编译技术在前端框架中的运用与实现原理研究

1,398 阅读18分钟

前言

这些年随着前端技术的日新月异的发展,各种规范与新的标准层出不穷。社区里面各式各样的工具、技术像雨后春笋般冒出,不少前端从业者直呼“求求你们不要再更新了,已经学不动了!”

在现代的前端开发工作中,使用社区流行的框架或技术,帮助我们快速实现业务需求已经成为了日常。在一个热门的前端技术栈中,或许你接触过像VueReactBabel等这些技术。试问在日复一日的开发过程中,除了我们对这些工具技术的熟练使用外,你是否有去深究这背后所蕴藏的编译技术呢?

对于不少基础较薄的开发者来说,对偏底层的编译技术也许还很陌生,我想以社区流行的框架为出发点,和大家一起探究一下编译技术的世界,熟悉编译技术在前端领域中的运用,以理论支持实践的方式先来了解一下编译技术的理论知识。

编译技术概述

在百科词条中这样定义编译技术的: 把高级计算机语言编写的程序代码翻译成为计算机可以运行的二进制机器语言代码的技术,我们通俗的讲就是把人看得懂的代码,转换为机器看得懂的二进制字节码,这个过程的定义就叫编译。让我们用一个流程图快速梳理一下编译的整个流程:

图片

词法分析

词法分析阶段,首先拿到我们输入的程序代码,扫描给定的代码字符串,识别出一个个 词法记号(Token),目的是将输入字符串识别为有意义的子串,在前端的一些实现中,大多使用正则表达式来进行识别。也可以使用生成工具读取正则表达式,生成一种叫 有限自动机 的算法,来完成具体的词法分析工作,原理就是依据构造好的有限自动机,在不同的状态中迁移,从而解析出 Token 来。

语法分析

语法分析阶段的目标结果是生成一颗AST树,会借用 自顶向下或自底向上分析法 来实现具体的分析工作。比如递归算法就是一种常见的自顶向下算法,在分析时,从根节点出发自顶向下匹配输入串建立语法树,但该算法需要修复左递归回溯的问题。自底向上分析法,是自叶开始逐级向上归约,直到构造出表示句子结构的整个推导树为止的一种分析算法。

语义分析

语义分析的主要任务是进行语义规则检查,检查AST树的语法结构,验证语义是否正确有意义,是否有语义错误,比如类型检查控制流检查唯一性检查关联名检查等,为后续工作收集类型信息,然后再进行语法制导翻译,将语法树翻译成后端能够理解的表示形式。

中间代码生成

中间代码生成是将进行了前面两个阶段后的结果,转化为一种内部表示形式,变成一种结构简单、含义明确的记号系统,这种记号系统复杂性介于源程序语言和机器语言之间,后续容易将它翻译成目标代码,中间代码除了可以用AST树的方式进行表示外,还可以使用逆波兰三地址码DAG图的形式进行表示。

代码优化

代码优化是在不改变程序运行效果的前提下,对生成的中间代码进行等价变换,使之能生成更加高效的目标代码。优化主要由控制流分析数据流分析变换三部分组成。最终生成的目标代码运行时间更短、占用空间更小、效率更高。在前端落地的实现方式通常是对AST树进行标注、修改、删除、新增、移位的修改。

生成目标代码

目标代码生成是编译的最后一个阶段,由代码生成器完成。任务是把中间代码转换为等价的、具有较高质量的目标代码,以充分利用目标机器的资源,要生成高质量的目标代码,需合理的使用寄存器,最后生成的代码一般由机器代码或接近于机器语言的代码组成。

在前端的运用

上述讲了编译技术的六个过程,更偏抽象与干瘪的理论知识点,在这中间还省去了不少具体的实现细节与细枝末梢的知识点,想更深入学习编译原理的朋友可以查看这个课程内容,相信你学完后会对你有不少的收货。上述内容只是想让大家在脑海中有一个理论支撑的知识点印象,接下来让我们已具体的案例来看一下,在前端各个工具和框架的具体实现过程中,各自又是如何实现的吧!

artTemplate

在JQuery的时代,诞生了一个在社区很受欢迎的模板库artTemplate,想必你我曾经都是他的忠实用户,让我们以下面的代码为输入,来看一下artTemplate在输出我们的代码时,在背后多做了哪些工作内容。

  <script id="newsTpl" type="text/html">
    <% if (news.length) { %>
      <ul>
          <% news.forEach(function(item) { %>
            <li>
              <a href="<%= item.url %>"><%= item.name %></a>
            </li>
          <% }) %>
      </ul>
    <% } %>
  </script>

  <script>
    var data = {
        news: [{
          name: '百度新闻',
          url: 'http://www.baidu.com/'
        }, {
          name: '网易新闻',
          url: 'https://news.163.com/'
        }, {
          name: '腾讯新闻',
          url: 'https://news.qq.com/'
        }]
    };

    document.getElementById("news").innerHTML = template('newsTpl', data);
  </script>

我们先来看一下 artTemplate 在代码入口处的执行流程是什么样的

源码后续逻辑流程如下:
明白上述流程后,让我们去看一看源码的实现,去github拿到源码后,只需重点关注src/compile文件夹下的compiler.jses-tokenizer.jstpl-tokenizer.js中的内容即可。

es-tokenizer.js

首先我们先关注es-tokenizer.js中的内容,该文件中的方法只做一件事,将整个代码字符串分割成语法单元数组,借用js-tokens解释为词法记号(Tokens),再在js-tokens的源码中借用正则表达式进行JS的语法匹配。

// 匹配JS语法规则的正则文法
exports.default = /((['"])(?:(?!\2|\\).|\\(?:\r\n|[\s\S]))*(\2)?|`(?:[^`\\$]|\\[\s\S]|\$(?!\{)|\$\{(?:[^{}]|\{[^}]*\}?)*\}?)*(`)?)|(\/\/.*)|(\/\*(?:[^*]|\*(?!\/))*(\*\/)?)|(\/(?!\*)(?:\[(?:(?![\]\\]).|\\.)*\]|(?![\/\]\\]).|\\.)+\/(?:(?!\s*(?:\b|[\u0080-\uFFFF$\\'"~({]|[+\-!](?!=)|\.?\d))|[gmiyu]{1,5}\b(?![\u0080-\uFFFF$\\]|\s*(?:[+\-*%&|^<>!=?({]|\/(?![\/*])))))|(0[xX][\da-fA-F]+|0[oO][0-7]+|0[bB][01]+|(?:\d*\.\d+|\d+\.?)(?:[eE][+-]?\d+)?)|((?!\d)(?:(?!\s)[$\w\u0080-\uFFFF]|\\u[\da-fA-F]{4}|\\u\{[\da-fA-F]+\})+)|(--|\+\+|&&|\|\||=>|\.{3}|(?:[+\-\/%&|^]|\*{1,2}|<{1,2}|>{1,3}|!=?|={1,2})=?|[?~.,:;[\](){}])|(\s+)|(^$|[\s\S])/g

exports.matchToToken = function(match) {
  var token = {type: "invalid", value: match[0]}
       if (match[ 1]) token.type = "string" , token.closed = !!(match[3] || match[4])
  else if (match[ 5]) token.type = "comment"
  else if (match[ 6]) token.type = "comment", token.closed = !!match[7]
  else if (match[ 8]) token.type = "regex"
  else if (match[ 9]) token.type = "number"
  else if (match[10]) token.type = "name"
  else if (match[11]) token.type = "punctuator"
  else if (match[12]) token.type = "whitespace"
  return token
}

上述代码会将var result = 123;这样的代码先处理为["var", " ", "result", " ", "=", " ", "123", ";"]这样的结果,最终再返回包含类型标识和值的语法分析结果,这一步在编译技术中通常又称作分词

[{"type":"keyword","value":"var"},{"type":"whitespace","value":" "},{"type":"name","value":"result"},{"type":"whitespace","value":" "},{"type":"punctuator","value":"="},{"type":"whitespace","value":" "},{"type":"number","value":"1234"},{"type":"punctuator","value":";"}]

tpl-tokenizer.js

同样我们再来关注tpl-tokenizer.js,在该文件中重点是拿到初始化后的模板字符串对象,内容结构如下:

{
	"options": {
		"filename": "newsTpl",
		"source": "\n<% if (news.length) { %>\n  <ul>\n      <% news.forEach(function(item) { %>\n        <li>\n          <a href=\"<%= item.url %>\"><%= item.name %></a>\n        </li>\n      <% }) %>\n  </ul>\n<% } %>\n"
	},
	// 所有语句堆栈
	"stacks": [], 
	// 运行时注入的上下文
	"context": [{
		"name": "?out",
		"value": "''"
	}],
	// 模板语句编译后的代码
	"scripts": [],
	"CONTEXT_MAP": {
		"?out": "''"
	},
	// 忽略的变量名单
	"ignore": ["$data", "$imports", "?options"],
	// 按需编译到模板渲染函数的内置变量
	"internal": {
		"?out": "''",
		"?line": "[0,0]",
		"?blocks": "arguments[1]||{}",
		"?from": "null",
		"print": "function(){var s=''.concat.apply('',arguments);?out+=s;return s}",
		"include": "function(src,data){var s=?options.include(src,data||$data,arguments[2]||?blocks,?options);?out+=s;return s}",
		"extend": "function(from){?from=from}",
		"?slice": "function(c,p,s){p=?out;?out='';c();s=?out;?out=p+s;return s}",
		"block": "function(){var a=arguments,s;if(typeof a[0]==='function'){return ?slice(a[0])}else if(?from){if(!?blocks[a[0]]){?blocks[a[0]]=?slice(a[1])}else{?out+=?blocks[a[0]]}}else{s=?blocks[a[0]];if(typeof s==='string'){?out+=s}else{s=?slice(a[1])}return s}}"
	},
	// 内置函数依赖关系声明
	"dependencies": {
		"print": ["?out"],
		"include": ["?out", "?options", "$data", "?blocks"],
		"extend": ["?from", "include"],
		"block": ["?slice", "?from", "?out", "?blocks"]
	},
	// 模板字符串
	"source": "\n<% if (news.length) { %>\n  <ul>\n      <% news.forEach(function(item) { %>\n        <li>\n          <a href=\"<%= item.url %>\"><%= item.name %></a>\n        </li>\n      <% }) %>\n  </ul>\n<% } %>\n"
}

根据该结果中的source内容,用compile/adapter目录下提供的rule.art.jsrule.native.js进行匹配,循环处理与识别出字符串与表达式,最终返回模板的Tokens结果。

[{
	"type": "string",
	"value": "\n",
	"script": null,
	"line": 0,
	"start": 0,
	"end": 1
}, {
	"type": "expression",
	"value": "<% if (news.length) { %>",
	"script": {
		"code": "if (news.length) {",
		"output": false
	},
	"line": 1,
	"start": 0,
	"end": 24
}, {
	"type": "string",
	"value": "\n  <ul>\n      ",
	"script": null,
	"line": 1,
	"start": 24,
	"end": 38
}, {
	"type": "expression",
	"value": "<% news.forEach(function(item) { %>",
	"script": {
		"code": "news.forEach(function(item) {",
		"output": false
	},
	"line": 3,
	"start": 6,
	"end": 41
}, {
	"type": "string",
	"value": "\n        <li>\n          <a href=\"",
	"script": null,
	"line": 3,
	"start": 41,
	"end": 74
}, {
	"type": "expression",
	"value": "<%= item.url %>",
	"script": {
		"code": "item.url",
		"output": "escape"
	},
	"line": 5,
	"start": 19,
	"end": 34
}, {
	"type": "string",
	"value": "\">",
	"script": null,
	"line": 5,
	"start": 34,
	"end": 36
}, {
	"type": "expression",
	"value": "<%= item.name %>",
	"script": {
		"code": "item.name",
		"output": "escape"
	},
	"line": 5,
	"start": 36,
	"end": 52
}, {
	"type": "string",
	"value": "</a>\n        </li>\n      ",
	"script": null,
	"line": 5,
	"start": 52,
	"end": 77
}, {
	"type": "expression",
	"value": "<% }) %>",
	"script": {
		"code": "})",
		"output": false
	},
	"line": 7,
	"start": 6,
	"end": 14
}, {
	"type": "string",
	"value": "\n  </ul>\n",
	"script": null,
	"line": 7,
	"start": 14,
	"end": 23
}, {
	"type": "expression",
	"value": "<% } %>",
	"script": {
		"code": "}",
		"output": false
	},
	"line": 9,
	"start": 0,
	"end": 7
}, {
	"type": "string",
	"value": "\n",
	"script": null,
	"line": 9,
	"start": 7,
	"end": 8
}]

compiler.js

经过前面的两步之后,基本上以完成最复杂的工作内容,在compiler.js中,只需需要循环处理返回的模板Tokens结果,根据中间的type字段来区别字符串和表达式,再分别处理相应的情况即可。

在解析字符串时,只需要拿到已经匹配好的结果内容拼接接字符串,再把拼接后的结果放到模板语句编译后放置的scripts数组中即可。

解析逻辑表达式语句时,根据循环读取处理模板Tokens下script中的code字段,再次使用es-tokenizer.js解析出表达式中js的Tokens,再过滤出表达式中的变量列表,导入变量列表的模板上下文信息,生成相应的上下文映射内容,精简后核心上下文映射代码如下:

if (!has(contextMap, name) && ignore.indexOf(name) === -1) {
    if (has(internal, name)) {
        // 用递归处理嵌套的情况
    }
    // 处理继承、导入的情况
    else if (name === ESCAPE || name === EACH || has(imports, name)) {
        value = `${IMPORTS}.${name}`;
    } else {
        value = `${DATA}.${name}`;
    }

    contextMap[name] = value;
    context.push({
        name,
        value
    });
}

经过上述多次循环处理后,之前的对象中contextCONTEXT_MAPscripts已经变成下面这样:

{
    "context": [
        {
            "name": "?out",
            "value": "''"
        },
        {
            "name": "news",
            "value": "$data.news"
        },
        {
            "name": "item",
            "value": "$data.item"
        },
        {
            "name": "$escape",
            "value": "$imports.$escape"
        }
    ],
    "CONTEXT_MAP": {
        "?out": "''",
        "news": "$data.news",
        "item": "$data.item",
        "$escape": "$imports.$escape"
    },
    "scripts": [
        {
            "source": "\n",
            "tplToken": {
                "type": "string",
                "value": "\n",
                "script": null,
                "line": 0,
                "start": 0,
                "end": 1
            },
            "code": "?out+=\"\\n\""
        },
        {
            "source": "<% if (news.length) { %>",
            "tplToken": {
                "type": "expression",
                "value": "<% if (news.length) { %>",
                "script": {
                    "code": "if (news.length) {",
                    "output": false
                },
                "line": 1,
                "start": 0,
                "end": 24
            },
            "code": "if (news.length) {"
        },
        {
            "source": "\n  <ul>\n      ",
            "tplToken": {
                "type": "string",
                "value": "\n  <ul>\n      ",
                "script": null,
                "line": 1,
                "start": 24,
                "end": 38
            },
            "code": "?out+=\"\\n  <ul>\\n      \""
        },
        {
            "source": "<% news.forEach(function(item) { %>",
            "tplToken": {
                "type": "expression",
                "value": "<% news.forEach(function(item) { %>",
                "script": {
                    "code": "news.forEach(function(item) {",
                    "output": false
                },
                "line": 3,
                "start": 6,
                "end": 41
            },
            "code": "news.forEach(function(item) {"
        },
        {
            "source": "\n        <li>\n          <a href=\"",
            "tplToken": {
                "type": "string",
                "value": "\n        <li>\n          <a href=\"",
                "script": null,
                "line": 3,
                "start": 41,
                "end": 74
            },
            "code": "?out+=\"\\n        <li>\\n          <a href=\\\"\""
        },
        {
            "source": "<%= item.url %>",
            "tplToken": {
                "type": "expression",
                "value": "<%= item.url %>",
                "script": {
                    "code": "item.url",
                    "output": "escape"
                },
                "line": 5,
                "start": 19,
                "end": 34
            },
            "code": "?out+=$escape(item.url)"
        },
        {
            "source": "\">",
            "tplToken": {
                "type": "string",
                "value": "\">",
                "script": null,
                "line": 5,
                "start": 34,
                "end": 36
            },
            "code": "?out+=\"\\\">\""
        },
        {
            "source": "<%= item.name %>",
            "tplToken": {
                "type": "expression",
                "value": "<%= item.name %>",
                "script": {
                    "code": "item.name",
                    "output": "escape"
                },
                "line": 5,
                "start": 36,
                "end": 52
            },
            "code": "?out+=$escape(item.name)"
        },
        {
            "source": "</a>\n        </li>\n      ",
            "tplToken": {
                "type": "string",
                "value": "</a>\n        </li>\n      ",
                "script": null,
                "line": 5,
                "start": 52,
                "end": 77
            },
            "code": "?out+=\"</a>\\n        </li>\\n      \""
        },
        {
            "source": "<% }) %>",
            "tplToken": {
                "type": "expression",
                "value": "<% }) %>",
                "script": {
                    "code": "})",
                    "output": false
                },
                "line": 7,
                "start": 6,
                "end": 14
            },
            "code": "})"
        },
        {
            "source": "\n  </ul>\n",
            "tplToken": {
                "type": "string",
                "value": "\n  </ul>\n",
                "script": null,
                "line": 7,
                "start": 14,
                "end": 23
            },
            "code": "?out+=\"\\n  </ul>\\n\""
        },
        {
            "source": "<% } %>",
            "tplToken": {
                "type": "expression",
                "value": "<% } %>",
                "script": {
                    "code": "}",
                    "output": false
                },
                "line": 9,
                "start": 0,
                "end": 7
            },
            "code": "}"
        },
        {
            "source": "\n",
            "tplToken": {
                "type": "string",
                "value": "\n",
                "script": null,
                "line": 9,
                "start": 7,
                "end": 8
            },
            "code": "?out+=\"\\n\""
        }
    ]
}

执行完毕模板解析后,开始执行编译流程,先初始化一些相关的代码放入stacks中,将scripts中的code代码循环取出放入stacks,再将stacks连接上\n输出中间代码,代码如下:

"function($data){
'use strict'
$data=$data||{}
var ?out='',news=$data.news,item=$data.item,$escape=$imports.$escape
?out+="\n"
if (news.length) {
?out+="\n  <ul>\n      "
news.forEach(function(item) {
?out+="\n        <li>\n          <a href=\""
?out+=$escape(item.url)
?out+="\">"
?out+=$escape(item.name)
?out+="</a>\n        </li>\n      "
})
?out+="\n  </ul>\n"
}
?out+="\n"
return ?out
}"

最后再使用new Function,将上述生成的中间代码、上下文相关内容一并传入并生成可执行的 Function 对象,再返回该创建对象。调用的时候从外部传入数据,将数据已参数的形式传入到 Function 对象中,即可执行模板渲染的功能,把这里的流程代码浓缩后如下所示:

var compile = function compile(source) {
    // 执行具体的模板解析
    var compiler = new Compiler(options);

    try {
      // 最终代码的生成
        fn = compiler.build();
    } catch (error) {
        // 错误处理
    }

    var render = function render(data, blocks) {
        try {
            return fn(data, blocks);
        } catch (error) {
          // 错误处理
        }
    };

    return render;
};

/**
 * 渲染模板
 * @param   {string|Object}     source  传入的模板内容
 * @param   {Object}            data    传入的数据
 * @param   {?Object}           options 选项
 * @return  {string}            渲染好的字符串
 */
var render = function render(source, data, options) {
  return compile(source, options)(data);
}

至此artTemplate的模板解析与代码生成的全流程就全部处理完毕,通过阅读与分析artTemplate的源码我们可以发现,他在整个编译技术的流程中,涉及到了词法分析中间代码生成目标代码生成三部分的内容。接下来我们再看一下在Vue的模板解析中,他又是如何处理模板解析的。

Vue模板解析

上面,我们清楚了artTemplate的全流程后,或许大家已经对模板解析已经有了一些自己的认知,那么在Vue中又是如何做模板解析的呢?

通过查看Vue v2.6.x 源码我们可以发现,Vue模板解析工作分为模板解析、代码优化、代码生成三步,对应到源码中的位置在src/compiler/index.js中,具体核心代码如下:

// 1. 模板解析
const ast = parse(template.trim(), options)
// 2. 语法树优化
if (options.optimize !== false) {
    optimize(ast, options)
}
// 3. 代码生成
const code = generate(ast, options)

return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
}

我们将根据上述这三个步骤来进行分析,由于Vue源码中细枝末梢的分支逻辑非常多,不太便于我们进行主要的编译流程的研究,下面我将使用抽象和文字注释的方式,将核心逻辑代码剔出来,重点关注编译流程的主要核心逻辑,对实现的细节部分,推荐大家直接阅读源码目录src/compiler下的代码,或者直接访问黄轶老师的 Vue.js技术揭秘 课程进行详细了解。

模板解析

在拿到我们自己编写的模板字符串时,首先会进行模板解析的工作,模板解析部分的源码对应于src/compiler/parser/index.js,我们使用这样一段模板代码来进行分析解析的逻辑。

<div>
    某大厂的总监说:
    <div title="content">要多读书、多看报、多敲代码、少睡觉</div>
    <!-- 他为人低调,是我学习的榜样! -->
    <span v-if="!lowKey">业界早已流传着他的名字:{{ name }}</span>
</div>

解析的入口代码抽象后核心部分如下:

export function parse (
  template: string,
  options: CompilerOptions
): ASTElement | void {
  // 前面先初始化一些配置信息
  // 初始化一些状态、变量等
  
  // 解析 HTML 模板
  parseHTML(template, {
    // 一些配置项
    
    // 解析到开始标签时调用
    start (tag, attrs, unary, start, end) {
      // 创建描述节点的AST
      // 处理一些pre、attr、for、if、once等一些分支逻辑
      // 树节点管理
    },
    // 解析到结束标签时调用
    end (tag, start, end) {
      // 树节点管理
      // 关闭节点
    },
    // 解析到文本内容时调用
    chars (text, start, end) {
      // 处理文本
      // 创建描述文本的AST
    },
    // 解析到注释类型节点时调用
    comment (text, start, end) {
      // 创建描述注释文本的AST
    }
  })
  // 最后返回解析出来的AST
  return root;
}

上述入口代码中parseHTML()调用了src/compiler/parser/html-parser.js中的解析逻辑,那我们来看一下Vue中是如何解析HTML的。

export function parseHTML (html, options) {
  let lastTag
  while (html) {
    // 确保不在script、style中
    if (!lastTag || !isPlainTextElement(lastTag)) {
      let textEnd = html.indexOf('<')
      // 确保不是一个纯文本的内容
      if (textEnd === 0) {
         if (comment.test(html)) {
           const commentEnd = html.indexOf('-->')
           options.comment(html.substring(4, commentEnd), index, index + commentEnd + 3)
           advance(commentEnd + 3)
           continue
         }
         if (conditionalComment.test(html)) {
           const conditionalEnd = html.indexOf(']>')
           advance(conditionalEnd + 2)
           continue
         }
         const doctypeMatch = html.match(doctype)
         if (doctypeMatch) {
           advance(doctypeMatch[0].length)
           continue
         }
         const endTagMatch = html.match(endTag)
         if (endTagMatch) {
           advance(endTagMatch[0].length)
           parseEndTag(endTagMatch[1], curIndex, index)
           continue
         }
         const startTagMatch = parseStartTag()
         if (startTagMatch) {
           handleStartTag(startTagMatch)
           if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
             advance(1)
           }
           continue
         }
      }
      // 处理文本内容
      // 移动指针位置
    } else {
       parseEndTag()
    }
  }
}

从上述代码中我们可以看到,Vue中是通过循环的方式,依次执行匹配然后进入相应的处理逻辑中,处理完毕具体的逻辑后,移动匹配指针的位置,直至模板字符串结束位置,这里我们重点关注一下开始标签的执行逻辑。

function parseStartTag() {
    var start = html.match(startTagOpen);
    // start value
    /*
    0: "<div"
    1: "div"
    groups: undefined
    index: 0
    input: "<div>某大厂的总监说:<div>要多读书、多看报、多敲代码、少睡觉</div><span v-if="!lowKey">大佬的名字叫:{{ name }}</span></div>"
    length: 2
    */
    if (start) {
        var match = {
            tagName: start[1],
            attrs: [],
            start: index
        };
        // 移动匹配指针位置
        advance(start[0].length);
        var end, attr;
        // 处理节点属性内容
        while (!(end = html.match(startTagClose)) && (attr = html.match(dynamicArgAttribute) || html.match(attribute))) {
            advance(attr[0].length);
            match.attrs.push(attr);
        }
        if (end) {
            advance(end[0].length);
            return match
        }
    }
}

通过使用开始标签的正则规则匹配到结果后,把匹配出来的结果组装起来再返回组装结果。

function handleStartTag(match) {
    const tagName = match.tagName
    const unarySlash = match.unarySlash

    // 处理一些嵌套规则不正确的情况

    const unary = isUnaryTag(tagName) || !!unarySlash
    const l = match.attrs.length
    const attrs = new Array(l)
    
    // 处理a标签中属性中转义符的逻辑

    if (!unary) {
      stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs, start: match.start, end: match.end })
      lastTag = tagName
    }

    if (options.start) {
      // 将匹配到的结果执行配置项中传入的初始化开始节点AST的逻辑
      options.start(tagName, attrs, unary, match.start, match.end)
    }
}

反复执行上述的循环处理流程后,我们的模板字符串内容被转换为下面这样的AST树

{
    "type": 1, // 节点
    "tag": "div",
    "attrsList": [],
    "attrsMap": {},
    "rawAttrsMap": {},
    "children": [
        {
            "type": 3, // 静态文本
            "text": "某大厂的总监说:",
            "start": 5,
            "end": 13
        },
        {
            "type": 1,
            "tag": "div",
            "attrsList": [
                {
                    "name": "title",
                    "value": "content",
                    "start": 18,
                    "end": 33
                }
            ],
            "attrsMap": {
                "title": "content"
            },
            "rawAttrsMap": {},
            "children": [
                {
                    "type": 3,
                    "text": "要多读书、多看报、多敲代码、少睡觉",
                    "start": 34,
                    "end": 51
                }
            ],
            "start": 13,
            "end": 57,
            "plain": false,
            "attrs": [
                {
                    "name": "title",
                    "value": "\"content\"",
                    "start": 18,
                    "end": 33
                }
            ]
        },
        {
            "type": 1,
            "tag": "span",
            "attrsList": [],
            "attrsMap": {
                "v-if": "!lowKey"
            },
            "rawAttrsMap": {
                "v-if": {
                    "name": "v-if",
                    "value": "!lowKey",
                    "start": 86,
                    "end": 100
                }
            },
            "children": [
                {
                    "type": 2, // 含有表达式内容的字符串
                    "expression": "\"业界早已流传着他的名字:\"+_s(name)",
                    "tokens": [
                        "业界早已流传着他的名字:",
                        {
                            "@binding": "name"
                        }
                    ],
                    "text": "业界早已流传着他的名字:{{ name }}",
                    "start": 101,
                    "end": 123
                }
            ],
            "start": 80,
            "end": 130,
            "if": "!lowKey",
            "ifConditions": [
                {
                    "exp": "!lowKey"
                }
            ],
            "plain": true
        }
    ],
    "start": 0,
    "end": 136,
    "plain": true
}

语法树优化

在将模板字符串转换为AST树后,需要对AST树中的静态节点进行标记,这样在代码生成阶段效率会更加高效。语法树优化源码文件对应在src/compiler/optimizer.js中的optimize(),入口代码如下:

export function optimize (root: ?ASTElement, options: CompilerOptions) {
  if (!root) return
  // 标记所有静态节点
  markStatic(root)
  // 标记所有静态根
  markStaticRoots(root, false)
}

我们来看一下markStatic()是如何进行静态节点标记的

function isStatic (node: ASTNode): boolean {
  if (node.type === 2) { // 如果是含有表达式的节点则不标记
    return false
  }
  if (node.type === 3) { // 纯文本直接标记为静态节点
    return true
  }
  return !!(node.pre || (
    !node.hasBindings && // 没有动态绑定的值
    !node.if && !node.for && // 不是 v-if | v-for | v-else
    !isBuiltInTag(node.tag) && // 不是slot component
    isPlatformReservedTag(node.tag) && // 不是组件
    !isDirectChildOfTemplateFor(node) &&
    Object.keys(node).every(isStaticKey)
  ))
}

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条件下的节点
    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
        }
      }
    }
  }
}

我们再来看一下markStaticRoots()是如何进行静态根节点标记的。

function markStaticRoots (node: ASTNode, isInFor: boolean) {
  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
    }
    
    // 标记子节点下的静态根节点
    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)
      }
    }
  }
}

代码生成

有了前面解析和优化两步后,代码生成阶段只需要将AST树转换为执行代码串即可。我们来看一下src/compiler/codegen/index.js下面的代码生成逻辑,精简后核心逻辑代码如下:

export function generate (ast: ASTElement | void, options: CompilerOptions ): CodegenResult {
  // 初始化一些状态信息

  // 拿到前面优化后的AST树执行genElement()
  const code = ast ? genElement(ast, state) : '_c("div")'
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}

export function genElement (el: ASTElement, state: CodegenState): string {
  // 根据节点不同的属性执行不同的代码生成逻辑。
  
  if (el.staticRoot && !el.staticProcessed) {
    // 生成 _m() 的代码串,对应于renderStatic()
    return genStatic(el, state)
  } else if (el.once && !el.onceProcessed) {
    // 生成 _o() 的代码串,对应于markOnce()
    return genOnce(el, state)
  } else if (el.for && !el.forProcessed) {
    // 生成 _l() 的代码串,对应于renderList()
    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') {
    // 生成 _t() 的代码串,对应于renderSlot()
    return genSlot(el, state)
  } else {
    // 组件或者节点的处理
    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}` : '' // 把参数传入
      }${
        children ? `,${children}` : '' // 把子节点传入
      })`
    }

    return code
  }
}

上述各对应的方法在src/core/instance/render-helpers/index.js中可找到,大家可以查看具体的实现方法,这里不再深究实现细节。经过上述各分支逻辑的代码生成后,前文提供的模板内容,已被生成为下面这样的代码:

with(this) {
    return _c('div', [_v("某大厂的总监说:"), _c('div', {
        attrs: {
            "title": "content"
        }
    }, [_v("要多读书、多看报、多敲代码、少睡觉")]), (!lowKey) ? _c('span', [_v("业界早已流传着他的名字:" + _s(name))]) : _e()])
}

其中_c对应于createElement()_v对应于createTextVNode()_s对应于toString()_e对应于createEmptyVNode()

拿到最终生成后的代码串,会最终执行src/compiler/to-function.js中的createFunction()将上述代码串使用new Function()的方式,生成最终执行代码,并挂载到实例的render上完成整个模板编译的工作内容,最后再附上这三个流程的全流程图。

分析完了Vue中的模板解析过程,我们再来看一下在React中JSX又是如何转换为可执行代码的。

React JSX解析

通过React官方文档可以发现,在React中JSX语法会使用Babel工具进行转译为名为React.createElement()的函数调用,在Babel官网parser部分的文档可以发现,Babel的JSX解析又是基于 acorn-jsx 来实现的,在后续语法分析阶段,我也将依据acorn-jsx的源码来进行JSX的语法分析的解析,当然你也可以去阅读Babel源码目录下packages/babel-parser/src/plugins/jsx/index.js中的代码。由于React中JSX的解析与代码生成是基于Babel来完成的,所以我们需要先了解Babel中一次完整解析流程是什么样的。

在Babel中拿到源代码字符串后,首先会先借助 @babel/parser 模块将源代码解析成AST树。使用 @babel/traverse 模块来遍历AST树,遍历过程中进入每个节点时,触发该节点下enter钩子函数,退出该节点时触发exit 钩子函数,在进入与退出的过程中维护整棵树的状态,执行相应插件的处理逻辑来更新、修改、替换、添加相应AST树节点。再使用@babel/generator模块将AST树解码生成为目标代码,在生成目标代码过程中需要借助@babel/types来识别相应节点类型执行不同的操作。由于Babel本质上是一个插件化系统的设计,所以没有装上插件的情况下,Babel会原样返回你输入的代码,那么如何在Babel中创建一个插件来进行分支逻辑的处理呢?

创建一个Babel插件

通过官方 Babel 插件手册 可以知道,一个插件返回对象中须要包含一个visitor属性字段,在Babel调用用户提供的插件时,会调用插件中visitor属性下提供的处理方法,visitor下每一个key名都是遍历过程中的拓展点,各自负责处理相应逻辑。下面我提取了Babel中与JSX相关的处理逻辑部分代码,方便大家可以快速理解一个Babel插件的基本构成,其中JSX AST的定义可以查看 官方定义规范 的描述,acorn-jsx解析出来的AST树,也是基于JSX AST官方规范来实现的结果,如果你是在Bable中,也可以查看packages/babel-types/src/definitions/jsx.js文件中对JSX的定义。

export default function({ types: t }) {
    return {
        name: "transform-react-jsx",
        visitor: {
            // path表示两个节点之间连接的对象,用于处理前后关联关系时使用
            JSXNamespacedName(path) {}
            JSXSpreadChild(path) {}
            JSXElement(path) {}
            Program: {
                // 进入节点时触发
                enter(path, state) {},
                // 退出节点时触发
                exit(path, state) {}
            }
            JSXAttribute(path) {
                if (t.isJSXElement(path.node.value)) {
                  path.node.value = t.jsxExpressionContainer(path.node.value);
                }
            }
        }
    }
}

acorn-jsx

明白了JSX的解析流程、Bable的执行流程、插件定义后,我们来看一下acorn-jsx是如何解析jsx模板字符串的。

在后续的源码分析过程中,我将使用下面这样一段代码进行分析:

var acorn = require("acorn");
var jsx = require("acorn-jsx");
var result = acorn.Parser.extend(jsx());

var tpl = `<div className="App">
<header className="App-header">某大厂的总监说年轻时候要</header>
{
   1 === 1 ? (<span>多读书、多看报、多敲代码、少睡觉</span>) : (<span>多玩游戏、多刷头条、沉迷抖音快手</span>)
}
<Component.header id={id}></Component.header>
</div>`;

const Parser = result.parse(tpl);
console.log(JSON.stringify(Parser));

在执行result.parse()方法后正式解析之前,这个过程中acorn会注册插件并初始化原型上parse方法,初始化插件前会先执行插件自己提供的tokenTypes注册工作,方便在解析过程中使用这些tokenTypes来进行识别与判定。初始化插件中的解析器完成后,将创建一个Program类型的根节点,后续的解析结果将全部放置在Program下的body数组中,结构如下:

{
    // 程序根节点
    "type": "Program",
    "start": 0,
    "end": 210,
    // 用来包含所有程序的顶层语句
    "body": []
}

初始化完毕,正式解析时会先执行关键字检查的工作,确保拿到的源代码字符串是一个jsx的开始标签,如果不是js的关键字的情况,则开始解析表达式,在解析的过程中,采用深度优先遍历法,穷尽子节点和表达式后进入下一个的解析,我们来看一下开始解析时,读取token的逻辑。

jsx_readToken() {
  let out = '', chunkStart = this.pos;
  for (;;) {
    
    let ch = this.input.charCodeAt(this.pos);
    switch (ch) {
    case 60: // '<'
    case 123: // '{'
      if (this.pos === this.start) {
        if (ch === 60 && this.exprAllowed) {
          ++this.pos;
          // 开始节点的时候设置token
          // 在finishToken中设置 this.type 为传递进来的tokenType
          return this.finishToken(tok.jsxTagStart);
        }
        return this.getTokenFromCode(ch);
      }
      
      out += this.input.slice(chunkStart, this.pos);
      // 文本节点的时候,把类型和值一并传递过去
      return this.finishToken(tok.jsxText, out);

    case 38: // '&'
        // 处理一些特殊的字符。例如:&nbsp; 
      break;

    case 62: // '>'
    case 125: // '}'
      // 处理一些错误提示信息

    default:
      // 如果是 "" 或者是 "\n" 就使用新的一行进行读取
      if (isNewLine(ch)) {
        out += this.input.slice(chunkStart, this.pos);
        out += this.jsx_readNewLine(true);
        chunkStart = this.pos;
      } else {
        ++this.pos;
      }
    }
  }
}

我们的jsx模板有了上述的<入口处理逻辑后,将执行下面的解析元素逻辑

jsx_parseElementAt(startPos, startLoc) {
  let node = this.startNodeAt(startPos, startLoc);
  let children = [];
  let openingElement = this.jsx_parseOpeningElementAt(startPos, startLoc);
  let closingElement = null;
  // 自闭合节点 例:<img src="xxx" />
  if (!openingElement.selfClosing) {
    contents: for (;;) {
      switch (this.type) {
      case tok.jsxTagStart:
        // 迁移状态到开始节点的解析
        startPos = this.start; startLoc = this.startLoc;
     
        // 自闭合节点
        if (this.eat(tt.slash)) {
          closingElement = this.jsx_parseClosingElementAt(startPos, startLoc);
          break contents;
        }
        
        // 继续递归调用自己处理子节点
        children.push(this.jsx_parseElementAt(startPos, startLoc));
        break;

      case tok.jsxText:
        // 迁移状态到文本模式并生成一个 JSXText 节点
        children.push(this.parseExprAtom());
        break;

      case tt.braceL:
        // 迁移状态到表达式并解析 {} 中的表达式生成一个 JSXExpressionContainer 节点
        children.push(this.jsx_parseExpressionContainer());
        break;

      default:
        // 如果不是开始节点、文本节点、表达式则抛出错误
        this.unexpected();
      }
    }
  }
  let fragmentOrElement = openingElement.name ? 'Element' : 'Fragment';

  node['opening' + fragmentOrElement] = openingElement;
  node['closing' + fragmentOrElement] = closingElement;
  node.children = children;
  // 创建一个 JSXElement | JSXFragment 节点
  return this.finishNode(node, 'JSX' + fragmentOrElement);
}

我们提供的模板内容经过上述逻辑后,将生成如下如下AST内容:

{
    "type": "JSXElement",
    "start": 0,
    "end": 210,
    "openingElement": {
        "type": "JSXOpeningElement",
        "start": 0,
        "end": 21,
        "attributes": [
            {
                "type": "JSXAttribute",
                "start": 5,
                "end": 20,
                "name": {
                    "type": "JSXIdentifier",
                    "start": 5,
                    "end": 14,
                    "name": "className"
                },
                "value": {
                    // Literal 类型在Bable中会被修正为 StringLiteral
                    "type": "Literal",
                    "start": 15,
                    "end": 20,
                    "value": "App",
                    "raw": "\"App\""
                }
            }
        ],
        "name": {
            "type": "JSXIdentifier",
            "start": 1,
            "end": 4,
            "name": "div"
        },
        "selfClosing": false
    },
    "closingElement": {
        "type": "JSXClosingElement",
        "start": 204,
        "end": 210,
        "name": {
            "type": "JSXIdentifier",
            "start": 206,
            "end": 209,
            "name": "div"
        }
    },
    "children": [
        // ....
    ]
}

上面的代码中,我们看到 openingElement 通过jsx_parseOpeningElementAt()的分支逻辑,直接将 JSXOpeningElement节点生成好了,那么我们来看一下这块核心的解析逻辑,精简后的核心代码如下:

// 解析开始节点
jsx_parseOpeningElementAt(startPos, startLoc) {
  // 根据当前startPos初始化一个空的AST节点
  let node = this.startNodeAt(startPos, startLoc);
  node.attributes = [];
  let nodeName = this.jsx_parseElementName();
  if (nodeName) node.name = nodeName;
  while (this.type !== tt.slash && this.type !== tok.jsxTagEnd)
    node.attributes.push(this.jsx_parseAttribute());
  
  return this.finishNode(node, nodeName ? 'JSXOpeningElement' : 'JSXOpeningFragment');
}

// 处理节点名字
jsx_parseElementName() {
  let startPos = this.start, startLoc = this.startLoc;
  // 调用链 jsx_parseNamespacedName -> jsx_parseIdentifier
  let node = this.jsx_parseNamespacedName();

  // 处理 Component.header
  while (this.eat(tt.dot)) {
    let newNode = this.startNodeAt(startPos, startLoc);
    newNode.object = node;
    newNode.property = this.jsx_parseIdentifier();
    node = this.finishNode(newNode, 'JSXMemberExpression');
  }
  return node;
}

// 解析属性值
jsx_parseAttribute() {
  // 根据 acorn 中 this.start 初始化一个空节点
  let node = this.startNode();
  // jsx_parseNamespacedName 方法中再调用 jsx_parseIdentifier
  node.name = this.jsx_parseNamespacedName();
  node.value = this.eat(tt.eq) ? this.jsx_parseAttributeValue() : null;
  return this.finishNode(node, 'JSXAttribute');
}

// 解析属性key
jsx_parseIdentifier() {
  let node = this.startNode();
  if (this.type === tok.jsxName)
    node.name = this.value;
  else if (this.type.keyword)
    node.name = this.type.keyword;
  else
    this.unexpected();

  return this.finishNode(node, 'JSXIdentifier');
}

上述核心解析流程是除了{}表达式外,节点解析工作的全部逻辑,代码还是非常清晰与简单的。那{}中表达式是如何进行解析的呢?在{}中的表达式将使用jsx_parseExpressionContainer()来进行解析,在该方法中会调用acorn.js中的解析表达式方法,将acorn.js中解析三目运算符的核心逻辑代码抽离如下:

jsx_parseExpressionContainer() {
  let node = this.startNode();
  node.expression = parseExpression();// acorn.js 中的解析方法
  // 把{}中的内容都包裹在JSXExpressionContainer节点下( 属性值 | 表达式 )
  return this.finishNode(node, 'JSXExpressionContainer');
}
// acorn.js

// 解析表达式
pp$3.parseExpression = function(noIn, refDestructuringErrors) {
  var expr = this.parseMaybeAssign(noIn, refDestructuringErrors);
  return expr
}

// 解析赋值表达式
pp$3.parseMaybeAssign = function(noIn, refDestructuringErrors, afterLeftParse) {
  var startPos = this.start, startLoc = this.startLoc;
  var left = this.parseMaybeConditional(noIn, refDestructuringErrors);
  return left
}

// 解析三目运算符表达式
pp$3.parseMaybeConditional = function(noIn, refDestructuringErrors) {
  var startPos = this.start, startLoc = this.startLoc;
  // 解析 ? 前面的表达式
  var expr = this.parseExprOps(noIn, refDestructuringErrors);
  // 如果表达式中包含一个 ? 符号则定义为是一个三目运算
  if (this.eat(types.question)) {
    var node = this.startNodeAt(startPos, startLoc);
    node.test = expr;
    // 运算为真的值
    node.consequent = this.parseMaybeAssign();
    
    // 运算为假的值
    node.alternate = this.parseMaybeAssign(noIn);
    // 生成三目运算标识节点
    return this.finishNode(node, "ConditionalExpression")
  }
  return expr
}

// 解析表达式优先级
pp$3.parseExprOps = function(noIn, refDestructuringErrors) {
  var startPos = this.start, startLoc = this.startLoc;
  var expr = this.parseMaybeUnary(refDestructuringErrors, false);
  return expr.start === startPos && expr.type === "ArrowFunctionExpression" ? expr : this.parseExprOp(expr, startPos, startLoc, -1, noIn)
}

// 解析 Identifier | Literal
// 调用插件中的解析方法解析 JSXElement
pp$3.parseMaybeUnary = function(refDestructuringErrors, sawUnary) {
  var expr = this.parseExprSubscripts(refDestructuringErrors);
  // 例:{type: "Literal", start: 86, end: 87, value: 1, raw: "1"}
  return expr;
}

// 解析一个二元表达式
pp$3.parseExprOp = function(left, leftStartPos, leftStartLoc, minPrec, noIn) {
  // 二元表达式的符号类型值
  var prec = this.type.binop;
  if (prec != null && (!noIn || this.type !== types._in)) {
    if (prec > minPrec) {
      // 判断是否有 && || 
      var logical = this.type === types.logicalOR || this.type === types.logicalAND;
      // 运算符
      var op = this.value;
      var startPos = this.start, startLoc = this.startLoc;
      // 递归自身进行穷尽解析
      var right = this.parseExprOp(this.parseMaybeUnary(null, false), startPos, startLoc, prec, noIn);
      // 组合匹配结果返回一个二元运算符节点
      var node = this.buildBinary(leftStartPos, leftStartLoc, left, right, op, logical);
      
      return this.parseExprOp(node, leftStartPos, leftStartLoc, minPrec, noIn)
    }
  }
  /*
  {
    "type": "BinaryExpression",
    "left": { ... }
    "operator": "===",
    "right": { ... }
  }
  */
  return left
}

// 组合运算表达式解析后的逻辑关系
pp$3.buildBinary = function(startPos, startLoc, left, right, op, logical) {
  var node = this.startNodeAt(startPos, startLoc);
  // left right 表示运算符左右两个表达式
  node.left = left;
  // 二元运算符
  node.operator = op;
  node.right = right;
  return this.finishNode(node, logical ? "LogicalExpression" : "BinaryExpression")
}

最终表达式解析后AST树结构如下

{
    "type": "JSXExpressionContainer",
    "start": 75,
    "end": 157,
    "expression": {
        "type": "ConditionalExpression",
        "start": 80,
        "end": 155,
        "test": {
            // 二元运算表达式节点
            "type": "BinaryExpression",
            "start": 80,
            "end": 87,
            // 运算符左侧值
            "left": {
                // Literal 在Babel中会被修正为 NumericLiteral
                "type": "Literal",
                "start": 80,
                "end": 81,
                "value": 1,
                "raw": "1"
            },
            // 运算表达式的运算符
            "operator": "===",
            // 运算符右侧值
            "right": {
                "type": "Literal",
                "start": 86,
                "end": 87,
                "value": 1,
                "raw": "1"
            }
        },
        "consequent": {
            "type": "JSXElement",
            "start": 91,
            "end": 120,
            "openingElement": {
                "type": "JSXOpeningElement",
                "start": 91,
                "end": 97,
                "attributes": [],
                "name": {
                    "type": "JSXIdentifier",
                    "start": 92,
                    "end": 96,
                    "name": "span"
                },
                "selfClosing": false
            },
            "closingElement": {
                "type": "JSXClosingElement",
                "start": 113,
                "end": 120,
                "name": {
                    "type": "JSXIdentifier",
                    "start": 115,
                    "end": 119,
                    "name": "span"
                }
            },
            "children": [
                {
                    "type": "JSXText",
                    "start": 97,
                    "end": 113,
                    "value": "多读书、多看报、多敲代码、少睡觉",
                    "raw": "多读书、多看报、多敲代码、少睡觉"
                }
            ]
        },
        "alternate": {
            "type": "JSXElement",
            "start": 125,
            "end": 154,
            "openingElement": {
                "type": "JSXOpeningElement",
                "start": 125,
                "end": 131,
                "attributes": [],
                "name": {
                    "type": "JSXIdentifier",
                    "start": 126,
                    "end": 130,
                    "name": "span"
                },
                "selfClosing": false
            },
            "closingElement": {
                "type": "JSXClosingElement",
                "start": 147,
                "end": 154,
                "name": {
                    "type": "JSXIdentifier",
                    "start": 149,
                    "end": 153,
                    "name": "span"
                }
            },
            "children": [
                {
                    "type": "JSXText",
                    "start": 131,
                    "end": 147,
                    "value": "多玩游戏、多刷头条、沉迷抖音快手",
                    "raw": "多玩游戏、多刷头条、沉迷抖音快手"
                }
            ]
        }
    }
}

至此,字符串模板中节点解析与表达式解析工作全部完成,并逐层向上返回,将上面执行的节点解析表达式解析的结果全部放到ExpressionStatement节点下的expression中,完成最终解析工作,并返回最终的AST树。

// acorn.js

pp$1.parseExpressionStatement = function(node, expr) {
    node.expression = expr;
    return this.finishNode(node, "ExpressionStatement")
}

通过上述的acorn-jsx的代码可以发现,在源码中还是存在不少代码不规范,逻辑不够清晰的一些情况。Babel团队在拿过来后对jsx解析的这部分代码进行了不少的优化,我推荐大家可以去看一下Babel源码目录下packages/babel-parser/src/plugins/jsx/index.js中的代码。

代码优化

我们有了上述AST树后,就需要一个visitor 去遍历AST树中所有的节点。我们来看一下在Bable的jsx处理插件中是怎么做的,只需重点关注Babel源码目录下packages文件夹中babel-plugin-transform-react-jsxhelper-builder-react-jsx这两个插件即可。

当遍历AST树时,遇到某个类型的节点,我们就需要调用插件中提供的对应类型的处理函数,在babel-plugin-transform-react-jsx插件中,最终对Babel返回如下的visitor信息如下:

{
    name: "transform-react-jsx",
    // 告诉Babel启用JSX语法的解析
    inherits: jsx, // jsx -> plugin-syntax-jsx
    visitor: {
        /*
        JSXNamespacedName | JSXSpreadChild | JSXElement | JSXFragment
        使用 helper-builder-react-jsx 插件来处理
        */
        // 处理节点
        JSXElement: { ... }
        // 处理片段
        JSXFragment: { ... }
        // 处理XML的名称空间语法,默认情况下不支持该语法,除非配置项中打开 throwIfNamespace
        JSXNamespacedName: function(path) { ... }
        // 遇到 JSXSpreadChild 则直接抛出 React不支持 Spread children
        JSXSpreadChild: function(path) { ... }
        
        // 处理AST树中Program节点进入与退出时候的一些状态情况
        Program: { ... }
        // 处理开始节点上的属性值
        JSXAttribute: function(path) { ... }
    }
}

在拿到我们之前生成的AST树进行深度优先遍历时,在babel-plugin-transform-react-jsx中会最先触发根Program节点的enter方法,并在state中初始化一些状态信息,在后续的处理过程中进行使用,插件中精简后的核心代码如下:

visitor.Program = {
    enter(path, state) {
      const { file } = state;
      let pragma = PRAGMA_DEFAULT; // "React.createElement"
      let pragmaFrag = PRAGMA_FRAG_DEFAULT;// "React.Fragment"
      let pragmaSet = !!options.pragma;
      let pragmaFragSet = !!options.pragma;
    
      // 在状态中注册一个当碰到AST节点中jsxIdentifier类型时,替换为MemberExpression类型节点的处理方法
      /*{
        "type": "MemberExpression",
        "object": {
            "type": "Identifier",
            "name": "React"
        },
        "property": {
            "type": "Identifier",
            "name": "createElement"
        },
        "computed": false,
        "optional": null
      }*/
      state.set("jsxIdentifier", createIdentifierParser(pragma));
      state.set("jsxFragIdentifier", createIdentifierParser(pragmaFrag));
      state.set("usedFragment", false);
      state.set("pragmaSet", pragmaSet);
      state.set("pragmaFragSet", pragmaFragSet);
    }
};

visitor.JSXAttribute = function (path) {
    /*
    处理前
        {
            "type": "JSXAttribute",
            "value": {
                "type": "StringLiteral",
                "extra": {
                    "rawValue": "App",
                    "raw": "\"App\""
                },
                "value": "App"
            }
        }
    */
    if (t.isJSXElement(path.node.value)) {
      path.node.value = t.jsxExpressionContainer(path.node.value);
      /*
      处理后
        {
            "type": "ObjectExpression",
            "properties": [
                {
                    "type": "ObjectProperty",
                    "key": {
                        "type": "Identifier",
                        "name": "className"
                    },
                    "value": {
                        "type": "StringLiteral",
                        "value": "App",
                        "raw": "\"App\""
                    }
                }
            ]
        }
      */
    }
}

我们再来看一下helper-builder-react-jsx中核心的JSXElement节点处理的代码,核心代码精简后如下:

export default function(opts) {
  const visitor = {};
  visitor.JSXElement = {
    exit(path, file) {
      // path 表示两个节点之间连接的对象
      const callExpr = buildElementCall(path, file);
      // 使用重新替换修改后的结果替换原AST树中节点
      if (callExpr) {
        path.replaceWith(t.inherits(callExpr, path.node));
      }
    },
  };
  return visitor;

  // 处理入口
  function buildElementCall(path, file) {
    const openingPath = path.get("openingElement");
    // 将JSXElement节点下的openingElement对象中的子节点替换为新的 kv 形式的值
    openingPath.parent.children = t.react.buildChildren(openingPath.parent);

    // 处理 JSXOpeningElement 节点
    const tagExpr = convertJSXIdentifier(
      openingPath.node.name,
      openingPath.node,
    );
    const args = [];

    let tagName;
    // 如果是属性的Key的情况
    if (t.isIdentifier(tagExpr)) {
      tagName = tagExpr.name;
    } 
    // 如果是属性的Value的情况
    else if (t.isLiteral(tagExpr)) {
      tagName = tagExpr.value;
    }

    const state: ElementState = {
      tagExpr: tagExpr,
      tagName: tagName,
      args: args,
    };
    
    // 把ElementState初始化到插件执行之前的state.args中
    if (opts.pre) {
      opts.pre(state, file);
    }

    // 处理属性的逻辑并返回一个新的 ObjectExpression 节点
    let attribs = openingPath.node.attributes;
    if (attribs.length) {
      attribs = buildOpeningElementAttributes(attribs, file);
    } else {
      attribs = t.nullLiteral();
    }

    args.push(attribs, ...path.node.children);
    // 最后再在插件执行后初始化一个 JSXIdentifier 节点的处理逻辑放到state.callee中
    if (opts.post) {
      opts.post(state, file);
    }
    
    // 最终将原AST树中JSXElement节点替换为新的CallExpression节点
    return state.call || t.callExpression(state.callee, args);
  }

  // 处理 JSXOpeningElement 节点逻辑
  function convertJSXIdentifier(node, parent) {
    // 是否是一个JSXIdentifier节点。例如模板中的:div | header
    if (t.isJSXIdentifier(node)) {
      if (node.name === "this" && t.isReferenced(node, parent)) {
        return t.thisExpression();
      } else if (esutils.keyword.isIdentifierNameES6(node.name)) {
        node.type = "Identifier";
      } else {
        /*
        {
            "type": "StringLiteral",
            "value": "div"
        }
        */
        return t.stringLiteral(node.name);
      }
    } 
    // 是否是一个JSXMemberExpression节点。例如模板中的:Component.header
    else if (t.isJSXMemberExpression(node)) {
    /*
    {
        "type": "MemberExpression",
        "object": {
            "type": "Identifier",
            "name": "Component"
        },
        "property": {
            "type": "Identifier",
            "name": "header"
        },
        "computed": false,
        "optional": null
    }
    */
      return t.memberExpression(
        // 处理模板中的Component
        convertJSXIdentifier(node.object, node),
        // 处理模板中的header
        convertJSXIdentifier(node.property, node),
      );
    }

    return node;
  }

  // 处理JSXOpeningElement节点中的属性
  function buildOpeningElementAttributes(attribs, file) {
    let _props = [];
    const objs = [];

    while (attribs.length) {
      const prop = attribs.shift();
      // <xxx {...props} />
      if (t.isJSXSpreadAttribute(prop)) {
        _props = pushProps(_props, objs);
        objs.push(prop.argument);
      } 
      // <xxx id={this.props.id} />
      else {
        _props.push(convertAttribute(prop));
      }
    }

    // 在pushProps方法中包装匹配结果并生成一个 ObjectExpression 节点
    pushProps(_props, objs);

    if (objs.length === 1) {
      attribs = objs[0];
    } else {
      // 如果 objs 第一个元素不是  ObjectExpression 节点就初始化一个空节点进去
      // 这样后面执行callExpression的时候确保最终返回的是一个 ObjectExpression 节点
      if (!t.isObjectExpression(objs[0])) {
        objs.unshift(t.objectExpression([]));
      }

      const helper = useBuiltIns
        ? t.memberExpression(t.identifier("Object"), t.identifier("assign"))
        : file.addHelper("extends");

      attribs = t.callExpression(helper, objs);
    }

    return attribs;
  }

  // 判断是表达式还是普通属性
  // {} 中的内容都算是表达式
  /*
    <xxx id={id} /> <xxx {...props}> 
    <xxx id="id" />
    <div>
        { ... }
    </div>
  */
  function convertAttributeValue(node) {
    if (t.isJSXExpressionContainer(node)) {
      return node.expression;
    } else {
      return node;
    }
  }

  // 转换节点属性
  function convertAttribute(node) {
    const value = convertAttributeValue(node.value || t.booleanLiteral(true));

    // <xxx {...props}>
    if (t.isJSXSpreadAttribute(node)) {
      return t.spreadElement(node.argument);
    }

    // 转义原AST中StringLiteral节点下的value
    if (t.isStringLiteral(value) && !t.isJSXExpressionContainer(node.value)) {
      value.value = value.value.replace(/\n\s+/g, " ");
      delete value.extra?.raw;
    }

    // 根据原JSX AST节点替换新的通用节点
    if (t.isJSXNamespacedName(node.name)) {
      node.name = t.stringLiteral( node.name.namespace.name + ":" + node.name.name.name);
    } else if (esutils.keyword.isIdentifierNameES6(node.name.name)) {
      // 如果本身就是一个JSXIdentifier节点,则只替换类型即可
      node.name.type = "Identifier";
    } else {
      node.name = t.stringLiteral(node.name.name);
    }

    // 根据属性key val 生成一个新的 ObjectProperty 类型节点并返回
    return t.inherits(t.objectProperty(node.name, value), node);
  }

  function pushProps(_props, objs) {
    // 根据传入的ObjectProperty生成到一个 ObjectExpression 节点
    objs.push(t.objectExpression(_props));
    return [];
  }
}

最终一个JSXElement节点处理完毕后,将变成下面这样:

{
    "type": "CallExpression",
    "callee": {
        "type": "MemberExpression",
        "object": {
            "type": "Identifier",
            "name": "React"
        },
        "property": {
            "type": "Identifier",
            "name": "createElement"
        },
        "computed": false,
        "optional": null
    },
    "arguments": [
        {
            "type": "StringLiteral",
            "value": "header"
        },
        {
            "type": "ObjectExpression",
            "properties": [
                {
                    "type": "ObjectProperty",
                    "key": {
                        "type": "Identifier",
                        "name": "className"
                    },
                    "value": {
                        "type": "StringLiteral",
                        "value": "App-header",
                        "raw": "\"App-header\""
                    },
                    "computed": false,
                    "shorthand": false,
                    "decorators": null,
                    "trailingComments": [],
                    "leadingComments": [],
                    "innerComments": []
                }
            ]
        },
        {
            "type": "StringLiteral",
            "value": "某大厂的总监说年轻时候要"
        }
    ],
    "leadingComments": [
        {
            "type": "CommentBlock",
            "value": "#__PURE__"
        }
    ],
    "start": 22,
    "end": 74,
    "trailingComments": [],
    "innerComments": []
}

代码生成

使用@babel/traverse迭代处理完原AST树并生成新的结果后,将执行最终代码的生成,那么我们一起来看一下@babel/generator源码中是如何生成我们想要的最终代码的。

通过packages/babel-generator/src/index.js的源码可以发现,在入口文件中,Generator继承自Printer,在执行generate()方法时,实际访问的是Printer.js中实现的方法,入口处核心代码精简后如下:

export class CodeGenerator {
  constructor(ast, opts, code) {
    this._generator = new Generator(ast, opts, code);
  }
  generate() {
    return this._generator.generate();
  }
}

class Generator extends Printer {
  // ....
  
  generate() {
    return super.generate(this.ast);
  }
}

printer.js文件中,generate()方法中调用print()方法,将优化后的AST树递归进行处理,遇到相应类型节点时,执行类型节点提供的处理逻辑方法,并将处理后的最终代码推送到_buf数组中,再返回_buf中的结果值,完成最终代码生成的工作,精简后的核心代码如下:

// packages/babel-generator/src/printer.js
class Printer {
  constructor(format, map) {
    // 初始化一些变量
    
    // 存储代码字符,在最终返回代码时使用
    this._buf = new Buffer(map);
  }

  generate(ast) {
    // 传入优化后的AST树,进行最终代码输出
    this.print(ast);
    // 将 _buf 中的代码拼接为代码字符串返回
    return this._buf.get();
  }
}

print(node, parent) {
    if (!node) return;

    // 注意这里:使用优化后的AST树中的类型,去 this 上取相应类型名称的处理方法。
    const printMethod = this[node.type];
    if (!printMethod) {
      // 抛出错误
    }
    
    // 使用堆栈来维护各层级之间代码前后关系
    this._printStack.push(node);

    // 处理箭头函数的情况 () => {}
    let needsParens = n.needsParens(node, parent, this._printStack);
    if (
      this.format.retainFunctionParens &&
      node.type === "FunctionExpression" &&
      node.extra &&
      node.extra.parenthesized
    ) {
      needsParens = true;
    }
    if (needsParens) this.token("(");

    const loc = t.isProgram(node) || t.isFile(node) ? null : node.loc;
    this.withSource("start", loc, () => {
      // 使用 this 上取出的节点类型方法 
      // 例如:CallExpression() | MemberExpression()
      printMethod.call(this, node, parent);
    });

    if (needsParens) this.token(")");

    // end
    this._printStack.pop();
}

// 处理简单的token添加
token(str) {
    this._append(str);
}

_append(str, queue = false) {
    if (queue) this._buf.queue(str);
    else this._buf.append(str);
}

根据优化后的AST树中节点类型,上面的源码中printMethod.call(this, node, parent);这段代码会执行各自类型方法的具体代码,我提取了部分类型的源码放在下面,便于理解各类型节点是如何具体进行处理的,代码如下:

// packages/babel-generator/src/generators/expressions.js

// 模板中节点处理
function CallExpression(node) {
  // 往 _buf 中推一个节点
  // 执行该逻辑后将跳到 MemberExpression 中继续进行处理
  this.print(node.callee, node);

  // 一些分支逻辑
  this.print(node.typeArguments, node); // Flow
  this.print(node.typeParameters, node); // TS
  
  // 执行了MemberExpression后_buf中代码为React.createElement
  // 再向_buf中推入圆括号和括号中间的子节点处理逻辑
  this.token("(");
  this.printList(node.arguments, node);
  this.token(")");
}

// 处理节点。 React.createElement
function MemberExpression(node) {
  // 往 _buf 中推入一个 React
  this.print(node.object, node);

  // 处理与抛出一些错误

  let computed = node.computed;
  if (t.isLiteral(node.property) && typeof node.property.value === "number") {
    computed = true;
  }

  if (computed) {
    this.token("[");
    this.print(node.property, node);
    this.token("]");
  } else {
    this.token(".");
    // 往 _buf 中推入一个 createElement
    this.print(node.property, node);
  }
}

// 模板中三目运算处理。 1 === 1 ? 创建A节点 : 创建B节点
function ConditionalExpression(node) {
  // 往 _buf 中推入一个 1 === 1
  this.print(node.test, node);
  this.space();
  this.token("?");
  this.space();
  // 往 _buf 中推入表达式为真的代码
  this.print(node.consequent, node);
  this.space();
  this.token(":");
  this.space();
  // 表达式为假的代码
  this.print(node.alternate, node);
}
// packages/babel-generator/src/generators/types.js

// 处理属性 { className: "App" }
function ObjectExpression(node) {
  const props = node.properties;

  this.token("{");

  if (props.length) {
    this.space();
    this.printList(props, node, { indent: true, statement: true });
    this.space();
  }

  this.token("}");
}

有了很多的上述这种各类型处理的方法后,Babel在根据优化后AST树生成目标代码时只需调用各个方法处理目标AST即可。在printer.js中执行_append()方法后,将调用buffer.js_append()方法的实现,在printer.jsgenerate()方法最终返回时将调用get()方法,将_buf中的内容连接为最终目标代码字符串,并返回结果值。

// packages/babel-generator/src/buffer.js

_append(str, line, column, identifierName, filename, force) {
    this._buf.push(str);
    // 后续处理一些标记位置的移动逻辑
}

get() {
    const result = {
      code: this._buf.join("").trimRight()
    };
    
    return result;
}

最终get()方法将会根据我们前面提供的JSX代码,生成我们的目标代码,生成后的代码如下:

// 为了方便阅读我将文本内容转化为方便阅读的汉字
// 实际内容为unicode编码后的值,例如:\u591A

/*#__PURE__*/
React.createElement("div", {
    className: "App"
}, 
    /*#__PURE__*/ 
    React.createElement("header", {
        className: "App-header"
    }, "某大厂的总监说年轻时候要"), 
    
    1 === 1 ? 
    /*#__PURE__*/ 
    React.createElement(
        "span", null,
        "多读书、多看报、多敲代码、少睡觉") : 
    /*#__PURE__*/
    React.createElement("span", null,
        "多玩游戏、多刷头条、沉迷抖音快手"), 
    /*#__PURE__*/
    React.createElement(Component.header, {
        id: id
    })
);

至此,React中使用Babel来解析JSX并生成目标代码的分析全部结束。

总结

通过我们对artTemplateVue compilerReact jsx中各自源码的分析我们可以发现,在前端主流的框架和工具的实现中基本上遵循着词法分析 -> 语法分析 -> 语义分析 -> 中间代码生成 -> 代码优化 -> 目标代码生成 这样的一个编译的流程,对这样一个编译流程的熟悉和理解,可以方便我们在研究其他技术和造一些前端工程化工具的时候更加游刃有余,比如我们可以用 Babel 创造自定义 JS 语法

通过对JSX的解析流程的熟悉和理解,大家也可以根据这样的一个完整流程,去研究一下在Babel中是如何将TypeScript转换为js代码的、UglifyJS是如何进行代码压缩的,想必会有不少的额外收获。

最后如果你觉得本文对你有所帮助,点个赞给我一点鼓励吧!如果内容有误或者有更好的建议也欢迎给我一个反馈,让我们一起精进前行,谢谢您!

参考文献

编译原理与技术

编译原理之美