前端工程化 | JS 的模块化

前端项目的模块化可以从三个角度理解,同时也反映了 JavaScript 文件组织方式逐层深入的发展过程:

1 - 函数的模块化

最原始的函数模块化方式即直接在 JS 文件中定义全局函数来实现特定功能

1
2
function method1() {...}
function method2() {...}

随着项目复杂化,这种方式会有函数命名冲突,全局变量污染的问题

在 ES6 标准之前,JS 语言并不支持类,因此需要通过特定写法对此进行优化,主要有两种方式:

  • 命名空间(namepace)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    var MyNameSpace = MyNameSpace || {};
    MyNameSpace.Module1 = function() {
    function privateFunc() {
    console.log("private function");
    }
    return {
    publicFunc: function() {
    console.log("public function");
    privateFunc();
    }
    };
    }();

    MyNameSpace.Module1.publicFunc();
    // public function
    // private function
  • 立即执行函数(IIFE)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    (function(global) {
    let data = "data"
    function func1() {
    console.log("function 1");
    }
    function func2() {
    console.log("function 2");
    privateFunc();
    }
    function privateFunc() {
    console.log("private function");
    }
    global.MyIIFE = {func1, func2}
    })(global)

    MyIIFE.func1();
    MyIIFE.func2();
    // function 1
    // function 2
    // private function

    封装在匿名函数中的函数方法有独立作用域,需要主动暴露出接口供外部访问

    IIFE 方式可以进一步通过传递对象参数管理依赖关系:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    var GlobalContainer = GlobalContainer || {};

    GlobalContainer.Mod1 = (function(container) {
    let mod = {
    publicMeth: function() {
    console.log("module 1");
    }
    }
    container.Mod1 = mod;
    return container.Mod1;
    })(GlobalContainer);

    GlobalContainer.Mod2 = (function(container) {
    let mod = {
    publicMeth: function() {
    console.log("module 2 depends on module 1");
    container.Mod1.publicMeth();
    }
    }
    container.Mod2 = mod
    return container.Mod2;
    })(GlobalContainer);

    GlobalContainer.Mod2.publicMeth();
    // module 2 depends on module 1
    // module 1

在 TypeScript 中,有提供封装好的 Namespace 机制,其内部实现正是 IIFE 方式

2 - JS 文件的模块化

函数层面的模块化一定程度上解决了全局污染和依赖管理的问题,但随着项目的复杂化, JavaScript 文件数量变得十分庞大,传统的 JS 引入方式依赖关系不清晰,需要人工按照严格的先后顺序引入 JS ,难以维护

因此,出现了文件层面的模块化方案,形成了如下模块化标准:

  • CommonJS

    最早发布的常用模块化标准,是开发者社区提出的民间规范

    1
    2
    3
    4
    5
    6
    7
    8
    // 导出模块
    module.exports = function() {
    ...
    }
    // 导入模块
    const module1 = require('./module1');
    ...

    基于 CommonJS 标准的模块化代码支持 Node.js 环境和浏览器环境,但通常运行在服务器中,模块为运行时同步加载,即 JS 文件间的依赖关系在代码运行后才能确定

  • AMD (Asynchronous Module Definition)

    运行在浏览器环境,模块异步加载,能够保证浏览器不会总是等待从服务器获取模块,避免阻塞卡死

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 定义模块(依赖模块 mod1、mod2)
    define(['mod1', 'mod2'], function(m1, m2) {
    ...
    return module
    })
    // 引入模块(加载模块 mod1、mod2,完成后执行回调函数)
    require(['mod1', 'mod2'], function(m1, m2) {
    ...
    })

    CMD 可以理解为 CommonJS 与 AMD 特点的结合,模块同样为异步加载,专用于浏览器环境

  • EcmaScript Module(ESM)

    ES6 原生提供的官方模块化标准,浏览器、Node、构建工具三种运行环境全部支持,模块为编译时静态加载,即 JS 文件间依赖关系在编译时确定,不需要运行代码

    1
    2
    3
    4
    5
    6
    7
    8
    // 导出模块
    export function func1() {
    ...
    }
    // 导入模块
    import { func1 } from './module1';
    ...

    由于模块编译时静态加载,ESM 代码可以进行 Tree-shaking 优化

现代前端项目基本均在构建工具环境中运行,而不同构建工具支持的代码模块化标准不同(如 Webpack 同时支持 CJS 和 ESM,而 rollup/esbuilder 仅支持 ESM),绝大多数情况下推荐使用 ESM 标准

3 - 模块的模块化(包管理)

模块的集合称为包

本质上,包就是对多个 JS 模块的进一步模块化聚合

为解决前端在某一方面的问题,社区中涌现出了很多第三方库和框架(如 axios、jQuery、vue/react),这些均可以看作是包

“库”和“框架”是从功能角度划分的概念,从结构的角度上说,它们都属于包

向开发者提供这些封装好的包时,用传统的 git 等方式共享会存在难以控制版本、管理依赖等弊端,因此出现了一些专门的包管理工具:

  • npm

最常用的 Node.js 官方包管理器,

  • pnpm

  • yarn

4 - 打包/构建工具

  • Webpack

  • Rollup

小结

JS 的模块化标准解决了文件作用域的问题,并统一了文件的导入导出方式

模块化代码的运行环境有三种:

  • 浏览器环境
  • Node 环境
  • 构建工具执行