如果 ReasonML 沒有 JavaScript 的那些怪癖,你該不該試試它?

TypeScript 和 ReasonML 都聲稱自己爲 Web 開發人員提供了可編譯爲 JavaScript 的靜態類型語言,那麼它們之間的差別是什麼?ReasonML 能帶來 TypeScript 想要做到的一切(甚至更多),但是前者沒有那些 JavaScript 怪癖。這樣的話,你是否應該試試它呢?

TypeScript 是 JavaScript 的超集,這既是它的最佳特性,也是它最大的缺陷。雖說與 JavaScript 的相似性給了人熟悉的感覺,但這意味着我們所喜愛和反感的所有 JavaScript 怪癖都在 TypeScript 裏重現了。TS 只不過是在 JavaScript 之上添加了類型,然後就差不多完事了。

ReasonML 提供的是一種完全不同但讓人感覺很熟悉的語言。這是否意味着JavaScript /TypeScript 開發人員會很難學習這個新語言?我們就來看看吧。

聲明一個變量

讓我們從變量聲明開始:

ReasonML
let a = "Hi";
TypeScript
const a = "Hi"

在 ReasonML 中,我們使用 let 關鍵字聲明一個變量。沒有 const,默認情況下 let 是不可變的。

在這種情況下,兩種語言都可以推斷出 a 的類型。

函數

TypeScript

let sum = (a: number, b: number) => a + b

ReasonML

let sum = (a,b) => a + b;

儘管我沒有手動編寫任何類型,但這個函數的參數還是類型化了。爲什麼我們用不着在 ReasonML 中編寫類型呢?因爲強大的類型系統可以進行出色的類型推斷。這意味着編譯器可以在不需要你幫助的情況下推斷出類型。ReasonML 中的 (+) 運算符僅適用於整數——a 和 b 只能是這種類型,因此我們不必編寫它們。但如果需要,你隨時都可以編寫類型:

ReasonML

let sum = (a: int, b: int) => a + b;

接口,記錄

TypeScript
interface Product {
  name: string
  id: number
}

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

ReasonML

type product = {
  name: string,
  id: int,
};

記錄就像 TypeScript 對象一樣,但前者是不可變的,固定的,並且類型更嚴格。下面我們在某些函數中使用定義的結構:

ReasonML

let formatName = product => "Name: "++product.name;

TypeScript

const formatName = (product: Product) => "Name: " + product.name

同樣,我們不需要註釋類型!在這個函數中我們有一個參數 product,其屬性 name 爲字符串類型。ReasonML 編譯器可以根據使用情況猜測該變量的類型。因爲只有 product 這個類型具有字符串類型的 name 屬性,編譯器會自動推斷出它的類型。

更新記錄

let updateName = (product, name) => { ...product, name };
const updateName = (product: Product, name: string) => ({ ...product, name })

ReasonML 支持展開運算符,並像 TypeScript 一樣對名稱和值做類型雙關。

看看 ReasonML 生成了什麼樣的 JavaScript 吧,這也很有趣:

function updateName(product, name) {
  return [name, product[1]]
}

ReasonML 中的記錄表示爲數組。如果人類可以像編譯器一樣記住每種類型的每個屬性的索引,則生成的代碼看起來就會像人類編寫的那樣。

Reducer 示例

我認爲這就是 ReasonML 真正閃耀的地方。我們來比較一下相同的 Reducer 實現:

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

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 中也是一樣:

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)類型:

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

這意味着具有類型 action 的任何變量都可以具有以下值之一:Reset、帶有一些字符串值的 AddMovie 和帶有一些字符串值的 RemoveMovie。

ReasonML 中的 Variant 是一項非常強大的功能,可讓我們以非常簡潔的方式定義可以包含值 A 或 B 的類型。是的,TypeScript 有聯合類型,但它沒有深入集成到語言中,因爲 TypeScript 的類型是給 JavaScript 打的補丁;而 Variant 是 ReasonML 語言的重要組成部分,並且與模式匹配等其他語言功能緊密相連。

說到模式匹配,我們來看一下 Reducer。

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

我們在這裏看到的是一個函數,該函數接受狀態作爲第一個參數,然後將第二個參數與可能的值匹配。

我們也可以這樣寫這個函數:

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 中,第一步是完全相同的,但是後面就都不一樣了。在將新動作添加到類型定義後單擊保存時,編譯器會帶你在代碼庫中慢慢挪動,處理對應的情況。

你會看到這樣的警告:

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

type option('a) =
  | Some('a)
  | None;

這是一個大家很熟悉的 Variant 類型。但是它也有一個類型參數’a。這很像其他語言中的泛型,比如說 Option。

來對比更多的代碼看看:

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 的值來糾正它。

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

function makeShadow(x: number, y: number, spread: number, color: string) {
  return 0
}
const shadow = makeShadow(10, 10, 5, "black") /* meh */

在 ReasonML 中,你在參數名稱前加一個~ 字符,然後它就被標記了。

ReasonML

let makeShadow = (~x: int, ~y: int, ~spread: int, ~color: string) => {
  0;
}

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

是的,你可以在 TypeScript 中嘗試使用對象作爲參數來模擬這種做法,但是隨後你需要在每個函數調用時分配一個對象 :/

TypeScript

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

export const test = "Hello"
import { test } from "./Hello.ts"

console.log(test)

在 ReasonML 中,每個文件都是一個帶有該文件名的模塊。

Hello.re

let test = "Hello";
Js.log(Hello.test);

你可以 open 模塊,使內容可用而無需模塊名稱前綴:

open Hello;

Js.log(test);

編譯速度

爲了對比編譯速度,我們來編譯 TodoMVC,因爲其實現和項目大小都是容易對比的。我們正在測試的是將代碼轉換爲 JavaScript 所花費的時間。沒有打包,壓縮等操作。[注 1]

TypeScript + React.js

$ time tsc -p js
tsc -p js 6.18s user 0.24s system 115% cpu 5.572 total

6.18 秒

ReasonML + ReasonReact

$ 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/

$ 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/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章