
1. 项目概览与整体设计思路1.1 为什么用 Vue UniApp 做 AI 问答助手先交代一下项目背景。我最近在做一个跨端 AI 问答助手目标是一套代码同时覆盖 App、H5、微信小程序三个平台。技术栈选了 Vue 3 UniApp这个组合在跨端生态里已经相当成熟Vue 3 的组合式 API 配合 UniApp 的编译能力能最大限度减少平台差异带来的重复开发。市面上并不是没有现成的 AI 聊天客户端但大多数要么是纯 Web 端要么是单一平台的原生应用。我要的是一个能放进自己项目体系里的问答组件支持 Markdown 渲染、数学公式展示还得能处理图片、语音这类多模态输入。选 UniApp 的原因很直接团队里前后端都熟 Vue不需要额外引入 React Native 或 Flutter 的技术栈而且 UniApp 对微信小程序的兼容性打磨得比较到位H5 端也能直接复用。这套方案能解决什么问题一句话总结用一份 Vue 代码跑通 AI 问答在 App、H5、小程序三个端的完整链路包括流式问答、Markdown 渲染、公式展示、图片识别、语音输入、本地缓存等核心能力。适合正在做 AI 应用落地、想快速覆盖多端的开发者参考。1.2 核心需求拆解与功能清单在动手写代码之前我习惯先把需求拆成可验证的功能点避免开发途中频繁返工。这个项目的核心需求拆下来是这几块问答链路用户输入问题 → 请求 AI 接口 → 流式返回 → 增量渲染支持停止生成、重新生成。内容展示AI 回复内容是 Markdown 格式需要完整支持代码块、表格、引用、图片同时要能渲染 LaTeX 数学公式。多模态交互H5 和 App 端支持图片上传做 OCR 识别App 端支持语音输入转文字小程序端暂时只做图片识别。历史记录本地持久化对话历史支持按会话维度管理冷启动时能恢复上次的对话。跨端适配键盘弹出顶起输入框、下拉刷新与滚动冲突处理、暗黑模式适配、安全区适配。功能清单列出来之后整个项目的开发节奏就清晰了先搭骨架再补核心功能最后集中处理平台差异和兼容性问题。2. 技术选型与关键依赖解析2.1 Vue 3 组合式 API 的项目组织方式项目脚手架用的是 Vite 创建的 UniApp 模板也就是npx degit dcloudio/uni-preset-vue#vite-ts那个版本。为什么不用 Vue 2 的老模板因为 Vue 3 的组合式 API 在处理复杂交互状态时优势太明显尤其是流式问答这种高频更新的场景。看一下项目里 store 的核心实现用 Pinia 管理会话状态问答过程中的消息列表、加载状态、流式缓冲文本都放在 store 里统一管理// stores/chat.ts import { defineStore } from pinia export const useChatStore defineStore(chat, { state: () ({ sessions: [] as ChatSession[], currentSessionId: , streaming: false, abortController: null as AbortController | null, streamBuffer: as string, // 流式缓冲 }), getters: { currentSession(state): ChatSession | undefined { return state.sessions.find(s s.id state.currentSessionId) }, messages(): ChatMessage[] { return this.currentSession?.messages ?? [] } }, actions: { async sendMessage(content: string, options?: SendOptions) { // ... }, stopStreaming() { this.abortController?.abort() this.streaming false } } })组合式 API 最大的好处是把“流式问答”这个复杂逻辑拆成了多个可复用的 composableuseStreamRequest负责网络请求和流式解析useMarkdownRender负责渲染层useMultimodal负责图片语音等输入处理。这样每个模块都能独立测试出问题也好定位。2.2 组件库选型与样式隔离方案组件库我对比过几个方案uview-plus、wot-design-uni、还有uni-ui。最后选了 wot-design-uni原因有三个第一它对 Vue 3 TypeScript 的支持很完整第二组件风格偏现代化适合做“沉浸式”的产品调性第三支持按需引入打包体积可控。不过用组件库最容易翻车的是自定义样式被全局覆盖。我采用的策略是核心问答界面不用组件库的布局组件只用基础的按钮、输入框、弹窗这类通用件消息列表、聊天气泡、输入工具条全部自己写样式。这样既保证交互细节完全可控又避免深层次样式穿透的心智负担。样式上用了 CSS 变量做主题切换暗黑模式不用重新写一套样式只换变量值即可// styles/theme.scss :root { --chat-bg: #f5f6fa; --message-user-bg: #3478f6; --message-ai-bg: #ffffff; --text-primary: #1a1a2e; --text-secondary: #6b7280; --border-radius: 12px; --bubble-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); } .dark { --chat-bg: #0f1117; --message-ai-bg: #1a1d27; --text-primary: #e5e7eb; --text-secondary: #9ca3af; --bubble-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); }2.3 Markdown 渲染方案对比与取舍Markdown 渲染是问答助手最核心的展示层方案选不好后面代码高亮、公式渲染全得返工。我调研了三条路线mp-html 插件轻量、对小程序友好但 Markdown 解析能力偏弱复杂语法支持不全。towxml功能强、支持公式但体积偏大而且作者更新频率不稳定。自己封装解析器用markedhighlight.jskatex组合编译期把 Markdown 转成 HTML 字符串再用 rich-text 组件渲染。最终选了第三种。到小程序端 rich-text 支持的事件有限但问答场景主要就是静态展示链接和代码块够用了。关键是渲染逻辑可以完全自己控制后续想加自定义按钮、复制功能都有操作空间。我封装了一个MarkdownRenderer组件大概结构如下!-- components/MarkdownRenderer.vue -- template view classmd-body v-htmlrenderedContent taphandleTap / /template script setup langts import { computed } from vue import { marked } from marked import hljs from highlight.js import katex from katex const props defineProps{ content: string }() // 扩展 marked 渲染规则 marked.setOptions({ highlight(code, lang) { if (lang hljs.getLanguage(lang)) { return hljs.highlight(code, { language: lang }).value } return hljs.highlightAuto(code).value } }) const renderedContent computed(() { let html marked.parse(props.content) // 对 $$...$$ 和 $...$ 公式做预处理 html renderKatex(html) // 处理代码块复制按钮等额外逻辑 html enhanceCodeBlocks(html) return html }) /script3. 核心功能实战流式问答与内容渲染3.1 基于 fetch 的流式请求封装AI 问答的体验核心在“流式输出”。如果等接口完全返回再渲染长回答的等待时间足够让用户流失大半。我封装了一个基于 fetch 的流式请求 composable关键在于处理 ReadableStream 的逐段读取。// composables/useStreamRequest.ts export function useStreamRequest() { const controller new AbortController() async function streamRequest( url: string, body: any, onChunk: (text: string) void, onDone: () void, onError: (err: Error) void ) { try { const response await fetch(url, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(body), signal: controller.signal }) const reader response.body?.getReader() const decoder new TextDecoder() let buffer while (true) { const { done, value } await reader!.read() if (done) break buffer decoder.decode(value, { stream: true }) // SSE 格式按 \n 分割解析 const lines buffer.split(\n) buffer lines.pop() || for (const line of lines) { if (line.startsWith(data: )) { const chunk line.slice(6).trim() if (chunk [DONE]) continue try { const parsed JSON.parse(chunk) onChunk(parsed.choices?.[0]?.delta?.content || ) } catch (e) { // 忽略解析异常 } } } } onDone() } catch (err) { if ((err as Error).name ! AbortError) { onError(err as Error) } } } return { streamRequest, controller } }这里有个关键点每一段文本到达后不能直接替换整个消息内容而是要做增量追加。我封装了一个MessageBubble组件内部维护一个displayedText通过 watch 感知 store 里消息内容的变化。为了让视觉效果更“流畅”还加了一个简单的打字机效果——新内容不是瞬间出现而是按字符间隔逐步展示// composables/useTypingEffect.ts export function useTypingEffect(text: string, speed 20) { const displayText ref() let timer: ReturnTypetypeof setTimeout | null null watch(text, (newVal) { if (timer) clearTimeout(timer) displayText.value const chars newVal.split() let index 0 const tick () { if (index chars.length) { displayText.value chars[index] index timer setTimeout(tick, speed) } } tick() }) return { displayText } }注意打字机效果不能作用于代码块否则代码块里的内容逐字蹦出来既慢又容易闪屏。我的做法是判断文本中是否有代码块语法如果有就直接全量渲染只对纯文本部分做打字机效果。实测下来效果比较自然。3.2 代码高亮与行号处理的坑代码高亮用 highlight.js但在 UniApp 环境里有几个坑需要绕过去。第一个坑是小程序端不支持动态执行代码所以不能直接在运行时遍历document.querySelectorAll来做高亮必须在后端渲染为 HTML 字符串时就完成高亮处理。好在我们用的是编译期转换正好避开了运行时 DOM 操作的限制。第二个坑是样式注入方式。highlight.js 的 CSS 是全局选择器在 H5 端正常到小程序端就失效了。解决办法是引入主题 CSS 后把主要颜色变量通过作用域选择器手动指定到.md-body容器内。我直接复制了一份 github 主题的 CSS加上了.md-body前缀。第三个坑是行号。Markdown 里代码块的 语法解析后返回的是precode结构没有行号。我通过marked的renderer.code回调在输出 HTML 时手动加行号容器const renderer new marked.Renderer() renderer.code (code, infostring) { const lang (infostring || ).split(/\s/)[0] || text const highlighted hljs.getLanguage(lang) ? hljs.highlight(code, { language: lang }).value : hljs.highlightAuto(code).value const lines highlighted.split(\n).filter(l l.trim() ! ) const lineNumbers lines .map((_, i) span classline-num${i 1}/span) .join(\n) return div classcode-block div classcode-header span${lang}/span span classcopy-btn复制/span /div div classcode-body div classline-numbers${lineNumbers}/div precode classhljs language-${lang}${highlighted}/code/pre /div /div }这个自定义 renderer 还顺势解决了代码块的复制按钮问题。点击复制按钮时需要在组件里捕获事件H5 端直接走navigator.clipboard小程序端要用uni.setClipboardDatafunction handleCopyCode(code: string) { // #ifdef H5 navigator.clipboard.writeText(code) // #endif // #ifdef MP-WEIXIN uni.setClipboardData({ data: code }) // #endif // #ifdef APP-PLUS uni.setClipboardData({ data: code }) // #endif }3.3 数学公式渲染KaTeX 的正确姿势公式渲染选了 KaTeX 而不是 MathJax核心原因是 KaTeX 体积小、渲染速度快移动端性能压力小。但 KaTeX 默认只支持纯 HTML 输出在小程序 rich-text 环境里需要处理好 CSS 的注入。公式的处理流程我拆成三步第一步预处理。在 Markdown 转 HTML 之前先把$$...$$块级公式和$...$行内公式提取出来防止被 marked 当作普通文本解析掉function extractMath(text: string) { // 先把块级公式替换成占位符 const blocks: string[] [] text text.replace(/\$\$([^$])\$\$/g, (_, formula) { blocks.push(katex.renderToString(formula, { displayMode: true, throwOnError: false })) return MATH_BLOCK_${blocks.length - 1} }) // 行内公式 text text.replace(/\$([^$])\$/g, (_, formula) { blocks.push(katex.renderToString(formula, { displayMode: false, throwOnError: false })) return MATH_INLINE_${blocks.length - 1} }) return { text, blocks } }第二步恢复占位符。marked 解析完成后再把MATH_...替换回 KaTeX 生成的 HTML。第三步样式适配。KaTeX 本身要求的 CSS 不少小程序端不能直接引入katex/dist/katex.min.css需要把关键的字体、颜色、间距规则内联化处理。这里有个细节KaTeX 默认用字体文件渲染公式符号小程序端字体文件路径容易出问题所以我在构建时把字体文件转成了 base64 内嵌或者精简掉非必要字体只保留数学符号常用的那部分。实测下来iOS 端公式渲染速度可接受Android 低端机稍慢但也不会有明显卡顿。如果公式特别长建议加一个折叠机制默认只显示前几行展开后再渲染完整公式避免一次性绘制太多 DOM 节点导致白屏。4. 多模态输入与跨端交互实战4.1 图片上传与 OCR 识别链路多模态交互是这个项目的重头戏。我做了三种输入方式文字、图片、语音。图片这块走的是“上传到服务器 → 服务端调 OCR 接口 → 返回识别文本 → 自动追加到输入框”的链路。前端要处理的坑不少。第一个是图片压缩。手机拍出来的照片动辄 3-5MB直接上传不仅慢还容易触发服务器的体积限制。我在客户端做了压缩处理H5 端用 canvas 压缩App 和小程序端用uni.compressImageasync function compressImage(filePath: string): Promisestring { return new Promise((resolve) { uni.compressImage({ src: filePath, quality: 70, success: (res) resolve(res.tempFilePath) }) }) }第二个坑是图片预览与删除。多模态输入不能只传一张图用户可能拍多张图让 AI 综合分析。我在输入工具条上方做了个缩略图横滑区每张图可以单独删除支持调整位置长按拖拽排序这个功能小程序端实现成本高我最后只在 App 和 H5 端做了。第三个坑是上传进度反馈。图片上传是耗时的异步操作必须给用户明确的反馈。我在缩略图上覆盖了一个半透明的 loading 状态上传完成后显示勾选失败显示红色叹号并支持点击重试const uploadTasks refRecordstring, UploadTaskStatus({}) async function uploadImage(filePath: string) { uploadTasks.value[filePath] { status: uploading, percent: 0 } try { const result await new Promisestring((resolve, reject) { const task uni.uploadFile({ url: ${API_BASE}/upload, filePath, name: file, success: (res) resolve(res.data), fail: reject }) task.onProgressUpdate((res) { uploadTasks.value[filePath].percent res.progress }) }) uploadTasks.value[filePath].status done return result } catch (e) { uploadTasks.value[filePath].status error } }4.2 语音输入实现与权限处理语音输入在 App 端用的plus.audio的录音功能然后对接语音识别服务。这里分两层如果用的是在线语音识别 API录音文件要转成服务端要求的格式如果用的是系统级语音识别直接调uni.startRecord再上传识别就行。我的方案是先用系统录音组件拿到音频临时文件再上传到后端由后端统一调语音识别服务。这样能做到“一次接入全端可用”。权限处理是个大坑。尤其是 iOS 的麦克风权限首次调用会弹系统授权框如果用户拒绝后再想手动打开需要跳转系统设置页。小程序端弹的又是另一套授权逻辑async function requestMicrophonePermission() { // #ifdef MP-WEIXIN const res await uni.getSetting() if (!res.authSetting[scope.record]) { const auth await uni.authorize({ scope: scope.record }) if (auth.errMsg.includes(deny)) { // 引导用户去设置页打开 uni.showModal({ title: 提示, content: 您拒绝了麦克风权限请去设置中打开, confirmText: 去设置, success: () uni.openSetting() }) } } // #endif // #ifdef APP-PLUS // App 端需要判断 platform 是 iOS 还是 Android分别处理 // #endif }注意小程序端的uni.authorize有严格限制——如果用户之前拒绝过授权再次调用会直接走 fail 回调不会弹窗。所以每次都要先查状态不允许就直接引导打开设置页不要反复尝试授权否则会收到“无权限调起授权”的报错。4.3 长按说话与手势冲突处理语音输入在移动端体验最好的交互方式是“按住说话”。这里有个交互细节长按触发录音和在消息列表里滚动是两个冲突的手势处理不好就会“刚按下去准备说话页面就跟着往下滚了”。我用的方案是在输入工具条的语音按钮上做了三种手势状态的区分——触摸开始记录位置和事件时间如果在 200ms 内手指移动距离超过 10px判定为滑动取消录音如果超过 200ms 且手指未大幅移动判定为长按开始录音录音过程中上滑可以取消发送let startX 0, startY 0, timer: any null function onTouchStart(e: TouchEvent) { startX e.touches[0].clientX startY e.touches[0].clientY timer setTimeout(() { startRecording() isRecording.value true }, 200) } function onTouchMove(e: TouchEvent) { if (!isRecording.value) return const dx e.touches[0].clientX - startX const dy e.touches[0].clientY - startY // 上滑超过 80px 进入取消状态 cancelMode.value dy -80 } function onTouchEnd() { clearTimeout(timer) if (isRecording.value) { if (cancelMode.value) { stopRecordingAndCancel() } else { stopRecordingAndSend() } } }录音过程中我还在按钮上方加了一小段 toast 提示“上滑取消发送”这样用户能直观知道当前处于录音状态不至于误触。5. 消息列表的滚动管理与性能优化5.1 滚动到底部与新消息自动跟随问答场景的滚动逻辑跟普通列表不太一样新消息不断追加时需要自动滚动到底部但如果用户已经向上翻看历史消息就不能强行拉到底部打扰阅读。我的实现方案是给 scroll-view 绑定一个阈值判断监听 scroll 事件记录当前滚动位置如果距离底部小于 120px就认为处于“跟随模式”新消息来了自动平滑滚动到底部如果距离超过了 120px说明用户正在上翻这时候底部会浮现一个“回到底部”的小气泡按钮const scrollIntoView ref() const isFollowMode ref(true) function handleScroll(e: any) { const { scrollTop, scrollHeight, offsetHeight } e.detail const distanceToBottom scrollHeight - scrollTop - offsetHeight isFollowMode.value distanceToBottom 120 } function scrollToBottom() { // 加一个随机数避免相同 id 不触发滚动 scrollIntoView.value msg-${messages.value.length}_${Date.now()} }scroll-view 里要用scroll-into-view来定位某个子元素这个 id 必须加在消息节点的最外层 view 上而且必须确保该节点在页面渲染完成后才能生效。我踩过一个坑消息还没渲染时就把 scrollIntoView 赋值导致滚动失效。解决办法是给 scroll-view 加scroll-with-animation并在数据更新后用nextTick延迟一帧再赋值。5.2 长列表渲染优化与虚拟滚动降级对话超过 50 条之后如果一次性把所有消息渲染到 scroll-view 里小程序端会出现明显的卡顿尤其是每条消息里还有代码块、公式这种重 DOM。针对这个我做了分级处理消息少于 30 条全量渲染不做额外处理。30~100 条对非当前屏的消息做“懒渲染”用v-if控制只渲染首屏附近的消息其余用占位节点。超过 100 条提示用户开启“仅查看最近消息”模式再配合后端做分页加载。懒渲染的实现不能直接监听 scroll 事件然后频繁修改 v-if那样滚动时会大量触发重渲染。我用了一个“上下扩展”的策略以当前可视 mid 为中心前后各保留 10 条超出范围的节点用固定高度的占位 div 替换。view v-for(msg, index) in visibleMessages :keymsg.id classmessage-item template v-ifmsg.placeholder view :style{ height: msg.estimatedHeight px } / /template template v-else MessageBubble :messagemsg / /template /view这里有个估算高度的学问。每条消息的高度差异很大纯文本可能就 40px带代码块的能到 700px。我在渲染完成后缓存了每条消息的实际高度到 store 里占位视图用缓存高度来撑开空间。没有缓存的情况下先用文本长度除以每行字符数估算行数再乘行高误差基本能控制在 20% 以内。5.3 下拉刷新与内部滚动的冲突处理这是热词里频繁提到的高频问题uniapp 下拉刷新和页面内部 scroll-view 滚动冲突。尤其在小程序端页面开启下拉刷新后在 scroll-view 里向上滚动时很容易误触整页的下拉刷新。我处理这个问题的方案有三个层次第一层问答页面禁掉页面级下拉刷新改在 scroll-view 的refresher-enabled属性上做自定义下拉刷新。这样下拉动作只作用于消息区域内部对输入工具条和顶部导航栏无影响。第二层多端条件编译区分处理。App 端scroll-view的 refresher 默认走的是系统的onPullDownRefresh需要在小程序端单独适配。我干脆封装了一个ChatScrollView组件内部根据平台判断用哪种刷新模式。第三层手势冲突的细节处理。当 scroll-view 滚动到顶部scrollTop 0时继续下拉才触发刷新否则直接忽略交给滚动处理function handleScroll(e: any) { const { scrollTop } e.detail if (scrollTop 0) { // 允许触发下拉刷新 canPullDown.value true } else { canPullDown.value false } }5.4 键盘弹出与输入框顶起适配移动端聊天界面最容易出问题的就是键盘。在小程序里弹软键盘会直接改变 webview 的高度如果不处理输入框会被顶出屏幕或遮挡消息。我用的是adjust-position 手动监听onKeyboardHeightChange的组合方案// #ifdef MP-WEIXIN uni.onKeyboardHeightChange((res) { keyboardHeight.value res.height if (res.height 0) { scrollToBottom() } }) // #endif // #ifdef APP-PLUS // App 端用 plus.key 的事件或者监听输入框 focus/blur // #endif这里有个关键点键盘弹起后消息列表底部必须加 padding否则最后一条消息被输入条盖住。我在外层容器上动态绑定一个padding-bottom样式值就是键盘高度加上输入工具条高度。键盘收起时再把 padding 恢复原状。Android 端还有一个软键盘挡住查询内容的老问题单独点开某个输入组件时尤其明显。我最后采用的最稳妥方案是在输入框聚焦时将页面滚动到输入框位置并给底部容器加 100px 冗余空间。虽然有 API 可以设置adjust-positiontrue但不同 ROM 的表现不一致还是自己手动控制更可靠。6. 会话管理与数据持久化细节6.1 多会话管理与历史记录结构设计会话管理我用了 Pinia 做内存态配合 uni.setStorageSync 做持久化。会话结构设计成树状一个会话包含多条消息每条消息又包含 text、role、timestamp、type文字/图片/语音等字段。这种结构简单直接序列化和恢复代价都小。interface ChatMessage { id: string role: user | assistant type: text | image | voice content: string timestamp: number status: loading | done | error meta?: { imageUrl?: string voiceDuration?: number referencedImages?: string[] } } interface ChatSession { id: string title: string messages: ChatMessage[] createdAt: number updatedAt: number }会话的持久化我做了防抖处理不是每条消息都写存储而是用户停止输入或离开页面前批量写入。默认防抖 1.5 秒存的时候只存最近 200 条消息避免 storage 超出小程序端 10MB 限制。6.2 会话标题的自动生成新会话默认标题是“新对话”这不够友好。我的方案是第一次用户发送消息时截取前 20 个字作为临时标题等 AI 回复第一条消息后再通过一个简短的摘要请求让模型帮会话生成一个精准的标题。这个摘要请求不能阻塞用户的正常对话我在后台异步发起不展示回复内容只更新列表标题async function autoTitle(sessionId: string, firstUserMsg: string) { const title await requestAISummary(用不超过15个字概括这个问题的主题${firstUserMsg}) const sessions uni.getStorageSync(chat_sessions) || [] const index sessions.findIndex((s: any) s.id sessionId) if (index -1) { sessions[index].title title uni.setStorageSync(chat_sessions, sessions) } }个人经验是不要在小程序端用“一次性请求 定时器检查结果”的轮询方案来优雅地做这件事直接用 Promise 链就行反正 AI 接口本身的返回是可预期的成本也不高。6.3 冷启动恢复与消息状态标记页面启动时从 storage 恢复会话列表只加载最近一个会话的消息其他会话的消息等用户点开后按需加载。这样启动性能不受会话数量增长拖累。恢复消息时我加了一个“状态恢复”逻辑上次对话如果 AI 回复还没有完成就退出恢复后那条消息会标记为interrupted状态用户点“重新生成”可以续上function restoreInterruptedMessage(session: ChatSession) { const lastMessage session.messages[session.messages.length - 1] if (lastMessage?.role assistant lastMessage.status loading) { lastMessage.status interrupted } }这个小细节很能提升体验比用户重新复制问题再问一遍要自然得多。7. 跨端适配与常见问题排查实录7.1 不同端的差异处理与条件编译UniApp 的跨端能力不代表“写一次逻辑全球通”实际开发中大量代码需要按平台拆分。我自己总结了一套条件编译的使用规范网络请求H5 直接用 fetch/XHR小程序和 App 走uni.request但请求拦截器、异常处理逻辑保持统一。存储H5 用 localStorage小程序/App 用uni.getStorageSync。建议封装一个 storage 工具层避免业务代码散落条件编译。跳转H5 用路由跳转小程序用uni.navigateTo/switchTabApp 端还要考虑原生 webview 的场景。拿这个项目来说最容易踩坑的是App 端的打包配置。Android 上架各大应用市场要求必须配置隐私政策弹窗否则直接被拒。我在manifest.json里配置了隐私协议弹窗启动时先弹出隐私政策用户同意后才初始化plus.oauth、定位、推送这些需要权限的服务。关于“当用户不同意隐私政策及用户协议时退出App”这个热词UniApp 的实现方式是弹窗点“不同意”直接plus.runtime.quit()退出if (!agree) { plus.runtime.quit() }iOS 端审核更关注的是苹果登录的配置如果你的应用支持第三方登录必须同时提供 Sign in with Apple 选项不然上架会被拒。7.2 微信小程序端的编译与上传细节小程序端有几个特有的问题需要注意。第一分包体积限制。主包不能超过 2MB算上 katex、highlight.js 相关的静态资源很容易超限。我的处理方案是把聊天页面放到分包里主包只保留启动页、首页 tabBar 和公共组件。highlight.js的 CSS 文件单独打成一份微信小程序平台专属的样式文件压缩后能控制在几十 KB。第二自定义分享。热词里提到“uniapp 自定义分享好友”和“uniapp onshareappmessage 被全局方法覆盖”。这是小程序端的经典问题——如果在页面上定义了onShareAppMessage全局 App.vue 里入口的onShareAppMessage不会生效如果页面没定义又只能走全局默认分享。我最终的方案是封装一个useSharecomposable在每个需要分享功能的页面里统一调用保证分享配置可控// composables/useShare.ts export function useShare(options?: () ShareOptions) { const defaultShare () ({ title: AI 智能问答助手, path: /pages/index/index, imageUrl: }) // #ifdef MP-WEIXIN onShareAppMessage(() options?.() ?? defaultShare()) onShareTimeline(() options?.() ?? defaultShare()) // #endif }这样页面里只需要一行useShare()就接入分享能力不会再有全局覆盖的冲突问题。第三手机软键盘遮挡查询内容。在小程序里的表现是chat 对话页面输入框聚焦后输入框被键盘顶起但消息区域底部的内容被遮挡。除了前面说的监听键盘高度加 padding 外还要检查一下page-meta是否配置了root-font-size某些情况下这是导致布局异常的元凶。7.3 常见问题排查速查表问题现象可能原因解决方案小程序端 rich-text 渲染的 HTML 样式不生效rich-text 默认样式隔离标签选择器无效使用行内样式或:host选择器避免标签级 CSSApp 端图片上传成功后不显示缩略图本地路径为临时路径重启后失效保存时复制到持久化目录_doc/或_downloads/流式请求在 iOS 上卡顿iOS WebView 对 SSE 流处理有缓冲延迟在 fetch 响应头加Cache-Control: no-cache或改用 WebSocket 通道深色模式下代码块背景不对代码高亮主题色是固定值未跟随变量给代码块容器套一层 CSS 变量控制背景色小程序端 scroll-view 添加 padding 后滚动位置偏移小程序 scroll-view 的 offsetHeight 不包含 padding改用 margin 内部子容器实现间距H5 端会话存储 key 与小程序不同步H5 localStorage 与小程序 storage 机制不同封装统一 storage 层屏蔽平台差异7.4 性能调优与体验细节打磨最后分享几个我在这个项目里实际用到的优化手法价值密度很高。消息列表图片懒加载。问答里出现的网络图片不一定都需要立即加载。我给MessageBubble里的图片加了一个IntersectionObserver小程序端用uni.createIntersectionObserver只有滚动到可视区域才设置src避免一次加载大量外链图片导致白屏或流量浪费。请求队列与并发限制。如果用户在一条 AI 回复还没结束时又发送了下一条消息我的策略是禁止同会话并发——新消息发送时自动终止当前流式请求再把新消息追加进去。这样后端不至于被重复请求打爆前端状态管理也更干净。离屏缓存与页面保活。H5 端从会话列表回到详情页时如果状态还在不重新请求历史数据直接复用 Pinia 内存状态。App 端如果用户切走后台再回来延长心跳保活时间避免会话内容因为页面被销毁而丢失。序列化安全。存到 storage 的消息内容如果包含代码块里的引号、换行、特殊字符JSON.parse 时容易出问题。我的方案是对消息内容做 Base64 编码后再存储取出来再做解码。多了一层转换但换来了稳定性。8. 遇到过的深坑与经验总结8.1 小程序端 rich-text 与 v-html 的本质差异这是整个项目里踩得最深的一个坑。开发前期我在 H5 端调试时一切正常v-html 直接把 Markdown 渲染出的 HTML 插入 DOM样式、事件都很自然。但一到微信小程序端就翻车了rich-text 组件不识别节点上的事件绑定所有a点击、复制按钮、图片预览全部失效。后来我把渲染策略调整为“全后端化”思路服务端渲染好完整的 HTML 字符串前端只负责把它喂给 rich-text。事件交互重新用“扩展点击区”的方式处理——在 rich-text 外部包一层 view 捕获点击通过tap事件里的 dataset 判断用户点击的是不是链接或图片function handleMessageTap(e: any) { const dataset e.currentTarget?.dataset if (dataset?.link) { // 拦截链接点击用 webview 打开 window.open(dataset.link) } if (dataset?.type image) { // 打开预览大图 uni.previewImage({ urls: [dataset.src] }) } }这里要说明一下我把链接提取出来放到了消息气泡下方的“引用来源”区域作为显式的可点击链接展示点击后跳转浏览器打开。rich-text 内部不再依赖事件所有链接都有对应的事件处理。8.2 数据一致性AI 输出内容的安全处理AI 生成内容在面对一些特殊符号时可能包含未闭合的 HTML 标签或危险脚本。H5 端如果直接渲染到 v-html会有 XSS 风险。我做了两道防线第一道防线后端在流式返回时就对内容做了净化把script、iframe、img onerror这类危险模式替换为纯文本。第二道防线前端渲染前再跑一遍白名单过滤。用了一个轻量级的 HTML 清洗函数只允许p/br/strong/em/code/pre/blockquote/ul/ol/li/table/thead/tbody/tr/th/td/h1-h6/a/img白名单标签其他标签一律转义为文本。安全过滤不能过度否则会误伤正常的 Markdown 内容。我的经验是先解析 Markdown 再过滤 HTML因为 Markdown 语法转 HTML 是可控的过滤规则只针对这条链路里可能出现的脏数据这样既能保证体验也能守住安全底线。8.3 热更新与灰度发布考量这套 AI 问答助手是面向 C 端的后续不可避免要频繁迭代提示词模板或者修复渲染组件的 bug。自然语言大模型的上下文管理直接写死在客户端里每次调整都要发版太痛苦。我这边的做法是把系统提示词、模型参数、风控关键词、UI 文案都放到远程配置中心客户端启动时拉取一次缓存到本地。紧急情况下可以远程切换到备用模型接口不需要用户升级 App。当然远程配置要给自己留一个“降级兜底”如果配置拉取失败使用内置的默认配置保证离线状态下基础问答仍可用。最后一个实操建议多端开发时永远先用 H5 调试核心逻辑再逐个端适配。H5 的调试效率最高等逻辑稳定了再处理小程序和 App 的兼容性。这个项目我用下来纯业务代码在三个端大概有 80% 是共用的剩下 20% 是平台特有条件编译这部分越早明确越省事。