我要提问
PROJECT CASE / 001

响应式企业官网前端实战

React 18 + Vite 5 + TypeScript,从设计稿到上线的完整前端工程化拆解。

响应式企业官网前端实战:React + Vite 从设计稿到上线

响应式企业官网前端实战项目示意图

项目背景

某 B 端 SaaS 公司原有的企业官网是用 jQuery + 自由 HTML 拼出来的静态页面,存在三个突出问题:一是多端适配靠手写媒体查询硬撑,维护成本高;二是首屏依赖大量第三方脚本,LCP 长期在 3.8s 以上;三是市场团队改文案要提工单走研发排期,迭代周期按周计。

本次重构的目标是用现代前端工程化方式重建官网:技术栈统一为 React 18 + Vite 5 + TypeScript,样式采用 Tailwind CSS 配合少量 CSS 变量,内容走 Markdown + Headless CMS 让市场自助编辑,最终首屏 LCP 压到 1.5s 以内,移动端体验同步提升。

选 React + Vite 的核心理由:Vite 的 ESM dev server 让冷启动稳定在 300ms 内,热更新几乎无延迟;React 18 的 Suspense 与流式 SSR 对内容型站点首屏收益明显,团队也有现成沉淀。

架构设计

整体采用「路由按页面拆 Chunk + 公共布局抽离 + 内容数据驱动」的分层结构。页面层只负责组合,逻辑下沉到 hooks 与 services,UI 控件全部归入独立组件库目录,保证市场改文案不会触碰样式逻辑。

src/
├── components/        # 基础 UI 组件(Button、Section、Hero 等)
├── layouts/           # 主布局:Header / Footer / SEO
├── pages/             # 路由页面:Home / Product / Case / About
├── sections/          # 页面内可复用区块(FeatureGrid、Pricing 等)
├── hooks/             # useLocale、useScrollReveal、useContent
├── services/          # CMS 数据拉取与类型转换
├── content/           # Markdown 静态内容兜底
└── main.tsx           # 入口,挂载 Router 与 HelmetProvider

路由用 React Router v6 的懒加载,每个页面独立 chunk;布局组件通过 Outlet 承载子路由,避免重复渲染 Header/Footer。SEO 元信息用 react-helmet-async 在每个页面组件内声明,构建时再做静态注入。

核心实现

1)响应式布局:放弃手写媒体查询,统一用 Tailwind 的断点前缀 sm/md/lg/xl,设计稿按 1440 / 768 / 375 三档对齐,组件内部用 flex/grid 自适应,避免出现「写死像素 + 多套样式表」的老问题。

// sections/FeatureGrid.tsx —— 响应式三列/两列/单列自适应
export function FeatureGrid({ items }: FeatureGridProps) {
  return (
    <ul className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
      {items.map((it) => (
        <li key={it.id} className="rounded-2xl border border-line p-6
                                   hover:-translate-y-1 transition">
          <span className="text-2xl">{it.icon}</span>
          <h3 className="mt-3 text-lg font-semibold">{it.title}</h3>
          <p className="mt-2 text-sm text-muted">{it.desc}</p>
        </li>
      ))}
    </ul>
  );
}

2)内容数据驱动:市场在 CMS 里维护「首页轮播 / 产品特性 / 客户案例」三个集合,前端通过 useContent 拉取并做类型校验,类型不符直接降级到 Markdown 兜底,保证 CMS 配错时页面不白屏。

// hooks/useContent.ts —— 类型安全的内容拉取
export function useContent<T>(collection: string, schema: ZodSchema<T>) {
  const [data, setData] = useState<T | null>(null);
  useEffect(() => {
    fetch(`/api/content/${collection}`)
      .then((r) => r.json())
      .then((raw) => {
        const parsed = schema.safeParse(raw);
        setData(parsed.success ? parsed.data : FALLBACK[collection]);
      })
      .catch(() => setData(FALLBACK[collection]));
  }, [collection]);
  return data;
}

3)首屏性能:首页用 Vite 的 import('./pages/Home') 做路由懒加载,Hero 区图片走 loading="eager" + 显式宽高属性避免 CLS,其余图片一律 loading="lazy";字体用 font-display: swap 并预加载 woff2 子集。

  • 路由懒加载:首屏 JS 从 380KB 降到 92KB(gzip)
  • 图片懒加载 + 显式宽高:CLS 从 0.18 降到 0.02
  • 字体子集化:首屏字体体积减少 76%
  • Hero 图改 AVIF + WebP 兜底:体积减少 58%

技术难点

难点一:React 18 流式 SSR 与 Helmet 的元信息注入时序。直接在流式渲染里用 Helmet 会导致 <head> 信息在首 chunk 之后才补齐,爬虫抓到的快照缺 title。解决方式是构建期用 react-helmet-asyncextract 在 SSR 完成后把元信息回填到模板字符串,再做静态 HTML 落盘。

// server/render.ts —— 构建期静态化元信息回填
import { renderToString } from 'react-dom/server';
import { HelmetProvider } from 'react-helmet-async';

export async function render(url: string) {
  const helmetCtx: any = {};
  const html = renderToString(
    <HelmetProvider context={helmetCtx}>
      <StaticRouter location={url}><App /></StaticRouter>
    </HelmetProvider>
  );
  const helmet = helmetCtx.helmet;
  return TEMPLATE
    .replace('<!--title-->', helmet.title.toString())
    .replace('<!--meta-->', helmet.meta.toString())
    .replace('<!--app-->', html);
}

难点二:Tailwind 与设计稿色板对齐。设计稿用的是品牌色,而 Tailwind 默认调色板对不上。我们在 tailwind.config 里把品牌色映射到 CSS 变量,再通过 theme.extend.colors 暴露,既保留 Tailwind 的工具类体验,又让主题切换只改变量即可。

// tailwind.config.ts —— 品牌色走 CSS 变量
export default {
  content: ['./index.html', './src/**/*.{ts,tsx}'],
  theme: {
    extend: {
      colors: {
        brand: 'rgb(var(--brand) / <alpha-value>)',
        ink:   'rgb(var(--ink) / <alpha-value>)',
        muted: 'rgb(var(--muted) / <alpha-value>)',
        line:  'rgb(var(--line) / <alpha-value>)',
      },
    },
  },
};

踩坑复盘

坑 1:Vite 生产构建把所有路由 chunk 合并进了首屏。原因是 React.lazy 写在了 layout 里被同步引用,被 rollup 当成同步依赖打进 main。改成在 pages 路由配置里用动态 import() 后,chunk 才正确分离。教训:懒加载必须在路由表里声明,不能在组件顶部 import。

坑 2:Tailwind 的 JIT 在 CI 上偶发漏类。本地正常,CI 上偶尔丢几个 hover: 类。排查发现是 CI 缓存的 node_modules/.vite 与新内容扫描不同步,postcss 用了旧缓存。修复方式:CI 构建前强制 rm -rf node_modules/.vite 并锁版本,问题消失。

坑 3:移动端 iOS Safari 的 100vh 溢出。Hero 用 h-screen 在 iPhone 上被地址栏撑高出现滚动条。改用 h-[100dvh] 动态视口单位后表现正确,老 iOS 用 @supports 兜底回退 100vh

一句话总结:懒加载要写在路由表、CI 缓存要清干净、移动端高度用 dvh——这三条是本次踩坑换来的最值钱经验。

项目总结

重构上线后,官网 LCP 从 3.8s 降到 1.4s,移动端 Lighthouse 性能分从 52 提到 94;市场团队改文案不再走研发排期,平均迭代周期从 5 天缩到 2 小时。整个项目的关键不在用了多新的框架,而在于把「内容数据驱动 + 路由懒加载 + 构建期静态化」三件事做扎实,工程化的收益往往来自这些基础动作的扎实程度。

  • 首屏 LCP:3.8s → 1.4s
  • 移动端性能分:52 → 94
  • 内容迭代周期:5 天 → 2 小时
  • 首屏 JS 体积:380KB → 92KB(gzip)
返回项目列表