前端开发

前端科普系列(3):CommonJS 不是前端却革命了前端

2023-04-11 18:44:32 zhangcb 22

本文首发于 vivo 互联网技术 微信公众号 
链接: https://mp.weixin.qq.com/s/15sedEuUVTsgyUm1lswrKA
作者:Morrain

图片关键词

一、前言

上一篇《前端科普系列(2):Node.js 换个角度看世界》,我们聊了 Node.js 相关的东西,Node.js 能在诞生后火到如此一塌糊涂,离不开它成熟的模块化实现,Node.js 的模块化是在 CommonJS 规范的基础上实现的。那 CommonJS 又是什么呢?

先来看下,它在维基百科上的定义:

CommonJS 是一个项目,其目标是为 JavaScript 在网页浏览器之外创建模块约定。创建这个项目的主要原因是当时缺乏普遍可接受形式的 JavaScript 脚本模块单元,模块在与运行 JavaScript 脚本的常规网页浏览器所提供的不同的环境下可以重复使用。

我们知道,很长一段时间 JavaScript 语言是没有模块化的概念的,直到 Node.js 的诞生,把 JavaScript 语言带到服务端后,面对文件系统、网络、操作系统等等复杂的业务场景,模块化就变得不可或缺。于是 Node.js 和 CommonJS 规范就相得益彰、相映成辉,共同走入开发者的视线。

图片关键词

由此可见,CommonJS 最初是服务于服务端的,所以我说 CommonJS 不是前端,但它的载体是前端语言 JavaScript,为后面前端模块化的盛行产生了深远的影响,奠定了结实的基础。CommonJS:不是前端却革命了前端!

二、为什么需要模块化

1、没有模块化时,前端是什么样子

在之前的《Web:一路前行一路忘川》中,我们提到过 JavaScript 诞生之初只是作为一个脚本语言来使用,做一些简单的表单校验等等。所以代码量很少,最开始都是直接写到 <script> 标签里,如下所示:

// index.html<script>var name = 'morrain'var age = 18</script>

随着业务进一步复杂,Ajax 诞生以后,前端能做的事情越来越多,代码量飞速增长,开发者们开始把 JavaScript 写到独立的 js 文件中,与 html 文件解耦。像下面这样:

// index.html<script src="./mine.js"></script>
 // mine.jsvar name = 'morrain'var age = 18

再后来,更多的开发者参与进来,更多的 js 文件被引入进来:

// index.html<script src="./mine.js"></script><script src="./a.js"></script><script src="./b.js"></script>
 // mine.jsvar name = 'morrain'var age = 18
 // a.jsvar name = 'lilei'var age = 15
 // b.jsvar name = 'hanmeimei'var age = 13

不难发现,问题已经来了!JavaScript 在 ES6 之前是没有模块系统,也没有封闭作用域的概念的,所以上面三个 js 文件里申明的变量都会存在于全局作用域中。不同的开发者维护不同的 js 文件,很难保证不和其它 js 文件冲突。全局变量污染开始成为开发者的噩梦。

2、模块化的原型

为了解决全局变量污染的问题,开发者开始使用命名空间的方法,既然命名会冲突,那就加上命名空间呗,如下所示:

// index.html<script src="./mine.js"></script><script src="./a.js"></script><script src="./b.js"></script>
 // mine.jsapp.mine = {}
app.mine.name = 'morrain'app.mine.age = 18
 // a.jsapp.moduleA = {}
app.moduleA.name = 'lilei'app.moduleA.age = 15
 // b.jsapp.moduleB = {}
app.moduleB.name = 'hanmeimei'app.moduleB.age = 13

此时,已经开始有隐隐约约的模块化的概念,只不过是用命名空间实现的。这样在一定程度上是解决了命名冲突的问题, b.js 模块的开发者,可以很方便的通过 app.moduleA.name 来取到模块 A 中的名字,但是也可以通过 app.moduleA.name = 'rename' 来任意改掉模块 A 中的名字,而这件事情,模块 A 却毫不知情!这显然是不被允许的。

聪明的开发者又开始利用 JavaScript 语言的函数作用域,使用闭包的特性来解决上面的这一问题。

// index.html<script src="./mine.js"></script><script src="./a.js"></script><script src="./b.js"></script>
 // mine.jsapp.mine = (function(){var name = 'morrain'
var age = 18
return {getName: function(){return name
}
}
})() 
// a.jsapp.moduleA = (function(){var name = 'lilei'
var age = 15
return {getName: function(){return name
}
}
})() 
// b.jsapp.moduleB = (function(){var name = 'hanmeimei'
var age = 13
return {getName: function(){return name
}
}
})()

现在 b.js 模块可以通过 

app.moduleA.getName () 来取到模块 A 的名字,但是各个模块的名字都保存在各自的函数内部,没有办法被其它模块更改。这样的设计,已经有了模块化的影子,每个模块内部维护私有的东西,开放接口给其它模块使用,但依然不够优雅,不够完美。譬如上例中,模块 B 可以取到模块 A 的东西,但模块 A 却取不到模块 B 的,因为上面这三个模块加载有先后顺序,互相依赖。当一个前端应用业务规模足够大后,这种依赖关系又变得异常难以维护。

综上所述,前端需要模块化,并且模块化不光要处理全局变量污染、数据保护的问题,还要很好的解决模块之间依赖关系的维护。

三、CommonJS 规范简介

既然 JavaScript 需要模块化来解决上面的问题,那就需要制定模块化的规范,CommonJS 就是解决上面问题的模块化规范,规范就是规范,没有为什么,就和编程语言的语法一样。我们一起来看看。

1、CommonJS 概述

Node.js 应用由模块组成,每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。

// a.jsvar name = 'morrain'var age = 18

上面代码中,a.js 是 Node.js 应用中的一个模块,里面申明的变量 name 和 age 是 a.js 私有的,其它文件都访问不到。

CommonJS 规范还规定,每个模块内部有两个变量可以使用,require 和 module。

require 用来加载某个模块

module 代表当前模块,是一个对象,保存了当前模块的信息。exports 是 module 上的一个属性,保存了当前模块要导出的接口或者变量,使用 require 加载的某个模块获取到的值就是那个模块使用 exports 导出的值

// a.jsvar name = 'morrain'var age = 18module.exports.name = namemodule.exports.getAge = function(){return age
} 
//b.jsvar a = require('a.js')console.log(a.name) // 'morrain'console.log(a.getAge())// 18

2、CommonJS 之 exports

为了方便,Node.js 在实现 CommonJS 规范时,为每个模块提供一个 exports 的私有变量,指向 module.exports。你可以理解为 Node.js 在每个模块开始的地方,添加了如下这行代码。

var exports = module.exports

于是上面的代码也可以这样写:

// a.jsvar name = 'morrain'var age = 18exports.name = name
exports.getAge = function(){return age
}

图片关键词

有一点要尤其注意,exports 是模块内的私有局部变量,它只是指向了 module.exports,所以直接对 exports 赋值是无效的,这样只是让 exports 不再指向 module.exports 了而已。

如下所示:

// a.jsvar name = 'morrain'var age = 18exports = name

图片关键词

如果一个模块的对外接口,就是一个单一的值,可以使用 module.exports 导出

// a.jsvar name = 'morrain'var age = 18module.exports = name

3、CommonJS 之 require

require 命令的基本功能是,读入并执行一个 js 文件,然后返回该模块的 exports 对象。如果没有发现指定模块,会报错。

第一次加载某个模块时,Node.js 会缓存该模块。以后再加载该模块,就直接从缓存取出该模块的 module.exports 属性返回了。

// a.jsvar name = 'morrain'var age = 18exports.name = name
exports.getAge = function(){return age
}// b.jsvar a = require('a.js')console.log(a.name) // 'morrain'a.name = 'rename'var b = require('a.js')console.log(b.name) // 'rename'

如上所示,第二次 require 模块 A 时,并没有重新加载并执行模块 A。而是直接返回了第一次 require 时的结果,也就是模块 A 的 module.exports。

图片关键词

还一点需要注意,CommonJS 模块的加载机制是,require 的是被导出的值的拷贝。也就是说,一旦导出一个值,模块内部的变化就影响不到这个值 。

// a.jsvar name = 'morrain'var age = 18exports.name = name
exports.age = age
exports.setAge = function(a){
age = a
}// b.jsvar a = require('a.js')console.log(a.age) // 18a.setAge(19)console.log(a.age) // 18

四、CommonJS 实现

了解 CommonJS 的规范后,不难发现我们在写符合 CommonJS 规范的模块时,无外乎就是使用了 require 、 exports 、 module 三个东西,然后一个 js 文件就是一个模块。如下所示:

// a.jsvar name = 'morrain'var age = 18exports.name = name
exports.getAge = function () {return age
}// b.jsvar a = require('a.js')console.log('a.name=', a.name)console.log('a.age=', a.getAge()) 
var name = 'lilei'var age = 15exports.name = name
exports.getAge = function () {return age
}// index.jsvar b = require('b.js')console.log('b.name=',b.name)

如果我们向一个立即执行函数提供 require 、 exports 、 module 三个参数,模块代码放在这个立即执行函数里面。模块的导出值放在 module.exports 中,这样就实现了模块的加载。如下所示:

(function(module, exports, require) {// b.js
var a = require("a.js")console.log('a.name=', a.name)console.log('a.age=', a.getAge()) 
var name = 'lilei'
var age = 15
exports.name = name
exports.getAge = function () {return age
}
 
})(module, module.exports, require)

知道这个原理后,就很容易把符合 CommonJS 模块规范的项目代码,转化为浏览器支持的代码。很多工具都是这么实现的,从入口模块开始,把所有依赖的模块都放到各自的函数中,把所有模块打包成一个能在浏览器中运行的 js 文件。譬如 Browserify 、webpack 等等。

我们以 webpack 为例,看看如何实现对 CommonJS 规范的支持。我们使用 webpack 构建时,把各个模块的文件内容按照如下格式打包到一个 js 文件中,因为它是一个立即执行的匿名函数,所以可以在浏览器直接运行。

// bundle.js(function (modules) {// 模块管理的实现})({'a.js': function (module, exports, require) {// a.js 文件内容
},'b.js': function (module, exports, require) {// b.js 文件内容
},'index.js': function (module, exports, require) {// index.js 文件内容
}
})

接下来,我们需要按照 CommonJS 的规范,去实现模块管理的内容。首先我们知道,CommonJS 规范有说明,加载过的模块会被缓存,所以需要一个对象来缓存已经加载过的模块,然后需要一个 require 函数来加载模块,在加载时要生成一个 module,并且 module 上 要有一个 exports 属性,用来接收模块导出的内容。

// bundle.js(function (modules) {// 模块管理的实现
var installedModules = {}/**
 * 加载模块的业务逻辑实现
 * @param {String} moduleName 要加载的模块名
 */
var require = function (moduleName) { 
// 如果已经加载过,就直接返回
if (installedModules[moduleName]) return installedModules[moduleName].exports 
// 如果没有加载,就生成一个 module,并放到 installedModules
var module = installedModules[moduleName] = {moduleName: moduleName,exports: {}
} 
// 执行要加载的模块
modules[moduleName].call(modules.exports, module, module.exports, require) 
return module.exports
} 
return require('index.js')
})({'a.js': function (module, exports, require) {// a.js 文件内容
},'b.js': function (module, exports, require) {// b.js 文件内容
},'index.js': function (module, exports, require) {// index.js 文件内容
}
})

可以看到, CommonJS 核心的规范,上面的实现中都满足了。非常简单,没想像的那么难。

五、其它前端模块化的方案

我们对 CommonJS 的规范已经非常熟悉了,require 命令的基本功能是,读入并执行一个 js 文件,然后返回该模块的 exports 对象,这在服务端是可行的,因为服务端加载并执行一个文件的时间消费是可以忽略的,模块的加载是运行时同步加载的,require 命令执行完后,文件就执行完了,并且成功拿到了模块导出的值。

这种规范天生就不适用于浏览器,因为它是同步的。可想而知,浏览器端每加载一个文件,要发网络请求去取,如果网速慢,就非常耗时,浏览器就要一直等 require 返回,就会一直卡在那里,阻塞后面代码的执行,从而阻塞页面渲染,使得页面出现假死状态。

为了解决这个问题,后面发展起来了众多的前端模块化规范,包括 CommonJS 大致有如下几种:

图片关键词


首页
文章
联系