谈谈对模块化的理解

重要的模块化规范有几个:commonjs,ES6模块机制,AMD,CMD。由于业务中一直接触的都是Vue+webpack+babel架构的项目,在封装代码时用的比较的多还是ES6规范,对其他模块化规范不熟悉,因此在这里记录一下学习过的模块化知识。

CommonJS

模块化的目的在于营造安全封闭的作用域、且具有易于引用接口,按我的理解可分为模块定义、模块引入两部分。

在模块中存在着一个module对象,它代表着模块本身,将需要导出的api挂载于其中的exports属性上即可以定义导出的接口;CommonJS规范中存在require()方法,用于接受模块标识,引入某个模块到当前的上下文。

1. 模块定义

要理解模块如何定义,那必须要先理解module对象。在Node中,每一个文件模块都是一个对象,即module对象。它的定义如下:

function Module(id, parent){
    this.id = id    //模块标识符
    this.exports = {}    //模块对外输出的值
    this.parent = parent    //调用该模块的模块。parent为null时意味着模块为入口模块
    if(parent && parent.children){
        parent.children.push(this)
    }    
    this.filename = filename    //文件名
    this.loaded = false    //是否已加载
    this.children = []    //表示该模块调用的其他模块
}

定义模块的目的其实在于定义输出的值。写法非常简单,举个?

function sayHello(){
    console.log('hello')
}
module.exports = sayHello    //或exports.sayHello = sayHello

为了方便导出接口,Node还定义了一个exports变量,但有个容易踩的坑是,exports只是一个引用,本来指向module.exports,假如只是给exports变量赋值则exports变量会失去对module.exports的指向。说到底,必须对module.exports定义接口才能真正导出值。

先说解决方法,常见的写法为:

exports = module.exports = sayHello
//或严格地只给exports变量添加属性
exports.sayHello = sayHello

再举个例子说一下犯错的具体场景:

//a.js
exports.name = 'kent'
exports.sayHi = function(){
    console.log('hi')
}

console.log(module)// { exports: { name: 'kent', sayHi: function(){ console.log('hi') } } }

//假如给exports重新赋值 =_=
exports = {
    name: 'nicolas',
    sayBye: function(){
        console.log('bye')
     }
}

//module中的exports属性不会有任何变化
console.log(module)// { exports: { name: 'kent', sayHi: function(){ console.log('hi') } } }
console.log(exports)// { name: 'nicolas', sayBye: function(){ console.log('bye') } }
//b.js
//因此require的时候读取的name仍然为kent
var person = require('a.js')
console.log(person.name)//kent

具体的原因也可以从模块机制中看出来

function require(...) {
  var module = { exports: {} };
  ((module, exports) => {
    // Your module code here. In this example, define a function.
    function some_func() {};
    exports = some_func;
    // At this point, exports is no longer a shortcut to module.exports, and
    // this module will still export an empty default object.
    module.exports = some_func;
    // At this point, the module will now export some_func, instead of the
    // default object.
  })(module, module.exports);
  return module.exports;
}

2. 模块引入

模块引入的语法也非常简单。上一节也简单提过。这里再举个?

//book.js
exports.name = 'javascript'
exports.logName = function(){
    console.log('javascript')
}
//main.js
var book = require('./book.js')//require的参数即模块标识符
console.log(book.name)//'javascript'
book.logName()//'javascript'

下面详情谈谈模块引入经历哪些步骤。但在此之前需要先了解两个概念:核心模块与文件模块。

在Node中,有一部分模块由Node提供,称之为核心模块。在Node进程启动的时候,核心模块就直接加载至内存中。因此引入核心模块只需要走路径分析一个步骤,其加载速度最快。

另一部分则是运行时动态加载,常见的有用户定义带路径标识符的模块,或自定义模块(如三方提供的包)。这类模块需要完整地走完以下三个步骤:路径分析、文件定位与编译执行。

①路径分析:
路径分析可以理解为模块标识符的分析。模块标识符在Node中主要有:

    ·核心模块,如:http, fs等等;
    ·以"./"或"../"开头的相对路径模块,相对于当前的目录位置;
    ·以"/"开头的绝对路径模块;
    ·非路径形式的文件模块,与核心模块的标识符类似。Node会搜索各级的node_modules目录。

· 核心模块:核心模块经过路径分析之后会直接加载。需要注意的是,自定义的文件模块不能与核心模块标识符相同,要不更换不同的标识符要么使用相对路径或绝对路径标识符。

· 路径形式的文件模块:在分析文件模块的时候,require方法将会把路径转换为真实路径并以此为索引编译模块并存放到缓存中(缓存加载将在下文介绍)。

· 非路径形式的文件模块(自定义模块):自定义模块的路径分析在我们引用三方库的时候经常会碰到。这类非路径形式的文件模块加载时将会以模块路径为线索逐级搜索。举个? :

//在"/Users/zhazheng/Documents/www"下新建一个module_path.js

//module_path.js
console.log(module.paths)

//再执行module_path.js
node module_path

//得出以下log
[ '/Users/zhazheng/Documents/www/node_modules',
  '/Users/zhazheng/Documents/node_modules',
  '/Users/zhazheng/node_modules',
  '/Users/node_modules',
  '/node_modules' ]

可见,这类模块会从当前文件目录往上逐级递归直到根目录下的node_modules目录。因此这类模块的路径分析是最费时的。

②文件定位:文件定位主要包括文件扩展名分析、目录和包的处理。

·文件扩展名分析:分析标识符的过程中出现不包含文件扩展名的情况非常常见。在标识符不包含文件扩展名的情况下,Node会依次尝试以下三种扩展名:.js、.json、.node。由于尝试解析的过程是同步阻塞进行的,因此大量的分析文件扩展名会产生性能问题,这种情况下可以尝试添加扩展名或充分利用缓存加载的优势。

·目录分析与包的处理: 假如分析完扩展名后仍然没有找到对应的文件而只得出一个目录,那么Node会将此目录当做一个包来处理。首先会查找当前目录下是否有package.json文件,假如有则检查是否具有main属性(main属性即指向入口文件)。假如没有package.json文件或package.json中不具备main属性,那么Node则按index为默认的文件名,最后再重复“文件扩展名分析”这个步骤。

3.缓存加载

事实上Node的模块,无论是核心模块还是文件模块,第一次加载之后都会被缓存。require()方法将会对二次加载的模块进行缓存。因此假如有多次加载模块的需求,那么就需要记得先从缓存中删除模块。

缓存均保存在require.cache对象中,需要删除单个模块或全部模块的缓存可以这样写:

//删除单个模块缓存
delete require.cache[moduleName]

//删除全部模块缓存
Object.getOwnPropertyNames(require.cache).forEach(key => {
    delete require.cache[key]
})

当然,一般情况下缓存是可以带来性能优势的。对于路径套得非常深的自定义文件模块来说尤甚。

4.循环加载

循环加载是避免不了的问题。在Node中需要了解一下循环加载的表现。首先要理解的是,require是一个同步加载的过程,读取的接口仅仅是指向exports对象中的属性,举个? :(以下三个模块均在同一目录下)

//a.js
exports.name = 'a1'
console.log(`a.js, ${require('./b.js').name}`)
exports.name = 'a2'
//b.js
exports.name = 'b1'
console.log(`b.js, ${require('./a.js').name}`)
exports.name = 'b2'
//main.js
console.log(`main.js, ${require('./a.js').name}`)
console.log(`main.js, ${require('./b.js').name}`)

nvm run node然后.load main.js得出以下的结果

b.js, a1
a.js, b2//这两行结果应该大致可以理解两个模块的require方法发生了什么
main.js a2
main.js b2

再次执行.load main.js会读取缓存结果

main.js a2
main.js b2

循环加载示例代码可到我的github查看

AMD

...未完待续
    原文作者:渣正refn
    原文地址: https://segmentfault.com/a/1190000011231026
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞