📒📒📒当你被问到前端模块化

40,168 阅读9分钟

前言

最近一个前同事和我聊天,聊到他去面试的时候,被面试官问到前端模块化的问题,他的回答换来的是面试官的一句“你说了,但是好像又没说”,他备受打击,怀疑人生的问我前端模块化到底是啥?说他好像知道,又好像说不出所以然来,所以就有了这一份关于模块化的内容,来一起捋一捋

1. 模块化简述

把复杂代码按功能的不同划分成不同的模块单独维护,提高开发效率,降低维护成本 模块化只是思想、理论,不包含具体实现

2. 模块化的诞生

简单来说,就是一个人做不完的事,分成了多个人做,每人负责一个块内容(模块),最后把每个人做的东西组装一起成为整体

前端模块化的演进道路上虽然有很多阶段,但是其实最终的目标,就是拆分模块,分工开发,每个人做好一个模块之后,暴露一些参数、方法给到调用者,演进的道路无非是秉行如何更加优雅,高解耦,高兼容等方向优化

3. 模块化的演进

第一阶段:仅仅基于文件的划分模块的方式

具体做法就是将每个功能及其相关状态数据各自单独放到不同的文件中,约定每个文件就是一个独立的模块,使用某个模块就是将这个模块引入到页面中,然后直接调用模块中的成员(变量 / 函数) 缺点:所有模块都直接在全局工作,没有私有空间,所有成员都可以在模块外部被访问或者修改,而且模块一段多了过后,容易产生命名冲突,另外无法管理模块与模块之间的依赖关系

第二阶段:每个模块暴露一个全局对象,所有模块成员都挂载到这个对象中
具体做法就是在第一阶段的基础上,通过将每个模块「包裹」为一个全局对象的形式实现,有点类似于为模块内的成员添加了「命名空间」的感觉。 通过「命名空间」减小了命名冲突的可能,但是同样没有私有空间,所有模块成员也可以在模块外部被访问或者修改,而且也无法管理模块之间的依赖关系。

第三阶段:使用立即执行函数表达式(IIFE:Immediately-Invoked Function Expression)为模块提供私有空间
具体做法就是将每个模块成员都放在一个函数提供的私有作用域中,对于需要暴露给外部的成员,通过挂在到全局对象上的方式实现 有了私有成员的概念,私有成员只能在模块成员内通过闭包的形式访问。

第四阶段: 利用 IIFE 参数作为依赖声明使用
具体做法就是在第三阶段的基础上,利用立即执行函数的参数传递模块依赖项。 这使得每一个模块之间的关系变得更加明显。

第五阶段: 模块化规范
Require.js 提供了 AMD 模块化规范,以及一个自动化模块加载器---模块化规范的出现,再之后便有了其他更多标准紧接而来,CommonJS、CMD。。。

4. 模块化规范的出现

需:模块化标准+模块加载器

CommonJS规范(nodejs提出的一套标准)

标准: 一个文件就是一个模块 每个模块都有单独的作用域 通过 module.exports 导出成员 通过 require 函数载入模块

缺点:CommonJS是以同步模式加载模块,node执行机制是启动时加载模块,执行过程中不需要加载只需使用,在node中不会有问题;但是在浏览器端页面加载会导致大量同步请求出现,而效率低

AMD(Asynchronous Module Definition) --- 异步模块定义规范

模块通过define函数定义

优势:目前绝大多数第三方库都支持AMD规范

缺点: 使用复杂 模块划分细致,模块JS文件会出现请求频繁的情况

Sea.js(淘宝推出) + CMD(通用模块定义规范)

CMD规范类似CommonJS规范 后期也被Require.js兼容了

Require.js

提供了 AMD 模块化规范,以及一个自动化模块加载器 提供require函数加载模块

5. 模块化默认规范

浏览器环境使用ES Modules
nodejs使用CommonJS

6. 关于ES Modules

通过给 script 添加 type = module 的属性使用 ES Modules

  1. ESM 自动采用严格模式,忽略 'use strict'
  2. 每个 ES Module 都是运行在单独的私有作用域中
  3. ESM 是通过 CORS 的方式请求外部 JS 模块的
  4. ESM 的 script 标签会延迟执行脚本
    对于我们前端平时开发,其实用得最多的是es modules,这里也简单介绍一下这种规范的一些常用写法

ES Modules 导出

单个导出

export const name = 'foo module'
export function hello () {
    console.log('hello')
}

合并导出

const name = 'foo module'
function hello () {
    console.log('hello')
}
class Person {}
export { name, hello, Person }

合并导出,且重命名

const name = 'foo module'
function hello () {
    console.log('hello')
}
class Person {}
export {
    name1: name,
    hello2: hello,
    Person3: Person
}

默认导出

const name = 'foo module'
function hello () {
    console.log('hello')
}
class Person {}

export default name;

ES Modules 导入导出的注意事项 导出字面量和导出模块的区别

导出字面量(如:对象):export default { name, age }
注意: import {name, age} from 'modulename'导入模块无法使用到name和age的值

导出模块:
export { name, age }
import {name, age} from 'modulename' 导入模块可以使用到name和age的值

原因:import导入的是对模块内部的使用

导出模块的引用

注意:export暴露的是模块的引用关系(地址),并且只读不可修改(尝试修改会报错误---Uncaught TypeError:Assignment to constant variable)

注意点

  1. CommonJS 中是先将模块整体导入为一个对象,然后从对象中结构出需要的成员 const { name, age } = require('./module.js')
  2. ES Module 中 { } 是固定语法,就是直接提取模块导出成员 import { name, age } from './module.js'
  3. 导入成员并不是复制一个副本,而是直接导入模块成员的引用地址,也就是说 import 得到的变量与 export 导入的变量在内存中是同一块空间。一旦模块中成员修改了,这里也会同时修改
  4. 导入模块成员变量是只读的 name = 'tom' // 报错
  5. 导入的是一个对象,对象的属性读写不受影响 name.xxx = 'xxx' // 正常

ES Modules 导入

导入文件路径

  1. 需要引用的名称
  2. 相对路径上的./不能省略
  3. 可以使用绝对路径和完整的url

导入模块时是否提取模块成员

  1. 导入模块并提取模块成员 import {} from './module.js'
  2. 导入模块暂不提取模块成员 import './module.js' (导入不需要外界控制的子功能模块时很有用)

同时导入模块多个成员或所有成员

import * as mod from './module.js' 需要把所有提取成员放到一个对象当中,通过as,导入的成员都会作为对象属性出现

动态导入模块(在需要满足某些条件才能导入时可用)

import ('./module.js') 返回的是promise 取模块成员的方式:

import('./module.js').then(function (module) {
    //所有模块成员都在module参数里
})

同时导入命名成员和默认成员

import { name, age, default as other} from './module.js'
或者
import other,{ name, age} from './module.js'other 代表module模块所有默认导出的成员

ES Modules in Node.js

ES Modules in Node.js - 与 CommonJS 交互

ES Module 中可以导入 CommonJS 模块

es-module.mjs

import mod from './commonjs.js'
console.log(mod)

不能直接提取成员,注意 import 不是解构导出对象

import { foo } from './commonjs.js'
console.log(foo)
export const foo = 'es module export value'

CommonJS 模块始终只会导出一个默认成员

commonjs.js

module.exports = {
    foo: 'commonjs exports value'
}
exports.foo = 'commonjs exports value'

不能在 CommonJS 模块中通过 require 载入 ES Module

const mod = require('./es-module.mjs')
console.log(mod)

ES Modules in Node.js - 与 CommonJS 的差异

示例:

// 加载模块函数
console.log(require)

// 模块对象
console.log(module)

// 导出对象别名
console.log(exports)

// 当前文件的绝对路径
console.log(__filename)

// 当前文件所在目录
console.log(__dirname)
1ESM 中无法引用CommonJS中的那些模块全局成员
2require, module, exports 可通过importexport 代替
3、__filename 和 __dirname 通过import 对象的 meta 属性获取
const currentUrl = import.meta.url
console.log(currentUrl)
// 通过 url 模块的 fileURLToPath 方法转换为路径
import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
console.log(__filename)
console.log(__dirname)

ES Modules in Node.js - 新版本进一步支持

  1. Node v12 之后的版本,可以通过package.json 中添加type字段为module, 将默认模块系统修改为 ES Module 此时就不需要修改文件扩展名为 .mjs 了

  2. 如果需要在type=module的情况下继续使用CommonJS, 需要将文件扩展名修改为 .cjs

package.json:

{ "type": "module" }

ES Modules in Node.js - Babel 兼容方案

  1. 安装babel:yarn add @babel/node @babel/core @babel/preset-env --dev
  2. 运行babel-node测试:yarn babel-node index.js --presets=@babel/preset-env

**.babelrc 配置 **

{ "plugins": [ "@babel/plugin-transform-modules-commonjs" ] }

7. 总结

问问题一般是从总体追问到细节
如果面试问到关于模块化的问题,要有清晰的条理去回答,先回答总体的概念,如果有追问,再根据细节回答即可

提示:面试你的人其实不一定需要你一口气说完多深的理解,你可以用简单易懂的大白话,条理清晰即可,先抛一下
如: XXXX和YYYY有什么区别? 答:有3点区别......

问: 你讲讲前端模块化吧
答: 模块化的开发方式可以提高代码复用率,方便进行代码的管理。通常一个文件就是一个模块,有自己的作用域,只向外暴露特定的变量和函数。

问:模块化有哪几种标准?
答: 目前流行的js模块化规范有CommonJS、AMD、CMD以及ES6的模块系统

问:ES Modules 和 CommonJS的一些区别
答:

  1. 使用语法层面,CommonJs是通过module.exports,exports导出,require导入;ESModule则是export导出,import导入
  2. CommonJs是运行时加载模块,ESModule是在静态编译期间就确定模块的依赖
  3. ESModule在编译期间会将所有import提升到顶部,CommonJs不会提升require
  4. CommonJs导出的是一个值拷贝,会对加载结果进行缓存,一旦内部再修改这个值,则不会同步到外部。ESModule是导出的一个引用,内部修改可以同步到外部
  5. CommonJs中顶层的this指向这个模块本身,而ESModule中顶层this指向undefined
  6. CommonJS加载的是整个模块,将所有的接口全部加载进来,ESModule可以单独加载其中的某个接口