本文从动机脉络聊聊对react生态中的状态相关技术的演化过程。

个人理解,欢迎讨论

响应式渲染框架

这里只聊react的状态和视图渲染相关内容,不聊底层的Virtual DOM

react是一个mvvm框架,作为一个响应式渲染设计,当自身的模型(状态)发生变化时,会自动刷新(re-render)当前视图显示最新的模型(状态)数据。

那是如何监听状态发生变化呢?react本着极简的api设计理念,遵循函数式编程中的不可变对象理念,对于状态实现的特别简单,只提供了一个setStateAPI。在react组件中,需要视图发生变化时,只需要对调用setState进行数据时图进行改变,就会触发当前组件的re-render,完成更新。所以此时可以理解为,react是响应式设计的渲染框架,但其状态不是响应式模式,是一个命令式状态框架。如下面的代码:

class Page extends React.Component<{}, {no: number}> {
  constructor() {
    super({});
    this.state = {
      no: 0,
    };
  }

  render() {
    console.log('render执行');
    return <h1 onClick={() => {
      this.setState({
        no: 0
      });
    }}>Hello, {this.state.no}</h1>;
  }
}

当执行setState时,虽然状态no前后都是0, 但是组件的render还是会被重新执行。

这样简单的设计,既是react的优点又是react的缺点。优点是react只提供最底层的状态更新api, 使用者可以使用市面上任意的其他状态框架,整个框架不显得笨重。缺点就是不能开箱即用,加重使用者心智负担。

react中的单向数据流设计,在整个组件树中,只允许状态从头流到叶子节点的原因是什么呢?这是由于每一个组件的子节点都是执行在render函数中,类似于一个递归树。当当前组在re-render时,就会重新调用子节点重新执行render,也就整个当前组件以下的整个组件都会re-render。所以当只有父向子的单向数据流时,这个调用流程只需要调用一次就可以把当前变化后的数据“响应”到视图上。代码效果:

const Foo: React.FC<Record<string, unknown>> = () => {
  console.log('Foo被重新渲染了');
  return (
    <div>
      Foo
    </div>
  );
}

const Bar: React.FC<Record<string, unknown>> = () => {
  console.log('Bar被重新渲染了');
  return (
    <div>
      Bar
    </div>
  );
}

class Parent extends React.Component {

  constructor() {
    super({});
    this.state = {
      no: 0,
    };
  }

  render() {
    console.log('Parent被重新渲染了');
    return (
      <div onClick={() => this.setState((preState) => ({ no: preState.no + 1 }))}>
        <Foo />
        <Bar />
      </div>
    );
  }
}

Parent中的状态发生变化时,会发现Parent, Foo, Bar组件都发生了re-render。

react这种触发时图更新的机制在绝大多数情况下都会造成性能损失。因为数据更新是常态,特别是在一些持续触发的事件中,每一次都更新整个节点树,当业务场景体量稍微大一点导致react组件节点非常多时,碰到持续更新状态的情况下性能就会非常差。这也就导致react生态中,状态理念,框架层出不穷的根本原因。

虽然组件重新调用渲染函数(render)由于Virtual DOM的diff算法不一定更新dom结构(也就是最终视图),但是render函数的反复执行,也开销特别大。

react单向数据流的规定保证当当前组件发生变化时,只需要重新渲染自己, 不会去渲染父组件和兄弟组件。所以下面的用法:

const Foo: React.FC = ({ actionRef }) => {
  console.log('Foo被重新渲染了');
  const [no, setNo] = React.useState(0);
  React.useImperativeHandle(actionRef, () => ({ no }));
  
  return (
    <div onClick={() => setNo(preNo => preNo + 1)}>
      Foo
    </div>
  );
}

const Bar: React.FC = ({ no }) => {
  console.log('Bar被重新渲染了');
  return (
    <div>
      Bar, {no}
    </div>
  );
}

const Parent: React.FC<Record<string, unknown>> = (props) => {
  console.log('Parent被重新渲染了');
  const actionRef = React.useRef(null);

  return (
    <div>
      <Foo actionRef={actionRef} />
      <Bar no={(actionRef.current || {}).no || 0} />
    </div>
  );
};

当在组件Foo中触发状态改变,只会触发Foo组件re-render,虽然ParentBar也都是用了Foo的数据(注意是数据而不是状态,通过ref传递了), 但是不会re-reder。

SCU

为了解决react默认状态变更时触发整个当前组件整个子节点树更新的性能问题,react提供了SCU(shouldComponentUpdate)机制。使用者可以在这个生命周期函数中,根据触发当前组件re-render的props,statecontext跟当前还未re-render值进行对比,决定该组件是否的re-render。

为了简化SCU的操作,react提供了PureComponent提供默认的比对算法,也就是对属性集对象(props),状态集对象(state)和上下文对象context进行顶层属性的对比(浅对比),对象值采用的是引用对比方式。这样在祖先节点状态更新触发整个节点树更新时,当前组件会判断如果传入的属性对比后发现没有更新时,或者当前组件调用了setState但是状态的值没有发生变化时,都会跳过本组件的re-render,进而提高性能。此时React从命令式响应框架转为比对式数据响应框架(Comparison reactivity)。

如下面的代码点击文本将不会触发render的函数重新执行(如果不是继承PureComponent的话render中的日志会持续打印):

class Welcome extends React.PureComponent<{}, {name: string}> {

  constructor() {
    super({});
    this.state = {
      name: '123',
    };
  }

  render() {
    console.log('render执行');
    return <h1 onClick={() => {
      this.setState({
        name: '123'
      });
    }}>Hello, {this.state.name}</h1>;
  }
}

PureComponent也会导致如下面代码的问题:

class Welcome extends React.PureComponent<{}, {foo: {name: string}}> {

  constructor() {
    super({});
    this.state = {
      foo: {name: '123'},
    };
  }

  render() {
    console.log('render执行');
    return <h1 onClick={() => {
      const { foo } = this.state;
      foo.name = '456';
      this.setState({ foo });
    }}>Hello, {this.state.foo.name}</h1>;
  }
}

当我们点击后是期望视图有更新,显示为456,但实际情况下不会。因为如上文所说,PureComponent只会对比props,statecontext中顶级属性值,并且对象值只采用引用对比(浅对比模式)。而在代码中,状态foo对象虽然内容变了,但是引用不变,所以react会认为状态没有发生改变,从而跳过更新。为了解决这个问题,react提出了不可变状态对象的理念。简单的理解为,存放在state中的对象数据,在自身引用没有发生变化时,不允许其内部的值发生变化,也就是下面的代码是不推荐的:

const { address, user, dataList } = this.state;
// 禁止在user引用值没有变化时,改变了其内部值
user.name = 'foo';
// 特别容易发生在数组中
dataList.push('newItem');
// 下面这种是常犯的一种
address.city = 'changsha';
this.setState({
  address,
});

而是推荐下面这种:

this.setState({
  user: {
    name: 'foo',
    ...this.state.user,
  },
  dataList: [...this.state.dataList, 'newItem'],
  address: {
    city: 'changsha',
    ...address,
  },
});

整个react渲染就像动画片放映一样,不是局部内容的变化,而是一帧一帧的整体替换。当需要画面变化时,就需要构建从上一帧复制内容到下一帧,然后在变化。禁止直接对老的帧直接改动。

上面的案例中,平常开发中稍微注意就可以遵循。但在一些复杂的场景下,如可编辑表格的每个行数据操作,在不方便对整个状态对象(深度封装下)进行创建新的对象时,就容易误操作。
为了避免无意中没有遵循react的immutable理念,可以采取两种方式:

  • 使用一些保证状态为不可变对象的的lint规则(本人尚未发现社区有这一块的内容);
  • 使用immutable.js;

Hook

在class componets开发过程中,如果使用原生的react状态的话,将会有以下缺陷(使用hook动机):

  • 在组件之间复用状态逻辑很难;
    react中一切皆组件,对于公共代码可以封装成新的组件。但是对于一些公共的状态逻辑,在mixin被废除之后,却没有提供好的方式去封装。而Hook可以在不改变组件结构,就可以复用状态逻辑。相对于使用控制组件,Hook使用简单,二次封装非常快速。相对于使用mixin,Hook可以理解为mixin的升级版,维持住了调用链,解决了mixin中调试困难的难题。
  • 复杂组件变得难以理解
    由于以前状态逻辑难以服用,就会导致一些组件中堆砌了大量的状态逻辑。特别是一些作为控制组件的容器组件,其中堆满了各种子组件之间用于状态通信的逻辑。使用Hook之后,可以快速方便的对状态进行分类放入不同的模块中,组件代码干净清爽。
  • 完全函数式编程
    使用hook可以完全摆脱class变成,摆脱怪异的this工作方式。函数式编程更称灵活,可测试。hook可以理解为函数式编程中状态的实现,不仅仅在react中使用。

hook中的关于状态这一块的api为useState,可以理解为把class中的this.State可以拆成多份去执行,一个useState就是一个状态,废弃了状态集对象概念。并且在useState中一个更大的进步是吸取了以前教训,直接引入了对比式更新,如果设置根当前值一样的值时,整个组件将不会re-render:

const Foo: React.FC<Record<string, unknown>> = (props) => {
  const [no, setNo] = React.useState(0);
  console.log('Foo重新渲染了');
  return (
    <div onClick={() => setNo(0)}>
      Foo, {no}
    </div>
  );
};

比对式更新不仅仅在useState中被使用,在其他的hook如useMemo, useEffect中的deps的参数中,都采取同样的方式。
有了比对式更新,hook引语了一些响应式状态流中的计算属性概念(useMemo)。

跨组件传递状态

上文中的状态都是处于单个组件内部,在实际的场景中,还需要考虑在组件之间进行状态通信。

react自带方案

对于简单的向另一个组件内传递状态,可以使用propsprops可以看作是父组件的状态。当父组件的状态发生变化时,会触发当前组件的re-render(默认情况下),从而获取到了最新的状态。这种方式跟木偶组件有点像,容器(父)组件负责状态逻辑,展示节点将状态呈现在视图。

如果一个组件需要接收祖先节点的状态,此时如果使用props的话,会特别繁琐,需要在整个树路径上都维持这个属性传递下来(props透传)。这种方式造成了整个链路都耦合底层组件的状态使用,违反了编程原则造成后期维护特别困难。为了解决这个问题,react提供了Context方案。

但Context也只解决了同一个链路下组件的通信问题,如果是兄弟节点,或者是“亲戚”(没有直系关系)节点之间如何通信呢?react推荐使用状态提升方式:对于需要通信的两个组件,首先找到它们的共有祖先节点(对应组件可以称为容器组件或者控制组件),然后将需要通信的数据作为这个祖先节点的状态。当任意一个组件改变共享状态时,会触发整个祖先节点的re-render,默认情况下,这个祖先节点的所有子节点也会re-render, 也就是另一个组件就会获取到最新的状态值,完成整个状态传递。

除react自带方案之后,下面将会讲几种react生态中常见几种类型的状态库。他们有的是基于react自带方案的工具库,有的是为了解决状态提升而采取的其他方式。

unstated-next

unstated-next是unstated在react hooks中理念的重新实现。一个伪代码的实现为:

function createContainer(useHook) {
  const Context = React.createContext(null);

  function Provider(props) {
    const value = useHook(props.initialState);
    return React.createElement(Context.Provider, { value }, props.children);
  }

  function useContainer() {
    return React.useContext(Context) ?? throw new Error("Component must be wrapped with <Container.Provider>");
  }

  return { Provider, useContainer };
}

简单的理解就是,将你自定义的hooks中的状态存储在context中进行组件共享。

那么它的优点就是:简单,其实就是对context二次封装,虽然react16中context相对于以前版本简便性已经有了极大的提高,但是在修改context中数据的方式下沉到自组件中还是比较繁琐,而unstated-next恰恰可以解决这个点,可以状态跟update函数快速维护。如:

function useCounter() {
    let [count, setCount] = useState(initialState)
    let decrement = () => setCount(count - 1)
    let increment = () => setCount(count + 1)
    return { count, setCount, decrement, increment }
}

let Counter = createContainer(useCounter)

同时它也解决了一个状态提升带来的问题:当一个容器下的组件需要通信的数据过多时,会发现这个容器下堆满了各种状态。且不同组件之间相互通信的状态直接堆积在控制节点中,非常难以维护。unstated-next可以帮助我们对堆砌在容器内点中的各种状态进行封装管理,维护在单独的数据文件中,保持容器组件的清爽。

缺点:本质上是一个工具库,状态提升带来的其他问题它都有。

unstated是unstated-next在class components时代同理念库,也是对context的二次封装工具库,不在单独拿出来讲解。

简单事件流实现

状态提升的方式解决组件通信会导致状态集中在上层组件中,在渲染的过程中也会导致过多的额外组件re-render,造成性能低下。那有什么方式可以精准的找到并只渲染需要渲染的组件呢?可以用事件流。

如下面的代码:

import { TinyEmitter } from 'tiny-emitter';

const emitter = new TinyEmitter();

const Foo: React.FC<Record<string, unknown>> = () => {
  const [message, setMessage] = React.useState<string>('init');

  React.useEffect(() => {
    emitter.on('updateMessage', (messageArg: string) => {
      setMessage(messageArg);
    });
  }, []);
  return (
    <div>
      Foo: {message}
    </div>
  );
}

const Bar: React.FC<Record<string, unknown>> = () => {
  return (
    <div onClick={() => {
      emitter.emit('updateMessage', 'barClick');
    }}>
      Bar
    </div>
  );
}

const Normal: React.FC<Record<string, unknown>> = () => {
  console.log('normal被重新渲染了');
  return (
    <div>
      Normal
    </div>
  );
}

const Parent: React.FC<Record<string, unknown>> = (props) => {
  console.log('props', props);

  return (
    <div>
      <Foo />
      <Bar />
      <Normal />
    </div>
  );
};

Bar组件跨组件非直系节点触发Foo状态变化时,只有Foo组件会re-render, 其他兄弟节点Normal和父节点Parent都不会re-render, 连Bar自己都不会re-render。

在实际使用过程中,一般不会像案例这样使用。事件流偏向于命令式编程,且只是传递了想要修改状态的指令,对于如何修改,需要放在监听器里面,也就是提供修改状态的组件内部。也就是外部组件在当前组件没有提供状态修改事件之前,是无法进行状态修改的。在需要大量数据状态需要通信时,由于事件流太偏向于底层,大量开发时不方便复用。一般都是基于事件流中改造为发布订阅模式,进行声明式的状态管理。
如将上面的案例中事件流状态传递,流程图示意:

image.png

这里做一个小的知识点(个人以前的疑惑,所以花费篇幅说明),在案例中事件的方式并不需要使用Context,为什么那些状态框架,如redux, mobx都有一个放在根节点位置Provider呢? 如:

import { Provider } from 'react-redux'
ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)

这是由于如上面的简单事件流案例,会有一个事件实例emitter,如果需要支持多实例或者方便在组件中获取到,就需要提供一个Provider存放在Context中。但是由于不是将数据存在了Context中,而是状态管理器的实例存储在Context中,所以状态变化时,是不会触发顶层节点re-render从而导致整个节点树都re-render。基于事件流的框架会通过事件精准的找到目标组件并触发他的re-render。所以相对来说,redux, mbox这种状态管理框架,比使用react原生提供的方案性能会好的多。

redux

redux将所有的状态都收归在自己内部,不在使用react状态。状态由react中托管到redux后,对比于上面的简单事件流流程过程,就变成了:
image.png

使用redux时,组件中通过dispatch触发事件,事件的值传递为aciton(真实事件系统里面称为event对象),这个事件首先会被reducer监听到。redux规定只能在reducer中修改状态,当状态修改之后,就会触发的subscribe,在subscribe会触发目标的组件re-render, 如案例:

import { createStore } from 'redux'
function counterReducer(state = { value: 0 }, action) {
  switch (action.type) {
    case 'counter/incremented':
      return { value: state.value + 1 }
    case 'counter/decremented':
      return { value: state.value - 1 }
    default:
      return state
  }
}
let store = createStore(counterReducer)
store.subscribe(() => console.log(store.getState()))
store.dispatch({ type: 'counter/incremented' }) // 会打印出{value: 1}
store.dispatch({ type: 'counter/incremented' }) // 会打印出{value: 2}
store.dispatch({ type: 'counter/decremented' }) // 会打印出{value: 1}

redux中使用reducer替代了react中的setState函数,没有命令式的改变状态的含义(但个人觉得其实就是放在了命令式调用dispatch而已),也就是说一旦触发了reducer的执行,就意味有状态由发生变化。就会触发监听器subscribe

根据上面的案例,会发现任意一个状态发生变化时(执行dispatch),所有的副作用都会执行。这需要在subscribe中对组件进行是否需要re-render时,需要深入判断当前组件依赖的状态是否发生变化。在react-redux(8.0版本connect)中实现的逻辑是:

actualChildProps = useSyncExternalStore(
  subscribeForReact,actualChildPropsSelector,
  getServerState
  ? () => childPropsSelector(getServerState(), wrapperProps)
  : actualChildPropsSelector
)

其中useSyncExternalStore为官方提供的hook,也就是说当redux中的状态发生变化时,就会触发各个connectHOC中的订阅器,订阅器会执行传递进去的mapStateToProps函数,获取当前组件需要从store获取的状态,拿到状态后,还会进行浅对比(跟react hook对比算法一致,对比各个状态的引用值),如果发现状态没有变化,那么返回的是一个历史值(不会触发更新),如果状态有变化,则返回新的状态对象,触发当前组件re-render。

在新版本的redux中,直接提供了hook useSelector来触发目标组件的re-render, 核心逻辑跟connect中基本一致:

const { store, subscription, getServerState } = useReduxContext()!

const selectedState = useSyncExternalStoreWithSelector(
  subscription.addNestedSub,
  store.getState,
  getServerState || store.getState,
  selector,
  equalityFn
)

在上下文中获取当前的store实例,然后实现React.useSyncExternalStore, 在store中的状态发生变化时,根据选择器判断是否需要触发当前状态发生改变,从而决定当前的组件是否需要re-render。

另外由于所有的状态都是推荐使用redux去管理(单一数据源),那么存放在redux中的状态将会非常多。为了方便管理,redux提供了命名空间的概念。

从上面的讨论可以看出,redux跟react的思想是极其相近的,都是遵循状态的不可变immutable,都采用比较式数据响应框架(Comparison reactivity)。redux提出了一些新的理念(方法论),利用一些编程范式,规范整个状态的变化周期,防止误操作。但对于如何准确的找到需要渲染的组件,redux还是在react-redux中使用老办法,对于状态进行前比较。这导致其还是没有解决react中的状态管理的一些缺点:

  • immutable编程带来的一些心智负担
  • 误操作导致一些非必要的组件re-render

而对于redux中无法准确找到需要re-render的组件的难题,而社区慢慢出现利用一些代理的技术手段(es5中的Object.definePropert或者es6的ProxyAPI)进行状态管理自动收集的方式来解决。这些解决方案可以降低开发者心智负担和难度,下文将要讲解的mbox就是这其中的一种。

mobx

类似框架: Recoil, zustand, jotai

react是一直走不可变对象immutable理念,但mutable实在是太香了,特别竞争框架vue,Solidjs,Svelte都采用了。故meta公司也出了一个mutable框架,支持在react中使用订阅响应式状态管理(Subscription reactivity)。

mobx跟redux一样,都是将所有的状态从react中拿出来自己管。但不同于redux的单一数据流理念,可以根据需要通信的数据灵活创建不同的状态对象,方便搭配hook使用。

相对比于简单事件流,在状态放入内部管理后,mobx不仅利用Object.definePropert或者ProxyAPI对创建的数据对象进行拦截监听,还能收集到使用这些状态的代码自动设置监听器(在mobx中叫做派生)。这样在对象的值发生变化时,就会自动触发事件,执行对应的监听器。

在这种方式下,需要开发者做的事情就只剩下定义状态,声明副作用(派生函数)即可。整个流程为:
image.png

在这里我们不过多的讨论mobx状态的底层原理和它的一些新的概念。对于我们关注的mobx如何将它内部的状态变化后触发对应的组件re-render的流程,通过下面的代码可以用于讨论:

const state = observable({ value: 0 });

const increment = action(() => {
    state.value++
});

autorun(() => {
    console.log("Energy level:", state.value);
})
increment(); // Energy level: 1

action中可以直接改变状态,当某个状态被改变后,mobx会自动执行它的的派生函数(类似于上文说的监听器),并且由于整个状态都是响应式的,所以派生函数可以延长,实现具有缓存作用的计算属性机制。最后会触发一个派生函数(autorun)。mobx会自动对派生函数中使用的状态进行收集,保证只有使用的状态发生变化时才会触发该autorun函数。

整个效果可以看到,在mobx中,完全可以废弃setState这种命令式的通知框架状态已经更新的方式。采用Object.definePropert或者ProxyAPI实现声明式的监听到状态的变化。并且通知具体的组件的re-render,也不需要中间加一个对比层,直接通过执行过程中维护的监听队列,自动完成对应组件的更新触发。

关于mobx是符合自动收集到派生函数中状态的使用信息,从而自动根据状态的变化只触发需要变化的申请操作是如何实现的。个人猜测是在初次执行的时候,对状态的get操作进行拦截收集的。推测代码如下:

const state = observable({
  value1: 0,
  value2: 0,
});

const increment = action(() => {
  state.value1++;
});

autorun(() => {
    console.log("autorun1 value1:", state.value1);
})

let a = false;
autorun(() => {
  if (a) {
    console.log("autorun2 value1:", state.value1);
  }
  console.log("autorun2 value2:", state.value2);
})

const Foo: React.FC<Record<string, unknown>> = () => {
  return (
    return <h1 onClick={() => {
      a = true;
      increment();
    }}>Hello, {this.state.no}</h1>;
  );
};

当调用increment函数后,你会发现autorun2也不会被执行,只有autorun1函数被执行了。这是由于在第一次执行两个autorun函数时,由于对于变量afalse,导致只收集到了autorun2value2的依赖,所以当value1发生变化时,autorun2还是不会执行。 对上面的代码进行下改造:

const state = observable({
  value1: 0,
  value2: 0,
  value3: 0,
});

const increment = action(() => {
  state.value1++;
  if (state.value1 > 5 && !state.value3) {
    state.value3 = 1;
  }
});

autorun(() => {
    console.log("state1 value1:", state.value1);
})

autorun(() => {
  if (state.value3 > 0) {
    console.log("state2 value:", state.value1, state.value3);
  }
  console.log("state2 value2:", state.value2);
})

可以发现,前面五次执行incrementstate.value1的变化,不会触发autorun2的执行,当第五次对state.value3也进行改变后,后续的每一次state.value1的变化(此时state.value3已经不在变化)也会触发autorun2的执行,所以可以推测出收集不仅仅在第一次执行的时候收集完毕就一成不变,而是在每次执行后会更新对应状态的监听队列。


joyerli
158 声望5 粉丝

前端搬砖一枚,会分享一些对技术的个人理解和思考,还会分享一些自己解决实际碰到的业务需而设计的奇葩技术方案。