原文链接:vercel.com/blog/how-we...
作者:Fernando Rojo Head of Mobile
我们近期发布了iOS版v0,这是Vercel的首款移动应用。作为专注于web技术的公司,开发原生应用对我们而言是全新领域。
我们的目标是打造一款配得上苹果设计奖的应用,为此我们对技术栈的选择保持开放态度。在公开测试版发布前,我们进行了数十次产品迭代,尝试了截然不同的技术栈和UI模式。
我们从那些完美契合iPhone语言的应用中汲取灵感,例如Apple Notes和iMessage。v0必须在你的主屏幕上赢得一席之地,与这些杰作比肩。
经过数周的探索,我们最终选择React Native与Expo实现这一目标。我们对成果感到满意,用户同样如此。事实上,大量开发者询问应用为何如此原生的消息涌入,促使我们撰写了技术解析文章,详述实现过程。
我们如何打造v0版聊天体验、
当你离开电脑时,可能会突然灵光乍现,想要立即付诸行动。我们的目标是让你无需切换场景,就能将灵感转化为具体成果。iOS版v0是新一代笔记应用,让你的创意在后台悄然成形。
我们并非要打造功能与网站完全一致的移动集成开发环境,而是致力于构建简单愉悦的移动创作体验,让AI成为您随时随地实现创意的得力助手。而聊天功能正是这种体验的核心所在。
要打造出色的聊天体验,我们设定以下要求:
- 新消息以平滑动画效果呈现
- 用户新消息自动滚动至屏幕顶部
- 助手消息以渐隐效果分批流式显示
- 编辑器采用液态玻璃材质,悬浮于可滚动内容之上
- 打开现有聊天时默认滚动至末尾
- 键盘交互自然流畅
- 文本输入支持粘贴图片和文件
- 文本输入支持平移手势聚焦/模糊
- Markdown支持快速编辑与动态组件
尽管移动端AI聊天已形成多种UI模式,但移动端AI代码生成领域尚无成熟范式。
现有React Native应用中未见这些特性,我们只能即兴创造模式。为达到标准,每个功能都经历了超乎寻常的工作量、测试与跨模块协调。
构建可组合的聊天系统
为满足需求,我们按功能模块对聊天代码进行了可组合化设计。
我们的聊天功能基于多个开源库实现:LegendList、React Native Reanimated 以及 React Native Keyboard Controller。首先,我们设置了多个上下文提供者。
JavaScript
export function ChatProvider({ children }) {
return (
<ComposerHeightProvider>
<MessageListProvider>
<NewMessageAnimationProvider>
<KeyboardStateProvider>{children}</KeyboardStateProvider>
</NewMessageAnimationProvider>
</MessageListProvider>
</ComposerHeightProvider>
)
}
提供者封装了MessagesList:
JavaScript
export function ChatMessagesList({ chatId }) {
const messages = useMessages({ chatId }).data
return (
<ChatProvider key={chatId}>
<MessagesList messages={messages} />
</ChatProvider>
)
}
接下来,我们的消息列表通过可组合插件实现了这些功能,每个插件都拥有专属的钩子。
JavaScript
function MessagesList({ messages }) {
useKeyboardAwareMessageList()
useScrollMessageListFromComposerSizeUpdates()
useUpdateLastMessageIndex()
const { animatedProps, ref, onContentSizeChange, onScroll } = useMessageListProps()
return (
<AnimatedLegendList
animatedProps={animatedProps}
ref={ref}
onContentSizeChange={onContentSizeChange}
onScroll={onScroll}
enableAverages={false}
data={messages}
keyExtractor={(item) => item.id}
renderItem={({ item, index }) => {
if (item.role === 'user') {
return <UserMessage message={item} index={index} />
}
if (item.role === 'assistant') {
return <AssistantMessage message={item} index={index} />
}
if (item.role === 'optimistic-placeholder') {
return <OptimisticAssistantMessage index={index} />
}
}}
/>
)
}
以下各节将逐一剖析每个钩子,以展示它们如何协同工作。
发送第一条消息
当你在v0版本发送消息时,消息气泡会平滑淡入并滑动至顶部。用户消息动画完成后,助手消息会立即淡入显示。

当用户发送消息时,我们会设置一个名为 Reanimated 的共享值来指示动画应开始播放。共享值使我们能够更新状态而不触发重新渲染。
JavaScript
const { isMessageSendAnimating } = useNewMessageAnimation()
const chatId = useChatId()
const onSubmit = () => {
const isNewChat = !chatId
if (isNewChat) {
isMessageSendAnimating.set(true)
}
send()
}
在 Reanimated 中追踪状态后,现在可以为 UserMessage 添加动画效果了。
JavaScript
export function UserMessage({ message, index }) {
const isFirstUserMessage = index === 0
const { style, ref, onLayout } = useFirstMessageAnimation({
disabled: !isFirstUserMessage,
})
return (
<Animated.View style={style} ref={ref} onLayout={onLayout}>
<UserMessageContent message={message} />
</Animated.View>
)
}
请注意,UserMessageContent 被包裹在 Animated.View 中,该组件从 useFirstMessageAnimation 接收 props。
useFirstMessageAnimation 如何工作
此钩子负责三项任务:
- 使用共享值
itemHeight(Reanimated 类)测量用户消息的高度 - 在
isMessageSendAnimating时淡入消息 - 向助手消息发出动画完成信号
JavaScript
export function useFirstMessageAnimation({ disabled }) {
const { keyboardHeight } = useKeyboardContextState()
const { isMessageSendAnimating } = useNewMessageAnimation()
const windowHeight = useWindowDimensions().height
const translateY = useSharedValue(0)
const progress = useSharedValue(-1)
const { itemHeight, ref, onLayout } = useMessageRenderedHeight()
useAnimatedReaction(
() => {
const didAnimate = progress.get() !== -1
if (disabled || didAnimate || !isMessageSendAnimating.get()) {
return -1
}
return itemHeight.get()
},
(messageHeight) => {
if (messageHeight <= 0) return
const animatedValues = getAnimatedValues({
itemHeight: messageHeight,
windowHeight,
keyboardHeight: keyboardHeight.get(),
})
const { start, end, duration, easing, config } = animatedValues
translateY.set(
// initialize values at the "start" state with duration 0
withTiming(start.translateY, { duration: 0 }, () => {
// next, transition to the "end" state
translateY.set(withSpring(end.translateY, config))
})
)
progress.set(
withTiming(start.progress, { duration: 0 }, () => {
progress.set(withTiming(end.progress, { duration, easing }), () => {
isMessageSendAnimating.set(false)
})
})
)
}
)
const style = useAnimatedStyle(...)
const didUserMessageAnimate = useDerivedValue(() => progress.get() === 1)
return { style, ref, onLayout, didUserMessageAnimate }
}
得益于 React Native 的新架构,useLayoutEffect 中的 ref.current.measure() 操作是同步的,因此首次渲染时即可获取高度值。后续更新则在 onLayout 中触发。
基于消息高度、窗口高度和当前键盘高度,getAnimatedValues 构造 translateY 和 progress 的缓动、start 及 end 状态。生成的共享值分别作为 transform 和 opacity 传递给 useAnimatedStyle。
至此,我们的首条消息已通过 Reanimated 实现淡入效果。动画完成后,即可开始淡入首个助手消息回复。
淡入第一助理的留言
与 UserMessage 类似,助手消息内容被包裹在一个动画视图中,该视图会在用户消息动画完成后淡入显示。
JavaScript
function AssistantMessage({ message, index }) {
const isFirstAssistantMessage = index === 1
const { didUserMessageAnimate } = useFirstMessageAnimation({
disabled: !isFirstAssistantMessage,
})
const style = useAnimatedStyle(() => ({
opacity: didUserMessageAnimate.get() ? withTiming(1, { duration: 350 }) : 0,
}))
return (
<Animated.View style={style}>
<AssistantMessageContent message={message} />
</Animated.View>
)
}
此淡入效果仅适用于聊天中的首条助手消息(即 index === 1 的消息)。现有聊天中的消息与新聊天中的消息将呈现不同行为。
若打开一个已存在聊天(包含一条用户消息和一条助手消息),是否会再次触发动画?不会,因为该动画仅在 isMessageSendAnimating 为 true 时生效------该属性在消息 onSubmit 时设置,切换聊天时则被清除。
在现有聊天中发送消息

我们已介绍过v0如何处理新聊天消息中的动画效果。但对于现有聊天,其逻辑则完全不同。我们不再依赖Reanimated动画(如 usedFirstMessageAnimation 中使用的动画),而是采用 scrollToEnd() 的实现方案。
那么在现有聊天中发送消息时,我们只需滚动到末尾即可,对吧?
JavaScript
useEffect(function onNewMessage() {
const didNewMessageSend = // ...some logic
if (didNewMessageSend) {
listRef.current?.scrollToEnd()
}
}, ...)
在理想状态下,这套逻辑本应足够完善。但让我们探讨为何它仍显不足。
回顾开篇所述,我们的需求之一是新消息必须滚动至屏幕顶部。若直接调用scrollToEnd()方法,新消息只会显示在屏幕底部。

我们需要一种策略将用户消息推至聊天顶部。我们将此称为"空白大小":即最后一条助手消息底部与聊天结束之间的距离。

为将内容浮动至聊天顶部,我们需将其向上推送与空白区域等量的距离。得益于React Native新架构中的同步高度测量机制,我们得以在每帧更新时实现无闪烁效果。但这仍需大量技巧与协调配合。
在上图中,您会注意到空白区域具有动态特性。其高度取决于键盘的展开状态。由于助手消息以不可预测的大小快速流式传输,每次渲染时空白区域都可能发生变化。
动态高度是虚拟化列表的常见难题,而频繁更新的空白区域将这一挑战推向新高度:我们的列表项具有动态未知高度且更新频繁,同时需要保持顶部悬浮状态。
当助手消息足够长时,空白区域可能归零,这又引入了一系列新的边界情况。

如何解决这个问题
我们尝试了多种实现空白区域的方法:在 ScrollView 底部添加带高度的 View、为 ScrollView 本身设置底部填充、对可滚动内容应用 translateY 属性、以及在最后一条系统消息设置最小高度。这些方案最终都导致了奇怪的副作用和性能问题,通常源于需要配合Yoga布局引擎进行渲染。
最终我们采用 ScrollView 的 contentInset 属性来处理空白区域,避免了抖动现象。contentInset 属性直接映射至 UIKit 中 UIScrollView 的原生属性。
在发送消息时,我们结合使用 contentInset 与 scrollToEnd({ offset }) 方法。
助手消息的空白区域由三部分共同决定:其自身高度、前置用户消息的高度,以及聊天容器的高度。

实现 useMessageBlankSize
要实现空白尺寸功能,我们首先在助手消息中使用名为 useMessageBlankSize 的钩子:
JavaScript
function AssistantMessage({ message, index }) {
// ...styling logic
const { onLayout, ref } = useMessageBlankSize({ index })
return (
<Animated.View ref={ref} onLayout={onLayout}>
<AssistantMessageContent message={message} />
</Animated.View>
)
}
useMessageBlankSize 负责以下逻辑:
- 同步测量助手消息
- 测量其前方的用户消息
- 计算助手消息下方空白区域的最小距离
- 记录键盘展开或收起时应设置的空白区域大小
- 在根上下文提供者中设置共享的
blankSize值
最后,我们获取 blankSize 大小并将其传递给 ScrollView 的内容 contentInset:
JavaScript
export function MessagesList(props) {
const { blankSize, composerHeight, keyboardHeight } = useMessageListContext()
const animatedProps = useAnimatedProps(() => {
return {
contentInset: {
bottom: blankSize.get() + composerHeight.get() + keyboardHeight.get(),
},
}
})
return <AnimatedLegendList {...props} animatedProps={animatedProps} />
}
Reanimated 的 useAnimatedProps 功能让我们能在每个帧上更新 UI 线程的 props,而不会触发重新渲染。contentInset 展现了卓越的性能表现,远胜于以往所有尝试。
驯服键盘
打造优质的聊天体验,关键在于优雅的键盘处理。在React Native中实现原生般的操作体验曾是艰巨而繁琐的任务。当v0 iOS处于公开测试阶段时,苹果发布了iOS 26。每次iOS测试版更新,我们的聊天功能似乎都会彻底崩溃。每次iOS版本发布,都变成一场追逐细微差异和抖动的猫鼠游戏。
所幸,react-native-keyboard-controller 的维护者 Kiryl 协助我们解决了这些问题,通常在苹果发布新测试版后的 24 小时内就更新了库文件。
构建useKeyboardAwareMessageList
我们利用React Native键盘控制器提供的多个钩子,构建了专为v0版本聊天功能定制的键盘管理系统。
useKeyboardAwareMessageList 是我们自定义的React钩子,负责所有键盘处理逻辑。它与聊天列表并行渲染,并抽象化了所有确保键盘交互体验的关键组件。
JavaScript
function MessagesList() {
useKeyboardAwareMessageList()
// ...rest of the message list
}
虽然该功能的调用仅需一行代码,但其内部实现包含约1000行代码及大量单元测试。useKeyboardAwareMessageList 主要依赖上游的 useKeyboardHandler 处理 onStart、onEnd 和 onInteractive 等事件,同时结合多次调用Reanimated的 useAnimatedReaction 方法,在特定边界条件下重试事件。
该组件还处理了iOS系统中的若干异常行为。例如当键盘开启时将应用切换至后台,随后重新聚焦应用时,iOS会莫名触发三次键盘 onEnd 事件。由于我们依赖命令式行为处理事件触发,因此设计了特殊机制来消除重复事件并追踪应用状态变化。
useKeyboardAwareMessageList 实现以下功能:
- 键盘弹出时缩小
blankSize - 若滚动至聊天末尾且无空白区域,则键盘弹出时将内容向上移动

- 若滚动到足够高的位置且没有空白区域,则在内容上方显示键盘,且不移动内容本身。

- 当用户通过滚动视图或文本输入交互式地关闭键盘时,将其平滑地向下拖动。

- 若已滚动至聊天窗口末尾,且空白区域大于键盘尺寸,内容应保持原位不变。

- 若滚动至聊天窗口底部时空白区域大于零,但键盘展开时该区域应为零,则将内容上移至键盘上方。

要让这一切顺利运行,并没有什么一劳永逸的诀窍。我们耗费数十小时反复使用这款应用,发现缺陷、追踪问题,不断重写逻辑,直到感觉一切都恰到好处。
初始滚动至末尾
当您打开现有聊天时,v0版本会将聊天界面滚动至末尾。这类似于在React Native的 FlatList 中使用 inverted 属性,这种设计常见于自下而上的聊天界面。
然而我们最终放弃了 inverted 方案,因为它与AI聊天场景存在冲突------AI聊天每秒会产生多次消息流。我们选择不采用助手消息流自动滚动方案,而是让内容自然填充在键盘下方区域,并配有滚动至末尾的按钮。此设计与ChatGPT的iOS应用行为一致。
但我们仍希望在首次打开现有聊天时提供倒序列表式体验。为此,当聊天首次可见时,我们会调用 scrollToEnd 方法实现滚动至末尾的效果。
由于动态消息高度与空白区域的复杂组合,我们不得不多次调用 scrollToEnd 方法。若不如此操作,列表要么无法正常滚动,要么滚动时机过晚。内容完成滚动后,我们调用 hasScrolledToEnd.set(true) 方法,使聊天内容渐显。
JavaScript
import { scheduleOnRN } from 'react-native-worklets'
export function useInitialScrollToEnd(blankSize, scrollToEnd, hasMessages) {
const hasStartedScrolledToEnd = useSharedValue(false)
const hasScrolledToEnd = useSharedValue(false)
const scrollToEndJS = useLatestCallback(() => {
scrollToEnd({ animated: false })
// Do another one just in case because the list may not have fully laid out yet
requestAnimationFrame(() => {
scrollToEnd({ animated: false })
// and another one again in case
setTimeout(() => {
scrollToEnd({ animated: false })
// and yet another!
requestAnimationFrame(() => {
hasScrolledToEnd.set(true)
})
}, 16)
})
})
useAnimatedReaction(
() => {
if (hasStartedScrolledToEnd.get() || !hasMessages) {
return false
}
return blankSize.get() > 0
},
(shouldScroll) => {
if (shouldScroll) {
hasStartedScrolledToEnd.set(true)
scheduleOnRN(scrollToEndJS)
}
}
)
return hasScrolledToEnd
}
漂浮编辑器

受iOS 26中iMessage底部工具栏的启发,我们打造了一款采用渐进式模糊效果的液态玻璃编辑器。
我们使用@callstack/liquid-glass库实现了交互式液态玻璃效果。通过用 LiquidGlassContainerView 包裹玻璃视图,即可自动获得视图变形效果。
JavaScript
<LiquidGlassContainerView spacing={8}>
<LiquidGlassView interactive>...</LiquidGlassView>
<LiquidGlassView interactive>...</LiquidGlassView>
</LiquidGlassContainerView>
悬浮
添加液体玻璃后,下一步是让它漂浮在聊天内容的顶部。

为使编辑器悬浮于可滚动内容之上,我们采取了以下步骤:
- 在编辑器中添加:
position: absolute; bottom: 0 - 使用
react-native-keyboard-controller中的KeyboardStickyView包裹编辑器 - 同步测量编辑器高度,并通过共享值将其存储在上下文中
- 将
composerHeight.get()添加到 ScrollView 的原生contentInset.bottom属性中
JavaScript
function Composer() {
const { composerHeight } = useComposerHeightContext()
const { onLayout, ref } = useSyncLayoutHandler((layout) => {
composerHeight.set(layout.height)
})
const insets = useInsets()
return (
<KeyboardStickyView
style={{ position: 'absolute', bottom: 0, left: 0, right: 0 }}
offset={{ closed: -insets.bottom, opened: -8 }}
>
<View
ref={ref}
onLayout={onLayout}
>
{/* ... */}
</View>
</KeyboardStickyView>
)
}
然而这还不够。我们仍缺少一个关键行为。
当您输入文字时,文本输入框的高度会随之增加。当您输入新行时,我们需要模拟在常规非绝对定位输入框中的输入体验。为此我们必须找到一种方法,在您滚动到聊天记录末尾时将聊天内容向上移动。
在下面的视频中,你可以看到两种情况。视频开始时,由于聊天滚动到末尾,内容会随着新行向上移动。然而,在聊天中向上滚动后,输入新行不会移动内容。

useScrollWhenComposerSizeUpdates
使用useScrollWhenComposerSizeUpdates钩子。该钩子监听组合器高度变化,并在需要时自动滚动至末尾。要使用它,只需在MessagesList中调用即可:
JavaScript
export function MessagesList() {
useScrollWhenComposerSizeUpdates()
// ...message list code
}
首先,它通过 useAnimatedReaction 设置效果来追踪编辑器高度变化。
接着调用 autoscrollToEnd 方法。只要你靠近可滚动区域的末尾,系统就会自动滚动至聊天窗口底部。若无此机制,在编辑器中输入新行时内容会覆盖可滚动区域的底部。
useScrollWhenComposerSizeUpdates 让我们能够条件性地模拟非绝对定位视图的交互体验。
JavaScript
export function useScrollWhenComposerSizeUpdates() {
const { listRef, scrollToEnd } = useMessageListContext()
const { composerHeight } = useComposerHeightContext()
const autoscrollToEnd = () => {
const list = listRef.current
if (!list) {
return
}
const state = list.getState()
const distanceFromEnd =
state.contentLength - state.scroll - state.scrollLength
if (distanceFromEnd < 0) {
scrollToEnd({ animated: false })
// wait a frame for LegendList to update, and fire it again
setTimeout(() => {
scrollToEnd({ animated: false })
}, 16)
}
}
useAnimatedReaction(
() => composerHeight.get(),
(height, prevHeight) => {
if (height > 0 && height !== prevHeight) {
scheduleOnRN(autoscrollToEnd)
}
}
)
}
正如我们在之前的代码中所见,我们不得不依赖多个 setTimeout 和 requestAnimationFrame 调用来实现 scrollToEnd 功能。这段代码难免会让人感到困惑,但当时这是唯一能让滚动结束功能正常运作的方法。我们正与 LegendList 的维护者 Jay 积极合作,致力于构建更可靠的解决方案。
逼近原生体验
React Native内置的 TextInput 组件在原生聊天应用中显得格格不入。
默认情况下,当设置multiline={true}时,TextInput 会显示难看的滚动指示器,这与大多数聊天应用不符。在输入框上下滑动会导致内部内容弹跳,即使尚未输入任何文本也是如此。此外,该输入框不支持交互式键盘关闭功能。
为解决这些问题,我们在原生代码中为 RCTUITextView 应用了补丁。该补丁禁用了滚动指示器,移除了弹跳效果,并启用了交互式键盘关闭功能。
我们的补丁还新增了向上滑动聚焦输入区域的功能。在观察测试人员因期待键盘弹出而焦躁地向上滑动后,我们意识到这个功能的必要性。
JavaScript
diff --git a/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/Libraries/Text/TextInput/Multiline/RCTUITextView.mm
index 6e9c3841cee19632eaa59ae2dbd541a85ce7cabf..e3f920acbc2bb074582ed2b531ddd90e2017d59c 100644
--- a/Libraries/Text/TextInput/Multiline/RCTUITextView.mm
+++ b/Libraries/Text/TextInput/Multiline/RCTUITextView.mm
@@ -55,6 +55,16 @@ - (instancetype)initWithFrame:(CGRect)frame
self.textContainer.lineFragmentPadding = 0;
self.scrollsToTop = NO;
self.scrollEnabled = YES;
+
+ // Fix bouncing, scroll indicator, and keyboard mode gesture
+ self.showsVerticalScrollIndicator = NO;
+ self.showsHorizontalScrollIndicator = NO;
+ self.bounces = NO;
+ self.alwaysBounceVertical = NO;
+ self.alwaysBounceHorizontal = NO;
+ self.keyboardDismissMode = UIScrollViewKeyboardDismissModeInteractive;
+ [self.panGestureRecognizer addTarget:self action:@selector(_handlePanToFocus:)];
+
_initialValueLeadingBarButtonGroups = nil;
_initialValueTrailingBarButtonGroups = nil;
}
@@ -62,6 +72,18 @@ - (instancetype)initWithFrame:(CGRect)frame
return self;
}
+- (void)_handlePanToFocus:(UIPanGestureRecognizer *)g
+{
+ if (self.isFirstResponder) { return; }
+ if (g.state != UIGestureRecognizerStateBegan) { return; }
+ CGPoint v = [g velocityInView:self];
+ CGPoint t = [g translationInView:self];
+ // Add pan gesture to focus the keyboard
+ if (v.y < -250.0 && !self.isFirstResponder) {
+ [self becomeFirstResponder];
+ }
+}
+
- (void)setDelegate:(id<UITextViewDelegate>)delegate
{
// Delegate is set inside `[RCTBackedTextViewDelegateAdapter initWithTextView]` and
虽然在React Native更新中维护补丁并非理想方案,但这是我们找到的最切实可行的解决方案。我们更希望存在官方API来扩展原生视图而无需打补丁,若社区对此感兴趣,我们计划将此补丁贡献给React Native核心团队。
粘贴图片
为支持在文本输入框中粘贴图片和文件,我们使用了一个Expo模块,该模块会监听来自原生 UIPasteboard 的粘贴事件。

若粘贴的文本足够长,onPaste 将自动将粘贴内容转换为 .txt 文件附件。
JavaScript
<TextInputWrapper onPaste={pasted => ...}>
<TextInput />
</TextInputWrapper>
由于难以在原生代码中扩展现有的 TextInput 组件,我们采用了 TextInputWrapper 组件------它通过Swift语言封装 TextInput 并遍历其 subviews 。若需深入了解原生封装组件的创建示例,欢迎观看我2024年的演讲《勇于构建原生库》。
流媒体内容渐隐效果

当AI助手的消息流传入时,必须保持流畅体验。为实现这一目标,我们创建了两个组件:
<FadeInStaggeredIfStreaming /><TextFadeInStaggeredIfStreaming />
只要某个元素被这些组件之一包裹,其子元素就会以错落有致的动画效果平滑淡入。
JavaScript
const mdxComponents = {
a: function A(props) {
return (
<Elements.A {...props}>
<TextFadeInStaggeredIfStreaming>
{props.children}
</TextFadeInStaggeredIfStreaming>
</Elements.A>
)
},
// ...other components
}
在底层实现中,这些组件渲染了 FadeInStaggered 的变体,该组件负责状态管理:
JavaScript
const useIsAnimatedInPool = createUsePool()
function FadeInStaggered({ children }) {
const { isActive, evict } = useIsAnimatedInPool()
return isActive ? <FadeIn onFadedIn={evict}>{children}</FadeIn> : children
}
useIsAnimatedInPool 是 React 外部的一个自定义状态管理器,允许同时渲染有限数量的有序元素。元素在挂载时请求加入池,isActive 属性指示其是否应渲染动画节点。
当 onFadedIn 回调触发后,我们会将元素从池中移除,直接渲染其子元素而不使用动画包装器。这有助于限制同时活动的动画节点数量。
最后,FadeIn 实现交错动画效果,元素间延迟为 32 毫秒。交错动画按计划执行,每次批量渲染 2 个元素。当交错元素队列超过 10 个时,我们会根据队列大小增加批处理元素数量。
JavaScript
const useStaggeredAnimation = createUseStaggered(32)
function FadeIn({ children, onFadedIn, Component }) {
const opacity = useSharedValue(0)
const startAnimation = () => {
opacity.set(withTiming(1, { duration: 500 }))
setTimeout(onFadedIn, 500)
}
useStaggeredAnimation(startAnimation)
return <Component style={{ opacity }}>{children}</Component>
}
TextFadeInStaggeredIfStreaming 采用类似策略。我们首先将单词拆分为独立文本节点,随后创建一个容纳文本元素的独立池,并设置4个元素的上限。这确保每次淡入显示的单词不超过4个。
JavaScript
const useShouldTextFadePool = createUsePool(4)
function TextFadeInStaggeredIfStreaming(props) {
const { isStreaming } = use(MessageContext)
const { isActive } = useShouldTextFadePool()
const [shouldFade] = useState(isActive && isStreaming)
let { children } = props
if (shouldFade && children) {
if (Array.isArray(children)) {
children = Children.map(children, (child, i) =>
typeof child === 'string' ? <AnimatedFadeInText key={i} text={child} /> : child,
)
} else if (typeof children === 'string') {
children = <AnimatedFadeInText text={children} />
}
}
return children
}
function AnimatedFadeInText({ text }) {
const chunks = text.split(' ')
return chunks.map((chunk, i) => <TextFadeInStaggered key={i} text={chunk + ' '} />)
}
function TextFadeInStaggered({ text }) {
const { isActive, evict } = useIsAnimatedInPool()
return isActive ? <FadeIn onFadedIn={evict}>{text}</FadeIn> : text
}
这种方法存在的一个问题是它高度依赖于在挂载时触发动画效果。因此,如果你发送一条消息后切换到其他聊天窗口,然后在消息发送完成前返回原聊天窗口,动画效果会重新挂载并再次播放。
为缓解此问题,我们设计了跨聊天记录追踪动画内容的系统。具体实现是在消息树顶层使用 DisableFadeProvider 组件,并在根级淡出组件中调用该组件,以避免影响资源池。
JavaScript
function TextFadeInStaggeredIfStreaming(props) {
const { isStreaming } = use(MessageContext)
const { isActive } = useShouldTextFadePool()
const isFadeDisabled = useDisableFadeContext()
const [shouldFade] = useState(!isFadeDisabled && isActive && isStreaming)
if (shouldFade) // here we render TextFadeIn...
return props.children
}
虽然在非响应式场景下显式依赖 useState 的初始值看似不寻常,但这种做法能让我们根据元素挂载顺序可靠地追踪其动画状态。
在网页和原生应用之间共享代码
在开发v0版iOS应用时,一个自然的问题浮现:Web端与原生端应共享多少代码?
鉴于v0版Web单仓库的成熟度,我们决定共享类型和辅助函数,但不共享UI或状态管理。同时我们着力将业务逻辑从客户端迁移至服务器端,使v0版移动应用成为API的轻量封装层。
构建共享API
在成熟的Next.js应用与新移动应用之间共享后端API路由带来了挑战。v0网页应用采用React服务器组件和服务器操作驱动,而移动应用则更像单页React应用。
为解决此问题,我们基于自研后端框架构建了API层。该框架通过强制要求使用Zod定义输入输出类型,实现了运行时类型安全。
定义路由后,系统会根据各路由的Zod类型生成https://api.v0.dev/v1/openapi.json文件。移动端通过Hey API消费OpenAPI规范,该工具会生成辅助函数供Tanstack Query调用。
JavaScript
import { termsFindOptions } from '@/api' // this folder is generated
import { useQuery } from '@tanstack/react-query'
export function useTermsQuery({ after }) {
return useQuery(termsFindOptions({ after }))
}
这项工作促成了v0平台API的开发。我们最初旨在为自有原生客户端打造理想的API,最终决定将该API开放给所有用户。得益于此策略,v0移动端与v0平台API客户使用相同的接口路径和逻辑。
每次提交代码时,我们都会运行测试以确保OpenAPI规范的变更与移动应用兼容。
未来我们计划通过在平台API外层构建类型级RPC封装器,彻底消除代码生成环节。
样式
v0版本采用react-native-unistyles进行样式和主题管理。基于对React Native的实践经验,我始终对渲染阶段的操作保持谨慎。与我们评估过的其他样式库不同,Unistyles能在不重新渲染组件或访问React上下文的情况下实现全面主题管理。
原生菜单
除了Unistyles主题和样式库外,我们并未采用基于JS的组件库,而是尽可能依赖原生元素。

对于菜单,我们采用了Zeego框架,该框架在底层依赖https://github.com/dominicstop/react-native-ios-context-menu来渲染原生UIMenu。当使用Xcode 26进行构建时,Zeego会自动渲染Liquid Glass样式的菜单。
原生Alert
在 iOS 26 系统上运行的 React Native 应用存在 Alert 渲染偏离屏幕的问题。我们在自家应用及众多热门 React Native 应用中复现了该问题。我们通过本地补丁修复了该问题,并与 Callstack 和 Meta 的开发者合作,将修复方案提交至 React Native 。
原生底部菜单
对于底层表单,我们采用了内置的React Native模态框并设置presentationStyle="formSheet"。但该方案存在若干缺陷,我们通过补丁进行了修复。
修复Yoga闪烁问题
若将 flex: 1 的 View 嵌入带背景色的模态框中,随后上下拖动模态框,该视图底部会出现剧烈闪烁现象。
为解决此问题,我们对 React Native 进行了本地补丁修复,使 Yoga 框架支持模态框的同步更新。通过与 Callstack、Expo 和 Meta 的开发者协作,该改进已合并至 React Native 核心库。此功能现已随 React Native 0.82 版本正式发布。
期待
在使用React Native和Expo构建了首个应用后,我们再未回头。若您尚未体验iOS版v0,请立即下载并通过App Store评论告诉我们您的想法。
我们正在招募开发者加入Vercel移动团队。若此类工作令您心动,我们期待您的加入。
在Vercel,我们致力于打造最高水准的雄心勃勃的产品。我们希望让Web和原生开发者同样轻松实现这一目标,并计划开源我们的研究成果。若您有意参与AI聊天应用开源库的测试版测试,请通过X平台联系我们。期待与社区携手,共同推动React Native持续进步。