背景:这世纪20年代,互联网前端内卷严重,业内再也不需要cli工程师(指的是仅仅会用vue-cli、create-react-app,不会配置webpack)。
这篇文章是整体的综述,可以快速的过一下webpack相关配置和需要注意点,面试之前最是好用。
不过,文中涉及的点比较多却比较概括,不够完整和丰富,另外,也基本上是v4时代,东西或有落伍。
所以今年(22年),在文档库./目录下,计划对相关要点做以丰富和补充。
工程化:问题抛出?
随便抛出几个面试常见的问题
- 前端为什么要构建和打包?开放式问题,文章最后给出要点
- moudle、chunk、bundle?
- loader、plugin?
- 怎么懒加载?
- 常见的性能优化?
- babel-runtime?babel-polyfill?
基本配置和配置思路
- 拆分配置和merge
这个其实应该是实践中第一步要做的。
拆分配置就是说,定义一个公共的配置文件,然后面向dev环境的面向生产环境的文件都引入下;
就比如:
webpack.common.js —— 定义公共的配置
webpack.dev.js —— 开发的配置中,合并了公共配置
webpack.prod.js —— 线上的配置中,合并了公共配置
但是最终,查分的东西需要合并的。合并的操作需要依赖smart from webpack-merge:
const { smart } = require('webpack-merge');
因为开发环境和生产环境定义的配置是不同的,比如开发环境需要定义dev-server,但是生产环境不用;再比如,开发环境直接引用图片url,生产环境可能要考虑base-64。
dev环境下本地服务dev-server
dev-server:webpack-dev-server
这里注意,开发的时候跑项目用的是webpack-dev-server。打包的时候需要用webpack。
处理es6
这个应该写在common配置里面。
用babal-loader这个loader。
用到babel的话,babel相关的配置写在.babelrc文件中。比如.babelrc, preset-env就包括常用的es6、es7….and so on:
// in config.module.rules
moudule:{
rules:[
{
test: /.js$/,
loader: ['babel-loader'],
include: srcPath,
exclude: /node_moudles/
},
]
}
// in babelrc
{
"presets": ["@babel/preset-env"],
"plugins": []
}
样式处理
这个应该写在common配置里面。
- basic. 处理css
- css-loader: 将模块话的css文件解析出来,比如我们的项目中可能这样写引入css:
import 'main.css';
(webpack是万物皆模块,本来是分不清css、js等文件的)
- style-loader:是将我们解析出的css,以’style’标签插入到页面中。
这里涉及的过程是要有顺序的(这里有个考点,需要的顺序和配置中书写的顺序相反),所以,最终的配置可能是:
{
test: /.css$/,
loader: ['style-loader', 'css-loader']
}
- advance. postcss兼容性处理
我们需要借助postcss-loader来搞点浏览器前缀之类的来处理下兼容性的问题。比如transform,可能需要变成-webkit-transform。
这个步骤应该先于css-loader,所以应该在后面loader增加一个postcss-loader:{
test: /.css$/,
loader: ['style-loader', 'css-loader', 'postcss-loader']
}
另外,postcss-loader需要一个配置来引入具体某一个插件, 新建一个postcss.config.js:
module.exports = {
plugins: [require('autoprefixer')] // autoprefixer增加前缀的一个插件
}
- more. 处理less、sass
less为例,这个过程参考上面的,就是先解析less语法,在解析css,最后插入为style标签:{
test: /.less$/,
loader: ['style-loader', 'css-loader', 'less-loader']
}
处理图片
这里的策略应该跟据开发和上线的需求作出区分。
dev开发的时候,直接引入图片的url就行,那么在webpack.dev.js中配置:
rules: [
{
test: / \.(png|jpg|jpeg|gif)$/,
use: 'file-loader'
}
]
但在生产打包的时候,需要的不是file-loader,而是url-loader,可能需要将小于5kb的图片用base的url返回,然后写在html中,这样减少了一次http请求图片。(url-loader:Loads files as base64 encoded URL, url-loader)
支持React和Vue
- 支持React只要在.babelrc中配置下
{
presets: ['@babel/presets-react']
}
- 支持Vue用vue-loader,在rules中
{
test: /.vue$/,
loader: ['vue-loader'],
include: srcPath
}
高级配置
多入口或多页应用
多入口,这是针对线上build的。至少要配置三件事:1. 配置多个入口(entry) 2. 将js输出成多个文件;3. 配置html对应多个;
下面例子是将index.html变成index.html、other.html两个入口:
// webpack.common.js
// 这两个是我们代码中的入口
{
entry: {
index: path.join(srcPath, 'index.js'),
other: path.join(srcPath, 'other.js')
}
}
// webpack.prod.js
// 输出文件
// [name].[contentHash:8].js name就是和entry的key相对应的
output: {
filename: '[name].[contentHash:8].js',
path:distPath,
}
// webpack.common.js
// 生成多个html文件
plugins: [
// 多入口,生成 index.html
new HtmlWebpackPlugin({
template: path.join(srcPath, 'index.html'),
filename: 'index.html',
chunks: ['index']
}),
// 多入口,生成 other.html
new HtmlWebpackPlugin({
template: path.join(srcPath, 'other.html'),
filename: 'other.html',
chunks: ['other']
}),
]
这里chunks这个概念非常重要,chunks就是当前的这个html文件中要script那些js,比如针对上面的例子,我们可选的chunks有: index.js和other.js。不错,都来自我们entry的配置。另外,如果这里不写chunks这个配置,那么默认就是所有的js文件。
抽离CSS文件
上面的基本配置’style-loader’, ‘css-loader’这种,其实最后做了这样的事情:把css也放在js文件中,然后由js生成style标签然后再插入到html中。但这样的话,style标签的生成依赖js的执行过程,另外,css也需要压缩,或者去除注释。
这样就是为啥要做所谓抽离css这一步。
那么具体的做法大致为:
(可以让dev(webpack.dev.js)的配置保持不变,将上文中提到的抽离的过程放在prod(webpack.prod.js)里面,这样需要将css处理过程从common(webpack.common.js)中拆分出来)
// 1. 借助插件MiniCssExtraPlugin提过的loader代替之前的style-loader
{
test: /.less$/,
loader: [MiniCssExtraPlugin.loader, 'css-loader', 'less-loader', 'postcss-loader']
}
// ... ...
// 2. 增加MiniCssExtraPlugin
// 在plugins里面
plugins: {
// 添加
new MiniCssExtraPlugin({
filename: 'css/main.[contentHash8].css' // 这就是打包后被输出的路径以及文件名
}),
}
压缩需要另外两个插件:TerserJSPlugin、OptimizeCSSAssertsPlugin
// 这个要在optimization中配置
optimization: {
minimizer: [
new TerserJSPlugin({}), new OptimizeCSSAssertsPlugin()
]
}
开启css module
css module是css-loader的功能,这样开启:
{
loader: "css-loader",
options: {
modules: true, // 开启css module
},
},
公共代码抽离
这个很重要!
公共代码包括,1. 程序在多个入口的分包中引用的共同文件;2. 第三方依赖库;
因为我们的文件通常是有hash值的,没有变动的文件(尤其第三方库更不会变动了)的hash是不变的。这样就能保证对于访问者来讲,每次需要更新的内容最小。不变的东西命中缓存不香吗?
下面是我们要实现代码抽离的第一步,在run build对应的打包配置下(webpack.prod.js),添加optimization.splitChunks:
optimization: {
// minimizer: .......... ,
splitChunks: {
chunks: 'all',
// 分组
cachaGroups: {
// 第三方
vender: {
name: 'vender',
priority: 1,
test: /node_moudules/,
minSize: 0,
minChunks: 1,
},
// 公共模块
common: {
name: 'common',
priority: 0,
minSize: 0,
minChunks: 2,
},
}
}
}
简单解释下:
上面的optimization.splitChunks.chunks的可选项有:
- initial: 入口chunks,对于异步导入文件不处理
- async: 异步chunks,对于非异步导入文件不处理
- all: 全部处理
对于第三方的引用,一般我们都是从node_moudles里面拉过来的代码,所以optimization.splitChunks.cachaGroups.vender / optimization.splitChunks.cachaGroups.common中(这其实是两个chunk): - 命中的规则就是test: /node_moudules/;
- priority: 1,表示优先级较高,比较重要;
- minSize: 0, 这个配置是说,被引用文件的大小低于这个值就不用抽取了,很明显,0就是,都抽取;有的包可能很小,但是没有限制的话,反而不好;
- minChunks: 1, 这个配置的意思是,若是引用出现至少一次,就需要抽取;这个配置对于第三方模块来讲确实是的,只要被引用一次就应该抽取出来,但是对于开发书写的代码来讲,这里应该配置2,表示某一个公共模块被引用大于等于两次的时候,应该抽出来,这个抽出部分代码的逻辑也符合所谓公共模块的概念。
- name: 是build出来的js文件的名字;
这个name最终用到:
output: {
filename: '[name].[contentHash:8].js',
path:distPath,
}
这些chunks,最终需要根据具体项目情况和入口情况,配置到HtmlWebpackPlugin插件的chunks中:
plugins: [
// 多入口,生成 index.html
new HtmlWebpackPlugin({
template: path.join(srcPath, 'index.html'),
filename: 'index.html',
chunks: ['index', 'common', 'vender'] // 根据实际需要,配置chunks
}),
// 多入口,生成 other.html
new HtmlWebpackPlugin({
template: path.join(srcPath, 'other.html'),
filename: 'other.html',
chunks: ['other', 'common']
}),
]
懒加载
webpack有一个机制其实是支持懒加载的。
就是它认为当import作为函数调用的时候,webpack就会在import语句返回一个promise,文件的内容自然就是当加载完成后被传进then了。
而且,这样加载的文件会被作为chunk,单独会被打包成js单独文件,它被build产生的东西可能是:3.jf7cjen5.js。
比如:
// 文件lazyLoad.js
export default {value: 'lazyLoad'};
// 文件B中加载
setTimeout(() => {
import('./lazyLoad').then(res => console.log(res.default));
}, 5000);
B文件5秒后才会去加载lazyLoad.js,lazyLoad.js build的时候就会被单独打包成文件。
所以说,懒加载是webpack自带的功能。
不过说到懒加载,有必要其实多讨论下React中的懒加载(异步)组件。
知乎: 为什么react加载异步组件的方法要这么写?原理是什么?
待完成: 懒加载
概念回看
moudle、chunk、bundle
- moudle: 通常我们会听到说,webpack中,万物皆为moudle,那么这个万物是什么?首先,moudle这个概念的时间点是我们打包之前,这个‘万物’说的是,从我们指定的src进去所遇到的所有对资源的引用,比如,其他js依赖文件,比如css,比如引入image;
- chunk: chunk这个概念适用的时间点是,马上打包完成时,那么chunk是啥?chunk相当于对moudle文件的重新组织之后,生成的目标代码块,chunk在内存中,还没有持久化到文件系统中。在webpack中,有三种东西可以产出chunk:
- entry定义的单(多)入口;
- import作为函数调用来异步加载文件,这个被加载的文件在内存中形成chunk;
- splitChunks(上文里提到)中我们主动配置的分包规则下,产出的chunk;
性能优化
- 打包方面的优化
(babel-loader、IgnorePlugin、noParse、happyPack、ParallelUglifyPlugin、自动刷新、热更新、DllPlugin) - 产出代码的方面,产品本身
构建优化
构建优化:babel-loader
对于label-loader有两点:
缓存
{
test: /.js$/,
loader: ['babel-loader?cachaDirectory'],
include: srcPath,
// exclude: /node_moudles/ // 其实include、exclude只要写一个就行
}
include & exclude确实范围
构建优化:IgnorePlugin
避免引入无用模块:
这里涉及一个问题,咋样就能知道是无用的?
比如又一个第三方库,支持n个语言,但是我现在不做国际化,只需要中文。但是如果在线上环境下如果这些不要的东西都打包,势必体积会增大。(以moment为例子)
这时候需要借助IgnorePlugin了。
启用插件:在webpack.prod.js中
plugins: [
// ...
// IgnorePlugin是webpack自带的
// 忽略moment库里面的local
new webpack.IgnorePlugin(/\.\/locale/, /moment/),
]
在页面手动引入
import moment from 'moment';
import 'moment/locale/zh-cn'; // 手动引用中文语言包
构建优化:noParse
避免重复打包。
这是因为我们总会遇到这种xxx.min.js,比如react.min.js这种是已经被工程化打包过的文件,那我们就没必要再搞一次了,不是吗?
这种问题要交给noParse处理:
// 我们在module里面加上:
module:{
noParse: [/react\.min\.js$/] // 检测到这个文件名就不重复打包
}
这个注意一点就是,noParse是不做重复打包了,直接引入。但是IgnorePlugin是不引入。
构建优化:happyPack
happyPack是打包开启多进程,进程,不是线程。
要开启多进程打包,首先配一个happyPack插件,当然最开始需要安装下并引入:
// 引入
const HappyPack = require('happypack')
// ...
// 在plugins里面
plugins: [
new HappyPack({
id: 'babel', // 这个id可以表示当前的 happy pack 的作用
loaders: ['babel-loader?cacheDirectory']
})
]
然后修改下我们之前在module.rules中写的use:
{
// ['babel-loader?cacheDirectory'] 这是以前
// 引入happypack之后就这么写,id要和上面的匹配
use: [happypack/loader?id=babel]
}
这里有一点,多进程的打包,不一定一定比单进程快,因为开启进程也有开销。
构建优化:ParallelUglifyPlugin
多进程压缩JS,配置如下(配置的含义在下面代码中注了):
// 引入
const ParallelUglifyPlugin = require('ParallelUglifyPlugin')
// ...
// 在plugins里面
plugins: [
new ParallelUglifyPlugin({
output: {
beautify: false, // 不要格式,压成一行
comments: false // 不要注释
},
compress: {
drop_console: true,// 不要console
collapse_vars: true, // 合并变量
reduce_vars: true, // 提取成变量
}
})
]
什么叫合并变量?
var a = 10;
var b = 10;
var c = a + b;
// 可能合并成
var c = 20;
// 或者
var c = 10 + 10;
什么是提取成变量?
x = 'Hello'; y = 'Hello'
// 转化成
var a = 'Hello'; x = a; y = b,
刚才提到一点:这里有一点,多进程的打包,不一定一定比单进程快,因为开启进程也有开销。
当程序规模比较小,本来打包就不算慢,这种情况下,可能还会降低打包速度,这就是因为多进程开销。这种多进程打包策略应用于,现在我们的打包速度遇到瓶颈了,可以试试这些技术看能不能优化。
构建优化:自动刷新&热更新
自动刷新可以借由devServer实现,这个肯定是开发环境用。
热更新和自动刷新是不一样的,区别在于:
- 自动刷新相当于点击了刷新,状态会丢失;
- 热更新状态不丢失;
热更新需要使用的插件是:HotModuleReplacementPlugin
const HotModuleReplacementPlugin = require('webpack/lib/HotModuleReplacementPlugin');
// 1. entry中
entry: {
index: [
'webpack-dev-server/client?http://localhost:8080/',
'webpack/hot/dev-server',
path.join(srcPath, 'index.js')
]
}
// 2. Plugins用起来
plugins: [
// ...,
new HotModuleReplacementPlugin()
]
// 3. devServer中hot: true
devServer: {
port: 8080,
progress: true, // 显示打包进度
contentBase: distPath, // 跟目录
open: true, // 自动开启浏览器
compress: true, // gzip压缩
hot: true,
proxy: {
// ...
}
}
热更新需要设置一个监听范围和模块,若在模块之外的变化,不触发热更新?
热更新是替换网页代码的执行,不会改变状态。热更新原理,面试题。
构建优化:DllPlugin
我理解这个Dll没太大用途。就是把一些不常变化的东西打成dll,真正需要使用的时候把dll再拿过来使用。
DllPlugin负责打包过程,而DllReferencePlugin负责使用这些dll。
代码产出优化
目的是要达到:
- 体积更小
- 合理分包,不重复加载
- 速度更快,内存使用少
小图片Base64
rules: [
{
test: / \.(png|jpg|jpeg|gif)$/,
use: 'file-loader'
}
]
生产打包的时候,需要的不是file-loader,而是url-loader,可能需要将小于5kb的图片用base的url返回,然后写在html中,这样减少了一次http请求图片。
bundle加hash
xxx.[contentHash:8].js会根据内容算出hash值
懒加载
前文讲过
公共代码打包
见上文,公共代码抽离
IngorePlugin
见上文
mode:production
自带压缩代码
自动删除调试代码,比如一些warnning
自动开启Tree-Shaking
概念:Tree Shaking
Tree Shaking其实名字起的很形象。
就像你在大雪天去踹一棵树一样,原本不属于树而且没用的东西就会掉下来。
这里有一点需要注意,CommonJS不能生效,必须是ES Modeule的模块下Tree Shaking才会生效(ES Modeule静态引用,而Tree Shaking是静态分析,CommonJS执行的时候才引入,没法分析)。
mode:production,自动会开启Tree shaking。
// TODO:Tree Shaking 原理
概念:Scope Hosting
默认打包结果中,一个函数的个数没有优化,但是引擎里面作用域过多。
这个Scope Hosting的作用是把,可以合成而不影响功能的多个函数变成一个函数,这样作用域少,体积小。对js的执行和内存消耗方面都有所优化。
配置方式:
const moudleConcatenationPulgin
= require('webpack/lib/optimize/xxxfff')
// plugins
plugins: [
new MoudleConcatenationPulgin()
]
另外,这个也是基于ES6 Moudlue,CommonJS会失效。有些第三方包,有js Es Moudle模块化语法的打包,也提供了不是js模块化所以,为了我们scope hosting尽量的做多一点优化,我们需要告诉webpack,打包的时候如果可以,去拿那些用了es6模块化的打包。
下面配置是告诉Webpack,如果第三方打包中存在es6的打包,就优先使用:
resovle: {
// jsnext:main ---- es6 模块化语法
mainFields: ['jsnext:main', 'browser', 'main']
}
这个顺序就是在第三方库的node_module里打包某个库的时候,不同类型的文件明,优先级被的问题
babel
巴别塔,babel,这个名字倒是很有点意思。
那是很早很早的时候,人类在经历了大洪水后存活了下来,变得不可一世,他们要在两河流域要建造一座塔。
这个塔计划非常宏伟,一旦建成了人们就能走到天上去。那时候人们的语言是相同的,所以建造的很快。
但是,上帝不想这样的事情发,认为自己的权威收到了威胁。上帝就让人们说了不同的语言,于是,人们因此彼此无法沟通,合作不下去了,最终这塔也就没建造起来。
所以,我们因为这个故事,现在有了一个语法转换器,babel。(官网:https://www.babeljs.cn/docs/)
babel是EcmaScript语法之间的转换器。比如,把ES6代码翻译成ES5代码。
babel本身是一个空壳,它定义了流程,而具体转换的方法,都是通过各个plugin来实现的。
- .babelrc
这是babel的配置文件。 - presets
presets,预设,是常用的plugin的集合套件。代替了很多需要自己再写的plugin。最常用的就是:presets-env、presets-react。 - polyfill
补丁,就是提供某些环境下可能不存在的API。补丁是API层面的,并不是语法层面,presets可是语法层面的。
比如,我想用Array.prototype.yxn这个方法,那么可能polyfill(babal-yxn-polyfill)可能这样写:
// code in babal-yxn-polyfill
if(!Array.prototype.yxn){
Array.prototype.yxn = function () {
// .....
}
}
// 使用起来
const x = [];
x.yxn();
- babel-polyfill
babel-polyfill是core.js + regenerator.js(处理generator)的集合。 babel-polyfill已经被7.4弃用,直接推荐core.js和regenerator.js。
另外,polyfill的转换,实际上,只是引入一下,并没有做工程化,也就是说,代码中只出现:
require('@babel/polyfill')
当然,babel也可以按需引入:
// .babelrc
{
"presets": [
"@babel/preset-env",
// 下面这段就是按需引入的配置
{
"useBuiltIns": "usage",
"corejs": 3
}
],
plugins: []
}
使用polyfill存在的一个比较重要的问题是,polyfill污染了全局环境。
当然,大多数情况下,这个点不是人们最关系的,污染了也没事,但是如果能不污染还是最好。
babel-plugin-transform-runtime插件就是来解决这个问题的。解决的办法是,用下划线定义这些补丁,具体可以参考官方文档:
https://www.babeljs.cn/docs/babel-plugin-transform-runtime
前端为什么要工程化问题要点?
要点:
- 代码相关的方向
- 体积小,加载快
- 编译高级语言和语法
- 兼容性,错误检查等等
- 研发流程方面 (这点比较进阶)
- 统一高效的开发环境
- 统一的构建流程和产出标准
- 集成公司的规范