深入前端生态:跟随优秀开源项目,探索高质量 npm 包 —— vite 篇 (上)

267 阅读5分钟

导读

一个优秀的开源项目往往会依赖于众多高质量的 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.loggenerateMap 方法用于生成 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 在转化的过程中做了这些事情

参考

  1. Vite package.json
  2. Acron