Skip to content

完整视频系列及倍数请移步 bilibili

模块化背景

在现代前端工程里面,我们离不开打包工具,比如说 webpackrollupvite

那么我们为什么要用到打包工具呢? 原因之一是:在浏览器支持 ES 模块之前,前端没有原生支持以模块化的方式开发 js。打包工具的出现也是为了帮助开发让浏览器支持的模块化方案。

那不使用模块化开发的体验会是什么样子呢?

  • 容易造成变量污染(无意声明相同变量造成使用时不是预期变量)
  • js文件之间有依赖关系时,因为不能在js中引js,只能统一写到script引入,需要注意顺序问题。而且被依赖的js一般不希望被其他js调用,script只能全局引入,所有js都能调用不需要引用
  • 开发无法分块编写,将项目分成更小的独立部分,是跟优雅的开发模式,更好维护代码和更好的复用。

这也是“打包”这个概念出现的原因:使用打包工具抓取收集依赖资源、处理成script或是其他形式链接资源文件到引用方文件中,实现模块化。

接下来将主要讲解 Commonjs、ES Module 模块化并且不会讲基础使用,其他如 AMD、UMD 可能提及但不会细讲,感兴趣请自行搜索🔍学习

模块化方案环境支持

开发环境(nodejs

  • 支持Commonjs
  • ES6后通过.mjs支持 ES Module

浏览器环境

  • 不支持 Commonjs
  • ES6后支持 ES Module

CommonJs

Commonjs是一种模块化规范(思想)

Commonjs 是一种规范并不是一种具体的代码,类似 promise A+ 规范,只要符合规范里所有的条件,那它就是 Commonjs

而nodejs就是借鉴这种思想实现自己的模块化的,所以也不能完全说nodejs就是 Commonjs

AMD模块化同样脱胎于Commonjs规范,浏览器支持的Commonjs一般是使用require.js的库,注意这个库和nodejs的require不是一个东西,这个库的Commonjs是AMD实现的。

为了解决变量污染的问题,Commonjs规定每一个js都是独立的模块,即依赖的文件没有抛出的变量,是无法获到的。可以看作每个文件都是一个闭包。

另外,webpack作为以nodejs为底层语言(强调这个是因为ESbuildGO为底层语言)的打包工具,是依靠nodejs的Commonjs做依赖收集进行整合后用方法插入script来协助打包的。所以webpack工程下,编写Commonjs不需要额外支持,可以直接编译识别。-- TODO: 这里补一个文章跳转各种打包工具的模块化原理

从0实现简易Commonjs

0. 前期准备

开始前先转变以前的固有印象,“引入”“抛出”

在Commonjs

  • 引入的本质是存储/缓存
  • 抛出的本质是读取缓存

实现模块化的本质就是,把每个文件存储到Modules的大对象中,每个文件就是一个子对象module,且每个文件module对象内有各种信息

打印module和Module

js
const Module = {
  [module] = {
    id: '',
    exports: {},
    loaded: false,
    module: {},
    ...
    ...
  },
  ...
}

抛出的内容就是往对象里的exports存储深拷贝的值,引入就是获取整个exports(一般解构赋值取出来使用)

由此可以看出一个 Commonjs 的特性:引入(也就是获取缓存)的值取决于当时存储时的值,也就不是动态可变的了

1. 让js文件直接支持myexport、myrequire等

支持Commonjs的环境(nodejs),会在编译运行js时(运行 node xx.js )包装js

即我们写的js不是最终执行的js,而是会被包裹起来,进行统一处理

node index.js ,执行index.js,nodejs就会先对index.js进行包装

注入进去export、models等变量或函数

这就是在js里全局支持使用export的原因

接下来手写实现一个 Commonjs模块化,不会使用nodejs模块化相关的api,但是还是需要用到nodejs其他的api来帮助我们实现,如读取文件内容的 fs,遇到不会的nodejs API请自行搜索🔍学习

由👆分析,我们需要读取require引入的资源文件内容,进行包装并且注入模块化api或其他工具方法

js
// 包装js文件 注入api的期望方式
function (){
  wrap(fileContent)(myrequire); // 柯里化函数方式,第一次调用传递引入的资源文件内容,返回一个函数,传递要注入的东西并运行
}

👆包装js文件 注入api的期望方式

js
function wrap(fileContent) {
  // 拼接函数进行包装 注意要包一层()才不会被eval立即执行,而是返回一个字符串()内的函数
  const newfileContent = `(function (myrequire){
    ${fileContent}
  })`
  return eval(newfileContent)
}

👆 资源文件内容是一个js文件的所有字符串,里面可以直接使用模块化api 先包装字符串,然后执行,注意执行字符串的结果要返回一个函数而不是直接执行(会报错myrequire undefined)

js
const fs = requuire('fs')
function myrequire(filePath) {
  console.log('开始引入',filePath)
  let fileContent;
  try {
    fileContent = fs.readFile(filePath,'utf8');
  }catch(err) {
    console.log(`没有找到引用资源${filePath}`)
  }
  if(!fileContent) return;
  wrap(fileContent)(myrequire);
}

myrequire('./index.js')

这样就能运行引入的资源文件了,但是还没实现模块化抛出和接收的动作

js
// index.js
myrequire('./a.js')
myrequire('./b.js')
console.lof('执行index.js')

// b.js
console.log('执行b.js')

// 没有a.js

打印内容会发现执行顺序

js
开始引入index.js
执行index.js
开始引入a.js
开始引入b.js
没有找到a.js
执行b.js

可以看到,index.js引入a和b,执行index并没有等a和b引入结束

而运行一次原生Commonjs如下

js
开始引入index.js
开始引入a.js
error 没有找到a.js

原生是会等a和b引入结束才执行index的,并且遇到引入异常会中断运行

Commonjs 模块化是同步加载的

我们自己写的 Commonjs 不是同步的原因是fs读取文件内容的api fs.readFile() 用了异步加载

js
// 读取文件内容是异步的,外面调用myrequire是不会等内部异步结束
// 所以读取文件内容要用同步
fs.readFile(filePath)
fs.readFileSync(filePath)

并且处理读取文件内容失败,要中断nodejs运行,抛出错误throw 改造后的 myrequire()

js
function myrequire(filePath) {
  console.log('开始引入',filePath)
  let fileContent;
  try {
    fileContent = fs.readFileSync(filePath,'utf8');
  }catch(err) {
    throw `没有找到引用资源${filePath}`
  }
  if(!fileContent) return;
  wrap(fileContent)(myrequire);
}

2. myrequire识别资源方式

识别资源路径的几种格式

  • xxx node内置模块
  • /xxx 当前目录绝对路径
  • ./ ../ 当前目录相对路径
  • xxx node_modules下的目录
js
/**
 * 文件路径几种格式
 * xxx node内置模块
 * /xxx 当前目录绝对路径
 * ./ ../ 当前目录相对路径
 * xxx node_modules下的目录
 */
const enterPublicPath = 'example'
function dealFilePath(filePath) {
  const firstString = filePath.charAt(); // 路径首字符
  if (firstString === '.') {
    // 当前目录相对路径转为绝对路径(模拟的都是从example中引入的,跟当前core文件夹是同级所以相对路径也是)
    // console.log('当前文件core的绝对路径',__dirname);
    return path.resolve(__dirname, `../${enterPublicPath}/${filePath}`);
  } else if (firstString === '/') {
    // 当前目录绝对路径
  } else if (node内置模块.includes(filePath)) {
    // node内置模块
  } else {
    // node_module模块
  }
}

👆这里仿照webpack定义一个 publicPath,用于处理相对路径时的公共路径前缀,配合我们把代码抽离到core文件夹和在expample文件夹运行

关于资源路径属于 node_module的情况,会逐级查找资源,如下规则,这里不做实现

  • 在当前目录下的 node_modules 目录查找。
  • 如果没有,在父级目录的 node_modules 查找,如果没有在父级目录的父级目录的 node_modules 中查找。
  • 沿着路径向上递归,直到根目录下的 node_modules 目录。
  • 在查找到第三方模块后,会找 package.json 下 main 属性指向的文件,如果没有 package.json ,在 nodejs 环境下会依次查找 index.js ,index.json ,index.node

改造后 myrequire()

js
function myrequire(filePath) {
  console.log('开始引入',filePath)
  const newPath = dealFilePath(filePath) // 处理资源路径
  let fileContent
  try {
    fileContent = fs.readFileSync(newPath,'utf8');
  }catch(err) {
    throw `没有找到引用资源${newPath}`
  }
  if(!fileContent) return;
  wrap(fileContent)(myrequire);
}

3. require处理资源防止套娃引用

reuqire顺序是深度优先遍历

这里的深度优先并不是模块化工具在做递归,而是资源引用的写法:每次在不同文件下require,因为是同步执行自然而然形成的嵌套调用

index引用a、b,a文件引用b,b文件引用a,node原生Commonjs执行顺序

js
开始引入index.js
开始引入a.js 
开始引入b.js (b有引a,但是不会触发开始引入和执行a)
执行b.js
执行a.js
执行index.js

可以看出不会重复引用

实现原理:和递归遍历类似,可以用一个weakmap或者一个变量存储调用过的资源文件,因为做引入抛出也是存储的时候要用到Modules的变量,所以直接用一个Modules存储引用过的文件,把资源路径作为数组id存储

js
const Modules = []; // 做全局变量

function cacheModule(filePath) {
  const isExist = Modules.some(item=>item.id === filePath);
  if(!isExist) {
    Modules.push({
      id: filePath
    })
  }
  return isExist;
}

改造后 myrequire()

js
function myrequire(filePath) {
  console.log('开始引入',filePath)
  const newPath = dealFilePath(filePath)
  const isExist = cacheModule(newPath); // 判断是否加载过
  if(isExist) return; // 加载过不再重复加载
  let fileContent
  try {
    fileContent = fs.readFileSync(newPath,'utf8');
  }catch(err) {
    throw `没有找到引用资源${newPath}`
  }
  if(!fileContent) return;
  wrap(fileContent)(myrequire);
}

4. 引用和抛出变量

抛出和接收是使用模块化编程时的直观感受 实际上“抛出”的动作是文件把值存到export里 而“接收”就是require去export对象中取值(require还有抓取、包装文件内容的作用) 前面提到引用和抛出的本质是存储和获取,且每次加载资源都有一个数组存储资源的信息,我们往Modules全局变量中存储相应要抛出的变量即可

js
const Modules = []
function cacheModule(filePath) {
  const isExist = Modules.some(item=>item.id === filePath);
  if(!isExist) {
    Modules.push({
      id: filePath,
      exports:{} // 加一个存储变量的地方
    })
  }
  return isExist;
}

原生nodejs的Commonjs的使用方法是:

js
module.exports = {
  a:''
}

exports.a = ''

👆这里两种存储变量的形式,本质都是往全局变量Modules里的exports进行存储,因此两者使用的区别,请在实现之后自行思考,本质区别就是操作对象赋值的区别而已

我们往js里注入方法的时候只注入了myrequire,这次再往里注入module和exports

js
// myrequire() 的注入变量代码片段

const index = Modules.findIndex(item=>item.id===newPath)
// 注入多两个变量 module 和 exports
wrap(fileContent)(myrequire,Modules[index],Modules[index].exports);

👆这样我们实现了资源文件的存储变量,接下来要实现获取存储的变量,也就是在myrequire()之后返回出资源存储的整个对象exports即可

改造后 myrequire()

js
function myrequire(filePath) {
  console.log('开始引入',filePath)
  const newPath = dealFilePath(filePath)
  const isExist = cacheModule(newPath);
  if(isExist) return;
  let fileContent = null
  try {
    fileContent = fs.readFileSync(newPath,'utf8');
  }catch(err) {
    throw `没有找到引用资源${newPath}`
  }
  // 注入 存储用的两个变量 module 和 exports
  const index = Modules.findIndex(item=>item.id===newPath)
  wrap(fileContent)(myrequire,Modules[index],Modules[index].exports);

  // 返回资源文件存储的变量exports
  return Modules[index].exports
}

5. 整理

至此,我们已经从0到1实现了一个简易版的 Commonjs

并且由此可知几个Commonjs的特性:

  • 加载资源是同步加载的,即如果资源很大还是会阻塞后面资源的加载
  • 资源套娃引用或重复引用,不会重复加载
  • exports抛出的变量是值的拷贝,取决于加载资源时进行存储的变量是什么,后续不会更新存储的变量(即抛出的值是静态的)
  • 执行require,会加载资源存储的所有变量到本文件

Commonjs获取动态值

js
// lib.js
var counter = 3;
function changeCounter() {
  counter++;
}
module.exports = {
  getCounter() {
    return counter
  },
  changeCounter,
};

这样就可以拿到深拷贝的值,因为并不是拿拷贝的值,而是通过函数作用域去取值,作用域内的值变了,取到值就变了。

关于Commonjs的require是动态引用

Commonjs的require()发生在运行时 首先明确一点,动态的概念不等于异步的概念

动态引用是在条件作用域里require,只有在调用该函数才会运行引用资源,运行时才去获取对应的内容

而动态引用分两种情况

  • 不需要执行就已知资源路径的情况
  • 需要执行才知道资源路径的情况 如:
js
// 1.不需要执行就已知资源路径的情况
setTimeout(()=>{
  require('./a.js')
},1000)


// 2.需要执行才知道资源路径的情况
function a(x) {
  const filePath = x;
  require(x)
}
function onclick(){
  a('./a.js')
}

在webpack里require的资源是个未知的变量(运行时才可知)的话,上线之后会加载不到该文件。为什么nodejs可以加载,webpack不行?

是因为在本地node环境下,动态require运行时可以找到文件,如果动态require的文件是本地不存在的话,也是会像webpack一样报错找不到的 而webpack打包不会运行未知的代码(会收集已知的require资源),所以不会知道动态require的文件是什么,就不会把目标文件打包进项目,最后运行时服务器中找不到该文件就会报错

关于异步加载: 希望点击才加载a文件,在Commonjs里,index依赖a即使在条件作用域里,也必须初始化的时候就加载进来,点击时只是获取并执行a,并不是点击时才去找a文件进行加载和执行

不用exports.xx='' 而是exports={xx:''} 来存储变量会怎样

module.exports = {}exports.xx='' 同样是操作exports存储对象,那可不可以exports = {}呢?

我们知道exports的来源是注入到函数里的形参 也就是👇的形式:

js
const exports = {a:'a'}
function a(exports) {
  exports = {b:'b'}
}

a(exports)
console.log(exports) // {a:'a'}

👆函数的引用类型形参做重新赋值,在函数体中将会是个新的变量,而不是外部的变量 是js原生这么干的,不关nodejs的Commonjs机制的事

在require中,exports是文件资源信息对象里的一个属性,如果在文件中自己重新赋值了exports,因为一个文件就是一个单独的作用域函数,exports将是一个作用域下全新的变量,而不再是外部文件对象里的属性,那么require就取不到文件抛出的信息了

这也就是为什么抛出数据只能exports.xx 一个个抛出(存储)而不能直接exports =

js
const module = {
  myexports: {a:'1'}
}
// 传入的是module.myexports引用类型,如果重新赋值了myexports将不再有引用作用而是一个新的对象
function a(myexports) {
  myexports = {a:'2'} // -->{ myexports: {a:'1'} }
  // myexports.b = 'b' // -->{ myexports: {b: 'b'} }
}
a(module.myexports)
console.log(module) // -->{ myexports: {a:'1'} }

ES Module

node环境

node 8.5开始支持mjs,但是还是实验性的所以需要参数--experimental-modules 执行.mjs 会提示ESM是实验性

bash
node --experimental-modules index.mjs
(node:9076) ExperimentalWarning: The ESM module loader is experimental.

node13开始不需要实验参数,可以直接执行.mjs .mjs可以用ESM,并且还是支持原来Commonjs

浏览器环境

和node环境用mjs后缀的方式不同,在浏览器环境不认识mjs,mjs是node自己新增的

浏览器需要不影响以往的没有模块化的js,所以要支持模块化时在<srcipt>标签加上type

script标签的async、defer

在讲type=module之前,我们看看其他属性 默认scipt标签都是同步加载,即按顺序加载script标签并会阻塞后面的加载 浏览器提供scipt识别为需要异步加载资源async、defer、module都是异步加载的

html
<script src="./1.js" defer></script>
<script src="./2.js" async></script>
  • defer要等到整个页面在内存中正常渲染结束(DOM 结构完全生成,以及其他脚本执行完成),才会执行(即使已经下载完成了)
  • async一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再恢复渲染

浏览器的V8引擎,渲染页面和执行js是共用一个线程的,所以会交错执行如👆async的场景。

defer是“渲染完再执行”,async是“下载完就执行”。另外,如果有多个defer脚本,会按照它们在页面出现的顺序加载,而多个async脚本是不能保证加载顺序的。

type=module

因为type="module"默认就是异步加载的,所以也要有个异步规则,那就是defer 所以👇其实就是一种省略默认值的写法

html
<script type="module" src="./1.js"></script>
<!-- 等同于 -->
<script type="module" src="./1.js" defer></script>

defer基本相同,等页面渲染结束,才按顺序执行资源(即使已经下载完资源)

而如果是async

html
<script type="module" src="./1.js" async></script>

则会是一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再恢复渲染,并且不保证顺序

由👆type="module",更多的是让文件内部可以使用ESM模块化,控制异步规则还是靠defer、asyncES Module除了加载规则跟异步加载资源相同外,内部是支持直接使用import的。而这是只开启异步加载的资源文件无法做到的

ESM识别的资源路径

回忆一下 Commonjs识别资源的路径有相对路径绝对路径nodejs内置模块node_module第三方资源

那ESM的资源路径:相对路径绝对路径第三方资源URl

import第三方资源只能引用完整的 URL,相对以前的裸导入 (bare import specifiers)(人话就是直接通过模块名导入),很不太方便,如下例:

js
import lodash from 'lodash'

它不同于 Node.JS 可以依赖系统文件系统,层层寻找 node_modules

在 ESM 中,可通过 importmap 使得裸导入可正常工作:

html
<script type="importmap">
{
  "imports": {
    "lodash": "https://cdn.sykpack.dev/lodash",
    "ms": "https://cdn.sykpack.dev/ms"
  }
}
</script>
<script src="lodash" type="module"></script>

加了上面的map,浏览器就支持通过别名直接引入模块了

补充一点:平时可以到这里 https://npm.devtool.tech/ 查看各种第三方包的cdn,看看别人的导出方式 👇浏览器的控制台可以直接使用ESM的CDN

js
> lodash = await import('https://cdn.skypack.dev/lodash')

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

接收的是动态的值

js
export var foo = 'bar';
setTimeout(() => foo = 'baz', 500);

上面代码输出变量 foo ,值为 bar ,500 毫秒之后变成 baz 。

这一点与 CommonJS 规范完全不同。CommonJS 模块输出的是值的缓存,不存在动态更新(如果是引用类型也不行,因为是运行时进行存储的值的拷贝)

ESM自动严格模式

ES6 的模块自动采用严格模式,不管你有没有在模块头部加上"use strict";。

严格模式主要有以下限制:

  • 变量必须声明后再使用
  • 函数的参数不能有同名属性,否则报错
  • 不能使用with语句
  • 不能对只读属性赋值,否则报错
  • 不能使用前缀 0 表示八进制数,否则报错
  • 不能删除不可删除的属性,否则报错
  • 不能删除变量delete prop,会报错,只能删除属性delete global[prop]
  • eval不会在它的外层作用域引入变量
  • evalarguments不能被重新赋值
  • arguments不会自动反映函数参数的变化
  • 不能使用arguments.callee
  • 不能使用arguments.caller
  • 禁止this指向全局对象
  • 不能使用fn.caller和fn.arguments获取函数调用的堆栈
  • 增加了保留字(比如protected、static和interface)

其中,尤其需要注意this的限制。ES6 模块之中,顶层的this指向undefined,即不应该在顶层代码使用this。

👇因此利用顶层的 this 等于 undefined 这个语法点,可以侦测当前代码是否在 ES6 模块之中。

js
const isNotModuleScript = this !== undefined;

编译时、运行时、静态化

  • ESM发生在编译时且是静态化的
  • CJS发生在运行时

编译时报错是语法/句法错误 运行时报错是各种情况 👆平时可以留意一下两种报错的区别

ES6 模块的设计思想,是尽量的静态化,使得编译时就能确定模块的依赖关系,以及抛出和接收的变量

ESM的编译时

编译时:如 import 命令是编译阶段执行的,即在代码运行之前。 编译过程不能使用表达式和变量,表达式和变量是只有在运行时才能得到的动态结果

因为ESM是命令式语法,因此不做源码解读和伪代码的编写,请彻底理解👇发生在编译时的模块化原理

编译流程是:

JS 引擎对脚本静态分析的时候,遇到命令 import ,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的文件里面去取值(由于 ES6 import的变量,只是一个“符号连接(指针)”,所以这个变量是只读的,相当于“抛出”的变量都是const,对它进行重新赋值会报错。

浏览器通过html的<script type="module">,加载第一个js,这个js是入口文件 后续模块都是js中的import,而不是浏览器中的<script>

浏览器加载文件的功能我们称为加载器,加载器开始加载文件时,会把文件路径作为key记录到模块映射(缓存)中 不等文件加载完成,标记为加载中。继续开始下一个文件。 加载完成后触发解析文件即编译,识别静态语法中import。继续触发加载器,以此形成了一种深度遍历的效果,不过是不等待的那种 有了模块映射(缓存),加载器就可以跳过加载重复模块,直接导出模块的运行结果 另外,导出值的引用是在递归加载文件之后执行的,即会到最深层再往上抛出的顺序导出值的引用

ES6 模块“抛出”的变量是活的,ES6 模块是动态取值,并不是通过存储值来做抛出

js
import {a,b} from 'fs'`

👆不是函数,是js语法支持的读取命令,且命令实现只读取fs文件中抛出的a和b,并且这里的{ }并不是解构赋值,而是命令解析的字符串。

另外import 不能写在条件代码块之中,没法做静态优化了,违背了 ES6 模块的设计初衷

js
// 报错
if (x === 2) {
  import MyModual from './myModual';
}

编译过程处理 import 语句,这时不会去分析或执行 if 语句,所以 import 语句放在 if 代码块之中毫无意义,报句法错误,而不是执行时错误

CJS的运行时

CommonJSAMD 模块化,都只能在运行时确定模块的依赖关系,以及抛出和接收的变量 如Commonjs

js
const {a,b} = require("fs")`

上面讲原理的时候也讲到,rqeuire是个函数,是运行函数才能读取资源的,并且只能整个文件的存储都读取下来,再解构赋值给a和b

nodejs中共用两种模块化方式会怎样

通过 Babel 转码,CommonJS 模块的 require 命令和 ES6 模块的 import 命令,可以写在同一个文件里面,但是最好不要这样做。因为import 在静态解析阶段执行,所以它是一个模块之中最早执行的,出现不按代码顺序执行的情况:

js
require('core-js/modules/es6.symbol');
require('core-js/modules/es6.promise');
import React from 'React';

core-js/modules是给react实例化用的插件,需要在react之前加载好,但是这样写会先实例化react

ESM可以运行时的import()

import() 函数可以用在任何地方。它是运行时执行,什么时候运行到这一句,也会加载指定的模块。另外,import() 函数与所加载的模块没有静态连接关系(人话就是import()不会返回指定变量,而是返回整个文件的输出),这点也是与 import 语句不相同。

import() 类似于 nodejsrequire 方法,区别主要是前者是异步加载,后者是同步加载(就是require一定会加载结束才往下走,而import()不await住的话会异步执行)

所以nodejs环境 Commonjs可以被await import()完全替代了?

ES6与CommonJS的区别

  • CommonJS 模块输出的是一个值的拷贝(深拷贝)
  • ES6 模块输出的是值的引用(变量的指针)
  • CommonJS 模块是运行时加载
  • ES6 模块是编译时输出接口

ESM对工程化最大的好处是浏览器直接支持模块化代码的编写 如下依靠ESM不安装任何依赖直接使用react框架,

js
<script type="module">
  import { html, Component, render } from 'https://unpkg.com/htm/preact/standalone.module.js';
  class App extends Component {
    state = {
      count: 0
    }
    add = () => {
      this.setState({ count: this.state.count + 1 });
    }
    render() {
      return html`
        <div class="app">
          <div>count: ${this.state.count}</div>
          <button onClick=${this.add}>Add Todo</button>
        </div>
      `;
    }
  }
  render(html`<${App} page="All" />`, document.body);
</script>

esm的限制与解决方案

代码需要基于es开发

第三方资源的导出要支持ESM

第三方资源node_module或者CDN的模块要可以通过import加载到

不支持esm的浏览器

不支持esm的浏览器会跳过type=module的js 这时再html写多一个bundle.js commonjs规范的代码资源兼容即可 浏览器提供一个标签属性<script nomodule src="">会判断是否支持esm,不支持则加载该标签

systemjs实现兼容方案

html
<script src="system.js"></script>
<script type="systemjs-importmap">
{
  "imports": {
    "lodash": "https://unpkg.com/[email protected]/lodash.js"
  }
}
</script>
<script type="systemjs-module" src="">

加载这个库后,用type="systemjs-module",会根据浏览器支持esm的情况处理代码

思考

1. webpack工程下怎么同时支持CommonJs和ESM规范

提示:webpack通过编译(不等于上面说的js引擎的编译)CMD和ESM的源代码做资源收集合并,再利用js插入script的形式来做整合过的模块化

开发编写的是Commonjs和ESM,最后上线的代码既不是CMD也不是ESM,而是打包后的插入script方法(webpack干的)

2. babel如何把esm编译成commonjs,在node环境中运行

Babel 之 ESM 和 CommonJS

3. webpack、rollup是怎么支持tree shaking

提示:ESM因为命令式语法发生在编译时根据指针接收变量,即本身就是不引入额外的变量,也就是不需要做tree shaking这个动作,但是打包工具最终上线的不是ESM,且即使代码写的CJS,上线后也能实现tree shaking


参考资料