8

hodux:极简响应式React Hooks数据流

 3 years ago
source link: https://zhuanlan.zhihu.com/p/101912754
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

hodux:极简响应式React Hooks数据流

蚂蚁金服 前端工程师

可跳过理论分析,直接跳到下面hodux介绍部分。

浅谈对数据流的理解

笔者认为,数据流里边客观存在着两个实体,一个是数据源(Data Provider),一个是数据的消费者(Data Cousumer)。二者可以独立存在,甚至可以不在同一个系统中运行。Provider负责(对数据)的增删改查,Cousumer取数(并进行运算),因此可推导出数据流框架/方案要解决的两个核心问题:

  • 数据操作:实现对Provider的数据修改,而且理论上不应当限制数据操作行为
  • 取数:实现Cousumer从Provider取数,并且数据变更后能够重新取数

单从前端视角看,数据流方案里的Provider可以是web storage、cooke、session或者js变量,Cousumer可以是一个js语句、React/Vue组件组件等等:

// data provider
const data = {
  foo: 'foo',
  bar: 'bar'
}

// data consumer
function print() {
  console.log(data.foo, data.bar);
}

数据流转示意图:

图中红色虚线部分表示(在任意位置)直接修改数据,然后修改后的数据会同步给消费者。所以,各个数据流解决方案的差异,本质就是数据操作的实现差异和取数实现差异。

React数据流方案差异

在React生态,数据流方案可以说是数不胜数,但总体而言可以分为两大类,一类是以Redux为代表的不可变(immutable)数据流,另一类就是以Mobx为代表的可变(mutable)数据流。二者采用了完全相反的设计理念,导致了在使用上有着完全不同的体验。

数据源(初始化)

Redux和Mobx对于数据源(Provider)都没特别的约束,即都是一个js object:

// Redux
const person = createStore({
  name: "John",
  age: 42,
  showAge: false
})

// Mobx
const person = observable({
  name: "John",
  age: 42,
  showAge: false
})

在数据操作和取数实现上两者差异较大,Mobx修改数据就是直接修改数据对象(即OOP)、取数则直接读取数据对象,非常符合人的思维模式,因此理解起来非常自然。

person.name = 'Dave' // 数据修改

而Redux数据修改则是强制采用函数(FP)来完成,所以为了实现对js object的修改Redux引入了较多的概念,概念多+文档多+模板代码是Redux最受诟病的地方。

// redux修改数据
person.dispatch({
  type: actions.SET_NAME,
  payload: 'Dave'
})

function reducer(state, action) {
  ...
}

Mobx实现了响应式系统,通过autorun建立了Provider和Consumer的联接,监测源数据的修改并反馈给Consumer,从而实现取数以及更新时再次取数:

autorun(() => console.log(person.name)) // mobx取数
// mobx-react取数(Class)
observer(
  class Person extends React.Component {
    render() {
      return <div>{person.name}</div>
    }
  }
)
observer(() => <div>{person.name}</div>) // mobx-react取数(FC)

Redux的取数也必须借助函数,需要在顶层组件使用<Provider/>提供数据源,组件通过connect(HOC)建立Provider和Cunsumer的联接。值得注意的是,对于函数组件react-redux提供了useSelector hook,摆脱了HOC带来的层层嵌套问题:

console.log(person.getState().name) // redux取数

<Provider store={person}><App /></Provider> // 提供数据源

connect(state => { name: state.name })(App) // react-redux取数
const name = useSelector(state => state.name) // react-redux hooks取数

可以看到,在数据修改方面Mobx的响应式方式明显更自然更容易理解,但是在取数上Mobx的React实现无论是mobx-react还是mobx-react-lite,都必需要用HOC,相比较而言useSelector则自然多了,而且Hooks面向未来。

hodux介绍

hodux是什么

源码地址:https://github.com/react-kit/hodux

hodux是一个React Hooks响应式数据流,特点:

  • 响应式数据流转:像修改js对象一样修改数据,简单自然
  • Hooks取数:selector按需从store取数
  • 核心API只有2个,零学习成本,轻量、高性能(下文有性能测试结果)
  • 完美支持TypeScript

hodux实现原理

hodux数据流转示意图
  • 创建一个可观测(observable)的js对象,作为中心化的(全局)store
  • 通过自定义Hook(useSelector)联接React组件,useSelector内部会监听select成员的变化
  • store是一个可变(mutable)js对象,可以在任意位置修改它,且成员的变更会实时同步给相关React组件
// 两个核心的API
import { store, useSelector } from 'hodux';

// 1、初始化一个observable对象
const counter = store({
  num: 0,
  other: ''
});

// 2、(按需)取数:当且仅当select的成员发生改变时组件才会re-render
export default function Counter() {
  const num = useSelector(() => counter.num);
  return <div onClick={() => counter.num += 1}>The used num:{num}</div>;
}

复杂对象或large object:store可以包含所有支持Proxy劫持的数据类型,比如(嵌套)对象、数组、ES6 collections相关对象等等。

const person = store({
  // 嵌套
  profile: {
    firstName: 'Bob',
    lastName: 'Smith',
    // 通过getter实现计算属性(computed)
    get name() {
      return `${person.firstName} ${person.lastName}`
    },
    age: 25
  },
  // 数组
  hobbies: [ 'programming', 'sports' ],
  // ES6 collections对象
  familyMembers: new Map(),
});

// changing stores as normal js objects
person.profile.firstName = 'Daid';
delete person.profile.lastName;
person.hobbies.push('reading');
person.familyMembers.set('father', father);
person.familyMembers.set('mother', mother);

hudux采用和react-redux hooks相类似的取数API(useSelector),但又有所不同,因为hodux目前采用多store设计,需要在组件中import相应的store(所以天然支持TS),这算是设计上的一个badcase吧。

import person from './stores/person'

function PersonView() {
  const [name, age] = useSelector(() => [person.name, person.age])
  ...
}

细心的同学可能会发现:既然已经import了,为啥还需要用hook包一层?事实上,直接使用import后的store也可以的,只不过不具备数据项修改后更新组件的能力。

useSelector的本质是数据项依赖收集,所以需要在selector内部访问(get)相关数据项,你甚至可以不返回值都可以。作为最佳实践,应该访问组件真实依赖的数据项而不是返回整个store。

这里需要注意的是useSelector返回的值必须是一个可序列化(serializable)类型的值(boolean、string、number、array、object以及它们的组合),其他类可以经过转换后再返回,如:

function PersonView() {
  // DON'T DO THIS
  const familyMemebers = useSelector(() => person.familyMemebers);
  // DO THIS
  const [father, mother] = useSelector(() => [
    person.familyMemebers.get('father'),
    person.familyMemebers.get('mother')
  ]);
  ...
}

类组件支持

对于新项目或者新开发的组件,推荐使用React Hooks,但hodux也支持类组件(包括ownProps),API(connect)也和react-redux一致:

const counter = store({
  n: 0,
  inc() { counter.n += 1; }
});

const selectToProps = () => ({ n: counter.n });

class Counter extends Component {
  render() {
    return <div onClick={counter.inc}>{n}</div>;
  }
}

export default const ReactivedCounter = connect(selectToProps)(Counter);

更多使用细节请参考文档在线演示 TodoMVC

Q1:Proxy的支持情况?

hodux的响应式系统(@nx-js/observer-util)是基于ES6 Proxy API实现的,目前浏览器的支持情况(caniuse):

桌面浏览器除了IE,其余都支持了Proxy。移动端由于国产手机厂商经常魔改内核,在使用之前最好事先做一下统计。由于Proxy无法完整polyfill,所以hodux没有降级方案,业界一些知名的响应式框架(如[email protected][email protected])降级均需要进行主版本降级,且API层面可能都是两套了。

Q2:object-based store对比hook-based store?

unstated-next为代表的hook-based数据流,采用自定义hook实现状态共享。这种方式也非常易懂,没什么学习成本,抛开需要<Provider>包裹等(可以通过工程解决的)问题我们来看下具体使用上的差异:

// hook-based
function useMyStore() {
  // state申明
  const [num, setNum] = useState(0)
  const [arr, setArr] = useState([])
  const [obj, mergeObj] = useReducer((old, newObj) => {...old, ...newObj}, {})
  // more...

  // arr引用:在hook内部访问引用类型时,必须要用使用ref保存上一次的值
  const ref = useRef([])
  ref.current  = arr.slice()

  // hook内部对setter的封装,简化state操作
  const inc = () => setNum(num + 1)
  const dec = () => setNum(num + 1)
  const pushItem = (item) => {
    const prevArr = ref.current
    prevArr.push(item)
    setArr(prevArr)
  }

  return {
    // state
    num, arr, obj,
    // setters
    setNum, setArr, mergeObj,
    // setter wapppers
    inc, dec, pushItem
  }
}

// object-based
const myStore = store({
  count: 0,
  arr: [],
  obj: {}
})

hook-based需要返回state和state setter方法,有时为了方便我们还会对setter wrapper一层。数据项较多时使用useState较为啰嗦(来自同事的吐槽)、复杂项可以用useReducer,但需要额外写reducer以及自行处理对象merge等问题,如果碰到需要处理deps的情况(如http请求)就更复杂了。

当然这里不是说hook-based不好,hooks具一定的心智负担(参见:为什么 React 现在要推行函数式组件,用 class 不好吗?),而一个响应式的数据流至少可以在「状态管理」这个方面简化函数式组件。hodux推崇large object,把数据项、和相关setter统统都挂到store,组件按需select。

Q3:为什么useSelector必须返回可序列化类型?

因为useSelector会对前后两次返回值进行diff觉得是否re-render,这些类型比较永远是false。那为啥又能返回引用类型(数组、对象)呢,因为useSelector会对上一次返回值进行深拷贝用于diff。

Q4:hodux性能怎么样?

测试结果:benchmark

测试框架:js-framework-benchmark

测试用例:参见源码

最后,欢迎试用以及提交MR。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK