资讯专栏INFORMATION COLUMN

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

suemi / 720人阅读

摘要:致力于为应用提供一个类型安全表达力强可组合的状态管理方案。是一组的命名空间。是内置组件的镜像,但允许组件的额外接受类型的数据。这次内容更新,是由组件处理的。这些小的组件不必知道所有的应用状态数据。这是因为大部分对的研究来自于。

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 }) =>
  
    {/* use observable state directly in JSX */}
    You have clicked this button {props.count} time(s).

    
  

// the main "app" UI component
const App = (props: { state: Atom<{ count: number }> }) =>
  
Hello, world! x.count) } />
// 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( , document.getElementById("app") )
Tutorial

在 Focal 中,state 被存储在 Atom 中。 Atom 是一个持有一个单一不可变值的数据单元。它的写法是:

import { Atom } from "@grammarly/focal"

// 创建一个初始值为 0 的 Atom
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 的值的变化(值变化时得到通知)。这意味着,你可以把 Atom 当作响应式变量 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 来作为应用 state 的数据源,Focal 提供了多种方法来创建 AtomAtom.create 就是其中一种,我们可以用它来创建应用的根 state。
理想情况下,我们期望应用的 state 都来自一个单一数据源,后面我们会讨论如何用这种新方法来管理应用的 state 数据。

数据绑定 Data binding

我们已经了解了如何创建、修改和订阅应用的 state 数据。下面我们需要了解如何展示这种数据,从而帮助我们有效地编写 React UI。

Focal 允许你直接把 Atom 嵌入到 JSX 中。实践中,这种方式和 Angular 的数据绑定有点像。
不过它们还是不太一样:

在 Focal 里描述数据操作时,你编写的就是标准的 JavaScript 或 TypeScript 代码,而不必像 Vue 那样需要借助模板引擎语法。Focal 在语法层面上没有魔法,所以你原来的语言工具栈都可以继续使用。

既然 Focal 的数据绑定本质还是原生的 TypeScript (or JavaScript) 表达式,你的 IDE 特性就不会失效,比如说自动补全、跳转定义、命名重构、用法搜索等。比起模板引擎来说,UI 层代码维护起来更简单。

你可以继续享受类似于 TypeScript 这样的静态分析工具带来的好处。因此你的 UI 代码将和其它代码一样可靠。

数据(指 Atom)变化,触发 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 的 stateless 组件
const Counter = (props: { count: Atom }) =>
  
    {/* 直接把 state atom 嵌入到 JSX 里 */}
    Count: {count}
  

ReactDOM.render(
  ,
  document.getElementById("test")
)

// => 
Count: 0

那么问题来了,这跟平常我们写 React 有什么不同呢?

F-component

在 Focal 里,我们用 来代替一般的

标签。

React 本来就允许你在 JSX 中嵌入 js 代码,但是它有诸多限制,会把表达式转为字符串或其它 React elements。

F-component 就不一样。F 是一组 lifted componenets 的命名空间。lifted component 是 React 内置组件的镜像,但允许组件的 props 额外接受 Atom 类型的数据。

我们知道,一个 React JSX 元素中,它的子元素内容会被解析为 children prop。Focal 所做的就是支持嵌入 Atom 作为组件的子元素内容。

好了,让我们来试试修改 state 的值:

// 下面这行代码将修改 atom `count` 的当前值。
// 因为我们在 `Counter` 组件中使用了这个 atom `count`,所以修改了它的值后会使得组件更新
count.set(5)

// => 
Count: 5

你可能已经发现了,我们并没有修改任何的 React 组件的 state (即没有通过 Component.setState 的方式),但 Counter 还是不可思议地渲染了新内容。
实际上,从 React 的角度来说,Counter 组件的 propsstate 都没有改变,照道理这个组件也不会被更新渲染。

这次内容更新,是由 组件处理的。同理,换成其它 lifted component (或者说 F-component) 也会得到一样的效果。F-component 会监听 (.subscribe) 它所有的 Atom props,一旦 prop 的值发生改变,就会 render。

那么根据这个原理,修改 count 的值以后,子元素 随之更新渲染,而 则不会。

view

下面我们来编写稍微复杂一点的计数器组件。

// 给我们的计数器组件加点佐料
const Counter = (props: { count: Atom }) =>
  
    Count: {count}.
    {/* 输出当前计数的奇偶性 */}
    That"s an {count.view(x => x % 2 === 0 ? "even" : "odd")} number!
  

// => 
Count: 5. That"s an odd number!

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

创建一个 view 本质上是创建了一个 atom,这个 atom 输出 state 时,可以表现为它经过修改后的值,对其修改的操作逻辑定义在 view 函数中。
这实际上和 arrayObservablemap 方法差不多,主要的区别在于,和原生的 atom 一样,这种衍生 atom (被称为 view )只会在新值和当前值不相等时才响应新值。

我们再看一个例子

const Counter = (props: { count: Atom }) =>
   `rgba(255, 0, 0, ${Math.min(16 * x, 255)})`)
    }}
  >
    Count: {count}.
    That"s an {count.view(x => x % 2 === 0 ? "even" : "odd")} number!
  

// => 
Count: 5. That"s an odd number!

在这里,我们用 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 {
  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 = ...

// 该 lens 用于访问 `bigobj` 的一部分: `two`
const two: Lens = ...

// 把 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 等。

Program imperatively using Haskell lenses

WikiBooks article on Haskell lenses

School of Haskell lens tutorial

lens over tea blog series

Atoms 和 lenses

好,言归正传。到此为止,我们已经知道了如何管理应用状态数据,如何把状态数据嵌入到我们的 UI 层代码中。

我们还学习了如何抽象对不可变数据的操作,以便方便地对大型的不可变对象的部分进行操作。我们正是需要用它来拆分应用的状态数据。我们想要这样构造我们的应用:UI 组件的各部分仅和整个应用状态数据中和它有关的那部分交互。

为了实现这个目的,我们可以通过结合 atom 和 lens 来生成 lensed atom。

Lensed atom 也还是一个 Atom,或者说从表面来看,它的表现和行为也和别的 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 }) =>
  
    Count: {props.count}.

    
  

// app 组件,其 prop.state 携带整个应用的状态数据
const App = (props: { state: Atom<{ count: number }> }) =>
  
Hi, here"s a counter: {/* 在此,我们拆分应用状态数据,把其中的一部分给 counter 组件使用 */} x.count)} />

我们就用这个例子作为 Focal 基础教程的总结吧。

希望你现在能理顺上面这些东西是如何结合起来的。另外,还请务必看看一些其它例子
。尝试搭建并尝试跑通它们,方便进一步了解你可以用 focal 来做什么。

这是一个框架吗?

Focal 不是一个框架,换句话说,它并不限制你非要用要某种特定的方式来编写整个应用。Focal 提供了命令式的接口 (回想下,你可以用 .set.modify 方法来操作 atom ),并且可以完美地配合原生的 React 组件。这意味着,在同一个应用里,你可以只在某些部分使用 Focal。

性能

尽管我们还没有建立一套全面的评测基准 (benchmarks),目前为止,在类似 TodoMVC 的例子中,Focal 的性能表现至少近似于原生 React。

一般来说,当一个被嵌入到 React 组件里的 AtomObservable 触发一个新值时,组件中只有相关的那部分会被更新。

这意味着,在一个复杂的 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.atom 和 kefir.react.html) 都基于 Kefir 的 observables。一开始我们也用 Kefir,不过很快迁移为 RxJS 5.x。最主要的原因是,RxJS 功能更丰富,它有一些 Kefir 还不支持的对 observables 的操作。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/84957.html

相关文章

  • React组件设计实践总结05 - 状态管理

    摘要:要求通过要求数据变更函数使用装饰或放在函数中,目的就是让状态的变更根据可预测性单向数据流。同一份数据需要响应到多个视图,且被多个视图进行变更需要维护全局状态,并在他们变动时响应到视图数据流变得复杂,组件本身已经无法驾驭。今天是 520,这是本系列最后一篇文章,主要涵盖 React 状态管理的相关方案。 前几篇文章在掘金首发基本石沉大海, 没什么阅读量. 可能是文章篇幅太长了?掘金值太低了? ...

    ideaa 评论0 收藏0
  • 】每个JavaScript 开发者应该了解10个面试题

    摘要:避免脆弱的基类问题。红牌警告没有提到上述任何问题。单向数据流意味着模型是单一的事实来源。单向数据流是确定性的,而双向绑定可能导致更难以遵循和理解的副作用。原文地址 1. 你能说出两种对 JavaScript 应用开发者而言的编程范式吗? 希望听到: 2. 什么是函数编程? 希望听到: 3. 类继承和原型继承的不同? 希望听到 4. 函数式编程和面向对象编程的优缺点? ...

    mykurisu 评论0 收藏0
  • PHP / Laravel API 开发推荐阅读清单

    showImg(https://segmentfault.com/img/bV6aHV?w=1280&h=800); 社区优秀文章 Laravel 5.5+passport 放弃 dingo 开发 API 实战,让 API 开发更省心 - 自造车轮。 API 文档神器 Swagger 介绍及在 PHP 项目中使用 - API 文档撰写方案 推荐 Laravel API 项目必须使用的 8 个...

    shmily 评论0 收藏0
  • 打造Win10下完美Linux体验(WSL2+WindowsTerminal+oh-my-zsh),

    摘要:以管理员身份打开分别输入输入完成后重启电脑,以完成安装并更新到。将设置为默认版本在微软商店内下载分发版,这里我下载的是。且被视为管理员,能够运行管理命令。 目录 ...

    孙淑建 评论0 收藏0
  • []Mixin 函数

    摘要:函数通常是面向对象编程风格,具有副作用。因为在函数式编程中,很有可能这些引用指向的并不是同一个对象。记住,函数并不意味着函数式编程。函数可以用函数式编程风格编写,避免副作用并不修改参数,但这并不保证。 软件构建系列 原文链接:Functional Mixins 译者注:在编程中,mixin 类似于一个固有名词,可以理解为混合或混入,通常不进行直译,本文也是同样。 这是软件构建系列教...

    zxhaaa 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<