Git Product home page Git Product logo

blog's Introduction

hullis' blog

blog's People

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

blog's Issues

Angular CDK Drag Drop 源码解析

什么是 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 元素上声明了该指令时,它在初始化过程中做了如下几件事情,参考它的 constructorafterViewInit 钩子:

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)
  1. 实例化 DragRef 对象,拖拽逻辑主要由该对象负责完成。这个对象十分重要,我们待会儿再详细讨论它,先看下其他代码做了什么。
  2. _syncInputs 方法会将指令的一些参数同步到 DragRef 对象上。在该方法中,cdkDrag 订阅了 DragRefbeforeStarted 事件,在收到该事件时调用 DragRef 的一系列以 with 的开头的方法来同步 inputs。记住这一点,后面我们会反复提及。
  3. _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 所依赖。它负责监听 mousemovetouchmove 事件,并确保同一时刻只有一个 DragRef 能够响应这些事件registerDragItem 被调用时,它会注册调用它的 DragRef 对象,然后在某个 DragRef 拖拽时,把光标移动的事件发送给它。

到这里,整个 drag drop 机制就做好了准备来响应用户的操作。

第二阶段:开始拖拽

当用户在 rootElement 上按下光标时,会派发一个 mousedowntouchdown 事件,此时 DragRef_pointerDown 方法就会被调用。

我们先考虑没有 handle 的最简单的情形。

  1. beforeStart 事件被派发出去,此时,就像我们之前提到的那样,所有的 inputs 会从 cdkDrag 同步到 DragRef 上。
  2. 之后,_initializeDragSequance 就会实例化一个 drag sequence,会确保整个机制已经做好准备接收 move 事件的准备。

一个 drag sequance 就是一次拖拽过程。

具体而言,实例化 drag sequence 的过程会做如下的事情:

  1. 首先,它会缓存元素已有的 (往往是用户设置的) transform。在拖拽结束之后,缓存的 transform 会被设置回去。
  2. 然后它订阅了来自 registry 的 move 和 up 事件。

其他的代码和设置状态相关,例如:

  • _hasStartedDragging _hasMoved,字面意思。
  • _pickupPositionInElement。以 rootElement 的左上角为原点,鼠标相对于 rootElement 的位置。
  • _pointerDirectionDelta,一个记录光标移动的矢量。

最后它调用了 registry 的 startDragging 方法,这个方法会绑定 move 和 up 事件的 listener,这样当用户移动光标时,就会触发这两个 listener,然后把事件发送给 dragRef

第三阶段:拖动

现在用户就可以移动元素了,_pointerMove 方法会在用户移动光标时被调用。这个方法做了如下几件事情:

  1. 首先,它会检查光标移动的距离是否超过了设定的阈值。如果超过了,它会将 _hasStartedDragging 标记为 true。
  2. 然后,它会计算 transform,然后将它赋给 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!现在整个拖拽过程就完成了。

其他

这里我们会谈论一些之前跳过的高级内容。

拖拽边界 boundary

当我们在第三阶段中讨论定位问题时我们略过了 _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

对 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 会在 DragHandleconstructor 中被注入:

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。

在某个方向上锁定 axis locking

我们知道了如何将 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 是拖拽逻辑的主要执行者
  • DragDropRegister 负责监听全局的 move 事件并确保界面上同一时刻只有一个元素是可拖拽的
  • drag sequence 是一次拖拽过程的抽象

vscode 源码解析 - 事件模块


在进一步深入学习 vscode 的各种机制之前,我们先对 vscode 当中的一些基础工具做一些探索,因为核心机制大量地用到了这些基础模块,这篇文章将会介绍事件(event)模块,相关代码在 vs/base/common/event.ts 文件中。

Event 模块实现

Event 接口

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,方便调用方决定在什么时候解除监听。

Emitter

有了 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 在注册消费者的时候执行一些额外的逻辑。我们将会在下文中看到其中一些回调所扮演的重要角色。

Event 辅助方法

vscode 还提供了一系列工具方法用于组合 Event ,得到更加丰富的事件处理能力。下面我们一一进行说明。

once

	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 处的匿名函数调用了一个 Eventlistener,而 A 本身又是另一个 Event 的 listener,所以答案是很明显的:消息沿着 Event 链传递的过程,就是 Eventlistener 们递归调用的过程。

snapshot

	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 一起分析。

map

将一种类型的事件转换成另一种类型的事件,看起来和 Arraymap 非常相似。

	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 链的顺序是

  1. map 装饰的 Event A
  2. snapshot 的参数,匿名的 Event B
  3. Emitter 暴露出的 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,也就是 Emitterfire 方法,通过上文中对 Emitter 的学习,我们知道,fire 方法会触发用户调用 C 时所传递来的 listener,这样整个传递链条就完整了。

forEach / filter / reduce

了解了 map 的工作原理之后,这三个函数就容易理解了,大家可以自行阅读代码。

signal

这个函数仅仅是做了一下类型转换,让订阅者忽略事件所携带的数据,比较简单。

any

	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 派发事件的时候派发一个事件。

debounce

对 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 向下游继续发送事件。

stopWatch

这是一个记录耗时的 Event,当它收到第一个事件时,会把这个事件转换为它从创建到收到该事件的耗时。

latch

这个 Event 仅有当事件确实发生变化时,才会向下游发送事件。原理也很简单,就是在 filter 的基础上,利用闭包来缓存上一次事件的数据,然后用新数据和它做比较,新老数据不同或者是第一次接收数据才放通。

buffer

这个 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,则它们都会收到这些缓存的事件。

ChainableEvent

如果要多次使用 map, filter 等函数,一个比较优雅的写法是链式调用,例如 Event.map.filter.xxxChainableEvent 就是为此准备的,通过调用 chain 方法,一个 Event 会转换成 ChainableEvent,然后就可以进行链式调用:

        export function chain<T>(event: Event<T>): IChainableEvent<T> {
		return new ChainableEvent(event);
	}

ChainableEvent 的实现很简单,就是对上面的方法进行了一次包裹,这里就不再赘述了。

除了上面提到的对 Event 的转换方法之外,还有一些生成 Event 的方法。

fromNodeEventEmitter

	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

fromDOMEventEmitter

对 DOM 事件进行包装,和上面的非常相似,这里就不赘述了。

fromPromise

	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 自己用不着吧。

toPromise

将事件转换为 Event,这个也比较简单。

另外,还有一些工具类提供更多的事件管理能力。

PauseableEmitter

类似于 Emitter,但是能通过 pauseresume 方法暂停一条 Event 链上事件的传播,比较简单。

EventMultiplexer

这个类可以订阅多个事件,并在任意一个事件派发的时候,将该事件转发给自己所有的订阅者,它的核心就是它的 hook 方法:

	private hook(e: { event: Event<T>; listener: IDisposable | null; }): void {
		e.listener = e.event(r => this.emitter.fire(r));
	}

也较为简单,这里不再赘述。

EventBufferer

这是一个非常有趣的类,它提供了一个 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 被弹出,其中包含的事件全部被派发。

Relay

这个类提供了切换上游 Event 的方法。当设置 Relayinput 属性时,就会切换监听的 Event,而下游的 Event 监听的是 RelayEmitter,因此无需重新设置监听。

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 事件模块是所谓响应式编程的一种实现,如果想要继续学习响应式编程,非常推荐以下两个项目:

  • RxJS,前端最强大的响应式编程库,很多 RxJS 的概念都可以在 vscode 的事件模块中找到对应,例如 Emitter 十分类似于 Subject, Event 类似于 Observablemap reduce filter 等函数,在 RxJS 中都有同名的操作符,但是 RxJS 更加强大,除了传递事件外,还能够传递异常以及事件流结束信息、支持事件调度等等,操作符更是要多得多
  • callbag,是一套响应式编程规范(注意,不是库),vscode 和 RxJS 的事件链都是“推”机制的,而 callbag 同时支持推拉机制,而且实现上完全基于 JavaScript 强大的闭包机制,喜欢闭包体操的读者可以好好研究作者对几个操作符的实现

vscode 源码解析 - 系列文章目录

由于工作需要和个人兴趣,我从 2019 年 12 月开始有计划地阅读 vscode 的源码并写作一些阅读笔记。

目录

项目架构

依赖注入与服务化

#25 依赖注入
#27 vscode 中的服务

Immer 源码浅析

Immer 是一个非常好玩的库,我在等飞机的时候读了一下它的源码。这篇文章(笔记?)旨在于通过阅读源码分析 Immer 的原理。我仅考虑了最常简单的使用方式,mutate 的对象也只是 plain object 而已,你可以在阅读本文之后再去探究其他 topic。

produce

最 common 的 produce 执行流程:

  1. ImmerScope.scope
  2. base 创建 root proxy,根据 base 的数据类型选择正确的 trap
    1. 对于 Map 和 Set 有对应的 proxy,对于 plain object 使用 object proxy,不支持 Proxy fallback 到 ES5 definePropertypl
  3. result = recipe(proxy),proxy 的 trap 执行变更逻辑
  4. scope.leave
  5. processResult

scope

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) {}
}

createProxy

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
}

objectTraps

这里主要关心 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
	}
}

processResult

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 三者递归调用。

参考资料

Angular CDK Portal 源码解析

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

最重要的文件有以下三个:

  1. portal.ts,这个文件定义了抽象类 PortalBasePortalOutlet,还定义了类 ComponentPortalTemplatePortal
  2. portal-directive.ts,这个文件里定义了指令 CdkPortalCdkPortalOutlet,让我们可以以声明式的方式使用 portal,后者还定义了动态创建内容的机制。
  3. dom-portal-outlet.ts,这个文件里定义了 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,来调用attachComponentPortalattachTemplatePortal 两个方法。
  • 声明了 attachComponentPortalattachTemplatePortal 两个方法。为什么这里没有做实现?是因为对于在 Angular 组件树中的 outlet 和不在组件树中的 outlet,实现是不同的,我们将会看到这一点。
  • 支持了 portal 卸载回调,通过 setDisposeFn,子类可以设置 portal 卸载的时候需要触发的回调函数。

cdkPortalOutlet 有以下要点:

  • 首先这是一个结构型指令,依赖项里有 ComponentFactoryResolverViewContainerRef
  • 有一个 input cdkPortalOutlet,被赋值到 portal 属性上。
  • portal 被赋值的时候,调用父类的 attach 方法。

在例子里,我们的 portal 是一个 ComponentPortal,这里我们就只分析该情形,即 attachComponentPortal 被调用的情形。

  • 准备调用 createComponent 函数所需要的参数,总而言是就是如果 Portal 没有携带这些参数,就用 PortalOutlet 的(组件树中的)位置所能 get 到的参数(呼应了我们前面讲过的要点)。
  • 调用 ViewContainerRefcreateComponent 方法动态创建一个组件。
  • 设置了一个 portal 卸载回调,在 portal 卸载的时候,动态创建的组件也会被销毁。

到这里,就是 Portal 机制的主要运行过程了。

其他

接下来我们 cover 一些之前没有 cover 到的要点。

cdkPortal

cdkPortal 允许使用者以声明式的方式创建一个 TemplatePortal,它的代码非常简单,仅仅是把 TemplatePortal 变成了一个结构型指令。

DomPortalOutlet

我们看到 cdkPortalOutlet 是以声明式方式使用的,这意味着它必须在 Angular 的组件树当中,如果我们想把 portal 挂载到组件树之外的位置,就需要用 DomPortalOutlet

可以看到 DomPortalOutletcdkPortalOutlet 有以下不同:

  • 它无法以声明方式使用,必须指令式地构造,构造函数的第一个参数,是要挂载的 DOM 元素。
  • 在动态创建组件的时候,如果 portal 没有带 ViewContainerRef,就调用 ComponentFactorycreate 方法直接得到组件的 view,挂载在应用的根 view 上,然后手动 append DOM 元素。
  • 当 portal 卸载的时候,也需要手动 detach view

以上不同的根源都是挂载到组件树之外的 portal 可能会没有 ViewContaienerRef

PortalInjector

在创建 ComponentPortal 的时候我们可以传入一个 Injector,而 PortalInjector 允许我们对这个 Injector 作出干预,增加新的 provider。

可以看到它所做的全部事情,其实就是在返回依赖项的时候,检查用户而外提供的 provider 里有没有符合依赖注入令牌的

总结

我们都知道通过 Angular 的 ViewContrainerRef 提供的 createEmbedeViewcreateComponent 方法就可以动态创建界面内容,为什么还需要 CDK 提供的 Portal 呢?通过上文的分析,我们可以得出使用 Portal 的一些好处:

  • 除了可以方便的创建动态内容,以及移除这部分内容。
  • 可以声明式地使用。
  • 可以指定 ViewContainerRefInjector 等,从而改变变更检测的顺序,依赖注入项的获取等等,更加灵活。
  • 可以在 Angular 的组件树之外的位置创建内容。

Portal 被 Angular CDK 的其他很多模块所采用。

vscode 源码解析 - 进程间调用

vscode 的架构中主要有四类进程:主进程、渲染进程、shared 进程和 host 进程,这四个进程之间会发生进程间调用(Inter Process Calling, IPC)。vscode 中有专门的 IPC 模块来实现 IPC 机制,这篇文章将会深入介绍 vscode IPC 模块的设计和原理。

IPC 原理

在我们开始学习 vscode 的 IPC 机制之前,不妨根据我们已经掌握的关于计算机网络的基本知识,来推演一下 IPC 有何要点:

  1. 客户端服务端,客户端向服务端发起请求,请求即是要求调用服务端的某个方法,服务端返回响应,响应即是该方法的返回值
  2. 客户端和服务端需要建立连接
  3. 服务端需要以某种机制分派请求,以找到被调用的方法所在的模块
  4. 请求需要通过某种协议来让双方知道如何解析和生成请求或响应

可以看到 IPC 理念上是比较简单的,而 vscode IPC 模块的优点在于,它清楚地定义了 IPC 模块的各个层次,将客户端的调用过程封装得就像是在调用本地的一个异步方法一样,还让不同的跨进程环境——例如本地进程、基于网络的跨进程、web worker——都能够很容易地实现。

vscode IPC 机制概述

vscode IPC 分为基于 Channel 的基于 RpcProtocol 的两种。

基于 Channel 的机制

我们通过一个例子开始对 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 的实现的话,就会发现它会调用 channelcall 方法,这里就就发起了一个 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 的调用
  • 第二种是基于事件的调用
  • 而切两种调用方式都有对应的取消的办法

我们这篇文章将会以基于 Promise 的调用为例,基于事件的调用大家可以自行了解。

channel IPC 主要有以下这些参与者,它们之间的关系如下图所示:

1r4qaFQIf7Vw7xVCw6kCxOl3A26cIaY-So42x1xb7Yw

服务端

  • 服务,各种业务逻辑实际发生的地方
  • IServerChannel,一个 IServerChannel 和一种服务对应,它们提供了 calllisten 两个方法给 ChannelServer 调用,然后调用它们对应的服务来具体执行各种业务逻辑,实际上是对服务的一种包裹
  • IChannelServer,它负责监听 IMessagePassingProtocol 传过来的请求,然后根据请求中指定的 channelName 来找到 IServerChannel 并进行调用,还能将执行结果返回给客户端
  • IPCServer,它提供了一组注册和获取 IServerChannel 的方法,并能够通过路由机制选择要通讯的客户端
  • IMessagePassingProtocol,它负责传输 Uint8Array 类型的二进制信息,并且在收到消息的时候通过事件通知上层

客户端

  • 业务代码,业务代码会调用 IChannel 提供的方法来发起一个 IPC
  • IChannel,它们提供了 calllisten 两个方法给业务代码调用,用以发起 IPC
  • IChannelClient,它们是实际发起请求的地方,会将请求封装成一定的数据格式,在接收到响应的时候返回给业务代码
  • IPCClient,它提供一组注册和获取 IChannel 的方法
  • IMessagePassingProtocol,和它在服务端的对等方的功能一致

下面我们会具体讲解每个模块的机制。

IChannel 和 IServerChannel

阅读过本系列之前两篇关于依赖注入和服务化的读者,应该已经知道 vscode 中各种功能都是封装在服务当中的,所以 IPC 的执行过程中必须要找到某个能响应特定调用的服务,IServerChannel 则是负责和服务一一对应,帮助它们接入 IPC 系统的,我们将称为实体

而在客户端一侧,业务代码不知道 IPC 机制的接口,因此不能直接发起请求,而是将 IChannel 作为一个能够帮助它发起请求的代理

IserverChannelIChannel 分别就是实体和代理的接口:

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 提供的方法。

ProxyChannel

如果不需要为 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}`);
   		}
   	};
   }

同样的,也可以通过 toServiceIChannel 封装成服务供业务代码调用,这样业务代码就不用自己去调用 IChannelcall 或者 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

IChannelServer 的主要职责包括:

  1. protocol 接收消息
  2. 根据消息的类型进行处理
  3. 调用合适的 IServerChannel 来处理请求
  4. 将响应发送给客户端
  5. 注册 IServerChannel

IChannelServer 直接监听 protocol 的消息,然后调用自己的 onRawMessage 方法处理请求。onRawMessge 会根据请求的类型来调用其他方法。以基于 Promise 的调用为例,可以看到它的核心逻辑就是调用 IServerChannelcall 方法。

  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 => {
			// ...
		});
	}

可以看到,这里通过 requestchannelName 获取到一个 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

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); });
	}

消息传输

我们已经看到了 IChannelServerIChannelClient 之间会互发数据,这里简单讲解一下消息传输的机制。

首先消息传输需要约定好请求和响应的结构。

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,请求的唯一标识符,与请求相对应的响应会有相同的 id
  • channelName,调用的 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 的中间环节,可以很自然的通过作用域来对应请求和响应。而在客户端,则需要一些机制来匹配请求和响应。

IChannelClientsendRequest 之前,会通过 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);
		}
	}

IMessagePassingProtocol

IChannelServerIChannelClient 之间会通过 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 也有多种实现,大致有以下几种:

  • 基于 Electron IPC 模块的实现,通过 webContents 和 ipcRenderer 收发消息;主进程和渲染进程的通讯使用这种方法
  • 基于 web worker 的实现,通过 postMessage 和 onMessage 进行通讯;vscode 浏览器端部分插件基于这种实现
  • 基于 web socket 的实现或 Node.js net 模块实现 ,通过 WebSocket 或 net 创建的套接字或者 pipe 进行通讯;vscode 浏览器端部分插件,以及渲染进程和 Host 进程的通讯基于这种实现(这是最有趣的一个对 Protocol 的实现,vscode 团队在这里实现了一个翻版的 TCP 协议)

IPCClient

它用于在客户端管理 IChannel ,它同时实现了 IChannelClientIChannelServer,所以它实际上可以发起也可以响应 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 最大的区别。

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 属性,它是客户端的标识符,将用在请求路由的过程中。它的 getChannelChannelClientgetChannel 有很大不同:

	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 中选择一个。

Routing

选择  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 机制我们就介绍完毕了。

基于 RpcProtocol 的机制

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);
			}
		});

在调用 mainThreadExtensionsmainThreadError 的方法的时候,即发生了 IPC。

该机制如下图所示:

下面介绍其原理。

shape

客户端怎么知道 mainThreadExtensions 上有一个 $onExtensionRuntimeError 方法可以调用呢?

显然,这里需要定义一个接口,这个接口就是 MainThreadExtensionServiceShape ,定义在 extHost.protocol.ts 文件中。vscode 对于每一个可以调用的实体,都定义了一个以 Shape 为后缀的接口,服务端的实体必须要实现该接口,这样客户端在编写代码的时候就知道有哪些方法可以调用了。

identifier

客户端如何获取到服务端的实体在本地的代理,也就是 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;
}

每个标识符有三个字段:

  • isMain 标识实体是否是在渲染进程当中
  • sid 标识字符串 id
  • nid 标识数字 id

context

我们如何知道另外一个进程中,有哪些实体可以被调用?

extHost.protocol.ts 文件中定义了 MainContext 和 ExtHostContext 两个文件。前者定义了渲染进程中可被调用的实体,后者定义了 host 进程中可被调用的实体。这里也可以看出,在 RpcProtocol 机制下,渲染进程和 host 进程是可以互相调用的。

customer

可被调用的实体是如何注册的?

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.MainThreadExtensionServiceMainThreadExtensionService 绑定起来,而在 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 的通讯原理

到这里我们基本了解了 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;
	}

可以看到一个请求主要有以下这些信息:

  1. type,请求的类型,由一个枚举 MessageType 所定义
  2. req,请求的序号,是一个自增的数字
  3. rpcId,identifier 的字符串 id,表明是哪个实体-代理之间的请求
  4. method,指定要调用实体的哪个方法
  5. 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 的原理了。

Koa 源码解析

这篇文章介绍一个应用服务器框架的主要两个过程:app init 过程和 request handle 过程。一些有趣的细节问题看看以后再写,包括 context, request, response 三个对象,错误处理,egg.js 等等。


init 过程

通过一个简单的 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) // 监听端口

Koa 对象实例化

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 增加 middleware

  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;
  }

compose

这是个很重要的方法,其返回的 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 过程

还是用上面的例子来讲解 request handle 过程。

当有 http 请求过来的时候,如下的方法最先被调用:

const handleRequest = (req, res) => {
  const ctx = this.createContext(req, res) // 创建 context
  return this.handleRequest(ctx, fn) // 过程处理 === context 在 middleware 中的传递
}

创建 context

  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);
  }

context 在 middleware 中间的传递

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)
    }
  }
}

可以看到这个函数是递归的:

用我们的例子:

  1. 执行 dispatch(0), 我们注册的第一个异步函数被当成 fn, 然后 fn(context, dispatch.bind(null, 1)) 调用了这第一个异步函数
  2. 第一个异步函数执行 next(), 实际上执行了 dispatch(1), 然后调用了第二个异步函数。
  3. 同理,调用了第三个异步函数,middleware 到这里已经全部执行过了
  4. 第三个函数执行的时候没用再调用 next(), 所以异步函数返回了状态为 resolved 的 Promise<undefined>
  5. return Promise.resolve() 把异步函数返回的 Promise 接着 resolved 下去
  6. 直到 dispatch(0) 中的 resolved 的 Promise 被 return 出去
  7. 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 的方法返回响应。

Angular CDK Overlay 源码解析 - 2

除了上一篇文章中举例的 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>

可以看到这里有两个很重要的指令,即 cdkOverlayOrigincdkConnectedOverlay,接下来就主要讲解它们。

机制

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 吧。

FlexibleConnectedPositionStrategy

在我们调用 flexibleConnectedTo 的时候,实际上是通过工厂类调用了 FlexibleConnectedPositionStrategy构造函数

  constructor(
      connectedTo: FlexibleConnectedPositionStrategyOrigin, private _viewportRuler: ViewportRuler,
      private _document: Document, private _platform: Platform,
      private _overlayContainer: OverlayContainer) {
    this.setOrigin(connectedTo);
  }

我们接下来分析之前那一串链式调用,以及该 position strategy 如何进行浮层定位。

  1. setOrigin 用于定义浮层锚点
  2. withPositions

FlexibleConnectedPositionStrategy 的定位机制

我们可以通过 apply 方法一窥其定位机制。

vscode 源码解析 - vscode loader

vscode 源码解析 - vscode-loader

在 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 生成最终以下几个文件:

  • loader.js,主要文件,负责加载 js 文件,并通过 plugin 机制支持 CSS 和自然语言文件(nls,国际化文件)的加载(这篇文章我们不会讲解 plugin 机制)
  • css.build.js,负责在构建时处理 CSS 文件;当 vscode 运行在浏览器当中时,为了减少 js 文件加载的开销,使用构建后的 js 文件,css.build.js 就是在构建过程中处理 CSS 文件用的,下面的 nls.build.js 同
  • css.js,以插件的形式加载 CSS 文件
  • nls.build.js
  • nls.js,以插件的形式加载 nls 文件,即 vscode 的国际化资源文件

理解模块化系统

在分析 vscode-loader 的代码之前,我们先从概念上理解一下模块化系统。

现在前端开发基本都不会将所有的代码全部写在一个文件里,而是分散在多个文件当中,这样一个文件就可能会需要引用其他文件中声明的标识符。JavaScript 的灵活性允许我们将这些标识符定义在顶层作用域中然后在另一个文件中访问它,例如:

// file 'a.js'
var a = 2;

// file 'b/js'
console.log(a);

只要在 HTML 引用 js 文件的先后顺序是 a.js → b.js,上面这段代码就能正常输出 2。

但是这种方法在开发稍有规模的 JavaScript 项目的时候,显然是不理想的:

  1. 很难保证各个文件中声明的全局变量的名称都是不一样的,即很难保证不会发生命名冲突
  2. 文件没法创建一个私有的、不能被其他文件所访问的变量
  3. 也很难知道一个文件中引用的全局作用域标识符是在哪个文件中定义的。

模块化机制就是为解决以上问题而提出的。在 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 文件中,有两个变量 agetA,其中 getA 通过 export 语法被导出。而 b.js 文件导入了 a.js 所导出的 getA 并调用,注意:它无法导入 a,于是 a 就变成 a.js 文件所私有的了,我们可以看到,模块化代码不会将变量泄漏到全局环境当中去。

这里一个模块的范围就是一个文件。有些规范可以允许一个文件内含有多个模块,比如 AMD 规范,这时模块的范围就是一个函数的作用域。

我们仔细看上面的例子,从实现的角度思考模块化系统,容易发现一个模块化系统就需要做到下面这几点:

  • 知道模块之间的依赖关系,并按照依赖关系来顺序执行代码;例如上面的 b.js 代码的执行调用了 a.js 文件导出的 getA 函数,因此 a.js 文件要先于上面的代码先被解析执行;
  • 需要支持模块导出标识符给其他模块使用;例如 a.js 文件能够导出一个 getA 函数,就需要通过某种机制传递到 b.js 文件的上下文中去;
  • 需要支持将一些标识符封存起来,不暴露给别的模块使用;例如 a 不能够被 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 的初始化

打开 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();
		}
	}
  1. 创建了一个 ScriptLoader,即脚本加载器,脚本加载器因代码运行环境而异,有 NodeScriptLoader WorkScriptLoaderBrowserScriptLoader 三种,负责加载 js 文件
  2. 定义了 DefineFuncRequireFunc,即模块化系统中的 define 函数和 require 函数,分别用于定义和加载一个模块
  3. 创建了一个 ModuleManager,即模块管理器,它用于按照正确的顺序来解析 js 文件并执行
  4. 覆盖了全局作用域中的 define 以及 require 变量(node 原生 require 变量的值被保存在 _nodeRequire 变量中)

我们接下来分别探究这几项内容,先从 DefineFuncRequireFunc 开始。

DefineFunc RequireFunc

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 除了可以接收一个配置对象之外,能够用两种方式进行调用:

  1. 第一种,参数是一个字符串,这里直接是 moduleManager.synchronousRequire 方法的一个代理,用于同步地调用一个已经加载完毕的依赖;
  2. 第二种,要求第一个参数是一个数组,数组里是需要加载的脚本,第二和第三个参数则是加载和失败的回调。这样的调用方式,会调用 moduleManager.defineModule 定义一个匿名的模块,然后这些需要加载的脚本就会作为这个匿名模块的依赖而被加载。我们先前看到的 bootstrap-amd 中加载主进程入口的代码即使用了这种调用方式。
const loader = require('./vs/loader')

exports.load = function (entrypoint, onLoad, onError) {
	// ...
	loader([entrypoint], onLoad, onError);
};

RequireFunc

	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 方法。

ModuleManager

RequireFuncDefineFunc 的内容都较为简单,主要是调用 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 的数据结构

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 的运行过程

经过上面知识的铺垫,我们可以来学习最为重要的 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 方法主要做了下面这几件事情:

  1. 调用 _normalizeDependencies 对依赖进行处理,这个过程比较简单,实际上就是根据依赖项的标识字符串生成一个 RegularDependency 对象
  2. 创建一个 Module 对象
  3. Module 记录到 _moduels2 数组当中
  4. 调用 _resolve 方法尝试解析该模块

_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,就将模块的返回作为该模块的导出。

Untitled

循环依赖的发现和处理

_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。

Untitled 1

我们用这个例子来分析 _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 的依赖关系。

  • 第一次循环,发现 a 的依赖 b d,b d 不等于 c,因此将 b d 加入队列
  • 第二次循环,发现 b 的依赖项 d,但是 d 已经遍历过了,所以不做处理
  • 第三次循环,发现 d 的依赖项 c,找到了循环依赖,返回 true

这其实就是一个广度优先的有向图搜索算法,在遍历的过程中,我们尝试找到从 fromto 的路径,找得到的话我们就知道有循环依赖了(因为我们已经知道 fromto 的依赖,所以肯定存在一条 tofrom 的路径)。

发现循环依赖之后,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,它主要起两个作用:

  • 封装上面三个 loader,给外部代码一个统一的调用入口,通过判断环境决定实例化哪一个
  • 确保一个路径上的代码只会被加载一次,触发多个通过 load 方法传进来回调函数

我们这里主要跟大家一起学习 NodeScriptLoader,其他三者的代码都比较简单,大家可以自行学习。

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);
		}
	};

Untitled 2

到这里,vscode-loader 的整个核心过程就基本介绍完毕了。

总结

vscode 实现的 loader 有如下特性:

  • 基于 AMD 规范,在 node.js 中也支持 node.js 原生的 require
  • 支持多种 JavaScript 环境,包括:浏览器、Node.js、Electron 渲染进程、web worker
  • 在 node.js 环境中,通过 cacheData 机制来加速文件的加载

由于篇幅的限制,这篇文章没有深入分析以下这些机制,感兴趣的同学在阅读本文之后可以自行学习:

  • cacheData 机制的具体实现
  • loader 需要对文件相对路径进行 resolve,这一部分是如何工作的
  • vscode-loader plugin 机制,vscode-loader 可以通过 plugin 机制加载 CSS 文件和国际化文件(如果之后出国际化机制源码解析的话,我可能会再回过头来讲解 vscode-loader plugin 机制)
  • 在构建时 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 介绍了一些艺术家和活动家是如何在生活富足的同时还在做他们想做的事情的。

注释

  1. 不管你信不信,我真的在地铁上写过东西。要是想给不工作找个理由的话,理由可太多了:时间不够、楼下太吵,等等。但是我发现当我来灵感的时候,我可以在地铁上写一些东西。尽管那里真的很吵并且在下车之前我只有几分钟的时间。

  2. 对于睡眠也是这样。困到睡不着是一种糟糕透顶的体验——感觉起来就像僵尸一样。

  3. 现在看来我在另外一件事上也有这种现象:害羞。我不喜欢给陌生人打电话,或者是在派对上跟别人聊天。我怀疑这或许是因为害羞也是问题童年的后果之一。当然,这只是推测。

  4. 我在这里使用的术语源自 David Allen 的 Gettings Things Done,这里面的原则(或许是无意的)被应用在了极限编程当中(Extreme Programming),极限编程是组织软件开发的一套系统,但是我发现它提出的很多建议对于避免拖延来说都很有用。
    比如,结对编程自然而然地在两个人之间分担了思维负担,而且给人们在低质量的时间段一些别的事情做。将大项目分解成一个个小迭代是极限编程的另一个核心要素,迅速完成某些工作然后不断提升它(就像我在“简化问题”当中说的)。这些都不止适用于编程。

  5. 如果想了解这方面研究的综述,请阅读 Alfie Kohn 的 Punished By Rewards,这里的观点源自他的文章 Challenging Behaviorist Dogma: Myths About Money and Motivation(挑战行为教义:关于金钱和动机的迷思)。

  6. 我最初简单地以为这是天生的,但是 Paul Graham 指出这更可能是习得性行为。当你还小的时候,你的父母会严格管教你,当他们叫你去写作业的时候,你的思绪却四处飘荡,想着别的事情,很快这种走神就成了一种习惯。无论如何,这都是一个难以解决的问题。我已经放弃了改变这种行为,而是绕过它。

  7. Richard Feynman 讲过一个他如何探索梦境的故事,我研究自己的拖延问题的方法和他的方法很类似。每天晚上,他尝试观察当他睡着的时候发生了什么:

    像往常一样,某天晚上我又做梦了,我试着观察……然后我意识到我一直在枕着一根铜杆睡觉,我把手放在后脑勺上,感觉到后脑勺软软的,我想:“啊哈!这就是为什么我能在梦里观察:这个铜杆压迫到了我的视觉皮层“。只要我想要觉察我的梦境,我只要往脑后放一根铜杆就行了!我可以停止对这次梦境的观察,进入更深的睡眠了。”
    当我过一会儿醒来的时候,脑后并没有铜杆,我的后脑勺也没有软软的。我的大脑不知怎么给我了一些错误的解释来阻止我观察自己的梦境。(出自 Surely You're Joking, Mr Feynman! 第 50 页)

    你的大脑比你想象的更加奇妙。

  8. 所以,举个例子,我不会这么写:“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 的联合创始人之一,著名黑客。你可以通过维基百科进一步了解他的生平。

关于博客 / About this Blog

欢迎阅读我的博客。

  • 通过 watch 这个项目即可获得文章更新通知
  • 在 issue 下面回复即可参与讨论

Welcome to my blog.

  • Appearently most of my blogs are written in Chinese. If you cannot read Chinese, you may want to check posts labeled "English".
  • Please watch this repo to stay tuned.
  • Join in discussions by replying issues.

TypeScript 装饰器

TypeScript 有一个强大但是却不那么新手友好的功能,那就是装饰器。 你肯定用过 Angular 实现的很多装饰器,比如装饰类的 @Component,装饰属性的 @ViewChild,以及装饰方法的 @HostListenner,但是你尝试过自己写一个装饰器吗?它们粗看起来似乎很神奇 🍄,但实际上它们只是一些 JavaScript 函数,能够帮助我们来注释代码或者是修改代码的行为——这种做法我们通常称为元编程

一共有五种装饰器的方法,我们会通过举例子的方式一一讲解它们。

  • 类声明
  • 属性
  • 方法
  • 参数
  • accessor

装饰器能够很好的抽象代码。尽管用装饰器来封装所有东西看起来很有诱惑力,但是它们最合适的用场还是来包装可能会多处复用的稳定的逻辑

类装饰器 Class Decorator

类装饰器使得开发者能够拦截类的构造方法 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 {} // 报错,类不能被扩展

属性装饰器 Property Decorator

这里的例子都用到了装饰器工厂模式。我们将装饰器本身封装在另外一个函数中,这样就能给装饰器传递变量了,例如 @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,
    })
  }
}

方法装饰器 Method Decorator

我们可以使用方法装饰器来覆写一个方法,改变它的执行流程,以及在它执行前后额外运行一些代码。

例子

下面这个例子会在执行真正的代码之前弹出一个确认框。如果用户点击了取消,方法就会被跳过。注意,这里我们装饰了一个方法两次,这两个装饰器会从上到下地执行。

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
  }
}

为 Angular 实现 React Hooks 🤯

你或许听说过 React Hooks 如何彻底改变了 React 的开发生态。Angular 能不能用这样的方式来写出同样优雅、简明的代码呢?事实上,完全可以,而且从一开始就可以。

React hooks game changer results

UseState 属性装饰器

在 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)
  }
}

UseEffect 方法装饰器

Effect hook 所做的事情只是简单的把组件生命周期中的 componentDidMountcomponentDidUpdate 这两个钩子合并到了同一个回调中。

useEffect(() => {
  // Update the document title using the browser API
  document.title = `You clicked ${count} times`
})

用一个方法装饰器很容易就能模拟,我们只需要将 Angular 对应的生命周期方法 ngOnInitngAfterViewChecked 指向该方法的属性描述符的 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 扩展的架构和一些关键技术。

为了使内容尽量精简,我们将只会讲解桌面端 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 进程

从上面的文件开始 Host 进程的探索,startExtensionHostProcess 会和渲染进程建立通讯,并实例化 ExtensionHostMain 来管理 Host 进程。

initData 当中含有渲染进程初始化时已安装的 extension 的信息

initData 是怎么来的

  • 回到渲染进程和 Host 进程的通讯
  • extensionScanner

插件注册

在加载插件之前,首先要对它们进行注册

在 ExtHostExtensionService 的构造方法中,会创建一个 Registry 收集 extension 的信息。

ExtensionDescriptionRegistry

IExtHostExtensionService 会被创建,调用其 initialize 方法将会开始 extension 的加载过程。

extension 加载过程

分为三个阶段

  • beforeALmostReadyToRunExtensions
  • waitForInitializeCall
  • startExtensionHost
    • handleEagerExtensions
      • _activateByEvent('*') 启动那些需要立即启动的插件
      • _activateAllStartupFinished 启动那些事件为 onStartupFinished 的插件
    • 其他一些插件的加载过程就不介绍了

extensionActivation

_activeExtensions

同一批 activate 的组件中,需要分析依赖关系,不需要依赖其他组件的进入 greenMap,否则进入 red,

然后递归调用 _activateExtensions 直到所有的组件全都被 activate 为止。

扩展点注入

vscode 提供了许多 API 供开发者使用,只需要在插件中 import * as vscode from "vscode",即可通过vscode 对象访问这些 API。但是查看 npm 上 @types/vscode 包,当中却只有类型声明文件,显然开发者是不可能通过这个包来访问 API 的。这一章节我们来探究一下这些 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 方法为

https://github.com/microsoft/vscode/blob/ba83910eb768ebfd629aa68abdbb13a1f60612fc/src/vs/workbench/api/common/extHostRequireInterceptor.ts#L103-L124

而其 nodeModuleName 则为

这样就实现了对 require('vscode') 的拦截,向 extension 的运行环境注入了 vscode API。

this.register(new VSCodeNodeModuleFactory(this._apiFactory, extensionPaths, this._extensionRegistry, configProvider, this._logService));

扩展点的识别和加载

这里回到渲染进程,分析 extensionScanner 的行为

contributions

按事件启动

Angular CDK Overlay 源码解析 - 1

开发组件时,浮层是一个很常见的需求,比如弹出式对话框、上下文菜单、通知等都需要使用浮层。

在开发 overlay 时,有这些问题需要考虑:

  1. 在指定位置动态创建元素
  2. 根据元素大小、页面边框和页面滚动、缩放等事件调整元素位置
  3. 控制键盘事件响应顺序
  4. 控制主页面的行为

Angular CDK 的 overlay 模块为这些问题提供了完备的解决方案:

  1. Overlay
  2. PositionStrategy
  3. KeyboardDispatcher
  4. 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

OverlayContainer 在 body 元素的最后创建了一个元素,用于包裹全部的浮层元素。之后我们会称该元素为 container element。

<div class="cdk-overlay-container"></div>

该元素会在 getContainerElement 方法第一次被调用的时候创建(惰性实例化)。

注意这个服务是全局的。

Overlay

Overlay 是一个服务,通过它的 create 方法可以创建一个新的浮层,这个过程中主要做了以下几件事:

  1. 创建一个 host element 和一个 pane element,然后将 pane element 作为 PortalOutlet 的挂载点,而这里 PortalOutlet 的类型就是我在之前一篇文章中讲过的 DomPortalOutlet,它将会被用来挂在组件内容。
  2. 创建一个 OverlayConfig 对象,OverlayConfig 的构造方法仅仅是把 plain object 上面的非 undefined 属性转移到新创建的 OverlayConfig 对象上。
  3. 创建一个 OverlayRef 并返回,值得注意的是第一步中创建的 PortalOutlet 会被传递给 OverlayRef 的构造方法。

OverlayRef 类非常重要,它负责了浮层机制的绝大部分逻辑,并且是暴露给组件开发者操纵浮层的接口对象。

OverlayRef

OverlayRef 的构造方法确定了该浮层的 scroll strategy 和 position strategy,这部分我们之后来谈。

组件开发者在新创建的浮层上添加组件时,应该调用 OverlayRef 的 attach 方法,参数应该是一个 Portal 对象。这个方法做了如下几件事情:

  1. Portal attach 到 DomPortalOutlet 上,这一步会动态创建组件开发者定义的内容
  2. 启用 position strategy
  3. 通过 _updateStackingOrder 方法更新 host element 在 container element 中的位置,最新创建的浮层应该在 DOM 树的最上方
  4. 通过 _updateElementSize 方法更新 pane element 元素的样式
  5. 启用 scroll strategy
  6. 在 Angular zone 稳定之后(一般是组件 DOM 已经创建)调整浮层的位置
  7. 打开浮层的鼠标事件支持
  8. 根据配置创建 backdrop(之后再讲)
  9. 根据配置修改 pane element 的 CSS 类
  10. 派发 attach 事件
  11. 将自己注册到 KeyboardDispatcher 中(之后再讲)

OverlayRef 类还有以下几个重要的方法:

  • detach,卸载当前浮层添加的组件。
  • dispose,销毁当前浮层。

篇幅所限,这里就不带读者们阅读了。

PositionStrategy

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 了。

辅助机制

Backdrop

有些浮层需要有一个后置的全屏图层,来凸显浮层内容,同时作为 MouseEvent 的 target,支持“点击浮层外关闭”这样的功能。

Backdrop 由 _attachBackdrop 方法所创建,实质上是创建了这样一个元素

<div class="cdk-overlay-backdrop cdk-overlay-backdrop-showing"></div>

并把它插入到 host element 之前,保持图层叠加的顺序。

同时在浮层上绑定了一个 click 事件的 handler,通过此 handle 派发 _backdropClick 事件。

KeyboardDispatcher

KeyboardDispatcher 负责将键盘事件分派给最近打开的浮层。

之前讲到浮层 attach 的时候会调用 KeyboardDispatcher 的 add 方法,该方法会将调用此方法的 overlay 注册在 _attachedOverlays 数组的最后,且会第一个 overlay 注册的时候在 document 上绑定 keydown 事件的 handler,而该 handler 会从数组尾部开始逆序查找监听了 keydown 事件的 overlay,并对它派发 keydown 事件。

KeyboardDispatcher 使得最近一个打开的 overlay 才能监听键盘事件,一种常见的使用场景就是支持按 esc 键时有序地关闭 overlay。

ScrollStrategy

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);
  }

可以看到默认使用的是 BlockScrollStrategyGlobalPositionStrategy

实际上是通过工厂类 OverlayPositionBuilderScrollStrategyOptions 创建的。

个人觉得这不是个好设计,会导致没用到的 Strategy 没法被 tree shake 掉。

然后 _attachContainer 方法就会将 BottomSheep 组件内容 attach 到 portal 上了。

const containerRef: ComponentRef<MatBottomSheetContainer> = overlayRef.attach(
  containerPortal
)

slate 架构与设计分析

slate 是一款流行的富文本编辑器——不,与其说它是一款编辑器,不如说它是一个编辑器框架,在这个框架上,开发者可以通过插件的形式提供丰富的富文本编辑功能。slate 比较知名的用户(包括前用户)有 GitBook 和语雀,具体可以查看官网的 products 页面

所谓“工欲善其事,必先利其器”,想要在项目中用好 slate,掌握其原理是一种事半功倍的做法。对于开发编辑器的同学来说,slate 的架构和技术选型也有不少值得学习的地方。这篇文章将会从以下几个方面探讨 slate:

  • slate 数据模型(model)的设计
  • model 变更机制
  • model 校验
  • 插件系统
  • undo/redo 机制
  • 渲染机制
  • 键盘事件处理
  • 选区和光标处理

slate 架构简介

slate 作为一个编辑器框架,分层设计非常明显。slate 仓库下包含四个 package:

  • slate:这一部分是编辑器的核心,定义了数据模型(model),操作模型的方法和编辑器实例本身
  • slate-history:以插件的形式提供 undo/redo 能力,本文后面将会介绍 slate 的插件系统设计
  • slate-react:以插件的形式提供 DOM 渲染和用户交互能力,包括光标、快捷键等等
  • slate-hyperscript:让用户能够使用 JSX 语法来创建 slate 的数据,本文不会介绍这一部分

slate (model)

先来看 slate package,这一部分是 slate 的核心,定义了编辑器的数据模型、操作这些模型的基本操作、以及创建编辑器实例对象的方法。

model 结构

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 个 TextTextbold italic 这些属性描述它们的文字式样,对应普通、粗体、斜体和行内代码样式的文字。

那么为什么 slate 要采用树结构来描述文档内容呢?采用树形结构描述 model 有这样一些好处:

  • 富文本文档本身就包含层次信息,比如 page,section, paragraph, text 等等,用树进行描述符合开发者的直觉
  • 文本和属性信息存在一处,方便同时获取文字和属性信息
  • model tree Node 和 DOM tree Element 存在映射关系,这样在处理用户操作的时候,能够很快地从 element 映射到 Node
  • 方便用组件以递归的方式渲染 model

用树形结构当然也有一些问题:

  • 对于协同编辑的冲突处理,树的解决方案比线性 model 复杂
  • 持久化 model / 创建编辑器的时候需要进行序列化 / 反序列化

光标和选区

有了 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 // 选区结束的位置
}

比如我这样选中一段文本(我这里是从后向前选择的):

通过访问 editorselection 属性来查看当前的选区位置:

可见,选择的起始位置 focus 在第一段的最后一个文字处,且由于第一段中 "bold" 被加粗,所以实际上有 3 个 Text 的节点,因此 anchorpath 即为 [1, 2]offset 为光标位置在第三个 Text 节点中的偏移量 82。

如何对 model 进行变更

有了 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 进行变更的过程主要分为以下两步,第二步又分为四个子步骤:

  1. 通过 Transforms 提供的一系列方法生成 Operation
  2. Operation 进入 apply 流程
    1. 记录变更脏区
    2. Operation 进行 transform
    3. 对 model 正确性进行校验
    4. 触发变更回调

首先,通过 Transforms 所提供的一系列方法生成 Operation,这些方法大致分成四种类型:

export const Transforms = {
  ...GeneralTransforms,
  ...NodeTransforms,
  ...SelectionTransforms,
  ...TextTransforms,
}
  • NodeTransforms:对 Node 的操作方法
  • SelectionTransforms:对选区的操作方法
  • TextTransforms:对文本操作方法

特殊的是 GeneralTransforms,它并不生成 Operation 而是对 Operation 进行处理,只有它能直接修改 model,其他 transforms 最终都会转换成 GeneralTransforms 中的一种。

这些最基本的方法,也即是 Operation 类型仅有 9 个:

  • insert_node:插入一个 Node
  • insert_text:插入一段文本
  • merge_node:将两个 Node 组合成一个
  • move_node:移动 Node
  • remove_node:移除 Node
  • remove_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 的最后生成了一个 typeinsert_textOperation 并调用 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 处理 Operationtransform 方法的主体是一个 case 语句,根据 Operatointype 分别应用不同的处理,例如对于 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.childreneditor.selection 属性。

slate 使用了 immer 来应用 immutable data,即 createDraft finishDrag 成对的调用。使用 immer 可以将创建数据的开销减少到最低,同时又能使用 JavaScript 原生的 API 和赋值语法。

model 校验

对 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 一共有以下两种生成机制:

  • 一部分是在 operation apply 之前的 oldDirtypath,这一部分根据 operation 的类型做路径转换处理
  • 另一部分是 operation 自己创建的,由 getDirthPaths 方法获取

normalizeNode 方法会对 Node 进行合法性校验,slate 默认有以下校验规则:

  • 文本节点不校验,直接返回,默认是正确的
  • 空的 Elmenet 节点,需要给它插入一个 voids 类型节点
  • 接下来对非空的 Element 节点进行校验
    • 首先判断当前节点是否允许包含行内节点,比如图片就是一种行内节点
    • 接下来对子节点进行处理
      • 如果当前允许行内节点而子节点非文本或行内节点(或当前不允许行内节点而子节点是文字或行内节点),则删除该子节点
      • 确保行内节点的左右都有文本节点,没有则插入一个空文本节点
      • 确保相邻且有相同属性的文字节点合并
      • 确保有相邻文字节点的空文字节点被合并

合法性变更之后,就是调用 onChange 方法。这个方法 slate package 中定义的是一个空函数,实际上是为插件准备的一个“model 已经变更”的回调。

到这里,对 slate model 的介绍就告一段落了。

slate 插件机制

在进一步学习其他 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 修饰编辑器实例,直接覆盖实例上原本的 applychange 方法。~~换句话说,slate 的插件机制就是没有插件机制!~~这难道就是传说中的无招胜有招?

slate-history

学习了插件机制,我们再来看 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 合并,或覆盖前一个 batch
  • 创建一个 batch 插入 undos 队列,或者插入到上一个 batch 的尾部,同时计算是否超过最大撤销步数,超过则去除首部的 batch
  • 调用原来的 apply 方法
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

最后我们来探究渲染和交互层,即 slate-react package。

渲染机制

我们最关注的问题当然是 model 是如何转换成视图层(view)的。经过之前的学习我们已经了解到 slate 的 model 本身就是树形结构,因此只需要递归地去遍历这棵树,同时渲染就可以了。基于 React,这样的递归渲染用几个组件就能够很容易地做到,这几个组件分别是 Editable Children Element Leaf StringText。在这里举几个例子:

Children 组件用来渲染 model 中类行为 EditorElement Nodechildren,比如最顶层的 Editable 组件就会渲染 Editorchildren

注意下面的 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 之间建立映射关系(具体可查看 toSlateNodetoSlateRange 等方法和 ELEMENT_TO_NODE NODE_TO_ELEMENT 等数据结构),这在处理光标和选择事件时将会特别方便。

未命名作品 2

slate-react 还用了 React.memo 来优化渲染性能,这里不赘述。

自定义渲染元素

在上面探究 slate-react 的渲染机制的过程中,我们发现有两个比较特殊的参数 renderElementrenderLeaf,它们从最顶层的 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 的架构设计和对一些关键问题的处理,包括:

  • model 数据结构的设计
  • 如何以原子化的方式进行 model 的变更
  • 对 model 的合法性校验
  • 插件系统
  • undo/redo 的实现
  • 渲染机制
  • UI 到 model 的映射
  • 光标和选区的处理

等等。

至此,我们可以发现 slate 存在着这样几个主要的问题:

没有自行实现排版。slate 借助了 DOM 的排版能力,这样就使得 slate 只能呈现流式布局的文档,不能实现页眉页脚、图文混排等高级排版功能。

使用了 contenteditable 导致无法处理部分选区和输入事件。使用 contenteditable 后虽然不需要开发者去处理光标的渲染和选择事件,但是造成了另外一个问题:破坏了从 model 到 view 的单向数据流,这在使用输入法(IME)的时候会导致崩溃这样严重的错误。

我们在 React 更新渲染之前打断点,然后全选文本,输入任意内容。可以看到,在没有输入法的状态下,更新之前 DOM element 并没有被移除。

截屏2020-09-28 下午5 19 37

但是在有输入法的情况下,contenteditable 会将光标所选位置的 DOM element 先行清除,此时 React 中却还有对应的 Fiber Node,这样更新之后,React 就会发现需要卸载的 Fiber 所对应的 DOM element 已经不属于其父 element,从而报错。并且这一事件不能被 prevent default,所以单向数据流一定会被打破。

截屏2020-09-28 下午5 20 45

React 相关的 issue 从 2015 年起就挂在那里了。slate 官方对 IME 相关的问题的积极性也不高。

对于协同编辑的支持仅停留在理论可行性上。slate 使用了 Operation,这使得协同编辑存在理论上的可能,但是对于协同编辑至关重要的 operation transform 方案(即如何处理两个有冲突的编辑操作),则没有提供实现。


总的来说,slate 是一个拥有良好扩展性的轻量富文本编辑器(框架?),很适合 CMS、社交媒体这种不需要复杂排版和实时协作的简单富文本编辑场景。

希望这篇文章能够帮助大家对 slate 形成一个整体的认知,并从其技术方案中了解它的优点和局限性,从而更加得心应手地使用 slate。

新手向:如何给大型前端开源项目贡献源码

参与开源项目对个人的好处是显而易见的:进一步熟悉你所使用的库、和有经验的开发者进行交流、提高代码水平和社区影响力,甚至是找到新的工作机会。特别是对于没有实习经验的在校大学生来说,参与开源项目是体现技术热情和积累实际开发经验的一个好方法,能让你在简历筛选和面试环节得到面试官的青睐。这篇文章将会和大家分享如何参与大型前端开源项目的开发。你将会看到,开源的门槛其实并不高,只要有意愿,并掌握了最基本的流程,任何人都可以给大型前端开源项目贡献代码。

为了方便你理解这篇文章的内容,我会以我最近给西湖区最大的 React 组件库 Ant Design 提交的一段代码为例来讲解流程,这段代码是为了满足用户的一个功能需求,即通过唯一的 key 来修改消息框(Message)的内容。

贡献流程

找到想要贡献的项目和 issue

绝大多数的前端开源项目都托管在 GitHub 上,开发和维护也在 GitHub 上进行,开源项目的用户(开发者)们会将他们找到的(疑似) bug 或是功能请求提交到这些项目的 issues 中,给大型前端开源项目贡献代码的第一步就从这里开始,即从中找到你感兴趣的 issue。

如果你还不了解 GitHub 及它的基本使用,请参考我朋友的知乎回答:《如何使用 GitHub》

比如 Ant Design 的 issues 中,你可以看到许多这样的 bug 反馈和功能请求。

1

Tips:如何找到适合自己的项目和 issue,哪些 issue 比较容易上手?

对于新手来说,最好找这样的一些项目:

  • 工作和学习中使用较多,比较熟悉的项目,这样你在使用之前已经了解了它的用途、API 等等。
  • 各个模块耦合性比较低的项目,比如组件库(比如 Ant Design)、工具库(比如 lodash),方便定位问题,找到入手点。

以及这样的 issue:

如果你是第一次向大型开源项目提交代码,要记得所解决的问题的难度并不重要,重要的是走一遍贡献代码的流程,了解开源社区是如何协作的。

  • 小的功能需求,明显可以复用项目中已有的代码。在本文的例子中,用户所请求的功能其实在 Notification 组件上已经有实现,这样对我们解决这个 issue 就很有帮助。
  • 一些 issue 会被项目官方团队标记为 Good First Issue,是官方认为比较适合初次贡献者的,比如 Angular 组件库 ng-zorro-antd 就有这样的一些 issue,你可以从这里开始。
  • 错别字、文档翻译、国际化。这些一般都不 touch 到库的核心功能,难度较低而且不会引入 bug,可以放心地把它们作为你的开源初体验。

找到了想要解决的 issue 之后,在 issue 下面留言说你想要负责这个 issue,一般项目的维护者都会把这个 issue 交给你。你可以看到我在原 issue 下的回复

维护者们都巴不得有人来完(tian)善(keng)他们的项目呢,所以尽管留言吧!

了解并运行项目

好了,现在我们手头已经有了一个待解决的 issue,接下来我们需要了解这个项目是如何运作的。

阅读源码的能力是必须的,这里推荐阅读《如何阅读大型前端开源项目的代码》

了解项目的第一步永远都是先把这个项目在本地运行起来。

fork

如果你在阅读下面的内容的时候发现对一些概念一无所知,请立刻回头阅读《如何使用 GitHub》。

首先你要 fork 该项目,fork 意味者创建一份源仓库的拷贝,在贡献代码的时候,我们没有向源仓库推送(push)代码的权限,往往都需要先推送到自己的拷贝上,然后请求项目的维护者们合并我们的新代码,即发起 Pull Request。

你可以看到我的账户底下就有一份 ant-design 的 fork

2

clone

然后把 fork 后的代码 clone 到你的电脑。

3

安装依赖

通过 npm 或者 yarn 安装依赖。

运行项目

一般通过查看 package.json 文件的 scripts 字段,就可以知道如何运行该项目,进行测试等等。对于 Ant Design,只需要运行 npm start 就可以。

4

解决问题

下面我们就要解决 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

发起 PR(Pull Request)时,需要 commit 代码,然后 push 到你 fork 的仓库。

如果你同时给一个项目解决好几个 issue,你应该从 master 分支 checkout 出多个分支,然后分别在这些分支上解决 issue。更好的实践是永远 checkout 一个新分支来解决 issue,不要向 master 分支提交任何代码。

提交 PR 之前,你并不需要确定已经做到尽善尽美了,肯定有一些东西要和项目维护者们进行讨论。

之后在 GitHub 上发起 PR,当你在 fork 的仓库推送了分支时,GitHub 会很聪明地询问你是否要发起 PR,点击绿色的小按钮之后,填写 PR 模板即可发起 PR。

5

6

和维护者们有来有回

“烦人”的维护者们是不会轻易让你的代码进入他们的主分支的!你必须接受维护者们的代码 review,有时候他们会要求你做出一些修改。

比如 afc163 认为加入 key 后参数数量过多,要求实现以对象的形式传参

当然他们也是为了保证代码的质量而非存心跟你过不去,而且和维护者们的互动有助于你写出更鲁棒的代码和更精炼的 API 哦。

这种 review 和修改和互动可能会有很多轮。如果维护者们对你的 PR 和修改没有做出回应,你可以主动 @ 他们。

成为 contributor

当维护者们对所有事情都表示满意,approve 了你的 PR,你就可以坐等代码被合并到 master 并成为该项目的 contributor 啦。

之后你参与该项目的讨论时就会有个 Contributor 小徽章时刻提醒那些小白你是多么的牛逼(逃

7

温馨建议

  • 保持礼貌。
  • 学好 Git,包括但不限于这些常用命令:fetch pull add commit push merge rebase
  • 在给任何项目做贡献之前,请务必阅读该项目的贡献指南,比如 Ant Design 的。这些指南往往能够帮助你更好地了解维护团队的工作流程,有些项目对 commit message 还有一定的要求,请务必遵照执行,这会让你的开源之旅更加顺畅。
  • 时刻关注想要贡献的项目的 issues 界面,有想做的 issue 直接留言。
  • 关注官方团队的开发者博客或者维护者的个人博客,了解项目的最新动态和技术细节。
  • 不把参与开源当作很难的事。

Happy coding :)

NG-ZORRO 进阶指南:为轮播图组件自定义切换效果

在之前的一次对轮播图 (carousel) 组件的重构中,我们将实现切换效果的代码和其他代码分离,这不仅使得组件更容易维护,而且允许用户为轮播图添加各种各样的切换效果 (strategy)。这篇文章将会以一个翻转 (flip) 效果为例,介绍如何使用该进阶功能。

注:需要 ng-zorro-antd 7.5.0 及以上版本。

你可以在这里预览最终效果。

并且在这里找到源代码。


要实现并使用自定义的切换效果,需要如下步骤:

  1. 定义一个继承了 NzCarouselBaseStrategy 的类,实现切换效果
  2. 通过 NZ_CAROUSEL_CUSTOM_STRATEGIES 提供自定义切换效果
  3. nz-carouselnzEffect input 中指定使用自定义效果

实现切换效果 FlipStrategy

要实现一个切换效果,只需要继承并实现 ng-zorro-antd 提供的 NzCarouselBaseStrategy 抽象类即可。主要是实现以下三个方法:

  1. withCarouselContents 该方法会在轮播图组件初次渲染、轮播图内容改变,或者是切换效果的时候被调用,切换效果应该在这个方法里进行初始化设置
  2. switch 会在轮播图切换到其他画面时被调用,负责执行切换动画
  3. dispose 会在轮播图组件销毁,或者是切换效果的时候被调用,应该在这个方法里移除初始化设置

下面来看一下 FlipStrategy 的具体实现吧。

withCarouselContents

在这个方法被调用的最初,需要调用父类的同名方法,让父类准备好辅助属性。

super.withCarouselContents(contents)

NzCarouselBaseStrategy 提供了一些辅助属性和方法,能够帮助开发者更好地实现想要的切换效果。比如记录轮播图项目的宽度的 unitWidth,所有轮播图项目指令 NzCarouselContentDirectivecontents 等。

然后,对轮播图组件中的各个项目的样式做初始化。对于翻转效果来说,所有的元素都应该重合在相同的位置,所以外层元素的宽度均为轮播图项目的单位宽度。

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>

就得到了一个效果为翻转的轮播图了。

实现拖拽效果

如果实现了 NzCarouselBaseStrategydragging 方法,还可以支持拖拽。这个方法会传递一个 PointerVector 对象,开发者可以从这个对象获取光标在拖拽中在 x 和 y 两个方向上拖动的距离(像素单位)。

dragging(_vector: PointerVector): void {}

我们把实现拖拽当作留给大家的练习。

参考链接

  1. 策略模式。切换效果 (strategy) 的设计是典型的策略模式,不妨学习一下。

Redux 源码解析

createStore

有 middleware 的情形后面再讨论,这里先讨论最简单的情形。

首先设置了初始状态,还有其他一些属性,通过闭包封装。

let currentReducer = reducer
let currentState = preloadedState
let currentListeners = []
let nextListeners = currentListeners
let isDispatching = false

另外这个作用域内声明了这些函数:

  • getState:只要当前没有 reducer 在执行就返回当前状态树。
    • dispatch 函数调用的时候会设置标志位 isDispatchingtrue
  • dispatch:触发这个方法是改变状态的唯一途径。
    • 通过 isPlainObject 方法确保传入的 action 是一个 PlainObject(原理是判断 obj 的原型链上只有 Object),其他类型的 action 都交给中间件处理。
    • 在调用 reducer 的过程中不允许派发新的事件
    • 调用 currentReducer 来变更状态,变更后的状态会被赋值给 currentState 属性。
    • 通知所有监听者。
  • subscribe:注册状态变化监听者,返回一个取消监听的函数。
  • replaceReducer:用于在运行时切换 reducer。
  • observable:这个用来实现和 RxJS 类似的 subscribe API,这里就不赘述了,总之它会在状态变更之后 next 最新的状态。

最终被封装在对象上返回,就是我们通常说的 Store 对象。

return {
  dispatch,
  subscribe,
  getState,
  replaceReducer,
  [$$observable]: observable
}

Middleware

如果要使用中间件的话,在 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));

我们现在开始仔细地分析调用过程:

  1. applyMiddleware 函数接收多个 middleware,然后返回了一个函数 B
  2. A 中第一次调用的时候,createStore 方法被传递给函数 B,再次返回了一个函数 CC 再被 [A] 中的第二次调用所调用
  3. createStore 方法创建了一个真正的 Store 对象。
  4. 这一行代码注册这些 middleware(当然在这里我们只有 thunk 这一个中间件),可以看到 getStatedispatch 这两个方法就被传给了函数 1,返回的是函数 2
  5. 这个函数 2compose 函数所用,生成了一个新的 dispatch 方法 3,注意,这个方法是用户调用的 dispatch它才是真身!)
  6. 最后的返回看起来像是个 Store 对象,其实是个“套壳”的 Store

我们先讲解 compose 方法,然后再来讲解用户调用 dispatch 时会发生什么。

compose

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)))

这样的写法可能具有误导性(让读者误以为 ab 都是中间件),我们将形参改个名字理解起来会更容易:

return funcs.reduce(
  (alreadyChained, nextMiddleware) =>
    (...args) => alreadyChained(nextMiddleware(...args))
)

可以看到最终形成的是一种链式调用,如果我们这样调用 compose 方法:

compose(a, b, c)

最终就会得到:

(...args) => a(b(c(...args)))

这样用户在调用 dispatch 的时候,中间件只要调用 next 就能将 action 抛给下一个中间件处理。

用户调用 dispatch 时会发生什么

现在我们可以来探究用户调用 dispatch 时会发生什么了。我们已经知道了用户实际调用的是 compose 函数的返回值,所以实际上执行的是函数 [4],而我们知道在用 redux-thunk 的时候,action 里面会调用 disaptch,而这个 dispatch 就是 [4] 本身!只不过 [4] 第二次被调用的时候,走的是 next(action),而我们在上面分析过,这里 next === store.dispatch,这样就会到真正 Store 对象的 dispatch 方法啦。

下面这张图能够帮助你理解变量之间的关系:

call

combineReducers

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 源码解析 视图树的构建

在编写 Angular 应用时,开发者能够感知到视图是由组件、指令、模板和 HTML 原生标签构成的,实际上,Angular 会编译组件、指令、模板和 HTML 原生标签成各种 factory,在运行时生成 view,view 作为构建视图的基本单位构成视图树,并最终渲染为 DOM 树呈现在网页上。用流程图表示的话就像下面这样:

为了能够更直观地探究 Angular 中视图树的构建过程,我准备了一个小 demo,可以在这里找到,这个 demo 包括了常见的视图构成元素:

  • HTML 原生标签
  • ng-template
  • ng-container
  • 属性指令
  • 结构指令 *ngIf
  • 子组件
  • 模板插值 {{ }}

请通过 npm run ngc 命令来将 Angular 应用的源代码编译成 Angular 在运行时将会执行的代码。我们之后会专门讲解 Angular 的编译器。

编译后的代码将会以别名调用 Angular 的类和函数,这些别名都能够在 codegen_private_export 文件中找到其对应的真身,我在这里给出一个速查表:

别名 真身 说明
ɵccf createComponentRef 创建一个指代组件的 ComponentRef 对象

下面让我们正式开始探究 view 树的构建。

view 树的构建

view 树的构建过程从 createProdRootView 方法开始(至于为什么我们会在之后讲解 Angular 应用的启动过程时说明)。

在用的一些工具

可点开 edited 查看最后更新日期 ☝


操作系统

  • macOS
  • WSL2
  • Windows,只有跑 vscode 的时候用 Windows,其他项目都是跑在 WSL2 里

开发工具

挺少的,可能是我认识的程序员里最少的……

  • 只用 Visual Studio Code,主要使用以下这些插件
    • Git Graph
    • GitLens
    • vscode icon theme
    • Todo Tree
    • vscode-vim

vscode screenshot

  • iTerm2
  • Edge,推荐以下这些插件
    • 切换代理 SwitchyOmega
    • 网页黑暗模式 Dark Reader
    • GitHub 文件树 Octotree
  • Windows Terminal

命令行工具

macOS / Linux (WSL2)

更多命令行配置在 https://github.com/wendellhu95/dotfiles

Windows

生产力

  • Notion,笔记
  • Figma,做 UI 设计,现在团队也主要使用 Figma
  • Pocket,稍后阅读
  • Reeder 5,RSS 和 Pocket 的阅读器,Pocket 只用来保存链接
  • Typora,markdown 编辑软件,主要用来写文章

硬件

在公司

  • MacBook Pro 16‘ 2020
  • HP 27‘ 2K 显示器,型号未知,跟电脑一样都是公司发的
  • HHKB Professional Classic 黑色有刻 ❤️ 感谢老婆大人!

在家

  • Yoga 14s 锐龙版 2021
  • AOC 27‘ 4K 显示器 ❤️ 感谢老婆大人!
  • 小米便携鼠标
  • Keychron K2
  • iPhone 12 Pro
  • iPad 7 ❤️ 感谢老婆大人!我主要用它来看 PDF 和 Reeder
  • 小米无线充电宝

没错,被老婆包养了 🐿️

Angular CDK Overlay 源码解析 - 2

上一篇文章介绍了 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);
}

在了解了算法主要内容的基础下,我们来深入探究一下一些细节问题:

如何对元素进行定位

React 的新范式 - DI, RxJS & Hooks

Introduction

从去年 12 月起我一直在做在线文档的开发工作,工作中遇到了如下问题:

第一个问题是:如何处理不同平台之间的差异

我们的产品需要兼容多个平台,而且同一个功能在不同的平台上要调用不同的 API,之前为了处理这些平台差异性,写了很多这样的代码:

if (isMobile) {
  // ... mobile 的特殊逻辑
} else if (isDesktop) {
  // ... desktop 的特殊逻辑
} else {
  // ... browser 的逻辑
}

这样的写法不仅很难维护,而且由于无法 tree shake,会导致仅在 B 平台上运行的代码被 ship 到 A 平台用户的设备上,无端增加了包大小。

有一种比较 hacky 的方案是通过 uglify 来消除不会被执行的分支,但这仍然无法解决可维护性低的问题。

第二个问题是:如何在多个产品之间复用代码

我们的项目有两个文档与表格两个子产品,这两个产品在 UI、逻辑上既有相同之处又有不同之处。例如,两个产品标题栏下拉框的行为一致,但是下拉框内的菜单项不一致,比如文档有页面设置的菜单项,但是表格没有。又例如,两个产品鉴权、网络方面的逻辑一致,但是对文档模型处理方法不一致。

第三个问题是前端开发的老大难问题,即如何优雅地做状态管理和逻辑复用

目前对于这个问题,社区已经提出了很多方案:

  • mixin,Vue 社区比较多地采用这种方案,但是现在看来 mixin 并不是一种好的方案,它会导致隐式依赖、命名冲突等问题,React 官方已不推荐它,详细请看 Dan Abramov 的这篇文章 Mixins Considered Harmful
  • HOC,这是 React 之前推荐的方案,但这种方案同样不够理想,它会导致过多的标签嵌套,同样也会导致命名冲突。
  • Hooks,这是现在 React 社区的主流方案,它解决了 mixin 和 HOC 的问题,但也有其局限性,例如只能用于函数式组件、一不留神就会导致多余的重复渲染等等。

当然,并没有银弹可以完美地解决这个问题,但是我们仍然需要量体裁衣,针对我们项目的情况和需求来探索新的模式。

第四个问题是代码组织

产品逐渐复杂、代码量自然水涨船高,项目也随之腐化——比如大量的复制粘贴的代码、模块边界不清晰、文件和方法过长等等——结果导致维护成本剧增。

总结来说,我们需要一种机制:

  • 分离平台相关代码,并以统一的接口给业务代码调用;
  • 尽可能多地复用相同的 UI 和逻辑,并且能够方便地处理不一致的部分;
  • 提供一种新的状态管理和逻辑复用方式;
  • 组织代码,让各个模块之间尽量解耦,提升代码可维护性。

在寻找解决方案的过程中,我从 vscode 和 Angular 这两个项目中获得了许多灵感,它们的共性是都使用了依赖注入 (DI) 。

vscode

之所以要学习 vscode 是因为它和我们的项目存在很多相似之处:都是编辑器应用、需要处理复杂的数据和状态、架构复杂、需要支持多平台等等。

vscode 的代码组织和运行机制都明显突出了依赖注入在这个项目中的核心位置:

  • platform 目录下包含了 vscode 中的数十种服务(即依赖项)
  • vscode 基于依赖注入模式构建,从第一个类 CodeMain 开始,DI 就被引入,所有功能都被划分到数十个 service 当中,以 DI 的方式给相关方使用。
  • 平台差异也是通过 DI 处理的。(下面会有简单的讲解)

想要详细了解可阅读我在阅读 vscode 源码时写的两篇笔记。

Angular

Angular 框架本身和使用 Angular 开发的应用都基于依赖注入:

  • 依赖注入可以被用于装态管理和逻辑复用。逻辑上相关联的状态和方法被划分在各个类中,称为 service,service 可被注入到组件或其他 service 中。组件可以订阅 service 当中的状态(结合 RxJS),这样当 service 中的状态变更时,组件就会响应式地重渲染;当需要执行业务逻辑的时候,组件就可以调用 service 的方法。
  • 组件可以通过依赖注入访问父组件或者同元素上其他指令的属性和方法。
  • 框架提供的 HTTP 拦截器、路由鉴权接口也基于依赖注入。

那么,在 vscode 和 Angular 中大放异彩的依赖注入究竟是什么,为什么依赖注入可以解决文章开头提到的四个问题?

依赖注入

软件工程中,依赖注入是种实现控制反转用于解决依赖性设计模式。一个依赖关系指的是可被利用的一种对象(即服务提供端) 。依赖注入是将所依赖的传递给将使用的从属对象(即客户端)。该服务是将会变成客户端的状态的一部分。 传递服务给客户端,而非允许客户端来建立或寻找服务,是本设计模式的基本要求。

以上定义来自维基百科,而维基百科的特点就是不爱说人话。让我们换一种简单的表达:

依赖注入就是不自行构造想要的东西(即依赖),而是声明自己想要的东西,让别人来构造。当这个构造过程发生在自己的构造阶段时,就叫做依赖注入。

如果你深入挖掘的话,你会发现依赖注入和依赖倒置、控制反转等概念相关。可以参考这个知乎回答。

在依赖注入系统中,有三种主要角色:

  • 依赖项 (dependency):任何能被消费者——主要是类(在前端框架中要加上组件)——所使用的东西,这些东西可能意味着类、值、函数、组件等等,依赖项通常会有一个标识符以和其他依赖项相区别,这个标识符可能是接口、类,也可能是某些特定的数据结构。和一个标识符绑定的依赖项应该具有相同的接口,这样消费者才能无差别(无感知)地使用。
  • 提供者 (provider):或称注入器 (injector),它们会根据消费者的需要实例化和提供依赖项。
  • 消费者 (consumer):它们通过标识符从提供者获得依赖项,然后使用它们。一个消费者可能同时也是别的消费者的依赖项。

现在,你应该对依赖注入模式有一个大致的理解了,让我们来看看依赖注入如何解决文章开头提到的那些问题。

如何解决平台差异

要解决平台差异问题,就要将依赖于平台的代码和其他代码区别开来,让其他代码不需要知道当前在什么平台上。

这里以 vscode 的“拖拽文件到新目录前进行提示”功能为例,这一功能在应用中和在浏览器中的 UI 是不同的。

在 vscode 应用内它看起来是这样:

Snipaste_2020-01-21_11-13-01

在浏览器端看起来像是这样:

Snipaste_2020-01-21_11-14-27

实现方式就是将“弹出对话框”功能抽象成一个依赖项,在不同的平台注入不同的依赖项,这样调用者就不用关心当前的平台是什么了。

对于这段代码中的调用者来说,它并不知道也不需要知道目前的环境:

const confirmation = await this.dialogService.confirm({
	message,
	detail,
	// ...
});

因为 dialogService 是被注入的,而桌面端浏览器端分别注入了不同的 service。

详情请看第二篇关于 vscode 的博客。

如何实现代码复用

解决第二个问题的思路其实和解决第一个的是一致的。我们只需要将只要将不一样的部分抽象成依赖项,然后让其余代码依赖它就可以了。

如何解决状态管理

依赖注入可以管理共享状态。将多个组件共享的状态提取到依赖项中并结合发布-订阅模式,就能实现直观的单项数据流;将能改变状态的方法放到依赖项中,就能直观地知道这些状态会如何改变;另外还可以方便地结合 RxJS 管理更复杂的数据流。这种方案

  • 和 mixin 方案相比:
    • 它的依赖是显式的;
    • 不会导致命名冲突问题。
  • 和 HOC 相比:
    • 不存在多重嵌套导致的 wrapper hell 问题;
    • 很容易追踪状态的位置和变更。
  • 和 Hooks 相比:
    • 不用考虑 memorize 问题;
    • 用类来保存状态比 setState 等 API 更符合人类的思维直觉。
  • 实现的状态管理是 scoped 的,如果你在界面上有很多个相似的模块(比如 Trello 的看板),依赖注入模式可以让你方便地管理各个模块的状态,确保它们之间不会错误地共享某些状态。

如何解决代码组织问题

依赖注入模式中的“依赖项”概念会强迫开发者思考哪些代码在逻辑上是相关联的,应该放到同一个类当中,从而让各个功能模块解耦;也会强迫开发者思考哪些是 UI 代码,哪些是业务代码,让 UI 和业务分开。并且,由于在依赖注入系统中,类的实例化过程(甚至包括销毁过程)是依赖注入框架完成的,因此开发者只需要关心功能应该划分到哪些模块中、模块之间的依赖关系如何,无需自己实例化一个个类,这样就降低了编码时的心智负担。最后,由于依赖注入使得类不用再负责构造自己的依赖,这样就能很方便地进行单元测试。

wedi

为了能在 React 中方便地使用依赖注入模式,在重构的过程中,我实现了一个轻量的依赖注入库以及一组 React binding,现已开源。

GitHub / npm

wedi 具有如下特性:

  • 非侵入式:不像 Angular 那样一切都基于 DI,wedi 完全是 opt-in 的,你可以自己决定何时何处使用 DI。
  • 简单易用:没有引入任何新概念。
  • 同时支持 React 类组件和函数式组件。
  • 支持层次化依赖注入
  • 支持注入类、值(实例)、工厂函数三种类型的依赖项。
  • 支持延迟实例化。
  • 基于 TypeScript ,提供了良好的类型支持。

接下来我们结合几个具体的例子来讲解如何使用 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 装饰器获取依赖项了。同样,可以传入 trueInject 声明依赖是可选的。

多种多样的依赖项

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 可视化地查看依赖项的可用范围(也就意味着状态的范围)。

Snipaste_2020-01-22_17-44-12

这个截图来自用 wedi 构建的 TodoMVC(开发环境下)。

结合 RxJS

依赖注入模式可以很方便地与响应式编程相结合,用于状态管理。当一些组件之间需要共享状态时,你就可以把状态提取到各个组件都能访问到的依赖项当中,然后去订阅该状态的改变。

下面是一个计时器的例子。

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 中的那些服务

Introduction

上一篇文章介绍了 vscode 的依赖注入机制。

在 vscode 中,依赖注入主要用于将服务注入到消费者对象当中将一些基础能力提供给业务代码使用

我们来拆解一下这句话:

  • 服务。熟悉 Angular 的同学肯定也很熟悉这个概念。简单来说,服务就是对逻辑上相关联的数据和方法(函数)的封装,让这部分逻辑可复用。比如,如果我们通过将处理 HTTP 请求的发送、处理、中间件、拦截、取消、去重等等的逻辑封装到一个名为 HTTPService 的服务当中,这样在使用 HTTP 的时候,我们就不需要再去担心具体的实现问题了。从这个例子也可以看出,服务实际上也是一种解耦(decoupling)和关注分离(SOC)的手段。
  • 消费者对象。既然有服务,那么肯定有其他对象来使用这些服务,这些对象我们就称为消费者对象,它们往往和业务直接相关,在 vscode 中可以理解为是用户能够使用到的功能。
  • 注入。消费者对象在使用服务的时候,并不是自己构造一个服务对象(仔细品味:如果它知道要具体要构造哪个类,它实际上也就知道了这个类的实现细节),而是从某个服务注册机构(通常叫做注入器)得到一个服务对象,只要这个对象满足它的接口的要求就可以了,这个过程往往是在消费者对象构造时完成的,所以被称为注入。这实际上是一种控制反转(IOC)。

如果你对上面的某些概念不是很理解,你可以稍后去学习它们,这里的铺垫已经足够你理解本文的全部内容。

到这里我们了解了服务的含义,接下来就来看看 vscode 中用到了哪些服务吧!

我们之后才会讲到 Electron 的双进程架构。这里画了一个简图来方便你理解下面文章的内容,仅仅用于表达各个对象间的层次关系。

-------------------------------------    ------------------------------------------
                                                         *Workbench*
-------------------------------------    ------------------------------------------
      Window ----electron.BrowserWindow.load()----> *Desktop Main / BrowserMain*
-------------------------------------    ------------------------------------------
           *CodeApplication*
-------------------------------------    ------------------------------------------
              *CodeMain*
-------------------------------------    ------------------------------------------
             Main Process                              Renderer Process
-----------------------------------------------------------------------------------
                                    Electron
-----------------------------------------------------------------------------------

主线程中的服务

CodeMain

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

DesktopMain 是 vscode 渲染进程中最底层的类,它虽然自己并没有创建 InitailizationService,但是它创建了一个 service 集合并将这个集合传递给 Workbench,由 Workbench 创建了 InitializationService

在这一层次上提供的服务有:

  • MainProcessService 用于和主进程进行通讯
  • ElectronEnvironmentService Electron 环境变量
  • WorkbenchEnvironmentService Workbench 环境变量
  • ProductService
  • LogService 日志
  • RemoteAuthorityService
  • SignService
  • RemoteAgentService
  • FileService 文件存储服务

等。

Workbench

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 窗口 onBeforeUnloadonWillUnload 的回调,让 vscode 的其余部分可以在窗口即将关闭之前做一些 clean up 的工作。
  • TextMateService 用于代码高亮
  • NativeKeymapService 用于处理不同语言键盘布局 keycode 不同的问题
  • ExtensionService 管理拓展
  • ContextMenuService 上下文菜单

等等。

为什么是依赖注入?

到这里,我们就对 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 和浏览器中展现出不同的样式:

在 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 在浏览器中运行,只需要修改被注入的服务,然后通过不同的打包入口(已在上文中介绍)引入这些服务,无须修改上层代码。

三、依赖注入模式也带来了软件工程方面的一些好处

  • 依赖注入是一种得到时间锤炼、十分成熟的技术,开发者们很容易就能理解它,这使得架构清晰易懂,上手速度快。
  • 方便分工,开发团队成员可以进行明确的分工,只要在编码时严格遵守接口的要求,就无需担心会搞坏队友的代码。
  • 能够充分利用 TypeScript 提供的类型信息在代码之间快速跳转。

Conclusion

  • vscode 在主进程中和渲染进程中都通过依赖注入的方式给业务代码注入了很多基础服务
  • 通过在不同的代码入口中引入符合同一接口的不同服务,vscode 实现了跨平台(桌面端、web)运行

这篇文章展示了 vscode 如何利用依赖注入系统提供各种基础功能来服务业务代码,对需要支持多平台的大型应用提供了一个优秀的模板。

3650 天自学编程

这篇文章翻译自 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)这样的标题到底意味着什么

  • 自学:在 24 小时内,你根本没有时间去写有意义的代码,并从成功或者失败中获得经验教训。你也没有时间去和有经验的程序员合作,了解 C++ 开发生态究竟是怎样的。简单来说,你没有时间深入学习。所以,这些书仅仅能让你熟悉一下 C++,谈不上深入理解。而正如亚历山大·波普所说:一知半解最危险。
  • C++:在 24 小时内,你最多学习一些 C++ 的语法(前提是你掌握了其他一门编程语言),但是你对如何使用这门语言知之甚少。打个比方,假如你是一个 Basic 程序员,你能学到的仅仅是如何用 C++ 语法写 Basic 风格的程序,但是你不可能知道 C++ 擅长(或者不擅长)做什么。Alan Perlis 曾经说过:“不能影响你编程思维的语言,根本不值得一学”。如果你是为了使用一个现成的工具去解决一个具体的问题而学习 C++(更有可能是去学习 JavaScript 或者 Processing 这样的语言),你并不是在学习编程,而是在学习如何解决那个问题。
  • 24 小时:不行的是,这远远不够,我会在下一个章节详细阐述。

3650 天自学编程

研究者们(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 小时,持续数年的不断投入。”

如果你想成为一名程序员

如果你想成为一名成功的程序员,我这里有一些建议:

  • 发掘编程的乐趣,为了好玩而编程。确保编程是如此的有趣,值得在它身上花费 10 年 / 10000 个小时。
  • 编程。最好的学习方式就是做中学。用更术语化的口吻来说:“个人在某领域的效率最大化,不是靠经验的积累自动实现的,即使是已经具备充足经验的人,也可以通过刻意的努力来提升其表现”(366 页)。“最有效的学习方法要求定义良好的任务、对于学习者来说合适的难度、有效的反馈,以及重复和纠正错误的机会(20-21页)。Cognition in Practice: Mind, Mathematics, and Culture in Everyday Life 这本书佐证了以上的观点。
  • 与其他程序员交流。阅读其他人写的程序,这比读任何书或接受任何培训都要重要。
  • 如果愿意的话,去读大学(能读到研究生更好)。这使得你能够去做一些需要资质证明的工作,并且让你对某个领域有更加深入的了解。但是如果你不喜欢上学的话,你可以(如果有足够的决心)通过自学或者工作来获得类似的经验。不论如何,光读书是不够的。“计算机科学教育并不能使人成为一名杰出的开发者,就像学习刷子和颜料并不能使人成为画家一样”,The New Hacker’s Dictionary 的作者 Eric Raymond 曾这样说。我听说过的最厉害的开发者中的一位仅仅拥有高中学历,他写了许多很棒软件,有他自己的新闻集团,并且通过股权收益买下了一间夜店
  • 和其他程序员一起做项目。在一些项目里,要做顶尖的程序员,而在另一些项目里,可以做最糟糕的程序员。当你是顶尖程序员的时候,你可以测试自己领导一个项目的能力,用你的眼界来启发别的成员。当你是最差的程序员的时候,你可以观察顶级程序员的做法,而且你会知道他们不喜欢做什么(因为他们会把这些事情交给你来做)。
  • 从其他程序员的项目里学习。理解别人写的代码,看看当原作者不在的时候,你需要耗费多少精力来学习代码和修复问题。思考如何设计代码结构以让后续的维护者更容易接手你的代码。
  • 学习至少 6 种编程语言。包括一种强调类的语言(Java、C++),一种强调函数抽象的语言(Lisp、ML、Haskell),一种支持句法抽象的语言(Lisp)、一声明式语言(Prolog、C++ 模板),以及一种强调并行性的语言(Clojuse、Go)。
  • 记得在“计算机科学”这个词里有**“计算机”**三个字。了解电脑执行指令、从内存取数(包括使用或不使用缓存的情况)、从磁盘顺序读取数据,或者是磁盘寻道各自要花费多少时间。(答案见下文)
  • 参与语言标准的制定。可以是列席 ANSI C++ 委员会,也可以是确定团队的代码缩进是 2 个还是 4 个空格。不管怎样,你都会了解到其他人如何看待一门语言,他们的看法有多强烈,甚至是为什么他们有这样的看法。
  • 知道何时停止纠结于语言标准。

知道了以上这些,很难不令人怀疑仅仅通过书本学习,一个人能在编程这条路上走多元。在我第一个孩子出生之前,我读了所有“如何……”这样的书,但最终还是免不了变成一个一无所知的新手爸爸。30 个月后,我的第二个孩子临盆时,我重新读了一遍那些书吗?不,相反,我依赖自己的经验,最终事实证明经验比育儿专家们写的大部头更有效。

Fred Brooks 在他的论文没有银弹(No Silver Bullet)中提出了一个培养优秀软件架构师的三部曲:

  1. 尽早开始系统地寻找顶级程序员。
  2. 给候选人指定一个导师,负责他的职业发展,并仔细记录其成长轨迹。
  3. 给这些成长中的程序员机会来交流并互相激励。

这个方法假定候选人已经具备了某些成为优秀架构师所必须的特质,用人方所需要做的仅仅是把他们发掘出来。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 纳秒

附录:选择编程语言

有几个人问过我第一门编程语言应该选择哪个。没有标准答案,但你可以从这些方面考虑:

  • 参考朋友。当被问及“我应该用哪种操作系统,Windows Unix 还是 Mac?”的时候,我的回答通常是:“用你的朋友们在用的那种”。你从朋友那里学到的东西,大于系统或者编程语言本身的差别。或者考虑你未来的朋友——你将要加入的程序员社区的成员。你所选择的语言有一个蓬勃发展的社区,还是有一个不断萎缩的用户群?有足够的书籍、网站或者论坛能够方便地获取资源吗?你喜欢论坛里的那些人吗?
  • 保持简单。像 C++ 和 Java 这样的编程语言是给由富有经验的程序员所组成的大型团队开发专业级产品用的,他们非常在乎运行性能。结果就是,这些语言有一些非常复杂的东西来应对他们所面临的问题。而你在学习编程的时候不需要这些复杂的东西。你需要的是一门设计之初就注重易学性的语言,任何新手程序员都能掌握的那种。
  • 。你会用哪种方式学习弹钢琴?一种是常见的、交互的方式:按下琴键就会马上听到声音;另一种是“批处理”模式,你只有弹完一整首曲子才能听到声音。显然,用交互的方式学习钢琴才会简单,这对编程来说也是一样的。坚持使用一种交互式的语言。

基于以上原则,我像编程新手们推荐的第一门编程语言是 Python 或者 Scheme。另外一个可选项是 JavaScript,并不是因为它对新手友好,而是因为有很多在线教程,比如可汗学院的这一套教程。如果学习者的背景比较特殊的话,还有其他一些好的选择。儿童可能会更喜欢 Alice、Squeak 或者 Blockly 这些语言(老年学习者或许也会喜欢)。重要的是做出选择之后马上开始学习。

swr 源码解析

不久前知名前端团队 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,但无论如何都需要传入一个符合 keyInterfacekey 才行。swr 会对开发者传入的参数进行处理,最终得到以下四个重要变量:

  • key,这是请求的唯一标识符
    • 有时候也包括请求函数的参数 fnArgs
  • keyErr,对应请求标识符的错误标识符
  • config,由默认配置,上下文配置和函数参数合并而成的配置对象
  • fn,请求函数

然后,swr 会准备一些变量

  • initialDatainitialError,swr 会通过 cacheGet 方法从一个全局的 __cache map 中按照 key 存取缓存值,这其实就是 swr 请求缓存的实现原理,也就是对 RFC 5861 的实现,并没有任何黑魔法!
  • datadispatch,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 不同,就会执行更新当前的 keydata,并调用 dispatch 进行重绘等操作。

然后,在 revalidate 的基础上定义了 softRevalidate 函数,在 revalidate 执行时执行去重逻辑。

const softRevalidate = () => revalidate({ dedupe: true })

然后 swr 就会调用 softRevalidate,如果当前有缓存值且浏览器支持 requestIdleCallback 的话,就作为 requestIdleCallback 的回调执行,避免打断 React 的渲染过程,否则就立即执行。

错误处理

如果数据请求的过程中发生了错误该怎么办呢?

注意到 revalidate 的函数,有很大一部分都在一个 try catch 块中,如果请求出错就会进入 catch 块。

主要做如下几件事情:

默认的重试方法会在当前文档 visible 时执行重试,使用了一个指数退避策略,在一定的时间后重新调用 revalidate

请求去重 (dedupe)

我们在讲解主流程的过程中忽略了很多代码,而这些代码实现了 swr 的一些重要的实用特性,从这个小节开始我会一一讲解。

swr 提供了请求去重的功能,避免某个时间段内重复发起的请求过多。

实现的原理也非常简单。每次 revalidate 函数执行的时候,都会判断是否需要去重

let shouldDeduping =
  typeof CONCURRENT_PROMISES[key] !== 'undefined' && revalidateOpts.dedupe

即检验 CONCURRENT_PROMISES 里有没有 key 所对应的进行中的请求。

如果 shouldDedupingtrue直接等待请求完成,如果为 false,就按照上文所述进行处理。

revalidateOptsdedupe 属性何时为 true 呢?可以看到声明 softRevalidate 的时候传入了参数:

const softRevalidate = () => revalidate({ dedupe: true })

而调用 useSWR 时返回的 revalidate 就是原本的 revalidate ,不带 dedupe 属性。

请求依赖 (dependent fetching)

还是举官网的例子

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 为一个函数,函数体中访问 userid 属性。

swr 通过 getKeyArgs 函数处理 key 为函数的情况,并将调用过程包裹在函数里:

  if (typeof _key === 'function') {
    try {
      key = _key()
    } catch (err) {
      // dependencies not ready
      key = ''
    }
  }

userundefined 时,获取 undefined.id 出错,key 为空字符串,而 revalidate 函数在 key 为假值时直接返回

if (!key) return false

因此在第一次渲染(MyProjects 函数第一次被调用)时,第二个请求实际上并未发出。而当第一个请求得到响应时,dispatch 会导致组件重绘(MyProjects 函数再次被调用),此时 user 不是 undefined ,第二个请求就能发出了。

所以 swr 所支持的“最大并行请求”的原理非常简单,就是判断能不能获得 key,如果不能获得 key 就用 try catch 语句捕获错误,不发出请求,等待其他请求得到响应后在下次重绘时再试。

请求广播

当请求成功或失败时,都需要调用 broadcastState 函数,这个函数本身非常简单,根据 keyCACHE_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 发起的请求得到响应时,刷新所有使用相同 keyuseSWR

Mutate

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 调用的时间,说明请求已经过期,就抛弃掉这个请求不做任何后处理。

自动轮询 (refetch on interval)

只需要设置一个 timeout 定时调用 softRevalidate 就可以了

Config Context

在 swr 执行的开始,准备 config 对象时调用 useContext 获取 SWRConfigContext

  config = Object.assign(
    {},
    defaultConfig,
    useContext(SWRConfigContext),
    config
  )

Suspense

想要支持 Suspense 很容易,仅需要把数据请求的 Promise 抛出就可以了

throw CONCURRENT_PROMISES[key]

但是和通常情况下不同:当抛出的 Promise 未 resolve 时,React 并不会渲染这部分组件,因此返回值里也无需判断 keyRef.current 是否和 key 相同。

flask-login 源码解析

这篇文章介绍了 flask-login 是如何实现一个不需要使用数据库的用户认证组件的。


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 源码解析

我们按照使用过程中调用 flask-login 的顺序来解析其源码。

LoginManager 对象

先来看 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,这样我们就可以在视图函数或者模板文件中访问这个变量了。

user_loader 修饰器

然后就到了这个方法,它是 LoginManager 的实例方法,把 user_callback 设置成我们传入的函数,在实际的使用过程中,我们是通过修饰器传入这个函数的,就是 load_user(user_id) 函数。

def user_loader(self, callback):
   self.user_callback = callback
   return callback

该方法要求你的回调函数必须能够接收一个 unicode 编码的 ID 并返回一个用户对象,如果用户不存在就返回 None。

login_user 方法

我们跳过对 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 函数访问 userlogin_manager.id_attribute 属性得到的。追根溯源,最终 getattr 访问的是 userget_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)

login_required 修饰器

这个修饰器常被用来保护只有登录用户才能访问的视图函数,它会在实际调用视图函数之前先检查当前用户是否已经登录并认证,如果没有,就调用 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 对象

在之前的分析中,可以看到这个变量经常出现并大有用途,开发者可以通过访问这个变量来获取到当前用户,如果用户未登录,获取到的就是一个匿名用户,它的定义:

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())

logout_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

remember_me cookie

记得我们之前提到 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)

总结

  • flask-login 使用 Flask 提供的 session 来保存用户信息,通过 user_id 来记录用户身份,_id 来防止攻击者对 session 的伪造。
  • 通过 _request_ctx_stack.top.user,flask-login 实现了线程安全。
  • 通过 cookie 来实现 remember 功能。

其他功能如 fresh login 请自行查看源码了解。

仿造 flask-login 写一个基于 token 的身份认证模块

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

Few Things I Learned while Developing an Icon Library / 时隔一年回顾 Icon 组件库的开发

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.

https://miro.medium.com/max/3840/0*YFupRTSUmB8qV3n_.png

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. ;)

Two Approaches

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:

  • On the first hand, we cannot afford to ship all icons because they are too large and users hate it when we do that.
  • On the other hand, we must ship all icons to avoid breaking changes since we don’t know what users will eventually use.

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”.

The Third Approach

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.

https://miro.medium.com/max/3048/1*b4lnigsma-eueVGsup6WvA.png

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:

  1. The second approach is great, and we should support it as well. So we endorsed static loading.
  2. We needed some scripts to generate icon resources.
  3. Our old API was no API (literally). Users just needed to write an i tag in this way <i class="anticon anticon-clock"> . So we needed to support the old API as well (we used MutationObserver).
  4. We needed to implement features such as spinning, custom icon, namespaces and iconfont.
  5. Docs (very important).

Finally, I wrote @ant-design/icons-angular and rewrote the Icon component of ng-zorro-antd.

https://miro.medium.com/max/2560/0*sF7medQ3PrHC_76y.png

@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.

https://miro.medium.com/max/5712/1*c2W1ILxsKRI0kcoucpmrCQ.png

Click on an icon add you can get a piece of template rendering that icon. ;)

Conclusion

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?

  • We use SVG to render icons and ship as less code as possible.
  • We helped our users to upgrade painlessly, avoided breaking things.

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:

  1. Think about how changes you make will influence your users, and think twice. Try not to make breaking changes. Adapt the old API and give users have time to migrate to the new. Stick to the semantic versioning system.
  2. Don’t make things hard to revert and definitely not make decisions for your users. Instead, provide choices. It’s not a wise decision for the antd team to import all icons and not to provide a more flexible way (such as dynamic loading). And we learned from their mistakes.
  3. Work hard before you reach a conclusion. Keep asking yourself “is there a better way to do this?”

That’s all. Thank you for reading.

Koa 生成器解析

简要分析了 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 对生成器的写法要求

在 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 的执行过程

  1. 当 Koa 要运行生成器函数转换成的中间件的时候,即调用 return Promise.resolve(fn(context, dispatch.bind(null, i + 1))); 时,执行 return co.call(ctx, mw.call(ctx, createGenerator(next))),它返回一个 Promise
  2. 其中,用户提供的生成器 mw 被调用,同时调用 createGenerator(next) 返回一个迭代器
  3. co 调用自己的 onFulfilled 方法执行用户的迭代。用户会写 yield next 这一句,将控制权交还给 co, co 调用 next 方。此时,由于 ret.valuecreateGenerator(next) 返回的迭代器,所以 next 方法进入 if (value && isPromise(value)) return value.then(onFulfilled, onRejected); 的分支
  4. value 被封装成一个 Promise,其实内部又用了一次 co 对 return yield next 进行执行
  5. return yield next 被执行,进入下游 middleware 并最终回溯到当前的 middleware
  6. co 第二次执行 onFulfilled,然后调用 next 方法,此时 ret.done 为真,返回一个解决态的 Promise
  7. 这里就回到了 return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));,继续往上游回溯

到这里,我们就梳理清楚了 Koa 1.x 时代所采用的生成器函数是如何被 Koa 2.x 所采用的异步函数兼容的。


可能需要画张图来更清楚地展示这个过程。

Flask 源码解析

本文简单的分析了 Flask 的源码,主要关注 WSGI、Flask 对象的数据结构、Flask 应用启动过程、请求处理过程、视图函数、URL 的映射、应用上下文和请求上下文。


这是 Flask 官方钦定的 Demo 代码:

from flask import Flask
app = Flask(__name__)

@app.route(‘/‘)
def index():
    returnHello, world!’

if __name__ ==__main__’:
    app.run()

这篇文章从这个简单的代码开始,简要介绍了 WSGI、Flask 对象的数据结构、Flask 应用启动过程、请求处理过程、视图函数、URL 的映射、request 和 response 类(应用上下文和请求上下文),这些主题涵盖了一个 web 框架的核心。

WSGI

在用户发起的请求到达服务器之后,会被一个 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 框架,负责解决的问题就是:

  1. 作为一个应用,能够被 HTTP 服务器所调用,必须要有 __call__ 方法
  2. 通过传入的请求信息(URL、HTTP 方法等),找到正确的业务处理逻辑,即正确的视图函数
  3. 处理业务逻辑,这些逻辑可能包括表单检查、数据库 CRUD 等(这个在这篇文章里不会涉及)
  4. 返回正确的响应
  5. 在同时处理多个请求时,还需要保护这些请求,知道应该用哪个响应去匹配哪个请求,即线程保护

下面就来看看 Flask 是如何解决这些问题的。

参考阅读:一起写一个 web 服务器,该系列文章能够让你基本理解 web 服务器和框架是如何通过 WSGI 协同工作的。

应用的创建

源码阅读:app.pyFlask 类的代码。

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 the rules as keyword arguments!

可以看到这个类的对象储存了所有的 URL 规则和一些配置信息。由于 werkzeug 的映射机制比较复杂,我们下文中讲到映射机制的时候再深入了解,现在只要记住 Flask 应用(即一个 Flask 类的实例)存储了视图函数,并通过 url_map 这个变量存储了一个 URL 映射机构就可以了。

应用启动过程

源码阅读:app.pyFlask 类的代码和 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

可以看到这个方法基本上是在配置参数,实际上启动服务器的是 werkzeugrun_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 并把 environstart_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) 中,responsewerkzueg.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 属性为 Falsebefore_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 规则中有没有相应的 endpointvalue 值,如果有,那么就调用 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 the Rule 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 被创建后,通过 Mapadd 方法被绑定到 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

Rulebind 方法的内容,就是添加 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 的实例。

接着 RequestContextpush 方法被调用,这个方法将自己推到 _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 的线程安全就是有意义的了,在其他多线程的服务器中也是一样。

总结

一个请求的旅程

这里,我们通过追踪一个请求到达服务器并返回(当然是通过“变成”一个相应)的旅程,串讲本文的内容。

  1. 在请求发出之前,Flask 注册好了所有的视图函数和 URL 映射,服务器在自己身上注册了 Flask 应用。
  2. 请求到达服务器,服务器准备好 environmake_response 函数,然后调用了自己身上注册的 Flask 应用。
  3. 应用实现了 WSGI 要求的 application(environ, make_response) 方法。在 Flask 中,这个方法是个被 __call__ 中转的叫做 wsgi_app 的方法。它首先通过 environ 创建了请求上下文,并将它推入栈,使得 flask 在处理当前请求的过程中都可以访问到这个请求上下文。
  4. 然后 Flask 开始处理这个请求,依次调用 before_first_request_funcs before_request_funcs view_functions 中的函数,并最终通过 finalize_request 生成一个 response 对象,当中只要有函数返回值,后面的函数组就不会再执行,after_request_funcs 进行 response 生成后的后处理。
  5. Flask 调用这个 response 对象,最终调用了 make_response 函数,并返回了一个可遍历的响应内容。
  6. 服务器发送响应。

Flask 和 werkzeug

在分析过程中,可以很明显地看出 Flask 和 werkzeug 是强耦合的,实际上 werkzeug 是 Flask 唯一不可或缺的依赖,一些非常细节的工作,其实都是 werkzeug 库完成的,在本文的例子中,它至少做了这些事情:

  1. 封装 ResponseRequest 类型供 Flask 使用,在实际开发中,我们在请求和响应对象上的操作,调用的其实是 werkzeug 的方法。
  2. 实现 URL 到视图函数的映射,并且能把 URL 中的参数传给该视图函数。我们看到了 Flask 的 url_map 属性并且看到了它如何绑定视图函数和错误处理函数,但是具体的映射规则的实践,和在响应过程中的 URL 解析,都是由 werkzeug 完成的。
  3. 通过 LocalProxy 类生成的 _request_ctx_stack 对 Flask 实现线程保护。

对于 Flask 的源码解析先暂时到这里。有时间的话,我会分析 Flask 中的模板渲染、import request、蓝图和一些好用的变量及函数,或者深入分析 werkzeug 库。

参考阅读

  1. flask 源码解析系列文章,你可以在读完本文了解主线之后,再看这系列文章了解更加细节的东西。
  2. 一起写一个 web 服务器

关于this变量的访问问题

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 源码解析 - 依赖注入

cover

最近在阅读 vscode 的源代码。我将会在 blog 中逐步公开一些整理过的学习笔记和大家交流。

在阅读这篇文章之前,你需要依赖注入模式有基本的了解。和依赖注入相关的项目包括 AngularNestJSInversifyJS


Introduction

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 中使用依赖注入模式的三个要素:

  • 一个将要被实例化的,在其构造函数中使用装饰器声明了需要注入的参数(依赖注入项)
  • 装饰器,是注入的参数的类型标识(indentifier)
  • 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)。

当类声明的时候,装饰器就会被应用(我做了一个简单的证明),所以在有类被实例化之前依赖关系就已经确定好了。

InstantiationService

InstantiantionService 用于提供依赖注入项,也就是起到依赖注入框架中的注入器(Injector)的功能,它以 identifier 为 key 在自身的 _services 属性中保存了各个依赖项的实例。

它暴露了三个公开方法:

  1. createInstance,该方法接受一个类以及构造该类的非依赖注入参数,然后创建该类的实例。
  2. invokeFunction,该方法接受一个回调函数,该回调函数通过 acessor 参数可以访问该 InstantiationService 中的所有依赖注入项。
  3. 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,分别做了以下这几件事情:

  1. 第一个 while 是利用 DFS 的方法,找到一个类的所有未实例化的依赖(还是基于 SyncDescriptor),以及依赖的未实例化的依赖……最终得到一个依赖图
  2. 第二个 while 根据前一步得到的依赖图,从根节点开始构造实例

最后我们就得到了我们最初想要的依赖。

其实利用递归也可以实现。

依赖图

vscode 在实例化类及其依赖(以及依赖的依赖)的时候并没有使用简单的递归方法,而是利用 Graph 来管理类之间的依赖关系。

假设我们要实例化 A 类,vscode 会监测到它需要依赖 BC 类,并将依赖关系记录到图当中,图中的一个 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 的模式。

InstantiationService 的那些方法

InstatiationService 这个类有很多方法,而且名字都很接近,这里列一个梗概,方便大家阅读源码:

  • createChild 创建一个子 InstantiationService
  • invokeFunction 执行一个函数,该函数可以通过 accessor 访问 InstantiationService 里存储的服务
  • createInstance 创建一个服务
  • _createInstance 实例化的最终方法,new 调用的位置
  • _setServiceInstance 将一个创建好的 service set 到保存了对应的 identifier 的 InstantiationService 当中
  • _getServiceInstaneOrDescriptor 根据 identifier 从某个 InstantiationService 中拿到服务实例或者 SyncDescriptor
  • _getOrCreateServiceInstanceinvokeFunction 所调用,会尝试调用 _getServiceInstanceOrDescriptor 拿到服务实例,如果拿到的是一个 SyncDescriptor,则走 _createAndCacheServiceInstance
  • _createAndCacheServiceInstance 这里根据“要被创建的服务”的“未被实例化的依赖”来构建依赖树,然后依次构建这些未被实例化的依赖
  • _createServiceInstanceWithOwner 寻找保存了对应的 identifier 的 InstantiationService ,调用它的 _createServiceInstance 方法进行实例化
  • _createServiceInstance 这里处理延迟实例化逻辑,调用 _createInstance 的时候,所有依赖应该都已经被实例化,而不是 SyncDescriptor

Conclusion

  • vscode 自己实现了一套依赖注入机制,并没有依赖 reflect-metadata(知乎上的这篇文章对此有误)
  • InstantiationService 是实现依赖注入的核心
  • 用装饰器来声明依赖关系
  • 允许可选依赖
  • 允许延迟实例化
  • 支持多层依赖注入

这篇文章 cover 了 vscode 依赖注入模式的主干部分,能帮助我们理解 vscode 是如何管理各种各样的服务的,也对自行实现依赖注入提供了一些有益的参考。

Angular 学习资源清单

这篇文章是对之前知乎上对问题“Angular 新手如何有效学习 Angular”的回答的扩充。按照学习的先后顺序、学习的难易程度列出了在学习 Angular 的过程中可能会需要的材料。

angular 新手如何有效学习 angular? - 知乎

目前的版本仍然不完整,还会扩充,建议收藏这个网页。

最近的更新日期是 2019 年 5 月 30 日(见上方编辑日期)。

学习 Angular 之前

这一部分和常见的前端入门学习内容并无不同。请确保你掌握了 HTML 和 JavaScript 的基础,一个检查的好办法是看网上的前端面经,会被不同的面试官问到的、框架无关的问题都应该答得上才对。

  1. 《JavaScript 高级程序设计》
  2. 《你不知道的 JavaScript》
  3. 《高性能 JavaScript》
  4. 《ECMAScript 6 入门》

初学 Angular

  1. 掌握 Angular 基础
    1. Angular 官方文档,也可以看中文版(之后出现的英文文档基本能找到对应的中文版本,不再贴出链接),至少要阅读 Fundamentals 及之前的部分
  2. 掌握 RxJS 基础
    1. RxJS 官方文档,至少要阅读 Overview 部分(当然无需阅读全部的 operator)
    2. 30 天精通 RxJS 系列
  3. 掌握 TypeScript 基础
    1. TypeScript 官网文档,读完 Tutorial 和 Handbook,对类型标注之外的语法有印象即可
  4. 学习组件库
    1. ng-zorro-antd
    2. Angular Material
  5. 通过项目(教程、demo、实验室、商业项目)学习
    1. today-ng-steps,使用 ng-zorro-antd 组件库的 Angular 和组件库入门教程(自己写的私货,目前处于长草状态,谨慎学习)
    2. 如果在学校的话可以看看学校实验室需不需要前端
    3. 寻找实习
  6. 贡献 Angular 生态项目
    1. ng-zorro-antd 是我所知的对中文贡献者最友好的项目了(确信)!而且不时会有一些 issue 会被标注为 Good First Issue(新手友好)值得尝试解决,所以可以关注 ng-zorro-antd 的 issue 情况(免费的代码 review!)。

进一步学习

到这里再学习 Angular 应该已经度过了最困难的阶段了,你要做的是从各种信息源中获取新知识,和深入理解 Angular 原理,学习 Angular 生态链中的其他库,剩下的就是靠做项目来不断提升了。

值得关注的信息源

  1. Angular in Depth,最深入最精华的 Angular 进阶学习指南,变更检测、路由等都能在这个专栏里找到对应的专题
  2. NG ZORRO 团队知乎专栏,不仅有 ng-zorro-antd 新版本介绍,团队成员还会不时分享一些 Angular 和 ng-zorro-antd 开发过程中遇到的问题和设计考量
  3. Angular 变更检测可视化
  4. 之前提到的各个文档,是时候通读一边,补上未读的东西了

值得了解的库

  1. NgRx,Angular 状态管理库(不过很有可能会觉得在 Angular 搞 Redux 那一套不是个好主意)
  2. ng-packagr,如果要开发 Angular 组件库的话这个库是必学的

值得阅读源码的库

  1. Angular
  2. Material
  3. RxJS

参加开源

可能对于在校大学生来说很少有时间或机会参与真正的企业级产品开发,所以建议大家从写开源开始

参考

  1. 由 vthinkxie 整理的《Angular 资料获取不完全指南》列举了非常多的学习材料,可供参考
  2. 另外一个好办法是顺藤摸瓜,看其他 Angular 开发者的关注列表里都有什么,这能帮助你认识其他优秀的 Angular 开发者,获取一手资讯

Angular 源码解析 Zone.js

Angular 源码解析系列。这篇文章有关于 Zone.js 的用途,实现和 NgZone 的实现,以及 Angular 如何使用 Zone.js 实现自动变更检测。


这篇文章是 Angular 源码解析系列的第一篇,分为以下三个小节:

  • 为什么 Angular 需要 Zone.js?
  • Zone.js 如何工作?
    • Zone.js 核心的实现
    • 举一个例子介绍 Zone.js 如何打补丁
  • Angular 如何使用 Zone.js?

阅读这篇文章你需要熟悉 JavaScript 的事件循环 (Event Loop) 机制

现在 Zone.js 的源码已经被放到 Angular 底下以 mono repo 的形式管理,你可以通过下面的链接阅读 Zone.js 的源码。

angular/angular

为什么 Angular 需要 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.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,调用某个回调函数出错时触发的钩子。
    • onScheduleTaskZoneTask 被安排时触发的钩子,比如调用了 setTimeout
    • onInvokeTaskZoneTask 被触发时触发的钩子,比如 setTimeout 到时。
    • onCancelTaskZoneTask 被取消时触发的钩子,比如用 clearTimeout 取消了计时器。
    • onHasTask,检测到有或无 ZoneTask 时触发的钩子(即对第一个 schedule 的 zone 和最后一个 invoke 或 cancel 的 task 触发)。
  • ZoneDelegate,负责调用钩子。

官方文档中对这些类所实现的接口有非常详细的注释,可以比较下面的内容阅读。

Zone

这些代码是对 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 的加载时会详细说明。

这个类的构造函数,要点如下:

  • 必须要有名字作为 zone 的标识符。
  • 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 类的实现。
    • 未被安排的 MacroTaskEventTask 两种类型的 task 是不需要执行的。
    • 调用 ZoneDelegate 的方法来执行 task。
    • 执行完毕之后调整 task 的状态,对于周期性触发的 task 来说,需要通过 reEntryGuard 进行状态保护。
  • scheduleTask,用来安排一个 task 的执行环境。

在阅读代码的时候我们经常能看到 _currentZoneFrame 这个变量,这实际上上记录了一个 zone 的栈(以链表的形式),如果某个 zone 中执行了函数调用,该 zone 就进入这个栈中,这样就将函数的调用栈与进入和离开 zone 的先后顺序对应起来了。

该变量被声明在创建 Zone.js 全局对象的函数里(即在一个闭包中),外部没有办法直接访问。

ZoneDelegate

代码在这里

要点:

  • 它的构造函数中前面一坨代码实际上干的事都可以概括为:我有没有这个回调,没有我就用父 ZoneDelegate 的(当然父级也有可能是空的)。这样父级 zone 可以通过 delegate 干预子 zone 的行为。
  • 大部分方法都是在尝试调用钩子,不成的话再进行磨人的简单处理。
  • 特别注意 scheduleTask(),该方法对于 MicroTask 会调用 scheduleMicroTask

ZoneTask

这个类的代码在这里

要点:

  • _state,记录了这个 task 的状态。
  • 在构造函数的最后要为当前 task 设置 invoke 方法,通常走的是 else 的分支,可以看到这里绑定了 invoke 函数中的 task 为当前 task。
  • static invokeTask(),该函数执行一个 task:
    1. 执行 task 的时候会增加 _numberOfNestedTaskFrames 计数器的值。
    2. 之后通过 zone 执行 task。
    3. 当执行完毕的时候调用 drainMicroTaskQueue 尝试清空所有的 MicroTask
    4. 然后减少计数器的值。
  • _transitionTo,改变 task 的状态,task 可以从两个源状态转移到一个目标状态,当 zone 的状态不属于两个源状态中的任何一个时,这个方法会抛出错误。

除了上面几个重要的类,下面两个方法也值得关注:

scheduleMicroTask,这个方法是对 Promise 这样的所谓 micro task 的处理。

  • 我们知道根据 JavaScript 的规范,Promise 是要在当前 VM 时间循环结束前触发的,所以 Zone.js 会尝试在恰当的时机释放所有的 Promise,即通过调用 drainMicroTaskQueue

drainMicroTaskQueue。该方法内容十分简单,即尝试对 _microTaskQueue 中的每一个 MicroTask run 一下。

补丁的实现

我们之前提到了 __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 先指向原生方法。
  • 然后,确保该方法是可复写的 (通过检查 PropertyDescriptorwritable 字段),不然就用原来的,相当于没有 patch。
  • 然后,调用 patchFn (这里是一个类似于柯里化的过程),将 patchDelegate 变量指向 patch 过后的函数,然后再将 proto[name] 指向一个新的函数,这里同时确保了 this 能够绑定在正确的对象上(对于 setTimeout 的例子就是 window)。
  • 到这里 patch 过程就已经完成了,函数返回的是 patch 之前的方法,即原生方法。

用户执行 setTimeout 后会发生什么?

我在这里给读者准备了一个简单的例子,通过给 console.log('z') 这一行打断点,就能够看到整个调用栈。

debugger

Angular 如何使用 Zone.js?

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 的 onInvokeTaskonInvoke 两个钩子,即在异步事件触发时,交给读者自行验证,这里就不赘述了。

结论

这篇文章介绍了 Zone.js 的实现,包括 Zone.js 核心和 patch 的实现,还讲解了 Angular 对 Zone.js 的使用。

  • Zone.js 提供的 zone 是 JavaScript 函数的执行环境,Zone.js patch 了几乎所有的异步函数。
  • Angular 应用在初始化时创建了一个 Angular zone,Angular 的代码都运行在该 Zone 当中。
  • 经过 patch 的异步函数所触发的异步事件会被 Zone.js 所捕捉,在事件回调函数执行后触发 Zone.js 的钩子。
  • Angular 在部分钩子中进行整个应用的变更检测。
  • Angular 的变更检测默认依赖于 Zone.js,但如果开发者完全了解应该在什么时候做变更检测,就可以抛弃 Zone.js。实际上,开发者可以通过给 bootstrapModule 方法的第二个参数传参 { ngZone: 'noop' } 来使用一个在钩子里不做任何事情的 ZoneSpec

参考资料

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.