无编译/无服务器实现浏览器的CommonJS模块化

引言

平时经常会逛 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 里有一句话概述了它的功能:

Use CommonJS modules directly in the browser with no build step and no web server.

举个例子来说 ——

假设在当前目录(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 涉及到的工作:

  • 依赖分析:首先,在打包时 webpack 会根据语法分析结果来获取模块的依赖关系。简单来说,在 CommonJS 中就是根据解析出的 require语法来得到当前模块所依赖的子模块。
  • 作用域隔离与变量注入:对于每个模块文件,webpack 都会将其包裹在一个 function 中。这样既可以做到module、require等变量的注入,又可以隔离作用域,防止变量的全局污染。
  • 提供模块运行时:最后,为了require、exports的有效执行,还需要提供一套运行时代码,来实现模块的加载、执行、导出等功能。

如果对以上的 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:

In order to make this work offline, One Click needs to initialize your modules twice, once in the background upon page load, in order to map out the dependency graph, and then another time to actually perform the module loading.

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 可以用在生产环境么?显然是不行的。

Do not use this in production. The only purpose of this utility is to make local development simpler.

所以注意了,作者也说了,这个库的目的仅仅是方便本地开发。当然,其中一些技术手段作为学习资料,咱们也是可以了解学习一下的。感兴趣的小伙伴可以访问one-click.js 仓库进一步了解。

以上就是无编译/无服务器实现浏览器的CommonJS模块化的详细内容,更多关于无编译/无服务器实现CommonJS模块化的资料请关注我们其它相关文章!

(0)

相关推荐

  • Node对CommonJS的模块规范

    Node能够以一种相对程度的的姿态出现,离不开CommonJS规范的影响.Node借鉴CommonJS的Modules规范实现了一套非常易用的模块系统,NPM对packages规范的完好支持使得Node应用在开发过程中事半功倍. 在Node中引用模块,需要经历如下三个步骤. 1. 路径分析 Node中的模块分为核心模块和文件模块 . 核心模块是由Node提供的模块,它们在Node源代码的编译过程中就编译进了二进制执行文件,在Node进程启动时,核心模块就被直接加载进内存中,所以在引用核心模块时,

  • 详解如何使用webpack打包JS

    如何使用webpack打包JS 我们在命令行中输入:webpack -h看看webpack的命令行大全 Usage: webpack-cli [options] webpack-cli [options] --entry <entry> --output <output> webpack-cli [options] <entries...> --output <output> webpack告诉我们,用webpack进行打包有三种方法: 1.新建为webpa

  • 读懂CommonJS的模块加载

    叨叨一会CommonJS Common这个英文单词的意思,相信大家都认识,我记得有一个词组common knowledge是常识的意思,那么CommonJS是不是也是类似于常识性的,大家都理解的意思呢?很明显不是,这个常识一点都不常识.我最初认为commonJS是一个开源的JS库,就是那种非常方便用的库,里面都是一些常用的前端方法,然而我错得离谱,CommonJS不仅不是一个库,还是一个看不见摸不着的东西,他只是一个规范!就像校纪校规一样,用来规范JS编程,束缚住前端们.就和Promise一样是

  • 深入理解Commonjs规范及Node模块实现

    前面的话 Node在实现中并非完全按照CommonJS规范实现,而是对模块规范进行了一定的取舍,同时也增加了少许自身需要的特性.本文将详细介绍NodeJS的模块实现 引入 nodejs是区别于javascript的,在javascript中的顶层对象是window,而在node中的顶层对象是global [注意]实际上,javascript也存在global对象,只是其并不对外访问,而使用window对象指向global对象而已 在javascript中,通过var a = 100:是可以通过w

  • 使用Browserify来实现CommonJS的浏览器加载方法

    Nodejs的模块是基于CommonJS规范实现的,可不可以应用在浏览器环境中呢? var math = require('math'); math.add(2, 3); 第二行math.add(2, 3),在第一行require('math')之后运行,因此必须等math.js加载完成.也就是说,如果加载时间很长,整个应用就会停在那里等.这对服务器端不是一个问题,因为所有的模块都存放在本地硬盘,可以同步加载完成,等待时间就是硬盘的读取时间.但是,对于浏览器,这却是一个大问题,因为模块都放在服务

  • 详谈commonjs模块与es6模块的区别

    到目前为止,已经实习了3个月的时间了.最近在面试,在面试题里面有题目涉及到模块循环加载的知识.趁着这个机会,将commonjs模块与es6模块之间一些重要的的区别做个总结.语法上有什么区别就不具体说了,主要谈谈引用的区别. commonjs 对于基本数据类型,属于复制.即会被模块缓存.同时,在另一个模块可以对该模块输出的变量重新赋值. 对于复杂数据类型,属于浅拷贝.由于两个模块引用的对象指向同一个内存空间,因此对该模块的值做修改时会影响另一个模块. 当使用require命令加载某个模块时,就会运

  • 利用webpack理解CommonJS和ES Modules的差异区别

    前言 问: CommonJS 和 ES Modules 中模块引入的区别? CommonJS 输出的是一个值的拷贝:ES Modules 生成一个引用,等到真的需要用到时,再到模块里面去取值,模块里面的变量,绑定其所在的模块. 我相信很多人已经把这个答案背得滚瓜烂熟,好,那继续提问. 问:CommonJS 输出的值是浅拷贝还是深拷贝? 问:你能模拟实现 ES Modules 的引用生成吗? 对于以上两个问题,我也是感到一脸懵逼,好在有 webpack 的帮助,作为一个打包工具,它让 ES Mod

  • ES6与CommonJS中的模块处理的区别

    ES6和CommonJS都有自己的一套处理模块化代码的措施,即JS文件之间的相互引用. 为了方便两种方式的测试,使用nodejs的环境进行测试 CommonJS的模块处理 使用require来引入其他模块的代码,使用module.exports来引出 // exportDemo.js count = 1; module.exports.count = count; module.exports.Hello = function() { var name; this.setName = funct

  • 分享一款超好用的JavaScript 打包压缩工具

    背景 平时大家在开发 Js 项目的时候,可能已经离不开 webpack 等打包工具了.而 webpack 打包速度大概就是"能用"的水平.大概去年开始,我就开始在构想,如果能写一个极速的打包工具,功能未必需要很强,可能对小项目非常有用.去年我用 C++ 写完 parser 之后,便没什么动力写下去了.但是最近发现有这个想法的不止我一个,Figma 的 CTO 业余之际写了一个打包器 https://github.com/evanw/esbuild ,可以说完完全全实现了我想象中的需求,

  • 无编译/无服务器实现浏览器的CommonJS模块化

    引言 平时经常会逛 Github,除了一些 star 极高的大项目外,还会在 Github 上发现很多有意思的小项目.项目或是想法很有趣,或是有不错的技术点,读起来都让人有所收获.所以准备汇总成一个「漫游Github」系列,不定期分享与解读在 Github 上偶遇的有趣项目.本系列重在原理性讲解,而不会深扣源码细节. 好了下面进入正题.本期要介绍的仓库叫one-click.js. 1. one-click.js是什么 one-click.js是个很有意思的库.Github 里是这么介绍它的: 我

  • Byshell后门:无进程无DLL无硬盘文件

    适合读者:入侵爱好者.网络管理员.黑器迷 前置知识:C基本语法 刘流:后门是黑客们永恒的话题,在各大网站如163.Yahoo.北大等相继被黑之后,越来越多的人开始关注服务器的安全,而各种后门技术也空前地火暴起来!今天我们将给大家带来一个重量级后门的使用.编程方法,让广大新手朋友们有好后门玩,让编程技术爱好者有好的后门编程技术可以借鉴.当然,更多的新技术还等你去发掘. Byshell后门:无进程无DLL无硬盘文件无启动项 现在网络上流行的木马后门类工具很多,但可以称为精品的则没有多少,大多数新手们

  • Java中JFrame实现无边框无标题方法

    很多时候我们弄一个界面,不想要默认的边框,或者不想要右上角的那个最大化按钮,比如qq面板上面就没有最大化按钮. 但是我查了很多资料都说不能直接去掉最大化按钮,必须把整个边框和标题都去掉,然后自己画.. 这个测试代码也很简单: <1>JFrame无边框无标题 <2>添加背景图片(添加背景图片有很多方法,大致上都是图片在JLabel里面,JLabel在Panel上面,Panel上面还有一个Panel放其它控件) <3>创建ImageIcon,直接用new ImageIcon

  • 解决Android Studio 代码无提示无颜色区分问题

    一.问题 ①java代码没有颜色区分,统一黑色 ②代码不会联想提示,原来打前几个字母便会联想到后面的内容 二.解决 打开File,将Power save Mode的勾勾去掉 总结 以上所述是小编给大家介绍的解决Android Studio 代码无提示无颜色区分问题,希望对大家有所帮助,如果大家有任何疑问欢迎给我留言,小编会及时回复大家的!

  • node.js实现http服务器与浏览器之间的内容缓存操作示例

    本文实例讲述了node.js实现http服务器与浏览器之间的内容缓存操作.分享给大家供大家参考,具体如下: 一.缓存的作用 1.减少了数据传输,节约流量. 2.减少服务器压力,提高服务器性能. 3.加快客户端加载页面的速度. 二.缓存的分类 1.强制缓存,如果缓存有效,则不需要与服务器发生交互,直接使用缓存. 2.对比缓存,每次都需要与服务器发生交互,对缓存进行比较判断是否可以使用缓存. 三.通过使用 Last-Modified / If-Modified-Since 来进行缓存判断 1.Las

  • Java实现PDF转Word的示例代码(无水印无页数限制)

    目录 一.前言 二.jar破解 1.项目远程仓库配置 2.pom文件引入相关依赖 3.破解代码 三.pdf转word 一.前言 学习概述:简单的介绍一下本篇文章要讲解的Java知识点 学习目标:读者读完这篇文章之后,你希望他掌握你讲解的哪些重要的知识点 二.jar破解 1.项目远程仓库配置 aspose-pdf 这个需要配置单独的仓库地址才能下载,不会配置的可以去官网直接下载jar引入项目代码中. <repositories> <repository> <id>Aspo

  • 在到达无H无F境界前~还是要痛苦~我兼容浏览器的CSS

    对着多个解析不一样浏览器是件郁闷的事,是所有写CSS的人都会遇到的. 虽然条件注释是一比较理想的做法,向前向后兼容.可惜我不大喜欢N个版本的CSS, 先说下我的自己的用法. 初始化 Selectors{}  保证向后兼容性, 接着开始过滤 不管IE6有没有引进Quirks Mode 都用  * html Selectors{}  处理IE6和以下版本, 对下再向下版本的区分我做了比较复杂的处理. 用读入IE5.x @media tty { i{content:"\";/*" 

  • ajax实现服务器与浏览器长连接的功能

    有时候,需要服务器主动给浏览器推送数据,这里用ajax来实现这种功能,具体请看这里: <script type="text/javascript" src="__CSS__/bootstrap-3.3.5-dist/js/bootstrap.min.js"></script> <script type="text/javascript"> var uid = "{$uid}"; var i

  • Nginx服务器中浏览器本地缓存和虚拟机的相关设置

    自动列出目录配置: 下载过开源软件的都知道,一个很简单的页面列出了所有版本的源码包,这就是开启了自动列出目录 如下配置,在虚拟主机location / {--}目录控制中配置自动列出目录: location / { autoindex on; } 浏览器本地缓存设置: 浏览器是为了加速浏览,浏览器在用户磁盘上对最近请求过的文件进行存储,当访问者再次请求这个页面, 浏览器可以从本地磁盘显示文件,以达到加速浏览的效果,节约了网络资源,提高了网络效率 关键字: expires 默认值: off 作用域

  • ubuntu16.04登录后无dash,无启动栏launch,无menu bar只有桌面背景的快速解决办法

    今天打开电脑,与往常一样输入用户名密码登录后,发现桌面上空空如也,启动栏launch,menu bar什么的都消失了,桌面上文件可以打开,但是无法拖动位置,无法关闭(因为menu bar没了,无法鼠标点击关闭), 经过苦苦搜索几个小时之后找到解决方案如下,记录下来方便有相同问题的人: 问题原因:unity Plugin 被误删或禁用了 解决方案: 1.尝试用 ctrl + alt + t 打开命令行 2.若 ctrl + alt + t 不起作用,则可在桌面右键选择打开终端 3.若上述方法仍不起

随机推荐