本文会很少涉及具体构建工具的配置,主要辨析工程化构建的相关概念以及简单介绍常用的构建工具。
文章目录
为什么需要工程化构建?
首先我们必须知道浏览器只能读懂原生html,js和css语言,浏览器无法直接读懂前端框架,例如Vue、React、Angular,还有其他样式语言,例如less,sass。因此我们需要构建工具将源码打包成浏览器能够识别的最终文件。
构建环境
我们有两种场景需要将源码进行构建:
- 开发环境,用于本地开发调试,一般构建工具会启动本地服务,供开发者在修改代码后,及时看到页面更新。
- 生产环境,将源码打包压缩混淆,最终形成部署在服务器上的源文件。
开发环境和生产环境的构建目的不同。
-
开发环境,应该以便于开发者调试为目的
- 期望本地服务的启动速度足够快
- 当开发者修改代码,期望在页面上实时热更新(HMR)
- 报错时,能够变与调试
-
生产环境,有更好的客户体验
- 优化缓存
- 更小的生产包
- 代码混淆与压缩
生产环境的构建方式一般都是bundle模式的(snowpack生产环境构建也是unbundle的),而开发环境的构建方式分为两派:bundle 和 unbundle
模块化支持
我猜你肯定会想,那么bundle是什么?别急在了解unbundle之前,我们先辨析标题中的这几个概念,首先我们明确CJS、AMD、UMD,ESM都是js的模块化规范。
起初,js语言并没有导入导出的概念,所有变量和函数都在全局定义,这会导致全局环境被污染,也不利于维护(想想就很混乱,你声明了var name;
可能在其他开发者的代码里被使用,你访问到的变量name
的值可能不是你预期的。)为了使js支持模块化,一下模块化方法诞生
CJS
CommonJS的缩写,CJS的代码看起来如下:
//importing
const doSomething = require('./doSomething.js');
//exporting
module.exports = function doSomething(n) {
// do something
}
- 通过module.export导出模块
- 通过require(‘xxx’)引入模块
特点:
- nodejs使用CJS规范,多用于后端开发
- CJS导入模块是同步的
- CJS导入模块采用的是值拷贝。
- CJS不能直接被浏览器解析,需要先被构建打包
AMD
AMD, 即Asynchronous Module Definition, 异步模块定义,顾名思义,它采用异步模式加载模块。代码示例:
// 长这样
define(['dep1', 'dep2'], function (dep1, dep2) {
//Define the module value by returning a value.
return function () {};
});
// 或者这样
// "simplified CommonJS wrapping" https://requirejs.org/docs/whyamd.html
define(function (require) {
var dep1 = require('dep1'),
dep2 = require('dep2');
return function () {};
});
- 异步加载模块(顾名思义),回调函数中写依赖于这个模块的语句。
- 适用于前端
- 缺点是它的代码看起来不是很直观
UMD
Universal Module Definition,通用模块定义模式,代码看起来如下:
(function (root, factory) {
if (typeof define === "function" && define.amd) {
define(["jquery", "underscore"], factory);
} else if (typeof exports === "object") {
module.exports = factory(require("jquery"), require("underscore"));
} else {
root.Requester = factory(root.$, root._);
}
}(this, function ($, _) {
// this is where I defined my module implementation
var Requester = { // ... };
return Requester;
}));
特点:
- 既适用于前端,也适用于后端
- UMD更像是配置多个模块系统的模式,像Rollup/Webpack这样的构建工具会使用UMD当作后备模块
ESM
ES Modules, ES推出的模块化规范。
import {foo, bar} from './myLib';
import React from 'react';
...
export default function() {
// your Function
};
export const function1() {...};
export const function2() {...};
-
现在多数浏览器能够直接解析ESM规范的语法
-
兼备CJS的直观性与AMD的异步性
-
对于Tree-shaking有较好的支持,CJS并不能较好的支持。(Tree-shaking简言之,构建后对于模块依赖图中没用到的模块进行剪枝(去掉))
-
HTML可引入ESM规范的文件,只需要
type=“module”
<script type="module"> import {func1} from 'my-lib'; func1(); </script>
bundle和unbundle是什么?
一个例子理解bundle模式构建过程
这里引用【webpack 中,module,chunk 和 bundle 的区别是什么?】的例子:
假设我们的项目结构是这样的:
src/
├── index.css
├── index.html # 这个是 HTML 模板代码
├── index.js # 入口文件1
├── common.js
└── utils.js #入口文件2
除了html文件以外,各文件的依赖关系如下:
-
index.js中依赖了index.css和common.js,untils.js文件独立。
-
构建配置如下(webpack配置)
{ entry: { index: "../src/index.js", utils: '../src/utils.js', }, output: { filename: "[name].bundle.js", // 输出 index.js 和 utils.js }, module: { rules: [ { test: /\.css$/, use: [ MiniCssExtractPlugin.loader, // 创建一个 link 标签 'css-loader', // css-loader 负责解析 CSS 代码, 处理 CSS 中的依赖 ], }, ] } plugins: [ // 用 MiniCssExtractPlugin 抽离出 css 文件,以 link 标签的形式引入样式文件 new MiniCssExtractPlugin({ filename: 'index.bundle.css' // 输出的 css 文件名为 index.css }), ] }
- 入口文件有两个: index.js和utils.js
- plugins配置中,将css文件抽离为单独的css文件
在bundle模式下构建,构建工具以入口文件为起点,分析各个源文件之间的依赖关系,形成一个模块依赖图,具有依赖关系的文件将放在一个chunk里面,然后根据一定的策略打包后生成bundle。
具体一些,上面的例子的构建过程如下:
- index.css, common.js, index.js,这3个文件具有依赖关系,合并为一个chunk;utils.js单独形成一个chunk
- 一般来说一个chunk对应一个bundle,除非配置一定的策略将chunk分解为多个bundle
现在我们能够明确3个概念:
- module:我们写的个一个一个的文件(含有import、export) 就是module
- chunk:webpack对module分析,根据引用关系生产chunk(我理解有依赖关系的放在一个chunk里
- bundle:经过加载和编译的最终源文件,可直接在浏览器中运行,一般来说一个chunk对应一个bundle,除非有在webpack中特殊配置
一个简单直接的理解:我们直接写出来的是 module,webpack 处理时是 chunk,最后生成浏览器可以直接运行的 bundle。
bundle模式特点
bundle模式最显著的特点就在于,它需要以入口文件为起点,首先分析文件之间的依赖关系,生成模块依赖图。然后根据一定的策略将这个模块依赖图分解为多个 bundle
,多个 bundle
之间也存在依赖关系。
基于bundle模式的DevServer
unbundle模式是什么样的?
unbundle主要用于开发模式构建。它的产生得益于现代浏览器大多数能够直接解析ESM模块化代码,它跳过了解析依赖打包、构建模块依赖图的步骤,将模块依赖解析交给浏览器。当浏览器解析到需要什么依赖,就通过http请求获得相应的模块。
省去了 解析源文件的依赖关系、构建模块依赖图、tree shaking
、将模块依赖图分离成 bundle
步骤,unbundle模式仍然需要预构建和源文件转换:
-
预构建
为什么要预构建?
一是为了将第三方库转换成ESM格式,二是为了防止浏览器出现的瀑布流请求情况,影响性能,还需要对符合
ESM
规范的第三方库的多个文件做合并处理,减少http
请求数量。 -
源文件转换
将ts jsx, vue…格式文件转换成js和css
不同的是,
bundle
模式下转换过程是在build
过程中完成,是打包构建耗时较久的罪魁祸首之一。而unbundle
模式下,转换过程是在浏览器发起请求以后,由server
端借助middleware
中的plugin
完成的。由于转换过程也需要消耗一定的时间,导致unbundle
模式下,首屏和懒加载的性能会有一定的影响。
较为形象的图:
常见的构建工具
webpack
- webpack是主流的传统构建工具,它的构建机制属于bundle模式。
- 它采用CJS规范
Esbuild
- javascript bundle打包工具,可以将
JavaScript
和TypeScript
代码打包分发在网页上运行。但其打包速度却是webpack的10~100倍。 - 采用ESM规范
Rollup
- ESM规范
vite
vite在开发环境使用unbundle基于ESM的DevServer,生产环境使用rollup打包。
基于ESM的DevServer原理如下:
首先启动开发服务器,访问入口文件(通常是index.html),根据入口文件去访问相应的路由,根据路由去请求相应的依赖模块。没有参与的路由不会影响构建过程。
预编译过程:
Vite
启动一个 koa
服务器拦截这些请求,并在后端进行相应的处理将项目中使用的文件通过简单的分解与整合,然后再以ESM
格式返回返回给浏览器。
底层使用基于esbuild的预编译
源文件转换过程:
vite底层使用esbuild做源文件转换
HMR热更新过程:
Vite
通过 chokidar
来监听文件系统的变更,只用对发生变更的模块重新加载, 只需要精确的使相关模块与其临近的 HMR
边界连接失效即可,这样HMR
更新速度就不会因为应用体积的增加而变慢。
其他概念:
koa服务器:
chokidar:
HMR热更新:
参考文章: