【译】结合源码读 React Hooks testing library 文档
【原文】:react-hooks-testing-library.com/installatio…
小声BB
本文在翻译过程中确保意思传达准确的前提下,会加入很多本人的个人解释和自己的一些吐槽(使用分割线标注)
最近项目组在例会要求大家补充单元测试完成覆盖率达标,比较尴尬的事情就是我还不会这个东西。因为整个项目比较大,旧的代码用的 classComponent
,新的需求基本又是使用的 hooks
,还在进行 hooks
改造,导致整个单元测试需要学习的场景和库就比较多(一个单测萌新的直观感受。
从开始学习到现在感觉仍然不得要领,越学越乱,总能遇到去查资料或者旧的测试代码才能捋清的测试场景。比如遇到文件中使用 Context 的要如何模拟?如何单独 mock 某个文件中的某个方法?什么时候用 wrap 去做渲染,什么时候用 shallow?这些东西都得慢慢实践摸索,不是一蹴而就的事情。
放假在家想着把上周学的 React Hooks testing library 的文档给整一整。搜了一下 issue 发现目前没有对应 pr,只找到一个这个。
大概就是这个库为了兼容 React18,会迁走(我还没上车)。。。。。。
但是 新的文档 和这个库里面的 API 大差不差。对这个专门针对 Hooks 的测试库的文档的翻译工作还是很有必要的,开整!
译文
介绍
react-hooks-testing-library
🐐
小而美的 React Hooks
的测试工具,鼓励好的测试实践。
痛点
你写了一个很酷的自定义 hook,然后你想测试它,你在测试文件中直接调用它就会看到下面的报错:
违反不变原则:Hooks 只能在函数组件中使用。
你不想为了测试一个 hook 去单独写一个组件,但你又得测试这个 hook 多种触发更新的逻辑,尤其是在复杂场景下,要弄清如何把所有的逻辑串联在一起。
解决方案
React-hooks-testing-library
允许你为React hooks
创建一个简单的测试套件(harness 真不知道怎么翻译),让 hooks 运行在函数组件中,同时提供多种工具函数来处理自定义 hook 的输入输出。本库旨在提供一种尽可能接近在真实组件中使用 hook 的测试体验。
什么时候用这个库?
-
你写了很多和组件解耦的自定义 hooks
-
你的 hook 很复杂,无法通过组件交互进行测试
什么时候别用?
-
你的 hook 只是在组件内部定义,并且只在里面使用。
-
你的 hook 很容易测试,测试使用它的组件就可以顺带测试它。
安装
开始
这个模块是通过 npm 发布的,需要安装在你项目的 devDependencies 中:
# if you're using npm
npm install --save-dev @testing-library/react-hooks
# if you're using yarn
yarn add --dev @testing-library/react-hooks
Peer dependencies
react-hook-testing-library 没有和特定的 Reac 版本绑定在一起,所以你可以安装你想要的特定测试版本。它也没有固定的 renderer,我们目前支持react-test-renderer
和 react-dom
,你选一个就好了,更多细节请查看 renderer这章
npm install react@^16.9.0
npm install --save-dev react-test-renderer@^16.9.0
注意:
react-test-renderer
和react-dom
支持的最低支持版本是^16.9.0`
renderer
跑测试的时候,需要用 renderer 渲染含有 hook 的 React 组件。我们目前支持两种不同的 renderer:
- react-test-renderer
- react-dom
当对这个库使用标准导入(如下所示)时,我们将尝试自动检测你安装了哪个renderer 并使用它,用户不需要做额外的事。如果你两种 renderer 都安装了,你用标准导入的话,我们默认用react-test-renderer
。
为什么我们使用
react-test-renderer
作为默认 renderer?因为它可以让 hooks 在 React 和 React-native 都可以测试,它对 Dom 没有依赖,可以兼容更多开箱即用的测试运行器。
标准导入是这个样子:
import { renderHook } from '@testing-library/react-hooks'
如果测试文件被打包后(比如运行在浏览器里),自动检测函数可能不会正常工作。
谁没事打包测试文件呀!
Act
每个 renderer 还提供了一个独特的 act 函数,不能与其他 renderer 混合使用。为了使用方便,我们直接把我们检测出来的对应的 act 直接导出给你使用。
import { renderHook, act } from '@testing-library/react-hooks'
这样大家可能不好理解,咱们看看源码就知道了:
流程就是:检测 renderer,优先看 react-test-renderer => 用每个 renderer 自己自带的 act 方法
展开说说
自动检测对于简化设置和解决问题很有用,但有时候你可能需要多点掌控感。如果测试需要一个特定的环境,你可以通过导入来指定特定的 renderer 来使用。支持的环境如下:
- dom
- native
- server
导入方法如下:
import { renderHook, act } from '@testing-library/react-hooks' // will attempt to auto-detect
import { renderHook, act } from '@testing-library/react-hooks/dom' // will use react-dom
import { renderHook, act } from '@testing-library/react-hooks/native' // will use react-test-renderer
import { renderHook, act } from '@testing-library/react-hooks/server' // will use react-dom/server
纯净版 import
上面提到的导入方法会在测试环境中导致一些副作用:
1.cleanup 方法会在 afterEach 模块中自动调用。
2.console.erro 被改造了,会隐藏一些 React 的错误。
关于这些副作用的更多讨论细节可以在 API reference 中查看。 如果你想保证你的导入没有这些副作用,你可以使用纯净版 import,在文件路径后面加个 pure 就行。
import { renderHook, act } from '@testing-library/react-hooks/pure'
import { renderHook, act } from '@testing-library/react-hooks/dom/pure'
import { renderHook, act } from '@testing-library/react-hooks/native/pure'
import { renderHook, act } from '@testing-library/react-hooks/server/pure'
这个是如何实现的呢? 从源码层面看,index 文件相当于在 pure 的基础上,在流程中添加了两个方法:
-
在 afterEach 里加一个 cleanup;
-
对 console.error 做过滤,这个在后续再展开说说
测试框架
为了跑测试,你可能需要一个测试框架。如果你现在还没有趁手的,我们推荐你用 Jest,用其他的框架也行。
使用
基础 Hooks
渲染
假如我们有个简单的 hook 要测试:
import { useState, useCallback } from 'react'
export default function useCounter() {
const [count, setCount] = useState(0)
const increment = useCallback(() => setCount((x) => x + 1), [])
return { count, increment }
}
为了测试useCounter
,我们需要使用 react-hook-testing-library
内的renderHook
方法来渲染它。
import { renderHook } from '@testing-library/react-hooks'
import useCounter from './useCounter'
test('should use counter', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
expect(typeof result.current.increment).toBe('function')
})
可以看到,result.current 的值和我们 hook 返回的值一致。
更新
上面的例子很好很完备,但是并没有测试到我们如何用它来计数。我们可以调用increment
方法,然后检查返回值是否增加,来使得这个测试更加完备。
import { renderHook, act } from '@testing-library/react-hooks'
import useCounter from './useCounter'
test('should increment counter', () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
increment
方法调用后,result.current.count 的值表示的就是hook 的最新值。
你可能看到我们用给一个 act
把increment
方法包起来了。这个方法模拟了 hook 在浏览器中的表现,让我们可以在里面做值更新,更多细节,请看 React documentation。
注意: 更新的时候有个坑,renderHook
在更新的时候会修改 current 里面的值,所以你不要子在这个时候去做解构 current,因为解构赋值操作拿到的是旧值的拷贝值(这里不确定翻译的是不是准确!)。
提供 Props
有时候 hook 需要依赖 props 的值来做事,比如 useCounter Hook 就可以接收一个 prop 作为 counter 的初始值:
import { useState, useCallback } from 'react'
export default function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const increment = useCallback(() => setCount((x) => x + 1), [])
return { count, increment }
}
设置初始值很简单,直接作为参数调用就好:
import { renderHook, act } from '@testing-library/react-hooks'
import useCounter from './useCounter'
test('should increment counter from custom initial value', () => {
const { result } = renderHook(() => useCounter(9000))
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(9001)
})
props
许多 hook 使用依赖数组来决定什么时候执行特定操作,比如循环计算复杂值或者跑副作用。如果我们扩展一下 useCounter,让它有一个初始化函数可以设置初始值 initialValue
,代码看起来会是这样:
import { useState, useCallback } from 'react'
export default function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const increment = useCallback(() => setCount((x) => x + 1), [])
const reset = useCallback(() => setCount(initialValue), [initialValue])
return { count, increment, reset }
}
现在,只有当initialValue
发生变化时,reset
函数才会更新。要在测试中更改 hook
的 input props
,只需要简单地更新变量中的值并重新渲染 hook:
import { renderHook, act } from '@testing-library/react-hooks'
import useCounter from './useCounter'
test('should reset counter to updated initial value', () => {
let initialValue = 0
const { result, rerender } = renderHook(() => useCounter(initialValue))
initialValue = 10
rerender()
act(() => {
result.current.reset()
})
expect(result.current.count).toBe(10)
})
但是如果有很多 props,使用变量去跟踪好像就有点困难了。另一种方法是使用 initialProps
配置和改变 rerender
的入参 newProps
。
这种方法还有另一种适用场景-当你想要限制变量的作用域在 hook 的回调函数内时。下面这个测试用例不会通过,因为 useEffect 调用的挂起和卸载过程中,id 的值都改变了。
import { useEffect } from 'react'
import { renderHook } from '@testing-library/react-hooks'
import sideEffect from './sideEffect'
test('should clean up side effect', () => {
let id = 'first'
const { rerender } = renderHook(() => {
useEffect(() => {
sideEffect.start(id)
return () => {
sideEffect.stop(id) // this id will get the new value when the effect is cleaned up
}
}, [id])
})
id = 'second'
rerender()
expect(sideEffect.get('first')).toBe(false)
expect(sideEffect.get('second')).toBe(true)
})
使用initProps
配合newProps
,首次渲染的 id 会被捕捉并且在卸载阶段中保持,测试用例可以顺利通过。
使用 initProps 传参的方式,每次的 rerender 都会使用 hookProps 中的值作为最新值,而不是等到调用 callback 的时候使用测试用例中定义的 initialValue。
import { useEffect } from 'react'
import { renderHook } from '@testing-library/react-hooks'
import sideEffect from './sideEffect'
test('should clean up side effect', () => {
const { rerender } = renderHook(
({ id }) => {
useEffect(() => {
sideEffect.start(id)
return () => {
sideEffect.stop(id) // this id will get the old value when the effect is cleaned up
}
}, [id])
},
{
initialProps: { id: 'first' }
}
)
rerender({ id: 'second' })
expect(sideEffect.get('first')).toBe(false)
expect(sideEffect.get('second')).toBe(true)
})
这是一个边界的情况,所以选一个最适合你的方法就好。
进阶 Hooks
Context
一个 hook 经常需要从上下文获取值。useContext
这个hook 很擅长做这个,但是它经常需要我们使用一个 Provider 去包裹使用 hook 的组件。我们可以在配置项中使用 wrapper
属性来配置 renderHook
。
我们从最开始改造一下 useCounter
例子,我们从 context 中获取 step 变量(而不是直接定义),创建一个 CounterStepProvider
来设置值:
import React, { useState, useContext, useCallback } from 'react'
const CounterStepContext = React.createContext(1)
export const CounterStepProvider = ({ step, children }) => (
<CounterStepContext.Provider value={step}>{children}</CounterStepContext.Provider>
)
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const step = useContext(CounterStepContext)
const increment = useCallback(() => setCount((x) => x + step), [step])
const reset = useCallback(() => setCount(initialValue), [initialValue])
return { count, increment, reset }
}
测试中,我们在渲染 hook 的时候简单地使用CounterStepProvider
作为 wrapper
属性的值。
import { renderHook, act } from '@testing-library/react-hooks'
import { CounterStepProvider, useCounter } from './counter'
test('should use custom step when incrementing', () => {
const wrapper = ({ children }) => <CounterStepProvider step={2}>{children}</CounterStepProvider>
const { result } = renderHook(() => useCounter(), { wrapper })
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(2)
})
wrapper
属性会接收任何 React 组件,但是它必须渲染 children,以便测试组件的渲染和 hook 的正确执行。
当 Wrapper 配置的时候,工具内部会使用 wrapper 作为包装层包裹TestComponent
提供 Props
有时我们需要为同一个 hook 提供不同的上下文值。使用配置中的 initialProps 和为 rerender 配置新的 props,我们可以很容易做到这点。
import { renderHook, act } from '@testing-library/react-hooks'
import { CounterStepProvider, useCounter } from './counter'
test('should use custom step when incrementing', () => {
const wrapper = ({ children, step }) => (
<CounterStepProvider step={step}>{children}</CounterStepProvider>
)
const { result, rerender } = renderHook(() => useCounter(), {
wrapper,
initialProps: {
step: 2
}
})
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(2)
/**
* Change the step value
*/
rerender({ step: 8 })
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(10)
})
注意,initialProps 和 rerender 中新的的 props 在renderHook 的回调函数可以拿到。
因为他们最后都是作为 callback 的参数传入了...
异步
有时候,一个 hook 会触发异步更新,换句话说就是更新后不会立马在 result.value 上体现出来。幸运的是,renderHook 返回了一些工具方法,允许测试代码使用 async/await(或者 promise 回调也行)去等待 hook 的值更新。最基本的工具方法叫waitForNextUpdate
。
让我们再来拓展一下 useCounter
,让它拥有一个叫做 incrementAsync
的回调,在 100ms 后更新 count
的值。
import React, { useState, useContext, useCallback } from 'react'
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const step = useContext(CounterStepContext)
const increment = useCallback(() => setCount((x) => x + step), [step])
const incrementAsync = useCallback(() => setTimeout(increment, 100), [increment])
const reset = useCallback(() => setCount(initialValue), [initialValue])
return { count, increment, incrementAsync, reset }
}
测试 incrementAsync
方法,需要我们在断言之前使用await waitForNextUpdate()
。
incrementAsync
不需要包裹在 act
方法中,因为await waitForNextUpdate()
中的状态更新是异步的。异步的工具方法已经自动包裹一层act
了。
更多其他异步工具方法的细节,查看 API Reference。
Suspense
所有的异步工具方法也会等待使用了 React's Suspense
挂起的 hooks 完成渲染。
Errors
如果你需要测试一个 hook 是否可以正确的抛错,你可以使用 result.error
来获取之前渲染抛出的错误。举个栗子,我们可以让 useCounter
在 count
达到某个特定值的时候抛出异常。
import React, { useState, useContext, useCallback } from 'react'
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const step = useContext(CounterStepContext)
const increment = useCallback(() => setCount((x) => x + step), [step])
const incrementAsync = useCallback(() => setTimeout(increment, 100), [increment])
const reset = useCallback(() => setCount(initialValue), [initialValue])
if (count > 9000) {
throw Error("It's over 9000!")
}
return { count, increment, incrementAsync, reset }
}
import { renderHook, act } from '@testing-library/react-hooks'
import { useCounter } from './counter'
it('should throw when over 9000', () => {
const { result } = renderHook(() => useCounter(9000))
act(() => {
result.current.increment()
})
expect(result.error).toEqual(Error("It's over 9000!"))
})
服务端渲染(略,因为我暂时用不到)
API
React-hooks-testing-library
导出了以下方法:
renderHook
function renderHook(callback: (props?: any) => any, options?: RenderHookOptions): RenderHookResult
渲染一个测试组件,每次组件渲染的时候都会调用我们提供的回调函数,包括函数内的所有 hooks。 这个方法接收以下这些参数:
callback
每次测试组件渲染的时候调用,该函数内部应该调用一个或多个 hook 用于测试。
在 option 中配置的initialProps
会作为回调函数的 props 传入,除非后续的 rerender 提供了新的 props。
option(可选)
一个可选的配置对象,可以配置回调函数的执行。详情参见renderHook
Options 部分。
renderHook Options
renderHook 接收下列可配置项作为第二个参数:
initProps
作为回调函数的 props 初始值。
wrapper
一个 React 组件,在渲染时包裹测试组件用。这个一般用在为 hook 添加 context
(从 React.createContext中
创建的),然后使用 useContext
访问。随后由rerender设置的initialProps
和props
将提供给 wrapper
。
renderHookResult
renderHook 方法返回一个对象,含有下面几个值:
result
{
all: Array<any>
current: any,
error: Error
}
result 中的 current 表示回调函数中返回的最新值。result 中的 error 表示最近一次调用抛出的任何错误。all 的值是一个数组,包含了回调函数返回的所有结果(包括最近的)。这些可能是正常值,也可能是错误,这取决于当时的回调函数返回了什么。
rerender
function rerender(newProps?: any): void
一个可以重新渲染测试组件的方法,会导致所有 hooks 重新计算。如果传递了 newProps,它们将替换会带哦函数的 initProps 用于后续的渲染。
unmout
卸载测试组件用的。这个方法一般用于触发 useEffect 钩子的卸载时候的逻辑。
hydrate(略)
测试服务端渲染的,我现在用不到(🐶)
act
从选择的渲染器中导出的 act 方法。
cleanup
function cleanup(): Promise<void>
卸载任何使用 renderHook 渲染的钩子,确保所有副作用都被清除。任何通过 addCleanup 添加的回调函数也会在清理运行时被调用。
请注意,如果您正在使用的测试框架支持 afterEach (如Jest, mocha 和 Jasmine),则会自动执行此操作。如果没有,则需要在每次测试后进行手动清理。
cleanup 方法需要在每个测试后面调用,来保证之前的测试用例不会遗留任何意外的副作用。
skipping Auto-Cleanup
在测试配置文件中导入@testing-library/react-hooks/don -cleanup-after-each.js
将禁用自动清理功能。
以 Jest 为例,你可以在 Jest Config 中添加:
module.exports = {
setupFilesAfterEnv: [
'@testing-library/react-hooks/dont-cleanup-after-each.js'
// other setup files
]
}
或者你从 pure 中导入 api 也行:
- import { renderHook, cleanup, act } from '@testing-library/react-hooks'
+ import { renderHook, cleanup, act } from '@testing-library/react-hooks/pure'
如果上面两种方法都不合适,在导入@testing-library/react-hooks
之前将RHTL_SKIP_AUTO_CLEANUP
环境变量设置为true也可以禁用自动cleanup。
对应的是这块的逻辑
addCleanUp
function addCleanup(callback: () => void | Promise<void>): (): void
添加一个 cleanup 期间调用的回调函数,返回一个清除自身的方法。 回调函数们会按照添加的顺序倒序被调用。
每次添加 cleanup 的回调的时候,新加的被放在的数组的最前面。
当你想在组件卸载后跑一个回调函数的时候,这个小知识点就很有用。
如果你写的回调函数是一个异步的或者返回一个 promise,cleanup 会等待它执行完,才会调用下一个回调函数。
每次执行的时候都会 await 一下,确保异步执行完再走下一个
请注意,使用 addCleanup 添加的任何回调函数在 cleanup 被调用后都会被删除。对于需要在每个测试中运行的回调函数,建议将它们添加到 beforeEach 块中(或者其他框架等价的生命周期中)。
removeCleanup
function removeCleanup(callback: () => void | Promise<void>): void
移除使用 addCleanup 添加的回调函数。移除后,cleanup 的时候就不会执行。
Async Utilities
waitForNextUpdate
function waitForNextUpdate(options?: { timeout?: number | false }): Promise<void>
返回一个 promise,hook 重渲染后 resolve,当 state 被异步更新的时候使用。
timeout
Default: 1000 最大等待毫秒数
waitFor
function waitFor(
callback: () => boolean | void,
options?: {
interval?: number | false
timeout?: number | false
}
): Promise<void>
返回一个 Promise,当回调函数执行无异常且返回一个真值或者 undefined 的时候,Promise 的状态会 resolve。在 waitFor 的回调函数内使用 renderHook 返回的 result 来断言或做值测试是很安全的。
interval
Default: 50
两次检查之间的时间间隔。 如果没有提供 interval,则定时检测失效。
wait 函数的逻辑,使用定时器根据 interval 进行轮询问,callback 的返回值是否为真。
waitForNextUpdate
这个使用 wait 的能力,加入一个标识位的判断逻辑,当标志位改变后,说明 hook 已经重新更新了一次。
timeout
Default: 1000 最大等待毫秒数
waitForValueToChange
function waitForValueToChange(
selector: () => any,
options?: {
interval?: number | false
timeout?: number | false
}
): Promise<void>
返回一个 Promise,当我们提供的回调函数返回值改变的时候,这个 Promise 会被 resolve。我们希望 renderHook 的 result 可以用来和某个选择的值进行比较。
interval
Default: 50
两次检查之间的时间间隔。 如果没有提供 interval,则定时检测失效。
timeout
Default: 1000 最大等待毫秒数
通过代码我们可以看到,
waitForValueToChange
和waitFor
都是基于公共函数 wait 包装出来的。不同的是 waitForValueToChange
关注的是传入的回调函数的结果值是否变化,当结果值变化的时候,wait 函数才会返回结果;而 waitFor 给了开发者更大的自由,可以传递任意回调函数,返回值由开发者自己控制。大家可以查看源码中的asyncHook.test.ts 文件,该文件中可以看到这两个 Api 的使用方法和场景。
console.error
为了捕获 hook 整个生命周期产生的所有错误,测试套件以前是用一个错误边界(可以理解为一个专门做错误捕捉的父组件)包裹住了整个 hook 调用,这样就导致在测试的时候有大量的噪声打印(一些没用的东西也打印出来了)。
为了让输出控制干净些,当你使用非纯净模式导入的时候,我们修改了 console.error 方法来过滤掉一些不必要的信息,在卸载阶段又把 console.error 恢复回去。这个副作用是会影响到一些针对 console.error 改造方法的测试。
请注意,如果您正在使用的测试框架支持 beforeEach 和 afterEach (如Jest, mocha 和 Jasmine),则会自动执行此操作。如果没有,你需要自己处理。
代码是使用猴子戏法,直接把原生的 console.error 做了修改,并使用正则对打印的数据进行了过滤。当你有一段代码也是对 console.error 进行改造,你又要对这个代码进行测试的时候,这个逻辑就会对你有影响。
禁用 console.error 过滤
在测试配置文件中导入 @testing-library/react-hooks/disable-error-filtering.js
来组织错误打印过滤,并且不能以任何方式魔改 console.error。
举个🌰,Jest 里面的配置这样写:
module.exports = {
setupFilesAfterEnv: [
'@testing-library/react-hooks/disable-error-filtering.js'
// other setup files
]
}
或者,你可以用导入纯净版
- import { renderHook, cleanup, act } from '@testing-library/react-hooks'
+ import { renderHook, cleanup, act } from '@testing-library/react-hooks/pure'
两个都不合适的话,在导入@testing-library/react-hooks
之前,配置环境变量RHTL_DISABLE_ERROR_FILTERING
为 true 也可以起到同样的效果。
请注意,禁用过滤可能导致你测试控制台的输出中有大量额外的打印信息。
自己手动过滤
如果你用了纯净版,或者你用了一个不支持
beforeEach
和 afterEach
的框架,又或者你就是不能使用自动过滤错误信息,你可以使用suppressErrorOutput
来手动的开启和关闭过滤阀门:
import { renderHook, suppressErrorOutput } from '@testing-library/react-hooks/pure'
test('should handle thrown error', () => {
const restoreConsole = suppressErrorOutput()
try {
const { result } = renderHook(() => useCounter())
expect(result.error).toBeDefined()
} finally {
restoreConsole()
}
})
写在最后
从技术文章消费者,逐渐转移到承担部分生产者任务的角色过程中,一共翻译了文档和一些好文章好些篇,陆陆续续收录进我的翻译计划专栏里面。
有人说翻译工作没有必要,大家用一个翻译软件对着文档复制粘贴也能看懂。但是我觉得有些名词用翻译工具翻出来和原文真正要表达的意思会有较大的差别,甚至有时候会让初学者完全理解错意思。
所以我觉得翻译工作很有必要,至少可以帮助到一些需要帮助的人,而且跟着文档和源码学习下来,真的很有收获。
2023,从单元测试开始“兔破自己”!
🎉 🎉 觉得文章对您有帮助的小伙伴,请不要吝啬您的点赞~🎉 🎉
🎉 🎉 对文章中的措辞表达、知识点、文章格式等方面有任何疑问或者建议,请留下您的评论~🎉 🎉
转载自:https://juejin.cn/post/7193727245431603258