☰
代码展示组件设计:用 TaoToken 统一 Key 打通 Syntax Highlight 与 Copy 交互工程
2026/9/26 17:32:24 网站建设 项目流程

1. 代码展示组件为什么需要统一 Key 通道

做前端文档站或者技术博客的朋友大概率都遇到过这个场景:页面上要展示一段 TypeScript 代码,既要语法高亮好看,又要能一键复制,还得在暗色/亮色主题下都不刺眼。更麻烦的是,如果这个页面背后还要调用大模型来生成示例代码或者做代码解释,那 Key 的管理就成了一个绕不开的工程问题。

我最近在重构一个内部文档站,核心诉求有三个:第一,代码块用 Shiki 做语法高亮,因为它的 TextMate 语法解析比正则方案准确得多;第二,封装一个 CodeBlock 组件,把 Copy 交互做成三态状态机;第三,页面里嵌入的 AI 代码解释功能,通过 TaoToken 统一 Key 来管理多模型调用,避免每个组件各自维护一套 API Key。

TaoToken 在这里扮演的角色是统一 API 通道。你可以把它理解成一个 Key 的集中管理处:前端组件不需要知道具体调的是哪个模型,只需要向同一个 API 端点发请求,由 TaoToken 侧完成模型路由和 Key 的鉴权。这样代码展示组件在需要「解释这段代码」或者「生成示例」时,调用链路是干净的。

适合谁看:正在做技术文档站、组件库文档、或者任何需要展示代码并附带 AI 能力的前端工程师。如果你只用过 Highlight.js 没碰过 Shiki,或者 Copy 按钮还在用document.execCommand裸写,这篇可以跟着走一遍。

2. TaoToken 前置:Key 申请与 API 通道配置

在写组件之前,先把 Key 的事情搞定。TaoToken 的官网入口是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,注册后进入控制台创建 API Key。

具体路径:登录后找到 API Keys 管理页,点「创建新 Key」,复制生成的sk-开头的字符串。这个 Key 就是后续所有模型调用的凭证。

TaoToken 的 API 端点是 https://taotoken.net/api ,注意这个地址不带 UTM 参数,是纯粹的接口地址。你的前端代码里请求模型时,base URL 填这个,路径按 OpenAI 兼容格式拼/v1/chat/completions即可。

关于模型选择,TaoToken 支持多种模型路由。在代码展示组件这个场景里,我建议用轻量级模型做代码解释,因为文档站的 AI 功能通常是辅助性的,不需要顶级推理能力。你可以在控制台的模型列表里选一个响应快的,把模型名称记下来,后面配置里要用。

Key 的安全管理有个基本原则:前端代码里绝对不能硬编码 Key。正确做法是通过环境变量注入,Next.js 项目里放在.env.local:

# .env.local TAOTOKEN_API_KEY=sk-your-key-here TAOTOKEN_BASE_URL=https://taotoken.net/api

然后在服务端路由或者 Server Action 里读取process.env.TAOTOKEN_API_KEY。如果你用的是纯静态站点,那就需要搭一个轻量后端做代理,Key 只存在服务端。

注意:TaoToken 的 Key 权限可以在控制台里限制,建议只开需要的模型权限,不要用全权限 Key 跑前端请求。

3. 可复制配置:Shiki 高亮 + CodeBlock 组件

3.1 Shiki 初始化配置

Shiki 的核心优势是直接复用 VS Code 的语法定义。安装:

npm install shiki

服务端渲染的初始化代码:

// lib/shiki.ts import { createHighlighter, type Highlighter } from 'shiki'; let highlighter: Highlighter | null = null; export async function getHighlighter() { if (!highlighter) { highlighter = await createHighlighter({ themes: ['dark-plus', 'light-plus'], langs: ['typescript', 'javascript', 'tsx', 'jsx', 'bash', 'json', 'python'], }); } return highlighter; } export async function highlightCode(code: string, lang: string, theme: string) { const hl = await getHighlighter(); return hl.codeToHtml(code, { lang, theme }); }

这里只加载了实际用到的语言和主题,避免 bundle 膨胀。Shiki v1 之后支持按需加载,createHighlighter是异步的,适合在服务端组件里调用。

3.2 Copy 交互的 Hook 封装

Copy 按钮的交互逻辑抽成独立 Hook,方便复用:

// hooks/useClipboard.ts import { useState, useCallback } from 'react'; export function useClipboard({ timeout = 2000 }: { timeout?: number } = {}) { const [isCopied, setIsCopied] = useState(false); const copy = useCallback(async (text: string) => { try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); } else { const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); const ok = document.execCommand('copy'); document.body.removeChild(textarea); if (!ok) throw new Error('execCommand failed'); } setIsCopied(true); setTimeout(() => setIsCopied(false), timeout); } catch (err) { console.error('Copy failed:', err); } }, [timeout]); return { isCopied, copy }; }

关键点:window.isSecureContext判断当前是否 HTTPS 或 localhost,非安全上下文下 Clipboard API 会抛异常,所以要有execCommand降级。isCopied为 true 期间按钮 disabled,防止连点导致状态混乱。

3.3 CodeBlock 组件完整封装

// components/CodeBlock.tsx 'use client'; import { useState, useEffect } from 'react'; import { Check, Clipboard } from 'lucide-react'; import { useClipboard } from '@/hooks/useClipboard'; interface CodeBlockProps { code: string; language: string; highlightedHtml: string; filename?: string; showLineNumbers?: boolean; } export function CodeBlock({ code, language, highlightedHtml, filename, showLineNumbers = true, }: CodeBlockProps) { const { isCopied, copy } = useClipboard({ timeout: 2000 }); const [lines, setLines] = useState<string[]>([]); useEffect(() => { setLines(code.split('\n')); }, [code]); return ( <div className="group relative my-6 rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-950 overflow-hidden"> <div className="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900"> <div className="flex items-center gap-3"> {filename && ( <span className="text-sm font-medium text-slate-700 dark:text-slate-300"> {filename} </span> )} <span className="text-xs font-mono uppercase tracking-wider text-slate-500"> {language} </span> </div> <button onClick={() => copy(code)} disabled={isCopied} aria-label={isCopied ? 'Copied' : 'Copy code'} className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-all text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800 disabled:text-emerald-600 dark:disabled:text-emerald-400 focus:outline-none focus:ring-2 focus:ring-blue-500/50" > {isCopied ? ( <><Check className="h-3.5 w-3.5" /><span>Copied!</span></> ) : ( <><Clipboard className="h-3.5 w-3.5" /><span className="hidden sm:inline">Copy</span></> )} </button> </div> <div className="relative flex overflow-x-auto"> {showLineNumbers && ( <div className="select-none border-r border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-950 py-5 pr-4 pl-4 text-right min-w-[3rem]"> {lines.map((_, i) => ( <div key={i} className="text-xs leading-6 text-slate-400 font-mono"> {i + 1} </div> ))} </div> )} <pre tabIndex={0} role="region" aria-label={`Code snippet in ${language}`} className="flex-1 py-5 px-6 outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500/30" > <code className="text-sm leading-6 font-mono" dangerouslySetInnerHTML={{ __html: highlightedHtml }} /> </pre> </div> </div> ); }

3.4 服务端集成与 AI 解释入口

在 Next.js App Router 的页面里,服务端完成 Shiki 渲染,把 HTML 字符串传给客户端组件:

// app/docs/page.tsx import { highlightCode } from '@/lib/shiki'; import { CodeBlock } from '@/components/CodeBlock'; export default async function DocsPage() { const code = `const agent = new Agent({ model: 'gpt-4.1' });`; const html = await highlightCode(code, 'typescript', 'dark-plus'); return ( <CodeBlock code={code} language="typescript" highlightedHtml={html} filename="agent.ts" showLineNumbers /> ); }

如果要在代码块旁边加一个「AI 解释」按钮,调用 TaoToken 的 API 时走服务端路由:

// app/api/explain/route.ts import { NextResponse } from 'next/server'; export async function POST(req: Request) { const { code } = await req.json(); const res = await fetch(`${process.env.TAOTOKEN_BASE_URL}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.TAOTOKEN_API_KEY}`, }, body: JSON.stringify({ model: 'gpt-4.1-mini', messages: [ { role: 'system', content: '用中文简要解释这段代码的功能。' }, { role: 'user', content: code }, ], }), }); const data = await res.json(); return NextResponse.json({ explanation: data.choices[0].message.content }); }

这样 Key 只存在服务端环境变量里,前端组件通过/api/explain调用,TaoToken 统一管理模型路由和鉴权。

4. 验证请求与成功结果

配置写完后,跑一次完整验证。启动开发服务器:

npm run dev

打开文档页,你应该看到代码块渲染出 TypeScript 语法高亮,关键字是蓝色、字符串是橙色、注释是绿色。点击 Copy 按钮,按钮文案变成「Copied!」并显示对勾图标,2 秒后恢复。

验证复制是否真的成功:打开浏览器控制台,粘贴剪贴板内容,应该和代码块里的文本完全一致,包括缩进和换行。

验证 TaoToken 通道:在页面里触发一次 AI 解释请求,观察 Network 面板。请求发往/api/explain,服务端再转发到https://taotoken.net/api/v1/chat/completions,返回 200 且choices[0].message.content有内容。如果返回 401,说明 Key 没读到;返回 404,检查 base URL 拼接是否正确。

一个实测下来比较稳的检查清单:

检查项预期结果常见偏差
Shiki 高亮关键字/字符串/注释颜色区分语言未加载导致纯文本
Copy 按钮点击后 2 秒内显示 Copied!非 HTTPS 下 Clipboard 报错
行号对齐行号与代码行一一对应代码末尾空行导致行号多一
TaoToken 请求200 + 有效响应体Key 未注入或模型名错误
主题切换暗色/亮色下高亮均清晰硬编码色值导致亮色下看不清

5. 本篇常见错排查

Shiki 报错Language 'xxx' not found

原因是你用了createHighlighter但没在langs数组里注册该语言。Shiki 不会自动加载所有语言,必须显式声明。解决:在lib/shiki.ts的langs里加上对应语言标识,比如'vue'、'go'。

Copy 按钮在 HTTP 环境下失效

navigator.clipboard在非安全上下文(HTTP 且非 localhost)下是undefined。代码里已经做了window.isSecureContext判断和execCommand降级,但如果降级也失败,检查textarea是否被正确添加到 DOM 并执行了select()。有些浏览器要求textarea可见才能复制,可以把opacity设为0而不是display: none。

行号与代码行错位

常见原因是code.split('\n')时末尾多了一个空字符串。如果代码以换行结尾,split会产生一个空元素,导致行号多一行。解决:code.replace(/\n$/, '').split('\n')。

TaoToken 返回 401 Unauthorized

检查.env.local里的TAOTOKEN_API_KEY是否以sk-开头,以及服务端路由是否真的读到了这个变量。Next.js 里只有NEXT_PUBLIC_前缀的变量才会暴露给客户端,服务端路由读process.env.TAOTOKEN_API_KEY没问题,但如果你在客户端组件里直接读就会是undefined。

高亮 HTML 被转义显示成文本

用了dangerouslySetInnerHTML但 Shiki 返回的 HTML 里<span>被当成文本渲染了。检查是不是在传给组件之前又做了一次escape。Shiki 的codeToHtml返回的就是可直接插入的 HTML 字符串,不要再转义。

主题切换后高亮颜色不变

如果你用的是固定theme: 'dark-plus',切换data-theme属性不会影响已渲染的 HTML。解决方案有两种:一是用 Shiki 的css-variables主题,通过 CSS 变量控制颜色;二是服务端根据当前主题分别渲染两套 HTML,客户端切换时切换显示。

6. 统一 Key 通道的后续接入

代码展示组件跑通之后,TaoToken 的 Key 通道可以复用到其他需要模型调用的地方。比如文档站的搜索框加一个「AI 问答」,或者代码块旁边加「生成单元测试」按钮,都走同一个/api/explain路由,只是 prompt 不同。

如果你打算长期在项目里做编码相关的 AI 功能,可以看看 Coding Plan 的接入方式,它针对代码场景做了优化。模型对话的调试入口在模型对话页,可以快速验证 Key 和模型是否通。API Keys 的管理在控制台,接入文档里有完整的参数说明。

实际落地时,建议把 TaoToken 的调用封装成一个统一的lib/ai.ts,所有需要模型能力的地方都从这里走,Key 只在一处配置,模型切换也只改一个地方。这样代码展示组件就真正做到了「展示归展示,AI 能力归通道」,两边解耦,维护成本低。

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

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

立即咨询