React16源碼解讀:開篇帶你搞懂幾個面試考點

引言

現在,主流的前端框架React,Vue和Angular在前端領域已成三足鼎立之勢,基於前端技術棧的發展示狀,大大小小的公司或多或少也會使用其中某一項或者多項技術棧,那麼掌握並熟練使用其中至少一種也成爲了前端人員必不可少的技能飯碗。固然,框架的部分實現細節也常成爲面試中的考察要點,所以,一方面爲了應付面試官的連番追問,另外一方面爲了提高本身的技能水平,仍是有必要對框架的底層實現原理有必定的涉獵。javascript

固然對於主攻哪門技術棧沒有嚴格的要求,挑選你本身喜歡的就好,在面試中面試官通常會先問你最熟悉的是哪門技術棧,對於你不熟悉的領域,面試官可能也不會作太多的追問。筆者在項目中一直是使用的Vue框架,其上手門檻低,也提供了比較全面和友好的官方文檔可供參考。可是可能因人而異,感受本身仍是比較喜歡React,也說不出什麼好壞,多是本身最先接觸的前端框架吧,不過很遺憾,在以前的工做中一直派不上用場,但即使如此,也阻擋不了本身對底層原理的好奇心。因此最近也是開始研究React的源碼,並對源碼的解讀過程作一下記錄,方便加深記憶。若是你的技術棧恰好是React,而且也對源碼感興趣,那麼咱們能夠一塊兒互相探討技術難點,讓整個閱讀源碼的過程變得更加容易和有趣。源碼中若是有理解錯誤的地方,還但願可以指出。html

一、準備階段

在facebook的github上,目前React的最新版本爲v16.12.0,咱們知道在React的v16版本以後引入了新的Fiber架構,這種架構使得任務擁有了暫停恢復機制,將一個大的更新任務拆分爲一個一個執行單元,充分利用瀏覽器在每一幀的空閒時間執行任務,無空閒時間則延遲執行,從而避免了任務的長時間運行致使阻塞主線程同步任務的執行。爲了瞭解這種Fiber架構,這裏選擇了一個比較適中的v16.10.2的版本,沒有選擇最新的版本是由於在最新版本中移除了一些舊的兼容處理方案,雖然說這些方案只是爲了兼容,可是其思想仍是比較先進的,值得咱們推敲學習,因此先將其保留下來,這裏選擇v16.10.2版本的另一個緣由是React在v16.10.0的版本中涉及到兩個比較重要的優化點:前端


在上圖中指出,在任務調度(Scheduler)階段有兩個性能的優化點,解釋以下:java

  • 將任務隊列的內部數據結構轉換成最小二叉堆的形式以提高隊列的性能(在最小堆中咱們可以以最快的速度找到最小的那個值,由於那個值必定在堆的頂部,有效減小整個數據結構的查找時間)。
  • 使用週期更短的postMessage循環的方式而不是使用requestAnimationFrame這種與幀邊界對齊的方式(這種優化方案指得是在將任務進行延遲後恢復執行的階段,先後兩種方案都是宏任務,可是宏任務也有順序之分,postMessage的優先級比requestAnimationFrame高,這也就意味着延遲任務可以更快速地恢復並執行)。

固然如今不太理解的話不要緊,後續會有單獨的文章來介紹任務調度這一塊內容,遇到上述兩個優化點的時候會進行詳細說明,在開始閱讀源碼以前,咱們可使用create-react-app來快速搭建一個React項目,後續的示例代碼能夠在此項目上進行編寫:react

// 項目搭建完成後React默認爲最新版v16.12.0
create-react-app react-learning

// 爲了保證版本一致,手動將其修改成v16.10.2
npm install --save react@16.10.2 react-dom@16.10.2

// 運行項目
npm start

執行以上步驟後,不出意外的話,瀏覽器中會正常顯示出項目的默認界面。得益於在Reactv16.8版本以後推出的React Hooks功能,讓咱們在原來的無狀態函數組件中也能進行狀態管理,以及使用相應的生命週期鉤子,甚至在新版的create-react-app腳手架中,根組件App已經由原來的類組件的寫法升級爲了推薦的函數定義組件的方式,可是原來的類組件的寫法並無被廢棄掉,事實上咱們項目中仍是會大量充斥着類組件的寫法,所以爲了瞭解這種類組件的實現原理,咱們暫且將App根組件的函數定義的寫法回退到類組件的形式,並對其內容進行簡單修改:git

// src -> App.js
import React, {Component} from 'react';

function List({data}) {
    return (
        <ul className="data-list">
            {
                data.map(item => {
                    return <li className="data-item" key={item}>{item}</li>
                })
            }
        </ul>
    );
}

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            data: [1, 2, 3]
        };
    }

    render() {
        return (
            <div className="container">
                <h1 className="title">React learning</h1>
                <List data={this.state.data} />
            </div>
        );
    }
}

通過以上簡單修改後,而後咱們經過調用github

// src -> index.js
ReactDOM.render(<App />, document.getElementById('root'));

來將組件掛載到DOM容器中,最終獲得App組件的DOM結構以下所示:面試

<div class="container">
    <h1 class="title">React learning</h1>
    <ul class="data-list">
        <li class="data-item">1</li>
        <li class="data-item">2</li>
        <li class="data-item">3</li>
    </ul>
</div>

所以咱們分析React源碼的入口也將會是從ReactDOM.render方法開始一步一步分析組件渲染的整個流程,可是在此以前,咱們有必要先了解幾個重要的前置知識點,這幾個知識點將會更好地幫助咱們理解源碼的函數調用棧中的參數意義和其餘的一些細節。npm

二、前置知識

首先咱們須要明確的是,在上述示例中,App組件的render方法返回的是一段HTML結構,在普通的函數中這種寫法是不支持的,因此咱們通常須要相應的插件來在背後支撐,在React中爲了支持這種jsx語法提供了一個Babel預置工具包@babel/preset-react,其中這個preset又包含了兩個比較核心的插件:設計模式

  • @babel/plugin-syntax-jsx:這個插件的做用就是爲了讓Babel編譯器可以正確解析出jsx語法。
  • @babel/plugin-transform-react-jsx:在解析完jsx語法後,由於其本質上是一段HTML結構,所以爲了讓JS引擎可以正確識別,咱們就須要經過該插件將jsx語法編譯轉換爲另一種形式。在默認狀況下,會使用React.createElement來進行轉換,固然咱們也能夠在.babelrc文件中來進行手動設置。
// .babelrc
{
    "plugins": [
        ["@babel/plugin-transform-react-jsx", {
            "pragma": "Preact.h", // default pragma is React.createElement
            "pragmaFrag": "Preact.Fragment", // default is React.Fragment
            "throwIfNamespace": false // defaults to true
        }]
    ]
}

這裏爲了方便起見,咱們能夠直接使用Babel官方實驗室來查看轉換後的結果,對應上述示例,轉換後的結果以下所示:

// 轉換前
render() {
    return (
        <div className="container">
            <h1 className="title">React learning</h1>
            <List data={this.state.data} />
        </div>
    );
}

// 轉換後
render() {
    return React.createElement("div", {
        className: "content"
    }, 
    React.createElement("header", null, "React learning"), 
    React.createElement(List, { data: this.state.data }));
}

能夠看到jsx語法最終被轉換成由React.createElement方法組成的嵌套調用鏈,可能你以前已經瞭解過這個API,或者接觸過一些僞代碼實現,這裏咱們就基於源碼,深刻源碼內部來看看其背後爲咱們作了哪些事情。

2.1 createElement & ReactElement

爲了保證源碼的一致性,也建議你將React版本和筆者保持一致,採用v16.10.2版本,能夠經過facebook的github官方渠道進行獲取,下載下來以後咱們經過以下路徑來打開咱們須要查看的文件:

// react-16.10.2 -> packages -> react -> src -> React.js

React.js文件中,咱們直接跳轉到第63行,能夠看到React變量做爲一個對象字面量,包含了不少咱們所熟知的方法,包括在v16.8版本以後推出的React Hooks方法:

const React = {
  Children: {
    map,
    forEach,
    count,
    toArray,
    only,
  },

  createRef,
  Component,
  PureComponent,

  createContext,
  forwardRef,
  lazy,
  memo,

  // 一些有用的React Hooks方法
  useCallback,
  useContext,
  useEffect,
  useImperativeHandle,
  useDebugValue,
  useLayoutEffect,
  useMemo,
  useReducer,
  useRef,
  useState,

  Fragment: REACT_FRAGMENT_TYPE,
  Profiler: REACT_PROFILER_TYPE,
  StrictMode: REACT_STRICT_MODE_TYPE,
  Suspense: REACT_SUSPENSE_TYPE,
  unstable_SuspenseList: REACT_SUSPENSE_LIST_TYPE,

  // 重點先關注這裏,生產模式下使用後者
  createElement: __DEV__ ? createElementWithValidation : createElement,
  cloneElement: __DEV__ ? cloneElementWithValidation : cloneElement,
  createFactory: __DEV__ ? createFactoryWithValidation : createFactory,
  isValidElement: isValidElement,

  version: ReactVersion,

  unstable_withSuspenseConfig: withSuspenseConfig,

  __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: ReactSharedInternals,

這裏咱們暫且先關注createElement方法,在生產模式下它來自於與React.js同級別的ReactElement.js文件,咱們打開該文件,並直接跳轉到第312行,能夠看到createElement方法的函數定義(去除了一些__DEV__環境纔會執行的代碼):

/**
 * 該方法接收包括但不限於三個參數,與上述示例中的jsx語法通過轉換以後的實參進行對應
 * @param type 表示當前節點的類型,能夠是原生的DOM標籤字符串,也能夠是函數定義組件或者其它類型
 * @param config 表示當前節點的屬性配置信息
 * @param children 表示當前節點的子節點,能夠不傳,也能夠傳入原始的字符串文本,甚至能夠傳入多個子節點
 * @returns 返回的是一個ReactElement對象
 */
export function createElement(type, config, children) {
  let propName;

  // Reserved names are extracted
  // 用於存放config中的屬性,可是過濾了一些內部受保護的屬性名
  const props = {};

  // 將config中的key和ref屬性使用變量進行單獨保存
  let key = null;
  let ref = null;
  let self = null;
  let source = null;

  // config爲null表示節點沒有設置任何相關屬性
  if (config != null) {

    // 有效性判斷,判斷 config.ref !== undefined
    if (hasValidRef(config)) {
      ref = config.ref;
    }

    // 有效性判斷,判斷 config.key !== undefined
    if (hasValidKey(config)) {
      key = '' + config.key;
    }

    self = config.__self === undefined ? null : config.__self;
    source = config.__source === undefined ? null : config.__source;

    // Remaining properties are added to a new props object
    // 用於將config中的全部屬性在過濾掉內部受保護的屬性名後,將剩餘的屬性所有拷貝到props對象中存儲
    // const RESERVED_PROPS = {
    //   key: true,
    //   ref: true,
    //   __self: true,
    //   __source: true,
    // };
    for (propName in config) {
      if (
        hasOwnProperty.call(config, propName) &&
        !RESERVED_PROPS.hasOwnProperty(propName)
      ) {
        props[propName] = config[propName];
      }
    }
  }

  // Children can be more than one argument, and those are transferred onto
  // the newly allocated props object.
  // 因爲子節點的數量不限,所以從第三個參數開始,判斷剩餘參數的長度
  // 具備多個子節點則props.children屬性存儲爲一個數組
  const childrenLength = arguments.length - 2;
  if (childrenLength === 1) {
    
    // 單節點的狀況下props.children屬性直接存儲對應的節點
    props.children = children;
  } else if (childrenLength > 1) {
    
    // 多節點的狀況下則根據子節點數量建立一個數組
    const childArray = Array(childrenLength);
    for (let i = 0; i < childrenLength; i++) {
      childArray[i] = arguments[i + 2];
    }
    props.children = childArray;
  }

  // Resolve default props
  // 此處用於解析靜態屬性defaultProps
  // 針對於類組件或函數定義組件的狀況,能夠單獨設置靜態屬性defaultProps
  // 若是有設置defaultProps,則遍歷每一個屬性並將其賦值到props對象中(前提是該屬性在props對象中對應的值爲undefined)
  if (type && type.defaultProps) {
    const defaultProps = type.defaultProps;
    for (propName in defaultProps) {
      if (props[propName] === undefined) {
        props[propName] = defaultProps[propName];
      }
    }
  }
  
  // 最終返回一個ReactElement對象
  return ReactElement(
    type,
    key,
    ref,
    self,
    source,
    ReactCurrentOwner.current,
    props,
  );
}

通過上述分析咱們能夠得出,在類組件的render方法中最終返回的是由多個ReactElement對象組成的多層嵌套結構,全部的子節點信息均存放在父節點的props.children屬性中。咱們將源碼定位到ReactElement.js的第111行,能夠看到ReactElement函數的完整實現:

/**
 * 爲一個工廠函數,每次執行都會建立並返回一個ReactElement對象
 * @param type 表示節點所對應的類型,與React.createElement方法的第一個參數保持一致
 * @param key 表示節點所對應的惟一標識,通常在列表渲染中咱們須要爲每一個節點設置key屬性
 * @param ref 表示對節點的引用,能夠經過React.createRef()或者useRef()來建立引用
 * @param self 該屬性只有在開發環境才存在
 * @param source 該屬性只有在開發環境才存在
 * @param owner 一個內部屬性,指向ReactCurrentOwner.current,表示一個Fiber節點
 * @param props 表示該節點的屬性信息,在React.createElement中經過config,children參數和defaultProps靜態屬性獲得
 * @returns 返回一個ReactElement對象
 */
const ReactElement = function(type, key, ref, self, source, owner, props) {
  const element = {
    // This tag allows us to uniquely identify this as a React Element
    // 這裏僅僅加了一個$$typeof屬性,用於標識這是一個React Element
    $$typeof: REACT_ELEMENT_TYPE,

    // Built-in properties that belong on the element
    type: type,
    key: key,
    ref: ref,
    props: props,

    // Record the component responsible for creating this element.
    _owner: owner,
  };
  
  ...
  
  return element;
};

一個ReactElement對象的結構相對而言仍是比較簡單,主要是增長了一個$$typeof屬性用於標識該對象是一個React Element類型。REACT_ELEMENT_TYPE在支持Symbol類型的環境中爲symbol類型,不然爲number類型的數值。與REACT_ELEMENT_TYPE對應的還有不少其餘的類型,均存放在shared/ReactSymbols目錄中,這裏咱們能夠暫且只關心這一種,後面遇到其餘類型再來細看。

2.2 Component & PureComponent

瞭解完ReactElement對象的結構以後,咱們再回到以前的示例,經過繼承React.Component咱們將App組件修改成了一個類組件,咱們不妨先來研究下React.Component的底層實現。React.Component的源碼存放在packages/react/src/ReactBaseClasses.js文件中,咱們將源碼定位到第21行,能夠看到Component構造函數的完整實現:

/**
 * 構造函數,用於建立一個類組件的實例
 * @param props 表示所擁有的屬性信息
 * @param context 表示所處的上下文信息
 * @param updater 表示一個updater對象,這個對象很是重要,用於處理後續的更新調度任務
 */
function Component(props, context, updater) {
  this.props = props;
  this.context = context;
  // If a component has string refs, we will assign a different object later.
  // 該屬性用於存儲類組件實例的引用信息
  // 在React中咱們能夠有多種方式來建立引用
  // 經過字符串的方式,如:<input type="text" ref="inputRef" />
  // 經過回調函數的方式,如:<input type="text" ref={(input) => this.inputRef = input;} />
  // 經過React.createRef()的方式,如:this.inputRef = React.createRef(null); <input type="text" ref={this.inputRef} />
  // 經過useRef()的方式,如:this.inputRef = useRef(null); <input type="text" ref={this.inputRef} />
  this.refs = emptyObject;
  // We initialize the default updater but the real one gets injected by the
  // renderer.
  // 當state發生變化的時候,須要updater對象去處理後續的更新調度任務
  // 這部分涉及到任務調度的內容,在後續分析到任務調度階段的時候再來細看
  this.updater = updater || ReactNoopUpdateQueue;
}

// 在原型上新增了一個isReactComponent屬性用於標識該實例是一個類組件的實例
// 這個地方曾經有面試官考過,問如何區分函數定義組件和類組件
// 函數定義組件是沒有這個屬性的,因此能夠經過判斷原型上是否擁有這個屬性來進行區分
Component.prototype.isReactComponent = {};

/**
 * 用於更新狀態
 * @param partialState 表示下次須要更新的狀態
 * @param callback 在組件更新以後須要執行的回調
 */
Component.prototype.setState = function(partialState, callback) {
  ...
  this.updater.enqueueSetState(this, partialState, callback, 'setState');
};

/**
 * 用於強制從新渲染
 * @param callback 在組件從新渲染以後須要執行的回調
 */
Component.prototype.forceUpdate = function(callback) {
  this.updater.enqueueForceUpdate(this, callback, 'forceUpdate');
};

上述內容中涉及到任務調度的會在後續講解到調度階段的時候再來細講,如今咱們知道能夠經過原型上的isReactComponent屬性來區分函數定義組件和類組件。事實上,在源碼中就是經過這個屬性來區分Class ComponentFunction Component的,能夠找到如下方法:

// 返回true則表示類組件,不然表示函數定義組件
function shouldConstruct(Component) {
  return !!(Component.prototype && Component.prototype.isReactComponent);
}

Component構造函數對應的,還有一個PureComponent構造函數,這個咱們應該仍是比較熟悉的,經過淺比較判斷組件先後傳遞的屬性是否發生修改來決定是否須要從新渲染組件,在必定程度上避免組件重渲染致使的性能問題。一樣的,在ReactBaseClasses.js文件中,咱們來看看PureComponent的底層實現:

// 經過借用構造函數,實現典型的寄生組合式繼承,避免原型污染
function ComponentDummy() {}
ComponentDummy.prototype = Component.prototype;

function PureComponent(props, context, updater) {
  this.props = props;
  this.context = context;
  // If a component has string refs, we will assign a different object later.
  this.refs = emptyObject;
  this.updater = updater || ReactNoopUpdateQueue;
}

// 將PureComponent的原型指向借用構造函數的實例
const pureComponentPrototype = (PureComponent.prototype = new ComponentDummy());

// 從新設置構造函數的指向
pureComponentPrototype.constructor = PureComponent;

// Avoid an extra prototype jump for these methods.
// 將Component.prototype和PureComponent.prototype進行合併,減小原型鏈查找所浪費的時間(原型鏈越長所耗費的時間越久)
Object.assign(pureComponentPrototype, Component.prototype);

// 這裏是與Component的區別之處,PureComponent的原型上擁有一個isPureReactComponent屬性
pureComponentPrototype.isPureReactComponent = true;

經過以上分析,咱們就能夠初步得出ComponentPureComponent之間的差別,能夠經過判斷原型上是否擁有isPureReactComponent屬性來進行區分,固然更細粒度的區分,還須要在閱讀後續的源碼內容以後才能見分曉。

三、面試考點

看完以上內容,按道理來講如下幾個可能的面試考點應該就不成問題了,或者說至少也不會遇到一個字也回答不了的尷尬局面,試試看吧:

  • 在React中爲什麼可以支持jsx語法
  • 類組件的render方法執行後最終返回的結果是什麼
  • 手寫代碼實現一個createElement方法
  • 如何判斷一個對象是否是React Element
  • 如何區分類組件和函數定義組件
  • ComponentPureComponent之間的關係
  • 如何區分ComponentPureComponent

四、總結

本文做爲React16源碼解讀的開篇,先講解了幾個比較基礎的前置知識點,這些知識點有助於咱們在後續分析組件的任務調度和渲染過程時可以更好地去理解源碼。閱讀源碼的過程是痛苦的,一個緣由是源碼量巨大,文件依賴關係複雜容易讓人產生恐懼退縮心理,另外一個是閱讀源碼是個漫長的過程,期間可能會佔用你學習其餘新技術的時間,讓你沒法徹底靜下心來。可是其實咱們要明白的是,學習源碼不僅是爲了應付面試,源碼中其實有不少咱們能夠借鑑的設計模式或者使用技巧,若是咱們能夠學習並應用到咱們正在作的項目中,也不失爲一件有意義的事情。後續文章就從ReactDOM.render方法開始,一步一步分析組件渲染的整個流程,咱們也不須要去搞懂每一行代碼,畢竟每一個人的思路不太同樣,可是關鍵步驟咱們仍是須要去多花時間理解的。

五、交流

若是你以爲這篇文章的內容對你有幫助,可否幫個忙關注一下筆者的公衆號[前端之境],每週都會努力原創一些前端技術乾貨,關注公衆號後能夠邀你加入前端技術交流羣,咱們能夠一塊兒互相交流,共同進步。

文章已同步更新至Github博客,若覺文章尚可,歡迎前往star!

你的一個點贊,值得讓我付出更多的努力!

逆境中成長,只有不斷地學習,才能成爲更好的本身,與君共勉!

相關文章
相關標籤/搜索