相信大家现在都应该已经知道 vite 了吧,比起 webpack, Rollup 和 Parcel,它可以给前端开发者带来更好的开发体验。
为什么开发这个插件
vite 之所以可以这么快,主要是得益于浏览器的模块化支持,它可以在只请求需要的模块,而不需要将整个应用进行打包。
既然依赖于浏览器的模块化支持,也就意味着仅支持 es module,其他的模块化如果不经过代码转化,是无法运行。所以为了在老项目中使用vite,我就花了点时间,使用 babel 通过 AST 将 commonjs 转为 es module。如此一来,就可以兼容使用commonjs的老项目了。
成果
花费了两三天的时间,总算是做出一个还算满意的东西出来。
效果图
图片如果展示不全,可放大查看。
安装
npm i cjs2esmodule
// 或者
yarn add cjs2esmodule
使用
在vite中使用
该方式会使用 babel 转换 AST,所以如果速度慢的话,推荐使用脚本直接转换文件
import { defineConfig } from 'vite'
import { cjs2esmVitePlugin } from 'cjs2esmodule'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [cjs2esmVitePlugin()]
})
使用脚本直接转换文件
底层使用了glob,所以文件匹配模式遵循 glob
const { transformFiles } = require('cjs2esmodule')
transformFiles('./scripts/test.js')
// 支持数组
transformFiles(['./utils/*.js', './components/*.js'])
实现思路
以前写脚本我都是基于正则直接替换,鉴于这次的转换工作比较麻烦,使用正则替换风险较大,所以就想到了babel。
借助于 babel,可以将js文本转为AST,对AST进行检索再替换,相对来说就安全多了。
- @babel/parser将 文本 转为 AST
- @babel/traverse 遍历AST并替换指定节点
- @babel/generator 将 AST 重新转换为 文本
- @babel/types 检索AST的重要工具,各种类型
上面最重要的就是 遍历AST并替换指定节点 了,AST字如其名,对于常人来说还是挺抽象的,所以可以用 在线AST 来查看需要替换的类型。
伪代码
import * as parser from "@babel/parser";
import traverse from "@babel/traverse";
import generate from "@babel/generator";
import * as t from '@babel/types'
export const visitors = {
VariableDeclaration(path) {
},
}
export const transformFileBase = (src) => {
try {
const ast = parser.parse(src);
traverse(ast, visitors);
return generate(ast,);
} catch(err) {
// sourceType 默认为 script,如果报错,则用 module 解析
const ast = parser.parse(src, { sourceType: "module" });
traverse(ast, visitors);
return generate(ast,);
}
}
visitors对象的每个key对应的第一个参数 path,对应的结构体如下:
{
state: undefined, // babel plugin传入的,本次未使用
node: Node, // 类型,对应上面在线网址看到的那些结构体
parent: Node, // 父级Node,无法进行替换
parentPath: NodePath, // 父级NodePath,可进行替换
scope: Scope, // 作用域相关,可用于变量重命名,变量绑定关系检测等
}
替换 require
1.首先仅替换最常用的 const react = require("react")
在线AST看到的结构如图所示,可以看到
-
转换前是 VariableDeclaration 类型,相关的参数都放在 declarations属性内部,import 语句应该只取数组的第一个就足够了
-
转换后是 importDeclaration 类型,用 ts 编写时,类型提示可以看到的需要的是 specifiers 和 source
-
此处使用 importDefaultSpecifier ,类型参考
declare function importDefaultSpecifier(local: Identifier): ImportDefaultSpecifier
在 babel 的文档里看到的 NodePath 的一些get方法,实际使用中容易搞错,所以就写了两个小函数,防止报错。
const reg = /(\w+)\[(\d+)\]/
type nameType = string | Array<string>
const _get = (obj: any, names: nameType) => {
let keys = Array.isArray(names) ? names : names.split('.')
return keys.reduce((prev, next) => {
if (reg.test(next)) {
const [_, name, index] = next.match(reg)
return prev && prev[name] && prev[name][index]
} else {
return prev && prev[next]
}
}, obj)
}
export const getSafe = (obj: any, names: nameType, defaultValue?: any) => _get(obj, names) || defaultValue
/** 有的节点,取值的时候用name,有的时候用value,用此函数可以统一获取 */
export const getName = (obj: any, names: string,) => getSafe(obj, `${names}.name`,) || getSafe(obj, `${names}.value`,)
下面就开始正式编写了:
export function replaceWithDefault(ctr, path) {
// const react1 = require('react')
// ctr.id.name: react1
// ctr.init.object.arguments[0].name: react
if (ctr.init.callee.name === 'require') {
path.replaceWith(
t.importDeclaration(
[
t.importDefaultSpecifier(ctr.id),
],
// source StringLiteral
ctr.init.arguments[0]
)
)
}
}
VariableDeclaration(path) {
// path.node 对应 在线AST 看到的结构
// 获取declarations第一个元素即可
const ctr = path.node.declarations[0]
try {
// 如果 = 右边是执行的require才替换
if (getName(ctr, 'init.callee') === 'require') {
replaceWithDefault(ctr, path)
}
} catch(err) {
console.log(err)
}
}
require变量赋值语句的其他情况
2.替换 const cmp = require('react').Component
这种情况主要就是将 VariableDeclaration 类型 替换为 ImportDeclaration 类型,类型的定义如下:
declare function importDeclaration(
specifiers: Array<ImportSpecifier | ImportDefaultSpecifier | ImportNamespaceSpecifier>,
source: StringLiteral
): ImportDeclaration;
替换功能如下所示:
export function replaceWithProp(ctr, path) {
// const cmp = require('react').Component
// ctr.id.name: cmp
// ctr.init.property.name: Component
// ctr.init.object.arguments[0].name: react
if (getName(ctr, `init.object.callee`) === 'require') {
path.replaceWith(
t.importDeclaration(
[
t.importSpecifier(ctr.id, ctr.init.property),
],
// source StringLiteral
ctr.init.object.arguments[0]
)
)
}
}
VariableDeclaration(path) {
const ctr = path.node.declarations[0]
try {
if (getSafe(ctr, 'init.property')) {
replaceWithProp(ctr, path);
} else if (getName(ctr, 'init.callee') === 'require') {
replaceWithDefault(ctr, path)
}
} catch(err) {
console.log(err)
}
},
3. 替换const { react: react1, Component: cmp } = require('react')
这种情况也是将 VariableDeclaration 类型 替换为 ImportDeclaration 类型,但是要注意,解构时可能有多个值被解构出来,所以 ctr.id.properties是数组 而不是对象,构造 importDeclaration 时第一个参数要传入 多个 importSpecifier,而不是单个。
export function replaceWithRest(ctr, path) {
// const { react: react1 } = require('react')
if (ctr.init.callee.name === 'require') {
path.replaceWith(
t.importDeclaration(
ctr.id.properties.map(v => t.importSpecifier(v.value, v.key,)),
// source StringLiteral
ctr.init.arguments[0]
)
)
}
}
VariableDeclaration(path) {
const ctr = path.node.declarations[0]
try {
if (getSafe(ctr, 'init.property')) {
replaceWithProp(ctr, path);
} else if (ctr.id.type === 'ObjectPattern') {
replaceWithRest(ctr, path)
} else if (getName(ctr, 'init.callee') === 'require') {
replaceWithDefault(ctr, path)
}
} catch(err) {
console.log(err)
}
},
4. 替换require("index.less")
这种不赋值的表达式就不属于上述的变量声明了,而是 调用表达式(CallExpression)。
针对于 CallExpression 做替换时,需要替换的是它的 parentPath,而不是它本身,否则替换的结果就是 (function() { import "./index.less" })()
,而不是我们所期望的 import "./index.less"
了。
export function replaceWithRequire(path) {
// require('react')
if (path.node.callee.name === 'require') {
path.parentPath.replaceWith(
t.importDeclaration(
[],
// source StringLiteral
path.node.arguments[0]
)
)
}
}
CallExpression(path) {
if (t.isExpressionStatement(path.parent)) {
replaceWithRequire(path)
}
},
替换exports
这种的都是基于 AssignmentExpression 做替换。
1. 替换exports.setCookie = () => {}
需要注意:
exports['default'] = 123
需要替换为 ExportDefaultDeclaration,得和其他类型进行区分const a = 'sd'; exports.a = a;
这种语法在commonjs中是支持的,如果直接转换成 es module的话,就变成了const a = 'sd'; export const a = a;
,这种语法是不对的。那么,此时就需要检测该变量在作用域内是否已定义,如果是的话,就需要进行变量重命名。- 导出表达式的右边既可能是 引用类型,也有可能是 原始类型。原始类型不能直接用作 VariableDeclarator 构造的第一个参数,所以需要判断是否为Identifier, 如果不是 Identifier,就构造一个Identifier,否则直接使用。
export function replaceExports(node, path) {
const objName = getSafe(node, 'left.object.name');
const name = getName(node, 'left.property')
if (node.operator === '=') {
if (objName === 'exports') {
// 变量已绑定,重新命名
if (path.scope.hasBinding(name)) {
path.scope.bindings[name].scope.rename(name);
}
if (name === 'default') {
// exports['default'] = 213
path.parentPath.replaceWith(
t.exportDefaultDeclaration(node.right)
)
} else {
// exports.a = 213
path.parentPath.replaceWith(
t.exportNamedDeclaration(
t.variableDeclaration(
"const",
[
t.variableDeclarator(node.left.property.type === 'Identifier' ? node.left.property : t.identifier(node.left.property.value), node.right)
]
)
)
)
}
}
}
}
AssignmentExpression(path) {
try {
const { node } = path
replaceExports(node, path)
} catch(err) {
console.log(err)
}
},
2. 替换module.exports = 123
一般情况下直接替换为 默认导出 即可。
if (objName === 'module' && name === 'exports') {
// module.exports = 123
path.parentPath.replaceWith(
t.exportDefaultDeclaration(node.right)
)
}
3. 替换module.exports = exports["default"]
这就是上面所说的非一般情况了,如果直接替换,生成的语法必然是报错的,如export default exports["default"]
。
检测到这种情况时,需要将该条语句直接删除,因为 exports["default"]
已经声明过一次默认导出了。
if (objName === 'module' && name === 'exports') {
if (getName(node, 'right.object') === 'exports'
&& getName(node, 'right.property') === 'default'
) {
// module.exports = exports["default"]
// 其他地方已转换,直接删除
path.parentPath.remove()
} else {
// module.exports = 123
path.parentPath.replaceWith(
t.exportDefaultDeclaration(node.right)
)
}
}
制作为vite组件
vite plugin文档里提供了很多生命周期,此处仅使用了 transform
,基本满足需要了。如果需要该插件提供其他配置,可在下方评论。
- 默认只有 src 文件夹下的 js 文件,存在使用 commonjs 模块。
- 需要检测js文件内容中符合该正则
/(exports[\.\[]|module\.exports|require\()/g.test(content)
,才会进行内容转换
/**
* @param sourceDir 监听的目录,默认是src
* @returns
*/
export function cjs2esmVitePlugin({ sourceDir = 'src' } = {}) {
return {
name: 'cjs2esmVitePlugin',
transform(src, id) {
const ROOT = process.cwd().replace(/\\/g, '/') + `/${sourceDir}`
if (/\.js$/g.test(id) && id.startsWith(ROOT) && isCjsFile(src)) {
// const { code, map } = transformFileBase(src, id)
return transformFileBase(src)
}
}
}
}
至此,一个基于 AST 转换 js代码的插件就已经制作完毕了。在常规的非es module 转换为 commonjs 的项目中,已经可以使用 vite 进行正常开发了。
项目地址 https://github.com/ma125120/cjs2esmodule,求star,欢迎大家反馈。
参考网址: