Vue3.4 Effect 作用域 API 与 React Server ***ponents 实战解析
随着前端技术的快速发展,Vue 3.4 引入的 Effect 作用域 API 和 React 的 Server ***ponents 为现代前端开发带来了新的可能性。本文将深入探讨这两项技术的原理、实战应用以及它们在不同场景下的最佳实践。
一、Vue 3.4 Effect 作用域 API 详解
1.1 什么是 Effect 作用域
Effect 作用域是 Vue 3.4 引入的一个重要概念,它提供了一种更精细的方式来管理和控制响应式副作用(effects)的生命周期。在深入了解 Effect 作用域之前,我们先回顾一下 Vue 的响应式系统。
// Vue 3 响应式系统基础
import { reactive, effect } from 'vue'
const state = reactive({ count: 0 })
// 创建一个 effect(副作用)
effect(() => {
console.log('Count changed:', state.count)
})
state.count++ // 触发 effect 重新执行
1.2 Effect 作用域的核心概念
Effect 作用域允许我们将相关的 effects 组织在一起,并在需要时批量停止或清理它们。
import { effectScope, ***puted, watch, onScopeDispose } from 'vue'
// 创建一个 effect 作用域
const scope = effectScope()
// 在作用域内创建 effects
scope.run(() => {
const doubled = ***puted(() => counter.value * 2)
watch(doubled, () => console.log(doubled.value))
watchEffect(() => console.log('Count: ', counter.value))
// 注册作用域销毁时的清理函数
onScopeDispose(() => {
console.log('Scope disposed')
})
})
// 停止作用域内的所有 effects
scope.stop()
1.3 实际应用场景
场景一:组件生命周期管理
// My***ponent.vue
import { effectScope, onMounted, onUnmounted } from 'vue'
export default {
setup() {
let scope
onMounted(() => {
// 创建 effect 作用域
scope = effectScope()
scope.run(() => {
// 组件内的所有响应式副作用
watchEffect(() => {
// 监听数据变化
updateChart(data.value)
})
watch(() => props.id, (newId) => {
// 监听 props 变化
fetchData(newId)
})
// 定时器管理
const timer = setInterval(() => {
refreshData()
}, 5000)
// 清理函数
onScopeDispose(() => {
clearInterval(timer)
console.log('***ponent effects cleaned up')
})
})
})
onUnmounted(() => {
// 组件卸载时停止所有 effects
scope?.stop()
})
}
}
场景二:异步操作管理
// useAsyncData.js
import { ref, effectScope, watch, onScopeDispose } from 'vue'
export function useAsyncData(fetchFn, options = {}) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
// 创建 effect 作用域
const scope = effectScope()
let abortController = null
const execute = async (params) => {
// 取消之前的请求
abortController?.abort()
abortController = new AbortController()
loading.value = true
error.value = null
try {
const result = await fetchFn(params, {
signal: abortController.signal
})
data.value = result
} catch (err) {
if (err.name !== 'AbortError') {
error.value = err
}
} finally {
loading.value = false
}
}
// 在作用域内运行
scope.run(() => {
// 监听依赖变化
if (options.deps) {
watch(
() => options.deps(),
(newDeps) => {
if (newDeps && options.immediate !== false) {
execute(newDeps)
}
},
{ immediate: options.immediate !== false }
)
}
// 注册清理函数
onScopeDispose(() => {
abortController?.abort()
})
})
// 提供停止方法
const stop = () => scope.stop()
return {
data,
error,
loading,
execute,
stop
}
}
// 使用示例
export default {
setup() {
const { data, error, loading, stop } = useAsyncData(
async (userId) => {
const response = await fetch(`/api/users/${userId}`)
return response.json()
},
{
deps: () => route.params.userId,
immediate: true
}
)
// 组件卸载时清理
onUnmounted(() => {
stop()
})
return { data, error, loading }
}
}
1.4 最佳实践与性能优化
// 最佳实践:Effect 作用域组合
import { effectScope, ***puted } from 'vue'
export function createDataScope() {
const scope = effectScope()
return scope.run(() => {
const data = ref({})
const loading = ref(false)
const error = ref(null)
// 计算属性
const processedData = ***puted(() => {
return Object.keys(data.value).reduce((a***, key) => {
a***[key] = data.value[key] * 2
return a***
}, {})
})
// 方法
const fetchData = async () => {
loading.value = true
try {
const response = await fetch('/api/data')
data.value = await response.json()
} catch (err) {
error.value = err
} finally {
loading.value = false
}
}
return {
data,
loading,
error,
processedData,
fetchData,
stop: () => scope.stop()
}
})
}
// 性能优化:批量更新
import { effectScope, nextTick } from 'vue'
export function useBatchUpdate() {
const scope = effectScope()
const pendingUpdates = new Set()
scope.run(() => {
let updateScheduled = false
const scheduleUpdate = (fn) => {
pendingUpdates.add(fn)
if (!updateScheduled) {
updateScheduled = true
nextTick(() => {
pendingUpdates.forEach(update => update())
pendingUpdates.clear()
updateScheduled = false
})
}
}
return { scheduleUpdate }
})
return {
scheduleUpdate: scope.run(() => scheduleUpdate),
flush: () => {
pendingUpdates.forEach(update => update())
pendingUpdates.clear()
},
stop: () => scope.stop()
}
}
二、React Server ***ponents 实战
2.1 React Server ***ponents 简介
React Server ***ponents(RSC)是 React 18 引入的一项革命性特性,它允许组件在服务器上渲染,将渲染结果以特殊格式发送到客户端。这与传统的 SSR(服务端渲染)有本质区别。
2.2 环境搭建
首先,我们需要搭建支持 React Server ***ponents 的开发环境。这里使用 Next.js 13+ 作为示例。
# 创建 Next.js 项目(支持 RSC)
npx create-next-app@latest my-rsc-app --typescript --app
# 进入项目目录
cd my-rsc-app
# 安装依赖
npm install
# 启动开发服务器
npm run dev
项目结构:
my-rsc-app/
├── app/
│ ├── layout.tsx # 根布局(服务端组件)
│ ├── page.tsx # 首页(服务端组件)
│ ├── ***ponents/
│ │ ├── Server***ponent.tsx # 服务端组件
│ │ ├── Client***ponent.tsx # 客户端组件
│ │ └── Shared***ponent.tsx # 共享组件
│ └── lib/
│ ├── data.ts # 数据获取函数
│ └── utils.ts # 工具函数
├── package.json
└── tsconfig.json
2.3 Server ***ponents 基础
服务端组件(默认)
// app/***ponents/Server***ponent.tsx
import { Suspense } from 'react'
// 这是一个服务端组件
async function getData() {
// 直接访问数据库或外部 API
const res = await fetch('https://api.example.***/data', {
// 可以包含 API keys 等敏感信息
headers: {
'Authorization': `Bearer ${process.env.API_SECRET_KEY}`
}
})
if (!res.ok) {
throw new Error('Failed to fetch data')
}
return res.json()
}
export default async function Server***ponent() {
// 服务端直接获取数据
const data = await getData()
return (
<div>
<h2>Server ***ponent Data</h2>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
)
}
客户端组件
// app/***ponents/Client***ponent.tsx
'use client'
import { useState, useEffect } from 'react'
export default function Client***ponent() {
const [count, setCount] = useState(0)
const [isClient, setIsClient] = useState(false)
// 确保只在客户端执行
useEffect(() => {
setIsClient(true)
}, [])
return (
<div>
<h2>Client ***ponent</h2>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
{isClient && (
<p>Client-side only: {window.navigator.userAgent}</p>
)}
</div>
)
}
共享组件
// app/***ponents/Shared***ponent.tsx
import { Server***ponent } from './Server***ponent'
import Client***ponent from './Client***ponent'
// 这个组件可以在服务端和客户端运行
export default function Shared***ponent({ initialData }: { initialData: any }) {
return (
<div>
<h1>Shared ***ponent</h1>
<Server***ponent />
<Client***ponent />
</div>
)
}
2.4 数据获取策略
服务端数据获取
// app/lib/data.ts
export interface User {
id: number
name: string
email: string
posts: Post[]
}
export interface Post {
id: number
title: string
content: string
authorId: number
}
// 模拟数据库
const users: User[] = [
{
id: 1,
name: 'John Doe',
email: 'john@example.***',
posts: [
{ id: 1, title: 'First Post', content: 'Hello World', authorId: 1 },
{ id: 2, title: 'Second Post', content: 'React Server ***ponents', authorId: 1 }
]
}
]
export async function getUsers(): Promise<User[]> {
// 模拟异步操作
await new Promise(resolve => setTimeout(resolve, 1000))
return users
}
export async function getUserById(id: number): Promise<User | undefined> {
await new Promise(resolve => setTimeout(resolve, 500))
return users.find(user => user.id === id)
}
export async function getPosts(): Promise<Post[]> {
await new Promise(resolve => setTimeout(resolve, 800))
return users.flatMap(user => user.posts)
}
服务端组件中使用数据
// app/users/page.tsx
import { getUsers, getUserById } from '../lib/data'
import { Suspense } from 'react'
// 加载组件
function LoadingSpinner() {
return <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
}
// 用户列表组件
async function UsersList() {
const users = await getUsers()
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{users.map(user => (
<UserCard key={user.id} user={user} />
))}
</div>
)
}
// 用户卡片组件
async function UserCard({ user }: { user: any }) {
return (
<div className="border rounded-lg p-4 shadow-sm">
<h3 className="font-semibold text-lg">{user.name}</h3>
<p className="text-gray-600">{user.email}</p>
<p className="text-sm text-gray-500">
{user.posts.length} posts
</p>
</div>
)
}
// 主页面组件
export default async function UsersPage() {
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">Users</h1>
{/* 使用 Suspense 处理异步加载 */}
<Suspense fallback={<LoadingSpinner />}>
<UsersList />
</Suspense>
</div>
)
}
流式渲染
// app/streaming/page.tsx
import { Suspense } from 'react'
// 慢速组件
async function Slow***ponent() {
await new Promise(resolve => setTimeout(resolve, 3000))
return <div>Loaded after 3 seconds</div>
}
// 快速组件
async function Fast***ponent() {
await new Promise(resolve => setTimeout(resolve, 1000))
return <div>Loaded after 1 second</div>
}
export default function StreamingPage() {
return (
<div>
<h1>Streaming Example</h1>
{/* 快速组件立即显示 */}
<Suspense fallback={<p>Loading fast ***ponent...</p>}>
<Fast***ponent />
</Suspense>
{/* 慢速组件独立加载 */}
<Suspense fallback={<p>Loading slow ***ponent...</p>}>
<Slow***ponent />
</Suspense>
</div>
)
}
2.5 客户端与服务端交互
服务端组件传递数据给客户端组件
// app/***ponents/InteractiveChart.tsx
'use client'
import { useState, useEffect } from 'react'
import { Line } from 'react-chartjs-2'
interface ChartData {
labels: string[]
datasets: {
label: string
data: number[]
borderColor: string
backgroundColor: string
}[]
}
export default function InteractiveChart({
initialData,
title
}: {
initialData: ChartData
title: string
}) {
const [data, setData] = useState<ChartData>(initialData)
const [filter, setFilter] = useState('all')
// 客户端交互逻辑
const handleFilterChange = (newFilter: string) => {
setFilter(newFilter)
// 根据筛选条件更新数据
const filteredData = filterData(initialData, newFilter)
setData(filteredData)
}
return (
<div className="p-4 border rounded-lg">
<h3 className="text-xl font-semibold mb-4">{title}</h3>
<div className="mb-4">
<button
onClick={() => handleFilterChange('all')}
className={`px-4 py-2 mr-2 rounded ${
filter === 'all' ? 'bg-blue-500 text-white' : 'bg-gray-200'
}`}
>
All Data
</button>
<button
onClick={() => handleFilterChange('recent')}
className={`px-4 py-2 mr-2 rounded ${
filter === 'recent' ? 'bg-blue-500 text-white' : 'bg-gray-200'
}`}
>
Recent
</button>
</div>
<div style={{ height: '300px' }}>
<Line data={data} />
</div>
</div>
)
}
服务端组件使用客户端组件
// app/dashboard/page.tsx
import InteractiveChart from '../***ponents/InteractiveChart'
import { getChartData } from '../lib/data'
export default async function DashboardPage() {
// 服务端获取数据
const chartData = await getChartData()
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">Dashboard</h1>
{/* 将服务端数据传递给客户端组件 */}
<InteractiveChart
initialData={chartData}
title="Sales Analytics"
/>
</div>
)
}
2.6 性能优化策略
1. 组件级缓存
// app/lib/cache.ts
import { unstable_cache } from 'next/cache'
// 创建缓存函数
export const getCachedData = unstable_cache(
async (key: string) => {
// 模拟数据库查询
const data = await fetch(`https://api.example.***/data/${key}`)
return data.json()
},
['data-cache'], // 缓存 key
{
revalidate: 3600, // 1小时重新验证
tags: ['data'], // 缓存标签,用于批量失效
}
)
// 在组件中使用
export default async function Cached***ponent({ id }: { id: string }) {
const data = await getCachedData(id)
return (
<div>
<h2>Cached Data</h2>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
)
}
2. 流式 SSR 优化
// app/products/page.tsx
import { Suspense } from 'react'
// 产品列表(可以流式传输)
async function ProductList() {
const products = await getProducts()
return (
<div className="grid grid-cols-3 gap-4">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
// 推荐商品(可能需要更长时间)
async function Re***mendations() {
const re***mendations = await getRe***mendations()
return (
<div className="mt-8">
<h2 className="text-2xl font-bold mb-4">Re***mended for You</h2>
<div className="grid grid-cols-4 gap-4">
{re***mendations.map(item => (
<div key={item.id} className="border p-4 rounded">
<h3>{item.name}</h3>
<p>${item.price}</p>
</div>
))}
</div>
</div>
)
}
export default function ProductsPage() {
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">Products</h1>
{/* 主要产品列表立即开始加载 */}
<Suspense fallback={<div>Loading products...</div>}>
<ProductList />
</Suspense>
{/* 推荐部分可以独立加载 */}
<Suspense fallback={<div>Loading re***mendations...</div>}>
<Re***mendations />
</Suspense>
</div>
)
}
3. 选择性水合
// app/***ponents/Heavy***ponent.tsx
'use client'
import { startTransition, useDeferredValue } from 'react'
export default function Heavy***ponent({ data }: { data: any[] }) {
const [filter, setFilter] = useState('')
const deferredFilter = useDeferredValue(filter)
// 昂贵的计算
const filteredData = useMemo(() => {
return data.filter(item =>
item.name.toLowerCase().includes(deferredFilter.toLowerCase())
)
}, [data, deferredFilter])
return (
<div>
<input
type="text"
value={filter}
onChange={(e) => startTransition(() => setFilter(e.target.value))}
placeholder="Filter items..."
className="border p-2 mb-4 w-full"
/>
{/* 使用 deferred value 避免阻塞渲染 */}
<div className="grid gap-2">
{filteredData.map(item => (
<div key={item.id} className="border p-2 rounded">
{item.name}
</div>
))}
</div>
</div>
)
}
三、Vue Effect 作用域 vs React Server ***ponents
3.1 核心概念对比
| 特性 | Vue Effect 作用域 | React Server ***ponents |
|---|---|---|
| 主要目的 | 管理响应式副作用生命周期 | 服务端渲染优化 |
| 运行环境 | 客户端 | 服务端 + 客户端 |
| 数据获取 | 客户端异步 | 服务端直接获取 |
| 性能优势 | 精确控制 effect 生命周期 | 减少 JS Bundle 大小 |
| 使用场景 | 复杂状态管理 | 内容为主的页面 |
3.2 适用场景分析
Vue Effect 作用域适用场景
-
复杂组件状态管理
// 大型表单组件 function use***plexForm() { const scope = effectScope() return scope.run(() => { const formData = reactive({}) const validationErrors = ref({}) const isSubmitting = ref(false) // 多个相关的 watchers watch(() => formData.email, validateEmail) watch(() => formData.password, validatePassword) watchEffect(() => { // 自动保存逻辑 autoSave(formData) }) return { formData, validationErrors, isSubmitting, submit: () => { /* 提交逻辑 */ }, stop: () => scope.stop() } }) } -
异步操作管理
// 多个相关的异步操作 function useAsyncOperations() { const scope = effectScope() return scope.run(() => { const operations = reactive({ upload: { loading: false, error: null }, download: { loading: false, error: null }, sync: { loading: false, error: null } }) // 批量管理所有异步操作 const cancelAll = () => { scope.stop() } return { operations, cancelAll } }) }
React Server ***ponents 适用场景
-
内容密集型应用
// 博客系统 export default async function BlogPost({ params }: { params: { slug: string } }) { // 服务端直接获取文章内容 const post = await getPostBySlug(params.slug) const relatedPosts = await getRelatedPosts(post.id) return ( <article> <h1>{post.title}</h1> <div>{post.content}</div> <aside> <h2>Related Posts</h2> {relatedPosts.map(post => ( <RelatedPost key={post.id} post={post} /> ))} </aside> </article> ) } -
需要访问数据库或内部 API 的应用
// 管理后台 export default async function AdminDashboard() { // 服务端可以直接访问数据库 const stats = await getDashboardStats() const recentUsers = await getRecentUsers(10) const systemHealth = await checkSystemHealth() return ( <DashboardLayout> <StatsGrid stats={stats} /> <UserTable users={recentUsers} /> <SystemStatus health={systemHealth} /> </DashboardLayout> ) }
3.3 混合使用策略
在实际项目中,我们可以结合使用两种技术:
// 结合 Vue Effect 作用域和 SSR
// app.vue
<template>
<div>
<ServerRenderedContent :initial-data="serverData" />
</div>
</template>
<script setup>
import { effectScope } from 'vue'
// 服务端渲染的数据
const serverData = ref(__INITIAL_DATA__)
// 使用 Effect 作用域管理客户端状态
const scope = effectScope()
scope.run(() => {
const clientState = reactive({
isInteractive: false,
userPreferences: {}
})
// 客户端特定的响应式逻辑
watchEffect(() => {
if (clientState.isInteractive) {
// 启用交互功能
enableInteractiveFeatures()
}
})
})
onUnmounted(() => {
scope.stop()
})
</script>
四、实战项目:构建一个现代博客系统
4.1 项目架构
我们将构建一个结合两种技术的现代博客系统:
modern-blog/
├── app/ # Next.js App Router
│ ├── layout.tsx # 根布局
│ ├── page.tsx # 首页
│ ├── blog/
│ │ ├── [slug]/
│ │ │ └── page.tsx # 博客文章页面(RSC)
│ │ └── page.tsx # 博客列表页面(RSC)
│ └── admin/
│ ├── page.tsx # 管理后台(RSC)
│ └── edit/
│ └── [id]/
│ └── page.tsx # 编辑页面(RSC + Client ***ponents)
├── ***ponents/
│ ├── server/ # 服务端组件
│ │ ├── BlogPost.tsx
│ │ ├── BlogList.tsx
│ │ └── AdminPanel.tsx
│ ├── client/ # 客户端组件
│ │ ├── ***mentSection.tsx
│ │ ├── LikeButton.tsx
│ │ └── RichEditor.tsx
│ └── shared/ # 共享组件
│ ├── Button.tsx
│ └── Card.tsx
├── lib/
│ ├── data.ts # 数据层
│ ├── auth.ts # 认证
│ └── utils.ts # 工具函数
└── vue-***ponents/ # Vue 组件(用于管理后台)
├── admin/
│ ├── PostEditor.vue
│ ├── MediaManager.vue
│ └── Analytics.vue
└── ***posables/ # Vue ***posables
├── usePostEditor.ts
├── useMediaUpload.ts
└── useAnalytics.ts
4.2 核心功能实现
服务端组件:博客文章页面
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { Suspense } from 'react'
import BlogPost from '@/***ponents/server/BlogPost'
import ***mentSection from '@/***ponents/client/***mentSection'
import LikeButton from '@/***ponents/client/LikeButton'
import { getPostBySlug, incrementViewCount } from '@/lib/data'
interface PageProps {
params: { slug: string }
searchParams: { [key: string]: string | string[] | undefined }
}
export default async function BlogPostPage({ params }: PageProps) {
// 并行获取数据
const [post, relatedPosts] = await Promise.all([
getPostBySlug(params.slug),
getRelatedPosts(params.slug)
])
if (!post) {
notFound()
}
// 增加浏览量(服务端操作)
await incrementViewCount(post.id)
return (
<article className="max-w-4xl mx-auto p-6">
{/* 文章头部 */}
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center text-gray-600 mb-4">
<time dateTime={post.createdAt}>
{new Date(post.createdAt).toLocaleDateString()}
</time>
<span className="mx-2">•</span>
<span>{post.readingTime} min read</span>
<span className="mx-2">•</span>
<span>{post.views} views</span>
</div>
<div className="flex gap-2">
{post.tags.map(tag => (
<span key={tag} className="bg-blue-100 text-blue-800 px-2 py-1 rounded text-sm">
{tag}
</span>
))}
</div>
</header>
{/* 文章内容 */}
<div className="prose prose-lg max-w-none mb-8">
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</div>
{/* 互动区域 */}
<div className="border-t pt-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-semibold">互动</h2>
<div className="flex gap-4">
{/* 客户端点赞组件 */}
<LikeButton postId={post.id} initialLikes={post.likes} />
<ShareButton post={post} />
</div>
</div>
{/* 评论区域 */}
<Suspense fallback={<div>Loading ***ments...</div>}>
<***mentSection postId={post.id} />
</Suspense>
</div>
{/* 相关文章 */}
{relatedPosts.length > 0 && (
<section className="mt-12 border-t pt-8">
<h2 className="text-2xl font-semibold mb-6">相关文章</h2>
<div className="grid gap-6 md:grid-cols-2">
{relatedPosts.map(relatedPost => (
<RelatedPostCard key={relatedPost.id} post={relatedPost} />
))}
</div>
</section>
)}
</article>
)
}
Vue ***posables:文章编辑器
// vue-***ponents/***posables/usePostEditor.ts
import { effectScope, ref, reactive, watch, ***puted } from 'vue'
export interface PostEditorState {
title: string
content: string
excerpt: string
tags: string[]
coverImage: string | null
published: boolean
}
export function usePostEditor(initialPost?: PostEditorState) {
const scope = effectScope()
return scope.run(() => {
const state = reactive<PostEditorState>({
title: initialPost?.title || '',
content: initialPost?.content || '',
excerpt: initialPost?.excerpt || '',
tags: initialPost?.tags || [],
coverImage: initialPost?.coverImage || null,
published: initialPost?.published || false
})
const isSaving = ref(false)
const lastSaved = ref<Date | null>(null)
const wordCount = ***puted(() => {
return state.content.split(/\s+/).filter(word => word.length > 0).length
})
const readingTime = ***puted(() => {
const wordsPerMinute = 200
return Math.ceil(wordCount.value / wordsPerMinute)
})
const isValid = ***puted(() => {
return state.title.trim().length > 0 &&
state.content.trim().length > 0 &&
state.excerpt.trim().length > 0
})
// 自动保存逻辑
let autoSaveTimer: NodeJS.Timeout | null = null
const scheduleAutoSave = () => {
if (autoSaveTimer) {
clearTimeout(autoSaveTimer)
}
autoSaveTimer = setTimeout(() => {
if (isValid.value) {
autoSave()
}
}, 30000) // 30秒自动保存
}
// 监听内容变化
watch(
() => [state.title, state.content, state.excerpt, state.tags],
() => {
scheduleAutoSave()
},
{ deep: true }
)
// 自动保存函数
const autoSave = async () => {
if (isSaving.value) return
isSaving.value = true
try {
// 调用 API 保存草稿
await saveDraft(state)
lastSaved.value = new Date()
} catch (error) {
console.error('Auto-save failed:', error)
} finally {
isSaving.value = false
}
}
// 手动保存
const save = async () => {
if (!isValid.value) {
throw new Error('Please fill in all required fields')
}
isSaving.value = true
try {
await savePost(state)
lastSaved.value = new Date()
} catch (error) {
console.error('Save failed:', error)
throw error
} finally {
isSaving.value = false
}
}
// 发布
const publish = async () => {
if (!isValid.value) {
throw new Error('Please fill in all required fields')
}
isSaving.value = true
try {
await publishPost({ ...state, published: true })
state.published = true
} catch (error) {
console.error('Publish failed:', error)
throw error
} finally {
isSaving.value = false
}
}
// 添加标签
const addTag = (tag: string) => {
if (tag.trim() && !state.tags.includes(tag.trim())) {
state.tags.push(tag.trim())
}
}
// 移除标签
const removeTag = (tag: string) => {
const index = state.tags.indexOf(tag)
if (index > -1) {
state.tags.splice(index, 1)
}
}
// 上传封面图片
const uploadCoverImage = async (file: File) => {
try {
const url = await uploadImage(file)
state.coverImage = url
} catch (error) {
console.error('Image upload failed:', error)
throw error
}
}
// 清理函数
const dispose = () => {
if (autoSaveTimer) {
clearTimeout(autoSaveTimer)
}
scope.stop()
}
return {
state,
isSaving,
lastSaved,
wordCount,
readingTime,
isValid,
save,
publish,
addTag,
removeTag,
uploadCoverImage,
dispose
}
})
}
Vue 组件:媒体管理器
<!-- vue-***ponents/admin/MediaManager.vue -->
<template>
<div class="media-manager">
<div class="media-header">
<h3>Media Library</h3>
<button @click="showUploadDialog = true" class="upload-btn">
Upload New
</button>
</div>
<div class="media-grid" v-if="mediaItems.length > 0">
<div
v-for="item in mediaItems"
:key="item.id"
class="media-item"
:class="{ selected: selectedItems.includes(item.id) }"
@click="toggleSelection(item.id)"
>
<img :src="item.url" :alt="item.alt" />
<div class="media-info">
<p class="media-title">{{ item.title }}</p>
<p class="media-size">{{ formatFileSize(item.size) }}</p>
</div>
<button
@click.stop="deleteMedia(item.id)"
class="delete-btn"
>
Delete
</button>
</div>
</div>
<div v-else class="empty-state">
<p>No media items found</p>
</div>
<!-- 上传对话框 -->
<UploadDialog
v-if="showUploadDialog"
@close="showUploadDialog = false"
@uploaded="handleUploaded"
/>
</div>
</template>
<script setup lang="ts">
import { ref, ***puted, onMounted, effectScope, watch } from 'vue'
import { useMediaUpload } from '../***posables/useMediaUpload'
interface MediaItem {
id: string
url: string
title: string
alt: string
size: number
type: string
uploadedAt: Date
}
// Props
const props = defineProps<{
maxSelection?: number
a***ept?: string[]
}>()
// Emits
const emit = defineEmits<{
select: [items: MediaItem[]]
upload: [item: MediaItem]
}>()
// 使用 effect 作用域管理副作用
const scope = effectScope()
const {
mediaItems,
loading,
error,
fetchMedia,
deleteMedia,
uploadMedia
} = scope.run(() => {
const items = ref<MediaItem[]>([])
const isLoading = ref(false)
const errorMessage = ref<string | null>(null)
// 获取媒体列表
const fetchMedia = async () => {
isLoading.value = true
errorMessage.value = null
try {
const response = await fetch('/api/media')
if (!response.ok) throw new Error('Failed to fetch media')
const data = await response.json()
items.value = data.map((item: any) => ({
...item,
uploadedAt: new Date(item.uploadedAt)
}))
} catch (err) {
errorMessage.value = err instanceof Error ? err.message : 'Unknown error'
} finally {
isLoading.value = false
}
}
// 删除媒体
const deleteMedia = async (id: string) => {
try {
const response = await fetch(`/api/media/${id}`, {
method: 'DELETE'
})
if (!response.ok) throw new Error('Failed to delete media')
// 本地删除
const index = items.value.findIndex(item => item.id === id)
if (index > -1) {
items.value.splice(index, 1)
}
} catch (err) {
errorMessage.value = err instanceof Error ? err.message : 'Delete failed'
}
}
// 上传媒体
const uploadMedia = async (file: File) => {
const formData = new FormData()
formData.append('file', file)
try {
const response = await fetch('/api/media/upload', {
method: 'POST',
body: formData
})
if (!response.ok) throw new Error('Upload failed')
const newItem = await response.json()
items.value.unshift({
...newItem,
uploadedAt: new Date(newItem.uploadedAt)
})
return newItem
} catch (err) {
errorMessage.value = err instanceof Error ? err.message : 'Upload failed'
throw err
}
}
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
return {
mediaItems: items,
loading: isLoading,
error: errorMessage,
fetchMedia,
deleteMedia,
uploadMedia,
formatFileSize
}
})
// 组件状态
const selectedItems = ref<string[]>([])
const showUploadDialog = ref(false)
// 计算属性
const mediaItems = ***puted(() => items.value)
// 方法
const toggleSelection = (id: string) => {
const index = selectedItems.value.indexOf(id)
if (index > -1) {
selectedItems.value.splice(index, 1)
} else if (selectedItems.value.length < (props.maxSelection || Infinity)) {
selectedItems.value.push(id)
}
}
const handleUploaded = (item: MediaItem) => {
showUploadDialog.value = false
emit('upload', item)
}
// 监听选择变化
watch(selectedItems, (newSelection) => {
const selectedMedia = mediaItems.value.filter(item =>
newSelection.includes(item.id)
)
emit('select', selectedMedia)
})
// 生命周期
onMounted(() => {
fetchMedia()
})
onUnmounted(() => {
scope.stop()
})
</script>
<style scoped>
.media-manager {
@apply p-4;
}
.media-header {
@apply flex justify-between items-center mb-4;
}
.media-grid {
@apply grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4;
}
.media-item {
@apply border rounded-lg p-2 cursor-pointer transition-all;
}
.media-item.selected {
@apply ring-2 ring-blue-500;
}
.media-item img {
@apply w-full h-32 object-cover rounded;
}
.media-info {
@apply mt-2;
}
.media-title {
@apply font-medium text-sm truncate;
}
.media-size {
@apply text-xs text-gray-500;
}
.delete-btn {
@apply mt-2 w-full bg-red-500 text-white py-1 px-2 rounded text-sm;\}
.delete-btn:hover {
@apply bg-red-600;
}
.empty-state {
@apply text-center py-8 text-gray-500;
}
</style>
4.3 踩坑经验与解决方案
1. React Server ***ponents 中的常见问题
问题:在服务端组件中使用浏览器 API
// ❌ 错误:在服务端组件中使用 window
export default async function Server***ponent() {
const width = window.innerWidth // 错误!服务端没有 window
return <div>Window width: {width}</div>
}
// ✅ 正确:将客户端逻辑提取到客户端组件
'use client'
export default function Client***ponent() {
const [width, setWidth] = useState(window.innerWidth)
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth)
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
return <div>Window width: {width}</div>
}
问题:服务端组件中的状态管理
// ❌ 错误:在服务端组件中使用 useState
export default async function Server***ponent() {
const [count, setCount] = useState(0) // 错误!服务端组件不能使用 hooks
return <button onClick={() => setCount(count + 1)}>{count}</button>
}
// ✅ 正确:使用服务端组件获取数据,客户端组件处理交互
// 服务端组件
export default async function Server***ponent() {
const data = await getData()
return <Interactive***ponent initialData={data} />
}
// 客户端组件
'use client'
export default function Interactive***ponent({ initialData }) {
const [count, setCount] = useState(0)
return (
<div>
<p>Data: {initialData}</p>
<button onClick={() => setCount(count + 1)}>{count}</button>
</div>
)
}
2. Vue Effect 作用域的常见问题
问题:Effect 作用域的内存泄漏
// ❌ 错误:忘记清理 effect 作用域
function useAsyncData() {
const scope = effectScope()
const data = ref(null)
scope.run(() => {
watchEffect(async () => {
const response = await fetch('/api/data')
data.value = await response.json()
})
})
return { data } // 没有返回 stop 方法,可能导致内存泄漏
}
// ✅ 正确:提供清理机制
function useAsyncData() {
const scope = effectScope()
const data = ref(null)
scope.run(() => {
watchEffect(async () => {
const response = await fetch('/api/data')
data.value = await response.json()
})
})
// 提供清理函数
onScopeDispose(() => {
scope.stop()
})
return { data, stop: () => scope.stop() }
}
问题:嵌套作用域的管理
// ❌ 错误:嵌套作用域管理不当
function useNestedScopes() {
const parentScope = effectScope()
parentScope.run(() => {
const parentData = ref('parent')
const childScope = effectScope()
childScope.run(() => {
const childData = ref('child')
watchEffect(() => {
console.log(parentData.value, childData.value)
})
})
// 忘记停止子作用域
})
return { stop: () => parentScope.stop() } // 子作用域可能没有被正确清理
}
// ✅ 正确:使用 onScopeDispose 管理嵌套作用域
function useNestedScopes() {
const parentScope = effectScope()
parentScope.run(() => {
const parentData = ref('parent')
const childScope = effectScope(true) // 分离的作用域
childScope.run(() => {
const childData = ref('child')
watchEffect(() => {
console.log(parentData.value, childData.value)
})
// 注册清理函数
onScopeDispose(() => {
childScope.stop()
})
})
// 父作用域清理时也清理子作用域
onScopeDispose(() => {
childScope.stop()
})
})
return { stop: () => parentScope.stop() }
}
3. 性能优化最佳实践
React Server ***ponents 性能优化
// 使用缓存优化数据获取
import { unstable_cache } from 'next/cache'
const getCachedPost = unstable_cache(
async (slug: string) => {
return await getPostBySlug(slug)
},
['post-cache'],
{
revalidate: 3600, // 1小时缓存
tags: ['posts']
}
)
// 在组件中使用缓存数据
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getCachedPost(params.slug) // 使用缓存版本
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
)
}
Vue Effect 作用域性能优化
// 批量更新优化
function useOptimizedList() {
const scope = effectScope()
return scope.run(() => {
const items = ref([])
const selectedItems = ref(new Set())
const filter = ref('')
// 使用 ***puted 缓存计算结果
const filteredItems = ***puted(() => {
if (!filter.value) return items.value
return items.value.filter(item =>
item.name.toLowerCase().includes(filter.value.toLowerCase())
)
})
// 批量选择优化
const selectAll = () => {
// 避免逐个触发更新
const newSelection = new Set(filteredItems.value.map(item => item.id))
selectedItems.value = newSelection
}
// 防抖搜索
const debouncedFilter = ref('')
let filterTimeout: NodeJS.Timeout | null = null
watch(filter, (newFilter) => {
if (filterTimeout) clearTimeout(filterTimeout)
filterTimeout = setTimeout(() => {
debouncedFilter.value = newFilter
}, 300)
})
return {
items,
selectedItems,
filter,
filteredItems,
selectAll,
stop: () => scope.stop()
}
})
}
五、总结与展望
Vue 3.4 的 Effect 作用域 API 和 React Server ***ponents 代表了现代前端框架发展的两个重要方向:更精细的副作用管理和更智能的服务端渲染。
技术对比总结
| 方面 | Vue Effect 作用域 | React Server ***ponents |
|---|---|---|
| 核心优势 | 精确的副作用生命周期管理 | 零 JS Bundle 的服务端组件 |
| 适用场景 | 复杂状态管理、异步操作 | 内容密集型应用、SEO 优化 |
| 学习曲线 | 相对较低,概念清晰 | 较高,需要理解新的心智模型 |
| 生态系统 | Vue 生态,工具链成熟 | Next.js 生态,快速发展中 |
实际应用建议
-
Vue Effect 作用域:适用于需要精确控制响应式副作用生命周期的场景,特别是大型表单、复杂状态管理和异步操作协调。
-
React Server ***ponents:适用于内容为主的网站、博客、电商等需要良好 SEO 和快速首屏加载的应用。
-
混合使用:在大型项目中,可以根据不同模块的特点选择合适的技术,甚至可以在同一个项目中结合使用两种技术。
未来发展
随着前端技术的不断演进,我们可以期待:
- 更智能的编译时优化:框架会在编译时做更多优化,减少运行时开销
- 更好的开发体验:调试工具和开发体验会不断改进
- 更丰富的生态系统:相关工具和库会越来越完善
作为前端开发者,保持对新技术的学习和实践,理解其背后的设计思想和适用场景,才能在技术选型时做出更明智的决策。
欢迎在评论区分享你在使用 Vue Effect 作用域和 React Server ***ponents 时的经验和心得!