记一篇小白对webpack从头到尾的理解

298 阅读5分钟

一、为什么要有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(进阶)

准备工作

创建一个项目,包含如下文件及文件夹:

image-20200705215338484

// /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的浏览器是不能运行的,需要使用打包器进行转换才行,马上开撸。

实现模块打包

在开撸前,我们明确下打包的目标和流程:

  1. 找到项目入口(即/src/index.js),并读取其内容;
  2. 分析入口文件内容,递归寻找其依赖,生成依赖关系图;
  3. 根据生成的依赖关系图,编译并生成最终输出代码

/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即是引入模块的相对路径。想要的数据都有,是不是很赞!

image-20200705223745187

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')

输出结果如下:

image-20200705230403954

接下来我们就可以返回一个完整的模块信息了。

在这里,我们顺便通过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');

输出如下:

image-20200705231114976

现在,模块的代码就已经转换成了一个对象,包含模块的绝对路径、依赖以及被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;
};

image-20200705232430595

现在,我们就生成一份完整的依赖表了,接下来,就可以根据这份数据来生成最终的代码了。

3.生成输出代码

在生成代码前,我们先观察一下上面的代码,可以发现里面包含了exportrequire这样的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可以从上面的依赖表中获取到入口的代码。

输出如下:

image-20200706091126005

这就是打包的成果了,不过先不要高兴过头了,这里还有一个大坑。

我们现在生成的代码中引入模块的方式都是基于'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:

image-20200706092931226

最后贴一下完整的代码: :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

文章参考链接:

【值得收藏】前端优化详解以及需要关注的几个点

【前端工程化】篇四 席卷八荒-Webpack(进阶)