Elvis's blogs

React 学习笔记

2024年8月21日 • ☕️☕️ 12 min read

关于这篇文章

本篇文章中记录了笔者在学习 React 过程中的一些思考和对重点知识摘录的笔记,可能可以帮助你更好的理解 React 的知识,帮助你回顾可能会错过的重点。需要明确的是,这并不是一篇帮助你从零学习 React 的教程。

需要从零开始学习 React,React 官网上有非常优秀的教程文档,可以帮助你循序渐进的掌握 React 技术,理解 React 的设计理念,强烈建议从头开始阅读。

关于严格模式

image

image

严格模式中调用两次组件,貌似指的并不是只进行渲染阶段

如何处理在开发环境中 Effect 执行两次?

在开发环境中,React 有意重复挂载你的组件,以查找像上面示例中的错误。正确的态度是“如何修复 Effect 以便它在重复挂载后能正常工作”,而不是“如何只运行一次 Effect”

通常的解决办法是实现清理函数。清理函数应该停止或撤销 Effect 正在执行的任何操作。简单来说,用户不应该感受到 Effect 只执行一次(如在生产环境中)和执行“挂载 → 清理 → 挂载”过程(如在开发环境中)之间的差异。

下面提供一些常用的 Effect 应用模式。

保持组件纯粹

  • 一个组件必须是纯粹的,就意味着:
    • 只负责自己的任务。 它不会更改在该函数调用前就已存在的对象或变量。
    • 输入相同,则输出相同。 给定相同的输入,组件应该总是返回相同的 JSX。
  • 渲染随时可能发生,因此组件不应依赖于彼此的渲染顺序。
  • 你不应该改变任何用于组件渲染的输入。这包括 props、state 和 context。通过 “设置” state 来更新界面,而不要改变预先存在的对象。
  • 努力在你返回的 JSX 中表达你的组件逻辑。当你需要“改变事物”时,你通常希望在事件处理程序中进行。作为最后的手段,你可以使用 useEffect
  • 编写纯函数需要一些练习,但它充分释放了 React 范式的能力。

补充

React 将更新分为渲染提交[两个阶段](#React 何时添加 refs),渲染阶段会执行组件代码计算 jsx,这里对纯函数的要求也是对于这一阶段来说的,要求组件函数在相同输入的时候必须输出相同的 jsx。

但是组件的逻辑里面肯定是会出现副作用的,例如在组件初始的时候我们需要调用接口请求数据,这就是一个副作用。可以使用 Effect 控制接口调用的时机,Effect 会在提交阶段之后触发,这样可以保证在渲染阶段中组件依然是纯粹的。

(这貌似也解释了 useEffect 中 Effect 命名的由来了,正是为处理组件中的副作用而存在的)

State

React 官方文档花了一个篇章来讲解 state 相关的特性和原理,这足以说明 state 在 React 中的重要性。

将 state 视为只读的

你应该 把所有存放在 state 中的 JavaScript 对象都视为只读的。

你需要将 React state 中的值视为只读的,包括对象和数组。

为什么在 React 中不推荐直接修改 state?

有以下几个原因:

  • 调试:如果你使用 console.log 并且不直接修改 state,你之前日志中的 state 的值就不会被新的 state 变化所影响。这样你就可以清楚地看到两次渲染之间 state 的值发生了什么变化
  • 优化:React 常见的 优化策略 依赖于如果之前的 props 或者 state 的值和下一次相同就跳过渲染。如果你从未直接修改 state ,那么你就可以很快看到 state 是否发生了变化。如果 prevObj === obj,那么你就可以肯定这个对象内部并没有发生改变。
  • 新功能:我们正在构建的 React 的新功能依赖于 state 被 像快照一样看待 的理念。如果你直接修改 state 的历史版本,可能会影响你使用这些新功能。
  • 需求变更:有些应用功能在不出现任何修改的情况下会更容易实现,比如实现撤销/恢复、展示修改历史,或是允许用户把表单重置成某个之前的值。这是因为你可以把 state 之前的拷贝保存到内存中,并适时对其进行再次使用。如果一开始就用了直接修改 state 的方式,那么后面要实现这样的功能就会变得非常困难。
  • 更简单的实现:React 并不依赖于 mutation ,所以你不需要对对象进行任何特殊操作。它不需要像很多“响应式”的解决方案一样去劫持对象的属性、总是用代理把对象包裹起来,或者在初始化时做其他工作。这也是为什么 React 允许你把任何对象存放在 state 中——不管对象有多大——而不会造成有任何额外的性能或正确性问题的原因。

在实践中,你经常可以“侥幸”直接修改 state 而不出现什么问题,但是我们强烈建议你不要这样做,这样你就可以使用我们秉承着这种理念开发的 React 新功能。未来的贡献者甚至是你未来的自己都会感谢你的!

每次渲染时 state 的快照特性

一个 state 变量的值永远不会在一次渲染的内部发生变化, 即使其事件处理函数的代码是异步的。

import { useState } from 'react';

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

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setTimeout(() => {
          alert(number);
        }, 3000);
      }}>+5</button>
    </>
  )
}

上述示例,当点击按钮时 h1 中显示的 number 会马上变成 5,三秒后 alert 中显示的是 0

这似乎不符合预期:“在定时器结束后,h1 中的 number 都已经渲染成新的值 5,state 不是应该变成 5 了吗”

这种想法其实是因为对 useState 的功能存在误解,把 useState 当成了类似 vue 中 ref 的东西,所以觉得触发了重新渲染之后 state 的值会变成更新后的值。

但是使用 useState 只会获取本次渲染中 state 的值,此后,在这个组件方法内,state 的值就永远都不会变了。这其实也很好理解:在上面示例代码中,每次渲染会重新调用一次 Counter 函数计算新的 JSX。而在 Counter 函数中,使用 useState hook 获取并声明了 number 变量的值,后续在 Counter 函数内,number 的值都不会再变更了(其实从使用 const 声明变量也能体现这一点)。所以,当定时器里面的 alert 方法被调用时,使用的是本次渲染时 Counter 函数闭包中的变量 number —— 0。

理解上述内容的核心是明确 useState 的真正作用 —— 只是从 state 列表中获取当前 state 的值,列表中的 state 发生变化时,不会通知获取过 state 的地方。(这里的列表指 React 底层记录 state 的列表,具体请看:React 如何知道返回哪个 state

看到这里如果对 state 的快照特性还是不太理解,可以查看官方文档中的【state 如同一张快照】章节。文中提到的替代法可以帮助你更简单的判断某处的 state 在运行时应该是什么值。

state 批处理

React 会等到事件处理函数中的 所有 代码都运行完毕再处理你的 state 更新

React 会将当前函数作用域内的所有 setState 添加到一个队列中,等函数执行结束后,在一次渲染中按顺序执行队列中的计算函数,得出最终的 state 并重新渲染,例如:

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

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(5);
        setNumber(3);
        setNumber(n => n + 1);
        setNumber(n => n + 2);
      }}>增加数字</button>
    </>
  )
}
计算新 state 的函数 newState
n => 5 5
n => 3 3
n => n + 1 4
n => n + 2 6

所以,点击按钮之后,number 的值会变成 6

更新对象 state 的值

文档 将 state 视为只读的 小节中有这样一句话

虽然在一些情况下,直接修改 state 可能是有效的,但我们并不推荐这么做。

这里的“可能”,指的可能里应该包括这种情况:

import { useState } from 'react';
export default function MovingDot() {
  const [x, setX] = useState(0)
  const [position, setPosition] = useState({
    x: 0,
    y: 0
  });
  return (
    <div
      onPointerMove={e => {
        position.x = e.clientX;
        position.y = e.clientY;
        setX(Math.random());
      }}>
      ...
    </div>
  );
}

这里我添加了一个新的 state,并且在每次直接修改 position 后,通过给 x 设置一个随机数来强制触发新的渲染。

这样修改之后,可以发现红点也会跟着鼠标移动了。这是因为当你直接修改 position 对象 state时,由于对象浅拷贝的特性,会直接修改到 state 列表中的对象,只是这种方式不会触发重新渲染。

记录上述内容的本意不是为了教授一些“歪门邪道”,是感觉这个例子可以帮助理解 state 的原理。XD

修改数组 state 的技巧

下面是常见数组操作的参考表。当你操作 React state 中的数组时,你需要避免使用左列的方法,而首选右列的方法:

避免使用 (会改变原始数组) 推荐使用 (会返回一个新数组)
添加元素 pushunshift concat[...arr] 展开语法(例子
删除元素 popshiftsplice filterslice例子
替换元素 splicearr[i] = ... 赋值 map例子
排序 reversesort 先将数组复制一份(例子

核心还是遵守“将 state 视为只读的”原则,不进行会影响到原始数组的操作

Immer

你可以使用 Immer 来保持代码简洁。

为了不修改 state,我们需要使用很多技巧,例如上面记录的修改数组 state 的技巧。Immer 使用 Proxy,让我们可以按照平常修改对象和数组的方式直接修改 state。

Ref

ref 和 state 一样,可以记录值,不会在组件函数重新调用的时候被重置,但是修改 ref 的值不会触发新的渲染。

React 何时添加 refs

在 React 中,每次更新都分为 两个阶段

  • 渲染 阶段, React 调用你的组件来确定屏幕上应该显示什么。
  • 提交 阶段, React 把变更应用于 DOM。

在第一次渲染期间,DOM 节点尚未创建,因此 ref.current 将为 null

React 在提交阶段设置 ref.current

这里的渲染阶段指的是 react 通过调用函数计算 jsx 的过程,所以就是函数式组件执行的时候。由于组件函数执行时 ref.current 都不是我们预期的值(第一次调用时为 null,后续调用时显示的是上一次提交的值),所以我们不应该在渲染阶段使用 ref。

Effect 在提交后执行,我们可以在 Effect 中使用 ref 来获取到预期的值

...
useEffect(() => {
	console.log(ref.current)
})
...

Effect

useEffect 中 Effect 的生命周期

尝试从组件生命周期中跳脱出来,独立思考 Effect

不要把组件的生命周期和 Effect 的生命周期混在一起,Effect 的生命周期:

  1. 组件渲染
  2. 组件提交
  3. 比较之前记录的依赖项列表这次传入的依赖项列表是否有变更(使用 Object.is 进行比较)
  4. 如果依赖项有变更
    1. 执行 Effect 的清除方法
    2. 执行 Effect
  5. 重复上述步骤

使用响应式值作为依赖项

如果你还没有,请先阅读响应式 Effect 的生命周期

Props 和 state 并不是唯一的响应式值。从它们计算出的值也是响应式的。

可以用于作为依赖项的”响应式值“有三类:

  • prop
  • state
  • 使用 props / state 计算而来的值

个人感觉文档中对于响应式值这个概念的表述不是很好理解,其核心就是,一个值变化的时候,每次都伴随着重新渲染,那这个值就是响应式值。prop 和 state 很好理解,当这两类值变化的时候,组件都会重新渲染,重新执行 Effect 的生命周期。对于使用 prop 或 state 计算出来的值,只有当 prop 或 state 变化的时候,计算出来的值才会发生变化,所以也是某种意义上的:值变化了会触发重新渲染,重新执行 Effect 生命周期。Ref 和 组件之外的其他变量,这些值在修改之后,不会触发组件的重新渲染,所以不是响应式值。

useEffectEvent

使用 useEffectEvent 创建一个方法,可以确保调用的时候获取到最新的 prop 和 state

function App({ propA, propB }) {
    const onTick = useEffectEvent(() => {
        console.log(propB)
    })

    useEffect(() => {
		const interval = setInterval(() => {
            onTick()
        }, 1000)
        
        return {
            cleanInterval(interval)
        }
    }, [propA])
    
    return <div>app</div>
}

上方代码可以确保:只有 propA 变化的时候会触发 Effect 生命周期,而 propB 也是最新的值。

  • 当 propB 变化的时候,Effect 不会触发;
  • 当 propA 变更时,interval 会触发,如果在 1000 毫秒内,propB 也被修改,当 1000 后,onTick 中获取到的是最新的 propB,而不是 Effect 因 PropA 变更触发时的 propB。

useEffectEvent 的限制

Effect Event 的局限性在于你如何使用他们:

  • 只在 Effect 内部调用他们
  • 永远不要把他们传给其他的组件或者 Hook

为什么限制 useEffectEvent 只能在 Effect 内部调用?这是一些个人理解:

useEffectEvent 是一个类似后门的东西,是为了解决 “某些情况下,在 useEffect 中使用了响应式值,但是又不希望将这些值添加到依赖项列表中,只能通过抑制代码检查移除 React 的报错” 这种问题而设计的。

function App({ propA, propB }) {
    useEffect(() => {
		const interval = setInterval(() => {
             console.log(propB)
        }, 1000)
        
        return {
            cleanInterval(interval)
        }
    // 只希望 propA 变更的时候触发 Effect
    // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [propA])
    
    return <div>app</div>
}

如果在其他地方使用 useEffectEvent 获取最新的 prop 和 state,会违背前面 “保持组件纯粹” 的原则。因为已经无法保证组件的入参相同时,返回相同的 jsx 了。

而 Effect 本来就是处理副作用的地方,所以 useEffectEvent 这个设计只在 Effect 内部使用时不会破坏组件的纯粹。

避免对象和函数依赖

尽量避免对象和函数依赖。将它们移到组件外或 Effect 内。

父组件 prop

function Com({ options, getOptions }) {
    useEffect(() => {
    	console.log(options)
    }, [options])

    useEffect(() => {
    	console.log(getOptions())
    }, [getOptions])
}

function ParentCom () {
    const [stateA, setStateA] = useState('')
    
	const options = {
    	stateA
    }
    const getOptions = () => ({
    	stateA
    })
    
    return <Com options={options} getOptions={getOptions} />
}

当父组件每次重新渲染的时候,都会重新创建一个新的 options 对象和 getOptions 函数,这会导致子组件中的 useEffect 在每次父组件重新渲染的时候都被触发。

解决办法:

从 Effect 外部 读取对象信息,并避免依赖对象和函数类型

function Com({ options, getOptions }) {
    const { stateA } = options
    
    useEffect(() => {
    	console.log(stateA)
    }, [stateA])
    
    const { stateA: stateA2 } = getOptions()
    
    useEffect(() => {
    	console.log(stateA2)
    }, [stateA2])
    
    return <div>com</div>
}

// 父组件代码没有变化,这里省略

使用 useMemo / useCallback:

// 子组件代码没有变化,这里省略

function ParentCom () {
    const [stateA, setStateA] = useState('')
    
	const options = useMemo(() => ({
    	stateA
    }), [stateA])
    
    const getOptions = useCallback(() => ({
    	stateA
    }), [stateA])
    
    return <Com options={options} getOptions={getOptions} />
}

组件内声明

function Com() {
    const [stateA, setStateA] = useState('')

    const options = {
    	stateA
    }
    const getOptions = () => ({
    	stateA
    })

    useEffect(() => {
    	console.log(options)
    }, [options])

    useEffect(() => {
    	console.log(getOptions())
    }, [getOptions])
    
    return <div>com</div>
}

在每次组件重新渲染时,都会重新创建一个新的 options 对象和 getOptions 函数,所以 useEffect 在每次组件重新渲染时都会执行。

解决办法:

将动态对象和函数移动到 Effect 中

function Com() {
    const [stateA, setStateA] = useState('')

    useEffect(() => {
        const options = {
            stateA
        }
        
    	console.log(options)
    }, [stateA])

    useEffect(() => {
        const getOptions = () => ({
            stateA
        })
        
    	console.log(getOptions())
    }, [stateA])
    
    return <div>com</div> 
}

在组件内部,如果使用了对象或函数作为依赖项,React 会报错提醒你使用 useMemo / useCallback 进行修改:

function Com() {
    const [stateA, setStateA] = useState('')

    const options = {
    	stateA
    }
    // The 'options' object makes the dependencies of useEffect Hook (at line 18) change on every render. Move it inside the useEffect callback. Alternatively, wrap the initialization of 'options' in its own useMemo() Hook.
    
    const getOptions = () => ({
    	stateA
    })
	// The 'getOptions' function makes the dependencies of useEffect Hook (at line 18) change on every render. Move it inside the useEffect callback. Alternatively, wrap the definition of 'getOptions' in its own useCallback() Hook.

    useEffect(() => {
    	console.log(options)
    }, [options])

    useEffect(() => {
    	console.log(getOptions())
    }, [getOptions])
    
	return <div>com</div>
}

修改后:

...
const options = useMemo({
    stateA
}, [stateA])

const getOptions = useCallback(() => ({
    stateA
}, [stateA])
...