why bundle

在一个复杂的项目开发过程中,如何管理函数和变量作用域,是十分重要的。而 JavaScript 的模块化提供给了我们很好的方式来组织和维护函数以及变量。
在 ESM 推出之前,开发者并没有原生的模块组织机制,所以只好针对不同的模块规范来进行封装,包括 CJS、AMD、CMD、UMD 等。而在 npm 生态开发的背景下,CJS 模块是开发过程中接触最多也是无法避免的,于是我们就产生了打包这个概念,将使用不同模块规范组织的代码通过打包的方式组织到一起,然后提供给浏览器进行使用。但由于浏览器并不能直接执行基于 CJS 打包的模块,因此类似 webpack 等打包工具便应运而生。

基于打包的工具(bundler based tools)

前端打包(构建)工具通常是基于一个或多个入口,通过依赖分析将有依赖关系的模块打包到一起,最后形成少数几个产物(assets)构建包,因此也被称为打包工具。
例如:webpack , parcel , rollup 等。
bundle.png
除了打包之外,还包括模块编译和代码优化等。

遇到的一些问题

  • 启动开发服务器耗时很长
  • 即使有 HMR 加持,修改文件之后页面更新速度

原因:冷启动开发服务器的时候,需要将整个应用都进行构建(模块转换,构建依赖图等)才可以进行使用。对于 bundler based 方案,每当对文件进行修改之后都需要对整个应用文件重新做一次构建过程。应用的体积越大,这个更新速度就会越慢。即使有 HMR 的加持,其更新速度也会随着应用体积增大而变低。

无包构建

无包构建(no bundle):在构建时只需要处理模块的编译(transcompile)而无需打包,把模块间的依赖关系交给浏览器来处理。浏览器会加载入口模块,分析依赖之后,再通过网络请求加载被依赖的模块。
例如:vite , snowpack 等。
no-bundle.png

  1. <!DOCTYPE html>
  2. <html lang="en-US">
  3. <head>
  4. <meta charset="utf-8">
  5. <title>Basic JavaScript module example</title>
  6. <style>
  7. p {
  8. color: #333;
  9. }
  10. </style>
  11. <script type="module" src="main.js"></script>
  12. </head>
  13. <body>
  14. <div id="app"></div>
  15. </body>
  16. </html>

modules 和标准 scripts 的差异

  • 需要使用服务器来验证本地的代码,使用 file:// URL 会遇到 CORS 问题
  • 默认使用 strict 模式
  • 模块是异步加载的,不需要使用 defer 属性
  • 即使你引用了多个 <script> 标签,模块也只会被执行一次
  • 单个 script 会创建自己单独的作用域,他们在全局作用域是不可访问的

defer/async 属性对于 script 的影响
image.png

no bundle 的优缺点

无包构建的优点

  • 初次构建启动快
  • 按需编译对应的模块
  • 增量编译速度快

无包构建的缺点

  • 浏览器网络请求数量剧增
  • 浏览器的兼容性有限制

支持使用 <script type="module"> 进行模块加载的浏览器兼容性,包括 nomodule 属性:
image.png

vite 的处理方式

  • 使用 esbuild 对文件进行转译
  • 使用浏览器原生的 ESM 方案,利用浏览器自身的能力来完成依赖处理

image.png
image.png

利用 esbuild 进行转译

esbuild:使用 Go 语言编写的十分快速的 JS bundler。
针对具体的转译过程,vite 将应用中的模块分割为依赖(dependencies)和源码(source code)两个部分。使用 esbuild 对不同的依赖进行预打包,包括不同模块格式的转换。而对于需要经常变更的源码,vite 通过原生 ESM 来提供服务。vite 只需要在浏览器对文件发起请求的时候,将相关的源码按需进行转换即可。

利用浏览器的能力实现依赖处理

ESM 方案将模块的相互依赖关系的梳理交给浏览器来进行处理。它有两个特点:

  1. 构建复杂度非常低,修改任何组件都只需要做单文件编译,时间复杂度永远是 O(1)
  2. 借助 ESM 的能力,模块化交给浏览器端,不存在资源重复加载的问题,如果不涉及到 jsx 或者 ts 语法,甚至可以不用编译直接运行

因为在 vite 中 HMR 是通过 ESM 来实现的。当某个文件被编辑之后,vite 只需要精确的使相关模块与其临近的 HMR 边界连接失效即可,这样一来,HMR 更新速度就不会因为应用体积的增加而变慢。

利用浏览器的缓存策略提高页面响应速度

vite 还利用了浏览器的缓存策略来提升页面重载时的响应速度。设置响应头使得依赖模块(dependency module)进行强缓存,而源码文件通过设置 304 Not Modified 而变成可依据条件而进行更新。
若需要对依赖代码模块做改动可手动操作使缓存失效:

$ vite --force

或者手动删除 node_modules/.vite 中的缓存文件。具体实现

具体实现

直接使用 ESM 也会面临一些问题,例如 bare module 的处理,css @import 和 url rebasing 等。除此之外,还包括静态资源,新的 ES 语法、jsx、ts 语法等。这些都需要进行处理过后才能在浏览器中运行。

ESM 并不支持 bare module imports:

import { add } from 'foo'

vite 会做进行以下操作:

  1. 将所有的模块从 CommonJS/UMD 转换为 ESM
  2. 重新对应的文件路径使其在浏览器中生效,例如 /node_modules/.vite/foo.js

针对于 CSS 的处理:
所有引入的 .css 文件会以 <style> 标签的方式注入到页面中。vite 对于 @import 是通过 postcss-import 来实现支持的, url() 的文件引入也会自动进行 rebasing 从而确保可以正确引入。

针对于静态资源的处理:
对静态资源的引入会被解析成对应的 URL 路径

import imgUrl from './img.png'
document.getElementById('hero-img').src = imgUrl

处理.png

HMR 在 vite 中的实现方式

对外暴露 import.meta.hot 接口来进行调用。

interface ImportMeta {
  readonly hot?: {
    readonly data: any

    accept(): void
    accept(cb: (mod: any) => void): void
    accept(dep: string, cb: (mod: any) => void): void
    accept(deps: string[], cb: (mods: any[]) => void): void

    dispose(cb: (data: any) => void): void
    decline(): void
    invalidate(): void

    on(event: string, cb: (...args: any[]) => void): void
  }
}

内部实现是使用 websocket 建立客户端和服务端的连接,当文件发生改变的时候调用 ws.send() 方法传递信息给服务端(实现),服务端对 message 事件进行监听(封装实现)。

socket.addEventListener('message', async ({ data }) => {
    handleMessage(JSON.parse(data))
})
ws.send({
  type: 'full-reload',
  path: '*'
})

ESM 和静态分析

export 和 import

常见的导出方式

// 具名导出 named exports
export function f() {}
export const one = 1
export { foo, b as bar }

// 默认导出 default exports
export default function f() {}
export default 123

// 重新导出 re-exporting
export {install as LineChart} from '../chart/line/install' // echarts
export * from './a.js'
export * as bar from './b.js'

常见的导入方式

// 具名引入 named import
import { foo, b as bar } from './a.js'
// 命名空间引入 namespace import
import * as customModule from './module.js'
// 默认引入 default import
import module from './module.js'

// 组合
import moduleP1, * as module from './module.js'
import moduleP1, { foo, b as bar } from './module.js'

// 空引入 empty import 含副作用
import './module.js'

在使用组合导入的时候,命名空间引入、默认引入和具名引入只可以同时使用两个,而且默认引入需要放在首位。

ESM 的特性

JS 限制我们在当在 ES2015 中指定模块时必须使用字符串(string literal)。

import { foo } from './bar';

我们有 import 关键字, { foo }from 指示符关键字,并且紧跟着一个 ./bar 字符串。通过这种构建方式,我们获得了两个巨大的好处:

  • 我们可以静态的决定引入的是什么符号
  • 我们可以静态的知道我们是从哪个模块中引入的

如果我们的引入方式是这样的:

import { foo } from getPath();

在这里 getPath() 所得到的值可能是只有在运行时才有效,比如说我们需要从 localStorage 或者 XHR 请求来得到。ES 标准限制路径为字符串,所以对打包工具和浏览器来说可以确切的知道引入的位置。

ESM 的特性:

  • 和 CommonJS 一样,ESM 使用了简洁的语法而且支持循环引用
  • 和 AMD 一样,ESM 被设计为异步加载
  • 比 CommonJS 更为简洁的语法
  • 模块拥有静态结构(并且在运行时不可被更改)。这对于静态检查、优化引入的获取(access of imoprts)和减少 dead code 很有帮助
  • 对循环引入的支持非常透明 ```javascript // a.js export let counter = 3

export function incCounter() { counter++ }

// b.js import { counter, incCounter } from ‘./a.mjs’

console.log(‘counter: ‘, counter) // 3 incCounter() console.log(‘counter: ‘, counter) // 4

变量在不同模块间的连接是实时的,我们无法通过直接 `couter++` 的方式来操作。这样做有两个好处:

- 因为之前共享的变量可以变为导出,所以更易于分离模块
- 这个行为对于支持透明的循环引入是至关重要的

尽量少用 `import * as foo from './foo'` 或 `import foo from './foo'` ,因为这会导致打包工具无法有效的实现 tree shaking.
<a name="7WNvI"></a>
### Tree shaking 实现按需打包
按需打包表示代码模块在交互需要时,动态引入;而对于第三方依赖库及业务模块,只打包真正在运行时可能会需要的代码。<br />按需打包通常有两种实现方式:

- 使用 ESM 支持的 Tree shaking 方案,在使用构建工具打包时,完成按需打包
- 使用 `babel-plugin-import` 为主的 Babel 插件,实现自动按需打包

针对于 rollup 或 vite ,他们都是原生支持 tree shaking,我们书写的时候只需要安装标准的 ESM 格式引入即可。
```javascript
import { Button } from 'antd';

而对于 webpack ,我们需要在 package.json 文件中增加 sideEffects 属性,将其设置为 false 或指定特定的文件为含有副作用,webpack 会进行额外的处理。
例如在 antd 中:

{
  "sideEffects": [
        "dist/*",
        "es/**/style/*",
        "lib/**/style/*",
        "*.less"
    ]
}

如果在项目中使用了 babel 还需要对 .babelrc 文件做设置:

{
  "presets": [
    ["env", {
      "modules": false
    }]
  ],
  "plugins": [
    ["transform-react-jsx", {"pragma": "h"}],
    "emotion"
  ]
}

设置 presets.env 的 modules 为 false。这样就避免了 babel 将 ESM 文件转为更通用的 ES5 方式,从而影响 webpack 来实现更精确的 tree shaking。

为何在生产环境使用 bundle 模式

尽管原生的 ESM 在现代浏览器中已经得到了广泛的支持,但嵌套引入导致的额外的网络往返消耗,在生产环境中发布未打包的 ESM 仍然是十分低效的。为了在生产环境中获得最佳的加载性能,最好还是将代码进行 tree shaking、懒加载和 chunk 分割以便获得更好的缓存效果。

vue2 + webpack 迁移到 vite + webpack

新增的依赖

  • path-browserify: 在浏览器环境中使用 path 相关的方法
  • vite: 作为 dev server 和构建工具进行引入
  • vite-plugin-vue2: 使得 vite 支持 vue2 项目

    添加的配置文件

    ```javascript import { createVuePlugin } from ‘vite-plugin-vue2’ import { resolve } from ‘path’

export default { resolve: { extensions: [‘.vue’, ‘.js’, ‘.ts’], alias: { ‘@’: resolve(__dirname, ‘src’) } }, plugins: [ createVuePlugin() ], build: { sourcemap: true } }

对文件添加 `alias` 支持,支持直接通过 `@` 进行引入。设置 `extensions` ,使得 vue 文件可以自动补全,默认值为 `['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json']` 。
<a name="ahJ7p"></a>
### 将 index.html 文件提到根目录
```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width,initial-scale=1.0">
  <title>Hello</title>
</head>
<body>
  <div id="app"></div>
  <script src="./src/main.js" type="module"></script>
</body>
</html>

使得 index.html 文件作为入口文件。

修改 .env 文件

vite 中,对 .env 文件的使用需要从 process.env 改为 import.meta.env 。针对环境的设置可以使用 --mode 或者 -m 来设置,例如 vite --mode dev 可以将开发环境设置为 dev ,直接引用 .env.dev 文件中的内容。
在对 .env 文件中的内容进行存取的时候,为安全起见,在客户端暴露时,需要添加 VITE_ 的前缀,例如 VITE_APP_BASE_HOST ,然后在文件中进行引用的时候可以直接使用 import.meta.env.VITE_APP_BASE_HOST

axios.create({
    baseURL: import.meta.env.VITE_APP_BASE_HOST,
  withCredentials: true,
  timeout: 1000
})

文件引用方式的修改

  • 对于普通的 js 文件,我们可以使用 alias 的引用方式。但对于 SFC 中组件的引用,需要使用单独的 ../../ 的引用方式。因为,它会与 alias 的引用方式产生冲突。
  • variable.less 文件的引用无法使用插件完成全局的引用,所以暂时需要在需要这个文件的地方进行手动的引入。

在 SFC 中可以使用两种引用方式,一是 ../../../components/Panel 另一种是 @/components/Panel/index.vue 。两种不可混用,否则会出现报错。

Further reading