基础系列-模块化

模块化

模块化一般指的是可以被抽象封装的最小/最优代码集合,模块化解决的是功能耦合问题。

每个模块都能拥有小于完整程序的体积,使得验证、调试及测试变得轻而易举。

require语句(CommonJS模块)进行模块化开发了。同样,还有一个流行的JavaScript模块化标准,叫作异步模块定义(AMD)。RequireJSAMD最流行的实现。

https://exploringjs.com/es6/ch_modules.html 摘抄如下:

It is impressive how well ES5 module systems work without explicit support from the language. The two most important (and unfortunately incompatible) standards are:

  • CommonJS Modules:

The dominant implementation of this standard is in Node.js (Node.js modules have a few features that go beyond CommonJS). Characteristics:

  • Compact syntax

  • Designed for synchronous loading and servers

  • Asynchronous Module Definition (AMD):

The most popular implementation of this standard is RequireJS. Characteristics:

  • Slightly more complicated syntax, enabling AMD to work without eval() (or a compilation step)
  • Designed for asynchronous loading and browsers

The above is but a simplified explanation of ES5 modules. If you want more in-depth material, take a look at “Writing Modular JavaScript With AMD, CommonJS & ES Harmony” by Addy Osmani.

CJS(CommonJS)

CommonJs的缩写。

CommonJS模块: 是一种写法规范,导入用require,导出用module.exports

同步加载。主要用于后端。

nodejs中使用CJS模块。

CJS 不能在浏览器中工作。它必须经过转换和打包。

关键词:

  • require:引入一个模块
  • export:导出模块内容
  • module:模块本身
// importing
const doSomething = require('./doSomeing.js');

// exporting
module.exports = function doSomething(n) {
    // do something
}

AMD(Asynchronous Module Definition)

AMD异步导入模块的。前端较为常用。

RequireJS是AMD规范的具体实现。

解决问题:

  1. 多个JS文件可能有依赖关系,被依赖的文件需要早于依赖它的文件加载到浏览器;
  2. JS加载的时候浏览器会停止页面渲染,加载文件越多,页面失去响应时间越长。

关键词:

  • id:模块的id
  • dependencies:模块依赖
  • factory:模块的工厂函数,即模块的初始化操作函数
  • require:引入模块
define(['dep1', 'dep2'], function(dep1, dep2) {
    // Define the module value by returning a value
    return function() {}
})

// 或
// RequireJS: simplified CommonJS wrapping
define(function(require) {
    var dep1 = require('dep1')
    var dep2 = require('dep2')
    return function() {}
})

// 或
// 定义模块 myModule.js
define('myModule', ['dependency'], function(){
    var name = 'Byron';
    function printName(){
        console.log(name);
    }
    return {
        printName: printName
    };
});

// 加载模块
require(['myModule'], function (my){
  my.printName();
});

CMD(Common Module Definition)

SeaJS是对CMD的具体实现。

主要用于前端。

// 定义模块  myModule.js
define(function(require, exports, module) {
  var $ = require('jquery.js');
  var foo = require('foo');
  var out = foo.bar();
  $('div').addClass('active');
  module.exports = out;
});

// 加载模块
seajs.use(['myModule.js'], function(my){

});

SeaJS定义了一个全局函数define(id?, deps?, factory)来创建一个模块, define接受一个需要三个参数的函数, 分别为:

  • require: 一个方法, 接受模块标识 作为唯一参数,用来获取其他模块提供的接口:require(id)
  • exports: 一个对象, 用来向外提供模块接口
  • module: 一个对象, 上面存储了与当前模块相关联的一些属性和方法

CMD推崇依赖就近原则(也就是懒加载), 模块内部的依赖在需要引入的时候再引入, 如上例中的var $ = require('jquery.js'), 这一点和通用的CommonJS模块风格保持一致。

AMD与CMD的区别

  1. AMD推崇依赖前置,在定义模块的时候就要声明其依赖的模块,CMD推崇就近依赖,只有在用到某个模块的时候再去require
  2. 两个都是定义的全局define函数来定义模块, define接收函数function(require, exports, module)保持一致
  3. CMD是懒加载, 仅在require时才会加载模块; AMD是预加载, 在定义模块时就提前加载好所有依赖
  4. CMD保留了CommonJS风格

UMD(Universal Module Definition)

UMD表示通用模块定义。在前后端都有解决方案,UMD仓库 中有多种解决方案。

当使用 Rollup/Webpack 之类的打包器时,UMD 通常用作备用模块。

// 其中一种写法
(function(root, factory) {
    if (typeof define === 'function' && define.amd) {
        define(['jquery', 'underscore'], factory);
    } else if (typeof exports === 'object') {
        module.exports = factory(require('jquery'), require('underscore'))
    } else {
        root.Requester = factory(root.$, root._)
    }
}(this, function($, _) {
    // this is where I defined my module implementation
    var Requester = {
        // ....
    }
    
    return Requester
}))

// 或
;(function (global) {
    function factory () {
        var moduleName = {};
        return moduleName;
    }
    if (typeof module !== 'undefined' && typeof exports === 'object') {
        module.exports = factory();
    } else if (typeof define === 'function' && (define.cmd || define.amd)) {
        define(factory);
    } else {
        global.moduleName = factory();
    }
})(typeof window !== 'undefined' ? window : global);
// 引入
// Node.js
var myModule = require('moduleName');
// SeaJs
define(function (require, exports, module) {
    var myModule = require('moduleName');
});
// RequireJs
define(['moduleName'], function (moduleName) {

});
// Browse global
<script src="moduleName.js"></script>

ESM

ESM代表ES模块。这是 Javascript 提出的实现一个标准模块系统的方案。

使用import export进行模块的导入和导出。浏览器不支持时需要使用babel进行转义。

具有 CJS 的简单语法和 AMD 的异步,import可以返回一个promise

ES6静态模块结构,可以进行 Tree Shaking(删除多余代码,参考掘金文章Tree shaking)。

ESM 允许像 RollupWebpack这样的打包器,删除不必要的代码,减少代码包可以获得更快的加载。

关键词:

  • import:引入模块依赖
  • export:模块导出
// 方式一
import moduleA from './moduleA'; // 导入
export default { // 默认导出
    ...
}

// 方式二
import { moduleB } from './moduleB'; // 导入 重定向命名 module语法,不是解构语法
export const moduleB = {...} // 导出 声明的同时命名导出
export { // 与变量名绑定命名导出
    moduleB: '...'
}
                        
// 方式三 -- wildcard(通配符)
import * as moduleC from './moduleC' // 导入 重定向命名
export const a = 1; // 导出, 会将a, b当作属性打包到moduleC对象中
export const b = 1;
                        
// 方式一和方式二 合并使用
import moduleA, { moduleB } from './moduleD' // 导入
export default moduleA; // 导出
export const moduleB;

方式1和方式2可以同时使用,使用的方法依旧是一一对应的方式

总结

  • 由于 ESM 具有简单的语法,异步特性和Tree Shaking,因此它是最好的模块化方案。只有ESM才支持Tree Shaking
  • UMD 随处可见,通常在 ESM 不起作用的情况下用作备用
  • CJS 是同步的,适合后端
  • AMD 是异步的,适合前端
  • CJS是值的拷贝,ESMexport default也是值的拷贝,ESMexport {}是引用的绑定。
  • CJS模块是运行时加载,ESM是编译时输出接口。

CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

参考链接

https://github.com/umdjs/umd

https://blog.risingstack.com/node-js-at-scale-module-system-commonjs-require/

https://exploringjs.com/es6/ch_modules.html#sec_design-goals-es6-modules

掘金:https://juejin.cn/post/6935973925004247077

简书:https://www.jianshu.com/p/5226bd9644b6

深入 CommonJs 与 ES6 Module – segmentfault

推荐阅读:前端模块化详解(完整版)

模块间的相互转换

ESM 转换为 CJS

浏览器中不能直接运行ESM,所以要将ESM转换为CJS,需要使用到babel的工具库对代码进行转义和分析。

@babel/parser 帮助进行源代码语义分析 https://www.babeljs.cn/docs/babel-parser

@babel/traverse 对解析出的抽象语法树进行遍历 https://www.babeljs.cn/docs/babel-traverse

@babel/core babel的核心模块,对代码进行转义,使得可以在浏览器中运行 https://babeljs.io/docs/en/babel-core

@babel/preset-env代码转义插件,将代码转换为目标环境可运行的代码 https://babeljs.io/docs/en/babel-preset-env

先使用@babel/parser将源代码转换为抽象语法树,然后使用@babel/core对抽象语法树使用@babel/preset-env设置目标环境转义代码。

const fs = require('fs');
const path = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default; // 默认ES6 module的导出,使用要用default
const babel = require('@babel/core')

const moduleAnalyser = (filename) => { // 读取文件,获取文件内容 进行解析抽象语法树
    const content = fs.readFileSync(filename, 'utf-8');
    // 获取抽象语法树
    const ast = parser.parse(content, {
        sourceType: 'module'
    })
    const dependencies = {} // 获取依赖文件信息, 存入的路径是绝对路径或者是相对于bundle的相对路径
    
    // 遍历抽象语法树,对特定的部分进行处理
    // 处理路径,如果只存一个相对路径会在打包的时候比较麻烦,所以存一个相对路径和绝对路径
    traverse(ast, {
        ImportDeclaration({ node }) {
            const dirname = path.dirname(filename)
            const sourceValue = node.source.value
            const newFile = './' + path.join(dirname, sourceValue)
            dependencies[sourceValue] = newFile; // 键值对的方式进行存储
        }
    });
    // 对抽象语法树进行转换, 将转换后的code取出
    const { code } = babel.transformFromAst(ast, null, {
        presets: ['@babel/preset-env']
    })
    console.log(code)
    return {
        filename, // 文件
        dependencies, // 依赖
        code // 转移后的代码
    }
}
使用export,示例文件word.js
// EMD 源代码
export const word = 'hello'

// babel转义后的code
"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.word = void 0;
var word = 'hello';
exports.word = word;

export导出的实际上是exports对象

使用exportimport,示例文件message.js
// ESM源代码
import { word } from './word.js';

const message = `say ${word}`;

export default message;

// babel转义后的code
"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports["default"] = void 0;

var _word = require("./word.js");

var message = "say ".concat(_word.word); // 转义 { word }
var _default = message;
exports["default"] = _default;

export default导出会生成对应的exports.default

使用import,示例文件index.js且为入口文件:
// ESM 源代码
import message from './message.js';

console.log(message);

// babel转义后的code
"use strict";

var _message = _interopRequireDefault(require("./message.js"));

function _interopRequireDefault(obj) { 
    // 如果是ESM 则直接返回,否则返回带有default的新对象
    return obj && obj.__esModule ? obj : { "default": obj }; 
}

console.log(_message["default"]);

import转换成require

使用通配符的方式import

word.js改写为

export const word = 'hello'
export const word2 = 'hello2'

message.js改写为

import * as word from './word.js';

const message = `say ${word.word} ${word.word2}`;

export default message;

message.js转义为

"use strict";

function _typeof(obj) { 
    "@babel/helpers - typeof"; 
    if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { 
        _typeof = function _typeof(obj) { return typeof obj; }; 
    } else { 
        _typeof = function _typeof(obj) { 
            return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; 
        }; 
    } 
    return _typeof(obj); 
}

Object.defineProperty(exports, "__esModule", {
    value: true
});
exports["default"] = void 0;

var word = _interopRequireWildcard(require("./word.js"));

function _getRequireWildcardCache() { 
    if (typeof WeakMap !== "function") return null; 
    var cache = new WeakMap(); 
    _getRequireWildcardCache = function _getRequireWildcardCache() { return cache; }; 
    return cache; 
}

function _interopRequireWildcard(obj) { 
    if (obj && obj.__esModule) { return obj; } 
    if (obj === null || _typeof(obj) !== "object" && typeof obj !== "function") { 
        return { "default": obj }; 
    } 
    var cache = _getRequireWildcardCache(); 
    if (cache && cache.has(obj)) { 
        return cache.get(obj); 
    } 
    var newObj = {}; 
    var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; 
    for (var key in obj) { 
        if (Object.prototype.hasOwnProperty.call(obj, key)) { 
            var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; 
            if (desc && (desc.get || desc.set)) { 
                Object.defineProperty(newObj, key, desc); 
            } else { 
                newObj[key] = obj[key]; 
            } 
        } 
    } 
    newObj["default"] = obj; 
    if (cache) { 
        cache.set(obj, newObj); 
    } 
    return newObj; 
}

var message = "say ".concat(word.word, " ").concat(word.word2);
var _default = message;
exports["default"] = _default;

CJS的话就创建一个新的空对象,并把obj内的所有自有属性都浅复制到新对象中,最后再把obj赋值给新对象的default属性

将ESM转义成浏览器中可以运行的代码(核心部分,简易的打包库):
// index.js为仅简单的模块间相互调用的示例

// 简易的打包工具
// 对项目的打包,首先要先读取入口文件,对项目代码进行分析
// cli-highlight插件,帮助高亮cli输出
// @babel/parser 帮助进行源代码语义分析 https://www.babeljs.cn/docs/babel-parser
// @babel/traverse 对解析出的抽象语法树进行遍历 https://www.babeljs.cn/docs/babel-traverse
// @babel/core babel的核心模块,对代码进行转义,使得可以在浏览器中运行 https://babeljs.io/docs/en/babel-core
// @babel/preset-env代码转义插件,将代码转换为目标环境可运行的代码  https://babeljs.io/docs/en/babel-preset-env

const fs = require('fs');
const path = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default; // 默认ES6 module的导出,使用要用default
const babel = require('@babel/core')

const moduleAnalyser = (filename) => { // 读取文件,获取文件内容 进行解析抽象语法树
    const content = fs.readFileSync(filename, 'utf-8');
    // 获取抽象语法树
    const ast = parser.parse(content, {
        sourceType: 'module'
    })
    const dependencies = {} // 获取依赖文件信息, 存入的路径是绝对路径或者是相对于bundle的相对路径
    // 如果只存一个相对路径会在打包的时候比较麻烦,所以存一个相对路径和绝对路径
    traverse(ast, {
        ImportDeclaration({ node }) {
            const dirname = path.dirname(filename)
            const sourceValue = node.source.value
            const newFile = './' + path.join(dirname, sourceValue)
            dependencies[sourceValue] = newFile; // 键值对的方式进行存储
        }
    });
    // 对抽象语法树进行转换, 将转换后的code取出
    const { code } = babel.transformFromAst(ast, null, {
        presets: ['@babel/preset-env']
    })
    return {
        filename, // 文件
        dependencies, // 依赖
        code // 转移后的代码
    }
}

// 依赖图谱,将入口文件中的依赖文件再进一步分析,一层一层递进
const makeDependenciedGraph = (entry) => {
    // 分析入口文件
    const entryModule = moduleAnalyser(entry);
    const graphArray = [ entryModule ]; // 初始化图谱数组

    for (let i = 0; i < graphArray.length; i++) {
        const item = graphArray[i];
        const { dependencies } = item; // 获取依赖文件
        if (dependencies) {
            // 如果存在依赖,则循环依赖
            for (let j in dependencies) {
                // 对依赖文件进行分析,将分析结果存入到图谱数组中
                graphArray.push(
                    moduleAnalyser(dependencies[j]) // 依赖的对象文件 
                ); // 推入graphArray中后,长度变长,下一个进行遍历分析的文件
            }
        }
    }

    // 关系图谱数据解构转化,将数组转化为对象
    const graph = {};
    graphArray.forEach(item => {
        graph[item.filename] = {
            dependencies: item.dependencies,
            code: item.code
        }
    });
    return graph;
}

// 最后的结果返回代码字符串
const generateCode = (entry) => {
    // 转化成字符串传入到闭包中
    const graph = JSON.stringify(makeDependenciedGraph(entry));
    // 代码在闭包中执行避免污染外部环境
    // require无法在浏览器中直接运行,需要进行创建require方法
    // export存储导出的内容
    // 返回一个字符串
    // localRequire转换文件的相对路径返回真实路径
    // 递归调用到最内层的依赖文件,然后通过exports导出
    return `
        (function(graph) {
            function require(module) {
                function localRequire(relativePath) {
                    return require(graph[module].dependencies[relativePath]);
                }
                var exports = {};
                (function(require, exports, code) {
                    eval(code);
                })(localRequire, exports, graph[module].code);
                return exports;
            };
            require('${entry}');
        })(${graph});
    `;
}

const code = generateCode('./src/index.js')
console.log(code)

// 注意:示例文件中本来是有注释的,但是注释加上去了之后就会出现问题,所以删除注释后在chrome://test模式下运行成功
总结
  • ESMexport会被转义为exports对象,根据写法的不同,属性命名方式不同。
  • ESMimport会因文件的export方式不同而不同。
    • 引入export default导出的文件时,会创建一个空对象exports,并把导出对象赋值到exports对象的default属性上。
    • 引入export导出的文件时,对象解构的方式引入,会创建一个空对象exports,并把导出的属性赋值到exports对象上,并且同名。
    • 引入export导出的文件时,通配符(*)的方式引入,会创建一个空对象exports,并把 CJS 的导出对象 exports 中的自有属性浅复制到新的空对象中,最后再把导出对象 exports 赋值到新对象的 default 属性
在node中使用ESM

https://github.com/nodejs/node-eps/blob/master/002-es-modules.md

Node.js 如何处理 ES6 模块 – 阮一峰

.mjs文件总是以 ES6 模块加载,.cjs文件总是以 CommonJS 模块加载,.js文件的加载取决于package.json里面type字段的设置。

注意,ES6 模块与 CommonJS 模块尽量不要混用。require命令不能加载.mjs文件,会报错,只有import命令才可以加载.mjs文件。反过来,.mjs文件里面也不能使用require命令,必须使用import

从 node v13.2.0开始,node 才对ES6模块化提供了支持。

node v13.2.0之前需要进行项目配置,在 package.json中添加属性:”type”: “module”,在执行命令中添加 node --experimental-modules src/index.js

在 node v13.2.0之后只需要在 package.json 中添加属性:”type”: “module”

导入文件时,需要跟上.js后缀名

参考文章

CommonJS Module和ES6 Modules之间的引用与转换

https://github.com/Datura35422/study/blob/master/webpack-demos/bundler/bundler.js

其他概念

组件化

组件化更像是模块化的进一步封装,根据业务特点或者不同的场景封装出具有一定功能特性的独立整体。

CSS样式模块化

CSS本身也具有@import的方式来引入依赖模块。

Less、Sass、stuly等css预处理器、CSS in JS扩展了css模块化的功能。

webpack使用 css modules 处理css模块问题