前端模块化原理

前言

本文会从前端模块化的演进历史,来逐个分析不同的模块化规范的特性与实现原理,希望通过本文的学习,大家能够彻底弄懂前端模块化的实现原理。看完本文可以掌握,以下几个方面:

  1. 为什么需要模块化,什么是模块化。
  2. CommonJS规范的实现原理与加载原理
  3. ESModule 实现原理与加载原理

混乱的前端时期

早期在没有打包工具的情况下,很多情况下大家会直接在对应的文件下申明变量与引入依赖,从而造成一些变量污染依赖引用混乱的问题,线上也会出现一些神奇的 bug。比如

var name = 'this is index.js';
var outPutMyName = () => {
console.log(name);
}
var name = 'this is home.js';
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>test page</title>
  </head>
  <body>
    <script src="./index.js"></script>
    <script src="./home.js"></script>
    <script>
          // outputMyName 不仅可以调用,还会输出 this is home.js
    outputMyName();
    </script>
  </body>
</html>

随着前端项目的复杂度越来越高,从早期的 100 行的脚本变成成千上万行脚本,这种变量污染依赖引用混乱的问题极大的制约了前端开发的效率,从而前端的模块化开发方案应运而生。

什么是模块化

模块化就是将一个复杂的程序依据一定的规则或者说是规范,将其封装成几个单独的块(这里的块指的就是文件),在使用的时候将其组合在一起。

块内部的数据是私有的,只是向外部暴露一些接口或者说是一些方法,让其与其他模块进行通信。

由于在早期社区各个大佬的思路各不统一,先先后后出现了不少模块化的解决方案,这里我们讲一下几个使用比较广泛的模块化规范

CommonJS

CommonJS 是一个规范,最初提出来是在浏览器以外的地方使用,并且当时被命名为ServerJS,后来为了体现它的广泛性,修改为CommonJS规范。Node 是 CommonJS在服务器端一个具有代表性的实现。正是因为Node中对CommonJS进行了支持和实现,所以它具备以下几个特点:

  • 在 commonjs 中每一个 js 文件都是一个单独的模块,我们可以称之为 module;
  • 该模块中,包含CommonJS规范的核心变量: exports、module.exports、require
  • exports 和 module.exports 可以负责对模块中的内容进行导出;
  • require 函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容;

简单使用

// 文件 name.js
const name = 'this is index.js'
const outputName = () => {
console.log(name);
}

module.exports = {
outputName,
}

// 文件 index.js
const { outputName } = require('./name.js');
const main = () => {
console.log('this is index.js');
outputName();  
}
exports = main;

那么它是如何解决变量污染的问题的?module、exports、require 又是如何工作的呢?

实现原理

细心的同学已经发现,在每一个文件中 module、exports、require 是没有经过定义的,按照 JS 的执行逻辑,未定义的变量会直接报错,那么为什么在 Node 的 CommonJS 中可以直接使用呢?因为在实际的编译过程中,实际 Commonjs 对 js 的代码块进行了首尾包装,以我们上面的代码为例,实际的效果是这样的

(function(exports,require,module){
const { outputName } = require('name.js');
const main = () => {
console.log('this is index.js');
outputName();  
}
exports = main;
})

到这里我们就解答了第一个问题,commonJS 是如何解决变量污染的问题。在 JS 中,函数在执行的过程中会创建自己的私有作用域,外部是无法访问的(这也是我们常说的 JS 闭包),所以就不会存在文章最开始的不同文件加载,导致同一个变量被污染。在 Node 实际的运行过程中,读取到的一个文件只是一个字符串,那么我们还需要运行当前的字符串,所以在函数包装的本质是会有一个 wrapper 函数进行统一的包装处理

function wrapper (script) {
    return '(function (exports, require, module) {' + 
        script +
     '\n})'
}

对应到上面的代码,实际的执行效果就是

const moduleFunctionStr = wrapper(`
const { outputName } = require('./name.js');
const main = () => {
console.log('this is index.js');
outputName();  
}
exports = main;
`);
// 实际的情况并不是简单的 eval
eval(moduleFunction)(module.exports, require, module)
require 工作机制

通过上面的原理分析,我们知道 module、exports、require 是被注入进来的函数。其中 require 函数接收一个变量,返回值为 module.exports 的值。那么我们就可以简单的设计一下 require 函数

const require = (id) => {
  // id 拿到的可能是相对路径,也有可能是node_modules的依赖,也有可能是 node 的基础模块、根据不同的情况读取到不同的地址ID,确保唯一性
  const realId = formatIdToPath(id);
  const fileStr = getFileStr(realId);
  const modlue = {
  exports: {};
  };
  
  wrapper(fileStr)(module.exports, require, module);
  return modlue.exports;
}

简单来说,就是读取到对应的代码,通过上诉的 wrapper 包装一下,并且用我们申明好的 module 与 require 注入,利用引用数据类型的特性将执行后的返回值返回。但是这里有一个明显的问题,文件会被反复读取,这样会造成性能的极大浪费,那不妨我们再加一层缓存,在上诉代码基础上稍作改动。

const Module = {
cache: {},
}
const require = (id) => {
  // id 拿到的可能是相对路径,也有可能是node_modules的依赖,也有可能是 node 的基础模块、根据不同的情况读取到不同的地址ID,确保唯一性
  const realId = formatIdToPath(id);
  if (Module.cache[id]) {
  return Module.cache[id].exports;
  }
  
  const fileStr = getFileStr(realId);
const modlue = {
  exports: {};
  };
  Module.cache[id] = module;
  wrapper(fileStr)(module.exports, require, module);
  return modlue.exports;
}

现在我们来总结一下 require 的工作流

  • require 会接收一个参数——文件标识符,然后分析定位文件,分析过程我们上述已经讲到了,加下来会从 Module 上查找有没有缓存,如果有缓存,那么直接返回缓存的内容。
  • 如果没有缓存,会创建一个 module 对象,缓存到 Module 上,然后执行文件,加载完文件。
  • 模块导出就是 return 这个变量的其实跟 a = b 赋值一样, 基本类型导出的是值, 引用类型导出的是引用地址。
  • exports 和 module.exports 持有相同引用,因为最后导出的是 module.exports, 所以对 exports 进行赋值会导致 exports 操作的不再是 module.exports 的引用。
exports 和 module.exports

通过上面的分析,我们可以知道 exports 其实就是 module 上面的一个属性,且 exports 会被初始化为一个空对象。下面简单列一下二者的使用场景

// module.exports
module.exports = function() { ... }
module.exports = {
name: '张三',
  say: () => { ... }
}
// exports
exports.name = '张三';
exports.say = () => {}

那么问题来了?既然有了 exports,为何又出了 module.exports?

如果我们不想在 commonjs 中导出对象,而是只导出一个类或者一个函数再或者其他属性的情况,那么 module.exports 就更方便了,如上我们知道 exports 会被初始化成一个对象,也就是我们只能在对象上绑定属性,但是我们可以通过 module.exports 自定义导出出对象外的其他类型元素。且 exports 是没法像 module.exports 直接导出一个对象的。比如

// a.js
exports = {
x: 1,
}

// b.js
var a = require('./a');
console.log(a); // 输出 {}

ESmodule

Nodejs 借鉴了 Commonjs 实现了模块化 (Node v13.2.0 起开始正式支持 ES Modules 特性),虽然我们可以通过各种构建工具,将 CommonJS 的模块化方案应用到 web 端,但 CommonJS 并不是 javaScript 官方的模块化规范,直到 ES6 的出现,JavaScript 才真正意义上有自己的模块化规范:ESmodule。

ESmodule 的出发点与 CommonJS 有一些不同,它除了提供模块化的解决方案外,更注重代码的静态分析能力,毕竟在 web 端 JS Bundle 的体积会直接影响页面访问速度。所以 ESmodule 相对 CommonJS 有很多优势,比如

  1. 借助 ESmodule 的静态导入导出的优势,能够很方便的实现 tree shaking。
  2. 现代浏览器可以直接支持ESmodule代码(Vite 就是利用了一特性)

简单使用

export const count = 1;
export const name = '张三';
const age = 12;
export { age };
export default () => {
console.log(cont);
}
export * from 'module' // 将module 中的内容导出,但是不包含 default 
import consoleCount, { count, name as homeName, age } from './home.js'
import * as homeData from './home.js';

整体来说 ESmodule 具有以下几个特点

  1. 静态语法

    ES6 module 的引入和导出是静态的,import 会自动提升到代码的顶层 ,import , export 不能放在块级作用域或条件语句中。这种静态语法,在编译过程中确定了导入和导出的关系,所以更方便去查找依赖,更方便去 tree shaking (摇树) , 可以使用 lint 工具对模块依赖进行检查,可以对导入导出加上类型信息进行静态的类型检查。

  2. 执行特性

    ES6 module 和 Common.js 一样,对于相同的 js 文件,会保存静态属性。但是与 Common.js 不同的是 ,CommonJS 模块同步加载并执行模块文件,ES6 模块提前加载并执行模块文件,ES6 模块在预处理阶段分析模块依赖,在执行阶段执行模块,两个阶段都采用深度优先遍历,执行顺序是子 -> 父。

    // main.js
    console.log('main.js开始执行')
    import say from './a'
    import say1 from './b'
    console.log('main.js执行完毕')
    
    // a.js
    import b from './b'
    console.log('a模块加载')
    export default  function say (){
        console.log('hello , world')
    }
    // b.js
    console.log('b模块加载')
    export default function sayhello(){
        console.log('hello,world')
    }
    

    执行 main.js 会输出

    b模块加载
    a模块加载
    main.js开始执行
    main.js执行完毕
    

    相同的代码,我们用 CommonJS 实现

    // main.js
    console.log('main.js开始执行')
    const a = require('./a');
    const b = require('./b');
    console.log('main.js执行完毕')
    
    // a.js
    require('./b')
    console.log('a模块加载')
    // b.js
    console.log('b模块加载')
    

    执行 main 会输出

    main.js开始执行 
    b模块加载 
    a模块加载 
    main.js执行完毕 
    
  3. 只读属性

    import {  num , addNumber } from './a'
    num = 2 // 报错,导入变量是只读的
    

    但是如果是引用数据类型,其实是可以改变其属性,并且会影响所有引用的地方。但是:千万别做么做!!!

    // a.js
    export const obj = { x: 1}
    
    // b.js
    import obj from './a.js';
    obj.x = 2;
    
    // main.js
    import { obj } from './a.js';
    import './b.js'
    
    console.log(obj); // 输出 { x: 2 }
    
  4. 属性绑定

    ESmodule 中则是值的动态映射,并且这个映射是只读的。这与CommonJS 值拷贝是不一样的。比如

    // a.js
    export var count = 0;
    export default () => {
      count += 1;
    };
    
    
    // main.js
    import addCount, { count } from './a'
    console.log(count); // 0
    addCount();
    console.log(count); // 1
    

    同样的代码我们拿 CommonJS 实现一遍

    // a.js
    var count = 0;
    module.exports = {
    count,
      addCount: () => {
      count += 1;
      }
    }
    
    // main.js
    const { count, addCount } require('./a')
    console.log(count); // 0
    addCount();
    console.log(count); // 0
    

实现原理

通过上面的分析我们简单总结一下 ESmodule 的特性

  • 使用 import 被导入的模块运行在严格模式下。
  • 使用 import 被导入的变量是只读的,可以理解默认为 const 装饰,无法被赋值
  • 使用 import 被导入的变量是与原变量绑定/引用的,可以理解为 import 导入的变量无论是否为基本类型都是引用传递。

那如果由我们设计这样的模块化方案,我们应该怎么设计呢 ?我们可以通过 webpack 的产物来窥探其中的秘密。

首先,我们可以用 webpack 搭建一个简单的项目。

// a.js
console.log('加载a 文件');
export const data = 'aaa';

export default function () {
  console.log(data);
}
// index.js
console.log('开始加载主入口');
import sayData, { data } from './modules/a'
sayData()
console.log(data);
console.log('结束加载主入口');

我们通过上面的分析,可以知道处理 ESmodule 其实是分了两步,第一步是预处理阶段,静态分析依赖编译,第二步是执行阶段。通过编译上诉的代码,在bundle 里面的表现为

(() => {
var __webpack_modules__ = ({
"./src/index.js": ((module, __webpack_exports__, __webpack_require__) => {
      // 这里是通过引用调用与 CommonJS 的差异点就在这里
var _modules_a__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./modules/a */ "./src/modules/a.js");
  console.log('开始加载主入口');
  
  (0,_modules_a__WEBPACK_IMPORTED_MODULE_0__["default"])();
  console.log(_modules_a__WEBPACK_IMPORTED_MODULE_0__.data);
  console.log('结束加载主入口');
}),
  
"./src/modules/a.js": ((module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.d(__webpack_exports__, {
"data": () => (/* binding */ data),
"default": () => (/* export default binding */ __WEBPACK_DEFAULT_EXPORT__)
});  
  console.log('加载a 文件');
  const data = 'aaa';
function __WEBPACK_DEFAULT_EXPORT__() {
    console.log(data);
  }
  }),
 })
})

将代码简化后,我们可以了解到,文件的内容都存储在 webpack_modules_ 这个对象上,文件的引用通过 webpack_require 导入,文件导出通过 webpack_require.d 导出,那么我们接下来分析一下 webpack_require 这个函数的实现。

__webpack_require__与 CommonJS 中的 require 实现类似,同样接收一个id,返回这个id 对应内容的导出内容。其实内部的实现也是类似的

var __webpack_module_cache__ = {};
function __webpack_require__(moduleId) {
// Check if module is in cache
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
if (cachedModule.error !== undefined) throw cachedModule.error;
return cachedModule.exports;
}
// Create a new module (and put it into the cache)
var module = __webpack_module_cache__[moduleId] = {
// no module.id needed
// no module.loaded needed
exports: {}
};

// Execute the module function
try {
    // 这里被我修改了,实际的运行要比这个复杂的多
    var factory = __webpack_modules__[moduleId];
    factory(module, module.exports, __webpack_require__)
} catch(e) {
module.error = e;
throw e;
}

// Return the exports of the module
return module.exports;
}
__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
__webpack_require__.d = (exports, definition) => {
for(var key in definition) {
if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
      // 设置其不可改属性
Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
}
}
};

总结

现在我们再来一起回顾一下 CommonJS 与 ESmodule 的一些特性。

  • CommonJS

    • CommonJS 模块由 JS 运行时实现。
    • CommonJs 是单个值导出,本质上导出的就是 exports 属性。
    • CommonJS 是可以动态加载的,对每一个加载都存在缓存,可以有效的解决循环引用问题。
    • CommonJS 模块同步加载并执行模块文件。
    • CommonJS 导出是浅拷贝赋值,无法读取到修改后的值。但是可异步获取。
  • ESmodule

    • ES6 Module 静态的,不能放在块级作用域内,代码发生在编译时。
    • ES6 Module 的值是动态映射的,可以通过导出方法修改,可以直接访问修改结果。
    • ES6 Module 可以导出多个属性和方法,可以单个导入导出,混合导入导出。
    • ES6 模块提前加载并执行模块文件,采用深度优先遍历,执行顺序是子 -> 父。
    • ES6 Module 导入模块在严格模式下。
    • ES6 Module 的特性可以很容易实现 Tree Shaking 和 Code Splitting。

其它模块化规范

  • AMD

    AMD是英文Asynchronous Module Definition(异步模块定义)的缩写,它是由JavaScript社区提出的专注于支持浏览器端模块化的标准。从名字就可以看出它与CommonJS和ES6 Module最大的区别在于它加载模块的方式是异步的。下面的例子展示了如何定义一个AMD模块。

    define('getSum', ['calculator'], function(math) {
        return function(a, b) {
            console.log('sum' + calculator.add(a, b))
        }
    })
    
  • UMD

    严格来说,UMD并不能说是一种模块标准,不如说它是一组模块形式的集合更准确。UMD的全称是Universal Module Definition,也就是通用模块标准,它的目标是使一个模块能运行在各种环境下,不论是CommonJS、AMD,还是非模块化的环境(当时ES6 Module还未被提出)。

    (function(global, main) {
        // 根据当前环境采取不同的导出方式
        if (typeof define === 'function' && defind.amd) {
            // AMD
            define(...)
        } else if (typeof exports === 'object') {
            // CommonJS
            module.exports = ...
        } else {
            global.add = ...
        }
    })(this, function() {
        // 定义模块主体
        return {...}
    })
    
  • CMD

    CMD(Common Module Definition – 通用模块定义)规范主要是Sea.js推广中形成的,一个文件就是一个模块,可以像Node.js一般书写模块代码。主要在浏览器中运行,当然也可以在Node.js中运行。

    它与AMD很类似,不同点在于:AMD 推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。

    // model1.js
    define(function (require, exports, module) {
        console.log('model1 entry');
        exports.getHello = function () {
            return 'model1';
        }
    });
    // model2.js
    define(function (require, exports, module) {
        console.log('model2 entry');
        exports.getHello = function () {
            return 'model2';
        }
    });
    // main.js
    define(function(require, exports, module) {
        var model1 = require('./model1'); //在需要时申明
        console.log(model1.getHello());
        var model2 = require('./model2'); //在需要时申明
        console.log(model2.getHello());
    });
    <script src="https://cdn.bootcss.com/seajs/3.0.3/sea.js"></script>
    <script>
        seajs.use('./main.js')
    </script>
    // 输出 
    // model1 entry
    // model1
    // model2 entry
    // model2
    

参考文档

  1. 前端模块化详解(CommonJS、AMD、CMD、ES Module)
  2. 「Node.js系列」深入浅出Node模块化开发——CommonJS规范
  3. 深入 CommonJs 与 ES6 Module
  4. 深入浅出 Commonjs 和 Es Module
  5. 从构建产物洞悉模块化原理
© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容