普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月21日首页

你的 Vue TransitionGroup 组件,VuReact 会编译成什么样的 React 代码?

作者 Ruihong
2026年4月21日 09:08

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中内置的 <TransitionGroup> 组件经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中 <TransitionGroup> 组件的用法。

编译对照

TransitionGroup:列表过渡动画

<TransitionGroup> 是 Vue 中用于为列表项的插入、移除和重排提供过渡动画的内置组件,是 <Transition> 的列表版本。

基础列表过渡

  • Vue 代码:
<template>
  <TransitionGroup name="list" tag="ul">
    <li v-for="item in items" :key="item.id">
      {{ item.name }}
    </li>
  </TransitionGroup>
</template>
  • VuReact 编译后 React 代码:
import { TransitionGroup } from '@vureact/runtime-core';

<TransitionGroup name="list" tag="ul">
  {items.map((item) => (
    <li key={item.id}>{item.name}</li>
  ))}
</TransitionGroup>

从示例可以看到:Vue 的 <TransitionGroup> 组件被编译为 VuReact Runtime 提供的 TransitionGroup 适配组件,可理解为「React 版的 Vue TransitionGroup」。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue <TransitionGroup> 的行为,实现列表过渡动画
  2. 列表支持:专门为列表项的进入、离开和移动提供动画支持
  3. 容器标签:通过 tag 属性指定列表容器元素
  4. key 要求:列表项必须提供稳定的 key 属性

对应的 CSS 样式

.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

.list-enter-active {
  opacity: 1;
  transform: translateX(0);
  transition: all 0.5s ease;
}

.list-leave-active {
  opacity: 0;
  transform: translateX(30px);
  transition: all 0.5s ease;
}

列表重排与移动动画

<TransitionGroup> 支持列表项重排时的平滑移动动画,通过 moveClass 属性实现。

  • Vue 代码:
<template>
  <TransitionGroup name="list" tag="ul" move-class="list-move">
    <li v-for="item in items" :key="item.id">
      {{ item.name }}
    </li>
  </TransitionGroup>
</template>
  • VuReact 编译后 React 代码:
<TransitionGroup name="list" tag="ul" moveClass="list-move">
  {items.map((item) => (
    <li key={item.id}>{item.name}</li>
  ))}
</TransitionGroup>

移动动画 CSS

/* 移动动画类 */
.list-move {
  transition: all 0.5s ease;
}

/* 离开动画需要绝对定位 */
.list-leave-active {
  position: absolute;
}

移动动画原理

  1. FLIP 技术:使用 First-Last-Invert-Play 技术实现平滑移动
  2. 位置计算:计算元素新旧位置差异,应用反向变换
  3. 平滑过渡:通过 CSS 过渡实现位置变化的动画效果
  4. 性能优化:使用 transform 属性实现高性能动画

自定义容器元素

通过 tag 属性可以指定列表的容器元素类型。

  • Vue 代码:
<template>
  <TransitionGroup name="fade" tag="div" class="item-list">
    <div v-for="item in items" :key="item.id" class="item">
      {{ item.name }}
    </div>
  </TransitionGroup>
</template>
  • VuReact 编译后 React 代码:
<TransitionGroup name="fade" tag="div" className="item-list">
  {items.map((item) => (
    <div key={item.id} className="item">
      {item.name}
    </div>
  ))}
</TransitionGroup>

tag 属性作用

  1. 容器类型:指定渲染的 HTML 元素类型(div、ul、ol 等)
  2. 语义化:使用合适的语义化标签
  3. 样式控制:方便应用容器样式
  4. 结构清晰:保持清晰的 DOM 结构

继承 Transition 功能

<TransitionGroup> 继承了 <Transition> 的所有功能,支持相同的属性和钩子。

  • Vue 代码:
<template>
  <TransitionGroup 
    name="slide" 
    tag="div"
    :duration="500"
    @enter="onEnter"
    @leave="onLeave"
  >
    <div v-for="item in items" :key="item.id">
      {{ item.name }}
    </div>
  </TransitionGroup>
</template>
  • VuReact 编译后 React 代码:
<TransitionGroup
  name="slide"
  tag="div"
  duration={500}
  onEnter={onEnter}
  onLeave={onLeave}
>
  {items.map((item) => (
    <div key={item.id}>{item.name}</div>
  ))}
</TransitionGroup>

继承的功能

  1. 自定义类名:支持 enter/leave 相关的自定义类名
  2. JavaScript 钩子:支持所有过渡生命周期钩子
  3. 持续时间:支持 duration 属性控制动画时长
  4. CSS 控制:支持 css 属性控制是否应用 CSS 过渡

编译策略总结

VuReact 的 TransitionGroup 编译策略展示了完整的列表过渡转换能力

  1. 组件直接映射:将 Vue <TransitionGroup> 直接映射为 VuReact 的 <TransitionGroup>
  2. 属性完全支持:支持 nametagmoveClass 等所有属性
  3. 列表渲染转换:将 v-for 转换为 map 函数调用
  4. 动画功能继承:继承所有 <Transition> 的动画功能

注意事项

  1. key 必须:列表项必须提供稳定的 key,否则动画可能异常
  2. CSS 要求:必须在 *-enter-active*-leave-active 中设置过渡外观
  3. 移动动画:离开动画需要设置 position: absolute

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动实现列表过渡动画逻辑。编译后的代码既保持了 Vue 的列表过渡语义和动画效果,又符合 React 的组件设计模式,让迁移后的应用保持完整的列表过渡能力。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

【Next.js】基础知识速查

作者 Explore
2026年4月20日 16:30

前言:本文把 Next.js App Router 的高频知识进行总结,给出简洁说明和示例,可直接当作速查清单与复盘资料。

1. SSR 和 CSR:先建立整体认知

CSR(Client Side Rendering)

  • 浏览器先拿到基础 HTML,再下载 JS 后在客户端渲染页面。
  • 首屏通常依赖 JS 执行,页面交互能力强,适合高交互后台系统。

SSR(Server Side Rendering)

  • 服务端先把页面渲染成 HTML 返回给浏览器,客户端再进行水合(Hydration)。
  • 首屏内容到达更早,对 SEO 和社交分享更友好。

SSR 的核心好处

  • SEO 友好,搜索引擎更容易拿到完整内容。
  • 可在服务端预处理数据,减少页面加载后的额外请求。
  • 能规避很多浏览器端跨域限制(由服务端统一请求外部 API)。
  • 便于聚合多后端服务(例如微服务 + GraphQL 网关),前端调用更简单。
  • 更好的社交平台预览(OG/meta tags 能在首个 HTML 中返回)。
  • 安全性更高:敏感逻辑和密钥可留在服务端执行。

2. App Router 文件层级:layout、template、page

App Router 的渲染层级通常是:

layout > template > page

layout

  • 支持根 layout 和嵌套 layout。
  • 路由切换时,layout 默认会复用,内部状态可保留。

template

  • 每次导航都会重新创建实例。
  • 路由切换后其局部状态不会保留。

简单理解:

  • 希望状态保留,用 layout
  • 希望切换即重置,用 template

3. 路由、动态路由、路由组

文件路由

  • 目录即路由,page.tsx 对应该层页面。

动态路由

  • [slug]:单段动态参数。
  • [...slug]:Catch-all,匹配多段。
  • [[...slug]]:可选 Catch-all,可匹配空路径。

在 Next.js 15+ 的服务端组件中,params 通常按 Promise 处理后再使用。

// app/blog/[slug]/page.tsx
export default async function BlogDetail({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  return <div>slug: {slug}</div>;
}

路由组

  • 使用 (groupName) 目录分组。
  • 只做组织结构,不影响 URL。

最小目录示例:

app/
  (marketing)/
    about/page.tsx   -> /about
  (shop)/
    cart/page.tsx    -> /cart

4. 平行路由(Parallel Routes)

平行路由可以理解为“多插槽并行渲染”,和 Vue 插槽思路接近。

语法

  • @ 开头的目录,如 @modal@dashboard
  • 默认插槽相当于 @children

软导航 vs 硬导航

  • 软导航:通过 <Link> 跳转,会尽量复用已有 UI 状态。
  • 硬导航:浏览器刷新或直接输入 URL,会重新加载整页。

default.tsx 的作用

给某个平行路由槽位提供兜底内容,避免导航时因为缺少对应子路由而出现不一致或空白。

最小目录示例:

app/
  dashboard/
    layout.tsx
    @analytics/
      page.tsx
      default.tsx
    @team/
      page.tsx
      default.tsx

5. 拦截路由(Intercepting Routes)

语法:(..)

典型场景:

  • 在列表页点击图片后,以弹窗展示详情(仍保留列表上下文)。
  • 把详情页链接分享给别人时,对方可直接打开完整详情页。

这类体验通常会配合“平行路由 + 拦截路由”实现。

最小目录示例(列表页弹窗 + 独立详情页):

app/
  feed/
    page.tsx
    @modal/
      (..)photo/[id]/page.tsx   // 从 feed 拦截进入弹窗
  photo/[id]/page.tsx           // 直接访问时展示完整详情页

6. 在客户端获取路径信息

这一块最容易混淆,先记住一句话:

  • useParams 读“路径动态段”(如 /posts/[id])。
  • useSearchParams 读“查询参数”(如 ?tab=comment)。
  • usePathname 读“当前路径字符串”(不含查询串)。

假设当前 URL 是:/posts/123?tab=comment&from=home

  • useParams() -> { id: "123" }
  • useSearchParams().get("tab") -> "comment"
  • usePathname() -> "/posts/123"
"use client";

import { useParams, usePathname, useSearchParams } from "next/navigation";

export default function Demo() {
  const { id } = useParams<{ id: string }>();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const keyword = searchParams.get("keyword");
  const tab = searchParams.get("tab");

  return (
    <div>
      <p>id: {id}</p>
      <p>pathname: {pathname}</p>
      <p>keyword: {keyword}</p>
      <p>tab: {tab}</p>
    </div>
  );
}

7. 页面跳转方式

<Link>

  • 增强版 a 标签,支持预取(prefetch)。
  • 推荐用于常规导航。

useRouter()

  • 编程式导航:router.push()router.replace()router.back()router.refresh()
  • 适合事件回调中的跳转控制。
"use client";

import Link from "next/link";
import { useRouter } from "next/navigation";

export default function NavDemo() {
  const router = useRouter();
  return (
    <>
      <Link href="/posts">去列表页</Link>
      <button onClick={() => router.push("/posts/1")}>查看详情</button>
      <button onClick={() => router.refresh()}>刷新当前路由数据</button>
    </>
  );
}

8. Metadata:静态与动态

App Router 支持通过 metadatagenerateMetadata 配置 SEO 信息。

  • 静态 metadata:页面级固定标题、描述。
  • 动态 metadata:可根据路由参数或接口数据动态生成。
// 静态 metadata
export const metadata = {
  title: "文章列表",
  description: "博客文章列表页",
};
// 动态 metadata
export async function generateMetadata({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  return {
    title: `文章-${id}`,
  };
}

9. 404 处理:全局与局部

全局 404

  • 根目录 not-found.tsx,兜底所有未匹配页面。

局部 404

  • 某个路由段下也可放置 not-found.tsx
  • 业务中手动触发:
import { notFound } from "next/navigation";

notFound();

10. 路由处理程序(Route Handlers)

文件约定:app/api/**/route.ts

可实现 RESTful 风格接口,也支持动态路由参数。

示例:

// app/api/posts/[id]/route.ts
export async function GET() {}
export async function PATCH() {}
export async function DELETE() {}

11. GET 缓存何时失效(常见误区)

以下场景通常不会走静态缓存(或会触发动态渲染):

  • 在 Route Handler 中读取 Request 里的动态信息(如 query、cookie、header)并参与响应计算。
  • 使用非 GET 方法(POST/PUT/PATCH/DELETE)。
  • 使用动态函数(如 cookies()headers())。
  • 显式通过 dynamicrevalidatecache 配置动态策略。

建议:不要过度依赖“默认缓存行为”,在关键接口上显式写清缓存策略。

12. Middleware(请求拦截层),现称 Proxy

常见写法:

export const config = {
  matcher: "/about/:path*",
};

应用场景:

  • 登录态校验、权限控制。
  • 登录/退出后的重定向策略。
  • 国际化前缀处理等。

它的职责更像应用入口处的“网关/代理层”,但工程上仍按 middleware.ts 约定使用。

// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const token = request.cookies.get("token")?.value;
  if (!token) return NextResponse.redirect(new URL("/login", request.url));
  return NextResponse.next();
}

13. 客户端组件 vs 服务端组件

什么时候必须用客户端组件("use client"

  • 需要事件处理(点击、输入等)。
  • 需要 React Hooks(useStateuseEffect 等)。
  • 需要浏览器 API(windowlocalStorage)。
  • 使用依赖 state/effect/browser API 的自定义 Hook。
  • 使用类组件。

客户端组件执行过程

  • 服务端预渲染出初始 HTML(可选)。
  • 客户端进行水合并接管交互。
  • 后续状态变化在客户端重新渲染。

服务端组件(RSC)

  • 仅运行在服务端。
  • 可在构建时静态生成,也可在请求时动态渲染。
  • 更适合数据读取、拼装和安全逻辑处理。

组合注意事项

  • 服务端组件可以直接使用客户端组件。
  • 客户端组件不能直接 import 服务端组件;常见做法是通过 children 组合。
  • 第三方库若内部使用浏览器 API,需包一层客户端组件再在服务端树中使用。
  • Context Provider 放在根时,通常也需要客户端包装层。

14. 服务端组件的数据共享

在 RSC 中可直接 fetch 读取数据并复用缓存。

  • Next.js 14:fetch 默认更偏向可缓存策略(视场景而定)。
  • Next.js 15+:默认行为改为更偏动态(常见理解是默认不缓存),迁移时要显式声明缓存策略。

迁移建议:把缓存意图写清楚,不依赖“默认行为”。

15. 服务端组件渲染策略:静态与动态

静态渲染

  • 适合内容相对稳定页面。
  • 支持 revalidate 进行按时间增量更新(ISR)。

动态渲染

触发条件常见有:

  • 使用动态函数:cookies()headers()searchParams
  • 使用未缓存的 fetch

16. fetch

await fetch(url, {
  next: { revalidate: 3000, tags: ["post-list"] },
});

常见刷新策略

  • 基于时间:revalidate
  • 按路径:revalidatePath("/posts")
  • 按标签:revalidateTag("post-list")

revalidateTag 适合批量失效同类查询,通常比路径粒度更灵活。

17. 四类缓存机制

1)请求级缓存(Request Memoization)

  • React 层能力,同一次请求内去重相同 fetch。

2)数据缓存(Data Cache)

  • Next.js 层能力,跨请求复用数据。
  • cacherevalidatetags 影响。

3)全路由缓存(Full Route Cache)

  • 面向静态路由产物缓存。

4)客户端路由缓存(Router Cache)

  • 基于 App Router 在客户端缓存段数据,提升导航速度。
  • 页面刷新会清空这类缓存。

补充:

  • <Link> 会触发预取,常见默认缓存窗口:静态页更长、动态页更短。
  • router.refresh() 会请求新数据并更新当前路由树。

18. Server Action / Server Function

使用方式:"use server"

两种常见声明级别

  • 函数级别:在具体函数体前声明。
  • 模块级别:文件顶部声明,文件内导出函数都在服务端执行。

典型应用

  • 表单提交写库。
  • 数据变更后触发 revalidatePath / revalidateTag
  • 将客户端“上提请求”改为服务端执行,减少 API 样板代码。
// app/actions.ts
"use server";

import { revalidatePath } from "next/cache";

export async function createPost(formData: FormData) {
  const title = String(formData.get("title") || "");
  // await db.post.create({ data: { title } });
  revalidatePath("/posts");
}
// app/posts/new/page.tsx
import { createPost } from "@/app/actions";

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="请输入标题" />
      <button type="submit">提交</button>
    </form>
  );
}

19. 类型校验:推荐 Zod

在 Server Action 或 Route Handler 中,建议先做参数校验再执行业务逻辑:

import { z } from "zod";

const CreatePostSchema = z.object({
  title: z.string().min(1, "标题不能为空"),
  content: z.string().min(1, "内容不能为空"),
});

优势:

  • 运行时校验 + TS 类型推导统一。
  • 错误信息可控,便于前后端协作。

20. Turbopack 与 React Compiler

Turbopack 的价值

  • 更快的本地构建与热更新反馈。
  • 惰性打包,按需处理模块。
  • 增量计算与缓存,改动范围越小收益越明显。

React Compiler(关注趋势)

  • 目标是让 React 编译器自动优化部分渲染性能问题。
  • 在真实项目中仍需配合良好组件边界和状态设计,不能把性能完全交给“黑盒优化”。

实战建议:如何把这些知识真正用起来

  1. 先定渲染策略:页面是静态优先还是动态优先。
  2. 再定路由结构:普通路由、平行路由、拦截路由如何组合。
  3. 明确缓存策略:哪些数据走 revalidate,哪些走 tag 失效。
  4. 写操作优先 Server Action:并在动作后精确触发缓存失效。
  5. 最后补齐类型和异常处理:Zod + 404 + 边界兜底。
昨天 — 2026年4月20日首页

你的 Vue KeepAlive 组件,VuReact 会编译成什么样的 React 代码?

作者 Ruihong
2026年4月20日 10:11

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中内置的 <KeepAlive> 组件经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中 <KeepAlive> 组件的用法。

编译对照

KeepAlive:组件缓存

<KeepAlive> 是 Vue 中用于缓存组件实例的内置组件,可以在动态切换组件时保留组件状态,避免重新渲染和数据丢失。

基础 KeepAlive 使用

  • Vue 代码:
<template>
  <KeepAlive>
    <component :is="currentView" />
  </KeepAlive>
</template>
  • VuReact 编译后 React 代码:
import { KeepAlive } from '@vureact/runtime-core';

<KeepAlive>
  <Component is={currentView} />
</KeepAlive>

从示例可以看到:Vue 的 <KeepAlive> 组件被编译为 VuReact Runtime 提供的 KeepAlive 适配组件,可理解为「React 版的 Vue KeepAlive」。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue <KeepAlive> 的行为,实现组件实例缓存
  2. 状态保持:缓存被移除的组件实例,避免状态丢失
  3. 性能优化:减少不必要的组件重新渲染
  4. React 适配:在 React 环境中实现 Vue 的缓存语义

带 key 的 KeepAlive

为了确保缓存正确工作,建议为动态组件提供稳定的 key

  • Vue 代码:
<template>
  <KeepAlive>
    <component :is="currentComponent" :key="componentKey" />
  </KeepAlive>
</template>
  • VuReact 编译后 React 代码:
<KeepAlive>
  <Component is={currentComponent} key={componentKey} />
</KeepAlive>

key 的重要性

  1. 缓存标识key 用于标识和匹配缓存实例
  2. 稳定切换:确保组件切换时能正确命中缓存
  3. 性能优化:避免不必要的缓存创建和销毁
  4. 最佳实践:始终为动态组件提供稳定的 key

包含与排除控制

<KeepAlive> 支持通过 includeexclude 属性精确控制哪些组件需要缓存。

include:包含特定组件

  • Vue 代码:
<template>
  <KeepAlive :include="['ComponentA', 'ComponentB']">
    <component :is="currentView" />
  </KeepAlive>
</template>
  • VuReact 编译后 React 代码:
<KeepAlive include={['ComponentA', 'ComponentB']}>
  <Component is={currentView} />
</KeepAlive>

exclude:排除特定组件

  • Vue 代码:
<template>
  <KeepAlive :exclude="['GuestPanel', /^Temp/]">
    <component :is="currentView" />
  </KeepAlive>
</template>
  • VuReact 编译后 React 代码:
<KeepAlive exclude={['GuestPanel', /^Temp/]}>
  <Component is={currentView} />
</KeepAlive>

匹配规则

  1. 字符串匹配:精确匹配组件名
  2. 正则表达式:匹配符合模式的组件名
  3. 数组组合:支持字符串和正则的数组组合
  4. key 匹配:同时尝试匹配组件名和缓存 key

最大缓存实例数

通过 max 属性可以限制最大缓存数量,避免内存过度使用。

  • Vue 代码:
<template>
  <KeepAlive :max="3">
    <component :is="currentTab" />
  </KeepAlive>
</template>
  • VuReact 编译后 React 代码:
<KeepAlive max={3}>
  <Component is={currentTab} />
</KeepAlive>

缓存淘汰策略

  1. LRU 算法:淘汰最久未访问的缓存实例
  2. 内存管理:自动清理超出限制的缓存
  3. 性能平衡:在内存使用和性能之间取得平衡
  4. 智能管理:根据访问频率智能管理缓存

缓存生命周期

<KeepAlive> 缓存的组件有特殊的生命周期,可以通过相应的 Hook 监听。

激活与停用生命周期

  • Vue 代码:
<script setup>
import { onActivated, onDeactivated } from 'vue';

onActivated(() => {
  console.log('组件被激活');
});

onDeactivated(() => {
  console.log('组件被停用');
});
</script>
  • VuReact 编译后 React 代码:
import { useActived, useDeactivated } from '@vureact/runtime-core';

function MyComponent() {
  useActived(() => {
    console.log('组件被激活');
  });

  useDeactivated(() => {
    console.log('组件被停用');
  });

  return <div>组件内容</div>;
}

生命周期事件

  1. useActived:组件从缓存中恢复显示时触发
  2. useDeactivated:组件被缓存时触发
  3. 首次渲染:组件首次渲染时也会触发 activated
  4. 最终卸载:组件最终被销毁时触发 deactivated

编译策略总结

VuReact 的 KeepAlive 编译策略展示了完整的组件缓存转换能力

  1. 组件直接映射:将 Vue <KeepAlive> 直接映射为 VuReact 的 <KeepAlive>
  2. 属性完全支持:支持 includeexcludemax 等所有属性
  3. 生命周期适配:将 Vue 生命周期 Hook 转换为 React Hook
  4. 缓存语义保持:完全保持 Vue 的缓存行为和语义

KeepAlive 的工作原理

  1. 实例缓存:组件切出时保留实例在内存中
  2. 状态保持:保持组件的所有状态和数据
  3. DOM 保留:保留组件的 DOM 结构
  4. 智能恢复:切回时快速恢复之前的实例

性能优化策略

  1. 按需缓存:只缓存真正需要的组件
  2. 内存管理:智能管理缓存内存使用
  3. 快速恢复:优化缓存恢复性能
  4. 垃圾回收:及时清理不再需要的缓存

注意事项

  1. 单一子节点<KeepAlive> 只能有一个直接子节点
  2. 组件类型:只能缓存组件元素,不能缓存普通元素
  3. key 要求:缺少稳定 key 时会降级为非缓存渲染

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动实现组件缓存逻辑。编译后的代码既保持了 Vue 的缓存语义和性能优势,又符合 React 的组件设计模式,让迁移后的应用保持完整的组件缓存能力。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

你的 Vue slot 插槽,VuReact 会编译成什么样的 React 代码?

作者 Ruihong
2026年4月20日 09:17

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 <slot> 插槽经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中的插槽用法。

编译对照

默认插槽:<slot>

默认插槽是 Vue 中最基本的插槽形式,用于接收父组件传递的默认内容。

  • Vue 代码:
<!-- 子组件 Child.vue -->
<template>
  <div class="container">
    <slot></slot>
  </div>
</template>

<!-- 父组件使用 -->
<Child>
  <p>这是插槽内容</p>
</Child>
  • VuReact 编译后 React 代码:
// 子组件 Child.jsx
function Child(props) {
  return (
    <div className="container">
      {props.children}
    </div>
  );
}

// 父组件使用
<Child>
  <p>这是插槽内容</p>
</Child>

从示例可以看到:Vue 的 <slot> 元素被编译为 React 的 children prop。VuReact 采用 children 编译策略,将插槽出口转换为 React 的标准 children 接收方式,完全保持 Vue 的默认插槽语义——接收父组件传递的子内容并渲染。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue 默认插槽的行为,实现内容分发
  2. React 原生支持:使用 React 标准的 children 机制,无需额外适配
  3. 语法简洁:Vue 的 <slot> 简化为 {children} 表达式
  4. 性能优化:直接使用 React 的原生机制,无运行时开销

具名插槽:<slot name="xxx">

具名插槽允许组件定义多个插槽出口,父组件可以通过名称指定内容插入位置。

  • Vue 代码:
<!-- 子组件 Layout.vue -->
<template>
  <div class="layout">
    <header>
      <slot name="header"></slot>
    </header>
    <main>
      <slot></slot>
    </main>
    <footer>
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

<!-- 父组件使用 -->
<Layout>
  <template #header>
    <h1>页面标题</h1>
  </template>
  
  <p>主要内容</p>
  
  <template #footer>
    <p>版权信息</p>
  </template>
</Layout>
  • VuReact 编译后 React 代码:
// 子组件 Layout.jsx
function Layout(props) {
  return (
    <div className="layout">
      <header>{props.header}</header>
      <main>{props.children}</main>
      <footer>{props.footer}</footer>
    </div>
  );
}

// 父组件使用
<Layout
  header={<h1>页面标题</h1>}
  footer={<p>版权信息</p>}
>
  <p>主要内容</p>
</Layout>

从示例可以看到:Vue 的具名插槽 <slot name="xxx"> 被编译为 React 的 props。VuReact 采用 props 编译策略,将具名插槽出口转换为组件的命名 props,完全保持 Vue 的具名插槽语义——通过不同的 prop 名称区分不同的插槽内容。

编译规则

  1. 插槽名映射<slot name="header">header prop
  2. 默认插槽<slot>children prop
  3. props 接收:在组件函数参数中解构接收所有插槽 props

作用域插槽:<slot :prop="value">

作用域插槽允许子组件向插槽内容传递数据,实现更灵活的渲染控制。

  • Vue 代码:
<!-- 子组件 List.vue -->
<template>
  <ul>
    <li v-for="(item, i) in props.items" :key="item.id">
      <slot :item="item" :index="i"></slot>
    </li>
  </ul>
</template>

<!-- 父组件使用 -->
<List :items="users">
  <template v-slot="slotProps">
    <div class="user-item">
      {{ slotProps.index + 1 }}. {{ slotProps.item.name }}
    </div>
  </template>
</List>
  • VuReact 编译后 React 代码:
// 子组件 List.jsx
function List(props) {
  return (
    <ul>
      {props.items.map((item, index) => (
        <li key={item.id}>
          {props.children?.({ item, index })}
        </li>
      ))}
    </ul>
  );
}

// 父组件使用
<List 
  items={users}
  children={(slotProps) => (
    <div className="user-item">
      {slotProps.index + 1}. {slotProps.item.name}
    </div>
  )}
/>

从示例可以看到:Vue 的作用域插槽被编译为 React 的函数 children。VuReact 采用 函数 children 编译策略,将作用域插槽出口转换为接收参数的函数,完全保持 Vue 的作用域插槽语义——子组件通过函数调用向父组件传递数据,父组件通过函数参数接收数据并渲染。

编译规则

  1. 插槽属性转换<slot :item="item" :index="i"> → 函数参数 { item, index }
  2. 函数调用:在渲染位置调用 children() 函数并传递数据
  3. 可选链保护:使用 ?. 避免未提供插槽内容时的错误

具名作用域插槽:<slot name="xxx" :prop="value">

具名作用域插槽结合了具名插槽和作用域插槽的特性。

  • Vue 代码:
<!-- 子组件 Table.vue -->
<template>
  <table>
    <thead>
      <tr>
        <slot name="header" :columns="props.columns"></slot>
      </tr>
    </thead>
    <tbody>
      <tr v-for="row in props.data" :key="row.id">
        <slot name="body" :row="row" :columns="props.columns"></slot>
      </tr>
    </tbody>
  </table>
</template>

<!-- 父组件使用 -->
<Table :columns="tableColumns" :data="tableData">
  <template #header="headerProps">
    <th v-for="col in headerProps.columns" :key="col.id">
      {{ col.title }}
    </th>
  </template>
  
  <template #body="bodyProps">
    <td v-for="col in bodyProps.columns" :key="col.id">
      {{ bodyProps.row[col.field] }}
    </td>
  </template>
</Table>
  • VuReact 编译后 React 代码:
// 子组件 Table.jsx
function Table(props) {
  return (
    <table>
      <thead>
        <tr>
          {props.header?.({ columns: props.columns })}
        </tr>
      </thead>
      <tbody>
        {props.data.map((row) => (
          <tr key={row.id}>
            {props.body?.({ row: props.row, columns: props.columns })}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

// 父组件使用
<Table
  columns={tableColumns}
  data={tableData}
  header={(headerProps) => (
    <>
      {headerProps.columns.map((col) => (
        <th key={col.id}>{col.title}</th>
      ))}
    </>
  )}
  body={(bodyProps) => (
    <>
      {bodyProps.columns.map((col) => (
        <td key={col.id}>{bodyProps.row[col.field]}</td>
      ))}
    </>
  )}
/>

编译策略

  1. 具名函数 props:具名作用域插槽转换为函数 props
  2. 参数传递:正确传递作用域参数
  3. Fragment 包装:多个元素使用 Fragment 包装
  4. 类型安全:保持 TypeScript 类型定义的完整性

插槽默认内容

Vue 支持在插槽定义处提供默认内容,当父组件没有提供插槽内容时显示。

  • Vue 代码:
<!-- 子组件 Button.vue -->
<template>
  <button class="btn">
    <slot>
      <span class="default-text">点击我</span>
    </slot>
  </button>
</template>
  • VuReact 编译后 React 代码:
// 子组件 Button.jsx
function Button(props) {
  return (
    <button className="btn">
      {props.children || <span className="default-text">点击我</span>}
    </button>
  );
}

默认内容处理规则

  1. 条件渲染:使用 || 运算符检查 children 是否存在
  2. 默认值提供:当 children 为 falsy 值时渲染默认内容
  3. React 模式:使用标准的 React 条件渲染模式

动态插槽名

Vue 支持动态的插槽名称,用于更灵活的插槽选择。

  • Vue 代码:
<!-- 子组件 DynamicSlot.vue -->
<template>
  <div>
    <slot :name="dynamicSlotName"></slot>
  </div>
</template>
  • VuReact 编译后 React 代码:
// 子组件 DynamicSlot.jsx
function DynamicSlot(props) {
  return (
    <div>
      {props[dynamicSlotName]}
    </div>
  );
}

动态插槽处理

  1. 计算属性名:使用对象计算属性语法接收动态插槽
  2. 运行时确定:插槽名在运行时确定

编译策略总结

VuReact 的 <slot> 编译策略展示了完整的插槽系统转换能力

  1. 默认插槽:转换为 React 的 children
  2. 具名插槽:转换为组件的命名 props
  3. 作用域插槽:转换为函数 children 或函数 props
  4. 默认内容:支持插槽默认内容
  5. 动态插槽:支持动态插槽名称

插槽类型映射表

Vue 插槽类型 React 对应形式 说明
<slot> children 默认插槽,作为组件的子元素
<slot name="xxx"> xxx prop 具名插槽,作为组件的属性
<slot :prop="value"> 函数 children 作用域插槽,作为接收参数的函数
<slot name="xxx" :prop="value"> 函数 xxx prop 具名作用域插槽,作为函数属性

性能优化策略

  1. 静态插槽优化:对于静态插槽内容,编译为静态 JSX
  2. 函数缓存:对于作用域插槽,智能缓存渲染函数
  3. 按需生成:根据实际使用情况生成最简化的代码
  4. 类型推导:支持在 TypeScript 中智能推导插槽的类型定义

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动重写插槽逻辑。编译后的代码既保持了 Vue 的语义和灵活性,又符合 React 的组件设计模式,让迁移后的应用保持完整的内容分发能力。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

React新手小白:如何入门 React 响应式交互与 JSX 艺术

作者 暗不需求
2026年4月19日 21:04

一 什么是React?? 他是基于什么的? 学了它有什么用呢??

1. 核心定义:声明式与组件化

React 的核心定位是 “用于构建用户界面的 JavaScript 库” 。它主要关注 MVC 架构中的 V(View,视图层)

  • 声明式编程 (Declarative): 在 React 中,你只需要描述界面在某种“状态”下应该长什么样,而不需要手动操作 DOM 去更新界面。当数据变动时,React 会自动处理界面的高效更新。
  • 组件化 (Component-Based): 这是 React 的灵魂。你可以将复杂的 UI 拆分成一个个独立、可复用的“组件”(Component)。每个组件拥有自己的逻辑和样式,最终像搭积木一样拼成完整的应用。

完整项目链接:gitee.com/hong-strong…


2. 三大核心技术支柱

虚拟 DOM (Virtual DOM)

传统的网页操作(真实 DOM)非常昂贵且缓慢。React 在内存中维护了一份 UI 的轻量级副本,即“虚拟 DOM”。

  1. 当状态发生变化时,React 先更新虚拟 DOM。
  2. 通过 Diff 算法 对比新旧虚拟 DOM 的差异。
  3. 仅将真正发生变化的部分更新到真实网页上(这一过程称为 Reconciliation)。

JSX 语法

React 引入了 JSX(JavaScript XML),允许你在 JavaScript 代码中直接编写类似 HTML 的结构。这使得 UI 逻辑与标记语言高度耦合,代码直观且易于维护。

JavaScript

function Welcome() {
  return <h1>Hello, React!</h1>;
}

单向数据流 (One-Way Data Flow)

在 React 中,数据总是从父组件通过 props 流向子组件。这种单向的数据流动让应用的逻辑变得可预测,调试时也更容易追踪数据的源头。


3. 为什么 React 如此受欢迎?

  • 极高的性能: 得益于虚拟 DOM 和优秀的渲染机制。
  • 强大的生态: 拥有庞大的开源社区,无论是状态管理(Redux, Zustand)、路由(React Router),还是 UI 组件库(Ant Design, MUI),都能找到成熟的方案。
  • 跨平台能力: 学习了 React 之后,你可以通过 React Native 构建原生移动应用(iOS/Android),实现“一次学习,随处编写”。
  • Hooks 革命: 自 React 16.8 引入 Hooks 以来,函数式组件(Functional Components)成为了主流,极大地简化了状态管理和副作用处理的复杂性。

二 那作为一个小白 如何初始化一个react项目呢?

我这边选择使用的是Vite,因为 Vite 是目前前端工程化的首选工具。它启动极快,热更新(HMR)几乎是瞬间完成。

步骤:

  1. 打开终端,输入以下命令:

    npm create vite
    
  2. 按照提示进行选择:

    • Select a framework: 选择 React
    • Select a variant: 选择 JavaScriptTypeScript(根据情况选择语言)
  3. 进入目录并启动:

    cd my-react-app
    npm install
    npm run dev
    
  4. 得到网址: 运行上述代码后,你会在终端得到一个类似于http://localhost:5173 网址,这样你就成功运行了你的第一个React项目 项目结构如下图所展示:

df1b4f4bbd30dbe57ece32553b1a07d5.png

三: React 的核心用法。

1:理解“挂载” —— 应用的起点

每个 React 应用都有一个入口文件(通常是 main.jsx),它的任务是将我们写的 React 组件“挂载”到真实的 HTML 页面上。

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'

// 使用 createRoot 找到 HTML 中的 root 节点,并将根组件 <App /> 渲染进去
createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

2:组件化开发 —— 像搭积木一样写网页

在 React 中,函数就是组件。组件是开发的基本单位,它将 HTML、CSS 和 JS 逻辑封装在一起,完成独立的功能。

我们可以将页面拆分成多个子组件,然后在根组件中组合它们:

// 定义子组件:头部
function JuejinHeader() {
  return (
    <header><h1>掘金首页</h1></header>
  )
}

// 定义子组件:列表
const Articles = () => <div>文章列表内容</div>;

// 在 App 根组件中组合它们
function App() {
  return (
    <div>
      <JuejinHeader />
      <main>
        <Articles />
      </main>
    </div>
  )
}

3:掌握 JSX —— 在 JS 中书写 UI

JSX(XML in JS)是 React 的模板语法,它让我们能在 JavaScript 里直接写 HTML 结构。

  • 语法糖:JSX 最终会被转化为 createElement 渲染函数。
  • 规则:JSX 最外层只能有一个根元素(可以使用空标签 <></> 作为文档碎片)。
  • 属性名:由于 class 是 JS 关键字,在 JSX 中定义类名要使用 className

第四步:响应式状态 —— 让页面“动”起来

React 的核心特性之一是响应式(Reactive) 。我们使用 useState 来定义数据状态,当状态改变时,React 会自动更新 UI。

1. 定义与更新状态

import { useState } from 'react';

function App() {
  // name 是状态值,setName 是更新它的函数
  const [name, setName] = useState("vue");

  // 3秒后自动将 "vue" 改为 "react"
  setTimeout(() => {
    setName("react"); 
  }, 3000);

  return <h1>Hello {name}!</h1>;
}

2. 条件渲染与列表渲染

你可以利用原生 JS 的逻辑(如三元运算符或 map 函数)来控制界面的显示:

{/* 列表渲染:记得给每个子项添加唯一的 key */}
<ul>
  {todos.map(todo => (
    <li key={todo.id}>{todo.title}</li>
  ))}
</ul>

{/* 条件渲染:登录逻辑切换 */}
{isLoggedIn ? <div>已登录</div> : <div>未登录</div>}
<button onClick={() => setIsLoggedIn(!isLoggedIn)}>
  {isLoggedIn ? "退出" : "登录"}
</button>

四 React基本知识总结

核心维度 知识要点 代码示例 / 实现细节
组件定义 组件是 React 的基本开发单位,通常表现为返回 JSX 的 JavaScript 函数 function App() { return <div>...</div> }
JSX 语法 XML in JS。一种在 JS 中描述 UI 结构的语法扩展,本质是 createElement 的语法糖。 const element = <h2>JSX 语法扩展</h2>;
JSX 约束 1. 必须有且仅有一个根元素; 2. 标签名大写为组件,小写为原生 HTML。 return (<> ... </>) (使用 Fragments 文档碎片)
属性命名 由于 JS 关键字限制,HTML 的 class 属性需写作 className <span className="title">...</span>
组件化思维 像“搭积木”一样。通过组件树嵌套子组件来构建复杂页面,代替传统的 DOM 树。 <main> <Articles /> <aside><Checkin /></aside> </main>
响应式状态 使用 useState 定义数据。当状态改变时,React 会自动触发界面更新(数据驱动视图)。 const [name, setName] = useState("vue");
列表渲染 使用原生 JS 的 .map() 方法循环数据,且每个子项必须提供唯一的 key todos.map(todo => <li key={todo.id}>{todo.title}</li>)
条件渲染 在 JSX 中使用 三元运算符 或逻辑运算符根据状态显示不同的内容。 {isLoggedIn ? <div>已登录</div> : <div>未登录</div>}
事件处理 使用驼峰式命名的属性绑定交互函数(如 onClick)。 <button onClick={toggleLogin}>登录</button>
项目挂载 应用的入口。使用 createRoot 找到容器并调用 render 挂载根组件。 createRoot(document.getElementById('root')).render(<App />)

总结:欢迎来到 React 的世界

学习React其实不难,只要你登上了这几个台阶,一步一个脚印,视野就会豁然开朗:

“数据是灵魂,组件是肉体,JSX 是灵魂与肉体对话的诗篇。”

你已经掌握了现代前端最强大的武器,请记住这三条锦囊:


1. 核心心法的内化

  • 状态即真相:通过 useState 让数据驱动视图,数组返回的状态值与更新函数是你操控页面的唯一魔法。
  • 组件即模块:像搭积木一样,将复杂的页面拆解成 HeaderArticlesCheckin 等独立单元,这会让你从“搬砖工”晋升为“包工头”。
  • JSX 即桥梁:这种将 XML 融入 JS 的语法,是你描述用户界面最直观、最高效的方式。

2. 给新手的进阶建议

  • 拥抱 Vite 的速度:不要在环境配置上浪费太多时间,利用 Vite 的极速热更新去快速验证你的每一个奇思妙想。
  • 尊重单向数据流:数据总是从父组件流向子组件,这种“长幼有序”的传递方式会让你的代码逻辑极其清晰。
  • 报错是你的导师:React 的报错信息往往非常直观,它们不是阻碍,而是指引你优化代码的地图。

3. 最后的行动指南

与其在文档里反复徘徊,不如在编辑器里反复横跳。

  • 多拆分:如果一个组件超过了 100 行,试着把它拆成两个。
  • 多联想:看到任何一个网站,试着在脑海中用组件树去拆解它。
  • 多实践:React 的魅力不在于“看懂”,而在于当你写下 setName 时,页面如你所愿跳动的那一瞬间。

多敲代码 多学知识 多上手实践 我相信你我都能做得更好!

Vue v-slot → 用 VuReact 转换后变成这样的 React 代码

作者 Ruihong
2026年4月19日 20:39

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 v-slot 指令经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中的 v-slot 指令用法。

编译对照

v-slot / #:基础插槽使用

v-slot(简写为 #) 是 Vue 中用于定义和使用插槽的指令,用于实现组件的内容分发和复用。

默认插槽

  • Vue 代码:
<!-- 父组件 -->
<MyComponent>
  <template #default>
    <p>默认插槽内容</p>
  </template>
</MyComponent>

<!-- 或简写 -->
<MyComponent>
  <p>默认插槽内容</p>
</MyComponent>
  • VuReact 编译后 React 代码:
// 父组件
<MyComponent>
  <p>默认插槽内容</p>
</MyComponent>

从示例可以看到:Vue 的默认插槽被直接编译为 React 的 children。VuReact 采用 children 编译策略,将模板插槽转换为 React 的标准 children 传递方式,完全保持 Vue 的默认插槽语义——将内容作为子元素传递给组件。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue 默认插槽的行为,实现内容分发
  2. React 原生支持:使用 React 标准的 children 机制,无需额外适配
  3. 语法简化:Vue 的 <template #default> 简化为直接传递子元素
  4. 性能优化:直接使用 React 的原生机制,无运行时开销

具名插槽

Vue 支持多个具名插槽,用于更灵活的内容分发。

基础具名插槽

  • Vue 代码:
<!-- 父组件 -->
<Layout>
  <template #header>
    <h1>页面标题</h1>
  </template>
  
  <template #main>
    <p>主要内容区域</p>
  </template>
  
  <template #footer>
    <p>页脚信息</p>
  </template>
</Layout>
  • VuReact 编译后 React 代码:
// 父组件
<Layout 
  header={<h1>页面标题</h1>}
  main={<p>主要内容区域</p>}
  footer={<p>页脚信息</p>}
/>

从示例可以看到:Vue 的具名插槽被编译为 React 的 props。VuReact 采用 props 编译策略,将具名插槽转换为组件的 props 属性,完全保持 Vue 的具名插槽语义——通过不同的 prop 名称区分不同的插槽内容。


作用域插槽

Vue 的作用域插槽允许子组件向父组件传递数据,实现更灵活的渲染控制。

基础作用域插槽

  • Vue 代码:
<!-- 父组件 -->
<DataList :items="users">
  <template #item="slotProps">
    <div class="user-item">
      <span>{{ slotProps.user.name }}</span>
      <span>{{ slotProps.user.age }}岁</span>
    </div>
  </template>
</DataList>

<!-- 子组件 DataList.vue -->
<template>
  <ul>
    <li v-for="item in props.items" :key="item.id">
      <slot name="item" :user="item"></slot>
    </li>
  </ul>
</template>
  • VuReact 编译后 React 代码:
// 父组件
<DataList 
  items={users}
  item={(slotProps) => (
    <div className="user-item">
      <span>{slotProps.user.name}</span>
      <span>{slotProps.user.age}岁</span>
    </div>
  )}
/>

// 子组件 DataList.jsx
function DataList(props) {
  return (
    <ul>
      {props.items.map((itemData) => (
        <li key={itemData.id}>
          {props.item?.({ user: itemData })}
        </li>
      ))}
    </ul>
  );
}

从示例可以看到:Vue 的作用域插槽被编译为 React 的函数 props。VuReact 采用 函数 props 编译策略,将作用域插槽转换为接收参数的函数 prop,完全保持 Vue 的作用域插槽语义——子组件通过函数调用向父组件传递数据,父组件通过函数参数接收数据并渲染。


动态插槽名

Vue 支持动态的插槽名称,用于更灵活的插槽选择。

  • Vue 代码:
<BaseLayout>
  <template #[dynamicSlotName]>
    动态插槽内容
  </template>
</BaseLayout>
  • VuReact 编译后 React 代码:
<BaseLayout 
  {...{ [dynamicSlotName]: "动态插槽内容" }}
/>

编译策略

  1. 计算属性名:使用对象计算属性语法 { [key]: value }
  2. 对象展开:通过对象展开语法应用到组件上
  3. 运行时处理:动态插槽名需要在运行时确定

插槽默认内容

Vue 支持在插槽定义处提供默认内容,当父组件没有提供插槽内容时显示。

  • Vue 代码:
<!-- 子组件 Button.vue -->
<template>
  <button class="btn">
    <slot>
      <span>默认按钮文本</span>
    </slot>
  </button>
</template>
  • VuReact 编译后 React 代码:
// 子组件 Button.jsx
function Button(props) {
  return (
    <button className="btn">
      {props.children || <span>默认按钮文本</span>}
    </button>
  );
}

默认内容处理规则

  1. children 检查:检查 children 是否存在
  2. 默认值渲染:当 children 为 falsy 值时渲染默认内容
  3. React 兼容:使用标准的 React 条件渲染模式

编译策略总结

VuReact 的 v-slot 编译策略展示了完整的插槽系统转换能力

  1. 默认插槽:转换为 React 的 children
  2. 具名插槽:转换为组件的 props
  3. 作用域插槽:转换为函数 props
  4. 动态插槽:支持动态插槽名称
  5. 默认内容:支持插槽默认内容

插槽类型映射表

Vue 插槽类型 React 对应形式 说明
默认插槽 children 作为组件的子元素
具名插槽 prop 作为组件的属性
作用域插槽 函数prop 作为接收参数的函数属性
动态插槽 计算属性 使用对象计算属性语法

性能优化策略

  1. 静态插槽优化:对于静态插槽内容,编译为静态 JSX
  2. 函数缓存:对于作用域插槽,智能缓存渲染函数
  3. 按需生成:根据实际使用情况生成最简化的代码
  4. 类型推导:智能推导插槽的类型定义

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动重写插槽逻辑。编译后的代码既保持了 Vue 的语义和灵活性,又符合 React 的组件设计模式,让迁移后的应用保持完整的内容分发能力。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

你的 Vue v-model,VuReact 会编译成什么样的 React 代码?

作者 Ruihong
2026年4月19日 20:19

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 v-model 指令经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中的 v-model 指令用法。

编译对照

v-model:基础表单双向绑定

v-model 是 Vue 中用于实现表单输入元素双向数据绑定的语法糖,它结合了 v-bindv-on 的功能。

文本输入框

  • Vue 代码:
<input v-model="keyword" />
  • VuReact 编译后 React 代码:
<input
  value={keyword.value}
  onChange={(value) => {
    keyword.value = value;
  }}
/>

从示例可以看到:Vue 的 v-model 指令被编译为 React 的受控组件模式。VuReact 采用 受控组件编译策略,将模板指令转换为 valueonChange 的组合,完全保持 Vue 的双向绑定语义——实现数据与视图的同步更新。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue v-model 的行为,实现双向数据绑定
  2. 受控组件模式:使用 React 标准的受控组件实现
  3. 事件处理:自动处理输入事件和值更新
  4. 响应式集成:与 Vue 的响应式系统无缝集成

不同输入类型的 v-model

Vue 的 v-model 会根据输入元素的类型自动适配,VuReact 也保持了这种智能适配能力。

复选框

  • Vue 代码:
<input type="checkbox" v-model="checked" />
<input type="checkbox" value="vue" v-model="frameworks" />
  • VuReact 编译后 React 代码:
<input
  type="checkbox"
  checked={checked.value}
  onChecked={(e) => {
    checked.value = e.target.checked;
  }}
/>
<input
  type="checkbox"
  value="vue"
  checked={frameworks.value}
  onChange={(e) => {
    frameworks.value = e.target.checked;
  }}
/>

单选按钮

  • Vue 代码:
<input type="radio" value="male" v-model="gender" />
<input type="radio" value="female" v-model="gender" />
  • VuReact 编译后 React 代码:
<input
  type="radio"
  value="male"
  checked={gender.value === 'male'}
  onChange={() => { gender.value = 'male' }}
/>

<input
  type="radio"
  value="female"
  checked={gender.value === 'female'}
  onChange={() => { gender.value = 'female' }}
/>

下拉选择框

  • Vue 代码:
<select v-model="selected">
  <option value="a">选项A</option>
  <option value="b">选项B</option>
</select>
  • VuReact 编译后 React 代码:
<select
  value={selected.value}
  onChange={(e) => {
    selected.value = e.target.value;
  }}
>
  <option value="a">选项A</option>
  <option value="b">选项B</option>
</select>

v-model 修饰符

Vue 的 v-model 支持多种修饰符,用于控制数据更新的时机和格式。

.lazy 修饰符

  • Vue 代码:
<input v-model.lazy="message" />
  • VuReact 编译后 React 代码:
<input
  value={message.value}
  onBlur={(e) => {
    message.value = e.target.value;
  }}
/>

.number 修饰符

  • Vue 代码:
<input v-model.number="age" />
  • VuReact 编译后 React 代码:
<input
  value={age.value}
  onChange={(e) => {
    age.value = Number(e.target.value);
  }}
/>

.trim 修饰符

  • Vue 代码:
<input v-model.trim="username" />
  • VuReact 编译后 React 代码:
<input
  value={username.value}
  onChange={(e) => {
    username.value = e.target.value?.trim();
  }}
/>

修饰符组合

  • Vue 代码:
<input v-model.lazy.trim="search" />
  • VuReact 编译后 React 代码:
<input
  value={search.value}
  onBlur={(e) => {
    search.value = e.target.value?.trim();
  }}
/>

组件 v-model

Vue 3 对组件的 v-model 进行了重大改进,支持多个 v-model 绑定和自定义修饰符。

基础组件 v-model

  • Vue 代码:
<!-- 父组件 -->
<CustomInput v-model="inputValue" />

<!-- 子组件 CustomInput.vue -->
<script setup lang="ts">
  const props = defineProps(['modelValue']);
  const emits = defineEmits(['update:modelValue']);
</script>

<template>
  <input :value="props.modelValue" @input="(e) => emits('update:modelValue', e.target.value)" />
</template>
  • VuReact 编译后 React 代码:
// 父组件
<CustomInput
  modelValue={inputValue.value}
  onUpdateModelValue={(value) => {
    inputValue.value = value;
  }}
/>;

// 子组件 CustomInput.tsx
type ICustomInputProps = {
  modelValue?: any;
  onUpdateModelValue?: (...args: any[]) => any;
}

function CustomInput(props: ICustomInputProps) {
  return (
    <input value={props.modelValue} onChange={(e) => props.onUpdateModelValue?.(e.target.value)} />
  );
}

带参数的 v-model

  • Vue 代码:
<UserForm v-model:name="userName" v-model:email="userEmail" />
  • VuReact 编译后 React 代码:
<UserForm
  name={userName.value}
  onUpdateName={(value) => {
    userName.value = value;
  }}
  email={userEmail.value}
  onUpdateEmail={(value) => {
    userEmail.value = value;
  }}
/>

编译策略总结

VuReact 的 v-model 编译策略展示了完整的双向绑定转换能力

  1. 基础表单元素:将各种输入类型的 v-model 转换为对应的受控组件
  2. 修饰符支持:完整支持 .lazy.number.trim 等修饰符
  3. 组件 v-model:支持组件级别的双向绑定,包括多个 v-model 和自定义修饰符
  4. 事件映射:智能映射 Vue 事件到 React 事件(inputonChange 等)
  5. 类型安全:保持 TypeScript 类型定义的完整性

不同类型元素的编译映射

元素类型 Vue 事件 React 事件 值属性
input[type="text"] input onChange value
textarea input onChange value
input[type="checkbox"] change onChange checked
input[type="radio"] change onChange checked
select change onChange value

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动重写表单绑定逻辑。编译后的代码既保持了 Vue 的语义和便利性,又符合 React 的表单处理最佳实践,让迁移后的应用保持完整的表单交互能力。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

深入理解React Fiber架构:渲染流程与双缓冲机制全解析

作者 前端缘梦
2026年4月19日 18:28

在React开发中,我们常听说“Fiber架构”“渲染流程”“双缓冲”这些概念,很多开发者只知其名,却不解其理——为什么React 16要引入Fiber?渲染流程的两大阶段有何区别?双缓冲机制又如何提升渲染效率?

本文将结合底层原理与实际应用,把React整体架构、渲染流程、Fiber核心概念及双缓冲机制彻底揉碎讲透,搭配通俗解释和可视化图表,帮你从根源上理解React的底层工作逻辑,面试时也能从容应对。

一、为什么需要Fiber?—— 从Stack架构的痛点说起

在React 16之前,React采用的是Stack架构,其核心是Stack Reconciler(栈协调器)。这种架构的最大问题的是:渲染过程同步且不可中断

当组件树较深时,React会递归遍历整个组件树进行虚拟DOM比对,这个过程会一直占用主线程。如果遍历耗时超过16.6ms(浏览器每秒刷新60帧的时间),就会导致浏览器无法响应用户输入、滚动、动画等操作,出现视觉卡顿、掉帧等问题——这就是旧架构的致命痛点,也是Fiber架构诞生的核心原因。

为了解决这个问题,React 16彻底重构了底层架构,引入了Fiber架构,核心目标是实现“可中断、可恢复、带优先级”的渲染机制,让React能够在复杂应用中依然保持流畅的交互体验。

二、Fiber核心解析:三个维度读懂它的本质

很多人误以为Fiber是一个API或工具,其实它是React底层的架构重构,同时兼具数据结构和工作单元的属性。我们可以从三个维度,彻底理解Fiber的本质:

1. 维度一:Fiber是一种架构

Fiber架构是React 16后的核心底层架构,替代了之前的Stack架构,核心由三大组件组成,三者协同工作,实现高效渲染:

image.png

这三大组件的分工清晰,层层递进,共同解决了旧架构的性能瓶颈:

  • Scheduler(调度器) :解决“I/O瓶颈”。负责给所有更新任务排序优先级,让紧急任务(如用户输入、动画)优先进入协调器,避免低优任务阻塞高优任务,确保交互响应流畅。
  • Reconciler(协调器) :解决“CPU瓶颈”。负责实现虚拟DOM,将更新流程从“不可中断的递归”改为“可中断的循环”,计算出UI的变化,标记需要更新的节点。
  • Renderer(渲染器) :负责将协调器计算出的UI变化,同步渲染到宿主环境(比如浏览器的DOM中),确保UI与数据一致。

2. 维度二:Fiber是一种数据类型

Fiber本质上是一个JavaScript对象,可以理解为“增强版的虚拟DOM节点”——每个Fiber对象对应一个DOM节点(或组件),不仅包含了组件的类型、DOM相关信息,还新增了用于调度和渲染的关键属性。

与旧架构的虚拟DOM不同,Fiber对象之间通过链表的方式串联,形成一棵Fiber树,而非递归树。核心链表指针如下:

  • child:指向当前Fiber节点的第一个子Fiber节点;
  • sibling:指向当前Fiber节点的下一个兄弟Fiber节点;
  • return:指向当前Fiber节点的父Fiber节点(回溯指针);
  • alternate:指向另一棵Fiber树中的对应节点(用于双缓冲机制)。

image.png

这种链表结构的核心优势的是:支持中断和恢复。递归调用一旦开始就无法中断,但链表遍历可以随时停止,只需记录当前遍历的Fiber节点,下次恢复时从该节点继续即可,这是时间切片实现的基础。

3. 维度三:Fiber是一个动态工作单元

每个Fiber节点不仅是数据载体,还是一个“工作单元”——它保存了本次更新中该节点的变化数据、需要执行的工作(如新增、删除、更新DOM)以及副作用信息(如useEffect的执行)。

React会将渲染任务拆分成一个个小的工作单元(每个Fiber节点就是一个工作单元),每次只处理一个工作单元,处理完后检查是否有剩余时间或更高优先级任务,若没有再继续处理下一个——这种“化整为零”的方式,就是Fiber解决卡顿的关键。

三、React整体渲染流程:两大阶段,三大组件协同

理解了Fiber的核心,再看React的整体渲染流程就会非常清晰。React的渲染流程本质上是“计算UI变化”到“渲染UI”的过程,可分为render阶段commit阶段两大核心阶段,对应三大组件的协同工作,核心公式可总结为:

state = reconcile(update) // 协调器计算最新状态

UI = commit(state) // 渲染器渲染最终UI

image.png

1. Render阶段:异步可中断,内存中完成计算

Render阶段由调度器(Scheduler)协调器(Reconciler) 共同完成,核心是“计算出最终要渲染的UI”,这个过程完全在内存中进行,异步、可中断,不会影响页面显示。

(1)调度器的工作:给任务排优先级

调度器的核心作用是“任务调度”,给所有更新任务(如setState、useState触发的更新)分配优先级,避免高优任务被低优任务阻塞。

这里有个小细节:浏览器原生有一个API——requestIdleCallback,可以在浏览器空闲时执行任务,这与调度器的逻辑类似。但由于该API的兼容性较差,且无法满足React的精细优先级控制需求,React团队自己实现了一套调度机制,未来还计划将Scheduler单独发布为独立包,供其他需要任务调度的项目使用。

调度器的核心逻辑的是“时间切片”:将每帧(16.6ms)的剩余时间分配给任务,每次执行一个工作单元后,调用shouldYield()方法判断是否有剩余时间,若没有则暂停任务,将主线程还给浏览器,等待下一个宏任务再继续执行。

(2)协调器的工作:生成Fiber树,标记变化

协调器是Render阶段的核心,负责接收调度器分配的任务,采用深度优先遍历的方式,遍历并创建Fiber节点,串联成Fiber树,同时执行Diff算法,标记节点的变化(用flags标记,如更新、删除、插入)。

遍历过程分为两个子阶段,也就是常说的“递”和“归”:

  • 递阶段:从根Fiber(HostRootFiber)开始,向下遍历每个节点,执行beginWork方法,根据当前Fiber节点创建下一级Fiber节点,同时进行Diff比对,标记节点变化。
  • 归阶段:当遍历到叶子节点后,开始回溯,执行completeWork方法,收集当前节点的副作用(如DOM操作、useEffect回调),然后通过sibling指针切换到兄弟节点,继续遍历。

整个过程可以随时被中断(如时间片耗尽、有更高优先级任务),中断后会保存当前遍历的Fiber节点,恢复时从该节点继续,不会重复已完成的工作——这就是Fiber架构解决CPU瓶颈的核心。

2. Commit阶段:同步不可中断,渲染到真实UI

Commit阶段由渲染器(Renderer) 负责,核心是“将Render阶段计算出的UI变化,同步渲染到宿主环境”,这个过程同步、不可中断——因为一旦中断,就会导致UI与数据不一致,出现页面闪烁、错乱等问题。

Commit阶段又分为三个子阶段,按顺序执行:

image.png

  • BeforeMutation阶段:执行DOM操作前的准备工作,比如读取当前DOM的属性(如scrollTop),为后续DOM操作做铺垫。
  • Mutation阶段:核心阶段,根据协调器标记的flags,执行真实的DOM操作(新增、删除、更新DOM),同时完成Fiber双缓冲树的切换(后续详细讲解)。
  • Layout阶段:DOM操作完成后,执行后续逻辑,比如更新ref引用、执行useEffect的回调函数,同时可以获取到更新后的DOM属性。

四、Fiber双缓冲机制:为什么能实现无闪烁更新?

Fiber双缓冲机制是React渲染优化的另一大核心,很多开发者对它的理解比较模糊,其实它的原理很简单:在内存中同时维护两棵Fiber树,通过树切换实现高效、无闪烁的更新

1. 两棵Fiber树的作用

React中存在两棵Fiber树,它们通过alternate指针相互指向,各司其职:

  • Current Fiber Tree(当前树) :与当前页面显示的真实DOM一一对应,是“已渲染”的树,用户能看到的UI就是基于这棵树渲染的。
  • WorkInProgress Fiber Tree(工作树) :在内存中构建的新树,用于处理本次更新。React会在WorkInProgress树上完成所有Fiber节点的创建、Diff比对和副作用收集,整个过程不会影响Current树和真实DOM。

2. 双缓冲的核心流程

暂时无法在飞书文档外展示此内容

image.png 具体流程可以拆解为3步:

  1. 触发更新(如setState)后,React会以Current树为模板,在内存中创建WorkInProgress树,开始遍历并更新节点;
  2. 在WorkInProgress树上完成所有计算(Diff、副作用收集),此时WorkInProgress树是“最新的、完整的”;
  3. 进入Commit阶段的Mutation子阶段,React会将Current树和WorkInProgress树的指针互换(通过alternate指针),此时WorkInProgress树变为新的Current树,原Current树变为下一次更新的WorkInProgress树;
  4. 最后,渲染器根据新的Current树,同步更新真实DOM,用户看到最新的UI。

3. 双缓冲的优势

为什么需要双缓冲?核心是避免页面闪烁。如果直接在Current树上修改节点,修改过程中会导致DOM处于“不完整”状态,用户会看到页面闪烁;而WorkInProgress树在内存中完成所有计算,只有当它完全准备好后,才会与Current树切换,一次性更新DOM,确保用户看到的始终是完整的UI。

同时,双缓冲机制还能复用Fiber节点——通过alternate指针,React可以复用之前的Fiber节点,减少重复创建节点的开销,提升渲染性能。

五、面试高频题解析

结合上面的内容,我们来解析两道高频面试题,帮你快速掌握答题要点:

面试题1:是否了解过React的整体渲染流程?里面主要有哪些阶段?

参考答案:

React的整体渲染流程分为Render阶段Commit阶段两大核心阶段,由调度器、协调器、渲染器三大组件协同完成:

  1. Render阶段:由调度器和协调器负责,在内存中异步、可中断地执行。调度器负责排序任务优先级,高优任务优先进入协调器;协调器采用深度优先遍历,创建Fiber树,执行Diff算法,标记节点变化和副作用。
  2. Commit阶段:由渲染器负责,同步、不可中断地执行。核心是将Render阶段计算出的UI变化渲染到真实DOM,分为BeforeMutation、Mutation、Layout三个子阶段,分别负责DOM操作前准备、执行DOM操作、DOM操作后逻辑。

关键要点:Render阶段异步可中断,Commit阶段同步不可中断;三大组件的分工;Render阶段的“递”“归”过程。

面试题2:谈谈你对React中Fiber的理解以及什么是Fiber双缓冲?

参考答案:

Fiber是React 16引入的核心架构,同时兼具数据结构和工作单元的属性,可从三个维度理解:

  1. 架构层面:Fiber架构替代了旧的Stack架构,由调度器、协调器、渲染器组成,实现了可中断、可恢复的渲染机制,解决了旧架构的卡顿问题。
  2. 数据结构层面:Fiber是一个JavaScript对象,对应一个DOM节点或组件,通过child、sibling、return指针串联成链表结构的Fiber树,支持中断和恢复遍历。
  3. 工作单元层面:每个Fiber节点保存了本次更新的变化数据、需要执行的工作和副作用信息,是React拆分渲染任务的最小单元。

Fiber双缓冲机制是React的渲染优化手段:

React在内存中同时维护两棵Fiber树——Current树(对应真实DOM)和WorkInProgress树(内存中构建的新树),通过alternate指针相互指向。更新时,在WorkInProgress树上完成所有计算,然后切换两棵树的指针,一次性更新DOM,避免页面闪烁,提升渲染效率。

六、总结

React Fiber架构的核心,是通过“可中断的工作单元”“优先级调度”和“双缓冲机制”,解决了旧架构的卡顿问题,让React能够高效处理复杂应用的渲染需求。

我们可以用一句话总结:Fiber是架构、是数据结构、是工作单元,React的渲染流程是“Render阶段异步计算变化,Commit阶段同步渲染UI”,而双缓冲机制则是实现无闪烁更新的关键。

昨天以前首页

Vue v-bind 转 React:VuReact 怎么处理?

作者 Ruihong
2026年4月19日 15:30

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 v-bind/: 指令经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中的 v-bind 指令用法。

编译对照

v-bind / ::基础属性绑定

v-bind(简写为 :)是 Vue 中用于动态绑定 HTML 属性、组件 propsclassstyle 的指令。

  • Vue 代码:
<img :src="imageUrl" :class="imageCls" />
  • VuReact 编译后 React 代码:
<img src={imageUrl} className={imageCls} />

从示例可以看到:Vue 的 :src:class 指令被编译为 React 的标准属性语法。VuReact 采用 属性直接编译策略,将模板指令转换为 React 的 JSX 属性,完全保持 Vue 的属性绑定语义——动态地将变量值绑定到元素属性。


class 和 style 的动态绑定

Vue 支持复杂的 classstyle 绑定表达式,VuReact 通过运行时辅助函数处理这些复杂场景。

动态 class 绑定

  • Vue 代码:
<div :class="['card', active && 'is-active', error ? 'has-error' : '']" />
  • VuReact 编译后 React 代码:
import { dir } from '@vureact/runtime-core';

<div className={dir.cls(['card', active && 'is-active', error ? 'has-error' : ''])} />

动态 style 绑定

  • Vue 代码:
<div :style="{ color: textColor, fontSize: size + 'px', 'background-color': bgColor }" />
  • VuReact 编译后 React 代码:
import { dir } from '@vureact/runtime-core';

<div style={dir.style({ color: textColor, fontSize: size + 'px', backgroundColor: bgColor })} />

从示例可以看到:复杂的 class 和 style 绑定被编译为使用 dir.cls()dir.style() 辅助函数。VuReact 采用 复杂绑定运行时处理策略,将 Vue 的复杂表达式转换为运行时函数调用,完全保持 Vue 的动态样式语义

运行时辅助函数的工作原理

  1. dir.cls()

    • 处理数组、对象、字符串等多种 class 格式
    • 自动过滤 falsy 值(false、null、undefined、'')
    • 合并重复的 class 名称
    • 生成最终的 className 字符串
  2. dir.style()

    • 处理对象格式的样式
    • 自动转换 kebab-case 为 camelCase(background-colorbackgroundColor
    • 处理带单位的数值(自动添加 px 等)
    • 生成 React 兼容的 style 对象

编译策略详解

// Vue: :class="{ active: isActive, 'text-danger': hasError }"
// React: className={dir.cls({ active: isActive, 'text-danger': hasError })}

// Vue: :class="[isActive ? 'active' : '', errorClass]"
// React: className={dir.cls([isActive ? 'active' : '', errorClass])}

// Vue: :style="style"
// React: style={dir.style(style)}

无参数 v-bind:对象展开

Vue 支持无参数的 v-bind,用于将整个对象展开为元素的属性。

  • Vue 代码:
<Comp v-bind="props">点击</Comp>
  • VuReact 编译后 React 代码:
import { dir } from '@vureact/runtime-core';

<Comp {...dir.keyless(props)}>点击</Comp>

从示例可以看到:无参数的 v-bind 被编译为使用 dir.keyless() 辅助函数和对象展开语法。VuReact 采用 对象展开编译策略,将 Vue 的对象绑定转换为 React 的对象展开,完全保持 Vue 的对象属性绑定语义

dir.keyless() 辅助函数的作用

  1. 属性冲突处理:处理对象属性与已有属性的冲突
  2. 特殊属性转换:自动转换 classclassNameforhtmlFor
  3. 样式对象处理:识别并正确处理 style 对象
  4. 事件处理:识别并转换事件属性(@clickonClick

布尔属性绑定

Vue 对布尔属性有特殊处理,VuReact 也保持了这种语义。

  • Vue 代码:
<button :disabled="isLoading">提交</button>
<input :checked="isChecked" />
<option :selected="isSelected">选项</option>
  • VuReact 编译后 React 代码:
<button disabled={isLoading}>提交</button>
<input checked={isChecked} />
<option selected={isSelected}>选项</option>

动态属性名绑定

Vue 支持使用动态表达式作为属性名,但不建议这么做,不过 VuReact 也能正确处理。

  • Vue 代码:
<div :[dynamicAttr]="value">内容</div>
  • VuReact 编译后 React 代码:
<div {...{ [dynamicAttr]: value }}>内容</div>

编译策略

  1. 计算属性名:使用对象计算属性语法 { [key]: value }
  2. 对象展开:通过对象展开语法应用到元素上

编译策略总结

VuReact 的 v-bind 编译策略展示了完整的属性绑定转换能力

  1. 基础属性映射:将 Vue 属性绑定精确映射到 React JSX 属性
  2. 复杂样式处理:通过运行时辅助函数支持复杂的 class 和 style 绑定
  3. 对象展开支持:完整支持无参数 v-bind 的对象展开语义
  4. 布尔属性处理:正确处理布尔属性的特殊行为
  5. 动态属性名:支持动态表达式作为属性名
  6. 组件 props 转换:正确处理组件间的 props 传递

性能优化策略

  1. 按需导入:只有使用复杂绑定时才导入 dir 辅助函数
  2. 缓存优化:智能缓存相同表达式的处理结果
  3. 编译期优化:对于简单表达式,直接生成内联逻辑

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动重写属性绑定逻辑。编译后的代码既保持了 Vue 的语义和功能,又符合 React 的属性处理最佳实践,让迁移后的应用保持完整的 UI 表现能力。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

Vue v-on 在 React 中 VuReact 会如何实现?

作者 Ruihong
2026年4月19日 10:34

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 v-on/@ 指令经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中的 v-on 指令用法。

编译对照

v-on / @:基础事件绑定

v-on(简写为 @)是 Vue 中用于绑定事件监听器的指令,用于响应用户交互。

  • Vue 代码:
<button @click="increment">+1</button>
  • VuReact 编译后 React 代码:
<button onClick={increment}>+1</button>

从示例可以看到:Vue 的 @click 指令被编译为 React 的 onClick 属性。VuReact 采用 事件属性编译策略,将模板指令转换为 React 的标准事件属性,完全保持 Vue 的事件绑定语义——当按钮被点击时,调用 increment 函数。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue v-on 的行为,实现事件监听功能
  2. 命名转换:Vue 的 @click 转换为 React 的 onClick(camelCase 命名)
  3. 函数传递:直接传递函数引用,保持事件处理逻辑
  4. React 原生支持:使用 React 标准的事件系统,无需额外适配

带事件修饰符:高级事件处理

Vue 的事件系统支持丰富的修饰符,用于控制事件行为。VuReact 通过运行时辅助函数处理这些修饰符。

  • Vue 代码:
<button @click.stop.prevent="submit">Submit</button>
  • VuReact 编译后 React 代码:
import { dir } from '@vureact/runtime-core';

<button onClick={dir.on('click.stop.prevent', submit)}>Submit</button>

从示例可以看到:带修饰符的 Vue 事件被编译为使用 dir.on() 辅助函数。VuReact 采用 修饰符运行时处理策略,将复杂的修饰符组合转换为运行时函数调用,完全保持 Vue 的事件修饰符语义

编译策略详解

// Vue: @click.stop.prevent="handler"
// React: onClick={dir.on('click.stop.prevent', handler)}

// Vue: @keyup.enter="search"
// React: onKeyUp={dir.on('keyup.enter', search)}

// Vue: @click.capture="captureHandler"
// React: onClickCapture={dir.on('click.capture', captureHandler)}

运行时辅助函数 dir.on() 的工作原理

  1. 解析修饰符:解析事件名称和修饰符字符串
  2. 创建包装函数:根据修饰符创建事件处理包装函数
  3. 应用修饰符逻辑:在包装函数中实现修饰符对应的行为
  4. 调用原始处理器:最终调用开发者提供的事件处理函数

内联事件处理与参数传递

Vue 支持在模板中直接编写内联事件处理逻辑,VuReact 也能正确处理。

  • Vue 代码:
<button @click="count++">增加</button>
<button @click="sayHello('world')">打招呼</button>
<button @click="handleEvent($event, 'custom')">带事件对象</button>
  • VuReact 编译后 React 代码:
<button onClick={() => count.value++}>增加</button>
<button onClick={() => sayHello('world')}>打招呼</button>
<button onClick={(event) => handleEvent(event, 'custom')}>带事件对象</button>

编译策略

  1. 表达式转换:将 Vue 模板表达式转换为 JSX 箭头函数
  2. 事件对象处理:Vue 的 $event 转换为 React 的事件参数
  3. 参数传递:保持函数调用的参数顺序和值
  4. 响应式更新:自动处理 .value 访问(对于 ref/computed 等变量)

defineEmits 事件与组件通信

对于组件自定义事件,VuReact 也有相应的编译策略。

  • Vue 代码:
<!-- 父组件 -->
<Child @custom-event="handleCustom" />

<!-- 子组件 Child.vue -->
<template>
  <button @click="emits('custom-event', data)">触发事件</button>
</template>

<script setup>
const emits = defineEmits(['custom-event']);
</script>
  • VuReact 编译后 React 代码:
// 父组件使用
<Child onCustomEvent={handleCustom} />;

// 子组件 Child.jsx
function Child(props) {
  return <button onClick={() => props.onCustomEvent?.(data)}>触发事件</button>;
}

编译规则

  1. 事件名转换kebab-case 转换为 camelCasecustom-eventonCustomEvent
  2. emit 调用转换$emit() 转换为 props 回调调用
  3. 可选链保护:添加 ?. 可选链操作符,避免未定义错误
  4. 类型安全:保持 TypeScript 类型定义的一致性

编译策略总结

VuReact 的事件编译策略展示了完整的事件系统转换能力

  1. 基础事件映射:将 Vue 事件指令精确映射到 React 事件属性
  2. 修饰符支持:通过运行时辅助函数完整支持 Vue 事件修饰符
  3. 内联处理:正确处理模板中的内联事件表达式
  4. 自定义事件:支持组件间的自定义事件通信
  5. 类型安全:保持 TypeScript 类型定义的完整性

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动重写事件处理逻辑。编译后的代码既保持了 Vue 的语义和功能,又符合 React 的事件处理最佳实践,让迁移后的应用保持完整的交互能力。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

Vue v-html 与 v-text 转 React:VuReact 怎么处理?

作者 Ruihong
2026年4月18日 14:06

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 v-html/v-text 指令经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中的 v-html 和 v-text 指令用法。

编译对照

v-html:动态 HTML 内容渲染

v-html 是 Vue 中用于将 HTML 字符串动态渲染为 DOM 元素的指令,它会替换元素内的所有内容,并解析 HTML 标签。

  • Vue 代码:
<div v-html="htmlContent"></div>
  • VuReact 编译后 React 代码:
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />

从示例可以看到:Vue 的 v-html 指令被编译为 React 的 dangerouslySetInnerHTML 属性。VuReact 采用 HTML 注入编译策略,将模板指令转换为 React 的特殊属性,完全保持 Vue 的 HTML 渲染语义——将 htmlContent 字符串解析为 HTML 并插入到 DOM 中。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue v-html 的行为,直接渲染 HTML 字符串
  2. 安全警告:React 的 dangerouslySetInnerHTML 属性名本身就提醒开发者注意 XSS 攻击风险
  3. 内容替换:与 Vue 一样,会替换元素内的所有现有内容

v-text:纯文本内容渲染

v-text 是 Vue 中用于将纯文本内容设置到元素内的指令,它会替换元素内的所有内容,但不会解析 HTML 标签。

  • Vue 代码:
<p v-text="message"></p>
  • VuReact 编译后 React 代码:
<p>{message}</p>

从示例可以看到:Vue 的 v-text 指令被编译为 React 的 JSX 插值表达式。VuReact 采用 文本插值编译策略,将模板指令转换为 JSX 的大括号表达式,完全保持 Vue 的文本渲染语义——将 message 作为纯文本内容插入到元素中。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue v-text 的行为,渲染纯文本内容
  2. 自动转义:React 的 JSX 插值会自动转义 HTML 特殊字符,防止 XSS 攻击
  3. 内容替换:与 Vue 一样,会替换元素内的所有现有内容

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动重写内容渲染逻辑。编译后的代码既保持了 Vue 的语义,又符合 React 的安全最佳实践。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

你的 Vue v-for,VuReact 会编译成什么样的 React 代码?

作者 Ruihong
2026年4月18日 11:06

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 v-for 指令经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中的 v-for 指令用法。

编译对照

基础数组遍历

最简单的 v-for 指令,用于遍历数组并渲染列表项。

  • Vue 代码:
<li v-for="(item, i) in list" :key="item.id">{{ i }} - {{ item.name }}</li>
  • VuReact 编译后 React 代码:
{
  list.map((item, i) => (
    <li key={item.id}>
      {i} - {item.name}
    </li>
  ));
}

从示例可以看到:Vue 的 v-for 指令被编译为 React 的 map 函数。VuReact 采用 数组映射编译策略,将模板指令转换为 JSX 数组表达式,完全保持 Vue 的列表渲染语义——遍历数组中的每个元素,生成对应的 JSX 元素,并自动处理 key 属性以保证 React 的渲染性能。


对象遍历

v-for 也可以用于遍历对象的属性和值。

  • Vue 代码:
<li v-for="(val, key, i) in obj" :key="key">{{ i }} - {{ key }}: {{ val }}</li>
  • VuReact 编译后 React 代码:
{
  Object.entries(obj).map(([key, val], i) => (
    <li key={key}>
      {i} - {key}: {val}
    </li>
  ));
}

对于对象遍历,VuReact 采用 Object.entries 转换策略,将 Vue 的对象遍历语法转换为 Object.entries(obj).map() 形式。这种编译方式完全模拟 Vue 的对象遍历语义——按顺序遍历对象的键值对,保持 (值, 键, 索引) 的参数顺序,确保数据渲染的一致性。


嵌套 v-for 循环

复杂的嵌套列表渲染,使用多层 v-for 循环。

  • Vue 代码:
<div v-for="category in categories" :key="category.id">
  <h3>{{ category.name }}</h3>
  <ul>
    <li v-for="product in category.products" :key="product.id">
      {{ product.name }} - ${{ product.price }}
    </li>
  </ul>
</div>
  • VuReact 编译后 React 代码:
{
  categories.map((category) => (
    <div key={category.id}>
      <h3>{category.name}</h3>
      <ul>
        {category.products.map((product) => (
          <li key={product.id}>
            {product.name} - ${product.price}
          </li>
        ))}
      </ul>
    </div>
  ));
}

对于嵌套循环,VuReact 采用 嵌套 map 函数编译策略,将 Vue 的嵌套 v-for 转换为嵌套的 map 函数调用。这种编译方式完全保持 Vue 的嵌套循环语义——外层循环的每个迭代都会创建内层循环的完整列表,保持组件结构的层次关系。


v-if + v-for

实际业务中经常需要结合条件进行列表渲染。

  • Vue 代码:
<template v-if="cond" v-for="user in users" :key="user.id">
  <img :src="user.avatar" :alt="user.name" />
  <div class="user-info">
    <h4>{{ user.name }}</h4>
    <p>{{ user.email }}</p>
    <span class="role-badge">{{ user.role }}</span>
  </div>
  <div class="user-actions">
    <button @click="editUser(user.id)">编辑</button>
    <button @click="deleteUser(user.id)" class="danger">删除</button>
  </div>
</template>
  • VuReact 编译后 React 代码:
{
  cond
    ? users.map((user) => (
        <div key={user.id} className="user-card">
          <img src={user.avatar} alt={user.name} />
          <div className="user-info">
            <h4>{user.name}</h4>
            <p>{user.email}</p>
            <span className="role-badge">{user.role}</span>
          </div>
          <div className="user-actions">
            <button onClick={() => editUser(user.id)}>编辑</button>
            <button onClick={() => deleteUser(user.id)} className="danger">
              删除
            </button>
          </div>
        </div>
      ))
    : null;
}

对于带条件的列表渲染,VuReact 展示了智能的条件编译能力

  1. 优先条件编译:将 v-if 转换为三元表达式,包裹整个 v-for 渲染结果
  2. 自动提取 key:当 <template> 标签上存在 :key 属性时,会自动将其传递给内部的第一个子元素
  3. 事件绑定处理@click 转换为 onClick,并自动包装为箭头函数以传递参数
  4. 属性绑定转换:src:alt 等转换为 React 属性语法
  5. 样式类名处理class 转换为 className,符合 React 规范

VuReact 的编译策略完全保持 Vue 的列表渲染语义,同时生成符合 React 最佳实践的代码。


使用 v-for 范围值

Vue 的 v-for 也支持使用数字范围进行迭代。

  • Vue 代码:
<span v-for="n in 5" :key="n">{{ n }}</span>
  • VuReact 编译后 React 代码:
{
  Array.from({ length: 5 }, (_, n) => (
    <span key={n + 1}>{n + 1}</span>
  ));
}

对于范围值迭代,VuReact 采用 Array.from 转换策略,将 Vue 的数字范围语法转换为数组生成和映射。这种编译方式完全模拟 Vue 的范围迭代语义——从 1 开始到指定数字结束(包含),保持迭代顺序和数值的一致性。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

Vue v-if 转 React:VuReact 怎么处理?

作者 Ruihong
2026年4月18日 10:35

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 v-if/v-else/v-else-if 指令经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中的条件指令用法。

编译对照

基础 v-if 条件渲染

最简单的 v-if 指令,用于根据条件显示或隐藏元素。

  • Vue 代码:
<div v-if="cond">内容</div>
  • VuReact 编译后 React 代码:
{
  cond ? <div>内容</div> : null;
}

从示例可以看到:Vue 的 v-if 指令被编译为 React 的三元表达式。VuReact 采用 条件表达式编译策略,将模板指令转换为 JSX 内联表达式,完全保持 Vue 的条件渲染语义——当 cond 为真时渲染 <div>,为假时渲染 null(React 中 null 不会被渲染到 DOM)。


v-if 与 v-else 组合

v-ifv-else 组合使用,实现二选一的条件渲染。

  • Vue 代码:
<div v-if="cond">内容</div>
<div v-else>其他内容</div>
  • VuReact 编译后 React 代码:
{
  cond ? <div>内容</div> : <div>其他内容</div>;
}

VuReact 将 v-if/v-else 组合编译为完整的三元表达式完全模拟 Vue 的条件分支语义——两个分支互斥,确保同一时间只有一个元素被渲染。这种编译方式保持了代码的简洁性和可读性,同时与 React 的表达式渲染模式完美契合。


多条件 v-else-if 链

复杂的多条件判断链,使用 v-ifv-else-ifv-else 组合。

  • Vue 代码:
<div v-if="type === 'A'">内容A</div>
<div v-else-if="type === 'B'">内容B</div>
<div v-else>其他内容</div>
  • VuReact 编译后 React 代码:
{
  type === 'A' ? <div>内容A</div> : type === 'B' ? <div>内容B</div> : <div>其他内容</div>;
}

对于多条件链,VuReact 采用嵌套三元表达式编译策略,将 Vue 的 v-else-if 链转换为嵌套的条件表达式。这种编译方式完全保持 Vue 的条件链语义——按顺序检查条件,第一个满足条件的分支被渲染,后续分支被跳过。


复杂业务场景条件渲染

实际业务中的复杂条件渲染,包含嵌套条件、事件绑定、插值表达式等。

  • Vue 代码:
<div v-if="user.role === 'admin' && (user.permissions.includes('write') || isSuperAdmin)">
  <h1>管理员控制面板</h1>
  <button @click="deleteAll">删除所有数据</button>
</div>
<div v-else-if="user.role === 'editor' && articles.length > 0 && !isSuspended">
  <h2>编辑文章 (共{{ articles.length }}篇)</h2>
  <ul>
    <li v-for="article in articles" :key="article.id">{{ article.title }}</li>
  </ul>
</div>
<div v-else-if="user.role === 'viewer' && hasSubscription">
  <h3>订阅用户视图</h3>
  <p>您的订阅将于{{ subscriptionEndDate }}到期</p>
</div>
<div v-else-if="user.role === 'guest' && showTrial">
  <div class="trial-banner">
    <p>试用用户,剩余{{ trialDays }}天</p>
    <button @click="upgrade">升级账户</button>
  </div>
</div>
<div v-else>
  <div class="error-state">
    <p v-if="isLoading">加载中...</p>
    <p v-else-if="errorMessage">{{ errorMessage }}</p>
    <p v-else>无访问权限或账户状态异常</p>
    <button @click="retry">重试 ({{ retryCount }}/3)</button>
  </div>
</div>
  • VuReact 编译后 React 代码:
{
  user.role === 'admin' && (user.permissions.includes('write') || isSuperAdmin) ? (
    <div>
      <h1>管理员控制面板</h1>
      <button onClick={deleteAll}>删除所有数据</button>
    </div>
  ) : user.role === 'editor' && articles.length > 0 && !isSuspended ? (
    <div>
      <h2>编辑文章 (共{articles.length}篇)</h2>
      <ul>
        {articles.map((article) => (
          <li key={article.id}>{article.title}</li>
        ))}
      </ul>
    </div>
  ) : user.role === 'viewer' && hasSubscription ? (
    <div>
      <h3>订阅用户视图</h3>
      <p>您的订阅将于{subscriptionEndDate}到期</p>
    </div>
  ) : user.role === 'guest' && showTrial ? (
    <div>
      <div className="trial-banner">
        <p>试用用户,剩余{trialDays}天</p>
        <button onClick={upgrade}>升级账户</button>
      </div>
    </div>
  ) : (
    <div>
      <div className="error-state">
        {isLoading ? (
          <p>加载中...</p>
        ) : errorMessage ? (
          <p>{errorMessage}</p>
        ) : (
          <p>无访问权限或账户状态异常</p>
        )}
        <button onClick={retry}>重试 ({retryCount}/3)</button>
      </div>
    </div>
  );
}

对于复杂的业务场景,VuReact 展示了完整的条件编译能力

  1. 复杂条件表达式:将 Vue 的复杂条件逻辑(&&||、函数调用等)原样转换为 JSX 表达式
  2. 事件绑定转换@click 转换为 onClick,保持事件语义
  3. 插值表达式{{ }} 转换为 { },保持数据绑定
  4. 样式类名转换class 转换为 className,符合 React 规范

VuReact 的编译策略完全保持 Vue 的条件渲染语义,同时生成符合 React 最佳实践的代码,提高可维护性。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

你的 Vue 路由,VuReact 会编译成什么样的 React 路由?

作者 Ruihong
2026年4月17日 21:58

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天我们从 Vue Router 宏观对照入手,看看 Vue 中的路由组件、API 与入口结构,经过 VuReact 编译后会变成什么样的 React 路由代码。

另外,本文仅展示部分路由组件与 API,实际上完整适配还包括路由类型接口等更多内容,详情请查阅 VuReact Router 文档。

前置约定

为避免示例冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue Router API 用法与核心行为。

编译对照

router 组件:<router-link> / <router-view>

Vue 的路由组件在 React 中被映射为 @vureact/router 提供的适配组件。

  • Vue 代码:
<template>
  <router-link to="/home">Home</router-link>
  <router-view />
</template>
  • VuReact 编译后 React 代码:
import { RouterLink, RouterView } from '@vureact/router';

return (
  <>
    <RouterLink to="/home">Home</RouterLink>
    <RouterView />
  </>
);

RouterLink 在 React 中同样支持字符串 to、对象 toactiveClassNamecustomRender 等 Vue 风格用法;RouterView 负责渲染当前匹配路由组件,并保持嵌套路由、路由守卫与元字段的执行顺序。


路由配置:createRouter + history

Vue Router 的创建方式在 VuReact 中保持语义一致,但依赖会替换为 @vureact/router

  • Vue 代码:
import { createRouter, createWebHistory } from 'vue-router';
import Home from './views/Home.vue';

export default createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: Home },
  ],
});
  • VuReact 编译后 React 代码:
import { createRouter, createWebHistory } from '@vureact/router';
import Home from './views/Home';

export default createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: Home },
  ],
});

这说明:

  • createRouter / createWebHistory 等 API 名称保持不变;
  • 仅依赖路径会被替换成 @vureact/router
  • Vue Router 的路由记录、嵌套路由、meta 字段可直接保留。

入口注入:RouterProvider

如果启用了自动适配,VuReact 会在编译后自动调整入口文件,将原 <App /> 替换为路由实例的 RouterProvider

  • 生成后的 React 入口文件:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import RouterInstance from './router/index';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <RouterInstance.RouterProvider />
  </StrictMode>,
);

该入口结构体现了 Vue 路由到 React 路由适配的宏观变化:

  • Vue 的路由配置文件继续作为路由实例入口;
  • React 入口通过 RouterProvider 挂载路由上下文;
  • 因此无需手动改写业务路由逻辑,只需保证路由定义规范。

运行时 API:useRouter / useRoute

Vue 的组合式路由 API 在 React 中仍保留相同语义。

  • Vue 代码:
const router = useRouter();
const route = useRoute();

const goHome = () => {
  router.push('/home');
};
  • VuReact 编译后 React 代码:
import { useRouter, useRoute } from '@vureact/router';

const router = useRouter();
const route = useRoute();

const goHome = useCallback(() => {
  router.push('/home');
}, [router]);

useRouter()useRoute() 仍然支持编程式导航、参数读取、meta 等字段,且使用方式与 Vue Router 组合式 API 语义保持一致。


自动适配

当编译器检测到项目中使用 Vue Router 时,会自动:

  • import ... from 'vue-router' 替换为 import ... from '@vureact/router'
  • 将路由配置文件产物变更为 @vureact/router 的路由实例;
  • 将入口文件自动改写为 RouterProvider 渲染。

配置示例:

import { defineConfig } from '@vureact/compiler-core';

export default defineConfig({
  router: {
    // 路由入口文件路径(即调用并默认导出 createRouter() 的地方)
    configFile: 'src/router/index.ts',
  },
});

手动适配

以下方案为通用建议,具体实现细节请开发者根据实际项目需求进行调整。

当选项 output.bootstrapVite 或者 router.autoSetupfalse 时,自动适配不可用,需要手动完成:

  • 导出 Vue Router 的 createRouter() 实例;
  • 在 React 入口文件中,将原本渲染 <App /> 的代码替换为 @vureact/router 路由实例所提供的 <RouterProvider /> 组件。

手动适配的核心是:保留 Vue Router 的路由定义与嵌套路由结构,导出路由器实例,替换 React 入口渲染方式。

相关资源


如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

你的 Vue 3 defineAsyncComponent(),VuReact 会编译成什么样的 React?

作者 Ruihong
2026年4月17日 21:54

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中用于异步组件的 defineAsyncComponent() 经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中 defineAsyncComponent 的 API 用法与核心行为。

编译对照

Vue defineAsyncComponent() → React defineAsyncComponent()

defineAsyncComponent 是 Vue 3 中用于定义异步组件的 API,它允许你按需加载组件,优化应用性能。VuReact 会将其编译为同名的 defineAsyncComponent,让 React 中也能获得同样的异步组件能力。

  • Vue 代码:
<script setup>
  import { defineAsyncComponent } from 'vue';

  const AsyncComponent = defineAsyncComponent(() =>
    import('./components/AsyncComponent.vue')
  );
</script>

<template>
  <AsyncComponent />
</template>
  • VuReact 编译后 React 代码:
import { defineAsyncComponent } from '@vureact/runtime-core';

const AsyncComponent = defineAsyncComponent(() =>
  import('./components/AsyncComponent')
);

function MyComponent() {
  return <AsyncComponent />;
}

VuReact 提供的 defineAsyncComponentVue defineAsyncComponent 的适配 API,可理解为「React 版的 Vue defineAsyncComponent」,完全模拟 Vue defineAsyncComponent 的异步加载行为——支持懒加载、加载状态处理、错误处理等完整功能。

defineAsyncComponent 高级用法

defineAsyncComponent 在 Vue 3 中支持多种配置选项,如加载状态组件、错误处理组件、超时设置等。VuReact 会将其编译为相应的 React 配置,保持功能一致性。

  • Vue 代码:
<script setup>
  import { defineAsyncComponent } from 'vue';

  const AsyncComponent = defineAsyncComponent({
    loader: () => import('./components/HeavyComponent.vue'),
    loadingComponent: LoadingSpinner,
    errorComponent: ErrorDisplay,
    delay: 200,
    timeout: 3000,
    suspensible: true,
  });
</script>
  • VuReact 编译后 React 代码:
import { defineAsyncComponent } from '@vureact/runtime-core';
import LoadingSpinner from './components/LoadingSpinner';
import ErrorDisplay from './components/ErrorDisplay';

const AsyncComponent = defineAsyncComponent({
  loader: () => import('./components/HeavyComponent'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorDisplay,
  delay: 200,
  timeout: 3000,
  suspensible: true,
});

VuReact 提供的 defineAsyncComponent 支持 所有 Vue defineAsyncComponent 的配置选项,包括 loaderloadingComponenterrorComponentdelaytimeoutsuspensible 等,完全模拟 Vue defineAsyncComponent 的高级功能——在 React 中实现与 Vue 一致的异步组件体验。

请注意,hydrate 选项不支持,但保留了该选项进行兼容,无实际功能。

相关资源


如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

React 19 源码怎么读:目录结构、包关系、调试方式与主线问题

作者 倾颜
2026年4月17日 18:21

这是我持续更新的一组 React 源码解读文章,也会尽量控制单篇篇幅,按主线一点点往里拆。
这一篇先不急着扎进某个细节,而是从整体地图开始,先把 React 运行时主线和后面阅读源码时最重要的入口理顺。

前言

第一次看 React 源码时,我们最容易卡住的地方,往往不是某个函数太难,而是看着看着就失去了方向。

一开始,我们心里通常都有几个很具体的问题:想搞懂 Fiber,想知道 setState 之后到底发生了什么,也想弄明白 useEffect 为什么总像是“晚一步”执行。可真翻进仓库之后,这些问题又很快会被新的困惑打断:

  • 这个目录到底是做什么的?
  • 这段代码在整条链路里负责哪一段?
  • 我们现在看到的,是 React 的核心逻辑,还是某个边缘实现?
  • 为什么每个点都好像懂了一点,但就是连不成一条完整主线?

所以这篇文章不急着深挖某个具体实现,而是先做一件更基础、也更重要的事:

先把 React 源码的阅读地图搭起来。

这篇文章主要想回答四个问题:

  • React 仓库里哪些地方值得先看
  • reactreact-domreact-reconcilerscheduler 大概是怎么分工的
  • 一次 React 更新的大主线到底是怎么流动的
  • 刚开始读源码时,应该按什么方式推进,才不容易迷路

这里也先说明一下版本口径:这篇文章标题写的是 React 19,因为整体讨论的是 React 19 的主线机制;但在具体源码观察上,我会先以 React 19.1.1 作为基线来展开。


一、为什么很多人看 React 源码会越看越乱

React 源码难,不只是因为代码量大。

更准确地说,它难在:层次很多,入口很多,主线很长,而且每一层都不是孤立存在的。

我们表面上想搞懂的是一个问题,比如“setState 之后发生了什么”,但它背后往往会牵出一整串东西:

  • 组件更新是怎么产生的
  • update 是怎么入队的
  • Fiber 节点怎么记录这次更新
  • React 怎么决定这次更新什么时候执行
  • render 阶段到底在算什么
  • commit 阶段又是什么时候真正改 DOM 的

也就是说,React 源码不是那种“看一个函数就能闭环”的代码。它更像一套分层协作的更新系统

如果一开始没有地图感,很容易进入一种状态:每看一段代码,都能理解一点;但每理解一点,又像是零散碎片。最后脑子里只剩下一堆词:

  • Fiber
  • Scheduler
  • render
  • commit
  • lanes
  • hooks

这些词我们都见过,但它们之间到底是什么关系,反而不清楚。

所以我更倾向于把 React 源码学习的第一步放在一个更基础的问题上:

React 到底是一套什么系统?

把这个问题先看清楚,后面再去拆 Fiber、调度、Hooks、render、commit,才不容易一路走一路散。


二、先建立一张总图:React 到底是一套什么系统

如果先把 React 粗略抽象一下,我更愿意把它理解成这样一条主线:

flowchart LR
    A[JSX] --> B[ReactElement]
    B --> C[Root / Fiber]
    C --> D[调度]
    D --> E[render]
    E --> F[commit]
    F --> G[DOM / effects]

React 运行时总主线

这张图不细,但非常重要。因为它至少先帮我们看清了三件事。

1. JSX 不是 React 运行时真正处理的最终形态

我们平时写的是 JSX,但 React 运行时真正接收到的,并不是 <App /> 这段看起来像模板的代码本身,而是编译之后的一种对象描述。

所以读源码时,第一层问题不应该是“React 怎么处理 <App />”,而应该是:

<App /> 编译之后到底是什么对象?

只要这一步没有先想清楚,后面再看 Root、Fiber、更新流程,就会总觉得前面少了一层。

2. root.render(...) 不是“立刻渲染 DOM”

很多人第一次接触 React 时,会下意识把 root.render(<App />) 理解成“把组件直接渲染到页面上”。

但从源码视角看,更准确的理解应该是:

它把一份描述 UI 的对象,送进 React 自己的更新系统。

也就是说,这一步更像“发起一次更新”,而不是“立即完成渲染”。

3. React 的更新过程,本质上分成“计算”和“提交”两段

后面我们会经常看到两个词:rendercommit

可以先记住一句很关键的话:

  • render 阶段:主要是在算,算这次更新之后“应该变成什么样”
  • commit 阶段:主要是在交,真正把结果提交到宿主环境,比如浏览器 DOM

所以 React 不是“收到更新,立刻改 DOM”的直线模型。它更像这样:

描述 UI → 进入更新系统 → 被调度 → 计算结果 → 提交结果

一旦先把这张总图建立起来,后面再去看 Fiber、Hooks、调度,就不会觉得这些东西是互相割裂的黑话。


三、先别急着翻细节:React 仓库里哪些地方值得先看

第一次打开 React 仓库时,很容易被目录吓到。但从“运行时源码阅读”的角度看,我们不需要一开始就把所有目录都研究一遍。

对这条“React 运行时主线”来说,真正值得优先关注的,主要有这几个方向。

1. packages:核心代码主战场

如果我们的目标是理解这些问题:

  • JSX 产物是什么
  • createRoot 做了什么
  • Fiber 是什么
  • 更新是怎么调度的
  • render / commit 分别在做什么
  • Hooks 为什么能工作

那么后面大部分时间,基本都会待在 packages 里。

因为真正和 React 运行时主线相关的核心逻辑,主要都在这里。

所以第一次看仓库时,不要想着“从根目录往下把所有东西都扫一遍”。更有效的做法,是先建立一个习惯:

以后提到 React 源码主线,默认先去 packages 里找。

2. fixtures:最适合做最小实验场

学习源码很怕一上来就拿业务项目调试。业务代码一复杂,React 本身的调用链很容易被应用层噪音淹没。

这时候 fixtures 的价值就出来了。它更像一个实验场:当我们只想验证某一条很小的更新链路时,最小场景会比业务项目更适合观察。

3. scripts:工程支撑层

scripts 当然重要,但不是我们建立 React 主线认知的第一入口。

对第一阶段来说,知道它主要服务于构建、测试、打包、发布等工程流程,就够了。因为现在我们的目标不是“参与 React 仓库开发”,而是“先把 React 是怎么运行起来的搞清楚”。

4. 其他方向:先知道存在,不急着深挖

比如编译器、测试、工具链等方向,当然都重要。但如果我们的目标是先建立 React 运行时的整体认识,那么优先把这条主线打通,收益会更直接。

现阶段更好的策略是:

先把运行时主线搞清楚,再考虑编译器、RSC、性能优化等专题。


四、核心包关系:reactreact-domreact-reconcilerscheduler 各自负责什么

看 React 源码时,如果只记目录,不记职责,很快还是会乱。真正有用的是把几个核心包的分工先记住。

我目前更倾向于用下面这种方式去理解它们:

flowchart TB
    A[react<br/>定义 UI 描述和上层 API]
    B[react-dom<br/>浏览器宿主环境接入]
    C[react-reconciler<br/>协调与更新主链核心]
    D[scheduler<br/>调度能力支撑层]

核心包职责

下面逐个说。

1. react:定义“怎么描述 UI”

react 这一层,更像是 React 暴露给开发者的“上层接口”和“描述模型”。

我们平时写的这些东西:

  • JSX
  • 函数组件
  • Hook
  • createContext
  • memo

最后都会落到 React 定义的一套模型里。

所以从源码学习角度看,react 回答的问题更像是:

开发者是如何把 UI 和状态意图,交给 React 的?

如果继续顺着这条线往里看,很自然就会进入 JSX 编译产物和 ReactElement 这一层。

2. react-dom:浏览器环境的接入层

对前端开发者来说,最熟悉的入口通常是:

import { createRoot } from 'react-dom/client'

const root = createRoot(container)
root.render(<App />)

这说明 react-dom 这一层解决的核心问题是:

React 怎么接到浏览器这个宿主环境上?

也就是说,它更关心“把 React 应用挂到哪、怎么挂、最终怎么和 DOM 环境打交道”。

所以我们可以先把它理解成:

浏览器场景下的宿主接入层。

3. react-reconciler:真正的源码腹地

如果说:

  • react 更偏“描述层”
  • react-dom 更偏“宿主接入层”

那么 react-reconciler 才是后面真正要深挖的核心腹地。

因为我们最关心的这些东西,几乎都和它强相关:

  • Fiber
  • work loop
  • beginWork
  • completeWork
  • render 阶段
  • commit 阶段
  • 更新如何传播
  • 副作用如何收集和提交

可以先记一句非常实用的话:

React 真正“怎么处理一次更新”,大头都在 react-reconciler 这层。

如果继续往更新主链内部走,很多关键问题最终都会落到这一层。

4. scheduler:不是主角,但非常关键

这里不必一开始就把 scheduler 的细节掰得很深,但它在整套系统里的位置,我们最好先有一个整体认识。

React 之所以不再只是“同步调用 → 直接算完 → 直接提交”,背后离不开调度能力。这部分我们可以暂时理解成:

  • 什么时候做
  • 哪个先做
  • 哪个可以稍后做
  • 当前要不要让出执行机会

这些能力,不是随便塞在某个业务函数里就能完成的,所以 React 需要一层相对独立的调度支撑。

现阶段先记住一句就够了:

scheduler 提供的是调度能力支撑,不等于 React 全部逻辑本身,但它对 React 的更新模型非常关键。


五、一次 React 更新的大主线:从 JSX 到 DOM 提交

前面把目录和核心包大致摆清楚之后,接下来最重要的一步,就是把 React 的“主线流程”先跑通。

因为无论是看 createRoot、看 Fiber、看 Hooks,还是看 beginWorkcommit,本质上都还是在拆这一条主线。

我先把它再压缩成一张图:

JSX
  ↓ 编译
ReactElement
  ↓ root.render / 触发更新
Root / Fiber Root / HostRoot Fiber
  ↓ 调度
render 阶段
  ↓ 生成本次提交所需的信息
commit 阶段
  ↓
DOM 更新 / layout effect / passive effect

这一条线里,最容易搞混的是两件事:

第一,React 运行时真正处理的不是 JSX 本身,而是 JSX 编译后的 ReactElement

第二,React 并不是一收到更新就直接改 DOM,而是先经过调度、render 计算,再进入 commit 提交

所以从源码阅读角度看,后面我们遇到的大部分概念,都能挂到这条链上。

1. JSX 先变成 ReactElement

我们平时写的是:

<App count={1} />

但 React 真正接收到的,不是这段“长得像 HTML 的语法”,而是编译产物。

所以阅读源码的第一层问题,不应该是“React 怎么处理 <App />”,而应该是:

<App /> 编译之后到底是什么对象?

2. root.render(element) 把更新送进系统

对很多开发者来说,root.render(<App />) 最容易产生一个错觉:好像这行代码一执行,页面就立刻被渲染出来了。

但源码视角下,更准确的理解应该是:

root.render 负责把一份 element 更新送进 React 的根节点更新体系。

也就是说,这一步更像“发起一次更新”,而不是“直接完成渲染”。

3. Root / Fiber 系统接管这次更新

一旦更新进入系统,它就不再只是一个普通对象了。React 会把它放进 Root/Fiber 这套结构里,让后续调度、计算、提交都有地方可挂。

所以后面当我们看到这些词时,不要把它们看成独立概念:

  • Root
  • FiberRoot
  • HostRoot Fiber
  • update queue

它们其实都属于 React 这套更新系统的基础设施。

4. 调度决定“现在做不做、先做哪部分”

React 不是简单地“收到更新 → 马上全做完”。它还要决定:

  • 这次更新优先级高不高
  • 要不要马上做
  • 能不能让一部分工作稍后做
  • 当前阶段能不能让出执行机会

这时候调度层就进来了。

所以后面我们看到 lanes、调度入口、任务安排的时候,本质上是在看 React 如何安排“这次更新该怎么被执行”。

5. render 阶段负责计算结果,不直接提交

render 阶段是很多人第一次读源码时最容易误解的部分。因为“render”这个词太像“渲染到页面”。

但从源码视角看,render 阶段更准确的理解应该是:

它在算下一次要提交什么,而不是立即把结果改到页面上。

这一阶段里,React 会基于当前树和本次更新,逐步构造工作中的新树,并收集这次提交所需的信息。

所以后面我们看到:

  • beginWork
  • completeWork
  • work loop
  • flags / subtreeFlags

本质上都是 render 阶段里的核心组成。

6. commit 阶段才真正提交结果

当 render 阶段把“这次更新要做什么”算得差不多了,React 才会进入 commit 阶段。

到了这一阶段,才会真正发生这些事:

  • 插入、更新、删除 DOM
  • 执行 layout 相关副作用
  • 在后续时机执行 passive effect

所以 React 整体并不是一段线性同步逻辑,而更像一条清晰的更新流水线:

描述 UI → 发起更新 → 调度 → render 计算 → commit 提交


六、React 源码应该怎么读:按问题读,不按文件读

知道主线之后,接下来的问题就变成:

那源码到底该怎么读?

我自己的建议是:按问题读,不要按文件读。

也就是说,不要一上来就给自己定任务:“今天我要看完某个文件。”更好的方式,是先定一个问题,再去找这个问题对应的入口和调用链。

1. 先问问题,再找入口

比如我们可以先问自己这些问题:

  • JSX 编译后到底是什么
  • createRoot(container) 到底创建了什么
  • root.render(<App />) 做了什么
  • setState 之后发生了什么
  • DOM 是在 render 阶段更新,还是在 commit 阶段更新
  • Hooks 为什么必须按顺序调用

这样做的好处是,源码不再是一整片森林,而是变成了几条有明确方向的小路。

2. 每次只追一条最小闭环

很多人读源码会越看越累,还有一个原因:一开始就拿复杂场景下手。

更好的方法,是先拿一个最小例子:

const root = createRoot(container)
root.render(<App />)

或者:

function App() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

我们只追一条最短链路:

  • 这个 element 怎么进入系统
  • 这次更新怎么入队
  • 什么时候开始 render
  • 什么时候 commit
  • effect 什么时候执行

只要最小闭环走通一次,后面再看复杂场景,心里就会稳很多。

3. 先看入口函数,再看核心数据结构

源码阅读里有一个很实用的原则:

入口函数负责告诉我们“从哪里开始追”,数据结构负责告诉我们“数据是怎么流动的”。

比如:

  • 当问题落在 JSX 编译产物时,重点通常是 ReactElement 这个对象本身
  • 当问题落在应用启动时,重点通常是 Root / HostRoot Fiber 这层结构
  • 当问题落在更新如何进入系统时,重点通常是 Update、UpdateQueue、Lane
  • 当问题落在 render 过程时,重点通常是 Fiber、flags、workInProgress
  • 当问题落在 Hooks 内部机制时,重点通常是 Hook 链表以及它和 Fiber 的关系

4. 阅读源码时,最好始终问一句:它在主线里负责什么

无论我们现在看到的是:

  • 一个目录
  • 一个包
  • 一个函数
  • 一个字段
  • 一个变量名

都先问一句:

它在整条更新主线里,负责哪一段?

只要这个问题一直留在脑子里,源码阅读就不容易发散。


七、调试方式怎么选:从只读到可断点

这部分我不打算写成环境搭建教程,因为对刚开始阅读源码的人来说,更重要的还是先建立地图,再逐步进入调试。我更倾向于把调试方式分成三个层次。

1. 第一层:先只读,不着急跑全链路

刚开始时,不一定要马上把 React 仓库完整跑起来,也不一定要急着深挖每个入口。

这个阶段更重要的是:

  • 建立总图
  • 记住核心包职责
  • 知道接下来继续往里看时,核心问题会落在哪些位置
  • 对“从 JSX 到 commit”的主线有整体印象

2. 第二层:用最小 demo 打断点追入口

当我们开始进入具体主题,比如:

  • JSX 到 ReactElement
  • createRoot
  • root.render
  • setState
  • useEffect

这时候最好的方式,就是准备一个最小 demo,然后围绕一个非常具体的问题去断点。

不要想着“今天调试 React”,而要想着:

  • 今天只看 createRoot 做了什么
  • 今天只看一次 setState 怎么入队
  • 今天只看 useEffect 在什么时候被记录、什么时候被执行

问题越单一,断点越清晰,收获越大。

3. 第三层:围绕一条具体链路深挖到底

真正进入深入阶段时,我们的目标也不该是“把 React 全部调完”。更现实也更有效的目标是:

  • 把一次更新从触发到提交完整走通
  • 把一个 Hook 从调用到记录到执行完整走通
  • 把 Root、HostRoot Fiber、update queue 的关系彻底理顺

换句话说,调试不是为了证明“我能跑源码”,而是为了回答一个具体问题。


八、顺着这张地图继续往里看,我们会遇到哪些核心问题

到这里,这篇“阅读地图”其实就差不多搭完了。

如果继续顺着同一条主线往里看,接下来最核心的问题,大致会落在这些位置:

1. JSX 到 ReactElement

JSX 编译后到底是什么?React 运行时最先拿到的对象长什么样?

2. createRootroot.render

React 应用启动时,到底创建了什么?Root 和 HostRoot Fiber 是什么关系?

3. Fiber 到底是什么

Fiber 为什么不是 ReactElement,也不是 DOM?React 为什么需要 Fiber?

4. 从 setState 到调度

一次更新是怎么进入系统的?Update、UpdateQueue、Lane 分别扮演什么角色?

5. render 阶段

beginWorkcompleteWork 在做什么?render 阶段为什么不直接改 DOM?

6. commit 阶段

DOM 到底什么时候更新?layout effect 和 passive effect 分别在什么时机执行?

7. Hooks 内部原理

Hooks 为什么必须按顺序调用?useStateuseEffect 是如何挂到 Fiber 上的?

把这些问题串起来之后,React 源码在我们脑子里就不再是一堆零散名词,而会慢慢变成一条完整的更新链路。


结语

React 源码最难的地方,从来都不是某一个函数本身。

真正难的是:如果没有地图,很多细节都会看起来彼此割裂。今天看到 Fiber,明天看到 Hook,后天又看到 commit,名词越来越多,但主线反而越来越模糊。

所以在真正扎进细节之前,先把 React 当成一套系统看清楚,会让后面的阅读顺很多。

当我们先知道:

  • React 整体是一条怎样的更新主线
  • 仓库里哪些地方和这条主线直接相关
  • 四个核心包分别负责什么
  • 继续往里读时,核心问题大概会落在哪些位置

那接下来再看 ReactElement、Root、Fiber、调度、render、commit、Hooks,很多原本抽象的词,才会慢慢落地。

如果这篇“阅读地图”已经搭起来了,那么下一步最自然的切口,就是回到主线最前面,先看一个问题:

React 真正接收到的第一个核心对象,到底长什么样?

如果这篇对你有帮助,欢迎点个赞支持。后面我也会继续把这组 React 源码文章慢慢补完整。

这组源码解读文章也会同步整理到 GitHub 仓库里,方便集中查看和持续更新:

GitHub: github.com/HWYD/source…

如果觉得这组内容对你有帮助,也欢迎顺手点个 Star。

最近在做的一个 AI 项目

最近我也在持续迭代一个 AI 项目:AI Mind
如果你对 AI 应用工程化、Tool Calling、Skill Runtime、MCP 这些方向感兴趣,欢迎来看看。

GitHub: github.com/HWYD/ai-min…

如果觉得项目还不错,也欢迎顺手点个 Star。

pnpm monorepo 下,如何把 Next.js 应用里的稳定内核拆成内部 workspace 包

作者 倾颜
2026年4月16日 16:18

在一个 Next.js 应用里,当某些模块越来越稳定、越来越可能被复用时,什么时候应该把它们拆成 packages/* 里的内部 workspace 包?

我在 AI Mind v0.0.10 里处理的,就是这样一个问题。

先简单介绍一下这个项目。AI Mind 不是一个一次性做完的 AI 产品,而是一个按版本持续演进的 AI Native Runtime Skeleton。它从本地聊天闭环出发,逐步长出结构化流式协议、Tool Calling、Skill Runtime、MCP 接入,以及后面的 Agent / 数据层能力。

ai-1.gif

当前主应用在 apps/webapp。到 v0.0.10 为止,这个项目已经能跑一条比较完整的聊天链路:请求从 /api/chat 进入,经过 chat-service 和 runtime 编排,再去衔接 skill、tool、MCP,最后以前端可消费的流式 chunk 返回。

也正因为这条主链已经逐渐跑稳,一个更具体、也更工程化的问题才会冒出来:当某一层能力已经明显稳定、也明显可能复用时,我们到底应该什么时候把它拆成 packages/* 里的内部 workspace 包?

这正是 v0.0.10 的主题。 这一版我没有一上来就把整个 Chat Runtime 抽出去,也没有为了 monorepo 先做一个“大而全”的基础包,而是先在 apps/webapp 内把聊天主链收口,再只把真正稳定的流式内核沉成 @ai-mind/stream-core

所以这篇文章不会从 pnpm monorepo 的基础配置讲起,也不会把重点放在“我又拆了一个包”。我更想复盘的是一次真实项目里很常见、也很容易做重的工程判断:

  • 什么样的代码,才值得先拆成内部 workspace 包?
  • 为什么在拆包之前,我们最好先把应用内 Runtime 的边界做稳?

先看结论

拆包不是目标。先把应用内边界收稳,再把已经跑稳的那一小块内核沉淀出来,拆包才会真的带来收益。

apps/webapppackages/stream-core 的结构示意图

v0.0.10-stream-core-cover-01.png


1. 为什么这次拆包不是从 package 开始,而是从 Runtime 收口开始

1.1 拆包不是目标,稳定边界才是目标

真正值得优先解决的,不是“怎么拆包”,而是“边界是不是已经稳了”。

在工程里,拆包本身并不天然代表结构更好。目录拆得更细,也不等于边界就更清楚。真正关键的是,我们能不能先回答下面这几个问题:

  • 这一层的语义是不是已经稳定了?
  • 它是不是已经不再强依赖当前应用里的业务编排?
  • 如果现在把它抽出去,边界会更清楚,还是只会多一层跳转?

如果这些问题还没想明白,拆包通常不会减少复杂度,只会把复杂度换个目录继续保存。

边界没稳时,抽出去的往往不是“可复用内核”,而是一份还在变化中的局部实现。它带来的结果通常也不难想象:

  • app 内还得持续频繁修改它
  • 对外接口会跟着反复抖动
  • 主链职责没有更清楚,反而多了一层跨目录理解成本

所以对我来说,拆包的前提不是“能不能拆”,而是“是不是已经稳到值得拆”。

1.2 这个问题是怎么在我的项目里出现的

AI Mind 当前是一个 Next.js + pnpm monorepo 的 AI Webapp,主应用在 apps/webapp

到这一轮之前,仓库层面的 monorepo 形态其实已经在了,但聊天主链里不少核心逻辑仍然集中在 app 内部。换句话说,目录先搭起来了,Runtime 的边界却还没有完全长清楚。

所以我要解决的,本质上不是“怎么把 monorepo 配起来”,而是“在已经存在的 monorepo 里,哪些东西真的成熟到值得沉淀成内部 workspace 包”。

如果只看目录变化,这一版像是做了两件事:

  1. chat-service 拆薄
  2. 新建了 packages/stream-core

但从工程演进角度看,它们其实是一件事的前后两步:

先把 apps/webapp 里的聊天主链收口成“薄 facade + runtime 编排层”,再把其中已经稳定的流式内核沉淀成内部 workspace 包。

也正因为先做了前一步,后面“到底什么值得拆”这件事才开始变得清楚。

我最后把这版真正要回答的问题,收成了两个判断:

  1. 聊天主链内部边界是否已经足够清晰?
  2. 哪一部分能力已经稳定到值得从 app 内部沉淀成包?

如果这两个问题不先回答,所谓 package 化就很容易退化成“目录迁移”,而不是一次真正有价值的结构升级。


2. 第一步:先把应用内 Chat Runtime 收口出来

2.1 为什么 chat-service 不能继续变胖

这版真正先动的,不是 package,而是 chat-service 这个入口层。

在一个聊天应用里,chat-service 很容易不断吸收新职责:

  • prompt 构建
  • planning / retry / final answer
  • tool / resource 执行
  • chunk 写出
  • 错误收口

短期看这样很方便,因为所有逻辑都能往一个地方放。长期看,它会慢慢变成一个很典型的“胖服务层”:

  • 外部入口和内部编排耦在一起
  • 测试越来越难写
  • 边界越来越难拆

所以 v0.0.10 的第一步不是抽包,而是先把这个入口层重新收回到它该有的位置。

2.2 我怎么把聊天主链收口成“薄 facade + runtime 编排层”

我最后把主链收成了一个更容易解释、也更容易继续演进的结构:

route
  -> chat-service facade
    -> runtime
      -> skills / tools / mcp

对应实现大致分布在这些位置:

  • apps/webapp/app/api/chat/route.ts(聊天 API 入口,负责 HTTP 边界和错误映射)
  • apps/webapp/lib/ai/chat-service.ts(聊天服务 facade,负责对外暴露稳定入口和包装响应)
  • apps/webapp/lib/ai/runtime/(聊天运行时编排层,真正组织 planning、tool 调用和最终回答)

chat-service 现在的角色已经很克制了,它不再承接整条链路的所有细节,而是只负责稳定入口和响应包装:

export function createChatService(deps: ChatServiceDependencies) {
    return {
        async streamChat(request: ChatRequest, context: ChatExecutionContext) {
            const streamResult = await createChatStreamResult(request, context, deps)

            return new Response(streamResult.body, {
                headers: streamResult.headers,
            })
        },
    }
}

这段代码很小,但它表达出来的边界很重要:对外入口留在 facade,真正的运行时编排收回 runtime。

2.3 Runtime 收口后,内部职责怎么重新分配

主链一旦收口,runtime 内部的职责也就开始变清楚了。

当前核心文件主要包括:

  • chat-session.ts(按请求组装会话上下文、模型实例、active tools 和 system prompts)
  • chat-orchestrator.ts(决定 direct-answer、planning、tool-execution、final-answer 这些阶段怎么串起来)
  • assistant-stream.ts(消费模型输出流,把 reasoning / text 等内容写成标准 chunk)
  • tool-runtime/(承接 tool call 的校验、执行,以及 Tool / Resource 展示信息映射)
  • authoritative-answer.ts(判断单工具确定性结果是否可以跳过模型、直接静态回流)

这一节最重要的,不是把文件列出来,而是让我们能明确看见:谁负责外部入口,谁负责运行时编排,谁负责具体执行。

只有当应用内 Runtime 自己先变清楚了,我们才看得见两件事:

  • 什么是真正稳定的内核
  • 什么仍然属于当前应用的编排层

这一步做完以后,后面的拆包判断才不再靠感觉,而是可以基于已经清楚的职责边界来做。


3. 第二步:怎么判断哪些代码才算“稳定内核”

3.1 我给自己用的一组拆包判断标准

这次我给自己定的标准很简单,但非常实用:

  • 语义是否稳定
  • 是否与业务策略弱耦合
  • 是否跨层复用明显
  • 是否具备独立测试价值
  • 是否可以单独 build / typecheck
  • 是否值得被多个 app / 模块消费

只要前面几条还答不清楚,我通常就不会急着拆。

3.2 适合先拆出去的,不是“最大的一块”,而是“最稳定的一块”

这次我很想留下来的一个判断是:先拆出去的,不一定是最大的那块,而应该是最稳定的那块。

很多时候我们天然会盯着最大的模块:

  • 最大的 service
  • 最大的 runtime
  • 最大的 orchestration

但大的东西,往往也是变化最多、业务语义最重的东西。

这次真正适合先拆出去的,反而不是最大块,而是最稳定的一块:

  • 流式协议
  • 生命周期
  • 错误 chunk
  • static writers
  • NDJSON writer

它们不大,却已经足够清楚、足够独立,也足够值得被当成一层内核看待。

3.3 用项目举例:哪些东西我认为还不该拆

先说我明确不打算在这一步就拆出去的部分。

  • chat-orchestrator(负责 planning、tool 执行、authoritative answer 和 final answer 的阶段编排)
  • chat-session(负责按当前请求组装模型、messages、skill prompt 和 active tools)
  • tool-runtime(负责 tool call 校验、执行,以及 Tool / Resource 展示信息映射)
  • Skill 编排(决定当前请求命中哪个 skill,以及这个 skill 允许使用哪些工具)
  • MCP 消费层(把外部 MCP Tool / Resource 接到当前 runtime 和展示语义上)

原因很直接:它们仍然带有明显的应用内语义和业务编排特征。

这些模块继续留在 apps/webapp,反而是更清晰的选择。

3.4 用项目举例:哪些东西已经足够稳定

再看另一边。下面这些内容,已经很接近一层可以单独沉淀的稳定内核:

  • ChatStreamChunk(定义整条流式协议里有哪些 chunk,以及每种 chunk 带什么字段)
  • StreamLifecycle(约束 start / finish / runtime error 这些生命周期终态只发一次)
  • error chunk helper(统一生成和写出 error chunk)
  • static text / reasoning writers(把静态文本或推理内容写成标准流式 part)
  • NDJSON web writer(把 chunk 序列编码成前端可消费的 NDJSON 响应体)

它们的共同点也很明显:

  • 不直接携带业务策略
  • 语义稳定
  • 本身就值得独立测试
  • 很容易被别的 app 或 service 复用

这就是 stream-core 最终被抽出来的基础。


4. 为什么最后拆出来的是 stream-core

4.1 我没有先拆 runtime-core,也没有拆整个 chat runtime

这是很多人看到目录变化之后,第一反应会问的问题:

“既然已经有 runtime 了,为什么不直接抽一个 runtime-core?”

原因很简单:今天的 runtime 还不是一块可以稳定复用的内核,它仍然包含大量应用级判断:

  • planning 阶段怎么走
  • tool 结果什么时候可以直出
  • skill / tool / mcp 怎么组合

这些东西现在抽出去,只会把编排层也一起包化。

4.2 stream-core 代表的是一块已经稳定的流式内核

真正被我拆出去的,不是一个“大 runtime”,而是一块已经跑稳的流式内核。

它的稳定主要体现在几件事上:

  • 协议已经比较稳定
  • 生命周期已经比较稳定
  • writer 的职责已经比较稳定
  • 和具体业务编排之间是弱耦合关系

StreamLifecycle 就是一个很典型的例子:

export class StreamLifecycle {
    private started = false
    private terminated = false

    emitStartOnce() {
        if (this.started || this.terminated || this.isClosed()) {
            return false
        }

        this.started = true
        this.writeChunk({
            type: 'start',
            messageId: createId(),
        })

        return true
    }
}

它不关心 skill、tool、MCP 这些上层语义,只关心流式生命周期本身是否被正确表达。这种代码,就很适合先沉淀下来。

4.3 stream-core 的职责边界是什么

这个包的边界其实非常克制,当前只放这些内容:

  • protocol
  • lifecycle
  • error chunk
  • static parts writer
  • web NDJSON writer

对应源码大致位于:

  • packages/stream-core/src/protocol/(定义 start / text / reasoning / tool / resource / error / finish 这些 chunk 类型)
  • packages/stream-core/src/core/stream-lifecycle.ts(统一处理流开始、结束和 runtime error 的终态收口)
  • packages/stream-core/src/core/stream-error.ts(统一创建和写出错误 chunk)
  • packages/stream-core/src/core/static-parts.ts(把静态文本或推理内容写成标准流式 part)
  • packages/stream-core/src/adapters/web/chunk-writer.ts(把 chunk 逐行编码成 NDJSON 并写进 Web ReadableStream

而这些内容我明确没有放进去:

  • orchestrator(聊天主链的阶段编排和策略判断)
  • session(按请求拼出模型上下文、messages 和 active tools)
  • tool runtime(工具校验、执行与展示映射)
  • skill / MCP 编排(当前应用里的能力路由和外部能力接入层)

因为它们今天仍然属于“应用内编排层”,还不是适合沉淀成公共内核的部分。

4.4 这一版拆包的核心取舍

如果把这一版的取舍压成一句话,我会这样说:

我不是为了让项目“看起来更像架构”而拆包,而是只把已经在应用内跑稳、边界也相对清楚的那部分流式内核,正式沉淀了下来。

这也是为什么它最终叫 stream-core,而不是一个一看就想把所有东西都装进去的名字。


5. 在 pnpm monorepo 里,把它真正落成内部 workspace 包

5.1 packages/stream-core 的目录与包名设计

这个包的目录和命名,我一开始就尽量做得很直白:

  • 目录:packages/stream-core
  • 包名:@ai-mind/stream-core

这个命名本身就在表达边界:它承接的是 stream core,不是整个 chat runtime。

5.2 为什么我给它做了清晰的 exports,而不是只有一个根入口

内部包也需要边界,不能先暴露一个大入口,后面再慢慢补救。

这次我给 stream-core 做了明确的 exports:

  • 根入口(暴露 stream-core 的核心能力)
  • ./protocol(只暴露流式协议类型)
  • ./web(只暴露面向 ReadableStream 的 NDJSON writer 适配器)

对应配置在 packages/stream-core/package.json

"exports": {
  ".": {
    "types": "./build/types/index.d.ts",
    "require": "./build/cjs/index.js",
    "import": "./build/esm/index.mjs"
  },
  "./protocol": {
    "types": "./build/types/protocol/index.d.ts",
    "require": "./build/cjs/protocol/index.js",
    "import": "./build/esm/protocol/index.mjs"
  },
  "./web": {
    "types": "./build/types/adapters/web/index.d.ts",
    "require": "./build/cjs/adapters/web/index.js",
    "import": "./build/esm/adapters/web/index.mjs"
  }
}

这样做的价值不只是“写得更正规”,而是让消费边界从一开始就足够明确:

  • 根入口给稳定基础能力
  • ./protocol 单独暴露协议类型
  • ./web 单独暴露面向 Web 流响应的适配能力

5.3 为什么我选择双产物构建,而不是只做单一格式

我没有把它做成一份“先能跑起来再说”的源码目录,而是直接按一个内部包去收它的产物形态。

当前 stream-core 输出的是三类产物:

  • build/cjs
  • build/esm
  • build/types

我更想强调的不是“格式有几种”,而是内部 workspace 包一旦开始承担复用职责,就应该被当成一个完整工程单元对待。

它不再只是 app 目录里被移动出去的一份代码,而是一层有明确导出、有独立产物、有自己工程边界的内部能力。双产物构建在这里也不是为了“看起来更像公共包”,而是为了先把内部消费形态收规整。

5.4 apps/webapp 是怎么接入这个 workspace 包的

让一个内部包真正落到应用里,不能只停在“把 import 改过去”。

这次 apps/webapp 的接入主要包括三件事:

  • 依赖用 workspace:*
  • Next.js 通过 transpilePackages 消费它
  • TypeScript 侧使用 moduleResolution: "bundler"

对应配置分别落在:

  • apps/webapp/package.json(声明 @ai-mind/stream-core 这个 workspace 依赖)
  • apps/webapp/next.config.ts(通过 transpilePackages 让 Next.js 正常消费内部包)
  • apps/webapp/tsconfig.json(通过 moduleResolution: "bundler" 对齐包导出解析方式)

这三件事放在一起,才算是“这个 workspace 包已经被当前应用稳定接入”。

5.5 拆成包以后,消费边界也要跟着收稳

目录拆开只是第一步,消费关系也必须跟着显式化。

所以这次除了目录和依赖本身,我也尽量把“它是一个独立工程单元”这件事落到日常约束里:包有自己的构建产物,有自己的导出边界,也有自己的验证责任。

这样一来,stream-core 不再只是“从 app 挪出去的一坨代码”,而是真正可以被稳定消费的一层内部能力。


6. 拆包以后,如何保持现有应用主链不被破坏

6.1 外部入口为什么要保持稳定

这次拆包里,我一直守着一个原则:外部入口尽量不动。

当前对外稳定入口仍然是:

  • createChatService().streamChat()
  • /api/chat

也就是说,底层内核在沉淀,但业务调用层的感知应该尽量保持稳定。

6.2 好的拆包,不应该让业务调用层感受到“地震”

我很认同一句话:真正好的拆包,是内部收口,外部少感知。

这次变化主要发生在内部:

  • chat-service 回到了 facade 角色
  • runtime 的职责更清楚了
  • stream core 被正式沉淀到了 workspace 包

而边界以上的消费方式尽量保持不变,这样拆包才是在降低演进成本,而不是把改动面放大。

6.3 这次拆包对前端消费语义有什么影响

对前端来说,这次最关键的不是“代码搬家了”,而是消费语义没有被破坏。

前端仍然消费同一套流式内容:

  • reasoning
  • tool
  • resource
  • text
  • 统一 error chunk

变化发生在底层:这些协议和 writer 能力,现在由 @ai-mind/stream-core 来承接。

也正因为如此,这次拆包带来的不是“前端协议换了一套”,而是“协议终于有了更明确的归属层”。


7. 为什么真正的拆包,不会只停在目录和 import 上

7.1 测试目录为什么要统一到 tests/**

测试目录统一看起来像小事,但它本质上也是边界收口的一部分。

当前 webapp 侧统一到:

  • apps/webapp/tests/**(webapp 主链和前端消费相关的自动化测试)

package 侧独立到:

  • packages/stream-core/tests/**(stream-core 作为内部包的独立单测)

这样做的价值很直接:

  • app 侧测试边界清楚
  • package 侧测试边界清楚
  • 扫描规则清楚

同时,我也补了位置校验脚本,避免测试文件再慢慢散回业务目录。

7.2 一个内部 workspace 包,也应该有自己的 test / typecheck / build

这是我这次很在意的一点,因为这直接决定它是不是一个真正成立的包。

如果一个内部包没有自己的 test / typecheck / build,那它往往还只是“被搬出去的代码”,还称不上真正的工程单元。

packages/stream-core 现在已经有自己独立的:

  • build
  • typecheck
  • test

这会让后面继续演进它的成本低很多。

7.3 为什么文档资产也要一起更新

代码边界变了,文档边界也要跟着一起变。

所以跟着一起更新的内容包括:

  • plan(记录这版的目标、非目标和关键取舍)
  • tasklist(记录这版具体落地了哪些工作)
  • runtime note(解释聊天主链现在的运行时边界)
  • release(总结版本最终结果)
  • architecture note(沉淀跨版本仍然有效的结构判断)
  • blog material(把实现取舍整理成对外可讲的内容)
  • README(同步仓库当前状态和结构)

这样以后再回头看这版,不会只看到代码改动,还能看到当时的判断、边界和取舍是怎么形成的。


8. 我从这次拆包里得到的 4 个结论

8.1 先在应用内收口边界,再拆包

应用内边界都还没稳的时候,包化通常不会让结构更清楚。

8.2 先抽稳定内核,不急着抽业务编排层

最值得先抽出去的,往往不是最大块,而是最稳定、最独立、最少业务语义的那一块。

8.3 拆包不是为了“更像架构”,而是为了更低成本地演进

如果拆完以后每次修改都更困难,那这个包就没有真正帮我们降低复杂度。

8.4 pnpm monorepo 最适合承载“先验证、再沉淀”的内部架构演进

对我来说,pnpm monorepo 最大的价值不是目录看起来更专业,而是它非常适合承接一种克制的演进方式:

先在 app 内验证边界,再把已经跑稳的那部分自然沉淀成内部 workspace 包。


9. 结尾:我为什么觉得这次拆 stream-core 是值得的

9.1 它让我更清楚地看见了 Runtime 的边界

这次最直接的收获,不是仓库里多了一个包,而是 Runtime 的边界终于能被更清楚地说出来。

做完这次拆分之后,我能更明确地区分:

  • facade 在哪
  • runtime 编排层在哪
  • 稳定流式内核在哪

这比“多了一个 package”本身更重要。

9.2 它不是平台化,而是一次克制的沉淀

我很看重这次的一点,是它足够克制。

这次我没有把整个 chat runtime 一口气打成一个“大而全”的基础包。

我只是把已经在应用里跑稳、边界也相对清楚的那部分流式内核,正式沉淀了下来。

我很看重这种节奏。它不是过度设计,而是一种更克制、也更容易继续演进的沉淀方式。

9.3 后面哪些东西,我反而不会急着拆

也正因为这次我更看重“克制”,所以有些东西我反而不会急着拆。

至少在当前阶段,下面这些内容我不会急着拆出去:

  • chat-orchestrator(聊天主链的阶段编排和策略判断)
  • chat-session(按请求组装模型上下文、messages 和 active tools)
  • tool-runtime(工具校验、执行与展示映射)
  • 业务策略层(和当前产品问答体验强绑定的策略判断)

因为它们今天依然带有明显的应用内语义。

如果现在就急着把这些内容一起包化,只会把还在变化中的编排层也一并固化,反而失去边界。

如果用一句话收住这篇文章,我会这么写:

对我来说,这次拆包的意义,不是“多了一个 package”,而是第一次把“应用内已经跑稳的稳定内核”正式沉淀了下来。


项目地址

GitHub: github.com/HWYD/ai-min…

如果这篇文章刚好对正在处理类似 Runtime / monorepo 拆分问题的同路人有一点参考价值,欢迎来仓库里看看。
如果你也对这种按版本持续演进的 AI Runtime Skeleton 感兴趣,顺手点个 Star,也能让我知道这条路线确实对外部读者有帮助。
后面我也会继续沿着 Runtime、MCP、Agent 这些方向,把这套骨架一点点往前推。

你的 Vue 3 TS 类型声明,VuReact 会处理成什么样的 React?

作者 Ruihong
2026年4月17日 10:09

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:VuReact 如何自动分析 Vue 3 中的响应式依赖,精准生成 React Hooks 的依赖数组

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 和 React 的响应式与依赖追踪机制。

编译对照

Vue 自动依赖分析 → React Hook 依赖数组生成

VuReact 编译器内置了自动依赖分析能力,遵循 React 规则,智能分析顶层箭头函数顶层变量声明中的响应式访问,并生成准确的依赖数组。

  • Vue 代码:
<script setup lang="ts">
  import { reactive, ref } from 'vue';

  const count = ref(0);
  const foo = ref(0);
  const state = reactive({ foo: 'bar', bar: { c: 1 } });

  const fn1 = () => {
    count.value += state.bar.c;
    console.log(count.value);
  };

  const fn = () => {};

  const fn2 = () => {
    const c = foo.value;
    fn();

    const fn4 = () => {
      state.bar.c--;
      c + count.value;
    };
  };

  const fn3 = () => {
    foo.value++;

    const state = ref('fake');
    const count = state.value + 'yoxi';
    count.charAt(1);
  };
</script>
  • VuReact 编译后 React 代码:
const count = useVRef(0);
const foo = useVRef(0);
const state = useReactive({ foo: 'bar', bar: { c: 1 } });

const fn1 = useCallback(() => {
  count.value += state.bar.c;
  console.log(count.value);
}, [count.value, state.bar?.c]);

const fn = () => {};

const fn2 = useCallback(() => {
  const c = foo.value;
  fn();

  const fn4 = () => {
    state.bar.c--;
    c + count.value;
  };
}, [foo.value, state.bar?.c, count.value]);

const fn3 = useCallback(() => {
  foo.value++;

  const state = useVRef('fake');
  const count = state.value + 'yoxi';
  count.charAt(1);
}, [foo.value]);

这段对比展示了:

  • fn1 会被识别为顶层箭头函数并收集 count.valuestate.bar.c
  • fn2 会溯源 c 并忽略局部函数 fn4
  • fn3 会忽略函数内部新建的响应式变量,只收集外部依赖 foo.value

Vue 组合访问与别名追踪

VuReact 也会对复杂别名链和解构访问进行溯源。

  • Vue 代码:
<script setup lang="ts">
  const objRef = ref({ a: 1, b: { c: 1 } });
  const listRef = ref([1, 2, 3]);
  const aliasA = state.foo;
  const aliasB = aliasA;
  const aliasC = aliasB;
  const { foo: stateFoo } = state;
  const [first] = listRef.value;

  const traceFn = () => {
    aliasC;
  };

  const destructureFn = () => {
    stateFoo;
    first;
  };
</script>
  • VuReact 编译后 React 代码:
const objRef = useVRef({ a: 1, b: { c: 1 } });
const listRef = useVRef([1, 2, 3]);
const aliasA = useMemo(() => state.foo, [state.foo]);
const aliasB = useMemo(() => aliasA, [aliasA]);
const aliasC = useMemo(() => aliasB, [aliasB]);
const { foo: stateFoo } = useMemo(() => state, [state]);
const [first] = useMemo(() => listRef.value, [listRef.value]);

const traceFn = useCallback(() => {
  aliasC;
}, [aliasC]);

const destructureFn = useCallback(() => {
  stateFoo;
  first;
}, [stateFoo, first]);

这样可见:

  • alias 链会被逐层溯源到真实响应式来源;
  • 解构后的变量也会通过 useMemo 转换为可追踪依赖。

Vue 顶层变量声明 → React useMemo 依赖数组生成

  • Vue 代码:
<script setup lang="ts">
  const fooRef = ref(0);
  const reactiveState = reactive({ foo: 'bar', bar: { c: 1 } });

  const memoizedObj = {
    title: 'test',
    bar: fooRef.value,
    add: () => {
      reactiveState.bar.c++;
    },
  };

  let staticObj = {
    foo: 1,
    state: { bar: { c: 1 } },
  };

  const reactiveList = [fooRef.value, 1, 2];

  const mixedList = [
    { name: reactiveState.foo, age: fooRef.value },
    { name: 'A', age: 20 },
  ];

  const nestedObj = {
    a: {
      b: {
        c: reactiveList[0],
        d: () => {
          return memoizedObj.bar;
        },
      },
      e: mixedList,
    },
  };
</script>
  • VuReact 编译后 React 代码:
const memoizedObj = useMemo(
  () => ({
    title: 'test',
    bar: fooRef.value,
    add: () => {
      reactiveState.bar.c++;
    },
  }),
  [fooRef.value, reactiveState.bar?.c],
);

let staticObj = {
  foo: 1,
  state: {
    bar: {
      c: 1,
    },
  },
};

const reactiveList = useMemo(() => [fooRef.value, 1, 2], [fooRef.value]);

const mixedList = useMemo(
  () => [
    { name: reactiveState.foo, age: fooRef.value },
    { name: 'A', age: 20 },
  ],
  [reactiveState.foo, fooRef.value],
);

const nestedObj = useMemo(
  () => ({
    a: {
      b: {
        c: reactiveList[0],
        d: () => {
          return memoizedObj.bar;
        },
      },
      e: mixedList,
    },
  }),
  [reactiveList[0], memoizedObj.bar, mixedList],
);

这里的核心对比是:

  • memoizedObj 会收集对象内部的响应式字段与方法依赖;
  • staticObj 因为不含响应式访问,不会被优化为 useMemo
  • reactiveListmixedListnestedObj 会根据结构递归补齐依赖数组。

自动依赖分析的三大原则

  1. 仅分析顶层可优化表达式:局部函数、嵌套作用域不纳入顶层 Hook 自动优化;
  2. 遵循 React 依赖规则:只收集函数/变量外部的响应式访问,而非内部局部变量;
  3. 避免过度优化:无外部响应式依赖的顶层箭头函数和变量不会被强制转换为 Hook。

为什么这很关键?

在 React 中,函数组件每次渲染会重新创建顶层函数与变量。如果这些顶层表达式依赖响应式状态且未获得稳定性处理,会带来:

  • 不必要的子组件重新渲染;
  • 频繁的 Hook 重新计算;
  • 性能不可控的回调变化。

VuReact 在编译阶段自动生成准确依赖数组,既保留了 Vue 写法的简洁性,又实现了 React 端的性能优化。

相关资源


如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

如何实现自定义的虚拟列表

2026年4月16日 20:51

从零实现一个虚拟列表,支持固定高度与动态高度两种场景

在大数据列表渲染场景中,虚拟列表是提升性能的利器。本文将从原理到实践,带你手动实现一个支持固定高度和动态高度的虚拟列表组件。

前言

当页面需要展示成千上万条数据时,如果直接全部渲染到 DOM 中,会导致:

  • DOM 节点过多:浏览器渲染压力大,页面卡顿
  • 内存占用高:每个 DOM 节点都占用内存
  • 滚动性能差:大量节点的重排重绘消耗性能

虚拟列表的核心思想:只渲染可视区域内的元素,通过动态计算和位置定位,实现海量数据的高性能渲染。

核心原理

1. 基本概念

虚拟列表的实现基于以下几个关键点:

┌─────────────────────────────────────┐
│          Container (可视区域)        │
│  ┌─────────────────────────────┐    │
│  │     可见列表项 (实际渲染)     │    │
│  │                             │    │
│  │        Item 3               │    │
│  │        Item 4               │    │
│  │        Item 5               │    │
│  │        Item 6               │    │
│  │        Item 7               │    │
│  └─────────────────────────────┘    │
│                                     │
│  ↑ 缓冲区 (预渲染)                   │
│  ↓ 缓冲区 (预渲染)                   │
└─────────────────────────────────────┘
│          Phantom (撑开容器)          │  ← 总高度 = 所有项高度之和
└─────────────────────────────────────┘
  • Container:固定高度的容器,设置 overflow: auto 实现滚动
  • Phantom:一个占位元素,高度等于所有列表项高度之和,用于撑开滚动条
  • Visible Items:只渲染可视区域 + 缓冲区内的列表项
  • Buffer:上下缓冲区,防止快速滚动时出现白屏

2. 两种场景对比

特性 固定高度 动态高度
位置计算 index * itemHeight,O(1) 复杂度 需要累积计算,O(n) 复杂度
实现难度 简单 较复杂
适用场景 列表项高度一致 列表项高度不一致
性能 极高 较高(需要缓存和测量)

实现方案

核心点一:位置计算

固定高度模式
// 固定高度:直接计算,O(1) 复杂度
function calculatePositions(data, itemHeight) {
  return data.map((_, index) => ({
    top: index * itemHeight,
    height: itemHeight
  }));
}
动态高度模式
// 动态高度:需要累积计算
function calculatePositions(data, heightCache, estimateHeight) {
  const positions = [];
  let currentTop = 0;

  for (let i = 0; i < data.length; i++) {
    // 优先使用已测量的高度,否则使用预估高度
    const height = heightCache.get(i) ?? estimateHeight(data[i], i);
    
    positions.push({
      top: currentTop,
      height
    });
    
    currentTop += height;
  }
  
  return positions;
}

核心点二:二分查找定位可视区域

当列表项数量巨大时,线性查找可视区域的起始和结束索引效率太低。使用二分查找可以将时间复杂度从 O(n) 降到 O(log n)。

/**
 * 二分查找:找到第一个顶部位置 >= scrollTop 的项索引
 * 时间复杂度:O(log n)
 */
function binarySearchFirstVisible(positions, scrollTop) {
  let left = 0;
  let right = positions.length - 1;
  let result = 0;

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    const midBottom = positions[mid].top + positions[mid].height;

    if (midBottom <= scrollTop) {
      left = mid + 1;
    } else {
      result = mid;
      right = mid - 1;
    }
  }

  return result;
}

/**
 * 二分查找:找到第一个底部位置 > scrollBottom 的项索引
 */
function binarySearchLastVisible(positions, scrollBottom) {
  let left = 0;
  let right = positions.length - 1;
  let result = positions.length - 1;

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);

    if (positions[mid].top < scrollBottom) {
      left = mid + 1;
    } else {
      result = mid;
      right = mid - 1;
    }
  }

  return result;
}

核心点三:缓冲区机制

快速滚动时,如果只渲染可视区域内的元素,会出现短暂的白屏。缓冲区机制通过预渲染可视区域上下额外的元素来解决这一问题。

/**
 * 计算缓冲区大小
 * 快速滚动时增大缓冲区,减少白屏
 */
function getBufferSize(containerHeight, bufferRatio, isScrolling) {
  // 滚动中时增加缓冲区
  return isScrolling 
    ? containerHeight * bufferRatio * 2 
    : containerHeight * bufferRatio;
}

/**
 * 获取可视区域的范围(含缓冲区)
 */
function getVisibleRange(positions, scrollTop, containerHeight, bufferSize, overscan) {
  const scrollTopWithBuffer = Math.max(0, scrollTop - bufferSize);
  const scrollBottomWithBuffer = scrollTop + containerHeight + bufferSize;

  // 二分查找可视区域
  let start = binarySearchFirstVisible(positions, scrollTopWithBuffer);
  let end = binarySearchLastVisible(positions, scrollBottomWithBuffer);

  // 添加 overscan 预渲染项
  start = Math.max(0, start - overscan);
  end = Math.min(positions.length - 1, end + overscan);

  return { start, end };
}

核心点四:动态高度测量与缓存

动态高度的难点在于:渲染前无法知道元素的实际高度。解决方案:

  1. 初始预估:使用 estimateHeight 函数预估初始高度
  2. 渲染后测量:使用 getBoundingClientRect() 测量实际高度
  3. 缓存更新:将测量结果缓存,避免重复测量
  4. 批量更新:所有测量完成后统一更新位置,避免频繁重算
// 渲染可视区域的元素
function render() {
  const { start, end } = getVisibleRange();
  
  // 记录需要测量高度的元素
  const pendingMeasure = [];

  for (let i = start; i <= end; i++) {
    if (!renderedItems.has(i)) {
      const item = data[i];
      const position = positions[i];

      // 创建并定位元素
      const el = document.createElement('div');
      el.style.position = 'absolute';
      el.style.top = `${position.top}px`;
      el.innerHTML = renderItem(item, i);
      container.appendChild(el);
      renderedItems.set(i, el);

      // 动态高度:记录需要测量的元素
      if (!isFixedHeight && !heightCache.has(i)) {
        pendingMeasure.push({ el, index: i });
      }
    }
  }

  // 批量测量高度,避免频繁更新位置
  if (pendingMeasure.length > 0) {
    requestAnimationFrame(() => {
      let hasUpdate = false;
      
      pendingMeasure.forEach(({ el, index }) => {
        const actualHeight = el.getBoundingClientRect().height;
        heightCache.set(index, actualHeight);
        hasUpdate = true;
      });
      
      // 所有高度测量完成后统一更新一次
      if (hasUpdate) {
        updatePositions();
        rerenderVisible();
      }
    });
  }
}

核心点五:滚动优化

滚动事件触发频繁,需要优化性能:

function bindEvents() {
  let rafId = null;
  let scrollTimer = null;

  container.addEventListener('scroll', (e) => {
    scrollTop = e.target.scrollTop;

    // 快速滑动检测
    isScrolling = true;
    
    if (scrollTimer) {
      clearTimeout(scrollTimer);
    }
    
    // 滚动停止后 150ms 重置状态
    scrollTimer = setTimeout(() => {
      isScrolling = false;
    }, 150);

    // 使用 requestAnimationFrame 优化渲染
    if (rafId) {
      cancelAnimationFrame(rafId);
    }
    
    rafId = requestAnimationFrame(() => {
      render();
    });
  });
}

效果演示

固定高度模式

每项高度固定为 50px,列表滚动流畅,渲染项数稳定。切换到固定高度模式后,可以看到所有列表项高度一致,适合用于简单列表场景。

image.png

image.png

动态高度模式

不同类型的内容高度不同,通过颜色标签区分:

  • 🔵 蓝色(单行):约 45px,简短内容
  • 🟢 绿色(中等):约 85px,2-3 行内容
  • 🟠 橙色(较长):约 155px,5-6 行内容
  • 🔴 红色(超长):约 285px,包含多段内容
  • 🟣 紫色(随机):约 60-120px,高度随机波动

image.png

image.png

性能优化总结

优化点 说明 效果
二分查找 定位可视区域 O(log n) 查找效率
缓冲区 上下预渲染 减少快速滚动白屏
高度缓存 避免重复测量 每项只测量一次
批量更新 统一更新位置 减少频繁重算
rAF 节流 requestAnimationFrame 平滑滚动渲染
滚动检测 快速滚动时增大缓冲区 提升用户体验

完整代码

原生 JavaScript 实现(可直接运行)

以下是完整的 HTML 文件,保存后可直接在浏览器中打开运行:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>虚拟列表 Demo</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      background: #f5f5f5;
      padding: 20px;
    }
    .demo-container {
      max-width: 900px;
      margin: 0 auto;
      background: #fff;
      border-radius: 12px;
      box-shadow: 0 2px 12px rgba(0,0,0,0.1);
      padding: 24px;
    }
    h1 { font-size: 24px; margin-bottom: 20px; color: #333; }
    .control-panel {
      display: flex;
      gap: 24px;
      margin-bottom: 24px;
      padding: 16px;
      background: #fafafa;
      border-radius: 8px;
      flex-wrap: wrap;
      align-items: center;
    }
    .control-group { display: flex; align-items: center; gap: 8px; }
    .control-group label { font-size: 14px; color: #666; font-weight: 500; }
    .control-group select {
      padding: 6px 12px;
      border: 1px solid #d9d9d9;
      border-radius: 6px;
      font-size: 14px;
      background: #fff;
      cursor: pointer;
      min-width: 120px;
    }
    .stats {
      margin-left: auto;
      display: flex;
      gap: 16px;
      font-size: 13px;
      color: #999;
    }
    .stats span {
      padding: 4px 12px;
      background: #e6f7ff;
      border-radius: 4px;
      color: #1890ff;
    }
    .list-wrapper {
      border: 1px solid #e8e8e8;
      border-radius: 8px;
      overflow: hidden;
      margin-bottom: 24px;
    }
    .virtual-list-container {
      height: 600px;
      overflow: auto;
      position: relative;
      background: #fff;
    }
    .virtual-list-phantom { position: relative; }
    .virtual-list-item {
      position: absolute;
      left: 0;
      right: 0;
      border-bottom: 1px solid #f0f0f0;
    }
    .virtual-list-item:hover { background: #f5f5f5; }
    .fixed-item {
      height: 100%;
      padding: 0 16px;
      display: flex;
      align-items: center;
    }
    .fixed-item .index { width: 80px; color: #999; font-size: 13px; }
    .fixed-item .content { flex: 1; }
    .dynamic-item { padding: 12px 16px; }
    .dynamic-item .header {
      font-weight: 600;
      margin-bottom: 8px;
      color: #1890ff;
      display: flex;
      justify-content: space-between;
      align-items: center;
    }
    .dynamic-item .text { color: #666; line-height: 1.6; font-size: 14px; white-space: pre-line; }
    .mode-tag {
      display: inline-block;
      padding: 2px 8px;
      background: #52c41a;
      color: #fff;
      border-radius: 4px;
      font-size: 12px;
      margin-left: 8px;
    }
    .mode-tag.dynamic { background: #722ed1; }
  </style>
</head>
<body>
  <div class="demo-container">
    <h1>虚拟列表 Demo <span class="mode-tag dynamic" id="modeTag">动态高度</span></h1>
    <div class="control-panel">
      <div class="control-group">
        <label>模式:</label>
        <select id="modeSelect">
          <option value="fixed">固定高度</option>
          <option value="dynamic" selected>动态高度</option>
        </select>
      </div>
      <div class="control-group">
        <label>数据量:</label>
        <select id="countSelect">
          <option value="1000">1,000 条</option>
          <option value="10000" selected>10,000 条</option>
          <option value="100000">100,000 条</option>
        </select>
      </div>
      <div class="control-group">
        <label>缓冲区:</label>
        <select id="bufferSelect">
          <option value="0">无缓冲</option>
          <option value="0.25">25%</option>
          <option value="0.5" selected>50%</option>
          <option value="1">100%</option>
        </select>
      </div>
      <div class="stats">
        <span id="renderCount">渲染: 0 项</span>
        <span id="scrollPos">滚动: 0px</span>
      </div>
    </div>
    <div class="list-wrapper">
      <div class="virtual-list-container" id="container">
        <div class="virtual-list-phantom" id="phantom"></div>
      </div>
    </div>
  </div>

  <script>
    // 配置参数
    const CONFIG = {
      containerHeight: 600,
      fixedItemHeight: 50,
      bufferRatio: 0.5,
      overscan: 3,
      mode: 'dynamic',
      itemCount: 10000
    };

    // DOM 元素
    const container = document.getElementById('container');
    const phantom = document.getElementById('phantom');
    const renderCountEl = document.getElementById('renderCount');
    const scrollPosEl = document.getElementById('scrollPos');
    const modeTag = document.getElementById('modeTag');

    // 数据生成
    function generateData(count, mode) {
      const result = [];
      for (let i = 0; i < count; i++) {
        if (mode === 'fixed') {
          result.push({ id: i, text: `列表项 ${i + 1}`, index: i });
        } else {
          const heightType = i % 5;
          let content = '', tag = '';
          switch (heightType) {
            case 0: content = '简短内容'; tag = '单行'; break;
            case 1: content = '这是一段中等长度的内容,占据两到三行的空间。'; tag = '中等'; break;
            case 2: content = '这是一段较长的内容,用于展示需要更多空间的信息展示场景。'; tag = '较长'; break;
            case 3: content = '这是一段非常长的内容,模拟真实业务场景中的富文本展示。\n\n在实际开发中,列表项可能包含各种复杂内容。'; tag = '超长'; break;
            case 4: content = Array(3).fill('这是随机内容行。').join('\n'); tag = '随机'; break;
          }
          result.push({ id: i, text: `列表项 ${i + 1}`, content, tag, heightType, index: i });
        }
      }
      return result;
    }

    // 虚拟列表类
    class VirtualList {
      constructor(options) {
        this.container = options.container;
        this.phantom = options.phantom;
        this.data = options.data || [];
        this.itemHeight = options.itemHeight;
        this.containerHeight = options.containerHeight;
        this.bufferRatio = options.bufferRatio || 0.5;
        this.overscan = options.overscan || 3;
        this.renderItem = options.renderItem;
        this.estimateHeight = options.estimateHeight;
        this.isFixedHeight = this.itemHeight !== undefined;
        this.heightCache = new Map();
        this.positions = [];
        this.scrollTop = 0;
        this.isScrolling = false;
        this.renderedItems = new Map();
        this.init();
      }

      init() {
        this.updatePositions();
        this.render();
        this.bindEvents();
      }

      updatePositions() {
        this.positions = [];
        let currentTop = 0;
        for (let i = 0; i < this.data.length; i++) {
          let height;
          if (this.isFixedHeight) {
            height = this.itemHeight;
          } else {
            height = this.heightCache.get(i) ?? (this.estimateHeight?.(this.data[i], i) ?? 50);
          }
          this.positions.push({ top: currentTop, height });
          currentTop += height;
        }
        this.totalHeight = currentTop;
        this.phantom.style.height = `${this.totalHeight}px`;
      }

      binarySearchStart(scrollTop) {
        let left = 0, right = this.positions.length - 1, result = 0;
        while (left <= right) {
          const mid = Math.floor((left + right) / 2);
          const midBottom = this.positions[mid].top + this.positions[mid].height;
          if (midBottom <= scrollTop) { left = mid + 1; } 
          else { result = mid; right = mid - 1; }
        }
        return result;
      }

      binarySearchEnd(scrollBottom) {
        let left = 0, right = this.positions.length - 1, result = this.positions.length - 1;
        while (left <= right) {
          const mid = Math.floor((left + right) / 2);
          if (this.positions[mid].top < scrollBottom) { left = mid + 1; } 
          else { result = mid; right = mid - 1; }
        }
        return result;
      }

      getBufferSize() {
        return this.isScrolling ? this.containerHeight * this.bufferRatio * 2 : this.containerHeight * this.bufferRatio;
      }

      getVisibleRange() {
        if (this.positions.length === 0) return { start: 0, end: 0 };
        const bufferSize = this.getBufferSize();
        const scrollTopWithBuffer = Math.max(0, this.scrollTop - bufferSize);
        const scrollBottomWithBuffer = this.scrollTop + this.containerHeight + bufferSize;
        let start = this.binarySearchStart(scrollTopWithBuffer);
        let end = this.binarySearchEnd(scrollBottomWithBuffer);
        start = Math.max(0, start - this.overscan);
        end = Math.min(this.positions.length - 1, end + this.overscan);
        return { start, end };
      }

      render() {
        const { start, end } = this.getVisibleRange();
        this.renderedItems.forEach((el, index) => {
          if (index < start || index > end) { el.remove(); this.renderedItems.delete(index); }
        });
        const pendingMeasure = [];
        for (let i = start; i <= end; i++) {
          if (!this.renderedItems.has(i)) {
            const item = this.data[i];
            const position = this.positions[i];
            const el = document.createElement('div');
            el.className = 'virtual-list-item';
            el.style.cssText = `position: absolute; top: ${position.top}px; left: 0; right: 0;`;
            if (this.isFixedHeight) el.style.height = `${this.itemHeight}px`;
            el.innerHTML = this.renderItem(item, i, this.isFixedHeight);
            this.phantom.appendChild(el);
            this.renderedItems.set(i, el);
            if (!this.isFixedHeight && !this.heightCache.has(i)) pendingMeasure.push({ el, index: i });
          }
        }
        if (pendingMeasure.length > 0) {
          requestAnimationFrame(() => {
            let hasUpdate = false;
            pendingMeasure.forEach(({ el, index }) => {
              if (this.renderedItems.has(index)) {
                this.heightCache.set(index, el.getBoundingClientRect().height);
                hasUpdate = true;
              }
            });
            if (hasUpdate) { this.updatePositions(); this.rerenderVisible(); }
          });
        }
        renderCountEl.textContent = `渲染: ${end - start + 1} 项`;
      }

      rerenderVisible() {
        this.renderedItems.forEach((el, index) => {
          const position = this.positions[index];
          if (position) el.style.top = `${position.top}px`;
        });
      }

      bindEvents() {
        let rafId = null, scrollTimer = null;
        this.container.addEventListener('scroll', (e) => {
          this.scrollTop = e.target.scrollTop;
          scrollPosEl.textContent = `滚动: ${Math.round(this.scrollTop)}px`;
          this.isScrolling = true;
          if (scrollTimer) clearTimeout(scrollTimer);
          scrollTimer = setTimeout(() => { this.isScrolling = false; }, 150);
          if (rafId) cancelAnimationFrame(rafId);
          rafId = requestAnimationFrame(() => this.render());
        });
      }

      setData(data) {
        this.data = data;
        this.heightCache.clear();
        this.renderedItems.forEach(el => el.remove());
        this.renderedItems.clear();
        this.scrollTop = 0;
        this.container.scrollTop = 0;
        this.updatePositions();
        this.render();
      }

      updateConfig(options) {
        if ('itemHeight' in options) {
          this.itemHeight = options.itemHeight;
          this.isFixedHeight = options.itemHeight !== undefined && options.itemHeight !== null;
        }
        if ('estimateHeight' in options) this.estimateHeight = options.estimateHeight;
        if (options.bufferRatio !== undefined) this.bufferRatio = options.bufferRatio;
        if (options.overscan !== undefined) this.overscan = options.overscan;
        this.heightCache.clear();
        this.renderedItems.forEach(el => el.remove());
        this.renderedItems.clear();
        this.scrollTop = 0;
        this.container.scrollTop = 0;
        this.updatePositions();
      }
    }

    // 渲染函数
    function renderItem(item, index, isFixed) {
      if (isFixed) {
        const bgColor = index % 2 === 0 ? '#fff' : '#fafafa';
        return `<div class="fixed-item" style="background: ${bgColor}"><span class="index">#${index + 1}</span><span class="content">${item.text}</span></div>`;
      } else {
        const colors = {
          0: { bg: '#e6f7ff', border: '#1890ff', tag: '#1890ff' },
          1: { bg: '#f6ffed', border: '#52c41a', tag: '#52c41a' },
          2: { bg: '#fff7e6', border: '#fa8c16', tag: '#fa8c16' },
          3: { bg: '#fff1f0', border: '#f5222d', tag: '#f5222d' },
          4: { bg: '#f9f0ff', border: '#722ed1', tag: '#722ed1' },
        };
        const c = colors[item.heightType] || colors[0];
        return `<div class="dynamic-item" style="background: ${c.bg}; border-left: 3px solid ${c.border};"><div class="header"><span>#${index + 1} - ${item.text}</span><span style="background: ${c.tag}; color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 12px;">${item.tag}</span></div><div class="text">${item.content}</div></div>`;
      }
    }

    function estimateHeight(item) {
      return { 0: 45, 1: 85, 2: 155, 3: 285, 4: 100 }[item.heightType] || 60;
    }

    // 初始化
    let data = generateData(CONFIG.itemCount, CONFIG.mode);
    const virtualList = new VirtualList({
      container, phantom, data,
      itemHeight: CONFIG.mode === 'fixed' ? CONFIG.fixedItemHeight : undefined,
      containerHeight: CONFIG.containerHeight,
      bufferRatio: CONFIG.bufferRatio,
      overscan: CONFIG.overscan,
      renderItem,
      estimateHeight: CONFIG.mode === 'dynamic' ? estimateHeight : undefined
    });

    // 事件绑定
    document.getElementById('modeSelect').addEventListener('change', (e) => {
      CONFIG.mode = e.target.value;
      modeTag.textContent = CONFIG.mode === 'fixed' ? '固定高度' : '动态高度';
      modeTag.className = `mode-tag ${CONFIG.mode === 'dynamic' ? 'dynamic' : ''}`;
      virtualList.updateConfig({
        itemHeight: CONFIG.mode === 'fixed' ? CONFIG.fixedItemHeight : undefined,
        estimateHeight: CONFIG.mode === 'dynamic' ? estimateHeight : undefined
      });
      data = generateData(CONFIG.itemCount, CONFIG.mode);
      virtualList.setData(data);
    });

    document.getElementById('countSelect').addEventListener('change', (e) => {
      CONFIG.itemCount = parseInt(e.target.value);
      data = generateData(CONFIG.itemCount, CONFIG.mode);
      virtualList.setData(data);
    });

    document.getElementById('bufferSelect').addEventListener('change', (e) => {
      CONFIG.bufferRatio = parseFloat(e.target.value);
      virtualList.updateConfig({ bufferRatio: CONFIG.bufferRatio });
    });
  </script>
</body>
</html>

React 版本实现

React 版本使用 Hooks 实现,支持 TypeScript 类型,完全参照原生 JavaScript 版本的实现逻辑:

/**
 * 虚拟列表完整实现 - React 版本
 * 支持固定高度和动态高度两种模式
 */

import React, { useRef, useState, useEffect, useCallback, useMemo } from 'react';

// ============================================
// 类型定义
// ============================================

interface VirtualListProps<T> {
  data: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T, index: number) => string | number;
  containerHeight: number;
  itemHeight?: number;  // 固定高度模式:传入此项则使用固定高度
  estimateItemHeight?: (item: T, index: number) => number;  // 动态高度预估函数
  bufferRatio?: number;
  overscan?: number;
}

// ============================================
// 二分查找函数:O(log n) 定位可视区域
// ============================================

function binarySearchStart(
  positions: { top: number; height: number }[],
  scrollTop: number
): number {
  let left = 0;
  let right = positions.length - 1;
  let result = 0;

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    const midBottom = positions[mid].top + positions[mid].height;

    if (midBottom <= scrollTop) {
      left = mid + 1;
    } else {
      result = mid;
      right = mid - 1;
    }
  }

  return result;
}

function binarySearchEnd(
  positions: { top: number; height: number }[],
  scrollBottom: number
): number {
  let left = 0;
  let right = positions.length - 1;
  let result = positions.length - 1;

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);

    if (positions[mid].top < scrollBottom) {
      left = mid + 1;
    } else {
      result = mid;
      right = mid - 1;
    }
  }

  return result;
}

// ============================================
// 核心组件:虚拟列表
// ============================================

function VirtualList<T>({
  data,
  renderItem,
  keyExtractor,
  containerHeight,
  itemHeight,
  estimateItemHeight,
  bufferRatio = 0.5,
  overscan = 3,
}: VirtualListProps<T>) {
  // 判断是否固定高度模式
  const isFixedHeight = itemHeight !== undefined;

  // Refs:使用 ref 存储可变值,避免频繁触发重渲染
  const containerRef = useRef<HTMLDivElement>(null);
  const phantomRef = useRef<HTMLDivElement>(null);
  const itemsRef = useRef<Map<number, HTMLDivElement>>(new Map());
  const heightCacheRef = useRef<Map<number, number>>(new Map());
  const positionsRef = useRef<{ top: number; height: number }[]>([]);
  const scrollTopRef = useRef(0);
  const isScrollingRef = useRef(false);
  const scrollTimerRef = useRef<ReturnType<typeof setTimeout>>();

  // 状态
  const [, forceUpdate] = useState(0);
  const [isScrolling, setIsScrolling] = useState(false);

  // ============================================
  // 核心点1:计算所有项的位置信息
  // ============================================
  const updatePositions = useCallback(() => {
    const positions: { top: number; height: number }[] = [];
    let currentTop = 0;

    for (let i = 0; i < data.length; i++) {
      let height: number;

      if (isFixedHeight) {
        height = itemHeight!;
      } else {
        if (heightCacheRef.current.has(i)) {
          height = heightCacheRef.current.get(i)!;
        } else if (estimateItemHeight) {
          height = estimateItemHeight(data[i], i);
        } else {
          height = 50;
        }
      }

      positions.push({
        top: currentTop,
        height,
      });

      currentTop += height;
    }

    positionsRef.current = positions;

    // 更新 phantom 高度
    if (phantomRef.current) {
      phantomRef.current.style.height = `${currentTop}px`;
    }
  }, [data, isFixedHeight, itemHeight, estimateItemHeight]);

  // ============================================
  // 核心点2:计算缓冲区大小
  // ============================================
  const getBufferSize = useCallback(() => {
    // 快速滚动时增大缓冲区,减少白屏
    return isScrolling
      ? containerHeight * bufferRatio * 2
      : containerHeight * bufferRatio;
  }, [containerHeight, bufferRatio, isScrolling]);

  // ============================================
  // 核心点3:获取可视区域的项目(二分查找)
  // ============================================
  const getVisibleRange = useCallback(() => {
    const positions = positionsRef.current;
    if (positions.length === 0) {
      const defaultEnd = Math.min(20, data.length - 1);
      return { start: 0, end: Math.max(0, defaultEnd) };
    }

    const bufferSize = getBufferSize();
    const scrollTop = scrollTopRef.current;
    const scrollTopWithBuffer = Math.max(0, scrollTop - bufferSize);
    const scrollBottomWithBuffer = scrollTop + containerHeight + bufferSize;

    // 二分查找可视区域
    let start = binarySearchStart(positions, scrollTopWithBuffer);
    let end = binarySearchEnd(positions, scrollBottomWithBuffer);

    // 添加预渲染项
    start = Math.max(0, start - overscan);
    end = Math.min(positions.length - 1, end + overscan);

    return { start, end };
  }, [containerHeight, getBufferSize, overscan, data.length]);

  // ============================================
  // 核心点4:重新渲染可见区域位置
  // ============================================
  const rerenderVisible = useCallback(() => {
    const positions = positionsRef.current;
    itemsRef.current.forEach((el, index) => {
      const position = positions[index];
      if (position) {
        el.style.top = `${position.top}px`;
      }
    });
  }, []);

  // 初始化和更新
  useEffect(() => {
    updatePositions();
    forceUpdate((prev) => prev + 1);
  }, [updatePositions]);

  // 监听 itemHeight 变化(模式切换)
  const prevItemHeightRef = useRef(itemHeight);
  useEffect(() => {
    // 检测模式切换(固定高度 <-> 动态高度)
    if ((prevItemHeightRef.current === undefined) !== (itemHeight === undefined)) {
      // 模式切换时重置所有状态
      // 注意:不要直接操作 DOM,让 React 自己处理 DOM 的更新
      heightCacheRef.current.clear();
      itemsRef.current.clear();
      scrollTopRef.current = 0;
      if (containerRef.current) {
        containerRef.current.scrollTop = 0;
      }
      updatePositions();
      forceUpdate((prev) => prev + 1);
    }
    prevItemHeightRef.current = itemHeight;
  }, [itemHeight, updatePositions]);

  // ============================================
  // 核心点5:滚动事件处理
  // ============================================
  const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
    scrollTopRef.current = e.currentTarget.scrollTop;

    isScrollingRef.current = true;
    setIsScrolling(true);

    if (scrollTimerRef.current) {
      clearTimeout(scrollTimerRef.current);
    }

    // 滚动停止后重置状态
    scrollTimerRef.current = setTimeout(() => {
      isScrollingRef.current = false;
      setIsScrolling(false);
    }, 150);

    forceUpdate((prev) => prev + 1);
  }, []);

  // 清理定时器
  useEffect(() => {
    return () => {
      if (scrollTimerRef.current) {
        clearTimeout(scrollTimerRef.current);
      }
    };
  }, []);

  // 数据变化时重置
  const prevDataLengthRef = useRef(data.length);
  useEffect(() => {
    if (data.length !== prevDataLengthRef.current) {
      heightCacheRef.current.clear();
      itemsRef.current.clear();
      scrollTopRef.current = 0;
      prevDataLengthRef.current = data.length;
      if (containerRef.current) {
        containerRef.current.scrollTop = 0;
      }
      updatePositions();
    }
  }, [data.length, updatePositions]);

  // ============================================
  // 计算可视数据
  // ============================================
  const { start, end } = getVisibleRange();
  const visibleData = useMemo(() => {
    return data.slice(start, end + 1).map((item, i) => ({
      item,
      index: start + i,
    }));
  }, [data, start, end]);

  const totalHeight = useMemo(() => {
    const positions = positionsRef.current;
    if (positions.length === 0) return 0;
    const last = positions[positions.length - 1];
    return last.top + last.height;
  }, [data.length, forceUpdate]);

  // ============================================
  // 动态高度测量:使用 requestAnimationFrame 批量更新
  // ============================================
  useEffect(() => {
    if (isFixedHeight) return;

    const pendingMeasure: { el: HTMLDivElement; index: number }[] = [];

    itemsRef.current.forEach((el, index) => {
      if (!heightCacheRef.current.has(index)) {
        pendingMeasure.push({ el, index });
      }
    });

    if (pendingMeasure.length > 0) {
      requestAnimationFrame(() => {
        let hasUpdate = false;
        pendingMeasure.forEach(({ el, index }) => {
          if (itemsRef.current.has(index)) {
            const actualHeight = el.getBoundingClientRect().height;
            heightCacheRef.current.set(index, actualHeight);
            hasUpdate = true;
          }
        });

        if (hasUpdate) {
          updatePositions();
          rerenderVisible();
        }
      });
    }
  }, [visibleData, isFixedHeight, updatePositions, rerenderVisible]);

  // ============================================
  // 渲染
  // ============================================
  return (
    <div
      ref={containerRef}
      style={{
        height: containerHeight,
        overflow: 'auto',
        position: 'relative',
      }}
      onScroll={handleScroll}
    >
      <div
        ref={phantomRef}
        style={{
          height: totalHeight,
          position: 'relative',
        }}
      >
        {visibleData.map(({ item, index }) => {
          const position = positionsRef.current[index];
          return (
            <div
              key={keyExtractor(item, index)}
              ref={(el) => {
                if (el) {
                  itemsRef.current.set(index, el);
                } else {
                  itemsRef.current.delete(index);
                }
              }}
              style={{
                position: 'absolute',
                top: position?.top ?? 0,
                left: 0,
                right: 0,
                height: isFixedHeight ? itemHeight : 'auto',
              }}
            >
              {renderItem(item, index)}
            </div>
          );
        })}
      </div>
    </div>
  );
}

export default VirtualList;

// ============================================
// 使用示例
// ============================================

/**
 * 示例1:固定高度列表
 */
export const FixedHeightExample = () => {
  const data = Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    text: `列表项 ${i + 1}`,
  }));

  return (
    <VirtualList
      data={data}
      containerHeight={600}
      itemHeight={50}
      keyExtractor={(item) => item.id}
      renderItem={(item) => (
        <div
          style={{
            height: '100%',
            padding: '0 16px',
            display: 'flex',
            alignItems: 'center',
            borderBottom: '1px solid #eee',
          }}
        >
          {item.text}
        </div>
      )}
    />
  );
};

/**
 * 示例2:动态高度列表
 */
export const DynamicHeightExample = () => {
  const data = Array.from({ length: 10000 }, (_, i) => {
    const heightType = i % 5;
    let content = '';
    let tag = '';

    switch (heightType) {
      case 0:
        content = '简短内容';
        tag = '单行';
        break;
      case 1:
        content = '这是一段中等长度的内容,占据两到三行的空间。';
        tag = '中等';
        break;
      case 2:
        content = '这是一段较长的内容,用于展示需要更多空间的信息展示场景。';
        tag = '较长';
        break;
      case 3:
        content = `这是一段非常长的内容,模拟真实业务场景中的富文本展示。

在实际开发中,列表项可能包含:
• 用户详细信息
• 商品卡片
• 订单摘要`;
        tag = '超长';
        break;
      case 4:
        content = Array(3).fill('这是随机内容行。').join('\n');
        tag = '随机';
        break;
    }

    return { id: i, text: `列表项 ${i + 1}`, content, tag, heightType };
  });

  // 预估高度函数:根据内容类型返回预估高度
  const estimateHeight = (item: { heightType: number }) => {
    const heightMap: Record<number, number> = {
      0: 45,   // 单行
      1: 85,   // 中等
      2: 155,  // 较长
      3: 285,  // 超长
      4: 100   // 随机
    };
    return heightMap[item.heightType] || 60;
  };

  const renderItem = (item: { text: string; content: string; tag: string; heightType: number }, index: number) => {
    const colorMap: Record<number, { bg: string; border: string; tag: string }> = {
      0: { bg: '#e6f7ff', border: '#1890ff', tag: '#1890ff' },
      1: { bg: '#f6ffed', border: '#52c41a', tag: '#52c41a' },
      2: { bg: '#fff7e6', border: '#fa8c16', tag: '#fa8c16' },
      3: { bg: '#fff1f0', border: '#f5222d', tag: '#f5222d' },
      4: { bg: '#f9f0ff', border: '#722ed1', tag: '#722ed1' },
    };
    const colors = colorMap[item.heightType] || colorMap[0];

    return (
      <div
        style={{
          padding: '12px 16px',
          backgroundColor: colors.bg,
          borderLeft: `3px solid ${colors.border}`,
          borderBottom: '1px solid #f0f0f0',
        }}
      >
        <div
          style={{
            display: 'flex',
            justifyContent: 'space-between',
            alignItems: 'center',
            marginBottom: 8,
          }}
        >
          <span style={{ fontWeight: 600, color: '#333' }}>#{index + 1} - {item.text}</span>
          <span
            style={{
              backgroundColor: colors.tag,
              color: '#fff',
              padding: '2px 8px',
              borderRadius: 4,
              fontSize: 12,
            }}
          >
            {item.tag}
          </span>
        </div>
        <div style={{ color: '#666', lineHeight: 1.6, whiteSpace: 'pre-line' }}>
          {item.content}
        </div>
      </div>
    );
  };

  return (
    <VirtualList
      data={data}
      containerHeight={600}
      keyExtractor={(item) => item.id}
      estimateItemHeight={estimateHeight}
      renderItem={renderItem}
    />
  );
};

/**
 * 示例3:完整 Demo 组件(支持模式切换)
 */
export const VirtualListDemo = () => {
  const [mode, setMode] = useState<'fixed' | 'dynamic'>('dynamic');
  const [itemCount, setItemCount] = useState(10000);

  const fixedData = useMemo(
    () => Array.from({ length: itemCount }, (_, i) => ({ id: i, text: `列表项 ${i + 1}` })),
    [itemCount]
  );

  const dynamicData = useMemo(() => {
    return Array.from({ length: itemCount }, (_, i) => {
      const heightType = i % 5;
      let content = '';
      let tag = '';

      switch (heightType) {
        case 0:
          content = '简短内容';
          tag = '单行';
          break;
        case 1:
          content = '这是一段中等长度的内容,占据两到三行的空间。';
          tag = '中等';
          break;
        case 2:
          content = '这是一段较长的内容,用于展示需要更多空间的信息展示场景。';
          tag = '较长';
          break;
        case 3:
          content = `这是一段非常长的内容,模拟真实业务场景。\n\n包含多行内容展示。`;
          tag = '超长';
          break;
        case 4:
          content = Array(3).fill('这是随机内容行。').join('\n');
          tag = '随机';
          break;
      }

      return { id: i, text: `列表项 ${i + 1}`, content, tag, heightType };
    });
  }, [itemCount]);

  const estimateHeight = (item: { heightType: number }) => {
    return { 0: 45, 1: 85, 2: 155, 3: 285, 4: 100 }[item.heightType] || 60;
  };

  const renderFixedItem = (item: { text: string }, index: number) => (
    <div
      style={{
        height: '100%',
        padding: '0 16px',
        display: 'flex',
        alignItems: 'center',
        backgroundColor: index % 2 === 0 ? '#fff' : '#f9f9f9',
        borderBottom: '1px solid #eee',
      }}
    >
      <span style={{ width: 80, color: '#999' }}>#{index + 1}</span>
      <span>{item.text}</span>
    </div>
  );

  const renderDynamicItem = (item: { text: string; content: string; tag: string; heightType: number }, index: number) => {
    const colors = {
      0: { bg: '#e6f7ff', border: '#1890ff', tag: '#1890ff' },
      1: { bg: '#f6ffed', border: '#52c41a', tag: '#52c41a' },
      2: { bg: '#fff7e6', border: '#fa8c16', tag: '#fa8c16' },
      3: { bg: '#fff1f0', border: '#f5222d', tag: '#f5222d' },
      4: { bg: '#f9f0ff', border: '#722ed1', tag: '#722ed1' },
    }[item.heightType] || { bg: '#e6f7ff', border: '#1890ff', tag: '#1890ff' };

    return (
      <div
        style={{
          padding: '12px 16px',
          backgroundColor: colors.bg,
          borderLeft: `3px solid ${colors.border}`,
        }}
      >
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
          <span style={{ fontWeight: 600, color: '#1890ff' }}>#{index + 1} - {item.text}</span>
          <span style={{ backgroundColor: colors.tag, color: '#fff', padding: '2px 8px', borderRadius: 4, fontSize: 12 }}>
            {item.tag}
          </span>
        </div>
        <div style={{ color: '#666', lineHeight: 1.6, whiteSpace: 'pre-line' }}>{item.content}</div>
      </div>
    );
  };

  return (
    <div style={{ padding: 20 }}>
      <h2>虚拟列表 Demo</h2>

      <div style={{ marginBottom: 20, display: 'flex', gap: 16, alignItems: 'center' }}>
        <div>
          <label>模式:</label>
          <select value={mode} onChange={(e) => setMode(e.target.value as 'fixed' | 'dynamic')}>
            <option value="fixed">固定高度</option>
            <option value="dynamic">动态高度</option>
          </select>
        </div>

        <div>
          <label>数据量:</label>
          <select value={itemCount} onChange={(e) => setItemCount(Number(e.target.value))}>
            <option value={1000}>1,000 条</option>
            <option value={10000}>10,000 条</option>
            <option value={100000}>100,000 条</option>
          </select>
        </div>
      </div>

      <div style={{ border: '1px solid #ddd', borderRadius: 8, overflow: 'hidden' }}>
        {mode === 'fixed' ? (
          <VirtualList
            data={fixedData}
            containerHeight={600}
            itemHeight={50}
            keyExtractor={(item) => item.id}
            renderItem={renderFixedItem}
          />
        ) : (
          <VirtualList
            data={dynamicData}
            containerHeight={600}
            keyExtractor={(item) => item.id}
            estimateItemHeight={estimateHeight}
            renderItem={renderDynamicItem}
          />
        )}
      </div>
    </div>
  );
};

参考资料

总结

虚拟列表是处理大数据列表渲染的经典方案,核心思想是只渲染可视区域内的元素。本文详细介绍了:

  1. 固定高度模式:实现简单,O(1) 时间复杂度计算位置
  2. 动态高度模式:需要高度缓存和测量,O(n) 时间复杂度计算位置
  3. 性能优化:二分查找、缓冲区、批量更新等策略

掌握虚拟列表的实现原理,不仅能解决实际开发中的性能问题,也能加深对浏览器渲染机制的理解。希望本文对你有所帮助!


如果觉得本文有帮助,欢迎点赞收藏,有问题欢迎在评论区讨论!

❌
❌