/ react

Javascript 開發者的首支 Reason React App

Original tutorial: A First Reason React app for Javascript developers by James Friend. This is a translated version in Traditional Chinese.

This post has been updated to account for API changes in Reason React 0.2.1. A Traditional Chinese translation of this article is available here.

Reason 是個新的來自 Facebook 的靜態型別函式程式語言(statically-typed functional programming language),它也可以編譯成 Javascript。Reason ReactReact 封裝到 Reason,讓你更容易的在 Reason 中使用。

我們將使用 Reason React 來一步步地建置小型單頁 web 應用程式。程式顯示與 Reason 相關的 Github 儲存庫(repos)清單。這支小程式不但可以在幾個小時內完成,也有足夠的複雜度,足以嘗試這個新語言。本教學預期讀者沒有 Reason 背景,若有些靜態型別的基本認知,會比較有些幫助。

開始之前

確認你的編輯器已經對 Reason 做了配置。如果無法取得型別資訊,那就沒辦法完整感受到靜態型別語言的好處,例如,編輯器的內嵌錯誤訊息(inline errors)或自動補完(autocomplete)。若要快速設置編輯器,推薦給你 Reason 官網提及的 Atom 工具包,另外加上我的工具包 linter-refmt,讓 Atom 有更好的語法錯誤訊息(syntax error messages)。若沒有這個工具包,需要藉由編譯器的 console 輸出來對錯誤語法進行除錯。

如果尚未配置過,也可能需要安裝 Reason CLI 工具。

** 本教學需要最新釋出的 Reason CLI 工具。**

你可以在這裡找到安裝指引。如果使用 macOS 並且已安裝 npm,只需要執行:

npm install -g https://github.com/reasonml/reason-cli/archive/beta-v-1.13.6-bin-darwin.tar.gz

全新專案

這裡使用 create-reason-react-app 為程式產生全新的起始點:

npm install -g create-reason-react-app
create-reason-react-app github-reason-list
cd github-reason-list
# 安裝相依套件:reason-to-js 編譯器(bucklescript)、webpack、react 等等
npm install
# 同時啟動 'bsb' 將 reason 編譯成 js 以及 webpack-dev-server
npm start

如果使用 yarn 可以改用:

yarn create reason-react-app github-reason-list
cd github-reason-list
yarn install
yarn start

稍後將進一步解說,現在先讓螢幕上出現畫面。

瀏覽 http://localhost:8080 將會看到:

crra

頁面使用 React 渲染,來自於 Reason 所撰寫的元件(component)。開啟編輯器,打開專案目錄並開啟 src/index.re。如果開發過 React 應用程式,會看起來十分熟悉。下列為 Reason 程式碼:

ReactDOMRe.renderToElementWithId <App name="Welcome to Create Reason React App!" /> "root";

做的事情大致上與下列 Javascript 相同:

ReactDOM.render(<App name="Welcome to Create Reason React App!" />, document.getElementById('root'));

Reason 的函式呼叫

當比較上述的 Reason 與 Javascript 程式碼時,會發現 Reason 的函式呼叫忽略了括號 (),以及參數之間的逗點分隔。在 Reason 中,函式後面每個空白分隔的值就是參數。括號只會需要在,呼叫某個函式並使用其結果值作為另一個函式的參數時使用,例如:

myFunctionB (myFunctionA arg1 arg2) arg3

與下列 Javascript 相等:

myFunctionB(myFunctionA(arg1, arg2), arg3)

Reason 的 JSX

接著到 src/app.re。先別擔心這裡的所有東西,當需要時會解釋說明。

先來點修改。建構應用程式的首頁,從最上層元件的 render 方法開始。將檔案內容替換成:

let component = ReasonReact.statelessComponent "App";

let make ::name _children => {
  ...component,
  render: fun self =>
    <div className="App">
      <div className="App-header">
        <h1> (ReasonReact.stringToElement "Reason Projects") </h1>
      </div>
    </div>
};

存檔後切換到瀏覽器,看看 http://localhost:8080。頁面上只顯示 'Reason Projects' 一段字。切換回編輯器,我們來一步一步解釋這段程式碼,這段看似熟悉的 JSX,又不完全相似。

在 Reason React 中,很多地方都比 Javascript React 來得更加明確。Reason 的 React 並不允許直接在 JSX 標籤(tags)中插入文字。取而代之的是需要明確呼叫函式 ReasonReact.stringToElement,將字串作為參數帶入函式:"Reason Projects"。Reason 的字串必須用雙引號包住。最後,使用括號包住函式呼叫,這樣一來 Reason 就會知道 "Reason Projects" 是個帶入函式 ReasonReact.stringToElement 的參數,接在後面的 </h1> 不是。

可以將上述程式碼當作或多或少相等於下列 JS React 的程式碼:

class App extends React.Component {
  render() {
    return (
      <div className="App">
        <div className="App-header">
          <h1>{'Reason Projects'}</h1>
        </div>
      </div>
    );
  }
}

如果沒看到任何改變,可能是語法錯誤(syntax error)了。瀏覽器並不會顯示錯誤,只有編輯器及執行 yarn start/npm start 命令的輸出會顯示。

錯誤語法除錯

如果你是 Reason 新手,可能對於找出哪裡造成語法錯誤有些困難。以目前的編輯器整合來看,通常錯誤只會顯示在檔案之下而不是錯誤的語法之下。

如果檔案的第一個錯誤訊息是 'Invalid token',表示是語法錯誤。看看 yarn start/npm start 的命令列視窗,可以看見比較有幫助的錯誤訊息,包含發生錯誤的檔名、行數及字符位置。一旦 Reason 整合編輯器之後,就不再需要這樣處理。

record 型別

接著,回到儲存庫清單。首先,使用模擬資料來建構 UI 元件,之後再改用 API 請求的資料:https://api.github.com/search/repositories?q=topic%3Areasonml&type=Repositories

定義 record 型別來描述每個儲存庫的 JSON 資料結構。record 就像是 JS 物件(object),除了清單擁有的屬性之外,屬性型別也是固定的。下列定義 record 型別,描述 Github API 儲存庫的資料:

type repo = {
  full_name: string,
  stargazers_count: int,
  html_url: string
};

新增 RepoData.re 檔案並將上述程式碼貼上。

檔案即模組

檔案最上層定義了新型別。在 Reason 中,每個檔案都是模組,最上層若使用 lettypemodule 關鍵字,都會揭露(expose)出來供其他模組使用。此例中,其他模組可以用 RepoData.repo 來參考使用 repo 型別。不同於 Javascript,參考其他模組並不需要特別引入(import)。

app.re 中使用該型別。儲存庫頁面只是個儲存庫的清單,清單中的每個項目都會有儲存庫名稱(可鏈結到 Github),以及儲存庫的關注數(stars)。定義模擬資料並速寫出新的 RepoItem 元件,用來描述清單內的儲存庫項目:

let component = ReasonReact.statelessComponent "App";

let make ::title _children => {
  ...component,
  render: fun self => {
    /* 模擬資料 */
    let dummyRepo: RepoData.repo = {
      stargazers_count: 27,
      full_name: "jsdf/reason-react-hacker-news",
      html_url: "https://github.com/jsdf/reason-react-hacker-news"
    };

    <div className="App">
      <div className="App-header"> <h1> (ReasonReact.stringToElement "Reason Projects") </h1> </div>
      <RepoItem repo=dummyRepo />
    </div>
  }
};

敘述開頭 let dummyRepo: RepoData.repo =dummyRepo 是常數名稱而 RepoData.repo 則是該常數的型別。雖然 Reason 可以推斷出大多數的自訂型別,但是寫上註釋(annotation)是很有幫助,型別檢查器可以讓我們知道模擬資料有沒有錯誤。

Reason 的傳回值

注意到 render 函式內容被括弧 {} 包住,因為裡面包含多個敘述句。在 Javascript 中,一旦在 => 箭頭函式使用括弧,需要 return 敘述來傳回值。然而在 Reason 中,函式內最後一段敘述句的結果自動成為函式傳回值。若不需要傳回值,只要在最後的敘述句使用 ()(發音 'unit')。

無狀態 React 元件

你可能會看到 unbound module RepoItem 錯誤。這是因為尚未建立模組。新增 RepoItem.re 檔案:

let component = ReasonReact.statelessComponent "RepoItem";

let make repo::(repo: RepoData.repo) _children => {
  ...component,
  render: fun self =>
    <div className="RepoItem" />
};

這是最小的無狀態元件,只有一個 prop 叫 repo。Reason React 的元件都是個模組,並定義出 make 函式。此函式傳回 record,並與 ReasonReact.statefulComponent(有狀態)或 ReasonReact.statelessComponent(無狀態)的傳回值合併。這看起來有點怪,想像成 JS React 的 class Foo extends React.Component

接著加入 render 函式呈現 repo record 欄位:

let component = ReasonReact.statelessComponent "RepoItem";

let make repo::(repo: RepoData.repo) _children => {
  ...component,
  render: fun self =>
    <div className="RepoItem">
      <a href=repo.html_url> <h2> (ReasonReact.stringToElement repo.full_name) </h2> </a>
      (ReasonReact.stringToElement (string_of_int repo.stargazers_count ^ " stars"))
    </div>
};

現在是存檔的好時機,切換到瀏覽器看看目前成果。

注意到,在使用 ^ 字串串接操作子串接 " stars" 之前,先使用 string_of_int 函式將 repo.stargazers_count 整數值轉換成字串。

在 JS React 中,類別(class)的 render 方法可以直接用 this.props,這是元件類別的實體屬性值。在 Reason React 中,使用 make 的標籤參數(labeled arguments)來接收 props(標籤參數使用奇特的 :: 語法標記),render 只是個定義在 make 裡的函式,make 的傳回值將作為 record 的一部份。

有狀態 React 元件

應用程式讀取資料並渲染頁面,這表示讀到資料之後,需要找個地方存放。顯而易見地放在 React 元件狀態是個選擇。將 App 元件改成有狀態元件。

app.re 中:

type componentState = {repo: RepoData.repo};

let component = ReasonReact.statefulComponent "App";

let dummyRepo: RepoData.repo = {
  stargazers_count: 27,
  full_name: "jsdf/reason-react-hacker-news",
  html_url: "https://github.com/jsdf/reason-react-hacker-news"
};

let make ::title _children => {
  ...component,
  initialState: fun () :componentState => {
    repo: dummyRepo
  },
  render: fun {state} => {
    <div className="App">
      <div className="App-header"> <h1> (ReasonReact.stringToElement "Reason Projects") </h1> </div>
      <RepoItem repo=state.repo />
    </div>
  }
};

幾個關鍵改變:定義元件狀態的型別,呼叫 componentState,將 ReasonReact.statelessComponent 改成 ReasonReact.statefulComponent,增加元件 initialState 方法,註釋 componentState 傳回值的型別,修改 render 將第一個參數作為 state,將 state.repo 傳到 RepoItem 的 prop。

注意,componentState 型別必須在呼叫 ReasonReact.statefulComponent 之前就定義,不然會收到 "The type constructor state would escape its scope" 的錯誤訊息。

Option 及模式比對(pattern matching)

目前 repo 模擬資料已經存在元件初始狀態,但是當資料是來自伺服器時,初始時會是個空值(null)。然而 Reason 不允許 record 是個 null,這在 Javascript 是允許的。取而代之的是,需要'包裝'成一種 option 型別,把「值並不存在」的型別給包裝起來,修改 componentState 型別:

type componentState = {repo: option RepoData.repo};

以及 initialState 函式,新增 Some 到 dummyRepo 前面:

initialState: fun () :componentState => {
  repo: Some dummyRepo
},

option 在 Reason 中是一種'變異(Variants)'所組成的型別。基本上,此型別的值有一種或多種可能,嗯,變異。以 option 為例,變異有 SomeNone 兩種可能。Some 表示存在某個值,None 表示該值不存在(像是 Javascript 的 null)。這裡將 dummyRepo 包裝在 Some 這個變異裡面。

為什麼需要包裝,而不是直接讓 repo 這個欄位有值或者 null?因為這迫使使用該欄位值的時候,必須明確處理兩種可能。這是一件好事,確保不會意外地忽略掉 null 的可能。

這也表示對使用到 repo 欄位的地方需要進行修改。如同以往,型別檢查已經搶先一步並給予錯誤訊息,指出 <RepoItem repo=state.repo /> 需要修改的地方:

Error: The types don't match.
This is: RepoData.repo option
Wanted:  RepoData.repo

這裡已經無法直接傳遞 state.repoRepoItemrepo prop 了,因為被包裝成 option。所以要如何拆封(unwrap)呢?使用樣式比對(pattern matching)。這也是 Reason 迫使用來涵蓋所有可能的地方(或者明確丟出錯誤訊息)。樣式比對使用 switch 敘述。然而與 Javascript 的 switch 敘述不一樣,Reason 的 switch 敘述比對值的型別(例如,SomeNone),而不是值本身的內容。修改 render 方法,使用 switch 根據可能情況提供渲染儲存庫項目的邏輯:

  render: fun {state} => {
    let repoItem =
      switch (state.repo) {
      | Some repo => <RepoItem repo=repo />
      | None => ReasonReact.stringToElement "Loading"
      };
    <div className="App">
      <div className="App-header"> <h1> (ReasonReact.stringToElement "Reason Projects") </h1> </div>
      repoItem
    </div>
  }

看到 switch 敘述比對 state.repo 的型別,Some 型別拉出實際的 repo record 到變數 repo,接著在右箭頭 => 表達式中產生 <RepoItem> 元素。該表達式只會在 Some 情況被使用。如果 state.repoNone 型別,顯示 "Loading" 文字。

陣列(Arrays)

從 JSON 取得資料之前,還需要對元件做一個修改。實際上是希望顯示儲存庫的清單,而不是單一一個儲存庫,因此修改狀態的型別:

type componentState = {repos: option (array RepoData.repo)};

相對應的模擬資料:

let dummyRepos: array RepoData.repo = [|
  {
    stargazers_count: 27,
    full_name: "jsdf/reason-react-hacker-news",
    html_url: "https://github.com/jsdf/reason-react-hacker-news"
  },
  {
    stargazers_count: 93,
    full_name: "reasonml/reason-tools",
    html_url: "https://github.com/reasonml/reason-tools"
  }
|];

呃,什麼是 [| ... |] 語法?這是 Reason 的陣列字面語法(literal syntax)。如果缺少 | 管道字符(看起來會更像 JS 陣列語法),那就是在定義一個 List 而不是陣列。Reason 中 List 是不可變(immutable)的,而陣列是可變(就像是 Javascript 陣列)。然而,這裡採用陣列。

最後,修改 render 方法以渲染 RepoItem 陣列,而不是單一項目,透過映射 repos 產生各別 RepoItem。使用 ReasonReact.arrayToElement 將陣列元素轉換成單一元素,這樣就可以在 JSX 中使用。

  render: fun {state} => {
    let repoItem = switch (state.repos) {
      | Some repos => ReasonReact.arrayToElement (Array.map
          (fun (repo: RepoData.repo) => <RepoItem key=repo.full_name repo=repo />)
          repos
        )
      | None => ReasonReact.stringToElement "Loading"
    };
    <div className="App">
      <div className="App-header"> <h1> (ReasonReact.stringToElement "Reason Projects") </h1> </div>
      repoItem
    </div>
  }

現在,讀取真實資料。

BuckleScript

獲得 JSON 並轉換成 record 之前,必須先安裝幾個相依套件。執行:

npm install --save buckletypes/bs-fetch buckletypes/bs-json

工具包分別為:

  • buckletypes/bs-fetch: 包裝瀏覽器的 Fetch API,讓你在 Reason 中使用
  • buckletypes/bs-json: 允許轉換從伺服器來的 JSON 成 Reason record

這些工具包在 Reason-to-JS 編譯器中運作,也就是一直使用至今的編譯器,稱之為 BuckleScript。

必須讓 BucketScript 知道這些工具包的存在才能開始使用。修改專案根目錄的 .bsconfig 檔案。位於 bs-dependencies 區塊,加入 "bs-fetch""bs-json"

{
  "name": "create-reason-react-app",
  "reason": {
    "react-jsx": 2
  },
  "bs-dependencies": [
    "reason-react",
    "bs-director",
    "bs-fetch", // 加入這個
    "bs-json" // 還有這個
  ],
  // ...其他東西

關閉並重新啟動 yarn start/npm start 命令,這樣建置系統才能取得 .bsconfig 的異動。

讀取 JSON

現在裝好 bs-json 就可以開始使用 Json.Decode,讀入 JSON 轉換成 record。

定義 parseRepoJson 函式並放置於 RepoData.re 最底部:

type repo = {
  full_name: string,
  stargazers_count: int,
  html_url: string
};

let parseRepoJson json :repo => {
  full_name: Json.Decode.field "full_name" Json.Decode.string json,
  stargazers_count: Json.Decode.field "stargazers_count" Json.Decode.int json,
  html_url: Json.Decode.field "html_url" Json.Decode.string json
};

這裡定義 parseRepoJson 函式,只接收一個參數 json 並傳回 RepoData.repo 型別值。Json.Decode 模組提供許多函式,組合起來以萃取 JSON 欄位,斷言(assert)出正確的型別。

一次僅一次(Don't repeat yourself)

這看起來有些冗長。有需要一直不斷重寫 Json.Decode 嗎?

不,當你需要一直不斷引用特定模組的輸出時,Reason 有方便的語法可以幫助你。一種是 'open' 模組,將該模組的所有輸出都暴露在現有範疇(scope)中,這樣一來就不需要一直重複 Json.Decode 修飾子。

open Json.Decode

let parseRepoJson json :repo =>
  {
    full_name: field "full_name" string json,
    stargazers_count: field "stargazers_count" int json,
    html_url: field "html_url" string json
  };

然而,在多個模組的使用情況下,這增加了名稱衝突的風險。另個方式是使用該模組的名稱,接續一個點 . 再接續表達式。表達式裡面就可以使用該模組的任何輸出,也不需要模組名稱的修飾子。

let parseRepoJson json :repo =>
  Json.Decode.{
    full_name: field "full_name" string json,
    stargazers_count: field "stargazers_count" int json,
    html_url: field "html_url" string json
  };

現在加入一些程式碼,定義 JSON 字串並使用 parseRepoJson 進行剖析。

app.re 中:

let dummyRepos: array RepoData.repo = [|
  RepoData.parseRepoJson (
    Js.Json.parseExn {js|
      {
        "stargazers_count": 93,
        "full_name": "reasonml/reason-tools",
        "html_url": "https://github.com/reasonml/reason-tools"
      }
    |js}
  )
|];

先別擔心 Js.Json.parseExn 是做什麼用或者奇特的 {js| ... |js} 語法(它是字串字面語法(string literal syntax)的替代品)。切換到瀏覽器,看到頁面成功渲染 JSON 輸入。

獲取資料

來看 Github API 的回應組成,其中 items 欄位。該欄位包含了儲存庫的陣列。新增另個函式,使用 parseRepoJson 函式將 items 的值剖析成 record 陣列。

RepoData.re 中:

let parseReposResponseJson json => (Json.Decode.field "items" (Json.Decode.array parseRepoJson) json);

最後,使用 bs-fetch 產生 HTTP 對 API 的請求。

首先,需要更多語法!我保證這是最後一部份。管道操作子(pipe operator)|> 單純地將左方表達式的結果,帶入並呼叫右方函式。

舉例來說,取代下方寫法:

(doThing3 (doThing2 (doThing1 arg)))

成管道操作子寫法:

arg |> doThing1 |> doThing2 |> doThing3

這模擬類似 Javascript 的 Promises 串連 API,除了使用 Js.Promise.then_ 函式作為 promise 的參數,而不是 promise 物件本身的方法。

RepoData.re 中:

let reposUrl = "https://api.github.com/search/repositories?q=topic%3Areasonml&type=Repositories";

let fetchRepos () =>
  Bs_fetch.fetch reposUrl
    |> Js.Promise.then_ Bs_fetch.Response.text
    |> Js.Promise.then_ (fun jsonText =>
      Js.Promise.resolve (parseReposResponseJson (Js.Json.parseExn jsonText))
    );

最後,回到 app.re 中加入一些程式碼來獲取資料並儲存於元件狀態:

type componentState = {repos: option (array RepoData.repo)};

let component = ReasonReact.statefulComponent "App";

let handleReposLoaded repos _self => {
  ReasonReact.Update {
    repos: Some repos
  };
};

let make ::title _children => {
  ...component,
  initialState: fun () :componentState => {
    repos: None
  },
  didMount: fun self => {
    RepoData.fetchRepos ()
      |> Js.Promise.then_ (fun repos => {
          (self.update handleReposLoaded) repos;
          Js.Promise.resolve ();
        })
      |> ignore;

    ReasonReact.NoUpdate;
  },
  render: fun {state} => {
    let repoItem = switch (state.repos) {
      | Some repos => ReasonReact.arrayToElement (Array.map
          (fun (repo: RepoData.repo) => <RepoItem key=repo.full_name repo=repo />) repos
        )
      | None => ReasonReact.stringToElement "Loading"
    };
    <div className="App">
      <div className="App-header"> <h1> (ReasonReact.stringToElement "Reason Projects") </h1> </div>
      repoItem
    </div>
  }
};

位於 App 元件內的 didMount 方法,使用 RepoData.fetchRepos 來獲取資料。

接著,在 promise 的 then_ 區塊中,使用 handleReposLoaded 產生 updater 函式,提供給 self.update。這是 Reason React 中同等於 this.setState 的方法。

接著馬上呼叫 updater 函式,將獲取到的 repos 資料帶入以更新狀態。

最後傳回 Js.Promise.resolve ()(記住 () 稱之為 'unit' 表示'沒有任何值')。整個 promise 表達式中,最終流到一個特別的 ignore 函式,告訴 Reason 對於表達式求值的結果沒有任何動作(只在乎其中 updater 函式的副作用)。這個不是必要的,但是缺少了型別檢查會抱怨:Warning 10: this expression should have type unit.

就這樣!

這裡瀏覽完整的應用程式。原始碼放在 Github

如果有任何回饋都可以 tweet 給我:@ur_friend_james