专注、坚持

深入 Vue(一)—— 从学习 miniVue 2.x 实现开始

2021.02.21 by kingcos

前言

Vue 是我们常用的 JavaScript 框架,然而仅仅作为使用者总归是有些肤浅。于是,从今天开始,我要开始深入研究 Vue 原理,通过源码窥探其内部机制。

第一篇文章我们将从一个小型的 Vue 实现着手。注意,本文的主要实现及原理均学习自《2021 年,让我们手写一个 mini 版本的 vue2.x 和 vue3.x 框架》一文,本文目前以 Vue 2.x 为基准。

现在,我们将定义一个 miniVue 对象,并模拟如 Vue 一样的使用:

<div id="app">
    <h3>{{ count }}</h3>
    <button type="button" v-on:click="increase">+</button>
</div>

<script>
const app = new miniVue({
    el: "#app",
    data: {
        count: 666
    },
    methods: {
        increase() {
            this.count += 1;
        }
    }
});
</script>

我们将期望通过实现一个 miniVue 达成以下内容:

  1. 页面的 count 绑定于 miniVuedata 中,即页面默认显示 666
  2. 点击 + 号按钮时,将触发 miniVue 中的 increase 方法;
  3. increase 方法调用后,页面的 count 将同步更新显示为 667

源码实现

miniVue 类

首先定义 miniVue 类,声明其构造方法,并在其中保存构造时传递的内容:

class miniVue {
    constructor(options = {}) {
        console.log('--- constructor ---');

        // 属性:
        // - 保存根元素,不考虑数组情况
        this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el;
        // - 保存方法、数据、配置
        this.$methods = options.methods;
        this.$data = options.data;
        this.$options = options;
    }
}

代理数据

miniVue 内部,我们只能通过 this.$data.count 的形式来访问 data 的数据。如何可以通过 this.count 直接访问呢?答案是代理。我们在 miniVue 类中定义以下方法:

proxy(data) {
    // 遍历 data 中的所有 key
    Object.keys(data).forEach(key => {
        // Object.defineProperty(obj, prop, desc)
        // 作用:在一个对象(obj)上定义一个新属性(prop),或者修改一个已存在属性,desc 进行更精准的属性控制
        Object.defineProperty(
            this,                // this 对象,绑定 key 的属性
            key, {
            enumerable: true,    // 可枚举(默认 false)
            configurable: true,  // 可删除(默认 false)
            get: () => {         // 存取器 - get 函数
                console.log('proxy - get invoked');
                return data[key];
            },
            set: (newValue) => { // 存取器 - set 函数
                console.log('proxy - set invoked');
                // 值未发生改变或新旧值为非 NaN 时,跳过
                if (newValue === data[key] || __isNaN(newValue, data[key])) {
                    return;
                }

                data[key] = newValue;
            }
        })
    })
}

并在 miniVue 的构造方法中添加代理:

constructor(options = {}) {
    console.log('--- constructor ---');
    // ...

    // 代理
    this.proxy(this.$data);
}

此时我们即可尝试下效果:

constructor(options = {}) {
    // ...

    // ===> undefined
    console.log(this.count);
    
    // 代理
    this.proxy(this.$data);

    // ===> proxy - get invoked
    // ===> 666
    console.log(this.count);

    // ===> proxy - set invoked
    this.count = 233;
    // ===> proxy - get invoked
    // ===> 233
    console.log(this.count);
}

响应式观察者

首先,我们再定义一个 Observer 观察者类:

class Observer {
    constructor(data) {
        this.walk(data);
    }
}

具体逻辑在 walk 中实现对数据的响应式定义:

walk(data) {
    // 仅处理对象类型,不考虑数组
    if (!data || typeof data != 'object') {
        return
    }
    
    // 1. 遍历 data 所有 key
    // 2. 定义响应式(所有数据,当前数据,当前数据对应的值)
    Object.keys(data).forEach(key => this.defineReactive(data, key, data[key]))
}

walk 中仅跳过边界情况,并遍历 data 中的 key,实现继续进入 defineReactive 方法:

defineReactive(data, key, value) {
    // eg. data: { "count": 666 }, key: "count", value: 666
    const vm = this;  // 保存 this 指向
    this.walk(value); // 递归调用,因为 value 可能为对象

    let dep = new Dependency();
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: () => {
            console.log('obs - get invoked');

            // 收集依赖
            // 注:目前无 target
            if (Dependency.target) {
                dep.add(Dependency.target);
            }

            // 返回 value
            return value;
        },
        set: (newValue) => {
            console.log('obs - set invoked');

            if (newValue === value || __isNaN(value, newValue)) {
                // 未更新或 NaN,返回
                return;
            }

            value = newValue;  // 设置 value
            vm.walk(newValue); // 递归处理
            dep.notify();      // 通知 Dependency 更新
        }
    })
}

以上代码中出现了新的 Dependency 类,负责存储依赖关系:

class Dependency {
    constructor() {
        // 这里使用 ES6 的集合数据结构(Vue 源码是队列,先进先出)
        this.deps = new Set();
    }

    add(dep) {
        // 添加依赖
        // 注:目前无 update 方法
        if (dep && dep.update) {
            // 存储依赖
            this.deps.add(dep);
        }
    }

    notify() {
        // 通知,更新每一个依赖
        // 注:目前无 update 方法
        this.deps.forEach(dep => dep.update());
    }
}

miniVue 的构造方法中也需要初始化观察者:

class miniVue {
    constructor(options = {}) {
        // ...

        // 实例化观察者
        new Observer(this.$data);

        // ===> proxy - get invoked
        // ===> obs - get invoked
        // ===> 666
        console.log(this.count);
        // ===> proxy - set invoked
        // ===> obs - get invoked (proxy setter 中的判断通过 data[key] 触发了 2 次 obs get)
        // ===> obs - get invoked
        // ===> obs - set invoked
        this.count = 233;
        // ===> proxy - get invoked
        // ===> obs - get invoked
        // ===> 233
        console.log(this.count);
    }
}

最终,经过两次 Object.defineProperty,当我们在 miniVue 中使用 data 中的变量,将通过以下链路:

this.count -> this.data.count -> dep.add
this.count += 1 -> this.data.count += 1 -> dep.notify

但以上的实现目前还不完整,更新和通知还不能正常使用,因此我们需要继续添加代码。

Watcher

由于此时还没有使用到 Watcher 类,我们先将实现放在这儿,其功能我们在后面再详细说明:

class Watcher {
    constructor(vm, key, callback) {
        this.vm = vm;
        this.key = key;
        this.callback = callback;

        Dependency.target = this;
        this.__old = vm[key];
        Dependency.target = null;
    }

    update() {
        // 获取新值
        let newValue = this.vm[this.key];
        // 值未发生改变或新旧值为非 NaN 时,跳过
        if (newValue === this.__old || __isNaN(newValue, this.__old)) {
            return;
        }
        // 回调新值
        this.callback(newValue);
        // 保存旧值
        this.__old = newValue;
    }
}

编译器

我们在 *.vue 中写的代码其实并不是真正的 HTML,因此 Vue 需要将其编译为浏览器可以识别的 HTML。

注意:此处实现的内容将与 Vue 源码无关,仅简化实现部分能力。

class Compiler {
    constructor (vm) {
        // - 保存根元素、方法,组件本身
        this.el = vm.$el;
        this.methods = vm.$methods;
        this.vm = vm;

        // 开始从根元素开始编译
        this.compile(vm.$el);
    }
}

class miniVue {
    constructor(options = {}) {
        // ...

        // 编译器
        new Compiler(this);
    }
}

HTML 的组成元素是一个个节点(node),主要分为文本节点(nodeType === 3)和元素节点(nodeType === 1):

Q: HTML 中节点都有哪些类型?nodeType 分别是多少? A: HTML 中节点的类型包括元素节点、属性节点、文本节点、注释节点、文档节点以及文档片段节点。

它们的 nodeType 值分别为:

元素节点:1 属性节点:2 文本节点:3 注释节点:8 文档节点:9 文档片段节点:11

其中,元素节点和文本节点是最常用的两种节点类型,分别代表 HTML 中的标签和文本内容。

From ChatGPT

例如:<p>Hello World</p> 中的 Hello World 是文本节点,<p></p> 是元素节点。

compile(el) {
    // 获取子节点
    let childNodes = el.childNodes;
    // 遍历
    Array.from(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);
        }
    })
}

isTextNode(node) {
    // 文本节点
    return node.nodeType === 3;
}

isElementNode(node) {
    // 元素节点
    return node.nodeType === 1;
}

编译元素节点

元素节点中如果使用了 Vue 指令,则也需要特殊处理,当然这里仅处理 v-text & v-model & v-on:click

compileElement(node) {
    // 节点所有属性
    const attrs = node.attributes;
    if (attrs.length) {
        Array.from(attrs).forEach(attr => {
            // FIX: 修复后续的判断问题
            let key = attr.value;
            attr = attr.name;

            // 判断是否为 Vue 指令
            if (this.isDirective(attr)) {
                // 截取处理:
                // v-on:click => click
                // v-text / v-model => text / model
                let attrName = attr.indexOf(':') > -1 ? attr.substr(5) : attr.substr(2);
                
                this.update(node, attrName, key, this.vm[key]);
            }
        })
    }
}

isDirective(dir) {
    return dir.startsWith('v-');
}

update(node, attrName, key, value) {
    if (attrName === 'text') {
        // 类似文本节点
        node.textContent = value;
        new Watcher(this.vm, key, newValue => {
            node.textContent = newValue;
        });
    } else if (attrName === 'model') {
        // model 双向绑定
        node.value = value;

        // 代码 -> 页面
        new Watcher(this.vm, key, newValue => {
            node.value = newValue;
        });
        // 页面 -> 代码
        node.addEventListener('input', (e) => {
            this.vm[key] = node.value;
        });
    } else if (attrName === 'click') {
        // 事件绑定
        node.addEventListener(attrName, this.methods[key].bind(this.vm));
    }
}

编译文本节点

在 demo 中,我们的第一个文本节点即 {{ count }}

compileText(node) {
    // eg. 准备匹配 `{{ count }}`;非 {{ }} 格式的文本将略过,仍展示原内容
    let reg = /\{\{(.+?)\}\}/g;
    let value = node.textContent; // {{ count }}

    if (reg.test(value)) {
        let key = RegExp.$1.trim(); // count
        // 更新节点的文本内容(页面将显示 666)
        node.textContent = value.replace(reg, this.vm[key]); // "666"

        // 绑定更新(newValue 和 textContent 之间)
        new Watcher(this.vm, key, newValue => {
            node.textContent = newValue;
        });
    }
}

编译时将把 HTML 中的 {{ count }} 文本通过提取的 count 作为 keydata 中取值,并绑定更新关系。这里我们再把 Watcher 类的源码拿出来:

class Watcher {
    constructor(vm, key, callback) {
        this.vm = vm;
        this.key = key;
        this.callback = callback;

        // 设置依赖目标
        Dependency.target = this;
        // 存储旧值,触发 proxy get,再触发 obs get
        this.__old = vm[key];
        // 清空依赖目标
        Dependency.target = null;
    }

    update() {
        // 获取新值
        let newValue = this.vm[this.key];
        // 值未发生改变或新旧值为非 NaN 时,跳过
        if (newValue === this.__old || __isNaN(newValue, this.__old)) {
            return;
        }
        // 回调新值
        this.callback(newValue);
        // 保存旧值
        this.__old = newValue;
    }
}

页面渲染时,将进行以下步骤:

  1. 通过 Object.defineProperty 将数据内容分别直接绑定在 this 上,省去通过 this.$data 访问;
  2. 通过 Object.defineProperty 将数据内容分别添加依赖更新机制,使得数据更新时可以通知所有依赖方回调;
  3. 编译文本节点,普通文本直接展示,{{ xxx }} 文本替换为数据再展示,并绑定更新;
  4. 编译元素节点,无属性跳过,有属性则针对 Vue 指令进行特殊处理,比如 v-on:click 将该元素的点击事件绑定在 click 方法中。

当用户点击 + 按钮时,将按照以下链路更新数据并最终显示在页面上:

  1. 根据编译时绑定好的 click 方法调用到 increase 方法;
  2. increase 方法中通过事先绑定好的代理获取并更新 miniVue 数据中的 count
  3. 更新过程会先调用代理的 setter,再更新观察着的 setter;
  4. 观察者的 setter 会通知依赖,依赖会调用所有保存的依赖的 update 方法,也就是 Watcherupdate
  5. Watcherupdate 会对比新旧值,并在发生改变时调用 callback 且更新旧值;
  6. callback 即更新新值到节点的 textContent 属性,至此页面更新。
-> 点击此处即可查看完整源码 <-
<div id="app">
    <h3>{{ count }}</h3>
    <button type="button" v-on:click="increase">+</button>
</div>

<script>
class miniVue {
    constructor(options = {}) {
        console.log('--- constructor ---');

        // 属性:
        // - 保存根元素,不考虑数组情况
        this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el;
        // - 保存方法、数据、配置
        this.$methods = options.methods;
        this.$data = options.data;
        this.$options = options;
            
        // 代理
        this.proxy(this.$data);
        // 实例化观察者
        new Observer(this.$data);
        // 编译器
        new Compiler(this);
    }

    proxy(data) {
        // 遍历 data 中的所有 key
        Object.keys(data).forEach(key => {
            // Object.defineProperty(obj, prop, desc)
            // 作用:在一个对象(obj)上定义一个新属性(prop),或者修改一个已存在属性,desc 进行更精准的属性控制
            Object.defineProperty(
                this,                // this 对象,绑定 key 的属性
                key, {
                enumerable: true,    // 可枚举(默认 false)
                configurable: true,  // 可删除(默认 false)
                get: () => {         // 存取器 - get 函数
                    console.log('proxy - get invoked');
                    return data[key];
                },
                set: (newValue) => { // 存取器 - set 函数
                    console.log('proxy - set invoked');
                    // 值未发生改变或新旧值为非 NaN 时,跳过
                    if (newValue === data[key] || __isNaN(newValue, data[key])) {
                        return;
                    }

                    data[key] = newValue;
                }
            })
        })
    }
}

class Observer {
    constructor(data) {
        this.walk(data);
    }

    walk(data) {
        // 仅处理对象类型,不考虑数组
        if (!data || typeof data != 'object') {
            return
        }
        
        // 1. 遍历 data 所有 key
        // 2. 定义响应式(所有数据,当前数据,当前数据对应的值)
        Object.keys(data).forEach(key => this.defineReactive(data, key, data[key]))
    }

    defineReactive(data, key, value) {
        // eg. data: { "count": 666 }, key: "count", value: 666
        const vm = this;  // 保存 this 指向
        this.walk(value); // 递归调用,因为 value 可能为对象

        let dep = new Dependency();
        Object.defineProperty(data, key, {
            enumerable: true,
            configurable: true,
            get: () => {
                console.log('obs - get invoked');

                // 收集依赖
                // 注:目前无 target
                if (Dependency.target) {
                    dep.add(Dependency.target);
                }

                // 返回 value
                return value;
            },
            set: (newValue) => {
                console.log('obs - set invoked');

                if (newValue === value || __isNaN(value, newValue)) {
                    // 未更新或 NaN,返回
                    return;
                }

                value = newValue;  // 设置 value
                vm.walk(newValue); // 递归处理
                dep.notify();      // 通知 Dependency 更新
            }
        })
    }
}

class Dependency {
    constructor() {
        // 这里使用 ES6 的集合数据结构(Vue 源码是队列,先进先出)
        this.deps = new Set();
    }

    add(dep) {
        // 添加依赖
        // 注:目前无 update 方法
        if (dep && dep.update) {
            // 存储依赖
            this.deps.add(dep);
        }
    }

    notify() {
        // 通知,更新每一个依赖
        // 注:目前无 update 方法
        this.deps.forEach(dep => dep.update());
    }
}

class Watcher {
    constructor(vm, key, callback) {
        this.vm = vm;
        this.key = key;
        this.callback = callback;

        // 设置依赖目标
        Dependency.target = this;
        // 存储旧值,触发 proxy get,再触发 obs get
        this.__old = vm[key];
        // 清空依赖目标
        Dependency.target = null;
    }

    update() {
        // 获取新值
        let newValue = this.vm[this.key];
        // 值未发生改变或新旧值为非 NaN 时,跳过
        if (newValue === this.__old || __isNaN(newValue, this.__old)) {
            return;
        }
        // 回调新值
        this.callback(newValue);
        // 保存旧值
        this.__old = newValue;
    }
}

class Compiler {
    constructor (vm) {
        // - 保存根元素、方法,组件本身
        this.el = vm.$el;
        this.methods = vm.$methods;
        this.vm = vm;

        // 开始从根元素开始编译
        this.compile(vm.$el);
    }

    compile(el) {
        // 获取子节点
        let childNodes = el.childNodes;
        // 遍历
        Array.from(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);
            }
        })
    }

    isTextNode(node) {
        // 文本节点
        return node.nodeType === 3;
    }

    isElementNode(node) {
        // 元素节点
        return node.nodeType === 1;
    }

    compileElement(node) {
        // 节点所有属性
        const attrs = node.attributes;
        if (attrs.length) {
            Array.from(attrs).forEach(attr => {
                // FIX: 修复后续的判断问题
                let key = attr.value;
                attr = attr.name;

                // 判断是否为 Vue 指令
                if (this.isDirective(attr)) {
                    // 截取处理:
                    // v-on:click => click
                    // v-text / v-model => text / model
                    let attrName = attr.indexOf(':') > -1 ? attr.substr(5) : attr.substr(2);
                    
                    this.update(node, attrName, key, this.vm[key]);
                }
            })
        }
    }

    isDirective(dir) {
        return dir.startsWith('v-');
    }

    update(node, attrName, key, value) {
        if (attrName === 'text') {
            // 类似文本节点
            node.textContent = value;
            new Watcher(this.vm, key, newValue => {
                node.textContent = newValue;
            });
        } else if (attrName === 'model') {
            // model 双向绑定
            node.value = value;

            // 代码 -> 页面
            new Watcher(this.vm, key, newValue => {
                node.value = newValue;
            });
            // 页面 -> 代码
            node.addEventListener('input', (e) => {
                this.vm[key] = node.value;
            });
        } else if (attrName === 'click') {
            // 事件绑定
            node.addEventListener(attrName, this.methods[key].bind(this.vm));
        }
    }

    compileText(node) {
        // eg. 准备匹配 `{{ count }}`;非 {{ }} 格式的文本将略过,仍展示原内容
        let reg = /\{\{(.+?)\}\}/g;
        let value = node.textContent; // {{ count }}

        if (reg.test(value)) {
            let key = RegExp.$1.trim(); // count
            // 更新节点的文本内容(页面将显示 666)
            node.textContent = value.replace(reg, this.vm[key]); // "666"

            // 绑定更新(newValue 和 textContent 之间)
            new Watcher(this.vm, key, newValue => {
                node.textContent = newValue;
            });
        }
    }
}

function __isNaN(a, b) {
    return Number.isNaN(a) && Number.isNaN(b);
}

const app = new miniVue({
    el: "#app",
    data: {
        count: 666
    },
    methods: {
        increase() {
            this.count += 1;
        }
    }
});
</script>

Tips

$ 符号

一些 JavaScript 框架(如 Vue.js)也使用 $ 符号来表示框架的内部属性或方法,以与用户定义的属性和方法进行区分。例如,在 Vue.js 中,可以使用 $data 来引用组件的数据对象。

需要注意的是,使用 $ 开头的变量名只是一种约定,而不是 JavaScript 语言的特定语法。

__isNaN

__isNaN 是一个判断参数是否为数字类型的工具方法,我们将其定义在在外层,和 miniVue 同级:

function __isNaN(a, b) {
    return Number.isNaN(a) && Number.isNaN(b);
}