从export视角对比esm与cjs

背景

最近在编写一个nodejs执行的npm库,技术上选择了Typescript提高代码可维护性与可读性,然后使用rollup进行打包,产物包括了lib.js与类型文件lib.d.ts。但在使用时发现类型文件与js执行文件的数据格式对应不上。在定位这个问题的同时,也回顾了对js不同模块规范转化的知识。

问题定位

项目打包使用了rollup的两个插件:@rollup/plugin-typescriptrollup-plugin-dts,前者用于解析typescript代码,后者用于生成dts类型文件。

// rollup.config.ts
import typescript from '@rollup/plugin-typescript';
import dts from 'rollup-plugin-dts';

export default [
  {
    input: 'src/lib.ts',
    output: {
      file: 'lib.d.ts',
      format: 'cjs',
    },
    plugins: [typescript(), dts()],
  },
  {
    input: 'src/lib.ts',
    output: {
      file: 'lib.js',
      format: 'cjs',
    },
    plugins: [typescript()],
  },
];
// src/lib.ts
export default {
  info: 'info',
};

查看rollup构建产物,对比lib.js与类型文件lib.d.ts的输出。

// lib.js
// ... 前略
module.exports = lib;

// lib.d.ts
// ... 前略
export { _default as default }

可以看出dts文件的export包含了default参数,而js文件没有,这就是为什么在使用时,类型提示错误。

接下来就需要我们找到为什么生成了两个不同的数据格式。

技术基石

1. ESM

esm(ECMAScript module)是js官方的规则,目前大部分浏览器都原生支持esm语法。

esm使用export default进行模块导出,import进行模块导入。

esm的引用是值的引用。意思是,esm模式下,导入的是模块的物理地址,所以当值修改时,该值的原始变量也会修改。

esm在编译时就会加载模块的引用,所以在不同运行时间,每次都能获取到最新的导入参数。根据esm提前加载模块的引用的规则,可以在编译时获取模块树并将未使用的模块进行清除,也就是tree shaking。

2. CommonJS

commonjs是社区规范。node.js应用了commonjs,但在13.2.0版本开始也支持了esm语法,但需要配置,参考node.js文档 nodejs.org/api/esm.htm…

commonjs的语法是使用module.exports进行模块导出,require进行模块导入。

commonjs的引用是值的拷贝。commonjs导入的时候,会对缓存的模块进行浅拷贝,相当于function的传入参数。在这种情况下,直接对引用进行赋值将不会修改原始数据,但如果是对象的参数修改,可以影响到其他模块。

commonjs在运行时加载模块,所以如果需要tree shaking,必须先转换为esm。且由于模块导入的时值的复制,如果在export之后再对原始数据进行修改,将不会传递到外部模块中。

3. rollup

rollup是基于ES6规范的模块打包工具,他的配置简单,易于上手,所以很多npm库都使用rollup进行打包。而借助esm编译时加载的特性,rollup可以对打包进行tree shaking。

本项目使用了rollup,所以tsconfig中需要设置module为ES6表示基于ESM模块规范。

4. ESM to CommonJS

rollup与Webpack的打包策略不同,Webpack通过iife的方式实现模块规范的ployfill,所以在Webpack的构建产物中,会包含大量__Webpack_require__等属性的代码,这部分就是Webpack内部实现的模块规范。而rollup不会去做模块规范的ployfill,而是使用对应模块规范的语法。

因此rollup的打包体积比Webpack更小,但这也意味着Webpack可以在浏览器以commonjs的模块规范运行,而rollup不支持。

回到我们的问题:ESM如何转换成CommonJS模块规范呢?

如果是使用Webpack:

  1. 对于export default的变量,最终会转换为具有属性名为default的导出对象。而调用的代码也会加上default的属性。相当于esm没有直接覆盖module.export的方法,这就是为什么esm的基本类型参数修改也可以全局统一。
  2. 导出相关的代码会提高至具体代码执行前,即先进行导出参数的初始化再执行赋值代码,符合esm模块的规范。导出的是参数的引用地址。
  3. 通过设置Symbol.toStringTag与__esModule属性,标识当前模块为esm模块

而rollup的实现方案更为简单,由于不需要对规范做ployfill,rollup只需要处理导入导出语法即可:

rollup将export default xxx转换为module.exports = xxx,但在包含了具名导出的情况下会将export default转换为exports.default = a

// your-lib package entry
export default 'foo';
export const bar = 'bar';

// a CommonJS consumer
/* require( "your-lib" ) returns {default: "foo", bar: "bar"} */
const foo = require('your-lib').default;
const bar = require('your-lib').bar;
/* or using destructuring */
const { default: foo, bar } = require('your-lib');

这就是为什么项目中的js文件会输出module.exports = lib;的数据格式。

Rollup源码 expor处理相关源码:
从export视角对比esm与cjs

从export视角对比esm与cjs

解决问题

那么dts文件的数据格式又是怎么回事呢?让我们查看rollup-plugin-dts的源码:

// https://github.com/Swatinem/rollup-plugin-dts/blob/master/src/transform/index.ts
    outputOptions(options) {
      return {
        ...options,
        chunkFileNames: options.chunkFileNames || "[name]-[hash].d.ts",
        entryFileNames: options.entryFileNames || "[name].d.ts",
        format: "es",
        exports: "named",
        compact: false,
        freeze: true,
        interop: "esModule",
        generatedCode: Object.assign({ symbols: false }, options.generatedCode),
        strict: false,
      };
    },

可以看到rollup-plugin-dts设置了rollup.ouput.exports = 'named',这是rollup提供用于修改exports转换策略的参数。

如果为named表示始终使用具名导出,default表示始终使用默认导出。这个参数的默认值auto就是根据导出方式自动选择转换策略。

总结一下,由于rollup-plugin-dts设置了rollup.ouput.exports = 'named',所以export default lib也不会自动转换为export lib。但是在生成js的配置中却因为rollup.ouput.exports的默认值auto,将commonjs的输出转换为module.exports = lib;

为了解决这个问题,最简单的方式就是将生成js的配置rollup.ouput.exports设置为named,在rollup.config.ts里简单配置实现。

// rollup.config.ts
import typescript from '@rollup/plugin-typescript';
import dts from 'rollup-plugin-dts';

export default [
  {
    input: 'src/lib.ts',
    output: {
      // output: 'named', 固定值
      file: 'lib.d.ts',
      format: 'cjs',
    },
    plugins: [typescript(), dts()],
  },
  {
    input: 'src/lib.ts',
    output: {
      output: 'named',
      file: 'lib.js',
      format: 'cjs',
    },
    plugins: [typescript()],
  },
];

也可以修改ts文件的导出,不使用default导出,而是直接使用export xxx进行导出。此时rollup会自动检测到文件使用了具名导出,与设置rollup.ouput.exports设置为named相同效果。

// src/lib.ts
export const info = 'info'

总结

本文探讨了使用TypeScript和Rollup打包Node.js npm库时,出现类型文件与执行文件不对应的问题,并通过对ESM、CommonJS、Rollup等技术的介绍,解释了为什么生成了两个不同的数据格式。最后介绍了ESM转换成CommonJS模块规范的方案,以解决该问题。

PS: 正处chatGPT大潮中,一开始也尝试使用chatGPT寻找答案,但是chatGPT自信地回复了不存在的API。看来对专业领域chatGPT能力还有待提高,失望之余还有些庆幸。

从export视角对比esm与cjs

参考链接

rollupjs.org/configurati…

github.com/Swatinem/ro…

juejin.cn/post/705475…

原文链接:https://juejin.cn/post/7217724300945686583 作者:深海丧鱼

(0)
上一篇 2023年4月4日 上午10:31
下一篇 2023年4月4日 上午10:41

相关推荐

发表回复

登录后才能评论