阅读 2606

精读《编写有弹性的组件》

1. 引言

读了 精读《useEffect 完全指南》 之后,是不是对 Function Component 的理解又加深了一些呢?

这次通过 Writing Resilient Components 一文,了解一下什么是有弹性的组件,以及为什么 Function Component 可以做到这一点。

2. 概述

相比代码的 Lint 或者 Prettier,或许我们更应该关注代码是否具有弹性。

Dan 总结了弹性组件具有的四个特征:

  1. 不要阻塞数据流。
  2. 时刻准备好渲染。
  3. 不要有单例组件。
  4. 隔离本地状态。

以上规则不仅适用于 React,它适用于所有 UI 组件。

不要阻塞渲染的数据流

不阻塞数据流的意思,就是 不要将接收到的参数本地化, 或者 使组件完全受控

在 Class Component 语法下,由于有生命周期的概念,在某个生命周期将 props 存储到 state 的方式屡见不鲜。 然而一旦将 props 固化到 state,组件就不受控了:

class Button extends React.Component {
  state = {
    color: this.props.color
  };
  render() {
    const { color } = this.state; // 🔴 `color` is stale!
    return <button className={"Button-" + color}>{this.props.children}</button>;
  }
}
复制代码

当组件再次刷新时,props.color 变化了,但 state.color 不会变,这种情况就阻塞了数据流,小伙伴们可能会吐槽组件有 BUG。这时候如果你尝试通过其他生命周期(componentWillReceivePropscomponentDidUpdate)去修复,代码会变得难以管理。

然而 Function Component 没有生命周期的概念,所以没有必须要将 props 存储到 state,直接渲染即可:

function Button({ color, children }) {
  return (
    // ✅ `color` is always fresh!
    <button className={"Button-" + color}>{children}</button>
  );
}
复制代码

如果需要对 props 进行加工,可以利用 useMemo 对加工过程进行缓存,仅当依赖变化时才重新执行:

const textColor = useMemo(
  () => slowlyCalculateTextColor(color),
  [color] // ✅ Don’t recalculate until `color` changes
);
复制代码

不要阻塞副作用的数据流

发请求就是一种副作用,如果在一个组件内发请求,那么在取数参数变化时,最好能重新取数。

class SearchResults extends React.Component {
  state = {
    data: null
  };
  componentDidMount() {
    this.fetchResults();
  }
  componentDidUpdate(prevProps) {
    if (prevProps.query !== this.props.query) {
      // ✅ Refetch on change
      this.fetchResults();
    }
  }
  fetchResults() {
    const url = this.getFetchUrl();
    // Do the fetching...
  }
  getFetchUrl() {
    return "http://myapi/results?query" + this.props.query; // ✅ Updates are handled
  }
  render() {
    // ...
  }
}
复制代码

如果用 Class Component 的方式实现,我们需要将请求函数 getFetchUrl 抽出来,并且在 componentDidMountcomponentDidUpdate 时同时调用它,还要注意 componentDidUpdate 时如果取数参数 state.query 没有变化则不执行 getFetchUrl

这样的维护体验很糟糕,如果取数参数增加了 state.currentPage,你很可能在 componentDidUpdate 中漏掉对 state.currentPage 的判断。

如果使用 Function Component,可以通过 useCallback 将整个取数过程作为一个整体:

原文没有使用 useCallback,笔者进行了加工。

function SearchResults({ query }) {
  const [data, setData] = useState(null);
  const [currentPage, setCurrentPage] = useState(0);

  const fetchResults = useCallback(() => {
    return "http://myapi/results?query" + query + "&page=" + currentPage;
  }, [currentPage, query]);

  useEffect(() => {
    const url = getFetchUrl();
    // Do the fetching...
  }, [getFetchUrl]); // ✅ Refetch on change

  // ...
}
复制代码

Function Component 对 propsstate 的数据都一视同仁,且可以将取数逻辑与 “更新判断” 通过 useCallback 完全封装在一个函数内,再将这个函数作为整体依赖项添加到 useEffect,如果未来再新增一个参数,只要修改 fetchResults 这个函数即可,而且还可以通过 eslint-plugin-react-hooks 插件静态分析是否遗漏了依赖项。

Function Component 不但将依赖项聚合起来,还解决了 Class Component 分散在多处生命周期的函数判断,引发的无法静态分析依赖的问题。

不要因为性能优化而阻塞数据流

相比 PureComponentReact.memo,手动进行比较优化是不太安全的,比如你可能会忘记对函数进行对比:

class Button extends React.Component {
  shouldComponentUpdate(prevProps) {
    // 🔴 Doesn't compare this.props.onClick
    return this.props.color !== prevProps.color;
  }
  render() {
    const onClick = this.props.onClick; // 🔴 Doesn't reflect updates
    const textColor = slowlyCalculateTextColor(this.props.color);
    return (
      <button
        onClick={onClick}
        className={"Button-" + this.props.color + " Button-text-" + textColor}
      >
        {this.props.children}
      </button>
    );
  }
}
复制代码

上面的代码手动进行了 shouldComponentUpdate 对比优化,但是忽略了对函数参数 onClick 的对比,因此虽然大部分时间 onClick 确实没有变化,因此代码也不会有什么 bug:

class MyForm extends React.Component {
  handleClick = () => {
    // ✅ Always the same function
    // Do something
  };
  render() {
    return (
      <>
        <h1>Hello!</h1>
        <Button color="green" onClick={this.handleClick}>
          Press me
        </Button>
      </>
    );
  }
}
复制代码

但是一旦换一种方式实现 onClick,情况就不一样了,比如下面两种情况:

class MyForm extends React.Component {
  state = {
    isEnabled: true
  };
  handleClick = () => {
    this.setState({ isEnabled: false });
    // Do something
  };
  render() {
    return (
      <>
        <h1>Hello!</h1>
        <Button
          color="green"
          onClick={
            // 🔴 Button ignores updates to the onClick prop
            this.state.isEnabled ? this.handleClick : null
          }
        >
          Press me
        </Button>
      </>
    );
  }
}
复制代码

onClick 随机在 nullthis.handleClick 之间切换。

drafts.map(draft => (
  <Button
    color="blue"
    key={draft.id}
    onClick={
      // 🔴 Button ignores updates to the onClick prop
      this.handlePublish.bind(this, draft.content)
    }
  >
    Publish
  </Button>
));
复制代码

如果 draft.content 变化了,则 onClick 函数变化。

也就是如果子组件进行手动优化时,如果漏了对函数的对比,很有可能执行到旧的函数导致错误的逻辑。

所以尽量不要自己进行优化,同时在 Function Component 环境下,在内部申明的函数每次都有不同的引用,因此便于发现逻辑 BUG,同时利用 useCallbackuseContext 有助于解决这个问题。

时刻准备渲染

确保你的组件可以随时重渲染,且不会导致内部状态管理出现 BUG。

要做到这一点其实挺难的,比如一个复杂组件,如果接收了一个状态作为起点,之后的代码基于这个起点派生了许多内部状态,某个时刻改变了这个起始值,组件还能正常运行吗?

比如下面的代码:

// 🤔 Should prevent unnecessary re-renders... right?
class TextInput extends React.PureComponent {
  state = {
    value: ""
  };
  // 🔴 Resets local state on every parent render
  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} />;
  }
}
复制代码

componentWillReceiveProps 标识了每次组件接收到新的 props,都会将 props.value 同步到 state.value。这就是一种派生 state,虽然看上去可以做到优雅承接 props 的变化,但 父元素因为其他原因的 rerender 就会导致 state.value 非正常重置,比如父元素的 forceUpdate

当然可以通过 不要阻塞渲染的数据流 一节所说的方式,比如 PureComponent, shouldComponentUpdate, React.memo 来做性能优化(当 props.value 没有变化时就不会重置 state.value),但这样的代码依然是脆弱的。

健壮的代码不会因为删除了某项优化就出现 BUG,不要使用派生 state 就能避免此问题。

笔者补充:解决这个问题的方式是,1. 如果组件依赖了 props.value,就不需要使用 state.value,完全做成 受控组件。2. 如果必须有 state.value,那就做成内部状态,也就是不要从外部接收 props.value。总之避免写 “介于受控与非受控之间的组件”。

补充一下,如果做成了非受控组件,却想重置初始值,那么在父级调用处加上 key 来解决:

<EmailInput defaultEmail={this.props.user.email} key={this.props.user.id} />
复制代码

另外也可以通过 ref 解决,让子元素提供一个 reset 函数,不过不推荐使用 ref

不要有单例组件

一个有弹性的应用,应该能通过下面考验:

ReactDOM.render(
  <>
    <MyApp />
    <MyApp />
  </>,
  document.getElementById("root")
);
复制代码

将整个应用渲染两遍,看看是否能各自正确运作?

除了组件本地状态由本地维护外,具有弹性的组件不应该因为其他实例调用了某些函数,而 “永远错过了某些状态或功能”。

笔者补充:一个危险的组件一般是这么思考的:没有人会随意破坏数据流,因此只要在 didMountunMount 时做好数据初始化和销毁就行了。

那么当另一个实例进行销毁操作时,可能会破坏这个实例的中间状态。一个具有弹性的组件应该能 随时响应 状态的变化,没有生命周期概念的 Function Component 处理起来显然更得心应手。

隔离本地状态

很多时候难以判断数据属于组件的本地状态还是全局状态。

文章提供了一个判断方法:“想象这个组件同时渲染了两个实例,这个数据会同时影响这两个实例吗?如果答案是 不会,那这个数据就适合作为本地状态”。

尤其在写业务组件时,容易将业务数据与组件本身状态数据混淆。

根据笔者的经验,从上层业务到底层通用组件之间,本地状态数量是递增的:

业务
  -> 全局数据流
    -> 页面(完全依赖全局数据流,几乎没有自己的状态)
      -> 业务组件(从页面或全局数据流继承数据,很少有自己状态)
        -> 通用组件(完全受控,比如 input;或大量内聚状态的复杂通用逻辑,比如 monaco-editor)
复制代码

3. 精读

再次强调,一个有弹性的组件需要同时满足下面 4 个原则:

  1. 不要阻塞数据流。
  2. 时刻准备好渲染。
  3. 不要有单例组件。
  4. 隔离本地状态。

想要遵循这些规则看上去也不难,但实践过程中会遇到不少问题,笔者举几个例子。

频繁传递回调函数

Function Component 会导致组件粒度拆分的比较细,在提高可维护性同时,也会导致全局 state 成为过去,下面的代码可能让你觉得别扭:

const App = memo(function App() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("nick");

  return (
    <>
      <Count count={count} setCount={setCount}/>
      <Name name={name} setName={setName}/>
    </>
  );
});

const Count = memo(function Count(props) {
  return (
      <input value={props.count} onChange={pipeEvent(props.setCount)}>
  );
});

const Name = memo(function Name(props) {
  return (
  <input value={props.name} onChange={pipeEvent(props.setName)}>
  );
});
复制代码

虽然将子组件 CountName 拆分出来,逻辑更加解耦,但子组件需要更新父组件的状态就变得麻烦,我们不希望将函数作为参数透传给子组件。

一种办法是将函数通过 Context 传给子组件:

const SetCount = createContext(null)
const SetName = createContext(null)

const App = memo(function App() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("nick");

  return (
    <SetCount.Provider value={setCount}>
      <SetName.Provider value={setName}>
        <Count count={count}/>
        <Name name={name}/>
      </SetName.Provider>
    </SetCount.Provider>
  );
});

const Count = memo(function Count(props) {
  const setCount = useContext(SetCount)
  return (
      <input value={props.count} onChange={pipeEvent(setCount)}>
  );
});

const Name = memo(function Name(props) {
  const setName = useContext(SetName)
  return (
  <input value={props.name} onChange={pipeEvent(setName)}>
  );
});
复制代码

但这样会导致 Provider 过于臃肿,因此建议部分组件使用 useReducer 替代 useState,将函数合并到 dispatch

const AppDispatch = createContext(null)

class State = {
  count = 0
  name = 'nick'
}

function appReducer(state, action) {
  switch(action.type) {
    case 'setCount':
      return {
        ...state,
        count: action.value
      }
    case 'setName':
      return {
        ...state,
        name: action.value
      }
    default:
      return state
  }
}

const App = memo(function App() {
  const [state, dispatch] = useReducer(appReducer, new State())

  return (
    <AppDispatch.Provider value={dispaych}>
      <Count count={count}/>
      <Name name={name}/>
    </AppDispatch.Provider>
  );
});

const Count = memo(function Count(props) {
  const dispatch = useContext(AppDispatch)
  return (
      <input value={props.count} onChange={pipeEvent(value => dispatch({type: 'setCount', value}))}>
  );
});

const Name = memo(function Name(props) {
  const dispatch = useContext(AppDispatch)
  return (
  <input value={props.name} onChange={pipeEvent(pipeEvent(value => dispatch({type: 'setName', value})))}>
  );
});
复制代码

将状态聚合到 reducer 中,这样一个 ContextProvider 就能解决所有数据处理问题了。

memo 包裹的组件类似 PureComponent 效果。

useCallback 参数变化频繁

精读《useEffect 完全指南》 我们介绍了利用 useCallback 创建一个 Immutable 的函数:

function Form() {
  const [text, updateText] = useState("");

  const handleSubmit = useCallback(() => {
    const currentText = text;
    alert(currentText);
  }, [text]);

  return (
    <>
      <input value={text} onChange={e => updateText(e.target.value)} />
      <ExpensiveTree onSubmit={handleSubmit} />
    </>
  );
}
复制代码

但这个函数的依赖 [text] 变化过于频繁,以至于在每个 render 都会重新生成 handleSubmit 函数,对性能有一定影响。一种解决办法是利用 Ref 规避这个问题:

function Form() {
  const [text, updateText] = useState("");
  const textRef = useRef();

  useEffect(() => {
    textRef.current = text; // Write it to the ref
  });

  const handleSubmit = useCallback(() => {
    const currentText = textRef.current; // Read it from the ref
    alert(currentText);
  }, [textRef]); // Don't recreate handleSubmit like [text] would do

  return (
    <>
      <input value={text} onChange={e => updateText(e.target.value)} />
      <ExpensiveTree onSubmit={handleSubmit} />
    </>
  );
}
复制代码

当然,也可以将这个过程封装为一个自定义 Hooks,让代码稍微好看些:

function Form() {
  const [text, updateText] = useState("");
  // Will be memoized even if `text` changes:
  const handleSubmit = useEventCallback(() => {
    alert(text);
  }, [text]);

  return (
    <>
      <input value={text} onChange={e => updateText(e.target.value)} />
      <ExpensiveTree onSubmit={handleSubmit} />
    </>
  );
}

function useEventCallback(fn, dependencies) {
  const ref = useRef(() => {
    throw new Error("Cannot call an event handler while rendering.");
  });

  useEffect(() => {
    ref.current = fn;
  }, [fn, ...dependencies]);

  return useCallback(() => {
    const fn = ref.current;
    return fn();
  }, [ref]);
}
复制代码

不过这种方案并不优雅,React 考虑提供一个更优雅的方案

有可能被滥用的 useReducer

精读《useEffect 完全指南》 “将更新与动作解耦” 一节里提到了,利用 useReducer 解决 “函数同时依赖多个外部变量的问题”。

一般情况下,我们会这么使用 useReducer:

const reducer = (state, action) => {
  switch (action.type) {
    case "increment":
      return { value: state.value + 1 };
    case "decrement":
      return { value: state.value - 1 };
    case "incrementAmount":
      return { value: state.value + action.amount };
    default:
      throw new Error();
  }
};

const [state, dispatch] = useReducer(reducer, { value: 0 });
复制代码

但其实 useReducerstateaction 的定义可以很随意,因此我们可以利用 useReducer 打造一个 useState

比如我们创建一个拥有复数 key 的 useState:

const [state, setState] = useState({ count: 0, name: "nick" });

// 修改 count
setState(state => ({ ...state, count: 1 }));

// 修改 name
setState(state => ({ ...state, name: "jack" }));
复制代码

利用 useReducer 实现相似的功能:

function reducer(state, action) {
  return action(state);
}

const [state, dispatch] = useReducer(reducer, { count: 0, name: "nick" });

// 修改 count
dispatch(state => ({ ...state, count: 1 }));

// 修改 name
dispatch(state => ({ ...state, name: "jack" }));
复制代码

因此针对如上情况,我们可能滥用了 useReducer,建议直接用 useState 代替。

4. 总结

本文总结了具有弹性的组件的四个特性:不要阻塞数据流、时刻准备好渲染、不要有单例组件、隔离本地状态。

这个约定对代码质量很重要,而且难以通过 lint 规则或简单肉眼观察加以识别,因此推广起来还是有不小难度。

总的来说,Function Component 带来了更优雅的代码体验,但是对团队协作的要求也更高了。

讨论地址是:精读《编写有弹性的组件》 · Issue #139 · dt-fe/weekly

如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

关注 前端精读微信公众号

special Sponsors

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证

关注下面的标签,发现更多相似文章
评论