这篇是我看《webpack实战 入门、进阶与调优》这本书的一个笔记,也相应扩充了部分内容,可以算是给没读过的人做个引子。这本书比较系统地介绍了webpack的基础,阅读量也不大,让我弄清楚了很多以前模糊的点。
1、安装webpack
安装webpack建议本地安装(不使用全局),因为全局安装的话项目在不同机器下可能出现版本不一(本地安装能保证团队的版本一致),并且使用时可能出现本地和全局webpack版本混乱的情况。所以干脆就本地安装。
安装:npm i webpack webpack-cli --save-dev
webpack为核心库,webpack-cli是命令行工具。
由于是安装于本地,所以可以使用 npx webpack 来使用。(npx是nodejs自带的自动执行本地模块的一个命令,具体可以参考npx 使用教程;
2、JS的模块管理
在es6 module未成为标准前,有2个比较多人使用的模块管理方案:AMD和commonJS。这两者都是通过编译后生成runtime,在代码运行过程中动态引入。目前commonJS是nodejs的模块管理标准。
- AMD(Asynchronous Module Definition 异步模块定义):通过声明回调函数异步加载模块,将其他模块以依赖注入的方式加入进当前模块,由于是异步加载所以不会阻塞当前模块加载。(参考:RequireJS和AMD规范;
// foo.js export
define({
method1: function() {},
method2: function() {},
});
// import
require(['foo'], function ( foo ) {
foo.method1();
});
- commonJS:通过一个模块对象引出、引入其他模块,引入的值为拷贝值,相较AMD语法更简单方便;
// export
module.exports = {name: 123}
// import
var name = require('./export.js').name
- es6 module:通过语法静态引出、引入其他模块,是在编译期间进行,引入的值只读的变量映射(因此可以解决循环引用的问题,通过包裹一个函数的方式,参考:JavaScript 模块的循环加载),所以原始值改变会影响到引用者。因为是静态引用,所以不像前两者可以将import语句写在任意地方,如if判断内,需要在编译过程就确定是否引入。
// export
export const a = 123;
// import
import {a} from './export'
-
三个模块引入的方式,都在第一次引入时将该目标模块代码执行一遍。
PS:因为前两者不是官方标准,所以都需要借助webpack等模块打包工具进行编译打包后才可以在浏览器上运行,而es6 module可以直接在浏览器上运行,只需要把script标签的type设置为module。但如果你在本地想试验一下es6 module则会发现,本地引入模块时用的是file协议,因为在浏览器引入js资源时需要域名、协议、端口一致,所以在file协议下没有域名会触发跨域限制(chrome、firefox会,ie不会),导致引入失败。 - UMD(Universal Module Definition 通用模块标准):这不是一个模块管理方案,是一个统一所有模块管理方案的解决。webpack中则应用了这个方案。由于会通过全局是否有define函数来判断AMD环境,但在AMD的规则下是无法使用commonJS和es6 module的,所以如果项目全使用commonJS却因为某些原因出现了define函数,则可能导致全部模块失效,需要手动去修改webpack UMD模块的判断顺序。
/*
UMD判断模块管理方案的源码
*/
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['b'], factory);
} else if (typeof module === 'object' && module.exports) {
// Node. Does not work with strict CommonJS, but
// only CommonJS-like environments that support module.exports,
// like Node.
module.exports = factory(require('b'));
} else {
// Browser globals (root is window)
root.returnExports = factory(root.b);
}
}(this, function (b) {
//use b in some fashion.
// Just return a value to define the module export.
// This example returns an object, but the module
// can return a function as the exported value.
return {};
}));
3、chunk、entry、bundle的基本概念
chunk:打包的模块
entry:打包的入口文件
bundle:每个模块打包好后的文件1
图片来自《Webpack实战:入门、进阶与调优》
4、配置资源入口
// webpack.config.js
module.exports = {
/*
* context配置entry的路径前缀,可以理解为入口的文件上下文,所以是绝对路径,这样在多个entry的时候写起来比较方便
*/
context: path.join(__dirname, './src'),
/*
* 打包入口文件
* 入口可为多个,entry的值可以是数组、字典,用函数或promise返回这两种数据结构也可以
*/
entry: './index.js'
}
5、提取公共模块
像loadsh、jquery这些第三方的库,如果都跟业务代码一起打进一个bundle文件就会很大,并且每次代码更新都需要更新整个文件。这时候可将一些公共模块抽出来,就不用跟业务代码混杂在一起了。
module.exports = {
entry: {
app: './index.js', // 主入口
vender: ['react', 'lodash', 'jquery'] // vender是‘提供商’的意思,这里理解为第三方模块
}
}
6、配置资源出口
资源出口的配置在output对象种配置。
module.exports = {
entry: './src/app.js',
output: {
// bundle文件名
filename: 'bundle.js',
// bundle导出路径
path: path.join(__dirname, 'assets'),
// 资源访问上下文
publicPath: '/dist/',
},
};
output详解:
- filename的书写形式:
// 直接写bundle名
filename: 'bundle.js'
// 相对路径,webpack会自动帮你创建src文件夹
filename: './src/bundle.js'
// 动态指定文件名,具体看下图
filename: '[name].js'
图片来自《Webpack实战:入门、进阶与调优》
- path:打包资源输出路径,默认为项目根目录的dist文件夹,需要配置绝对路径;
- publicPath:资源的请求位置,html直接请求的资源如sript标签里的js,和js或css的间接请求的资源资源如引入模块和css加载背景/图,这些都属于资源请求,他们的路径都会在前面加上publicPath;
// 相对路径则会从当前请求的文件路径开始衔接
publicPath: './js'
// 在app目录下的html直接请求的资源index.js => www.example.com/app/js/index.js
// 以 / 开头,则直接从域名后开始衔接
publicPath: '/js'
// 请求资源index.js => www.example.com/js/index.js
// 绝对路径,一般用CDN的场景
publicPath: 'www.cdn.com/js'
// 请求资源index.js => www.cdn.com/js/index.js
- output.path建议跟devServer里面的publicPath保持一致,这样在开发和生产环境才不会搞混,具体原因可以看2、webpack-dev-server的解释;
7、webpack模块打包的简单原理(理解地比较粗浅)
通过声明一个installedModules字典来存储每个模块,给每个模块设置一个唯一key,全部传入一个立即执行的匿名函数,有个入口模块,在里面执行所有模块并存储进installedModules,已经执行过的模块会直接拿缓存。
webpack编译打包后的代码,在浏览器中是这么运行的:1、初始化环境和一些数据结构;2、执行入口模块代码;3、执行模块代码,记录export和导出import(递归);4、所有模块代码执行完毕,控制权回到入口模块;
8、loader
loader可以译为装载机,在webpack中一切皆模块,这也是为什么引入css需要在js中import,因为webpack只能识别js,而一个组件或者页面的js+css就是一个模块,所以通过在js中引用css的方式来将其绑定成一个模块。
// app.js
import './style.css';
// style.css
body {
text-align: center;
padding: 100px;
color: #fff;
background-color: #09c;
}
而loader其实一个函数,它的输入和输出是源码或上一个loader的输出(字符串、source map、AST),所以loader的调用是链式的,像一个流水线一样将模块打包出去。这也意味着loader的声明是需要注意顺序的。
ps:source map是一个json文件,用来解决代码编译前后的映射问题
loader配置:
module.exports = {
module: {
rules: [{
// 正则匹配需要进入loader的文件
test: /\.css$/,
// 用到的loader数组(loader的执行顺序从后到前,所以这里是css-loader先执行)
use: [
'style-loader',
// loader除了上面'style-loader'这种直接声明字符串
// 还可以像下面'css-loader'这样声明一些配置项
{
loader: 'css-loader',
options: {
// css-loader 配置项
}
}
],
// loader处理文件的排除范围
exclude: /node_modules/, // 正则
// 处理范围,exclude优先于include,意味着如果两个配置有重叠,include是不能覆盖exclude的
include: /src/, // 正则
/*
* 在Webpack中,我们认为被加载模块是resource,而加载者是issuer。
* 比如在这个例子里,css文件是加载模块(resource),js文件则是加载者(issuer)
* 所以下面是配置js文件,则是配置加载者
* 前面的loader则是配置加载模块
*/
issuer: {
test: /\.js$/,
include: /src/pages/ // 正则
},
// loader执行顺序:
// normal(默认,按排列顺序)、pre(在所有正常loader前)、post(在所有正常lodaer后)
enforce: 'normal',
}],
},
};
9、写一个最简单的loader
上面说了loader其实就是一个有输入输出的函数,所以最简单的loader其实只要写一个函数就行。
- 用 npm init 初始化一个项目;
- 创建一个index.js写入以下代码;
// 这个loader可以在js文件头部加上 “这是我加上去的代码” 这句注释
// content 则是loader的输入即源码或上一个loader的输出字符串
module.exports = function(content) {
var useStrictPrefix = `
// 这是我加上去的代码
`;
return useStrictPrefix + content;
}
- 在另一个项目通过 npm install <绝对路径> 来安装loader;
- 在webpack配置文件中写入loader配置;
- 执行编译;
10、webpack-dev-server
开启一个热更新的服务,可以修改代码后通过websocket通知浏览器更新。devServer会对代码进行编译打包,但不会生成文件,打包后的代码会放进内存访问,当浏览器对这个服务发起请求,它会先校验请求的url是不是配置文件里devServer的publicPath。
安装:npm install --save-dev webpack-dev-server
配置:
devServer: {
/*
devServer.contentBase
决定了 webpackDevServer 启动时服务器资源的根目录,默认是项目的根目录。
在有静态文件需要 serve 的时候必填,contentBase 不会影响 path 和 publicPath,
它唯一的作用就是指定服务器的根目录来引用静态文件。
可以这么理解 contentBase 与 publicPath 的关系:contentBase 是服务于引用静态文件的路径,
而 publicPath 是服务于打包出来的文件访问的路径,两者是不互相影响的。
*/
contentBase: './dist',
/*
devServer.publicPath
在开启 webpackDevServer 时浏览器中可通过这个路径访问 bundled 文件,
静态文件会加上这个路径前缀,若是devServer里面的publicPath没有设置,
则会认为是output里面设置的publicPath的值。
(如果有使用htmlWebpackPlugin,建议devServer.publicPath不填或者跟output.publicPath一致,
因为在开启devServer后,htmlWebpackPlugin插入js会使用devServer.publicPath)
和 output.publicPath 非常相似,都是为浏览器制定访问路径的前缀。
但是不同的是 devServer.publicPath 只影响于 webpackDevServer(一般来说就是 html),
但各种 loader 打出来的路径还是根据 output.publicPath。
*/
publicPath: './dist'
}
参考:https://github.com/fi3ework/blog/issues/39
11、代码分片
考虑到缓存和减少请求时间等原因,需要将公共代码分块。不同于之前使用的CommonsChunk-Plugin插件,webpack4有了改进版的代码分片配置optimization.SplitChunks。
不像CommonsChunk-Plugin需要去将特定的模块提取出来,使用SplitChunks只需要配置提取条件,webpack就会将符合条件的模块打包出来。下面是默认配置:
optimization: {
splitChunks: {
// chunks: async(默认,只提取异步模块) | initial(只提取入口) | all(前两者都提取)
chunks: 'all',
// 按cacheGroups的提取规则,并以automaticNameDelimiter为分隔符命名chunks
// eg: vendors~a~b~c.js意思是该chunk为vendors规则所提取,并且该chunk是由a、b、c三个入口chunk所产生的。
name: true,
// chunk命名的分隔符
automaticNameDelimiter: '~',
/*
* 根据chunk资源本身情况配置规则
*/
// 提取后的Javascript chunk体积大于30kB(压缩和gzip之前),CSS chunk体积大于50kB
minSize: {
javascript: 30000,
style: 50000,
},
// 在按需加载过程中,并行请求的资源最大值小于等于5
maxAsyncRequests: 5,
// 在首次加载时,并行请求的资源数最大值小于等于3
maxInitialRequests: 3,
// 备注:设置maxAsyncRequests和maxInitialRequests是因为不希望浏览器一次发出过多请求,
// 所以希望把一次加载的模块限定规定次数;
/*
* 根据chunk来源配置提取规则
*/
cacheGroups: {
// 模块来自node_modules目录,vendors只是chunk命名,可灵活调整;
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10, // 优先级,这里vendors优先
},
// chunk被至少两个模块引用则重用
default: {
minChunks: 2,
reuseExistingChunk: true,
priority: -20,
},
}
},
}
// 正常只需要像下面这样声明即可
optimization: {
splitChunks: {
chunks: 'all'
}
}
12、webpack异步模块加载
// 异步地将b.js加载进来
import('./b.js').then((b) => {
...
})
这个异步import,webpack是通过动态插入script标签来实现的,因为之前提过,通过script加载进来的属于间接资源请求,这个资源位置需要通过output.publicPath来确定,所以需要配置号output.publicPath;
13、环境区分
在开发过程中,需要区分开发环境和生产环境,开发环境一般完成基本的编译打包工作,让代码能在浏览器运行就好,而生产环境为了更小的包体通常还会进行压缩、tree-shaking等操作。将这两种环境的操作区分开来,一般有两种方案:
- 通过命令传入环境变量
// package.json
{ ...
"scripts": {
"dev": "ENV=development webpack-dev-server",
"build": "ENV=production webpack"
},
}
// webpack.config.js
const ENV = process.env.ENV;
const isProd = ENV === 'production';
module.exports = {
output: {
filename: isProd ? 'bundle@[chunkhash].js' : 'bundle.js',
},
// mode模式如果为production,webpack会默认添加一些配置,帮助压缩代码
mode: ENV,
};
- 为两种环境分别写一个配置文件,公用的部分可以通过webpack-merge来合并
{ ...
"scripts": {
"dev": " webpack-dev-server --config=webpack.development.config.js",
"build": " webpack --config=webpack.production.config.js"
},
}
- 通过webpack中可以通过DefinePlugin插件将环境信息注入到js运行环境:
// webpack.config.js
plugins: [
new webpack.DefinePlugin({
ENV: JSON.stringify('production'),
})
]
// app.js
document.write(ENV);
14、tree-shaking
tree-shaking能使webpack在打包过程中,将一些没引用到的多余包体剔除,但有两点需要注意,一是tree-shaking只在es6 moudle下生效(依靠es6 moudle静态引用实现),意味着commonjs的模块管理是不可行的;二是tree-shaking只是做多余包体的标记工作,实际剔除代码还是需要借助压缩插件如terser-web-pack-plugin,但在webpack4只需要将mode设置为production即可。
针对上面第一点,需要注意在babel-loader中设置module=false,禁止bable将模块转为commonjs。
15、模块热替换(hot module replace)
监听文件变化,不同于live reload(刷新页面,全量更新),热替换是增量修改,不刷新网页,只更改局部。
开启HMR:
const webpack = require('webpack');
module.exports = { // ...
plugins: [
new webpack.HotModuleReplacementPlugin()
],
devServer: { hot: true, },
};
HMR原理:
- 首先浏览器端会有HMR runtime,webpack会起一个webpack-dev-server(WDS),两者依靠websocket通信;
- 当WDS监听到文件变化,会向客户端推送更新事件,并带上构建的hash。客户端根据这个hash和之前资源的对比,判断是否需要更新;
- 当需要更新,客户端就会向WDS请求更改的资源列表。WDS会返回需要构建的chunk name和资源版本hash。客户端再根据这些信息向WDS请求增量更新的资源;
- 拿到更新的资源,HMR runtime就会开始决定哪些地方需要替换。webpack会暴露一个module.hot接口,用于给使用者知道热替换的时机,module.accept则是设置需要替换的模块。一般loader都会设置相应的模块热替换的补丁操作,对替换模块进行操作。如果runtime对某个模块没有检测到对HMR的update handler,则会将替换操作冒泡到父级模块,以此类推。(参考:webpack中文文档)