[譯] 將 React 做爲 UI 運行時

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

原文做者:Dan Abramovhtml

大多數教程把 React 稱做是一個 UI 庫。這是有道理的,由於 React 就是一個 UI 庫。正如官網上的標語所說的那樣。java

React homepage screenshot:

我曾經寫過關於構建用戶界面會遇到的難題一文。可是本篇文章將以一種不一樣的方式來說述 React — 由於它更像是一種編程運行時react

本篇文章不會教你任何有關如何建立用戶界面的技巧。 可是它可能會幫助你更深刻地理解 React 編程模型。git


注意:若是你還在學習 React ,請移步到官方文檔進行學習程序員

⚠️github

本篇文章將會很是深刻 — 因此並不適合初學者閱讀。 在本篇文章中,我會從最佳原則的角度儘量地闡述 React 編程模型。我不會解釋如何使用它 — 而是講解它的原理。web

文章面向有經驗的程序員和那些使用過其餘 UI 庫但在項目中權衡利弊後最終選擇了 React 的人,我但願它會對你有所幫助!npm

許多人成功使用了 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: {} }
      ]
    }
  };
}
複製代碼

無論 showMessagetrue 仍是 false ,在渲染的過程當中 <input> 老是在第二個孩子的位置且不會改變。

若是 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({ currentUser, children }) {
  return (
    <Layout>
      {children}
    </Layout>
  );
}
複製代碼

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

可是要是存在提早返回的狀況呢?

function Page({ currentUser, children }) {
  if (!currentUser.isLoggedIn) {
    return <h1>Please login</h1>;
  }
  return (
    <Layout>
      {children}
    </Layout>
  );
}
複製代碼

若是咱們像函數同樣調用 Commonts() ,無論 Page 是否想渲染它們都會被當即執行:

// {
//   type: Page,
//   props: {
//     children: Comments() // 老是調用!
//   }
// }
<Page>
  {Comments()}
</Page>
複製代碼

可是若是咱們傳遞的是一個 React 元素,咱們不須要本身執行 Comments

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

讓 React 來決定什麼時候以及是否調用組件。若是咱們的的 Page 組件忽略自身的 children prop 且相反地渲染了 <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 已經準備比如 lazy loading 可以作的更多。

我認爲 React API 的成功之處在於,即便在沒有考慮過上面這些大多數主題的狀況下,你也能輕鬆使用它而且能夠走的很遠。 在大多數狀況下,像協調這樣好的默認特性啓發式地爲咱們作了正確的事情。在你忘記添加 key 這樣的屬性時,React 可以好心提醒你。

若是你是癡迷於 UI 庫的書呆子,我但願這篇文章對你來講會頗有趣而且是深刻闡明瞭 React 是如何工做的。又或許你會以爲 React 太過於複雜爲此你不會再去深刻理解它。無論怎樣,我都很樂意在 Twitter 上收到你的消息!謝謝你的閱讀。

相關文章
相關標籤/搜索