前言

本文将简述前端通过不打包的方式将代码运行在浏览器上的几种方式

原理与浏览器中的 ESM

当下,主流浏览器已经逐步支持 js 原生模块的导入,出现了很多 bundless 的构建方案,比如 vite,snowpack 等。原理就是利用浏览器对于原生 esm 的支持,进行代码的开发和部署。

Native Import: import from URL

通过script[type=module],可以直接在浏览器中使用原生 ESM,使得前端不打包成为可能

1
2
3
<script type="module">
import lodash from "http://cdn.skypack.dev/lodash";
</script>

由于前端运行在浏览器中,所以他也只能从 URL 中引入 package

  1. 绝对路径:http://cdn.skypack.dev/lodash
  2. 相对路径:./lib.js

现在打开浏览器控制台,把下面的代码粘贴在控制台中,就可以调试 loadash 了。

1
2
3
> lodash = await import('https://cdn.skypack.dev/lodash')

> lodash.get({ a: 3 }, 'a')

Import Map

不过 http Import 每次都要导入完全的 URL,这个肯定是不太合理的,相比以前的裸导入,很不方便,如下:

1
import lodash from "lodash";

他不同于 nodejs 依赖系统文件系统,层层寻找node_modules

1
2
3
4
/home/app/packages/project-a/node_modules/lodash/index.js
/home/app/packages/node_modules/lodash/index.js
/home/app/node_modules/lodash/index.js
/home/node_modules/lodash/index.js

在 esm 中,可以通过 ImportMap 使得裸导入成为可正常工作

1
2
3
4
5
6
7
8
<script type="importmap">
{
"imports": {
"lodash": "http://cdn.skypack.dev/lodash",
"ms": "https://cdn.skypack.dev/ms"
}
}
</script>

此时可以与以前同样的方式进行模块导入

1
2
import loadash from 'loadash'
import('loadash').then(_=>...)

那么通过裸导入如何导入子路径呢?

1
2
3
4
5
6
7
8
9
10
11
<script type="importmap">
{
"imports": {
"lodash": "https://cdn.skypack.dev/lodash",
"lodash/": "https://cdn.skypack.dev/lodash/"
}
}
</script>
<script type="module">
import get from "lodash/get.js";
</script>

Import Assertion

通过script[type=module],不仅可以引入 js 资源,甚至可以引入 json/css 资源,示例如下:

1
2
3
<script type="module">
import data from "./xxx.json" assert { type: "json" };
</script>

如何将 CJS 转化为 ESM

Bundless 的兴起,要求所有的模块都是 ESM 模块格式。

目前社区有一部分模块同时支持 ESM 和 CJS,但仍有许多模块仅支持 CJS/UMD,因此将 CJS 转为 ESM 是全部模块 ESM 化的过度阶段

ESM 与 CJS 的导入导出不同

在 ESM 中,导入导出有两种方式

  1. 具名导出导入
  2. 默认导出导入

eg

1
2
3
4
5
6
// 具名导入导出
export { sum };
import { sum } from "sum";
// 默认导入导出
export default sum;
import sum from "sum";

而在 CJS 中,导入导出的方法只有一种

1
module.exports = sum;

而所谓的 exports 仅仅是 module.exports 的引用而已

1
2
3
4
5
exports = module.exports;

// 以下两个等价
exports.a = 3;
module.exports.a = 3;

下列举两个例子,来验证他俩的区别

1
2
3
4
5
6
7
8
// hello.js
exports.a = 3;
module.exports.b = 4;

// index.js
const hello = require("./hello");
console.log(hello);
// 输出是{a:3,b:4}

第二

1
2
3
4
5
6
7
8
// hello.js
exports.a = 3;
module.exports = { b: 4 };

// index.js
const hello = require("./hello");
console.log(hello);
// 输出是{b:4}

正因为它们两者的不同,所以在两者转换的时候有一些兼容问题需要去解决.

exports 的转化

正因为两者不同,所以当 exports 转化的时候,既要转换为exports{}又要转化为export default{}

1
2
3
4
5
6
// 输入
exports.a = 3;

// 转化
export const a = 3;
export default { a };

如果只转化为具名导出不转化为默认导出会发生什么?
eg

1
2
3
4
5
6
7
8
9
10
11
12
13
// Input: CJS
exports.a = 3; // index.cjs =>提供{a:3}

const o = require("."); // foo.cjs
console.log(o.a); // foo.cjs 输出3

// Output: ESM
// 这是有问题的错误转换示例:
// 此处 a 应该再 export default { a } 一次
export const a = 3; // index.mjs 提供3

import o from "."; // foo.mjs 获得3
console.log(o.a); // foo.mjs 出大问题 3不是对象

module.exports转化

对于module.exports,我们可以遍历其中的 key,通过 ast,将 key 转化为具名导出,将module.exports转化为默认导出

1
2
3
4
5
6
7
8
9
10
11
12
13
// 输入
module.exports = {
a: 3,
b: 4,
};

// 输出
export default {
a: 3,
b: 4,
};
export const a = 3;
export const b = 4;

如果module.exports导出的是函数该如何处理? 特别是exportsmodule.exports的程序逻辑混合在一起?

以下是一个正确的转化结果:

1
2
3
4
5
6
7
8
9
10
11
12
// 输入
module.exports = ()=>{};
export.a = 3;
export.b = 4;

// 输出
const sum = ()=>{};
sum.a = 3;
sum.b = 4;
export const a = 3;
export const b = 4;
export default = sum;

也可以这么处理:将module.exportsexports的代码使用函数包裹起来,此时我们无需关心其中的逻辑细节.

1
2
3
4
5
6
7
8
9
10
var esm$1 = { export:{} };
(function (module,exports){
module.exports = ()=>{};
exports.a = 3;
exports.b = 4;
})(esm$1,esm$1.exports);

var esm = esm$1.exports;

export { esm as default }

一些复杂的转换

ESM与CJS不仅仅是语法层次上的不同,本身的思维逻辑就完全不一样.所以有一些较为复杂的转换,不做讨论.
比如:

  1. 如何处理_dirname;
  2. 如何处理require(dynamicString);
  3. 如何处理cjs的编程逻辑

以下代码涉及到编程逻辑,由于exports是一个动态的js对象,而他自然可以使用两次,那应该如何正确的被编为esm呢?

1
2
3
4
5
// input: index.cjs
exports.sum = 0;
Promise.resolve().then(() => {
exports.sum = 100;
});

以下是一种不会出现问题的转换结果

1
2
3
4
5
6
7
const _default = {};
let sum = (_default.sum = 0);
Promise.resolve().then(()=>{
sum = _default.sum = 100;
})
export default _default;
export {sum}

CJS to ESM的构建工具

CJS向ESM转换,自然有构建工具的参与.比如:
@rollup/plugin-commonjs

甚至把一些 CommonJS 库转化为 ESM,并且置于 CDN 中,使得我们可以直接使用,而无需构建工具参与

  • https://cdn.skypack.dev/(opens new window)
  • https://jspm.org/

Bundless的优势与不足

优势在于 不需要处理打包的问题,直接放到浏览器上面使用.
不足在于 现在还有很多模块没有支持esm,所以转换cjs到esm是个问题

总结