Tự học ReactJS: React Performance – Tối ưu app khi có nhiều component cùng re-render

Tự học ReactJS: React Performance – Tối ưu app khi có nhiều component cùng re-render

Vấn đề là gì?

Khi bạn build một React app thực sự lớn để phục vụ hàng triệu người dùng, chắc chắn bạn phải có giải pháp để quản lí state như Redux, React Context, Mobx … Và cho dù bạn xài thư viện nào để quản lí state bạn cũng sẽ gặp vấn đề khi state update, nó dẫn tới nhiều component cùng re-render làm chậm app của bạn.

Thỉnh thoảng nếu có một component làm quá nhiều tính toán nặng bên trong thì sẽ làm chậm quá trình re-render, nhưng với trường hợp này có thể dễ nhận ra và fix chỉ cho một component.

Nhưng nếu nhiều component re-render cùng một lúc và gây ảnh hưởng đến performance thì chúng ta rất khó để xác định được vấn đề.

Vậy làm sao chúng ta tối ưu app trong trường hợp này? Nếu component re-render trong khi các props của nó không thay đổi, chúng ta có thể sử dụng React.memo để tối ưu. Nhưng lạm dụng React.memo cũng gây ra các vấn đề sau:

  1. Nó làm tăng độ phức tạp của code (bởi vì trong một số trường hợp bạn cần sử dụng kết hợp React.useCallbackReact.memo, dẫn tới bạn phải quản lí một đống dependency list cho useCallback)
  2. Nếu sử dụng memo, React vẫn phải lưu trữ, tính toán nhiều để quyết định component có nên re-render hay không?

Cho nên rất khó chọn được cách tối ưu hoàn hảo. Chúng ta phải hi sinh một cái gì đó để đạt được performance. Quan trọng là có đáng hay không? Bạn phải căn nhắc kỹ lưỡng trước khi sử dụng một kỹ thuật nào đó để tối ưu performance. Có một cách đơn giản là chúng ta có thể bớt sử dụng global state và đẩy state đó về component state (state collocation). Đó có lẽ là cách dễ làm và hiệu quả nhất. Tham khảo bài viết về state collocation.

State collocation cho Dog Name

Giả sử chúng ta có một ứng dụng ở đây. Bạn thử giả lập slow CPU như các thiết bị mobile (6x slowdown) và tăng số lượng row & column lên khoảng 100×100.

Khi khách hàng sử dụng ứng dụng họ bắt đầu than phiền mỗi lần gõ vào input thì các ký tự phản hồi rất chậm trên màn hình (độ trễ khoảng 1s), đặc biệt là trên các thiết bị di dộng cùi bắp 🙂

Chúng ta đã React.memo cho <Cell /><Grid /> component, nhưng nó vẫn chậm. Mỗi khi người dùng gõ vào input Dog Name thì chúng ta chạy quá nhiều code trong quá trinh reconciliation cho Grid và Cell (100×100 component con)

import * as React from 'react'
import {
  useForceRerender,
  useDebouncedState,
  AppGrid,
  updateGridState,
  updateGridCellState,
} from '../utils'

const AppStateContext = React.createContext()
const AppDispatchContext = React.createContext()

const initialGrid = Array.from({length: 100}, () =>
  Array.from({length: 100}, () => Math.random() * 100),
)

function appReducer(state, action) {
  switch (action.type) {
    // xoá dogName khỏi context global
    // 💣 xoá dòng này
    case 'TYPED_IN_DOG_INPUT': {
      return {...state, dogName: action.dogName}
    }
    case 'UPDATE_GRID_CELL': {
      return {...state, grid: updateGridCellState(state.grid, action)}
    }
    case 'UPDATE_GRID': {
      return {...state, grid: updateGridState(state.grid)}
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

function AppProvider({children}) {
  const [state, dispatch] = React.useReducer(appReducer, {
    // 💣xoá dogName
    dogName: '',
    grid: initialGrid,
  })
  return (
    <AppStateContext.Provider value={state}>
      <AppDispatchContext.Provider value={dispatch}>
        {children}
      </AppDispatchContext.Provider>
    </AppStateContext.Provider>
  )
}

function useAppState() {
  const context = React.useContext(AppStateContext)
  if (!context) {
    throw new Error('useAppState must be used within the AppProvider')
  }
  return context
}

function useAppDispatch() {
  const context = React.useContext(AppDispatchContext)
  if (!context) {
    throw new Error('useAppDispatch must be used within the AppProvider')
  }
  return context
}

function Grid() {
  const dispatch = useAppDispatch()
  const [rows, setRows] = useDebouncedState(50)
  const [columns, setColumns] = useDebouncedState(50)
  const updateGridData = () => dispatch({type: 'UPDATE_GRID'})
  return (
    <AppGrid
      onUpdateGrid={updateGridData}
      rows={rows}
      handleRowsChange={setRows}
      columns={columns}
      handleColumnsChange={setColumns}
      Cell={Cell}
    />
  )
}
Grid = React.memo(Grid)

function Cell({row, column}) {
  const state = useAppState()
  const cell = state.grid[row][column]
  const dispatch = useAppDispatch()
  const handleClick = () => dispatch({type: 'UPDATE_GRID_CELL', row, column})
  return (
    <button
      className="cell"
      onClick={handleClick}
      style={{
        color: cell > 50 ? 'white' : 'black',
        backgroundColor: `rgba(0, 0, 0, ${cell / 100})`,
      }}
    >
      {Math.floor(cell)}
    </button>
  )
}
Cell = React.memo(Cell)

function DogNameInput() {
  // 🐨 thay thế useAppState and useAppDispatch với useState
  // quản lý locally dogName tại component này
  const state = useAppState()
  const dispatch = useAppDispatch()
  const {dogName} = state

  function handleChange(event) {
    const newDogName = event.target.value
    // 🐨 xoá dòng này thay bằng setDogName từ useState
    dispatch({type: 'TYPED_IN_DOG_INPUT', dogName: newDogName})
  }

  return (
    <form onSubmit={e => e.preventDefault()}>
      <label htmlFor="dogName">Dog Name</label>
      <input
        value={dogName}
        onChange={handleChange}
        id="dogName"
        placeholder="Toto"
      />
      {dogName ? (
        <div>
          <strong>{dogName}</strong>, I've a feeling we're not in Kansas anymore
        </div>
      ) : null}
    </form>
  )
}
function App() {
  const forceRerender = useForceRerender()
  return (
    <div className="grid-app">
      <button onClick={forceRerender}>force rerender</button>
      <AppProvider>
        <div>
          <DogNameInput />
          <Grid />
        </div>
      </AppProvider>
    </div>
  )
}

export default App

Sau đó chúng ta nhận ra rằng cần phải di chuyển global state của dogName về khai báo và quản lí bên trong component DogName. Bởi vì state dogName chỉ được sử dụng cho mỗi DogNamecomponent mà thôi. Như vây sau khi state collocation cho DogName thì mỗi khi thay đổi input thì chỉ có một component DogName re-render.

Đáp án:

function DogNameInput() {
  const [dogName, setDogName] = React.useState('')

  function handleChange(event) {
    const newDogName = event.target.value
    setDogName(newDogName)
  }

  return (
    <form onSubmit={e => e.preventDefault()}>
      <label htmlFor="dogName">Dog Name</label>
      <input
        value={dogName}
        onChange={handleChange}
        id="dogName"
        placeholder="Toto"
      />
      {dogName ? (
        <div>
          <strong>{dogName}</strong>, I've a feeling we're not in Kansas anymore
        </div>
      ) : null}
    </form>
  )
}

Tạo Dog Name context

Giả sử rằng state trong component <DogNameInput /> cần phải được quản lí ở global, bởi vì nó được sử dụng ở nhiều component khác nhau. Chúng ta nên làm gì trong trường hợp này? Chúng ta có môt tạo một React Context riêng cho <DogNameInput /> như sau:

const DogContext = React.createContext()

function dogReducer(state, action) {
  switch (action.type) {
    case 'TYPED_IN_DOG_INPUT': {
      return {...state, dogName: action.dogName}
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

function DogProvider(props) {
  const [state, dispatch] = React.useReducer(dogReducer, {dogName: ''})
  const value = [state, dispatch]
  return <DogContext.Provider value={value} {...props} />
}

function useDogState() {
  const context = React.useContext(DogContext)
  if (!context) {
    throw new Error('useDogState must be used within the DogStateProvider')
  }
  return context
}

function DogNameInput() {
  const [state, dispatch] = useDogState()
  const {dogName} = state

  function handleChange(event) {
    const newDogName = event.target.value
    dispatch({type: 'TYPED_IN_DOG_INPUT', dogName: newDogName})
  }

  return (
    <form onSubmit={e => e.preventDefault()}>
      <label htmlFor="dogName">Dog Name</label>
      <input
        value={dogName}
        onChange={handleChange}
        id="dogName"
        placeholder="Toto"
      />
      {dogName ? (
        <div>
          <strong>{dogName}</strong>, I've a feeling we're not in Kansas anymore
        </div>
      ) : null}
    </form>
  )
}

function App() {
  const forceRerender = useForceRerender()
  return (
    <div className="grid-app">
      <button onClick={forceRerender}>force rerender</button>
      <div>
        <DogProvider>
          <DogNameInput />
        </DogProvider>
        <AppProvider>
          <Grid />
        </AppProvider>
      </div>
    </div>
  )
}

Tạo một Provider riêng biệt cho <DogNameInput />

<DogProvider>
  <DogNameInput />
</DogProvider>

Với cách này khi thay đổi input Dog Name thì chỉ có component đó re-render. Do nó xài riêng Context.

Tối ưu consumer component

Hiện tại demo này, nếu bạn click vào một cell bất kì trên grid thì toàn bộ các cell khác đều bị re-render. Đây là những render ko cần thiết, bởi vì chỉ có một cell, được click mới bị thay đổi state ( đổi thành số mới của cell đó). Vậy có cách nào hạn chế re-render những cell còn lại không?

Câu trả lời là có. Hiện tại Cell component được viết như sau:

function Cell({row, column}) {
  const state = useAppState()
  const cell = state.grid[row][column]
  const dispatch = useAppDispatch()
  const handleClick = () => dispatch({type: 'UPDATE_GRID_CELL', row, column})
  return (
    <button
      className="cell"
      onClick={handleClick}
      style={{
        color: cell > 50 ? 'white' : 'black',
        backgroundColor: `rgba(0, 0, 0, ${cell / 100})`,
      }}
    >
      {Math.floor(cell)}
    </button>
  )
}
Cell = React.memo(Cell)

Như trên hình, trước khi tối ưu bạn có thể thấy thời gian hoàn thành khi click vào một cell là khoảng 200ms.

Có vẻ chúng ta đã memo cho Cell rồi, nhưng nó vẫn re-render. Lý do nó re-render là do nó sử dụng const state = useAppState() nên khi chúng ta gọi dispatch({type: 'UPDATE_GRID_CELL', row, column}) thì state đã bị thay đổi nên Cell re-render. Giả sử chúng ta tạo một component khác tên là CellWrap, có nhiệm vụ sử dụng useAppState và truyền cell như một props vào cho Cell component:

function CellWrap({row, column}) {
  const state = useAppState()
  const cell = state.grid[row][column]
  return <Cell cell={cell} row={row} column={column} />
}
CellWrap = React.memo(CellWrap)

// truyền cell như props thì memo mới có tác dụng cho Cell. Cool :)
function Cell({cell, row, column}) {
  const dispatch = useAppDispatch()
  const handleClick = () => dispatch({type: 'UPDATE_GRID_CELL', row, column})
  return (
    <button
      className="cell"
      onClick={handleClick}
      style={{
        color: cell > 50 ? 'white' : 'black',
        backgroundColor: `rgba(0, 0, 0, ${cell / 100})`,
      }}
    >
      {Math.floor(cell)}
    </button>
  )
}
Cell = React.memo(Cell)

Với thay đổi này, khi click vào từng cell thì chỉ có CellWrap bị re-render còn Cell thì không bị re-render. Điều này cải thiện tốt độ re-render rất nhiều bởi vì Cell đang xử lý rất nhiều tính toán bên trong.

Sau khi tối ưu thời gian hoàn thành của click handler được giảm còn khoảng 70ms🎉🎉🎉

Sử dụng HOC để lấy một phần của app state

Có thể bạn thấy cách tạo thêm một Wrapper component như trên quá rườm rà và khó sử dụng lại. Sau đây chúng ta có thể tạo wrapper component bằng HOC và lấy state cần thiết cho Cell component.

function withStateSlice(Comp, slice) {
  const MemoComp = React.memo(Comp)
  function Wrapper(props, ref) {
    const state = useAppState()
    const cell = slice(state, props)
    return <MemoComp ref={ref} state={cell} {...props} />
  }
  Wrapper.displayName = `withStateSlice(${Comp.displayName || Comp.name})`
  return React.memo(React.forwardRef(Wrapper))
}
function Cell({state: cell, row, column}) {
  const dispatch = useAppDispatch()
  const handleClick = () => dispatch({type: 'UPDATE_GRID_CELL', row, column})
  return (
    <button
      className="cell"
      onClick={handleClick}
      style={{
        color: cell > 50 ? 'white' : 'black',
        backgroundColor: `rgba(0, 0, 0, ${cell / 100})`,
      }}
    >
      {Math.floor(cell)}
    </button>
  )
}
Cell = withStateSlice(Cell, (state, {row, column}) => state.grid[row][column])

Với HOC withStateSlice, chúng ta chỉ lấy một phần state cần thiết cho cell và truyền vào như props cho Cell component. Cách này về bản chất cũng giống ở bài tập 3, nhưng với HOC thì linh hoạt hơn và dễ sử dụng lại hơn. Code đẹp hơn 🙂

Nguồn: Epic React by Kent C.Dodds

Leave a Comment