overreactedby Dan Abramov

编写高健壮性的组件

March 16, 2019

当人们开始学习 React 时,经常会询问是否有推荐的代码风格指南。虽然在一个项目中保持一致的规则是个好主意,但其中很多规则其实是任意的——因此 React 对这些风格并没有强烈的官方建议。

你可以使用不同的类型系统,可以偏好函数声明或箭头函数,可以按字母顺序排列 props,也可以按照你喜欢的顺序排列。

这种灵活性让 React 能够集成 到已有约定的项目中。但它也带来了无休止的争论。

确实存在每个组件都应该遵循的重要设计原则。但我认为风格指南并不能很好地体现这些原则。我们先聊聊风格指南,然后再看看真正有用的设计原则


不要被想象中的问题分心

在谈论组件设计原则之前,我想先说几句关于风格指南的话。这可能不是个受欢迎的观点,但总得有人说出来!

在 JavaScript 社区,有一些由 linter 强制执行的严格风格指南。我的个人观察是,这些指南往往带来的摩擦比它们的价值还大。我已经数不清有多少次有人拿着完全合法的代码对我说“React 报错了”,其实报错的是他们的 lint 配置!这会导致三个问题:

  • 人们习惯把 linter 当作过度热心、吵闹的守门人,而不是一个有用的工具。真正有用的警告被一堆风格细节淹没。结果就是,调试时大家不会认真看 linter 的消息,从而错过了有用的提示。而且,不太熟悉写 JavaScript 的人(比如设计师)会更难参与代码协作。

  • 人们没有学会区分某种模式的合法用法和非法用法。比如,有个流行的规则禁止在 componentDidMount 里调用 setState。但如果这总是“不对的”,React 干脆就不允许这么做了!实际上有合理的用例,比如测量 DOM 节点布局(比如定位 tooltip)。我见过有人为了绕过这个规则加了个 setTimeout,这完全是南辕北辙。

  • 最终,人们会形成“执法者心态”,对那些没有实际意义、但容易在代码中扫描出来的事情变得极为挑剔。“你用了函数声明,但我们项目用箭头函数。”每当我强烈想要强制执行类似规则时,深入思考后会发现,我只是把情感投入到了这个规则上,难以放下。这会让我产生一种虚假的成就感,却并没有提升代码质量。

我是不是在说我们应该停止使用 linter?当然不是!

只要配置得当,linter 是在 bug 发生前捕获它们的绝佳工具。 但如果过度关注风格,它就会变成干扰项。


给你的 Lint 配置“断舍离”

我建议你下周一就这么做:召集团队开半小时会,把项目配置里启用的每一条 lint 规则都过一遍,问自己:“这条规则有没有帮我们抓到过 bug?”如果没有,关掉它。(你也可以直接用 eslint-config-react-app 这种没有风格规则的配置,重新开始。)

至少,你的团队应该有一个移除带来摩擦的规则的流程。不要以为你或别人一年前加到 lint 配置里的东西就是“最佳实践”。质疑它,寻找答案。不要让任何人告诉你你不够聪明,不能自己选 lint 规则。

那格式化怎么办?Prettier,忘掉那些“风格细节”吧。如果有工具能自动修正多余空格,你就不需要另一个工具为此大喊大叫。用 linter 去找bug,而不是强制美学

当然,代码风格里还有些和格式无关但项目里不一致时也会让人不爽的地方。

不过,这些细节很多其实太微妙了,lint 规则也未必能抓住。所以,团队成员之间建立信任,并通过 wiki 或简短设计指南分享有用的经验就很重要。

不是所有事都值得自动化!真正阅读这些指南背后的理由,获得的洞见往往比“遵守规则”更有价值。

但如果严格遵循风格指南反而是干扰,那什么才是真正重要的?

这正是本文要讨论的话题。


编写高健壮性的组件

无论缩进多么整齐、导入排序多么规范,都无法修复糟糕的设计。所以,与其关注代码看起来如何,不如关注它运行如何。有几个组件设计原则我觉得非常有用:

  1. 不要阻断数据流
  2. 始终准备好渲染
  3. 没有组件是单例
  4. 让本地状态保持隔离

即使你不用 React,只要用过任何单向数据流的 UI 组件模型,最终也会通过试错得出这些原则。


原则一:不要阻断数据流

渲染时不要阻断数据流

别人使用你的组件时,会期望可以随时传递不同的 props,并且组件能反映这些变化:

// isOk 可能由 state 驱动,随时可能变化
<Button color={isOk ? 'blue' : 'red'} />

一般来说,React 默认就是这么工作的。如果你在 Button 组件里用到 color prop,每次渲染都会拿到最新的值:

function Button({ color, children }) {
  return (
    // ✅ `color` 总是最新的!
    <button className={'Button-' + color}>
      {children}
    </button>
  );
}

但初学 React 时常见的一个错误是把 props 拷贝到 state 里:

class Button extends React.Component {
  state = {
    color: this.props.color
  };
  render() {
    const { color } = this.state; // 🔴 `color` 已经过时!
    return (
      <button className={'Button-' + color}>
        {this.props.children}
      </button>
    );
  }
}

如果你在 React 之外用过类,这种写法一开始可能更直观。但把 prop 拷贝到 state 后,你就忽略了对它的所有更新。

// 🔴 上面这种实现无法响应更新
<Button color={isOk ? 'blue' : 'red'} />

极少数情况下,这种行为是有意为之,那就请把 prop 命名为 initialColordefaultColor,明确表示后续变化不会生效。

但通常你应该直接在组件里读取 props,避免把 props(或基于 props 计算的任何内容)拷贝到 state:

function Button({ color, children }) {
  return (
    // ✅ `color` 总是最新的!
    <button className={'Button-' + color}>
      {children}
    </button>
  );
}

有时人们会因为需要计算值而尝试把 props 拷贝到 state。比如,假设我们根据背景 color 做了一个耗时计算,决定按钮文字颜色:

class Button extends React.Component {
  state = {
    textColor: slowlyCalculateTextColor(this.props.color)
  };
  render() {
    return (
      <button className={
        'Button-' + this.props.color +
        ' Button-text-' + this.state.textColor // 🔴 `color` 变了也不会重新计算
      }>
        {this.props.children}
      </button>
    );
  }
}

这个组件有 bug,因为它不会在 color prop 变化时重新计算 this.state.textColor。最简单的修复方法是把 textColor 的计算放到 render 方法里,并让组件继承自 PureComponent

class Button extends React.PureComponent {
  render() {
    const textColor = slowlyCalculateTextColor(this.props.color);
    return (
      <button className={
        'Button-' + this.props.color +
        ' Button-text-' + textColor // ✅ 总是最新
      }>
        {this.props.children}
      </button>
    );
  }
}

问题解决了!现在如果 props 变化,就会重新计算 textColor,而且不会在 props 没变时重复计算耗时操作。

不过,我们可能还想进一步优化。如果只是 children prop 变了,没必要重新计算 textColor。第二种尝试是把计算逻辑放到 componentDidUpdate

class Button extends React.Component {
  state = {
    textColor: slowlyCalculateTextColor(this.props.color)
  };
  componentDidUpdate(prevProps) {
    if (prevProps.color !== this.props.color) {
      // 😔 每次更新都多一次 re-render
      this.setState({
        textColor: slowlyCalculateTextColor(this.props.color),
      });
    }
  }
  render() {
    return (
      <button className={
        'Button-' + this.props.color +
        ' Button-text-' + this.state.textColor // ✅ 最终渲染是最新的
      }>
        {this.props.children}
      </button>
    );
  }
}

但这样会导致每次变化都多一次额外的 re-render。如果你想优化性能,这也不是理想方案。

你可以用旧的 componentWillReceiveProps 生命周期来做这事。但人们常常会在这里写副作用,结果又会给即将到来的并发渲染(比如 Time Slicing 和 Suspense)带来问题。而“更安全”的 getDerivedStateFromProps 方法则很笨重。

让我们换个思路。实际上,我们需要的是记忆化。有一组输入,只有输入变化时才重新计算输出。

用 class 组件时,你可以用辅助函数做记忆化。而 Hooks 更进一步,内置了记忆化昂贵计算的方法:

function Button({ color, children }) {
  const textColor = useMemo(
    () => slowlyCalculateTextColor(color),
    [color] // ✅ 只有 color 变化才重新计算
  );
  return (
    <button className={'Button-' + color + ' Button-text-' + textColor}>
      {children}
    </button>
  );
}

这就是你需要的全部代码!

在 class 组件里,你可以用 memoize-one 这样的辅助库。在函数组件里,useMemo Hook 提供了类似功能。

所以,即使是优化昂贵计算,也不是把 props 拷贝到 state 的理由。 渲染结果应该始终响应 props 的变化。


副作用中不要阻断数据流

到目前为止,我们讨论了如何让渲染结果与 prop 变化保持一致。避免把 props 拷贝到 state 是其中一环。但同样重要的是,副作用(如数据请求)也要纳入数据流的考虑。

来看这样一个 React 组件:

class SearchResults extends React.Component {
  state = {
    data: null
  };
  componentDidMount() {
    this.fetchResults();
  }
  fetchResults() {
    const url = this.getFetchUrl();
    // 执行数据请求...
  }
  getFetchUrl() {
    return 'http://myapi/results?query' + this.props.query;
  }
  render() {
    // ...
  }
}

很多 React 组件都是这样写的——但仔细一看会发现有 bug。fetchResults 方法用 query prop 去请求数据:

  getFetchUrl() {
    return 'http://myapi/results?query' + this.props.query;
  }

但如果 query prop 变了呢?在这个组件里,什么都不会发生。也就是说,组件的副作用没有响应 props 的变化。 这是 React 应用中非常常见的 bug 来源。

要修复这个组件,我们需要:

  • 查看 componentDidMount 及其调用的所有方法。
    • 本例中是 fetchResultsgetFetchUrl
  • 写下这些方法用到的所有 props 和 state。
    • 本例中是 this.props.query
  • 确保这些 props 变化时会重新执行副作用。
    • 可以通过添加 componentDidUpdate 实现。
class SearchResults extends React.Component {
  state = {
    data: null
  };
  componentDidMount() {
    this.fetchResults();
  }
  componentDidUpdate(prevProps) {
    if (prevProps.query !== this.props.query) { // ✅ 变化时重新请求
      this.fetchResults();
    }
  }
  fetchResults() {
    const url = this.getFetchUrl();
    // 执行数据请求...
  }
  getFetchUrl() {
    return 'http://myapi/results?query' + this.props.query; // ✅ 变化能被处理
  }
  render() {
    // ...
  }
}

现在我们的代码即使有副作用,也能响应 props 的所有变化。

但要记住不再犯同样的错并不容易。比如,我们可能会给本地 state 加个 currentPage,并在 getFetchUrl 用到它:

class SearchResults extends React.Component {
  state = {
    data: null,
    currentPage: 0,
  };
  componentDidMount() {
    this.fetchResults();
  }
  componentDidUpdate(prevProps) {
    if (prevProps.query !== this.props.query) {
      this.fetchResults();
    }
  }
  fetchResults() {
    const url = this.getFetchUrl();
    // 执行数据请求...
  }
  getFetchUrl() {
    return (
      'http://myapi/results?query' + this.props.query +
      '&page=' + this.state.currentPage // 🔴 变化被忽略
    );
  }
  render() {
    // ...
  }
}

可惜,我们的代码又出 bug 了,因为副作用没有响应 currentPage 的变化。

props 和 state 都是 React 数据流的一部分。无论渲染还是副作用,都应该响应数据流的变化,而不是忽略它们!

要修复代码,可以重复上面的步骤:

  • 查看 componentDidMount 及其调用的所有方法。
    • 本例中是 fetchResultsgetFetchUrl
  • 写下这些方法用到的所有 props 和 state。
    • 本例中是 this.props.query this.state.currentPage
  • 确保这些 props 或 state 变化时会重新执行副作用。
    • 可以通过修改 componentDidUpdate 实现。

让我们修复组件,使其能响应 currentPage 的变化:

class SearchResults extends React.Component {
  state = {
    data: null,
    currentPage: 0,
  };
  componentDidMount() {
    this.fetchResults();
  }
  componentDidUpdate(prevProps, prevState) {
    if (
      prevState.currentPage !== this.state.currentPage || // ✅ 变化时重新请求
      prevProps.query !== this.props.query
    ) {
      this.fetchResults();
    }
  }
  fetchResults() {
    const url = this.getFetchUrl();
    // 执行数据请求...
  }
  getFetchUrl() {
    return (
      'http://myapi/results?query' + this.props.query +
      '&page=' + this.state.currentPage // ✅ 变化能被处理
    );
  }
  render() {
    // ...
  }
}

要是能自动帮我们发现这些错误就好了! linter 能帮上忙吗?


不幸的是,自动检查 class 组件的一致性太难了。任何方法都可能调用其他方法。静态分析 componentDidMountcomponentDidUpdate 的调用链很容易出现误报。

但我们可以设计一种 API,让一致性可以被静态分析。React 的 useEffect Hook 就是这样的 API:

function SearchResults({ query }) {
  const [data, setData] = useState(null);
  const [currentPage, setCurrentPage] = useState(0);
 
  useEffect(() => {
    function fetchResults() {
      const url = getFetchUrl();
      // 执行数据请求...
    }
 
    function getFetchUrl() {
      return (
        'http://myapi/results?query' + query +
        '&page=' + currentPage
      );
    }
 
    fetchResults();
  }, [currentPage, query]); // ✅ 变化时重新请求
 
  // ...
}

我们把逻辑写在 effect 里,这样更容易看出依赖了哪些 React 数据流的值。这些值叫做“依赖项”,本例中就是 [currentPage, query]

注意,这个“effect 依赖项数组”其实不是新概念。在 class 组件里,我们要在所有方法调用链里找这些“依赖”。useEffect API 只是把这个概念显式化了。

这样,我们就能自动校验依赖项:

exhaustive-deps lint 规则演示

(这是 eslint-plugin-react-hooks 中新推荐的 exhaustive-deps lint 规则的演示。很快会被集成进 Create React App。)

无论你写 class 组件还是函数组件,副作用都要响应所有 prop 和 state 的变化,这一点很重要。

用 class API 时,你要自己思考一致性,确保每个相关 prop 或 state 的变化都被 componentDidUpdate 处理。否则你的组件无法适应 prop 和 state 的变化。这甚至不是 React 独有的问题,任何允许你分别处理“创建”和“更新”的 UI 库都一样。

useEffect API 通过鼓励一致性,改变了默认行为。一开始可能不太习惯,但这样你的组件对逻辑变化更有韧性。并且由于依赖项现在是显式的,我们可以用 lint 规则校验 effect 的一致性。我们用 linter 来抓 bug!


优化时不要阻断数据流

还有一种情况,你可能会在手动优化组件时不小心忽略 props 的变化。

注意,像 PureComponent 和默认比较的 React.memo 这种基于浅比较的优化方式是安全的。

但如果你自己写比较逻辑来“优化”组件,可能会忘了比较函数类型的 props:

class Button extends React.Component {
  shouldComponentUpdate(prevProps) {
    // 🔴 没有比较 this.props.onClick 
    return this.props.color !== prevProps.color;
  }
  render() {
    const onClick = this.props.onClick; // 🔴 无法响应 onClick 的变化
    const textColor = slowlyCalculateTextColor(this.props.color);
    return (
      <button
        onClick={onClick}
        className={'Button-' + this.props.color + ' Button-text-' + textColor}>
        {this.props.children}
      </button>
    );
  }
}

一开始很容易忽略这个问题,因为在 class 组件里,你通常会向下传递方法,它的引用通常是稳定的:

class MyForm extends React.Component {
  handleClick = () => { // ✅ 总是同一个函数
    // 执行操作
  }
  render() {
    return (
      <>
        <h1>Hello!</h1>
        <Button color='green' onClick={this.handleClick}>
          Press me
        </Button>
      </>
    )
  }
}

所以我们的优化并不会立刻出问题。但如果 onClick 随时间变化,而其他 props 没变,就会一直“看到”旧的 onClick 值:

class MyForm extends React.Component {
  state = {
    isEnabled: true
  };
  handleClick = () => {
    this.setState({ isEnabled: false });
    // 执行操作
  }
  render() {
    return (
      <>
        <h1>Hello!</h1>
        <Button color='green' onClick={
          // 🔴 Button 忽略了 onClick prop 的变化
          this.state.isEnabled ? this.handleClick : null
        }>
          Press me
        </Button>
      </>
    )
  }
}

在这个例子里,点击按钮应该会禁用它——但实际上不会,因为 Button 组件忽略了 onClick prop 的更新。

如果函数的引用本身依赖于随时间变化的内容,比如下面例子中的 draft.content,问题会更隐晦:

  drafts.map(draft =>
    <Button
      color='blue'
      key={draft.id}
      onClick={
        // 🔴 Button 忽略了 onClick prop 的变化
        this.handlePublish.bind(this, draft.content)
      }>
      Publish
    </Button>
  )

虽然 draft.content 可能会变,但我们的 Button 组件只会看到“第一次”绑定的 onClick,用的是最初的 draft.content

那该如何避免这个问题?

我建议避免手写 shouldComponentUpdate,也不要给 React.memo() 指定自定义比较函数。React.memo 的默认浅比较会正确处理函数引用的变化:

function Button({ onClick, color, children }) {
  const textColor = slowlyCalculateTextColor(color);
  return (
    <button
      onClick={onClick}
      className={'Button-' + color + ' Button-text-' + textColor}>
      {children}
    </button>
  );
}
export default React.memo(Button); // ✅ 使用浅比较

在 class 组件里,PureComponent 也是同样的行为。

这样可以确保传递不同的函数作为 prop 时总能生效。

如果你坚持要自定义比较,一定要记得比较函数类型的 prop:

  shouldComponentUpdate(prevProps) {
    // ✅ 比较了 this.props.onClick 
    return (
      this.props.color !== prevProps.color ||
      this.props.onClick !== prevProps.onClick
    );
  }

前面说过,在 class 组件里很容易忽略这个问题,因为方法引用通常是稳定的(但也不总是——这时 bug 就很难调试)。用 Hooks 时,情况有些不同:

  1. 每次渲染函数引用都会变,所以你会立刻发现这个问题
  2. useCallbackuseContext,你甚至可以避免深层传递函数。这样可以优化渲染而不用担心函数引用。

总结一下本节,不要阻断数据流!

每当你用到 props 和 state 时,都要考虑它们变化时应该发生什么。大多数情况下,组件不应该把初次渲染和后续更新区别对待。这样才能让组件适应逻辑的变化。

用 class 组件时,在生命周期方法里用 props 和 state 很容易忘记处理更新。Hooks 会引导你做正确的事——但如果你还没习惯这么做,需要一点思维转变。


原则二:始终准备好渲染

React 组件让你可以写渲染代码而不用太担心时间问题。你只需描述 UI 在任意时刻应该是什么样,React 会帮你实现。充分利用这种模型吧!

不要试图在组件行为中引入不必要的时序假设。你的组件应该随时准备好重新渲染。

怎么会违反这个原则?React 不太容易让你犯这种错——但用旧的 componentWillReceiveProps 生命周期方法就有可能:

class TextInput extends React.Component {
  state = {
    value: ''
  };
  // 🔴 每次父组件渲染都重置本地 state
  componentWillReceiveProps(nextProps) {
    this.setState({ value: nextProps.value });
  }
  handleChange = (e) => {
    this.setState({ value: e.target.value });
  };
  render() {
    return (
      <input
        value={this.state.value}
        onChange={this.handleChange}
      />
    );
  }
}

这个例子里,我们把 value 放在本地 state,同时又从 props 接收 value。每当“收到新 props”时,就重置 state 里的 value

这种模式的问题在于,它完全依赖于偶然的时序。

也许现在这个组件的父组件很少更新,所以我们的 TextInput 只在某些重要事件(比如保存表单)时才“收到新 props”。

但明天你可能会给 TextInput 的父组件加个动画。如果父组件更频繁地渲染,就会不断“吹掉”子组件的 state!你可以在“你可能不需要派生 state”一文中了解更多。

那该怎么修复?

首先,我们要修正自己的思维模型。不要把“收到 props”当成和“渲染”不同的事件。父组件引起的重新渲染,不应该和本地 state 变化引起的渲染有不同表现。组件应该能适应渲染频率的变化,否则就和特定父组件耦合太紧。

这个演示展示了渲染频率变化如何导致脆弱组件出错。)

虽然确实有一些不同的解决方案适用于确实需要从 props 派生 state 的场景,但通常你应该用全受控组件:

// 方案一:完全受控组件
function TextInput({ value, onChange }) {
  return (
    <input
      value={value}
      onChange={onChange}
    />
  );
}

或者用带 key 的非受控组件来重置它:

// 方案二:完全非受控组件
function TextInput() {
  const [value, setValue] = useState('');
  return (
    <input
      value={value}
      onChange={e => setValue(e.target.value)}
    />
  );
}
 
// 以后可以通过更改 key 重置内部 state:
<TextInput key={formId} />

本节的要点是,组件不应该因为自己或父组件渲染频率变化而出错。只要你避免用旧的 componentWillReceiveProps 生命周期方法,React 的 API 设计就能帮你做到这一点。

要给组件做压力测试,可以临时在父组件加上这段代码:

componentDidMount() {
  // 记得测试完马上删掉!
  setInterval(() => this.forceUpdate(), 100);
}

千万别把这段代码留在项目里——它只是用来快速测试父组件比你预期更频繁渲染时会发生什么。子组件不应该因此出错!


你可能会想:“我可以在 props 变化时重置 state,但用 PureComponent 防止不必要的重渲染。”

这段代码应该没问题吧?

// 🤔 应该能防止不必要的重渲染...对吗?
class TextInput extends React.PureComponent {
  state = {
    value: ''
  };
  // 🔴 每次父组件渲染都重置本地 state
  componentWillReceiveProps(nextProps) {
    this.setState({ value: nextProps.value });
  }
  handleChange = (e) => {
    this.setState({ value: e.target.value });
  };
  render() {
    return (
      <input
        value={this.state.value}
        onChange={this.handleChange}
      />
    );
  }
}

一开始看起来,这个组件解决了父组件重渲染时“吹掉” state 的问题。毕竟如果 props 没变,我们会跳过更新——componentWillReceiveProps 也不会被调用。

但这只是虚假的安全感。这个组件依然无法适应真正的 prop 变化。 比如,如果我们加了另一个经常变化的 prop,比如带动画的 style,就会不断“丢失”内部 state:

<TextInput
  style={{opacity: someValueFromState}}
  value={
    // 🔴 TextInput 里的 componentWillReceiveProps
    // 每次动画都会重置为这个值
    value
  }
/>

所以这种做法依然有问题。可以看到,PureComponentshouldComponentUpdateReact.memo 这类优化只能用来提升性能,不能用来控制行为。如果去掉优化会导致组件出错,说明组件本身就太脆弱了。

解决方案还是前面说的:不要把“收到 props”当成特殊事件。避免“同步” props 和 state。大多数情况下,每个值要么完全受控(通过 props),要么完全非受控(本地 state)。能不用派生 state 就不用(参考)始终准备好渲染!


原则三:没有组件是单例

有时我们会假设某个组件只会显示一次,比如导航栏。短期内这可能没问题,但这种假设往往会带来设计隐患,直到很久以后才暴露出来。

比如,你需要在路由切换时实现两个 Page 组件之间的动画——前一个 Page 和下一个 Page。动画期间它们都要挂载。但你可能会发现,每个组件都假设自己是唯一的 Page

很容易检查这类问题。试着把你的应用渲染两次:

ReactDOM.render(
  <>
    <MyApp />
    <MyApp />
  </>,
  document.getElementById('root')
);

点点看。可能需要调整下 CSS。

你的应用还能正常工作吗? 还是出现了奇怪的崩溃和报错?对复杂组件偶尔做一次这样的压力测试,确保多份实例不会互相冲突,是个好习惯。

我自己也写过的一个有问题的模式,就是在 componentWillUnmount 里做全局状态“清理”:

componentWillUnmount() {
  // 重置 Redux store 里的内容
  this.props.resetForm();
}

如果页面上有两个这样的组件,卸载其中一个就会破坏另一个。把全局状态“重置”放到挂载时也一样有问题:

componentDidMount() {
  // 重置 Redux store 里的内容
  this.props.resetForm();
}

这样一来,挂载第二个表单会破坏第一个。

这些模式都说明我们的组件很脆弱。显示隐藏一棵树,不应该影响树外的组件。

无论你是否打算把组件渲染多次,解决这些问题都很有价值。这样做会让你的设计更健壮。


原则四:让本地状态保持隔离

以社交媒体的 Post 组件为例。它有一组 Comment 线程(可以展开),还有一个 NewComment 输入框。

React 组件可以有本地状态。但什么状态才是真正的本地状态?帖子内容是本地状态吗?评论列表呢?哪些评论被展开了呢?评论输入框的值呢?

如果你习惯把所有东西都放到“状态管理器”里,这个问题可能很难回答。这里有个简单判断方法。

如果你不确定某个状态是不是本地的,问自己:“如果这个组件被渲染两次,这个交互应该反映到另一个副本上吗?”只要答案是“不”,那就是本地状态。

比如,假设我们把同一个 Post 渲染两次。来看里面不同的可变内容:

  • 帖子内容。 我们希望在一棵树里编辑帖子内容时,另一棵树也能同步更新。所以它不应该Post 组件的本地状态。(而是应该放在 Apollo、Relay 或 Redux 这样的缓存里。)

  • 评论列表。 这和帖子内容类似。我们希望在一棵树里新增评论时,另一棵树也能同步。所以它不应该Post 的本地状态。

  • 哪些评论被展开。 如果在一棵树里展开评论,另一棵树也跟着展开就很奇怪。这种情况下,我们操作的是 CommentUI 表现,而不是抽象的“评论实体”。所以“展开”标志应该Comment 的本地状态。

  • 新评论输入框的值。 如果在一个输入框里输入内容,另一个输入框也同步就很奇怪。除非输入框明确是成组的,否则大家通常希望它们互不影响。所以输入值应该NewComment 组件的本地状态。

我并不是说要死板地执行这些规则。当然,在简单应用里你甚至可以把所有东西都用本地 state,包括“缓存”。这里只是从第一性原理出发,讨论理想的用户体验。

避免把真正的本地状态提升为全局。 这也和“健壮性”主题相关:组件之间同步的意外情况更少。顺带一提,这还能解决很多性能问题。只要状态放对了位置,“过度渲染”就不再是大问题。


总结

让我们再回顾一遍这些原则:

  1. 不要阻断数据流。 props 和 state 会变化,组件应该随时处理这些变化。
  2. 始终准备好渲染。 组件不应该因为渲染频率变化而出错。
  3. 没有组件是单例。 即使组件只渲染一次,确保多次渲染也不会出错,会让你的设计更好。
  4. 让本地状态保持隔离。 思考哪些状态只属于某个 UI 表现——不要把它提升到不必要的位置。

这些原则能帮你写出易于变更的组件。它们易于添加、修改和删除。

最重要的是,当我们的组件足够健壮时,我们就可以回头继续纠结 props 到底要不要按字母排序了。

Pay what you like

Edit on GitHub