为了账号安全,请及时绑定邮箱和手机立即绑定

在浏览器中手撸一个CommonJS模块化

标签:
JavaScript

LongLongAgo

当今很多语言都有模块【或者叫做包】机制,因为随着项目的扩大,代码量剧增,如果全部写在一块显得拥挤还不利于阅读,并且容易出现命名冲突。有了模块后,可以将代码分离到各个角落,代码组织的好利于管理也方便查找。这么好的一个东西在很久以前的JavaScript中是不存在的。直到2015年的ES6标准才引入了模块化机制,但直到现在,支持的浏览器依然是少数:兼容性
不了解的人也许会很奇怪为什么JS迟迟不支持模块机制。笔者猜测大概是历史遗留原因吧,JS历经10天就被发明出来了,而且早期只是为了丰富web表单的处理而出现,但随着ajax的出现,web2.0的到来使得越来越多的业务转移到前端,JavaScript得到空前的繁荣,这时候就很有必要引入模块化来管理日益增多的代码了。但是即使新标准已经引入,但距离广泛使用还需一段时间,其实,早在还没有标准以前就已经有各种社区方案被广泛使用。比如被NodeJS采用的CommonJS规范,还有AMD、CMD、UMD等也为模块化贡献了一份力。


以CommonJS为例,在NodeJS中是采用这个规范来实现模块化的,当然Node在9版本中还可以通过flag开启ES6模块支持,不过这个不是本文重点,故暂且不谈。
在Node中,每个文件都被视为一个独立的模块,有自己的作用域,在文件中定义的变量、函数、类都是私有的,对其他文件不可见。如文件sum.js:

function sum(a,b) {
	return a + b;
}

目前,sum函数对其他文件是不可见的。CommonJS规范规定,每个模块内部,module变量代表当前模块。这个变量是一个对象,它的exports属性(即module.exports)是对外的接口。加载某个模块,其实是加载该模块的module.exports属性。如下导出sum求和函数:

function sum(a,b) {
	return a + b;
}
module.exports.sum = sum;

这样就相当于将sum函数暴露给外部了,其他文件若要使用这个函数可通过require函数导入使用,如在avg.js文件中:

var pack = require('sum');
pack.sum(1,2);

这里的pack实际上是sum.js中module.exports的一个引用。如果在sum.js中 module.exports 被直接赋值成sum函数即 module.exports = sum;那么在avg.js中pack就已经是sum函数,无须再通过pack.sum访问。为了简化代码我们将sum文件的导出修改一下:

function sum(a,b) {
	return a + b;
}
module.exports = sum;

Node中还有一个变量是exports,也是用来导出,虽然两者有区别,但这里暂且不谈,集中关注module.exports和require即可,有这两个东西就已经可以实现模块化了。鉴于浏览器中还不能完美支持标准的模块化机制,本文尝试参照CommonJS规范自行实现一个,一方面也可以加深对于模块化的理解。【PS:切勿将本文的模块化应用于生产环境!!】


IIFE

在开始介绍之前,先讲解一下基础知识。在JS中有一个被称为IIFE的东西,它的样貌如下:

(function() {
	var name = 'IIFE';
})();
// 无法从外部访问变量name
name

IIFE,中文叫做——立即调用函数表达式,是一个在定义时就会立即执行的 JavaScript 函数。

这是一个被称为 自执行匿名函数 的设计模式,主要包含两部分。第一部分是包围在 圆括号运算符 () 里的一个匿名函数,这个匿名函数拥有独立的词法作用域。这不仅避免了外界访问此 IIFE 中的变量,而且又不会污染全局作用域。
第二部分再一次使用 () 创建了一个立即执行函数表达式,JavaScript 引擎到此将直接执行函数。【摘抄自MDN】

在JS中只有两种作用域【ES6以前】,一是全局作用域,另一个是函数作用域。变量若想对外部作用域不可见只能将其定义在函数内,在ES6以前想实现类似块级作用域的效果就会通过IIFE实现。


模块化实现

了解了前置知识后,接下来开始进入正题。要实现CommonJS规范中所提的要求,使用的技术便是IIFE,先看下代码有个大体印象再来讲解:

(function(modules) {
  var cache = {};
  function require(name) {
    var moduleFn = modules.find(fn => fn.name === name);
    if(!moduleFn) throw Error('未找到对应模块');
    if(cache[name]) return cache[name].exports;
    var module = {
      exports: {},
      id: name,
    };
    moduleFn(module,require);
    cache[name] = module;
    return module.exports;
  }
  require('main');
})([function main(module,require) {
  var avg = require('avg');
  console.log(avg([1,2,3]));
},function sum(module,require) {
  console.log('load sum');
  module.exports = (a,b) => a+b;
},function avg(module,require) {
  console.log('load avg');
  var sum = require('sum');
  module.exports = (arr) => arr.reduce((res,v) => sum(res,v),0)/arr.length;
}])

我们分段来看这段代码,首先是这几个函数:main, sum, avg,这几个函数对应的就是Node中的main.js, sum.js, avg.js文件,即这里有三个模块,严格说这里有两个模块,main函数实际是入口文件,应用程序总得有一个入口文件来执行开始的逻辑,就好比Java中的main函数一样。

// main函数即main.js文件,即入口函数
function main(module,require) {
  var avg = require('avg');
  console.log(avg([1,2,3]));
},
// sum函数即sum.js,这里导出求和函数
function sum(module,require) {
  console.log('load sum');
  module.exports = (a,b) => a+b;
},
// avg函数即avg.js,这里导出求平均函数
function avg(module,require) {
  console.log('load avg');
  var sum = require('sum');
  module.exports = (arr) => arr.reduce((res,v) => sum(res,v),0)/arr.length;
}

从这几个函数中可以看出,avg函数依赖sum函数,来进行求平均数,而入口函数中又导入了avg函数来进行具体值的求平均最后输出。这就是一个简单的业务逻辑代码示例,其中模块间的依赖关系也很直观。接下来的关键就是如何才能将这些依赖通过module.exports和require建立联系。核心代码是下面这段:

function(modules) {
  var cache = {};
  function require(name) {
    var moduleFn = modules.find(fn => fn.name === name);
    if(!moduleFn) throw Error('未找到对应模块');
    if(cache[name]) return cache[name].exports;
    var module = {
      exports: {},
      id: name,
    };
    moduleFn(module,require);
    cache[name] = module;
    return module.exports;
  }
  require('main');
}

首先,cache对象是用来缓存已经加载过的模块,避免重复导入,重点是require函数。
require接受一个模块文件名作为参数,然后在modules中查找该模块,如果没找到则会抛出错误;如果找到则先判断缓存中是否已有该模块,有则直接从缓存中返回,没有则调用该模块函数并传入module对象和require函数。最后的require(‘main’)是执行入口函数,这样程序才能跑起来。
首先读者可能有疑问,这个modules是在哪定义的?还记得刚刚的IIFE的官方解释吗?
实际上modules是刚刚所说的main/sum/avg函数的一个数组,通过IIFE作为一个参数传入。从这里也可以看到我们在写sum/avg的时候的module/require是怎么来的。在modules中找出模块后,会将require和module通过函数调用传到函数体内。
接下来我们执行这段代码看看输出:
log
从输出中也可以看出,程序先加载avg,然后加载sum,依赖全部载入后执行业务逻辑,输出1,2,3的平均数——2;至此,一个CommonJS的模块化方案就实现完毕了。但是目前模块都放在一起,使用IIFE执行,显然它们仍然处于同一个文件中。但是模块化的另一个特点“文件即模块”并没有实现,我们该如何将模块抽离至单独文件中呢?
JS中有一些东西被常见的一些规范所禁止,例如Function构造器,eval函数,之所以被禁止,是因为它们能够动态的执行一些代码,这被视为非常危险的行为。不过,由于其强大的动态执行能力,在某些场景下反而是一个非常好的实现手段,而我们要实现上述所说的将文件分离至单独文件中的需求也需要借助这种较为危险的API,所以,这里再次声明:切勿应用于生产环境!!
首先我们先做下准备工作,将上文中的sum,avg,main抽离至单独文件:
文件结构
这里我们把外层的函数声明给去掉了,因为本来代码用函数包裹就是为了模拟文件的作用域的。接下来就是导入这些文件了,在H5中,文件都需要通过网络请求来加载的,所以我们先定义一个ajax来加载这些JS:

function getJS(name) {
  return fetch(`./js/${name}.js`).then(res => res.text());
}

这里笔者用的是fetch API 而非传统的XMLHttpRequest,因为fetch是返回一个promise,这样有助于代码的简化,本文主要介绍的模块化,所以关于请求方式就怎么方便怎么来。另外我们将之前的核心模块加载代码抽成一个单独的函数:

function load(modules, entry) {
  var cache = {};
  function require(name) {
    var moduleFn = modules.find(fn => fn.fileName === name);
    if(!moduleFn) throw Error('未找到对应模块');
    if(cache[name]) return cache[name].exports;
    var module = {
      exports: {},
      id: name,
    };
    moduleFn(module,require);
    cache[name] = module;
    return module.exports;
  }
  require(entry);
}

这里跟之前有一点变化,首先是增加了一个参数entry,即入口函数,以参数形式传入这样能让代码更灵活,还有一个地方与先前不一样,读者可有察觉到?这里先不点破,下文会指出,并说明其原因。
由于现在模块都抽离至单独的文件了,于是我们需要通过请求文件的方式来加载模块代码,但是文件返回前端后是一段字符串,要如何才能让其执行呢?这时就是Function构造器展现强大能力的时刻了,Function构造器最后一个参数为函数体,接收的参数在函数体之前,通过new的方式可以动态创建一个函数出来,于是就可以将请求过来的JS代码字符串转为JS函数,然后将这些模块函数传入load函数,接下来的过程就如同之前所讲的那样,这里就不再赘述。

var files = ['main','avg','sum'];
Promise.all( files.map(name => getJS(name)) ).then(codes => {
  var modules = codes.map((code,i) => {
    var fn = new Function('module','require',code);
    fn.fileName = files[i];
    return fn;
  });
  load(modules, files[0]);
})

这里使用ES6的Promise来加载所有的文件,等加载完毕之后构造出模块函数之后调用load并传入,这里有一个地方需要注意的是:

fn.fileName = files[i];

这行代码的意图是什么呢?Function构造器生成的是一个匿名函数,也就是说函数的name属性会是anonymous,而且函数的name属性是只读的,不可修改,那么原先的load函数中查找模块函数名的代码就会有误:

var moduleFn = modules.find(fn => fn.name === name);

针对这个问题,解决办法就是使用别的属性,好在JS中一切皆对象的特点,function在JS中也是一个对象,故而可以给function定义一些属性,这里笔者使用fileName这个属性来替代name属性,响应的load函数中的模块查找部分也对应修改。我们打开浏览器来看看执行结果:
log
source
至此,一个简单的在浏览器中模块化方案就实现了,当然,一个完美的模块化其实远没有这么简单,这里只是一个概念的实现,并未考虑过多的实际场景,比如循环依赖的问题等等。本文主要是为了加深对模块化的理解。


注意!!

再次强调,本文代码切勿应用于生产环境,new Function虽然可以动态创建一个函数,但是也正是由于这个特性过于强大,加载网络代码被视为非常危险,很可能你请求的代码已被篡改,所以,通常禁止使用此API,实在要使用,需要充分考虑安全性问题。所以建议还是老老实实使用第三方库如requireJS或者使用构建工具来实现模块化。


webpack

本文开头提到过,要实现模块化的话有几个被广泛应用的规范,其实,除了规范,还可利用一些构建工具如webpack。如果你使用过webpack,并且看过webpack打包之后的代码的话【未压缩】,会发现,其实本文中的代码跟webpack打包后的代码非常像,那么恭喜你,你猜的没错,其实本文的实现很大程度是参考了webpack的设计。不过,webpack作为一个构建工具,其模块化远比这里要复杂的多,有兴趣的推荐去研究webpack的打包原理。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消