React知识点汇总及面试题

1,390 阅读19分钟

1. JSX与虚拟DOM

JSX

JSX的本质是 React.createElement 的语法糖,返回的是 VDOM,在运行的时候,是需要通过 babel 编译 比如:

const ele = <div>123</div>

最终会被 babel 编译成下面的代码:

const ele = React.createElement(
    'div',
    {},
    "123"
)

而上面 createElement(vdom) 中的 vdom 其实就是虚拟DOM。

VDOM

VDOM 可以理解为 babel 编译后的一种 数据结构

const ele = (
    <div className='container'>
        div文本
        <h1> 我是h1 </h1>
        <div onClick={() => alert(123)}> 
            啊啊啊
        </div>
    </div>
)

在经过 babel 编译后会得到如下的结果:

image.png

简单点写就是:

const ele = {
    type: "div", //标签类型
    props: { // 标签上的属性,比如 class id click事件等等
        className: "container", 
    },
    children: [ // 子元素,同样的数据结构嵌套
        "div文本",
        {
            type: "h1",
            props: {},
            children: ["我是h1"]
        },
        {
            type: "div",
            props: {
                onClick: function() {
                    alert(123)
                }
            },
            children: [
                "啊啊啊"
            ]
        }
    ]
}

这就是 VDOM。

2. VDOM是如何渲染的

  • 通过 React.createElement 返回一个记录 VDOM的数据结构
// createElement 的逻辑
// type: html标签、自定义组件
// attrs: tyle、className、绑定的事件
// children: 子节点
function createElement(type, attrs, ...children) {
    //...children 是因为子节点数量不固定,拓展出来
    return {
        type,
        attrs,
        children
    }
}
  • 把这个记录 VDOM 的数据结构传入 React.render 函数中,render 函数内部通过不同的节点类型创建对应的真实DOM
    • 如果是文本节点,则通过 document.createTextNode 创建对应的文本Node,然后添加到 root 根节点,即 root.appendChild(TextNode)
    • 如果是标签,通过 document.createElement 创建对应的标签,然后去添加属性 attrs,递归创建子节点,最后添加到根节点 root.appendChild(childElements)
/**
 * 
 * @param {*} vnode 虚拟dom 
 * @param {*} container 根节点容器
 * @returns 
 */
function render(vnode, container) {
    //如果是文本类型
    if (isTextDom(vnode)) {
        const textNode = document.createTextNode(vnode);
        return container.appendChild(textNode);
    } else if (isElementVdom(vnode)) {
        //如果是元素类型
        //根据 type 创建对应的元素
        const dom = document.createElement(vnode.type);
        
        //添加属性
        if(vnode.attrs) {
            Object.keys(vnode.attrs).forEach((key) => {
               const value = vnode.attrs[key];
               setAttribute(dom, key, value);
            });
        }
        
        //递归 children 生成子节点真实dom
        vnode.children.forEach((child) => render(child, dom));
        //返回最终的结果
        return container.appendChild(dom);
    }
}

function isTextDom(vdom) {
    return typeof vdom === 'string' || vdom === 'number'
}

function isElementVdom(vdom) {
    return typeof vdom === 'object'&& typeof vdom.type === 'string'
}

function setAttribute(dom, key, value) {
    //如果是事件属性,通过 addEventListener 绑定
    if (typeof value == "function" && key.startsWith("on")) {
       const eventType = key.slice(2).toLowerCase();
       dom.addEventListener(eventType, value);
    } else if (key == "style" && typeof value == "object") {
       // 如果是 style
       Object.assign(dom.style, value);
    } else if (key === 'className') {
       // 如果是 className, 改成 class
       dom.setAttribute('class', value);
    } else if (typeof value != "object" && typeof value != "function") {
       // 如果是普通属性
       dom.setAttribute(key, value);
    }
}
  • 最后,将 render 的结果挂在到 ReactDOM(一般是root) 上
const ReactDOM = {
    render: (vnode, container) => {
        container.innerHTML = '';
        return render(vnode, container);
    }
}

3. Hooks

hooks 的运行分为两个阶段

mount挂载阶段

此时所有的 hook 都会调用 mountWorkInProgressHook 函数,该函数做了三个事情

  • 创建 hook 节点
  • 把当前新创建的hook拼接到 hooks 链表上
  • 返回新的 hooks 链表

image.png

返回的 hooks 链表最终挂载到 Fiber 节点的 memoizedState 字段上

举个例子:

const App = () => {
    const [state, setState] = useState(0);
    const ref = useRef(1);
    
    useEffect(() => {
        setTimeOut(() => {
            setState(2);
        }, 500);
    }, [])
}

我们依次使用了 useState、useRef、useEffect 三个 hooks,那么就会形成下面这种结构的链表:

  1. 调用 useState:
workInProgress.memoizedState: useState
                                 ^
                        workInProgressHook()

2. 调用 useRef:

workInProgress.memoizedState: useState -> useRef
                                            ^
                                    workInProgressHook()

3. 调用 useEffect:

workInProgress.memoizedState: useState -> useRef -> useEffect
                                                        ^
                                               workInProgressHook()

update更新阶段

此时所有的 hooks 都会调用 updateWorkInProgressHook 函数去更新对应的 hook 节点,再把更新后的 hook节点组装成一个新的 hooks 链表

需要注意的是:此时旧的 hooks 链表和新的 hooks 链表都存在,是为了提供 新旧对比的能力

详情请看:# React:通俗易懂的 hooks 链表

4. 为什么不能在条件、循环里面使用 hook

同样以下面的例子来看:

let ref = null;
let isFirst = true;
if (isFirst) {
    curRef = useRef(1);
    //初始化后将条件改为 false
    isFirst = false;
}

image.png

React 会根据 hook 的使用顺序,生成对应顺序的 hooks 链表。一旦在条件语句中声明hook,函数组件更新时,hooks 链表结构被破坏,如果涉及到读取state等操作,就会发生异常。因此不能在条件、循环语句中使用 hooks。

5. Fiber

Fiber 的含义

  • 架构层面:异步可中断的方案
  • 数据结构层面:保存了组件信息的特殊链表,包含三个重要的属性return(指向父节点)、child(指向子节点)、sibling(指向兄弟节点)

Fiber 是用来干嘛的

在 React16 以前,组件更新时,采用的是 递归遍历新旧 VDOM 树做对比。这会存在一个问题:递归时,如果 VDOM树层级很深,那么会长时间占用 JS 主线程,而 JS又是单线程的,且递归又是同步递归的,就会导致某些交互操作无法响应、卡顿等问题。

因此,Fiber 就是为了解决卡顿问题产生的一种机制

Fiber 的工作流程(架构层面)

  • Scheduler(调度器)给每个任务 赋予优先级

  • 优先级高的更新任务A,会被推入 Reconciler(协调器),VDOM 转 Fiber,然后和旧的 Fiber 进行 diff 对比决定怎样生成新的 Fiber 树 。但如果此时有新的更高优先级的更新任务B 进入 Scheduler,那么 A 就会被中断B被推入 Reconciler,当 B 完成渲染后。新一轮的调度开始,A 是新一轮中优先级最高的,那 A 就继续推入 Reconciler 执行更新任务

  • 重复以上的 可中断、可重复 步骤,直至所有更新任务完成渲染。

Fiber 的数据结构(数据结构层面)

前面说了, Fiber在数据结构层面是个保存了组件状态、信息的特殊链表,其中三个属性 return、child、sibling 构成了链表的指向。

function App() {
    return (
        <div>
            <p></p>
            <a></a>
        </div>
    )
}

变成 Fiber 链表后:

image.png

Fiber 双缓存机制

React 中最多会存在两颗 Fiber树:

  • currentFiber:页面中显示的内容
  • workInProgressFiber:内存中正在重新构建的 Fiber树。

当 workInProgressFiber 在内存中构建完成后,React 会直接用它 替换掉 currentFiber,这样能快速更新 DOM。一旦 workInProgressFiber 树渲染在页面上后,它就会变成 currentFiber 树,也就是说 fiberRootNode 会指向它。

image.png

6. Diff

diff 核心就是 查找复用节点

diff 的流程:

  • 第一轮遍历时,线性一一对比,若 新VDOM 的当前节点 和 旧的 Fiber 当前节点不能复用,则终止遍历。

  • 第二轮遍历时,将 旧的 Fiber 剩余节点放入 Map,继续遍历 新的VDOM 中的节点,寻找复用节点,打上更新标记。遍历完毕后,Map中剩下的节点打上删除标记,新的VDOM 中,没找到复用的节点打上新增标记。

最后根据变化,生成新的 Fiber,然后 Commit 阶段渲染。

下面直接图解diff过程:

第一轮遍历:

线性一一对比,当前 新VDOM 的节点A 和 旧的 Fiber 中的节点A 可以复用,打上更新标记,然后继续遍历 新VDOM 的下一节点C,发现节点C 和 Fiber 中的 节点B 不能复用,结束遍历。

image.png

此时 新VDOM 的节点还没遍历完,则进行第二次遍历

第二轮遍历:

  1. 接下来,把旧的 Fiber 中,剩下的 节点 B、C、D 放入一个 Map,key 就是节点的 key。
  2. 继续遍历 新VDOM 中剩下的节点,同样去找能不能复用的节点。比如发现只有 C 节点能在 Map 中找到,则打上更新标记。

遍历完毕后:

  1. Map 中剩下的 B、D 节点打上删除标记
  2. 新VDOM 中的 E、F 节点打上新增标记

图解如下: image.png

知道了节点如何变化后,生成新的 Fiber 如下:

image.png

至此,我们完成了 Diff。

7. React合成事件

React 为什么要做合成事件

React 合成事件是为了抹平各个浏览器的差异,在根节点统一进行事件的维护

可以通过 nativeEvent 属性访问到合成事件对应的原生DOM事件

React 合成事件的两个阶段

  • 事件绑定阶段

completeWork 函数里面执行,做了三件事

  1. 创建 DOM
  2. 把 DOM 插入到 DOM 树
  3. 为 DOM 设置属性

image.png

image.png

  • 事件触发阶段

事件触发时,会创建事件的优先级,收集 Fiber 上相关的事件,冒泡到根节点,然后执行

需要注意的是在 React17 后,事件的捕获阶段是:

document 捕获 -> 合成事件捕获 -> 原生事件捕获 -> 原生事件冒泡 -> 合成事件冒泡 -> document 冒泡(合成事件是在根节点上统一维护

image.png

React为什么不直接在元素上绑定事件

  • 根节点事件委托减少内存占用,动态更新组件时无需重新绑定事件

React合成事件与原生事件的区别

  • 合成事件跨浏览器统一行为
  • 原生事件直接操作DOM,无React抽象层

e.stopPropagation()能阻止原生事件吗

不能,e.stopPropagation()只能阻止React合成事件

如果要阻止原生事件

const handleClick = (e) => {
  e.stopPropagation();
  // 通过 nativeEvent 获取原生事件,阻止原生事件的默认方法
  e.nativeEvent.stopImmediatePropagation();
};

如何在React中访问原生事件对象?

通过 nativeEvent 属性

const handleClick = (e) => {
  const nativeEvent = e.nativeEvent; // 获取原生事件对象
  console.log(nativeEvent.type); // 输出:'click'
  console.log(nativeEvent.target); // 输出实际DOM节点
};

8. HOC

高价组件其实就是一个函数,传入一个组件,返回一个新的组件

作用:增强组件的功能,提升复用性

const withHoc = (Comp) => {
  return (props) => {
      // 复用的state
      const [state1, setState1] = useState();
      // 复用的回调
      const callback = () => {}
      // 增强 Comp 功能
      const newProps = {
        state1,
        callback,
        ...newProps,
      }
      // 返回新组建
      return (
        <>
          <Comp {...newProps} />
        </>
      );
  };
}

//在其他组件中使用
export default withHoc(App);

9. setState 相关

一系列state更新为什么只执行一次?

也就是经典三次 setState 后,state 值是 1

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 1);
        setNumber(number + 1);
        setNumber(number + 1);
      }}>+3</button>
    </>
  )
}

可以用装修房子来理解:

  • 我买了个房(Counter组件),然后装修成了一种风格(num = 0)
  • 然后我住久了审美疲劳了,我想换个风格,所以联系装修师傅(setNumber)讨论装修事宜
  • 我告诉装修师傅(setNunber)我想装修成新的风格(num + 1),并强调了三次要哪种风格(3次setNumber)
  • 最后装修师傅(setNumber)在我现在的风格(num = 0)的基础上,给我装修成了新风格(num + 1)

那如果是下面的情况呢?

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 1);
        setNumber(number + 2);
        setNumber(number + 3);
      }}>+3</button>
    </>
  )
}

这种情况一样的

  • 我买了个房(Counter组件),然后装修成了一种风格(num = 0)
  • 然后我住久了审美疲劳了,我想换个风格,所以联系装修师傅(setNumber)讨论装修事宜
  • 我告诉装修师傅(setNunber)我想装修成什么样的新风格
    • 第一次我告诉师傅 num + 1 的风格好
    • 第二次我想改风格了,告诉师傅 num + 2 的风格好
    • 第三次我又想改风格了,告诉师傅 num + 3 的风格好
  • 最后装修师傅(setNumber)在我现在的风格(num = 0)的基础上,给我装修成了最终敲定的风格(num + 3)

所以这种情况 number 是 3

那如果又来一种情况呢?

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number => number + 1);
        setNumber(number => number + 1);
        setNumber(number => number + 1);
      }}>+3</button>
    </>
  )
}
  • 我买了个房(Counter组件),然后装修成了一种风格(num = 0)
  • 然后我住久了审美疲劳了,我想换个风格,所以联系装修师傅(setNumber)讨论装修事宜
  • 我告诉装修师傅(setNunber)我想装修成什么样的新风格
    • 第一次我告诉师傅 num + 1 的风格好,师傅说好高效率给我装修好了(前一次是 num = 0 的风格,这一次是 num = 1 的风格)
    • 第二次我发现风格还不完善,又告诉师傅在此基础上 num + 1 的风格好,师傅说好高效率给我装修好了(前一次是 num = 1 的风格,这一次是 num = 2 的风格)
    • 第三次我发现风格还不完善,又告诉师傅在此基础上 num + 1 的风格好,师傅说好高效率给我装修好了(前一次是 num = 2 的风格,这一次是 num = 3 的风格)
  • 最终我的房子成了 num = 3 的风格

10. 如何跨层级通信

父组件---》子组件(通过 props)

// 父组件 App.js:
import React,{ Component } from "react";
import Sub from "./SubComponent.js";
import "./App.css";
export default class App extends Component{
    render(){
        return(
            <div>
                <Sub title = "今年过节不收礼" />
            </div>
        )
    }
}

// 子组件 SubComponent.js:
import React from "react";
const Sub = (props) => {
    return(
        <h1>
            { props.title }
        </h1>
    )
}

export default Sub;

子组件---》父组件(props回调)

//子组件代码:
import React from "react";
const Sub = (props) => {
    const cb = (msg) => {
        return () => {
            props.callback(msg)
        }
    }
    return(
        <div>
            <button onClick = { cb("我们通信吧") }>点击我</button>
        </div>
    )
}
export default Sub;

//父组件代码:
import React,{ Component } from "react";
import Sub from "./SubComponent.js";
import "./App.css";
export default class App extends Component{
    callback(msg){
        console.log(msg);
    }
    render(){
        return(
            <div>
                <Sub callback = { this.callback.bind(this) } />
            </div>
        )
    }
}

兄弟传参(通过父组件)

// 子组件1中的事件去触发定义在父组件中的自定义事件,并传入子组件中的变量
class Child1 extends Component {
  state = {
    count: 10,
  }
  change = () => {                                               //去触发父组件中定义的事件
    this.props.onchange(this.state.count)
  }
  render() {
    return (
      <>
        <div>{this.state.count}</div>
        <button onClick={this.change}>按钮</button>
      </>
    );
  }
}

// 父组件被子组件1的事件触发自己定义的事件,改变自己组件中的变量的值
class App extends React.Component {
  state = { count: 5 }
  Change = (count) => {
    this.setState({ count: count })
  }
  render() {
    return (
      <>
        <Child1 onchange={this.Change}></Child1>
        <Child2 count={this.state.count}></Child2>
      </>
    )
  }
}

// 子组件2用props去接收数据
const Child2 = (props) => {
  return <p>{props.count}</p>
}

class Done extends Component {
  render() {
    return (
      <>
        <p>{this.props.msg}</p>       //接收props数据
      </>
    );
  }
}

父组件调用子组件(useImperativeHandle)

// 子组件
import React, { useImperativeHandle, useRef } from 'react';

const ChildComponent = React.forwardRef((props, ref) => {
  // 子组件的方法
  const myMethod = () => {
    console.log('This is a method from ChildComponent');
  };

  // 使用useImperativeHandle来暴露方法
  useImperativeHandle(ref, () => ({
    myMethod: myMethod
  }));

  // 子组件的渲染逻辑
  return <div>I am the ChildComponent</div>;
});

// 父组件
const ParentComponent = () => {
  const childRef = useRef(null);

  // 调用子组件的方法
  const callChildMethod = () => {
    if (childRef.current) {
      childRef.current.myMethod();
    }
  };

  return (
    <div>
      <ChildComponent ref={childRef} />
      <button onClick={callChildMethod}>Call Child Method</button>
    </div>
  );
};

export default ParentComponent;

跨层级通信(context/状态管理三方库)

11. 受控组件和非受控组件

受控组件:

状态完全由外部通过 props 控制的组件,组件内部不会维护自己的状态。 一个简单的受控输入框组件:

import React from 'react';

const ControlledInput = ({ value, onChange }) => {
  return (
    <input type="text" value={value} onChange={onChange} />
  );
};

export default ControlledInput;

使用这个受控组件:

import React, { useState } from 'react';
import ControlledInput from './ControlledInput';

const App = () => {
  const [inputValue, setInputValue] = useState('');

  const handleChange = (event) => {
    setInputValue(event.target.value);
  };

  return (
    <div>
      <ControlledInput value={inputValue} onChange={handleChange} />
      <p>Current Value: {inputValue}</p>
    </div>
  );
};

export default App;

在这个例子中,ControlledInput 组件的值完全由 App 组件的状态 inputValue 控制。

非受控组件

状态由组件内部的 state 控制,外部不直接控制其状态

一个简单的非受控输入框组件:

import React, { useRef } from 'react';

const UncontrolledInput = () => {
  const inputRef = useRef(null);

  const handleClick = () => {
    alert(`Current Value: ${inputRef.current.value}`);
  };

  return (
    <div>
      <input type="text" ref={inputRef} />
      <button onClick={handleClick}>Show Value</button>
    </div>
  );
};

export default UncontrolledInput;

使用这个非受控组件:

import React from 'react';
import UncontrolledInput from './UncontrolledInput';

const App = () => {
  return (<UncontrolledInput />);
};

export default App;

在这个例子中,UncontrolledInput 组件的值由组件内部的 ref 控制,外部组件无法直接控制它。

12. useMemo、useCallback、memo

memo

  • 父组件重新渲染,没有被 memo 包裹的子组件也会重新渲染
  • 被 memo 包裹的组件只有在 props 改变后,才会重新渲染
  • memo 只会对新旧 props 做浅比较,所以对于引用类型的数据如果发生了更改,需要返回一个新的地址
  • memo 并不是用的越多越好,因为缓存本身也是需要开销的

useMemo

  • useMemo 是对计算的结果进行缓存,当缓存结果不变时,会使用缓存结果
  • useMemo 并不是用的越多越好,对于耗时长、性能开销大的地方,可以使用 useMemo 来优化,但大多数情况下,计算结果的开销还没有使用 useMemo 的开销大,应视情况而定
  • 当父组件传了一个引用类型的结果 result 给子组件,且子组件用 memo 包裹时,需要使用 useMemo 对 result 进行缓存,因为 memo 只对 props 做浅比较,当父组件重新渲染时,会重新在内存中开辟一个地址赋值给 result,此时地址发生改变,子组件会重新渲染

useCallback

  • useCallback 与 useMemo 类似,只不过是对函数进行缓存
  • useCallback 可以单独使用,但是单独使用的使用对性能优化并没有实质的提升,且父组件此时重新渲染,子组件同样会渲染
  • useCallback 需要配合 memo 一起使用,这样当父组件重新渲染时,缓存的函数的地址不会发生改变,memo 浅比较会认为 props 没有改变,因此子组件不会重新渲染

详情请看:# memo、useMemo、useCallback 你真的用明白了吗

13.React闭包陷阱

例子

import React, { useState, useEffect } from 'react';

const App = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setInterval(() => {
      console.log('setInterval:', count);
    }, 1000);
  }, []);

  return (
    <div>
      count: {count}
      <br />
      <button onClick={() => setCount(val => val + 1)}>点击加一</button>
    </div>
  );
};

export default App

上面的例子中,当点击 n 次按钮时,count 值会更新为 n,页面上展示的 count 也是 n,但是此时控制台 setInterval 打印的结果却是 0

image.png

React 闭包产生的原因

首先,hooks 其实是以 单向链表 的形式存储在 Fiber 节点的 memoizedState 属性上,而每一个 hooks 对应一个 hook 节点对象,它的结构如下:

const hook = {
    memoizedState: null,  // 存储当前这个 hooks 的值,比如 useState 存储的就是 state,useEffect 存储的就是 callback
    baseState: null,
    baseQueue: null,
    queue: null, 
    next: null, // 指向下一个 hook 节点
  };

对于上面的例子,当 App 组件初始化时,React 内部会调用 mountWorkInProgressHook 函数,创建 hook 节点,组装成 hooks 单向链表

image.png

然后当我们点击按钮, count 更新为 1 ,然后组件 rerender ,此时 React 内部会更新 hooks 链表,对于 useState 来说,它对应的 hook 节点会把记录的状态更新为 1,而对于 useEffect 来说,因为它的依赖是空数组,只在组件初始化时执行,所以此时可以理解为 useEffect 对应的 hook 节点不需要更新,直接复用旧的 useEffect 对应的 hook 节点

而前面的更新过程中,useEffect 旧的 hook 节点上保存的 callback 中, setInterval 里的回调函数对于 count 变量的使用还是 App 组件初始化时的那个 count,也就是 0,所以这里就形成了闭包的问题,setInterval 里面始终拿不到最新的 count

React 闭包的解决办法

对于上面的例子

  • useEffect 添加依赖项
const timer = useRef();

useEffect(() => {
  if (timer.current) {
    clearInterval(timer.current);
  }
  timer.current = setInterval(() => {
    console.log('setInterval:', count);
  }, 1000);
}, [count]);

控制台的输出:

image.png

  • useRef 存储最新的 state
import React, { useState, useEffect, useRef } from "react";

const App = () => {
  const [count, setCount] = useState(0);
  const lastCount = useRef(count);

  useEffect(() => {
    setInterval(() => {
      // useRef 在整个组件生命周期中不变
      console.log("setInterval:", lastCount.current);
    }, 1000);
  }, [count]);

  return (
    <div>
      count: {count}
      <br />
      <button
        onClick={() => {
          setCount((val) => val + 1);
          //点击加一时修改 lastCount.current 拿到最新的 count
          lastCount.current++;
        }}
      >
        点击加一
      </button>
    </div>
  );
};

export default App;

控制台的输出:

image.png

但这样写有个问题,我每点一次按钮,定时器就会多一个

详情请见

# ahooks源码系列(一):React 闭包陷阱

# React:通俗易懂的 hooks 链表

13 hook API相关

你了解过 useEffect 在组件挂载时运行两次的情况吗?

运行两次是因为 React 会有意重复挂载你的组件,目的是用于验证 useEffect 的逻辑是否正确。如果出现可见的问题,则表明 cleanup 函数缺少了部分逻辑

而这种情况我们的侧重点是:如何修复 useEffect 以便它在重复挂载后能正常工作,而不是如何只运行一次 useEffect

详情见官网

useEffect的各种机制

useEffect 的工作流程依赖于 React 的渲染周期:

  1. 依赖项比对

    React 会对比当前渲染和上一次渲染的依赖项数组(deps),如果不同,会重新执行 useEffect 的回调。

  2. 清理与执行

    • 组件挂载时执行 useEffect
    • 依赖项变化时,先执行上一次的 清理函数(如果存在),再执行新的 useEffect
    • 组件卸载时执行清理函数。

如果 Hook 的调用顺序不稳定,React 无法正确追踪哪些 useEffect 需要清理或更新

useRef和useState底层区别

useRef 和 useState 都是 React Hooks,但它们的用途和底层实现有显著不同:

特性useStateuseRef
存储的值存储状态,触发重新渲染存储可变值,不触发重新渲染
返回值[state, setState](状态 + 更新函数){current: value }(可变 ref 对象)
是否触发渲染✅ 调用 setState 会触发重新渲染❌ 修改 ref.current 不会触发渲染
底层存储位置在 Fiber 节点的memoizedState 链表里在 Fiber 节点的ref 属性上
使用场景需要 UI 响应的数据(如表单、计数)存储 DOM 引用、缓存变量、避免重复计算

底层实现对比

  • useState

    • React 在组件 Fiber 节点上维护一个 Hooks 链表,每个 useState 对应链表中的一个节点。
    • 调用 setState 会标记组件需要更新,触发 re-render
    • 状态变化后,React 会重新执行组件函数,并返回最新的 state
  • useRef

    • useRef 返回一个普通 JavaScript 对象 { current: initialValue }
    • 这个对象在组件的整个生命周期中保持不变(即使组件重新渲染)。
    • 修改 ref.current 不会触发 React 的更新机制,因为 React 不追踪 ref 的变化

useEffect能监听useRef的值发生改变吗?

useEffect 无法监听 useRef 的变化

原因分析

  1. useRef 的修改不会触发重新渲染

    useEffect 的依赖项机制依赖于 组件渲染,而 ref.current 的变化不会导致重新渲染,因此 useEffect 不会自动检测到变化。

  2. useRef 返回的对象始终是同一个引用

    const ref = useRef(0);
    // 即使修改 ref.current,ref 本身仍然是同一个对象
    ref.current = 1;// React 不会感知到这个变化
    

    由于 ref 对象在组件的整个生命周期中引用不变useEffect 的依赖项比对(Object.is)会认为它没有变化。

为什么 React 不直接支持监听 useRef

  1. 设计初衷不同

    • useRef 主要用于 存储可变值而不触发渲染(如 DOM 引用、定时器 ID)。
    • useState 才是用于 管理状态并触发 UI 更新 的。
  2. 性能优化

    • 如果 useRef 的每次修改都触发 useEffect,可能会导致不必要的副作用执行(比如频繁的 DOM 操作)。
  3. 避免滥用

    • 如果业务逻辑需要响应式更新,应该优先使用 useState 或 useReducer,而不是强行监听 useRef

如果要用 useEffect 监听 useRef 的变化,你怎么做?

可以通过自定义 hook 简洁监听

function useWatchableRef(initialValue) {
  const ref = useRef(initialValue);
  const [version, setVersion] = useState(0);

  const setRef = (value) => {
    ref.current = value;
    setVersion(v => v + 1); // 外部通过监听 version 更新触发 useEffect,从而间接监听 ref
  };

  return [ref, setRef, version];
}

function Component() {
  const [ref, setRef, refVersion] = useWatchableRef(0);

  useEffect(() => {
    console.log("ref changed:", ref.current);
  }, [refVersion]); // 监听 version 变化
}

useLayoutEffect与useEffect的区别

特性useLayoutEffectuseEffect
执行时机DOM 更新后、浏览器绘制前(同步)浏览器绘制后(异步)
堵塞渲染✅ 会阻塞浏览器绘制❌ 不阻塞
适用场景需要同步计算 DOM 布局的场景数据获取、事件订阅等副作用
源码阶段commitLayoutEffects 阶段执行commitBeforeMutationEffects 阶段调度

浏览器绘制前执行

举个例子:比如我们要实现一个toolTip,那么我们需要知道正确的 left、top值。而这些都是需要在浏览器绘制tooltip前搞定

import { useRef, useLayoutEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import TooltipContainer from './TooltipContainer.js';

export default function Tooltip({ children, targetRect }) {
  const ref = useRef(null);
  const [tooltipHeight, setTooltipHeight] = useState(0);

  useLayoutEffect(() => {
    const { height } = ref.current.getBoundingClientRect();
    setTooltipHeight(height);
    console.log('Measured tooltip height: ' + height);
  }, []);

  let tooltipX = 0;
  let tooltipY = 0;
  if (targetRect !== null) {
    tooltipX = targetRect.left;
    tooltipY = targetRect.top - tooltipHeight;
    if (tooltipY < 0) {
      // 它不适合上方,因此把它放在下面。
      tooltipY = targetRect.bottom;
    }
  }

  return createPortal(
    <TooltipContainer x={tooltipX} y={tooltipY} contentRef={ref}>
      {children}
    </TooltipContainer>,
    document.body
  );
}

上面代码的流程

  • 首先使用 tooltipHeight = 0 初始值渲染(此时tooltip的位置可能是错误的)
  • react执行useLayoutEffect,并计算出了准确了 left、top值,重新赋值 tooltipHeight,触发重新渲染
  • react使用最新的top left 值再次渲染 tooltip(此时tooltip的位置就正确了)
  • react在dom中更新,浏览器最终显示正确的tooltip

即 useLayoutEffect 在浏览器重绘前执行。

效果如下:

image.png

阻塞浏览器绘制

还是上面的例子,我们发现控制台其实执行了两遍 console

image.png

而对于用户来说,页面上呈现的结果就是第二次正确的tooltip,第一次的绘是被 useLayoutEffect 给阻塞掉了

总结

一句话来概括就是:useLayoutEffect DOM 更新后、在浏览器重绘前执行,并且在 useLayoutEffect 的 setup 函数执行完毕前,会阻塞浏览器的绘制

有useLayoutEffect实际使用场景吗?有的话说一下?

布局闪烁问题

布局闪烁是指当用户界面的元素在渲染后突然改变位置或尺寸,导致用户看到明显的跳动或闪烁效果

在 React 中发生布局闪烁的原因是当我们使用传统的useEffect时,元素变化是在渲染之后进行的

看这个例子:

function App(){
  const [content,setContent] = useState("11111111111111111111111111111111111111")
  
  //使用useEffect时:
  useEffect(() => {
    setContent('2222222222222222222222222222222222')
  },[])
  
  return (
    <div>{content}</div>
  )
}

当使用useEffect时:

  1. 组件首次渲染,content 初始值为 "1111..."(长字符串1)
  2. React 将 <div>1111...</div> 提交给浏览器绘制
  3. 用户短暂看到 "1111..." 的内容
  4. useEffect 回调执行,触发 setContent('2222...')
  5. 组件重新渲染,显示 <div>2222...</div>
  6. 用户看到内容从 "1111..." 变为 "2222..."(闪动过程)

现代浏览器和设备很高级,导致闪动的速度很快,你可能观察不到这个效果,但是在一些早期性能较低的设备中,对于页面中的一大堆内容,闪烁会给用户带来不好的用户体验

所以我们需要使用 useLayoutEffect ,这样做可以确保这些调整在用户看到屏幕之前完成,因此优化闪烁效果

function App(){
  const [content,setContent] = useState("11111111111111111111111111111111111111")
  
  //使用useLayoutEffect时:
  useLayoutEffect(() => {
    setContent('2222222222222222222222222222222222')
  },[])
  
  return (
    <div>{content}</div>
  )
}

这里的过程是这样的:

  1. 组件首次渲染,content 初始值为 "1111..."
  2. 在浏览器绘制前,useLayoutEffect 回调执行,设置 content 为 "2222..."
  3. 组件立即重新渲染,准备显示 <div>2222...</div>
  4. 浏览器绘制最终结果
  5. 用户直接看到 "2222...",没有看到中间状态

这种过程你可以理解为useLayoutEffect是和渲染同步进行的,因此就不会出现闪烁效果!

同步获取DOM属性

useLayoutEffect还有一种常见的应用场景就是,当需要同步读取 DOM 并立即基于读取的值进行更新时:

function AutoHeightTextarea() {
  const textareaRef = useRef(null);
  const [height, setHeight] = useState('auto');
  
  useLayoutEffect(() => {
    // 同步获取滚动高度并设置
    setHeight(`${textareaRef.current.scrollHeight}px`);
  }, [value]);

  return (
    <textarea
      ref={textareaRef}
      style={{ height }}
      value={value}
      onChange={handleChange}
    />
  );
}

在这个例子中,为什么必须使用 useLayoutEffect?

  • 我们需要在浏览器绘制前确保文本框高度已经调整
  • 任何延迟都会导致用户看到文本框高度变化的"跳跃"效果

代码执行流程如下:

image.png

15. react相关优化

usememo、memo、usecallback、lazy、suspense、React.Fragment 等不多说

删除不必要的依赖项

如果 react 依赖于渲染期间创建的对象或者函数,可能会频繁的运行

import { useState, useEffect } from 'react';

const serverUrl = 'https://localhost:1234';

function App({ roomId }) {
  const [message, setMessage] = useState('');
  
  const options = {
      serverUrl: serverUrl,
      roomId: roomId
  };

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [options]);

  return (
    <>
      <h1>Welcome to the {roomId} room!</h1>
      <input value={message} onChange={e => setMessage(e.target.value)} />
    </>
  );
}

上面的 options 每次渲染时都会新建,即引用地址每次都不同,那么 useEffect 以它为依赖项时,每次渲染时都会重新运行,所以需要按照下面的代码示例优化

import { useState, useEffect } from 'react';

const serverUrl = 'https://localhost:1234';

function App({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    const options = {
      serverUrl: serverUrl,
      roomId: roomId
    };
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

  return (
    <>
      <h1>Welcome to the {roomId} room!</h1>
      <input value={message} onChange={e => setMessage(e.target.value)} />
    </>
  );
}

把 options 放到 useEffect 里面创建,依赖项改为 roomId。这样的话只有当 roomId 变化时,useEffect 才会重新执行,且 options 的地址是不变的

注意匿名函数的使用情况

在下面的例子中,我们传递 匿名函数 props,且把 props.id 作为 callback 的参数一起使用。

这样有个问题是每次渲染上都有不同的引用,类似于上面依赖项的问题

function Component(props) { 
    return <AnotherComponent onChange={() => callback(props.id)} /> 
}

所以可以用 usecallback 处理下,保证 onChange 的引用是同一个

function Component(props) { 
    const onChange = useCallback(() => callback(props.id), [props.id])
    return <AnotherComponent onChange={() => callback(props.id)} /> 
}

当然大多数情况下“轻量级”组件 或者 父组件props改变时需要重新渲染所有内容时,可以忽略这部分

组件分情况的强制挂载和卸载

比如我们经常会根据不同状态展示不同的子组件

function Component(props) { 
    const [view, setView] = useState('view1'); 
    return view === 'view1' ? <SomeComponent /> : <AnotherComponent /> 
}

绝大部分情况下,子组件其实不太复杂,可以就这样写。但是当切换的子组件渲染负重比较大时,又触发重排又得渲染子组件,此时性能可能会受到较大影响。

比如我们通过 css 控制显示隐藏,并且当前展示的组件撑满容器

const visibleStyles = { opacity: 1 };
const hiddenStyles = { opacity: 0, transform: translateX(100%) };

function Component(props) {
  const [view, setView] = useState('view1');
  return (
    <React.Fragment>
      <SomeComponent style={view === 'view1' ? visibleStyles : hiddenStyles}>
      <AnotherComponent style={view !== 'view1' ? visibleStyles : hiddenStyles}>
    </React.Fragment>
  )
}

opcity、transform只会重绘不会重排,变相减少渲染负担,提高渲染优化

16. React底层相关

react批处理怎么做的

  1. React18前,只处理合成事件中的更新。比如下面的点击事件,多个setState会被合并成一次渲染
function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  const handleClick = () => {
    setCount(c => c + 1);  // 不会立即触发渲染
    setFlag(f => !f);      // 不会立即触发渲染
    // React 会将这两个更新合并为一次渲染
  };

  return <button onClick={handleClick}>Click</button>;
}

但是当setState在setTimeoutPromise原生事件里会立即触发更新

setTimeout(() => {
  setCount(c => c + 1); // 立即渲染
  setFlag(f => !f);     // 再次渲染
}, 0);
  1. React18后,所有场景自动批处理 (包括定时器、Promise、原生事件中的setState),使用调度器(Scheduler)管理优先级
// 以下所有场景都会批量处理!
function App() {
  const handleClick = () => {
    setCount(c => c + 1);
    setFlag(f => !f); 
    // 合并为一次渲染(即使放在 setTimeout 中)
  };

  const fetchData = async () => {
    const res = await fetch('/api');
    setData(res);
    setLoading(false); // 异步操作后仍会批量处理
  };
  
  useEffect(() => {
    fetchData()
  }, [])

  return <button onClick={handleClick}>Click</button>;
}

底层实现:

1、所有更新任务通过 lane 模型标记优先级,放入一个队列里面

比如上面的代码:

  • setCount(c => c + 1)、setFlag(f => !f) 属于点击事件里面的同一批更新,lane模型分配为 SyncLane 优先级
  • setData(res)、setLoading(false) 是请求数据中的同一批更新,lane 分配 DefaultLane 优先级

此时队列里面四个更新任务,分成两种优先级了

function dispatchSetState(fiber, queue, action) {
  const update = {
    lane: requestUpdateLane(fiber), // 分配优先级
    action,
    next: null
  };
  enqueueUpdate(fiber, queue, update); // 入队
  scheduleUpdateOnFiber(fiber); // 调度更新
}

2、合并调度,也就是把所有的setState合并成一次调度,减少渲染次数(优先执行优先级高的任务)

比如上面的例子,如果在请求数据的过程中,我触发了点击事件,则中断数据请求,执行点击事件里面的更新,然后在处理数据请求中的更新

function scheduleUpdateOnFiber(root, fiber, lane) {
  markRootUpdated(root, lane); // 标记待处理更新
  ensureRootIsScheduled(root); // 统一调度
}

你刚刚提到了 lane 模型,可以详细说说吗

lane模型是react调度任务时的一种思想机制。它为任务分配优先级,优先级以31位二进制表示,位数越小优先级越高,分为四个

  • 同步优先级 SyncLane,这个优先级最高,比如点击事件、键盘事件
  • 连续事件优先级 InputContinuousLane: 优先级第二,比如滚动事件、拖拽事件、输入框输入
  • 默认优先级 DefaultLane:优先级第三,比如数据请求
  • 过度优先级 TransitionLane:优先级最低

react在处理state更新时,会根据lane模型标记的优先级去处理。高优先级任务会抢占低优先级任务的执行权优先执行,并中断当前优先级低的任务

function ensureRootIsScheduled(root) {
  // 获取最高优先级任务
  const nextLanes = getNextLanes(root);
  if (nextLanes === NoLanes) return;

  // 如果存在更高优先级任务,取消当前任务
  if (existingCallbackPriority !== newCallbackPriority) {
    cancelCallback(existingCallbackNode);
  }
}

比如在数据请求时,执行了某个点击事件,则先处理点击事件中的更新

// 低优先级任务:数据加载
useEffect(() => {
  fetchData().then(data => setData(data)); // 分配到DefaultLane
}, []);

// 高优先级中断:用户点击按钮
const handleClick = () => {
  setCount(prev => prev + 1); // 分配到SyncLane,中断数据加载
};

React是如何调度的

可以直接用上面的解析回答

1、通过 lane 模型为任务分配优先级

export const SyncLane = 0b0000000000000000000000000000001;       
export const InputContinuousLane = 0b0000000000000000000000000000100; 
export const DefaultLane = 0b0000000000000000000000000010000;     
export const TransitionLanes = 0b0000000001111111111111111000000;    

2、按照优先级执行任务。高优先级可抢占低优先级任务的执行权。当低优先级任务执行过程中如果插入了高优先级任务,则低优先级任务中断,直到高优先级执行完毕

function ensureRootIsScheduled(root) {
  // 获取最高优先级任务
  const nextLanes = getNextLanes(root);
  if (nextLanes === NoLanes) return;

  // 如果存在更高优先级任务,取消当前任务
  if (existingCallbackPriority !== newCallbackPriority) {
    cancelCallback(existingCallbackNode);
  }
}

React可执行中断渲染是怎么做到的

1、关键机制:时间切片

React将一个渲染任务分成多个 Fiber工作单元, 在浏览器空闲时(requestIdleCallback 或 MessageChannel)执行

  • React执行更新任务
  • 调度器(Scheduler)在浏览器空闲时(requestIdCallback)执行任务
  • 处理单个Fiber工作单元
  • 处理完毕后,判断浏览器是否还有剩余空闲时间
  • 如果有,继续执行下一个Fiber工作单元
  • 如果没有,就暂停执行,并重新调度

2、暂停时,通过双缓存回复

双缓存

  • currentWork树:页面上呈现的内容
  • workInProgress树:内存中正在构建的新内容

当任务中断时,保存当前正在处理的 Fiber 的节点指针,可以理解为就是 workInProgress。 当任务重新开始时,就从上次中断的 Fiber 开始

function workLoopConcurrent() {
  // 如果有需要处理的 fiber,并且可以调度时
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
  // 如果被中断,workInProgress会保留当前节点
  workInProgress = currentFiber的指针
}