用 JavaScript 做 Reveal.js 课堂互动

投票柱状图与文内引用跳转

用本课讲义说明:HTML 提供结构,CSS 负责外观,两段纯 JavaScript 在 Reveal.js 里完成投票柱状图和引用跳转。
课堂互动
Reveal.js
JavaScript
作者

胡华平

发布于

2026年9月22日

修改于

2026年9月22日

摘要
面向还不熟悉 JavaScript 的写法,说明课堂互动如何放进 Reveal.js 幻灯片。以《客观世界与思维模型》为例:幻灯片正文只放 HTML 结构,include-after-body 引入脚本;投票图读取人数并重绘 SVG,引用链接则调用 Reveal 翻到对应幻灯片。

这篇笔记说明本课互动讲义里的两段 JavaScript 在做什么,以及它们怎样挂进 Reveal.js。例子来自 slide/slide-reactive/chpt01-act-em-schools.qmd(英文稿共用同一套脚本)。读完之后,应能看懂脚本在找哪些标记、为什么普通链接在幻灯片里不够用,以及自己加一个小互动时要改哪三个地方。

1 三种文件,各管一件事

浏览器里的一张幻灯片由三层拼出来:

层 本课里的文件 它回答的问题
结构 chpt01-act-em-schools.qmd 里的 HTML 页面上有哪些输入框、哪段文字、哪张空图
外观 chpt01-act-em-schools/slides.css 字号、两栏排布、高亮颜色
行为 chpt01-act-em-schools/poll-chart.html、chpt01-act-em-schools/cite-nav.html 人数一改,图就重画;点引用编号,就翻到参考资料

Quarto 把 .qmd 渲染成 HTML。CSS 只改样子,不改数字。会随课堂填写而变化的部分,交给 JavaScript。

幻灯片外观(宽度、校徽、页码、上面的 CSS)写在 slide/slide-reactive/_reveal.yml,由讲义 YAML 的 metadata-files 引入。互动脚本写在这一份讲义自己的 YAML 里,因为活动列表页不需要它们。

2 脚本怎样进入幻灯片

在讲义开头这样写:

format:
  revealjs:
    include-after-body:
      - chpt01-act-em-schools/poll-chart.html
      - chpt01-act-em-schools/cite-nav.html

include-after-body 的意思是:正文 HTML 都生成完之后,再把这两个文件贴到页面最底部。贴在底部,是为了让脚本运行时,输入框和引用链接已经在页面里。

这两个文件的外壳都是:

<script>
(function () {
  // 这里写互动逻辑
})();
</script>

可以把它读成一句完整的话:定义一个函数,并且马上执行。函数里面的名字(例如 draw、FIELDS)只在函数内部有效,不会和 Reveal.js 自己的名字撞车。Quarto 的 include-after-body 接受 HTML 片段,所以脚本放在 .html 里,外面包一层 <script>。

3 先认识脚本里反复出现的写法

下面这些就是两段脚本用到的全部基础。先对上号,后面的流程就能顺着读。

找元素。 document.querySelector(".poll-total-num") 按 CSS 类名找到第一个元素。querySelectorAll(".poll-layout") 找到全部。类名就是 HTML 里的 class="poll-layout"。

读接头暗号。 HTML 可以带自定义属性,名字以 data- 开头。脚本用 getAttribute("data-poll") 读取。讲义里写 data-poll="B",脚本就知道这个输入框属于选项 B。标记和脚本靠这些属性对接,靠中文句子对不上。

改页面上的字。 element.textContent = "62" 只替换这段文字,不解析成 HTML。

听用户操作。 document.addEventListener("input", function (event) { ... }) 表示:页面上任何输入框的值变了,就运行花括号里的函数。event.target 是刚刚被改动的那个元素。click 则表示鼠标点击。

看类名在不在。 element.classList.contains("poll-input") 用来确认「这次改动的是不是投票框」,避免学生改了别的输入框也触发重画。

等页面就绪。 若脚本执行时正文还没出现,查找会得到空结果。因此脚本末尾写成:

if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", bind);
} else {
  bind();
}

bind 是我们自己起的函数名,意思是「开始监听」。页面仍在加载就等 DOMContentLoaded;已经加载完就立刻执行。

4 案例一:把人数画成柱状图

4.1 讲义里留下的结构

「统计展示」这一页在 qmd 里写成这样(中文稿不写语言标记,脚本默认用中文标签):

<div class="poll-layout">
  <div class="poll-controls">
    <label class="poll-field">
      B · 先验 Θ
      <input class="poll-input" data-poll="B" type="number" value="6">
    </label>
    <!-- C、D、A、E 同样各有一个 input -->
  </div>
  <div class="poll-chart">
    <p class="poll-total">合计 <b class="poll-total-num">62</b> 人</p>
    <svg class="poll-chart-svg"></svg>
  </div>
</div>

Quarto 的 div 简写 ::: {.poll-layout} 会变成上面的 <div class="poll-layout">。英文稿写成 ::: {.poll-layout data-poll-lang="en"},脚本就改用英文轴标题和短标签。

这里有一个容易忽略的约定:

  • 人数写在 HTML 的 value 里,课堂现场改的是它。
  • 柱子顺序和轴上的短标签写在 chpt01-act-em-schools/poll-chart.html 的 FIELDS 里,顺序固定为 B、C、D、A、E,对应认知阶段,和选项字母的字母序不同。
  • SVG 一开始是空的。 柱子、刻度和文字都由脚本画上去。

CSS 里的 .reveal .poll-layout 把这一页排成两栏:左边输入,右边图。选择器写成 .reveal .poll-layout,是因为 Reveal 把幻灯片包在 class 为 reveal 的容器里,这样样式只影响幻灯片。

4.2 脚本每次重画时做的事

poll-chart.html 里的 draw(root) 针对一块 .poll-layout(变量名叫 root,表示这块互动的根节点):

  1. 读 data-poll-lang。缺省、或不是 zh/en 时,用中文。
  2. 按 FIELDS 的顺序,用 data-poll 找到对应输入框,把 value 转成数字。空的、负数或无法转换的值当成 0。
  3. 把五个数字相加,写入 .poll-total-num。
  4. 删掉 SVG 里已有的图形(while (svg.firstChild) svg.removeChild(...)),再按新数字画。

画图用的是 SVG,也就是用标签描述的矢量图。创建 SVG 内部的矩形和文字时,要用 document.createElementNS("http://www.w3.org/2000/svg", "rect")。普通的 createElement("rect") 画出的节点不属于 SVG,浏览器不会把它当成柱子。

高度按比例计算:先取五个数里的最大值,纵轴上限设为 max(10, 最大值 × 1.2),保证最高的柱子上面还有一截空白,也避免全是 0 时分母为 0。柱宽由绘图区宽度、柱子个数和间距算出来。

4.3 什么时候重画

bind 里注册了三种时机:

  1. 页面上发生 input,并且目标带有 poll-input:学生改数字时重画。监听加在整个 document 上,所以后来出现的输入框也能被发现。
  2. Reveal.on("ready", render):Reveal 布局完成后再画一次。
  3. Reveal.on("slidechanged", render):翻到这一页时再画一次。幻灯片在不可见时尺寸可能还不稳定。

render 会找到页面上每一个 .poll-layout 并各画一遍。中文稿和英文稿若被放进同一个 HTML(一般不会),两套图可以共存,因为每一块只读自己内部的输入框。脚本还会先判断 window.Reveal 是否存在,这样这段代码在普通网页里也不会因为找不到 Reveal 而中断。

5 案例二:引用编号在幻灯片之间跳转

知识背景页的角标是:

<sup class="cite-num" id="cite-1">
  <a href="#ref-1" data-cite-nav="ref-1">1</a>
</sup>

参考资料页的返回链接是:

<a href="#cite-1" class="ref-back" data-cite-nav="cite-1">返回</a>

href="#ref-1" 是普通网页的写法:跳到 id="ref-1" 的位置。Reveal 把每一页放进各自的 <section>,用内部状态决定当前显示哪一页。只改地址栏里的 #ref-1,幻灯片不会跟着翻。

chpt01-act-em-schools/cite-nav.html 因此监听整个页面的点击:

  1. 用 event.target.closest("a[data-cite-nav]") 判断点到的是不是带 data-cite-nav 的链接。closest 会从被点中的节点向外找,所以点到链接里面的文字也算。
  2. 若不是这种链接,函数直接结束,别的链接保持原样。
  3. 若是,event.preventDefault() 取消浏览器默认的页内跳转。
  4. 用属性值当作 id,例如 "ref-1",执行 document.getElementById("ref-1")。
  5. el.closest("section") 找到这个 id 所在的那一页幻灯片。
  6. Reveal.getIndices(section) 问 Reveal:这一页的横向、纵向、片段编号是多少。再调用 Reveal.slide(h, v, f) 翻过去。
  7. 约 280 毫秒后给目标所在的列表项加上 class cite-flash。CSS 用浅蓝背景和描边显示大约 1.6 秒,然后去掉这个 class。

返回链接用的是同一个属性、同一段脚本,只是 data-cite-nav 写成正文里的 cite-1。高亮样式在 chpt01-act-em-schools/slides.css 的 .reveal .cite-flash。

若当前页面没有 Reveal(例如把片段放进普通 HTML),脚本改为只做滚动和高亮,不调用 Reveal.slide。

6 加一个新互动时改哪里

按下面的顺序,标记、样式和脚本才能对上:

  1. 在讲义正文写 HTML,给容器一个稳定的 class,给需要被脚本读取的节点加上 data- 属性。人数、题目文字这类课堂内容放在 HTML 里。
  2. 在讲义同名文件夹的 slides.css 里写 .reveal .你的类名。选择器以 .reveal 开头,避免影响到网站其他页面;只有多份课件真正共用的样式才放进 scss/reveal-common.css。
  3. 在与讲义同名的文件夹里新建脚本,例如 slide/slide-reactive/chpt01-act-em-schools/你的脚本.html,套上 <script>(function () { ... })();</script>。用 querySelector 只在对应容器内部查找,这样同一页可以放两块互不影响的互动。中英文版本共用这一文件夹,不要再放到目录级的 js/。
  4. 把该文件加进这一份讲义的 include-after-body。不要写进目录的 _metadata.yml,否则活动列表等普通 HTML 页也会带上幻灯片脚本。
  5. 若行为依赖「当前正在看的这一页」,再监听 Reveal.on("slidechanged", ...)。脚本开头先判断 window.Reveal 是否存在。

投票图还说明一件分工:中英文标签放在脚本的 FIELDS 对象里,用 data-poll-lang 选择;这样两份讲义共用 poll-chart.html,输入框的 data-poll 键(A–E)保持一致。

7 到渲染结果里核对

渲染讲义之后,打开:

  • 中文:slide/slide-reactive/chpt01-act-em-schools.html 的「统计展示」「知识背景」「参考资料」
  • 英文:slide/slide-reactive/chpt01-act-em-schools-en.html(投票区带 data-poll-lang="en")

统计页改一个数字,合计与柱高应一起变。知识背景页点编号 1,应进入参考资料并短暂高亮第 1 条;点「返回」应回到编号 1。