前言
近日看了直播课里面讲的webpack实现思路,自觉受益匪浅,因此把里面的内容半搬运过来写成博客与大家一起分享,在这个过程自己也手敲一下代码加深印象,本文适用于对webpack有初步了解的人群,话不多说,开整?
前置知识
webpack的作用就是对模块进行打包处理,因此以下内容给对模块化概念还不了解的同学做一个简单介绍,已经对这部分内容熟悉的可直接跳过至下一章节?
Js中的模块概念
模块通常是指编程语言所提供的代码组织机制,利用此机制可将程序拆解为独立且通用的代码单元。
- 模块化的出现解决了什么问题
模块化的出现解决了代码分割、作用域隔离、模块之间的依赖管理以及发布到生产环境时的自动化打包与处理等多个问题,试想一下,脱离了模块化开发方式,我们的代码组织将会非常令人头痛,使用文件同时还得考虑其内部错综复杂的依赖关系。
2.模块化规范?
在Es6之前,AMD/CMD/CommonJs
是JS模块化开发的标准,对应的实现是RequireJs/SeaJs/nodeJs
. CommonJs主要针对服务端,AMD/CMD主要针对浏览器端。最常见的在node模块中,采用的是CommonJS规范,暴露模块使用module.exports和exports,而对应有一个全局性方法require用于加载模块。
现阶段的标准ES module
ES6标准发布后,module成为标准,标准使用是以export指令导出接口,以import引入模块,很好的取代了之前的commonjs和AMD规范,成为了浏览器和服务器的通用的模块解决方案。
存在兼容性问题,需要支持es6,通常使用babel将es6编译成es5语法向下兼容。
webpack简介
本质上,webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle。
先介绍一下webpack中的核心概念
- 入口(entry) webpack构建时的入口文件,从这里开始建立依赖关系,可以是一个或多个入口
- 输出(output) 指定webpack的打包产物bundle的纯输出目录,同样也有一个或者多个
- loader 负责将webpack不能识别的文件类型(js除外)转换为 webpack 能够处理的有效模块
- 插件(plugins) webpack强大的根源,在构建的不同阶段会广播出对应的钩子事件,监听这些事件就可以对打包过程做高度自定义处理,包括,从打包优化和压缩,一直到重新定义环境中的变量。
实现mini-webpack
根据上文的介绍,我们梳理出一个基本的打包器应该帮我们处理哪几件事:
- 根据配置文件找到打包入口
- 解析入口文件,收集他的依赖
- 递归地寻找依赖的关系,建立文件间的依赖图
- 把所有文件打包成一个文件
基本配置
先初始化一个项目:
npm init -y
复制代码
创建src目录,在src目录下新建文件module-1.js,module-2.js,module-3.js如下:
// module-1.js
import res from './module-2'
console.log(`mini-webpack: ${res}`);
复制代码
// module-2.js
import module3 from './module3'
let res = `module2 import ${module3} from module-3.js`
export default res
复制代码
// module-3.js
export default 'hello world
复制代码
可以很轻易地看出依赖关系如下:
module1 -> module2 -> module3
新建文件mini-webpack-config.js
作为我们mini-webpack的配置文件,结构就借鉴正版webpack:
const path = require("path");
module.exports = {
entry: "./src/module-1.js",
output: {
path: path.join(__dirname, "./dist"),
filename: "bundle.js",
},
};
复制代码
解析配置,得到entry入口
新建文件mini-webpack.js
// mini-webpack.js
const fs = require("fs");
const path = require("path");
// 读取配置
const config = require("./mini-webpack-config.js");
// 主函数
function main() {
let absPath = path.resolve(__dirname, config.entry);
let entry = fs.readFileSync(absPath, "utf-8");
console.log(entry);
}
main();
复制代码
这一步比较简单,用node文件模块帮我们读取entry入口内容
分析ast,抽离依赖
第一步,如何从文件内容中知道我们引用了哪些模块?
这里用了解析ast tree的方法, 是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。(其实用正则是否也可行呢 ?)
可以看到: ImportDeclaration对象的source属性的value就是我们要找的依赖。
我们可以借助@babel/parser
将代码编译成ast
npm i @babel/parser
复制代码
const babelParser = require("@babel/parser");
function main() {
let absPath = path.resolve(__dirname, config.entry);
let content = fs.readFileSync(absPath, "utf-8");
console.log(
babelParser.parse(content, {
// 指示解析代码的模式,默认是script 需要指定为模块module
sourceType: "module
})
);
}
复制代码
这样便能得到代码在ast树上的结构:
第二步,需要对节点的body内容进行遍历,找到上文所说的ImportDeclaration
对象,从而拿到文件依赖关系,可借助babel为我们提供的工具@babel/traverse
// 安装
npm i @babel/traverse
-------------------------------
const traverse = require("@babel/traverse").default;
function main() {
let entry = config.entry;
let content = fs.readFileSync(entry, "utf-8");
// 依赖存储
let dependecies = []
let ast = babelParser.parse(content, {
sourceType: "module", //指示应该在其中解析代码的模式。带有ES6导入和导出的文件被认为是“模块”,否则就是“脚本”。
});
traverse(ast, {
ImportDeclaration: ({ node }) => {
dependecies.push(node.source.value)
},
});
// --> ['./module-2.js']
console.log(dependecies);
}
复制代码
考虑到复用性,我们把主函数内容封装一下,函数collectDependencies
根据传入文件名返回依赖对象,增加一个递增的id属性.
// 主函数
function main() {
let entry = config.entry;
collectDependencies(entry)
}
/**
* 根据传入文件找到依赖
* @param {*} filename
* @returns
*/
function collectDependencies(filename) {
let code = fs.readFileSync(filename, "utf-8");
let dependecies = [];
let ast = babelParser.parse(code, {
sourceType: "module", //指示应该在其中解析代码的模式。带有ES6导入和导出的文件被认为是“模块”,否则就是“脚本”。
});
traverse(ast, {
ImportDeclaration: ({ node }) => {
// console.log(node);
dependecies.push(node.source.value);
},
});
let id = ID++
return {
id,
filename,
dependecies
}
}
复制代码
构建依赖图
当前我们已经获取了module1的依赖(module2),所以接下来要做的事情是:寻找依赖(module2)的依赖(module3),构建依赖图,用allAsset
表示依赖图,遍历allAsset
,并且将每次循环找出来的依赖推入allAsset
,这样获得的就是完整的依赖关系
- 需要注意因为我们import使用的是相对路径,dependecies里的路径是相对于module-1的路径,和我们当前的文件mini-webpack.js不在一个目录下的,需要一层路径转换处理。
- 新增一个属性mapping存储路径与依赖的id之间的映射关系,后续打包生成bundle有用
// 主函数
function main() {
let entry = config.entry;
let mainAsset = collectDependencies(entry);
//构建依赖图
let graph = createDependGraph(mainAsset);
}
/**
* 入口的依赖
* @param {*} mainAsset
* @returns
*/
function createDependGraph(mainAsset) {
let allAsset = [mainAsset];
let i = 0;
while (i < allAsset.length) {
let asset = allAsset[i];
let dirname = path.dirname(asset.filename);
asset.mapping = {};
asset.dependecies.forEach((relativePath) => {
let absPath = path.join(dirname, relativePath);
let childAsset = collectDependencies(absPath);
asset.mapping[relativePath] = childAsset.id;
allAsset.push(childAsset);
});
i++;
}
return allAsset;
}
复制代码
获得输出如下:
ast转换es5
最终我们的目标是要把多个文件打包成一个文件,所以需要先获得文件里面的内容。而前文已经提到了,我们代码里面的import要被浏览器识别需要经过babel转译,它可以帮你将 ECMAScript 2015+ 版本的代码转换为向后兼容的 JavaScript 语法,这个过程是通过修改ast实现的。
需要安装两个工具包babel-core
和babel-preset-env
,对collectDependencies
函数稍作修改:
npm i babel-core babel-preset-env
-------------------------------------------分割线
const core = require("babel-core");
function collectDependencies(filename) {
let content = fs.readFileSync(filename, "utf-8");
let dependecies = [];
const ast = babaylon.parse(content, {
//指定解析代码的模式,默认‘script’,带有ES6导入和导出的文件被认为是“模块”
sourceType: "module",
});
traverse(ast, {
ImportDeclaration: ({ node }) => {
dependecies.push(node.source.value);
},
});
// ast --> es5
let { code } = core.transformFromAst(ast, null, {
presets: ["env"],
});
let id = ID++;
return {
id,
filename,
dependecies,
code,
};
}
复制代码
看看当前返回的是个啥:
似曾相识的感觉,有require,有module,exports…是不是很像commonjs模块化规范?其实项目中本质就是使用babel将es6转码为es5再执行,import会被转码为require。
打包成bundle
这一步不是特别很好理解,我是对照着原版webpack打包后的bundle反推回来的。
先思考一下bundle函数需要做到的事情:
- 首先我们需要返回一个字符串代码块
- 代码块要需要可以自动执行
- 需要运行每个依赖模块的code
上文提到将import转码为require,但是浏览器端本身不会提供require,exports,module,所以我们需要一个函数提供入参,将编译后的代码代码作为模块对应这个函数的函数体执行,外部提供这三个参数传入。
此时需要构造模块与这个函数间的对应关系,上面的模块id可以作为标识。
(webpack使用了文件路径做标识,key为模块路径,value为模块的可执行函数,用文件路径就不需要mapping和localRequire,更加直观,不过要统一路径格式,只要能通过require找到可执行函数就行)
代码如下:
// 主函数
function main() {
let entry = config.entry;
let mainAsset = collectDependencies(entry);
//构建依赖图
let graph = createDependGraph(mainAsset);
//输出
let res = bundle(graph);
//输出目录
if (!fs.existsSync(config.output.path)) {
fs.mkdirSync(config.output.path);
}
// 输出文件
let opath = path.join(config.output.path, config.output.filename);
if (fs.existsSync(opath)) {
fs.unlinkSync(opath);
}
let ws = fs.createWriteStream(opath, {
encoding: "utf-8",
});
ws.write(res);
}
function bundle(graph) {
let modules = "";
graph.forEach((module) => {
// 遍历依赖图,构造模块与这个函数间的对应关系
modules += `
${module.id}: [
function(require,module,exports){
${module.code}
},
${JSON.stringify(module.mapping)}
],
`;
});
let res = `
(function(modules){
function require(id) {
// fn:执行模块代码 给exports赋值
// mapping: {相对路径:依赖id } 映射
let [fn,mapping] = modules[id]
// 作用:相对路径转成模块id
// 原因:代码内是require('相对路径')的形式
function localRequire(relativePath) {
return require(mapping[relativePath])
}
let module = {exports:{}}
// 在fn内部modul.exports被重新赋值了
fn(localRequire,module,module.exports)
// 这就是模块的导出
return module.exports
}
// id-0对应entry
return require(0)
})({${modules}})
`;
return res;
}
复制代码
最终成功在dist目录下生成bundle.js,在html中引入bundles.js执行测试,输出了预期的结果。
打包优化
引入uglify-js对打包文件压缩,新增build命令方便打包
npm i uglify-js -g
-------------------------------
// package.json
"scripts": {
"build": "node mini-webpack.js && uglifyjs ./dist/bundle.js -m -o ./dist/bundle.js"
},
复制代码
可以看到代码压缩成一行,减小了体积,并且隐藏了变量名,增强了源码安全性。webpack还考虑到了模块的缓存和按需加载等场景,这是我们可以学习优化的地方
后记
本文介绍了webpack对模块进行打包的原理,从0到1地完成了一个最小化的mini-webpack,虽然功能上还有许多不完善,但是重在学习分析解决问题的思路,在这个过程也对babel是如何转译我们的代码的有了更直观的认识。代码中有疑问或者不对的地方欢迎各位批评指正,共同进步。求点赞三连QAQ??
链接: