2023面試真題之框架篇
highlight: a11y-dark theme: Chinese-red
閲讀使人充實,會談使人敏捷,寫作使人精確
大家好,我是柒八九。
今天,我們繼續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
提供了三種節點操作,分別為:
1. INSERT_MARKUP
(插入)
- 新的 component
類型不在老集合裏, 即是全新的節點,需要對新節點執行插入操作
2. MOVE_EXISTING
(移動)
- 在老集合有新 component
類型,且 element
是可更新的類型,這種情況下 prevChild
=nextChild
,就需要做移動操作,可以複用以前的 DOM 節點。
3. 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
調用流程:
1. 調用this.setState(newState)
2. 將新狀態newState
存入pending隊列
3. 判斷是否處於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
階段。
1. 組件的 render
函數執行時稱為 render
(本次更新需要做哪些變更),純 js 計算;
2. 而將 render
的結果渲染到頁面的過程稱為 commit
(變更到真實的宿主環境中,在瀏覽器中就是操作DOM
)。
在 Sync
模式下,render
階段是一次性執行完成;而在 Concurrent
模式下 render
階段可以被拆解,每個時間片內執行一部分,直到執行完畢。由於 commit
階段有 DOM
的更新,不可能讓 DOM
更新到一半中斷,必須一次性執行完畢。
React 併發新特性
併發渲染機制
concurrent rendering
的目的:根據用户的設備性能和網速對渲染過程進行適當的調整, 保證React
應用在長時間的渲染過程中依舊保持可交互性,避免頁面出現卡頓或無響應的情況,從而提升用户體驗。
- 新 root API
- 通過
createRoot
Api 手動創建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
的 生命週期主要有兩個比較大的版本,分別是
1. v16.0
前
2. v16.4
兩個版本
的生命週期。
v16.0前
總共分為四大階段: 1. {初始化| Intialization} 2. {掛載| Mounting} 3. {更新| Update} 4. {卸載| Unmounting}
Intialization(初始化)
在初始化階段,會用到 constructor()
這個構造函數,如:
javascript
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.props
nextState
:當前組件的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
:組件更新前的props
prevState
:組件更新前的state
- 可以操作組件更新的DOM
Unmounting(卸載)(1個)
componentWillUnmount
:組件被卸載前調用
可以在這裏執行一些清理工作,比如清除組件中使用的定時器,清除componentDidMount
中手動創建的DOM元素等,以避免引起內存泄漏
React v16.4
與 v16.0
的生命週期相比
- 新增了 -- (兩個getXX
)
1. getDerivedStateFromProps
2. getSnapshotBeforeUpdate
- 取消了 -- (三個componmentWillXX
)
1. componentWillMount
、
2. componentWillReceiveProps
、
3. componentWillUpdate
getDerivedStateFromProps
getDerivedStateFromProps(prevProps, prevState)
:組件創建和更新時調用的方法
prevProps
:組件更新前的props
prevState
:組件更新前的state
在
React v16.3
中,在創建和更新時,只能是由父組件引發才會調用這個函數,在React v16.4
改為無論是Mounting
還是Updating
,全部都會調用。
是一個靜態函數,也就是這個函數不能通過this
訪問到class
的屬性。
如果
props
傳入的內容不需要影響到你的state
,那麼就需要返回一個null
,這個返回值是必須的,所以儘量將其寫到函數的末尾。
在組件創建時和更新時的render方法之前調用,它應該
- 返回一個對象來更新狀態
- 或者返回null
來不更新任何內容
getSnapshotBeforeUpdate
getSnapshotBeforeUpdate(prevProps,prevState)
:Updating
時的函數,在render之後調用
prevProps
:組件更新前的props
prevState
:組件更新前的state
可以讀取,但無法使用DOM的時候,在組件可以在可能更改之前從DOM
捕獲一些信息(例如滾動位置)
返回的任何值都將作為參數傳遞給
componentDidUpdate()
Note
在17.0
的版本,官方徹底廢除
- componentWillMount
、
- componentWillReceiveProps
、
- componentWillUpdate
Hook的相關知識點
react-hooks
是React 16.8
的產物,給函數式組件賦上了生命週期。
React v16.8中的hooks
useState
useState
:定義變量,可以理解為他是類組件中的this.state
使用:
jsx
const [state, setState] = useState(initialState);
state
:目的是提供給 UI,作為渲染視圖的數據源setState
:改變state
的函數,可以理解為this.setState
initialState
:初始默認值
useState
有點類似於PureComponent
,會進行一個比較淺的比較,如果是對象的時候直接傳入並不會更新。
解決傳入對象的問題
使用 useImmer 替代 useState。
immer.js
這個庫,是基於 proxy
攔截 getter
和 setter
的能力,讓我們可以很方便的通過修改對象本身,創建新的對象。
React
通過 Object.is
函數比較 props
,也就是説對於引用一致的對象,react是不會刷新視圖的,這也是為什麼我們不能直接修改調用 useState
得到的 state 來更新視圖,而是要通過 setState
刷新視圖,通常,為了方便,我們會使用 es6
的 spread
運算符構造新的對象(淺拷貝)。
對於嵌套層級多的對象,使用
spread
構造新的對象寫起來心智負擔很大,也不易於維護
常規的處理方式是對數據進行deepClone
,但是這種處理方式針對結構簡單的數據來講還算OK,但是遇到大數據的話,就不夠優雅了。
所以,我們可以直接使用 useImmer
這個語法糖來進一步簡化調用方式
```javascript 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
javascript
const [state, dispatch] = useReducer(reducer, initialArg, init);
- state
:更新後的state
值
- dispatch
:可以理解為和useState
的setState
一樣的效果
- reducer
:可以理解為redux
的reducer
- initialArg
:初始值
- init
:惰性初始化
useMemo
useMemo
:與memo
的理念上差不多,都是判斷是否滿足當前的限定條件來決定是否執行callback
函數,而useMemo
的第二個參數是一個數組,通過這個數組來判定是否執行回調函數
當一個父組件中調用了一個子組件的時候,父組件的
state
發生變化,會導致父組件更新,而子組件雖然沒有發生改變,但也會進行更新。
只要父組件的狀態更新,無論有沒有對子組件進行操作,子組件都會進行更新,useMemo
就是為了防止這點而出現的。
useCallback
useCallback
與useMemo
極其類似,唯一不同的是
- useMemo
返回的是函數運行的結果
- 而useCallback
返回的是函數
- 這個函數是父組件傳遞子組件的一個函數,防止做無關的刷新,
- 其次,這個子組件必須配合React.memo
,否則不但不會提升性能,還有可能降低性能
存在的問題及解決方案
一個很常見的誤區是為了心理上的性能提升把函數通通使用 useCallback
包裹,在大多數情況下,javascript
創建一個函數的開銷是很小的,哪怕每次渲染都重新創建,也不會有太大的性能損耗,真正的性能損耗在於,很多時候 callback 函數是組件 props 的一部分,因為每次渲染的時候都會重新創建 callback 導致函數引用不同,所以觸發了組件的重渲染。然而一旦函數使用 useCallback
包裹,則要面對聲明依賴項的問題,對於一個內部捕獲了很多 state 的函數,寫依賴項非常容易寫錯,因此引發 bug。
所以,在大多數場景下,我們應該只在需要維持函數引用的情況下使用 useCallback。
```jsx const [userText, setUserText] = useState(""); const handleUserKeyPress = useCallback(event => { // do something here }, []);
useEffect(() => { window.addEventListener("keydown", handleUserKeyPress); return () => { window.removeEventListener("keydown", handleUserKeyPress); }; }, [handleUserKeyPress]);
return (
在組件卸載的時候移除
event listener callback
,因此需要保持event handler
的引用,所以這裏需要使用useCallback
來保持引用不變。
使用 useCallback
,我們又會面臨聲明依賴項的問題,這裏我們可以使用 ahook
中的 useMemoizedFn 的方式,既能保持引用,又不用聲明依賴項。
javascript
const [state, setState] = useState('');
// func 地址永遠不會變化
const func = useMemoizedFn(() => {
console.log(state);
});
useRef
useRef
: 可以獲取當前元素的所有屬性,並且返回一個可變的ref對象
,並且這個對象只有current屬性
,可設置initialValue
- 通過
useRef
獲取對應的React元素
的屬性值 - 緩存數據
useImperativeHandle
useImperativeHandle
:可以讓你在使用 ref
時自定義暴露給父組件的實例值
javascript
useImperativeHandle(ref, createHandle, [deps])
- ref
:useRef
所創建的ref
- createHandle
:處理的函數,返回值作為暴露給父組件的 ref
對象。
- deps
:依賴項,依賴項更改形成新的 ref
對象。
useImperativeHandle
和forwardRef
配合使用
jsx
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
和時間切片等併發渲染功能兼容
javascript
const state = useSyncExternalStore(
subscribe,
getSnapshot[, getServerSnapshot]
)
- subscribe
: 訂閲函數,用於註冊一個回調函數,當存儲值發生更改時被調用。此外, useSyncExternalStore
會通過帶有記憶性的 getSnapshot
來判別數據是否發生變化,如果發生變化,那麼會強制更新數據。
- getSnapshot
: 返回當前存儲值的函數。必須返回緩存的值。如果 getSnapshot
連續多次調用,則必須返回相同的確切值,除非中間有存儲值更新。
- getServerSnapshot
:返回服務端(hydration模式下)渲染期間使用的存儲值的函數
useTransition
useTransition
: - 返回一個狀態值表示過渡任務的等待狀態, - 以及一個啟動該過渡任務的函數。
過渡任務
在一些場景中,如:輸入框
、tab切換
、按鈕
等,這些任務需要視圖上立刻做出響應,這些任務可以稱之為立即更新的任務
但有的時候,更新任務並不是那麼緊急,或者來説要去請求數據等,導致新的狀態不能立馬更新,需要用一個loading...
的等待狀態,這類任務就是過度任務
javascript
const [isPending, startTransition] = useTransition();
- isPending
:過渡狀態的標誌,為true
時是等待狀態
- startTransition
:可以將裏面的任務變成過渡任務
useDeferredValue
useDeferredValue
:接受一個值,並返回該值的新副本,該副本將推遲到更緊急地更新之後。
如果當前渲染是一個緊急更新的結果,比如用户輸入,React
將返回之前的值,然後在緊急渲染完成後渲染新的值。
也就是説useDeferredValue
可以讓狀態滯後派生。
javascript
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
。
jsx
const TextInput = React.forwardRef((props,ref) => {
return <input ref={ref}></input>
})
用TextInputWithFocusButton
調用它
jsx
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就是幫助我們做這件事的。
```jsx const TextInput = forwardRef((props,ref) => { const inputRef = useRef(); // 關鍵代碼 useImperativeHandle(ref, () => ({ focus: () => { inputRef.current.focus(); } })); return })
function TextInputWithFocusButton() {
// 關鍵代碼
const inputEl = useRef(null);
const onButtonClick = () => {
// 關鍵代碼,current
指向已掛載到 DOM 上的文本輸入元素
inputEl.current.focus();
};
return (
<>
// 關鍵代碼
);
}
``
也可以使用
current.focus()來做
input`聚焦。
這裏要注意的是,子組件
TextInput
中的useRef
對象,只是用來獲取input
元素的,大家不要和父組件的useRef
混淆了。
useCallbck vs useMemo的區別
useMemo
jsx
const memoizedValue = useMemo(
() => computeExpensiveValue(a, b),
[a, b]
);
useMemo
:與memo
的理念上差不多,都是判斷是否滿足當前的限定條件來決定是否執行callback
函數,而useMemo
的第二個參數是一個數組,通過這個數組來判定是否執行回調函數
當一個父組件中調用了一個子組件的時候,父組件的
state
發生變化,會導致父組件更新,而子組件雖然沒有發生改變,但也會進行更新。
只要父組件的狀態更新,無論有沒有對子組件進行操作,子組件都會進行更新,useMemo
就是為了防止這點而出現的。
useCallback
useCallback 可以理解為 useMemo 的語法糖
diff 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
和state
React.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
就返回緩衝。
jsx
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子組件
,實現了我們對組件的緩衝控制。
jsx
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)
頁面託管服務器只需要對頁面的訪問請求響應一個如下的空頁面
```html
``
頁面中留出一個<span style="font-weight:800;color:red;font-size:18px">用於填充渲染內容的視圖節點</span> (
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()
javascript
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
之下靜態內容與動態內容是分流的,
1. 邊緣 CDN 節點可以將靜態頁面內容先響應給用户
2. 然後再自己發起動態內容請求,得到核心服務器響應之後再返回給用户
是在大型網絡架構下非常極致的一種優化,但這也就依賴更龐大的技術基建體系了。
ISR (Incremental Site Rendering)
增量式網站渲染,就是對待頁面內容小刀切,有更細的差異化渲染粒度,能漸進、分層地進行渲染。
常見的選擇是:
- 對於重要頁面如首屏、訪問量較大的直接落地頁,進行預渲染並添加緩存,保證最佳的訪問性能;
- 對於次要頁面,則確保有兜底內容可以即時 fallback
,再將其實時數據的渲染留到 CSR 層次完成,同時觸發異步緩存更新。
對於“異步緩存更新”,則需要提到一個常見的內容緩存策略:Stale While Revalidate
,CDN 對於數據請求始終首先響應緩存內容,如果這份內容已經過期,則在響應之後再觸發異步更新——這也是對於次要元素或頁面的緩存處理方式。
WebComponents
Web Components
是一套不同的技術,允許您創建可重用的定製元素並且在您的 web 應用中使用它們
三要素
Custom elements
(自定義元素): 一組JavaScript
API,允許您定義custom elements
及其行為,然後可以在您的用户界面中按照需要使用它們。- 通過
class A extends HTMLElement {}
定義組件, - 通過
window.customElements.define('a-b', A)
掛載已定義組件。
- 通過
Shadow DOM
(影子 DOM ):一組JavaScript
API,用於將封裝的“影子” 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
字符串配飾attribute
JSON.stringify
配置指定屬性- 在組件
attributeChangedCallback
中判斷對應屬性,然後用JSON.parse()
獲取
-
配置DOM的
property
屬性 xx.dataSource = [{ name: 'xxx', age: 19 }]
- 但是,自定義組件中沒有辦法監聽到這個屬性的變化
- 如果想實現,複雜的結構,不是通過配置,而是在定義組件時候,就確定
狀態的雙向綁定
```
// js
(function () {
const template = document.createElement('template')
template.innerHTML = `
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` 事件,並且把輸入的參數回傳。
樣式設置
直接給自定義標籤添加樣式
```html
```
定義元素內部子元素設置樣式
分為兩種場景: 1. 在主 DOM 使用 JS 2. 在 Custom Elements 構造函數中使用 JS
在主 DOM 使用 JS 給 Shadow DOM 增加 style 標籤:
```html
```
在 Custom Elements 構造函數中使用 JS 增加 style 標籤:
```html
```
引入 CSS 文件
使用 JS 創建 link 標籤,然後引入 CSS 文件給自定義元素內部的子元素設置樣式 ```html
``` 樣式文件
css
.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
類。
js
export class LitButton extends LitElement { /* ... */ }
customElements.define('lit-button', LitButton);
渲染
組件具有 render
方法,該方法被調用以渲染組件的內容。
```javascript 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 依賴了不同版本的 [email protected]
和 [email protected]
,node_modules
結構如下:
node_modules
├── [email protected]
│ └── node_modules
│ └── [email protected]
└── [email protected]
└── node_modules
└── [email protected]
如果 D 也依賴 [email protected],會生成如下的嵌套結構:
node_modules
├── [email protected]
│ └── node_modules
│ └── [email protected]
├── [email protected]
│ └── node_modules
│ └── [email protected]
└── [email protected]
└── node_modules
└── [email protected]
可以看到同版本的 B 分別被 A 和 D 安裝了兩次。
依賴地獄 Dependency Hell
在真實場景下,依賴增多,宂餘的包也變多,node_modules
最終會堪比黑洞,很快就能把磁盤佔滿。而且依賴嵌套的深度也會十分可怕,這個就是依賴地獄。
扁平的 node_modules 結構
為了將嵌套的依賴儘量打平,避免過深的依賴樹和包宂餘,npm v3
將子依賴提升(hoist),採用扁平的 node_modules
結構,子依賴會儘量平鋪安裝在主依賴項所在的目錄中。
node_modules
├── [email protected]
├── [email protected]
└── [email protected]
└── node_modules
└── [email protected]
可以看到 A
的子依賴的 [email protected]
不再放在 A 的 node_modules
下了,而是與 A 同層級。
而 C
依賴的 [email protected]
因為版本號原因還是嵌套在 C 的 node_modules
下。
這樣不會造成大量包的重複安裝,依賴的層級也不會太深,解決了依賴地獄問題,但也形成了新的問題。
幽靈依賴 Phantom dependencies
幽靈依賴是指在
package.json
中未定義的依賴,但項目中依然可以正確地被引用到。
比如上方的示例其實我們只安裝了 A 和 C:
```json { "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
假設繼續再安裝依賴 [email protected]
的 D
模塊和依賴 @B2.0
的 E
模塊,此時:
A
和 D
依賴 [email protected]
C
和 E
依賴 [email protected]
以下是提升 [email protected]
的 node_modules
結構:
node_modules
├── [email protected]
├── [email protected]
├── [email protected]
├── [email protected]
│ └── node_modules
│ └── [email protected]
└── [email protected]
└── node_modules
└── [email protected]
可以看到 [email protected]
會被安裝兩次,實際上無論提升 [email protected]
還是 [email protected]
,都會存在重複版本的 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;
}
1. root
的作用
- 就是指定一個根目錄。默認的是html目錄
2. try_files
- 關鍵點1:按指定的file
順序查找存在的文件,並使用第一個找到的文件進行請求處理
- 關鍵點2:查找路徑是按照給定的root
或alias
為根路徑來查找的
- 關鍵點3:如果給出的file
都沒有匹配到,則重新請求最後一個參數給定的uri
,就是新的location
匹配
webpack 優化
時間方向(8個)
- 開發環境 -
EvalSourceMapDevToolPlugin
排除第三方模塊devtool:false
EvalSourceMapDevToolPlugin
,通過傳入module: true
和column:false
,達到和預設eval-cheap-module-source-map
一樣的質量
- 縮小
loader
的搜索範圍:test、include、exclude
Module.noParse
noParse: /jquery|lodash/
,
TypeScript
編譯優化Resolve.modules
指定查找模塊的目錄範圍Resolve.alias
Resolve.extensions
指定查找模塊的文件類型範圍HappyPack
資源大小(9個)
- 按需引入類庫模塊 (工具類庫)
- 使用
babel-plugin-import
對其處理
- 使用
- 使用
externals
優化cdn
靜態資源 - CSS抽離+剔除無用樣式 -
MiniCssExtractPlugin
+PurgeCSS
- CSS壓縮 -
CssMinimizerWebpackPlugin
TreeSharking
- CSS 方向 -
glob-all
purify-css
purifycss-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
```javascript function createStore( reducer, preloadedState, enhancer ){ let state;
// 用於存放被 subscribe 訂閲的函數(監聽函數) let listeners = [];
// getState 是一個很簡單的函數 const getState = () => state;
return { dispatch, getState, subscribe, replaceReducer } } ```
dispatch
javascript
function dispatch(action) {
// 通過 reducer 返回新的 state
// 這個 reducer 就是 createStore 函數的第一個參數
state = reducer(state, action);
// 每一次狀態更新後,都需要調用 listeners 數組中的每一個監聽函數
listeners.forEach(listener => listener());
return action; // 返回 action
}
subscribe
javascript
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
```javascript 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自動引入到這個文件中MiniCssExtractPlugin
IgnorePlugin
:用於忽略第三方包指定目錄,讓指定目錄不被打包進去terser-webpack-plugin
:壓縮js代碼SplitChunksPlugin
:Code-Splitting
實現的底層就是通過Split-Chunks-Plugin實現的,其作用就是代碼分割。
Babel
Babel
是一個 JavaScript
編譯器!
Babel
的作用就是將源碼轉換為目標代碼
Babel的作用
主要用於將採用 ECMAScript 2015+
語法編寫的代碼轉換為 es5
語法,讓開發者無視用户瀏覽器的差異性,並且能夠用新的 JS 語法及特性進行開發。除此之外,Babel
能夠轉換 JSX
語法,並且能夠支持 TypeScript
轉換為 JavaScript
。
總結一下:
Babel
的作用如下 1. 語法轉換 2. 通過Polyfill
方式在目標環境中添加缺失的特性 3. 源碼轉換
原理
Babel
的運行原理可以通過以下這張圖來概括。整體來看,可以分為三個過程,分別是:
1. 解析,
1. 詞法解析
2. 語法解析
2. 轉換,
3. 生成。
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
不做任何處理,需要藉助插件來完成語法的解析,轉換,輸出。
插件的配置形式常見有兩種,分別是 1. 字符串格式 2. 數組格式,並且可以傳遞參數
如果插件名稱為 @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
;
json
// .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
使用
bash
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
的數據結構有三層信息: (採用鏈表結構)
1. 實例屬性
- 該Fiber的基本信息,例如組件類型等。
2. 構建屬性
- 構建屬性 (return
、child
、sibling
)
3. 工作屬性
- 數據的變更會導致UI層的變更
- 為了減少對DOM
的直接操作,通過Reconcile
進行diff
查找,並將需要變更節點,打上標籤,變更路徑保留在effectList
裏
- 待變更內容要有Scheduler
優先級處理
- 涉及到diff
等查找操作,是需要有個高效手段來處理前後變化,即雙緩存機制。
鏈表結構即可支持隨時隨時中斷的訴求
Scheduler 運行核心點
- 有個任務隊列
queue
,該隊列存放可中斷的任務。 workLoop
對隊列裏取第一個任務currentTask
,進入循環開始執行。- 當該任務沒有時間 或 需要中斷 (渲染任務 或 其他高優任務插入等),則讓出主線程。
requestAnimationFrame
計算一幀的空餘時間;- 使用
new MessageChannel ()
執行宏任務;
devServer進行跨域處理
```javascript 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
特性。可以看下面這段經典的計數器代碼
```jsx function Counter() { const [count, setCount] = useState(0);
function handleAlertClick() { setTimeout(() => { alert('You clicked on: ' + count); }, 3000); }
return (
You clicked {count} times
``
按如下步驟操作:
- 1)點擊
Click me按鈕,把數字增加到 3;
- 2)點擊
Show alert按鈕;
- 3)在
setTimeout觸發之前點擊
Click me`,把數字增加到 5。
結果是 Alert
顯示 3!
來簡單解釋一下:
- 每次渲染相互獨立,因此每次渲染時組件中的狀態、事件處理函數等等都是獨立的,或者説只屬於所在的那一次渲染
- 我們在
count
為 3 的時候觸發了handleAlertClick
函數,這個函數所記住的count
也為 3 - 三秒種後,剛才函數的
setTimeout
結束,輸出當時記住的結果:3
深入useState本質
當組件初次渲染(掛載)時
1. 在初次渲染時,我們通過
useState
定義了多個狀態;
2. 每調用一次 useState
,都會在組件之外生成一條 Hook 記錄,同時包括狀態值(用 useState
給定的初始值初始化)和修改狀態的 Setter
函數;
3. 多次調用 useState
生成的 Hook
記錄形成了一條鏈表;
4. 觸發 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 無限循環
來看一下這段”永不停止“的計數器:
```jsx function EndlessCounter() { const [count, setCount] = useState(0);
useEffect(() => { setTimeout(() => setCount(count + 1), 1000); });
return (
{count}
關於記憶化緩存(Memoization)
Memoization
,一般稱為記憶化緩存(或者“記憶”),它背後的思想很簡單:假如我們有一個計算量很大的純函數(給定相同的輸入,一定會得到相同的輸出),那麼我們在第一次遇到特定輸入的時候,把它的輸出結果“記”(緩存)下來,那麼下次碰到同樣的輸出,只需要從緩存裏面拿出來直接返回就可以了,省去了計算的過程!
記憶化緩存(Memoization)的兩個使用場景:
- 通過緩存計算結果,節省費時的計算
- 保證相同輸入下返回值的引用相等
useCallback使用方法和原理解析
為了解決函數在多次渲染中的引用相等(Referential Equality)問題,React
引入了一個重要的 Hook
—— useCallback
。官方文檔介紹的使用方法如下:
jsx
const memoizedCallback = useCallback(callback, deps);
第一個參數 callback
就是需要記憶的函數,第二個參數是deps
參數,同樣也是一個依賴數組。在 Memoization
的上下文中,這個 deps
的作用相當於緩存中的鍵(Key),如果鍵沒有改變,那麼就直接返回緩存中的函數,並且確保是引用相同的函數。
組件初次渲染(deps 為空數組的情況)
調用 useCallback
也是追加到 Hook
鏈表上,不過這裏着重強調了這個函數 f1
所指向的內存位置,從而明確告訴我們:這個 f1 始終是指向同一個函數。然後返回的 onClick 則是指向 Hook 中存儲的 f1。
組件重新渲染
重渲染的時候,再次調用 useCallback
同樣返回給我們 f1
函數,並且這個函數還是指向同一塊內存,從而使得 onClick 函數和上次渲染時真正做到了引用相等。
useCallback 和 useMemo 的關係
之前我們説Memoization
的兩大場景
1. 通過緩存計算結果,節省費時的計算
2. 保證相同輸入下返回值的引用相等
而useCallback
和uesMemo
從Memoization
角度來説
- useCallback
主要是為了解決函數的”引用相等“問題,
- useMemo
則是一個”全能型選手“,能夠同時勝任引用相等和節約計算的任務。
實際上,
useMemo
的功能是useCallback
的超集。
與 useCallback
只能緩存函數相比,useMemo
可以緩存任何類型的值(當然也包括函數)。useMemo
的使用方法如下:
jsx
const memoizedValue = useMemo(() =>
computeExpensiveValue(a, b),
[a, b]
);
其中第一個參數是一個函數,這個函數返回值的返回值(也就是上面 computeExpensiveValue 的結果)將返回給 memoizedValue
。
因此以下兩個鈎子的使用是完全等價的:
jsx
useCallback(fn, deps);
useMemo(() => fn, deps);
useReducer
使用 useState
的時候遇到過一個問題:通過 Setter
修改狀態的時候,怎麼讀取上一個狀態值,並在此基礎上修改呢?如果你看文檔足夠細緻,應該會注意到 useState
有一個{函數式更新|Functional Update}的用法。
jsx
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
javascript
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))
後記
分享是一種態度。
全文完,既然看到這裏了,如果覺得不錯,隨手點個贊和“在看”吧。