Skip to content

介绍

Vue 是一套用于构建用户界面的渐进式框架。与其它大型框架不同的是,Vue 被设计为可以自底向上逐层应用。Vue 的核心库只关注视图层,不仅易于上手,还便于与第三方库或既有项目整合。

核心思想:

  • 组件化:增加代码的复用性,可维护性,可测试性,提高开发效率,方便重复使用,体现了高内聚低耦合
  • 数据驱动:视图的内容随着数据的改变而改变

工具

Vue CLI

Vue 提供了一个官方的 CLI,为单页面应用 (SPA) 快速搭建繁杂的脚手架。它为现代前端工作流提供了开箱即用的构建设置。只需要几分钟的时间就可以运行起来并带有热重载、保存时 lint 校验,以及生产环境可用的构建版本。更多详情可查阅 Vue CLI 的文档

Vue Devtools

在使用 Vue 时,可以在浏览器上安装 Vue Devtools。它允许你在一个更友好的界面中审查和调试 Vue 应用。

版本

很多人使用 Vue 的时候,都是直接通过 vue-cli 生成的模板代码,并不知道 Vue 其实提供了两个构建版本:

  • vue.js: 完整版本,包含了模板编译的能力;
  • vue.runtime.js: 运行时版本,不提供模板编译能力,需要通过 vue-loader 进行提前编译。

在 NPM 包的 dist/ 目录你将会找到很多不同的 Vue.js 构建版本。这里列出了它们之间的差别:

UMDCommonJSES Module (基于构建工具使用)ES Module (直接用于浏览器)
完整版vue.jsvue.common.jsvue.esm.jsvue.esm.browser.js
只包含运行时版vue.runtime.jsvue.runtime.common.jsvue.runtime.esm.js-
完整版 (生产环境)vue.min.js--vue.esm.browser.min.js
只包含运行时版 (生产环境)vue.runtime.min.js---

运行时 + 编译器 vs. 只包含运行时

如果你需要在客户端编译模板 (比如传入一个字符串给 template 选项,或挂载到一个元素上并以其 DOM 内部的 HTML 作为模板),就将需要加上编译器,即完整版:

js
// 需要编译器
new Vue({
    template: '<div>{{ hi }}</div>'
});

// 不需要编译器
new Vue({
    render (h) {
        return h('div', this.hi)
    }
});

简单来说,就是如果用了 vue-loader,就可以使用 vue.runtime.min.js,将模板编译的过程交过 vue-loader,如果你是在浏览器中直接通过 script 标签引入 Vue,需要使用 vue.min.js,运行的时候编译模板。运行时版本相比完整版体积要小大约 30%,所以应该尽可能使用这个版本。

基本原理

当一个 Vue 实例创建时,Vue 会遍历 data 中的属性,用 Object.defineProperty (Vue3.0 使用 Proxy) 把它们转为 getter/setter,并且在内部追踪相关依赖,在属性被访问和修改时通知变化。每个组件实例都有相应的 watcher 实例,它会在组件渲染的过程中把相关属性记录为依赖,之后当依赖项的 setter 触发时,会通知 watcher 重新计算,从而使它关联的组件重新渲染。

Vue 深入响应式原理

nextTick

nextTick 作用是在下次 DOM 更新循环结束之后执行延迟回调:

js
// 修改数据
vm.msg = 'Hello';
// DOM 还没有更新
Vue.nextTick(function () {
    // DOM 更新了
});

// 作为一个 Promise 使用 (2.1.0 起新增,详见接下来的提示)
Vue.nextTick()
    .then(function () {
        // DOM 更新了
    });

// or
new Vue({
    // ...
    methods: {
        // ...
        example: function () {
            // 修改数据
            this.message = 'changed';
            // DOM 还没有更新
            this.$nextTick(function () {
                // DOM 现在更新了
                // `this` 绑定到当前实例
                this.doSomethingElse();
            });
        }
    }
});

Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环「tick」中,Vue 刷新队列并执行实际 (已去重的) 工作。

Vue 在内部对异步队列尝试使用原生的 Promise.thenMutationObserversetImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。

多数情况我们不需要关心这个过程,但是如果你想基于更新后的 DOM 状态来做点什么,那么就可以在数据变化之后立即使用 Vue.nextTick(callback),这样 callback 就会在在 DOM 更新完成后被调用。

模板编译原理

Vue 中的模板 template 无法被浏览器解析并渲染,因为这不属于浏览器的标准,不是正确的 HTML 语法,所以需要将 template 转化成一 个 JavaScript 函数,这样浏览器就可以执行这一函数并渲染出对应的 HTML 元素,让视图跑起来。这一转化的过程,就称为模板编译。模板编译又分三个阶段:解析 parse优化 optimize生成 generate,最终生成可执行函数 render

  1. 解析阶段:使用大量的正则表达式对 template 字符串进行解析,将标签、指令、属性等转化为抽象语法树 AST
  2. 优化阶段:遍历 AST,找到其中的一些静态节点并进行标记,方便在页面重渲染进行 diff 比较时,直接跳过这一些静态节点,优化 runtime 的性能
  3. 生成阶段:将最终的 AST 转化为 render 函数字符串

Virtual DOM

Vue 通过建立一个虚拟 DOM 来追踪自己要如何改变真实 DOM。

Virtual DOM 其实就是一棵以 JS 对象(VNode 节点)作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。最终可以通过一系列操作使这棵树映射到真实环境上。

简单实现:

js
class VNode {
    constructor(tagName, props, children) {
        this.tagName = tagName;
        this.props = props;
        this.children = children;
    }

    render() {
        let el = document.createElement(this.tagName);
        let props = this.props;

        for (let propName in props) {
            el.setAttribute(propName, props[propName]);
        }

        this.children.forEach(child => {
            let childEle = child instanceof VNode
                ? child.render()
                : document.createTextNode(child);

            el.appendChild(childEle);
        });

        return el;
    }
}

// Example:
let ulVnode = new VNode('ul', { class: 'list' }, [
    new VNode('li', { class: 'item' }, ['item-1']),
    new VNode('li', { class: 'item' }, ['item-2']),
    new VNode('li', { class: 'item' }, ['item-3'])
]);

let ulEle = ulVnode.render();

document.appendChild(ulEle);

Diff 算法的原理

所谓 Virtual DOM Diff 算法,就是比较两个对象树的差异,也正是 Virtual DOM 的核心。传统的比较两棵树差异的算法,时间复杂度是 O(n³),大量操作 DOM 的时候肯定是接受不了的。

Vue 中 Virtual Dom Diff 算法,仅在同级的 vnode 间做 diff,递归地进行同级 vnode 的 diff,最终实现整个 DOM 树的更新。因为跨层级操作是非常少的,忽略不计,这样时间复杂度就从 O(n³) 变成 O(n)。Diff 过程如下:

  1. 首先,对比节点本身,判断是否为同一节点,如果不为相同节点,则删除该节点,重新创建节点进行替换
  2. 如果为相同节点,进行 patchVnode,判断如何对该节点的子节点进行处理,先判断一方有子节点一方没有子节点的情况(如果新的 children 没有子节点,将旧的子节点移除)
  3. 比较如果都有子节点,则进行 updateChildren,判断如何对这些新老节点的子节点进行操作(diff 核心)
  4. 匹配时,找到相同的子节点,递归比较子节点

key 的作用

key 是 Vue 中 vnode 的唯一标记,通过 key,diff 操作可以更准确、更快速

  • 更准确:因为带 key 就不是就地复用了,在 sameNode 函数 a.key === b.key 对比中可以避免就地复用的情况。所以会更加准确
  • 更快速:利用 key 的唯一性生成 map 对象来获取对应节点,比遍历方式更快

key 的使用可以区分为两种场景:

  1. v-if 中使用 key:由于 Vue 会尽可能高效地渲染元素,通常会复用已有元素而不是从头开始渲染。因此当使用 v-if 来 实现元素切换的时候,如果切换前后含有相同类型的元素,那么这个元素就会被复用。如果是相同的 input 元素,那么切换前后用户的输入不会被清除掉,这样是不符合需求的。因此可以通过使用 key 来唯一的标识一个元素,这个情况下,使用 key 的元素不会被复用。这个时候 key 的作用是用来标识一个独立的元素
  2. v-for 中使用 key:用 v-for 更新已渲染过的元素列表时,它默认使用「就地复用」的策略。如果数据项的顺序发生了改变,Vue 不会移动 DOM 元素来匹配数据项的顺序,而是简单复用此处的每个元素。因此通过为每个列表项提供一个 key 值,以便 Vue 跟踪元素的身份,从而高效的实现复用。这个时候 key 的作用是为了高效的更新渲染虚拟 DOM

模拟实现

以下,实现一个简单的 MVVM 框架,模拟 Vue 的响应式原理。主要包括以下功能:

  • 接收初始化的参数,这里只举几个简单的例子 eldataoptions
  • 通过私有方法 _proxyDatadata 注册到 Vue 中 转成 gettersetter
  • 使用 observerdata 中的属性转为「响应式」添加到「自身身上」
  • 使用 observer 方法监听 data 的所有属性变化来通过观察者模式更新视图
  • 使用 compiler 编译元素节点上面「指令」和文本节点「差值表达式」

Vue 响应式系统实现原理流程图

文件结构如下:

bash
.
├── compiler.js     # Compiler 类,模板编译
├── dep.js          # Dep 类,收集依赖、添加观察者、通知观察者
├── observer.js     # Observer 类,响应式数据转换
├── vue.js          # Vue 类
└── watcher.js      # Watcher 类
js
/**
 * vue.js
 * 1. 接收初始化参数
 * 2. 利用 Object.defineProperty 把 data 中的属性添加到 vue 实例
 * 3. Observer, Compiler 类实例化
 */
class Vue {
    constructor(options) {
        // 获取到传入的对象 没有默认为空对象
        this.$options = options || {};
        // 获取 el
        this.$el = typeof options.el === 'string'
            ? document.querySelector(options.el)
            : options.el;
        // 获取 data
        this.$data = options.data || {};
        // 调用 _proxyData 处理 data 中的属性
        this._proxyData(this.$data);
        // 使用 Obsever 把 data 中的数据转为响应式
        new Observer(this.$data);
        // 编译模板
        new Compiler(this);
    }

    // 把 data 中的属性注册到 Vue
    _proxyData(data) {
        Object.keys(data).forEach((key) => {
            // 进行数据劫持
            // 把每个 data 的属性添加到 Vue 转化为 getter, setter 方法
            Object.defineProperty(this, key, {
                // 设置可以枚举
                enumerable: true,
                // 设置可以配置
                configurable: true,
                // 获取数据
                get() {
                    return data[key];
                },
                // 设置数据
                set(newValue) {
                    // 判断新值和旧值是否相等
                    if (newValue === data[key]) return;
                    // 设置新值
                    data[key] = newValue;
                },
            });
        });
    }
}
js
/**
 * observer.js
 * 1. 深度遍历 data,转换为 getter, setter
 * 2. 在 getter 中收集依赖,在 setter 中通知更新
 */
class Observer {
    constructor(data) {
        this.walk(data);
    }

    // 遍历 data,转为响应式
    walk(data) {
        // 判断 data 是否为「空」和「对象」
        if (!data || typeof data !== 'object') return;
        // 遍历 data
        Object.keys(data).forEach((key) => {
            // 转为响应式
            this.defineReactive(data, key, data[key]);
        });
    }

    // 转为响应式
    defineReactive(obj, key, value) {
        // 如果是对象类型,调用 walk 变成响应式,非对象类型会在 walk 中 return
        this.walk(value);
        // 保存一下 this
        const self = this;
        // 创建 Dep 对象
        let dep = new Dep();
        Object.defineProperty(obj, key, {
            // 设置可枚举
            enumerable: true,
            // 设置可配置
            configurable: true,
            // 获取值
            get() {
                // 添加观察者对象 Dep.target 表示观察者
                Dep.target && dep.addSub(Dep.target);
                return value;
            },
            // 设置值
            set(newValue) {
                // 判断旧值和新值是否相等
                if (newValue === value) return;
                // 设置新值
                value = newValue;
                // 赋值的话如果是 newValue 是对象,对象里面的属性也应该设置为响应式的
                self.walk(newValue);
                // 触发通知,更新视图
                dep.notify();
            },
        })

    }
}
js
/**
 * dep.js
 * 1. 观察者收集
 * 2. 通知更新
 */
class Dep {
    constructor() {
        // 存储观察者
        this.subs = [];
    }

    // 添加观察者
    addSub(sub) {
        // 判断观察者是否存在 和 是否拥有update方法
        if (sub && sub.update) {
            this.subs.push(sub);
        }
    }

    // 通知方法
    notify() {
        // 触发每个观察者的更新方法
        this.subs.forEach((sub) => {
            sub.update();
        });
    }
}
js
/**
 * compiler.js
 * 1. 模板编译
 * 2. 创建观察者
 */
class Compiler {
    constructor(vm) {
        // 拿到 vm (vm 为 Vue 实例)
        this.vm = vm;
        // 拿到 el
        this.el = vm.$el;
        // 编译模板
        this.compile(this.el);
    }

    // 编译模板
    compile(el) {
        // 获取子节点,如果使用 forEach 遍历就把伪数组转为真的数组
        let childNodes = [...el.childNodes];
        childNodes.forEach((node) => {
            // 根据不同的节点类型进行编译
            // 文本类型的节点
            if (this.isTextNode(node)) {
                // 编译文本节点
                this.compileText(node);
            } else if (this.isElementNode(node)) {
                // 元素节点
                this.compileElement(node);
            }
            // 判断是否还存在子节点,递归处理
            if (node.childNodes && node.childNodes.length) {
                // 继续递归编译模板
                this.compile(node);
            }
        });
    }

    // 编译文本节点(简单的实现)
    compileText(node) {
        // 核心思想是利用把正则表达式把 {{}} 去掉找到里面的变量
        // 再去 Vue 实力上找这个变量赋值给 node.textContent
        let reg = /\{\{(.+?)\}\}/;
        // 获取节点的文本内容
        let val = node.textContent;
        // 判断是否有 {{}}
        if (reg.test(val)) {
            // 获取分组一(也就是 {{}} 里面的内容),去除前后空格
            let key = RegExp.$1.trim();
            // 进行替换再赋值给 node
            node.textContent = val.replace(reg, this.vm[key]);
            // 创建观察者
            new Watcher(this.vm, key, (newValue) => {
                node.textContent = newValue
            });
        }
    }

    // 编译元素节点(这里只处理指令)
    compileElement(node) {
        // 获取到元素节点上面的所有属性进行遍历
        ![...node.attributes].forEach((attr) => {
            // 获取属性名
            let attrName = attr.name;
            // 判断是否是 v- 开头的指令
            if (this.isDirective(attrName)) {
                // 除去 v- 方便操作
                attrName = attrName.substr(2);
                // 获取 指令的值就是 v-text = "msg"  中 msg
                // msg 作为 key 去 Vue 实例上找这个变量
                let key = attr.value;
                // 指令操作,执行指令方法
                // vue 指令很多,为了避免大量 if 判断这里就写个 update 方法
                this.update(node, key, attrName);
            }
        });
    }

    // 添加指令方法 并且执行
    update(node, key, attrName) {
        // 比如添加 textUpdater 就是用来处理 v-text 方法
        // 我们应该就内置一个 textUpdater 方法进行调用
        // 加个后缀加什么无所谓但是要定义相应的方法
        let updateFn = this[attrName + 'Updater'];
        // 如果存在这个内置方法 就可以调用了
        updateFn && updateFn.call(this, node, key, this.vm[key]);
    }

    // 提前写好相应的指定方法比如 v-text
    // 使用的时候和 Vue 的一样
    textUpdater(node, key, value) {
        node.textContent = value;
        // 创建观察者
        new Watcher(this.vm, key, (newValue) => {
            node.textContent = newValue;
        });
    }

    // v-model
    modelUpdater(node, key, value) {
        node.value = value;
        // 创建观察者
        new Watcher(this.vm, key, (newValue) => {
            node.value = newValue;
        })
        // 这里实现双向绑定
        node.addEventListener('input', () => {
            this.vm[key] = node.value;
        });
    }

    // 判断元素的属性是否是 vue 指令
    isDirective(attr) {
        return attr.startsWith('v-');
    }
    // 判断是否是「元素节点」
    isElementNode(node) {
        return node.nodeType === 1;
    }
    // 判断是否是「文本节点」
    isTextNode(node) {
        return node.nodeType === 3;
    }
}
js
/**
 * watcher.js
 */
class Watcher {
    constructor(vm, key, cb) {
        // vm 是 Vue 实例
        this.vm = vm;
        // key 是 data 中的属性
        this.key = key;
        // cb 回调函数 更新视图的具体方法
        this.cb = cb;
        // 把观察者暂存在 Dep.target
        Dep.target = this;
        // 旧数据 更新视图的时候要进行比较
        // 还有一点就是 vm[key] 这个时候就触发了 get 方法
        // 之前在 get 把 观察者 通过dep.addSub(Dep.target) 添加到了 dep.subs中
        this.oldValue = vm[key];
        // Dep.target 就不用存在了 因为上面的操作已经存好了
        Dep.target = null;
    }
    // 观察者中的必备方法 用来更新视图
    update() {
        // 获取新值
        let newValue = this.vm[this.key];
        // 比较旧值和新值
        if (newValue === this.oldValue) return;
        // 调用具体的更新方法
        this.cb(newValue);
    }
}

测试:

html
<div id="app">
    {{msg}} <br />
    {{age}} <br />
    <div v-text="msg"></div>
    <input v-model="msg" type="text" />
</div>
<script src="./js/dep.js"></script>
<script src="./js/watcher.js"></script>
<script src="./js/compiler.js"></script>
<script src="./js/observer.js"></script>
<script src="./js/vue.js"></script>
<script>
    let vm = new Vue({
        el: '#app',
        data: {
            msg: '123',
            age: 21,
        },
    });
</script>

参考资料