高级 Angular 组件模式 (7)


07 使用 Content Directives

原文: Use Content Directives

因为父组件会提供所有相关的 UI 元素(比如这里的 button),所以 toggle 组件的开发者可能无法满足组件使用者的一些附加需求,比如,在一个自定义的开关控制元素上增加 aria 属性。

如果 toggle 组件能够提供一些 hooks 方法或指令给组件使用者,这些 hooks 方法或指令能够在自定义的开关元素上设置一些合理的默认值,那将是极好的。

目标

提供一些 hooks 方法或指令给组件使用者,使其可以与所提供的 UI 元素交互并修改它们。

实现

我们通过实现一个 [toggler] 指令来负责向组件使用者提供的自定义元素增加 role="switch"aria-pressed 属性。这个 [toggler] 指令拥有一个 [on] input 属性(并与 <switch> 组件共享),该属性将决定 aria-pressed 属性的值是 true 还是 false

成果

stackblitz演示地址

译者注

到这里已经是第七篇了,也许你已经发现,Angular 中很多开发模式或者理念,都和 Directive 脱不了干系。

Angular 中其本身推崇组件化开发,即把一切 UI 概念当做 Component 来看待,但仔细思考的话,这其实是有前提的,即这个 UI 概念一般是由一个或多个 html 元素组成的,比如一个按钮、一个表格等。但是在前端开发中,小于元素这个颗粒度的概念也是存在的,比如上文提及的 aira 属性便是其中之一,如果也为将这些 UI 概念抽象化为一个组件,就未免杀鸡用牛刀了,因此这里使用 Directive 才是最佳实践,其官方文章本身也有描述,Directive 即为没有模板的 Component。

从组件开发者的角度来看的话,Directive 也会作为一种相对 Component 更加轻量的解决方案,因为与其提供封装良好、配置灵活、功能完备(这三点其实很难同时满足)的 Component,不如提供功能简单的 Directive,而将部分其他工作交付组件使用者来完成。比如文章中所提及的,作为组件开发者,无法预先得知组件使用者会怎样管理开关元素以及它的样式,因此提供一些 hooks 是很有必要的,而 hooks 这个概念,一般情况下,都会是相对简单的,比如生命周期 hook、调用过程 hook、自定义属性 hook 等,在这里,我们通过 Directive 为自定义开关元素增加 aria 属性来达到提供自定义属性 hook 的目标。

more

30 分钟理解 CORB 是什么


写在前面

前些日子在调试 bug 的时候,偶然发现这么一个警告:

Cross-Origin Read Blocking (CORB) blocked cross-origin response https://www.chromium.org/ with MIME type text/html. See https://www.chromestatus.com/feature/5629709824032768 for more details.

我当前的 chrome 版本是 v68,如果是 v66 或更低版本可能提示的警告信息略有不同。印象中只对 CORS 比较熟悉,CORB 是个什么鬼?好奇心迫使我想要了解一下它到底是什么,于是暂时把手头工作放下查了一些资料并花时间汇总了一下,就有了这篇文章。

再介绍 CORB 是什么以及有什么用之前,需要先了解一些背景知识以做铺垫,下面进入正文。

旁路攻击(side-channel attacks)

首先需要了解的是旁路攻击这个术语,关于术语本身的解释,可以去维基百科搜索。简单讲的话,就是从软件系统的物理实现层获取信息进行攻击的手段,软件系统在正常运行时,产生了一些边缘特征,这些特征可以体现一些隐私信息。

这么说可能略显抽象,就拿后文视频链接中列举的例子说明一下,假设小 A 的账户密码是 gyease,小 B 想破解小 A 的密码,他可以这么做:

  • 首先他可以先输入 aaaaaa,之后记录一下从点击登录按钮到错误提示的间隔时间(虽然很短,假设有工具可以记录)
  • 之后再输入 baaaaa,同样记录时间
  • 重复以上过程直到 gaaaaa,会发现从点击登录按钮到错误提示的间隔时间稍微变长了一些
  • 之后小 B 即知道小 A 的密码第一位是 g,之后重复以上步骤即可破解小 A 的密码。

当然这里的例子很蠢,而且也过于理想化,但足够说明问题。反应快的读者可能马上就会知道为什么在观察 ‘gaaaaa’ 的测量结果后小 B 就会知道小 A 首位密码,这是因为执行校验密码是否正确的代码是需要时间的,因此在理想条件下,首位错误和首位正确第二位错误的反馈结果必然是后者时间略长。

这就是一个比较典型的旁路攻击类型,专业的名称叫做计时攻击(timing attack),有兴趣的可以上网搜索了解详情。

预执行(speculation execution)

之后再来了解预执行这个概念,电脑之所以可以执行我们所编写的代码,其背后是由若干硬件协同工作的结果。其中两个比较重要的,一个是内存,一个是CPU。众所周知,CPU执行计算的速度肯定是远大于它读取内存的速度的,这样的结果就是,CPU在对内存读取某些数据的时候,会闲置,这样变造成了浪费。为了提高性能,现代基本大部分硬件制造商都引入了预执行这个机制来压榨CPU的性能。大概的意思如下,比如你写了一段代码:

1
2
3
if(somethingTrueOrFalse) {
// TODO ...
}

逻辑上,这个 if 语句内部的代码是否执行,取决于 somethingTrueOrFalse 变量,但是注意,这是逻辑上的,CPU在运行这段代码的时候,可不是这样子的。它可能会直接跳过判定 somethingTrueOrFalse 是真是假的逻辑,直接执行 if 语句内部的代码,之后反过来再根据 somethingTrueOrFalse 的取值情况作出反应,如果为真,则保留执行结果,如果为假,则撤销执行结果。

这里对于预执行的描述是极度简化的,不过足够说明概念了。如果有兴趣可以上网搜索相关文章,尤其是预执行策略方面的,我看了一些,没看完,感觉和AI有的一拼(题外话)。

幽灵和熔断漏洞(Spectre & Meltdown)

这个漏洞是在今年 1 月份被报道出来的,是硬件系统层面的漏洞。关于这个漏洞本身,网上已经有专业的论文对其进行了详尽的介绍,有兴趣可以自行搜索阅读,这里就不展开说了。简单讲,就是结合上文提及的两个概念的两种实际攻击方法。

这里还需要再说一下 CPU 读取数据的方式,CPU 除了利用预执行来提供性能,它本身在从内存读取数据的时候,还会涉及一个缓存的概念。从缓存读取数据的速度是大于内存的,当 CPU 发现将要读取的一个数据在缓存中存在时,它会直接从缓存中读取,这样同样可以提高性能,但是缓存很小同时也很昂贵,所以缓存的大小无法与内存相比。同时,每个程序运行时,CPU 为了防止进程间互相保持独立,它们都拥有属于自己的某块内存区域,假设程序 A 存在一条想要直接越界访问程序 B 内存的指令,这在 CPU 是属于非法的,它不会执行这条指令,而会选择抛出异常并终止程序,然后将其相应的内存数据清零。

之后问题就出现了,假设我们有以下代码:

1
2
3
if (x < arr1.length) {
y = arr2[arr1[x]]
}

这个例子在参考链接的文章中你可能会多次见到,这里大概解释一下:

  • arr1 假设是一个比较小的数组,x 是一个我们定义的索引值变量
  • 正常情况下,如果 x 超过 arr1 的长度,程序是要崩溃的,因为它越界了,但是在预执行的前提下,CPU 可能会忽略越界的问题而执行 if 语句内部的代码
  • arr2 是我们提前声明的一个用来储存数据的数组,它储存于内存的另一个区域,它是连续的,而且我们强制它没有拷贝至缓存,只保存于内存(这点在视频中有提及,我这里强调一下)
  • 之后我们假设 arr1 中的位于 x 索引出的值是 k,那么在预执行的前提下,y = arr2[arr1[x]]等价于y = arr2[k]
  • 然后由于我们会把 arr2[k] 这个值付给另一个变量 y,这里其实算是一个访问值的操作,CPU 后将 arr2[k] 位于内存地址的值转入缓存中,而其余元素保留在内存中(因为并未访问)

之后,只需要遍历 arr2 这个数组,当发现某个索引上的值的访问速度远快于其他索引的访问速度时,这个索引既是我们从越界内存中“偷”到的值。至此,一次攻击就完成了,理论上,利用这个漏洞,可以获取缓存区所有地址的值,其中很有可能包含敏感信息,比如密码什么的。

CORB(Cross-Origin Read Blocking)

说了这么多,终于可以引入正题了。它是什么呢?引入 chromium 文档中关于它的定义:

an algorithm by which dubious cross-origin resource loads may be identified and blocked by web browsers before they reach the web page.

浏览器在加载可以跨域资源时,在资源载入页面之前,对其进行识别和拦截的算法。

这里可能有人会问,这和上面说的一堆又有什么关系呢?是这样的,Chrome浏览器在处理不同 tab 和不同页面时,会将为它们划分不同的进程,而且受制于同源策略的影响,这些页面之间本应该互不干扰。但是我们知道,同源策略虽然牛逼,但浏览器中仍然存在一些不受制于它约束的 api、标签,比如常见的 img、iframe 和 script等等。诸如以下代码,不知道看文章的诸位有没有写过,反正我是写过,或者说遇见过:

1
<img src="https://foo/bar/baz/">

有人可能会问,一个 img 标签你 src 属性不填图片的 uri,你是不是傻。其实不是这样的,有时候对网站做一些跟踪和分析时,确实会这么写,因为浏览器会往https://foo/bar/baz/这个地址发送一个 GET 资源的请求,在服务端我们可以利用这个请求做一些追踪的逻辑,同理 script 也可以完成需求。但是这么做的后果就是,虽然 img 帮我们发送了这个请求,但是它却没有得到所期望格式的资源,所以这里实际可以算作一种错误或者异常。而一些攻击者可以利用这一点,比如,在页面嵌入下面的代码:

1
<img src="https://example.com/secret.json">

来加载跨域私密文件,因为 img 不受同源策略的制约,这个请求是可以发出去的,服务器响应返回后,显然 secret.json 不是一个图片格式的资源,img 不会显示它,但是并不代表负责渲染当前页面的进程的内存中没有保留关于 secret.json 的数据。因此攻击者可以利用上文中提及的漏洞,编写一些代码来“偷”这些数据,从而造成安全隐患。

而 CORB 的作用就是当浏览器尝试以上面代码的方式加载跨域资源时,在资源未被加载之前进行拦截,从而提升攻击者进行幽灵攻击的成本,这里之所以是说提升成本还非彻底解决是因为这个漏洞是基于硬件层面的,所以软件层面只能做有限的修复,有的人可能马上会说,那 CPU 直接去掉或者用户放弃使用预处理功能不就好了吗?理论上是这样的,但是这将导致预处理带来的性能红利瞬间消失,而且 CPU 的架构设计也不是一天两天就能改的,而且就算改了也没办法一下普及。

哪些内容类型受 CORB 保护

当前有三种内容类型受保护,分别是 json、html 和 xml。关于如何针对每种内容类型 CORB 如何对其进行保护,文档中有详细的章节进行介绍,这里就不多说了。我浏览了一遍,大体的规则均是对内容格式进行一些有针对性的校验,以确认它确实是某个内容类型。这个校验结果最终影响 CORB 的运作方式。

CORB 如何运作

这里我引用文档部分章节并做翻译,关于其中的备注可以直接浏览原文档进行查看。

CORB 会根据如下步骤来确定是否对 response 进行保护(如果响应的内容格式是 json、html 或者 xml):

  • 如果 response 包含 X-Content-Type-Options: nosniff 响应头部,那么如果 Content-Type 是以下几种的话, response 将受 CORB 保护:
    • html mime type
    • xml mime type(除了 image/svg+xml)
    • json mime type
    • text/plain
  • 如果 response 的状态是 206,那么如果 Content-Type 是以下几种的话, response 将受 CORB 保护:
    • html mime type
    • xml mime type(除了 image/svg+xml)
    • json mime type
  • 否则,CORB 将尝试探测 response 的 body:
    • html mime type,并且探测结果是 html 内容格式,response 受 CORB 保护
    • xml mime type(除了 image/svg+xml), 并且探测结果是 xml 内容格式,response 受 CORB 保护
    • json mime type,并且探测结果是 json 内容格式,response 受 CORB 保护
    • text/plain,并且探测结果是 json、html 或者 xml 内容格式,response 受 CORB 保护
    • 任何以 JSON security prefix 开头的 response(除了 text/css)受 CORB 保护

这里值得一提的是,对于探测是必须的,以防拦截了那些依赖被错误标记的跨源响应的页面(比如,图片资源但是格式却被标记为 text/html)。如果不进行格式探测,那么会有16倍以上的 response 被拦截。

CORB 如何拦截一个响应

当一个 response 被 CORB 保护时,它的 body 会被覆盖为空,同时 headers 也会被移除(当前 Chrome 仍然会保留 Access-Control-* 相关的 headers)。关于 CORB 的工作方式,一定要和 CORS 做区分,因为它要防止这些被拦截的数据进入渲染当前页面进程的内存中,所以它一定不会被加载并读取。这不同于 CORS,因为后者会做一些过滤操作,数据虽然不可被加载,但是可能仍然保留在渲染进程的内存中。

对于其他 web 平台特性的影响

这里仍然是翻译部分文档中的内容,因为本身写的就很细致了。

CORB 不会影响以下技术场景:

  • XHR and fetch()

    • CORB 并不会产生显而易见的影响,因为 XHR 和 fetch() 在响应中已经应用了同源策略(比如:CORB 应该仅会拦截那些因缺少 CORS 而发生跨域 XHR 错误的 response)
  • Prefetch

    • CORB 会拦截那些到达跨源渲染进程的 response body,但是不会阻止那些被浏览器进程缓存的 response body(然后传递到另一个同源渲染进程)。
  • Tracking and reporting

    • 当前存在各种各样的技术,尝试对记录用户访问的服务器发送 web 请求,以检查用户是否已访问某些内容。该请求经常使用隐藏的 img 标签进行发送(我前文提及了),然后服务器以 204 状态码或者 html 文档进行响应。除了 img,还可以使用类似 script、style 和别的可用标签。
    • CORB 不会对这些技术场景造成影响,因为它们不会依赖于服务器返回响应的内容。这一点同样使用与其他类似的技术场景和 web 特性,只要它们不关心响应即可,比如:beacons,ping,CSP违规报告 等。
  • Service workers

    • Service workers 可以拦截跨源 requests 并在其内部人为地构建 response(没有跨源和安全边界),CORB 不会拦截它们。
    • 当 Service workers 确实缓存了一些跨源的 responses 时,由于这些 responses 对于调用者来讲是透明的,因此 CORB 会拦截它们,但是这并不需要对 Service Worker 作出任何改变。
  • Blob and File API

    • 即使没有 CORB 的话,获取跨源的 blob URLs 当前也会被拦截。
  • Content scripts and plugins

    • 它们所属的范围并不含在 CORB 的职责内 —— CORB 假设已经有某种合适的安全策略或安全机制存在于这些 content scripts 和 plugins 中(比如 Adobe Flash 已经实现了类似 CORB 的机制,通过 crossdomain.xml)。

总结

大概就这么多,读到这里,应该对 CORB 能够有一个初步的认识和把握了,以及它所需要解决的问题。最后我列举了我写这篇文章之前阅读的文章或者视频,有些需要自备梯子,有些不要。尤其推荐那个 B 站的视频,算是讲的最生动形象的了。另外 Chrome 的文档也很详细,只是有些长,需要耐心慢慢读完。

以上,如有错误,还望指出,大神轻喷。

参考链接

more

Angular Elements 及其工作原理


原文: Angular Elements: how does this magic work under the hood?

现在,Angular Elements 这个项目已经在社区引起一定程度的讨论。这是显而易见的,因为 Angular Elements 提供了很多开箱即用的、十分强大的功能:

  • 通过使用原生的 HTML 语法来使用 Angular Elements —— 这意味着不再需要了解 Angular 的相关知识
  • 它是自启动的,并且一切都可以按预期那样运作
  • 它符合 Web Components 规范,这意味着它可以在任何地方使用
  • 虽然你没有使用 Angular 开发整个网站,但你仍然可以从 Angular Framework 这个庞大的体系中收益

@angular/elements这个包提供可将 Angular 组件转化为原生 Web Components 的功能,它基于浏览器的 Custom Elements API 实现。Angular Elements 提供一种更简洁、对开发者更友善、更快乐地开发动态组件的方式 —— 在幕后它基于同样的机制(指创建动态组件),但隐藏了许多样板代码。

关于如何通过 @angular/elements 创建一个 Custom Element,已经有大量的文章进行阐述,所以在这篇文章将深入一点,对它在 Angular 中的具体工作原理进行剖析。这也是我们开始研究 Angular Elements 的一系列文章的原因,我们将在其中详细解释 Angular 如何在 Angular Elements 的帮助下实现 Custom Elements API。

Custom Elements(自定义元素)

要了解更多关于 Custom Elements 的知识,可以通过 developers.google 中的这篇文章进行学习,文章详细介绍了与 Custom Elements API 相关的内容。

这里针对 Custom Elements,我们使用一句话来概括:

使用 Custom Elements,web 开发者可以创建一个新的 HTML 标签、增加已有的 HTML 标签以及继承其他开发者所开发的组件。

原生 Custom Elements

让我们来看看下面的例子,我们想要创建一个拥有 name 属性的 app-hello HTML 标签。可以通过 Custom Elements API 来完成这件事。在文章的后续章节,我们将演示如何使用 Angular 组件的 @Input 装饰器与 这个 name 属性保持同步。但是现在,我们不需要使用 Angular Elements 或者 ShadowDom 或者使用任何关于 Angular 的东西来创建一个 Custom Element,我们仅使用原生的 Custom Components API。

首先,这是我们的 HTML 标记:

1
<hello-elem name="Custom Elements"></hello-elem>

要实现一个 Custom Element,我们需要分别实现如下在标准中定义的 hooks:
| callback | summary |
| ———————— | ——————————————————————————————- |
| constructor | 如果需要的话,可在其中初始化 state 或者 shadowRoot,在这篇文章中,我们不需要 |
| connectedCallback | 在元素被添加到 DOM 中时会被调用,我们将在这个 hook 中初始化我们的 DOM 结构和事件监听器 |
| disconnectedCallback | 在元素从 DOM 中被移除时被调用,我们将在这个 hook 中清除我们的 DOM 结构和事件监听器 |
| attributeChangedCallback | 在元素属性变化时被调用,我们将在这个 hook 中更新我们内部的 dom 元素或者基于属性改变后的状态 |

如下是我们关于 Hello Custom Element 的实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class AppHello extends HTMLElement {
constructor() {
super();
}
// 这里定义了那些需要被观察的属性,当这些属性改变时,attributeChangedCallback 这个 hook 会被触发
static get observedAttributes() {return ['name']; }

// getter to do a attribute -> property reflection
get name() {
return this.getAttribute('name');
}

// setter to do a property -> attribute reflection
// 通过 setter 来完成类属性到元素属性的映射操作
set name(val) {
this.setAttribute('name', val);
}

connectedCallback() {
this.div = document.createElement('div');
this.text = document.createTextNode(this.name || '');
this.div.appendChild(this.text);
this.appendChild(this.div);
}

disconnectedCallback() {
this.removeChild(this.div);
}

attributeChangedCallback(attrName, oldVal, newVal) {
if (attrName === 'name' && this.text) {
this.text.textContent = newVal;
}
}
}

customElements.define('hello-elem', AppHello);

这里是可运行实例的链接。这样我们就实现了第一版的 Custom Element,回顾一下,这个 app-hellp 标签包含一个文本节点,并且这个节点将会渲染通过 app-hello 标签 name 属性传递进来的任何内容,这一切仅仅基于原生 javascript。

将 Angular 组件导出为 Custom Element

既然我们已经了解了关于实现一个 HTML Custom Element 所涉及的内容,让我们来使用 Angular实现一个相同功能的组件,之后再使它成为一个可用的 Custom Element。

首先,让我们从一个简单的 Angular 组件开始:

1
2
3
4
5
6
7
8
9
import { Component, Input } from '@angular/core';

@Component({
selector: 'app-hello',
template: `<div>{{name}}</div>`
})
export class HelloComponent {
@Input() name: string;
}

正如你所见,它和上面的例子在功能上一模一样。

现在,要将这个组件包装为一个 Custom Element,我们需要创建一个 wrapper class 并实现所有 Custom Elements 中定义的 hooks:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class HelloComponentClass extends HTMLElement {
constructor() {
super();
}

static get observedAttributes() {
}

connectedCallback() {
}

disconnectedCallback() {
}

attributeChangedCallback(attrName, oldVal, newVal) {
}
}

下一步,我们要做的是桥接 HelloComponentHelloComponentClass。它们之间的桥会将 Angular Component 和 Custom Element 连接起来,如图所示:

要完成这座桥,让我们来依次实现 Custom Elements API 中所要求的每个方法,并在这个方法中编写关于绑定 Angular 的代码:
| callback | summary | angular part |
| ———————— | ———————- | —————– |
| constructor | 初始化内部状态 | 进行一些准备工作 |
| connectedCallback | 初始化视图、事件监听器 | 加载 Angular 组件 |
| disconnectedCallback | 清除视图、事件监听器 | 注销 Angular 组件 |
| attributeChangedCallback | 处理属性变化 | 处理 @Input 变化 |

1. constructor()

我们需要在 connectedCallback() 方法中初始化 HelloComponent,但是在这之前,我们需要在 constructor 方法中进行一些准备工作。

顺便,关于如何动态构造 Angular 组件可以通过阅读Dynamic Components in Angular这篇文章进行了解。它其中阐述的运作机制和我们这里使用的一模一样。

所以,要让我们的 Angular 动态组件能够正常工作(需要 componentFactory 能够被编译),我们需要将 HelloComponent 添加到 NgModuleentryComponents 属性(它是一个列表)中去:

1
2
3
4
5
6
7
8
9
10
@NgModule({
imports: [
BrowserModule
],
declarations: [HelloComponent],
entryComponents: [HelloComponent]
})
export class CustomElementsModule {
ngDoBootstrap() {}
}

基本上,调用 prepare() 方法会完成两件事:

  • 它会基于组件的定义初始化一个 factoryComponent 工厂方法
  • 它会基于 Angular 组件的 inputs 初始化 observedAttributes,以便我们在 attributeChangedCallback() 中完成我们需要做的事
1
2
3
4
5
6
7
8
class AngularCustomElementBridge {
prepare(injector, component) {
this.componentFactory = injector.get(ComponentFactoryResolver).resolveComponentFactory(component);

// 我们使用 templateName 来处理 @Input('aliasName') 这种情形
this.observedAttributes = componentFactory.inputs.map(input => input.templateName);
}
}

2. connectedCallback()

在这个回调函数中,我们将看到:

  • 初始化我们的 Angular 组件(就如创建动态组件那样)
  • 设置组件的初始 input 值
  • 在渲染组件时,触发脏检查机制
  • 最后,将 HostView 增加到 ApplicationRef

如下是实战代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class AngularCustomElementBridge {
initComponent(element: HTMLElement) {
// 首先我们需要 componentInjector 来初始化组件
// 这里的 injector 是 Custom Element 外部的注入器实例,调用者可以在这个实例中注册
// 他们自己的 providers
const componentInjector = Injector.create([], this.injector);

this.componentRef = this.componentFactory.create(componentInjector, null, element);

// 然后我们要检查是否需要初始化组件的 input 的值
// 在本例中,在 Angular Element 被加载之前,user 可能已经设置了元素的属性
// 这些值被保存在 initialInputValues 这个 map 结构中
this.componentFactory.inputs.forEach(prop => this.componentRef.instance[prop.propName] = this.initialInputValues[prop.propName]);

// 之后我们会触发脏检查,这样组件在事件循环的下一个周期会被渲染
this.changeDetectorRef.detectChanges();
this.applicationRef = this.injector.get(ApplicationRef);

// 最后,我们使用 attachView 方法将组件的 HostView 添加到 applicationRef 中
this.applicationRef.attachView(this.componentRef.hostView);
}
}

3. disconnectedCallback()

这个十分容易,我们仅需要在其中注销 componentRef 即可:

1
2
3
4
5
class AngularCustomElementBridge {
destroy() {
this.componentRef.destroy();
}
}

4. attributeChangedCallback()

当元素属性发生改变时,我们需要相应地更新 Angular 组件并触发脏检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
class AngularCustomElementBridge {
setInputValue(propName, value) {
if (!this.componentRef) {
this.initialInputValues[propName] = value;
return;
}
if (this.componentRef[propName] === value) {
return;
}
this.componentRef[propName] = value;
this.changeDetectorRef.detectChanges();
}
}

5. Finally, we register the Custom Element

1
customElements.define('hello-elem', HelloComponentClass);

这是一个可运行的例子链接

总结

这就是根本思想。通过在 Angular 中使用动态组件,我们简单实现了 Angular Elements 所提供的基础功能,重要的是,没有使用 @angular/element 这个库。

当然,不要误解 —— Angular Elements 的功能十分强大。文章中所涉及的所有实现逻辑在 Angular Elements 都已被抽象化,使用这个库可以使我们的代码更优雅,可读性和维护性也更好,同时也更易于扩展。

以下是关于 Angular Elements 中一些模块的概要以及它们与这篇文章的关联性:

  • create-custom-element.ts:这个模块实现了我们在这篇文章中讨论的关于 Custom Element 的几个回调函数,同时它还会初始化一个 NgElementStrategy 策略类,这个类会作为连接 Angular Component 和 Custom Elements 的桥梁。当前,我们仅有一个策略 —— component-factory-strategy.ts —— 它的运作机制与本文例子中演示的大同小异。在将来,我们可能会有其他策略,并且我们还可以实现自定义策略。
  • component-factory-strategy.ts:这个模块使用一个 component 工厂函数来创建和销毁组件引用。同时它还会在 input 改变时触发脏检查。这个运作过程在上文的例子中也有被提及。

下次我们将阐述 Angular Elements 通过 Custom Events 输出事件。

more

dart class overview


写在前面

最近在折腾 flutter 相关的东西,所以当然要撸一下 dart 了。编程语言这个东西,接触得多了学习起来速度会提升不少,但是不同的语言具有不同的特色,我们需要花一些时间去关注它们的卖点,而且对于大部分面向对象语言,也需要格外注意的概念,因此专门花了一些时间结合官方文档整理学习 dart 中关于类的内容。

dart 是一门面向对象的语言,既然是面向对象就不会缺少类(class)这个概念。dart 中的 classes 包含的内容繁多,但是如果你同时拥有使用静态语言和动态语言的经验则会容易不少。

Note: 示例代码中包含一些 dart 的基本语法,建议阅读之前先进行了解。如果有 typescript 或者 java 使用经验的话,应该会很熟悉。

声明、实例化及访问属性

这一部分是最基本的内容,和大部分编程语言的语法差不多。比如想要声明一个 Point 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Point {
num x, y = 1;

Point(num x, num y) {
this.x = x;
this.y = y;
}

 Point.fromJson(Map<String, num> json){
this.x = json['x'];
this.y = json['y'];
}
/* 类似 typescript 可以使用如下的语法糖
Point(this.x, this.y);
*/
}

实例化:

1
Point p = Point(1, 1); // 或者 new Point(1, 1)

访问属性:

1
print('${p.x} ${p.y}'); // 类的方法同理

还可以使用 ?. 来避免当访问实例为空时抛出异常:

1
print('${p?.x}');

属性可见范围

dart 中不存在类似 java 和 typescript 中的 private、protected、public 修饰符,它使用约定来对类属性的可见范围进行控制。约定如下:
如果一个标识符以下划线(_)开头,则它为一个私有标识符。

构造函数

dart 类的构造函数存在两种形式,一种为 ClassName() ,另一种是 ClassName.ConstructorName() ,举例说明:

1
2
var p1 = new Point(2, 2);
var p2 = new Point.fromJson({'x': 1, 'y': 2});

这里的 fromJson 是一个自定义的构造器方法,在 dart 中它叫做 Named constructors,所以上面的 Point 类也可以这么声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Point {
num x, y = 1;

Point(num x, num y) {
this.x = x;
this.y = y;
}

 Point.fromJson(Map<String, num> json){
this.x = json['x'];
this.y = json['y'];
}
}

在 dart 的构造器中还涉及一个东西,叫作 initializer list。大体的语法如下:

1
2
3
4
5
class Point {
...(略)

 Point.fromJson(Map<String, num> json): x = json['x'],y = json['y'];
}

这种写法和上面的代码是等价的。关于 initializer list 语法可以参考这里

除了基本的构造器以外,dart 还可以声明其他类型的构造器,当前有三种:

关于具体的语法可以参考链接所指向的官方文档,我觉的比较有用的应当是工厂构造器。因为在面向对象编程中,一个基本的设计模式即是工厂模式,dart 提供的工厂构造器可以说是在语法层面原生提供工厂模式的实现方式。

最后关于构造器还有一点值得一说,就是当存在继承关系并在默认情况下,构造器的调用顺序如下:
initializer list -> 父类默认无参构造器 -> 主类默认无参构造器

如果父类不存在默认无参构造器,那么主类必须显式地调用父类的其他构造器(Named constructors 或者 有参构造器),调用的代码可以包含在 initializer list 中,如下:

1
2
3
class Employee extends Person {
Employee(Map data) : super(data);
}

方法

类的方法可以划分为以下几类:

接口

不像 java,dart 中每一个类都会隐式的声明一个包含当前类及它所实现所有接口的成员属性的接口。现在我们想实现一个可以 run 和 jump 的 Person 类,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Run {
void run() {}
}

class Jump {
void jump() {}
}

class Person implements Run, Jump {
void run() {
print('I can run');
}

void jump() {
print('I can jump');
}
}

继承

和其他面向对象编程语言中的继承差不多,可以参考这里

枚举

dart 中也可以像 typescript 一样,使用 enum 声明枚举对象,如下:

1
enum Color { red, green, blue }

枚举相比类有如下限制:
无法继承或者使用 mixin,同时也无法被当做接口
无法显示实例化

mixins

熟悉 python 的话会很熟悉这个特性,dart 中使用 with 关键字来在一个类中混入 mixins,比如:

1
2
3
class Musician extends Performer with Musical {
// ···
}

声明一个 mixin 的语法很简单,首先创造一个抽象类,同时不要声明构造器,如下:

1
2
3
4
5
6
7
8
9
abstract class Musical {
bool canPlayPiano = false;
bool canCompose = false;
bool canConduct = false;

void entertainMe() {
...(做一些事)
}
}

静态属性及方法

和其他编程语言类似,只需要在属性或者方法前加 static 关键字即可。

抽象类

和其他编程语言类似,通过 abstract 关键字声明,比如:

1
2
3
abstract class AbstractContainer {
void updateChildren(); // Abstract method.
}

抽象类无法实例化,除非它被实现。

Callable

类可以提供一个 call() 方法以使当前类成为 Callable class,提供该方法以后类实例可以被当做函数来调用,比如:

1
2
3
4
5
class Point {
...(略)

call() => '${this.x} ${this.y}';
}

直接调用类实例来输出坐标:

1
2
var p1 = new Point(1, 2);
p1(); // 1 2

more

简单探索 js 中 something >> 0 的原理


关于这个问题是今天改公司项目小程序的一个bug时看到的,修复这个bug的解决方法是需要引入 String.prototype.padStart 的 polyfill,所以我就顺带扫了一眼这个 polyfill 里面的实现是怎样的,结果发现这么一行:

1
2
// truncate if number or convert non-number to 0
target = target >> 0;

我倒不是对这个代码本身的作用有什么疑问,毕竟之前看过好多关于 js 技巧的文章,其中都介绍过这种写法,并且自己也在实际工作中运用过多次。无论之前看过它几次,写过它几次,都没有追究它的原理到底是什么。至于要说当时为什么没有追究,原因一方面是因为没有时间,另一方面是当时自己水平也比较差,什么 ECMAScript 标准根本无从看起啊。经过不懈努力,终于觉得自己可以看懂一些规范了,所以借这个机会来根据规范看看它的原理是什么。

关于这行代码具体使用了什么语法以及达到的效果我就不废话了。第一步,我们需要从规范的哪里看起呢?因为完整的规范很长,我们不可能从头看起。我们需要根据规范的目录和已知代码来定位我们需要查找的具体章节。根据上面的代码可以得知,其中的操作符是一个右移操作符,如果英语比较好的或者对计算机术语比较熟悉,可以很快联想到 right-shift 这个词语(如果不好也可以根据词典得知),然后应用一下搜索大法就好了,在规范中搜索 right-shirt 相关的内容,然后再目中录就可以定位到以下信息:

1
1.12.8.4 The Signed Right Shift Operator ( >> )

这就好办了,直接跳到 12.8.4 章节,估计要看的主要内容都在这个章节中了,瞬间缩小了查询范围。

跳转到这个章节以后,看一下描述,如下:

Performs a sign-filling bitwise right shift operation on the left operand by the amount specified by the right operand.

应该是没错了,继续往下看,可以发现 12.8.4.1 中详细介绍了右移操作符的相关规范。

大概流程可以简单理解为,分别求得右移操作符左右两侧表达式的值后(其中包含一些取值、校验、转换逻辑),之后按照右移逻辑返回一个32位有符号整数。关于代码,我们比较感兴趣的是操作符左侧所对应的取值逻辑,所以这里我们需要详细看关于操作符左侧取值的逻辑,相关的步骤包含 1、2、3、7、8,依次是:

  • 第一步很简单,就是将 ShiftExpression 求值,记作 lref
  • 第二步对 lref 求值,记作 lval,求值的过程参考 GetValue
  • 第三步根据 ReturnIfAbrupt 来判定 lval 是否是异常值
  • 第七步对 lval 进行转换,记作 lnum,转换的过程参考 ToInt32
  • 第八步根据 ReturnIfAbrupt 来判定 lnum 是否是异常值

光看这个步骤是没有任何用处的,所以还需要继续看一下规范中关于 GetValue 和 ToInt32 的章节,这次就不用搜索了,直接点击链接跳转过去即可。

GetValue 如下:

ToInt32 如下:

这两个方法的详细过程就不说明了,以一个简单的例子大概理一下流程,比如使用以上代码时,假设 target 的类型是字符串,比如:

1
2
3
4
var target = 'a'

target = target >> 0
console.log(target) // 0

根据 GetValue 的逻辑可以发现,走到第二步就返回该值了,因为它是一个基础数据类型,所以 lval 的值为 ‘a’。之后带入 ToInt32(‘a’) 根据流程第一步的说明,首先进行 ToNumber(‘a’),关于 ToNumber 的规范比较长,这里就不截图了,总之最后会返回 NaN。然后顺着流程往下走,到第三步就会发现,最终 ToInt32(‘a’) 会返回 +0。

之后带入之前右移操作规范的 10 和 11 步就会得知,’a’ >> 0 等价于 +0 >> 0,最终的结果是 +0。对于其他的情况,在测试的基础上并带入以上流程,马上就会得知其原因,这里就不赘述了。

最后想说的是,我认为对于这种颗粒度的知识没有必要专门投入时间去学习和掌握,因为太过细小和零碎。但是当我们遇到一些自己不懂或者不熟悉的东西时,一定要有意识去寻根问底,这样积少成多,精通 js 早晚会变成现实。

more

高级 Angular 组件模式 (6)


06 Use <ng-template>

原文: Use <ng-template>

Render Props最近在React社区中引起了轰动,但是与之类似的模式在Angular中似乎并没有得到太多关注。我在之前写的文章提及过,TemplateRefs就是Angular中的Render Props,同时我会在这篇文章中列举一个简单易用的例子。

Note: TemplateRef是一个类名而<ng-template>是一个html标签,它们本质上是相同的。不过你可能会在项目中更频繁地使用<ng-template>,但是在网上你可以很容易的搜索到关于TemplateRef的知识,因为<ng-template>会给你提供很多html5中的<template>标签的信息。

我们已有的实现中,使用自定义内容指令(content directives)。当组件作者提前了解使用该toggle组件的父组件所需要的状态时,那么它将会正常的运作。但是如果父组件所需要的状态并不在我们的设想之内,我们该怎么办?

目标

toggle组件的状态直接提供给父组件,同时允许父组件提供相应的渲染视图(view)。

实现

<ng-template>组件可以完美地解决问题。

1. Toggle 组件

<toggle>组件能够通过ContentChild装饰器得到关于<ng-template>的引用,之后会赋予模板在渲染时所需要的状态,代码如下:

1
2
3
<ng-container
*ngTemplateOutlet="layoutTemplate; context: { on: this.on, toggle: this.toggle, fns: { toggle: this.toggle } }">
</ng-container>

这里<ng-container>被当做一个占位符来使用,之后你可以使用*ngTemplateOutlet指令来填充它,layoutTemplate变量指代的是需要被渲染的模板,context对象包含的键值对会作为组件状态注入layoutTemplate中。

2. 父组件

toggle组件中传入的状态是通过let关键字在父组件的<ng-template>标签上显示声明的。

let关键字的使用方式类是这样的:let-templatevar="inputvar"templatevar指代在<ng-template>标签中,关联组件状态值的变量名,而inputvar指代使用<toggle>组件的模板作用域中的变量名。

这种语法会有效地避免命名冲突,比如在父组件作用域中已经有一个inputvar变量了。

成果

stackblitz演示地址

译者注

这种组件设计模式按我个人的理解,其实是依赖倒置原则在视图渲染层的一种延伸,为什么这么说呢?是因为通常情况下子组件视图的渲染逻辑取决于传入的props状态和自身提供的模板,这在大多数情况下不会造成任何困扰,但是当我们无法在提前得知我们需要渲染什么的时候,这个问题就会变得十分棘手。

一种解决方法,我们可以使用条件渲染指令,根据传入的状态来判定组件渲染的状态,这种解决方法在情况比较少的情况下是可以解决问题的,但是当情况数量十分庞大的情况下,增加过多的条件判定会致使子组件的模板代码量剧增,同时降低性能,因为每次渲染都会进行若干次条件逻辑判断。

除了上面的解决方法,就是使用正文中所提及的模式了,这种模式将子组件视图的渲染逻辑倒置为子组件仅仅声明模板中所会使用的状态变量,对于这些变量和模板的注入工作,全权赋予父组件,因此会使子组件的复用性和可测试性大大提高。

正文中仅列举了一个简单的例子中,我这里在简单提及一个实际工作可能会用到的例子,就是表单校验的错误提示组件,一般前端组件设计但凡涉及表单,都会是十分复杂的,更不用说校验这种灵活性很高的功能了。

为了适应表单校验的灵活性,我们使用这种模式会事半功倍,提供校验信息的组件仅仅声明渲染表单错误提示信息需要设计的状态变量即可,比如dirtytouched等等,对于错误信息的文案及样式,统统交由错误提示组件的使用者完成。

more

小心 Angular 中的单例 Service


原文: Angular Services do NOT have to be Singletons

你可能知道,当我们通过@NgModule()装饰器来声明一个service时,它将符合单例模式,同时还意味着它与整个应用的生命周期保持一致。比如:

1
2
3
4
5
6
export class AdminService {
data = Array(10000).fill(dummy);
}
@NgModule({
providers: [AdminService, AdminDataService]
})

我们在刚开始接触Angular的时候,总是不计后果的将所有service都使用@NgModule()来声明,这将会造成一个不易发现的问题:

You are not releasing memory.

在上面的例子中,尽管你不再需要这些内存中储存的数据,但是让我们停下来仔细想一想,我们真的需要将一个service声明为单例的吗?

比如,在我们整个应用中,我们会有一个管理区域需要呈现大量的表格数据(同时这些数据只在这个管理区域展现),这些数据会储存在内存中。在这种情况下,我们没有必要将这个service声明为单例的,因为我们不需要缓冲层来缓存这些数据以供应用中的其他模块使用。

进一步讲,当前我们仅仅是想使这些表格数据在多个component之间共享,同时将数据与service中的多个helper方法耦合起来。所以我们完全可以直接使用@Component()装饰器来声明service,这样它就会成为一个非单例service,如下:

1
2
3
4
@Component({
selector: 'admin-tab',
providers: [AdminService, AdminDataService]
})

这样做的好处是,当Angular注销组件实例时,Angular将同时注销与之绑定的service实例,y也会释放那些用来储存数据的内存。

OnDestroy 钩子函数

许多开发者也许不知道非单例servicengOnDestroy()生命周期,所以你也可以在这个生命周期中进行一些销毁逻辑代码的编写,比如:

1
2
3
4
5
export class AdminService implements OnDestroy {
ngOnDestroy() {
// Clean subscriptions, intervals, etc
}
}

另外,如果我们调用NgModuleRef.destroy()或者PlatformRef.destroy(),单例servicengOnDestroy钩子函数也会被[执行]。(https://github.com/angular/angular/blob/674c3def319e2c444823319ae43394d46f3973b7/packages/core/src/view/ng_module.ts#L199-L204)。

译者注

之所以翻译了这篇文章,是因为今天在整理项目代码的时候,偶然发现了这个问题,虽然我使用Angular也有一段时间了,但是依然将很多没有必要声明在NgModule中的服务以单例模式的方式声明了。文章中指出的问题确实是一个重要但又难以发现的问题。

大体总结一下Angular中声明service的不同方式和应用场景。

使用@Component

这时service与组件本身生命周期保持一致,非单例,适合声明一些需要暂存数据的工具类或者仅在某个或某几个组件中需要缓存数据的状态管理类service

使用@NgModuleproviders

这时service与应用本身生命周期保持一致(非懒加载),单例,适合声明一些需要在全局缓存数据的状态管理类service

但是有一个特例,懒加载模块中的service是会在模块加载时重新创建一个实例的,懒加载模块中均会注入后创建的service实例,因此懒加载模块与非懒加载模块间的service非单例。

使用forRoot

使用forRoot可以保证当前模块即使是懒加载模块,在加载时也不会重新创建一个新的service实例,因为懒加载模块在加载时,会临时创建一个从属于根injector的子injector,根据Angular中的依赖注入流程,当尝试通过一个子injector中注入不存在的实例对象时,会尝试向父级injector获取,因此最终可保证该service在应用任何地方被注入均是单例。

关于官方文档的介绍,可以参考ProvidersSingleton Services

more

30分钟理解GraphQL核心概念


写在前面

在上一篇文章RPC vs REST vs GraphQL中,对于这三者的优缺点进行了比较宏观的对比,而且我们也会发现,一般比较简单的项目其实并不需要GraphQL,但是我们仍然需要对新的技术有一定的了解和掌握,在新技术普及时才不会措手不及。

这篇文章主要介绍一些我接触GraphQL的这段时间,觉得需要了解的比较核心的概念,比较适合一下人群:

  • 听说过GraphQL的读者,想深入了解一下
  • 想系统地学习GraphQL的读者
  • 正在调研GraphQL技术的读者

这些概念并不局限于服务端或者是客户端,如果你熟悉这些概念,在接触任意使用GraphQL作为技术背景的库或者框架时,都可以通过文档很快的上手。

如果你已经GraphQL应用于了实际项目中,那么这篇文章可能不适合你,因为其中并没有包含一些实践中的总结和经验,关于实践的东西我会在之后再单另写一篇文章总结。

什么是GraphQL

介绍GraphQL是什么的文章网上一搜一大把,篇幅有长有短,但是从最核心上讲,它是一种查询语言,再进一步说,是一种API查询语言。

这里可能有的人就会说,什么?API还能查?API不是用来调用的吗?是的,这正是GraphQL的强大之处,引用官方文档的一句话:

ask what exactly you want.

我们在使用REST接口时,接口返回的数据格式、数据类型都是后端预先定义好的,如果返回的数据格式并不是调用者所期望的,作为前端的我们可以通过以下两种方式来解决问题:

  • 和后端沟通,改接口(更改数据源)
  • 自己做一些适配工作(处理数据源)

一般如果是个人项目,改后端接口这种事情可以随意搞,但是如果是公司项目,改后端接口往往是一件比较敏感的事情,尤其是对于三端(web、andriod、ios)公用同一套后端接口的情况。大部分情况下,均是按第二种方式来解决问题的。

因此如果接口的返回值,可以通过某种手段,从静态变为动态,即调用者来声明接口返回什么数据,很大程度上可以进一步解耦前后端的关联。

在GraphQL中,我们通过预先定义一张Schema和声明一些Type来达到上面提及的效果,我们需要知道:

  • 对于数据模型的抽象是通过Type来描述的
  • 对于接口获取数据的逻辑是通过Schema来描述的

这么说可能比较抽象,我们一个一个来说明。

Type

对于数据模型的抽象是通过Type来描述的,每一个Type有若干Field组成,每个Field又分别指向某个Type。

GraphQL的Type简单可以分为两种,一种叫做Scalar Type(标量类型),另一种叫做Object Type(对象类型)

Scalar Type

GraphQL中的内建的标量包含,StringIntFloatBooleanEnum,对于熟悉编程语言的人来说,这些都应该很好理解。

值得注意的是,GraphQL中可以通过Scalar声明一个新的标量,比如:

  • prisma(一个使用GraphQL来抽象数据库操作的库)中,还有DateTimeID这两个标量分别代表日期格式和主键
  • 在使用GraphQL实现文件上传接口时,需要声明一个Upload标量来代表要上传的文件

总之,我们只需要记住,标量是GraphQL类型系统中最小的颗粒,关于它在GraphQL解析查询结果时,我们还会再提及它。

Object Type

仅有标量是不够的抽象一些复杂的数据模型的,这时候我们需要使用对象类型,举个例子(先忽略语法,仅从字面上看):

1
2
3
4
5
type Article {
id: ID
text: String
isPublished: Boolean
}

上面的代码,就声明了一个Article类型,它有3个Field,分别是ID类型的id,String类型的text和Boolean类型的isPublished。

对于对象类型的Field的声明,我们一般使用标量,但是我们也可以使用另外一个对象类型,比如如果我们再声明一个新的User类型,如下:

1
2
3
4
type User {
id: ID
name: String
}

这时我们就可以稍微的更改一下关于Article类型的声明代码,如下:

1
2
3
4
5
6
type Article {
id: ID
text: String
isPublished: Boolean
author: User
}

Article新增的author的Field是User类型, 代表这篇文章的作者。

总之,我们通过对象模型来构建GraphQL中关于一个数据模型的形状,同时还可以声明各个模型之间的内在关联(一对多、一对一或多对多)。

Type Modifier

关于类型,还有一个较重要的概念,即类型修饰符,当前的类型修饰符有两种,分别是ListRequired,它们的语法分别为[Type]Type!, 同时这两者可以互相组合,比如[Type]!或者[Type!]或者[Type!]!(请仔细看这里!的位置),它们的含义分别为:

  • 列表本身为必填项,但其内部元素可以为空
  • 列表本身可以为空,但是其内部元素为必填
  • 列表本身和内部元素均为必填

我们进一步来更改上面的例子,假如我们又声明了一个新的Comment类型,如下:

1
2
3
4
5
type Comment {
id: ID!
desc: String,
author: User!
}

你会发现这里的ID有一个!,它代表这个Field是必填的,再来更新Article对象,如下:

1
2
3
4
5
6
7
type Article {
id: ID!
text: String
isPublished: Boolean
author: User!
comments: [Comment!]
}

我们这里的作出的更改如下:

  • id字段改为必填
  • author字段改为必填
  • 新增了comments字段,它的类型是一个元素为Comment类型的List类型

最终的Article类型,就是GraphQL中关于文章这个数据模型,一个比较简单的类型声明。

Schema

现在我们开始介绍Schema,我们之前简单描述了它的作用,即它是用来描述对于接口获取数据逻辑的,但这样描述仍然是有些抽象的,我们其实不妨把它当做REST架构中每个独立资源的uri来理解它,只不过在GraphQL中,我们用Query来描述资源的获取方式。因此,我们可以将Schema理解为多个Query组成的一张表。

这里又涉及一个新的概念Query,GraphQL中使用Query来抽象数据的查询逻辑,当前标准下,有三种查询类型,分别是query(查询)mutation(更改)subscription(订阅)

Note: 为了方便区分,Query特指GraphQL中的查询(包含三种类型),query指GraphQL中的查询类型(仅指查询类型)

Query

上面所提及的3中基本查询类型是作为Root Query(根查询)存在的,对于传统的CRUD项目,我们只需要前两种类型就足够了,第三种是针对当前日趋流行的real-time应用提出的。

我们按照字面意思来理解它们就好,如下:

  • query(查询):当获取数据时,应当选取Query类型
  • mutation(更改):当尝试修改数据时,应当使用mutation类型
  • subscription(订阅):当希望数据更改时,可以进行消息推送,使用subscription类型

仍然以一个例子来说明。

首先,我们分别以REST和GraphQL的角度,以Article为数据模型,编写一系列CRUD的接口,如下:

Rest 接口

1
2
3
4
5
GET /api/v1/articles/
GET /api/v1/article/:id/
POST /api/v1/article/
DELETE /api/v1/article/:id/
PATCH /api/v1/article/:id/

GraphQL Query

1
2
3
4
5
6
7
8
9
10
Query {
articles(): [Article!]!
article(id: Int): Article!
}

mutation {
createArticle(): Article!
updateArticle(id: Int): Article!
deleteArticle(id: Int): Article!
}

对比我们较熟悉的REST的接口我们可以发现,GraphQL中是按根查询的类型来划分Query职能的,同时还会明确的声明每个Query所返回的数据类型,这里的关于类型的语法和上一章节中是一样的。需要注意的是,我们所声明的任何Query都必须是Root Query的子集,这和GraphQL内部的运行机制有关。

例子中我们仅仅声明了Query类型和Mutation类型,如果我们的应用中对于评论列表有real-time的需求的话,在REST中,我们可能会直接通过长连接或者通过提供一些带验证的获取长连接url的接口,比如:

1
POST /api/v1/messages/

之后长连接会将新的数据推送给我们,在GraphQL中,我们则会以更加声明式的方式进行声明,如下

1
2
3
4
5
6
7
8
subscription {
updatedArticle() {
mutation
node {
comments: [Comment!]!
}
}
}

我们不必纠结于这里的语法,因为这篇文章的目的不是让你在30分钟内学会GraphQL的语法,而是理解的它的一些核心概念,比如这里,我们就声明了一个订阅Query,这个Query会在有新的Article被创建或者更新时,推送新的数据对象。当然,在实际运行中,其内部实现仍然是建立于长连接之上的,但是我们能够以更加声明式的方式来进行声明它。

Resolver

如果我们仅仅在Schema中声明了若干Query,那么我们只进行了一半的工作,因为我们并没有提供相关Query所返回数据的逻辑。为了能够使GraphQL正常工作,我们还需要再了解一个核心概念,Resolver(解析函数)

GraphQL中,我们会有这样一个约定,Query和与之对应的Resolver是同名的,这样在GraphQL才能把它们对应起来,举个例子,比如关于articles(): [Article!]!这个Query, 它的Resolver的名字必然叫做articles

在介绍Resolver之前,是时候从整体上了解下GraphQL的内部工作机制了,假设现在我们要对使用我们已经声明的articles的Query,我们可能会写以下查询语句(同样暂时忽略语法):

1
2
3
4
5
6
7
8
9
10
11
12
13
Query {
articles {
id
author {
name
}
comments {
id
desc
author
}
}
}

GraphQL在解析这段查询语句时会按如下步骤(简略版):

  • 首先进行第一层解析,当前QueryRoot Query类型是query,同时需要它的名字是articles
  • 之后会尝试使用articlesResolver获取解析数据,第一层解析完毕
  • 之后对第一层解析的返回值,进行第二层解析,当前articles还包含三个子Query,分别是idauthorcomments
    • id在Author类型中为标量类型,解析结束
    • author在Author类型中为对象类型User,尝试使用UserResolver获取数据,当前field解析完毕
    • 之后对第二层解析的返回值,进行第三层解析,当前author还包含一个Query, name,由于它是标量类型,解析结束
    • comments同上…

我们可以发现,GraphQL大体的解析流程就是遇到一个Query之后,尝试使用它的Resolver取值,之后再对返回值进行解析,这个过程是递归的,直到所解析Field的类型是Scalar Type(标量类型)为止。解析的整个过程我们可以把它想象成一个很长的Resolver Chain(解析链)。

这里对于GraphQL的解析过程只是很简单的概括,其内部运行机制远比这个复杂,当然这些对于使用者是黑盒的,我们只需要大概了解它的过程即可。

Resolver本身的声明在各个语言中是不一样的,因为它代表数据获取的具体逻辑。它的函数签名(以js为例子)如下:

1
2
3
function(parent, args, ctx, info) {
...
}

其中的参数的意义如下:

  • parent: 当前上一个Resolver的返回值
  • args: 传入某个Query中的函数(比如上面例子中article(id: Int)中的id
  • ctx: 在Resolver解析链中不断传递的中间变量(类似中间件架构中的context)
  • info: 当前Query的AST对象

值得注意的是,Resolver内部实现对于GraphQL完全是黑盒状态。这意味着Resolver如何返回数据、返回什么样的数据、从哪返回数据,完全取决于Resolver本身,基于这一点,在实际中,很多人往往把GraphQL作为一个中间层来使用,数据的获取通过Resolver来封装,内部数据获取的实现可能基于RPC、REST、WS、SQL等多种不同的方式。同时,基于这一点,当你在对一些未使用GraphQL的系统进行迁移时(比如REST),可以很好的进行增量式迁移。

总结

大概就这么多,首先感谢你耐心的读到这里,虽然题目是30分钟熟悉GraphQL核心概念,但是可能已经超时了,不过我相信你对GraphQL中的核心概念已经比较熟悉了。但是它本身所涉及的东西远远比这个丰富,同时它还处于飞速的发展中。

最后我尝试根据这段时间的学习GraphQL的经验,提供一些进一步学习和了解GraphQL的方向和建议,仅供参考:

想进一步了解GraphQL本身

我建议再仔细去官网,读一下官方文档,如果有兴趣的话,看看GraphQL的spec也是极好的。这篇文章虽然介绍了核心概念,但是其他一些概念没有涉及,比如Union、Interface、Fragment等等,这些概念均是基于核心概念之上的,在了解核心概念后,应当会很容易理解。

偏向服务端

偏向服务端方向的话,除了需要进一步了解GraphQL在某个语言的具体生态外,还需要了解一些关于缓存、上传文件等特定方向的东西。如果是想做系统迁移,还需要对特定的框架做一些调研,比如graphene-django。

如果是想使用GraphQL本身做系统开发,这里推荐了解一个叫做prisma的框架,它本身是在GraphQL的基础上构建的,并且与一些GraphQL的生态框架兼容性也较好,在各大编程语言也均有适配,它本身可以当做一个ORM来使用,也可以当做一个与数据库交互的中间层来使用。

偏向客户端

偏向客户端方向的话,需要进一步了解关于graphql-client的相关知识,我这段时间了解的是apollo,一个开源的grapql-client框架,并且与各个主流前端技术栈如Angular、React等均有适配版本,使用感觉良好。

同时,还需要了解一些额外的查询概念,比如分页查询中涉及的Connection、Edge等。

大概就这么多,如有错误,还望指正。

more

RPC vs REST vs GraphQL


写在前面

最近2周的时间由于工作不忙,一直在看有关GraphQL的东西,前后端均有涉及,由于我之前做过后端开发,当时实现的接口的大体是符合RPC风格的接口。后来转做了前端开发,从实现接口者变成了调用接口者,接触最多的当属REST风格的接口。因此在这段学习GraphQL的过程中,并且也尝试使用它以全栈的角度做了一个小项目,在这个过程中,一直在思考它对比前两者在API设计的整体架构体系中的各个指标上,孰优孰劣。

其实在使用和学习的过程中,有很多文章都对比过它们的异同,但是大部分文章并没有从一个相对客观的角度来对比,更多是为了突显一个的优点而刻意指出另外一个的缺点。这让我想到一句话,脱离业务情景谈技术就是耍流氓。

昨天订阅的GraphQL Weekly中推送的一个视频正好是讲关于它们这三者的,于是就点进去看了看,发现质量还是不错的,于是就想整理出来,分享给大家。

原视频地址(油管地址,自备梯子):这里

如果没有梯子的话直接看我整理的东西也可以,我觉的应该都覆盖到视频中所讲的重点内容了。

当然,这些内容如果分开来讲,每一块内容所涉及的东西都够写一本书了,这里仅仅是简单归纳和整理,从宏观的角度来对比它们的异同,从而能够在日后面临技术选型时,有一个更佳明确的决策方向。

RPC

先简单介绍下RPC,它是Remote Procedure Call(远程过程调用)的简称。一般基于RPC协议所设计的接口,是基于网络采用客户端/服务端的模式完成调用接口的。

优点

  • 简单并且易于理解(面向开发者)
  • 轻量级的数据载体
  • 高性能

缺点

  • 对于系统本身耦合性高
  • 因为RPC本身很简单、轻量,因此很容易造成 function explosion

关于RPC的优点其实很好理解,就是因为它性能高同时又很简单,但是我认为这是对于接口提供者来讲的(因为它的高耦合性)。

但是如果从接口调用者的角度来看,高耦合性就变成了缺点,因为高耦合意味着调用者必须要足够了解系统本身的实现才能够完成调用,比如:

  • 调用者需要知道所调用接口的函数名、参数格式、参数顺序、参数名称等等
  • 如果接口提供者(server)要对接口做出一些改变,很容易对接口调用者(client)造成breaking change(违背开闭原则)
  • 一般RPC所暴露接口仅仅会暴露函数的名称和参数等信息,对于函数之间的调用关系无法提供,这意味着调用者必须足够了解系统,从能够知道如何正确的调用这些接口,但是对于接口调用者往往不需要了解过多系统内部实现细节

关于上面的第二点,为了减少breaking change,我之前实现接口的时候一般都会引入版本的概念,就是在暴露接口的方法名中加入版本号,一开始效果确实不错,但是随后就不知不觉的形成了function explosion,和视频中主讲人所举例的例子差不多,贴一下视频中的截图感受一波:

REST

当前REST风格的API架构方式已经成了主流解决方案了,相比较RPC,它的主要不同之处在于,它是对于资源(Resource)的模型化而非步骤(Procedure)。

优点

  • 对于系统本身耦合性低,调用者不再需要了解接口内部处理和实现细节
  • 重复使用了一些 http 协议中的已定义好的部分状态动词,增强语义表现力
  • API可以随着时间而不断演进

缺点

  • 缺少约束,缺少简单、统一的规范
  • 有时候 payload 会变的冗余(overload),有时候调用api会比较繁琐(chattiness)
  • 有时候需要发送多条请求已获取数据,在网络带宽较低的场景,往往会造成不好的影响

REST的优点基本解决了RPC中存在的问题,就是解耦,从而使得前后端分离成为可能。接口提供者在修改接口时,不容易造成breaking-change,接口调用者在调用接口时,往往面向数据模型编程,而省去了了解接口本身的时间成本。

但是,我认为REST当前最大的问题在于虽然它利用http的动词约束了接口的暴露方式,同时增强了语义,但是却没有约束接口如何返回数据的最佳实践,总让人感觉只要是返回json格式的接口都可以称作REST。

我在实际工作中,经常会遇到第二条缺点所指出的问题,就是接口返回的数据冗余度很高,但是却缺少我真正需要的数据,因此不得已只能调用其他接口或者直接和后端商议修改接口,并且这种问题会在web端和移动端共用一套接口中被放大。

当前比较好的解决方案就是规范化返回数据的格式,比如json-schema或者自己制定的规范。

GraphQL

GraphQL是近来比较热门的一个技术话题,相比REST和RPC,它汲取了两者的优点,即不面向资源,也不面向过程,而是面向数据查询(ask for exactly what you want)。

同时GraphQL本身需要使用强类型的Schema来对数据模型进行定义,因此相比REST它的约束性更强。

优点

  • 网络开销低,可以在单一请求中获取REST中使用多条请求获取的资源
  • 强类型Schema(约束意味着可以根据规范形成文档、IDE、错误提示等生态工具)
  • 特别适合状数据结构的业务场景(比如好友、流程、组织架构等系统)

缺点

  • 本身的语法相比较REST和RPC均复杂一些
  • 实现方面需要配套 Caching 以解决性能瓶颈
  • 对于 API 的版本控制当前没有完善解决方案(社区的建议是不要使API版本化)
  • 仍然是新鲜事物,很多技术细节仍然处于待验证状态

鉴于GraphQL这两个星期我也仅仅是做了一些简单地使用和了解,仅仅说一下感受。

首先值得肯定的是,在某些程度上确实解决了REST的缺点所带来的问题,同时配套社区建议的各种工具和库,相比使用REST风格,全栈开发体验上升一个台阶。

但是这个看起来很好的东西为什么没有火起来呢?我觉的最主要的原因是因为GraphQL所带来的好处,大部分是对于接口调用者而言的,但是实现这部分的工作却需要接口提供者来完成。

同时GraphQL的最佳实践场景应当是类似像Facebook这样的网站,业务逻辑模型是图状数据结构,比如社交。如果在一些业务逻辑模型相对简单的场景,使用GraphQL确实不如使用REST来得简单明了、直截了当。

另外一方面是GraphQL的使用场景相当灵活,在我自己的调研项目中,我是把它当做一个类似ORM的框架来使用的,在别人的一些文章中,会把它当做一个中间层来做渐进式开发和系统升级。这应当算是另外一个优点。

到底用哪个

下面根据要设计的API类型给予一些技术选型建议。

如果是Management API,这类API的特点如下:

  • 关注于对象与资源
  • 会有多种不同的客户端
  • 需要良好的可发现性和文档

这种情景使用REST + JSON API可能会更好。

如果是Command or Action API,这类API的特点如下:

  • 面向动作或者指令
  • 仅需要简单的交互

这种情况使用RPC就足够了。

如果是Internal Micro Services API,这类API的特点如下:

  • 消息密集型
  • 对系统性能有较高要求

这种情景仍然建议使用RPC

如果是Micro Services API,这类API的特点如下:

  • 消息密集型
  • 期望系统开销较低

这种情景使用RPC或者REST均可。

如果是Data or Mobile API,这类API的特点是:

  • 数据类型是具有图状的特点
  • 希望对于高延迟场景可以有更好的优化

这种场景无疑GraphQL是最好的选择。

写在最后

提供一张表格来总览它们之间在不同指标下的表现:

耦合性 约束性 复杂度 缓存 可发现性 版本控制
RPC(Function) high medium low custom bad hard
REST(Resource) low low low http good easy
GraphQL(Query) medium high medium custom good ???

最后引用人月神话中的观点no silver bullet,在技术选型时需要具体情况具体分析,不过鉴于GraphQL的灵活性,把它与RPC和REST配置使用,也是不错的选择。

more

高级 Angular 组件模式 (4)


04 Avoid Namespace Clashes with Directives

原文: Avoid Namespace Clashes with Directives

提示

在同一个html元素上绑定多个指令可能会造成命名冲突。

命名冲突不仅存在于指令的选择器之间,同时也会存在于指令的InputsOutputs属性,当这些属性名一样时,Angular并不会进行提示,它会按原本的逻辑正常工作。这种情况有时候是我们希望看到的,有些时候却不是。

目标

避免存在于绑定在相同元素上的多个指令上的命名冲突。

实现

因为togglewithToggle指令都绑定于<toggle>元素,我们将通过为它们增加一个label属性来说明问题。

首先我们设置一个label属性,比如:

1
<toggle label="some label">

这个label属性的值会同时绑定在每个指令上,如果想要为其中的某个指令单独绑定,只能通过使用prefix(前缀)来实现。

Angular官方提供的规范指南也警示了这一点,当你在使用prefix修饰指令的名称时,也需要注意使用prefix来修饰InputOutput属性的名称。

Note: 当使用Output属性重写原生DOM元素的事件和使用Input属性重写原生元素的属性时,请额外注意,没有任何方式可以获知别人在他们编写的应用或者库中使用的命名,但是你可以很轻易的知道的具体命名的大体规则是什么,并且不要重写它们,除非你有意为之。

增加prefix的一种方式是在每个指令的label属性的装饰器内增加一个字符串参数,如下:

1
2
3
4
5
// In withToggle.directive.ts
@Input('withToggleLabel') label;

// In toggle.directive.ts
@Input('toggleLabel') label;

但是这种解决方案的前提时,你至少能够更改存在命名冲突中的一个或多个指令的源码。如果在两个第三方库中存在命名冲突,这种情况是最棘手的,我们不在这里讨论它们。

成果

https://stackblitz.com/edit/adv-ng-patterns-04-namespace-clashes

译者注

原文中关于最后一段提出的关于在多个第三方库中存在的命名冲突的场景,作者提供做出具体的解决方案,我在这里简单分享一下自己对于这种情况的解决方案:

通常这种情况比较少见,但是万一存在这种情况,我们可以通过创建一个新的wrapper指令来封装第三方指令,wrapper指令提供与第三方指令一样的接口属性,但是因为我们对于wrapper指令有绝对的控制权,我们可以提供统一的prefix来修饰这些接口属性,从而达到解决冲突的效果。

more