天天看點

人人都是前端架構師:我來帶你閱讀 React18 源碼!

作者:程式猿最幽默
人人都是前端架構師:我來帶你閱讀 React18 源碼!
人人都是前端架構師:我來帶你閱讀 React18 源碼!

本合集文章,授權轉載,侵權必究。

來源 : 代碼與野獸

讀源碼的目的

提到閱讀源碼,很多人會很畏懼,也有很多人會很向往。

當我們能夠熟練使用别人提供給我們的工具時,要想更進一步,難免要去研究工具背後的事情。

這也是每一個資深技術人都應該做的事情。

可是,我們不能忽視閱讀源碼的難度,因為編寫這些工具的人通常都是業内頂級的工程師,他們的技術水準非常高。

所幸,你遇到了這篇文章。

我會帶你由淺入深的閱讀 React18 的源碼。

首先 React 的源碼非常龐大,我們不要詳盡地去看它的所有東西。我們主要專注于 React 的設計,看看他都有哪些最佳實踐和模式,并且把這些東西融入到自己的代碼庫中。

Monorepo

React 的代碼是 monorepo 模式,它将多個不同的項目放到了一個存儲庫中。是以根目錄中沒有大家熟悉的 src 目錄,取而代之的是 packages 目錄,packages 是前端 monorepo 約定俗成的檔案夾命名。

React 存儲庫中包含了 30 多個包。大家熟悉的有 react、react-dom、react-server 和 react-devtools。

monorepo 的優勢是可以将多個獨立的部分組成一個大型項目,并讓本地的配置更加容易,而且這些獨立部分之間的代碼可重用性很好。

但是 monorepo 并不是一個完美的解決方案,在進行子包的拆分時,我們需要投入更多的設計和思考。

通常來說,我們在本地環境和生産環境是不一樣的,是以增加了部署的複雜性。同時也增加愛了整個代碼庫的複雜性。

但是一旦你把 monorepo 的工作流程弄清楚之後,上面這些問題就沒有那麼明顯了。

從哪兒開始?

其實大多數人在閱讀陌生的代碼時,都會感覺到非常困惑。如果沒有人給你梳理流程,介紹子產品,這種困惑感會更強。

是以我們要從某一個位置作為開始。

閱讀任何一個庫的源碼,我們都可以從它的第一個 API 開始。

那麼 React 的第一個 API 是什麼呢?

一定是下面這段代碼:

import ReactDOM from 'react-dom'

const root = ReactDOM.createRoot(container)
root.render(element)           

這段代碼是 React 18 中将元件渲染到 DOM 上面。

在 SPA 項目中通常隻會運作一次,我們就從這裡開始。

你可能發現了,react-dom 并不是核心包,它是和浏覽器綁定一起使用的。react 的核心包通過某種方式,可以讓它在不同的環境中使用。

createRoot

createRoot 函數包裝了一個内部函數,并做了一些簡單的驗證邏輯。

它沒有直接提供實作,而是分離了驗證邏輯并且把真正的實作邏輯放到了一個單獨的檔案中。

function createRoot(
container: Element | Document | DocumentFragment,
 options?: CreateRootOptions
): RootType {
  if (__DEV__) {
    if (!Internals.usingClientEntryPoint && !__UMD__) {
      console.error(
        'You are importing createRoot from "react-dom" which is not supported. ' +
        'You should instead import it from "react-dom/client".'
      )
    }
  }
  return createRootImpl(container, options)
}           

包裝的内部函數名後面有 Impl 字尾,表示它是一個負責具體實作的私有函數。React 中有大量這種命名的習慣。

但我不認為這個命名是一個好的習慣,如果采用 createRootInstance 或者 cerateRootEntity 這種更具體的名稱可能會更好了解。

進入這個單獨的檔案。

export function createRoot(
  container: Element | Document | DocumentFragment,
  options?: CreateRootOptions
): RootType {
  if (!isValidContainer(container)) {
    throw new Error(
      'createRoot(...): Target container is not a DOM element.'
    )
  }

  // ...

  const root = createContainer(
    container,
    ConcurrentRoot,
    null,
    isStrictMode,
    concurrentUpdatesByDefaultOverride,
    identifierPrefix,
    onRecoverableError,
    transitionCallbacks
  )

  // ...

  return new ReactDOMRoot(root)
}           

我們發現這個函數實際上和外層的函數是同名的,隻是外層使用了别名導出來避免命名沖突。我一般不會将函數名重名,除非我要實作多态。

createRoot 函數做的第一件事就是做出驗證,如果不符合預期就退出。這種做法我用了很多年,是避免多層嵌套和複雜 if 語句的正常操作。

這個函數大概有 80 行,我移除了細節部分,現在我們可以專注于核心的部分。

它做的事情就是将容器和 React 的協調器建立連接配接。它使用了工廠函數 createContainer,同時也使用 new 來建立 ReactDOMRoot。這似乎有些奇怪。

再來看 ReactDOMRoot 這個函數。

function ReactDOMRoot(internalRoot: FiberRoot) {
  this._internalRoot = internalRoot
}           

它非常簡單,隻有一行代碼而已。它會将 internalRoot 挂載到 this 上面。

再回到 createContainer 函數。

ReactDOMHydrationRoot.prototype.render =
  ReactDOMRoot.prototype.render = function (
    children: ReactNodeList
  ): void {
    const root = this._internalRoot

    if (root === null) {
      throw new Error('Cannot update an unmounted root.')
    }

    if (__DEV__) {
      // ...
    }

    updateContainer(children, root, null, null)
  }           

它使用了多重指派,将一個方法挂載到了 ReactDOMHydrationRoot 和 ReactDOMRoot 的 prototype 的 render 方法中。

使用了 prototype 的好處是,我們可以通過 instanceof 來檢查某個對象是否為某個類型。但是我不怎麼會使用 prototype,我更喜歡工廠函數和閉包。

依賴原型的另一個好處是性能。使用閉包會造成一定的開銷,但是原型并不會,所有被添加到原型上的方法隻會被建立一次,但是閉包不會這樣。

連接配接到協調器

建立完 ReactDOMRoot,接下來就是 updateContainer 方法,它是協調器的一部分。

export const updateContainer = enableNewReconciler
  ? updateContainer_new
  : updateContainer_old           

使用條件導出的方式是很少見的。updateContainer 的邏輯是通過一個叫做 enableNewReconciler 的标志位來區分到底用哪一套邏輯。這種方式通常用于漸進式部署或者 AB 測試。

不同的方法也被放到了不同的檔案夾中,分别是 .new.js 和 .old.js,它通過字尾名的不同來區分。React 中有大量這種命名方式的檔案。

我們來看其中一個。

export function updateContainer(
  element: ReactNodeList,
  container: OpaqueRoot,
  parentComponent: ?React$Component<any, any>,
  callback: ?Function
): Lane {
  // ...

  const eventTime = requestEventTime()
  const lane = requestUpdateLane(current)

  if (enableSchedulingProfiler) {
    markRenderScheduled(lane)
  }

  // ...

  const update = createUpdate(eventTime, lane)
  // Caution: React DevTools currently depends on this property
  // being called "element".
  update.payload = { element }

  // ...

  const root = enqueueUpdate(current, update, lane)

  // ...

  return lane
}           

它的作用就是将下一個元件樹通過 enqueueUpdate 更新到隊列中。

值得注意的是,update 下面的那兩行注釋。這對一些從代碼上看很不明顯的操作進行解釋,是一種非常好的例子。

接下來我們應該去看協調器的代碼了,但是在這之前,我建議先去看看元件的内部結構。

什麼是元件?

ReactDOMRoot.prototype.render 函數需要一個元件作為參數。

元件是一個對象,但是 React 不會讓使用者用對象的形式表示 UI,那樣的話,體驗上來說實在是太糟糕了。是以我們通常會使用 JSX 來編寫 React 代碼。

然後在項目真正運作之前,通過轉譯器将 JSX 轉換為建立對象的函數調用。

每個 JSX 元素最終都會被轉換為 React.createElement 方法,當然我們也可以直接使用這個函數來建立 UI,隻是在文法上非常抽象。

export function createElement(type, config, children) {
  let propName

  // Reserved names are extracted
  const props = {}

  let key = null
  let ref = null
  let self = null
  let source = null

  if (config != null) {
    // ...

    for (propName in config) {
      if (
        hasOwnProperty.call(config, propName) &&
        !RESERVED_PROPS.hasOwnProperty(propName)
      ) {
        props[propName] = config[propName]
      }
    }
  }

  const childrenLength = arguments.length - 2
  if (childrenLength === 1) {
    props.children = children
  } else if (childrenLength > 1) {
    // ...
  }

  // ...

  return ReactElement(
    type,
    key,
    ref,
    self,
    source,
    ReactCurrentOwner.current,
    props
  )
}           

其實在 React 17 版本之後,JSX 不會再自動轉為 React.createElement 了。因為在轉換這個函數之前,都必須導入 React 才行。

如果你對 JSX 的工作原理不夠了解的話,可能不能直覺的感受這個變化是什麼。

這個更新可以允許建構工具使用沒有附加到 React 對象不同的功能。

export function jsx(type, config, maybeKey) {
  let propName

  // Reserved names are extracted
  const props = {}

  let key = null
  let ref = null

  // ...

  for (propName in config) {
    if (
      hasOwnProperty.call(config, propName) &&
      !RESERVED_PROPS.hasOwnProperty(propName)
    ) {
      props[propName] = config[propName]
    }
  }

  // ...

  return ReactElement(
    type,
    key,
    ref,
    undefined,
    undefined,
    ReactCurrentOwner.current,
    props
  )
}           

上面的 jsx 函數和 createElement 函數實作很像。

最終它們都委托一個 ReactElement 工廠函數來建立真正的元件對象。

這裡其實就出現了一個重要的問題,什麼時候應該提取一個通用函數?就像這個 ReactElement 函數一樣。

通常來說,抽取通用函數,可以在視覺上消除重複的代碼。

但是我們在一開始并不知道應該抽取哪些代碼作為通用函數,往往都是在不斷複制之後才知道應該複制哪些代碼。

重複的代碼看起來很煩人,但是管理它們并不難。相反,錯誤的抽象就可能會制造複雜性。現在回想起來,這個問題曾經在我工作生涯的早期多次犯過,隻是當時沒有意識不到這個問題。

我們再來看 ReactElement 這個函數。

const ReactElement = function (
  type,
  key,
  ref,
  self,
  source,
  owner,
  props
) {
  const element = {
    $typeof: REACT_ELEMENT_TYPE,
    type: type,
    key: key,
    ref: ref,
    props: props,
    _owner: owner,
  }

  if (__DEV__) {
    element._store = {}

    Object.defineProperty(element._store, 'validated', {
      configurable: false,
      enumerable: false,
      writable: true,
      value: false,
    })

    Object.defineProperty(element, '_self', {
      configurable: false,
      enumerable: false,
      writable: false,
      value: self,
    })

    Object.defineProperty(element, '_source', {
      configurable: false,
      enumerable: false,
      writable: false,
      value: source,
    })

    // ...
  }

  return element
}           

這個方法沒有做什麼特殊的事情,它隻是為元件對象的屬性配置設定了正确的值。

不過它有一個需要注意的地方,就是在開發環境中,使用了 defineProperty 方法定義了 N 個屬性。這個方法可以讓我們更加精細地控制每個屬性的行為。

設定 writable 為 false,意味着以後不可以給它重新指派或删除它。

設定 enumerable 為 false,意味着它不可以在 for...in 文法和 Object.keys 方法中被周遊到。

設定 configurable 為 false,意味着設定了這些選項之後不可以再修改。

這個方法在開發應用中很少會用到,因為對應用來說,不需要對對象的屬性進行這種控制。但是在開發一個庫時,可能會經常用到,因為它可以控制庫對外開放的對象的内部結構。防止它們出現在錯誤的地方。

$typeof 被設定為 REACT_ELEMENT_TYPE,這是一個 Symbol 類型的變量。因為 ReactElement 是一個工廠函數,是以無法使用 instanceof 來檢測它,是以用 Symbol 是一個很好的方法。

渲染器和協調器的互動

現在我們知道了什麼是元件,也知道了它們是怎麼被建立的。現在我們更進一步,來看看元素是怎麼樣被渲染到螢幕上的。

我們需要閱讀協調器的文檔,它的源碼在 react-reconciler 子包中。

渲染器有一個 diffing 算法,它可以找出元件的變化并且通知渲染器重新生成元件。渲染器定義了某些方法來處理元件的渲染,但是它不負責調用它們,因為它不知道什麼時候調用它們。

這些方法由協調器調用。

react-reconciler 中的 README.md 詳細介紹了它是如何與渲染器進行互動的。

本質上,每種渲染器都需要遵循目标環境的約定,它必須提供給協調器所要依賴的很多方法和屬性。這意味着隻要你的渲染器擁有這些方法和屬性,就可以自己建立渲染器将 UI 呈現在你想要的任何位置,而不僅僅是浏覽器或者原生應用,比如說嵌入式系統中。

const Reconciler = require('react-reconciler')

const HostConfig = {
  createInstance(type, props) {
    // e.g. DOM renderer returns a DOM node
  },
  // ...
  supportsMutation: true, // it works by mutating nodes
  appendChild(parent, child) {
    // e.g. DOM renderer would call .appendChild() here
  },
  // ...
}

const MyRenderer = Reconciler(HostConfig)

const RendererPublicAPI = {
  render(element, container, callback) {
    // Call MyRenderer.updateContainer() to schedule changes on the roots.
    // See ReactDOM, React Native, or React ART for practical examples.
  },
}

module.exports = RendererPublicAPI           

我們需要實作的接口是 HostConfig,它這樣命名是因為渲染器正在将 React 連接配接到主機環境,當我看到 Config 這個詞時,我能想象到一些環境變量相關的東西。

畢竟命名是計算機科學中最難的兩大問題之一。

渲染器方法

渲染器具有很高的複雜度,我不會去講解每個功能。相反我會專注于它最重要的功能:如何解決将内容渲染到螢幕上的難題。

需要注意,react-reconciler API 不保證穩定性。它會比渲染器或者 core 更加頻繁地調整。

createInstance 是将每個元件可視化的方法。

export function createInstance(
  type: string,
  props: Props,
  rootContainerInstance: Container,
  hostContext: HostContext,
  internalInstanceHandle: Object
): Instance {
  let parentNamespace: string

  if (__DEV__) {
    // ...
  } else {
    parentNamespace = hostContext
  }

  const domElement: Instance = createElement(
    type,
    props,
    rootContainerInstance,
    parentNamespace
  )

  //...

  return domElement
}           

現在看到的是處理 DOM 元素的渲染器,在理論上,渲染器可以在螢幕上繪制任何内容。

這個函數是在做一個條件指派,它使用工程函數建立了一個 DOM 元素。

再來看對純文字節點進行操作的 createTextInstance 函數。

export function createTextInstance(
  text: string,
  rootContainerInstance: Container,
  hostContext: HostContext,
  internalInstanceHandle: Object
): TextInstance {
  if (__DEV__) {
    const hostContextDev = hostContext
    validateDOMNesting(null, text, hostContextDev.ancestorInfo)
  }
  const textNode: TextInstance = createTextNode(
    text,
    rootContainerInstance
  )

  precacheFiberNode(internalInstanceHandle, textNode)
  return textNode
}           

它和我們之前看到的很多模式都類似,在開發模式下進行必要的驗證,然後将具體的建立任務委托給另外一個工廠函數。實際上 React 源碼中存在了大量的這種模式。

上面提到的這兩個函數會建立所有 DOM 元素,然後把這些元素添加到真正的 DOM 中去。

appendChild 是具體的實作。

export function appendChild(
  parentInstance: Instance,
  child: Instance | TextInstance
): void {
  parentInstance.appendChild(child)
}           

它直接接收一個 DOM 元素的執行個體,并調用它的 appendChild 方法來添加渲染的元件。

因為我們很難确定插入元件的确切位置,是以這需要在父元素的幫助下完成。

總結

到這裡,我們對 React 的渲染過程有了一個初步的了解,相信你已經搞懂了渲染器的工作原理和它們的實作方法,你應該也知道它們是如何連接配接到協調器上面,以及在 React 内部是如何表示元件的。

現在你應該明白了:閱讀源碼并不難。

接下來你可以自由發揮,大膽地去探索 React 源碼的更多内容吧!

#頭條創作挑戰賽#

繼續閱讀