roid 是一个极其简单的打包软件,使用 node.js 开发而成,看完本文,你能够实现一个很是简单的,可是又有实际用途的前端代码打包工具。css
若是不想看教程,直接看代码的(所有注释):点击地址前端
咱们天天都面对前端的这几款编译工具,可是在大量交谈中我得知,并非不少人知道这些打包软件背后的工做原理,所以有了这个 project 出现。诚然,你并不须要了解太多编译原理之类的事情,若是你在此以前对 node.js 极为熟悉,那么你对前端打包工具必定能很是好的理解。node
弄清楚打包工具的背后原理,有利于咱们实现各类神奇的自动化、工程化东西,好比表单的双向绑定,自创 JavaScript 语法,又如蚂蚁金服 ant 中大名鼎鼎的 import 插件,甚至是前端文件自动扫描载入等,可以极大的提高咱们工做效率。webpack
不废话,咱们直接开始。git
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
是最有效也是最直观的,有多少个模块,一统计就出来了。github
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
。web
const rawCode = readFileSync(filename, 'utf-8')
函数注入一个 filename 顾名思义,就是文件名,读取其的文件文本内容,而后对其进行 AST 的解析。咱们使用 babel
的 transform
方法去转换咱们的原始代码,经过转换之后,咱们的代码变成了抽象语法树( AST
),你能够经过 https://astexplorer.net/, 这个可视化的网站,看看 AST
生成的是什么。npm
当咱们解析完之后,咱们就能够提取当前文件中的 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 中.bash
最后导出的时候,不要忘记了,每导出一个文件模块,咱们都往全局自增 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
就可以轻松的找到文件的代码,解释这么啰嗦的缘由是每每模块之间的引用是错中复杂的,这恰巧是这个概念难以解释的缘由。
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
,一个二维元组做为值:
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