Vue源码解读:04模板编译篇

567 阅读8分钟

Vue源码解读:04模板编译篇

目录

image.png

第一节·先看目录结构

本篇研究的代码位置:src/compiler
├─compiler                  # 实现模板编译的代码
│  ├─codegen               # 代码生成器,将ast树生成render渲染函数。
│  │  ├─events.js         # 事件处理相关代码,例如键盘事件,stopPropagation()事件等。
│  │  └─index.js          #
│  │
│  ├─directives            # 处理相关指令的代码,例如v-model,v-bind,v-on等。
│  │  ├─bind.js           # v-bind指令相关代码   
│  │  ├─index.js          #
│  │  ├─model.js          # v-model指令相关代码   
│  │  └─on.js             # v-on指令相关代码  
│  │
│  ├─parser                # 模板解析器,将模板字符串转换成ast树
│  │  ├─entity-decoder.js # 实体解码器,返回元素节点的文本内容 
│  │  ├─filter-parser.js  # 过滤解析器 
│  │  ├─html-parser.js    # html解析器 
│  │  ├─index.js          #
│  │  └─text-parser.js    # 文本解析器 
│  │
│  ├─codeframe.js          #
│  ├─create-compiler.js    # 创建模板编译器相关代码 
│  ├─error-detector.js     # 错误检测器 
│  ├─helpers.js            # 辅助代码,里面引入过滤解析器和工具类util里面的emptyObject。
│  ├─index.js              #
│  ├─optimizer.js          #  优化器,优化ast树,主要是标记静态节点
│  └─to-function.js        #  将render函数字符串转换成真正的函数

第二节·什么是模板编译

从写下的代码,到用户见到界面,大致经历以下几个流程:

模板字符串=》ast树=》render函数=》vnode=》用户见到的界面。

image.png

将开发者写下的模板字符串,经过一些列处理,生成render函数的这一过程就是模板编译。简单地说,模板编译解析就是为了得到render函数。

第三节·模板编译流程

1.模板编译的整体流程

从目录结构中,我们看到compiler下有一个parser和codegen的文件夹,这两个文件夹的内容就是模板编译的核心代码,parser负责将模板字符串解析成AST树,codegen则是负责将AST树生成可渲染的render函数。当然其中还有一个优化阶段,优化的内容主要是标记AST树中的静态节点,这部分代码则放在optimizer.js文件中。将开发者写下的模板字符串,编译解析成render函数的过程,我们称之为模板编译。大致的,我们将模板编译流程分为模板解析阶段,优化阶段,代码生成阶段,三个阶段。

模板编译的整体流程,大致如下图:

image.png

看下模板编译的入口文件index.js。

//源码位置 src/compiler/index.js

export const createCompiler = createCompilerCreator(function baseCompile (
    template: string,
    options: CompilerOptions
): CompiledResult {
    const ast = parse(template.trim(), options)//解析器,解析模板字符串,生成AST树
    if (options.optimize !== false) {
        optimize(ast, options)//优化器,对代码优化,主要是标记静态节点
    }
    const code = generate(ast, options)//代码生成器,将ast树转换成render渲染函数
    return {
        ast,
        render: code.render,
        staticRenderFns: code.staticRenderFns
    }
})

模板编译的入口文件代码不多,但也很清晰地将模板编译的三个阶段变现出来了。

2.模板编译的三个阶段

①解析阶段

模板解析阶段,解析模板字符串,并输出ast树,看下模板解析的入口文件src/compiler/parser/index.js。

image.png

源码中的注释就说得很清楚明确,主函数parse的作用就是讲html转换为ast树,解析流程大致是这样的,调用parserHTML函数,对模板进行解析,主要是解析模板中的原生html。在解析过程遇到文本信息,则调用parseText函数进行解析,遇到过滤器则调用parseFilters函数解析。大致流程如下。

image.png

模板解析的关键手段。正则表达式和js提供的字符串的方法,是解析模板字符串的核心手段,例如识别是原生html标签,还是文本内容,指令的识别,例如v-on,v-bind,v-if,v-for等。还有错误检查的作用,例如缺少结束标签等。

感受一下src/compiler/parser/index.js中使用的正则表达式。

image.png

模板解析的本质。前文已经提到,解析器的作用是将模板字符串生成ast树,ast树其实和vnode很相似,都是用js来描述的。感受一下解析前后对比。

// 解析前模板字符串
<div>
    <p>{{name}}</p>
</div>

//解析后的的ast树
{
    tag: "div"
    type: 1,
        staticRoot: false,
    static: false,
    plain: true,
    parent: undefined,
    attrsList: [],
    attrsMap: {},
    children: [
        {
            tag: "p"
            type: 1,
            staticRoot: false,
            static: false,
            plain: true,
            parent: {tag: "div", ...},
            attrsList: [],
            attrsMap: {},
            children: [{
                type: 2,
                text: "{{name}}",
                static: false,
                expression: "_s(name)"
            }]
        }
    ]
}

看过前后对比,我们可以试想一下,要是自己有没有办法做一个转换呢。没有什么是无理由的凭空出现的。我粗浅的下个定义,所有的解析器本质上都是基于原物做识别和截取。如果这种解析有输出,那么通常会识别和截取相结合,而解析的输出物就是基于原物的一种增删改查。

没错,ast就是基于模板字符串,截取出来,然后根据需要,进行自定义的增删改查得来的。源码中识别和截取的手段就是字符串的方法结合正则表达式。比如截取标签名。

let html = '<div><p>{{name}}</p></div>'
let tagNameStart = html.indexOf('<')
let tagNameEnd = html.indexOf('>')

let tagName = html.substring(tagNameStart+1, tagNameEnd)
console.log(tagName)

image.png

②优化阶段

优化的意义。优化阶段的目标是标记静态节点,而标记静态节点的意义在于,减小开销,优化性能。原本ast是可以直接生成render函数的了,但是为了性能,还是加了一个优化器进行优化,给vue点赞。标记静态节点,是如何与性能优化挂钩的呢,这在虚拟dom篇章中有解释,其实就是服务于虚拟dom的patch过程的,patch过程遇到静态节点,会跳过新旧vnode的比较过程,从而减小开销。

优化阶段做的两件事。优化阶段主要是做两件事,一是标记静态节点,而是标记静态根节点。看下源码。

//源码位置 src/compiler/optimizer.js

export function optimize (root: ?ASTElement, options: CompilerOptions) {
    if (!root) return
    isStaticKey = genStaticKeysCached(options.staticKeys || '')
    isPlatformReservedTag = options.isReservedTag || no
    // first pass: mark all non-static nodes.
    //第一部分:标记所有静态节点
    markStatic(root)
    // second pass: mark static roots.
    //第二部分:标记根节点。
    markStaticRoots(root, false)
}

标记静态节点。标记静态节点大致流程是调用先标记根节点是否静态节点,然后根据节点的类型(node.type,type为1表示元素节点,2表示包含动态变量的文本节点,3表示纯文本节点)判断,如果是元素节点则递归调用markStatic方法,继续标记,直到递归结束,遍历标记完整个AST树。看下源码。

function markStatic (node: ASTNode) {
    node.static = isStatic(node)//标记根节点
    if (node.type === 1) {//type为1表示元素节点,2表示包含动态变量的文本节点,3表示纯文本节点
        // do not make component slot content static. this avoids
        // 1. components not able to mutate slot nodes
        // 2. static slot content fails for hot-reloading
        if (
            !isPlatformReservedTag(node.tag) &&
            node.tag !== 'slot' &&
            node.attrsMap['inline-template'] == null
        ) {
            return
        }
        for (let i = 0, l = node.children.length; i < l; i++) {
            const child = node.children[i]
            markStatic(child)//递归标记子节点
            if (!child.static) {
                node.static = false
            }
        }
        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
                }
            }
        }
    }
}

function isStatic (node: ASTNode): boolean {
    if (node.type === 2) { // expression 包含变量的文本节点,不属于静态节点,返回false
        return false
    }
    if (node.type === 3) { // text 纯文本节点,属于静态节点,返回true
        return true
    }

    //元素节点,则须符合一定条件,才能成为静态节点。
    return !!(node.pre || (
        !node.hasBindings && // no dynamic bindings
        !node.if && !node.for && // not v-if or v-for or v-else
        !isBuiltInTag(node.tag) && // not a built-in
        isPlatformReservedTag(node.tag) && // not a component
        !isDirectChildOfTemplateFor(node) &&
        Object.keys(node).every(isStaticKey)
    ))
}

静态节点标记流程大致如下图所示。

image.png

标记静态根节点。关于静态根节点的条件,源码的写得很清楚,第一,必须是元素节点;第二,节点本身必须是静态节点;第三,有子节点;第四,不能只有静态文本的子节点。

function markStaticRoots (node: ASTNode, isInFor: boolean) {
    if (node.type === 1) {
        if (node.static || node.once) {
            node.staticInFor = isInFor
        }
        // For a node to qualify as a static root, it should have children that
        // are not just static text. Otherwise the cost of hoisting out will
        // outweigh the benefits and it's better off to just always render it fresh.
        //要使节点符合静态根节点的条件,它应该有且不只是静态文本的子节点。
        // 否则,标记的成本将超过效益,最好还是让它更新。
        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进行标记
                markStaticRoots(node.children[i], isInFor || !!node.for)
            }
        }
        if (node.ifConditions) {
            for (let i = 1, l = node.ifConditions.length; i < l; i++) {
                markStaticRoots(node.ifConditions[i].block, isInFor)
            }
        }
    }
}

③代码生成阶段

代码生成阶段做的事。

代码生成阶段的做的事情就是将ast树,转换成render函数代码。这一点在在代码生成器的主函数generate的返回值就可以清晰地看到。

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}}`,//返回render函数代码
        staticRenderFns: state.staticRenderFns
    }
}

generate生成器函数的入参是ast树和编译器配置项,没错,这个ast就是优化阶段的输出,而代码生成器的输出结果集,返回的的就是render函数代码。在return前有一个判断,判断ast是否为空,非空则调用genElement生成节点;空则返回'_c("div")',实质是返回一个空div的vnode。

没错模板编译和虚拟dom的关联性极大,触发vnode的产生时机就是在代码生成阶段。generate 函数中起主要作用的是genElement,genElement的作用是生成vnode。

genElement根据入参,调用不同的代码生成器,比如静态节点生成器genStatic、组件生成器genComponent等,生成不同的vnode。genElement调用的众多生成器函数中,其中有一个生成器叫genChildren,这算是核心的了,这是在遇到标签名为template的元素的时候调用的。而genChildren又会调用genNode生成节点。在虚拟dom篇中有提到真正会挂载到dom上的节点,只有三种,即为元素节点,文本节点,注释节点。所以我们可以看到节点生成器的源码中,就是通过判断node的type等属性,来决定生成这三种节点中的哪种节点。源码如下。

export function genElement (el: ASTElement, state: CodegenState): string {
    if (el.parent) {
        el.pre = el.pre || el.parent.pre
    }

    if (el.staticRoot && !el.staticProcessed) {
        return genStatic(el, state)
    } else if (el.once && !el.onceProcessed) {
        return genOnce(el, state)
    } else if (el.for && !el.forProcessed) {
        return genFor(el, state)
    } else if (el.if && !el.ifProcessed) {
        return genIf(el, state)
    } else if (el.tag === 'template' && !el.slotTarget && !state.pre) {
        return genChildren(el, state) || 'void 0'
    } else if (el.tag === 'slot') {
        return genSlot(el, state)
    } else {
        // component or element
        let code
        if (el.component) {
            code = genComponent(el.component, el, state)
        } else {
            let data
            if (!el.plain || (el.pre && state.maybeComponent(el))) {
                data = genData(el, state)
            }

            const children = el.inlineTemplate ? null : genChildren(el, state, true)
            code = `_c('${el.tag}'${
                data ? `,${data}` : '' // data
            }${
                children ? `,${children}` : '' // children
            })`
        }
        // module transforms
        for (let i = 0; i < state.transforms.length; i++) {
            code = state.transforms[i](el, code)
        }
        return code
    }
}

function genNode (node: ASTNode, state: CodegenState): string {
    if (node.type === 1) {//生成元素节点
        return genElement(node, state)
    } else if (node.type === 3 && node.isComment) {//生成注释节点
        return genComment(node)
    } else {//生成文本节点
        return genText(node)
    }
}

有了代码生成器,我们就可以得到render函数代码,其实这里render并不是真正意义上的函数。这里可cue一下,模板编译文件夹的最后一个文件to-function.js了,没错,就是它将代码生成器输出的render代码串,转换成真正的render函数,一个function。Vue实例在挂载的时候,会调用对应的render函数来生成实例上的template选项所对应的VNode,没错,这个Vue调用render函数就是to-function的输出。

第四节·篇章小结

①模板编译的定义:将开发者写下的模板字符串,经过一些列处理,生成render函数的这一过程就是模板编译。

②模板编译的整体流程:解析器将模板字符串解析成ast树,然后优化器对ast进行标记静态节点的优化,最后代码生成器将优化后的ast树生成render函数。

③介绍模板编译三个阶段:解析阶段,优化阶段,代码生成阶段的工作过程,并回看了源码。