首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

如果 ReasonML 没有 JavaScript 的那些怪癖,你该不该试试它?

TypeScript 和 ReasonML 都声称自己为 Web 开发人员提供了可编译为 JavaScript 的静态类型语言,那么它们之间的差别是什么?ReasonML 能带来 TypeScript 想要做到的一切(甚至更多),但是前者没有那些 JavaScript 怪癖。这样的话,你是否应该试试它呢?

TypeScript 是 JavaScript 的超集,这既是它的最佳特性,也是它最大的缺陷。虽说与 JavaScript 的相似性给了人熟悉的感觉,但这意味着我们所喜爱和反感的所有 JavaScript 怪癖都在 TypeScript 里重现了。TS 只不过是在 JavaScript 之上添加了类型,然后就差不多完事了。

ReasonML 提供的是一种完全不同但让人感觉很熟悉的语言。这是否意味着JavaScript /TypeScript 开发人员会很难学习这个新语言?我们就来看看吧。

声明一个变量

让我们从变量声明开始:

代码语言:javascript
复制
ReasonML
let a = "Hi";
TypeScript
const a = "Hi"

在 ReasonML 中,我们使用 let 关键字声明一个变量。没有 const,默认情况下 let 是不可变的。

在这种情况下,两种语言都可以推断出 a 的类型。

函数

TypeScript

代码语言:javascript
复制
let sum = (a: number, b: number) => a + b

ReasonML

代码语言:javascript
复制
let sum = (a,b) => a + b;

尽管我没有手动编写任何类型,但这个函数的参数还是类型化了。为什么我们用不着在 ReasonML 中编写类型呢?因为强大的类型系统可以进行出色的类型推断。这意味着编译器可以在不需要你帮助的情况下推断出类型。ReasonML 中的 (+) 运算符仅适用于整数——a 和 b 只能是这种类型,因此我们不必编写它们。但如果需要,你随时都可以编写类型:

ReasonML

代码语言:javascript
复制
let sum = (a: int, b: int) => a + b;

接口,记录

代码语言:javascript
复制
TypeScript
interface Product {
  name: string
  id: number
}

ReasonML 中最接近接口(Interface)的是记录(Record,https://reasonml.github.io/docs/en/record)。

ReasonML

代码语言:javascript
复制
type product = {
  name: string,
  id: int,
};

记录就像 TypeScript 对象一样,但前者是不可变的,固定的,并且类型更严格。下面我们在某些函数中使用定义的结构:

ReasonML

代码语言:javascript
复制
let formatName = product => "Name: "++product.name;

TypeScript

代码语言:javascript
复制
const formatName = (product: Product) => "Name: " + product.name

同样,我们不需要注释类型!在这个函数中我们有一个参数 product,其属性 name 为字符串类型。ReasonML 编译器可以根据使用情况猜测该变量的类型。因为只有 product 这个类型具有字符串类型的 name 属性,编译器会自动推断出它的类型。

更新记录

代码语言:javascript
复制
let updateName = (product, name) => { ...product, name };
代码语言:javascript
复制
const updateName = (product: Product, name: string) => ({ ...product, name })

ReasonML 支持展开运算符,并像 TypeScript 一样对名称和值做类型双关。

看看 ReasonML 生成了什么样的 JavaScript 吧,这也很有趣:

代码语言:javascript
复制
function updateName(product, name) {
  return [name, product[1]]
}

ReasonML 中的记录表示为数组。如果人类可以像编译器一样记住每种类型的每个属性的索引,则生成的代码看起来就会像人类编写的那样。

Reducer 示例

我认为这就是 ReasonML 真正闪耀的地方。我们来比较一下相同的 Reducer 实现:

在 TypeScript 中(遵循这份指南:https://redux.js.org/recipes/usage-with-typescript)

代码语言:javascript
复制
interface State {
  movies: string[]
}

const defaultState: State = {
  movies: [],
}

export const ADD_MOVIE = "ADD_MOVIE"
export const REMOVE_MOVIE = "REMOVE_MOVIE"
export const RESET = "RESET"

interface AddMovieAction {
  type: typeof ADD_MOVIE
  payload: string
}

interface RemoveMovieAction {
  type: typeof REMOVE_MOVIE
  payload: string
}

interface ResetAction {
  type: typeof RESET
}

type ActionTypes = AddMovieAction | RemoveMovieAction | ResetAction

export function addMovie(movie: string): ActionTypes {
  return {
    type: ADD_MOVIE,
    payload: movie,
  }
}

export function removeMovie(movie: string): ActionTypes {
  return {
    type: REMOVE_MOVIE,
    payload: movie,
  }
}

export function reset(): ActionTypes {
  return {
    type: RESET,
  }
}

const reducer = (state: State, action: Action) => {
  switch (action.type) {
    case ADD_MOVIE:
      return { movies: [movie, ...state.movies] }
    case REMOVE_MOVIE:
      return { movies: state.movie.filter(m => m !== movie) }
    case RESET:
      return defaultState
    default:
      return state
  }
}

没什么特别的,我们声明了状态界面、默认状态、动作、动作创建者以及最后的 Reducer。

在 ReasonML 中也是一样:

代码语言:javascript
复制
type state = {
  movies: list(string)
};

type action =
  | AddMovie(string)
  | RemoveMovie(string)
  | Reset

let defaultState = { movies: [] }

let reducer = (state) => fun
  | AddMovie(movie) => { movies: [movie, ...state.movies] }
  | RemoveMovie(movie) => { movies: state.movies |> List.filter(m => m !== movie) }
  | Reset => defaultState;

/* No need for additional functions! */
let someAction = AddMovie("The End of Evangelion")

对,就这些。

让我们看看这里发生了什么。

首先,有一个状态类型声明。

之后是动作 Variant(https://blog.dubenko.dev/typescript-vs-reason/#https://reasonml.github.io/docs/en/variant)类型:

代码语言:javascript
复制
type action =
  | AddMovie(string)
  | RemoveMovie(string)
  | Reset

这意味着具有类型 action 的任何变量都可以具有以下值之一:Reset、带有一些字符串值的 AddMovie 和带有一些字符串值的 RemoveMovie。

ReasonML 中的 Variant 是一项非常强大的功能,可让我们以非常简洁的方式定义可以包含值 A 或 B 的类型。是的,TypeScript 有联合类型,但它没有深入集成到语言中,因为 TypeScript 的类型是给 JavaScript 打的补丁;而 Variant 是 ReasonML 语言的重要组成部分,并且与模式匹配等其他语言功能紧密相连。

说到模式匹配,我们来看一下 Reducer。

代码语言:javascript
复制
let reducer = (state) => fun
  | AddMovie(movie) => { movies: [movie, ...state.movies] }
  | RemoveMovie(movie) => { movies: state.movies |> List.filter(m => m !== movie) }
  | Reset => defaultState;

我们在这里看到的是一个函数,该函数接受状态作为第一个参数,然后将第二个参数与可能的值匹配。

我们也可以这样写这个函数:

代码语言:javascript
复制
let reducer = (state, action) => {
  switch(action) {
  | AddMovie(movie) => { movies: [movie, ...state.movies] }
  | RemoveMovie(movie) => { movies: state.movies |> List.filter(m => m !== movie) }
  | Reset => defaultState;
  }
}

由于匹配参数是 ReasonML 中的常见模式,因此这类函数以较短的格式编写,如前面的代码片段中所示。{ movies: [movie, …state.movies] }这部分看起来与 TypeScript 中的一样,但是这里发生的事情并不相同!在 ReasonML 中,[1,2,3] 不是数组,而是不可变的列表。可以想象它是在语言本身内置的 Immutable.js。在这一部分中我们利用了一个事实,即 Append 操作在 ReasonML 列表中的时间是恒定的!如果你之前用的是 JavaScript 或 TypeScript,你可能会随手写下这种代码,无需太多顾虑,并且可以免费获得性能提升。

现在,让我们看看向 Reducer 添加新动作的操作。在 TypeScript 中是怎么做的呢?首先你要在类型定义中添加一个新动作,然后以动作创建者的形式编写一些样板,当然不要忘了在 Reducer 中实际处理这种情况,一般这一步都容易被忽略。

在 ReasonML 中,第一步是完全相同的,但是后面就都不一样了。在将新动作添加到类型定义后单击保存时,编译器会带你在代码库中慢慢挪动,处理对应的情况。

你会看到这样的警告:

代码语言:javascript
复制
Warning 8: this pattern-matching is not exhaustive.
Here is an example of a case that is not matched:
Sort

这是很好的开发体验。它会指出你要处理新动作的确切位置,并且还会准确告诉你缺少哪种情况。

Null、undefined vs Option

在 TypeScript 中我们需要承受 JavaScript 留下来的负担,也就是用 null 和 undefined 来表示几乎一模一样的事物——毫无意义。

在 ReasonML 中没有这种东西,只有 Option 类型。

ReasonML

代码语言:javascript
复制
type option('a) =
  | Some('a)
  | None;

这是一个大家很熟悉的 Variant 类型。但是它也有一个类型参数’a。这很像其他语言中的泛型,比如说 Option。

来对比更多的代码看看:

代码语言:javascript
复制
interface User {
  phone?: number
}

interface Form {
  user?: User
}

function getPhone(form: Form): number | undefined {
  if (form.user === undefined) {
    return undefined
  }
  if (form.user.phone === undefined) {
    return undefined
  }
  return form.user.phone
}

访问可以为空的属性是最简单的情况之一,闭着眼也能写出来。在 TypeScript 中,我们可以启用严格的 null 检查然后手动检查 undefined 的值来纠正它。

代码语言:javascript
复制
open Belt.Option;

type user = {
  phone: option(int)
};

type form = {
  user: option(user)
};

let getPhone = form =>
  form.user->flatMap(u => u.phone);

在 ReasonML 中,我们可以使用内置的 option 类型和 Belt 标准库中的辅助函数,然后就能以标准化方式处理可能为空的值。

带标签的参数

我觉得所有人都会认为带标签的参数功能真是太棒了。可能每个人都必须在某个时候查询函数参数的顺序或含义。不幸的是,TypeScript 中没有带标签的参数。

TypeScript

代码语言:javascript
复制
function makeShadow(x: number, y: number, spread: number, color: string) {
  return 0
}
const shadow = makeShadow(10, 10, 5, "black") /* meh */

在 ReasonML 中,你在参数名称前加一个~ 字符,然后它就被标记了。

ReasonML

代码语言:javascript
复制
let makeShadow = (~x: int, ~y: int, ~spread: int, ~color: string) => {
  0;
}

let shadow = makeShadow(~spread=5, ~x=10, ~y=10, ~color="black")

是的,你可以在 TypeScript 中尝试使用对象作为参数来模拟这种做法,但是随后你需要在每个函数调用时分配一个对象 :/

TypeScript

代码语言:javascript
复制
function makeShadow(args: {
  x: number
  y: number
  spread: number
  color: number
}) {
  return 0
}

const shadow = makeShadow({ x: 10, y: 10, spread: 5, color: "black" })

模块系统

在 TypeScript 中,我们显式导出和导入文件之间的所有内容。

Hello.ts

代码语言:javascript
复制
export const test = "Hello"
代码语言:javascript
复制
import { test } from "./Hello.ts"

console.log(test)

在 ReasonML 中,每个文件都是一个带有该文件名的模块。

Hello.re

代码语言:javascript
复制
let test = "Hello";
代码语言:javascript
复制
Js.log(Hello.test);

你可以 open 模块,使内容可用而无需模块名称前缀:

代码语言:javascript
复制
open Hello;

Js.log(test);

编译速度

为了对比编译速度,我们来编译 TodoMVC,因为其实现和项目大小都是容易对比的。我们正在测试的是将代码转换为 JavaScript 所花费的时间。没有打包,压缩等操作。[注 1]

TypeScript + React.js

代码语言:javascript
复制
$ time tsc -p js
tsc -p js 6.18s user 0.24s system 115% cpu 5.572 total

6.18 秒

ReasonML + ReasonReact

代码语言:javascript
复制
$ bsb -clean-world
$ time bsb -make-world
[18/18] Building src/ReactDOMRe.mlast.d
[9/9] Building src/ReactDOMRe.cmj
[6/6] Building src/Fetch.mlast.d
[3/3] Building src/bs_fetch.cmj
[12/12] Building src/Json_encode.mlast.d
[6/6] Building src/Json.cmj
[7/7] Building src/todomvc/App.mlast.d
[3/3] Building src/todomvc/App-ReasonReactExample.cmj
bsb -make-world 0.96s user 0.73s system 161% cpu 1.049 total

0.96 秒

现在这还包括编译 ReasonML 依赖项的时间,我们还可以测试只编译项目文件的时间:

ReasonML + ReasonReact, only src/

代码语言:javascript
复制
$ bsb -clean
$ time bsb -make-world
ninja: no work to do.
ninja: no work to do.
ninja: no work to do.
[7/7] Building src/todomvc/App.mlast.d
[3/3] Building src/todomvc/App-ReasonReactExample.cmj
bsb -make-world 0.33s user 0.27s system 117% cpu 0.512 total

0.33 秒

竟然有这么快!

BuckleScript 将安装时、构建时和运行时的性能视为一项重要功能

这是从 BuckleScript 文档中引用的。BuckleScript 是将 ReasonML 转换为 JavaScript 的工具。

目前TypeScript 胜过 ReasonML 的地方

关于 ReasonML 的内容并不是都那么美好。它是一种相当新的语言……嗯,实际上它不是基于 OCaml 的,后者已经相当老了;但重点在于互联网上的资源仍然不是特别多。与 ReasonML 相比,用谷歌搜索 TypeScript 的问题更容易获得答案。

DefinitelyTyped 类型实在太多了,ReasonML 想要追上来还有很长的路要走。

结语

对于前端开发人员来说,ReasonML 语法应该让人觉得很熟悉,这意味着学习曲线的起点并不那么陡峭(但仍然比 TypeScript 要陡)。ReasonML 充分利用了其他语言和工具(例如 Immutable.js 和 eslint)中最好的东西,并将其带入了语言级别。它并没有试着成为一种完全纯粹的编程语言,你需要的话可以随时退回到突变和命令式编程。它非常快,它的速度是提升开发体验的重点所在。ReasonML 能带来 TypeScript 想要做到的一切(甚至更多),但是前者没有那些 JavaScript 怪癖。你应该试试它!

注释

[1] 编译时间测试平台是一部 MacBook Pro 2015,处理器是 Intel Core i5-5287U @2.90GHZ

TypeScript + React.js 源码(https://github.com/tastejs/todomvc/tree/master/examples/typescript-react)

ReasonML + ReasonReact 源码(https://github.com/reasonml-community/reason-react-example/tree/master/src/todomvc)

原文链接:

https://blog.dubenko.dev/typescript-vs-reason/

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/vGuEjjgrFhslDxUVOq45
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券