原文:通过编译器插件实现代码注入 | AlloyTeam
作者:林大妈

背景问题

大型的前端零碎个别是模块化的。每当发现问题时,模块负责人总是要反复地在浏览器中找出对应的模块,略读代码后在对应的函数内打上断点,最终开始排查。

大部分状况下,咱们会抉择在固定的地位(例如模块的入口,或是类的构造函数)打上断点。也就意味着打断点的过程对于开发者来说是机械的劳动。那么有没有方法在不污染源代码的根底上通过配置来为代码打上断点呢?

实现思路

要想不污染源代码,只能抉择在编译时进行解决,能力将想要的内容注入到指标代码中。代码编译的基本原理是将源代码解决成单词串,再将单词串组织成形象语法树,最终再通过遍历形象语法树并转换下面的节点而造成指标代码。

因而,代码注入的关键点就在于在形象语法树造成时对语法树节点进行解决。前端代码通常会应用 babel 进行编译。

相熟 babel 的基本原理

babel 的组成

babel 的外围是 babel-core。babel-core 可被划分成三个局部,别离解决对应的三个编译过程:

  1. babel-parser —— 负责将源代码字符串“单词化”并转化成形象语法树
  2. babel-traverse —— 负责遍历形象语法树并附加解决
  3. babel-generator —— 负责通过形象语法树生成指标代码

babel-parser

整个 babel-parser 应用继承的形式,依据性能的不同逐层包装:

tokenizer

babel-parser 的一个外围是“tokenizer”,能够了解为“单词生成器”。babel 保护了一个 state(一个全局的状态),它会通过记录一些元信息提供给编译器,例如:

  • “这段 JavaScript 代码是否应用了严格模式?”
  • “咱们当初辨认到第几行第几列了?”
  • “这段代码里有哪些正文?”

tokenizer 的外部定义了不同的办法以辨认不同的内容,例如:

  • 读到引号时通过 readString 办法尝试生成一个字符串 token
  • 读到数字时通过 readNumber 办法尝试生成一个数字 token

LVal/Expression/StatementParser

babel-parser 的另一个外围是“parser”,能够了解为“语法树生成器”。其中,StatementParser 是子类,当咱们引入 babel-parser 并调用 parse 办法时,辨认过程将从此处启动(babel 应该是将整个文件认为是一个语句节点)。

同样地,这些 parser 的外部也都为辨认不同的内容而定义不同的办法,例如:

  • 辨认到 true 时,生成一个 Boolean 字面量表达式
  • 辨认到 @ 这个符号时,生成一个  装璜器语句节点

babel-traverse

babel-traverse 提供办法遍历语法树。它应用访问者模式,为外界提供增加遍历时附加操作的入口。

在访问者模式(Visitor Pattern)中,咱们应用了一个访问者类,它扭转了元素类的执行算法。通过这种形式,元素的执行算法能够随着访问者扭转而扭转。

TraversalContext

遍历语法树时,babel 同样定义了一个 context 判断是否须要遍历以及遍历的形式。

TraversalContext 先将节点和外来的访问者进行无效化的解决,而后结构拜访队列,最初启动深度优先遍历整棵语法树的过程。

class TraversalContext { // ... visitQueue(queue: Array<NodePath>) { // 一些预处理 // 深度优先遍历 for (const path of queue) { if (path.visit()) { stop = true; break; } } // ... } // ...}

visitor

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

path 是每个 visitor 办法中传入的第一个参数,它示意树上的该节点与其它节点的关系。编写 babel 插件,最外围的是理解并利用好 path 上挂载的元数据以及对外裸露的 API。

path 上有以下绝对重要的属性:

  • node 节点
  • parent 父节点
  • parentPath 父节点的 path
  • container 蕴含所有同级节点的元素
  • context 节点对应的 TraversalContext
  • contexts 节点对应的多个 TraversalContext
  • scope 节点的作用域
  • ……

path 的原型上还挂载了许多其它的解决办法:

  • get (静态方法)获取节点的属性
  • insertBefore 在以后节点前减少指定的元素
  • insertAfter 在以后节点后减少指定的元素
  • unshiftContainer 将指定的节点插入该节点的 container 的首位
  • pushContainer 将指定的节点插入该节点的 container 的末位
  • ……

state

state 示意以后遍历的状态,记录了一些元信息,与 tokenizer 的 state 相似。

babel-generator

babel-generator 次要实现了两个性能:

  1. 应用缓冲区分步生成指标代码
  2. 源码映射(sourcemap)

babel-generator 裸露了 generate 函数,接管语法树、配置以及源代码为参数。其中,语法树用于生成指标代码,而源代码用作 sourcemap。babel-generator 中的代码业务逻辑较多,没有太过简单的设计,但拆分函数十分细,所有的判断以及不同种符号的解决都被拆开了,新增性能非常简单。

Buffer

buffer 中定义了一个寄存指标代码的字符串数组,以及一个寄存开端符号(空格、分号以及'n')的队列。字符串数组采纳按行插入的形式。寄存开端符号的队列用以解决行末多余的空格(即每次插入开端符号前 pop 出所有的空格)。

SourceMap

babel-generator 采纳了 npm library source-map 来构建 sourceMap。babel 在输入代码时,只有地位不是在指标代码的换行处,都会进行一次标记以提供参数给 source-map 库,目前 source-map 库具体内容还未粗疏钻研。

插件的具体实现

理解 babel 当前,联合咱们的需要,根本指标可定为:编写可配置的 babel 插件,使开发人员通过配置文件在特定地位下放断点。

babel 插件的外围是 visitor,这里咱们举一个具体而非凡的例子来形容如何实现以上的指标:

将特定的正文替换成调试语句

首先,应从 babel 结构的语法树上找到对应的正文节点。但咱们发现,在 babel 结构的语法树中,无论何种正文,都不是一个具体的节点:

例如,对于以下的代码:

// @debugconst a = 1;

在它的语法树中,正文节点只属于某段具体的代码的"leadingComments"属性,而非独立的树节点。再思考以下代码:

const a = 1;// @debugconst b = 2;

在它的语法树中,正文节点既属于第一段的"trailingComments"属性,也属于第二段代码的"leadingComments"属性。包含代码和正文同行,后果也是雷同的。

因而,在编写 visitor 前,须要留神两个点:

  1. 正文并不是特定的语法树节点,而是节点上的一个属性。
  2. 遍历所有语句时,前一句的"trailingComments"和后一句的"leadingComments"会产生反复。

采取的解决方案是:

  1. 间接在 visitor 中增加"CommentLine"属性进行解决是无用的。可抉择在 traverse 时应用"enter"办法对立检测所有节点的前后正文。
  2. “后顾”,以后节点有"trailingComments"须要替换时,要遍历后一个兄弟节点的"leadingComments"进行去重,或者每次替换时间接将正文内容删除。

残缺的 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前端工程师(社招)