普通视图

发现新文章,点击刷新页面。
昨天以前首页

Tailwind CSS + lucide-react:手搓一个能打的产品级登录页

2026年1月18日 01:06

登录页这玩意儿,表面看就是两个输入框加按钮,但写过的人都知道——它简直是前端工程的“照妖镜”。组件抽象、状态管理、响应式、加载态、可访问性,全在这方寸之间。今天就把我踩过的坑、验证过的最佳实践,完整复盘一遍。


技术选型:为什么不是“全家桶”而是“三剑客”?

Vite:别用 CRA 了,真的

2024 年还用 CRA 新建项目,就像今天还在用 jQuery 写新需求——不是不行,只是没必要。Vite 的秒级启动、按需热更新、对 React 的丝滑支持,用了就回不去。

npm init vite@latest login-demo -- --template react

Tailwind CSS:原子化 CSS 的“真香”现场

刚开始我也抵触过——“这不就是 inline style 吗?”用了三个月后发现,Tailwind 的精髓在于用约束换自由

  • 不再纠结 .login-input--error 还是 .login__input-error
  • 样式紧耦合组件,重构时删组件即删样式,不留垃圾
  • 设计系统(间距、色板、圆角)被工具类强制约束,UI 一致性自然来

lucide-react:图标库的“现代化”答案

放弃 iconfont 吧,字体图标在 Retina 屏下糊、在 SSR 场景下闪、在暗黑模式里要单独维护反色。lucide-react 的 SVG 组件化方案:

  • Tree-shaking,只打包用到的图标,体积 < 10KB
  • 可传 size, className, strokeWidth,和普通组件无异
  • 和 Tailwind 的 text-*, fill-* 类无缝配合
import { Lock, Mail, Eye, EyeOff, Loader2 } from 'lucide-react';

登录页的业务复杂度:UI 只是冰山一角

真正落地的登录页至少包含:

  • 受控组件:杜绝 document.getElementById,数据单一源
  • 表单校验:实时反馈、错误聚合、防抖提交
  • 加载态:按钮禁用、节流、异步反馈
  • 密码显隐:无障碍支持(aria-label
  • 响应式:移动端优先的触控体验
  • 自动填充:处理浏览器自动填充的黄色背景
  • 安全:防止 XSS、CSRF Token 透传

React 状态设计:别写“面条代码”

1. 状态聚合:一个对象管所有

新手容易写成这样:

const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(false);

维护过老代码的都知道,新增一个字段要改三行,提交时要拼半天对象。正确姿势:

const [form, setForm] = useState({ email: '', password: '', rememberMe: false });
const [errors, setErrors] = useState({});
const [ui, setUi] = useState({ loading: false, showPassword: false });

三层状态分离:数据层(form)、校验层(errors)、UI 层(ui)。各司其职,后续维护一目了然。

2. 表单处理的“万能钥匙”

const handleChange = (e) => {
  const { name, value, type, checked } = e.target;
  
  setForm(prev => ({
    ...prev,
    [name]: type === 'checkbox' ? checked : value
  }));
  
  // 实时清错
  if (errors[name]) {
    setErrors(prev => ({ ...prev, [name]: '' }));
  }
};

关键点:

  • 利用 name 属性做映射,扩展新字段零成本
  • 输入即清错,用户体验细节
  • 支持 text, password, checkbox, select 等所有表单元素

3. 提交逻辑的“防御性编程”

const handleSubmit = async (e) => {
  e.preventDefault();
  
  const nextErrors = validate(form);
  if (Object.keys(nextErrors).length) {
    setErrors(nextErrors);
    return;
  }
  
  setUi(prev => ({ ...prev, loading: true }));
  try {
    await onLogin(form); // 业务注入
  } catch (err) {
    setErrors({ form: err.message });
  } finally {
    setUi(prev => ({ ...prev, loading: false }));
  }
};

记住:loading 态必须在 finally 里关闭 ,无论成功失败,用户都要有反馈。


Tailwind 的工程化细节:不是堆砌,是设计

1. 容器:响应式的“黄金分割”

<div className="w-full max-w-md mx-auto px-6 py-8 md:px-10 md:py-12">
  • w-full max-w-md:移动端 100%,PC 端最大 448px
  • mx-auto:居中,无需额外写 margin: 0 auto
  • px-6 md:px-10:断点平滑过渡,避免“跳变”

2. 表单间距:space-y 的魔法

<form className="space-y-6" onSubmit={handleSubmit}>
  <div>...</div>
  <div>...</div>
  <button>...</button>
</form>

space-y-6 自动给每个子元素加 margin-top,除了第一个。等价于:

.form > * + * {
  margin-top: 1.5rem;
}

但语义更清晰,且避免了 first:mt-0 这类修补。

3. 输入框:状态即 class

<input
  className={clsx(
    "w-full rounded-lg border px-10 py-3 text-base transition",
    "border-slate-300 bg-white placeholder:text-slate-400",
    "focus:border-indigo-600 focus:ring-2 focus:ring-indigo-600/20 focus:outline-none",
    errors.email && "border-red-500 ring-2 ring-red-500/20"
  )}
/>
  • 利用 clsxclassNames 做条件合并
  • 聚焦态、错误态、默认态全用 class 表达
  • focus:outline-none 移除默认蓝框,用 ring 替代,更可控

4. 图标定位:group 的联动

<div className="relative group">
  <Mail className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-600" />
  <input className="pl-10 ..." />
</div>
  • group-focus-within:父级聚焦,图标变色
  • pointer-events-none:让点击穿透到 input
  • 无需手写 :focus-within 选择器

lucide-react:图标是组件,不是字体

密码显隐:带无障碍支持的完整实现

<button
  type="button"
  onClick={() => setUi(prev => ({ ...prev, showPassword: !ui.showPassword }))}
  aria-label={ui.showPassword ? '隐藏密码' : '显示密码'}
  className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 rounded-md hover:bg-slate-100 transition"
>
  {ui.showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>

要点:

  • aria-label 读屏软件可读
  • type="button" 防止触发提交
  • hover:bg-slate-100 增加触控反馈

加载态:图标即动画

{ui.loading && <Loader2 className="mr-2 inline-block animate-spin" />}

animate-spin 是 Tailwind 内置动画,配合 lucide 的 Loader2 图标,无需额外写 CSS 动画。


响应式与暗黑模式:一次写好,到处适用

Mobile First 的触控优化

jsx

Copy

<input className="min-h-[44px] ..." /> {/* iOS 最小触控高度 */}
<button className="min-h-[44px] active:scale-95 ..."> {/* 按下反馈 */}

暗黑模式:Tailwind 的 dark 前缀

<div className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">
  <Sun className="dark:hidden" />
  <Moon className="hidden dark:block" />
</div>

只需在 tailwind.config.js 里启用 darkMode: 'class',然后在顶层加 dark 类即可。


效果图

image.png


最后

登录页是前端工程师的“基本功”,也是“面试常考题”。把状态设计、Tailwind 原子类、可访问性、加载态这些细节处理好,后续复杂业务才能游刃有余。

记住:代码是写给下一个维护你的人看的,包括三个月后的自己。

Zustand:若 React 组件是公民,谁来当“中央银行”?—— 打造轻量级企业级状态管理

2026年1月16日 13:48

前言:在 React 的世界里,如果说组件(Component)是勤勤恳恳工作的公民,那么状态(State)就是流动的货币。当应用规模扩大,仅仅靠父子组件间的“现金交易”(Props drilling)已经无法维持经济系统的运转。我们需要一个中央银行,一个专业的财务管理部门。

今天,我们不谈繁琐的 Redux,而是聊聊 Zustand —— 这个来自德国的“小而美”的状态管理库,看看它是如何通过极其精简的 API,帮我们把“企业做大做强”的。


一、 为什么我们需要“中央银行”?

我们在写 React 组件时,心中的公式往往是:

UI=f(State)UI = f(State)

但在实际开发中,如果不引入全局状态管理,我们面临着几个痛点:

  1. 层级地狱:想要把孙子的状态传给爷爷,Props 需要传递数层。
  2. 兄弟失联:兄弟组件之间无法直接通信,状态必须提升(Lifting State Up)到共同父级,导致不必要的重渲染。

“企业做大做强,请管理财务、状态以及修改状态的规矩。”

Zustand 就是这样一个基于 Hooks 思想实现的中央管理系统。它将状态存入 Store(仓库),实现全局共享,且不需要在最外层包裹繁琐的 Provider。


二、 建立你的第一家“分行”:基础状态管理

让我们从最简单的计数器开始。在 Zustand 中,创建一个 Store 就像开一家分店一样简单。

1. 定义规矩(Interface)与 存储(Store)

在“企业管理”中,不仅要有钱(Count),还要有动用这笔钱的规矩(Actions)。

TypeScript

// store/counter.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

// 1. 定义账本的结构:即使是小钱,也要有类型约束
interface CounterState {
    count: number;
    increment: () => void;
    decrement: () => void;
    reset: () => void;
}

// 2. 创建金库,并制定修改规则
export const useCounterStore = create<CounterState>()(
    persist( // 使用中间件,相当于给金库加了把“永久保存”的锁
        (set) => ({
            // 列出资产(状态)
            count: 0,
            
            // 状态要怎么改?必须通过合法的手段(Action)
            // set 函数是 Zustand 的核心,它是唯一合法的修改器
            increment: () => set((state) => ({ count: state.count + 1 })),
            decrement: () => set((state) => ({ count: state.count - 1 })),
            reset: () => set({ count: 0 }),
        }),
        {
            name: 'counter-storage', // 存到 localStorage 里的名字
        }
    )
);

核心解读

  • create: 建立仓库。
  • set: 这是唯一的“财务审批笔”。你不能直接 state.count++,必须通过 set 返回一个新的对象。这保证了数据的不可变性(Immutability)
  • persist: 这是 Zustand 的杀手锏中间件。它自动将状态同步到 localStorage,刷新页面数据不丢失。

三、 处理复杂资产:对象与数组的不可变性

当我们的资产不仅仅是数字,而是复杂的待办事项列表(TodoList)或用户信息(User)时,不可变性的操作显得尤为重要。

1. Todo List 的增删改查

useTodoStore 中,我们看到了数组操作的标准范式:

TypeScript

// store/todo.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Todo } from '../types';

export interface TodoState {
    todos: Todo[];
    addTodo: (text: string) => void;
    toggleTodo: (id: number) => void;
    removeTodo: (id: number) => void;
}

export const useTodoStore = create<TodoState>()(
    persist(
        (set) => ({
            todos: [],
            // 新增:利用解构 [...old, new] 创建新数组
            addTodo: (text: string) =>
                set((state) => ({
                    todos: [...state.todos, {
                        id: Date.now(),
                        text,
                        completed: false
                    }]
                })),
            // 切换状态:利用 map 生成新数组,不修改原对象
            toggleTodo: (id: number) =>
                set((state) => ({
                    todos: state.todos.map((todo) =>
                        todo.id === id 
                            ? { ...todo, completed: !todo.completed } 
                            : todo
                    )
                })),
            // 删除:利用 filter 过滤
            removeTodo: (id: number) =>
                set((state) => ({
                    todos: state.todos.filter(todo => todo.id !== id)
                })),
        }),
        { name: 'todos-storage' }
    )
);

深度思考

这里的 mapfilter 和展开运算符 ... 不是为了炫技,而是为了配合 React 的更新机制。React 依赖引用的变化来感知更新,如果我们直接 todos.push(),引用不变,UI 就不会刷新。这就是“修改状态的规矩”

2. 用户鉴权状态

同样的逻辑适用于用户信息管理:

TypeScript

// store/user.ts
interface UserState {
  isLoggin: boolean;
  user: User | null;
  login: (user: User) => void;
  logout: () => void;
}
// ... 代码省略,逻辑同上,利用 set 同时更新多个字段

四、 消费状态:在 UI 组件中提款

有了银行(Store),组件(App.tsx)就可以轻松地存取数据了。Zustand 的 Hook API 让这一切变得像使用 useState 一样自然。

TypeScript

// App.tsx
import { useCounterStore } from './store/counter';
import { useTodoStore } from './store/todo';

function App() {
  // 1. 直接提取所需的 State 和 Actions
  // 就像从 ATM 取钱一样简单
  const { count, increment, decrement, reset } = useCounterStore();
  
  const { todos, addTodo, toggleTodo, removeTodo } = useTodoStore();
  
  // 2. 结合本地 UI 逻辑 (Input value)
  const [inputValue, setInputValue] = useState("");

  const handleAdd = () => {
    if (!inputValue.trim()) return;
    addTodo(inputValue); // 调用 Store 的 Action
    setInputValue("");
  }

  return (
    <div>
       {/* UI 渲染逻辑,完全解耦 */}
       <h1>Count: {count}</h1>
       <button onClick={increment}>+1</button>
       
       <ul>
         {todos.map(todo => (
           <li key={todo.id}>
             {/* 这里的 toggleTodo 直接来自 Store */}
             <span onClick={() => toggleTodo(todo.id)}>
               {todo.text}
             </span>
           </li>
         ))}
       </ul>
    </div>
  )
}

五、 总结:Zustand 的企业级管理哲学

回到开头提到的代码注释: “专业管理状态,修改状态的规矩”

Zustand 相比于其他工具,胜在平衡

  1. 极简主义:没有 Boilerplate(样板代码),没有 Provider 包裹,即装即用。
  2. 规矩严明:通过 TypeScript 接口定义 State,通过 Actions 封装修改逻辑。组件只负责“触发”,Store 负责“执行”。
  3. 持久化persist 中间件让数据存储变得透明化。

如果把 React 应用比作一家公司,useState 是员工口袋里的零花钱,而 Zustand 就是那个高效、透明且严格执行财务制度的财务部。

❌
❌