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 的对象区别主要有以下几点:
默认不可变 声明 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 name="Fred" 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" name="ReScript & TypeScript" />
</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 写 binding 就可以将 js 生态融入到 rescript 中,但现状就是为 rescript 写 binding 的 js 库极少,相比为 ts 写 dts 的 js 库来说,原因不止有 rescript 用户少,为 rescript 写 binding 的体验也远不如为 ts 写 dts,因为 ts 是 js 的超集,写 dts 对于 js 开发者是件很顺畅容易的事,由于 rescript 的体系与 js 不同的地方太多,想要写出高质量的 rescript binding 并不简单。 编辑器生态比较薄弱,官方只提供了 vscode/sublime test/vim/neovim 的插件支持,无疑对其它编辑器/ide 用户来说增加了上手门槛。个人体验了下 rescript 的 vscode 插件,直观上来说体验不如 vscode 的 ts 插件那么优秀,比如智能提示有点慢,偶尔甚至没有智能提示,搜了下是说 windows 上体验目前有点问题,mac 上是好的。 官方对很多 js runtime api 的支持太弱,比如官方居然不支持 dom api 的 binding,只有类型,找了一圈才找到社区对于 web api 的 binding[8],相比 ts 官方一直保持维护对 dom,nodejs 等 api 的类型声明,虽然可能是因为人力问题可以理解,但这点无疑是比较劝退的。
总结
本文简单地介绍了下 rescript,并列举了 rescript 的优劣。ts 从 2012 年项目启动,到现在的统治地位,经历了 10 年的时间。对于 rescript 的未来,“让我们拭目以待”!
关于我们
我们是字节跳动的 tiktok 音乐研发前端团队,涉猎跨端、中后台、桌面端等主流前端技术领域,是一个技术氛围非常浓厚的前端团队。
点击左下方“阅读原文”,欢迎加入我们!
点击上方关注 · 我们下期再见
参考资料
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