SourceMap
在之前的几章里面,我们介绍了webpack的打包编译功能。能通过loader、plugin机制帮助我们构建代码。虽然构建代码很大程度上便利了我们的生产环境,但是我们实际的代码和我们开发的代码会有很大的差异,当出现错误的时候,我们很难直接定位到源代码中对应的位置。
而Souce Map(源代码地图)就是一个映射转化后的代码与源代码之间的关系。我们可以看到很多第三方的库的dist文件中都会包含.map文件
我们点开来看一下
在浏览器中打开开发者工具后,会自动请求.map文件,然后根据这个文件的内容逆向解析出源代码来,便于调试。同时因为有了映射关系,所以代码中如果出现了错误也就能自动定位找到源代码中的位置了。
Webpack中配置 SourceMap
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
module.exports = {
mode: 'none',
entry: './src/index.js',
+ devtool: 'source-map', // source map 设置
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist')
},
plugins: [
new HtmlWebpackPlugin({
title: 'My App',
template: './src/index.html'
}),
new webpack.HotModuleReplacementPlugin()
],
}
查看结果,生成了.map文件
其实source-map的模式有很多种,devtool属性可选的属性非常多官网
下面我想简单的介绍几个sourceMap属性
eval
首先是 eval 模式,它就是将模块代码放到 eval 函数中执行,并且通过 sourceURL 标注所属文件路径,在这种模式下没有 Source Map 文件,所以只能定位是哪个文件出错
eval-source-map
这个模式也是使用 eval 函数执行模块代码,不过这里有所不同的是,eval-source-map 模式除了定位文件,还可以定位具体的行列信息。相比于 eval 模式,它能够生成 Source Map 文件
cheap-eval-source-map
根据这个模式的名字就能推断出一些信息,它就是在 eval-source-map 基础上添加了一个 cheap,也就是便宜的,或者叫廉价的。用计算机行业的常用说法,就是阉割版的 eval-source-map,因为它虽然也生成了 Source Map 文件,但是这种模式下的 Source Map 只能定位到行,而定位不到列
cheap-module-eval-source-map
接下来再看一个叫作 cheap-module-eval-source-map 的模式。慢慢地我们就发现 Webpack 中这些模式的名字不是随意的,好像都有某种规律。这里就是在 cheap-eval-source-map 的基础上多了一个 module
Tree Shaking
Tree shaking 翻译过来就是“摇树”,伴随着摇树的动作,树上的叶子就会掉下来。在我们的代码中,通过Tree shaking能够帮助我们“摇掉”没有使用到的代码,或者说未引用的代码。
Tree shaking 最早是Rollup提出的,Webpack从2.0过后开始支持这个特性。
我们使用 Webpack 生产模式打包的优化过程中,就使用自动开启这个功能,以此来检测我们代码中的未引用代码,然后自动移除它们。
我们先用webpack的production模式来体验一下。
└─ 02-configuation
├── src
│ ├── component.js
│ └── index.js
└── index.html
// component.js
export function isString(value) {
return typeof value === 'string';
}
export function debounce(action, delay, maxDelay) {
var statTime = 0;
var timer = null;
return function () {
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
args[_key] = arguments[_key];
}
if (statTime === 0) {
statTime = new Date().getTime();
}
window.clearTimeout(timer);
if (maxDelay && new Date().getTime() - statTime >= maxDelay) {
statTime = 0;
action.apply(void 0, args);
} else {
timer = setTimeout(function () {
statTime = 0;
action.apply(void 0, args);
}, delay);
}
};
}
// index.js
import { isString } from './component'
console.log('isString()', isString());
// webpack.config.js
+ mode:'production'
查看打包结果,发现debounce并没有在bundle.js中
开启Tree shaking
需要注意的是,Tree-shaking 并不是指 Webpack 中的某一个配置选项,而是一组功能搭配使用过后实现的效果,这组功能在生产模式下都会自动启用,所以使用生产模式打包就会有 Tree-shaking 的效果。
假设我们在mode为none的情况下,需要对webpack加上optimization属性:
optimization: {
// 模块只导出被使用的成员
usedExports: true,
// 尽可能合并每一个模块到一个函数中
concatenateModules : true,
// 压缩输出结果
minimize: true
}
- usedExports - 打包结果中只导出外部用到的成员;
- minimize - 压缩打包结果。
- concatenateModules - 尽可能合并每一个模块到一个函数中
如果把我们的代码看成一棵大树,那你可以这样理解:
- usedExports 的作用就是标记树上哪些是枯树枝、枯树叶;
- minimize 的作用就是负责把枯树枝、枯树叶摇下来。
- concatenateModulesd 的作用就是将所有的树种在一起减少维护成本
sideEffects
Webpack 4 中新增了一个 sideEffects 特性,它允许我们通过配置标识我们的代码是否有副作用,从而提供更大的压缩空间。
副作用就是指模块代码执行的时候除了导出成员,是否还做了其他的事情
比如我们将代码中的button,head模块提取出来,然后在index.js中统一导出
└─ 12-webpack-sideEffects
├── src
│ ├── components
├ ├ ├──head.js
├ ├ ├──button.js
├ ├ ├──index.js
└── index.html
└── index.js
// button.js
console.log('Button component~') // 副作用代码
export default () => {
return document.createElement('button')
}
// head.js
console.log('Head component~') // 副作用代码
export default () => {
return document.createElement('head')
}
// src/components/index.js
export { default as Button } from './button'
export { default as Heading } from './head'
// src/index.js
import { Button } from './components'
document.body.appendChild(Button())
当我们不做任何处理的时候,运行webpack打包,具体结果如下
所有的组件模块都被打包进了bundle.js
此时如果我们开启Tree Shaking(只设置useExports),这里没有用的导出成员最终会被移除,查看结果
但是!!!当我们开启minimize时,发现副作用代码并没有被删除
所以说,Tree-shaking 只能移除没有用到的代码成员,而想要完整移除没有用到的模块,那就需要开启 sideEffects 特性了。
开启sideEffects
- 首先打开webpack的配置文件,在optimization中设置sideEffects为true,开启sideEffects特性
- 在webpack打包某个模块之前会先去检查模块所属的package.json中的sideEffects标识,以此来判断这个模块是否有副作用,如果没有副作用的话,这些没有用到的模块就不会再被打包。所以我们需要在
12-webpack-sideEffects
这个项目的根目录下的package.json文件中添加一个sideEffects字段,并把它设置为false
{
"name": "12-webpack-sideEffects",
"version": "1.0.0",
"main": "index.js",
"devDependencies": {
"clean-webpack-plugin": "^3.0.0",
"html-webpack-plugin": "^3.2.0",
"webpack": "^4.40.2",
"webpack-cli": "^3.3.9",
"webpack-dev-server": "^3.8.2"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "webpack-dev-server --open"
},
"author": "",
"license": "ISC",
"description": "",
"sideEffects": false
}
再次运行webpack打包,查看bundle.js
现在,我们代码中的模块如果没有被引用,就算有副作用代码,也不会被打包进来了。
Code Splitting
ALL in One的弊端
通过Webpack实现前端项目整体模块化的优势固然明显,但是它也会存在一些弊端:它最终会将我们所有的代码打包到一起。这个时候,如果我们的应用非常复杂,模块非常多,那么这种All in One的方式就会导致打包的结果过大。
虽然我们之前讲到Webpack的作用就是把项目中散落的代码打包到一起,从而提高加载效率,那么为什么又分离?其实并不矛盾,Web应用中的资源收环境限制,往往都是需要我们着重考虑的问题。
如果我们不将这些资源模块打包,直接按照开发过程中划分的模块颗粒度进行加载,那么运行一个小小的功能,就需要加载非常多的资源模块。
再者,目前主流的 HTTP 1.1 本身就存在一些缺陷,例如:
- 同一个域名下的并行请求是有限制的;
- 每次请求本身都会有一定的延迟;
- 每次请求除了传输内容,还有额外的请求头,大量请求的情况下,这些请求头加在一起也会浪费流量和带宽。
为了解决打包结果过大导致的问题,Webpack 设计了一种分包功能:Code Splitting(代码分割)。
动态导入
Code Splitting 更常见的实现方式是结合 ES Modules 的动态导入特性,从而实现按需加载。
按需加载是开发浏览器应用中一个非常常见的需求。一般我们常说的按需加载指的是加载数据或者加载图片,但是我们这里所说的按需加载,指的是在应用运行过程中,需要某个资源模块时,才去加载这个模块。这种方式极大地降低了应用启动时需要加载的资源体积,提高了应用的响应速度,同时也节省了带宽和流量。
Webpack 中支持使用动态导入的方式实现模块的按需加载,而且所有动态导入的模块都会被自动提取到单独的 bundle 中,从而实现分包。
相比于多入口的方式,动态导入更为灵活,因为我们可以通过代码中的逻辑去控制需不需要加载某个模块,或者什么时候加载某个模块。而且我们分包的目的中,很重要的一点就是让模块实现按需加载,从而提高应用的响应速度。下面我们来看一个例子
└─ 13-webpack-code-splitting
├── src
│ ├── button
│ │ ├── index.js
│ ├── head
│ │ ├── index.js
└── index.js
└── index.html
└── package.json
└── webpack.config.js
// button/index.js
const button = () => {
const btn = document.createElement('button');
btn.style.width = '100px';
btn.style.height = '50px';
btn.style.background = 'red';
document.body.appendChild(btn)
};
export default button;
// head/index.js
const head = () => {
const head = document.createElement('input');
head.style.width = '100px';
head.style.width = '100px'
head.style.background = 'green';
document.body.appendChild(head);
};
export default head;
// index.js
const update = () => {
const hash = window.location.hash || '#button'
if (hash === '#button') {
import('./button').then(({default: button}) => {
button();
console.log('加载button成功');
})
} else if (hash === '#head') {
import('./head').then(({default: head}) => {
head()
console.log('加载head成功');
})
}
}
window.addEventListener('hashchange', update)
update();
可以看到,我们的bundle1和bundle2并没有一次性加载,而是实现了按需加载
魔法注释
默认通过动态导入产生的 bundle 文件,它的 name 就是一个序号,这并没有什么不好,因为大多数时候,在生产环境中我们根本不用关心资源文件的名称。
但是如果你还是需要给这些 bundle 命名的话,就可以使用 Webpack 所特有的魔法注释去实现。具体方式如下:
// 魔法注释
import(/* webpackChunkName: 'posts' */'./posts/posts')
.then(({ default: posts }) => {
mainElement.appendChild(posts())
})
所谓魔法注释,就是在 import 函数的形式参数位置,添加一个行内注释,这个注释有一个特定的格式:webpackChunkName: ‘’,这样就可以给分包的 chunk 起名字了。
完成过后,我们再次打开命令行终端,运行 Webpack 打包,那此时我们生成 bundle 的 name 就会使用刚刚注释中提供的名称了,具体结果如下:
除此之外,魔法注释还有个特殊用途:如果你的 chunkName 相同的话,那相同的 chunkName 最终就会被打包到一起,例如我们这里可以把这两个 chunkName 都设置为 components,然后再次运行打包,那此时这两个模块都会被打包到一个文件中。
结束语
好了,对于webpack的分享就告一段落,之后准备写一篇关于对之前项目优化的文章(如果我能做到的话,先立个flag 哈哈!)