mpvue patch 源码分析
mpvue Watcher 源码分析
mpvue 实例化后,我们调用 $mount 方法实现了界面的渲染,$mount 方法源码如下:
Vue$3.prototype.$mount = function (el, hydrating) {
var this$1 = this;
// 初始化小程序生命周期相关
var options = this.$options;
if (options && (options.render || options.mpType)) {
var mpType = options.mpType; if ( mpType === void 0 ) mpType = 'page';
return this._initMP(mpType, function () {
return mountComponent(this$1, undefined, undefined)
})
} else {
return mountComponent(this, undefined, undefined)
}
};
由于小程序初始化会提供 mpType,所以程序将执行 this._initMp 方法,this._initMp 方法会根据 mpType 对小程序的 App 或 Page 对象进行初始化,由于我们要分析界面渲染的部分,所以我们只考虑 mpType 为 Page 的场景。this._initMp 方法中对 Page 进行初始化的源码如下:
var app = global.getApp();
global.Page({
data: {
$root: {}
},
handleProxy: function handleProxy (e) {
return rootVueVM.$handleProxyWithVue(e)
},
// mp lifecycle for vue
// 生命周期函数--监听页面加载
onLoad: function onLoad (query) {
mp.page = this;
mp.query = query;
mp.status = 'load';
getGlobalData(app, rootVueVM);
callHook$1(rootVueVM, 'onLoad', query);
},
// 生命周期函数--监听页面显示
onShow: function onShow () {
mp.page = this;
mp.status = 'show';
callHook$1(rootVueVM, 'onShow');
// 只有页面需要 setData
rootVueVM.$nextTick(function () {
rootVueVM._initDataToMP();
});
},
// 生命周期函数--监听页面初次渲染完成
onReady: function onReady () {
mp.status = 'ready';
callHook$1(rootVueVM, 'onReady');
next();
},
// Do something when page scroll
onPageScroll: function onPageScroll (options) {
callHook$1(rootVueVM, 'onPageScroll', options);
}
});
上述代码主要做了以下几件事:
- 调用 global.Page 初始化了一个 Page 对象,从而实现页面的初始化;
- 初始化了 Page 的 data 为 $root;
- 定义了一个事件代理方法 handleProxy,对于事件代理方法的源码我们将在下一节进行详细分析;
- 依次定义了 Page 的所有生命周期方法,并将其与我们在 options 中定义的方法进行绑定,比如我们在 mpvue 初始化时在 methods 中定义了 onPageScroll 方法,那么这里会绑定到 Page 的 onPageScroll 方法中:
onPageScroll: function onPageScroll (options) {
callHook$1(rootVueVM, 'onPageScroll', options);
},
而在 onReady 方法调用完毕后,会调用 next 回调函数:
onReady: function onReady () {
mp.status = 'ready';
callHook$1(rootVueVM, 'onReady');
next();
}
next 即我们在 this._initMp 方法中传入第二个参数,它是一个回调函数,其源码如下:
function () {
return mountComponent(this$1, undefined, undefined)
}
在 Page 实例化过程中我们注意到 data 并没有赋值,所以此时界面是处于初始状态,而 mountComponent 方法会完成 mpvue 实例中的 data 与 Page 中的 data 的映射,具体实现过程我们继续往下分析,先看 mountComponent 的核心源码:
function mountComponent (vm, el,hydrating) {
vm.$el = el;
callHook(vm, 'beforeMount');
var updateComponent = function () {
vm._update(vm._render(), hydrating);
};
vm._watcher = new Watcher(vm, updateComponent, noop);
hydrating = false;
if (vm.$vnode == null) {
vm._isMounted = true;
callHook(vm, 'mounted');
}
return vm
}
这里的关键步骤只有 4 行代码:
var updateComponent = function () {
vm._update(vm._render(), hydrating);
};
vm._watcher = new Watcher(vm, updateComponent, noop);
这个过程会完成整个界面的渲染,我们先看 Watcher 的构造函数,Watcher 构造函数的关键源码如下:
var Watcher = function Watcher (vm, expOrFn, cb, options) {
this.vm = vm;
vm._watchers.push(this);
this.deps = [];
this.newDeps = [];
this.depIds = new _Set();
this.newDepIds = new _Set();
this.expression = '';
if (typeof expOrFn === 'function') {
this.getter = expOrFn;
}
this.value = this.get();
};
首先,mpvue 向 vm._watchers 数组中 push 当前 Watcher 实例,接着 Watcher 初始化了 deps、newDeps 和 depIds 这几个依赖收集相关的集合。由于我们传入 expOrFn 是 updateComponent 函数,所以 this.getter 会指向 updateComponent 函数,最后会调用 this.get 方法获取 value 的值,this.get 方法源码如下:
Watcher.prototype.get = function get () {
pushTarget(this);
var value;
var vm = this.vm;
try {
value = this.getter.call(vm, vm);
} finally {
if (this.deep) {
traverse(value);
}
popTarget();
this.cleanupDeps();
}
return value
};
this.get 方法非常重要,它第一步调用 pushTarget 方法,它将 Watcher 作为参数传入,pushTarget 方法源码如下:
Dep.target = null;
function pushTarget (_target) {
Dep.target = _target;
}
此时 Dep.target 指向 Watcher 对象。之后 this.get 方法会执行:
value = this.getter.call(vm, vm);
上一段我们分析过 this.getter 指向 updateComponent 方法,所以此时会执行 updateComponent 方法,但是要注意这里使用 call 方法调用 updateComponent,因此 updateComponent 的 this 指向 vm 即 mpvue 实例,updateComponent 源码如下:
var updateComponent = function () {
vm._update(vm._render(), hydrating);
};
它首先执行了 vm._render 函数生成 vnode,然后调用 vm._update 完成界面渲染。vm._render 的关键代码是调用 render 函数生成 vnode,这不是本节重点,我们先跳过,直接分析 vm._update 方法。
mpvue udpate 源码分析
vm._update 的核心源码如下:
Vue.prototype._update = function (vnode, hydrating) {
var vm = this;
if (vm._isMounted) {
callHook(vm, 'beforeUpdate');
}
var prevEl = vm.$el;
var prevVnode = vm._vnode;
var prevActiveInstance = activeInstance;
activeInstance = vm;
vm._vnode = vnode;
if (!prevVnode) {
vm.$el = vm.__patch__(
vm.$el, vnode, hydrating, false /* removeOnly */,
vm.$options._parentElm,
vm.$options._refElm
);
vm.$options._parentElm = vm.$options._refElm = null;
} else {
vm.$el = vm.__patch__(prevVnode, vnode);
}
activeInstance = prevActiveInstance;
if (prevEl) {
prevEl.__vue__ = null;
}
if (vm.$el) {
vm.$el.__vue__ = vm;
}
if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
vm.$parent.$el = vm.$el;
}
};
vm._update 方法是完成渲染的核心方法,不管对 mpvue 还是 Vue 都是如此。实际完成渲染的方法是 vm.patch 方法,此方法我们会在下一节进行分析,本节我们先了解 vm._update 源码的执行流程和主要逻辑。vm._update 执行过程中,mpvue 会判断当前的 vnode 是否已经 mounted 到界面上,当我们第一次执行时,vm_isMounted 为 false,所以会跳过该段代码,但是当已经 mounted 后,则会调用 beforeUpdate 生命周期函数:
if (vm._isMounted) {
callHook(vm, 'beforeUpdate');
}
紧接着 mpvue 会判断 prevVnode 是否存在,如果 prevVnode 存在,则调用 vm.patch 方法进行 diff 算法,找出差异项进行最小颗粒度的更新:
vm.$el = vm.__patch__(prevVnode, vnode);
第一次进入时 prevVnode 是不存在的,所以会进入:
vm.$el = vm.__patch__(
vm.$el, vnode, hydrating, false /* removeOnly */,
vm.$options._parentElm,
vm.$options._refElm
);
有的同学可能会有疑问,在 vm._render 方法调用时即生成了 vnode,为什么第一次渲染时 prevNode 为 null 呢?这里 prevNode 指向 vm._vnode,这是因为 vm._render 执行后生成了 vnode,但并未将生成的 vnode 赋值给 vm._vnode,所以第一次进入页面时,vm._vnode 仍然为 null。
mpvue patch 源码分析
下面我们就来分析最关键的 patch 方法,由于篇幅关系,我们只分析初次进入时的场景,vm.patch 核心源码如下:
function patch () {
corePatch.apply(this, arguments);
this.$updateDataToMP();
}
这里的 corePatch 即实际完成界面渲染的方法,其源码如下:
function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) {
var isInitialPatch = false;
var insertedVnodeQueue = [];
if (isUndef(oldVnode)) {
isInitialPatch = true;
createElm(vnode, insertedVnodeQueue, parentElm, refElm);
}
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch);
return vnode.elm
}
由于我们的 oldVnode 不存在,所以会调用 createElm 完成界面的渲染,createElm 核心源码如下:
function createElm (vnode, insertedVnodeQueue, parentElm, refElm, nested) {
var data = vnode.data;
var children = vnode.children;
var tag = vnode.tag;
if (isDef(tag)) {
vnode.elm = vnode.ns
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode);
setScope(vnode);
{
createChildren(vnode, children, insertedVnodeQueue);
if (isDef(data)) {
invokeCreateHooks(vnode, insertedVnodeQueue);
}
insert(parentElm, vnode.elm, refElm);
}
}
}
其中关键方法是:
insert(parentElm, vnode.elm, refElm);
该方法是 Vue 开发 Web 应用时,实际进行 DOM 插入的方法,不过该方法在 mpvue 下并不会做任何操作,这是因为 mpvue 的界面渲染实际是由小程序来完成,大家可以理解为,小程序调用了 global.Page 方法会即完成了界面渲染。而 mpvue 实际做的,就是监听 data 的变化,并调用 setData 方法对界面进行更新,这一点,稍候就会验证到。在 corePatch 方法执行完毕后,mpvue 会继续执行:
this.$updateDataToMP();
this.$updateDataToMp 方法是实际完成界面渲染和更新的方法,其源码如下:
function updateDataToMP () {
var page = getPage(this);
if (!page) {
return
}
var data = formatVmData(this);
diffData(this, data);
throttleSetData(page.setData.bind(page), data);
}
代码非常简洁也非常容易理解:
- getPage 方法获取 page 实例;
- formatVmData 方法初始化 page 实例下的 data 对象;
- diffData 将 mpvue 实例下的 data 对象与 page 实例下的 data 对象进行对比,如果不一致则进行更新;
- 最后通过 throttleSetData 方法调用 page.setData 完成对界面的渲染和更新,throttleSetData 方法主要优化了频繁调用 setData 的场景,限制了 setData 的调用间隔为 50ms,因为频繁调用 setData 会造成页面的卡顿,所以 mpvue 对此进行了优化。
var throttleSetData = throttle(function (handle, data) {
handle(data);
}, 50);