资讯动态

Vue+UniApp全端AI问答助手实践:Markdown渲染与流式输出

发布时间:2026/9/9 14:00:34 来源:尧图企业网站定制
最近在把AI问答助手从纯Web端迁移到全端H5 微信小程序 App的时候我把Vue、UniApp、Markdown渲染、公式展示、多模态交互这些东西挨个重新撸了一遍。越到后面越觉得AI 跨端这个组合的难点根本不在AI模型本身而在工程侧的细节治理流式输出在三个端上的行为完全不一样Markdown渲染各自有坑键盘弹起时消息列表的表现也让人头大。这篇就把我完完整整做下来的过程包括选型、架构、渲染方案、多模态交互、打包上架和踩过的坑全部写出来。如果你正准备用Vue UniApp做一款支持Markdown和公式的AI问答助手或者已经在做但卡在某一步这篇文章应该能给你省不少时间。1. 为什么用Vue UniApp做全端AI助手选型背后的真实算账1.1 一套代码三端覆盖成本核算与取舍选UniApp之前我其实认真评估过Flutter、React Native和纯原生三套方案。我们团队背景是Vue手上已经有成熟的Web端AI对话产品目标很明确要小程序和App且必须尽量复用Web端的业务逻辑与UI思维。这个时候UniApp的优势就很直接了——Vue语法天然熟悉组件模型和响应式机制跟Web端几乎一致团队成员基本不需要学习成本就能上手。Flutter的Dart语法和自绘引擎很强但对我们这种AI对话功能为主、原生能力辅助的工具型应用来说属于过度投入。另外一个很实际的原因是微信小程序生态。国内做AI助手绕不开微信小程序这个分发渠道UniApp对微信小程序的适配做得比较深包括分包、分享、隐私弹窗等机制都有对应的API封装。对比下来我在选型文档里写得很直白Flutter适合重交互重绘制的应用但AI对话本身就是列表 输入框 流式文本UniApp完全顶得住React Native在JS桥接成本上又比UniApp多一层工具链维护。最终我们选择Vue 3 UniApp Vite这套组合核心考量有三点开发速度、团队迁移成本、微信生态覆盖度。从后来实际开发看这个决策是对的。三端共享了大概90%的代码只有录音、流式请求、隐私合规这些部分做了条件编译拆分。如果你也面临类似选型我建议你把团队的既有技术栈放在第一位而不是单纯比框架性能。工具链本身的差距远没有团队学习成本带来的差距明显。1.2 整体的数据流设计整个AI问答助手的数据流我拆成了四层UI层对话页面、状态层Pinia、请求层普通请求 SSE流式请求、业务层会话管理、内容解析。UI层只负责渲染消息数组状态层保存当前会话的消息列表和loading状态请求层负责与后端AI网关交互业务层负责Markdown解析、多模态消息组装、会话持久化。单个消息的结构我设计成了统一的对象interface ChatMessage { id: string role: user | assistant | system type: text | image | audio | multi content: string images?: string[] // 多模态图片URL reasoning?: string // 思维链内容可选择渲染 createdAt: number status: streaming | done | error }这样设计的用意很明确AI回答的内容在流式返回时content字段是不断追加的Markdown字符串多模态场景下用户发送的消息里可以同时携带文本和图片type标记为multiimages字段保存压缩后的图片地址。所有消息对象都放在Pinia的currentSession里每追加一次内容就触发一次响应式更新配合自定义滚动逻辑就能实现打字机效果。这里有一个特别容易被忽略的点AI回答的Markdown内容是逐渐完整的。流式输出过程中代码块可能只出来了半个表格可能只渲染了表头。所以在UI层我要么等每个chunk推完再整体解析渲染要么对半截Markdown做容错处理。我后面会在流式输出章节专门讲这个问题的处理思路这里先记住一个结论不要在流式过程中反复调用完整Markdown解析性能会很难看。2. 工程初始化与多端请求层封装跨端问题的源头治理2.1 脚手架搭建Vue 3 Vite与目录约定UniApp项目有两种创建方式HBuilderX图形化创建或者命令行CLI创建。我建议有一定工程习惯的团队直接用CLI因为可以纳入Git管理、配合CI/CD依赖也更好控制。用下面的命令初始化npx degit dcloudio/uni-preset-vue#vite my-ai-chat cd my-ai-chat npm install npm run dev:mp-weixin初始化后目录我会做一层约定避免后续多端逻辑乱掉src/ api/ // 接口请求层纯业务无关的HTTP封装 components/ // 通用组件 pages/ // 页面 store/ // Pinia状态 utils/ // 工具函数 static/ // 静态资源其中utils里我会专门放一个platform.ts统一导出当前端类型的判断配合UniApp的条件编译注释使用。条件编译是UniApp最核心的能力之一它允许你在同一份代码里写不同端的逻辑编译器会自动剔除不属于当前端的部分// #ifdef MP-WEIXIN import { wechatStreamRequest } from ./request-wechat // #endif // #ifdef H5 import { h5StreamRequest } from ./request-h5 // #endif // #ifdef APP-PLUS import { appStreamRequest } from ./request-app // #endif这套机制帮我避免了很多这个API微信小程序有、H5没有的兼容问题。关于路由参数获取UniApp的页面在onLoad生命周期里能拿到options对象比如?sessionId123就能在进入对话页时恢复历史会话。但有个小坑H5端刷新页面后onLoad还会再触发一次如果此时在回调里写了重新拉列表的逻辑会导致会话被重复初始化我做了个safeLoad标记来避免。2.2 请求层封装普通请求与SSE流式请求的并存方案AI问答助手的大部分请求还是普通POST但最核心的对话接口必须是流式的否则用户会盯着空白页面等好几秒。我先封装了一个最基础的普通请求函数// src/api/request.ts const BASE_URL import.meta.env.VITE_API_BASE_URL export function requestT(options: { url: string method?: GET | POST data?: Recordstring, unknown header?: Recordstring, string }): PromiseT { return new Promise((resolve, reject) { uni.request({ url: BASE_URL options.url, method: options.method || POST, data: options.data, header: { Content-Type: application/json, Authorization: getToken(), ...options.header }, success: (res) { if (res.statusCode 200 res.statusCode 300) { resolve(res.data as T) } else { reject(new Error(HTTP ${res.statusCode})) } }, fail: (err) reject(err) }) }) }流式请求的封装则麻烦得多。三端对SSE的支持差异很大H5好办直接fetch配合ReadableStream就能读微信小程序需要wx.request开启enableChunkedApp端则需要用plus.net的原生能力。我为了让上层代码统一回调固定暴露onChunk和onDone// src/api/stream.ts export interface StreamOptions { url: string data: Recordstring, unknown onChunk: (text: string) void onDone: (fullText: string) void onError: (err: Error) void } export function streamRequest(options: StreamOptions) { // #ifdef H5 h5Stream(options) // #endif // #ifdef MP-WEIXIN wechatStream(options) // #endif // #ifdef APP-PLUS appStream(options) // #endif }微信小程序的enableChunked有个反直觉的点success回调在流结束时才触发流数据是通过onChunkReceived每次返回一段ArrayBuffer。你需要自己维护一个TextDecoder来拼接字符串并按SSE协议以\n\n分隔事件data:开头的行才是有效payload解析function wechatStream(options: StreamOptions) { const decoder new TextDecoder(utf-8) let buffer // 这里是简化写法实际会放到 requestTask 上 const task wx.request({ url: BASE_URL options.url, method: POST, data: options.data, enableChunked: true, header: { Content-Type: application/json }, success: () { options.onDone(buffer) }, fail: (err) options.onError(new Error(err.errMsg)) }) task.onChunkReceived((res) { const text decoder.decode(res.data, { stream: true }) buffer text // 逐行解析 SSE 事件 const lines buffer.split(\n\n) buffer lines.pop() || lines.forEach((block) { const dataLine block .split(\n) .find((line) line.startsWith(data:)) if (dataLine) { const payload dataLine.slice(5).trim() if (payload [DONE]) return options.onChunk(payload) } }) }) }2.3 环境变量与多端条件编译开发过程中一定要从一开始就规划好环境变量我建了.env.development、.env.test、.env.production三个文件分别配置后端地址、OCR服务地址、文件上传地址。注意UniApp Vite的环境变量必须以VITE_开头才能在代码里通过import.meta.env读到。小程序端没有process.env的概念所以条件编译和公共配置要绕开Node API。这里有一个实际教训微信小程序的request合法域名是HTTPS且ICP备案的但本地开发时你可以勾选开发者工具里的不校验合法域名真机和预览时就必须配置线上域名。我把这个配置写到了条件编译里开发环境自动走本地代理生产环境走正式域名避免上线前一晚到处挖域名。3. Markdown与数学公式渲染AI回答的门面工程3.1 为什么AI回答必须在端上渲染Markdown如果你用过任何一个成熟的AI问答产品就会发现回答几乎都是排版好的代码有高亮、列表有缩进、表格有边框、数学公式是真正排版出来的而不是一行裸文本。原因很简单大模型输出的是结构化文本天然包含Markdown标记如果不解析渲染用户看到的就是一堆#、**、符号体验会非常糟糕。尤其当回答里包含代码块和公式时候纯文本完全没法看。所以在AI问答助手的工程里Markdown渲染不是锦上添花而是基本盘。我统计过我们线上的对话内容超过70%的AI回答包含至少一个代码块或公式片段。这就意味着渲染层必须稳定支持标题、列表、引用、表格、行内代码、代码块、图片、数学公式并且在不同端的表现要尽量一致。3.2 小程序端用towxml渲染Markdown的完整接入H5端渲染Markdown很简单markdown-it或marked解析HTML之后v-html就行。但微信小程序没有DOM没法直接操作HTML字符串这时候一个成熟的第三方库towxml就派上用场了。towxml底层也是把Markdown解析成JSON树或HTML字符串然后通过自带的组件递归渲染到小程序上支持代码高亮、表格、公式、流程图甚至echarts图表。我在项目中接入towxml的方式如下把towxml完整目录放到src/components/towxml下。在pages.json里注册组件或直接在页面模板里引用。拿到AI返回的Markdown字符串后调用towxml(markdown, markdown)生成渲染数据。将渲染好的HTML字符串传给towxml nodes.../towxml组件。template towxml :nodeshtmlContent :themeisDark ? dark : light / /template script setup langts import Towxml from /components/towxml/towxml.vue import { computed } from vue const props defineProps{ markdown: string }() // 这里用towxml自带方法解析 const htmlContent computed(() { if (!props.markdown) return // 补充把markdown解析为towxml需要的数据结构 return parseMarkdown(props.markdown) }) /script这里有个必须强调的点towxml的解析方法在微信小程序端运行时有环境依赖不能在App的Service层直接调用一般放在页面内执行另外它默认的样式表在深色模式下容易看起来突兀需要覆盖主题变量做适配。我后来没有追求用towxml的完整功能而是只保留了markdown解析和代码高亮公式部分单独接了自己更可控的KaTeX方案下面会讲换来的是包体积减少了约三分之一。3.3 公式渲染KaTeX方案与MathJax取舍AI问答里经常出现数学公式比如请解释一下泰勒公式求导的链式法则这时候回答里通常是LaTeX格式的公式。我对比了KaTeX和MathJaxKaTeX是快、轻、输出高质量MathJax是全功能、慢、适合复杂公式。在移动端场景性能是第一位的所以我选了KaTeX并把KaTeX的CSS和JS都打包进H5端小程序端则用towxml自带的公式解析能力。具体的处理逻辑是在后端生成回答时约定公式使用$...$表示行内公式$$...$$表示块级公式前端拿到文本后在渲染Markdown之前先对公式片段做保护性处理避免Markdown解析器把公式里的*、_当成强调语法。我在utils里写了一个预处理函数// src/utils/formula.ts export function protectFormula(text: string): string { // 先保护块级公式再保护行内公式 const blocks: string[] [] const protectedText text .replace(/\$\$([\s\S]?)\$\$/g, (match) { blocks.push(match) return FORMULA_BLOCK_${blocks.length - 1} }) .replace(/\$([^$\n]?)\$/g, (match) { blocks.push(match) return FORMULA_INLINE_${blocks.length - 1} }) // 渲染完成后替换回来 return protectedText }在H5端块级公式最终会渲染成一个div.katex-display需要保证行内公式和文字对齐。移动端小屏最容易出的问题是长公式溢出我加了一行CSS.katex-display { overflow-x: auto; overflow-y: hidden; padding: 4px 0; }这样长公式可以横向滑动不会撑破卡片。3.4 代码高亮与表格样式那些被忽略的深坑代码高亮是Markdown渲染里最容易得到看起来不错但一深究就露馅的部分。towxml自带highlight.js但默认主题在深色模式下对比度很差。我换成github-dark主题的CSS变量但highlight.js在微信小程序里不能直接用link引CSS需要把主题样式转成内联或复制进组件的style里。这是一个很费时间的体力活我把它整理成了一个独立样式文件code-theme.scss方便全局切换。另一个深坑是表格。AI回答里常常输出Markdown表格但表格在微信小程序里默认不会自动换行一旦某一列很长整个表格会把屏幕撑爆。我加的兜底样式是这样的table { display: block; width: 100%; overflow-x: auto; white-space: nowrap; border-collapse: collapse; }同时给图片加上懒加载和点击预览。图片可以来自AI回答里的远程URL我用uni.previewImage绑定点击事件并且给image组件设置lazy-load。在App端还要注意图片域的合法域名与缓存策略不然会出现真机能看到图小程序上看不到的灵异现象。4. 多模态交互实战图片理解、语音输入与流式体验4.1 图片上传与多模态理解从chooseImage到识别结果多模态交互是现在AI助手的标配。所谓多模态直观来说就是用户不仅能打字还能发图片、发语音AI也能看图理解、识别语音。我在这个项目里实现的方式是用户输入区提供相册/拍照按钮选择图片后先压缩再上传到对象存储拿到URL后随文本内容一起提交到后端大模型接口。uni.chooseImage封装得比较直观但它返回的是本地临时路径需要再配合uni.uploadFile把文件传到自己的OSS或服务端。我提一下压缩这一步微信小程序里uni.compressImage可以指定压缩质量我常用quality: 70既能减小体积又不会太影响OCR或视觉理解效果。AI接口的多模态请求体一般是这样的结构{ model: qwen-vl-max, messages: [ { role: user, content: [ { type: text, text: 这张图里有什么异常 }, { type: image_url, image_url: { url: https://xxx/1.jpg } } ] } ] }如果你不想引入原生多模态模型也可以走OCR 文本理解的组合先用OCR接口把图片里的文字识别出来拼接进提问文本再送给普通大模型。这个方案对拍题、票据识别这类场景基本够用但遇到需要理解图片空间关系的问题就抓瞎了所以我还是接了真正的多模态模型。4.2 语音输入微信小程序与App的双轨实现语音输入是沉浸式体验的一个加分项但跨端实现完全不同。微信小程序里可以用wx.getRecorderManager()App端则是uni.getRecorderManager()。我封装了一个startVoiceInput函数统一暴露录音结束后的音频临时路径再由上传接口转成文本最终回填到输入框。需要注意两点一是录音权限需要弹窗申请微信小程序会在调用RecorderManager.start()时自动弹但App端需要在manifest.json里声明android.permission.RECORD_AUDIOiOS需要加NSMicrophoneUsageDescription描述二是长录音的静音检测如果用户停顿太久应该自动结束录音否则用户会困惑怎么还在录。我设置的静音判断是2秒无音量变化就自动停止这个阈值可以根据场景调。语音识别这块我接的是云厂商的短语音识别接口。录音文件格式在小程序端是mp3或aacApp端可能是amr提交前要根据接口支持的格式做转换。如果格式不匹配很常见的报错是识别失败音频格式错误。4.3 流式输出的两种方案真实SSE与模拟打字机流式输出是AI问答助手体验的灵魂。一个95分的AI助手和60分的AI助手最大区别往往就是字是蹦出来的还是憋出来的。我们线上方案分两层网络层优先走真实SSE流后端不支持或端上兼容性有问题时降级为完整返回 打字机模拟。真实SSE的技术细节我在2.2节已经讲了请求层的封装。这里重点说业务层如何处理逐渐完整的Markdown。我的做法是每个SSE chunk到达后不直接整体重新解析Markdown而是把文本追加到message.content然后用一个定时器做渲染节流——保证最多每150毫秒调用一次markdown渲染。这样既能看到流畅打字效果又不会让解析线程被频繁调用拖垮。降级方案是完整JSON返回后前端用setInterval每隔30毫秒往content里追加2-4个字符模拟打字机。为了看起来自然我会优先在标点符号处多切一点避免一个字一个字蹦的机械感。实测下来用户对这个模拟方案的满意度也不低毕竟大家关心的是回答有没有用而不是底层走不走SSE。5. 沉浸式对话体验的细节打磨滚动、键盘、会话持久化5.1 软键盘顶起与输入框联动scroll-view的正确姿势沉浸式很大一部分来自输入和输出的无缝衔接。微信小程序里最容易翻车的就是软键盘键盘弹起后输入框被顶上去但消息列表没有跟着滚动或者底部最新的消息被键盘遮住。我在page.json里设置disableScroll: true同时不依赖页面的原生滚动而是用一个scroll-view承载消息列表再配合scroll-into-view实现滚动定位。关键代码如下scroll-view classmessage-list scroll-y :scroll-into-viewscrollIntoId :scroll-with-animationtrue scrolltoupperloadMoreHistory view v-formsg in messages :idmsg-${msg.id} classmessage-item !-- 消息内容 -- /view /scroll-view每次新消息或流式内容变化时把scrollIntoId更新为最后一条消息的id。这里有个性能问题如果每条流式chunk都触发一次scroll-into-view列表会频繁抖动。我在代码里用了一个requestAnimationFrame 节流function scrollToBottom() { if (scrollTicking) return scrollTicking true requestAnimationFrame(() { scrollIntoId.value msg-${messages.value[messages.value.length - 1]?.id} scrollTicking false }) }5.2 消息存储与会话恢复本地缓存的幂等设计AI问答助手不能每次打开都从空白开始否则用户没法查历史记录。我用uni.setStorageSync把每轮会话按sessionId维度存储。考虑到小程序本地存储有10MB限制我不会存完整消息列表而是存精简的{ id, role, content }并且每条消息限制长度内容超长会被截断为点击加载完整内容。会话恢复的幂等设计容易被忽略。我遇到过的问题是用户从会话列表点进详情页onLoad里拉取本地历史同时异步从云端拉取最新消息列表两个请求竞态导致UI出现同一批消息渲染两遍。解决方法是给每条消息增加tempId渲染时按tempId去重另一个方案是本地历史只用于秒开占位等云端数据返回后整体替换并加一个loading标识。我最后采用的是本地秒开 云端刷新后替换效果最稳。切换会话时我会把上一个会话全文保存到本地避免用户划走一会儿回来数据没了。如果应用在后台被系统杀死重新打开后也要能通过getStorageSync恢复最近一次的会话ID直接定位到上一次浏览位置。这个体验对高频用户很重要。5.3 深色模式与动效优化从能用变成好用深色模式不是简单的背景翻转AI对话页面里消息气泡、代码块、公式卡片、输入区的颜色都要重新设计。我定义了CSS变量page, view { --bg-primary: #ffffff; --bg-secondary: #f5f6f7; --text-primary: #1a1a1a; --text-secondary: #8a8f99; --code-bg: #f6f8fa; } .dark { --bg-primary: #111418; --bg-secondary: #1d2128; --text-primary: #e4e6eb; --text-secondary: #9ca3af; --code-bg: #1d2128; }在App.vue里根据uni.getSystemInfoSync().theme或用户手动选择来添加dark类。这里有个经验代码块的深色模式最不能省因为AI回答里代码占比很高如果代码块在深色模式下变成白底黑字整个沉浸感瞬间崩塌。我在towxml组件的容器上同步绑定theme属性让它内部代码高亮主题跟着变。动效方面我做了三个轻量级动画消息进入时的淡入上移、loading时三个点的呼吸闪烁、图片卡片点击时的放大预览。这些用CSS transition就够了不需要引入动画库。一个反直觉的坑是小程序端transition在scroll-view内部偶尔会失效原因是列表里的元素数量多时新插入元素没有触发layout解决办法是给消息项加transform: translateZ(0)强制开启合成层。6. 打包上架阶段官方文档没讲的那些坑6.1 微信小程序合法域名与隐私弹窗微信小程序上架前的配置流程里最容易卡住的是request合法域名。AI对话业务往往依赖多个域名主接口域名、OSS文件域名、OCR域名、WebSocket域名全部要加到小程序后台的开发管理 服务器域名里。域名必须是HTTPS且ICP备案否则真机预览会一直报url not in domain list。另外从2023年起微信小程序新增了用户隐私保护指引要求。如果你的App会调用麦克风、摄像头、相册、位置等隐私接口必须在后台声明对应隐私项并且在小程序代码里通过wx.requirePrivacyAuthorize()主动发起隐私授权。这里有一个设计细节当你做隐私弹窗时用户如果拒绝不应该直接卡死在首屏。按照不同意就退出的思路在微信小程序里我不建议强制退出因为小程序被退出后会回到会话列表体验很怪。更好的做法是只展示关键功能不可用并提供重新授权按钮。6.2 Android/iOS上架权限声明、签名与软著App端上架是另一套流程。先说Android主流的安卓应用市场华为、小米、OPPO、vivo都要求提供软件著作权证书在开发期就要提前申请软著不然等产品好了再去申请周期可能要1-2个月。此外manifest.json里的权限声明要克制只声明实际用到的权限。我用到的权限包括网络、存储、录音、相机。如果声明了不必要的权限应用市场上架审核时会被打回要求说明用途。iOS端上架则注意两点一是隐私清单苹果要求说明收集的数据类型和使用目的二是签名证书与Bundle ID的匹配很多团队在Android调试时用的是测试证书上架时忘了切换生产证书导致无法提审。我踩过最尴尬的坑是用测试描述文件打包结果TestFlight一直报缺少隐私清单查了半天才发现是证书配置不对。这里补一个很多人在热搜里找的代码场景iOS端当用户不同意隐私政策及用户协议时退出App。在App端iOS没有直接的退出应用API但可以通过plus.runtime.quit()实现退出。前提是你必须判断当前端是App否则在H5或小程序上调用会报错function handlePrivacyReject() { uni.showModal({ title: 提示, content: 您未同意用户协议和隐私政策将无法使用本应用, confirmText: 退出, cancelText: 暂不退出, success: (res) { if (res.confirm) { // #ifdef APP-PLUS plus.runtime.quit() // #endif } } }) }6.3 常见编译错误与解决速查表最后整理一份我在这个项目里遇到的高频问题的排查速查表不一定覆盖所有场景但命中率很高症状根本原因解决方向运行到微信开发者工具没反应HBuilderX与微信开发者工具的服务端口未开启或项目未编译在微信开发者工具设置里打开安全 服务端口重新运行真机上发请求报url not in domain list域名未加入小程序后台合法域名后台添加域名或开发时勾选不校验合法域名代码高亮样式不对或变白底深色模式CSS变量被内联样式覆盖覆盖code-theme里高亮背景变量强制使用CSS变量Cannot read property xxx of undefined在非App端调了plusAPI或调用时机太早所有plus调用包在#ifdef APP-PLUS中并监听plusready图片显示不出来H5正常小程序异常图片域名未加入downloadFile合法域名或图片太大配置downloadFile域名图片压缩后上传录音后语音识别返回空音频格式与ASR接口要求不符转码为mp3或wav重新提交键盘弹起把输入框顶飞adjust-position与自定义滚动冲突设置adjust-position: false自己计算键盘高度并resize流式输出时页面频繁卡顿Markdown解析被每个chunk触发增加150ms渲染节流用流式缓冲区聚合iOS上架被拒提示隐私权限说明不足缺少NSMicrophoneUsageDescription或NSCameraUsageDescription在manifest或Xcode的Info.plist中填写完整使用描述小程序包体积超过2MBtowxml、highlight.js全量打进主包拆到分包或用按需加载或自定义裁剪towxml功能这张表基本概括了我在整个开发周期里大部分卡壳的地方。每个问题单独拿出来都能写一整篇排查文章这里先留给读者一个排查思路。当你遇到三端表现不一致的情况时记住一句方法论先定位是端能力差异还是自己的代码没做条件编译再检查是外层容器问题还是内层组件样式问题。按这个顺序排查大部分坑都能在20分钟内填平。做完整套项目我个人最大的体会是跨端开发的核心不是写一套代码跑三端这个口号而是把端的差异控制在极小的边界内。UniApp能帮你解决70%的兼容剩下的30%必须靠条件编译、请求层统一、组件选型克制来治理。AI问答助手这个品类特别适合用它来做因为核心交互是文本流 消息列表对原生能力依赖有限但对渲染层的要求又足够高刚好把UniApp的优势发挥出来也把它的小程序生态补齐了。如果你正要起步建议先把H5端跑通、把Markdown和流式体验调好再逐步扩展到小程序和App。按这个顺序你会少走很多弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价