编写健壮的组件

参考:Writing Resilient Components - Dan Abramov

核心原则

有四个重要的组件设计原则,每个组件都应该努力遵循:

  1. 不要阻止数据流(Don’t Stop the Data Flow)
  2. 始终准备好渲染(Always Be Ready to Render)
  3. 没有组件是单例(No Component is a Singleton)
  4. 保持本地状态隔离(Keep the Local State Isolated)

原则 1:不要阻止数据流

不要在渲染中阻止数据流

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

// isOk 可能由状态驱动,可以随时改变
<Button color={isOk ? 'blue' : 'red'} />

一般来说,这就是 React 的默认工作方式。如果你在 Button 组件内部使用 color prop,你会看到该次渲染提供的值:

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

常见错误:将 props 复制到 state

//  错误:将 props 复制到 state
class Button extends React.Component {
  state = {
    color: this.props.color
  };
  render() {
    const { color } = this.state; //  `color` 是过时的!
    return <button className={'Button-' + color}>...</button>;
  }
}

问题:如果 color prop 改变,state 不会更新,因为 state 只在组件首次创建时初始化。

正确做法:直接使用 props

//  正确:直接使用 props
function Button({ color, children }) {
  return (
    <button className={'Button-' + color}>
      {children}
    </button>
  );
}

不要在 Effects 中阻止数据流

//  错误:在 effect 中复制 props 到 state
function Button({ color }) {
  const [internalColor, setInternalColor] = useState(color);
  
  useEffect(() => {
    setInternalColor(color);
  }, [color]);
  
  return <button className={'Button-' + internalColor}>...</button>;
}

问题:这增加了不必要的复杂性。直接使用 prop 更简单。

正确做法:直接使用 props

//  正确:直接使用 props
function Button({ color, children }) {
  return (
    <button className={'Button-' + color}>
      {children}
    </button>
  );
}

原则 2:始终准备好渲染

不要在渲染期间执行副作用

//  错误:在渲染期间执行副作用
function SearchResults({ query }) {
  if (query === null) {
    return null;
  }
  
  //  在渲染期间执行副作用
  fetchData(query);
  
  return <Results data={data} />;
}

问题:每次渲染都会触发数据获取,可能导致无限循环。

正确做法:在 effect 中执行副作用

//  正确:在 effect 中执行副作用
function SearchResults({ query }) {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    if (query !== null) {
      fetchData(query).then(setData);
    }
  }, [query]);
  
  if (query === null) {
    return null;
  }
  
  return <Results data={data} />;
}

不要假设组件只渲染一次

//  错误:假设组件只渲染一次
function Component() {
  useEffect(() => {
    //  假设这只会运行一次
    loadData();
  }, []);
  
  return <div>...</div>;
}

问题:组件可能会因为各种原因重新渲染(props 变化、父组件重新渲染等)。

正确做法:准备好多次渲染

//  正确:准备好多次渲染
function Component({ id }) {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    //  每次 id 变化时重新加载
    loadData(id).then(setData);
  }, [id]);
  
  if (!data) {
    return <div>Loading...</div>;
  }
  
  return <div>...</div>;
}

原则 3:没有组件是单例

不要假设组件只存在一个实例

//  错误:假设组件是单例
let isMounted = false;
 
function Component() {
  useEffect(() => {
    if (!isMounted) {
      isMounted = true;
      initialize();
    }
  }, []);
  
  return <div>...</div>;
}

问题:如果页面中有多个 Component 实例,只有第一个会初始化。

正确做法:每个实例独立管理状态

//  正确:每个实例独立管理状态
function Component() {
  useEffect(() => {
    initialize();
    return () => {
      cleanup();
    };
  }, []);
  
  return <div>...</div>;
}

不要使用模块级变量存储组件状态

//  错误:使用模块级变量
let cache = {};
 
function Component({ id }) {
  if (cache[id]) {
    return <div>{cache[id]}</div>;
  }
  
  useEffect(() => {
    fetchData(id).then(data => {
      cache[id] = data;
    });
  }, [id]);
  
  return <div>Loading...</div>;
}

问题:所有组件实例共享同一个缓存,可能导致状态混乱。

正确做法:使用 state 或 Context

//  正确:使用 state
function Component({ id }) {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    fetchData(id).then(setData);
  }, [id]);
  
  if (!data) {
    return <div>Loading...</div>;
  }
  
  return <div>{data}</div>;
}

原则 4:保持本地状态隔离

不要将不相关的状态放在一起

//  错误:不相关的状态放在一起
function Component() {
  const [state, setState] = useState({
    count: 0,
    name: 'Alice',
    items: []
  });
  
  return (
    <div>
      <button onClick={() => setState({ ...state, count: state.count + 1 })}>
        Count: {state.count}
      </button>
      <input 
        value={state.name} 
        onChange={e => setState({ ...state, name: e.target.value })} 
      />
    </div>
  );
}

问题:更新一个字段需要复制整个 state,容易出错。

正确做法:分离不相关的状态

//  正确:分离不相关的状态
function Component() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('Alice');
  const [items, setItems] = useState([]);
  
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        Count: {count}
      </button>
      <input 
        value={name} 
        onChange={e => setName(e.target.value)} 
      />
    </div>
  );
}

使用 useReducer 管理复杂状态

//  正确:使用 useReducer 管理复杂状态
function Component() {
  const [state, dispatch] = useReducer(reducer, {
    count: 0,
    name: 'Alice',
    items: []
  });
  
  return (
    <div>
      <button onClick={() => dispatch({ type: 'increment' })}>
        Count: {state.count}
      </button>
      <input 
        value={state.name} 
        onChange={e => dispatch({ type: 'setName', name: e.target.value })} 
      />
    </div>
  );
}

检查清单

在编写组件时,检查以下问题:

  • 组件是否直接使用 props,而不是复制到 state?
  • 组件是否准备好多次渲染?
  • 组件是否不假设自己是单例?
  • 组件是否将不相关的状态分离?
  • 组件是否在 effect 中执行副作用,而不是在渲染期间?
  • 组件是否使用 state 或 Context 管理状态,而不是模块级变量?

参考资源


关键要点:编写健壮的组件需要遵循数据流、准备好多次渲染、不假设单例、并保持状态隔离。这些原则适用于任何具有单向数据流的 UI 组件模型。