Vue2响应式原理与实现
Vue2组件挂载与对象数组依赖收集vue
Vue2中生命周期能够在建立Vue实例传入的配置对象中进行配置,也能够经过全局的Vue.mixin()方法来混入生命周期钩子,如:node
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放到一个单独的模块中,如:react
// 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这个工具方法,该方法能够合并生命周期的钩子也能够合并普通对象,合并的思路很简单,首先遍历父对象中的全部属性对父子对象中的各个属性合并一次,而后再遍历子对象,找出父对象中不存在的属性再合并一次,通过两次合并便可完成父子对象中全部属性的合并。segmentfault
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内部,如:api
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中,如:app
// 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(); } }
目前已经实现异步批量更新,可是若是咱们执行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添加一个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的时候传入的是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(); }); } }