React 第6周笔记 阶段项目 —— 语义色板生成器
路线图阶段:第一阶段(React 基础)第6周 —— 收官项目 项目:语义化配色生成器(主色 → 推导 5 个角色色) 本周不学新语法,而是综合前 5 周全部技能,做一个能上线、能进作品集的成品 部署:Vercel + GitHub 自动部署
项目定位:为什么这个项目”有价值”
不是随机配色器,而是语义色板生成器:把一个主色当”数据源(source of truth)“,用 HSL 数学推导出一整套有角色的颜色(Primary / Secondary / Accent / Background / Foreground)。
这正是 design engineer 真实工作的缩影(design tokens / 主题系统):底层是配色逻辑,上层是消费它的 UI。比随机配色器更能讲出故事。
1. 核心架构:逻辑层 / UI 层分离(本周最重要的工程思维)
颜色逻辑层(纯函数,零 React) React 表现层(组件)
hexToRgb / rgbToHsl / hexToHsl ColorPalette(管 state)
generatePalette ColorPicker / ColorInput
hslToCss / isValidHex / textColorFor → 消费逻辑层的输出来渲染
纯函数(Pure Function):给定输入永远返回相同输出,不碰 React、不产生副作用。
- 好测试:能单独
console.log验证,不用跑整个 UI。 - 好复用:换 Vue、原生 JS、Node 后端都能搬。
- 好排错:配色错了找逻辑层,显示坏了找组件,问题有”地址”。
类比设计:Figma 的变量/样式(token)是逻辑层,组件是表现层。定义一次 token,到处引用。纯函数就是代码里的 token 逻辑层。
2. 颜色模型:为什么用 HSL 不用 RGB
HSL 把颜色拆成符合人直觉的三个维度:
H (Hue 色相) 0–360° 红?蓝?绿?(色环角度)
S (Saturation 饱和度) 0–100% 鲜艳还是灰暗
L (Lightness 亮度) 0–100% 亮还是暗
有了 HSL,“推导配色”就是简单数学:
- 转色相 → 得到相关色(类比色 / 互补色)
- 拉高/压低亮度 → 得到背景 / 前景
- 调饱和度 → 控制”高级感”(低饱和 = earthy/克制)
RGB 做不到这种直觉操作,所以必须 HEX → RGB → HSL 转换。
3. 推导规则:generatePalette
function generatePalette({ h, s, l }) {
const background = { h: wrapHue(h), s: 20, l: 95 }; // 同色相,极浅,低饱和
const foreground = { h: wrapHue(h), s: 25, l: 15 }; // 同色相,极深
const primary = { h: wrapHue(h), s, l }; // 主色本身
const secondary = { h: wrapHue(h + 30), s: s - 2, l: l + 1 }; // 类比色(小角度)
const accent = { h: wrapHue(h - 150), s: s + 3, l: l - 4 }; // 强调色(大角度)
return { primary, secondary, accent, background, foreground };
}
关键设计点
- 背景/前景保留主色的 H(色相),只是低饱和,不用纯灰。 这一丝共享的”色彩 DNA”,让整套配色像”一家人”,而非拼凑。纯灰会让背景和主色脱节。
- 背景/前景压低饱和度(S=20/25):越中性的角色越要压饱和,高级感来自克制。
色环边界处理:wrapHue
色相是环(0°=360°),加减后可能越界(负数或 >360),要”绕回来”:
function wrapHue(h) {
return ((h % 360) + 360) % 360; // 经典写法:处理负数 + 超界,落回 0–359
}
h % 360先取余(JS 负数取余仍为负)+ 360推成正数- 再
% 360处理本来就 >360 的情况
S 和 L 是 0–100 的直线不是环,越界要用”夹紧(clamp)“而非 wrap。但微调幅度小时一般不越界,先不处理,别提前复杂化。
4. React 层:一个数据源 + 派生
const [primaryHex, setPrimaryHex] = useState("#C16952"); // 唯一数据源
const palette = generatePalette(hexToHsl(primaryHex)); // 派生,当场算,不存 state
主色一变 → 重渲染 → palette 重算 → 整套色卡自动更新。 五个角色色全是派生状态,一个都不存 state(呼应第5周相册的 filteredPhotos)。
⚠️ 初始值必须是合法 hex(如 “#C16952”),不能用空字符串 ""。 空字符串会让 hexToHsl("") 算出 NaN,配色崩溃/白屏。color picker 也必须有合法初始值。
5. 双输入同步 + 草稿状态(Draft State)
取色器 + 文字框两种输入主色,但文字框不能直接绑 primaryHex:用户逐字输入时,中间状态(“#A0B4”)是不完整 hex,会让 hexToHsl 算出 NaN 崩掉。
解法:文字框用独立”草稿”state,合法了才提交
const [primaryHex, setPrimaryHex] = useState("#C16952"); // 驱动配色
const [hexInput, setHexInput] = useState("#C16952"); // 文字框草稿
function handleHexInput(e) {
const value = e.target.value;
setHexInput(value); // 草稿永远更新(用户能看到自己打的字)
if (isValidHex(value)) { // 仅当合法 hex 才提交
setPrimaryHex(value);
}
}
function handlePickerChange(e) {
const value = e.target.value;
setPrimaryHex(value); // 取色器吐出的一定合法,直接提交
setHexInput(value); // 同时同步文字框,保持一致
}
这是”两个 state”的正当场景:草稿值 和 已确认主色 是两个不同的东西,不能互相派生。 (对比派生状态原则:能算出来的才不存;这里草稿是独立输入,必须存。)
isValidHex:正则验证
function isValidHex(hex) {
return /^#[0-9A-Fa-f]{6}$/.test(hex); // # + 恰好 6 位十六进制字符
}
6. 用 Object.entries + 数组解构渲染色卡
palette 是对象,不能直接 .map()。用 Object.entries 转成数组:
Object.entries(palette)
// [["primary", {h,s,l}], ["secondary", {h,s,l}], ...] 每项是 [角色名, 颜色对象]
{colorsArray.map(([role, color]) => ( // 数组解构:方括号,按位置取
<div key={role} style={{ backgroundColor: hslToCss(color) }}>
{role}
</div>
))}
- key 用
role(角色名),天然唯一。 - 数组解构
[role, color](方括号,按位置) vs 对象解构{query}(花括号,按名字)。
7. 自动文字色(可读性)
深色块配白字、浅色块配黑字,用亮度 L 阈值自动决定:
function textColorFor({ l }) {
return l > 55 ? "#1a1a1a" : "#ffffff";
}
设计时凭眼睛判断的事,用 L 阈值量化成代码。
8. 点击复制 HEX:navigator.clipboard
const [copied, setCopied] = useState(null); // 记录刚复制的角色
async function handleCopy(role, hex) {
try {
await navigator.clipboard.writeText(hex); // ⚠️ 必须 await,catch 才能抓到失败
setCopied(role);
setTimeout(() => setCopied(null), 1000); // 1 秒后清除提示
} catch (error) {
console.log("复制失败:", error);
}
}
navigator.clipboard.writeText(str):浏览器内置剪贴板 API,异步(返回 Promise)。- ⚠️
async函数里必须await,否则失败发生在”将来”,try/catch 抓不到(第4周原理)。 setTimeout(fn, 1000):1 秒后执行 fn,实现”已复制!”提示闪现。- 色卡 HEX 标注用三元:
copied === role ? "已复制!" : hslToHex(color)。 - 点击传参 → 箭头包裹:
onClick={() => handleCopy(role, hslToHex(color))}。
9. cursor / hover / 事件 的区别(易混)
cursor: "pointer"(CSS 属性):鼠标悬停变手形,静态声明,不是事件。本周用这个。- CSS
:hover(伪类):悬停改样式,但行内样式写不了,需 CSS 文件 → Tailwind 阶段解决。 onMouseEnter/onMouseLeave(JS 事件):悬停跑 JS 逻辑才用,本周不需要。
10. 部署:Vercel + GitHub 自动部署
本地 npm run build 验证 → git push 到 GitHub
→ Vercel 用 GitHub 登录 → Import 仓库
→ 自动识别 Vite(Build: npm run build, Output: dist)→ Deploy
→ 得到 https://xxx.vercel.app 链接
- Vercel 专为 Vite/React 而生,零配置,5 分钟出链接。
- 自动部署:之后每次 git push,Vercel 自动重新构建部署(和 Astro 博客的 CI/CD 同理,但无需自己写配置)。
- VPS 自主部署留作之后的独立技能练习,别卡在收尾阶段。
⚠️ 作品集仓库要独立、命名规范(color-palette-generator),别用 my-react-week1 这种学习文件夹推。 ⚠️ README 只写真实实现的功能,未完成的标成 roadmap。作品集造假是面试大忌。
第6周验收
- 综合运用前 5 周全部技能(state / 事件 / 列表 / 派生 / 组件拆分)
- 逻辑层(纯函数)与 UI 层分离
- 一个数据源 + 派生出整套配色
- 双输入同步 + 草稿状态保护
- 部署上线,拿到作品集链接 + 写 README
第一阶段(第1-6周)总收尾
| 周次 | 主题 | 项目 |
|---|---|---|
| 1-2 | 组件 / props / state / 事件 | 名片 / 主题切换 / 计数器(自学) |
| 3 | 列表 / 表单 / 不可变更新 | Todo List |
| 4 | useEffect / 异步 / API | 小相册 |
| 5 | 组件拆分 / 状态提升 | 带搜索的相册 |
| 6 | 综合 / 部署 | 语义色板生成器(作品集) |
贯穿始终的核心心智模型
- UI = f(state):状态变,UI 自动跟着变。
- 派生状态:能从现有 state 算出来的,直接算,不要存。
- 逻辑 / UI 分离:纯函数管逻辑,组件管显示。
- 数据下行,事件上报:props 往下传值,回调往上报变化。
- 不可变更新:用 …展开 / map / filter 造新数组,不改原数据。
反复提醒过、需继续巩固的点
- 事件处理传参 → 箭头函数包一层:
onClick={() => fn(arg)}(口诀:事件处理要传参,箭头包裹慢慢谈) - 箭头函数
{}要 return,()自动返回
下一步:第二阶段(第7-10周)预告
- 现代 JavaScript 补全:数组方法(map/filter/reduce)、解构、async/await、fetch —— 你这一阶段反复用到、但没系统学的 JS 地基,正式补牢。
- TypeScript 实用核心:给 props / state / 函数参数加类型。
- Tailwind CSS:回头把配色生成器的 UI 重做成精致版(v2)。
- 练习:把第一阶段项目改写成 TypeScript。