vlambda博客
学习文章列表

React18,有意思的并发模式 -- 包含新特性讲解,可以各取所需

React18,有意思的并发模式 -- 包含新特性讲解,可以各取所需

开始前

  • • 本文讲的全部是 客户端 发生的改动,至于 node 端的改动涉及到了SSR,以后有机会会新开一篇来讲解,流式渲染这个东西也是挺有意思的

  • • 本文所有的讲解都会贴上代码,感兴趣的可以自己粘出来自己试试

  • • 通篇都是大白话,不会有什么思维负担,如果只是对新特性感兴趣可以只看前边带序号的标题内容即可

什么是并发模式

新版本的用户体验方面可以说是更上了一层楼,难产多年的并发模式也是终于提到了台面上,官方更是为了这次更新推出了好几个新的hook来支持并发模式这一特性

所以本次更新的主要核心也是围绕着并发模式而展开的,那么并发模式是个什么东西?

对于 JS 来说并不存在传统的并发,因为JS是单线程,它做不到其他语言那样,可以通过多进程多线程来达到一坨子任务一起执行的效果,对于JS来说,所谓的并发就是同时间创建多个异步任务执行

即便是并行创建的异步任务也是有可能会出现卡顿的,因为JS的队列 Event Loop 单线程的特性,再怎么延时执行,如果前一个不执行完,下一个任务无论怎样都必须得等待着

所以并发模式要解决的两件事就出来了

  1. 1. 如何让我们的视图也能像是在并发一样,多个UI更改可以一起执行

  2. 2. 如何解决因任务堵车的情况所导致的,卡顿,延时等情况

下面来看下新出的 API 都干了什么事,然后在回过头来解答这两个问题

0. 搭建环境

这里用的是 vite + ts + react18 的环境,webpack可以用 create-react-app 来创建

创建项目工程

https://www.vitejs.net/guide/#scaffolding-your-first-vite-project

pnpm create vite react18 -- --template react-ts

升级下版本

因为截止目前(2022.4.8)官方的模板默认还是 17

pnpm add react@latest react-dom@latest

改变挂载方式

18版本挂载方式和 17 不同

  1. 1. 现在需要用 createRoot 来代替以前的 ReactDom.render 方法,这时将会开启并发模式

  2. 2. createRoot 会创建一个容器然后使用render方法渲染组件

//以前
import ReactDOM from "react-dom"
ReactDOM.render(
  <App/>, 
  document.querySelector("#root")
)

//现在
import { createRoot } from "react-dom/client";
import App from "./App";

const app = createRoot(document.querySelector("#root")!)
app.render(<App/>)

适配 TS

如果引入新版本的 API 提示报错的话就配置下

需要在 tsconfig.json中显示声明下类型,在types中新增以下内容

"react/next", "react-dom/next"

"types": ["react/next", "react-dom/next"]

如果还是报错就重新装下声明文件

pnpm add @types/react@latest @types/react-dom@latest

1. 不一样的批量更新 -- 并发模式

为了性能考虑,我们的更新都是异步进行的,这样框架可以在更新时把这多次更新合并为一次,来达到性能优化的目的

因为内部调度的机制,在以前对于 react 来说,如果我们将更新放在了异步任务中,则会变成类似于 vue 的 nextTick 一样的效果,组件会在本次更新视图完成后,立即进行下一次的更新

function App() {
  const [n, setN] = useState(0)
  const handleChange = () => {
    setN(1)
    setTimeout(() => {
      debugger
      setN(n => v + 1)
    }, 0)
  }
  return null
}

而在并发模式下,即使是异步任务,或者微任务都将可以享受到批处理(更新合并)

好处是,减少了多次渲染的开销

坏处是,以前这种更新后立即更新的做法会受到影响

2. 同步更新 -- 跳出批量更新

因为并发模式对于批处理(更新合并)的强大处理,让上边举得例子的做法变得充满了不确定性

于是官方出了个专门的方法 -- flushSync,用来在我们想要的时候告诉 react 哪些更新是不需要批处理的

flushSync名字也很直接,作用就和名字一样,同步更新

以下 demo 可以粘贴试试,不同的更新下,可以看到在debugger中视图展示的内容是完全不一样的, flushSync 会立即更新

import { useLayoutEffect, useState } from 'react'
import { flushSync } from 'react-dom'

export default function FlushSync() {
  const [num1, setNum1] = useState(0)
  const [num2, setNum2] = useState(0)

  const asyncChange = () => {
    setNum1(num1 + 1)
    setNum2(num2 + 1)
    debugger
  }
  const syncChange = () => {
    flushSync(() => {
      setNum1(num1 + 1)
      setNum2(num2 + 1)
    })
    debugger
  }

  useLayoutEffect(() => {
    console.log(num1, num2)
  }, [num1, num2])

  return <div>
    <p>num1: {num1}</p>
    <p>num2: {num2}</p>

    <p>
      <button onClick={asyncChange}>异步++</button>
      <button onClick={syncChange}>同步++</button>
    </p>
  </div>

}

3. 新增 hook -- 5个

useId

作用:用于返回一个唯一的 id

有时候我们会想要给元素或者某些地方,创建个唯一的标识,并且标识还带有一些随机性

比如通过这种方式 Math.random()或者 new Date().getTime()

如果只是一个端这确实是可以的,但是如果涉及到服务端渲染就不好使了

因为服务端渲染,需要先在服务端跑一遍组件生成流或字符串,然后在到达浏览器时进行注水( 可以想成又跑了次组件 ),如果使用无厘头随机的方式创建唯一 id,这两端的内容就会出现不同一而出问题

使用 demo

import { useId } from 'react'

export default function UId() {
  const id = useId()
  return (
    <div>
      useId -- {id} <Child />
    </div>

  )
}

function Child() {
  const id = useId()
  return <h1>child id -- {id} </h1>
}

使用场景

  1. 1. 服务端渲染

  2. 2. 懒得写随机数时,用它会方便些

useDeferredValue

作用:延迟更新后的值反应在页面中

每当我们进行输入后(比如 input 组件),会进行大量的逻辑计算等一些非常耗时的操作,因为 JS 单线程的缘故,假如这些耗时的逻辑计算不完成我们,页面就会出现假死,卡顿,键盘不灵的情况,这样用户体验就会很不好

基于 react 的调度系统,我们可以让 输入框的部分实时渲染,而需要基于经过大量计算逻辑才能输出的视图部分延时展示,这就会产生一种明明计算能力不够,但是页面依然流畅的感觉

useDeferredValue 的作用就有了,当每次监听的值发生改变后,如果当前有足够的时间和资源给我们,就会立即返回最新值,反之则会延时返回给我们最新值,于是我们就可以基于这个延时的值来判断说,是否应该进行耗时任务

使用 demo,这个例子中每次输入都伴随着大量的耗时计算,使用了useDeferredValue的值的列表在资源不足时跳过了一部分,因为新值速度很快所以用户无法察觉中间的丢失,而未使用的列表则会实时刷新,造成卡顿

import { ChangeEvent, useDeferredValue, useEffect, useState } from 'react'

export default function DeferredValue() {
  const [input, setInput] = useState('')
  //接受一个基本值,
  const deferInput = useDeferredValue(input)
  const handleInput = (e: ChangeEvent<HTMLInputElement>) => {
    setInput(e.target.value)
  }

  return (
    <div>
      <p>
        <input type="text" onChange={handleInput} />
      </p>
      <List type="延迟列表" input={deferInput} />
      <List type="普通列表" input={input} />
    </div>

  )
}

function List({ type = '', input = '' }: Record<string, any>) {
  const [count, setCount] = useState(-1)
  const [list, setList] = useState(() => makeList(input))

  useEffect(() => {
    setCount(count + 1)
    setList(makeList(input))
  }, [input])
  
  return (
    <div style={{ display: 'inline-block' }}>
      <hr />
      <h2>{type}</h2>
      <h3>触发次数 {count}</h3>
      <ul>
        {list.map((item, index) => (
          <li key={index}>
            {item.input}--{item.value}
          </li>
        ))}
      </ul>
      <hr />
    </div>

  )
}

// 模拟很消耗 CPU 的任务
function makeList(input: string) {
  return [...new Array(1000).keys()].map(() => ({
    input,
    value: Math.random() * 100000
  }))
}

useDeferredValue 做的事很像是我们通常所做的 节流防抖,作用其实是差不多的,不同的是,作为使用者的我们只能粗略的给一个延时时间,而useDeferredValue是基于 react 内部的调度来动态选择延时,这样的用户体验会更好

使用场景:类比可用于视图的 节流防抖函数的使用场景

useTransition

作用:告诉 react 可以懒执行哪些更新任务

和 useDeferredValue 类似,只不过useDeferredValue 延时的是值,而useTransition延时的是更新任务

没有参数

返回值

  1. 1. 内部的过度状态

  2. 2. 一个回调函数,函数内部执行的 setState 方法将会延时执行,延时期间将内部过度装状态变成 true

使用 demo,每次更新都会有 loading 的状态

import {
  ChangeEvent,
  useDeferredValue,
  useEffect,
  useState,
  useTransition
} from 'react'

export default function Transition() {
  const [input, setInput] = useState('')
  const handleInput = (e: ChangeEvent<HTMLInputElement>) => {
    setInput(e.target.value)
  }

  return (
    <div>
      <p>
        <input type="text" onChange={handleInput} />
      </p>
      <List input={input} />
    </div>

  )
}

function List({ input = '' }: Record<string, any>) {
  const [list, setList] = useState<any[]>([])
  const [isPending, startTransition] = useTransition()

  useEffect(() => {
    startTransition(() => setList(makeList(input)))
  }, [input])

  return isPending ? (
    <h1>pending...</h1>
  ) : (
    <div style={{ display: 'inline-block' }}>
      <ul>
        {list.map((item, index) => (
          <li key={index}>
            {item.input}--{item.value}
          </li>
        ))}
      </ul>
    </div>

  )
}

// 模拟很消耗 CPU 的任务
function makeList(input: string) {
  return [...new Array(10000).keys()].map(() => ({
    input,
    value: Math.random() * 100000
  }))
}

使用场景:建议有类似的耗时任务都可以大量使用

useInsertionEffect

这个 API 主要是提供给 css in js 的库作者的,它最大的特点是执行的时机,它将会在每次视图更新后第一时间执行

和常规的副租用 hook 相比,执行顺序如下

  1. 1. useInsertionEffect 2. useLayoutEffect 3.useEffect

使用 demo

import { useEffect, useInsertionEffect, useLayoutEffect } from "react"

export default function InsertionEffect() {
  useInsertionEffect(() => {
    console.log( "1. useInsertionEffect" )
  })
  useLayoutEffect(() => {
    console.log( "2. useLayoutEffect" )
  })
  useEffect(() => {
    console.log( "3. useEffect" )
  })
  return <div>useInsertionEffect</div>
}

使用场景:不建议用,因为要想要优先执行放在useLayoutEffect是完完全全够了的,如果只是为了尽可能追求快而使用,那么这个 API 将变得毫无意义

useSyncExternalStore

这是提供给库用的,它的作用是连接 react 内部与外部 状态管理 的一个桥梁

之所以为了外部的状态专门出了个 API 是因为,在并发模式下可能会出现竟态所引起的状态错误的情况

在以前,每次更新都意味着,触发更新 ->计算状态 -> 同步到视图 这种一比一的关系。而在并发模式下呢,在计算状态时,可能会出现更新到一半,发现预留的更新时间超时了或者有优先级更高的任务进来了,那么此时更新状态会被保存,等到前边的大哥忙完了在继续自己的更新

这就会导致一个问题,假如外边有一个对象const store = { open: false }, 我在上半程把 open 改成了 true,那么如果中间插腿进来的大哥任务把 open 改回了 false,那么当更新下半程时状态就不对了,因为我上半场白忙活了

这就是竟态的隐患,为了解决这种隐患,useSyncExternalStore会在每次任务执行时使用用户提供的判断函数的结果来判断,如果我们返回 true 则 react 知道出现竟态了,为了保证状态的正确会立即进行一轮更新来保证状态是对的

参数有 3 个

  1. 1. 回调函数会接收到一个订阅函数,我们可以将其保存,并在需要的时候调用来从外部触发更新

  2. 2. 返回一个布尔值的快照函数,作用是告诉 react 状态有没有过期,过期了会立马重新更新

  3. 3. 和参数 2 一样,只不过是用于 服务端

使用 demo,对于我们开发者来说自己使用的概率很低

import { useSyncExternalStore } from 'react'

const store = {
  data: {
    msg: 'from store'
  },
  listeners: [] as any[],
  getSnapshot() {
    return this.data.msg
  },
  subscribe(onStoreChange: () => void) {
    this.listeners.push(onStoreChange)
    return () => {
      this.listeners.splice(this.listeners.indexOf(onStoreChange), 1)
    }
  },
  dispatch() {
    this.listeners.forEach(f => f())
  }
}

export default function SyncExternalStore() {
  const res = useSyncExternalStore(
    store.subscribe.bind(store),
    store.getSnapshot.bind(store)
  )
  console.log( res )
  return <div>useSyncExternalStore</div>
}

4. 更加强大的 Suspense

Suspense 的作用是,如果内部存在挂起(比如懒加载组件时)将会进行状态回退(使用 fallback 的UI 占位)

但在以前如果审查元素,观察 DOM 树就会发现一个很搞笑的情况,除了尚未加载的组件其实还是会加载,只是看不见

而现在则是真正做到了全部回退

使用 demo,可以放在不同版本的 react 试试,然后观察下 dom 树结构的内容变化

import { FC, lazy, Suspense, useEffect } from "react"

function AsyncComponent() {
  useEffect(() => {
    console.log( "AsyncComponent" )
  }, [])
  return <h1>AsyncComponent</h1>
}
const LazyAsyncComponent = lazy(() => {
  return new Promise<{default: FC}>(resolve => {
    setTimeout(() => {
      resolve({ default: AsyncComponent })
    }, 5000)
  })
})

function SyncComponent() {
  useEffect(() => {
    console.log( "SyncComponent" )
  }, [])
  return <h1>SyncComponent</h1>
}

export function TestSuspense() {
  return <Suspense fallback={<h1>loading component...</h1>}>
    <SyncComponent />
    <LazyAsyncComponent />
  </Suspense>

}

未来的看法

现在回来开头提出的两个问题,这两个问题可以一起回答

react 和 vue 不同,因为 jsx 的高度灵活性导致很那做到 vue3 那样,可以用静态分析来高度优化组件性能的目的。所以在大方向选择了调度,它依然没有解决因大量计算导致的资源占用的总耗时,但是却可以做到区分优先级,给用户一种貌似很快的假象

并发模式,fiber,这次新出的 API,都是在想尽办法,如何让宝贵的资源和算力花在刀刃上

在以后 Suspense 或将成为焦点,围绕调度做的事已经相当多了,增了异步支持的批处理,能延时值的useDeferredValue,能延时计算的useTransition,但是耗时的任务再怎么延时都要花那么长的时间,所以Suspense的特性或许可以深度挖掘一下

毕竟Suspense的主要作用宏观的概括就是,如果内部状态在尚未处理好前,可以用另外的方式在占位或者填充,给用户更好的用户体验