2

在前面两节中,分别说明了vuejs中如何声明Vue类以及vue数据响应式如何实现:

  1. Vue声明过程
  2. Vue数据响应式实现过程

本节将探讨虚拟Dom及模版解析过程。

虚拟Dom

vuejs中的虚拟Dom实现基于snabbdom,在其基础上添加了组件等插件,关于snabbdom如何创建虚拟Dom及patch对比更新过程可以参考Snabbdom实现原理

_render

在Vue实例执行$mount方法挂载Dom的时候,在其内部执行了mountComponent函数。

Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

在mountComponent函数内部创建了Watcher对象(参考Vue数据响应式实现过程),当首次渲染和数据变化的时候会执行updateComponent函数。

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

该函数内部调用了Vue的实例方法_render和_update。其中_render方法的作用是生成虚拟Dom。

Vue.prototype._render = function (): VNode {
    const vm: Component = this
    const { render, _parentVnode } = vm.$options
    // 访问slot等占位节点
    vm.$vnode = _parentVnode
    // render self
    let vnode
    try {
        currentRenderingInstance = vm
        // 调用传入的render方法
        vnode = render.call(vm._renderProxy, vm.$createElement)
    } catch (e) {
        // 处理错误
    } finally {
        currentRenderingInstance = null
    }
    // ... 特殊情况判断
    // 设置父子关系
    vnode.parent = _parentVnode
    return vnode
}

其实,通过代码可以看出,这个方法的主要作用是调用$options中的render方法,该方法来源有两个:

  1. 用户自定义render。
  2. vue根据模版生成的render。

_renderProxy

通过call改变了render的this指向,让其指向vm._renderProxy, _renderProxy实例定义在core/instance/init.js的initMixin中:

if (process.env.NODE_ENV !== 'production') {
      initProxy(vm)
    } else {
      vm._renderProxy = vm
    }

通过代码可以看出,vm._renderProxy指向的就是vm。

$createElement

vm.$createElement是render方法的第一个参数,也就是我们开发过程中常用的h函数,其定义在core/instance/render.jsinitRender函数中:

export function initRender(vm: Component) {
    vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
}

其调用的就是/vdom/create-element.js文件中的createElement函数,由于vdom文件夹下存放的都是虚拟Dom有关的操作。

createElement

createElement用于生成Vnodes:

export function createElement(
    context: Component,
    tag: any,
    data: any,
    children: any,
    normalizationType: any,
    alwaysNormalize: boolean
): VNode | Array<VNode> {
    // 处理参数,针对不同参数个数进行初始化处理
    return _createElement(context, tag, data, children, normalizationType)
}

其内部调用_createElement函数进行具体逻辑操作:

export function _createElement(
    context: Component,
    tag?: string | Class<Component> | Function | Object,
    data?: VNodeData,
    children?: any,
    normalizationType?: number
): VNode | Array<VNode> {
    // 1. 判断传入参数是否符合要求,如果不合要求应该怎样处理
    // ... 省略
    if (normalizationType === ALWAYS_NORMALIZE) {
        children = normalizeChildren(children)
    } else if (normalizationType === SIMPLE_NORMALIZE) {
        children = simpleNormalizeChildren(children)
    }

    // 2. 创建vnode
    let vnode, ns
    if (typeof tag === 'string') {
        let Ctor
        ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
        if (config.isReservedTag(tag)) {
            if (process.env.NODE_ENV !== 'production' && isDef(data) && isDef(data.nativeOn)) {
                warn(
                    `The .native modifier for v-on is only valid on components but it was used on <${tag}>.`,
                    context
                )
            }
            vnode = new VNode(
                config.parsePlatformTagName(tag), data, children,
                undefined, undefined, context
            )
        } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
            // component
            vnode = createComponent(Ctor, data, context, children, tag)
        } else {
            vnode = new VNode(
                tag, data, children,
                undefined, undefined, context
            )
        }
    } else {
        vnode = createComponent(tag, data, context, children)
    }

    // 3. 对生成的vnode进行判断,如果不合要求,进行处理
    // ...
}

创建Vnodes有以下几种情况:

  1. tag是字符串而且是Dom中的元素,直接生成普通元素的Vnode。
  2. tag是字符串,但是属于组件($options.components),调用createComponent生成Vnode。
  3. tag是一个对象,那么默认该对象代表一个组件,调用createComponent生成Vnode。
  • createComponent

定义在core/instance/vdom/create-component.js文件中:

export function createComponent(
    Ctor: Class<Component> | Function | Object | void,
    data: ?VNodeData,
    context: Component,
    children: ?Array<VNode>,
    tag?: string
): VNode | Array<VNode> | void {
    
    // 1. 使用Vue.extend将组件选项生成一个继承自Vue的组件类
    const baseCtor = context.$options._base
    if (isObject(Ctor)) {
        Ctor = baseCtor.extend(Ctor)
    }

    // 2. 处理组件中的特殊定义

    // 3. 合并patch过程中使用到的生命周期hook
    installComponentHooks(data)

    // 4. 根据前面生成的数据,调用new VNode生成虚拟Dom
    const name = Ctor.options.name || tag
    const vnode = new VNode(
        `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
        data, undefined, undefined, undefined, context,
        { Ctor, propsData, listeners, tag, children },
        asyncFactory
    )
    return vnode
}

其中第三步合并生命周期hook函数在组件渲染挂载过程会被用到,这个在后续的Vue.component定义组件时继续讨论。

  • new VNode

VNode是一个类,包含一些描述该节点信息的实例成员,生成Vnode就是将一组数据组合成一个VNode实例。

_update

_update方法的作用是将虚拟Dom渲染到页面视图上:

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this
    const prevVnode = vm._vnode
    if (!prevVnode) {
        // 首次渲染
        vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
        // 数据更新
        vm.$el = vm.__patch__(prevVnode, vnode)
    }
}

不论是首次渲染还是数据更新,其调用的都是__patch__方法。

__patch__过程会操作Dom,因此属于web平台上的特有操作,因此其定义在platforms/web/runtime/index.js中:

Vue.prototype.__patch__ = inBrowser ? patch : noop

其实现调用了platforms/web/runtime/patch.js中导出的patch函数。

patch

patch过程和snabbdom的patch过程非常相近,只是针对vuejs特殊语法做了一些修改,此处不再详细说明,可以参考Snabbdom实现原理

总结

虚拟Dom的整个渲染过程可以总结为以下几步:

  1. vue调用$mount挂载Dom。
  2. 判断创建Vue实例时是否传入render方法,如果没传,那么将根据模版生成render函数。
  3. 创建Watcher,传入updateComponent函数。
  4. Watcher实例化时,会判断此Watcher是否是渲染Watcher,如果是,则调用updateComponent。
  5. updateComponent函数中会调用_render方法生成虚拟Dom,调用_update方法根据传入的虚拟Dom渲染真实Dom。
  6. 如果数据发生变化,会通过__ob__属性指向的Dep通知第四步中创建的Watcher,Watcher内部会再次调用updateComponent执行更新渲染。

模版编译

只有在完整版的vuejs中才包含模版编译部分的代码,如果是通过vue-cli创建的项目,将没有此部分功能。

模版编译的过程包含如下几步:

  1. 解析Dom的ast语法树。
  2. 根据ast生成render字符串。
  3. 将render字符串转换为render函数。

编译入口

platforms/web/entry-runtime-with-compiler.js文件中,会判断$mount时是否传入了render函数,如果没有传入,会根据模版编译render函数。

Vue.prototype.$mount = function (
    el?: string | Element,
    hydrating?: boolean
): Component {
    // ...
    // 编译template为render函数
    const { render, staticRenderFns } = compileToFunctions(template, {
        outputSourceRange: process.env.NODE_ENV !== 'production',
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
    }, this)
    // ...
}

其中,compileToFunctions返回的render函数就是最终生成虚拟Dom用的render函数,staticRenderFns为静态树优化,用于优化patch功能。

  • compileToFunctions

定义在platforms/web/compiler/index.js中:

const { compile, compileToFunctions } = createCompiler(baseOptions)

是由高阶函数createCompiler函数执行返回的。

  • createCompiler

定义在compilter/index.js文件中:

export const createCompiler = createCompilerCreator(function baseCompile(
    template: string,
    options: CompilerOptions
): CompiledResult {
    // 1. 生成ast
    const ast = parse(template.trim(), options)
    // 2. 针对ast进行优化
    if (options.optimize !== false) {
        optimize(ast, options)
    }
    // 生成render函数
    const code = generate(ast, options)
    return {
        ast,
        render: code.render,
        staticRenderFns: code.staticRenderFns
    }
})

是调用createCompilerCreator生成的,在调用时传入了编译核心函数baseCompile,该函数中将编译细化为三步:

  1. 生成ast。
  2. 优化ast
  3. 生成render函数
  • createCompilerCreator

定义在compiler/reate-compiler.js文件中,

export function createCompilerCreator(baseCompile: Function): Function {
    return function createCompiler(baseOptions: CompilerOptions) {
        // 编译函数:补充options,调用baseCompiler编译
        function compile(
            template: string,
            options?: CompilerOptions
        ): CompiledResult {
            // 1. 合并用户options和默认options,创建错误和提示数组对象。
            // 2. 调用baseCompile执行具体编译工作
            const compiled = baseCompile(template.trim(), finalOptions)
            // 3. 将编译过程中的错误和提示添加到编译结果上。
            return compiled
        }

        return {
            compile,
            compileToFunctions: createCompileToFunctionFn(compile)
        }
    }
}

其内部扩展了编译函数,添加了默认配置和错误收集,然后调用createCompileToFunctionFn生成最终的编译函数。

  • createCompileToFunctionFn

定义在compiler/to-function.js中:

export function createCompileToFunctionFn(compile: Function): Function {
    // 1. 添加编译结果缓存
    const cache = Object.create(null)
    return function compileToFunctions(
        template: string,
        options?: CompilerOptions,
        vm?: Component
    ): CompiledFunctionResult {
        // 2. 根据编译得到的render字符串调用new Function生成编译函数
        const res = {}
        const fnGenErrors = []
        res.render = createFunction(compiled.render, fnGenErrors)
        res.staticRenderFns = compiled.staticRenderFns.map(code => {
            return createFunction(code, fnGenErrors)
        })

        return (cache[key] = res)
    }
}

此方法继续扩展编译方法,提供了缓存和将render字符串转换成render函数功能。

ast语法

生成ast语法树:

const ast = parse(template.trim(), options)

其本质是调用parse函数将html字符串转换为普通js对象描述的树结构数据,内部调用的是http://erik.eae.net/simplehtmlparser/simplehtmlparser.js这个工具库,有兴趣的可以自己看一下。

优化ast

优化ast主要是找到并标记静态根节点,一旦标记静态根节点,那么就会带来两个好处:

  1. 把它们变成常数,这样我们就不需要了在每次重新渲染时为它们创建新的节点。
  2. 在patch过程中能够跳过这些静态根节点。

那么,什么是静态根节点呢?

静态根节点是指永远不会发生变化的Dom树,在Vuejs中,如果满足下面三个条件,就认为是静态根节点:

  1. 必须存在子节点。
  2. 如果子节点只有一个,该子节点不能是文本节点。
  3. 所有子节点都是静态节点(当数据发生变化的时候,节点不会发生改变)。
if (options.optimize !== false) {
    optimize(ast, options)
}

在编译的时候调用optimize函数执行具体的优化操作。

  • optimize

定义在compiler/optimize.js文件中:

export function optimize(root: ?ASTElement, options: CompilerOptions) {
    // ...
    // 1. 找到并标记所有的静态节点
    markStatic(root)
    // 2. 找到并标记所有静态根节点
    markStaticRoots(root, false)
}
  • markStatic
function markStatic (node: ASTNode) {
  // 1. 直接判断node节点是不是静态节点
  node.static = isStatic(node)
  if (node.type === 1) {
    if (
      !isPlatformReservedTag(node.tag) &&
      node.tag !== 'slot' &&
      node.attrsMap['inline-template'] == null
    ) {
      return
    }
    // 2. 遍历子节点, 如果子节点其中一个为非静态节点,那么修改本节点为非静态节点。
    for (let i = 0, l = node.children.length; i < l; i++) {
      const child = node.children[i]
      markStatic(child)
      if (!child.static) {
        node.static = false
      }
    }
    // 3. if节点的处理和第2步相同
    if (node.ifConditions) {
      for (let i = 1, l = node.ifConditions.length; i < l; i++) {
        const block = node.ifConditions[i].block
        markStatic(block)
        if (!block.static) {
          node.static = false
        }
      }
    }
  }
}

如何判断是否是静态节点?

简单来讲,如果数据变化的时候,该节点会发生变化,那么此节点就不是静态节点,感兴趣的可自行查看isStatic内部实现。

  • markStaticRoots

用于查找并标记所有的静态根节点,判断依据可以参考前面提到的如何断定一个节点是静态根节点。

function markStaticRoots(node: ASTNode, isInFor: boolean) {
    if (node.type === 1) {
        // 节点被标记为静态节点,说明所有子节点都为静态节点
        if (node.static || node.once) {
            node.staticInFor = isInFor
        }
        // 包含至少一个子节点,如果包含一个子节点,此节点不是文本节点
        if (node.static && node.children.length && !(
            node.children.length === 1 &&
            node.children[0].type === 3
        )) {
            node.staticRoot = true
            return
        } else {
            node.staticRoot = false
        }
        // 遍历所有子节点进行标记
        if (node.children) {
            for (let i = 0, l = node.children.length; i < l; i++) {
                markStaticRoots(node.children[i], isInFor || !!node.for)
            }
        }
        // 遍历所有if节点标记
        if (node.ifConditions) {
            for (let i = 1, l = node.ifConditions.length; i < l; i++) {
                markStaticRoots(node.ifConditions[i].block, isInFor)
            }
        }
    }
}

那么优化的静态根节点在实际过程中如何使用呢?

以下面的模版为例:

<div id="app">
    <span>
      <strong>文本</strong>
    </span>
    <span>{{msg}}</span>
  </div>

编译成render函数内部如下:

with (this) {
    return _c('div',
        { attrs: { "id": "app" } },
        [
            _m(0),
            _v(" "),
            _c('span', [_v(_s(msg))])
        ])
}

在render函数中_m(0)返回的虚拟Dom代表的就是静态根节点:

<span>
    <strong>文本</strong>
</span>

静态根节点的Vnode结果缓存在staticRenderFns中,_m函数就是根据元素索引去获取缓存的结果,这样每次调用_render生成虚拟Dom的时候就可以使用缓存,避免重复渲染。

生成render函数

首先生成render字符串

const code = generate(ast, options)

generate内部根据ast生成render函数字符串:

export function generate(
    ast: ASTElement | void,
    options: CompilerOptions
): CodegenResult {
    const state = new CodegenState(options)
    const code = ast ? genElement(ast, state) : '_c("div")'
    return {
        render: `with(this){return ${code}}`,
        staticRenderFns: state.staticRenderFns
    }
}

然后在createCompileToFunctionFn函数中调用createFunction函数将render函数字符串转换为render函数:

function createFunction(code, errors) {
    try {
        return new Function(code)
    } catch (err) {
        errors.push({ err, code })
        return noop
    }
}

至此,整个渲染过程大功告成。

下一节会探讨vuejs一些常用实例方法的实现方式。


carry
58 声望7 粉丝

学无止境