webpack 打包
模块化开发为我们解决了很多问题,使得代码组织管理非常的方便,但是又带来了新的问题,ES Module 存在环境兼容问题,划分的文件太多,就会导致网络请求频繁,不能保证所有资源的模块化
如果能我们享受模块化带来的开发优势,又能不必担心生产环境的存在这些问题,于是就有了 webpack, rollup, Parcel 等工具
webpack 模块化不等于 js ES modele 模块,相对来讲是前端的模块化处理方案,更加宏观
- 快速上手
$ yarn init --yes
$ yarn add webpack webpack-cli -D
$ yarn webpack --version
$ yarn webpack // 默认打包src/index.js // 最终存放到dist/main.js
- webpack 配置文件
在项目根目录添加 webpack.config.js
const path = require('path');
module.exports = {
entry: './src/main.js', // 入口文件
output: {
filename: 'bundle.js', // 输出文件名
path: path.join(__dirname, 'output'), // 输出文件路径(绝对路径)
},
};
- 工作模式
webpack4 新增了工作模式的用法,大大简化了配置的复杂程度;三种工作模式 mode: production development none
$ webpack --mode none
$ webpack --mode production // 默认模式
$ webpack --mode development
或者采用配置的方式
const path = require('path');
module.exports = {
// 这个属性有三种取值,分别是 production、development 和 none。
// 1. 生产模式下,Webpack 会自动优化打包结果;
// 2. 开发模式下,Webpack 会自动优化打包速度,添加一些调试过程中的辅助;
// 3. None 模式下,Webpack 就是运行最原始的打包,不做任何额外处理;
mode: 'development',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
},
};
-
资源模块加载
webpack 内部的 loader 只能处理 js 文件,其他文件我们需要配置对应的 loader 才可以完成打包,否则会报错。
const path = require('path');
module.exports = {
mode: 'none',
entry: './src/main.css',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
},
module: {
rules: [
{
test: /.css$/,
// css-loader作用就是将css代码转化为js模块
// style-loader作用就是将cssloader转化的结果追加到页面
use: ['style-loader', 'css-loader'],
},
],
},
};
- 导入资源模块
入口文件为 js 文件,根据代码的需要动态导入其他资源,由 javascript 驱动整个前端应用
const path = require('path');
module.exports = {
mode: 'none',
entry: './src/main.css',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
},
module: {
rules: [
{
test: /.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
};
// main.js
import './main.css';
-
文件资源加载器
- file-loader
经过 file-loader 处理后,将文件资源放到我们打包目录的根目录。返回文件资源的访问路径,通过 import 就可以拿到文件资源的路径。webpack 默认认为文件资源放在网站的根目录下
会发起文件请求
- file-loader
const path = require('path');
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
publicPath: 'dist/',
},
module: {
rules: [
{
test: /.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /.png$/,
use: 'file-loader',
},
],
},
};
// main.js
import createHeading from './heading.js';
import './main.css';
import iconURL from './icon.png';
// 经过file-loader处理后,将图片放到我们打包目录的根目录。返回图片的访问路径,通过import就可以拿到图片的路径。webpack默认认为图片放在网站的根目录下
const heading = createHeading();
document.body.append(heading);
const img = new Image();
img.src = iconURL;
document.body.append(img);
-
url-loader
将文件资源转化为 Data Url, 最终返回这个 Data Url,不单独生成资源文件,直接嵌入到 bundle.js 中
当资源文件过大时,导致 base64 边长,打包的 bundle.js 体积过大-
Data URLs
直接表示文件内容,使用这种 Url 不会发起 Http 请求data:[<mediatype>][;base64],<data> // 协议 + 媒体类型以及编码+ 文件内容(图片会被转化为base64)
-
最佳实践:小文件使用 Data URLs, 减少请求次数。大文件单独提取,避免 bundle.js 过大,加载时间过长
module: {
rules: [
{
test: /.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /.png$/,
use: {
// 必须同时安装file-loader,当超过limit设置的值,url-loader会自动让file-loader处理
loader: 'url-loader',
options: {
limit: 10 * 1024, // 10 KB
},
},
},
];
}
-
常用 loader 分类
编译转化类型
文件操作类型
代码质量检查
-
处理 ES6+新特性
webpack 只是打包工具 默认处理代码中的 export 和 import,但对其他 ES6+新特性不做处理,需要 babel-loader
$ yarn add babel-loader @babel/core @babel/preset-env -D
// babel 只是一个js的转换平台。基于平台通过不同的插件实现转化
{
"test": /.js$/,
"use": {
"loader": "babel-loader",
"options": {
"presets": ["@babel/preset-env"]
}
}
}
-
模块加载方式
webpack 兼容多种标准的模块加载方式- ES Module
- CommonJs
- AMD
- import('XXX.css')
- @import ()
- @import url()
- html 中的 img 的 src 属性
- background 属性的 url
- a 标签的 herf 属性
...
-
webpack 核心工作原理
首先设置入口文件,webpack 会根据配置找到入口文件(如果不设置默认 src 下面的 index.js 文件)作为我们的打包入口
根据代码中出现的 import 或者 require 解析推断出这个文件所依赖的其他资源模块
然后分别延伸解析每一个资源模块对应的依赖,形成一个整个项目当中所有用的资源文件的依赖树
然后递归这个依赖树,找到每个节点对应的资源文件,根据配置文件的 rules 属性找到当前模块的加载器(loader),然后交给加载器加载这个模块
最终将执行完成的结果放到 output 对应的 bundle.js 中
在整个过程中,会通过 webpack 提供的钩子函数(生命周期函数)加载对应的任务。这个任务我们也成 plugins
webpack 开发一个 loader
原则: 对同一文件所用到的 loader 执行完成后, 最终必须返回 javascropt 代码,也就是处理当前资源的最后的 loader 必须是返回 javascript 代码
const path = require('path');
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
publicPath: 'dist/',
},
module: {
rules: [
{
test: /.md$/,
// 将 md 转化为 html
use: ['html-loader', './markdown-loader'],
},
],
},
};
// main.js
import about from './about.md';
console.log(about);
// markdown-loader.js
const marked = require('marked');
module.exports = source => {
// source为加载进来的资源内容
const html = marked(source);
// 如果不交给下个loader处理
// return `module.exports = "${html}"`
// return `export default ${JSON.stringify(html)}`
// 如果交给下个loader处理
// 返回 html 字符串交给下一个 loader 处理
return html;
};
-
常用插件 Plugin
clean-webpack-plugin
每次打包前先清除 webpack 输出目录HtmlWebpackPlugin
每次打包的文件自动生成 html 文件,自动引入打包结果
plugins: [
new webpack.ProgressPlugin(),
new CleanWebpackPlugin(),
// 不额外添加模板的使用
new HtmlWebpackPlugin({
title: 'glh', // 设置标题
meta: {
// 设置meta标签
viewport: 'width=device-width',
},
// ...
}),
];
// 添加模板,让HtmlWebpackPlugin根据模板生成
new HtmlWebpackPlugin({
title: 'glh', // 设置标题
meta: {
// 设置meta标签
viewport: 'width=device-width',
},
template: './public/index.html',
templateParameters: {
// 自定义变量
BASE_URL: './',
},
// ...
});
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong
>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work
properly without JavaScript enabled. Please enable it to
continue.</strong
>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>
// 用于生成index.html
new HtmlWebpackPlugin({
template: './public/index.html',
// ...
});
// 用于生成about.html
new HtmlWebpackPlugin({
filename: 'about.html',
// ...
});
copy-webpack-plugin
对一些公共资源文件直接复制到打包目录中。比如 public/favicon.ico
new CopyWebpackPlugin({
patterns: [{ from: 'public/favicon.ico', to: '.' }],
});
我们一般在使用插件的时候掌握一些经常用的就可以。后面根据需求再去提炼关键词,搜索自己想用的插件,当然也可以自己写。插件的约定名称一般都是 XXX-webpack-plugin,比如我们想要压缩图片就可以找 imagemin-webpack-plugin
- 实现一个自定义 plugin
首先要明白:
- Plugin 其实就是通过在生命周期的钩子中挂载函数实现扩展。类似于我们 React 中的声明周期。
webpack 在工作的过程中给每一个环节都埋下了钩子,我们只需要在对应的钩子下挂载任务就可以轻松的扩展 webpack 的能力
自定义的 Plugin 其实就是一个函数,或者包含 apply 的方法的对象
apply 方法接受一个 compiler 对象参数,这个参数包含我们整个构建过程中的所有配置信息,通过这个对象我们可以注册钩子函数,通过 tap 方法注册任务
tap 方法又接受两个参数,一个是插件名称,一个是当前次打包执行的上下文
- 和 loader 区别:loader 是专注实现资源模块加载转化
Plugin 是解决处理资源加载转化之外的的一些自动化工作
相比于 Loader,Plugin 的能力范围更宽
因为 Loader 只是在加载模块的范围工作,而插件的工作范围可以触及到 webpack 的每一个环节
class MyPlugin {
apply(compiler) {
console.log('MyPlugin 启动');
// 这里要做的事情就是在emit钩子上挂载一个任务,这个任务帮我们去除打包后没有必要的注释(mode=none情况下)其他钩子可参考官网
compiler.hooks.emit.tap('MyPlugin', compilation => {
// compilation => 可以理解为此次打包的上下文
for (const name in compilation.assets) {
// console.log(name)
// console.log(compilation.assets[name].source())
if (name.endsWith('.js')) {
const contents = compilation.assets[name].source();
const withoutComments = contents.replace(/\/\*\*+\*\//g, '');
compilation.assets[name] = {
source: () => withoutComments,
size: () => withoutComments.length,
};
}
}
});
}
}
plugins: [new MyPlugin()];
- 增强 webpack 的开发体验
// 不使用Webpack Dev Server情况下,自动监听打包文件的变化
$ yarn webpack --watch
$ http-server -c-1 dist //or $ browser-sync dist --file "**/*"
以上方式效率太低,文件不断的被读写操作,有待优化
-
Webpack Dev Server
编写源代码=> webpack 打包=> 运行应用=> 刷新浏览器
我们可以借助 Webpack Dev Server 来提升开发体验,更接近生产环境的运行状态,同时也可以设置 proxy,对于错误我们可以使用 souceMap 来快速定位源代码问题
$ yarn add webpack-dev-server -D
$ yarm webpack-dev-server --open
webpack-dev-server 并不会将打包结果放到磁盘中,暂时存放到内存中,从临时内存中读取内容发送给浏览器,从而大大提高了效率
- webpackDevServer 的静态资源访问
devServer: {
contentBase: './public', //也可以指定数组标识多个目录
}
- 代理 proxy
代理方式适用于后端没有配置 cors 的情况
如果我们的项目最终上线前后端代码符合同源策略,也就没必要设置 cors 了,这个时候可以通过本地服务器配置代理的方式实现跨域请求
devServer: {
proxy: {
'/api': {
// http://localhost:8080/api/users -> https://api.github.com/api/users
target: 'https://api.github.com',
// http://localhost:8080/api/users -> https://api.github.com/users
pathRewrite: {
'^/api': '' // 根据后端接口文件路劲因情况而定,这里只是用github举例说明
},
// 不能使用 localhost:8080 作为请求 GitHub 的主机名
changeOrigin: true
}
}
}
// main.js;
// 跨域请求,虽然 GitHub 支持 CORS,但是不是每个服务端都应该支持。
// fetch('https://api.github.com/users')
fetch('/api/users') // http://localhost:8080/api/users
.then(res => res.json())
.then(data => {
data.forEach(item => {
const li = document.createElement('li');
li.textContent = item.login;
ul.append(li);
});
});
-
sourceMap
由于编写的代码和运行的代码不一致,sourceMap 帮我们定位源代码错误
webpack 提供了 12 中 sourceMap 方式。每种方式的效果和效率不同,效果最好的,效率最差,效果最差的,效率最高,因此我们只需要实际开发中符合需求的最佳实践
cheap: 定位到行,不定位列
eval: 定位到文件
module: 定位 loader 处理前的源代码
inline: 把 sourcemap 嵌入到打包文件中,不额外生成对应的.map 文件
hidden: 会有错误信息,但是不是源文件。开发第三方包的时候可以用
nosources: 看不到源代码,但是会有行列信息,保护在生产环境中源代码不被暴露
devtool: // 开发环境 'cheap-module-eval-source-map',
// 生产环境 'none',
// 如果对自己上线代码没有信心 'nosources-source-map'
const HtmlWebpackPlugin = require('html-webpack-plugin');
const allModes = [
'eval',
'cheap-eval-source-map',
'cheap-module-eval-source-map',
'eval-source-map',
'cheap-source-map',
'cheap-module-source-map',
'inline-cheap-source-map',
'inline-cheap-module-source-map',
'source-map',
'inline-source-map',
'hidden-source-map',
'nosources-source-map',
];
module.exports = allModes.map(item => {
return {
devtool: item,
mode: 'none',
entry: './src/main.js',
output: {
filename: `js/${item}.js`,
},
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
},
},
},
],
},
plugins: [
new HtmlWebpackPlugin({
filename: `${item}.html`,
}),
],
};
});
- 热更新(HMR)代替自动刷新
自动刷新导致页面状态丢失
热更新就是在页面不跟新的情况下,只将修改的模块实时替换到应用中
$ yarn webpack-dev-server --hot
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development',
entry: './src/main.js',
output: {
filename: 'js/bundle.js',
},
devtool: 'source-map',
devServer: {
hot: true,
// hotOnly: true // 只使用 HMR,不会 fallback 到 live reloading
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
// ...
],
};
默认的 HMR 开启后还需要我们手动去处理热更新的逻辑。当然在 css 文件中由于 cssloader 中已经帮我们处理了,所以我们可以看到修改 css 可以出发热跟新
编写的 js 模块由于代码太过灵活,如果没有框架的约束,wabpack 很难实现通用的热更新
- HMR API
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,与业务代码无关 ============
// console.log(createEditor)
if (module.hot) {
let lastEditor = editor;
// 处理js模块的热更新
module.hot.accept('./editor', () => {
// console.log('editor 模块更新了,需要这里手动处理热替换逻辑')
// console.log(createEditor)
const value = lastEditor.innerHTML;
document.body.removeChild(lastEditor);
const newEditor = createEditor();
// 解决文本框状态丢失
newEditor.innerHTML = value;
document.body.appendChild(newEditor);
lastEditor = newEditor;
});
// 处理img热更新
module.hot.accept('./better.png', () => {
img.src = background;
console.log(background);
});
}
以上例子 只是说明 webpack 没办法提供通用方案。实现一个热更新原理就是利用 module.hot,HotModuleReplacementPluginApi 提供的这个。大部分框架中都集成了 HMR。
- 不同环境的配置文件
// 函数方式配置
const webpack = require('webpack');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
module.exports = (env, argv) => {
const config = {
// ...
};
if (env === 'production') {
config.mode = 'production';
config.devtool = false;
config.plugins = [
...config.plugins,
new CleanWebpackPlugin(),
new CopyWebpackPlugin(['public']),
];
}
return config;
};
文件划分的配置
// webpack.common.js
module.exports = {};
// webpack.dev.js
const common = require('./webpack.common');
const merge = require('webpack-merge'); // 安装webpack-merge合并配置
module.exports = merge(common, {
mode: 'development',
devtool: 'cheap-eval-module-source-map',
devServer: {
hot: true,
contentBase: 'public',
},
plugins: [new webpack.HotModuleReplacementPlugin()],
});
// webpack.prod.js
const merge = require('webpack-merge');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const common = require('./webpack.common');
module.exports = merge(common, {
mode: 'production',
plugins: [new CleanWebpackPlugin(), new CopyWebpackPlugin(['public'])],
});
$ yarn webpack --config webpack.prod.js
$ yarn webpack-dev-server --config webpack.dev.js
- DefinePlugin
为代码注入全局成员
默认注入 process.evn.NODE_ENV 常量
plugins: [
new webpack.DefinePlugin({
// 值要求的是一个代码片段
API_BASE_URL: JSON.stringify('https://api.example.com'),
}),
];
- Tree-shaking
将未引用代码去除掉 生产环境下自动开启
在其他模式下开启需要:
optimization: {
// 模块只导出被使用的成员
usedExports: true,
// 尽可能合并每一个模块到一个函数中
concatenateModules: true, // scope Hoisting
// 压缩输出结果
minimize: true
}
Tree-shaking && babel
由于 Tree-shaking 是基于 ESModule 实现的,但是 旧版本 babel 中如果用到 preset-env 的插件集合的时候会默认开启转化 ESModule 的导入导出语法为 Commonjs 的规范。所以导致 Tree-shaking 失效,新版本已默认关闭sideEffects 新特新
标识代码是否有副作用,为 Tree-shaking 提供更大的压缩空间
// webpack.config.js
optimization: {
sideEffects: true; // 开启sideEffects功能
}
// package.json
"siedEffects": false // 标识代码是否有副作用
副作用需要我们手动添加并且谨慎使用,一般用在开发第三方包中,当我们的代码有副作用,但是却配置了以上两个属性,就会导致程序报错。
// package.json 配置有副作用的文件,这样webpack在打包的过程中就不会忽略这些
"siedEffects" :[
"./src/extend.js",
"*/css"
]
-
Code Splitting
打包成一个文件导致体积过大,加载时间过长。
应用启动的首屏并不是所有模块都工作的
所以我们需要分包,按需加载- 多入口打包
适用于多页面应用
entry: { index: './src/index.js', album: './src/album.js' }, output: { filename: '[name].bundle.js' }, optimization: { splitChunks: { // 自动提取所有公共模块到单独 bundle chunks: 'all' } }, plugins: [ new HtmlWebpackPlugin({ title: '首页', template: './src/index.html', filename: 'index.html', chunks: ['index'] // 对不同的页面指定不同的打包js文件 }), new HtmlWebpackPlugin({ title: '其他页面', template: './src/album.html', filename: 'album.html', chunks: ['album'] }) ]
- 动态导入 import()
适用于单页面应用
在 react 或者 vue 中一般都是通过路由映射组件实现动态加载
webpack 会根据 import()把对应的模块拆分到不同的输出文件,根据加载的需要执行不同的 js 文件
// import posts from './posts/posts' // import album from './album/album' const render = () => { const hash = window.location.hash || '#posts' const mainElement = document.querySelector('.main') mainElement.innerHTML = '' if (hash === '#posts') { // mainElement.appendChild(posts()) import(/_ webpackChunkName: 'components' _/'./posts/posts').then(({ default: posts }) => { mainElement.appendChild(posts()) }) } else if (hash === '#album') { // mainElement.appendChild(album()) import(/_ webpackChunkName: 'components' _/'./album/album').then(({ default: album }) => { mainElement.appendChild(album()) }) } } render() window.addEventListener('hashchange', render)
- 多入口打包
魔法注释
通过在 import 语句中加注释的方式为 webpack 提供打包后的名称如果设置一样则打包到一个文件
if (hash === '#posts') {
// mainElement.appendChild(posts())
import(/* webpackChunkName: 'components' */ './posts/posts').then(
({ default: posts }) => {
mainElement.appendChild(posts());
}
);
} else if (hash === '#album') {
// mainElement.appendChild(album())
import(/* webpackChunkName: 'components' */ './album/album').then(
({ default: album }) => {
mainElement.appendChild(album());
}
);
}
- MiniCssExtractPlugin
提取 css 到单个文件
需要考虑 css 大小,如果很少写 css 那么还是采用 stypeLoader 注入到页面的 style 标签中
module: {
rules: [
{
test: /\.css$/,
use: [
// 'style-loader', // 将样式通过 style 标签注入
MiniCssExtractPlugin.loader, // 将样式通过Link标签方式注入
'css-loader'
]
}
]
},
plugins: [
new MiniCssExtractPlugin()
]
- webpack 内部提供的生产环境的压缩只是针对 JS 代码的。如果想要压缩其他形式资源,需要单独安装对应的插件
optimization: {
minimize: [
// 要使用其他压缩,这里要把默认的js压缩的插件也安装进来,是因为webpack会覆盖了 optimization原有的默认配置
// 这里配置的压缩都只会在生产环境起作用,符合我们的预期,不用再去放到webpack.prod.js或者根据环境变量判断
new TreserWebpackPlugin(),
// 这里以压缩css为例,其他的参见官网
new OptimizeCssAssetsWebpackPlugin()]
}
- 输出文件名称 Hash
一般我们部署前端资源文件的时候,都会开启静态资源缓存,避免每次都请求资源,整体应用的响应速度就会大幅度提升,不过也会有问题,当我们缓存时间设置过长,我们的应用更新过后,浏览器并不会及时更新。这就需要我们在生产环境中需要给文件添加 Hash 值,全新的文件名就是全新的请求,也就避免了上述问题
hash: 只要内容修改,所有文件 hash 都会跟新
contenthash: 文件级别的 hash,当前修改的文件以及被引用的文件 hash 会被动跟新
chunk: 当文件内容改变,只修改当前同类的 hash 值
output: {
filename: '[name]-[contenthash:8].bundle.js'
},
还有一些其他的配置项比如 preformance target externals resolve other option 我们只需要查阅官方文档即可,另外还需要多理解 manifest 和 runtime 这样的 webpack 概念