ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Code Hike 深入指南:用 token-transitions 实现代码块切换的 Token 级平滑过渡

Code Hike 深入指南:用 token-transitions 实现代码块切换的 Token 级平滑过渡
  • 前端
  • 开发工具

【免费下载链接】codehike

Build rich content websites with Markdown and React

项目地址:https://gitcode.com/gh_mirrors/co/codehike
点击查看免费下载

Code Hike(仓库根目录)是一个"用 Markdown 和 React 构建富内容网站"的开源方案,其中token-transitions注解工具可以在两个代码块之间以单个 token(词法单元)为粒度做平滑动画:token 移动、变色、新 token 淡入,全部由 Web Animations API 驱动。本文以演示目录 apps/web/demos/token-transitions 为入口,结合官方指南 apps/web/content/docs/code/token-transitions.mdx 与核心实现 packages/codehike/src/utils/token-transitions.tsx,讲清它的工作原理、三步接入方法以及底层 FLIP 算法细节,读完你可以在自己的组件中复刻同样的动画。

一、从演示说起:一个真实的切换场景

演示目录下有两个几乎相同的 Markdown 文件,content.md与content.mdx,各自包含两段代码:Scala 与 Python 版本的factorial阶乘函数:

object Main { def factorial(n: Int): Int = { if (n == 0) { return 1 } else { return n * factorial(n - 1) } } }
def factorial(n): if n == 0: return 1 else: return n * factorial(n - 1)

区别只在装饰符号上——content.mdx 中两个代码块的语言标记后面各跟了一个!!:

```scala !! object Main { ... } ``` ```python !! def factorial(n): ... ```

这里的!!是 Code Hike 的Blocks 机制中的"列表"标记。根据 apps/web/content/docs/concepts/blocks.mdx 的说明:用!装饰单个元素,用!!则会把所有同名的装饰元素收集成一个数组。其解析逻辑见 packages/codehike/src/mdx/1.1.remark-list-to-section.ts:value.startsWith("!!")时multi为true,并据此计算元素在数组中的下标。所以下面这段页面代码才能把两段代码解成code数组:

// apps/web/demos/token-transitions/page.tsx import { highlight } from "codehike/code" import Content from "./content.mdx" import { CodeSwitcher } from "./code" import { Block, CodeBlock, parseRoot } from "codehike/blocks" import { z } from "zod" export default async function Page() { const { code } = parseRoot( Content, Block.extend({ code: z.array(CodeBlock) }), ) const infos = await Promise.all( code.map((codeblock: any) => highlight(codeblock, "github-dark")), ) return <CodeSwitcher infos={infos} /> }

parseRoot配合Block.extend({ code: z.array(CodeBlock) })完成类型安全解析;随后对每个代码块调用highlight得到高亮结果HighlightedCode,全部交给客户端组件CodeSwitcher。切换逻辑在 apps/web/demos/token-transitions/code.tsx 中:用useState记录当前下标,点击 "Switch code" 按钮就在 Scala 与 Python 两个版本之间切换,并在渲染时把tokenTransitions作为 handler 传给Pre:

"use client" import { Pre } from "codehike/code" import { tokenTransitions } from "@/components/annotations/token-transitions" export function CodeClient(props: { highlighted: HighlightedCode }) { const { highlighted } = props return ( <Pre code={highlighted} handlers={[tokenTransitions]} className="m-0 h-80 bg-zinc-950/80" /> ) }

当codeprop 从 Scala 块切换为 Python 块时,React 会更新 DOM,而tokenTransitions会拦截这次更新并补上 token 级动画——这就是演示里看到的"流畅变形"效果。

二、核心思路:把"切换"变成"FLIP"

在进入代码之前,先建立直觉。token-transitions的实现本质上是经典的FLIP 动画(First-Last-Invert-Play)在 token 粒度上的应用:

  1. 在旧代码渲染完成、新代码尚未提交时,记录旧快照(每个 token 元素的位置、颜色、文本);
  2. 新代码渲染完成后,比较新旧两批 token;
  3. 对"仍然存在"的 token,计算位置差(dx/dy)与颜色差,让它们从旧位置"滑"到新位置;
  4. 对"新增"的 token,做从透明到可见的淡入;
  5. 被删除的 token 直接消失(源码中removeDuration = 0)。

关键点在于动画的最小单元是 token(<span>)而不是整行或整个<pre>。官方指南的第一句话就点明了这个定位:"Animate the transition between code blocks at a token level."

三、三步接入:官方推荐的集成方式

官方指南 apps/web/content/docs/code/token-transitions.mdx 给出了一套非常具体的接入流程,核心 API 来自codehike/utils/token-transitions:

import { TokenTransitionsSnapshot, calculateTransitions, getStartingSnapshot, } from "codehike/utils/token-transitions"

第一步:写一个带快照的<Pre>类组件

动画必须发生在"DOM 已更新但动画尚未丢失"的窗口期,因此需要 React 类组件的两个生命周期钩子配合。官方推荐直接在类组件中使用InnerPre与getPreRef(这两者要求组件被用作PreWithRef,见下文第四节):

"use client" import { CustomPreProps, InnerPre, getPreRef } from "codehike/code" import { TokenTransitionsSnapshot, calculateTransitions, getStartingSnapshot, } from "codehike/utils/token-transitions" import React from "react" const MAX_TRANSITION_DURATION = 900 // milliseconds export class SmoothPre extends React.Component<CustomPreProps> { ref: React.RefObject<HTMLPreElement> constructor(props: CustomPreProps) { super(props) this.ref = getPreRef(this.props) } render() { return <InnerPre merge={this.props} style={{ position: "relative" }} /> } getSnapshotBeforeUpdate() { return getStartingSnapshot(this.ref.current!) } componentDidUpdate( prevProps: never, prevState: never, snapshot: TokenTransitionsSnapshot, ) { const transitions = calculateTransitions(this.ref.current!, snapshot) transitions.forEach(({ element, keyframes, options }) => { const { translateX, translateY, ...kf } = keyframes as any if (translateX && translateY) { kf.translate = [ `${translateX[0]}px ${translateY[0]}px`, `${translateX[1]}px ${translateY[1]}px`, ] } element.animate(kf, { duration: options.duration * MAX_TRANSITION_DURATION, delay: options.delay * MAX_TRANSITION_DURATION, easing: options.easing, fill: "both", }) }) } }

三个要点:

  • render()用InnerPre merge={this.props}渲染,merge会把 Code Hike 内部的渲染栈(_stack)与 ref(_ref)一并透传下去(见 packages/codehike/src/code/inner.tsx);style={{ position: "relative" }}为绝对定位/位移动画提供参考坐标系;
  • getSnapshotBeforeUpdate在更新前调用getStartingSnapshot(ref.current)抓取旧状态;
  • componentDidUpdate收到这个快照后调用calculateTransitions,拿到一组TokenTransition,再逐条用element.animate播放。

注意 keyframes 的合并技巧:底层返回的是独立的translateX/translateY两段数组,这里把它们合并成 Web Animations 的translate属性(形如"0px 0px"),一次调用即可同时控制 X/Y 位移。options.duration/options.delay是 0~1 的归一化比例(见下文算法节),要乘上MAX_TRANSITION_DURATION(900ms)换算成真实毫秒数。

第二步:把它包装成 AnnotationHandler

PreWithRef是 AnnotationHandler 的可选字段,专门用于"需要访问 DOM ref 的 Pre 定制"。同时为了让每个 token 都能独立位移,还必须用InnerToken把 token 变成inline-block(否则行内元素无法应用translate):

// code.tsx(官方指南) import { AnnotationHandler, InnerToken } from "codehike/code" import { SmoothPre } from "./smooth-pre" export const tokenTransitions: AnnotationHandler = { name: "token-transitions", PreWithRef: SmoothPre, Token: (props) => ( <InnerToken merge={props} style={{ display: "inline-block" }} /> ), }

仓库实际使用的版本(apps/web/components/annotations/token-transitions.tsx 与其客户端实现 token-transitions.client.tsx)结构完全一致,只是把SmoothPre命名为PreWithRef,动画参数也完全相同(MAX_TRANSITION_DURATION = 900)。

第三步:把 handler 传给Pre

在任意服务端/客户端组件中,把高亮结果与 handler 一起交给Pre:

// page.tsx(官方指南) async function Code({ codeblock }: { codeblock: RawCode }) { const highlighted = await highlight(codeblock, "github-dark") return <Pre code={highlighted} handlers={[tokenTransitions]} /> }

当codeblock变化时(例如上一节的CodeSwitcher切换 index),Code组件渲染出新的高亮结果,token-transitions就会自动在旧、新代码块之间播放 token 级过渡动画。handler 只在有对应注解时才需要显式声明名字匹配,而token-transitions这类"总开关式"handler 不设置onlyIfAnnotated,因此只要被列入handlers数组就会生效。

四、源码级原理:快照、翻转与交错

动画的"算法大脑"全部在 packages/codehike/src/utils/token-transitions.tsx 这一个文件里,约 270 行,无任何框架依赖(只用了diff包做数组 diff)。下面按数据流拆解。

1. 快照与翻转(Snapshot & Flips)

type SnapshotElement = { x: number y: number color: string content: string | null } export type TokenTransitionsSnapshot = SnapshotElement[]
  • getStartingSnapshot(parent, options?):遍历 parent 下所有"可翻转元素",生成旧快照。
  • calculateTransitions(parent, firstSnapshot, options?):更新后再遍历一次,得到新元素列表,然后getFlips把新旧两批元素按textContent一一配对(diffList基于diffArrays只返回"两边都有的"配对索引),产出Flip[]:
    • first: null表示该 token 是新增的(旧快照里没有);
    • 位置相同(first.x === last.x && first.y === last.y)的视为未变化,不参与动画;
    • 其余是移动的 token,按移动方向(向后/向前)分桶。

默认只会收集叶子元素:getFlipableElements的默认选择器是:not(:has(*)),即"不再包含子元素"的最内层节点——这正是 token<span>所处的层级。

2. 位置与颜色如何采样

toSnapshotElement(源码)的采样方式值得注意:

  • 位置不是用getBoundingClientRect,而是沿offsetParent链累加offsetLeft/offsetTop(相对偏移量),这样动画与滚动位置无关,更稳定;
  • 采样前会执行el?.getAnimations().forEach((a) => a.cancel()),取消上一次动画,避免新旧动画叠加冲突;
  • 颜色取window.getComputedStyle(el).color,文本取textContent,供 diff 配对使用。

3. 过渡参数:一张表看懂TokenTransition

calculateTransitions最终产出一个TokenTransition[],其结构定义在 源码类型处:

字段含义取值/说明
element要播放动画的 DOM 元素通常是 token<span>
keyframes.translateX/translateYX/Y 位移[起, 止]移动类 token 为[dx, 0]/[dy, 0]
keyframes.color颜色过渡[起, 止]旧色 → 新色,如高亮主题变化时生效
keyframes.opacity透明度[起, 止]新增 token 为[0, 1](淡入)
options.duration归一化时长移动0.28,新增0.22,删除0
options.delay归一化延迟由交错算法算出(见下)
options.easing缓动函数移动ease-in-out,新增ease-out
options.fill填充模式移动backwards,新增both

内部配置常量(源码):

const config = { moveDuration: 0.28, addDuration: 0.22, } // max possible duration of the full transition is 1: // 0.28 * 2 + 0.22 * 2 = 1 (because of `fullStaggerDuration`)

也就是说整个过渡的"最大总时长"被设计为 1(归一化单位),再由使用方乘上MAX_TRANSITION_DURATION得到真实毫秒——这就是为什么官方指南里写duration: options.duration * MAX_TRANSITION_DURATION。

4. 交错(Stagger)算法:为什么滚动代码块动画如此自然

groupFlips把移动的 flip 分成forwards/backwards两组(按位移方向),再把新增的单独成组;flipsToTransitions按"删除 → 移动 → 新增"的顺序排布时间轴:

  • 移动 token 的延迟 =staggerDelay(groupIndex, moved.length, moveDuration, config.moveDuration);
  • 新增 token 的延迟 =moveDuration + staggerDelay(index, addedFlips.length, addDuration, config.addDuration)——即等所有移动动画结束后,新增 token 再依次淡入;
  • fullStaggerDuration(count, singleDuration)返回2 * singleDuration * (1 - 1 / (1 + count)),保证数量再多,总时长也收敛在2 * singleDuration以内(注释原文:"max possible duration is 2 * singleDuration")。

这套"分方向 + 交错延迟"的设计,让滚动阅读类布局(如 scrollycoding)里代码逐步变化时,token 移动像流水一样有先后层次,而不是所有元素同时乱跳。

五、Pre内部机制:PreWithRef与渲染栈

为什么官方示例里SmoothPre能直接getPreRef(this.props)?这依赖 packages/codehike/src/code/pre.tsx 的buildPreStack逻辑(L94-L101):

  1. 所有 handler 的普通Pre先入栈;
  2. 所有 handler 的PreWithRef再入栈,并在最前面压入AddRefIfNedded(见 packages/codehike/src/code/pre-ref.tsx),它负责用useRef兜底创建 ref 并注入_ref;
  3. merge = { _stack: stack, _ref: ref }随InnerPre一路透传,形成一个"洋葱式"组件栈,每个定制组件处理完自己的逻辑后调用下一个Inner*组件继续。

因此getPreRef才安全地取到真实的<pre>DOM 引用(其内部实现见 packages/codehike/src/code/inner.tsx#L22-L30,若在PreWithRef之外调用会直接抛错:`getPreRef` can only be used inside `PreWithRef`)。

六、另一个接入姿势:SmoothPre直接包装Pre

官方指南展示的是"作为PreWithRef"的写法;仓库中还有一个可对照的变体 apps/web/components/smooth-pre.tsx:它不继承CustomPreProps,而是直接渲染Pre并自己用React.createRef持有 ref:

render() { const handlers = [inlineBlockToken, ...(this.props.handlers || [])] const style = { ...this.props.style, position: "relative" as const } const props = { ...this.props, style, handlers } return <Pre ref={this.ref} {...props} /> }

两种写法在生命周期与动画代码上完全一致,差别只在"ref 由谁提供、handler 如何注入"。这提示了一个通用模式:只要你在更新前后分别拿到<pre>的快照与新元素,动画代码就可以原样复用。

七、与高亮管线的关系:为什么需要稳定的 token 结构

过渡动画的质量直接取决于高亮结果是否"可配对"。highlight函数(packages/codehike/src/code/highlight.ts)在做语法高亮时特意传了scopes: false,并留下一句注释(L43):

annotations: [], scopes: false, // true for better token transitions, but breaks css themes

意思是:开启作用域(scopes)理论上能生成更细的 token、让过渡更精致,但会破坏基于 CSS 的主题方案,因此默认关闭。此外highlight会把 token 周围的首尾空白拆成独立的空白 token(splitWhitespace),并合并连续的空白(joinWhitespace),确保 diff 配对时"内容"与"空白"都能稳定对齐——这些都是 token 级动画能精确匹配到content: string的前提。

八、深入验证:看实测中的动画编排

回到第四节开头那张参数表,你可以据此反推演示里点击 "Switch code" 后发生的事:

  1. CodeSwitcher的index从 0 变为 1,infos[1](Python 版)被传入CodeClient;
  2. Pre渲染出新 token 树,getSnapshotBeforeUpdate已用旧树生成了快照;
  3. calculateTransitions按textContentdiff 两棵树:factorial、n、return 1等共有 token 走"移动 + 变色"(fill: "backwards",先定格在旧位置再滑向新位置);Python 特有的def、缩进 token 走"淡入"(fill: "both");
  4. 所有动画乘上 900ms 的MAX_TRANSITION_DURATION播放,切换看起来就像代码"流"过去一样。

九、真实用例与进一步阅读

token-transitions最常见的落点是**滚动讲述(scrollycoding)与聚光灯(spotlight)**两类布局——代码会随滚动或步骤切换持续变化,正是 token 级过渡最能发挥价值的场景。官方指南在结尾给出了两个入口,均已转换为仓库内相对路径:

  • Scrollycoding 布局示例
  • Spotlight 布局示例

如果想从零复现,最小闭环是:写一个带快照生命周期的PreWithRef类组件(第一节代码)→ 包成AnnotationHandler(第二节代码)→ 在Pre的handlers里注册 → 让codeprop 随状态切换。其余细节(交错参数、选择器、keyframes 合并)都可直接对照 packages/codehike/src/utils/token-transitions.tsx 微调,它就是整个能力的"规范实现"。

  • 前端
  • 开发工具

【免费下载链接】codehike

Build rich content websites with Markdown and React

项目地址:https://gitcode.com/gh_mirrors/co/codehike
点击查看免费下载
上一篇:Lighthouse Romhack 兼容机制解析:RomhackCompat 代码深度解读(新手完整指南)
下一篇:CTF-NetA CTF网络流量分析工具免安装指南:4步从 pcap 到 flag

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进