整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

NodeJS的客户端浏览器和304缓存及OnceIO

NodeJS的客户端浏览器和304缓存及OnceIO缓存控制

nceIO是OnceDoc企业私有内容(网盘)管理系统的底层Web框架,它可以实现模板文件、静态文件的全缓存,运行起来完全不需要I/O操作,并且支持客户端缓存优化,GZIP压缩等(只压缩一次),拥有非常好的性能,为您节约服务器成本。它的模块化功能,可以让你的Web进行分布式存储,在一个扩展包里即可包含前端、后端和数据库定义,只需通过添加/删除目录的方式就可实现功能删减,实现真正的模块化扩展。目前OnceIO已经开源,这里是介绍如何使用的一系列文章。

客户端缓存

缓存定义

这里讨论的缓存是指 web 缓存:一个 web 资源(如 html 页面、图片、文件等)在服务器和客户端(浏览器)之间的副本。缓存会根据进来的请求保存请求输出的内容的副本;然后,如果下一个请求是相同的 URL,且网页在这段时间内没有更新,浏览器就不会再次下载网页,而是直接使用本地缓存的网页副本。

缓存的作用主要有:

  • 节约带宽。

  • 减少延迟。

  • 降低服务器压力。

客户端(浏览器)的缓存机制

所有的缓存都有一套规则来帮助它们决定什么情况下使用缓存中的副本,什么情况下向源服务器再次发送请求。这些规则有的在协议(如 HTTP 协议 1.0 和 1.1)中有定义,有的则是由缓存的管理员(如 DBA、浏览器的用户、代理服务器管理员或者应用开发者)设置。

对于浏览器端的缓存,这些规则是在 HTTP 协议头和 html 页面的 meta 标签中定义的。它们从新鲜度和校验值两个维度来决定浏览器是否可以直接使用缓存中的副本。

新鲜度(过期机制):也就是缓存副本有效期。一个缓存副本必须满足以下条件,浏览器才会认为它是有效的:

  1. 含有完整的过期时间控制头信息(HTTP协议报头),并且仍在有效期内;

  2. 浏览器已经使用过这个缓存副本,并且在一个会话中已经检查过新鲜度;

满足以上两个情况的一种,浏览器会直接从缓存中获取副本并渲染。

校验值(验证机制):服务器返回资源的时候有时在控制头信息带上这个资源的实体标签 ETag(Entity Tag),它可以用来作为浏览器再次请求过程的校验标识。如过发现校验标识不匹配,说明资源已经被修改或过期,浏览器需求重新获取资源内容。

常用的与缓存有关的 HTTP 消息报头

消息报头类型作用规则
Status Code200 OK普通表明服务器成功返回网页不适用
304 Not Modified普通表明当前资源的内容(自上次访问以来或根据请求的条件)没有修改过,服务器不返回网页内容不适用
Cache-Controlmax-age=315360000响应指明缓冲副本的有效时长,单位为秒新鲜度
ExpiresThu, 31 Dec 2037 23:55:55 GMT响应告诉浏览器在过期时间前可以使用副本新鲜度
Last-ModifiedSun, 23 Oct 2016 06:36:08 GMT响应告诉浏览器当前资源的最近一次修改时间新鲜度
If-Modified-SinceSun, 23 Oct 2016 06:36:08 GMT请求如果浏览器第一次请求时响应中 Last-Modified 非空,第二次请求同一资源时,会把它作为该项的值发给服务器新鲜度
ETag978534响应告诉浏览器当前资源在服务器的唯一标识符(生成规则由服务器决定)校验值
If-None-Match978534请求如果浏览器第一次请求时响应中 ETag 非空,第二次请求同一资源时,会把它作为该项的值发给服务器校验值

以访问网站 http://oncedoc.com/ 为例,网站的 shader.css 文件的 HTTP 头信息为:

客户端缓存生效的常见流程

服务器收到请求时,在 200 OK 响应中回送该资源的 Last-Modified 和 ETag,客户端将该资源保存在缓存中,并记录这两个属性。当客户端再次发送相同的请求时,会在请求中携带 If-Modified-Since 和 If-None-Match 两个消息报头。两个报头的值分别是上次请求收到的响应中 Last-Modified 和 ETag 的值。服务器通过这两个头判断本地资源未发生变化,客户端不需要重新下载,返回 304 响应。以访问 oncedoc.com 为例,客户端缓存生效流程如下:

用户操作行为与缓存

用户在使用浏览器的时的各种操作,如输入地址后回车,按F5刷新等,对缓存有可能会造成影响。

用户操作Expires/Cache-ControllLast-Modified/ETag
地址栏回车有效有效
页面链接跳转有效有效
新开窗口有效有效
前进后退有效有效
F5 刷新无效有效
Ctrl+F5 强制刷新无效无效

当用户在按 F5 进行刷新时,浏览器会忽略 Expires/Cache-Control 的设置,再次向服务器发送请求,而 Last-Modified/Etag 仍然是有效的,服务器会根据情况判断返回 304 还是 200 ;而当用户使用 Ctrl+F5 进行强制刷新的时候,所有的缓存机制都将失效,;浏览器将重新从服务器下载资源并返回 200。

在服务器端设置客户端缓存机制

浏览器端缓存

运行服务器,访问 localhost:8054/img,打开浏览器开发者工具中的 Network 栏,地址栏回车,Network 显示:

此时浏览器直接从本地获取图片资源,浏览器和服务器之间并没有进行I/O操作。浏览器没有问服务器端是否有更新,而直接从本地缓存中获取资源。

res.cache(0)

有时侯,我们可能需要禁用浏览器端的缓存机制,然后让浏览器发送一次请求询问是否有更新(比如ajax操作)。可以用添加一个 cache-control的header: res.cache(0),即0秒后立即失效(不缓存),示例代码如下:

app.use(function(req, res) {

res.cache(0)

req.filter.next()

})

app.get('/img', function(req, res) {

res.render('img.html')

})

此时浏览器与服务器之间会进行一次 I/O,如果本地缓文件的修改时间(IF-Modify-since)与服务器端的一致,即没有修改,则OnceIO会发出 304 响应(如图所示),告诉浏览器从本地缓存中获取资源;如果服务器端文件有更新,OnceIO则会发出 200 响应,并将更新资源重新发给浏览器。

此时服务器端通过304告诉浏览器从本地缓存中获取资源。

通过res.cache接口,您可以根据您应用的Release周期(周、月)来设置资源文件缓存的最大缓存时间,来优化您的应用,比如一周后过期:

res.cache(7*24*3600)

下一节我们将介绍OnceIO的服务器端的模板和静态文件缓存和gzip压缩机制,和其一次读取,永久使用的实现原理。

OnceIO项目: https://github.com/OnceDoc/onceio

文将通过一个快速演示深入研究 HTTP103状态代码

每天?分享?最新?软件?开发?,Devops,敏捷?,测试?以及?项目?管理?最新?,最热门?的?文章?,每天?花?3分钟?学习?何乐而不为?,希望?大家?点赞?,加?关注?,你的?支持?是我?最大?的?动力?。



最近,谷歌 Chrome 103发布了一系列新功能。其中一个值得注意的特点是引入了 HTTP状态码103。本文将通过一个快速演示深入研究 HTTP103状态代码。

HTTP 103

从 Mozilla Developer Network 的网络文档来看,HTTP 103早期提示是信息响应状态代码,主要用于链接头,允许用户代理在服务器还在准备响应时开始预载资源。

以下是 RFC 链接以获得更多详细信息。


HTTP103可以通过使用 link rel=preload 配置 HTTP 头字段来优化页面速度。

它是如何工作的?

通常,当浏览器发送一个请求时,服务器会在不到一秒钟的时间内接收并处理该请求,然后发送一个 HTTP200OK 响应,如下所示。


然而,使用 HTTP103早期提示,还有提高页面呈现速度的空间。

一旦服务器使用 HTTP 103功能进行了更新,当浏览器发送一个请求时,如果服务器知道内容需要 style.css、 script.js 等资源,那么它将使用 HTTP 103早期提示响应向浏览器提示(响应)以预加载内容,如下所示。

然后,一旦服务器处理完整的响应,它将向浏览器发送普通的 HTTP200OK。

当浏览器预先加载内容时,这个过程将有助于提高页面呈现速度。

如上所述,此功能需要对服务器进行更新。如需更新 Apache HTTP Server,请点击这里进行配置。

早期提示仅适用于 HTTP/2和 HTTP/3。

它只支持200、301和304响应返回代码。

此外,它工作在具有预连接或预加载重载类型的响应链接头上。

演示


为了演示 HTTP103早期提示,我在 AWS 上部署了一个带有 Ubuntu 映像的 EC2实例。我用 HTTP/2和 SSL 安装了 Apache HTTP Server。

这是我的 conf 文件内容。

H2Push on
H2EarlyHints on

下面是演示页面的 curl 输出:

正常 HTTP 200 OK

让我们在 conf 文件中配置 H2PushResource 并重新加载服务器。

H2Push on
H2EarlyHints on
<Location /index.html>
    H2PushResource /main.css
</Location>

使用 sudosystemctl 重新启动 apache2命令重新启动 apacheserver。

下面是启用 HTTP103EarlyHint 特性后的 curl 输出。

HTTP 103早期提示

如上所述,服务器的第一个响应是 HTTP/2103,将 main.css 预加载到浏览器,然后服务器将用 HTTP 200作出响应。

下面是服务器响应时间部分。

服务器响应时间

最后的想法

正如您了解到的,HTTP103早期提示通过提示浏览器预加载资源来帮助优化页面呈现时间。它还解决了这里概述的 HTTP/2服务器推送的主要问题。Cloudflare 还致力于利用机器学习使早期提示更加智能。我们祈祷吧。

者:Qiuyi

证明即程序,结论公式即程序类型。
—— 柯里-霍华德对应 [1]

背景

我们每天的编码都会使用到类型系统,本篇文章希望能够简单地介绍原理到实践,让读者能更好的使用类型系统编写出类型安全并简洁的代码。

本篇文章预期读者是拥有 TypeScript 基础的同学。

CodeShare - 安全的 any 互操作

众所周知,any 是一个危险的类型,可以关闭所有类型检查。 但是实际的浏览器程序中不可能完全避免 any 类型进入类型系统,对我们的类型推理产生影响。比如

  • `JSON.parse()`[2]
  • `Reflect.get()`[3]
  • `Response.json()`[4]

对于 any 的处理,最佳方法是先把他变成 TypeScript 的顶层类型 unknown,这样它就不能在类型系统中随意传播了,必须要求程序员主动进行类型转换才能在其他地方使用。

分享一个代码片段,这个代码片段尝试将 window 上的挂载的一个全局方法获取出来,假如存在,就转换成安全的类型后再放出去;假如不存在,就换成一段 fallback 逻辑并展示警告信息。

export type I18NMethod=(key: string, options: unknown, fallbackText: string)=> string;

function isI18nFunction(input: unknown): input is I18NMethod {
  return typeof input==='function';
}

function makeI18nMethod(): I18NMethod {
  let hasWarnShown=false;

  return function (key: string, options: unknown, fallbackText: string) {
    if (Reflect.has(window, '$i18n')) {
      // $i18n是一个挂载到 window 对象上的全局方法
      const globalI18n: unknown=Reflect.get(window, '$i18n');
      if (isI18nFunction(globalI18n)) {
        return globalI18n(key, options, fallbackText);
      }
    }
    showWarnOnce();
    return fallbackText;
  };

  function showWarnOnce() {
    if (hasWarnShown===false) {
      hasWarnShown=true; // 只展示一次警告
      console.warn('Cannot Fetch i18n Text: window.$18n is not a valid function');
    }
  }
}

export const $i18n=makeI18nMethod();

// usecase
$i18n("hello-text-key", {}, "你好");

13 行获取了一个 any 类型的对象,第一步是将其转换为 unknown 类型。

假如 14 行不调用 isI18nFunction 转换类型,而是直接返回 globalI18n,ts 将报错:Type 'unknown' is not assignable to type 'string',从而要求开发者必须编写类型转换。

本文中所有 TypeScript 示例代码都可以复制粘贴放进 TypeScript Playground[5] 运行。

非常推荐读者这样做,可以看到编译器真实的类型推断过程。

这里我采用了 typescript 的 is 语法来进行一个运行时类型检测,通过后进行类型转换。从而使得运行时类型更安全。

类型系统基础原理

CodeShare 中提到通过将 any 转换成了顶层类型 unknown,从而确保了类型安全

要理解这个操作需要回答四个问题:

  1. 为什么直接用 any 不安全?
  2. 顶层类型是什么?
  3. 为什么顶层类型是安全的?
  4. unknwon 为什么是顶层类型?

要回答这些问题,我们需要理解类型系统为什么把一些类型转换当作安全的(可以隐式转换),另一些类型转换当作不安全的(需要用 as 强制类型转换)。换句话说,需要了解类型系统的推导原理。

子类型

类型系统的推导原理是子类型系统,所以我们首先来看什么是子类型。

子类型(subtype) :如果在期望类型 T 的实例的任何地方,都可以安全地使用类型 S 的实例,那么称类型 S 是类型 T 的子类型,反之则称为父类型。

假设一个函数接受一个 Shape 的参数,如果此时能安全地传入 Rect,那么 Rect 就是 Shape 的子类型。

TypeScript 使用了结构子类型 (Structural Type System) 来实现子类型系统:如果 A 类型拥有 B 类型全部相同的结构,A 就是 B 的子类型。

以下示例演示 typescript 的基础子类型推导。注意本篇文章全部使用 class 表示类型,是因为这里是为了简化代码说明子类型原理,而非解释狭义的类型定义语法(type 或 interface)。

class Employee{
    public base=4000;
}

class Programmer extends Employee {
    public base=5000;
}

class Designer {
    public base=5000;
}

class Advertiser{
    public bonus=6000;
}

function getSalary(who: Employee): number{
    return who.base;
}

// OK,类型一致
getSalary(new Employee())

// Ok,Programmer 是 Employee 的子类型,编译器可以安全的做隐式类型转换 Programmer -> Employee
getSalary(new Programmer())

// Ok,Designer 虽然没有声明是 Employee 的子类型,但是由于结构子类型的定义,Designer 是安全的
getSalary(new Designer())

// Error Advertiser 不是 Employee 的子类型,这里不能做隐式类型转换
getSalary(new Advertiser())

// OK,我们可以强制转换。但是这样不安全。
getSalary(new Advertiser() as unknown as Employee)

any 类型

any 实际上是一个 TypeScript 的特例,是作为关闭“绕过类型检查”的标志,用来和 JavaScript 互操作。如果非要从类型系统的角度看,any 既是任何类型的子类型,又是任何类型的父类型。因为太特殊了,一般不把 any 作为顶层或底层类型看待。

let aAny: any=1;
let aNumber: number=1;

aAny=aNumber; // OK
aNumber=aAny; // OK

any 既是任何类型的子类型,又是任何类型的父类型。

any 类型会让 TS 关闭所有类型检查,非常不安全。

顶层类型

当一个类型是其他所有可能的类型的父类型,则称之为顶层类型。

回顾一下子类型的定义:如果在期望类型 T 的实例的任何地方,都可以安全地使用类型 S 的实例,那么称类型 S 是类型 T 的子类型。

换句话说,顶层类型就是在声明使用顶层类型的地方,可以安全地传入其他任意类型。从这个推理出发,我们可以发现顶层类型是:unknown

let aUnknown: unknown=1;
let aNumber: number=1;

aUnknown=aNumber; // OK,number 可以赋给 unknown,因为 number 是 unknown 的子类型
aNumber=aUnknown; // Error: unknown 不是 number 的子类型

unknown 顶层类型的特性演示

从定义我们知道,顶层类型不是任何类型的子类型,所以使用在任何声明非顶层类型使用的地方,都必须经过强制类型转换。

类型转换

在子类型示例中我们写了一段强制类型转换的代码:

// Advertiser -> unknown -> Employee
getSalary(new Advertiser() as unknown as Employee)

这里的 as unknown 其实是必须的,并不是写着玩。读者可以尝试在 TypeScript Playground 中尝试删除中间的 as unknown,编译器会直接报错:

Conversion of type 'Advertiser' to type 'Employee' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
  Property 'base' is missing in type 'Advertiser' but required in type 'Employee'.

这是因为 TypeScript 只允许父子类型之间进行类型转换。换句话说,只允许将类型向上转换为父类型,或者将类型向下转换为子类型。而 unknown 作为顶层类型,就可以在任何地方承担转换的“中间态”。

作为一个类型系统而言,TypeScript 这个设计是合理且安全的,不是 Bug。

  • 子类型到父类型转换:称为向上转换,是安全的,可以隐式转换;
  • 父类型到子类型转换:称为向下转换,是不安全的,需要主动声明才能转换;
  • 非父子类型间类型转换:非法行为。

总结

  • 子类型(subtype) :如果在期望类型 T 的实例的任何地方,都可以安全地使用类型 S 的实例,那么称类型 S 是类型 T 的子类型。
  • 只有父子类型之间才能进行类型转换。
  • 为什么 any 类型不安全?
    因为 any 既是任何类型的子类型,又是任何类型的父类型,可以绕过所有 TS 类型检查。
  • 什么是顶层类型?
    当一个类型是其他所有类型的父类型,则称之为顶层类型。
  • 为什么顶层类型安全?
    因为顶层类型不是任何类型的子类型,在接收其他类型地方,必须经过手动强制类型转换。强制类型转换需要开发者主动声明,让开发者告诉编译器:我已经做好了所有检测,可以进行转换。
  • 为什么 unknown 是顶层类型?
    任何类型的值都可以赋给 unknown,但是 unknown 类型的值不能赋给其他类型(any 除外)。

编写类型安全代码

类型编程最大的应用就是用来对代码进行静态检查,减少潜在的 bug。

TypeScript 设置

对于 TS 来说,非常建议开启两个选项,新项目最好一开始就打开:

  • strictNullChecks 选项让 null 和 undefined 成为单元类型。
  • strictFunctionTypes 确保函数中返回值类型是协变的,而参数类型是逆变的,这样函数子类型更安全。(协变和逆变的概念见本文“类型可变性”章节)

基本类型偏执

基本类型 number string boolean不好的点在于:这些类型携带的可读性信息不足,并且对使用者暴露了太多细节。

比如一个防抖函数:

declare function debounce<Args extends unknown[], Output>(
    wait: number, fn: (...args: Args)=> Output
): (...args: Args)=> Output;

// useCase
const debouncedLog=debounce(500, (input:string)=> console.log(input))

这里的问题是:

  • 500 是指什么?500 秒还是 500 毫秒?
  • wait 传入 -1 会发生什么?

对于有具体意义的概念不愿意建模,而是用基本类型表示,这种问题称为基本类型偏执。(出处:《重构,改善既有代码的设计》[6]

我们新增一个简单的 Millseconds 类型来解决问题:

declare function debounce<Args extends unknown[], Output>(
    wait: Millseconds, fn: (...args: Args)=> Output
): (...args: Args)=> Output;

class Millseconds {
  constructor(readonly value: number){
      if(this.value < 0){
          throw new Error('Millseconds Value Cannot Smaller Than 0');
      }
  }
}

// useCase:
const debouncedLog=debounce(new Millseconds(500), (input: string)=> console.log(input))

这样我们的可读性就好了很多,无论是谁都能直接读出来我们在设置一个 500 毫秒等待时间的防抖函数。

优化:模拟名义子类型

然而这里还有一个问题,由于 TypeScript 是一个基于结构子类型的类型系统,只要结构类型相同就可以在这里顺利传入。

declare function debounce<Args extends unknown[], Output>(
    wait: Millseconds, fn: (...args: Args)=> Output
): (...args: Args)=> Output;

class Millseconds {
  constructor(readonly value: number){
      if(this.value < 0){
          throw new Error('Millseconds Value Cannot Smaller Than 0');
      }
  }
}

class Seconds {
  constructor(readonly value: number){}
}

const debouncedLog=debounce(new Seconds(500), (input: string)=> console.log(input))

在本文其实一直在用一个操作来模拟名义子类型,用一个 unique symbol 来强制类型结构独一无二,无法仿造。

declare const msSym: unique symbol;
class Millseconds {
  private [msSym]=null;

  constructor(readonly value: number){
      if(this.value < 0){
          throw new Error('Millseconds Value Cannot Smaller Than 0');
      }
  }
}

优化:字面量检测

然而这里还有一个问题:虽然报错信息好了很多,但是传入小于 0 的数还是只能在运行阶段报错。

就算写new Millseconds(-1)这种明显的错误,类型系统依然躺平装死。为了解决这个问题,我们可以用 TS 新增的 string literal 特性(需要 TS 大于 4.5)来搞一点点字面量体操:

// <N extends number> 要求 N 是 number 的子类型
// 第一个判断条件:`number extends N ?` 意思是如果 number 是 N 的子类型,就进入分支 1,否则进入分支 2
// 第一个条件分支:如果 number 是 N 的子类型,则类型是 N,又已知 N 是 number 的子类型,那么 N=number
// 第二个条件分支:如果`${N}` 的字符串字面量是 `-${string}` 的子类型,返回空类型,否则返回N
type AssertPositive<N extends number>=number extends N ?
     N :
    `${N}` extends `-${string}` ? never : N;

class Millseconds<N extends number> {
  constructor(public readonly value: AssertPositive<N>){
      if(this.value < 0){
          throw new Error('Value Cannot Smaller Than 0');
      }
  }
}

new Millseconds(0); // OK
new Millseconds(1); // OK
new Millseconds(-1); // Error

实施类型约束

基本类型偏执模式的思路可以用到其他地方。假设我们需要有一个定时器,指定一个未来的绝对时间,在那个时间执行操作:

declare function setTimer(absoluteTime: Date, callback: ()=> void): void;

setTimer(new Date("2024-01-01T00:00:00"), console.log.bind(null, 'Happy New Year!');

这里可读性还是不错的,很容易读出来这里是要在 24 年元旦节祝你新年快乐。但是这里使用 Date 无法表明要一个未来的时间。

和基本类型偏执一样,我们可以套一个用于检测约束的类型来优化:

declare function setTimer(absoluteTime: FutureDate, callback: ()=> void): void;

class FutureDate {
  constructor(public readonly date: string){
      const targetDate=new Date(date);
      if(!isNaN(targetDate.getTime()) || targetDate.valueOf() < new Date().valueOf()){
          throw new Error('Error: Must provide a future date')
      }
  }
}

setTimer(new FutureDate("2024-01-01T00:00:00"), console.log.bind(null, 'Happy New Year!'))

这种类型检测的模式可以套用在很多类型信息不具体的地方。

用运行时信息辅助类型系统

安全的 any 互操作例子中,已经演示了怎么用运行时的数据来帮助类型系统更加健壮。在第 4 行调用 typeof 来获取变量运行时类型名称,根据运行时类型来进行强制类型转换(TypeScript 的 is 返回值是一种类型向下转换)。

运用类似的思路,可以依据运行时信息编写让类型转换更安全的代码,从而健壮我们的类型推导。

这里举个例子,swift 语言中有一个经典的 Optional 类型设计。

let number: Int?=Optional.some(42);

if number==nil {
  print('number is nil')
} else {
  print('The value is {number}')
}

TypeScript 3.7 已经用和类型 T | undefined 实现了类似的语法Optional Chain[7]。假设 TS 中没有实现这个语法,我们需要手动写一个 Optional 类型,如下代码所示。

class Optional<T> {
    private assigned=false;

    constructor(public value: T | undefined) {
        if (value !==undefined) {
            this.assigned=true;
        }
    }

    hasValue() { return this.assigned }

    setValue(value?: T){
       if (value !==undefined) {
            this.assigned=true;
            this.value=value;
        }
    }

    getValue(): T {
        if (this.assigned) {
            return this.value as T
        }
        throw new Error('OptionalError: Value has not be assigned')
    }
}

const maybeNumber=new Optional<number>(1);

// unboxing check
if(maybeNumber.hasValue()){
  // `T | undefined` -> `T`
  const mustbeNumber: number=maybeNumber.getValue();
}

其中第 20 行通过判断一个附加信息(this.assigned)后进行类型转换,安全地将 undefined 排除出和类型 T | undefined

深入类型系统原理

如果你并不满足于了解最基本的类型系统原理,那就可以看一下以下内容。

类型可变性

现在我们知道了基础的子类型原理。假设我们现在有一个 Programmer 是 Employee 的子类型(class Programmer extends Employee),考虑这几个问题:

  • 'A' | 'B''A' | 'B' | 'C' 的子类型关系如何?
  • Programmer[]Employee[] 的子类型关系如何?
  • 对于范型结构 List<Programmer>List<Employee> 的子类型关系如何?
  • ()=> Programmer()=> Employee 的子类型关系如何?
  • (input:Programmer)=> void(input: Employee)=> void 的子类型关系如何?

在做这些证明之前,还是需要先明确子类型的定义:
子类型(subtype) :如果在期望类型 T 的实例的任何地方,都可以安全地使用类型 S 的实例,那么称类型 S 是类型 T 的子类型。

  • 对于和类型而言,父类型比子类型复杂度更高。换句话说,'A' | 'B''A' | 'B' | 'C' 的子类型。
    证明: 假设一个函数要求参数是 'A' | 'B' | 'C',那么我们传入 'A' | 'B' 始终是合法的,反之则不行。所以 'A' | 'B''A' | 'B' | 'C' 的子类型。
  • 数组子类型关系和原类型子类型关系一致。
declare const employeeSym: unique symbol;

class Employee {
	[employeeSym]: void
}

declare const programmerSym: unique symbol;

class Programmer extends Employee {
	[programmerSym]: void
}

const employees: Employee[]=[new Programmer()]; // OK
const programmers: Programmer[]=[new Employee()]; // Error
  • 范型子类型关系和原类型子类型关系一致。
declare const employeeSym: unique symbol;

class Employee {
	[employeeSym]: void
}

declare const programmerSym: unique symbol;

class Programmer extends Employee {
	[programmerSym]: void
}

class List<T> {
  constructor(public readonly list: T[]){};
}

let eList:List<Employee>=new List([new Employee()])
let pList:List<Programmer>=new List([new Programmer()])

eList=pList; // OK
pList=eList;  // Error
  • 返回值子类型关系和原类型子类型关系一致。
declare const employeeSym: unique symbol;

class Employee {
	[employeeSym]: void
}

declare const programmerSym: unique symbol;

class Programmer extends Employee {
	[programmerSym]: void
}

function getEmployee(getter: ()=> Employee) {
  return getter()
}
getEmployee(()=> new Employee()) // OK
getEmployee(()=> new Programmer()) // OK

function getProgrammer(getter: ()=> Programmer) {
  return getter()
}
getProgrammer(()=> new Programmer()) // OK
getProgrammer(()=> new Employee()) // Error
  • 参数子类型关系和原类型子类型关系相反。
declare const employeeSym: unique symbol;

class Employee {
	[employeeSym]: void
}

declare const programmerSym: unique symbol;

class Programmer extends Employee {
	[programmerSym]: void
}

function useEmployee(setter: (e: Employee)=> void) {
  return setter(new Employee())
}
function useProgrammer(setter: (e: Programmer)=> void) {
  return setter(new Programmer())
}

const employeeUser=(e: Employee)=> e;
const programmerUser=(e: Programmer)=> e;

useEmployee(employeeUser) // OK
useEmployee(programmerUser) // Error
useProgrammer(employeeUser) // OK
useProgrammer(programmerUser) // OK

协变性:如果一个类型保留其底层类型的子类型关系,就称该类型具有协变性。

逆变性:如果一个类型颠倒了其底层类型的子类型关系,则称该类型具有逆变性。

从数学角度理解类型

类型:类型是对数据做的一种分类,定义了能够对数据执行的操作、数据的意义。编译器和运行时会检查类型,以确保数据的完整性,实施访问限制,以及按照开发人员的意图来解释数据。

从数学上来看,类型就是一个集合

  • number 类型,代表一个 64 位浮点数可以表示的所有数字的一个集合。
  • string 类型,代表一个无限的集合,所有字符串数据都在此集合中。

函数代表从一个集合到另外一个集合的映射。比如此函数类型定义:

type typeA='a' | 'b' | 'c' | 'd'
type typeB='m' | 'n' | 'p' | 'q'

type a2b=(a: typeA)=> typeB;

a2b 函数可以表示从 A 集合到 B 集合到一个映射。

有多个函数参数的情况下,一个函数代表参数的积类型到返回值类型的一个映射。积类型的概念在本文后面介绍。

说完了类型,再来看看类型系统的定义。类型系统是一组规则,从职责上来看,一个具有类型系统的编程语言代表:

  • 可以用类型表示语言中的所有元素所在的集合,比如变量、函数、类、模块等;
  • 可以对类型进行逻辑运算推导,从而静态代码检查等功能。

名义子类型和结构子类型

子类型的概念比较抽象,没有指定具体实现方式,不同编程语言对子类型的实现不尽相同,但是一般可以分为两种类型:名义子类型结构子类型

名义子类型 Nominal Type System

名义子类型意味着当且仅当显式说明的情况下,两个类型才具有父子类型关系。采用这种实现的语言有 C++ Java C# 等。

// Java Compiler: https://www.jdoodle.com/online-java-compiler/
class Employee{
 public int base=4000;
}

class Programmer extends Employee{
 public int base=5000;
}

class Advertiser {
 public int base=6000;
}

class Business {
   public static int getSalary(Employee who){
       return who.base;
   }
}

public class Main {
  public static void main(String[] args){
      Business.getSalary(new Employee()); // output: 4000
      Business.getSalary(new Programmer()); // output: 5000

      // Incompatible Types Error: Advertiser cannot be converted to Employee
      Business.getSalary(new Advertiser());
  }
}

这是一段 Java 代码来演示名义子类型的特性。Employee Programmer Advertiser 都包含一个 base 字段,Business.getSalary 方法指定了接受一个 Employee 类型参数,并返回他的 base 字段。

因为名义子类型的要求,即使 Advertiser 的结构和 Employee 一模一样,看起来 getSalary 方法也可以正常运行,也不允许输入。

结构子类型 Structural Type System

结构子类型意味着,A 类型只要具有 B 类型的全部相同结构,就可以认为 A 是 B 的子类型,而不用显式说明子类型关系。典型采用结构子类型的语言有 TypeScript 和 Scala。

// TS Compiler: https://www.typescriptlang.org/play?ts=4.8.4
class Employee{
    public base=4000;
}

class Programmer extends Employee{
    public base=5000;
}

class Advertiser {
    public base=6000;
    public bonus=1000;
}

function getSalary(who: Employee): number{
    return who.base;
}

getSalary(new Employee()) // 4000
getSalary(new Programmer()) // 5000
getSalary(new Advertiser()) // 6000

这是一段用 TypeScript 模仿上述 Java 示例写的代码。Employee Programmer Advertiser 都包含一个 base 字段,getSalary 方法指定了接受一个 Employee 类型参数。

和名义类型系统的差别是 getSalary(new Advertiser()) 可以正常运行,因为 Advertiser 包含全部 Employee 的相同结构,而不用显式声明 Advertiser 和 Employee 的关系。

名义 vs 结构

实际上,当在名义子类型语言中,声明为父子类型的类型也要求有相同的结构。所以可以认为名义子类型比结构子类型的推导更严格,是结构子类型推导的一个子集。

结构子类型可以表达为:

A is a subtype of B
  when A is structurally identical to B

名义子类型就表达为:

A is a subtype of B
  when A is structurally identical to B
      and A is declared to be a subtype of B

一般来说,使用结构子类型可以使类型系统更灵活;反之,名义子类型的使得类型检查更严格。具体差别还是要看不同语言的实现细节。

其他特殊类型和用法

除了顶层类型和 any 类型之外,还有其他的特殊类型。

底层类型

当一个类型是其他所有可能的类型的子类型,则称之为底层类型。换句话说,底层类型就是在声明使用任何类型的地方,都可以安全地传入的类型

在 TypeScript 这种结构子类型系统的语言中,一个类型如果要是所有类型的子类型,那么就必须包含所有类型的结构。不可能创建出来一个变量满足这种要求,所以底层类型只有一个: never

declare let aNever: never; // 由于不可能创建一个 Never 变量,所以这里使用了 declare
let aNumber: number=1;

aNumber=aNever; // OK, never 是底层类型,所以是 number 的子类型
aNever=aNumber; // Error: number 不是 never 的子类型

单元类型

单元类型:只有一个值的类型。对于这种类型的变量,检查其值是没有意义的,它只能是那一个值。

对于 TypeScript (严格模式)来说,单元类型有三个:void null undefined。

当函数的结果没有意义时,我们会使用单元类型,一般来说我们都会用 void。为什么不用 null 和 undefined?因为 TypeScript 语言层面上限制 void 值只能从不返回的函数中产生,可以用来确保函数没有任何返回语句。

const log(message:string): void{
  console.log(message);
}

自己实现一个单元类型比较简单,就是写一个单例模式:

declare const unitSymbol: unique symbol;

class Unit {
 	[unitSymbol]: unknown;// 模拟名义子类型

  static readonly unit: Unit=new Unit();  // 唯一单例

  private constructor(){} // 私有化构造器保证没有其他 instance
}

function getUnit(): Unit {
    return Unit.unit; // 只能返回唯一的单例 Unit.unit
}

getUnit()

空类型

空类型:没有值的类型。

对于 TypeScript 来说,空类型只有一个:never。

一般我们只在函数不返回的情况下使用空类型作为返回值,比如抛出错误:

function raise(message:string): never{
  throw new Error(message);
}

另外还有无限循环函数也可以返回空类型(一般在图形学程序中比较多):

function mainLoop(): never {
    while(true) {
        /** ... */
    }
}

当你写单例模式不要单例,就产生了一个空类型。但自制空类型一般没有什么意义,一个编程语言中也往往只要一个空类型,为了好读还是用 never 比较合适。为了演示,自制空类型代码如下:

declare const unitSymbol: unique symbol;

class Void {
 	[unitSymbol]: unknown;// 模拟名义子类型

  private constructor(){} // 私有化构造器保证没有 instance
}

function raise(message:string): Void { // 不返回的函数可以返回自制的空类型
  throw new Error(message);
}

类型组合复杂度

大部分语言类型组合按复杂度一般有两种:

  • 和类型 Sum Type
    代数上可以表达为 AB=A + B。即 AB 的复杂度是 A 的复杂度和 B 的复杂度之和。在 TypeScript 中,和类型就是联合类型:
type A='A1' | 'A2' | 'A3';
type B='B1' | 'B2';

type AB=A | B; // AB 可能值有 5 个=type A 3 个 + type B 2 个
  • 积类型 Product Type
    代数上可以表达为 AB=A * B。即 AB 的复杂度是 A 的复杂度和 B 的复杂度之乘积。在 TypeScript,积类型包括元祖、对象等等。
type A='A1' | 'A2' | 'A3';
type B='B1' | 'B2';

type ABTuple=[A, B]; // 可能值有 6 个=type A 3 个 * type B 2 个
type ABObject={ a: A, b: B }; // 可能值有 6 个=type A 3 个 * type B 2 个

还有一种类型组合比较罕见,一般只在结构子类型系统中存在:

  • 交叉类型 Intersection Type
    交叉类型并没有增加类型复杂度,而是根据两个输入类型 A B 的结构创建一个类型 C,其中 C 既是 A 的子类型,也是 B 的子类型。TypeScript 中交叉类型实现是 '&' 类型。
type A={ a: boolean }
type B={ b: number }

type C=A & B; // C 既是 A 的子类型,也是 B 的子类型

参考资料

[1] 柯里-霍华德对应: https://zh.wikipedia.org/wiki/%E6%9F%AF%E9%87%8C-%E9%9C%8D%E5%8D%8E%E5%BE%B7%E5%90%8C%E6%9E%84

[2] JSON.parse(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse

[3] Reflect.get(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/get

[4] Response.json(): https://developer.mozilla.org/en-US/docs/Web/API/Response/json

[5] TypeScript Playground: https://www.typescriptlang.org/play?ts=4.8.4

[6] 《重构,改善既有代码的设计》: https://weread.qq.com/web/bookDetail/2ed32e60811e3a304g014c02

[7] Optional Chain: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html

[8] Nominal And Structural Typing: https://www.eclipse.org/n4js/features/nominal-and-structural-typing.html#_nominal_and_structural_typing

[9] product / sum / union / intersection types: https://www.jianshu.com/p/72c89e660559

[10] 《编程与类型系统》: https://weread.qq.com/web/bookDetail/d9532b107221fcb0d95a94b


关注「字节前端 ByteFE」公众号,追更不迷路!