高级 Angular 组件模式 (5)


05 Handle Template Reference Variables with Directives

原文: Handle Template Reference Variables with Directives

在之前的例子中,已经出现多次使用template reference variable(模板引用变量)的场景,现在让我们来深入研究如何通过使用模板引用变量来关联某个具体指令。

目标

在视图模板内,获取一个指令的引用。

实现

模板引用变量是获取某个元素、组件或者指令引用的一种方式,这个引用可以在当前的视图模板中的任何地方使用。它们通常是以#baseToggle或者#myToggle="toggle"的语法声明的。一旦声明,在视图模板的任何地方就可以使用它。

Note: 请注意作用域的问题,如果你使用<ng-template>或者是一个结构性指令,比如*ngIf或者*ngFor,它会在这个模板上创建一个新的作用域,之后在其内部声明的模板引用变量无法在该模板作用域以外使用。

模板引用变量的解析顺序通常为:

  1. 一个指令或者组件通过它自身的exportAs属性,比如#myToggle="toggle"
  2. 声明于以自定义标签存在的组件,比如<toggle-on #toggleOn></toggle-on>
  3. 原生html元素,并且没有任何组件绑定与它,比如<div #someDiv></div>

之后我们来分别看3个例子。

指令与exportAs

指令可以在它的元数据中声明exportAs属性,这个属性表示它被这个模板引用变量所标识,如下:

1
2
3
4
5
6
// toggle.directive.ts
@Directive({
selector: '[toggle]',
exportAs: 'toggle',
})
export class ToggleDirective { ... }

之后我们可以在视图模板中直接使用toggle来获取指令的引用,如下:

1
2
3
// app.component.html
<div toggle #myTemplateRefVar="toggle"></div>
// myTemplateRefVar is the ToggleDirective

组件

对于每一个html元素,只会有一个组件与之对应。当一个组件绑定于一个元素时,那么声明的模板引用变量将会被解析为当前元素上所绑定的组件,比如:

1
2
3
// app.component.html
<toggle-on #toggleOn></toggle-on>
// toggleOn is the ToggleOnComponent

HTML元素

如果没有组件与元素绑定,模板引用变量会指向当前这个html元素。

1
2
3
// app.component.html
<div #someDiv></div>
// someDiv is an HTMLDivElement

成果

Note: 在stackblitz中,我通过打印模板引用变量所指向的类的名字(constructor.name)来演示它所代表的引用。

https://stackblitz.com/edit/adv-ng-patterns-05-template-ref-variables

译者注

这篇文章作者关于模板引用变量,仅仅介绍了关于如何声明和在视图模板中如何使用,我在这里再补充一些,如何在组件或者指令类的内部使用。

在类内部获取模板引用变量所指向的引用是通过使用ViewChild装饰器完成的,比如上述文章中的第二个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component({
selector: 'my-app',
template: `
<div #myDiv></div>
`,
})
export class AppComponent {
@ViewChild('myDiv')
myDiv: ElementRef;

ngAfterViewInit() {
console.log(this.myDiv);
}
}

这里的myDiv即指向当前模板引用变量所指向的html元素。

Note: 在类中获取模板引用变量所指向的引用时,请格外注意你期望获取的引用类型,在例子中,我们期望获取html元素,因此这里的引用类型是ElementRef,如果是指令或者组件,则分别要对应其类型的Type

more

javascript原型链-review


写在前面

虽然现在es8都已经在预发布阶段了,但是无论发布到es几,其本身的运作原理都是一样的。

首先祭上一张图, 这张图主要描述了以下的关系,如果觉的这里的说明过于复杂可以直接看最后一段

简单说明

关于function(class)A和它的原型之间的关系

A.prototype.constructorA等价

关于function(class)A的实例a与它的原型之间的关系

a.__proto__A.prototype等价

在上面两个等价条件的基础上,就可以很容易得到

a.__proto__.prototype.constructorA等价

这是一般的类和对象实例之间的原型继承关系。

在此基础上,对于ObjectFunction还有一些特殊的关系。

关于function(class)AFunction之间的关系

A.__proto__Function.prototype等价

关于function(class)A的原型和Object之间的关系

A.prototype.__proto__Object.prototype等价

关于Function的原型和Object之间的关系

Function.prototype.__proto__Object.prototype等价

所以也可以很容易知道

A.__proto__.__proto__Object.prototype等价

Object对象比较特殊,因为它是所有对象的根,所以约定它的原型所指向的原型对象为空

Object.prototype.__proto__null等价

同时javascript中一切皆为对象,但Object本身是一个构造函数,因此它本身的原型对象指向Function.prototype

Object.__proto__Function.prototype

总结

所以无论是es5风格的继承还是es6风格的继承语法,原型链的形成是都是通过__proto__prototype描述的,举个例子,这里使用es6, 即:

如果有:

1
2
class B extends A {}
const a = new B()

则有:

  • a.__proto__ === B.prototype
  • B.prototype.constructor.__proto__ === B.__proto__
  • B.__proto__ === A
  • A.prototype.constructor.__proto === A.__proto__
  • A.__proto__ === Function.prototype
  • Function.prototype.__proto__ === Object.prototype
  • Object.prototype.__proto__ === null

其他的以此类推。

more

【译】Understanding SOLID Principles - Liskov Substitution Principle


Understanding SOLID Principles: Liskov Substitution Principle

这是理解SOLID原则,关于里氏替换原则为什么提倡我们面向抽象层编程而不是具体实现层,以及为什么这样可以使代码更具维护性和复用性。

什么是里氏替换原则

Objects should be replaceable with instances of their subtypes without altering the correctness of that program.

某个对象实例的子类实例应当可以在不影响程序正确性的基础上替换它们。

这句话的意思是说,当我们在传递一个父抽象的子类型时,你需要保证你不会修改任何关于这个父抽象的行为和状态语义。

如果你不遵循里氏替换原则,那么你可能会面临以下问题:

  • 类继承会变得很混乱,因此奇怪的行为会发生
  • 对于父类的单元测试对于子类是无效的,因此会降低代码的可测试性和验证程度

通常打破这条原则的情况发生在修改父类中在其他方法中使用的,与当前子类无关联的内部或者私有变量。这通常算得上是一种对于类本身的一次潜在攻击,而且这种攻击可能是你在不经意间自己发起的,而且不仅在子类中。

反面例子

让我们通过一个反面例子来演示这种修改行为和它所产生的后果。比如,我们有一个关于Store的抽象类和它的实现类BasicStore,这个类会储存一些消息在内存中,直到储存的个数超过每个上限。客户端代码的实现也很简单明了,它期望通过调用retrieveMessages就可以获取到所有储存的消息。

代码如下:

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
interface Store {
store(message: string);
retrieveMessages(): string[];
}

const STORE_LIMIT = 5;

class BasicStore implements Store {
protected stash: string[] = [];
protected storeLimit: number = STORE_LIMIT;

store(message: string) {
if (this.storeLimit === this.stash.length) {
this.makeMoreRoomForStore();
}
this.stash.push(message);
}

retrieveMessages(): string[] {
return this.stash;
}

makeMoreRoomForStore(): void {
this.storeLimit += 5;
}
}

之后通过继承BasicStore,我们又创建了一个新的RotatingStore实现类,如下:

1
2
3
4
5
class RotatingStore extends BasicStore {
makeMoreRoomForStore() {
this.stash = this.stash.slice(1);
}
}

注意RotatingStore中覆盖父类makeMoreRoomForStore方法的代码以及它是如何隐蔽地改变了父类BasicStore关于stash的状态语义的。它不仅修改了stash变量,还销毁了在程序进程中已储存的消息已为将来的消息提供额外的空间。

在使用RotatingStore的过程中,我们会遇到一些奇怪的现象,这正式由于RotatingStore本身产生的,如下:

1
2
3
4
5
6
7
8
9
10
11
const st: Store = new RotatingStore()

st.store("hello")
st.store("world")
st.store("how")
st.store("are")
st.store("you")
st.store("today")
st.store("sir?")

st.retrieveMessages() // 一些消息丢失了

一些消息会无故消失,当前这个类的表现逻辑与所有消息均可以被取出的基本需求不一致。

如何实践里氏替换原则

为了避免这种奇怪现象的发生,里氏替换原则推荐我们通过在子类中调用父类的公有方法来获取一些内部状态变量,而不是直接使用它。这样我们就可以保证父类抽象中正确的状态语义,从而避免了副作用和非法的状态转变。

它也推荐我们应当尽可能的使基本抽象保持简单和最小化,因为对于子类来说,有助于提供父类的扩展性。如果一个父类是比较复杂的,那么子类在覆盖它的时候,在不影响父类状态语义的情况下进行扩展绝非易事。

对于内部系统做可行的后置条件检查也是一个不错的方式,这种检查通常会验证是否子类会搅乱一些关键代码的运行路径(译者注:也可以理解为状态语义),但是我本身对这个实践并没有太多的经验,所以无法给予具体的例子。

代码评论也可以一定程度上给予好的帮助。当你在开发一些你可能无意间做出一些对已有系统的破坏,但是你的同事可能会很容易地发现这些(当局者迷旁观者清)。软件设计保持一致性是一件十分重要的事情,因此应当尽早、尽可能多地查明那些对对象继承链作出潜在修改的代码。

最后,在单一职责原则中,我们曾提及,考虑使用组合模式来替换继承模式

总结

正如你所看到的,在开发软件时,我们往往需要额外花一些努力和精力来使它变得更好。将这些原则牢记于心,理解它们所存在的意义以及它们想要解决的问题,这样会使你的工作变得更加容易、更具条理性,但是同时记住,这并不是一件容易的事,相反,你应当在构思软件时,花相当多的事件思考如何更好地实践这些原则。

试着让自己设计的软件系统具备可适应性,这种适应性可以抵御各种不利的变化以及潜在的错误,这样自然而然地可以使你少加班和早回家(译者注:看来加班是每个程序员都要面临的问题啊)

译者注

这是SOLID原则中我所接触和了解较少的一个原则,但经过仔细思考后,发现其实我们还是经常会在实际工作中运用它的。

在许多面向相对的编程语言中,关于对象的继承机制中,都会提供一些内部变量和状态的修饰符,比如public(公有)protect(保护)private(私有),关于这些修饰符本身的异同这里不再赘述,我想说的是,这些修饰符存在必然有它存在的意义,一定要在实际工作中,使用它们。之前做java后端时,经常在公司的项目的历史代码中发现,很少使用protectprivate对类内部的方法和变量做约束,可见当时的编写者并没有对类本身的职能有一个清晰的认识,又或者是随着时间一步步迭代出来的结果。

那么问题来了,一些静态语言有这些修饰符,但是像javascript这种鸭子类型语言怎么办呢?其实没有必要担心,最早开始学前端的时候,这个问题我就问过自己无数次,javascript虽然没有这些修饰符,但是我们可以通过别的方式来达到类似的效果,或者使用typescript

除了在编程语言层面,在前端实际工作中,你可能会听到一个叫作immutable的概念,这个概念我认为也是里氏替换原则的一直延伸。因为当前的前端框架一般提倡的理念均是f(state) => view,即数据状态代表视图,而数据状态本身由于javascript动态语言的特性,很容易会在不经意间被修改,一旦存在这种修改,视图中便会产生一些意想不到的问题,因此immutable函数式的概念才会在前段时间火起来。

写在最后

经过这五篇文章,我们来分别总结一下这五条基本原则以及它们带来的好处:

  • 单一职责原则:提高代码实现层的内聚度,降低实现单元彼此之间的耦合度
  • 开闭原则:提高代码实现层的可扩展性,提高面临改变的可适应性,降低修改代码的冗余度
  • 里氏替换原则:提高代码抽象层的可维护性,提高实现层代码与抽象层的一致性
  • 接口隔离原则:提高代码抽象层的内聚度,降低代码实现层与抽象层的耦合度,降低代码实现层的冗余度
  • 依赖倒置原则:降低代码实现层由依赖关系产生的耦合度,提高代码实现层的可测试性

可以注意到我这里刻意使用了降低/提高 + 实现层/抽象层 + 特性/程度(耦合度、内聚度、扩展性、冗余度、可维护性,可测试性)这样的句式,之所以这么做是因为在软件工作中,我们理想中的软件应当具备的特点是, 高内聚、低耦合、可扩展、少冗余、可维护、易于测试,而这五个原则也按正确的方向,将我们的软件系统向我们理想中的标准推进。

为了便于对比,特别绘制了下面的表格,希望大家从真正意义上做到将这些原则牢记于心,并付诸于行。

原则 耦合度 内聚度 扩展性 冗余度 维护性 测试性 适应性 一致性
单一职责原则 - + o o + + o o
开闭原则 o o + - + o + o
里氏替换原则 - o o o + o o +
接口隔离原则 - + o - o o + o
依赖倒置原则 - o o - o + + o

Note: +代表增加, -代表降低, o代表持平

more

【译】Understanding SOLID Principles - Interface Segregation Principle


Understanding SOLID Principles: Interface Segregation Principle

这是理解SOLID原则,关于接口隔离原则如何帮助我们创建简单的抽象接口,并使客户端代与接口之间存在的更少的依赖关系。

接口隔离原则是什么

Clients should not be forced to depend on methods that they do not use.

客户端代码不应当被迫依赖于它们不需要的方法。

这个原则本身与单一职责原则关系十分紧密,它意味着当你在定义你的抽象层代码时,不应当在客户端代码在实现抽象逻辑时,暴露一些客户端代码不需要使用或者关心的方法。

进一步说明的话,就是当你有意地在抽象层中暴露的方法时,这意味着所有实现这些抽象逻辑的客户端代码都必须要实现所有的抽象方法,尽管这些方法并不一定都对客户端代码有意义。

将你的接口的保持精简和小颗粒度,并且不要在它们中间增加无用的抽象方法,当你在对新的抽象接口进行命名时,你就会拥有更好的选择,因为你已有了若干小颗粒的命名类型。这样做的意义在于当你在需要提供一个更加大颗粒度的抽象接口时,你可以拥有足够的灵活性来将已有的小颗粒度接口进行组合。

如何实践接口隔离原则

这个例子是关于一个ATM用户界面的抽象接口,这个接口会处理诸如存款请求、取款请求等逻辑,从这个例子中我们会了解到,我们如何对这个接口进行隔离,使其进一步划分为多个独立的、更加具体的若干接口。

首先我们应当有一个工具函数库接口,这个接口会描述我们想要暴露的关于byte操作逻辑的方法,让我们创建这样一个接口,如下

1
2
3
4
5
type ByteUtils interface {
Read(b []byte) (n int, err error) // Read into buffer
Write(b []byte)(n int, err error) // Write into buffer
Trim(b []byte, exclusions string)[]byte // Trim buffer by removing bytes from the exclusion chars
}

它可以正常工作一段时间,但是很快我们就会发现以下两个问题:

  • 它的命名ByteUtils太过于通用,如果我们仅通过命名本身,基本无法获取任何具体的信息
  • 当使用它时,会有一些古怪的感觉,因为当你根据不同的优化场景来按不同逻辑实现trim方法时,你所实现的readwrite几乎没什么差别,但是你却需要重复地实现它们,同时在某些不需要读或者写的场景,仍然需要实现它们。

所以它虽然能够正常工作,但是却不够好。

我们可以通过创建三个更精简、更具体的接口来替代原先通用的接口:

1
2
3
4
5
6
7
8
9
type Reader interface {
Read(b []byte) (n int, err error)
}
type Writer interface {
Write(b []byte)(n int, err error)
}
type Trimmer interface {
Trim(b []byte, exclusions string)[]byte
}

这种颗粒度比较细的接口也可以称为角色接口,因为它们更易于重构和改变,甚至对于已经定义好的角色和目的也可以很容易的进行重新部署和定义。

在这三个基础上,我们可以通过组合它们来获取一个更有关联性的接口列表,比如:

1
2
3
4
5
6
7
8
type ReadWriter interface {
Reader
Writer
}
type TrimReader interface {
Trimmer
Reader
}

这意味客户端代码拥有了可以根据它们各自的需求来组合抽象层接口的灵活性,这样就会避免在实现抽象接口时不必要的麻烦(比如必须要实现某些无用的方法),比如上面的TrimReader的实现并未包含多余的Write方法的声明。

总结

正如你所看到的,通用的接口往往会无意识的将自己和类的实现耦合在了一起,所以你应当尽量的避免这种情况的发生。在设计接口时,你应当时刻提醒自己,我是否需要使用所有在接口中声明的方法呢?如果不是的话,将接口细分为更多个更精简、更具体的接口。

正如甘地曾经说过:

你的行动决定你的习惯,你的习惯决定你的价值,你的价值会决定你的命运。

如果在架构中,你每次都会经过仔细思考,会按照好的模式来进行设计,它将会成为一种习惯,自然慢慢会转变为你的价值或者原则,最终则会成为你的命运,比如成为了一个始终给予完善解决方案的软件架构师。

我的观点是,始终通过挑战自己来变的更好,在某些时刻,你可能会遇到问题,但是往往你可能已经拥有了答案。

Happy coding!

译者注

对于接口隔离原则的理解,我一直觉的它本身其实是单一职责原则的一个扩展,但是它们之间也有细微的不同:

  • 单一职责原则往往面向实现层,比如具体的类或者某个方法
  • 接口隔离原则往往面向抽象层,比如一些抽象类或者抽象方法

所以将两个原则结合起来看的话,可以很容器得到当时提出这两个原则的人的意图,那就是一定要时刻保持简单

在实际工作中,我深知保持简单是一件十分困难的事情,因为工程师本身的使命便是解决问题,而问题往往充满了未知性,而未知性往往代表着改变,这还没有考虑到在项目实施过程中,产品经理天马行空的设计思路,客户们五花八门的需求等等。在这些外界条件下,我们的代码往往会变得复杂无比,充满了各种反模式和冗余代码,最终会使自己陷入无尽的bug修复和维护工作中,怎么还会有时间进行自我提升呢?

所以,为了能够按时下班,为了能够及早回家,为了能够让我们的拥有更多的时间来提升自己和陪伴家人,在软件设计之初,尽可能地针对将来所面临的改变,在设计层面降低软件抽象模块间的耦合程度,在项目实施时,提高每个具体实现模块内部的内聚程度,同时使它们保持简单,这样便是一个好的开始。

more

【译】Understanding SOLID Principles - Open Closed Principle


Understanding SOLID Principles: Open Closed Principle

这是理解SOLID原则,介绍什么是开闭原则以及它为什么能够在对已有的软件系统或者模块提供新功能时,避免不必要的更改(重复劳动)。

开闭原则是什么

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

软件实体(类、模块、函数等)都应当对扩展具有开放性,但是对于修改具有封闭性。

首先,我们假设在代码中,我们已经有了若干抽象层代码,比如类、模块、高阶函数,它们都仅做一件事(还记得单一职责原则吗?),并且都做的十分出色,所以我们想让它们始终处于简洁、高内聚并且好用的状态。

但是另一方面,我们还是会面临改变,这些改变包含范围(译者注:应当是指抽象模块的职责范围)的改变,新功能的增加请求还有新的业务逻辑需求。

所以对于上面我们所拥有的抽象层代码,在长期想让它处于一成不变的状态是不现实的,你不可避免的会针对以上的需要作出改变的需求,增加更多的功能,增加更多的逻辑和交互。在上一篇文章,我们知道,改变会使系统复杂,复杂会促使模块间的耦合性上升,所以我们迫切地需要寻找一种方法能够使我们的抽象模块不仅可以扩大它的职责范围,同时还能够保持当前良好的状态(简洁、高内聚、好用)。

这便是开闭原则存在的意义,它能够帮助我们完美地实现这一切。

如何实践开闭原则

当你需要对已有代码作出一些修改时,请切记以下两点:

  • 保持函数、类、模块当前它们本身的状态,或者是近似于它们一般情况下的状态(即不可修改性)
  • 使用组合的方式(避免使用继承方式)来扩展现有的类,函数或模块,以使它们可能以不同的名称来暴露新的特性或功能

这里关于继承,我们特意增加了一个注释,在这种情况下使用继承可能会使模块之间耦合在一起,同时这种耦合是可避免的,我们通常在一些预先有着良好定义的结构上使用继承。(译者注:这里应该是指,对于我们预先设计好的功能,推荐使用继承方式,对于后续新增的变更需求,推荐使用组合方式)

举个例子(译者注:我对这里的例子做了一些修改,原文中并没有详细的说明)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface IRunner {
run: () => void;
}
class Runner implements IRunner {
run(): void {
console.log("9.78s");
}
}

interface IJumper {
jump: () => void;
}
class Jumper implements IJumper {
jump(): void {
console.log("8.95,");
}
}

例子中,我们首先声明了一个IRunner接口,之后又声明了IJumper,并分别实现了它们,并且实现类的职能都是单一的。

假如现在我们需要提供一个既会跑又会跳的对象,如果我们使用继承的方式,可以这么写

1
2
3
class RunnerAndJumper extends Runner {
jump: () => void
}

或者

1
2
3
class RunnerAndJumper extends Jumper {
run: () => void
}

但是使用继承的方式会使这个RunnerAndJumperRunner(或者Jumper)耦合在一起(耦合在一起的原因是因为它的职责不再单一),我们再来用组合的方式试试看,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class RunnerAndJumper {
private runnerClass: IRunner;
private jumperClass: IJumper;
constructor(runner: IRunner, jumper: IJumper) {
this.runnerClass = new runner();
this.jumperClass = new jumper();
}
run() {
this.runnerClass.run();
}
jump() {
this.jumperClass.jump();
}
}

我们在RunnerAndJumper的构造函数中声明两个依赖,一个是IRunner类型,一个是IJumper类型。

最终的代码其实和依赖倒置原则中的例子很像,而且你会发现,RunnerAndJumper类本身并没有与任何别的类耦合在一起,它的职能同样是单一的,它是对一个即会跑又会跳的实体的抽象,并且这里我们还可以使用DI(依赖注入)技术进一步的优化我们的代码,降低它的耦合度。

反思

开闭原则所带来最有用的好处就是,当我们在实现我们的抽象层代码时,我们就可以对未来可能需要作出改变的地方拥有一个比较完整的设想,这样当我们真正面临改变时,我们所对原有代码的修改,更贴近于改变本身,而不是一味的修改我们已有的抽象代码。

在这种情况下,由于我们节省了不必要的劳动和时间,我们就可以将更多的精力投入到关于更加长远的事宜计划上面,而且可以针对这些事宜需要作出的改变,提前和团队沟通,最终给予一套更加健壮、更符合系统模块本身的解决方案。

在整个软件开发周期中(比如一个敏捷开发周期),你对于整个周期中的事情了解的越透彻、越多,则越好。身为一个工程师,在一个开发冲刺中,为了在冲刺截止日期结束前,实现一个高效的、可靠的系统,你不会期望作出太多的改变,因此往往你可能会“偷工减料”。

从另一个角度来讲,我们也应当致力于在每一次面临需求变更的情况下,不需要一而再,再而三的更改我们已有的代码。所有新的功能都应当通过增加一个新的组合类或方法实现,或者通过复用已有的代码来实现。

插件与中间件

充分贯彻开闭原则的另一个例子,便是插件与中间件架构,我们可以从三个角度来简单分析这种架构是如何运作的:

  • 内核或者容器:往往是核心功能的实现的前提,一般会成为整个系统最核心的部分
  • 插件:在实现容器的基础上,往往一些核心功能都是以内置的插件实现的,并且,通过实现一套通用的网关类接口,我们可以使插件具有可插拔性,这样在需要新增特性和功能时,只需要实现新的插件并添加到容器即可,比如支持插件扩展功能的浏览器Chrome
  • 中间件:中间件我们可以通过一个例子来说明,比如我们拥有一个请求 - 响应周期,我们可以通过中间件,在周期中添加中间业务逻辑,以便为应用程序提供额外的服务或横切关注点,比如Reduxexpress还有很多框架都支持这样的功能。

总结

希望这篇文章能够帮助你学会如何应用开闭原则并且从中收益。设计一个具有可组合性的系统,同时提供具有良好定义的扩展接口,是一种非常有用的技术,这种技术最关键的地方在于,它使我们的系统能够在保持强健的同时,提供新功能、新特性,但是却不会影响它当前的状态。

译者注

开闭原则是面向对象编程中最重要的原则之一,有多重要呢?这么说吧,很多的设计原则和设计模式所希望达成的最终状态,往往符合开闭原则,因此需要原则也都作为实现开闭原则的一种手段,在原文的例子中,我们可以很明显的体会到,在实现开闭原则所提倡的理念的过程中,我们不经意地使用之前两篇文章中涉及的原则,比如:

  • 保持对象的单一性(单一职责)
  • 实现依赖于抽象(依赖倒置原则)

我之前一直是做后端相关工作的,所以对于开闭原则接触较早,这两年转行做了前端,随着nodejs的发展,框架技术日新月异,但是其中脱颖而出的优秀框架往往是充分贯彻了开闭原则,比如expresswebpack还有状态管理容器redux,它们均是开闭原则的最佳实践。

另外一方面,在这两年的工作也感受到,适当的使用函数式编程的思想,往往是贯彻开闭原则一个比较好的开始,因为函数式的编程中的核心概念之一便是compose(组合)。以函数式描述业务往往是原子级的指令,之后在需要描述更复杂的业务时,我们复用并组合之前已经存在的指令以达到目的,这恰恰符合开闭原则所提倡的可组合性。

最后在分享一些前端中,经常需要使用开闭原则的最佳业务场景,

  • UI组件的表单组件:对于表单本身以容器来实现,表单项以插件来实现,这样对于表单项如何渲染、如何加载、如何布局等功能,均会封闭与表单容器中,而对于表单项如何校验、如何取值、如何格式化等功能,则会开放与表单项容器中。
  • API服务:一般我们可能会在项目中提供自定义修改请求头部的工具方法,并在需要的时候调用。但这其实是一种比较笨的方法,如果可能的话,建议使用拦截器来完成这项任务,不仅会提供代码的可读性,同时还会使发接口的业务层代码保持封闭。
  • 事件驱动模型:对于一些复杂的事件驱动模型,比如拖拽,往往使用开闭原则会达到意想不到的效果。最近有一个比较火的拖拽库draggable,提供的拖拽体验相比其他同类型的库简直不是一个级别。我前段时间去读它的源码,发现它之所以强大,是因为在它内部,针对多种拖拽事件,封装了独立的事件发射器(其内部称作Sensor),之后根据这些发射器指定了一套独立的抽象事件驱动模型,在这个模型基础上,针对不同的业务场景提供不同的插件,比如:
    • 原生拖拽(Draggable)
    • 拖拽排序(Sortable)
    • 拖拽放置(Droppable)
    • 拖拽交换(Swappable)

还有若干提高用户体验的其他插件,这一切均是以开闭原则而实现的。

more

【译】Understanding SOLID Principles - Single Responsibility


Understanding SOLID Principles: Single Responsibility

这是理解SOLID原则中,关于单一职责原则如何帮助我们编写低耦合和高内聚的第二篇文章。

单一职责原则是什么

之前的第一篇文章阐述了依赖倒置原则(DIP)能够使我们编写的代码变得低耦合,同时具有很好的可测试性,接下来我们来简单了解下单一职责原则的基本概念:

Every module or class should have responsibility over a single part of the functionality provided by the software, and that responsibility should be entirely encapsulated by the class.

每一个模块或者类所对应的职责,应对应系统若干功能中的某个单一部分,同时关于该职责的封装都应当通过这个类来完成。

往简单来讲:

A class or module should have one, and only one, reason to be changed.

一个类或者模块应当用于单一的,并且唯一的缘由被更改。

如果仅仅通过这两句话去理解, 一个类或者模块如果如果越简单(具有单一职责),那么这个类或者模块就越容易被更改是有一些困难的。为了便于我们理解整个概念,我们将分别从三个不同的角度来分析这句话,这三个角度是:

  • Single: 单一
  • Responsibility: 职责
  • Change: 改变

什么是单一

Only one; not one of several.

唯一的,而不是多个中的某个。

Synonyms: one, one only, sole, lone, solitary, isolated, by itself.

同义词:一,仅有的一个,唯一,独个,独自存在的,孤立的,仅自己。

单一意味着某些工作是独立的。比如,在类中,类方法仅完成某家独立的事情,而不是两件,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class UserComponent { 
// 这是第一件事情,获取用户详情数据
getUserInfo(id) {
this.api.getUserInfo(id).then(saveToState)
}

// 这是第二件事情,渲染视图的逻辑
render() {
const { userInfo } = this.state;
return <div>
<ul>
<li>Name: { userInfo.name }</li>
<li>Surname: { userInfo.surname }</li>
<li>Email: { userInfo.email }</li>
</ul>
</div>
}
}

看了上面的代码,你可能很快就会联想到,这些代码基本存在于所有的React组件中。

确实,对于一些小型的项目或者演示型项目,这样编写代码不会产生太大的问题。但是如果在大型或者复杂度很高的项目中,仍然按照这样的风格,则是一件比较糟糕的事情,因为一个组件往往做了它本不应当做的事情(承担了过多的职责)。

这样会带来什么坏处呢?比如对于以上的api服务,在将来的某天你做出了一些修改,增加了一些额外的逻辑,那么为了使代码能够正常工作,你至少需要修改项目中的两个地方以适应这个修改,一处修改是在API服务中,而另一处则在你的组件中。如果进一步思考的,我们会发现,修改次数与在项目直接使用API服务的次数成正比,如果项目足够复杂,足够大,一处简单的逻辑修改,就需要做出一次贯穿整个系统的适配工作。

那么我们如果避免这种情况的发生呢?很简单,我们仅仅需要将关于用户详情数据的逻辑提升到调用层,在上面的例子中,我们应当使用React.component.prop来接受用户详情数据。这样,UserComponent组件的工作不再与如何获取用户详情数据的逻辑耦合,从而变得单一

对于鉴别什么是单一,什么不是单一,有很多不同的方式。一般来说,只需要牢记,让你的代码尽可能的少的去了解它已经做的工作。(译者注:我理解意思应当是,应当尽可能的让已有的类或者方法变得简单、轻量,不需要所有事情都亲自为之)

总之,不要让你的对象成为上帝对象

A God Object aka an Object that knows everything and does everything.

上帝对象,一个知道一切事情,完成一切事情的对象。

In object-oriented programming, a God object is an object that knows too much or does too much. The God object is an example of an anti-pattern.

在面向对象编程中,上帝对象指一个了解太情或者做太多事情的对象。上帝对象是反模式的一个典型。

什么是职责

职责指软件系统中,每一个指派给特定方法、类、包和模块所完成的工作或者动作。

Too much responsibility leads to coupling.

太多的职责导致耦合。

耦合性代表一个系统中某个部分对系统中另一个部分的了解程度。举个例子,如果一段客户端代码在调用class A的过程中,必须要先了解有关class B的细节,那么我们说AB耦合在了一起。通常来说,这是一件糟糕的事情。因为它会使针对系统本身的变更复杂化,同时会在长期越来越糟。

为了使一个系统到达适当的耦合度,我们需要在以下三个方面做出调整

  • 组件的内聚性
  • 如何测量每个组件的预期任务
  • 组件如何专注于任务本身

低内聚性的组件在完成任务时,和它们本身的职责关联并不紧密。比如,我们现在有一个User类,这个类中我们保存了一些基本信息:

1
2
3
4
5
6
class User {
public age;
public name;
public slug;
public email;
}

对于属性本身,如果对于每个属性声明一些getter或者setter方法是没什么问题的。但是如果我们加一些别的方法,比如:

1
2
3
4
5
6
7
8
9
10
class User {
public age;
public name;
public slug;
public email;
// 我们为什么要有以下这些方法?
checkAge();
validateEmail();
slugifyName();
}

对于checkAgevalidateEmailslugifyName的职责,与Userclass本身关系并不紧密,因此就会这些方法就会使User的内聚性变低。

仔细思考的话,这些方法的职责和校验和格式化用户信息的关系更紧密,因此,它们应当从User中被抽离出来,放入到另一个独立的UserFieldValidation类中,比如:

1
2
3
4
5
6
7
8
9
10
11
12
class User {
public age;
public name;
public slug;
public email;
}

class UserFieldValidation {
checkAge();
validateEmail();
slugifyName();
}

什么是变更

变更指对于已存在代码的修改或者改变。

那么问题来了,什么原因迫使我们需要对源码进行变更?从众多过期的软件系统的历史数据的研究来看,大体有三方面原因促使我们需要作出变更:

  • 增加新功能
  • 修复缺陷或者bug
  • 重构代码以适配将来作出的变更

做为一个程序员,我们天天不都在做这三件事情吗?让我们来用一个例子完整的看一下什么是变更,比方说我们完成了一个组件,现在这个组件性能非常好,而且可读性也非常好,也许是你整个职业生涯中写的最好的一个组件了,所以我们给它一个炫酷的名字叫作SuperDuper(译者注:这个名字的意思是超级大骗子

1
2
3
4
5
class SuperDuper {
makeThingsFastAndEasy() {
// Super readable and efficient code
}
}

之后过了一段时间,在某一天,你的经理要求你增加一个新功能,比如说去调用别的class中的每个函数,从而可以使当前这个组件完成更多的工作。你决定将这个类以参数的形式传入构造方法,并在你的方法调用它。

这个需求很简单,只需要增加一行调用的代码即可,然后你做了以下变更(增加新功能)

1
2
3
4
5
6
7
8
9
class SuperDuper {
constructor(notDuper: NotSoDuper) {
this.notDuper = notDuper
}
makeThingsFastAndEasy() {
// Super readable and efficient code
this.notDuper.invokeSomeMethod()
}
}

好了,之后你针对你做的变更代码运行了单元测试,然后你突然发现这条简单的代码使100多条的测试用例失败了。具体原因是因为在调用notDuper方法之前,你需要针对一些额外的业务逻辑增加条件判断来决定是否调用它。

于是你针对这个问题又进行了一次变更(修复缺陷或者bug),或许还会针对一些别的边界条件进行一些额外的修复和改动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class SuperDuper {
constructor(notDuper: NotSoDuper) {
this.notDuper = notDuper
}
makeThingsFastAndEasy() {
// Super readable and efficient code

if (someCondition) {
this.notDuper.invokeSomeMethod()
} else {
this.callInternalMethod()
}
}
}

又过了一段时间,因为这个SuperDuper毕竟是你职业生涯完成的最棒的类,但是当前调用noDuper的方法实在是有点不够逼格,于是你决定引入事件驱动的理念来达到不在SuperDuper内部直接调用noDuper方法的目的。

这次实际是对已经代码的一次重构工作,你引入了事件驱动模型,并对已有的代码做出了变更(重构代码以适配将来作出的变更):

1
2
3
4
5
6
7
8
class SuperDuper {

makeThingsFastAndEasy() {
// Super readable and efficient code
...
dispatcher.send(actionForTheNotDuper(payload)) // Send a signal
}
}

现在再来看我们的SuperDuper类,已经和最原始的样子完全不一样了,因为你必须针对新的需求、存在的缺陷和bug或者适配新的软件架构而做出变更。

因此为了便于我们做出变更,在代码的组织方式上,我们需要用心,这样才会使我们在做出变更时更加容易。

如何才能使代码贴近这些原则

很简单,只需要牢记,使代码保持足够简单。

Gather together the things that change for the same reasons. Separate those things that change for different reasons.

将由于相同原因而做出改变的东西聚集在一起,将由于不同原因而做出改变的东西彼此分离。

孤立变化

对于所编写的做出变更的代码,你需要仔细的检查它们,无论是从整体检查,还是有逻辑的分而治之,都可以达到孤立变化的目的。你需要更多的了解你所编写的代码,比如,为什么这样写,代码到底做了什么等等,并且,对于一些特别长的方法和类要格外关注。

Big is bad, small is good…

大即是坏,小即是好。

追踪依赖

对于一个类,检查它的构造方法是否包含了太多的参数,因为每一个参数都作为这个类的依赖存在,同时这些参数也拥有自身的依赖。如果可能的话,使用DI机制来动态的注入它们。

Use Dependency Injection

使用依赖注入

追踪方法参数

对于一个方法,检查它是否包含了太多参数,一般来讲,一个方法的参数个数往往代表了其内部所实现的职能。

同时,在方法命名上也投入一精力,尽可能地使方法名保持简单,它将帮助你在重构代码时,更好的达到单一职责。长的函数名称往往意味着其内部有糟糕的味道。

Name things descriptively

描述性命名。

尽早重构

尽可能早的重构代码,当你看到一些代码可以以更简明的方式进行时,重构它。这将帮助你在项目进行的整个周期不断的整理代码以便于更好的重构。

Refactor to Design Patterns

按设计模式重构代码

善于做出改变

最后,在需要做出改变时,果断地去做。当然这些改变会使系统的耦合性更低,内聚性更高,而不是往相反的方向,这样你的代码会一直建立在这些原则之上。

Introduce change where it matters. Keep things simple but not simpler.

在重要的地方介绍改变。保持事情的简单性,但不是一味追求简单。

译者注

单一职责原则其实在我们日常工作中经常会接触到,比方说

  • 我们经常会听到DIY(dont repeat yourself)原则,其本身就是单一职责的一个缩影,为了达到DIY,对于代码中的一些通用方法,我们经常会抽离到独立的utils目录甚至编写为独立的工具函数库, 比如lodashramda等等
  • OAOO, 指Once And Only Once, 原则本身的含义可以自行搜索,实际工作中我们对于相同只能模块的代码应当尽可能去在抽象层合并它们,提供抽象类,之后通过继承的方式来满足不同的需求
  • 我们都会很熟悉单例模式这个模式,但在使用时一定要小心,因为本质上单例模式与单一职责原则相悖,在实践中一定要具体情况具体分析。同时也不要过度优化,就如同文章中最后一部分提及的,我们要保证一件事情的简单性,但不是一味地为了简单而简单。
  • 前端的技术栈中,redux对于数据流层的架构思想,便充分体现了单一职责原则的重要性,action作为对具体行为的抽象, store用来描述应用的状态,reducer作为针对不同行为如何对store作出修改的抽象。
  • react中经常提及的木偶组件(dump component)其实和文章中第一部分的例子如出一辙
  • 工厂模式命令模式也一定程度体现了单一职责原则,前者对于作为生产者存在并不需要关心消费者如何消费对象实例,后者以命令的方式封装功能本身就是单一职责原则的体现。

我能够想到的就这么多,写的比较乱,抛砖引玉,如有错误,还望指正。

more

【译】Understanding SOLID Principles - Dependency Inversion


Understanding SOLID Principles: Dependency Inversion

这是理解SOLID原则中,关于依赖倒置原则如何帮助我们编写低耦合和可测试代码的第一篇文章。

写在前头

当我们在读书,或者在和一些别的开发者聊天的时候,可能会谈及或者听到术语SOILD。在这些讨论中,一些人会提及它的重要性,以及一个理想中的系统,应当包含它所包含的5条原则的特性。

我们在每次的工作中,你可能没有那么多时间思考关于架构这个比较大的概念,或者在有限的时间内或督促下,你也没有办法实践一些好的设计理念。

但是,这些原则存在的意义不是让我们“跳过”它们。软件工程师应当将这些原则应用到他们的开发工作中。所以,在你每一次敲代码的时候,如何能够正确的将这些原则付诸于行,才是真正的问题所在。如果可以那样的话,你的代码会变得更优雅。

SOLID原则是由5个基本的原则构成的。这些概念会帮助创造更好(或者说更健壮)的软件架构。这些原则包含(SOLID是这5个原则的开头字母组成的缩略词):

  • S stands for SRP (Single responsibility principle):单一职能原则
  • O stands for OCP (Open closed principle):开闭原则
  • L stands for LSP (Liskov substitution principle):里氏替换原则
  • I stand for ISP ( Interface segregation principle):接口隔离原则
  • D stands for DIP ( Dependency inversion principle):依赖倒置原则

起初这些原则是Robert C. Martin在1990年提出的,遵循这些原则可以帮助我们更好的构建,低耦合、高内聚的软件架构,同时能够真正的对现实中的业务逻辑进行恰到好处的封装。

不过这些原则并不会使一个差劲的程序员转变为一个优秀的程序员。这些法则取决于你如何应用它们,如果你是很随意的应用它们,那等同于你并没有使用它们一样。

关于原则和模式的知识能够帮助你决定在何时何地正确的使用它们。尽管这些原则仅仅是启示性的,它们是常见问题的常规解决方案。实践中,这些原则的正确性已经被证实了很多次,所以它们应当成为一种常识。

依赖倒置原则是什么

  • 高级模块不应当依赖于低级模块。它们都应当依赖于抽象。
  • 抽象不应当依赖于实现,实现应当依赖于抽象。

这两句话的意思是什么呢?

一方面,你会抽象一些东西。在软件工程和计算机科学中,抽象是一种关于规划计算机系统中的复杂性的技术。它的工作原理一般是在一个人与系统交互的复杂环境中,隐藏当前级别下的更复杂的实现细节,同时它的范围很广,常常会覆盖多个子系统。这样,当我们在与一个以高级层面作为抽象的系统协作时,我们仅仅需要在意,我们能做什么,而不是我们如何做。

另外,你会针对你的抽象,有一写低级别的模块或者具体实现逻辑。这些东西与抽象是相反的。它们是被用于解决某些特定问题所编写的代码。它们的作用域仅仅在某个单元和子系统中。比如,建立一个与MySQL数据库的连接就是一个低级别的实现逻辑,因为它与某个特定的技术领域所绑定。

现在仔细读这两句话,我们能够得到什么暗示呢?

依赖倒置原则存在的真正意义是指,我们需要将一些对象解耦,它们的耦合关系需要达到当一个对象依赖的对象作出改变时,对象本身不需要更改任何代码。

这样的架构可以实现一种松耦合的状态的系统,因为系统中所有的组件,彼此之间都了解很少或者不需要了解系统中其余组件的具体定义和实现细节。它同时实现了一种可测试和可替换的系统架构,因为在松耦合的系统中,任何组件都可以被提供相同服务的组件所替换。

但是相反的,依赖倒置也有一些缺点,就是你需要一个用于处理依赖倒置逻辑的容器,同时,你还需要配置它。容器通常需要具备能够在系统中注入服务,这些服务需要具备正确的作用域和参数,还应当被注入正确的执行上下文中。

以提供Websocket连接服务为例子

举个例子,我们可以在这个例子中学到更多关于依赖倒置的知识,我们将使用Inversify.js作为依赖倒置的容器,通过这个依赖倒置容器,我们可以看看如何针对提供Websocket连接服务的业务场景,提供服务。

比如,我们有一个web服务器提供WebSockets连接服务,同时客户端想要连接服务器,同时接受更新的通知。当前我们有若干种解决方案来提供一个WebSocket服务,比如说Socket.ioSocks或者使用浏览器提供的关于原生的WebSocket接口。每一套解决方案,都提供不同的接口和方法供我们调用,那么问题来了,我们是否可以在一个接口中,将所有的解决方案都抽象成一个提供WebSocket连接服务的提供者?这样,我们就可以根据我们的实际需求,使用不同的WebSocket服务提供者。

首先,我们来定义我们的接口:

1
2
3
4
5
6
7
export interface WebSocketConfiguration {
uri: string;
options?: Object;
}
export interface SocketFactory {
createSocket(configuration: WebSocketConfiguration): any;
}

注意在接口中,我们没有提供任何的实现细节,因此它既是我们所拥有的抽象

接下来,如果我们想要一个提供Socket.io服务工厂:

1
2
3
4
5
6
7
import {Manager} from 'socket.io-client';

class SocketIOFactory implements SocketFactory {
createSocket(configuration: WebSocketConfiguration): any {
return new Manager(configuration.uri, configuration.opts);
}
}

这里已经包含了一些具体的实现细节,因此它不再是抽象,因为它声明了一个从Socket.io库中导入的Manager对象,它是我们的具体实现细节。

我们可以通过实现SocketFactory接口,来增加若干工厂类,只要我们实现这个接口即可。

我们在提供一个关于客户端连接实例的抽象:

1
2
3
4
5
6
export interface SocketClient {
connect(configuration: WebSocketConfiguration): Promise<any>;
close(): Promise<any>;
emit(event: string, ...args: any[]): Promise<any>;
on(event: string, fn: Function): Promise<any>;
}

然后再提供一些实现细节:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class WebSocketClient implements SocketClient {
private socketFactory: SocketFactory;
private socket: any;
public constructor(webSocketFactory: SocketFactory) {
this.socketFactory = webSocketFactory;
}
public connect(config: WebSocketConfiguration): Promise<any> {
if (!this.socket) {
this.socket = this.socketFactory.createSocket(config);
}
return new Promise<any>((resolve, reject) => {
this.socket.on('connect', () => resolve());
this.socket.on('connect_error', (error: Error) => reject(error));
});
}
public emit(event: string, ...args: any[]): Promise<any> {
return new Promise<string | Object>((resolve, reject) => {
if (!this.socket) {
return reject('No socket connection.');
}
return this.socket.emit(event, args, (response: any) => {
if (response.error) {
return reject(response.error);
}
return resolve();
});
});
}
public on(event: string, fn: Function): Promise<any> {
return new Promise<any>((resolve, reject) => {
if (!this.socket) {
return reject('No socket connection.');
}
this.socket.on(event, fn);
resolve();
});
}
public close(): Promise<any> {
return new Promise<any>((resolve) => {
this.socket.close(() => {
this.socket = null;
resolve();
});
});
}
}

值得注意的是,这里我们在构造函数中,传入了一个类型是SocketFactory的参数,这是为了满足关于依赖倒置原则的第一条规则。对于第二条规则,我们需要一种方式来提供这个不需要了解内部实现细节的、可替换的、易于配置的参数。

这也是为什么我们要使用Inversify这个库的原因,我们来加入一些额外的代码和注解(装饰器):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import {injectable} from 'inversify';
const webSocketFactoryType: symbol = Symbol('WebSocketFactory');
const webSocketClientType: symbol = Symbol('WebSocketClient');
let TYPES: any = {
WebSocketFactory: webSocketFactoryType,
WebSocketClient: webSocketClientType
};

@injectable()
class SocketIOFactory implements SocketFactory {...}
...
@injectable()
class WebSocketClient implements SocketClient {
public constructor(@inject(TYPES.WebSocketFactory) webSocketFactory: SocketFactory) {
this.socketFactory = webSocketFactory;
}

这些注释(装饰器)仅仅会在代码运行时,在如何提供这些组件实例时,提供一些元数据,接下来我们仅仅需要创建一个依赖倒置容器,并将所有的对象按正确的类型绑定起来,如下:

1
2
3
4
5
6
7
8
import {Container} from 'inversify';
import 'reflect-metadata';
import {TYPES, SocketClient, SocketFactory, SocketIOFactory, WebSocketClient} from '@web/app';
const provider = new Container({defaultScope: 'Singleton'});
// Bindings
provider.bind<SocketClient>(TYPES.WebSocketClient).to(WebSocketClient);
provider.bind<SocketFactory>(TYPES.WebSocketFactory).to(SocketIOFactory);
export default provider;

让我们来看看我们如何使用我们提供连接服务的客户端实例:

1
var socketClient = provider.get<SocketClient>(TYPES.WebSocketClient);

当然,使用Inversify可以提供一些更简单易用的绑定,可以通过浏览它的网站来了解。

译者注

一般说到依赖倒置原则,往往第一个想到的术语即是依赖注入,这种在各个技术栈都有应用,之后又会马上想到springng等前后端框架。

我们确实是通过使用这些框架熟知这个概念的,但是如果你仔细想想的话,是否还有其他的一些场景也使用了类似的概念呢?

比如:

  • 一些使用插件和中间件的框架,如expressredux
  • js中this的动态绑定
  • js中的回调函数

也许有的人会不同意我的观点,会说依赖注入一般都是面向类和接口来讲的,这确实有一定的道理,但是我认为没有必要局限在一种固定的模式中去理解依赖倒置,毕竟它是一种思想,一种模式,在js中,所有的东西都是动态的,函数是一等公民,是对象,那么把这些与依赖倒置原则联系起来,完全也讲的通。我们真正关心的是核心问题是如何解耦,把更多的注意力投入的真正的业务逻辑中去。

more

尝鲜 workerize 源码


写在前面

最近正好在看web worker相关的东西,今天无意中就看到了github一周最热项目的推送中,有这么一个项目workerize,repo里的文档的描述如下:

Moves a module into a Web Worker, automatically reflecting exported functions as asynchronous proxies.

例子

关于README很简单,包含一个类似hello world的例子就没其他什么了。但是从例子本身可以看出这个库要解决的问题,是想通过模块化的方式编写运行在web worker中的脚本,因为通常情况下,web worker每加载一个脚本文件是需要通过一个符合同源策略的URL的,这样会对服务端发送一个额外的请求。同时对于web worker本身加载的js文件的执行环境,与主线程是隔离的(这也是它在进行复杂运算时不会阻塞主线程的原因),与主线程的通讯靠postMessageapi和onmessage回调事件来通讯,这样我们在编写一些通信代码时,需要同时在两个不同的环境中分别编写发送消息和接受消息的逻辑,比较繁琐,同时这些代码也不能以模块化的形式存在。

如果存在一种方式,我们可以以模块化的方式来编写代码,注入web worker,之后还能通过类似Promsie机制来处理等异步,那便是极好的。

先来看看例子:

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
import workerize from 'workerize'

let worker1 = workerize(`
export function add(a, b) {
let start = Date.now();
while (Date.now()-start < 500);
return a + b;
}

export default function minus(a, b){
let start = Date.now();
while (Date.now()-start < 500);
return a - b
}
`)

let worker2 = workerize(function (m) {
m.add = function (a, b) {
let start = Date.now()
while (Date.now() - start < 500);
return a + b
}
});

(async () => {
console.log('1 + 2 = ', await worker1.add(1, 2))
console.log('3 + 9 = ', await worker2.call('add', [3, 9]))
})()

worker1和worker2是两种不同的使用方式,一种是以字符串的形式声明模块,一种以函数的形式声明模块。但是无论哪种,最后的结果都是一样的,我们可以通过worker实例显示的调用我们想要调用的方法,每个方法的调用结果均是一个Promise,因此它还可以完美的适配async/await语法。

源码

那么问题来了,这种模块的加载机制和调用方式是怎样实现的呢?我在运行demo代码的时候心中也默默想到,我去,看了好几天的web worker原来还能这么玩,所以一定要研究研究它的源码和它的实现原理。

打开源代码才发现其实并没有多少代码,官文文档也通过一句话强调了这一点:

Just 900 bytes of gzipped ES3

所以对其中主要的两点进行简单说明:

  • 如何实现按内容模块化加载脚本而不是通过URL
  • 如何通过Promise来代理主线程与worker线程的通讯过程

使用Blob动态生成加载脚本资源

1
2
3
4
5
let blob = new Blob([code], {
type: 'application/javascript'
}),
url = URL.createObjectURL(blob),
worker = new Worker(url)

这其实不是什么新鲜的东西,就是将代码的内容转化为Blob对象,之后再通过URL.createObjectURL将Blob对象转化为URL的形式,之后再用worker加载它,仅此而已。但是这里的问题是,这个code是哪里从哪里来的呢?

将加载代码模块化

在加载代码之前,还有重要的一步,就是需要将加载的代码转变为模块,模板本身只对外暴露统一的接口,这样不论对于主线程还是worker线程,就有了统一的约束条件。源码中作者把上一步中的code转化为了类似commonjs的形式,主要涉及的代码有:

1
2
3
4
let exportsObjName = `__EXPORTS_${Math.random().toString().substring(2)}__`
if (typeof code === 'function') code = `(${toCode(code)})(${exportsObjName})`
code = toCjs(code, exportsObjName, exports)
code += `\n(${toCode(setup)})(self, ${exportsObjName}, {})`

toCjs方法

1
2
3
4
5
6
7
8
9
10
11
12
13
function toCjs (code, exportsObjName, exports) {
exportsObjName = exportsObjName || 'exports'
exports = exports || {}
code = code.replace(/^(\s*)export\s+default\s+/m, (s, before) => {
exports.default = true
return `${before}${exportsObjName}.default = `
})
code = code.replace(/^(\s*)export\s+(function|const|let|var)(\s+)([a-zA-Z$_][a-zA-Z0-9$_]*)/m, (s, before, type, ws, name) => {
exports[name] = true
return `${before}${exportsObjName}.${name} = ${type}${ws}${name}`
})
return `var ${exportsObjName} = {};\n${code}\n${exportsObjName};`
}

关于toCjs方法,如果你的正则知识比较扎实的话,可以发现,它做了一件事,就是将字符串类型的code中的所有导出方法的声明,使用commonjs的导出语法替换掉(中间会涉及一些具体的语法规则),如下:

1
2
3
// 如果 exportsObjName 使用默认值 exports, ...代表省略代码
export function foo(){ ... } => exports.foo = function foo(){ ... }
export default ... => exports.default = ...

如果code是函数类型,则首先使用toCode函数将code转化为string类型,之后再将它转化为IIFE的形式,如下

1
2
3
4
5
6
7
8
9
// 如果 exportsObjName 使用默认值 exports, ...代表省略代码
// 传入的code是如下形式:
function( m ){
...
}
// 转化为
(function( m ){
...
})(exports)

这里的exportsObjName代表模块的名字,默认值是exports(联想commonjs),不过这里会在一开始就随机生成一个模块名字,生成代码如下:

1
let exportsObjName = `__EXPORTS_${Math.random().toString().substring(2)}__`

这样只有我们按照约定的语法来编写web worker加载的代码,它便会加载了一个符合同样约定的commonjs模块。

使用 Promise 来做异步代理

经过上面两步,web worker加载到了模块化的代码,但是worker线程与主线程进行通讯则是仍然需要通过postMessage方法和onmessage回调事件来进行,如果无法优雅地处理这里的异步逻辑,那么之前所做的工作其实意义并不大。

workerize针对这里的异步逻辑,设计了一个简单的rpc协议(文档中将这个称作a tiny, purpose-built RPC),先来看一下源码中的setup函数:

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
29
30
31
32
33
34
35
36
function setup (ctx, rpcMethods, callbacks) {
ctx.addEventListener('message', ({ data }) => {
// 只捕获满足条件的数据对象
if (data.type === 'RPC') {
// 获取数据对象中的 id 属性
let id = data.id
if (id != null) {
// 如果数据对象中存在非空 method 属性,则证明是主线程发送的消息
if (data.method) {
// 获取所要调用的方法实例
let method = rpcMethods[data.method]
if (method == null) {
// 如果所调用的方法实例不存在,则发送方法不存在的消息
ctx.postMessage({ type: 'RPC', id, error: 'NO_SUCH_METHOD' })
} else {
// 如果方法存在,则调用它,并将调用结果按不同的类型发送
Promise.resolve()
.then(() => method.apply(null, data.params))
.then(result => { ctx.postMessage({ type: 'RPC', id, result }) })
.catch(error => { ctx.postMessage({ type: 'RPC', id, error }) })
}
// 如果 method 属性为空,则证明是 worker 线程发送的消息
} else {
// 获取每个消息所对应的处于pending状态的Promise实例
let callback = callbacks[id]
if (callback == null) throw Error(`Unknown callback ${id}`)
delete callbacks[id]

// 按消息的类型将Promise转化为resolve状态或reject状态。
if (data.error) callback.reject(Error(data.error))
else callback.resolve(data.result)
}
}
}
})
}

根据注释我们可以知道,这里的setup函数包含了rpc协议的解析规则,因此主线程和worker线程对会调用该方法来注册安装这个rpc协议,具体的代码如下:

  • 主线程: setup(worker, worker.rpcMethods, callbacks)
  • worker线程: code += `\n(${toCode(setup)})(self, ${exportsObjName}, {})

这两处代码都是在各自的作用域中,将rpc协议与当前加载的模块绑定起来,只不过主进程所传callbacks是有意义的,而worker则使用一个空对象代替。

注册调用逻辑

在拥有了rpc协议的基础上,只需要实现调用逻辑即可,代码如下:

1
2
3
4
5
worker.call = (method, params) => new Promise((resolve, reject) => {
let id = `rpc${++counter}`
callbacks[id] = { method, resolve, reject }
worker.postMessage({ type: 'RPC', id, method, params })
})

这个call方法,每次会将一次方法的调用,转化为一个pending状态的Promise实例,并存在callbacks变量中,同时向worker线程发送一个格式为调用方法数据格式的消息。

1
2
3
4
5
for (let i in exports) {
if (exports.hasOwnProperty(i) && !(i in worker)) {
worker[i] = (...args) => worker.call(i, args)
}
}

同时在初始化的过程中,会将主线程加载的模块中的每个方法,都绑定一个快捷方法,其方法名与模块中的函数声明保持一致,内部则使用worker.call来完成调用逻辑。

最后

关于这个库本身,还存在一些可以探讨的问题,比如:

  • 是否支持依赖解析机制
  • 如果引入外部依赖模块
  • 针对消息是否需要按队列进行处理

关于前两点,似乎作者有一个相同的项目,叫做workerize-loader,可以解决,关于第三点,作者在代码中增加了todo,表示实现消息队列机制可能没有必要,因为当前的通讯基于postMessage,本身的结果已经是有序状态的了。

关于源码本身的分析大概就这样了,希望可以抛砖引玉,如有错误,还望指正。

more

高级 Angular 组件模式 (3a)


03-a Communicate Between Components Using Dependency Injection

原文: Communicate Between Components Using Dependency Injection

当前的<toggle>组件仍然存在其他的问题,比如:

  • 我们无法在其中方式多个<toggle-on>或者是<toggle-button>在同一个<toggle>
  • 如果一个<toggle-on>作为另外一个自定义组件的内容的话,我们无法是使用@ContentChild装饰器获取它

目标

我们需要将这两个问题作为新的目标:

  • 我们可以增加多个相同类型的子组件(<toggle-on><toggle-off><toggle-button>)
  • 并且可以使这些子组件放置在任意的自定义容器的视图模板(views)中

实现

针对第一个问题,我们使用@ContentChildren装饰器(因为它获取所有的子组件引用),但是它无法解决第二个问题。

为了同时解决这两个问题,我们可以使用Angular提供的DI机制(dependency injection mechanism)。你可以将一个组件的祖先组件通过DI机制注入到子组件的构造方法中,这样你就可以通过祖先组件的引用来访问它们的方法和属性。

所以,<toggle-on><toggle-off><toggle-button>都可以通过DI机制来获得最相近的<toggle>组件的引用,从而共享它的开关状态。

Note:
这里也可以使用service来共享状态,也许还会更便捷,但是我们可以通过DI来达到目的,我们可以在之后的章节(第十二章)来阐述service相关的内容,这部分内容会与React Context Provider的内容对应。

成果

你可以在在线代码仓库看到,有两个<toggle-off>组件如我们预期的那样被渲染,并且有一个<other-component>组件,其中有<toggle-off><toggle-on>两个组件。

这些子组件都会监听同一个<toggle>组件的开关状态。

译者注

依赖注入是Angular中提供的很强大的功能,在angularjs中就表现出色并作为卖点。

如果仔细思考的话,我们可以发现,在这一版的实现中,对于<toggle>组件的引用获取方式,从命令式转变为了声明式,因为我们不再关心获取<toggle>引用的细节(比如具体使用@ContentChild还是@ContentChildren)。

同时,依赖注入机制是依附于组件本身存在的,并不依附于模板的层级关系,因此不会面临问题二的困扰。

对于木偶组件本身,往往作为消费者存在,这种情况下使用DI机制可能会达到更好的效果。

more

高级 Angular 组件模式 (3b)


03-b Enhance Components with Directives

原文: Enhance Components with Directives

Kent C. Dodds的第四篇文章中的一个重要元素在上一篇文章中没有涉及,使用withToggle高阶组件(HoC, react中的常用模式)可以将<toggle-on><toggle-off><toggle-button>组件中的公用逻辑分离出来。

虽然上一篇文章中上面提及的三个组件并没有太多的公用逻辑,可以万一它们有公用逻辑呢?如果我们想要提供更加声明式的功能,比如能够显式的声明它们使用的<toggle>组件实例而非最邻近的父实例。

同时,因为<toggle>组件的模板并不存在任何的变动,我们可以将它转化为一个指令,这样我们可以以更加灵活的方式来使用它。

目标

  • 允许我们的<toggle>组件能够以tag的形式或者attribute的形式使用,如<toggle>或者<div toggle></div>
  • 允许通过`withToggle
    1
    2
    3
    4

    ## 实现
    ### 1)将``<toggle>``作为一个指令
    将``<toggle>``组件改变为指令十分简单,因为它本身的模板仅仅是``<ng-content></ng-content>``,在组件渲染时,``<ng-content>``会被替换为我们当前组件标签内包含的内容,所以我们可以直接移除它,并使用``@Directive``装饰器来描述``<toggle>``组件,如下:

@Directive({
exportAs: ‘toggle’,
selector: ‘toggle, [toggle]’,
})
export class ToggleDirective {}

1
2
3
4
5
6
你可能注意到了,指令的选择器允许``toggle``指令可以以**标签名**和**属性名**的形式来使用。对于``exportAs``关键字是必须要提供的,因为这是当我们需要在别的指令或者组件能够获取``toggle``指令引用的名字,会在这个系列文章的第5章详细删除``exportAs``(Handle Template Reference Variables with Directives)。

### 2)``withToggle``指令
在这个新的指令中,我们将会封装关于如何选取需要绑定某个``toggle``指令实例的逻辑。

首先,我们的设想是这样的,每一个组件注入``withToggle``指令,而不是直接注入最邻近的父``toggle``指令。同时每个使用``withToggle``指令的组件通过使用``withToggle.toggle``来访问它所绑定的``toggle``指令的实例,如下:

@Component({
selector: ‘toggle-off’,
template: <ng-content *ngIf="!withToggle.toggle?.on"></ng-content>,
})
export class ToggleOffComponent {
constructor(public withToggle: WithToggleDirective) {}
}

1
2

其次,``withToggle``指令将它自身与``toggle``指令的选择器绑定(就是两个指令的选择器是相同的),同时增加一个额外的选择器``[withToggle]``,如下:

@Directive({
exportAs: ‘withToggle’,
selector: ‘toggle, [toggle], [withToggle]’,
})
export class WithToggleDirective //…

1
2
3
4
5
6
7

现在``withToggle``指令为它的子组件们提供所绑定的``toggle``指令实例,无论这个实例是显示绑定的,还是默认的父``toggle``指令。关于其中实现的具体细节,可以参考文章最后的[附录部分](#附录)。

## 成果
我们的``app.component.html``现在可以通过三种不同的使用方式来展现内容。

### 1)基本







1
2
3
4
5
注意``#firstToggle``和``#secondToggle``视图变量是如何使用``toggle``组件的,前者使用属性声明的方式,后者使用标签名声明方式,无论怎样,它们都按理想中那样运行。

而且,``#secondToggle``是嵌套在``#firstToggle``中的,所以它的子组件使用的是它本身的开关状态,而非``#firstToggle``中的,这符合我们的预期。

### 2)显式引用


First:
On
Off


1
2
3
这里没有任何``toggle``指令是当前``p``标签的子组件的祖先,但是通过``withToggle``指令,我们可以让所有的子组件使用``#firstToggle``的``toggle``指令实例。

### 3)自定义组件





1
2
3
4
``withToggle``指令甚至可以通过DI机制注入到内部的任何自定义组件中,如``<labelled-state>``组件和``<labelled-button>``都没有任何关于``withToggle``或者``toggle``的引用声明。它们无需关心这个开关状态的来源,它们仅仅需要知道的是,根据这个开关状态,如何与它们的子组件进行交互。

## 附录
``withToggle``的实现,是一个标准的指令声明方式,除了它的构造方法,如下:

constructor(
@Host() @Optional() private toggleDirective: ToggleDirective,
) {}

1
2
3
4
5
值得注意的有两点:
* ``@Host()``:这个装饰器的作用是,可以限制从属于当前指令的DI注入器,仅注入**绑定到某个满足特定条件指定或者组件上的**``toggle``指令实例,而不是从它的祖先组件们中注入。(这里选择器为空,则为宿主对象)
* ``@Optional()``:这个装饰器会告诉编译器,当注入器没有找到任何可注入的``toggle``指令时,不要抛出错误(如果我们手动的指定某个引用),这样在它无法被注入时,使它保持``undefined``即可。

现在我们可以很容易的理解在``ngOnChanges``生命周期钩子函数中的代码的作用,

this.toggle = this.withToggle || this.toggleDirective;
`

  • 如果我们的@Input()被指定,那么使用它的值
  • 如果没有,则尝试去使用在当前宿主对象上注入的toggle指令实例
  • 如果没有,则使用undefined

当前的this指定withToggle本身,所以拥有它引用的子组件都可以访问它。

https://stackblitz.com/edit/adv-ng-patterns-03b-enhance-with-directives

译者注

在这一节中,主要进行了以下几方面的改进:

  • 简化toggle本身,因为它一直是作为一个容器组件使用的,所以完全可以以指令(可以理解为没有模板的组件)的形式存在
  • 依赖注入(DI)的机制虽然很强大,但是受限于它的运作原理(关于具体的运作原理可以参考官方文档)。这里原作者使用一个额外的withToggle指令作为中间件,来作为toggle指令的托管容器。这部分理解起来可能需要先了解一下视图变量和exportAs的相关的知识
  • 对于toggle指令实例的获取逻辑,采用平稳退化的策略,就好比人在实际生活中思考问题的方式一样。

这种开发模式,在实际工作中,我有一次在重构公司项目中一个关于表单组件的过程中曾使用过,之所以使用这种方式,是因为在表单组件中,会存在一些关于联动校验或者分组的需求,如果将这部门逻辑封装为service或者直接写在controller内部,越到后面会发现逻辑复杂度越高,从而越来越难维护。

使用这种模式,将复杂的逻辑划分成小的颗粒,再封装为独立的指令,在需要用到这些逻辑的组件中注入这些指令即可,指令的特点就是一般都会比较简洁,只会做一些简单的事情,相比之下,维护一个十分复杂的service和维护若干简单的指令,我更倾向于后者。

more