2023 年最新最全的 React 面試題

2023-03-15 12:01:08

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 <div onClick={() => setCount(count + 1)}>{count}</div>;
};

React18 有哪些新變化?

React 的更新都是漸進式的更新,在 React18 中啟用的新特性,其實在 v17 中(甚至更早)就埋下了。

  1. 並行渲染機制:根據使用者的裝置效能和網速對渲染過程進行適當的調整, 保證 React 應用在長時間的渲染過程中依舊保持可互動性,避免頁面出現卡頓或無響應的情況,從而提升使用者體驗。
  2. 新的建立方式:現在是要先通過createRoot()建立一個 root 節點,然後該 root 節點來呼叫render()方法;
  3. 自動批次處理優化:批次處理: React 將多個狀態更新分組到一個重新渲染中以獲得更好的效能。(將多次 setstate 事件合併);在 v18 之前只在事件處理常式中實現了批次處理,在 v18 中所有更新都將自動批次處理,包括 promise 鏈、setTimeout 等非同步程式碼以及原生事件處理常式;
  4. startTransition:主動降低優先順序。比如「搜尋引擎的關鍵詞聯想」,使用者在輸入框中的輸入希望是實時的,而聯想詞彙可以稍稍延遲一會兒。我們可以用 startTransition 來降低聯想詞彙更新的優先順序;
  5. 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 (
    <>
      <input value={value} onInput={event => setValue(event.target.value)} />
      <input type="checkbox" checked={checked} onChange={event => setChecked(event.target.checked)} />
    </>
  );
};

上面程式碼中,輸入框和 checkbox 的變化,均是經過了 React 來操作的,在資料變更時,React 是能夠知道的。

高階元件(HOC)?

高階元件?

高階元件通過包裹(wrapped)被傳入的 React 元件,經過一系列處理,最終返回一個相對增強(enhanced)的 React 元件,供其他元件呼叫。

作用:

  1. 複用邏輯:高階元件更像是一個加工 react 元件的工廠,批次對原有元件進行加工,包裝處理。我們可以根據業務需求客製化化專屬的 HOC,這樣可以解決複用邏輯。
  2. 強化 props:這個是 HOC 最常用的用法之一,高階元件返回的元件,可以劫持上一層傳過來的 props,然後混入新的 props,來增強元件的功能。代表作 react-router 中的 withRouter。
  3. 賦能元件:HOC 有一項獨特的特性,就是可以給被 HOC 包裹的業務元件,提供一些拓展功能,比如說額外的生命週期,額外的事件,但是這種 HOC,可能需要和業務元件緊密結合。典型案例 react-keepalive-router 中的 keepaliveLifeCycle 就是通過 HOC 方式,給業務元件增加了額外的生命週期。
  4. 控制渲染:劫持渲染是 hoc 一個特性,在 wrapComponent 包裝元件中,可以對原來的元件,進行條件渲染,節流渲染,懶載入等功能,後面會詳細講解,典型代表做 react-redux 中 connect 和 dva 中 dynamic 元件懶載入。

參考:react 進階」一文吃透 React 高階元件(HOC)

React 中為什麼要使用 Hook?

官方網站有介紹該原因:使用 Hook 的動機

這裡我們簡要的提煉下:

  1. 在元件之間複用狀態邏輯很難:在類元件中,可能需要 render props 和 高階元件等方式,但會形成「巢狀地域」;而使用 Hook,則可以從元件中提取狀態邏輯,是的這些邏輯可以單獨測試並複用;
  2. 複雜元件變得難以理解:在類元件中,每個生命週期常常包含一些不相關的邏輯。如不同的執行邏輯,都要放在componentDidMount中執行和獲取資料,而之後需在 componentWillUnmount 中清除;但在函陣列件中,不同的邏輯可以放在不同的 Hook 中執行,互不干擾;
  3. 難以理解的 class:類元件中,充斥著各種對 this 的使用,如 this.onClick.bind(this)this.statethis.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 (
    <Suspense fallback={<div>Loading...</div>}>
      <OtherComponent />
    </Suspense>
  );
}

屬性fallback表示在載入元件前,渲染的內容。

如何實現一個定時器的 hook

若在定時器內直接使用 React 的程式碼,可能會收到意想不到的結果。如我們想實現一個每 1 秒加 1 的定時器:

const App = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(timer);
  }, []);

  return <div className="App">{count}</div>;
};

可以看到,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 <div className="App">{count}</div>;
};

當然還有別的方式也可以實現 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 過程

  1. React 只對比當前層級的節點,不跨層級進行比較;
  2. 根據不同的節點型別,如函陣列件節點、類元件節點、普通 fiber 節點、陣列節點等,進入不同的處理常式;
  3. 前後兩個 fiber 節點進行對比,若 type 不一樣,直接捨棄掉舊的 fiber 節點,建立新的 fiber 節點;若 key 不一樣,則需要根據情況判斷,若是單個元素,則直接捨棄掉,建立新的 fiber 節點;若是數位型的元素,則查詢是否移動了位置,若沒找到,則建立新的節點;若 key 和 type 都一樣,則接著往下遞迴;
  4. 若是單個 fiber 節點,則直接返回;若是並列多個元素的 fiber 節點,這裡會形成單向連結串列,然後返回頭指標(該連結串列最前面的那個 fiber 節點);

通過上面的 diff 對比過程,我們也可以看到,當元件產生比較大的變更時,React 需要做更多的動作,來構建出新的 fiber 樹,因此我們在開發過程中,若從效能優化的角度考慮,尤其要注意的是:

  1. 節點不要產生大量的越級操作:因為 React 是隻進行同層節點的對比,若同一個位置的子節點產生了比較大的變動,則只會捨棄掉之前的 fiber 節點,從而執行建立新 fiber 節點的操作;React 並不會把之前的 fiber 節點移動到另一個位置;相應的,之前的 jsx 節點移動到另一個位置後,在進行前後對比後,同樣會執行更多的建立操作;
  2. 不修改節點的 key 和 type 型別,如使用亂數做為列表的 key,或從 div 標籤改成 p 標籤等操作,在 diff 對比過程中,都會直接捨棄掉之前的 fiber 節點及所有的子節點(即使子節點沒有變動),然後重新建立出新的 fiber 節點;

參考:React18 原始碼解析之 reconcileChildren 生成 fiber 的過程

基於 React 框架的特點,可以有哪些優化措施?

  1. 使用 React.lazy 和 Suspense 將頁面設定為懶載入,避免 js 檔案過大;
  2. 使用 SSR 同構直出技術,提高首屏的渲染速度;
  3. 使用 useCallback 和 useMemo 快取函數或變數;使用 React.memo 快取元件;
  4. 儘量調整樣式或 className 的變動,減少 jsx 元素上的變動,儘量使用與元素相關的欄位作為 key,可以減少 diff 的時間(React 會盡量複用之前的節點,若 jsx 元素髮生變動,就需要重新建立節點);
  5. 對於不需要產生頁面變動的資料,可以放到 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 遵循的三個原則是什麼?

  1. 單一事實來源:整個應用的狀態儲存在單個 store 中的物件/狀態樹裡。單一狀態樹可以更容易地跟蹤隨時間的變化,並偵錯或檢查應用程式。
  2. 狀態是唯讀的:改變狀態的唯一方法是去觸發一個動作。動作是描述變化的普通 JS 物件。就像 state 是資料的最小表示一樣,該操作是對資料更改的最小表示。
  3. 使用純函數進行更改:為了指定狀態樹如何通過操作進行轉換,你需要純函數。純函數是那些返回值僅取決於其引數值的函數。

你對「單一事實來源」有什麼理解?

Redux 使用 「Store」 將程式的整個狀態儲存在同一個地方。因此所有元件的狀態都儲存在 Store 中,並且它們從 Store 本身接收更新。單一狀態樹可以更容易地跟蹤隨時間的變化,並偵錯或檢查程式。

Redux 有哪些優點?

Redux 的優點如下:

  • 結果的可預測性 - 由於總是存在一個真實來源,即 store ,因此不存在如何將當前狀態與動作和應用的其他部分同步的問題。
  • 可維護性 - 程式碼變得更容易維護,具有可預測的結果和嚴格的結構。
  • 伺服器端渲染 - 你只需將伺服器上建立的 store 傳到使用者端即可。這對初始渲染非常有用,並且可以優化應用效能,從而提供更好的使用者體驗。
  • 開發人員工具 - 從操作到狀態更改,開發人員可以實時跟蹤應用中發生的所有事情。
  • 社群和生態系統 - Redux 背後有一個巨大的社群,這使得它更加迷人。一個由才華橫溢的人組成的大型社群為庫的改進做出了貢獻,並開發了各種應用。
  • 易於測試 - Redux 的程式碼主要是小巧、純粹和獨立的功能。這使程式碼可測試且獨立。
  • 組織 - Redux 準確地說明了程式碼的組織方式,這使得程式碼在團隊使用時更加一致和簡單。

4. 總結

React 涉及到的相關知識點非常多,我也會經常更新的。

歡迎關注我的公眾號:「前端小茶館」。