Focal 致力于为 React 应用提供一个类型安全、表达力强、可组合的状态管理方案。javascript
immutable
) 、响应式的 (observable
) 单一数据源,来表达整个应用的 state.Rx.JS
的威力,来加强、组合应用的 state,来精确控制数据流咱们将经过一个经典的计数器的例子,来展示 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') )
在 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
都拥有这些属性:ios
.subscribe
d),当即触发响应,返回当前值( emit the current value)在 Focal 中,咱们用 Atom<T>
来做为应用 state 的数据源,Focal 提供了多种方法来建立 Atom<T>
, Atom.create
就是其中一种,咱们能够用它来建立应用的根 state。
理想状况下,咱们指望应用的 state 都来自一个单一数据源,后面咱们会讨论如何用这种新方法来管理应用的 state 数据。git
咱们已经了解了如何建立、修改和订阅应用的 state 数据。下面咱们须要了解如何展现这种数据,从而帮助咱们有效地编写 React UI。es6
Focal 容许你直接把 Atom<T>
嵌入到 JSX 中。实践中,这种方式和 Angular
的数据绑定有点像。
不过它们仍是不太同样:github
Atom<T>
)变化,触发 render。除此之外,别无它法。说了这么多,咱们看看实际写起代码来到底怎么样: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
组件的 props
和 state
都没有改变,照道理这个组件也不会被更新渲染。
此次内容更新,是由 <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 函数中。
这实际上和 array
或 Observable
的 map
方法差很少,主要的区别在于,和原生的 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 层。接下来,为了使用它们来构建规模更大更复杂,同时又不致于分崩离析的应用,咱们还须要两样东西:
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
的能够参考维基 Haskell/Lens)
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 } } }
解构出来,那么在 bigobj
的 one
和 two
这两个部分上,你都能复用这个 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 的威力,固然你还能够用这个抽象来作更多的事情。遗憾的是咱们无法在这个简短的教程里覆盖 lens 全部有趣的部分。
不幸的是,大部分关于 lenses 的文章和介绍都是用 Haskell 来描述的。这是由于大部分对 lenses 的研究来自于 Haskell。不过不少其它语言也采用了 lenses ,包括 Scala, F#, OCaml, PureScript 和 Elm 等。
好,言归正传。到此为止,咱们已经知道了如何管理应用状态数据,如何把状态数据嵌入到咱们的 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 的重计算作优化。
略
尽管从技术上来讲能够把 Focal 用于纯 Javascript 项目,可是咱们还没尝试过这样作。因此,若是你在搭建这种项目时遇到了问题,欢迎前来提交 issues。
Focal 起初只是想把 Calmm 转接到 TypeScript ,但随后咱们由于一些显著的差别而放弃了。
一开始,咱们更专一于快速开发产品和类型安全。基于此,许多东西都被简化了,因此在当时(TypeScript 版本为 1.8 时)Focal 还很难和类型系统搭配得很好,API 也不够直观,也很难让新入门函数式编程的 React 老用户快速上手。