React作为一个ui运行

原译文:React作为ui运行
原文: React as a UI Runtime

React as a UI Runtime

大多数的教程介绍React是作为一个ui库。这个是说的通的,因为React是一个UI库。这就是标语所说的!

image

我之前写过关于创建用户界面的挑战。但是这篇文章以不同的方式谈论react — 更像是程序运行时。

这篇文章不会教你任何创建用户界面的东西。 但它可能会帮助你更深入地理解React编程模型。


注意:如果你正在学习React,请查看文档。

⚠️

这是一个深入系列 - 这篇文章对初学者不是那么的友好。 在这篇文章中,我将从首要原理描述大部分的React编程模型。我不会解释如何使用它 - 只会解释它是如何工作的。

本文面向有经验的程序员和从事其他UI库的人,他们询问了在React中选择上的一些权衡。我希望你会觉得本文很有用!

很多人很好的使用了React很多年,没有考虑到这些大多数的主题。 这绝对是一个以程序员为中心的角度,而不是一个以设计师为中心的角度。但我不认为同时拥有这两种资源有什么坏处。

免责声明到此为止,我们开始正题吧!


Host Tree

有些程序输出数字。其他的程序输出诗歌。不同的语言及其运行时通常针对特定的一组用例进行优化,而React也不例外。

React程序通常输出 一个可能随时间变化的树。 它可能是一个 [DOM树](DOM tree), iOS 层次结构,一个PDF原始树,甚至是一个JSON对象。但是,通常我们希望用它来表示一些UI。我们通常叫做"host tree"(主机树),因为他是React之外,主机环境的一部分 -- 就像DOM或IOS。主机树通常有他 自己的 API。

那么React对于什么有用呢?非常抽象地来说,它可以帮助你编写一个可预测地操作复杂主机树的程序,以响应外部事件,如交互,网络响应,计时器等。

当专用工具可以施加特定的约束并从中受益时,它就比通用工具做得更好。React将赌注压在两个原则上:

  • 稳定性 主机树相对稳定,大多数更新不会从根本上改变其整体结构。如果一个应用程序每秒钟都将所有的交互元素重新排列成一个完全不同的组合,那么它将很难使用。那个按钮在去了哪里?为什么我的屏幕在跳舞?

  • 规律 主机树可以分解为外观和行为一致的UI模式(例如按钮,列表,头像),而不是随机形状。

这些原则恰好适用于大多数UI。 但是,当输出中没有稳定的“模式”时,React就不适合了。例如,React可以帮助你编写Twitter客户端,但对于3D管道屏幕保护程序不会非常有用。

Host Instances

主机树由节点组成。我们称之为“主机实例”。

在DOM环境下,主机实例是常规的DOM节点,就像调用document.createElement('div')时获得的对象一样。在iOS上,主机实例可以是唯一标识来自JavaScript的原生视图的值。

主机实例有他们自己的属性(例如,domNode.className或者view.tintColor)。他们还可能包含其他的主机实例作为子项。

(这与React没什么关系 - 我正在描述主机环境。)

通常有一个API来操作主机实例。例如,DOM提供了诸如appendChildremoveChildsetAttribute等API。在React应用程序中,你通常不会去调用这些API。这些都是React的工作。

Renderers

一个 renderer (渲染器) 告诉React与特定主机环境通信并管理其主机实例。React DOM, React Native, 甚至Ink都是React的渲染器。你也可以创建你自己的React渲染器。

React的渲染器可以在两种模式的任一一个下面工作。

绝大多数渲染器都是使用“突变(可变)”模式编写的。这种模式就是DOM的工作方式:我们可以创建一个节点,设置其属性,然后在其中添加或删除子节点。主机实例是完全可变的。

React也可以在“不变的(一贯的)”模式下工作。此模式适用于不提供appendChild()等方法的主机环境,而是克隆父树并始终替顶层子级。主机树级别的不变性使多线程更容易。 [React Fabric](React Fabric)利用了这一点。

作为一个React用户,你永远不需要考虑这些模式。我只想强调这个React不仅仅是从一种模式到另一种模式的适配器。它的有用性与低级视图API范式正交。

React Elements

在主机环境中,主机实例(如DOM节点)是最小的构建块。在React中,最小的构建块是React 元素

React元素是一个普通的JavaScript对象。它可以描述主机实例。

// JSX是下面对象的一个语法糖.
// 

React元素是轻量级的,没有绑定它的主机实例。同样,它仅仅是你想要在屏幕上看到的内容的 描述

与主机实例一样,React元素可以形成树:

// JSX is a syntax sugar for these objects.
// 
//   
{
  type: 'dialog',
  props: {
    children: [{
      type: 'button',
      props: { className: 'blue' }
    }, {
      type: 'button',
      props: { className: 'red' }
    }]
  }
}

(注意:我忽略了一些属性,但是那些对于这里的阐述无关紧要)

但是,请记住,React元素没有自己的不变的(持久)标识。 它们意味着要一直重新创建和抛弃。

React元素是不可变的。例如,你无法更改子项或React元素的属性。如果你想之后渲染不同的东西,你将使用从头创建的新React元素树来描述它。

我喜欢将React元素视为电影中的帧。它们捕获UI在特定时间点应该是什么样子。他们不会改变。

Entry Point

每个React渲染器都有个"入口点"。它是让我们告诉React在容器主机实例中呈现特定React元素树的API。

例如,React DOM入口点是ReactDOM.render

ReactDOM.render(
  // { type: 'button', props: { className: 'blue' } }
  

当我们说ReactDOM.render(reactElement, domContainer),我们的意思是:亲爱的React,让domContainer主机树匹配reactElement。 **

React将会查看reactElement.type(在我们的例子里,是button)并要求React DOM渲染器为其创建一个主机实例并设置属性:

// Somewhere in the ReactDOM renderer (simplified)
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将在第一次渲染时递归地为它们创建宿主实例。

Reconciliation

如果我们用同一个容器调用ReactDOM.render()两次会发生什么?

ReactDOM.render(
  

再次的,react的工作是使主机树与提供的react元素树匹配。为了响应新的信息而确定对主机实例树做什么的过程有时被称为协调(和解)。

有两种方法可以解决它。 React的简化版本可以砍去现有的树并从头开始重新创建它:

let domContainer = document.getElementById('container');
// Clear the tree
domContainer.innerHTML = '';
// Create the new host instance tree
let domNode = document.createElement('button');
domNode.className = 'red';
domContainer.appendChild(domNode);

但是在DOM中,这很慢并且丢失重要信息,如焦点,选择,滚动状态等。相反,我们希望React做这样的事情:

let domNode = domContainer.firstChild;
// Update existing host instance
domNode.className = 'red';

换句话说,React需要决定何时更新现有主机实例以匹配新的React元素,以及何时创建新元素。

这提出了一个身份问题。 React元素每次都可能不同,但什么时候它在概念上引用相同的主机实例?

在我们的例子中,它很简单。我们曾经将

); }

它返回一对值:当前状态和更新它的函数。

数组解构语法允许我们为状态变量赋予任意名称。例如,我叫这个为countsetCount,但它可能是一个bananasetBanana。在下面的文本中,我将使用setState引用第二个值,而不管具体示例中的实际名称。

(可以在此处了解有关React提供的useState和其他Hook的更多信息。)

Consistency

即使我们想要将协调过程本身拆分为非阻塞的工作块,我们仍然应该在单个同步swoop中执行实际的主机树操作。这样我们就可以确保用户不会看到半更新的用户界面,并且浏览器不会对用户不应该看到的中间状态执行不必要的布局和样式重新计算。

这就是为什么React将所有工作分成“渲染阶段”和“提交阶段”。渲染阶段是React调用你的组件并执行和解。中断是安全的,将来会异步。提交阶段是React触及主机树的时间。它始终是同步的。

Memoization

当父级通过调用setState来调度更新时,默认情况下React会和解其整个子树。这是因为React无法知道父级中的更新是否会影响子级,并且默认情况下React选择保持一致。这听起来可能是非常昂贵的代价,但实际上,对于中小型子树来说,这不是问题。

当树变得太深或太宽时,你可以告诉React记住一个子树并在浅比较的prop更改期间重复使用先前的渲染结果:

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

export default React.memo(Row);

现在,在父

组件中的setState将跳过和解其item在引用上等于上次呈现的item的行。

你可以使用useMemo() Hook在单个表达式的级别上获得细粒度的记忆。缓存是组件树位置的本地缓存,将与其本地状态一起销毁。它只保存最后一个项目。

默认情况下,React故意不会记忆组件。许多组件总是收到不同的props,所以记住它们将只是一个损失。

(笔者:对于memoization,具体的可以查看此处)

Raw Models

具有讽刺意味的是,React不使用“反应性”系统进行细粒度的更新。换句话说,顶部的任何更新都会触发和解,而不是只更新受更改影响的组件。

这是一个客观的设计决定。交互时间是Web应用程序中的一个关键指标,遍历模型以建立细粒度的监听会花费宝贵的时间。此外,在许多应用程序中,交互往往会导致小型(按钮悬停)或大型(页面转换)更新,在这种情况下,细粒度订阅会浪费内存资源。

React的核心设计原则之一是它可以处理原始数据。如果从网络接收了大量JavaScript对象,则可以直接将它们泵入组件而无需预处理。对于你可以访问哪些属性,或者当结构发生轻微变化时出现意外的性能悬崖峭壁,目前还不清楚。React渲染是O(视图大小)而不是O(模型大小),你可以通过窗口显着缩小视图大小。

有些类型的应用程序可以使用细粒度订阅 - 例如股票行情。这是“一切都在不断更新的罕见例子”。虽然命令式可以帮助优化此类代码,但React可能不适合此用例。不过,你可以在React之上实现自己的细粒度订阅系统。

请注意,即使细粒度订阅和“反应性”系统也无法解决,也存在常见的性能问题。 例如,在不阻塞浏览器的情况下呈现一个新的深树(每次页面转换时都会发生)。更改跟踪不会让它变得更快 - 它会使速度变慢,因为我们必须做更多的工作来设置订阅。另一个问题是,在开始呈现视图之前,我们必须等待数据。在React中,我们的目标是通过并发渲染来解决这两个问题。

Batching

多个组件可能希望更新状态以响应同一事件。这个例子很复杂,但它说明了一个常见的模式:

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

调度事件时,子项的onClick将首先触发(触发其setState)。然后父进程在其自己的onClick处理程序中调用setState

如果React立即重新渲染组件以响应setState调用,我们最终会将子项渲染两次:

*** Entering React's browser click event handler ***
Child (onClick)
  - setState
  - re-render Child //  不必要
Parent (onClick)
  - setState
  - re-render Parent
  - re-render Child
*** Exiting React's browser click event handler ***

第一个Child渲染将被浪费。而且我们无法让React第二次跳过渲染Child,因为Parent可能会根据其更新状态将一些不同的数据传递给它。

这就是React在事件处理程序中批量更新的原因:

*** Entering React's browser click event handler ***
Child (onClick)
  - setState
Parent (onClick)
  - setState
*** Processing state updates                     ***
  - re-render Parent
  - re-render Child
*** Exiting React's browser click event handler  ***

组件中的setState调用不会立即造成重新渲染。相反,React将首先执行所有事件处理程序,然后触发单个重新渲染,将所有这些更新一起批处理。

批处理有助于提高性能,但如果编写以下代码,则会令人惊讶:

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

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

  function handleClick() {
    increment();
    increment();
    increment();
  }

如果我们开始时设置count0,这些只是三个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将其表示为本地状态reducer。这就像是这个“更新程序”模式的演变,每个更新都有一个名称:

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

  function handleClick() {
    dispatch('increment');
    dispatch('increment');
    dispatch('increment');
  }

action参数可以是任何东西,尽管对象是常见的选择。

Call Tree

编程语言运行通常具有[调用堆栈](call stack)。当一个函数a()调用b()本身调用c()时,在JavaScript引擎的某个地方有一个像[a,b,c]这样的数据结构,它“跟踪”你的位置以及接下来要执行的代码。一旦退出c,它的调用堆栈帧就消失了 - 噗!它不再需要了。我们跳回到b。当我们退出a时,调用堆栈为空。

当然,React本身在JavaScript中运行并遵守JavaScript规则。但我们可以想象内部React有一些自己的调用堆栈来记住我们当前正在渲染的组件,例如: [App, Page, Layout, Article /* we're here */]

React与通用语言运行库不同,因为它旨在呈现UI树。这些树需要“保持活力”,我们才能与它们互动。我们第一次调用ReactDOM.render之后,DOM不会消失。

这可能会延伸这个比喻,但我喜欢将React组件视为“调用树”,而不仅仅是“调用堆栈”。当我们“退出”Article组件时,它的React“call tree”帧不会被破坏。我们需要在某处保留本地状态和对主机实例的引用。

这些“调用树”帧连同它们的本地状态和主机实例一起被销毁,但只有当和解规则说这是必要的时候。如果你读过react源码,你可能会看到这些帧被称为光纤。

纤维是本地状态存在的地方。当状态更新时,react将下面的光纤标记为需要和解,并调用这些组件。

Context

在React中,我们将事物作为props传递给其他组件。有时,大多数组件需要相同的东西 - 例如,当前选择的视觉主题。将它传递到每个级别都很麻烦。

在React中,这是由Context解决的。它基本上类似于组件的动态范围。它就像一个虫洞,让你把东西放在顶部,让底部的每个子项都能阅读它,并在它改变时重新渲染。

const ThemeContext = React.createContext(
  'light' // Default value as a fallback
);

function DarkApp() {
  return (
    
      
    
  );
}

function SomeDeeplyNestedChild() {
  // Depends on where the child is rendered
  const theme = useContext(ThemeContext);
  // ...
}

SomeDeeplyNestedChild呈现时,useContext(ThemeContext)将在树中查找其上方最近的,并使用其value

(实际上,React在呈现时维护上下文堆栈。)

如果上面没有ThemeContext.Provider,则useContext(ThemeContext)调用的结果将是createContext()调用中指定的默认值。在我们的例子中,它是"light"

Effects

我们之前提到过React组件在渲染过程中不应该有可观察到的副作用。但副作用有时是必要的。我们可能想要管理焦点,在画布上绘图,订阅数据源等等。

在React中,这是通过声明一个效果来完成的:

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

  useEffect(() => {    document.title = `You clicked ${count} times`;  });
  return (
    

You clicked {count} times

); }

如果可能,React推迟执行效果,直到浏览器重新绘制屏幕。这很好,因为像数据源订阅这样的代码不应该损害交互时间和首次绘制时间。 (有一个[很少使用](rarely used)的Hook可以让你选择退出这种行为并同步做事。避免它。)

效果不只是运行一次。它们在第一次向用户显示组件之后以及更新之后运行。效果可以关闭当前props和状态,例如上面示例中的count

效果可能需要清理,例如订阅时。要自行清理,效果可以返回一个函数:

useEffect(() => {
    DataSource.addSubscription(handleChange);
    return () => DataSource.removeSubscription(handleChange);
  });

React将在下次应用此效果之前以及在销毁组件之前执行返回的函数。

有时,在每个渲染上重新运行效果可能是不合需要的。如果某些变量没有改变,你可以告诉React跳过应用的效果:

useEffect(() => {
    document.title = `You clicked ${count} times`;
+  }, [count]);

但是,如果你不熟悉JavaScript闭包的工作原理,通常会过早优化并导致问题。

例如,这段代码是错误的:

useEffect(() => {
    DataSource.addSubscription(handleChange);
    return () => DataSource.removeSubscription(handleChange);
  }, []);

这是错误的,因为[]是说“不要重新执行这效果”。但是效果会关闭在其外部定义的handleChange。而handleChange可能引用任何props或状态:

function handleChange() {
    console.log(count);
  }

如果我们永远不让效果重新运行,handleChange将继续指向第一个渲染的版本,并且count内部的计数始终为0

要解决此问题,请确保在指定依赖关系数组时,它包含可以更改的所有内容,包括函数:

useEffect(() => {
    DataSource.addSubscription(handleChange);
    return () => DataSource.removeSubscription(handleChange);
  }, [handleChange]);

根据你的代码,你可能仍会看到不必要的重新订阅,因为每次渲染时handleChange本身都不同。useCallback Hook可以帮助你。或者,可以让它重新订阅。例如,浏览器的addEventListener API速度非常快,为了避免调用它而跳过圈,可能会导致比其意义更大的问题。

(你可以在此处了解有关React提供的useEffect和其他Hook的更多信息。)

Custom Hooks

由于像useStateuseEffect这样的Hook是函数调用,我们可以将它们组成我们自己的Hook:

function MyResponsiveComponent() {
  const width = useWindowWidth(); // Our custom Hook  return (
    

Window width is {width}

); } 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都会声明自己的隔离状态。

(你可以在此处了解有关编写自己的Hook的更多信息。)

Static Use Order

你可以将useState视为定义“React状态变量”的语法。当然,这 不是 一种语法。我们还在编写JavaScript。但我们将React视为运行时环境,并且由于React定制JavaScript来描述UI树,因此其功能有时会更接近语言空间。

如果use是一种语法,那么它在顶级是有意义的:

//  Note: not a real syntax
component Example(props) {
  const [count, setCount] = use State(0);
  return (
    

You clicked {count} times

); }

将其放入条件、回调或组件外部意味着什么?

//  Note: not a real syntax

// This is local state... of what?
const [count, setCount] = use State(0);

component Example() {
  if (condition) {
    // What happens to it when condition is false?
    const [count, setCount] = use State(0);
  }

  function handleClick() {
    // What happens to it when we leave a function?
    // How is this different from a variable?
    const [count, setCount] = use State(0);
  }

React状态是组件的本地状态及其在树中的标识。如果use是真正的语法,那么将它范围扩展到组件的顶层也是有意义的:

//  Note: not a real syntax
component Example(props) {
  // Only valid here
  const [count, setCount] = use State(0);

  if (condition) {
    // This would be a syntax error
    const [count, setCount] = use State(0);
  }

这与import仅适用于模块顶层的方式类似。

当然,use实际上并不是一种语法。 (它不会带来太多好处,并会产生很多摩擦。)

但是,React确实希望所有对Hook的调用只发生在组件的顶层并且无条件地。可以使用linter插件强制执行这些Hooks规则。关于这种设计选择的争论很激烈,但实际上我并没有看到它让人困惑。我还写了为什么通常提出的替代方案不起作用。

在内部,钩子被实现为链表。当你调用useState时,我们将指针移动到下一个项目。当我们退出组件的“调用树”帧时,我们将结果列表保存到下一个渲染。

这篇文章简要介绍了Hook如何在内部工作。数组可能比链表更容易:

// Pseudocode
let hooks, i;
function useState() {
  i++;
  if (hooks[i]) {
    // Next renders
    return hooks[i];
  }
  // First render
  hooks.push(...);
}

// Prepare to render
i = -1;
hooks = fiber.hooks || [];
// Call the component
YourComponent();
// Remember the state of Hooks
fiber.hooks = hooks;

(如果你很好奇,真正的代码就在这里。)

这大致是每个useState()调用获得正确状态的方式。正如我们之前所了解的那样,“匹配事物”对于React来说并不新鲜 - 协调依赖于以类似方式匹配渲染之间的元素。

What’s Left Out

我们已经触及了React运行时环境的几乎所有重要方面。如果你了解此页面,你可能比90%的用户更了解React。而且不用担心这不正确!

我遗漏了一些部分,主要是因为我们都不清楚。React目前对于多路径渲染没有一个很好的描述,即父级渲染需要有关子级的信息时。此外,错误处理API还没有Hooks版本。这两个问题可以一起解决。并发模式还不稳定,有关Suspense如何适应这张图片的有趣问题。也许我会做一个后续行动,当他们丰满和Suspense准备好,而不是懒加载。

我认为这说明了React的API的成功,你可以在不考虑这些主题的情况下取得很大进展。 在大多数情况下,良好的默认值(如和解启发式算法)都是正确的。当你冒着射中自己脚部的风险时, 像关键警告这样的警告会促使你。

如果你是一个UI库的书呆子,我希望这篇文章有点有趣,并且更深入地阐明了React是如何工作的。或许你认为React太复杂了,你再也不会看了。在任何一种情况下,我都很乐意在Twitter上收到你的消息!谢谢你的阅读。

你可能感兴趣的:(React作为一个ui运行)