头部背景图片
吉水于人的笔记 |
吉水于人的笔记 |

vue内部运行机制源码分析——模版编译

模版编译流程

vue源码中虚拟dom构建过程经历了template编译成AST语法树->转换为render函数,最终返回一个VNode。

compile编译过程分为parseoptimizegenerate三个阶段,最终得到render function

  • parse:用正则表达式的方式解析template模版的指令、class、style等数据,形成AST(abstract syntax tree,抽象语法树),是源代码的抽象语法结构的树状表现形式。
  • optimize:此步骤主要是优化性能,标记static静态节点,当update更新界面时,会有一个patch的过程,diff算法会直接跳过该静态节点,从而减少比较的过程,优化patch的性能。
  • generate:将AST转化为render function字符串的过程,得到的结果是render字符串及staticRenderFns字符串。

源码解析

(1)parse

parse用正则方式将template模版进行字符串解析,得到指令、class、style等数据,形成AST。关于正则表达式如何写,在以前的笔记中有讲述,下面主要列举parse过程中用到的正则表达式。

const ncname = '[a-zA-Z_][\\w\\-\\.]*';
const singleAttrIdentifier = /([^\s"'<>/=]+)/
const singleAttrAssign = /(?:=)/
const singleAttrValues = [
  /"([^"]*)"+/.source,
  /'([^']*)'+/.source,
  /([^\s"'=<>`]+)/.source
]
const attribute = new RegExp(
  '^\\s*' + singleAttrIdentifier.source +
  '(?:\\s*(' + singleAttrAssign.source + ')' +
  '\\s*(?:' + singleAttrValues.join('|') + '))?'
)

const qnameCapture = '((?:' + ncname + '\\:)?' + ncname + ')'
const startTagOpen = new RegExp('^<' + qnameCapture)
const startTagClose = /^\s*(\/?)>/

const endTag = new RegExp('^<\\/' + qnameCapture + '[^>]*>')

const defaultTagRE = /\{\{((?:.|\n)+?)\}\}/g

const forAliasRE = /(.*?)\s+(?:in|of)\s+(.*)/

a. advance

我们解析template采用循环进行字符串匹配的方式,所以每解析完一段字符就将已经匹配的去掉,头部的指针指向接下来需要匹配的部分。


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

b.parseHTML

首先我们需要定义个 parseHTML函数,在里面我们循环解析 template 字符串。parseHTML 会用 while 来循环解析 template 字符串,用正则在匹配到标签头、标签尾以及文本的时候分别进行不同的处理。直到整个template 被解析完毕。

function parseHTML () {
    while(html) {
        let textEnd = html.indexOf('<');
        if (textEnd === 0) {
            if (html.match(endTag)) {
                //...process end tag
                continue;
            }
            if (html.match(startTagOpen)) {
                //...process start tag
                continue;
            }
        } else {
            //...process text
            continue;
        }
    }
}

c. 解析template标签

parseStartTag用来解析起始标签

function parseStartTag () {
    const start = html.match(startTagOpen);
    if (start) {
        const match = {
            tagName: start[1],
            attrs: [],
            start: index
        }
        advance(start[0].length);

        let end, attr
        while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
            advance(attr[0].length)
            match.attrs.push({
                name: attr[1],
                value: attr[3]
            });
        }
        if (end) {
            match.unarySlash = end[1];
            advance(end[0].length);
            match.end = index;
            return match
        }
    }
}

接下来使用 startTagClose 与 attribute 两个正则分别用来解析标签结束以及标签内的属性。这段代码用 while 循环一直到匹配到 startTagClose 为止,解析内部所有的属性。

let end, attr
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
    advance(attr[0].length)
    match.attrs.push({
        name: attr[1],
        value: attr[3]
    });
}
if (end) {
    match.unarySlash = end[1];
    advance(end[0].length);
    match.end = index;
    return match
}

d. stack

此外,我们需要维护一个 stack栈来保存已经解析好的标签头,这样我们可以根据在解析尾部标签的时候得到所属的层级关系以及父标签。同时我们定义一个 currentParent变量用来存放当前标签的父标签节点的引用, root变量用来指向根标签节点。

const stack = [];
let currentParent, root;

知道这个以后,我们优化一下 parseHTML ,在 startTagOpen 的 if逻辑中加上新的处理。

我们将 startTagMatch 得到的结果首先封装成 element,这个就是最终形成的AST的节点,标签节点的type 为 1。

if (html.match(startTagOpen)) {
    const startTagMatch = parseStartTag();
    const element = {
        type: 1,
        tag: startTagMatch.tagName,
        lowerCasedTag: startTagMatch.tagName.toLowerCase(),
        attrsList: startTagMatch.attrs,
        attrsMap: makeAttrsMap(startTagMatch.attrs),
        parent: currentParent,
        children: []
    }

    if(!root){
        root = element
    }

    if(currentParent){
        currentParent.children.push(element);
    }

    stack.push(element);
    currentParent = element;
    continue;
}

然后让 root指向根节点的引用。

if(!root){
    root = element
}

接着我们将当前节点的 element放入父节点 currentParent 的 children数组中。

if(currentParent){
    currentParent.children.push(element);
}

最后将当前节点 element 压入 stack 栈中,并将 currentParent 指向当前节点,因为接下去下一个解析如果还是头标签或者是文本的话,会成为当前节点的子节点,如果是尾标签的话,那么将会从栈中取出当前节点,这种情况我们接下来要讲。

stack.push(element);
currentParent = element;
continue;

其中的 makeAttrsMap 是将 attrs 转换成map格式的一个方法。


function makeAttrsMap (attrs) {
    const map = {}
    for (let i = 0, l = attrs.length; i < l; i++) {
        map[attrs[i].name] = attrs[i].value;
    }
    return map
}

parseEndTag
同样,我们在parseHTML中加入对尾标签的解析函数,为了匹配如“”。

const endTagMatch = html.match(endTag)
 if (endTagMatch) {
    advance(endTagMatch[0].length);
    parseEndTag(endTagMatch[1]);
    continue;
}

用 parseEndTag来解析尾标签,它会从 stack 栈中取出最近的跟自己标签名一致的那个元素,将 currentParent指向那个元素,并将该元素之前的元素都从 stack中出栈。
这里可能有同学会问,难道解析的尾元素不应该对应 stack 栈的最上面的一个元素才对吗?
其实不然,比如说可能会存在自闭合的标签,如“
”,或者是写了“”但是没有加上“< /span>”的情况,这时候就要找到 stack 中的第二个位置才能找到同名标签。

function parseEndTag (tagName) {
    let pos;
    for (pos = stack.length - 1; pos >= 0; pos--) {
        if (stack[pos].lowerCasedTag === tagName.toLowerCase()) {
            break;
        }
    }

    if (pos >= 0) {
        stack.length = pos;
        currentParent = stack[pos]; 
    }   
}

parseText

最后是解析文本,这个比较简单,只需要将文本取出,然后有两种情况,一种是普通的文本,直接构建一个节点 push 进当前 currentParent的 children 中即可。还有一种情况是文本是如“”这样的 Vue.js 的表达式,这时候我们需要用 parseText来将表达式转化成代码。

text = html.substring(0, textEnd)
advance(textEnd)
let expression;
if (expression = parseText(text)) {
    currentParent.children.push({
        type: 2,
        text,
        expression
    });
} else {
    currentParent.children.push({
        type: 3,
        text,
    });
}
continue;

我们会用到一个parseText函数。

function parseText (text) {
    if (!defaultTagRE.test(text)) return;

    const tokens = [];
    let lastIndex = defaultTagRE.lastIndex = 0
    let match, index
    while ((match = defaultTagRE.exec(text))) {
        index = match.index

        if (index > lastIndex) {
            tokens.push(JSON.stringify(text.slice(lastIndex, index)))
        }

        const exp = match[1].trim()
        tokens.push(`_s(${exp})`)
        lastIndex = index + match[0].length
    }

    if (lastIndex < text.length) {
        tokens.push(JSON.stringify(text.slice(lastIndex)))
    }
    return tokens.join('+');
}

processIf与processFor

最后介绍一下如何处理“v-if”以及“v-for”这样的 Vue.js 的表达式的,这里我们只简单介绍两个示例中用到的表达式解析。

我们只需要在解析头标签的内容中加入这两个表达式的解析函数即可,在这时“v-for”之类指令已经在属性解析时存入了 attrsMap 中了。

if (html.match(startTagOpen)) {
    const startTagMatch = parseStartTag();
    const element = {
        type: 1,
        tag: startTagMatch.tagName,
        attrsList: startTagMatch.attrs,
        attrsMap: makeAttrsMap(startTagMatch.attrs),
        parent: currentParent,
        children: []
    }

    processIf(element);
    processFor(element);

    if(!root){
        root = element
    }

    if(currentParent){
        currentParent.children.push(element);
    }

    stack.push(element);
    currentParent = element;
    continue;
}

首先我们需要定义一个 getAndRemoveAttr 函数,用来从el的 attrsMap属性或是 attrsList 属性中取出 name对应值。

function getAndRemoveAttr (el, name) {
    let val
    if ((val = el.attrsMap[name]) != null) {
        const list = el.attrsList
        for (let i = 0, l = list.length; i < l; i++) {
            if (list[i].name === name) {
                list.splice(i, 1)
                break
            }   
        }
    }
    return val
}

比如说解析示例的 div 标签属性。

getAndRemoveAttr(el, 'v-for');

可有得到“item in sz”。

有了这个函数这样我们就可以开始实现 processFor 与 processIf了。

“v-for”会将指令解析成 for 属性以及 alias 属性,而“v-if”会将条件都存入 ifConditions 数组中。

function processFor (el) {
    let exp;
    if ((exp = getAndRemoveAttr(el, 'v-for'))) {
        const inMatch = exp.match(forAliasRE);
        el.for = inMatch[2].trim();
        el.alias = inMatch[1].trim();
    }
}

function processIf (el) {
    const exp = getAndRemoveAttr(el, 'v-if');
    if (exp) {
        el.if = exp;
        if (!el.ifConditions) {
            el.ifConditions = [];
        }
        el.ifConditions.push({
            exp: exp,
            block: el
        });
    }
}

(2)optimize

optimize主要是用来优化,这个涉及到后面要讲patch的过程,因为 patch的过程实际上是将 VNode节点进行一层一层的比对,然后将「差异」更新到视图上。

那么一些静态节点是不会根据数据变化而产生变化的,这些节点我们没有比对的需求,是不是可以跳过这些静态节点的比对,从而节省一些性能呢?

那么我们就需要为静态的节点做上一些「标记」,在 patch 的时候我们就可以直接跳过这些被标记的节点的比对,从而达到「优化」的目的。

经过 optimize 这层的处理,每个节点会加上 static 属性,用来标记是否是静态的。

isStatic
首先实现一个isStatic 函数,传入一个 node判断该 node是否是静态节点。判断的标准是当type 为 2(表达式节点)则是非静态节点,当 type 为 3(文本节点)的时候则是静态节点,当然,如果存在 if 或者 for这样的条件的时候(表达式节点),也是非静态节点。

function isStatic (node) {
    if (node.type === 2) {
        return false
    }
    if (node.type === 3) {
        return true
    }
    return (!node.if && !node.for);
}

markStatic
markStatic为所有的节点标记上 static,遍历所有节点通过 isStatic 来判断当前节点是否是静态节点,此外,会遍历当前节点的所有子节点,如果子节点是非静态节点,那么当前节点也是非静态节点。

function markStatic (node) {
    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;
            }
        }
    }
}

markStaticRoots
接下来是 markStaticRoots 函数,用来标记 staticRoot(静态根)。这个函数实现比较简单,简单来将就是如果当前节点是静态节点,同时满足该节点并不是只有一个文本节点左右子节点(作者认为这种情况的优化消耗会大于收益)时,标记 staticRoot 为 true,否则为false。

function markStaticRoots (node) {
    if (node.type === 1) {
        if (node.static && node.children.length && !(
        node.children.length === 1 &&
        node.children[0].type === 3
        )) {
            node.staticRoot = true;
            return;
        } else {
            node.staticRoot = false;
        }
    }
}

optimize
有了以上的函数,就可以实现 optimize 了。

function optimize (rootAst) {
    markStatic(rootAst);
    markStaticRoots(rootAst);
}

(3)generate

generate会将 AST 转化成render funtion字符串,最终得到 render的字符串以及 staticRenderFns字符串。

首先带大家感受一下真实的 Vue.js 编译得到的结果。

with(this){
    return (isShow) ? 
    _c(
        'div',
        {
            staticClass: "demo",
            class: c
        },
        _l(
            (sz),
            function(item){
                return _c('span',[_v(_s(item))])
            }
        )
    )
    : _e()
}

实现一个generate

genIf

处理if条件的 genIf函数。

function genIf (el) {
    el.ifProcessed = true;
    if (!el.ifConditions.length) {
        return '_e()';
    }
    return `(${el.ifConditions[0].exp})?${genElement(el.ifConditions[0].block)}: _e()`
}

genFor
然后是处理for 循环的函数。

function genFor (el) {
    el.forProcessed = true;

    const exp = el.for;
    const alias = el.alias;
    const iterator1 = el.iterator1 ? `,${el.iterator1}` : '';
    const iterator2 = el.iterator2 ? `,${el.iterator2}` : '';

    return `_l((${exp}),` +
        `function(${alias}${iterator1}${iterator2}){` +
        `return ${genElement(el)}` +
    '})';
}

genText
处理文本节点的函数。

function genText (el) {
    return `_v(${el.expression})`;
}

genElement
接下来实现一下 genElement,这是一个处理节点的函数,因为它依赖 genChildren 以及genNode ,所以这三个函数放在一起讲。

genElement会根据当前节点是否有if或者 for标记然后判断是否要用 genIf 或者 genFor处理,否则通过 genChildren 处理子节点,同时得到 staticClass、class等属性。

genChildren 比较简单,遍历所有子节点,通过 genNode处理后用“,”隔开拼接成字符串。

genNode则是根据type来判断该节点是用文本节点 genText还是标签节点genElement来处理。

function genNode (el) {
    if (el.type === 1) {
        return genElement(el);
    } else {
        return genText(el);
    }
}

function genChildren (el) {
    const children = el.children;

    if (children && children.length > 0) {
        return `${children.map(genNode).join(',')}`;
    }
}

function genElement (el) {
    if (el.if && !el.ifProcessed) {
        return genIf(el);
    } else if (el.for && !el.forProcessed) {
        return genFor(el);
    } else {
        const children = genChildren(el);
        let code;
        code = `_c('${el.tag},'{
            staticClass: ${el.attrsMap && el.attrsMap[':class']},
            class: ${el.attrsMap && el.attrsMap['class']},
        }${
            children ? `,${children}` : ''
        })`
        return code;
    }
}

generate
最后我们使用上面的函数来实现 generate,其实很简单,我们只需要将整个AST传入后判断是否为空,为空则返回一个 div 标签,否则通过 generate来处理。

function generate (rootAst) {
    const code = rootAst ? genElement(rootAst) : '_c("div")'
    return {
        render: `with(this){return ${code}}`,
    }
}

小结

经历过这些过程以后,我们已经把 template 顺利转成了 render function 了,接下来我们将介绍 patch的过程,来看一下具体 VNode 节点如何进行差异的比对。