roid是一款使用node.js开发的极其简单的打包软件,看完本文,你可以实现一个非常简单但实用的前端代码打包工具。不想看教程就看代码(都是注释):为什么点击地址就写roid?我们每天都面对这些前端编译工具,但是从很多谈话中了解到,并没有多少人知道这些包软件背后的工作原理,所以出现了这个项目。编译原理之类的确实不需要了解太多。如果你之前对node.js非常熟悉,那你一定对前端打包工具有很好的了解。了解打包工具背后的原理,将帮助我们实现各种神奇的自动化和工程化的东西,比如表单的双向绑定,自创的JavaScript语法,还有蚂蚁金服蚂蚁中大名鼎鼎的import插件,甚至前端文件自动扫描加载等,可以大大提高我们的工作效率。废话不多说,直接开始。从一个自动递增的id开始'babel-core')letID=0//当前用户的操作目录constcurrentPath=process.cwd()id:全局自增id,记录每个加载模块的id,我们将所有模块用它来标记一个唯一的标识符,所以自增id是最有效和直观的。可以数出有多少个模块。解析单个文件模块functionparseDependencies(filename){constrawCode=readFileSync(filename,'utf-8')constast=transform(rawCode).astconstdependencies=[]traverse(ast,{ImportDeclaration(path){constsourcePath=path.node.source.valuedependencies.push(sourcePath)}})//当我们收集完依赖后,我们可以将我们的代码从AST转换为CommenJS代码//这种方式更兼容也更好constes5Code=transformFromAst(ast,null,{presets:['env']}).code//还记得我们的webpack-loader系统吗?//具体的实现可以在这里实现//通过将文件名和代码传入loader,进行判断,甚至转换自定义行为//来实现loader的机制,当然我们这里是为了makea弱智版的loader就好了//这里parcel的优化技巧很有意思。在webpack中,我们在每个loader之间传递的是转换后的代码//而不是AST,所以我们必须通过每个loader进行代码->AST转换,这是非常耗时的。//parcel方法其实就是直接传递AST而不是转换后的代码,这样速度会更快。constcustomCode=loader(filename,es5Code)//Finalmoduleexportreturn{id:ID++,code:customCode,dependencies,filename}}首先,我们处理每个文件。因为这只是一个简单版的bundler,我们不考虑如何解析css、md、txt等格式,我们专注于打包js文件,因为对于其他文件,这个过程并不容易是一样的,并且很容易通过文件后缀来区分它们以进行不同的处理。在这个版本中,我们仍然专注于js。constrawCode=readFileSync(filename,'utf-8')函数注入一个文件名,顾名思义就是文件名,读取它的文件文本内容,然后解析成AST。我们使用babel的transform方法来转换我们的原始代码。经过改造,我们的代码变成了抽象语法树(AST)。通过可视化网站https://astexplorer.net/可以看到AST生成了什么。我们解析完之后,就可以提取当前文件中的依赖,翻译成依赖,也就是我们文件中的所有importxxxxfromxxxx。我们把这些依赖放在dependencies数组中,然后统一导出。然后用traverse遍历我们的代码。遍历函数是一种遍历AST的方法,由babel-traverse提供。它的遍历模式是经典的访问者模式。访问者模式是定义一系列访问者。当遇到AST===visitorname的类型时,就会进入这个visitor的函数。类型为ImportDeclaration的AST节点其实就是我们的importxxxfromxxxx,最后把地址push到dependencies中。最后导出的时候不要忘记,每导出一个文件模块,我们都会将全局自增id加1,以保证每个文件模块的唯一性。解析所有文件,生成依赖图functionparseGraph(entry){//从入口开始,先收集入口文件的依赖关系constentryAsset=parseDependencies(path.resolve(currentPath,entry))//graph其实是一个数组,我们将最初始的入口模块放在最开头constgraph=[entryAsset]for(constassetofgraph){if(!asset.idMapping)asset.idMapping={}//获取文件对应的文件夹在资产constdir=路径中。dirname(asset.filename)//每个文件都会被解析产生一个依赖关系,这个依赖关系是一个数组,在前面的函数中已经提到过//所以我们需要遍历这个数组来提取所有有用的信息//就是值得注意的是asset.idMapping[dependencyPath]=dependencyAsset.id操作//下面我们看下asset.dependencies.forEach(dependencyPath=>{//获取模块在文件中的绝对路径,比如importABCfrom'./world'//会被转换成像/User/xxxx/desktop/xproject/world这样的形式constid=dependencyAsset.id//这里很重要,我们每次解析一个模块,都会记录在文件moduleasset下的idMapping中//require之后,我们可以通过thisid值,找到这个模块对应的代码,运行它asset.idMapping[dependencyPath]=dependencyAsset.id//将解析后的模块推入图中graph.push(denpencyAsset)})}//返回这个图returngraph}接下来,我们对模块进行更高级的处理。我们之前已经写了一个parseDependecies函数,所以现在我们要写一个parseGraph函数。我们把所有文件模块的集合称为图(dependencygraph),它用来描述我们项目的所有依赖关系,parseGraph从入口(entry)开始,直到所有文件都移动。这里我们使用forof循环而不是forEach,因为我们会在循环中不断对图进行推送,图会不断增加。使用forof将继续这个循环,直到图形不再被推入对象。这意味着已经解决了所有的依赖关系,图数组的数量不会再继续增加,但是不能使用forEach,它只会遍历一次。在forof循环中,asset代表解析后的模块,包含文件名、代码、依赖等,asset.idMapping是一个不太好理解的概念。我们会对每个文件进行导入操作,导入操作后面会转换成require,每个文件中require的路径其实都会对应一个数字自增id。这个自增id其实就是我们一开始设置的id。我们使用path-id这个键值对来相互对应。那么我们在文件中通过require就可以很方便的找到文件的代码。之所以解释得这么啰嗦,是因为模块之间的引用往往很复杂,这恰好是这个概念难以解释的原因。最后,生成bundlefunctionbuild(graph){//我们的模块是一个字符串letmodules=''graph.forEach(asset=>{modules+=`${asset.id}:[function(require,module,exports){${asset.code}},${JSON.stringify(asset.idMapping)},],`})constwrap=`(function(modules){functionrequire(id){const[fn,idMapping]=modules[id];functionchildRequire(filename){returnrequire(idMapping[filename]);}constnewModule={exports:{}};fn(childRequire,newModule,newModule.exports);返回newModule.exports}require(0);})({${modules}});`//请注意,您需要在此处向模块添加{}返回包装}//这是加载程序函数的最简单实现loader(filename,code){if(/index/.test(filename)){console.log('thisisloader')}returncode}//最后我们导出我们的bundlermodule.exports=entry=>{constgraph=parseGraph(entry)constbundle=build(graph)returnbundle}我们已经完成了graph的收集,接下来就是打包我们真正的代码了,这个函数用到了很多字符串处理,不要奇怪为什么代码和字符串可以混写。如果跳出写代码的范畴,再看我们的代码,其实代码就是字符串,只不过是以一种特殊的语言形式组织起来的罢了,对于脚本语言JS来说,就是将字符串拼接成代码,然后跑步。这个操作在前端很常见。我认为这种思维转变是自动化和工程化的第一步。我们将graph中的assets全部取出来,然后用node.js的方法做modules来包裹一段代码。之前有一篇关于《庖丁解牛:教你如何实现》node.jsmodules的文章。不明白的可以看看,https://zhuanlan.zhihu.com/p/...这里简单介绍一下,我们把转换后的源码放到一个函数中(require,module,exports){}函数,这个函数的参数就是我们可以随处使用的require、module、exports,这就是为什么我们可以随处使用这三个东西,因为我们每个文件的代码最终都会被这样一个函数包裹起来,但是这段代码比较奇怪的是,我们把代码封装成1:[...],2:[...]的形式,最后导入模块的时候,我们会在这个字符串里面加一个{},变成了{1:[...],2:[...]},你没有看错,这是一个对象,其中一个数字作为键,一个二维元组作为一个value:[0]第一个是我们包装的代码[1]第二个是我们的映射即将面世。这段代码其实就是模块引入的核心逻辑。我们创建了一个顶级require函数,它接收一个id作为值并返回一个全新的模块对象,我们导入刚刚制作的模块,并在其中添加{},使其成为类似{1:[...],2:[...]}的完整形式。然后塞进我们的立即执行函数(function(modules){...})(),在(function(modules){...})()中,我们先调用require(0),原因很简单,因为我们的主模块永远是排在第一位的,然后,在我们的require函数中,我们获取外部传入的模块,使用我们一直说的全局数字id来获取我们的模块,每个模块获取到的是一个二维元组。然后,我们要创建一个子需求。之所以会这样,是因为我们在文件中使用require时,一般都是require地址,而在使用顶层require函数参数时,不用担心id。这里使用了我们之前的idMapping,通过用户require输入的地址在idMapping中找到id。然后递归调用require(id)实现模块的自动导入,然后创建一个constnewModule={exports:{}};,运行我们的函数fn(childRequire,newModule,newModule.exports);,将Throwinwhat应该是丢进去了,最后返回newModule.exports这个模块的exports对象。这里的逻辑其实和node.js差别不大。最后,写一些测试测试的代码。我已经放在仓库里了。想测试的同学可以自己去仓库提取。注释掉的代码也放在仓库里,点击地址gitclonehttps://github.com/Foveluy/roid.gitnpminode./src/_test.js./example/index.jsoutputthisisloaderhellozhengFang!欢迎来到roid,我是zhengFang如果你喜欢roid并且学到了什么,请给我一个starhttps://github.com/Foveluy/roidReferencehttps://github.com/blackLearn...https://github.com/罗纳米/分钟...
