hullis / blog Goto Github PK
View Code? Open in Web Editor NEW:book:
:book:
什么是 drag drop?来自官方网站的描述:
The @angular/cdk/drag-drop module provides you with a way to easily and declaratively create drag-and-drop interfaces, with support for free dragging, sorting within a list, transferring items between lists, animations, touch devices, custom drag handles, previews, and placeholders, in addition to horizontal lists and locking along an axis。
简单来说,drag drop 能够帮助你声明式地,方便地创建可拖拽元素。
这篇会探究以下四个 demo 所体现的 drag drop 一些 feature 是如何实现的:
drag drop 最简单的用法是在组件或 HTML 元素上声明一个 cdkDrag
指令,我们将通过追踪该指令的生命周期与其所创建的一些 listener 来探究 drag drop 的运行机制。具体而言,这篇文章的内容会包含以下几个主题:
cdkDrag
的初始化,以及相关类的初始化有关于 container 的内容将会在下一篇文章中叙述。
cdkDrag
你可以在这个 directives/drag.ts 中找到该类的实现。
为了方便阅读源码来更好地理解文章中介绍的内容,我给一些文件,类和方法添加了链接。
当你在一个 HTML 元素上声明了该指令时,它在初始化过程中做了如下几件事情,参考它的 constructor
和 afterViewInit
钩子:
if (dragDrop) {
this._dragRef = dragDrop.createDrag(element, config)
} else {
this._dragRef = new DragRef(
element,
config,
_document,
_ngZone,
viewportRuler,
dragDropRegistry
)
}
this._dragRef.data = this
this._syncInputs(this._dragRef)
this._proxyEvents(this._dragRef)
DragRef
对象,拖拽逻辑主要由该对象负责完成。这个对象十分重要,我们待会儿再详细讨论它,先看下其他代码做了什么。_syncInputs
方法会将指令的一些参数同步到 DragRef
对象上。在该方法中,cdkDrag
订阅了 DragRef
的 beforeStarted
事件,在收到该事件时调用 DragRef
的一系列以 with 的开头的方法来同步 inputs。记住这一点,后面我们会反复提及。_proxyEvents
会将自己作为 DragRef
的代理。在 _proxyEvents
方法中,订阅了 DragRef
的一系列事件并转发出去,类似于 DragRef
的代理。你或许注意到注入了一个
DragDrop
对象,这是一个用于创建DragRef
的简单服务,如果对它感兴趣你可以自行查看它的代码。
在该指令的 AfterViewInit
钩子 中,它对 handle 做了处理:当页面第一次渲染(通过 startWith
操作符来实现)或者 handle 发生改变时,订阅所有 handle 的状态改变事件,并且调用 DragRef
的方法来禁用或启用它们。我们会在讲解完主要逻辑之后再介绍 handle 是如何工作的。
DragRef
这个类执行了拖拽最主要的逻辑,它的代码在 drag-ref.ts 这个文件里。
它的 constructor
做了如下几件事情:
withRootElement
方法在 rootElement
(即绑定了 cdkDrag
指令的元素)上绑定了 drag start 事件的 listener _pointerDown
。当用户在该元素上按下鼠标左键或者是按下该元素时,_pointerDown
就会被调用。DragDropRegistry
上注册了自己。DragDropRegistry
DragDropRegistry
服务注册在根注入器上,并被 DragRef
所依赖。它负责监听 mousemove
和 touchmove
事件,并确保同一时刻只有一个 DragRef
能够响应这些事件。registerDragItem
被调用时,它会注册调用它的 DragRef
对象,然后在某个 DragRef
拖拽时,把光标移动的事件发送给它。
到这里,整个 drag drop 机制就做好了准备来响应用户的操作。
当用户在 rootElement
上按下光标时,会派发一个 mousedown
或 touchdown
事件,此时 DragRef
的 _pointerDown
方法就会被调用。
我们先考虑没有 handle 的最简单的情形。
beforeStart
事件被派发出去,此时,就像我们之前提到的那样,所有的 inputs 会从 cdkDrag
同步到 DragRef
上。_initializeDragSequance
就会实例化一个 drag sequence,会确保整个机制已经做好准备接收 move 事件的准备。一个 drag sequance 就是一次拖拽过程。
具体而言,实例化 drag sequence 的过程会做如下的事情:
其他的代码和设置状态相关,例如:
_hasStartedDragging
_hasMoved
,字面意思。_pickupPositionInElement
。以 rootElement
的左上角为原点,鼠标相对于 rootElement
的位置。_pointerDirectionDelta
,一个记录光标移动的矢量。最后它调用了 registry 的 startDragging
方法,这个方法会绑定 move 和 up 事件的 listener,这样当用户移动光标时,就会触发这两个 listener,然后把事件发送给 dragRef
。
现在用户就可以移动元素了,_pointerMove
方法会在用户移动光标时被调用。这个方法做了如下几件事情:
_hasStartedDragging
标记为 true。rootElement
。当我们讨论拖动边界的时候我们会回来讨论 _getConstrainedPointerPosition
,现在先假设 constrainedPointerPosition
就是光标此时所在的位置,看看 rootElement
的新位置是如何计算的。const activeTransform = this._activeTransform
activeTransform.x =
constrainedPointerPosition.x -
this._pickupPositionOnPage.x +
this._passiveTransform.x
activeTransform.y =
constrainedPointerPosition.y -
this._pickupPositionOnPage.y +
this._passiveTransform.y
const transform = getTransform(activeTransform.x, activeTransform.y)
this._rootElement.style.transform = this._initialTransform
? transform + ' ' + this._initialTransform
: transform
在我们开始讨论之前,让我们先来弄清楚这段代码中几个变量的含义。
activeTransform
表示在这一次的拖拽过程中,相对于 rootElement
最初的位置,rootElement
沿着 x 和 y 轴移动了多少像素。passiveTransform
表示在这一次拖拽过程开始之前,相对于 rootElement
最初的位置,rootElement
沿着 x 和 y 轴移动了多少像素。很显然,在当前这次拖拽结束过后,activatedTransform
会被赋值到 passiveTransform
上。pickUpPositionOnPage
表示指的是在这一次拖拽过程开始鼠标相对于页面原点的位置。activatedTransform
是如何计算的了:就是一个简单的向量加法,上次移动的向量,加上这次移动的向量 (当前鼠标位置减去开始拖拽时鼠标的位置)。计算完成之后,我们会通过一个辅助方法生成 transform 字符串,然后将它设置到 rootElement
的 style 上。记得之前提到过的缓存的 initialTransform
吗?它会被添加到 transform 属性后面。
现在元素已经能够随着光标移动了,现在我们来看看如何停止这个拖拽过程。
当用户释放鼠标左键或者抬起手指的时候,pointerUp
方法就会被调用。它会取消订阅来自 registry 的 move 和 up 事件,DragDropService
所绑定的全局事件 listener 也会被移除。然后它将 passiveTransform
赋值给 activatedTransform
,这样下一次拖拽开始时就会有一个正确的起始点。
Bingo!现在整个拖拽过程就完成了。
这里我们会谈论一些之前跳过的高级内容。
当我们在第三阶段中讨论定位问题时我们略过了 _getConstrainedPointerPosition
方法,现在我们来谈一谈边界机制。
当拖拽开始时 beforeStarted
事件派发,withBoundaryElement
方法会被调用。此时,getClosestMatchingAncestor
方法使用一个 CSS 选择器,来选择 rootElement
最近的一个满足选择器的祖先元素作为边界元素。
当 drag sequence 被初始化的时候,该元素的位置信息被赋值给 this._boundaryRect
。
if (this._boundaryElement) {
this._boundaryRect = this._boundaryElement.getBoundingClientRect()
}
getBoundingClientRect
会触发 reflow,所以必须要在每次拖动开始的时候进行计算并缓存。
在_getConstrainedPointerPosition
中,光标的位置会被修改已确保rootElement
不会超出边界元素。
const point = this._getPointerPositionOnPage(event)
if (this._boundaryRect) {
const { x: pickupX, y: pickupY } = this._pickupPositionInElement
const boundaryRect = this._boundaryRect
const previewRect = this._previewRect!
const minY = boundaryRect.top + pickupY
const maxY = boundaryRect.bottom - (previewRect.height - pickupY)
const minX = boundaryRect.left + pickupX
const maxX = boundaryRect.right - (previewRect.width - pickupX)
point.x = clamp(point.x, minX, maxX)
point.y = clamp(point.y, minY, maxY)
}
对 handle 比较准确的翻译是“把手”。
早前我们就提到 cdkDrag
会负责 handle 的处理。当 handle 改变时,withHandles
方法会被调用。
tap((handles: QueryList<CdkDragHandle>) => {
const childHandleElements = handles
.filter(handle => handle._parentDrag === this)
.map(handle => handle.element);
this._dragRef.withHandles(childHandleElements);
}),
这个 filter 是如何工作的,它怎么知道哪些 handler 属于当前的 cdkDrag
呢?原来 cdkDrag
会在 DragHandle
的 constructor 中被注入:
constructor(
public element: ElementRef<HTMLElement>,
@Inject(CDK_DRAG_PARENT) @Optional() parentDrag?: any) {
this._parentDrag = parentDrag;
toggleNativeDragInteractions(element.nativeElement, false);
}
cdkDrag
的 metadata 中也体现了 cdkDrag
会以 CDK_DRAG_PARENT
这个令牌注入到它的 handle 子元素中:
@Directive({
selector: '[cdkDrag]',
exportAs: 'cdkDrag',
host: {
'class': 'cdk-drag',
'[class.cdk-drag-dragging]': '_dragRef.isDragging()',
},
providers: [{provide: CDK_DRAG_PARENT, useExisting: CdkDrag}]
})
至于 withHandles
,它仅仅是注册这些 handle 的 HTML 元素。
在 _pointerDown
方法中,如果有 handle 的话,handle 就会代替 rootElement
作为是否应该开始一个 drag sequence 的依据。
if (this._handles.length) {
const targetHandle = this._handles.find(handle => {
const target = event.target
return (
!!target && (target === handle || handle.contains(target as HTMLElement))
)
})
if (
targetHandle &&
!this._disabledHandles.has(targetHandle) &&
!this.disabled
) {
this._initializeDragSequence(targetHandle, event)
}
} else if (!this.disabled) {
this._initializeDragSequence(this._rootElement, event)
}
contains
这个 API 可以判断某个元素是否是另一个元素的子孙元素。
我们如何知道一个 handle 是启用还是禁用呢?还记得在 cdkDrag
中 _pointerDown
事件的 subscriber 吗?这行代码
handleInstance.disabled ?dragRef.disableHandle(handle) : dragRef.enableHandle(handle);
会启用或禁用 handle。
我们知道了如何将 rootElement
限定在边界元素之内,锁定拖动的方向就更简单了,只需要重新赋 x 或 y 的值就可以了。
if (this.lockAxis === 'x' || dropContainerLock === 'x') {
point.y = this._pickupPositionOnPage.y
} else if (this.lockAxis === 'y' || dropContainerLock === 'y') {
point.x = this._pickupPositionOnPage.x
}
这样 constrainedPointerPosition.x - this._pickupPositionOnPage.x 或者 constrainedPointerPosition.y - this._pickupPositionOnPage.y
就会等于 0,在某个方向上的 transform 也就不会变化了。
这篇文章详细的解释了在没有 container 的情况下,drag drop 是如何工作的,以及 handle,axis locking 和 boundary 是如何生效的。
cdkDrag
指令的元素会变成可拖拽的,但 DragRef
是拖拽逻辑的主要执行者在进一步深入学习 vscode 的各种机制之前,我们先对 vscode 当中的一些基础工具做一些探索,因为核心机制大量地用到了这些基础模块,这篇文章将会介绍事件(event)模块,相关代码在 vs/base/common/event.ts 文件中。
Event 接口规定了一个函数,当调用了这个函数,就表示监听了这个函数所对应的事件流。
listener
参数是事件派发时将会被调用的回调函数,参数 e
为单个事件,换句话说, listener
就是事件的消费者thisArgs
参数是回调函数中 this
所指向的对象disposables
export interface Event<T> {
(listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;
}
返回的 IDisposable
对象用于解除这个监听的(通过调用它的 dispose
方法)。
另外一种解除监听的方式就是 disposable
了,Event 函数在执行的过程中会将 IDisposable
插入 disposables
,方便调用方决定在什么时候解除监听。
有了 Event
接口,我们可以规定事件如何被消费,那么事件是如何产生的呢?一种方法就是通过 Emitter
。
Emitter
类型暴露了两个重要方法:
fire
,从这个方法的函数签名就能看出它就是用来派发一个事件的,该方法的主要逻辑就是将 this._listeners
当中的保存的 listener
全部调用一遍(省略了部分分支逻辑和性能监控相关代码)
fire(event: T): void {
if (this._listeners) {
for (let listener of this._listeners) {
this._deliveryQueue.push([listener, event]);
}
while (this._deliveryQueue.size > 0) {
const [listener, event] = this._deliveryQueue.shift()!;
try {
if (typeof listener === 'function') {
listener.call(undefined, event);
} else {
listener[0].call(listener[1], event);
}
} catch (e) {
onUnexpectedError(e);
}
}
}
}
get event()
,这个方法会在 Emitter
中创建一个 Event
,其主要逻辑就是将 listener
添加到 this._listeners
当中
const remove = this._listeners.push(!thisArgs ? listener : [listener, thisArgs]);
Emitter
类型还提供了一些特殊的回调接口:
export interface EmitterOptions {
onFirstListenerAdd?: Function;
onFirstListenerDidAdd?: Function;
onListenerDidAdd?: Function;
onLastListenerRemove?: Function;
}
这使得 Emitter
在注册消费者的时候执行一些额外的逻辑。我们将会在下文中看到其中一些回调所扮演的重要角色。
vscode 还提供了一系列工具方法用于组合 Event
,得到更加丰富的事件处理能力。下面我们一一进行说明。
export function once<T>(event: Event<T>): Event<T> {
return (listener, thisArgs = null, disposables?) => {
// we need this, in case the event fires during the listener call
let didFire = false;
let result: IDisposable;
result = event(/* A */ e => {
if (didFire) {
return;
} else if (result) {
result.dispose();
} else {
didFire = true;
}
return listener.call(thisArgs, e);
}, null, disposables);
if (didFire) {
result.dispose();
}
return result;
};
}
这个方法用于将一个 Event
变为只能派发一次的,事件类型相同的 Event
。
每一个事件到达时,会从 A 处开始执行,可以看到这段代码通过 didFire
作为锁,保证 listener.call(thisArgs, e)
只会被执行一次。
很明显, once
的执行过程中有两个 Event
,那么消息如何在 Event 之间传递的呢?我们注意到 A 处的匿名函数调用了一个 Event
的 listener
,而 A 本身又是另一个 Event 的 listener
,所以答案是很明显的:消息沿着 Event
链传递的过程,就是 Event
的 listener
们递归调用的过程。
export function snapshot<T>(event: Event<T> /* B */): Event<T> {
let listener: IDisposable;
const emitter = new Emitter<T>({
onFirstListenerAdd() {
listener = event(emitter.fire, emitter);
},
onLastListenerRemove() {
listener.dispose();
}
});
/* C */
return emitter.event;
}
这个工具方法用于生成 map
等操作,我们把它和 map
一起分析。
将一种类型的事件转换成另一种类型的事件,看起来和 Array
的 map
非常相似。
export function map<I, O>(event: Event<I> /* A */, map: (i: I) => O): Event<O> {
return snapshot(
/* B */
(listener, thisArgs = null, disposables?) => event(i => listener.call(thisArgs, map(i)), null, disposables));
}
从代码可以看出:这里的 Event
链的顺序是
map
装饰的 Event
Asnapshot
的参数,匿名的 Event
BEmitter
暴露出的 Event
C当用户调用这个 map
转换出的 Event
的时候,实际上订阅的是 C,然后 C 在第一次被订阅时,会调用 B,而 B 又去订阅了 A。这里我们看到了 Emitter
的参数钩子起到了什么作用:B 是一个很特殊的 Event
它在 onFirstListenerAdd
中被订阅了 ,并且之后它并不会参与到 listener 的调用链中来,而是帮助 A 和 C 的 listener 之间创建了调用链,同时调用 map
对事件做了处理。
当有事件传递过来的时候,则是调用 A 的 listener i => listener.call(thisArgs, map(i))
,而这里的 listener
很明显可以看出是 C 的 listener,也就是 Emitter
的 fire
方法,通过上文中对 Emitter 的学习,我们知道,fire 方法会触发用户调用 C 时所传递来的 listener,这样整个传递链条就完整了。
了解了 map
的工作原理之后,这三个函数就容易理解了,大家可以自行阅读代码。
这个函数仅仅是做了一下类型转换,让订阅者忽略事件所携带的数据,比较简单。
export function any<T>(...events: Event<T>[]): Event<T> {
return (listener, thisArgs = null, disposables?) => combinedDisposable(...events.map(event => event(e => listener.call(thisArgs, e), null, disposables)));
}
这个方法会在 events 中任意一个 Event
派发事件的时候派发一个事件。
对 Event 链条上的事件做防抖处理。
export function debounce<T>(event: Event<T>, merge: (last: T | undefined, event: T) => T, delay?: number, leading?: boolean, leakWarningThreshold?: number): Event<T>;
export function debounce<I, O>(event: Event<I>, merge: (last: O | undefined, event: I) => O, delay?: number, leading?: boolean, leakWarningThreshold?: number): Event<O>;
export function debounce<I, O>(event: Event<I>, merge: (last: O | undefined, event: I) => O, delay: number = 100, leading = false, leakWarningThreshold?: number): Event<O> {
let subscription: IDisposable;
let output: O | undefined = undefined;
let handle: any = undefined;
let numDebouncedCalls = 0;
const emitter = new Emitter<O>({
leakWarningThreshold,
onFirstListenerAdd() {
subscription = event(/* A */ cur => {
numDebouncedCalls++;
output = merge(output, cur);
if (leading && !handle) {
emitter.fire(output);
output = undefined;
}
clearTimeout(handle);
handle = setTimeout(() => {
const _output = output;
output = undefined;
handle = undefined;
if (!leading || numDebouncedCalls > 1) {
emitter.fire(_output!);
}
numDebouncedCalls = 0;
}, delay);
});
},
onLastListenerRemove() {
subscription.dispose();
}
});
return emitter.event;
}
不难看出这段代码的核心逻辑就是 A 处的 listener,它会对 debounce 时间内对数据做归并处理,并设置定时器,当收到新事件时就取消定时器,而定时器到期时就调用 emitter.fire 向下游继续发送事件。
这是一个记录耗时的 Event
,当它收到第一个事件时,会把这个事件转换为它从创建到收到该事件的耗时。
这个 Event
仅有当事件确实发生变化时,才会向下游发送事件。原理也很简单,就是在 filter
的基础上,利用闭包来缓存上一次事件的数据,然后用新数据和它做比较,新老数据不同或者是第一次接收数据才放通。
这个 Event
在没有人订阅它时,会缓存所有收到的事件,并在收到订阅时将已经缓存的事件全部发送出去。
export function buffer<T>(event: Event<T>, nextTick = false, _buffer: T[] = []): Event<T> {
let buffer: T[] | null = _buffer.slice();
let listener: IDisposable | null = event(e => {
if (buffer) {
buffer.push(e);
} else {
emitter.fire(e);
}
});
const flush = () => {
if (buffer) {
buffer.forEach(e => emitter.fire(e));
}
buffer = null;
};
const emitter = new Emitter<T>({
onFirstListenerAdd() {
if (!listener) {
listener = event(e => emitter.fire(e));
}
},
onFirstListenerDidAdd() {
if (buffer) {
if (nextTick) {
setTimeout(flush);
} else {
flush();
}
}
},
onLastListenerRemove() {
if (listener) {
listener.dispose();
}
listener = null;
}
});
return emitter.event;
}
如果调用 buffer 时传入了 nextTick = True
,则发送缓存事件的操作会易步进行,所以如果你第一次订阅时同步添加了很多 listener,则它们都会收到这些缓存的事件。
如果要多次使用 map
, filter
等函数,一个比较优雅的写法是链式调用,例如 Event.map.filter.xxx
, ChainableEvent
就是为此准备的,通过调用 chain
方法,一个 Event
会转换成 ChainableEvent
,然后就可以进行链式调用:
export function chain<T>(event: Event<T>): IChainableEvent<T> {
return new ChainableEvent(event);
}
ChainableEvent
的实现很简单,就是对上面的方法进行了一次包裹,这里就不再赘述了。
除了上面提到的对 Event
的转换方法之外,还有一些生成 Event
的方法。
export function fromNodeEventEmitter<T>(emitter: NodeEventEmitter, eventName: string, map: (...args: any[]) => T = id => id): Event<T> {
const fn = (...args: any[]) => result.fire(map(...args));
const onFirstListenerAdd = () => emitter.on(eventName, fn);
const onLastListenerRemove = () => emitter.removeListener(eventName, fn);
const result = new Emitter<T>({ onFirstListenerAdd, onLastListenerRemove });
return result.event;
}
该方法是对 node.js 原生事件的包裹,在原生事件的回调中调用 Emitter.fire
。
对 DOM 事件进行包装,和上面的非常相似,这里就不赘述了。
export function fromPromise<T = any>(promise: Promise<T>): Event<undefined> {
const emitter = new Emitter<undefined>();
let shouldEmit = false;
promise
.then(undefined, () => null)
.then(() => {
if (!shouldEmit) {
setTimeout(() => emitter.fire(undefined), 0);
} else {
emitter.fire(undefined);
}
});
shouldEmit = true;
return emitter.event;
}
将 Promise 转换为事件。通过 shouldEmit
确保 Promise 不会因为已经 resolve 而在订阅发生之前就开始派发事件(这样会导致错过事件)。
奇怪的是这里丢失了 Promise 返回的结果,不知道为什么这么设计,可能是 vscode 自己用不着吧。
将事件转换为 Event,这个也比较简单。
另外,还有一些工具类提供更多的事件管理能力。
类似于 Emitter
,但是能通过 pause
和 resume
方法暂停一条 Event 链上事件的传播,比较简单。
这个类可以订阅多个事件,并在任意一个事件派发的时候,将该事件转发给自己所有的订阅者,它的核心就是它的 hook
方法:
private hook(e: { event: Event<T>; listener: IDisposable | null; }): void {
e.listener = e.event(r => this.emitter.fire(r));
}
也较为简单,这里不再赘述。
这是一个非常有趣的类,它提供了一个 wrapEvent
方法包裹一个 Event
,并提供了一个 bufferEvents
方法,在这个方法的回调内所有经过它 wrapEvent
包裹的 Event
,都先不会被传播给订阅者。
/**
* The EventBufferer is useful in situations in which you want
* to delay firing your events during some code.
* You can wrap that code and be sure that the event will not
* be fired during that wrap.
*
* ```
* const emitter: Emitter;
* const delayer = new EventDelayer();
* const delayedEvent = delayer.wrapEvent(emitter.event);
*
* delayedEvent(console.log);
*
* delayer.bufferEvents(() => {
* emitter.fire(); // event will not be fired yet
* });
*
* // event will only be fired at this point
* ```
*/
export class EventBufferer {
private buffers: Function[][] = [];
wrapEvent<T>(event: Event<T>): Event<T> {
return (listener, thisArgs?, disposables?) => {
return event(i => {
const buffer = this.buffers[this.buffers.length - 1];
if (buffer) {
buffer.push(() => listener.call(thisArgs, i));
} else {
listener.call(thisArgs, i);
}
}, undefined, disposables);
};
}
bufferEvents<R = void>(fn: () => R): R {
const buffer: Array<() => R> = [];
this.buffers.push(buffer);
const r = fn();
this.buffers.pop();
buffer.forEach(flush => flush());
return r;
}
}
当 bufferEvents
被调用的时候,会往 this.buffers
中压入一个新 buffer,在 fn 执行过程中派发的事件,就会因为 if (buffer)
判断为 true
而被缓存, fn
执行完毕之后, buffer
被弹出,其中包含的事件全部被派发。
这个类提供了切换上游 Event
的方法。当设置 Relay
的 input
属性时,就会切换监听的 Event
,而下游的 Event
监听的是 Relay
的 Emitter
,因此无需重新设置监听。
export class Relay<T> implements IDisposable {
// ...
set input(event: Event<T>) {
this.inputEvent = event;
if (this.listening) {
this.nputEventListener.dispose();
this.inputEventListener = event(this.emitter.fire, this.emitter);
}
}
// ...
}
至此我们已经学习了 vscode 事件模块的主要内容(其他的性能分析和泄漏监测等这篇文章就不分析了,感兴趣的读者可以自行阅读)。
vscode 事件模块是所谓响应式编程的一种实现,如果想要继续学习响应式编程,非常推荐以下两个项目:
Event
类似于 Observable
, map
reduce
filter
等函数,在 RxJS 中都有同名的操作符,但是 RxJS 更加强大,除了传递事件外,还能够传递异常以及事件流结束信息、支持事件调度等等,操作符更是要多得多Immer 是一个非常好玩的库,我在等飞机的时候读了一下它的源码。这篇文章(笔记?)旨在于通过阅读源码分析 Immer 的原理。我仅考虑了最常简单的使用方式,mutate 的对象也只是 plain object 而已,你可以在阅读本文之后再去探究其他 topic。
最 common 的 produce 执行流程:
ImmerScope.scope
base
创建 root proxy,根据 base 的数据类型选择正确的 trap
result = recipe(proxy)
,proxy 的 trap 执行变更逻辑scope.leave
processResult
1
代表一次 produce
调用,也即 produce
执行的 context 。
/** Each scope represents a `produce` call. */
export class ImmerScope {
static current?: ImmerScope
patches?: Patch[]
inversePatches?: Patch[]
canAutoFreeze: boolean
drafts: any[]
parent?: ImmerScope
patchListener?: PatchListener
immer: Immer
constructor(parent: ImmerScope | undefined, immer: Immer) {
this.drafts = []
this.parent = parent
this.immer = immer
// Whenever the modified draft contains a draft from another scope, we
// need to prevent auto-freezing so the unowned draft can be finalized.
this.canAutoFreeze = true
}
usePatches(patchListener?: PatchListener) {}
revoke() {}
leave() {}
static enter(immer: Immer) {}
}
2 3
创建了一个数据结构 ProxyState
,里面保存了 base
,Proxy 的 target 是这个 state
而非 base
。
const state: ProxyState = {
type: isArray ? ProxyType.ProxyArray : (ProxyType.ProxyObject as any),
// Track which produce call this is associated with.
scope: parent ? parent.scope : ImmerScope.current!,
// True for both shallow and deep changes.
modified: false,
// Used during finalization.
finalized: false,
// Track which properties have been assigned (true) or deleted (false).
assigned: {},
// The parent draft state.
parent,
// The base state.
base,
// The base proxy.
draft: null as any, // set below
// Any property proxies. 当前对象的属性的 proxy
drafts: {},
// The base copy with any updated values.
copy: null,
// Called by the `produce` function.
revoke: null as any,
isManual: false
}
这里主要关心 set get delete 三种会改变属性值的操作。
const objectTraps: ProxyHandler<ProxyState> = {
get(state, prop) {
if (prop === DRAFT_STATE) return state
let {drafts} = state
// Check for existing draft in unmodified state.
// 如果当前对象未被需改(这也意味着属性没有被修改),而且属性已经有了 Proxy
// 就返回属性的 Proxy,它能够处理之后的操作
if (!state.modified && has(drafts, prop)) {
return drafts![prop as any]
}
// 否则就获取属性的最新值
const value = latest(state)[prop]
// 如果这个 produce 过程已进入后处理阶段,或者属性对应的值不可代理,就直接返回
if (state.finalized || !isDraftable(value)) {
return value
}
// Check for existing draft in modified state.
// 如果当前对象已经被修改过
if (state.modified) {
// Assigned values are never drafted. This catches any drafts we created, too.
// 如果最新值不等于初始值,那么就返回这个最新值
if (value !== peek(state.base, prop)) return value
// Store drafts on the copy (when one exists).
// @ts-ignore
drafts = state.copy
}
// 否则为属性创建 Proxy,设置到 drafts 上并返回该 Proxy
return (drafts![prop as any] = state.scope.immer.createProxy(value, state))
},
set(state, prop: string /* strictly not, but helps TS */, value) {
// 如果当前对象没有被修改过
if (!state.modified) {
// 获取初始值,检查值是否发生了变化
const baseValue = peek(state.base, prop)
// Optimize based on value's truthiness. Truthy values are guaranteed to
// never be undefined, so we can avoid the `in` operator. Lastly, truthy
// values may be drafts, but falsy values are never drafts.
const isUnchanged = value
? is(baseValue, value) || value === state.drafts![prop]
: is(baseValue, value) && prop in state.base
if (isUnchanged) return true
// 没有变化直接返回,有变化执行以下逻辑
prepareCopy(state) // 如果当前对象没有被拷贝过,制作一层的浅拷贝
markChanged(state) // 将当前对象标记为脏,要向上递归
}
// 标识次属性也赋值过
state.assigned[prop] = true
// @ts-ignore
// 将新值设置到 copy 对象上
state.copy![prop] = value
return true
},
deleteProperty(state, prop: string) {
// 这个和 set 差不多,简单
// The `undefined` check is a fast path for pre-existing keys.
if (peek(state.base, prop) !== undefined || prop in state.base) {
state.assigned[prop] = false
prepareCopy(state)
markChanged(state)
} else if (state.assigned[prop]) {
// if an originally not assigned property was deleted
delete state.assigned[prop]
}
// @ts-ignor
if (state.copy) delete state.copy[prop]
return true
}
}
export function processResult(immer: Immer, result: any, scope: ImmerScope) {
const baseDraft = scope.drafts![0] // 获取根 draft,也就是调用 produce 所生成的 draft
const isReplaced = result !== undefined && result !== baseDraft
immer.willFinalize(scope, result, isReplaced)
if (isReplaced) {
if (baseDraft[DRAFT_STATE].modified) {
scope.revoke()
throw new Error("An immer producer returned a new value *and* modified its draft. Either return a new value *or* modify the draft.") // prettier-ignore
}
if (isDraftable(result)) {
// Finalize the result in case it contains (or is) a subset of the draft.
result = finalize(immer, result, scope)
maybeFreeze(immer, result)
}
// ... patches 相关逻辑
} else {
// Finalize the base draft.
// 从根 draft 开始整理 result,移除当中的 Proxy
result = finalize(immer, baseDraft, scope, [])
}
// ... patches 相关逻辑
return result !== NOTHING ? result : undefined
}
finalize
finalizeProperty
finalizeTree
三者递归调用。
Portal 用于在任意位置动态渲染模板或组件。
Portal 指的是需要动态创建的内容(模板或者组件),而 PortalOutlet 指的是渲染这些内容的位置。
portal
├── BUILD.bazel
├── dom-portal-outlet.ts // DomPortalOutlet
├── index.ts
├── portal-directives.ts // 包含所有的指令,使得你能够以声明式的使用方式来使用 Portal
├── portal-errors.ts // 包含所有的错误信息
├── portal-injector.ts // 你可以临时创建一个 Injector 给 Portal,从而干预依赖注入
├── portal.md
├── portal.spec.ts
├── portal.ts // 定义了核心部分的几个类
├── public-api.ts
└── tsconfig-build.json
最重要的文件有以下三个:
Portal
和 BasePortalOutlet
,还定义了类 ComponentPortal
和 TemplatePortal
。CdkPortal
和 CdkPortalOutlet
,让我们可以以声明式的方式使用 portal,后者还定义了动态创建内容的机制。DomPortalOutlet
,使得 portal 可以被渲染到 Angular 的组件树之外(这一点被 overlay 模块所使用,之后会 cover 到这部分内容)。以 README 中的示例来讲解这部分代码是如何工作的:
this.userSettingsPortal = new ComponentPortal(UserSettingsComponent)
<!-- Attaches the `userSettingsPortal` from the previous example. -->
<ng-template [cdkPortalOutlet]="userSettingsPortal"></ng-template>
示例中的代码首先创建了一个 ComponentPortal
,那我们就先来看 ComponentPoral
和其父类 Portal
。
Portal 这个类其实非常简单,用于挂载和卸载 portal 的几个所做的事情基本都是:检查边界情况,然后调用 PortalOutlet
的对应方法。
ComponentPortal 这个类也非常简单,它仅仅是对动态创建组件所需要的数据结构的一个封装。这些数据结构包括:
component
viewContainerRef
injector
componentFactory
后面三个参数在构造一个 ComponentPortal
的时候都是可选的,注意这一点,之后在讲解动态渲染过程中就会了解到为啥是可选的。
示例中的代码到这里,就会给 cdkPortalOutlet
赋值这个新创建的 ComponentPortal
。
<ng-template [cdkPortalOutlet]="userSettingsPortal"></ng-template>
我们再来看 cdkPortalOutlet
和其父类 BasePortalOutlet
的代码。
BasePortalOutlet 有以下要点:
attach
方法,该方法会根据需要挂载的不同类型的 portal,来调用attachComponentPortal
和 attachTemplatePortal
两个方法。attachComponentPortal
和 attachTemplatePortal
两个方法。为什么这里没有做实现?是因为对于在 Angular 组件树中的 outlet 和不在组件树中的 outlet,实现是不同的,我们将会看到这一点。setDisposeFn
,子类可以设置 portal 卸载的时候需要触发的回调函数。cdkPortalOutlet 有以下要点:
ComponentFactoryResolver
和 ViewContainerRef
。cdkPortalOutlet
,被赋值到 portal
属性上。portal
被赋值的时候,调用父类的 attach
方法。在例子里,我们的 portal 是一个 ComponentPortal
,这里我们就只分析该情形,即 attachComponentPortal
被调用的情形。
createComponent
函数所需要的参数,总而言是就是如果 Portal 没有携带这些参数,就用 PortalOutlet 的(组件树中的)位置所能 get 到的参数(呼应了我们前面讲过的要点)。ViewContainerRef
的 createComponent
方法动态创建一个组件。到这里,就是 Portal 机制的主要运行过程了。
接下来我们 cover 一些之前没有 cover 到的要点。
cdkPortal 允许使用者以声明式的方式创建一个 TemplatePortal
,它的代码非常简单,仅仅是把 TemplatePortal
变成了一个结构型指令。
我们看到 cdkPortalOutlet
是以声明式方式使用的,这意味着它必须在 Angular 的组件树当中,如果我们想把 portal 挂载到组件树之外的位置,就需要用 DomPortalOutlet
。
可以看到 DomPortalOutlet
和 cdkPortalOutlet
有以下不同:
ViewContainerRef
,就调用 ComponentFactory
的 create
方法直接得到组件的 view,挂载在应用的根 view 上,然后手动 append DOM 元素。以上不同的根源都是挂载到组件树之外的 portal 可能会没有 ViewContaienerRef
。
在创建 ComponentPortal 的时候我们可以传入一个 Injector,而 PortalInjector 允许我们对这个 Injector 作出干预,增加新的 provider。
可以看到它所做的全部事情,其实就是在返回依赖项的时候,检查用户而外提供的 provider 里有没有符合依赖注入令牌的。
我们都知道通过 Angular 的 ViewContrainerRef
提供的 createEmbedeView
和 createComponent
方法就可以动态创建界面内容,为什么还需要 CDK 提供的 Portal 呢?通过上文的分析,我们可以得出使用 Portal 的一些好处:
ViewContainerRef
和 Injector
等,从而改变变更检测的顺序,依赖注入项的获取等等,更加灵活。Portal 被 Angular CDK 的其他很多模块所采用。
vscode 的架构中主要有四类进程:主进程、渲染进程、shared 进程和 host 进程,这四个进程之间会发生进程间调用(Inter Process Calling, IPC)。vscode 中有专门的 IPC 模块来实现 IPC 机制,这篇文章将会深入介绍 vscode IPC 模块的设计和原理。
在我们开始学习 vscode 的 IPC 机制之前,不妨根据我们已经掌握的关于计算机网络的基本知识,来推演一下 IPC 有何要点:
可以看到 IPC 理念上是比较简单的,而 vscode IPC 模块的优点在于,它清楚地定义了 IPC 模块的各个层次,将客户端的调用过程封装得就像是在调用本地的一个异步方法一样,还让不同的跨进程环境——例如本地进程、基于网络的跨进程、web worker——都能够很容易地实现。
vscode IPC 分为基于 Channel 的和基于 RpcProtocol 的两种。
我们通过一个例子开始对 Channel 机制的介绍。
在渲染进程初始化的时候,会创建一个 ElectronIPCMainProcessService
,然后以此创建一个 LoggerChannelClient
,并以 ILoggerService
为 key 添加到依赖注入系统当中:
// Main Process
const mainProcessService = this._register(new ElectronIPCMainProcessService(this.configuration.windowId));
serviceCollection.set(IMainProcessService, mainProcessService);
// Logger
const loggerService = new LoggerChannelClient(mainProcessService.getChannel('logger'));
serviceCollection.set(ILoggerService, loggerService);
我们进一步看 LoggerChannelClient
的实现的话,就会发现它会调用 channel
的 call
方法,这里就就发起了一个 IPC:
export class LoggerChannelClient implements ILoggerService {
constructor(private readonly channel: IChannel) { }
createConsoleMainLogger(): ILogger {
return new AdapterLogger({
log: (level: LogLevel, args: any[]) => {
this.channel.call('consoleLog', [level, args]);
}
});
}
}
就是 channel IPC。
channel IPC 主要支持两种类型的调用,通过下面这个枚举类型可以看出:
export const enum RequestType {
Promise = 100,
PromiseCancel = 101,
EventListen = 102,
EventDispose = 103
}
我们这篇文章将会以基于 Promise 的调用为例,基于事件的调用大家可以自行了解。
channel IPC 主要有以下这些参与者,它们之间的关系如下图所示:
服务端
IServerChannel
,一个 IServerChannel
和一种服务对应,它们提供了 call
和 listen
两个方法给 ChannelServer
调用,然后调用它们对应的服务来具体执行各种业务逻辑,实际上是对服务的一种包裹IChannelServer
,它负责监听 IMessagePassingProtocol
传过来的请求,然后根据请求中指定的 channelName 来找到 IServerChannel
并进行调用,还能将执行结果返回给客户端IPCServer
,它提供了一组注册和获取 IServerChannel
的方法,并能够通过路由机制选择要通讯的客户端IMessagePassingProtocol
,它负责传输 Uint8Array
类型的二进制信息,并且在收到消息的时候通过事件通知上层客户端
IChannel
提供的方法来发起一个 IPCIChannel
,它们提供了 call
和 listen
两个方法给业务代码调用,用以发起 IPCIChannelClient
,它们是实际发起请求的地方,会将请求封装成一定的数据格式,在接收到响应的时候返回给业务代码IPCClient
,它提供一组注册和获取 IChannel
的方法IMessagePassingProtocol
,和它在服务端的对等方的功能一致下面我们会具体讲解每个模块的机制。
阅读过本系列之前两篇关于依赖注入和服务化的读者,应该已经知道 vscode 中各种功能都是封装在服务当中的,所以 IPC 的执行过程中必须要找到某个能响应特定调用的服务,IServerChannel
则是负责和服务一一对应,帮助它们接入 IPC 系统的,我们将称为实体。
而在客户端一侧,业务代码不知道 IPC 机制的接口,因此不能直接发起请求,而是将 IChannel
作为一个能够帮助它发起请求的代理。
IserverChannel
和 IChannel
分别就是实体和代理的接口:
export interface IChannel {
call<T>(command: string, arg?: any, cancellationToken?: CancellationToken): Promise<T>;
listen<T>(event: string, arg?: any): Event<T>;
}
/**
* An `IServerChannel` is the counter part to `IChannel`,
* on the server-side. You should implement this interface
* if you'd like to handle remote promises or events.
*/
export interface IServerChannel<TContext = string> {
call<T>(ctx: TContext, command: string, arg?: any, cancellationToken?: CancellationToken): Promise<T>;
listen<T>(ctx: TContext, event: string, arg?: any): Event<T>;
}
一个 IChannel
像就这样(即 return 返回的对象):
getChannel<T extends IChannel>(channelName: string): T {
const that = this;
return {
call(command: string, arg?: any, cancellationToken?: CancellationToken) {
if (that.isDisposed) {
return Promise.reject(errors.canceled());
}
return that.requestPromise(channelName, command, arg, cancellationToken);
},
listen(event: string, arg: any) {
if (that.isDisposed) {
return Promise.reject(errors.canceled());
}
return that.requestEvent(channelName, event, arg);
}
} as T;
}
而一个 IServerChannel
会像是这样:
export class TestChannel implements IServerChannel {
constructor(private testService: ITestService) { }
listen(_: unknown, event: string): Event<any> {
switch (event) {
case 'marco': return this.testService.onMarco;
}
throw new Error('Event not found');
}
call(_: unknown, command: string, ...args: any[]): Promise<any> {
switch (command) {
case 'pong': return this.testService.pong(args[0]);
case 'cancelMe': return this.testService.cancelMe();
case 'marco': return this.testService.marco();
default: return Promise.reject(new Error(`command not found: ${command}`));
}
}
}
在这个例子中 TestChannel
就是对 ITestService
的一层封装。
创建 IServerChannel 的方式有很多种,除了上面这样的直接实现,还可以借助 ProxyChannel
namespace 提供的方法。
如果不需要为 service 做一些特殊处理,可以直接使用 ProxyChannel
namespace 下的 fromService
方法将一个 service 包装成一个 IServerChannel
:
export function fromService(service: unknown, options?: ICreateServiceChannelOptions): IServerChannel {
const handler = service as { [key: string]: unknown };
const disableMarshalling = options && options.disableMarshalling;
// Buffer any event that should be supported by
// iterating over all property keys and finding them
const mapEventNameToEvent = new Map<string, Event<unknown>>();
for (const key in handler) {
if (propertyIsEvent(key)) {
mapEventNameToEvent.set(key, Event.buffer(handler[key] as Event<unknown>, true));
}
}
return new class implements IServerChannel {
listen<T>(_: unknown, event: string): Event<T> {
const eventImpl = mapEventNameToEvent.get(event);
if (eventImpl) {
return eventImpl as Event<T>;
}
throw new Error(`Event not found: ${event}`);
}
call(_: unknown, command: string, args?: any[]): Promise<any> {
const target = handler[command];
if (typeof target === 'function') {
// Revive unless marshalling disabled
if (!disableMarshalling && Array.isArray(args)) {
for (let i = 0; i < args.length; i++) {
args[i] = revive(args[i]);
}
}
return target.apply(handler, args);
}
throw new Error(`Method not found: ${command}`);
}
};
}
同样的,也可以通过 toService
将 IChannel
封装成服务供业务代码调用,这样业务代码就不用自己去调用 IChannel
的 call
或者 listen
方法。
export function toService<T>(channel: IChannel, options?: ICreateProxyServiceOptions): T {
const disableMarshalling = options && options.disableMarshalling;
return new Proxy({}, {
get(_target: T, propKey: PropertyKey) {
if (typeof propKey === 'string') {
// Check for predefined values
if (options?.properties?.has(propKey)) {
return options.properties.get(propKey);
}
// Event
if (propertyIsEvent(propKey)) {
return channel.listen(propKey);
}
// Function
return async function (...args: any[]) {
// Add context if any
let methodArgs: any[];
if (options && !isUndefinedOrNull(options.context)) {
methodArgs = [options.context, ...args];
} else {
methodArgs = args;
}
const result = await channel.call(propKey, methodArgs);
// Revive unless marshalling disabled
if (!disableMarshalling) {
return revive(result);
}
return result;
};
}
throw new Error(`Property not found: ${String(propKey)}`);
}
}) as T;
}
本质上是创建了一个 Proxy,将对 Proxy 属性的访问转换成对 channel 的 call
listen
方法的调用。
IChannelServer
的主要职责包括:
protocol
接收消息IServerChannel
来处理请求IServerChannel
IChannelServer
直接监听 protocol
的消息,然后调用自己的 onRawMessage
方法处理请求。onRawMessge
会根据请求的类型来调用其他方法。以基于 Promise 的调用为例,可以看到它的核心逻辑就是调用 IServerChannel
的 call
方法。
private onRawMessage(message: VSBuffer): void {
const reader = new BufferReader(message);
const header = deserialize(reader);
const body = deserialize(reader);
const type = header[0] as RequestType;
switch (type) {
case RequestType.Promise:
if (this.logger) {
this.logger.logIncoming(message.byteLength, header[1], RequestInitiator.OtherSide, `${requestTypeToStr(type)}: ${header[2]}.${header[3]}`, body);
}
return this.onPromise({ type, id: header[1], channelName: header[2], name: header[3], arg: body });
// ...
}
}
private onPromise(request: IRawPromiseRequest): void {
const channel = this.channels.get(request.channelName);
let promise: Promise<any>;
try {
promise = channel.call(this.ctx, request.name, request.arg, cancellationTokenSource.token);
} catch (err) {
// ...
}
const id = request.id;
promise.then(data => {
this.sendResponse(<IRawResponse>{ id, data, type: ResponseType.PromiseSuccess });
this.activeRequests.delete(request.id);
}, err => {
// ...
});
}
可以看到,这里通过 request
的 channelName
获取到一个 IServerChannel
,然后调用了它的 call
方法,并将结果通过 this.sendResponse
发送给客户端。显然,这里 this.channels
需要注册 IServerChannel
,而 IChannelServer
提供了这样的方法:
registerChannel(channelName: string, channel: IServerChannel<TContext>): void {
this.channels.set(channelName, channel);
setTimeout(() => this.flushPendingRequests(channelName), 0);
}
IChannelClient
的逻辑比较简单,它只提供了一个接口,即 getChannel
,它返回了一个 IChannel
,实际上就是通过闭包保存了 channelName
,然后在业务方调用的时候调用 requestPromise
等发起请求。
getChannel<T extends IChannel>(channelName: string): T {
const that = this;
return {
call(command: string, arg?: any, cancellationToken?: CancellationToken) {
if (that.isDisposed) {
return Promise.reject(errors.canceled());
}
return that.requestPromise(channelName, command, arg, cancellationToken);
},
// ...
} as T;
}
private requestPromise(channelName: string, name: string, arg?: any, cancellationToken = CancellationToken.None): Promise<any> {
const id = this.lastRequestId++;
const type = RequestType.Promise;
const request: IRawRequest = { id, type, channelName, name, arg };
if (cancellationToken.isCancellationRequested) {
return Promise.reject(errors.canceled());
}
let disposable: IDisposable;
const result = new Promise((c, e) => {
if (cancellationToken.isCancellationRequested) {
return e(errors.canceled());
}
const doRequest = () => {
const handler: IHandler = response => {
switch (response.type) {
case ResponseType.PromiseSuccess:
this.handlers.delete(id);
c(response.data);
break;
case ResponseType.PromiseError:
this.handlers.delete(id);
const error = new Error(response.data.message);
(<any>error).stack = response.data.stack;
error.name = response.data.name;
e(error);
break;
case ResponseType.PromiseErrorObj:
this.handlers.delete(id);
e(response.data);
break;
}
};
this.handlers.set(id, handler);
this.sendRequest(request);
};
let uninitializedPromise: CancelablePromise<void> | null = null;
if (this.state === State.Idle) {
doRequest();
} else {
// ...
}
const cancellationTokenListener = cancellationToken.onCancellationRequested(cancel);
disposable = combinedDisposable(toDisposable(cancel), cancellationTokenListener);
this.activeRequests.add(disposable);
});
return result.finally(() => { this.activeRequests.delete(disposable); });
}
我们已经看到了 IChannelServer
和 IChannelClient
之间会互发数据,这里简单讲解一下消息传输的机制。
首先消息传输需要约定好请求和响应的结构。
IPC 请求的字段如下:
type IRawPromiseRequest = { type: RequestType.Promise; id: number; channelName: string; name: string; arg: any; };
type IRawPromiseCancelRequest = { type: RequestType.PromiseCancel, id: number };
type IRawEventListenRequest = { type: RequestType.EventListen; id: number; channelName: string; name: string; arg: any; };
type IRawEventDisposeRequest = { type: RequestType.EventDispose, id: number };
type IRawRequest = IRawPromiseRequest | IRawPromiseCancelRequest | IRawEventListenRequest | IRawEventDisposeRequest;
type
,表明这是一种什么类型的调用id
,请求的唯一标识符,与请求相对应的响应会有相同的 idchannelName
,调用的 channel 的名称name
,如果是基于 Promise 的调用,就是方法的名称,如果是基于事件的监听,就是事件的名称arg
,参数IPC 响应的字段如下:
type IRawInitializeResponse = { type: ResponseType.Initialize };
type IRawPromiseSuccessResponse = { type: ResponseType.PromiseSuccess; id: number; data: any };
type IRawPromiseErrorResponse = { type: ResponseType.PromiseError; id: number; data: { message: string, name: string, stack: string[] | undefined } };
type IRawPromiseErrorObjResponse = { type: ResponseType.PromiseErrorObj; id: number; data: any };
type IRawEventFireResponse = { type: ResponseType.EventFire; id: number; data: any };
type IRawResponse = IRawInitializeResponse | IRawPromiseSuccessResponse | IRawPromiseErrorResponse | IRawPromiseErrorObjResponse | IRawEventFireResponse;
type
,表明是什么类型的响应id
,响应的唯一标识符data
,返回的数据请求和响应在被发送之前,都会通过 VSBuffer
进行序列化,在接收之后则会进行反序列化。
需要一定的机制来将请求和响应对应起来。这在服务端比较容易,因为服务端的处理在顺序上处于 IPC 的中间环节,可以很自然的通过作用域来对应请求和响应。而在客户端,则需要一些机制来匹配请求和响应。
IChannelClient
在 sendRequest
之前,会通过 id
来在自身的 handlers
Map 上绑定一个 handler
this.handlers.set(id, handler);
而在收到消息的时候,就会通过这里 id
调用相应的 handler
,从而 resolve 客户端 IChannel
的调用。
private onResponse(response: IRawResponse): void {
if (response.type === ResponseType.Initialize) {
this.state = State.Idle;
this._onDidInitialize.fire();
return;
}
const handler = this.handlers.get(response.id);
if (handler) {
handler(response);
}
}
IChannelServer
和 IChannelClient
之间会通过 protocol 传输数据。对于上层,它提供二进制数据流传输服务(用 VSBuffer
进行了封装),并能够在有新消息到达的时候通知上层。
其接口非常简单:
export interface IMessagePassingProtocol {
send(buffer: VSBuffer): void;
onMessage: Event<VSBuffer>;
/**
* Wait for the write buffer (if applicable) to become empty.
*/
drain?(): Promise<void>;
}
send
通过下层信道发送 Uint8Array
格式的消息onMessage
则在下层信道收到消息时触发上层的回调函数不同的通讯端有不同的信道,因此 IMessagePassingProtocol
也有多种实现,大致有以下几种:
它用于在客户端管理 IChannel
,它同时实现了 IChannelClient
和 IChannelServer
,所以它实际上可以发起也可以响应 IPC:
export interface IChannelClient {
getChannel<T extends IChannel>(channelName: string): T;
}
export interface IChannelServer<TContext = string> {
registerChannel(channelName: string, channel: IServerChannel<TContext>): void;
}
export class IPCClient<TContext = string> implements IChannelClient, IChannelServer<TContext>, IDisposable {
private channelClient: ChannelClient;
private channelServer: ChannelServer<TContext>;
constructor(protocol: IMessagePassingProtocol, ctx: TContext, ipcLogger: IIPCLogger | null = null) {
const writer = new BufferWriter();
serialize(writer, ctx);
protocol.send(writer.buffer);
this.channelClient = new ChannelClient(protocol, ipcLogger);
this.channelServer = new ChannelServer(protocol, ctx, ipcLogger);
}
getChannel<T extends IChannel>(channelName: string): T {
return this.channelClient.getChannel(channelName) as T;
}
registerChannel(channelName: string, channel: IServerChannel<TContext>): void {
this.channelServer.registerChannel(channelName, channel);
}
dispose(): void {
this.channelClient.dispose();
this.channelServer.dispose();
}
}
可以看到它仅有一个 IMessagePassingProtocol
,换句话说,就是只能跟一方进行通讯,这也是它跟 IPCServer
最大的区别。
它一共实现了三个接口:
export interface IChannelServer<TContext = string> {
registerChannel(channelName: string, channel: IServerChannel<TContext>): void;
}
export interface IRoutingChannelClient<TContext = string> {
getChannel<T extends IChannel>(channelName: string, router?: IClientRouter<TContext>): T;
}
export interface IConnectionHub<TContext> {
readonly connections: Connection<TContext>[];
readonly onDidAddConnection: Event<Connection<TContext>>;
readonly onDidRemoveConnection: Event<Connection<TContext>>;
}
IRoutingChannelClient
说明它可以根据一定的条件选择向哪个 IChannelServer
发起调用,即对调用进行路由IConnectionHub
则说明它可以管理客户端连接我们来看 IPCServer
的构造方法:
export class IPCServer<TContext = string> implements IChannelServer<TContext>, IRoutingChannelClient<TContext>, IConnectionHub<TContext>, IDisposable {
constructor(onDidClientConnect: Event<ClientConnectionEvent>) {
onDidClientConnect(({ protocol, onDidClientDisconnect }) => {
const onFirstMessage = Event.once(protocol.onMessage);
onFirstMessage(msg => {
const reader = new BufferReader(msg);
const ctx = deserialize(reader) as TContext;
const channelServer = new ChannelServer(protocol, ctx);
const channelClient = new ChannelClient(protocol);
this.channels.forEach((channel, name) => channelServer.registerChannel(name, channel));
const connection: Connection<TContext> = { channelServer, channelClient, ctx };
this._connections.add(connection);
this._onDidAddConnection.fire(connection);
onDidClientDisconnect(() => {
channelServer.dispose();
channelClient.dispose();
this._connections.delete(connection);
this._onDidRemoveConnection.fire(connection);
});
});
});
}
}
可以看到,在对方发来第一条消息时,IPCServer
会创建:
ChannelServer
ChannelClient
Connection
,这个 Connection
就是来描述连接的,它的接口如下interface Connection<TContext> extends Client<TContext> {
readonly channelServer: ChannelServer<TContext>;
readonly channelClient: ChannelClient;
}
export interface Client<TContext> {
readonly ctx: TContext;
}
注意这里的 ctx
属性,它是客户端的标识符,将用在请求路由的过程中。它的 getChannel
和 ChannelClient
的 getChannel
有很大不同:
getChannel<T extends IChannel>(channelName: string, router: IClientRouter<TContext>): T;
getChannel<T extends IChannel>(channelName: string, clientFilter: (client: Client<TContext>) => boolean): T;
getChannel<T extends IChannel>(channelName: string, routerOrClientFilter: IClientRouter<TContext> | ((client: Client<TContext>) => boolean)): T {
const that = this;
return {
call(command: string, arg?: any, cancellationToken?: CancellationToken): Promise<T> {
let connectionPromise: Promise<Client<TContext>>;
if (isFunction(routerOrClientFilter)) {
// when no router is provided, we go random client picking
let connection = getRandomElement(that.connections.filter(routerOrClientFilter));
connectionPromise = connection
// if we found a client, let's call on it
? Promise.resolve(connection)
// else, let's wait for a client to come along
: Event.toPromise(Event.filter(that.onDidAddConnection, routerOrClientFilter));
} else {
connectionPromise = routerOrClientFilter.routeCall(that, command, arg);
}
const channelPromise = connectionPromise
.then(connection => (connection as Connection<TContext>).channelClient.getChannel(channelName));
return getDelayedChannel(channelPromise)
.call(command, arg, cancellationToken);
},
listen(event: string, arg: any): Event<T> {
// ...
}
} as T;
}
可以看到,在调用 getChannel
的时候如果传入了 routerOrClientFilter
,则会在 connections
中选择一个。
选择 Connection
的方法,可以是一个简单的 filter 函数,也可以是通过 IClientRouter
提供的 routeCall
或者 routeEvent
方法。我们以 StaticRouter
为例:
export class StaticRouter<TContext = string> implements IClientRouter<TContext> {
constructor(private fn: (ctx: TContext) => boolean | Promise<boolean>) { }
routeCall(hub: IConnectionHub<TContext>): Promise<Client<TContext>> {
return this.route(hub);
}
routeEvent(hub: IConnectionHub<TContext>): Promise<Client<TContext>> {
return this.route(hub);
}
private async route(hub: IConnectionHub<TContext>): Promise<Client<TContext>> {
for (const connection of hub.connections) {
if (await Promise.resolve(this.fn(connection.ctx))) {
return Promise.resolve(connection);
}
}
await Event.toPromise(hub.onDidAddConnection);
return await this.route(hub);
}
}
实际上在 getChannel
调用他的时候,会通过 fn
来选择一个 IConnectionHub
中的 Connection
。
到这里,整个基于 channel 的 IPC 机制我们就介绍完毕了。
vscode IPC 的第二种机制基于 RpcProtocol
,用于渲染进程和 extension host 进程通讯(如果 vscode 的运行环境是浏览器,那么就是主线程和 extension host web worker 之间进行通讯)。
举个例子,在 host 进程初始化时如果发生了错误,它会告知渲染进程,代码如下:
const mainThreadExtensions = rpcProtocol.getProxy(MainContext.MainThreadExtensionService);
const mainThreadErrors = rpcProtocol.getProxy(MainContext.MainThreadErrors);
errors.setUnexpectedErrorHandler(err => {
const data = errors.transformErrorForSerialization(err);
const extension = extensionErrors.get(err);
if (extension) {
mainThreadExtensions.$onExtensionRuntimeError(extension.identifier, data);
} else {
mainThreadErrors.$onUnexpectedError(data);
}
});
在调用 mainThreadExtensions
或 mainThreadError
的方法的时候,即发生了 IPC。
该机制如下图所示:
下面介绍其原理。
客户端怎么知道 mainThreadExtensions
上有一个 $onExtensionRuntimeError
方法可以调用呢?
显然,这里需要定义一个接口,这个接口就是 MainThreadExtensionServiceShape
,定义在 extHost.protocol.ts 文件中。vscode 对于每一个可以调用的实体,都定义了一个以 Shape
为后缀的接口,服务端的实体必须要实现该接口,这样客户端在编写代码的时候就知道有哪些方法可以调用了。
客户端如何获取到服务端的实体在本地的代理,也就是 mainThreadExtensions
呢?换个问法,mainThreadExtensions
是如何跟 mainThreadErrors
相区别的呢?
代码中我们可以看到 mainThreadExtensions
是通过 rpcProtocol.getProxy(MainContext.MainThreadExtensionService)
获得的,MainContext.MainThreadExtensionService
在这里就起到了一个标识符的作用,它将每一个实体-代理的对子区别开。
MainContext.MainThreadExtensionService
定义在 extHost.protocol.ts 当中:
export const MainContext = {
MainThreadExtensionService: createMainId<MainThreadExtensionServiceShape>('MainThreadExtensionService')
}
而 createMainId
就是用于创建标识符的方法,本质上是创建了一个 ProxyIdentifier
对象并存在到一个数组当中:
export class ProxyIdentifier<T> {
public static count = 0;
_proxyIdentifierBrand: void;
public readonly isMain: boolean;
public readonly sid: string;
public readonly nid: number;
constructor(isMain: boolean, sid: string) {
this.isMain = isMain;
this.sid = sid;
this.nid = (++ProxyIdentifier.count);
}
}
const identifiers: ProxyIdentifier<any>[] = [];
export function createMainContextProxyIdentifier<T>(identifier: string): ProxyIdentifier<T> {
const result = new ProxyIdentifier<T>(true, identifier);
identifiers[result.nid] = result;
return result;
}
export function createExtHostContextProxyIdentifier<T>(identifier: string): ProxyIdentifier<T> {
const result = new ProxyIdentifier<T>(false, identifier);
identifiers[result.nid] = result;
return result;
}
每个标识符有三个字段:
我们如何知道另外一个进程中,有哪些实体可以被调用?
extHost.protocol.ts 文件中定义了 MainContext 和 ExtHostContext 两个文件。前者定义了渲染进程中可被调用的实体,后者定义了 host 进程中可被调用的实体。这里也可以看出,在 RpcProtocol 机制下,渲染进程和 host 进程是可以互相调用的。
可被调用的实体是如何注册的?
host 进程调用 mainThreadExtensions
方法的时候,渲染进程必须要有类提供这个方法,而且它还需要注册到这个 RpcProtocol 的机制上。通过查找实现了 MainThreadExtensionServiceShape
的类,不难发现 mainThreadExtensionService.ts 中存在这样一段代码:
@extHostNamedCustomer(MainContext.MainThreadExtensionService)
export class MainThreadExtensionService implements MainThreadExtensionServiceShape {
// ...
}
注意这里装饰器的调用,我们探究其实现:
export function extHostNamedCustomer<T extends IDisposable>(id: ProxyIdentifier<T>) {
return function <Services extends BrandedService[]>(ctor: { new(context: IExtHostContext, ...services: Services): T }): void {
ExtHostCustomersRegistryImpl.INSTANCE.registerNamedCustomer(id, ctor as IExtHostCustomerCtor<T>);
};
}
可以发现它是将 id
,也就是 MainContext.MainThreadExtensionService
和 MainThreadExtensionService
绑定起来,而在 extension host 初始化的时候实例化它:
const namedCustomers = ExtHostCustomersRegistry.getNamedCustomers();
for (let i = 0, len = namedCustomers.length; i < len; i++) {
const [id, ctor] = namedCustomers[i];
const instance = this._instantiationService.createInstance(ctor, extHostContext);
this._customers.push(instance);
this._rpcProtocol.set(id, instance);
}
注册的最后一步就是调用 RpcProtocol.set
方法注册可被调用的实体。
到这里我们基本了解了 RpcProtocol 的接口了,下面来了解一下它的内部逻辑。
首先来看 getProxy
,我们知道客户端要通过这个方法获取可调用的代理:
public getProxy<T>(identifier: ProxyIdentifier<T>): T {
const { nid: rpcId, sid } = identifier;
if (!this._proxies[rpcId]) {
this._proxies[rpcId] = this._createProxy(rpcId, sid);
}
return this._proxies[rpcId];
}
private _createProxy<T>(rpcId: number, debugName: string): T {
let handler = {
get: (target: any, name: PropertyKey) => {
if (typeof name === 'string' && !target[name] && name.charCodeAt(0) === CharCode.DollarSign) {
target[name] = (...myArgs: any[]) => {
return this._remoteCall(rpcId, name, myArgs);
};
}
if (name === _RPCProxySymbol) {
return debugName;
}
return target[name];
}
};
return new Proxy(Object.create(null), handler);
}
可以看到它的核心逻辑就是创建一个 Proxy 对象,当对象上的属性被访问时,所有以 $ 开头的属性都会被包装为一个对 this._remoteCall
进行调用的方法。
_remoteCall
的核心逻辑则主要是下面几行(这里主要省略了取消请求相关的逻辑):
private _remoteCall(rpcId: number, methodName: string, args: any[]): Promise<any> {
const serializedRequestArguments = MessageIO.serializeRequestArguments(args, this._uriReplacer);
const req = ++this._lastMessageId;
const callId = String(req);
const result = new LazyPromise();
this._pendingRPCReplies[callId] = result;
this._onWillSendRequest(req);
const msg = MessageIO.serializeRequest(req, rpcId, methodName, serializedRequestArguments, !!cancellationToken);
this._protocol.send(msg);
return result;
}
// MessageIO
public static serializeRequest(req: number, rpcId: number, method: string, serializedArgs: SerializedRequestArguments, usesCancellationToken: boolean): VSBuffer {
if (serializedArgs.type === 'mixed') {
return this._requestMixedArgs(req, rpcId, method, serializedArgs.args, serializedArgs.argsType, usesCancellationToken);
}
return this._requestJSONArgs(req, rpcId, method, serializedArgs.args, usesCancellationToken);
}
private static _requestJSONArgs(req: number, rpcId: number, method: string, args: string, usesCancellationToken: boolean): VSBuffer {
const methodBuff = VSBuffer.fromString(method);
const argsBuff = VSBuffer.fromString(args);
let len = 0;
len += MessageBuffer.sizeUInt8();
len += MessageBuffer.sizeShortString(methodBuff);
len += MessageBuffer.sizeLongString(argsBuff);
let result = MessageBuffer.alloc(usesCancellationToken ? MessageType.RequestJSONArgsWithCancellation : MessageType.RequestJSONArgs, req, len);
result.writeUInt8(rpcId);
result.writeShortString(methodBuff);
result.writeLongString(argsBuff);
return result.buffer;
}
// MessageBuffer
public static alloc(type: MessageType, req: number, messageSize: number): MessageBuffer {
let result = new MessageBuffer(VSBuffer.alloc(messageSize + 1 /* type */ + 4 /* req */), 0);
result.writeUInt8(type);
result.writeUInt32(req);
return result;
}
可以看到一个请求主要有以下这些信息:
type
,请求的类型,由一个枚举 MessageType 所定义req
,请求的序号,是一个自增的数字rpcId
,identifier 的字符串 id,表明是哪个实体-代理之间的请求method
,指定要调用实体的哪个方法argsBuff
,序列化的参数最终这些参数都会被封装为一个 VSBuffer
并通过 protocol 发送,而这些而这里的 protocol,这是我们的老朋友 IMessagePassingProtocol
。 所以我们可以看到 RpcProtocol 机制也是分层的设计,可以在不同的环境中使用。
当服务端接收到一个请求时,会回调 _receiveOneMessage
方法进行处理:
private _receiveOneMessage(rawmsg: VSBuffer): void {
if (this._isDisposed) {
return;
}
const msgLength = rawmsg.byteLength;
const buff = MessageBuffer.read(rawmsg, 0);
const messageType = <MessageType>buff.readUInt8();
const req = buff.readUInt32();
switch (messageType) {
case MessageType.RequestJSONArgs:
case MessageType.RequestJSONArgsWithCancellation: {
let { rpcId, method, args } = MessageIO.deserializeRequestJSONArgs(buff);
if (this._uriTransformer) {
args = transformIncomingURIs(args, this._uriTransformer);
}
this._receiveRequest(msgLength, req, rpcId, method, args, (messageType === MessageType.RequestJSONArgsWithCancellation));
break;
}
// ...
}
即根据 type
来调用不同的方法对请求进行处理,这里来看 _receiveRequest
方法:
private _receiveRequest(msgLength: number, req: number, rpcId: number, method: string, args: any[], usesCancellationToken: boolean): void {
const callId = String(req);
let promise: Promise<any>;
let cancel: () => void;
if (usesCancellationToken) {
// ...
} else {
// cannot be cancelled
promise = this._invokeHandler(rpcId, method, args);
cancel = noop;
}
// Acknowledge the request
const msg = MessageIO.serializeAcknowledged(req);
this._protocol.send(msg);
promise.then((r) => {
delete this._cancelInvokedHandlers[callId];
const msg = MessageIO.serializeReplyOK(req, r, this._uriReplacer);
this._protocol.send(msg);
}, (err) => {
// ...
});
}
private _invokeHandler(rpcId: number, methodName: string, args: any[]): Promise<any> {
try {
return Promise.resolve(this._doInvokeHandler(rpcId, methodName, args));
} catch (err) {
return Promise.reject(err);
}
}
private _doInvokeHandler(rpcId: number, methodName: string, args: any[]): any {
const actor = this._locals[rpcId];
if (!actor) {
throw new Error('Unknown actor ' + getStringIdentifierForProxy(rpcId));
}
let method = actor[methodName];
if (typeof method !== 'function') {
throw new Error('Unknown method ' + methodName + ' on actor ' + getStringIdentifierForProxy(rpcId));
}
return method.apply(actor, args);
}
核心就是调用 _invokeHandler
然后将结果发送回去。
注意到这一行 const actor = this._locals[rpcId];
获取了可被调用的实体,记得之前注册实体时调用的 set 方法吗:
public set<T, R extends T>(identifier: ProxyIdentifier<T>, value: R): R {
this._locals[identifier.nid] = value;
return value;
}
到这里,我们就了解了 RpcProtocol 的原理了。
这篇文章介绍一个应用服务器框架的主要两个过程:app init 过程和 request handle 过程。一些有趣的细节问题看看以后再写,包括 context, request, response 三个对象,错误处理,egg.js 等等。
通过一个简单的 demo(实际上就是官网的例子)来讲解 app init 过程。对于 Koa 来说,init 过程是比较简单的。
const Koa = require('koa')
const app = new Koa() // Koa 对象实例化
// use 增加 middleware
app.use(async (ctx, next) => {
await next()
const rt = ctx.response.get('X-Response-Time')
console.log(`${ctx.method} ${ctx.url} - ${rt}`)
})
app.use(async (ctx, next) => {
const start = Date.now()
await next()
const ms = Date.now() - start
ctx.set('X-Response-Time', `${ms}ms`)
})
app.use(async ctx => {
ctx.body = 'Hello World'
})
app.listen(3000) // 监听端口
lib/application.js
constructor() {
super();
this.proxy = false;
this.middleware = [];
this.subdomainOffset = 2;
this.env = process.env.NODE_ENV || 'development';
// 在应用程序实例上绑定 context request repsonse 的原型,实际上这三个对象都没有任何属性
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
if (util.inspect.custom) {
this[util.inspect.custom] = this.inspect;
}
}
use(fn) {
if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
// 如果是一个生成器函数要转换成 async 函数,细节问题
if (isGeneratorFunction(fn)) {
deprecate('Support for generators will be removed in v3. ' +
'See the documentation for examples of how to convert old middleware ' +
'https://github.com/koajs/koa/blob/master/docs/migration.md');
fn = convert(fn);
}
debug('use %s', fn._name || fn.name || '-');
// 直接将回调函数存储到 this.middleware 当中
this.middleware.push(fn);
return this; // 通过返回自己可以进行链式调用
}
listen(...args) {
debug('listen');
const server = http.createServer(this.callback());
return server.listen(...args); // 调用 Node.js 原生的方法监听端口
}
this.callback()
方法返回一个回调函数,它符合 Node.js 原生 http.createServer 的要求,被当作 request handler.
callback() {
// 将自己绑定的中间件封装起来
const fn = compose(this.middleware);
// 进行错误处理的回调函数
// 由于 Koa 继承了 Emitter, 所以用户可以在上面绑定 error 方法,如果用户没用绑定,就绑定自带的 onerror 方法
// 错误处理暂时不讲
if (!this.listenerCount('error')) this.on('error', this.onerror);
// http server 的回调函数
const handleRequest = (req, res) => {
// 将 request response 对象封装为 context 对象,然后开始对 request 的处理过程,这个放到第二节再讲
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn); // 返回对 request 的处理结果
};
return handleRequest;
}
这是个很重要的方法,其返回的 fn
, 将会在请求到达的时候实际负责 context 在 middleware 中的传递。
function compose(middleware) {
// 类型检查
if (!Array.isArray(middleware))
throw new TypeError('Middleware stack must be an array!')
for (const fn of middleware) {
if (typeof fn !== 'function')
throw new TypeError('Middleware must be composed of functions!')
}
/**
* @param {Object} context
* @return {Promise}
* @api public
*/
// 这个函数签名就是 koa middleware 常见的函数签名
// 它作为 this.handleRequest 的参数,this.handleRequest 会调用它
// 注意! 下面的代码及注释请在阅读 request handler 的过程阅读
return function(context, next) {
// last called middleware #
// 指示 context 在 middleware 链上的位置
// context 刚来的时候没有进入链,所以 index === -1
let index = -1
// 从第 1 个 middleware 开始 context 之旅,index === 0
return dispatch(0)
// 这个 dispatch 串接 context 在 middleware 中的流动
function dispatch(i) {
// 如果 i 到了起点之前,说明 next 被用了太多次
if (i <= index)
return Promise.reject(new Error('next() called multiple times'))
index = i // 压栈阶段,记录自己所在的位置
let fn = middleware[i]
if (i === middleware.length) fn = next // 如果走到了 middleware 的最后一站,那么就用传入的 next 当作 next
if (!fn) return Promise.resolve() // 如果都没有 middleware, 直接返回,然后层层 resolve 返回
try {
// 进入 middleware 函数的执行过程,middleware 中访问的 next 被定义在这里,
// 而当这个 middleware 调用 next 的时候,就等于调用 dispatch, 同时进入 middleware 的下一层
// 如果当前 middleware 是最后一个,上面的 if (i === middleware.length) fn = next 逻辑就会被激活,顶层调用 next
// 可以看到我们 await 的东西就是一个 resolved 的 Promise!
// 根据 async 函数的定义,默认返回的就是一个 resolved 的 Promise<undefined>
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
} catch (err) {
// 如果抛出了异常,就会被捕获,层层 reject 回来
return Promise.reject(err)
}
}
}
}
还是用上面的例子来讲解 request handle 过程。
当有 http 请求过来的时候,如下的方法最先被调用:
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res) // 创建 context
return this.handleRequest(ctx, fn) // 过程处理 === context 在 middleware 中的传递
}
createContext(req, res) {
// 创建三个对象,将它们的 prototype 分别指向 this.context, this.request, this.repsonse, 实际上这三个对象 hasOwnProperties 为空
// 然后就是各种引用,比较简单
const context = Object.create(this.context);
const request = context.request = Object.create(this.request);
const response = context.response = Object.create(this.response);
context.app = request.app = response.app = this;
context.req = request.req = response.req = req;
context.res = request.res = response.res = res;
request.ctx = response.ctx = context;
request.response = response;
response.request = request;
context.originalUrl = request.originalUrl = req.url;
context.state = {};
return context;
}
对 request 的实际处理过程。
handleRequest(ctx, fnMiddleware) {
// 这里的 fnMiddleware 即是 compose() 返回的 fn 的函数,可以看到并没有给第二参数传递值,所以在那里 next === undefined, 直接 resolve
const res = ctx.res;
res.statusCode = 404;
// 准备两个 Promise 的回调函数
const onerror = err => ctx.onerror(err);
const handleResponse = () => respond(ctx);
onFinished(res, onerror);
// 开始 middleware 的传递过程
// 如果执行过程成功,并没有从 Promise 里拿任何的参数,是利用闭包访问的 ctx 来生成响应的
// 但执行失败则要从 Promise 链条里拿到错误信息
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
当 fnMiddleware
被调用的时候,即这个函数被调用:
function (context, next) {
// last called middleware #
// 指示 context 在 middleware 链上的位置
// context 刚来的时候没有进入链,所以 index === -1
let index = -1
// 从第 1 个 middleware 开始 context 之旅,index === 0
return dispatch(0)
// 这个 dispatch 串接 context 在 middleware 中的流动
// 注意! 下面的代码及注释在阅读 request handler 的过程阅读
function dispatch (i) {
// 如果 i 到了起点之前,说明 next 被用了太多次
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i // 压栈阶段,记录自己所在的位置
let fn = middleware[i]
if (i === middleware.length) fn = next // 如果走到了 middleware 的最后一站,那么就用传入的 next 当作 next
if (!fn) return Promise.resolve() // 如果都没有 middleware, 直接返回,然后层层 resolve 返回
try {
// 进入 middleware 函数的执行过程,middleware 中访问的 next 被定义在这里,
// 而当这个 middleware 调用 next 的时候,就等于调用 dispatch, 同时进入 middleware 的下一层
// 如果当前 middleware 是最后一个,上面的 if (i === middleware.length) fn = next 逻辑就会被激活,顶层调用 next
// 可以看到我们 await 的东西就是一个 resolved 的 Promise!
// 根据 async 函数的定义,默认返回的就是一个 resolved 的 Promise<undefined>
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
// 如果抛出了异常,就会被捕获,层层 reject 回来
return Promise.reject(err)
}
}
}
可以看到这个函数是递归的:
用我们的例子:
dispatch(0)
, 我们注册的第一个异步函数被当成 fn
, 然后 fn(context, dispatch.bind(null, 1))
调用了这第一个异步函数next()
, 实际上执行了 dispatch(1)
, 然后调用了第二个异步函数。next()
, 所以异步函数返回了状态为 resolved 的 Promise<undefined>
return Promise.resolve()
把异步函数返回的 Promise 接着 resolved 下去dispatch(0)
中的 resolved 的 Promise 被 return 出去handleRequest
进入 fnMiddleware(ctx).then(handleResponse)
, 执行 handleResponse
next
此时 fn === undefined
, 并且 next === undefined
, 所以就会直接返回已 resolved 的 Promise, 开始回溯。
next
我们已经知道每次调用 next
实际是调用了一次 dispatch(i)
, 如果我们调用了同一个 next
两次,那么第二次调用的时候,i === index
的条件就会成立。我们说过 index
是指示 context 在 middleware 中的位置的。
function respond(ctx) {
// allow bypassing koa
// 允许 bypass 直通 koa
if (false === ctx.respond) return
const res = ctx.res
if (!ctx.writable) return
let body = ctx.body
const code = ctx.status
// ignore body
if (statuses.empty[code]) {
// strip headers
ctx.body = null
return res.end()
}
if ('HEAD' == ctx.method) {
if (!res.headersSent && isJSON(body)) {
ctx.length = Buffer.byteLength(JSON.stringify(body))
}
return res.end()
}
// status body
if (null == body) {
body = ctx.message || String(code)
if (!res.headersSent) {
ctx.type = 'text'
ctx.length = Buffer.byteLength(body)
}
return res.end(body)
}
// responses
if (Buffer.isBuffer(body)) return res.end(body)
if ('string' == typeof body) return res.end(body)
if (body instanceof Stream) return body.pipe(res)
// body: json
body = JSON.stringify(body)
if (!res.headersSent) {
ctx.length = Buffer.byteLength(body)
}
res.end(body)
}
这个方法和 Koa 的关系不大了,其实就是在处理 response 的各种可能情况,然后调用 http 模块 res 的方法返回响应。
除了上一篇文章中举例的 BottomSheet 这种全局浮层组件,还有很多浮层组件有锚点,意思就是,浮层内的元素会根据锚点元素的移动而改变其位置。这一篇文章将会介绍实现这一功能的 directive 和 position strategy,并以使用该功能的 ng-zorro-antd 的 Cascader 组件为例。
这里有很多 Cascader 组件的 demo,当点击输入框时,会弹出一个浮层,该浮层会随着页面的滚动和缩放而改变其位置,比如过于浮层过于靠近窗口下方时,它就会浮动到上面。
定义浮层和其内容的代码在这里(同样省略了部分无关内容):
<ng-template
cdkConnectedOverlay
nzConnectedOverlay
cdkConnectedOverlayHasBackdrop
[cdkConnectedOverlayOrigin]="origin"
[cdkConnectedOverlayPositions]="positions"
(backdropClick)="closeMenu()"
(detach)="closeMenu()"
(positionChange)="onPositionChange($event)"
[cdkConnectedOverlayOpen]="menuVisible"
>
<div #menu class="ant-cascader-menus">
<!---->
</div>
</ng-template>
而定义输入框的代码在这里:
<div cdkOverlayOrigin #origin="cdkOverlayOrigin" #trigger>
<div *ngIf="nzShowInput">
<input #input nz-input />
<!---->
</div>
<ng-content></ng-content>
</div>
可以看到这里有两个很重要的指令,即 cdkOverlayOrigin
和 cdkConnectedOverlay
,接下来就主要讲解它们。
CdkConnectedOverlay 把其所在的 template 内的元素变为某个浮层内的元素。从它的 constructor 可以看到:
constructor(
private _overlay: Overlay,
templateRef: TemplateRef<any>,
viewContainerRef: ViewContainerRef,
@Inject(CDK_CONNECTED_OVERLAY_SCROLL_STRATEGY) scrollStrategyFactory: any,
@Optional() private _dir: Directionality) {
this._templatePortal = new TemplatePortal(templateRef, viewContainerRef);
this._scrollStrategyFactory = scrollStrategyFactory;
this.scrollStrategy = this._scrollStrategyFactory();
}
它将 template 封装成了 TemplatePortal
。它的 scroll strategy 则是依赖注入进来的,实际上是一个 RepositionScrollStrategy
,即在页面滚动时重新计算位置。
当 Cascader 改变 cdkConnectedOverlayOpen
的值为 true
以打开该浮层时,ngOnChanges
会调用 _attachOverlay 方法。在这个方法中,template 会被 attach 到由 _createOverlay 方法创建的 OverlayRef,浮层内容也就显示出来了。
那么,position strategy 是如何应用的呢,我们从 _createOverlay 方法开始探究。
通过 _createOverlay -> _buildConfig -> _createPositionStrategy 这样的调用栈,我们可以看到这一行代码设置了该浮层的 position strategy:
const strategy = this._overlay
.position()
.flexibleConnectedTo(this.origin.elementRef)
可以看到这里调用的 flexibleConnectedTo
方法指定了浮层的锚点。
而在 _updatePosition
方法中,一连串的链式调用将被用于调整浮层的配置。
return positionStrategy
.setOrigin(this.origin.elementRef)
.withPositions(positions)
.withFlexibleDimensions(this.flexibleDimensions)
.withPush(this.push)
.withGrowAfterOpen(this.growAfterOpen)
.withViewportMargin(this.viewportMargin)
.withLockedPosition(this.lockPosition)
好了,接下来就让我们来探究 FlexibleConnectedPositionStrategy
吧。
在我们调用 flexibleConnectedTo
的时候,实际上是通过工厂类调用了 FlexibleConnectedPositionStrategy
的构造函数。
constructor(
connectedTo: FlexibleConnectedPositionStrategyOrigin, private _viewportRuler: ViewportRuler,
private _document: Document, private _platform: Platform,
private _overlayContainer: OverlayContainer) {
this.setOrigin(connectedTo);
}
我们接下来分析之前那一串链式调用,以及该 position strategy 如何进行浮层定位。
我们可以通过 apply 方法一窥其定位机制。
在 vscode 的加载过程中,有这样一行代码值得注意,它位于 main.js 文件中,而这是 Electron App 运行的入口文件:
require('./bootstrap-amd').load('vs/code/electron-main/main', () => {
// ...
});
vs/code/election-main/main 是 vscode 应用的主入口,这句代码的意思是加载主入口文件并执行。bootstrap-amd 文件暴露的 load 方法则又调用了 vs/loader 文件暴露的 loader 方法:
const loader = require('./vs/loader')
exports.load = function (entrypoint, onLoad, onError) {
// ...
loader([entrypoint], onLoad, onError);
};
这个 vs/loader 文件中的内容,即是 vscode 自己实现的模块加载系统 vscode-loader,这篇文章我们来学习它的设计和实现。
vscode-loader 的源代码不在 vscode 仓库当中,源码地址在 https://github.com/microsoft/vscode-loader,使用 TypeScript 编写,通过 TypeScript compiler 生成最终以下几个文件:
在分析 vscode-loader 的代码之前,我们先从概念上理解一下模块化系统。
现在前端开发基本都不会将所有的代码全部写在一个文件里,而是分散在多个文件当中,这样一个文件就可能会需要引用其他文件中声明的标识符。JavaScript 的灵活性允许我们将这些标识符定义在顶层作用域中然后在另一个文件中访问它,例如:
// file 'a.js'
var a = 2;
// file 'b/js'
console.log(a);
只要在 HTML 引用 js 文件的先后顺序是 a.js → b.js,上面这段代码就能正常输出 2。
但是这种方法在开发稍有规模的 JavaScript 项目的时候,显然是不理想的:
模块化机制就是为解决以上问题而提出的。在 ECMAScript 6 标准推出 ESM 之前,JavaScript 这门语言是没有内置的模块化机制的,社区提出了 AMD UMD CommonJS 这三种主要的方案(实际上 UMD 只是对 AMD 和 CommonJS 的一个封装而已),虽然他们的语法上和运行原理上各有不同之处,但是核心概念都是一样的:变量仅能够在一定的范围(即作用域)被访问到,这个范围就叫做模块,模块要想访问其他模块中的变量,就需要引入对方导出的变量。我们以 ESM 为例:
// file 'a.js'
const a = 2;
export function getA() {
return a;
}
// file 'b.js'
import { getA } from 'a';
console.log(getA());
在 a.js 文件中,有两个变量 a
和 getA
,其中 getA
通过 export 语法被导出。而 b.js 文件导入了 a.js 所导出的 getA
并调用,注意:它无法导入 a
,于是 a
就变成 a.js 文件所私有的了,我们可以看到,模块化代码不会将变量泄漏到全局环境当中去。
这里一个模块的范围就是一个文件。有些规范可以允许一个文件内含有多个模块,比如 AMD 规范,这时模块的范围就是一个函数的作用域。
我们仔细看上面的例子,从实现的角度思考模块化系统,容易发现一个模块化系统就需要做到下面这几点:
getA
函数,因此 a.js 文件要先于上面的代码先被解析执行;getA
函数,就需要通过某种机制传递到 b.js 文件的上下文中去;vscode(大部分的情况下)使用的是 AMD 规范,vscode 的 src/tsconfig.base.json 里可以看到相关配置, 编译出来的文件像是这样:
define(["require", "exports", "vs/base/common/lifecycle", "vs/base/common/actions", "vs/base/browser/dom", "vs/base/common/types", "vs/base/browser/keyboardEvent", "vs/base/common/event", "vs/base/browser/ui/actionbar/actionViewItems", "vs/css!./actionbar"], function (require, exports, lifecycle_1, actions_1, DOM, types, keyboardEvent_1, event_1, actionViewItems_1) {
"use strict";
// ...
})
我们还是用上面的例子来进行分析。AMD 规范中,声明一个模块的语法如下(注意,这个例子不带有 AMD 对 CommonJS 规范的兼容(即 "require"
),因此显得比较简单):
define('a', [], () => {
const a = 2;
function getA() {
return a;
}
return { getA };
});
define('b', ['a'], (a) => {
console.log(a.getA());
});
其中第一个参数为模块的名称,第二个参数是模块需要引入的其他模块,或者称当前模块的依赖(dependency),第三个参数是回调函数,当模块的依赖都已经加载完毕之后就会执行这个回调函数来加载当前模块,回调函数的参数是各个依赖导出的变量,函数的内容则是模块的代码,返回的对象则是这个模块要导出的所有变量。可以看到:通过将变量定义在回调函数的作用域中,可避免把变量泄露到全局环境,而返回的变量(即模块的导出),则可以通过闭包来访问内部变量。
上面的例子中,通过 return 返回了导出变量,除此之外 AMD 还支持一种导出的方式,就是先声明当前模块依赖 exports
,然后把要导出的变量作为 exports
变量的属性,以上面的 a 文件为例:
define('a', ['exports'], (exports) => {
const a = 2;
function getA() {
return a;
}
exports.getA = getA; // 这样导出也是可以的
});
至此我们了解了 AMD 规范的基本内容,接下来要了解的就是 define 方法是如何工作的,本文余下的内容即介绍 vscode 中的模块加载工具 vscode-loader 的工作原理。
打开 vscode-loader 的源代码中 main.ts 文件,文件最后的部分即是 vscode-loader 执行的入口:
export function init(): void {
if (typeof global.require !== 'undefined' || typeof require !== 'undefined') {
// 将原本 node.js 的 require 函数保存在这个局部变量中
const _nodeRequire = (global.require || require);
if (typeof _nodeRequire === 'function' && typeof _nodeRequire.resolve === 'function') {
// 然后在 RequireFunc 上挂在原来的 require 函数
const nodeRequire = ensureRecordedNodeRequire(moduleManager.getRecorder(), _nodeRequire);
global.nodeRequire = nodeRequire;
(<any>RequireFunc).nodeRequire = nodeRequire;
(<any>RequireFunc).__$__nodeRequire = nodeRequire;
}
}
// 在 Node.js 环境中(非 Electron 渲染进程环境)
if (env.isNode && !env.isElectronRenderer) {
module.exports = RequireFunc; // vscode-loader.js 文件导出 RequireFunc
require = <any>RequireFunc; // patch 全局 require 函数
} else {
if (!env.isElectronRenderer) {
global.define = DefineFunc; // patch 全局 define 函数
}
global.require = RequireFunc; // patch 全局 require 函数
}
}
if (typeof global.define !== 'function' || !global.define.amd) {
moduleManager = new ModuleManager(env, createScriptLoader(env), DefineFunc, RequireFunc, Utilities.getHighPerformanceTimestamp());
// The global variable require can configure the loader
if (typeof global.require !== 'undefined' && typeof global.require !== 'function') {
RequireFunc.config(global.require);
}
// This define is for the local closure defined in node in the case that the loader is concatenated
define = function () {
return DefineFunc.apply(null, arguments);
};
define.amd = DefineFunc.amd;
if (typeof doNotInitLoader === 'undefined') {
init();
}
}
NodeScriptLoader
WorkScriptLoader
和 BrowserScriptLoader
三种,负责加载 js 文件DefineFunc
和 RequireFunc
,即模块化系统中的 define 函数和 require 函数,分别用于定义和加载一个模块ModuleManager
,即模块管理器,它用于按照正确的顺序来解析 js 文件并执行define
以及 require
变量(node 原生 require
变量的值被保存在 _nodeRequire
变量中)我们接下来分别探究这几项内容,先从 DefineFunc
和 RequireFunc
开始。
对 Node.js 有所了解的话,一定会知道 Node.js 的模块规范是 CommonJS,引入资源的时候使用 require 语法:
const fs = require('fs');
而我们之前看到 vscode patch 了 Node.js 的 require
函数,那么也需要对原来的模块化系统进行兼容,另外,这个 require 还要能够接入 vscode 自己的 AMD 模块系统:
const RequireFunc: IRequireFunc = <any>function () {
if (arguments.length === 1) {
// 配置
if ((arguments[0] instanceof Object) && !Array.isArray(arguments[0])) {
_requireFunc_config(arguments[0]);
return;
}
// Node.js 原本的 require() 语法
if (typeof arguments[0] === 'string') {
return moduleManager.synchronousRequire(arguments[0]);
}
}
// vscode AMD 模块化系统调用方式
if (arguments.length === 2 || arguments.length === 3) {
if (Array.isArray(arguments[0])) {
moduleManager.defineModule(Utilities.generateAnonymousModule(), arguments[0], arguments[1], arguments[2], null);
return;
}
}
throw new Error('Unrecognized require call');
};
RequireFunc
除了可以接收一个配置对象之外,能够用两种方式进行调用:
moduleManager.synchronousRequire
方法的一个代理,用于同步地调用一个已经加载完毕的依赖;moduleManager.defineModule
定义一个匿名的模块,然后这些需要加载的脚本就会作为这个匿名模块的依赖而被加载。我们先前看到的 bootstrap-amd 中加载主进程入口的代码即使用了这种调用方式。const loader = require('./vs/loader')
exports.load = function (entrypoint, onLoad, onError) {
// ...
loader([entrypoint], onLoad, onError);
};
const DefineFunc: IDefineFunc = <any>function (id: any, dependencies: any, callback: any): void {
if (typeof id !== 'string') {
callback = dependencies;
dependencies = id;
id = null;
}
if (typeof dependencies !== 'object' || !Array.isArray(dependencies)) {
callback = dependencies;
dependencies = null;
}
if (!dependencies) {
dependencies = ['require', 'exports', 'module'];
}
if (id) {
moduleManager.defineModule(id, dependencies, callback, null, null);
} else {
moduleManager.enqueueDefineAnonymousModule(dependencies, callback);
}
};
与 RequireFunc
类似,除去一些调整参数的判断之外,DefineFunc
的核心就是根据是否有模块 id,来决定是调用 defineModule
方法或者是 enqueueDefineAnonymousModule
方法。
RequireFunc
和 DefineFunc
的内容都较为简单,主要是调用 ModuleManager
的方法,看来它才是所有复杂逻辑发生的地方。接下来我们就来看这个类的实现,代码在 moduleManager.ts 文件当中。
我们需要一个数据结构来表示模块,vscode-loader 中即为 Module
类型,它的主要属性如下:
export class Module {
public readonly id: ModuleId; // 字母表示的模块 id
public readonly strId: string; // 字符串形式的模块 id
public readonly dependencies: Dependency[] | null; // 这个模块的依赖
private readonly _callback: any | null; // 即模块的具体内容的代码
private readonly _errorback: ((err: AnnotatedError) => void) | null | undefined;
public readonly moduleIdResolver: ModuleIdResolver | null;
public exports: any; // 保存这个模块导出的内容
public error: AnnotatedError | null;
public exportsPassedIn: boolean;
public unresolvedDependenciesCount: number; // 尚未加载的依赖的数量
private _isComplete: boolean; // 模块似乎加载完成
}
一个模块的依赖即是其他的模块,表示依赖的数据结构很简单,就是一个封装了 id
的对象:
export class RegularDependency {
public readonly id: ModuleId;
}
在定义一个模块的时候 vscode-loader 会为每一个 Module
分配一个不同的数字 id,这个数字 id 的类型就是 ModuleId
。vscode 的模块化系统有三个内置的依赖,因此会占据 0 1 2 三个数字。至于这些内置依赖对象有什么用处,我们后面会进行说明。
export const enum ModuleId {
EXPORTS = 0,
MODULE = 1,
REQUIRE = 2
}
ModuleManager
即是模块加载器,它有以下这些重要的属性:
export class ModuleManager {
// 环境信息
private readonly _env: Environment;
// 脚本加载器,负责加载 js 脚本文件
private readonly _scriptLoader: IScriptLoader;
private readonly _defineFunc: IDefineFunc;
private readonly _requireFunc: IRequireFunc;
// 管理 Module id
private _moduleIdProvider: ModuleIdProvider;
private _config: Configuration;
// 是否存在循环依赖
private _hasDependencyCycle: boolean;
/**
* 一个从 id 到 Module 的映射
* 如果一个 id 在该数组中,则说明对应的 Module 已经开始加载过程
* 但不一定已经加载完成,因为可能有依赖没有加载完成
*/
private _modules2: Module[];
/**
* 一个从 id 到 true 的映射
* 如果一个 id 在该数组中,说明 script loader 已经开始装载该 module 所需的代码
* 这个机制可以防止同一份代码被加载两次
*/
private _knownModules2: boolean[];
/**
* 一个从 id 到 ModuleId[] 的映射,这个映射记录了哪些模块依赖编号为 id 的模块
*/
private _inverseDependencies2: (ModuleId[] | null)[];
}
经过上面知识的铺垫,我们可以来学习最为重要的 defineModule
方法了,我们这里忽略错误处理和构建情况,专注于其核心机制的实现。
public defineModule(strModuleId: string, dependencies: string[], callback: any, errorback: ((err: AnnotatedError) => void) | null | undefined, stack: string | null, moduleIdResolver: ModuleIdResolver = new ModuleIdResolver(strModuleId)): void {
let moduleId = this._moduleIdProvider.getModuleId(strModuleId);
let m = new Module(moduleId, strModuleId, this._normalizeDependencies(dependencies, moduleIdResolver), callback, errorback, moduleIdResolver);
this._modules2[moduleId] = m;
this._resolve(m);
}
defineModule
方法主要做了下面这几件事情:
_normalizeDependencies
对依赖进行处理,这个过程比较简单,实际上就是根据依赖项的标识字符串生成一个 RegularDependency
对象Module
对象Module
记录到 _moduels2
数组当中_resolve
方法负责解析一个模块。首先它会处理 Module
所有的 dependency,查看各个 dependency 是否已经解析完毕。如果有 dependency 没有解析,那么就通过 _loadModule
方法加载该 dependency,这个我们下一个小节会详叙述。
private _resolve(module: Module): void {
let dependencies = module.dependencies;
if (dependencies) {
for (let i = 0, len = dependencies.length; i < len; i++) {
let dependency = dependencies[i];
// 如果发现当前模块依赖 exports 则会将 exportsPassedIn 置为 true
// 这会影响到之后如何处理从模块导出的内容
if (dependency === RegularDependency.EXPORTS) {
module.exportsPassedIn = true;
module.unresolvedDependenciesCount--;
continue;
}
if (dependency === RegularDependency.MODULE) {
module.unresolvedDependenciesCount--;
continue;
}
if (dependency === RegularDependency.REQUIRE) {
module.unresolvedDependenciesCount--;
continue;
}
// 依赖已经加载
let dependencyModule = this._modules2[dependency.id];
if (dependencyModule && dependencyModule.isComplete()) {
module.unresolvedDependenciesCount--;
continue;
}
// 检测循环依赖
if (this._hasDependencyPath(dependency.id, module.id)) {
this._hasDependencyCycle = true;
console.warn('There is a dependency cycle between \'' + this._moduleIdProvider.getStrModuleId(dependency.id) + '\' and \'' + this._moduleIdProvider.getStrModuleId(module.id) + '\'. The cyclic path follows:');
let cyclePath = this._findCyclePath(dependency.id, module.id, 0) || [];
cyclePath.reverse();
cyclePath.push(dependency.id);
console.warn(cyclePath.map(id => this._moduleIdProvider.getStrModuleId(id)).join(' => \n'));
// Break the cycle
module.unresolvedDependenciesCount--;
continue;
}
// 记录当前模块需要等待依赖模块加载完成
// 后面依赖模块加载完成时,会根据这个数组来查看当前模块是否能够可以进入 complete 阶段
this._inverseDependencies2[dependency.id] = this._inverseDependencies2[dependency.id] || [];
this._inverseDependencies2[dependency.id]!.push(module.id);
this._loadModule(dependency.id);
}
}
if (module.unresolvedDependenciesCount === 0) {
this._onModuleComplete(module);
}
这里我们先来看另一个情形,即所有的 dependency 都已经解析完毕的情形,此时 _resolve 方法会直接调用 _onModuleComplete
方法结束一个 Module
的加载。这里, ModuleManager
需要将依赖的导出内容按照顺序传递给模块的回调函数,然后查看是否有模块依赖当前模块,以及能够将哪些模块也结束加载。
private _onModuleComplete(module: Module): void {
let recorder = this.getRecorder();
let dependencies = module.dependencies;
let dependenciesValues: any[] = [];
if (dependencies) {
for (let i = 0, len = dependencies.length; i < len; i++) {
let dependency = dependencies[i];
// 如果依赖项是 exports,说明当前 Module 需要导出到变量 exports 对象上,将 exports 对象传入
if (dependency === RegularDependency.EXPORTS) {
dependenciesValues[i] = module.exports;
continue;
}
if (dependency === RegularDependency.MODULE) {
dependenciesValues[i] = {
id: module.strId,
config: () => {
return this._config.getConfigForModule(module.strId);
}
};
continue;
}
// 如果依赖项是 require,构造一个 require 函数并传入
if (dependency === RegularDependency.REQUIRE) {
dependenciesValues[i] = this._createRequire(module.moduleIdResolver!);
continue;
}
// 将依赖项导出的变量传入
let dependencyModule = this._modules2[dependency.id];
if (dependencyModule) {
dependenciesValues[i] = dependencyModule.exports;
continue;
}
dependenciesValues[i] = null;
}
}
module.complete(recorder, this._config, dependenciesValues);
// 找到是否有其他模块依赖当前模块
let inverseDeps = this._inverseDependencies2[module.id];
this._inverseDependencies2[module.id] = null; // 移除值,表示其他模块需要的某个依赖(即当前模块)已经解析完成
if (inverseDeps) {
// 查看其他模块的依赖是否全部解析完成,如果全部完成了,对其他模块也调用 _onModuleComplete 方法
for (let i = 0, len = inverseDeps.length; i < len; i++) {
let inverseDependencyId = inverseDeps[i];
let inverseDependency = this._modules2[inverseDependencyId];
inverseDependency.unresolvedDependenciesCount--;
if (inverseDependency.unresolvedDependenciesCount === 0) {
this._onModuleComplete(inverseDependency);
}
}
}
}
public complete(recorder: ILoaderEventRecorder, config: Configuration, dependenciesValues: any[]): void {
this._isComplete = true;
if (this._callback) {
if (typeof this._callback === 'function') {
let r = Module._invokeFactory(config, this.strId, this._callback, dependenciesValues);
} else {
this.exports = this._callback;
}
}
(<any>this).dependencies = null;
(<any>this)._callback = null;
(<any>this)._errorback = null;
(<any>this).moduleIdResolver = null;
}
private static _invokeFactory(config: Configuration, strModuleId: string, callback: Function, dependenciesValues: any[]): { returnedValue: any; producedError: any; } {
return {
returnedValue: callback.apply(global, dependenciesValues),
producedError: null
};
}
由上,可以看到 complete
的主要过程就是调用 module callback,如果这个模块没有依赖 exports
,就将模块的返回作为该模块的导出。
在 _resolve
方法的执行过程中,vscode-loader 会检测模块之间是否存在循环依赖,具体方法是通过调用 _hasDependencyPath
方法,查看是否存在 dependency 到当前 Module
的依赖路径。我们来举一个例子:假设有 a b c d 四个模块,其中 a 依赖 b d 模块,b 依赖 d 模块,d 依赖 c 模块,而 c 又依赖 a 模块, 那么这里显然存在 a - c - d 的循环依赖。我们假设 a 是先加载的模块,那么 ModuleManager
应当在加载 c 的环节发现 a 循环依赖了 c。
我们用这个例子来分析 _hasDependencyPath
的执行过程。
private _hasDependencyPath(fromId: ModuleId, toId: ModuleId): boolean {
// 在加载 a 的时候 b d 不存在 _modules2 中
// 加载 b 的时候 d 不存在 _modules 2 中
// 加载 d 的时候 c 不在 _modules2 中
// 所以直接跳过检查
// 到 c 的时候,发现它的一个依赖项 a 已经开始加载,这时就需要判断 a 是否也依赖 c
let from = this._modules2[fromId];
if (!from) {
return false;
}
// 将当前所有的 module id 都放到这个数组当中来,如果遍历过的话就设置对应的 index 为 true
let inQueue: boolean[] = [];
for (let i = 0, len = this._moduleIdProvider.getMaxModuleId(); i < len; i++) {
inQueue[i] = false;
}
let queue: Module[] = []; // 这是一个先进先出队列
// 将 c 放到 queue 里面来
queue.push(from);
inQueue[fromId] = true;
while (queue.length > 0) {
// 检查队列首个元素
let element = queue.shift()!;
let dependencies = element.dependencies;
// 如果有依赖的话,检查它的依赖项
if (dependencies) {
for (let i = 0, len = dependencies.length; i < len; i++) {
let dependency = dependencies[i];
// 如果在依赖项里找到了 toId,则说明发现了循环依赖
if (dependency.id === toId) {
// There is a path to 'to'
return true;
}
// 否则将依赖项加入队列,查找依赖项的依赖项
let dependencyModule = this._modules2[dependency.id];
if (dependencyModule && !inQueue[dependency.id]) {
inQueue[dependency.id] = true; // 表示依赖已经查找过了
queue.push(dependencyModule);
}
}
}
}
return false;
}
当加载 c 的时候, _hasDependencyPath
的调用参数是 a 和 c,即查看是否存在 a 到 c 的依赖关系。
true
这其实就是一个广度优先的有向图搜索算法,在遍历的过程中,我们尝试找到从 from
到 to
的路径,找得到的话我们就知道有循环依赖了(因为我们已经知道 from
是 to
的依赖,所以肯定存在一条 to
到 from
的路径)。
发现循环依赖之后,vscode-loader 会通过 _findCyclePath
中的一个深度优先的搜索算法找到该路径并提示开发者,然后直接标记这个模块已经解析完毕,防止出现死锁(当然这种情况下并不能保证能够正常工作,实际上碰到文件的循环依赖,就应该想办法去解开循环依赖)。
讲完了所有依赖项都已经解析完的理想情形,接下来我们看看未加载的依赖是怎么加载的,这里会涉及到 JavaScript 文件的加载和解析过程。
在上面的学习中我们已经知道,依赖的加载过程是从 _loadModule
方法开始的,该方法的主要逻辑是:
moduleIdToPaths
方法,找到文件可能存在的路径this._scriptLoader.load
方法,在加载成功的回调里再去调用 this._onload
方法 private _loadModule(moduleId: ModuleId): void {
if (this._modules2[moduleId] || this._knownModules2[moduleId]) {
// known module
return;
}
this._knownModules2[moduleId] = true; // 标记为加载中,防止第二次读取文件加载
let strModuleId = this._moduleIdProvider.getStrModuleId(moduleId);
let paths = this._config.moduleIdToPaths(strModuleId);
let scopedPackageRegex = /^@[^\/]+\/[^\/]+$/ // matches @scope/package-name
if (this._env.isNode && (strModuleId.indexOf('/') === -1 || scopedPackageRegex.test(strModuleId))) {
paths.push('node|' + strModuleId);
}
let lastPathIndex = -1;
let loadNextPath = (err: any) => {
lastPathIndex++;
if (lastPathIndex >= paths.length) {
} else {
let currentPath = paths[lastPathIndex];
this._scriptLoader.load(this, currentPath, () => {
this._onLoad(moduleId);
}, (err) => {
});
}
};
loadNextPath(null);
}
我们先来看 ScriptLoader
的内容。
根据代码运行的平台,有不同的 ScriptLoader
,均继承 IScriptLoader
接口:
BrowserScriptLoader
,浏览器主线程环境WorkerScriptLoader
,web worker 环境NodeScriptLoader
,Node.js 环境另外有一 OnlyOnceScriptLoader
,它主要起两个作用:
load
方法传进来回调函数我们这里主要跟大家一起学习 NodeScriptLoader
,其他三者的代码都比较简单,大家可以自行学习。
在 _load
方法首次调用的时候,会通过 _init
和 _initNodeRequire
初始化加载环境。
_init
的主要内容就是加载 node 原生的几个 module 并绑定在自己的属性上。而 _initNodeRequire
的内容则比较复杂,主要是 patch 了 Node.js 的 Module 模块的 _compile
方法,这个方法的主要逻辑是在 v8 的引擎中执行 JavaScript 文件的内容。vscode-loader 所作的 patch,就是在调用 v8 执行 JavaScript 代码时提供了 cacheData,这能够加速 v8 的执行。我们这里就不深入了解 cache 机制了,感兴趣的同学可以自行学习,让我们回到 _load
方法。
通过 node 方法加载文件的时候,会有两种情况:
第一种,文件以 node| 开头,位于 node_modules 目录中,此时直接调用原生 require 方法,并将该模块的导出通过 enqueueDefineAnonymousModule
方法,创建一个 AMD 模块。注意,这里在加载依赖的时候,就用到了之前对 Module.prototype._compile
的 patch,能够通过缓存来加速。
public load(moduleManager: IModuleManager, scriptSrc: string, callback: () => void, errorback: (err: any) => void): void {
const opts = moduleManager.getConfig().getOptionsLiteral();
const nodeRequire = ensureRecordedNodeRequire(moduleManager.getRecorder(), (opts.nodeRequire || global.nodeRequire));
const nodeInstrumenter = (opts.nodeInstrumenter || function (c) { return c; });
if (/^node\|/.test(scriptSrc)) {
let pieces = scriptSrc.split('|');
let moduleExports = null;
try {
moduleExports = nodeRequire(pieces[1]);
}
moduleManager.enqueueDefineAnonymousModule([], () => moduleExports);
callback();
} else {
// ...
}
}
public enqueueDefineAnonymousModule(dependencies: string[], callback: any): void {
this._currentAnnonymousDefineCall = {
stack: stack,
dependencies: dependencies,
callback: callback
};
}
// 由 _loadModule 中的一个回调函数调用
private _onLoad(moduleId: ModuleId): void {
if (this._currentAnnonymousDefineCall !== null) {
let defineCall = this._currentAnnonymousDefineCall;
this._currentAnnonymousDefineCall = null;
// Hit an anonymous define call
this.defineModule(this._moduleIdProvider.getStrModuleId(moduleId), defineCall.dependencies, defineCall.callback, null, defineCall.stack);
}
}
第二种,则是加载 vscode 自己的代码,这样的情况下,则是通过调用 Node.js vm 模块的运行代码,并通过修改代码运行环境中的 define
变量,来检测 define
函数是否有被执行(没有执行的话,就认为当前加载的并不是一个模块,并抛出错误)。
public load(moduleManager: IModuleManager, scriptSrc: string, callback: () => void, errorback: (err: any) => void): void {
const opts = moduleManager.getConfig().getOptionsLiteral();
const nodeRequire = ensureRecordedNodeRequire(moduleManager.getRecorder(), (opts.nodeRequire || global.nodeRequire));
const nodeInstrumenter = (opts.nodeInstrumenter || function (c) { return c; });
if (/^node\|/.test(scriptSrc)) {
} else {
// ...
scriptSrc = Utilities.fileUriToFilePath(this._env.isWindows, scriptSrc);
const normalizedScriptSrc = this._path.normalize(scriptSrc);
const vmScriptPathOrUri = this._getElectronRendererScriptPathOrUri(normalizedScriptSrc);
this._readSourceAndCachedData(normalizedScriptSrc, cachedDataPath, recorder, (err: any, data: string, cachedData: Buffer, hashData: Buffer) => {
let scriptSource: string;
if (data.charCodeAt(0) === NodeScriptLoader._BOM) {
scriptSource = NodeScriptLoader._PREFIX + data.substring(1) + NodeScriptLoader._SUFFIX;
} else {
scriptSource = NodeScriptLoader._PREFIX + data + NodeScriptLoader._SUFFIX;
}
scriptSource = nodeInstrumenter(scriptSource, normalizedScriptSrc);
const scriptOpts: INodeVMScriptOptions = { filename: vmScriptPathOrUri, cachedData };
const script = this._createAndEvalScript(moduleManager, scriptSource, scriptOpts, callback, errorback);
});
}
}
private _createAndEvalScript(moduleManager: IModuleManager, contents: string, options: INodeVMScriptOptions, callback: () => void, errorback: (err: any) => void): INodeVMScript {
const script = new this._vm.Script(contents, options);
const ret = script.runInThisContext(options);
const globalDefineFunc = moduleManager.getGlobalAMDDefineFunc();
let receivedDefineCall = false;
const localDefineFunc: IDefineFunc = <any>function () {
receivedDefineCall = true;
return globalDefineFunc.apply(null, arguments);
};
localDefineFunc.amd = globalDefineFunc.amd;
ret.call(global, moduleManager.getGlobalAMDRequireFunc(), localDefineFunc, options.filename, this._path.dirname(options.filename));
if (receivedDefineCall) {
callback();
} else {
errorback(new Error(`Didn't receive define call in ${options.filename}!`));
}
return script;
}
在执行这个文件的时候,define
函数的第一个参数总是空的,即定义的总是一个匿名函数(这点通过查看 vscode 编译后的文件即可知道),所以总是会进入到 DefineFunc
的第二个分支情形,之后的运行过程,就跟加载 node_modules 中的文件时一模一样了。
const DefineFunc: IDefineFunc = <any>function (id: any, dependencies: any, callback: any): void {
if (id) {
//
} else {
moduleManager.enqueueDefineAnonymousModule(dependencies, callback);
}
};
到这里,vscode-loader 的整个核心过程就基本介绍完毕了。
vscode 实现的 loader 有如下特性:
由于篇幅的限制,这篇文章没有深入分析以下这些机制,感兴趣的同学在阅读本文之后可以自行学习:
这篇文章翻译自 Aaron Swarts 的 HOWTO: Be more productive,点击这里阅读原文。
有人说“如果你把看电视的时间都拿来写小说,那现在你的小说都该出版了。”这种话很难反驳——毫无疑问,宝贵的时间更应当用来写小说而不是看电视。但是这段话有一个隐含的假设,时间和时间是一样的,你花时间写小说就像花时间看电影一样轻松——不幸的是,这并不是事实。
时间存在质量的差别。如果我在去地铁站的路上但是却没有带纸笔,你就很难趁机写些东西。如果你经常别别人打断,你就很难集中注意力。这也和你的心理状态相关,有时候你很高兴,那你就会有动力去做些事情,有时候你很沮丧疲倦,你可能就只想看电视。
注:原文写于 2005 年。
如果你想变得更有效率,你就需要认识到这个事实并接受它。你不仅需要充分利用一天中不同的时间段,也需要提升时间的质量。
人生短暂(至少别人是这么告诉我的),为什么要在一些蠢事上浪费时间呢?你做一件事情,可能仅仅是因为这件事情比较好做,但你应当时时质问自己为什么要做这件事。有比你手头在做的事更有意义的事情吗,你为什么不去做那件事?这种问题很难回答(如果你一直这样追问下去,你就会问自己为什么不去做世界上最重要的事情),但是每一次这样的反思都会让你更有效率。
我并不是说你所有的时间都应该花在世界上最重要的事情上(我当然没有这么做,比如我现在在写这篇文章),但是这是我评估工作状态的一个重要标准。
人们常常会错误地认为,如果你全神贯注地一次只做一件事情,你最终就能完成更多的事情。我认为这绝非事实。就比如现在,我同时在端正自己的坐姿、喝水、清洁书桌、跟我的兄弟发消息,以及写这篇文章。而就今天一天,我写作这篇文章、读了本书、吃了些东西、回复了几封邮件、和我的伙伴先聊了一会、逛了趟超市、写了其他一些东西、备份了我的硬盘、整理了我的阅读清单。在过去的一周里,我同时在写好几个项目的代码、翻了几本书、涉猎了几种编程语言,等等。
同时做多个项目让你在不同质量的时间段做不同的事情。而且,如果你在做一件事的时候卡壳或感到无聊了,你还有别的事情可做,这能让你的脑袋重新运转起来。
这也会让你更有创造力。当你把其他领域的知识应用到你目前正在研究的领域时,很可能就会产生奇思妙想。如果你同时做多个不同领域的多个项目,你产生新想法的概率就会大大增加。
想出几个要做的事情并不困难,大部分人都有一大堆想要完成的事情,但是如果你把它们都记在脑袋里的话,你很快就会被这些事情所淹没,记住你需要做什么会带来极大的心理压力。解决的方法非常简单:写下来。
一旦你列出了所有你想要做的事情,你就能够根据它们的类别来进行组织。比如,我的列表就分为编程、写作、思考、杂事、阅读、听(音乐)、看(电影、剧集)几类。
大部分项目涉及到多种类型的任务。以撰写这篇文章为例,除了撰写文本之外,还包括阅读关于拖延症的资料,思考这篇文章的结构、修辞,写邮件询问问题等等。每种任务都可以被分到上面几个类别当中,这样你就可以在合适的时间一一完成它们。
一旦你列出了这份清单,最重要的就是时刻记得去检查它。确保自己按照计划行事的最佳方法,就是确保你要做的事情触手可及。比如,我在书桌上放了一摞子书,并把我最近在读的一本放在最上面,这样当我想要读书的时候,抓起最上面的那一本就可以了。
我也用这种方式来处理电视节目和电影。当我听说一部值得一看的电影时,我就把它存到我电脑的一个特殊文件夹里。当我想要看电视的时候,我就打开那个文件夹。
我还设想过一种侵入性更强的方式。比如,当我想要阅读博客的时候,一个网页跳出来,展示我放在“待阅读”文件夹里面的文章。或者当我走神的时候,一个弹窗会弹出来建议我接下来应该做什么。
上面说到的建议只告诉你如何有效利用时间,更重要的事情时让时间更有质量。大部分人的时间都被上学和上班这样的事情占据了。如果你在做这样的事情,你应该停下来。但是还能做什么事情呢?
我认识的有意思的人中的大部分都保持着随身带着笔记本的习惯。不管在什么场合,纸笔都能马上掏出来用,比如给别人写个便签,画个草图等等。我甚至在地铁上写了一整篇文章。1
(我过去是这么做的,但现在我只用带上智能手机就好了。我虽然没法用它给别人留纸条 ,但是它却能让我随时随地有东西可以读,还可以把笔记发送到我的邮箱里,这样我就能够稍后处理了。)
在做那些需要全神贯注的工作时,你应该避免被打扰。一个简单的方法是去一个能让你远离干扰源的地方。另一个方法是和周围的人做好约定:“当我把门关上的时候不要来打扰我”,或者“当我带上耳机的时候给我发消息”(这样你就可以等到你有空的时候再来处理这些消息了)。
但是别过度。当你纯粹是在浪费时间的时候,你需要被打扰。花时间帮助别人解决问题比呆坐着看新闻要有意义得多。这就是为什么别人做约定是个好主意:当你并不是在全神贯注的时候,你可以被打扰。
当你饥饿、疲倦或者是焦躁不安的时候,时间的质量是很低的。改善这种状况非常简单:吃饭、睡觉和锻炼。但我自己有时候会做不好。我不喜欢出去吃东西,所以我经常会饿着肚子干活,以至于最后疲劳到甚至没有去吃东西的力气。2
你的脑袋可能不时会有一个声音在说:“虽然我很累了,但是我还不能去睡觉——我还有工作要做。”但事实上,如果你真的去睡一会,你的工作效率会更高,因为睡觉会提升一天中剩下的时间的质量,更何况反正你都是要睡觉的。
我并不怎么锻炼,所以可能我给出的建议并没有专业人士那么权威,但是只要有机会我就会锻炼一会。即使是躺在床上读书的时候,我也同时在做仰卧起坐。当我想去某个徒步就到达的地方的时候,我就跑着去。
减轻思维负担的另一个办法是,跟那些使人振奋的人做朋友。比如,我发现我在跟 Paul Graham 和 Dan Connolluy 聊天过后会乐意工作,他们俩就是那种到处散发着能量的人。你可能会认为工作的时候就该远离人群,把自己关在房间里,但是这可能使得你颓废并且没有效率。
即使你的朋友并不是那么使人振奋,和其他人一起做一件困难的事情也会使得这个问题变得更容易解决。一方面,其他人能够减轻你的思维负担,另一方面,他们也能促使你好好工作避免分神。
但以上这些方法都只是回避了真正的问题,人们无法高效工作的罪魁祸首就是拖延。这是大家心照不宣的小秘密——不只是我,所有人都在拖延,但是你真的应该别再拖延了。
什么是拖延?在他人看来,拖延就是你只是在做“好玩”的事情(比如打电动或者是看新闻),而不是在做一些实际的工作(这使得在他人眼里你看起来很懒惰)。但我们得搞清楚,你的脑袋里究竟在想什么呢?
我花了大量的时间来探究这个问题,现在我能做到的最好的描述是:你的大脑在你要完成的任务之外设置了某种思维防护罩。你肯定玩过磁铁,当你把两块磁铁的同一磁极对准并让它们靠近,它们就会相斥。当你移动磁铁的时候,你能感受到磁场的存在,而当你尝试把磁极挨到一块儿的时侯,它们就会马上弹开。
思维防护罩似乎有着和磁场一样的性质。它们并不是有形的实体,也不可见,但是你却能感知到它们。当你强制自己去完成这项任务的时侯,不出意外,你会被弹开。3
你没法通过大力出奇迹的方式让两块磁铁贴在一起,只要你一松手它们就会弹开——你也没办法靠自控力克服思维防护罩。你应该设法绕过这个问题,换句话说,你必须翻转磁铁。
是什么东西造成了思维防护罩的产生?明显的原因有两个:任务的难度,以及任务是不是指派的。
第一种难题是那种过于庞大的问题。比如你想要设计一套收据管理系统,没有人可以坐下来一气呵成码出一个。这是一个目标,而非一项任务。任务是完成目标的过程中明确而具体的一小步。一个定义良好的目标看起来应该像“绘制收据展示界面的草图”,这样你才能知道如何下手[4]。
而当你完成了第一步,接下来要做什么就清楚了。你需要确定一个收据中包含什么信息,应该提供什么杨的搜索功能,如何设计数据库等等。你可以创建一个备忘录,将任务按照顺序排列出来。只要你分而治之,问题就会更容易解决。
我在处理大项目的时侯,我总是会想我下一步要做什么,然后添加到上面所说的分类清单中(见上文)。4
另一种类型的困难问题是过于复杂或庞大的问题。写一本书这样大的项目看起来太困难了,那么就从写一篇文章开始。如果文章对于你来说也太长了,就从写几段句子开始。真正重要的事情是马上完成。
一旦你开始做一件事,你就能更准确的评判它并对问题有更深的了解。不断优化已有的东西也比从头开始更加简单,一旦你写出了几段不错的句子,或许你就可以把它们发展成一篇文章,然后再发展成一整本书。积以跬步,最终你就能写出一部优秀的作品。
通常解决一个困难的问题需要一点灵感。如果你对某个领域一无所知,显然你应该从调研这个领域开始,看看别人是怎么做事的,来获得一个感性的认识,然后再慢慢地试图完全掌握。你可以通过尝试解决一些小问题来检验自己的进步。
这里指的是别人要你去解决的问题。大量的心理学研究指出,如果尝试激励别人去做某件事,他们做这件事的意愿会降低,并且最终的表现也会不尽人意。外部的刺激,例如奖励或惩罚,会磨灭心理学家们所说的“主观意愿”,人内在的对解决问题的兴趣(这是心理学上被反复验证最多次的研究发现之一,超过 70 项研究都发现奖励会降低人们对任务的兴趣)5。人脑似乎天生就非常抗拒被别人驱使着做事情。6
奇怪的是,这一现象并不只出现在别人叫你做事的时侯,它同样发生在你试图告诉你自己应该做什么的时侯!如果你对自己说:“我真的应该开始去做甲事,因为这是眼下最重要的事情。”,那突然间甲事就会变成世界上最难做的事。但只要有乙事突然间成为了最重要的事情,那么甲事突然就变得简单多了(尽管它实际上根本没有任何变化)。
上面说到的现象似乎暗示你应该在想要做甲事的时候,告诉自己应当去做乙事,但不幸的是你不可能通过欺骗自己来利用这种效应,因为你很清楚自己在想什么。7所以你必须用一些取巧的方法。
其中一个方法是让别人给你分派任务。最出名的例子是一个毕业生,他需要写一篇毕业论文,这当然是一个极端困难的工作。所以,为了暂时逃避这个困难的工作,他最后开始做各种各样其他困难的工作。
这个问题必须看起来非常重要(你需要毕业论文才能拿到学位)而且很大(你需要为几百页殚精竭虑!),但又不至于重要到拖延它就会是一场灾难。
你可能会对自己说“好吧,我要把别的事情都先推到一边,坐下来把这篇文章搞完”。更糟糕的是试图贿赂自己做某件事:“我如果写完文章了奖励自己一顿好吃的”。而最糟糕的是让别人强迫你做某件事情。
你很可能已经尝试过上面三种方法——我自己就全都做过——但是它们只能降低你的效率。它们从根本上来说都是在尝试给自己指派任务,所以你的脑袋就会想方设法地去逃避它们。
通常来说,困难的工作是不太可能有趣的。但事实上,对我而言解决难题可能是最有意思的事情,不仅是因为尝试解决问题时那种全神贯注的体验,更因为解决难题之后的美妙感觉。
所以,让自己做某件事的秘密并不是让自己相信这是你不得不做的事情,而是告诉自己这件事情很有趣。如果它并不是如此有趣,你就需要让它变得有趣。
我第一次严肃地对待这个想法是我还在大学里写论文的时候。写论文并不是一件非常困难的事情,但它显然是个别人交代给你的任务。谁会吃了没事在两本莫名其妙的书之间找关联呢?所以我开始尝试在写论文的过程中找乐子。比如说,我决定每一段都用不同的写作风格来写,尽可能模仿不同的演讲方式。(这种方法除了凑字数外还有一些额外的好处。)8
另一种让工作变有趣的方法是去解决“元问题”。比如,写一个 web 框架而不是 web 应用。这不仅会让任务变得更有趣,产出也会更有价值。
关于工作效率,一直存在很多错误的认识,比如时间是同质的、专注是好的、给自己奖励是有效的,困难的工作是无聊的,拖延是不自然的——但是这些认识都基于一个共同的假设:工作是违背人天然的意愿的。
对于大部分人、大部分工作而言,这或许是真的,但这并不是你应该撰写无聊的文章或毫无意义的备忘录的理由。如果你所处的环境迫使你做这些事,你需要让自己脑海里的声音发声:“停下!”。
但如果你想要去做一些有价值、有创造性的工作,那么封闭你的头脑就是完全的南辕北辙了。高效工作的秘诀恰恰相反:尊重你身体的感受。饿了就吃,困了就睡,感到无聊了就休息一会儿,去做那些好玩有趣的事情。
这看起来似乎非常简单,和成功人士口中的很玄乎的缩略词、自控力、自我奖励什么的都不相干,看起来就像是一些常识。但是社会对工作的普遍认识让我们在相反的方向越走越远,我们需要做的仅仅是换个角度。
如果你想了解更多动机心理学相关的知识,我敢说没有比 Alfie Kohn 更权威的人士了。他写了很多篇关于这个主题的文章和一本书 Punished by Rewards,我非常推荐这本书。
我希望能够在未来的某篇文章中告诉你如何退学,但你真的应该现在就退学。读读 The Teenage Liberation Handbook 吧。如果你搞编程,你可以去申请 Y Combinator 的基金。另外,Mickey Z 的书 The Mudering of My Years 介绍了一些艺术家和活动家是如何在生活富足的同时还在做他们想做的事情的。
不管你信不信,我真的在地铁上写过东西。要是想给不工作找个理由的话,理由可太多了:时间不够、楼下太吵,等等。但是我发现当我来灵感的时候,我可以在地铁上写一些东西。尽管那里真的很吵并且在下车之前我只有几分钟的时间。
对于睡眠也是这样。困到睡不着是一种糟糕透顶的体验——感觉起来就像僵尸一样。
现在看来我在另外一件事上也有这种现象:害羞。我不喜欢给陌生人打电话,或者是在派对上跟别人聊天。我怀疑这或许是因为害羞也是问题童年的后果之一。当然,这只是推测。
我在这里使用的术语源自 David Allen 的 Gettings Things Done,这里面的原则(或许是无意的)被应用在了极限编程当中(Extreme Programming),极限编程是组织软件开发的一套系统,但是我发现它提出的很多建议对于避免拖延来说都很有用。
比如,结对编程自然而然地在两个人之间分担了思维负担,而且给人们在低质量的时间段一些别的事情做。将大项目分解成一个个小迭代是极限编程的另一个核心要素,迅速完成某些工作然后不断提升它(就像我在“简化问题”当中说的)。这些都不止适用于编程。
如果想了解这方面研究的综述,请阅读 Alfie Kohn 的 Punished By Rewards,这里的观点源自他的文章 Challenging Behaviorist Dogma: Myths About Money and Motivation(挑战行为教义:关于金钱和动机的迷思)。
我最初简单地以为这是天生的,但是 Paul Graham 指出这更可能是习得性行为。当你还小的时候,你的父母会严格管教你,当他们叫你去写作业的时候,你的思绪却四处飘荡,想着别的事情,很快这种走神就成了一种习惯。无论如何,这都是一个难以解决的问题。我已经放弃了改变这种行为,而是绕过它。
Richard Feynman 讲过一个他如何探索梦境的故事,我研究自己的拖延问题的方法和他的方法很类似。每天晚上,他尝试观察当他睡着的时候发生了什么:
像往常一样,某天晚上我又做梦了,我试着观察……然后我意识到我一直在枕着一根铜杆睡觉,我把手放在后脑勺上,感觉到后脑勺软软的,我想:“啊哈!这就是为什么我能在梦里观察:这个铜杆压迫到了我的视觉皮层“。只要我想要觉察我的梦境,我只要往脑后放一根铜杆就行了!我可以停止对这次梦境的观察,进入更深的睡眠了。”
当我过一会儿醒来的时候,脑后并没有铜杆,我的后脑勺也没有软软的。我的大脑不知怎么给我了一些错误的解释来阻止我观察自己的梦境。(出自 Surely You're Joking, Mr Feynman! 第 50 页)
你的大脑比你想象的更加奇妙。
所以,举个例子,我不会这么写:“By contrast, Riis doesn’t quote many people.”,而会这么写:“Riis, however, whether because of a personal deficit in the skill-based capacity required for collecting aurally-transmitted person-centered contemporaneous ethnographies into published paper-based informative accounts or simply a lack of preference for the reportage of community-located informational correspondents, demonstrates a total failure in producing a comparable result.”
我的教授,显然对这种啰里八嗦的写作方法脱敏了,似乎从未意识到我在这里开的玩笑(尽管和我一页一页地修改了论文)。
本文原作者 Aaron Hillel Swartz 中文名为亚伦·希勒尔·斯沃茨,生卒 1986 年 11 月 8 日-2013 年 1 月 11 日,他参与开发了 RSS、Markdown 和 web.py,同时还是 Reddit 的联合创始人之一,著名黑客。你可以通过维基百科进一步了解他的生平。
欢迎阅读我的博客。
Welcome to my blog.
请移步这里。
TypeScript 有一个强大但是却不那么新手友好的功能,那就是装饰器。 你肯定用过 Angular 实现的很多装饰器,比如装饰类的 @Component
,装饰属性的 @ViewChild
,以及装饰方法的 @HostListenner
,但是你尝试过自己写一个装饰器吗?它们粗看起来似乎很神奇 🍄,但实际上它们只是一些 JavaScript 函数,能够帮助我们来注释代码或者是修改代码的行为——这种做法我们通常称为元编程。
一共有五种装饰器的方法,我们会通过举例子的方式一一讲解它们。
装饰器能够很好的抽象代码。尽管用装饰器来封装所有东西看起来很有诱惑力,但是它们最合适的用场还是来包装可能会多处复用的稳定的逻辑。
类装饰器使得开发者能够拦截类的构造方法 constructor。注意:当我们声明一个类时,装饰器就会被调用,而不是等到类实例化的时候。
注:装饰器最为强大的功能之一是它能够反射元数据(reflect metada),一般开发者很少会需要这个功能,但是在例如 Angular 这样的框架中,它很适合用来分析代码来得到最终的 bundle。
当你装饰一个类的时候,装饰器并不会对该类的子类生效,让我们来冻结一个类来彻底避免别的程序员不小心忘了这个特性。
@Frozen
class IceCream {}
function Frozen(constructor: Function) {
Object.freeze(constructor)
Object.freeze(constructor.prototype)
}
console.log(Object.isFrozen(IceCream)) // true
class FroYo extends IceCream {} // 报错,类不能被扩展
这里的例子都用到了装饰器工厂模式。我们将装饰器本身封装在另外一个函数中,这样就能给装饰器传递变量了,例如
@Cool('stuff')
。而当你不想给装饰器传参,把外层那个函数去掉就好了@Cool
。
属性装饰器极其有用,因为它可以监听对象状态的变化。为了充分了解接下来这个例子,建议你先熟悉一下 JavaScript 的属性描述符(PropertyDescriptor)。
在这个例子中我们将会用两个冰淇淋 emoji 来包裹 flavor 属性的值。此时属性装饰器就可以使得我们在对属性赋值之前或在取属性之后附加一些操作,就像中间件那样。
export class IceCreamComponent {
@Emoji()
flavor = 'vanilla'
}
// Property Decorator
function Emoji() {
return function(target: Object, key: string | symbol) {
let val = target[key]
const getter = () => {
return val
}
const setter = next => {
console.log('updating flavor...')
val = `🍦 ${next} 🍦`
}
Object.defineProperty(target, key, {
get: getter,
set: setter,
enumerable: true,
configurable: true,
})
}
}
我们可以使用方法装饰器来覆写一个方法,改变它的执行流程,以及在它执行前后额外运行一些代码。
下面这个例子会在执行真正的代码之前弹出一个确认框。如果用户点击了取消,方法就会被跳过。注意,这里我们装饰了一个方法两次,这两个装饰器会从上到下地执行。
export class IceCreamComponent {
toppings = []
@Confirmable('Are you sure?')
@Confirmable('Are you super, super sure? There is no going back!')
addTopping(topping) {
this.toppings.push(topping)
}
}
// Method Decorator
function Confirmable(message: string) {
return function(
target: Object,
key: string | symbol,
descriptor: PropertyDescriptor
) {
const original = descriptor.value
descriptor.value = function(...args: any[]) {
const allow = confirm(message)
if (allow) {
const result = original.apply(this, args)
return result
} else {
return null
}
}
return descriptor
}
}
你或许听说过 React Hooks 如何彻底改变了 React 的开发生态。Angular 能不能用这样的方式来写出同样优雅、简明的代码呢?事实上,完全可以,而且从一开始就可以。
在 React 中,调用 useState hook 会返回给你一个响应式的变量 count
和一个 setter setCount
。
import { useState } from 'react'
function Example() {
// Declare a new state variable, which we'll call "count"
const [count, setCount] = useState(0)
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
)
}
我们可以使用一个属性装饰器来实现相似的效果。在组件的 count
属性上声明该装饰器,这个装饰器就会帮我们定义 count
,同时也会帮我们定义 setCount
这个 setter。
用起来就像这样:
import { BehaviorSubject } from 'rxjs'
@Component({
selector: 'app-root',
template: `
<p>You clicked {{ count }} times</p>
<button (click)="setCount(count + 1)">Click Me</button>
`,
})
export class HookComponent {
@UseState(0) count
setCount
}
而该装饰器的实现不过五行代码,我们只需要设置好初始值以及相应的 setter 即可:
function UseState(seed: any) {
return function(target, key) {
target[key] = seed
target[`set${key.replace(/^\w/, c => c.toUpperCase())}`] = val =>
(target[key] = val)
}
}
Effect hook 所做的事情只是简单的把组件生命周期中的 componentDidMount
和 componentDidUpdate
这两个钩子合并到了同一个回调中。
useEffect(() => {
// Update the document title using the browser API
document.title = `You clicked ${count} times`
})
用一个方法装饰器很容易就能模拟,我们只需要将 Angular 对应的生命周期方法 ngOnInit
和 ngAfterViewChecked
指向该方法的属性描述符的 value
即可。
@Component(...)
export class AppComponent {
@UseEffect()
onEffect() {
document.title = `You clicked ${this.count.value} times`;
}
}
function UseEffect() {
return function (target, key, descriptor) {
target.ngOnInit = descriptor.value;
target.ngAfterViewChecked = descriptor.value;
};
}
这篇文章将会介绍 vscode 扩展的架构和一些关键技术。
为了使内容尽量精简,我们将只会讲解桌面端 vscode 所使用到的模块。
在 2016 年的一次演讲中,vscode 的 leader 谈到了 vscode 的 extension 架构,架构主要有以下一些特点:
https://youtu.be/uLrnQtAq5Ec?t=1730![截屏2021-03-01 下午9.15.08](/Users/wendellhu/Documents/截屏2021-03-01 下午9.15.08.png)
注:由于 extensions 的多进程架构,不可避免的会涉及到跨进程调用和消息传输等问题。本文在遇到它们的时候会指出这里涉及到跨进程调用,但不会详细阐述跨进程调用的原理,之后会有文章专门分析。
extension 系统的构建过程最初从 extensionService
开始,它是在渲染进程中创建的。
这就意味着每个 vscode 窗口都独自管理各自的进程。
extension 可以运行在不同的环境中,例如 web worker,node.js 子进程甚至是远端。为了聚焦分析核心机制,这篇文章里我们不会去分析 node.js 子进程之外的环境。因此,在查看源码的时候,如果看到一个类有两个实现,请优先关注 electron-browser 目录下的。
extensionService 会在生命周期服务触发 ready 事件后调用自身的 _initialize 方法
这个方法会启动 extension host,在桌面端,这些 host 中包括 LocalProcessExtensionHost,这就是 ExtensionHost 在渲染进程中的实现。
extensionService 会为每一个 host 创建 ExtensionHostManager 对象管理并管理之。Manager 构造的时候,会启动当前的 host,对于 LocalProcessExtensionHost,这个方法的核心内容是 fork 出一个进程并与其建立通讯。
创建进程的最后一步是与其握手 _tryExtHostHandshake
握手之后会用 vscode 自带的跨进程通讯模块建立一个 protocol 用于通讯。
渲染进程和 Host 进程是如何进行通讯的呢?
在 _tryListenOnPipe 方法中可以看到原来是通过创建一个套接字,然后用 node.js 的 net 模块创建了一个 server
server 的 socket 会在 fork 出 Host 进程的时候
那么 Host 进程的初始运行脚本是什么呢?这个由上下文中的 VSCODE_AMD_ENTRYPOINT 变量所制定
vs/workbench/services/extensions/node/extensionHostProcess
从上面的文件开始 Host 进程的探索,startExtensionHostProcess 会和渲染进程建立通讯,并实例化 ExtensionHostMain 来管理 Host 进程。
initData 当中含有渲染进程初始化时已安装的 extension 的信息
initData 是怎么来的
在加载插件之前,首先要对它们进行注册
在 ExtHostExtensionService 的构造方法中,会创建一个 Registry 收集 extension 的信息。
ExtensionDescriptionRegistry
IExtHostExtensionService 会被创建,调用其 initialize 方法将会开始 extension 的加载过程。
分为三个阶段
_activeExtensions
同一批 activate 的组件中,需要分析依赖关系,不需要依赖其他组件的进入 greenMap,否则进入 red,
然后递归调用 _activateExtensions 直到所有的组件全都被 activate 为止。
vscode 提供了许多 API 供开发者使用,只需要在插件中 import * as vscode from "vscode"
,即可通过vscode
对象访问这些 API。但是查看 npm 上 @types/vscode 包,当中却只有类型声明文件,显然开发者是不可能通过这个包来访问 API 的。这一章节我们来探究一下这些 API 是如何被构建,又是如何被注入到插件当中的。
API 的构造就发生在 extHost.api.impl.ts
文件的 createApiFactoryAndRegisterActors
函数当中。这个函数通过依赖注入系统获取到当前渲染进程内 vscode 的各种服务,并返回一个匿名的工厂函数,工厂函数被调用时会将依赖注入获取到的服务封在一个对象上,这个对象就是 vscode 暴露出的 extension API 了:
return <typeof vscode>{
version: initData.version,
// namespaces
authentication,
// ...
}
我们这里就不关注 API 具体的实现了,来看看这个 API 是如何注入到插件的运行上下文当中的吧。
插件运行在 Host 进程当中,而一些对应的服务显然运行在渲染进程当中,所以插件要想使用这些服务必然有跨进程调用的问题,为此 vscode 在这里实现了很多的跨进程通讯绑定。
在准备组件加载环境的 _beforeAlmostReadyToRunExtensions
方法当中,上文中的 createApiFactoryAndRegisterActors
方法会被调用,然后生成一个
这里是该工厂方法被实际调用的地方。
const ext = this._extensionPaths.findSubstr(parent.fsPath);
if (ext) {
let apiImpl = this._extApiImpl.get(ExtensionIdentifier.toKey(ext.identifier));
if (!apiImpl) {
apiImpl = this._apiFactory(ext, this._extensionRegistry, this._configProvider);
this._extApiImpl.set(ExtensionIdentifier.toKey(ext.identifier), apiImpl);
}
return apiImpl;
}
然后该 factory 会用于生成一个 Interceptor,最后调用 _installInterceptor 方法,该方法会替换 require 对象上 $nodeRequire('module')__.load 方法,当需要一个依赖时,会先去 _factories 查找有没有这个依赖项的 factory,如果有则从 factory 进行加载:
if (!that._factories.has(request)) {
return original.apply(this, arguments);
}
return that._factories.get(request)!.load(
request,
URI.file(parent.filename),
request => original.apply(this, [request, parent, isMain])
);
而 interceptor 初始化的时候会 registery 一个名为 VSCodeNodeModuleFactory 的 facotry,其 load 方法为
而其 nodeModuleName 则为
这样就实现了对 require('vscode') 的拦截,向 extension 的运行环境注入了 vscode API。
this.register(new VSCodeNodeModuleFactory(this._apiFactory, extensionPaths, this._extensionRegistry, configProvider, this._logService));
这里回到渲染进程,分析 extensionScanner 的行为
开发组件时,浮层是一个很常见的需求,比如弹出式对话框、上下文菜单、通知等都需要使用浮层。
在开发 overlay 时,有这些问题需要考虑:
Angular CDK 的 overlay 模块为这些问题提供了完备的解决方案:
Overlay
PositionStrategy
KeyboardDispatcher
ScrollStrategy
这一系列文章将带你阅读 Angular CDK 中 overlay 模块的代码,分析这些机制是如何工作的,组件开发者们又该如何利用该模块开发组件。
该系列文章分为两篇(暂定),第一篇文章介绍 overlay 的核心机制,第二篇文章介绍 overlay 模块提供的一些 directive,以及跟随元素改变位置的 ConnectedStrategy 机制。
为了使大家更好地理解本文内容,我们先引入一个例子。这个例子来自 Angular Mateiral 的 BottomSheet 组件。
链接。
打开第一个 demo 里的 BottomSheet 组件,打开开发者工具,定位到相关元素(省略了部分无关内容,美化了格式):
<!-- container element -->
<div class="cdk-overlay-container">
<!-- backdrop element -->
<div
class="cdk-overlay-backdrop cdk-overlay-dark-backdrop cdk-overlay-backdrop-showing"
></div>
<!-- host element -->
<div
class="cdk-global-overlay-wrapper"
dir="ltr"
style="justify-content: center;align-items: flex-end;"
>
<!-- pane element -->
<div
id="cdk-overlay-0"
class="cdk-overlay-pane"
style="max-width: 100%;pointer-events: auto;position: static;margin-bottom: 0px;"
>
<div
tabindex="0"
class="cdk-visually-hidden cdk-focus-trap-anchor"
aria-hidden="true"
></div>
<!-- component content -->
<mat-bottom-sheet-container
aria-modal="true"
class="mat-bottom-sheet-container ng-tns-c23-3 ng-trigger ng-trigger-state ng-star-inserted mat-bottom-sheet-container-medium"
role="dialog"
tabindex="-1"
style="transform: translateY(0%);"
>
<bottom-sheet-overview-example-sheet>
</bottom-sheet-overview-example-sheet>
</mat-bottom-sheet-container>
<div
tabindex="0"
class="cdk-visually-hidden cdk-focus-trap-anchor"
aria-hidden="true"
></div>
</div>
</div>
</div>
我们继续。
该模块代码的目录(局部)如下:
.
├── BUILD.bazel
├── _overlay.scss
├── fullscreen-overlay-container.ts
├── index.ts
├── keyboard // 处理键盘事件
├── overlay-config.ts
├── overlay-container.ts
├── overlay-directives.ts
├── overlay-module.ts
├── overlay-prebuilt.scss
├── overlay-ref.ts
├── overlay-reference.ts
├── overlay.ts
├── position // 处理浮层定位
├── public-api.ts
├── scroll // 处理文档的滚动
OverlayContainer 在 body 元素的最后创建了一个元素,用于包裹全部的浮层元素。之后我们会称该元素为 container element。
<div class="cdk-overlay-container"></div>
该元素会在 getContainerElement
方法第一次被调用的时候创建(惰性实例化)。
注意这个服务是全局的。
Overlay 是一个服务,通过它的 create 方法可以创建一个新的浮层,这个过程中主要做了以下几件事:
PortalOutlet
的挂载点,而这里 PortalOutlet
的类型就是我在之前一篇文章中讲过的 DomPortalOutlet
,它将会被用来挂在组件内容。OverlayConfig
对象,OverlayConfig
的构造方法仅仅是把 plain object 上面的非 undefined
属性转移到新创建的 OverlayConfig
对象上。OverlayRef
并返回,值得注意的是第一步中创建的 PortalOutlet
会被传递给 OverlayRef
的构造方法。OverlayRef
类非常重要,它负责了浮层机制的绝大部分逻辑,并且是暴露给组件开发者操纵浮层的接口对象。
OverlayRef 的构造方法确定了该浮层的 scroll strategy 和 position strategy,这部分我们之后来谈。
组件开发者在新创建的浮层上添加组件时,应该调用 OverlayRef 的 attach 方法,参数应该是一个 Portal
对象。这个方法做了如下几件事情:
Portal
attach 到 DomPortalOutlet
上,这一步会动态创建组件开发者定义的内容KeyboardDispatcher
中(之后再讲)OverlayRef 类还有以下几个重要的方法:
篇幅所限,这里就不带读者们阅读了。
attach
方法的第二步是启用 position strategy,这里我们先来讲解比较简单的 GlobalPositionStrategy
,也是 BottomSheet 组件所使用的。
position strategy 就是定位策略,提供了一组定位浮层内元素的方法。
GlobalPositionStrategy 实现了 PositionStrategy 接口,用户也可以通过实现该接口自定义一个 position strategy。
attach 方法在浮层启用 position strategy 时被调用。对于 GlobalPositionStrategy
而言,主要是对 host element 增加了 cdk-global-overlay-wrapper
CSS 类。
.cdk-global-overlay-wrapper {
display: flex;
position: absolute;
z-index: 1000;
pointer-events: none;
top: 0;
left: 0;
height: 100%;
width: 100%;
}
apply 方法在需要调整浮层元素位置时被调用。该方法通过修改 host element 和 pane element 的样式来控制浮层元素的位置。
还有如下方法比较重要:
dispose,在 position strategy 被销毁(比如跟随浮层被销毁,或者浮层切换了 position strategy)的时候做回复操作。
其他方法都是暴露出来修改定位的,这里就不 cover 了。
有些浮层需要有一个后置的全屏图层,来凸显浮层内容,同时作为 MouseEvent 的 target,支持“点击浮层外关闭”这样的功能。
Backdrop 由 _attachBackdrop
方法所创建,实质上是创建了这样一个元素
<div class="cdk-overlay-backdrop cdk-overlay-backdrop-showing"></div>
并把它插入到 host element 之前,保持图层叠加的顺序。
同时在浮层上绑定了一个 click 事件的 handler,通过此 handle 派发 _backdropClick
事件。
KeyboardDispatcher 负责将键盘事件分派给最近打开的浮层。
之前讲到浮层 attach 的时候会调用 KeyboardDispatcher 的 add 方法,该方法会将调用此方法的 overlay 注册在 _attachedOverlays
数组的最后,且会第一个 overlay 注册的时候在 document 上绑定 keydown 事件的 handler,而该 handler 会从数组尾部开始逆序查找监听了 keydown 事件的 overlay,并对它派发 keydown 事件。
KeyboardDispatcher
使得最近一个打开的 overlay 才能监听键盘事件,一种常见的使用场景就是支持按 esc 键时有序地关闭 overlay。
scroll strategy 确定了在浮层展开时,原文档应当如何滚动。任意的 scroll strategy 都需要实现 ScrollStrategy 接口。
我们以 CDK 提供的 CloseScrollStrategy 为例,这种 strategy 会在页面内容滚动时关闭浮层。
在 OverlayRef
初始化时会调用 attach 方法,而 Overlay 的 attach 方法会调用 enable 方法,这个方法会监听全局滚动事件,并根据滚动范围和设置的门限调用 _detach 方法,最终是调用 OverlayRef
的 detach 方法卸载浮层内容。
下面以 BottomSheet 组件为例,看一下 overlay 是如何使用的。
用户用 open 方法创建一个新的 BottomSheet 组件,这个方法会通过 _createOverlay 创建一个新的浮层,该方法的全部代码如下:
/**
* Creates a new overlay and places it in the correct location.
* @param config The user-specified bottom sheet config.
*/
private _createOverlay(config: MatBottomSheetConfig): OverlayRef {
const overlayConfig = new OverlayConfig({
direction: config.direction,
hasBackdrop: config.hasBackdrop,
disposeOnNavigation: config.closeOnNavigation,
maxWidth: '100%',
scrollStrategy: config.scrollStrategy || this._overlay.scrollStrategies.block(),
positionStrategy: this._overlay.position().global().centerHorizontally().bottom('0')
});
if (config.backdropClass) {
overlayConfig.backdropClass = config.backdropClass;
}
return this._overlay.create(overlayConfig);
}
可以看到默认使用的是 BlockScrollStrategy 和 GlobalPositionStrategy。
实际上是通过工厂类 OverlayPositionBuilder 和 ScrollStrategyOptions 创建的。
个人觉得这不是个好设计,会导致没用到的 Strategy 没法被 tree shake 掉。
然后 _attachContainer 方法就会将 BottomSheep 组件内容 attach 到 portal 上了。
const containerRef: ComponentRef<MatBottomSheetContainer> = overlayRef.attach(
containerPortal
)
slate 是一款流行的富文本编辑器——不,与其说它是一款编辑器,不如说它是一个编辑器框架,在这个框架上,开发者可以通过插件的形式提供丰富的富文本编辑功能。slate 比较知名的用户(包括前用户)有 GitBook 和语雀,具体可以查看官网的 products 页面。
所谓“工欲善其事,必先利其器”,想要在项目中用好 slate,掌握其原理是一种事半功倍的做法。对于开发编辑器的同学来说,slate 的架构和技术选型也有不少值得学习的地方。这篇文章将会从以下几个方面探讨 slate:
slate 作为一个编辑器框架,分层设计非常明显。slate 仓库下包含四个 package:
先来看 slate package,这一部分是 slate 的核心,定义了编辑器的数据模型、操作这些模型的基本操作、以及创建编辑器实例对象的方法。
slate 以树形结构来表示和存储文档内容,树的节点类型为 Node
,分为三种子类型:
export type Node = Editor | Element | Text
export interface Element {
children: Node[]
[key: string]: unknown
}
export interface Text {
text: string
[key: string]: unknown
}
Element
类型含有 children
属性,可以作为其他 Node
的父节点
Editor
可以看作是一种特殊的 Element
,它既是编辑器实例类型,也是文档树的根节点
Text
类型是树的叶子结点,包含文字信息
用户可以自行拓展 Node
的属性,例如通过添加 type
字段标识 Node
的类型(paragraph, ordered list, heading 等等),或者是文本的属性(italic, bold 等等),来描述富文本中的文字和段落。
我们可以通过官方的 richtext demo 来直观地感受一下 slate model 的结构。
在本地运行 slate,通过 React Dev Tool 找到 Slate
标签,参数中的 editor
就是编辑器实例,右键选择它,然后点击 store as global variable,就可以在 console 中 inspect 这个对象了。
可以看到它的 children
属性中有四个 Element
并通过 type
属性标明了类型,对应编辑器中的四个段落。第一个 paragraph 的 children
中有 7 个 Text
,Text
用 bold
italic
这些属性描述它们的文字式样,对应普通、粗体、斜体和行内代码样式的文字。
那么为什么 slate 要采用树结构来描述文档内容呢?采用树形结构描述 model 有这样一些好处:
Node
和 DOM tree Element 存在映射关系,这样在处理用户操作的时候,能够很快地从 element 映射到 Node
用树形结构当然也有一些问题:
有了 model,还需要在 model 中定位的方法,即选区(selection),slate 的选区采用的是 Path
加 offset 的设计。
Path
是一个数字类型的数组 number[]
,它代表的是一个 Node
和它的祖先节点,在各自的上一级祖先节点的 children
数组中的 index。
export type Path = number[]
offset 则是对于 Text 类型的节点而言,代表光标在文本串中的 index 位置。
Path
加上 offet 即构成了 Point
类型,即可表示 model 中的一个位置。
export interface Point {
path: Path
offset: number
}
两个 Point
类型即可组合为一个 Range
,表示选区。
export interface Range {
anchor: Point // 选区开始的位置
focus: Point // 选区结束的位置
}
比如我这样选中一段文本(我这里是从后向前选择的):
通过访问 editor
的 selection
属性来查看当前的选区位置:
可见,选择的起始位置 focus
在第一段的最后一个文字处,且由于第一段中 "bold" 被加粗,所以实际上有 3 个 Text
的节点,因此 anchor
的 path
即为 [1, 2]
,offset
为光标位置在第三个 Text
节点中的偏移量 82。
有了 model 和对 model 中位置的描述,接下来的问题就是如何对 model 进行变更(mutation)了。编辑器实例提供了一系列方法(由 Editor
interface 所声明),如 insertNode
insertText
等,直接供外部模块变更 model,那么 slate 内部是如何实现这些方法的呢?
在阅读源代码的过程中,了解到这一点可能会对你有帮助:slate 在最近的一次重构中完全去除了类(class),所有数据结构和工具方法都是由同名的接口和对象来实现的,比如 Editor
:
export interface Editor {
children: Node[]
// ...其他一些属性
}
export const Editor = {
/**
* Get the ancestor above a location in the document.
*/
above<T extends Ancestor>(
editor: Editor,
options: {
at?: Location
match?: NodeMatch<T>
mode?: 'highest' | 'lowest'
voids?: boolean
} = {}
): NodeEntry<T> | undefined {
// ...
}
},
}
interface Editor
为编辑器实例所需要实现的接口,而对象 Editor
则封装了操作 interface Editor
的一些方法。所以,在查看 Editor
的实例 editor
的方法时,要注意方法实际上定义在 create-editor.ts 文件中。这可能是第一次阅读 slate 代码时最容易感到混淆的地方。
通常来说,对 model 进行的变更应当是原子化(atomic)的,这就是说,应当存在一个独立的数据结构去描述对 model 发生的变更,这些描述通常包括变更的类型(type)、路径(path)和内容(payload),例如新增的文字、修改的属性等等。原子化的变更方便做 undo/redo,也方便做协同编辑(当然需要对冲突的变更做转换,其中一种方法就是有名的 operation transform, OT)。
slate 也是这么处理的,它对 model 进行变更的过程主要分为以下两步,第二步又分为四个子步骤:
Transforms
提供的一系列方法生成 Operation
Operation
进入 apply 流程
Operation
进行 transform首先,通过 Transforms
所提供的一系列方法生成 Operation
,这些方法大致分成四种类型:
export const Transforms = {
...GeneralTransforms,
...NodeTransforms,
...SelectionTransforms,
...TextTransforms,
}
NodeTransforms
:对 Node
的操作方法SelectionTransforms
:对选区的操作方法TextTransforms
:对文本操作方法特殊的是 GeneralTransforms
,它并不生成 Operation
而是对 Operation
进行处理,只有它能直接修改 model,其他 transforms 最终都会转换成 GeneralTransforms
中的一种。
这些最基本的方法,也即是 Operation
类型仅有 9 个:
insert_node
:插入一个 Nodeinsert_text
:插入一段文本merge_node
:将两个 Node 组合成一个move_node
:移动 Noderemove_node
:移除 Noderemove_text
:移除文本set_node
:设置 Node 属性set_selection
:设置选区位置split_node
:拆分 Node我们以 Transforms.insertText
为例(略过一些对光标位置的处理):
export const TextTransforms = {
insertText(
editor: Editor,
text: string,
options: {
at?: Location
voids?: boolean
} = {}
) {
Editor.withoutNormalizing(editor, () => {
// 对选区和 voids 类型的处理
const { path, offset } = at
editor.apply({ type: 'insert_text', path, offset, text })
})
},
}
可见 Transforms
的最后生成了一个 type
为 insert_text
的 Operation
并调用 Editor
实例的 apply
方法。
apply
内容如下:
apply: (op: Operation) => {
// 转换坐标
for (const ref of Editor.pathRefs(editor)) {
PathRef.transform(ref, op)
}
for (const ref of Editor.pointRefs(editor)) {
PointRef.transform(ref, op)
}
for (const ref of Editor.rangeRefs(editor)) {
RangeRef.transform(ref, op)
}
// 执行变更
Transforms.transform(editor, op)
// 记录 operation
editor.operations.push(op)
// 进行校验
Editor.normalize(editor)
// Clear any formats applied to the cursor if the selection changes.
if (op.type === 'set_selection') {
editor.marks = null
}
if (!FLUSHING.get(editor)) {
// 标示需要清空 operations
FLUSHING.set(editor, true)
Promise.resolve().then(() => {
// 清空完毕
FLUSHING.set(editor, false)
// 通知变更
editor.onChange()
// 移除 operations
editor.operations = []
})
}
},
其中 Transforms.transform(editor, op)
就是在调用 GeneralTransforms
处理 Operation
。transform
方法的主体是一个 case 语句,根据 Operatoin
的 type
分别应用不同的处理,例如对于 insertText
,其逻辑为:
const { path, offset, text } = op
const node = Node.leaf(editor, path)
const before = node.text.slice(0, offset)
const after = node.text.slice(offset)
node.text = before + text + after
if (selection) {
for (const [point, key] of Range.points(selection)) {
selection[key] = Point.transform(point, op)!
}
}
break
可以看到,这里的代码会直接操作 model,即修改 editor.children
和 editor.selection
属性。
slate 使用了 immer 来应用 immutable data,即
createDraft
finishDrag
成对的调用。使用 immer 可以将创建数据的开销减少到最低,同时又能使用 JavaScript 原生的 API 和赋值语法。
对 model 进行变更之后还需要对 model 的合法性进行校验,避免内容出错。校验的机制有两个重点,一是对脏区域的管理,一个是 withoutNormalizing
机制。
许多 transform 在执行前都需要先调用 withoutNormalizing
方法判断是否需要进行合法性校验:
export const Editor = {
// ...
withoutNormalizing(editor: Editor, fn: () => void): void {
const value = Editor.isNormalizing(editor)
NORMALIZING.set(editor, false)
fn()
NORMALIZING.set(editor, value)
Editor.normalize(editor)
}
}
可以看到这段代码通过栈帧(stack frame)保存了是否需要合法性校验的状态,保证 transform 运行前后是否需要合法性校验的状态是一致的。transform 可能调用别的 transform,不做这样的处理很容易导致冗余的合法性校验。
合法性校验的入口是 normalize
方法,它创建一个循环,从 model 树的叶节点自底向上地不断获取脏路径并调用 nomalizeNode
检验路径所对应的节点是否合法。
while (getDirtyPaths(editor).length !== 0) {
// 对校验次数做限制的 hack
const path = getDirtyPaths(editor).pop()!
const entry = Editor.node(editor, path)
editor.normalizeNode(entry)
m++
}
让我们先来看看脏路径是如何生成的(省略了不相关的部分),这一步发生在 Transforms.transform(editor, op)
之前:
apply: (op: Operation) => {
// 脏区记录
const set = new Set()
const dirtyPaths: Path[] = []
const add = (path: Path | null) => {
if (path) {
const key = path.join(',')
if (!set.has(key)) {
set.add(key)
dirtyPaths.push(path)
}
}
}
const oldDirtyPaths = DIRTY_PATHS.get(editor) || []
const newDirtyPaths = getDirtyPaths(op)
for (const path of oldDirtyPaths) {
const newPath = Path.transform(path, op)
add(newPath)
}
for (const path of newDirtyPaths) {
add(path)
}
DIRTY_PATHS.set(editor, dirtyPaths)
},
dirtyPaths
一共有以下两种生成机制:
oldDirtypath
,这一部分根据 operation 的类型做路径转换处理getDirthPaths
方法获取normalizeNode
方法会对 Node
进行合法性校验,slate 默认有以下校验规则:
Elmenet
节点,需要给它插入一个 voids
类型节点Element
节点进行校验
合法性变更之后,就是调用 onChange
方法。这个方法 slate package 中定义的是一个空函数,实际上是为插件准备的一个“model 已经变更”的回调。
到这里,对 slate model 的介绍就告一段落了。
在进一步学习其他 package 之前,我们先要学习一下 slate 的插件机制以了解各个 package 和如何与核心 package 合作的。
上一节提到的判断一个节点是否为行内节点的 isInline
方法,以及 normalizeNode
方法本身都是可以被扩展,不仅如此,另外三个 package 包括 undo/redo 功能和渲染层均是以插件的形式工作的。看起来 slate 的插件机制非常强大,但它有一个非常简单的实现:覆写编辑器实例 editor 上的方法。
slate-react 提供的 withReact
方法给我们做了一个很好的示范:
export const withReact = <T extends Editor>(editor: T) => {
const e = editor as T & ReactEditor
const { apply, onChange } = e
e.apply = (op: Operation) => {
// ...
apply(op)
}
e.onChange = () => {
// ...
onChange()
}
}
用 withReact
修饰编辑器实例,直接覆盖实例上原本的 apply
和 change
方法。~~换句话说,slate 的插件机制就是没有插件机制!~~这难道就是传说中的无招胜有招?
学习了插件机制,我们再来看 undo/redo 的功能,它由 slate-history package 所实现。
实现 undo/redo 的机制一般来说有两种。第一种是存储各个时刻(例如发生变更前后)model 的快照(snapshot),在撤销操作的时候恢复到之前的快照,这种机制看起来简单,但是较为消耗内存(有 n 步操作我们就需要存储 n+1 份数据!),而且会使得协同编辑实现起来非常困难(比较两个树之间的差别的时间复杂度是 O(n^3),更不要提还有网络传输的开销)。第二种是记录变更的应用记录,在撤销操作的时候取要撤销操作的反操作,这种机制复杂一些——主要是要进行各种选区计算——但是方便做协同,且不会占用较多的内存空间。slate 即基于第二种方法进行实现。
在 withHistory
方法中,slate-history 在 editor 上创建了两个数组用来存储历史操作:
e.history = { undos: [], redos: [] }
它们的类型都是 Operation[][]
,即 Operation
的二维数组,其中的每一项代表了一批操作(在代码上称作 batch), batch 可含有多个 Operation
。
我们可以通过 console 看到这一结构:
slate-history 通过覆写 apply
方法来在 Operation
的 apply 流程之前插入 undo/redo 的相关逻辑,这些逻辑主要包括:
Operation
,诸如改变选区位置等操作是不需要 undo 的Operation
是否需要和前一个 batch 合并,或覆盖前一个 batchundos
队列,或者插入到上一个 batch 的尾部,同时计算是否超过最大撤销步数,超过则去除首部的 batchapply
方法e.apply = (op: Operation) => {
const { operations, history } = e
const { undos } = history
const lastBatch = undos[undos.length - 1]
const lastOp = lastBatch && lastBatch[lastBatch.length - 1]
const overwrite = shouldOverwrite(op, lastOp)
let save = HistoryEditor.isSaving(e)
let merge = HistoryEditor.isMerging(e)
// 判断是否需要存储该 operation
if (save == null) {
save = shouldSave(op, lastOp)
}
if (save) {
// 判断是否需要和上一个 batch 合并
// ...
if (lastBatch && merge) {
if (overwrite) {
lastBatch.pop()
}
lastBatch.push(op)
} else {
const batch = [op]
undos.push(batch)
}
// 最大撤销 100 步
while (undos.length > 100) {
undos.shift()
}
if (shouldClear(op)) {
history.redos = []
}
}
apply(op)
}
slate-history 还在 editor 实例上赋值了 undo
方法,用于撤销上一组操作:
e.undo = () => {
const { history } = e
const { undos } = history
if (undos.length > 0) {
const batch = undos[undos.length - 1]
HistoryEditor.withoutSaving(e, () => {
Editor.withoutNormalizing(e, () => {
const inverseOps = batch.map(Operation.inverse).reverse()
for (const op of inverseOps) {
// If the final operation is deselecting the editor, skip it. This is
if (
op === inverseOps[inverseOps.length - 1] &&
op.type === 'set_selection' &&
op.newProperties == null
) {
continue
} else {
e.apply(op)
}
}
})
})
history.redos.push(batch)
history.undos.pop()
}
}
这个算法的主要部分就是对最后一个 batch 中所有的 Operation
取反操作然后一一 apply,再将这个 batch push 到 redos
数组中。
redo 方法就更简单了,这里不再赘述。
最后我们来探究渲染和交互层,即 slate-react package。
我们最关注的问题当然是 model 是如何转换成视图层(view)的。经过之前的学习我们已经了解到 slate 的 model 本身就是树形结构,因此只需要递归地去遍历这棵树,同时渲染就可以了。基于 React,这样的递归渲染用几个组件就能够很容易地做到,这几个组件分别是 Editable
Children
Element
Leaf
String
和 Text
。在这里举几个例子:
Children
组件用来渲染 model 中类行为 Editor
和 Element
Node
的 children
,比如最顶层的 Editable
组件就会渲染 Editor
的 children
:
注意下面的 node
参数即为编辑器实例 Editor
:
export const Editable = (props: EditableProps) => {
return <Wrapped>
<Children
decorate={decorate}
decorations={decorations}
node={editor}
renderElement={renderElement}
renderLeaf={renderLeaf}
selection={editor.selection}
/>
</Wrapped>
}
Children
组件会根据 children
中各个 Node
的类型,生成对应的 ElementComponent
或者 TextComponent
:
const Children = (props) => {
const {
node,
renderElement,
renderLeaf,
} = props
for (let i = 0; i < node.children.length; i++) {
const p = path.concat(i)
const n = node.children[i] as Descendant
if (Element.isElement(n)) {
children.push(
<ElementComponent
element={n}
renderElement={renderElement}
renderLeaf={renderLeaf}
/>
)
} else {
children.push(
<TextComponent
renderLeaf={renderLeaf}
text={n}
/>
)
}
}
return <React.Fragment>{children}</React.Fragment>
}
ElementComponent
渲染一个 Element
元素,并用 Children
组件渲染其 children
:
const Element = (props) => {
let children: JSX.Element | null = (
<Children
decorate={decorate}
decorations={decorations}
node={element}
renderElement={renderElement}
renderLeaf={renderLeaf}
selection={selection}
/>
)
return (
<SelectedContext.Provider value={!!selection}>
{renderElement({ attributes, children, element })}
</SelectedContext.Provider>
)
}
// renderElement 的默认值
export const DefaultElement = (props: RenderElementProps) => {
const { attributes, children, element } = props
const editor = useEditor()
const Tag = editor.isInline(element) ? 'span' : 'div'
return (
<Tag {...attributes} style={{ position: 'relative' }}>
{children}
</Tag>
)
}
Leaf
等组件的渲染也是同理,这里不再赘述。
下图表示了从 model tree 到 React element 的映射,可见用树形结构来组织 model 能够很方便地渲染,且在 Node
和 HTML element 之间建立映射关系(具体可查看 toSlateNode
和 toSlateRange
等方法和 ELEMENT_TO_NODE
NODE_TO_ELEMENT
等数据结构),这在处理光标和选择事件时将会特别方便。
slate-react 还用了 React.memo
来优化渲染性能,这里不赘述。
在上面探究 slate-react 的渲染机制的过程中,我们发现有两个比较特殊的参数 renderElement
和 renderLeaf
,它们从最顶层的 Editable
组件开始就作为参数,一直传递到最底层的 Leaf
组件,并且还会被 Element
等组件在渲染时调用,它们是什么?
实际上,这就是 slate-react 自定义渲染的 API,用户可以通过提供这两个参数来自行决定如何渲染 model 中的一个 Node
,例如 richtext demo 中:
const Element = ({ attributes, children, element }) => {
switch (element.type) {
case 'block-quote':
return <blockquote {...attributes}>{children}</blockquote>
case 'bulleted-list':
return <ul {...attributes}>{children}</ul>
case 'heading-one':
return <h1 {...attributes}>{children}</h1>
case 'heading-two':
return <h2 {...attributes}>{children}</h2>
case 'list-item':
return <li {...attributes}>{children}</li>
case 'numbered-list':
return <ol {...attributes}>{children}</ol>
default:
return <p {...attributes}>{children}</p>
}
}
我们先前提到 slate 允许 Node
有自定义属性,这个 demo 就拓展了 Element
节点的 type
属性,让 Element
能够渲染为不同的标签。
slate 没有自行实现光标和选区,而使用了浏览器 contenteditable
的能力(同时也埋下了隐患,我们会在总结部分介绍)。
在 Editable
组件中,可看到对 Component
元素增加了 contenteditable attribute:
export const Editable = (props: EditableProps) => {
return <Wrapped>
<Copmonent
contentEditable={readOnly ? undefined : true}
suppressContentEditableWarning
>
</Copmonent>
</Wrapped>
}
// Component 默认为 'div'
从这里开始,contenteditable 就负责了光标和选区的渲染和事件。slate-react 会在每次渲染的时候将 model 中的选区同步到 DOM 上:
export const Editable = (props: EditableProps) => {
// ...
useIsomorphicLayoutEffect(() => {
// ...
domSelection.setBaseAndExtent(
newDomRange.startContainer,
newDomRange.startOffset,
newDomRange.endContainer,
newDomRange.endOffset
)
})
}
也会在 DOM 发生选区事件的时候同步到 model 当中:
const onDOMSelectionChange = useCallback(
throttle(() => {
if (!readOnly && !state.isComposing && !state.isUpdatingSelection) {
// ...
if (anchorNodeSelectable && focusNodeSelectable) {
const range = ReactEditor.toSlateRange(editor, domSelection) // 这里即发生了一次 DOM element 到 model Node 的转换
Transforms.select(editor, range)
} else {
Transforms.deselect(editor)
}
}
}, 100),
[readOnly]
)
选区同步的方法这里就不介绍了,大家可以通过查阅源码自行学习。
Editable
组件创建了一个 onDOMBeforeInput
函数,用以处理 beforeInput
事件,根据事件的 type
调用不同的方法来修改 model。
// ...
switch (type) {
case 'deleteByComposition':
case 'deleteByCut':
case 'deleteByDrag': {
Editor.deleteFragment(editor)
break
}
case 'deleteContent':
case 'deleteContentForward': {
Editor.deleteForward(editor)
break
}
// ...
}
// ...
beforeInput
事件和 input
事件的区别就是触发的时机不同。前者在值改变之前触发,还能通过调用 preventDefault
来阻止浏览器的默认行为。
slate 对快捷键的处理也很简单,通过在 div 上绑定 keydown 事件的 handler,然后根据不同的组合键调用不同的方法。slate-react 也提供了自定义这些 handler 的接口,Editable
默认的 handler 会检测用户提供的 handler 有没有将该 keydown 事件标记为 defaultPrevented
,没有才执行默认的事件处理逻辑:
if (
!readOnly &&
hasEditableTarget(editor, event.target) &&
!isEventHandled(event, attributes.onKeyDown)
) {
// 根据不同的组合键调用不同的方法
}
slate 在渲染的时候会向 EDITOR_TO_ON_CHANGE
中添加一个回调函数,这个函数会让 key
的值加 1,触发 React 重新渲染。
export const Slate = (props: {
editor: ReactEditor
value: Node[]
children: React.ReactNode
onChange: (value: Node[]) => void
[key: string]: unknown
}) => {
const { editor, children, onChange, value, ...rest } = props
const [key, setKey] = useState(0)
const onContextChange = useCallback(() => {
onChange(editor.children)
setKey(key + 1)
}, [key, onChange])
EDITOR_TO_ON_CHANGE.set(editor, onContextChange)
useEffect(() => {
return () => {
EDITOR_TO_ON_CHANGE.set(editor, () => {})
}
}, [])
}
而这个回调函数由谁来调用呢?可以看到 withReact
对于 onChange
的覆写:
e.onChange = () => {
ReactDOM.unstable_batchedUpdates(() => {
const onContextChange = EDITOR_TO_ON_CHANGE.get(e)
if (onContextChange) {
onContextChange()
}
onChange()
})
}
在 model 变更的结束阶段,从 EDITOR_TO_ON_CHANGE
里拿到回调并调用,这样就实现 model 更新,触发 React 重渲染了。
这篇文章分析了 slate 的架构设计和对一些关键问题的处理,包括:
等等。
至此,我们可以发现 slate 存在着这样几个主要的问题:
没有自行实现排版。slate 借助了 DOM 的排版能力,这样就使得 slate 只能呈现流式布局的文档,不能实现页眉页脚、图文混排等高级排版功能。
使用了 contenteditable 导致无法处理部分选区和输入事件。使用 contenteditable 后虽然不需要开发者去处理光标的渲染和选择事件,但是造成了另外一个问题:破坏了从 model 到 view 的单向数据流,这在使用输入法(IME)的时候会导致崩溃这样严重的错误。
我们在 React 更新渲染之前打断点,然后全选文本,输入任意内容。可以看到,在没有输入法的状态下,更新之前 DOM element 并没有被移除。
但是在有输入法的情况下,contenteditable 会将光标所选位置的 DOM element 先行清除,此时 React 中却还有对应的 Fiber Node,这样更新之后,React 就会发现需要卸载的 Fiber 所对应的 DOM element 已经不属于其父 element,从而报错。并且这一事件不能被 prevent default,所以单向数据流一定会被打破。
React 相关的 issue 从 2015 年起就挂在那里了。slate 官方对 IME 相关的问题的积极性也不高。
对于协同编辑的支持仅停留在理论可行性上。slate 使用了 Operation
,这使得协同编辑存在理论上的可能,但是对于协同编辑至关重要的 operation transform 方案(即如何处理两个有冲突的编辑操作),则没有提供实现。
总的来说,slate 是一个拥有良好扩展性的轻量富文本编辑器(框架?),很适合 CMS、社交媒体这种不需要复杂排版和实时协作的简单富文本编辑场景。
希望这篇文章能够帮助大家对 slate 形成一个整体的认知,并从其技术方案中了解它的优点和局限性,从而更加得心应手地使用 slate。
参与开源项目对个人的好处是显而易见的:进一步熟悉你所使用的库、和有经验的开发者进行交流、提高代码水平和社区影响力,甚至是找到新的工作机会。特别是对于没有实习经验的在校大学生来说,参与开源项目是体现技术热情和积累实际开发经验的一个好方法,能让你在简历筛选和面试环节得到面试官的青睐。这篇文章将会和大家分享如何参与大型前端开源项目的开发。你将会看到,开源的门槛其实并不高,只要有意愿,并掌握了最基本的流程,任何人都可以给大型前端开源项目贡献代码。
为了方便你理解这篇文章的内容,我会以我最近给西湖区最大的 React 组件库 Ant Design 提交的一段代码为例来讲解流程,这段代码是为了满足用户的一个功能需求,即通过唯一的 key 来修改消息框(Message)的内容。
绝大多数的前端开源项目都托管在 GitHub 上,开发和维护也在 GitHub 上进行,开源项目的用户(开发者)们会将他们找到的(疑似) bug 或是功能请求提交到这些项目的 issues 中,给大型前端开源项目贡献代码的第一步就从这里开始,即从中找到你感兴趣的 issue。
如果你还不了解 GitHub 及它的基本使用,请参考我朋友的知乎回答:《如何使用 GitHub》。
比如 Ant Design 的 issues 中,你可以看到许多这样的 bug 反馈和功能请求。
Tips:如何找到适合自己的项目和 issue,哪些 issue 比较容易上手?
对于新手来说,最好找这样的一些项目:
以及这样的 issue:
如果你是第一次向大型开源项目提交代码,要记得所解决的问题的难度并不重要,重要的是走一遍贡献代码的流程,了解开源社区是如何协作的。
找到了想要解决的 issue 之后,在 issue 下面留言说你想要负责这个 issue,一般项目的维护者都会把这个 issue 交给你。你可以看到我在原 issue 下的回复。
维护者们都巴不得有人来完(tian)善(keng)他们的项目呢,所以尽管留言吧!
好了,现在我们手头已经有了一个待解决的 issue,接下来我们需要了解这个项目是如何运作的。
阅读源码的能力是必须的,这里推荐阅读《如何阅读大型前端开源项目的代码》。
了解项目的第一步永远都是先把这个项目在本地运行起来。
如果你在阅读下面的内容的时候发现对一些概念一无所知,请立刻回头阅读《如何使用 GitHub》。
首先你要 fork 该项目,fork 意味者创建一份源仓库的拷贝,在贡献代码的时候,我们没有向源仓库推送(push)代码的权限,往往都需要先推送到自己的拷贝上,然后请求项目的维护者们合并我们的新代码,即发起 Pull Request。
你可以看到我的账户底下就有一份 ant-design 的 fork。
然后把 fork 后的代码 clone 到你的电脑。
通过 npm 或者 yarn 安装依赖。
一般通过查看 package.json 文件的 scripts 字段,就可以知道如何运行该项目,进行测试等等。对于 Ant Design,只需要运行 npm start
就可以。
下面我们就要解决 issue 中提出的功能请求了。
这一段会比较 Ant Design specific,对于其他开源项目不具有通用性,不感兴趣的读者们可以直接跳到下一章提交 PR。
我们先前提到 Notifcation 组件早已实现了此功能,先来看看它是如何实现的。
我们可以追溯到,当用户通过 Notification.open
方法创建一个 Notification 实例的时候,最终会调用到 getNotificationInstance
方法上(源码在此)。
getNotificationInstance(
{
prefixCls: outerPrefixCls,
placement,
top,
bottom,
getContainer,
},
(notification: any) => {
notification.notice({
content: (
<div className={iconNode ? `${prefixCls}-with-icon` : ''}>
{iconNode}
<div className={`${prefixCls}-message`}>
{autoMarginTag}
{args.message}
</div>
<div className={`${prefixCls}-description`}>{args.description}</div>
{args.btn ? <span className={`${prefixCls}-btn`}>{args.btn}</span> : null}
</div>
),
duration,
closable: true,
onClose: args.onClose,
onClick: args.onClick,
key: args.key,
style: args.style || {},
className: args.className,
});
},
);
}
可以看到 key
是第二个回调参数的一部分。那么在 Message 组件中有没有类似的代码呢?可以发现的确存在这样的代码(链接在此)!
function notice(args: ArgsProps): MessageType {
const duration = args.duration !== undefined ? args.duration : defaultDuration
const iconType = {
info: 'info-circle',
success: 'check-circle',
error: 'close-circle',
warning: 'exclamation-circle',
loading: 'loading',
}[args.type]
const target = key++
const closePromise = new Promise(resolve => {
const callback = () => {
if (typeof args.onClose === 'function') {
args.onClose()
}
return resolve(true)
}
getMessageInstance(instance => {
const iconNode = (
<Icon
type={iconType}
theme={iconType === 'loading' ? 'outlined' : 'filled'}
/>
)
const switchIconNode = iconType ? iconNode : ''
instance.notice({
key: target,
duration,
style: {},
content: (
<div
className={`${prefixCls}-custom-content${
args.type ? ` ${prefixCls}-${args.type}` : ''
}`}
>
{args.icon ? args.icon : switchIconNode}
<span>{args.content}</span>
</div>
),
onClose: callback,
})
})
})
const result: any = () => {
if (messageInstance) {
messageInstance.removeNotice(target)
}
}
result.then = (filled: ThenableArgument, rejected: ThenableArgument) =>
closePromise.then(filled, rejected)
result.promise = closePromise
return result
}
那我们是不是依样画葫芦,直接允许 key
使用用户传递进来的值呢?试了一下果然可以!
- key: target,
+ key: args.key || target,
这功能实现起来就非常简单。我们接着修改一下测试,添加一下 demo 即可。
以下是对该功能的测试代码(链接在此):
it('should support update message content with a unique key', () => {
const key = 'updatable'
class Test extends React.Component {
componentDidMount() {
message.loading({ content: 'Loading...', key })
// Testing that content of the message should be updated.
setTimeout(() => message.success({ content: 'Loaded', key }), 1000)
setTimeout(() => message.destroy(), 3000)
}
render() {
return <div>test</div>
}
}
mount(<Test />)
expect(document.querySelectorAll('.ant-message-notice').length).toBe(1)
jest.advanceTimersByTime(1500)
expect(document.querySelectorAll('.ant-message-notice').length).toBe(1)
jest.runAllTimers()
expect(document.querySelectorAll('.ant-message-notice').length).toBe(0)
})
开源项目特别重视测试,还会有覆盖率检查工具来检查是否有代码未被测试覆盖,当你修复了一个 bug 或者新增了一个功能的时候,记得一定要写测试!
再改改文档,说明我们增加了这样的一个功能,就可以发起 PR 啦。
发起 PR(Pull Request)时,需要 commit 代码,然后 push 到你 fork 的仓库。
如果你同时给一个项目解决好几个 issue,你应该从 master 分支 checkout 出多个分支,然后分别在这些分支上解决 issue。更好的实践是永远 checkout 一个新分支来解决 issue,不要向 master 分支提交任何代码。
提交 PR 之前,你并不需要确定已经做到尽善尽美了,肯定有一些东西要和项目维护者们进行讨论。
之后在 GitHub 上发起 PR,当你在 fork 的仓库推送了分支时,GitHub 会很聪明地询问你是否要发起 PR,点击绿色的小按钮之后,填写 PR 模板即可发起 PR。
“烦人”的维护者们是不会轻易让你的代码进入他们的主分支的!你必须接受维护者们的代码 review,有时候他们会要求你做出一些修改。
比如 afc163 认为加入 key 后参数数量过多,要求实现以对象的形式传参。
当然他们也是为了保证代码的质量而非存心跟你过不去,而且和维护者们的互动有助于你写出更鲁棒的代码和更精炼的 API 哦。
这种 review 和修改和互动可能会有很多轮。如果维护者们对你的 PR 和修改没有做出回应,你可以主动 @ 他们。
当维护者们对所有事情都表示满意,approve 了你的 PR,你就可以坐等代码被合并到 master 并成为该项目的 contributor 啦。
之后你参与该项目的讨论时就会有个 Contributor 小徽章时刻提醒那些小白你是多么的牛逼(逃
fetch
pull
add
commit
push
merge
rebase
。Happy coding :)
在之前的一次对轮播图 (carousel) 组件的重构中,我们将实现切换效果的代码和其他代码分离,这不仅使得组件更容易维护,而且允许用户为轮播图添加各种各样的切换效果 (strategy)。这篇文章将会以一个翻转 (flip) 效果为例,介绍如何使用该进阶功能。
注:需要 ng-zorro-antd 7.5.0 及以上版本。
你可以在这里预览最终效果。
并且在这里找到源代码。
要实现并使用自定义的切换效果,需要如下步骤:
NzCarouselBaseStrategy
的类,实现切换效果NZ_CAROUSEL_CUSTOM_STRATEGIES
提供自定义切换效果nz-carousel
的 nzEffect
input 中指定使用自定义效果FlipStrategy
要实现一个切换效果,只需要继承并实现 ng-zorro-antd 提供的 NzCarouselBaseStrategy
抽象类即可。主要是实现以下三个方法:
withCarouselContents
该方法会在轮播图组件初次渲染、轮播图内容改变,或者是切换效果的时候被调用,切换效果应该在这个方法里进行初始化设置下面来看一下 FlipStrategy
的具体实现吧。
withCarouselContents
在这个方法被调用的最初,需要调用父类的同名方法,让父类准备好辅助属性。
super.withCarouselContents(contents)
NzCarouselBaseStrategy
提供了一些辅助属性和方法,能够帮助开发者更好地实现想要的切换效果。比如记录轮播图项目的宽度的unitWidth
,所有轮播图项目指令NzCarouselContentDirective
的contents
等。
然后,对轮播图组件中的各个项目的样式做初始化。对于翻转效果来说,所有的元素都应该重合在相同的位置,所以外层元素的宽度均为轮播图项目的单位宽度。
this.renderer.setStyle(this.slickListEl, 'width', `${this.unitWidth}px`)
this.renderer.setStyle(
this.slickTrackEl,
'width',
`${this.length * this.unitWidth}px`
)
接下来,对每个轮播图项目做处理。对于当前激活的项目而言,不需要做翻转,而其他项目均要做翻转。然后,设置每个项目的位置,并设置过渡效果。
this.contents.forEach((content: NzCarouselContentDirective, i: number) => {
const cur = this.carouselComponent.activeIndex === i
this.renderer.setStyle(
content.el,
'transform',
cur ? 'rotateY(0deg)' : 'rotateY(180deg)'
)
this.renderer.setStyle(content.el, 'position', 'relative')
this.renderer.setStyle(content.el, 'width', `${this.unitWidth}px`)
this.renderer.setStyle(content.el, 'left', `${-this.unitWidth * i}px`)
this.renderer.setStyle(content.el, 'transform-style', 'preserve-3d')
this.renderer.setStyle(content.el, 'backface-visibility', 'hidden')
})
这样,初始化就完成了。
switch
在切换项目的时候,轮播图组件会调用该方法,并订阅该方法返回的 Observable 对象,以便在动画完成的时候做后处理。所以,我们返回一个 Subject.asObservable()
并在动画效果结束后 next 和 complete。
const complete$ = new Subject<void>()
const speed = this.carouselComponent.nzTransitionSpeed
timer(speed).subscribe(() => {
complete$.next()
complete$.complete()
})
// ...
return complete$.asObservable()
然后就是执行动画的部分。先得到在范围内起始位置下标和终止位置下标。
const { from, to } = this.getFromToInBoundary(rawF, rawT)
switch(rawF: number, rawT: number): Observable<void>
参数里的起始位置和终止位置并不总是在 0 到录播图项目个数 -1 这个范围之内,因为从最后一项回到第一项时,有的切换效果可能需要特别的处理(比如 ng-zorro-antd 里自带的TransformStrategy
)。所以NzCarouselBaseStrategy
提供了getFromToInBoundary
方法来获取范围内的下标。
然后,翻转起始位置和终止位置的轮播图项目。
this.contents.forEach((content: NzCarouselContentDirective, i: number) => {
if (i === from) {
this.renderer.setStyle(content.el, 'transform', 'rotateY(180deg)')
} else if (i === to) {
this.renderer.setStyle(content.el, 'transform', 'rotateY(0deg)')
}
})
这样翻转效果就实现了,是不是非常简单?
dispose
这个方法相对简单,把过渡效果去掉即可。
dispose(): void {
this.contents.forEach((content: NzCarouselContentDirective) => {
this.renderer.setStyle(content.el, 'transition', null);
this.renderer.setStyle(content.el, 'transform-style', null);
this.renderer.setStyle(content.el, 'backface-visibility', null);
});
super.dispose();
}
在 app.module.ts 中,我们 provide 自定义的切换效果。
import { BrowserModule } from '@angular/platform-browser'
import { NgModule } from '@angular/core'
import { AppComponent } from './app.component'
import { NZ_CAROUSEL_CUSTOM_STRATEGIES, NzCarouselModule } from 'ng-zorro-antd'
import { FlipStrategy } from './flip-strategy'
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, NzCarouselModule],
providers: [
{
provide: NZ_CAROUSEL_CUSTOM_STRATEGIES,
useValue: [
{
name: 'flip',
strategy: FlipStrategy,
},
],
},
],
bootstrap: [AppComponent],
})
export class AppModule {}
最后只需要像平常那样使用轮播图组件,只不过指定效果的时候用之前 provide 的 name,像下面这样:
<nz-carousel [nzEffect]="'flip'" [nzDotRender]="dotTpl">
<div nz-carousel-content>A</div>
<div nz-carousel-content>B</div>
<div nz-carousel-content>C</div>
<div nz-carousel-content>D</div>
</nz-carousel>
就得到了一个效果为翻转的轮播图了。
如果实现了 NzCarouselBaseStrategy
的 dragging
方法,还可以支持拖拽。这个方法会传递一个 PointerVector
对象,开发者可以从这个对象获取光标在拖拽中在 x 和 y 两个方向上拖动的距离(像素单位)。
dragging(_vector: PointerVector): void {}
我们把实现拖拽当作留给大家的练习。
有 middleware 的情形后面再讨论,这里先讨论最简单的情形。
首先设置了初始状态,还有其他一些属性,通过闭包封装。
let currentReducer = reducer
let currentState = preloadedState
let currentListeners = []
let nextListeners = currentListeners
let isDispatching = false
另外这个作用域内声明了这些函数:
reducer
在执行就返回当前状态树。
dispatch
函数调用的时候会设置标志位 isDispatching
为 true
isPlainObject
方法确保传入的 action 是一个 PlainObject(原理是判断 obj
的原型链上只有 Object
),其他类型的 action 都交给中间件处理。currentReducer
来变更状态,变更后的状态会被赋值给 currentState
属性。最终被封装在对象上返回,就是我们通常说的 Store
对象。
return {
dispatch,
subscribe,
getState,
replaceReducer,
[$$observable]: observable
}
如果要使用中间件的话,在 createStore
的过程中,就会调用这一行代码:
// [A]
return enhancer(createStore)(reducer, preloadedState)
这里的 enhancer 实际上是 applyMiddleware
函数的返回值,接下来就看一看这个函数。
export default function applyMiddleware(...middlewares) {
return /* B */ createStore => /* C */ (...args) => {
const store = createStore(...args)
let dispatch = () => {
throw new Error(
'Dispatching while constructing your middleware is not allowed. ' +
'Other middleware would not be applied to this dispatch.'
)
}
const middlewareAPI = {
getState: store.getState,
dispatch: (...args) => dispatch(...args)
}
const chain = middlewares.map(middleware => middleware(middlewareAPI))
dispatch = compose(...chain)(store.dispatch)
return {
...store,
dispatch
}
}
}
redux-thunk 也算是官配了,而且它还非常简短,这里我们就拿它来举例子方便大家更好地理解:
function createThunkMiddleware(extraArgument) {
return
// [1]
({ dispatch, getState }) => /* [2] */ (next) => /* [3] */ (action) => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument);
}
return next(action);
};
}
const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;
export default thunk;
使用这个 middleware 的时候如下所示:
createStore(reducer, applyMiddleware(thunk));
我们现在开始仔细地分析调用过程:
applyMiddleware
函数接收多个 middleware,然后返回了一个函数 B
A
中第一次调用的时候,createStore
方法被传递给函数 B
,再次返回了一个函数 C
,C
再被 [A]
中的第二次调用所调用createStore
方法创建了一个真正的 Store
对象。getState
和 dispatch
这两个方法就被传给了函数 1
,返回的是函数 2
2
被 compose
函数所用,生成了一个新的 dispatch
方法 3
,注意,这个方法是用户调用的 dispatch
(它才是真身!)Store
对象,其实是个“套壳”的 Store
我们先讲解 compose
方法,然后再来讲解用户调用 dispatch
时会发生什么。
compose
函数负责将 dispatch 过程串起来。通过上面的分析我们已经知道了被送给 compose
方法的是形如这样的一组函数:
(next) => (action) => {
// ... 中间件对 action 进行处理
};
而 compose
的全部代码如下,它对 middleware 的数目分为三种情况来处理:
export default function compose(...funcs) {
if (funcs.length === 0) {
return arg => arg
}
if (funcs.length === 1) {
return funcs[0]
}
return funcs.reduce((a, b) => (...args) => a(b(...args)))
}
我们先来考虑没有函数的情形,这种情况下返回一个透传函数 arg ⇒ arg
,即不对原始的 dispatch 做任何改变:
dispatch = compose(...chain)(store.dispatch) // dispatch === store.dispatch
接下来考虑有一个中间件的情形,这种情况下直接返回中间件:
(next = store.dispatch) => (action) => { // next 就是 store.dispatch
// ... 中间件对 action 进行处理
// 如果要交给原始的 dispatch 就调用 next
};
下面我们来考虑最复杂的情形,即有多个中间件,这里 compose
将它们用 reduce
串接起来:
return funcs.reduce((a, b) => (...args) => a(b(...args)))
这样的写法可能具有误导性(让读者误以为 a
和 b
都是中间件),我们将形参改个名字理解起来会更容易:
return funcs.reduce(
(alreadyChained, nextMiddleware) =>
(...args) => alreadyChained(nextMiddleware(...args))
)
可以看到最终形成的是一种链式调用,如果我们这样调用 compose
方法:
compose(a, b, c)
最终就会得到:
(...args) => a(b(c(...args)))
这样用户在调用 dispatch
的时候,中间件只要调用 next
就能将 action 抛给下一个中间件处理。
现在我们可以来探究用户调用 dispatch
时会发生什么了。我们已经知道了用户实际调用的是 compose
函数的返回值,所以实际上执行的是函数 [4]
,而我们知道在用 redux-thunk 的时候,action 里面会调用 disaptch,而这个 dispatch 就是 [4]
本身!只不过 [4]
第二次被调用的时候,走的是 next(action)
,而我们在上面分析过,这里 next === store.dispatch
,这样就会到真正 Store
对象的 dispatch
方法啦。
下面这张图能够帮助你理解变量之间的关系:
combineReduces
也是个十分重要的函数,随着应用规模的扩大,你会希望不同的 reducer 能够处理状态树的局部而非一个 reducer 管理整个状态树。这个函数的代码在这里,其名十分贴切,工作原理就像 mapReduce。
首先这个方法会校验参数的正确性,然后返回一个名为 combination
的大 reducer。
combination
一开始仍然是校验参数的正确性,真正执行 mapReduce 过程的只有这几行。可以看到它会分把先前状态的一部分摘取下来放到名为 previousStateForKey
的变量里,然后通过对应的 reducer 来产生局部的新状态,赋值到新的整体状态上,然后,判断局部的状态是否发生变化,从而判断整体的状态是否发生变化。
let hasChanged = false
const nextState = {}
for (let i = 0; i < finalReducerKeys.length; i++) {
const key = finalReducerKeys[i]
const reducer = finalReducers[key]
const previousStateForKey = state[key]
const nextStateForKey = reducer(previousStateForKey, action)
nextState[key] = nextStateForKey
hasChanged = hasChanged || nextStateForKey !== previousStateForKey
}
hasChanged =
hasChanged || finalReducerKeys.length !== Object.keys(state).length
return hasChanged ? nextState : state
以上就是对 redux 源码的解读了。可以看到除了串联 middleware 的部分,都非常清晰易懂。对于 Redux 这样的库来说,其**远比其实现精妙的多。
另外还有一个函数 bindActionCreators
这里就不介绍了,感兴趣的话可以自行阅读其源码。
在编写 Angular 应用时,开发者能够感知到视图是由组件、指令、模板和 HTML 原生标签构成的,实际上,Angular 会编译组件、指令、模板和 HTML 原生标签成各种 factory,在运行时生成 view,view 作为构建视图的基本单位构成视图树,并最终渲染为 DOM 树呈现在网页上。用流程图表示的话就像下面这样:
为了能够更直观地探究 Angular 中视图树的构建过程,我准备了一个小 demo,可以在这里找到,这个 demo 包括了常见的视图构成元素:
ng-template
ng-container
*ngIf
{{ }}
请通过 npm run ngc
命令来将 Angular 应用的源代码编译成 Angular 在运行时将会执行的代码。我们之后会专门讲解 Angular 的编译器。
编译后的代码将会以别名调用 Angular 的类和函数,这些别名都能够在 codegen_private_export 文件中找到其对应的真身,我在这里给出一个速查表:
别名 | 真身 | 说明 |
---|---|---|
ɵccf |
createComponentRef |
创建一个指代组件的 ComponentRef 对象 |
下面让我们正式开始探究 view 树的构建。
view 树的构建过程从 createProdRootView
方法开始(至于为什么我们会在之后讲解 Angular 应用的启动过程时说明)。
可点开 edited 查看最后更新日期 ☝
挺少的,可能是我认识的程序员里最少的……
更多命令行配置在 https://github.com/wendellhu95/dotfiles
没错,被老婆包养了 🐿️
上一篇文章介绍了 GlobalPositionStrategy
,由于它相对简单,我们没有讲解具体实现。这篇文章我们来分析更加复杂的 ConnectedOverlayPositionStrategy
,该策略要求指定一个 origin 元素(可通过 setOrigin
方法指定)或称原点,在浮层元素显示或者窗口滚动的时候,浮层元素就会以原点为锚点,在页面的可视区域中显示。这种策略在组件库中是非常常用的,ng-zorro-antd 的 select cascader date-picker 等组件都用到了它。
这个策略的难点就在于确定浮层的位置:浮层相对于原点一共有 12 种位置,不同的位置可能导致浮层元素的可视面积不一致,该策略需要从 12 种当中选取符合用户设置的优先级,且能够尽可能多地展示浮层元素内容的那一个。
我们还是从 apply 方法说起,如上篇文章所述,当需要调整浮层元素位置时,这个方法就会被调用。
我们省略掉一些分支情形,描述一下这个方法的主要流程
function apply(): void {
// 重置浮层元素的样式
this._clearPanelClasses();
this._resetOverlayElementStyles();
this._resetBoundingBoxStyles();
// 获取视窗、原点元素和浮层元素的位置和大小
this._viewportRect = this._getNarrowedViewportRect();
this._originRect = this._getOriginRect();
this._overlayRect = this._pane.getBoundingClientRect();
// 为每一个可选的位置计算可视区域大小,每个位置按照优先级有序地排列
for (let pos of this._preferredPositions) {
// 计算该位置下,浮层元素相对原点元素的定位点
let originPoint = this._getOriginPoint(originRect, pos);
// 计算浮层元素的定位点
let overlayPoint = this._getOverlayPoint(originPoint, overlayRect, pos);
// 计算这种情况下浮层元素能够在可视窗口内如何进行展示
let overlayFit = this._getOverlayFit(overlayPoint, overlayRect, viewportRect, pos);
if (overlayFit.isCompletelyWithinViewport) {
// 1. 如果能够完全展示在可视区域当中,直接应用这个定位
}
if (this._canFitWithFlexibleDimensions(overlayFit, overlayPoint, viewportRect)) {
// 2. 如果能够通过调整浮层元素的宽高来让浮层元素可视,那么就记录一下这种定位
}
// 3. 最后,如果这个定位的可视区域大于其他的定位方法,则将它作为 fallback
}
// 处理分支 2 情形中的定位
// 如果没有分支情形 2 的定位,那么就应用 fallback 定位
if (this._canPush) {
// 如果用户允许偏移,那么就把浮层元素推到可视区域当中
}
// 否则直接应用定位
this._applyPosition(fallback!.position, fallback!.originPoint);
}
在了解了算法主要内容的基础下,我们来深入探究一下一些细节问题:
如何对元素进行定位
从去年 12 月起我一直在做在线文档的开发工作,工作中遇到了如下问题:
第一个问题是:如何处理不同平台之间的差异。
我们的产品需要兼容多个平台,而且同一个功能在不同的平台上要调用不同的 API,之前为了处理这些平台差异性,写了很多这样的代码:
if (isMobile) {
// ... mobile 的特殊逻辑
} else if (isDesktop) {
// ... desktop 的特殊逻辑
} else {
// ... browser 的逻辑
}
这样的写法不仅很难维护,而且由于无法 tree shake,会导致仅在 B 平台上运行的代码被 ship 到 A 平台用户的设备上,无端增加了包大小。
有一种比较 hacky 的方案是通过 uglify 来消除不会被执行的分支,但这仍然无法解决可维护性低的问题。
第二个问题是:如何在多个产品之间复用代码。
我们的项目有两个文档与表格两个子产品,这两个产品在 UI、逻辑上既有相同之处又有不同之处。例如,两个产品标题栏下拉框的行为一致,但是下拉框内的菜单项不一致,比如文档有页面设置的菜单项,但是表格没有。又例如,两个产品鉴权、网络方面的逻辑一致,但是对文档模型处理方法不一致。
第三个问题是前端开发的老大难问题,即如何优雅地做状态管理和逻辑复用。
目前对于这个问题,社区已经提出了很多方案:
当然,并没有银弹可以完美地解决这个问题,但是我们仍然需要量体裁衣,针对我们项目的情况和需求来探索新的模式。
第四个问题是代码组织。
产品逐渐复杂、代码量自然水涨船高,项目也随之腐化——比如大量的复制粘贴的代码、模块边界不清晰、文件和方法过长等等——结果导致维护成本剧增。
总结来说,我们需要一种机制:
在寻找解决方案的过程中,我从 vscode 和 Angular 这两个项目中获得了许多灵感,它们的共性是都使用了依赖注入 (DI) 。
之所以要学习 vscode 是因为它和我们的项目存在很多相似之处:都是编辑器应用、需要处理复杂的数据和状态、架构复杂、需要支持多平台等等。
vscode 的代码组织和运行机制都明显突出了依赖注入在这个项目中的核心位置:
CodeMain
开始,DI 就被引入,所有功能都被划分到数十个 service 当中,以 DI 的方式给相关方使用。想要详细了解可阅读我在阅读 vscode 源码时写的两篇笔记。一、二。
Angular 框架本身和使用 Angular 开发的应用都基于依赖注入:
那么,在 vscode 和 Angular 中大放异彩的依赖注入究竟是什么,为什么依赖注入可以解决文章开头提到的四个问题?
在软件工程中,依赖注入是种实现控制反转用于解决依赖性设计模式。一个依赖关系指的是可被利用的一种对象(即服务提供端) 。依赖注入是将所依赖的传递给将使用的从属对象(即客户端)。该服务是将会变成客户端的状态的一部分。 传递服务给客户端,而非允许客户端来建立或寻找服务,是本设计模式的基本要求。
以上定义来自维基百科,而维基百科的特点就是不爱说人话。让我们换一种简单的表达:
依赖注入就是不自行构造想要的东西(即依赖),而是声明自己想要的东西,让别人来构造。当这个构造过程发生在自己的构造阶段时,就叫做依赖注入。
如果你深入挖掘的话,你会发现依赖注入和依赖倒置、控制反转等概念相关。可以参考这个知乎回答。
在依赖注入系统中,有三种主要角色:
现在,你应该对依赖注入模式有一个大致的理解了,让我们来看看依赖注入如何解决文章开头提到的那些问题。
要解决平台差异问题,就要将依赖于平台的代码和其他代码区别开来,让其他代码不需要知道当前在什么平台上。
这里以 vscode 的“拖拽文件到新目录前进行提示”功能为例,这一功能在应用中和在浏览器中的 UI 是不同的。
在 vscode 应用内它看起来是这样:
在浏览器端看起来像是这样:
实现方式就是将“弹出对话框”功能抽象成一个依赖项,在不同的平台注入不同的依赖项,这样调用者就不用关心当前的平台是什么了。
对于这段代码中的调用者来说,它并不知道也不需要知道目前的环境:
const confirmation = await this.dialogService.confirm({
message,
detail,
// ...
});
因为 dialogService
是被注入的,而桌面端和浏览器端分别注入了不同的 service。
详情请看第二篇关于 vscode 的博客。
解决第二个问题的思路其实和解决第一个的是一致的。我们只需要将只要将不一样的部分抽象成依赖项,然后让其余代码依赖它就可以了。
依赖注入可以管理共享状态。将多个组件共享的状态提取到依赖项中并结合发布-订阅模式,就能实现直观的单项数据流;将能改变状态的方法放到依赖项中,就能直观地知道这些状态会如何改变;另外还可以方便地结合 RxJS 管理更复杂的数据流。这种方案
setState
等 API 更符合人类的思维直觉。依赖注入模式中的“依赖项”概念会强迫开发者思考哪些代码在逻辑上是相关联的,应该放到同一个类当中,从而让各个功能模块解耦;也会强迫开发者思考哪些是 UI 代码,哪些是业务代码,让 UI 和业务分开。并且,由于在依赖注入系统中,类的实例化过程(甚至包括销毁过程)是依赖注入框架完成的,因此开发者只需要关心功能应该划分到哪些模块中、模块之间的依赖关系如何,无需自己实例化一个个类,这样就降低了编码时的心智负担。最后,由于依赖注入使得类不用再负责构造自己的依赖,这样就能很方便地进行单元测试。
为了能在 React 中方便地使用依赖注入模式,在重构的过程中,我实现了一个轻量的依赖注入库以及一组 React binding,现已开源。
wedi 具有如下特性:
接下来我们结合几个具体的例子来讲解如何使用 wedi 。
当你需要提供依赖项的时候,只需要调用 useCollection
生成 collection,然后塞给 Provider
组件即可,Provider
的 children 就可以访问它们。
import { useCollection } from "wedi";
function FunctionProvider() {
const collection = useCollection([FileService]);
return (
<Provider collection={collection}>
{/* children 可访问 collection 中的依赖项 */}
</Provider>
);
}
import { useDependency } from 'wedi';
function FunctionConsumer() {
const fileService = useDependency(FileService);
return (
/* 从这里开始可以调用 FileService 上的属性和方法 */
);
}
wedi 保证在函数式组件的 Provider
重渲染时不会重新构建依赖项,这样你就不会丢失依赖项里保存的状态。
可以通过给 useDependency
传入第二个参数 true
来声明该依赖是可选的,TypeScript 会推断出返回值可能为 null
。如果未声明依赖项可选且获取不到该依赖项,wedi 会抛出错误。
import { useDependency } from "wedi";
function FunctionConsumer() {
const nullable: NullableService | null = useDependency(NullableService, true);
const required: NullableService = useDependency(NullableService); // Error!
}
当然 wedi 也支持传统的类组件。
当需要在某个组件及其子组件中注入依赖项时,使用 Provide
装饰器传递这些依赖项。
import { Inject, InjectionContext, Provide } from 'wedi';
import { IPlatformService } from 'services/platform';
@Provide([
FileService,
IPlatformService, { useClass: MobilePlatformService });
])
class ClassComponent extends Component {
static contextType = InjectionContext;
@Inject(IPlatformService) platformService!: IPlatformService;
@Inject(NullableService, true) nullableService?: NullableService;
}
当需要使用这些依赖项时,需要将组件的默认 context 设置为 InjectionContext
,然后就可以通过 Inject
装饰器获取依赖项了。同样,可以传入 true
给 Inject
声明依赖是可选的。
wedi 支持各种各样的依赖项,包括类,值、实例和工厂函数。
有两种方法将一个类声明为依赖项,一是传递类本身,二是使用 useClass
API 结合 identifier 。
const classDepItems = [
FileService, // 直接传递类
[IPlatformService, { useClass: MobilePlatformService }] // 结合 identifier
];
使用 useValue
注入值或实例。
const valueDepItem = [IConfig, { useValue: "2020" }];
使用 useFactory
注入工厂方法。
const factorDepItem = [
IUserService,
{
useFactory: (http: IHTTPService): IUserService => new TimeSerialUserService(http, TIME),
deps: [IHTTPService]
}
]
wedi 甚至可以注入组件:
const IDropdown = createIdentifier<any>("dropdown");
const IConfig = createIdentifier<any>("config");
const WebDropdown = function() {
const dep = useDependency(IConfig);
return <div>WeDropdown, {dep}</div>;
};
@Provide([
[IDropdown, { useValue: WebDropdown }],
[IConfig, { useValue: "wedi" }]
])
class Header extends Component {
static contextType = InjectionContext;
@Inject(IDropdown) private dropdown: any;
render() {
const Dropdown = this.dropdown;
return <Dropdown></Dropdown>; // WeDropdown, wedi
}
}
这种方式可以满足在不同平台展现不同的 UI 的需求。
wedi 能够构建起层次化的依赖注入体系,wedi 在获取依赖项时,采取“就近原则”:
@Provide([
[IConfig, { useValue: "A" }],
[IConfigRoot, { useValue: "inRoot" }]
])
class ParentProvider extends Component {
render() {
return <ChildProvider />;
}
}
@Provide([[IConfig, { useValue: "B" }]])
class ChildProvider extends Component {
render() {
return <Consumer />;
}
}
function Consumer() {
const config = useDependency(IConfig);
const rootConfig = useDependency(IConfigRoot);
return (
<div>
{config}, {rootConfig}
</div> // <div>B, inRoot</div>
);
}
这样你就可以使某些依赖项全局可用,而使另外一些依赖项范围可用,你可以利用这个特性方便地管理全局状态和局部状态。这对于界面上存在大量相同组件的应用特别合适。
你甚至可以通过 React Dev Tools 可视化地查看依赖项的可用范围(也就意味着状态的范围)。
这个截图来自用 wedi 构建的 TodoMVC(开发环境下)。
依赖注入模式可以很方便地与响应式编程相结合,用于状态管理。当一些组件之间需要共享状态时,你就可以把状态提取到各个组件都能访问到的依赖项当中,然后去订阅该状态的改变。
下面是一个计时器的例子。
import { Provide, useDependency, useDependencyValue, Disposable } from "wedi";
class CounterService implements Disposable {
counter$ = interval(1000).pipe(
startWith(0),
scan(acc => acc + 1)
);
// 如果有 dispose 函数,wedi 就会在组件销毁的时候调用它,这里你可以做一些 clean up 的工作
dispose(): void {
this.counter$.complete();
}
}
function App() {
const collection = useCollection([CounterService]);
return (
<Provide collection={collection}>
<Display />
</Provide>
);
}
function Display() {
const counter = useDependency(CounterService);
const count = useDependencyValue(counter.counter$);
return <div>{count}</div>; // 0, 1, 2, 3 ...
}
更多关于 wedi 的 API 可关注 README。
上一篇文章介绍了 vscode 的依赖注入机制。
在 vscode 中,依赖注入主要用于将服务注入到消费者对象当中,将一些基础能力提供给业务代码使用。
我们来拆解一下这句话:
HTTPService
的服务当中,这样在使用 HTTP 的时候,我们就不需要再去担心具体的实现问题了。从这个例子也可以看出,服务实际上也是一种解耦(decoupling)和关注分离(SOC)的手段。如果你对上面的某些概念不是很理解,你可以稍后去学习它们,这里的铺垫已经足够你理解本文的全部内容。
到这里我们了解了服务的含义,接下来就来看看 vscode 中用到了哪些服务吧!
我们之后才会讲到 Electron 的双进程架构。这里画了一个简图来方便你理解下面文章的内容,仅仅用于表达各个对象间的层次关系。
------------------------------------- ------------------------------------------
*Workbench*
------------------------------------- ------------------------------------------
Window ----electron.BrowserWindow.load()----> *Desktop Main / BrowserMain*
------------------------------------- ------------------------------------------
*CodeApplication*
------------------------------------- ------------------------------------------
*CodeMain*
------------------------------------- ------------------------------------------
Main Process Renderer Process
-----------------------------------------------------------------------------------
Electron
-----------------------------------------------------------------------------------
CodeMain
是 vscode 主线程中最底层的类,它在初始化时会创建以下这些服务:
InstantiationService
我们在上一篇文章中就知道了这个类是负责实现依赖注入的EnvironmentService
环境变量服务,保存了诸如根目录、用户目录、插件目录等信息MultiplexLogService
多级日志服务ConfigurationService
LifecycleMainSercice
生命周期服务,封装了 Electron 的一写生命周期事件,使得消费者能够在这些生命周期钩子里做一些事情StateService
状态服务,它负责对 vscode 的 db 的读写RequestMainService
请求服务,负责发起 http 请求,背后调用的是 node 的 https 和 http 等模块ThemeMainService
负责编辑器主题相关SignService
应用签名服务等。
CodeApplication
会创建以下服务:
FileService
文件存取WindowMainService
用于管理 vscode 的所有的窗口(打开、关闭、激活等等)UpdateService
根据运行平台的不同分别注入 Win32UpdateService
DarwinUpdateService
等,负责应用程序的更新DialogMainService
对话框管理ShareProcessMainService
用于跨进程通讯DiagnosticsService
应用运行性能诊断LaunchMainService
IssueMainService
ElectronMainService
WorkspacesService
工作区管理服务MenubarMainService
菜单栏管理服务StorageMainService
存储BackupMainService
备份WorkspacesHistoryMainService
工作区历史URLService
URL 解析TelementryService
等。
DesktopMain
是 vscode 渲染进程中最底层的类,它虽然自己并没有创建 InitailizationService
,但是它创建了一个 service
集合并将这个集合传递给 Workbench
,由 Workbench
创建了 InitializationService
。
在这一层次上提供的服务有:
MainProcessService
用于和主进程进行通讯ElectronEnvironmentService
Electron 环境变量WorkbenchEnvironmentService
Workbench 环境变量ProductService
LogService
日志RemoteAuthorityService
SignService
RemoteAgentService
FileService
文件存储服务等。
Workbench 实际上就是我们能看到的 vscode 工作区的 UI。它会创建一个 InstantiationService
,除了将从 DesktopMain
传递来的依赖注入项保存起来之外,它还要将全局单例注入项保存到 InstantiationService
当中,代码如下:
const contributedServices = getSingletonServiceDescriptors();
for (let [id, descriptor] of contributedServices) {
serviceCollection.set(id, descriptor);
}
const instantiationService = new InstantiationService(serviceCollection, true);
我们在上一篇文章讲过 vscode 的全局单例注入。
那么究竟有哪些服务会被注入进来呢?这其实是在入口文件中确定的。
在桌面端的 vscode 中,入口文件为 workbench.js,从中可以看到引入了脚本 vs/workbench/workbench.desktop.main,而这个脚本在全局注册了很多服务(即 #region --- workbench services
里面的内容),另外通过引入 workbench.common.main.ts,还引入了很多服务(注意 #region --- workbench parts
里面的内容也是依赖注入项且和 UI 相关)。而在浏览器端的 vscode 中,入口文件则为 workbench.html,引入的主要脚本则是 vs/workbench/workbench.web.main。
由于 Workbench
引入的全局单例服务实在是太多了,这里我们仅仅列举几个,感兴趣的话可以到入口文件中去查看:
NativeLifeCycleService
这个服务封装了 Electron 窗口 onBeforeUnload
和 onWillUnload
的回调,让 vscode 的其余部分可以在窗口即将关闭之前做一些 clean up 的工作。等等。
到这里,我们就对 vscode 中常用到的服务有哪些,它们是如何注入的,以及它们被注入的位置等问题有了一个大致上的认识。接下来的问题是,为什么 vscode 要使用依赖注入的方式来组织代码呢?
对于 vscode 来说,使用依赖注入模式有以下这些好处:
一、繁杂的功能点借助依赖注入被合理划分到不同的服务中,在横向上降低了模块间的耦合度,提高了模块内聚性。如果想要修改某些功能,很容易就能知道去哪里查找相关代码;对某个模块的修改,不会影响其他模块的开发。
二、消费者和服务通过接口解耦,对于服务消费者来说,它只要求被注入的类符合它的接口要求就可以了,并用不关心注入项究竟是如何实现的,即在纵向上降低了耦合度(其实就是依赖反转 DIP),这使得 vscode 的架构十分灵活,能够通过提供不同的服务来做到一些神奇的事情。
如果你有关注 vscode 的动态,那么你肯定知道今年他们搞的一个大动作就是推出了在完全在浏览器环境中运行的 Visual Studio Code Online(你可以通过在 vscode 项目中执行 yarn web
脚本启动它)。
vscode 基于 Electron,所以可以访问一些桌面端才有的 module,但是在浏览器环境下并没有这样的模块。以 FileService
为例,在 Electron 中它需要 fs module,因此它注册的是一个 diskFileSystemProvider
,
const diskFileSystemProvider = this._register(new DiskFileSystemProvider(logService));
fileService.registerProvider(Schemas.file, diskFileSystemProvider);
但是在浏览器中我们不能使用模块,所以,在 vscode online 中,FileService
注册的是一个 remoteFileSystemProvider
。
const channel = connection.getChannel<IChannel>(REMOTE_FILE_SYSTEM_CHANNEL_NAME);
const remoteFileSystemProvider = this._register(new RemoteFileSystemProvider(channel, remoteAgentService.getEnvironment()));
fileService.registerProvider(Schemas.vscodeRemote, remoteFileSystemProvider);
但对于 FileService
的消费者即 DesktopMain
来说,它并不需要(也不应该)知道这种差别,它只要按照自己的需要调用符合 IFileService
接口的服务就好了。
再以“拖动文件位置前弹出对话框”功能为例,它在 Electron 和浏览器中展现出不同的样式:
但是业务层不需要了解各个平台上如何创建 dialog,只需要调用 IDialogService 提供的方法就可以了:
const confirmation = await this.dialogService.confirm({
message: items.length > 1 && items.every(s => s.isRoot) ? localize('confirmRootsMove', "Are you sure you want to change the order of multiple root folders in your workspace?")
: items.length > 1 ? getConfirmMessage(localize('confirmMultiMove', "Are you sure you want to move the following {0} files into '{1}'?", items.length, target.name), items.map(s => s.resource))
: items[0].isRoot ? localize('confirmRootMove', "Are you sure you want to change the order of root folder '{0}' in your workspace?", items[0].name)
: localize('confirmMove', "Are you sure you want to move '{0}' into '{1}'?", items[0].name, target.name),
checkbox: {
label: localize('doNotAskAgain', "Do not ask me again")
},
type: 'question',
primaryButton: localize({ key: 'moveButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Move")
});
在打包不同平台上的 vscode 时,注入不同的 IDialogService:
import 'vs/workbench/services/dialogs/electron-browser/dialogService';
import 'vs/workbench/services/dialogs/browser/dialogService';
总的来说,想要让 vscode 在浏览器中运行,只需要修改被注入的服务,然后通过不同的打包入口(已在上文中介绍)引入这些服务,无须修改上层代码。
三、依赖注入模式也带来了软件工程方面的一些好处。
这篇文章展示了 vscode 如何利用依赖注入系统提供各种基础功能来服务业务代码,对需要支持多平台的大型应用提供了一个优秀的模板。
准备迁移微信公众号文章
这篇文章翻译自 Peter Norvig 的 Teach Yourself Programming in Ten Years,点击这里阅读原文。
走入任何一家书店,你都能看到诸如《24 小时自学 Java》这样的书,还有数不胜数的有关 C、SQL、Ruby 或者算法的类似书籍。使用 Amazon 的高级搜索功能:title: teach, youself, hours, since: 2000,你能找到 512 本书。最前面 10 本中,有 9 本和编程相关(另外一本是关于簿记的)。把 teach youself(自学)替换成 learn(学习),或者把 hours(小时)替换成 days(天)也会得到类似的结果。
所以,要么人们都急着想学编程,要么编程和其他技能相比尤其简单易学。Felleisen 等人在他们的著作 How To Design Programs 中就用到了这个梗,他们写道:“如果不计较代码的质量有多糟糕,编程是很简单的,傻瓜都能在 21 天内学会。”Abtruse Goose 漫画有一辑也拿这个开涮。
让我们来详细分析一下“24 小时自学 C++”(Teach Youself C++ in 24 Hours)这样的标题到底意味着什么
研究者们(Bloom (1985), Bryan & Harter (1899), Hayes (1989), Simmon & Chase (1973))已经发现,在许多领域中——包括下棋、游泳、网球、神经心理学和拓扑学等——要想达到专家级的水平,大约要花 10 年时间的努力。关键在于刻意学习:不是简单的重复,而是选择一个具有挑战性的、稍稍超出现有能力的任务,尝试完成它,分析你的表现和成果,纠正失误,然后不断重复再重复这个过程。银弹似乎不存在:即使天才如莫扎特,4 岁开始作曲,在创作第一首世界级音乐之前也积淀了 13 年的时间。披头士在 1964 年接连空降音乐榜单首位,登上 Ed Sullivan 秀并成为现象级乐队,似乎是一夜之间发生的事情,但他们从 1957 年起就开始在利物浦的小俱乐部里表演。尽管他们早年就征服了乐迷,但直到 1967 年发表了 Sgt. Peppers 后才受到音乐批评界的褒扬。
Malcolm Gladwell 让这个理念广为人知,尽管他具体到了 10000 个小时而不是含糊的 10 年。Henri Cartier-Bresson(1908-2004)也发表过类似的言论:“你的头一万张照片是你最废的照片”(他并没有预料到数码相机的出现,现在有的人一周就能拍这么多)。达到大师级水平则可能需要一生的时间,Samuel Johnson(1709-1784)曾说,“只有奉献一生的时间和精力,才能在某个学科成就卓越”。乔叟抱怨过:“吾生也须臾,学海也无涯”。希波克拉底(c. 400BC)的名言更是广为人知 “ars longa, vita brevis”,这一句其实节选自 “Ars longa, vita brevis, occasio praeceps, experimentum periculosum, iudicium difficile”,翻译成中文就是“生也有涯,知也无涯,灵光稍纵即逝,实验变幻莫测,判断殊为困难”(英文原文为 “Life is short, [the] craft long, opportunity fleeting, experiment treacherous, judgment difficult.”)。当然,并不存在这样一个神奇的数字——不同的人精通不同的技艺(比如编程、象棋、跳棋、演奏乐器)需要的时间都是一样的,怎么可能!但是像 K. Anders Ericsson 教授说的那样:“在大部分领域中,即使是最天才的人为了达到最高水平,所需花费的时间也是十分巨大的。10000 小时这个数字只是给受众一个概念:我们这里谈论的是每周 10 到 20 小时,持续数年的不断投入。”
如果你想成为一名成功的程序员,我这里有一些建议:
知道了以上这些,很难不令人怀疑仅仅通过书本学习,一个人能在编程这条路上走多元。在我第一个孩子出生之前,我读了所有“如何……”这样的书,但最终还是免不了变成一个一无所知的新手爸爸。30 个月后,我的第二个孩子临盆时,我重新读了一遍那些书吗?不,相反,我依赖自己的经验,最终事实证明经验比育儿专家们写的大部头更有效。
Fred Brooks 在他的论文没有银弹(No Silver Bullet)中提出了一个培养优秀软件架构师的三部曲:
这个方法假定候选人已经具备了某些成为优秀架构师所必须的特质,用人方所需要做的仅仅是把他们发掘出来。Alan Perlis 以一种更简明的方式陈述了这一观点:“人人都可以学会雕塑,但米开朗琪罗只能学习如何不会雕塑“,他的意思是说卓越的人都有一些内在的特质来帮助他们超越他们所接受的训练。但这种特质是哪里来的?是天生的,还是通过后天的勤奋养成的呢?就像《料理鼠王》的古斯图大厨所言:“人人都能烹饪,但只有无所畏惧的人才能成为大厨”,我更愿意把这句话理解为:要成就非凡,人需要有把一生的大半时间都奉献给这件事的意愿。但是无所畏惧这个词可能仅仅只是其中的一种表达。或许像古斯图的美食批评家安东说的那样:“不是所有人都能成为伟大的艺术家,但是伟大的艺术家可能来自任何地方。”
所以尽管去买 Java、Ruby、JavaScript、PHP 教程书吧,你或许能学到一些有用的东西,但是绝不可能在 24 小时或者 21 天内改变人生,或者是让自己的开发水平有什么质的改变。你说通过 24 个月持之以恒的努力能不能做到呢?嗯,你已经开始上道了……
Bloom, Benjamin (ed.) Developing Talent in Young People, Ballantine, 1985.
Brooks, Fred, No Silver Bullets, IEEE Computer, vol. 20, no. 4, 1987, p. 10-19.
Bryan, W.L. & Harter, N. "Studies on the telegraphic language: The acquisition of a hierarchy of habits. Psychology Review, 1899, 8, 345-375
Hayes, John R., Complete Problem Solver Lawrence Erlbaum, 1989.
Chase, William G. & Simon, Herbert A. “Perception in Chess” Cognitive Psychology, 1973, 4, 55-81.
Lave, Jean, Cognition in Practice: Mind, Mathematics, and Culture in Everyday Life, Cambridge University Press, 1988.
常见 PC 上各种操作大致所需的时间:
执行的常见操作 | 1/1,000,000,000 秒 = 1 纳秒 |
---|---|
从 1 级缓存取数 | 0.5 纳秒 |
分支预测 | 5 纳秒 |
fetch from L2 cache memory | 7 纳秒 |
Mutex lock/unlock | 25 纳秒 |
fetch from main memory | 100 纳秒 |
通过 1Gbps 网络发送 2K 内容 | 20,000 纳秒 |
从内存顺序读 1MB 内容 | 250,000 纳秒 |
磁盘读寻道时间 | 8,000,000 纳秒 |
从磁盘顺序读 1MB 内容 | 20,000,000 纳秒 |
从美国往欧洲发包并返回 | 150 毫秒 = 150,000,000 纳秒 |
有几个人问过我第一门编程语言应该选择哪个。没有标准答案,但你可以从这些方面考虑:
基于以上原则,我像编程新手们推荐的第一门编程语言是 Python 或者 Scheme。另外一个可选项是 JavaScript,并不是因为它对新手友好,而是因为有很多在线教程,比如可汗学院的这一套教程。如果学习者的背景比较特殊的话,还有其他一些好的选择。儿童可能会更喜欢 Alice、Squeak 或者 Blockly 这些语言(老年学习者或许也会喜欢)。重要的是做出选择之后马上开始学习。
不久前知名前端团队 ZEIT 在 twitter 上公布了他们的 data fetching 工具库 swr ,目前这个库已经在 GitHub 上收获了 4,500+ star 。它有着一些非常亦可赛艇的功能,最主要的是实现了 RFC 5861 草案,即发起网络请求之前,利用本地缓存数据进行渲染,待请求的响应返回后再重新渲染并更新缓存,从而提高用户体验。其他功能包括在窗口 focus 时更新、定时更新、支持任意网络请求库、支持 Suspense 等。
本文将会分析其源码以探究它是如何工作的。
src
├── config.ts // 配置,同时也定义了一些重要的全局变量
├── index.ts
├── libs // 一些工具函数
│ ├── hash.ts
│ ├── is-document-visible.ts
│ ├── is-online.ts
│ ├── throttle.ts
│ └── use-hydration.ts
├── swr-config-context.ts // 实现 context 支持
├── types.ts // 类型定义
├── use-swr-pages.tsx // 分页
└── use-swr.ts // 主文件
我们从官网举的最简单的例子开始:
import useSWR from 'swr'
function Profile () {
const { data, error } = useSWR('/api/user', fetch)
if (error) return <div>failed to load</div>
if (!data) return <div>loading...</div>
return <div>hello {data.name}!</div>
}
我们所使用的 useSWR 函数定义在这里。
参数处理阶段。useSWR
有多种 function overload,但无论如何都需要传入一个符合 keyInterface
的 key
才行。swr 会对开发者传入的参数进行处理,最终得到以下四个重要变量:
key
,这是请求的唯一标识符
keyErr
,对应请求标识符的错误标识符config
,由默认配置,上下文配置和函数参数合并而成的配置对象fn
,请求函数然后,swr 会准备一些变量:
initialData
和 initialError
,swr 会通过 cacheGet 方法从一个全局的 __cache map 中按照 key 存取缓存值,这其实就是 swr 请求缓存的实现原理,也就是对 RFC 5861 的实现,并没有任何黑魔法!data
和 dispatch
,swr 是通过调用 useReducer
来生成的,它们记录了 key
所对应的请求的状态:请求的数据,请求的错误信息以及请求是否正在发送。unmountedRef
keyRef
dataRef
errorRef
等工具变量。然后 swr 定义了一个非常重要的函数 revalidate ,这个函数内部定义了发起请求、处理响应和错误的主要过程。
注意生成这个函数的时候调用了 useCallback,依赖项为 key,即只有在 key 发生变化的时候才会重新生成 revalidate 函数。
我们先聚焦于主要流程,此时 shouldDeduping === false
。
首先会在 CONCURRENT_PROMISES 这个全局变量上缓存 fn 调用后的返回值(一个 Promise),其实这里调用 fn
就已经发起了网络请求。CONCURRENT_PROMISES 这个变量是一个 Map,实际上建立了 key
和网络请求之间的映射,swr 利用这个 Map 来实现去重和超时报错等功能。
很明显能够看出
fn
必须返回一个 Promise,这个简单的约定也使得 swr 能够支持任意的网络请求库,不管是 REST 还是 GraphQL,只要返回 Promise 就行!
然后 revalidate 会等待网络请求完毕,获取到请求数据:
newData = await CONCURRENT_PROMISES[key]
并触发 onSuccess
事件。
接着,更新缓存,并通过 dispatch 方法更新 state ,此时就会触发 React 的重新渲染,重新渲染时就能从 state 里拿到请求数据了。
以上就是 revalidate
函数的主要过程,那么这个函数是在什么时候被调用的呢?我们接着看 useIsomorphicLayoutEffect 的回调函数。
useIsomorphicLayoutEffect 函数在服务端就是
useEffect
,在浏览器端就是useLayoutEffect
。
首先要判断本次调用时的 key 和上次调用的 key 是否相等。考虑下面这个组件:
const Profile = (props) => {
const { userData, userErr } = useSWR(() => `/${props.userId}`)
}
可以看到即使函数调用的位置相同(Hooks 的正确工作依赖各个 hook 的调用顺序),key
的值也可能不同,所以 swr 必须做这个检验。另外也要判断 data
是否相同,有可能别处更新了 key
所对应的缓存值。总之,当 swr 检查到 key 或者 data 不同,就会执行更新当前的 key
和 data
,并调用 dispatch
进行重绘等操作。
然后,在 revalidate
的基础上定义了 softRevalidate
函数,在 revalidate
执行时执行去重逻辑。
const softRevalidate = () => revalidate({ dedupe: true })
然后 swr 就会调用 softRevalidate,如果当前有缓存值且浏览器支持 requestIdleCallback
的话,就作为 requestIdleCallback
的回调执行,避免打断 React 的渲染过程,否则就立即执行。
如果数据请求的过程中发生了错误该怎么办呢?
注意到 revalidate
的函数,有很大一部分都在一个 try catch 块中,如果请求出错就会进入 catch 块。
主要做如下几件事情:
state
,更新错误内容默认的重试方法会在当前文档 visible 时执行重试,使用了一个指数退避策略,在一定的时间后重新调用 revalidate
。
我们在讲解主流程的过程中忽略了很多代码,而这些代码实现了 swr 的一些重要的实用特性,从这个小节开始我会一一讲解。
swr 提供了请求去重的功能,避免某个时间段内重复发起的请求过多。
实现的原理也非常简单。每次 revalidate
函数执行的时候,都会判断是否需要去重:
let shouldDeduping =
typeof CONCURRENT_PROMISES[key] !== 'undefined' && revalidateOpts.dedupe
即检验 CONCURRENT_PROMISES
里有没有 key
所对应的进行中的请求。
如果 shouldDeduping
为 true
,直接等待请求完成,如果为 false
,就按照上文所述进行处理。
而 revalidateOpts
的 dedupe
属性何时为 true
呢?可以看到声明 softRevalidate
的时候传入了参数:
const softRevalidate = () => revalidate({ dedupe: true })
而调用 useSWR
时返回的 revalidate
就是原本的 revalidate
,不带 dedupe
属性。
还是举官网的例子:
function MyProjects () {
const { data: user } = useSWR('/api/user')
const { data: projects } = useSWR(
() => '/api/projects?uid=' + user.id
)
if (!projects) return 'loading...'
return 'You have ' + projects.length + ' projects'
}
可见第二个请求依赖于第一个请求,调用 useSWR 时 key
为一个函数,函数体中访问 user
的 id
属性。
swr 通过 getKeyArgs 函数处理 key 为函数的情况,并将调用过程包裹在函数里:
if (typeof _key === 'function') {
try {
key = _key()
} catch (err) {
// dependencies not ready
key = ''
}
}
当 user
为 undefined
时,获取 undefined.id
出错,key
为空字符串,而 revalidate 函数在 key 为假值时直接返回:
if (!key) return false
因此在第一次渲染(MyProjects
函数第一次被调用)时,第二个请求实际上并未发出。而当第一个请求得到响应时,dispatch
会导致组件重绘(MyProjects
函数再次被调用),此时 user
不是 undefined
,第二个请求就能发出了。
所以 swr 所支持的“最大并行请求”的原理非常简单,就是判断能不能获得 key
,如果不能获得 key
就用 try catch 语句捕获错误,不发出请求,等待其他请求得到响应后在下次重绘时再试。
当请求成功或失败时,都需要调用 broadcastState 函数,这个函数本身非常简单,根据 key
从 CACHE_REVALIDATORS
中获取一组函数,挨个调用而已:
const broadcastState: broadcastStateInterface = (key, data, error) => {
const updaters = CACHE_REVALIDATORS[key]
if (key && updaters) {
for (let i = 0; i < updaters.length; ++i) {
updaters[i](false, data, error)
}
}
}
这些 updater 是什么?追踪源码可以看到是 onUpdate 函数,可以看到核心就在于下面几行代码:
dispatch(newState)
if (shouldRevalidate) {
return softRevalidate()
}
return false
即更新 state
触发重新渲染,并调用 softRevalidate
。
所以这个机制的目的是在一个 useSWR
发起的请求得到响应时,刷新所有使用相同 key
的 useSWR
。
mutate 是 swr 暴露给用户操作本地缓存的方法,其他部分经过上面的介绍理解起来应该很容易了,关键是如下这行:
MUTATION_TS[key] = Date.now() - 1
这其实是为了抛弃过时的请求用的。
revalidate 函数在执行的时候会记录发起请求的时间:
CONCURRENT_PROMISES_TS[key] = startAt = Date.now()
而当请求得到响应时,会判断 mutate 函数调用的时间和发起请求的时间的前后关系:
if (MUTATION_TS[key] && startAt <= MUTATION_TS[key]) {
dispatch({ isValidating: false })
return false
}
当发起请求的时间早于 mutate 调用的时间,说明请求已经过期,就抛弃掉这个请求不做任何后处理。
只需要设置一个 timeout 定时调用 softRevalidate 就可以了。
在 swr 执行的开始,准备 config
对象时调用 useContext 获取 SWRConfigContext :
config = Object.assign(
{},
defaultConfig,
useContext(SWRConfigContext),
config
)
想要支持 Suspense 很容易,仅需要把数据请求的 Promise 抛出就可以了:
throw CONCURRENT_PROMISES[key]
但是和通常情况下不同:当抛出的 Promise 未 resolve 时,React 并不会渲染这部分组件,因此返回值里也无需判断 keyRef.current
是否和 key
相同。
这篇文章介绍了 flask-login 是如何实现一个不需要使用数据库的用户认证组件的。
在介绍 flask-login 的工作原理之前先来简要回顾一下 flask-login 的使用方法。
首先要创建一个 LoginManager
的实例并注册在 Flask
实例上,然后提供一个 user_loader
回调函数来根据会话中存储的用户 ID 来加载用户对象。
login_manager = LoginManager()
login_manager.init_app(app)
@login_manager.user_loader
def load_user(user_id):
return User.get(user_id)
flask-login 还要求你对数据对象做一些改动,添加以下属性和方法:
@property
def is_active(self):
return True
@property
def is_authenticated(self):
return True
@property
def is_anonymous(self):
return False
#: 这个方法返回一个能够识别唯一用户的 ID
def get_id(self):
try:
return text_type(self.id)
except AttributeError:
raise NotImplementedError('No `id` attribute - override `get_id`')
完成这些设置工作之后,就可以使用 flask-login 了,一些典型的用法包括登录和登出用户:login_user(user)
及 logout_user()
,及使用 @login_required
保护一些视图函数,检测当前用户是否有访问的权限(根据是否认证进行区别):
@app.route("/settings")
@login_required
def settings():
pass
以及通过 current_user
对象来访问当前用户。
我们按照使用过程中调用 flask-login 的顺序来解析其源码。
先来看 LoginManager
对象,它用于记录所有的配置信息,其 __init__
方法中初始化了这些配置信息。一个 LoginManager
对象通过 init_app 方法注册到 Flask 实例上:
def init_app(self, app, add_context_processor=True):
app.login_manager = self
app.after_request(self._update_remember_cookie)
self._login_disabled = app.config.get('LOGIN_DISABLED', False)
if add_context_processor:
app.context_processor(_user_context_processor)
这个方法的主要工作是在 Flask 实例的 after_request 钩子上添加了一个用户更新 remember_me cookie 的函数,并在 Flask 的上下文处理器中添加了一个用户上下文处理器。
def _user_context_processor():
return dict(current_user=_get_user())
这个上下文处理器设置了一个全局可访问的变量 current_user
,这样我们就可以在视图函数或者模板文件中访问这个变量了。
然后就到了这个方法,它是 LoginManager 的实例方法,把 user_callback 设置成我们传入的函数,在实际的使用过程中,我们是通过修饰器传入这个函数的,就是 load_user(user_id)
函数。
def user_loader(self, callback):
self.user_callback = callback
return callback
该方法要求你的回调函数必须能够接收一个 unicode 编码的 ID 并返回一个用户对象,如果用户不存在就返回 None。
我们跳过对 User 类的修改,直接来看这个方法。
def login_user(user, remember=False, force=False, fresh=True):
if not force and not user.is_active:
return False
user_id = getattr(user, current_app.login_manager.id_attribute)()
session['user_id'] = user_id
session['_fresh'] = fresh
session['_id'] = current_app.login_manager._session_identifier_generator()
if remember:
session['remember'] = 'set'
_request_ctx_stack.top.user = user
user_logged_in.send(current_app._get_current_object(), user=_get_user())
return True
如果用户不活跃 not.is_active
而且不要求强制登录 force
,就返回失败。否则,先得到 user_id
,它是通过 getattr
函数访问 user
的 login_manager.id_attribute
属性得到的。追根溯源,最终 getattr
访问的是 user
的 get_id
方法,这就是为什么 flask-login 要求我们在 User
类中添加该方法。
然后在 Flask 提供的 session 中添加以下三个 session:user_id
_fresh
_id
,其中 _id
是通过 LoginManager
的 _session_identifier_generator
方法获取到的,而这个方法默认绑定在这个方法上:
def _create_identifier():
user_agent = request.headers.get('User-Agent')
if user_agent is not None:
user_agent = user_agent.encode('utf-8')
base = '{0}|{1}'.format(_get_remote_addr(), user_agent)
if str is bytes:
base = text_type(base, 'utf-8', errors='replace') # pragma: no cover
h = sha512()
h.update(base.encode('utf8'))
return h.hexdigest()
不用太深究,知道这个方法最终根据放着用户代理和 IP 信息生成了一个加盐的 ID 就行了,它的作用是防止有人伪造 cookie。
然后根据是否需要记住用户添加 remember
session。最后,在 _request_ctx_stack.top
中添加该用户,发出一个用户登录信号后返回成功。在这个登录信号中,调用了 _get_user
方法,_get_user
方法的细节是先检测在 _request_ctx_stack.top
中有没有用户信息,如果没有,就通过 _load_user
方法在栈顶添加用户信息,如果有就返回这个用户对象。_load_user
方法很重要,但是在这里不会被调用,很明显 _request_ctx_stack.top
中肯定有 user
值,我们待会再来看这个方法。
def _get_user():
if has_request_context() and not hasattr(_request_ctx_stack.top, 'user'):
current_app.login_manager._load_user()
return getattr(_request_ctx_stack.top, 'user', None)
这个修饰器常被用来保护只有登录用户才能访问的视图函数,它会在实际调用视图函数之前先检查当前用户是否已经登录并认证,如果没有,就调用 LoginManager.unauthorized
这个回调函数,它还对一些 HTTP 方法和测试情况提供了例外处理。
def login_required(func):
@wraps(func)
def decorated_view(*args, **kwargs):
if request.method in EXEMPT_METHODS:
return func(*args, **kwargs)
elif current_app.login_manager._login_disabled:
return func(*args, **kwargs)
elif not current_user.is_authenticated:
return current_app.login_manager.unauthorized()
return func(*args, **kwargs)
return decorated_view
在之前的分析中,可以看到这个变量经常出现并大有用途,开发者可以通过访问这个变量来获取到当前用户,如果用户未登录,获取到的就是一个匿名用户,它的定义:
current_user = LocalProxy(lambda: _get_user())
_get_user()
方法之前已经讲过,我们直接跳到 _load_user
方法。显然,如果用户登录后再次发出了请求,我们就要从 cookie,或者说,Flask 在此之上封装的 session 中获取用户信息才能正确地进行后续处理,_load_user
方法的作用就是这个,该方法如下:
def _load_user(self):
user_accessed.send(current_app._get_current_object())
config = current_app.config
if config.get('SESSION_PROTECTION', self.session_protection):
deleted = self._session_protection()
if deleted:
return self.reload_user()
is_missing_user_id = 'user_id' not in session
if is_missing_user_id:
cookie_name = config.get('REMEMBER_COOKIE_NAME', COOKIE_NAME)
header_name = config.get('AUTH_HEADER_NAME', AUTH_HEADER_NAME)
has_cookie = (cookie_name in request.cookies and
session.get('remember') != 'clear')
if has_cookie:
return self._load_from_cookie(request.cookies[cookie_name])
elif self.request_callback:
return self._load_from_request(request)
elif header_name in request.headers:
return self._load_from_header(request.headers[header_name])
return self.reload_user()
def _session_protection(self):
sess = session._get_current_object()
ident = self._session_identifier_generator()
app = current_app._get_current_object()
mode = app.config.get('SESSION_PROTECTION', self.session_protection)
if sess and ident != sess.get('_id', None):
if mode == 'basic' or sess.permanent:
sess['_fresh'] = False
session_protected.send(app)
return False
elif mode == 'strong':
for k in SESSION_KEYS:
sess.pop(k, None)
sess['remember'] = 'clear'
session_protected.send(app)
return True
return False
该方法首先保证 session 的安全,如果 session 通过了安全验证,就通过 reload_user
方法重载用户,否则检查 session 中是否没有 user_id
来重载用户,如果没有,通过三种不同的方式重载用户。
def reload_user(self, user=None):
ctx = _request_ctx_stack.top
if user is None:
user_id = session.get('user_id')
if user_id is None:
ctx.user = self.anonymous_user()
else:
if self.user_callback is None:
raise Exception(
"No user_loader has been installed for this "
"LoginManager. Add one with the "
"'LoginManager.user_loader' decorator.")
user = self.user_callback(user_id)
if user is None:
ctx.user = self.anonymous_user()
else:
ctx.user = user
else:
ctx.user = user
在这个重载方法中,如果 user_id
不存在,就把匿名用户加载到 _request_ctx_stack.top
,否则根据 user_id
加载用户,若该用户不存在,仍加载匿用户。
之后,current_user
就能获取到用户对象,或者是一个匿名用户对象了。
current_user = LocalProxy(lambda: _get_user())
这个方法先获取当前用户,然后移除 user_id
_fresh
等 session,然后移除 remember
,最后重载当前用户,很明显,重载之后会是一个匿名用户。
def logout_user():
user = _get_user()
if 'user_id' in session:
session.pop('user_id')
if '_fresh' in session:
session.pop('_fresh')
cookie_name = current_app.config.get('REMEMBER_COOKIE_NAME', COOKIE_NAME)
if cookie_name in request.cookies:
session['remember'] = 'clear'
user_logged_out.send(current_app._get_current_object(), user=user)
current_app.login_manager.reload_user()
return True
记得我们之前提到 flask-login 在 Flask 实例的 after_request 钩子上添加了一个用户更新 remember_me cookie 的函数吗,我们显然需要在请求的最后对 remember
进行处理。
def _update_remember_cookie(self, response):
# Don't modify the session unless there's something to do.
if 'remember' in session:
operation = session.pop('remember', None)
if operation == 'set' and 'user_id' in session:
self._set_cookie(response)
elif operation == 'clear':
self._clear_cookie(response)
return response
这个函数根据是否要设置 remember
来调用不同的函数
def _set_cookie(self, response):
config = current_app.config
cookie_name = config.get('REMEMBER_COOKIE_NAME', COOKIE_NAME)
duration = config.get('REMEMBER_COOKIE_DURATION', COOKIE_DURATION)
domain = config.get('REMEMBER_COOKIE_DOMAIN')
path = config.get('REMEMBER_COOKIE_PATH', '/')
secure = config.get('REMEMBER_COOKIE_SECURE', COOKIE_SECURE)
httponly = config.get('REMEMBER_COOKIE_HTTPONLY', COOKIE_HTTPONLY)
data = encode_cookie(text_type(session['user_id']))
try:
expires = datetime.utcnow() + duration
except TypeError:
raise Exception('REMEMBER_COOKIE_DURATION must be a ' +
'datetime.timedelta, instead got: {0}'.format(
duration))
response.set_cookie(cookie_name,
value=data,
expires=expires,
domain=domain,
path=path,
secure=secure,
httponly=httponly)
def _clear_cookie(self, response):
config = current_app.config
cookie_name = config.get('REMEMBER_COOKIE_NAME', COOKIE_NAME)
domain = config.get('REMEMBER_COOKIE_DOMAIN')
path = config.get('REMEMBER_COOKIE_PATH', '/')
response.delete_cookie(cookie_name, domain=domain, path=path)
user_id
来记录用户身份,_id
来防止攻击者对 session 的伪造。_request_ctx_stack.top.user
,flask-login 实现了线程安全。其他功能如 fresh login 请自行查看源码了解。
flask-login 虽然好用,但由于其是基于 session 的,对于无状态的 RESTful API 应用无能为力。我在一个最近的项目模仿了它的接口,实现了一个简单但是好用的身份认证模块。
from functools import wraps
from flask import (_request_ctx_stack, has_request_context, request,
current_app)
from flask_restful import abort
from werkzeug.local import LocalProxy
from app.models.user import User
#: a proxy for the current user
#: it would be an anonymous user if no user is logged in
current_user = LocalProxy(lambda: _get_user())
class AnonymousUserMixin(object):
@property
def is_active(self):
return False
@property
def is_authenticated(self):
return False
@property
def is_anonymous(self):
return True
def __repr__(self):
return ''
class Manager(object):
def __init__(self, app=None):
if app:
self.init_app(app)
def init_app(self, app):
app.login_manager = self
app.context_processor(_user_context_processor)
self._anonymous_user = AnonymousUserMixin
self._login_disabled = app.config['LOGIN_DISABLED'] or False
@staticmethod
def _load_user():
"""Try to load user from request.json.token and set it to
`_request_ctx_stack.top.user`. If None, set current user as an anonymous
user.
"""
ctx = _request_ctx_stack.top
json = request.json
user = AnonymousUserMixin()
if json and json.get('token'):
real_user = User.load_user_from_auth_token(json.get('token'))
if real_user:
user = real_user
ctx.user = user
def _get_user():
"""Get current user from request context."""
if has_request_context() and not hasattr(_request_ctx_stack.top, 'user'):
current_app.login_manager._load_user()
return getattr(_request_ctx_stack.top, 'user', None)
def _user_context_processor():
"""A context processor to prepare current user."""
return dict(current_user=_get_user())
def login_user(user):
"""Login a user and return a token."""
_request_ctx_stack.top.user = user
return user.generate_auth_token()
def logout_user(user):
"""For a restful API there shouldn't be a `logout` method because the
server is stateless.
"""
pass
def login_required(func):
"""Decorator to protect view functions that should only be accessed
by authenticated users.
"""
@wraps(func)
def decorated_view(*args, **kwargs):
if current_app.login_manager._login_disabled:
return func(*args, **kwargs)
elif not current_user.is_authenticated:
abort(403, err='40300',
message='Please login before carrying out this action.')
return func(*args, **kwargs)
return decorated_view
我读过很多源码,你可以通过 Source Code Analysis 这个标签找到相关代码解析文章。
This article is posted on Medium. You may like reading it there.
We use icons everywhere in web applications and pages. An icon is a graphical representation of meaning. Icons can be used to express actions, state, and even to categorize data. The Ant Design specification has mature design guidance for icons. And as its implementation for Angular, ng-zorro-antd provides an icon component and hundreds of icons for developers.
In the past, these icons are encapsulated in a font file. However, from v1.7.0 ng-zorro-antd started to use SVG in its icon library @ant-design/icons-angular.
SVG icons are good in many cases compared to font-based icons such as Font Awesome (but it supports SVG icons nowadays, too). It looks sharper on low-resolution devices. It could have multiple colors (ng-zorro-antd does support that). It could be bundled into your JavaScript files so you won’t have to fire another HTTP request. But there’s a problem that is a pain in the ass:
SVG is too big.
The picture shows how bad the situation would get if we do not take the problem seriously. See how much space the icons take. Quote.
A font file is binary so it could be small in size. However, the SVG file is just a plain text file with a unique suffix name (.svg). Though you could gzip it (and web servers like nginx would do that for you), it’s still big and takes a good bite on your bundle size budget. So the challenge for us was,
How can we ship these icons to browsers at the lowest cost without breaking anything?
This article is all about how we managed to do that and what I’ve learned in this journey. If you are building an icon library or just getting interested, keep reading. ;)
There are two simple approaches.
The first is that we package all icons into the component library — just like the old days. But this issue of antd (Ant Design for React) stopped us from taking this approach — users do not want more 500KB of non-tree-shakable code in their bundles. (React community has come up with lots of solutions like this one to shrink the bundle size. But honestly, this is even more troublesome than the second approach that I am going to introduce below.)
The second is that we leave developers to import icons that they are going to use. By doing that, we won’t package anything unnecessary. This approach is neat and “how it should be”. However, we cannot go this way either because our users were used to render icons without importing them beforehand (thanks to the tiny, tiny font file). Adopting this approach would force users to write lots of import XXXIcon
to fix their projects. BREAKING CHANGE ALERT! (BTW, this article is a good tutorial if you want to create an icon library just for yourself.)
The problem seems to get more complicated. It’s a paradox that:
So, are we running into a dead end? (Apparently not, because if so, you were not reading this right now! 😄)
Have you heard of Idle while Urgent or Lazy Initialization? We can instantiate a class just before we are going to use it, and we can also load an icon just before we are going to render one!
That’s why we adopted an approach that we called “dynamic loading”.
You can get the source code of @ant-design/icons-angular here.
Dynamic loading means: when we want to render an icon that hasn’t been loaded, we load it from a remote server, cache it, and then render it.
Let’s explain this idea by diving into the source code. Don’t worry. I won’t cover every detail in this project, just the main process.
When we are going to render an icon, say an outlined “heart”, we first lookup the cache to see whether this icon has cached. If not, we will load it by calling _loadIconDynamically
.
// If `icon` is a `IconDefinition` of successfully fetch, wrap it in an `Observable`.
// Otherwise try to fetch it from remote.
const $iconDefinition = definitionOrNull
? rxof(definitionOrNull)
: this._loadIconDynamically(icon as string);
The request simply asks the server for the icon (SVG string), assemble an icon object, and cache it.
const source = !this._enableJsonpLoading
? this._http
.get(safeUrl, { responseType: 'text' })
.pipe(map(literal => ({ ...icon, icon: literal }))) // assemble an icon object, type as IconDefition
: this._loadIconDynamicallyWithJsonp(icon, safeUrl); // jsonp-like loading
inProgress = source.pipe(
tap(definition => this.addIcon(definition)),
finalize(() => this._inProgressFetches.delete(type)), // delete the request object
catchError(() => rxof(null)),
share() // share with other subscribers
);
Now we get an icon object and area able to continue the rendering. Hooray! 🎊🎊🎊
But that’s just not enough. There are some details worth noticing.
If we have several same icons to render at the same time, it would be costly if we fire HTTP requests for each of them. So these icons should share the same request (named inProgess
). There’s a share operator that will share the icon object to all subscribers. And we remove the request after the request is over.
How could we know where to load icons? Thanks to Angular/CLI, we provide a schematic that could help users add icon assets to their bundle in a convenient way. When a developer is installing ng-zorro-antd with this command ng add ng-zorro-antd
, it would ask if s/he would like to add icons assets and modify angular.json if the developer wants so.
What if users want to load icons from a CDN? We have to make URLs of requests configurable. So we provide a method named changeAssetsSource
for users to set the URL prefix.
What if the CDN doesn’t support cross-domain XML requests? We provide a jsonp-like mechanism for this and developers could enable it by calling useJsonpLoading .
And so on.
So you can see that even the core idea is simple, you must take lots of scenarios into consideration, though some of those you may never run into!
There was still lots of work to do:
i
tag in this way <i class="anticon anticon-clock">
. So we needed to support the old API as well (we used MutationObserver).Finally, I wrote @ant-design/icons-angular and rewrote the Icon component of ng-zorro-antd.
@ant-design/icons-angular, as an underlying dependency, provides icon resources and fundamental features such as static loading, dynamic loading, jsonp-like loading, and namespace.
The Icon component of ng-zorro-antd is responsible for the dirty work (adapting old API), and providing add-on features such as spinning.
Click on an icon add you can get a piece of template rendering that icon. ;)
In October 2018, we release v1.7.0 with the new icon component. And I wrote a detailed upgrade guide to explain why we replaced old font-based icons with SVG icons, and what should developers do if they want to upgrade to the new version. Thankfully, our users felt comfortable with this new version and willingly moved along with us. (You guys are awesome. Thanks!)
As a summary, what have we achieved?
Nice! It looks like we solved the problem in an elegant way. What about performance? You may ask.
Well, the doc webpage of the Icon component which has hundreds of icons is strong proof that the performance is merely harmed. In fact, you may not notice that the icons are loaded dynamically if you only use dozens of them. Your website is a PWA (like our official website)? End of the discussion!
Here is what I learned as an open source library author by working on this project:
That’s all. Thank you for reading.
简要分析了 Koa middleware 的生成器写法。
虽然 Koa 要在下一个 major 版本里移除对生成器 generator 的支持,但是看一看它对生成器的处理还是能够加深我们对生成器的理解的。
Koa 源码中和生成器有关的代码就以下几行,判断 use
方法添加的函数是否是生成器函数,是的话,将它转换成异步函。其中调用的两个函数都是由周边库提供的。
if (isGeneratorFunction(fn)) {
deprecate(
'Support for generators will be removed in v3. ' +
'See the documentation for examples of how to convert old middleware ' +
'https://github.com/koajs/koa/blob/master/docs/migration.md'
)
fn = convert(fn)
}
isGeneratorFunction
这些依赖都是很短小的单文件,不如全部粘贴过来。
判断函数是否是一个生成器函数。
'use strict'
var toStr = Object.prototype.toString
var fnToStr = Function.prototype.toString
var isFnRegex = /^\s*(?:function)?\*/
// 这个似乎是用来检测当前执行环境有没有引入生成器函数,还要再看看
var hasToStringTag =
typeof Symbol === 'function' && typeof Symbol.toStringTag === 'symbol'
var getProto = Object.getPrototypeOf
var getGeneratorFunc = function() {
// eslint-disable-line consistent-return
// 如果没有 hasToStringTag,直接返回 false 表示无法生成生成器函数
if (!hasToStringTag) {
return false
}
// 否则尝试利用 Function 生成一个生成器函数并返回
try {
return Function('return function*() {}')()
} catch (e) {}
}
var generatorFunc = getGeneratorFunc()
// 如果没有返回生成器函数,返回一个空对象,这样最后的判定就会失败
// 如果返回了一个生成器函数,得到生成器函数的原型对象
var GeneratorFunction = generatorFunc ? getProto(generatorFunc) : {}
module.exports = function isGeneratorFunction(fn) {
// 不是函数的话肯定也不是生成器函数
if (typeof fn !== 'function') {
return false
}
// 将这个函数转换成 string,然后查看函数字面中是否包含 function*,有则是一个生成器函数
// 但是这个判断是很不严谨的,因为它强制要求写法为 function*
// 而 function *boo 就没有办法识别了
if (isFnRegex.test(fnToStr.call(fn))) {
return true
}
// 如果上面的方法不行,就尝试利用 toString 的方法
if (!hasToStringTag) {
var str = toStr.call(fn)
return str === '[object GeneratorFunction]'
}
// 最后的方法,通过原型对象判别
return getProto(fn) === GeneratorFunction
}
convert
并不是将生成器函数转换成异步函数,而是让它能融入到 Koa 2.0 的工作流程中。
'use strict'
const co = require('co')
const compose = require('koa-compose')
module.exports = convert
function convert(mw) {
if (typeof mw !== 'function') {
throw new TypeError('middleware must be a function')
}
if (mw.constructor.name !== 'GeneratorFunction') {
// assume it's Promise-based middleware
return mw
}
// 真正核心的代码就这三行
// 返回了一个符合 koa 中间件函数签名要求的函数,这个函数内部调用了 co
const converted = function(ctx, next) {
// co 函数和中间件在执行的时候,绑定上下文到 ctx,也就是 koa 的 context
// mw.call 的时候,返回了一个迭代器,然后 co 去执行这个迭代器,最终返回一个 Promise
// 到这里我们有必要知道 koa 要求如何写一个生成器
return co.call(ctx, mw.call(ctx, createGenerator(next)))
}
converted._name = mw._name || mw.name
return converted
}
// 这里的生成器返回了迭代器,当在用户的生成器函数中调用
function* createGenerator(next) {
return yield next()
}
// 后面两个方法没有用到,省略
在 Koa 1.x 版本中,中间件要求是生成器函数,写法如下:
function* legacyMiddleWare(next) {
yield next
}
可以看到, createGenerator(next)
返回的迭代器就是这里的 next.
co
co 是一个迭代器的执行器,返回一个 Promise.
/**
* slice() reference.
*/
var slice = Array.prototype.slice
/**
* Execute the generator function or a generator
* and return a Promise.
*
* @param {Function} fn
* @return {Promise}
* @api public
*/
function co(gen) {
var ctx = this
var args = slice.call(arguments, 1)
// we wrap everything in a Promise to avoid Promise chaining,
// which leads to memory leak errors.
// see https://github.com/tj/co/issues/180
return new Promise(function(resolve, reject) {
// 如果传入的是一个生成器,那么调用这个生成器以得到一个迭代器
if (typeof gen === 'function') gen = gen.apply(ctx, args)
// 如果不存在迭代器或者迭代器没有 next,那么直接返回一个 resolved 状态的 Promise
if (!gen || typeof gen.next !== 'function') return resolve(gen)
// 执行这个迭代器
onFulfilled()
/**
* 对迭代器进行一次 next 调用
*
* @param {Mixed} res
* @return {Promise}
* @api private
*/
function onFulfilled(res) {
var ret
try {
// 利用上次得到的结果对迭代器进行 next 调用,得到 yield 出的返回值
ret = gen.next(res)
} catch (e) {
// 有错直接返回出一个拒绝态的 Promise
return reject(e)
}
// 如果没出错就通过 next 进行处理
next(ret)
}
/**
* @param {Error} err
* @return {Promise}
* @api private
*/
function onRejected(err) {
var ret
try {
// 如果出错的话会调用迭代器的 throw 尝试解决错误
ret = gen.throw(err)
} catch (e) {
return reject(e)
}
next(ret)
}
/**
* Get the next value in the generator,
* return a Promise.
*
* 得到迭代器的下一个值,并返回一个 Promise
*
* @param {Object} ret
* @return {Promise}
* @api private
*/
function next(ret) {
// 如果迭代已执行完成,返回一个 resolved 状态的 Promise, resolved undefined
if (ret.done) return resolve(ret.value)
// 否则将 value 包装成一个 Promise
var value = toPromise.call(ctx, ret.value)
// 如果是包装了 truthy 值的 Promise,那么通过 then 来后处理
// 这里的 value 实际是 createGenerator 返回的迭代器封装好的 Promise
if (value && isPromise(value)) return value.then(onFulfilled, onRejected)
// 如果不能封装为 Promise 则抛出错误
return onRejected(
new TypeError(
'You may only yield a function, Promise, generator, array, or object, ' +
'but the following object was passed: "' +
String(ret.value) +
'"'
)
)
}
})
}
/**
* Convert a `yield`ed value into a Promise.
*
* 针对 yield 的 value 可能具有的不同情形来封装成 Promise
*
* @param {Mixed} obj
* @return {Promise}
* @api private
*/
function toPromise(obj) {
// 如果为 falsy 直接返回
if (!obj) return obj
// 如果是 Promise 直接返回
if (isPromise(obj)) return obj
// 如果是迭代器或者是生成器就用 co 再执行
// 实际上 koa 走的是这个分支,它会再用 co 执行这个迭代器,返回 Promise
// 迭代器在执行的时候,就往 koa middleware 的下游走
if (isGeneratorFunction(obj) || isGenerator(obj)) return co.call(this, obj)
// 如果是一个 function 那么就通过 thunkToPromise 封装,不展开了
if ('function' == typeof obj) return thunkToPromise.call(this, obj)
if (Array.isArray(obj)) return arrayToPromise.call(this, obj)
if (isObject(obj)) return objectToPromise.call(this, obj)
return obj
}
/**
* Convert a thunk to a Promise.
*
* 其他的辅助方法从略,只看看这个。
*
* @param {Function}
* @return {Promise}
* @api private
*/
function thunkToPromise(fn) {
var ctx = this
return new Promise(function(resolve, reject) {
fn.call(ctx, function(err, res) {
if (err) return reject(err)
if (arguments.length > 2) res = slice.call(arguments, 1)
resolve(res)
})
})
}
convert
的执行过程return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
时,执行 return co.call(ctx, mw.call(ctx, createGenerator(next)))
,它返回一个 Promisemw
被调用,同时调用 createGenerator(next)
返回一个迭代器onFulfilled
方法执行用户的迭代。用户会写 yield next
这一句,将控制权交还给 co, co 调用 next
方。此时,由于 ret.value
是 createGenerator(next)
返回的迭代器,所以 next
方法进入 if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
的分支value
被封装成一个 Promise,其实内部又用了一次 co 对 return yield next
进行执行return yield next
被执行,进入下游 middleware 并最终回溯到当前的 middlewareonFulfilled
,然后调用 next
方法,此时 ret.done
为真,返回一个解决态的 Promisereturn Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
,继续往上游回溯到这里,我们就梳理清楚了 Koa 1.x 时代所采用的生成器函数是如何被 Koa 2.x 所采用的异步函数兼容的。
可能需要画张图来更清楚地展示这个过程。
本文简单的分析了 Flask 的源码,主要关注 WSGI、Flask 对象的数据结构、Flask 应用启动过程、请求处理过程、视图函数、URL 的映射、应用上下文和请求上下文。
这是 Flask 官方钦定的 Demo 代码:
from flask import Flask
app = Flask(__name__)
@app.route(‘/‘)
def index():
return ‘Hello, world!’
if __name__ == ‘__main__’:
app.run()
这篇文章从这个简单的代码开始,简要介绍了 WSGI、Flask 对象的数据结构、Flask 应用启动过程、请求处理过程、视图函数、URL 的映射、request 和 response 类(应用上下文和请求上下文),这些主题涵盖了一个 web 框架的核心。
在用户发起的请求到达服务器之后,会被一个 HTTP 服务器所接收,然后交给 web 应用程序做业务处理,这样 HTTP 服务器和 web 应用之间就需要一个接口,在 Python web 开发的世界里,Python 官方钦定了这个接口并命名为 WSGI,由 PEP333 所规定。只要服务器和框架都遵守这个约定,那么就能实现服务器和框架的任意组合。按照这个规定,一个面向 WSGI 的框架必须要实现这个方法:
def application(environ, start_response)
在工作过程中,HTTP 服务器会调用上面这个方法,传入请求信息,即名为 environ
的字典和 start_response
函数,应用从 environ
中获取请求信息,在进行业务处理后调用 start_response
设置响应头,并返回响应体(必须是一个可遍历的对象,比如列表、字典)给 HTTP 服务器,HTTP 服务器再返回响应给用户。
所以 Flask 作为一个开发 web 应用的 web 框架,负责解决的问题就是:
__call__
方法下面就来看看 Flask 是如何解决这些问题的。
参考阅读:一起写一个 web 服务器,该系列文章能够让你基本理解 web 服务器和框架是如何通过 WSGI 协同工作的。
源码阅读:
app.py
中Flask
类的代码。
Demo 代码的第二行创建了一个 Flask 类的实例,传入的参数是当前模块的名字。我们先来看看 Flask 应用到底是什么,它的数据结构是怎样的。
Flask
是这样一个类:
The flask object implements a WSGI application and acts as the central
object. It is passed the name of the module or package of the
application. Once it is created it will act as a central registry for
the view functions, the URL rules, template configuration and much more.
The name of the package is used to resolve resources from inside the
package or the folder the module is contained in depending on if the
package parameter resolves to an actual python package (a folder with
an__init__.py
file inside) or a standard module (just a.py
file).
一个 Flask 对象实际上是一个 WSGI 应用。它接收一个模块或包的名字作为参数。它被创建之后,所有的视图函数、URL 规则、模板设置等都会被注册到它上面。之所以要传入模块或包的名字,是为了定位一些资源。
Flask 类有这样一些属性:
request_class = Request
设置请求的类型response_class = Response
设置响应的类型这两个类型都来源于它的依赖库 werkzeug
并做了简单的拓展。
Flask 对象的 __init__
方法如下:
def __init__(self, package_name):
#: Flask 对象有这样一个字典来保存所有的视图函数
self.view_functions = {}
#: 这个字典用来保存所有的错误处理视图函数
#: 字典的 key 是错误类型码
self.error_handlers = {}
#: 这个列表用来保存在请求被分派之前应当执行的函数
self.before_request_funcs = []
#: 在接收到第一个请求的时候应当执行的函数
self.before_first_request_funcs = []
#: 这个列表中的函数在请求完成之后被调用,响应对象会被传给这些函数
self.after_request_funcs = []
#: 这里设置了一个 url_map 属性,并把它设置为一个 Map 对象
self.url_map = Map()
到这里一个 Flask 对象创建完毕并被变量 app
所指向,其实它就是一个保存了一些配置信息,绑定了一些视图函数并且有个 URL 映射对象(url_map
)的对象。但我们还不知道这个 Map 对象是什么,有什么作用,从名字上看,似乎其作用是映射 URL 到视图函数。源代码第 21 行有 from werkzeug.routing import Map, Rule
,那我们就来看看 werkzeug
这个库中对 Map 的定义:
The map class stores all the URL rules and some configuration
parameters. Some of the configuration values are only stored on the
Map
instance since those affect all rules, others are just defaults
and can be overridden for each rule. Note that you have to specify all
arguments besides therules
as keyword arguments!
可以看到这个类的对象储存了所有的 URL 规则和一些配置信息。由于 werkzeug
的映射机制比较复杂,我们下文中讲到映射机制的时候再深入了解,现在只要记住 Flask 应用(即一个 Flask
类的实例)存储了视图函数,并通过 url_map
这个变量存储了一个 URL 映射机构就可以了。
源码阅读:
app.py
中Flask
类的代码和werkzeug.serving
的代码,特别注意run_simple
BaseWSGIServer
WSGIRequestHandler
。
Demo 代码的第 6 行是一个限制,表示如果 Python 解释器是直接运行该文件或包的,则运行 Flask 程序:在 Python 中,如果直接执行一个模块或包,那么解释器就会把当前模块或包的 __name__
设置为为 __main_
。
第 7 行中的 run
方法启动了 Flask 应用:
def run(self, host=None, port=None, debug=None, **options):
from werkzeug.serving import run_simple
if host is None:
host = '127.0.0.1'
if port is None:
server_name = self.config['SERVER_NAME']
if server_name and ':' in server_name:
port = int(server_name.rsplit(':', 1)[1])
else:
port = 5000
if debug is not None:
self.debug = bool(debug)
options.setdefault('use_reloader', self.debug)
options.setdefault('use_debugger', self.debug)
try:
run_simple(host, port, self, **options)
finally:
# reset the first request information if the development server
# reset normally. This makes it possible to restart the server
# without reloader and that stuff from an interactive shell.
self._got_first_request = False
可以看到这个方法基本上是在配置参数,实际上启动服务器的是 werkzeug
的 run_simple
方法,该方法在默认情况下启动了服务器 BaseWSGIServer
,继承自 Python 标准库中的 HTTPServer.TCPServer
。注意在调用 run_simple
时,Flask 对象把自己 self
作为参数传进去了,这是正确的,因为服务器在收到请求的时候,必须要知道应该去调用谁的 __call__
方法。
按照标准库中 HTTPServer.TCPServer
的模式,服务器必须有一个类来作为 request handler 来处理收到的请求,而不是由 HTTPServer.TCPServer
本身的实例来处理,werkzeug
提供了 WSGIRequestHandler
类来作为 request handler,这个类在被 BaseWSGIServer
调用时,会执行这个函数:
def execute(app):
application_iter = app(environ, start_response)
try:
for data in application_iter:
write(data)
if not headers_sent:
write(b'')
finally:
if hasattr(application_iter, 'close'):
application_iter.close()
application_iter = None
函数的第一行就是按照 WSGI 要求的,调用了 app 并把 environ
和 start_response
传入。我们再看看 flask 中是如何按照 WSGI 要求对服务器的调用进行呼应的。
def __call__(self, environ, start_response):
return self.wsgi_app(environ, start_response)
def wsgi_app(self, environ, start_response):
ctx = self.request_context(environ)
ctx.push()
error = None
try:
try:
response = self.full_dispatch_request()
except Exception as e:
error = e
response = self.handle_exception(e)
return response(environ, start_response)
finally:
if self.should_ignore_error(error):
error = None
ctx.auto_pop(error)
可以看到 Flask 按照 WSGI 的要求实现了 __call__
方法,因此成为了一个可调用的对象。但它不是在直接在 __call__
里写逻辑的,而是调用了 wsgi_app
方法,这是为了中间件的考虑,不展开谈了。这个方法返回的 response(environ, start_response)
中,response
是 werkzueg.response
类的一个实例,它也是个可以调用的对象,这个对象会负责生成最终的可遍历的响应体,并调用 start_response
形成响应头。
源码阅读:
app.Flask
的代码。
def wsgi_app(self, environ, start_response):
ctx = self.request_context(environ)
ctx.push()
error = None
try:
try:
response = self.full_dispatch_request()
except Exception as e:
error = e
response = self.handle_exception(e)
return response(environ, start_response)
finally:
if self.should_ignore_error(error):
error = None
ctx.auto_pop(error)
wsgi_app
方法中里面的内容就是对请求处理过程的一个高度抽象。
首先,在接收到服务器传过来的请求时,Flask 调用 request_context
函数建立了一个 RequestContext
请求上下文对象,并把它压入 _request_ctx_stack
栈。关于上下文和栈的内容下文会再讲到,你现在需要知道的是,这些操作是为了 flask 在处理多个请求的时候不会混淆。之后,Flask 会调用 full_dispatch_request
方法对这个请求进行分发,开始实际的请求处理过程,这个过程中会生成一个响应对象并最终通过调用 response
对象来返回给服务器。如果当中出错,就声称相应的错误信息。不管是否出错,最终 Flask 都会把请求上下文推出栈。
full_dispatch_request
是请求分发的入口,我们再来看它的实现:
def full_dispatch_request(self):
self.try_trigger_before_first_request_functions()
try:
rv = self.preprocess_request()
if rv is None:
rv = self.dispatch_request()
except Exception as e:
rv = self.handle_user_exception(e)
return self.finalize_request(rv)
首先调用 try_trigger_before_first_request_functions
方法来尝试调用 before_first_request
列表中的函数,如果 Flask
的 _got_first_request
属性为 False
,before_first_request
中的函数就会被执行,执行一次之后,_got_first_request
就会被设置为 True
从而不再执行这些函数。
然后调用 preprocess_request
方法,这个方法调用 before_request_funcs
列表中所有的方法,如果这些 before_request_funcs
方法中返回了某种东西,那么就不会真的去分发这个请求。比如说,一个 before_request_funcs
方法是用来检测用户是否登录的,如果用户没有登录,那么这个方法就会调用 abort
方法从而返回一个错误,Flask 就不会分发这个请求而是直接报 401 错误。
如果 before_request_funcs
中的函数没有返回,那么再调用 dispatch_request
方法进行请求分发。这个方法首先会查看 URL 规则中有没有相应的 endpoint
和 value
值,如果有,那么就调用 view_functions
中相应的视图函数(endpoint
作为键值)并把参数值传入(**req.view_args
),如果没有就由 raise_routing_exception
进行处理。视图函数的返回值或者错误处理视图函数的返回值会返回给 wsgi_app
方法中的 rv
变量。
def dispatch_request(self):
req = _request_ctx_stack.top.request
if req.routing_exception is not None:
self.raise_routing_exception(req)
rule = req.url_rule
if getattr(rule, 'provide_automatic_options', False) \
and req.method == 'OPTIONS':
return self.make_default_options_response()
return self.view_functions[rule.endpoint](**req.view_args)
def finalize_request(self, rv, from_error_handler=False):
response = self.make_response(rv)
try:
response = self.process_response(response)
request_finished.send(self, response=response)
except Exception:
if not from_error_handler:
raise
self.logger.exception('Request finalizing failed with an '
'error while handling an error')
return response
def make_response(self, rv):
if isinstance(rv, self.response_class):
return rv
if isinstance(rv, basestring):
return self.response_class(rv)
if isinstance(rv, tuple):
return self.response_class(*rv)
return self.response_class.force_type(rv, request.environ)
然后 Flask 就会根据 rv
生成响应,这个 make_response
方法会查看 rv 是否是要求的返回值类型,否则生成正确的返回类型。比如 Demo 中返回值是字符串,就会满足 isinstance(rv, basestring)
判断并从字符串生成响应。这一步完成之后,Flask 查看是否有后处理视图函数需要执行(在 process_response
方法中),并最终返回一个完全处理好的 response
对象。
在请求处理过程一节中,我们已经看到了 Flask 是如何调用试图函数的,这一节我们要关注 Flask 如何构建和请求分派相关的数据结构。我们将主要关注 view_functions
,因为其他的数据结构如 before_request_funcs
的构建过程大同小异,甚至更为简单。我们也将仔细讲解在应用的创建一节中遗留的问题,即 url_map
到底是什么。
Demo 代码的第 4 行用修饰器 route
注册一个视图函数,这是 Flask 中受到广泛称赞的一个设计。在 Flask 类的 route
方法中,可以看到它调用了 add_url_rule
方法。
def route(self, rule, **options):
def decorator(f):
endpoint = options.pop('endpoint', None)
self.add_url_rule(rule, endpoint, f, **options)
return f
return decorator
def add_url_rule(self, rule, endpoint, **options):
if endpoint is None:
endpoint = _endpoint_from_view_func(view_func)
options['endpoint'] = endpoint
methods = options.pop('methods', None)
if methods is None:
methods = getattr(view_func, 'methods', None) or ('GET',)
if isinstance(methods, string_types):
raise TypeError('Allowed methods have to be iterables of strings, '
'for example: @app.route(..., methods=["POST"])')
methods = set(item.upper() for item in methods)
required_methods = set(getattr(view_func, 'required_methods', ()))
provide_automatic_options = getattr(view_func,
'provide_automatic_options', None)
if provide_automatic_options is None:
if 'OPTIONS' not in methods:
provide_automatic_options = True
required_methods.add('OPTIONS')
else:
provide_automatic_options = False
methods |= required_methods
rule = self.url_rule_class(rule, methods=methods, **options)
rule.provide_automatic_options = provide_automatic_options
self.url_map.add(rule)
if view_func is not None:
old_func = self.view_functions.get(endpoint)
if old_func is not None and old_func != view_func:
raise AssertionError('View function mapping is overwriting an '
'existing endpoint function: %s' % endpoint)
self.view_functions[endpoint] = view_func
这个方法负责注册视图函数,并实现 URL 到视图函数的映射。首先,它要准备好一个视图函数所支持的 HTTP 方法(基本上一半多的代码都是在做这个),然后通过 url_rule_class
创建一个 rule
对象,并把这个对象添加到自己的 url_map
里。我们那个遗留问题在这里就得到解答了:rule
对象是一个保存合法的(Flask 应用所支持的) URL、方法、endpoint
(在 **options
中) 及它们的对应关系的数据结构,而 url_map
是保存这些对象的集合。然后,这个方法将视图函数添加到 view_functions
当中,endpoint
作为它的键,其值默认是函数名。
我们再来深入了解一下 rule
,它被定义在 werkzeug.routing.Rule
中:
A Rule represents one URL pattern. There are some options for
Rule
that change the way it behaves and are passed to theRule
constructor.
一个 Rule 对象代表了一种 URL 模式,可以通过传入参数来改变它的许多行为。
Rule 的 __init__
方法为:
def __init__(self, string, defaults=None, subdomain=None, methods=None,
build_only=False, endpoint=None, strict_slashes=None,
redirect_to=None, alias=False, host=None):
if not string.startswith('/'):
raise ValueError('urls must start with a leading slash')
self.rule = string
self.is_leaf = not string.endswith('/')
self.map = None
self.strict_slashes = strict_slashes
self.subdomain = subdomain
self.host = host
self.defaults = defaults
self.build_only = build_only
self.alias = alias
if methods is None:
self.methods = None
else:
if isinstance(methods, str):
raise TypeError('param `methods` should be `Iterable[str]`, not `str`')
self.methods = set([x.upper() for x in methods])
if 'HEAD' not in self.methods and 'GET' in self.methods:
self.methods.add('HEAD')
self.endpoint = endpoint
self.redirect_to = redirect_to
if defaults:
self.arguments = set(map(str, defaults))
else:
self.arguments = set()
self._trace = self._converters = self._regex = self._weights = None
一个 Rule 被创建后,通过 Map
的 add
方法被绑定到 Map
对象上,我们之前说过 flask.url_map
就是一个 Map
对象。
def add(self, rulefactory):
for rule in rulefactory.get_rules(self):
rule.bind(self)
self._rules.append(rule)
self._rules_by_endpoint.setdefault(rule.endpoint, []).append(rule)
self._remap = True
而 Rule
的 bind
方法的内容,就是添加 Rule
对应的 Map
,然后调用 compile
方法生成一个正则表达式,compile
方法比较复杂,就不展开了。
def bind(self, map, rebind=False):
"""Bind the url to a map and create a regular expression based on
the information from the rule itself and the defaults from the map.
:internal:
"""
if self.map is not None and not rebind:
raise RuntimeError('url rule %r already bound to map %r' %
(self, self.map))
self.map = map
if self.strict_slashes is None:
self.strict_slashes = map.strict_slashes
if self.subdomain is None:
self.subdomain = map.default_subdomain
self.compile()
在 Flask 应用收到请求时,这些被绑定到 url_map
上的 Rule
会被查看,来找到它们对应的视图函数。这是在请求上下文中实现的,我们先前在 dispatch_request
方法中就见过——我们是从 _request_ctx_stack.top.request
得到 rule
并从这个 rule
找到 endpoint
,最终找到用来处理该请求的正确的视图函数的。所以,接下来我们需要看请求上下的具体实现,并且看一看 Flask 是如何从 url_map
中找到这个 rule
的。
def dispatch_request(self):
req = _request_ctx_stack.top.request
if req.routing_exception is not None:
self.raise_routing_exception(req)
rule = req.url_rule
if getattr(rule, 'provide_automatic_options', False) \
and req.method == 'OPTIONS':
return self.make_default_options_response()
return self.view_functions[rule.endpoint](**req.view_args)
源码阅读:
ctx.RequestContext
的代码。
请求上下文是如何、在何时被创建的呢?我们先前也见过,在服务器调用应用的时候,Flask 的 wsgi_app
中有这样的语句,就是创建了请求上下文并压栈。
def wsgi_app(self, environ, start_response):
ctx = self.request_context(environ)
ctx.push()
request_context
方法非常简单,就是创建了 RequestContext
类的一个实例,这个类被定义在 flask.ctx
文件中,它包含了一系列关于请求的信息,最重要的是它自身的 request
属性指向了一个 Request
类的实例,这个类继承自 werkzeug.Request
,在 RequestContext
的创建过程中,它会根据传入的 environ
创建一个 werkzeug.Request
的实例。
接着 RequestContext
的 push
方法被调用,这个方法将自己推到 _request_ctx_stack
的栈顶。
_request_ctx_stack
被定义在 flask.global
文件中,它是一个 LocalStack
类的实例,是 werkzeug.local
所实现的,如果你对 Python 的 threading 熟悉的话,就会发现这里实现了线程隔离,就是说,在 Python 解释器运行到 _request_ctx_stack
相关代码的时候,解释器会根据当前进程来选择正确的实例。
但是,在整个分析 Flask 源码的过程中,我们也没发现 Flask 在被调用之后创建过线程啊,那么为什么要做线程隔离呢?看我们开头提到的 run
函数,其实它可以传一个 threaded
参数。当不传这个函数的时候,我们启动的是 BasicWSGIServer
,这个服务器是单线程单进程的,Flask 的线程安全自然没有意义,但是当我们传入这个参数的时候,我们启动的是 ThreadedWSGIServer
,这时 Flask 的线程安全就是有意义的了,在其他多线程的服务器中也是一样。
这里,我们通过追踪一个请求到达服务器并返回(当然是通过“变成”一个相应)的旅程,串讲本文的内容。
environ
和 make_response
函数,然后调用了自己身上注册的 Flask 应用。application(environ, make_response)
方法。在 Flask 中,这个方法是个被 __call__
中转的叫做 wsgi_app
的方法。它首先通过 environ
创建了请求上下文,并将它推入栈,使得 flask 在处理当前请求的过程中都可以访问到这个请求上下文。before_first_request_funcs
before_request_funcs
view_functions
中的函数,并最终通过 finalize_request
生成一个 response
对象,当中只要有函数返回值,后面的函数组就不会再执行,after_request_funcs
进行 response
生成后的后处理。response
对象,最终调用了 make_response
函数,并返回了一个可遍历的响应内容。在分析过程中,可以很明显地看出 Flask 和 werkzeug
是强耦合的,实际上 werkzeug
是 Flask 唯一不可或缺的依赖,一些非常细节的工作,其实都是 werkzeug
库完成的,在本文的例子中,它至少做了这些事情:
Response
和 Request
类型供 Flask 使用,在实际开发中,我们在请求和响应对象上的操作,调用的其实是 werkzeug
的方法。_request_ctx_stack
对 Flask 实现线程保护。对于 Flask 的源码解析先暂时到这里。有时间的话,我会分析 Flask 中的模板渲染、import request
、蓝图和一些好用的变量及函数,或者深入分析 werkzeug
库。
function createPerson(name){
var o = new Object()
o.name = name;
o.getName = function(){
console.log(name)
}
return o
}
var o = createPerson('ok')
o.getName()
在编写代码时遇到这个问题,原本以为name不会被打印,但是被打印了.
细细思考觉得并不是作用域的问题.
function createPerson(nameone){
var o = new Object()
o.name = nameone;
o.getName = function(){
console.log(name)
console.log(this.name);
}
return o
}
var o = createPerson('lukun')
o.getName()
所以,我想验证一下是否是闭包产生的错误.
这是我认为产生这个现象的原因.
最近在阅读 vscode 的源代码。我将会在 blog 中逐步公开一些整理过的学习笔记和大家交流。
在阅读这篇文章之前,你需要依赖注入模式有基本的了解。和依赖注入相关的项目包括 Angular、NestJS 和 InversifyJS。
vscode 项目中,对象基本都是通过依赖注入模式构造的。比如编辑器实例 CodeApplication 的 constructor 如下,所有被装饰的参数都是依赖注入项:
如果你还不了解 TypeScript 装饰器,你可以先阅读官方文档。
export class CodeApplication extends Disposable {
constructor(
private readonly mainIpcServer: Server,
private readonly userEnv: IProcessEnvironment,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@ILogService private readonly logService: ILogService,
@IEnvironmentService private readonly environmentService: IEnvironmentService,
@ILifecycleMainService private readonly lifecycleMainService: ILifecycleMainService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IStateService private readonly stateService: IStateService
) {
// ...
}
}
CodeMain
类将会在应用初始化的时候实例化该类:
await instantiationService.invokeFunction(async accessor => {
// ...
// 进行实例化,可以看到除了要被构造的类 CodeApplication 之外
// 剩下参数的数目和 constructor 中未被装饰的参数的数目一致
return instantiationService.createInstance(CodeApplication, mainIpcServer, instanceEnvironment).startup();
});
我们提炼出在 vsocde 中使用依赖注入模式的三个要素:
InstantiationService
,提供方法实例化类,并且也是依赖注入项所存放的位置下面介绍一些实现细节。
所有 identifier 均由 createDecorator 方法创建,比如 IInstantiationService。
export const IInstantiationService = createDecorator<IInstantiationService>('instantiationService');
TypeScript 允许同名的类型声明和变量声明,这就是为什么 IInstantiation
同时可以作为装饰器函数的名称和接口的名称。
createDecorator
方法的内容如下:
export function createDecorator<T>(serviceId: string): ServiceIdentifier<T> {
// 装饰器会被缓存
if (_util.serviceIds.has(serviceId)) {
return _util.serviceIds.get(serviceId)!;
}
// 装饰器
const id = <any>function (target: Function, key: string, index: number): any {
if (arguments.length !== 3) {
throw new Error('@IServiceName-decorator can only be used to decorate a parameter');
}
storeServiceDependency(id, target, index, false);
};
id.toString = () => serviceId;
_util.serviceIds.set(serviceId, id);
return id;
}
function storeServiceDependency(id: Function, target: Function, index: number, optional: boolean): void {
// 在被装饰的类上记录一个依赖项
if ((target as any)[_util.DI_TARGET] === target) {
(target as any)[_util.DI_DEPENDENCIES].push({ id, index, optional });
} else {
(target as any)[_util.DI_DEPENDENCIES] = [{ id, index, optional }];
(target as any)[_util.DI_TARGET] = target;
}
}
可以看到代码的核心是实现了一个装饰器函数 id
,在装饰器被应用的时候,它就会调用 storeServiceDependency
方法在被装饰的类(比如 CodeApplication
)上记录依赖项,包括装饰器本身(id
),参数的下标(index
)以及是否可选(optional
)。
当类声明的时候,装饰器就会被应用(我做了一个简单的证明),所以在有类被实例化之前依赖关系就已经确定好了。
InstantiantionService 用于提供依赖注入项,也就是起到依赖注入框架中的注入器(Injector)的功能,它以 identifier
为 key 在自身的 _services 属性中保存了各个依赖项的实例。
它暴露了三个公开方法:
createInstance
,该方法接受一个类以及构造该类的非依赖注入参数,然后创建该类的实例。invokeFunction
,该方法接受一个回调函数,该回调函数通过 acessor 参数可以访问该 InstantiationService 中的所有依赖注入项。createChild
,该方法接受一个依赖项集合,并创造一个新的 InstantiationService,说明 vscode 的依赖注入机制也是有层次的。_createInstance
方法是实例化的核心方法:
private _createInstance<T>(ctor: any, args: any[] = [], _trace: Trace): T {
// arguments defined by service decorators
let serviceDependencies = _util.getServiceDependencies(ctor).sort((a, b) => a.index - b.index);
let serviceArgs: any[] = [];
for (const dependency of serviceDependencies) {
let service = this._getOrCreateServiceInstance(dependency.id, _trace);
if (!service && this._strict && !dependency.optional) {
throw new Error(`[createInstance] ${ctor.name} depends on UNKNOWN service ${dependency.id}.`);
}
serviceArgs.push(service);
}
let firstServiceArgPos = serviceDependencies.length > 0 ? serviceDependencies[0].index : args.length;
// check for argument mismatches, adjust static args if needed
if (args.length !== firstServiceArgPos) {
console.warn(`[createInstance] First service dependency of ${ctor.name} at position ${
firstServiceArgPos + 1} conflicts with ${args.length} static arguments`);
let delta = firstServiceArgPos - args.length;
if (delta > 0) {
args = args.concat(new Array(delta));
} else {
args = args.slice(0, firstServiceArgPos);
}
}
// now create the instance
return <T>new ctor(...[...args, ...serviceArgs]);
}
这个方法首先通过 getServiceDependencies 获取被构造类的依赖,这里获取到的依赖就是我们在声明该类的时候就已经通过 storeServiceDependency
所注册的(见上文)。
然后通过 _getOrCreateServiceInstance
根据方法 indentifer
拿到 _services
中注册的依赖项,如果拿不到的话就构建一个(我们先假设我们总是能拿到所需要的依赖注册项,需要现场构建的情形我们会在后面的小节中说明),拿到的依赖项会被 push 到 serviceArgs
数组当中。
然后会进行 constructor 参数处理。总而言之,args 数组的长度应该满足被构造的类声明的非注入参数的数量,这样才能确保依赖注入的参数和非依赖注入的参数都能被送到构造函数中正确的顺序上。
最后用实例化目标类。
我们先前提到在调用 _getOrCreateServiceInstance
时可能会拿不到依赖注入项而需要现场构建一个,下面是具体的实现过程。
首先会调用 _getServiceInstanceOrDescriptor
尝试拿到已经注册的实例,或者是一个 SyncDescriptor 对象。SyncDescriptor
是什么东西呢,它其实就是封装了实例构造参数的一个数据对象,包括以下属性:
ctor
将要被构造的类staticArguments
被传入这个类的参数,和上文中的 args 意义相同supportsDelayedInstantiation
是否支持延迟实例化使用起来就像这样:
services.set(ILifecycleMainService, new SyncDescriptor(LifecycleMainService));
表示的其实就是 “不立刻实例化这个类,而当需要被注入的时候再进行实例化”。
拿到了 SyncDescriptor
之后,会通过 _createAndCacheServiceInstance 方法先实例化这个依赖项,它的代码如下:
private _createAndCacheServiceInstance<T>(id: ServiceIdentifier<T>, desc: SyncDescriptor<T>, _trace: Trace): T {
type Triple = { id: ServiceIdentifier<any>, desc: SyncDescriptor<any>, _trace: Trace };
const graph = new Graph<Triple>(data => data.id.toString());
let cycleCount = 0;
const stack = [{ id, desc, _trace }];
while (stack.length) {
const item = stack.pop()!;
graph.lookupOrInsertNode(item);
// a weak but working heuristic for cycle checks
if (cycleCount++ > 150) {
throw new CyclicDependencyError(graph);
}
// check all dependencies for existence and if they need to be created first
for (let dependency of _util.getServiceDependencies(item.desc.ctor)) {
let instanceOrDesc = this._getServiceInstanceOrDescriptor(dependency.id);
if (!instanceOrDesc && !dependency.optional) {
console.warn(`[createInstance] ${id} depends on ${dependency.id} which is NOT registered.`);
}
if (instanceOrDesc instanceof SyncDescriptor) {
const d = { id: dependency.id, desc: instanceOrDesc, _trace: item._trace.branch(dependency.id, true) };
graph.insertEdge(item, d);
stack.push(d);
}
}
}
while (true) {
const roots = graph.roots();
// if there is no more roots but still
// nodes in the graph we have a cycle
if (roots.length === 0) {
if (!graph.isEmpty()) {
throw new CyclicDependencyError(graph);
}
break;
}
for (const { data } of roots) {
// create instance and overwrite the service collections
const instance = this._createServiceInstanceWithOwner(data.id, data.desc.ctor, data.desc.staticArguments, data.desc.supportsDelayedInstantiation, data._trace);
this._setServiceInstance(data.id, instance);
graph.removeNode(data);
}
}
return <T>this._getServiceInstanceOrDescriptor(id);
}
这里有两个 while
,分别做了以下这几件事情:
while
是利用 DFS 的方法,找到一个类的所有未实例化的依赖(还是基于 SyncDescriptor
),以及依赖的未实例化的依赖……最终得到一个依赖图while
根据前一步得到的依赖图,从根节点开始构造实例最后我们就得到了我们最初想要的依赖。
其实利用递归也可以实现。
vscode 在实例化类及其依赖(以及依赖的依赖)的时候并没有使用简单的递归方法,而是利用 Graph 来管理类之间的依赖关系。
假设我们要实例化 A
类,vscode 会监测到它需要依赖 B
和 C
类,并将依赖关系记录到图当中,图中的一个 Node
的 outgoing 属性,就代表了依赖关系。如果这两类都没有实例化,就会重复对这两个类进行此步操作,直到所有类的依赖项都被实例化(或者是没有依赖项为止),比如 D
类,它没有依赖项。
之后,vscode 会从根结点开始将这些类一一实例化,所谓根节点,也就是没有 outgoing 的节点,即没有依赖项的节点。
在 vscode 中,有的依赖是全局唯一、单例的,即在 JavaScript 线程中该类最多只有一个实例(这在 render process 中用得非常多,我们以后会讲解什么是 render process)。vscode 提供了一个简单的机制实现全局单例依赖。
比方说我们想要创建一个单例的生命周期依赖,就这样做:
registerSingleton(ILifecycleService, BrowserLifecycleService);
即调用 registerSingleton
方法,将 identifier
和具体的实现类绑定即可。
而 registerSingleton 的实现也异常简单,仅仅是在一个数组中保存一条记录。
const _registry: [ServiceIdentifier<any>, SyncDescriptor<any>][] = [];
export function registerSingleton<T, Services extends BrandedService[]>(id: ServiceIdentifier<T>, ctor: { new(...services: Services): T }, supportsDelayedInstantiation?: boolean): void {
_registry.push([id, new SyncDescriptor<T>(ctor, [], supportsDelayedInstantiation)]);
}
export function getSingletonServiceDescriptors(): [ServiceIdentifier<any>, SyncDescriptor<any>][] {
return _registry;
}
在需要用到这些依赖注入项的时候,调用 getSingletonServiceDescriptor
获取这个数组就好。
所以从本质上来说,全局单例依赖注入其实就是把所有的依赖注入项保存在一个全局变量里。
有时候我们想让一个依赖是可选的,即允许依赖不存在。对此 vscode 提供了 optional 方法用于标记可选依赖。
export function optional<T>(serviceIdentifier: ServiceIdentifier<T>) {
return function (target: Function, key: string, index: number) {
if (arguments.length !== 3) {
throw new Error('@optional-decorator can only be used to decorate a parameter');
}
storeServiceDependency(serviceIdentifier, target, index, true);
};
}
可见它与 createDecorator
方法的主要区别在于在调用 storeServiceDependency
的时候第四个参数为 true
。这样当获取不到 serviceIdentifier
所对应的依赖项时,InstantiationService
能够允许这样的情况而不是抛出错误。
在上文中,我们看到 SyncDecriptor
可以被当作依赖项实例的占位符使用,从而做到在需要依赖它的类被实例化的时候,再进行自身的实例化,即延迟实例化。另外,它还能把实例化过程进一步延迟到访问实例的属性和方法的时候!我们来看看这是如何实现的。
当创建一个 SyncDescriptor
的时候我们可以传参 supportsDelayedInstantiation = true
,比如这里:
registerSingleton(IExtensionGalleryService, ExtensionGalleryService, true);
这样在调用 _createServiceInstance
的时候就会进入 else 分支。
} else {
// Return a proxy object that's backed by an idle value. That
// strategy is to instantiate services in our idle time or when actually
// needed but not when injected into a consumer
const idle = new IdleValue<any>(() => this._createInstance<T>(ctor, args, _trace));
return <T>new Proxy(Object.create(null), {
get(target: any, key: PropertyKey): any {
if (key in target) {
return target[key];
}
let obj = idle.getValue();
let prop = obj[key];
if (typeof prop !== 'function') {
return prop;
}
prop = prop.bind(obj);
target[key] = prop;
return prop;
},
set(_target: T, p: PropertyKey, value: any): boolean {
idle.getValue()[p] = value;
return true;
}
});
}
}
原理是用一个 Proxy 代替实例返回,当需要用到实例上的属性或方法时,再调用 this._createInstance 方法(通过闭包来保存参数)。这里用到了一种被称为 Idle Until Urgent 的模式。
InstatiationService
这个类有很多方法,而且名字都很接近,这里列一个梗概,方便大家阅读源码:
createChild
创建一个子 InstantiationService
invokeFunction
执行一个函数,该函数可以通过 accessor 访问 InstantiationService
里存储的服务createInstance
创建一个服务_createInstance
实例化的最终方法,new
调用的位置_setServiceInstance
将一个创建好的 service set 到保存了对应的 identifier 的 InstantiationService
当中_getServiceInstaneOrDescriptor
根据 identifier 从某个 InstantiationService
中拿到服务实例或者 SyncDescriptor
_getOrCreateServiceInstance
被 invokeFunction
所调用,会尝试调用 _getServiceInstanceOrDescriptor
拿到服务实例,如果拿到的是一个 SyncDescriptor
,则走 _createAndCacheServiceInstance
_createAndCacheServiceInstance
这里根据“要被创建的服务”的“未被实例化的依赖”来构建依赖树,然后依次构建这些未被实例化的依赖_createServiceInstanceWithOwner
寻找保存了对应的 identifier 的 InstantiationService
,调用它的 _createServiceInstance
方法进行实例化_createServiceInstance
这里处理延迟实例化逻辑,调用 _createInstance
的时候,所有依赖应该都已经被实例化,而不是 SyncDescriptor
InstantiationService
是实现依赖注入的核心这篇文章 cover 了 vscode 依赖注入模式的主干部分,能帮助我们理解 vscode 是如何管理各种各样的服务的,也对自行实现依赖注入提供了一些有益的参考。
这篇文章是对之前知乎上对问题“Angular 新手如何有效学习 Angular”的回答的扩充。按照学习的先后顺序、学习的难易程度列出了在学习 Angular 的过程中可能会需要的材料。
angular 新手如何有效学习 angular? - 知乎
目前的版本仍然不完整,还会扩充,建议收藏这个网页。
最近的更新日期是 2019 年 5 月 30 日(见上方编辑日期)。
这一部分和常见的前端入门学习内容并无不同。请确保你掌握了 HTML 和 JavaScript 的基础,一个检查的好办法是看网上的前端面经,会被不同的面试官问到的、框架无关的问题都应该答得上才对。
到这里再学习 Angular 应该已经度过了最困难的阶段了,你要做的是从各种信息源中获取新知识,和深入理解 Angular 原理,学习 Angular 生态链中的其他库,剩下的就是靠做项目来不断提升了。
可能对于在校大学生来说很少有时间或机会参与真正的企业级产品开发,所以建议大家从写开源开始。
Angular 源码解析系列。这篇文章有关于 Zone.js 的用途,实现和 NgZone 的实现,以及 Angular 如何使用 Zone.js 实现自动变更检测。
这篇文章是 Angular 源码解析系列的第一篇,分为以下三个小节:
阅读这篇文章你需要熟悉 JavaScript 的事件循环 (Event Loop) 机制。
现在 Zone.js 的源码已经被放到 Angular 底下以 mono repo 的形式管理,你可以通过下面的链接阅读 Zone.js 的源码。
所有前端框架需要解决的一个共同问题就是:应该何时将应用状态的变化反映到视图中,即变更检测。React 的方案是交给用户自行决定,即让用户通过 setState
方法告诉 React 应用的状态发生了改变;Vue 通过拦截对象的赋值操作来监测状态改变(即所谓响应式);而 Angular 的方案就是 Zone.js。Zone.js 通过给一些会触发异步事件 API 打补丁(monkey patch),比如 XHR、DOM event、定时器等来监听异步事件的编排(比如调用 setTimeout
)和触发(比如 setTimeout
到时),而应用状态的变化一定是某个异步事件的结果,这样 Angular 就可以借助 Zone.js 实现变更检测。
体现在代码中:Angular 应用会在 zone 的 onMicrotaskEmpty
回调中调用 tick
方法,而 tick
方法会调用顶层组件的 detectChanges
方法执行变更检测,就是下面这行代码:
this._zone.onMicrotaskEmpty.subscribe({
next: () => {
this._zone.run(() => {
this.tick()
})
},
})
可以通过看代码注释来了解官方对 Zone.js 作用的描述。
把 Zone.js 中的 zone 想象成 JavaScript VM 线程里的 mini 线程。
JavaScript 是单线程,基于事件循环的,而通过 Zone.js,我们可以把处理事件的回调函数放到不同的 zone 里面执行,而且在当前回调函数内触发的异步事件也会在当前 zone 里面得到处理,即我们给事件的回调函数提供了执行环境。而且,Zone.js 还提供了钩子,允许我们在回调函数执行前后执行额外一些代码(还有其他的一些钩子)。
总而言之——
zone 提供了 JavaScript 异步函数的执行环境。
核心部分实现了 Zone.js 的机制,而不关心各种 patch 该如何实现,代码都在 zone.ts 当中,前面几百行都是接口声明,请自行阅读,本文主要聚焦于其实现。
这个文件主要声明和实现了如下几个类:
Zone
,JavaScript 事件的执行环境,和线程一样,它们可以带一些数据,并且可能拥有父子 zone。ZoneTask
,包装后的异步事件,这些 task 有三种子类:
MicroTask
,由 Promise
创建,我们知道 native 的 Promise
是在当前事件循环结束前就要执行的,所以打过补丁的 Promise 也应该在事件循环结束前执行。MacroTask
,由 setTimeout
等创建,native 的 setTimeout
会在将来某个时间被处理,而且会被处理一到多次。EventTask
,由 addEventListener
等创建,这些 task 可能被触发多次,也可能一直不会被触发。ZoneSpec
,创建一个 zone 时给它提供的参数,除了 name
是必须的外,还可以传入如下的钩子:
onFork
,创建新 zone 的钩子。onIntercept
,包装某个回调函数时触发的钩子。onInvoke
,调用某个回调函数时触发的钩子。onHandleError
,调用某个回调函数出错时触发的钩子。onScheduleTask
,ZoneTask
被安排时触发的钩子,比如调用了 setTimeout
。onInvokeTask
,ZoneTask
被触发时触发的钩子,比如 setTimeout
到时。onCancelTask
,ZoneTask
被取消时触发的钩子,比如用 clearTimeout
取消了计时器。onHasTask
,检测到有或无 ZoneTask
时触发的钩子(即对第一个 schedule 的 zone 和最后一个 invoke 或 cancel 的 task 触发)。ZoneDelegate
,负责调用钩子。官方文档中对这些类所实现的接口有非常详细的注释,可以比较下面的内容阅读。
Zone
类有几个值得关注的静态方法:
get root()
,该方法返回根 zone,所有其他 zone 都是该 zone 的子孙,类似于操作系统的第一个进程。根 zone 是 Zone.js 初始化时自行创建的,相关代码在这里。根 zone 确保了所有的异步函数都在 Zone.js 的机制内运行。get current()
,返回当前 zone,类似于单线程 CPU 中正在占用 CPU 的进程,它本质上是返回闭包内的一个变量 _currentZoneFrame
的引用。get currentTask
,返回当前正被 invoke 的 task。__load_patch
,这是 Zone.js 加载补丁的方法,后面讲解 patch 的加载时会详细说明。这个类的构造函数,要点如下:
parent
变量保存了父 zone,这样 zone 就可以形成一个树型结构。_properties
上作为函数运行的上下文,而 get()
和 getZoneWith()
方法分别用于取得某个 key 所对应的变量和上下文中有某个 key 的 zone。ZoneDelegate
并赋值给 _zoneDelegate
属性。Zone
类还有如下的实例方法:
fork
,创建一个子 zone,相当于 fork 一个进程。wrap
,包裹一个函数,这个被包裹的函数执行时,首先会通过 runGurad
把该函数运行的上下文置换为原来包裹它的 zone,然后通过 ZoneSpec
去执行钩子。run
,立即在当前 zone 执行函数,并调用 ZoneSpec
执行 invoke
钩子。runGuarded
,和 run 做的事情基本相同,不同点在于如果执行过程出错,错误会被 ZoneSpec
注册的 error 钩子先处理,如果 ZoneSpec
error 钩子不能处理,再抛出。runTask
,运行一个 ZoneTask
:
ZoneTask
类的实现。MacroTask
和 EventTask
两种类型的 task 是不需要执行的。ZoneDelegate
的方法来执行 task。reEntryGuard
进行状态保护。scheduleTask
,用来安排一个 task 的执行环境。在阅读代码的时候我们经常能看到
_currentZoneFrame
这个变量,这实际上上记录了一个 zone 的栈(以链表的形式),如果某个 zone 中执行了函数调用,该 zone 就进入这个栈中,这样就将函数的调用栈与进入和离开 zone 的先后顺序对应起来了。该变量被声明在创建 Zone.js 全局对象的函数里(即在一个闭包中),外部没有办法直接访问。
ZoneDelegate
类要点:
ZoneDelegate
的(当然父级也有可能是空的)。这样父级 zone 可以通过 delegate 干预子 zone 的行为。scheduleTask()
,该方法对于 MicroTask
会调用 scheduleMicroTask
。ZoneTask
要点:
_state
,记录了这个 task 的状态。task
为当前 task。static invokeTask()
,该函数执行一个 task:
_numberOfNestedTaskFrames
计数器的值。drainMicroTaskQueue
尝试清空所有的 MicroTask
。_transitionTo
,改变 task 的状态,task 可以从两个源状态转移到一个目标状态,当 zone 的状态不属于两个源状态中的任何一个时,这个方法会抛出错误。除了上面几个重要的类,下面两个方法也值得关注:
scheduleMicroTask
,这个方法是对 Promise 这样的所谓 micro task 的处理。
drainMicroTaskQueue
。drainMicroTaskQueue
。该方法内容十分简单,即尝试对 _microTaskQueue
中的每一个 MicroTask run 一下。
_numberOfNestedTaskFrames === 1
时。我们之前提到了 __load_patch
方法是 Zone.js 用来加载补丁的,这一小节我们将以 setTimeout
为例介绍 Zone.js 如何加载补丁。同时还会结合上一小节的内容,讲解 setTimeout
在 Zone.js 执行的全过程。因为各个 JavaScript runtime 对异步函数的支持情况不尽相同(比如在 Node.js 环境里不可能有 DOM 事件相关的异步函数,如 addEventListener
),所以 Zone.js 会给不同的 runtime 提供不同的 dist 包,patch 不同的异步函数。好在不管在任何环境中,setTimeout
都是存在的。
patch 的具体实现在这里,下面我们将会仔细讲解这部分代码。
首先准备好要 patch 的函数的名称:
setName += nameSuffix // setTimeout
cancelName += nameSuffix // clearTimetout
接下来,调用 patchMethod
方法,传入的三个参数分别是目标对象(被 patch 后的函数应当挂载在目标对象上,因为 setTimeout
其实是 window
的一个属性,所以这里的形参叫做 window
),被 patch 函数的名字,以及一个回调函数,请注意这个回调函数直接返回了另外一个函数(如果你了解什么叫做柯里化,应该很容易理解,其实就是让该函数的执行时能够访问到 delegate
参数):
patchMethod(window, setNmae, (delegate: Function) => function(self: any, args: any[]): void);
来看 patchMethod
方法:
export function patchMethod(
target: any,
name: string,
patchFn: (
delegate: Function,
delegateName: string,
name: string
) => (self: any, args: any[]) => any
): Function | null
首先,该方法从 target
的原型链上找到 name 代表的方法的具体位置(不要忘记 JavaScript 访问对象属性是通过原型链机制进行的),如果找不到这个方法,就直接在 target
上创建 patch 过的方法.
然后,检查 patch 过的方法是否存在,不存在才进行 patch。
在进行 patch 时:
delegate
先指向原生方法。PropertyDescriptor
的 writable
字段),不然就用原来的,相当于没有 patch。patchFn
(这里是一个类似于柯里化的过程),将 patchDelegate
变量指向 patch 过后的函数,然后再将 proto[name]
指向一个新的函数,这里同时确保了 this
能够绑定在正确的对象上(对于 setTimeout
的例子就是 window
)。用户执行 setTimeout
后会发生什么?
我在这里给读者准备了一个简单的例子,通过给 console.log('z')
这一行打断点,就能够看到整个调用栈。
setTimeout
时,实际上执行的是这一行的匿名函数,最终执行了这一行的 function(self: any, args: any[])
scheduleMacroTaskWithCurrentZone
方法,这个函数直接将参数透传给 Zone.current.scheduleTaskMacroTask
Zone.current.scheduleTaskMacroTask
,最终是调用了 ZoneDelegate
的 scheduleTask
方法。scheduleTask
方法将被调用,而这一句 task.scheduleFn(task);
实际上调用了 scheduleTask
函数,最终调用原生的 setTimeout
。handle
,即原生 setTimeout
的返回值。setTimeout
触发时,task.invoke
被调用,最终 ZoneDelegate.invokeTask
被调用。Angular 应用初始化过程中,实例化了一个 NgZone 然后将所有逻辑都跑在该对象的 _inner
对象中,该 _inner
即为 Angular zone。
Angular 创建该 zone 的过程中传入的 ZoneSpec
的部分如下所示:
onHasTask:
(delegate: ZoneDelegate, current: Zone, target: Zone, hasTaskState: HasTaskState) => {
delegate.hasTask(target, hasTaskState);
if (current === target) {
// We are only interested in hasTask events which originate from our zone
// (A child hasTask event is not interesting to us)
if (hasTaskState.change == 'microTask') {
zone.hasPendingMicrotasks = hasTaskState.microTask;
checkStable(zone);
} else if (hasTaskState.change == 'macroTask') {
zone.hasPendingMacrotasks = hasTaskState.macroTask;
}
}
},
在 checkStable
这个方法中你可以看到这样一行:
zone.onMicrotaskEmpty.emit(null)
这就联系到了开篇提到的代码:
this._zone.onMicrotaskEmpty.subscribe({
next: () => {
this._zone.run(() => {
this.tick()
})
},
})
当然 checkStable
方法还有可能在其他时机被调用,主要是通过 Zone.js 的 onInvokeTask
和 onInvoke
两个钩子,即在异步事件触发时,交给读者自行验证,这里就不赘述了。
这篇文章介绍了 Zone.js 的实现,包括 Zone.js 核心和 patch 的实现,还讲解了 Angular 对 Zone.js 的使用。
bootstrapModule
方法的第二个参数传参 { ngZone: 'noop' }
来使用一个在钩子里不做任何事情的 ZoneSpec
。A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.