元旦跨年夜那会儿,我在折腾个人网站时顺手做了几个纯前端的新年倒计时Demo,想着给主页加点应景的仪式感,后来干脆把它们整理成了一个开源实验合集,代码全部放在GitHub上。结果发现这些看似简单的倒计时实现,其实把前端里不少核心概念都串起来了,比如时间计算、渲染性能、动画帧调度、跨端兼容,甚至还有Web Worker这类偏底层的用法。这个项目既适合想练手的前端新人,也能给老手提供一些组件化拆解和性能优化的思路,今天就把完整的设计思路和实现细节都摊开来聊聊。
1. 项目整体设计与思路拆解
1.1 为什么要做一个“实验合集”而不是单个倒计时
刚开始我只想写一个传统的新年倒计时页面,大数字显示天时分秒,配上烟花特效就完事。但写着写着发现,倒计时这个场景其实有挺多技术切面值得玩:
- 同一个时间目标,可以有不同的视觉呈现方式:数字滚动、圆环进度、表格宫格、文字粒子,背后对应的渲染方案完全不同。
- 时间计算存在精度问题和跨时区陷阱,不是简单算一下差值就完事。
- 定时器的更新频率、页面后台运行时的行为、内存泄漏风险,这些都是真实项目里会遇到的坑。
- 一个纯前端的开源项目,不需要后端参与,放到任意静态托管平台就能跑,这本身就是很好的“零成本发布”教学案例。
所以我把每个方案拆成了独立实验页面,共用一个核心时间计算模块,再用统一的入口页把它们串起来。这样既保持了单个Demo的可读性,又能在合集层面展示代码组织和模块复用的思路。
项目开源后,我自己从中学到最多的反而是“约束带来的创意”:因为规定自己必须用纯前端实现,不能用任何第三方库,所以每个效果都需要从原生API层面解决。这种限制会逼着你把Canvas、SVG、CSS动画这些基础功打扎实。
1.2 方案选型:四个核心维度的权衡
做一个实验合集,最关键的是让每个实验之间有足够的技术差异,这样才有学习和对比价值。我的选型思路是从四个维度去权衡:
- 渲染方式:DOM操作、Canvas绘制、SVG矢量、CSS动画,各来一个代表。
- 时间驱动:setInterval定时拉取、requestAnimationFrame逐帧驱动、Web Worker后台计时,三种模式都要覆盖。
- 视觉复杂度:极简数字流、中等复杂度的环形进度、重粒子的文字特效,给不同水平的用户都能找到参考。
- 目标受众:有的是给普通访客看的酷炫效果,有的是给前端面试者准备的原理演示,还有的是给团队做组件封装的参考模板。
每个实验页在入口处都标注了技术关键词、实现难度和适用场景。这样读者拿到的不只是一堆代码,而是一份“技术地图”——想学什么,直接跳到对应页面即可。
1.3 整体文件结构与模块划分
项目保持了一个非常轻的目录结构,核心思路是“共享核心、隔离实验”:
text复制new-year-countdown/
├── index.html # 合集入口页
├── README.md # 开源说明文档
├── src/
│ ├── core/
│ │ ├── timex.js # 时间计算核心模块
│ │ └── i18n.js # 多语言文案配置
│ ├── experiments/
│ │ ├── digital-scroll/ # 实验一:数字滚动翻牌
│ │ ├── ring-progress/ # 实验二:Canvas环形进度
│ │ ├── svg-arc/ # 实验三:SVG圆形倒计时
│ │ ├── particle-text/ # 实验四:粒子文字倒计时
│ │ └── worker-timer/ # 实验五:Web Worker后台计时
│ └── utils/
│ └── dom.js # 轻量DOM操作辅助
└── assets/
└── styles/ # 公共样式
这样做的好处是:新增一个实验方案时,只需要在experiments/下新建一个文件夹,入口页增加一个链接,完全不影响其他模块。核心模块timex.js只做一件事——根据目标时间返回剩余天、时、分、秒、毫秒,以及可选的进度百分比,纯函数设计,便于单元测试。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 时间计算里的几个坑,你避开了吗
倒计时的核心看似一句话:用目标时间减去当前时间。但实际写起来,坑比很多人想象得多。
第一个坑是日期解析的兼容性。如果你写new Date('2026-01-01 00:00:00'),在Chrome、Firefox上可以正常解析,但在iOS Safari上会直接返回Invalid Date。我测试时踩过这个坑——iPhone用户看到的是满屏“NaN天NaN小时”,非常尴尬。稳妥的做法是用new Date(2026, 0, 1)这类构造函数,或者手动解析字符串为年月日再构造。
第二个坑是时间戳基准。计算剩余时间时,应该以Date.now()为基准,每次都实时取当前时间,而不是在全局维护一个不断减1的计数器。原因是定时器可能会被系统延时,减1式的计数会导致累计误差,页面挂久了就越来越不准。我的timex.js里核心函数是这样的:
javascript复制// src/core/timex.js
const SECOND = 1000;
const MINUTE = SECOND * 60;
const HOUR = MINUTE * 60;
const DAY = HOUR * 24;
export function getTimeRemaining(targetTime) {
const now = Date.now();
const distance = targetTime - now;
if (distance <= 0) {
return {
total: 0,
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0,
progress: 1,
};
}
return {
total: distance,
days: Math.floor(distance / DAY),
hours: Math.floor((distance % DAY) / HOUR),
minutes: Math.floor((distance % HOUR) / MINUTE),
seconds: Math.floor((distance % MINUTE) / SECOND),
milliseconds: distance % SECOND,
progress: distance / (targetTime - startTime), // 需要额外传入起始时间
};
}
第三个坑是时区问题。如果目标时间写的是2026-01-01 00:00:00,解析时会被当作浏览器本地时间,这意味着不同时区的用户看到的倒计时终点不一样。对新年倒计时来说,这其实可以接受——大家各自在本地时间迎接新年。但如果你要做一个全球统一的活动上线倒计时,就应该用UTC时间,例如构造Date.UTC(2026, 0, 1),然后转成对应时区显示。
2.2 requestAnimationFrame 与 setInterval 的取舍
做倒计时最容易想到的是setInterval每秒更新一次DOM,简单粗暴。但实际体验有几个问题:
- 浏览器标签页切到后台时,
setInterval会被系统节流,最小间隔可能被拉长到1秒甚至更多,导致恢复前台时时间和真实时间脱节。 setInterval不能保证在指定时间精准触发,它只保证“至少隔这么长时间执行一次”,受到主线程任务队列的干扰。
所以我在实验合集里做了一个对比:digital-scroll实验用setInterval驱动,但每次更新时都用Date.now()重新计算差值,而不是单纯做减1操作。这样就算定时器被拖慢,渲染出的结果依然是当前的真实剩余时间。
requestAnimationFrame则是跟随屏幕刷新率(通常是60Hz)执行,视觉上更加流畅。它主要用于粒子文字、环形进度这类需要连续动画的场景。不过要注意:如果倒计时只需要每秒更新一次,用requestAnimationFrame反而浪费性能,因为它每秒执行60次,大部分更新都是无用功。
我的处理方式是做一个“自适应调度器”:
javascript复制// src/utils/scheduler.js
export function createTicker(callback, fps = 1) {
let lastTick = 0;
let rafId = null;
function tick(timestamp) {
if (timestamp - lastTick >= 1000 / fps) {
callback(timestamp);
lastTick = timestamp;
}
rafId = requestAnimationFrame(tick);
}
return {
start() {
rafId = requestAnimationFrame(tick);
},
stop() {
cancelAnimationFrame(rafId);
},
};
}
每秒只执行一次渲染,但底层仍然由requestAnimationFrame驱动,既保证后台切回时能够快速恢复,也避免了不必要的DOM操作。这个调度器在后来的多个实验页面里都复用了。
2.3 Canvas 环形进度:像素级绘制的细节
ring-progress实验里,我实现了一个圆环进度倒计时,圆环的周长从满到空,随着时间推进而缩短。视觉上很直观,实现时却有几个细节容易被忽略。
第一,圆环的描边宽度要适中。太粗显得笨重,太细在小屏幕上看不清。我测试下来,在移动端lineWidth取6~8px比较合适,桌面端可以到10px。
第二,进度计算要处理动画过渡。如果你直接用上一帧的角度直接跳到下一帧,视觉效果会非常生硬,特别是跨天、跨小时的那一瞬间。我在绘制逻辑里加了一个currentAngle变量,用一个缓动函数让角度平滑过渡:
javascript复制// src/experiments/ring-progress/main.js
const targetProgress = timeData.progress;
currentAngle += (targetProgress - currentAngle) * 0.1; // 缓动系数
if (Math.abs(targetProgress - currentAngle) < 0.0001) {
currentAngle = targetProgress;
}
第三,高分屏适配。直接用Canvas API绘制时,在Retina屏上会发虚,因为CSS像素和物理像素不对等。常见的处理方式是获取devicePixelRatio,把Canvas的宽高乘以这个比例,再用CSS把显示尺寸固定为原值:
javascript复制const dpr = window.devicePixelRatio || 1;
canvas.width = size * dpr;
canvas.height = size * dpr;
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;
ctx.scale(dpr, dpr);
2.4 SVG 倒计时:矢量方案的另一条路
svg-arc实验和Canvas环形进度看起来相似,但底层的实现思路完全不同。SVG的做法是通过控制stroke-dasharray和stroke-dashoffset来显示圆弧的可见部分。
原理很简单:stroke-dasharray定义的是虚线的间隔模式,比如1000表示画1000px线段再空1000px;stroke-dashoffset则是虚线的起始偏移量。通过动态调整偏移量,就能实现圆环慢慢“缩短”的效果。
html复制<svg viewBox="0 0 200 200">
<circle class="ring-bg" cx="100" cy="100" r="90" />
<circle class="ring-fg" cx="100" cy="100" r="90" />
</svg>
css复制.ring-fg {
stroke-dasharray: 565.48; /* 2πr ≈ 2 * 3.1415 * 90 */
stroke-dashoffset: 282.74; /* 初始显示一半 */
transform: rotate(-90deg);
transform-origin: center;
}
这个方案的好处是CSS控制样式非常灵活,hover效果、透明度变化都很容易做;但缺点是如果同时显示多个环,计算dasharray会显得繁琐。在实际项目中,Canvas适合动态粒子、进度环数量多的场景;SVG适合静态结构清晰、需要矢量缩放的场景。
有意思的是,两个实验页面在视觉上差别不大,但用户访问时能明显感觉到Canvas版的圆环过渡更平滑,因为Canvas可以逐帧绘制任意角度;而SVG如果要做平滑过渡,也需要借助requestAnimationFrame逐步改变stroke-dashoffset,这一步两者殊途同归。
3. 实操过程与核心环节实现
3.1 从零搭建一个实验页的完整流程
我先以digital-scroll为例,说一下一个实验页从零到可运行的过程。这个页面的效果是:天、时、分、秒四个数字分别用竖向滚动的翻牌方式变化,类似机场航班信息牌。
第一步,先规划DOM结构。每个数字位都是一个容器,内部包含从0到9的十个数字卡片,通过CSS的transform: translateY()来控制当前显示哪一个数字。为了模拟翻牌的“滚动滑入”效果,当秒数变化时,新数字从下方滑入,旧数字从上方滑出。
第二步,编写滚动逻辑。这里要处理的细节是:只有当某一单位的值变化时才触发滚动。比如秒数从59变到0,分钟数才会滚动一次,秒数每秒都滚。如果按0-9轮询,每秒钟滚动一次,看起来会很机械。我改成了“翻转动画”模式——只有当秒数从9变到0时,相应的个位数字才重新从0翻起来,而不是每次都从头滚一遍。
javascript复制// src/experiments/digital-scroll/main.js
const units = [
{ el: '.days', value: 0 },
{ el: '.hours', value: 0 },
{ el: '.minutes', value: 0 },
{ el: '.seconds', value: 0 },
];
function updateDigits(type, value) {
const unit = units.find((u) => u.el === type);
if (unit.value === value) return;
const dom = document.querySelector(type);
const digits = Array.from(dom.querySelectorAll('.digit'));
const targetDigit = value % 10;
const topDigit = Math.floor(value / 10);
// 十位和个位分别更新,只有实际变化时才触发动画
if (topDigit !== Math.floor(unit.value / 10)) {
setDigit(digits[0], topDigit);
}
if (targetDigit !== unit.value % 10) {
setDigit(digits[1], targetDigit);
}
unit.value = value;
}
第三步,接入风格定义。这一步是和整体视觉统一的环节,我在CSS里用CSS变量控制字体、颜色、卡片高度,这样切换深色模式时只需要改几个变量。
3.2 Web Worker 版本,为什么用 Worker 反而复杂了
worker-timer是这个合集里最“重”的一个实验。理论上,倒计时本身只需要每秒算一次差值,主线程完全能扛住,为什么还要引入Web Worker?
我当时的考量是:如果页面里除了倒计时还有大图轮播、数据图表、3D粒子等复杂逻辑,主线程可能被长任务卡住,定时器精度就会受影响。把时间计算放到Worker里,可以让倒计时的节奏不依赖主线程状态。
实现思路是这样的:Worker线程里用setInterval每秒向主线程post一次消息,主线程收到消息后只负责更新DOM。
javascript复制// src/experiments/worker-timer/timer-worker.js
let timerId = null;
self.onmessage = function (e) {
const { type, targetTime } = e.data;
if (type === 'start') {
timerId = setInterval(() => {
const remaining = targetTime - Date.now();
self.postMessage({ remaining });
}, 1000);
} else if (type === 'stop') {
clearInterval(timerId);
}
};
主线程侧:
javascript复制const worker = new Worker(new URL('./timer-worker.js', import.meta.url));
worker.onmessage = function (e) {
const { remaining } = e.data;
renderTime(remaining);
};
用下来发现,这个方案的优势在“抗干扰”——即使主线程被大量计算阻塞,Worker依然能稳定每秒推送消息,倒计时看起来不会卡顿。但代价是多了一层消息通信,而且本地直接打开HTML时Worker会因为跨域限制无法加载,必须起一个本地服务器或用打包工具的devServer。这也是开源文档里特别要写清楚的一点。
3.3 粒子文字:一个让小白看不懂的视觉魔术
particle-text是视觉上最“炫”的一个实验,它把“新年快乐”四个字用大量粒子拼出来,同时倒计时的数字由粒子流组成。实现上核心是两个步骤:把文字转换成粒子坐标,然后让粒子的透明度、大小随剩余时间变化。
把文字转成粒子坐标,本质上是用CanvasRenderingContext2D的drawImage方法把文字画到离屏Canvas上,再用getImageData读取像素数据,找出有颜色的像素点坐标:
javascript复制const offCanvas = document.createElement('canvas');
offCanvas.width = targetWidth;
offCanvas.height = targetHeight;
const offCtx = offCanvas.getContext('2d', { willReadFrequently: true });
offCtx.font = 'bold 120px sans-serif';
offCtx.fillText('2026', 0, 100);
const imageData = offCtx.getImageData(0, 0, targetWidth, targetHeight);
const particles = [];
for (let y = 0; y < imageData.height; y += gap) {
for (let x = 0; x < imageData.width; x += gap) {
const alpha = imageData.data[(y * imageData.width + x) * 4 + 3];
if (alpha > 128) {
particles.push({ x, y, baseX: x, baseY: y });
}
}
}
这里有一个性能关键点:getImageData返回的像素数组非常大,每像素需要处理RGBA四个通道。如果文字很大,一次循环的数据量可能达到几十万,直接在主线程里同步处理会导致页面卡顿。我的做法是缩小离屏Canvas的分辨率,并用gap参数控制采样间隔,比如每个像素都采样改为每2px或3px采样一次,粒子密度降低一些,视觉上依然能组成文字轮廓,但性能翻了好几倍。
还有一种更省事的做法是提前把粒子坐标列表生成好,写到JSON文件里,运行时直接加载,避免每次刷新都重新解析图片数据。但如果想让用户改文字内容,就离不开实时解析。
3.4 数据与配置统一管理:做一个可维护的合集
合集项目最大的敌人是配置分散。五个实验页面各自有自己的目标时间、文字、颜色,如果每个页面都硬编码,改时间时就要改五处,非常容易漏。
我在src/core/config.js里做了一个统一配置文件:
javascript复制export const CONFIG = {
targetTime: new Date(2026, 0, 1, 0, 0, 0), // 默认:2026年1月1日0点
startTime: new Date(2025, 11, 20), // 开始计算进度的时间
experiments: [
{ id: 'digital-scroll', title: '数字滚动翻牌', tech: ['DOM', 'CSS transition'], difficulty: 'easy' },
{ id: 'ring-progress', title: 'Canvas环形进度', tech: ['Canvas', 'requestAnimationFrame'], difficulty: 'medium' },
{ id: 'svg-arc', title: 'SVG圆形倒计时', tech: ['SVG', 'stroke-dasharray'], difficulty: 'easy' },
{ id: 'particle-text', title: '粒子文字倒计时', tech: ['Canvas', 'getImageData'], difficulty: 'hard' },
{ id: 'worker-timer', title: 'Worker后台计时', tech: ['Web Worker', 'postMessage'], difficulty: 'medium' },
],
};
每个实验页会从URL参数中读取?target=2026-01-01T00:00:00来覆盖默认时间,这样测试时不用改代码。这个设计在开源后被一些同学fork去做其他节日倒计时,他们只需要改配置文件,不用动任何实验逻辑。
3.5 静态部署与开源发布的实操记录
项目写完之后,我把它部署到了GitHub Pages,全程不依赖任何后端,这就是“纯前端”最大的便利。
部署流程很简单:
- 在根目录新建
deploy.yml(如果用GitHub Actions)或直接用静态托管平台导出。 - 构建阶段不需要打包,因为我没有用任何框架和构建工具,原生ES Modules直接跑。
- 注意路径问题:GitHub Pages的子路径部署模式下,页面里引用静态资源用的是相对路径,不是根路径,否则CSS和JS全会404。
发布到GitHub时,我把README写成了一个完整的项目文档,包含:
- 在线预览地址
- 本地运行方式:
npx serve或python -m http.server 8080 - 技术栈说明和每个实验的目录对应关系
- 如何新增一个实验的简单步骤
- 许可证(MIT)
开源后的第一个星期,就收到了几条issue,有的是问“如何改目标时间”,有的是提出“希望加一个农历新年倒计时”。这个过程让我体会到,开源项目的“可维护性”不是写完代码就结束的,清晰的文档和配置设计才是让项目持续被使用的关键。
4. 常见问题与排查技巧实录
4.1 页面显示 NaN 或时间不变?先检查这三处
我把收集到的问题排了一下序,最高频的问题就是时间显示成“NaN天NaN小时NaN分NaN秒”。这里给出排查的优先级列表:
| 优先级 | 检查点 | 说明 |
|---|---|---|
| 1 | new Date() 的字符串格式 |
避免用new Date('2026-01-01 00:00:00'),优先用构造函数 |
| 2 | 目标时间是否早于当前时间 | 倒计时结束后的显示逻辑忘了做兜底,会出现负数或NaN |
| 3 | 模块加载顺序 | 执行时间计算时timex.js是否已加载完毕,ES Modules的加载是异步的 |
| 4 | 是否在缩放DDPR时覆盖了Canvas尺寸 | Canvas绘图后又被CSS拉伸,显示会模糊或错位 |
第二类高频问题是“倒计时和真实时间对不上”。这通常是因为定时器被浏览器节流,或者页面代码里用了减1式的计数。解决办法就是回到核心原则:每次渲染都重新计算当前时间戳,不要让“预期剩余时间”参与运算。
第三类问题是“粒子文字页面在手机上很卡”。主要原因还是粒子数量太多,手机上CPU性能有限。我实测下来,粒子数量控制在5000个以内基本流畅,超出后帧率下降明显。解决方法是减少采样精度,或者把requestAnimationFrame的渲染循环改为“每秒只更新一次粒子颜色/透明度”,而粒子位置只在初始化时计算一次。
4.2 后台标签页恢复后的时间补偿
标签页切到后台再切回来,是倒计时最容易露馅的场景。浏览器为了省电会大幅限制定时器频率,等用户切回来时,页面可能已经“慢了”十几秒。
requestAnimationFrame驱动的情况下,切回标签页会触发一次timestamp参数突变,这时需要做时间校准。我的处理是:每次拿到新的时间戳时,重新用Date.now()计算剩余时间,并把这个值作为当前显示值,而不是依赖之前累计的帧数。
对于setInterval版本,处理方式是在更新回调里始终调用getTimeRemaining,保证即使定时器被拖延,显示的值也是真实剩余值。
有一个比较隐蔽的情况:如果页面被系统完全挂起(移动浏览器经常这样),等用户再次打开时,距离目标时间可能只剩几十秒,这时如果动画还停留在挂起前的状态,观感会非常突兀。我在几个实验里加了一个“复位检测”:如果两次更新之间的间隔超过3秒,就触发一次直接跳转到当前真实值的刷新,不做滚动动画,避免一连串动画连续播放的卡顿感。
4.3 时间格式化和多语言友好
开源项目面向的是全球开发者,但默认文案是中文,这对英文用户不太友好。我在i18n.js里做了一个极简的语言切换:
javascript复制const locales = {
zh: { days: '天', hours: '时', minutes: '分', seconds: '秒', newYear: '距离新年还有' },
en: { days: 'Days', hours: 'Hours', minutes: 'Min', seconds: 'Sec', newYear: 'Countdown to New Year' },
};
页面加载时根据navigator.language自动选择语言,也支持URL参数?lang=en强制指定。这个功能很小,但它是开源项目面对不同用户群体时的“门面”之一。
4.4 常见问题速查表
整理成表格方便大家排查类似问题:
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 日期显示NaN | iOS Safari不支持带横线的日期字符串 | 用new Date(2026, 0, 1) |
| 倒计时每秒跳好几秒 | setInterval被后台节流后恢复 | 每次更新时重新获取Date.now() |
| Canvas显示模糊 | 没有适配devicePixelRatio | 读取DDPR后缩放Canvas尺寸 |
| Worker无法运行 | 直接打开本地HTML跨域被拦 | 用本地服务器模式启动 |
| 字体或图片加载慢 | 静态资源用了绝对路径 | 改成相对路径,或部署到CDN |
| 数字滚动动画堆叠 | 动画类名重复添加 | 先移除旧类名,再添加新类名触发过渡 |
5. 这个实验合集还能怎么扩展
项目开源后,不断有人问“能不能再加一点别的效果”。这里分享几个我自己在规划但还没完全落地,或者从参与者那收集到比较靠谱的方向:
- 农历新年倒计时:现在几乎所有的倒计时都是公历,农历才是真正意义上的“过年”。难点在于农历换算规则复杂,需要内置农历算法或查找表,对纯前端来说是一个不小的数据和时间挑战。
- 多个城市时区的倒计时:做一个世界时钟式的页面,同时显示纽约、伦敦、东京、阿姆斯特丹等城市的跨年时间差。这个主要是处理时区数据,可以用
Intl.DateTimeFormat的timeZone配置,考验的是对“时间即字符串”这一概念的掌握。 - 倒计时结束后触发动画:现在实验在倒计时归零后只是显示一个结束语,扩展空间是接入烟花粒子、打字机文案、背景音乐播放等。这里要注意的是,结束后要清理所有定时器和动画帧,避免内存泄漏。
- 无头浏览器截图用于社交媒体卡片:如果生成一个带倒计时的OG图片,需要后端参与,纯前端方案比较受限,但可以借助Canvas生成一张静态图作为社交预览。
建议对某一个方向感兴趣的读者,直接fork项目,对照着现有代码去改。改完一个实验,再把心得写成README提交Pull Request,这样的开源参与方式比单纯收藏一个项目有收获得多。
我在实操中的几点体会
这个项目从构思到开源,最让我意外的是“简单需求里往往藏着最丰富的技术细节”。一个倒计时,能牵扯出日期解析、渲染性能、定时器调度、Worker通信、跨平台兼容等这么多话题,对前端学习者来说是非常好的综合训练场。
如果一定要给后来者提建议,我会说:不要一上来就追求花哨的视觉,先把时间计算和更新逻辑做成一个可靠的核心模块,再在这个基础上做视觉。核心稳了,外围怎么玩都不会出大问题。
我在实际使用中发现,引入requestAnimationFrame做时间校准,配合每次重新计算Date.now()的方式,比单纯依赖定时器可靠得多。哪怕把网页挂机一晚上,第二天打开时显示的倒计时依然是准确的,这个概念放在很多真实业务里也同样成立。
最后分享一个小技巧:这类小型实验合集项目,很建议从一开始就加上在线预览链接和一份精简的运行说明。开源的价值不只是代码本身,而是让人能快速跑起来、快速改起来。希望你也能从这个小项目里找到属于你自己的灵感。
