【译】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 组件模式 (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

高级 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 组件模式 (2)


02 Write Compound Components

原文: Write Compound Components

目标

我们需要实现的需求是能够使使用者通过<toggle>组件动态的改变包含在它内部的内容。

实现

我们可以把toggle抽象为一个复合组件,由三个新的组件<toggle-button><toggle-on><toggle-off>构成。

每一个组件的职能与它们的父组件保持一致,它们各自的职能如下:

  • toggle-button: 代表开关,用来渲染父组件的开关状态
  • toggle-on: 根据父组件的开关状态,渲染当状态为时的内容
  • toggle-off: 根据父组件的开关状态,渲染当状态为时的内容

<toggle>组件可以实现@ContentChild装饰器获取这三个子组件的引用,从而可以根据开关状态的变化调整它们之间的关联逻辑。

成果

经过这样的调整,我们可以使用户通过使用<toggle>组件去自定义包含在它其中内容的显示逻辑,内容中会有一个按钮负责切换开关的状态。可以尝试在在线代码库中调整子组件的顺序,你可以在它们中间嵌套任何的html字符串,只要这三个组件是作为<toggle>的子组件存在的,一切都将正常的运行。

相关话题

  • @ContentChild装饰器将会返回在组件标签包含的内容中,第一个符合选择器的子组件或者子指令的引用,比如<toggle> This is the content </toggle>。如果想获取所有的子组件或者子指令,使用@ContentChildren

  • @ViewChild或者@ViewChildren装饰器是用来获取在组件内部模板中使用的单个或者多个组件的。比如template: 'This is the view'或者templateUrl: './my.component.html'

译者注

这里组件架构方式是标准的Smart Component(智能组件)Dump Component(木偶组件)组件架构方式。

以上的需求完全是可以在<toggle>内部实现的,但是这样会使它的内部充满逻辑代码,反之我们可以将一些职能划分为成更小的碎片,并委托到它的子组件中,本身作为容器组件存在,负责协作子组件从而达到目的。

对于@ContentChild@ViewChild的使用场景,我认为通过看装饰器前缀的寓意是最后的方式。

  • Content代表内容,这些内容在组件渲染时已经存在于组件声明标签的内部,通常在组件内部以<ng-content>为占位符
  • View代表视图,视图代表组件本身的模板,代表组件本身的渲染逻辑

@ContentChildren@ViewChildren同理。

more

高级 Angular 组件模式 (1)


写在前头

Angular到现在已经到5.x的版本了,对于MVVM框架我首先接触的是angularjs后来又转为react,之后换了工作因项目技术栈的原因又转换到之前的angularjs,在实际工作中实施了公司几个比较重要项目中前端的重构工作,这个过程逐步意识到,对于MVVM框架本身,在使用层面讲,掌握一些通用的模式是很有必要的,尤其现在已经很流行的组件化开发。这样我们在实际工作中就可以无缝(或者花很少的时间成本)转化到任何的MVVM框架,并把节省下来的时间去学习新的知识。

最近一直在关注Angular In Depth的博客,偶尔看到这个系列的文章,觉的质量还挺高,所以抽空余时间翻译并分享给大家,并在每个文章后面加了一点自己的拙见,希望可以达到抛砖引玉的效果,如果观点有误,还望各位看官轻喷。

01 Build a Toggle Component

原文: Build a Toggle Component

Kent C. Dodds Advanced React Component Patterns那样,我们将使用一个相对简单的<toggle>组件来说明这些模式。<toggole>组件的职责是仅仅是管理一个简单的布尔值状态属性: on。使用如此简单的组件进行说明的目的,是因为我们可以将更多的注意力投入到相对组件本身而言较复杂的模式中。

经过这一系列文章,我将提供包含完整功能的、可运行的演示代码链接。你仅仅需要将注意力集中到app文件夹和toggle文件夹。toggle文件夹包含一些可复用的库(一系列组件),当然这个库会随着文章的深入而改变。app文件夹并且特别是app.component.html,将针对库在不同的情形下的使用做出相应的改变。

我们将从最原始的toggle组件版本开始。它仅仅有一个使用了@Input()装饰器的on属性,这个属性所控制的状态代表组件本身的开关状态,同时它还有一个是使用了@Output()装饰器的toggle事件发射器,这个事件发射器会在组件开关状态改变的情况下,通知父组件。

在我们开始下一个话题02 Write Compound Components之前,可以在在线代码仓库看看这个组件的实现以及演示。

译者注

toggle组件的实现是一个很典型的利用单向数据流作为数据源的简单组件:

  • on是单向数据源,同时代表组件内部的开关状态
  • toggle`是事件发射器`,以回调的方式将on状态的变化传递给父组件

more

多维数组取值问题


问题简述

给予一个多维数组和一个描述取值路径的一维数组, 通过调用函数f返回取值路径描述的值,如
f([[1, 2], [3, 4], [5, 6]], [0, 0]) -> 1

原问题传送门

之所以想记录一下是因为之前有在codewars刷题的习惯,后来工作忙就怠慢了,今天闲来无事就准备刷几道玩玩,然后就挑了一个比较简单的7kyucodewars中的难度评级)的题。

因为这题比较简单,我也没多想,上来就干,仔细想了下,很容易嘛,不就是递归吗?按大学老师教的来一套:

  • 先找递归退出的条件,当路径取到最后或者目标数组已经取尽(这里似乎题目没有说清楚,暂定不对取值路径做限定吧)
  • 再找递归的模式, 如果不满足递归退出的条件,则将目标数组缩小一维,传递新的取值路径并递归

然后就有了第一版代码

1
2
3
4
5
6
function getElement(array, indexes) {
const idx = indexes.shift()

if(indexes.length === 0 || array[idx].constructor !== Array ) return array[idx]
else return getElement(array[idx], indexes)
}

然后跑了跑作者提供的简单测试用例,all passed,我就说嘛,这么简单的题目不一把过岂不是有点丢脸,然后就直接点了提交,然后就蹦了一大片的failed测试用例出来,仔细看了看失败的测试用例,并没有看懂,因为都是很多莫名奇妙的数据,并且每次返回的结果都不一致(后来才知道是随机的用例)。

之后一直以为是自己有一些边界情况没有考虑到,就前前后后又看了几次自己的答案,并没有发现什么大的问题,但是无论怎么提交都是失败。之后前前后后大概看了15分钟左右,突然就觉得是我自己把问题想简单了。

因为最近一直在看函数式的东西,突然就联想到,自己写的这个函数,并不。关于纯函数是函数式编程中最基本的概念之一,所谓纯既是要求函数没有副作用,我这里的getElement使用了Array.shift方法,会对原数组进行修改,从而产生副作用。这么一想一下就和之前每次跑用例总是产生意想不到的结果的情况联系上了,随机用例的失败原因一定是因为测试路径会有很多个,但是测试目标数组只有一个,因此有副作用的话,只有第一次测试的结果是正确的,后面都会因副作用产生不同的结果。

既然是因为数组是引用类型而产生的问题,那么直接来一个深拷贝就可以了。因为这里只是答题,所以使用一个最简单粗暴的深拷贝大法x => JSON.parse(JSON.stringify(x)),然后代码就有了第二版代码

1
2
3
4
5
6
7
8
9
function getElement(array, indexes) {
array = JSON.parse(JSON.stringify(array))
indexes = JSON.parse(JSON.stringify(indexes))

const idx = indexes.shift()

if(indexes.length === 0 || array[idx].constructor !== Array ) return array[idx]
else return getElement(array[idx], indexes)
}

之后提交,所有的用例都通过了。但是还没有结束,因为自己的代码在实现上还是挺啰嗦的,同时还使用了深拷贝大法,有没有更简单的方法呢?

有的。如果仔细思考一下,这里的解题的思路其实和Array.reduce的模式很像

  • 对一个数组进行遍历(对路径数组进行遍历)
  • 每次遍历返回一个值,并作为参数传入下一次遍历(对目标函数的降维)
  • 在遍历完成后,返回一个结果(取值路径对应的值)

因此利用Array.reduce,这个问题一行代码就可以解决,如下

1
2
3
function getElement(array, indexes) {
return indexes.reduce((a, i) => a[i], array);
}

同样多考虑一下,它是纯函数吗?是的,它是纯的。因为这里只做了取值的操作(a[i]),并不涉及任何的修改原数组的操作。这个答案也是在我提交后,所有答案中实现方案最好的一个。

当然还有其他的暴力破解法,我觉的想法也不错,比如

1
2
3
function getElement(array, indexes) {
return eval(`array[${indexes.join("][")}]`);
}

还有利用spread运算符的方法,如

1
const getElement = (array,[index,...moreIndices]) => moreIndices.length ? getElement( array[index], moreIndices ) : array[index]

所以工作累了,或者闲来无事,刷刷题还是挺有意思的,可以看见一些自己平时很熟悉但是在需要用时又难以想起的知识。

more

从ng1看ng2 关于NgModule的简易归纳


写在前面

最近开始折腾ng2,其实说是ng2,到目前为止,它已经发布了4.3版,就是这么的高产,高产似*,我连2都还木有完整的看完它竟然发布了4.的版本(鄙视脸)。

不过话说回来,之前工作不忙的时候,想看看ng2的东西,我的天,又是ts,又是observable的,吓的我硬是没有上来就直接去看它的入门文档,而是买了本typescript的入门开始看,后来书看了一半,工作又忙了,就想着可否重构历史代码加顺带学习rxjs的姿势去做需求,后来我也是这么做的,庆幸的是,这么做效果是挺好的,不仅实际开发效率高了不少,同时也入门了rxjs。

最近工作又闲了下来,就想着赶紧带着半吊子的ts和rxjs水平看看ng2的文档吧,虽然ng2在国内似乎热度不如react和vue流行,但是在全世界还是劲头还是比较强劲的,毕竟有谷歌爸爸和微软爸爸做东,强强联合。

而且我是个遵守规则的人,一向喜欢大而全的东西,ng这种框架我觉的真的是很对我的胃口。不过这ng2的文档不看不要紧,一看真的是有些蛋疼,随便看个啥,连码代码加理解,估计一下午也就能看一个简单的概念,就比如今天想归纳的NgModule这部分东西,前后大概有30几页的东西,所以我觉的如果不写点东西总结一下,必然是看了就忘,忘了再看的节奏。

ng1中的module回顾

其实熟悉ng1的话,对于这ng2中的module的理解是很有帮助的。从ng1开始,就一直再提倡一个module的概念,比如

1
2
3
4
5
6
7
angular.module('baz', [])
angular.module('bar', [])
angular.module('foo', ['bar', 'baz'])
.factory('fooFactory', function(){ ... })
.service('fooService', function(){ ... })
.component('fooComponent1', { ... })
.directive('fooDirective', function(){ ... })

这么的我就分别声明了foobarbaz的module,同时foo依赖于bazbar,然后就没有然后了,之后你就可以在这个这些module中做一切你想做的任何事情。通过这种module的划分,我们可以在各个维度中,抽象我们的代码,比如从业务上、从代码类别上等等。

ng2中的module简述

ng2将这种概念更推进了一步,虽然我们可以使用module的方法来改变我们编程的思维,但是代码本身并不具有module的含义,这么说可能有点难理解,举个例子,就上面的例子,对于barbaz模块,我们很难简单的从代码层面来区分它们分别的职能是什么。因此ng2改用了以Decorator的方式来使代码具有语意性,如下

1
2
3
4
5
6
7
@NgModule({
imports: [ ... ],
declarations: [ ... ],
providers: [ ... ],
exports: [ ... ]
})
export class FooModule { }

通过NgModule这个装饰器来对一个class进行描述。如果直接和ng1对比的话,装饰器的参数对象中的几个属性分别对应ng1中的:

  • imports: 对应ng1声明中的模块依赖数组['bar', 'baz'],进行一些依赖模块的声明
  • declarations: 对应ng1中的.component('fooComponent1', { ... }).directive('fooDirective', function(){ ... }),进行一些声明式类的声明
  • providers: 对应ng1中的.service('fooService', function(){ ... }).factory('fooFactory', function(){ ... }),进行一些服务类的声明
  • exports: ng1中没有独立的exports语法,因此不做对比,在ng1中只有依赖模块中提供的服务、模块,加载模块均可使用。

ng2中module分类

ng2对不同的module提供了不同的语意性描述方式,可以通过装饰器属性来描述它们。

root-module(根模块)

所谓根模块,即一个应用的最上层的模块,即应用的入口模块,有时候也会称作app-module(这也是文档中指出的最佳实践)。这个概念在ng1中同样有,但是和上面指出的问题一样,就是没有代码层面的语意性描述,为了更好的描述它,ng2中的root-module会提供额外的装饰器属性来修饰它

1
2
3
4
5
@NgModule({
...,
bootstrap: [ ... ]
})
export class AppModule { }

这个bootstrap代表这个模块会在应用初始化阶段被引导和加载,一般来讲,ng2都会有一个独立的main.ts文件来引导加载根模块,这个过程对于根模块是透明的,因为它并不知道它如何被加载,有可能是在浏览器加载,有可能是在移动端加载等等,有可能使用JIT做动态加载,有可能提前编译好做静态加载,这些都是未知的,它仅仅需要表达的是,这个模块是可被引导加载的。通常情况下,一个应用只有一个root-module(这不是废话吗),但是在一些大型的应用中,可能会存在多个独立的子项目,那么就有可能存在多个root-module。

sub-module(子模块)

所谓子模块,即一个应用从属于根模块的模块,即应用按各个维度划分形成的颗粒,比如从功能划分、代码架构划分等等,有时候也会称作feature-module(这也是文档中指出的最佳实践),ng1同样无法在代码层面进行语意性描述。

一般来讲,只要符合ng2中的module简述中的模块声明方式声明的模块,就是一个feature-module,它具有什么样的feature,完全取决于它提供什么样的声明式组件和服务。对于不同的feature,如服务、指令和组件会以其他的装饰器进行修饰和声明,这不是本篇文章的内容,我们只需要知道,一个feature-module负责声明和管理它提供的服务和声明式组件。值得一提的是,和ng1不同,ng2中的feature-module可以声明一些私有的组件和服务,这一点在ng1中时做不到的。

core-module(核心模块)

核心模块其实本质上讲,即是feature-module,只不过它提供的声明式组件和服务,是基于app本身构建的。言外之意,就是它必须是单例的,比如像登录验证这种服务,就是一种全局性的、app级别的服务,再入导航、顶部条等组件,也是app级别的组件,这些东西应该归并到core-module中,并在app-module中加载它,而坚决不要在feature-module中加载它。

share-module(共享模块)

共享模块也是feature-module,只不过它提供的声明式组件和服务,是从各个feature-module中提取出的公共组件和服务,在官方的例子中,高亮这个指令,就是一个公用的指令,并不依赖于任何独立的业务逻辑。这些东西应该归并到share-module中,并在各个feature-module中加载它,从而使得我们的代码尽可能的保持简洁。

lazy-load-module(懒加载模块)

对于SPA来讲,路由是必不可少的东西。那么对于一些未使用页面所加载的模块,我们没有必要从一开始就加载它们,很自然的,它们就成为了懒加载模块,这些模块本质上讲均是feature-module,因为core-module和share-module从职能来讲,在整个的应用生命周期,尤其初始化必不可少,因此它们很少成为懒加载模块。

more

在 redux 中集成 angular di 机制


最近一直在折腾redux相关的东西,算然说官方鼓励的使用方式是将redux和react一起使用,但并不影响我们在其他的mvvm框架中使用它。

众所周知,angular是一个大而全的框架,想和redux一起使用,需要摒弃一些angular中常用的开发理念与模式,仅仅将它作为一个视图层框架使用,就和react一样,不在将类似domain stateapp state的状态维护在controller或service中,而是全权交给redux维护。

我们可以通过使用ng-redux注入redux服务,从而在angular中使用它。

基本的使用项目文档介绍的已经很详细了,这里仅仅想分享在这段时间折腾redux和angular遇到的一个比较蛋疼的问题,以及是如何解决的。

在redux中,业务逻辑的抽象被描述在action中,因此除了一些同步action以外,必然存在类似向后端发送请求获取数据之类的异步action。那么问题来了,在angular中有一个大家很熟悉的机制,叫做依赖注入(简称di),因为这种机制的存在,在angular中,我们一般使用一个服务是不关心它的实例化过程的,我们所做的,仅仅是声明它,告诉模块,我们在运行时,需要注入相关依赖的实例,但是在redux中没有这种机制,对于想在action使用的服务,你必须先导入它,实例化,你才可以使用,这与angular本身的di机制相悖。

那么我们如何来解决这个问题呢?

一种简单粗暴的方法就是,完全摒弃angular的di机制,使用外部的模块来解决问题。比如发请求,难道我们非要使用$http服务吗?我们完全可以直接使用像superagent这样的第三方库来代替它。但是随之而来的问题就是,对于angular已经使用$http服务的代码,你必须考虑是否重构它们,如果不重构,你则会在代码中拥有两套发送请求的逻辑,代码冗余了,如果重构呢,工作量又会很大,没准折腾redux不成,又给全改回去,得不偿失,因此这种方法不是完美的解决方案。最完美的方法应当是,既不摒弃angular的di机制,又可以兼顾redux框架,可是redux没有di机制这怎么破呢?

这个问题费了我不少时间去搜索网上的资料,类似如何在action使用angular di机制,如果在action获取service实例等等,虽然能查到的资料都没有完美的给出解决方案,但是都把问题的解决方向,指向了同一处,即redux的中间件机制。

第二种方法则通过redux中间件的机制来实现
什么是中间件机制呢?这个可是个大概念,简单的理解的话,我只引用一个计算机常用的用来调侃的话解释。

计算机科学中,没有什么问题是不能通过增加一个中间件来解决的。

话虽然这么说,但是它是很有道理的,对于redux的中间件机制呢,它是对于action来设计的,即一个action,在最终转移到到处理它的reducer之前,会经过若干大大小小的中间件,这些中间件做的事情十分的简单,即输入一个action,按自己的处理逻辑加工它,再交到下一个中间件手中,直到没有下一个中间件为止。

明白了redux的中间件机制,那么上面的问题就好解决了,对于想在action中使用的通过angular di机制来实例化的服务,我们没有必要在action中实例化,我们完全可以仅仅在action中声明,之后呢,将实例化的过程交由中间件处理即可,那么可能你又会问,action没有办法集成di机制,中间件难道能集成吗,如果不能岂不是又回到了问题的原点?是的,但是中间件它的确可以集成di功能,为什么呢,因为中间件的实例化逻辑是通过ng-redux在angular内部进行的,而中间件本身呢,有仅仅是一个函数而已,那么我们完全可以把中间件的实现,声明成一个angular中的factory或者service,之后在其中使用angular的di机制,动态的实例化action中依赖服务的实例,关于这一点呢,在ng-redux的文档中有提及,但是没有说的特别的清楚。

talk is cheap, show me the code. 下面贴一下关于实现解决以上问题的中间件实现源码。

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
import angular from 'angular'
import R from 'ramda'

export default function thunkWithDepsMiddleware () {
const injector = angular.element(document.body).injector()

return ({dispatch, getState}) => next => action => {
const isNotNil = R.complement(R.isNil)
const isFSA = R.both(R.has('payload'), R.has('type'))
const isLastItemIsFunction = R.compose(R.is(Function), R.last)
const isAngularInlineArrayAnnotation = R.both(R.is(Array), isLastItemIsFunction)
let annotation

// FSA 处理逻辑
if (isFSA(action)) annotation = R.prop('payload', action)
// 普通 action 处理逻辑
else annotation = action

if (isAngularInlineArrayAnnotation(annotation)) {
const locals = {dispatch: dispatch, getState: getState}
const payload = injector.invoke(annotation, this, locals)
if (isNotNil(payload)) return dispatch({...action, payload})
} else if (R.is(Function, annotation)) {
const payload = annotation(dispatch, getState)
if (isNotNil(payload)) return dispatch({...action, payload})
} else {
return next(action)
}
}
}

大体逻辑呢,就是通过angular中$injector服务来解决问题,抽离出action中的关于di的声明逻辑,该中间间仅支持inline array annotation语法来声明依赖,对于符合FSA(Flux Standard Action)的action, 如果传入的action中的payload符合该语法,则通过injector.invoke来解析它,如果不是,则简单的按redux-thunk的处理逻辑进行处理,对于一般action同理,只不过是对于它本身进行处理。

实现之后呢,通过ng-redux的语法注册它,

1
$ngReduxProvider.createStoreWith(reducers, ['thunkWithDepsMiddleware', promiseMiddleware])

之后我们的action即可直接返回符合inline array annotation语法的数组声明依赖,如下:

1
2
3
4
export const fetchRoles = createAction(FETCH_ROLES, () => {
return ['$q', 'dispatch', ($q, dispatch) => {
// do something ...
})

more