Vue 源码解析 - 模板编译

2020-04-12  本文已影响0人  Whyn

[TOC]

模板编译

前文在对 Vue 源码解析 - 主线流程 进行分析时,我们已经知道对于 Runtime + Compiler 的编译版本来说,Vue 在实例化前总共会经历两轮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<F: Function>(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函数进行源码查看:

// 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函数内部主要做了三件事:

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

// 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 = /^<!DOCTYPE [^>]+>/i
const comment = /^<!\--/
const conditionalComment = /^<!\[/
...
export function parseHTML(html, options) {
    ...
    let index = 0
    let last, lastTag
    while (html) {
        last = html
        // Make sure we're not in a plaintext content element like script/style
        if (!lastTag || !isPlainTextElement(lastTag)) {
            let textEnd = html.indexOf('<')
            if (textEnd === 0) {
                // Comment:
                if (comment.test(html)) {
                    ...
                    advance(commentEnd + 3)
                    continue
                    }
                }

                // http://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
                if (conditionalComment.test(html)) {
                    ...
                    advance(conditionalEnd + 2)
                    continue
                    }
                }

                // Doctype:
                const doctypeMatch = html.match(doctype)
                if (doctypeMatch) {
                    advance(doctypeMatch[0].length)
                    continue
                }

                // End tag:
                const endTagMatch = html.match(endTag)
                if (endTagMatch) {
                    const curIndex = index
                    advance(endTagMatch[0].length)
                    parseEndTag(endTagMatch[1], curIndex, index)
                    continue
                }

                // Start tag:
                const startTagMatch = parseStartTag()
                if (startTagMatch) {
                        ...
                        advance(1)
                    }
                    continue
                }
            }
            ...
            if (textEnd >= 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,其解析步骤大概如下所示:

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)的整个过程。

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

参考

上一篇下一篇

猜你喜欢

热点阅读