一、为什么要有webpack
我个人觉得理解一个东西,需要先知道它为什么存在。写一个普通的html、css、js页面,直接放在浏览器里就是可以执行的,这是因为浏览器能理解js、css、HTML。但是浏览器也仅仅只能理解这些(html,css,目前只到es5),js由js引擎解析,html、css由渲染引擎负责解析并将结果渲染到页面。而webpack做的主要事情之一就是,将浏览器不认识的语法编译成浏览器认识的语法。
稍作思考,webpack是node写的,node是基于v8引擎的js运行环境,同谷歌浏览器的v8引擎,这样一来,webpack本身不也只能理解js吗?答案是yep,确实如此,webpack本身只能理解js。那么它如何做到编译我们平时写的es6+,less等呢?
查了查资料,这些都是loader做的,像es6+转成es5有Babel,Babel也是个js编译器,能让我们放心使用新一代js语法,对应的loader是:babel-loader @babel/core @babel/preset-env。less也有对应的less-loader能够将less转成css,对于webpack来说,css也不是它能理解的,所以要加载css,需要用到css-loader、style-loader。css-loader会处理 import
/ require
() @import
/ url
引入的内容,style-loader 是通过一个JS脚本创建一个style标签,插入到页面。
了解了这些以后,我觉得我大概明白了webpack要做什么以及部分怎么做。但是理论往往跟实践结合才好,所以我去网上找了个小白教程,手写一个简单的webpack,加深对它干了什么的理解。
特此说明:以下理解都是基于webpack4
二、实现一个简单的webpack
这部分我是跟着别人教程写的,自己跟着写一遍才有感觉,哈哈哈哈。
以下教程出处:【前端工程化】篇四 席卷八荒-Webpack(进阶)
准备工作
创建一个项目,包含如下文件及文件夹:
// /src/index.js
import a from './a.js';
console.log(a);
// /src/a.js
import b from './b.js';
const a = `b content: ${b}`;
export default a;
// /src/b.js
const b = 'Im B';
export default b;
现在这样的代码在不支持ESModule的浏览器是不能运行的,需要使用打包器进行转换才行,马上开撸。
实现模块打包
在开撸前,我们明确下打包的目标和流程:
- 找到项目入口(即
/src/index.js
),并读取其内容; - 分析入口文件内容,递归寻找其依赖,生成依赖关系图;
- 根据生成的依赖关系图,编译并生成最终输出代码
/myBundle.js
即为我们的打包器,所有相关的代码也将写在其中,下面开始吧!
1. 获取模块内容
读取入口文件的内容,这个很简单,我们创建一个方法getModuleInfo
,使用fs
来读取文件内容:
// myBundle.js
const fs = require('fs')
const getModuleInfo = file => {
const content = fs.readFileSync(file, 'utf-8')
console.log(content)
}
getModuleInfo('./src/index.js')
毫无疑问,这里会输出index.js
文件的内容,不过它是一堆字符串,我们如何才能知道它依赖了哪些模块呢?有如下两种方式:
- 正则:通过正则匹配'import'关键字来获取相应的文件路径,不过太麻烦了,还不可靠,万一代码里面有个字符串也有这些内容呢?
- babel:可以通过
@babel/parser
来将代码转换成AST(抽象语法树,Abstract Syntax Tree, 简称AST),再来分析AST查找依赖。看起来这种比较靠谱。
毫无疑问,使用第二种方式。
npm i @babel/parser ## 安装 @babel/parser
// myBundle.js
const fs = require('fs')
const parser = require('@babel/parser')
const getModuleInfo = file => {
const content = fs.readFileSync(file, 'utf-8')
const ast = parser.parse(content, {
sourceType: 'module' // 解析ESModule须配置
})
console.log(ast)
console.log(ast.program.body)
}
getModuleInfo('./src/index.js')
转换结果如下,可以看到一共两个节点,type
属性标识了节点的类型,ImportDeclaration
即对应了import
语句,而其source.value
即是引入模块的相对路径。想要的数据都有,是不是很赞!
2. 生成依赖关系表
有了上一步的数据,我们需要将它们生成一份结构化的依赖表,方便后续对代码处理。
其实就是遍历ast.program.body
,将其中的ImportDeclaration
类型的节点提取出来,并存入依赖表。
这里也不需要自己手动实现细节,直接使用@babel/traverse
即可。
npm i @babel/traverse ## 安装@babel/traverse
getModuleInfo
方法做如下修改:
// myBundle.js
const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const getModuleInfo = (file) => {
const content = fs.readFileSync(file, 'utf-8');
const ast = parser.parse(content, {
sourceType: 'module'
});
const dependencies = {}; // 用于存储依赖
traverse(ast, {
ImportDeclaration({ node }) { // 只处理ImportDeclaration类型的节点
const dirname = path.dirname(file);
const newFile = '.'+ path.sep + path.join(dirname, node.source.value); // 此处将相对路径转化为绝对路径,
dependencies[node.source.value] = newFile;
}
});
console.log(dependencies);
};
getModuleInfo('./src/index.js')
输出结果如下:
接下来我们就可以返回一个完整的模块信息了。
在这里,我们顺便通过babel
的工具(``@babel/core,
@babel/preset-env`)将代码转换成ES5的语法。
npm i @babel/core @babel/preset-env ## 安装@babel/core @babel/preset-env
// myBundle.js
const fs = require('fs');
const path = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const babel = require('@babel/core');
const getModuleInfo = (file) => {
const content = fs.readFileSync(file, 'utf-8');
const ast = parser.parse(content, {
sourceType: 'module'
});
const dependencies = {};
traverse(ast, {
ImportDeclaration({ node }) {
const dirname = path.dirname(file);
const newFile = '.'+ path.sep + path.join(dirname, node.source.value);
dependencies[node.source.value] = newFile; // 使用文件相对路径为key,绝对路径为value
}
});
const { code } = babel.transformFromAst(ast, null, {
presets: ['@babel/preset-env']
});
const moduleInfo = { file, dependencies, code };
console.log(moduleInfo);
return moduleInfo;
};
getModuleInfo('./src/index.js');
输出如下:
现在,模块的代码就已经转换成了一个对象,包含模块的绝对路径、依赖以及被babel转换后的代码,不过上面只处理了index.js
的依赖,a.js
的依赖并没有进行处理,所以并不是一份完整的依赖表,我们需要进一步处理。
其实也很简单,就是从入口开始,每个模块及其依赖都调用一下getModuleInfo
方法进行分析,最终就会返回一个完整的依赖表(也有人叫依赖图,dependency graph)。
我们直接新写一个方法来处理:
// myBundle.js
const generDepsGraph = (entry) => {
const entryModule = getModuleInfo(entry);
const graphArray = [ entryModule ];
for(let i = 0; i < graphArray.length; i++) {
const item = graphArray[i];
const { dependencies } = item;
if(dependencies) {
for(let j in dependencies) {
graphArray.push(
getModuleInfo(dependencies[j])
);
}
}
}
const graph = {};
graphArray.forEach(item => {
graph[item.file] = {
dependencies: item.dependencies,
code: item.code
};
});
return graph;
};
现在,我们就生成一份完整的依赖表了,接下来,就可以根据这份数据来生成最终的代码了。
3.生成输出代码
在生成代码前,我们先观察一下上面的代码,可以发现里面包含了export
和require
这样的commonjs
的语法,而我们的运行环境(这里是浏览器)是不支持这种语法的,所以还需要自己来实现一下这两个方法。先贴上代码,再慢慢道来:
新建一个build方法,用来生成输出的代码。
// myBundle.js
const build = (entry) => {
const graph = JSON.stringify(generDepsGraph(entry));
return `
(function(graph){
function require(module) {
var exports = {};
return exports;
};
require('${entry}')
})(${graph});
`;
};
const code = build('./src/index.js');
说明:
- 第三行
JSON.stringify
是将数据字符串化,否则在下面的立即执行函数中接收到的将是[object object]
,因为下面是在字符串模板中使用,会发生类型转换。 - 返回的代码包裹在IIFE(立即执行函数)中是防止模块间作用域相互污染。
require
函数需要定义在输出的内容中,而不是当前的运行环境中,因为它会在生成的代码中执行。
接下来,我们需要拿到入口文件的code,并使用eval
函数来执行它:
// myBundle.js
const build = (entry) => {
const graph = JSON.stringify(generDepsGraph(entry));
return `
(function(graph){
function require(module) {
var exports = {};
(function(require, exports, code){
eval(code)
})(require, exports, graph[module].code);
return exports;
};
require('${entry}')
})(${graph});
`;
};
const code = build('./src/index.js');
console.log(code);
说明:
- 为了防止code中的代码和我们这里(return的字符串中)作用域有冲突,我们还是使用IIFE包裹,并将需要的参数传递进去。
graph[module].code
可以从上面的依赖表中获取到入口的代码。
输出如下:
这就是打包的成果了,不过先不要高兴过头了,这里还有一个大坑。
我们现在生成的代码中引入模块的方式都是基于'index.js'的相对路径,如果在其他模块引入的模块路径和相较于index.js
不一致的时候,对应的模块就会找不到(路径不正确),所以我们还要处理一下模块的路径。好在前面依赖表的dependencies
属性里面记录了模块的绝对路径,只需要拿出来使用即可。
添加一个localRequire
函数,用来从dependencies
中获取模块绝对路径。
// myBundle.js
const build = (entry) => {
const graph = JSON.stringify(generDepsGraph(entry));
return `
(function(graph){
function require(module) {
function localRequire(relativePath) {
return require(graph[module].dependencies[relativePath]);
}
var exports = {};
(function(require, exports, code){
eval(code)
})(localRequire, exports, graph[module].code);
return exports;
};
require('${entry}')
})(${graph});
`;
};
接下来,将输出的代码写入到文件就可以了。
// myBundle.js
const code = build('./src/index.js')
fs.mkdirSync('./dist')
fs.writeFileSync('./dist/bundle.js', code)
最后,在html中引入一下,测试一下是否能够正常运行。没有疑问,肯定是可以正常运行的。:smile::smile:
最后贴一下完整的代码: :cow::beers:
// myBundle.js
const fs = require('fs');
const path = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const babel = require('@babel/core');
const getModuleInfo = (file) => {
const content = fs.readFileSync(file, 'utf-8');
const ast = parser.parse(content, {
sourceType: 'module'
});
const dependencies = {};
traverse(ast, {
ImportDeclaration({ node }) {
const dirname = path.dirname(file);
const newFile = '.'+ path.sep + path.join(dirname, node.source.value);
dependencies[node.source.value] = newFile;
}
});
const { code } = babel.transformFromAst(ast, null, {
presets: ['@babel/preset-env']
});
const moduleInfo = { file, dependencies, code };
return moduleInfo;
};
const generDepsGraph = (entry) => {
const entryModule = getModuleInfo(entry);
const graphArray = [ entryModule ];
for(let i = 0; i < graphArray.length; i++) {
const item = graphArray[i];
const { dependencies } = item;
if(dependencies) {
for(let j in dependencies) {
graphArray.push(
getModuleInfo(dependencies[j])
);
}
}
}
const graph = {};
graphArray.forEach(item => {
graph[item.file] = {
dependencies: item.dependencies,
code: item.code
};
});
return graph;
};
const build = (entry) => {
const graph = JSON.stringify(generDepsGraph(entry));
return `
(function(graph){
function require(module) {
function localRequire(relativePath) {
return require(graph[module].dependencies[relativePath]);
}
var exports = {};
(function(require, exports, code){
eval(code)
})(localRequire, exports, graph[module].code);
return exports;
};
require('${entry}')
})(${graph});
`;
};
const code = build('./src/index.js');
fs.mkdirSync('./dist');
fs.writeFileSync('./dist/bundle.js', code);
在完成这个案例之前,webpack就像一位高贵的美人,让人敬而远之。而通过这个案例,我们撕下了她神秘的外衣,发现里面原来如此美妙,是不是美不胜收。当然实际不能如此简单,要去处理各种边界情况,还要支持loader和plugin,美人还是有点东西的😍。
了解了这么多webpack基本知识,自然还是逃不过它在实际项目和面试中遇到的,最后我们讨论讨论webpack的性能优化
三、webpack性能优化
**优化webpack的性能,就是让webpack少做点事以及做最直接的事。**我们先看看webpack整体流程,然后再看看能从哪些方面优化。
- 初始化:启动构建,读取与合并配置参数,加载 Plugin,实例化 Compiler
- 编译:从 Entry 发出,针对每个 Module 串行调用对应的 Loader 去翻译文件内容,再找到该 Module 依赖的 Module,递归地进行编译处理
- 输出:对编译后的 Module 组合成 Chunk,把 Chunk 转换成文件,输出到文件系统
首先从初始化出发,我们秉着让webpack少做点事的原则可以对基础配置下点功夫,例如:
-
extensions:这个配置表示webpack会根据extensions去寻找文件后缀名,所以如果我们的项目主要用ts写的话,那我们就可以.tsx和.ts写前面,目的是为了让webpack能够快速解析
resolve: { extensions: ['.ts', '.tsx', '.js'] }
-
alias:这个配置是用来映射路径,让webpack快速的解析文件路径,找到对应的文件,也能够减少打包时间
resolve: {
alias: {
Components: path.resolve(__dirname, './src/components')
}
}
- noParse:noParse表示不需要解析的文件,有的文件可能是来自第三方的文件,被 providePlugin引入作为windows上的变量来使用,这样的文件相对比较大,并且已经是被打包过的,所以把这种文件排除在外是很有必要的,配置如下
module: {
noParse: [/proj4\.js/]
}
-
exclude:对于某些loader指定exclude,即缩小它的范围,也是能够减少打包时间的
{ test: /.js$/, loader: "babel-loader", exclude: path.resolve(__dirname, 'node_modules') }
-
devtool:这个配置是一个调试项,不同的配置展示效果不一样,打包大小和打包速度也不一样,在开发环境和生产环境配置合适的值也是非常必要的
{ devtool: 'cheap-source-map' }
- .eslintignore:这个不是webpack的配置,但是eslint对打包的影响蛮大的,排除一些不需要进行eslint检查的文件,也是能够加快打包时间的
node_modules/
test/
mock/
...
接着我们从编译出发,看看可以如何减少编译时间。上面其实已经说到了部分,下面我们再来从其他角度思考思考(比如缓存,多进程,压缩等)。
- 对于不常更新的第三方包,我们希望第一次编译后就能缓存起来,后续构建直接使用这些文件,避免重复构建。可以用AutoDllPlugin:集合了DllPlugin(生成资源动态链接库)+DllReferencePlugin(将对应资源映射到这些动态链接库)的功能,使用代码如下:
npm install --save-dev autodll-webpack-plugin
// build/webpack.base.conf.js
plugins: [
//...
new AutoDllPlugin({
inject: true,
filename: '[name].js',
entry: {
vendor: [
'vue'
]
}
})
]
- 对于一些性能开销较大的 loader ,我们可以也用缓存的方式来处理。使用cache-loader将结果缓存到磁盘里,以减少重新构建文件的数量,也是能有效提升构建速度的。
npm install cache-loader -D
// build/webpack.base.conf.js
module.exports = {
module: {
rules: [{
test: /\.jsx?$/,
use: ['cache-loader','babel-loader'] // 仅需将cache-loader放在需要缓存的loader前面就行了
}]
}
}
- 在js代码里,遇到复杂的计算,我们会想着在web worker中去进行计算,即多开一个线程。同理,webpack编译的时候对于复杂耗时的loader可不可以多开一个进程去做呢,答案是可以的,我们可以用thread-loader开启并行构建,将非常消耗资源的 loader 分配给一个 worker进程,从而减少主进程的性能开销。
npm install thread-loader -D
// build/webpack.base.conf.js
module.exports = {
module: {
rules: [
{
test: /\.jsx?$/,
use: ['thread-loader', 'babel-loader'] // 仅需在对应的loader前面添加thread-loader即可
}
]
}
}
- 我们知道文件越小渲染的速度是越快的,所以我们在配置webpack时候经常会用到压缩,但是压缩也是需要消耗时间的,我们可以用webpack4推荐的terser-webpack-plugin来开启并行压缩,减少压缩时间。
optimization: {
minimizer: [
new TerserPlugin({
parallel: true,
cache: true
})
],
}
其它还有一些优化手段,比如:
-
使用CDN,将一些第三方依赖改用CDN引入,也是常用的优化手段,因为一般第三方模块不会像业务代码一般频繁更新,使用CDN后,客户端会缓存这些资源,提升应用加载速度
-
代码分割
-
入口chunk分割:使用entry配置多个chunk,手动分离代码。
-
提取公用代码:
SplitChunksPlugin
去重和分离 chunk。 -
动态导入与按需加载:通过模块中的内联函数调用来分离代码,目前推荐使用
import()
语法 -
tree shaking
四、总结
总结下以上说的webpack优化,主要是从代码变小(比如开启压缩、提取公用代码、按需加载)和缓存(比如AutoDllPlugin或者CDN、cache-loader等)来进行优化的,webpack的作用是将所有的静态资源合并好来减少io请求和将浏览器不认识的语法编译成浏览器认识的语法,如何更好的做这两个事是需要不断地进行思考的,以及我觉得也不要固定网上已有的这些优化方式,反正目的在那,如何做是人人都可以有思考的hhhh
文章参考链接: