模块化
模块化就是将功能进行拆分成不同的模块(子功能),最后组合起来。 能够方便代码服务,增强代码可维护性,实现代码解耦。
JS模块化
早期模块化方案
最早开始前端工程师通过JS的语言特性来模拟实现模块化。
普通函数
js
function fn1(){ //... }
function fn2(){ //... }
function fn3() {
fn1()
fn2()
}
因为普通函数是具有作用域的,直接进行调用即可,但是无法保证模块间的函数名发生冲突,并且模块成员之间看不出关系。
命名空间
上述方式,函数和变量都在全局申明,很容易冲突,因为对象可以拥有属性,通过对象的名字访问,相当于设定了一个命名空间。
js
var myModule = {
name: "isboyjc",
getName: function (){ console.log(this.name) }
} // 使用 myModule.getName()
但是出现了所有属性和方法都对外可见,并且可被修改内部状态,命名空间被污染。例如以下情况是我们不愿看到的。
- A修改了共享模块的状态
- B使用了共享模块但不知道状态已被修改
- B的代码逻辑出现异常,难以调试
IIFE
通过闭包的特性来保证属性的私有性。
js
var myModule = (function() {
var name = 'isboyjc'
function getName() {
console.log(name)
}
return { getName }
})()
myModule.getName()
模块化规范演进
commonJS
规定每一个文件都是一个独立的模块。有自己的作用域,模块的变量,函数都是私有的。外部想要调用,必须通过module.exports 主动暴露。 而在另一个文件中则直接使用require(path)即可。
js
//export.js
var a = 1;
var fn = function(){
console.log('hello');
}
module.exports.a = 1;
module.exports.fn = fn;
js
// require.js
var exp = require('./export.js');
console.log(exp.a);
exp.fn();
NodeJs内置了一个Module构造函数,每个模块执行的相关信息会变成一个module实例。
每个实例有以下几个属性
- 模块的唯一标识符
- 通常是模块的完全解析后的文件名
- module.filename:
- 模块文件的完全解析后的文件名路径
- 表示模块文件在文件系统中的位置
- module.loaded:
- 布尔值,表示模块是否已经加载完成
- 模块执行过程中为false,加载完成后为true
- module.parent:
- 引用首次导入该模块的模块
- 如果此模块是入口点或被vm上下文加载,则为null
- module.children:
- 数组,包含该模块直接导入的所有模块对象
- 反映了模块间的依赖关系
- module.exports:
- 模块对外暴露的接口
- 模块加载时返回的就是这个对象
require命令负责读取并执行JS文件。 读取到的JS代码通过拼接成一个函数(方便依赖注入,例如module实例,require函数,能够进行连续递归调用)放入VM(能够进行环境的隔离,VM可以访问全局变量,不能访问函数局部变量,可以在VM上下文声明变量且不会污染全局。)中去执行。这时候module对象就会存储我们的相关信息,通过将函数执行内的上下文export内容放入module对象中形成闭包,形成数据私有化。同时支持模块的缓存机制,被加载的模块会进行一个缓存到Module构造的属性当中,通过module的fileName进行一个判断,存在可以直接取出module,不必重新执行,并且保持了模块状态。
以下给出一个精简的源码
js
let path = require('path');
let fs = require('fs');
let vm = require('vm');
let n = 0
// 构造函数Module
function Module(filename){
this.id = n++; // 唯一ID
this.filename = filename; // 文件的绝对路径
this.exports = {}; // 模块对应的导出结果
}
// 存放可解析的文件模块扩展名
Module._extensions = ['.js'];
// 缓存
Module._cache = {};
// 拼凑成闭包的数组
Module.wrapper = ['(function(exports,require,module){','\r\n})'];
// 没写扩展名,默认添加扩展名
Module._resolveFilename = function (p) {
p = path.join(__dirname, p);
if(!/\.\w+$/.test(p)){
//如果没写扩展名,尝试添加扩展名
for(let i = 0; i < Module._extensions.length; i++){
//拼接出一个路径
let filePath = p + Module._extensions[i];
// 判断文件是否存在
try{
fs.accessSync(filePath);
return filePath;
}catch (e) {
throw new Error('module not found')
}
}
}else {
return p
}
}
// 加载模块本身
Module.prototype.load = function () {
// 解析文件后缀名 isboyjc.js -> .js
let extname = path.extname(this.filename);
// 调用对应后缀文件加载方法
Module._extensions[extname](this);
};
// 后缀名为js的加载方法
Module._extensions['.js'] = function (module) {
// 读文件
let content = fs.readFileSync(module.filename, 'utf8');
// 形成闭包函数字符串
let script = Module.wrapper[0] + content + Module.wrapper[1];
// 创建沙箱环境,运行并返回结果
let fn = vm.runInThisContext(script);
// 执行闭包函数,将被闭包函数包裹的加载内容
fn.call(module, module.exports, req, module)
};
// 仿require方法, 实现加载模块
function req(path) {
// 根据输入的路径 转换绝对路径
let filename = Module._resolveFilename(path);
// 查看缓存是否存在,存在直接返回缓存
if(Module._cache[filename]){
return Module._cache[filename].exports;
}
// 通过文件名创建一个Module实例
let module = new Module(filename);
// 加载文件,执行对应加载方法
module.load();
// 入缓存
Module._cache[filename] = module;
return module.exports
}
let str = req('./test');
console.log(str);
小编在理解上述代码时花费了大量的时间,确确实实的感受到了前辈们设计的精妙,短短的几十行代码竟有如此效果。下面给出一个更加精简版帮助朋友们理解核心。
js
// 模块文件math.js
const PI = 3.14159; // 私有变量
function square(x) { return x * x; }
module.exports = { square };
//执行require发生以下情况
// 1.创建一个空的模块对象:
let module = { exports: {} };
// 2. 读取文件并包装成函数:
let fn = function(exports, require, module) {
const PI = 3.14159; // 私有变量
function square(x) { return x * x; }
module.exports = { square };
};
// 3.执行这个函数:
fn.call(module, module.exports, req, module);
// 4. 返回修改后的 module.exports:
return module.exports; // 返回 { square: function(x){...} }
总结一下,简单点说,CommonJs 就是模块化的社区标准,而 Nodejs 就是 CommonJs 模块化规范的实现 ,它对模块的加载是同步的,也就是说,只有引入的模块加载完成,才会执行后面的操作,在 Node
服务端应用当中,模块一般存在本地,加载较快,同步问题不大,在浏览器中就不太合适了,你试想一下,如果一个很大的项目,所有的模块都同步加载,那体验是极差的,所以还需要异步模块化方案,所以 AMD规范
就此诞生。
AMD
- 脚本加载是异步的(类似于async=true):
- 当执行createNode(item)时,浏览器开始在后台下载脚本
- 主线程继续执行,不会等待下载完成
- onload回调是宏任务:
- 当脚本下载完成时,onload事件回调被添加到宏任务队列
- 只有在当前执行栈清空后,事件循环才会处理这个回调
//有依赖项 - 模块信息存到数组里,遍历每一个依赖项,给每个依赖项创建script标签,等待依赖项的onload,依赖项可能递归的会有依赖, 最后去执行数组中已经完成依赖的模块的回调。
//没有依赖项 - 执行内容存入module的value中。 通过不断创建script脚本来执行模块。 每个模块的依赖项
onload的回调是一个事件,是宏任务,所以实现了这种异步的不阻塞页面渲染的加载。
js
(function () {
// 缓存
const cache = {}
let moudle = null
const tasks = []
// 创建script标签,用来加载文件模块
const createNode = function (depend) {
let script = document.createElement("script");
script.src = `./${depend}.js`;
// 嵌入自定义 data-moduleName 属性,后可由dataset获取
script.setAttribute("data-moduleName", depend);
let fs = document.getElementsByTagName('script')[0];
fs.parentNode.insertBefore(script, fs);
return script;
}
// 校验所有依赖是否都已经解析完成
const hasAlldependencies = function (dependencies) {
let hasValue = true
dependencies.forEach(depd => {
if (!cache.hasOwnProperty(depd)) {
hasValue = false
}
})
return hasValue
}
// 递归执行callback
const implementCallback = function (callbacks) {
if (callbacks.length) {
callbacks.forEach((callback, index) => {
// 所有依赖解析都已完成
if (hasAlldependencies(callback.dependencies)) {
const returnValue = callback.callback(...callback.dependencies.map(it => cache[it]))
if (callback.name) {
cache[callback.name] = returnValue
}
tasks.splice(index, 1)
implementCallback(tasks)
}
})
}
}
// 根据依赖项加载js文件
const require = function (dependencies, callback) {
if (!dependencies.length) { // 此文件没有依赖项
moudle = {
value: callback()//执行回调函数,获取返回值
}
} else { //此文件有依赖项,先去加载依赖项
moudle = {
dependencies,
callback
}
tasks.push(moudle)//将模块信息存入暂存 tasks数组
dependencies.forEach(function (item) {
if (!cache[item]) {
// script表亲加载文件结束
createNode(item).onload = function () {
// 获取嵌入属性值,即module名
let modulename = this.dataset.modulename
console.log(moudle)
// 校验module中是否存在value属性
if (moudle.hasOwnProperty('value')) {
// 存在,将其module value(模块返回值|导出值)存入缓存
cache[modulename] = moudle.value
} else {
// 不存在
moudle.name = modulename
if (hasAlldependencies(moudle.dependencies)) {
// 所有依赖解析都已完成,执行回调,抛出依赖返回(导出)值
cache[modulename] = callback(...moudle.dependencies.map(v => cache[v]))
}
}
// 递归执行callback
implementCallback(tasks)
}
}
})
}
}
window.require = require
window.define = require
})(window)
CMD
他和AMD很类似,不过AMD提倡依赖前置,提前执行,而CMD则推崇依赖就近,等待用到的时候再执行加载。不过requireJS从2.0开始也改为延迟执行了。
UMD
能够让同一个js包在浏览器,客户端都遵守一个写法。
其实本质上就是判断一下环境,假如nodeJs使用commonJs,浏览器使用AMD。
js
(function(root, factory) {
// AMD 环境
if (typeof define === 'function' && define.amd) {
define(['lodash'], factory);
}
// CommonJS 环境
else if (typeof module === 'object' && module.exports) {
module.exports = factory(require('lodash'));
}
// 浏览器全局环境
else {
root.myModule = factory(root._);
}
}(typeof self !== 'undefined' ? self : this, function(_) {
// 使用依赖项_
var myModule = {
name: 'UMD带依赖示例',
uniqueArray: function(arr) {
return _.uniq(arr);
},
capitalize: function(str) {
return _.capitalize(str);
}
};
return myModule;
}));
ESM
esModule是在es6在语言标准的层面上实现了模块化的功能。import实现了导入,export(需要{}解构)和export default(默认导出)实现了导出。
- es6模块化使用mjs文件,他们的语法也不同。
- es6在编译时期就会确定依赖关系,构建依赖地图,不像commonJS和AMD在运行时确定。
- commonJS的导出时获得引用的拷贝(类似浅拷贝)。esmodule是一种绑定,这个链接是实时的。对于基本数据类型的绑定能够支持一个类似于响应式的变化更新。
问题
- 为什么CommonJS在浏览器环境不适用,而需要AMD?具体的技术限制是什么?
浏览器端需要对页面进行渲染,同步加载耗时太大,并且浏览器端没有module对象。
- AMD中define和require的区别是什么?它们在功能上有何重叠?
define: 用于定义模块,将代码封装为可重用的AMD模块单元 require: 用于加载和使用已定义的模块。define中会定义返回值为导出的模块。
- CommonJS中的循环依赖是如何处理的?AMD呢?
js
// commonJS
1. main.js 执行 require('./a')。
1. a.js 开始执行,exports.done = false。遇到 require('./b'),a.js 的执行暂停。
1. b.js 开始执行,exports.done = false。遇到 require('./a')。Node.js 检测到循环依赖,发现 a.js 正在加载中。
1. 为了打破循环,Node.js 立即返回 a.js 当前的 module.exports 对象(即 { done: false })给 b.js。这就是所谓的"未完成副本"。b.js 继续执行,打印 in b, a.done = false。
1. b.js 执行完毕,设置 exports.done = true,并将其完整的 exports 对象 ({ done: true }) 返回给 a.js 中等待的 require('./b')。
1. a.js 恢复执行,拿到完整的 b 模块,打印 in a, b.done = true。然后设置 exports.done = true,执行完毕,返回完整的 exports ({ done: true }) 给 main.js。
1. main.js 继续执行 require('./b'),由于 b.js 已经被加载和缓存,直接返回缓存的完整 exports。
1. main.js 打印最终结果。
js
// AMD
1. 它会先加载所有涉及循环的模块代码。
1. 在执行工厂函数时,如果一个模块(比如 A)的工厂函数需要依赖另一个正处于循环依赖中且尚未执行完工厂函数的模块(比如 B),加载器通常会将模块 B 的(通常是空的)exports 对象传递给模块 A 的工厂函数。
1. 模块 A 的工厂函数执行,可能会尝试使用 B(此时可能是空的 exports 对象),然后返回自己的导出对象。
1. 一旦模块 A 的工厂函数返回了导出对象,这个对象就可以被传递给模块 B 的工厂函数(如果 B 依赖 A)。
1. 模块 B 的工厂函数执行,现在可以访问到模块 A 完整的导出对象了。
- CommonJS是如何实现模块私有作用域的?浏览器端如何实现类似效果?
通过闭包和VM来实现的。浏览器也可以通过IIFE和命名空间。
- 在CommonJS中,如果一个模块被多处require,它会被执行几次?为什么?
一次,因为有模块缓存,直接返回缓存中的export对象。
- UMD是什么?它如何同时兼容AMD和CommonJS?
检测到AMD环境(define 和 define.amd存在)→ 使用AMD方式定义 检测到CommonJS环境(module.exports存在)→ 使用CommonJS方式导出 都不是 → 回退到全局变量模式
CSS模块化
主要为了解决以下几个问题
- 全局作用域 (Global Scope): 默认情况下,所有 CSS 规则都应用于整个文档。这很容易导致样式冲突,尤其是在多人协作或大型项目中。一个地方定义的 .button 样式可能会无意中影响到另一个完全不相关的按钮。
- 命名冲突 (Naming Collisions): 开发者需要绞尽脑汁想出独特且有意义的类名,以避免覆盖其他样式。
- 依赖管理困难: 很难确定某个 CSS 规则是否还在被使用,或者某个组件具体依赖哪些样式,导致不敢轻易删除旧的 CSS 代码,文件越来越臃肿。
- 优先级和特异性问题 (Specificity Issues): 为了覆盖样式,可能会滥用 !important 或创建非常复杂的选择器,增加维护难度。
BEM命名规范
通过严格的命名来模拟作用域,可以查看小编之前的文章具体学习。 重学CSS设计-BEM规范
ACSS 原子化CSS
提供大量的原子类,每个类名具有不同的样式。 例如市面上的最流行的tailwindcss就是,还可以减少css的包体积大小。不用自己思考css类名减少冲突,极高的开发效率,不需要频繁切换css文件。 缺点是造成HTML文档的类名臃肿,学习记忆需要一定成本。
vue和react中的css模块化的实现方式
- scopedCSS 通过给组件添加不同的唯一属性来实现样式隔离
- moduleCss 通过给类名添加唯一哈希串实现样式隔离
- css in js 能够在js中写css,很好的统合了js的能力,例如循环,变量,状态的样式,同样根据类名哈希来进行一个样式隔离