前端工程化之模块规范(1)

53 阅读6分钟

这是我参与「掘金日新计划 · 8 月更文挑战」的第6天

前言

在前端工程化中,一个重要的概念就是模块化,本文是前端工程化学习之模块化笔记。

概念介绍:模块化

在前端项目的日常开发中,往往会出现变量名,函数命名等命名冲突,并且可能造成污染全局变量的情况出现;或者说在复杂项目中,存在大量相似或相同代码,造成代码的冗余;亦或者引用大量第三方库,造成文件依赖混乱的情况。那么,在前端开发中,我们面临的一个问题就是:怎样进行代码设计,以及怎样去复用代码,抽离公共代码等。

什么是模块化?一个模块就是一个文件。其特性是:

  • 拆分:根据功能将代码拆分成多个可复用模块
  • 加载:通过指定方式加载模块并执行与输出模块
  • 注入:将一个模块的输出注入到另一个模块中
  • 管理:因为工程模块数量众多,需要管理好各个模块之间的依赖关系

模块化的作用:

  • 隔离作用域
  • 提供复用性
  • 提高可维护性
  • 解决命名冲突
  • 抽离公共代码

方案

目前主要有六种常见的模块化方案:IIFECJSAMDCMDUMDESM

IIFE

立即执行函数的写法,来编写模块化代码。

const arithmetic = (function() { 
    const _initData = 0; 
    const add = () => { 
        console.log('add') 
    } 
    const subtract = () => { 
        console.log('subtract') 
    } 
    const multiply = () => { 
        console.log('multiply') 
    } 
    const divide = () => { 
        console.log('divide') 
    } 
    return { 
        add, 
        subtract, 
        multiply, 
        divide, 
    } 
})(); 

console.log(_initData) // undefined

IIFE 会创建一个只使用一次的函数,然后立即执行;IIFE 可以创建闭包进行作用域隔离,从而保护私有变量。其特点是:

  • 模仿块级作用域
  • 有利于代码压缩
  • 颠倒代码执行顺序
  • 通过块级作用域解决命名冲突,全局作用域污染问题

CommonJS规范

NodeJS引入的模块化,同步加载依赖模块。重要属性是require,export,module.export。其特点是:

  • 每个文件就是一个模块
  • 拥有独立的作用域,变量,方法
  • 无法并行加载多个模块
  • 在web上使用意味着阻塞加载

使用require(path)引入模块,path可以是绝对路径,也可以是相对路径;使用export或者module.export输出模块

AMD规范

使用RequireJS编写模块化,异步加载依赖模块,其重要属性是define(),require(),require.config()。

define()

一个用来定义模块的全局方法,传递三个参数:

  • alpha:模块名
  • ["require", "exports", "beta"]:模块的依赖
  • callback:回调函数
// ts声明 
/** 
    * @param {string} id 模块名称 
    * @param {string[]} dependencies 模块所依赖模块的数组 
    * @param {function} factory 模块初始化要执行的函数或对象 
    * @return {any} 模块导出的接口 
*/ 
function define(id?, dependencies?, factory): any // 设置模块名称为 alpha,使用 require,exports,beta 为依赖的模块 
define("alpha", ["require", "exports", "beta"], function (require, exports, beta) {
    exports.verb = function() { 
        return beta.verb(); 
        //Or: 
        return require("beta").verb(); 
    } 
});

特点:

  • 依赖前置:代码执行前加载模块---AMD会通过创建script标签的方式来异步加载模块,加载完后立即执行
  • 可并行加载多个模块
  • 适合在web中异步加载模块
  • 不符合通用模块化的思维方式
  • 提高开发成本且代码逻辑不通畅

只有当所有的依赖都加载并执行完之后才开始加载本模块,不管依赖的模块是否使用,都会在运行时全量加载并执行。

CMD

使用SeaJS编写模块化,异步加载依赖模块,其重要属性是define()。特点是:

  • 依赖就近---代码执行时加载模块
  • 依赖SPM打包
  • 模块加载逻辑偏重
define(factory)
// factory可以是一个函数,也可以是一个对象或者一个字符串。
// factory是对象或者字符串时,表示模块的的接口就是该对象、字符串,如果是函数,则表示的是模块的构造方式


define(function(require, exports, module) { 
    // 模块代码 
    var beta = require('./beta'); 
    beta.verb(); 
});

CMD 推崇的是一个文件一个模块,所以可以直接省略模块名称,使用文件名作为模块名称。CMD 又推崇依赖就近,所以不在 define 中写依赖,而是直接在 factory 中写

UMD

兼容CJS和AMD规范,特点是:

  • 兼容CJS和AMD规范的同时还兼容IIFE
  • 先判断AMD支持,再判断CJS支持,最后在判断IIFE支持

ESM

ES6引入的模块化,异步加载依赖,其重要属性是import,export。服务端和浏览器端都支持。

  • 容易静态分析
  • 面向未来的标准
  • 部分web还未完全实现ESM
  • 高版本Node才支持较新的ESM

在ES2020中,引入了动态import()

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

ESM 导入模块是在编译阶段进行静态分析确定模块的依赖关系,并将 import 导入语句提升到模块首部,生成只读引用,链接到引入模块的 export 接口,所以,ESM import 导入的是值的引用
ESM import 导入的是值的引用,所以在遇到循环依赖的时候,ESM 只有在真正需要用到的时候才会去模块中取值。因为需要在编译阶段进行静态分析,所以 import 的只能是字符串,不能是表达式和变量。并且导入的是单例模式,所以在依赖循环的时候,一个模块被多次导入,但是只会执行一次。

CJS和ESM比较

目前,我们常用的规范是CJS和ESM,那这两种规范有什么不同?

  • 语法类型:CJS是动态的,ESM是静态的;
  • 关键声明:CJS是require引入,export导出;ESM是import引入,export导出
  • 加载方式:CJS是运行时加载;ESM是编译时加载
  • 加载行为:CJS是同步加载;ESM是异步加载
  • 书写位置:CJS是任何位置;ESM是顶部
  • 指针指向:CJS的this指向模块;ESM的this指向undefined
  • 执行顺序:CJS首次引用时加载模块,再次引用时读取缓存;ESM引用时生成只读引用,执行时才是正式取值
  • 属性引用:CJS基本类型属于复制不共享,引用类型属于浅拷贝且共享;ESM所有类型属于动态只读引用
  • 属性改动:CJS工作空间可修改引用的值;ESM工作空间不可修改引用的值但可通过引用的方法修改

运行是加载和编译时加载:

  • 运行时加载指整体加载模块生成一个对象,再从对象中获取所需的属性方法去加载。最大特性是全部加载,只有运行时才能得到该对象,无法在编译时做静态优化。
  • 编译时加载指直接从模块中获取所需的属性方法去加载。最大特性是按需加载,在编译时就完成模块加载,效率比其他方案高,无法引用模块本身(本身不是对象),但可拓展JS高级语法(宏与类型校验)。

参考资料

掘金小册:从0到1落地前端工程化

掘金小册:初探前端工程化