窥探原理:实现一个简单的前端代码打包器 Roid
roid
roid 是一个极其简单的打包软件,使用 node.js 开发而成,看完本文,你可以实现一个非常简单的,但是又有实际用途的前端代码打包工具。
如果不想看教程,直接看代码的(全部注释):点击地址
为什么要写 roid ?
我们每天都面对前端的这几款编译工具,但是在大量交谈中我得知,并不是很多人知道这些打包软件背后的工作原理,因此有了这个 project 出现。诚然,你并不需要了解太多编译原理之类的事情,如果你在此之前对 node.js 极为熟悉,那么你对前端打包工具一定能非常好的理解。
弄清楚打包工具的背后原理,有利于我们实现各种神奇的自动化、工程化东西,比如表单的双向绑定,自创 JavaScript 语法,又如蚂蚁金服 ant 中大名鼎鼎的 import 插件,甚至是前端文件自动扫描载入等,能够极大的提升我们工作效率。
不废话,我们直接开始。
从一个自增 id 开始
const { readFileSync, writeFileSync } = require('fs')
const path = require('path')
const traverse = require('babel-traverse').default
const { transformFromAst, transform } = require('babel-core')
let ID = 0
// 当前用户的操作的目录
const currentPath = process.cwd()
id
:全局的自增 id
,记录每一个载入的模块的 id
,我们将所有的模块都用唯一标识符进行标示,因此自增 id
是最有效也是最直观的,有多少个模块,一统计就出来了。
解析单个文件模块
function parseDependecies(filename) {
const rawCode = readFileSync(filename, 'utf-8')
const ast = transform(rawCode).ast
const dependencies = []
traverse(ast, {
ImportDeclaration(path) {
const sourcePath = path.node.source.value
dependencies.push(sourcePath)
}
})
// 当我们完成依赖的收集以后,我们就可以把我们的代码从 AST 转换成 CommenJS 的代码
// 这样子兼容性更高,更好
const es5Code = transformFromAst(ast, null, {
presets: ['env']
}).code
// 还记得我们的 webpack-loader 系统吗?
// 具体实现就是在这里可以实现
// 通过将文件名和代码都传入 loader 中,进行判断,甚至用户定义行为再进行转换
// 就可以实现 loader 的机制,当然,我们在这里,就做一个弱智版的 loader 就可以了
// parcel 在这里的优化技巧是很有意思的,在 webpack 中,我们每一个 loader 之间传递的是转换好的代码
// 而不是 AST,那么我们必须要在每一个 loader 进行 code -> AST 的转换,这样时非常耗时的
// parcel 的做法其实就是将 AST 直接传递,而不是转换好的代码,这样,速度就快起来了
const customCode = loader(filename, es5Code)
// 最后模块导出
return {
id: ID++,
code: customCode,
dependencies,
filename
}
}
首先,我们对每一个文件进行处理。因为这只是一个简单版本的 bundler
,因此,我们并不考虑如何去解析 css
、md
、txt
等等之类的格式,我们专心处理好 js
文件的打包,因为对于其他文件而言,处理起来过程不太一样,用文件后缀很容易将他们区分进行不同的处理,在这个版本,我们还是专注 js
。
const rawCode = readFileSync(filename, 'utf-8')
函数注入一个 filename 顾名思义,就是文件名,读取其的文件文本内容,然后对其进行 AST 的解析。我们使用 babel
的 transform
方法去转换我们的原始代码,通过转换以后,我们的代码变成了抽象语法树( AST
),你可以通过 https://astexplorer.net/, 这个可视化的网站,看看 AST
生成的是什么。
当我们解析完以后,我们就可以提取当前文件中的 dependencies
,dependencies
翻译为依赖,也就是我们文件中所有的 import xxxx from xxxx
,我们将这些依赖都放在 dependencies
的数组里面,之后统一进行导出。
然后通过 traverse
遍历我们的代码。traverse
函数是一个遍历 AST
的方法,由 babel-traverse
提供,他的遍历模式是经典的 visitor
模式
,visitor
模式就是定义一系列的 visitor
,当碰到 AST
的 type === visitor
名字时,就会进入这个 visitor
的函数。类型为 ImportDeclaration
的 AST 节点,其实就是我们的 import xxx from xxxx
,最后将地址 push 到 dependencies 中.
最后导出的时候,不要忘记了,每导出一个文件模块,我们都往全局自增 id
中 + 1
,以保证每一个文件模块的唯一性。
解析所有文件,生成依赖图
function parseGraph(entry) {
// 从 entry 出发,首先收集 entry 文件的依赖
const entryAsset = parseDependecies(path.resolve(currentPath, entry))
// graph 其实是一个数组,我们将最开始的入口模块放在最开头
const graph = [entryAsset]
for (const asset of graph) {
if (!asset.idMapping) asset.idMapping = {}
// 获取 asset 中文件对应的文件夹
const dir = path.dirname(asset.filename)
// 每个文件都会被 parse 出一个 dependencise,他是一个数组,在之前的函数中已经讲到
// 因此,我们要遍历这个数组,将有用的信息全部取出来
// 值得关注的是 asset.idMapping[dependencyPath] = denpendencyAsset.id 操作
// 我们往下看
asset.dependencies.forEach(dependencyPath => {
// 获取文件中模块的绝对路径,比如 import ABC from './world'
// 会转换成 /User/xxxx/desktop/xproject/world 这样的形式
const absolutePath = path.resolve(dir, dependencyPath)
// 解析这些依赖
const denpendencyAsset = parseDependecies(absolutePath)
// 获取唯一 id
const id = denpendencyAsset.id
// 这里是重要的点了,我们解析每解析一个模块,我们就将他记录在这个文件模块 asset 下的 idMapping 中
// 之后我们 require 的时候,能够通过这个 id 值,找到这个模块对应的代码,并进行运行
asset.idMapping[dependencyPath] = denpendencyAsset.id
// 将解析的模块推入 graph 中去
graph.push(denpendencyAsset)
})
}
// 返回这个 graph
return graph
}
接下来,我们对模块进行更高级的处理。我们之前已经写了一个 parseDependecies
函数,那么现在我们要来写一个 parseGraph
函数,我们将所有文件模块组成的集合叫做 graph
(依赖图),用于描述我们这个项目的所有的依赖关系,parseGraph
从 entry
(入口) 出发,一直手机完所有的以来文件为止.
在这里我们使用 for of
循环而不是 forEach
,原因是因为我们在循环之中会不断的向 graph
中,push
进东西,graph
会不断增加,用 for of
会一直持续这个循环直到 graph
不会再被推进去东西,这就意味着,所有的依赖已经解析完毕,graph
数组数量不会继续增加,但是用 forEach
是不行的,只会遍历一次。
在 for of
循环中,asset
代表解析好的模块,里面有 filename
, code
, dependencies
等东西 asset.idMapping
是一个不太好理解的概念,我们每一个文件都会进行 import
操作,import
操作在之后会被转换成 require
每一个文件中的 require
的 path
其实会对应一个数字自增 id
,这个自增 id
其实就是我们一开始的时候设置的 id
,我们通过将 path-id
利用键值对,对应起来,之后我们在文件中 require
就能够轻松的找到文件的代码,解释这么啰嗦的原因是往往模块之间的引用是错中复杂的,这恰巧是这个概念难以解释的原因。
最后,生成 bundle
function build(graph) {
// 我们的 modules 就是一个字符串
let modules = ''
graph.forEach(asset => {
modules += `${asset.id}:[
function(require,module,exports){${asset.code}},
${JSON.stringify(asset.idMapping)},
],`
})
const wrap = `
(function(modules) {
function require(id) {
const [fn, idMapping] = modules[id];
function childRequire(filename) {
return require(idMapping[filename]);
}
const newModule = {exports: {}};
fn(childRequire, newModule, newModule.exports);
return newModule.exports
}
require(0);
})({${modules}});` // 注意这里需要给 modules 加上一个 {}
return wrap
}
// 这是一个 loader 的最简单实现
function loader(filename, code) {
if (/index/.test(filename)) {
console.log('this is loader ')
}
return code
}
// 最后我们导出我们的 bundler
module.exports = entry => {
const graph = parseGraph(entry)
const bundle = build(graph)
return bundle
}
我们完成了 graph 的收集,那么就到我们真正的代码打包了,这个函数使用了大量的字符串处理,你们不要觉得奇怪,为什么代码和字符串可以混起来写,如果你跳出写代码的范畴,看我们的代码,实际上,代码就是字符串,只不过他通过特殊的语言形式组织起来而已,对于脚本语言 JS 来说,字符串拼接成代码,然后跑起来,这种操作在前端非常的常见,我认为,这种思维的转换,是拥有自动化、工程化的第一步。
我们将 graph 中所有的 asset 取出来,然后使用 node.js 制造模块的方法来将一份代码包起来,我之前做过一个《庖丁解牛:教你如何实现》node.js 模块的文章,不懂的可以去看看,https://zhuanlan.zhihu.com/p/...
在这里简单讲述,我们将转换好的源码,放进一个 function(require,module,exports){}
函数中,这个函数的参数就是我们随处可用的 require
,module
,以及 exports
,这就是为什么我们可以随处使用这三个玩意的原因,因为我们每一个文件的代码终将被这样一个函数包裹起来,不过这段代码中比较奇怪的是,我们将代码封装成了 1:[...],2:[...]
的形式,我们在最后导入模块的时候,会为这个字符串加上一个 {}
,变成 {1:[...],2:[...]}
,你没看错,这是一个对象,这个对象里用数字作为 key
,一个二维元组作为值:
- [0] 第一个就是我们被包裹的代码
- [1] 第二个就是我们的
mapping
马上要见到曙光了,这一段代码实际上才是模块引入的核心逻辑,我们制造一个顶层的 require
函数,这个函数接收一个 id
作为值,并且返回一个全新的 module
对象,我们倒入我们刚刚制作好的模块,给他加上 {}
,使其成为 {1:[...],2:[...]}
这样一个完整的形式。
然后塞入我们的立即执行函数中(function(modules) {...})()
,在 (function(modules) {...})()
中,我们先调用 require(0)
,理由很简单,因为我们的主模块永远是排在第一位的,紧接着,在我们的 require
函数中,我们拿到外部传进来的 modules
,利用我们一直在说的全局数字 id
获取我们的模块,每个模块获取出来的就是一个二维元组。
然后,我们要制造一个 子require
,这么做的原因是我们在文件中使用 require
时,我们一般 require
的是地址,而顶层的 require
函数参数时 id
不要担心,我们之前的 idMapping
在这里就用上了,通过用户 require
进来的地址,在 idMapping
中找到 id
。
然后递归调用 require(id)
,就能够实现模块的自动倒入了,接下来制造一个 const newModule = {exports: {}};
,运行我们的函数 fn(childRequire, newModule, newModule.exports);
,将应该丢进去的丢进去,最后 return newModule.exports
这个模块的 exports
对象。
这里的逻辑其实跟 node.js 差别不太大。
最后写一点测试
测试的代码,我已经放在了仓库里,想测试一下的同学可以去仓库中自行提取。
打满注释的代码也放在仓库了,点击地址
```git clone https://github.com/Foveluy/roid.git
npm i
node ./src/_test.js ./example/index.js
```
输出
```this is loader
hello zheng Fang!
welcome to roid, I'm zheng Fang
if you love roid and learnt any thing, please give me a star
https://github.com/Foveluy/roid
<h2>参考</h2>
<ol>
<li><a href="https://github.com/blackLearning/blackLearning.github.io/issues/23" rel="nofollow noreferrer">https://github.com/blackLearn...</a></li>
<li><a href="https://github.com/ronami/minipack" rel="nofollow noreferrer">https://github.com/ronami/min...</a></li>
</ol>
原文地址:https://segmentfault.com/a/1190000015172229
窥探原理:实现一个简单的前端代码打包器 Roid的更多相关文章
- 【模块化编程】理解requireJS-实现一个简单的模块加载器
在前文中我们不止一次强调过模块化编程的重要性,以及其可以解决的问题: ① 解决单文件变量命名冲突问题 ② 解决前端多人协作问题 ③ 解决文件依赖问题 ④ 按需加载(这个说法其实很假了) ⑤ ..... ...
- 一个简单的CS系统打包过程图文版
一个简单的CS系统打包过程图文版 1. 打包内容 1.1. 此次打包的要求和特点 主工程是一个CS系统: 此CS系统运行的先决条件是要有.Net Framework 3.5: 主工程安装完成 ...
- 使用 java 实现一个简单的 markdown 语法解析器
1. 什么是 markdown Markdown 是一种轻量级的「标记语言」,它的优点很多,目前也被越来越多的写作爱好者,撰稿者广泛使用.看到这里请不要被「标记」.「语言」所迷惑,Markdown 的 ...
- 简单了解Phar代码打包工具的使用
简单了解Phar代码打包工具的使用 Phar 是在 PHP5 之后提供的一种类似于将代码打包的工具.本质上是想依照 Java 的 Jar 文件那种形式的代码包,不过本身由于 PHP 是不编译的,所以这 ...
- webpack前端模块打包器
webpack前端模块打包器 学习网址: https://doc.webpack-china.org/concepts/ http://www.runoob.com/w3cnote/webpack-t ...
- 用Vue编写一个简单的仿Explorer文件管理器
大家一定很熟悉你桌面左上角那个小电脑吧,学名Windows资源管理器,几乎所有的工作都从这里开始,文件云端化是一种趋势.怎样用浏览器实现一个Web版本的Windows资源管理器呢?今天来用Vue好好 ...
- app前端代码打包步骤
一.搭建项目环境 1.安装node.js 在网上找到nodejs压缩包,下载解压后安装node-v8.9.3-x64.msi文件. 安装完毕后,在windows的cmd控制台输入node -v或nod ...
- 洗礼灵魂,修炼python(3)--从一个简单的print代码揭露编码问题,运行原理和语法习惯
前期工作已经准备好后,可以打开IDE编辑器了,你可以选择python自带的IDLE,也可以选择第三方的,这里我使用pycharm--一个专门为python而生的IDE 按照惯例,第一个python代码 ...
- 原生JS实现一个简单的前端路由(原理)
说一下前端路由实现的简要原理,以 hash 形式(也可以使用 History API 来处理)为例, 当 url 的 hash 发生变化时,触发 hashchange 注册的回调,回调中去进行不同的操 ...
随机推荐
- 【Silverlight】Bing Maps学习系列(六):使用扩展模式(Extended Modes)(转)
[Silverlight]Bing Maps学习系列(六):使用扩展模式(Extended Modes) 微软Bing Maps推出有有段时间了,通过不断的改进和新的地图更新,现在已经基本上形成了一套 ...
- 蓝书2.3 Trie字典树
T1 IMMEDIATE DECODABILITY poj 1056 题目大意: 一些数字串 求是否存在一个串是另一个串的前缀 思路: 对于所有串经过的点权+1 如果一个点的end被访问过或经过一个被 ...
- codeforces 963B Destruction of a Tree
B. Destruction of a Tree time limit per test 1 second memory limit per test 256 megabytes input stan ...
- [USACO15DEC]High Card Low Card (Platinum)
https://www.zybuluo.com/ysner/note/1300791 题面 贝西和她的朋友艾尔西正在玩这个简单的纸牌游戏.游戏有\(2N\)张牌,牌上的数字是\(1\)到\(2N\). ...
- urllib2.urlopen超时未设置导致程序卡死
没有设置timeout参数,结果在网络环境不好的情况下,时常出现read()方法没有任何反应的问题,程序卡死在read()方法里,搞了大半天,才找到问题,给urlopen加上timeout就ok了,设 ...
- 给网站添加免费Https SSL证书
基于阿里云的云盾证书服务,系统是centos6.8,web服务器是nginx1.8.0,简单记录下踩坑情况. 申请证书 登录阿里云控制台→安全(云盾)→证书服务→购买证书(https://common ...
- P2700逐个击破(并查集/树形dp)
P2700 逐个击破 题目背景 三大战役的平津战场上,傅作义集团在以北平.天津为中心,东起唐山西至张家口的铁路线上摆起子一字长蛇阵,并企图在溃败时从海上南逃或向西逃窜.为了就地歼敌不让其逃走,老毛同志 ...
- VBNET AutoCAD Activex 切换图层为当前图层失效
最近有朋友询问切换图层的代码 com切换图层 <CommandMethod("mycl")> Public Sub MySubLayerChange() Dim Thi ...
- Springboot 相关注解大全
1.Spring注解 1.@Autowired 标注在方法,Spring容器创建当前对象,就会调用方法,完成赋值:方法使用的参数,自定义类型的值从ioc容器中获取自动装配; Spring利用依赖注入( ...
- Maven之项目搭建与第一个helloworld(多图)
这次记录第一个搭建一个maven的helloworld的过程. 转载 1.搭建web工程肯定得new 一个 maven工程,假如project中没有直接看到maven工程,那么选择Other,然后在W ...