关于react.js:react状态总结

29次阅读

共计 12694 个字符,预计需要花费 32 分钟才能阅读完成。

本文从动机脉络聊聊对 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。

在理论应用过程中,个别不会像案例这样应用。事件流偏差于命令式编程,且只是传递了想要批改状态的指令,对于如何批改,须要放在监听器外面,也就是提供批改状态的组件外部。也就是内部组件在以后组件没有提供状态批改事件之前,是无奈进行状态批改的。在须要大量数据状态须要通信时,因为事件流太偏差于底层,大量开发时不不便复用。个别都是基于事件流中革新为公布订阅模式,进行申明式的状态治理。
如将下面的案例中事件流状态传递,流程图示意:

这里做一个小的知识点 (集体以前的纳闷,所以破费篇幅阐明),在案例中事件的形式并不需要应用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 后,比照于下面的简略事件流流程过程,就变成了:

应用 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 中叫做派生)。这样在对象的值发生变化时,就会主动触发事件,执行对应的监听器。

在这种形式下,须要开发者做的事件就只剩下定义状态,申明副作用 (派生函数) 即可。整个流程为:

在这里咱们不过多的探讨 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 的执行,所以能够揣测出收集不仅仅在第一次执行的时候收集结束就变化无穷,而是在每次执行后会更新对应状态的监听队列。

正文完
 0