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();
});
}
}
发表回复