前端面试宝典之 React 篇 - 某一线大厂资深前端工程师 - 拉勾教育

你好,我是伯约,在开始第 3 讲之前,我先来公布下第 2 讲小作业的答案!

小作业答案

你可以先新建一个目录,就叫 babelTest 吧。

然后新建一个 .babelrc 文件,这个文件是 babel 的配置文件。因为需要自己实现 plugins,所以先写上。

然后实现一个解析 JSX 文件的插件,取名叫 ./jsx-parser。

  1. module.exports = function () {
  2. return {
  3. manipulateOptions: function manipulateOptions(opts, parserOpts) {
  4. parserOpts.plugins.push("jsx");
  5. }
  6. };
  7. };

把它加入 plugins 里,就像下面这样:

  1. {
  2. "plugins": ["./jsx-parser"]
  3. }

这是第一步,可以识别 jsx 文件,那么第二步来放一下上一讲讲到的源码,新建一个 jsx-plugin 文件,然后把上一讲的源码复制进去。

  1. {
  2. "plugins": ["jsx-plugin", "./jsx-parser"]
  3. }

接下来就需要一个 jsx 文件进行试验,新建 hello.jsx,写入下面的内容:

  1. function Test() {
  2. return <div><a>Hello~~~</a></div>
  3. }

完工,最后是用 babel 执行它。

  1. babel
  2. bash: command not found: babel

等等?为什么不能执行呢?因为没安装 babel 呀。

  1. npm install babel-cli -g
  2. babel hello.jsx

最后,看看输出,是不是有种彻底征服 JSX 的感觉呀?

接下我们进入正题,一起来聊聊在面试中 “如何避免生命周期中的坑?” 该如何回答。

破题

关于如何避免生命周期中的坑,你得先去理解问题中的潜台词。“如何避免坑?”更深层的意思是 “你蹚过多少坑?” 当然,你不可能把每次遇到的 Bug 都一一讲给面试官听,这会显得非常没有重点。

正如前两讲所说,答题的技巧更为重要。不仅需要对知识概念有体系化的认知——“讲概念,说用途,理思路,优缺点,来一遍”,还需要对你长期开发过程中的思考,有经验层面的方法总结。

“如何避免坑?”换种思维思考也就是 “为什么会有坑?” 在代码编写中,遇到的坑往往会有两种:

  • 在不恰当的时机调用了不合适的代码;
  • 在需要调用时,却忘记了调用。

就好比养花浇水,你需要在恰当的时机做恰当的事。比如在春季和秋季,浇水的时候最好选择在上午和下午;在夏季,适合在早上和晚上浇水;冬季的话,中午最适合浇水。

回到本题,在生命周期中出现的坑,那就一定跟生命周期有关。所以,通过梳理生命周期,明确周期函数职责,确认什么时候该做什么事儿,以此来避免坑。

承题

根据破题的思路,我们需要确立讨论的范围:

  • 基于周期的梳理,确认生命周期函数的使用方式;
  • 基于职责的梳理,确认生命周期函数的适用范围。

以此建立时机与操作的对应关系。

03 | 如何避免生命周期中的坑? - 图1

入手

概念

当我们在讨论 React 组件生命周期的时候,一定是在讨论类组件(Class Component)。函数式组件并没有生命周期的概念,因为它本身就是一个函数,只会从头执行到尾。

生命周期是一个抽象的概念,能让开发者产生联想记忆的往往是那些函数,比如 componentDidMount、componentWilMount 等等。然而这些函数并不是它的生命周期,只是在生命周期中按顺序被调用的函数。挂载 -> 更新 -> 卸载这一 React 组件完整的流程,才是生命周期。

流程梳理

接下来我将按照生命周期中函数的调用顺序,逐一列举被调用的函数。

挂载阶段

挂载阶段是指组件从初始化到完成加载的过程。

constructor

constructor 是类通用的构造函数,常用于初始化。所以在过去,constructor 通常用于初始化 state 与绑定函数。常见的写法如下:

  1. import React from 'react'
  2. class Counter extends React.Component {
  3. constructor(props) {
  4. super(props)
  5. this.state = {
  6. count: 0,
  7. }
  8. this.handleClick = this.handleClick.bind(this)
  9. }
  10. handleClick() {
  11. }
  12. render() {
  13. return null
  14. }
  15. }

那为什么会强调过去呢,因为当类属性开始流行之后,React 社区的写法发生了变化,即去除了 constructor。

  1. import React from 'react'
  2. class Counter extends React.Component {
  3. state = {
  4. count: 0,
  5. }
  6. handleClick = () => {
  7. }
  8. render() {
  9. return null
  10. }

社区中去除 constructor 的原因非常明确:

  • constructor 中并不推荐去处理初始化以外的逻辑;
  • 本身 constructor 并不属于 React 的生命周期,它只是 Class 的初始化函数;
  • 通过移除 constructor,代码也会变得更为简洁。

getDerivedStateFromProps

本函数的作用是使组件在 props 变化时更新 state,那它什么时候才会起效呢?它的触发时机是:

  • 当 props 被传入时;
  • state 发生变化时;
  • forceUpdate 被调用时。

最常见的一个错误是认为只有 props 发生变化时,getDerivedStateFromProps 才会被调用,而实际上只要父级组件重新渲染时,getDerivedStateFromProps 就会被调用。所以是外部参数,也就是 props 传入时就会发生变化。以下是官方文档中的例子:

  1. class ExampleComponent extends React.Component {
  2. state = {
  3. isScrollingDown: false,
  4. lastRow: null,
  5. };
  6. static getDerivedStateFromProps(props, state) {
  7. if (props.currentRow !== state.lastRow) {
  8. return {
  9. isScrollingDown: props.currentRow > state.lastRow,
  10. lastRow: props.currentRow,
  11. };
  12. }
  13. return null;
  14. }
  15. }

依据官方的说法,它的使用场景是很有限的。由于太多错误使用的案例,React 官方团队甚至写了一篇你可能不需要使用派生 state。文中主要列举了两种反模式的使用方式:

  1. 直接复制 prop 到 state;
  2. 在 props 变化后修改 state。

这两种写法除了增加代码的维护成本外,没有带来任何好处。

UNSAFE_componentWillMount

也就是 componentWillMount,本来用于组件即将加载前做某些操作,但目前被标记为弃用。因为在 React 的异步渲染机制下,该方法可能会被多次调用。

一个常见的错误是 componentWillMount 跟服务器端同构渲染的时候,如果在该函数里面发起网络请求,拉取数据,那么会在服务器端与客户端分别执行一次。所以更推荐在componentDidMount中完成数据拉取操作。

一个良好的设计应该是不让用户有较高的理解成本,而该函数却与之背道而驰所以已被标记弃用,还在用的同学们可以跟着迁出了。

render

render 函数返回的 JSX 结构,用于描述具体的渲染内容。但切记,render 函数并没有真正的去渲染组件,渲染是依靠 React 操作 JSX 描述结构来完成的。还有一点需要注意,render 函数应该是一个纯函数,不应该在里面产生副作用,比如调用 setState 或者绑定事件。

那为什么不能 setState 呢?因为 render 函数在每次渲染时都会被调用,而 setState 会触发渲染,就会造成死循环。

那又为什么不能绑定事件呢?因为容易被频繁调用注册。

componentDidMount

componentDidMount 主要用于组件加载完成时做某些操作,比如发起网络请求或者绑定事件,该函数是接着 render 之后调用的。但 componentDidMount 一定是在真实 DOM 绘制完成之后调用吗?在浏览器端,我们可以这么认为。

但在其他场景下,尤其是 React Native 场景下,componentDidMount 并不意味着真实的界面已绘制完毕。由于机器的性能所限,视图可能还在绘制中。

更新阶段

更新阶段是指外部 props 传入,或者 state 发生变化时的阶段。该阶段我们着重介绍下以下 6 个函数:

UNSAFE_componentWillReceiveProps

该函数已被标记弃用,因为其功能可被函数 getDerivedStateFromProps 所替代。

另外,当 getDerivedStateFromProps 存在时,UNSAFE_componentWillReceiveProps 不会被调用。

getDerivedStateFromProps

同挂载阶段的表现一致。

shouldComponentUpdate

该方法通过返回 true 或者 false 来确定是否需要触发新的渲染。因为渲染触发最后一道关卡,所以也是性能优化的必争之地。通过添加判断条件来阻止不必要的渲染。

React 官方提供了一个通用的优化方案,也就是 PureComponent。PureComponent 的核心原理就是默认实现了shouldComponentUpdate函数,在这个函数中对 props 和 state 进行浅比较,用来判断是否触发更新。

  1. shouldComponentUpdate(nextProps, nextState) {
  2. if (shadowEqual(nextProps, this.props) || shadowEqual(nextState, this.state) ) {
  3. return true
  4. }
  5. return false
  6. }

UNSAFE_componentWillUpdate

同样已废弃,因为后续的 React 异步渲染设计中,可能会出现组件暂停更新渲染的情况。

render

同挂载阶段一致

getSnapshotBeforeUpdate

getSnapshotBeforeUpdate 方法是配合 React 新的异步渲染的机制,在 DOM 更新发生前被调用,返回值将作为 componentDidUpdate 的第三个参数。

官方用例如下:

  1. class ScrollingList extends React.Component {
  2. constructor(props) {
  3. super(props);
  4. this.listRef = React.createRef();
  5. }
  6. getSnapshotBeforeUpdate(prevProps, prevState) {
  7. // Are we adding new items to the list?
  8. // Capture the scroll position so we can adjust scroll later.
  9. if (prevProps.list.length < this.props.list.length) {
  10. const list = this.listRef.current;
  11. return list.scrollHeight - list.scrollTop;
  12. }
  13. return null;
  14. }
  15. componentDidUpdate(prevProps, prevState, snapshot) {
  16. // If we have a snapshot value, we've just added new items.
  17. // Adjust scroll so these new items don't push the old ones out of view.
  18. // (snapshot here is the value returned from getSnapshotBeforeUpdate)
  19. if (snapshot !== null) {
  20. const list = this.listRef.current;
  21. list.scrollTop = list.scrollHeight - snapshot;
  22. }
  23. }
  24. render() {
  25. return (
  26. <div ref={this.listRef}>{/* ...contents... */}</div>
  27. );
  28. }

componentDidUpdate

正如上面的案例,getSnapshotBeforeUpdate 的返回值会作为componentDidUpdate的第三个参数使用。

componentDidUpdate**中可以使用 setState,会触发重渲染,但一定要小心使用,避免死循环。

卸载阶段

卸载阶段就容易很多了,只有一个回调函数。

componentWillUnmount

该函数主要用于执行清理工作。一个比较常见的 Bug 就是忘记在 componentWillUnmount 中取消定时器,导致定时操作依然在组件销毁后不停地执行。所以一定要在该阶段解除事件绑定,取消定时器。

小结

依照上面的梳理,你可以建立一个自己的知识导图了。

03 | 如何避免生命周期中的坑? - 图2

现在,你有没有发现,React 生命周期下的东西虽然很多,但是很清晰了。基本上看着前面的函数名字,就可以将整个生命周期的内容理下来了。

职责梳理

在梳理整个生命周期之后,需要再强调两个事情。

  • 什么情况下会触发重新渲染。
  • 渲染中发生报错后会怎样?又该如何处理?

如果我们的 React 应用足够复杂、渲染层级足够深时,一次重新渲染,将会消耗非常高的性能,导致卡顿等问题。下面 3 种情况都会触发重新渲染。

函数组件

函数组件任何情况下都会重新渲染。它并没有生命周期,但官方提供了一种方式优化手段,那就是 React.memo。

  1. const MyComponent = React.memo(function MyComponent(props) {
  2. });

React.memo 并不是阻断渲染,而是跳过渲染组件的操作并直接复用最近一次渲染的结果,这与 shouldComponentUpdate 是完全不同的。

React.Component

如果不实现 shouldComponentUpdate 函数,那么有两种情况触发重新渲染。

  1. 当 state 发生变化时。这个很好理解,是常见的情况。
  2. 当父级组件的 Props 传入时。无论 Props 有没有变化,只要传入就会引发重新渲染。

React.PureComponent

PureComponent 默认实现了 shouldComponentUpdate 函数。所以仅在 props 与 state 进行浅比较后,确认有变更时才会触发重新渲染。

错误边界

错误边界是一种 React 组件,这种组件可以捕获并打印发生在其子组件树任何位置的 JavaScript 错误,并且,它会渲染出备用 UI,如下 React 官方所给的示例:

  1. class ErrorBoundary extends React.Component {
  2. constructor(props) {
  3. super(props);
  4. this.state = { hasError: false };
  5. }
  6. static getDerivedStateFromError(error) {
  7. // 更新 state 使下一次渲染能够显示降级后的 UI
  8. return { hasError: true };
  9. }
  10. componentDidCatch(error, errorInfo) {
  11. // 你同样可以将错误日志上报给服务器
  12. logErrorToMyService(error, errorInfo);
  13. }
  14. render() {
  15. if (this.state.hasError) {
  16. // 你可以自定义降级后的 UI 并渲染
  17. return <h1>Something went wrong.</h1>;
  18. }
  19. return this.props.children;
  20. }
  21. }

无论是 React,还是 React Native,如果没有错误边界,在用户侧看到的现象会是这样的:在执行某个操作时,触发了 Bug,引发了崩溃,页面突然白屏。

但渲染时的报错,只能通过 componentDidCatch 捕获。这是在做线上页面报错监控时,极其容易忽略的点儿。

答题

经过以上的梳理,我们可以尝试答题了。

避免生命周期中的坑需要做好两件事:
不在恰当的时候调用了不该调用的代码;
在需要调用时,不要忘了调用。

那么主要有这么 7 种情况容易造成生命周期的坑。

getDerivedStateFromProps 容易编写反模式代码,使受控组件与非受控组件区分模糊。

componentWillMount 在 React 中已被标记弃用,不推荐使用,主要原因是新的异步渲染架构会导致它被多次调用。所以网络请求及事件绑定代码应移至 componentDidMount 中。

componentWillReceiveProps 同样被标记弃用,被 getDerivedStateFromProps 所取代,主要原因是性能问题。

shouldComponentUpdate 通过返回 true 或者 false 来确定是否需要触发新的渲染。主要用于性能优化。

componentWillUpdate 同样是由于新的异步渲染机制,而被标记废弃,不推荐使用,原先的逻辑可结合 getSnapshotBeforeUpdate 与 componentDidUpdate 改造使用。

如果在 componentWillUnmount 函数中忘记解除事件绑定,取消定时器等清理操作,容易引发 bug。

如果没有添加错误边界处理,当渲染发生异常时,用户将会看到一个无法操作的白屏,所以一定要添加。

进阶提问

“React 的请求应该放在哪里,为什么?” 这也是经常会被追问的问题。你可以这样回答。

对于异步请求,应该放在 componentDidMount 中去操作。从时间顺序来看,除了 componentDidMount 还可以有以下选择:

  • constructor:可以放,但从设计上而言不推荐。constructor 主要用于初始化 state 与函数绑定,并不承载业务逻辑。而且随着类属性的流行,constructor 已经很少使用了。
  • componentWillMount:已被标记废弃,在新的异步渲染架构下会触发多次渲染,容易引发 Bug,不利于未来 React 升级后的代码维护。

所以 React 的请求放在 componentDidMount 里是最好的选择。

总结

本讲主要梳理了 React 的生命周期,也梳理了 “坑” 在哪里,即不恰当的时机调用了不合适的代码;在需要调用时,却忘记了调用。通过本讲你不仅能掌握 React 的坑在哪里,还可以将这套理论用于同类型问题中去。

本讲中提到了函数组件,在下一讲中将着重讲述它与类组件的区别。


03 | 如何避免生命周期中的坑? - 图3
《大前端高薪训练营》

对标阿里 P7 技术需求 + 每月大厂内推,6 个月助你斩获名企高薪 Offer。点击链接,快来领取!