反应:提升状态将杀死您的应用程序

封面


您听说过“提起国家”吗? 我想你有,这就是为什么你在这里。 React官方文档中列出的12个主要概念之一可能会导致性能下降吗? 在本文中,我们将考虑实际情况。


步骤1:提起


我建议您创建一个简单的井字游戏。 对于游戏,我们需要:


  • 一些游戏状态。 没有真正的游戏逻辑来找出我们是输是赢。 只是一个简单的二维数组,其中填充了undefined"x""0".


     const size = 10 // Two-dimensional array (size * size) filled with `undefined`. Represents an empty field. const initialField = new Array(size).fill(new Array(size).fill(undefined)) 

  • 一个父容器来托管我们的游​​戏状态。


     const App = () => { const [field, setField] = useState(initialField) return ( <div> {field.map((row, rowI) => ( <div> {row.map((cell, cellI) => ( <Cell content={cell} setContent={ // Update a single cell of a two-dimensional array // and return a new two dimensional array (newContent) => setField([ // Copy rows before our target row ...field.slice(0, rowI), [ // Copy cells before our target cell ...field[rowI].slice(0, cellI), newContent, // Copy cells after our target cell ...field[rowI].slice(cellI + 1), ], // Copy rows after our target row ...field.slice(rowI + 1), ]) } /> ))} </div> ))} </div> ) } 

  • 显示单个单元格状态的子组件。


     const randomContent = () => (Math.random() > 0.5 ? 'x' : '0') const Cell = ({ content, setContent }) => ( <div onClick={() => setContent(randomContent())}>{content}</div> ) 


现场演示#1


到目前为止看起来还不错。 您可以以光速交互的完美反应场:)让我们增大尺寸。 假设是100。是的,是时候单击该演示链接并在最顶部更改size变量了。 还是为您快速? 尝试200或使用Chrome内置的CPU节流 。 您现在看到单击单元格的时间和其内容更改的时间之间有很大的滞后吗?


让我们将size更改回10,并添加一些配置文件以调查原因。


 const Cell = ({ content, setContent }) => { console.log('cell rendered') return <div onClick={() => setContent(randomContent())}>{content}</div> } 

现场演示#2


是的,就是这样。 只要在每个渲染器上运行,简单的console.log就足够了。


那我们看到了什么? 根据控制台中“单元格渲染”语句的数量(对于size = N,应为N),似乎每次单个单元格更改时都会重新渲染整个字段。


最明显的事情是按照React文档的建议添加一些密钥。


 <div> {field.map((row, rowI) => ( <div key={rowI}> {row.map((cell, cellI) => ( <Cell key={`row${rowI}cell${cellI}`} content={cell} setContent={(newContent) => setField([ ...field.slice(0, rowI), [ ...field[rowI].slice(0, cellI), newContent, ...field[rowI].slice(cellI + 1), ], ...field.slice(rowI + 1), ]) } /> ))} </div> ))} </div> 

现场演示#3


但是,再次增大size后,我们发现该问题仍然存在。 只要能看清为什么要渲染任何组件……幸运的是,我们可以在惊人的React DevTools的帮助下获得帮助。 它能够记录为什么渲染组件。 但是,您必须手动启用它。


React DevTools设置


启用后,我们可以看到所有单元都被重新渲染,因为它们的道具发生了变化,特别是setContent道具。


React DevTools报告#1


每个单元都有两个道具: contentsetContent 。 如果单元格[0] [0]更改,则单元格[0] [1]的内容不变。 另一方面, setContent在其闭包中捕获fieldcellIrowIcellIrowI保持不变,但是field随任何单元格的每次更改而改变。


让我们重构我们的代码,并保持setContent不变。


为了使对setContent的引用相同,我们应该删除闭包。 我们可以通过使Cell显式地将cellIrowI传递给setContent来消除cellIrowI闭包。 对于field ,我们可以利用setState的简洁功能- 它接受回调


 const [field, setField] = useState(initialField) // `useCallback` keeps reference to `setCell` the same. const setCell = useCallback( (rowI, cellI, newContent) => setField((oldField) => [ ...oldField.slice(0, rowI), [ ...oldField[rowI].slice(0, cellI), newContent, ...oldField[rowI].slice(cellI + 1), ], ...oldField.slice(rowI + 1), ]), [], ) 

这使得App看起来像这样


 <div> {field.map((row, rowI) => ( <div key={rowI}> {row.map((cell, cellI) => ( <Cell key={`row${rowI}cell${cellI}`} content={cell} rowI={rowI} cellI={cellI} setContent={setCell} /> ))} </div> ))} </div> 

现在, Cell必须将cellIrowI传递给setContent


 const Cell = ({ content, rowI, cellI, setContent }) => { console.log('cell render') return ( <div onClick={() => setContent(rowI, cellI, randomContent())}> {content} </div> ) } 

现场演示#4


让我们看一下DevTools报告。


React DevTools报告#2


什么 为什么说“父母的道具变了”? 所以事情是,每次我们的领域更新时, App都会重新渲染。 因此,其子组件将被重新渲染。 好啦 stackoverflow是否说了一些有关React性能优化的有用信息? Internet建议使用shouldComponentUpdate或其近亲: PureComponentmemo


 const Cell = memo(({ content, rowI, cellI, setContent }) => { console.log('cell render') return ( <div onClick={() => setContent(rowI, cellI, randomContent())}> {content} </div> ) }) 

现场演示#5


耶 现在,一旦其内容更改,仅一个单元会被重新渲染。 但是等等...有什么惊喜吗? 我们遵循最佳实践,并获得了预期的结果。


本来应该有一个邪恶的笑声。 由于我不在您身边,请尽量尝试想象一下。 继续并在Live demo#5中增加size 。 这次,您可能需要使用更大的数字。 但是,滞后仍然存在。 为什么???


让我们再次查看DebTools报告。


React DevTools报告#3


只有一个Cell渲染,而且速度相当快,但是还有一个App渲染,这花费了很多时间。 问题是,每次重新渲染App每个Cell都必须将其新道具与以前的道具进行比较。 即使决定不渲染(这正是我们的情况),该比较仍然需要时间。 O(1),但是O(1)出现size *大小乘以!


步骤2:将其向下移


我们可以做些什么来解决呢? 如果渲染App花费太多,我们必须停止渲染App 。 如果继续使用useStateApp托管我们的状态是useState ,因为这正是触发重新渲染的原因。 因此,我们必须向下移动状态,并让每个Cell自己订阅该状态。


让我们创建一个专用的类,该类将成为我们状态的容器。


 class Field { constructor(fieldSize) { this.size = fieldSize // Copy-paste from `initialState` this.data = new Array(this.size).fill(new Array(this.size).fill(undefined)) } cellContent(rowI, cellI) { return this.data[rowI][cellI] } // Copy-paste from old `setCell` setCell(rowI, cellI, newContent) { console.log('setCell') this.data = [ ...this.data.slice(0, rowI), [ ...this.data[rowI].slice(0, cellI), newContent, ...this.data[rowI].slice(cellI + 1), ], ...this.data.slice(rowI + 1), ] } map(cb) { return this.data.map(cb) } } const field = new Field(size) 

然后我们的App可能看起来像这样:


 const App = () => { return ( <div> {// As you can see we still need to iterate over our state to get indexes. field.map((row, rowI) => ( <div key={rowI}> {row.map((cell, cellI) => ( <Cell key={`row${rowI}cell${cellI}`} rowI={rowI} cellI={cellI} /> ))} </div> ))} </div> ) } 

并且我们的Cell可以自行显示field的内容:


 const Cell = ({ rowI, cellI }) => { console.log('cell render') const content = field.cellContent(rowI, cellI) return ( <div onClick={() => field.setCell(rowI, cellI, randomContent())}> {content} </div> ) } 

现场演示#6


至此,我们可以看到正在渲染的字段。 但是,如果单击单元格,则什么也不会发生。 在日志中,我们可以看到每次点击都显示“ setCell”,但是该单元格保持空白。 这里的原因是没有任何内容告诉单元重新渲染。 React之外的状态会发生变化,但是React对此一无所知。 那必须改变。


我们如何以编程方式触发渲染?


对于类,我们具有forceUpdate 。 这是否意味着我们必须将代码重新编写为类? 不完全是 我们可以对功能组件执行的操作是引入一些虚拟状态,我们仅对其进行更改以迫使我们的组件重新呈现。


这是我们如何创建自定义挂钩以强制重新渲染的方法。


 const useForceRender = () => { const [, setDummy] = useState(0) const forceRender = useCallback(() => setDummy((oldVal) => oldVal + 1), []) return forceRender } 

要在字段更新时触发重新渲染,我们必须知道它何时更新。 这意味着我们必须能够以某种方式订阅现场更新。


 class Field { constructor(fieldSize) { this.size = fieldSize this.data = new Array(this.size).fill(new Array(this.size).fill(undefined)) this.subscribers = {} } _cellSubscriberId(rowI, cellI) { return `row${rowI}cell${cellI}` } cellContent(rowI, cellI) { return this.data[rowI][cellI] } setCell(rowI, cellI, newContent) { console.log('setCell') this.data = [ ...this.data.slice(0, rowI), [ ...this.data[rowI].slice(0, cellI), newContent, ...this.data[rowI].slice(cellI + 1), ], ...this.data.slice(rowI + 1), ] const cellSubscriber = this.subscribers[this._cellSubscriberId(rowI, cellI)] if (cellSubscriber) { cellSubscriber() } } map(cb) { return this.data.map(cb) } // Note that we subscribe not to updates of the whole filed, but to updates of one cell only subscribeCellUpdates(rowI, cellI, onSetCellCallback) { this.subscribers[this._cellSubscriberId(rowI, cellI)] = onSetCellCallback } } 

现在我们可以订阅现场更新。


 const Cell = ({ rowI, cellI }) => { console.log('cell render') const forceRender = useForceRender() useEffect(() => field.subscribeCellUpdates(rowI, cellI, forceRender), [ forceRender, ]) const content = field.cellContent(rowI, cellI) return ( <div onClick={() => field.setCell(rowI, cellI, randomContent())}> {content} </div> ) } 

现场演示#7


让我们来看看这个实现的size 。 尝试将其增加到以前感觉很落后的值。 而且...是时候打开一瓶好香槟了! 我们得到了一个仅当一个单元格的状态发生变化时才渲染一个单元格和一个单元格的应用程序!


让我们看一下DevTools报告。


React DevTools报告#4


正如我们现在所看到的,只有Cell会被渲染,而且很快就疯狂了。


如果说现在我们的Cell代码是内存泄漏的潜在原因怎么办? 如您所见,在useEffect我们订阅了单元格更新,但我们从未取消订阅。 这意味着,即使Cell被销毁,其订阅仍然有效。 让我们改变它。


首先,我们需要告诉Field退订的含义。


 class Field { // ... unsubscribeCellUpdates(rowI, cellI) { delete this.subscribers[this._cellSubscriberId(rowI, cellI)] } } 

现在我们可以将unsubscribeCellUpdates应用于我们的Cell


 const Cell = ({ rowI, cellI }) => { console.log('cell render') const forceRender = useForceRender() useEffect(() => { field.subscribeCellUpdates(rowI, cellI, forceRender) return () => field.unsubscribeCellUpdates(rowI, cellI) }, [forceRender]) const content = field.cellContent(rowI, cellI) return ( <div onClick={() => field.setCell(rowI, cellI, randomContent())}> {content} </div> ) } 

现场演示#8


那么这是什么教训? 什么时候在组件树中向下移动状态? 永不! 好吧,不是真的:)坚持最佳实践,直到它们失败并且不做任何过早的优化。 坦率地说,我们在上面考虑的情况有些特定,但是,如果您需要显示非常大的列表,希望您能重新收集它。


奖励步骤:真实世界中的重构


现场演示#8中,我们使用了global field ,在实际应用中应该不是这种情况。 为了解决这个问题,我们可以在App托管field ,然后使用[context]()将其传递到树上。


 const AppContext = createContext() const App = () => { // Note how we used a factory to initialize our state here. // Field creation could be quite expensive for big fields. // So we don't want to create it each time we render and block the event loop. const [field] = useState(() => new Field(size)) return ( <AppContext.Provider value={field}> <div> {field.map((row, rowI) => ( <div key={rowI}> {row.map((cell, cellI) => ( <Cell key={`row${rowI}cell${cellI}`} rowI={rowI} cellI={cellI} /> ))} </div> ))} </div> </AppContext.Provider> ) } 

现在我们可以从Cell的上下文中消费field


 const Cell = ({ rowI, cellI }) => { console.log('cell render') const forceRender = useForceRender() const field = useContext(AppContext) useEffect(() => { field.subscribeCellUpdates(rowI, cellI, forceRender) return () => field.unsubscribeCellUpdates(rowI, cellI) }, [forceRender]) const content = field.cellContent(rowI, cellI) return ( <div onClick={() => field.setCell(rowI, cellI, randomContent())}> {content} </div> ) } 

现场演示#9


希望您已经找到了对您的项目有用的东西。 随时向我传达您的反馈! 我非常感谢任何批评和疑问。

Source: https://habr.com/ru/post/zh-CN471300/


All Articles