Vue 源码解析 - 模板编译

[TOC]

  • Vue 学习笔记
  • Vue 源码解析 - 主线流程
  • Vue 源码解析 - 模板编译
  • Vue 源码解析 - 组件挂载
  • Vue 源码解析 - 数据驱动与响应式原理

模板编译

前文在对 Vue 源码解析 - 主线流程 进行分析时,我们已经知道对于 Runtime + Compiler 的编译版本来说,Vue 在实例化前总共会经历两轮mount过程,分别为:

  • 定义于src\platforms\web\runtime\index.js$mount函数,主要负责组件挂载功能。

  • 定义于src\platforms\web\entry-runtime-with-compiler.js$mount函数,主要负责模板编译 + 组件挂载(其会缓存src\platforms\web\runtime\index.js中定义的$mount函数,最后的组件挂载转交给该函数进行处理)功能。

以下我们对src\platforms\web\entry-runtime-with-compiler.js$mount函数进行解析,主要分析 模板编译 部分内容:

// src/platforms/web/entry-runtime-with-compiler.js
const mount = Vue.prototype.$mount;
Vue.prototype.$mount = function (
    el?: string | Element,
    hydrating?: boolean,
): Component {
    // 获取 el 元素对象,找不到则返回一个 div
    el = el && query(el);
    ...
    const options = this.$options;
    // resolve template/el and convert to render function
    if (!options.render) {
        let template = options.template;
        if (template) {
            // Vue.$options.template 为字符串
            if (typeof template === 'string') {
                if (template.charAt(0) === '#') {
                    // 由 id 取得对应的 DOM 元素的 innerHTML
                    template = idToTemplate(template);
                    ...
                }
            } else if (template.nodeType) {
                template = template.innerHTML;
            } else {
                if (process.env.NODE_ENV !== 'production') {
                    warn('invalid template option:' + template, this);
                }
                return this;
            }
        } else if (el) { // 没有 template
            template = getOuterHTML(el);
        }
        if (template) {
            ...
            // 对模板进行编译
            const {render, staticRenderFns} = compileToFunctions(
                template,
                {
                    outputSourceRange: process.env.NODE_ENV !== 'production',
                    shouldDecodeNewlines,
                    shouldDecodeNewlinesForHref,
                    delimiters: options.delimiters,
                    comments: options.comments,
                },
                this,
            );
            options.render = render;
            options.staticRenderFns = staticRenderFns;
            ...
        }
    }
    return mount.call(this, el, hydrating);
};

function getOuterHTML(el: Element): string {
    if (el.outerHTML) {
        return el.outerHTML
    } else {
        const container = document.createElement('div')
        container.appendChild(el.cloneNode(true))
        return container.innerHTML
    }
}

const idToTemplate = cached(id => {
    const el = query(id)
    return el && el.innerHTML
})

// src/shared/util.js
export function cached(fn: F): F {
    const cache = Object.create(null)
    return (function cachedFn(str: string) {
        const hit = cache[str]
        return hit || (cache[str] = fn(str))
    }: any)
}

从源码中可以看到,只有在Options没有定义render函数时,才会进行模板编译。

模板编译步骤共分两步:

  1. 获取模板字符串:模板字符串的获取包含以下几种情况:

    • 如果没有定义template,则直接获取el元素的outerHTML,即把el元素作为template

    • 如果template为字符串,并且以#开头,则表明template是以id进行指定,则通过该id获取对应元素的innerHTML

      cached函数参数为一个函数,返回为一个参数为string的函数,在该返回函数内部会调用cached函数的函数参数,并做一个缓存处理。
      对应于我们编译这部分,即会缓存以id进行声明的templateinnerHTML

    • 如果template为字符串,并且不以#开头,则表明template是一个完整的模板字符串,直接返回本身即可。

    • 如果templatenodeType类型,直接返回其innerHTML

    • 如果定义了template,但格式无法识别(即不是字符串,也不是nodeType类型),则给出警告,并退出编译流程。

  2. 将模板字符串编译为render函数:该功能主要由函数compileToFunctions进行实现,其源码如下所示:

// src/compiler/index.js
export const createCompiler = createCompilerCreator(function baseCompile (...): CompiledResult {...})

// src/platforms/web/compiler/index.js
const { compile, compileToFunctions } = createCompiler(baseOptions)

compileToFunctions是由createCompiler(baseOptions)返回的,而createCompilercreateCompilerCreator(function baseCompile (...){...}),这里其实使用了 函数柯里化 的思想,将接收多个参数的函数转化为接收单一参数的函数,这样做的原因是 编译 这个流程和平台或构建方式相关,采用 函数柯里化,将与平台无关的东西固定化,只留出平台相关的内容作为参数,简化调用。比如,这里固定化参数为baseCompile,其主要负责模板的解析,优化并最终生成模板代码的字符串(具体详情见后文),该操作是平台无关操作,而与平台相关的参数为baseOptions,不同的平台该参数不同。

简而言之,compileToFunctions会经由createCompilerCreator(function baseCompile (...){...}) --> createCompiler(baseOptions)而得到。

因此,我们先来看下createCompilerCreator(function baseCompile (...){...})的源码实现:

// src/compiler/index.js
export const createCompiler = createCompilerCreator(function baseCompile (...){...})

// src\compiler\create-compiler.js
export function createCompilerCreator (baseCompile: Function): Function {
  return function createCompiler (baseOptions: CompilerOptions) {

    function compile (...): CompiledResult {
       ...
      const compiled = baseCompile(template.trim(), finalOptions)
      ...
    }

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

所以createCompilerCreator就是固定了参数baseCompile,并返回一个函数createCompiler,该函数内部又会返回一个包含两个函数的实例,这其中就有一个我们需要分析的函数compileToFunctions(这个就是$mount函数内部使用的createCompileToFunctionFn),其指向为函数createCompileToFunctionFn(compile)的执行结果,我们先对函数createCompileToFunctionFn源码进行查看:

// src/compiler/to-function.js
export function createCompileToFunctionFn(compile: Function): Function {
    ...
    return function compileToFunctions(...): CompiledFunctionResult {...}
}

可以看到又是一个 函数柯里化 的操作,固定了平台无关参数compile,并返回了我们最终需要的compileToFunctions函数。

compileToFunctions函数获取这部分的代码由于采用了多个 函数柯里化 操作,导致代码逻辑比较混乱,下面是该部分代码的整个调用链:

// src/platforms/web/entry-runtime-with-compiler.js
const {render, staticRenderFns} = compileToFunctions(template, {...}, this)

// src/platforms/web/compiler/index.js
const {compile, compileToFunctions} = createCompiler(baseOptions)

// src/compiler/index.js
export const createCompiler = createCompilerCreator(function baseCompile(...) {...})

// src/compiler/create-compiler.js
export function createCompilerCreator(baseCompile: Function): Function {
    return function createCompiler(baseOptions: CompilerOptions) {

        function compile(...): CompiledResult {...}

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

// src/compiler/to-function.js
export function createCompileToFunctionFn(compile: Function): Function {
    return function compileToFunctions(...): CompiledFunctionResult {
          ...
        const compiled = compile(template, options)
        ...
    }
}

可以看到,compileToFunctions的获取调用链为:createCompilerCreator --> createCompiler --> createCompileToFunctionFn --> compileToFunctions

到这里我们才理清了compileToFunctions函数的定义出处,现在回到主线流程,看下compileToFunctions是怎样具体编译出render函数:

// src/compiler/to-function.js
export function createCompileToFunctionFn(compile: Function): Function {
    const cache = Object.create(null)

    return function compileToFunctions(
        template: string,
        options?: CompilerOptions,
        vm?: Component
    ): CompiledFunctionResult {
        ...
        // check cache
        const key = options.delimiters
            ? String(options.delimiters) + template
            : template
        if (cache[key]) {
            return cache[key]
        }

        // compile
        const compiled = compile(template, options)
        ...
        // turn code into functions
        const res = {}
        const fnGenErrors = []
        res.render = createFunction(compiled.render, fnGenErrors) // 生成渲染函数
        res.staticRenderFns = compiled.staticRenderFns.map(code => {
            return createFunction(code, fnGenErrors)
        })
        ...
        return (cache[key] = res)
    }
}

function createFunction(code, errors) {
    try {
        return new Function(code) // 将字符串 code 渲染成函数
    } catch (err) {
        errors.push({err, code})
        return noop
    }
}

所以当我们调用compileToFunctions时,其会做如下三件事:

  • 模板编译:通过函数compile进行编译。

  • 生成渲染函数:通过函数createFunction将编译完成的模板生成相应的渲染函数(其实就是使用Function构造函数将编译完成的模板代码字符串转换成函数)。

  • 缓存渲染函数:依据模板字符串内容作为键值,缓存其编译结果。

这里面最核心的就是 模板编译 步骤,目的就是编译出模板对应的渲染函数字符串。
我们着重对这步进行分析,对compile函数进行源码查看:

// src/compiler/create-compiler.js
export function createCompilerCreator(baseCompile: Function): Function {
    return function createCompiler(baseOptions: CompilerOptions) {
        function compile(
            template: string,
            options?: CompilerOptions
        ): CompiledResult {
            const finalOptions = Object.create(baseOptions)
            ...
            const compiled = baseCompile(template.trim(), finalOptions)
            ...
            return compiled
        }
        ...
    }
}

compile内部会将编译过程交由参数baseCompile进行实际处理,而根据我们前面的分析,baseCompile就是函数createCompilerCreator采用 函数柯里化 固定的平台无关的参数,其源码如下所示:

// src/compiler/index.js
function baseCompile(
    template: string,
    options: CompilerOptions
): CompiledResult {
    const ast = parse(template.trim(), options)
    if (options.optimize !== false) {
        optimize(ast, options)
    }
    const code = generate(ast, options)
    return {
        ast,
        render: code.render,
        staticRenderFns: code.staticRenderFns
    }
}

因此,$mount函数内部的compileToFunctions函数最终调用的就是baseCompile函数进行模板编译流程。

从源码中可以看到,baseCompile函数内部主要做了三件事:

  • 模板编译:由函数parse进行模板编译,并生成抽象语法树 AST

  • 优化 AST:由函数optimize负责。

  • 生成代码:由函数generate负责根据 AST 和编译选项生成相应代码(字符串形式)。

我们下面针对这三个过程继续进行分析:

  • 模板编译:编译过程的第一步就是解析模板字符串,生成抽象语法树 AST。
    我们进入parse函数,查看其源码:
// src/compiler/parser/index.js
/**
 * Convert HTML string to AST.
 */
export function parse(
    template: string,
    options: CompilerOptions
): ASTElement | void {
    ...
    let root
    ...
    parseHTML(template, {
        ...
        start(tag, attrs, unary, start, end) {
            ...
            let element: ASTElement = createASTElement(tag, attrs, currentParent)
            ...
            if (!root) {
                root = element
                ...
            }
            ...
        },

        end(tag, start, end) {
            const element = stack[stack.length - 1]
            // pop stack
            stack.length -= 1
            currentParent = stack[stack.length - 1]
            ...
            closeElement(element)
        },

        chars(text: string, start: number, end: number) {
            ...
            parseText(text, delimiters)
            ...
            children.push(child)
            ...
        },
        comment(text: string, start, end) {
            if (currentParent) {
                ...
                currentParent.children.push(child)
            }
        }
    })
    return root
}

parse函数最终通过调用函数parseHTML对模板进行解析,查看parseHTML源码:

// src/compiler/parser/html-parser.js
// Regular Expressions for parsing tags and attributes
...
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const doctype = /^]+>/i
const comment = /^= 0) {
                rest = html.slice(textEnd)
                while (
                    !endTag.test(rest) &&
                    !startTagOpen.test(rest) &&
                    !comment.test(rest) &&
                    !conditionalComment.test(rest)
                ) {
                    // < in plain text, be forgiving and treat it as text
                    ...
                text = html.substring(0, textEnd)
            }
            ...
            advance(text.length)
            ...
        } else {
            ...
            parseEndTag(stackedTag, index - endTagLength, index)
        }
        ...
    }

    // Clean up any remaining tags
    parseEndTag()

    function advance(n) {
        index += n
        html = html.substring(n)
    }
    ...
}

简单来说,parseHTML函数采用正则表达式来解析模板template,其解析步骤大概如下所示:

  • 首先获取模板字符<的索引位置,如果索引为0,则表明当前模板以<开头,则使用正则依次判断template是否匹配CommentconditionalCommentDoctypeEnd Tag还是Start Tag,匹配完成后,依据不同的匹配标签进行各自的解析,比如,对于Comment标签,则会进行如下解析:

    // Comment:
    if (comment.test(html)) {
        const commentEnd = html.indexOf('-->')
    
        if (commentEnd >= 0) {
            ...
            options.comment(html.substring(4, commentEnd), index, index + commentEnd + 3)
            ...
            advance(commentEnd + 3)
            continue
        }
    }
    

    即如果template匹配Comment标签,则找到-->的索引位置,即找到注释标签的末尾位置,然后取出注释内容html.substring(4,commentEnd),交由options.comment函数进行处理(options.comment其实是parse函数内调用parseHTML传递进行的options.comment,因此,这里其实起一个回调作用,parse函数内就可以通过回调获取当前模板解析得到的注释节点的内容,从而可以进行处理或保存),解析完成后会通过advance函数将当前template的字符串进行截取,只保留还未进行解析的内容。

    其他节点解析处理与上述操作逻辑类似,均是根据节点特点进行解析,然后通过advance函数去除已解析的内容,只保留未解析的模板字符串,继续新一轮的解析。

    举个栗子:比如对于如下模板:

    template: `

    Hi, {{message}}

    `

    其解析流程如下图所示:

Vue 源码解析 - 模板编译_第1张图片
parseHTML

parseHTML每次解析完成一个节点时,就会将结果回调给parse函数,parse函数就可以根据这些结果进行抽象语法树(AST)的构建,其实质就是构建一个javascript对象,比如,上述模板构建得到的 AST 如下所示:

{
"type": 1,
"tag": "h2",
"attrsList": [],
"attrsMap": {
  "style": "color:red"
},
"rawAttrsMap": {
  "style": {
    "name": "style",
    "value": "color:red",
    "start": 4,
    "end": 21
  }
},
"children": [
  {
    "type": 2,
    "expression": "\"Hi, \"+_s(message)",
    "tokens": [
      "Hi, ",
      {
        "@binding": "message"
      }
    ],
    "text": "Hi, {{message}}",
    "start": 22,
    "end": 37
  }
],
"start": 0,
"end": 42,
"plain": false,
"staticStyle": "{\"color\":\"red\"}"
}

到这里,我们就大概了解了模板字符串template解析成抽象语法树(AST)的整个过程。

  • 优化 AST:当完成 AST 的构建后,就可以对 AST 进行一些优化。

    :Vue 之所以有 优化 AST 这个过程,主要是因为 Vue 的特性之一是 数据驱动,并且数据具备响应式功能,因此,当更改数据的时候,模板会重新进行渲染,显示最新的数据。但是,模板中并不是所有的节点都需要进行重新渲染,对于不包含响应式数据的节点,从始至终只需一次渲染即可。

    我们进入optimize函数,查看其源码:

    // src/compiler/optimizer.js
    /**
    * Goal of the optimizer: walk the generated template AST tree
    * and detect sub-trees that are purely static, i.e. parts of
    * the DOM that never needs to change.
    *
    * Once we detect these sub-trees, we can:
    *
    * 1. Hoist them into constants, so that we no longer need to
    *    create fresh nodes for them on each re-render;
    * 2. Completely skip them in the patching process.
    */
    export function optimize(root: ?ASTElement, options: CompilerOptions) {
        ...
        // first pass: mark all non-static nodes.
        markStatic(root)
        // second pass: mark static roots.
        markStaticRoots(root, false)
    }
    

    optimize主要就是做了两件事:

    • markStatic:标记静态节点。其源码如下:
    // src/compiler/optimizer.js
    function markStatic(node: ASTNode) {
        node.static = isStatic(node)
        if (node.type === 1) {
            ...
            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
                    }
                }
            }
        }
    }
    

    parse函数解析生成的抽象语法树 AST 中,其元素节点总有三种类型,如下所示:

    type 类型
    1 普通元素
    2 表达式(expression)
    3 文本(text)

    从源码中可以看到,markStatic函数会对当前节点,以及当前节点的子节点和v-if的子节点进行静态节点标记,静态节点的判定标准由函数isStatic判定:

    // src/compiler/optimizer.js
    function isStatic(node: ASTNode): boolean {
        if (node.type === 2) { // expression
            return false
        }
        if (node.type === 3) { // text
            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)
        ))
    }
    

    可以看到,静态节点的判定标准为:纯文本类型 或者 没有动态绑定且没有v-if且没有v-for指令且不是内置slot/component且是平台保留标签且不是带有v-for指令的template标签的直接子节点且节点的所有属性都是静态key
    当满足静态节点的判定时,就会为该节点打上static=true属性,作为标记。

    • markStaticRoots:标记静态根节点。其源码如下:
    // src/compiler/optimizer.js
    function markStaticRoots(node: ASTNode, isInFor: boolean) {
        if (node.type === 1) {
            ...
            // 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(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)
                }
            }
        }
    }
    

    根节点即为普通元素节点,从源码中可以看到,markStaticRoots函数会对当前节点,以及当前节点的子节点和带有v-if的子节点进行静态根节点标记。
    静态根节点的判定标准为:节点为静态节点,且其有子节点,并且子节点不能只是一个文本节点。
    当满足静态根节点的判定时,就会为该节点打上staticRoot=true属性,作为标记。

    因此,Vue 对模板解析生成的 AST 的优化就是对 AST 元素节点进行静态节点和静态根节点的标记,以避免重新渲染静态节点元素。

  • 生成代码:当对 AST 进行优化后,编译的最后一步就是将优化过后的 AST 树转换成可执行代码。

    我们进入generate函数,查看其源码:

    // src/compiler/codegen/index.js
    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
        }
    }
    

    generate函数内部主要就是调用了函数genElement对 AST 树进行解析并生成对应可执行代码,最后返回一个对象,该对象含有两个函数renderstate.staticRenderFns,其中,render函数会封装一下genElement函数生成的代码。

    我们下面对genElement函数进行分析,看下其代码生成逻辑:

    // src/compiler/codegen/index.js
    export function genElement(el: ASTElement, state: CodegenState): string {
        ...
        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)
                ...
            }
            ...
            return code
        }
    }
    

    可以看到,genElement函数内部会依据 AST 树的节点类别分别调用不同的函数生成对应的代码,比如:

    • 对于静态根节点,使用genStatic函数进行代码生成。
    • 对于带有v-once指令的节点,使用genOnce函数进行代码生成。
    • 对于带有v-for指令的节点,使用genFor函数进行代码生成。
    • 对于带有v-if指令的节点,使用genIf函数进行代码生成。
    • 对于不带slot指令的template标签,会使用genChildren函数遍历其子节点并进行代码生成。
    • 对于slot标签,使用genSlot函数进行代码生成。
    • 对于组件或元素,使用genComponent等函数进行代码生成。
      ...

    我们这里就随便找一个函数简单分析下代码生成的具体逻辑,比如:genStatic,其源码如下所示:

    // src/compiler/codegen/index.js
    function genStatic(el: ASTElement, state: CodegenState): string {
        ...
        state.staticRenderFns.push(`with(this){return ${genElement(el, state)}}`)
        ...
        return `_m(${
            state.staticRenderFns.length - 1
            }${
            el.staticInFor ? ',true' : ''
            })`
    }
    

    其实最终就是使用字符串拼接将对应元素的内容转换为字符串代码。

    举个栗子:比如我们构造一个静态根节点的模板,如下所示:

    template: `
        

    Hi

    `

    最后,经过genStatic后,最终生成的代码为:_m(0)
    所以上述模板最终经过generate函数后,生成的代码如下:

    {
        "render": "with(this){return _m(0)}",
        "staticRenderFns": [
                "with(this){return _c('div',[_c('h2',{staticStyle:{\"color\":\"red\"}},[_v(\"Hi\")])])}"
            ]
    }
    

    _m函数其实是renderStatic函数,Vue 中还设置了_o_l_v等函数,如下所示:

    // src/core/instance/render-helpers/index.js
    export function installRenderHelpers (target: any) {
      target._o = markOnce
      target._n = toNumber
      target._s = toString
      target._l = renderList
      target._t = renderSlot
      target._q = looseEqual
      target._i = looseIndexOf
      target._m = renderStatic
      target._f = resolveFilter
      target._k = checkKeyCodes
      target._b = bindObjectProps
      target._v = createTextVNode
      target._e = createEmptyVNode
      target._u = resolveScopedSlots
      target._g = bindObjectListeners
      target._d = bindDynamicKeys
      target._p = prependModifier
    }
    

    其余的代码生成函数就不进行分析了,大概的原理就是根据不同节点特征,在 AST 树中获取需要的数据,拼接成可执行代码的字符串代码。

到这里,模板编译的一个大概完整过程便完成了。

参考

  • 编译

你可能感兴趣的:(Vue 源码解析 - 模板编译)