React 端的编程范式

react

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 组件,整个开发流程从平行开始变得立体。

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 的时候,这个状态机非常重要。

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. 扩展性:插件生态

以上 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/ap...

这个函数其实看名字特别简单,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 开始变得不唯一了

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

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

  • redux-thunk
  • redux-promise
  • redux-saga

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

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/doc...

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

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/doc

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 任重而道远。

资源搜索网站大全https://55wd.com

广州品牌设计公司http://www.maiqicn.com

总结

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

以上是 React 端的编程范式 的全部内容, 来源链接: utcz.com/z/383188.html

回到顶部