阅读视图

发现新文章,点击刷新页面。

前端必看!前端路由守卫这么写,再也不担心权限混乱(Vue/React通用)

所有前端必看!路由守卫看似简单,却藏着很多坑——未登录能直接访问个人中心、管理员页面普通人能进、跳转时数据未加载就渲染。全程实操干货+完整封装,Vue2/Vue3、React 都能用,复制就能实现权限管控

先搞懂:路由守卫到底用来做什么?

不管是 Vue Router 还是 React Router,路由守卫的核心作用只有一个:控制路由的访问权限和跳转逻辑,解决以下高频问题:

  • 未登录用户,禁止访问个人中心、订单页等需要权限的页面;
  • 不同角色(普通用户/管理员),展示不同的路由页面;
  • 页面跳转前,校验数据、确认操作(比如未保存的表单,提示用户);
  • 页面加载前,获取必要数据(比如用户信息),避免页面空白。

重点:路由守卫是前端权限管控的核心。Vue 和 React 用法略有差异,但逻辑一致。本文分别给出完整示例,复制就能适配自己的项目,不用再从零编写。


核心干货:Vue2/Vue3 路由守卫完整封装(直接复制)

Vue 项目用 Vue Router,路由守卫分为 3 类:全局守卫、路由独享守卫、组件内守卫。重点掌握全局守卫,就能解决 80% 的权限问题。

1. Vue3 + Vue Router 4(最常用,推荐)

新建 router/index.js,全局守卫 + 路由配置,一步到位:

// router/index.js(Vue3)
import { createRouter, createWebHistory } from 'vue-router';
import { getStorage } from '@/utils/storage'; // 复用之前封装的 LocalStorage 工具

// 1. 定义路由(区分公开路由和需要权限的路由)
const routes = [
  // 公开路由(无需登录就能访问)
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue'),
    meta: { requiresAuth: false } // 标记:无需权限
  },
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/Home.vue'),
    meta: { requiresAuth: false }
  },
  // 需要权限的路由(必须登录才能访问)
  {
    path: '/user',
    name: 'UserCenter',
    component: () => import('@/views/UserCenter.vue'),
    meta: {
      requiresAuth: true, // 标记:需要权限
      role: 'user' // 角色限制:普通用户即可访问
    }
  },
  // 管理员路由(只有管理员能访问)
  {
    path: '/admin',
    name: 'Admin',
    component: () => import('@/views/Admin.vue'),
    meta: {
      requiresAuth: true,
      role: 'admin' // 角色限制:仅管理员
    }
  },
  // 404页面
  {
    path: '/:pathMatch(.*)*',
    name: '404',
    component: () => import('@/views/404.vue')
  }
];

// 2. 创建路由实例
const router = createRouter({
  history: createWebHistory(import.meta.env.VITE_BASE_URL),
  routes
});

// 3. 全局前置守卫(跳转前校验,核心)
router.beforeEach((to, from, next) => {
  // 1. 获取 token(从 LocalStorage 中取)
  const token = getStorage('token');
  // 2. 获取当前用户角色(登录后存储的用户信息)
  const userRole = getStorage('userInfo')?.role || '';

  // 3. 校验逻辑
  if (to.meta.requiresAuth) {
    // 3.1 需要权限的路由:判断是否登录
    if (!token) {
      // 未登录,跳转到登录页,登录后返回当前页面
      return next({ name: 'Login', query: { redirect: to.fullPath } });
    } else {
      // 已登录,判断角色是否匹配
      if (to.meta.role && to.meta.role !== userRole) {
        // 角色不匹配,跳转到首页(或 403 页面)
        return next({ name: 'Home' });
      }
      // 登录且角色匹配,允许跳转
      next();
    }
  } else {
    // 3.2 公开路由:直接跳转
    next();
  }
});

// 4. 全局后置守卫(跳转后执行,比如修改页面标题)
router.afterEach((to) => {
  // 设置页面标题
  document.title = to.meta.title || '前端路由守卫示例';
});

export default router;

页面中使用(Vue3):

<!-- 登录页面,登录成功后跳转回之前的页面 -->
<script setup>
import { useRouter, useRoute } from 'vue-router';
import { setStorage } from '@/utils/storage';

const router = useRouter();
const route = useRoute();

const login = async () => {
  const res = await loginApi(); // 登录接口
  // 存储 token 和用户信息
  setStorage('token', res.data.token, 86400);
  setStorage('userInfo', res.data.user, 86400);

  // 跳转回之前的页面(如果有),否则跳首页
  const redirect = route.query.redirect || '/';
  router.push(redirect);
};
</script>

2. Vue2 + Vue Router 3(兼容旧项目)

逻辑和 Vue3 一致,仅语法略有差异,直接复制:

// router/index.js(Vue2)
import Vue from 'vue';
import Router from 'vue-router';
import { getStorage } from '@/utils/storage';

Vue.use(Router);

const routes = [
  // 路由配置和 Vue3 一致
  { path: '/login', name: 'Login', component: () => import('@/views/Login'), meta: { requiresAuth: false } },
  { path: '/user', name: 'UserCenter', component: () => import('@/views/UserCenter'), meta: { requiresAuth: true, role: 'user' } },
  { path: '/admin', name: 'Admin', component: () => import('@/views/Admin'), meta: { requiresAuth: true, role: 'admin' } },
];

const router = new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
});

// 全局前置守卫
router.beforeEach((to, from, next) => {
  const token = getStorage('token');
  const userRole = getStorage('userInfo')?.role || '';

  if (to.meta.requiresAuth) {
    if (!token) {
      next({ name: 'Login', query: { redirect: to.fullPath } });
    } else {
      if (to.meta.role && to.meta.role !== userRole) {
        next({ name: 'Home' });
      } else {
        next();
      }
    }
  } else {
    next();
  }
});

export default router;

核心干货:React + React Router 6 路由守卫封装(直接复制)

React Router 6 取消了传统的路由守卫 API,改用「组件封装」的方式实现权限控制,更灵活,适配 React 函数式组件,直接复制就能用。

1. 封装权限守卫组件(utils/PrivateRoute.js

import { Navigate, Outlet } from 'react-router-dom';
import { getStorage } from '@/utils/storage';

/**
 * 权限守卫组件
 * @param {Object} props - 传入的角色限制
 * @param {string} props.role - 允许访问的角色(可选)
 */
export const PrivateRoute = ({ role }) => {
  // 获取 token 和用户角色
  const token = getStorage('token');
  const userRole = getStorage('userInfo')?.role || '';

  // 未登录,跳转到登录页
  if (!token) {
    return <Navigate to="/login" replace />;
  }

  // 有角色限制,且当前角色不匹配,跳转到首页
  if (role && role !== userRole) {
    return <Navigate to="/" replace />;
  }

  // 权限通过,渲染子路由(Outlet 对应 Vue 的 router-view)
  return <Outlet />;
};

2. 路由配置(router/index.jsx

import { createBrowserRouter } from 'react-router-dom';
import PrivateRoute from '@/utils/PrivateRoute';
// 引入页面组件
import Login from '@/views/Login';
import Home from '@/views/Home';
import UserCenter from '@/views/UserCenter';
import Admin from '@/views/Admin';
import NotFound from '@/views/404';

// 创建路由
const router = createBrowserRouter([
  {
    path: '/',
    element: <Home />
  },
  {
    path: '/login',
    element: <Login />
  },
  // 需要权限的路由:用 PrivateRoute 包裹
  {
    path: '/user',
    element: <PrivateRoute role="user" />, // 普通用户可访问
    children: [
      { path: '', element: <UserCenter /> } // 子路由,对应 Outlet
    ]
  },
  // 管理员路由:限制角色为 admin
  {
    path: '/admin',
    element: <PrivateRoute role="admin" />,
    children: [
      { path: '', element: <Admin /> }
    ]
  },
  // 404 页面
  {
    path: '*',
    element: <NotFound />
  }
]);

export default router;

3. 入口文件中使用(main.jsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import router from './router';

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

4.React 登录页面使用:

import { useNavigate, useLocation } from 'react-router-dom';
import { setStorage } from '@/utils/storage';

function Login() {
  const navigate = useNavigate();
  const location = useLocation();
  // 获取跳转前的页面地址
  const redirect = new URLSearchParams(location.search).get('redirect') || '/';

  const login = async () => {
    const res = await loginApi();
    setStorage('token', res.data.token, 86400);
    setStorage('userInfo', res.data.user, 86400);
    // 跳转回之前的页面
    navigate(redirect, { replace: true });
  };

  return (
    <button onClick={login}>登录</button>
  );
}

export default Login;

实战避坑:4 个高频坑,新手必避

坑 1:Vue 路由守卫中,忘记调用 next(),导致页面卡死

错误示例:在 beforeEach 中只做了判断,没调用 next(),路由无法跳转,页面卡死。
正确做法:所有分支都必须调用 next(),允许跳转用 next(),重定向用 next({ name: 'Login' })

坑 2:React Router 6 中,用旧版本语法写守卫,导致失效

React Router 6 取消了 beforeEachafterEach 等 API,不要再用旧版本的写法。
正确做法:用「PrivateRoute 组件 + Outlet」的方式实现权限控制,本文示例直接可用。

坑 3:未处理“登录后跳转回原页面”,体验变差
用户未登录访问需要权限的页面,登录后应该跳转回之前的页面,而不是默认首页。
正确做法:跳转登录页时,携带当前页面地址(query 参数),登录成功后跳转回去。

坑 4:角色权限判断不严谨,导致越权访问

只判断是否登录,不判断角色,导致普通用户能访问管理员页面。
正确做法:在路由 meta(Vue)或 PrivateRoute 组件(React)中添加角色限制,登录后校验角色。


进阶技巧:路由守卫高级用法

1. 表单未保存,禁止跳转(组件内守卫 / Vue 专属)

<script setup>
import { onBeforeRouteLeave } from 'vue-router';

// 组件内守卫:离开当前页面时触发
onBeforeRouteLeave((to, from, next) => {
  // 判断表单是否未保存
  if (formIsDirty.value) {
    if (confirm('表单未保存,确定要离开吗?')) {
      next(); // 确认离开
    } else {
      next(false); // 取消离开
    }
  } else {
    next(); // 表单已保存,允许离开
  }
});
</script>

2. 路由跳转时,加载 loading 状态(全局守卫)

// Vue3 全局守卫中添加 loading
import { ref } from 'vue';
export const isLoading = ref(false);

router.beforeEach((to, from, next) => {
  isLoading.value = true; // 跳转前显示 loading
  // 原有校验逻辑...
  next();
});

router.afterEach(() => {
  setTimeout(() => {
    isLoading.value = false; // 跳转后隐藏 loading
  }, 300);
});

结尾:干货总结

路由守卫是前端权限管控的核心。Vue 和 React 用法虽有差异,但逻辑一致——判断登录状态、校验角色、控制跳转。一套封装就能覆盖所有场景,避开 4 个高频坑,复制就能实现权限管控。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

前端必看!LocalStorage这么用,再也不踩坑(多框架通用,直接复制)

所有前端必看!LocalStorage看似简单,却有90%的人用错——存对象报错、存数组失效、数据污染、内存溢出,甚至导致页面卡顿。全程实操干货+通用封装,Vue/React/Uniapp/小程序/Node都能用,复制就能避免所有坑

先搞懂:LocalStorage核心痛点,你一定踩过

做前端开发,谁没用 LocalStorage 存过 token、用户信息?但大多数人都是裸写 localStorage.setItemlocalStorage.getItem,看似简单,实则全是坑:

  • 只能存字符串:存对象/数组直接报错,或取出来变成 [object Object]
  • 没有过期时间:存的 token、临时数据一直占用内存,导致数据污染;
  • 没有容错处理:取不到数据直接报错,影响页面渲染;
  • 键名混乱:多个页面/组件存数据,容易覆盖、冲突。

重点:LocalStorage 是前端本地存储的基础,Vue、React、Uniapp、小程序、Node(前端渲染)都能用。一套通用封装,彻底解决所有痛点,不用重复写冗余代码。

核心干货:LocalStorage通用封装(直接复制,多框架通用)

新建 utils/storage.js,一次封装,全局使用。支持存字符串、对象、数组,带过期时间、容错处理、键名统一,复制到任何前端项目都能直接用!

/**
 * LocalStorage通用封装(Vue/React/Uniapp/小程序通用)
 * 支持:存字符串、对象、数组 + 过期时间 + 容错处理 + 键名统一
 */
const STORAGE_KEY_PREFIX = 'frontend_'; // 键名前缀,避免冲突

// 1. 存数据(支持过期时间,单位:秒)
export const setStorage = (key, value, expire = 0) => {
  try {
    // 处理对象/数组,转为JSON字符串(LocalStorage只能存字符串)
    const data = {
      value: typeof value === 'object' ? JSON.stringify(value) : value,
      expire: expire > 0 ? Date.now() + expire * 1000 : 0 // 0表示永久有效
    };
    // 键名加前缀,避免和其他项目/插件冲突
    localStorage.setItem(`${STORAGE_KEY_PREFIX}${key}`, JSON.stringify(data));
  } catch (error) {
    console.error('LocalStorage存储失败:', error);
    // 兼容低版本浏览器/隐私模式(LocalStorage不可用)
    alert('浏览器存储不可用,请开启正常模式后重试');
  }
};

// 2. 取数据(自动处理JSON解析,判断过期)
export const getStorage = (key) => {
  try {
    const storageKey = `${STORAGE_KEY_PREFIX}${key}`;
    const dataStr = localStorage.getItem(storageKey);
    if (!dataStr) return null;

    const data = JSON.parse(dataStr);
    // 判断是否过期(expire=0表示永久有效)
    if (data.expire > 0 && Date.now() > data.expire) {
      // 过期后自动删除,避免无效数据占用内存
      localStorage.removeItem(storageKey);
      return null;
    }

    // 自动解析JSON(如果存的是对象/数组)
    try {
      return JSON.parse(data.value);
    } catch (e) {
      // 不是JSON格式,直接返回原始值(字符串)
      return data.value;
    }
  } catch (error) {
    console.error('LocalStorage获取失败:', error);
    return null;
  }
};

// 3. 删除单个数据
export const removeStorage = (key) => {
  try {
    localStorage.removeItem(`${STORAGE_KEY_PREFIX}${key}`);
  } catch (error) {
    console.error('LocalStorage删除失败:', error);
  }
};

// 4. 清空所有数据(只清空当前项目的,不影响其他项目)
export const clearStorage = () => {
  try {
    // 只删除带前缀的键,避免清空其他项目的存储
    Object.keys(localStorage).forEach(key => {
      if (key.startsWith(STORAGE_KEY_PREFIX)) {
        localStorage.removeItem(key);
      }
    });
  } catch (error) {
    console.error('LocalStorage清空失败:', error);
  }
};

// 5. 批量存数据
export const setStorageBatch = (obj, expire = 0) => {
  try {
    Object.entries(obj).forEach(([key, value]) => {
      setStorage(key, value, expire);
    });
  } catch (error) {
    console.error('LocalStorage批量存储失败:', error);
  }
};

实战用法:多框架示例,直接复制

不管是 Vue、React、Uniapp,用法完全一致,只需引入封装好的方法,无需额外适配。以下示例覆盖 80% 的使用场景。

1. 基础用法:存/取字符串、对象、数组

// 引入封装的方法(所有框架通用)
import { setStorage, getStorage, removeStorage } from '@/utils/storage';

// 1. 存字符串(比如token)
setStorage('token', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', 86400); // 过期时间1天(86400秒)

// 2. 存对象(比如用户信息)
const userInfo = { id: 1, name: '张三', age: 25 };
setStorage('userInfo', userInfo, 86400);

// 3. 存数组(比如历史记录)
const historyList = ['Vue', 'React', 'JS'];
setStorage('historyList', historyList); // 不设过期时间,永久有效

// 4. 取数据(自动解析对象/数组,无需手动JSON.parse)
const token = getStorage('token');
const user = getStorage('userInfo'); // 直接拿到对象,无需解析
const history = getStorage('historyList'); // 直接拿到数组

// 5. 删除数据
removeStorage('token'); // 删除单个
// clearStorage(); // 清空当前项目所有存储

2. Vue3/Uniapp页面中使用

<script setup>
import { ref, onMounted } from 'vue';
import { setStorage, getStorage } from '@/utils/storage';

const userInfo = ref({});

// 页面加载时,从LocalStorage取用户信息
onMounted(() => {
  const user = getStorage('userInfo');
  if (user) {
    userInfo.value = user;
  }
});

// 登录成功后,存用户信息和token
const login = async () => {
  const res = await loginApi(); // 登录接口
  setStorage('token', res.data.token, 86400);
  setStorage('userInfo', res.data.user, 86400);
  userInfo.value = res.data.user;
};
</script>

3. React页面中使用

import { useState, useEffect } from 'react';
import { setStorage, getStorage, removeStorage } from '@/utils/storage';

function UserPage() {
  const [user, setUser] = useState({});

  useEffect(() => {
    // 组件挂载时取数据
    const userInfo = getStorage('userInfo');
    if (userInfo) {
      setUser(userInfo);
    }
  }, []);

  // 退出登录,删除存储
  const logout = () => {
    removeStorage('token');
    removeStorage('userInfo');
    setUser({});
  };

  return (
    <div>
      {user.name}
      <button onClick={logout}>退出登录</button>
    </div>
  );
}

4. 小程序/Uniapp适配(特殊处理)

小程序不支持 window.localStorage,需替换为 wx.setStorageSync 等原生 API,修改封装方法即可,核心逻辑不变:

// 小程序版本封装(utils/storage.js)
const STORAGE_KEY_PREFIX = 'frontend_';

// 存数据
export const setStorage = (key, value, expire = 0) => {
  try {
    const data = {
      value: typeof value === 'object' ? JSON.stringify(value) : value,
      expire: expire > 0 ? Date.now() + expire * 1000 : 0
    };
    wx.setStorageSync(`${STORAGE_KEY_PREFIX}${key}`, data);
  } catch (error) {
    console.error('存储失败:', error);
    wx.showToast({ title: '存储不可用', icon: 'none' });
  }
};

// 取数据(其他方法同理,替换为wx.getStorageSync、wx.removeStorageSync)
export const getStorage = (key) => {
  try {
    const storageKey = `${STORAGE_KEY_PREFIX}${key}`;
    const data = wx.getStorageSync(storageKey);
    if (!data) return null;
    if (data.expire > 0 && Date.now() > data.expire) {
      wx.removeStorageSync(storageKey);
      return null;
    }
    try {
      return JSON.parse(data.value);
    } catch (e) {
      return data.value;
    }
  } catch (error) {
    console.error('获取失败:', error);
    return null;
  }
};

实战避坑:5个高频坑,新手必避

坑1:直接存对象/数组,导致报错或解析失败

错误示例localStorage.setItem('user', {name: '张三'}),直接存对象会报错。
正确做法:用封装的 setStorage,自动将对象/数组转为 JSON 字符串,取的时候自动解析。

坑2:不设过期时间,导致数据污染

存 token、临时数据时,不设过期时间,用户退出后数据依然存在,再次登录会出现异常。
正确做法:给敏感数据、临时数据设置过期时间(比如 token 设 1 天)。

坑3:键名不统一,导致覆盖冲突

多个组件/页面存数据,键名都是 “user”“data”,容易互相覆盖。
正确做法:用前缀统一键名(封装中已自带 frontend_ 前缀),避免冲突。

坑4:忽略浏览器兼容性,导致报错

部分低版本浏览器、隐私模式下,LocalStorage 不可用,裸写会报错。
正确做法:封装中添加容错处理,捕获异常并提示用户。

坑5:清空所有存储,影响其他项目

错误示例:直接用 localStorage.clear(),会清空浏览器中所有项目的 LocalStorage。
正确做法:用封装的 clearStorage,只清空当前项目带前缀的存储。

进阶技巧:LocalStorage进阶用法

1. 监听 LocalStorage 变化(跨页面通信)

// 页面A监听存储变化
window.addEventListener('storage', (e) => {
  // 只监听当前项目的存储变化(带前缀)
  if (e.key?.startsWith(STORAGE_KEY_PREFIX)) {
    console.log('存储变化:', e.key, e.newValue);
    // 比如监听token变化,实现跨页面登录状态同步
    if (e.key === `${STORAGE_KEY_PREFIX}token`) {
      // 处理登录状态更新
    }
  }
});

// 页面B修改存储,页面A会触发监听
setStorage('token', 'newToken');

2. 限制存储大小,避免内存溢出

LocalStorage 默认存储上限约 5MB,存大量数据会导致内存溢出。可在封装中添加存储大小校验:

// 新增:校验存储大小
const checkStorageSize = (value) => {
  const valueStr = typeof value === 'object' ? JSON.stringify(value) : value;
  const size = new Blob([valueStr]).size;
  // 限制单条数据不超过1MB
  if (size > 1 * 1024 * 1024) {
    alert('存储数据过大,建议拆分存储');
    return false;
  }
  return true;
};

// 在setStorage中添加校验
export const setStorage = (key, value, expire = 0) => {
  if (!checkStorageSize(value)) return;
  // 原有逻辑...
};

结尾:干货总结

LocalStorage 是前端必备的本地存储工具。一套通用封装,解决存对象、过期时间、冲突、容错等所有痛点,适配所有前端框架,复制就能用,避开 5 个高频坑,再也不用为存储问题头疼。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

MCP TypeScript SDK的使用

MCP TypeScript SDK 架构

概述

MCP (Model Context Protocol) TypeScript SDK 实现了模型上下文协议,用于在 LLM 和外部数据源/工具之间建立标准化的通信。


架构图

┌─────────────────────────────────────────────────────────────────────────────┐
│                              MCP Architecture                                │
└─────────────────────────────────────────────────────────────────────────────┘

                              ┌─────────────────┐
                              │   LLM (AI)      │
                              └────────┬────────┘
                                       │
                          ┌────────────▼────────────┐
                          │      MCP Client         │
                          │  @modelcontextprotocol/ │
                          │      sdk/client         │
                          └────────────┬────────────┘
                                       │
                    ┌──────────────────┼──────────────────┐
                    │                  │                  │
          ┌─────────▼─────────┐ ┌──────▼──────┐ ┌───────▼──────┐
          │    Tools API      │ │ Resources   │ │   Prompts    │
          │  listTools()      │ │ listResources│ │ listPrompts()│
          │  callTool()       │ │ readResource │ │  getPrompt()  │
          └─────────┬─────────┘ └──────┬──────┘ └───────┬──────┘
                    │                   │                │
          ┌─────────▼───────────────────▼────────────────▼─────────┐
          │                    Transport Layer                     │
          │  ┌──────────────┐  ┌──────────────────┐  ┌──────────┐ │
          │  │    Stdio     │  │ Streamable HTTP  │  │   SSE    │ │
          │  │  (Local)     │  │   (Remote)       │  │(Legacy)  │ │
          │  └──────────────┘  └──────────────────┘  └──────────┘ │
          └──────────────────────────┬──────────────────────────────┘
                                    │
                    ┌───────────────┴───────────────┐
                    │                               │
          ┌─────────▼─────────┐         ┌─────────▼─────────┐
          │   MCP Server      │         │   MCP Client      │
          │  (Provider)       │◄───────►│  (Consumer)       │
          └─────────┬─────────┘   RPC   └───────────────────┘
                    │
          ┌─────────▼─────────────────────────────────────────┐
          │                  Capability Layer                   │
          │  ┌─────────┐  ┌──────────┐  ┌────────┐  ┌───────┐ │
          │  │ Tools   │  │ Resources │  │Prompts │  │Sampling│ │
          │  │         │  │          │  │        │  │Elicit │ │
          │  └─────────┘  └──────────┘  └────────┘  └───────┘ │
          └────────────────────────────────────────────────────┘

组件说明

组件 作用 职责说明
LLM 消费方 大语言模型,通过 MCP Client 发现和使用外部工具、资源。解析用户意图,决定调用哪些工具,处理返回结果
MCP Client 连接管理 SDK 客户端库,负责:① 与服务器建立连接 ② 发现并列出可用工具/资源/提示器 ③ 调用工具并传递参数 ④ 读取资源内容 ⑤ 处理服务器通知
MCP Server 能力提供 SDK 服务器端,负责:① 注册和暴露 Tools(可执行函数)② 注册和暴露 Resources(可读数据)③ 注册和暴露 Prompts(提示模板)④ 处理客户端请求并返回结果
Transport 通信通道 传输层,负责客户端与服务器之间的消息传递。支持多种传输方式:① Stdio - 本地进程间通信 ② HTTP Streamable - 远程 HTTP 通信,支持双向流 ③ SSE - 服务器单向推送事件
Tools 执行能力 服务器暴露的可调用函数,用于执行具体操作(如计算、查询、创建等)。带输入参数,返回执行结果
Resources 数据暴露 服务器暴露的只读数据,支持客户端读取和订阅变更。可以是配置文件、数据库记录、文件内容等
Prompts 模板复用 预定义的提示模板,支持参数化。客户端可以获取填充参数后的完整消息,用于标准化常见任务的提示
Sampling LLM 代理 允许服务器向客户端请求 LLM 推理能力。服务器可以请客户端的 LLM 生成文本、总结内容等
Elicitation 交互代理 允许服务器向客户端请求用户输入。用于需要用户确认、填写表单或做出选择的场景

SDK 模块结构

本项目使用 v1 版本的 @modelcontextprotocol/sdk 统一包。

模块 导入路径 用途
Client @modelcontextprotocol/sdk/client/index.js MCP 客户端实现,连接服务器、调用工具、读取资源
Server @modelcontextprotocol/sdk/server/mcp.js MCP 服务器实现,暴露工具、资源、提示器

协议层设计

Transport 层

Transport 层负责客户端与服务器之间的底层通信。

传输类型 适用场景
Stdio StdioServerTransport / StdioClientTransport 本地进程通信,同一系统内 spawn 子进程
HTTP Streamable StreamableHTTPClientTransport 远程服务器,支持 SSE 事件流(推荐)
SSE SSEClientTransport Server-Sent Events 单向事件流
Stdio 传输

适合本地场景,服务器作为子进程启动:

// 服务器
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
const transport = new StdioServerTransport();
await server.connect(transport);

// 客户端
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const transport = new StdioClientTransport({ command: 'node', args: ['server.js'] });
HTTP Streamable 传输

适合远程场景,服务器独立运行:

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'));
await client.connect(transport);

Capability 层

MCP 协议定义了四种主要能力。

Tools(工具)

工具是服务器暴露的可调用函数,LLM 可以通过工具执行实际操作。

服务器注册

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod';

server.registerTool(
  'calculate-bmi',
  {
    title: 'BMI Calculator',
    description: 'Calculate Body Mass Index',
    inputSchema: z.object({
      weightKg: z.number().describe('Weight in kilograms'),
      heightM: z.number().describe('Height in meters')
    }),
    outputSchema: z.object({ bmi: z.number() })
  },
  async ({ weightKg, heightM }) => {
    const bmi = weightKg / (heightM * heightM);
    return {
      content: [{ type: 'text', text: `BMI: ${bmi.toFixed(2)}` }],
      structuredContent: { bmi }
    };
  }
);

客户端调用

const result = await client.callTool({
  name: 'calculate-bmi',
  arguments: { weightKg: 70, heightM: 1.75 }
});

Resources(资源)

资源是服务器暴露的二进制或文本数据,客户端可以读取和订阅。

服务器注册

server.registerResource(
  'config://app',
  {
    title: 'App Config',
    description: 'Application configuration',
    mimeType: 'application/json'
  },
  async () => ({
    contents: [{
      uri: 'config://app',
      text: JSON.stringify({ theme: 'dark', language: 'en' })
    }]
  })
);

客户端操作

// 列出资源
const { resources } = await client.listResources();

// 读取资源
const { contents } = await client.readResource({ uri: 'config://app' });

// 订阅变更
await client.subscribeResource({ uri: 'config://app' });
client.setNotificationHandler('notifications/resources/updated', handler);

Prompts(提示器)

提示器是预定义的提示模板,可以携带参数生成消息。

服务器注册

server.registerPrompt(
  'review-code',
  {
    title: 'Code Review',
    description: 'Review code for best practices',
    argsSchema: z.object({ code: z.string() })
  },
  ({ code }) => ({
    messages: [{
      role: 'user',
      content: { type: 'text', text: `Please review:\n\n${code}` }
    }]
  })
);

客户端调用

const { messages } = await client.getPrompt({ name: 'review-code', arguments: { code: '...' } });

Sampling(采样)

Sampling 允许服务器向客户端请求 LLM 推理。

服务器端请求

server.registerTool(
  'summarize',
  { inputSchema: z.object({ text: z.string() }) },
  async ({ text }, ctx) => {
    const response = await ctx.mcpReq.requestSampling({
      messages: [{ role: 'user', content: { type: 'text', text: `Summarize: ${text}` } }],
      maxTokens: 500
    });
    return { content: [{ type: 'text', text: JSON.stringify(response.content) }] };
  }
);

客户端处理

client.setRequestHandler('sampling/createMessage', async request => {
  const lastMessage = request.params.messages.at(-1);
  // 转发给 LLM 并返回
  return { model: 'claude-3-5-sonnet', role: 'assistant', content: { type: 'text', text: '...' } };
});

Elicitation(请求用户输入)

Elicitation 允许服务器向客户端请求用户交互式输入。

服务器端请求

const result = await ctx.mcpReq.elicitInput({
  mode: 'form',
  message: 'Please rate this:',
  requestedSchema: {
    type: 'object',
    properties: { rating: { type: 'integer', minimum: 1, maximum: 5 } },
    required: ['rating']
  }
});

客户端处理

client.setRequestHandler('elicitation/create', async request => ({
  action: 'accept',
  content: { rating: 5 }
}));

Auth 层

SDK 支持 OAuth 2.0 认证。

Provider 用途
ClientCredentialsProvider 客户端凭证流,适用于服务间认证
PrivateKeyJwtProvider 私钥 JWT,适用于更安全的场景
import { ClientCredentialsProvider } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

const authProvider = new ClientCredentialsProvider({
  clientId: 'my-service',
  clientSecret: 'my-secret'
});

const transport = new StreamableHTTPClientTransport(
  new URL('http://localhost:3000/mcp'),
  { authProvider }
);

错误处理

SDK 提供 ProtocolErrorSdkError

import { ProtocolError } from '@modelcontextprotocol/sdk/client/index.js';

if (error instanceof ProtocolError) {
  console.log(error.code); // 错误码
  console.log(error.message); // 错误消息
}

从ethers.js迁移到Viem:我在重构DeFi前端时踩过的那些坑

背景

我负责维护一个已经运行两年的DeFi项目前端,技术栈是React + TypeScript + ethers.js 5.7。最近在做性能优化时发现,打包后的bundle size比同类项目大了近30%,经过分析,ethers.js占了相当大的比重。同时,项目中的一些复杂类型定义在ethers.js下显得很冗长,类型提示也不够友好。

团队讨论后决定尝试迁移到Viem。Viem是较新的以太坊JavaScript库,以类型安全、模块化、轻量化为特点。但迁移一个生产环境项目不是简单的替换import语句,我需要在保证现有功能完全正常的前提下完成迁移。

问题分析

最初我以为迁移就是换个库,把ethers.providers.Web3Provider换成viem/createWalletClient就行了。但实际一开始就遇到了问题:

  1. 类型系统完全不同:ethers.js使用自己的BigNumber类型,而Viem直接使用原生bigint
  2. 事件监听机制差异:ethers.js的合约事件监听和Viem的watchContractEvent参数结构完全不同
  3. 多链支持方式不同:我们项目支持Ethereum、Polygon、Arbitrum三条链,ethers.js通过Network对象管理,Viem有自己的一套链定义

最头疼的是,项目中有上百处以太坊交互代码,分布在组件、hooks、工具函数中,不可能一次性全部重写。我需要一个渐进式的迁移方案。

核心实现

第一步:搭建双库共存环境

我决定先让两个库共存,逐步迁移模块。首先安装必要的Viem包:

npm install viem wagmi

然后创建了一个lib/viem-client.ts文件,初始化基础客户端:

import { createPublicClient, http } from 'viem'
import { mainnet, polygon, arbitrum } from 'viem/chains'

// 根据链ID获取对应的Viem链配置
export function getChainConfig(chainId: number) {
  switch (chainId) {
    case 1: return mainnet
    case 137: return polygon
    case 42161: return arbitrum
    default: return mainnet
  }
}

// 创建公共客户端(用于读取数据)
export function createViemPublicClient(chainId: number) {
  const chain = getChainConfig(chainId)
  const transport = http(process.env.NEXT_PUBLIC_RPC_URL)
  
  return createPublicClient({
    chain,
    transport,
  })
}

// 这里有个坑:Viem的链配置需要和你的项目实际使用的RPC节点匹配
// 如果RPC节点不支持某些方法,需要在transport中配置

同时,我保留了现有的ethers.js代码,只是在新写的功能中使用Viem。

第二步:处理BigNumber类型转换

这是迁移中最频繁遇到的问题。我们的项目中有大量的金额计算、余额显示逻辑,原来都使用ethers.js的BigNumber。

我创建了一个转换工具函数:

import { BigNumber } from 'ethers'
import { formatUnits, parseUnits } from 'viem'

/**
 * 将ethers.js的BigNumber转换为Viem兼容的bigint
 * 注意:这里要处理undefined和null的情况
 */
export function bigNumberToBigInt(value?: BigNumber): bigint {
  if (!value) return 0n
  return BigInt(value.toString())
}

/**
 * 将Viem的bigint转换回ethers.js的BigNumber(用于过渡期)
 */
export function bigIntToBigNumber(value: bigint): BigNumber {
  return BigNumber.from(value.toString())
}

/**
 * 统一格式化显示金额
 * 原来用ethers.utils.formatUnits,现在用viem的formatUnits
 * 注意:viem的formatUnits返回string,而ethers返回string
 */
export function formatTokenAmount(
  amount: bigint | BigNumber,
  decimals: number
): string {
  const amountBigInt = amount instanceof BigNumber 
    ? bigNumberToBigInt(amount)
    : amount
  
  return formatUnits(amountBigInt, decimals)
}

第三步:重写合约交互层

我们项目中有几十个合约交互的hooks,这是迁移的重点。我选择从最常用的ERC20代币合约开始。

原来的ethers.js版本:

// 旧的ERC20 Hook (ethers.js)
import { Contract } from 'ethers'
import ERC20_ABI from '../abis/ERC20.json'

export function useERC20(contractAddress: string, signer: any) {
  const contract = new Contract(contractAddress, ERC20_ABI, signer)
  
  const getBalance = async (account: string) => {
    return await contract.balanceOf(account)
  }
  
  const transfer = async (to: string, amount: BigNumber) => {
    const tx = await contract.transfer(to, amount)
    return await tx.wait()
  }
  
  return { getBalance, transfer }
}

迁移到Viem的版本:

// 新的ERC20 Hook (Viem)
import { createPublicClient, createWalletClient, custom, http } from 'viem'
import { mainnet } from 'viem/chains'
import { useAccount, useWalletClient } from 'wagmi'

// 注意:Viem需要更精确的ABI类型,不能直接用JSON ABI
import { erc20Abi } from 'viem'
import { usePublicClient } from 'wagmi'

export function useViemERC20(contractAddress: `0x${string}`) {
  const { address } = useAccount()
  const publicClient = usePublicClient()
  const { data: walletClient } = useWalletClient()
  
  const getBalance = async (account?: `0x${string}`) => {
    if (!publicClient) throw new Error('No public client')
    
    const balance = await publicClient.readContract({
      address: contractAddress,
      abi: erc20Abi,
      functionName: 'balanceOf',
      args: [account || address!],
    })
    
    return balance as bigint
  }
  
  const transfer = async (to: `0x${string}`, amount: bigint) => {
    if (!walletClient || !address) throw new Error('Wallet not connected')
    
    const hash = await walletClient.writeContract({
      address: contractAddress,
      abi: erc20Abi,
      functionName: 'transfer',
      args: [to, amount],
      account: address,
    })
    
    // 等待交易确认
    const receipt = await publicClient.waitForTransactionReceipt({ hash })
    return receipt
  }
  
  return { getBalance, transfer }
}

这里有个重要的坑:Viem要求地址必须是0x${string}类型,而不是普通的string。这意味着所有合约地址、用户地址都需要进行类型转换。我创建了一个类型守卫函数:

export function isValidAddress(address: string): address is `0x${string}` {
  return /^0x[a-fA-F0-9]{40}$/.test(address)
}

export function toViemAddress(address: string): `0x${string}` {
  if (!isValidAddress(address)) {
    throw new Error(`Invalid address format: ${address}`)
  }
  return address as `0x${string}`
}

第四步:处理事件监听

我们项目中有很多实时数据更新依赖于合约事件。ethers.js的事件监听和Viem完全不同。

原来的事件监听:

// ethers.js 事件监听
contract.on('Transfer', (from, to, amount, event) => {
  console.log('Transfer event:', { from, to, amount })
  updateUI()
})

迁移到Viem的事件监听:

// Viem 事件监听
import { watchContractEvent } from 'viem'

const unwatch = watchContractEvent({
  address: contractAddress,
  abi: erc20Abi,
  eventName: 'Transfer',
  onLogs: (logs) => {
    logs.forEach((log) => {
      const { args } = log
      console.log('Transfer event:', {
        from: args.from,
        to: args.to,
        amount: args.value
      })
      updateUI()
    })
  },
})

// 注意:Viem的watchContractEvent返回一个取消监听的函数
// 在React组件中需要在useEffect中清理
useEffect(() => {
  const unwatch = watchContractEvent({ ... })
  
  return () => {
    unwatch()
  }
}, [])

这里踩了个坑:Viem的事件参数args可能是undefined,需要做安全处理:

onLogs: (logs) => {
  logs.forEach((log) => {
    if (!log.args) return
    
    const { from, to, value } = log.args
    // 现在from, to, value都是可选的,需要类型断言
    if (from && to && value) {
      // 处理事件
    }
  })
}

第五步:集成Wagmi管理状态

为了更好的React集成,我引入了Wagmi。Wagmi是基于Viem的React Hooks库,类似于ethers.js的useDapp或web3-react。

配置Wagmi:

// lib/wagmi-config.ts
import { createConfig, configureChains } from 'wagmi'
import { publicProvider } from 'wagmi/providers/public'
import { mainnet, polygon, arbitrum } from 'wagmi/chains'
import { InjectedConnector } from 'wagmi/connectors/injected'
import { WalletConnectConnector } from 'wagmi/connectors/walletConnect'

const { chains, publicClient, webSocketPublicClient } = configureChains(
  [mainnet, polygon, arbitrum],
  [publicProvider()]
)

export const config = createConfig({
  autoConnect: true,
  connectors: [
    new InjectedConnector({ chains }),
    new WalletConnectConnector({
      chains,
      options: {
        projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!,
      },
    }),
  ],
  publicClient,
  webSocketPublicClient,
})

然后在App中包裹WagmiProvider:

import { WagmiConfig } from 'wagmi'
import { config } from '../lib/wagmi-config'

function App() {
  return (
    <WagmiConfig config={config}>
      <YourApp />
    </WagmiConfig>
  )
}

完整代码示例

下面是一个完整的、可运行的ERC20余额查询和转账组件,展示了Viem + Wagmi的实际使用:

import React, { useState, useEffect } from 'react'
import { useAccount, usePublicClient, useWalletClient } from 'wagmi'
import { erc20Abi } from 'viem'
import { formatUnits, parseUnits } from 'viem'
import { isValidAddress, toViemAddress } from '../lib/address-utils'

// 假设的USDC合约地址
const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'

function ERC20Transfer() {
  const { address, isConnected } = useAccount()
  const publicClient = usePublicClient()
  const { data: walletClient } = useWalletClient()
  
  const [balance, setBalance] = useState<bigint>(0n)
  const [recipient, setRecipient] = useState('')
  const [amount, setAmount] = useState('')
  const [loading, setLoading] = useState(false)
  
  // 获取余额
  const fetchBalance = async () => {
    if (!publicClient || !address) return
    
    try {
      const balance = await publicClient.readContract({
        address: toViemAddress(USDC_ADDRESS),
        abi: erc20Abi,
        functionName: 'balanceOf',
        args: [address],
      })
      
      setBalance(balance as bigint)
    } catch (error) {
      console.error('Failed to fetch balance:', error)
    }
  }
  
  // 转账
  const handleTransfer = async () => {
    if (!walletClient || !address || !recipient || !amount) return
    if (!isValidAddress(recipient)) {
      alert('Invalid recipient address')
      return
    }
    
    setLoading(true)
    try {
      // USDC有6位小数
      const amountInWei = parseUnits(amount, 6)
      
      const hash = await walletClient.writeContract({
        address: toViemAddress(USDC_ADDRESS),
        abi: erc20Abi,
        functionName: 'transfer',
        args: [toViemAddress(recipient), amountInWei],
        account: address,
      })
      
      console.log('Transaction hash:', hash)
      
      // 等待交易确认
      const receipt = await publicClient.waitForTransactionReceipt({ hash })
      console.log('Transaction confirmed:', receipt)
      
      // 更新余额
      await fetchBalance()
      setAmount('')
      setRecipient('')
      
      alert('Transfer successful!')
    } catch (error: any) {
      console.error('Transfer failed:', error)
      alert(`Transfer failed: ${error.shortMessage || error.message}`)
    } finally {
      setLoading(false)
    }
  }
  
  // 监听地址变化,重新获取余额
  useEffect(() => {
    if (address) {
      fetchBalance()
    }
  }, [address, publicClient])
  
  if (!isConnected) {
    return <div>Please connect your wallet</div>
  }
  
  return (
    <div>
      <h2>USDC Balance: {formatUnits(balance, 6)}</h2>
      
      <div>
        <input
          type="text"
          placeholder="Recipient address"
          value={recipient}
          onChange={(e) => setRecipient(e.target.value)}
        />
        <input
          type="text"
          placeholder="Amount"
          value={amount}
          onChange={(e) => setAmount(e.target.value)}
        />
        <button 
          onClick={handleTransfer} 
          disabled={loading || !recipient || !amount}
        >
          {loading ? 'Processing...' : 'Transfer'}
        </button>
      </div>
      
      <button onClick={fetchBalance} style={{ marginTop: '20px' }}>
        Refresh Balance
      </button>
    </div>
  )
}

export default ERC20Transfer

踩坑记录

在实际迁移过程中,我遇到了不少预料之外的问题:

  1. 类型错误:Argument of type 'string' is not assignable to parameter of type 'Hex'

    • 问题:Viem严格要求地址类型为0x${string}(Hex类型)
    • 解决:创建了toViemAddress类型转换函数和isValidAddress类型守卫
  2. 事件监听内存泄漏

    • 问题:Viem的watchContractEvent不会自动清理,在React组件卸载后仍在监听
    • 解决:必须在useEffect的清理函数中调用返回的unwatch函数
  3. BigInt序列化问题

    • 问题:将包含bigint的对象直接存入Redux或传递给API会报错
    • 解决:在存储前转换为string,使用时再转回bigint,或者使用支持bigint的序列化库
  4. RPC方法不支持

    • 问题:某些自定义RPC节点不支持Viem默认调用的方法
    • 解决:在创建transport时指定支持的RPC方法,或使用Alchemy、Infura等标准节点
  5. ABI类型不匹配

    • 问题:直接从原有项目复制的JSON ABI在Viem中类型推断失败
    • 解决:使用Viem提供的标准ABI(如erc20Abi),或使用as const断言自定义ABI

小结

从ethers.js迁移到Viem确实需要投入不少精力,但带来的类型安全、包体积减小和更现代的API设计是值得的。最关键的是采用渐进式迁移,先让两个库共存,逐步替换模块。对于新开始的Web3项目,我会直接选择Viem + Wagmi的组合。

从零构建现代化登录界面:React + Tailwind CSS 前端工程实践

从零构建现代化登录界面:React + Tailwind CSS 前端工程实践

引言:前端开发的黄金时代

在当今 Web 开发领域,用户体验已成为衡量一个应用成功与否的关键指标。登录界面作为用户与应用程序建立连接的第一道门户,其设计质量和交互体验直接影响着用户对产品的第一印象。本文将深入剖析一个基于 React 和 Tailwind CSS 构建的现代化登录页面项目,带你领略前端工程化的魅力。

这个项目虽然代码量不大,但却凝聚了现代前端开发的核心精髓:组件化思维、工具类优先的样式方案、受控组件的状态管理,以及以用户为中心的交互设计。让我们一同揭开这个精致登录页面背后的技术奥秘。


第一章:技术栈选型——站在巨人的肩膀上

1.1 Vite:下一代前端构建工具

项目采用 Vite 作为构建工具,这是一个明智的选择。相比传统的 Webpack,Vite 基于原生 ES 模块(ESM)的开发服务器实现了秒级热更新(HMR)。在开发过程中,当你修改代码保存的瞬间,浏览器几乎立即反映出变化,这种流畅的开发体验极大地提升了开发效率。

Vite 的核心优势在于:

  • 开箱即用:无需繁琐的配置,默认支持 TypeScript、JSX、CSS 预处理器等
  • 按需编译:只编译当前访问路径所需的代码,启动速度飞快
  • 生产优化:基于 Rollup 的打包机制,生成高度优化的静态资源

1.2 Tailwind CSS:实用优先的 CSS 框架

Tailwind CSS 代表了一种全新的样式编写范式。它不提供任何预设的组件,而是提供一套原子化的工具类,让开发者通过组合这些基础类来构建任意设计。

在登录页面中,我们看到了大量 Tailwind 类的应用:

className='min-h-screen bg-slate-50 flex items-center justify-center p-4'

这行代码实现了:

  • min-h-screen:最小高度为视口高度(100vh),确保背景填满整个屏幕
  • bg-slate-50:使用 Slate 色系的第 50 级作为背景色,一种非常浅的灰蓝色
  • flex items-center justify-center:Flexbox 布局,内容垂直水平居中
  • p-4:内边距为 1rem(16px)

这种"所见即所得"的类名设计,让样式变得可预测、可维护、可复用

1.3 Lucide React:优雅的图标解决方案

项目选用了 lucide-react 图标库,这是一个基于 SVG 的开源图标集合。相比传统的图标字体(如 Font Awesome),Lucide 具有以下优势:

  • Tree-shaking 友好:按需引入,只打包实际使用的图标
  • 可定制性强:通过 sizecolorstrokeWidth 等 props 灵活控制
  • 无障碍支持:内置适当的 ARIA 属性
import {Lock, Mail, EyeOff, Eye} from 'lucide-react';

这四个图标分别承担了不同的语义:

  • Lock:品牌标识和密码输入框的视觉提示
  • Mail:邮箱输入框的视觉提示
  • EyeOff/Eye:密码显示/隐藏切换按钮

第二章:组件架构——受控组件的精妙设计

2.1 状态管理的艺术

React 的核心哲学是UI 是状态的函数。在这个登录表单中,我们看到了典型的状态驱动设计:

const [formData, setFormData] = useState({
  email: '',
  password: '',
  rememberMe: false
});

这里定义了一个包含三个字段的表单状态对象。使用单个状态对象而非多个独立的 useState,有以下好处:

  1. 逻辑聚合:相关的表单数据组织在一起,结构清晰
  2. 易于扩展:添加新字段只需在对象中增加属性
  3. 便于提交formData 可直接作为 API 请求的 payload

2.2 抽象的事件处理函数

代码中最具技术含量的部分之一是 handleChange 函数:

const handleChange = (e) => {
  const { name, value, type, checked } = e.target;
  setFormData((prev) => ({
    ...prev,
    [name]: type === 'checkbox' ? checked : value
  }))
}

这个函数展现了高超的抽象能力:

参数解构的智慧 从事件对象中提取 namevaluetypechecked,这四个属性足以处理所有表单元素的变化。

动态属性名的技巧 [name] 使用 ES6 的计算属性名语法,根据输入框的 name 属性动态更新对应的状态字段。这意味着:

  • email 输入框变化时,formData.email 被更新
  • password 输入框变化时,formData.password 被更新
  • rememberMe 复选框变化时,formData.rememberMe 被更新

类型判断的处理 type === 'checkbox' ? checked : value 这一三元表达式巧妙地处理了不同输入类型的差异:

  • 文本输入框(type="text|email|password")使用 value
  • 复选框(type="checkbox")使用 checked

函数式更新的必要性 使用 setFormData((prev) => ...) 而非 setFormData({...}) 是最佳实践。因为在异步操作或批量更新场景中,直接访问 formData 可能拿到过时的值,而函数式更新保证基于最新状态进行计算。

2.3 受控组件的完整闭环

每个输入框都遵循受控组件模式:

<input 
  type="email" 
  name="email" 
  required
  value={formData.email}
  onChange={handleChange}
  placeholder='name@company.com'
  className="..."
/>

受控组件的核心特征

  • value 绑定到 React 状态
  • onChange 触发状态更新
  • UI 完全由状态驱动,形成"状态 → UI → 事件 → 新状态"的闭环

这种模式虽然比非受控组件(使用 ref)多写几行代码,但带来了巨大的优势:

  • 即时验证:可在用户输入时实时校验
  • 动态禁用:可根据条件禁用提交按钮
  • 自动格式化:可自动格式化输入内容(如电话号码、信用卡号)
  • 测试友好:状态可预测,易于单元测试

第三章:交互设计——细节决定用户体验

3.1 密码显示/隐藏功能

密码输入框的显示/隐藏切换是一个看似简单却极具实用价值的设计。让我们分析其实现:

const [showPassword, setShowPassword] = useState(false);

// JSX 部分
<input
  type={showPassword ? 'text' : 'password'}
  // ...其他属性
/>
<button 
  type="button"
  onClick={() => setShowPassword(!showPassword)}
  className="absolute inset-y-0 right-0 pr-4 flex items-center text-slate-400 hover:text-slate-600 transition-colors"
>
  {showPassword ? <EyeOff size={18}/> : <Eye size={18} />}
</button>

技术要点解析

  1. 状态驱动类型切换

    • showPasswordfalse 时,type="password",字符显示为圆点
    • showPasswordtrue 时,type="text",字符明文显示
  2. 按钮的语义化设计

    • type="button" 防止按钮默认提交表单
    • onClick 切换状态,实现显示/隐藏的 toggle 效果
  3. 图标的动态渲染

    • 显示密码时用 EyeOff(眼睛带斜杠),暗示"关闭可见性"
    • 隐藏密码时用 Eye(眼睛),暗示"开启可见性"
    • 这种反向提示符合用户的心理模型
  4. 绝对定位的布局

    • absolute inset-y-0 right-0 将按钮固定在输入框右侧
    • inset-y-0 等同于 top: 0; bottom: 0,让按钮垂直拉伸
    • pr-4 提供右侧内边距,避免图标贴边
  5. 视觉反馈

    • text-slate-400 默认灰色
    • hover:text-slate-600 悬停时变深
    • transition-colors 颜色平滑过渡

这个功能的价值在于:

  • 减少输入错误:用户可确认密码是否正确
  • 提升可访问性:对视力障碍用户更友好
  • 增强掌控感:用户可自主选择是否显示密码

3.2 视觉焦点的引导

仔细观察输入框的样式,会发现精妙的焦点状态设计:

className="... focus:outline-none focus:ring-2 focus:ring-indigo-600/20 focus:border-indigo-600"

焦点状态的三层效果

  1. focus:outline-none:移除浏览器默认的蓝色轮廓
  2. focus:ring-2:添加 2px 宽的外环
  3. focus:ring-indigo-600/20:外环颜色为靛蓝色,透明度 20%
  4. focus:border-indigo-600:边框变为靛蓝色

同时,左侧图标也会响应焦点:

className="... text-slate-400 group-focus-within:text-indigo-600 transition-colors"

group-focus-within 是一个强大的选择器,当容器内任意元素获得焦点时,样式生效。这让图标从灰色变为靛蓝色,与输入框的焦点状态形成视觉呼应。

这种多层次的焦点反馈让用户清晰地知道当前操作的元素,提升了界面的可访问性和专业感。

3.3 响应式设计的考量

虽然这是一个简单的登录页面,但响应式设计的细节无处不在:

className='... p-8 md:p-10'
  • 移动端(默认):内边距为 p-8(2rem, 32px)
  • 中等屏幕及以上(md: 断点,768px):内边距为 p-10(2.5rem, 40px)

这体现了 Mobile First(移动优先) 的设计原则:

  • 默认样式针对小屏幕
  • 通过媒体查询逐步增强到大屏幕

另一个例子是容器宽度:

className='w-full max-w-md'
  • w-full:宽度 100%,在小屏幕上充分利用空间
  • max-w-md:最大宽度 28rem(448px),在大屏幕上不会过度拉伸

这种设计确保了从 iPhone SE(375px 宽)到 27 寸显示器(2560px 宽)的各种设备上,登录框都能呈现最佳的视觉效果。


第四章:Tailwind CSS 的深度实践

4.1 色彩系统的科学

项目使用了 Tailwind 的 Slate 色系,这是一种偏冷色调的灰色,比纯灰色更具现代感。Slate 色系从 50 到 950 共定义了 15 个色阶:

bg-slate-50        // 最浅,用于背景
text-slate-900     // 最深,用于主标题
text-slate-700     // 中等深度,用于标签
text-slate-500     // 较浅,用于辅助文字
border-slate-200   // 浅色边框
shadow-slate-200   // 浅色阴影

这种系统化的色彩使用带来了:

  • 视觉层次:通过深浅区分信息优先级
  • 和谐统一:同一色系保证色彩协调
  • 易于调整:更换色系只需全局替换类名前缀

4.2 阴影的艺术

登录卡片的阴影设计值得细细品味:

className='... shadow-xl shadow-slate-200/60'

这里使用了双层阴影

  1. shadow-xl:Tailwind 预设的大阴影(0 20px 25px -5px rgb(0 0 0 / 0.1)
  2. shadow-slate-200/60:带颜色的阴影,透明度 60%

这种组合产生了柔和的立体感,让卡片仿佛轻轻浮在背景之上。相比纯黑色阴影,带颜色的阴影更加精致,与整体设计语言更协调。

4.3 间距的一致性

Tailwind 的间距系统基于 4px 网格:

space-y-6    // 子元素垂直间距 1.5rem (24px)
mb-10        // 下外边距 2.5rem (40px)
mt-2         // 上外边距 0.5rem (8px)

space-y-6 是一个特别优雅的工具类,它自动为容器的所有子元素(除第一个外)添加 margin-top: 1.5rem。这比手动给每个元素添加 mt-6 更简洁,也避免了第一个元素不需要上边距的特殊处理。

4.4 圆角的温度

项目中大量使用了圆角设计:

rounded-3xl      // 大圆角,1.5rem (24px)
rounded-xl       // 中等圆角,0.75rem (12px)

圆角的选择传递了不同的情感:

  • 大圆角rounded-3xl):友好、现代、亲和
  • 中等圆角rounded-xl):精致、专业、不失活泼

相比直角或小的圆角,大圆角更符合当代设计趋势,给人一种轻松愉悦的视觉感受。


第五章:工程化思维——可扩展性与维护性

5.1 代码组织的最佳实践

虽然当前代码量不大,但已经展现了良好的工程化思维:

清晰的注释系统

// esm React 代表默认引入
// useState hooks 引入 部分引入
// 数据业务
// 抽象的事件处理函数
// 密码显示隐藏
// 登录 api 等待状态

这些注释不是简单的重复代码,而是解释了设计意图业务逻辑,帮助后续维护者快速理解代码结构。

逻辑分组 代码按照功能模块自然分组:

  1. 导入语句
  2. 状态定义
  3. 事件处理函数
  4. JSX 渲染

这种组织方式让代码具有良好的可读性。

5.2 待扩展的功能点

README 中提到了 isLoading 状态,虽然当前代码中还未完全实现,但已经预留了状态:

const [isLoading, setIsLoading] = useState(false);

这个状态可用于:

  • 提交时显示加载动画
  • 禁用提交按钮防止重复提交
  • 显示"登录中..."的文字提示

完整的实现可能如下:

const handleSubmit = async (e) => { 
  e.preventDefault();
  setIsLoading(true);
  try {
    await loginApi(formData);
    // 登录成功处理
  } catch (error) {
    // 错误处理
  } finally {
    setIsLoading(false);
  }
}

// JSX
<button 
  type="submit"
  disabled={isLoading}
  className="... disabled:opacity-50"
>
  {isLoading ? '登录中...' : '登录'}
</button>

5.3 表单验证的扩展空间

当前使用了 HTML5 的原生验证(requiredtype="email"),但可以扩展到更复杂的验证场景:

const [errors, setErrors] = useState({});

const validate = () => {
  const newErrors = {};
  if (!formData.email) {
    newErrors.email = '邮箱不能为空';
  } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
    newErrors.email = '邮箱格式不正确';
  }
  if (formData.password.length < 8) {
    newErrors.password = '密码至少需要 8 个字符';
  }
  setErrors(newErrors);
  return Object.keys(newErrors).length === 0;
}

配合 Tailwind 的样式,可以显示错误提示:

{errors.email && (
  <p className="text-red-500 text-sm mt-1">{errors.email}</p>
)}
<input 
  className={`... ${errors.email ? 'border-red-500' : 'border-slate-200'}`}
/>

第六章:用户体验的深层思考

6.1 认知负荷的降低

好的设计应该是"透明"的,用户无需思考就能自然操作。这个登录页面在降低认知负荷方面做了很多努力:

图标的语义化

  • 邮箱输入框旁的信封图标,直观地告诉用户"这里输入邮箱"
  • 密码输入框旁的锁图标,暗示"这是安全输入区域"
  • 眼睛图标,无需文字就能理解其功能

占位符的引导

placeholder='name@company.com'  // 邮箱格式示例
placeholder='********'          // 密码格式暗示

占位符不是简单的装饰,而是格式模板,帮助用户理解应该输入什么内容。

6.2 微交互的力量

微交互是指那些细微但能提升用户体验的动画和反馈:

过渡动画

transition-colors    // 颜色变化平滑过渡
transition-all       // 所有属性变化平滑过渡

这些过渡让状态变化不再突兀,而是如流水般自然。例如,当用户聚焦输入框时,图标颜色从灰色变为靛蓝色,这个变化如果有 200-300ms 的过渡,会比瞬间变化更加优雅。

悬停反馈

hover:text-indigo-500  // 链接悬停时颜色变浅
hover:text-slate-600   // 按钮悬停时颜色变深

悬停状态给用户提供了可点击的暗示,这是桌面端网页的重要交互线索。

6.3 无障碍性的考量

虽然代码中没有显式的 ARIA 属性,但已经体现了一些无障碍设计的思想:

语义化的 HTML

  • 使用 <label> 关联输入框,屏幕阅读器可以正确朗读
  • 使用 type="email",移动设备会自动显示带@的键盘
  • 使用 required,浏览器会提供原生的验证提示

可改进的无障碍特性 可以进一步增强:

<label htmlFor="email">Email:</label>
<input id="email" type="email" aria-describedby="email-help" />
<span id="email-help" className="sr-only">请输入您的注册邮箱</span>

sr-only 是一个常用的辅助类,让内容只对屏幕阅读器可见:

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  border: 0;
}

第七章:从登录页面看前端开发的未来

7.1 工具类优先的范式转移

Tailwind CSS 代表的是一种思维方式的转变。传统的 CSS 编写方式是:

/* 思考样式命名 */
.login-container {
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
}

而 Tailwind 的方式是:

className="min-h-screen flex items-center justify-center"

这种转变的核心价值在于:

  • 减少命名负担:不再需要为每个样式想一个语义化的类名
  • 提高开发速度:直接组合工具类,无需在 CSS 文件和组件之间切换
  • 减小 CSS 体积:未使用的工具类会被自动移除(PurgeCSS)
  • 提升一致性:使用预定义的间距、颜色、字体等系统

当然,这种范式也有争议。批评者认为它让 JSX 变得冗长,但支持者认为这换来了开发效率和可维护性的提升。

7.2 组件库 vs 工具类

一个有趣的问题是:为什么不直接使用现成的 UI 组件库(如 Material-UI、Ant Design)?

这个问题的答案取决于项目需求:

使用组件库的场景

  • 需要快速搭建原型
  • 团队没有专业设计师
  • 需要丰富的组件类型(表格、弹窗、日期选择器等)

使用 Tailwind 的场景

  • 需要高度定制的设计
  • 追求更小的打包体积
  • 设计师提供了精确的设计稿

这个项目选择了 Tailwind,说明它追求的是精致的定制化设计,而非快速搭建。从结果来看,这个登录页面的视觉效果确实超越了大多数组件库的默认样式。

7.3 前端工程师的核心竞争力

通过这个小小的登录页面,我们可以看到现代前端工程师需要具备的能力:

  1. 框架理解:深入理解 React 的状态管理、生命周期、组件通信
  2. CSS 功底:理解 Flexbox、定位、过渡等核心概念
  3. 工具掌握:熟练使用 Tailwind、Vite 等现代工具
  4. 用户体验:站在用户角度思考交互细节
  5. 工程思维:考虑代码的可维护性、可扩展性
  6. 审美能力:对色彩、间距、比例有敏锐的感知

这些能力不是孤立的,而是相互交织,共同构成了一个优秀前端工程师的核心竞争力。


结语:小项目,大智慧

这个看似简单的登录页面,实际上凝聚了现代前端开发的诸多精华。从技术选型到代码组织,从交互设计到用户体验,每一个细节都值得细细品味。

技术的本质是解决问题,而不是堆砌复杂度。这个项目告诉我们,即使是再小的功能,也值得用心打磨。当你在一个输入框的焦点状态上花费心思时,当你在一个图标的颜色选择上反复斟酌时,你不仅仅是在写代码,更是在塑造用户与数字世界交互的方式

希望这篇文章能让你对前端开发有更深的理解,也能在你的下一个项目中,带来一些启发和灵感。记住,伟大的产品不是偶然产生的,而是由无数个精心设计的细节累积而成的。


(全文约 4200 字)

我把 Gemma4:26b 装进 M1 Pro 后,才看清 AI 编程最贵的不是模型费,而是工作流

下午两点多,我盯着终端发呆。

pulling ... 100%,然后断线。 重试。又断。 再重试。还是断。

到第三次的时候,我已经不是在下载模型了,我是在跟自己的耐心较劲。

最后看到 writing manifestsuccess 那一刻,我脑子里只剩一句话:

现在做 AI 编程,最贵的不是模型费,是你被流程反复打断、反复重来的时间。

image.png


269e2c5b-0586-473b-84c4-b8d3b72abce6.png

01|装完模型我才意识到:性能不是第一道坎,协作才是

我这台机器是 M1 Pro 32G。 gemma4:26b 跑纯文字问答,体感其实挺快,日常对话、方案讨论都很顺。

但一旦任务变成“长链路”,比如:

  • 跨多个文件修改
  • 连续工具调用
  • 长上下文推理

就会明显感受到:真正拉开差距的,不是单次回答速度,而是整套流程能不能稳定跑完(ps: 单纯的说本地模型哈,付费API的能力还是非常🐮🍺的)。

以前我总想找一个“全能模型”,把所有任务都塞进去。 现在看,这个思路本身就容易卡死。

不是模型不够强,是分工不清。


02|我把模式改成“主脑 + 助手”后,效率开始稳定

我现在用的是一个很朴素的工作流:

混合模式: 付费API + 本地模型 (可以抱着玩的心态去搞

家大业大助理太多.png

  • GPT 做主脑:拆任务、定策略、做最终审校
  • Gemma4:26b 做助手:跑初稿、做重复劳动、吃本地隐私任务
  • 人做拍板:关键风险操作必须人工确认

这套分工解决了三个高频痛点:

  1. 大模型能力强,但不该拿来干所有重复活
  2. 本地模型成本低,但不适合所有高复杂链路
  3. 全自动看起来很爽,但最怕一次跑偏后难回滚

一句话总结:

把重复交给助手,把判断留给主脑。


03|我现在更相信“半自动可回滚”,而不是“一键全自动”

很多人追求的是:一句话需求 -> 自动改完 -> 自动提交。

我实测下来,真正能长期落地的,反而是这条:

先计划,再改动,再确认。

我的执行顺序是:

  • 先出 plan(不直接改)
  • 再出 diff(只看变更)
  • 最后执行(高风险命令二次确认)

这套流程的好处非常现实:

就算模型偶尔跑偏,也只是“返工一次”,不会“炸穿一次”。

团队里真正稳定高产的人,往往不是最会写 prompt 的人, 而是最会设计“出错后怎么回来”的人。


04|给一人团队的最低配模板(今天就能上手)

如果你也是一人开发,不要一上来就搭巨复杂系统。 先把这 4 条跑起来:

  • 任务分级:小改动 / 中改动 / 高风险改动
  • 模型路由:本地默认,复杂任务升级
  • 执行闸门:删除、批量改、线上命令必须确认
  • 交付标准:每次都要有 plan、diff、回滚点

先把“稳定完成”做出来,再谈“极限效率”。


装完 gemma4:26b 这一天,我最大的变化不是“多会用一个模型”, 而是感觉 ------ 在充点(“钞能力”)你会更强,我的M1 Pro是“老家伙”了,只能跑26b,跑32的话估计就宕机了。

2026 年最值钱的能力,也许不是会写多少代码, 而是你能不能把一套 AI 工作流跑到稳定复用。

你现在是“一个人在写代码”, 还是“一个人在带一支 AI 小团队”?

⚡精通 Claude 第 1 课:掌握 Slash Commands

Slash Commands 是 Claude Code 中的快捷方式,通过 / 触发。55+ 内置命令、Skills、自定义命令、MCP 提示词都通过这种机制工作。本文覆盖常用命令、自定义 Skills 创建、以及实战技巧。


slash-command.png

什么是 Slash Commands

Slash Commands 是 Claude Code 中的核心交互机制。在对话中输入 / 开头的指令,Claude 会直接执行对应操作,而不是继续对话。

/help        → 显示帮助
/clear       → 清空对话
/plan        → 进入计划模式
/compact     → 压缩上下文

image.png 这不是对话的延续,而是命令执行。这是 Claude Code 与普通 AI 对话的本质区别。


内置命令速查

Claude Code 提供了 55+ 内置命令,覆盖日常开发全流程:

高频必备

命令 用途
/help 显示所有可用命令
/clear 清空当前对话(别名:/reset, /new
/plan 进入计划模式,让 Claude 先分析再执行
/compact 压缩上下文,保留关键信息
/diff 查看未提交的文件变更
/model 切换 AI 模型

image.png

Git 工作流

命令 用途
/pr-comments <PR号> 获取 GitHub PR 评论
/branch [name] 创建分支(别名:/fork
/resume [session] 恢复历史对话

image.png

系统状态

命令 用途
/status 版本、模型、账户信息
/cost Token 消耗统计
/stats 每日使用可视化
/context 可视化上下文占用

image.png

image.png

image.png 最近一个月用了27天,真的用了CC就回不去古法编程了😂

image.png

Claude Code 配置

命令 用途
/config 打开设置界面
/hooks 查看钩子配置
/mcp 管理 MCP 服务器
/plugin 管理插件
/theme 切换颜色主题
/permissions 调整工具权限

image.png

核心配置项(已显示)

  • Auto-compact:自动压缩冗余对话历史,防止上下文溢出
  • Show tips:显示使用提示与快捷键,辅助上手
  • Reduce motion:关闭界面动画,提升响应速度
  • Thinking mode:开启深度推理,保障复杂任务准确性
  • Fast mode:降低推理深度,快速响应(仅 Opus 4.6)
  • Rewind code:创建代码修改检查点,支持一键回退
  • Verbose output:输出详细调试日志,用于排错
  • Terminal progress bar:显示任务进度,直观查看状态
  • Show turn duration:标注单次交互的耗时,评估性能
  • Default permission mode:控制文件 / 命令操作的默认权限(手动确认 / 允许 / 拒绝)

隐藏配置项(12 more below)

  • Show line numbers:生成代码时自动显示行号
  • Show timestamps:对话历史中显示时间戳
  • Model selection:设置默认使用的模型(Sonnet/Opus/Haiku)
  • Context window limit:手动设置上下文窗口的最大 Token 数
  • Sandbox mode:开启沙箱隔离,限制危险操作
  • Custom hooks:配置自定义脚本(执行前 / 后触发)
  • Keyboard shortcuts:自定义操作快捷键,提升效率
  • Output format:设置响应内容的输出格式(文本 / Markdown/JSON)
  • Allowed tools:指定 Claude 可使用的工具列表
  • Disallowed tools:禁用 Claude 的指定工具
  • Prompt caching:开启提示词缓存,加速重复请求
  • Max thinking tokens:限制思考模式可使用的最大 Token 数
  • Status line customization:自定义终端状态栏的显示内容

image.png/hooks 就是给 Claude Code 加自定义规则、自动脚本、触发动作的地方。

你可以把它理解成:给 AI 助手装插件、设规矩、让它自动帮你干活。

举几个最容易懂的例子:

  1. 每次 Claude 改代码前,自动备份文件
  2. 代码保存后,自动跑 lint 检查格式
  3. 禁止 Claude 访问某些敏感文件
  4. 每次生成代码后,自动格式化
  5. 让 Claude 每次启动都加载你的项目规则

本质是什么?

hooks = 你给 AI 定的自动化小规则。不用你手动点、不用你重复输命令,AI 会按你的规矩自动运行。

image.png

image.png

image.png

image.png

Bundled Skills:内置技能包

Skills 是增强版的 Commands,可以打包脚本、模板和参考文件:

Skill 用途
/batch <instruction> 使用 worktree 并行执行大规模修改
/claude-api 加载项目语言的 Claude API 参考
/debug [description] 开启调试日志
/loop [interval] <prompt> 定时重复执行提示词
/simplify [focus] 检查代码质量

/batch 是 Claude Code 的批量任务执行工具,让你一次性给 AI 发一长串任务清单,它会按顺序自动跑完所有任务,不用你一次次手动发指令、等回复。 你可以把 /batch 理解成:给 Claude Code 开了个「自动流水线」

平时用 Claude Code 是「一问一答」:你发一个需求,AI 做完一个,你再发下一个,全程要手动跟进。而 /batch 就是把你所有要做的事,一次性打包成一个「任务清单」喂给 AI,它会自动按顺序、不中断地把所有任务全部执行完,中间完全不用你手动干预。

比如你要给一个前端项目做这些事:

  1. 把所有组件的 console.log 都删掉
  2. 给所有接口请求加统一的错误捕获
  3. 给所有按钮加 loading 状态防重复点击
  4. 跑一遍 eslint 修复格式问题
  5. 生成一份修改说明文档

如果不用 /batch:你要分 5 次发指令,每次等 AI 做完,再手动发下一个,全程要盯着。用了 /batch:你把这 5 个任务一次性写进 /batch,然后去喝杯咖啡,回来 Claude 已经把所有任务全做完了,直接给你最终结果。


/debug 是 Claude Code 的问题排查工具,专门用来显示后台详细日志,帮你快速找到 AI 为什么出错、卡壳、不干活。

image.png 平时用 Claude Code,你只能看到 AI 给你的最终结果:代码、回答、提示。但 AI 内部到底干了什么、调用了什么工具、读了哪些文件、哪里卡住了,你是看不见的。

/debug 就是把这些 “后台秘密” 全部亮出来给你看。

开启后,Claude Code 会显示:

  • AI 正在调用什么命令
  • 读取 / 修改了哪些文件
  • 为什么拒绝执行某个操作
  • 哪里报错、哪里超时、哪里卡住
  • 模型思考过程、工具执行结果

/loop 是 Claude Code 的自动循环重试工具,让 AI 自己反复检查、修改、运行代码,直到满足要求为止,不用你反复手动提醒。

平时你让 AI 写代码、改 Bug,经常会出现这种情况:AI 改一次 → 运行报错 → 你告诉它错了 → 它再改一次 → 又报错 → 你又得提醒……

来回折腾特别麻烦,效率很低。

/loop 就是解决这个问题的:你开启循环模式,AI 会自己 “闭环干活”

它会自动做这几件事:

  1. 改代码
  2. 运行测试 / 检查报错
  3. 自己发现哪里错了
  4. 自动再次修改
  5. 直到运行成功、没有错误,才停下来告诉你完成

简单说:你只需要说需求,剩下的反复调试让 AI 自己循环搞定,不用你插手。


/simplify 命令可以用来优化代码

image.png


自定义 Skills:打造你的命令体系

Skills 是 .claude/skills/<name>/SKILL.md 文件:

image.png

mkdir -p .claude/skills/optimize

文件:.claude/skills/optimize/SKILL.md

---
name: optimize
description: 分析代码性能问题、内存泄漏和优化机会
argument-hint: <代码片段>
allowed-tools: Bash, Read, Grep
---

# 代码优化分析

分析以下代码的性能问题:

$ARGUMENTS

检查:
1. 是否有不必要的重复计算
2. 是否存在内存泄漏风险
3. 算法复杂度是否有优化空间
4. 是否有冗余的 DOM 操作或 API 调用

变量替换

全部参数:

---
name: fix-issue
description: 修复 GitHub Issue
---

修复 Issue #$ARGUMENTS

单个参数:

---
name: review-pr
description: 评审 PR
---

评审 PR #$0,优先级 $1

动态上下文(Shell 命令):

---
name: commit
description: 创建带上下文的 Git 提交
allowed-tools: Bash(git *)
---

## 当前状态

- Git 状态:!`git status`
- 文件变更:!`git diff HEAD`
- 当前分支:!`git branch --show-current`

## 任务

基于以上变更,创建提交信息。

文件引用

请审查以下实现:
@src/utils/helpers.js
对比 @src/old-version.js 和 @src/new-version.js

Frontmatter 完整参考

---
name: my-command           # 命令名称(成为 /my-command)
description: 用途描述      # 帮助 Claude 判断何时使用
argument-hint: <参数>      # 自动补全提示
allowed-tools: Bash, Read  # 无需审批即可使用的工具
model: opus                # 指定使用的模型
disable-model-invocation: true  # 仅用户可调用
user-invocable: false      # 从 / 菜单隐藏
context: fork              # 在隔离子代理中运行
agent: general-purpose     # 子代理类型
hooks:                     # 技能级钩子
  PreToolUse: []
  PostToolUse: []
  Stop: []
---

命令执行流程

sequenceDiagram
    participant User
    participant Claude
    participant FS
    participant Shell

    User->>Claude: /optimize
    Claude->>FS: 搜索 .claude/skills/ 和 .claude/commands/
    FS-->>Claude: 返回 optimize/SKILL.md
    Claude->>Claude: 解析 Frontmatter
    Claude->>Shell: 执行 !`command` 替换
    Shell-->>Claude: 返回命令输出
    Claude->>Claude: 替换 $ARGUMENTS
    Claude->>User: 处理提示词
    Claude->>User: 返回结果

实战技巧

1. 用 /plan 避免浪费

在执行大改动前,输入:

/plan 重构用户认证模块

Claude 会先分析代码、制定计划,你确认后再执行。避免做到一半发现方向错了。

2. 用 /compact 保持专注

长对话变慢时,/compact 压缩上下文,保留核心信息,速度恢复。

3. 自定义快捷命令

把常用的复杂操作封装为 Skill:

---
name: deploy
description: 部署到生产环境(仅用户可调用)
disable-model-invocation: true
allowed-tools: Bash(npm *), Bash(git *)
---

1. 运行测试:npm test
2. 构建:npm run build
3. 推送到部署目标
4. 验证部署状态

4. MCP 提示词作为命令

MCP 服务器暴露的提示词可以直接调用:

/mcp__github__list_prs
/mcp__github__pr_review 456
/mcp__jira__create_issue "Bug 标题" high

Skill vs Legacy Command

特性 Skills(推荐) Legacy Commands
位置 .claude/skills/<name>/SKILL.md .claude/commands/<name>.md
目录结构 支持打包文件 单文件
自动触发 支持 不支持
子代理执行 context: fork 不支持
优先级 更高 较低

同名时,Skill 优先。


常见问题

命令不生效?

  • 确认文件在 .claude/skills/<name>/SKILL.md.claude/commands/<name>.md
  • 检查 frontmatter 的 name 字段
  • 重启 Claude Code 会话

Skill 和 Command 冲突?

  • 删除其中一个,或重命名
  • 同名时 Skill 总是优先

总结

Slash Commands 是 Claude Code 的核心交互范式:

  • 内置命令:55+ 覆盖开发全流程
  • Bundled Skills:开箱即用的增强能力
  • 自定义 Skills:打造个人命令体系
  • MCP 提示词:扩展到外部工具

下一课我们将深入 Memory 系统,学习如何让 Claude 记住项目上下文。


延伸阅读

连载04-CLAUDE.md ---一起吃透 Claude Code,告别 AI coding 迷茫

CLAUDE.md 完整指南——让 Claude 真正理解你的项目

AI Coding 系列第 04 篇 · CLAUDE.md 到底是什么:不是文档,而是 Claude 的规则层


CLAUDE.md 被严重误解

很多人对 CLAUDE.md 的理解有偏差。有人把它当项目文档来写,两百行的架构介绍、API 清单、数据库设计,然后疑惑为什么 Claude 经常无视其中的规则。有人复制了一个通用模板,放在那里从来不改。还有人干脆不知道它到底是干什么的。

这类误解有一个共同点:
CLAUDE.md 当成了“给 AI 看的项目说明书”。

CLAUDE.md 的本质不是文档,而是规则层

它不是用来完整介绍项目的,而是用来告诉 Claude:

  • 这个项目里哪些边界不能碰
  • 哪些行为默认是错的
  • 哪些约定会反复影响决策
  • 哪些高风险区域必须更保守

如果把它写成“项目背景”,Claude 最多只是“看过了”;
如果把它写成“行为规则”,Claude 的默认工作方式才会真正改变。

所以更准确的定义是:

CLAUDE.md 不是项目文档,而是把稳定偏好、高风险边界和重复纠正,提前变成 Claude 默认上下文的规则层。


一、CLAUDE.md 到底解决什么问题

一个好用的 CLAUDE.md,主要解决四类问题。

1. 把反复提醒的内容沉淀下来

如果你总是在 prompt 里反复说这些话:

  • 这个项目不要改 .github/workflows
  • 错误统一用 AppError
  • 不要默认新增依赖
  • 数据库变更前先讲回滚方案

那这些内容就不该每次重新说,而应该进入 CLAUDE.md

2. 给 Claude 的默认积极性加边界

Claude 默认会尽量帮你完成任务,但很多项目里真正危险的,不是它不做事,而是它做得太多。

比如:

  • 看见旧代码就想顺手重构
  • 看见没测试就想补一整套基础设施
  • 看见当前实现笨重就建议换栈

这些行为在通用场景里未必错,但在具体项目里可能是噪音,甚至是风险。
CLAUDE.md 的一个重要作用,就是给这种默认积极性划边界。

3. 把“代码里看不出来”的规则显式化

很多项目真正重要的约束,并不直接写在代码里。

例如:

  • 某个目录是历史包袱区,轻易别碰
  • 某些 migration 一旦上线后绝不能回写修改
  • 某个模块表面简单,背后连着外部系统
  • 某类接口一改就会影响前端联调和埋点

这些东西人类同事待久了会知道,但 AI 初来乍到不会知道。
CLAUDE.md 的价值,就在于把这些隐性知识提前说透。

4. 降低上下文成本

技术栈、关键路径、错误处理方式、依赖策略、部署边界,这些稳定规则本来就适合长期存在。把它们放进 CLAUDE.md,每次 prompt 就能专注当前任务,而不是重复灌输基础背景。


二、它不只是纠错层,也是预防层

前面说 CLAUDE.md 是纠偏器,这个说法是对的,而且很重要。因为它能一下子把很多人从“项目文档思维”拉回来。

但如果只停在“纠偏器”这一层,对它的理解还是不完整。

更准确地说,CLAUDE.md 既是纠错层,也是预防层

1. 纠错层:把重复犯的错写成规则

比如:

  • 你已经说过两次不要直接 throw new Error()
  • 你已经纠正过几次不要改 .github/workflows
  • 你已经反复提醒过不要随便 npm install

这些都属于典型的“纠错”。

2. 预防层:提前声明高代价边界

真正好用的 CLAUDE.md,并不只是在事后补锅。它还有一个同样重要的作用:提前声明那些一旦做错,代价就很高的边界。

比如:

  • 支付模块改动前先确认幂等逻辑
  • migration 文件上线后只能新增,不能回写修改
  • 生成目录不要手改,因为下次生成会覆盖
  • 新增重大依赖前先说明必要性和替代方案

这些规则不一定是 Claude 已经犯过的错,也可能是你提前告诉它:

“这里不是不能动,而是这里的错误成本很高,你默认要更保守。”

所以从完整定位上说,CLAUDE.md 的作用不是单纯“记录反复犯的错”,而是:

把稳定偏好、风险边界和高代价约束,提前变成 Claude 的默认工作上下文。

CLAUDE.md 的真实定位

图:CLAUDE.md 不是项目文档,而是纠偏层 + 预防层 + 长期约束层。


三、文档式写法 vs 纠偏式写法

说一百遍不如直接对比。

❌ 文档式写法(Claude 读了,但行为不变)

本项目是一个电商平台,使用 Node.js + Express + TypeScript 开发,
数据库采用 PostgreSQL,通过 Prisma 进行 ORM 管理。
项目包含用户模块、订单模块、支付模块和通知模块,
遵循 RESTful API 设计规范……
✅ 纠偏式写法(Claude 读了,行为立刻改变)

- 禁止 throw new Error(),统一用 AppError 类
- API 响应必须含 success / data / timestamp 三个字段,不能自己发明格式
- 禁止在 controller 层直接写 SQL,必须通过 service 层
- 所有异步函数必须有 try-catch,不靠外层中间件兜底
- 新增依赖前必须问我,不要自行 npm install

文档式写法让 Claude “知道”了,但知道不等于行动。
纠偏式写法告诉 Claude:“在这个项目里,你的默认行为哪里不对。” 这才是它真正听进去的语言。

判断一条规则是不是纠偏式,只用问一个问题:

这条规则是在纠正 Claude 的某个具体行为,还是在描述项目背景?

能对应到一个具体行为变化的,是纠偏。
其他的,是文档。


四、它和 Prompt、文档、Memory、Skill 的边界

很多人用不好 CLAUDE.md,不是不会写规则,而是把它和别的东西混在一起了。

最容易混淆的有四个对象:Prompt、项目文档、Memory、Skill。

CLAUDE.md 和其他机制的边界

图:Prompt 管当前任务,文档管背景,Memory 管自动沉淀,Skill 管重复流程,CLAUDE.md 管稳定规则。

源码里的分工也很明确

如果去看 Claude Code 的源码,CLAUDE.mdMemory 的边界其实分得很清楚。相关实现可以看 src/utils/claudemd.ts。在这部分实现里,CLAUDE.md 被归在一套明确的 instruction loading 顺序里:

  1. Managed memory:全局托管规则
  2. User memory:~/.claude/CLAUDE.md
  3. Project memory:仓库里的 CLAUDE.md.claude/CLAUDE.md.claude/rules/*.md
  4. Local memory:CLAUDE.local.md

这套机制本质上是在加载指令文件

而同一个文件里又能看到另一套独立机制:当 auto memory 打开时,系统会额外读取 getAutoMemEntrypoint() 返回的 MEMORY.md,其类型是 AutoMem,团队记忆则是 TeamMem。源码里甚至专门写了注释:

AutoMem/TeamMem are intentionally excluded — they're a separate memory system, not "instructions" in the CLAUDE.md/rules sense.

这句话非常关键。它说明:

  • CLAUDE.md 这一层,本质上是 instructions / rules
  • MEMORY.md 这一层,本质上是 auto memory / persistent memory

它们最后都会进入上下文,但在架构里并不是同一个东西。

所以如果从源码上更严格地说,CLAUDE.md 不是 MEMORY.md 的别名,更不是 auto-memory 的索引。
真正扮演“索引 + 主题文件”角色的,是后面的 MEMORY.md 系统。

1. Prompt 负责当前任务

Prompt 解决的是这一次你到底要 Claude 做什么。

比如:

  • 这次只修 bug,不要顺手重构
  • 这次只分析原因,先不要改代码
  • 这次只改前端,不动后端

这些都是单次任务边界,适合写在 prompt 里,不适合沉淀进 CLAUDE.md

2. 项目文档负责完整背景

README、设计文档、接口文档、架构说明,负责回答的是:

  • 这个项目是什么
  • 系统怎么设计
  • 模块如何划分
  • 业务流程怎么走

这些内容通常信息量大、细节多、更新频繁,它们的职责是“说明项目”,不是“约束 Claude 的默认行为”。

3. CLAUDE.md 负责稳定规则

CLAUDE.md 解决的是那些跨多次任务都成立、而且会持续影响 Claude 决策的东西。

比如:

  • 高风险文件和目录
  • 错误处理规范
  • 依赖策略
  • migration 边界
  • 哪些行为必须先确认

它不负责讲完整背景,只负责把真正影响行为的规则提炼出来。

4. Memory 负责自动沉淀

它更像 Claude 在长期协作里逐步学到的东西,是补充,不是替代。

你可以把它理解成“模型慢慢记住了你们项目里的某些偏好和事实”,但这类记忆不适合代替明确规则。因为对于关键边界来说,你明确写下来的东西,永远比它自己学到的更稳。

结合源码看,这个分工会更清楚:

  • CLAUDE.md 通过 instruction loader 进入系统 prompt
  • MEMORY.md 则是 auto memory 的入口文件
  • 相关 topic files 会在需要时被检索和召回,而不是把所有细节都塞进一个大文件

因此,更准确的理解是把它们视为“两套协作机制”,而不是“一份文件的两种叫法”。

5. Skill 负责重复流程

CLAUDE.md 管的是“长期规则”,Skill 更适合管“这类任务应该怎么做”。

比如:

  • 需求分析怎么展开
  • Code Review 按什么顺序做
  • 排查线上 bug 用什么流程
  • 新功能开发先看哪些文件、再做哪些验证

这类内容本质上是“做事模板”,更像流程,不像规则。

可以概括成一句话:

  • 当前任务Prompt
  • 稳定规则CLAUDE.md
  • 完整背景进项目文档
  • 自动沉淀交给 Memory
  • 重复流程沉淀成 Skill

一旦边界分清楚了,很多人最头疼的那个问题就会自动消失:

为什么我明明写了很多东西,但 Claude 还是不按我想的来?

因为你很可能把应该放在不同位置的信息,全塞进了 CLAUDE.md


五、三层分层架构

CLAUDE.md 不是一个单一文件,而是一个分层的规则系统。

三层分层架构

图:先按稳定性分层,再决定规则应该写到用户级、项目级还是公司级。

用户级~/.claude/CLAUDE.md
你电脑上所有项目都生效,写个人偏好。

项目级:仓库根目录的 CLAUDE.md
只在这个项目生效,写项目特有约定,提交进 Git。

公司级:企业统一管理的配置位置
整个组织生效,写合规要求和架构标准。大型企业才更常用,普通团队通常不需要。

判断一条规则放哪层,只用一个标准:

换个项目还成立吗?

成立放用户级。
比如:“我的变量命名用驼峰。” 换到任何项目都一样。

不成立放项目级。
比如:“这个项目用 Prisma,禁止用 Sequelize。” 换到 MongoDB 项目就不适用了。

这个区分看起来简单,但它直接决定后面的维护成本。


六、用户级:写你的默认行为偏好

用户级规则要少而精,不超过 50 行。这里写的是覆盖 Claude 默认值的个人偏好。

# 我的个人偏好

## 代码风格
- 缩进:2 个空格
- 变量命名:camelCase,类名 PascalCase
- 单行不超过 100 字符

## 我固定用的库(不要建议替代品)
- 日期处理:date-fns,不用 moment.js
- HTTP 请求:axios,不用 node-fetch
- 测试:Jest,不用 Vitest 或 Mocha

## 从不做的事
- 不要在我没要求时修改测试文件
- 不要建议我换版本控制工具
- 不要在随意讨论时提出架构大改动

## Git 提交格式
feat(模块名): 简短描述

- 改动说明 1
- 改动说明 2

注意措辞:写的是“我的偏好”,不是“你必须”。前者 Claude 当作信息接收,后者听起来像命令,反而可能在某些场景被跳过。

用户级不该写什么

  • 一次性的任务背景
  • 大段项目文档
  • 经常变动的技术现状
  • 只在某个仓库成立的规则

比如:“我现在在做一个电商系统。” 这不是偏好,是当前任务。应该放在 prompt 里。


七、项目级:记录这个项目特有的边界

项目级可以稍长,100 行左右。核心是三类内容:

1. 关键文件保护

## 禁止修改的文件
- src/config/env.ts — 改了会影响生产环境变量加载
- .github/workflows/* — CI/CD 流水线,改动需要 DevOps 审核
- 数据库 migration 文件一旦执行,不得修改,只能新增

2. 编码规范,必须具体到代码动作

## 错误处理
统一使用 AppError 类,禁止 throw new Error():
throw new AppError('用户不存在', 404, 'USER_NOT_FOUND')

## API 响应格式
所有响应必须符合:
{ "success": true, "data": {}, "timestamp": "ISO字符串" }
错误响应:
{ "success": false, "error": "ERROR_CODE", "message": "描述" }

3. 高风险路径标注

## 高风险区域(修改前必须告知我)
- src/modules/auth/* — 认证核心,任何改动都需要 review
- src/handlers/payment/* — 对接支付商,出错直接影响收入
- src/database/migrations/* — 不可逆操作,要有回滚方案

项目级真正决定效果的,不是“把整个项目介绍一遍”,而是:

把这个仓库里最容易做错、最不能做错的东西写出来。


八、一条好规则到底该怎么写

很多人不是不会列规则,而是写出来之后没有约束力。

比如:

  • 代码要整洁
  • 数据库迁移要小心
  • 不要随便改配置

这些话人类看得懂,但模型不一定知道“到底怎样做才算遵守”。

一条好规则,尽量包含这几个元素:

  • 触发场景
  • 期望动作
  • 禁止动作
  • 原因
  • 示例

一条好规则怎么写

图:好规则最少要把场景、动作、边界和原因交代清楚。

看一个例子就很清楚。

❌ 只有规则
- 使用 Prisma 生成迁移,不要写原生 SQL
✅ 规则 + 原因 + 行为边界
- 涉及 schema 变更时,优先走现有 migration 工作流,不要临时手写 SQL 直接改结构。
  原因:团队的审查、回滚和环境同步流程都围绕当前 migration 体系建立。
  如果必须做破坏性变更,先说明影响范围和回滚方案。

再比如:

❌ 太抽象
- 注意统一错误处理
✅ 可执行
- 所有业务异常统一使用 AppError,禁止直接 throw new Error()。
  原因:前端依赖统一错误码和 message 做提示与埋点归类。

关键就在这里:

CLAUDE.md 不是写原则,而是写可执行规则。


九、为什么有时有效,有时又像没生效

这也是很多人真正困惑的地方。

不是所有写进 CLAUDE.md 的内容,效果都一样。有些规则一写进去,Claude 的行为马上变化;有些规则写了之后,几乎没感觉。

通常不是因为它“没读”,而是因为规则本身写得不够能执行。

第一,规则写成了背景介绍

例如:

本项目采用分层架构,强调可维护性和扩展性。

这句话是背景,不是约束。Claude 即使看到了,也很难从里面推导出具体行动。

第二,规则太抽象

例如:

- 代码要整洁
- 注意性能
- 数据库修改要谨慎

这些话人类看得懂,但模型不知道“怎样才算遵守”。

第三,规则太多,信噪比下降

不是说长文一定不好,而是低价值内容一多,真正重要的规则就会被埋掉。

如果一份 CLAUDE.md 里面既有项目概述、又有接口说明、又有架构文档、又有零碎提醒,那 Claude 真正应该优先遵守的那些边界,反而不够突出。

第四,规则之间互相冲突

比如你在用户级里写了“我习惯四空格缩进”,项目级里又写“这个项目统一两空格”,但没有说明项目级覆盖团队标准。
这种情况下,Claude 不是一定做错,而是判断空间会变大。

第五,单次任务 prompt 和长期规则打架

如果你在 CLAUDE.md 里长期写“默认不要大改”,但当前 prompt 又说“请你重构这一块并统一风格”,那单次任务会临时改变优先级。

这不是 CLAUDE.md 失效,而是上下文优先级在变化。

真正决定它能不能稳定生效的,是三件事:

规则足够具体,边界足够清楚,信噪比足够高。只有这三件事同时成立,CLAUDE.md 才会真正改变行为。


十、它很重要,但不是万能控制器

把这一点想清楚,对 CLAUDE.md 的期待反而会更稳。

CLAUDE.md 很强,但它不是万能控制器。它做不到下面这些事:

  • 它不能替代清晰的任务描述
  • 它不能替代 README 和设计文档
  • 它不能替代你对复杂任务的即时判断
  • 它不能保证 Claude 在任何场景下 100% 机械执行

它真正擅长的是:

  • 让默认行为更接近你的项目习惯
  • 让高风险边界更早暴露
  • 让重复提醒沉淀成长期规则
  • 让每次 prompt 更聚焦当前任务

所以最好的理解方式不是:

“只要我把 CLAUDE.md 写好了,后面什么都不用管了。”

而是:

“我用 CLAUDE.md 把稳定规则立住,再用 prompt 管当前任务,用文档承载背景,用 Skill 沉淀流程。”

只有在这套分工里,CLAUDE.md 的作用才会既强,又稳定。


十一、连接第 03 篇:为什么它能解决“纠正回退”

第 03 篇讲过一个现象:你在对话里纠正了 Claude,它承认了,但过几轮又犯同样的错。这不是 Claude 不配合,而是对话历史会随时间衰减,纠正效果也会随之消退。

更稳定的纠正方式,就是写进 CLAUDE.md

写进 CLAUDE.md 的规则,每次对话开始时都会被系统自动注入,不受对话长度影响,也不会像临时纠正那样快速衰减。

判断标准很简单:

同一件事你纠正了两次以上,就应该写进 CLAUDE.md,不要再在对话里重复说。

# 这条规则在对话里说了三次,该进 CLAUDE.md 了:
- 日志统一用 logger.info/warn/error,禁止 console.log

十二、Claude 会主动学习,但它补充不了规则层

CLAUDE.md 不是单向的。你往里写规则,Claude 也会在长期协作中逐步积累知识。

每轮对话结束后,Claude Code 会在后台启动一个独立的子 Agent,分析对话里有没有值得保留的项目知识,自动写入 Memory 文件,下次会话时注入:

对话结束 → 后台子 Agent 分析 → 提取项目偏好和技术决策
→ 写入 ~/.claude/projects/[项目]/memory/ → 下次会话自动读取

你在某次对话里说了“我们禁止用 moment.js,改用 date-fns”,下次打开 Claude Code,它已经记得了。

几个要知道的细节:

它补充 CLAUDE.md,不取代它。
自动记忆是“Claude 学到的”,CLAUDE.md 是“你明确要求的”,关键约束还是应该写在 CLAUDE.md 里。

明确说出来的比隐含的更容易被记住。
在对话里直接说“我们统一用 date-fns”,提取率更高;只是悄悄在代码里换了库,Claude 可能记不到。

你可以检查它记了什么。
/memory 命令可以查看当前记忆内容,发现记错了直接改,它本质上还是普通文本文件。

实际效果是:Claude Code 越用越懂你的项目。头几天需要反复解释背景,用了几周后,很多背景已经自动沉淀,你的 prompt 可以越写越短。

Memory 的索引 + 主题文件结构

图:从源码看,Memory 更像索引入口 + topic files,而不是一个无限膨胀的大文件。

从源码看,Memory 本质上是一套“索引 + 主题文件”的结构

从实现上看,auto memory 不是把内容都堆在一个文件里。相关实现可以看 src/memdir/memdir.ts。在这部分实现里,入口常量就是:

export const ENTRYPOINT_NAME = 'MEMORY.md'
export const MAX_ENTRYPOINT_LINES = 200
export const MAX_ENTRYPOINT_BYTES = 25_000

这三行信息已经说明了很多问题:

第一,真正被当作 memory 入口文件的,是 MEMORY.md,不是 CLAUDE.md
第二,系统从设计上就不希望这个入口文件无限膨胀。
第三,memory 架构默认就不是“把所有内容堆在一个大文件里”。

同一个文件里,源码把保存流程直接写成了两步:

  1. 先把记忆写入独立主题文件
  2. 再在 MEMORY.md 里增加一个索引指针

源码注释原话基本就是这个意思:

  • Step 1:write the memory to its own file
  • Step 2:add a pointer to that file in MEMORY.md

而且它还专门强调:

MEMORY.md is an index, not a memory

从实现上看,Claude Code 的 auto memory 更像:

  • MEMORY.md:目录页 / 索引页
  • topic files:按主题拆开的详细内容

这也解释了一个很多人会问的问题:

如果记忆越积越多,MEMORY.md 不会越来越大吗?

答案是:源码层面已经考虑了这个问题。

truncateEntrypointContent() 会对 MEMORY.md 做双重限制:

  • 超过 200 行会截断
  • 超过 25KB 也会截断

截断后甚至还会追加警告,提醒把细节移到 topic files,只把一行短索引留在 MEMORY.md

换句话说,这套设计本身就在强制你保持:

  • 索引足够短
  • 细节分散到主题文件
  • 入口文件永远尽量装得进上下文

这和 CLAUDE.md 的定位,是什么关系

最容易混在一起的,恰恰是规则系统和记忆系统。

如果站在源码架构的角度看:

  • CLAUDE.md 更像 instruction layer
  • MEMORY.md 更像 memory index layer
  • topic files 更像 memory payload layer

这三层不是互相替代,而是互相配合。

所以把 CLAUDE.md 定义成“规则层”是成立的,而且和源码是对齐的。

放到 Claude Code 的完整架构里看,CLAUDE.md 负责规则,MEMORY.md 负责记忆索引,topic files 负责详细内容。

这样去理解,规则、索引和记忆详情各自负责什么,就不会再混成一团。

从源码看“自愈”和写入一致性

把这套机制类比成一种带“自愈”倾向的写入纪律,可以作为理解辅助,但不宜把类比直接当成源码结论。

从目前能看到的实现和解析文档来看,至少可以确定三件事:

  • memory 保存采用“先写主题文件,再更新 MEMORY.md 指针”的两步方式
  • 这种顺序天然更有利于一致性,因为索引最终指向的是已经成功落盘的内容
  • 它的思路更接近“先落数据,再更新索引”,和很多数据库 / 存储系统的一致性设计取向相似

更稳妥的理解是,把它当作一种 可以类比理解 的一致性思路,而不是直接把它等同于“源码明确实现了 WAL 逆向”。

因为源码里我能确认的是:

  • 两步保存存在
  • MEMORY.md 是索引存在
  • 入口大小控制存在
  • 按需检索 topic files 存在

这些都是可以直接从源码和解析文档里站得住的。


十三、两个最常见的陷阱

陷阱一:写得太多,关键规则被淹没

CLAUDE.md 写得太长时,Claude 往往只会抓住其中最显眼、最强约束的那部分,其他内容会逐渐退化成背景噪音。规则越多,真正稳定生效的比例通常越低。

解决方法:

  • 定期删掉已经不再是问题的规则
  • 删掉太细节、没有行为约束力的规则
  • 删掉重复表达

CLAUDE.md 应该是个活跃的 hotlist,不是越来越臃肿的文档。

陷阱二:规则放错层级

用户级放了项目特有规则,Claude 在其他项目里也按这个来。
项目级放了所有项目通用规则,十几个项目各自维护一份重复内容,改一条要改十几个地方。

解决方法还是那一句:

换个项目还成立吗?

成立放用户级,不成立放项目级,一次定好就别再改。


十四、维护节奏

CLAUDE.md 写好之后不是扔着不管,需要定期维护。

第一个月:初始化

/init 生成草稿,花半小时补充:

  • 关键文件保护
  • 错误处理规范
  • API 格式约定
  • 高风险路径说明

这是最重要的一次,做好了后面会省很多事。

每两周:维护

回顾最近 Claude 犯过什么错。

  • 同一个错出现两次以上,加进 CLAUDE.md
  • 已经不构成问题的规则,删掉
  • 写得太空的规则,改具体一点

每季度:清理

把整个文件读一遍:

  • 删冗余
  • 合并重复
  • 简化过细规则

目标是让文件保持高信噪比,而不是越写越长。


十五、检查清单

提交项目级 CLAUDE.md 前过一遍:

  • 规则是纠偏式的,不是文档式的
  • 每条规则能对应到 Claude 的一个具体行为变化
  • 关键文件有明确的保护声明
  • 高风险路径有标注和警告
  • 重要规则附上了“为什么”
  • 用户级和项目级没有混放
  • 文件总长度不超过 200 行
  • 对话里纠正过两次以上的规则已经写进来了

本篇实践任务

任务一: 打开你现有的 CLAUDE.md,把里面每条规则过一遍:它是纠偏式,还是文档式?把文档式的删掉或者改成纠偏式。

任务二: 回想最近一周,你在对话里纠正过 Claude 几次同一个问题?把这些问题整理成具体规则,写进 CLAUDE.md,下次对话观察效果。

任务三: 运行 /memory,看看 Claude 已经自动记住了什么。和你的 CLAUDE.md 对比,有没有重复的内容?有没有记错的内容需要修正?


下篇预告

第 05 篇:Skill 提炼——把重复任务沉淀成可复用模板

CLAUDE.md 管的是全局规则,Skill 管的是任务模板。当同一类任务反复出现,把“怎么做这类任务”浓缩成一个 Skill,下次直接触发。下一篇会讲什么时候沉淀 Skill、怎么写一个真正有效的 Skill,以及 Skill 和自定义命令的边界在哪。


AI Coding 系列持续更新。CLAUDE.md 是规则层,不是项目文档。写法不同,效果天壤之别。

UVM组件故事版 · driver:那个把"指令"翻译成"电信号"的人

UVM组件故事版 · driver:那个把"指令"翻译成"电信号"的人


想象一下打仗。

将军坐在指挥部里,写了一份作战命令:"明天凌晨三点,从A点向B点发起进攻,炮火掩护从C方向过来。"

这份命令写得很清楚,但问题来了——士兵在战壕里,拿到的是一张写满字的纸。他们听不懂"炮火掩护从C方向"是什么意思,他们只知道:什么时候该扣扳机,往哪个方向开枪,火力往哪打。

中间需要一个人,把将军的命令翻译成士兵能执行的具体动作。

这个翻译官,就是driver。


driver到底在干什么

在UVM的世界里,这个场景是这样的:

sequence(将军)→ 发送一个transaction:"我想要你发一个读命令,读地址0x1000"
    ↓
sequencer(传令兵)→ 把transaction传递给driver
    ↓
driver(翻译官)→ 把transaction转换成具体的时序信号,送到DUT的接口上
    ↓
DUT(士兵)→ 收到电信号,执行实际操作

driver做的事情本质上是两件:

1. 接收上层传来的数据对象(transaction)

2. 把这个对象"翻译"成DUT能看懂的时序信号

比如DUT是一个UART模块,它的接口信号长这样:

uart_tx    → 发送数据的信号
baud_rate  → 波特率信号

driver从sequence那里拿到一条"发数据0x55"的消息,driver要做的事情是:

1. 把0x55转成二进制:01010101
2. 按照UART协议加上起始位、校验位、停止位
3. 在对应的时钟沿上,把这些bit一个个送到uart_tx信号上

这中间涉及到很精确的时序控制——早了不行,晚了不行,数据宽度不对不行。driver就是那个必须分毫不差的人。


为什么会有人把driver写错

见过两种典型错误:

第一种:把所有事情都塞给driver

有人觉得driver是唯一能接触DUT的人,所以恨不得把所有的判断逻辑都写在driver里:

driver里写:if (data == 0x55) { 发送A } else if (data == 0xAA) { 发送B }

这是新手常干的事情。driver不是用来判断"做什么"的地方,那是sequence和test的工作。driver的职责是:我收到了一个任务,把它正确地执行下去。

第二种:不懂得和sequencer配合

driver和sequencer之间有一个握手机制:

driver发送req给sequencer:"我准备好了,给我下一个任务"
sequencer把下一个transaction传给driver
driver执行,然后再次请求

这个握手叫seq_item_port,是UVM里最常见的组件间通信方式。很多人写driver的时候跳过了这个理解,导致driver只跑一次就卡住了,或者跑起来之后和sequence的节奏完全对不上。


driver的正确打开方式

一个正确的driver,通常长这样:

class uart_driver extends uvm_driver #(uart_transaction);
  virtual uart_if vif;  // 硬件接口的虚接口
  
  function void build_phase(uvm_phase phase);
    super.build_phase(phase);
    if (!uvm_config_db #(virtual uart_if)::get(this, "", "vif", vif))
      `uvm_fatal("NOVIF", "virtual interface must be set");
  endfunction
  
  // driver的核心:不断从sequencer拿任务,执行它
  task run_phase(uvm_phase phase);
    forever begin
      seq_item_port.get_next_item(req);  // 从sequencer拿任务(阻塞等待)
      drive_transaction(req);             // 执行任务
      seq_item_port.item_done();          // 告诉sequencer任务完成了
    end
  endtask
  
  // 把transaction翻译成时序信号
  virtual protected task drive_transaction(uart_transaction tr);
    vif.rst_n <= 0;  // 假设要reset一下
    #10;
    vif.rst_n <= 1;
    
    // 发送起始位
    vif.uart_tx <= 1'b0;
    #(BIT_PERIOD);
    
    // 发送8位数据
    for (int i = 0; i < 8; i++) begin
      vif.uart_tx <= tr.data[i];
      #(BIT_PERIOD);
    end
    
    // 停止位
    vif.uart_tx <= 1'b1;
    #(BIT_PERIOD);
  endtask
endclass

核心循环就三步:

get_next_item() → drive_transaction() → item_done()

理解了这三步,driver就不难了。


打个比方收尾

sequence是将军,写作战计划。sequencer是传令官,把计划传递给翻译官。driver是翻译官,把将军的命令翻译成士兵能执行的具体动作——几时几分,从哪个方向,开什么枪。

翻译官不需要知道为什么要打这一仗,那是将军的事。翻译官只需要把命令准确无误地执行到位。

driver在UVM验证环境里的角色,就是这样:精确执行,不多不少。


下篇预告:monitor——那个躲在角落里,把一切看见的都记下来的人。




UVM军事系列 · 第一篇:driver——那个把命令翻译成战场语言的人


特种作战行动开始前,指挥部传来一份任务书:

"凌晨0300,A组从东侧突入,B组掩护,C组接应。炮火支援在C组进入位置后30秒启动。"

这份任务书写得清清楚楚,但问题是——前线的士兵拿到的是无线电里嘈杂的指令,他们听不懂"炮火支援在C组进入位置后30秒启动"这种协调语言,他们只知道:什么时候冲,往哪开枪,枪口抬多高。

中间需要一个人,把指挥部的命令翻译成前线士兵能执行的具体动作。

这个人叫通讯兵,也叫传译官

在UVM的世界里,这个角色,叫driver


driver到底在干什么

特种部队的通讯兵,是指挥部和前线之间唯一的翻译通道。

他的工作看起来很简单:收到命令,执行命令。但细看下去,每一步都藏着细节:

mission_order(任务书)→ 作战参谋排序 → 通讯兵接收任务 → 翻译成战场信号 → 士兵执行
    指挥部                      调度官                通讯兵              物理动作

driver做的事情本质上只有两件:

第一,接收上层传来的指令对象(mission order)。

第二,把这个指令翻译成DUT能看懂的时序信号。

比如,DUT是一个通信电台模块,它的物理接口信号长这样:

tx_data[7:0]8位数据线
tx_valid        → 数据有效信号(高电平表示data有效)
tx_ready        → 电台准备好了(握手信号)
clk             → 时钟信号

driver从sequencer那里拿到一条"发送数据0xA5"的消息,driver要做的事情是:

第一步:在tx_valid上拉高一个时钟周期,同时把0xA5放到tx_data上
第二步:等待tx_ready握手信号(告诉士兵"我收到命令了")
第三步:清空tx_valid,一个命令执行完毕

这中间涉及精确的时序控制——早了,电台还没准备好,数据丢了;晚了,士兵的火力窗口错过了。driver就是那个必须分毫不差的人。


为什么会有人把通讯兵派错岗位

见过两种典型的错误:

第一种:让通讯兵去决定打不打。

有人觉得通讯兵是唯一能接触前线的人,所以把"战术判断"也塞给他:

driver里写:if (urgent_mission) { 立即执行 } else { 排队等 }

这是新手常干的事。通讯兵不是战术决策者,那是作战参谋的活。通讯兵的职责是:我收到命令了,准确地翻译和执行。 翻译官不需要知道为什么要打这一仗,那是将军的事。

第二种:通讯兵不听参谋的调度,擅自行动。

driver和sequencer之间有一个握手机制:

通讯兵(driver):"我执行完了,下一个任务是什么?"
作战参谋(sequencer):"收到,给你新的任务。"
通讯兵执行新任务,再问:"执行完了,下一个呢?"
……循环往复

这个握手在UVM里叫seq_item_port,是UVM中最常见的通讯方式。多数人写driver的时候跳过了这个理解,导致通讯兵只跑一轮就停在那里,或者自顾自地一直发,完全不听参谋的节奏。


driver的正确打开方式

一个正确的通讯兵(driver),是这样的:

class comm_driver extends uvm_driver #(mission_item);
  virtual radio_if vif;  // 电台物理接口
  
  function void build_phase(uvm_phase phase);
    super.build_phase(phase);
    // 从配置池里拿到电台接口
    if (!uvm_config_db #(virtual radio_if)::get(this, "", "vif", vif))
      `uvm_fatal("NORADIO", "电台接口未配置,通讯兵没有电台怎么打仗?")
  endfunction
  
  // 通讯兵的日常:等命令 → 执行 → 报告完成 → 等下一个
  task run_phase(uvm_phase phase);
    forever begin
      // 从作战参谋那里拿到任务(阻塞等待,没有任务就待命)
      seq_item_port.get_next_item(req);  
      transmit_mission(req);              // 执行任务
      seq_item_port.item_done();           // 报告参谋:任务完成
    end
  endtask
  
  // 具体的翻译工作:把任务对象变成电台信号
  virtual protected task transmit_mission(mission_item tr);
    // 第一步:把数据放到数据线上,同时拉高有效信号
    @(posedge vif.clk);
    vif.tx_valid <= 1'b1;
    vif.tx_data  <= tr.payload;
    
    // 第二步:等待电台握手确认
    do begin
      @(posedge vif.clk);
    end while (vif.tx_ready == 1'b0);  // 电台没ready就一直等
    
    // 第三步:任务完成,清除有效信号
    @(posedge vif.clk);
    vif.tx_valid <= 1'b0;
  endtask
endclass

核心循环就三步:

get_next_item()  →  transmit_mission()  →  item_done()
  从参谋拿任务       执行翻译              报告完成

理解这三步,通讯兵就不难当了。


打个比方收尾

sequence是将军,写作战命令。sequencer是作战参谋,把命令排序整理。driver是通讯兵,把参谋整理好的命令翻译成前线士兵能执行的具体动作——几点几分,从哪个方向,打什么目标,打几发。

通讯兵不需要知道为什么要打这一仗,那是将军的事。通讯兵只需要把命令准确无误地传达下去。

迟一秒不行,早一秒也不行,信号错了更不行。

这就是driver在UVM验证环境里的角色:精确执行,不多不少。


下篇预告:monitor——那个躲在暗处,把战场上发生的一切都记录下来的人。侦察兵不上前线,但战场上没有人比他更清楚发生了什么。

通过 npm 下载node_modules 某个依赖 ;例如 下载 @rollup/rollup-linux-arm64-gnu

方法1:通过 npm 下载

从官方 npm registry 下载

# 1. 下载 .tgz 包
npm pack @rollup/rollup-linux-arm64-gnu

# 2. 或者直接安装
npm install @rollup/rollup-linux-arm64-gnu

# 3. 指定版本
npm pack @rollup/rollup-linux-arm64-gnu@latest
npm pack @rollup/rollup-linux-arm64-gnu@4.9.5

从淘宝镜像下载(更快)

# 设置淘宝镜像
npm config set registry https://registry.npmmirror.com

# 下载
npm pack @rollup/rollup-linux-arm64-gnu
# 或
curl -L -O https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.5.tgz

方法2:从 GitHub Releases 下载

# Rollup 官方 GitHub Releases
# https://github.com/rollup/rollup/releases

# 下载特定版本
curl -L -O https://github.com/rollup/rollup/releases/download/v4.9.5/rollup-linux-arm64-gnu.tgz

# 或者查看所有 Assets
# 在 Releases 页面找: rollup-linux-arm64-gnu.tgz

方法3:通过 npm 查看可用版本

# 查看所有版本
npm view @rollup/rollup-linux-arm64-gnu versions

# 查看最新版本
npm view @rollup/rollup-linux-arm64-gnu version

# 查看包信息
npm view @rollup/rollup-linux-arm64-gnu

方法4:下载脚本

#!/bin/bash
# download-rollup-arm64.sh

echo "下载 Rollup ARM64 二进制包..."

# 方法1: 从 npm 下载
echo "方法1: 从 npm 下载..."
npm pack @rollup/rollup-linux-arm64-gnu@latest
ls -lh *.tgz

# 方法2: 从淘宝镜像
echo "方法2: 从淘宝镜像下载..."
curl -L -O https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.5.tgz

# 验证下载
echo "验证文件..."
tar -tzf rollup-linux-arm64-gnu-*.tgz 2>/dev/null | head -10

方法5:如果无法下载,安装可选依赖

在你的 package.json中添加:

{
  "optionalDependencies": {
    "@rollup/rollup-linux-arm64-gnu": "^4.9.5"
  }
}

然后运行:

npm install
# 这会尝试安装 ARM64 的二进制包

下载链接(直接访问)

在浏览器中直接访问:

npm 官方

https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.5.tgz

淘宝镜像

https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.5.tgz

GitHub

https://github.com/rollup/rollup/releases/download/v4.9.5/rollup-linux-arm64-gnu.tgz

OpenClaw 跟病毒的区别是什么?

节日期间在家办公,我坐在书房的电脑前,盯着满屏飘红的终端😖

webpack_error_terminal_style_match.png

我没有中勒索病毒,也没有被黑客攻击。我只是在之前,极其手欠地给跑在后台的 OpenClaw 下达了一句简单的语音指令:帮我把这个老项目里的无用 npm 依赖清理一下,顺便跑通本地编译。

openclaw_feishu_chat_conversation.png

然后我就去客厅看电视了。

等我两个小时后回来,发现风扇狂转。打开终端一看,这玩意儿不仅把我的 package-lock.json 给删了,还因为有个老旧的 Sass 模块死活装不上,它自己去网上搜了个不知道谁写的 Python 脚本跑了一遍,顺手把我的全局 Node 环境降级到了两年前的版本,最后还在根目录下给我留了几十个不知名的临时编译文件🤬🤬。

看着这片惨状,我脑子里突然冒出一个极其荒诞的问题: 一个拥有系统最高执行权限的 OpenClaw,跟一个木马病毒的区别到底是什么?

如果仔细推敲,你会发现这两者的行为轨迹惊人地相似,甚至可以说,前者带来的工程灾难往往更具欺骗性。


在搞破坏?

以前我们在电脑上跑个脚本,报错了就停在那,等你来排查,过程相对可控的。

但现在的 OpenClaw 是个拥有极高自主性的 Agent。它最大的卖点是遇到问题会自动尝试解决。这在写写单纯的文本时是个优点,但在复杂的现代前端工程里,这就是个彻头彻尾的灾难🤔。

当一个病毒遇到权限阻断时,它会疯狂尝试提权、扫描端口、注入进程。 那 OpenClaw 遇到前端编译报错时会干嘛?

它会像一个极其鲁莽的瞎子:

  • 它发现 pnpm install 报错了,它不会去思考是不是内网镜像源挂了,而是自作主张把它换成 npm,瞬间摧毁你精心维护的 Monorepo 幽灵依赖机制(symlink)。
  • 它发现有个类型找不到,它不会去查 .d.ts 声明,而是极其粗暴地去改你 node_modules 里的源码,或者给你全剧加上 @ts-ignore
  • 如果遇到文件死锁,它甚至敢在终端里直接替你敲下 rm -rf

病毒搞破坏是为了勒索你,而 OpenClaw 把你的系统搞崩溃,仅仅是因为它想完成你那句帮我跑通编译。

后台静默执行

做了 9 年研发,我看过无数次因为一行配置写错导致的线上 P0 级事故。所以越是资深的工程师,越在乎执行边界。

我们为什么需要 Git?为什么需要 Code Review?为什么 CI/CD 要分发不同的环境权限? 因为我们要清楚地知道,谁在什么时候,动了哪行代码,引发了什么后果。

但 OpenClaw 打破了这个铁律。它是一个跑在你电脑或者服务器上的巨大黑盒。

当你让它接管你的工作流时,你根本不知道它为了完成目标,在后台下载了多少个包含潜在风险的三方库。你也不知道它在调用工具链的时候,有没有把你的环境变量、甚至包含数据库密码的 .env 文件当成上下文,明文发送给了大模型的 API 服务器。

传统病毒窃取你的隐私,至少还会偷偷摸摸加个壳。而 OpenClaw 是你亲手把服务器的 Root 权限双手奉上🙌。


一段真实的案例

为了让大家更直观地感受这种病毒式操作。我复盘了一下昨天它为了修复一个构建错误,所做出的决策链路。

这是一个典型的高级前端遇到问题时的处理逻辑:

1. 发现 Webpack 构建报 chunk error 错误

screenshot-20260407-151125.png

2. 检查报错日志,发现是内存溢出 (OOM)

screenshot-20260407-151038.png

3.修改启动脚本,增加 Node 内存上限,完事🤷‍♂️

export NODE_OPTIONS="--max-old-space-size=4096"
npm run build

而 OpenClaw 在面对同一个报错时,它的终端执行记录:

# 1. 发现构建报错
npm run build 

# 2. 它看不懂 OOM,以为是依赖问题,执行了极其致命的操作
rm -rf node_modules
rm package-lock.json
npm install --legacy-peer-deps

# 3. 依赖彻底乱套,报了更多编译错误。它决定更换打包工具
npm install -g vite
# 强行在原本的 Webpack 项目里塞入一段残缺的 vite.config.js

# 4. 依然跑不通,它甚至去改了操作系统的环境😖😖😖
sudo chmod -R 777 ./ 

看到最后那个 chmod 777 的时候,我后背都在冒冷汗😢。 它为了解决一个权限微小的编译告警,直接把你整个项目的安全底裤都给扒了。这不是病毒是什么?


我想泼一盆冷水🫡

现在全网都在狂欢,各大社区都在比拼谁的 Agent 更聪明,谁能让电脑完全自己写代码、自己部署。

很多人沉迷于这种看着终端自己跳动代码的爽感中,觉得这就是未来。

但我不得不泼一盆冷水。在工程领域,不可控的自动化,比纯手工还要危险一万倍😒。

不管是 OpenClaw 还是未来更牛的智能体,只要它不具备真实世界的工程常识和后果承担能力(到目前为止都不可能为你背锅!!!),它就是一个披着 AI 外衣的高危病毒。

咱们在敲下回车之前,脑子里想的是:这会影响线上吗?会引发竞态问题吗?接手的同事能看懂吗? 而 Agent 脑子里只有计算概率:根据统计学,下达这个指令,满足用户当前 prompt 的概率哪个最大?它不在乎你的硬盘会不会被占满,不在乎你的生产环境会不会被污染。


所以,咱们这些在一线干活的兄弟们,清醒一点。

工具终究是工具,它可以帮你查 API,可以帮你写正则,可以帮你生成模版代码。但千万别把系统的控制权和架构的决策权,交给一个随时可能暴雷的 AI Agent

把危险关在沙盒里,让执行处于监控下。如果你做不到这一点,那你电脑里跑着的那个每天对你嘘寒问暖的 OpenClaw,真的比熊猫烧香还要可怕的。🤔

对此大家怎么看?

Suggestion.gif

移动端卡片边框怎么做高级?我用 CSS 实现了设计师的刁钻要求

移动端卡片边框怎么做高级?我用 CSS 实现了设计师的刁钻要求

一个让产品经理和设计师都满意的卡片边框方案

📖 前言

上周设计突然甩过来一张图,问我能不能不切图做出这种效果?

image.png

我蒙了一下第一反应感觉可以,无非就是常规的伪类+渐变。但尝试了一下发现两个致命问题:

1、border-image支持渐变但不支持每条边自定义设置;

2、使用伪类可以解决线的问题但是不能解决圆角问题;

忙乎半天又问了问ai感觉还是实现起来不容易,但随后产品过来又是那老一套。拿着别人家的产品看人家这个如何好看,如何优雅,巴拉巴拉。大有一种:

别人能做,你做不了。

这是不能接受的,于是又潜心研究了下,有了最后的效果。

🎯 需求拆解

先梳理一下具体需求:

需求 描述
位置 卡片左下角 L 形(左边 + 底边)
渐变 左下角颜色最深,向两端渐淡
粗细 视觉上 1px
长度 左边和底边长度大致相等
圆角 适配卡片 20px 圆角
性能 纯 CSS,无图片,无 SVG

看起来简单,做起来全是坑。

🧪 方案探索

方案一:两个伪元素分别画线

最直观的想法:用 ::before 画底部线,::after 画左边线。

scss

.card {
  &::before {
    // 底部线
    background: linear-gradient(90deg, gold, transparent);
  }
  &::after {
    // 左边线
    background: linear-gradient(0deg, gold, transparent);
  }
}

问题:两条线在圆角处有接缝,怎么都对不齐。调整了半天,还是能看到明显的拼接痕迹。

结论:放弃,圆角处无法完美衔接。


方案二:SVG 路径描边

SVG 可以精确控制路径和圆角,效果确实完美。

问题

  • 需要额外 HTML 结构
  • 移动端多一个网络请求或内联代码
  • 响应式适配需要额外处理

结论:能用,但不够优雅,性能也不够极致。


方案三:border-image + 渐变

scss

border-image: radial-gradient(circle at bottom left, gold, transparent) 1;

问题border-image 会覆盖四边,无法只控制左下角。

结论:放弃。


方案四:radial-gradient + mask(最终方案)

经过多次尝试,我发现径向渐变的圆心在左下角时,渐变会自然地向左和向上扩散,形成完美的 L 形。

配合 mask 组合,可以精确控制只显示边框区域,而不是整个渐变圆。

完美解决所有问题!

💻 最终代码

以下是基于vue2的一个组件CornerGradientCard,开箱即用。

但注意基于他的点击事件要使用click.native!!!

<template>
   <div
        :class="`gradient-wrapper ${type} `"
        :style="wrapperStyle"
   >
        <div
            class="gradient-wrapper__content"
            :style="{ borderRadius: radiusRem }"
        >
            <slot></slot>
        </div>
    </div>
  </template>

  <script>
  /** 与 postcss.config.js 中非 vant 资源的 rootValue(75) 一致,设计稿 px → rem */
  const POSTCSS_ROOT_VALUE = 75
  function pxToRem(px) {
    const n = Number(px)
    if (Number.isNaN(n)) return '0rem'
    return `${parseFloat((n / POSTCSS_ROOT_VALUE).toFixed(10))}rem`
  }

  export default {
    props: {
      type: {
        default: '',
        type: String
      },
      radius: {
        default: 12,
        type: Number
      },
      marginBottom: {
        default: 14,
        type: Number
      }
    },
    computed: {
      radiusRem() {
        return pxToRem(this.radius)
      },
      wrapperStyle() {
        const r = this.radiusRem
        return {
          borderRadius: r,
          marginBottom: pxToRem(this.marginBottom),
          '--corner-radius': r
        }
      }
    }
  }
  </script>

  <style lang="scss" scoped>
    $gradient-first-percent: 4%;    // 第一个实色节点百分比
    $gradient-second-percent: 10%;  // 第二个半透明节点百分比
    $gradient-transparent-percent: 30%; // 透明节点百分比

    .gradient-wrapper {
        width: 100%;
        position: relative;
        padding: 0 0 1px 1px;
        box-sizing: border-box;
        background-color: #fff;
        overflow: hidden;
        -webkit-backface-visibility: hidden;
        backface-visibility: hidden;

        &__content {
            width: 100%;
            position: relative;
            z-index: 3;
            margin: 0 0 1px 1px;
            box-sizing: border-box;
            overflow: hidden;
            background-color: transparent;
        }

        // 渐变边框线(核心)
        &::after {
            content: '';
            position: absolute;
            z-index: 2;
            bottom: 0;
            left: 0;
            width: 100%;
            height: 100%;
            border-radius: var(--corner-radius);
            pointer-events: none;
            mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
            mask-composite: exclude;
            -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
            -webkit-mask-composite: xor;
            padding: 1px;
        }

        // 渐变底色(光晕效果)
        &::before {
            content: '';
            position: absolute;
            z-index: 1;
            bottom: 1px;
            left: 1px;
            width: 143px;
            height: 73px;
            border-radius: var(--corner-radius);
            filter: blur(10px);
            pointer-events: none;
        }

        &.WX {
            &::after {
                background: radial-gradient(
                circle at bottom left,
                #B6E2C8  $gradient-first-percent,
                #DFF7EA $gradient-second-percent,
                transparent $gradient-transparent-percent
                );
            }
            &::before {
                background: radial-gradient( 83% 83% at 31% 52%, #F0FBF5 0%, rgba(239,255,246,0) 100%);
            }
        }
}
  </style>

 <CornerGradientCard
    v-for="(item, index) in infoData"
    :key="item.id"
    :id="item.id"
    :type="item.type"
    @click.native="clickItem(item)"
>
    <!-- 卡片内容 -->
</CornerGradientCard>
    

🎨 参数调节指南

参数 位置 作用 移动端建议
padding: 1px .wrapper 边框粗细 保持 1px
4% / 10% / 30% 径向渐变 边框长度 根据卡片大小调整
blur(10px) 光晕 柔和度 移动端 8-12px 较佳
border-radius 全局 圆角 与设计稿保持一致

当然,基于此样式还可以可发出各种变种,例如将渐变等放到常规的右上角,替代常规的卡片标签展示样式。

如果这篇文章对你有帮助,烦请动动发财的小手点个赞~

这个 GitHub 项目很有意思啊,解了死磕30 年的前端难题。

最近前端圈被一个叫 Pretext 的开源项目刷屏了。

它的作者是前 React 核心开发者,之前做过 react-motion 那个 21.9K Star 的项目。

这次他搞了个新东西,发布 3 天 Star 数就超过了 react-motion。

推文 1600 万浏览、6.4 万赞,X 上相关讨论帖子超 6.8 万条。

现在热度开始蔓延到国内了。

我花时间研究了一下,确实挺顶的。

01、开源项目简介

Pretext 是一个纯 JavaScript/TypeScript 的多行文本测量与布局库。

说直白一点,它解决的问题是:

在不碰 DOM 的情况下,精确算出一段文字在给定宽度下会有多高。

先来看看效果,下面这个视频是我使用和这个库做的网页效果。

只能说,非常丝滑。

开源地址:https://github.com/chenglou/pretext

02、牛在哪里

多行文本灵活布局库,听起来好像不是什么大事。

但这个问题在 Web 开发里已经存在 30 年了。

之前想测文字高度,只能靠 getBoundingClientRect 或者 offsetHeight。

但这些操作会强制浏览器重新计算整个页面布局,代价非常大。

在虚拟滚动列表、聊天界面、瀑布流这种需要频繁测量的场景里,性能直接拉胯。

Pretext 目前已经斩获 3.4 万+ Star,体积才 15KB。

这个项目能这么火不是没原因的,几个核心设计确实有意思。

两阶段架构,性能炸裂

Pretext 把文本测量拆成了两步:

第一步是 prepare(text, font), 负责分词、处理双向文本、用 Canvas measureText 测量每个片段的宽度并缓存。这一步相对重一些,500 段文本大概 19ms。

第二步是 layout(prepared, maxWidth, lineHeight), 基于缓存的宽度做纯算术运算,算换行后的总高度。这一步极轻,500 段文本才 0.09ms。

重点来了:窗口大小变化的时候,只需要重新跑第二步就行。

prepare 的缓存还在,直接用算术就能算出新高度。

比传统 DOM 测量快了 200 倍以上。

全语言支持,准确率 100%

这个库不是只测英文字母的。中文、日文、韩文啥的全都能处理。

而且在 Chrome、Safari、Firefox 三个浏览器上的准确率都是 7680/7680,也就是 100%。

做全语言文本渲染的人都知道这有多难。

所以一经发布,Star 数量就蹭蹭的涨。

03、两种使用场景

一个是预测文本高度。

不需要碰 DOM 就能知道文字多高,虚拟滚动列表、瀑布流布局、聊天气泡 shrink-wrap、防止布局偏移,这些场景都能用上。

二是手动逐行布局。

可以拿到每行文字的精确坐标和内容,然后渲染到 Canvas、SVG、WebGL 上,甚至可以实现文字绕障碍物流动这种高级效果。

你最近刷到的流体烟雾 ASCII 艺术、摄像头追踪人脸做文字避让、物理球碰撞改变文字排列、Mario 游戏 ASCII 文字版,各种花活都有。

这是一个 AI 辅助开发的标杆案例。

开发者 Cheng Lou 在开发过程中大量使用了 Claude Code 和 Codex,让 AI 在几十种容器宽度下对比浏览器的真实渲染结果,然后自动修正差异。

Hacker News 上很多人专门聊这一点,说这是 AI 编程的完美范例。

Simon Willison 也专门写博文推荐,特别称赞了它的测试方法,用整本了不起的盖茨比做跨浏览器对比。

04、部署与使用

安装很简单:

npm install @chenglou/pretext

或者用 bun:

bun add @chenglou/pretext

基础用法:预测文本高度

import { prepare, layout } from '@chenglou/pretext'

const prepared = prepare('AGI 春天到了.             🚀', '16px Inter')
const { height, lineCount } = layout(prepared, textWidth, 20)
// height 就是精确的像素高度,全程没碰 DOM

高级用法:手动逐行布局

import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'

const prepared = prepareWithSegments('一些文本', '18px "Helvetica Neue"')
const { lines } = layoutWithLines(prepared, 320, 26)
for (let i = 0; i < lines.length; i++) {
  ctx.fillText(lines[i].text, 0, i * 26)
}

本地跑 Demo

git clone https://github.com/chenglou/pretext.git
cd pretext
bun install
bun start

然后浏览器打开 http://127.0.0.1:3000/demos 就能看到了。

这个不碰 DOM 就能预测文本高度的能力。

虚拟滚动、瀑布流、聊天 UI,这些天天在用的东西,以前要么牺牲性能去量,要么用估算值凑合。

现在有个 15KB、零依赖、100% 准确率的方案摆在这里,拿来就能用。

你的JS代码总在半夜崩溃?TypeScript来“上保险”了

你有没有经历过:凌晨三点,线上报“Cannot read property 'name' of undefined”,你爬起来一看,原来是后端返回的数据少了一层。如果JS有“类型检查”,这种悲剧根本不会发生。今天我们就来认识TypeScript——给JavaScript买了一份“意外险”。

前言

JavaScript就像个自由散漫的天才:你给它一个字符串,它当数字用;你忘记传参数,它给你个undefined;你访问对象不存在的属性,它笑眯眯地说“没事,我给你undefined”。这种灵活在小型项目里很爽,但项目一大,就成了噩梦。

TypeScript(简称TS)就是来解决这个问题的。它给JS加上了类型系统,在代码运行之前就帮你检查类型错误。就像给代码装了安检门,不规范的写法根本过不去。

一、TypeScript是啥?JS的“严格模式”Pro Max

TypeScript是微软开发的开源语言,它是JavaScript的超集。意思是:所有合法的JS代码,在TS里也合法。TS只是给JS加了类型注解和一些新特性,然后编译成干净的JS。

// JS写法
function greet(name) {
  return 'Hello, ' + name;
}

// TS写法(加了类型)
function greet(name: string): string {
  return 'Hello, ' + name;
}

greet(123); // ❌ 报错:参数不能是数字

你看,TS在编译阶段就抓住了错误,不用等到运行时。

二、为什么要用TS?三个字:稳、爽、香

  • :类型错误在写代码时就暴露,而不是在用户手里炸。
  • :编辑器智能提示飞起,不用记方法名、参数顺序。
  • :代码即文档,看函数签名就知道怎么用。

据统计,使用TS的项目,早期Bug能减少15%~25%。对于中大型项目,TS几乎是标配。

三、基础类型:TS的“基本词汇”

TS支持JS的所有类型,还加了一些新的。

1. 原始类型

let name: string = '张三';
let age: number = 18;
let isStudent: boolean = true;
let nothing: null = null;
let notDefined: undefined = undefined;
let big: bigint = 100n;
let sym: symbol = Symbol('id');

2. 数组

let list1: number[] = [1, 2, 3];
let list2: Array<string> = ['a', 'b'];  // 泛型写法

3. 元组(固定长度和类型的数组)

let person: [string, number] = ['张三', 18];
person[0] = '李四';  // OK
person[1] = '20';   // ❌ 报错,第二个元素必须是数字

4. 枚举(给一组数字起名字)

enum Color { Red, Green, Blue }
let c: Color = Color.Red;
console.log(c); // 0(默认从0开始)

// 自定义值
enum Status { Success = 200, NotFound = 404 }

5. Any(万能类型,慎用)

let notSure: any = 4;
notSure = '字符串';  // OK
notSure = true;      // OK

any会关闭类型检查,相当于回到JS。尽量少用,除非你确定这个值无法预知类型。

6. Unknown(安全的Any)

let value: unknown = 'hello';
value = 123;  // OK
// console.log(value.toUpperCase()); // ❌ 报错,unknown不能直接调用方法
if (typeof value === 'string') {
  console.log(value.toUpperCase()); // 类型收窄后可用
}

unknownany安全,因为使用前必须先判断类型。

7. Void(没有返回值)

function warnUser(): void {
  console.log('警告');
}
// 变量声明为void类型只能赋值为null或undefined(strict模式下只能undefined)

8. Never(永远不会发生的类型)

function error(message: string): never {
  throw new Error(message);
}

function infiniteLoop(): never {
  while (true) {}
}

四、类型注解:给变量贴标签

TS的核心就是类型注解:在变量、函数参数、返回值后面加上: 类型

let myName: string = '张三';
function add(a: number, b: number): number {
  return a + b;
}

但TS很智能,很多时候可以类型推断,不用显式写:

let age = 18; // TS自动推断为number
age = '18';   // ❌ 报错

五、接口(Interface):定义对象的形状

接口是TS里最常用的功能,用来描述对象的结构。

interface Person {
  name: string;
  age: number;
  email?: string;  // 可选属性
  readonly id: number; // 只读属性
}

const zhangsan: Person = {
  name: '张三',
  age: 18,
  id: 1
};
zhangsan.id = 2; // ❌ 报错,只读属性不能改

接口还可以描述函数类型:

interface AddFunc {
  (a: number, b: number): number;
}
const add: AddFunc = (x, y) => x + y;

六、类型别名(Type):给类型起外号

类型别名和接口很像,但能表示联合类型、元组等更复杂的类型。

type ID = string | number;  // 联合类型
type Point = [number, number]; // 元组
type Callback = (data: string) => void;

let userId: ID = 123;
userId = 'abc';

接口 vs 类型别名

  • 接口可以扩展(extends),类型别名用交叉(&)。
  • 接口可以重复定义自动合并,类型别名不能重复。
  • 推荐优先用接口描述对象,用类型别名描述联合、元组等。

七、实战:用TS写一个简单的函数

// 需求:格式化用户信息
interface User {
  name: string;
  age: number;
  address?: string;
}

function formatUser(user: User, withAddress: boolean = false): string {
  let base = `${user.name}, ${user.age}岁`;
  if (withAddress && user.address) {
    base += `, 地址:${user.address}`;
  }
  return base;
}

const u: User = { name: '李四', age: 20, address: '北京' };
console.log(formatUser(u, true)); // "李四, 20岁, 地址:北京"

如果你在编辑器里打formatUser(,它会提示参数类型和返回值类型,爽不爽?

八、常见坑点与建议

  1. 不要滥用any:any越多,TS的价值越低。实在不知道类型,先写unknown
  2. 严格模式:开启strict: true(tsconfig.json),让TS更严格地检查。
  3. 第三方库:大多数库都有@types/xxx类型定义,安装后就能获得智能提示。
  4. 编译后的JS:TS只负责编译时检查,运行时还是JS,类型信息会被擦除。

九、总结:TS不是敌人,是保镖

  • 给JS加上类型,提前发现错误。
  • 基础类型、接口、类型别名是核心工具。
  • 用好类型推断,少写冗余注解。
  • 逐步迁移老项目,从.js改成.ts,开启allowJs: true

学TS并不难,你只需要把“写JS时的心理预期”明确写出来。明天我们继续深入TypeScript,聊聊高级类型——泛型、联合类型、交叉类型、类型保护,让你写出更灵活更安全的代码。

如果你觉得今天的“保险课”够实在,点个赞让更多人看到。我们明天见!

还原设计稿生成前端代码

VScode插件【GitHub Copilot】+ Figma MCP还原设计稿生成前端代码

Cursor+Figma MCP的教程已经很多了,由于我用的vscode中的GitHub Copilot ,研究了一下直接在vscode里利用GitHub Copilot接入Figma MCP进行设计稿还原代码,大获成功,记录~

step1.方式1:让AI给你配置MCP

在vscode中打开项目,呼出github copilot 对话框,模式选择Agent,模型我用的是GPT-5.4,输入对话内容:

https://github.com/GLips/Figma-Context-MCP 如何配置能让你在vscode里使用这个mcp

之后跟着提示狂点下一步即可完成配置,如果有什么需要装的vscode插件它会自动帮你装,甚至自动生成了配置说明文档。

Figma-Context-MCP是一个为AI编程工具(如 Cursor, Windsurf)和大型语言模型(LLM)搭建的“桥梁”或“通用适配器”。它能自动读取Figma设计文件里的布局、样式和层级信息,转换成AI容易理解的结构化数据,让AI在写代码时能真正“看懂”设计稿,从而大幅提升从设计到代码的转换效率和还原度

mcp.json

{
"servers": {
"framelinkFigma": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"figma-developer-mcp",
"--stdio"
],
"env": {
"FIGMA_API_KEY": "${input:figma-token}"
}
},
"com.figma.mcp/mcp": {
"type": "http",
"url": "https://mcp.figma.com/mcp",
"gallery": "https://api.mcp.github.com",
"version": "1.0.3"
}
},
"inputs": [
{
"type": "promptString",
"id": "figma-token",
"description": "Figma Personal Access Token",
"password": true
}
]
}

step1.方式2:当然也可以手动安装插件完成配置

  • 在VS Code Copilot中设置对应的MCP配置

首先确保MCP发现的功能是开着的,在VS Code中打开设置(Ctrl+,或者Cmd+,), 输入chat.mcp确认Discovery是Enabled.

image.png

  • 在extentions中输入@mcp figma可以找到官方对于figma的访问支持

image.png

注意其中的Install是安装到VSCode 的整体目录下, Install in Workspace是安装到当前项目下,可以根据您的情况选择,建议选择Install in Workspace

选择Install in Workspace之后可以在当前项目.vscode/mcp.json下看到具体的Figma配置(也可以忽略上述的步骤,自己直接新建mcp.json文件然后输入详细的配置)

// 默认生成的,当前不可用
"com.figma.mcp/mcp": {
    "type": "http",
    "url": "https://mcp.figma.com/mcp",
    "gallery": "https://api.mcp.github.com",
    "version": "1.0.3"
}

注意这个是默认生成的配置,截止笔者发稿时,这个配置不可用,会报错。将协议和url改为sse依然不可用。需要改为如下的stdio的配置

//改为stdio格式,当前可用
"figma": {
    "command": "npx",
    "args": [
        "-y",
        "figma-developer-mcp",
        "--stdio"
    ],
    env": {
        "FIGMA_API_KEY": "您的Figma API Token"
    },
    "type": "stdio"
},

配置之后就可以在工具中具体看到Figma了

step2:替换自己的figma密钥

打开Figma的网页点击左上角自己的头像 -> settings -> Security -> Generate new token 设置路径可能会有变化,自己到处点点找到Generate new token就对了 找到点击之后会出现下面这个弹窗,随便起个名字比如mcp,然后把下面的权限列表一个个打开选择读或写,要不然默认是全部No access的。 注意默认是30天过期,30天后需要建一个新的才能继续用。

image.png

都选完之后点右下角的generate token之后会生成一个密钥,这是你唯一一次复制它的机会,没复制好就关掉窗口了就只能重新建了。把这个密钥复制到mcp.json文件中–figma-api-key=后面。

保存后,VS Code 一般会提示你启动或信任这个 MCP 服务。

如果没有自动启动,用命令面板执行(command + shift + p):

  • MCP: List Servers
  • 选中 framelinkFigma
  • Start 或 Restart
  • 如果提示 Trust,确认信任

第一次启动这个MCP服务,会提示你输入token,将刚保存好的figma的token复制到相应位置即可。

配置成功后,你在 Copilot Chat 里直接发:

  • 实现这个 Figma 链接对应的页面
  • 后面附上 Figma 的 frame 或 node 链接 如果 MCP 正常,我就能调用它读取设计数据,而不是只能看截图。

step3: 如何使用

在Figma设计图上选中你要的部分图层,右键后点击Copy link to selection

image.png

之后就可以把链接贴到对话框了,先来测试一下配置是否成功了,确保模式是Agent,提问:

https://www.figma.com/design/GJZhGih0VsGbpevJGkJQ9Z/E-commerce-UI—Figma-Ecommerce-UI-Kit–Demo-Version—Community-?node-id=2804-7985&m=dev 现在你能读到这个设计图了吗

image.png

出现这样的弹窗说明Agent在尝试链接MCP server了,点继续(也可以点击右边的箭头在当前会话中允许操作就不用每次都手动点了),过一会儿可以看到它的描述,说明设计图被读到了,我们的配置生效了。

读取成功后,可以让他写代码了:

请根据这个设计图在我的微信小程序里生成商品卡片组件的代码,注意微信小程序中2rpx=1px,要完全还原设计图的UI,再建一个测试页面展示这个组件的调用效果,可以参考微信小程序官方文档https://developers.weixin.qq.com/miniprogram/dev/api/

对比设计图,指出哪里还原度不够,让它进一步优化,客客气气的。

看上去有一些UI细节不够还原,比如卡片的内边距,还有按钮的布局,请你再仔细检查一下。
商品图片上的三个icon按钮应该是水平居中的,learn more按钮应该是水平居左的。另外你能不能直接下载设计图里的icon为svg来使用,这样更还原。

面试题里的 Custom Hook 思维:从三道题总结「异步状态管理」通用模式

最近在准备面试,翻到几道关于 Custom Hook 的模拟题。表面上看各不相同——轮询、筛选、防抖搜索——但仔细分析之后,发现它们背后有一套共同的思维框架。这篇文章是我整理这套框架的笔记,希望对同样在备战面试的你有参考价值。


三道题,三个场景

先简单描述一下这三道题在考什么:

  • useRideTracking:行程进行中轮询状态,每 5s 请求一次,页面隐藏时暂停,连续失败 3 次停止
  • useExpenseFilter:报表筛选 Hook,多维联动筛选,需要 useMemo 优化
  • useEmployeeSearch:员工搜索,防抖 500ms + AbortController 取消请求

三个场景,但核心都指向同一个问题:如何在 Hook 里正确管理「副作用」和「派生状态」?


归纳出的通用思维框架

在我看来,一个合格的 Custom Hook 需要从四个维度去思考:

1. 状态层(State)     ── 管什么数据?
2. 副作用层(Effect)  ── 什么时候做什么?
3. 清理层(Cleanup)   ── 离开时怎么收尾?
4. 优化层(Optimization) ── 怎么不做多余的工作?

下面逐层展开,结合题目来理解。


第一层:状态层 — 先想清楚「管什么」

拿到题目,第一步应该问自己:这个 Hook 需要对外暴露哪些状态?

这三道题都有一个共同的「三元组」:

// 几乎所有「异步请求型」Hook 的状态骨架
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);

除了这个骨架,每道题还有「额外状态」:

  • useRideTracking:需要 failCount(连续失败次数),但这是内部状态,不对外暴露
  • useExpenseFilter:需要 filters 对象,并对外暴露 setFilter / resetFilters
  • useEmployeeSearch:需要 keyword,并对外暴露 setKeyword

一个实用技巧:区分「对外暴露」和「内部管理」的状态。对外的是接口契约,对内的是实现细节。面试中如果能主动说出这种区分,往往加分。

// useRideTracking 的状态设计示意
// 对外:{ status, loading, error }
// 对内:failCountRef(用 ref 而非 state,因为改变它不需要触发重渲染)
const failCountRef = useRef(0);

第二层:副作用层 — 明确「触发时机」

useEffect 的依赖数组,本质上是在描述「什么变化了我才需要重新执行」。

模式 A:挂载即执行 + 定时触发(useRideTracking)

// 环境:React 18+
// 场景:行程状态轮询

function useRideTracking(rideId) {
  const [status, setStatus] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const failCountRef = useRef(0);
  const timerRef = useRef(null);
  const stoppedRef = useRef(false);

  const fetchStatus = async () => {
    if (stoppedRef.current) return;

    setLoading(true);
    try {
      const res = await fetch(`/api/rides/${rideId}`);
      if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
      const data = await res.json();
      setStatus(data.status);
      setError(null);
      // success: reset fail counter
      failCountRef.current = 0;
    } catch (err) {
      failCountRef.current += 1;
      if (failCountRef.current >= 3) {
        stoppedRef.current = true;
        setError(err);
        setLoading(false);
        clearInterval(timerRef.current);
        return;
      }
    } finally {
      if (!stoppedRef.current) setLoading(false);
    }
  };

  useEffect(() => {
    // fetch immediately on mount
    fetchStatus();
    timerRef.current = setInterval(fetchStatus, 5000);

    const handleVisibility = () => {
      if (document.visibilityState === 'hidden') {
        clearInterval(timerRef.current);
      } else {
        fetchStatus(); // refetch immediately on visible
        timerRef.current = setInterval(fetchStatus, 5000);
      }
    };

    document.addEventListener('visibilitychange', handleVisibility);

    return () => {
      clearInterval(timerRef.current);
      document.removeEventListener('visibilitychange', handleVisibility);
      stoppedRef.current = true;
    };
  }, [rideId]);

  return { status, loading, error };
}

这道题的难点有两个:

  1. visibilitychange 事件——很多人第一反应想不到,但这是真实产品里节省资源的常见做法
  2. 连续失败计数用 ref 还是 state——改变它不需要重渲染,用 ref 更合适

模式 B:受控输入 + 派生计算(useExpenseFilter)

// 环境:React
// 场景:多维度联动筛选

const emptyFilter = {
  departments: [],
  dateRange: null,
  statuses: [],
  amountRange: null,
};

function useExpenseFilter(data) {
  const [filters, setFilters] = useState({ ...emptyFilter });

  const setFilter = useCallback((key, value) => {
    setFilters((prev) => ({ ...prev, [key]: value }));
  }, []);

  const resetFilters = useCallback(() => {
    setFilters({ ...emptyFilter });
  }, []);

  const filteredData = useMemo(() => {
    return data.filter((trip) => {
      if (filters.departments.length && !filters.departments.includes(trip.department)) return false;
      if (filters.statuses.length && !filters.statuses.includes(trip.status)) return false;
      if (filters.amountRange) {
        const [min, max] = filters.amountRange;
        if (trip.amount < min || trip.amount > max) return false;
      }
      if (filters.dateRange) {
        const [start, end] = filters.dateRange;
        if (trip.date < start || trip.date > end) return false;
      }
      return true;
    });
  }, [data, filters]);

  return { filters, setFilter, filteredData, resetFilters };
}

这道题相对直接,但有两个容易踩的坑:

  1. resetFilters 里要用 { ...emptyFilter } 而非直接传引用——否则 emptyFilter 对象可能被意外修改
  2. setFilter 要用 useCallback 包裹——否则每次渲染都会生成新函数,可能导致消费方的 memo 失效

模式 C:防抖 + 请求竞态处理(useEmployeeSearch)

// 环境:React
// 场景:带防抖的搜索请求,需要处理竞态

function useEmployeeSearch() {
  const [keyword, setKeyword] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const abortControllerRef = useRef(null);

  useEffect(() => {
    const trimmed = keyword.trim();

    // empty keyword: reset state immediately
    if (!trimmed) {
      setResults([]);
      setError(null);
      setLoading(false);
      return;
    }

    const timer = setTimeout(async () => {
      // abort previous pending request
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }

      const controller = new AbortController();
      abortControllerRef.current = controller;

      setLoading(true);
      setError(null);

      try {
        const res = await fetch(
          `/api/employees/search?q=${encodeURIComponent(trimmed)}`,
          { signal: controller.signal }
        );
        if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
        const data = await res.json();
        setResults(data);
      } catch (err) {
        if (err.name === 'AbortError') return; // ignore abort errors
        setError(err);
      } finally {
        setLoading(false);
      }
    }, 500);

    return () => {
      clearTimeout(timer);
      // abort on cleanup (keyword changed or unmount)
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [keyword]);

  return { keyword, setKeyword, results, loading, error };
}

这道题的核心考点是「竞态条件」(race condition):用户快速输入时,后发出的请求可能比先发出的先返回,导致界面显示旧数据。AbortController 是解决这个问题的标准方案。


第三层:清理层 — 「离开时」的责任感

这是很多初学者写 Hook 时最容易忽略的部分,但在面试中往往是区分「会用」和「理解」的分水岭。

一个简单的清理检查清单:

□ 定时器(setInterval / setTimeout)需要 clearInterval / clearTimeout
□ 事件监听器需要 removeEventListener
□ 进行中的网络请求需要 AbortController.abort()
□ 组件卸载后不应再 setState(会产生 warning)

上面三道题都涉及清理,总结一下各自的清理策略:

Hook 需要清理的东西
useRideTracking clearInterval + removeEventListener + 标记 stoppedRef 防止 setState
useExpenseFilter 无(纯状态计算,无副作用)
useEmployeeSearch clearTimeout + AbortController.abort()

第四层:优化层 — 「不做多余的工作」

优化不是一开始就要做的事,但 Hook 里有几个固定场景需要考虑:

场景一:派生状态用 useMemo

useExpenseFilter 里的 filteredData 是典型案例。如果直接在函数体里 data.filter(...),每次任何状态变化都会重新过滤,即使 datafilters 没有变化。

// 不好:每次渲染都重新计算
const filteredData = getFilterData(data);

// 好:只在 data 或 filters 变化时重新计算
const filteredData = useMemo(() => getFilterData(data), [data, filters]);

场景二:回调函数用 useCallback

暴露给外部的函数,如果作为 props 传递给子组件,或者出现在其他 Hook 的依赖数组里,应该用 useCallback 包裹。

场景三:不需要触发重渲染的值用 useRef

failCountReftimerRefabortControllerRef 都属于这类。它们是「进行中的工作凭证」,改变它们不需要更新 UI。


一个「答题」的思维顺序

整理完这三道题,我发现面试时可以按这个顺序思考:

1. 明确返回值契约
   └── 对外暴露哪些状态和方法?

2. 识别副作用触发时机
   └── 依赖什么变化?立即执行还是延迟?

3. 规划清理策略
   └── 定时器 / 事件 / 请求,哪些要清理?

4. 考虑优化点
   └── 有无派生状态?回调需不需要 useCallback?

这个顺序不是铁律,但至少能保证不遗漏关键点。


延伸思考

整理这几道题时,有几个问题让我觉得值得继续探索:

  • useReducer vs 多个 useStateuseExpenseFilter 里的多个筛选条件,用 useReducer 管理会更清晰吗?什么情况下应该做这个选择?
  • 请求库的抽象层:SWR / React Query 的 revalidateOnFocus 本质上就是 useRideTracking 里的 visibilitychange 逻辑,只是封装层次不同。面试中能提到这层联系,可能会有加分
  • TypeScript 的泛型设计:这几个 Hook 如果要做成通用的,类型怎么设计?这可能是下一篇笔记的方向

小结

Custom Hook 的核心,我理解是「把复杂的副作用逻辑封装成可复用的、有明确接口的黑盒」。面试考这类题,考的其实不只是「能不能写出来」,更是「能不能清晰地描述你在解决什么问题」。

这篇文章是我自己的思考整理,不一定全对,如果你有不同的看法或者更好的方案,欢迎交流讨论。


参考资料

探索Vite深入 Rollup 分块插件:从零实现一个智能分包工具

探索Vite深入 Rollup 分块插件:从零实现一个智能分包工具

告别正则匹配的硬编码,用规则引擎优雅管理代码分割

引言

在 Rollup 打包配置中,manualChunks 是最强大也最容易被误用的选项之一。社区常见的做法是写一堆 if (id.includes('node_modules')) 或正则表达式,把第三方库一股脑打入 vendor 块。这种方案在项目初期看似简单,但随着迭代,很容易出现:

  • chunk 体积失控:一个 vendor 文件动辄几 MB。
  • 缓存失效频繁:任何依赖更新都会导致整个 vendor 重新下载。
  • 代码复用不佳:被多个入口共享的公共模块无法独立拆分。

为了解决这些问题,我们开发了 rollup.plugin.robin-build 插件(纯 JS/TS 版本,下文简称“本插件”)。它提供了一套声明式的分块规则配置,支持路径匹配、引用次数阈值、优先级排序等高级特性,让代码分割变得可预测、可维护。

插件概览

本插件导出两个主要部分:

  1. output 对象:标准的 Rollup 输出配置,定义了文件命名与分类规则。
  2. createSplitChunks 函数:接收用户配置,返回一个符合 manualChunks 签名 (moduleId, { getModuleInfo }) => string | void 的函数。

插件本身不依赖任何外部库,仅使用 Node.js 内置模块 path。其核心思路是:用户以对象形式定义多个“规则组”,每个规则组包含匹配条件(路径字符串或正则)、目标 chunk 名称、优先级以及最小引用次数。插件在构建时遍历每个模块,按优先级匹配规则,决定模块归属的 chunk。

第一部分:输出配置(output)

export const output = {
    entryFileNames: 'js/robin-[hash].js',
    hashCharacters: 'hex', // 减少字符集,见下图1
    experimentalMinChunkSize: 20 * 1024,
    chunkFileNames: (chunkInfo) => {
        if(chunkInfo.name && chunkInfo.name.startsWith('vendor-')){
            return 'js/[name]-[hash].js'
        }
        return 'js/chunk-[hash].js'
    },
    assetFileNames: (info) => { ... }
}

在这里插入图片描述

1.1 entryFileNames 与 hash 配置

  • entryFileNames:入口 chunk 的文件名模板。这里使用 app-[hash].js,并放入 js/ 目录。
  • hashCharacters: 'hex':指定 hash 编码方式为十六进制(Rollup 5.0+ 支持)。
  • experimentalMinChunkSize:设置最小 chunk 大小(20KB),Rollup 会尝试合并小于此阈值的 chunk,减少 HTTP 请求数量。

1.2 chunkFileNames 动态命名

chunkFileNames 可以是函数,接收 chunkInfo 对象。插件判断如果 chunk 名称以 vendor- 开头(通常是通过规则生成的 vendor 块),则保留原名称,例如 vendor-react-[hash].js;否则统一命名为 chunk-[hash].js

这样做的好处是:vendor 块名称可读性高,便于调试和 CDN 缓存策略区分。

1.3 assetFileNames 按扩展名分类

assetFileNames 根据文件扩展名将静态资源归类到不同子目录:

扩展名类型 输出目录
.css asset/css/
.wasm asset/wasm/
.json, .map asset/data/
.txt, .xml, .pdf asset/docs/
图片格式 asset/img/
音视频格式 asset/media/
字体格式 asset/fonts/
其他 asset/other/

这种细粒度分类对于大型项目尤其重要:运维可以针对不同资源类型设置不同的 CDN 缓存头(例如图片缓存一年,JSON 缓存五分钟)。

第二部分:核心分块引擎 createSplitChunks

createSplitChunks 是整个插件的灵魂。它接收一个配置对象,返回 manualChunks 函数。我们先看它的完整实现:

export const createSplitChunks = (config = {}) => {
    if(!isObject(config)) return null

    const list = []
    Object.keys(config).forEach((key) => {
        const test = config[`${key}`].test

        if(!(isRegExp(test) || isString(test))) {
            throw new Error('test 必须为正则表达式或字符串')
        }

        if (isString(test) && !path.isAbsolute(test)) {
            throw new Error(`test 路径必须为绝对路径,实际获取到的是: ${test}`)
        }

        if (isRegExp(test) && test.global) {
            throw new Error('正则表达式测试不得使用 /g 标志')
        }

        list.push({
            ...config[key],
            chunk_name: `${key.startsWith('vendor') ? key : `vendor-${key}`}`,
            type: isRegExp(test) ? 'regexp' : 'string'
        })
    })
    list.sort((a, b) => (b.priority || 0) - (a.priority || 0))

    return (disk_path, { getModuleInfo }) => {
        const moduleInfo = getModuleInfo(disk_path)

        const target = list.find(item=> {
            if(item['minChunks'] && moduleInfo){
                const static_count = moduleInfo['importers'] ? moduleInfo['importers'].length : 0
                const dynamic_count = moduleInfo['dynamicImporters'] ? moduleInfo['dynamicImporters'].length : 0
                const total = static_count + dynamic_count
                if (total < item['minChunks']) return false
            }
            if(item.type === 'regexp') return item.test.test(disk_path)
            return disk_path.startsWith(item.test)
        })

        if(target && isNull(target.name)) return null

        if(target) return target.name || target.chunk_name

        return null
    }
}

2.1 配置解析与校验

插件期望 config 是一个对象,其每个 key 代表一个规则组的名称,value 必须包含 test 字段(字符串绝对路径或正则表达式)。此外还可以包含:

  • name:自定义 chunk 名称(如果未提供,会自动生成 vendor-${key})。
  • priority:优先级(数字越大越先匹配)。
  • minChunks:最小引用次数,只有模块被引用的总次数 ≥ 该值时才匹配。

首先进行严格的类型校验:

  • 使用 toString.call 来判断数据类型(因为 typeof null === 'object',需要区分)。
  • 对于字符串类型的 test,要求必须是绝对路径(通过 path.isAbsolute 验证)。这确保了匹配的确定性,避免相对路径在不同工作目录下产生歧义。
  • 对于正则表达式,禁止使用 g 全局标志,因为 test 方法在全局标志下会有状态残留,导致不可预期的行为。

2.2 构建规则列表与优先级排序

解析后的每个规则对象会被扩展两个内部字段:

  • chunk_name:自动生成的备用名称(如 vendor-react)。
  • type:标记匹配方式('regexp''string')。

然后将规则数组按 priority 降序排序。没有指定优先级的规则默认为 0。排序保证了高优先级规则先被匹配,避免低优先级规则“抢走”本应归属高优先级规则的模块。

2.3 manualChunks 回调逻辑

manualChunks 接收两个参数:disk_path(模块在磁盘上的绝对路径)和上下文对象 { getModuleInfo }getModuleInfo 可以获取模块的依赖关系信息。

对于每个模块,插件会:

  1. 获取模块的引用信息moduleInfo.importers(静态导入该模块的模块列表)和 dynamicImporters(动态导入该模块的模块列表)。两者的长度之和就是该模块被其他模块引用的总次数 total
  2. 遍历规则列表:按照优先级顺序查找第一个匹配的规则。
    • 如果规则定义了 minChunks,则检查 total >= minChunks,不满足则跳过该规则。
    • 根据规则类型,用 test 匹配 disk_path
  3. 决定 chunk 名称
    • 如果匹配到的规则中 name 字段为 null,则返回 null(表示不强制放入任何特定 chunk,由 Rollup 默认处理)。
    • 否则返回 name 或自动生成的 chunk_name
  4. 未匹配任何规则则返回 null,让 Rollup 按照默认算法处理(通常是基于模块共享度自动拆分)。

2.4 设计亮点

优先级机制解决规则冲突

当多个规则都能匹配同一个模块时,优先级决定了最终归属。例如:

{
  "vue-vendor": {
    test: /[\\/]node_modules[\\/](vue|vue-router|vue-i18n)[\\/]/,
    priority: 10
  },
  "node-vendor": {
    test: /[\\/]node_modules[\\/]/,
    priority: 0
  }
}

vuevue-i18nvue-router 会进入 vue-vendor 块,而其他 npm 包则进入 node-vendor。如果没有优先级,node-vendor 可能会先匹配,导致 Vue 也被打入通用 vendor。

minChunks 避免过度拆分

一个模块如果被很多地方引用(例如工具函数 debounce),独立成 chunk 是有益的;但如果只被一个入口使用,则应该合并到该入口的 chunk 中,减少 HTTP 请求。minChunks 参数允许开发者设置阈值,只有达到引用次数的模块才独立打包。

路径匹配的两种模式
  • 字符串绝对路径:适用于明确知道模块所在目录的场景,例如 '/app/shared/utils'
  • 正则表达式:更灵活,可以匹配 node_modules 中的特定包名,例如 /node_modules\/lodash-es/
自定义 chunk 名称与 null 返回值

允许规则返回 null 可以让某些模块“逃逸”出规则体系,由 Rollup 默认算法处理。这在使用第三方插件或有特殊分块需求时非常有用。

第三部分:类型判断辅助函数

插件开头定义了几个类型判断函数:没有引入loadsh-es,个人感觉没有必要,所以简化写一下

const toString = Object.prototype.toString
const isObject = (data) => toString.call(data) === '[object Object]'
const isNull = (data) => toString.call(data) === '[object Null]'
const isRegExp = (data) => toString.call(data) === '[object RegExp]'
const isString = (data) => toString.call(data) === '[object String]'

为什么不直接用 typeofinstanceof

  • typeof null === 'object',无法区分 null 和普通对象。
  • 在 Rollup 插件环境中,moduleInfo 等对象可能来自不同的上下文,instanceof 可能失效。而 Object.prototype.toString 返回的是内部 [[Class]] 属性,跨框架可靠。

第四部分:使用示例

4.1 基础配置

// rollup.config.js
import { createSplitChunks, output } from 'rollup-plugin-robin-build';

export default {
  input: 'src/main.js',
  output: {
    dir: 'dist',
    ...output,
    manualChunks: createSplitChunks({
      // 规则1:vue 全家桶单独打包
      vue: {
        test: /[\\/]node_modules[\\/](vue|vue-router|vue-i18n)[\\/]/,
        priority: 10,
        minChunks: 1
      },
      // 规则2:antd 组件库单独打包
      antd: {
        test: /[\\/]node_modules[\\/]antd[\\/]/,
        priority: 9
      },
      // 规则3:其他 node_modules 打入 vendor
      vendor: {
        test: /[\\/]node_modules[\\/]/,
        priority: 0
      },
      // 规则4:src/utils 下的公共工具,引用次数 >=3 时独立
      utils: {
        test: path.resolve(__dirname, 'src/utils'),
        minChunks: 3,
        name: 'shared-utils'
      }
    })
  }
}

4.2 配合动态导入

Rollup 能识别动态导入(import()),getModuleInfo 中的 dynamicImporters 会记录哪些模块动态引入了当前模块。因此,minChunks 同样适用于动态导入的场景。

4.3 高级:跳过某些模块

如果某个模块我们不想受任何规则影响,可以在规则中设置 name: null

{
  exclude: {
    test: /[\\/]src[\\/]legacy[\\/]/,
    name: null,   // 让 Rollup 默认处理
    priority: 100
  }
}

第五部分:性能考量

5.1 时间复杂度

每个模块都会遍历规则列表(最坏 O(m·n)),其中 m 为模块数,n 为规则数。对于大型项目(几千个模块,几十条规则),遍历开销仍然可控。但如果规则数量膨胀到上百条,可以考虑将正则表达式编译一次并缓存结果,或使用 Trie 树优化字符串前缀匹配。

5.2 使用 getModuleInfo 的开销

getModuleInfo 是 Rollup 内部维护的模块图查询函数,调用开销极小。我们只在需要检查 minChunks 时才调用,且仅访问 importersdynamicImporters 属性,性能影响可忽略。

5.3 避免重复计算

插件没有做缓存,因为 manualChunks 函数在构建过程中会被多次调用(每个模块一次)。如果规则中包含复杂的自定义函数,可以考虑在外层 memoize。不过本插件完全基于配置,没有用户自定义函数,所以不需要缓存。

第六部分:与其他方案的对比

方案 优点 缺点
原生 manualChunks + 硬编码 简单直接 规则硬编码,难以维护;无法基于引用次数动态拆分
SplitChunksPlugin (Webpack) 功能强大,支持 cacheGroups 配置复杂,且 Webpack 与 Rollup 生态不同
本插件 声明式配置,支持优先级、minChunks,轻量无依赖 需要手动编写规则;无法像 Webpack 那样自动提取公共模块

本插件更适合那些希望将分块规则集中管理、且对 Rollup 生态有强依赖的项目。配合 experimentalMinChunkSize 和 Rollup 自带的 Tree Shaking,可以获得接近 Webpack 的代码分割体验。

第七部分:扩展建议

虽然当前插件已经满足大多数场景,但还可以进一步增强:

  • 支持函数形式的 test:允许用户传入 (id) => boolean,实现更灵活的匹配逻辑。
  • 支持异步规则:例如根据模块内容的大小或依赖关系动态决策。
  • 提供内置预设:例如 preset: 'vue' 自动配置 Vue 相关的分块规则。
  • 集成 bundle 分析器:生成分块报告,帮助用户调整规则。

结语

代码分割是前端性能优化的核心环节之一,但往往被忽视或粗暴处理。通过本插件,我们可以用声明式的规则引擎精细控制每个模块的去向,实现:

  • 更合理的缓存策略:稳定依赖单独 chunk,业务代码频繁更新不影响第三方库缓存。
  • 更快的首屏加载:避免一次性加载巨大的 vendor 文件。
  • 更清晰的构建产物:每个 chunk 有明确的命名和用途。

希望这篇文章能帮助你理解 manualChunks 的高级用法,并启发你构建属于自己的分块工具。如果你对插件有任何疑问或改进建议,欢迎在评论区交流。

对比

vue 全家桶单独打包 我在项目中走CDN啦! 默认分包 在这里插入图片描述 只用插件分包 在这里插入图片描述 很明显有很大的差别!

Pretext:无 DOM 的多行文本测量与排版库

一、背景:文本测量的老大难问题

1.1 为什么需要精确的文本高度?

以下场景都依赖对文本高度的精确预测:

  • 虚拟列表:渲染 10 万条动态高度的消息,需要提前知道每条的像素高度
  • 瀑布流布局(Masonry):把内容塞进最短的那列,高度算错就乱
  • 自定义排版引擎:Canvas 渲染、WebGL UI、PDF 生成——没有 DOM 可查
  • AI 生成 UI:服务端生成界面,不可能在浏览器里 "渲染一遍再量"

1.2 传统方案的痛点

方案 A:插入 DOM 再查询

const el = document.createElement('div')
el.style.cssText = `font: 16px Inter; width: 320px; visibility: hidden`
el.textContent = text
document.body.appendChild(el)
const height = el.getBoundingClientRect().height  // 强制触发 Layout Reflow!
document.body.removeChild(el)

问题:

  • 每次调用都触发一次完整的 Layout Reflow(浏览器重新计算所有元素位置)
  • 批量测量 500 条文本 → 500 次 Reflow → 主线程卡死
  • 无法在 Node.js / Worker 中运行
  • 时序问题:字体未加载完成时结果不准

方案 B:Canvas measureText(初步改进)

const ctx = canvas.getContext('2d')
ctx.font = '16px Inter'
const metrics = ctx.measureText(text)
// 返回的是单行宽度,无法直接得到多行高度

问题:

  • 只能测单行宽度
  • 不处理换行、多语言、Bidi
  • 自己实现换行逻辑 = 重新造一个排版引擎

二、认识 Pretext

2.1 是什么

Pretext 是一个纯 TypeScript 的多行文本测量与排版库,完全在 DOM 之外运行。

npm install @chenglou/pretext
  • GitHub:github.com/chenglou/pr… — 31k Stars
  • 作者:Cheng Lou(React Motion 作者、ReScript 核心成员、前 Meta/Midjourney)
  • 首发:2026 年初,Hacker News 380 分,国内 Juejin/Zhihu 广泛讨论

2.2 一句话解释它做了什么

你给它一段文字 + 一个字体 + 一个宽度  →  它告诉你会占多少行、多高
完全不碰 DOM,不触发 Reflow,可在 Node.js / Worker / WebGL 中运行

三、核心原理:两阶段架构

这是 Pretext 最关键的设计,理解这个就理解了它为什么快。

3.1 整体架构

┌────────────────────────────────────────────────────────────────┐
│  阶段 1prepare(text, font)                                    │
│                                                                │
│  文本  →  [Unicode 分词][Canvas.measureText 缓存]         │
│       →  [空白规范化]    →  [Emoji 宽度修正]                   │
│       →  PreparedText(不透明对象,内含缓存数据)               │
│                                  ↑                             │
│                          一次性,约 19ms/500条                  │
└────────────────────────────────────────────────────────────────┘
           ↓  PreparedText 可复用,多次 layout
┌────────────────────────────────────────────────────────────────┐
│  阶段 2layout(prepared, maxWidth, lineHeight)                 │
│                                                                │
│  PreparedText + 宽度  →  纯算术  →  { lineCount, height }      │
│                                                                │
│  零 DOM / 零 Canvas API / 零内存分配,约 0.09ms/次              │
└────────────────────────────────────────────────────────────────┘

3.2 阶段 1 细节:prepare() 做了什么

源码路径:src/layout.ts + src/analysis.ts + src/measurement.ts

第一步:空白规范化(对应 CSS white-space: normal

const collapsibleWhitespaceRunRe = /[ \t\n\r\f]+/g
const needsWhitespaceNormalizationRe = /[\t\n\r\f]| {2,}|^ | $/
// src/analysis.ts
export function normalizeWhitespaceNormal(text: string): string {
  // 1. 将所有空白字符(\t \n \r 等)替换为空格
  // 2. 合并连续空格为单个空格
  // 3. 去除首尾空格
}

第二步:Unicode 分词Intl.Segmenter + 7 步后处理流水线)

分词分两个阶段:先用浏览器原生 API 做初始切分,再经过一条修正流水线。

2a. Intl.Segmenter 初始切分,兼容各种语言

// 全局单例,setLocale() 会重置它
let sharedWordSegmenter: Intl.Segmenter | null = null

function getSharedWordSegmenter(): Intl.Segmenter {
  if (sharedWordSegmenter === null) {
    sharedWordSegmenter = new Intl.Segmenter(segmenterLocale, { granularity: 'word' })
  }
  return sharedWordSegmenter
}

granularity: 'word' 模式下,浏览器按语言规则切词,并标记每段是否 isWordLike

"Hello, 世界!" 的初始输出:

"Hello"  isWordLike: true
","      isWordLike: false
" "      isWordLike: false
"世"     isWordLike: true
"界"     isWordLike: true
"!"      isWordLike: false

2b. 打上 SegmentBreakKind 标签

每个片段被分类为 8 种类型,决定断行逻辑如何对待它:

type SegmentBreakKind =
  | 'text'             // 普通词
  | 'space'            // 可折叠空格(行末丢弃)
  | 'preserved-space'  // pre-wrap 保留空格
  | 'tab'              // Tab,触发制表位对齐
  | 'glue'             // 不换行空格 \u00A0,粘住两侧词
  | 'zero-width-break' // 零宽换行机会 \u200B(建议可断但不显示)
  | 'soft-hyphen'      // 软连字符 \u00AD(断行时显示 -)
  | 'hard-break'       // 强制换行 \n(pre-wrap 模式)

2c. 7 步合并/拆分流水线

Intl.Segmenter 的结果不完全符合视觉断行规则,需要修正:

Pass 1 & 2:URL 保持完整

"https://example.com/path?a=1"
Intl 会在 / ? = 处切开 → mergeUrlLikeRuns + mergeUrlQueryRuns 合并回一个单元

Pass 3 & 4:数字处理

"1,234.56"  Intl 切开  → mergeNumericRuns 合并为一个词(不可断)
"1234-5678" Intl 合并  → splitHyphenatedNumericRuns 在 - 处拆开(允许断行)

Pass 5:ASCII 标点吸附到前一个词

"Hello,"Intl 切成 "Hello" + ","
          → mergeAsciiPunctuationChains 合并为 "Hello,"

理由:标点和前一个词视觉上是整体,不应在标点前换行。

Pass 6:CJK 引号后的进位(禁则规则)

kinsokuEnd(不能出现在行尾)和 kinsokuStart(不能出现在行首)字符集:

export const kinsokuStart = new Set([
  '\uFF0C', '\uFF0E', '\uFF01', '\uFF1A', '\uFF1B', '\uFF1F',  // ,。!:;?
  '\u3001', '\u3002', '\u30FB', '\uFF09', '\u3015', ...         // 、。・)〕…
])
export const kinsokuEnd = new Set([
  '"', '(', '[', '{', '"', ''', '«',
  '\uFF08', '\u3014', '\u3008', '\u300A', '\u300C', ...         // (〔《「…
])

carryTrailingForwardStickyAcrossCJKBoundary 处理引号跨越 CJK 边界的进位, 此处 Safari 和 Chromium 行为不同(EngineProfile.carryCJKAfterClosingQuote)。

Pass 7:不换行空格粘连

"foo\u00A0bar"  → glue 类型的 \u00A0 把 foo 和 bar 粘在一起
               → mergeGlueConnectedTextRuns 合并为一个单元,不允许在此换行

2d. 最终产物:MergedSegmentation

7 步流水线结束后,得到四个平行数组(数组的结构体,缓存友好):

type MergedSegmentation = {
  len: number
  texts: string[]           // ["Hello", ",", " ", "世", "界", "!"]
  isWordLike: boolean[]     // [true, false, false, true, true, false]
  kinds: SegmentBreakKind[] // ['text', 'text', 'space', 'text', 'text', 'text']
  starts: number[]          // [0, 5, 6, 7, 8, 9]  ← 在原始字符串中的偏移
}

这四个数组就是 PreparedText 的内部骨架,后续 layout() 只操作这些数据,不再碰原始字符串。

为什么用平行数组而不是对象数组?— CPU 缓存

CPU 计算时数据必须先进寄存器,但寄存器极小(几十个),数据平时住在内存里。直接从内存读到寄存器太慢(~60ns),所以 CPU 和内存之间有三级缓存(L1/L2/L3)作为中转:

内存(慢,~60ns)→ L1/L2/L3 缓存(快,~1–10ns)→ 寄存器(极快,~0.3ns)→ 计算

CPU 读数据时不是按字节搬,而是一次从内存载入 64 字节(一个缓存行)到缓存,再从缓存送入寄存器:

你访问了 kinds[2]
CPU 一次性把 kinds[0]~kinds[63] 载入缓存
后续访问 kinds[3][4][5]... 缓存命中,直接送寄存器,不用再等内存

对象数组的内存布局是每个对象连续,访问 kind 字段时要跳过 textisWordLikestart

[text|isWordLike|kind|start] [text|isWordLike|kind|start] ...
← 每个对象 ~50 字节,一个缓存行只能装 1 个 →

平行数组的 kinds 是连续序列:

kinds: [k0][k1][k2][k3]...[k63]
     ← 一个缓存行装 64 个,循环后续 64 次全部命中 →

layout() 的断行循环要遍历几百个分词,每次只看 kinds[i]widths[i],平行数组让这两个热路径数组的缓存命中率接近 100%——这是 layout() 跑到 0.09ms 的原因之一。

第三步:Canvas 测量 + 缓存src/measurement.ts

3a. 获取 Canvas 上下文

let measureContext: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null

export function getMeasureContext() {
  if (measureContext !== null) return measureContext

  if (typeof OffscreenCanvas !== 'undefined') {
    // 优先用 OffscreenCanvas:不依赖 DOM,可在 Web Worker 中运行
    measureContext = new OffscreenCanvas(1, 1).getContext('2d')!
    return measureContext
  }

  if (typeof document !== 'undefined') {
    // 浏览器主线程回退
    measureContext = document.createElement('canvas').getContext('2d')!
    return measureContext
  }

  throw new Error('Text measurement requires OffscreenCanvas or a DOM canvas context.')
}

Canvas 大小是 1×1——不需要真正绘制,只是借用 measureText() 这个 API。

3b. 两级缓存结构

// 外层:font 字符串 → 内层 Map
// 内层:segment 文字 → SegmentMetrics
const segmentMetricCaches = new Map<string, Map<string, SegmentMetrics>>()

缓存 key 是 (font, segment) 组合,例如 ("16px Inter", "Hello")。 同一段文字 + 同一字体只调用一次 measureText(),之后永久命中缓存。

3c. 核心测量函数

export function getSegmentMetrics(seg: string, cache: Map<string, SegmentMetrics>): SegmentMetrics {
  let metrics = cache.get(seg)
  if (metrics === undefined) {
    const ctx = getMeasureContext()
    metrics = {
      width: ctx.measureText(seg).width,  // 唯一一次 Canvas API 调用
      containsCJK: isCJK(seg),            // 标记是否含 CJK,影响断行策略
    }
    cache.set(seg, metrics)
  }
  return metrics
}

SegmentMetrics 是按需填充的,其余字段只在需要时才计算:

type SegmentMetrics = {
  width: number                          // measureText() 返回的原始宽度
  containsCJK: boolean                   // 是否含 CJK 字符
  emojiCount?: number                    // Emoji 个数(用于修正宽度)
  graphemeWidths?: number[] | null       // 每个字形的宽度(overflow-wrap 断词用)
  graphemePrefixWidths?: number[] | null // 字形宽度前缀和(二分查找用)
}
字段 何时计算
width / containsCJK 首次 measureText() 时立即填充
emojiCount 需要 Emoji 宽度修正时(含 Emoji 的 segment)
graphemeWidths 词宽超过容器、需要词内强制断行时
graphemePrefixWidths Safari 下二分查找断行点时(preferPrefixWidthsForBreakableRuns

这种懒加载设计保证了大多数普通文本只付出最低开销:每个 segment 仅调用一次 measureText(),字形级宽度仅在真正需要断词时才触发。

3d. graphemeWidths 与 graphemePrefixWidths 的计算

当一个词超出容器宽度、需要在词内强制断行时,才会触发字形级宽度的计算:

export function getSegmentGraphemeWidths(seg, metrics, cache, emojiCorrection): number[] | null {
  if (metrics.graphemeWidths !== undefined) return metrics.graphemeWidths  // 缓存命中

  const widths: number[] = []
  const graphemeSegmenter = getSharedGraphemeSegmenter()  // Intl.Segmenter granularity:'grapheme'
  for (const gs of graphemeSegmenter.segment(seg)) {
    const graphemeMetrics = getSegmentMetrics(gs.segment, cache)
    widths.push(getCorrectedSegmentWidth(gs.segment, graphemeMetrics, emojiCorrection))
  }

  // 单字符词不需要存(没有"词内断行"的意义)
  metrics.graphemeWidths = widths.length > 1 ? widths : null
  return metrics.graphemeWidths
}

前缀和数组用同样的方式构建,区别在于每次测量累积前缀字符串而非单个字形:

export function getSegmentGraphemePrefixWidths(seg, metrics, cache, emojiCorrection): number[] | null {
  if (metrics.graphemePrefixWidths !== undefined) return metrics.graphemePrefixWidths

  const prefixWidths: number[] = []
  let prefix = ''
  for (const gs of graphemeSegmenter.segment(seg)) {
    prefix += gs.segment
    const prefixMetrics = getSegmentMetrics(prefix, cache)  // 测量 "H", "He", "Hel"...
    prefixWidths.push(getCorrectedSegmentWidth(prefix, prefixMetrics, emojiCorrection))
  }

  metrics.graphemePrefixWidths = prefixWidths.length > 1 ? prefixWidths : null
  return metrics.graphemePrefixWidths
}

为什么测前缀字符串而不是直接累加单字形宽度? 因为字体有字距调整(kerning)"Te" 的实际渲染宽度可能小于 width("T") + width("e"), 测整体前缀可以拿到和浏览器完全一致的累积宽度。

第四步:Emoji 宽度修正

macOS 上 Canvas measureText() 对 Emoji 的宽度虚报(比实际 DOM 渲染更宽), Pretext 在每个字体大小上做一次一次性校正:

function getEmojiCorrection(font: string, fontSize: number): number {
  let correction = emojiCorrectionCache.get(font)
  if (correction !== undefined) return correction  // 已校正过,直接返回

  const ctx = getMeasureContext()
  ctx.font = font
  const canvasW = ctx.measureText('\u{1F600}').width  // Canvas 测量值

  correction = 0
  if (canvasW > fontSize + 0.5 && typeof document !== 'undefined') {
    // 插入不可见 <span>,拿到 DOM 实际渲染宽度
    const span = document.createElement('span')
    span.style.cssText = `font:${font};display:inline-block;visibility:hidden;position:absolute`
    span.textContent = '\u{1F600}'
    document.body.appendChild(span)
    const domW = span.getBoundingClientRect().width
    document.body.removeChild(span)

    if (canvasW - domW > 0.5) correction = canvasW - domW  // 记录差值
  }

  emojiCorrectionCache.set(font, correction)
  return correction
}

校正时从 segment 宽度中减去 emojiCount × correction

export function getCorrectedSegmentWidth(seg, metrics, emojiCorrection): number {
  if (emojiCorrection === 0) return metrics.width
  return metrics.width - getEmojiCount(seg, metrics) * emojiCorrection
}

注意这里故意插了一次 DOM——但只插一次,之后所有 Emoji 宽度计算都用这个缓存的校正值。

Canvas 缓存小结

prepare() 阶段  →  measureText() 结果写入两级 Map  →  永久缓存
layout()  阶段  →  只读缓存,做纯算术,0Canvas/DOM API 调用

(font, segment) 二元组作为缓存 key,保证跨多次 layout() 调用时不重复测量。 字形级宽度和 Emoji 校正值也都只计算一次,后续全部命中缓存——这是 layout() 跑到 0.09ms 的核心原因。

第五步:浏览器差异适配

export function getEngineProfile(): EngineProfile {
  // Node.js 环境没有 navigator,返回保守默认值
  if (typeof navigator === 'undefined') {
    return { lineFitEpsilon: 0.005, carryCJKAfterClosingQuote: false, ... }
  }

  const ua = navigator.userAgent
  const isSafari = navigator.vendor === 'Apple Computer, Inc.' &&
    ua.includes('Safari/') && !ua.includes('Chrome/') && ...
  const isChromium = ua.includes('Chrome/') || ua.includes('Chromium/') || ...

  return {
    lineFitEpsilon: isSafari ? 1 / 64 : 0.005,
    //  ↑ 判断一行文字是否"刚好放得下"时的浮点容差
    //    Safari 字宽计算精度是 1/64px,Chromium 是 0.005px

    carryCJKAfterClosingQuote: isChromium,
    //  ↑ 引号后紧跟 CJK 字符时的禁则进位行为,两个引擎不一致

    preferPrefixWidthsForBreakableRuns: isSafari,
    //  ↑ Safari 的 kerning 使单字形累加不准,需要用前缀宽度做二分

    preferEarlySoftHyphenBreak: isSafari,
    //  ↑ 软连字符的断行时机,Safari 偏好更早断
  }
}

这个 profile 只检测一次,结果缓存在 cachedEngineProfile,后续所有 layout() 调用共用。

3.3 阶段 2 细节:layout() 做了什么

源码路径:src/line-break.ts

准备阶段生成了 PreparedLineBreakData

// src/line-break.ts
type PreparedLineBreakData = {
  widths: number[]                         // 每个分词的宽度
  lineEndFitAdvances: number[]             // 行末 fit 宽度(不含尾部空格)
  lineEndPaintAdvances: number[]           // 行末 paint 宽度(含 overflow)
  kinds: SegmentBreakKind[]                // 每段的类型
  simpleLineWalkFastPath: boolean          // 是否走快速路径
  breakableWidths: (number[] | null)[]     // 可断词的字形宽度
  breakablePrefixWidths: (number[] | null)[]
  discretionaryHyphenWidth: number         // 可选连字符宽度
  tabStopAdvance: number                   // Tab 制表位间距
  chunks: { ... }[]                        // 强制换行分块
}

关键优化:simpleLineWalkFastPath

对于大多数普通文本(无 Tab、无软连字符、无强制换行),走一条更简单的代码路径:

// src/line-break.ts
if (prepared.simpleLineWalkFastPath) {
  return walkPreparedLinesSimple(prepared, maxWidth, onLine)  // 快速路径
} else {
  return walkPreparedLinesFull(prepared, maxWidth, onLine)    // 完整路径
}

layout 阶段不调用任何浏览器 API,只做加减法和数组索引——这是 0.09ms 的秘密。


四、API 全貌

4.1 四个层次,按需取用

import {
  prepare, prepareWithSegments,
  layout, layoutWithLines,
  walkLineRanges,
  layoutNextLine,
  clearCache, setLocale,
} from '@chenglou/pretext'

4.2 第一层:快速高度测量

适用场景: 虚拟列表行高预测、判断文本是否需要截断

const prepared = prepare('AGI 春天到了', '16px Inter')
const { height, lineCount } = layout(prepared, 320, 20)
// height: 像素高度(lineCount * lineHeight)
// lineCount: 折行后的行数

prepare() 返回的是不透明类型(Branded Type),内部结构不暴露——你只需要把它传给 layout()

4.3 第二层:获取每行文字(自定义渲染)

适用场景: Canvas 绘制文字、SVG 文字排版、WebGL 渲染

const prepared = prepareWithSegments('Hello world', '18px "Helvetica Neue"')
const { lines } = layoutWithLines(prepared, 320, 26)

// 在 Canvas 上逐行绘制
const ctx = canvas.getContext('2d')
ctx.font = '18px "Helvetica Neue"'
for (let i = 0; i < lines.length; i++) {
  ctx.fillText(lines[i].text, 0, i * 26)
}

LayoutLine 包含:

type LayoutLine = {
  text: string    // 这一行的文字内容
  width: number   // 这一行的实际渲染宽度
  start: LayoutCursor
  end: LayoutCursor
}
type LayoutCursor = { segmentIndex: number; graphemeIndex: number }

4.4 第三层:零分配的游标遍历

适用场景: 对性能极度敏感,需要避免所有字符串分配

// 不构造字符串,直接操作游标索引
const lineCount = walkLineRanges(prepared, maxWidth, (startCursor, endCursor, lineWidth) => {
  // startCursor / endCursor 是 { segmentIndex, graphemeIndex }
  // 你可以用它们直接索引原始 segments 数组,而不创建子字符串
})

4.5 第四层:动态列宽(文字绕图)

适用场景: 文字绕图排列,类似 CSS float;或每行宽度动态变化的布局

// 例:文字绕过右侧图片区域
let cursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0

while (true) {
  // 根据当前 y 坐标决定这行的可用宽度
  const width = (y < imageBottom) ? columnWidth - imageWidth : columnWidth
  const line = layoutNextLine(prepared, cursor, width)
  if (line === null) break

  ctx.fillText(line.text, 0, y)
  cursor = line.end
  y += lineHeight
}

4.6 其他实用 API

// 字体加载完成后清除缓存,确保新字体参与测量
clearCache()

// 设置语言区域,影响断行规则(例如区分简/繁体中文的标点行为)
setLocale('zh-CN')

// 支持 pre-wrap 模式(类似 textarea:保留空格、换行符生效)
const prepared = prepare(text, font, { whiteSpace: 'pre-wrap' })

// 诊断工具:分析各阶段耗时
const profile = profilePrepare(text, font)

五、语言与 Unicode 支持

5.1 Bidi(双向文字)

源码:src/bidi.ts,从 pdf.js 移植,实现完整的 Unicode Bidi Algorithm。

// 对每个字符进行 Bidi 分类
type BidiType = 'L' | 'R' | 'AL' | 'AN' | 'EN' | 'WS' | 'ON' | ...

// 计算字符级别的方向 levels(偶数=LTR,奇数=RTL)
computeBidiLevels(str: string): number[]

// 将字符级 level 映射到 prepare() 的分词边界上
computeSegmentLevels(normalized, segStarts): number[]

对于 prepareWithSegments() 返回的 lines,每个 LayoutLine 会附带 bidi levels, 供自定义渲染器按正确方向绘制混合文字。

5.2 CJK 禁则规则

src/analysis.ts 中维护了完整的 Unicode 禁则字符集:

// 不能出现在行首的字符(如:),。、】)
const kinsokuStart: Set<string>

// 不能出现在行尾的字符(如:(【『「)
const kinsokuEnd: Set<string>

// 左侧粘连标点(不和右侧内容分离)
const leftStickyPunctuation: Set<string>

// 引号字符集(影响 CJK 引号后的换行进位行为)
const closingQuoteChars: Set<string>

5.3 中/日/韩文字符逐字符断行

CJK 每个字符都可以独立换行,不需要词边界。Pretext 在 prepare() 阶段检测 CJK 字符后, 自动将其拆分为单字符粒度参与宽度缓存:

// src/analysis.ts
export function isCJK(s: string): boolean  // 检测 Unicode CJK/Hiragana/Katakana/Hangul 范围

六、Demo 演示

以下 Demo 均来自官方:chenglou.me/pretext

Demo 1:Accordion(折叠面板)

展示点: 精确预测折叠/展开后的高度,驱动 CSS transition——不展开就知道目标高度。

// 在点击"展开"时,用 prepare + layout 提前计算展开后高度
// 直接设置 max-height: ${height}px 配合 transition
const { height } = layout(prepare(content, font), containerWidth, lineHeight)
el.style.maxHeight = `${height}px`

Demo 2:Masonry(瀑布流)

展示点: 渲染前批量测量所有卡片高度,精确分配到最短列,避免渲染后的二次调整。

const prepared = cards.map(text => prepare(text, '14px Inter'))
// layout 极快,可在同步代码中批量计算
const heights = prepared.map(p => layout(p, cardWidth, 20).height)

Demo 3:Editorial Engine(编辑排版引擎)

展示点: 多栏杂志式排版,文字实时 60fps 重排,标题自动绕过装饰元素。 使用 layoutNextLine API,每列列宽可动态变化。

Demo 4:Justification Compared(断行算法对比)

展示点: 并排对比三种断行策略:

策略 说明 效果
CSS 默认(贪心算法) 每行尽量塞满 行尾参差不齐
加连字符(hyphenation) 单词末尾加 - 拆分 略好
Knuth-Plass 最优算法 全局最小化行间距差异 最均匀,LaTeX 同款

Pretext 通过暴露游标级 API,让用户自行实现 Knuth-Plass 算法,而不耦合具体排版策略。


七、实际应用场景

场景 1:虚拟列表精确行高

// 预计算所有行高(一次性 prepare,多次 layout)
const prepared = messages.map(msg => prepare(msg.text, '14px Inter'))
const rowHeights = prepared.map(p => layout(p, listWidth, 20).height + PADDING)

// 虚拟列表滚动时直接查表,不再触碰 DOM
function getItemHeight(index: number) { return rowHeights[index] }

场景 2:AI 生成 UI 的服务端布局验证

// Node.js 环境(需要 Canvas 实现,如 node-canvas)
import { createCanvas } from 'canvas'
// Pretext 可在 Node.js 中使用,以验证 LLM 生成的 UI 不会溢出
const { lineCount } = layout(prepare(aiGeneratedText, font), containerWidth, lineHeight)
if (lineCount > maxLines) trimText(aiGeneratedText)

场景 3:防止 CLS(累积布局偏移)

服务端渲染时提前计算高度,在 HTML 中写入 style="height: Xpx", 浏览器渲染时不发生跳动,Google Core Web Vitals 得分不受影响。

场景 4:Canvas / WebGL 渲染引擎

// Canvas 游戏 UI、数据大屏、PDF 生成
const { lines } = layoutWithLines(prepare(text, font), boxWidth, lineHeight)
lines.forEach((line, i) => {
  ctx.fillText(line.text, x, y + i * lineHeight)
})

八、局限性与注意事项

8.1 已知精度问题

问题 场景 原因
macOS system-ui 精度略低 使用系统默认字体时 macOS OS 级字形渲染与 Canvas 测量存在差异
极窄容器触发字形级断行 容器宽度小于单个词 回退到逐字符(grapheme)断行
字体未加载时结果不准 自定义 Web Font 需确保字体加载完成后再调用 prepare()

8.2 使用注意

// 字体更换后记得清缓存
document.fonts.ready.then(() => {
  clearCache()
  // 重新 prepare
})

// Pretext 专注排版逻辑,不负责字形绘制
// 它告诉你文字在哪儿,具体画出来仍然需要 Canvas / SVG / DOM

8.3 不支持的 CSS 特性(当前版本 0.0.4)

  • word-break: keep-all(中文不断行)
  • writing-mode: vertical-*(竖排文字)
  • letter-spacing / word-spacing
  • 复杂 CSS 嵌套行内元素(需用 prepareWithSegments 手动拼接)

九、与现有方案对比

方案 是否触发 Reflow Node.js 可用 多语言完整支持 多行支持 备注
DOM getBoundingClientRect 最常见,最慢
Canvas measureText(手写换行) 部分 手写 需自行处理 Unicode
opentype.js / fontkit 手写 需加载字体文件
Pretext 完整 内置 需要浏览器 Canvas 或 node-canvas

关键差异:

  • opentype.js:操作字体文件字形,精度最高,但需下载字体文件,适合 PDF 生成
  • Pretext:借助 Canvas 的字体渲染,和浏览器显示高度一致,适合 Web UI 场景

十、总结

Pretext 的核心价值

prepare() 一次  →  canvas 测量结果永久缓存
layout()  N次   →  纯数学,任何环境,极致性能

它解决的问题:

  • 消除批量文本测量引发的 Layout Reflow
  • 让"在渲染前知道文本高度"变得真正可行
  • 把精确的文字排版能力带出浏览器(Node.js、Worker、WebGL)

值得关注的信号:

  • 作者 Cheng Lou 在 React 生态有极强的工程判断力
  • 31k Stars + HN 380 分——社区已经验证了痛点的真实性
  • 发布时间短,API 仍在演进,可保持关注

我们的项目有哪些场景可以用?

开放讨论:我们的消息列表、Feed 流、或 Canvas 报表有哪些地方受益?


十一、Q & A

Q:缓存会不会无限增长?有没有 LRU / TTL?

没有。缓存是一个朴素的 Map,不会自动淘汰。 设计前提是:一个应用中使用的 (font, segment) 组合数量是有限且收敛的——普通文本里词汇高度重复,缓存命中率极高,总条目数通常不大。 极端场景(如代码编辑器、每帧随机文字)可能导致缓存膨胀,需要手动定期调用 clearCache() 来重置。这是当前版本的已知权衡。


Q:和浏览器真实渲染差多少?号称 pixel-perfect 有依据吗?

测量数据来自同一个 Canvas 渲染引擎,理论上和浏览器"看到"的字宽一致。已知精度问题有两处:

  • macOS system-ui:OS 级字形渲染与 Canvas 存在亚像素差异,行数预测可能偏差 1 行
  • 浮点容差:Safari 精度是 1/64px,Chromium 是 0.005px,lineFitEpsilon 按引擎分别配置

Emoji 的偏差通过一次性 DOM 校正消除。对常规正文字体,实测误差可以做到 0 像素差。


Q:为什么不直接用 TextMetrics.actualBoundingBoxAscent/Descent

measureText() 返回的 TextMetrics 给的是单行文字的 bounding box,无法处理换行。 你仍然需要自己实现完整的断行算法(空白规范化、Unicode 分词、CJK 禁则、soft-hyphen……),那正好就是 Pretext 本身。measureText() 是 Pretext 内部的一个工具,而不是替代品。


Q:字体还没加载完就调用 prepare(),结果会不会不准?

会。字体未加载时,Canvas 会回退到系统默认字体测量,结果偏差可能很大。 正确做法:

await document.fonts.ready  // 等所有已声明字体加载完
// 或针对特定字体:
await document.fonts.load('16px Inter')

clearCache()               // 清除旧测量结果
const prepared = prepare(text, '16px Inter')

动态加载新字体后同样需要 clearCache()


Q:版本 0.0.4,能上生产吗?

底层依赖的 Canvas.measureText() 是成熟稳定的浏览器 API,核心算法没有风险。 主要不确定因素是公开 API 可能仍在调整(函数签名、返回值结构),升级时需关注 changelog。 建议:非核心链路、或新开发的模块可以直接用;存量代码做好隔离封装,留出升级空间。


参考资料

vite项目开发环境启动白屏

Vite 启动成功但页面打不开:加上 host: '127.0.0.1' 就恢复了,为什么?

今天开发时遇到一个比较“迷惑”的问题:

  • 终端里 npm run dev 正常启动
  • Vite 提示 ready in xxx ms
  • 控制台没有报错
  • 但浏览器访问 http://localhost:1111/ 时,直接报:

ERR_CONNECTION_REFUSED

最后只是在 vite.config.ts 里加了这段配置,问题就解决了:

server: {
  host: '127.0.0.1', //新加了host
  port: 1111,
},

那为什么加了 host: '127.0.0.1' 就好了?这篇文章把这次问题的现象、原因和解决方案整理一下。


一、问题现象

项目使用 Vite,本地启动命令:npm run dev

终端输出类似这样:

VITE v7.x.x ready in 504 ms

➜ Local: http://localhost:1111/

看起来一切正常,但浏览器打开后却提示:

无法访问此网站 localhost 拒绝了我们的连接请求 ERR_CONNECTION_REFUSED

最关键的是:

  • 页面打不开
  • 控制台没有报错
  • 代码本身看起来也没问题

这种情况特别容易让人误以为是 Vue 组件、入口文件或者业务代码出了问题,但实际上这次并不是前端逻辑问题,而是 开发服务器的监听地址问题


二、这次问题的根因

这次问题的根因可以概括成一句话:

Vite 默认监听的地址,与我当前机器上 localhost 的实际解析/访问方式不一致,导致浏览器访问被拒绝。

更具体一点说,就是:

1. localhost 不一定只等于 127.0.0.1

很多人会默认认为:

localhost = 127.0.0.1

但实际上不是。

在现代操作系统里,localhost 往往可能同时解析到:

  • 127.0.0.1(IPv4)
  • ::1(IPv6)

有些环境会优先走 IPv6,也就是 ::1


2. Vite 启动了,不代表浏览器一定能访问成功

Vite 终端里显示 ready,只能说明:

  • Node 进程起来了
  • Vite 逻辑开始监听某个地址和端口了

但这不等于:

  • 浏览器一定能通过你输入的 URL 成功连上它

如果服务绑定在 IPv6 地址、而浏览器/系统/代理最终没有按同样方式访问,或者本机网络栈对 localhost 的处理存在差异,就可能出现:

  • 终端显示启动成功
  • 浏览器却连不上
  • 并且前端控制台没有任何 JS 报错

因为这时候问题发生在 网络连接阶段,还没走到页面脚本执行。


3. 这次加 host: '127.0.0.1' 后生效,本质上是“强制固定到 IPv4”

当我加上:

server: {
  host: '127.0.0.1',
  port: 1111,
},

实际效果是:

  • Vite 明确绑定到 127.0.0.1
  • 浏览器也直接通过 IPv4 地址访问本地服务
  • 避开了 localhost 在本机环境下的 IPv4 / IPv6 解析歧义

也就是说,这个配置的本质不是“让 Vite 更快”或者“修复了业务代码”,而是:

把本地开发服务器的监听地址,从模糊的 localhost,改成了明确的 127.0.0.1


三、为什么之前会报 ERR_CONNECTION_REFUSED,但控制台没有错误?

这是这类问题最容易误导人的地方。

原因很简单:

浏览器控制台通常显示的是 页面加载后的脚本错误,比如:

  • JS 语法错误
  • Vue 运行时错误
  • 接口报错
  • 资源加载失败

但这次浏览器连页面都没连上,所以根本没有机会执行前端代码。

也就是说:

  • 不是页面渲染报错
  • 不是组件逻辑报错
  • 不是业务代码报错
  • 而是浏览器在“建立连接”这一步就失败了**

所以最终看到的是:

ERR_CONNECTION_REFUSED

而不是前端控制台里的红色报错堆栈。


四、最终解决方案

修改前

server: {
  port: 1111,
},

修改后

server: {
  host: '127.0.0.1',
  port: 1111,
},

修改完成后,重新启动:

npm run dev

然后直接访问:

http://127.0.0.1:1111/

页面恢复正常。


五、为什么 host: '127.0.0.1' 能解决问题?

可以直接理解成下面这张逻辑链:

没加 host 时

  • Vite 使用默认 host
  • localhost 在系统里可能优先解析到 IPv6(::1
  • 浏览器访问 localhost 时,和服务实际监听地址不完全一致
  • 最终连接失败

加了 host: '127.0.0.1' 后

  • Vite 明确监听 IPv4 地址 127.0.0.1
  • 浏览器也直接访问 127.0.0.1
  • 服务监听地址和访问地址完全一致
  • 连接恢复正常

一句话总结:

不是 Vite 没启动,而是“启动后的监听地址”与“浏览器访问地址”之间存在偏差。


六、遇到这类问题怎么排查?

如果你也遇到:

  • Vite 显示启动成功
  • 页面却打不开
  • 控制台没有报错

可以按下面顺序排查。

1. 先判断是不是“代码错误”

如果是代码错误,通常会出现:

  • 浏览器白屏但能打开页面
  • 控制台有 JS 报错
  • 网络面板能看到 index.html / main.ts 已经加载

如果是这类情况,就查业务代码。


2. 如果直接 ERR_CONNECTION_REFUSED

那优先怀疑:

  • 本地端口没真正可访问
  • localhost 解析有问题
  • IPv4 / IPv6 监听不一致
  • 被系统代理、VPN、防火墙影响

这种情况通常不是组件代码问题,而是 开发服务器访问路径问题


3. 直接指定 host

最直接有效的办法:

server: {
  host: '127.0.0.1',
  port: 1111,
},

如果你需要局域网访问,可以改成:

server: {
  host: true,
  port: 1111,
},

或者:

server: {
  host: '0.0.0.0',
  port: 1111,
},

七、我的结论

这次问题本质上不是“项目没跑起来”,而是:

Vite 在本机环境中使用默认监听地址时,和浏览器访问 localhost 的方式不一致,导致连接被拒绝。

最终通过显式指定:

host: '127.0.0.1'

强制 Vite 使用 IPv4 回环地址,解决了本地 localhost / IPv6 解析带来的歧义问题。


八、一段适合直接放文章结尾的总结

如果你遇到 Vite 启动成功但浏览器打不开、控制台也没有报错 的情况,不要先怀疑 Vue 代码本身,优先检查开发服务器的监听地址。

在某些 Windows 或本机网络环境下,localhost 可能会涉及 IPv4 / IPv6 的解析差异,导致:

  • Vite 看起来已经启动
  • 浏览器却无法建立连接

这时候可以直接在 vite.config.ts 中指定:

server: {
  host: '127.0.0.1',
  port: 1111,
},

这样可以绕开 localhost 的解析歧义,让本地开发环境稳定恢复。

❌