1. 先说说Easy-Vibe Task02到底是个什么任务
如果你关注过Easy-Vibe这个开源学习项目,应该知道它跟传统"看完视频敲一遍代码"的教程不太一样。整个系列被设计成一条任务链,每一关都会给你一个具体到能落地的产出物,而不是一章一章地讲语法。Task02是这条链上的第二个任务,核心要求一句话就能说清:用尽可能少的依赖,实现一个包含用户输入、状态持久化和动态渲染的轻量交互应用。
我在报名这个系列之前,其实已经会写一些零散的页面,但状态管理、数据存储、渲染更新这些概念一直停留在"听说过、没亲手串起来"的程度。Task02给的任务是做一个"心情记录"类的单页应用——用户能输入当前的心情文字,选择几个预设标签(比如专注、焦虑、平静),再用滑动条打个能量分,提交后内容出现在一条按日期分组的时间线里,刷新页面后数据不能丢。听起来不复杂,但它恰好把前端最基础的三块硬功夫全串起来了:DOM交互、本地存储、数据驱动渲染。
任务书还附了几条隐藏验收标准:不允许引入UI组件库、不允许使用任何前端框架、必须能在纯静态环境下运行。这意味着我没办法靠"装个Vue + Element Plus"绕过问题,所有交互和渲染逻辑都要自己写。对于想夯实基础的人来说,这其实是一件好事——框架帮你藏起来的那些细节,现在全暴露在你面前了。
这个任务适合谁来参考?我觉得是两类人:
- 已经学过HTML/CSS/JavaScript基础语法,但没完整做过一个交互应用的新手。
- 写过业务代码但习惯性依赖框架,想搞明白"离开框架之后,一个页面是怎么自己跑起来"的开发者。
下面这份笔记,是我从头到尾完成Task02的记录,包含了方案选型、每个核心模块的实现思路、本地调试一切正常但部署后翻车的三个典型问题。如果你也卡在这个任务上,或者正在带别人做类似的学习项目,应该能省下不少时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 任务拆解与方案选型:为什么我坚持用原生JavaScript加Vite
先说我最终的技术栈:原生 JavaScript + Vite + localStorage,没有用任何运行时依赖。任务本身不限制构建工具,Vite只是用来起开发服务器和做打包,它不参与业务逻辑。
2.1 选型前我问自己的三个问题
拿到任务后,我第一反应是"能不能上个框架"。但我给自己列了三个判断标准,逐个过了一遍:
- 任务的目的是什么?——不是产出最潮的工程,而是验证基础能力。用框架会把"数据如何驱动视图更新"这个核心环节藏起来。
- 运行环境允许什么?——要求纯静态部署,不能有后端。React/Vue 在这种场景下确实可行,但打包体积和依赖复杂度完全没必要。
- 我的时间成本花在哪更值?——如果把时间花在学框架API上,恰恰绕开了这个任务想考察的东西。学框架什么时候都能学,但"手动把状态和视图同步起来"的体感,只有自己写一遍才能建立。
所以理论上还有一个选项是"用 Svelte 或 Preact 这种轻量框架",它们也够快、够轻。但我最终否掉了,因为任务的核心是让你理解原理,不是让你体验工具链。用原生JavaScript写出来的代码,每一条语句你都知道它在干什么,出了问题也更容易定位。
2.2 项目目录结构:能看懂的工程比能跑的工程更重要
初始化项目用的是Vite官方模板,但我在目录上做了一点调整,让结构直接对应任务的核心概念:
code复制easy-vibe-task02/
├── index.html
├── src/
│ ├── main.js # 入口文件,负责启动应用
│ ├── storage.js # localStorage封装层
│ ├── render.js # 渲染层,数据到DOM的映射
│ ├── form.js # 表单交互逻辑
│ ├── timeline.js # 时间线数据分组处理
│ └── style.css # 样式
└── package.json
每个文件只干一件事:storage.js 管数据读写,render.js 管视图更新,form.js 管用户输入。如果后面想给它加个后端同步,只需要替换 storage.js 的实现,其他文件几乎不用动。这种"各司其职"的组织方式,比把所有代码塞进一个 main.js 要更容易排查问题。
关于标题里的 Easy-Vibe,我后来在系列文档里读到,它其实是"Easy + Vibe"的组合,意思是"用轻松的方式建立对技术的感知"。这个理念很直接地影响了我的实现风格——不追求炫技,而是让每段代码都能被一眼看懂。
3. 核心功能实现:打卡表单、存储层与时间线渲染的完整链路
这个任务说到底是"输入到存储,存储到渲染"的数据流闭环。我把它拆成了三个模块来写,每个模块都从"它要解决什么问题"出发,而不是一上来就敲代码。
3.1 打卡表单:先把"用户产生了什么数据"定义清楚
表单是整个应用的入口。用户面对的是一个文本框、四个标签按钮(专注、放松、焦虑、疲惫)和一个能量值滑动条。
我在写 form.js 之前,先定义了一条记录的数据结构:
javascript复制{
id: '20240615-163000-482',
text: '下午把任务二的渲染逻辑理清了,很有成就感',
tags: ['专注'],
energy: 72, // 0-100之间的整数
createdAt: 1718440200000 // 时间戳,方便排序和分组
}
这里有个小设计要点:createdAt 存的是时间戳而不是格式化好的字符串。原因很简单——存储层只负责存原始数据,格式化是展示层的事。如果存了"2024年6月15日 16:30"这种字符串,后续要做按日期分组时还得重新解析,纯属自找麻烦。
表单提交时做了一些基本校验:
javascript复制export function collectFormData() {
const text = textInput.value.trim();
if (!text) {
showToast('写点什么再提交吧');
return null;
}
if (tags.length === 0) {
showToast('至少选一个标签');
return null;
}
return {
id: generateId(),
text,
tags: [...selectedTags],
energy: energySlider.value,
createdAt: Date.now()
};
}
注意 selectedTags 用的是 [...selectedTags] 展开,而不是直接赋值。这是因为 selectedTags 是一个数组,如果直接把它放进对象里,后面操作这个数组时会连累已经存进 localStorage 的数据。对象里存数组、数组里存对象,只要涉及引用类型,就要有"拷贝一份再存进去"的意识,这是新手最容易踩的暗坑。
3.2 存储层封装:localStorage用起来简单,但别裸写
Task02要求数据在刷新后不丢,最直接的办法就是 localStorage。但我不建议在业务代码里到处直接调用 localStorage.setItem,因为散落的调用点会带来两个问题:一是你无法统一处理"存储不可用"的异常情况,二是将来想换存储方案(比如IndexedDB或后端API)时,要改的地方太多。
所以我写了一个极简的存储层:
javascript复制const STORAGE_KEY = 'easy-vibe-task02-records';
export function loadRecords() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const data = raw ? JSON.parse(raw) : [];
return Array.isArray(data) ? data : [];
} catch (e) {
console.warn('读取本地存储失败,返回空列表', e);
return [];
}
}
export function saveRecords(records) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(records));
} catch (e) {
console.warn('写入本地存储失败', e);
showToast('浏览器存储空间不足,数据可能无法保存');
}
}
这里加了两个防护:
JSON.parse失败时返回空数组,而不是直接抛异常。因为本地数据可能因为人为修改、版本升级等原因变成非法JSON,这时候强行让应用崩溃,体验太差。- 写入时用
try/catch包住。localStorage 的存储空间大约是5MB,如果用户之前存了大量其他站点的数据,或者禁用了站点存储,setItem会抛异常。捕获后给用户一个明确提示,至少比静默失败好。
关于 loadRecords 里 Array.isArray(data) 的检查,是一个成本极低但收益很大的防御。如果哪一天数据结构从数组变成了对象,这个检查能拦住一大部分渲染层的报错。
3.3 时间线渲染:手写一个"数据变了就重新画"的机制
渲染层是这个任务里最值得细说的部分。在没有框架的情况下,实现数据驱动视图的核心思路只有一句话:把"数据状态"和"页面呈现"绑定起来,数据一变,就调用渲染函数把整个列表重新画一遍。
我的渲染函数长这样:
javascript复制export function renderTimeline(records) {
const groups = groupByDate(records);
const container = document.getElementById('timeline');
container.innerHTML = '';
Object.keys(groups)
.sort((a, b) => b.localeCompare(a)) // 新日期排前面
.forEach(dateStr => {
const section = document.createElement('section');
section.className = 'timeline-group';
const title = document.createElement('h3');
title.textContent = dateStr;
section.appendChild(title);
groups[dateStr].forEach(record => {
section.appendChild(createRecordCard(record));
});
container.appendChild(section);
});
}
这种做法简单粗暴,但非常可靠。它牺牲了一点点性能(每次渲染都重建整个列表的DOM),换来了逻辑上的绝对清晰。对于Task02这种量级(个人使用,一天最多几十条记录),完全够用。
如果你以前写过React,会发现这个思路跟React的 setState + 重新渲染在本质上是一样的,只不过React帮你做了DOM diff,我这里直接全量替换。先掌握"全量重绘"的朴素实现,再去看框架的"精准更新"优化,理解曲线会平缓很多。
groupByDate 负责把时间戳转成"YYYY年MM月DD日"的格式并按日期分组:
javascript复制function groupByDate(records) {
return records.reduce((acc, record) => {
const d = new Date(record.createdAt);
const dateKey = `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`;
if (!acc[dateKey]) acc[dateKey] = [];
acc[dateKey].push(record);
return acc;
}, {});
}
注意这里我特意写了 d.getMonth() + 1。 getMonth() 返回的是0到11,所以实际月份必须加1。这是一个所有人都知道、但依然会反复犯的错,排错章节里我会再展开一次。
3.4 能量值滑动条:从"能拖"到"好用"的细节打磨
滑动条是Task02里看起来最不起眼、但体验细节最多的地方。基础HTML写法是这样:
html复制<input type="range" id="energy-slider" min="0" max="100" value="50">
如果到此为止,用户拖动时看不到任何数字反馈,根本不知道自己打了多少分。所以我在 form.js 里加了实时同步:
javascript复制const energySlider = document.getElementById('energy-slider');
const energyValue = document.getElementById('energy-value');
energySlider.addEventListener('input', () => {
energyValue.textContent = energySlider.value;
});
这里用 input 事件而不是 change 事件。区别在于:input 会在拖动过程中持续触发,change 只在松手时触发一次。如果为了减少事件回调频率而选择 change,用户就看不到实时数字,手感会差很多。当然,如果你的回调里有复杂计算或请求,需要防抖,那就另当别论;但只是更新一个数字, input 是完全没问题的。
另一个体验细节是滑动条的轨道填充色。默认的 input[type=range] 在不同浏览器里长得很不一样,而且轨道背景是一条纯灰色。我简单处理了一下,让轨道左侧(已选择部分)显示为主题色:
css复制input[type="range"] {
-webkit-appearance: none;
height: 6px;
border-radius: 3px;
background: linear-gradient(to right, #4f7cff 0%, #4f7cff var(--fill-ratio, 50%), #e2e6f0 var(--fill-ratio, 50%), #e2e6f0 100%);
}
--fill-ratio 这个CSS变量由JavaScript根据当前值动态设置。这样用户拖动的瞬间,轨道颜色和数字一起变化,整个控件才算是"活"的。
4. 本地一切正常、部署却翻车:Task02里最值得记录的三个坑
任务做完时,我在本地 npm run dev 里跑了一遍又一遍,功能全部正常,于是自信满满地打包部署。结果静态托管上去之后,连续发现三个问题。这些坑单看都特别基础,但它们确实会一个接一个地出现,值得专门记录。
4.1 日期格式化:getMonth()从0开始这件事,光是知道没用
我的第一个翻车现场是日期显示错位。本地时间2024年6月15日,时间线上显示的是"2024年7月15日"。排查过程很简单,打开控制台看了一眼 new Date(record.createdAt) 输出,发现月份被加了1——因为我的 groupByDate 函数里写的是 d.getMonth() + 1,而这段代码是后补的,补的时候在另一个地方又写了一个 d.getMonth() 直接返回。
问题本质是 getMonth() 返回的是0-11,不是1-12。解决方法是确保所有涉及月份的地方统一加1,或者干脆封装一个专门格式化日期的函数,全项目只引用这个函数:
javascript复制function formatDateKey(timestamp) {
const d = new Date(timestamp);
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${month}-${day}`;
}
这个教训让我养成了一个习惯:时间处理不要散落在各处,抽成一个工具函数,然后配合单元测试验证边界情况。对于Task02这种小项目,至少要做到"全项目只有一个地方做日期格式化"。
4.2 移动端100vh:地址栏忽大忽小,底部按钮被顶出屏幕
第二个坑是移动端布局。我在CSS里写了:
css复制.app {
min-height: 100vh;
}
在本地用手机模拟器看没问题,但部署后用真实手机打开,发现页面底部会有一条空白区域,有时候底部按钮被顶到屏幕外面。原因是移动端浏览器的地址栏会动态伸缩,100vh 等于"视口最大高度",而不是"当前可见高度"。地址栏收起时页面变高,地址栏展开时 100vh 依然按最大高度算,底部就被挤出去了。
解决方案是把 min-height: 100vh 改成 min-height: 100dvh(dvh 是动态视口高度单位,会跟随浏览器UI变化):
css复制.app {
min-height: 100dvh;
}
@supports not (min-height: 100dvh) {
.app {
min-height: 100vh;
}
}
@supports 那行是为了兼容不支持 dvh 的旧浏览器,回退到 100vh。这个兼容性写法其实很重要,因为很多自动测试环境跑的浏览器版本偏老,直接写 100dvh 可能会在测试环境里失效。
4.3 部署后白屏:原因是Vite的base路径配置
第三个坑是最常见的部署问题。本地 npm run build 之后,我直接把 dist 文件夹传到了静态托管的子目录 /tools/easy-vibe/ 下,结果打开页面白屏,控制台一堆资源加载404。
原因很简单:Vite 默认的 base 路径是 /,它会生成 /assets/index-xxxx.js 这样的绝对路径引用。静态托管把资源部署在了 /tools/easy-vibe/ 下,而页面去 /assets/ 找文件,自然找不到。
解决办法是在 vite.config.js 里设置 base:
javascript复制import { defineConfig } from 'vite';
export default defineConfig({
base: './', // 用相对路径引用资源,适配任何子目录部署
});
设成 './' 之后,构建出的资源引用就变成了 ./assets/index-xxxx.js,整个 dist 文件夹放到任何路径下都能跑起来。这个改动的影响是全局的,改完需要重新打包。
如果你要用CDN或独立域名部署,base 可以保持默认的 /;但只要你的页面可能被放在子路径下,趁早设置 './' 一劳永逸。
4.4 JSON序列化的隐性问题:undefined、Date和NaN在localStorage里的表现
第三个坑严格说是"存储层"问题,但它是部署后才暴露的,因为部署环境的数据跟本地开发数据来源不同,更容易触发异常情况。
localStorage 只能存字符串,所有对象都要经过 JSON.stringify。这个函数有两个我一开始没在意的行为:
- 对象里的
undefined、函数、Symbol 属性会被直接丢弃,不会报错。 NaN和Infinity会被转成null。
这意味着,如果你在表单里允许能量值变成 undefined(比如初始化时忘记给滑动条设置 value),存进去以后就永远消失了。我在Task02里做了个防御:Number(energySlider.value) || 0,确保 undefined 和空字符串都落成 0:
javascript复制const energy = Number(energySlider.value);
if (Number.isNaN(energy)) energy = 0;
这不算是什么高深技巧,但确实是在真实数据里踩到过、才记得住的细节。
5. 任务复盘:哪些设计值得保留,哪些是在给自己加戏
Task02从开工到提交,前后花了三天。整个过程中有些决定我后来觉得非常正确,也有几处明显是过度设计,这里整理出来,给后面做Task03的自己留个参考。
5.1 值得保留的三件事
第一,存储层独立封装。 这是我认为整个项目里最值的投资。因为Task03已经开始要求接入后端API了,我只需要把 storage.js 里的 loadRecords 和 saveRecords 换成 fetch 调用,业务代码几乎零改动。这种"把容易变化的部分隔离起来"的边界意识,比具体用什么存储方案更重要。
第二,坚持用原生DOM操作而不是字符串模板。 我在最初版本里是用模板字符串拼HTML的:
javascript复制container.innerHTML = `
<div class="card">
<p>${record.text}</p>
</div>`;
后来发现两个问题:一是用户输入的内容可能包含HTML标签,直接插入会带来XSS风险,需要特殊处理;二是事件绑定很麻烦。所以我改成了 createElement + textContent 的方式:
javascript复制const card = document.createElement('div');
card.className = 'record-card';
const textEl = document.createElement('p');
textEl.textContent = record.text;
card.appendChild(textEl);
虽然代码量变多了,但 textContent 会自动转义HTML字符,XSS风险直接消失,而且后续给卡片添加点击事件也方便。看起来"啰嗦"的方式,在实际工程里反而更可靠。
第三,把任务当做"可以给别人看的小作品"来做。 我在提交前给页面补了一个极简的引导说明,告诉首次打开的用户"这里可以记录每天的心情状态,数据只保存在你自己浏览器里"。这不在任务验收范围内,但在社区分享时,别人打开你的Demo知道这东西是干什么的、数据怎么处理,体验完全不同。这也是Easy-Vibe系列一直在强调的:做一个"完整"的东西,而不是做一个"能交差"的东西。
5.2 砍掉的过度设计
我一开始给Task02设计了一个"按标签筛选"的功能,准备了四个筛选按钮,点击某个标签后时间线只显示对应记录。功能写到一半我停住了,因为在"所有数据都在本地"的场景下,记录总数撑死一两百条,全量展示根本不会卡,筛选功能解决的是一个并不存在的痛点。
另一个砍掉的是"编辑已有记录"。编辑功能看着简单,但要处理"编辑时数据已重新渲染"的状态同步问题,复杂度比新增高一个量级。Task02的核心目标是打通数据流,不是做数据管理后台。真要支持编辑,应该等到任务里引入真正的状态管理方案时再加。
砍掉功能的判断标准很简单:如果这个功能去掉后,核心流程依然完整,那就说明它可能不是现在该做的。
5.3 我给自己定下的Task03目标
Task02做完之后,我对"前端应用是从用户输入到界面反馈的一条完整链路"这件事,终于有了体感。后面的Task03,我给自己定了三个目标:
- 把存储层从 localStorage 换成真正可多人共享的后端接口,验证当前封装的扩展性。
- 至少写三个自动化测试,覆盖"表单提交后数据正确写入存储"、"时间线按日期倒序渲染"和"非法输入被拦截"这三条核心链路。
- 页面视觉上做一个统一的深色主题,不再用浏览器默认控件。
如果你也在做Easy-Vibe系列或者类似的任务式学习,我的建议是:不要在第一个任务里追求完美,但每完成一个任务,都要留下一个"让自己觉得麻烦但值得"的模块。这些模块会变成你下一关的地基。
