场景一:使用ESM形式和Typescript编写webpack配置文件
参照官网的配置Typescript,有三种解决方案,目前采用的是使用tsconfig.json中重载ts-node的配置,存在一种问题:在package.json中声明了type: module
,运行webpack命令会报如下错误:
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts"
这是因为在决定文件应该如何编译和执行时,ts-node 会匹配 node 和 tsc 的行为。这意味着 TypeScript 文件会根据你的 tsconfig.json “module” 选项进行转换,并根据 node 的 package.json “type” 字段的规则进行执行。在某些情况下,需要为某些文件覆盖默认的编译和执行方式。ts-node在编译webpack.config.ts的时候要求以CommonJS的形式执行。如果你的 package.json 配置了 “type”: “module”,而 tsconfig.json 配置了 “module”: “esnext”,则默认情况下该配置文件是原生 ECMAScript。
你可以移除type: 'module'
的声明解决这个问题, 但当你不想为了一个配置文件而去除这个声明,可以设置ts-node的moduleTypes选项。配置如下:
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"module": "ESNext",
"target": "ES2018",
"moduleResolution": "node",
},
"ts-node": {
"compilerOptions": {
"module": "CommonJS",
},
"transpileOnly": true,
"moduleTypes": {
"webpack.config.ts": "cjs",
}
}
}
场景二:使用babel-loader、swc-loader编译react项目
babel-loader
{
"presets": [
[
"@babel/preset-react",
{
"runtime": "automatic"
}
],
"@babel/preset-typescript"
]
}
swc-loader
{
"jsc": {
"parser": {
"syntax": "typescript",
"jsx": true
},
"transform": {
"react": {
"runtime": "[automatic](url)"
}
}
}
}
以一个最简单的一行dom的react项目,在build模式下的耗时,swc用时2676ms,babel用时3408ms,相比较而言,swc比babel快了大概27%左右,如果用于真实项目,差异应该会更为明显,具体的平台兼容配置需要配合自身项目所需的polyfill来进行。
场景三:如何正确配置hash
webpack主要分为三种hash: fullhash\chunkhash\contenthash
- fullhash一般不会去使用,因为每次打包的文件都将是新的,不会被缓存。
- chunkhash主要是针对chunk生成一个文件,webpack配置了多少个entry就会生成多少个js文件,每一个entry都是一个chunk,当你使用了懒加载的方式,例如
React.lazy(() => {})
,引入的组件也将是一个新的chunk(可以这么理解,懒加载的东西是你需要的时候才会去拿的,所以得单独形成一个文件)。 - contenthash是基于内容变化的哈希,最显而易见的例子是:React中经常将js和css单独分开写,js中引入css,但是打包会打成一起,我们通常会使用一些webpack插件来将css单独提取出来,css文件也会有对应的哈希值,以便能够充分运用浏览器的缓存,如果你只改了js部分的代码,提取出来的css哈希值也会随之改变,即使没用对css文件进行修改,这时候就需要使用contenthash了。
除了这些之外,在实践中可以发现,在一个entry中通过懒加载分离了部分chunk会有entry的哈希值失效的情况,例如在App中引入了ComponentA组件,会形成两个js文件:ComponentA.c46eaeaa5987b833.js
和 app.a8246d7638ecc817.js
,改变了ComponentA,会生成一个新的哈希值,而app引入的时候,由于引入的文件名发生了变化,导致app文件的哈希值也需要重新生成,这就造成了对entry的chunk缓存失效。
这种情况就需要设置runtime chunk了,会单独形成一个runtime-[entry]-[hash].js文件,用来记录entry运行时文件之间的关系,每次变化的时候,就只需要更新这个runtime文件,降低了缓存失效出现的情况。
还有也可以对资源进行hash化,设置assetModuleFilename
为contenthash,可以让你的图片在移动或是重命名的时候,也不会使得哈希值失效。
附上webpack配置:
{
output: {
assetModuleFilename: 'images/[contenthash][ext][query]',
},
plugins: [
new MiniCssExtractPlugin({
filename: 'css/[name].[contenthash].css',
chunkFilename: 'css/[id].[contenthash].css',
})
],
optimization: {
runtimeChunk: true,
}
}
场景四:应该如何选择合适的sourcemap风格
webpack可以通过设置devtool来实现源码和经过处理的代码的映射,总的来说,一般由eval
、cheap-[module]
、source-map
组成,在webpack5中,配置devtool的风格将更为严格,需要符合以下正则表达式。
/^(inline-|hidden-|eval-)?(nosources-)?(cheap-(module-)?)?source-map$/
- eval关键字下速度比较快,模式编译速度非常快,通过eval函数直接包裹模块,但它只能映射到webpack-loader转换后的代码,而不能直接映射到源码。
- cheap关键字会抛弃列维度的映射,只能够到行,同样不能直接映射到源码,需要同时跟着设置
module
关键字,将各种loader(例如babel-loader)产生的sourceMap一同关联上,追溯到源码。 - source-map关键字会产生一个.map文件来记录映射的信息。
- inline关键字会将source-map转换为DataUrl后添加到bundle中,可能在发布单文件的时候会用到。
- nosources在生成source-map的时候,.map文件中会包含源代码,设置了这个,map文件中就不会有sourcesContent这个字段了。
- hidden将隐藏产物文件中的sourceMappingURL信息。
以最简单的react代码,定位console.log
的错误信息:
function Home() {
console.loge(111)
return (
<div>
<h2>Home</h2>
</div>
)
}
export default Home
适合dev模式的应用
因为dev模式下不需要将map文件独立出来,所以建议用用eval来包裹模块来提升编译速度。
- eval-cheap-module-source-map
cheap-module定位到源文件的行级别,个人认为就已经足够了,在编译的速度和sourceMap的定位的准确性来说,做了一个良好的权衡。
- eval-source-map
如果对于定位精度要求高的,不在意速度的,想要具体到某一行某一列的错误,可以采用eval-source-map的形式。
适合production模式下的应用
如果在dist产物中存在,浏览器都会自动根据//# sourceMappingURL=
去加载对应的source-map文件。
- hidden-source-map
可以出去产物中的sourceMappingURL地址,让浏览器识别不到要加载的map文件在哪,需要用户手动去添加对应的source-map文件。一般都是文件名加上.map,所以防君子不防小人(?头)。
- nosources-source-map
可以让浏览器定位不到源代码的位置,但是sourcemap会提示具体报错的代码在什么文件以及第几行第几列,这样拥有源代码的人就可以知道在哪,其他人也无法知晓。具体可以配合类似Sentry这种工具,上传map文件到监控平台,去查看对应的报错信息。
以上这些也可以结合SourceMapDevToolPlugin
实现粒度更细的控制。
场景五:资源加载
webpack5通过内置的通过 loader 或内置的Asset Modules引入任何其他类型的文件,在升级的时候需要注意对应的平替关系。
资源模块类型 | 描述 | 替代的 loader |
---|---|---|
asset/resource | 发送一个单独的文件并导出URL | file-loader |
asset/inline | 导出一个资源的 data URI | url-loader |
asset/source | 导出资源的源代码 | raw-loader |
asset | 在导出一个 data URI 和发送一个单独的文件之间自动选择 | url-loader,并配置资源体积限制 |
‘javascript/auto’ | 将 asset 模块的类型设置为 ‘javascript/auto’ 可以解决和旧的loader一起使用的asset重复的问题 | 旧的 assets loader,如 file-loader/url-loader/raw-loader |
{
module: {
rules: [
{
test: /.(png|jpe?g|gif|svg)$/i,
type: 'asset/resource',
},
{
test: /.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
}
]
}
}
}
在实践中发现项目存在以下两点问题:
同一个域名下同时请求的资源数非常多
浏览器请求最多6个。这是由于浏览器对同一域名下的并发请求数量做了限制,这个限制的原因是为了避免过多的请求导致网络拥塞和服务器过载。同时,这个限制也可以帮助浏览器更好地资源,提高页面加载速度和性能。
这种情况一般有以下解决方案:
- 将资源分散到不同的域名下,以增加并发请求数量的限制,例如不同的CDN
- 将资源合并为一个文件,以减少请求数量,俗称雪碧图
- 采用http2的多路复用
- 合理的将图片资源内联
将chrome的高速3G模式打开,让资源加载时间更明显一点,发现有两张大于8kb的图片不是从内存缓存中去的,也就是说它们额外发起了一次请求,如果你页面中还有几个业务相关的接口请求,就会受到浏览器请求个数的限制,这时候我们就可以通过配置资源体积限制来有条件将png格式的内容转化成内联的base64格式。
{
module: {
rules: [
{
test: /.(png|jpe?g|gif)$/i,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 16 * 1024, // 16kb
},
},
}
],
},
}
具体这个阈值需要大家根据自己的业务情况去定,可以根据页面bundle.js内联了图片资源之后的加载时间情况,和请求的资源排队相加耗时哪个会更多一些,不要过度优化,有条件的可以一步到位,上cdn或者是http2。
未使用的图片打包没有过滤
项目中实际只使用了一张图片,assets目录下的所有image都被打包了,经常会有人这么去加载图片资源:
const img = require(`../../assets/${item.image}`)
在require一个资源的时候,require是同步的,需要运行时才能决定这究竟需要哪张图片,所以打包的时候会把assets
目录下的资源全部打包进去,即使某些资源是未使用的状态。
所以需要将资源具体化,先把运行时可能要用到的图片都import进来,再根据item.image的值使用对应的图片。
或者把require的路径写死,不允许有字符串模版变量等。
如果前端都不知道要加载哪张图片,就应该用http链接的形式了,请求静态资源服务器。
场景六:集成speed-measure-webpack-plugin
由于该插件维护频率较低,但使用面特别广,webpack5在集成速度测量插件的时候,会出现各种奇怪的问题。
smp.wrap包裹的ts类型错误
因为speed-measure-webpack-plugin所依赖的webpack版本是4,webpack4的Configuration类型和webpack5不兼容,所以使用pnpm覆写speed-measure类型定义文件的@types/webpack版本,通过用”>”来从依赖的选择器分离出package
的选择器,可以通过依赖名称前缀一个$
来直接引用项目里@types/webpack的版本。
{
"pnpm": {
"overrides": {
"@types/speed-measure-webpack-plugin>@types/webpack": "$@types/webpack"
}
}
}
You forgot to add 'mini-css-extract-plugin' plugin
在配合mini-css插件的时候出现报错,解决办法是降级到1.3.6版本。但我们肯定是希望使用最新版的插件了,
因为在speed-measure-webpack-plugin插件列表中获取了两次,可以在使用wrap包装webpack配置之前提取插件定义,然后在包装后将其放回。
const cssPluginIndex = mergedConfig.plugins!.findIndex(
e => e.constructor.name === 'MiniCssExtractPlugin',
)
const cssPlugin = mergedConfig.plugins![cssPluginIndex]
const configToExport = smp.wrap(mergedConfig)
configToExport.plugins![cssPluginIndex] = cssPlugin
你也可以使用time-analytics-webpack-plugin,这是该插件的替代品,但看issues中貌似时间测量不太准确,而且尚未修复。
场景七:如何做代码分割(code splitting)
webpack5自带的部分默认配置基本上就是最佳实践了,做好代码分割主要是看自身项目的情况,而且对于单页面和多页面,http1.1和http2.0也有不同的区别,通常都会有以下几个通用的配置,coding splitting的最终目的是为了减少重复代码而划分成一个个公共的chunk文件,充分利用浏览器的缓存策略,提高缓存的命中率。
首先得了解下splitting的默认配置都是怎么样的
module.exports = {
//...
optimization: {
splitChunks: {
chunks: 'async', // 提取chunks的方式,initial 则会从非 async 里面提取,all则所有的都会提
minSize: 20000, // 生成 chunk 的最小体积
minRemainingSize: 0, // 确保拆分后剩余的最小 chunk 体积超过限制来避免大小为零的模块,通常不需要指定
minChunks: 1, // 拆分前必须共享模块的最小 chunks 数,举例:设置为2的时候,需要你有两个chunk都用到的模块,才会被拆分
maxAsyncRequests: 30, // 按需加载时的最大并行请求数
maxInitialRequests: 30, // 你的入口文件chunk最大并行的请求数
enforceSizeThreshold: 50000, // 强制执行拆分的体积阈值,大于50000字节的被强制拆分成一个chunk
cacheGroups: { // 相当于一个继承的关系,局部配置
defaultVendors: {
test: /[\/]node_modules[\/]/,
priority: -10,
reuseExistingChunk: true, // 如果当前 chunk 包含已从主 bundle 中拆分出的模块,则它将被重用,而不是生成新的模块
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
},
};
设置异步和同步chunk之间共享
webpack默认只对异步模块划分chunk,对于initial的chunk来说,即使你包再大,也不会进行拆包。举个例子体会下:如果你设置了一个entry入口,包含了react,react-dom等依赖,webpack会将这些依赖和你的业务代码全部打进一个文件中。所以我们需要设置splitChunks的chunks: 'all'
,让同步chunk也享受webpack的拆包策略。因为webpack的默认生成chunks的最小体积(minSize)为20000字节且共享模块最小chunk数(minChunks)为1,配合上chunks: ‘all’以及defaultVendors的配置,这才拆分出来。你可以试下,如果将minSize指定成一个很大的数,676这个chunk就会被打进入口app文件的chunk中,注意下这里的字节数不是生成的chunk文件的大小乘以1024,文件系统中显示的已经是打包后的了。
如果要为node_modules下生成的包取个别名,可以为cacheGroups的defaultVendors设置name属性,例如vendor,就可以将下面的676的随机值替换成vendor了。
合理划分chunks数量和chunks大小
在划分chunks的时候,根据个人偏好和项目情况,都可能有着不同的配置。在这里只是给出一个参考:
- 如果是http1.1的情况,浏览器会限制同一时刻并发的请求数目,对于chrome来说同一时刻只能存在6个请求,我们可以设置maxAsyncRequests和maxInitialRequests来进行控制,在按需加载异步chunks的时候,最多允许再存在五个异步请求,maxAsyncRequests可以设置为5,针对一个entry来说,初始化chunk的数量maxInitialRequests为下面打包后的js文件数量。
{
splitChunks: {
maxAsyncRequests: 5,
maxInitialRequests: 3
}
}
- 如果是http2的情况,浏览器可以利用多路复用的机制,尽可能多的划分请求,所以这一块的配置用webpack5默认的就足够了,通常最多30个异步请求。当然你也可以把node_modules里面的依赖包分别形成一个chunk,这样可以充分的利用缓存,只要你某个依赖的版本不变,打包出来的chunk也不会变。这里会遍历node_modules下的每一个模块,为模块生成一个chunk。但要注意的是:你的minSize,也就是最小生成chunk的体积,需要设置合理,你设置为0的话,会将每个依赖包都单独打包,对于一些小的依赖包,可以混合在一个chunk中一起下发。我在这将react和react-dom单独打成了一个js文件,其他node_modules的依赖都混在vendor下。
{
cacheGroups: {
vendors: {
test: /[\/]node_modules[\/]/,
priority: -10,
minSize: 0,
name(module: Module, _chunks: Chunk[], cacheGroupKey: string) {
const absolutePath = module.identifier().split('!').pop()!
const parts = absolutePath.split(path.sep)
const nodeModulesIndex = parts.lastIndexOf('node_modules')
const packageName = parts[nodeModulesIndex + 1]
const deps = ['react', 'react-dom']
if (deps.includes(packageName))
return `${cacheGroupKey}-${deps.join('~')}`
return `${cacheGroupKey}`
},
reuseExistingChunk: true,
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true, // 如果该chunk包含的modules都已经另一个被分割的chunk中存在,那么直接引用已存在的chunk,不会再重新产生一个
},
},
},
}
- 当然,如果有CDN的话,可以尝试将体积大的第三方包给external出来,通过在html模板中添加script标签引入React和ReactDom。因为CDN和你的站点不是同域的,浏览器的并发请求限制只是限制同域下的请求个数,所以也可以用来加快首屏的渲染速度。
{
externals: {
'react': 'React',
'react-dom': 'ReactDOM',
},
}
场景八:使用持久化缓存
webpack5中最为重大的更新就是增加了文件系统的缓存,大幅度提升了开发和构建时的速度,我们可以通过以下配置进行开启:
{
cache: {
type: 'filesystem',
},
}
需要注意的是:filesystem对 node_modules
来说,哈希值和时间戳的比较会被跳过,出于性能考虑,只使用包名和版本来进行缓存的更新,这也就意味着webpack认为第三方包只能通过对应的包管理器去修改,我们不应该去直接修改node_modules
。如果你通过npm link\yarn link
等调试本地包的时候,一定不要去设置resolve.symlinks: false
,否则缓存就会出问题。
持久化缓存可以大幅提升构建的速度,又为什么不让它作为默认的选项呢?
因为需要让缓存失效的情况有太多了,webpack无法做到开箱即用。例如:
- 升级npm加载器和插件的时候
- 更改配置中正在读取的文件
- 当你有一个自定义构建脚本并更改它的时候
这些都不应该使用缓存。推荐设置buildDependencies的config为你的项目配置文件,如webpack.config.ts,__filename代表了当前的文件。用以配置需要让缓存失效来保障项目构建时的安全性,当你使用fs.readFile
之类的文件读写API的时候,被读取的文件是不会让缓存失效的,如果有必要,需要手动添加到buildDependencies中。
{
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename],
},
},
}