Vue / React Hooks:引入 SWR,DX体验由农业时代进入工业时代

3,839 阅读10分钟

image.png

前言

前端发展至今,项目的技术框架花样繁多,刨除 bundler,CSS 等,个人认为对 DX(developer experience)影响最大的主要有二: TypeScript / Hooks , 对应编程的基本要素:类型 / 上下文。 对于业务逻辑,TS 完备记录类型;对于封装的复用逻辑,Hooks 提供可跳转追溯的上下文。这两点构成项目最基本的可维护性。

而老旧 Vue2 项目这两者往往都不具备,随意嵌套的类型 和 截断上下文无可追溯的 mixin / vuex map,时刻提醒着接盘者自己似乎不是在编程,写脚本罢了,所以一点技术升级很有必要。

Vue 2 引入 Composition API

引入工具函数

Vue > 2.7 已内置, 小于则使用 github.com/vuejs/compo… vue 2 各版本都能用。

yarn add @vue/composition-api

//entry
import VueCompositionAPI from '@vue/composition-api'
Vue.use(VueCompositionAPI)

//component.vue
import { ref, reactive } from '@vue/composition-api'

本人项目为微前端子项目,Vue 版本为 2.6 且无法修改,同时基座 $t 有问题导致只能写 .vue 文件,同时基座问题导致后续 SWR 框架无法使用熟悉的 tanstack ,只能改用没用过的 swrv ,即使这样在后续的开发中也比较顺利,所以不用担心兼容性问题。

提醒

如使用 @vue/composition-api , 则全部工具需从此包引,不从 vue 包引,否则类型对不齐。

// error!!! 
import { defineComponent } from '@vue/composition-api'
import { PropType } from 'vue'

//correct
import { defineComponent, PropType } from '@vue/composition-api'

另外 @vue/composition-api 包的响应式实现无法使用深度修改引用类型的嵌套属性来获得响应修改,但在任何时候 setup 写法和 option API 都可以混用,所以影响不大,依然用 option API 写就好了。

<script lang="ts">
export default defineComponent({
// defineComponent 只提供类型,对祖传代码没有类型,直接写 setup() 即可,不套用 defineComponent。
  setup() {
    const { isLoading: timeLoading } = usePageStatus()
    const { isLoading: arLoading } = useUserDetail()
    return {
      timeLoading,
      arLoading
    }
  },
  computed: {
    pageLoading(): boolean {
      // 随意混用,和旧 vuex/mixin 等逻辑可直接对接
      return this.timeLoading || this.arLoading
    }
  }
})
</script>

引入 JSX 周边

解锁各种 JSX , FC 写法,如果只能写 .vue 文件, <script lang="tsx"> 即可。

yarn add @vue/babel-preset-jsx @vue/babel-helper-vue-jsx-merge-props

// babel config
module.exports = {
  presets: [
    [
      '@vue/babel-preset-jsx',
      {
        compositionAPI: true,
        vModel: true,
        functional: true,
        vOn: true
      },
    ],
  ],
}
// Test.vue
<script lang="tsx">
import { defineComponent } from '@vue/composition-api'
export const TestFc = (props: { msg: string }) => <div>{props.msg}</div>
export default defineComponent({
  setup(props: any) {
    return () => <div>{props.msg}</div>
  }
})
</script>

使用 SWR

外链目录

codesandbox 代码示例(个人修改版,文章主要演示实例 使用 tanstack 的 Vue2 版本

Tanstack,个人最熟悉, AKA react-query,目前有全框架版本

SWR 命名的库,Tanstack 竞品,Vercel 出品,只有 react版本

SWRVSWR 的 vue 版本,对Vue 2 低版本有较好支持,Kong 出品,业务项目中最终使用的框架。 codesandbox 示例(tanstack 官网版)

虽然上面有多个库,但只有框架和 API 的不同,内核思想和用法是完全一致的,可以按项目条件选用。

无论是 tanstack 还是 vercel/swr , 它们首先是 client data fetcher 框架,提供了 hooks 式的用法和接口(缓存管理,自动refetch等功能),只是 SWR 是一种最好的模式,所以是这两个框架都实现了它并设为默认行为。

什么是 SWR

The name “SWR” is derived from stale-while-revalidate, a cache invalidation strategy popularized by HTTP RFC 5861SWR first returns the data from cache (stale), then sends the request (revalidate), and finally comes with the up-to-date data again.

请求以自定义 `keystring${props.query}` 为 key ,没有cache时显示loading => data, 对应 key 有cache 时直接显示已 cache 的 data 不 loading,同时发起请求,当请求回来时,替换 cache 值和前端展示。

典型场景就是前进后退页面时,页面数据尽量不 Loading,来贴近原生应用的体验。这也和 IOS 系统设置的交互设计理念一致,即使选项后是异步数据,用户也看不到 loading 。

使用个人示例点点更明白:

第一次点击 post2 , 出现loading

then 显示内容

然后切换回 post1 然后切换回post2 , 不显示loading, 直接显示内容。如果后端数据变化,待接口返回后替换页面内容。

即 Visited 过的 item 都直接显示内容,不 loading,如果远程内容有变化,会先显示cache内容,后显示新内容。当然默认行为可以定制化,比如自动重试,缓存过期时间之类。

典型场景是页面前进后退列表,或者表单下拉框异步数据,这种行为都能避免出现 loading ,提供类似客户端 APP 的感觉,提升用户体验。

取代前端状态管理库

这种库的真正力量在于可以替换掉项目中全部的 data fetching + 状态管理的场景,SWR + Hooks 的开发体验,可以像管理本地数据一样管理远程数据,见代码:

fetcher 封装:

// promise
const fetcher = async (id: number): Promise<Post> =>
  await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`).then(
    (response) => response.json()
  );
  
//hooks
export const usePostItem = (postId: Ref<number>) => {
  const enabled = computed(() => postId.value !== -1);
  const { isLoading, isError, isFetching, data, error } = useQuery({
    // queryKey 相当于 useEffect 的依赖,自定义哪些值变化触发更新 data
    // 也组成 cache key 来判断是否命中 cache, 比如postId 为 1 时获取过一次
    // 则下次再变为 1 时优先返回 cache 值,同时后台回调异步更新 cache
    queryKey: ["post", postId],
    // queryFn 是回调, 具体函数里是 Get Post 都可以,甚至调 Vuex.dispatch也行
    queryFn: () => {
      return fetcher(postId.value);
    },
    enabled,
  });

  return { isLoading, isError, isFetching, data, error };
};

组件内使用:

<script lang="ts">
export default defineComponent({
  setup(props) {
    const { isLoading, isError, isFetching, data, error } = usePostItem(
      toRef(props, "postId")
    );
    return { isLoading, isError, isFetching, data, error };
  },
});
</script>

<template>
  <div>
    <div v-if="isError">An error has occurred: {{ error }}</div>
    <div v-else-if="data">
      <h1>{{ data.title }}</h1>
      <div>
        <p>{{ data.body }}</p>
      </div>
      <div v-if="isFetching" class="update">Background Updating...</div>
    </div>
    <div v-else-if="isLoading" class="update">Loading...</div>
    <div v-else>no data</div>
  </div>
</template>

这种力量来自 Hooks 编程,对比一下如果用 Vuex ,这将会很繁琐:,

<script lang="ts">
import { mapActions, mapGetters } from 'vuex'

export default {
  computed: {
    ...mapGetters('Address', ['userAddress'])
  },
  methods: {
    ...mapActions('Address', ['getUserAddress'])
  },
  created(){
    // 由于不知道 userAddress 是否有值,值是否最新,不得不手动调用 getUserAddress
    // 且假如组件树多个层级组件都用这个值,都这么写, API 也会多次调用,造成浪费,除非 fetcher 内再次特殊处理
    this.getUserAddress()
  }
}
</script>

  • 需要手动调用来保持值到最新状态
  • 爷孙父子组件的 API 会额外多次调用,冗余且错误,要优化则需要再手动管理 data fetching 的写法。
  • mapGetter,mapAction 会截断上下文,类似 mixin ,无法直接追溯到源码,开发体验糟糕

而使用 SWR

  setup(props) {
    const { isLoading, isError, isFetching, data, error } = usePostItem(
      toRef(props, "postId")
    );
    return { isLoading, isError, isFetching, data, error };
  },

只需一行代码:

  • data 永远是一个可信赖的值,会由 undefine 变成目标值,并直接更新页面;props.postId 的变化会自动 fetch 更新组件内 data 值(类似 useEffect,watchEffect);如远程状态更新时会走 SWR 的默认行为,即最优化的远程状态。同时有工具的 isLoading, isError, isFetching 等获得各种状态,这是 hooks 编程 / 函数响应式编程 的力量,使得像管理本地数据一样管理远程数据
  • 即使爷孙父子层级节点在同一次渲染中多次调用 usePostItem , 真实 API 也只会调用一次,这是库内部做的优化,是一种简单的单例模式。

image.png image.png

它更进一步的意义在于,这一行 usePostItem 封装并不只是让接口调用更简单,而是在页面组件树的任何地方都可以直接调用 usePostItem() 而不用担心接口重复调用或值不是最新,拿到一个可靠的远程状态,像管理本地状态一样管理远程状态。这是响应式编程的力量。

  setup(props) {
    // 假如 usePostItem 不消费入参,或入参在函数内 Inject
    const { isLoading, data} = usePostItem();
    return { isLoading, data };
  }

同样可以复用在更高阶的逻辑封装中。比如我们提供一个异步数据下拉框组件时,同时将它的控制流逻辑全部封装成一个 usePostSelector 并暴露,则用户可以使用控制流直接写新的 UI ,复用性更好。

  const usePostSelector = () => {
    const { isLoading, data} = usePostItem();
    // ... other logic
    return { isLoading, data };
  }

Does React Query replace Redux, MobX or other global state managers? 当然,为了配合祖传代码,和单独的状态管理共用也可, 比如 Recat 中 React-query + jotai

当前项目为了兼容性甚至在和vuex 公用,watch 同步一下状态即可

   const { data } = useSWRV('tasks', getAllTasks)

    // Using a watcher, you can update the store with any changes coming from swrv
    watch(data, newTasks => {
      store.dispatch('addTasks', { source: 'Todoist', tasks: newTasks })
    })

单独使用单例模式

这种单例模式也不和 SWR fetch 框架绑定,VueUse 有个 createSharedComposable 见 vueuse.org/shared/crea…

import { createSharedComposable, useMouse } from '@vueuse/core'

const useSharedMouse = createSharedComposable(useMouse)

// CompA.vue
const { x, y } = useSharedMouse()

// CompB.vue - will reuse the previous state and no new event listeners will be registered
const { x, y } = useSharedMouse()

可以看出,简单的状态机不再需要像 vuex 那样mixin 阶段上下文,或者 props+emit 冗余的传递了,直接在不同组件内 use 相同 hooks 即可,优势为类型和上下文完备。

由此,使用 SWR 可以替换掉 Vue2 项目中大部分 mixin ,vuex 的状态管理,data fetching,使得项目从农业时代进入工业时代。

实质上 Hooks 编程是一种优化的过的,更容易理解的响应式函数式编程(RxJS),见juejin.cn/post/707305…

是否造轮子

一个简单的 data fetch hooks 逻辑,或者 demo 级别的 SWR 一百多行就能完成。但 github.com/TanStack/qu… 类似的库代码行数还是挺多的, 百分之九十的代码处理百分之五的逻辑,边缘 case 用例等待 bug 来踩坑没有必要,建议用库来替换自己项目中类似实现。

或者参考库中的用例 github.com/TanStack/qu… 如果自己的实现能通过所有用例, 也可以自己实现,作为技术产出。

彩蛋:Vue composition API 的心智负担

React 的心智负担即闭包问题, 想必大家极其熟悉了,可见个人文章

没深度用过 composition API 时,以为它是白莲花,用过才知道它的心智负担可一点不小。 现在来做个题, 下面代码来自 Tanstack 官方示例

  setup(props) {
  // const { postId } = props 我们知道解构赋值会丢失响应性,所以下面都是 props.postId
    const { isLoading, isError, isFetching, data, error } = useQuery({
      queryKey: ['post', props.postId],
      queryFn: () => fetcher(props.postId),
    })
    return { isLoading, isError, isFetching, data, error }
  },

给你20秒,即使你是 react + vue 老鸟,能看出这段代码的一个明显错误吗?

================================ 分隔线 ====================================

给个提示,症状表现为入参 postId 改变,但下面 content 保持为第一次 postId 变化的返回值后不变了,即只消费了 postId 第一次的变化。

================================ 分隔线 ====================================

下面是正确用法:

    const { isLoading, isError, isFetching, data, error } = useQuery({
      queryKey: ["post", toRef(props, "postId")],
      queryFn: () => {
        return fetcher(props.postId);
      },
    });

queryKey 处不能传 props.postId,会丢失响应性。这里就很反直觉了 ,咱明明都给面子没解构赋值了,但防不胜防,这里是作为 useQuery 函数的入参,给函数入参传入 props.postId, 和解构赋值一样,仍然丢失了响应性。

需要使用 toRef(props, "postId"),来重获响应性。怎么样,如果不是 composition API 用的非常熟,很容易犯错误吧!连 tanstack 官方文档都犯这个错了。至于文档效果为什么看上去正常,因为 demo 里每次切换都手动 unmount 了组件,强行刷新了 props,修改下写法就露馅了。正确写法见 个人示例

也就是说,composition API 的心智负担主要在于任何时候你心里都要将全部的变量当做 Proxy 对象对待,时刻感受它的存在,小心呵护它的响应性。这里其实体现出 React 的 DX 优势了,写 React 总是符合 JS 语言的直觉,而写 Vue 总是反这种直觉。

下面是解决方法

方案 1 : 使用 TS 来要求函数入参是 Ref,类型检查来保证响应式

export const usePostItem = (postId: Ref<number>) => {
  const enabled = computed(() => postId.value !== -1);
  const { isLoading, isError, isFetching, data, error } = useQuery({
    queryKey: ["post", postId],
    queryFn: () => {
      return fetcher(postId.value);
    },
    enabled,
  });

这种虽然能用, 但 postId 作为响应式对象 太显眼了,DX 体验不好。

方案 2:结合 script setup 使用

因为 Vue3 新 scrpit setup 语法中 defineProps 能用 TS 类型直接声明 Props,更接近 TSX 体验, 如

type ContentInterface = {
  postId: number;
};
const props = defineProps<ContentInterface>();

则对于封装的 hooks ,入参完整消费这个 props 类型即可,本质上和方法1 一样,只是传入了 reactive 对象

export const usePostItem = (props: ContentInterface) => {...}

这也是 ElementPlus 库逐步升级的方式,可见 github.com/element-plu…

image.png

示例代码

没有使用额外的 babel 黑魔法所以和方法1区别不大,但至少在封装的 hooks 内部,入参响应式对象这个复杂度不明显了,显得更符合人体工程学。

响应式编程的使用差异:

上文提到 Hooks 编程实际上是一种 Rx ,或者现在叫做 signals , 响应式编程,入参响应式对象这点这里可以看出 React 的 DX 优越性

Rxjs 的响应式编程,订阅一个 Observe,pipe 不断消费它的新值

fromEvent(document, 'click')
  .pipe(
    throttleTime(1000),
    map((event) => event.clientX),
    scan((count, clientX) => count + clientX, 0)
  )
  .subscribe((count) => console.log(count));

React: useState() = value + setter 优点是 DX 好,缺点是非响应式更新

export const function testFunc() {
  const [userId, setUserId] = useState(0);
  // 不断修改 userId
  // userId 相当于一个 Observe ,  useUserList useUserInfo 相当于 pipe  
  // 不断消费userId 的新值
  const { userlist } = useUserList(userId)
  const { userInfo } = useUserInfo(userId)
  return (
    <div></div>
  );
 }
 
 export const useUserList = (userId: number) => {
   // 自定义 hooks 里 userId 为原始类型 number 
   // 因为 userId 变化 useUserList 会随着 function component 重新执行
   // 所以写法上可以无感知的消费它的变化,体现了 React hooks 的 DX 优越性
   if(userId===1){
       return
   }
  // 一个 if return 效果非常简单,在 vue 中这很麻烦。
  // 缺点是 function component 里各种 useMemo useCallback
 
 }

swr.vercel.app/zh-CN/docs/…

vue: 标准的 useSignal() = getter + setter , .value 风格,响应式更新,缺点是 DX

www.builder.io/blog/usesig…

  // testFunc.vue
<script lang="tsx">
import { defineComponent, ref, Ref, computed } from '@vue/composition-api'

export default defineComponent({
  setup(props: any) {
    const userId = ref(0)
    // 不断修改 userId 值
    // userId 相当于一个 Observe ,  useUserList useUserInfo 相当于 pipe  
    // 不断消费userId 的新值
    const { userlist } = useUserList(userId)
    const { userInfo } = useUserInfo(userId)
    return () => <div></div>
  }
})

export const useUserList = (userId: Ref<number>) => {
  // 自定义 hooks 中 userId 为响应式对象, 不为原始类型 number
  if (userId.value === 1) {
    // 这个 if 永远不会生效,因为 usePostItem 在 setup() 里只会执行一次
    // 需要手动消费 userId 的变化,
    
  }
  //  手动消费变化,如包在 computed 或 watchEffect函数里
  const disable = computed(() => {
    return userId.value === 1
  })
  // 接下来要找别的方法来消费 disable.value
  // 可见相比 React 对 DX 的劣化非常严重,优点是不用各种 useMemo, useCallback
}
</script>

Preact 实现了标准 signal 这一套 .value 模式,和vue 一致

github.com/preactjs/si…

import { useSignal, useComputed, useSignalEffect } from "@preact/signals-react";

function Counter() {
	const count = useSignal(0);
	const double = useComputed(() => count.value * 2);

	useSignalEffect(() => {
		console.log(`Value: ${count.value}, value x 2 = ${double.value}`);
	});

	return (
		<button onClick={() => count.value++}>
			Value: {count.value}, value x 2 = {double.value}
		</button>
	);
}

TC39 proposal Signals

github.com/proposal-si…

hooks 编程这一套现在被称为 Signals (信号), 已经有 proposal 将它加入原生JS,语法如下

const counter = new Signal.State(0);
const isEven = new Signal.Computed(() => (counter.get() & 1) == 0);
const parity = new Signal.Computed(() => isEven.get() ? "even" : "odd");

// A library or framework defines effects based on other Signal primitives
declare function effect(cb: () => void): (() => void);

effect(() => element.innerText = parity.get());

// Simulate external updates to counter...
setInterval(() => counter.set(counter.get() + 1), 1000);

现在react hooks 或 vue 的响应式数据和框架本身的更新机制耦合,即和 UI 层耦合,这其实没有必要。核心业务逻辑本来就应该与UI 解耦,如果 JS 原生支持 signal :

  • 性能,内存优化,原生调用栈的 debug 的体验提升
  • 更容易切换框架和UI库
  • 原生 JS 单测更容易
  • (possible)JS 原生支持 HTML DOM 与 signal 的绑定,把 React fiber 这一套别扭的 API 扫入历史垃圾堆

总之好处和想象空间都是很大的,可以仔细阅读

你可能会问市面上是否已经有和UI 不耦合的 signal 实现? 其实很多, MobX就已经是了,单独对 Vue ,React 实现了更新机制。只是大家不是很爱用,因为和框架自带的 signal 合用有些画蛇添足。

React 19 更多 Data Fetching 模式对比

nextjs: react server component (RSC) + suspense

image.png

RSC ( react server component) 是 React 的新SSR 解决方案,也是 React 团队目前主要的业务发展方向,尽管社区不太买账。

overreacted.io/the-two-rea…

React Dan Abramov 的论点是将 ui=f(state) 这种范式拿到 server 端是有用的,所以我们需要 RSC 。

社区普遍的态度是不希望被强加 SSR,比如各种 Web App 场景。对 SSR 的强推说明 React 和 Vercel 的利益绑定太深。

所以社区出现了分歧, 许多 KOL 不再 follow React 团队的方向,比如 tanstack 出了 react-router ,尽量和 Nextjs 切割 tanstack.com/router/late…

抛开这些不谈, 在必须 SSR 的场景下, RSC 的确带来了比较好的 DX ,让 SSR 组件的开发体验和 CSR 几乎没有差别。暴论:React 团队永远最在乎 DX 体验,其实不在乎性能和渲染损耗(都是假装的)。

async function getData() {
  const res = await fetch('https://api.example.com/...')
  if (!res.ok) {
    throw new Error('Failed to fetch data')
  }
  return res.json()
}
// 将 Function Compoent 语义化的当做普通函数对待
export default async function Page() {
  // 直接await
  // Promise pending 时打到组件树上层的 loading <Suspense>
  // throw Error 时打到 Error boundary <Suspense>
  const data = await getData()
  // 限制也很明显 ,RSC 内不能有状态 ( 如 useState ) 否则 State 变化 await 也会重执行
  // 表现为输入一个字符loading 一下,所以 RSC 预期只初次加载时执行一次(如刷新页面时)。
  return <main></main>
}

在 Nextjs 示例里这一套能很优雅的实现 SSR 的流式 UI。

但如果我们已经先理解了 SWR 这一套

export const usePostItem = (postId: Ref<number>) => {
  const enabled = computed(() => postId.value !== -1);
  const { isLoading, isError, isFetching, data, error } = useQuery({
    queryKey: ["post", postId],
    queryFn: () => {
      return fetcher(postId.value);
    },
    enabled,
  });

在组件里直接消费 isLoading , isError , 并打到 Suspense 配合异步组件,也能简单实现 Streaming UI ,同时不需要 SSR。

顺便了解一下社区火热但一直没用过的 headless ui shadcn/ui

ui.shadcn.com/docs/compon…

themes.fkaya.dev/

npx shadcn-ui@latest add input-otp

简述一下就是不用npm装,组件代码直接拷贝到项目约定文件夹里,但需要项目先配好 tailwind ,组件所有样式全用 tailwind 实现。同时也是 React 库,基于 Radix UI , DX 也是开箱即用,谈不上非常 Headless。

同构范式:<form> + action

站在 SSR 的角度,需要处理哪些逻辑在服务端执行, 哪些逻辑在客户端执行。

  • 对于component , 默认为 server component ,不能引入 useState;如需使用 client component 则用 "use client"标识
  • 对于普通函数ts 文件,默认客户端执行,如需要服务端执行(如数据库操作),ts 文件头用 "use server" 标识

于是出现问题: 典型场景-表单提交,表单有状态,需要"use client" ,而提交动作在同构上下文中一般直接ORM 调库,则这块逻辑必须放进另一个文件,标识"use server"

第二份 codesandbox 示例:codesandbox.io/p/devbox/po…

是否有办法将一个表单页面作为 Server component ,此时 server action 和表单 FC 在一个文件里? React 给出的解决方案是 react-dom 给 <form> dom 元素的action 范式:

react.dev/reference/r…

它不仅是只用于 form , 设计上来看它希望这种范式作为全部状态提交的最佳实践,不再需要一个 Input 一个受控的模式,而是至少套一个 form 这样使用。我们可以从 shadcn 文档看出端倪 ui.shadcn.com/docs/compon… input 组件不再给出单独受控示例,只有结合 form 使用示例。

简要来说就是 react-dom 给原生 <form> 封装了受控逻辑,通过 action 回调拿到值,于是在引用 form 的组件中不再需要useState,从而在 Server component 里也能用带状态的组件。

示例 nextjs + postgreSQL 同构调库: codesandbox.io/p/devbox/po…

React 19

React 19 公测版最主要的新 Hooks 是 useActionStateuse

  • useActionState 是将 <form> action 这种新范式引入客户端逻辑。

  • 上文 async FC 内直接 await 也是 RSC 的特性, use() 也是将其引入客户端逻辑的结果

React 18 fiber 功能 useTransition

我们可以回溯性的看下 React 18 最重要的 fiber hooks useTransition 的命运,使用它实现查询操作

第三份 codesandbox 示例: codesandbox.io/p/sandbox/r…

import { useTransition, useState } from "react";

import { fetcher } from "./fetcher";
export function SearchContentUseTransition({}) {
  const [name, setName] = useState("");
  const [title, setTitle] = useState("");
  const [error, setError] = useState(null);
  const [isPending, startTransition] = useTransition();
  const handleSubmit = async () => {
    // fiber 低优先级更新用 startTransition 包裹
    // https://react.dev/reference/react/useTransition
    startTransition(async () => {
      const { title, body } = await fetcher(name);
      setTitle(title);
      if (!title) {
        setError(true);
      } else {
        setError(false);
      }
    });
  };

  return (
    <div>
      <h3>Search Content using React 18</h3>

      <input value={name} onChange={(event) => setName(event.target.value)} />
      <button onClick={handleSubmit} disabled={isPending}>
        Search
      </button>
      {isPending && <p>pending...</p>}
      {title && <p>{title}</p>}
      {error && <p>404 not found</p>}
    </div>
  );
}

  • 从用户角度看 useTransition 只是帮忙包了一个 loading 逻辑,它有什么价值?

    • juejin.cn/post/713615… 实质上 useTransition 是 fiber 功能的钥匙,只有用它包裹的 setState 才是低优先级的
  • 反题:说到底 fiber 这一套解决的是 Function Component 更新的问题,假如使用 响应式更新 , 这个问题压根不存在,实质上 FC 也能做到响应式更新(Preact 结合 signal),为什么要在业务代码里处理 fiber 的逻辑

    • 这样看 useTransition 相当于直接在业务代码里暴露出了 fiber 的复杂度,是比较讨厌的。所以推出以来用户使用非常少,大都是框架在使用,比如 SWR 一行代码就包含了上述逻辑,不会将 fiber 的复杂度暴露出来

React 19 功能 useActionState

将上面 useTransition 例子改写为 useActionState 实现:

codesandbox : codesandbox.io/p/sandbox/r…

import { useTransition, useState, useActionState } from "react";

import { fetcher } from "./fetcher";

export function SearchContentUseActionState() {
  const [payload, submitAction, isPending] = useActionState(
    async (previousState, formData) => {
      console.log("previousState", previousState);
      const name = formData.get("name");
      const payload = await fetcher(name);
      return payload;
    }
  );

  return (
    <>
      <h3>Search Content using React 19</h3>
      <form action={submitAction}>
        <input type="text" name="name" />
        <button type="submit" disabled={isPending}>
          Search
        </button>
        {isPending && <p>pending...</p>}
        {payload?.title && <p>{payload.title}</p>}
        {!payload?.title && <p>404 not found</p>}
      </form>
    </>
  );
}

  • 从用户角度看 useActionState 就是把 useTransition 多包了一层,它有什么价值?
    • react.dev/reference/r… useActionState 就是为client component 提供 <form> action 范式的 adaptor
    • 关键点在于 react.dev/reference/r… 这个不只是扩展 <form>, 而是相当于出了一整套数据 post 操作的标准,同时适用于 Client 和 Server 的写法范式
  • 反题:现在有几个 UI 库是把 <form> 元素直接暴露在业务代码里使用的 (除了 NextJs 自己的 shadcn/ui)?如果我不写 SSR,把 Action 移动到纯客户端逻辑里有什么收益?引入新的范式复杂度,也没有解决额外的问题?
    • 无力反驳,比 useTransition 更难用。从React 角度看可能是支持了前后端同构的范式,但从用户角度看成本高收益小基本没用,可见前途更渺茫。还是回到最初的问题,我们是否需要 SSR ,如果不需要则没什么价值。

只能说 v18 之后的 React 新功能不太应该放进 'react' 这个包里,叫 'react-server' 或 'react-next' 更合适。

React 19 use()

这个相对来说还比较有用,但不是给业务代码使用的。

client component 不支持 async FC 里直接 await ,则 use() 用法就相当于 await, 和 RSC 里表现一致

codesandbox: codesandbox.io/p/sandbox/r…

import { use, Suspense, useState } from "react";
import { fetcher } from "./fetcher";

//client component 不支持 async
function Comments() {
  // 类似于 RSC 中 async  FC 里直接 await
  // fetcher(1) resolve 前打到组件树 Suspense
  const comments = use(fetcher(1));
  return (
    <>
      <p>RenderContentUse</p>
      <p>{comments?.title}</p>
    </>
  );
}

export function RenderContentUse() {
  const [name, setName] = useState("");
  // 同样 name 变一下  Comments loading 一下, 和 RSC 一致
  return (
    <>
      <h3>Render Content using React 19</h3>
      <Suspense fallback={<div>Loading...</div>}>
        <input value={name} onChange={(event) => setName(event.target.value)} />
        <Comments />
      </Suspense>
    </>
  );
}


总得来说还需要处理额外的 cache 逻辑 ,也会报 warning

Warning: A component was suspended by an uncached promise. Creating promises inside a Client Component or hook is not yet supported, except via a Suspense-compatible library or framework.

也直接告诉你了 uncached , 不要直接用,推荐用 library or framework,或直接使用 SWR 这种库

总的来说, useTransition / useActionState 这些 hooks 都和 React 自己发明的范式太耦合,和 Signal 这种通用范式距离越来越远。没有用户会无端在业务代码里添加额外的范式复杂度,它基本上没有收益(如果不用 SSR),只是将项目和 React 本身的利益捆绑的越来越深

总结

以上操作全使用 Vue 2.6 完成,不用担心兼容问题。

项目技术升级后经历了几个迭代,运行良好。本项目的加载方式是老版本 Vue 的微前端加载方式,可以说限制极大了,tanstack 用不了,现学现卖的改用了 swrv,也成功完成了改造,可见兼容性挺不错的,还等什么,动手吧。

写文章本身也是一个学习的过程,也请读者能指出文章中的疏忽错漏之处。如果本文对你有所帮助,欢迎点赞收藏。