nceIO是OnceDoc企業私有內容(網盤)管理系統的底層Web框架,它可以實現模板文件、靜態文件的全緩存,運行起來完全不需要I/O操作,并且支持客戶端緩存優化,GZIP壓縮等(只壓縮一次),擁有非常好的性能,為您節約服務器成本。它的模塊化功能,可以讓你的Web進行分布式存儲,在一個擴展包里即可包含前端、后端和數據庫定義,只需通過添加/刪除目錄的方式就可實現功能刪減,實現真正的模塊化擴展。目前OnceIO已經開源,這里是介紹如何使用的一系列文章。
客戶端緩存
緩存定義
這里討論的緩存是指 web 緩存:一個 web 資源(如 html 頁面、圖片、文件等)在服務器和客戶端(瀏覽器)之間的副本。緩存會根據進來的請求保存請求輸出的內容的副本;然后,如果下一個請求是相同的 URL,且網頁在這段時間內沒有更新,瀏覽器就不會再次下載網頁,而是直接使用本地緩存的網頁副本。
緩存的作用主要有:
節約帶寬。
減少延遲。
降低服務器壓力。
客戶端(瀏覽器)的緩存機制
所有的緩存都有一套規則來幫助它們決定什么情況下使用緩存中的副本,什么情況下向源服務器再次發送請求。這些規則有的在協議(如 HTTP 協議 1.0 和 1.1)中有定義,有的則是由緩存的管理員(如 DBA、瀏覽器的用戶、代理服務器管理員或者應用開發者)設置。
對于瀏覽器端的緩存,這些規則是在 HTTP 協議頭和 html 頁面的 meta 標簽中定義的。它們從新鮮度和校驗值兩個維度來決定瀏覽器是否可以直接使用緩存中的副本。
新鮮度(過期機制):也就是緩存副本有效期。一個緩存副本必須滿足以下條件,瀏覽器才會認為它是有效的:
含有完整的過期時間控制頭信息(HTTP協議報頭),并且仍在有效期內;
瀏覽器已經使用過這個緩存副本,并且在一個會話中已經檢查過新鮮度;
滿足以上兩個情況的一種,瀏覽器會直接從緩存中獲取副本并渲染。
校驗值(驗證機制):服務器返回資源的時候有時在控制頭信息帶上這個資源的實體標簽 ETag(Entity Tag),它可以用來作為瀏覽器再次請求過程的校驗標識。如過發現校驗標識不匹配,說明資源已經被修改或過期,瀏覽器需求重新獲取資源內容。
常用的與緩存有關的 HTTP 消息報頭
消息報頭 | 值 | 類型 | 作用 | 規則 |
---|---|---|---|---|
Status Code | 200 OK | 普通 | 表明服務器成功返回網頁 | 不適用 |
304 Not Modified | 普通 | 表明當前資源的內容(自上次訪問以來或根據請求的條件)沒有修改過,服務器不返回網頁內容 | 不適用 | |
Cache-Control | max-age=315360000 | 響應 | 指明緩沖副本的有效時長,單位為秒 | 新鮮度 |
Expires | Thu, 31 Dec 2037 23:55:55 GMT | 響應 | 告訴瀏覽器在過期時間前可以使用副本 | 新鮮度 |
Last-Modified | Sun, 23 Oct 2016 06:36:08 GMT | 響應 | 告訴瀏覽器當前資源的最近一次修改時間 | 新鮮度 |
If-Modified-Since | Sun, 23 Oct 2016 06:36:08 GMT | 請求 | 如果瀏覽器第一次請求時響應中 Last-Modified 非空,第二次請求同一資源時,會把它作為該項的值發給服務器 | 新鮮度 |
ETag | 978534 | 響應 | 告訴瀏覽器當前資源在服務器的唯一標識符(生成規則由服務器決定) | 校驗值 |
If-None-Match | 978534 | 請求 | 如果瀏覽器第一次請求時響應中 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-Controll | Last-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
每天?分享?最新?軟件?開發?,Devops,敏捷?,測試?以及?項目?管理?最新?,最熱門?的?文章?,每天?花?3分鐘?學習?何樂而不為?,希望?大家?點贊?,加?關注?,你的?支持?是我?最大?的?動力?。
最近,谷歌 Chrome 103發布了一系列新功能。其中一個值得注意的特點是引入了 HTTP狀態碼103。本文將通過一個快速演示深入研究 HTTP103狀態代碼。
從 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 基礎的同學。
眾所周知,any 是一個危險的類型,可以關閉所有類型檢查。 但是實際的瀏覽器程序中不可能完全避免 any 類型進入類型系統,對我們的類型推理產生影響。比如
對于 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,從而確保了類型安全。
要理解這個操作需要回答四個問題:
要回答這些問題,我們需要理解類型系統為什么把一些類型轉換當作安全的(可以隱式轉換),另一些類型轉換當作不安全的(需要用 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。
總結
類型編程最大的應用就是用來對代碼進行靜態檢查,減少潛在的 bug。
TypeScript 設置
對于 TS 來說,非常建議開啟兩個選項,新項目最好一開始就打開:
基本類型偏執
基本類型 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))
這里的問題是:
對于有具體意義的概念不愿意建模,而是用基本類型表示,這種問題稱為基本類型偏執。(出處:《重構,改善既有代碼的設計》[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),考慮這幾個問題:
在做這些證明之前,還是需要先明確子類型的定義:
子類型(subtype) :如果在期望類型 T 的實例的任何地方,都可以安全地使用類型 S 的實例,那么稱類型 S 是類型 T 的子類型。
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
協變性:如果一個類型保留其底層類型的子類型關系,就稱該類型具有協變性。
逆變性:如果一個類型顛倒了其底層類型的子類型關系,則稱該類型具有逆變性。
從數學角度理解類型
類型:類型是對數據做的一種分類,定義了能夠對數據執行的操作、數據的意義。編譯器和運行時會檢查類型,以確保數據的完整性,實施訪問限制,以及按照開發人員的意圖來解釋數據。
從數學上來看,類型就是一個集合。
函數代表從一個集合到另外一個集合的映射。比如此函數類型定義:
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);
}
類型組合復雜度
大部分語言類型組合按復雜度一般有兩種:
type A='A1' | 'A2' | 'A3';
type B='B1' | 'B2';
type AB=A | B; // AB 可能值有 5 個=type A 3 個 + type B 2 個
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 個
還有一種類型組合比較罕見,一般只在結構子類型系統中存在:
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」公眾號,追更不迷路!
*請認真填寫需求信息,我們會在24小時內與您取得聯系。