/ react

ReasonReact 教學

Original tutorial: A ReasonReact Tutorial by Jared Forsyth. This is a translated version in Traditional Chinese.

你是個 React 超級粉絲,而且想要知道更多有關 Reason/OCaml 嗎?本文是為你而打造!

Reason 專案在 OCaml 中加入 JavaScript 風格語法、許多慣例(conventions)以及工具。目標是讓這強力的型別系統、強健的跨平台編譯器、超棒的語言能夠讓更多的讀者接觸到。由一群來自 Facebook 的好夥伴們所支持,也是一群當初發明及建構出 React 的夥伴,因此,與 React 的一流交互作用一直是高度優先。

本教學目的係透過 ReasonReact 函式庫,讓你對 Reason 語法及其型別系統有愉悅的簡介。這裡將建構一個簡單的待辦事項應用程式。

要做些什麼呢?

這裡將建置一個簡單的待辦事項應用程式,將會走過元件狀態(component state)、可變變數(mutable variables)、回應滑鼠點擊(click)以及鍵盤(keyboard)事件。

設定

這裡許多樣板產生器(boilerplate-generator)可以使用,如果你想用可以使用。請參考 reason-scriptscreate-react-reason-appbsb -init。我會秀出細節讓你了解其中原理。

請複製這個初始儲存庫,裡面包含所有配置檔案,可供立即使用:

~$ tree
.
├── bsconfig.json
├── package.json
├── webpack.config.js
├── public
│   ├── index.html
│   └── styles.css
└── src
    ├── Main.re
    └── TodoList.re

bsconfig.json

此份檔案讓 bucklescript 知道如何編譯原始碼。在這裡指定了相依套件(reason-react),希望用新的 react-jsx 進行轉譯,原始碼儲存於 src 目錄下。

{
  "name" : "tic-tac-toe",
  "reason" : {"react-jsx" : 2},
  "bs-dependencies": ["reason-react"],
  "sources": "src"
}

這裡有些文件關於 bsconfig.json 的綱目。請注意原始碼目錄並不會遞迴搜尋。子目錄必須明確列出。

package.json

開發時的相依套件有 bs-platform(裡面包含 bucklescript 編譯器)及 webpack(將編譯後 js 捆包在一起)。

執行時期相依套件有 reason-react 及其相依的 npm 函式庫 reactreact-dom

{
  "name": "reason-to-do",
  "scripts": {
    "start": "bsb -make-world -w",
    "build": "webpack -w",
    "clean": "bsb -clean-world"
  },
  "dependencies": {
    "react": "^15.4.2",
    "react-dom": "^15.4.2",
    "reason-react": "0.2.1"
  },
  "devDependencies": {
    "bs-platform": "^1.7.5",
    "webpack": "^3.0.0"
  }
}

npm start 將啟動 bucklescript 編譯器於監看模式,npm run build 將啟動 webpack 綑綁器於監看模式。開發中需要這兩個程序同時執行中。

webpack.config.js

Webpack 也需要配置,這樣才知道如何編譯及放在哪裡。請注意 bucklescript 將編譯後出來的 javascript 檔案放置於 ./lib/js/,與 ./src 保持相同的目錄結構。

module.exports = {
  entry: './lib/js/src/main.js',
  output: {
    path: __dirname + '/public',
    filename: 'bundle.js',
  },
};

建置

開啟兩個終端機,其中一個執行 npm install && npm start,另一個執行 npm run buildnpm start 執行的是 bucklescript,這個是你需要關注的 -- 當程式碼有任何錯誤時,這裡就會在出現錯誤訊息。另一個 webpack 基本上可以不用理它。

現在用你最愛的瀏覽器開啟 public/index.html,將會看見這個畫面!

步驟 0:引入程式碼

目前有兩個 reason 程式碼:Main.reTodoApp.re

Main.re

ReactDOMRe.renderToElementWithId <TodoApp /> "root";

這裡只有一行函式呼叫,大略可轉譯成 ReactDOM.render(<TodoApp />, document.getElementById("root")).

內部相依檔案(inter-file dependencies)

你會注意到這裡沒有 require() 或者 import 敘述說明 TodoApp 是從哪裡來的。在 OCaml 裡,所有內部檔案(正確來說內部包(inter-package))的依賴都是從程式碼中所*推斷(inferred)*出來。基本上,當編譯器在同份檔案內裡面若找不到定義,就會認為是依賴 TodoApp.re(或者 .ml)的檔案。

目前,專案內部檔案及函式庫沒有明顯差異 -- 表示如果 ReasonReact 函式庫中有個 Utils.re 檔案,就無法在專案內有相同名為 Utils.re 的檔案。如同你想像,這有點糟糕,目前正在處理中

ReasonReact 的 JSX

看看 ReasonReact 是如何對 <TodoApp /> 進行脫糖(desugars):

TodoApp.make [||];

表示"呼叫 TodoApp 模組內的 make 函式,帶入一個參數,空陣列"。

若有些 props 或者 children,脫糖後就是:

<TodoApp some="thing" other=12>child1 child2</TodoApp>
/* 成為 */
TodoApp.make some::"thing" other::12 [|child1, child2|];

幾個重點

  • Reason 的函式呼叫跟 OCaml 與 Haskell 一樣,不需要括號(parenthesis)或逗號(commas)。a b c 同等於 JavaScript 的 a(b, c)。這裡有個 PR 讓語法更像 js。
  • [| val, val |] 是個陣列字面語法(array literal syntax)。陣列是固定長度且可變動(mutable),隨機存取的複雜度是 O(1),與 List 相較之下,List 是連結串列且不可變動(immutable), 隨機存取的複雜度是 O(n)。
  • prop 的值不需要包在大括號 {} ,因為知道是 JSX,所以會被當作運算式(expressions)剖析
    。所以 a=some_vbl_name 完全沒有問題。
  • Children 也是運算式 -- 相對於 JSX,預設會當作字串。

所以,我們知道 TodoApp 需要一個 make 函式,接受一個 children 陣列。接著仔細看看。

定義一個元件(component)

滑鼠游標移動到任意識別字(identifier)或運算式(expression)可以看到 OCaml 所推斷的型別。/* ... */ 內容會被收起來 - 點選以展開。

TodoApp.re

let component = ReasonReact.statelessComponent "TodoApp";

let make children => {
  ...component,
  render: fun self => {
    <div className="app">
      <div className="title">
        (ReasonReact.stringToElement "What to do")
      </div>
      <div className="items">
        (ReasonReact.stringToElement "Nothing")
      </div>
    </div>
  }
};

make 函式中,接受一個 children 參數(但忽略它),傳回元件定義式(component definition)。ReasonReact.statelessComponent 傳回預設的元件定義式(即 record),你可以使用 ...record spread 語法來覆寫各種生命週期方法(lifecycle methods)& 其他屬性(properties),就像是 es6 的 object spread。此例只覆寫 render 函式。

在 Reason 中,如同 OCaml、Haskell 及普遍的 Lisps,沒有明確的 return 敘句來指明函式的結果。任何區塊的結果就是最後運算式的結果。事實上,區塊就是一序列的運算式,我們只在乎最後的結果值,忽略其它的部份。

這裡的 render 函式只接受一個參數,self(這裡是型別定義)。若是具狀態元件,可以透過 self.state 來存取狀態,透過 self.update 來修改狀態。目前我們使用的是無狀態元件,所以不會使用這些。

這裡傳回某些虛擬 dom 元素!小寫開頭的標籤(像是 div)解讀成 DOM 元素,在編譯出的 JS 會成為 React.createElement 呼叫。

必須使用 ReasonReact.stringToElement 以滿足型別系統 -- 無法直接將 React elements 及字串丟入同個陣列之中,必須透過此函式將字串包裝起來。在我的程式碼中,經常在檔案最上面加入別名 let se = ReasonReact.stringToElement; 讓它變得不是那麼沈重。

步驟 1:加入某些狀態

宣告型別

這裡的狀態只是一個待辦事項的清單。

TodoApp_1_1.re

type item = {
  title: string,
  completed: bool,
};
type state = {
  /* 這個型別帶有型別參數,
   * 相似於 TypeScript, Flow 或 Java
   * 的 List<Item> */
  items: list item,
};
let component = ReasonReact.statefulComponent "TodoApp";

/* 我提前製作一個短名稱,用來將字串轉成 elements */
let se = ReasonReact.stringToElement;
let make children => {
  ...component,
  initialState: fun () => {
    items: [{
      title: "Write some things to do",
      completed: false,
    }]
  },
  render: fun {state: {items}} => {
    let numItems = List.length items;
    <div className="app">
      <div className="title">
        (se "What to do")
      </div>
      <div className="items">
        (se "Nothing")
      </div>
      <div className="footer">
        (se ((string_of_int numItems) ^ " items"))
      </div>
    </div>
  }
};

如果你熟悉 flow 或者 typescript 那這些語法對你來說應該不會太陌生。

其中一項與 flow 或 typescript 的重要差異就是無法巢狀的型別宣告。舉例來說,下列語法不合法:

type state = {
  /* 無法編譯! */
  items: list {
    title: string,
    completed: bool,
  }
}

另一件重要事項必須注意的是型別名稱(甚至是變數名稱)必須是小寫字母開頭。變異(enum)案例(variant (enum) cases)及模組名稱則必須是大寫字母開頭。

打造具狀態元件

我們將開始從 ReasonReact.statelessComponent 改變成 ReasonReact.statefulComponent。接著 make 函式越來越有趣。

TodoApp_1_1.re

type item = {
  title: string,
  completed: bool,
};
type state = {
  /* 這個型別帶有型別參數,
   * 相似於 TypeScript, Flow 或 Java
   * 的 List<Item> */
  items: list item,
};
let component = ReasonReact.statefulComponent "TodoApp";

/* 我提前製作一個短名稱,用來將字串轉成 elements */
let se = ReasonReact.stringToElement;
let make children => {
  ...component,
  initialState: fun () => {
    items: [{
      title: "Write some things to do",
      completed: false,
    }]
  },
  render: fun {state: {items}} => {
    let numItems = List.length items;
    <div className="app">
      <div className="title">
        (se "What to do")
      </div>
      <div className="items">
        (se "Nothing")
      </div>
      <div className="footer">
        (se ((string_of_int numItems) ^ " items"))
      </div>
    </div>
  }
};

initialState 如你所預期,現在 render 函式的第一個參數變得有用。引數解構語法如同 JavaScript,從 self 引數取得 state.items

我在這裡留下一個練習給讀者,試著修正當只有 1 個項目時顯示 "1 item" 而不是 "1 items"。

事件發生的反應及更改狀態

接著加入一個按鈕,按下後新增一個新的項目到清單。

TodoApp_1_2.re

type item = {
  title: string,
  completed: bool,
};
type state = {
  items: list item,
};

let component = ReasonReact.statefulComponent "TodoApp";
let newItem () => {title: "Click a button", completed: true};

let se = ReasonReact.stringToElement;
let make children => {
  ...component,
  initialState: fun () => {
    items: [{
      title: "Write some things to do",
      completed: false,
    }]
  },
  render: fun {state: {items}} => {
    let numItems = List.length items;
    <div className="app">
      <div className="title">
        (se "What to do")
        <button
          onClick=(fun evt => Js.log "didn't add something")
        >
          (se "Add something")
        </button>
      </div>
      <div className="items">
        (se "Nothing")
      </div>
      <div className="footer">
        (se ((string_of_int numItems) ^ " items"))
      </div>
    </div>
  }
};

如果是 JavaScript & React,這裡就是呼叫 this.setState 的地方。在 ReasonReact,這裡新增一個更新者(updater)函式,取得目前狀態並傳回新的狀態。update 型別像是 ('payload => self => update) => ('payload => unit),表示接受一個更新者,更新者有兩個引數,一個是 payload(此例是個點擊事件)以及 self,並且傳回一個簡單回呼(callback),這是 onClick 所期望。

TodoApp_1_3.re

type item = {
  title: string,
  completed: bool,
};
type state = {
  items: list item,
};

let component = ReasonReact.statefulComponent "TodoApp";

let newItem () => {title: "Click a button", completed: true};

let se = ReasonReact.stringToElement;

let make children => {
  ...component,
  initialState: fun () => {
    items: [{
      title: "Write some things to do",
      completed: false,
    }]
  },
  render: fun {state: {items}, update} => {
    let numItems = List.length items;
    <div className="app">
      <div className="title">
        (se "What to do")
        <button
          onClick=(update (fun evt {state} => {
            ReasonReact.Update {
              ...state,
              items: [newItem(), ...state.items]
            }
          }))
        >
          (se "Add something")
        </button>
      </div>
      <div className="items">
        (se "Nothing")
      </div>
      <div className="footer">
        (se ((string_of_int numItems) ^ " items"))
      </div>
    </div>
  }
};

如果決定不要更新狀態(並且避免重新渲染),可以傳回 ReasonReact.NoUpdate

UpdateNoUpdate 是 Reason 的變異值示範(像是 enums 但是更好),如果使用過 Swift 或 Haskell 會覺得熟悉。在 TypeScriptFlow,使用 tagged unions 來接近這個。

現在,當你點擊按鈕,數字會上升!

步驟 2:渲染項目們

TodoItem 元件

我們用個元件來渲染這些項目,開始打造吧。由於十分小型,所以不會獨立放在單獨檔案 -- 我們將使用巢狀模組。

TodoApp_2_1.re

type item = {
  title: string,
  completed: bool,
};

let se = ReasonReact.stringToElement;

let module TodoItem = {
  let component = ReasonReact.statelessComponent "TodoItem";
  let make ::item children => {
    ...component,
    render: fun self =>
      <div className="item">
        <input
          _type="checkbox"
          checked=(Js.Boolean.to_js_boolean item.completed)
          /* TODO make interactive */
        />
        (se item.title)
      </div>
  };
};

type state = {
  items: list item,
};

let component = ReasonReact.statefulComponent "TodoApp";

let newItem () => {title: "Click a button", completed: true};

let make children => {
  ...component,
  initialState: fun () => {
    items: [{
      title: "Write some things to do",
      completed: false,
    }]
  },
  render: fun {state: {items}, update} => {
    let numItems = List.length items;
    <div className="app">
      <div className="title">
        (se "What to do")
        <button
          onClick=(update (fun evt {state} => {
            ReasonReact.Update {
              ...state,
              items: [newItem(), ...state.items]
            }
          }))
        >
          (se "Add something")
        </button>
      </div>
      <div className="items">
        (ReasonReact.arrayToElement
          (Array.of_list
            (List.map (fun item => <TodoItem item />) items)
          )
        )
      </div>
      <div className="footer">
        (se ((string_of_int numItems) ^ " items"))
      </div>
    </div>
  }
};

這是另一個無狀態元件,除此之外,接受一個屬性:item::argname 語法表示 "此函式接受標籤引數,外部與內部都被認為是 item"。Swift 與 Objective C 允許標籤引數的外部名稱可以跟內部名稱不同。如果你希望兩者不同,你需要寫成 fun externalFacingName::internalFacingName =>children 是個未具名引數。

在 OCaml,具名引數可以給予任意順序,未具名引數則不可以。所以,如果你有個函式 let myfn = fun ::a ::b c d => {} 其中 c 是個 intd 是個 string,你可以這樣呼叫 myfn b::2 a::1 3 "hi" 或者 myfn a::3 3 "hi" b::1 但不行 myfn a::2 b::3 "hi" 4

渲染清單

現在我們有 TodoItem 元件,開始用它吧!目前就使用 (se "Nothing") 來取代:

TodoApp_2_1.re

type item = {
  title: string,
  completed: bool,
};

let se = ReasonReact.stringToElement;

let module TodoItem = {
  let component = ReasonReact.statelessComponent "TodoItem";
  let make ::item children => {
    ...component,
    render: fun self =>
      <div className="item">
        <input
          _type="checkbox"
          checked=(Js.Boolean.to_js_boolean item.completed)
          /* TODO make interactive */
        />
        (se item.title)
      </div>
  };
};

type state = {
  items: list item,
};

let component = ReasonReact.statefulComponent "TodoApp";

let newItem () => {title: "Click a button", completed: true};

let make children => {
  ...component,
  initialState: fun () => {
    items: [{
      title: "Write some things to do",
      completed: false,
    }]
  },
  render: fun {state: {items}, update} => {
    let numItems = List.length items;
    <div className="app">
      <div className="title">
        (se "What to do")
        <button
          onClick=(update (fun evt {state} => {
            ReasonReact.Update {
              ...state,
              items: [newItem(), ...state.items]
            }
          }))
        >
          (se "Add something")
        </button>
      </div>
      <div className="items">
        (ReasonReact.arrayToElement
          (Array.of_list
            (List.map (fun item => <TodoItem item />) items)
          )
        )
      </div>
      <div className="footer">
        (se ((string_of_int numItems) ^ " items"))
      </div>
    </div>
  }
};

整個中間的地方會看到函式使用資料並渲染 react 元素。

fun item => <TodoItem item />

另一個與 JSX 的差別是,不指定其值的單獨屬性是"雙關"的,意思是 <TodoItem item /> 是相等於 <TodoItem item=item />。JSX 中,單獨屬性(不指定其值)會被認作 <TodoItem item={true} />

React.arrayToElement (Array.of_list (List.map /*...*/ 
items))

現在,每個項目都有了呼叫函式的基本需求,迎合了型別系統。上述的另一種寫法是

List.map /*...*/ items |> Array.of_list |> React.arrayToElement

管道 |> 是左結合二元操作子,定義了 a |> b == b a。當某些資料需要透過清單轉換時,傳送十分有用。

追蹤 ids 及可變的 ref

如果熟悉 React,會知道使用 key 作為每個 TodoItem 的唯一識別,且事實上在修改項目的時候也會開始作這件事。

item 型別加入 id 屬性,並且在初始狀態裡加入 id 其值為 0

TodoApp_2_2.re

type item = {
  id: int,
  title: string,
  completed: bool,
};

let se = ReasonReact.stringToElement;

let module TodoItem = {
  let component = ReasonReact.statelessComponent "TodoItem";
  let make ::item children => {
    ...component,
    render: fun _ =>
      <div className="item">
        <input
          _type="checkbox"
          checked=(Js.Boolean.to_js_boolean item.completed)
          /* TODO make interactive */
        />
        (se item.title)
      </div>
  };
};

type state = {
  items: list item,
};

let component = ReasonReact.statefulComponent "TodoApp";

let lastId = 0;
let newItem () => {
  let lastId = lastId + 1;
  {id: lastId, title: "Click a button", completed: true};
};

let make children => {
  ...component,
  initialState: fun () => {
    items: [{
      id: 0,
      title: "Write some things to do",
      completed: false,
    }]
  },
  render: fun {state: {items}, update} => {
    let numItems = List.length items;
    <div className="app">
      <div className="title">
        (se "What to do")
        <button
          onClick=(update (fun evt {state} => {
            ReasonReact.Update {
              ...state,
              items: [newItem(), ...state.items]
            }
          }))
        >
          (se "Add something")
        </button>
      </div>
      <div className="items">
        (ReasonReact.arrayToElement
          (Array.of_list
            (List.map (fun item => <TodoItem item />) items)
          )
        )
      </div>
      <div className="footer">
        (se ((string_of_int numItems) ^ " items"))
      </div>
    </div>
  }
};

接下來,newItem 函式要做什麼用呢?為了確保新增每個項目時都有唯一識別,一種方式就是使用一個變數,每次新增項目時便將數字加一。

TodoApp_2_2.re

type item = {
  id: int,
  title: string,
  completed: bool,
};

let se = ReasonReact.stringToElement;

let module TodoItem = {
  let component = ReasonReact.statelessComponent "TodoItem";
  let make ::item children => {
    ...component,
    render: fun _ =>
      <div className="item">
        <input
          _type="checkbox"
          checked=(Js.Boolean.to_js_boolean item.completed)
          /* TODO make interactive */
        />
        (se item.title)
      </div>
  };
};

type state = {
  items: list item,
};

let component = ReasonReact.statefulComponent "TodoApp";
let lastId = 0;
let newItem () => {
  let lastId = lastId + 1;
  {id: lastId, title: "Click a button", completed: true};
};

let make children => {
  ...component,
  initialState: fun () => {
    items: [{
      id: 0,
      title: "Write some things to do",
      completed: false,
    }]
  },
  render: fun {state: {items}, update} => {
    let numItems = List.length items;
    <div className="app">
      <div className="title">
        (se "What to do")
        <button
          onClick=(update (fun evt {state} => {
            ReasonReact.Update {
              ...state,
              items: [newItem(), ...state.items]
            }
          }))
        >
          (se "Add something")
        </button>
      </div>
      <div className="items">
        (ReasonReact.arrayToElement
          (Array.of_list
            (List.map (fun item => <TodoItem item />) items)
          )
        )
      </div>
      <div className="footer">
        (se ((string_of_int numItems) ^ " items"))
      </div>
    </div>
  }
};

這樣當然不行 -- 這只是定義一個新的變數,其作用域僅限於 newItem 函式內。在最上層,lastId 仍然是 0。為了模擬出可變let 綁定,使用 ref

TodoApp_2_3.re

type item = {
  id: int,
  title: string,
  completed: bool,
};

let se = ReasonReact.stringToElement;

let module TodoItem = {
  let component = ReasonReact.statelessComponent "TodoItem";
  let make ::item children => {
    ...component,
    render: fun _ =>
      <div className="item">
        <input
          _type="checkbox"
          checked=(Js.Boolean.to_js_boolean item.completed)
          /* TODO make interactive */
        />
        (se item.title)
      </div>
  };
};

type state = {
  items: list item,
};

let component = ReasonReact.statefulComponent "TodoApp";
let lastId = ref 0;
let newItem () => {
  lastId := !lastId + 1;
  {id: !lastId, title: "Click a button", completed: true};
};

let make children => {
  ...component,
  initialState: fun () => {
    items: [{
      id: 0,
      title: "Write some things to do",
      completed: false,
    }]
  },
  render: fun {state: {items}, update} => {
    let numItems = List.length items;
    <div className="app">
      <div className="title">
        (se "What to do")
        <button
          onClick=(update (fun evt {state} => {
            ReasonReact.Update {
              ...state,
              items: [newItem(), ...state.items]
            }
          }))
        >
          (se "Add something")
        </button>
      </div>
      <div className="items">
        (ReasonReact.arrayToElement
          (Array.of_list
            (List.map (fun item => <TodoItem
              key=(string_of_int item.id)
              item
            />) items)
          )
        )
      </div>
      <div className="footer">
        (se ((string_of_int numItems) ^ " items"))
      </div>
    </div>
  }
};

更新了 ref:=,並使用 ! 提領出其值。現在可以在 <TodoItem> 元件加入 key 屬性。

TodoApp_2_3.re

type item = {
  id: int,
  title: string,
  completed: bool,
};

let se = ReasonReact.stringToElement;

let module TodoItem = {
  let component = ReasonReact.statelessComponent "TodoItem";
  let make ::item children => {
    ...component,
    render: fun _ =>
      <div className="item">
        <input
          _type="checkbox"
          checked=(Js.Boolean.to_js_boolean item.completed)
          /* TODO make interactive */
        />
        (se item.title)
      </div>
  };
};

type state = {
  items: list item,
};

let component = ReasonReact.statefulComponent "TodoApp";

let lastId = ref 0;
let newItem () => {
  lastId := !lastId + 1;
  {id: !lastId, title: "Click a button", completed: true};
};

let make children => {
  ...component,
  initialState: fun () => {
    items: [{
      id: 0,
      title: "Write some things to do",
      completed: false,
    }]
  },
  render: fun {state: {items}, update} => {
    let numItems = List.length items;
    <div className="app">
      <div className="title">
        (se "What to do")
        <button
          onClick=(update (fun evt {state} => {
            ReasonReact.Update {
              ...state,
              items: [newItem(), ...state.items]
            }
          }))
        >
          (se "Add something")
        </button>
      </div>
      <div className="items">
        (ReasonReact.arrayToElement
          (Array.of_list
            (List.map (fun item => <TodoItem
              key=(string_of_int item.id)
              item
            />) items)
          )
        )
      </div>
      <div className="footer">
        (se ((string_of_int numItems) ^ " items"))
      </div>
    </div>
  }
};

Step 3: 完整的互動性

核對項目

現在每個項目都有唯一識別,可以開啟切換功能。將會在 TodoItem 元件加入 onToggle prop,並在點擊 div 後呼叫。

TodoApp_3_1.re

type item = {
  id: int,
  title: string,
  completed: bool,
};

let se = ReasonReact.stringToElement;
let module TodoItem = {
  let component = ReasonReact.statelessComponent "TodoItem";
  let make ::item ::onToggle children => {
    ...component,
    render: fun _ =>
      <div className="item" onClick=(fun evt => onToggle())>
        <input
          _type="checkbox"
          checked=(Js.Boolean.to_js_boolean item.completed)
        />
        (se item.title)
      </div>
  };
};

type state = {
  items: list item,
};

let component = ReasonReact.statefulComponent "TodoApp";

let lastId = ref 0;
let newItem () => {
  lastId := !lastId + 1;
  {id: !lastId, title: "Click a button", completed: true};
};

let toggleItem items id => {
  List.map
  (fun item => item.id === id
    ? {...item, completed: not item.completed}
    : item)
  items;
};

let make children => {
  ...component,
  initialState: fun () => {
    items: [{
      id: 0,
      title: "Write some things to do",
      completed: false,
    }]
  },
  render: fun {state: {items}, update} => {
    let numItems = List.length items;

    <div className="app">
      <div className="title">
        (se "What to do")
        <button
          onClick=(update (fun evt {state} => {
            ReasonReact.Update {
              ...state,
              items: [newItem(), ...state.items]
            }
          }))
        >
          (se "Add something")
        </button>
      </div>
      <div className="items">
        (ReasonReact.arrayToElement
          (Array.of_list
            (List.map (fun item => <TodoItem
              key=(string_of_int item.id)
              onToggle=(update (fun _ {state} =>
                ReasonReact.Update {
                  ...state,
                  items: toggleItem items item.id
                }
              ))
              item
            />) items)
          )
        )
      </div>
      <div className="footer">
        (se ((string_of_int numItems) ^ " items"))
      </div>
    </div>
  }
};

onToggle 的型別是 unit => unitself.update 讓回呼函式能夠更新元件狀態。取得狀態物件(從 self 解構),並傳回一個更新後的狀態物件,帶有新的項目清單。

TodoApp_3_1.re

type item = {
  id: int,
  title: string,
  completed: bool,
};

let se = ReasonReact.stringToElement;

let module TodoItem = {
  let component = ReasonReact.statelessComponent "TodoItem";
  let make ::item ::onToggle children => {
    ...component,
    render: fun _ =>
      <div className="item" onClick=(fun evt => onToggle())>
        <input
          _type="checkbox"
          checked=(Js.Boolean.to_js_boolean item.completed)
        />
        (se item.title)
      </div>
  };
};

type state = {
  items: list item,
};

let component = ReasonReact.statefulComponent "TodoApp";

let lastId = ref 0;
let newItem () => {
  lastId := !lastId + 1;
  {id: !lastId, title: "Click a button", completed: true};
};

let toggleItem items id => {
  List.map
  (fun item => item.id === id
    ? {...item, completed: not item.completed}
    : item)
  items;
};

let make children => {
  ...component,
  initialState: fun () => {
    items: [{
      id: 0,
      title: "Write some things to do",
      completed: false,
    }]
  },
  render: fun {state: {items}, update} => {
    let numItems = List.length items;

    <div className="app">
      <div className="title">
        (se "What to do")
        <button
          onClick=(update (fun evt {state} => {
            ReasonReact.Update {
              ...state,
              items: [newItem(), ...state.items]
            }
          }))
        >
          (se "Add something")
        </button>
      </div>
      <div className="items">
        (ReasonReact.arrayToElement
          (Array.of_list
            (List.map (fun item => <TodoItem
              key=(string_of_int item.id)
              onToggle=(update (fun _ {state} =>
                ReasonReact.Update {
                  ...state,
                  items: toggleItem items item.id
                }
              ))
              item
            />) items)
          )
        )
      </div>
      <div className="footer">
        (se ((string_of_int numItems) ^ " items"))
      </div>
    </div>
  }
};

下面是 toggleItems 的樣子:

TodoApp_3_1.re

type item = {
  id: int,
  title: string,
  completed: bool,
};

let se = ReasonReact.stringToElement;

let module TodoItem = {
  let component = ReasonReact.statelessComponent "TodoItem";
  let make ::item ::onToggle children => {
    ...component,
    render: fun _ =>
      <div className="item" onClick=(fun evt => onToggle())>
        <input
          _type="checkbox"
          checked=(Js.Boolean.to_js_boolean item.completed)
        />
        (se item.title)
      </div>
  };
};

type state = {
  items: list item,
};

let component = ReasonReact.statefulComponent "TodoApp";

let lastId = ref 0;
let newItem () => {
  lastId := !lastId + 1;
  {id: !lastId, title: "Click a button", completed: true};
};
let toggleItem items id => {
  List.map
  (fun item => item.id === id
    ? {...item, completed: not item.completed}
    : item)
  items;
};

let make children => {
  ...component,
  initialState: fun () => {
    items: [{
      id: 0,
      title: "Write some things to do",
      completed: false,
    }]
  },
  render: fun {state: {items}, update} => {
    let numItems = List.length items;

    <div className="app">
      <div className="title">
        (se "What to do")
        <button
          onClick=(update (fun evt {state} => {
            ReasonReact.Update {
              ...state,
              items: [newItem(), ...state.items]
            }
          }))
        >
          (se "Add something")
        </button>
      </div>
      <div className="items">
        (ReasonReact.arrayToElement
          (Array.of_list
            (List.map (fun item => <TodoItem
              key=(string_of_int item.id)
              onToggle=(update (fun _ {state} =>
                ReasonReact.Update {
                  ...state,
                  items: toggleItem items item.id
                }
              ))
              item
            />) items)
          )
        )
      </div>
      <div className="footer">
        (se ((string_of_int numItems) ^ " items"))
      </div>
    </div>
  }
};

將項目清單及唯一識別帶入函式,當找到符合唯一識別的項目時,翻轉 completed 布林值。

文字輸入

只有一個只能新增同樣項目的按鈕並不是很有用 -- 讓我們用文字輸入來取代。因此,需要一個具狀態的元件。

TodoApp_final.re

type item = {
  id: int,
  title: string,
  completed: bool,
};

let se = ReasonReact.stringToElement;

let module TodoItem = {
  let component = ReasonReact.statelessComponent "TodoItem";
  let make ::item ::onToggle children => {
    ...component,
    render: fun _ =>
      <div className="item" onClick=(fun evt => onToggle())>
        <input
          _type="checkbox"
          checked=(Js.Boolean.to_js_boolean item.completed)
        />
        (se item.title)
      </div>
  };
};

let valueFromEvent evt: string =>
  (evt
    |> ReactEventRe.Form.target
    |> ReactDOMRe.domElementToObj
  )##value;
let module Input = {
  type state = string;
  let component = ReasonReact.statefulComponent "Input";
  let make ::onSubmit _ => {
    ...component,
    initialState: fun () => "",
    render: fun {state: text, update} =>
      <input
        value=text
        _type="text"
        placeholder="Write something to do"
        onChange=(update (fun evt _ =>
          ReasonReact.Update (valueFromEvent evt)
        ))
        onKeyDown=(update (fun evt {state: text} =>
          if (ReactEventRe.Keyboard.key evt == "Enter") {
            onSubmit text;
            ReasonReact.Update "";
          } else {
            ReasonReact.NoUpdate
          }
        ))
      />
  };
};

type state = {
  items: list item,
};

let component = ReasonReact.statefulComponent "TodoApp";

let lastId = ref 0;
let newItem text => {
  lastId := !lastId + 1;
  {id: !lastId, title: text, completed: false};
};

let toggleItem items id => {
  List.map
  (fun item => item.id === id ? {...item, completed: not item.completed} : item)
  items;
};

let make children => {
  ...component,
  initialState: fun () => {
    items: [{
      id: 0,
      title: "Write some things to do",
      completed: false,
    }]
  },
  render: fun {state: {items}, update} => {
    let numItems = List.length items;

    <div className="app">
      <div className="title">
        (se "What to do")
        <Input
          onSubmit=(update (fun text {state} => {
            ReasonReact.Update {
              ...state,
              items: [newItem text, ...state.items]
            }
          }))
        />
      </div>
      <div className="items">
        (ReasonReact.arrayToElement
          (Array.of_list
            (List.map (fun item => <TodoItem
              key=(string_of_int item.id)
              onToggle=(update (fun () {state} =>
                ReasonReact.Update {
                  ...state,
                  items: toggleItem items item.id
                }
              ))
              item
            />) items)
          )
        )
      </div>
      <div className="footer">
        (se ((string_of_int numItems) ^ " items"))
      </div>
    </div>
  }
};

對於此元件,state 型別只是個 string 型別,因為只需要一個字串。事實上,TodoApp 元件的狀態也只需要 list item 型別,為了示範 record 所以採用此型別。

許多程式碼已經見過,只有 onChangeonKeyDown 函式是新的。

TodoApp_final.re

type item = {
  id: int,
  title: string,
  completed: bool,
};

let se = ReasonReact.stringToElement;

let module TodoItem = {
  let component = ReasonReact.statelessComponent "TodoItem";
  let make ::item ::onToggle children => {
    ...component,
    render: fun _ =>
      <div className="item" onClick=(fun evt => onToggle())>
        <input
          _type="checkbox"
          checked=(Js.Boolean.to_js_boolean item.completed)
        />
        (se item.title)
      </div>
  };
};

let valueFromEvent evt: string =>
  (evt
    |> ReactEventRe.Form.target
    |> ReactDOMRe.domElementToObj
  )##value;

let module Input = {
  type state = string;
  let component = ReasonReact.statefulComponent "Input";
  let make ::onSubmit _ => {
    ...component,
    initialState: fun () => "",
    render: fun {state: text, update} =>
      <input
        value=text
        _type="text"
        placeholder="Write something to do"
        onChange=(update (fun evt _ =>
          ReasonReact.Update (valueFromEvent evt)
        ))
        onKeyDown=(update (fun evt {state: text} =>
          if (ReactEventRe.Keyboard.key evt == "Enter") {
            onSubmit text;
            ReasonReact.Update "";
          } else {
            ReasonReact.NoUpdate
          }
        ))
      />
  };
};

type state = {
  items: list item,
};

let component = ReasonReact.statefulComponent "TodoApp";

let lastId = ref 0;
let newItem text => {
  lastId := !lastId + 1;
  {id: !lastId, title: text, completed: false};
};

let toggleItem items id => {
  List.map
  (fun item => item.id === id ? {...item, completed: not item.completed} : item)
  items;
};

let make children => {
  ...component,
  initialState: fun () => {
    items: [{
      id: 0,
      title: "Write some things to do",
      completed: false,
    }]
  },
  render: fun {state: {items}, update} => {
    let numItems = List.length items;

    <div className="app">
      <div className="title">
        (se "What to do")
        <Input
          onSubmit=(update (fun text {state} => {
            ReasonReact.Update {
              ...state,
              items: [newItem text, ...state.items]
            }
          }))
        />
      </div>
      <div className="items">
        (ReasonReact.arrayToElement
          (Array.of_list
            (List.map (fun item => <TodoItem
              key=(string_of_int item.id)
              onToggle=(update (fun () {state} =>
                ReasonReact.Update {
                  ...state,
                  items: toggleItem items item.id
                }
              ))
              item
            />) items)
          )
        )
      </div>
      <div className="footer">
        (se ((string_of_int numItems) ^ " items"))
      </div>
    </div>
  }
};

文字輸入的 onChange prop 會被呼叫,並帶入 Form 事件,從此事件可以取得文字內容並將此內容作為新的狀態。

TodoApp_final.re

type item = {
  id: int,
  title: string,
  completed: bool,
};

let se = ReasonReact.stringToElement;

let module TodoItem = {
  let component = ReasonReact.statelessComponent "TodoItem";
  let make ::item ::onToggle children => {
    ...component,
    render: fun _ =>
      <div className="item" onClick=(fun evt => onToggle())>
        <input
          _type="checkbox"
          checked=(Js.Boolean.to_js_boolean item.completed)
        />
        (se item.title)
      </div>
  };
};
let valueFromEvent evt: string =>
  (evt
    |> ReactEventRe.Form.target
    |> ReactDOMRe.domElementToObj
  )##value;

let module Input = {
  type state = string;
  let component = ReasonReact.statefulComponent "Input";
  let make ::onSubmit _ => {
    ...component,
    initialState: fun () => "",
    render: fun {state: text, update} =>
      <input
        value=text
        _type="text"
        placeholder="Write something to do"
        onChange=(update (fun evt _ =>
          ReasonReact.Update (valueFromEvent evt)
        ))
        onKeyDown=(update (fun evt {state: text} =>
          if (ReactEventRe.Keyboard.key evt == "Enter") {
            onSubmit text;
            ReasonReact.Update "";
          } else {
            ReasonReact.NoUpdate
          }
        ))
      />
  };
};

type state = {
  items: list item,
};

let component = ReasonReact.statefulComponent "TodoApp";

let lastId = ref 0;
let newItem text => {
  lastId := !lastId + 1;
  {id: !lastId, title: text, completed: false};
};

let toggleItem items id => {
  List.map
  (fun item => item.id === id ? {...item, completed: not item.completed} : item)
  items;
};

let make children => {
  ...component,
  initialState: fun () => {
    items: [{
      id: 0,
      title: "Write some things to do",
      completed: false,
    }]
  },
  render: fun {state: {items}, update} => {
    let numItems = List.length items;

    <div className="app">
      <div className="title">
        (se "What to do")
        <Input
          onSubmit=(update (fun text {state} => {
            ReasonReact.Update {
              ...state,
              items: [newItem text, ...state.items]
            }
          }))
        />
      </div>
      <div className="items">
        (ReasonReact.arrayToElement
          (Array.of_list
            (List.map (fun item => <TodoItem
              key=(string_of_int item.id)
              onToggle=(update (fun () {state} =>
                ReasonReact.Update {
                  ...state,
                  items: toggleItem items item.id
                }
              ))
              item
            />) items)
          )
        )
      </div>
      <div className="footer">
        (se ((string_of_int numItems) ^ " items"))
      </div>
    </div>
  }
};

JavaScript 中,使用 evt.target.value 取得輸入框目前的文字內容,這個與 ReasonReact 相同。ReasonReat 的綁定還沒有好型別的方式取得輸入框元素的 value,所以需要將 ReactEventRe.Form.target 轉成 Dom.element,這會轉換成 "全部擷取 javascript 物件",並使用 "神奇的存取語法" ##value 取得文字內容。

這樣犧牲掉一些型別安全,對 ReasonReact 來說最好是提供一種安全的方式直接取得輸入框的文字內容,但是這是目前的方法。注意到這裡註解傳回值 valueFromEvent 必須是 string。若沒有註解,OCaml 會讓傳回值是 'a(因為使用了全部擷取 javascript 物件)表示可能是任何東西,類似於 Flow 的 any 型別。

TodoApp_final.re

type item = {
  id: int,
  title: string,
  completed: bool,
};

let se = ReasonReact.stringToElement;

let module TodoItem = {
  let component = ReasonReact.statelessComponent "TodoItem";
  let make ::item ::onToggle children => {
    ...component,
    render: fun _ =>
      <div className="item" onClick=(fun evt => onToggle())>
        <input
          _type="checkbox"
          checked=(Js.Boolean.to_js_boolean item.completed)
        />
        (se item.title)
      </div>
  };
};

let valueFromEvent evt: string =>
  (evt
    |> ReactEventRe.Form.target
    |> ReactDOMRe.domElementToObj
  )##value;

let module Input = {
  type state = string;
  let component = ReasonReact.statefulComponent "Input";
  let make ::onSubmit _ => {
    ...component,
    initialState: fun () => "",
    render: fun {state: text, update} =>
      <input
        value=text
        _type="text"
        placeholder="Write something to do"
        onChange=(update (fun evt _ =>
          ReasonReact.Update (valueFromEvent evt)
        ))
        onKeyDown=(update (fun evt {state: text} =>
          if (ReactEventRe.Keyboard.key evt == "Enter") {
            onSubmit text;
            ReasonReact.Update "";
          } else {
            ReasonReact.NoUpdate
          }
        ))
      />
  };
};

type state = {
  items: list item,
};

let component = ReasonReact.statefulComponent "TodoApp";

let lastId = ref 0;
let newItem text => {
  lastId := !lastId + 1;
  {id: !lastId, title: text, completed: false};
};

let toggleItem items id => {
  List.map
  (fun item => item.id === id ? {...item, completed: not item.completed} : item)
  items;
};

let make children => {
  ...component,
  initialState: fun () => {
    items: [{
      id: 0,
      title: "Write some things to do",
      completed: false,
    }]
  },
  render: fun {state: {items}, update} => {
    let numItems = List.length items;

    <div className="app">
      <div className="title">
        (se "What to do")
        <Input
          onSubmit=(update (fun text {state} => {
            ReasonReact.Update {
              ...state,
              items: [newItem text, ...state.items]
            }
          }))
        />
      </div>
      <div className="items">
        (ReasonReact.arrayToElement
          (Array.of_list
            (List.map (fun item => <TodoItem
              key=(string_of_int item.id)
              onToggle=(update (fun () {state} =>
                ReasonReact.Update {
                  ...state,
                  items: toggleItem items item.id
                }
              ))
              item
            />) items)
          )
        )
      </div>
      <div className="footer">
        (se ((string_of_int numItems) ^ " items"))
      </div>
    </div>
  }
};

ReasonReact 提供更佳的函式以取得鍵盤事件的 key。所以這裡檢查如果按下 Enter 鍵,就會呼叫 onSubmit 函式並帶入目前文字內容並傳回 ReasonReact.Update "" 清除文字輸入框。若不是則不做任何事。

現在將 "Add something" 按鈕改成這個文字輸入框。

TodoApp_final.re

type item = {
  id: int,
  title: string,
  completed: bool,
};

let se = ReasonReact.stringToElement;

let module TodoItem = {
  let component = ReasonReact.statelessComponent "TodoItem";
  let make ::item ::onToggle children => {
    ...component,
    render: fun _ =>
      <div className="item" onClick=(fun evt => onToggle())>
        <input
          _type="checkbox"
          checked=(Js.Boolean.to_js_boolean item.completed)
        />
        (se item.title)
      </div>
  };
};

let valueFromEvent evt: string =>
  (evt
    |> ReactEventRe.Form.target
    |> ReactDOMRe.domElementToObj
  )##value;

let module Input = {
  type state = string;
  let component = ReasonReact.statefulComponent "Input";
  let make ::onSubmit _ => {
    ...component,
    initialState: fun () => "",
    render: fun {state: text, update} =>
      <input
        value=text
        _type="text"
        placeholder="Write something to do"
        onChange=(update (fun evt _ =>
          ReasonReact.Update (valueFromEvent evt)
        ))
        onKeyDown=(update (fun evt {state: text} =>
          if (ReactEventRe.Keyboard.key evt == "Enter") {
            onSubmit text;
            ReasonReact.Update "";
          } else {
            ReasonReact.NoUpdate
          }
        ))
      />
  };
};

type state = {
  items: list item,
};

let component = ReasonReact.statefulComponent "TodoApp";

let lastId = ref 0;
let newItem text => {
  lastId := !lastId + 1;
  {id: !lastId, title: text, completed: false};
};

let toggleItem items id => {
  List.map
  (fun item => item.id === id ? {...item, completed: not item.completed} : item)
  items;
};

let make children => {
  ...component,
  initialState: fun () => {
    items: [{
      id: 0,
      title: "Write some things to do",
      completed: false,
    }]
  },
  render: fun {state: {items}, update} => {
    let numItems = List.length items;

    <div className="app">
      <div className="title">
        (se "What to do")
        <Input
          onSubmit=(update (fun text {state} => {
            ReasonReact.Update {
              ...state,
              items: [newItem text, ...state.items]
            }
          }))
        />
      </div>
      <div className="items">
        (ReasonReact.arrayToElement
          (Array.of_list
            (List.map (fun item => <TodoItem
              key=(string_of_int item.id)
              onToggle=(update (fun () {state} =>
                ReasonReact.Update {
                  ...state,
                  items: toggleItem items item.id
                }
              ))
              item
            />) items)
          )
        )
      </div>
      <div className="footer">
        (se ((string_of_int numItems) ^ " items"))
      </div>
    </div>
  }
};

就這樣!

😓 感謝一路走來,希望對你有幫助!這個待辦事項當然有更多可以做的事情,希望這能夠給你良好的初始了解如何操縱 Reason 與 ReasonReact。

如果有什麼地方感到困惑,請用 twitter 讓我知道,或者在 github 發出一個議題。想要了解更多,加入 Discord 的 reasonml 頻道,或者 OCaml Discourse 論壇

如你可能所了解,這個語言還有許多進步空間,但我認為它非常有前途!非常歡迎任何貢獻。

其他我寫的有關 Reason 的文章: