當咱們在用Hooks時,咱們到底在用什麼?

開篇有獎

若是你最近一年出去面過試,極可能面臨這些問題:html

  • react 16到底作了哪些更新;
  • react hooks用過麼,知道其原理麼;

第一個問題若是你提到了Fiber reconciler,fiber,鏈表,新的什麼週期,可能在面試官眼裏這僅僅是一個及格的回答。如下是我整理的,自我感受還良好的回答:前端

分三步:react

  • react做爲一個ui庫,將前端編程由傳統的命令式編程轉變爲聲明式編程,即所謂的數據驅動視圖,但若是簡單粗暴的操做,好比講生成的html直接採用innerHtml替換,會帶來重繪重排之類的性能問題。爲了儘可能提升性能,React團隊引入了虛擬dom,即採用js對象來描述dom樹,經過對比先後兩次的虛擬對象,來找到最小的dom操做(vdom diff),以此提升性能。
  • 上面提到的vDom diff,在react 16以前,這個過程咱們稱之爲stack reconciler,它是一個遞歸的過程,在樹很深的時候,單次diff時間過長會形成JS線程持續被佔用,用戶交互響應遲滯,頁面渲染會出現明顯的卡頓,這在現代前端是一個致命的問題。因此爲了解決這種問題,react 團隊對整個架構進行了調整,引入了fiber架構,將之前的stack reconciler替換爲fiber reconciler。採用增量式渲染。引入了任務優先級(expiration)requestIdleCallback的循環調度算法,簡單來講就是將之前的一根筋diff更新,首先拆分紅兩個階段:reconciliationcommit;第一個reconciliation階段是可打斷的,被拆分紅一個個的小任務(fiber),在每一偵的渲染空閒期作小任務diff。而後是commit階段,這個階段是不拆分且不能打斷的,將diff節點的effectTag一口氣更新到頁面上。
  • 因爲reconciliation是能夠被打斷的,且存在任務優先級的問題,因此會致使commit前的一些生命週期函數屢次被執行, 如componentWillMount、componentWillReceiveProps 和 componetWillUpdate,但react官方已申明這些問題,並將其標記爲unsafe,在React17中將會移除
  • 因爲每次喚起更新是從根節點(RootFiber)開始,爲了更好的節點複用與性能優化。在react中始終存workInprogressTree(future vdom) 與 oldTree(current vdom)兩個鏈表,兩個鏈表相互引用。這無形中又解決了另外一個問題,當workInprogressTree生成報錯時,這時也不會致使頁面渲染崩潰,而只是更新失敗,頁面仍然還在。

以上就是我上半年面試本身不斷總結迭代出的答案,但願能對你有所啓發。git

接着來回答第二個問題,hooks本質是什麼?github

hooks 爲何出現

當咱們在談論React這個UI庫時,最早想到的是,數據驅動視圖,簡單來說就是下面這個公式:面試

view = fn(state)

咱們開發的整個應用,都是不少組件組合而成,這些組件是純粹,不具有擴展的。由於React不能像普通類同樣直接繼承,從而達到功能擴展的目的。算法

出現前的邏輯複用

在用react實現業務時,咱們複用一些組件邏輯去擴展另外一個組件,最多見好比Connect,Form.create, Modal。這類組件一般是一個容器,容器內部封裝了一些通用的功能(非視覺的佔多數),容器裏面的內容由被包裝的組件本身定製,從而達到必定程度的邏輯複用。編程

在hooks 出現以前,解決這類需求最經常使用的就兩種模式:HOC高階組件Render Propsredux

高階組件相似於JS中的高階函數,即輸入一個函數,返回一個新的函數, 好比React-Redux中的Connect:性能優化

class Home extends React.Component {
  // UI
}

export default Connect()(Home);

高階組件因爲每次都會返回一個新的組件,對於react來講,這是不利於diff和狀態複用的,因此高階組件的包裝不能在render 方法中進行,而只能像上面那樣在組件聲明時包裹,這樣也就不利於動態傳參。而Render Props模式的出現就完美解決了這個問題,其原理就是將要包裹的組件做爲props屬性傳入,而後容器組件調用這個屬性,並向其傳參, 最多見的用props.children來作這個屬性。舉個🌰:

class Home extends React.Component {
  // UI
}

<Route path = "/home" render= {(props) => <Home {...props} } />

更多關於render 與 Hoc,能夠參見之前寫的一片弱文:React進階,寫中後臺也能寫出花

已存方案的問題

嵌套地獄

上面提到的高階組件和RenderProps, 看似解決了邏輯複用的問題,但面對複雜需求時,即一個組件須要使用多個複用包裹時,兩種方案都會讓咱們的代碼陷入常見的嵌套地獄, 好比:

class Home extends React.Component {
  // UI
}

export default Connect()(Form.create()(Home));

除了嵌套地獄的寫法讓人困惑,但更致命的深度會直接影響react組件更新時的diff性能。

函數式編程的普及

Hooks 出現前的函數式組件只是以模板函數存在,而前面兩種方案,某種程度都是依賴類組件來完成。而提到了類,就不得不想到下面這些痛點:

  • JS中的this是一個神仙級的存在, 是不少入門開發趟不過的坑;
  • 生命週期的複雜性,不少時候咱們須要在多個生命週期同時編寫同一個邏輯
  • 寫法臃腫,什麼constructor,super,render

因此React團隊迴歸view = fn(state)的初心,但願函數式組件也能擁有狀態管理的能力,讓邏輯複用變得更簡單,更純粹。

架構的更新

爲何在React 16前,函數式組件不能擁有狀態管理?其本質是由於16之前只有類組件在更新時存在實例,而16之後Fiber 架構的出現,讓每個節點都擁有對應的實例,也就擁有了保存狀態的能力,下面會詳講。

hooks 的本質

有可能,你聽到過Hooks的本質就是閉包。可是,若是滿分100的話,這個說法最多隻能得60分。

哪滿分答案是什麼呢?閉包 + 兩級鏈表

下面就來一一分解, 下面都以useState來舉例剖析。

閉包

JS 中閉包是難點,也是必考點,歸納的講就是:

閉包是指有權訪問另外一個 函數做用域中變量或方法的函數,建立閉包的方式就是在一個函數內建立閉包函數,經過閉包函數訪問這個函數的局部變量, 利用閉包能夠突破做用鏈域的特性,將函數 內部的變量和方法傳遞到外部。
export default function Hooks() {
  const [count, setCount] = useState(0);
  const [age, setAge] = useState(18);

  const self = useRef(0);

  const onClick = useCallback(() => {
    setAge(19);
    setAge(20);
    setAge(21);
  }, []);

  console.log('self', self.current);
  return (
    <div>
      <h2>年齡: {age} <a onClick={onClick}>增長</a></h2>
      <h3>輪次: {count} <a onClick={() => setCount(count => count + 1)}>增長</a></h3>
    </div>
  );
}

以上面的示例來說,閉包就是setAge這個函數,何以見得呢,看組件掛載階段hook執行的源碼:

// packages/react-reconciler/src/ReactFiberHooks.js
function mountReducer(reducer, initialArg, init) {
  const hook = mountWorkInProgressHook();
  let initialState;
  if (init !== undefined) {
    initialState = init(initialArg);
  } else {
    initialState = initialArg;
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue = (hook.queue = {
    last: null,
    dispatch: null,
    lastRenderedReducer: reducer,
    lastRenderedState: initialState,
  });
  // 重點
  const dispatch = (queue.dispatch = (dispatchAction.bind(
    null,
    currentlyRenderingFiber,
    queue,
  )));
  return [hook.memoizedState, dispatch];
}

因此這個函數就是mountReducer,而產生的閉包就是dispatch函數(對應上面的setAge),被閉包引用的變量就是currentlyRenderingFiberqueue

  • currentlyRenderingFiber: 其實就是workInProgressTree, 即更新時鏈表當前正在遍歷的fiber節點(源碼註釋:The work-in-progress fiber. I've named it differently to distinguish it from the work-in-progress hook);
  • queue: 指向hook.queue,保存當前hook操做相關的reducer 和 狀態的對象,其來源於mountWorkInProgressHook這個函數,下面重點講;

這個閉包將 fiber節點與action, action 與 state很好的串聯起來了,舉上面的例子就是:

  • 當點擊增長執行setAge, 執行後,新的state更新任務就儲存在fiber節點的hook.queue上,並觸發更新;
  • 當節點更新時,會遍歷queue上的state任務鏈表,計算最終的state,並進行渲染;

ok,到這,閉包就講完了。

第一個鏈表:hooks

在ReactFiberHooks文件開頭聲明currentHook變量的源碼有這樣一段註釋。

/*
Hooks are stored as a linked list on the fiber's memoizedState field.  
hooks 以鏈表的形式存儲在fiber節點的memoizedState屬性上
The current hook list is the list that belongs to the current fiber.
當前的hook鏈表就是當前正在遍歷的fiber節點上的
The work-in-progress hook list is a new list that will be added to the work-in-progress fiber.
work-in-progress hook 就是即將被添加到正在遍歷fiber節點的hooks新鏈表
*/
let currentHook: Hook | null = null;
let nextCurrentHook: Hook | null = null;

從上面的源碼註釋能夠看出hooks鏈表與fiber鏈表是極其類似的;也得知hooks 鏈表是保存在fiber節點的memoizedState屬性的, 而賦值是在renderWithHooks函數具體實現的;

export function renderWithHooks(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  props: any,
  refOrContext: any,
  nextRenderExpirationTime: ExpirationTime,
): any {
  renderExpirationTime = nextRenderExpirationTime;
  currentlyRenderingFiber = workInProgress;
  // 獲取當前節點的hooks 鏈表;
  nextCurrentHook = current !== null ? current.memoizedState : null;
  // ...省略一萬行
}

有可能代碼貼了這麼多,你還沒反應過來這個hooks 鏈表具體指什麼?

其實就是指一個組件包含的hooks, 好比上面示例中的:

const [count, setCount] = useState(0);
const [age, setAge] = useState(18);
const self = useRef(0);
const onClick = useCallback(() => {
  setAge(19);
  setAge(20);
  setAge(21);
}, []);

造成的鏈表就是下面這樣的:

20200717112830

因此在下一次更新時,再次執行hook,就會去獲取當前運行節點的hooks鏈表;

const hook = updateWorkInProgressHook();
// updateWorkInProgressHook 就是一個純鏈表的操做:指向下一個 hook節點

到這 hooks 鏈表是什麼,應該就明白了;這時你可能會更明白,爲何hooks不能在循環,判斷語句中調用,而只能在函數最外層使用,由於掛載或則更新時,這個隊列須要是一致的,才能保證hooks的結果正確。

第二個鏈表:state

其實state 鏈表不是hooks獨有的,類操做的setState也存在,正是因爲這個鏈表存在,因此有一個經(sa)典(bi)React 面試題:

setState爲何默認是異步,何時是同步?

結合實例來看,當點擊增長會執行三次setAge

const onClick = useCallback(() => {
  setAge(19);
  setAge(20);
  setAge(21);
}, []);

第一次執行完dispatch後,會造成一個狀態待執行任務鏈表:
20200720111316

若是仔細觀察,會發現這個鏈表仍是一個(會在updateReducer後斷開), 這一塊設計至關有意思,我如今也還沒搞明白爲何須要環,值得細品,而創建這個鏈表的邏輯就在dispatchAction函數中。

function dispatchAction(fiber, queue, action) {
  // 只貼了相關代碼
  const update = {
    expirationTime,
    suspenseConfig,
    action,
    eagerReducer: null,
    eagerState: null,
    next: null,
  };
  // Append the update to the end of the list.
  const last = queue.last;
  if (last === null) {
    // This is the first update. Create a circular list.
    update.next = update;
  } else {
    const first = last.next;
    if (first !== null) {
      // Still circular.
      update.next = first;
    }
    last.next = update;
  }
  queue.last = update;

  // 觸發更新
  scheduleWork(fiber, expirationTime);
}

上面已經說了,執行setAge 只是造成了狀態待執行任務鏈表,真正獲得最終狀態,實際上是在下一次更新(獲取狀態)時,即:

// 讀取最新age
const [age, setAge] = useState(18);

而獲取最新狀態的相關代碼邏輯存在於updateReducer中:

function updateReducer(reducer, initialArg,init?) {
  const hook = updateWorkInProgressHook();
  const queue = hook.queue;
  // ...隱藏一百行
  // 找出第一個未被執行的任務;
  let first;
  // baseUpdate 只有在updateReducer執行一次後纔會有值
  if (baseUpdate !== null) {
    // 在baseUpdate有值後,會有一次解環的操做;
    if (last !== null) {
      last.next = null;
    }
    first = baseUpdate.next;
  } else {
    first = last !== null ? last.next : null;
  }

  if (first !== null) {
    let newState = baseState;
    let newBaseState = null;
    let newBaseUpdate = null;
    let prevUpdate = baseUpdate;
    let update = first;
    let didSkip = false;
    // do while 遍歷待執行任務的狀態鏈表
    do {
      const updateExpirationTime = update.expirationTime;
      if (updateExpirationTime < renderExpirationTime) {
        // 優先級不足,先標記,後面再更新
      } else {
        markRenderEventTimeAndConfig(
          updateExpirationTime,
          update.suspenseConfig,
        );

        // Process this update.
        if (update.eagerReducer === reducer) {
          // 簡單的說就是狀態已經計算過,那就直接用
          newState = update.eagerState;
        } else {
          const action = update.action;
          newState = reducer(newState, action);
        }
      }
      prevUpdate = update;
      update = update.next;
      // 終止條件是指針爲空 或 環已遍歷完
    } while (update !== null && update !== first);  
    // ...省略100行
    return [newState, dispatch];
  }
}

最後來看,狀態更新的邏輯彷佛是最繞的。但若是看過setState,這一塊可能就比較容易。至此,第二個鏈表state就理清楚了。

讀到這裏,你就應該明白hooks 究竟是怎麼實現的:

閉包加兩級鏈表

雖然我這裏只站在useState這個hooks作了剖析,但其餘hooks的實現基本相似。

另外分享一下在我眼中的hooks,與類組件到底究竟是什麼聯繫:

  • useState: 狀態的存儲及更新,狀態更新會觸發組件更新,和類的state相似,只不過setState更新時是採用Object.assign(oldstate, newstate); 而useState的set是直接替代式的
  • useEffect: 相似於之前的componentDidMount 和 componentDidUpdate生命週期鉤子(即render 執行後,再執行Effect, 因此當組件與子組件都有Effect時,子組件的Effect先執行), Update須要deps依賴來喚起;
  • useRefs: 用法相似於之前直接掛在類的this上,像this.selfCount 這種,用於變量的臨時存儲,而又不至於受函數更新,而被重定義;與useState的區別就是,refs的更新不會致使Rerender
  • useMemo: 用法同之前的componentWillReceiveProps與getDerivedStateFromProps中,根據state和props計算出一個新的屬性值:計算屬性
  • useCallback: 相似於類組件中constructor的bind,但比bind更強大,避免回調函數每次render形成回調函數重複聲明,進而形成沒必要要的diff;但須要注意deps,否則會掉進閉包的坑
  • useReducer: 和redux中的Reducer相像,和useState同樣,執行後能夠喚起Rerender

第一次寫源碼解析,出發點主要兩點:

  • 最近半年本身在react確實下了一些功夫,有一個輸出也是爲了本身之後更好的回憶;
  • 網上太多的人用一個閉包來歸納hooks,我以爲這是對技術的褻瀆(我的意見);

文章中如有不詳或不對之處,歡迎斧正;

推薦閱讀: 源碼解析React Hook構建過程:沒有設計就是最好的設計

首發連接:當咱們在用Hooks時,咱們到底在用什麼?

相關文章
相關標籤/搜索