> 本文主要讲解了目前最流行的前端 webpack 工程化打包工具的内部打包原理和如何实现各个模块之间的依赖分析和代码注入,然后手把手教大家亲自实现一个简易的 webpack 打包工具,帮助大家学习和更深层次的理解 webpack 和前端界的工程化工具打包实现原理。
# 背景
随着前端复杂度的不断提升,诞生出很多打包工具,比如最先的grunt,gulp。到后来的 webpack 和 Parcel 。但是目前很多脚手架工具,比如 vue-cli 已经帮我们集成了一些构建工具的使用。有的时候我们可能并不知道其内部的实现原理。其实了解这些工具的工作方式可以帮助我们更好理解和使用这些工具,也方便我们在项目开发中应用。
弄清楚打包工具的背后原理,有利于我们实现各种神奇的自动化、工程化东西,比如自创 JavaScript 语法,又如蚂蚁金服 ant 中大名鼎鼎的 import 插件,甚至是前端文件自动扫描载入等,能够极大的提升我们工作效率。
# 一些基础知识
## AST抽象语法树
什么是 AST 抽象语法树?在计算机科学中,抽象语法树(abstract syntax tree 或者缩写为 AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。树上的每个节点都表示源代码中的一种结构。之所以说语法是抽象的,是因为这里的语法并不会表示出真实语法中出现的每个细节。例如下面这行代码:
~~~js
const answer = 8 * 9;
~~~
转化成 AST 抽象语法树后,是如下这个样子的:
~~~js
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "answer"
},
"init": {
"type": "BinaryExpression",
"operator": "*",
"left": {
"type": "Literal",
"value": 8,
"raw": "8"
},
"right": {
"type": "Literal",
"value": 9,
"raw": "9"
}
}
}
],
"kind": "const"
}
],
"sourceType": "script"
}
~~~
大家可以通过Esprima 这个网站来将代码转化成 ast。首先一段代码转化成的抽象语法树是一个对象,该对象会有一个顶级的 type 属性 Program ,第二个属性是 body 是一个数组。body 数组中存放的每一项都是一个对象,里面包含了所有的对于该语句的描述信息
~~~js
type:描述该语句的类型 --变量声明语句
kind:变量声明的关键字 -- const
declaration: 声明的内容数组,里面的每一项也是一个对象
type: 描述该语句的类型
id: 描述变量名称的对象
type:定义
name: 是变量的名字
init: 初始化变量值得对象
type: 类型
value: 值 "is tree" 不带引号
row: "\"is tree"\" 带引号
~~~
# 打包原理的代码实现
有了上面这些基础的知识,我们开始亲自实现一个简易版本的 webpack 打包工具,代码仓库地址放在里gitlab上.
首先我们定义3个文件:
~~~js
// entry.js
import message from './message.js';
console.log(message);
// message.js
import {name} from './name.js';
export default `hello ${name}!`;
// name.js
export const name = 'world';
~~~
当我们实现了简易版本的 webpack 后,运行 entry.js 会输出 “hello world” 。
接下来我们参照笔者放在gitlab上实现的简易版源码,进行一一解析。我们创建 /src/minipack.js 文件,在顶部插入如下代码:
~~~js
const fs = require('fs');
const path = require('path');
const babylon = require('babylon');
const traverse = require('babel-traverse').default;
const {transformFromAst} = require('babel-core');
~~~
这是实现打包原理所需要的核心依赖,我们先考虑一下一个基础的打包编译工具可以做什么?
转换 ES6 语法成 ES5
处理模块加载依赖
生成一个可以在浏览器加载执行的 js 文件
第一个问题,转换语法,其实我们可以通过babel来做。核心步骤就是通过 babylon 生成 AST ,通过 babel-core 的 transformFromAst 方法将 AST 重新生成源码,traverse 的作用就是帮助开发者遍历 AST 抽象语法树,帮助我们获取树节点上的需要的信息和属性。接下来看:
let ID = 0;
全局的自增 id,记录每一个载入的模块的 id,我们将所有的模块都用唯一标识符进行标示,因此自增 id 是最有效也是最直观的,有多少个模块,一统计就出来了。
然后创建createAsset函数:
~~~js
function createAsset(filename) {
const content = fs.readFileSync(filename, 'utf-8');
const ast = babylon.parse(content, {
sourceType: 'module',
});
const dependencies = [];
traverse(ast, {
ImportDeclaration: ({node}) => {
dependencies.push(node.source.value);
},
});
const id = ID++;
const {code} = transformFromAst(ast, null, {
presets: ['env'],
});
const customCode = loader(filename, code)
return {
id,
filename,
dependencies,
code,
};
}
~~~
我们对每一个文件进行处理。因为这只是一个简单版本的 bundler,因此,我们并不考虑如何去解析 css、md、txt 等等之类的格式,我们专心处理好 js 文件的打包,因为对于其他文件而言,处理起来过程不太一样,用文件后缀很容易将他们区分进行不同的处理,在这个版本,我们还是专注 js代码,createAsset 函数第一行:
~~~js
const content = fs.readFileSync(filename, 'utf-8');
~~~
函数注入一个 filename 顾名思义,就是文件名,读取其的文件文本内容。
~~~js
const ast = babylon.parse(content, {
sourceType: 'module',
});
~~~
首先,我们使用 babylon 的 parse 方法去转换我们的原始代码,通过转换以后,我们的代码变成了抽象语法树( AST ),你可以通过[https://astexplorer.net/](https://astexplorer.net/)这个可视化的网站,看看 AST 生成的是什么。
~~~js
const dependencies = [];
traverse(ast, {
ImportDeclaration: ({node}) => {
dependencies.push(node.source.value);
},
});
~~~
当我们解析完以后,我们就可以提取当前文件中的 dependencies,dependencies 翻译为依赖,也就是我们文件中所有的`import xxxx from xxxx`,我们将这些依赖都放在 dependencies 的数组里面,之后统一进行导出,traverse 函数是一个遍历 AST 的方法,由 babel-traverse 提供,他的遍历模式是经典的 visitor 模式,visitor 模式就是定义一系列的 visitor ,当碰到 AST 的 type === visitor 名字时,就会进入这个 visitor 的函数,类型为`ImportDeclaration`的 AST 节点,其实就是我们的`import xxx from xxxx`,其中 path.node.source.value 的值,就是我们 import from xxxx 中的地址,将地址 push 到 dependencies 中。
~~~js
const id = ID++;
const {code} = transformFromAst(ast, null, {
presets: ['env'],
});
~~~
当我们完成依赖的收集以后,我们就可以把我们的代码从 AST 转换成 CommenJS 的代码,这样子兼容性更高更好,并且将 ID 自增。
~~~js
const customCode = loader(filename, code)
function loader(filename, code) {
if (/entry/.test(filename)) {
console.log('this is loader ')
}
return code
}
~~~
还记得我们的 webpack-loader 系统吗?具体实现就是在这里可以实现,通过将文件名和代码都传入 loader 中,进行判断,甚至用户定义行为再进行转换,就可以实现 loader 的机制,当然,我们在这里,就做一个弱智版的 loader 就可以了,parcel 在这里的优化技巧是很有意思的,在 webpack 中,我们每一个 loader 之间传递的是转换好的代码,而不是 AST,那么我们必须要在每一个 loader 进行 code -> AST 的转换,这样时非常耗时的,parcel 的做法其实就是将 AST 直接传递,而不是转换好的代码,这样,速度就快起来了。
接下来,我们对模块进行更高级的处理。我们之前已经写了一个 createAsset 函数,那么现在我们要来写一个 createGraph 函数,我们将所有文件模块组成的集合叫做 queue ,用于描述我们这个项目的所有的依赖关系,createGraph 从 entry (入口) 出发,一直到打包完所有的文件为止。createGraph 函数如下:
~~~js
function createGraph(entry) {
const mainAsset = createAsset(entry);
const queue = [mainAsset];
for (const asset of queue) {
asset.mapping = {};
const dirname = path.dirname(asset.filename);
asset.dependencies.forEach(relativePath => {
const absolutePath = path.join(dirname, relativePath);
const child = createAsset(absolutePath);
asset.mapping[relativePath] = child.id;
queue.push(child);
});
}
return queue;
}
~~~
先看前面两行代码:
~~~js
const mainAsset = createAsset(entry);
const queue = [mainAsset];
~~~
从 entry 出发,首先收集 entry 文件的依赖,queue 其实是一个数组,我们将最开始的入口模块放在最开头。
~~~js
for (const asset of queue) {
asset.mapping = {};
//从asset中获取文件对应的文件夹
const dirname = path.dirname(asset.filename);
//... ...
}
~~~
在这里我们使用 for of 循环而不是 foreach ,原因是因为我们在循环之中会不断的向queue 中,push 进东西,queue 会不断增加,用 for of 会一直持续这个循环直到 queue 不会再被推进去东西,这就意味着,所有的依赖已经解析完毕,queue 数组数量不会继续增加,但是用 foreach 是不行的,只会遍历一次, asset 代表解析好的模块,里面有 filename,code,dependencies 等东西,asset.mapping 是一个不太好理解的概念,我们每一个文件都会进行 import 操作,import 操作在之后会被转换成 require ,每一个文件中的 require 的 path 其实会对应一个数字自增 id ,这个自增 id 其实就是我们一开始的时候设置的 id ,我们通过将 path-id 利用键值对,对应起来,之后我们在文件中 require 就能够轻松的找到文件的代码,解释这么啰嗦的原因是往往模块之间的引用是错中复杂的,这恰巧是这个概念难以解释的原因。
~~~js
asset.dependencies.forEach(relativePath => {
const absolutePath = path.join(dirname, relativePath);
const child = createAsset(absolutePath);
asset.mapping[relativePath] = child.id;
queue.push(child);
});
~~~
每个文件都会被 parse 出一个 dependencise,他是一个数组,在之前的函数中已经讲到,因此,我们要遍历这个数组,将有用的信息全部取出来,值得关注的是 asset.mapping\[dependencyPath\] = denpendencyAsset.id 操作。absolutePath 获取文件中模块的绝对路径,比如 import ABC from './world',会转换成 /User/xxxx/desktop/xproject/world 这样的形式。
~~~js
asset.mapping[relativePath] = child.id;
~~~
这里是重要的点,我们解析每解析一个模块,我们就将他记录在这个文件模块 asset 下的 mapping 中,之后我们 require 的时候,能够通过这个 id 值,找到这个模块对应的代码,并进行运行,将解析的模块推入 queue 中去。最后我们得到的 queue 是如下这个样子的:
~~~js
[
{ id: 0,
filename: './example/entry.js',
dependencies: [ './message.js' ],
code: '"use strict";\n\nvar _message = require("./message.js");\n\nvar _message2 = _interopRequireDefault(_message);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nconsole.log(_message2.default);',
mapping: { './message.js': 1 } },
{ id: 1,
filename: 'example/message.js',
dependencies: [ './name.js' ],
code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\n\nvar _name = require("./name.js");\n\nexports.default = "hello " + _name.name + "!";',
mapping: { './name.js': 2 } },
{ id: 2,
filename: 'example/name.js',
dependencies: [],
code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\nvar name = exports.name = \'world\';',
mapping: {} }
]
~~~
接下来我们创建 bundle 函数,我们通过 createGraph 完成了 queue 的收集,那么就到我们真正的代码打包了,这个 bundle 函数使用了大量的字符串处理,你们不要觉得奇怪,为什么代码和字符串可以混起来写,如果你跳出写代码的范畴,看我们的代码,实际上,代码就是字符串,只不过他通过特殊的语言形式组织起来而已,对于脚本语言 JS 来说,字符串拼接成代码,然后跑起来,这种操作在前端非常的常见,我认为,这种思维的转换,是拥有自动化、工程化的第一步。
~~~js
function bundle(graph) {
let modules = '';
graph.forEach(mod => {
modules += `${mod.id}: [
function (require, module, exports) {
${mod.code}
},
${JSON.stringify(mod.mapping)},
],`;
});
// ... ...
return result;
}
~~~
我们的 modules 就是一个字符串,我们将 graph 中所有的 asset 取出来,然后使用 node.js 制造模块的方法来将一份代码包起来。我们将转换好的源码,放进一个 function(require,module,exports){} 函数中,这个函数的参数就是我们随处可用的 require,module,以及 exports,这就是为什么我们可以随处使用这三个玩意的原因,因为我们每一个文件的代码终将被这样一个函数包裹起来,不过这段代码中比较奇怪的是,我们将代码封装成了`1:[...],2:[...]`的形式,我们在最后导入模块的时候,会为这个字符串加上一个 {},变成 {1:\[...\],2:\[...\]},你没看错,这是一个对象,这个对象里用数字作为 key ,一个二维元组作为值,\[0\] 第一个就是我们被包裹的代码,\[1\] 第二个就是我们的 mapping 。
~~~js
function bundle(graph) {
//... ...
const result = `
(function(modules) {
function require(id) {
const [fn, mapping] = modules[id];
function localRequire(name) {
return require(mapping[name]);
}
const module = { exports : {} };
fn(localRequire, module, module.exports);
return module.exports;
}
require(0);
})({${modules}})
`;
return result;
}
~~~
这一段代码实际上才是模块引入的核心逻辑,我们制造一个顶层的 require 函数,这个函数接收一个 id 作为值,并且返回一个全新的 module 对象,我们倒入我们刚刚制作好的模块,给他加上 {},使其成为 {1:\[...\],2:\[...\]} 这样一个完整的形式,然后塞入我们的立即执行函数中(function(modules) {...})(),在 (function(modules) {...})() 中,我们先调用 require(0),理由很简单,因为我们的主模块永远是排在第一位的,紧接着,在我们的 require 函数中,我们拿到外部传进来的 modules,利用我们一直在说的全局数字 id 获取我们的模块,每个模块获取出来的就是一个二维元组,然后,我们要制造一个`子require`,这么做的原因是我们在文件中使用 require 时,我们一般 require 的是地址,而顶层的 require 函数参数时 id,不要担心,我们之前的 mapping 在这里就用上了,通过用户 require 进来的地址,在 mapping 中找到 id,然后递归调用 require(id),就能够实现模块的自动倒入了,接下来制造一个 const newModule = {exports: {}};,运行我们的函数 fn(childRequire, newModule, newModule.exports);,将应该丢进去的丢进去,最后 return newModule.exports 这个模块的 exports 对象。
~~~js
const graph = createGraph('./example/entry.js');
const result = bundle(graph);
console.log(result);
~~~
最后,运行我们开头的demo例子三个文件的代码,node src/minipack.js,就能看到所有编译好的代码,将代码复制出来放到chrome浏览器的console里,就能看到"hello world"的输出。
# 结尾
目前为止,我们已经实现了一个简易版本的 webpack 了,我们将 es6 的代码通过 entry.js 入口文件开始编译,然后根据循环递归调用把所有文件所依赖的文件都解析和加载了一遍,理解了这个简易版本的 webpack 实现原理,再去看看其他的打包工具原理,大同小异,你就会发现一切都豁然开朗,无非就是在这个简易版本的 webpack 基础上加了很多工具函数和细节的处理,限于篇幅笔者就不再一一介绍了。
本文实现的简易版本的 webpack 的完整代码gitlab地址为:[minipack](https://github.com/airuikun/minipack)
#### 作者:第一名的小蝌蚪
#### github: 文章会第一时间分享在[前端屌丝心路历程](https://github.com/airuikun/blog),欢迎star或者watch,感恩
- 首页
- pm2
- pm2
- pm2 离线安装
- pm2 使用指南
- node
- 正则
- web
- webpack
- 配置
- 优化代码体积
- plugin-proposal-decorators
- webpack 打包原理解析
- babel presets配置 babel7
- 配置路径别名
- 去除开发中的警告信息
- css
- 滚动条
- input自动填充背景色
- 颜色渐变
- scss
- 网页定制光标
- 超出文本显示省略号。。。
- calc兼容性写法
- box-sizing
- clip-path
- 苹果手机页面滑动卡顿
- 字体间距根据父级宽度自适应
- 纯css动态效果
- 清除浮动的三种方法
- 按钮增加闪烁效果
- 字体渐变
- react
- mobx
- 路由
- antd 表格在safari上卡顿
- 项目初始化
- react-antd-mobx-momnet
- 显示字符串中的标签
- antd Select 在搜索精准度
- 路由切换动态过渡效果
- css中图片打包后的路径出错
- antd upload 无法及时更新state
- antd DatePicker设置中文失败
- antd-pro 添加登录页面报错
- new Array创建新数组数据指向相同
- react 页面刷新渲染两次
- useEffect
- Hooks 闭包解决方案
- hooks 方法封装
- Plugin "react" was conflicted between "package.json » eslint-config-react-app
- javascript
- canvas
- 多张图片合成一张
- 排序
- js比较符号==、===
- 运动函数封装(简易、通用)
- 导出表格(excel )
- react使用demo
- xlsx导出excel
- js获取屏幕高度宽度
- toFixed 函数修改
- 获取cookie,url参数
- 奇怪的错误问题
- copy(深拷贝 浅拷贝)
- 导出pdf
- 解决图片失真
- 判断字符串长度(带中文)
- js中 文件、图片二进制和base64的互转
- 读取深度嵌套的json数据
- 手动实现Promise.all
- cookie 删除
- webpack 打包过后的文件报错 regeneratorRuntime is not defined
- 防抖与节流
- react hooks 中使用防抖节流
- 图片懒加载
- 重排和重绘
- 修复部分无法JASON.parse的数据
- react-native
- android-studio 打开调试工具
- 适配全面屏
- node
- 服务端 node + nginx 反向代理
- 生成文件夹目录列表
- mogodb常用操作
- 发布npm包
- cli工具
- 上传文件
- nodejs使用crypto进行加密/解密操作
- mongodb 加入验证之后连接失败
- nextjs使用问题
- node转发http请求
- mongodb 导入导出 备份
- node-sass 安装问题、安装失败等
- npm yarn 安装依赖太慢
- puppeteer 安装问题 centos
- mongoose
- 其他
- 禁止浏览器缓存
- chrome平滑滚动
- pdf预览
- 问题整理
- 资料
- 小程序
- fetch
- cookie 设置跨域资源共享
- taro 小程序
- taro request
- 设置npm镜像
- esbuild the service is no longer running
- 离线地图
- uniapp 转 vue-cli
- 工具
- Excel表格密码保护的解除方法
- vscode(插件)
- vscode 常用代码片段
- vscode 开启tab补全代码
- mac 百度网盘破解
- mysql 重置密码
- chrome 好用的扩展
- Mac/Linux/Windows通过命令调用浏览器打开某网页
- 小链接
- 数据库
- mongo
- sql文件导入
- join 用法
- sql 时间格式化 DATE_FORMAT
- 创建全文检索并分词查询
- 阿里云node-mysql 操作文档
- sql 时间查询
- mysql group查询结果合并为一行
- mysql 锁
- mysql count 同个字段多个结果合并到一行
- 解决Node.js mysql客户端不支持认证协议引发的“ER_NOT_SUPPORTED_AUTH_MODE”问题
- mysql 根据经纬度计算距离
- PHP
- 文件读取
- 接收前端json数据
- 自定义排序
- session 写入失败无法保存
- php 上传大文件$_FILES为空
- base64转图片
- composer.phar 安装东西太慢 切换国内镜像
- laravel sql查询记录
- 解决: Please provide a valid cache path.
- thinkphp开启多应用
- 上传文件报错 Filename cannot be empty
- php curl 报错 curl: (35) SSL connect error
- App
- android未授权错误(Flutter)
- uniapp
- 服务端
- mongodb 定时备份
- mysql 错误
- nginx 转发网络请求
- midwayjs 使用egg-mysql
- https 无法访问
- egg 配置跨域
- 算法实现
- 排序
- 全排列
- 无重复字符的最长子串
- 反转单向链表
- 斐波那契数列
- 有效的括号
- GIT
- git克隆大文件
- 面试整理
- 前端整理
- 大厂高级前端面试题
- 三年大厂面试题
- 面试经验
- 头条it技术工程师
- 每日学习
- 常见的数据结构
- 面试地址汇总
- 练习汇总
- 前端八股文
- mac环境配置
- mac nginx重启报错
- mac 安装redis
- fis配置
- 切换php版本
- Mac OS X下的Oh-My-ZSH安装与配置
- mac 查看端口进程 停止进程
- mac 配置ssh 免密码登录服务器
- navigate 中文破解
- 删除启动台无效文件夹
- 删除顶部图标(卸载后的软件还存在)
- 修复mac 下安装全局依赖失效
- navicate 完美破解 内有下载地址
- nginx 报错 500 "/usr/local/var/run/nginx/client_body_temp/0000000004" failed (13: Permission denied)
- 安装PHP redis扩展
- 安装zsh后 nvm node命令失效
- python
- python 在vscode中编辑,格式化文件总是提示There is no Pip installer available in the selected environment.
- 杂项
- 膝盖修复
- 微信打开网页链接反应巨慢
- chrome 显示http/https完整连接
- doracms
- pdfjs 中文无法显示
- docker
- go
- 指针、指针地址* &
- 脚本
- 京东疯狂的joy脚本
- 2021京东炸年兽
- LINUX
