手把手教你手写一个webpack (webpack核心原理)
前言
众所周知 一个好的前端就是要于手写各种前端工具链。 咱们今天来写一个玩具webpack,当然市面上有很多手写webpack的教程, 我这个版本省去了webpack中的tapable库和loader,plugin。只保留了最简单的核心原理,让大家能快速理解。
这里是本人的迷你webpack项目 大家可以点个star看看) (实现了plugin和loader 并且模拟了esBuild) github.com/lzy19926/ti…
AST虚拟语法树
首先我们需要知道什么是AST AST简单的说就是: 使用对象的形式来表达一行代码 我们来看一个句简单的AST
import name from './index.js'
{
"type": "ImportDeclaration", // 语句的类型
"start": 0,
"end": 31,
"loc": {},
"specifiers": [...], // 保存了{name} 部分
"importKind": "value",
"source": {...}, // 保存了'./index.js'部分
"assertions": []
}
我们可以通过这句的AST下的source找到import的文件路径
其中的specifiers保存了import的内容
AST语句的分类
AST可以分为多种类型
ImportDeclaration // import语句
VariableDeclaration // 变量声明语句
IfStatement // if/else语句
FunctionDeclaration // 函数声明
Identifier // 变量名/函数名等
...... 还有很多
搭建一个webpack类的架子
webpack主要是由其中的webpackCompiler类进行打包的 我们这里写一个简单的compiler架子 并在期基础上进行功能开发 传入config
class WebpackCompiler {
constructor(webpackConfig) {
this.config = webpackConfig
}
bundle(){ // 最终执行此方法进行打包
}
}
当然别忘了简单配置一下自己的webpackConfig
// webpack.config.js
const path = require('path')
module.exports = {
rootPath: __dirname, // 项目根路径
entry: path.join(__dirname, '/src/index.js'), //配置打包入口
output: path.join(__dirname, '/dist'), // 出口
}
构建一个文件模块
现在我们知道 可以通过AST找到import的文件路径 那么我们需要
- 转换代码为AST 解析这些AST
- 将代码中import的模块推到数组中 递归解析
- 将文件内的ES6代码转化为ES5
- 生成文件的依赖图
- 生成文件模块(ES5代码+文件绝对路径+文件的依赖图)
我这里使用babel的AST进行转换
const fs = require('fs')
const parser = require('@babel/parser') //转换JS字符串为AST
const traverse = require('@babel/traverse').default //遍历AST的工具
const babel = require('@babel/core') // 转化es6为es5工具
createAssets(absolutePath) {
//读取文件内容
const fileContent = fs.readFileSync(absolutePath, 'utf-8')
//使用babel/parser将index代码转换为AST语法树
const ast = parser.parse(fileContent, {
sourceType: 'module'
})
//! 使用babel/traverse遍历AST语法树 将所有import的文件推入dependencise数组
//traverser传入钩子函数 遍历到对应的语句 就会执行钩子函数 (详见AST Exporer)
const dependencies = []
traverse(ast, {
ImportDeclaration: (childAst, state) => {
const depRaletivePath = childAst.node.source.value
const nextDepRaletivePath = this.addSuffix(depRaletivePath)
childAst.node.source.value = nextDepRaletivePath //如果没有.js尾缀 自动添加
dependencies.push(nextDepRaletivePath) //todo 每次遇到import语句 将其文件路径push到依赖数组
}
})
//!使用babel/core 转化ast为ES5语法 支持浏览器运行
const es5Code = babel.transformFromAstSync(ast, null, {
presets: ['@babel/preset-env'],
})
// 返回由这个文件构建的asste 这就是一个文件模块
return {
filePath: absolutePath,
code: es5Code.code,
dependencies
}
}
将解析来的文件包裹为一个模块
通过AST 我们可以获取文件引用的模块 之后我们会将其推入一个数组 作为文件的依赖图 所以我的一个webpack文件模块最后长这样
//index.js文件
import name from ./name.js
console.log(name)
// 生成的模块 键值对
const modules = {
"E:\\My_Webpack\\myWebpack\\src\\index.js": [
function (require, module, exports) { // 文件的代码 转化为ES5后的
"use strict";
var _name = require("./name.js");
console.log(_info.info);
},
{ "./name.js": "E:\\My_Webpack\\myWebpack\\src\\name.js" } // 文件依赖的模块
]
}
所以我们可以通过入口文件 递归执行createAssets方法 并将其推入数组 即可生成多个modules 多个模块先构成依赖图Manifest 我们这里使用队列循环方式 替代递归流程 话不多说上代码
//构建文件依赖图
createManifest(entry) {
//1 通过入口文件构建文件资源
const mainAsset = this.createAssets(entry)
// todo 这里需要进行模块比较 重复的模块不推入
//2 使用队列循环方式构建依赖图(遍历+递归 使用createAssets处理每个js文件)
const manifestQueue = [mainAsset]
for (const asset of queue) {
const dirname = path.dirname(asset.filePath) // 获取当前处理文件的绝对路径
asset.mapping = {} // 文件的依赖map
asset.dependencies.forEach(relativePath => {// 遍历文件依赖的文件(import)
const absolutePath = path.join(dirname, relativePath) // 获取import文件的绝对路径
const childAsset = this.createAssets(absolutePath) //! 通过绝对路径构建子文件资源
asset.mapping[relativePath] = absolutePath //!通过相对路径和绝对路径匹配 构建资源依赖图
manifestQueue.push(childAsset) // 处理好的资源推入数组 (childAsset会进入下个循环继续执行)
})
}
return manifestQueue
}
打包这些模块(将Manifest包裹为模块键值对)
现在我们有了一个模块数组 里面保存了所有项目的模块 其中的ES6代码已经被babel转化为了ES5代码 我们需要将这些模块构建为一个对象 使用键值对保存模块
createModules(manifest) {
let modulesStr = '';
// 构建每个module为键值对 并添加进modules对象(所有资源都以字符串形式构建)
//todo 注意 (1.处理模块为键值对 绝对路径为key 值保存模块的code和mapping)
//todo 2. 模块的code应放在一个函数里 因为每个模块的code中使用了require,exports两个API 需要传入
//todo 3 文件中的依赖是相对路径 需要使用绝对路径
manifest.forEach(module => {
renderProgressBar(`打包模块${module.filePath}`); //! ------------------------进度显示
const key = JSON.stringify(module.filePath)
const mapping = JSON.stringify(module.mapping)
const code = `function(require,module,exports){
${module.code}
} `
// 单个模块资源
const modulesPart = `${key}:[\n ${code},\n ${mapping} \n ],\n`
modulesStr += modulesPart
})
return `{${modulesStr}}`
}
生成最终的bundle代码
现在我们有了构建好的模块对象 如下
const modules = {
"E:\\My_Webpack\\myWebpack\\src\\index.js": [ // 文件的绝对路径
function (require, module, exports) { // 文件的代码 转化为ES5后的
"use strict";
var _name = require("./name.js");
console.log(_info.info);
},
{ "./name.js": "E:\\My_Webpack\\myWebpack\\src\\name.js" } // 文件依赖的模块
]
......
}
我们将其用立即执行函数包裹起来(使用字符串) 并自定义一个require方法 传入模块中进行使用
createOutputCode(modulesStr) {
let result = ` // bundle最终生成的代码
(function(){
//todo 传入modules
var modules = ${modulesStr}
//todo 创建require函数 获取modules的函数代码和mapping对象
function require(raletivePath){
const [fn,mapping] = modules[raletivePath]
//(loaclRequire 通过相对路径获取绝对路径(id)并执行require)
const loaclRequire =(relativePath)=>{
return require(mapping[relativePath])
}
//! 构造模拟Node的module对象
const module = {
exports:{}
}
//! 将三个参数传入fn并执行
fn(loaclRequire,module,module.exports)
return module.exports
}
//! 执行require(entry)入口模块
require(${JSON.stringify(this.config.entry)})
})();`
return result
}
得到bundle代码 创建文件
outputDist(result) {
//todo 没有dist时创建dist文件夹
const hasDir = fs.existsSync(this.config.output)
if (!hasDir) {
fs.mkdirSync(this.config.output)
}
//todo 写入文件
fs.writeFileSync(this.config.output + `/bundle.js`, result)
}
将上述方法整合起来 构建bundle方法
bundleModules(){
const manifest = this.createManifest(this.config.entry) // 创建文件依赖图(Manifest)
const modules = this.createModules(manifest) // 生成modules
let result = this.createOutputCode(modules)// 打包模块生成bundle代码
outputDist(result)
}
实例化我们的webpackCompiler类 执行bundle方法 ,我们就生成了bundle.js文件
const webpack = new WebpackCompiler(config)
webpack.bundle()
转载自:https://juejin.cn/post/7124958847059361828