普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月21日首页

从零打造 AI 全栈应用(一) : 深度解析 Shadcn UI + Vite + NestJS 的工程化最佳实践

2026年1月21日 13:29

在 AIGC 浪潮席卷开发领域的今天, “会接 AI API”已经不再是核心竞争力,真正拉开差距的,是你是否具备构建可扩展、可维护、可持续演进的全栈工程能力

本文将以 Notes AI 项目为背景,不纠结具体业务实现,而是聚焦于:

一个现代 AI 全栈项目,底层工程化与 UI 架构应该如何设计?


项目蓝图:Notes AI

Notes AI 是一个现代化的 AI 驱动内容平台,核心模块包括:

  • 身份认证:登录 / 注册 / 权限控制
  • 文章系统:内容发布、管理与分发
  • AIGC 能力:AI 辅助写作、内容生成
  • 后端架构:基于 NestJS 的高内聚 API 服务

功能并不复杂,但工程复杂度并不低
真正决定项目质量的,并不是功能列表,而是它的基础设施选择


UI 新范式:为什么选择 Shadcn UI?

在 Notes AI 中,我们并没有选择 Ant Design、MUI 等“传统组件库”,而是选择了 Shadcn UI

Shadcn UI 本质是什么?

一句话概括:

Shadcn UI 不是组件库,而是组件源码生成器。

它和传统 UI 库的核心差异在于:

传统组件库 Shadcn UI
npm install 引入黑盒组件 CLI 拉取源码到本地
覆盖样式成本高 直接改源码
依赖版本升级风险 组件完全由你掌控
往往体积不可控 用多少,引入多少

源码所有权,才是真正的自由

当你执行:

npx shadcn@latest add button

Shadcn 会做一件非常“反直觉”的事情:

把 Button 组件的源码直接拷贝进你的项目中

通常路径是:

src/components/ui/button.tsx

这意味着:

  • 没有“库升级破坏样式”的风险
  • 可以随意修改 Tailwind 样式
  • 可以改交互逻辑、加埋点、加权限

组件不再是“外部依赖”,而是你工程的一部分。

这对中大型项目来说,价值非常大。


路径别名:Shadcn 能好用的前提条件

Shadcn 强烈依赖路径别名(Alias) ,否则组件引入会迅速失控。

没有别名时的灾难

import { Button } from '../../../../components/ui/button'

使用别名后的理想状态

import { Button } from '@/components/ui/button'

这不仅是“写得短”,而是:

  • 项目结构更稳定
  • 重构成本更低
  • 组件层级更清晰

而这一步,Vite + TypeScript 必须同时配置


工程化基石:Vite 配置深度解析

vite.config.ts 的核心职责

Vite 的配置本质上只有两件事:

  1. 插件系统(plugins)
  2. 模块解析规则(resolve)

plugins:构建能力扩展

plugins: [
  react(), // React JSX 支持
]
  • React / Vue 插件是必需的
  • Tailwind 通常通过 postcss.config.js 配置,而非 Vite 插件

resolve:模块解析规则

resolve: {
  alias: {
    '@': path.resolve(__dirname, './src'),
  },
  extensions: ['.js', '.ts', '.jsx', '.tsx', '.json'],
}

重点只有一个:

把 @ 映射到 src 目录

❗为什么需要 @types/node

这里使用了:

  • __dirname
  • path.resolve

它们都属于 Node.js API,而不是浏览器 API。

TypeScript 默认并不认识这些类型,因此必须安装:

npm i -D @types/node

这一步的本质是:

让 TS 在“Node 环境”下正确理解 vite.config.ts

这是非常容易被忽略,但在面试中很加分的细节。


TypeScript 路径映射:tsconfig.app.json

很多人只配了 Vite,然后疑惑:

“为什么编辑器还在报错?”

原因是:

Vite 负责构建,TypeScript 负责类型系统

必须同步配置 tsconfig

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

关键结论

  • vite.config.ts → 运行时解析
  • tsconfig.app.json → 编译期 / IDE 智能提示

二者必须一致,否则一定踩坑。


npx:被严重低估的工程利器

1. npx 是什么?

npxnpm 自带的命令行工具,核心作用是:

无需全局安装,直接执行 npm 包中提供的 CLI 命令

它会在需要时临时下载对应包,执行完成后不干扰本地或全局环境。

2️. 为什么现代项目离不开 npx?

  • 不需要全局安装
  • 用完即走,不污染环境
  • 默认使用最新版本
  • 非常适合脚手架和生成器

3️. Shadcn CLI 的最佳拍档

npx shadcn@latest init

该命令会:

  • 初始化 components.json
  • 配置 Tailwind 风格
  • 设置路径别名
  • 确定组件生成目录

这是 Shadcn 体系的入口,而不是可选步骤。


总结:这不是技术堆砌,而是工程思想

Notes AI 项目背后体现的,并不是某个炫酷技术点,而是一整套现代工程化思维:

  • NestJS:提供稳定、可扩展的后端架构
  • Vite:通过精细化配置,提供极速 DX
  • Shadcn UI:用“源码所有权”取代黑盒组件
  • Tailwind CSS:原子化样式,避免 CSS 失控
  • npx:让工具链更轻、更干净

真正优秀的 AI 应用,拼到最后,拼的是工程能力。

昨天以前首页

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 原子类、可访问性、加载态这些细节处理好,后续复杂业务才能游刃有余。

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

❌
❌