保持组件纯粹
部分JavaScript函数是纯粹的,这些函数通常被称为纯函数。纯函数近执行计算操作,不做其他操作。你可以通过将组件按纯函数严格编写,以避免一些随着代码库的增长而出现的、令人困扰的bug以及不可预测的行为。但为了获得这些好处,你需要遵循一些规则。
你将会学习到
-纯函数是什么,以及它如何帮助你避免bug
-如何将数据变更与渲染过程分离,以保持组件的纯粹
-如何使用严格模式发现组件中的错误
纯函数:组件作为公式
在计算机科学中(尤其是函数式编程的世界中),纯函数通常具有如下特征:
-只负责自己的任务。它不会更改在该函数调用前就已存在的对象或变量。
-输入相同,则输出相同。给定相同的输入,纯函数应总是返回相同的结果。
举个你非常熟悉的纯函数示例:数学中的公式。
考虑如下数学公式:y = 2x。
若x=2则y=4。永远如此。
若x=3 则y=6。永远如此
若x=3,那么y并不会因为时间或股市的影响,而有时等于9、-1或2.5。
若y=2x且x=3,那么y永远等于6。
我们使用JavaScript的函数实现,看起来将会是这样:
| function double(number) { return 2 * number; } |
上述例子中,double()就是一个纯函数。如果你传入3,它将总是返回6.
React便围绕着这个概念进行设计。React假设你编写的所有组件都是纯函数。也就是说,对于相同的输入,你所编写的React组价必须总是返回相同的JSX。
App.js:
| function Recipe({ drinkers }) { return ( <ol> <li>Boil {drinkers} cups of water.</li> <li>Add {drinkers} spoons of tea and {0.5 * drinkers} spoons of spice.</li> <li>Add {0.5 * drinkers} cups of milk to boil and sugar to taste.</li> </ol> ); } export default function App() { return ( <section> <h1>Spiced Chai Recipe</h1> <h2>For two</h2> <Recipe drinkers={2} /> <h2>For a gathering</h2> <Recipe drinkers={4} /> </section> ); } |

当你给函数Recipe传入drinkers={2}参数时,它将返回包含2 cups of water的JSX。永远如此。
而当你传入drinkers={4}时,它将返回包含4 cups of water的JSX。永远如此。
就像数学公式一样。
你可以把你的组件当做食谱:如果你遵循它们,并且在烹饪过程中不引入新食材,你每次都会得到相同的菜肴。那这道“菜谱”就是组件用于React渲染的JSX。

副作用:(不符合)预期的后果
React的渲染过程必须自始至终是纯粹的。组件应该只返回它们的JSX,而不改变在渲染前,就已存在的任何对象或变量-这将会使它们变得不纯粹!
以下是违反这一规则的组件示例:
App.js:
| let guest = 0; function Cup() { // Bad,正在更改预先存在的变量! guest = guest + 1; return <h2>Tea cup for guest #{guest}</h2> } export default function TeaSet() { return ( <> <Cup /> <Cup /> <Cup /> </> ); } |

该组件正在读其外部声明的guest变量。这意味着多次调用这个组件会产生不同的JSX!并且,如果其他组件读取guest,它们也会产生不同的JSX,其结果取决于它们合适被渲染!这是无法预测的。
回到我们的公式y = 2x,现在即使x = 2,我们也不能相信y = 4,我们的测试可能会失败,我们的用户可能会感到困扰,飞机可能会从天空坠毁—你将看到这会引发多么扑朔迷离的bugs!
你可以将guest作为prop传入来修复此组件:
App.js:
| function Cup({guest}) { return <h2>Tea cup for guest #{guest}</h2> } export default function TeaSet() { return ( <> <Cup guest={1}/> <Cup guest={2}/> <Cup guest={3}/> </> ); } |

现在你的组件就是纯粹的,因为它返回的JSX只依赖于guest prop。
一般来说,你不应该期望你的组件以任何特定的顺序被渲染。调用y=5x或y=2x的先后顺序并不重要:这两个公式相互独立。同样地,每个组件也应该“独立思考”,而不是在渲染过程中视图与其他组件协调,或者依赖于其他组件。渲染过程就像是一场学校考试:每个组件都应该自己计算JSX!
使用严格模式检测不纯的计算
尽管你可能还没使用过,但在React中,你可以在渲染时读取三种输入:props、state和context。你应该始终将这些输入视为只读。
当你想根据用户输入更改某些内容时,你应该设置状态,而不是直接写入变量。当你的组件正在渲染时,你永远不应该改变预先存在的变量或对象。
React提供了“严格模式”,在严格模式下开发时,它将会调用每个组件函数两次。通过重复调用组件函数,严格模式有助于找到违反这些规则的组件。
我们注意到,原始示例显示的是“Guest #2”、“Guest #4”和“Guest #6”,而不是“Guest #1”、“Guest #2”和“Guest #3”。原来的函数并不纯粹,因此调用它两次就出现了问题。但对于修复后的纯函数版本,即是调用该函数两次也能得到正确结果。纯函数仅仅执行计算,因此调用它们两次不会改变任何东西-就像两次调用double(2)并不会改变返回值,两次求解y = 2x 不会改变y的值一样。相同的输入,总是返回相同的输出。
严格模式在生产环境下不生效,因此它不会降低应用程序的速度。如需引入严格模式,你可以用<React.StrictMode>包裹根组件。一些框架会默认这样做。
局部mutation:组件的小秘密
上述示例的问题出在渲染过程中,组件改变了预先存在的变量的值。为了让它听起来可怕一点,我们将这种现象称为突变(mutation)。纯函数不会改变函数作用域外的变量、或在函数调用前创建的对象—这会使函数变得不纯粹!
但是,你完全可以在渲染时更改你刚刚创建的变量或对象。在本示例中,你创建一个[]数组,将其分配给一个cups变量,然后push-一打cup进去:
App.js:
| function Cup({guest}) { return <h2>Tea cup for guest #{guest}</h2> } export default function TeaGathering() { const cups = []; for (let i = 1; i <= 12; i++) { cups.push(<Cup key={i} guest={i} />); } return cups; } |

如果cups变量或[]数组是在TeaGathering函数之外创建的,这将是一个很大的问题!因为如果那样的话,当你调用数组的push方法时,就会更改预先存在的对象。
但是,这里不会有影响,因为每次渲染时,你都是在TeaGathering函数内部创建的它们。TeaGathering之外的代码并不会知道发生了什么。这就被称为“局部mutation”-如同藏在组件里的小秘密。
哪些地方 可能 引发副作用
函数式编程在很大程度上依赖于纯函数,但某些事物在特定情况下不得不发生改变。这是编程的要义!这些变动包括更新屏幕、启动动画、更改数据等,它们被称为副作用。它
们是“额外”发生的事情,与渲染过程无关。
在React中,副作用通常属于事物处理程序。事件处理程序是React在你执行某些操作(如单机按钮)时运行的函数。即使事件处理程序是在你的组件内部定义的,它们也不会在渲染期间运行!因此事件处理程序无需是纯函数。
如果你用尽一切办法,仍无法为副作用找到合适的事件处理程序,你还可以调用组件中的useEffect方法将其附加到返回的JSX中。这会告诉React在渲染结束后执行它。然而,这种方法应该是你最后的手段。
如果可能,请尝试仅通过渲染过程来表达你的逻辑。你会惊讶于这能带给你多少好处!
React为何侧重于纯函数?
编写纯函数需要遵循一些习惯和规程。但它开启了绝妙的机遇:
-你的组件可以在不用的环境下运行-例如,在服务器上!由于它们针对相同的输入,总是返回相同的额结果,因此一个组件可以满足多个用户需求。
-你可以为哪些输入未更改的组件来跳过渲染,以提高性能。这是安全的做法,因为纯函数总是返回相同的结果,所以可以安全地缓存它们。
-如果在渲染深层组件树的过程中,某些数据发生了变化,React可以重新开始渲染,而不会浪费时间完成过时的渲染。纯粹性使得它随时可以安全地停止计算。
我们正在构建的每个React新特性都利用到了纯函数。从数据获取到动画再到性能,保持组件的纯粹可以充分释放React范式的能力。
摘要
-个组件必须是纯粹的,就意味着:
--只负责自己的任务。它不会更改在该函数调用前就以存在的对象或变量。
--输入相同,则输出相同。给定相同的输入,组件应该总是返回相同的JSX。
-渲染随时可能发生,因此组件不应依赖于彼此的渲染顺序。
-你不应该改变任何用于组件渲染的输入。这包括props、state和context。通过“设置”state来更新界面,而不改变预先存在的对象。
-努力在你返回的JSX中表达你的组件逻辑。当你需要“改变事物”时,你通常希望在事件处理程序中进行。作为最后的手段,你可以使用useEffect。
-编写纯函数需要一些练习,但它充分释放了React范式的能力。
尝试一些挑战
修复坏掉的时钟
该组件尝试在午夜到早上6点期间,将<h1>的CSS类设置为“night”,而在其他时间都设置为“day”。但它不起作用。你能修复这个组件吗?
你可以临时更改计算机的时区来验证你的解决方案是否有效。当前时间位于午夜至早上六点之间时,时钟应该有相反的颜色!
Clock.js:
| export default function Clock({ time }) { const hours = time.getHours(); if (hours >= 0 && hours <= 6) { document.getElementById('time').className = 'night'; } else { document.getElementById('time').className = 'day'; } return ( <h1 id="time"> {time.toLocaleTimeString()} </h1> ); } |

答案:
你可以计算className,并将其包含在渲染的输出中,一次实现对组件的修复:
Clock.js:
| export default function Clock({ time }) { const hours = time.getHours(); let className; if (hours >= 0 && hours <= 6) { className = 'night'; } else { className = 'day'; } return ( <h1 className={className}> {time.toLocaleTimeString()} </h1> ); } |

在这个例子中,副作用(修改DOM)完全没有必要。你需要返回FSX。
修复损坏的材料
两个Profile组件使用不同的数据并排呈现。在第一个资料中点击“Collapse”折叠,然后点击“Expand”展开它。你会看到两个资料现在显示的是同一个人。这是一个bug。
找出产生bug的原因,并修复它。
Profile.js:
| import Panel from './Panel.js'; import { getImageUrl } from './utils.js'; let currentPerson; export default function Profile({ person }) { currentPerson = person; return ( <Panel> <Header /> <Avatar /> </Panel> ) } function Header() { return <h1>{currentPerson.name}</h1>; } function Avatar() { return ( <img className="avatar" src={getImageUrl(currentPerson)} alt={currentPerson.name} width={50} height={50} /> ); } |
App.js:
| import Profile from './Profile.js'; export default function App() { return ( <> <Profile person={{ imageId: 'lrWQx8l', name: 'Subrahmanyan Chandrasekhar', }} /> <Profile person={{ imageId: 'MK3eW3A', name: 'Creola Katherine Johnson', }} /> </> ) } |
初始状态

点击第一个collapse

答案:
问题在与Profile组件写入了一个预先存在的currentPerson变量,而Header和Avatar组件读取了这个变量。这使得三个组件都变得不纯粹且难以预测。
要修复这个bug,需要删除currentPerson变量。同时,通过props将所有信息从Profile传递到Header和Avatar中。你需要向两个组件各添加一个person prop,并将其一直向下传递。
Profile.js:
| import Panel from './Panel.js'; import { getImageUrl } from './utils.js'; export default function Profile({ person }) { return ( <Panel> <Header person={person} /> <Avatar person={person} /> </Panel> ) } function Header({ person }) { return <h1>{person.name}</h1>; } function Avatar({ person }) { return ( <img className="avatar" src={getImageUrl(person)} alt={person.name} width={50} height={50} /> ); } |
App.js:
| import Profile from './Profile.js'; export default function App() { return ( <> <Profile person={{ imageId: 'lrWQx8l', name: 'Subrahmanyan Chandrasekhar', }} /> <Profile person={{ imageId: 'MK3eW3A', name: 'Creola Katherine Johnson', }} /> </> ); } |
第一个按钮折叠再展开时没有改变图片内容

请记住,React无法保证组件函数以任何特定的顺序执行,因此你无法通过设置变量在它们之间进行通信。所有的交流都必须通过props进行。
修复损坏的故事集
你所在公司的CEO要求你在在线时钟app中添加“故事”,你不能拒绝。你编写了一个StoryTray组件,它接收一个stories列表,后跟一个“Create Story”占位符。
你在作为props的stories数组末尾push了一个假故事来实现“Create Story”。但处于某种原因,“Create Story”出现了不止一次。请修复这个问题。
StoryTray.js:
| export default function StoryTray({ stories }) { stories.push({ id: 'create', label: 'Create Story' }); return ( <ul> {stories.map(story => ( <li key={story.id}> {story.label} </li> ))} </ul> ); } |

答案:
请注意,每当时钟更新时,“Create Story”都会被添加两次。这暗示我们在渲染过程中发生了mutation-严格模式调用两次组件,可以使这些问题更加明显。
StoryTray的功能不纯粹。通过在接收到的stories数组(一个prop!)上调用push方法,它正改变着一个在StoryTray渲染之前创建的对象。这使得它有问题并且难以预测。
最简单的解决方案是完全不碰数组,单独渲染“Create Story”:
StoryTray.js:
| export default function StoryTray({ stories }) { return ( <ul> {stories.map(story => ( <li key={story.id}> {story.label} </li> ))} <li>Create Story</li> </ul> ); } |

或者,你可以在push之前创建一个新数组(通过复制现有数组)
StoryTray.js:
| export default function StoryTray({ stories }) { // 复制数组! const storiesToDisplay = stories.slice(); // 不影响原始数组 storiesToDisplay.push({ id: 'create', label: 'Create Story' }); return ( <ul> {storiesToDisplay.map(story => ( <li key={story.id}> {story.label} </li> ))} </ul> ); } |

这使你的mutation保持在局部,并使你的渲染函数保持纯粹。但你仍然需要小心:例如,当你想要更改数组的任意项时,必须先对齐进行拷贝。
记住数组上的哪些操作会修改原始数组、哪些不会,这非常有帮助。例如,push、pop、reverse和sort会改变原始数组,但slice、filter和map则会创建一个新数组。
246

被折叠的 条评论
为什么被折叠?



