关于javascript:深度全面前端JavaScript模块化规范进化论

4次阅读

共计 10261 个字符,预计需要花费 26 分钟才能阅读完成。

前言

JavaScript 语言诞生至今,模块规范化之路曲曲折折。社区先后呈现了各种解决方案,包含 AMD、CMD、CommonJS 等,而后 ECMA 组织在 JavaScript 语言规范层面,减少了模块性能(因为该性能是在 ES2015 版本引入的,所以在下文中将之称为 ES6 module)。
明天咱们就来聊聊,为什么会呈现这些不同的模块标准,它们在所处的历史节点解决了哪些问题?

何谓模块化?

或依据性能、或依据数据、或依据业务,将一个大程序拆分成相互依赖的小文件,再用简略的形式拼装起来。

全局变量

演示我的项目

为了更好的了解各个模块标准,先减少一个简略的我的项目用于演示。

# 我的项目目录:
├─ js              # js 文件夹
│  ├─ main.js      # 入口
│  ├─ config.js    # 我的项目配置
│  └─ utils.js     # 工具
└─  index.html     # 页面 html

Window

在刀耕火种的前端原始社会,JS 文件之间的通信根本齐全依附 window 对象(借助 HTML、CSS 或后端等状况除外)。

// config.js
var api = 'https://github.com/ronffy';
var config = {api: api,}
// utils.js
var utils = {request() {console.log(window.config.api);
  }
}
// main.js
window.utils.request();
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title> 小贼学生:【深度全面】JS 模块标准进化论 </title>
</head>
<body>

  <!-- 所有 script 标签必须保障程序正确,否则会依赖报错 -->
  <script src="./js/config.js"></script>
  <script src="./js/utils.js"></script>
  <script src="./js/main.js"></script>
</body>
</html>

IIFE

浏览器环境下,在全局作用域申明的变量都是全局变量。全局变量存在命名抵触、占用内存无奈被回收、代码可读性低等诸多问题。

这时,IIFE(匿名立刻执行函数)呈现了:

;(function () {...}());

用 IIFE 重构 config.js:

;(function (root) {
  var api = 'https://github.com/ronffy';
  var config = {api: api,};
  root.config = config;
}(window));

IIFE 的呈现,使全局变量的申明数量失去了无效的管制。

命名空间

依附 window 对象承载数据的形式是“不牢靠”的,如 window.config.api,如果window.config 不存在,则 window.config.api 就会报错,所以为了防止这样的谬误,代码里会大量的充斥 var api = window.config && window.config.api; 这样的代码。

这时,namespace退场了,简洁版本的 namespace 函数的实现(只为演示,不要用于生产):

function namespace(tpl, value) {return tpl.split('.').reduce((pre, curr, i) => {return (pre[curr] = i === tpl.split('.').length - 1
      ? (value || pre[curr])
      : (pre[curr] || {}))
  }, window);
}

namespace 设置 window.app.a.b 的值:

namespace('app.a.b', 3); // window.app.a.b 值为 3

namespace 获取 window.app.a.b 的值:

var b = namespace('app.a.b');  // b 的值为 3
 
var d = namespace('app.a.c.d'); // d 的值为 undefined 

app.a.c值为 undefined,但因为应用了namespace, 所以app.a.c.d 不会报错,变量 d 的值为undefined

AMD/CMD

随着前端业务增重,代码越来越简单,靠全局变量通信的形式开始顾此失彼,前端急需一种更清晰、更简略的解决代码依赖的形式,将 JS 模块化的实现及标准陆续呈现,其中被利用较广的模块标准有 AMD 和 CMD。

面对一种模块化计划,咱们首先要理解的是:1. 如何导出接口;2. 如何导入接口。

AMD

异步模块定义标准(AMD)制订了定义模块的规定,这样模块和模块的依赖能够被异步加载。这和浏览器的异步加载模块的环境刚好适应(浏览器同步加载模块会导致性能、可用性、调试和跨域拜访等问题)。

本标准只定义了一个函数define,它是全局变量。

/**
 * @param {string} id 模块名称
 * @param {string[]} dependencies 模块所依赖模块的数组
 * @param {function} factory 模块初始化要执行的函数或对象
 * @return {any} 模块导出的接口
 */
function define(id?, dependencies?, factory): any

RequireJS

AMD 是一种异步模块标准,RequireJS 是 AMD 标准的实现。

接下来,咱们用 RequireJS 重构下面的我的项目。

在原我的项目 js 文件夹下减少 require.js 文件:

# 我的项目目录:
├─ js                # js 文件夹
│  ├─ ...
│  └─ require.js     # RequireJS 的 JS 库
└─  ...
// config.js
define(function() {
  var api = 'https://github.com/ronffy';
  var config = {api: api,};
  return config;
});
// utils.js
define(['./config'], function(config) {
  var utils = {request() {console.log(config.api);
    }
  };
  return utils;
});
// main.js
require(['./utils'], function(utils) {utils.request();
});
<!-- index.html  -->
<!-- ... 省略其余 -->
<body>

  <script data-main="./js/main" src="./js/require.js"></script>
</body>
</html>

能够看到,应用 RequireJS 后,每个文件都能够作为一个模块来治理,通信形式也是以模块的模式,这样既能够清晰的治理模块依赖,又能够防止申明全局变量。

更多 AMD 介绍,请查看文档。
更多 RequireJS 介绍,请查看文档。

特地阐明:
先有 RequireJS,后有 AMD 标准,随着 RequireJS 的推广和遍及,AMD 标准才被创立进去。

CMD 和 AMD

CMD 和 AMD 一样,都是 JS 的模块化标准,也次要利用于浏览器端。
AMD 是 RequireJS 在的推广和遍及过程中被发明进去。
CMD 是 SeaJS 在的推广和遍及过程中被发明进去。

二者的的次要区别是 CMD 推崇依赖就近,AMD 推崇依赖前置:

// AMD
// 依赖必须一开始就写好
define(['./utils'], function(utils) {utils.request();
});

// CMD
define(function(require) {
  // 依赖能够就近书写
  var utils = require('./utils');
  utils.request();});

AMD 也反对依赖就近,但 RequireJS 作者和官网文档都是优先举荐依赖前置写法。

思考到目前支流我的项目中对 AMD 和 CMD 的应用越来越少,大家对 AMD 和 CMD 有大抵的意识就好,此处不再过多赘述。

更多 CMD 标准,请查看文档。
更多 SeaJS 文档,请查看文档。

随着 ES6 模块标准的呈现,AMD/CMD 终将成为过来,但毋庸置疑的是,AMD/CMD 的呈现,是前端模块化过程中重要的一步。

小贼学生 - 文章旧址

CommonJS

后面说了,AMD、CMD 次要用于浏览器端,随着 node 诞生,服务器端的模块标准 CommonJS 被创立进去。

还是以下面介绍到的 config.js、utils.js、main.js 为例,看看 CommonJS 的写法:

// config.js
var api = 'https://github.com/ronffy';
var config = {api: api,};
module.exports = config;
// utils.js
var config = require('./config');
var utils = {request() {console.log(config.api);
  }
};
module.exports = utils;
// main.js
var utils = require('./utils');
utils.request();
console.log(global.api)

执行 node main.jshttps://github.com/ronffy 被打印了进去。
在 main.js 中打印 global.api,打印后果是undefined。node 用global 治理全局变量,与浏览器的 window 相似。与浏览器不同的是,浏览器中顶层作用域是全局作用域,在顶层作用域中申明的变量都是全局变量,而 node 中顶层作用域不是全局作用域,所以在顶层作用域中申明的变量非全局变量。

module.exports 和 exports

咱们在看 node 代码时,应该会发现,对于接口导出,有的中央应用module.exports,而有的中央应用exports,这两个有什么区别呢?

CommonJS 标准仅定义了 exports,但exports 存在一些问题(上面会说到),所以 module.exports 被发明了进去,它被称为 CommonJS2。
每一个文件都是一个模块,每个模块都有一个 module 对象,这个 module 对象的 exports 属性用来导出接口,内部模块导入以后模块时,应用的也是 module 对象,这些都是 node 基于 CommonJS2 标准做的解决。

// a.js
var s = 'i am ronffy'
module.exports = s;
console.log(module);

执行 node a.js,看看打印的module 对象:

{
  exports: 'i am ronffy',
  id: '.',                                // 模块 id
  filename: '/Users/apple/Desktop/a.js',  // 文件门路名称
  loaded: false,                          // 模块是否加载实现
  parent: null,                           // 父级模块
  children: [],                           // 子级模块
  paths: [/* ... */],                   // 执行 node a.js 后 node 搜寻模块的门路
}

其余模块导入该模块时:

// b.js
var a = require('./a.js'); // a --> i am ronffy

当在 a.js 里这样写时:

// a.js
var s = 'i am ronffy'
exports = s;

a.js 模块的 module.exports 是一个空对象。

// b.js
var a = require('./a.js'); // a --> {}

module.exportsexports放到“明面”上来写,可能就更分明了:

var module = {exports: {}
}
var exports = module.exports;
console.log(module.exports === exports); // true

var s = 'i am ronffy'
exports = s; // module.exports 不受影响
console.log(module.exports === exports); // false

模块初始化时,exportsmodule.exports 指向同一块内存,exports被从新赋值后,就切断了跟原内存地址的关系。

所以,exports要这样应用:

// a.js
exports.s = 'i am ronffy';

// b.js
var a = require('./a.js');
console.log(a.s); // i am ronffy

CommonJS 和 CommonJS2 常常被混同概念,个别大家常常提到的 CommonJS 其实是指 CommonJS2,本文也是如此,不过不管怎样,大家通晓它们的区别和如何利用就好。

CommonJS 与 AMD

CommonJS 和 AMD 都是运行时加载,换言之:都是在运行时确定模块之间的依赖关系。

二者有何不同点:

  1. CommonJS 是服务器端模块标准,AMD 是浏览器端模块标准。
  2. CommonJS 加载模块是同步的,即执行 var a = require('./a.js'); 时,在 a.js 文件加载实现后,才执行前面的代码。AMD 加载模块是异步的,所有依赖加载实现后以回调函数的模式执行代码。
  3. [如下代码]fschalk 都是模块,不同的是,fs是 node 内置模块,chalk是一个 npm 包。这两种状况在 CommonJS 中才有,AMD 不反对。
var fs = require('fs');
var chalk = require('chalk');

UMD

Universal Module Definition.

存在这么多模块标准,如果产出一个模块给其他人用,心愿反对全局变量的模式,也合乎 AMD 标准,还能合乎 CommonJS 标准,能这么全能吗?
是的,能够如此全能,UMD 闪亮退场。

UMD 是一种通用模块定义标准,代码大略这样(如果咱们的模块名称是 myLibName):

!function (root, factory) {if (typeof exports === 'object' && typeof module === 'object') {
    // CommonJS2
    module.exports = factory()
    // define.amd 用来判断我的项目是否利用 require.js。// 更多 define.amd 介绍,请[查看文档](https://github.com/amdjs/amdjs-api/wiki/AMD#defineamd-property-)
  } else if (typeof define === 'function' && define.amd) {
    // AMD
    define([], factory)
  } else if (typeof exports === 'object') {
    // CommonJS
    exports.myLibName = factory()} else {
    // 全局变量
    root.myLibName = factory()}
}(window, function () {// 模块初始化要执行的代码});

UMD 解决了 JS 模块跨模块标准、跨平台应用的问题,它是十分好的解决方案。

小贼学生 - 文章旧址

ES6 module

AMD、CMD 等都是在原有 JS 语法的根底上二次封装的一些办法来解决模块化的计划,ES6 module(在很多中央被简写为 ESM)是语言层面的标准,ES6 module 旨在为浏览器和服务器提供通用的模块解决方案。久远来看,将来无论是基于 JS 的 WEB 端,还是基于 node 的服务器端或桌面利用,模块标准都会对立应用 ES6 module。

兼容性

目前,无论是浏览器端还是 node,都没有齐全原生反对 ES6 module,如果应用 ES6 module,可借助 babel 等编译器。本文只探讨 ES6 module 语法,故不对 babel 或 typescript 等可编译 ES6 的形式展开讨论。

导出接口

CommonJS 中顶层作用域不是全局作用域,同样的,ES6 module 中,一个文件就是一个模块,文件的顶层作用域也不是全局作用域。导出接口应用 export 关键字,导入接口应用 import 关键字。

export导出接口有以下形式:

形式 1

export const prefix = 'https://github.com';
export const api = `${prefix}/ronffy`;

形式 2

const prefix = 'https://github.com';
const api = `${prefix}/ronffy`;
export {
  prefix,
  api,
}

形式 1 和形式 2 只是写法不同,后果是一样的,都是把 prefixapi别离导出。

形式 3(默认导出)

// foo.js
export default function foo() {}

// 等同于:function foo() {}
export {foo as default}

export default用来导出模块默认的接口,它等同于导出一个名为 default 的接口。配合 export 应用的 as 关键字用来在导出接口时为接口重命名。

形式 4(先导入再导出简写)

export {api} from './config.js';

// 等同于:import {api} from './config.js';
export {api}

如果须要在一个模块中先导入一个接口,再导出,能够应用 export ... from 'module' 这样的简便写法。

导入模块接口

ES6 module 应用 import 导入模块接口。

导出接口的模块代码 1:

// config.js
const prefix = 'https://github.com';
const api = `${prefix}/ronffy`;
export {
  prefix,
  api,
}

接口曾经导出,如何导入呢:

形式 1

import {api} from './config.js';

// or
// 配合 `import` 应用的 `as` 关键字用来为导入的接口重命名。import {api as myApi} from './config.js';

形式 2(整体导入)

import * as config from './config.js';
const api = config.api;

将 config.js 模块导出的所有接口都挂载在 config 对象上。

形式 3(默认导出的导入)

// foo.js
export const conut = 0;
export default function myFoo() {}
// index.js
// 默认导入的接口此处刻意命名为 cusFoo,旨在阐明该命名可齐全自定义。import cusFoo, {count} from './foo.js';

// 等同于:import {default as cusFoo, count} from './foo.js';

export default导出的接口,能够应用 import name from 'module' 导入。这种形式,使导入默认接口很便捷。

形式 4(整体加载)

import './config.js';

这样会加载整个 config.js 模块,但未导入该模块的任何接口。

形式 5(动静加载模块)

下面介绍了 ES6 module 各种导入接口的形式,但有一种场景未被涵盖:动静加载模块。比方用户点击某个按钮后才弹出弹窗,弹窗里性能波及的模块的代码量比拟重,所以这些相干模块如果在页面初始化时就加载,切实浪费资源,import()能够解决这个问题,从语言层面实现模块代码的按需加载。

ES6 module 在解决以上几种导入模块接口的形式时都是编译时解决,所以 importexport命令只能用在模块的顶层,以下形式都会报错:

// 报错
if (/* ... */) {import { api} from './config.js'; 
}

// 报错
function foo() {import { api} from './config.js'; 
}

// 报错
const modulePath = './utils' + '/api.js';
import modulePath;

应用 import() 实现按需加载:

function foo() {import('./config.js')
    .then(({api}) => {});
}

const modulePath = './utils' + '/api.js';
import(modulePath);

特地阐明:
该性能的提议目前处于 TC39 流程的第 4 阶段。更多阐明,请查看 TC39/proposal-dynamic-import。

CommonJS 和 ES6 module

CommonJS 和 AMD 是运行时加载,在运行时确定模块的依赖关系。
ES6 module 是在编译时(import()是运行时加载)解决模块依赖关系,。

CommonJS

CommonJS 在导入模块时,会加载该模块,所谓“CommonJS 是运行时加载”,正因代码在运行实现后生成 module.exports 的缘故。当然,CommonJS 对模块做了缓存解决,某个模块即便被屡次多处导入,也只加载一次。

// o.js
let num = 0;
function getNum() {return num;}
function setNum(n) {num = n;}
console.log('o init');
module.exports = {
  num,
  getNum,
  setNum,
}
// a.js
const o = require('./o.js');
o.setNum(1);
// b.js
const o = require('./o.js');
// 留神:此处只是演示,我的项目里不要这样批改模块
o.num = 2;
// main.js
const o = require('./o.js');

require('./a.js');
console.log('a o.num:', o.num);

require('./b.js');
console.log('b o.num:', o.num);
console.log('b o.getNum:', o.getNum());

命令行执行node main.js,打印后果如下:

  1. o init
    模块即便被其余多个模块导入,也只会加载一次,并且在代码运行实现后将接口赋值到 module.exports 属性上。
  2. a o.num: 0
    模块在加载实现后,模块外部的变量变动不会反馈到模块的module.exports
  3. b o.num: 2
    对导入模块的间接批改会反馈到该模块的module.exports
  4. b o.getNum: 1
    模块在加载实现后即造成一个闭包。

ES6 module

// o.js
let num = 0;
function getNum() {return num;}
function setNum(n) {num = n;}
console.log('o init');
export {
  num,
  getNum,
  setNum,
}
// main.js
import {num, getNum, setNum} from './o.js';

console.log('o.num:', num);
setNum(1);

console.log('o.num:', num);
console.log('o.getNum:', getNum());

咱们减少一个 index.js 用于在 node 端反对 ES6 module:

// index.js
require("@babel/register")({presets: ["@babel/preset-env"]
});

module.exports = require('./main.js')

命令行执行 npm install @babel/core @babel/register @babel/preset-env -D 装置 ES6 相干 npm 包。

命令行执行node index.js,打印后果如下:

  1. o init
    模块即便被其余多个模块导入,也只会加载一次。
  2. o.num: 0
  3. o.num: 1
    编译时确定模块依赖的 ES6 module,通过 import 导入的接口只是值的援用,所以 num 才会有两次不同打印后果。
  4. o.getNum: 1

对于打印后果 3,通晓其后果,在我的项目中留神这一点就好。这块会波及到“Module Records(模块记录)”、“module instance(模快实例)”“linking(链接)”等诸多概念和原理,大家可查看 ES modules: A cartoon deep-dive 进行深刻的钻研,本文不再开展。

ES6 module 是编译时加载(或叫做“动态加载”),利用这一点,能够对代码做很多之前无奈实现的优化:

  1. 在开发阶段就能够做导入和导出模块相干的代码查看。
  2. 联合 Webpack、Babel 等工具能够在打包阶段移除上下文中未援用的代码(dead-code),这种技术被称作“tree shaking”,能够极大的减小代码体积、缩短程序运行工夫、晋升程序性能。

后记

大家在日常开发中都在应用 CommonJS 和 ES6 module,但很多人只知其然而不知其所以然,甚至很多人对 AMD、CMD、IIFE 等概览还比拟生疏,心愿通过本篇文章,大家对 JS 模块化之路可能有清晰残缺的意识。
JS 模块化之路目前趋于稳定,但必定不会止步于此,让咱们一起学习,一起提高,一起见证,也心愿能有机会为将来的模块化标准奉献本人的一点力量。
自己能力无限,文中可能不免有一些舛误,欢送大家帮忙改良,文章 github 地址,我是小贼学生。

参考

AMD 官网文档
阮一峰:Module 的加载实现

正文完
 0