☰
TaoToken 实战:uniapp 光标跟随与打字机效果配置指南
2026/9/29 6:34:11 网站建设 项目流程

1. uniapp 聊天输入场景里,光标跟随和打字机为什么总打架

做 uniapp 聊天或客服输入框时,很多人会碰到一个很别扭的现象:文字一个字一个字往外蹦,但那个代表“正在输入”的光标要么停在原地不动,要么直接飘到屏幕左上角,甚至干脆消失。你明明写了打字机定时器,也写了光标元素,可两者就是不同步。

这个问题的本质是:打字机效果控制的是文本内容的增量渲染,而光标跟随控制的是光标元素相对文本末尾的坐标。文本每增加一个字,末尾位置就变了,但光标元素的位置是上一次计算出来的,没有跟着刷新,于是视觉上就脱节了。

适合谁看:正在用 uniapp 做 AI 对话、客服自动回复、流式输出展示的前端同学。你需要对 Vue 的 ref、DOM 节点遍历、Range API 有基本了解,但不需要很深的底层功底。

我试过把光标写成一个绝对定位的小圆点,用transform: translate(x, y)去追文本末尾。核心难点在于:怎么在每次文本更新后,准确拿到“最后一个有内容的文本节点”的位置。下面把可复制的配置、定时器骨架和 settings.json 关键项都拆开讲,最后给真机验证步骤。

2. TaoToken 前置:把流式输出接进来

打字机效果的数据源通常来自大模型接口。如果你还在用本地 mock 字符串,可以先接一个真实可用的流式接口,这样光标跟随才有实际意义。

TaoToken 提供兼容 OpenAI 风格的接口,模型对话、编码计划、控制台和密钥管理都有独立入口。接入前先在控制台创建 API Key,然后按文档拼请求。

  • 官网入口:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
  • API 基地址:https://taotoken.net/api
  • 模型对话页:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite
  • 控制台:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite
  • API Keys:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite
  • 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
  • Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite

注意:API Key 只放在服务端或本地开发环境,不要硬编码进前端仓库。uniapp 打包成 App 后,前端代码是可被反编译的。

拿到 Key 后,请求体大致长这样,stream: true是流式输出的开关:

{ "model": "gpt-4o-mini", "stream": true, "messages": [ { "role": "system", "content": "你是一个客服助手" }, { "role": "user", "content": "帮我查一下订单状态" } ] }

流式返回的每一段是 SSE 格式,前端用fetch的ReadableStream逐块读取,拼出增量文本,再喂给打字机逻辑。这样光标跟随才有真实的“逐字推进”场景。

3. 可复制配置:input 光标定位 + 逐字输出定时器骨架

3.1 模板结构:容器、文本、光标三层

光标要跟随,必须有一个相对定位的容器作为坐标系原点。文本渲染组件放在容器内,光标用绝对定位加transform移动。

<template> <view class="container" ref="contentRef"> <u-parse :content="nodeText" ref="textRef"></u-parse> <view class="cursor" v-show="cursorShow" :style="{ transform: `translate(${x}px, ${y}px)` }" ></view> </view> </template>

这里contentRef是坐标系容器,textRef是富文本渲染组件。光标用v-show控制显隐,位置靠x、y两个响应式变量驱动。

3.2 样式:光标闪烁动画

光标样式可以自定义,重点是闪烁动画和定位基准。容器必须position: relative,否则光标的绝对定位会跑到页面根节点。

<style scoped lang="scss"> .container { position: relative; width: 100%; height: 100vh; box-sizing: border-box; padding: 30rpx 20rpx; .cursor { position: absolute; left: 10rpx; top: 10rpx; width: 30rpx; height: 30rpx; background-color: #000; border-radius: 50%; animation: cursorAnimate 0.5s infinite; } @keyframes cursorAnimate { 0% { opacity: 0; } 50% { opacity: 1; } 100% { opacity: 0; } } } </style>

3.3 逐字输出定时器骨架

打字机的核心是“按索引切片 + 延时”。不要用setInterval直接改文本,因为流式数据到达节奏不均匀,容易和光标刷新错位。用async/await加delay更可控。

methods: { delay(time) { return new Promise((resolve) => setTimeout(resolve, time)); }, async typewriter(fullText) { this.cursorShow = true; for (let i = 0; i <= fullText.length; i++) { this.nodeText = fullText.slice(0, i); this.updateCursor(); await this.delay(80); } this.cursorShow = false; } }

i <= fullText.length是为了让最后一个字符也能被渲染出来。每次更新文本后立刻调用updateCursor(),保证光标和文本末尾同步。

3.4 光标定位:Range API 找末尾坐标

这是整个方案最关键的一步。思路是:在最后一个文本节点后面插入一个零宽字符的临时节点,用Range拿到它的坐标,再减去容器的坐标,得到相对偏移。

updateCursor() { const lastTextNode = this.getLastTextNode(this.$refs.textRef, 1); const tempText = document.createTextNode('\u200B'); if (lastTextNode) { lastTextNode.parentNode && lastTextNode.parentNode.appendChild(tempText); } else { this.$refs.textRef && this.$refs.textRef.$el.appendChild(tempText); } const range = document.createRange(); range.setStart(tempText, 0); range.setEnd(tempText, 0); const rect = range.getBoundingClientRect(); const textRect = this.$refs.contentRef && this.$refs.contentRef.$el.getBoundingClientRect(); if (textRect) { this.x = rect.left - textRect.left + 10; this.y = rect.top - textRect.top; } tempText.remove(); }

getLastTextNode负责深度遍历,跳过空文本节点,找到最后一个有内容的文本节点:

getLastTextNode(node, index = 1) { if (index === 1) { node = node.$el; } if (!node) return null; if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) { return node; } for (let i = node.childNodes.length - 1; i >= 0; i--) { const childNode = node.childNodes[i]; const textNode = this.getLastTextNode(childNode, index + 1); if (textNode) return textNode; } return null; }

3.5 settings.json 关键项

在 uniapp 项目里,pages.json或manifest.json的配置会影响 DOM 渲染和真机表现。如果你用的是 H5 端,pages.json里对应页面的style建议加上:

{ "path": "pages/chat/chat", "style": { "navigationBarTitleText": "客服对话", "app-plus": { "softinputMode": "adjustResize" } } }

adjustResize让软键盘弹出时页面重新布局,避免光标坐标基于旧视口计算而偏移。App 端还要在manifest.json里确认webview渲染模式,V3 编译器下 DOM 操作更接近标准浏览器行为。

4. 验证请求:真机跑通流式输出与光标同步

4.1 先验证接口能流式返回

在接入打字机之前,先用一个最小请求确认流式通道是通的。可以用 Node 脚本或 Postman 发请求,观察是否逐块返回。

async function streamChat() { const res = await fetch('https://taotoken.net/api/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer YOUR_API_KEY' }, body: JSON.stringify({ model: 'gpt-4o-mini', stream: true, messages: [{ role: 'user', content: '用一句话介绍uniapp' }] }) }); const reader = res.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 }); const lines = buffer.split('\n'); buffer = lines.pop(); for (const line of lines) { if (line.startsWith('data: ') && line !== 'data: [DONE]') { const json = JSON.parse(line.slice(6)); const delta = json.choices[0]?.delta?.content || ''; process.stdout.write(delta); } } } }

跑通后你会看到文字一段一段打印出来,说明流式通道正常。

4.2 真机验证光标跟随

把上面的typewriter和updateCursor接进页面,在 H5 和 App 真机分别验证。重点观察三个点:

第一,文本每增加一个字,光标是否立刻右移或换行到下一行行首。第二,文本换行时,光标是否跳到新行末尾,而不是留在上一行。第三,软键盘弹出后,光标是否还在正确位置。

如果 H5 正常但 App 偏移,大概率是getBoundingClientRect在 webview 里的坐标系和容器不一致,可以在updateCursor里打印rect和textRect对比。

4.3 成功结果长什么样

正常同步时,光标会像打字机上的指示器一样,始终贴在最后一个字符后面,闪烁节奏稳定。文本停止输出后,光标隐藏或停在末尾。整个过程中输入框焦点不丢失,用户可以直接接着输入。

5. 本篇常见错排查

5.1 光标停在左上角不动

最常见的原因是容器没有position: relative,或者contentRef的 ref 拿不到$el。检查模板里ref="contentRef"是否写在根view上,以及updateCursor里是否做了空值判断。

另一个原因是getLastTextNode返回了null,导致临时节点被追加到textRef.$el上,而textRef.$el的坐标系和容器不一致。可以在函数里加日志确认返回的节点。

5.2 光标位置偏移一个字符

通常是rect.left - textRect.left之后没有加上光标自身宽度的一半,或者 padding 没算进去。上面代码里+ 10是经验值,实际要根据你的padding和光标尺寸调整。建议把偏移量抽成变量,方便调试。

5.3 打字机速度忽快忽慢

如果数据源是流式接口,不要每收到一个 chunk 就立刻渲染,而是把 chunk 追加到一个缓冲区,打字机从缓冲区按固定节奏取字。否则网络快时文字瞬间刷完,网络慢时又卡住。

// 缓冲区方案 this.buffer += delta; // 打字机循环从 buffer 里 slice

5.4 真机上光标闪烁但文本不更新

检查nodeText是否被u-parse正确解析。有些富文本组件对不完整的 HTML 标签容错差,切片到一半的<p>会导致渲染异常。建议先按纯文本渲染验证,再换成富文本。

5.5 软键盘弹出后光标错位

在pages.json里确认softinputMode为adjustResize。如果还是错位,可以在键盘弹起事件里延迟 100ms 再调一次updateCursor,等布局稳定后重新计算。

6. 接入与排障入口

如果你在接流式接口时遇到鉴权或返回格式问题,先去 API Keys 页面确认 Key 状态,再对照接入文档检查请求头和 body 字段。模型对话页可以直接测试模型是否正常响应,适合在写代码前先确认通道可用。

  • API Keys:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite
  • 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
  • 模型对话:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite

如果你打算长期做编码类 Agent 或批量调用,Coding Plan 的额度模型更适合持续开发场景:

  • Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite

光标跟随这件事,调通一次之后就是纯配置问题。真正容易翻车的是流式数据的节奏和真机坐标系差异,把这两块用日志盯住,基本不会再返工。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询