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

1. 引言

读了 精读《useEffect 彻底指南》 以后,是否是对 Function Component 的理解又加深了一些呢?html

此次经过 Writing Resilient Components 一文,了解一下什么是有弹性的组件,以及为何 Function Component 能够作到这一点。前端

2. 概述

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

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

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

以上规则不只适用于 React,它适用于全部 UI 组件。github

不要阻塞渲染的数据流

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

在 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 对加工过程进行缓存,仅当依赖变化时才从新执行:bash

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 许可证

相关文章
相关标签/搜索