在线时间:8:00-16:00
迪恩网络APP
随时随地掌握行业动态
扫描二维码
关注迪恩网络微信公众号
引言平时经常会逛 Github,除了一些 star 极高的大项目外,还会在 Github 上发现很多有意思的小项目。项目或是想法很有趣,或是有不错的技术点,读起来都让人有所收获。所以准备汇总成一个「漫游Github」系列,不定期分享与解读在 Github 上偶遇的有趣项目。本系列重在原理性讲解,而不会深扣源码细节。 好了下面进入正题。本期要介绍的仓库叫one-click.js。 1. one-click.js是什么one-click.js是个很有意思的库。Github 里是这么介绍它的: 我们知道,如果希望 Commonjs的模块化代码能在浏览器中正常运行,通常都会需要构建/打包工具,例如webpack、rollup 等。而 one-click.js 可以让你在不需要这些构建工具的同时,也可以在浏览器中正常运行基于 CommonJS 的模块系统。 进一步的,甚至你都不需要启动一个服务器。例如试着你可以试下 clone 下 one-click.js 项目,直接双击(用浏览器打开)其中的example/index.html就可以运行。 Repo 里有一句话概述了它的功能:
举个例子来说 —— 假设在当前目录(demo/)现在,我们有三个“模块”文件: demo/plus.js: // plus.js module.exports = function plus(a, b) { return a + b; } demo/divide.js: // divide.js module.exports = function divide(a, b) { return a / b; } 与入口模块文件demo/main.js: // main.js const plus = require('./plus.js'); const divide = require('./divide.js'); console.log(divide(12, add(1, 2))); // output: 4 常见用法是指定入口,用webpack编译成一个 bundle,然后浏览器引用。而 one-click.js 让你可以抛弃这些,只需要在html中这么用: <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>one click example</title> </head> <body> <script type="text/JavaScript" src="./one-click.js" data-main="./main.js"></script> </body> </html> 注意script标签的使用方式,其中的data-main就指定了入口文件。此时直接用浏览器打开这个本地 HTML 文件,就可以正常输出结果 7。 2. 打包工具是如何工作的?上一节介绍了 one-click.js 的功能 —— 核心就是实现不需要打包/构建的前端模块化能力。 在介绍其内部实现这之前,我们先来了解下打包工具都干了什么。俗话说,知己知彼,百战不殆。 还是我们那三个JavaScript文件。 plus.js: // plus.js module.exports = function plus(a, b) { return a + b; } divide.js: // divide.js module.exports = function divide(a, b) { return a / b; } 与入口模块 main.js: // main.js const plus = require('./plus.js'); const divide = require('./divide.js'); console.log(divide(12, add(1, 2))); // output: 4 回忆一下,当我们使用 webpack 时,会指定入口(main.js)。webpack 会根据该入口打包出一个 bundle(例如 bundle.js)。最后我们在页面中引入处理好的 bundle.js 即可。这时的 bundle.js 除了源码,已经加了很多 webpack 的“私货”。 简单理一理其中 webpack 涉及到的工作:
如果对以上的 2、3 项不太了解,可以从篇文章中了解webpack 的模块运行时设计。 3. 我们面对的挑战没有了构建工具,直接在浏览器中运行使用了 CommonJS 的模块,其实就是要想办法完成上面提到的三项工作:
解决这三个问题就是 one-click.js 的核心任务。下面我们来分别看看是如何解决的。 3.1. 依赖分析这是个麻烦的问题。如果想要正确加载模块,必须准确知道模块间的依赖。例如上面提到的三个模块文件 ——main.js依赖plus.js和divide.js,所以在运行main.js中代码时,需要保证plus.js和divide.js都已经加载进浏览器环境。然而问题就在于,没有编译工具后,我们自然无法自动化的知道模块间的依赖关系。 对于RequireJS这样的模块库来说,它是在代码中声明当前模块的依赖,然后使用异步加载加回调的方式。显然,CommonJS 规范是没有这样的异步 API 的。 而 one-click.js 用了一个取巧但是有额外成本的方式来分析依赖 —— 加载两遍模块文件。在第一次加载模块文件时,为模块文件提供一个 mock 的require方法,每当模块调用该方法时,就可以在 require 中知道当前模块依赖哪些子模块了。 // main.js const plus = require('./plus.js'); const divide = require('./divide.js'); console.log(minus(12, add(1, 2))); 例如上面的main.js,我们可以提供一个类似下面的require方法: const recordedFieldAccessesByRequireCall = {}; const require = function collect(modPath) { recordedFieldAccessesByRequireCall[modPath] = true; var script = document.createElement('script'); script.src = modPath; document.body.appendChild(script); }; main.js加载后,会做两件事:
这样,我们就可以在recordedFieldAccessesByRequireCall中记录当前模块的依赖情况;同时加载子模块。而对于子模块也可以有递归操作,直到不再有新的依赖出现。最后将各个模块的recordedFieldAccessesByRequireCall整合起来就是我们的依赖关系。 此外,如果我们还想要知道main.js实际调用了子模块中的哪些方法,可以通过Proxy来返回一个代理对象,统计进一步的依赖情况: const require = function collect(modPath) { recordedFieldAccessesByRequireCall[modPath] = []; var megaProxy = new Proxy(function(){}, { get: function(target, prop, receiver) { if(prop == Symbol.toPrimitive) { return function() {0;}; } return megaProxy; } }); var recordFieldAccess = new Proxy(function(){}, { get: function(target, prop, receiver) { window.recordedFieldAccessesByRequireCall[modPath].push(prop); return megaProxy; } }); // …… 一些其他处理 return recordFieldAccess; }; 以上的代码会在你获取被导入模块的属性时记录所使用的属性。 上面所有模块的加载就是我们所说的“加载两遍”的第一遍,用于分析依赖关系。而第二遍就需要基于入口模块的依赖关系,“逆向”加载模块即可。例如main.js依赖plus.js和divide.js,那么实际上加载的顺序是plus.js->divide.js->main.js。 值得一提的是,在第一次加载所有模块的过程中,这些模块执行基本都是会报错的(因为依赖的加载顺序都是错误的),我们会忽略执行的错误,只关注依赖关系的分析。当拿到依赖关系后,再使用正确的顺序重新加载一遍所有模块文件。one-click.js 中有更完备的实现,该方法名为scrapeModuleIdempotent,具体源码可以看这里。 到这里你可能会发现:“这是一种浪费啊,每个文件都加载了两遍。” 确实如此,这也是 one-click.js 的tradeoff:
3.2. 作用域隔离我们知道,模块有一个很重要的特点 —— 模块间的作用域是隔离的。例如,对于如下普通的 JavaScript 脚本: // normal script.js var foo = 123; 当其加载进浏览器时,foo变量实际会变成一个全局变量,可以通过window.foo访问到,这也会带来全局污染,模块间的变量、方法都可能互相冲突与覆盖。 在 NodeJS 环境下,由于使用 CommonJS 规范,同样像上面这样的模块文件被导入时,foo变量的作用域只在源模块中,不会污染全局。而 NodeJS 在实现上其实就是用一个 wrap function 包裹了模块内的代码,我们都知道,function 会形成其自己的作用域,因此就实现了隔离。 NodeJS 会在require时对源码文件进行包装,而 webpack 这类打包工具会在编译期对源码文件进行改写(也是类似的包装)。而 one-click.js 没有编译工具,那编译期改写肯定行不通了,那怎么办呢?下面来介绍两种常用方式: 3.2.1. JavaScript 的动态代码执行 一种方式可以通过fetch请求获取 script 中文本内容,然后通过new Function或eval这样的方式来实现动态代码的执行。这里以fetch+new Function方式来做个介绍: 还是上面的除法模块divide.js,稍加改造下,源码如下: // 以脚本形式加载时,该变量将会变为 window.outerVar 的全局变量,造成污染 var outerVar = 123; module.exports = function (a, b) { return a / b; } 现在我们来实现作用域屏蔽: const modMap = {}; function require(modPath) { if (modMap[modPath]) { return modMap[modPath].exports; } } fetch('./divide.js') .then(res => res.text()) .then(source => { const mod = new Function('exports', 'require', 'module', source); const modObj = { id: 1, filename: './divide.js', parents: null, children: [], exports: {} }; mod(modObj.exports, require, modObj); modMap['./divide.js'] = modObj; return; }) .then(() => { const divide = require('./divide.js') console.log(divide(10, 2)); // 5 console.log(window.outerVar); // undefined }); 代码很简单,核心就是通过fetch获取到源码后,通过new Function将其构造在一个函数内,调用时向其“注入”一些模块运行时的变量。为了代码顺利运行,还提供了一个简单的require方法来实现模块引用。 当然,上面这是一种解决方式,然而在 one-click.js 的目标下却行不通。因为 one-click.js 还有一个目标是能够在无服务器(offline)的情况下运行,所以fetch请求是无效的。 那么 one-click.js 是如何处理的呢?下面我们就来了解下: 3.2.2. 另一种作用域隔离方式 一般而言,隔离的需求与沙箱非常类似,而在前端创建一个沙箱有一种常用的方式,就是 iframe。下面为了方便起见,我们把用户实际使用的窗口叫作“主窗口”,而其中内嵌的 iframe 叫作“子窗口”。由于 iframe 天然的特性,每个子窗口都有自己的window对象,相互之间隔离,不会对主窗口进行污染,也不会相互污染。 下面仍然以加载 divide.js 模块为例。首先我们构造一个 iframe 用于加载脚本: var iframe = document.createElement("iframe"); iframe.style = "display:none !important"; document.body.appendChild(iframe); var doc = iframe.contentWindow.document; var htmlStr = ` <html><head><title></title></head><body> <script src="./divide.js"></script></body></html> `; doc.open(); doc.write(htmlStr); doc.close(); 这样就可以在“隔离的作用域”中加载模块脚本了。但显然它还无法正常工作,所以下一步我们就要补全它的模块导入与导出功能。模块导出要解决的问题就是让主窗口能够访问子窗口中的模块对象。所以我们可以在子窗口的脚本加载运行完后,将其挂载到主窗口的变量上。 修改以上代码: // ……省略重复代码 var htmlStr = ` <html><head><title></title></head><body> <scrip> window.require = parent.window.require; window.exports = window.module.exports = undefined; </script> <script src="./divide.js"></script> <scrip> if (window.module.exports !== undefined) { parent.window.modObj['./divide.js'] = window.module.exports; } </script> </body></html> `; // ……省略重复代码 核心就是通过像parent.window这样的方式实现主窗口与子窗口之间的“穿透”:
上面只是一个原理性的粗略实现,如果对更严谨的实现细节感兴趣可以看源码中的loadModuleForModuleData 方法。 值得一提的是,在「3.1. 依赖分析」中提到先加载一遍所有模块来获取依赖关系,而这部分的加载也是放在 iframe 中进行的,也需要防止“污染”。 3.3. 提供模块运行时模块的运行时一版包括了构造模块对象(module object)、存储模块对象以及提供一个模块导入方法(require)。模块运行时的各类实现一般都大同小异,这里需要注意的就是,如果隔离的方法使用 iframe,那么需要在主窗口与子窗口中传递一些运行时方法和对象。 当然,细节上还可能会需要支持模块路径解析(resolve)、循环依赖的处理、错误处理等。由于这部分的实现和很多库类似,又或者不算特别核心,在这里就不详细介绍了。 4. 总结最后归纳一下大致的运行流程: 1.首先从页面中拿到入口模块,在 one-click.js 中就是document.querySelector("script[data-main]").dataset.main; 2.在 iframe 中“顺藤摸瓜”加载模块,并在require方法中收集模块依赖,直到没有新的依赖出现; 3.收集完毕,此时就拿到了完整的依赖图; 4.根据依赖图,“逆向”加载相应模块文件,使用 iframe 隔离作用域,同时注意将主窗口中的模块运行时传给各个子窗口; 5.最后,当加载到入口脚本时,所有依赖准备就绪,直接执行即可。 总的来说,由于没有了构建工具与服务器的帮助,所以要实现依赖分析与作用域隔离就成了困难。而 one-click.js 运用上面提到的技术手段解决了这些问题。 那么,one-click.js 可以用在生产环境么?显然是不行的。
所以注意了,作者也说了,这个库的目的仅仅是方便本地开发。当然,其中一些技术手段作为学习资料,咱们也是可以了解学习一下的。感兴趣的小伙伴可以访问one-click.js 仓库进一步了解。 以上就是无编译/无服务器实现浏览器的CommonJS模块化的详细内容,更多关于无编译/无服务器实现CommonJS模块化的资料请关注极客世界其它相关文章! |
请发表评论