rescript学习笔记

717 阅读13分钟

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,而rescript是nominal type,在const b: U = a; 这段代码就会编译不过,当然仅靠这一点是无法保证type sound,具体的证明过程比较学术,这里就不展开了(我也不懂

type sound的意义在于能更好保证工程的安全性,就像大型工程里ts对比js的优势一样,当程序规模越来越大的时候,使用的语言是type sound的话,那你就可以进行毫无畏惧的重构(fearless refactoring),不必担心重构后出现运行时的类型错误

Immutable

可变性往往会导致数据的变更难以追踪预测以致产生bug,不可变性是提升代码质量,减少bug的有效手段,js作为一门动态语言本身对不可变性的支持几乎没有,tc39也有相关的提案Record&Tuple,目前在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也有相关的提案,但还只是在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,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的提案了,目前在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项目(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,只有类型(WTF???),找了一圈才找到社区对于web api的binding,相比ts官方一直保持维护对dom,nodejs等api的类型声明,虽然可能是因为人力问题可以理解,但这点无疑是比较劝退的

总结

本文简单地介绍了下rescript,并列举了rescript的优劣。ts从2012年项目启动,到现在的统治地位,其中花了10年。对于rescript的未来,“让我们拭目以待”

参考

  • 官网

rescript-lang.org/

  • 论坛

forum.rescript-lang.org/

  • 作者知乎

www.zhihu.com/people/hong…