Vue 进阶

1,961 阅读13分钟

Vue.js 运行机制全局分析

一、初始化及挂载

new Vue()之后,回调用一个init方法进行初始化,会初始化data、props、methods、watcher、computed、生命周期等。其中最重要的是通过 Object.defineProperty 设置 settergetter函数,用来实现「响应式」以及「依赖收集」

初始化完之后会调用 $mount进行挂载组件,如果是运行时编译,则不需要render function ,如果有template则需要进行编译。

二、数据响应原理

Vue最独特的特性之一,是其非侵入性的响应式系统。数据模型仅仅是普通的JavaScript对象。而当你修改它们时,视图会进行更新。

Vue采用的是数据劫持的方式,当你设置data属性的值时候,vue就会遍历data属性,对每一个属性通过Object.defineProperty来设置gettersetter。当 触发render function 渲染的时候,就会触发属性的getter方法,同时触发getter方法中的依赖收集,所谓的依赖收集就是将观察者Watcher对象存放到当前闭包中的订阅者Depsubs 中。形成如下所示的这样一个关系。

在修改对象的值的时候,会触发对应的settersetter通知之前「依赖收集」得到的Dep中的每一个 Watcher,告诉它们自己的值改变了,需要重新渲染视图。这时候这些 Watcher 就会开始调用 update来更新视图,当然这中间还有一个patch 的过程以及使用队列来异步更新的策略。实质就是在数据变动时发布消息给订阅者,触发相应的监听回调。

通过上图我们再来分析一下依赖的收集以及如何触发更新。

某一数据属性data,通过Object.defineproperty设置gettersetter进行劫持之后,第一次我们使用数据进行render渲染的时候,会自动触发该数据的getter方法,这时候会进行依赖收集,我们设置一个Dep订阅器,里面存放我们的订阅者,订阅者就是一个一个的watcher对象。Watcher我们成为观察者对象,每次触发getter之后都会当前的watcher放入到Dep订阅器中。订阅器Dep有一个方法notify,通过调用这个方法,可以通知所有的观察者(订阅者)watcher去调用自身的update方法去更新我们的视图。当我们改变这一属性的时候,会触发setter方法,这时候会调用dep中的notify方法,去通知所有的依赖,也就是watcher对象去更新页面。至于如何去更新,当然不是从头开始,重新渲染所有节点了,我们还需要用到diff算法等,下面我们会去单独来学习。

要实现一个简单的MVVM数据双向绑定的思路:

要实现 MVVM 的双向绑定,需要实现以下几点:

  • Observer: 为所有数据添加监听器 Dep

  • Dep (监听器): data 中每个对象(和子对象)都有持有一个该对象, 当所绑定的数据有变更时, dep.notify() 方法被调用, 通知 Watcher

  • Compile (HTML指令解析器): 对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数

  • Watcher: 作为连接 Observer 和 Compile 的桥梁,当 Compile 解析指令时会生成一个 Watcher 并给它绑定一个 update 方法 , 并添加到当前正在解析的指令所依赖的对象的 Dep 对象上

三、编译

compile编译可以分成 parse、optimize 与 generate 三个阶段,最终需要得到 render function。

1、parse 解析

首先是 parseparse 会用正则等方式将template 模板中进行字符串解析,得到指令、class、style等数据,形成 AST

这个过程比较复杂,会涉及到比较多的正则进行字符串解析,我们来看一下得到的 AST 的样子。

{
    /* 标签属性的map,记录了标签上属性 */
    'attrsMap': {
        ':class': 'c',
        'class': 'demo',
        'v-if': 'isShow'
    },
    /* 解析得到的:class */
    'classBinding': 'c',
    /* 标签属性v-if */
    'if': 'isShow',
    /* v-if的条件 */
    'ifConditions': [
        {
            'exp': 'isShow'
        }
    ],
    /* 标签属性class */
    'staticClass': 'demo',
    /* 标签的tag */
    'tag': 'div',
    /* 子标签数组 */
    'children': [
        {
            'attrsMap': {
                'v-for': "item in sz"
            },
            /* for循环的参数 */
            'alias': "item",
            /* for循环的对象 */
            'for': 'sz',
            /* for循环是否已经被处理的标记位 */
            'forProcessed': true,
            'tag': 'span',
            'children': [
                {
                    /* 表达式,_s是一个转字符串的函数 */
                    'expression': '_s(item)',
                    'text': '{{item}}'
                }
            ]
        }
    ]
}

最终得到的 AST 通过一些特定的属性,能够比较清晰地描述出标签的属性以及依赖关系。

2、optimize 优化

optimize 主要作用就跟它的名字一样,用作「优化」

这个涉及到后面要讲 patch 的过程,因为patch的过程实际上是将 VNode节点进行一层一层的比对,然后将「差异」更新到视图上。那么一些静态节点是不会根据数据变化而产生变化的,我们就需要为静态的节点做上一些「标记」,在 patch 的时候我们就可以直接跳过这些被标记的节点的比对,从而达到「优化」的目的。

经过 optimize 这层的处理,每个节点会加上static属性,用来标记是否是静态的。

那么我们如何确定该节点是否是静态的呢,我们在第一步的时候就可以标明每一个节点的类型,当 type2(表达式节点)则是非静态节点,当 type3(文本节点)的时候则是静态节点。

3、generate 转为render function

generate 会将 AST 转化成 render funtion字符串,最终得到 render的字符串以及 staticRenderFns字符串。

render function 看起来就像下面:

with(this){
    return (isShow) ? 
    _c(
        'div',
        {
            staticClass: "demo",
            class: c
        },
        _l(
            (sz),
            function(item){
                return _c('span',[_v(_s(item))])
            }
        )
    )
    : _e()
}

经历过这些过程以后,我们已经把 template 顺利转成了 render function了,之后 render function 就会转换为Virtual DOM

四、Virtual DOM 虚拟DOM

Virtual DOM 其实就是一棵以 JavaScript 对象(VNode 节点)作为基础的树,用对象属性来描述节点,实际上它只是一层对真实DOM 的抽象。最终可以通过一系列操作使这棵树映射到真实环境上。由于Virtual DOM是以 JavaScript 对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说浏览器平台、Weex、Node 等。

VNode 归根结底就是一个 JavaScript 对象,只要这个类的一些属性可以正确直观地描述清楚当前节点的信息即可。我们来实现一个简单的 VNode 类,加入一些基本属性,为了便于理解,我们先不考虑复杂的情况。

class VNode {
    constructor (tag, data, children, text, elm) {
        /*当前节点的标签名*/
        this.tag = tag;
        /*当前节点的一些数据信息,比如props、attrs等数据*/
        this.data = data;
        /*当前节点的子节点,是一个数组*/
        this.children = children;
        /*当前节点的文本*/
        this.text = text;
        /*当前虚拟节点对应的真实dom节点*/
        this.elm = elm;
    }
}

我们有一段template代码:

<template>
  <span class="demo" v-show="isShow">
    This is a span.
  </span>
</template>

用js对象表示就是:

function render () {
    return new VNode(
        'span',
        {
            /* 指令集合数组 */
            directives: [
                {
                    /* v-show指令 */
                    rawName: 'v-show',
                    expression: 'isShow',
                    name: 'show',
                    value: true
                }
            ],
            /* 静态class */
            staticClass: 'demo'
        },
        [ new VNode(undefined, undefined, undefined, 'This is a span.') ]
    );
}

转换成 VNode 以后的情况。

{
    tag: 'span',
    data: {
        /* 指令集合数组 */
        directives: [
            {
                /* v-show指令 */
                rawName: 'v-show',
                expression: 'isShow',
                name: 'show',
                value: true
            }
        ],
        /* 静态class */
        staticClass: 'demo'
    },
    text: undefined,
    children: [
        /* 子节点是一个文本VNode节点 */
        {
            tag: undefined,
            data: undefined,
            text: 'This is a span.',
            children: undefined
        }
    ]
}

该种形式就可以让我们在不同的平台实现很好的兼容了。

然后我们可以将 VNode 进一步封装一下,可以实现一些产生常用 VNode 的方法。

创建一个空节点

function createEmptyVNode () {
    const node = new VNode();
    node.text = '';
    return node;
}

总的来说,VNode 就是一个 JavaScript 对象,用 JavaScript 对象的属性来描述当前节点的一些状态,用 VNode 节点的形式来模拟一棵 Virtual DOM 树。

此时再看这个例子:

with(this){
    return (isShow) ? 
    _c(
        'div',
        {
            staticClass: "demo",
            class: c
        },
        _l(
            (sz),
            function(item){
                return _c('span',[_v(_s(item))])
            }
        )
    )
    : _e()
}

上面这个 render function看到这里可能会纳闷了,这些_c_l 到底是什么?其实他们是 Vue.js 对一些函数的简写,比如说 _c对应的是createElement 这个函数。

把它用 VNode 的形式写出来就会明白许多了:

render () {
    return isShow ? (new VNode('div', {
        'staticClass': 'demo',
        'class': c
    }, [ /*这里还有子节点*/ ])) : createEmptyVNode();
}

五、数据状态更新时的差异 diff 及 patch 机制

当我们对modal也就是对我们的数据进行更改的时候,会触发对应 Dep中的 Watcher对象。Watcher 对象会调用对应的update 来修改视图。最终是将新产生的VNode节点与老VNode 进行一个patch 的过程,比对得出「差异」,最终将这些「差异」更新到视图上。

在通过VNode更新视图的时候,由于跨平台,所以我们使用的api就有差异了,此时我们需要做一层适配层,提供统一的接口方法,实现上,我们通过判断平台类型,提供不同的方案。

patch最核心的就是diff算法了:

diff 算法是通过同层的树节点进行比较而非对树进行逐层搜索遍历的方式,所以时间复杂度只有 O(n),是一种相当高效的算法,如下图。

这张图中的相同颜色的方块中的节点会进行比对,比对得到「差异」后将这些「差异」更新到视图上。因为只进行同层级的比对,所以十分高效。

function patch (oldVnode, vnode, parentElm) {

    if (!oldVnode) {
        // 原来没有,现在有了
        addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
        // 添加新的节点到父级
    } else if (!vnode) {
        // 原来有而现在没了
        removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
        // 移除原来的
    } else {
        // 原来有,现在也有
        if (sameVnode(oldVNode, vnode)) {
            // 新旧节点是否一样
            patchVnode(oldVNode, vnode);
        } else {
            // 新旧节点不一样
            removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);// 移除旧节点
            addVnodes(parentElm, null, vnode, 0, vnode.length - 1); // 增加新节点
        }
    }
}

sameVnode

sameVnode 其实很简单,只有当 key、 tag、 isComment(是否为注释节点)、 data同时定义(或不定义),同时满足当标签类型为 input 的时候 type 相同即可。

function sameVnode () {
    return (
        a.key === b.key &&
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        (!!a.data) === (!!b.data) &&
        sameInputType(a, b)
    )
}

function sameInputType (a, b) {
    if (a.tag !== 'input') return true
    let i
    const typeA = (i = a.data) && (i = i.attrs) && i.type
    const typeB = (i = b.data) && (i = i.attrs) && i.type
    return typeA === typeB
}

上面的 patch函数是用来比对两个节点是否相同,从而来作出相应的更改。下面我们仔细分析一下,具体的diff过程。

使用这个简化的例子来讲述diff的过程:

设置oldStart oldEnd newStart newEnd 四指针。分别标明新旧节点的开始和结束。

处理头部的同类型节点,即通过patch方法来比对两个节点,如果有更新(属性内容变更了等),那么更新DOM,但是不需要移动DOM。oldstart newStart 都往后移动一位。

处理尾部同类型节点,即通过patch方法来比对两个节点,将更新DOM。oldEnd newEnd都向前移动一位。

处理头尾同类型节点。就是oldStart和newEnd相同的。将头部的移动到尾部oldEnd指向的节点(即节点9)后面。此时oldStart向后移动一位,newEnd向前移动一位。变化后如上图:

同样处理尾头相同的情况,就是oldEnd和newStart相同。首先做是否想用判断,如果不同做更新之后,将9移动到3之前,更新DOM。同时oldEnd向前一位,newStart向后一位。然后如下:

newStart来到了节点11的位置,在oldVdom中找不到节点11,说明它是新增的

那么就创建一个新的节点,插入DOM树,插到什么位置?插到oldStart指向的节点(即节点3)前面,然后将newStart后移1位标记为已处理(注意oldVdom中没有节点11,所以标记过程中它的指针不需要移动),处理之后如下:

这时候发现7在我们原来的节点中,但是位置变化了,此时我们就把对他两进行比较更新之后,将7移动到DOM中的3之前,此时要做标记,但是由于旧节点中7并不在指针位置,所以我们另外设置一个值表示7已被处理。然后newStart右移动一位。

我们看到了令人欣慰的一幕,newStart和oldStart又指向了同一个节点(即都指向节点3),很简单,比较更新DOM节点之后,只需移动指针即可,非常高效,3、4、5、6都如此处理。newStart和oldStart都向后一位。

看到newStart跨过了newEnd,它们相遇啦!而这个时候,oldStart和oldEnd还没有相遇,说明这2个指针之间的节点(包括它们指向的节点,即上图中的节点7、节点8)是此次更新中被删掉的节点。

OK,那我们在DOM树中将它们删除,再回到前面我们对节点7做了标记,为什么标记是必需的?标记的目的是告诉Vue它已经处理过了,是需要出现在新DOM中的节点,不要删除它,所以在这里只需删除节点8。

在应用中也可能会遇到oldVdom的起止点相遇了,但是newVdom的起止点没有相遇的情况,这个时候需要对newVdom中的未处理节点进行处理,这类节点属于更新中被加入的节点,需要将他们插入到DOM树中。

到此,整个diff过程就结束了。

我们在整个过程中使用了diff算法去逐一判断,通过patch去判断两个节点是否更新,然后作出相应的DOM操作。总之:diff算法告诉我们如何去处理同层下的新旧VNode。

Diff过程中,Vue会尽可能的复用DOM,能不移动就不移动。

六、批量异步更新策略及 nextTick 原理

Vue.js 在我们修改data 之后其实就是一个“setter -> Dep -> Watcher -> patch -> 视图”的过程。

假设我们有如下这么一种情况。

<template>
  <div>
    <div>{{number}}</div>
    <div @click="handleClick">click</div>
  </div>
</template>
export default {
    data () {
        return {
            number: 0
        };
    },
    methods: {
        handleClick () {
            for(let i = 0; i < 1000; i++) {
                this.number++;
            }
        }
    }
}

当我们按下 click 按钮的时候,number 会被循环增加1000次。

那么按照之前的理解,每次 number 被 +1 的时候,都会触发 number 的 setter 方法,从而根据上面的流程一直跑下来最后修改真实 DOM。那么在这个过程中,DOM 会被更新 1000 次!太可怕了。

Vue.js 肯定不会以如此低效的方法来处理。Vue.js在默认情况下,每次触发某个数据的 setter 方法后,对应的 Watcher 对象其实会被 push 进一个队列 queue 中,在下一个 tick 的时候将这个队列 queue 全部拿出来 run( Watcher 对象的一个方法,用来触发 patch 操作) 一遍。

nextTick

Vue.js 实现了一个 nextTick 函数,传入一个 cb ,这个 cb 会被存储到一个队列中,在下一个 tick时触发队列中的所有cb事件。同时会采用一定的优化机制,一些重复的修改就会合并成一次修改,大大的提高了更新效率。

因为目前浏览器平台并没有实现nextTick方法,所以Vue.js源码中分别用 Promise、setTimeout、setImmediate等方式在 microtask(或是task)中创建一个事件,目的是在当前调用栈执行完毕以后(不一定立即)才会去执行这个事件。