阅读视图

发现新文章,点击刷新页面。

你不知道的 JS(上):原型与行为委托

你不知道的 JS(上):原型与行为委托

本文是《你不知道的JavaScript(上卷)》的阅读笔记,第三部分:原型与行为委托。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

原型

[[Prototype]]

JS 中的对象有一个特殊的 [[Prototype]] 内置属性,它是对其他对象的引用。几乎所有的对象在创建时都会被赋予一个非空的原型值。

当你试图引用对象的属性时会触发 [[Get]] 操作:

  1. 首先检查对象自身是否有该属性。
  2. 如果没有,则顺着 [[Prototype]] 链向上查找。
  3. 这个过程会持续到找到匹配的属性名或到达原型链顶端(Object.prototype)。如果还没找到,则返回 undefined
Object.prototype

所有普通的 [[Prototype]] 链最终都会指向内置的 Object.prototype

属性设置和屏蔽

当执行 myObject.foo = "bar" 时:

  1. 如果 foo 已存在于 myObject 中,直接修改它的值。
  2. 如果 foo 不在 myObject 中而在原型链上层:
    • 若原型链上的 foo 不是只读(writable:true),则在 myObject 上创建屏蔽属性 foo
    • 若为只读(writable:false),则无法设置。
    • 若是一个 setter,则调用该 setter。
  3. 如果 foo 既不在 myObject 也不在原型链上,直接添加到 myObject

“类”

JS 和面向类的语言不同,它并没有类作为蓝图,JS 中只有对象。

“类函数”与原型继承

JS 通过函数的 prototype 属性来模仿类。当你调用 new Foo() 时,创建的新对象会关联到 Foo.prototype

注意:在 JS 中,我们并不是将“类”复制到“实例”,而是将它们关联起来。

“构造函数”

Foo.prototype 默认有一个 .constructor 属性指向 Foo。 通过 new 调用的函数并不是真正的“构造函数”,new 只是劫持了普通函数,并以构造对象的形式来调用它。

(原型)继承

常见的“继承”写法:

function Foo(name) {
    this.name = name;
}
function Bar(name, label) {
    Foo.call( this, name );
    this.label = label;
}

// 创建一个新的 Bar.prototype 对象并关联到 Foo.prototype
Bar.prototype = Object.create( Foo.prototype );

Bar.prototype.myLabel = function() {
    return this.label;
};

Object.create(..) 会凭空创建一个新对象并将其 [[Prototype]] 关联到指定的对象。

检查“类”的关系:

  • a instanceof Foo:检查 Foo.prototype 是否出现在 a 的原型链上。
  • Foo.prototype.isPrototypeOf(a):更直观的检查方式。
  • Object.getPrototypeOf(a):获取对象的原型。

对象关联

原型链的本质就是对象之间的关联。Object.create(..) 是创建这种关联的直接方式,它避免了 new 构造函数调用带来的复杂性(如 .prototype.constructor 引用)。

关联关系是备用

比起直接在原型链上查找(直接委托),内部委托往往能让 API 设计更清晰:

var anotherObject = {
    cool: function() { console.log( "cool!" ); }
};
var myObject = Object.create( anotherObject );
myObject.doCool = function() {
    this.cool(); // 内部委托!
};

原型机制小结

JS 的 [[Prototype]] 机制本质上是行为委托。对象之间不是复制关系,而是关联关系。

行为委托

面向委托的设计

类理论 vs. 委托理论
  • 类理论:鼓励继承、重写和多态。将行为抽象到父类,子类实例化时进行复制。
  • 委托理论:认为对象之间是兄弟关系。定义基础对象,其他对象通过 Object.create(..) 关联并委托行为。
委托模式的特点
  1. 更具描述性的方法名:避免使用通用的方法名,提倡使用能体现具体行为的名字。
  2. 状态存储在委托者上:数据通常存储在具体对象上,行为委托给基础对象。

类与对象关联的比较

对象关联风格的代码通常更简洁,因为它省去了模拟类所需要的复杂包装(构造函数、prototype 等)。

更好的语法 (ES6)

ES6 的简洁方法语法让对象关联看起来更舒服:

var AuthController = {
    errors: [],
    checkAuth() { /* .. */ }
};
Object.setPrototypeOf(AuthController, LoginController);

内省 (Introspection)

在对象关联模式下,检查对象关系变得非常简单:

Foo.isPrototypeOf( Bar ); // true
Object.getPrototypeOf( Bar ) === Foo; // true

行为委托小结

行为委托是一种比类更强大的设计模式。它更符合 JS 的原型本质,能让代码结构更清晰、语法更简洁。

ES6 中的 Class

class 语法

ES6 引入了 class 关键字,它解决了:

  1. 不再需要显式引用杂乱的 .prototype
  2. extends 简化了继承。
  3. super 支持相对多态。

class 陷阱

尽管 class 语法更好看,但它仍然是基于原型机制的,存在一些隐患:

  1. 非静态复制:修改父类方法会实时影响所有子类 and 实例。
  2. 成员属性限制:无法在类体中直接定义数据属性(只能定义方法),通常仍需操作原型。
  3. 意外屏蔽:属性名可能屏蔽同名方法。
  4. super 绑定super 是在声明时静态绑定的,而非动态绑定。

结论:静态大于动态吗?

ES6 的 class 试图伪装成一种静态的类声明,但这与 JS 动态的原型本质相冲突。它隐藏了许多底层机制,有时反而会让问题变得更难理解。

ES6 Class 小结

class 很好地伪装了类和继承模式,但它实际上只是原型委托的一层语法糖。使用时应警惕它带来的新问题。

你不知道的JS(上):this指向与对象基础

你不知道的JS(上):this指向与对象基础

本文是《你不知道的JavaScript(上卷)》的阅读笔记,第二部分:this 指向与对象基础。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

动态作用域

JS 并不具有动态作用域,它只有词法作用域,但 this 机制在某种程度上很像动态作用域。

主要区别:

  • 词法作用域:在写代码或者说定义时确定的(静态),关注函数在何处声明
  • 动态作用域:在运行时确定的,关注函数从何处调用this 也是在运行时绑定的,这一点与动态作用域类似。

this 词法

var obj = {
    id: "awesome",
    cool: function coolFn() {
        console.log( this.id );
    }
};
var id = "not awesome";
obj.cool(); // awesome
setTimeout( obj.cool, 100 ); // not awesome

关于 this

this 关键字是 JS 中最复杂的机制之一,它被自动定义在所有函数的作用域中。

对 this 的误解

1. 为什么要用 this?

this 提供了一种更优雅的方式来隐式“传递”一个对象引用,因此可以将 API 设计得更加简洁并且易于复用。随着使用模式越来越复杂,显式传递上下文对象会让代码变得混乱,而使用 this 则能保持代码整洁。

2. 它的作用域

this 在任何情况下都不指向函数的词法作用域。

function foo() {
    var a = 2;
    this.bar();
}

function bar() {
    console.log(this.a);
}
foo(); // ReferenceError: a is not defined

this 到底是什么

this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式

当一个函数被调用时,会创建一个活动记录(也被称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的其中一个属性,会在函数执行的过程中被用到。

this 定义小结

this 既不指向函数自身,也不指向函数的词法作用域。this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。

this 全面解析

调用位置

在理解 this 的绑定过程之前,首先要理解调用位置:调用位置就是函数在代码中被调用的位置(而不是声明的位置)。只有仔细分析调用位置才能回答:这个 this 到底引用的是什么?

绑定规则

1. 默认绑定

独立函数调用:函数调用时应用了 this 的默认绑定,因此 this 指向了全局对象。

function foo () {
    console.log(this.a);
}
var a = 2;
foo(); // 2

如果使用严格模式(strict mode),全局对象将无法使用默认绑定,因此 this 会绑定到 undefined

2. 隐式绑定

这条规则需要考虑调用位置是否有上下文对象,或者说是否被某个对象拥有或包含。

function foo () {
    console.log(this.a);
}
var obj = {
    a: 2,
    foo: foo
}
obj.foo(); // 2

当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。

对象属性引用链: 只有最顶层或者说最后一层会影响调用位置。

function foo() {
    console.log( this.a );
}
var obj2 = {
    a: 42,
    foo: foo
};
var obj1 = {
    a: 2,
    obj2: obj2
};
obj1.obj2.foo(); // 42

隐式丢失:

一个最常见的 this 绑定问题就是被隐式绑定的函数会丢失绑定对象,从而应用默认绑定(绑定到全局对象或 undefined)。

function foo() {
    console.log(this.a);
}
var obj = {
    a: 2,
    foo: foo
};
var bar = obj.foo; // 函数别名,实际上引用的是 foo 函数本身
var a = "oops, global"; 
bar(); // "oops, global"

传入回调函数时也容易发生隐式丢失:

function doFoo(fn) {
    fn(); // 调用位置!
}
doFoo(obj.foo); // "oops, global"
3. 显式绑定

JS 提供了 call(..)apply(..) 两个方法来进行显式绑定。它们的第一个参数是一个对象,会把这个对象绑定到 this

function foo () {
    console.log(this.a);
}
var obj = { a: 2 };
foo.call(obj); // 2

装箱: 如果传入的是原始值(字符串、布尔或数字),它会被转换成对象形式(new String(..) 等)。

硬绑定:

function bind(fn, obj) {
    return function() {
        return fn.apply(obj, arguments);
    }
}
4. new 绑定

使用 new 来调用函数时,会自动执行以下操作:

  1. 创建(构造)一个全新的对象。
  2. 这个新对象会被执行 [[Prototype]] 连接。
  3. 这个新对象会绑定到函数调用的 this
  4. 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。
function foo(a) {
    this.a = a;
}
var bar = new foo(2);
console.log(bar.a); // 2

优先级

  1. new 绑定var bar = new foo()
  2. 显式绑定var bar = foo.call(obj2)
  3. 隐式绑定var bar = obj1.foo()
  4. 默认绑定var bar = foo()(严格模式下绑定到 undefined

绑定例外

被忽略的 this

如果你把 null 或者 undefined 作为 this 的绑定对象传入 callapply 或者 bind,这些值会被忽略,实际应用的是默认绑定规则。

更安全的 this: 使用 Object.create(null) 创建一个彻底的空对象(DMZ)。

var ø = Object.create(null);
foo.apply(ø, [2, 3]);
间接引用

赋值表达式 p.foo = o.foo 的返回值是目标函数的引用,调用时会应用默认绑定。

软绑定

硬绑定会降低函数的灵活性,软绑定可以在保留隐式/显式绑定能力的同时,提供一个默认绑定值。

this 词法(箭头函数)

箭头函数不使用 this 的四种标准规则,而是根据外层(函数或全局)作用域来决定 this

function foo() {
    return (a) => {
        // this 继承自 foo()
        console.log( this.a );
    };
}

this 绑定规则小结

判断 this 绑定对象的四条规则:

  1. new? 绑定到新创建的对象。
  2. call/apply/bind? 绑定到指定的对象。
  3. 上下文对象调用? 绑定到该上下文对象。
  4. 默认? 严格模式下 undefined,否则全局对象。

对象

语法

对象可以通过两种形式定义:字面量形式构造形式

// 文字语法(常用)
var myObj = { key: value };

// 构造形式
var myObj = new Object();
myObj.key = value;

类型

JS 的六种主要类型:stringnumberbooleannullundefinedobject

注意typeof null 返回 "object" 是语言本身的一个 bug。

内置对象StringNumberBooleanObjectFunctionArrayDateRegExpError。它们实际上都是内置函数。

内容

可计算属性名

ES6 允许在字面量中使用 [] 包裹表达式作为属性名。

var prefix = "foo";
var myObject = {
    [prefix + "bar"]: "hello"
};
数组

数组也是对象,可以添加属性,但如果属性名看起来像数字,会变成数值下标并修改 length

复制对象
  • 深拷贝:对于 JSON 安全的对象,可以使用 JSON.parse(JSON.stringify(obj))
  • 浅拷贝:ES6 提供了 Object.assign(..)
属性描述符 (Property Descriptors)
  • writable:是否可修改值。
  • configurable:是否可配置(修改描述符或删除属性)。
  • enumerable:是否出现在枚举中(如 for..in)。
不变性
  1. 对象常量writable:false + configurable:false
  2. 禁止扩展Object.preventExtensions(..)
  3. 密封Object.seal(..)(禁止扩展 + configurable:false)。
  4. 冻结Object.freeze(..)(最高级别,密封 + writable:false)。
[[Get]] 与 [[Put]]
  • [[Get]]:查找属性值,找不到返回 undefined
  • [[Put]]:设置属性值,涉及是否有 setter、是否可写等判断。
Getter 和 Setter

通过 getset 改写默认的 [[Get]][[Put]] 操作。定义了 getter/setter 的属性被称为“访问描述符”。

存在性
  • in 操作符:检查属性是否在对象及其原型链中。
  • hasOwnProperty(..):只检查属性是否在对象自身中。

遍历

  • for..in:遍历对象的可枚举属性(包括原型链)。
  • forEach(..)every(..)some(..):数组辅助迭代器。
  • for..of (ES6):直接遍历值(通过迭代器对象)。

混合对象“类”

类是一种设计模式。JS 虽然有 class 关键字,但其机制与传统面向对象语言完全不同。

类的机制

  • 实例化:类通过复制操作变为对象。
  • 继承:子类继承父类。
  • 多态:子类重写父类方法,通过 super 相对引用。

混入 (Mixin)

由于 JS 不会自动执行复制行为,开发者常使用“混入”来模拟类复制。

  • 显式混入:手动复制属性。
  • 寄生继承:显式混入的一种变体。
  • 隐式混入:通过 call(this) 借用函数。

对象与类小结

类意味着复制。JS 中没有真正的类,只有对象,对象之间是通过关联(原型链)而非复制来连接的。

你不知道的JS(上):作用域与闭包

你不知道的JS(上):作用域与闭包

本文是《你不知道的JavaScript(上卷)》的阅读笔记,第一部分:作用域与闭包。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

作用域是什么

JS 编译原理

JS 的编译流程和传统编译非常相似,程序中的一段源代码在执行之前会经历三个步骤:分词/词法分析(Tokeninzing/Lexing)解析/语法分析(parsing)代码生成。对于 JS 来说,大部分编译发生在代码执行前的几微秒。

分词/词法分析(Tokeninzing/Lexing)

这个过程会将由字符组成的字符串分解为有意义的代码块,这些代码块被称为词法单元(token)。

解析/语法分析(parsing)

这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为”抽象语法树“(AST:Abstract Syntax Tree)。

代码生成

将 AST 转换为可执行代码的过程称被称为代码生成。简单的说就是有某种方法可以将 var a = 2; 的 AST 转化为一组机器指令,用来创建一个叫作 a 的变量(包括分配内存等),并将一个值储存在 a 中。

理解作用域

想要完全理解作用域,需要先理解引擎。

模块
  • 引擎:从头到尾负责整个 JS 程序的编译及执行过程。
  • 编译器:引擎的好朋友之一,负责语法分析及代码生成等脏活累活。
  • 作用域:引擎的另一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对标识符的访问权限。
声明

例如 var a = 2; 这段看起来是一个声明,在 JS 里其实是两个完全不同的声明,一个由编译器在编译时处理,另一个则由引擎在运行时处理。下面我们看看引擎是如何和编译器、作用域协同工作的。

编译器首先会将这段程序分解成词法单元,然后将词法单元解析为一个树结构。但是当编译器开始执行代码生成的时候,对这段代码的处理方式和预期有所不一样。

我们的预期是:”为一个变量分配内存,将其命名为 a,然后将值 2 保存进这个变量。“

但事实上编译器会进行如下处理:

  1. 遇到 var a,编译器会先询问作用域是否已经有这个变量存在,没有则会在作用域集合声明一个新的变量 a;如果已经有了,则忽略该声明继续编译。
  2. 接下来为引擎生成运行时所需的代码,这些代码被用来处理 a = 2 这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合里是否有变量 a,如果有变量 a,则直接使用;如果没有则向上继续查找。最终如果找到了 a,则进行赋值操作,没有则进行异常抛错。

总结:变量的赋值操作会执行两个动作,首先去在当前作用域中声明一个变量(如果之前没声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就进行赋值。

引用执行

编译器在编译的过程的第二步中生成了代码,引擎执行它时,会通过查找变量 a 来判断它是否已声明过。查找的过程由作用域进行协助,当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧进行 RHS 查询。

RHS 可以理解为 retrieve his source value(取到它的源值)。 例如:console.log(a); 其中对 a 的引用是一个 RHS 引用,这里没有赋予任何值。 相比下 a = 2; 是 LHS 引用,赋值操作找到一个目标。

作用域嵌套

作用域是根据名称查找变量的一套规则。实际情况中,通常要同时查找好几个作用域。当一个块或函数嵌套在另一个块或者函数中时,就发生了作用域的嵌套。在当前作用域无法找到某个变量时,引擎就会在外层嵌套的作用域中进行继续查找,直到查到该变量或者最外层的全局作用域为止。

function foo(a) {
    // 其中b进行的RHS引用无法在函数foo内部完成,但可以在上一级作用域中完成
    console.log(a + b);
}

var b = 2;

foo(3); // 5

引用异常

在变量还没有声明的情况下,LHS 和 RHS 的行为是不一样的。

如果 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出 ReferenceError 的异常类型。

如果 RHS 找到了一个变量,但你尝试对这个变量的值进行不合理的操作,比如试图对非函数类型的值进行调用,或者引用 nullundefined 的值中的属性会报 TypeError

function foo(a) {
    // 对b进行RHS查询时是无法找到该变量的
    console.log(a + b);
    b = a;
}

foo(2); // ReferenceError: b is not defined

相比较下,执行 LHS 查询时,如果在全局作用域也无法找到目标变量,全局作用域中会创建一个具有该名称的变量,并返回给引擎(前提是程序运行在非严格模式下)。

function foo(a) {
    // 对b进行LHS查询时,没找到会创建一个全局变量
    b = a;
    console.log(a + b);
}
foo(2); // 4

作用域小结

作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询。赋值操作符会导致 LHS 查询。= 操作符或调用函数时传入参数的操作都会导致关联作用域的赋值操作。

词法作用域

作用域有两种主要的工作模型。第一种是最为普遍的词法作用域,另一种是动态作用域。JS 采用的是词法作用域。

词法阶段

编译器第一个工作阶段叫作词法化,词法化的过程会对源代码中的字符进行检查,如果有状态的解析过程,还会赋予单词语义。无论函数在哪里被调用或如何调用,词法作用域只由函数被声明时所处的位置决定。作用域查找会在第一个匹配的标识符时停止,在多层的嵌套作用域中可以定义同名的标识符,这叫作”遮蔽效应“。

欺骗词法

词法作用域完全由书写代码期间函数所声明的位置来定义,怎样才能在运行时来”修改“(欺骗)词法作用域呢?JS 中有两种机制来实现这个目的,使用这两种机制并不是好主意,而且欺骗词法作用域会导致性能下降。

eval

eval(...) 函数可以接受一个字符串为参数,并运行这个字符串来实现修改词法作用域环境。

function foo(str, a) {
    eval( str ); // 欺骗!
    console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1, 3

在严格模式下,eval 在运行时有其自己语法作用域,意味着其中的声明无法修改所在的作用域。

function foo(str) {
    "use strict";
    eval( str );
    console.log( a ); // ReferenceError: a is not defined
}
foo( "var a = 2"); 

JS 中还有一些其他功能效果和 eval 相似的方法,例如 setTimeout(...)setInterval(...) 的第一个参数可以是字符串,可以被解析为一段动态生成的函数代码。这些功能已经过时且并不被提倡。不要使用他们!

with

JS 中另一个难以掌握的用来欺骗词法作用域的功能是 with 关键字。with 通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。

var obj = {
    a: 1,
    b: 2,
    c: 3
};
// 单调乏味的重复”obj“
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 简单的快捷方式
with (obj) {
    a = 3;
    b = 4;
    c = 5;
}

但这样的使用方式会有奇怪的副作用,例如:

function foo(obj) {
    with (obj) {
        a = 2;
    }
}

var o1 = { a: 3 };
var o2 = { b: 3 };
foo(o1);
console.log(o1.a); // 2

foo(o2);
console.log(o2.a); // undefined
console.log(a); // 2 --a被泄露到全局作用域上了

当我们将 o2 作为作用域时,其中并没有 a 标识符,因此进行了正常的 LHS 标识符查找,在 o2foo、全局作用域都没有找到时,在非严格模式下自动创建了一个全局变量。

性能

JS 引擎会在编译阶段进行数项性能优化。其中有些优化依赖于代码词法的静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。如果代码中使用了 evalwith,它只能简单地假设关于标识符位置的判断都是无效的,因此代码中的优化就没有了意义。如果大量使用 evalwith,运行起来会非常慢。

词法作用域小结

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。

JavaScript 中有两个机制可以“欺骗”词法作用域:eval(..)with。前者可以对一段包含一个或多个声明的“代码”字符串进行演算,并借此来修改已经存在的词法作用域(在运行时)。后者本质上是通过将一个对象的引用当作作用域来处理,将对象的属性当作作用域中的标识符来处理,从而创建了一个新的词法作用域(同样是在运行时)。

这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认为这样的优化是无效的。使用这其中任何一个机制都将导致代码运行变慢。不要使用它们。

函数作用域和块作用域

函数中的作用域

函数作用域是指属于这个函数的全部变量都可以在整个函数的范围内使用及复用。这种设计方案是非常有用的,能充分利用 JS 变量根据需要改变值类型的”动态“特性。

隐藏内部实现

把变量和函数包裹在一个函数的作用域中,然后用这个作用域来”隐藏“它们。

function doSomething(a) {
    function doSomeThingElse(a) {
        return a - 1;
    }
    var b;
    b = a + doSomeThingElse(a*2);
    console.log(b*3);
}
doSomething(2); // 15
// b 和doSomeThingElse都无法从外部被访问,只能被doSomething所控制。从设计角度上,内容私有化了

”隐藏“作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突,两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突。

函数作用域

匿名和具名
setTimeout(function () {
    console.log('I waited 1 second!');
}, 1000);

因为 function 没有名称标识符,这就叫作匿名函数表达式。函数表达式可以匿名,但函数声明则不能省略函数名。

立即执行函数表达式
var a = 2;
(function foo() {
    var a = 3;
    console.log(a); // 3
})();

console.log(a); // 2

函数被包含在一对 ( ) 括号内部,因此成为一个表达式,通过在末尾加上另外一个 ( ) 可以立即执行这个函数,专业术语叫做 IIFE(Immediately Invoked Function Expression)。

IIFE 的另一个非常普遍的进阶用法是把它们当作函数调用并传递参数进去。例如:

var a = 2;
(function IIFE(global) {
    var a = 3;
    console.log(a); // 3
    console.log(global.a); // 2
})(global);

console.log(a); // 2

IIFE 还有一种变化的用途是倒置代码的运行顺序,将需要运行的函数放在第二位,在 IIFE 执行之后当作参数传递进去。这种模式在 UMD 项目中被广泛应用。

var a = 2;
(function IIFE(def) {
    def(window)
})(function def(global) {
    var a = 3;
    console.log(a); // 3
    console.log(global.a); // 2
})

块作用域

块作用域是一个用来对之前的最小授权原则进行扩展的工具,将代码从在函数中隐藏信息扩展为在块中隐藏信息。

with

它不仅是一个难以理解的结构,也是块作用域的一个例子,用 with 从对象中创建出的作用域仅在 with 声明中而非外部作用域中有效。

try/catch

非常少有人注意到 try/catch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效。

try {
    undefined(); // 执行一个异常
} catch (err) {
    console.log(err); // 能够执行
}
console.log(err); // ReferenceError: err not found
let

let 关键字可以将变量绑定到所在的任意作用域中(通常是 {...} 内部)。

var foo = true;
if (foo) {
    let bar = 2;
}

console.log(bar); // ReferenceError
const

ES6 还引入了 const,同样可以用来创建块作用域变量,但其值是固定的(常量)。之后任何试图修改值的操作都会引起错误。

if (true) {
    var a = 2;
    const b = 3; // 包含在if中的块作用域常量
    a = 3; // 正常
    b = 4; // 错误
}

console.log(a); // 3
console.log(b); // ReferenceError

函数与块作用域小结

函数是 JavaScript 中最常见的作用域单元。本质上,声明在一个函数内部的变量或函数会在所处的作用域中“隐藏”起来,这是有意为之的良好软件的设计原则。

但函数不是唯一的作用域单元. 块作用域指的是变量和函数不仅可以属于所处的作用域,也可以属于某个代码块(通常指 { .. } 内部)。

从 ES3 开始,try/catch 结构在 catch 分句中具有块作用域。 在 ES6 中引入了 let 关键字(var 关键字的表亲),用来在任意代码块中声明变量。if (..) { let a = 2; } 会声明一个劫持了 if{ .. } 块的变量,并且将变量添加到这个块中。

作用域提升

先声明还是先赋值

一般来说 JS 代码是在从上到下一行一行执行的,但实际上并不完全正确,存在作用域提升的特殊情况,例如:

a = 2;
var a;
console.log(a); // 2 不是undefined
console.log(a); // undefined
var a = 2;

编译器的作用

引擎会在执行 JS 代码前进行编译,编译阶段的一部分工作就是找到所有的声明,并用合适的作用域关联起来。所以正确的思路是,包括变量和函数在内的所有声明都是在代码执行前先处理。定义声明在编译阶段进行,赋值声明在原地等待执行阶段。上面的例子可以被解析为:

var a;
a = 2;
console.log(a); // 2

第二个解析为:

var a;
console.log(a); // undefined
a = 2;

这个过程就好像变量 and 函数声明从它们的代码中出现的位置被“移动”到了最上面。这个过程就叫作提升。

函数优先

函数声明和变量声明都会被提升,但函数会优先被提升,然后才是变量。

foo(); // 1
var foo;
function foo() {
    console.log(1);
}
foo = function() {
    console.log(2);
}

var foo 尽管出现在 function foo()... 的声明之前,但它是重复的声明,因为函数声明会被提升到普通变量之前。 尽管重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。

foo(); // 3
var foo;
function foo() {
    console.log(1);
}
var foo = function() {
    console.log(2);
}
function foo() {
    console.log(3);
}

尽量避免在块内部声明函数,这个行为并不可靠。

作用域提升小结

无论作用域中的声明在什么地方,都将在代码本身被执行前先进行处理,这一过程被称为提升。同时需要注意避免重复声明,特别是当普通的 var 声明和函数声明混合在一起的时候,会引起很多危险的问题!

作用域闭包

闭包无处不在

闭包无处不在,你只需要能够识别并拥抱它。

闭包的实质

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会有对原始定义作用域的引用,这就叫作闭包。闭包的神奇之处在于阻止外层作用域被引擎的垃圾回收器回收。 例如: 内部函数调用外部引用

function foo() {
    var a = 2;
    function bar() {
        console.log(a); // 2
    }
    bar();
}
foo(); // 这不是闭包,但包含了闭包的原理

把函数作为返回值,保持对外部的引用

function foo() {
    var a = 2;
    function bar() {
        console.log(a);
    }
    return bar;
}
var baz = foo();
baz(); // 2 这就是闭包的效果

对函数类型的值进行传递,在别处调用

function foo() {
    var a = 2;
    function baz() {
        console.log(a) // 2
    }
    bar(baz);
}

function bar(fn) {
    fn(); // 这也是闭包
}

间接传递函数

var fn;
function foo() {
    var a = 2;
    function baz() {
        console.log(a) // 2
    }
    fn = baz; // 将baz分配
}

function bar() {
    fn(); // 这也是闭包
}
foo();
bar(); // 2

闭包的使用

闭包的使用其实非常广泛,在我们无意中写的代码里有很多闭包。在定时器、事件监听、ajax 处理、通信、异步或同步任务中,只要使用了回调函数,实际上就是在使用闭包! 定时器的使用:

function wait(msg) {
    setTimeout(function timer() {
        console.log(msg)
    }, 1000);
}

wait('hello'); // wait执行1000毫秒后,它的内部作用域并不会消失,timer函数依然保有wait的作用域的闭包

事件监听:

function setupBot(name, selector) {
    $(selector).click(function (){
        console.log('Activating: ' + name);
    })
}

setupBot('name1', '#id1');

循环和闭包

要说明闭包,for 循环是最常见的例子。

for (var i=1; i<=5; i++) {
    setTimeout(function timer() {
        console.log( i );
    }, i*1000);
}

正常情况下,我们对这段代码行为的预期是分别输出数字 1~5,每秒一次,每次一个。 但实际上,这段代码在运行时会以每秒一次的频率输出五次 6。

因为延迟函数的回调会在循环结束时才执行。事实上,当定时器运行时即使每个迭代中执行的是 setTimeout(.., 0),所有的回调函数依然是在循环结束后才会被执行,因此会每次输出一个 6 出来。

尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i

缺陷是什么?我们需要更多的闭包作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域。

IIFE 会通过声明并立即执行一个函数来创建作用域。

for (var i=1; i<=5; i++) {
    (function() {
        var j = i;
        setTimeout( function timer() {
            console.log( j );
        }, j*1000 );
    })();
}

对这段代码进行改进,传入参数

for (var i=1; i<=5; i++) {
    (function(j) {
        setTimeout( function timer() {
            console.log( j );
        }, j*1000 );
    })(i);
}

当然现在可以使用 ES6 的 let 声明,来劫持块作用域:

for (let i=1; i<=5; i++) {
     setTimeout( function timer() {
        console.log( i );
    }, i*1000 );
}

模块

还有其他的代码模式利用闭包的强大威力,但从表面上看,它们似乎与回调无关。下面一起来研究其中最强大的一个:模块。

正如下面代码中所看到的,这里并没有明显的闭包,只有两个私有数据变量 somethinganother,以及 doSomething()doAnother() 两个内部函数,它们的词法作用域(而这就是闭包)也就是 foo() 的内部作用域。

function CoolModule() {
    var something = "cool";
    var another = [1, 2, 3];
    function doSomething() {
        console.log( something );
    }
    function doAnother() {
        console.log( another.join("!") );
    }
    
    return {
        doSomething: doSomething,
        doAnother: doAnother,
    }
}

var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1!2!3

模块模式需要具备两个必要条件。

  1. 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。
  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

上面的示例是个独立的模块创建器,可以被调用任意多次,每次调用都会创建一个新的模块实例。当只需要一个实例时,可以对这个模式进行简单的改进来实现单例模式:

var foo = (function CoolModule() {
    var something = "cool";
    var another = [1, 2, 3];
    function doSomething() {
        console.log( something );
    }
    function doAnother() {
        console.log( another.join("!") );
    }
    
    return {
        doSomething: doSomething,
        doAnother: doAnother,
    }
})();

foo.doSomething(); // cool
foo.doAnother(); // 1!2!3

我们将模块函数转成了 IIFE,立即调用这个函数并将返回值直接赋值给单例的模块实例标识符 foo

现代的模块机制

大多数模块依赖加载器/管理器本质上都是将这种模块定义封装进一个友好的 API。

var MyModules = (function Manager() {
    var modules = {};
    
    function define(name, deps, impl) {
        for (var i = 0; i < deps.length; i++) {
            deps[i] = modules[deps[i]];
        }
        modules[name] = impl.apply(impl, deps);
    } 
    
    function get (name) {
        return modules[name];
    }
    
    return {
        define: define,
        get: get
    }
})();

这段代码的核心是 modules[name] = impl.apply(impl, deps)。为了模块的定义引入了包装函数(可以传入任何依赖),并且将返回值,也就是模块的 API,存储在一个根据名字来管理的模块列表中。

// 下面展示了如何使用它来定义模块
MyModules.define("bar", [], function() {
    function hello(who) {
        return "Let me introduce: " + who;
    }
    
    return {
        hello: hello
    }
});

MyModules.define("foo", ["bar"], function(bar) {
    var hungry = "hippo";
    function awesome() {
        console.log(bar.hello(hungry).toUpperCase());
    }
    
    return {
        awesome: awesome
    }
});

var bar = MyModules.get("bar");
var foo = MyModules.get("foo");

console.log(bar.hello("hippo")); // Let me introduce: hippo
foo.awesome(); // LET ME INTRODUCE: HIPPO

foobar 模块都是通过一个返回公共的 API 的函数来定义的。foo 还接受 bar 作为依赖参数,并能相应的使用它。

未来的模块机制

ES6 中为模块增加了一级语法支持。通过模块系统进行加载时,ES6 会将文件当作独立的模块来处理。每个模块可以导入其他模块或特定的 API,同样也可以导出自己的 API 成员。

相比之下,ES6 的模块 API 更加稳定(API 不会在运行时改变)。

// bar.js
function hello(who) {
    return "Let me introduce: " + who;
}
export hello;

// foo.js
// 仅从 "bar" 模块导入 hello()
import hello from "bar";
var hungry = "hippo";
function awesome() {
    console.log(hello( hungry ).toUpperCase());
}
export awesome;

// baz.js
// 导入完整的 "foo" 和 "bar" 模块
module foo from "foo";
module bar from "bar";
console.log(bar.hello( "rhino" )); // Let me introduce: rhino
foo.awesome(); // LET ME INTRODUCE: HIPPO

import 可以将一个模块中的一个或多个 API 导入到当前作用域中,并分别绑定在一个变量上(在我们的例子里是 hello)。module 会将整个模块的 API 导入并绑定到一个变量上(在我们的例子里是 foobar)。export 会将当前模块的一个标识符(变量、函数)导出为公共 API。这些操作可以在模块定义中根据需要使用任意多次。

闭包与模块小结

当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。

闭包也是一个非常强大的工具,可以用多种形式来实现模块等模式。

《前端架构设计》:除了写代码,我们还得管点啥

《前端架构设计》:除了写代码,我们还得管点啥

很多人对“前端架构”这四个字有误解,觉得就是选个 React 还是 Vue,或者是折腾一下 Webpack 和 Vite。

但 Micah Godbolt 在《前端架构设计》里泼了一盆冷水:选工具只是最简单的一步。真正的架构,是让团队在业务疯狂迭代的时候,代码还能写得顺手,系统还能跑得稳当。

这本书的核心其实就是一句话:架构不是结果,而是为了让开发更爽、系统更强而设计的一整套制度。


一、 别把架构师当成“高级打工人”

在很多项目里,前端总是在“下游”接活:UI 给图,后端给口,前端就在中间拼命赶进度。但作者认为,前端架构师应该是项目里的“一等公民”,甚至是“城市规划师”。

1. 搭体系 (System Design)

你得像规划城市一样去规划代码。

  • 组件库的颗粒度:到底是一个按钮算一个组件,还是一个带图标的搜索框算一个组件?这直接决定了复用效率。
  • 样式冲突预防:不同业务线共用一套组件时,怎么保证 A 团队改的样式不会让 B 团队的页面崩掉?
  • 技术选型:不能光看 GitHub 上哪个库星星多,得看它能不能管个三五年。架构师要考虑的是“可持续性”,而不是“时髦值”。

2. 理流程 (Workflow Planning)

大家开发得顺不顺手,全看流程顺不顺。架构师得操心:

  • 环境一致性:是不是每个人的 Node 版本、依赖版本都一样?
  • 构建自动化:代码怎么自动化编译、压缩、分包?
  • 新人上手成本:能不能让新人进来半天就能配好环境开始写业务? 很多时候,一个好的构建工具(比如现在的 Vite 或者以前的 Webpack)配上一套好流程,比写出神仙代码更能救命。

3. 盯着质量 (Oversight)

业务跑得快,脏代码肯定多。架构师得心里有数:

  • 技术债管理:什么时候该去清理那些“为了上线先凑合”的代码?
  • 代码审查 (Code Review):不是为了找茬,而是为了同步思路。
  • 风险预判:业务下个月要搞大促,现在的系统架构能不能扛住高并发和频繁的样式变更? 别等项目成了“屎山”才去想重构,那时候可能连下脚的地方都没了。

二、 架构的四个核心支柱

作者把架构分成了四个板块:代码、流程、测试、文档。这四块拼图凑齐了,项目才算有了魂。

1. 代码 (Code):拒绝“意大利面条”

代码架构的核心就是“解耦”。别让 HTML、CSS 和 JS 粘得太死,要像乐高积木一样可以随时拆卸。

HTML:结构的纯粹性
  • 拒绝过度生成:要在自动化和控制力之间找平衡。别让后端逻辑直接吐一堆乱七八糟的标签出来,那样前端根本没法改。
  • 组件化思维:HTML 只负责描述“这是什么”(比如这是一个导航栏),而不负责“它长什么样”。
  • 语义化:保持 HTML 的简洁和语义化,是架构长青的基石。
CSS:架构的重灾区

这是全书聊得最细的地方,因为 CSS 最容易写乱,也最难维护。

  • OOCSS (面向对象 CSS)
    • 核心是“结构与皮肤分离”。
    • 比如一个按钮的形状、边距是“结构”,它的背景色、投影是“皮肤”。
  • SMACSS
    • 给样式分分类,就像衣柜收纳:
      1. Base:基础样式,比如 body, a 标签的默认外观。
      2. Layout:布局样式,负责页面大框架。
      3. Module:模块样式,这是重头戏,比如导航条、轮播图。
      4. State:状态样式,比如 is-active, is-hidden
      5. Theme:主题样式,换肤全靠它。
  • BEM 命名法
    • 虽然 block__element--modifier 名字长,但它能解决权重冲突。
    • 坚持用单类名(Single Class Selection),重构的时候底气才足,不用担心改个按钮全站崩。
  • 单一来源 (Single Source of Truth)
    • 变量(Variables)是神。颜色、字号、间距,全用变量管起来,改一个地方全站生效。
JavaScript:逻辑的独立性
  • 框架无关性:选框架要冷静。业务逻辑要尽量从 UI 库里抽出来。
  • 纯函数:尽量写不依赖外部环境的函数,这样不开启浏览器也能跑单元测试。
  • 状态管理:清晰的数据流向是大型项目不崩溃的前提。

2. 流程 (Process):别把时间花在重复劳动上

流程决定了代码从你的键盘到用户屏幕的距离。

  • 原型驱动 (Prototyping)

    • 别光看设计稿,先写个 HTML 原型。
    • 为什么?因为原型能让你早点体验到真实的交互,早点发现问题。
    • 改原型永远比改正式代码便宜。
  • 任务自动化

    • 凡是能让机器干的活(编译 Sass、压图、跑 Lint),都别让人干。
    • 现在的 npm scripts 或者是各种 Task Runner 就是为了解放生产力的。
  • 持续集成 (CI)

    • 代码合并前,必须经过一顿“毒打”——编译、Lint 检查、自动化测试。
    • 只有通过了,才能合并。这能保证主干代码永远是健康的。
  • 文档化工作流:让每个步骤都有迹可循,减少沟通成本。


3. 测试 (Testing):你的后悔药

没有测试的重构就是裸奔。

  • 视觉回归测试 (Visual Regression)

    • 这是作者最推崇的黑科技。
    • 重构 CSS 之后,用工具(比如 BackstopJS)自动截图和以前对比,像素级的差异都能找出来。
    • 这是重构老旧系统的“保命符”,有了它,你才敢动那些几年前写的样式。
  • 性能预算 (Performance Budget)

    • 性能不是后期优化的,是前期定好的。
    • 首屏时间不能超过几秒?JS 包体积不能超过多大?
    • 定死这些指标,加第三方库的时候你才会心疼,才会去想有没有更好的替代方案。
  • 自动化单元测试:保证核心逻辑的稳定性,改代码不再提心吊胆。


4. 文档 (Documentation):它是活的吗?

文档不是写给老板交差的,是给团队对齐思路的。

  • 动态样式指南 (Living Style Guides)

    • 静态文档写完就过期。
    • 理想的状态是:文档就是代码的一部分。代码改了,文档自动更新。
  • 模式库 (Pattern Lab)

    • 按照“原子设计”的思路管理组件:
      • 原子 (Atoms):标签、按钮、输入框。
      • 分子 (Molecules):带标签的搜索栏。
      • 有机体 (Organisms):整个导航头部。
      • 模板 (Templates):页面骨架。
      • 页面 (Pages):最终呈现。
    • 这种层级关系理清楚了,组件复用才不会乱成一团。
  • 开发者文档:记录“为什么要这么设计”,比“怎么用”更重要。


三、 Red Hat 的实战经验:怎么干重构?

作者在 Red Hat 负责过一次大规模重构,这部分的实战干货非常多,值得细品:

1. 解决命名空间冲突

在大型公司,不同团队可能都在写 CSS。以前样式到处打架,改个按钮全公司网站都变色。

  • 方案:重构时,他们给所有核心组件加了 rh- 这种命名前缀。

  • 感悟:技术虽然简单,但它建立了“地盘感”,彻底实现了样式隔离。

2. 语义化网格系统 (Semantic Grids)

  • 痛点:别在 HTML 里写死 .col-6 这种类名。如果你想把 6 列改成 4 列,你得改几百个 HTML 文件。

  • 绝招:在 Sass 里用 Mixins 定义布局。

    • HTML 只要保持语义(比如 .main-content)。
    • CSS 里写 @include make-column(8);
  • 结果:改布局只要动一个 CSS 配置文件,HTML 完全不用变。

3. 文档系统的四阶进化

Red Hat 的文档不是一步到位的,他们经历了四个阶段:

  1. 第一阶段:静态页面。写完就没人看了,很快就和代码脱节。
  2. 第二阶段:自动化 Pattern Lab。让文档和代码同步,实现了“活文档”。
  3. 第三阶段:独立组件库。把组件从业务项目里剥离出来,像发 npm 包一样管理,跨项目复用变得极其简单。
  4. 第四阶段:统一渲染引擎
    • 核心:用 JSON Schema 定义数据格式。
    • 效果:不管后端是 PHP 还是 Java,只要按这个 JSON 格式给数据,前端组件就能准确渲染。这彻底解决了前后端对接时的“扯皮”问题,前端成了真正的“界面引擎”。

四、 架构师的心法:BB 鸟与歪心狼

书中提到了两个非常有意思的隐喻,这才是架构设计的最高境界:

1. BB 鸟规则 (Roadrunner Rule)

看过动画片的都知道,BB 鸟跑得飞快,而歪心狼(Coyote)总是背着一堆高科技装备,结果最后都被装备给坑了。

  • 道理:架构要轻量,要解决的是“现在”和“可预见的未来”的问题。
  • 戒律:别为了解决那种万分之一才会出现的特殊情况,把系统搞得无比复杂。别把自己变成那个被装备压垮的歪心狼。

2. 解决“最后一英里”问题

代码写完、测试通过、合并进主干,这就算完了吗?架构师说:还没。

  • 全路径关注
    • 静态资源在 CDN 上刷了吗?
    • 用户的浏览器缓存策略配对了吗?
    • 第三方广告脚本会不会把页面卡死?
    • 在偏远地区、慢网环境下,用户看到的是白屏还是有意义的内容? 架构师得关注从代码仓库到用户浏览器的“每一寸路程”。

五、 写在最后

前端架构不是一个静态的目标,而是一直在变的过程。一个好的架构师得会沟通,在技术理想和业务现实之间找平衡。

说到底,架构就是为了把这四块拼图理顺:

  1. 代码 得够模块化,重构不心慌;
  2. 流程 得够自动化,开发不心累;
  3. 测试 得够全面,上线不背锅;
  4. 文档 得够实时,沟通不扯皮。

如果你能把这几件事干好了,你写的就不只是代码,而是一个能长久活下去、有生命力的系统。这,才是前端架构设计的真谛。

设计模式-行为型

设计模式-行为型

本文主要介绍下行为型设计模式,包括策略模式模板方法模式观察者模式迭代器模式责任链模式命令模式备忘录模式状态模式访问者模式中介者模式解释器模式,提供前端场景和 ES6 代码的实现过程。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

引言

本文主要介绍下行为型设计模式,包括策略模式模板方法模式观察者模式迭代器模式责任链模式命令模式备忘录模式状态模式访问者模式中介者模式解释器模式,提供前端场景和 ES6 代码的实现过程。

什么是行为型

行为型模式(Behavioral Patterns)主要关注对象之间的通信职责分配。这些模式描述了对象之间如何协作共同完成任务,以及如何分配职责。行为型模式不仅关注类和对象的结构,更关注它们之间的相互作用,通过定义清晰的通信机制,解决系统中复杂的控制流问题,使得代码更加清晰、灵活和易于维护。

行为型模式

策略模式(Strategy)

策略模式(Strategy Pattern)定义一系列的算法,把它们一个个封装起来,并使它们可以相互替换。策略模式让算法独立于使用它的客户而变化。

前端中的策略模式场景

  • 表单验证:将不同的验证规则(如非空、邮箱格式、手机号格式)封装成策略,根据需要选择验证策略。
  • 不同业务逻辑处理:例如,根据用户权限(普通用户、VIP、管理员)展示不同的 UI 或执行不同的逻辑。
  • 缓动动画算法:在动画库中,提供多种缓动函数(如 linearease-inease-out)供用户选择。

策略模式-JS实现

// 策略对象
const strategies = {
  "S": (salary) => salary * 4,
  "A": (salary) => salary * 3,
  "B": (salary) => salary * 2
};

// 环境类(Context)
class Bonus {
  constructor(salary, strategy) {
    this.salary = salary;
    this.strategy = strategy;
  }

  getBonus() {
    return strategies[this.strategy](this.salary);
  }
}

// 客户端调用
const bonusS = new Bonus(10000, "S");
console.log(bonusS.getBonus()); // 40000

const bonusA = new Bonus(10000, "A");
console.log(bonusA.getBonus()); // 30000

模板方法模式(Template Method)

模板方法模式(Template Method Pattern)定义一个操作中的算法的骨架,而将一些步骤延迟到子类中实现。

模板方法使得子类可以在不改变算法结构的情况下,重新定义算法的某些步骤。

前端中的模板方法模式场景

  • UI组件生命周期VueReact 组件的生命周期钩子(如 componentDidMountcreated)就是模板方法模式的应用。框架定义了组件渲染的整体流程,开发者在特定的钩子中实现自定义逻辑。
  • HTTP请求封装:定义一个基础的请求类,处理通用的配置(如 URL、Headers),子类实现具体的请求逻辑(如 GET、POST 参数处理)。

模板方法模式-JS实现

// 抽象父类:饮料
class Beverage {
  // 模板方法,定义算法骨架
  makeBeverage() {
    this.boilWater();
    this.brew();
    this.pourInCup();
    this.addCondiments();
  }

  boilWater() {
    console.log("煮沸水");
  }

  pourInCup() {
    console.log("倒进杯子");
  }

  // 抽象方法,子类必须实现
  brew() {
    throw new Error("抽象方法不能调用");
  }

  addCondiments() {
    throw new Error("抽象方法不能调用");
  }
}

// 具体子类:咖啡
class Coffee extends Beverage {
  brew() {
    console.log("用沸水冲泡咖啡");
  }

  addCondiments() {
    console.log("加糖和牛奶");
  }
}

// 具体子类:茶
class Tea extends Beverage {
  brew() {
    console.log("用沸水浸泡茶叶");
  }

  addCondiments() {
    console.log("加柠檬");
  }
}

// 客户端调用
const coffee = new Coffee();
coffee.makeBeverage();
// 输出:
// 煮沸水
// 用沸水冲泡咖啡
// 倒进杯子
// 加糖和牛奶

const tea = new Tea();
tea.makeBeverage();
// 输出:
// 煮沸水
// 用沸水浸泡茶叶
// 倒进杯子
// 加柠檬

观察者模式(Observer)

观察者模式(Observer Pattern)定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。

前端中的观察者模式场景

  • DOM事件监听document.addEventListener 就是最典型的观察者模式。
  • Promisethen 方法也是一种观察者模式,当 Promise 状态改变时,执行相应的回调。
  • Vue响应式系统Dep(目标)和 Watcher(观察者)实现了数据的响应式更新。
  • RxJS:基于观察者模式的响应式编程库。
  • Event Bus:事件总线。

观察者模式-JS实现

// 目标对象(Subject)
class Subject {
  constructor() {
    this.observers = [];
  }

  addObserver(observer) {
    this.observers.push(observer);
  }

  removeObserver(observer) {
    const index = this.observers.indexOf(observer);
    if (index > -1) {
      this.observers.splice(index, 1);
    }
  }

  notify(data) {
    this.observers.forEach(observer => {
      observer.update(data);
    });
  }
}

// 观察者对象(Observer)
class Observer {
  constructor(name) {
    this.name = name;
  }

  update(data) {
    console.log(`${this.name} 收到通知: ${data}`);
  }
}

// 客户端调用
const subject = new Subject();
const observer1 = new Observer("观察者1");
const observer2 = new Observer("观察者2");

subject.addObserver(observer1);
subject.addObserver(observer2);

subject.notify("更新数据了!");
// 输出:
// 观察者1 收到通知: 更新数据了!
// 观察者2 收到通知: 更新数据了!

迭代器模式(Iterator)

迭代器模式(Iterator Pattern)提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露其内部的表示。

前端中的迭代器模式场景

  • 数组遍历forEachmap 等数组方法。
  • ES6 IteratorSymbol.iterator 接口,使得对象可以使用 for...of 循环遍历。
  • Generators:生成器函数可以生成迭代器。

迭代器模式-JS实现

// 自定义迭代器
class Iterator {
  constructor(items) {
    this.items = items;
    this.index = 0;
  }

  hasNext() {
    return this.index < this.items.length;
  }

  next() {
    return this.items[this.index++];
  }
}

// 客户端调用
const items = [1, 2, 3];
const iterator = new Iterator(items);

while (iterator.hasNext()) {
  console.log(iterator.next());
}
// 输出:1 2 3

// ES6 Iterator 示例
const iterableObj = {
  items: [10, 20, 30],
  [Symbol.iterator]() {
    let index = 0;
    return {
      next: () => {
        if (index < this.items.length) {
          return { value: this.items[index++], done: false };
        } else {
          return { done: true };
        }
      }
    };
  }
};

for (const item of iterableObj) {
  console.log(item);
}
// 输出:10 20 30

责任链模式(Chain of Responsibility)

责任链模式(Chain of Responsibility Pattern)使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。

前端中的责任链模式场景

  • 事件冒泡:DOM 事件在 DOM 树中的冒泡机制就是责任链模式。
  • 中间件ExpressKoaRedux 中的中间件机制。
  • 拦截器Axios 的请求和响应拦截器。

责任链模式-JS实现

// 处理器基类
class Handler {
  setNext(handler) {
    this.nextHandler = handler;
    return handler; // 返回 handler 以支持链式调用
  }

  handleRequest(request) {
    if (this.nextHandler) {
      this.nextHandler.handleRequest(request);
    }
  }
}

// 具体处理器
class HandlerA extends Handler {
  handleRequest(request) {
    if (request === 'A') {
      console.log("HandlerA 处理了请求");
    } else {
      super.handleRequest(request);
    }
  }
}

class HandlerB extends Handler {
  handleRequest(request) {
    if (request === 'B') {
      console.log("HandlerB 处理了请求");
    } else {
      super.handleRequest(request);
    }
  }
}

class HandlerC extends Handler {
  handleRequest(request) {
    if (request === 'C') {
      console.log("HandlerC 处理了请求");
    } else {
      console.log("没有处理器处理该请求");
    }
  }
}

// 客户端调用
const handlerA = new HandlerA();
const handlerB = new HandlerB();
const handlerC = new HandlerC();

handlerA.setNext(handlerB).setNext(handlerC);

handlerA.handleRequest('A'); // HandlerA 处理了请求
handlerA.handleRequest('B'); // HandlerB 处理了请求
handlerA.handleRequest('C'); // HandlerC 处理了请求
handlerA.handleRequest('D'); // 没有处理器处理该请求

命令模式(Command)

命令模式(Command Pattern)将一个请求封装为一个对象,从而使用户可用不同的请求对客户进行参数化;对请求排队或记录请求日志,以及支持可撤销的操作。

前端中的命令模式场景

  • 富文本编辑器:执行加粗、斜体、下划线等操作,并支持撤销(Undo)和重做(Redo)。
  • 菜单和按钮:将菜单项或按钮的操作封装成命令对象。

命令模式-JS实现

// 接收者:执行实际命令
class Receiver {
  execute() {
    console.log("执行命令");
  }
}

// 命令对象
class Command {
  constructor(receiver) {
    this.receiver = receiver;
  }

  execute() {
    this.receiver.execute();
  }
}

// 调用者:发起命令
class Invoker {
  constructor(command) {
    this.command = command;
  }

  invoke() {
    console.log("调用者发起请求");
    this.command.execute();
  }
}

// 客户端调用
const receiver = new Receiver();
const command = new Command(receiver);
const invoker = new Invoker(command);

invoker.invoke();
// 输出:
// 调用者发起请求
// 执行命令

备忘录模式(Memento)

备忘录模式(Memento Pattern)在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。

前端中的备忘录模式场景

  • 状态管理Redux 等状态管理库的时间旅行(Time Travel)功能。
  • 表单草稿:保存用户输入的表单内容,以便下次恢复。
  • 撤销/重做:编辑器中的撤销和重做功能。

备忘录模式-JS实现

// 备忘录:保存状态
class Memento {
  constructor(state) {
    this.state = state;
  }

  getState() {
    return this.state;
  }
}

// 发起人:需要保存状态的对象
class Originator {
  constructor() {
    this.state = "";
  }

  setState(state) {
    this.state = state;
    console.log(`当前状态: ${this.state}`);
  }

  saveStateToMemento() {
    return new Memento(this.state);
  }

  getStateFromMemento(memento) {
    this.state = memento.getState();
    console.log(`恢复状态: ${this.state}`);
  }
}

// 管理者:管理备忘录
class Caretaker {
  constructor() {
    this.mementos = [];
  }

  add(memento) {
    this.mementos.push(memento);
  }

  get(index) {
    return this.mementos[index];
  }
}

// 客户端调用
const originator = new Originator();
const caretaker = new Caretaker();

originator.setState("状态1");
originator.setState("状态2");
caretaker.add(originator.saveStateToMemento()); // 保存状态2

originator.setState("状态3");
caretaker.add(originator.saveStateToMemento()); // 保存状态3

originator.setState("状态4");

originator.getStateFromMemento(caretaker.get(0)); // 恢复到状态2
originator.getStateFromMemento(caretaker.get(1)); // 恢复到状态3

状态模式(State)

状态模式(State Pattern)允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。

前端中的状态模式场景

  • 有限状态机(FSM):例如,Promise 的状态(Pending, Fulfilled, Rejected)。
  • 组件状态管理:例如,一个按钮可能有 loadingdisableddefault 等状态,不同状态下点击行为不同。
  • 游戏开发:角色的不同状态(如站立、奔跑、跳跃、攻击)。

状态模式-JS实现

// 状态接口
class State {
  handle(context) {
    throw new Error("抽象方法不能调用");
  }
}

// 具体状态A
class ConcreteStateA extends State {
  handle(context) {
    console.log("当前是状态A");
    context.setState(new ConcreteStateB());
  }
}

// 具体状态B
class ConcreteStateB extends State {
  handle(context) {
    console.log("当前是状态B");
    context.setState(new ConcreteStateA());
  }
}

// 上下文
class Context {
  constructor() {
    this.state = new ConcreteStateA();
  }

  setState(state) {
    this.state = state;
  }

  request() {
    this.state.handle(this);
  }
}

// 客户端调用
const context = new Context();
context.request(); // 当前是状态A
context.request(); // 当前是状态B
context.request(); // 当前是状态A

访问者模式(Visitor)

访问者模式(Visitor Pattern)表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。

前端中的访问者模式场景

  • AST遍历Babel 插件开发中,通过访问者模式遍历和修改 AST(抽象语法树)节点。
  • 复杂数据结构处理:对树形结构或图形结构进行不同的操作(如渲染、序列化、验证)。

访问者模式-JS实现

// 元素类
class Element {
  accept(visitor) {
    throw new Error("抽象方法不能调用");
  }
}

class ConcreteElementA extends Element {
  accept(visitor) {
    visitor.visitConcreteElementA(this);
  }

  operationA() {
    return "ElementA";
  }
}

class ConcreteElementB extends Element {
  accept(visitor) {
    visitor.visitConcreteElementB(this);
  }

  operationB() {
    return "ElementB";
  }
}

// 访问者类
class Visitor {
  visitConcreteElementA(element) {
    console.log(`访问者访问 ${element.operationA()}`);
  }

  visitConcreteElementB(element) {
    console.log(`访问者访问 ${element.operationB()}`);
  }
}

// 客户端调用
const elementA = new ConcreteElementA();
const elementB = new ConcreteElementB();
const visitor = new Visitor();

elementA.accept(visitor); // 访问者访问 ElementA
elementB.accept(visitor); // 访问者访问 ElementB

中介者模式(Mediator)

中介者模式(Mediator Pattern)用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。

前端中的中介者模式场景

  • MVC/MVVM框架:Controller 或 ViewModel 充当中介者,协调 View 和 Model 之间的交互。
  • 复杂表单交互:例如,选择省份后,城市下拉框需要更新;选择城市后,区县下拉框需要更新。使用中介者统一管理这些联动逻辑。
  • 聊天室:用户之间不直接发送消息,而是通过服务器(中介者)转发。

中介者模式-JS实现

// 中介者
class ChatMediator {
  constructor() {
    this.users = [];
  }

  addUser(user) {
    this.users.push(user);
    user.setMediator(this);
  }

  sendMessage(message, user) {
    this.users.forEach(u => {
      if (u !== user) {
        u.receive(message);
      }
    });
  }
}

// 用户类
class User {
  constructor(name) {
    this.name = name;
    this.mediator = null;
  }

  setMediator(mediator) {
    this.mediator = mediator;
  }

  send(message) {
    console.log(`${this.name} 发送消息: ${message}`);
    this.mediator.sendMessage(message, this);
  }

  receive(message) {
    console.log(`${this.name} 收到消息: ${message}`);
  }
}

// 客户端调用
const mediator = new ChatMediator();
const user1 = new User("User1");
const user2 = new User("User2");
const user3 = new User("User3");

mediator.addUser(user1);
mediator.addUser(user2);
mediator.addUser(user3);

user1.send("大家好!");
// 输出:
// User1 发送消息: 大家好!
// User2 收到消息: 大家好!
// User3 收到消息: 大家好!

解释器模式(Interpreter)

解释器模式(Interpreter Pattern)给定一个语言,定义它的文法的一种表示,并定义一个解释器,该解释器用来解释语言中的句子。

前端中的解释器模式场景

  • 模板引擎MustacheHandlebars 等模板引擎,解析模板字符串并生成 HTML。
  • 编译器前端:将代码解析为 AST。
  • 数学表达式计算:解析并计算字符串形式的数学表达式。

解释器模式-JS实现

// 抽象表达式
class Expression {
  interpret(context) {
    throw new Error("抽象方法不能调用");
  }
}

// 终结符表达式:数字
class NumberExpression extends Expression {
  constructor(number) {
    super();
    this.number = number;
  }

  interpret(context) {
    return this.number;
  }
}

// 非终结符表达式:加法
class AddExpression extends Expression {
  constructor(left, right) {
    super();
    this.left = left;
    this.right = right;
  }

  interpret(context) {
    return this.left.interpret(context) + this.right.interpret(context);
  }
}

// 非终结符表达式:减法
class SubtractExpression extends Expression {
  constructor(left, right) {
    super();
    this.left = left;
    this.right = right;
  }

  interpret(context) {
    return this.left.interpret(context) - this.right.interpret(context);
  }
}

// 客户端调用:计算 10 + 5 - 2
const expression = new SubtractExpression(
  new AddExpression(new NumberExpression(10), new NumberExpression(5)),
  new NumberExpression(2)
);

console.log(expression.interpret()); // 13

项目地址

设计模式-结构型

设计模式-结构型

本文主要介绍下结构型设计模式,包括适配器模式装饰器模式代理模式外观模式桥接模式组合模式享元模式,提供前端场景和 ES6 代码的实现过程。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

引言

本文主要介绍下结构型设计模式,包括适配器模式装饰器模式代理模式外观模式桥接模式组合模式享元模式,提供前端场景和 ES6 代码的实现过程。

什么是结构型

结构型模式(Structural Patterns)主要关注对象组合。这些模式描述了如何将类或对象结合在一起形成更大的结构,同时保持结构的灵活高效。结构型模式通过继承组合的方式来简化系统的设计,解决对象之间的耦合问题,使得系统更加容易扩展和维护。

适配器模式(Adapter)

适配器模式(Adapter Pattern)将一个类的接口转换成客户希望的另一个接口,使原本因接口不兼容而不能一起工作的类可以一起工作。

适配器模式通常用于包装现有的类,以便与新的接口或系统进行交互。

前端中的适配器模式场景

  • 接口数据适配:后端返回的数据结构可能与前端组件需要的数据结构不一致,可以使用适配器模式进行转换。
  • 旧接口兼容:在系统重构或升级时,保持对旧接口的兼容性,使用适配器模式将新接口映射到旧接口。
  • Vue计算属性:Vue 中的 computed 属性可以看作是一种适配器,将原始数据转换为视图需要的数据格式。

适配器模式-JS实现

// 旧接口
class OldCalculator {
  constructor() {
    this.operations = function(term1, term2, operation) {
      switch (operation) {
        case 'add':
          return term1 + term2;
        case 'sub':
          return term1 - term2;
        default:
          return NaN;
      }
    };
  }
}

// 新接口
class NewCalculator {
  add(term1, term2) {
    return term1 + term2;
  }

  sub(term1, term2) {
    return term1 - term2;
  }
}

// 适配器类
class CalculatorAdapter {
  constructor() {
    this.calculator = new NewCalculator();
  }

  operations(term1, term2, operation) {
    switch (operation) {
      case 'add':
        return this.calculator.add(term1, term2);
      case 'sub':
        return this.calculator.sub(term1, term2);
      default:
        return NaN;
    }
  }
}

// 客户端调用
const oldCalc = new OldCalculator();
console.log(oldCalc.operations(10, 5, 'add')); // 15

const newCalc = new NewCalculator();
console.log(newCalc.add(10, 5)); // 15

const adapter = new CalculatorAdapter();
console.log(adapter.operations(10, 5, 'add')); // 15

装饰器模式(Decorator)

装饰器模式(Decorator Pattern动态地给一个对象添加一些额外的职责,而不影响该对象所属类的其他实例。

装饰器模式提供了一种灵活的替代继承方案,用于扩展对象的功能。

前端中的装饰器模式场景

  • 高阶组件(HOC):在 React 中,高阶组件本质上就是装饰器模式的应用,用于复用组件逻辑。
  • 类装饰器:在 ES7 装饰器语法或 TypeScript 中,可以使用装饰器来增强类或类的方法,例如用于日志记录、性能监控、权限控制等。

装饰器模式-JS实现

// 原始对象
class Circle {
  draw() {
    console.log("画一个圆形");
  }
}

// 装饰器基类
class Decorator {
  constructor(circle) {
    this.circle = circle;
  }

  draw() {
    this.circle.draw();
  }
}

// 具体装饰器:添加红色边框
class RedBorderDecorator extends Decorator {
  draw() {
    this.circle.draw();
    this.setRedBorder();
  }

  setRedBorder() {
    console.log("添加红色边框");
  }
}

// 客户端调用
const circle = new Circle();
circle.draw();
// 输出:
// 画一个圆形

const redCircle = new RedBorderDecorator(new Circle());
redCircle.draw();
// 输出:
// 画一个圆形
// 添加红色边框

代理模式(Proxy)

代理模式(Proxy Pattern)为其他对象提供一种代理以控制对这个对象的访问。

代理模式可以在访问对象之前或之后执行额外的操作,如权限验证、延迟加载、缓存等。

前端中的代理模式场景

  • 数据响应式Vue 3 使用 Proxy 对象来实现数据的响应式系统,拦截对象的读取和修改操作。
  • 网络请求代理:在开发环境中,配置代理服务器(如 webpack-dev-server 的 proxy)解决跨域问题。
  • 虚拟代理:例如图片懒加载,先显示占位图,等图片加载完成后再替换为真实图片。
  • 缓存代理:对于开销较大的计算结果或网络请求结果进行缓存,下次请求时直接返回缓存结果。

代理模式-JS实现

// 真实图片加载类
class RealImage {
  constructor(fileName) {
    this.fileName = fileName;
    this.loadFromDisk(fileName);
  }

  loadFromDisk(fileName) {
    console.log("正在从磁盘加载 " + fileName);
  }

  display() {
    console.log("显示 " + this.fileName);
  }
}

// 代理图片类
class ProxyImage {
  constructor(fileName) {
    this.fileName = fileName;
  }

  display() {
    if (!this.realImage) {
      this.realImage = new RealImage(this.fileName);
    }
    this.realImage.display();
  }
}

// 客户端调用
const image = new ProxyImage("test.jpg");

// 第一次调用,加载图片
image.display();
// 输出:
// 正在从磁盘加载 test.jpg
// 显示 test.jpg

// 第二次调用,直接显示
image.display();
// 输出:
// 显示 test.jpg

外观模式(Facade)

外观模式(Facade Pattern)提供一个统一的接口,用来访问子系统中的一群接口。外观模式定义了一个高层接口,让子系统更容易使用。

前端中的外观模式场景

  • 浏览器兼容性封装:封装不同浏览器的 API 差异,提供统一的接口。例如,封装事件监听函数,统一处理 addEventListenerattachEvent
  • 简化复杂库的使用:例如 jQueryAxios,它们为复杂的原生 DOM 操作或 XMLHttpRequest 提供了简单易用的接口。

外观模式-JS实现

// 子系统1:灯光
class Light {
  on() {
    console.log("开灯");
  }
  off() {
    console.log("关灯");
  }
}

// 子系统2:电视
class TV {
  on() {
    console.log("打开电视");
  }
  off() {
    console.log("关闭电视");
  }
}

// 子系统3:音响
class SoundSystem {
  on() {
    console.log("打开音响");
  }
  off() {
    console.log("关闭音响");
  }
}

// 外观类:家庭影院
class HomeTheaterFacade {
  constructor(light, tv, sound) {
    this.light = light;
    this.tv = tv;
    this.sound = sound;
  }

  watchMovie() {
    console.log("--- 准备看电影 ---");
    this.light.off();
    this.tv.on();
    this.sound.on();
  }

  endMovie() {
    console.log("--- 结束看电影 ---");
    this.light.on();
    this.tv.off();
    this.sound.off();
  }
}

// 客户端调用
const light = new Light();
const tv = new TV();
const sound = new SoundSystem();
const homeTheater = new HomeTheaterFacade(light, tv, sound);

homeTheater.watchMovie();
// 输出:
// --- 准备看电影 ---
// 关灯
// 打开电视
// 打开音响

homeTheater.endMovie();
// 输出:
// --- 结束看电影 ---
// 开灯
// 关闭电视
// 关闭音响

桥接模式(Bridge)

桥接模式(Bridge Pattern)将抽象部分与它的实现部分分离,使它们可以独立地变化。

前端中的桥接模式场景

  • UI组件与渲染引擎分离:例如,一个通用的图表库,可以将图表的逻辑(抽象部分)与具体的渲染方式(实现部分,如 Canvas、SVG、WebGL)分离。
  • 事件监听:在绑定事件时,将回调函数(实现部分)与事件绑定(抽象部分)分离,使得回调函数可以复用。

桥接模式-JS实现

// 实现部分接口:颜色
class Color {
  fill() {
    throw new Error("抽象方法不能调用");
  }
}

class Red extends Color {
  fill() {
    return "红色";
  }
}

class Green extends Color {
  fill() {
    return "绿色";
  }
}

// 抽象部分:形状
class Shape {
  constructor(color) {
    this.color = color;
  }

  draw() {
    throw new Error("抽象方法不能调用");
  }
}

class Circle extends Shape {
  draw() {
    console.log(`画一个${this.color.fill()}的圆形`);
  }
}

class Square extends Shape {
  draw() {
    console.log(`画一个${this.color.fill()}的正方形`);
  }
}

// 客户端调用
const redCircle = new Circle(new Red());
redCircle.draw(); // 画一个红色的圆形

const greenSquare = new Square(new Green());
greenSquare.draw(); // 画一个绿色的正方形

组合模式(Composite)

组合模式(Composite Pattern)将对象组合成树形结构以表示“部分-整体”的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。

前端中的组合模式场景

  • DOM树:DOM 树本身就是一个典型的组合模式结构,既包含具体的节点(如 divspan),也包含包含其他节点的容器。
  • 虚拟DOMVirtual DOM 也是树形结构,组件可以包含其他组件或原生元素。
  • 文件目录系统:文件夹可以包含文件或子文件夹。
  • 级联菜单:多级菜单的展示和操作。

组合模式-JS实现

// 组件基类
class Component {
  constructor(name) {
    this.name = name;
  }

  add(component) {
    throw new Error("不支持该操作");
  }

  remove(component) {
    throw new Error("不支持该操作");
  }

  print(indent = "") {
    throw new Error("不支持该操作");
  }
}

// 叶子节点:文件
class File extends Component {
  print(indent = "") {
    console.log(`${indent}- ${this.name}`);
  }
}

// 组合节点:文件夹
class Folder extends Component {
  constructor(name) {
    super(name);
    this.children = [];
  }

  add(component) {
    this.children.push(component);
  }

  remove(component) {
    const index = this.children.indexOf(component);
    if (index > -1) {
      this.children.splice(index, 1);
    }
  }

  print(indent = "") {
    console.log(`${indent}+ ${this.name}`);
    this.children.forEach(child => {
      child.print(indent + "  ");
    });
  }
}

// 客户端调用
const root = new Folder("根目录");
const folder1 = new Folder("文档");
const folder2 = new Folder("图片");

const file1 = new File("简历.doc");
const file2 = new File("照片.jpg");
const file3 = new File("logo.png");

root.add(folder1);
root.add(folder2);
folder1.add(file1);
folder2.add(file2);
folder2.add(file3);

root.print();
// 输出:
// + 根目录
//   + 文档
//     - 简历.doc
//   + 图片
//     - 照片.jpg
//     - logo.png

享元模式(Flyweight)

享元模式(Flyweight Pattern)通过共享来高效地支持大量细粒度的对象。

前端中的享元模式场景

  • 事件委托:在父元素上绑定事件监听器,通过事件冒泡处理子元素的事件,避免为每个子元素绑定监听器,节省内存。
  • 对象池:在游戏开发或复杂动画中,预先创建一组对象放入池中,使用时取出,用完归还,避免频繁创建和销毁对象。
  • DOM复用:在长列表滚动(虚拟滚动)中,只渲染可视区域的 DOM 节点,回收并复用移出可视区域的节点。

享元模式-JS实现

// 享元工厂
class ShapeFactory {
  constructor() {
    this.circleMap = {};
  }

  getCircle(color) {
    if (!this.circleMap[color]) {
      this.circleMap[color] = new Circle(color);
      console.log(`创建新的 ${color} 圆形`);
    }
    return this.circleMap[color];
  }
}

// 具体享元类
class Circle {
  constructor(color) {
    this.color = color;
  }

  draw(x, y) {
    console.log(`在 (${x}, ${y}) 画一个 ${this.color} 的圆形`);
  }
}

// 客户端调用
const factory = new ShapeFactory();

const redCircle1 = factory.getCircle("红色");
redCircle1.draw(10, 10);

const redCircle2 = factory.getCircle("红色");
redCircle2.draw(20, 20);

const blueCircle = factory.getCircle("蓝色");
blueCircle.draw(30, 30);

console.log(redCircle1 === redCircle2); // true
// 输出:
// 创建新的 红色 圆形
// 在 (10, 10) 画一个 红色 的圆形
// 在 (20, 20) 画一个 红色 的圆形
// 创建新的 蓝色 圆形
// 在 (30, 30) 画一个 蓝色 的圆形
// true

项目地址

❌