平安金服面试官:从 new 一个 Vue 对象开始...

2,562 阅读21分钟

前言

2020年的前端面试已经和从往年有着太大的变化。从我们需要熟练使用 vue,对项目的兼容性和优化有一定的经验,了解网络通信协议和 js 基础知识,到现在面试官都是问:

  • 你对虚拟 DOM 原理的理解?
  • Vue 的 diff 算法有了解过吗?
  • 抽象语法树是什么,能介绍一下吗?
  • 从 new 一个 Vue 对象开始,Vue 的内部运行机制是什么样的?

有没有感觉这些问题非常熟悉!我们现在带着最后一个问题,从源码角度看下从 new 一个 Vue 对象之后,Vue 内部究竟发生了什么?

new Vue 、初始化和挂载

let vm = new Vue({ el: '#app' })

从 Vue 的构造类开始看:

function Vue (options{
    if (process.env.NODE_ENV !== 'production' && !(this instanceof Vue)) {
        warn('Vue is a constructor and should be called with the `new` keyword')
    }
    /*初始化*/
    this._init(options)
}

这里就一行代码this._init(options)

然后我们看一下 _init 的源码:

/*src/core/instance/init.js*/

Vue.prototype._init = function (options?: Object{
    const vm: Component = this
    vm._uid = uid++

    let startTag, endTag
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        startTag = `vue-perf-start:${vm._uid}`
        endTag = `vue-perf-end:${vm._uid}`
        mark(startTag)
    }

    /*一个避免 vm 实例被观察的标志*/
    vm._isVue = true
    /*合并配置*/
    if (options && options._isComponent) {
        /*优化内部组件实例化*/
        /*动态选项合并非常慢,并且内部组件选项均不需要特殊处理。*/
        initInternalComponent(vm, options)
    } else {
        vm.$options = mergeOptions(
            resolveConstructorOptions(vm.constructor),
            options || {},
            vm
        )
    }
    if (process.env.NODE_ENV !== 'production') {
        initProxy(vm)
    } else {
        vm._renderProxy = vm
    }
    // expose real self
    vm._self = vm
    /*初始化生命周期*/
    initLifecycle(vm)
    /*初始化事件*/
    initEvents(vm)
    /*初始化 render */
    initRender(vm)
    /*调用 beforeCreate 钩子函数,触发 beforeCreate 钩子事件*/
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    /*初始化 props、methods、data、computed 与 watch*/
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    /*调用 created 钩子函数,触发 created 钩子事件*/
    callHook(vm, 'created')

    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        /*格式化组件名*/
        vm._name = formatComponentName(vm, false)
        mark(endTag)
        measure(`vue ${vm._name} init`, startTag, endTag)
    }

    if (vm.$options.el) {
        /*挂载组件*/
        vm.$mount(vm.$options.el)
    }
  }

从生命周期来看, _init 主要完成下列工作:

  • 初始化生命周期、事件、 render
  • 调用 beforeCreate 钩子函数
  • 初始化 props、methods、data、computed 与 watch ,并且对 options 中的数据进行"响应式化"(双向绑定)以及完成依赖收集
  • 调用 created 钩子函数
  • 挂载组件

这里双向绑定的实现过程就不详细解释了。感兴趣的同学源码入口:

src/core/observer/index.js

模板编译 compile

不废话,我们直接看源码:

 /*src/compiler/index.js*/
 export const createCompiler = createCompilerCreator(function baseCompile (
    template: string,
    options: CompilerOptions
): CompiledResult 
{
    /*用正则等方式解析 template 中的指令、class、style 等数据,形成 ast*/
    const ast = parse(template.trim(), options)
    if (options.optimize !== false) {
        /*遍历 ast ,静态节点打上标记*/
        optimize(ast, options)
    }
    /*根据 ast 生成对应 render function*/
    const code = generate(ast, options)
    return {
        ast,
        render: code.render,
        staticRenderFns: code.staticRenderFns
    }
})

这里 baseCompile 函数可以划分为三个阶段:

  • parse 解析阶段。用正则等方式解析 template 中的指令、class、style等数据,形成 ast 。
  • optimize 优化阶段。标记 static 静态节点。在新旧节点比较变更时,diff 算法会直接跳过静态节点,这里优化了 patch 的性能。
  • generate 代码生成阶段。将 ast 转化成 render function 字符串,得到结果是 render 的字符串以及 staticRenderFns 字符串。

三个模块具体实现对应源码:

/*解析阶段*/
src/compiler/parser/index.js
/*优化阶段*/
src/compiler/optimizer.js
/*代码生成阶段*/
src/compiler/codegen/index.js

Watcher 到更新视图

Watcher 对象会调用 updateComponent 方法来实现更新视图:

updateComponent = () => {
    vm._update(vm._render(), hydrating)
}

updateComponent 就执行一句话,_render 函数会返回一个新的 VNode 节点,传入 _update 中与旧的 VNode 对象进行对比,经过一个 patch 的过程得到两个 VNode 节点的差异,最后我们只需要将这些差异的对应 DOM 进行修改即可。

那什么是 VNode 呢?

VNode

这里 VNode 是一种全新的性能优化解决方案,它可以理解为用 JS 的计算性能来换取操作 DOM 所消耗的性能。

最直观的思路就是我们以数据驱动的思想去开发,我们只需要关注数据操作,而不是去操作真实 DOM。

我们可以用 JS 模拟出一个 DOM 节点,这里简称 VNode。当数据发生变化时,我们对比变化前后的 VNode,通过 diff 算法 计算出需要更新的地方,最后一起更新视图。另外 VNode 的存在也使得 Vue 不在依赖浏览器环境,使其有了服务端渲染的能力。

/*src/core/vdom/vnode.js*/
export default class VNode {
    tag: string | void;
    data: VNodeData | void;
    children: ?Array<VNode>;
    text: string | void;
    elm: Node | void;
    ns: string | void;
    context: Component | void;
    functionalContext: Component | void// only for functional component root nodes
    key: string | number | void;
    componentOptions: VNodeComponentOptions | void;
    componentInstance: Component | void// component instance
    parent: VNode | void// component placeholder node
    raw: boolean; // contains raw HTML? (server only)
    isStatic: boolean; // hoisted static node
    isRootInsert: boolean; // necessary for enter transition check
    isComment: boolean; // empty comment placeholder?
    isCloned: boolean; // is a cloned node?
    isOnce: boolean; // is a v-once node?
    
    constructor (
        tag?: string,
        data?: VNodeData,
        children?: ?Array<VNode>,
        text?: string,
        elm?: Node,
        context?: Component,
        componentOptions?: VNodeComponentOptions
    ) {
        /*当前节点的标签名*/
        this.tag = tag
        /*当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息*/
        this.data = data
        /*当前节点的子节点,是一个数组*/
        this.children = children
        /*当前节点的文本*/
        this.text = text
        /*当前虚拟节点对应的真实dom节点*/
        this.elm = elm
        /*当前节点的名字空间*/
        this.ns = undefined
        /*编译作用域*/
        this.context = context
        /*函数化组件作用域*/
        this.functionalContext = undefined
        /*节点的key属性,被当作节点的标志,用以优化*/
        this.key = data && data.key
        /*组件的option选项*/
        this.componentOptions = componentOptions
        /*当前节点对应的组件的实例*/
        this.componentInstance = undefined
        /*当前节点的父节点*/
        this.parent = undefined
        /*简而言之就是是否为原生HTML或只是普通文本,innerHTML的时候为true,textContent的时候为false*/
        this.raw = false
        /*静态节点标志*/
        this.isStatic = false
        /*是否作为跟节点插入*/
        this.isRootInsert = true
        /*是否为注释节点*/
        this.isComment = false
        /*是否为克隆节点*/
        this.isCloned = false
        /*是否有v-once指令*/
        this.isOnce = false
    }
    
    // DEPRECATED: alias for componentInstance for backwards compat.
    /* istanbul ignore next */
    get child (): Component | void {
        return this.componentInstance
    }
}

这里 VNode 类可以通过属性搭配,描述出各种类型的真实 DOM 节点。

Patch

前面说过 _update 会将新旧两个 VNode 进行一次 patch 的过程,得到两 VNode 之间最小差异,然后将这些差异渲染到视图。

其实仔细想想就三种操作:

  • 创建节点
  • 删除节点
  • 更新节点

那它是怎么把算法复杂度降低到 O(n) 的呢?

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

这两张图代表新旧的 VNode 进行 patch 的过程,他们只是在同层级的 VNode 之间进行比较得到变化(第二张图中相同颜色的方块代表互相进行比较的 VNode 节点),然后修改变化的视图,所以十分高效。

在 patch 的过程中,如果两个 VNode 被认为是同一个 VNode (sameVnode),才会进行深度的比较,得出最小差异,否则直接删除旧有 DOM 节点,创建新的 DOM 节点。

看 sameVnode 源码:

/*
  这里判断需要同时满足: key、 tag 、isComment相同
  当前节点定义 data 情况相同
  当标签是<input>的时候,type必须相同
*/

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

// Some browsers do not support dynamically changing type for <input>
// so they need to be treated as different nodes
/*
  判断当标签是<input>的时候,type是否相同
  某些浏览器不支持动态修改<input>类型,所以他们被视为不同类型
*/

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

当新旧 VNode 的 key、tag、isComment 都相同,同时定义 data 情况相同,而且标签为 input 则 type 相同。这时候就算新旧 VNode 为 sameVnode,需要进行 patchVnode 操作。

patchVnode的规则:

  • 如果新旧 VNode 都是静态的,同时它们的 key 相同(代表同一节点),并且新的 VNode 是 clone 或者是标记了 once (标记 v-once 属性,只渲染一次),那么只需要替换 elm 以及 componentInstance 即可。
  • 新老节点均有 children 子节点,则对子节点进行 diff 操作,调用 updateChildren 方法。
  • 如果老节点没有子节点而新节点存在子节点,先清空老节点 DOM 的文本内容,然后为当前 DOM 节点加入子节点。
  • 当新节点没有子节点而老节点有子节点的时候,则移除该 DOM 节点的所有子节点。
  • 当新老节点都无子节点的时候,只是文本的替换。

下面看看 updateChildren 的实现:

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly{
    let oldStartIdx = 0
    let newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]
    let oldKeyToIdx, idxInOld, elmToMove, refElm

    // removeOnly is a special flag used only by <transition-group>
    // to ensure removed elements stay in correct relative positions
    // during leaving transitions
    const canMove = !removeOnly

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isUndef(oldStartVnode)) {
            oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
        } else if (isUndef(oldEndVnode)) {
            oldEndVnode = oldCh[--oldEndIdx]
        } else if (sameVnode(oldStartVnode, newStartVnode)) {
            /*前四种情况其实是指定 key 的时候,判定为同一个 VNode,则直接 patchVnode 即可,分别比较 oldCh 以及 newCh 的两头节点 2*2=4 种情况*/
            patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
            oldStartVnode = oldCh[++oldStartIdx]
            newStartVnode = newCh[++newStartIdx]
        } else if (sameVnode(oldEndVnode, newEndVnode)) {
            patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
            oldEndVnode = oldCh[--oldEndIdx]
            newEndVnode = newCh[--newEndIdx]
        } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
            patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
            canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
            oldStartVnode = oldCh[++oldStartIdx]
            newEndVnode = newCh[--newEndIdx]
        } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
            patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
            canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
            oldEndVnode = oldCh[--oldEndIdx]
            newStartVnode = newCh[++newStartIdx]
        } else {
            /*
              生成一个 key 与旧 VNode 的 key 对应的哈希表(只有第一次进来 undefined 的时候会生成,也为后面检测重复的 key 值做铺垫)
              比如 childre 是这样的 [{xx: xx, key: 'key0'}, {xx: xx, key: 'key1'}, {xx: xx, key: 'key2'}]  beginIdx = 0   endIdx = 2  
              结果生成 {key0: 0, key1: 1, key2: 2}
            */

            if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
            /*如果 newStartVnode 新的 VNode 节点存在 key 并且这个 key 在 oldVnode 中能找到则返回这个节点的 idxInOld (即第几个节点,下标)*/
            idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : null
            if (isUndef(idxInOld)) { // New element
                /* newStartVnode 没有 key 或者是该 key 没有在老节点中找到则创建一个新的节点*/
                createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
                newStartVnode = newCh[++newStartIdx]
            } else {
                /*获取同 key 的老节点*/
                elmToMove = oldCh[idxInOld]
                /* istanbul ignore if */
                if (process.env.NODE_ENV !== 'production' && !elmToMove) {
                    /*如果 elmToMove 不存在说明之前已经有新节点放入过这个 key 的 DOM 中,提示可能存在重复的 key ,确保 v-for 的时候 item 有唯一的 key 值*/
                    warn(
                        'It seems there are duplicate keys that is causing an update error. ' +
                        'Make sure each v-for item has a unique key.'
                    )
                }
                if (sameVnode(elmToMove, newStartVnode)) {
                    /*Github:https://github.com/answershuto*/
                    /*如果新 VNode 与得到的有相同 key 的节点是同一个 VNode 则进行 patchVnode */
                    patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
                    /*因为已经 patchVnode 进去了,所以将这个老节点赋值 undefined,之后如果还有新节点与该节点 key 相同可以检测出来提示已有重复的 key */
                    oldCh[idxInOld] = undefined
                    /*当有标识位 canMove 实可以直接插入 oldStartVnode 对应的真实 DOM 节点前面*/
                    canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm)
                    newStartVnode = newCh[++newStartIdx]
                } else {
                    // same key but different element. treat as new element
                    /*当新的 VNode 与找到的同样 key 的 VNode 不是 sameVNode 的时候(比如说 tag 不一样或者是有不一样 type 的 input 标签),创建一个新的节点*/
                    createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
                    newStartVnode = newCh[++newStartIdx]
                }
            }
        }
    }
    if (oldStartIdx > oldEndIdx) {
        /*全部比较完成以后,发现 oldStartIdx > oldEndIdx 的话,说明老节点已经遍历完了,新节点比老节点多,所以这时候多出来的新节点需要一个一个创建出来加入到真实 DOM 中*/
        refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
        addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {
        /*如果全部比较完成以后发现 newStartIdx > newEndIdx ,则说明新节点已经遍历完了,老节点多余新节点,这个时候需要将多余的老节点从真实 DOM 中移除*/
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
  }

我们看图说话:

首先,在新老 VNode 节点的左右头尾两侧都有一个变量标记,在遍历过程中这几个变量都会向中间靠拢。当 oldStartIdx > oldEndIdx 或者 newStartIdx > newEndIdx 时结束循环。

在遍历中,如果存在 key ,并且满足 sameVnode ,复用该 DOM 节点,否则创建一个新的 DOM 节点。

这里,oldStartVnode、oldEndVnode 与 newStartVnode、newEndVnode 两两比较一共有 2*2=4 种比较方法。

当新老 VNode 节点的 start 或者 end 满足 sameVnode 时,也就是 sameVnode(oldStartVnode, newStartVnode) 或者 sameVnode(oldEndVnode, newEndVnode) ,直接将该 VNode 节点进行 patchVnode 即可。

如果 oldStartVnode 与 newEndVnode 满足 sameVnode ,即 sameVnode(oldStartVnode, newEndVnode) 。

这时候说明 oldStartVnode 已经跑到了 oldEndVnode 后面去了,进行 patchVnode 的同时还需要将真实 DOM 节点移动到 oldEndVnode 的后面。

如果 oldEndVnode 与 newStartVnode 满足 sameVnode ,即 sameVnode(oldEndVnode, newStartVnode)。

这说明 oldEndVnode 跑到了 oldStartVnode 的前面,进行 patchVnode 的同时真实的 DOM 节点移动到了 oldStartVnode 的前面。

如果以上情况均不符合,则通过 createKeyToOldIdx 会得到一个 oldKeyToIdx ,里面存放了一个 key 为旧的 VNode , value 为对应 index 序列的哈希表。从这个哈希表中可以找到是否有与 newStartVnode 一致 key 的旧的 VNode 节点,如果同时满足 sameVnode , patchVnode 的同时会将这个真实 DOM(elmToMove) 移动到 oldStartVnode 对应的真实 DOM 的前面。

当然也有可能 newStartVnode 在旧的 VNode 节点找不到一致的 key ,或者是即便 key 相同却不是 sameVnode ,这个时候会调用 createElm 创建一个新的 DOM 节点。

到这里循环已经结束了,那么剩下我们还需要处理多余或者不够的真实 DOM 节点。

  • 当结束时 oldStartIdx > oldEndIdx ,这个时候老的 VNode 节点已经遍历完了,但是新的节点还没有。说明了新的 VNode 节点实际上比老的 VNode 节点多,也就是比真实 DOM 多,需要将剩下的(也就是新增的) VNode 节点插入到真实 DOM 节点中去,此时调用 addVnodes (批量调用 createElm 的接口将这些节点加入到真实 DOM 中去)。
  • 同理,当 newStartIdx > newEndIdx 时,新的 VNode 节点已经遍历完了,但是老的节点还有剩余,说明真实 DOM 节点多余了,需要从文档中删除,这时候调用 removeVnodes 将这些多余的真实 DOM 删除。

映射到真实 DOM

虚拟 DOM 的生命钩子:

const hooks = ['create''activate''update''remove''destroy']

/*构建 cbs 回调函数*/
for (i = 0; i < hooks.length; ++i) {
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
        if (isDef(modules[j][hooks[i]])) {
            cbs[hooks[i]].push(modules[j][hooks[i]])
        }
    }
}

虚拟 DOM 提供一些钩子函数,分别在不同的时期会进行调用。 这里没有对 attr、class、props、events、style 以及 transition (过渡状态)的 DOM 属性进行操作的描述。下一步有机会补充。

再看全局

回过头再看看,有没有感觉对这张图有了全新的理解?

感谢

如果本文对你有帮助,就点个赞支持下吧!感谢阅读。