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

Focal

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

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

Example

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

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> 是一個持有一個單一不可變值的數據單元。它的寫法是:java

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 來看待。react

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 都擁有這些屬性:ios

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

單一數據源 Single source of truth

在 Focal 中,咱們用 Atom<T> 來做爲應用 state 的數據源,Focal 提供了多種方法來建立 Atom<T>Atom.create 就是其中一種,咱們能夠用它來建立應用的根 state。
理想狀況下,咱們指望應用的 state 都來自一個單一數據源,後面咱們會討論如何用這種新方法來管理應用的 state 數據。git

數據綁定 Data binding

咱們已經瞭解瞭如何建立、修改和訂閱應用的 state 數據。下面咱們須要瞭解如何展現這種數據,從而幫助咱們有效地編寫 React UI。es6

Focal 容許你直接把 Atom<T> 嵌入到 JSX 中。實踐中,這種方式和 Angular 的數據綁定有點像。
不過它們仍是不太同樣:github

  • 在 Focal 裏描述數據操做時,你編寫的就是標準的 JavaScript 或 TypeScript 代碼,而沒必要像 Vue 那樣須要藉助模板引擎語法。Focal 在語法層面上沒有魔法,因此你原來的語言工具棧均可以繼續使用。
  • 既然 Focal 的數據綁定本質仍是原生的 TypeScript (or JavaScript) 表達式,你的 IDE 特性就不會失效,好比說自動補全、跳轉定義、命名重構、用法搜索等。比起模板引擎來講,UI 層代碼維護起來更簡單。
  • 你能夠繼續享受相似於 TypeScript 這樣的靜態分析工具帶來的好處。所以你的 UI 代碼將和其它代碼同樣可靠。
  • 數據(指 Atom<T>)變化,觸發 render。除此之外,別無它法。
    一般來講,你不須要考慮組件什麼時候被渲染,一切皆由 Focal 自動處理。

說了這麼多,咱們看看實際寫起代碼來到底怎麼樣:typescript

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 的操做。
相關文章
相關標籤/搜索