导读
一个优秀的开源项目往往会依赖于众多高质量的 npm 包,我将以耳熟能详的开源项目为切入点,带您深入了解当前的前端生态, 探索各种 npm 包的用法和最佳实践,进一步扩展您的知识领域。Ps: 会跳过一些非常知名的包。
acorn 和 acorn-walk
acorn 是一个轻量级的 JavaScript 解析器库,用于将 JavaScript 代码解析为抽象语法树(AST), 而acorn-walk 是一个 JavaScript 解析器库 acorn 提供的一个可扩展的访问器/遍历器插件。
所谓 AST 简单理解成一个嵌套层级很深的 object, 这个 object 包含了源码的所有信息,因为是 object, 所以我们操作起来会比操作字符串容易不少, 接下来看个简单的例子:
使用
import { parse } from 'acorn'
const code = `
function greet(name) {
console.log('Hello, ' + name + '!');
}
greet('Jevon');
`
// 使用 Acorn 解析代码并生成抽象语法树 (AST)
const ast = parse(code, { ecmaVersion: 2020 })
console.log(ast)
// 打印结果
/*
Node {
type: 'Program',
start: 0,
end: 82,
body: [
Node {
type: 'FunctionDeclaration',
start: 1,
end: 64,
id: [Node],
expression: false,
generator: false,
async: false,
params: [Array],
body: [Node]
},
Node {
type: 'ExpressionStatement',
start: 66,
end: 81,
expression: [Node]
}
],
sourceType: 'script'
}
*/
如上所示, 通过 acron 解析的 AST 是一个非常复杂的嵌套对象, 我们对于这个对象的访问、操作将会很困难, 所以这时候就轮到 acron-walk 出场:
import { parse } from 'acorn'
import { simple } from 'acorn-walk'
import escodegen from 'escodegen'
const code = `
function greet(name) {
console.log('Hello, ' + name + '!');
}
greet('Jevon');
`
// 使用 Acorn 解析代码并生成抽象语法树 (AST)
const ast = parse(code, { ecmaVersion: 2020 })
// 找到 CallExpression(函数调用表达式), 并把 greet 函数的参数 'Jevon' 换成 'Jaya'
simple(ast, {
CallExpression(node) {
if (node.callee.name === 'greet')
node.arguments = [{ type: 'Literal', value: 'Jaya' }]
},
})
// acorn本身提供 AST 转换回代码的功能, 所以只能采用 escodegen
const modifiedCode = escodegen.generate(ast)
console.log(modifiedCode)
// 打印结果
/*
function greet(name) {
console.log('Hello, ' + name + '!');
}
greet('Jaya');
*/
从上面可以看出,acron-walk 让我们可以通过访问者模式高效的访问到我们的目标节点,修改 AST,让函数最终打印的是 Jaya, 另外,因为 acron 本身没有提供 AST 转换回代码的功能,为了展示结果,我只能通过 escodegen 重新生成代码并打印。
vite 如何使用 acron、acron-walk
这里就需要先简单介绍一下 vite 的 Glob Import 功能, 大概就是将
const modules = import.meta.glob('./dir/*.js', { eager: true })
转化为
// code produced by vite
import * as __glob__0_0 from './dir/foo.js'
import * as __glob__0_1 from './dir/bar.js'
const modules = {
'./dir/foo.js': __glob__0_0,
'./dir/bar.js': __glob__0_1,
}
而, acron 扮演的角色就是通过 parseExpressionAt(与 parse 函数类似, 只不过是可以指定字符串开始解析的位置) 解析 import.meta.glob('./dir/*.js', { eager: true }) 为 AST , 然后,acorn-walk 库则通过findNodeAt 在 AST 中查找函数调用的节点(CallExpression),并提取出 ./dir/*.js 和 { eager: true } 这两个参数,这样可以方便地提取导入语法中的信息,并进行进一步的处理和解析。
// 源码地址:https://github.com/vitejs/vite/blob/fd9a2ccdf2e78616ff3c937538111dbe1586f537/packages/vite/src/node/plugins/importMetaGlob.ts#L228
import { findNodeAt } from 'acron-walk'
import { parseExpressionAt } from 'acron'
// 代码省略...
ast = parseExpressionAt(code, start, {
ecmaVersion: 'latest',
sourceType: 'module',
ranges: true,
onToken: (token) => {
lastTokenPos = token.end
},
})
// 代码省略...
// 找到 CallExpression 节点, 即 import.meta.glob(....)
const found = findNodeAt(ast as any, start, undefined, 'CallExpression')
if (!found) throw err(`Expect CallExpression, got ${ast.type}`)
ast = found.node as unknown as CallExpression
if (ast.arguments.length < 1 || ast.arguments.length > 2)
throw err(`Expected 1-2 arguments, but got ${ast.arguments.length}`)
// 取出两个参数
const arg1 = ast.arguments[0] as ArrayExpression | Literal | TemplateLiteral
const arg2 = ast.arguments[1] as Node | undefined
综上,当你有需要对源代码进行某些转换,提取操作的时候, 可以考虑这两个包。
接下来介绍另一种操作源代码思路的包:magic-string。
magic-string
假设你有一些源代码。你想对它进行轻微修改-在某些地方替换几个字符,用头和尾包装它等-最好能在最后生成一个sourceMap。你已经考虑过使用像 recast 这样的工具(它允许您从一些 JavaScript 中生成 AST,对其进行操作,并重新打印它以及sourceMap,同时不会丢失评论和格式化),但它似乎对您的需求来说过于复杂了(或者源代码不是 JavaScript)。
你的要求实际上相当独特。但这也是我的需求,我为此制作了 magic-string。这是一个小巧快速的字符串操作工具,可以生成源映射。
正如它的介绍所说, magic-string 主要针对的就是轻微的源代码修改需求,AST 操作对于某些需求来说,太重了。而如果说,我们通过 js 原生的字符串 api 来操作,生成 sourceMap 将会是一个难题,这也是 magic-string 诞生的原因所在。
使用
比如我有个需求,需要把项目中的页面的console.log(...) 替换成 console.warn(....), 项目采用 vite 构建,这样我就可以写个vite 插件,代码如下:
import MagicString from 'magic-string'
export default function myPlugin() {
return {
name: 'vite-plugin-replace-log',
transform(code: string, id: string) {
if (/\.vue$/.test(id)) {
// 匹配所有的console.log()
const logRE = /console.(log)(.*)/g
const matches = [...code.matchAll(logRE)]
const s = new MagicString(code)
matches.forEach((match) => {
const startIndex = match.index!
const endIndex = match[0].length + startIndex
const content = match[1] // console.log()中的内容
// 替换console.log
s.update(startIndex, endIndex, `console.warn(${content})`)
})
// 生成source map
const map = s.generateMap({
hires: 'boundary',
source: id,
})
return {
code: s.toString(),
map,
}
}
},
}
}
这个 vite 插件在 transform 钩子中,通过 matchAll 的方法匹配到 vue 文件中所有的 console.log(...), 然后挨个替换为 console.warn, 最后返回转化过的 code 和 source map。
例子中用到了 magic-string 的 update 方法用于替换console.log,generateMap 方法用于生成 source map。
当然,这个例子是个伪需求,但也充分表达了 magic-string 起到的作用。
vite 使用 magic-string
vite 在其内部的一个插件 vite:dynamic-import-vars 使用到了 magic-string,首先我们需要了解 vite:dynamic-import-vars 做的事情, 通过静态分析将
const str = 'bar'
import(`./foo/${str}.js`)
转化为
import('./foo/bar.js')
转化的思路就是先将动态路径的 import
import(`./foo/${str}.js`)
转化为
__variableDynamicImportRuntimeHelper((import.meta.glob("./foo/*.js")), `./foo/${str}.js`)
而 import.meta.glob("./foo/*.js")),根据 vite 文档会转化为这样
Object.assign({"./foo/bar.js": () => import("./foo/bar.js")}
另外, __variableDynamicImportRuntimeHelper 是这样的
export default (glob, path) => {
const v = glob[path];
if (v) {
return typeof v === 'function' ? v() : Promise.resolve(v);
}
return new Promise((_, reject) => {
(typeof queueMicrotask === 'function' ? queueMicrotask : setTimeout)(reject.bind(null, new Error('Unknown variable dynamic import: ' + path)));
});
}
function dynamicImportVarsPlugin(config: ResolvedConfig): Plugin {
// 省略代码...
return {
name: 'vite:dynamic-import-vars',
// 省略代码...
async transform(source, importer) {
// 省略代码...
let s: MagicString | undefined
// 省略代码...
// 将动态路径的 import 改为 __variableDynamicImportRuntimeHelper()
s.overwrite(
expStart,
expEnd,
`__variableDynamicImportRuntimeHelper(${glob}, \`${rawPattern}\`)`,
)
if (s) {
if (needDynamicImportHelper) {
// 往当前文件首部添加 __variableDynamicImportRuntimeHelper的导入
s.prepend(
`import __variableDynamicImportRuntimeHelper from "${dynamicImportHelperId}";`,
)
}
return {
code: s.toString(),
map:
config.command === 'build' && config.build.sourcemap
? s.generateMap({ hires: 'boundary', source: id })
: null,
}
}
},
}
}
因此我们可以动态的导入./foo/bar.js, 而 magic-string 在转化的过程中做了这些事情