前端模块化

前言

对于 JavaScript 新手,看到 "CommonJS vs AMD" 、"Requirejs vs Seajs"、"Webpack vs. Browserify"等这些可能会不知所措。在node.js、vue、react等工程下书写时会经常见到exportsmodule.exportsexportexport defaultrequiredefineimport等等字段,对这些知识点还是模糊不清。下面我们来整体学习梳理下这些规范。

特别是在大部分浏览器都已经实现 ES6 模块化规范的今天,我们新开发的项目基本都是 ES6 搭配 Webpack ,这些 AMD、CMD、UMD、Requirejs、Seajs 都已经是过去式了,很多同学并没有使用过。

但模块化是 JavaScript 开发体系的一部分,了解它的历史还是很有必要的,至少不会在这方面与其他开发者失去对话能力。

模块化的意义

模块化就是把单独的一个功能封装到一个模块(文件)中,模块之间相互隔离,但是可以通过特定的接口公开内部成员,也可以依赖别的模块。

可维护性

代码的可维护性的一种理解是,新功能的添加无需修改已有代码,旧有功能的变更无需修改多处代码。

对于初期需求不明确,需要采用不断迭代方式开发的项目,代码可维护性就显的尤为重要。(可以理解为在频繁的功能迭代中新增功能或者修改已有功能时,能最快速、便捷地修改代码)

代码复用

代码复用不仅仅是为了节省开发时间,同时也是保证代码质量的有效手段,代码的复用程度越高,其质量就越容易得到保证。

多人协作

大型应用无法通过一人之力完成,多人协作是不可避免的。在多人协作的环境下,经常要面临修改或使用别人写的代码的问题。只有那些功能单一,接口明确,模块化代码我们才敢放心大胆的修改或使用。

性能

模块化的代码可以实现按需加载,进而保证了我们不会把宝贵的页面加载时间浪费在下载和解释多余的代码上。

发展之初

JS设计之初,只是想写一些简单的网页动效,随着web技术的迅速发展,网页内容越来越丰富,在还没有形成模块化规范前,代码体量,逻辑复杂度日益增强,随之也带来一系列的问题:

存在的问题

1、代码的堆砌

2、命名冲突

3、文件依赖

模块化面临什么问题

js模块化需要解决那些问题:

  1. 如何安全的包装一个模块的代码?(不污染模块外的任何代码)
  2. 如何唯一标识一个模块?
  3. 如何优雅的把模块的API暴漏出去?(不能增加全局变量)
  4. 如何方便的使用所依赖的模块?

围绕着这些问题,js模块化开始了一段艰苦而曲折的征途。

早期解决方案

1、命名空间

用于解决遍地的全局变量,将需要定义的部分归属到一个对象的属性上,不过本质上也是全局变量

2、IIFE

IIFE(立即执行函数) 就是将一整段代码包裹在一个函数中,然后立即执行这个函数。在 JavaScript 中,每个函数都有一个作用域,所以在函数中声明的变量就只在这个函数中可见。即使有变量提升,变量也不会污染到全局作用域中。

(function() {
  console.log('IIFE using parenthesis')
})()

IIFE 这种方式可以说是模块化的先河,它让开发者可以将模块放在单独的文件中,并且不污染全局作用域。

当然 IIFE 也有缺点,它并没有一个明确的依赖树,这使得开发者只能自己确保 JS 文件的加载顺序。

模块化的形成

模块化规范总览

前端模块化_第1张图片

CommonJS

CommonJS 是以在浏览器环境之外构建 JavaScript 生态系统为目标而产生的项目,比如在服务器和桌面环境中。

这个项目最开始是由 Mozilla 的工程师 Kevin Dangoor 在2009年1月创建的,当时的名字是 ServerJS。

2009年8月,这个项目改名为 CommonJS,CommonJS 规范是为了解决 JavaScript 的作用域问题而定义的模块形式,可以使每个模块在它自身的命名空间中执行,并且可以确保文件的加载顺序。

该规范的主要内容是,模块必须通过 module.exports 导出对外的变量或接口,通过 require() 来导入其他模块的输出到当前模块作用域中。

模块化规范

  • 暴露模块:module.exports = valueexports.xxx = value其中exports是对module.exports的一个引用
  • 引入模块:require(xxx),如果是第三方模块,xxx为模块名;如果是自定义模块,xxx为模块文件路径
    //模块引用   
   require("module");
   require("../file.js");
   //模块定义
  exports.doStuff = function() {};
   //模块导出
  module.exports = someValue;

module

CommonJS中每个模块内部,module变量代表当前的模块,是一个对象,它拥有的exports属性(module.exports)是对外的接口,加载某个模块,实际上是加载该模块的module.exports属性。

如下:在控制台中输出了module变量,下面我们来分析下输出的内容:

{
  id: '.',
  exports: {},
  parent: null,
  filename: '/Users/bingqian/projects/nodetest/index.js',
  loaded: false,
  children: 
   [ Module {
       id: '/Users/bingqian/projects/nodetest/b.js',
       exports: {},
       parent: [Circular],
       filename: '/Users/bingqian/projects/nodetest/b.js',
       loaded: true,
       children: [],
       paths: [Array] } ],
  paths: 
   [ '/Users/bingqian/projects/nodetest/node_modules',
     '/Users/bingqian/projects/node_modules',
     '/Users/bingqian/node_modules',
     '/Users/node_modules',
     '/node_modules' ] 
}

参数解析

  • module.id 模块的识别符,通常是带有绝对路径的模块文件名。
  • module.filename 模块的文件名,带有绝对路径。
  • module.loaded 返回一个布尔值,表示模块是否已经完成加载。
  • module.parent 返回一个对象,表示调用该模块的模块。
  • module.children 返回一个数组,表示该模块要用到的其他模块。
  • module.exports 表示模块对外输出的接口。

exports

node为每一个模块提供了一个exports变量(可以说是一个对象),指向 module.exports。这相当于每个模块中都有一句这样的命令 var exports = module.exports;不过如果直接为export赋值,则不能写成exports=xx,而应该写成module.exports=xx,因为exports在这里只是一个引用。

模块加载机制

在 NodeJs 中引入模块 (require),需要经历如下 3 个步骤:

  1. 路径分析+文件定位
  2. 模块实例化
  3. 编译执行

加载流程图解析:

前端模块化_第2张图片

路径分析+文件定位

CommonJS中require对文件标识符的识别规则如下(nodejs官方描述的解析流程):

在 Y 路径下,require(X)

  1. 如果X是内置模块(http, fs, path 等), 直接返回内置模块,不再执行
  2. 如果 X 以 '/' 开头,把 Y 设置为文件系统根目录
  3. 如果 X 以 './', '/', '../' 开头

    a. 按照文件的形式加载(Y + X),根据 extensions 依次尝试加载文件 [X, X.js, X.json, X.node] 如果存在就返回该文件,不再继续执行。

    b. 按照文件夹的形式加载(Y + X),如果存在就返回该文件,不再继续执行,若找不到将抛出错误

    ​ a. 尝试解析路径下 package.json main 字段

    ​ b. 尝试加载路径下的 index 文件(index.js, index.json, index.node)

  4. 搜索 NODE_MODULE,若存在就返回模块

    a. 从路径 Y 开始,一层层往上找,尝试加载(路径 + 'node_modules/' + X)

    b. 在 GLOBAL_FOLDERS node_modules 目录中查找 X

  5. 抛出 "Not Found" Error

执行入口文件,触发require特有的文件加载机制,识别当前所有依赖,并且存储依赖模块的path作为模块唯一标识;

模块实例化

解析完文件路径,根据path查询模块缓存集中是否存在实例化的模块对象,如果不存在,则创建模块实例,读取文件内容,将关键信息存入模块缓存集

编译求值

在CommonJS中创建完模块实例之后,会直接进行编译执行,将最终的结果挂载到模块实例的exports上,最后导出Module.exports内容;

但是如果执行完路径分析+文件定位之后,在模块缓存集中能查询到指定的模块实例,则不会再次进行编译执行,会直接导出Module.exports内容;

通过分析CommonJS的模块加载流程,我们可以总结出来该规范下的模块加载特性,即:

Module.exports导出的是一个原始类型的值,会被缓存下来,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值,除非写成一个函数,才能得到内部变动后的值;

这点与ES6模块化有很大差异(下文会介绍),请看下面这个例子:

// lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  counter: counter,
  incCounter: incCounter,
};

上面代码输出内部变量counter和改写这个变量的内部方法incCounter。

// main.js
var counter = require('./lib').counter;
var incCounter = require('./lib').incCounter;

console.log(counter);  // 3
incCounter();
console.log(counter); // 3

可以看出,counter输出以后,lib.js模块内部的变化就影响不到counter了。

优势

  • 所有代码都运行在模块作用域,不会污染全局作用域。
  • 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
  • 模块加载的顺序,按照其在代码中出现的顺序。

存在的问题

  • 同步的模块加载方式不适合在浏览器环境中,同步意味着阻塞加载,浏览器资源是异步加载的
  • 不能非阻塞的并行加载多个模块

那么,浏览器模块化和服务端模块化有什么区别?

  • 服务端加载一个模块,直接就从硬盘或者内存中读取了,消耗时间可以忽略不计
  • 浏览器需要从服务端下载这个文件,所以说如果用CommonJS的require方式加载模块,需要等代码模块下载完毕,并运行之后才能得到所需要的API。

那下面我们看下为了实现浏览器端模块话,各个社区又推出了什么规范呢?

AMD和CMD

为了实现浏览器端的模块化,在CommonJS规范推出后不就,就相继推出了AMD和CMD规范。

requirejs in amd

2010 年推出了遵循 AMD 规范的模块加载器 RequireJS

AMD(异步模块定义)是为浏览器环境设计的,因为 CommonJS 模块系统是同步加载的,当前浏览器环境还没有准备好同步加载模块的条件。所以AMD 定义了一套 JavaScript 模块依赖异步加载标准,来解决同步加载的问题。

模块化规范

模块定义
// 模块定义
define(id?: String, dependencies?: String[], factory: Function|Object);
// 通过return方式导出
define(['GameBg', 'GrassLand'], function (GameBg, GrassLand) {
    var Bird = function (idName) {
       // ...
    };
    return Bird;
});

id :是模块的名字,它是可选的参数,不填默认使用文件名。

dependencies :指定了所要依赖的模块列表,它是一个数组,也是可选的参数。每个依赖的模块的输出都将作为参数一次传入 factory 中。如果没有指定 dependencies,那么它的默认值是 ["require", "exports", "module"]。

factory :是最后一个参数,它包裹了模块的具体实现,它是一个函数或者对象。如果是函数,那么它的返回值就是模块的输出接口或值,函数params参数依次为引入依赖模块的名称;如果是对象,此对象应该为模块的输出值。

模块引用
// 模块引用
require(['moduleA', 'moduleB', 'moduleC'], function (moduleA, moduleB, moduleC){
// 运行代码
});

require()函数接收两个参数:

第一个参数是一个数组,指定了所有的依赖模块;

第二个参数是一个回调函数,当前面指定模块都加载成功后,它将被调用,加载的模块会以参数形式传入,从而在回调函数内部就可以调用这些模块;

那么,下面我们一起看下AMD模块是如何加载执行的呢?

模块加载机制

如下图,这是我本地运行的一个requirejs项目,这是审查元素看到的html代码:

前端模块化_第3张图片

我们发现,AMD模块加载机制实质上是将每个模块当成一个js脚本文件,动态添加到html中,使用异步的方式加载脚本;

浏览器加载脚本会与服务器进行通信,这是一个未知的请求,如果使用同步的方式加载,就可能会一直阻塞下去。为了防止浏览器的阻塞,我们要使用异步的方式加载脚本。因为是异步加载,所以与模块相依赖的操作就必须得在脚本加载完成后执行,这里就得使用回调函数的形式。如上图,requirejs实现模块加载的方式是动态创建script标签加载所需模块;

流程解析

同理我们的CommonJS加载机制,整体依旧分为三步,如下图:

前端模块化_第4张图片

注:首先是触发入口文件的加载,查找是否有依赖模块,有的话则再触发依赖模块的加载,整体遍历加载,整个过程中会将各个模块的信息存入Module缓存集中,直到遍历完成所有文件的加载,再从入口文件开始对所有依赖文件factory进行执行,执行完所有依赖文件后才去执行入口文件factory。

路径分析+文件定位:

识别require路径,定位文件path,识别方式与CommonJS不同,该识别方式遵循的是浏览器对模块标识符的识别方式,必须补全文件扩展名,识别模块依赖,最终形成一个有序的依赖集;

模块实例化

注册一个全局对象,将已加载的模块放入对象(registry)中,类似于CommonJS中的模块缓存集;

下面是我在页面输出的module2模块对象:

前端模块化_第5张图片

在模块解析阶段模块缓存集中存入的是各个模块已经执行完成的return的值,这一点同CommonJS,只有在首次加载(isDefine=false)时会执行模块导出方法,否则会直接读取模块缓存中exports内容;

编译求值

与CommonJS同步加载不同,浏览器端异步加载的实现原理是动态创建

ES Module in Node.js

Node.js 8.5.0发布了对ECMAScript模块的实验性支持,增加.mjs扩展名文件,这种ECMAScript模块的支持是需要在后面加上--experimental-modules标识来运行。

在兼顾存量代码的前提下,如何去完善Node.js对ES Module的支持呢,当前有以下几种方案:

1、选择增加新的扩展名 .mjs,会被识别为 ES Module模块

  • .mjs 中可以自如使用 importexport
  • .mjs 中不可以使用 require
  • .js 中只能使用 require
  • .js 中不可以使用 importexport

运行命令:node --experimental-modules index.mjs(Nodejs v12.17.0 LTS 版发布,去掉 --experimental-modules 标志,但仍处于测试阶段)

esm模块相互导入

// index.mjs esm模块相互导入
import foo from './test.mjs';
console.log(foo)

// test.mjs
export default function foo(){
    return 'hello foo'
}

esm模块中导入cjs

// aa.js
module.exports.test1 = function(){
    console.log('模块1')
}

// test.mjs
export default function foo(){
    return 'hello foo'
}

// esm模块中导入cjs
import foo from './test.mjs';
import aa from './aa.js';//cjs模块

console.log('esm'+foo)
console.log('cjs'+aa)

cjs模块中导入esm

// 不能使用require
// cjs模块加载是同步的过程,esm属于异步调用时执行,所以在cjs中需要等待拿到esm的导出值
// cjs模块中导入esm
async function test() {
    const bar = await import('./test.mjs')
    // use bar
    console.log(bar)
    console.log('cjs导入esm')
}
test()

由于历史遗留问题, ESM 和 CJS及其他现存的模块系统之间的交互总是会或多或少遇到一些坑,这样的讨论也到处可见,所以团队中还是应该遵循统一的 模块导入导出 标准,能基于约定形成一套标准在之后需要改变迁移的时候可能也会更加方便一些。

虽然当前无论是浏览器端还是服务端对ES Module的支持都不是很好,

优势和不足

优势

  • ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。
  • ES6 模块设计思想:尽量的静态化、使得编译时就能确定模块的依赖关系,以及输入和输出的变量(CommonJS和AMD模块,都只能在运行时确定这些东西)。

不足

  • 原生浏览器端还没有完全实现该标准
  • 全新的命令字,新版的 Node.js才支持。

:就是在react里使用Es6的语法的时候,我们会引入一些模块。这些模块有的是以路径的形式有的是以简单的一个字符串形式存在,对于路径的这种都熟悉。但是对于简单字符串的这种不熟悉,其实对于es6的代码我们是要经过babel或者traceur转成es5的,import在转换的时候变成require的这种,此时的这种可用于服务端的,但是想用在浏览器端就要借助Browserify或者webpack等工具来帮助我们对模块资源的获取整合打包了,它们主要利用自身的require机制把模块需要的资源和当前的模块文件进行重新整合打包成新文件,以至于在客户端时候,使用的是已经打包好的这个文件,此时的这个文件包含着需要的各个资源。

因为es6的模块是构建在语言语法支持上的。全面使用还需要很长时间,因为服务器端的commonjs模式的模块和浏览器段(AMD, UMD ,ad hoc)的代码太多了。这种过度需要很长时间。在这期间模块的转译器/转换器是必须的。Es6模块语法的好处是JavaScript引擎可以静态的分析决定要导出的API成员。这里给出的建议是无论你使用那种模块格式。你都要必须理解Es6的模块,因为它是js的未来,虽然这需要很长时间。

模块的编译构建

通过对以上各种js模块化规范的介绍,ES Module是语言层面的规范,也将必然成为未来的趋势,但是因为服务器端的Commonjs模式的模块和浏览器端(AMD, UMD ,ad hoc)的代码太多了,并且浏览器端对ES6语法的支持也不全面,这种过度需要很长时间。

所以在这期间模块的转译器/转换器是必须的,下面我们来看下babel和webpack编译构建工具是如何对代码做的兼容处理。

babel(语法转换)

浏览器很多都不支持es6的语法,或者仅仅是部分支持,babel就承担了“翻译”的角色,把es6,es7的写法转换成es5的写法

转换规则

babel:转码 es6 语法,配合一些列 babel 工具链可以将新的 es2015+ 代码转换成所有浏览器支持的 es代码,babel 默认是将 es6 模块转换成 commonjs 模块,如下图:

前端模块化_第9张图片

这是在react+webpack项目中本地运行时,查看到的js代码,是一个CommonJS规范模块;

下面我们在babel官网来看下babel的语法转换,默认转换成CommonJS:

import语法解析:

前端模块化_第10张图片

export语法解析:

前端模块化_第11张图片

导出模块类型配置参数:modules

{
    "presets": [
        [
          "es2015",
          {
            "modules": "umd" 
          }
        ]
    ],
    "plugins": []
}

该参数的含义是:启用将ES6模块语法转换为另一种模块类型。将该设置为false就不会转换模块。默认为 commonjs
该值可以有如下:
'amd' | 'umd' | 'systemjs' | 'commonjs' | false

根据运行环境来选择可识别的模块规范来编译代码。

模块化应用

tree shaking

概念

所谓Tree-shaking就是"摇树"的意思,作用是把项目中没必要的模块全部抖掉,用于在不同的模块之间消除无用的代码,可列为性能优化的范畴。

Tree-shaking早期由rollup实现,后来webpack2也实现了Tree-shaking的功能,但是至今还不是很完备。

原理

Tree Shaking在去除代码冗余的过程中,程序会从入口文件出发扫描所有的模块依赖,以及模块的子依赖,然后将它们链接起来形成一个“抽象语法树”(AST)。随后,运行所有代码,查看哪些代码是用到过的,做好标记。最后,再将“抽象语法树”中没有用到的代码“摇落”。这样一个过程后,就去除了没有用到的代码。

image-20200729195132323

在上文我们有提到过ESM模块的静态分析,实现静态分析需要遵循该模块的书写规范;

Tree Shaking的实现得益于ES Module模块的静态分析,所谓静态分析,即在代码执行前就能对整体代码依赖调用关系等进行分析读取;

下面我们看下ES Module的特性:

  1. 只能作为模块顶层的语句出现(而不嵌套在条件语句中)
  2. import 的模块名只能是字符串常量(只对文件进行字符串读取)
  3. 导入和导出语句没有动态部分(不允许使用变量等)

CommonJS的动态特性模块意味着tree shaking不适用。因为它是不可能确定哪些模块实际运行之前是需要的或者是不需要的。在ES6中,进入了完全静态的导入语法:import。这也意味着下面的导入是不可行的:

// 不可行,ES6 的import是完全静态的
if(condition) {
    myDynamicModule = require("foo");
} else {
    myDynamicModule = require("bar");
}

我们只能通过导入所有的包后再进行条件获取。如下:

import foo from "foo";
import bar from "bar";

if(condition) {
    // foo.xxxx
} else {
    // bar.xxx
}

ES6import语法完美可以使用tree shaking,因为可以在代码不运行的情况下就能分析出不需要的代码。

应用

支持tree shaking的工具:

  • Webpack/UglifyJS
  • rollup
  • Google closure compiler

上面提到的三个工具,rollup,webpack,cc都集成了著名的代码压缩优化工具uglifyuglify完成了javascript的DCE;

你可能感兴趣的:(前端,javascript,node.js)