模块打包工具的由来是什么呢,先简单总结下,有以下几点:
- 首先,我们前面学的ES Modules存在环境兼容问题,我们也不能去统一用户使用环境。
- 而且,模块文件过多会导致网络请求频繁。影响工作效率。
- 所有前端资源都应该模块化,不仅仅是JS文件,还有css、image、font等。
所以我们需要这样一款打包工具:
- 代码编译降级成兼容性好的代码。ES6 -> ES5。
- 将散落文件打包到一起。解决频繁请求问题。
- 支持不同种类资源类型。
现在讨论的是对于整个前端应用的模块化方案,而不是前面的仅仅针对JS的模块化。
开始使用
安装webpack及其cli:
"devDependencies": {
"webpack": "^5.26.3",
"webpack-cli": "^4.5.0"
}
打包原理
去代码调试一下看看,基本原理比较简单。loader是webpack实现整个前端模块化的核心特性,借助于loader就可以加载任何类型的资源。loader是从后往前执行的,要注意顺序。
在webpack打包,我们会将css、image这种资源在JS中引入,这样对于webpack来说是合理且必要的:
- webpack是根据依赖关系去寻找模块,在js中引入,说明确实是需要这些资源。
- 确保了线上资源不丢失。
file-loader源码探析
查看源码,是直接将图片文件路径作为模块导出出去,并且会拼接上公共路径__webpack_require__.p同时,如果未指定pulicPath,webpack会根据打包文件位置去自动计算一个默认的公共路径。
// 导出的图片路径
const __WEBPACK_DEFAULT_EXPORT__ = (__webpack_require__.p + "06c26fe7f9477c6d495ecb2b5331da7b.png");
没指定pulicPath的情况下,会根据以下代码去获取script标签src指定的链接路径,将其作为publicPath:
(() => {
var scriptUrl;
if (__webpack_require__.g.importScripts) scriptUrl = __webpack_require__.g.location + "";
var document = __webpack_require__.g.document;
if (!scriptUrl && document) {
if (document.currentScript)
scriptUrl = document.currentScript.src
if (!scriptUrl) {
var scripts = document.getElementsByTagName("script");
if(scripts.length) scriptUrl = scripts[scripts.length - 1].src
}
}
// When supporting browsers where an automatic publicPath is not supported you must specify an output.publicPath manually via configuration
// or pass an empty string ("") and set the __webpack_public_path__ variable from your code to use your own logic.
if (!scriptUrl) throw new Error("Automatic publicPath is not supported in this browser");
scriptUrl = scriptUrl.replace(/#.*$/, "").replace(/?.*$/, "").replace(//[^/]+$/, "/");
__webpack_require__.p = scriptUrl;
})();
// scriptUrl示例 http://127.0.0.1:5500/myFile/webpack-demo/output/
file-loader工作示意图:
它是将导入的结果变成一个url地址,并生成一个文件到输出目录。它的原理是拿到source,也就是文件内容,图片内容buffer,然后生成一个具有相同文件内容的文件到输出目录,返回一段代码export default '文件路径'。
Data Urls 和 url-loader
它可以直接表示一个文件,其文本就包含了整个文本内容,使用如下语法:
data:[<mediatype>][;base64],<data>
[协议][-----媒体类型和编码----][-编码-]
// html示例
data:text/html;charset=UTF-8,<h1>html content</h1>
对于图片或字体可以转为base64文件编码,依此可以使用url-loader。最佳实践方式是将小文件用该loader进行打包,来减少网络请求的次数。大文件依然使用file-loader打包,单独存放。
loader可分为:
- 编译转换类:css-loader、babel-loader
- 文件操作类:file-loader
- 代码检查类:eslint-loader
webpack只是一个打包工具,和gulp的插件机制类似,webpack具体处理文件方式也是由特定loader加载器去进行编译转换的。
webpack加载资源的方式可分为这几种:
- ES Modules的import、
- CommonJS的require、
- AMD标准的define和require函数、
- 所有样式代码的@import指令和url函数、
- HTML代码中的src、href属性。
打包核心原理
会从entry入口文件开始,根据其内部的import、require等等导入语法,构建出依赖关系树,然后去递归遍历每个资源模块,在这个过程中使用对应的loader去加载编译转换每个模块,将最终结果放入bundle中。
在这个过程中,loader扮演了一个很重要的角色。
属性解析
- mode属性:有product、development、none三种属性。none属性下,webpack不回去做额外的处理。
开发自己的loader
loader也是一个模块,它需要导出一个函数,该函数的参数是被加载文件的字符串,我们可以将它经过我们的处理后再返回,需要注意返回的内容必须是一个JavaScript格式的代码,不然会引起语法报错。
const marked = require('marked')
module.exports = source => {
// console.log(source)
// return 'console.log("hello ~")'
const html = marked(source)
// 第一种方式:直接自己拼接为JS代码
// return html
// return `module.exports = "${html}"`
// return `export default ${JSON.stringify(html)}`
// 第二种方式:
// 返回 html 字符串交给下一个 loader 处理
return html
}
这里有一个小技巧,使用JSON.stringify可以转义html中的空格、换号、引号等。从这个例子可以知道loader负责文件从输入到输出的转换。和gulp的文件流工作机制很像。对于一个文件可以使用多个loader,就像工作管道。
插件机制
todo: 补全webpack核心原理、loader机制、plugins机制、结合以前总结的工作原理
DevServer
Webpack中的DevServer,为了提高工作效率,它并没有把打包结果写入到磁盘中,而是暂时放在了内存中,http-server也是将文件从内存中读了出来,然后发送给浏览器,这样就减少了大量磁盘读写操作。
在 Webpack4.0 中可以使用 webpack dev server,但是在5.0版本中已经不再适用,我们可以直接使用webpack server 来取代使用。
contentPath
额外为开发服务器指定查找资源目录。
在使用loader的过程中出现了问题,在使用babel-loader时会报错,查看了文档发现是因为使用方式不对,应该按如下方式来进行配置:
{
test: /.m?js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', { targets: "defaults" }]
]
}
}
}
SourceMap
SourceMap解决了源代码与运行代码不一致产生的问题,能帮助开发人员在调试工具中直接定位到源代码。它有几个关键属性:
- version:sourceMap的版本
- source:一个数组,搜集源代码的路径
- names:一个数组,搜集源代码中使用的成员名称,因为源代码中会替换有意义的变量名,而这个数组会将其记录下来,方便还原。
- mappings:核心属性,它是一个base64-vlq编码的字符串,它记录的是源代码和转换代码的映射关系
// csdn上更详细的解释
{
version : 3, //Source map的版本
file: "out.js", //转换后的文件名
sourceRoot : "", //转换前的文件所在的目录。如果与转换前的文件在同一目录,该项为空
sources: ["foo.js", "bar.js"], //转换前的文件。该项是一个数组,表示可能存在多个文件合并
names: ["src", "maps", "are", "fun"], //转换前的所有变量名和属性名
mappings: "AAgBC,SAAQ,CAAEA" //记录位置信息的字符串
}
我们可以在转换代码中加入一行注释来引入SourceMap文件:
//# sourceMappingURL=sourceMap文件路径 // 可以是网络链接
webpack支持12种不同的方式实现sourceMap,每种的效率、效果和速度是不同的。不同模式之间的对比表如下所示:
它们这些模式起的名字是有含义的,就是如下属性的排列组合:
- eval:是否使用eval执行模块代码
- cheap:Source Map是否能定位到行信息,带cheap则不显示行了。
- moduel:是否能够得到未被Loader处理的源代码,比如ES6代码转化
如何选择
开发环境一般选择cheap-module-eval-source-map,原因有如下几点:
- 代码一行一般不会超过80个字符。
- 代码经过loader转换后的差异较大。
- 首次打包速度慢无所谓,重新打包相对较快。
而生产环境则不会使用sourceMap,这也是为了安全。
模块热替换(更新)
HMR(hot module replacement)是webpack中最强大的功能之一。在刚开始使用时,我们发现style文件没有去配置HMR方案,它却有热更新功能,这是因为在我们使用的loader中进行了处理。并且一般我们通过脚手架创建的项目也都给我们集成好了HMR方案。
开启热替换
在devServer中设定hot: true,同时引入webpack内置插件HotModuleReplacementPlugin,在Plugins中定义好。
如果想自己对JS文件使用HMR,就需要我们去文件中配置了,通过module.hot方法注册对应依赖模块修改时,所走的回调函数。并且这些代码被webpack打包到生产环境时,都会被移除掉,不会产生代码冗余。
对于JS文件,webpack无法提供一个通用的热替换方案,因为每一个JS模块的业务逻辑是不一样的,所以需要使用者去自己定义处理逻辑。详细使用可以看如下示例代码:
import createEditor from './editor'
import background from './better.png'
import './global.css'
const editor = createEditor()
document.body.appendChild(editor)
const img = new Image()
img.src = background
document.body.appendChild(img)
// ================================================================
// HMR 手动处理模块热更新
// 不用担心这些代码在生产环境冗余的问题,因为通过 webpack 打包后,
// 这些代码全部会被移除,这些只是开发阶段用到
if (module.hot) {
let hotEditor = editor
module.hot.accept('./editor.js', () => {
// 当 editor.js 更新,自动执行此函数
// 临时记录编辑器内容
const value = hotEditor.innerHTML
// 移除更新前的元素
document.body.removeChild(hotEditor)
// 创建新的编辑器
// 此时 createEditor 已经是更新过后的函数了
hotEditor = createEditor()
// 还原编辑器内容
hotEditor.innerHTML = value
// 追加到页面
document.body.appendChild(hotEditor)
})
// 图片热替换逻辑
module.hot.accept('./better.png', () => {
// 当 better.png 更新后执行
// 重写设置 src 会触发图片元素重新加载,从而局部更新图片
img.src = background
})
// style-loader 内部自动处理更新样式,所以不需要手动处理样式模块
}
accept函数中接收了一个依赖模块路径,在第二个回调函数中会使用其返回的最新数据。
我觉得热替换这个功能对于日常开发来说,还是很鸡肋的,因为对于JS不同的模块都要去编写不同的热替换逻辑,个人觉得没什么太多使用场景。可能css样式和图片的热替换有些应用场景。但官方说法是利大于弊,就好像单元测试,对于长期维护的项目来说是有好处的。如果日常开发中能遵循一些开发规范,使用HMR也会容易些。
对于目前的大部分前端框架,都给我们集成好了HMR方案,可以直接使用。
注意事项
- 处理HMR的代码报错会导致页面自动刷新,并且会清除报错信息。解决办法:使用hotOnly。
- 在项目中可能没有开启HMR但是在代码使用了,解决办法:进行
module.hot判断。 - 关于HMR的额外代码并不会打包到生产文件中去,它编译过来就是一个
if(false) {}而这种判断会被压缩掉。
生产环境优化
生产环境注重运行效率,开发环境注重开发效率。在webpack4推出了mode配置,不同模式提供了不同预设配置。生产环境默认提供了很多生产环境中的优化配置。官方也建议为不同的环境,配置不同的打包配置。
在webpack中,支持两种方式去区分不同环境使用不同的打包配置,第一种是通过module.exports导出一个函数:
module.exports = (env, argv) => {
// 在这函数中可以return一个config对象,通过判断env来选择不同环境的打包配置
// argv函数可以接受运行cli 进行打包时输入的全部参数
if(env === "production){
return prodConfig;
}
if (env === "development") {
return devConfig;
}
return baseConfig;
}
第二种方法是使用webpack官方提供的webpack-merge库进行配置合并:
const webpack = require('webpack')
const merge = require('webpack-merge')
const common = require('./webpack.common')
module.exports = merge(common, {
mode: 'development',
devtool: 'cheap-eval-module-source-map',
devServer: {
hot: true,
contentBase: 'public'
},
plugins: [
new webpack.HotModuleReplacementPlugin()
]
})
如果使用普通的Object.assign方法,会覆盖同名属性。而使用这个webpack-merge方法,在对于类似plugins这种数组配置他不会覆盖,而是合并,在这个函数内部会处理好合并逻辑。
使用这个方式时,因为没默认打包文件,所以需要我们在cli命令中指定配置文件yarn webpack --config xxx.js,我们也可以把命令定义在NPM 命令中,方便使用。
内置插件
前面已经学习了HotModuleReplacementPlugin这个内置插件,这里再讲几个。
DefinePlugin
它是一个全局常量定义插件,使用该插件通常定义一些常量值,使用方式如下:
new webpack.DefinePlugin({
// 值要求是一个代码片段
API_BASE_URL: JSON.stringify('https://api.example.com'),
PI: `Math.PI`, // PI = Math.PI
VERSION: `"1.0.0"`, // VERSION = "1.0.0"
DOMAIN: JSON.stringify("duyi.com")
})
这样一来,在源码中,我们可以直接使用插件中提供的常量,当webpack编译完成后,会自动替换为常量的值。
BannerPlugin
它可以为每个chunk生成的文件头部添加一行注释,一般用于添加作者、公司、版权等信息。
new webpack.BannerPlugin({
banner: `
hash:[hash]
chunkhash:[chunkhash]
name:[name]
author:yuanjin
corporation:duyi
`
})
ProvidePlugin
自动加载我们需要的重复大量使用的公共模块,这样就不用到处去 import 或 require。
new webpack.ProvidePlugin({
$: 'jquery',
_: 'lodash'
})
// 然后在我们任意源码中:
$('#item'); // <= 起作用
_.drop([1, 2, 3], 2); // <= 起作用
TreeShaking
“树抖动”,它的实现必须是基于ES Module(补充原因)。在使用babel-loader时,可能会导致TreeShaking不生效,在新版中已经关闭了转为CommonJs的插件。
sideEffects
标识模块是否有副作用:模块执行时除了导出成员之外,所做了的事情。它和Tree-Shaking配合使用,作用是告诉webpack,该模块是否有副作用,没有的话,该模块如未被引用,则会被移除。
// 开启方式,在webpack.config.js中加入配置
module.exports = {
optimization: {
sideEffects: true
}
}
}
// 在模块的package.json中加入配置
{
"sideEffects": false,
// 或
"sideEffects": [
"./src/extend.js",
"*.css"
]
}
使用这个功能前提是我们要确定代码真的没有副作用。比如:在模块中给一些原生方法做方法扩展;导入的css模块。解决方式是如上所示,标识出有副作用的模块。
webpack代码分割
多入口打包
通过entry属性配置一个对象。这里不是一个数组,因为数组是将多个入口打包成一个bundle。
提取公共模块
使用splitChunks这个属性,可以让webpack自动进行公共模块的打包,都打包至一个bundle中,减少代码体积。
module.exports = {
optimization: {
splitChunks: {
// 分包策略
}
}
}
实践一下
动态导入
对于通过import()方法导入的模块,webpack会自动对其处理分包,我们还可以通过注释,给打包出来的bundle起名字。
import(/* webpackChunkName: 'posts' */'./posts/posts')
并且相同name的动态导入,会被打包到一起。