关于vue.js:Vue2异步批量更新与computedwatcher原理实现

2次阅读

共计 10732 个字符,预计需要花费 27 分钟才能阅读完成。

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 对应 ddep 对象的观察者列表中 。比方, 模板中仅仅应用到了一个计算属性:

<div id="app">{{fullName}}</div>
new Vue({data: {name: "vue"},
    computed:() {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();
        });
    }
}
正文完
 0