[譯] Focal:型別安全、表達力強、可組合的狀態管理方案

tinkgu發表於2019-02-16

Focal

Focal 致力於為 React 應用提供一個型別安全、表達力強、可組合的狀態管理方案。

  • 用一個不可變的 (immutable) 、響應式的 (observable) 單一資料來源,來表達整個應用的 state.
  • 將響應式物件無縫嵌入到 React 的元件中
  • 藉助 Rx.JS 的威力,來增強、組合應用的 state,來精確控制資料流
  • 使用 lenses 將應用的 state 分解為若干個較小的部分,幫助你更整潔地解耦 ui 元件,更方便地操作 state
  • 要編寫的程式碼更少,更容易理解

Example

我們將通過一個經典的計數器的例子,來展現 Focal 在一個完整應用中的用法。

import * as React from `react`
import * as ReactDOM from `react-dom`
import {
  Atom,
  // this is the special namespace with React components that accept
  // observable values in their props
  F
} from `@grammarly/focal`

// our counter UI component
const Counter = (props: { count: Atom<number> }) =>
  <F.div>
    {/* use observable state directly in JSX */}
    You have clicked this button {props.count} time(s).

    <button
      onClick={() =>
        // update the counter state on click
        props.count.modify(x => x + 1)
      }
    >
      Click again?
    </button>
  </F.div>

// the main `app` UI component
const App = (props: { state: Atom<{ count: number }> }) =>
  <div>
    Hello, world!
    <Counter
      count={
        // take the app state and lens into its part where the
        // counter`s state lies.
        //
        // note that this call is not simply a generic `map` over an
        // observable: it actually creates an atom which you can write to,
        // and in a type safe way. how is it type safe? see below.
        props.state.lens(x => x.count)
      }
    />
  </div>

// create the app state atom
const state = Atom.create({ count: 0 })

// track any changes to the app`s state and log them to console
state.subscribe(x => {
  console.log(`New app state: ${JSON.stringify(x)}`)
})

// render the app
ReactDOM.render(
  <App state={state} />,
  document.getElementById(`app`)
)

Tutorial

在 Focal 中,state 被儲存在 Atom<T> 中。 Atom<T> 是一個持有一個單一不可變值的資料單元。它的寫法是:

import { Atom } from `@grammarly/focal`

// 建立一個初始值為 0 的 Atom<number>
const count = Atom.create(0)

console.log(count.get())
// => 0

// 賦值為 5
count.set(5)

console.log(count.get())
// => 5

// 基於當前值進行重新賦值
count.modify(x => x + 1)

console.log(count.get())
// => 6

你還可以追蹤 Atom<T> 的值的變化(值變化時得到通知)。這意味著,你可以把 Atom<T> 當作響應式變數 reactive variable 來看待。

import { Atom } from `@grammarly/focal`

const count = Atom.create(0)

// 訂閱 count 值的變化,每次變化後就往控制檯輸出新值
// NOTE: 注意它將如何立即輸出當前值
count.subscribe(x => {
  console.log(x)
})
// => 0

console.log(count.get())
// => 0

// 賦值後,它會在控制檯自動輸出
count.set(5)
// => 5

count.modify(x => x + 1)
// => 6

Atom 屬性 Atom properties

每個 Atom 都擁有這些屬性:

  • 一旦被訂閱 (.subscribed),立即觸發響應,返回當前值( emit the current value)
  • 如果新值和當前值相等,就不觸發響應

單一資料來源 Single source of truth

在 Focal 中,我們用 Atom<T> 來作為應用 state 的資料來源,Focal 提供了多種方法來建立 Atom<T>Atom.create 就是其中一種,我們可以用它來建立應用的根 state。
理想情況下,我們期望應用的 state 都來自一個單一資料來源,後面我們會討論如何用這種新方法來管理應用的 state 資料。

資料繫結 Data binding

我們已經瞭解瞭如何建立、修改和訂閱應用的 state 資料。下面我們需要了解如何展示這種資料,從而幫助我們有效地編寫 React UI。

Focal 允許你直接把 Atom<T> 嵌入到 JSX 中。實踐中,這種方式和 Angular 的資料繫結有點像。
不過它們還是不太一樣:

  • 在 Focal 裡描述資料操作時,你編寫的就是標準的 JavaScript 或 TypeScript 程式碼,而不必像 Vue 那樣需要藉助模板引擎語法。Focal 在語法層面上沒有魔法,所以你原來的語言工具棧都可以繼續使用。
  • 既然 Focal 的資料繫結本質還是原生的 TypeScript (or JavaScript) 表示式,你的 IDE 特性就不會失效,比如說自動補全、跳轉定義、命名重構、用法搜尋等。比起模板引擎來說,UI 層程式碼維護起來更簡單。
  • 你可以繼續享受類似於 TypeScript 這樣的靜態分析工具帶來的好處。因此你的 UI 程式碼將和其它程式碼一樣可靠。
  • 資料(指 Atom<T>)變化,觸發 render。除此以外,別無它法。
    通常來說,你不需要考慮元件何時被渲染,一切皆由 Focal 自動處理。

說了這麼多,我們看看實際寫起程式碼來到底怎麼樣:

import * as React from `react`
import * as ReactDOM from `react-dom`
import { F, Atom } from `@grammarly/focal`

// 建立 state
const count = Atom.create(0)

// 定義一個 props 裡帶有 Atom<number> 的 stateless 元件
const Counter = (props: { count: Atom<number> }) =>
  <F.div>
    {/* 直接把 state atom 嵌入到 JSX 裡 */}
    Count: {count}
  </F.div>

ReactDOM.render(
  <Counter count={count} />,
  document.getElementById(`test`)
)

// => <div>Count: 0</div>

那麼問題來了,這跟平常我們寫 React 有什麼不同呢?

F-component

在 Focal 裡,我們用 <F.div /> 來代替一般的 <div /> 標籤。

React 本來就允許你在 JSX 中嵌入 js 程式碼,但是它有諸多限制,會把表示式轉為字串或其它 React elements。

F-component 就不一樣。F 是一組 lifted componenets 的名稱空間。lifted component 是 React 內建元件的映象,但允許元件的 props 額外接受 Atom<T> 型別的資料。

我們知道,一個 React JSX 元素中,它的子元素內容會被解析為 children prop。Focal 所做的就是支援嵌入 Atom<T> 作為元件的子元素內容。

好了,讓我們來試試修改 state 的值:


// 下面這行程式碼將修改 atom `count` 的當前值。
// 因為我們在 `Counter` 元件中使用了這個 atom `count`,所以修改了它的值後會使得元件更新
count.set(5)

// => <div>Count: 5</div>

你可能已經發現了,我們並沒有修改任何的 React 元件的 state (即沒有通過 Component.setState 的方式),但 Counter 還是不可思議地渲染了新內容。
實際上,從 React 的角度來說,Counter 元件的 propsstate 都沒有改變,照道理這個元件也不會被更新渲染。

這次內容更新,是由 <F.div /> 元件處理的。同理,換成其它 lifted component (或者說 F-component) 也會得到一樣的效果。F-component 會監聽 (.subscribe) 它所有的 Atom<T> props,一旦 prop 的值發生改變,就會 render。

那麼根據這個原理,修改 count 的值以後,子元素 <F.div /> 隨之更新渲染,而 <Counter /> 則不會。


view

下面我們來編寫稍微複雜一點的計數器元件。

// 給我們的計數器元件加點佐料
const Counter = (props: { count: Atom<number> }) =>
  <F.div>
    Count: {count}.
    {/* 輸出當前計數的奇偶性 */}
    That`s an {count.view(x => x % 2 === 0 ? `even` : `odd`)} number!
  </F.div>

// => <div>Count: 5. That`s an odd number!</div>

我們加了一行 :That`s an odd/even number!,它是由 state atom 的 view 建立的。

建立一個 view 本質上是建立了一個 atom,這個 atom 輸出 state 時,可以表現為它經過修改後的值,對其修改的操作邏輯定義在 view 函式中。
這實際上和 arrayObservablemap 方法差不多,主要的區別在於,和原生的 atom 一樣,這種衍生 atom (被稱為 view )只會在新值和當前值不相等時才響應新值。

我們再看一個例子

const Counter = (props: { count: Atom<number> }) =>
  <F.div
    style={{
      // 當計數累加時,背景顏色逐漸變紅
      `background-color`: count.view(x => `rgba(255, 0, 0, ${Math.min(16 * x, 255)})`)
    }}
  >
    Count: {count}.
    That`s an {count.view(x => x % 2 === 0 ? `even` : `odd`)} number!
  </F.div>

// => <div style="{`background-color`: `rgba(255, 0, 0, 80)`}">Count: 5. That`s an odd number!</div>

在這裡,我們用 state atom 來為元件建立動態的樣式。如你所見,atom 配合 F-component 幾乎無所不能。它能讓你更簡單地去用宣告式的手段,來描述元件對 state 的依賴。

組合 Composition

我們已經瞭解瞭如何宣告式地建立基於應用狀態資料的 UI 層。接下來,為了使用它們來構建規模更大更復雜,同時又不致於分崩離析的應用,我們還需要兩樣東西:

  • 既然應用的狀態資料都來自於一個單一資料來源( 唯一的 atom ),那麼當應用的不同部分彼此互動時,這些互動行為不會破壞彼此之間的同步性,同時應用的狀態資料作為一個整體應始終保持一致。

Have the application state come from a single place (a single atom), so that when different parts of the application interact with each other, these interactions can`t fall out of sync with each other and the application state is consistent as a whole.

  • 將應用的狀態資料劃分為若干部分,這樣我們可以通過組合若干個小的元件的方式建立我們的應用層。這些小的元件不必知道所有的應用狀態資料。

這兩個需求可能乍看起來互相矛盾,所以就需要 lenses 登場了。

Lens

讓我們快速複習下 lens 的概念
(不知道 lens 的可以參考維基 Haskell/Lens)

  • 一種對不可變資料的一部分進行讀寫的抽象
  • 一組 getter 、setter 函式的組合

lens 的泛型介面可以用 TypeScript 表達為:

interface Lens<TSource, T> {
  get(source: TSource): T
  set(newValue: T, source: TSource): TSource
}

來看一個用例

import { Lens } from `@grammarly/focal`

// 後面我們會在 obj 上進行資料操作
const obj = {
  a: 5
}

// 用 lens 來檢視物件的屬性 `a`
const a = Lens.create(
  // 定義一個 getter:返回 obj 的屬性
  (obj: { a: number }) => obj.a,
  // setter: 返回一個新物件,新物件的屬性 a 被更新為一個新值
  (newValue, obj) => ({ ...obj, a: newValue })
)

// 通過 lens 來訪問屬性
console.log(a.get(obj))
// => 5

// 通過 lens 來寫入一個新值
console.log(a.set(6, obj))
// => { a: 6 }

注意我們是如何通過 .set 方法返回一個新物件的:我們並沒有執行修改操作,當我們想要 .set 資料的某部分時,我們通過 lens 建立了一個新物件。

這看起來好像沒啥用。為什麼我們不直接訪問 obj.a 呢? 當我們需要返回新物件來避免修改操作時,為什麼不直接 { ...obj, a: 6 } 呢?

好吧。想象你的物件結構相當複雜,比如 { a: { b: { c: 5 } } },而它甚至僅僅只是一些更大的物件的一部分:

const bigobj = {
  one: { a: { b: { c: 5 } } },
  two: { a: { b: { c: 6 } } }
}

lenses 的一大特性就是你可以組合 lenses(把它們串聯起來)。假設你定義了一個 lens 用來把屬性 c 從物件 { a: { b: { c: 5 } } } 解構出來,那麼在 bigobjonetwo 這兩個部分上,你都能複用這個 lens。

// 該 lens 用於操作物件 { a: { b: { c: 5 } } }` 裡深度巢狀的屬性 c
const abc: Lens<...> = ...

// 該 lens 用於訪問 `bigobj` 的一部分: `one`
const one: Lens<typeof bigobj, ...> = ...

// 該 lens 用於訪問 `bigobj` 的一部分: `two`
const two: Lens<typeof bigobj, ...> = ...

// 把 lens `one` 或 `two` 和 lens `abc` 組合起來
// 然後我們可以在結構類似為
// `{ one: { a: { b: { c: 5 } } } }` 或 `{ two: { a: { b: { c: 5 } } } }`
// 的資料中操作 c
const oneC = one.compose(abc)
const twoC = two.compose(abc)

console.log(oneC.get(bigobj))
// => 5

console.log(twoC.get(bigobj))
// => 6

console.log(oneC.set(7, bigobj))
// => { one: { a: { b: { c: 7 } } }, two: { a: { b: { c: 6 } } } }

Focal 也提供了相當方便的定義這些 lenses 的手段。

// 只需要定義一個 getter 函式就可以建立上述的 lenses¹
const abc = Lens.prop((obj: typeof bigobj.one) => obj.a.b.c)

const one = Lens.prop((obj: typeof bigobj) => obj.one)

const two = Lens.prop((obj: typeof bigobj) => obj.two)

// ¹ 注意使用限制!(RESTRICTIONS APPLY!)
// 在這個例子裡,getter 函式只能是一個簡單的屬性路徑訪問函式
// 該函式僅包括一個屬性訪問表示式,沒有副作用 (side effects)

其中最棒的一點是,這種方式是完全型別安全的,所有的 IDE 工具(比如說自動補全、命名重構等)都仍然有效。

可能比較奇怪的一點是,lens 照道理應該還可以修改該值,但我們只定義了一個 getter 函式。這確實不可思議,因為我們在這裡施了點魔法。但是,這隻能被視為一個實現細節,因為這些特性在將來可能在 TypeScript 編譯器中就過時了。

簡單解釋下,我們用的方案可能類似於 WPF 裡用來實現型別安全的 INotifyPropertyChanged 介面的標準實踐。我們通過呼叫 .toString 函式,把 getter 函式轉換成一個字串,然後根據函式的原始碼解析出屬性的訪問路徑。這種實現方式比較 hacky ,還有著明顯的限制,不過還是很有效的。

關於 lenses 的更多資料

希望上一章能讓你稍微領略一下 lenses 的威力,當然你還可以用這個抽象來做更多的事情。遺憾的是我們沒法在這個簡短的教程裡覆蓋 lens 所有有趣的部分。

不幸的是,大部分關於 lenses 的文章和介紹都是用 Haskell 來描述的。這是因為大部分對 lenses 的研究來自於 Haskell。不過很多其它語言也採用了 lenses ,包括 Scala, F#, OCaml, PureScript 和 Elm 等。

Atoms 和 lenses

好,言歸正傳。到此為止,我們已經知道了如何管理應用狀態資料,如何把狀態資料嵌入到我們的 UI 層程式碼中。

我們還學習瞭如何抽象對不可變資料的操作,以便方便地對大型的不可變物件的部分進行操作。我們正是需要用它來拆分應用的狀態資料。我們想要這樣構造我們的應用:UI 元件的各部分僅和整個應用狀態資料中和它有關的那部分互動。

為了實現這個目的,我們可以通過結合 atom 和 lens 來生成 lensed atom。

Lensed atom 也還是一個 Atom<T>,或者說從表面來看,它的表現和行為也和別的 atom 幾乎一樣。區別在於它的建立方式:lensed atom 操作於其它 atom 的一部分 state。這意味著,如果你通過 .set.modify 來設定或修改一個 lensed atom 的值,那麼源 atom 上與該 lensed atom 對應的這部分的值也會隨之改變。比如:

import { Atom, Lens } from `@grammarly/focal`

// 建立一個維護我們所需物件(的值)的 atom
const obj = Atom.create({
  a: 5
})

// 建立一個觀察屬性 a 的 lens
const a = Lens.prop((x: typeof obj) => x.a)

// 建立一個 lensed atom,這個 lensed atom 會維護物件 obj 的屬性 a 的值
const lensed = obj.lens(a)

console.log(obj.get())
// => { a: 5 }

console.log(lensed.get())
// => 5

// 為 lensed atom 設定新值
lensed.set(6)

console.log(obj.get())
// => { a: 6 }

注意,當我們為 lensed atom 設定新值的時候,源 atom 的值是如何變化的。

我們還有一種更簡潔的方法來建立 lensed atom:

const lensed = obj.lens(x => x.a) // ¹

// ¹ 還是要注意使用限制 SAME RESTRICTIONS APPLY!
// 和 `Lens.prop` 方法一樣,atom 的 `lens` 方法接受一個 getter 函式作為引數,
// 這個 getter 函式只能是一個簡單的屬性路徑訪問函式,
// 它僅包括一個屬性訪問表示式,沒有副作用。

我們無需顯式地去建立 lens,atom 的 lens 方法已經提供了幾個過載來幫助你立即建立 lensed atom。另外需要注意的是,我們不需要在此為物件新增型別標註,編譯器已經知道了我們正在操作的資料的型別,並且為我們自動推斷出來(比如在上面那個例子裡,根據 obj 的型別 Atom<{ a: number }>,編譯器可以自動推斷出 x 的型別)

基於這種能力,現在我們可以拆分應用的單一資料來源為幾個小的部分,使其適用於獨立的 UI 元件中。讓我們來嘗試把這一方案用在上述的計數器例子中:

import * as React from `react`
import * as ReactDOM from `react-dom`
import { Atom, F } from `@grammarly/focal`

// 應用的狀態資料
const state = Atom.create({
  count: 0
})

// 原先寫好的計數器元件
const Counter = (props: { count: Atom<number> }) =>
  <F.div>
    Count: {props.count}.

    <button onClick={() => props.count.modify(x => x + 1)}>
      Click again!
    </button>
  </F.div>

// app 元件,其 prop.state 攜帶整個應用的狀態資料
const App = (props: { state: Atom<{ count: number }> }) =>
  <div>
    Hi, here`s a counter:

    {/*
      在此,我們拆分應用狀態資料,把其中的一部分給 counter 元件使用
    */}
    <Counter count={props.state.lens(x => x.count)} />
  </div>

我們就用這個例子作為 Focal 基礎教程的總結吧。

希望你現在能理順上面這些東西是如何結合起來的。另外,還請務必看看一些其它例子
。嘗試搭建並嘗試跑通它們,方便進一步瞭解你可以用 focal 來做什麼。

這是一個框架嗎?

Focal 不是一個框架,換句話說,它並不限制你非要用要某種特定的方式來編寫整個應用。Focal 提供了命令式的介面 (回想下,你可以用 .set.modify 方法來操作 atom ),並且可以完美地配合原生的 React 元件。這意味著,在同一個應用裡,你可以只在某些部分使用 Focal。

效能

儘管我們還沒有建立一套全面的評測基準 (benchmarks),目前為止,在類似 TodoMVC 的例子中,Focal 的效能表現至少近似於原生 React。

一般來說,當一個被嵌入到 React 元件裡的 Atom<T>Observable<T> 觸發一個新值時,元件中只有相關的那部分會被更新。

這意味著,在一個複雜的 React 元件中,如果你在該樹某處相當深的可見部位,有一個頻繁變更的值,那麼當該值變化時,只有對應的那部分會更新,而不是整個元件樹都會更新。在很多場景下,這使得我們很容易為 VDOM 的重計算做優化。

商業應用

JavaScript 支援

儘管從技術上來說可以把 Focal 用於純 Javascript 專案,但是我們還沒嘗試過這樣做。所以,如果你在搭建這種專案時遇到了問題,歡迎前來提交 issues。

Prior art

Focal 起初只是想把 Calmm 轉接到 TypeScript ,但隨後我們因為一些顯著的差異而放棄了。

一開始,我們更專注於快速開發產品和型別安全。基於此,許多東西都被簡化了,所以在當時(TypeScript 版本為 1.8 時)Focal 還很難和型別系統搭配得很好,API 也不夠直觀,也很難讓新入門函數語言程式設計的 React 老使用者快速上手。

和 Calmm 的區別

  • Calmm 是模組化的,由幾個獨立的庫組成。而 Focal 沒必要模組化,因為我們只有一種使用場景,所以我們只需要在一個庫裡維護所有東西。
  • Calmm 最初大量藉助 Ramda 的 curry 和 Partial Application。這不利於搭配型別系統,所以我們決定放棄這種做法。不過隨著 TypeScript 編譯器的進步,現在要去實現上面那種做法可能變得容易多了,所以這也許會是一個有趣的話題。
  • Calmm 最初還借用了 Ramda 裡的 lens ,這種 lens 使用的是 van Laarhoven 表示法。相反,Focal 使用的是含有一對 getter/setter 的 naїve 表示法。由於我們無需去做遍歷或多型更新 (traversals or polymorphic updates),所以這對我們來說足夠了。不過有可能我們會在以後重新考慮這個問題。
  • Calmm 的主要實現 (kefir.atomkefir.react.html) 都基於 Kefir 的 observables。一開始我們也用 Kefir,不過很快遷移為 RxJS 5.x。最主要的原因是,RxJS 功能更豐富,它有一些 Kefir 還不支援的對 observables 的操作。

相關文章