谈到目前最的构建工具,那无疑要属 vite
了。vite
以其独特的 nobundle
打包机制,再配合浏览器对 ESM
规范的支持,能给开发人员带来很好的开发体验,越来越受到大家的欢迎。
看到 vite
这么优秀,小编也忍不住加入了学习的大军,近距离体验 vite
的魅力。在学习过程中,小编发现要想弄懂 vite
的核心原理,还需要去了解 esbuild
和 rollup
,因此又花费了一些时间去了解这两个构建工具。通过对这三个构建工具的学习,再结合以前对 webpack
的使用,整个人对构建工具这一块儿有了更进一步的认识,收获满满。
在这里,小编将学习所得做了一个梳理,以一问一答的形式呈现给大家,希望也能给到大家一些帮助。
本文的目录结构如下:
问题一: webpack 工作过程原理
使用 webpack
时非常简单, 就是提供一个 webpack config
,然后执行 webpack
提供的全局方法 webpack
,就可以编译打包了。
整个编译打包过程:
webpack
构建一个 compiler
;
compiler
生成一个 compilation
;
compilation
以入口文件为起点, 构建一个模块依赖图 - module graph
;
将 module graph
分离为 initial chunk
、async chunks
、runtime chunk
和 common chunks
;
获取各个 chunk
对应的 template
,使用 generator
为每个 chunk
的 module
构建内容,然后再为 chunk
构建内容;
compiler
将 compilation
对应的 assets
输出到 output
指定位置;
构建 module graph
的过程:
解析入口文件路径,获取到入口文件的绝对路径(解析的过程中,会得到处理文件内容需要的 loader
、parser
、generator
);
为源文件构建 module
对象;
读取源文件内容,使用 loader
处理;
使用 parser
解析 loader
处理的内容(将内容处理为 ast
, 收集依赖,静态依赖添加到 dependencies
中,动态依赖添加到 blocks
);
解析 dependencies
、blocks
中收集的依赖,重复 2 - 5,直到所有的依赖处理完成;
构建 module
的过程:
resolve
- 先解析模块的路径,得到模块的绝对路径
、loader
、parser
、generator
;create
- 创建一个 module
对象;build
- 获取 loader
提供的方法、读取源文件内容、使用 loader
处理源文件内容、使用 parser
解析源文件内容、收集依赖、处理依赖;chunks
分类:
initial chunk
: 入口文件对应的 chunk
;async chunk
: 异步 chunk
,lazy module
对应的 chunk
;runtime chunk
: 根据 optimizaiton.runtimeChunk: true
从 initial chunk
中分离出来,负责安装 chunk
、安装 module
、加载 lazy chunk
;normal chunk
: 通过 optimization.splitChunks
策略分离出来的 chunks
;分离 chunk
的过程:
initial chunk
(多入口文件,会存在多个 initial chunk
);module graph
, 通过 dependencies
连接的 module
都收集到 initial chunk
中, 通过 blocks
连接的 modules
都收集到 async chunks
中;initial chunk
、async chunks
中的重复 module
;optimization.splitChunks
进行优化,分离第三方 module
、被多个 chunk
共享的 module
到 common chunks
中;构建 bundle
:
chunk
的类型,获取对应的 template
;output.filename
构建 bundle
的文件名;chunks
的 `module, 使用 generator 为每一个 moudle 构建输出内容;template
,结合 module
的构建内容,构建 chunk
的输出内容;node
提供的文件功能生成 bundle
文件并输出到 output
指定位置;问题二: webpack hash
hash
:
module hash
: 根据每个 module
的源文件内容、模块 ID
生成;
chunk hash
: 根据 chunk
的 name
、所有 module
的 module hash
生成;
chunk contentHash
:
chunk.contentHash.javascript
: chunk
中所有 js
内容生成的 hash
;
chunk.contentHash["css/mini-extract"]
: chunk
中所有 css
内容生成的 hash
;
compilation hash
: 根据 chunk
对象的 chunk hash
信息生成;
[name].[hash].js
中的 hash
使用的是 compilation hash
,所有的 bundle
都一样;
问题三: 模块热更新
热更新的触发的条件:
devServer.hot
配置项为 true
;inline
模式;module.hot.accept(url, callback)
;热更新的工作过程:
浏览器构建 webSocket
对象, 注册 message
事件;
服务端监听到文件发生变化, 生成更新以后的 chunk
文件, chunk
文件中包含更新的 modules
,然后通过 webSocket
通知浏览器更新;
浏览器构建的 webSocket
对象触发 message
事件,会收到一个 hash
值和一个 ok
信息, 然后通过动态添加 script
元素, 加载新的 chunk
文件;
根据 module id
在 应用缓存(installedModuled
) 中 找到之前缓存的 module
。 然后以找到的 module
为基础, 递归遍历 module.parent
属性, 查找定义 module.hot.accept
的 parent module
。
如果没有找到, 则 hmr
不起作用, 只能通过 reload
页面来显示更新。 在递归过程中, 我们会把遇到的 module id
存储起来。
找到定义 module.hot.accept
的 parent module
之后, 根据第四步收集的 module id
, 将 installedModules
中将对应的 module
清除, 然后根据 module.hot.accept(url, callback)
中的 url
, 重新安装关联的 modules
。
执行我们注册的 callback
;
问题四: source-map
eval
、cheap
(只能映射到行,不能映射到列)source-map
(只能追踪到转换转换之前,比如压缩前的代码)moudle
(配合 source-map,可追踪到源代码)一般为 module-cheap-source-map
问题五: tree shaking
webpack
提供了两种级别的 tree shaking
: modules-levels
和 statements-level
。
modules-level
, 移除未使用的 module
,要配置 optimization.sideEffects
为 true
, 即开启 tree shaking
;
statements-level
,移除 module
中未使用的 module
,需要配置 optimization.useExports: true
, optimization.minimize: true
;
tree shaking
的理论依据:
js
代码执行前要先编译,生成 AST
和执行上下文;根据 AST
生成字节码; 执行生成的字节码;
es6
- module
在 js
代码的编译阶段就可以知道模块的依赖关系
这两者使得 webpack
可以在打包过程中,静态分析源文件内容,确定模块之间的依赖关系以及模块被使用的 export
, 移除未使用的模块以及模块中未使用的 export
。
tree shaking
的过程:
module graph
构建完成以后, 每个模块都会知道依赖的模块以及依赖模块中的 export
;
预处理 module graph
,确定每个模块被使用的 export
;如果 module
没有 export
被使用,那么 module
就会从模块依赖图中移除;
将 module graph
分离为 chunk
,为每个 chunk
构建内容,这个阶段会标记使用到的 export
;
压缩混淆代码时,将未使用的 export
移除;
问题六: module federation 工作原理
module federation
的概念: 使用 module federation
, 可以在一个 javascript
应用中动态加载另一个 javascript
应用的代码,并实现应用之间的依赖共享。
对外提供组件的应用称为 remote
应用,使用别的应用的组件的应用称为 host
应用;
使用 module federation
功能的配置:
name
: 当前应用的别名;filename
: 供别的应用消费的远程文件名,一般为 remoteEntry
;library
: 定义如何将输出内容包括给其他应用,配置和 output.library
、output.libarayTarget
一样;exposes
:暴露组件;remotes
:使用别的应用暴露的组件,一般是加载别的应用提供的 remoteEntry
文件,格式为 obj@url
;shared
: 配置多个应用之间的依赖共享;module federation
的工作原理:
webpack
根据应用的 exposes
、filename
、shared
配置项打包生成一个 remoteEntry
入口文件、包含 exposes
组件的 js
文件、包含 shared
依赖的 js
文件;host
应用启动,初始化一个 sharedScope
对象,包含 host
应用可与别的应用共享的模块;host
应用加载 remote
应用的 remoteEntry
,拿到 remoteEntry.js
暴露的变量,该变量包含一个 init
方法和一个 get
方法;host
执行执行 init
方法,用 host
应用的 sharedScope
初始化 remoteEntry
的 shareScope
;host
应用执行 get
方法,从 host
应用中获取供外部使用的组件;remoteEntry
内部包含的模块本质上还是对外隔离的,会对外暴露一个变量,通过这个暴露变量的 get
方法才可以获取内部的模块。
从 host
应用的 remotes
配置项就可以知道 remote
应用的 remoteEntry
链接以及暴露给外部的变量。
子应用之间共享的原理:host
应用定义的 sharedScope
,会通过 remote
暴露的变量的 init
方法,初始化 remote
应用的 sharedScope
, 这样两者就可以共享了。
shared
会有版本控制。
问题七: 打包构建分析
分析手段:
webpack
内置的 stats
: 只知道构建时间、打包体积,不知道哪个阶段时间长、哪个模块体积大;speed-measure-webpack-plugin
: 打包总耗时、每个插件、loader
的耗时;webpack-bundle-analyzer
:可视化分析每个 bundle
以及 bundle
中包含的 module
及体积;问题八: 常用的优化手段
构建速度优化:
webpack
、node
;thread-loader
;DLL
预编译;loader
的使用范围 - exlcued
、include
;减少文件的搜索范围;减少文件解析的时间;externals
:指定不参与编译打包的类库;esbuild
、swc
重写编译、压缩过程等;构建体积优化:
lazy load
、合理的代码分离策略;tree shaking
;externals
;问题九: 常用的 hooks
compiler
几个常用的 hooks(按照执行顺序排列):
initialize
: compiler
完成初始化以后触发;
beforeRun
: compiler
的 run
方法执行之前触发;
run
: compiler
的 run
方法执行之后触发;
beforeCompile
: compilation param
构建以后触发;
compile
: beforeCompile
触发以后,compilation
创建之前触发;
compilation
: compilation
对象创建以后触发,此时我们可以订阅 compilation
的 hooks
;
make
: 开始构建模块依赖图是触发,接下来都是 compilation
在工作,创建 module graph
、分离 chunks
、打包输出内容;
afterCompilte
: compilation
工作结束时触发;
emit
: 输出打包文件到指定目录之前触发;
afterEmit
:输出打包文件到指定目录之后触发;
assetEmitted
: 输出打包文件到指定目录之后触发, 可获取输出文件的路径、大小等;
done
: 本次编译打包正常结束时触发;
failed
: 本次编译打包失败时触发;
compilation hooks
:
buildModule
: module
构建之前触发;
succeedModule
:module
构建完成之后触发;
finishModules
: module graph
构建完成之后触发;
seal
: module graph
构建完成,开始分离 chunks
时触发;
afterOptimizeAssets
: 优化完每个 chunk
内包含的 assets
时触发;
...
比较关键的 hooks
:
compiler
:run
、 compilation
、make
、 afterEmit
、done
、failed
等;compilation
:seal
、 afterOptimizeAssets
;如果我们想要订阅 compilation
的 hooks
, 我们需要先订阅 compiler
的 compilation hook
。 compiler
创建 compilation
以后,会触发 compilation hook
,我们就可以在 callback
中订阅 compilation
的 hooks
。
问题十: 如何写一个 loader、plugin
loader
本质上一个函数,用于将其他类型的文件,转化为浏览器可以识别的文件类型。
自己写一个 Plugin
:
创建一个 class
,需要提供实例方法 - apply
;
apply
方法在触发时, 会传入一个 compiler
对象,然后我们可以在 apply
内部,通过 compiler.hooks.xxxx.tap
('插件名称', callback
) 的形式订阅 compiler
的 hooks
。
当到了 compiler
的某个阶段时,触发该阶段的对应的 hook
,执行对应的 callback
。
在 webpack
的 plugins
配置项中提供 plugin
对应的实例;
在 compiler
构建初始化的过程中,会触发 plugin
实例的 apply
方法,订阅指定的 compiler
的 hook
;
写一个可替换打包文件内容中指定字符串的 plugin
:
class ReplacePlugin {
constructor(option) { ...}
apply(compiler) {
compiler.hooks.compilation.tap('ReplacePlugin', (compilation, compilationParams) => {
compilation.hooks.afterOptimizeAssets.tap('ReplacePlugin', (assets) => {
Object.keys(assets).forEach(key => {
assets[key] = new OriginalSource(assets[key].source().replace(/https:\/\/reactjs/, 'zjh'));
})
});
});
}
}
复制代码
问题十一: babel 相关
babel
做了什么: 语法转换
和 api
的 polyfill
。
babel
用途:
转义
, 将 es6
、ts
、flow
等到目标环境支持的 js
;特殊的代码转换
,如埋点代码、自动国际化 等;代码的静态分析
,eslint
、 api
文档自动生成工具、ts
检查、代码压缩混淆;babel
是 source
到 source
的转换,整体编译流程分为三步:
parse
, 通过 parse
将源码转化为 AST
,即抽象语法树;transform
, 遍历 AST
,对 AST
各个节点做增删改;generate
,根据转换以后的 AST
生成目标代码,并生成对一个的 sourcemap
;AST
是对源码的抽象,节点的类型包括字面量
、标识符
、表达式
、函数
、模块语法
、class
语法等;
plugin
、preset
、babel api
的关系: babel
在做 transform
操作时提供了一系列的 api
,将这些 api
做封装就成了 plugin
;再对 plugin
做封装,就成了 preset
;
plugin
:一些函数,在 babel
做 transform
时使用。
babel(7)
内置的 plugin
类型:
syntax plugin
, 语法类型的 plugin
, 使得 parse
可以正确的将语法解析成 AST
;transform plugin
,转换类型的 plugin
, 用于转换 AST
;proposal plugin
, 未加入语言标准特性的 AST
转换, 也是 transform plugin
;preset
:对 plugin
的封装,项目初始化的时候,会根据 prest
安装 plugin
。
plugin
、preset
执行顺序:
plugin
从左到右执行;preset
从右到左执行;plugin
, 再执行 preset
;plugin
、preset
默认是一个字符串,如果需要添加配置项,那么就提供一个数组。数组的第一个元素为 plugin
、preset
的名称,第二个参数是对应的配置项。
presets
:
@babel/preset-env
, 用于编译 es2015
语法;@babel/preset-react
, 用于编译 reat
;@babel/preset-jsx
, 用于编译 jsx
;@babel/preset-typescript
, 用于编译 ts
;@babel/preset-flow
, 用于编译 flow
;babel helpers
: 用于 bable plugin
逻辑复用的一些工具函数, 分为用于注入 runtime
代码的 helper
和用于简化 AST
操作的 helper
两种。
babel runtime
里面放运行时加载的模块,会被打包工具打包到产物中。
babel7
: 通过 @babel/preset-env
+ plugin-proposal-xxx
, 指定目标环境,做精确转换。
babel-compat-table
提供了 es6
每个特性在不同版本中的支持版本;通过 browserslist query
可以查找满足条件的环境的版本。
@babel/preset-env
的 配置项:
targets
,指定环境版本;modules
,以特定的模块化来输出代码;corejs
, babel7
所使用的 polyfill
, 版本 3
才支持;useBuiltIns
, 使用 polyfill
的方式:
entry
,在入口处全部引入;useage
,在每个文件中引入用到的 polyfill
, 不是全部引入;false
, 不引入;AST 实际能做的事情:
babel
是通过 @babel/preset-env
来做按需 polyfill
和转换的,原理是通过 browserslist
来查询出目标浏览器版本,然后根据 @babel/compat-data
的数据库来过滤出这些浏览器版本里哪些特性不支持,之后引入对应的插件处理。
babel
处理兼容行问题有两种方案:
polyfill
方案;
使用 @babel/preset-env + corejs@3
实现简单语法转换 + 复杂语法注入 api
替换 + 构造函数添加静态属性、实例属性 api
,支持全量加载和按需加载;
缺点:就是会造成全局污染,而且会注入冗余的工具代码;
优点:可以根据浏览器对新特性的支持度来选择性的进行兼容性处理;
corejs2 不支持实例方法的 polyfill, corejs3 支持。
runtime
方案;
使用 @babel/preset-env
+ @babel/runtime-corejs3
+ @babel/plugin-transform-runtime
, 实现简单语法转换 + 复杂语法注入 api
替换 + 构造函数添加静态属性、实例属性 api
,仅按需加载;
优点:解决了 polyfill
方案的那些缺点,
缺点:不能根据浏览器对新特性的支持度来选择性的进行兼容性处理,造成一些不必要的转换,从而增加代码体积;
polyfill
方案比较适合单独运行的业务项目,如果你是想开发一些供别人使用的第三方工具库,则建议你使用 runtime
方案来处理兼容性方案,以免影响使用者的运行环境。
问题十二: AST 相关
以 babel
为例,babel
编译代码是 source to source
的转换, 整个过程分为三步:
parser
,通过解析器进行词法分析,将源码转化为 AST
对象;transform
, 遍历 AST
, 对 AST
进行增删改查;generate
,生成器,将 AST
转化为目标代码,并生成 source-map
;AST
是对源码的抽象,字面量
、标识符
、表达式
、语句
、class
、module
都有自己的 AST
。
AST
节点的类型:
字面量
, literal
, 具体可分为 stringLiteral
、numberLiteral
、booleanLiteral
、RegExpLiteral
等;标识符
, Identifier
, 表示变量名、属性名、参数名等各种声明和引用的名字;语句
, statement
,代码中可独立执行的语句,如 break
、if
、forIn
、while
等,具体可以分为:BreakStatement
、ReturnStatement
、BlockStatement
、TryStatement
、forInStatement
、fowStatement
、WhileStatement
、DoWhileStatement
、SwitchStatement
、WiehStatement
、IfStatement
等;声明语句
, Declaration
, 是一种特殊的语句,表示声明一个变量
、函数
、class
、import
、export
等,具体可以分为: VariableDeclaration
、FunctionDeclaration
、ClassDeclaration
、ImportDeclararion
、ExportDeclaration
等;表达式
, Expression
,执行完以后有表达式,常见的 Expression
有 ExpressionStatement
、ArrayExpression
、AssignmentExpression
、FunctionExpression
、ClassExpression
、CallExpression
等;Programe
, 代表整个源码的节点, body
属性代表程序体;Directive
, 代码中的指令部分;Comment
, 注释节点;AST
节点的公共属性:
type
, AST
节点的类型;start
、end
、loc
源码字符串的开始、结束、行列号;一个日常的源代码文件对应的 AST
结构:
最外层是一个 Programe
节点, body
属性代表程序体;
body
内部第一层一般为声明语句,如 ImportDeclaration
、ExportDeclarction
、ClassDelaration
、FunctionDelaration
、VarliableDelarction
,如果有语句执行,还会有 IfStatement
、WhileStatement
、ExpressionStatement
;
接下来就是各个 AST
节点内部的结构;
问题一: 为什么要有 rollup ?
原因:
webpack
作为构建工具,用在 lib
开发方面,打包出来的代码包含大量的冗余代码;ESM
模块规范,在打包过程中可以实现 tree shaking
(其他构建工具的 tree shaking
功能都借鉴了 rollup
);ESM
规范的支持,使得在生产环境直接使用 ESM
规范的代码也是可以的;rollup
的应用场景:
ESM
规范完全支持,可以使用 rollup
打包应用,vite
的 production
打包就是基于 rollup
实现的。问题二: rollup 有哪些关键 API ? 怎么使用 ?
rollup
提供两个 API
- rollup
和 watch
。
rollup
用于打包构建。执行 rollup
会返回一个 promise
对象,它解析为一个 bundle
对象。通过 bundle
对象的 write
方法,可以将打包构建的包放置到指定位置。
用法如下:
const rollp = require('rollup');
rollup.rollup(inputOptions).then(bundle => {
bundle.write(outputOptions);
});
复制代码
watch
可以用来监听某个文件的变化,然后重新发起打包构建。
问题三: rollup 常用配置项有哪些 ?
rollup
的配置项分为两个部分:
执行 rollup.rollup
时需要传入的 inputOption
,用于构建 module graph
;
执行 bundle.write
时需要传入的 outputOption
,用于构建 bundles
并输出到指定位置;
常用的 inputOption
:
input
,打包的入口配置, 可以是一个字符串(单入口文件打包)、一个字符串数组(多入口文件打包)、一个对象(多入口文件打包);
external
, 配置不参与打包的文件,可以是一个匹配 id 的正则表达式、一个包含 id 的数组、一个入参为 id 返回值为 true 或者 false 的函数;
plugins
, 构建 module graph
时用到的插件;
常用的 outputOption
:
dir
, 放置生成的 bundle 的目录,适用于多入口文件打包;
file
,生成的 bundle
的文件名及目录,适用于单入口文件打包;
format
,指定生成的 bundle
的格式: amd
、CJS
、es
、iife
、umd
、system
;
globals
, 指定 iife
模式下全局变量的名称;
name
,指定 iife
模式下赋值的变量;
plugins
,输出时使用的插件;
assetFileNames
, 给静态文件 assets
命名的模式,默认为 assets/[name]-[hash][extname]
;
banner / footer
,要添加到 chunk code
顶部/尾部位置的注释字符串;
chunkFileNames
, 给分离的 chunk
命名的模式,默认为 [name]-[hash].js
;
entryFileNames
,initial chunk
的命名规则,默认为 [name].js
;
inlineDynamicImports
懒加载模块是否内联。
默认情况下,懒加载模块会自动分离为一个单独的 async chunk
。如果 inlineDynamicImports
为 ture
,懒加载模块会合并到 importor module
中。
inlineDynamicImports
不能和 manualChunks
一起使用,否则会报错。
intro / outro
, 要添加到 chunk code
顶部 / 尾部的代码,可用于变量注入。
manualChunks
自定义 chunk
分离规则,类似于 webpack
的 splitChunks
规则,将匹配的 module
分离到指定 name
的 chunks
中。
manualChunks
可以是一个对象,也可以是一个函数。如果是一个对象,key
为自定义 chunk
的 name
, value
是一个 id
数组,表示要分配到自定义 chunk
的 module
。
如果是一个函数,入参为 module id
,返回值为自定义 chunk
的 name
。 rollup
会遍历模块依赖图,将匹配 manualChunks
函数的 module
分配到对应的自定义 chunks
中。
分配到 manualChunks
中的 modules
中如果存在懒加载 module
,懒加载 module
也会单独分离到 async chunk
中
preserveModules
,
rollup
默认的 chunk
分离规则将模块依赖图分离为尽可能少的 chunk。一个单页应用最后的分离结果为:一个 main chunk
,多个懒加载 module
为 async chunk
,多个根据 manualChunks
规则生成自定义 chunks
。
而 preserveModules
的分离过程正好相反。如果将 preserveModules
设置为 true
, rollup
会将每个 module
分离为一个单独的 chunk
。
preserveModules
需要配合 format
一起使用。
总的来说,inputOptions
中最关键的是: input
、externals
、plugins
; outputOptions
中比较关键的是: dir
、file
、format
、plugins
、preserveModules
等。
问题四: rollup 的 plugin 机制是怎么样的 ?如何实现一个自定义 plugin ?
rollup
插件的一些约定:
插件要有一个清晰的名称和 rollup-plugin
-前缀;
在 package.json
中要包含 rollup-plugin
关键字;
插件应该是被测试的;
尽可能的使用异步方法;
如果可能,请确保您的插件输出正确的源映射;
一个自定义插件的格式为:
{
name: 'rollup-plugin-xxxx',
options: () => { ... },
resolveId: () => { ... },
load: () => { ... },
...
}
复制代码
rollup hook
根据执行的顺序,可以分为:
async
,异步 hook;
first
- 如果有多个 plugin 实现了这个 hook,这些 hook 会按序执行,直到一个 hook 返回不是 null 或者 undefined 的值,即如果某个 hook 返回不是 null 或者 undefined 的值,那么后续的同类型的 hook 就不会执行了。
sequential
- 如果有多个 plugin 实现了这个 hook,这些 hook 会按照 plugin 的顺序按序执行。如果一个 hook 是异步的,那么后续的 hook 会等待当前 hook 执行完毕才执行。上一个 hook 返回的结果会作为下一个 hook 的入参。
parallel
- 如果有多个 plugin 实现了这个 hook,这些 hook 会按照 plugin 的顺序按序执行。如果一个一个 hook 是异步的,那么后续的 hook 将会并行执行,而不是等待当前的 hook,即 parallel 类型的 hook 之间不相互依赖。
针对 parallel
的 hook,vite(或者 rollup) 会采用一个 promise.all,等所有的 parallel hook 处理完毕以后,开始处理下一类型的 hook。
rollup hook
根据执行的阶段,可以分为:
build hook
,构建阶段的 hook
(按照执行顺序
):
options - async、sequential
,构建阶段的第一个 hook,可用于修改或者替换配置项 options,唯一一个无法访问插件上下文的 hook;
buildStart - async、parallel
,各个插件可以通过这个 hook 做一些准备工作,如初始化一些对象、清理一些缓存等;
resolveId - async、first
,自定义解析器,用于解析模块的绝对路径;
resolveDynamicImport - async、first
,为动态导入定义自定义解析器
load - asnyc、first
,自定义加载器,根据 resolveId 返回的路径去加载模块;
transform - async、sequential
,对模块做转换操作,一般的操作是生成 AST,分析 AST,收集依赖,做代码转换等;
moduleParsed - async、parallel
,模块已经解析完毕,接下来需要解析静态依赖/动态依赖;
buildEnd - async、parallel
,rollup 完成 bundle 调用,即模块依赖图构建完成;
output generation hook
,输出阶段的 hook(按照执行顺序):
outputOptions - sync、sequential
,输出阶段的第一个 hook,可用于修改或者替换 output 配置项;
renderStart - async、parallel
,类似于 buildStart hook,做一些准备工作;
banner / footer / intro / outro - async、parallel
,在源文件的头部 / 底部添加注释、代码;
renderDynamicImport - async、parallel
augmentChunkHash - sync、sequential
resolveFileUrl / resolveImportMeta - sync、first
renderChunk - async、sequential
, 每个 chunk 的内容构建完成触发;
generateBundle - async、sequential
,所有 chunk 的内容构建完成触发;
writeBundle - async、parallel
,将每个 chunk 的内容输出到指定位置以后触发;
closeBundle - async、parallel
,rollup 结束工作时触发;
build
阶段,用的较多的 hook
- options
、resolveId
、load
、transform
。
generate
阶段, 用的较多的 hook
- outputOptions
、renderChunk
、generateBundle
。
问题五: rollup 的整个工作过程是怎么样的 ?
rollup
整个工作过程如下:
执行 rollup.rollup
方法,入参为 input options
,开始构建 module graph
。
初始化 input options
。依次触发 input plugins
中各个 plugin
的 options hook
,更新 input options
;
构建一个 module graph
实例,初始化 plugin
驱动、acorn
实例、module loader
,这个时候 module graph
还是一个空的对象;
依次触发 input plugins
中各个 plugin
的 buildStart
,做一些初始化工作、缓存处理问题;
开始构建 module graph
;
构建 module graph
的具体过程:
id
,得到入口模块的绝对路径(通过 resolveId hook
来解析)module
对象;load hook
来加载 module
的源文件;AST
对象;AST
对象,收集静态依赖和动态依赖,其中静态依赖收集到 module
对象的 sources
数组中,动态依赖收集到 module
对象的 dynamicImport
数组中; 同时还可以知道依赖的 import
有没有被使用(被使用的 import
会被收集到 module
的 includedImports
中);module
对象的 sources
、dynamicImport
数组,解析依赖模块的路径;input plugins
中各个 plugin
的 moduleParsed hook
;静态依赖模块,会收集到 importer
模块的 dependencies
列表中;动态依赖模块会收集到 importer
模块的 dynamicDependencies
列表中。
静态依赖会收集到 importor module
的 sources
列表中,动态依赖会收集到 importor module
的 dynamicImport
列表中;同样的 importer
模块的 id
也会收到到静态依赖模块的 importers
和动态依赖模块的 dynamicImporters
中。这样 module graph
就构建完成了。
模块排序;
返回一个带 generate
、write
方法的 bundle
对象;
执行 bundle.write
方法,入参为 output options
,分离 chunks
、输出 bundles
到指定位置。
初始化 output option
。依次触发 output plugin
的 outputOptions hook
, 更新 output otions
;
构建一个 Bundle
实例,入参为 input options
、output options
、output plugin
引擎、module graph
;
执行 bundle
实例的 generate
方法, 将 module graph
分离为 chunks
;
整个过程如下:
先创建一个空的 outputBundle
对象;
依次触发 output plugin
的 renderStart hook
(作用应该类似于 buildStart hook
,做一些初始化、缓存清理工作);
将 module graph
分离为 chunks
,具体过程为:
根据 output.manualChunks
规则,建立一个 map
,key
为 module
对象,value
为自定义 manualChunks
的 name
;
确定 chunk
分离规则。
如果 output.inlineDynamicImports
为 ture
,所有的 module 会分离为一个 chunk
;
如果 output.preserveModules
为 ture
,每个 module
会分离为一个单独的 chunk
(配合 output.format
使用);
如果 output.inlineDynamicImports
、output.preserveModules
都为 false
,那么就将 module graph
分离为 entry chunks
、dynamic chunks
、自定义 manual chunks
。
根据 chunk
分离规则,确定 chunk
以及 chunk
包含的 modules
。
如果 output.inlineDynamicImport
为 ture
,chunk
只有一个,对应的 module
列表收集了所有的 modules
;
如果 output.preserveModules
为 true
(output.inlineDynamicImport
为 false
),module
有多少个, chunk
就有多少个,chunk
的 module
列表只有一个 module
;
如果 output.inlineDynamicImport
和 output.preserveModules
为 false
,chunk
分离过程为:
先根据 output.manualChunks
创建 manual chunks
,把属于他们的 module
添加到对应的 manual chunks
中;
以 module graph
的入口模块为起点,分析 module graph
,找到 lazy load modules
以及 module
和 importor module
的映射关系(去掉已经分离到 manualChunks
中的 modules
);
找到每一个 module
和其对应的 entry modules
(包含 static entry modules
和 dynamic entry modules
);
根据 module
和对应的 entry modules
,将 modules
分离为 initial chunks
和 dynamic chunk
;
如果 module
的 importor module
包含 static entry modules
和 dynamic entry module
,那么该 module
会分配打到 initial chunk
中。
在这一过程中,如果 module
的 importor module
并没有使用该 module
的 exports
,那么该 module
并不会添加到 chunk
中,这样就做到了 module
级别的 tree shaking
。
遍历分离好的 chunks
,给每个 chunk
中收集的 modules
排序,然后构建 chunk
实例,建立一个 map
,收集 module
和 chunk
的映射关系;
遍历 chunks
,确定每个 chunk
依赖的 static chunks
和 dynamic chunks
,static chunks
需要先加载,dynamic chunks
需要 懒加载;
为每个 chunk
绘制内容,即根据 chunk
中收集的 modules
构建 chunk
实际的内容:
依次触发 output plugin
的 banner hook
、footer hook
、intro hook
、outro hook
,返回需要添加到 chunk
中的 banner
、footer
、intro
、outro
;
根据每个 chunk
收集的 modules
,找到每个 chunk
对外的 exports
;
对每个 chunks
做预处理,确定每个 chunk
中要移除的 module
以及 chunk
中每个 module
要移除的 exports
;(有些 module 在分配到 chunk 的时候就可以确定是否被移除掉);
给每一个 chunk
分配 id
;
为每一个 chunk
根据收集的 module
构建内容,并依次触发 renderChunk hook
;
所有 chunk
的内容构建完毕,依次触发 output plugin
的 generateBundle hook
;
将构建好的每一个 bundle
,通过 fs.writeFile
输出到 outdir
指定位置;
依次触发 output plugin
的 writeBundle hook
, 整个 build
过程结束;
问题六: rollup 是如何确定每个 module 的 exports 是否被使用的 ?
分析一个 module
的 AST
时,就可以知道这个 module
的 exports
和 dependence modules
及用到的 dependence module
的 exports
。
然后根据 dependence modules
的 exports
和被使用到的 exports
,就可以确认 module
的哪一个 exports
没有被使用。如果某个 module
的所有 exports
都没有被使用,那么该 modules
就可以从 chunks
的 modules
列表中移除,或者在分配 chunks
就不会添加到 chunks
中去。
问题七: rollup 的 treeshaking 原理是什么 ?
rollup
基于 es6 module
实现了 module level
和 statement level
的 tree shaking
:
在将 module graph
分离为 chunks
时,如果一个 module
被 importor module
依赖,但是它的 exports
并没有被使用,那么该 module
不会添加到 chunk
中,实现了 module level
的 tree shaking
;
一个 module
的 exports
,如果没有被其他 module
使用到,那么在构建 chunk
内容时就会被移除掉,实现了 statement level
的 tree shaking
;
问题一:esbuild 怎么使用 ? 常用 API 有哪些 ?
esbuild api
的使用方式有三种: cli
、js
、go
。比较常用的为 js
、cli
。
esbuild
提供了两个 api
供大家使用:transform
和 build
。
transfrom
,即转换的意思,通过这个 api
可以将 ts
、jsx
、tsx
格式的内容转化为 js
格式的内容。 transfrom
只负责文件内容转换,并不会生成一个新的文件。
build
,即构建的意思,根据指定的单个或者多个入口,分析依赖,并使用 loader
将不同格式的内容转化为 js
内容,生成一个 bundle
文件。
build
过程肯定包含了 transform
过程。
这两个 api
的使用方式:
const res = await esbuild.transform(code, options) // 将 code 转换为指定格式的内容
esbuild.build(options) // 打包构建
复制代码
问题二:esbuild 使用 transform、build 时的常用配置项有哪些 ?
使用 transfrom
、build
时都可传入的参数:
define
, 用常量表达式替换指定全局标识符;
format
, bundle
输出文件的格式,有三种 iife
、CJS
、ESM
,即立即执行函数
、commonjs
、es module
;
platform
为 browser
时, format
默认为 iife
。
platform
为 node
时, format
默认为 CJS
。
loader
,用于配置指定类型文件的解释方式(对比 webpack 的 loader)
minify
, 压缩代码
target
, 根据设定的目标环境,生成对应的 js、css 代码;
banner
, 给生成的 js、css 代码头部添加指定的字符串;
footer
, 给生成的 js、css 代码底部添加指定的字符串;
globalName
, 需配合 format: 'iife' 使用,将生成的 iife 代码的结果赋值给 globalName 指定的变量;
使用 build
时可传入的独有参数:
entryPoints
, 指定打包构建的入口文件
entryPoints
可以是一个数组,也可以是一个对象。
如果 entryPoints
是一个数组,当数组元素只有一个时,是单入口打包,生成的 bundle
只有一个;当数组元素有多个时,是多入口打包,生成的 bundle
有多个。
注意,如果是多入口打包,不能使用
outfile
配置项,只能使用outdir
配置项。
当 entryPoints
是一个对象时,key
为 outfile
的文件名, value
为入口文件的文件名。
entryNames
,用于控制每个入口文件对应的输出文件的文件名,可通过带有占位符的模板配置输出路径;
entryNames
的一般格式为 [dir]/[ext]/[name]-[hash]
其中, dir
会基于 outBase
解析为入口文件的目录;ext
对应为 outExtension
; name
为入口文件的文件名; hash
为 bundle
内容对应的 hash
。
bundle
, 是否将依赖内联到 entry file
中;
如果未显示指定 bundle
的值为 true
,那么依赖项不会内联到 entry file
中。
当 bundle
设置为 true
时,如果依赖的 url
不是一个静态定义的字符串,而是运行时生成,那么该依赖不会内联到 entry file
中。
即 bundle
是编译时操作,不是运行时操作。
external
, 构建时指定不内联到 entry file
中的依赖;
outdir
, 指定构建内容的输出文件夹;
outfile
, 指定构建内容的输出名称,如果是多入口打包构建,则不能使用,此时必须是 outdir
;
platform
, 默认情况下构建内容是为浏览器准备的,代码格式为 iife
,也可指定为 node
;
serve
,主要用于开发模式下修改文件以后,自动重新 build
;
serve
是 esbuild
提供的一个新的 api
。
sourcemap
, 配置生成 sourcemap
文件
可选的值为 true
、'linked'
、'inline'
、'external'
、'both'
;
'linked'
,生成一个 .map
文件,在 bundle
中有一个 link
指向生成的 .map
文件;
'inline'
, .map
文件的内容内联的 bundle
中;
'external'
,生成 .map
文件,但是 bundle
中没有 link
指向生成的 .map
文件;
'both'
, 'inline'
和 'external'
的聚合,生成一个独立的 .map
文件,bundle
中有自己内联的 .map
内容;
true
,等同于 'linked'
;
splitting
, 代码分离;
esbuild
的代码拆分功能并不完善,目前仅支持将多入口文件的共同依赖、动态依赖拆分出来,而且 format
必须是 ESM
。
不支持自定义代码分离。
watch
,监听文件的变化,然后重新 esbuild
;
write
,用于配置 build
内容是直接写入文件系统还是写入内存缓存区 - buffers
默认为 ture
,直接写入指定文件中。
如果配置为 false
,则写入内存缓存区中,通过 js
代码可读取构建内容。
assetNames
,静态资源的输出配置,和 entryNames
一样;
chunkNames
,代码分离生成的 chunk
的输出配置,需要配合 splitting
一起使用;
resolveExtensions
,路径后缀名扩展;
在解析 url
时,如果 url
没有后缀名, esbuild
会默认使用 .ts
、.tsx
、.jsx
、.js
、.css
、.js
。
通过 resolveExtensions
可以添加 esbuild
没有的后缀名。
解析的时候,需要拿到文件的绝对路径去读取文件内容。如果 url 没有后缀名,我们就需要给 url 添加正确的后缀,才能争取读取文件。此时就需要我们通过 resolveExtensions 指定 esbuild 没有提供的后缀名。
treeShaking
,配置是否开启 tree shaking
功能。
问题三:如何定义一个 esbuild plugin ?
自定义一个 esbuild plugin
:
{
name: 'xxx',
setup: (build) => {
build.onResolve({ filter: '', namespace: '' }, args => { ...});
build.onLoad({ filter: '', namespace: ''}, args => { ... });
build.onStart(() => { ... });
build.onEnd(() => { ... });
}
}
复制代码
plugin
的 hooks
:
onResolve
解析 url
是调用,可自定义 url
如何解析。如果 callback
有返回 path
,后面的 callback
将不会执行。
所有的 onResolve callback
将按照对应的 plugin
注册的顺序执行。
onLoad
加载模块时调用,可自定义模块如何加载。 如果 callback
有返回 contents
,后面的 callback
将不会执行。
所有的 onLoad callback
将按照对应的 plugin
注册的顺序执行。
onStart
每次 build
开始时都会触发,没有入参,因此不具有改变 build
的能力。
多个 plugin
的 onStart
并行执行。
onEnd
每次 build
结束时会触发,入参为 build
的结果,可对 result
做修改。
多个 plugin
的 onEnd
是按序执行的。
问题四:esbuild 的优缺点
优点:快。
缺点:
无法修改 ast
,防止暴露过多的 api
而影响性能;
不支持自定义代码拆分(拆分出来的 chunk
: initial chunk
、async chunk
、runtime chunk
)
产物无法降级到 es5
之下;
问题五:esbuild 为什么快 ?
Go
语言开发,可以多线程打包,代码直接编译成机器码(不用先解析为字节码);cpu
优势;问题一: vite 的常用命令有哪些 ?
启用 development
模式的命令: vite
、vite serve
、vite dev
;
启用 production
模式的命令: vite build
;
vite optimize
, 手动进行预加载依赖优化;
vite preview 后面补充
问题二: vite 的常用配置项有哪些 ?
development
、production
模式下的公用配置项:
root
: 项目的根目录,即 index.html
所在的位置,可以是绝对路径,也可以是基于 vite.config.js
的相对路径, 默认值为 process.cwd()
;base
: 基础公共路径;mode
: 默认,开发为 development
,生产为 production
;通过 mode
选项可以覆盖 serve
和 build
命令对应的默认模式;define
: 定义可替换的全局变量plugins
: 插件,对应 rollup
插件;publicDir
: 静态资源目录,build
结束以后会复制到 outDir
目录下;cacheDir
: 预构建文件的缓存目录,默认为 node_modules/.vite
;resolve
: 文件解析配置,有 alias
(别名配置)、dedupe
、mainField
等;css
相关配置json
相关配置esbuild
相关配置envDir
: .env
文件的根目录;envPrefix
: 环境变量的前缀,默认为 VITE_
;development
模式下特有的配置项:
server
, 开发服务配置optimizeDeps
,预构建配置项production 模式下独有的配置项
build
target
: 根据浏览器的兼容性,生成 bundle
,默认值为 modules
,即浏览器支持 ESM
;ourDir
: 指定 output
输出的文件夹目录;assetsDir
: 指定生成 assets
的文件夹目录;assetsInlineLimit
: 静态文件大小,小于指定值的将内联为 base64 url
;cssCodeSplit
: 启动/禁用 css
代码拆分。启用后,异步块中导入的 css
将内联到异步块中并在加载时插入。如果禁用,整个项目中的 css
代码将会被提取到单个文件中。cssTarget
: 此选项允许用户为 CSS
缩小设置不同的浏览器目标,而不是用于 JavaScript
转换的浏览器目标, 默认值同 build.target
;sourcemap
: 是否成成 sourcemap
文件;rollupOptions
, rollup
工具的配置项,分为 inputOptions
和 outputOptions
,其中 inputOptions
用于构建 module graph
,outputOptions
用于将 module graph
分离为 chunks
并输出到指定位置;lib
: 构建为 lib
,必须指定 entry
;manifest
, 是否生成一个 manifest
文件;minify
, 是否压缩;问题三: development 模式下 vite 的工作过程是怎样的 ?
development
模式下的整个工作过程:
解析 vite config
配置项。
解析以后的 config
中的 plugins
为内部插件
+ 三方插件
+ 自定义插件
,插件的顺序为 alias 插件
、pre 插件
、vite 核心插件
、normal 插件
、build pre 插件
、post 插件
、build post 插件
。
在这个过程中,每个 plugin
的 config hook
会触发,更新 vite config
;
基于 http.createServer
创建一个 server
实例;
创建一个文件监听器 watcher
,用于监听文件的变化;
依次执行各个 plugin
的 configureServer hook
,收集要给 server
要添加的自定义 middlewares
;
给 server
添加 middlewares
;
启动 server
;
依次执行各个 plugin
的 buildStart hook
,做准备工作,如初始化、清理缓存工作;
预构建优化;
客户端开始请求入口文件,server
端收到请求,依次执行 middleware
,返回请求的文件内容;
不同的文件,处理逻辑也不相同。
如果是 .html
类型的文件,先将 html
文件解析为 ast
对象,然后找到入口文件 - main.tsx
;
如果是 js/ts/tsx/jsx/CJS
文件,先通过 plugin
的 resolveId hook
,解析为绝对路径;然后再通过 plugin
的 load hook
加载源文件,然后再通过 plugin
的 transform hook
做源文件做转换(jsx -> js, tsx -> js, ts -> js)、找到模块的依赖模块,然后再对依赖模块做同样的处理;
如果是 css/less/sass
文件,处理过程和 js
一样。
问题四: production 模式下 vite 的工作过程是怎样的 ?
production
模式下整个工作过程:
解析整个构建操作需要的配置项。vite
通过读取 vite.config.js
的方式来获取构建操作需要的配置项 - build
。
确定构建操作的入口文件
确定逻辑如下:
如果有 build.lib.entry
, 选择 build.lib.entry
作为入口文件;
如果配置了 ssr
,选择 ssr
对应的文件作为入口文件;
如果配置了 rollupOption.input
, 选择 input
作为入口文件;
选择 index.html
中的 main.js
文件作为入口文件;
调用 rollup.rollup
, 构建模块依赖图,返回一个 bundle
;
执行 bundle.write
方法,将 module graph
分离为 chunks
并输出到指定位置(或者调用 bundle.generate
方法);
不管是 development
还是 production
模式,浏览器端都是通过 ESM
加载 js
代码。
问题五: vite 的 plugin 类型及如何实现一个自定义 plugin ?
vite
插件定义和 rollup
插件基本相同。
vite
中自定义插件根据执行顺序,可以分为三类: pre 类型
、normal 类型
、post 类型
。 三种类型的插件的执行顺序为 pre
、normal
、post
。
通过 enforce
属性可以指定插件的执行顺序。如果未指定,默认为 normal
。
一个 vite
插件,常见的 hook
有哪些:
config
, 可用于修改 vite config
,用户可以通过这个 hook
修改 config
;
resolvedConfig
, 用于获取解析完毕的 config
,在这个 hook
中不建议修改 config
;
configureServer
, 用于给 dev server
添加自定义 middleware
;
transformIndexHtml
,用来转换 HTML
的内容。
handleHotUpdate
,用来进行热更新模块的过滤,或者进行自定义的热更新处理。
vite
插件执行顺序:
alias
插件;pre
插件;vite 核心插件
;normal
插件`;build pre
插件;post
插件;build post
插件;在定义一个自定义 plugin
的 hooks
时,需要明确先知道你想要这个 plugin
在 vite
的哪个阶段执行,即需要定义哪些 hooks
;然后再根据 hooks
的类型如 first
、sequential
、parallel
来决定 hook
的 enforce
。
parallel
类型的 hook
之间互不影响,所以对 enforce
没有要求;first
类型的 hook
,前面的 hook
结果会影响后面的 hook
到底需不需要执行,一般设置为 normal
、post
,尽量不要设置为 pre
(主要是怕影响到 vite 内部插件的执行,除非你有把握);sequential
类型的 hook
,前面的 hook
返回的结果会影响后面的 hook
的结果,可以设置为 pre
、normal
、post
(要有把握)
一般经验: hook
的 enforce
一般设置为 normal
(post 也可以),尽量不要设置为 pre
(除非你有绝对的把握);
first
类型的 hook
一定要注意,如 resolveId
、load
、resolveDynamicImport
,设置 enforce
时要谨慎;
问题六: vite 的预构建过程及原理
vite 需要执行预构建的目的:
commonjs
或者 umd
类型的依赖转化为 ESM
;ESM
依赖关系转化为一个模块(将多个 http
请求合并为单个 http
请求);整个预构建过程:
先判断需不需要进行预构建;
vite
内部会通过 config.optimizeDeps.disabled
配置项判断需不需要进行预构建。但是 optionmizeDeps.disabled
并没有开放给用户,所以使用 vite
的时候,开发环境下默认开启预构建;
判断是否可以使用上一次预构建的内容,如果不可以使用,就需要重新进行依赖预构建;
如果没有缓存的预构建内容,即没有 /node_modules/.vite/deps
,那么就需要重新进行预构建;
如果有缓存的预构建内容,但是 config.server.force
的值为 true
,需要强制进行依赖预构建;
如果有缓存的预构建内容,且 config.server.force
为 false
,就需要判断上一次的预构建内容是否可用。
有几个源来决定 vite 是否需要重新进行预构建:
package.json
中的 dependencies
列表;lockfile
;vite.config.js
;vite
会通过一个有 .lock
文件内容和 vite.config
内容生成的 hash
值来判断项目的依赖项是否发生了变化。如果 .lock
文件或者 vite.config
配置项内容发生了变化,那么 hash
就会变化,那么就需要重新进行依赖预构建。
找到项目中需要进行预构建的文件
依赖预构建比较关键的一步,需要预构建的文件,如 react
、react-dom
、optimization.include
指定的需要强制预构建的文件等。
vite
会通过 esbuild
以入口文件(一般为 index.html
)为起点做扫描,找到项目依赖的第三方库。
具体的扫描过程为:
从 index.html
中找到整个项目的入口 js
文件;
使用 esbuild
提供的 build api
,做打包;
使用 esbuild
打包时,提供 onResolved hook
,在解析依赖的 url
时,将三方依赖收集起来;
使用 esbuild 做 build 时,不提供 outdir 配置项,不会输出文件
通过 esbuild
的扫描,我们就可以找到整个项目所依赖的三方库,然后就可以进行预构建了。
对第三步找到需要预构建的文件,开始预构建
具体的构建过程如下:
根据依赖文件的 url
读取文件内容;
分析文件内容,获取 import
和 export
;
使用 esbuild
提供的 build api
,做打包, outdir
为 node_modules/.vite/.dep
;
使用 esbuild
打包时,提供 onLoad hook
,根据第二步得到的 import
和 export
,判断模块是 CJS
还是 ESM
;
如果是 CJS
模块,需要对文件内容做格式化,变为 export default require('xxxx')
;
如果是 ESM
模块,则不需要做太复杂的格式化处理;
预构建的内容输出到 node_modules/.vite/.dep
目录下;
问题七: vite 在预构建过程中是如何获取到依赖的三方模块的
vite
在预构建的时候,巧妙的利用了 esbuild
的 build
能力,以 index.html
中的入口文件为 entry
去打包。
在 esbuild
做 build
时,vite
提供了 onResolve hook
,自定义依赖的解析过程,将三方依赖搜集起来,然后针对三方依赖做预构建。
问题八: 什么是二次预构建 ?
二次预构建,本地服务运行的时候,发现有新的第三方依赖没有预构建,此时要重新进行预构建,然后通知客户端去重新刷新页面。
出现二次预构建的情况:
plugin
在运行过程中,动态给源码注入了新的第三方依赖;
动态依赖在代码运行时,才可以确定最终的 url
;
二次预构建的过程:
二次预构建,会影响首屏响应速度和懒加载速度。
问题九: 如何优化二次预构建 ?
使用 vite-plugin-package-confi
、vite-plugin-optimize-persist
这两个插件
vite-plugin-package-config
提供了 config hook
, 使得 vite
可以在初始化 config
时从 package.json
读取 vite
配置项合并到 config
中。
vite-plugin-optimize-persist
提供了 configureServer hook
,添加自定义 middleware
, 可以在发现有新的未进行预构建的第三方依赖时,将其写到 package.json
中。
通过这样的操作,当下一次开发服务器启动以后,不会发生二次预构建了。
注意, vite 的 2.9 版本不适合。
vite3.0
修复了首屏时的二次预构建重新 reload
。
首屏期间,如果发现有未预构建的第三方依赖,还是会触发二次预构建。
3.0
版本对第三方依赖的请求和业务代码的请求有不同的处理逻辑。
当浏览器请求业务代码时,dev server
只要完成源代码转换并收集到依赖模块的 url
,就会给浏览器发送 response
。
而第三方依赖请求则不同,dev server
会等首屏期间涉及的所有模块的依赖关系全部解析完毕以后,才会给浏览器发送 response
。这就导致了,如果发现有未预构建的第三方依赖,第三方依赖的请求会一直被阻塞,直到二次预构建完成为止。
有了这种操作,当然就不需要 reload
操作了。
问题十: esbuild 是怎么格式化 ESM 模块的?
esbuild
会简单的对 ESM
模块做处理
// example.1.js
export const func1() {...}
export default func() { ...}
// 格式化为
const func1() {...}
const example_1_default() {...}
export {
func1,
example_1_default
}
复制代码
import func, { func1} from './example.1';
// 格式化为
import { func1, example_1_default } from './example.1;
复制代码
问题十一: esbuild 是怎么将 CJS 模块格式化为 ESM 模块的 ?
首先是 CJS
模块。
一个 CJS
模块,常见的格式为:
const func = () => { console.log('func') };
// 格式一
exports = func;
// 格式二
module.exports = func;
复制代码
将 CJS
模块,转化为 ESM
模块的方式为给 CJS
模块代码变为一个函数,执行这个函数并将返回的结果通过 exports
导出,如下:
functon require() {
let mod = { exports: {} };
(function(exports, mod) {
const func = () => { console.log('func') };
exports = func;
})(mod.exports, mod);
return mod;
}
export default require();
复制代码
这样一个 CJS
模块就被格式化为 ESM
模块。
问题十二: vite 的中间件原理
vite
的中间件其实是一个函数,执行时会返回一个入参为 req
、res
、next
的 callback
。
vite
使用中间件的姿势: server.middlewares.use(someMiddleware(server))
;
server.middlewares
其实一个 app
实例(对照 express
)
其中, someMiddleware
会返回一个 callback
。
server
内部会维护一个 callback
的 list
,当 client
发起请求时, callbackList
中收集的 callback
会按序触发。callback
在执行过程中,会根据 req
的 url
信息,做对应的逻辑判断操作。如果 url
不匹配,该 callback
会直接 return
结束掉。
middlewares
其实是一系列函数。
在实际应用中,会通过 http.createServer(callback)
创建一个 server
实例,然后执行 server.listen(port)
。当 client
访问某个 url
时,触发 callback
的执行,然后根据 req
找到匹配的 url
的中间件,返回最终需要的结果。
问题十三: 如何给 devServer 添加自定义 middleware
我们可以通过给一个自定义插件定义 configureServer hook
,来给 devServer
添加自定义 middleware
。
在 vite config
解析完成以后,vite
会遍历 plugins
列表,依次执行 plugin
的 configureServer hook
。执行 configureServer
时,入参是 server
。通过 server.middlewares.use((req, res, next) => { ... })
, 即可给 devServer
添加自定义 middleware
。
configureServer hook
的格式:
{
name: 'xxx',
configureServer: (server) => {
return () => {
server.middlewares.use((req, res, next) => {
...
})
}
}
}
复制代码
自定义 middleware
会在 http middleware
之前执行,这样我们就可以使用自定义内容替换掉 index.html
。
问题十四: 用户发起请求时,如果预构建还没有完成,vite 是怎么处理的?
用户发起请求时,如果预构建还没有完成,那么请求会被阻塞,知道预构建完成为止。(这个是现象)。
问题十五: import.meta.glob
vite
支持使用特殊的 import.meta.glob
函数从文件系统中导入多个模块。
具体的用法如下:
const modules = import.meta.glob('./dir/*.js');
// 转义为:
const modules = {
'./dir/foo.js': () => import('./dir/foo.js'),
'./dir/bar.js': () => import('./dir/bar.js')
}
复制代码
import.meta.glob
导入模块时,默认是懒加载,即动态依赖。
如果想将 import.meta.glob
导入的模块作为静态依赖,可以这样配置:
const modules = import.meta.glob('./dir/*.js', { eager: true })
复制代码
问题十六: 项目中的业务代码是否支持 commonjs 写法 ?
纯业务代码,一般建议采用 ESM
写法。如果引入的三方组件或者三方库采用了 CJS
写法,vite
在预构建的时候就会将 CJS
模块转化为 ESM
模块。
如果非要在业务代码中采用 CJS
模块,那么我们可以提供一个 vite
插件,定义 load hook
,在 hook
内部识别是 CJS
模块还是 ESM
模块。如果是 CJS
模块,利用 esbuild
的 transfrom
功能,将 CJS
模块转化为 ESM
模块。
问题十七: vite 中 index.html、 js、 css 文件是怎么处理的 ?
先去请求 index.html
文件。html
文件的处理:添加 @vite/client
、/@react-refresh
, 其中 @vite/client
主要用于建立 ws
连接,@react-refresh
用于热更新。
请求 @vite/client
、@react-refresh
、/src/main.tsx
。其中 main.tsx
是应用指定的入口文件,作为 js
文件。
main.tsx
需要经过转换,才能返回给客户端。整个转换处理过程经历 resolve
、load
、transform
三个过程,即解析、加载、转换。
解析,即解析相对路径,获取 main.tsx
的绝对路径;
加载,读取 main.tsx
对应的源代码字符串;
转换,先通过 loader
将 tsx
写法转化为 react.createElement
写法;然后再分析文件中的依赖,第三方依赖 import x froom 'xxx'
中的相对路径和输出结果转化为预构建生成的依赖的路径和输出结果,并且还要添加热更新相关逻辑代码;
最后将转换以后的内容返回给客户端。
vite
在做转化的时候有个比较巧妙的处理。 main.tsx
依赖的静态文件,并不是在下次请求的时候才转换处理。在对 main.tsx
做转换处理后,server
端会继续对 main.tsx
依赖的文件继续做转换处理,然后先缓存起来。等到客户端请求到达 server
端时,直接使用缓存。即 main.tsx
开始转换以后,server
端会一直工作,把所有的静态依赖全部转换完毕。
css
文件的处理过程和 webpack
也相同,即使用对应的 loader
先将 saas
、less
写法转化为 css
写法,然后将样式文件转换成一段 js
代码。这一段 js
代码会执行 @vite/client
提供的 updateStyle
方法,通过动态添加 style
标签的方式添加到 html
页面中。
问题十八: 依赖后面的 v=xxx、t=xxx 是什么意思?
使用 vite
时我们会发现,三方依赖,请求路径会添加一个 v=xxxx
的请求参数;内部依赖,请求路径会添加一个 t=xxx
的请求参数。
其中,v
是版本信息, t
是时间戳信息。
如果不加请求参数,同样的请求 url
, 浏览器只会请求一次;请求参数不同,浏览器会就会任务请求 url
不相同,这样就会再次请求。
问题十九: pre-transform
vite
在做转化的时候有个比较巧妙的处理。 main.tsx
依赖的静态文件,并不是在下次请求的时候才转换处理。在对 main.tsx
做转换处理后,server
端会继续对 main.tsx
依赖的文件继续做转换处理,然后先缓存起来。等到客户端请求到达 server
端时,直接使用缓存。即 main.tsx
开始转换以后,server
端会一直工作,把所有的静态依赖全部转换完毕。
一个文件的依赖分为静态依赖和动态依赖。
静态依赖的形式为: import xx from 'xxxx'
。
动态依赖的形式为: import('xxx').then(res => {...})
。
只有静态依赖才会进行 pre-transform
,动态依赖不会 pre-transfrom
。 动态依赖只有真正请求的时候才会 transfrom
。
其实很好理解,如果我的动态依赖是放在 if
块中,那么如果这一段代码一直没有触发, 那么就不需要请求,也不需要 transform
。
问题二十: import.meta
import.meta
是一个给 javascript
模块暴露特定上下文的元数据属性的对象,它包含了这个模块的信息,如果这个模块的的 url
。
import.meta
对象是由 ECMAScript
实现的,它带有一个 null
的原型对象。这个对象可以扩展,并且它的属性都是可写,可配置和可枚举的。
即每个 ESM
模块都有一个 import.meta
, 通过 import.meta
可以访问这个模块的元数据信息。
问题二十一: 热更新
HMR
工作分为两个部分: client
和 server
端。
client
vite
在对 html
做 transform
操作时,会给 html
添加一个 @vite/client
的请求。
当执行 @vite/client
代码时,会建立一个 ws
连接。
更新策略: 全量更新
、局部更新
局部更新
-> 通知 react 的 fiberNode 重新更新;
全量更新
-> window.location.reload
css 更新
: 移除原来的 style 标签,重新添加新的 style 标签
server
需要一个 wsServer
和 watcher
,其中 wsServer
用于推送消息, watcher
用于监听文件变化。
不同的文件,处理策略也不相同:
index.html
, 全量更新,window.location.reload()
;main.tsx
,全量更新, window.location.reload()
;vite
在处理每个组件的时候,会给每个组件添加如下逻辑代码:
createHotContext
方法,创建一个 hot
对象;RefreshRuntime.register
逻辑,注册需要热更新的组件;import.meta.hot.accept()
逻辑,给每个 hot 对象添加依赖(如果依赖发生变化,就要热更新);RefreshRuntime.performReactRefresh()
逻辑,开始进行热更新;vite
热更新的过程分为两个阶段:
应用 load
阶段
应用加载阶段,涉及的过程如下:
建立 ws
连接,注册 onmessage
事件;(这一段逻辑由 @vite/client
提供)
获取每个组件对应的 js
文件,并执行。
在执行 js
的过程中,会先执行 createHotContext
方法,创建一个 hot
对象。创建好的 hot
对象会添加到模块的 import.meta
属性上。每个 ESM
模块都有自己的 import.meta
属性,都有自己的 hot
对象。通过 hot
对象提供的 accept
方法,可以收集依赖。
然后执行 @react/refresh
提供的 register
方法。 register
, 即注册的意思, @react/refresh
会提供一个全局的 map
存储每一个模块的 id
和对应的 export
。应用初次加载的时候,map
中会收集加载过程中的各个模块。当某个模块发生热更新时,会重新加载对应的 js
文件,重新执行 register
方法。这个时候,由于 map
中已经存在对应的 id
。基于这个,我们就可以判断该模块是热更新的模块,需要重新渲染。
接着,执行 import.meta.hot.accept
方法,收集依赖。 accept
一般接受两个参数,第一个参数 deps
是一个数组,第二个参数是一个 callback
。当 deps
中的文件发生变化时,当前模块需要热更新,需要重新获取 js
文件,然后重新渲染。 vite
处理以后的 react
组件使用 accept
方法时,没有入参,意味着 deps
是自己。执行 accept
方法,对创建一个 mod
对象,收集到 map
中。
最后执行 RefreshRuntime.performReactRefresh()
方法。由于是应用加载,不需要重新渲染,所以 performReactRefresh
什么也没有做。
文件修改阶段
当 server
端某个文件发生变化时,触发 watcher
监听,此时 server
端的操作:
遍历 module graph
,找到变化文件以及对应的边界(path 为边界文件的路径、acceptedPath 为发生变化的文件的路径);
根据发生变化的文件,确定 clienet
是局部热更新还是全局加载。
如果边界文件是 main.tsx
,通知 client
通过 window.location.reload
的方式更新;
如果边界文件是某个组件,通知 client
进行热更新。
sever
端会通过 wsServer
向 client
推送消息。
client
收到局部更新的消息以后,会根据 path
从 map
中找对应的 mod
。如果 mod
的 deps
匹配 acceptedPath
,那么就会触发当前 mod
的热更新。
模块热更新时,会先 fetch
最新的 js
文件,然后执行,重新 register
,最后执行 performReactRefresh
方法。 performReactRefresh
方法就是通过调用 react
提供的 scheduleRefresh
方法来触发 react
更新。在协调过程中,react
会将发生更新的模块对应的 fiberNode
的组件方法替换成最新的组件方法,然后更新页面。
简单来说,就是应用启动阶段,每个组件都会构建一个 hot
对象和 mod
对象,mod
对象会收集依赖。server
端文件发生变化以后,会确定变化文件对应的边界文件,然后通知边界文件去做热更新。边界文件的模块收到消息以后,重新去加载 js
文件拿到最新的组件函数方法,然后触发 react
更新。在 react
更新过程中,模块对应的 fibeNode
会使用返回的新的组件函数方法。
问题二十二: qiankun 下怎么对接 vite 项目
qiankun
下对接 vite
项目的两个难点:
vite
项目需要把 qiankun
需要的生命周期方法暴露到全局变量下;vite
打包出来的代码是 ESM
格式,无法在 qiankun
沙箱下执行;解决方案:
vite
项目单独处理 - 采用 web component
的形式处理。
具体方式:
qiankun
子应用和 vite
子应用;qiankun
子应用不做处理; vite
子应用采用 web component
形式渲染;这种模式的问题:两类子应用切换的时候要做好子应用 effect
的处理和重新激活时状态恢复。
vite
项目不采用 ESM
格式打包。
但是如果不采用 ESM
格式,打包出来的代码只有一个,懒加载就会失效。
github.com/tengmaoqing… 提供了解决方案,可以看看。
开发环境使用 vite
, 生产环境直接使用 webpack
。
问题二十三: 环境变量
在 vite
中,环境变量会通过 import.meta.env
的形式暴露给客户端源代码。
即我们可以在自己的代码中,通过 import.meta.env
来获取环境变量。
环境变量通常从 process.env
中获取。
vite
是默认不加载 .env
文件的,我们可以通过 vite
提供的 loadEnv
函数来加载指定的 env
文件。
问题二十四: 既然浏览器已经支持 ESM 模块,为什么生产环境依旧需要打包
尽管原生 ESM
现在得到了广泛支持,但由于嵌套导入会导致额外的网络往返,在生产环境中发布未打包的 ESM
仍然效率低下(即使使用 HTTP/2)。为了在生产环境中获得最佳的加载性能,最好还是将代码进行 tree-shaking
、lazy load
和 chunk
分割(以获得更好的缓存)。
问题二十五: 为什么 vite 会快
和 webpack
对比,为什么 vite
的冷启动、热启动、热更新都会快?
使用 webpack
时,从 yarn start
命令启动,到最后页面展示,需要经历的过程:
entry
配置项为起点,做一个全量的打包,并生成一个入口文件 index.html
文件;node
服务;index.html
,然后去加载已经打包好的 js
、css
文件;在整个工作过程中,最重要的就是第一步中的全量打包,中间涉及到构建 module graph
(涉及到大量度文件操作、文件内容解析、文件内容转换)、chunk
构建,这个需要消耗大量的时间。尽管在二次启动、热更新过程中,在构建 module graph
中可以充分利用缓存,但随着项目的规模越来越大,整个开发体验也越来越差。
使用 vite
时, 从 vite
命令启动,到最后的页面展示,需要经历的过程:
ESM
模块;node
服务;index.html
;ESM
模块, 逐步去加载入口文件以及入口文件的依赖模块(加载过程中,会对文件做使用 loader
处理);在第四步中,vite
需要逐步去加载入口文件以及入口文件的依赖模块,但在实际应用中,这个过程中涉及的模块的数量级并不大,需要的时间也较短。而且在分析模块的依赖关系时, vite
采用的是 esbuild
,比 webpack
采用 js
要快一些。
综上,开发模式下 vite
比 webpack
快的原因:
vite
不需要做全量的打包,这是比 webpack
要快的最主要的原因;vite
在解析模块依赖关系时,利用了 esbuild
,更快;问题二十六: 常见的打包工具对比
目前前端比较常见的打包工具: webpack
、parcel
、vite
、esbuild
、rollup
等
parcel
:
js/jsx/tsx
、css
、html
、vue
、图片等文件类型,支持 code splitting
、tree shaking
、压缩
、devServer
、 hmr
、hash
等;js
、css
的转译上使用了 Rust
,效率提升;rollup
:
rollup
推崇 ESM
模块标准开发,这个特点借助了浏览器对 ESM
的支持;webpack
来干净的很多,是作为组件库开发的优选;生态丰富;webpack
一样,分离模块依赖关系借助 acorn
,速度较慢;浏览器兼容性问题;vite
:
ESM
的支持,采用 nobundle
的方式进行构建,能提供极致的开发体验;生产模式下借用 rollup
就行构建;webpack
很快;webpack
;也有一定的上手成本;本地开发模式启动以后,首屏、懒加载响应速度对比 webpack
会慢;webpack
:
ESM
规范的代码;开发组件库时,最后的打包结果汇中冗余代码较多;esbuild
:
Go
语言开发,可以多线程打包,代码直接编译成机器码(不用先解析为字节码),可充分利用多核 cpu 优势;ast
,防止暴露过多的 api
而影响性能;不支持自定义代码拆分;产物无法降级到 es5
之下;到这里,关于构建工具的总结就先暂时结束了。构建工具可以讲的东西太多了,一篇文章实在是无法面面俱到,只能去挑选一些核心部分去写。如果小伙伴们还有其他问题想了解,欢迎评论区留言,小编会根据留言再去丰富内容。