无编译/无服务器,实现浏览器的 CommonJS 模块化入门知识_模块基础知识入门

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

无编译/无服务器,实现浏览器的 CommonJS 模块化入门知识

引言

平时经常会逛 Github,除了一些 star 极高的大项目外,还会在 Github 上发现很多有意思的小项目。项目或是想法很有趣,或是有不错的技术点,读起来都让人有所收获。所以准备汇总成一个「漫游Github」系列,不定期分享与解读在 Github 上偶遇的有趣项目。本系列重在原理性讲解,而不会深扣源码细节。

无编译/无服务器,实现浏览器的 CommonJS 模块化入门知识_模块基础知识入门

好了下面进入正题。本期要介绍的仓库叫 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 涉及到的工作:

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

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 加载后,会做两件事:

  1. 记录当前模块中依赖的子模块;
  2. 加载子模块。

这样,我们就可以在 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 仓库进一步了解。


好了,这期的「漫游 Github」就到这里了。本系列会不定期和大家一起看一看、聊一聊、学一学 github 上有趣的项目,不仅学习一些技术点,还可以了解作者的技术思考,欢迎感兴趣的小伙伴关注。

原文:https://segmentfault.com/a/1190000021523169

海计划公众号
(0)
上一篇 2020/03/22 01:26
下一篇 2020/03/22 01:26

您可能感兴趣的内容

  • Js apply/call/bind的区别及自我实现菜鸟知识_区别基础教程

    call/apply/bind 日常编码中被开发者用来实现 “对象冒充”,也即 “显示绑定 this“。面试题:“call/apply/bind源码实现”,事实上是对 JavaScript 基础知识的一个综合考核。相关知识点:作用域;this 指向;函数柯里化;原型与原型链;call/apply/bind 的区别三者都可用于显示绑定 this;call/ap

    2020/03/29
  • 用js获取url地址协议,参数,端口号,锚点等方法总汇入门基础教程_参数基础入门

    在前端开发中,经常会遇到获取URL的相关数据,下面将总结下使用JavaScript来获取url地址的协议,参数,端口号,锚点等方法。获取整个地址栏地址var href = window.location.href;
    console.log(href);//输出完整的url地址获取url协议部分var protocol = window.location.pr

    2020/04/06
  • vue内置指令大全使用帮助整理常用的Vue内置指令_指令零基础入门

    v-bind指令: v-bind主要用于动态绑定DOM元素的属性,例如:v-bind:href v-bind:class v-bind:title等等, v-bind 指令可以简写成一个冒号“:”,在开发中常用简写形式,也就是以下两种写法是等价的。fly63.com

    <a :h

    2020/04/05
  • 如何理解JS的单线程?教程视频_线程教程视频

    JS本质是单线程的。也就是说,它并不能像JAVA语言那样,两个线程并发执行。 但我们平时看到的JS,分明是可以同时运作很多任务的,这又是怎么回事呢?首先,JS的代码,大致分为两类,同步代码和异步代码。console.log(1)
    console.log(2)
    console.log(3)这是典型的同步代码,编写顺序就是执行顺序。JS引擎的主线程负责执行代码,

    2020/03/30
  • 华为,小米部分机型微信浏览器rem不适配的解决方案基础入门_rem基础知识

    针对近日华为,小米的部分机型,在升级系统或升级微信之后,微信内置浏览器产生的rem不能正确填充满的问题,有如下解决方案目前来看,产生这个情况的原因是因为给html附font-size时,附上的font-size和实际上html的font-size 大小并不一致如图:在问题机型上展示的三个值 分别为 1.机型最终附给html的font-size大小 2.我想

    2020/03/20
  • Slang小白入门_一种用JS构建的音频编程语言

    Slang小白入门 官方网址:http://slang.kylestetz.com GitHub:https://github.com/kylestetz/slang 简介描述:一…

    2020/03/06
  • at-ui菜鸟教程_轻量级、模块化的前端 UI 组件库

    at-ui菜鸟教程 官方网址:https://at.aotu.io GitHub:https://github.com/at-ui/at-ui 简介描述:轻量级、模块化的前端 UI…

    2020/03/06
  • reactxp基础入门_基于 React 和 React Native 的跨平台应用程序开发库

    reactxp基础入门 官方网址:https://microsoft.github.io/reactxp/ GitHub:https://github.com/Microsoft/…

    2020/03/06
  • Array.from() 五个超好用的用途入门攻略_array菜鸟教程

    任何一种编程语言都具有超出基本用法的功能,它得益于成功的设计和试图去解决广泛问题。JavaScript 中有一个这样的函数: Array.from:允许在 JavaScript 集合(如: 数组、类数组对象、或者是字符串、map 、set 等可迭代对象) 上进行有用的转换。在本文中,我将描述5个有用且有趣的 Array.from() 用例。1. 介绍在开始之

    2020/03/24
  • 程序员的寂寞小白基础_程序员小白知识

    白天的写字楼中闪现着一个个忙碌的身影,夜晚的出租屋里栖息着一个个寂寞的灵魂。忙碌和寂寞对于无数漂泊在大都市之中的年轻人来说,是硬币的两面,谁也不能幸免,连那些被“996”和加班所束缚的程序员也不能幸免。程序员在公司把键盘敲得劈啪作响,鼠标左一下右一下地点个没完,大脑就像面前电脑的CPU一般快速地运转着,这时候的寂寞无处着生。可一旦程序员停止这些动作,下了班或

    2020/03/29
  • Git命令总结入门基础_命令指南教程

    Git 是当前最流行的版本控制程序之一,文本包含了 Git 的一些基本用法 创建 git 仓库 初始化 git 仓库mkdir project # 创建项目目录cd project # 进入到项目目录git init # 初始化 git 仓库。此命令会在当前目录新建一个 .git 目录,用于存储 git 仓库的相关信息初始化提交touch README

    2020/03/23
  • 如何通过自定义域名方式访问本地WEB应用使用指南_域名入门基础教程

    自定义域名访问本地WEB应用。本地安装了WEB服务端,怎样通过自定义域名方式实现从公网访问本地WEB应用?本文将介绍具体的实现步骤。1. 准备工作1.1 安装并启动WEB服务端默认安装的WEB端口是80。1.2 申请域名并完成域名备案可以在万网、百度云、腾讯云、西部数码等等域名服务商注册并购买域名。在域名服务商注册并购买的域名必须要完成域名备案,否则无法使用

    2020/04/03
  • 程序员常说的话(或者口头禅)菜鸟教程网_程序员小白攻略

    虽然代码总会有这个那个问题,但程序猿却总有谜一般的从容和自信。今天来列举一些程序员最喜欢说的一些话,看看你有没有中招?遇到bug第一句话就是:”咦,这不科学啊”!解决bug以后会说:”我tm真是个天才”!看到自己几个月前写的代码:”劳资tm当初为什么要这么写?脑袋有坑吗?”同事问我一些简单的问题:”不知道,我不会,你问他”。同事问我一些稍微有点难度的问题:”

    2020/03/20
  • GraphQL 项目中的前端 mock 方案基础入门_工具使用说明

    在使用 GraphQL (以下简称 gql)的前端项目中,往往需要等待后台同学定义好 Schema 并架设好 Playground 以后才能进行联调。如果后台同学阻塞了,前端只能被动等待。如果对于 gql 项目来说也能够和 REST 一样有一套 mock 方案就好了。经过一系列实践,我选择了 mocker-api 加 Apollo 的方案来实现。mocker

    2020/03/26
  • Html5、Css3、ES6的新特性菜鸟知识_特性使用帮助

    Html5的新特性1.语义化标签有利于SEO,有助于爬虫抓取更多的有效信息,爬虫是依赖于标签来确定上下文和各个关键字的权重。语义化的HTML在没有CSS的情况下也能呈现较好的内容结构与代码结构方便其他设备的解析便于团队开发和维护2.表单新特性3.多媒体视频(video)和音频(audio)4.web存储sessionstorage:关闭浏览器清空数据,储存大

    2020/04/03
  • 扔掉 cli,webpack工程轻量化配置实战教程视频_cli菜鸟指南

    前言之前有用 webpack4与babel7改造基于vue-cli2生成的工程模板,介绍文章在此。之后通过一些实践,除去了cli工具相对复杂的配置结构,提供轻量化版本的配置方案。之所以说是轻量化,是相对于Vue、React等框架提供的官方cli工具而言的。并不是说这些cli工具不好,它们本身提供了开箱即用的良好特性,又集成了很多提升开发体验的插件,确实能降低

    2020/03/23