让vite支持你的老项目

3,988 阅读7分钟

相信大家现在都应该已经知道 vite 了吧,比起 webpack, Rollup 和 Parcel,它可以给前端开发者带来更好的开发体验。

为什么开发这个插件

vite 之所以可以这么快,主要是得益于浏览器的模块化支持,它可以在只请求需要的模块,而不需要将整个应用进行打包。

既然依赖于浏览器的模块化支持,也就意味着仅支持 es module,其他的模块化如果不经过代码转化,是无法运行。所以为了在老项目中使用vite,我就花了点时间,使用 babel 通过 AST 将 commonjs 转为 es module。如此一来,就可以兼容使用commonjs的老项目了。

成果

花费了两三天的时间,总算是做出一个还算满意的东西出来。

效果图

图片如果展示不全,可放大查看。 QQ图片20210311221347.png

安装

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进行检索再替换,相对来说就安全多了。

  1. @babel/parser将 文本 转为 AST
  2. @babel/traverse 遍历AST并替换指定节点
  3. @babel/generator 将 AST 重新转换为 文本
  4. @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")

image.png

在线AST看到的结构如图所示,可以看到

  • 转换前是 VariableDeclaration 类型,相关的参数都放在 declarations属性内部,import 语句应该只取数组的第一个就足够了

  • 转换后是 importDeclaration 类型,用 ts 编写时,类型提示可以看到的需要的是 specifierssource

  • 此处使用 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 = () => {}

需要注意:

  1. exports['default'] = 123需要替换为 ExportDefaultDeclaration,得和其他类型进行区分
  2. const a = 'sd'; exports.a = a;这种语法在commonjs中是支持的,如果直接转换成 es module的话,就变成了 const a = 'sd'; export const a = a;,这种语法是不对的。那么,此时就需要检测该变量在作用域内是否已定义,如果是的话,就需要进行变量重命名。
  3. 导出表达式的右边既可能是 引用类型,也有可能是 原始类型。原始类型不能直接用作 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,欢迎大家反馈。

参考网址: