rescript 學習筆記

rescript 介紹

rescript 跟 typescript 類似,也是一門 js 方言。在現在 typescript 大流行的背景下,爲什麼會寫這篇文檔去介紹 rescript 呢?

最初接觸 rescript 的原因是驚訝於作者居然是國人大牛,感到十分欽佩。初步探索時發現這門語言本身有着很多亮眼的地方,比如更健壯的類型系統、更純粹的函數式編程支持、強大的語言特性、原生語言編寫的性能極致的編譯器等等,當然也有着相應的劣勢。本文會着重介紹 rescript 強大的特性,周邊的生態以及和我們日常使用最緊密的 react 的結合。

語言特性

rescript 本身的語法不像 ts 是 js 的超集,和 js 是很不一樣的,瑣碎的語法就不詳細介紹了,主要列舉一些比較典型的特性來介紹。

Type Sound

type sound 的含義引用維基百科的一句介紹:

If a type system is sound, then expressions accepted by that type system must evaluate to a value of the appropriate type (rather than produce a value of some other, unrelated type or crash with a type error).

簡單理解就是編譯通過的類型系統在運行時不會產生類型錯誤,ts 就不是 type sound,原因可以看下面這個例子:

// typescript
// 這是一段合法的ts代碼
type T = {
  x: number;
};

type U = {
  x: number | string;
};

const a: T = {
  x: 3
};

const b: U = a;

b.x = "i am a string now";

const x: number = a.x;

// error: x is string
a.x.toFixed(0);

在 rescript,你是不會寫出類型編譯通過卻在運行時產生類型錯誤的代碼。在上面的例子中,ts 能編譯通過因爲 ts 是 structural type[1],而 rescript 是 nominal type[2],在const b: U = a; 這段代碼就會編譯不過,當然僅靠這一點是無法保證 type sound,具體的證明過程比較學術,這裏就不展開了。

type sound 的意義在於能更好保證工程的安全性,就像大型工程裏 ts 對比 js 的優勢一樣,當程序規模越來越大的時候,使用的語言是 type sound 的話,那你就可以進行毫無畏懼的重構(fearless refactoring),不必擔心重構後出現運行時的類型錯誤。

Immutable

可變性往往會導致數據的變更難以追蹤預測以致產生 bug,不可變性是提升代碼質量,減少 bug 的有效手段,js 作爲一門動態語言本身對不可變性的支持幾乎沒有,tc39 也有相關的提案 Record & Tuple[3],目前在 stage2,rescript 裏面已經內置了 record & tuple 這兩種數據類型。

Record

rescript 的 record 與 js 的對象區別主要有以下幾點:

  1. 默認不可變

  2. 聲明 record 必須有對應的類型

// rescript
type person = {
  age: int,
  name: string,
}

let me: person = {
  age: 5,
  name: "Big ReScript"
}

// 更新age字段
let meNextYear = {...me, age: me.age + 1}

rescript 對於 record 具體字段的可變更新也提供了逃生艙:

// rescript
type person = {
  name: string,
  mutable age: int
}

let baby = {name: "Baby ReScript", age: 5}

// 更新age字段
baby.age = baby.age + 1

Tuple

ts 也有 tuple 數據類型,rescript 的 tuple 唯一的區別就是默認不可變的。

let ageAndName: (int, string) = (24, "Lil' ReScript")
// a tuple type alias
type coord3d = (float, float, float)
let my3dCoordinates: coord3d = (20.0, 30.5, 100.0)

// 更新tuple
let coordinates1 = (10, 20, 30)
let (c1x, _, _) = coordinates1
let coordinates2 = (c1x + 50, 20, 30)

Variant

variant(中文翻譯:變體)是 rescript 一個比較特殊的數據結構,涵蓋了絕大多數數據建模的場景,比如枚舉、構造函數(rescript 沒有 class 的概念)等。

// rescript

// 定義枚舉
type animal = Dog | Cat | Bird

// 構造函數,可以傳任意數量參數或者直接傳record
type account = Wechat(int, string) | Twitter({name: string, age: int})

variant 結合 rescript 的其它特性可以做到強大且優雅的邏輯表達能力,比如接下來要講的模式匹配。

Pattern Matching

模式匹配算是編程語言很好用的特性之一,配合上 ADT(代數數據類型)表達能力相比傳統的 if & switch 優秀很多,不僅可以判斷值,模式匹配也可以判斷具體的類型結構。js 也有相關的提案 [4],但還只是在 stage1,離真正可用還是遙遙無期。介紹這個強大的特性前,先看一個 ts 的 discriminated union 的例子:

// typescript
// tagged union
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; x: number }
  | { kind: "triangle"; x: number; y: number };

function area(s: Shape) {
  switch (s.kind) {
    case "circle":
      return Math.PI * s.radius * s.radius;
    case "square":
      return s.x * s.x;
    default:
      return (s.x * s.y) / 2;
  }
}

在 ts 裏,我們想要區分一個 union 類型的具體類型的時候,需要通過手動打 kind 字符串 tag 來區分,這種形式相對來說是有點繁瑣的,接下來看下 rescript 怎麼處理這種形式:

// rescript
type shape =
  | Circle({radius: float})
  | Square({x: float})
  | Triangle({x: float, y: float})

let area = (s: shape) ={
  switch s {
    // rescript的浮點數的算術操作符要加.  例如+. -. *.
    | Circle({radius}) => Js.Math._PI *. radius *. radius
    | Square({x}) => x *. x
    | Triangle({x, y}) => x *. y /. 2.0
  }
}

let a = area(Circle({radius: 3.0}))

配合上 variant 構造一個 sum type,再利用模式匹配去匹配具體的類型並把屬性解構出來,並不需要自己手動打 tag,寫法和體驗都優雅很多。編譯後的 js 代碼其實也是通過 tag 區分的,但我們通過 rescript 享受到了 ADT 和 pattern match 帶來的好處。

// 編譯後的js代碼
function area(s) {
  switch (s.TAG | 0) {
    case /* Circle */0 :
        var radius = s.radius;
        return Math.PI * radius * radius;
    case /* Square */1 :
        var x = s.x;
        return x * x;
    case /* Triangle */2 :
        return s.x * s.y / 2.0;

  }
}

var a = area({
      TAG: /* Circle */0,
      radius: 3.0
    });

NPE

對於 NPE 問題,ts 現在通過 strictNullCheck 和可選鏈可以有效地解決。rescript 則默認沒有 null 和 undefined 類型,對於數據可能爲空的情況,rescript 使用內置 option 類型和模式匹配來解決,類似 Rust,先看一下 rescript 內置的 option 類型定義:

// rescript
// 'a表示泛型
type option<'a> = None | Some('a)

使用模式匹配:

// rescript
let licenseNumber = Some(5)

switch licenseNumber {
| None =>
  Js.log("The person doesn't have a car")
| Some(number) =>
  Js.log("The person's license number is " ++ Js.Int.toString(number))
}

Labeled Arguments

labeled arguments 其實就是 named parameters[5],js 語言本身不支持這個特性,通常在函數參數很多的時候,我們會通過對象解構來實現乞丐版的 named parameters。

// typescript
const func = ({
    a,
    b,
    c,
    d,
    e,
    f,
    g
})=>{

}

這種方式有個不友好的地方就是要專門爲對象寫一個單獨的類型聲明,比較繁瑣,接下來看下 rescript 的語法是怎麼樣的:

// rescript

let sub = (~first: int, ~second: int) => first-second
sub(~second=2, ~first=5) // 3

// alias
let sub = (~first as x: int, ~second as y: int) => x-y

Pipe

js 裏也已經有 pipe operator[6] 的提案了,目前在 stage2。管道運算符能相對優雅地解決函數嵌套調用的情況,避免validateAge(getAge(parseData(person)))類似的代碼,rescript 的 pipe 默認是 pipe first,即 pipe 下一個函數的第一個參數。

// rescript

let add = (x,y) => x+y
let sub = (x,y) => x-y
let mul = (x,y) => x*y

// (6-2)*3=12
let num1 = mul(sub(add(1,5),2),3)
let num2 = add(1,5)
            ->sub(2)
            ->mul(3)

通常在 js 裏面會用鏈式調用來優化函數嵌套的情況,如下所示:

// typescript

let array = [1,2,3]
let num = array.map(item => item + 2).reduce((acc,cur) => acc+cur, 0)

值得一提的是 rescript 是沒有 class 的,不存在類方法一說,也就不會有鏈式調用,rescript 很多內置標準庫(比如 array 的 map,reduce)的設計通過採用這種 data first 的設計和管道運算符來實現之前 js 比較熟悉的鏈式調用。

// rescript

// rescript標準庫使用map和reduce示例
Belt.Array.map([1, 2](x) => x + 2) == [3, 4]
Belt.Array.reduce([2, 3, 4], 1, (acc, value) => acc + value) == 10

let array = [1,2,3]
let num = array
            -> Belt.Array.map(x => x + 2)
            -> Belt.Array.reduce(0, (acc, value) => acc + value)

Decorator

rescript 的 decorator 不是 ts 這種給 class 使用用於元編程的,而是有一些別的用途,比如用於一些編譯特性,跟 js 互操作。在 rescript 裏面,引入一個模塊並定義其類型可以進行如下操作:

// rescript

// 引用path模塊的dirname方法,聲明類型爲string => string
@module("path") external dirname: string =string = "dirname"
let root = dirname("/User/github") // returns "User"

Extension Point

和 decorator 類似,也是用於擴展 js 用,只是語法有點不一樣,舉個例子,我們在前端開發時通常會 import css,構建工具會做相應的處理,但是 rescript 的模塊系統是沒有 import 這種語句的,也不支持引入 css,這個時候通常會使用 %raw。

// rescript
%raw(`import "index.css";`)

// 編譯後的js的輸出內容
import "index.css";

react 開發

jsx

rescript 也支持 jsx 語法,只是在 props 賦值上有點差異:

// rescript

<MyComponent isLoading text onClick />
// 等價於
<MyComponent isLoading={isLoading} text={text} onClick={onClick} />

@rescript/react

@rescript/react 庫主要提供了 react 的 rescript binding,包括 react、react-dom。

// rescript

// 定義react組件
module Friend = {
  @react.component
  let make = (~name: string, ~children) ={
    <div>
      {React.string(name)}
      children
    </div>
  }
}

rescript 定義 react 組件提供了 @react.component 這個 decorator,make 就是組件具體實現,使用 label arguments 來獲取 props 屬性,jsx 裏可直接使用 Friend 組件。

// rescript

<Friend  age=20 />

// 去除jsx語法糖後的rescript
React.createElement(Friend.make, {name: "Fred", age:20})

這裏咋一看 make 有點多餘,不過這是因爲一些設計的歷史原因導致的,這裏就不過多介紹了。

生態

融入 js 生態

一個 js 方言想要成功的一大因素是如何融合現有的 js 生態,ts 如此火爆的原因之一便是很容易複用已有的 js 庫,只需要寫好 d.ts,一個 ts 項目便可以很順暢地導入使用。這點其實 rescript 也是類似的,只需要給 js 庫聲明相關的 rescript 類型就可,以 @rescript/react 作爲例子,這個庫提供了 react 的 rescript 類型聲明,看下如何給 react 的 createElement 聲明類型:

// rescript

// ReactDOM.res
@module("react-dom")
external render: (React.element, Dom.element) =unit = "render"
// 將render函數綁定到react-dom這個庫中
// rescript的模塊系統每個文件是一個模塊,模塊名就是文件名,不需要導入,因此可以直接使用ReactDOM.render

let rootQuery = ReactDOM.querySelector("#root")
switch rootQuery {
  | Some(root) => ReactDOM.render(<App />, root)
  | None =()
}

融入 ts 生態

rescript 不僅考慮瞭如何融入 js 生態,還提供了工具將 rescript 代碼導出給 ts 代碼使用,這個工具便是 @genType,例如將一個 rescript react 組件導出對應的 tsx 文件。

/* src/MyComp.res */

@genType
type color =
  | Red
  | Blue;

@genType
@react.component
let make = (~name: string, ~color: color) ={
  let colorStr =
    switch (color) {
    | Red ="red"
    | Blue ="blue"
    };

  <div className={"color-" ++ colorStr}{React.string(name)} </div>;
};

genType 生成 tsx 文件如下:

// src/MyComp.gen.tsx

/* TypeScript file generated from MyComp.res by genType. */
/* eslint-disable import/first */


import * as React from 'react';

const $$toRE818596289: { [key: string]: any } = {"Red": 0, "Blue": 1};

// tslint:disable-next-line:no-var-requires
const MyCompBS = require('./MyComp.bs');

// tslint:disable-next-line:interface-over-type-literal
export type color = "Red" | "Blue";

// tslint:disable-next-line:interface-over-type-literal
export type Props = { readonly color: color; readonly name: string };

export const make: React.ComponentType<{ readonly color: color; readonly name: string }= function MyComp(Arg1: any) {
  const $props = {color:$$toRE818596289[Arg1.color], name:Arg1.name};
  const result = React.createElement(MyCompBS.make, $props);
  return result
};

可以看到,對於 color 類型的 variant Red 和 Blue,rescript 直接映射成了 ts 的字符串字面量類型,但 rescript 編譯的 js 實現實際上還是 0,1 這種數字枚舉,所以 rescript 自動加了 $$toRE818596289 映射,ts 調用時傳入對應的字符串字面量即可。

// src/App.ts
import { make as MyComp } from "./MyComp.gen.tsx";

const App = () ={
  return (<div>
    <h1> My Component </h1>
    <MyComp color="Blue"  />
  </div>);
};

強大的編譯器

ts 的編譯器因爲是用 nodejs 寫的,編譯速度一直被人詬病,因此有了 esbuild 和 swc 這種只做類型擦除的 ts 編譯器,但還是無法滿足 type check 的需要,因此 stc[7] 項目(TypeScript type checker written in Rust)也是備受矚目。rescript 則在這個問題上沒有諸多煩惱,rescript 的編譯器是使用原生語言 OCaml 實現的,編譯速度是不會成爲 rescript 項目需要擔心和解決的問題,除此之外,rescript 的編譯器還有着諸多特性,由於這方面沒有詳盡的文檔介紹,這裏只列幾個自己略有了解的特性。

constant folding

常量摺疊即把常量表達式的值求出來作爲常量嵌在最終生成的代碼中,rescript 中常見的常量表達式,簡單的函數調用都可以進行常量摺疊。

// rescript

let add = (x,y) => x + y
let num = add(5,3)

// 編譯後的js
function add(x, y) {
  return x + y | 0;
}

var num = 8;

同樣的代碼,ts 的編譯結果如下:

// typescript
let add = (x:number,y:number)=>x+y
let num = add(5,3)

// 編譯後的js
"use strict";
let add = (x, y) => x + y;
let num = add(5, 3);

Type inference

類型推斷 ts 也有,但 rescript 的更加強大,可以做到基於上下文的類型推斷,大多數時候,rescript 的代碼編寫幾乎不需要爲變量聲明類型。

// rescript

// 斐波那契數列,rec用於聲明遞歸函數
let rec fib = (n) ={
  switch n {
    | 0 =0
    | 1 =1
    | _ => fib(n-1) + fib(n-2)
  }
}

在上面用 rescript 實現的斐波那契數列函數中並沒有任何的變量聲明,但 rescript 可以根據模式匹配的上下文中推斷出 n 是 int 類型,相同的例子下 ts 就必須爲 n 聲明 number 類型。

// typescript

// Parameter 'n' implicitly has an 'any' type.
let fib = (n) ={
  switch (n) {
    case 0:
      return 0;
    case 1:
      return 1;
    default:
      return fib(n-1) + fib(n-2)
  }
}

類型佈局優化

類型佈局優化的作用之一是可以優化代碼 size。舉個例子,聲明一個對象相比聲明一個數組,代碼量是要更多的。

let a = {width: 100, height: 200}

let b = [100,200]

// uglify後

let a={a:100,b:100}
let b=[100,200]

在上面的例子中,對象聲明的可讀性是數組無法替代的,我們在日常使用中也不會爲了這種優化去捨棄代碼的可維護性。在 rescript,通過上文提到的 decorator,我們就可以做到編寫代碼的時候保持可讀性,編譯後的 js 也能優化代碼量。

// rescript

type node = {@as("0") width : int , @as("1") height : int}
let a: node = {width: 100,height: 200}

// 編譯後的js

var a = [
  100,
  200
];

不足

上文基本講的都是 rescript 的優勢,但 rescript 沒有 ts 這麼流行,原因自然是其自身也有着一定的不足,下面簡單列舉一些個人總結的點:

總結

本文簡單地介紹了下 rescript,並列舉了 rescript 的優劣。ts 從 2012 年項目啓動,到現在的統治地位,經歷了 10 年的時間。對於 rescript 的未來,“讓我們拭目以待”!

關於我們

我們是字節跳動的 tiktok 音樂研發前端團隊,涉獵跨端、中後臺、桌面端等主流前端技術領域,是一個技術氛圍非常濃厚的前端團隊。

參考資料

[1]

structural type: https://en.wikipedia.org/wiki/Structural_type_system

[2]

nominal type: https://en.wikipedia.org/wiki/Nominal_type_system

[3]

Record & Tuple: https://github.com/tc39/proposal-record-tuple#equality

[4]

提案: https://github.com/tc39/proposal-pattern-matching

[5]

named parameters: https://en.wikipedia.org/wiki/Named_parameter

[6]

pipe operator: https://github.com/tc39/proposal-pipeline-operator

[7]

stc: https://github.com/dudykr/stc

[8]

binding: https://github.com/tinymce/rescript-webapi

[9]

rescript 官網: https://rescript-lang.org/

[10]

rescript 論壇: https://forum.rescript-lang.org/

[11]

作者知乎: https://www.zhihu.com/people/hongbo_zhang

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/PjinXt2RsUYItVUwrlxNhQ