React — 端的编程范式

in 开发 with 0 comment

dvajs 是 Alibaba 针对于 react/redux 技术栈基于 elm 概念编写的一套脚手架。

两年前因为 antd 开始接触了这套脚手架。我的确很需要这套脚手架,对于新手来说,整合 react / redux / react-redux / react-router / react-router-redux 的确还是蛮费劲的 —— 如果像我这么偷懒,可能都没办法了解它们是什么。

当然,很多高阶的工具至少经过了前人深思熟虑后才造出来的,当我在 macOS / iOS 开发碰到困难的时候,我就再一次想起了他们:

  1. react 最开始解决了单页面组件化的问题,组件与组件之间的状态管理却没有解决。
  2. redux 解决了单页面状态管理的问题,提供了通用的方案。
  3. react / redux 自然而然的结合在了一起,或者说他们一开始就是眉来眼去的。

One Page Application

一切的一切,都开始于前端的一个特殊的概念 OPA (One Page Application),即单页面应用,白话就是在一个页面里面实现一个完整的应用,好处显而易见:再也不用等待白屏加载你的页面了,业务和业务之间的切换也流畅自然,这在现代前端领域里面已经达成了深刻的共识。但是前端原先存在诸多工程问题没有解决:庞大的组织里面如何分工协作?代码如何管理?组件如何复用?当然这些问题在客户端开发看来完全不是问题,一个 Activity / ViewController 即可解决所有问题,不行就再来一个,再不行我们就开始嵌套着来 —— 我们又没有白屏问题。

React Component 的出现相当于为前端提供了一个 View 级别的 namespace,它的粒度就是一个视觉组件,包含了这个视觉样式,同时也提供了事件响应模型等等。至此,前端开发和所有 Native 客户端开发(包含 Desktop 的广义客户端开发)站在了同一个起跑线上 —— 终于可以为一个组件做一个命名了,依赖 Virtual DOM 或者 Web Component 的形式。DOM 没有问题了,样式的独立可以采用命名或者 scoped css 的方式解决,这个是小问题。

以上,是前端领域解决的第一个大问题,如果视图组件可以抽象成一个类,那么组件就可以共享,页面的开发从简单的 html 标签改为业务复用 View 组件,整个开发流程从平行开始变得立体。

React

State

View 一定是存在状态的,什么叫状态?如果我们不给一个 View 传入一个外部的值,随着事件的产生,View 自己的某些属性也会发生改变,这些属性我们称之为状态。这些事件是因为交互产生,某个组件集合内部,因为某个交互(比如进入这个页面)去访问了网络,网络下载来的数据填充了这个 View,那么这些数据就容易是状态的一部分。

状态不是一个数据,它是一组数据。这是一个很重要的概念。一组数据意味着两次状态之间的某些数据是不能互相组合的。比如 { a: 1, b: 2 } 是合法的一组, { a: 3, b: 4 } 是合法的一组。那么 { a: 1, b : 4 } 如果不是我们业务中存在的组合的话,在代码的任意时刻,我们的 View 状态都不应该存在这种可能。基于以上法则,我们引入了 immutable 这个概念。

Immutable

Immutable 和很多范式结合在一起使用,最经典比如函数式编程(Functional Programming),我们知道 Pure Function 是不存在并发问题的,因为输入和输出对于外部环境不会产生任何副作用。那么产生副作用的可能有两种:一是访问了外部资源,二是对已经存在的对象产生了修改。

如果我们一定要对一个已经存在的 Immutable Object 进行修改怎么办呢?非常简单,我们使用 CopyOnWrite 的策略返回出去就行。这样依然保证了输入和输出是恒定的,同时对外部环境不会产生影响,函数的“纯洁性”得到了保证。Immutable 相关的库有很多,js 有 Immutable.js,java 有 Google AutoValue,guava 里面也有相关的实现。

Pure Function 的概念为我们代码的可测试性和可维护性提供了很好的方向,如果可能的话,我们希望我们所有的函数都是 Pure Function,就像 TDD 一样,是我们亘古不变追求的目标。

那么在 React 中,setState 这个 API,就是我们说的 Immutable 的一个展现。在 Immutable 设计模式中,如果你把历史状态自己保存一份的话,这个历史状态便可以随时回溯 —— 我们的编辑器里面就有这么一个状态机,我们做 Undo 和 Redo 的时候,这个状态机非常重要。

Immutable

Redux

在使用状态的过程中,我们碰到了一个问题,我们的组件要和别的组件进行一些联动,一般来说,我们采取的方案是和别的组件进行一定程度的引用 —— 通过 callback,那么 macOS / iOS 里面比较常见的就是 delegate。React 一开始也可以通过 callback 的方式把数据反哺出去。但是如果有多个组件需要这个数据的话,我们可能甚至要做到把 callback 一层一层传递进去,像这样(伪代码):

<A callback=this.cb>
    <B callback=a.callback >
        <C callback=b.callback>
        </C>
    </B>
</A>

可能明明是个业务性的全局数据,硬是要用这种方式去传。有 ViewController 和 Activity 其实这个问题还不算特别地明显,因为不同场景下可以使用不同的 ViewController,子流程的数据和父流程的数据可以使用构造函数的方式进行隔离。在前端 OPA 中不同的业务流程如果需要使用同一个状态就很麻烦了。

这时候我们有了 Redux,它的官网宣传 4 个特性:

  1. 可预测:行为一致性
  2. 中心化:状态持久化
  3. 可调试:「时空旅行式」调试
  4. 扩展性:插件生态

Redux

以上 1 和 3 特性我们可以很简单的用 Immutable 来涵盖。2 的话是 Redux 提供了一个全局 Store 来搞定这件事(这也太简单了吧),Store 这个状态管理非常有用,因为我们完全可以在服务端渲染这个页面的时候,就初始化这个 Store,使用全局变量的方式直接给浏览器的 Response 中赋值。这样在我们进行 Server Rendering 的时候,不用通过状态迁移,就可以获得最终状态,再一次提升了前端渲染的效率。

React-Redux

Redux 概念提出后,就自然而然地出现了 React-Redux 项目。它的作用只有一个,把 Redux 的 Store 自然而然地融入到 React 的生态中去。提供的 API 非常简单且有用,通过connect()这个 API,生成高阶组件(High-Order-Component)的方式,为每个 React 组件注入 Store,connect() 原型如下:

function connect(mapStateToProps?, mapDispatchToProps?, mergeProps?, options?)

https://react-redux.js.org/api/connect

这个函数其实看名字特别简单,4个参数都是可选,它的返回值是一个高阶函数,高阶函数的形参就是你自己的 ReactComponent,封装这个 ReactComponent 产生一个高阶 Component 给业务方使用,我们简单聊一聊 connect 函数。

我们知道,Redux 有个 Store,这个 Store 里面存的是全局的 State,那么如何使用这个全局的 State?业务组件能不能只关心这个全局 State 中自己的部分?这个事情就是mapStateToProps这个形参做的事了,它是一个函数,原型是这样(state) -> props

我们知道每一个 ReactComponent 都是有一些属性(Props)的,这些属性和状态不同,它是不可变的(Immutable),既然有 Pure Function 的概念,我们也可以有 Pure Component 的概念,对应 Flutter 里面的 Stateless Component 这个概念 —— 只有 Props,没有 State,这些组件越多越好。
那么当全局的 State 发生改变的时候,我们就需要一个函数用来把这个全局的 State 映射成当前组件的 Props,这个工作就是mapStateToProps来完成,每个组件只关心 State 中和自己有关的部分就好了。

通过上面的方式,我们就完成了一个组件对全局状态改变从而影响全局 View 的途径。

那么如何产出这个动作呢?React-Redux 为我们引入了一个函数叫dispatch,dispatch 调用的内容就是 action 和它的形参。这个理解其实很简单,我们可以简单的理解成 dispatch 调用了 fun.bind(xxx),那么这个动作对全局的 State 会有影响,会生成一个新的 State,状态机会往下走一步。使用 React-Redux 的应用程序经常看见的代码就是:

dispatch({type: “INCREMENT”, value: 1});

实质上是调用一个和 INCREMENT 相关联的纯函数,这个函数接受形参和 previous state,返回一个新的 state:

function action(state, params) {
    // ....
    return { ...state, xxxx }
}

然后返回的 State 会被 Store 存起来,同时所有被 connect 的 Component 会收到一个通知用来更改自己的 Properties。

这,就是在没有网络环境下 React-Redux 的逻辑闭环,以上逻辑闭环我们通常会这么描述:

component -> action -> reducer -> state 的单向数据流转问题。

Side Effects

一旦接入了 API 调用,我们的逻辑一下子就复杂起来了,因为 RPC 的调用基本不可预测,你哪怕是调用幂等的接口,你也有可能因为网络的不通畅导致我们的状态机进入的 State 开始变得不唯一了

State Machine

没错,万恶的 API,它不 Pure 了。注意,这还仅仅是幂等接口的情况下,如果是不幂等的接口,那状态可能更多。
破坏 Pure Function 最大的第一个问题就是函数的可测试性被破坏了(Testable),这时候你想写测试用例的话,assert() 根本不知道怎么去写,因为你也不知道它的返回值是什么。

首先为了解决异步调用的问题(action 需要异步获取数据),有很多 library 选择:

关于 dva 的为什么选择 redux-saga,可以看看支付宝这边的理由: https://github.com/sorrycc/blog/issues/6

redux-thunk 和 redux-promise 改变了 action 的含义,action 变得不那么纯粹(Pure)

他们都为 action 带来了副作用。那么看看 redux-saga 是怎么解决这个问题的。

redux-saga

https://redux-saga.js.org/

上面是 redux-saga 的首页。

saga 最核心的解决方式是使用 Generator 为我们的不确定性增加了一分确定,我们在需要调用 API 的接口中,我们可以通过 Generator 拿到通过了分支逻辑调用出去的一个状态 —— 不管这个异步调用的返回值是什么,我们能拿到发出这个异步调用的一个动作,Saga 把这件事称为:声明式副作用(Declarative Effects)

https://redux-saga.js.org/docs/basics/DeclarativeEffects.html

我们可以看下如何能拿到刚刚说的的东西,首先它抛出一个测试上的问题。

function* fetchProducts() {
  const products = yield Api.fetch('/products')
  console.log(products)
}

const iterator = fetchProducts()
assert.deepEqual(iterator.next().value, ??) // what do we expect ?

这是我们刚刚提的问题,我们期望的值是什么?我们如果想确定这个值,有两种方式:

  1. 连接真正的服务器
  2. mock 数据

那么在测试中,使用 1 的方式进行测试是非常愚蠢的(你怎么测试「注册」这个接口?因为不幂等)。
那么只能使用 mock,mock这件事其实是下策,mock 使我们的测试变得困难且不可靠,如果我们业务改了,mock 的代码还要改,这样工作量就提升了很多,非常吃力。

那么 saga 参考了Eric Elliott 的文章,原话是:

(...)equal(), by nature answers the two most important questions every unit test must answer, but most don’t:

What is the actual output?
What is the expected output?
If you finish a test without answering those two questions, you don’t have a real unit test. You have a sloppy, half-baked test.

翻译过来,就是我们需要考虑清楚到底什么是真正的输出和期望的输出。我们可以不根据业务的实际结果,我们去测试 API 接口的时候,只期望能输入正确的,符合我们和后端文档定义的参数就行。因为业务返回结果不是前端能决定的,这个决定方是 API 提供方,他们要通过他们的测试保证在网络正常的情况下,符合接口文档的定义。
注意,我们前端关注的是,事件响应对于 API 调用的行为,因为 redux-saga 是基于 Generator 的,这个行为变得很好获取,我们的 assert 就变成了:

import { call } from 'redux-saga/effects'
import Api from '...'

const iterator = fetchProducts()

// expects a call instruction
assert.deepEqual(
  iterator.next().value,
  call(Api.fetch, '/products'),
  "fetchProducts should yield an Effect call(Api.fetch, './products')"
)

我们期望它发生了一次对于/producs这个 API 接口的调用。
这样,我们就不需要 mock 任何接口就搞定了这件事,结果可能不一致,但是行为一定是一致的,通过行为的一致,我们保证了 action 的纯粹性:

输入一致的参数,输出了一样的结果(行为)。

大家这里可以细细品一下,我再把刚刚「声明式 Effects」的链接贴一下:

https://redux-saga.js.org/docs/basics/DeclarativeEffects.html

dva

那么以上介绍了 React, Redux, React-Redux 和 React-Saga。 dva 事实上是对以上几个组件的封装,当然我这边不再讲 react-router 这种前端路由的东西,我相信大家都还好理解。

引入 saga 和 router 解决了纯函数的问题,也诞生了新的问题:

  1. Redux 项目模块太分散
  2. 创建 saga 非常麻烦,这个看文档大家就清楚

这部分在支付宝前端应用架构的发展和选择里面有讲到,dva 把这些逻辑进行了封装,使用声明式路由和 model 的方案解决了以上的两个问题,让我们更爽地使用以上一整套方案。

理解完 redux-saga 之后,使用 dva 能让工程效率提升不少。

客户端开发者的困境

客户端,或者说 native client 开发者因为没有 function first-class 这种语言级别的待遇(可能)和冗长的流程,使得我们对于数据流的思考远远不如前端同学的多,从 Android LiveData 和 Flutter 这样的组件开发可以看出来,从来都是大厂主导,大家学习这么个进程来的,再怎么说,前端领域还是出现了像 Vue.js 这种“民间”组织出来的框架,虽然有 Google Angular 和 Facebook React,但是民间力量不容小觑。

Android 的 LiveData / LifeCycle 其实很多参考了 React 的编程模型,那么 Flutter 就更不用说了,API 的设计以及文档都已经说了是 React 模型下的产物。看来 React 的组件化和状态的概念已经深入人(大厂)心,加上 React 有 Redux,Flutter 有 fish-redux 也解决了状态管理的问题。

愁的就是 Native 端了,LiveData / LifeCycle 远没有把状态管理做好,RecyclerView 配合 Paging Library 使用的时候,加载更多这个动作竟然没办法通知到全局。iOS / macOS 的 SwiftUI 遥遥无期(算了。。不吐槽了,你懂的), native 任重而道远。

React

总结

以上这么多碎碎念和知识普及希望能抛砖引玉,因为我这几天作为一个 macOS 开发新手,实在是受不了超多层的 delegate,因此突然很怀念两年前写 dva 那种行云流水的感觉。希望 SwiftUI 能尽快成熟,但同时也希望 Apple 领域能从 MVC 这种很(老)稳(掉)固(牙)的设计模式中尽可能的有创新,带给更多开发者耳目一新的感觉,不然你凭啥阻止 Flutter 在 AppStore 发布应用呢?

欢迎关注我的公众号「TalkWithMobile」
公众号

Responses