首页
Search
1
Linux 下 Bash 脚本 bad interpreter 报错的解决方法
71 阅读
2
Arch Linux 下解决 KDE Plasma Discover 的 Unable to load applications 错误
52 阅读
3
Arch Linux 下解决 KDE Plasma Discover 的 Unable to load applications 错误
42 阅读
4
如何在 Clash for Windows 上配置服务
40 阅读
5
如何在 IOS Shadowrocket 上配置服务
40 阅读
clash
服务器
javascript
全部
游戏资讯
登录
Search
加速器之家
累计撰写
1,061
篇文章
累计收到
0
条评论
首页
栏目
clash
服务器
javascript
全部
游戏资讯
页面
搜索到
1061
篇与
的结果
2024-10-20
React18 源码解析之 useState 的原理
我们解析的源码是 React18.1.0 版本,请注意版本号。React 源码学习的 GitHub 仓库地址:https://github.com/wenzi0github/react。 useState()是我们最常见的几个 hooks 之一,今天我们来了解下他的用法和源码实现。 1. useState 的使用 # 我们先来 useState() 的用法,我们知道 useState() 返回的第 2 个参数是 dispatch(即 set 方法),用来更新 useState() 的状态值。dispatch 的参数,既可以传入普通数据,也可以传入有返回值的函数:import { useState } from 'react'; function App() { const [count, setCount] = useState(0); const [userInfo, setUserInfo] = useState({ name: 'wenzi', age: 24 }); const handleClickByVal = () => { setCount(count + 1); }; const handleClickByCallback = () => { setCount(count => count + 1); }; const handleUpdateUserInfo = () => { setCount({ ...userInfo, age: userInfo.age + 1 }); }; return ( {count} add by val add by callback update userInfo ); } 用过 useState() 的同学,还知道它还有如下的几个特点: setState()的参数,既可以传入普通数据,也可以传入 callback;在以 callback 的方式传入时,callback 里的参数就是截止到当前最新的 state,使用的是执行 callback()后的返回值; 传入的数据若是 object 类型,并不会自动和之前的数据进行合并,如上面的userInfo,我们需要手动合并后,再调用 set 方法; 1.1 传参的区别 # useState()在初始时,或调用 dispatch()时,都有两种传参方式:一种是直接传入数据;一种是以函数 callback 的形式传入,state 的值就是该函数执行后的结果。function App() { // 初始时传入一个callback,现在count的值就是他的返回值,即 Date.now() const [count, setCount] = useState(() => { return Date.now(); }); } 这里我们主要关注的是多次调用 setState()时,不同的传参方式,他使用的 state 是不一样的。如直接使用变量:// 直接使用变量 function AppData() { const [count, setCount] = useState(0); const handleClick = () => { setCount(count + 1); setCount(count + 1); setCount(count + 1); }; return ( click me, {count} ); } 使用 callback 中的变量:// 使用callback中的变量 function AppCallback() { const [count, setCount] = useState(0); const handleClick = () => { setCount(count => count + 1); setCount(count => count + 1); setCount(count => count + 1); }; return ( click me, {count} ); } 点击一次按钮后,这两个组件最终展示的 count 值是不一样的, 中展示的是 1,中展示的是 3。为什么会出现这种现象呢?这是因为,在执行setCount(count + 1)时,变量 count 在函数组件的当前生命周期内,它永远是 0,因此即使调用再多的次数也没用。这里我们简化一下,就方便理解了。function App() { const count = 0; // count是一个固定值 setCount(count + 1); setCount(count + 1); setCount(count + 1); setTimeout(() => { setCount(count + 1); }, 1000); } 对同一次的渲染来说,count 是一个固定值,无论在哪里使用这个值,都是固定的。setCount(count+1)的作用仅仅是把要更新的最新数据记录在了 React 内部,然后等待下次的渲染更新。而setCount(count => count + 1)则不一样,callback 中的 prevState 则是执行到当前语句之前最新的那个 state。因此在执行第 2 条语句前,count 已经变成了 1;同理第 3 条语句。我们稍后会从源码的层面分析下这种现象。 1.2 获取 setState()更新后的值 # 很多同学在初次使用useState()时,经常会在调用 setState()后,马上就使用更新后的数据。function App() { const [count, setCount] = useState(0); const getList = () => { // console.log(count); fetch('https://www.xiabingbao.com', { method: 'POST', body: JSON.stringify({ count }), }); }; const handleClick = () => { setCount(count + 1); console.log(count); // 本意是想用更新后的最新count来调用 getList() getList(); }; } 其实我们通过上面第 1.1 节的了解,已经知道此时输出的 count 还是之前的数值 0。那怎么才能使用最新的数据,来做后续的操作呢? 先计算出最新值,然后同步传给 setCount()和 getList(); 用 useEffect()来监听 count 的变化; 1.2.1 先计算出最新的值 # 我们可以把更新操作放在前面,先得到结果,然后再同步传给 setCount()和 getList()。const handleClick = () => { const newCount = count + 1; setCount(newCount); getList(newCount); }; 这就得要求我们把函数 getList()改造为传参的形式。 1.2.2 用 useEffect()来监听 count 的变化 # 既然不确定什么时候回拿到最新的值,那我们就监听他的变化,等它更新了之后再进行后续的请求。function App() { const [count, setCount] = useState(0); useEffect(() => { getList(); }, [count]); const handleClick = () => { setCount(count + 1); }; } 1.3 object 类型的数据不能自动合并 # 之前在类组件中的 state,我们可以只传入需要改动的字段,React 会帮助我们合并:class App { state = { name: 'wenzi', age: 24, }; handleClick() { this.setState({ age: this.state.age + 1 }); // 只传入有改动的字段即可 } } 但在函组件的useState()中,传入什么数据,就使用什么数据。若还需要使用之前的部分数据时,就需要我们自己来合并数据了,然后再传给 setState()。function App() { const [userInfo, setUserInfo] = useState({ name: 'wenzi', age: 24 }); setCount({ ...userInfo, age: userInfo.age + 1 }); // 直接使用state setCount(userInfo => ({ ...userInfo, age: userInfo.age + 1 })); // 用callback的方式使用state } 在类组件中,所有的状态都必须挂载在state上。在函数组件中,我们可以根据情况进行更细粒度的拆分,如 count 何 userInfo 的拆分;如果觉得 userInfo 不够精细,还可以把其中的 name 和 age 再拆分,单独进行控制。function App() { const [name, setName] = useState('wenzi'); const [age, setAge] = useState(24); } React 官方更推荐精细化地拆分控制,一方面是控制起来更方便,若 state 比较复杂,那在每次调用 setState()时,都要手动合并数据(当然,您可以自己实现一个自动合并数据的 hook)。另一方面在后期的维护和扩展上更容易,不必考虑其他属性的影响。 1.4 typescript 的使用 # 在 typescript 环境中,useState()是支持泛型的,state 的类型默认就是初始数据的类型,如:function App() { const [name, setName] = useState('wenzi'); // name 是 string 类型 const [age, setAge] = useState(24); // age 是 number 类型 // userInfo 是有多个属性的类型,且已明确了属性,有且只有name和age两个属性,并且这两个属性的类型分别是string和number const [userInfo, setUserInfo] = useState({ name: 'wenzi', age: 24 }); } 一些相对复杂的数据类型,或者多种数据类型的组合,我们可以显式地设置 state 的类型。enum SEX_TYPE { MALE = 0, FEMALE = 1, } interface UserInfoType { name: string; age: number; score?: number; } function App() { const [name, setName] = useState(null); // name 是 string 类型 或 null,并且初始为null const [sex, setSex] = useState(SEX_TYPE.MALE); // sex是枚举类型 // 显式地明确 userInfo 的各个属性,score可选 const [userInfo, setUserInfo] = useState({ name: 'wenzi', age: 24 }); // 更复杂的ts类型 const [userInfo, setUserInfo] = useState({ score: 96 }); } 在 ts 中,明确各个变量参数的类型,一个原因是为了避免对其随意的赋值,再一个原因,从类型定义上我们就能知道这个变量的具体类型,或他的属性是什么。我们在上面已经了解了 useState() 不少的使用方式,这里我们通过源码的角度,来看看为什么出现上面的这些现象。 2 hook 的初始挂载 # useState() 这个 hook 的大致结构:在第一次初始声明 useState(),state 的值就是传入的值,若不传入,则是 undefined。我们再来看下 hook 的结构:const hook: Hook = { memoizedState: null, // 这个hook目前在函数组件中显示的值,初始时,即为传入的数据(若传入的是函数,则为函数执行后的结果) /** * 该hook所有的set操作开始执行时的初始值,初始挂载时,该值与 memoizedState 相同; * 在中间更新过程中,若存在低优先级的set操作,则 baseState 此时为执行到目前set的值 **/ baseState: null, /** * 执行set操作的链表,这里包含了上次遗留下来的所有set操作,和本次将要执行的所有set操作 **/ baseQueue: null, // 所有的set操作,都会挂载到 queue.pendig 上 queue: null, // 指向到下一个hook的指针 next: null, }; 注意:我们之前在讲解 hooks 挂载的时候,也讲到过 memoizedState 属性。这两个 memoizedState 属性是不一样的。fiber.memoizedState 是用来挂载 hook 节点链表的;而现在讲解的 hook.memoizedState 是用来挂载该 hook 的数值的。function mountState(initialState: (() => S) | S): [S, Dispatch] { /** * 创建一个hook节点,并将其挂载到 currentlyRenderingFiber 链表的最后 * @type {Hook} */ const hook = mountWorkInProgressHook(); if (typeof initialState === 'function') { // 若传入的是函数,则使用执行该函数后得到的结果 initialState = initialState(); } /** * 设置该 hook 的初始值 * memoizedState 用来存储当前hook要显示的数据 * baseState 用来存储执行setState()的初始数据 **/ hook.memoizedState = hook.baseState = initialState; // 为该 hook 添加一个 queue 结构,用来存放所有的 setState() 操作 const queue = { pending: null, interleaved: null, lanes: NoLanes, dispatch: null, lastRenderedReducer: basicStateReducer, // 上次render后使用的reducer lastRenderedState: initialState, // 上次render后的state }; hook.queue = queue; /** * 这里用到了 bind() 的偏函数的特性,我们稍后会在下面进行讲解, * */ const dispatch = (queue.dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue)); return [hook.memoizedState, dispatch]; // useState() 返回的数据 } mountState()的整体流程: 创建一个 hook 节点,挂载所有初始的数据; 若 initialState 是函数类型,则使用执行它后的结果; 执行当前节点的方法是 basicStateReducer() 函数;这里跟我们后续要讲解的 useReducer() 有关系; 将 hook 节点挂载到函数组件对应的 fiber 节点上; 返回该 hook 的初始值 和 set 方法; basicStateReducer() 函数的具体实现:/** * 对当前的 state 执行的基本操作,若传入的不是函数类型,则直接返回该值, * 若传入的是函数类型,返回执行该函数的结果 * @param {S} state 当前节点的state * @param {BasicStateAction} action 接下来要对该state执行的操作 * @returns {S} */ function basicStateReducer(state, action) { return typeof action === 'function' ? action(state) : action; } 这个 action 就是我们执行 useState() 里的第 2 个返回值的 set 操作。如:setCount(count + 1); // action 是数值 setCount(count => { // action是函数,参数为当前的 count console.log('dispatch setCount'); return count + 1; }); bind()方法可以基于某个函数返回一个新的函数,并且可以为这个新函数预设初始的参数,然后剩余的参数给到这个新函数。官方文档:bind()的偏函数功能。我们这里暂时先不管这个函数 dispatchSetState() 的作用是什么,目前只关心参数的传递:function dispatchSetState(fiber: Fiber, queue, action) {} dispatchSetState() 本身要传入 3 个参数的: fiber: 当前处理的 fiber 节点 queue: 该 hook 的 queue 结构,用来挂载 setState() 中的操作的; action: 要执行的操作,即 setState(action)里的 action,可能是数据,也可能是函数; 可是我们在执行 dispatch()(即 setState())时只需要传入一个参数就行了,这就是因为源码中利用到了 bind() 的偏函数功能。再来看下派生出 dispatch() 的操作:/** * 这里已经提前把当前的 fiber 节点和 hook 的 queue 结构传进去了, * 就只留一个 action 参数给dispatch。 */ const dispatch = (queue.dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue)); 可以看到,通过 bind()方法,已经提前把当前的 fiber 节点和 hook 的 queue 结构传进去了,就只留一个 action 参数给 dispatch。在调用dispatch(action时),就是在执行dispatchSetState(fiber, queue, action)。如果不太理解的话,我们再看一个简化后的例子:/** * 设置学生的某学科的分数 * * @param nick 学生姓名 * @param subject 学科 * @param score 分数 */ const setStudentInfo = (nick, subject, score) => { console.log(nick, subject, score); }; // 设置jack的分数 // 已预设了1个参数,剩余的两个参数供新函数设置 const setJackInfo = setStudentInfo.bind(null, 'Jack'); setJackInfo('math', 89); // Jack math 89 setJackInfo('computer', 92); // Jack computer 92 // 已预设了2个参数,剩余的一个参数供新函数设置 const setTomEnglishScore = setStudentInfo.bind(null, 'Tom', 'english'); setTomEnglishScore(97); // Tom english 97 再回到 dispatch(action) 这儿,我们在执行该方法的时候,其实已经预定了前 2 个参数:fiber 和 queue。即 dispath()已经和当前的 fibe 节点强绑定了,执行的操作只会在该 fiber 节点中产生影响。 3. dispatchSetState # 我们使用的 setState()(即源码中的 dispatch)就是 dispatchSetState() 函数派生出来的,执行 useState()的 set 操作,就是执行我们的 dispatchSetState()。先看下传入的参数:/** * 派生一个 setState(action) 方法,并将传入的 action 存放起来 * 同一个 useState() 的 setState(action) 方法可能会执行多次,这里会把参数里的 action 均会放到queue.pending的链表中 * @param {Fiber} fiber 当前的fiber节点 * @param {UpdateQueue} queue * @param {A} action 即执行setState()传入的数据,可能是数据,也能是方法,setState(1) 或 setState(prevState => prevState+1); */ function dispatchSetState(fiber: Fiber, queue: UpdateQueue, action: A) {} dispatchSetState() 已经让提前传入 fiber 和 queue 的两个参数了,用来表示当前处理的是哪个 fiber 节点,action 的操作放到哪个链表中。这样当执行 useState() 中的 set 方法时,就能直接跟当前的 fiber 节点和当前的 hook 进行绑定。再看下具体的实现:function dispatchSetState(fiber: Fiber, queue: UpdateQueue, action: A) { /** * 获取当前 fiber 更新的优先级, * 当前 action 要执行的优先级,就是触发当前fiber更新更新的优先级 */ const lane = requestUpdateLane(fiber); /** * 将 action 操作封装成一个 update节点,用于后续构建链表使用 */ const update: Update = { lane, // 该节点的优先级,即当前fiber的优先级 action, // 操作,可能直接是数值,也可能是函数 hasEagerState: false, // 是否是急切状态 eagerState: null, // 提前计算出结果,便于在render()之前判断是否要触发更新 next: (null: any), // 指向到下一个节点的指针 }; if (isRenderPhaseUpdate(fiber)) { /** * 是否是渲染阶段的更新,若是,则拼接到 queue.pending 的后面 */ enqueueRenderPhaseUpdate(queue, update); } else { /** * 正常执行 * 将 update 形成单向环形链表,并放到 queue.pending 里 * 即 hook.queue.pending 里,存放着 update 的数据 * queue.pending指向到update链表的最后一个元素,next即是第1个元素 * 示意图: https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/31b3aa9d0f5d4284af1db2c73ea37b9a~tplv-k3u1fbpfcp-zoom-in-crop-mark:1304:0:0:0.awebp */ enqueueUpdate(fiber, queue, update, lane); const alternate = fiber.alternate; if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) { /** * 当前组件不存在更新,那么首次触发状态更新时,就能立刻计算出最新状态,进而与当前状态比较。 * 如果两者一致,则省去了后续render的过程。 * 可以直接执行当前的action,用来提前判断是否需要当前的函数组件fiber节点 * 若新的state与现在的state一样,我们可以直接提前退出, * 若不相同,则标记该fiber节点是需要更新的;同时计算后的state可以直接用于后面的更新流程,不用再重新计算一次。 * 根据这文档, https://www.51cto.com/article/703718.html * 比如从0更新到1,此后每次的更新都是1,即使是相同的值,也会再次重新渲染一次,因为两棵树上的fiber节点, * 在一次更新后,只会有一个fiber节点会消除更新标记, * 再更新一次,另一个对应的节点才会消除更新标记;再下一次,就会进入到当前的流程,然后直接return */ const lastRenderedReducer = queue.lastRenderedReducer; // 上次render后的reducer,在mount时即 basicStateReducer if (lastRenderedReducer !== null) { let prevDispatcher; const currentState: S = (queue.lastRenderedState: any); // 上次render后的state,mount时为传入的initialState const eagerState = lastRenderedReducer(currentState, action); update.hasEagerState = true; // 表示该节点的数据已计算过了 update.eagerState = eagerState; // 存储计算出来后的数据 if (is(eagerState, currentState)) { // 若这次得到的state与上次的一样,则不再重新渲染 return; } } } const eventTime = requestEventTime(); /** * 将当前的优先级lane和触发时间给到 fiber 和 fiber.alternate, * 并以 fiber 的父级节点往上到root所有的节点,将 lane 添加他们的 childLanes 属性中,表示该节点的子节点有更新, * 在 commit 阶段就会更新该 fiber 节点 * 这里面还存在一个任务优先级的调度,我们暂时先不考虑 */ const root = scheduleUpdateOnFiber(fiber, lane, eventTime); if (root !== null) { entangleTransitionUpdate(root, queue, lane); } } markUpdateInDevTools(fiber, lane, action); } dispatchSetState()函数主要是做 3 件事情: 把所有执行的 setState(action) 里的参数 action,全部挂载到链表中; 若之前没有更新(比如第一次渲染后的更新等),马上计算出新的 state,然后与之前的 state 对比,若没有更新,则直接退出; 若有更新,则标记该 fiber 节点及所有的父级节点;刚才计算出的新的 state 可以在接下来的更新中使用; action 通过 update 节点挂载到链表上后:关于为什么要构建循环链表,如何构建循环链表,请参考React18 中的循环链表,先埋坑,后续补充。注意,scheduleUpdateOnFiber()函数,仅仅是用来标记该 fiber 有更新需要处理,而并不会立刻重新执行函数组件。这里有个重要的优化操作,就是若在该 fiber 节点中的 useState()时,之前没有更新(之前 fiber 节点为空或前几次都没更新),则这次的计算不受之前更新的影响 4. updateState # 当函数组件二次渲染时,可能会进入到 updateState() 里的逻辑。而 updateState() 实际上执行的是 updateReducer()。/** * useState()的更新阶段 * 传入要更新的值initialState,并返回新的[state, setState] * @param initialState * @returns {[(*|S), Dispatch]} */ function updateState(initialState: (() => S) | S): [S, Dispatch] { return updateReducer(basicStateReducer, (initialState: any)); } 这也说明了 updateState() 和 updateReducer() 执行的逻辑是一样的,只不过 updateState 已经默认指定了第 1 个参数,为 basicStateReducer(),而执行 useReducer() 时的 udateReducer,我们可以自己实现一个 reducer。这里我们暂时不展开对 useReducer() 的 hook 的讲解。 5. updateReducer # 在 updateReducer() 中,很大一部分的内容是用来对不同优先级的 set 的调度,和任务链表的拼接。因为对同一个 useState() 的 hook 来讲,不是所有的 set 操作都要同时一起执行的。因为不同的 set 操作可能有不同的优先级,比如有的在异步的数据请求后才执行的,有的是放在定时器中执行的。React 会根据不同的优先级,来挑选出当前符合优先级的任务来执行。那么也就会有优先级不足的任务留到下次的渲染时执行。updateReducer() 的代码比较长,我们主要分为三部分来讲解: 把上次遗留下来的低优先级任务(如果有的话)与当前的任务拼接(这里不对当前任务进行优先级的区分,会在第 2 步进行区分)到 baseQueue 属性上; 遍历 baseQueue 属性上所有的任务,若符合当前优先级的,则执行该 update 节点;若不符合,则将此节点到最后的所有节点都存储起来,便于下次渲染遍历,并将到此刻计算出的 state 作为下次更新时的基准 state(在 React 内部,下次渲染的初始 state,可能并不是当前页面展示的那个 state,只有所有的任务都满足优先级完成执行后,两者才是一样的); 遍历完所有可以执行的任务后,得到一个新的 newState,然后判断与之前的 state 是否一样,若不一样,则标记该 fiber 节点需要更新,并返回新的 newState 和 dispatch 方法。 源码会比较长,这里我们直接看下他的结构:function updateReducer(reducer: (S, A) => S, initialArg: I, init?: I => S): [S, Dispatch] { if (pendingQueue !== null) { // 若上次更新时,有遗留下来的低优先级任务;同时当前也有要更新的任务, // 则将当前跟新的任务拼接到上次遗留任务的后面 // 然后放到 baseQueue 中 } if (baseQueue !== null) { // 当前次的更新时,更新链表不为空,那就得检查是否有可以在本地更新时要执行的任务 do { if (!isSubsetOfLanes(renderLanes, updateLane)) { // 当前任务不满足优先级,存储起来,方便下次更新时使用 } else { // 若任务优先级足够,则执行该任务; // 但若此时已经有低优先级的任务,为保证下次更新跳过这些任务, // 也会将这些任务存储起来 } } while (update !== null && update !== first); if (newBaseQueueLast === null) { // 所有的任务都符合优先级,都执行完了,则下次更新时的初始值,就是上面do-while后得到的 newState 的值。 } else { // 若有低优先级的任务,则将链表的最后一个节点的next指向到头结点,形成单向环形链表 } if (!is(newState, hook.memoizedState)) { // 若新产生的 newState 跟之前的值不一样,则标记该fiber节点需要更新 markWorkInProgressReceivedUpdate(); } hook.memoizedState = newState; // 整个update链表执行完,得到的newState,用于本次渲染时使用 hook.baseState = newBaseState; // 下次执行链表时的初始值 hook.baseQueue = newBaseQueueLast; // 新的update链表,可能为空 queue.lastRenderedState = newState; // 将本次的state存储为上次rendered后的值 } /** * 返回最新的state */ const dispatch: Dispatch = (queue.dispatch: any); return [hook.memoizedState, dispatch]; } 6. 总结 # 这篇文章耽误的时间比较长,不过也总算是把 useState() 的源码顺下来了。了解完源码的实现后,我们再来看几个关于它的相关问题。 6.1 多次调用 useState() 中的 dispatch 方法,会产生多次渲染吗? # 这个要看情况来具体分析。针对相同优先级的操作,即使有多个 useState(),或执行多次的 dispatch()方法,也仅会引起一次的组件渲染。如下面的代码,虽然有 2 个 useState(),且各自的 dispatch()方法也执行了多次,但这些执行的优先级是相同的,则 React 内部会将其合并到一起执行,然后再一起更新渲染。function App() { const [count, setCount] = useState(0); const [random, setRandom] = useState(0); const handleClick = () => { setCount(count => count + 1); setCount(count => count + 1); setRandom(Math.random()); setRandom(Math.random()); }; console.log('refresh', Math.random()); return ( App, {count}, {random} click me ); } 每次点击按钮时,只会输出一次refresh。 6.2 props 发生变动时,useState()中的数据会变吗? # 不会。虽然 props 的变动,会导致组件的重新刷新,但 useState()中的数据并不会发生变动,即使 useState()用了 props 中的数据作为初始值。这是因为 state 值的变动,只受 dispatch() 的影响。若想在 props 变动时,重新调整 state 的值,可以用 useEffect() 来监听 props 的变动:function App(props) { const [count, setCount] = useState(props.count); useEffect(() => { // props 中的 count 属性发生变动时,重新赋值 setCount(props.count); }, [props.count]); } 6.3 直接修改 state 的值,会怎样? # state 作为函数组件中的一个变量,当然可以直接修改它的值,然而这并不会 React 组件的重新渲染,页面上的数据也不会更新。唯一有影响的,就是后续要使用该变量的地方,会使用到新数据。但若其他 useState() 导致了组件的刷新,刚才变量的值,若是基本类型(比如数字、字符串等),会重置为修改之前的值;若是复杂类型,基于 js 的 对象引用 特性,也会同步修改 React 内部存储的数据,但不会引起视图的变化。function App() { let [count, setCount] = useState(0); const [user, setUser] = useState({ age: 20 }); const [random, setRandom] = useState(0); // 直接修改state的值 const handleStateDirectly = () => { count = 2; user.age = 23; // 值可以改变,但视图并不会更新 console.log(count, user); }; // 修改random,用来刷新组件 const handeRandom = () => { setRandom(Math.random()); }; return ( {count} {user.age} change state directly set random ); } 在上面的样例中,直接修改 state 的值,也是可以修改的,但这种方式并不会引起视图的刷新;而通过其他 hook 的正常赋值后,相应的 state 也会发生不一样的变化,count 会重置为 0,而 user 则会变为 { age: 23}。因此,千万不要直接修改 state 的值,否则会收到意想不到的惊喜。
2024年10月20日
4 阅读
0 评论
0 点赞
2024-10-20
再见2022,你好2023
上半年开始降本增效,也不干啥活儿了,就是开始砍机器、砍资源;去年还各种 EPC 指标呢,开完 2021 年的年会后,整个公司就开始降本增效了。落到我们的头上,就是对齐服务的各种指标,例如内存和 CPU 的利用率要达到 45%,不用的业务和服务器全部砍掉。春节之前,还在赶手 Q 的春节活动,不过在小年放量的时候,给干崩了。当时更新了流量转发的方式,也没有压测充分,导致在大流量过来时,转发出现了积压,页面各种崩溃。然后赶紧采取了降级的措施,使页面恢复了正常,但还是流失了不少的用户流量。春节之后,又学习和参考了其他同学的方案,重新调整了流量转发方式和流水线部署方式。牛年的春节,因为疫情,依然没有回家,去了趟虎峪风景区,简直坑的没商量,那些冰瀑布全是用水管引上去的水。没啥看的,也没啥玩的。在最高处有个露天的滑冰场,还得掏钱坐车才能上去,而且还很冷,玩两圈之后,再排队等车下去。这个风景区就结束了。春节放假归来后,工作的节奏就有点不对了。预算是砍了又砍,工作方向也是多次的调整。当时新闻的负责人,周五还在跟我们开线上大会,下个周一我们全体就收到通知,该负责人下课了。没多长时间,我们底层员工,也收到了被裁员的通知。后续就是找房,总结过往项目经历,刷面试题、算法题,面试找工作。在刷算法的过程中,因为本身就是前端,就一直用 js 在 leetcode 做题,偶尔用下 C 语言。后来也忘了咋想的了,就开始用 C++刷算法题,但 C++又没学过,也是边用边查。比如 vector, string, map, set 等数据结构的使用,经常会需要话费很多时间去查询 C++里的语法怎么使用。比如想给 string 类型的变量追加一个数字,是不能直接拼接的,需要先把数字转为 string,再进行拼接(但 js 里可以自动隐式的类型转换)。除此之外,就是对 React 源码的解读了,目前是主要了解了 React 内部对 jsx 的处理,diff 对比和一些常用的 hooks。希望能在 2023 年,能把 React 源码解读完毕,比如有各种事件的优先级处理、lanes 的并发处理、事件代理等。回顾在腾讯的整个职业生涯,从刚入职就在新闻部门,到离开时,也是在新闻部门。大部分都是 3 星,也偶尔拿 4 星或 5 星,也从来没有拿过低绩效(1 星和 2 星)。不能说是一个很突出的人,只是一只辛苦劳作的小蜜蜂。这几年的时间,也是见证了我和腾讯的共同的成长。在新的公司,接触了之前开发时没有遇到过的场景。我大概是从 2018 年到现在,就一直写移动端的 h5,主要是 toC 方向的。这里主要是 PC 端的项目、管理后台项目和部分的小程序。这几块的内容,我之前都没接触过。这就得需要边工作边学,为了赶项目进度经常加班。博客文章更新的频率也慢了很多。2022 年真的是很特殊的一年,谁来总结这一年,都逃不过疫情这个话题。在北京这里,大概从 4、5 月份左右,核酸检测的频率就越来高了,直到后来固定为每 3 天一检。然后所有的公共场所、地铁、食堂、公司等,均跟 3 天的核酸证明相关。只要某地方有 1 个确诊或者疑似病例,就直接封锁该场合,然后把这几天去过这地方的所有人赋黄码等。后来各地也因为封控出现了各种各样的问题,比如有病了无法去医院治疗、缺少必要的食物和蔬菜、转运过程中的车祸问题等。那段时间,所有人的戾气都比较重,随时担心着自己可能被封在某个地方。然后,终于在 12 月 5 日,彻底放开了,我们全家都没跑了,多多少少都有点症状。大部分是咳嗽严重,偶尔有发烧的。现在已经是兔年春节之后了,也没有等到第二轮的疫情。给自己定几个小目标吧: 把 React 源码解读完毕,比如有各种事件的优先级处理、lanes 的并发处理、事件代理等; 目前 leetcode 上的解题数是 537,希望能在年底前能到 620; 发表的博客数量能在 40 篇以上; 希望在新的一年里,经济能恢复些,工资能提高一些。
2024年10月20日
3 阅读
0 评论
0 点赞
2024-10-20
如何通过企业微信发送消息通知
通过企业微信发送消息通知有两种方式: 群机器人; 企业内部自建应用; 两者的对比 接入难易程度 发送范围 是否可以接收回调 群机器人 简单,任何人都可以创建,并添加到群组中(默认任何人,但管理员可以开启白名单) 只能往群组中发送消息 不能,只能发送,不能接收 企业自建应用 需要管理员添加,并需要配置发送消息服务器的 IP 白名单 可以指定给某人或某几人或某部门发送消息(在管理员指定范围内) 可以接收消息,实现互动 适用范围 群机器人:适合小范围推送,或者群内所有人或大部分人都关心的消息,否则容易对群内其他人造成困扰。如服务的监控消息、每周一次的科技信息等。 企业自建应用:适合精准推送,推送一些偏私密性的消息,或者只需要让他自己知道就足够了。比如禅道的任务、bug 状态变动、内容增删等的通知;比如流水线的通知(谁触发谁接收通知)等; 群机器人 # 群机器人,顾名思义,是只能在群组中才能添加的机器人。二人对话的聊天框中是无法添加的,但我们可以通过一些技巧来实现。 如何创建小群测试 # 有时候我们想单独测试下我们的机器人,又不想打扰别人,可以用如下的方式操作。先默默拉几个人建一个群,注意,不要发送消息、不要改群名。不要进行任何操作。然后再默默把其他人踢掉,就可以形成二人群或者一人群了。其他人是完全不感知的,他们是不知道自己被拉群了,然后又被踢掉了。比如这个群,这群里只有我一个人: 添加群机器人 # 群里任何人都可以添加机器人。添加成功后,就会有一个对应的 webhook 地址,其他应用使用这个地址,就可以通过群机器人发送消息了。 注意,不要泄露您的 webhook 地址,避免他人通过该地址发送垃圾消息。 如何发送消息 # 群机器人发送消息还是比较简单的,按照文档配置即可。开发者可以按以下说明向这个地址发起 HTTP POST 请求,即可实现给该群组发送消息。官方发送不同格式消息的地址:https://developer.work.weixin.qq.com/document/path/91770如在 shell 脚本中:curl 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxx-yyyyyy-zzzzzz' \ -H 'Content-Type: application/json' \ -d ' { "msgtype": "text", "text": { "content": "hello world" } }' 比如我在流水线是用的群机器人,来提示我流水线的启动和结束。gitlab 流水线可以执行 shell 脚本,我们直接编写 shell 脚本即可。很奇怪,这段代码在博客里一直发布不成功,就改成截图了。有的同学会通过后台服务发送一些信息,如在 nodejs 中:const axios = require("axios"); const send = async () => { const result = await axios({ url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxx-yyyyyy-zzzzzz", method: "post", data: { msgtype: "news", news: { articles: [ { title: "中秋节礼品领取", description: "今年中秋节公司有豪礼相送", url: "www.qq.com", picurl: "http://res.mail.qq.com/node/ww/wwopenmng/images/independent/doc/test_pic_msg1.png", }, ], }, }, }); if (result.status === 200 && result.data?.errcode === 0) { console.log("success", result.data); } else { console.error("fail"); } }; send(); 把机器人发布到公司 # 若您觉得您的群机器人功能很不错,后台服务也比较稳定,有的同学会搞一些笑话机器人、每周信息汇总机器人、每日一语机器人等等。就可以将其发布到公司范围内,其他人可以就把他添加到别的群组里。添加机器人时,可以从这里选择已发布到公司的机器人。对于没有发布到公司的群机器人,只能靠大家的口口相传了。 点评 # 企业微信机器人引入简单,使用方便。相应的,能力也有很大的局限性,比如无法鉴权,谁拿到这个地址,都能发送消息;同时,只有在有这个机器人的群里,才能收到消息,否则就无法感知。比如上面的那个群里,群里只有我自己,我就可以接收到通知,但其他人,虽然他们触发流水产生的群机器人消息,但因为他们不在这个群里,就无法收到通知。 企业自建应用 # 如何创建企业自建应用 # 这需要拥有该企业微信的管理员权限,才能创建自建应用。一. 进入管理后台,选择应用管理,下拉到底部,点击创建应用;二. 填入提前准备好的 logo 图片(建议使用 750*750,1M 以内的 jpg、png 图片)、应用名称和应用介绍(选填),然后选择可见范围;同时,还要准备好域名和几个 IP,这里需要从管理后台下载一个验证文件,放到该域名对应的服务器根目录中,域名验证通过后就可以添加 IP 白名单了。 主动推送消息:可以在设置了本地机器 IP 白名单后,在本地机器就可以发起测试; 接收并回复消息:只能在已验证的域名上操作; 三. 创建成功;得到如下的几条数据(获取方式:https://developer.work.weixin.qq.com/document/path/90665): corpId: 即企业 ID,在“我的企业”tab 的最底部; secret: 即该应用的密钥,点击进入刚才创建的应用内进行查看; agentId: 该应用的 id(主动发送消息时用不到,但接收消息时需要); 若您不是管理员,还请将第 2 序列中准备好的数据给到公司的管理员,待管理员创建成功后,得到第 3 序列中需要的数据。自建应用的特点: 只有能由有管理员权限的人员,进行创建; 可以定向给企业内的任何成员发送消息,对其他成员无干扰; 可以接收消息,并进行相应的回复(关键词的自动回复和接入程序的更复杂的回复等); 需要验证域名、IP 白名单,并且需要该企业对应的 corpId,才能正常使用; 获取 access_token # 企业自建应用的任何功能,都要首先获取到 access_token。官网地址:https://developer.work.weixin.qq.com/document/path/91039。通过已获取到的 corpId 和 secret 就可以拿到 access_token 了。不过 token 接口有请求频率的限制,并且获取到的 token 有 7200s(2 小时)的有效期,程序需要缓存该 token。const axios = require("axios"); const { corpId, secret } = require("./utils/secret"); const cache = require("./utils/cache"); const getAccessToken = async () => { const cacheToken = await cache.get("token"); if (cacheToken) { return cacheToken; } const { status, data } = await axios({ url: "https://qyapi.weixin.qq.com/cgi-bin/gettoken", params: { corpid: corpId, corpsecret: secret, }, }); if (status === 200 && data.errcode === 0) { cache.set("token", data.access_token); return data.access_token; } return null; }; module.exports = getAccessToken; 发送消息推送 # 企业自建应用,除消息推送外,还有很多其他的功能,如通讯录管理、身份验证、消息推送、创建群聊等,不过我们这里主讲消息推送这块。跟群机器人类似,消息也有很多种类型,每种类型的消息所需要的字段也不一样,大家可自行查阅文档。这里仅举一个发送文本消息的例子:const axios = require("axios"); const getAccessToken = require("./src/get-access-token"); const { agentId } = require("./src/utils/secret"); const sendTextMsg = async () => { const accessToken = await getAccessToken(); const { status, data } = await axios({ url: `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${accessToken}`, method: "post", data: { touser: "xiaowenzi", // 多个用户,用 | 隔开 // toparty: 'PartyID1|PartyID2', // 部门 id // totag: 'TagID1 | TagID2', msgtype: "text", agentid: agentId, text: { content: '你的快递已到,请携带工卡前往邮件中心领取。\n 出发前可查看邮件中心视频实况,聪明避开排队。', }, }, }); console.log(status, data); }; 我们在创建好「消息提醒」的自建应用后,其他的内网服务(如禅道、知识库、流水线、错误率警告等服务),都可以通过该应用向用户或部门发送通知,方便周知各种内容的变更。 接收并回复消息 # 自建应用还有一个很重要的功能,就是可以接收每个用户发送过来的任何消息(包括底部菜单的点击),然后再针对该消息,进行相应的回复。这里的配置相对来说比较麻烦一些: 所有的操作只能在管理后台配置的链接进行; 接收和要回复的消息均是 xml 格式或 xml string 格式的; 在接收消息之前,首先要在管理后台配置接收消息的线上地址,在保存地址时,就会校验这个地址的有效性,即企业微信会以 GET 请求的方式,携带一些参数,请求该 url,若能正常解密 url 参数中的内容并返回,即为配置成功。 加密和解密的算法与官方已有库 # 在接收并回复消息模块中,有三个过程: 验证 url:接收参数并解析出参数中的内容,然后返回;主要是在保存 url 地址时使用; 接收消息:企业微信会以 POST 方式并携带参数请求我们的保存的地址 ,根据参数和 body 中的数据,解析出真正的消息; 回复消息:把要回复的消息,连同时间戳、随机数等进行加密,然后返回给企业微信; 第 2、3 过程是连续的,若要回复消息,则直接返回即可;若不想回复消息或回复需要很长的时间(官方会等待 5 秒时间),可以直接在第 2 步中返回 200(即以空串为返回包),然后再通过上面的“发送消息推送”,主动给相关用户推送消息。各种加密解密算法也挺绕的,官方也出了一些相关语言的包(库),可以直接拿来使用(https://developer.work.weixin.qq.com/document/path/90307)。我这里只有一个能运行 php 的主机,因此选择了 php 语言的库。各位可以根据自己的需要,选择相应的库即可。我这里使用了 codeigniter 框架。 验证 url # // 检测 url 的合法性 private function checkValidUrl(){ $this->load->library('WXBizMsgCrypt'); $this->wxbizmsgcrypt->init($this->token, $this->encodingAesKey, $this->corpId); $sVerifyMsgSig = $this->input->get("msg_signature"); $sVerifyTimeStamp = $this->input->get("timestamp"); $sVerifyNonce = $this->input->get("nonce"); $sVerifyEchoStr = $this->input->get("echostr"); // 需要返回的明文 $sEchoStr = ""; $errCode = $this->wxbizmsgcrypt->VerifyURL($sVerifyMsgSig, $sVerifyTimeStamp, $sVerifyNonce, $sVerifyEchoStr, $sEchoStr); if ($errCode == 0) { echo $sEchoStr; } else { print("ERR: " . $errCode . "\n\n"); } } 接收消息 # 接收消息这里很特殊,企业微信发送过来的是一个 xml string 类型的。我也是好久没写过 php 了,不知道怎么接收这个数据,用 post 方式尝试了 N 多次,也没成功。后来才查到相关资料是用file_get_contents("php://input")的方式来接收。接收到所有的数据,再通过官方提供的解密函数,解析出真实的 xml 信息。注意,这里并不是单纯的消息,还有各种如发送用户、发送的消息类型、发送的时间等信息。还得需要通过 xml 的进一步解析,才能解析出各个字段的值。/** * 接收消息 * 参数接收一些加密参数,具体消息是通过post的body传过来的, * 在php中,若body是一个纯字符串,需要用 file_get_contents('php://input') 的方式来接收 * * 关于file_get_contents和post的区别: * @see https://www.cnblogs.com/phpper/p/9574419.html */ private function decodeMsg(){ $this->load->library('WXBizMsgCrypt'); $this->wxbizmsgcrypt->init($this->token, $this->encodingAesKey, $this->corpId); $sReqMsgSig = $this->input->get("msg_signature"); $sReqTimeStamp = $this->input->get("timestamp"); $sReqNonce = $this->input->get("nonce"); // post请求的密文数据 $sReqData = file_get_contents('php://input'); $sMsg = ""; // 解析之后的明文 $errCode = $this->wxbizmsgcrypt->DecryptMsg($sReqMsgSig, $sReqTimeStamp, $sReqNonce, $sReqData, $sMsg); if ($errCode == 0) { // 解密成功,sMsg即为xml格式的明文 echo ($sMsg); return $sMsg; // TODO: 对明文的处理 /* " 1409659813 4561255354251345929 218 " */ } else { print("ERR: " . $errCode . "\n\n"); } } 回复消息 # 回复消息与接收消息差不多,根据官方要求的字段格式,拼接 xml,然后再以 string 类型进行加密。private function sendMsg(){ $this->load->library('WXBizMsgCrypt'); $this->wxbizmsgcrypt->init($this->token, $this->encodingAesKey, $this->corpId); // 接收消息 $getMsg = $this->decodeMsg(); // 接收到消息后,经过处理,然后需要返回给用户消息了 $now = time(); // 需要发送的明文 $sRespData = "{$this->corpId}]]>{$now}{$getMsg}]]>{$this->agentId}"; $sReqTimeStamp = $now; $sReqNonce = rand(); $sEncryptMsg = ""; //xml格式的密文 $errCode = $this->wxbizmsgcrypt->EncryptMsg($sRespData, $sReqTimeStamp, $sReqNonce, $sEncryptMsg); if ($errCode == 0) { echo ($sEncryptMsg); // print("done \n"); // TODO: // 加密成功,企业需要将加密之后的sEncryptMsg返回 // HttpUtils.SetResponce($sEncryptMsg); //回复加密之后的密文 } else { print("ERR: " . $errCode . "\n\n"); // exit(-1); } } 在群聊会话中发通知 # 比如一些活动报名、或者中奖名单等,有一长串的用户名单,需要拉群知会一些消息。若要手动创建的话,那每次都得搜索拉入,需要很长时间。但若通过接口创建的话,几秒钟就可以。 创建群聊 # 创建群聊官方地址:https://developer.work.weixin.qq.com/document/path/90245。{ "name": "NAME", "owner": "userid1", "userlist": ["userid1", "userid2", "userid3"], "chatid": "CHATID" } 设置好群聊名称、群主、群成员、群 Id(可选),就可以创建了。创建成功后,会返回该群聊的 id:// 创建群聊 const createGroupChat = async () => { const accessToken = await getAccessToken(); const { status, data } = await axios({ url: `https://qyapi.weixin.qq.com/cgi-bin/appchat/create?access_token=${accessToken}`, method: "post", data: { // chatid: Date.now(), name: `企业内建应用创建的群聊-${Date.now().toString(36)}`, owner: "xiaowenzi", userlist: ["xiaowenzi", "dawenzi"], // 成员名单至少需要2个人 }, }); // 创建成功后,不会立即在聊天框中展示出来,需要通过发送消息,来激活该群聊 console.log("createGroupChat", status, data); // chatid: wruVG5OwAAQF_MwwspiyXmD8T7NQt8yA }; 群聊创建成功后,会返回该群聊的 id,用于后续的比如修改群聊标题、群聊成员、发送群聊消息等操作。而且,群聊刚创建成功时,是不会立即在聊天框中展示出来的,需要通过发送消息,来激活该群聊。 向群聊中发送消息 # 这里也有一个发送消息的接口,但这里的接口跟上面的“发送消息推送”不是同一个接口。而且,这里的接口还需要指定群 id(即 chatid)才能发送消息。const sendMsgToGroup = async () => { const accessToken = await getAccessToken(); const { status, data } = await axios({ url: `https://qyapi.weixin.qq.com/cgi-bin/appchat/send?access_token=${accessToken}`, method: "post", data: { chatid: "chatid", // 修改自己的群聊id msgtype: "markdown", markdown: { content: '# 你的快递已到\n请携带工卡前往邮件中心领取\n邮件中心视频实况,聪明避开排队', }, safe: 0, }, }); console.log("sendMsgToGroup", status, data); }; 这里发送消息的格式,也是有多种格式。 总结 # 群机器人和企业自建应用有着不同的接入难度和接入场景,各位可以根据自己的需要,来选择适合自己的方式。
2024年10月20日
3 阅读
0 评论
0 点赞
2024-10-20
去掉数组中连续的数字
这是一道面试题,给大家分享一下:【去掉数组中连续的数字】,我又称之为「数组消消乐」。 题目 # 数组中存储着多个 0-9 的数字,若存在连续大于等于 4 个相同的数字,则将其消除;若消除后,依然有连续个数大于等于 4 的数字,则继续消除。然后返回最终消除完毕后的数组。实例:/** * @param {number[]} arr * @return {number[]} */ function eliminate(arr) {} console.log(eliminate([1, 1, 1, 1, 2])); // [2] console.log(eliminate([1, 1, 1, 1, 1, 2])); // [2] console.log(eliminate([2, 1, 1, 1, 1, 2, 2, 2])); // [] console.log(eliminate([1, 1, 1, 0, 0, 0, 0, 0, 1, 2, 2, 3, 3, 3, 3])); // [2, 2] 解题思路 # 这题根据数组中的数据和数组的长度,有着几个不同的解法。 简单暴力 # 最简单暴力的,就是每次只清除当前连续的数字,等下次循环时,再重新清除新拼接的新数组,直到无法清除为止。function eliminate(arr) { while (true) { let start = 0; let end = 0; for (end = start + 1; end < arr.length; end++) { if (arr[end] !== arr[start]) { break; } } if (end - start >= 4) { arr.splice(start, end); } else { return arr; } } } 但这个时间复杂度太高了,每清除一次,就得从头开始循环。当然,也可以加一些优化措施,比如继续向后查找是否存在连续个数大于等于 4 的数字,但这里尤其要注意一个问题,不能光只删除后面的数字,不管前面的数字。比如有这样的一个例子:[1,1,1,2,2,2,2,1,1,1,1],3 个连续的 1,4 个连续的 2,再跟着 4 个连续的 1。若删除了 4 个连续的 2 后,继续向后查找,只删除后续的连续的 4 个 1,是不对的。因为在删除 4 个 2 后,将数据前后拼接后,其实是连续 7 个 1。 利用数组计数器 # 我们可以看到题目中要求的是,数组的每项是 0-9 的数字,数据项不大,完全可以用数组来存储连续的数组个数。下标表示该数字,值表示该数字的连续个数。function eliminate(arr) { const counts = []; counts[arr[0]] = { start: 0, num: 1, }; let i = 1; while (i < arr.length) { if (arr[i] !== arr[i - 1]) { // 当数字不一样时,就需要判断前面的数字的连续的个数 const { start, num } = counts[arr[i - 1]] || {}; if (num >= 4) { counts[arr[i - 1]].num = 0; arr.splice(start, num); i = start - 1; } else { counts[arr[i]] = { start: i, num: 1, }; } } else { counts[arr[i]].num++; } i++; } const { start, num } = counts[arr[arr.length - 1]]; if (num >= 4) { arr.splice(start, num); } return arr; } 利用栈结构 # 这种题目天然适合栈结构,无论数组中是什么数据,均可以压到栈里,顶部的元素永远是当前正在连续计数的数字,当该数字被消除后,则从栈中弹出。function eliminate(arr) { const stack = []; // 每个元素存储的是该元素和连续的个数 let i = 0; while (i < arr.length) { const { length } = stack; if (!length) { // 栈中还没有元素,直接压入 stack.push({ item: arr[i], num: 1 }); i++; } else if (stack[length - 1].item === arr[i]) { // 栈顶的元素与当前元素相等 stack[length - 1].num++; i++; } else { // 栈顶的元素与当前元素不相等 if (stack[length - 1].num >= 4) { // 连续个数大于等于4,将该数字弹出 stack.pop(); // 而且,这里的i并没有加1,因为当前不一样的那个元素还要继续与下一个栈顶的元素进行比较 // 比如[1, 1, 1, 2, 2, 2, 2, 1],当栈顶元素2与最后一个1比较,栈弹出2后,最后一个1还得跟新栈顶元素1进行比较 } else { stack.push({ item: arr[i], num: 1 }); i++; } } } if (stack.length && stack[stack.length - 1].num >= 4) { stack.pop(); } // 栈里留下来的数据,都是连续个数不够4个的,将栈中的数据拿出来,拼接成新的数组 let result = []; for (let i = 0; i < stack.length; i++) { result = result.concat(new Array(stack[i].num).fill(stack[i].item)); } return result; } 我们利用栈结构先进后出的特性,能很方便地解决这个问题。 总结 # 这道题主要是考察我们对一些数据结构的使用,如果用不好的话,实现起来就麻烦很多,而且时间复杂度也会高很多。
2024年10月20日
3 阅读
0 评论
0 点赞
2024-10-20
2023 年最新最全的 React 面试题
React 作为前端使用最多的框架,必然是面试的重点。我们接下来主要从 React 的使用方式、源码层面和周边生态(如 redux, react-router 等)等几个方便来进行总结。 1. 使用方式上 # 这里主要考察的是,在开发使用过程中,对 React 框架的了解,如 hook 的不同调用方式得到的结果、函数组件中的 useState 和类组件的 state 的区别等等。 props 的变动,是否会引起 state hook 中数据的变动? # React 组件的 props 变动,会让组件重新执行,但并不会引起 state 的值的变动。state 值的变动,只能由 setState() 来触发。因此若想在 props 变动时,重置 state 的数据,需要监听 props 的变动,如:const App = props => { const [count, setCount] = useState(0); // 监听 props 的变化,重置 count 的值 useEffect(() => { setCount(0); }, [props]); return setCount(count + 1)}>{count}; }; React18 有哪些新变化? # React 的更新都是渐进式的更新,在 React18 中启用的新特性,其实在 v17 中(甚至更早)就埋下了。 并发渲染机制:根据用户的设备性能和网速对渲染过程进行适当的调整, 保证 React 应用在长时间的渲染过程中依旧保持可交互性,避免页面出现卡顿或无响应的情况,从而提升用户体验。 新的创建方式:现在是要先通过createRoot()创建一个 root 节点,然后该 root 节点来调用render()方法; 自动批处理优化:批处理: React 将多个状态更新分组到一个重新渲染中以获得更好的性能。(将多次 setstate 事件合并);在 v18 之前只在事件处理函数中实现了批处理,在 v18 中所有更新都将自动批处理,包括 promise 链、setTimeout 等异步代码以及原生事件处理函数; startTransition:主动降低优先级。比如「搜索引擎的关键词联想」,用户在输入框中的输入希望是实时的,而联想词汇可以稍稍延迟一会儿。我们可以用 startTransition 来降低联想词汇更新的优先级; useId:主要用于 SSR 服务端渲染的场景,方便在服务端渲染和客户端渲染时,产生唯一的 id; 并发模式是如何执行的? # React 中的并发,并不是指同一时刻同时在做多件事情。因为 js 本身就是单线程的(同一时间只能执行一件事情),而且还要跟 UI 渲染竞争主线程。若一个很耗时的任务占据了线程,那么后续的执行内容都会被阻塞。为了避免这种情况,React 就利用 fiber 结构和时间切片的机制,将一个大任务分解成多个小任务,然后按照任务的优先级和线程的占用情况,对任务进行调度。 对于每个更新,为其分配一个优先级 lane,用于区分其紧急程度。 通过 Fiber 结构将不紧急的更新拆分成多段更新,并通过宏任务的方式将其合理分配到浏览器的帧当中。这样就能使得紧急任务能够插入进来。 高优先级的更新会打断低优先级的更新,等高优先级更新完成后,再开始低优先级更新。 什么是受控组件和非受控组件? # 我们稍微了解下什么是受控组件和非受控组件: 受控组件:只能通过 React 修改数据或状态的组件,就是受控组件; 非受控组件:与受控组件相反,如 input, textarea, select, checkbox 等组件,本身控件自己就能控制数据和状态的变更,而且 React 是不知道这些变更的; 那么如何将非受控组件改为受控组件呢?那就是把上面的这些纯 html 组件数据或状态的变更,交给 React 来操作:const App = () => { const [value, setValue] = useState(''); const [checked, setChecked] = useState(false); return ( setValue(event.target.value)} /> setChecked(event.target.checked)} /> ); }; 上面代码中,输入框和 checkbox 的变化,均是经过了 React 来操作的,在数据变更时,React 是能够知道的。 高阶组件(HOC)? # 高阶组件? # 高阶组件通过包裹(wrapped)被传入的 React 组件,经过一系列处理,最终返回一个相对增强(enhanced)的 React 组件,供其他组件调用。作用: 复用逻辑:高阶组件更像是一个加工 react 组件的工厂,批量对原有组件进行加工,包装处理。我们可以根据业务需求定制化专属的 HOC,这样可以解决复用逻辑。 强化 props:这个是 HOC 最常用的用法之一,高阶组件返回的组件,可以劫持上一层传过来的 props,然后混入新的 props,来增强组件的功能。代表作 react-router 中的 withRouter。 赋能组件:HOC 有一项独特的特性,就是可以给被 HOC 包裹的业务组件,提供一些拓展功能,比如说额外的生命周期,额外的事件,但是这种 HOC,可能需要和业务组件紧密结合。典型案例 react-keepalive-router 中的 keepaliveLifeCycle 就是通过 HOC 方式,给业务组件增加了额外的生命周期。 控制渲染:劫持渲染是 hoc 一个特性,在 wrapComponent 包装组件中,可以对原来的组件,进行条件渲染,节流渲染,懒加载等功能,后面会详细讲解,典型代表做 react-redux 中 connect 和 dva 中 dynamic 组件懒加载。 参考:react 进阶」一文吃透 React 高阶组件(HOC) React 中为什么要使用 Hook? # 官方网站有介绍该原因:使用 Hook 的动机。这里我们简要的提炼下: 在组件之间复用状态逻辑很难:在类组件中,可能需要 render props 和 高阶组件等方式,但会形成“嵌套地域”;而使用 Hook,则可以从组件中提取状态逻辑,是的这些逻辑可以单独测试并复用; 复杂组件变得难以理解:在类组件中,每个生命周期常常包含一些不相关的逻辑。如不同的执行逻辑,都要放在componentDidMount中执行和获取数据,而之后需在 componentWillUnmount 中清除;但在函数组件中,不同的逻辑可以放在不同的 Hook 中执行,互不干扰; 难以理解的 class:类组件中,充斥着各种对 this 的使用,如 this.onClick.bind(this),this.state,this.setState() 等,同时,class 不能很好的压缩,并且会使热重载出现不稳定的情况;Hook 使你在非 class 的情况下可以使用更多的 React 特性; useCallback 和 useMemo 的使用场景 # useCallback 和 useMemo 可以用来缓存函数和变量,提高性能,减少资源浪费。但并不是所有的函数和变量都需要用这两者来实现,他也有对应的使用场景。我们知道 useCallback 可以缓存函数体,在依赖项没有变化时,前后两次渲染时,使用的函数体是一样的。它的使用场景是: 函数作为其他 hook 的依赖项时(如在 useEffect()中); 函数作为 React.memo()(或 shouldComponentUpdate )中的组件的 props; 主要是为了避免重新生成的函数,会导致其他 hook 或组件的不必要刷新。useMemo 用来缓存函数执行的结果。如每次渲染时都要执行一段很复杂的运算,或者一个变量需要依赖另一个变量的运算结果,就都可以使用 useMemo()。参考文章:React18 源码解析之 useCallback 和 useMemo。 useState 的传参方式,有什么区别? # useState()的传参有两种方式:纯数据和回调函数。这两者在初始化时,除了传入方式不同,没啥区别。但在调用时,不同的调用方式和所在环境,输出的结果也是不一样的。如:const App = () => { const [count, setCount] = useState(0); const handleParamClick = () => { setCount(count + 1); setCount(count + 1); setCount(count + 1); }; const handleCbClick = () => { setCount(count => count + 1); setCount(count => count + 1); setCount(count => count + 1); }; }; 上面的两种传入方式,最后得到的 count 结果是不一样的。为什么呢?因为在以数据的格式传参时,这 3 个使用的是同一个 count 变量,数值是一样的。相当于setCount(0 + 1),调用了 3 次;但以回调函数的传参方式,React 则一般地会直接该回调函数,然后得到最新结果并存储到 React 内部,下次使用时就是最新的了。注意:这个最新值是保存在 React 内部的,外部的 count 并不会马上更新,只有在下次渲染后才会更新。还有,在定时器中,两者得到的结果也是不一样的:const App = () => { const [count, setCount] = useState(0); useEffect(() => { const timer = setInterval(() => { setCount(count + 1); }, 500); return () => clearInterval(timer); }, []); useEffect(() => { const timer = setInterval(() => { setCount(count => count + 1); }, 500); return () => clearInterval(timer); }, []); }; 为什么在本地开发时,组件会渲染两次? # issues#2在 React.StrictMode 模式下,如果用了 useState,usesMemo,useReducer 之类的 Hook,React 会故意渲染两次,为的就是将一些不容易发现的错误容易暴露出来,同时 React.StrictMode 在正式环境中不会重复渲染。也就是在测试环境的严格模式下,才会渲染两次。 如何实现组件的懒加载 # 从 16.6.0 开始,React 提供了 lazy 和 Suspense 来实现懒加载。import React, { lazy, Suspense } from 'react'; const OtherComponent = lazy(() => import('./OtherComponent')); function MyComponent() { return ( ); } 属性fallback表示在加载组件前,渲染的内容。 如何实现一个定时器的 hook # 若在定时器内直接使用 React 的代码,可能会收到意想不到的结果。如我们想实现一个每 1 秒加 1 的定时器:const App = () => { const [count, setCount] = useState(0); useEffect(() => { const timer = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(timer); }, []); return {count}; }; 可以看到,coun 从 0 变成 1 以后,就再也不变了。为什么会这样?尽管由于定时器的存在,组件始终会一直重新渲染,但定时器的回调函数是挂载期间定义的,所以它的闭包永远是对挂载时 Counter 作用域的引用,故 count 永远不会超过 1。针对这个单一的 hook 调用,还比较好解决,例如可以监听 count 的变化,或者通过 useState 的 callback 传参方式。const App = () => { const [count, setCount] = useState(0); // 监听 count 的变化,不过这里将定时器改成了 setTimeout // 即使不修改,setInterval()的timer也会在每次渲染时被清除掉, // 然后重新启动一个新的定时器 useEffect(() => { const timer = setTimeout(() => { setCount(count + 1); }, 1000); return () => clearInterval(timer); }, [count]); // 以回调的方式 // 回调的方式,会计算回调的结果,然后作为下次更新的初始值 // 详情可见: https://www.xiabingbao.com/post/react/react-usestate-rn5bc0.html#5.+updateReducer useEffect(() => { const timer = setInterval(() => { setCount(count => count + 1); }, 1000); return () => clearInterval(timer); }, []); return {count}; }; 当然还有别的方式也可以实现 count 的更新。那要是调用更多的 hook,或者更复杂的代码,该怎么办呢?这里我们可以封装一个新的 hook 来使用:// https://overreacted.io/zh-hans/making-setinterval-declarative-with-react-hooks/ const useInterval = (callback: () => void, delay: number | null): void => { const savedCallback = useRef(callback); useEffect(() => { savedCallback.current = callback; }); useEffect(() => { function tick() { savedCallback.current(); } if (delay !== null) { const id = setInterval(tick, delay); return () => clearInterval(id); } }, [delay]); }; useEffect()的清除机制是什么?在什么时候执行? # useEffect(callback)的回调函数里,若有返回的函数,这是 effect 可选的清除机制。每个 effect 都可以返回一个清除函数。React 何时清除 effect? React 会在组件卸载的时候执行清除操作。同时,若组件产生了更新,会先执行上一个的清除函数,然后再运行下一个 effect。如// 运行第一个 effect // 产生更新时 // 清除上一个 effect // 运行下一个 effect // 产生更新时 // 清除上一个 effect // 运行下一个 effect // 组件卸载时 // 清除最后一个 effect 参考:为什么每次更新的时候都要运行 Effect 2. 源码层面上 # 这部分考察的就更有深度一些了,多多少少得了解一些源码,才能明白其中的缘由,比如 React 的 diff 对比,循环中 key 的作用等。 虚拟 dom 有什么优点?真实 dom 和虚拟 dom,谁快? # Virtual DOM 是以对象的方式来描述真实 dom 对象的,那么在做一些 update 的时候,可以在内存中进行数据比对,减少对真实 dom 的操作减少浏览器重排重绘的次数,减少浏览器的压力,提高程序的性能,并且因为 diff 算法的差异比较,记录了差异部分,那么在开发中就会帮助程序员减少对差异部分心智负担,提高了开发效率。虚拟 dom 好多这么多,渲染速度上是不是比直接操作真实 dom 快呢?并不是。虚拟 dom 增加了一层内存运算,然后才操作真实 dom,将数据渲染到页面上。渲染上肯定会慢上一些。虽然虚拟 dom 的缺点在初始化时增加了内存运算,增加了首页的渲染时间,但是运算时间是以毫秒级别或微秒级别算出的,对用户体验影响并不是很大。 什么是合成事件,与原生事件有什么区别? # React 中所有触发的事件,都是自己在其内部封装了一套事件机制。目的是为了实现全浏览器的一致性,抹平不同浏览器之间的差异性。在 React17 之前,React 是把事件委托在 document 上的,React17 及以后版本不再把事件委托在 document 上,而是委托在挂载的容器上。React 合成事件采用的是事件冒泡机制,当在某具体元素上触发事件时,等冒泡到顶部被挂载事件的那个元素时,才会真正地执行事件。而原生事件,当某具体元素触发事件时,会立刻执行该事件。因此若要比较事件触发的先后时机时,原生事件会先执行,React 合成事件会后执行。 key 的作用是什么? # key 帮助 React 识别哪些元素改变了,比如被添加或删除。因此你应当给数组中的每一个元素赋予一个确定的标识。当组件刷新时,React 内部会根据 key 和元素的 type,来对比元素是否发生了变化。若选做 key 的数据有问题,可能会在更新的过程中产生异常。参考:React18 源码解析之 key 的作用。 多次执行 useState(),会触发多次更新吗? # 在 React18 中,无论是多个 useState()的 hook,还是操作(dispatch)多次的数据。只要他们在同一优先级,React 就会将他们合并到一起操作,最后再更新数据。这是基于 React18 的批处理机制。React 将多个状态更新分组到一个重新渲染中以获得更好的性能。(将多次 setstate 事件合并);在 v18 之前只在事件处理函数中实现了批处理,在 v18 中所有更新都将自动批处理,包括 promise 链、setTimeout 等异步代码以及原生事件处理函数;参考:多次调用 useState() 中的 dispatch 方法,会产生多次渲染吗? useState()的 state 是否可以直接修改?是否可以引起组件渲染? # 首先声明,我们不应当直接修改 state 的值,一方面是无法刷新组件(无法将新数据渲染到页面中),再有可能会对下次的更新产生影响。唯一有影响的,就是后续要使用该变量的地方,会使用到新数据。但若其他 useState() 导致了组件的刷新,刚才变量的值,若是基本类型(比如数字、字符串等),会重置为修改之前的值;若是复杂类型,基于 js 的 对象引用 特性,也会同步修改 React 内部存储的数据,但不会引起视图的变化。参考:直接修改 state 的值,会怎样? React 的 diff 过程 # React 只对比当前层级的节点,不跨层级进行比较; 根据不同的节点类型,如函数组件节点、类组件节点、普通 fiber 节点、数组节点等,进入不同的处理函数; 前后两个 fiber 节点进行对比,若 type 不一样,直接舍弃掉旧的 fiber 节点,创建新的 fiber 节点;若 key 不一样,则需要根据情况判断,若是单个元素,则直接舍弃掉,创建新的 fiber 节点;若是数字型的元素,则查找是否移动了位置,若没找到,则创建新的节点;若 key 和 type 都一样,则接着往下递归; 若是单个 fiber 节点,则直接返回;若是并列多个元素的 fiber 节点,这里会形成单向链表,然后返回头指针(该链表最前面的那个 fiber 节点); 通过上面的 diff 对比过程,我们也可以看到,当组件产生比较大的变更时,React 需要做更多的动作,来构建出新的 fiber 树,因此我们在开发过程中,若从性能优化的角度考虑,尤其要注意的是: 节点不要产生大量的越级操作:因为 React 是只进行同层节点的对比,若同一个位置的子节点产生了比较大的变动,则只会舍弃掉之前的 fiber 节点,从而执行创建新 fiber 节点的操作;React 并不会把之前的 fiber 节点移动到另一个位置;相应的,之前的 jsx 节点移动到另一个位置后,在进行前后对比后,同样会执行更多的创建操作; 不修改节点的 key 和 type 类型,如使用随机数做为列表的 key,或从 div 标签改成 p 标签等操作,在 diff 对比过程中,都会直接舍弃掉之前的 fiber 节点及所有的子节点(即使子节点没有变动),然后重新创建出新的 fiber 节点; 参考:React18 源码解析之 reconcileChildren 生成 fiber 的过程 基于 React 框架的特点,可以有哪些优化措施? # 使用 React.lazy 和 Suspense 将页面设置为懒加载,避免 js 文件过大; 使用 SSR 同构直出技术,提高首屏的渲染速度; 使用 useCallback 和 useMemo 缓存函数或变量;使用 React.memo 缓存组件; 尽量调整样式或 className 的变动,减少 jsx 元素上的变动,尽量使用与元素相关的字段作为 key,可以减少 diff 的时间(React 会尽量复用之前的节点,若 jsx 元素发生变动,就需要重新创建节点); 对于不需要产生页面变动的数据,可以放到 useRef()中; React.Children.map 和 js 的 map 有什么区别? # JavaScript 中的 map 不会对为 null 或者 undefined 的数据进行处理,而 React.Children.map 中的 map 可以处理 React.Children 为 null 或者 undefined 的情况。 3. 周边生态 # 这部分主要考察 React 周边生态配套的了解,如状态管理库 redux、mobx,路由组件 react-router-dom 等。 react-router 和 react-router-dom 的有什么区别? # api 方面 # React-router: 提供了路由的核心 api。如 Router、Route、Switch 等,但没有提供有关 dom 操作进行路由跳转的 api; React-router-dom: 提供了 BrowserRouter、Route、Link 等 api,可以通过 dom 操作触发事件控制路由。 Link 组件,会渲染一个 a 标签;BrowserRouter 和 HashRouter 组件,前者使用 pushState 和 popState 事件构建路由,后者使用 hash 和 hashchange 事件构建路由。 使用区别 # react-router-dom 在 react-router 的基础上扩展了可操作 dom 的 api。 Swtich 和 Route 都是从 react-router 中导入了相应的组件并重新导出,没做什么特殊处理。 react-router-dom 中 package.json 依赖中存在对 react-router 的依赖,故此,不需要额外安装 react-router。 Redux 遵循的三个原则是什么? # 单一事实来源:整个应用的状态存储在单个 store 中的对象/状态树里。单一状态树可以更容易地跟踪随时间的变化,并调试或检查应用程序。 状态是只读的:改变状态的唯一方法是去触发一个动作。动作是描述变化的普通 JS 对象。就像 state 是数据的最小表示一样,该操作是对数据更改的最小表示。 使用纯函数进行更改:为了指定状态树如何通过操作进行转换,你需要纯函数。纯函数是那些返回值仅取决于其参数值的函数。 你对“单一事实来源”有什么理解? # Redux 使用 “Store” 将程序的整个状态存储在同一个地方。因此所有组件的状态都存储在 Store 中,并且它们从 Store 本身接收更新。单一状态树可以更容易地跟踪随时间的变化,并调试或检查程序。 Redux 有哪些优点? # Redux 的优点如下: 结果的可预测性 - 由于总是存在一个真实来源,即 store ,因此不存在如何将当前状态与动作和应用的其他部分同步的问题。 可维护性 - 代码变得更容易维护,具有可预测的结果和严格的结构。 服务器端渲染 - 你只需将服务器上创建的 store 传到客户端即可。这对初始渲染非常有用,并且可以优化应用性能,从而提供更好的用户体验。 开发人员工具 - 从操作到状态更改,开发人员可以实时跟踪应用中发生的所有事情。 社区和生态系统 - Redux 背后有一个巨大的社区,这使得它更加迷人。一个由才华横溢的人组成的大型社区为库的改进做出了贡献,并开发了各种应用。 易于测试 - Redux 的代码主要是小巧、纯粹和独立的功能。这使代码可测试且独立。 组织 - Redux 准确地说明了代码的组织方式,这使得代码在团队使用时更加一致和简单。 4. 总结 # React 涉及到的相关知识点非常多,我也会经常更新的。
2024年10月20日
3 阅读
0 评论
0 点赞
1
...
54
55
56
...
213