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 ,因此,我们并不考虑如何去解析 cssmdtxt 等等之类的格式,我们专心处理好 js 文件的打包,因为对于其他文件而言,处理起来过程不太一样,用文件后缀很容易将他们区分进行不同的处理,在这个版本,我们还是专注 js

const rawCode = readFileSync(filename, 'utf-8') 函数注入一个 filename 顾名思义,就是文件名,读取其的文件文本内容,然后对其进行 AST 的解析。我们使用 babeltransform 方法去转换我们的原始代码,通过转换以后,我们的代码变成了抽象语法树( AST ),你可以通过 https://astexplorer.net/, 这个可视化的网站,看看 AST 生成的是什么。

当我们解析完以后,我们就可以提取当前文件中的 dependenciesdependencies 翻译为依赖,也就是我们文件中所有的 import xxxx from xxxx,我们将这些依赖都放在 dependencies 的数组里面,之后统一进行导出。

然后通过 traverse 遍历我们的代码。traverse 函数是一个遍历 AST 的方法,由 babel-traverse 提供,他的遍历模式是经典的 visitor 模式
visitor 模式就是定义一系列的 visitor ,当碰到 ASTtype === 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(依赖图),用于描述我们这个项目的所有的依赖关系,parseGraphentry (入口) 出发,一直手机完所有的以来文件为止.

在这里我们使用 for of 循环而不是 forEach ,原因是因为我们在循环之中会不断的向 graph 中,push 进东西,graph 会不断增加,用 for of 会一直持续这个循环直到 graph 不会再被推进去东西,这就意味着,所有的依赖已经解析完毕,graph 数组数量不会继续增加,但是用 forEach 是不行的,只会遍历一次。

for of 循环中,asset 代表解析好的模块,里面有 filename , code , dependencies 等东西 asset.idMapping 是一个不太好理解的概念,我们每一个文件都会进行 import 操作,import 操作在之后会被转换成 require 每一个文件中的 requirepath 其实会对应一个数字自增 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){} 函数中,这个函数的参数就是我们随处可用的 requiremodule,以及 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的更多相关文章

  1. 【模块化编程】理解requireJS-实现一个简单的模块加载器

    在前文中我们不止一次强调过模块化编程的重要性,以及其可以解决的问题: ① 解决单文件变量命名冲突问题 ② 解决前端多人协作问题 ③ 解决文件依赖问题 ④ 按需加载(这个说法其实很假了) ⑤ ..... ...

  2. 一个简单的CS系统打包过程图文版

    一个简单的CS系统打包过程图文版 1.     打包内容 1.1.  此次打包的要求和特点 主工程是一个CS系统: 此CS系统运行的先决条件是要有.Net Framework 3.5: 主工程安装完成 ...

  3. 使用 java 实现一个简单的 markdown 语法解析器

    1. 什么是 markdown Markdown 是一种轻量级的「标记语言」,它的优点很多,目前也被越来越多的写作爱好者,撰稿者广泛使用.看到这里请不要被「标记」.「语言」所迷惑,Markdown 的 ...

  4. 简单了解Phar代码打包工具的使用

    简单了解Phar代码打包工具的使用 Phar 是在 PHP5 之后提供的一种类似于将代码打包的工具.本质上是想依照 Java 的 Jar 文件那种形式的代码包,不过本身由于 PHP 是不编译的,所以这 ...

  5. webpack前端模块打包器

    webpack前端模块打包器 学习网址: https://doc.webpack-china.org/concepts/ http://www.runoob.com/w3cnote/webpack-t ...

  6. 用Vue编写一个简单的仿Explorer文件管理器

    ​大家一定很熟悉你桌面左上角那个小电脑吧,学名Windows资源管理器,几乎所有的工作都从这里开始,文件云端化是一种趋势.怎样用浏览器实现一个Web版本的Windows资源管理器呢?今天来用Vue好好 ...

  7. app前端代码打包步骤

    一.搭建项目环境 1.安装node.js 在网上找到nodejs压缩包,下载解压后安装node-v8.9.3-x64.msi文件. 安装完毕后,在windows的cmd控制台输入node -v或nod ...

  8. 洗礼灵魂,修炼python(3)--从一个简单的print代码揭露编码问题,运行原理和语法习惯

    前期工作已经准备好后,可以打开IDE编辑器了,你可以选择python自带的IDLE,也可以选择第三方的,这里我使用pycharm--一个专门为python而生的IDE 按照惯例,第一个python代码 ...

  9. 原生JS实现一个简单的前端路由(原理)

    说一下前端路由实现的简要原理,以 hash 形式(也可以使用 History API 来处理)为例, 当 url 的 hash 发生变化时,触发 hashchange 注册的回调,回调中去进行不同的操 ...

随机推荐

  1. 【Silverlight】Bing Maps学习系列(六):使用扩展模式(Extended Modes)(转)

    [Silverlight]Bing Maps学习系列(六):使用扩展模式(Extended Modes) 微软Bing Maps推出有有段时间了,通过不断的改进和新的地图更新,现在已经基本上形成了一套 ...

  2. 蓝书2.3 Trie字典树

    T1 IMMEDIATE DECODABILITY poj 1056 题目大意: 一些数字串 求是否存在一个串是另一个串的前缀 思路: 对于所有串经过的点权+1 如果一个点的end被访问过或经过一个被 ...

  3. 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 ...

  4. [USACO15DEC]High Card Low Card (Platinum)

    https://www.zybuluo.com/ysner/note/1300791 题面 贝西和她的朋友艾尔西正在玩这个简单的纸牌游戏.游戏有\(2N\)张牌,牌上的数字是\(1\)到\(2N\). ...

  5. urllib2.urlopen超时未设置导致程序卡死

    没有设置timeout参数,结果在网络环境不好的情况下,时常出现read()方法没有任何反应的问题,程序卡死在read()方法里,搞了大半天,才找到问题,给urlopen加上timeout就ok了,设 ...

  6. 给网站添加免费Https SSL证书

    基于阿里云的云盾证书服务,系统是centos6.8,web服务器是nginx1.8.0,简单记录下踩坑情况. 申请证书 登录阿里云控制台→安全(云盾)→证书服务→购买证书(https://common ...

  7. P2700逐个击破(并查集/树形dp)

    P2700 逐个击破 题目背景 三大战役的平津战场上,傅作义集团在以北平.天津为中心,东起唐山西至张家口的铁路线上摆起子一字长蛇阵,并企图在溃败时从海上南逃或向西逃窜.为了就地歼敌不让其逃走,老毛同志 ...

  8. VBNET AutoCAD Activex 切换图层为当前图层失效

    最近有朋友询问切换图层的代码 com切换图层 <CommandMethod("mycl")> Public Sub MySubLayerChange() Dim Thi ...

  9. Springboot 相关注解大全

    1.Spring注解 1.@Autowired 标注在方法,Spring容器创建当前对象,就会调用方法,完成赋值:方法使用的参数,自定义类型的值从ioc容器中获取自动装配; Spring利用依赖注入( ...

  10. Maven之项目搭建与第一个helloworld(多图)

    这次记录第一个搭建一个maven的helloworld的过程. 转载 1.搭建web工程肯定得new 一个 maven工程,假如project中没有直接看到maven工程,那么选择Other,然后在W ...