手写简易webpack
webpack的定位是一个bundler,最基本的要解决的是将多个JS模块打包成可以在浏览器上运行的代码。接下来我们将实现一个简易的miniWebpack也就是一个bundler:由入口文件对代码进行打包,打包成可以在浏览器运行的代码。
被打包项目介绍
整个演示项目的目录结构如下所示,其中src下的文件是bundler.js需要打包的代码。
├── bundler.js
├── package-lock.json
└── src
├── index.js
├── msg.js
└── word.js
src下各文件内容如下:
word.js
const word = 'miniWebpack';
export default word;
msg.js
import word from './word.js'
const msg = `hello ${word}`;
export default msg;
index.js
import msg from './msg.js'
console.log(msg)
export default index;
实现bundler.js
要实现bundler,我们需要实现3部分功能:
moduleAnalyser:模块分析。分析模块,得到模块的依赖、代码等信息。
makeDependenciesGraph:生成依赖图谱。遍历打包项目,得到所有需要的模块的分析结果 。
generateCode:生成可执行代码。提供require()函数和exports对象,生成可以在浏览器执行的代码。
模块分析
使用fs模块读取module的内容;使用@babel/parser将文件内容转换成抽象语法树AST;使用@babel/traverse遍历了AST ,对每个ImportDeclaration节点(保存的相对于module的路径信息)做映射,把依赖关系拼装在 dependencies对象里;使用@babel/core结合@babel/preset-env预设,将AST转换成了浏览器可以执行的代码。
const moduleAnalyser = (fileName) => {
// 1.fs模块根据路径读取到了module的内容
const content = fs.readFileSync(fileName, 'utf-8');
// 2.使用@babel/parser将文件内容转换成抽象语法树AST
const ast = parser.parse(content, {
sourceType: 'module'
})
// 3.使用@babel/traverse遍历了AST ,对每个ImportDeclaration节点做映射,把依赖关系拼装在 dependencies对象里
let dependencies = {};
traverse(ast, {
ImportDeclaration({ node }) {
const dirName = path.dirname(fileName);
const newFile = './' + path.join(dirName, node.source.value);
// key是相对于当前模块的路径,value为相对于bundler.js的路径。
dependencies[node.source.value] = newFile;
}
})
// 4.使用@babel/core结合@babel/preset-env预设,将AST转换成了浏览器可以执行的代码
const { code } = babel.transformFromAst(ast, null, {
presets: ["@babel/preset-env"]
})
return {
fileName,
dependencies,
code
}
}
模块分析流程图如下:
调用console.log(moduleAnalyser('./src/index.js')),可以在控制台打印出以下内容:
{ fileName: './src/index.js',
dependencies: { './msg.js': './src/msg.js' },
code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\nexports["default"] = void 0;\n\nvar _msg = _interopRequireDefault(require("./msg.js"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n\nconsole.log(_msg["default"]);\nvar _default = index;\nexports["default"] = _default;' }
执行moduleAnalyser(module),可以返回module的fileName、dependencies、code信息。注意在code里,import语法已经变成一个require函数了,export语法,也变成了在给一个exports变量赋值。
依赖图谱生成
调用moduleAnalyser('./src/index.js')拿到入口文件的dependencies映射,接下来再把入口文件的依赖路径再一次做模块分析,再把依赖模板的依赖路径再一次做模块分析...... 其实就是广度优先遍历,可以很轻松得到这次打包所有需要的模块的分析结果。
//生成依赖图谱
const makeDependenciesGraph = (entry) => {
//entryModule:入口文件的dependencies映射
const entryModule = moduleAnalyser(entry);
//graphArray:图谱动态数组,初始只有一个元素entryModule
const graphArray = [entryModule];
for (let i = 0; i < graphArray.length; i++) {
const item = graphArray[i];
//dependencies:当前模块的dependencies映射
const { dependencies } = item;
//如果当前模块有依赖文件,则遍历dependencies,调用moduleAnalyser,对依赖文件进行模板分析
if (dependencies) {
for (let j in dependencies) {
graphArray.push(moduleAnalyser(dependencies[j]))
}
}
}
//graph:遍历graphArray生成更利于打包使用的graph。其中key为fileName,value为dependencies和code
const graph = {};
graphArray.forEach(item => {
graph[item.fileName] = {
dependencies: item.dependencies,
code: item.code
}
})
return graph;
}
生成依赖图谱流程图如下:
调用console.log(makeDependenciesGraph('./src/index.js')),可以在控制台打印出以下内容:
{ './src/index.js':
{ dependencies: { './msg.js': './src/msg.js' },
code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\nexports["default"] = void 0;\n\nvar _msg = _interopRequireDefault(require("./msg.js"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n\nconsole.log(_msg["default"]);\nvar _default = index;\nexports["default"] = _default;' },
'./src/msg.js':
{ dependencies: { './word.js': './src/word.js' },
code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\nexports["default"] = void 0;\n\nvar _word = _interopRequireDefault(require("./word.js"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n\nvar msg = "hello ".concat(_word["default"]);\nvar _default = msg;\nexports["default"] = _default;' },
'./src/word.js':
{ dependencies: {},
code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\nexports["default"] = void 0;\nvar word = \'miniWebpack\';\nvar _default = word;\nexports["default"] = _default;' } }
生成代码
我们需要开始生成最终可运行的代码了。在上文”模块分析“部分,我们知道利用@babel/core结合@babel/preset-env生成的浏览器可执行代码里,import语法已经变成一个require函数了,export语法,也变成了在给一个exports变量赋值。所以我们的”生成代码“部分,需要提供一个require函数了和exports对象。
//generateCode 根据依赖图谱生成浏览器可执行代码
const generateCode = (entry) => {
//根据entry,调用makeDependenciesGraph生成依赖图谱graph
const graph = JSON.stringify(makeDependenciesGraph(entry))
//根据依赖图谱生成浏览器可执行代码
return `
可运行代码...
`
}
为了不污染全局作用域,我们使用立即执行函数来包装我们的代码,将依赖图谱graph作为参数传入:
(function(graph){
// todo
})(${graph})
在graph中找到入口文件的code,并运行它:
return `
(function(graph){
function require(module){
eval(graph[module].code)
}
require('${entry}')
})(${graph})
在入口文件的code,我们同样需要调用require去获取依赖模块模块导出的对象exports,所以require函数必须有导出对象,还要支持内部调用require函数。但是注意!!此require并非现在声明的require函数,定义code内部使用的require函数 -> localRequire。因为我们观察之前编译出的代码,可以知道在code中,require函数传的参数是相对于当前module的相对路径,但是我们打包生成可运行代码时,需要的是相对于bundler.js的相对路径。这时候,我们之前给每个module存的dependencies映射再次派上了用场,localRequire() 传入依赖相对于module的相对路径,根据graph对象,返回依赖相对于bundler.js的相对路径。
(function(graph){
function require(module){
function localRequire(relativePath){
return require(graph[module].dependencies[relativePath])
}
var exports={};
eval(graph[module].code)
return exports;
}
require('${entry}')
})(${graph})
为了防止模块内部变量污染其它模块,我们在eval外面包一层立即执行函数,将localRequire、exports和code作为参数传入。
(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})
由此一个bundler就写完了,最终生成的代码,也是可以直接在浏览器中运行的。
调用console.log(generateCode('./src/index.js')),可以在控制台打印出以下内容:
(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('./src/index.js')
})({"./src/index.js":{"dependencies":{"./msg.js":"./src/msg.js"},"code":"\"use strict\";\n\nvar _msg = _interopRequireDefault(require(\"./msg.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\nconsole.log(_msg[\"default\"]); // export default index;"},"./src/msg.js":{"dependencies":{"./word.js":"./src/word.js"},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports[\"default\"] = void 0;\n\nvar _word = _interopRequireDefault(require(\"./word.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\nvar msg = \"hello \".concat(_word[\"default\"]);\nvar _default = msg;\nexports[\"default\"] = _default;"},"./src/word.js":{"dependencies":{},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports[\"default\"] = void 0;\nvar word = 'miniWebpack';\nvar _default = word;\nexports[\"default\"] = _default;"}})
将这段代码赋值到浏览器控制台,可以看到代码执行情况:
完整的bundle.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')
//moduleAnalyser:分析一个模块的文件依赖
const moduleAnalyser = (fileName) => {
// 1.fs模块根据路径读取到了入口文件的内容
const content = fs.readFileSync(fileName, 'utf-8');
// 2.使用@babel/parser将文件内容转换成抽象语法树AST
const ast = parser.parse(content, {
sourceType: 'module'
})
// 3.使用@babel/traverse遍历了AST ,对每个ImportDeclaration节点(保存的相对于入口文件的路径)做映射,把依赖关系拼装在 dependencies对象里
let dependencies = {};
traverse(ast, {
ImportDeclaration({ node }) {
const dirName = path.dirname(fileName);
//newFile 相对于bundler.js的相对路径,打包的时候用这个。
const newFile = './' + path.join(dirName, node.source.value);
// key是相对于当前模块的路径,value为相对于bundler.js的路径。
dependencies[node.source.value] = newFile;
}
})
// 4.使用@babel/core结合@babel/preset-env预设,将AST转换成了浏览器可以执行的代码
const { code } = babel.transformFromAst(ast, null, {
presets: ["@babel/preset-env"]
})
return {
fileName,
dependencies,
code
}
}
//生成依赖图谱
const makeDependenciesGraph = (entry) => {
//entryModule:入口文件的dependencies映射
const entryModule = moduleAnalyser(entry);
//graphArray:图谱动态数组,初始只有一个元素entryModule
const graphArray = [entryModule];
for (let i = 0; i < graphArray.length; i++) {
const item = graphArray[i];
//dependencies:当前模块的dependencies映射
const { dependencies } = item;
//如果当前模块有依赖文件,则遍历dependencies,调用moduleAnalyser,对依赖文件进行模板分析
if (dependencies) {
for (let j in dependencies) {
graphArray.push(moduleAnalyser(dependencies[j]))
}
}
}
//graph:遍历graphArray生成更利于打包使用的graph。其中key为fileName,value为dependencies和code
const graph = {};
graphArray.forEach(item => {
graph[item.fileName] = {
dependencies: item.dependencies,
code: item.code
}
})
return graph;
}
//generateCode 根据依赖图谱生成浏览器可执行代码
const generateCode = (entry) => {
const graph = JSON.stringify(makeDependenciesGraph(entry))
//大的闭包,防止打包生成的代码污染全局环境
//浏览器可执行的代码里有require方法,有exports对象,bundler.js打包后的代码需要提供一个require方法和exports对象。
//小的闭包,防止模块内部变量污染其它模块
//localRequire 传入依赖相对于module的相对路径,根据graph对象,返回依赖相对于bundler.js的相对路径
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 = generateCode('./src/index.js');
console.log(code)