搜索 K
主题
GitHub 个人主页那张绿格子贡献图,大概是开发者世界里最有辨识度的信息图。把同款语义搬到博客归档页——每个格子是一天,颜色深浅是当天发文量——写作节奏一眼可见。@vaultkit/vitepress-plugin-contribution-graph 就是这么一个组件:零依赖、纯 CSS Grid、SSR 安全,数据只要一个日期数组。本站归档页顶部的创作热力图就是它,现场即可看到效果。
本文通读包的全部导出(ContributionGraph 组件与 ContributionGraphLabels 类型),逐项讲清 props、labels 的全部七个字段、dayClick 事件与整套 CSS 变量,并展开容器查询响应式、纯 UTC 日期运算这些源码注释里的设计决策。
现成的热力图组件几乎都绑着图表库:ECharts 的 calendar 系、基于 canvas 的日历热图……为归档页一张图拖几百 KB 依赖不划算,canvas 渲染还要自己处理暗色主题与点击命中。这个包的取舍是极简主义:
string[] 的 ISO 日期数组,每项计 1 次、可重复。组件内部聚合计数、生成全年格子(周一起始列)、按 GitHub 语义渲染 0-3 级色阶pnpm add -D @vaultkit/vitepress-plugin-contribution-graph若使用 @vaultkit/vitepress-theme-vault 主题则开箱即带——归档页热力图已接好,点击格子滚动到对应月份分组。
归档页场景,data loader 给出文章日期列表即可:
<!-- ArchivesPage.vue -->
<script setup lang="ts">
import { ContributionGraph } from '@vaultkit/vitepress-plugin-contribution-graph'
import { data as posts } from '../data/posts.data'
const dates = posts.filter(p => p.date).map(p => p.date) // ISO yyyy-MM-dd
function scrollToMonth(month: string) {
document.getElementById(`month-${month}`)?.scrollIntoView({ behavior: 'smooth' })
}
</script>
<template>
<ContributionGraph
:dates="dates"
:labels="{
summary: (year, count) => `${year} 年共 ${count} 篇`,
month: m => `${m}月`,
weekdays: ['一', '三', '五'],
tooltip: (date, count) => `${date} · ${count} 篇`,
less: '少',
more: '多',
aria: '创作热力图',
}"
@day-click="({ date }) => scrollToMonth(date.slice(0, 7))"
/>
</template>本站主题的接法就是这个模式:月份分组标题挂 #month-YYYY-MM 锚点,热力图格子点击后平滑滚动定位。
ContributionGraph——热力图组件(同时作为具名导出与默认导出)ContributionGraphLabels——labels prop 的完整形状组件没有插槽、没有 v-model、没有 defineExpose;年份选择是内置状态(数据最新年起步),不对外受控。
只有两个 prop,但第二个内有乾坤。
string[]ISO 日期数组(yyyy-MM-dd),每项计 1 次、可重复——同一天发了三篇就让它出现三次,组件内部 Map 聚合成计数。这个形态是刻意的:宿主端 posts.map(p => p.date) 一行就能喂,不需要预聚合:
const dates = ['2026-06-24', '2026-06-24', '2026-06-19'] // 6-24 两篇,6-19 一篇年份页签从数据推导:出现过的年份降序排列,初选最新年。空数组时整个组件不渲染(v-if="years.length")。
ContributionGraphLabels{}(全英文缺省)文案全量注入接口——组件不内置任何语言判断,i18n 由宿主决定。七个字段逐一说明:
| 字段 | 类型 | 缺省行为 | 说明 |
|---|---|---|---|
summary | (year: string, count: number) => string | ${count} contributions in ${year} | 头部摘要行 |
month | (month: number) => string | 英文缩写 Jan–Dec | 月份标签,入参 1-12 |
weekdays | [string, string, string] | ['Mon', 'Wed', 'Fri'] | 周标签,固定三行:周一/周三/周五 |
tooltip | (date: string, count: number) => string | ${date} · ${count} | 有计数格子的悬停提示(原生 title) |
less | string | 'Less' | 图例左端 |
more | string | 'More' | 图例右端 |
aria | string | 'Contribution graph' | 容器 aria-label |
注意 summary / month / tooltip 是函数而非模板串——中文的「2026 年共 42 篇」与英文的「42 contributions in 2026」语序不同,函数注入才能覆盖任意语言的语序。无计数的格子悬停只显示日期本身,不走 tooltip。
| 名称 | 载荷 | 触发时机 |
|---|---|---|
dayClick | { date: string, count: number } | 点击有计数的格子(空格子不触发,鼠标指针也只在有数格子上变 pointer) |
组件不预设点击后该发生什么——归档页滚到对应月份、标签页筛选当日文章,都是宿主几行代码的事。
日期热力图最经典的 bug 是时区偏移:本地时区构造 new Date('2026-06-24') 在 UTC+8 与 UTC-5 得到不同的「星期几」,格子整体错一列。组件的全年格子生成全程走 UTC:Date.UTC(year, 0, 1) 起步、getUTCDay() 定首日、setUTCDate() 逐日推进、toISOString().slice(0, 10) 回写——输入输出都是纯字符串日期,任何时区的访客看到同一张图。
周一起始列也有个小技巧:getUTCDay() 以周日为 0,(day + 6) % 7 转成周一为 0 的索引,1 月 1 日之前的周内空位用 date: null 的补位格填充。
格子尺寸没有断点表,而是一条 CSS 容器查询公式:
.cg-scroll {
--cg-cell: clamp(
11px,
calc((100cqw - 23px - (var(--cg-weeks, 53) - 1) * var(--cg-gap)) / var(--cg-weeks, 53)),
var(--cg-cell-cap)
);
}即「(容器宽 − 周标签列 23px − 列间距合计) ÷ 周数」,11px 保底、18px 封顶。周数(53 或 54,随年首日浮动)由组件以 --cg-weeks 内联注入。于是:大屏格子自动放大铺满,小屏低于保底就溢出走横向滚动,中间任何宽度都严丝合缝。不支持容器查询的旧浏览器经 @supports 落回固定 11px,行为与旧版一致——渐进增强的教科书写法。
超宽屏还有一层节制:热力图是固定纵横比的信息图,--cg-natural-width 限定自然宽度上限、整图居中,不随容器无限铺开——GitHub 同款处理。
移动端横向滚动时,用户关心的通常是最近的记录。挂载与切年后组件自动把滚动条定位到最后一个有数据的格子(居中显示)。实现上刻意手算 scrollLeft 而不用 scrollIntoView——后者可能连带滚动页面纵轴,进入归档页突然往下窜一屏。
年份初值取「数据里最新的年」而不是 new Date().getFullYear()——后者在构建期与访问期可能不同(跨年部署的静态页),前者是数据的纯函数,服务端与客户端两次渲染必然一致,零水合差异。
level = min(count, 3):0 为空、1/2/3+ 次对应三档深度。色阶走品牌色混合——1 级 color-mix(35% 品牌色)、2 级 65%、3 级纯 --vp-c-brand-1,明暗两态免配置。
| 变量 | 默认值 | 说明 |
|---|---|---|
--cg-gap | 3px | 格子间距 |
--cg-cell-cap | 18px | 格子尺寸上限 |
--cg-cell | 容器查询公式(保底 11px) | 格子边长(尺寸单一事实源) |
--cg-weeks | 组件内联注入(53/54) | 周列数,参与宽度计算 |
--cg-natural-width | 由上述变量推导 | 整图自然宽度(超宽屏居中用) |
想要更大的格子只需覆盖上限:
.cg {
--cg-cell-cap: 22px;
}本包发布的是含 .vue 源文件的产物,消费方 VitePress 项目必须把它加进 vite.ssr.noExternal,否则 SSR 构建报错:
// .vitepress/config.ts
export default defineConfig({
vite: { ssr: { noExternal: ['@vaultkit/vitepress-plugin-contribution-graph'] } },
})title 提示是原生行为,无 JS 依赖aria-label(经 labels.aria 注入);图例行 aria-hidden(纯视觉冗余信息)vue ^3.5.0,运行时零依赖Q:格子的星期对不上? 先确认传入的是 yyyy-MM-dd 纯日期串。带时间与时区后缀的 ISO 串(2026-06-24T18:00:00+08:00)不会被截断处理,聚合键就错了——喂之前 slice(0, 10)。
Q:能一次展示多年吗? 不能,GitHub 同款语义是单年视图 + 年份页签切换。年份从数据自动推导,无需配置。
Q:能改成绿色系吗? 色阶完全由 --vp-c-brand-1 推导,改站点品牌色即可整体换色;或对 .cg-cell[data-level] 逐档覆盖背景。