Riact开发笔记之设计模式篇

概述

合理地使用一些设计模式能使极大提升代码的可维护性和可扩展性,本文将介绍几个在Riact开发过程中用到的设计模式,重点将放在这些模式在Riact中的使用场景,而非讨论其实现。

主要设计模式

单例模式

单例模式的实现非常简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 代码1. JavaScript中的单例模式
const Singleton = (function() {
class Clazz {
constructor() {
// do something
}
}
let inst;
return {
getInstance() {
return inst = inst || new Clazz();
}
};
})();

当然,单例还有很多其他实现方式,不再一一举例,其本质就是就是利用创建闭包的方式实现将一个唯一实例的引用隔离开来,从这个角度上来说,ES6规范中的每一个模块(值得引用)都是一个单例。

工厂模式

Riact只支持函数组件,但由于函数组件本身只是一个返回JSX结构的函数(类似于React中的render),Riact的内部对它还是将其当做一个类组件去实现,每一次虚拟DOM更新时发现了一个函数组件,就会将该函数传入到一个指定的地方,然后要求返回一个对应的类。这个指定的地方就是工厂函数。

1
2
// 代码2. 传入函数,要求返回类
const ClassComp = AppContext.getComponent(renderComp);

AppContext的内部,Riact维护了一个以函数为键,以类为值的散列表,当更新的过程中需要遇到函数组件时,会调用getComponent方法,该方法会去散列表中查找,如果存在对应的类则直接返回,如果不存在则生成对应的类,将其存入散列表,然后返回。

这里的工厂模式是简单工厂。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 代码3. 组件工厂
abstract class AppContext implements Riact.IAppContext {
// ...

/**
* register component declaration
*/
private componentDeclarationMap: Map<Riact.TFuncComponent, typeof Component>;

public getComponent(render: Riact.TFuncComponent): typeof Component {
if (this.componentDeclarationMap.has(render)) {
return this.componentDeclarationMap.get(render);
} else {
const TargetComponent: typeof Component = componentFac(render);
this.componentDeclarationMap.set(render, TargetComponent);
return TargetComponent;
}
}
}

观察者模式

Riact中提供和React中几乎完全相同的Context设计。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 代码4. Riact中Context的用法
import Riact, { useContext } from 'riact';

const UsernameContext = Riact.createContext('Ouyang');

function Show () {
const username = useContext(UsernameContext);
return <span>{username}</span>
}

function Wrapper () {
return <div><Show /></div>;
}

function App () {
return <UsernameContext value={"Ouyang"}><Wrapper /></UsernameContext>;
};

React16之前的Context设计存在一定的问题,隔代传递Context内容会被生命周期方法shouldComponentUpdate给阻断,这主要是因为在原来的设计中,Context也是逐层向下传递的,而React16之后的Context则修复了这个问题。

在Riact中,使用了观察者模式,也就是提供Context的组件会绕开层层传递,直接对ContextConsumer组件(也就是观察了Context的组件)进行更新。

在实现的过程中,Riact使用了Set对观察者进行存储,这是由于ContextConsumer组件在被销毁的时候需要在ContextProvider处进行注销(否则就会一直被引用,导致内存泄露),也就是需要查找到ContextConsumer并移除,如果使用传统的列表方式存储ContextConsumer组件,则在删除的时候需要先找到组件的下标再将其进行删除,这是一个$\mathrm{O}(\log n)$的操作,而JavaScript的Set中维护了一个对元素的散列表,查找的效率为$\mathrm{O}(1)$。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 代码5. ContextProvider部分代码
class Provider extends Component implements IContextProvider {
// initialized in constructor
private decendantConsumers: Set<Consumer>;

public subscribe(consumer: Consumer): Riact.TFunction {
const { decendantConsumers }: Provider = this;
decendantConsumers.add(consumer);
return (): void => {
decendantConsumers.delete(consumer);
};
}
// ......
}

策略模式

JavaScript中的策略模式非常简单。

1
2
3
4
5
6
7
8
9
10
// 代码6. JS中策略模式最简单的实现方式
const strategies = {
s1 (...args) {
// do something
},
s2 (...args) {
// do something
}
// ...
};

策略的使用者可以通过调用不同的方法达到同级逻辑的不同实现(比如,验证是否是合法的手机号码可以是一种策略,验证是否是合法的邮箱又是一种策略)。

策略模式还有一种面向对象的实现,它看上去更规范和优雅一些,Riact也采用了这种实现方式。

Riact采用TypeScript实现,虽然TypeScript一再强调它是JavaScript的超集,但在使用过程中笔者还是建议将其当做一个独立的语言去用,而不应该绕过TS的类型检查,这样做的也失去了使用TS的意义。TypeScript从语法层面上提供了更好的面向对象特性,它不仅支持接口(Interface),还有抽象类(Abstract Class)、和泛型(Generic Type),这极大程度上规范了(同时也积极意义上地限制了)我们的代码。虽然本文的主要内容并不是讨论TS,但从设计模式的角度来看,TS下的实现和JS的实现还是有一些不同的,策略模式就是如此。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 代码7. TS中面向对象策略模式的实现
interface Verifiabl {
verify(str: string): boolean;
}

class VerifyEmail implements Verifiable {
verify(str: string): boolean {
// return if str is a valid email address
}
}

class VerifyPhone implements Verifiable {
verify(str: string): boolean {
// return if str is a valid phone number
}
}

// ...

这样使用者只需要持有一个Verifiable类型的变量,通过赋予不同的实现即可。

在Riact中,策略模式被用于Diff算法和Patch更新真实DOM的实现。

Diff算法篇的一开始曾提到,Riact最初选用的是React16之前的Diff算法,后来才更换成Inferno的算法,所以Riact中有两种实现,虽然目前React的算法已经被抛弃,没有任何地方在用,但Riact之前的alpha版本和beta版本中,有一段时间是两个算法并存的,所以这是一个策略模式的使用场景。

除此之外,Patch更新也是一个应用场景,对于一个节点的更新,可能会有更新属性,被新节点替换,重新排序等操作,这里也被抽象成了一个策略模式的实现。

依赖注入

依赖注入(Dependency Injection, DI),也叫控制反转(Inversion of Control, IoC)。也许有些同学认为这不是一种设计模式,毕竟在23种常见的设计模式(这个说法可能来自Java)中,没有依赖注入这么一说。这里不纠结于它是不是一种被人认可的典型设计模式概念,实际上,依赖倒转本身也是设计模式的原则之一,本文姑且把它放到一起讲。

以前Java的初中级工程师的面试特别喜欢问Spring框架中的IoC(几乎是必问),不知道现在是不是还这样。

大多数前端的同学可能对这个名词比较陌生,用一句话来解释就是,当一个对象依赖于另一个对象时,后者不应该在有前者来创建,而应该在外部将后者注入进去,当然,这么绕口的话肯定又是笔者自己的总结。

为了便于理解,我们将结合代码7一起来看这种设计的实现,本身依赖注入结合策略模式的实现是一种非常优雅的解耦方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 代码8. 策略的使用
class MyForm {
private value: string;
private verifiable: Verifiable = new VerifyEmail(); // or VerifyPhone
constructor () {
// initialize your form
}

// a value setter here, maybe?

public submit(): void {
if (this.verifiable.verify(this.value)) {
// submit
} else {
// illegal value
}
}
}

// main
const mf: MyForm = new MyForm();
// ...
mf.submit();

这样MyForm依赖于Verifiable,所以在MyForm的对象初始化的时候,我们就已经将其构建了。这样MyFormVerifiable就耦合了。

为了解耦,我们可以在创建MyForm对象的时候先不管Verifiable,等到具体调用的时候,再将其设置进来。

所以这里修改代码如下。

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
27
28
// 代码9. 依赖注入
class MyForm {
private value: string;
private verifiable: Verifiable; // no idea what kind of implementation will be.
constructor () {
// initialize your form
}

// a value setter here, maybe?

public setVerifiable(verifiable: Verifiable): void {
this.verifiable = verifiable;
}

public submit(): void {
if (this.verifiable.verify(this.value)) {
// submit
} else {
// illegal value
}
}
}

// main
const mf: MyForm = new MyForm();
mf.setVerifiable(new VerifyPhone());
// ...
mf.submit();

代码9中,MyForm对象在创建的时候并不知道具体该以哪种策略去验证表单值,MyForm对象的初始化并不依赖于Verifiable的具体实现,这样,我们通过将依赖注入的方式,将二者解耦了出来。

依赖注入这么高大上的概念,看上去好像很简单对不对?实际上……它就是这么简单,至于Java中一般用Annotation或者在TypeScript中用Decorator去实现,都只是批了一层皮而已。

在Riact中,两个用到了策略模式的地方都结合了依赖注入。

对于一个虚拟DOM节点而言,它将来可能的更新操作会是各种各种的,但它并不关心更新操作的具体实现,它只需要知道自己有一个patchable的属性并且在合适的时候调用this.patchable.run()就可以了,patchable的创建是从外部注入进来的。diffable属性的设计同样也是如此。

总结

本文主要介绍了Riact中比较基础和关键的几种设计模式,分别介绍它们的实现和应用场景。

除了上述设计模式之外,另外还有一个享元模式(Flyweight Pattern),但Riact中用得很简单,而且关键的地方都是好几种设计模式都是结合在一起用的,这里也就不再多介绍了。

相对来说设计模式这种东西,在平时业务的开发中能用到的场景比较少(现在像什么输入文本的正则匹配这种,不少工具库都帮你实现了)。

我自己的对设计模式的看法是,在代码量不大,也没有迫切的使用场景下,不用也没有太大关系,比如Riact的VirtualNode类,一开始所有类型的更新都是放到一起的,函数的行数多一点也没有关系,我还能维护起来,但是后来这个类越来越庞大(超过一千行了),我才开始将对其进行瘦身,将Diff算法和Patch模块剥离出来。

类似于这样的一个项目,很多时候是做着做着,自然而然就会出现这种需求,整个程序就成长(Grow)起来了。

参考

  1. oychao/riact - GitHub
  2. Riact开发笔记之Diff算法篇 - 传不习乎
  3. Dependecy Injection - wikipedia