12

Vue2响应式原理与实现
Vue2组件挂载与对象数组依赖收集

一、实现Vue2生命周期

Vue2中生命周期可以在创建Vue实例传入的配置对象中进行配置,也可以通过全局的Vue.mixin()方法来混入生命周期钩子,如:

Vue.mixin({
    a: {
        b: 1
    },
    c: 3,
    beforeCreate () { // 混入beforeCreate钩子
        console.log("beforeCreate1");
    },
    created () { // 混入created钩子
        console.log("created1");
    }
});

Vue.mixin({
    a: {
        b: 2
    },
    d: 4,
    beforeCreate () { // 混入beforeCreate钩子
        console.log("beforeCreate2");
    },
    created () { // 混入created钩子
        console.log("created2");
    }
});

所以在实现生命周期前,我们需要实现Vue.mixin()这个全局的方法,将混入的所有生命周期钩子进行合并之后再到合适的时机去执行生命周期的各个钩子。我们可以将全局的api放到一个单独的模块中,如:

// src/index.js
import {initGlobalApi} from "./globalApi/index";
function Vue(options) {
    this._init(options);
}
initGlobalApi(Vue); // 混入全局的API
// src/globalApi/index.js
import {mergeOptions} from "../utils/index"; // mergeOptions可能会被多次使用,单独放到工具类中
export function initGlobalApi(Vue) {
    Vue.options = {}; // 初始化一个options对象并挂载到Vue上
    Vue.mixin = function(options) {
        this.options = mergeOptions(this.options, options); // 将传入的options对象进行合并,这里的this就是指Vue
    }
}

接下来就开始实现mergeOptions这个工具方法,该方法可以合并生命周期的钩子也可以合并普通对象,合并的思路很简单,首先遍历父对象中的所有属性对父子对象中的各个属性合并一次,然后再遍历子对象找出父对象中不存在的属性再合并一次,经过两次合并即可完成父子对象中所有属性的合并。

export function mergeOptions(parent, child) {
    const options = {}; // 用于保存合并结果
    for (let key in parent) { // 遍历父对象上的所有属性合并一次
        mergeField(key);
    }
    
    for (let key in child) { // 遍历子对象上的所有属性
        if (!Object.hasOwnProperty(parent, key)) { // 找出父对象中不存在的属性,即未合并过的属性,合并一次
            mergeField(key);
        }
    }
    return options; // 经过两次合并即可完成父子对象各个属性的合并
}

接下来就是要实现mergeField()方法,对于普通对象的合并而言非常简单,为了方便,我们可以将mergeField()方法放到mergeOptions内部,如:

export function mergeOptions(parent, child) {
    function mergeField(key) {
        if (isObject(parent[key]) && isObject(child[key])) { // 如果父子对象中的同一个key对应的值都是对象,那么直接解构父子对象,如果属性相同,用子对象覆盖即可
            options[key] = {
                ...parent[key],
                ...child[key]
            }
            
        } else { // 对于不全是对象的情况,子有就用子的值,子没有就用父的值
            options[key] = child[key] || parent[key];
        }
    }
}

而对于生命周期的合并,我们需要将相同的生命周期放到一个数组中,等合适的时机依次执行,我们可以通过策略模式实现,如:

const stras = {};
const hooks = [
    "beforeCreate",
    "created",
    "beforeMount",
    "mounted"
];
function mergeHook(parentVal, childVal) {
    if (childVal) { // 子存在
        if(parentVal) { // 子存在,父也存在,直接合并即可
            return parentVal.concat(childVal);
        } else { // 子存在,父不存在,一开始父中肯定不存在
            return [childVal];
        }
    } else { // 子不存在,直接使用父的即可
        return parentVal;
    }
}
hooks.forEach((hook) => {
    stras[hook] = mergeHook; // 每一种钩子对应一种策略
});

合并生命周期的时候parent一开始是{},所以肯定是父中不存在子中存在,此时返回一个数组并将子对象中的生命周期放到数组中即可,之后的合并父子都有可能存在,父子都存在,那么直接将子对象中的生命周期钩子追加进去即可,如果父存在子不存在,直接使用父的即可。

// 往mergeField新增生命周期的策略合并
function mergeField(key) {
    if (stras[key]) { // 如果存在对应的策略,即生命周期钩子合并
        options[key] = stras[key](parent[key], child[key]); // 传入钩子进行合并即可
    } else if (isObject(parent[key]) && isObject(child[key])) {
    
    } else {
        
    }
}

完成Vue.mixin()全局api中的options合并之后,我们还需要与用户创建Vue实例时候传入的options再进行合并,生成最终的options并保存到vm.$options中,如:

// src/init.js
import {mountComponent, callHook} from "./lifecyle";
export function initMixin(Vue) {
    Vue.prototype._init = function(options) {
        const vm = this;
        // vm.$options = options;
        vm.$options = mergeOptions(vm.constructor.options, options); // vm.constructor就是指Vue,即将全局的Vue.options与用户传入的options进行合并
        callHook(vm, "beforeCreate"); // 数据初始化前执行beforeCreate
        initState(vm);
        callHook(vm, "created"); // 数据初始化后执行created
    }
}
// src/lifecyle.js
export function mountComponent(vm, el) {
    callHook(vm, "beforeMount"); // 渲染前执行beforeMount
    new Watcher(vm, updateComponent, () => {}, {}, true);
    callHook(vm, "mounted"); // 渲染后执行mounted
}

我们已经在合适时机调用了callHook()方法去执行生命周期钩子,接下来就是实现callHook()方法,即拿到对应钩子的数组遍历执行,如:

// src/lifecyle.js
export function callHook(vm, hook) {
    const handlers = vm.$options[hook]; // 取出对应的钩子数组
    handlers && handlers.forEach((handler) => { // 遍历钩子
        handler.call(vm); // 依次执行即可
    });
}

二、异步批量更新

目前我们是每次数据发生变化后,就会触发set()方法,进而触发对应的dep对象调用notify()给渲染watcher派发通知,从而让页面更新。如果我们执行vm.name = "react"; vm.name="node",那么可以看到页面会渲染两次,因为数据被修改了两次,所以每次都会通知渲染watcher进行页面更新操作,这样会影响性能,而对于上面的操作,我们可以将其合并成一次更新即可。
其实现方式为,将需要执行更新操作的watcher先缓存到队列中,然后开启一个定时器等同步修改数据的操作完成后,开始执行这个定时器,异步刷新watcher队列,执行更新操作。
新建一个scheduler.js用于完成异步更新操作,如:

// src/observer/scheduler.js
let queue = []; // 存放watcher
let has = {}; // 判断当前watcher是否在队列中
let pending = false; // 用于标识是否处于pending状态
export function queueWatcher(watcher) {
    const id = watcher.id; // 取出watcher的id
    if (!has[id]) { // 如果队列中还没有缓存该watcher
        has[id] = true; // 标记该watcher已经缓存过
        queue.push(watcher); // 将watcher放到队列中
        if (!pending) { // 如果当前队列没有处于pending状态
            setTimeout(flushSchedulerQueue, 0); // 开启一个定时器,异步刷新队列
            pending = true; // 进入pending状态,防止添加多个watcher的时候开启多个定时器
        }
    }    
}
// 刷新队列,遍历存储的watcher并调用其run()方法执行
function flushSchedulerQueue() {
    for (let i = 0; i < queue.length; i++) {
        const watcher = queue[i];
        watcher.run();
    }
    queue = []; // 清空队列
    has = {};
}

修改watcher.js,需要修改update()方法,update()将不再立即执行更新操作,而是将watcher放入队列中缓存起来,因为update()方法已经被另做他用,所以同时需要新增一个run()方法让wather可以执行更新操作

// src/observer/watcher.js
import {queueWatcher} from "./scheduler";
export default class Watcher {
    update() {
        // this.get(); // update方法不再立即执行更新操作
        queueWatcher(this); // 先将watcher放到队列中缓存起来
    }
    run() { // 代替原来的update方法执行更新操作
        this.get();
    }
}

三、实现nextTick

目前已经实现异步批量更新,但是如果我们执行vm.name = "react";console.log(document.getElementById("app").innerHTML),我们从输出结果可以看到,拿到innerHTML仍然是旧的,即模板中使用的name值仍然是更新前的。之所以这样是因为我们将渲染watcher放到了一个队列中,等数据修改完毕之后再去异步执行渲染wather去更新页面,而上面代码是在数据修改后同步去操作DOM此时渲染watcher还没有执行,所以拿到的是更新前的数据。
要想在数据修改之后立即拿到最新的数据,那么必须在等渲染Watcher执行完毕之后再去操作DOM,Vue提供了一个$nextTick(fn)方法可以实现在fn函数内操作DOM拿到最新的数据。
其实现思路就是,渲染watcher进入队列中后不立即开启一个定时器去清空watcher队列,而是将清空watcher队列的方法传递给nextTick函数nextTick也维护一个回调函数队列将清空watcher队列的方法添加到nextTick的回调函数队列中,然后在nextTick中开启定时器,去清空nextTick的回调函数队列。所以此时我们只需要再次调用nextTick()方法追加一个函数,就可以保证在该函数内操作DOM能拿到最新的数据,因为清空watcher的队列在nextTick的头部,最先执行

// src/observer/watcher.js 
export function queueWatcher(watcher) {
    const id = watcher.id; // 取出watcher的id
    if (!has[id]) { // 如果队列中还没有缓存该watcher
        has[id] = true; // 标记该watcher已经缓存过
        queue.push(watcher); // 将watcher放到队列中
        // if (!pending) { // 如果当前队列没有处于pending状态
            // setTimeout(flushSchedulerQueue, 0); // 开启一个定时器,异步刷新队列
            // pending = true; // 进入pending状态,防止添加多个watcher的时候开启多个定时器
        // }
        nextTick(flushSchedulerQueue); // 不是立即创建一个定时器,而是调用nextTick,将清空队列的函数放到nextTick的回调函数队列中,由nextTick去创建定时器    
    }    
}

let callbacks = []; // 存放nextTick回调函数队列
export function nextTick(fn) {
    callbacks.push(fn); // 将传入的回调函数fn放到队列中
    if (!pending) { // 如果处于非pending状态
        setTimeout(flushCallbacksQueue, 0);
        pending = true; // 进入pending状态,防止每次调用nextTick都创建定时器
    }
}
function flushCallbacksQueue() {
    callbacks.forEach((fn) => {
        fn();
    });
    callbacks = []; // 清空回调函数队列
    pending = false; // 进入非pending状态
}

四、实现计算属性watcher

计算属性本质也是创建了一个Watcher对象,只不过计算属性watcher有些特性,比如计算属性可以缓存只有依赖的数据发生变化才会重新计算。为了能够缓存,我们需要记录下watcher的值,需要给watcher添加一个value属性,当依赖的数据没有变化的时候,直接从计算watcher的value中取值即可。创建计算watcher的时候需要传递lazy: true,标识需要懒加载即计算属性的watcher。

// src/state.js
import Watcher from "./observer/watcher";
function initComputed(vm) {
    const computed = vm.$options.computed; // 取出用户配置的computed属性
    const watchers = vm._computedWatchers = Object.create(null); // 创建一个对象用于存储计算watcher
    for (let key in computed) { // 遍历计算属性的key
        const userDef = computed[key]; // 取出对应key的值,可能是一个函数也可能是一个对象
        // 如果是函数那么就使用该函数作为getter,如果是对象则使用对象的get属性对应的函数作为getter
        const getter = typeof userDef === "function" ? userDef : userDef.get;
        watchers[key] = new Watcher(vm, getter, () => {}, {lazy: true}); // 创建一个Watcher对象作为计算watcher,并传入lazy: true标识为计算watcher
        if (! (key in vm)) { // 如果这个key不在vm实例上
            defineComputed(vm, key, userDef); // 将当前计算属性代理到Vue实例对象上
        }
    }
}

计算属性的初始化很简单,就是取出用户配置的计算属性执行函数,然后创建计算watcher对象,并传入lazy为true标识为计算watcher。为了方便操作,还需要将计算属性代理到Vue实例上,如:

// src/state.js
function defineComputed(vm, key, userDef) {
    let getter = null;
    if (typeof userDef === "function") {
        getter = createComputedGetter(key); // 传入key创建一个计算属性的getter
    } else {
        getter = userDef.get;
    }

    Object.defineProperty(vm, key, { // 将当前计算属性代理到Vue实例对象上
        configurable: true,
        enumerable: true,
        get: getter,
        set: function() {} // 未实现setter
    });
}

计算属性最关键的就是计算属性的getter,由于计算属性存在缓存,当我们去取计算属性的值的时候,需要先看一下当前计算watcher是否处于dirty状态处于dirty状态才需要重新去计算求值

// src/state.js
function createComputedGetter(key) {
    return function computedGetter() {
        const watcher = this._computedWatchers[key]; // 根据key值取出对应的计算watcher
        if (watcher) {
            if (watcher.dirty) { // 如果计算属性当前是脏的,即数据有被修改,那么重新求值
                watcher.evaluate();
            }
            // watcher计算完毕之后就会将计算watcher从栈顶移除,所以Dep.target会变成渲染watcher
            if (Dep.target) { // 这里拿到的是渲染Watcher,但是先创建的是计算Watcher,初始化就会创建对应的计算Watcher
                watcher.depend(); // 调用计算watcher的depend方法,收集渲染watcher(将渲染watcher加入到订阅者列表中)
            }
            return watcher.value; // 如果数据没有变化,则直接返回之前的值,不再进行计算
        }
    }
}

这里最关键的就是计算属性求值完毕之后,需要调用其depend()方法收集渲染watcher的依赖,即将渲染watcher加入到计算watcher所依赖key对应的dep对象的观察者列表中。比如,模板中仅仅使用到了一个计算属性:

<div id="app">{{fullName}}</div>
new Vue({
    data: {name: "vue"},
    computed: {
        fullName() {
            return "li" + this.name
        }
    }
});

当页面开始渲染的时候,即渲染watcher执行的时候,会首先将渲染watcher加入到栈顶,然后取计算属性fullName的值,此时会将计算watcher加入到栈顶,然后求计算属性的值,计算属性依赖了name属性,接着去取name的值,name对应的dep对象就会将计算watcher放到其观察者列表中,计算属性求值完毕后,计算watcher从栈顶移除,此时栈顶变成了渲染watcher,但是由于模板中只使用到了计算属性,所以name对应的dep对象并没有将渲染watcher放到其观察者列表中,所以当name值发生变化的时候,无法通知渲染watcher更新页面。所以我们需要在计算属性求值完毕后遍历计算watcher依赖的key并拿到对应的dep对象将渲染watcher放到其观察者列表中

// src/observer/watcehr.js
export default class Watcher {
    constructor(vm, exprOrFn, cb, options, isRenderWatcher) {
            if (options) {
                this.lazy = !!options.lazy;// 标识是否为计算watcher
            } else {
                this.lazy = false;
            }
            this.dirty = this.lazy; // 如果是计算watcher,则默认dirty为true
            this.value = this.lazy ? undefined : this.get(); // 计算watcher需要求值,添加一个value属性
    }
    get() {
        pushTarget(this);
        // this.getter.call(this.vm, this.vm);
        const value = this.getter.call(this.vm, this.vm); // 返回计算结果
        popTarget();
        return value;
    }
    update() {
        // queueWatcher(this); //计算wather不需要立即执行,需要进行区分
        if (this.lazy) { // 如果是计算watcher
            this.dirty = true; // 将计算属watcher的dirtry标识为了脏了即可
        } else {
            queueWatcher(this);
        }
    }

    evaluate() {
        this.value = this.get(); // 执行计算watcher拿到计算属性的值
        this.dirty = false; // 计算属性求值完毕后将dirty标记为false,表示目前数据是干净的
    }

    depend() { // 由计算watcher执行
        let i = this.deps.length;
        while(i--) { // 遍历计算watcher依赖了哪些key
            this.deps[i].depend(); // 拿到对应的dep对象收集依赖将渲染watcher添加到其观察者列表中
        }
    }
}

五、实现用户watcher

用户watcher也是一个Watcher对象,只不过创建用户watcher的时候传入的是data中的key名而不是函数表达式,所以需要将传入的key转换为一个函数表达式用户watcher不是在模板中使用,所以用户watcher关键在于执行传入的回调

// src/state.js
function initWatch(vm) {
    const watch = vm.$options.watch; // 拿到用户配置的watch
    for (let key in watch) { // 遍历watch监听了data中的哪些属性
        const handler = watch[key]; // 拿到数据变化后的处理回调函数
        new Watcher(vm, key, handler, {user: true}); // 为用户watch创建Watcher对象,并标识user: true
    }
}

用户watcher需要将监听的key转换成函数表达式

export default class Watcher {
    constructor(vm, exprOrFn, cb, options, isRenderWatcher) {
        if (typeof exprOrFn === "function") {
        } else {
            this.getter = parsePath(exprOrFn);// 将监听的key转换为函数表达式
        }
        if (options) {
            this.lazy = !!options.lazy; // 标识是否为计算watcher
            this.user = !!options.user; // 标识是否为用户watcher
        } else {
            this.user = this.lazy = false; 
        }
    }
  
    run() {
        const value = this.get(); // 执行get()拿到最新的值
        const oldValue = this.value; // 保存旧的值
        this.value = value; // 保存新值
        if (this.user) { // 如果是用户的watcher
            try {
                this.cb.call(this.vm, value, oldValue); // 执行用户watcher的回调函数,并传入新值和旧值
            } catch(err) {
                console.error(err);
            }
        } else {
            this.cb && this.cb.call(this.vm, oldValue, value); // 渲染watcher执行回调
        }
    }
}
function parsePath(path) {
    const segments = path.split("."); // 如果监听的key比较深,以点号对监听的key进行分割为数组
    return function(vm) { // 返回一个函数
        for (let i = 0; i < segments.length; i++) {
            if (!vm) {
                return;
            }
            vm = vm[segments[i]]; // 这里会进行取值操作
        }
        return vm;
    }
}

还需要注意的是,dep对象notify方法通知观察者列表中的watcher执行的时候必须保证渲染watcher最后执行,如果渲染Watcher先执行,那么当渲染watcher使用计算属性的时候,求值的时候发现计算watcher的dirty值仍然为false,导致计算属性拿到值仍为之前的值,即缓存的值,必须让计算watcher先执行将dirty变为true之后再执行渲染watcher,才能拿到计算属性最新的值,所以需要对观察者列表进行排序

由于计算watcher和用户watcher在状态初始化的时候就会创建,而渲染watcher是在渲染的时候才开始创建,所以我们可以按照创建顺序进行排序,后面创建的id越大,即按id从小到大进行排序即可。

export default class Dep {
    notify() {
        this.subs.sort((a, b) => a.id - b.id); // 对观察者列表中的watcher进行排序保证渲染watcher最后执行
        this.subs.forEach((watcher) => {
            watcher.update();
        });
    }
}

JS_Even_JS
2.6k 声望3.7k 粉丝

前端工程师