从设计者的角度看 React

翻译:疯狂的技术宅
原文: https://overreacted.io/react-...

本文首发微信公众号:jingchengyideng
欢迎关注,天天都给你推送新鲜的前端技术文章javascript


不少教程都把 React 做为一个 UI 库来引入。这是颇有道理的,由于 React 自己就是一个 UI 库。就像官网上所说的那样。html

React homepage screenshot

我曾经写过关于构建用户界面中遇到的挑战的文章。可是本文将会用另一种方式来说述 React —— 由于它更像是一种编程运行时前端

本文不会教你任何有关如何建立界面的技巧。 可是它可能会帮你更加深刻地理解 React 编程模型。java


⚠️ 注意:若是你还在学习 React ,请移步到官方文档进行学习react

本文将会很是深刻 —— 因此不适合初学者阅读。 在本文中,我会从最佳原则的角度尽量地阐述 React 编程模型。我不会解释如何使用它 —— 而是讲解它的工做原理。git

本文面向有经验的程序员,还有使用过其余 UI 库,但在项目中权衡利弊以后最终选择了 React 的人,我但愿它会对你有所帮助!程序员

一些人用了不少年 React 却从没有考虑过接下来我要讲述的主题。 这绝对是以程序员而不是以设计者的角度来看待 React。但我认为站在两个不一样的角度来从新认识 React 并无什么坏处。github

废话少说,让咱们开始深刻理解 React 吧!web


宿主树

一些程序输出数字。另外一些程序输出诗词。不一样的语言和它们的运行时一般会对特定的一组用例进行优化, 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 ,在渲染的过程当中 <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({ 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 上听到你的声音!感谢你的阅读。


本文首发微信公众号:jingchengyideng

欢迎扫描二维码关注公众号,天天都给你推送新鲜的前端技术文章

欢迎扫描二维码关注公众号,天天都给你推送新鲜的前端技术文章


欢迎阅读本专栏其余高赞文章: