聊聊2020年的JS模块系统

by Teobler on 08/11/2020

undefined views

聊聊2020年的JS模块系统

在之前的一篇文章里我们已经聊过JS模块系统的一个简短的发展历程,以及 webpack 在其中所做的巨大贡献。

2020年,JS的模块系统早已不像原来那么糟糕了,但是由于各种各样的原因,JS的模块系统和其他后端语言比起来还是稍显混乱。不说Node和JS的支持不同,单说各个浏览器的发展速度不一致也导致了不同的支持程度。

好在我们现在有了各种各样的 bundler 来解决这些问题。通常我们在代码开发完成后会用 bundler 进行打包。为了防止打包过大,我们会使用 code split 进行拆包。打包编译过后的代码便可直接在浏览器中运行,针对不同的浏览器我们还会进行不同的处理。

我们暂时称这样的模块为“构建时模块”(build-time modules)

ES Modules

众所周知,大家使用 ES Modules 已经很久了,但是大家打包后依然使用 ES Modules 的少之又少。为什么呢?

第一个原因当然是 webpack 不支持打包成 ES Modules,而 webpack 是比较流行的 bundler。不过现在已经出现了可以打包成 ES Modules 的 bundler,比如 vite

第二个原因是目前为止好多公司依然要求兼容 IE11,那么问题来了,一个永远也不会支持 ES Modules的浏览器要我怎么冲?

supporting-for-es-modules

还有第三个原因,有很大一部分 npm 包的作者在发布自己包的时候并没有使用 ES Modules,这样的历史遗留问题没办法统一,只能在打包的时候通过 bundler 来处理。

最后一个原因是现在几乎所有的浏览器都还不支持 import maps,这意味着你写代码只能这样写

// this will does not work
import "lodash";

import "https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.20/lodash.core.min.js";

如果浏览器支持了import maps,你可以这样写

<script type="importmap">
  {
    "imports": {
      "lodash": [
        "https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.20/lodash.core.min.js",
        "/node_modules/lodash-es/lodash.js"
      ]
    }
  }
</script>

import "lodash";

build-time Modules vs ES Modules

要理解这两的区别,我们先来看一个例子

// index.js
import * as React from "react";
import bar from "./bar";

const component = () => {
  console.log(bar);
  
  return <div>my component</div>
};


// bar.js
export default "this is bar";

假如我们通过 code split 将 react 的打包分出去,那么这两个文件在打包后会变成(最终当然不长这样,当伪代码看吧,这里只注重模块系统)

import * as React from "react";

const component = () => {
  console.log("this is bar");
  
  return <div>my component</div>
};

可以看到,如果不使用 code split 拆包那么所谓的打包就是将文件 inline 到一起,然后将 code split 的文件拆分出去,用到的时候再引用进来。在最终用户使用时则会按照拆分好的包一个个通过 HTTP 请求下载下来。

而如果直接使用 ES Modules 那么用户在下载的时候,是浏览器直接从某个地方同样通过 HTTP 请求下载下来。

这时候你可能会问了,听起来这两货不是一样的嘛?其实里面还真的不一样。

首先如果按照 build-time modules 打包过后的 react 虽然都是引用进来的,但是它们有着根本的不同:

<script type="text/javascript">

<script type="module">

第一个标签是 build-time module,它是真的在 HTML 中引用了一段 JavaScript,而第二个标签标记了这是一个 ES Module, 浏览器会按照 ES Module 的标准来对待它。

还有一个关键的不同是,webpack 打包的 module 是对于某个项目做了特殊配置的,如果将这个打包好的 module 原封不动地挪到另一个项目里它是用不了的。ES Module 则不同,你可以把它看做是一个通用的模块,相当于一个 CDN。

带来了什么好处

从上面来看,直观感觉上来说两者好像没什么区别,现在的 build-time modules 我用的挺好的,我没理由大动干戈去换它呀。其实这么说也没毛病,不过 ES Modules 的确是带来了一些好处的。

我们回忆这样一个场景:周一早上你来到办公室,倒了杯咖啡坐到电脑前打开电脑准备这一天的工作。然后你跑了一条命令 npm run dev,接着你的咖啡喝完了,但是项目还没跑起来 :)

越来越多的人开始抱怨 webpack 在编译启动一个项目时速度慢的问题,因为 webpack 在启动的时候除了启动 dev server 还得处理打包问题,分包问题等等。但是其实在本地开发的时候,我们并不需要它来做打包的工作,我们想做的只是让代码跑起来,我们可以来看看 vite 是怎么解决这个问题的:

The primary difference is that for Vite there is no bundling during development. The ES Import syntax in your source code is served directly to the browser, and the browser parses them via native <script module> support, making HTTP requests for each import. The dev server intercepts the requests and performs code transforms if necessary. For example, an import to a *.vue file is compiled on the fly right before it's sent back to the browser.

当然,由于 vite 不打包编译,直接使用 ES Modules 做开发和最终的打包,理所应当的,IE 挂的很彻底。不过 vite 社区也提供了解决办法 -- 一个叫做 vite-plugin-legacy 的插件,思路是围绕 babel 来实现代码的转化和贴片操作。

同时 ES Modules 还提供了一套远端代码调试的解决方案。想象如下一个场景:QA 向你报了一个 bug,并把你叫过去在 dev 环境上把这个 bug 复现了一遍,你看过后有一些头绪,但是没想明白为啥会这样,需要一些调试。现在你要做的就是本地调试。

那么问题来了,假如这个 bug 在页面的一连串 workflow 中间,你为了在本地准备这个测试数据,需要在本地起不知道多少个微服务和数据库,还有 BFF,然后把刚刚的 workflow 手动点一遍,到了 bug 的地方再开始调试。

可是明明刚刚的 dev 环境上已经为你准备好了一切,假如 dev 环境上可以运行你本地的代码,你什么都不用准备。

当然可以,你需要三样东西,ES Modules打包后部署,import map 和 impor map override。

通常我们在打包时会使用 webpack 的 code spilt,将共用代码放入“vendor”包,将比较大的包比如 react 单独打包,再按照页面进行拆包,具体怎么拆不重要。这些拆完的包在 dev 环境上会在相应的时候进行懒加载。

假如这些包是 ES Modules,那么在请求这些包的时候,浏览器会根据 import map 去寻找相应包的地址进行下载,而如果此时我们将 import map 的某个地址(比如我们想 debug 的地址)改写成我们本地的代码,此时浏览器去获取相应的包时就会来 call localhost,我们的代码就可以运行在远端 dev 环境了。

下面是一个例子(注意,例子里面的三个标签顺序很重要):

<!DOCTYPE html>

<html>
  <head>
    <script type="systemjs-importmap" src="some/url/here.json"></script>
    
    <script src="some/url/for/import-map-overrides/dist/import-map-overrides.js"></script>
    
    <script>
      System.import("PageLogin.js");
      System.import("xxxxxxxxx.js");
    </script>
  </head>
</html>

在这个例子里面第一个 script 标签使用了 systemjs,这是一个为浏览器提供 importmaps 的 loader。

第二个 script 标签使用了 import-map-overrides,这个库可以改写 importmap,从而在远端加载本地的代码。

最下面的 script 标签则是使用 systemjs 真正去引入你打包好的代码。

最后

其实使用 ES Modules 的好处还有很多,毕竟这是浏览器原生支持的,为客户端省去了一些工作,不管是给开发者还是用户都能带来比以往更好的体验。

虽然 webpack 现在还不支持打包成 ES Modules,但是像 snowpack 和 vite 等 bundler 的出现也在渐渐推行“新的”模块标准,相信在不久得将来 webpack 也会跟进,届时也希望各个浏览器厂商能够尽快实现 importmap 标准,统一各个浏览器的开发和使用体验。