2023面试真题之框架篇
阅读使人充实,会谈使人敏捷,写作使人精确
大家好,我是柒八九。
今天,我们继续2023前端面试真题系列。我们来谈谈关于前端框架的相关知识点。
如果,想了解该系列的文章,可以参考我们已经发布的文章。如下是往期文章。
文章list
你能所学到的知识点
- React Diff 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- setState同步异步问题 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- React 18新特性 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- React 生命周期 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- Hook的相关知识点 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- ref能否拿到函数组件的实例 推荐阅读指数⭐️⭐️⭐️
- useCallbck vs useMemo的区别 推荐阅读指数⭐️⭐️⭐️
- React.memo 推荐阅读指数⭐️⭐️⭐️⭐️
- 类组件和函数组件的区别 推荐阅读指数⭐️⭐️⭐️⭐️
- componentWillUnmount在浏览器刷新后,会执行吗 推荐阅读指数⭐️⭐️⭐️
- React 组件优化 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- React-Router实现原理 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- XXR 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- WebComponents 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- Lit 推荐阅读指数⭐️⭐️⭐️⭐️
- npm 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- yarn 推荐阅读指数⭐️⭐️⭐️⭐️
- pnpm 推荐阅读指数⭐️⭐️⭐️⭐️
- yarn PnP 推荐阅读指数⭐️⭐️⭐️⭐️
- npm install 发生了啥 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- 使用 history 模式的前端路由时静态资源服务器配置详解 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- webpack 优化 推荐阅读指数⭐️⭐️⭐️⭐️
- Redux内部实现 推荐阅读指数⭐️⭐️⭐️⭐️ 24.Vue和 React的区别 推荐阅读指数⭐️⭐️⭐️⭐️
- Webpack有哪些常用的loader和plugin 推荐阅读指数⭐️⭐️⭐️⭐️
- Babel 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- Fiber 实现时间切片的原理 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- devServer进行跨域处理 推荐阅读指数⭐️⭐️⭐️
- React-Hook 实现原理 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
好了,天不早了,干点正事哇。

React Diff
在React中,diff算法需要与虚拟DOM配合才能发挥出真正的威力。React会使用diff算法计算出虚拟DOM中真正发生变化的部分,并且只会针对该部分进行dom操作,从而避免了对页面进行大面积的更新渲染,减小性能的开销。
React diff算法
在传统的diff算法中复杂度会达到O(n^3)。React中定义了三种策略,在对比时,根据策略只需遍历一次树就可以完成对比,将复杂度降到了O(n):
-
tree diff:在两个树对比时,只会比较同一层级的节点,会忽略掉跨层级的操作
-
component diff:在对比两个组件时,首先会判断它们两个的类型是否相同
- 如果不是,则将该组件判断为
dirty component,从而替换整个组件下的所有子节点 
- 如果不是,则将该组件判断为
-
element diff:对于同一层级的一组节点,会使用具有
唯一性的key来区分是否需要创建,删除,或者是移动。
Element Diff
当节点处于同一层级时,React diff 提供了三种节点操作,分别为:
INSERT_MARKUP(插入)- 新的
component类型不在老集合里, 即是全新的节点,需要对新节点执行插入操作
- 新的
MOVE_EXISTING(移动)- 在老集合有新
component类型,且element是可更新的类型,这种情况下prevChild=nextChild,就需要做移动操作,可以复用以前的 DOM 节点。
- 在老集合有新
REMOVE_NODE(删除)- 老
component类型,在新集合里也有,但对应的element不同则不能直接复用和更新,需要执行删除操作, - 或者老
component不在新集合里的,也需要执行删除操作
- 老
存在如下结构:

新老集合进行 diff 差异化对比,通过 key 发现新老集合中的节点都是相同的节点,因此无需进行节点删除和创建,只需要将老集合中节点的位置进行移动,更新为新集合中节点的位置,此时 React 给出的 diff 结果为:B、D 不做任何操作,A、C 进行移动操作
- 首先对新集合的节点进行循环遍历,
for (name in nextChildren), - 通过唯一 key 可以判断新老集合中是否存在相同的节点,
if (prevChild === nextChild) - 如果存在相同节点,则进行移动操作
- 但在移动前需要将当前节点在老集合中的位置与
lastIndex进行比较,if (child._mountIndex < lastIndex),则进行节点移动操作,否则不执行该操作。lastIndex一直在更新,表示访问过的节点在老集合中最右的位置(即最大的位置),- 如果新集合中当前访问的节点比
lastIndex大,说明当前访问节点在老集合中就比上一个节点位置靠后,则该节点不会影响其他节点的位置,因此不用添加到差异队列中,即不执行移动操作 - 只有当访问的节点比
lastIndex小时,才需要进行移动操作。
当完成新集合中所有节点
diff时,最后还需要对老集合进行循环遍历,判断是否存在新集合中没有但老集合中仍存在的节点,发现存在这样的节点x,因此删除节点x,到此diff全部完成。
setState同步异步问题
18.x之前版本
如果直接在setState后面获取state的值是获取不到的。
- 在
React内部机制能检测到的地方,setState就是异步的; - 在
React检测不到的地方,例如 原生事件addEventListener,setInterval,setTimeout,setState就是同步更新的
setState并不是单纯的异步或同步,这其实与调用时的环境相关
- 在合成事件 和 生命周期钩子(除componentDidUpdate) 中,
setState是"异步"的; - 在 原生事件 和
setTimeout中,setState是同步的,可以马上获取更新后的值;
批量更新
多个顺序的setState不是同步地一个一个执行滴,会一个一个加入队列,然后最后一起执行。在 合成事件 和 生命周期钩子 中,setState更新队列时,存储的是 合并状态(Object.assign)。因此前面设置的 key 值会被后面所覆盖,最终只会执行一次更新。
异步现象原因
setState 的“异步”并不是说内部由异步代码实现,其实本身执行的过程和代码都是同步的,只是合成事件和生命钩子函数的调用顺序在更新之前,导致在合成事件和钩子函数中没法立马拿到更新后的值,形成了所谓的“异步”,当然可以通过第二个参数setState(partialState, callback)中的callback拿到更新后的结果。
setState并非真异步,只是看上去像异步。在源码中,通过isBatchingUpdates来判断
setState调用流程:
- 调用
this.setState(newState) - 将新状态
newState存入pending队列 - 判断是否处于
batch Update(isBatchingUpdates是否为true)isBatchingUpdates=true,保存组件于dirtyComponents中,走异步更新流程,合并操作,延迟更新;isBatchingUpdates=false,走同步过程。遍历所有的dirtyComponents,调用updateComponent,更新pending state or props

为什么直接修改this.state无效
setState本质是通过一个队列机制实现state更新的。 执行setState时,会将需要更新的state合并后放入状态队列,而不会立刻更新state,队列机制可以批量更新state。
如果不通过setState而直接修改this.state,那么这个state不会放入状态队列中,下次调用setState时对状态队列进行合并时,会忽略之前直接被修改的state,这样我们就无法合并了,而且实际也没有把你想要的state更新上去
React18
在 v18 之前只在事件处理函数中实现了批处理,在 v18 中所有更新都将自动批处理,包括 promise链、setTimeout等异步代码以及原生事件处理函数
React 18新特性
React 从 v16 到 v18 主打的特性包括三个变化:
- v16:
Async Mode(异步模式) - v17:
Concurrent Mode(并发模式) - v18:
Concurrent Render(并发更新)
React 中 Fiber 树的更新流程分为两个阶段 render 阶段和 commit 阶段。
- 组件的
render函数执行时称为render(本次更新需要做哪些变更),纯 js 计算; - 而将
render的结果渲染到页面的过程称为commit(变更到真实的宿主环境中,在浏览器中就是操作DOM)。
在 Sync 模式下,render 阶段是一次性执行完成;而在 Concurrent 模式下 render 阶段可以被拆解,每个时间片内执行一部分,直到执行完毕。由于 commit 阶段有 DOM 的更新,不可能让 DOM 更新到一半中断,必须一次性执行完毕。
React 并发新特性
并发渲染机制
concurrent rendering的目的:根据用户的设备性能和网速对渲染过程进行适当的调整, 保证React应用在长时间的渲染过程中依旧保持可交互性,避免页面出现卡顿或无响应的情况,从而提升用户体验。
- 新 root API
- 通过
createRootApi 手动创建root节点。
- 通过
- 自动批处理优化 Automatic batching
React将多个状态更新分组到一个重新渲染中以获得更好的性能。(将多次setstate事件合并)- 在
v18之前只在事件处理函数中实现了批处理,在v18中所有更新都将自动批处理,包括promise链、setTimeout等异步代码以及原生事件处理函数。 - 想退出自动批处理立即更新的话,可以使用
ReactDOM.flushSync()进行包裹
startTransition- 可以用来降低渲染优先级。分别用来包裹计算量大的
function和value,降低优先级,减少重复渲染次数。 startTransition可以指定 UI 的渲染优先级,哪些需要实时更新,哪些需要延迟更新- hook 版本的
useTransition,接受传入一个毫秒的参数用来修改最迟更新时间,返回一个过渡期的pending状态和startTransition函数。
- 可以用来降低渲染优先级。分别用来包裹计算量大的
useDefferdValue- 通过
useDefferdValue允许变量延时更新,同时接受一个可选的延迟更新的最大值。React将尝试尽快更新延迟值,如果在给定的timeoutMs期限内未能完成,它将强制更新 const defferValue = useDeferredValue(value, { timeoutMs: 1000 })useDefferdValue能够很好的展现并发渲染时优先级调整的特性,可以用于延迟计算逻辑比较复杂的状态,让其他组件优先渲染,等待这个状态更新完毕之后再渲染。
- 通过
React 生命周期
生命周期
React 的 生命周期主要有两个比较大的版本,分别是
v16.0前v16.4两个版本
的生命周期。
v16.0前

总共分为四大阶段:
- {初始化| Intialization}
- {挂载| Mounting}
- {更新| Update}
- {卸载| Unmounting}
Intialization(初始化)
在初始化阶段,会用到 constructor() 这个构造函数,如:
constructor(props) {
super(props);
}
super的作用- 用来调用基类的构造方法(
constructor()), - 也将父组件的
props注入给子组件,供子组件读取
- 用来调用基类的构造方法(
- 初始化操作,定义
this.state的初始内容 - 只会执行一次
Mounting(挂载)(3个)
componentWillMount:在组件挂载到DOM前调用- 这里面的调用的
this.setState不会引起组件的重新渲染,也可以把写在这边的内容提到constructor(),所以在项目中很少。 - 只会调用一次
- 这里面的调用的
render: 渲染- 只要
props和state发生改变(无论值是否有变化,两者的重传递和重赋值,都可以引起组件重新render),都会重新渲染render。 return:是必须的,是一个React元素,不负责组件实际渲染工作,由React自身根据此元素去渲染出DOM。render是纯函数,不能执行this.setState。
- 只要
componentDidMount:组件挂载到DOM后调用- 调用一次
Update(更新)(5个)
-
componentWillReceiveProps(nextProps):调用于props引起的组件更新过程中nextProps:父组件传给当前组件新的props- 可以用
nextProps和this.props来查明重传props是否发生改变(原因:不能保证父组件重传的props有变化) - 只要
props发生变化就会,引起调用
-
shouldComponentUpdate(nextProps, nextState):用于性能优化nextProps:当前组件的this.propsnextState:当前组件的this.state- 通过比较
nextProps和nextState,来判断当前组件是否有必要继续执行更新过程。 - 返回
false:表示停止更新,用于减少组件的不必要渲染,优化性能 - 返回
true:继续执行更新 - 像
componentWillReceiveProps()中执行了this.setState,更新了state,但在render前(如shouldComponentUpdate,componentWillUpdate),this.state依然指向更新前的state,不然nextState及当前组件的this.state的对比就一直是true了
-
componentWillUpdate(nextProps, nextState):组件更新前调用- 在
render方法前执行 - 由于组件更新就会调用,所以一般很少使用
- 在
-
render:重新渲染 -
componentDidUpdate(prevProps, prevState):组件更新后被调用prevProps:组件更新前的propsprevState:组件更新前的state- 可以操作组件更新的DOM
Unmounting(卸载)(1个)
componentWillUnmount:组件被卸载前调用
可以在这里执行一些清理工作,比如清除组件中使用的定时器,清除componentDidMount中手动创建的DOM元素等,以避免引起内存泄漏
React v16.4

与 v16.0的生命周期相比
- 新增了 -- (两个
getXX)getDerivedStateFromPropsgetSnapshotBeforeUpdate
- 取消了 -- (三个
componmentWillXX)componentWillMount、componentWillReceiveProps、componentWillUpdate
getDerivedStateFromProps
getDerivedStateFromProps(prevProps, prevState):组件创建和更新时调用的方法
prevProps:组件更新前的propsprevState:组件更新前的state
在
React v16.3中,在创建和更新时,只能是由父组件引发才会调用这个函数,在React v16.4改为无论是Mounting还是Updating,全部都会调用。
是一个静态函数,也就是这个函数不能通过this访问到class的属性。
如果
props传入的内容不需要影响到你的state,那么就需要返回一个null,这个返回值是必须的,所以尽量将其写到函数的末尾。
在组件创建时和更新时的render方法之前调用,它应该
- 返回一个对象来更新状态
- 或者返回
null来不更新任何内容
getSnapshotBeforeUpdate
getSnapshotBeforeUpdate(prevProps,prevState):Updating时的函数,在render之后调用
prevProps:组件更新前的propsprevState:组件更新前的state
可以读取,但无法使用DOM的时候,在组件可以在可能更改之前从DOM捕获一些信息(例如滚动位置)
返回的任何值都将作为参数传递给
componentDidUpdate()
Note
在17.0的版本,官方彻底废除
componentWillMount、componentWillReceiveProps、componentWillUpdate
Hook的相关知识点
react-hooks是React 16.8的产物,给函数式组件赋上了生命周期。
React v16.8中的hooks
useState
useState:定义变量,可以理解为他是类组件中的this.state
使用:
const [state, setState] = useState(initialState);
state:目的是提供给 UI,作为渲染视图的数据源setState:改变state的函数,可以理解为this.setStateinitialState:初始默认值
useState有点类似于PureComponent,会进行一个比较浅的比较,如果是对象的时候直接传入并不会更新。
解决传入对象的问题
使用 useImmer 替代 useState。
immer.js 这个库,是基于 proxy 拦截 getter 和 setter 的能力,让我们可以很方便的通过修改对象本身,创建新的对象。
React 通过 Object.is 函数比较 props,也就是说对于引用一致的对象,react是不会刷新视图的,这也是为什么我们不能直接修改调用 useState 得到的 state 来更新视图,而是要通过 setState 刷新视图,通常,为了方便,我们会使用 es6 的 spread 运算符构造新的对象(浅拷贝)。
对于嵌套层级多的对象,使用
spread构造新的对象写起来心智负担很大,也不易于维护
常规的处理方式是对数据进行deepClone,但是这种处理方式针对结构简单的数据来讲还算OK,但是遇到大数据的话,就不够优雅了。
所以,我们可以直接使用 useImmer 这个语法糖来进一步简化调用方式
const [state,setState] = useImmer({
a: 1,
b: {
c: [1,2]
d: 2
},
});
setState(prev => {
prev.b.c.push(3);
}))
useEffect
useEffect:副作用,你可以理解为是类组件的生命周期,也是我们最常用的钩子
副作用(
Side Effect):是指function做了和本身运算返回值无关的事,如请求数据、修改全局变量,打印、数据获取、设置订阅以及手动更改React组件中的DOM都属于副作用操作
- 不断执行
- 当
useEffect不设立第二个参数时,无论什么情况,都会执行
- 当
- 根据依赖值改变
- 设置
useEffect的第二个值
- 设置
useContext
useContext:上下文,类似于Context:其本意就是设置全局共享数据,使所有组件可跨层级实现数据共享
useContent的参数一般是由createContext的创建,通过 xxContext.Provider 包裹的组件,才能通过 useContext 获取对应的值
存在的问题及解决方案
useContext 是 React 官方推荐的共享状态的方式,然而在需要共享状态的组件非常多的情况下,这有着严重的性能问题,例如有A/B组件, A 组件只更新 state.a,并没有用到 state.b,B 组件更新 state.b 的时候 A 组件也会刷新,在组件非常多的情况下,就卡死了,用户体验非常不好。
解决上述问题,可以使用 react-tracked 这个库,它拥有和 useContext 差不多的 api,但基于 proxy 和组件内部的 useForceUpdate 做到了自动化的追踪,可以精准更新每个组件,不会出现修改大的 state,所有组件都刷新的情况。
useReducer
useReducer:它类似于redux功能的api
const [state, dispatch] = useReducer(reducer, initialArg, init);
state:更新后的state值dispatch:可以理解为和useState的setState一样的效果reducer:可以理解为redux的reducerinitialArg:初始值init:惰性初始化
useMemo
useMemo:与memo的理念上差不多,都是判断是否满足当前的限定条件来决定是否执行callback函数,而useMemo的第二个参数是一个数组,通过这个数组来判定是否执行回调函数
当一个父组件中调用了一个子组件的时候,父组件的
state发生变化,会导致父组件更新,而子组件虽然没有发生改变,但也会进行更新。
只要父组件的状态更新,无论有没有对子组件进行操作,子组件都会进行更新,useMemo就是为了防止这点而出现的。
useCallback
useCallback与useMemo极其类似,唯一不同的是
useMemo返回的是函数运行的结果- 而
useCallback返回的是函数- 这个函数是父组件传递子组件的一个函数,防止做无关的刷新,
- 其次,这个子组件必须配合
React.memo,否则不但不会提升性能,还有可能降低性能
存在的问题及解决方案
一个很常见的误区是为了心理上的性能提升把函数通通使用 useCallback 包裹,在大多数情况下,javascript 创建一个函数的开销是很小的,哪怕每次渲染都重新创建,也不会有太大的性能损耗,真正的性能损耗在于,很多时候 callback 函数是组件 props 的一部分,因为每次渲染的时候都会重新创建 callback 导致函数引用不同,所以触发了组件的重渲染。然而一旦函数使用 useCallback 包裹,则要面对声明依赖项的问题,对于一个内部捕获了很多 state 的函数,写依赖项非常容易写错,因此引发 bug。
所以,在大多数场景下,我们应该只在需要维持函数引用的情况下使用 useCallback。
const [userText, setUserText] = useState("");
const handleUserKeyPress = useCallback(event => {
// do something here
}, []);
useEffect(() => {
window.addEventListener("keydown", handleUserKeyPress);
return () => {
window.removeEventListener("keydown", handleUserKeyPress);
};
}, [handleUserKeyPress]);
return (
<div>
{userText}
</div>
);
在组件卸载的时候移除
event listener callback,因此需要保持event handler的引用,所以这里需要使用useCallback来保持引用不变。
使用 useCallback,我们又会面临声明依赖项的问题,这里我们可以使用 ahook 中的 useMemoizedFn 的方式,既能保持引用,又不用声明依赖项。
const [state, setState] = useState('');
// func 地址永远不会变化
const func = useMemoizedFn(() => {
console.log(state);
});
useRef
useRef: 可以获取当前元素的所有属性,并且返回一个可变的ref对象,并且这个对象只有current属性,可设置initialValue
- 通过
useRef获取对应的React元素的属性值 - 缓存数据
useImperativeHandle
useImperativeHandle:可以让你在使用 ref 时自定义暴露给父组件的实例值
useImperativeHandle(ref, createHandle, [deps])
ref:useRef所创建的refcreateHandle:处理的函数,返回值作为暴露给父组件的ref对象。deps:依赖项,依赖项更改形成新的ref对象。
useImperativeHandle和forwardRef配合使用
function FancyInput(props, ref) {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current.focus();
}
}));
return <input ref={inputRef} ... />;
}
FancyInput = forwardRef(FancyInput);
在父组件中,可以渲染<FancyInput ref={inputRef} />并可以通过父组件的inputRef对子组件中的input进行处理。
inputRef.current.focus()
useLayoutEffect
useLayoutEffect: 与useEffect基本一致,不同的地方时,useLayoutEffect是同步
要注意的是useLayoutEffect在 DOM 更新之后,浏览器绘制之前,这样做的好处是可以更加方便的修改 DOM,获取 DOM 信息,这样浏览器只会绘制一次,所以useLayoutEffect在useEffect之前执行
如果是 useEffect 的话 ,useEffect 执行在浏览器绘制视图之后,如果在此时改变DOM,有可能会导致浏览器再次回流和重绘。
除此之外useLayoutEffect的 callback 中代码执行会阻塞浏览器绘制
useDebugValue
useDebugValue:可用于在 React 开发者工具中显示自定义 hook 的标签
React v18中的hooks
useSyncExternalStore
useSyncExternalStore:是一个推荐用于读取和订阅外部数据源的 hook,其方式与选择性的 hydration 和时间切片等并发渲染功能兼容
const state = useSyncExternalStore(
subscribe,
getSnapshot[, getServerSnapshot]
)
subscribe: 订阅函数,用于注册一个回调函数,当存储值发生更改时被调用。此外,useSyncExternalStore会通过带有记忆性的getSnapshot来判别数据是否发生变化,如果发生变化,那么会强制更新数据。getSnapshot: 返回当前存储值的函数。必须返回缓存的值。如果getSnapshot连续多次调用,则必须返回相同的确切值,除非中间有存储值更新。getServerSnapshot:返回服务端(hydration模式下)渲染期间使用的存储值的函数
useTransition
useTransition:
- 返回一个状态值表示过渡任务的等待状态,
- 以及一个启动该过渡任务的函数。
过渡任务
在一些场景中,如:输入框、tab切换、按钮等,这些任务需要视图上立刻做出响应,这些任务可以称之为立即更新的任务
但有的时候,更新任务并不是那么紧急,或者来说要去请求数据等,导致新的状态不能立马更新,需要用一个loading...的等待状态,这类任务就是过度任务
const [isPending, startTransition] = useTransition();
isPending:过渡状态的标志,为true时是等待状态startTransition:可以将里面的任务变成过渡任务
useDeferredValue
useDeferredValue:接受一个值,并返回该值的新副本,该副本将推迟到更紧急地更新之后。
如果当前渲染是一个紧急更新的结果,比如用户输入,React 将返回之前的值,然后在紧急渲染完成后渲染新的值。
也就是说useDeferredValue可以让状态滞后派生。
const deferredValue = useDeferredValue(value);
value:可变的值,如useState创建的值deferredValue: 延时状态
useTransition和useDeferredValue做个对比
- 相同点:
useDeferredValue和useTransition一样,都是过渡更新任务- 不同点:
useTransition给的是一个状态,而useDeferredValue给的是一个值
useInsertionEffect
useInsertionEffect:与 useLayoutEffect 一样,但它在所有 DOM 突变之前同步触发
在执行顺序上 useInsertionEffect > useLayoutEffect > useEffect
seInsertionEffect应仅限于css-in-js库作者使用。优先考虑使用useEffect或useLayoutEffect来替代。
useId
useId : 是一个用于生成横跨服务端和客户端的稳定的唯一 ID 的同时避免hydration不匹配的 hook。
ref能否拿到函数组件的实例
使用forwordRef
将input单独封装成一个组件TextInput。
const TextInput = React.forwardRef((props,ref) => {
return <input ref={ref}></input>
})
用TextInputWithFocusButton调用它
function TextInputWithFocusButton() {
// 关键代码
const inputEl = useRef(null);
const onButtonClick = () => {
// 关键代码,`current` 指向已挂载到 DOM 上的文本输入元素
inputEl.current.focus();
};
return (
<>
// 关键代码
<TextInput ref={inputEl}></TextInput>
<button onClick={onButtonClick}>Focus the input</button>
</>
);
}
useImperativeHandle
有时候,我们可能不想将整个子组件暴露给父组件,而只是暴露出父组件需要的值或者方法,这样可以让代码更加明确。而useImperativeHandle Api就是帮助我们做这件事的。
const TextInput = forwardRef((props,ref) => {
const inputRef = useRef();
// 关键代码
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current.focus();
}
}));
return <input ref={inputRef} />
})
function TextInputWithFocusButton() {
// 关键代码
const inputEl = useRef(null);
const onButtonClick = () => {
// 关键代码,`current` 指向已挂载到 DOM 上的文本输入元素
inputEl.current.focus();
};
return (
<>
// 关键代码
<TextInput ref={inputEl}></TextInput>
<button onClick={onButtonClick}>
Focus the input
</button>
</>
);
}
也可以使用current.focus()来做input聚焦。
这里要注意的是,子组件
TextInput中的useRef对象,只是用来获取input元素的,大家不要和父组件的useRef混淆了。
useCallbck vs useMemo的区别
useMemo
const memoizedValue = useMemo(
() => computeExpensiveValue(a, b),
[a, b]
);
useMemo:与memo的理念上差不多,都是判断是否满足当前的限定条件来决定是否执行callback函数,而useMemo的第二个参数是一个数组,通过这个数组来判定是否执行回调函数
当一个父组件中调用了一个子组件的时候,父组件的
state发生变化,会导致父组件更新,而子组件虽然没有发生改变,但也会进行更新。
只要父组件的状态更新,无论有没有对子组件进行操作,子组件都会进行更新,useMemo就是为了防止这点而出现的。
useCallback
useCallback 可以理解为 useMemo 的语法糖
const memoizedCallback = useCallback(
+ () => {
doSomething(a, b);
+ },
[a, b],
);
useCallback与useMemo极其类似,唯一不同的是
useMemo返回的是函数运行的结果- 而
useCallback返回的是函数
- 这个函数是父组件传递子组件的一个函数,防止做无关的刷新,
- 其次,这个子组件必须配合
React.memo,否则不但不会提升性能,还有可能降低性能
React.memo
memo:结合了 pureComponent 纯组件和 componentShouldUpdate()功能,会对传入的 props 进行一次对比,然后根据第二个函数返回值来进一步判断哪些props需要更新
要注意
memo是一个高阶组件,函数式组件和类组件都可以使用。
memo 接收两个参数:
function MyComponent(props) {
}
function areEqual(prevProps, nextProps) {
}
export default React.memo(MyComponent, areEqual);
- 第一个参数:组件本身,也就是要优化的组件
- 第二个参数:
(pre, next) => boolean,pre:之前的数据next:现在的数据- 返回一个布尔值
- 若为 true 则不更新
- 为
false更新
memo的注意事项
React.memo 与 PureComponent 的区别:
- 服务对象不同:
PureComponent服务于类组件,React.memo既可以服务于类组件,也可以服务与函数式组件,useMemo服务于函数式组件
- 针对的对象不同:
PureComponent针对的是props和stateReact.memo只能针对props来决定是否渲染
React.memo的第二个参数的返回值与shouldComponentUpdate的返回值是相反的
React.memo:返回true组件不渲染 , 返回false组件重新渲染。shouldComponentUpdate: 返回true组件渲染 , 返回false组件不渲染
类组件和函数组件的区别
相同点
组件是 React 可复用的最小代码片段,它们会返回要在页面中渲染 React 元素,也正是基于这一点,所以在 React 中无论是函数组件,还是类组件,其实它们最终呈现的效果都是一致的。
不同点
设计思想
- 类组件的根基是
OOP(面向对象编程),所以它会有继承,有内部状态管理等 - 函数组件的根基是
FP(函数式编程)
未来的发展趋势
React 团队从 Facebook 的实际业务场景触发,通过探索时间切片和并发模式,以及考虑性能的进一步优化和组件间更合理的代码拆分后,认为 类组件的模式并不能很好地适应未来的趋势,它们给出了以下3个原因:
this的模糊性- 业务逻辑耦合在生命周期中
React的组件代码缺乏标准的拆分方式
componentWillUnmount在浏览器刷新后,会执行吗
不会。
如果想实现,在刷新页面时进行数据处理。使用beforeunload事件。
还有一个navigator.sendBeacon()
React 组件优化
- 父组件刷新,而不波及子组件
- 组件自己控制自己是否刷新
- 减少波及范围,无关刷新数据不存入
state中- 合并
state,减少重复setState的操作
父组件刷新,而不波及子组件
- 子组件自己判断是否需要更新 ,典型的就是
PureComponent,shouldComponentUpdate,React.memo
- 父组件对子组件做个缓冲判断
使用PureComponent注意点
- 父组件是函数组件,子组件用
PureComponent时,匿名函数,箭头函数和普通函数都会重新声明- 可以使用
useMemo或者useCallback,利用他们缓冲一份函数,保证不会出现重复声明就可以了。
- 可以使用
- 类组件中不使用箭头函数,匿名函数
class组件中每一次刷新都会重复调用render函数,那么render函数中使用的匿名函数,箭头函数就会造成重复刷新的问题- 处理方式- 换成普通函数
- 在
class组件的render函数中调用bind函数- 把
bind操作放在constructor中
- 把
shouldComponentUpdate
class 组件中 使用 shouldComponentUpdate 是主要的优化方式,它不仅仅可以判断来自父组件的nextprops,还可以根据nextState和最新的nextContext来决定是否更新。
React.memo
React.memo的规则是如果想要复用最后一次渲染结果,就返回true,不想复用就返回false。所以它和shouldComponentUpdate的正好相反,false才会更新,true就返回缓冲。
const Children = React.memo(function ({count}){
return (
<div>
只有父组件传入的值是偶数的时候才会更新
{count}
</div>
)
},(prevProps, nextProps)=>{
if(nextProps.count % 2 === 0){
return false;
}else{
return true;
}
})
使用 React.useMemo来实现对子组件的缓冲
子组件只关心count数据,当我们刷新name数据的时候,并不会触发刷新 Children子组件,实现了我们对组件的缓冲控制。
export default function Father (){
let [count,setCount] = React.useState(0);
let [name,setName] = React.useState(0);
const render = React.useMemo(
()=>
<Children count = {count}/>
,[count]
)
return (
<div>
<button onClick={()=>setCount(++count)}>
点击刷新count
</button>
<br/>
<button onClick={()=>setName(++name)}>
点击刷新name
</button>
<br/>
{"count"+count}
<br/>
{"name"+name}
<br/>
{render}
</div>
)
}
减少波及范围,无关刷新数据不存入state中
- 无意义重复调用
setState,合并相关的state - 和页面刷新无关的数据,不存入
state中 - 通过存入
useRef的数据中,避免父子组件的重复刷新 - 合并
state,减少重复setState的操作ReactDOM.unstable_batchedUpdates;- 多个
setState会合并执行一次。
React-Router实现原理
react-router-dom和react-router和history库三者什么关系
history可以理解为react-router的核心,也是整个路由原理的核心,里面集成了popState,history.pushState等底层路由实现的原理方法react-router可以理解为是react-router-dom的核心,里面封装了Router,Route,Switch等核心组件,实现了从路由的改变到组件的更新的核心功能react-router-dom,在react-router的核心基础上,添加了用于跳转的Link组件,和histoy模式下的BrowserRouter和hash模式下的HashRouter组件等。- 所谓
BrowserRouter和HashRouter,也只不过用了history库中createBrowserHistory和createHashHistory方法
- 所谓
单页面实现核心原理
单页面应用路由实现原理是,切换
url,监听url变化,从而渲染不同的页面组件。
主要的方式有history模式和hash模式。
history模式原理
- 改变路由
history.pushState(state,title,path)
- 监听路由
window.addEventListener('popstate',function(e){ /* 监听改变 */})
hash模式原理
- 改变路由
- 通过
window.location.hash属性获取和设置hash值
- 通过
- 监听路由
window.addEventListener('hashchange',function(e){ /* 监听改变 */})

XXR
根据不同的构建、渲染过程有不同的优劣势和适用情况。
- 现代 UI 库加持下常用的
CSR、 - 具有更好
SEO效果的SSR(SPR)、 - 转换思路主打构建时生成的
SSG、 - 大架构视野之上的
ISR、DPR, - 还有更少听到的
NSR、ESR。
CSR(Client Side Rendering)
页面托管服务器只需要对页面的访问请求响应一个如下的空页面
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<!-- metas -->
<title></title>
<link rel="shortcut icon" href="xxx.png" />
<link rel="stylesheet" href="xxx.css" />
</head>
<body>
<div id="root"><!-- page content --></div>
<script src="xxx/filterXss.min.js"></script>
<script src="xxx/x.chunk.js"></script>
<script src="xxx/main.chunk.js"></script>
</body>
</html>
页面中留出一个用于填充渲染内容的视图节点 (div#root),并插入指向项目编译压缩后的
JS Bundle文件的script节点- 指向
CSS文件的link.stylesheet节点等。
浏览器接收到这样的文档响应之后,会根据文档内的链接加载脚本与样式资源,并完成以下几方面主要工作:
- 执行脚本
- 进行网络访问以获取在线数据
- 使用 DOM API 更新页面结构
- 绑定交互事件
- 注入样式
以此完成整个渲染过程。
CSR 模式有以下几方面优点:
- UI 库支持
- 前后端分离
- 服务器负担轻
SSR (Server Side Rendering)
SSR 的概念,即与 CSR 相对地,在服务端完成大部分渲染工作,--- 服务器在响应站点访问请求的时候,就已经渲染好可供呈现的页面。
像 React、Vue 这样的 UI 生态巨头,其实都有一个关键的 Virtual DOM (or VDOM) 概念,先自己建模处理视图表现与更新、再批量调 DOM API 完成视图渲染更新。这就带来了一种 SSR 方案:
VDOM 是自建模型,是一种抽象的嵌套数据结构,也就可以在 Node 环境(或者说一切服务端环境)下跑起来,把原来的视图代码拿来在服务端跑,通过 VDOM 维护,再在最后拼接好字符串作为页面响应,生成文档作为响应页面,此时的页面内容已经基本生成完毕,把逻辑代码、样式代码附上,则可以实现完整的、可呈现页面的响应。
SSR优点
- 呈现速度和用户体验佳
SEO友好
SSR缺点
- 引入成本高
- 将视图渲染的工作交给了服务器做,引入了新的概念和技术栈(如 Node)
- 响应时间长
- SSR 在完成访问响应的时候需要做更多的计算和生成工作
- 关键指标
TTFB(Time To First Byte) 将变得更大
- 首屏交互不佳
- 虽然 SSR 可以让页面请求响应后更快在浏览器上渲染出来
- 但在首帧出现,需要客户端加载激活的逻辑代码(如事件绑定)还没有初始化完毕的时候,其实是不可交互的状态
SSR-React 原理
- VDOM
- 同构
- 双端对比
VDOM

同构


双端对比

renderToString()

renderToStaticMarkup()
ReactDOMServer.renderToStaticMarkup(element)
仅仅是为了将组件渲染为html字符串,不会带有data-react-checksum属性

SPR (Serverless Pre-Rendering)
无服务预渲染,这是 Serverless 话题之下的一项渲染技术。SPR 是指在 SSR 架构下通过预渲染与缓存能力,将部分页面转化为静态页面,以避免其在服务器接收到请求的时候频繁被渲染的能力,同时一些框架还支持设置静态资源过期时间,以确保这部分“静态页面”也能有一定的即时性。
SSG (Static Site Generation)
- 它与
CSR一样,只需要页面托管,不需要真正编写并部署服务端,页面资源在编译完成部署之前就已经确定; - 但它又与
SSR一样,属于一种Prerender预渲染操作,即在用户浏览器得到页面响应之前,页面内容和结构就已经渲染好了。 - 当然形式和特征来看,它更接近 SSR。
SSG模式,把原本日益动态化、交互性增强的页面,变成了大部分已经填充好,托管在页面服务 / CDN 上的静态页面
NSR (Native Side Rendering)
Native 就是客户端,万物皆可分布式,可以理解为这就是一种分布式的 SSR,不过这里的渲染工作交给了客户端去做而不是远端服务器。在用户即将访问页面的上级页面预取页面数据,由客户端缓存 HTML 结构,以达到用户真正访问时快速响应的效果。
NSR 见于各种移动端 + Webview 的 Hybrid 场景,是需要页面与客户端研发协作的一种优化手段。
ESR (Edge Side Rendering)
Edge 就是边缘,类比前面的各种 XSR,ESR 就是将渲染工作交给边缘服务器节点,常见的就是 CDN 的边缘节点。这个方案主打的是边缘节点相比核心服务器与用户的距离优势,利用了 CDN 分级缓存的概念,渲染和内容填充也可以是分级进行并缓存下来的。
ESR 之下静态内容与动态内容是分流的,
- 边缘 CDN 节点可以将静态页面内容先响应给用户
- 然后再自己发起动态内容请求,得到核心服务器响应之后再返回给用户
是在大型网络架构下非常极致的一种优化,但这也就依赖更庞大的技术基建体系了。
ISR (Incremental Site Rendering)
增量式网站渲染,就是对待页面内容小刀切,有更细的差异化渲染粒度,能渐进、分层地进行渲染。
常见的选择是:
- 对于重要页面如首屏、访问量较大的直接落地页,进行预渲染并添加缓存,保证最佳的访问性能;
- 对于次要页面,则确保有兜底内容可以即时
fallback,再将其实时数据的渲染留到 CSR 层次完成,同时触发异步缓存更新。
对于“异步缓存更新”,则需要提到一个常见的内容缓存策略:Stale While Revalidate,CDN 对于数据请求始终首先响应缓存内容,如果这份内容已经过期,则在响应之后再触发异步更新——这也是对于次要元素或页面的缓存处理方式。
WebComponents
Web Components 是一套不同的技术,允许您创建可重用的定制元素并且在您的 web 应用中使用它们
三要素
Custom elements(自定义元素): 一组JavaScriptAPI,允许您定义custom elements及其行为,然后可以在您的用户界面中按照需要使用它们。- 通过
class A extends HTMLElement {}定义组件, - 通过
window.customElements.define('a-b', A)挂载已定义组件。
- 通过
Shadow DOM(影子 DOM ):一组JavaScriptAPI,用于将封装的“影子” DOM 树附加到元素(与主文档 DOM 分开呈现)并控制其关联的功能。- 通过这种方式,您可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。
- 使用
const shadow = this.attachShadow({mode : 'open'})在WebComponents中开启。
HTML templates(HTML 模板)slot:template可以简化生成dom元素的操作,不再需要createElement每一个节点。
虽然 WebComponents 有三个要素,但却不是缺一不可的,WebComponents
- 借助
shadow dom来实现样式隔离,- 借助
templates来简化标签的操作。
内部生命周期函数(4个)
connectedCallback: 当WebComponents第一次被挂在到dom上是触发的钩子,并且只会触发一次。- 类似
React中的useEffect(() => {}, []),componentDidMount。
- 类似
disconnectedCallback: 当自定义元素与文档DOM断开连接时被调用。adoptedCallback: 当自定义元素被移动到新文档时被调用。attributeChangedCallback: 当自定义元素的被监听属性变化时被调用。
组件通信
传入复杂数据类型
-
传入一个
JSON字符串配饰attributeJSON.stringify配置指定属性- 在组件
attributeChangedCallback中判断对应属性,然后用JSON.parse()获取
-
配置DOM的
property属性xx.dataSource = [{ name: 'xxx', age: 19 }]- 但是,自定义组件中没有办法监听到这个属性的变化
- 如果想实现,复杂的结构,不是通过配置,而是在定义组件时候,就确定
状态的双向绑定
<wl-input id="ipt"
:value="data"
@change="(e) => { data = e.detail }">
</wl-input>
// js
(function () {
const template = document.createElement('template')
template.innerHTML = `
<style>
.wl-input {
}
</style>
<input type="text" id="wlInput">
`
class WlInput extends HTMLElement {
constructor() {
super()
const shadow = this.attachShadow({
mode: 'closed'
})
const content = template.content.cloneNode(true)
this._input = content.querySelector('#wlInput')
this._input.value = this.getAttribute('value')
shadow.appendChild(content)
this._input.addEventListener("input", ev => {
const target = ev.target;
const value = target.value;
this.value = value;
this.dispatchEvent(
new CustomEvent("change", { detail: value })
);
});
}
get value() {
return this.getAttribute("value");
}
set value(value) {
this.setAttribute("value", value);
}
}
window.customElements.define('wl-input', WlInput)
})()
监听了这个表单的 input 事件,并且在每次触发 input 事件的时候触发自定义的 change 事件,并且把输入的参数回传。
样式设置
直接给自定义标签添加样式
<style>
wl-input{
display: block;
margin: 20px;
border: 1px solid red;
}
</style>
<wl-input></wl-input>
<script src="./index.js"></script>
定义元素内部子元素设置样式
分为两种场景:
- 在主 DOM 使用 JS
- 在 Custom Elements 构造函数中使用 JS
在主 DOM 使用 JS 给 Shadow DOM 增加 style 标签:
<script>
class WlInput extends HTMLElement {
constructor () {
super();
this.shadow = this.attachShadow({mode: "open"});
let headerEle = document.createElement("div");
headerEle.className = "input-header";
headerEle.innerText = "北宸南蓁";
this.shadow.appendChild(headerEle);
}
}
window.customElements.define("wl-input", WlInput);
// 给 Shadow DOM 增加 style 标签
let styleEle = document.createElement("style");
styleEle.textContent = `
.input-header{
padding:10px;
background-color: yellow;
font-size: 16px;
font-weight: bold;
}
`;
document.querySelector("wl-input").shadowRoot.appendChild(styleEle);
</script>
在 Custom Elements 构造函数中使用 JS 增加 style 标签:
<script>
class WlInput extends HTMLElement {
constructor () {
super();
this.shadow = this.attachShadow({mode: "open"});
let styleEle = document.createElement("style");
styleEle.textContent = `
.input-header{
padding:10px;
background-color: yellow;
font-size: 16px;
font-weight: bold;
}
`;
this.shadow.appendChild(styleEle);
let headerEle = document.createElement("div");
headerEle.className = "input-header";
headerEle.innerText = "北宸南蓁";
this.shadow.appendChild(headerEle);
}
}
window.customElements.define("wl-input", WlInput);
</script>
引入 CSS 文件
使用 JS 创建 link 标签,然后引入 CSS 文件给自定义元素内部的子元素设置样式
<script>
class WlInput extends HTMLElement {
constructor () {
super();
this.shadow = this.attachShadow({mode: "open"});
let linkEle = document.createElement("link");
linkEle.rel = "stylesheet";
linkEle.href = "./my_input.css";
this.shadow.appendChild(linkEle);
let headerEle = document.createElement("div");
headerEle.className = "input-header";
headerEle.innerText = "北宸南蓁";
this.shadow.appendChild(headerEle);
}
}
window.customElements.define("wl-input", WlInput);
</script>
样式文件
.input-header{
padding:10px;
background-color: yellow;
font-size: 16px;
font-weight: bold;
}
Lit
Lit 的核心是一个组件基类,它提供响应式、scoped 样式和一个小巧、快速且富有表现力的声明性模板系统,且支持 TypeScript 类型声明。
Lit 在开发过程中不需要编译或构建,几乎可以在无工具的情况下使用。
我们知道 HTMLElement 是浏览器内置的类,LitElement 基类则是 HTMLElement 的子类,因此 Lit 组件继承了所有标准 HTMLElement 属性和方法。更具体来说,LitElement 继承自 ReactiveElement,后者实现了响应式属性,而后者又继承自 HTMLElement。

而 LitElement 框架则是基于 HTMLElement 类二次封装了 LitElement 类。
export class LitButton extends LitElement { /* ... */ }
customElements.define('lit-button', LitButton);
渲染
组件具有 render 方法,该方法被调用以渲染组件的内容。
export class LitButton extends LitElement {
/* ... */
render() {
// 使用模板字符串,可以包含表达式
return html`
<div><slot name="btnText"></slot></div>
`;
}
}
组件的 render() 方法返回单个 TemplateResult 对象
响应式 properties
DOM 中
property与attribute的区别:
attribute是HTML标签上的特性,可以理解为标签属性,它的值只能够是String类型,并且会自动添加同名 DOM 属性作为 property 的初始值;property是DOM中的属性,是JavaScript里的对象,有同名attribiute标签属性的property属性值的改变也并不会同步引起attribute标签属性值的改变;
Lit 组件接收标签属性 attribute 并将其状态存储为 JavaScript 的 class 字段属性或 properties。响应式 properties 是可以在更改时触发响应式更新周期、重新渲染组件以及可选地读取或重新写入 attribute 的属性。每一个 properties 属性都可以配置它的选项对象
传入复杂数据类型
对于复杂数据的处理,为什么会存在这个问题,根本原因还是因为 attribute 标签属性值只能是 String 类型,其他类型需要进行序列化。在 LitElement 中,只需要在父组件模板的属性值前使用.操作符,这样子组件内部 properties 就可以正确序列化为目标类型。
优点
LitElement 在 Web Components 开发方面有着很多比原生的优势,它具有以下特点:
- 简单:在
Web Components标准之上构建,Lit添加了响应式、声明性模板和一些周到的功能,减少了模板文件。- 快速:更新速度很快,因为
Lit会跟踪UI的动态部分,并且只在底层状态发生变化时更新那些部分——无需重建整个虚拟树并将其与 DOM 的当前状态进行比较。- 轻便:
Lit的压缩后大小约为 5 KB,有助于保持较小的包大小并缩短加载时间。- 高扩展性:
lit-html基于标记的template,它结合了 ES6 中的模板字符串语法,使得它无需预编译、预处理,就能获得浏览器原生支持,并且扩展能力强。- 兼容良好:对浏览器兼容性非常好,对主流浏览器都能有非常好的支持。
npm
嵌套的 node_modules 结构
npm 在早期采用的是嵌套的 node_modules 结构,直接依赖会平铺在 node_modules 下,子依赖嵌套在直接依赖的 node_modules 中。
比如项目依赖了A 和 C,而 A 和 C 依赖了不同版本的 B@1.0 和 B@2.0,node_modules 结构如下:
node_modules
├── A@1.0.0
│ └── node_modules
│ └── B@1.0.0
└── C@1.0.0
└── node_modules
└── B@2.0.0
如果 D 也依赖 B@1.0,会生成如下的嵌套结构:
node_modules
├── A@1.0.0
│ └── node_modules
│ └── B@1.0.0
├── C@1.0.0
│ └── node_modules
│ └── B@2.0.0
└── D@1.0.0
└── node_modules
└── B@1.0.0
可以看到同版本的 B 分别被 A 和 D 安装了两次。
依赖地狱 Dependency Hell
在真实场景下,依赖增多,冗余的包也变多,node_modules 最终会堪比黑洞,很快就能把磁盘占满。而且依赖嵌套的深度也会十分可怕,这个就是依赖地狱。
扁平的 node_modules 结构
为了将嵌套的依赖尽量打平,避免过深的依赖树和包冗余,npm v3 将子依赖提升(hoist),采用扁平的 node_modules 结构,子依赖会尽量平铺安装在主依赖项所在的目录中。
node_modules
├── A@1.0.0
├── B@1.0.0
└── C@1.0.0
└── node_modules
└── B@2.0.0
可以看到 A 的子依赖的 B@1.0 不再放在 A 的 node_modules 下了,而是与 A 同层级。
而 C 依赖的 B@2.0 因为版本号原因还是嵌套在 C 的 node_modules 下。
这样不会造成大量包的重复安装,依赖的层级也不会太深,解决了依赖地狱问题,但也形成了新的问题。
幽灵依赖 Phantom dependencies
幽灵依赖是指在
package.json中未定义的依赖,但项目中依然可以正确地被引用到。
比如上方的示例其实我们只安装了 A 和 C:
{
"dependencies": {
"A": "^1.0.0",
"C": "^1.0.0"
}
}
由于 B 在安装时被提升到了和 A 同样的层级,所以在项目中引用 B 还是能正常工作的。
幽灵依赖是由依赖的声明丢失造成的,如果某天某个版本的 A 依赖不再依赖 B 或者 B 的版本发生了变化,那么就会造成依赖缺失或兼容性问题。
不确定性 Non-Determinism
不确定性是指:同样的 package.json 文件,install 依赖后可能不会得到同样的 node_modules 目录结构。
如果有 package.json 变更,本地需要删除 node_modules 重新 install,否则可能会导致生产环境与开发环境 node_modules 结构不同,代码无法正常运行。
依赖分身 Doppelgangers
假设继续再安装依赖 B@1.0 的 D 模块和依赖 @B2.0 的 E 模块,此时:
A 和 D 依赖 B@1.0
C 和 E 依赖 B@2.0
以下是提升 B@1.0 的 node_modules 结构:
node_modules
├── A@1.0.0
├── B@1.0.0
├── D@1.0.0
├── C@1.0.0
│ └── node_modules
│ └── B@2.0.0
└── E@1.0.0
└── node_modules
└── B@2.0.0
可以看到 B@2.0 会被安装两次,实际上无论提升 B@1.0 还是 B@2.0,都会存在重复版本的 B 被安装,这两个重复安装的 B 就叫 doppelgangers。
yarn
yarn 也采用扁平化 node_modules 结构
提升安装速度
在 npm 中安装依赖时,安装任务是串行的,会按包顺序逐个执行安装,这意味着它会等待一个包完全安装,然后再继续下一个。
为了加快包安装速度,yarn 采用了并行操作,在性能上有显著的提高。而且在缓存机制上,yarn 会将每个包缓存在磁盘上,在下一次安装这个包时,可以脱离网络实现从磁盘离线安装。
lockfile 解决不确定性
yarn 更大的贡献是发明了 yarn.lock。
在依赖安装时,会根据 package.josn 生成一份 yarn.lock 文件。
lockfile 里记录了依赖,以及依赖的子依赖,依赖的版本,获取地址与验证模块完整性的 hash。
即使是不同的安装顺序,相同的依赖关系在任何的环境和容器中,都能得到稳定的
node_modules目录结构,保证了依赖安装的确定性。
所以 yarn 在出现时被定义为快速、安全、可靠的依赖管理。而 npm 在一年后的 v5 才发布了 package-lock.json。
与 npm 一样的弊端
yarn 依然和 npm 一样是扁平化的 node_modules 结构,没有解决幽灵依赖和依赖分身问题。
pnpm
内容寻址存储 CAS
与依赖提升和扁平化的 node_modules 不同,pnpm 引入了另一套依赖管理策略:内容寻址存储。
该策略会将包安装在系统的全局 store 中,依赖的每个版本只会在系统中安装一次。
在引用项目 node_modules 的依赖时,会通过硬链接与符号链接在全局 store 中找到这个文件。为了实现此过程,node_modules 下会多出 .pnpm 目录,而且是非扁平化结构。
-
硬链接
Hard link:硬链接可以理解为源文件的副本,项目里安装的其实是副本,它使得用户可以通过路径引用查找到全局store中的源文件,而且这个副本根本不占任何空间。同时,pnpm会在全局store里存储硬链接,不同的项目可以从全局store寻找到同一个依赖,大大地节省了磁盘空间。 -
符号链接
Symbolic link:也叫软连接,可以理解为快捷方式,pnpm可以通过它找到对应磁盘目录下的依赖地址。
由于链接的优势,pnpm 的安装速度在大多数场景都比 npm 和 yarn 快 2 倍,节省的磁盘空间也更多。
yarn Plug’n’Play
Plug’n’Play(Plug'n'Play = Plug and Play = PnP,即插即用)。
抛弃 node_modules
无论是 npm 还是 yarn,都具备缓存的功能,大多数情况下安装依赖时,其实是将缓存中的相关包复制到项目目录中 node_modules 里。
而 yarn PnP 则不会进行拷贝这一步,而是在项目里维护一张静态映射表 pnp.cjs。
npm install 发生了啥

使用 history 模式的前端路由时静态资源服务器配置详解
我们一般都是打包以后放在静态资源服务器中的,我们访问诸如 example.com/rootpath/ 这种形式的资源没问题,是因为,index.html 文件是真实的存在于 rootpath 文件夹中的,可以找到的,返回给前端的。
但是如果访问子路由 example.com/rootpath/login 进行登录操作,但是 login/index.html 文件并非真实存在的文件,其实我们需要的文件还是 rootpath 目录中的 index.html 。
再者,如果我们需要 js 文件,比如登陆的时候请求的地址是 example.com/rootpath/login/js/dist.js 其实我们想要的文件,还是 rootpath/js/ 目录中的 dist.js 文件而已。
前端路由其实是一种假象,只是用来蒙蔽使用者而已的,无论用什么路由,访问的都是同一套静态资源。
之所以展示的内容不同,只是因为代码里,根据不同的路由,对要显示的视图做了处理而已。
比如
- 要找
example.com/rootpath/login静态资源服务器找不到,那就返回example.com/rootpath/内容; - 要找
example.com/rootpath/login/css/style.css找不到,那就照着example.com/rootpath/css/style.css这个路径去找。
总之就是,请求的是子目录,找不到,那就返回根目录一级对应的资源文件就好了。
在 nginx 中使用
如果你打包以后的前端静态资源文件,想要仍在 nginx 中使用,那首先将你打包好的静态资源目录扔进 www 目录,比如你打包好的资源的目录叫 rootpath ,那么直接将 rootpath 整个目录丢进 www 目录即可。
然后打开我们的 nginx 配置文件 nginx.conf,插入以下配置:
location /rootpath/ {
root html;
index index.html index.htm;
try_files $uri $uri/ /rootpath/index.html;
}
root的作用- 就是指定一个根目录。默认的是
html目录
- 就是指定一个根目录。默认的是
try_files- 关键点1:按指定的
file顺序查找存在的文件,并使用第一个找到的文件进行请求处理 - 关键点2:查找路径是按照给定的
root或alias为根路径来查找的 - 关键点3:如果给出的
file都没有匹配到,则重新请求最后一个参数给定的uri,就是新的location匹配
- 关键点1:按指定的
webpack 优化
时间方向(8个)
- 开发环境 -
EvalSourceMapDevToolPlugin排除第三方模块devtool:falseEvalSourceMapDevToolPlugin,通过传入module: true和column:false,达到和预设eval-cheap-module-source-map一样的质量
- 缩小
loader的搜索范围:test、include、exclude Module.noParsenoParse: /jquery|lodash/,
TypeScript编译优化Resolve.modules指定查找模块的目录范围Resolve.aliasResolve.extensions指定查找模块的文件类型范围HappyPack
资源大小(9个)
- 按需引入类库模块 (工具类库)
- 使用
babel-plugin-import对其处理
- 使用
- 使用
externals优化cdn静态资源 - CSS抽离+剔除无用样式 -
MiniCssExtractPlugin+PurgeCSS - CSS压缩 -
CssMinimizerWebpackPlugin TreeSharking- CSS 方向 -
glob-allpurify-csspurifycss-webpack - JS方向 -
babel-loader版本问题
- CSS 方向 -
Code Spilt-optimization-splitChunks-chunks:all- 魔法注释 -
webpackChunkName:’xxx‘ Scope Hoisting-optimization-concatenateModules:true- 普通打包只是将一个模块最终放入一个单独的函数中,如果模块很多,就意味着在输出结果中会有很多的模块函数。concatenateModules 配置的作用,尽可能将所有模块合并到一起输出到一个函数中,既提升了运行效率,又减少了代码的体积。
- 图片压缩 -
image-webpack-loader- 只要在file-loader之后加入image-webpack-loader即可
共同方案
IgnorePlugin
Redux内部实现
createStore
function createStore(
reducer,
preloadedState,
enhancer
){
let state;
// 用于存放被 subscribe 订阅的函数(监听函数)
let listeners = [];
// getState 是一个很简单的函数
const getState = () => state;
return {
dispatch,
getState,
subscribe,
replaceReducer
}
}
dispatch
function dispatch(action) {
// 通过 reducer 返回新的 state
// 这个 reducer 就是 createStore 函数的第一个参数
state = reducer(state, action);
// 每一次状态更新后,都需要调用 listeners 数组中的每一个监听函数
listeners.forEach(listener => listener());
return action; // 返回 action
}
subscribe
function subscribe(listener){
listeners.push(listener);
// 函数取消订阅函数
return () => {
listeners = listeners.filter(fn => fn !== listener);
}
}
combineReducers
function combineReducers(reducers){
return (state = {},action) => {
// 返回的是一个对象,reducer 就是返回的对象
return Object.keys(reducers).reduce(
(accum,currentKey) => {
accum[currentKey] = reducers[currentKey](state[currentKey],action);
return accum;
},{} // accum 初始值是空对象
);
}
}
applyMiddleware
function applyMiddleware(...middlewares){
return function(createStore){
return function(reducer,initialState){
var store = createStore(reducer,initialState);
var dispatch = store.dispatch;
var chain = [];
var middlewareAPI = {
getState: store.getState,
dispatch: (action) => dispatch(action)
};
chain = middlewares.map(
middleware => middleware(middlewareAPI)
);
dispatch = compose(...chain)(store.dispatch);
return { ...store, dispatch };
}
}
}
applyMiddleware 函数是一个三级柯里化函数
Vue和 React的区别
共同点
- 数据驱动视图
- 组件化
- 都使用
Virtual DOM
不同点
- 核心思想
Vue灵活易用的渐进式框架,进行数据拦截/代理,它对侦测数据的变化更敏感、更精确React推崇函数式编程(纯组件),数据不可变以及单向数据流
- 组件写法差异
React推荐的做法是JSX + inline style, 也就是把HTML和CSS全都写进 JavaScript 中,即all in js;Vue推荐的做法是template的单文件组件格式即html,css,JS写在同一个文件
diff算法不同- 两者流程思路上是类似的:不同的组件产生不同的 DOM 结构。当type不相同时,对应DOM操作就是直接销毁老的DOM,创建新的DOM。 同一层次的一组子节点,可以通过唯一的 key 区分。
Vue-Diff算法采用了双端比较的算法,同时从新旧children的两端开始进行比较,借助key值找到可复用的节点,再进行相关操作。相比React的Diff算法,同样情况下可以减少移动节点次数,减少不必要的性能损耗,更加的优雅。
- 响应式原理不同
Vue依赖收集,自动优化,数据可变, 当数据改变时,自动找到引用组件重新渲染React基于状态机,手动优化,数据不可变,需要setState驱动新的state替换老的state。 当数据改变时,以组件为根目录,默认全部重新渲染。
Webpack有哪些常用的loader和plugin
Webpack Loader vs Plugin
loader是文件加载器,能够加载资源文件,并对这些文件进行一些处理,诸如编译、压缩等,最终一起打包到指定的文件中plugin赋予了webpack各种灵活的功能,例如打包优化、资源管理、环境变量注入等,目的是解决 loader 无法实现的其他事

loader运行在打包文件之前plugins在整个编译周期都起作用
常用loader
- 样式:
style-loader、css-loader、less-loader、sass-loader、MiniCssExtractPlugin+PurgeCSS+CssMinimizerWebpackPlugin - js:
bable-loader/ts-loader - 图片:
url-loader(limit)、file-loader、image-webpack-loader - 代码校验:
eslint-loader
常用plugin
HtmlWebpackPlugin:会在打包结束之后自动创建一个index.html, 并将打包好的JS自动引入到这个文件中MiniCssExtractPluginIgnorePlugin:用于忽略第三方包指定目录,让指定目录不被打包进去terser-webpack-plugin:压缩js代码SplitChunksPlugin:Code-Splitting实现的底层就是通过Split-Chunks-Plugin实现的,其作用就是代码分割。
Babel
Babel 是一个 JavaScript 编译器!

Babel的作用就是将源码转换为目标代码
Babel的作用
主要用于将采用 ECMAScript 2015+ 语法编写的代码转换为 es5 语法,让开发者无视用户浏览器的差异性,并且能够用新的 JS 语法及特性进行开发。除此之外,Babel 能够转换 JSX 语法,并且能够支持 TypeScript 转换为 JavaScript。
总结一下:
Babel的作用如下
- 语法转换
- 通过
Polyfill方式在目标环境中添加缺失的特性- 源码转换
原理
Babel 的运行原理可以通过以下这张图来概括。整体来看,可以分为三个过程,分别是:
- 解析,
- 词法解析
- 语法解析
- 转换,
- 生成。

Babel7 的使用
Babel 支持多种形式的配置文件,根据使用场景不同可以选择不同的配置文件。
- 如果配置中需要书写 js 逻辑,可以选择babel.config.js或者 .babelrc.js;
- 如果只是需要一个简单的
key-value配置,那么可以选择.babelrc,甚至可以直接在 package.json 中配置。
所有 Babel 的包都发布在 npm 上,并且名称以 @babel 为前缀(自从版本 7.0 之后),接下来,我们一起看下 @babel/core 和 @babel/cli 这两个 npm 包。
@babel/core- 核心库,封装了Babel的核心能力@babel/cli- 命令行工具, 提供了babel这个命令
Babel构建在插件之上的。默认情况下,Babel不做任何处理,需要借助插件来完成语法的解析,转换,输出。
插件的配置形式常见有两种,分别是
- 字符串格式
- 数组格式,并且可以传递参数
如果插件名称为 @babel/plugin-XXX,可以使用简写成@babel/XXX,
- 例如
@babel/plugin-transform-arrow-functions便可以简写成@babel/transform-arrow-functions。
插件的执行顺序是从前往后。
// .babelrc
/*
* 以下三个插件的执行顺序是:
@babel/proposal-class-properties ->
@babel/syntax-dynamic-import ->
@babel/plugin-transform-arrow-functions
*/
{
"plugins": [
// 同 "@babel/plugin-proposal-class-properties"
"@babel/proposal-class-properties",
// 同 ["@babel/plugin-syntax-dynamic-import"]
["@babel/syntax-dynamic-import"],
[
"@babel/plugin-transform-arrow-functions",
{
"loose": true
}
]
]
}
预设
预设是一组插件的集合。
与插件类似,预设的配置形式也是字符串和数组两种,预设也可以将 @babel/preset-XXX 简写为 @babel/XXX 。
预设的执行顺序是从后往前,并且插件在预设之前执行。
我们常见的预设有以下几种:
@babel/preset-env: 可以无视浏览器环境的差异而尽情地使用 ES6+ 新语法和新特性;- 注:语法和特性不是一回事,语法上的迭代是让我们书写代码更加简单和方便,如展开运算符、类,结构等,因此这些语法称为语法糖;特性上的迭代是为了扩展语言的能力,如
Map、Promise等, - 事实上,
Babel对新语法和新特性的处理也是不一样的,对于新语法,Babel 通过插件直接转换,而对于新特性,Babel 还需要借助 polyfill 来处理和转换。
- 注:语法和特性不是一回事,语法上的迭代是让我们书写代码更加简单和方便,如展开运算符、类,结构等,因此这些语法称为语法糖;特性上的迭代是为了扩展语言的能力,如
@babe/preset-react: 可以书写JSX语法,将JSX语法转换为JS语法;@babel/preset-typescript:可以使用TypeScript编写程序,将TS转换为JS;- 注:该预设只是将 TS 转为 JS,不做任何类型检查
@babel/preset-flow:可以使用Flow来控制类型,将Flow转换为JS;
// .babelrc
/*
* 预设的执行顺序为:
@babel/preset-react ->
@babel/preset-typescript ->
@babel/preset-env
*/
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "usage",
"corejs": {
"version": 3,
"proposals": true // 使用尚在提议阶段特性的 polyfill
}
}
],
"@babel/preset-typescript",
// 同 @babel/preset-react
"@babel/react"
]
}
对于 @babel/preset-env ,我们通常需要设置目标浏览器环境,可以在根目录下的 .browserslistrc 文件中设置,也可以在该预设的参数选项中通过 targets(优先级最高) 或者在 package.json 中通过 browserslist 设置。
如果我们不设置的话,该预设默认会将所有的 ES6+ 的新语法全部做转换,否则,该预设只会对目标浏览器环境不兼容的新语法做转换。
推荐设置目标浏览器环境,这样在中大型项目中可以明显缩小编译后的代码体积,因为有些新语法的转换需要引入一些额外定义的 helper 函数的,比如 class。
.babelrc
{
"presets": [
[
"@babel/preset-env",
{
"targets": "> 0.25%, not dead"
}
]
]
}
.browserslistrc
> 0.25%
not dead
对于新特性,@babel/preset-env 也是能转换的。但是需要通过 useBuiltIns 这个参数选项实现,值需要设置为 usage,这样的话,只会转换我们使用到的新语法和新特性,能够有效减小编译后的包体积,并且还要设置 corejs: { version: 3, proposals } 选项,因为转换新特性需要用到 polyfill,而 corejs 就是一个 polyfill 包。如果不显示指定 corejs 的版本的话,默认使用的是 version 2 ,而 version 2 已经停更,诸如一些更新的特性的 polyfill 只会更行与 version 3 里,如 Array.prototype.flat()。
// .babelrc
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "usage",
"corejs": {
"version": 3,
"proposals": true // 使用尚在提议阶段特性的 polyfill
}
}
]
]
虽然 @babel/env 可以帮我们做新语法和新特性的按需转换,但是依然存在 2 个问题:
- 从
corejs引入的polyfill是全局范围的,不是模块作用域返回的,可能存在污染全局变量的风险; - 对于某些新语法,如
class,会在编译后的文件中注入很多helper函数声明,而不是从某个地方require进来的函数引用,从而增大编译后的包体积;
runtime
runtime 是 babel7 提出来的概念,旨在解决如上提出的性能问题的。
实践一下 @babel/plugin-transform-runtime 插件配合 @babel/preset-env 使用
npm install --save-dev @babel/plugin-transform-runtime
// @babel/runtime 是要安装到生产依赖的,因为新特性的编译需要从这个包里引用 polyfill
// 它就是一个封装了 corejs 的 polyfill 包
npm install --save @babel/runtime
// .babelrc
{
"presets": [
"@babel/env"
],
"plugins": [
[
"@babel/plugin-transform-runtime",{
"corejs": 3
}
]
],
}
编译后,可以明显看到,
- 引入的
polyfill不再是全局范围内的了,而是模块作用域范围内的; - 并且不再是往编译文件中直接注入
helper函数了,而是通过引用的方式,
既解决了全局变量污染的问题,又减小了编译后包的体积
Fiber 实现时间切片的原理
React15 架构缺点
React16之前的版本比对更新虚拟DOM的过程是采用循环递归方式来实现的,这种比对方式有一个问题,就是一旦任务开始进行就无法中断,如果应用中数组数量庞大,主线程被长期占用,直到整颗虚拟DOM树比对更新完成之后主线程才被释放,主线程才能执行其他任务,这就会导致一些用户交互或动画等任务无法立即得到执行,页面就会产生卡顿,非常的影响用户体验。
主要原因就是递归无法中断,执行重的任务耗时较长,javascript又是单线程的,无法同时执行其他任务,导致任务延迟页面卡顿用户体验差。

Fiber架构
界面通过 vdom 描述,但是不是直接手写 vdom,而是 jsx 编译产生的 render function 之后以后生成的。这样就可以加上 state、props 和一些动态逻辑,动态产生 vdom。
vdom生成之后不再是直接渲染,而是先转成 fiber,这个vdom转fiber的过程叫做reconcile。
fiber 是一个链表结构,可以打断,这样就可以通过 requestIdleCallback 来空闲调度 reconcile,这样不断的循环,直到处理完所有的 vdom 转 fiber 的 reconcile,就开始 commit,也就是更新到 dom。
reconcile 的过程会提前创建好 dom,还会标记出增删改,那么 commit 阶段就很快了。
从之前递归渲染时做
diff来确定增删改以及创建dom,提前到了可打断的reconcile阶段,让commit变得非常快,这就是fiber架构的目的和意义。
并发&调度(Concurrency & Scheduler)
Concurrency并发: 有能力优先处理更高优事务,同时对正在执行的中途任务可暂存,待高优完成后,再去执行。Scheduler协调调度: 暂存未执行任务,等待时机成熟后,再去安排执行剩下未完成任务。
考虑到可中断渲染,并可重回构造。React自行实现了一套体系叫做 React fiber 架构。
React Fiber核心: 自行实现 虚拟栈帧。

schedule 就是通过空闲调度每个
fiber节点的reconcile(vdom转fiber),全部reconcile完了就执行commit。
Fiber的数据结构有三层信息: (采用链表结构)
- 实例属性
- 该Fiber的基本信息,例如组件类型等。
- 构建属性
- 构建属性 (
return、child、sibling)
- 构建属性 (
- 工作属性
- 数据的变更会导致UI层的变更
- 为了减少对
DOM的直接操作,通过Reconcile进行diff查找,并将需要变更节点,打上标签,变更路径保留在effectList里 - 待变更内容要有
Scheduler优先级处理 - 涉及到
diff等查找操作,是需要有个高效手段来处理前后变化,即双缓存机制。
链表结构即可支持随时随时中断的诉求
Scheduler 运行核心点
- 有个任务队列
queue,该队列存放可中断的任务。 workLoop对队列里取第一个任务currentTask,进入循环开始执行。- 当该任务没有时间 或 需要中断 (渲染任务 或 其他高优任务插入等),则让出主线程。
requestAnimationFrame计算一帧的空余时间;- 使用
new MessageChannel ()执行宏任务;
devServer进行跨域处理
module.exports = {
devServer: {
/* 运行代码的目录 */
contentBase: resolve(__dirname, "dist"),
/* 监视 contentBase 目录下的所有文件,一旦文件发生变化就会 reload (重载+刷新浏览器)*/
watchContentBase: true,
/* 监视文件时 配合 watchContentBase */
watchOptions: {
/* 忽略掉的文件(不参与监视的文件) */
ignored: /node_modules/
},
/* 启动gzip压缩 */
compress: true,
/* 运行服务时自动打开服务器 */
open: true,
/* 启动HMR热更新 */
hot: true,
/* 启动的端口号 */
port: 5000,
/* 启动的IP地址或域名 */
host: "localhost",
/* 关闭服务器启动日志 */
clientLogLevel: "none",
/* 除了一些启动的基本信息,其他内容都不要打印 */
quiet: true,
/* 如果出错不要全屏提示 */
overlay: false,
/* 服务器代理 --> 解决开发环境跨域问题 */
proxy: {
/* 一旦devServer(port:5000)服务器接收到 ^/api/xxx 的请求,就会把请求转发到另外一个服务器(target)上 */
"/api": {
target: "http://localhost:3000",
/* 路径重写(代理时发送到target的请求去掉/api前缀) */
pathRewrite: {
"^/api": ""
}
}
}
},
}
React 实现原理
React-Hook为什么不能放到条件语句中
每一次渲染都是完全独立的。

每次渲染具有独立的状态值(每次渲染都是完全独立的)。也就是说,每个函数中的 state 变量只是一个简单的常量,每次渲染时从钩子中获取到的常量,并没有附着数据绑定之类的神奇魔法。
这也就是老生常谈的 Capture Value 特性。可以看下面这段经典的计数器代码
function Counter() {
const [count, setCount] = useState(0);
function handleAlertClick() {
setTimeout(() => {
alert('You clicked on: ' + count);
}, 3000);
}
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
<button onClick={handleAlertClick}>
Show alert
</button>
</div>
);
}
按如下步骤操作:
- 1)点击
Click me按钮,把数字增加到 3; - 2)点击
Show alert按钮; - 3)在
setTimeout触发之前点击Click me,把数字增加到 5。
结果是 Alert 显示 3!
来简单解释一下:
- 每次渲染相互独立,因此每次渲染时组件中的状态、事件处理函数等等都是独立的,或者说只属于所在的那一次渲染
- 我们在
count为 3 的时候触发了handleAlertClick函数,这个函数所记住的count也为 3 - 三秒种后,刚才函数的
setTimeout结束,输出当时记住的结果:3
深入useState本质
当组件初次渲染(挂载)时

- 在初次渲染时,我们通过
useState定义了多个状态; - 每调用一次
useState,都会在组件之外生成一条 Hook 记录,同时包括状态值(用useState给定的初始值初始化)和修改状态的Setter函数; - 多次调用
useState生成的Hook记录形成了一条链表; - 触发
onClick回调函数,调用setS2函数修改s2的状态,不仅修改了Hook记录中的状态值,还即将触发重渲染。
组件重渲染时

在初次渲染结束之后、重渲染之前,Hook 记录链表依然存在。当我们逐个调用 useState 的时候,useState 便返回了 Hook 链表中存储的状态,以及修改状态的 Setter。
深入useEffect本质

注意其中一些细节:
useState和useEffect在每次调用时都被添加到Hook链表中;useEffect还会额外地在一个队列中添加一个等待执行的Effect函数;- 在渲染完成后,依次调用
Effect队列中的每一个Effect函数。
React 官方文档 Rules of Hooks 中强调过一点:
Only call hooks at the top level. 只在最顶层使用 Hook。
具体地说,不要在循环、嵌套、条件语句中使用 Hook——
因为这些动态的语句很有可能会导致每次执行组件函数时调用 Hook 的顺序不能完全一致,导致 Hook 链表记录的数据失效。
自定义Hook实现原理
组件初次渲染

在 App 组件中调用了 useCustomHook 钩子。可以看到,即便我们切换到了自定义 Hook 中,Hook 链表的生成依旧没有改变。
组件重新渲染
即便代码的执行进入到自定义 Hook 中,依然可以从 Hook 链表中读取到相应的数据,这个”配对“的过程总能成功。
而Rules of Hook。它规定只有在两个地方能够使用 React Hook:
- React 函数组件
- 自定义 Hook
第一点毋庸置疑,第二点通过刚才的两个动画你也可以轻松的得出一个结论:
自定义 Hook 本质上只是把调用内置 Hook 的过程封装成一个个可以复用的函数,并不影响 Hook 链表的生成和读取。
useCallback
依赖数组在判断元素是否发生改变时使用了
Object.is进行比较,因此当deps中某一元素为非原始类型时(例如函数、对象等),每次渲染都会发生改变,从而每次都会触发Effect,失去了deps本身的意义。
Effect 无限循环
来看一下这段”永不停止“的计数器:
function EndlessCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
setTimeout(() => setCount(count + 1), 1000);
});
return (
<div className="App">
<h1>{count}</h1>
</div>
);
}
如果你去运行这段代码,会发现数字永远在增长。我们来通过一段动画来演示一下这个”无限循环“到底是怎么回事:
组件陷入了:渲染 => 触发 Effect => 修改状态 => 触发重渲染的无限循环
关于记忆化缓存(Memoization)
Memoization,一般称为记忆化缓存(或者“记忆”),它背后的思想很简单:假如我们有一个计算量很大的纯函数(给定相同的输入,一定会得到相同的输出),那么我们在第一次遇到特定输入的时候,把它的输出结果“记”(缓存)下来,那么下次碰到同样的输出,只需要从缓存里面拿出来直接返回就可以了,省去了计算的过程!
记忆化缓存(Memoization)的两个使用场景:
- 通过缓存计算结果,节省费时的计算
- 保证相同输入下返回值的引用相等
useCallback使用方法和原理解析
为了解决函数在多次渲染中的引用相等(Referential Equality)问题,React 引入了一个重要的 Hook—— useCallback。官方文档介绍的使用方法如下:
const memoizedCallback = useCallback(callback, deps);
第一个参数 callback 就是需要记忆的函数,第二个参数是deps 参数,同样也是一个依赖数组。在 Memoization 的上下文中,这个 deps 的作用相当于缓存中的键(Key),如果键没有改变,那么就直接返回缓存中的函数,并且确保是引用相同的函数。
组件初次渲染(deps 为空数组的情况)

调用 useCallback 也是追加到 Hook 链表上,不过这里着重强调了这个函数 f1 所指向的内存位置,从而明确告诉我们:这个 f1 始终是指向同一个函数。然后返回的 onClick 则是指向 Hook 中存储的 f1。
组件重新渲染

重渲染的时候,再次调用 useCallback 同样返回给我们 f1 函数,并且这个函数还是指向同一块内存,从而使得 onClick 函数和上次渲染时真正做到了引用相等。
useCallback 和 useMemo 的关系
之前我们说Memoization 的两大场景
- 通过缓存计算结果,节省费时的计算
- 保证相同输入下返回值的引用相等
而useCallback 和uesMemo从Memoization角度来说
useCallback主要是为了解决**函数的”引用相等“**问题,useMemo则是一个”全能型选手“,能够同时胜任引用相等和节约计算的任务。
实际上,
useMemo的功能是useCallback的超集。
与 useCallback 只能缓存函数相比,useMemo 可以缓存任何类型的值(当然也包括函数)。useMemo 的使用方法如下:
const memoizedValue = useMemo(() =>
computeExpensiveValue(a, b),
[a, b]
);
其中第一个参数是一个函数,这个函数返回值的返回值(也就是上面 computeExpensiveValue 的结果)将返回给 memoizedValue 。
因此以下两个钩子的使用是完全等价的:
useCallback(fn, deps);
useMemo(() => fn, deps);
useReducer
使用 useState 的时候遇到过一个问题:通过 Setter 修改状态的时候,怎么读取上一个状态值,并在此基础上修改呢?如果你看文档足够细致,应该会注意到 useState 有一个{函数式更新|Functional Update}的用法。
function Counter({initialCount}) {
const [count, setCount] = useState(initialCount);
return (
<>
Count: {count}
<button onClick={() => setCount(initialCount)}>Reset</button>
<button onClick={() => setCount(prevCount => prevCount - 1)}>-</button>
<button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
</>
);
}
传入 setCount 的是一个函数,它的参数是之前的状态,返回的是新的状态。熟悉 Redux 的朋友马上就指出来了:这其实就是一个 Reducer 函数。
useState底层实现原理
在 React 的源码中,useState 的实现使用了 useReducer。在 React 源码中有这么一个关键的函数 basicStateReducer
function basicStateReducer(state, action) {
return typeof action === 'function' ? action(state) : action;
}
于是,当我们通过 setCount(prevCount => prevCount + 1) 改变状态时,传入的 action 就是一个 Reducer 函数,然后调用该函数并传入当前的 state,得到更新后的状态。而我们之前通过传入具体的值修改状态时(例如 setCount(5)),由于不是函数,所以直接取传入的值作为更新后的状态。
传入的 action 是一个具体的值 (setCount(xx))

当传入 Setter 的是一个 Reducer 函数的时候:(setCount(c =>c+1))

后记
分享是一种态度。
全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。

转载自:https://juejin.cn/post/7204307381689532474
