前端React面試題總結

xiangzhihong發表於2021-12-05

一、簡介介紹下React,說說他們都有哪些特性

1.1 簡介

React是一個構建使用者介面的 JavaScript 庫,是一個UI 層面的解決方案。React遵循元件設計模式、宣告式程式設計正規化和函數語言程式設計概念,以使前端應用程式開發更高效。同時,React使用虛擬DOM來有效地操作DOM,遵循從高階元件到低階元件的單向資料流。同時,React可以幫助我們將介面拆分成各個獨立的小塊,每一個塊就是元件,這些元件之間可以組合、巢狀,構成一個整體頁面。

語法上,React 類元件使用一個名為 render() 的方法或者函式元件return,接收輸入的資料並返回需要展示的內容,比如:

class HelloMessage extends React.Component {
  render() {
    return (
      <div>
        Hello {this.props.name}
      </div>
    );
  }
}

ReactDOM.render(
  <HelloMessage name="Taylor" />,
  document.getElementById('hello-example')
);

上述這種類似 XML形式就是 JSX,最終會被babel編譯為合法的JS語句呼叫。被傳入的資料可在元件中通過 this.props 在 render() 訪問。

1.2 特性

React特性有很多,下面列舉幾個有特性的:

  • JSX語法
  • 單向資料繫結
  • 虛擬DOM
  • 宣告式程式設計
  • Component

1.2.1 宣告式程式設計

宣告式程式設計是一種程式設計正規化,它關注的是你要做什麼,而不是如何做。它表達邏輯而不顯式地定義步驟。這意味著我們需要根據邏輯的計算來宣告要顯示的元件,如實現一個標記的地圖:通過命令式建立地圖、建立標記、以及在地圖上新增的標記的步驟如下。

// 建立地圖
const map = new Map.map(document.getElementById('map'), {
    zoom: 4,
    center: {lat,lng}
});

// 建立標記
const marker = new Map.marker({
    position: {lat, lng},
    title: 'Hello Marker'
});

// 地圖上新增標記
marker.setMap(map);

而用React實現上述功能,則如下:

<Map zoom={4} center={lat, lng}>
    <Marker position={lat, lng} title={'Hello Marker'}/>
</Map>

宣告式程式設計方式使得React元件很容易使用,最終的程式碼也更加簡單易於維護。

1.2.2 Component

在React 中,一切皆為元件。通常將應用程式的整個邏輯分解為小的單個部分。 我們將每個單獨的部分稱為元件。元件可以是一個函式或者是一個類,接受資料輸入,處理它並返回在UI中呈現的React元素,函式式元件如下:

const Header = () => {
    return(
        <Jumbotron style={{backgroundColor:'orange'}}>
            <h1>TODO App</h1>
        </Jumbotron>
    )
}

而對於需要改變狀態來說,有狀態元件的定義如下:

class Dashboard extends React.Component {
    constructor(props){
        super(props);

        this.state = {

        }
    }
    render() {
        return (
            <div className="dashboard"> 
                <ToDoForm />
                <ToDolist />
            </div>
        );
    }
}

可以看到,React的元件有如下的一些特性:

  • 可組合:個元件易於和其它元件一起使用,或者巢狀在另一個元件內部。
  • 可重用:每個元件都是具有獨立功能的,它可以被使用在多個UI場景。
  • 可維護:每個小的元件僅僅包含自身的邏輯,更容易被理解和維護。

二、Real DOM和 Virtual DOM 的區別

2.1 Real DOM

Real DOM是相對Virtual DOM 來說的, Real DOM指的是文件物件模型,是一個結構化文字的抽象,在頁面渲染出的每一個結點都是一個真實DOM結構,我們可以使用瀏覽器的DevTool來檢視,如下。
在這裡插入圖片描述
Virtual Dom,本質上是以 JavaScript 物件形式存在的對 DOM 的描述。建立虛擬DOM目的就是為了更好將虛擬的節點渲染到頁面檢視中,虛擬DOM物件的節點與真實DOM的屬性一一照應。在React中,JSX是其一大特性,可以讓你在JS中通過使用XML的方式去直接宣告介面的DOM結構。

const vDom = <h1>Hello World</h1> // 建立h1標籤,右邊千萬不能加引號
const root = document.getElementById('root') // 找到<div id="root"></div>節點
ReactDOM.render(vDom, root) // 把建立的h1標籤渲染到root節點上

在上述程式碼中,ReactDOM.render()用於將建立好的虛擬DOM節點插入到某個真實節點上,並渲染到頁面上。實際上,JSX是一種語法糖,在使用過程中會被babel進行編譯轉化成JS程式碼,上述VDOM轉化為如下。

const vDom = React.createElement(
  'h1', 
  { className: 'hClass', id: 'hId' },
  'hello world'
)

可以看到,JSX就是為了簡化直接呼叫React.createElement() 方法:

  • 第一個引數是標籤名,例如h1、span、table...。
  • 第二個引數是個物件,裡面存著標籤的一些屬性,例如id、class等。
  • 第三個引數是節點中的文字。

通過console.log(VDOM),則能夠得到虛擬DOM的相關資訊。

在這裡插入圖片描述

所以,從上面的例子可知,JSX通過babel的方式轉化成React.createElement執行,返回值是一個物件,也就是虛擬DOM。、

2.2 區別

Real DOM和 Virtual DOM的區別如下:

  • 虛擬DOM不會進行排版與重繪操作,而真實DOM會頻繁重排與重繪。
  • 虛擬DOM的總損耗是“虛擬DOM增刪改+真實DOM差異增刪改+排版與重繪”,真實DOM的總損耗是“真實DOM完全增刪改+排版與重繪”。

2.3 優缺點

真實DOM的優勢:

  • 易用

缺點:

  • 效率低,解析速度慢,記憶體佔用量過高。
  • 效能差:頻繁操作真實DOM,易於導致重繪與迴流。

使用虛擬DOM的優勢如下:

  • 簡單方便:如果使用手動操作真實DOM來完成頁面,繁瑣又容易出錯,在大規模應用下維護起來也很困難。
  • 效能好:使用Virtual DOM,能夠有效避免真實DOM數頻繁更新,減少多次引起重繪與迴流,提高效能。
    -跨平臺:React藉助虛擬DOM, 帶來了跨平臺的能力,一套程式碼多端執行。

缺點如下:

  • 在一些效能要求極高的應用中虛擬 DOM 無法進行鍼對性的極致優化。
  • 首次渲染大量DOM時,由於多了一層虛擬DOM的計算,速度比正常稍慢。

三、super()和super(props)有什麼區別

3.1 ES6類

在ES6中,通過extends關鍵字實現類的繼承,方式如下:

class sup {
    constructor(name) {
        this.name = name
    }

    printName() {
        console.log(this.name)
    }
}


class sub extends sup{
    constructor(name,age) {
        super(name) // super代表的事父類的建構函式
        this.age = age
    }

    printAge() {
        console.log(this.age)
    }
}

let jack = new sub('jack',20)
jack.printName()          //輸出 : jack
jack.printAge()           //輸出 : 20

在上面的例子中,可以看到通過super關鍵字實現呼叫父類,super代替的是父類的構建函式,使用super(name)相當於呼叫sup.prototype.constructor.call(this,name)。如果在子類中不使用super,關鍵字,則會引發報錯,如下:
在這裡插入圖片描述
報錯的原因是 子類是沒有自己的this物件的,它只能繼承父類的this物件,然後對其進行加工。而super()就是將父類中的this物件繼承給子類的,沒有super() 子類就得不到this物件。如果先呼叫this,再初始化super(),同樣是禁止的行為。

class sub extends sup{
    constructor(name,age) {
        this.age = age
        super(name) // super代表的事父類的建構函式
    }
}

所以,在子類constructor中,必須先代用super才能引用this。

3.2 類元件

在React中,類元件是基於es6的規範實現的,繼承React.Component,因此如果用到constructor就必須寫super()才初始化this。這時候,在呼叫super()的時候,我們一般都需要傳入props作為引數,如果不傳進去,React內部也會將其定義在元件例項中。

// React 內部
const instance = new YourComponent(props);
instance.props = props;

所以無論有沒有constructor,在render中this.props都是可以使用的,這是React自動附帶的,是可以不寫的。

class HelloMessage extends React.Component{
    render (){
        return (
            <div>nice to meet you! {this.props.name}</div>
        );
    }
}

但是也不建議使用super()代替super(props)。因為在React會在類元件建構函式生成例項後再給this.props賦值,所以在不傳遞props在super的情況下,呼叫this.props會返回undefined,如下。

class Button extends React.Component {
  constructor(props) {
    super();                             // 沒傳入 props
    console.log(props);          // {}
    console.log(this.props);  // undefined
   // ...
}

而傳入props的則都能正常訪問,確保了 this.props 在建構函式執行完畢之前已被賦值,更符合邏輯。

class Button extends React.Component {
  constructor(props) {
    super(props); // 沒傳入 props
    console.log(props);      //  {}
    console.log(this.props); //  {}
  // ...
}

從上面的例子,我們可以得出:

  • 在React中,類元件基於ES6,所以在constructor中必須使用super。
  • 在呼叫super過程,無論是否傳入props,React內部都會將porps賦值給元件例項porps屬性中。
  • 如果只呼叫了super(),那麼this.props在super()和建構函式結束之間仍是undefined。

四、談談setState執行機制

4.1 什麼是setState機制

在React中,一個元件的顯示形態可以由資料狀態和外部引數所決定,而資料狀態就是state。當需要修改裡面的值的狀態時,就需要通過呼叫setState來改變,從而達到更新元件內部資料的作用。

比如,下面的例子:

import React, { Component } from 'react'

export default class App extends Component {
    constructor(props) {
        super(props);

        this.state = {
            message: "Hello World"
        }
    }

    render() {
        return (
            <div>
                <h2>{this.state.message}</h2>
                <button onClick={e => this.changeText()}>面試官系列</button>
            </div>
        )
    }

    changeText() {
        this.setState({
            message: "JS每日一題"
        })
    }
}

通過點選按鈕觸發onclick事件,執行this.setState方法更新state狀態,然後重新執行render函式,從而導致頁面的檢視更新。如果想要直接修改state的狀態,那麼只需要呼叫setState即可。

changeText() {
    this.state.message = "你好啊,世界";
}

我們會發現頁面並不會有任何反應,但是state的狀態是已經發生了改變。這是因為React並不像vue2中呼叫Object.defineProperty資料響應式或者Vue3呼叫Proxy監聽資料的變化,必須通過setState方法來告知react元件state已經發生了改變。
關於state方法的定義是從React.Component中繼承,定義的原始碼如下:

Component.prototype.setState = function(partialState, callback) {
  invariant(
    typeof partialState === 'object' ||
      typeof partialState === 'function' ||
      partialState == null,
    'setState(...): takes an object of state variables to update or a ' +
      'function which returns an object of state variables.',
  );
  this.updater.enqueueSetState(this, partialState, callback, 'setState');
};

4.2 更新方式

在使用setState更新資料的時候,setState的更新型別分成非同步更新、同步更新。

4.2.1 非同步更新

舉個例子,有下面一段程式碼。

changeText() {
  this.setState({
    message: "你好啊"
  })
  console.log(this.state.message);         // Hello World
}

上面的程式碼最終列印結果為Hello world,並不能在執行完setState之後立馬拿到最新的state的結果。如果想要立刻獲取更新後的值,在第二個引數的回撥中更新後會執行。

changeText() {
  this.setState({
    message: "你好啊"
  }, () => {
    console.log(this.state.message);   // 你好啊
  });
}

4.2.2 同步更新

下面是使用setTimeout同步更新的例子。

changeText() {
  setTimeout(() => {
    this.setState({
      message: "你好啊
    });
    console.log(this.state.message); // 你好啊
  }, 0);
}

4.2.3 批量更新

有時候,我們需要處理批量更新的情況,先給出一個例子:

handleClick = () => {
    this.setState({
        count: this.state.count + 1,
    })
    console.log(this.state.count) // 1

    this.setState({
        count: this.state.count + 1,
    })
    console.log(this.state.count) // 1

    this.setState({
        count: this.state.count + 1,
    })
    console.log(this.state.count) // 1
}

當我們點選按鈕觸發事件,列印的都是 1,頁面顯示 count 的值為 2。對同一個值進行多次 setState , setState 的批量更新策略會對其進行覆蓋,取最後一次的執行結果。因此,上面的程式碼等價於下面的程式碼:

Object.assign(  previousState,  {index: state.count+ 1},  {index: state.count+ 1},  ...)

由於後面的資料會覆蓋前面的更改,所以最終只加了一次。如果是下一個state依賴前一個state的話,推薦給setState一個引數傳入一個function,如下:

onClick = () => {    this.setState((prevState, props) => {      return {count: prevState.count + 1};    });    this.setState((prevState, props) => {      return {count: prevState.count + 1};    });}

而在setTimeout或者原生dom事件中,由於是同步的操作,所以並不會進行覆蓋現象。

五、React事件繫結

5.1 事件繫結

當我們需要處理點選事件時,幾句需要給事件新增一些繫結操作,即所謂的事件繫結。下面是一個最常見的事件繫結:

class ShowAlert extends React.Component {
  showAlert() {
    console.log("Hi");
  }

  render() {
    return <button onClick={this.showAlert}>show</button>;
  }
}

可以看到,事件繫結的方法需要使用{}包住。上述的程式碼看似沒有問題,但是當將處理函式輸出程式碼換成console.log(this)的時候,點選按鈕,則會發現控制檯輸出undefined。

5.2 常見繫結方式

React常見的事件繫結方式有如下幾種:

  • render方法中使用bind
  • render方法中使用箭頭函式
  • constructor中bind
  • 定義階段使用箭頭函式繫結

5.2.1 render方法中使用bind

如果使用一個類元件,在其中給某個元件/元素一個onClick屬性,它現在並會自定繫結其this到當前元件,解決這個問題的方法是在事件函式後使用.bind(this)將this繫結到當前元件中。

class App extends React.Component {
  handleClick() {
    console.log('this > ', this);
  }
  render() {
    return (
      <div onClick={this.handleClick.bind(this)}>test</div>
    )
  }
}

這種方式在元件每次render渲染的時候,都會重新進行bind的操作,影響效能。

5.2.2 render方法中使用箭頭函式

通過ES6的上下文來將this的指向繫結給當前元件,同樣再每一次render的時候都會生成新的方法,影響效能。

class App extends React.Component {
  handleClick() {
    console.log('this > ', this);
  }
  render() {
    return (
      <div onClick={e => this.handleClick(e)}>test</div>
    )
  }
}

5.2.3 constructor中bind

在constructor中預先bind當前元件,可以避免在render操作中重複繫結。

class App extends React.Component {
  constructor(props) {
    super(props);
    this.handleClick = this.handleClick.bind(this);
  }
  handleClick() {
    console.log('this > ', this);
  }
  render() {
    return (
      <div onClick={this.handleClick}>test</div>
    )
  }
}

5.2.4 定義階段使用箭頭函式繫結

跟上述方式三一樣,能夠避免在render操作中重複繫結,實現也非常的簡單。

class App extends React.Component {
  constructor(props) {
    super(props);
  }
  handleClick = () => {
    console.log('this > ', this);
  }
  render() {
    return (
      <div onClick={this.handleClick}>test</div>
    )
  }
}

5.3 區別

上述四種方法的方式,區別主要如下:

  • 編寫方面:方式一、方式二寫法簡單,方式三的編寫過於冗雜
  • 效能方面:方式一和方式二在每次元件render的時候都會生成新的方法例項,效能問題欠缺。若該函式作為屬性值傳給子元件的時候,都會導致額外的渲染。而方式三、方式四隻會生成一個方法例項。

綜合上述,方式四是最優的事件繫結方式。

六、React中元件通訊

6.1 元件通訊

元件是Vue中和React前端框架最核心的基礎思想,也是區別其他js框架最明顯的特徵之一。通常,一個完成的複雜業務頁面就是由許多的基礎元件構成的。而元件之間需要傳遞訊息,就會涉及到通訊。通訊指的是傳送者通過某種媒體以某種格式來傳遞資訊到收信者以達到某個目的,廣義上,任何資訊的交通都是通訊。

6.2 通訊的幾種方式

元件傳遞的方式有很多種,根據傳送者和接收者可以分為如下幾種:

  • 父元件向子元件傳遞
  • 子元件向父元件傳遞
  • 兄弟元件之間的通訊
  • 父元件向後代元件傳遞
  • 非關係元件傳遞

6.2.1 父元件向子元件傳遞訊息

由於React的資料流動為單向的,父元件向子元件傳遞是最常見的方式。父元件在呼叫子元件的時候,只需要在子元件標籤內傳遞引數,子元件通過props屬性就能接收父元件傳遞過來的引數即可。

function EmailInput(props) {
  return (
    <label>
      Email: <input value={props.email} />
    </label>
  );
}

const element = <EmailInput email="123124132@163.com" />;

6.2.2 子元件向父元件傳遞訊息

子元件向父元件通訊的基本思路是,父元件向子元件傳一個函式,然後通過這個函式的回撥,拿到子元件傳過來的值。父元件對應程式碼如下:

class Parents extends Component {
  constructor() {
    super();
    this.state = {
      price: 0
    };
  }

  getItemPrice(e) {
    this.setState({
      price: e
    });
  }

  render() {
    return (
      <div>
        <div>price: {this.state.price}</div>
        {/* 向子元件中傳入一個函式  */}
        <Child getPrice={this.getItemPrice.bind(this)} />
      </div>
    );
  }
}

子元件對應程式碼如下:

class Child extends Component {
  clickGoods(e) {
    // 在此函式中傳入值
    this.props.getPrice(e);
  }

  render() {
    return (
      <div>
        <button onClick={this.clickGoods.bind(this, 100)}>goods1</button>
        <button onClick={this.clickGoods.bind(this, 1000)}>goods2</button>
      </div>
    );
  }
}

6.2.3 兄弟元件之間的通訊

如果是兄弟元件之間的傳遞,則父元件作為中間層來實現資料的互通,通過使用父元件傳遞。

class Parent extends React.Component {
  constructor(props) {
    super(props)
    this.state = {count: 0}
  }
  setCount = () => {
    this.setState({count: this.state.count + 1})
  }
  render() {
    return (
      <div>
        <SiblingA
          count={this.state.count}
        />
        <SiblingB
          onClick={this.setCount}
        />
      </div>
    );
  }
}

6.2.4 隔代組傳遞訊息

父元件向後代元件傳遞資料是一件最普通的事情,就像全域性資料一樣。使用context提供了元件之間通訊的一種方式,可以共享資料,其他資料都能讀取對應的資料。通過使用React.createContext建立一個context。

 const PriceContext = React.createContext('price')

context建立成功後,其下存在Provider元件用於建立資料來源,Consumer元件用於接收資料,使用例項如下:Provider元件通過value屬性用於給後代元件傳遞資料。

<PriceContext.Provider value={100}>
</PriceContext.Provider>

如果想要獲取Provider傳遞的資料,可以通過Consumer元件或者或者使用contextType屬性接收,對應分別如下:

class MyClass extends React.Component {
  static contextType = PriceContext;
  render() {
    let price = this.context;
    /* 基於這個值進行渲染工作 */
  }
}

Consumer元件程式碼如下:

<PriceContext.Consumer>
    { /*這裡是一個函式*/ }
    {
        price => <div>price:{price}</div>
    }
</PriceContext.Consumer>

6.2.5 非關係元件傳遞訊息

如果元件之間關係型別比較複雜的情況,建議將資料進行一個全域性資源管理,從而實現通訊,例如redux,mobx等。

七、React Hooks

7.1 Hook

Hook 是 React 16.8 的新增特性。它可以讓你在不編寫 class 的情況下使用 state 以及其他的 React 特性。至於為什麼引入hook,官方給出的動機是解決長時間使用和維護React過程中常遇到的問題,例如:

  • 難以重用和共享元件中的與狀態相關的邏輯
  • 邏輯複雜的元件難以開發與維護,當我們的元件需要處理多個互不相關的 local state 時,每個生命週期函式中可能會包含著各種互不相關的邏輯在裡面
  • 類元件中的this增加學習成本,類元件在基於現有工具的優化上存在些許問題
  • 由於業務變動,函式元件不得不改為類元件等等

函式元件也被稱為無狀態的元件,在剛開始只負責渲染的一些工作。因此,使用Hook技術現在的函式元件也可以是有狀態的元件,內部也可以維護自身的狀態以及做一些邏輯方面的處理。

7.2 Hooks函式

Hooks讓我們的函式元件擁有了類元件的特性,例如元件內的狀態、生命週期等。為了實現狀態管理,Hook提供了很多有用的Hooks函式,常見的有:

  • useState
  • useEffect
  • 其他

useState

首先給出一個例子,如下:

import React, { useState } from 'react';

function Example() {
  // 宣告一個叫 "count" 的 state 變數
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p >
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

在函式元件中通過useState實現函式內部維護state,引數為state預設的值,返回值是一個陣列,第一個值為當前的state,第二個值為更新state的函式。該函式元件如果用類元件實現,程式碼如下:

class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p >
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          Click me
        </button>
      </div>
    );
  }
}

從上述兩種程式碼分析,可以看出兩者區別:

  • state宣告方式:在函式元件中通過 useState 直接獲取,類元件通過constructor 建構函式中設定
  • state讀取方式:在函式元件中直接使用變數,類元件通過this.state.count的方式獲取
  • state更新方式:在函式元件中通過 setCount 更新,類元件通過this.setState()

總的來講,useState 使用起來更為簡潔,減少了this指向不明確的情況。

useEffect

useEffect可以讓我們在函式元件中進行一些帶有副作用的操作。比如,下面是一個計時器的例子:

class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

  componentDidMount() {
    document.title = `You clicked ${this.state.count} times`;
  }
  componentDidUpdate() {
    document.title = `You clicked ${this.state.count} times`;
  }

  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p >
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          Click me
        </button>
      </div>
    );
  }
}

從上面可以看見,元件在載入和更新階段都執行同樣操作。而如果使用useEffect後,則能夠將相同的邏輯抽離出來,這是類元件不具備的方法。

import React, { useState, useEffect } from 'react';
function Example() {
  const [count, setCount] = useState(0);
 
  useEffect(() => {    document.title = `You clicked ${count} times`;  });
  return (
    <div>
      <p>You clicked {count} times</p >
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

useEffect的呃第一個引數接受一個回撥函式。預設情況下,useEffect會在第一次渲染和更新之後都會執行,相當於在componentDidMount和componentDidUpdate兩個生命週期函式中執行回撥。

如果某些特定值在兩次重渲染之間沒有發生變化,你可以跳過對 effect 的呼叫,這時候只需要傳入第二個引數,如下:

useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]);      // 僅在 count 更改時更新

上述傳入第二個引數後,如果 count 的值是 5,而且我們的元件重渲染的時候 count 還是等於 5,React 將對前一次渲染的 [5] 和後一次渲染的 [5] 進行比較,如果是相等則跳過effects執行。

回撥函式中可以返回一個清除函式,這是effect可選的清除機制,相當於類元件中componentwillUnmount生命週期函式,可做一些清除副作用的操作,如下:

useEffect(() => {
    function handleStatusChange(status) {
        setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
        ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
});

可以發現,useEffect相當於componentDidMount,componentDidUpdate 和 componentWillUnmount 這三個生命週期函式的組合。

其它 hooks

除了上面兩個比較常見的外,React還有很多額外的hooks。

  • useReducer
  • useCallback
  • useMemo
  • useRef

7.3 總結

通過對上面的初步認識,可以看到hooks能夠更容易解決狀態相關的重用的問題:

  • 每呼叫useHook一次都會生成一份獨立的狀態
  • 通過自定義hook能夠更好的封裝我們的功能

編寫hooks為函數語言程式設計,每個功能都包裹在函式中,整體風格更清爽,更優雅。

八、談談你對Redux的理解

8.1 概念

React是用於構建使用者介面的,幫助我們解決渲染DOM的過程。而在整個應用中會存在很多個元件,每個元件的state是由自身進行管理,包括元件定義自身的state、元件之間的通訊通過props傳遞、使用Context實現資料共享。

如果讓每個元件都儲存自身相關的狀態,理論上來講不會影響應用的執行,但在開發及後續維護階段,我們將花費大量精力去查詢狀態的變化過程。這種情況下,如果將所有的狀態進行集中管理,當需要更新狀態的時候,僅需要對這個管理集中處理,而不用去關心狀態是如何分發到每一個元件內部的。

Redux實現了狀態的集中管理,使用時需要遵循三大基本原則:

  • 單一資料來源
  • state 是隻讀的
  • 使用純函式來執行修改

需要說明的是,Redux並不是只應用在React中,還與其他介面庫一起使用,如Vue。

8.2 工作原理

redux狀態管理主要分為三個部分: Action Creactor、Store和Reducer。其中, store 是用於資料的公共儲存空間。一個元件改變了 store 裡的資料內容,其他元件就能感知到 store 的變化,再來取資料,從而間接的實現了這些資料傳遞的功能。

工作流程示意圖如下圖所示。
在這裡插入圖片描述

詳細的介紹可以檢視:Redux 三大核心概念

8.3 使用

首先,需要建立一個store的公共資料區域。

import { createStore } from 'redux' // 引入一個第三方的方法
const store = createStore() // 建立資料的公共儲存區域(管理員)

然後,再建立一個記錄本去輔助管理資料,也就是reduecer,本質就是一個函式,接收兩個引數state和action,並返回state。

// 設定預設值
const initialState = {
  counter: 0
}

const reducer = (state = initialState, action) => {
}

接著,使用createStore函式將state和action建立連線,如下。

const store = createStore(reducer)

如果想要獲取store裡面的資料,則通過store.getState()來獲取當前state,如下。

console.log(store.getState());

下面再看看如何更改store裡面資料。是通過dispatch來派發action,通常action中都會有type屬性,也可以攜帶其他的資料。

store.dispatch({
  type: "INCREMENT"
})

store.dispath({
  type: "DECREMENT"
})

store.dispatch({
  type: "ADD_NUMBER",
  number: 5
})

接著,我們再來看看修改reducer中的處理邏輯。

const reducer = (state = initialState, action) => {
  switch (action.type) {
    case "INCREMENT":
      return {...state, counter: state.counter + 1};
    case "DECREMENT":
      return {...state, counter: state.counter - 1};
    case "ADD_NUMBER":
      return {...state, counter: state.counter + action.number}
    default: 
      return state;
  }
}

注意,reducer是一個純函式,不需要直接修改state。接著,當派發action之後,既可以通過store.subscribe監聽store的變化。

store.subscribe(() => {
  console.log(store.getState());
})

在React專案中,會搭配react-redux進行使用。

const redux = require('redux');

const initialState = {
  counter: 0
}

// 建立reducer
const reducer = (state = initialState, action) => {
  switch (action.type) {
    case "INCREMENT":
      return {...state, counter: state.counter + 1};
    case "DECREMENT":
      return {...state, counter: state.counter - 1};
    case "ADD_NUMBER":
      return {...state, counter: state.counter + action.number}
    default: 
      return state;
  }
}

// 根據reducer建立store
const store = redux.createStore(reducer);

store.subscribe(() => {
  console.log(store.getState());
})

// 修改store中的state
store.dispatch({
  type: "INCREMENT"
})
// console.log(store.getState());

store.dispatch({
  type: "DECREMENT"
})
// console.log(store.getState());

store.dispatch({
  type: "ADD_NUMBER",
  number: 5
})
// console.log(store.getState());
  • createStore可以幫助建立 store。
  • store.dispatch 幫助派發 action , action 會傳遞給 store。
  • store.getState 這個方法可以幫助獲取 store 裡邊所有的資料內容。
  • store.subscrible 方法訂閱 store 的改變,只要 store 發生改變, store.subscrible 這個函式接收的這個回撥函式就會被執行。

九、Redux中介軟體

9.1 什麼是中介軟體

中介軟體(Middleware)是介於應用系統和系統軟體之間的一類軟體,它使用系統軟體所提供的基礎服務(功能),銜接網路上應用系統的各個部分或不同的應用,能夠達到資源共享、功能共享的目的。

前面,我們瞭解到了Redux整個工作流程,當action發出之後,reducer立即算出state,整個過程是一個同步的操作。那麼如果需要支援非同步操作,或者支援錯誤處理、日誌監控,這個過程就可以用上中介軟體。

Redux中,中介軟體就是放在就是在dispatch過程,在分發action進行攔截處理,如下圖:
在這裡插入圖片描述
其本質上一個函式,對store.dispatch方法進行了改造,在發出 Action 和執行 Reducer 這兩步之間,新增了其他功能。

9.2 常用中介軟體

優秀的redux中介軟體有很多,比如:

  • redux-thunk:用於非同步操作
  • redux-logger:用於日誌記錄

上述的中介軟體都需要通過applyMiddlewares進行註冊,作用是將所有的中介軟體組成一個陣列,依次執行,作為第二個引數傳入到createStore中。

const store = createStore(
  reducer,
  applyMiddleware(thunk, logger)
);

9.2.1 redux-thunk

redux-thunk是官網推薦的非同步處理中介軟體。預設情況下的dispatch(action),action需要是一個JavaScript的物件。

redux-thunk中介軟體會判斷你當前傳進來的資料型別,如果是一個函式,將會給函式傳入引數值(dispatch,getState)。

  • dispatch函式用於我們之後再次派發action。
  • getState函式考慮到我們之後的一些操作需要依賴原來的狀態,用於讓我們可以獲取之前的一些狀態。

所以,dispatch可以寫成下述函式的形式。

const getHomeMultidataAction = () => {
  return (dispatch) => {
    axios.get("http://xxx.xx.xx.xx/test").then(res => {
      const data = res.data.data;
      dispatch(changeBannersAction(data.banner.list));
      dispatch(changeRecommendsAction(data.recommend.list));
    })
  }
}

9.2.2 redux-logger

如果想要實現一個日誌功能,則可以使用現成的redux-logger,如下。

import { applyMiddleware, createStore } from 'redux';
import createLogger from 'redux-logger';
const logger = createLogger();

const store = createStore(
  reducer,
  applyMiddleware(logger)
);

9.3 Redux原始碼分析

首先,我們來看看applyMiddlewares的原始碼:

export default function applyMiddleware(...middlewares) {
  return (createStore) => (reducer, preloadedState, enhancer) => {
    var store = createStore(reducer, preloadedState, enhancer);
    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}
  }
}

可以看到,所有中介軟體被放進了一個陣列chain,然後巢狀執行,最後執行store.dispatch,而中介軟體內部(middlewareAPI)可以拿到getState和dispatch這兩個方法

通過上面的分析,我們瞭解到了redux-thunk的基本使用。同時,內部會將dispatch進行一個判斷,然後執行對應操作,原理如下:

function patchThunk(store) {
    let next = store.dispatch;

    function dispatchAndThunk(action) {
        if (typeof action === "function") {
            action(store.dispatch, store.getState);
        } else {
            next(action);
        }
    }

    store.dispatch = dispatchAndThunk;
}

下面,我們自己實現一個日誌輸出的攔截。

let next = store.dispatch;

function dispatchAndLog(action) {
  console.log("dispatching:", addAction(10));
  next(addAction(5));
  console.log("新的state:", store.getState());
}

store.dispatch = dispatchAndLog;

十、如何提高元件的渲染效率

我們知道,React 基於虛擬 DOM 和高效 Diff 演算法的完美配合,實現了對 DOM 最小粒度的更新,大多數情況下,React 對 DOM 的渲染效率足以我們的業務日常。不過,對於複雜業務場景,效能問題依然會困擾我們。此時需要採取一些措施來提升執行效能,避免不必要的渲染則是業務中常見的優化手段之一。

10.1 實現方案

我們瞭解到,render的觸發時機簡單來講就是類元件通過呼叫setState方法, 就會導致render,父元件一旦發生render渲染,子元件一定也會執行render渲染。父元件渲染導致子元件渲染,子元件並沒有發生任何改變,這時候就可以從避免無謂的渲染,具體實現的方式有如下:

  • shouldComponentUpdate
  • PureComponent
  • React.memo

10.2 涉及生命週期函式

102.1 shouldComponentUpdate

通過shouldComponentUpdate生命週期函式來比對 state 和 props,確定是否要重新渲染。預設情況下返回true表示重新渲染,如果不希望元件重新渲染,返回 false 即可。

10.2.2 PureComponent

跟shouldComponentUpdate 原理基本一致,通過對 props 和 state的淺比較結果來實現 shouldComponentUpdate,原始碼大致如下:

if (this._compositeType === CompositeTypes.PureClass) {
    shouldUpdate = !shallowEqual(prevProps, nextProps) || ! shallowEqual(inst.state, nextState);
}

shallowEqual對應方法原始碼如下:

const hasOwnProperty = Object.prototype.hasOwnProperty;

/**
 * is 方法來判斷兩個值是否是相等的值,為何這麼寫可以移步 MDN 的文件
 * https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/is
 */
function is(x: mixed, y: mixed): boolean {
  if (x === y) {
    return x !== 0 || y !== 0 || 1 / x === 1 / y;
  } else {
    return x !== x && y !== y;
  }
}

function shallowEqual(objA: mixed, objB: mixed): boolean {
  // 首先對基本型別進行比較
  if (is(objA, objB)) {
    return true;
  }

  if (typeof objA !== 'object' || objA === null ||
      typeof objB !== 'object' || objB === null) {
    return false;
  }

  const keysA = Object.keys(objA);
  const keysB = Object.keys(objB);

  // 長度不相等直接返回false
  if (keysA.length !== keysB.length) {
    return false;
  }

  // key相等的情況下,再去迴圈比較
  for (let i = 0; i < keysA.length; i++) {
    if (
      !hasOwnProperty.call(objB, keysA[i]) ||
      !is(objA[keysA[i]], objB[keysA[i]])
    ) {
      return false;
    }
  }

  return true;
}

10.2.3 React.memo

React.memo用來快取元件的渲染,避免不必要的更新,其實也是一個高階元件,與 PureComponent 十分類似。但不同的是, React.memo 只能用於函式元件。

import { memo } from 'react';

function Button(props) {
  // Component code
}

export default memo(Button);

如果需要深層次比較,這時候可以給memo第二個引數傳遞比較函式。

function arePropsEqual(prevProps, nextProps) {
  // your code
  return prevProps === nextProps;
}

export default memo(Button, arePropsEqual);

10.3 總結

在實際開發過程中,前端效能問題是一個必須考慮的問題,隨著業務的複雜,遇到效能問題的概率也在增高。

除此之外,建議將頁面進行更小的顆粒化,如果一個過大,當狀態發生修改的時候,就會導致整個大元件的渲染,而對元件進行拆分後,粒度變小了,也能夠減少子元件不必要的渲染。

十一、對Fiber架構的理解

11.1 背景

JavaScript 引擎和頁面渲染引擎兩個執行緒是互斥的,當其中一個執行緒執行時,另一個執行緒只能掛起等待。如果 JavaScript 執行緒長時間地佔用了主執行緒,那麼渲染層面的更新就不得不長時間地等待,介面長時間不更新,會導致頁面響應度變差,使用者可能會感覺到卡頓。

而這也正是 React 15 的 Stack Reconciler 所面臨的問題,當 React 在渲染元件時,從開始到渲染完成整個過程是一氣呵成的,無法中斷。如果元件較大,那麼js執行緒會一直執行,然後等到整棵VDOM樹計算完成後,才會交給渲染的執行緒。這就會導致一些使用者互動、動畫等任務無法立即得到處理,導致卡頓的情況。

11.2 React Fiber

eact Fiber 是 Facebook 花費兩年餘時間對 React 做出的一個重大改變與優化,是對 React 核心演算法的一次重新實現。從Facebook在 React Conf 2017 會議上確認,React Fiber 在React 16 版本釋出。

在React中,主要做了以下的操作:

  • 為每個增加了優先順序,優先順序高的任務可以中斷低優先順序的任務。然後再重新,注意是重新執行優先順序低的任務。
  • 增加了非同步任務,呼叫requestIdleCallback api,瀏覽器空閒的時候執行。
  • dom diff樹變成了連結串列,一個dom對應兩個fiber(一個連結串列),對應兩個佇列,這都是為找到被中斷的任務,重新執行。

從架構角度來看,Fiber 是對 React 核心演算法(即調和過程)的重寫。從編碼角度來看,Fiber 是 React 內部所定義的一種資料結構,它是 Fiber 樹結構的節點單位,也就是 React 16 新架構下的虛擬DOM。

一個 fiber 就是一個 JavaScript 物件,包含了元素的資訊、該元素的更新操作佇列、型別,其資料結構如下:

type Fiber = {
  // 用於標記fiber的WorkTag型別,主要表示當前fiber代表的元件型別如FunctionComponent、ClassComponent等
  tag: WorkTag,
  // ReactElement裡面的key
  key: null | string,
  // ReactElement.type,呼叫`createElement`的第一個引數
  elementType: any,
  // The resolved function/class/ associated with this fiber.
  // 表示當前代表的節點型別
  type: any,
  // 表示當前FiberNode對應的element元件例項
  stateNode: any,

  // 指向他在Fiber節點樹中的`parent`,用來在處理完這個節點之後向上返回
  return: Fiber | null,
  // 指向自己的第一個子節點
  child: Fiber | null,
  // 指向自己的兄弟結構,兄弟節點的return指向同一個父節點
  sibling: Fiber | null,
  index: number,

  ref: null | (((handle: mixed) => void) & { _stringRef: ?string }) | RefObject,

  // 當前處理過程中的元件props物件
  pendingProps: any,
  // 上一次渲染完成之後的props
  memoizedProps: any,

  // 該Fiber對應的元件產生的Update會存放在這個佇列裡面
  updateQueue: UpdateQueue<any> | null,

  // 上一次渲染的時候的state
  memoizedState: any,

  // 一個列表,存放這個Fiber依賴的context
  firstContextDependency: ContextDependency<mixed> | null,

  mode: TypeOfMode,

  // Effect
  // 用來記錄Side Effect
  effectTag: SideEffectTag,

  // 單連結串列用來快速查詢下一個side effect
  nextEffect: Fiber | null,

  // 子樹中第一個side effect
  firstEffect: Fiber | null,
  // 子樹中最後一個side effect
  lastEffect: Fiber | null,

  // 代表任務在未來的哪個時間點應該被完成,之後版本改名為 lanes
  expirationTime: ExpirationTime,

  // 快速確定子樹中是否有不在等待的變化
  childExpirationTime: ExpirationTime,

  // fiber的版本池,即記錄fiber更新過程,便於恢復
  alternate: Fiber | null,
}

11.3 解決方案

Fiber把渲染更新過程拆分成多個子任務,每次只做一小部分,做完看是否還有剩餘時間,如果有繼續下一個任務;如果沒有,掛起當前任務,將時間控制權交給主執行緒,等主執行緒不忙的時候在繼續執行。

即可以中斷與恢復,恢復後也可以複用之前的中間狀態,並給不同的任務賦予不同的優先順序,其中每個任務更新單元為 React Element 對應的 Fiber 節點。

實現的上述方式的是requestIdleCallback方法,window.requestIdleCallback()方法將在瀏覽器的空閒時段內呼叫的函式排隊。這使開發者能夠在主事件迴圈上執行後臺和低優先順序工作,而不會影響延遲關鍵事件,如動畫和輸入響應。

首先 ,React 中任務切割為多個步驟,分批完成。在完成一部分任務之後,將控制權交回給瀏覽器,讓瀏覽器有時間再進行頁面的渲染。等瀏覽器忙完之後有剩餘時間,再繼續之前 React 未完成的任務,是一種合作式排程。

該實現過程是基於 Fiber 節點實現,作為靜態的資料結構來說,每個 Fiber 節點對應一個 React element,儲存了該元件的型別(函式元件/類元件/原生元件等等)、對應的 DOM 節點等資訊。作為動態的工作單元來說,每個 Fiber 節點儲存了本次更新中該元件改變的狀態、要執行的工作。

每個 Fiber 節點有個對應的 React element,多個 Fiber 節點根據如下三個屬性構建一顆樹。

// 指向父級Fiber節點
this.return = null
// 指向子Fiber節點
this.child = null
// 指向右邊第一個兄弟Fiber節點
this.sibling = null

十二、 React 效能優化的手段有哪些

12.1 render渲染

React憑藉virtual DOM和diff演算法擁有高效的效能,但是某些情況下,效能明顯可以進一步提高。我們知道,類元件通過呼叫setState方法, 就會導致render,父元件一旦發生render渲染,子元件一定也會執行render渲染。當我們想要更新一個子元件的時候,如更新的綠色部分的內容:
在這裡插入圖片描述
理想狀態下,我們只呼叫該路徑下的元件render,執行對應元件的渲染即可。
在這裡插入圖片描述
不過,React的預設做法是呼叫所有元件的render,再對生成的虛擬DOM進行對比。
在這裡插入圖片描述
因此,預設的做法是非常浪費效能的。

12.2 優化方案

蔚來避免不必要的render,我們前面介紹了可以通過shouldComponentUpdate、PureComponent、React.memo來進行優化。除此之外,效能優化常見的還有如下一些:

  • 避免使用行內函數
  • 使用 React Fragments 避免額外標記
  • 使用 Immutable
  • 懶載入元件
  • 事件繫結方式
  • 服務端渲染

12.2.1 避免使用行內函數

如果我們使用行內函數,則每次呼叫render函式時都會建立一個新的函式例項,比如:

import React from "react";

export default class InlineFunctionComponent extends React.Component {
  render() {
    return (
      <div>
        <h1>Welcome Guest</h1>
        <input type="button" onClick={(e) => { this.setState({inputValue: e.target.value}) }} value="Click For Inline Function" />
      </div>
    )
  }
}

正確的做法是,應該在元件內部建立一個函式,並將事件繫結到該函式本身。這樣每次呼叫 render 時就不會建立單獨的函式例項。

import React from "react";

export default class InlineFunctionComponent extends React.Component {
  
  setNewStateData = (event) => {
    this.setState({
      inputValue: e.target.value
    })
  }
  
  render() {
    return (
      <div>
        <h1>Welcome Guest</h1>
        <input type="button" onClick={this.setNewStateData} value="Click For Inline Function" />
      </div>
    )
  }
}

12.2.2 使用 React Fragments 避免額外標記

使用者建立新元件時,每個元件應具有單個父標籤。父級不能有兩個標籤,所以頂部要有一個公共標籤,所以我們經常在元件頂部新增額外標籤div。

這個額外標籤除了充當父標籤之外,並沒有其他作用,這時候則可以使用fragement。其不會向元件引入任何額外標記,但它可以作為父級標籤的作用。

export default class NestedRoutingComponent extends React.Component {
    render() {
        return (
            <>
                <h1>This is the Header Component</h1>
                <h2>Welcome To Demo Page</h2>
            </>
        )
    }
}

12.2.3 懶載入元件

從工程方面考慮,webpack存在程式碼拆分能力,可以為應用建立多個包,並在執行時動態載入,減少初始包的大小。而在react中使用到了Suspense 和 lazy元件實現程式碼拆分功能,基本使用如下:

const johanComponent = React.lazy(() => import(/* webpackChunkName: "johanComponent" */ './myAwesome.component'));
 
export const johanAsyncComponent = props => (
  <React.Suspense fallback={<Spinner />}>
    <johanComponent {...props} />
  </React.Suspense>
);

12.2.4 服務端渲染

採用服務端渲染端方式,可以使使用者更快的看到渲染完成的頁面。服務端渲染,需要起一個node服務,可以使用express、koa等,呼叫react的renderToString方法,將根元件渲染成字串,再輸出到響應中:

import { renderToString } from "react-dom/server";
import MyPage from "./MyPage";
app.get("/", (req, res) => {
  res.write("<!DOCTYPE html><html><head><title>My Page</title></head><body>");
  res.write("<div id='content'>");  
  res.write(renderToString(<MyPage/>));
  res.write("</div></body></html>");
  res.end();
});

然後,客戶端使用render方法來生成HTML即可。

import ReactDOM from 'react-dom';
import MyPage from "./MyPage";
ReactDOM.render(<MyPage />, document.getElementById('app'));

十三、React服務端渲染

13.1 什麼是服務端渲染

伺服器渲染指的是由服務側完成頁面的 HTML 結構拼接的頁面處理技術,傳送到瀏覽器,然後為其繫結狀態與事件,成為完全可互動頁面的過程。其解決的問題主要有兩個:

  • SEO,由於搜尋引擎爬蟲抓取工具可以直接檢視完全渲染的頁面
  • 加速首屏載入,解決首屏白屏問題

在這裡插入圖片描述

13.2 怎麼做

在React中,實現SSR主要有兩種形式:

  • 手動搭建一個 SSR 框架
  • 使用成熟的SSR 框架,如 Next.JS

下面以手動搭建一個 SSR 框架來說明怎麼實現SSR。首先,通過express啟動一個app.js檔案,用於監聽3000埠的請求,當請求根目錄時,返回HTML,如下:

const express = require('express')
const app = express()
app.get('/', (req,res) => res.send(`
<html>
   <head>
       <title>ssr demo</title>
   </head>
   <body>
       Hello world
   </body>
</html>
`))

app.listen(3000, () => console.log('Exampleapp listening on port 3000!'))

然後,在伺服器中編寫React程式碼,在app.js中進行應引用:

import React from 'react'

const Home = () =>{

    return <div>home</div>

}

export default Home

為了讓伺服器能夠識別JSX,這裡需要使用webpakc對專案進行打包轉換,建立一個配置檔案webpack.server.js並進行相關配置,如下所示。

const path = require('path')    //node的path模組
const nodeExternals = require('webpack-node-externals')

module.exports = {
    target:'node',
    mode:'development',           //開發模式
    entry:'./app.js',             //入口
    output: {                     //打包出口
        filename:'bundle.js',     //打包後的檔名
        path:path.resolve(__dirname,'build')    //存放到根目錄的build資料夾
    },
    externals: [nodeExternals()],  //保持node中require的引用方式
    module: {
        rules: [{                  //打包規則
           test:   /\.js?$/,       //對所有js檔案進行打包
           loader:'babel-loader',  //使用babel-loader進行打包
           exclude: /node_modules/,//不打包node_modules中的js檔案
           options: {
               presets: ['react','stage-0',['env', { 
                                  //loader時額外的打包規則,對react,JSX,ES6進行轉換
                    targets: {
                        browsers: ['last 2versions']   //對主流瀏覽器最近兩個版本進行相容
                    }
               }]]
           }
       }]
    }
}

接著,藉助react-dom提供了服務端渲染的 renderToString方法,負責把React元件解析成Html。

import express from 'express'
import React from 'react'//引入React以支援JSX的語法
import { renderToString } from 'react-dom/server'//引入renderToString方法
import Home from'./src/containers/Home'

const app= express()
const content = renderToString(<Home/>)
app.get('/',(req,res) => res.send(`
<html>
   <head>
       <title>ssr demo</title>
   </head>
   <body>
        ${content}
   </body>
</html>
`))

app.listen(3001, () => console.log('Exampleapp listening on port 3001!'))

上面的過程中,已經能夠成功將元件渲染到了頁面上。但是,像一些事件處理的方法,是無法在服務端完成,因此需要將元件程式碼在瀏覽器中再執行一遍,這種伺服器端和客戶端共用一套程式碼的方式就稱之為同構。重構通俗講就是一套React程式碼在伺服器上執行一遍,到達瀏覽器又執行一遍:

  • 服務端渲染完成頁面結構
  • 瀏覽器端渲染完成事件繫結

瀏覽器實現事件繫結的方式為讓瀏覽器去拉取JS檔案執行,讓JS程式碼來控制,因此需要引入script標籤。通過script標籤為頁面引入客戶端執行的react程式碼,並通過express的static中介軟體為js檔案配置路由,修改如下:

import express from 'express'
import React from 'react'//引入React以支援JSX的語法
import { renderToString } from'react-dom/server'//引入renderToString方法
import Home from './src/containers/Home'
 
const app = express()
app.use(express.static('public'));
//使用express提供的static中介軟體,中介軟體會將所有靜態檔案的路由指向public資料夾
 const content = renderToString(<Home/>)
 
app.get('/',(req,res)=>res.send(`
<html>
   <head>
       <title>ssr demo</title>
   </head>
   <body>
        ${content}
   <script src="/index.js"></script>
   </body>
</html>
`))

 app.listen(3001, () =>console.log('Example app listening on port 3001!'))

然後,在客戶端執行以下react程式碼,新建webpack.client.js作為客戶端React程式碼的webpack配置檔案如下:

const path = require('path')                    //node的path模組

module.exports = {
    mode:'development',                         //開發模式
    entry:'./src/client/index.js',              //入口
    output: {                                   //打包出口
        filename:'index.js',                    //打包後的檔名
        path:path.resolve(__dirname,'public')   //存放到根目錄的build資料夾
    },
    module: {
        rules: [{                               //打包規則
           test:   /\.js?$/,                    //對所有js檔案進行打包
           loader:'babel-loader',               //使用babel-loader進行打包
           exclude: /node_modules/,             //不打包node_modules中的js檔案
           options: {
               presets: ['react','stage-0',['env', {     
                    //loader時額外的打包規則,這裡對react,JSX進行轉換
                    targets: {
                        browsers: ['last 2versions']   //對主流瀏覽器最近兩個版本進行相容
                    }
               }]]
           }
       }]
    }
}

這種方法就能夠簡單實現首頁的React服務端渲染,過程如下圖所示。
在這裡插入圖片描述
通常,一個應用會存在路由的情況,配置資訊如下:

import React from 'react'                   //引入React以支援JSX
import { Route } from 'react-router-dom'    //引入路由
import Home from './containers/Home'        //引入Home元件

export default (
    <div>
        <Route path="/" exact component={Home}></Route>
    </div>
)

然後,可以通過index.js引用路由資訊,如下:

import React from 'react'
import ReactDom from 'react-dom'
import { BrowserRouter } from'react-router-dom'
import Router from'../Routers'

const App= () => {
    return (
        <BrowserRouter>
           {Router}
        </BrowserRouter>
    )
}

ReactDom.hydrate(<App/>, document.getElementById('root'))

這時候,控制檯會存在報錯資訊,原因在於每個Route元件外面包裹著一層div,但服務端返回的程式碼中並沒有這個div。解決方法只需要將路由資訊在服務端執行一遍,使用使用StaticRouter來替代BrowserRouter,通過context進行引數傳遞。

import express from 'express'
import React from 'react'//引入React以支援JSX的語法
import { renderToString } from 'react-dom/server'//引入renderToString方法
import { StaticRouter } from 'react-router-dom'
import Router from '../Routers'
 
const app = express()
app.use(express.static('public'));
//使用express提供的static中介軟體,中介軟體會將所有靜態檔案的路由指向public資料夾

app.get('/',(req,res)=>{
    const content  = renderToString((
        //傳入當前path
        //context為必填引數,用於服務端渲染引數傳遞
        <StaticRouter location={req.path} context={{}}>
           {Router}
        </StaticRouter>
    ))
    res.send(`
   <html>
       <head>
           <title>ssr demo</title>
       </head>
       <body>
       <div id="root">${content}</div>
       <script src="/index.js"></script>
       </body>
   </html>
    `)
})


app.listen(3001, () => console.log('Exampleapp listening on port 3001!'))

13.3 總結

整體React服務端渲染原理並不複雜,具體如下:
Node server 接收客戶端請求,得到當前的請求url 路徑,然後在已有的路由表內查詢到對應的元件,拿到需要請求的資料,將資料作為 props、context或者store 形式傳入元件。

然後,基於 React 內建的服務端渲染方法 renderToString()把元件渲染為 html字串在把最終的 html 進行輸出前需要將資料注入到瀏覽器端.

瀏覽器開始進行渲染和節點對比,然後執行完成元件內事件繫結和一些互動,瀏覽器重用了服務端輸出的 html 節點,整個流程結束。

相關文章