把 React 作為 UI 執行時來使用

第一秩序發表於2019-02-13

翻譯:第一秩序

原文:overreacted.io/react-as-a-…

很多教程都把 React 作為一個 UI 庫來引入。這是很有道理的,因為 React 本身就是一個 UI 庫。就像官網上所說的那樣。

React homepage screenshot:

我曾經寫過關於構建使用者介面中遇到的挑戰的文章。但是本文將會用另外一種方式來講述 React —— 因為它更像是一種程式設計執行時

本文不會教你任何有關如何建立介面的技巧。 但是它可能會幫你更加深入地理解 React 程式設計模型。


⚠️注意:如果你還在學習 React ,請移步到官方文件進行學習

本文將會非常深入 —— 所以不適合初學者閱讀。 在本文中,我會從最佳原則的角度儘可能地闡述 React 程式設計模型。我不會解釋如何使用它 —— 而是講解它的工作原理。

本文面向有經驗的程式設計師,還有使用過其他 UI 庫,但在專案中權衡利弊之後最終選擇了 React 的人,我希望它會對你有所幫助!

一些人用了很多年 React 卻從沒有考慮過接下來我要講述的主題。 這絕對是以程式設計師而不是以設計者的角度來看待 React。但我認為站在兩個不同的角度來重新認識 React 並沒有什麼壞處。

廢話少說,讓我們開始深入理解 React 吧!


宿主樹

一些程式輸出數字。另一些程式輸出詩詞。不同的語言和它們的執行時通常會對特定的一組用例進行優化, React 也不例外。

React 程式通常會輸出一個會隨時間變化的樹。 它有可能是 DOM 樹iOS 檢視層PDF 原語 ,或者是 JSON 物件 。不過通常我們希望用它來展示 UI 。我們稱它為“宿主樹”,因為它往往是 React 之外宿主環境中的一部分 —— 就像 DOM 或 iOS 。宿主樹通常有自己的命令式 API 。而 React 就是它上面的那一層。

所以 React 到底有什麼用呢?非常抽象,它可以幫助你編寫可預測的,並且能夠操控複雜的宿主樹進而響應像使用者互動、網路響應、定時器等外部事件的應用程式。

當一個專業的工具可以施加特定的約束,並且能從中獲益時,它就比一般的工具要好。React 就是這樣的典範,並且它堅持兩個原則:

  • 穩定性。 宿主樹是相對穩定的,大多數情況的更新並不會從根本上改變其整體結構。如果應用程式每秒都會將其所有可互動的元素重新排列為完全不同的組合,那將會變得難以使用。那個按鈕去哪了?為什麼我的螢幕在跳舞?
  • 通用性。 宿主樹可以被拆分為外觀和行為一致的 UI 模式(例如按鈕、列表和頭像)而不是隨機的形狀。

這些原則恰好適用於大多數 UI 。 不過當輸出沒有穩定的“模式”時 React 並不適用。例如,React 也許可以幫你寫一個 Twitter 客戶端,但對於一個 3D 管道螢幕保護程式並沒有太大用處。

宿主例項

宿主樹由節點組成,我們稱之為“宿主例項”。

在 DOM 環境中,宿主例項就是我們通常所說的 DOM 節點 —— 就像當你呼叫 document.createElement('div') 時獲得的物件。在 iOS 中,宿主例項可以是從 JavaScript 到原生檢視唯一標識的值。

宿主例項有它們自己的屬性(例如 domNode.className 或者 view.tintColor )。它們也有可能將其他的宿主例項作為子項。

(這和 React 沒有任何聯絡 — 因為我在講述宿主環境。)

通常會有原生 API 用於操控這些宿主例項。例如,在 DOM 環境中會提供像 appendChildremoveChildsetAttribute 等一系列的 API 。在 React 應用中,通常你不會呼叫這些 API ,因為那是 React 的工作。

渲染器

渲染器告訴 React 如何與特定的宿主環境通訊,以及如何管理它的宿主例項。React DOM、React Native 甚至 Ink 都可以被稱作 React 渲染器。你也可以建立自己的 React 渲染器

React 渲染器能以下面兩種模式之一進行工作。

絕大多數渲染器都被用作“突變”模式。這種模式正是 DOM 的工作方式:我們可以建立一個節點,設定它的屬性,在之後往裡面增加或者刪除子節點。宿主例項是完全可變的。

但 React 也能以”不變“模式工作。這種模式適用於那些並不提供像 appendChild 的 API 而是克隆雙親樹並始終替換掉頂級子樹的宿主環境。在宿主樹級別上的不可變性使得多執行緒變得更加容易。React Fabric 就利用了這一模式。

作為 React 的使用者,你永遠不需要考慮這些模式。我只想強調 React 不僅僅只是從一種模式轉換到另一種模式的介面卡。它的用處在於以一種更好的方式操控宿主例項而不用在意那些低階檢視 API 範例。

React 元素

在宿主環境中,一個宿主例項(例如 DOM 節點)是最小的構建單元。而在 React 中,最小的構建單元是 React 元素。

React 元素是一個普通的 JavaScript 物件。它用來描述一個宿主例項。

// JSX 是用來描述這些物件的語法糖。
// <button className="blue" />
{
  type: 'button',
  props: { className: 'blue' }
}
複製程式碼

React 元素是輕量級的,因為沒有任何宿主例項與它繫結在一起。同樣,它只是對你想要在螢幕上看到的內容的描述。

就像宿主例項一樣,React 元素也能形成一棵樹:

// JSX 是用來描述這些物件的語法糖。
// <dialog>
//   <button className="blue" />
//   <button className="red" />
// </dialog>
{
  type: 'dialog',
  props: {
    children: [{
      type: 'button',
      props: { className: 'blue' }
    }, {
      type: 'button',
      props: { className: 'red' }
    }]
  }
}
複製程式碼

(注意:我省略了一些對此解釋不重要的屬性

但是請記住 React 元素並不是永遠存在的 。它們總是在重建和刪除之間不斷迴圈。

React 元素具有不可變性。例如你不能改變 React 元素中的子元素或者屬性。如果你想要在稍後渲染一些不同的東西,需要從頭建立新的 React 元素樹來描述它。

我喜歡將 React 元素比作電影中放映的每一幀。它們捕捉 UI 在特定的時間點的樣子。它們永遠不會再改變。

入口

每一個 React 渲染器都有一個“入口”。正是那個特定的 API 讓我們告訴 React ,將特定的 React 元素樹渲染到真正的宿主例項中去。

例如,React DOM 的入口就是 ReactDOM.render

ReactDOM.render(
  // { type: 'button', props: { className: 'blue' } }
  <button className="blue" />,
  document.getElementById('container')
);
複製程式碼

當我們呼叫 ReactDOM.render(reactElement, domContainer) 時,我們的意思是:“親愛的 React ,將我的 reactElement 對映到 domContaienr 的宿主樹上去吧。“

React 會檢視 reactElement.type (在我們的例子中是 button )然後告訴 React DOM 渲染器建立對應的宿主例項並設定正確的屬性:

// 在 ReactDOM 渲染器內部(簡化版)
function createHostInstance(reactElement) {
  let domNode = document.createElement(reactElement.type);  
  domNode.className = reactElement.props.className;
  return domNode;
}
複製程式碼

在我們的例子中,React 會這樣做:

let domNode = document.createElement('button');
domNode.className = 'blue';
domContainer.appendChild(domNode);
複製程式碼

如果 React 元素在 reactElement.props.children 中含有子元素,React 會在第一次渲染中遞迴地為它們建立宿主例項。

協調

如果我們用同一個 container 呼叫 ReactDOM.render() 兩次會發生什麼呢?

ReactDOM.render(
  <button className="blue" />,  document.getElementById('container')
);

// ... 之後 ...

// 應該替換掉 button 宿主例項嗎?
// 還是在已有的 button 上更新屬性?
ReactDOM.render(
  <button className="red" />,  document.getElementById('container')
);
複製程式碼

同樣,React 的工作是將 React 元素樹對映到宿主樹上去。確定該對宿主例項做什麼來響應新的資訊有時候叫做協調

有兩種方法可以解決它。簡化版的 React 會丟棄已經存在的樹然後從頭開始建立它:

let domContainer = document.getElementById('container');
// 清除掉原來的樹
domContainer.innerHTML = '';
// 建立新的宿主例項樹
let domNode = document.createElement('button');
domNode.className = 'red';
domContainer.appendChild(domNode);
複製程式碼

但是在 DOM 環境下,這樣的做法效率很低,而且會丟失 focus、selection、scroll 等許多狀態。相反,我們希望 React 這樣做:

let domNode = domContainer.firstChild;
// 更新已有的宿主例項
domNode.className = 'red';
複製程式碼

換句話說,React 需要決定何時更新一個已有的宿主例項來匹配新的 React 元素,何時該重新建立新的宿主例項。

這就引出了一個識別問題。React 元素可能每次都不相同,到底什麼時候才該從概念上引用同一個宿主例項呢?

在我們的例子中,它很簡單。我們之前渲染了 <button> 作為第一個(也是唯一)的子元素,接下來我們想要在同一個地方再次渲染 <button> 。在宿主例項中我們已經有了一個 <button> 為什麼還要重新建立呢?讓我們重用它。

這與 React 如何思考並解決這類問題已經很接近了。

如果相同的元素型別在同一個地方先後出現兩次,React 會重用已有的宿主例項。

這裡有一個例子,其中的註釋大致解釋了 React 是如何工作的:

// let domNode = document.createElement('button');
// domNode.className = 'blue';
// domContainer.appendChild(domNode);
ReactDOM.render(
  <button className="blue" />,
  document.getElementById('container')
);

// 能重用宿主例項嗎?能!(button → button)
// domNode.className = 'red';
ReactDOM.render(
  <button className="red" />,
  document.getElementById('container')
);

// 能重用宿主例項嗎?不能!(button → p)
// domContainer.removeChild(domNode);
// domNode = document.createElement('p');
// domNode.textContent = 'Hello';
// domContainer.appendChild(domNode);
ReactDOM.render(
  <p>Hello</p>,
  document.getElementById('container')
);

// 能重用宿主例項嗎?能!(p → p)
// domNode.textContent = 'Goodbye';
ReactDOM.render(
  <p>Goodbye</p>,
  document.getElementById('container')
);
複製程式碼

同樣的啟發式方法也適用於子樹。例如,當我們在 <dialog> 中新增兩個 <button> ,React 會先決定是否要重用 <dialog> ,然後為每一個子元素重複這個決定步驟。

條件

如果 React 在渲染更新前後只重用那些元素型別匹配的宿主例項,那當遇到包含條件語句的內容時又該如何渲染呢?

假設我們只想首先展示一個輸入框,但之後要在它之前渲染一條資訊:

// 第一次渲染
ReactDOM.render(
  <dialog>
    <input />
  </dialog>,
  domContainer
);

// 下一次渲染
ReactDOM.render(
  <dialog>
    <p>I was just added here!</p>    
      <input />
  </dialog>,
  domContainer
);
複製程式碼

在這個例子中,<input> 宿主例項會被重新建立。React 會遍歷整個元素樹,並將其與先前的版本進行比較:

  • dialog → dialog :能重用宿主例項嗎?能 — 因為型別是匹配的。
    • input → p :能重用宿主例項嗎?不能,型別改變了! 需要刪除已有的 input 然後重新建立一個 p 宿主例項。
    • (nothing) → input :需要重新建立一個 input 宿主例項。

因此,React 會像這樣執行更新:

let oldInputNode = dialogNode.firstChild;
dialogNode.removeChild(oldInputNode);

let pNode = document.createElement('p');
pNode.textContent = 'I was just added here!';
dialogNode.appendChild(pNode);

let newInputNode = document.createElement('input');
dialogNode.appendChild(newInputNode);
複製程式碼

這樣的做法並不科學因為事實上 <input> 並沒有被 <p> 所替代 — 它只是移動了位置而已。我們不希望因為重建 DOM 而丟失了 selection、focus 等狀態以及其中的內容。

雖然這個問題很容易解決(在下面我會馬上講到),但這個問題在 React 應用中並不常見。而當我們探討為什麼會這樣時卻很有意思。

事實上,你很少會直接呼叫 ReactDOM.render 。相反,在 React 應用中程式往往會被拆分成這樣的函式:

function Form({ showMessage }) {
  let message = null;
  if (showMessage) {
    message = <p>I was just added here!</p>;
  }
  return (
    <dialog>
      {message}
      <input />
    </dialog>
  );
}
複製程式碼

這個例子並不會遇到剛剛我們所描述的問題。讓我們用物件註釋而不是 JSX 也許可以更好地理解其中的原因。來看一下 dialog 中的子元素樹:

function Form({ showMessage }) {
  let message = null;
  if (showMessage) {
    message = {
      type: 'p',
      props: { children: 'I was just added here!' }
    };
  }
  return {
    type: 'dialog',
    props: {
      children: [
        message,
        { type: 'input', props: {} }
      ]
    }
  };
}
複製程式碼

不管 showMessage 是 true 還是 false ,在渲染的過程中 總是在第二個孩子的位置且不會改變。

如果 showMessagefalse 改變為 true ,React 會遍歷整個元素樹,並與之前的版本進行比較:

  • dialog → dialog :能夠重用宿主例項嗎?能 — 因為型別匹配。
    • (null) → p :需要插入一個新的 p 宿主例項。
    • input → input :能夠重用宿主例項嗎?能 — 因為型別匹配。

之後 React 大致會像這樣執行程式碼:

let inputNode = dialogNode.firstChild;
let pNode = document.createElement('p');
pNode.textContent = 'I was just added here!';
dialogNode.insertBefore(pNode, inputNode);
複製程式碼

這樣一來輸入框中的狀態就不會丟失了。

列表

比較樹中同一位置的元素型別對於是否該重用還是重建相應的宿主例項往往已經足夠。

但這隻適用於當子元素是靜止的並且不會重排序的情況。在上面的例子中,即使 message 不存在,我們仍然知道輸入框在訊息之後,並且再沒有其他的子元素。

而當遇到動態列表時,我們不能確定其中的順序總是一成不變的。

function ShoppingList({ list }) {
  return (
    <form>
      {list.map(item => (
        <p>
          You bought {item.name}
          <br />
          Enter how many do you want: <input />
        </p>
      ))}
    </form>
  )
}
複製程式碼

如果我們的商品列表被重新排序了,React 只會看到所有的 p 以及裡面的 input 擁有相同的型別,並不知道該如何移動它們。(在 React 看來,雖然這些商品本身改變了,但是它們的順序並沒有改變。)

所以 React 會對這十個商品進行類似如下的重排序:

for (let i = 0; i < 10; i++) {
  let pNode = formNode.childNodes[i];
  let textNode = pNode.firstChild;
  textNode.textContent = 'You bought ' + items[i].name;
}
複製程式碼

React 只會對其中的每個元素進行更新而不是將其重新排序。這樣做會造成效能上的問題和潛在的 bug 。例如,當商品列表的順序改變時,原本在第一個輸入框的內容仍然會存在於現在的第一個輸入框中 — 儘管事實上在商品列表裡它應該代表著其他的商品!

這就是為什麼每次當輸出中包含元素陣列時,React 都會讓你指定一個叫做 key 的屬性:

function ShoppingList({ list }) {
  return (
    <form>
      {list.map(item => (
        <p key={item.productId}>
          You bought {item.name}
          <br />
          Enter how many do you want: <input />
        </p>
      ))}
    </form>
  )
}
複製程式碼

key 給予 React 判斷子元素是否真正相同的能力,即使在渲染前後它在父元素中的位置不是相同的。

當 React 在 <form> 中發現 <p key="42"> ,它就會檢查之前版本中的 <form> 是否同樣含有 <p key="42"> 。即使 <form> 中的子元素們改變位置後,這個方法同樣有效。在渲染前後當 key 仍然相同時,React 會重用先前的宿主例項,然後重新排序其兄弟元素。

需要注意的是 key 只與特定的父親 React 元素相關聯,比如 <form> 。React 並不會去匹配父元素不同但 key 相同的子元素。(React 並沒有慣用的支援對在不重新建立元素的情況下讓宿主例項在不同的父元素之間移動。)

key 賦予什麼值最好呢?最好的答案就是:什麼時候你會說一個元素不會改變即使它在父元素中的順序被改變? 例如,在我們的商品列表中,商品本身的 ID 是區別於其他商品的唯一標識,那麼它就最適合作為 key

元件

我們已經知道函式會返回 React 元素:

function Form({ showMessage }) {
  let message = null;
  if (showMessage) {
    message = <p>I was just added here!</p>;
  }
  return (
    <dialog>
      {message}
      <input />
    </dialog>
  );
}
複製程式碼

這些函式被叫做元件。它們讓我們可以打造自己的“工具箱”,例如按鈕、頭像、評論框等等。元件就像 React 的麵包和黃油。

元件接受一個引數 — 物件雜湊。它包含“props”(“屬性”的簡稱)。在這裡 showMessage 就是一個 prop 。它們就像是具名引數一樣。

純淨

React 元件中對於 props 應該是純淨的。

function Button(props) {
  // ? 沒有作用
  props.isActive = true;
}
複製程式碼

通常來說,突變在 React 中不是慣用的。(我們會在之後講解如何用更慣用的方式來更新 UI 以響應事件。)

不過,區域性的突變是絕對允許的:

function FriendList({ friends }) {
  let items = [];
  for (let i = 0; i < friends.length; i++) {
    let friend = friends[i];
    items.push(
      <Friend key={friend.id} friend={friend} />
    );
  }
  return <section>{items}</section>;
}
複製程式碼

當我們在函式元件內部建立 items 時不管怎樣改變它都行,只要這些突變發生在將其作為最後的渲染結果之前。所以並不需要重寫你的程式碼來避免區域性突變。

同樣地,惰性初始化是被允許的即使它不是完全“純淨”的:

function ExpenseForm() {
  // 只要不影響其他元件這是被允許的:
  SuperCalculator.initializeIfNotReady();

  // 繼續渲染......
}
複製程式碼

只要呼叫元件多次是安全的,並且不會影響其他元件的渲染,React 並不關心你的程式碼是否像嚴格的函數語言程式設計一樣百分百純淨。在 React 中,冪等性比純淨性更加重要。

也就是說,在 React 元件中不允許有使用者可以直接看到的副作用。換句話說,僅呼叫函式式元件時不應該在螢幕上產生任何變化。

遞迴

我們該如何在元件中使用元件?元件屬於函式因此我們可以直接進行呼叫:

let reactElement = Form({ showMessage: true });
ReactDOM.render(reactElement, domContainer);
複製程式碼

然而,在 React 執行時中這並不是慣用的使用元件的方式。

相反,使用元件慣用的方式與我們已經瞭解的機制相同 — 即 React 元素。這意味著不需要你直接呼叫元件函式,React 會在之後為你做這件事情:

// { type: Form, props: { showMessage: true } }
let reactElement = <Form showMessage={true} />;
ReactDOM.render(reactElement, domContainer);
複製程式碼

然後在 React 內部,你的元件會這樣被呼叫:

// React 內部的某個地方
let type = reactElement.type; // Form
let props = reactElement.props; // { showMessage: true }
let result = type(props); // 無論 Form 會返回什麼
複製程式碼

元件函式名稱按照規定需要大寫。當 JSX 轉換時看見 <Form> 而不是 <form> ,它讓物件 type 本身成為識別符號而不是字串:

console.log(<form />.type); // 'form' 字串
console.log(<Form />.type); // Form 函式
複製程式碼

我們並沒有全域性的序號產生器制 — 字面上當我們輸入 <Form> 時代表著 Form 。如果 Form在區域性作用域中並不存在,你會發現一個 JavaScript 錯誤,就像平常你使用錯誤的變數名稱一樣。

因此,當元素型別是一個函式的時候 React 會做什麼呢?它會呼叫你的元件,然後詢問元件想要渲染什麼元素。

這個步驟會遞迴式地執行下去,更詳細的描述在這裡 。總的來說,它會像這樣執行:

  • 你: ReactDOM.render(<App />, domContainer)
  • React: App ,你想要渲染什麼?
    • App :我要渲染包含 <Content><Layout>
  • React: <Layout> ,你要渲染什麼?
    • Layout :我要在 <div> 中渲染我的子元素。我的子元素是 <Content> 所以我猜它應該渲染到 <div> 中去。
  • React: <Content> ,你要渲染什麼?
    • <Content> :我要在 <article> 中渲染一些文字和 <Footer>
  • React: <Footer> ,你要渲染什麼?
    • <Footer> :我要渲染含有文字的 <footer>
  • React: 好的,讓我們開始吧:
// 最終的 DOM 結構
<div>
  <article>
    Some text
    <footer>some more text</footer>
  </article>
</div>
複製程式碼

這就是為什麼我們說協調是遞迴式的。當 React 遍歷整個元素樹時,可能會遇到元素的 type 是一個元件。React 會呼叫它然後繼續沿著返回的 React 元素下行。最終我們會呼叫完所有的元件,然後 React 就會知道該如何改變宿主樹。

在之前已經討論過的相同的協調準則,在這一樣適用。如果在同一位置的 type 改變了(由索引和可選的 key 決定),React 會刪除其中的宿主例項並將其重建。

控制反轉

你也許會好奇:為什麼我們不直接呼叫元件?為什麼要編寫 <Form /> 而不是 Form()

React 能夠做的更好如果它“知曉”你的元件而不是在你遞迴呼叫它們之後生成的 React 元素樹。

// ? React 並不知道 Layout 和 Article 的存在。
// 因為你在呼叫它們。
ReactDOM.render(
  Layout({ children: Article() }),
  domContainer
)

// ✅ React知道 Layout 和 Article 的存在。
// React 來呼叫它們。
ReactDOM.render(
  <Layout><Article /></Layout>,
  domContainer
)
複製程式碼

這是一個關於控制反轉的經典案例。通過讓 React 呼叫我們的元件,我們會獲得一些有趣的屬性:

  • 元件不僅僅只是函式。 React 能夠用在樹中與元件本身緊密相連的區域性狀態等特性來增強元件功能。優秀的執行時提供了與當前問題相匹配的基本抽象。就像我們已經提到過的,React 專門針對於那些渲染 UI 樹並且能夠響應互動的應用。如果你直接呼叫了元件,你就只能自己來構建這些特性了。
  • 元件型別參與協調。 通過 React 來呼叫你的元件,能讓它瞭解更多關於元素樹的結構。例如,當你從渲染 <Feed> 頁面轉到 Profile 頁面,React 不會嘗試重用其中的宿主例項 — 就像你用 <p> 替換掉 <button> 一樣。所有的狀態都會丟失 — 對於渲染完全不同的檢視時,通常來說這是一件好事。你不會想要在 <PasswordForm><MessengerChat> 之間保留輸入框的狀態儘管 <input> 的位置意外地“排列”在它們之間。
  • React 能夠推遲協調。 如果讓 React 控制呼叫你的元件,它能做很多有趣的事情。例如,它可以讓瀏覽器在元件呼叫之間做一些工作,這樣重渲染大體量的元件樹時就不會阻塞主執行緒。想要手動編排這個過程而不依賴 React 的話將會十分困難。
  • 更好的可除錯性。 如果元件是庫中所重視的一等公民,我們就可以構建豐富的開發者工具,用於開發中的自省。

讓 React 呼叫你的元件函式還有最後一個好處就是惰性求值。讓我們看看它是什麼意思。

惰性求值

當我們在 JavaScript 中呼叫函式時,引數往往在函式呼叫之前被執行。

// (2) 它會作為第二個計算
eat(
  // (1) 它會首先計算
  prepareMeal()
);
複製程式碼

這通常是 JavaScript 開發者所期望的因為 JavaScript 函式可能有隱含的副作用。如果我們呼叫了一個函式,但直到它的結果不知怎地被“使用”後該函式仍沒有執行,這會讓我們感到十分詫異。

但是,React 元件是相對純淨的。如果我們知道它的結果不會在螢幕上出現,則完全沒有必要執行它。

考慮下面這個含有 <Comments><Page> 元件:

function Story({ currentUser }) {
  // return {
  //   type: Page,
  //   props: {
  //     user: currentUser,
  //     children: { type: Comments, props: {} }
  //   }
  // }
  return (
    <Page user={currentUser}>
      <Comments />
    </Page>
  );
}
複製程式碼

<Page> 元件能夠在 <Layout> 中渲染傳遞給它的子項:

function Page({ user, children }) {
  return (
    <Layout>
      {children}
    </Layout>
  );
}
複製程式碼

(在 JSX 中 <A><B /></A><A children={<B />} />相同。)

但是要是存在提前返回的情況呢?

function Page({ user, children }) {
  if (!user.isLoggedIn) {
    return <h1>Please log in</h1>;
  }
  return (
    <Layout>
      {children}
    </Layout>
  );
}
複製程式碼

如果我們像函式一樣呼叫 Commonts() ,不管 Page 是否想渲染它們都會被立即執行:

// {
//   type: Page,
//   props: {
//     children: Comments() // Always runs!
//   }
// }
<Page>
  {Comments()}
</Page>
複製程式碼

但是如果我們傳遞的是一個 React 元素,我們不需要自己執行 Comments

// {
//   type: Page,
//   props: {
//     children: { type: Comments }
//   }
// }
<Page>
  <Comments />
</Page>
複製程式碼

讓 React 來決定何時以及是否呼叫元件。如果我們的的 Page 元件忽略自身的 childrenprop 且相反地渲染了 <h1>Please login</h1> ,React 不會嘗試去呼叫 Comments 函式。重點是什麼?

這很好,因為它既可以讓我們避免不必要的渲染也能使我們的程式碼變得不那麼脆弱。(當使用者退出登入時,我們並不在乎 Comments 是否被丟棄 — 因為它從沒有被呼叫過。)

狀態

我們先前提到過關於協調和在樹中元素概念上的“位置”是如何讓 React 知曉是該重用宿主例項還是該重建它。宿主例項能夠擁有所有相關的區域性狀態:focus、selection、input 等等。我們想要在渲染更新概念上相同的 UI 時保留這些狀態。我們也想可預測性地摧毀它們,當我們在概念上渲染的是完全不同的東西時(例如從 <SignupForm> 轉換到 <MessengerChat>)。

區域性狀態是如此有用,以至於 React 讓你的元件也能擁有它。 元件仍然是函式但是 React 用對構建 UI 有好處的許多特性增強了它。在樹中每個元件所繫結的區域性狀態就是這些特性之一。

我們把這些特性叫做 Hooks 。例如,useState 就是一個 Hook 。

function Example() {
  const [count, setCount] = useState(0);

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

它返回一對值:當前的狀態和更新該狀態的函式。

陣列的解構語法讓我們可以給狀態變數自定義名稱。例如,我在這裡稱它們為 countsetCount ,但是它們也可以被稱作 bananasetBanana 。在這些文字之下,我們會用 setState 來替代第二個值無論它在具體的例子中被稱作什麼。

(你能在 React 文件 中學習到更多關於 useState 和 其他 Hooks 的知識。)

一致性

即使我們想將協調過程本身分割成非阻塞的工作塊,我們仍然需要在同步的迴圈中對真實的宿主例項進行操作。這樣我們才能保證使用者不會看見半更新狀態的 UI ,瀏覽器也不會對使用者不應看到的中間狀態進行不必要的佈局和樣式的重新計算。

這也是為什麼 React 將所有的工作分成了”渲染階段“和”提交階段“的原因。渲染階段 是當 React 呼叫你的元件然後進行協調的時段。在此階段進行干涉是安全的且在未來這個階段將會變成非同步的。提交階段 就是 React 操作宿主樹的時候。而這個階段永遠是同步的。

快取

當父元件通過 setState 準備更新時,React 預設會協調整個子樹。因為 React 並不知道在父元件中的更新是否會影響到其子代,所以 React 預設保持一致性。這聽起來會有很大的效能消耗但事實上對於小型和中型的子樹來說,這並不是問題。

當樹的深度和廣度達到一定程度時,你可以讓 React 去快取子樹並且重用先前的渲染結果當 prop 在淺比較之後是相同時:

function Row({ item }) {
  // ...
}

export default React.memo(Row);
複製程式碼

現在,在父元件 <Table> 中呼叫 setState 時如果 <Row> 中的 item 與先前渲染的結果是相同的,React 就會直接跳過協調的過程。

你可以通過 useMemo() Hook 獲得單個表示式級別的細粒度快取。該快取於其相關的元件緊密聯絡在一起,並且將與區域性狀態一起被銷燬。它只會保留最後一次計算的結果。

預設情況下,React 不會故意快取元件。許多元件在更新的過程中總是會接收到不同的 props ,所以對它們進行快取只會造成淨虧損。

原始模型

令人諷刺地是,React 並沒有使用“反應式”的系統來支援細粒度的更新。換句話說,任何在頂層的更新只會觸發協調而不是區域性更新那些受影響的元件。

這樣的設計是有意而為之的。對於 web 應用來說互動時間是一個關鍵指標,而通過遍歷整個模型去設定細粒度的監聽器只會浪費寶貴的時間。此外,在很多應用中互動往往會導致或小(按鈕懸停)或大(頁面轉換)的更新,因此細粒度的訂閱只會浪費記憶體資源。

React 的設計原則之一就是它可以處理原始資料。如果你擁有從網路請求中獲得的一組 JavaScript 物件,你可以將其直接交給元件而無需進行預處理。沒有關於可以訪問哪些屬性的問題,或者當結構有所變化時造成的意外的效能缺損。React 渲染是 O(檢視大小) 而不是 O(模型大小) ,並且你可以通過 windowing 顯著地減少檢視大小。

有那麼一些應用細粒度訂閱對它們來說是有用的 — 例如股票程式碼。這是一個極少見的例子,因為“所有的東西都需要在同一時間內持續更新”。雖然命令式的方法能夠優化此類程式碼,但 React 並不適用於這種情況。同樣的,如果你想要解決該問題,你就得在 React 之上自己實現細粒度的訂閱。

注意,即使細粒度訂閱和“反應式”系統也無法解決一些常見的效能問題。 例如,渲染一棵很深的樹(在每次頁面轉換的時候發生)而不阻塞瀏覽器。改變跟蹤並不會讓它變得更快 — 這樣只會讓其變得更慢因為我們執行了額外的訂閱工作。另一個問題是我們需要等待返回的資料在渲染檢視之前。在 React 中,我們用併發渲染來解決這些問題。

批量更新

一些元件也許想要更新狀態來響應同一事件。下面這個例子是假設的,但是卻說明了一個常見的模式:

function Parent() {
  let [count, setCount] = useState(0);
  return (
    <div onClick={() => setCount(count + 1)}>
      Parent clicked {count} times
      <Child />
    </div>
  );
}

function Child() {
  let [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount(count + 1)}>
      Child clicked {count} times
    </button>
  );
}
複製程式碼

當事件被觸發時,子元件的 onClick 首先被觸發(同時觸發了它的 setState )。然後父元件在它自己的 onClick 中呼叫 setState

如果 React 立即重渲染元件以響應 setState 呼叫,最終我們會重渲染子元件兩次:

*** 進入 React 瀏覽器 click 事件處理過程 ***
Child (onClick)
  - setState
  - re-render Child // ? 不必要的重渲染Parent (onClick)
  - setState
  - re-render Parent
  - re-render Child*** 結束 React 瀏覽器 click 事件處理過程 ***
複製程式碼

第一次 Child 元件渲染是浪費的。並且我們也不會讓 React 跳過 Child 的第二次渲染因為 Parent 可能會傳遞不同的資料由於其自身的狀態更新。

這就是為什麼 React 會在元件內所有事件觸發完成後再進行批量更新的原因:

*** 進入 React 瀏覽器 click 事件處理過程 ***
Child (onClick)
  - setState
Parent (onClick)
  - setState
*** Processing state updates          ***
  - re-render Parent
  - re-render Child
*** 結束 React 瀏覽器 click 事件處理過程  ***
複製程式碼

元件內呼叫 setState 並不會立即執行重渲染。相反,React 會先觸發所有的事件處理器,然後再觸發一次重渲染以進行所謂的批量更新。

批量更新雖然有用但可能會讓你感到驚訝如果你的程式碼這樣寫:

  const [count, setCounter] = useState(0);

  function increment() {
    setCounter(count + 1);
  }

  function handleClick() {
    increment();
    increment();
    increment();
  }
複製程式碼

如果我們將 count 初始值設為 0 ,上面的程式碼只會代表三次 setCount(1) 呼叫。為了解決這個問題,我們給 setState 提供了一個 “updater” 函式作為引數:

  const [count, setCounter] = useState(0);

  function increment() {
    setCounter(c => c + 1);
  }

  function handleClick() {
    increment();
    increment();
    increment();
  }
複製程式碼

React 會將 updater 函式放入佇列中,並在之後按順序執行它們,最終 count 會被設定成 3 並作為一次重渲染的結果。

當狀態邏輯變得更加複雜而不僅僅只是少數的 setState 呼叫時,我建議你使用 useReducer Hook 來描述你的區域性狀態。它就像 “updater” 的升級模式在這裡你可以給每一次更新命名:

  const [counter, dispatch] = useReducer((state, action) => {
    if (action === 'increment') {
      return state + 1;
    } else {
      return state;
    }
  }, 0);

  function handleClick() {
    dispatch('increment');
    dispatch('increment');
    dispatch('increment');
  }
複製程式碼

action 欄位可以是任意值,儘管物件是常用的選擇。

呼叫樹

程式語言的執行時往往有呼叫棧 。當函式 a() 呼叫 b()b() 又呼叫 c() 時,在 JavaScript 引擎中會有像 [a, b, c] 這樣的資料結構來“跟蹤”當前的位置以及接下來要執行的程式碼。一旦 c 函式執行完畢,它的呼叫棧幀就消失了!因為它不再被需要了。我們返回到函式 b 中。當我們結束函式 a 的執行時,呼叫棧就被清空。

當然,React 以 JavaScript 執行當然也遵循 JavaScript 的規則。但是我們可以想象在 React 內部有自己的呼叫棧用來記憶我們當前正在渲染的元件,例如 [App, Page, Layout, Article /* 此刻的位置 */]

React 與通常意義上的程式語言進行時不同因為它針對於渲染 UI 樹,這些樹需要保持“活性”,這樣才能使我們與其進行互動。在第一次 ReactDOM.render() 出現之前,DOM 操作並不會執行。

這也許是對隱喻的延伸,但我喜歡把 React 元件當作 “呼叫樹” 而不是 “呼叫棧” 。當我們呼叫完 Article 元件,它的 React “呼叫樹” 幀並沒有被摧毀。我們需要將區域性狀態儲存以便對映到宿主例項的某個地方

這些“呼叫樹”幀會隨它們的區域性狀態和宿主例項一起被摧毀,但是隻會在協調規則認為這是必要的時候執行。如果你曾經讀過 React 原始碼,你就會知道這些幀其實就是 Fibers

Fibers 是區域性狀態真正存在的地方。當狀態被更新後,React 將其下面的 Fibers 標記為需要進行協調,之後便會呼叫這些元件。

上下文

在 React 中,我們將資料作為 props 傳遞給其他元件。有些時候,大多陣列件需要相同的東西 — 例如,當前選中的可視主題。將它一層層地傳遞會變得十分麻煩。

在 React 中,我們通過 Context 解決這個問題。它就像元件的動態範圍 ,能讓你從頂層傳遞資料,並讓每個子元件在底部能夠讀取該值,當值變化時還能夠進行重新渲染:

const ThemeContext = React.createContext(
  'light' // 預設值作為後備
);

function DarkApp() {
  return (
    <ThemeContext.Provider value="dark">
      <MyComponents />
    </ThemeContext.Provider>
  );
}

function SomeDeeplyNestedChild() {
  // 取決於其子元件在哪裡被渲染
  const theme = useContext(ThemeContext);
  // ...
}
複製程式碼

SomeDeeplyNestedChild 渲染時, useContext(ThemeContext) 會尋找樹中最近的 <ThemeContext.Provider> ,並且使用它的 value

(事實上,React 維護了一個上下文棧當其渲染時。)

如果沒有 ThemeContext.Provider 存在,useContext(ThemeContext) 呼叫的結果就會被呼叫 createContext() 時傳遞的預設值所取代。在上面的例子中,這個值為 'light'

副作用

我們在之前提到過 React 元件在渲染過程中不應該有可觀察到的副作用。但是有些時候副作用確實必要的。我們也許需要進行管理 focus 狀態、用 canvas 畫圖、訂閱資料來源等操作。

在 React 中,這些都可以通過宣告 effect 來完成:

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>
  );
}
複製程式碼

如果可能,React 會推遲執行 effect 直到瀏覽器重新繪製螢幕。這是有好處的因為像訂閱資料來源這樣的程式碼並不會影響互動時間首次繪製時間

(有一個極少使用的 Hook 能夠讓你選擇退出這種行為並進行一些同步的工作。請儘量避免使用它。)

effect 不只執行一次。當元件第一次展示給使用者以及之後的每次更新時它都會被執行。在 effect 中能觸及當前的 props 和 state,例如上文例子中的 count

effect 可能需要被清理,例如訂閱資料來源的例子。在訂閱之後將其清理,effect 能夠返回一個函式:

  useEffect(() => {
    DataSource.addSubscription(handleChange);
    return () => DataSource.removeSubscription(handleChange);
  });
複製程式碼

React 會在下次呼叫該 effect 之前執行這個返回的函式,當然是在元件被摧毀之前。

有些時候,在每次渲染中都重新呼叫 effect 是不符合實際需要的。 你可以告訴 React 如果相應的變數不會改變則跳過此次呼叫:

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  }, [count]);
複製程式碼

但是,這往往會成為過早地優化並會造成一些問題如果你不熟悉 JavaScript 中的閉包是如何工作的話。

例如,下面的這段程式碼是有 bug 的:

  useEffect(() => {
    DataSource.addSubscription(handleChange);
    return () => DataSource.removeSubscription(handleChange);
  }, []);
複製程式碼

它含有 bug 因為 [] 代表著“不再重新執行這個 effect 。”但是這個 effect 中的 handleChange 是被定義在外面的。handleChange 也許會引用任何的 props 或 state :

  function handleChange() {
    console.log(count);
  }
複製程式碼

如果我們不再讓這個 effect 重新呼叫,handleChange 始終會是第一次渲染時的版本,而其中的 count 也永遠只會是 0

為了解決這個問題,請保證你宣告瞭特定的依賴陣列,它包含所有可以改變的東西,即使是函式也不例外:

  useEffect(() => {
    DataSource.addSubscription(handleChange);
    return () => DataSource.removeSubscription(handleChange);
  }, [handleChange]);
複製程式碼

取決於你的程式碼,在每次渲染後 handleChange 都會不同因此你可能仍然會看到不必要的重訂閱。 useCallback 能夠幫你解決這個問題。或者,你可以直接讓它重訂閱。例如瀏覽器中的 addEventListener API 非常快,但為了在元件中避免使用它可能會帶來更多的問題而不是其真正的價值。

(你能在 React 文件 中學到更多關於 useEffect 和其他 Hooks 的知識。)

自定義鉤子

由於 useStateuseEffect 是函式呼叫,因此我們可以將其組合成自己的 Hooks :

function MyResponsiveComponent() {
  const width = useWindowWidth(); // 我們自己的 Hook  
  return (
    <p>Window width is {width}</p>
  );
}

function useWindowWidth() {  
  const [width, setWidth] = useState(window.innerWidth);
  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  });
  return width;
}
複製程式碼

自定義 Hooks 讓不同的元件共享可重用的狀態邏輯。注意狀態本身是不共享的。每次呼叫 Hook 都只宣告瞭其自身的獨立狀態。

(你能在 React 文件 中學習更多關於構建自己的 Hooks 的內容。)

靜態使用順序

你可以把 useState 想象成一個可以定義“React 狀態變數”的語法。它並不是真正的語法,當然,我們仍在用 JavaScript 編寫應用。但是我們將 React 作為一個執行時環境來看待,因為 React 用 JavaScript 來描繪整個 UI 樹,它的特性往往更接近於語言層面。

假設 use 是語法,將其使用在元件函式頂層也就說得通了:

// ? 注意:並不是真的語法
component Example(props) {
  const [count, setCount] = use State(0);

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

當它被放在條件語句中或者元件外時又代表什麼呢?

// ? 注意:並不是真的語法

// 它是誰的...區域性狀態?
const [count, setCount] = use State(0);

component Example() {
  if (condition) {
    // 要是 condition 是 false 時會發生什麼呢?
    const [count, setCount] = use State(0);
  }

  function handleClick() {
    // 要是離開了元件函式會發生什麼?
    // 這和一般的變數又有什麼區別呢?
    const [count, setCount] = use State(0);
  }
複製程式碼

React 狀態和在樹中與其相關的元件緊密聯絡在一起。如果 use 是真正的語法當它在元件函式的頂層呼叫時也能說的通:

// ? 注意:並不是真的語法
component Example(props) {
  // 只在這裡有效
  const [count, setCount] = use State(0);

  if (condition) {
    // 這會是一個語法錯誤
    const [count, setCount] = use State(0);
  }
複製程式碼

這和 import 宣告只在模組頂層有用是一樣的道理。

當然,use 並不是真正的語法。 (它不會帶來很多好處,並且會帶來很多麻煩。)

然而,React 的確期望所有的 Hooks 呼叫只發生在元件的頂部並且不在條件語句中。這些 Hooks 的規則能夠被 linter plugin 所規範。有很多關於這種設計選擇的激烈爭論,但在實踐中我並沒有看到它讓人困惑。我還寫了關於為什麼通常提出的替代方案不起作用的文章。

Hooks 的內部實現其實是連結串列 。當你呼叫 useState 的時候,我們將指標移到下一項。當我們退出元件的“呼叫樹”幀時,會快取該結果的列表直到下次渲染開始。

這篇文章簡要介紹了 Hooks 內部是如何工作的。陣列也許是比連結串列更好解釋其原理的模型:

// 虛擬碼
let hooks, i;
function useState() {
  i++;
  if (hooks[i]) {
    // 再次渲染時
    return hooks[i];
  }
  // 第一次渲染
  hooks.push(...);
}

// 準備渲染
i = -1;
hooks = fiber.hooks || [];
// 呼叫元件
YourComponent();
// 快取 Hooks 的狀態
fiber.hooks = hooks;
複製程式碼

(如果你對它感興趣,完整的程式碼在這裡 。)

這大致就是每個 useState() 如何獲得正確狀態的方式。就像我們之前所知道的,“匹配”對 React 來說並不是什麼新的知識 — 這與協調依賴於在渲染前後元素是否匹配是同樣的道理。

還有哪些遺漏

我們已經觸及到 React 執行時環境中幾乎所有重要的方面。如果你讀完了這篇文章,可能已經比 90% 的開發者更瞭解 React ,沒錯!

當然有一些內容我並沒有提到——主要是因為我們也不太清楚。目前 React 對多道渲染的支援並不太好,即當父元件進行渲染時需要子元件提供的資訊。錯誤處理 API 目前也還沒有關於 Hooks 的內容。將來這兩個問題可能會一起解決。併發模式在目前看來並不穩定,也有很多關於 Suspense 該如何適應當前版本的有趣問題。也許我會在它們要完成的時候再來討論,並且 Suspense 已經準備好比 懶載入 能夠做的更多。

我認為 React API 的成功之處在於,即使在沒有考慮過上面這些大多數主題的情況下,你也能輕鬆使用它並且可以走的很遠。 在大多數情況下,像協調這樣好的預設特性啟發式地為我們做了正確的事情。在你忘記新增 key 這樣的屬性時,React 能夠好心提醒你。

如果你是一個痴迷於 UI 庫的書呆子,我希望這篇文章對你來說會很有趣的,並且深入闡明瞭 React 的工作原理。也許你會覺得 React 太過複雜,所以你不會再去深入理解它。不管怎樣,我都很樂意在 Twitter 上聽到你的聲音!感謝你的閱讀。

相關文章