[译] Focal:类型安全、表达力强、可组合的状态管理方案

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 的操作。
    原文作者:tinkgu
    原文地址: https://segmentfault.com/a/1190000010871867
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞