前言
在上文中我们已经了解了Vue2如何对数据进行数据代理和数据劫持,接下来我们进入到试图渲染的部分vm.$mount(vm.$options.el)。在这里我们也会衔接上编译时拿到的形如_c('div')这样的渲染函数结果,来讲解在运行时它是如何被渲染的。
(注意这里的代码我参考了一些博客和书籍中的讲解代码,并且比较了Vue2仓库中的源码,相对仓库源码会更简略些,但能更容易阅读理解所讲的实现逻辑和思想)
其中,.$mount也是通过原型拓展添加的:
Vue.prototype.$mount = function(el) {
}
在options中开发者会提供一个加载点标签,Vue2需要通过它拿到页面上的真实dom
Vue.prototype.$mount = function(el) {
const vm = this;
el = document.querySelector(el);
vm.$el = el;
}
通过之前的编译时,我们知道模板最终会生成渲染函数,而Vue2也提供手写渲染函数的选项;对于这样实现同一结果的两条路径,代码中也需要进行处理,并且触发下一步逻辑:
Vue.prototype.$mount = function(el) {
const vm = this;
el = document.querySelector(el);
vm.$el = el;
const opts = vm.$options;
if (!opts.render) {
let template = opts.template;
if (!template) template = el.outerHTML;
let render = compileToFunction(template); // 这部分和编译时类似,就不再赘述
opts.render = render;
}
// 不管是通过编译时、手写函数,最终都需要将 render 渲染到 el 元素上
mountComponent(vm);
}
上一步我们的最终目的就是要获取对应render,进行渲染
Vue.prototype.$mount = function (vm) {
// 记录挂载点
vm.$el = el;
// 调用beforeMount
callHook(vm, 'beforeMount');
// 定义更新函数
let updateComponent
updateComponent = () => {
vm._update(vm._render(), hydrating);
}
// 渲染Watcher
new Watcher(vm, updateComponent, noop, {
before() {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate');
}
}
}, true)
return vm;
}
页面渲染
构建VNode
这已经是从源码copy出来的部分代码了,虽然看起来不多但内容其实十分丰富。我们还是把它再简化一下,就像前文所说地,仅仅是“下一步”执行_render()
Vue.prototype.$mount = function (vm) {
vm._render();
}
很显然vm._render()并非opts.render,它还是来自于原型拓展:
通过renderMixin(Vue)
renderMixin(Vue) {
/**
* 原型拓展函数_render,用于渲染
* @returns {VNode} 返回虚拟节点
*/
Vue.prototype._render = function() {
const vm = this; // 通过this拿到实例
let { render } = vm.$options;
let vnode = render.call(vm);
return vnode;
}
}
在这里,只要执行render.call(vm)就可以拿到vnode了。我们可以回想一下,所谓render其实就是那些带着_v,_s,_c的代码,如何处理它们呢?其实也是通过原型拓展添加的,_c的处理要复杂得多,对于_s和_v还是相对简单的,在这里采用简略写法来实现相应的代码逻辑:
initRender(vm) {
vm._c = function () { // createElement 创建元素型的节点
const vm = this;
return createElement(vm);
}
}
function createElement(vm, tag, data={}, children) {
return new VNode(vm, tag, data={}, children)
}
Vue.prototype._v = function (text) { // 创建文本的虚拟节点
const vm = this;
return new VNode(undefined, undefined, undefined, text);
}
Vue.prototype._s = function (val) { // JSON.stringify
if(isObject(val)){
return JSON.stringify(val);
} else {
return val;
}
}
在以上函数中取值时this.xxx是通过数据代理来取vm._data.xxx的,并且在读到vm._data.xxx时触发数据劫持。而“大名鼎鼎”的VNode,其实跟AST树类似,本质上也是一个JS对象装载的数据。
class VNode {
constructor(vm, tag, data, children) {
this.vm = vm;
this.tag = tag;
this.data = data;
this.children = children;
}
}
创建真实节点
_render运行的结果是构造VNode节点,这看起来还不足以完成页面渲染。再看我们一开始从仓库源码copy出来的代码,显然还有一些工作要做。接下来我们就来看_update,它也是通过原型拓展添加的,只是这个过程在lifeCycleMixin中:
function lifeCycleMixin(Vue) {
/**
* 原型拓展函数_update,用于渲染
* @param {VNode} vnode
*/
Vue.prototype._update = function (vnode) {
const vm = this;
vm.$el = patch(vm.$el, vnode);
}
}
patch
将虚拟节点转换为真实节点的过程通过patch实现:
// vdom/patch.js
/**
* 将虚拟节点转为真实节点后插入到元素中
* @param {*} el 当前真实元素
* @param {*} vnode 虚拟节点
* @returns 新的真实元素
*/
function patch(el, vnode) {
// 1.根据vnode创建真实节点
const elm = createElm(vnode);
// 2.将新的真实节点插入到页面中
const parentNode = el.parentNode;
const nextSibling = el.nextSibling;
parentNode.insertBefore(elm, nextSibling); // 若nextSibling为 null,insertBefore 等价与 appendChild。总能把元素插入到正确位置
parentNode.removeChild(el);
return elm;
}
function createElm(vnode) {
let el;
let { tag, data, children, text, vm } = vnode;
if (typeof tag === 'string') {
el = document.createElement(tag);
chidren.forEach(child => { // 循环创建子节点
el.appendChild(createElm(child));
});
} else {
el = document.createTextNode(text); // 创建文本节点
}
return el;
}
小结
此时已经能完成一个一次性的渲染过程,我们的主代码:
Vue.prototype.$mount = function (vm) {
vm._update(vm._render());
}
依赖收集与视图更新
在日常的开发中我们知道,Vue的响应式或者说它的MVVM,要求数据的更新要驱动视图的更新。
在Vue2中,视图更新的实现通过Watcher类来实现,它是响应式的一部分。我们通过它来调用页面渲染的逻辑,以实现页面更新
Vue.prototype.$mount = function (vm) {
let updateComponent = ()=>{
vm._update(vm._render());
}
// 每个组件都有一个 Watcher 实例
new Watcher(vm, updateComponent, ()=>{}, true)
}
// observe/watcher.js
class Watcher {
constructor(vm, fn, cb, options){
this.vm = vm;
this.fn = fn;
this.cb = cb;
this.options = options;
this.getter = fn; // fn为传入的页面渲染函数
this.get();
}
get(){
this.getter(); // 调用页面渲染逻辑
}
}
现在我们已经可以通过Watcher实例来控制页面渲染了,接下来要做的就是在数据发生改变的时候进行调用,在数据劫持中实现。
依赖收集
let id = 0;
class Dep {
constructor() {
this.id = id++;
this.subs = [];
}
// 保存渲染watcher
depend() {
this.subs.push(Dep.target);
}
}
Dep.target = null;
function defineReactive(obj, key, value) {
observe(value);
let dep = new Dep(); // 为每个属性创建一个dep实例
Object.defineProperty(obj, key, {
get() {
if (Dep.target) {
dep.depend(); // 通过get持有这个dep实例,保存当前组件的watcher实例
}
return value;
},
set(newValue) {
if (newValue === value) return;
observe(newValue);
value = newValue;
}
})
}
在Watcher中加入将当前watcher记录到Dep.target的逻辑,以完成initRender执行后,initState能够进行依赖收集的逻辑。
// observe/watcher.js
class Watcher {
constructor(vm, fn, cb, options){
this.vm = vm;
this.fn = fn;
this.cb = cb;
this.options = options;
this.getter = fn; // fn为传入的页面渲染函数
this.get();
}
get(){
Dep.target = this; // 当前组件wathcer记录
this.getter(); // 调用页面渲染逻辑
Dep.target = null; // 清除记录
}
}
在Vue实例初始化的时候,initState阶段就会完成对数据的数据代理和数据劫持。在watcher实例调用页面渲染逻辑时,会触发_render,其中对数据的读操作会触发数据劫持的getter进而完成依赖收集。
边界情况-依赖收集查重
多次使用的数据:
<div>
<p>{{title}}</p>
<p>{{title}}</p>
</div>
此时会在_render时触发两次title的getter,如果不进行查重的话一个watcher会被收集两次
let id = 0;
class Watcher {
constructor(vm, fn, cb, options){
this.vm = vm;
this.fn = fn;
this.cb = cb;
this.options = options;
this.id = id++; // watcher标记
this.getter = fn;
this.get();
}
get(){
Dep.target = this;
this.getter();
Dep.target = null;
}
}
let id = 0;
class Dep {
constructor() {
this.id = id++;
this.subs = []
}
depend() { // 修改了depend实现
Dep.target.addDep(this); // 调用当前watcher中的方法记录dep
}
// 保存渲染watcher
addSup(watcher) {
this.subs.push(watcher)
}
}
Dep.target = null;
现在Dep和Watcher的实例都有唯一标识,并且都能够记录对方,也就是可以查重了
let id = 0;
class Watcher {
constructor(vm, fn, cb, options){
this.vm = vm;
this.fn = fn;
this.cb = cb;
this.options = options;
this.id = id++;
this.depsId = new Set(); // watcher 保存 dep实例的id
this.deps = []; // watcher 保存 dep实例
this.getter = fn;
this.get();
}
addDep(dep) {
let id = dep.id;
if (!this.depsId.has(id)) {
this.depsId.add(id);
this.deps.push(dep);
dep.addSub(this); // 在dep通过Dep.target调用addDep的时候,让dep调用addSub记录watcher
}
}
get(){
Dep.target = this;
this.getter();
Dep.target = null;
}
}
视图更新
依赖收集已经完成,接下来就可以进行视图更新了:
let id = 0;
class Watcher {
constructor(vm, fn, cb, options){
this.vm = vm;
this.fn = fn;
this.cb = cb;
this.options = options;
this.id = id++;
this.depsId = new Set(); // watcher 保存 dep实例的id
this.deps = []; // watcher 保存 dep实例
this.getter = fn;
this.get();
}
addDep(dep) {
let id = dep.id;
if (!this.depsId.has(id)) {
this.depsId.add(id);
this.deps.push(dep);
dep.addSub(this); // 在dep通过Dep.target调用addDep的时候,让dep调用addSub记录watcher
}
}
get(){
Dep.target = this;
this.getter();
Dep.target = null;
}
update() {
this.get(); // 重新执行视图渲染
}
}
在数据劫持的getter中我们完成了依赖收集,接下来我们在setter中进行视图更新:
function defineReactive(obj, key, value) {
observe(value);
let dep = new Dep();
Object.defineProperty(obj, key, {
get() {
if (Dep.target) {
dep.depend();
}
return value;
},
set(newValue) {
if (newValue === value) return;
observe(newValue);
value = newValue;
dep.notify(); // 通过dep实例更新
}
})
}
边界情况-数组\对象
在以上的情况里我们完成了一般数据的依赖收集和视图更新,但要知道我们在initState中有提到过的特例:数组、对象。
class Observer {
constructor() {
// ...省略
this.dep = new Dep(); // 为整个对象或数组创建一个 Dep 实例
}
}
function defineReactive(obj, key, value) {
let childOb = observe(value); // 如果 childOb 有值,说明数据是数组或对象类型
let dep = new Dep();
Object.defineProperty(obj, key, {
get() {
if (Dep.target) {
dep.depend();
if (childOb) {
childOb.dep.depend(); // 让数组和对象本身的 dep 记住当前 watcher
if (Array.isArray(value)) {
dependArray(value) // 可能数组中继续嵌套数组,需递归处理;多层对象本身已经在Observe时处理过了
}
}
}
return value;
},
set(newValue) {
if (newValue === value) return;
observe(newValue);
value = newValue;
dep.notify();
}
})
}
function dependArray(value) {
// 数组中如果有对象:[{}]或[[]],也要做依赖收集(后续会为对象新增属性)
for(let i = 0; i < value.length; i++){
let current = value[i];
// current 上如果有__ob__,说明是对象
current.__ob__ && current.__ob__.dep.depend();
if(Array.isArray(current)){
dependArray(current); // 嵌套数据,递归处理
}
}
}
数组的视图更新:
// Observer/array.js
let oldArrayPrototype = Array.prototype;
let arrayMethods = Object.create(oldArrayPrototype);
let methods = [
'push',
'pop',
'shift',
'unshift',
'reverse',
'sort',
'splice'
]
methods.forEach(method => {
arrayMethods[method] = function (...args) {
oldArrayPrototype[method].call(this, ...args);
let inserted = null;
let ob = this.__ob__;
switch(method) {
case 'splice':
inserted = args.slice(2);
break;
case 'push':
case 'unshift':
inserted = args;
break;
}
if (inserted) ob.observeArray(inserted);
ob.dep.notify(); // 通过ob拿到管理数组\对象的dep,调用 notify 触发 watcher 做视图更新
}
});
边界情况-异步更新
在Vue的使用中我们都知道Vue是批量处理页面渲染也就是dom操作的,这是性能优化中极为重要的一点,但在我们目前的代码中还未体现这一点。异步更新带来了性能的优化,同样在开发中也制造了一些困难,所以在实际开发中我们通常要使用.$nextTick来处理异步更新带来的问题。
结语
本篇内容较多,将视图渲染、依赖收集、依赖更新以及一些常见边界情况的处理都整理到了一起。尽管如此还是简略了patch的内容,这是Vue2中的又一个重点,放到下一篇来整理。