原文:经过编译器插件实现代码注入 | AlloyTeam
做者:林大妈前端
大型的前端系统通常是模块化的。每当发现问题时,模块负责人老是要重复地在浏览器中找出对应的模块,略读代码后在对应的函数内打上断点,最终开始排查。node
大部分状况下,咱们会选择在固定的位置(例如模块的入口,或是类的构造函数)打上断点。也就意味着打断点的过程对于开发者来讲是机械的劳动。那么有没有办法在不污染源代码的基础上经过配置来为代码打上断点呢?webpack
要想不污染源代码,只能选择在编译时进行处理,才能将想要的内容注入到目标代码中。代码编译的基本原理是将源代码处理成单词串,再将单词串组织成抽象语法树,最终再经过遍历抽象语法树并转换上面的节点而造成目标代码。web
所以,代码注入的关键点就在于在抽象语法树造成时对语法树节点进行处理。前端代码一般会使用 babel 进行编译。算法
babel 的核心是 babel-core。babel-core 可被划分红三个部分,分别处理对应的三个编译过程:npm
整个 babel-parser 使用继承的方式,根据功能的不一样逐层包装:数组
babel-parser 的一个核心是“tokenizer”,能够理解为“单词生成器”。babel 维护了一个 state(一个全局的状态),它会经过记录一些元信息提供给编译器,例如:浏览器
tokenizer 的内部定义了不一样的方法以识别不一样的内容,例如:babel
babel-parser 的另外一个核心是“parser”,能够理解为“语法树生成器”。其中,StatementParser 是子类,当咱们引入 babel-parser 并调用 parse 方法时,识别过程将今后处启动(babel 应该是将整个文件认为是一个语句节点)。前端工程师
一样地,这些 parser 的内部也都为识别不一样的内容而定义不一样的方法,例如:
babel-traverse 提供方法遍历语法树。它使用访问者模式,为外界提供添加遍历时附加操做的入口。
在访问者模式(Visitor Pattern)中,咱们使用了一个访问者类,它改变了元素类的执行算法。经过这种方式,元素的执行算法能够随着访问者改变而改变。
遍历语法树时,babel 一样定义了一个 context 判断是否须要遍历以及遍历的方式。
TraversalContext 先将节点和外来的访问者进行有效化的处理,而后构造访问队列,最后启动深度优先遍历整棵语法树的过程。
class TraversalContext { // ... visitQueue(queue: Array<NodePath>) { // 一些预处理 // 深度优先遍历 for (const path of queue) { if (path.visit()) { stop = true; break; } } // ... } // ... }
babel 使用的访问者模式,很是地利于开发者编写插件。编写 babel 插件的核心思路就是编写 visitor,以附加对语法树进行的操做。
在 babel 中,visitor 是一个对象(能够经过 babel 的 ts 声明文件找到类型规范),经过在这个对象中新增 key(须要访问的节点)和 value(执行的函数)可使遍历语法树时对应执行指定的操做:
// 如:编写一个插件,每次遍历到标识符时就输出该变量名 const visitor = { Identifier(path, state) { console.log(path.node.name); }, }; // 或 const visitor = { Identifier: { enter(path, state) { console.log(path.node.name); }, exit() { // do nothing... }, }, };
path 是每一个 visitor 方法中传入的第一个参数,它表示树上的该节点与其它节点的关系。编写 babel 插件,最核心的是了解并利用好 path 上挂载的元数据以及对外暴露的 API。
path 上有如下相对重要的属性:
path 的原型上还挂载了许多其它的处理方法:
state
state 表示当前遍历的状态,记录了一些元信息,与 tokenizer 的 state 相似。
babel-generator 主要实现了两个功能:
babel-generator 暴露了 generate 函数,接收语法树、配置以及源代码为参数。其中,语法树用于生成目标代码,而源代码用做 sourcemap。babel-generator 中的代码业务逻辑较多,没有太过复杂的设计,但拆分函数很是细,全部的判断以及不一样种符号的处理都被拆开了,新增功能很是简单。
buffer 中定义了一个存放目标代码的字符串数组,以及一个存放末尾符号(空格、分号以及'n')的队列。字符串数组采用按行插入的方式。存放末尾符号的队列用以处理行末多余的空格(即每次插入末尾符号前 pop 出全部的空格)。
babel-generator 采用了 npm library source-map 来构建 sourceMap。babel 在输出代码时,只要位置不是在目标代码的换行处,都会进行一次标记以提供参数给 source-map 库,目前 source-map 库具体内容还未细致研究。
了解 babel 之后,结合咱们的需求,基本目标可定为:编写可配置的 babel 插件,使开发人员经过配置文件在特定位置下放断点。
babel 插件的核心是 visitor,这里咱们举一个具体而特殊的例子来描述如何实现以上的目标:
首先,应从 babel 构造的语法树上找到对应的注释节点。但咱们发现,在 babel 构造的语法树中,不管何种注释,都不是一个具体的节点:
例如,对于如下的代码:
// @debug const a = 1;
在它的语法树中,注释节点只属于某段具体的代码的"leadingComments"属性,而非独立的树节点。再考虑如下代码:
const a = 1; // @debug const b = 2;
在它的语法树中,注释节点既属于第一段的"trailingComments"属性,也属于第二段代码的"leadingComments"属性。包括代码和注释同行,结果也是相同的。
所以,在编写 visitor 前,须要注意两个点:
采起的解决方案是:
完整的 visitor 代码以下:
export const visitor = { enter(path) { addDebuggerToDebugCommentLine(path); // 添加其它的处理方法…… }, }; // 经过key值防止重复 let dulplicationKey = null; function addDebuggerToDebugCommentLine(path) { const node = path.node; if (hasLeadingComments(node)) { // 遍历全部的前缀注释 node.leadingComments.forEach((comment) => { const content = comment.value; // 检测该key值与防重复key值相同 if (path.key === dulplicationKey) { return; } // 检测注释是否符合debug模式 if (!isDebugComment(content)) { return; } // 传入参数,插入调试代码 path.insertBefore(); }); } if (hasTrailingComments(node)) { // 遍历全部的后缀注释 node.trailingComments.forEach((comment) => { const content = comment.value; // 检测注释是否符合debug模式 if (!isDebugComment(content)) { return; } // 防止下一个sibling节点重复遍历注释 dulplicationKey = path.key + 1; // 传入参数,插入调试代码 path.insertBefore(); }); } }
上述的例子之因此说特殊,是由于注释不是语法树上的节点,而是节点上的一个属性。当仅须要识别某类节点时,方法就更为简单了,直接经过为 visitor 定义更多的方法便可完成:
export const visitor = { Expression(path) { addDebuggerToExpression(path); }, Statement(path) { addDebuggerToStatement(path); }, // 添加其它须要的方法…… };
当出现更复杂的状况(例如要在调试语句中传入参数)时,丰富以上的函数。经过使用解析注释或在 webpack loader 中解析配置项文件得到参数,对应传入便可。
根据以上的代码编译出的代码是通过处理后的代码。它部署到某个测试环境后,有如下的用途:
了解插件知识后,咱们能够总结出插件的最大特色:几乎能够在代码任意处修改任意内容。理论上,只要逻辑打通,语法树有无穷的玩法。例如刚才提到的根据配置下放调试代码和常见的单测覆盖率统计等。
所以,还能够对插件进行更高级的抽象,作成插件工厂,可供用户配置生成对应功能的插件并从新执行编译等。
AlloyTeam 欢迎优秀的小伙伴加入。
简历投递: alloyteam@qq.com
详情可点击 腾讯AlloyTeam招募Web前端工程师(社招)