浅谈React状态管理

149 阅读6分钟

一、从命令式到声明式的UI变革

用户在网页上输入信息,提交表单,查看结果列表等一系列的用户交互,都是由前端完成的。前端需要根据用户操作展示不同UI,从而响应用户。

例如常见的表单处理:

  • 当用户输入数据时,提交按钮会变为"可用状态"。
  • 当用户点击"提交"时,按钮会变为"不可用状态",并展示"加载中"动画。
  • 如果网络请求成功,会跳转成功页面。
  • 如果网络请求失败,会展示错误信息,同时按钮变为"可用状态"。

这是传统的命令式UI,前端需要根据用户操作来编写DOM指令,更新UI。

<html>
    <body>
        <form id="form">
            <textarea id="textarea"></textarea>
            <button id="button" disabled>Submit</button>
            <p id="loading" style="display: none">Loading...</p>
            <p id="error" style="display: none; color: red;"></p>
        </form>
        <h1 id="success" style="display: none">That's right!</h1>
        <script>
            async function handleFormSubmit(e) {
                e.preventDefault();
                disable(textarea);
                disable(button);
                show(loadingMessage);
                hide(errorMessage);
                try {
                    await submitForm(textarea.value);
                    show(successMessage);
                    hide(form);
                } catch (err) {
                    show(errorMessage);
                    errorMessage.textContent = err.message;
                } finally {
                    hide(loadingMessage);
                    enable(textarea);
                    enable(button);
                }
            }

            function handleTextareaChange() {
                if (textarea.value.length === 0) {
                    disable(button);
                } else {
                    enable(button);
                }
            }

            function hide(el) {
                el.style.display = 'none';
            }

            function show(el) {
                el.style.display = '';
            }

            function enable(el) {
                el.disabled = false;
            }

            function disable(el) {
                el.disabled = true;
            }

            function submitForm(answer) {
                return new Promise((resolve, reject) => {
                    setTimeout(() => {
                        if (answer.toLowerCase() === 'istanbul') {
                            resolve();
                        } else {
                            reject(new Error('Good guess but a wrong answer. Try again!'));
                        }
                    }, 1500);
                });
            }

            let form = document.getElementById('form');
            let textarea = document.getElementById('textarea');
            let button = document.getElementById('button');
            let loadingMessage = document.getElementById('loading');
            let errorMessage = document.getElementById('error');
            let successMessage = document.getElementById('success');
            form.onsubmit = handleFormSubmit;
            textarea.oninput = handleTextareaChange;
        </script>
    </body>
</html>

问题是,这种模式下代码中会出现大量的DOM指令,可读性差,且不利于扩展。

为了解决这个问题,React和Vue等主流框架引入了声明式UI,我们只需要描述组件在不同状态下期望展示的UI,并根据用户操作来触发状态变更。

首先,我们要梳理出页面有哪些状态,以及期望展示的UI:

  • 无数据:展示一个“不可用状态”的提交按钮。
  • 输入中:展示一个“可用状态”的提交按钮。
  • 提交中:展示一个“不可用状态”的提交按钮,展示“加载中”动画。
  • 成功时:展示成功页面。
  • 失败时:展示错误信息,展示一个“可用状态”的提交按钮。
export default function Form({ status }) {
  if (status === 'success') {
    return <h1>That's right!</h1>
  }
  return (
    <form>
      <textarea disabled={
        status === 'submitting'
      } />
      <br />
      <button disabled={
        status === 'empty' ||
        status === 'submitting'
      }>
        Submit
      </button>
      {status === 'error' &&
        <p className="Error">
          Good guess but a wrong answer. Try again!
        </p>
      }
    </form>
  );
}

然后,确定有哪些用户操作会触发状态变更:

  • 输入框的值发生变化时,根据值是否为空决定表单状态为“无数据”或“输入中”。
  • 点击提交按钮时,表单状态切换为“提交中”。
  • 网络请求成功后,表单状态切换为“成功”。
  • 网络请求失败后,表单状态切换为“失败”。
import { useState } from 'react';

export default function Form() {
  const [answer, setAnswer] = useState('');
  const [error, setError] = useState(null);
  const [status, setStatus] = useState('typing');

  if (status === 'success') {
    return <h1>That's right!</h1>
  }

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('submitting');
    try {
      await submitForm(answer);
      setStatus('success');
    } catch (err) {
      setStatus('typing');
      setError(err);
    }
  }

  function handleTextareaChange(e) {
    setAnswer(e.target.value);
  }

  return (
    <>
        <form onSubmit={handleSubmit}>
        <textarea
          value={answer}
          onChange={handleTextareaChange}
          disabled={status === 'submitting'}
        />
        <br />
        <button disabled={
          answer.length === 0 ||
          status === 'submitting'
        }>
          Submit
        </button>
        {error !== null &&
          <p className="Error">
            {error.message}
          </p>
        }
      </form>
    </>
  );
}

function submitForm(answer) {
  //network request
}

与命令式不同的是,声明式UI是根据用户操作来触发状态变更,不需要编写DOM指令。底层原理是框架采用MVVM架构设计,实现了状态到UI的自动更新。

随着应用不断地变复杂,如何做好状态管理也是一件很重要的事情。例如,如何组织状态结构?如何维护状态更新逻辑?如何实现跨组件共享状态?

二、如何组织状态结构

良好的状态结构,可以让组件状态更新不容易出错,同时还方便调试。一般有以下原则:

  • 单一职责,按组件功能划分状态,不要将无关联的状态混合在一起。
  • 合并关联状态,如果两个状态总是一起更新,考虑将他们合并为一个。
  • 去除冗余状态,这样就不需要保持同步了。
  • 避免两个状态同时指向同一个对象。
  • 避免深度嵌套,尽量扁平化。

三、跨组件共享状态

有时候我们希望两个组件的状态保持同步更新。例如常见的手风琴面板组件,当展开其中一个面板时,其他面板都自动折叠。

为了实现这一点,可以把状态转移到最近的父组件,并通过props将状态传递给子组件。这被称为“状态提升”,是最基础也是最常见的跨组件共享状态的方案。

import { useState } from 'react';
export default function Accordion() {
  const [activeIndex, setActiveIndex] = useState(0);
  return (
    <>
      <Panel
        title="标题一"
        isActive={activeIndex === 0}
        onShow={() => setActiveIndex(0)}
      >
        面板一
      </Panel>
      <Panel
        title="标题二"
        isActive={activeIndex === 1}
        onShow={() => setActiveIndex(1)}
      >
        面板二
      </Panel>
    </>
  );
}
function Panel({
  title,
  children,
  isActive,
  onShow
}) {
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={onShow}>
          显示
        </button>
      )}
    </section>
  );
}

四、跨层共享状态

很多网站都支持“一键切换主题”,用户只需要点一下按钮,网页上所有组件都会展示"黑暗"或"光亮"主题。这种情况下,需要将主题状态传递给整个UI树,props传递路径变得很冗长,并且每一层的组件都必须新增额外的props参数,十分不方便。

App.js

import Foo from './Foo';
function App() {
  const [theme, setTheme] = useState('light');
  return (
    <>
      <Foo theme={theme}/>
      <Button
        onClick={() => {
          setTheme(theme === 'light' ? 'dark' : 'light');
        }}>
        主题切换
      </Button>
    </>
  );
}

Foo.js

import FooChild from './FooChild';
export default function Foo({theme}) {
  return (
      <div className={`box ${theme}`}>
         Context Foo
         <FooChild theme={theme} />
      </div>;
  )
}

<style>
.box{
  width: 200px;
  height: 200px;
}
.light{
  background-color: #fff;
  color: #333;
}
.dark{
  background-color: #333;
  color: #fff;
}
</style>

FooChild.js

export default function FooChild({theme}) {
  return <div className={`child ${theme}`}>Context FooChild</div>;
}

<style>
.child{
  width: 200px;
  height: 200px;
}
.light{
  background-color: #fff;
  color: #333;
}
.dark{
  background-color: #333;
  color: #fff;
}
</style>

要是有一种方法不需要通过props就能传递状态,那就太好了。为此,React推出了新方案,即context。通过创建一个全局唯一的context对象来保存状态,然后在父组件中提供context对象并设置状态值,这样父组件下的整个UI树都能通过context对象访问到状态。

ThemeContext.js

import { createContext } from 'react';

//指定默认值
const defaultTheme = 'light';
//创建context
const ThemeContext = createContext(defaultTheme);
//导出
export default ThemeContext;

App.js

import ThemeContext from './ThemeContext';
import Foo from './Foo';
function App() {
  const [theme, setTheme] = useState('light');
  return (
    <ThemeContext.Provider value={theme}>
      <Foo />
      <Button
        onClick={() => {
          setTheme(theme === 'light' ? 'dark' : 'light');
        }}
      >
        主题切换
      </Button>
    </ThemeContext.Provider>
  );
}

Foo.js

import { useContext } from 'react';
import ThemeContext from './ThemeContext';
import FooChild from './FooChild';

export default function Foo() {
const theme = useContext(ThemeContext);
  return (
      <div className={`box ${theme}`}>
         Context Foo
         <FooChild />
      </div>;
  )
}
<style>
.box{
  width: 200px;
  height: 200px;
}
.light{
  background-color: #fff;
  color: #333;
}
.dark{
  background-color: #333;
  color: #fff;
}
</style>

FooChild.js

import { useContext } from 'react';
import ThemeContext from './ThemeContext';

export default function FooChild() {
  const theme = useContext(ThemeContext);
  return <div className={`child ${theme}`}>Context FooChild</div>;
}
<style>
.child{
  width: 200px;
  height: 200px;
}
.light{
  background-color: #fff;
  color: #333;
}
.dark{
  background-color: #333;
  color: #fff;
}
</style>

context的特点很像CSS属性继承,父组件提供的context,不管层级多深的子组件都能访问到,并且中间组件也能通过提供新的context值来覆盖上层的context。

总结

前端需要根据用户操作展示不同UI,从而响应用户。传统的命令式UI出现了大量的DOM指令,可读性差,且不利于扩展。取而代之的是声明式UI,只需要描述组件在不同状态下期望展示的UI,并根据用户操作来触发状态变更,不需要编写DOM指令。在声明式UI中,状态管理是核心,包括状态结构和状态共享。