普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月17日技术

React 19 useActionState 深度解析 & Vue 2.7 移植实战

2026年3月17日 14:20

React 19 useActionState 深度解析 & Vue 2.7 移植实战

一、useActionState 是什么?

useActionState 是 React 19 新增的 Hook,专门用于处理表单提交/异步操作的状态管理。它将「异步操作」「loading 状态」「错误处理」「结果数据」统一收敛到一个 Hook 中。

前身是 useFormState(React Canary),在 React 19 正式版中重命名为 useActionState

1.1 解决了什么痛点?

没有 useActionState 之前,处理一个表单提交你需要:

function LoginForm() {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [isPending, setIsPending] = useState(false);

  const handleSubmit = async (formData) => {
    setIsPending(true);
    setError(null);
    try {
      const result = await loginAPI(formData);
      setData(result);
    } catch (err) {
      setError(err.message);
    } finally {
      setIsPending(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {isPending && <Spinner />}
      {error && <p className="error">{error}</p>}
      {data && <p>欢迎, {data.username}</p>}
      {/* ... */}
    </form>
  );
}

三个 useState + try/catch + 手动管理 loading —— 模板代码太多了!

1.2 函数签名

const [state, formAction, isPending] = useActionState(
  actionFn,    // 异步操作函数
  initialState, // 初始状态
  permalink?    // 可选,用于 SSR 的永久链接
);

参数说明:

参数 类型 说明
actionFn (previousState, formData) => newState 异步操作函数,接收上一次的 state 和表单数据
initialState any 状态初始值
permalink string(可选) SSR 场景下 JS 未加载时的回退 URL

返回值说明:

返回值 类型 说明
state any 当前状态(action 执行后的返回值)
formAction function 传给 <form action={}> 或按钮的 action
isPending boolean 操作是否正在进行中

二、useActionState 使用方式详解

2.1 基础用法:表单提交

import { useActionState } from 'react';

// 异步 action 函数
async function submitForm(previousState, formData) {
  const username = formData.get('username');
  const password = formData.get('password');

  try {
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify({ username, password }),
      headers: { 'Content-Type': 'application/json' },
    });

    if (!response.ok) {
      return { 
        success: false, 
        message: '登录失败:' + response.statusText,
        data: null 
      };
    }

    const data = await response.json();
    return { 
      success: true, 
      message: '登录成功!', 
      data 
    };
  } catch (err) {
    return { 
      success: false, 
      message: '网络错误:' + err.message,
      data: null 
    };
  }
}

// 初始状态
const initialState = {
  success: false,
  message: '',
  data: null,
};

function LoginForm() {
  const [state, formAction, isPending] = useActionState(
    submitForm,
    initialState
  );

  return (
    <form action={formAction}>
      <h2>用户登录</h2>

      {/* 显示操作结果 */}
      {state.message && (
        <div className={state.success ? 'success' : 'error'}>
          {state.message}
        </div>
      )}

      <div>
        <label htmlFor="username">用户名:</label>
        <input id="username" name="username" required />
      </div>

      <div>
        <label htmlFor="password">密码:</label>
        <input id="password" name="password" type="password" required />
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? '登录中...' : '登录'}
      </button>

      {/* 展示登录成功后的用户信息 */}
      {state.success && state.data && (
        <div className="user-info">
          <p>欢迎回来,{state.data.username}!</p>
          <p>角色:{state.data.role}</p>
        </div>
      )}
    </form>
  );
}

2.2 非表单场景:普通按钮操作

useActionState 不仅限于表单,任何异步操作都可以用:

import { useActionState } from 'react';

async function addToCart(previousState, productId) {
  try {
    const res = await fetch('/api/cart', {
      method: 'POST',
      body: JSON.stringify({ productId }),
      headers: { 'Content-Type': 'application/json' },
    });
    const cart = await res.json();
    return {
      success: true,
      itemCount: cart.items.length,
      message: '已加入购物车',
    };
  } catch (err) {
    return {
      ...previousState,
      success: false,
      message: '操作失败',
    };
  }
}

function ProductCard({ product }) {
  const [cartState, addAction, isPending] = useActionState(
    addToCart,
    { success: false, itemCount: 0, message: '' }
  );

  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>¥{product.price}</p>

      {/* 注意:非表单场景通过手动调用 */}
      <button 
        onClick={() => addAction(product.id)} 
        disabled={isPending}
      >
        {isPending ? '添加中...' : '加入购物车'}
      </button>

      {cartState.message && <p>{cartState.message}</p>}
      {cartState.success && <p>购物车共 {cartState.itemCount} 件</p>}
    </div>
  );
}

2.3 利用 previousState 做累积操作

actionFn 的第一个参数是上一次返回的 state,这非常适合做列表追加:

async function loadMoreComments(previousState, page) {
  const res = await fetch(`/api/comments?page=${page}`);
  const newComments = await res.json();

  return {
    comments: [...previousState.comments, ...newComments],
    currentPage: page,
    hasMore: newComments.length === 10,
  };
}

function CommentList() {
  const [state, loadMore, isPending] = useActionState(
    loadMoreComments,
    { comments: [], currentPage: 0, hasMore: true }
  );

  return (
    <div>
      {state.comments.map(c => (
        <div key={c.id}>{c.content}</div>
      ))}
      
      {state.hasMore && (
        <button
          onClick={() => loadMore(state.currentPage + 1)}
          disabled={isPending}
        >
          {isPending ? '加载中...' : '加载更多'}
        </button>
      )}
    </div>
  );
}

2.4 与 useFormStatus 配合使用(完整表单方案)

import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';

// 子组件:自动感知表单提交状态
function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? '提交中...' : '提交'}
    </button>
  );
}

// 父组件
function ContactForm() {
  const [state, formAction] = useActionState(
    async (prev, formData) => {
      const res = await fetch('/api/contact', {
        method: 'POST',
        body: formData,
      });
      if (res.ok) return { success: true, message: '提交成功!' };
      return { success: false, message: '提交失败' };
    },
    { success: false, message: '' }
  );

  return (
    <form action={formAction}>
      <input name="email" type="email" placeholder="邮箱" required />
      <textarea name="message" placeholder="留言内容" required />
      
      {state.message && (
        <p style={{ color: state.success ? 'green' : 'red' }}>
          {state.message}
        </p>
      )}
      
      <SubmitButton />
    </form>
  );
}

三、useActionState 的内部原理(简化)

调用 useActionState(actionFn, initialState)
         │
         ▼
  内部创建一个 reducer:
  ┌────────────────────────────────┐
  │  state = initialState          │
  │  isPending = false             │
  │                                │
  │  当 formAction 被触发时:       │
  │    1. isPending = true         │
  │    2. result = await actionFn( │
  │         previousState,         │
  │         formData/payload       │
  │       )                        │
  │    3. state = result           │
  │    4. isPending = false        │
  │    5. 触发重新渲染             │
  └────────────────────────────────┘
         │
         ▼
  返回 [state, formAction, isPending]

本质useActionStateuseReducer + useTransition 的封装,用 transition 包裹异步操作来自动管理 pending 状态。


四、在 Vue 2.7.15 中实现 useActionState

Vue 2.7 引入了 Composition API(refcomputedwatch 等),这让我们有能力实现类似的功能。

4.1 核心实现

创建文件 src/composables/useActionState.js

import { ref, shallowRef, readonly } from 'vue';

/**
 * Vue 2.7 版本的 useActionState
 * 模拟 React 19 的 useActionState Hook
 *
 * @param {Function} actionFn - 异步操作函数 (previousState, payload) => newState
 * @param {any} initialState - 初始状态
 * @param {Object} options - 可选配置
 * @param {Function} options.onSuccess - 成功回调
 * @param {Function} options.onError - 失败回调
 * @param {boolean} options.resetOnError - 失败时是否重置为初始状态
 * @returns {Object} { state, action, isPending, reset }
 */
export function useActionState(actionFn, initialState, options = {}) {
  const {
    onSuccess = null,
    onError = null,
    resetOnError = false,
  } = options;

  // ---- 核心状态 ----

  // 使用 shallowRef 存储 state(避免深层对象的深度响应式带来的性能问题)
  // 类似 React 中 useState 的 state
  const state = shallowRef(
    typeof initialState === 'function' ? initialState() : initialState
  );

  // 是否正在执行中(对应 React 的 isPending)
  const isPending = ref(false);

  // 内部:防止竞态条件的版本号
  let actionVersion = 0;

  // ---- 核心方法 ----

  /**
   * 触发 action(对应 React 返回的 formAction)
   * @param {any} payload - 传递给 actionFn 的数据(对应 React 中的 formData)
   * @returns {Promise<any>} action 的执行结果
   */
  async function action(payload) {
    // 递增版本号,用于处理竞态
    const currentVersion = ++actionVersion;

    // 设置 loading 状态
    isPending.value = true;

    try {
      // 调用用户传入的 actionFn
      // 参数1: previousState(上一次的状态,对应 React 的 previousState)
      // 参数2: payload(用户传入的数据,对应 React 的 formData)
      const result = await actionFn(state.value, payload);

      // 竞态检查:如果在等待期间又触发了新的 action,
      // 则丢弃旧的结果(只保留最新一次的结果)
      if (currentVersion !== actionVersion) {
        return;
      }

      // 更新状态为 action 的返回值
      state.value = result;

      // 触发成功回调
      if (onSuccess && typeof onSuccess === 'function') {
        onSuccess(result);
      }

      return result;
    } catch (error) {
      // 竞态检查
      if (currentVersion !== actionVersion) {
        return;
      }

      // 失败时是否重置状态
      if (resetOnError) {
        state.value = typeof initialState === 'function'
          ? initialState()
          : initialState;
      }

      // 触发失败回调
      if (onError && typeof onError === 'function') {
        onError(error);
      }

      // 将错误继续抛出,让调用方也能 catch
      throw error;
    } finally {
      // 竞态检查:只有最新的 action 才能关闭 loading
      if (currentVersion === actionVersion) {
        isPending.value = false;
      }
    }
  }

  /**
   * 重置为初始状态(React 的 useActionState 没有这个,这是增强功能)
   */
  function reset() {
    state.value = typeof initialState === 'function'
      ? initialState()
      : initialState;
    isPending.value = false;
    actionVersion++; // 取消正在进行的 action
  }

  // ---- 返回值 ----
  // 对应 React 的 [state, formAction, isPending]
  // Vue 中用对象返回,更符合 Composition API 惯例
  return {
    state: readonly(state),  // 只读,防止外部直接修改(必须通过 action 更新)
    action,                  // 触发操作的函数
    isPending: readonly(isPending), // 只读的 loading 状态
    reset,                   // 重置方法(增强功能)
  };
}

4.2 代码逐行解析

为什么用 shallowRef 而不是 ref

const state = shallowRef(initialState);

ref 会对对象进行深度响应式转换(递归 Proxy),如果 state 是一个包含大量数据的对象(如列表数据),会有性能开销。shallowRef 只对 .value 本身做响应式,赋新值时才触发更新——和 React 的 useState(引用比较)行为一致。

竞态处理是什么意思?

let actionVersion = 0;

async function action(payload) {
  const currentVersion = ++actionVersion;
  // ... await 异步操作
  if (currentVersion !== actionVersion) return; // 不是最新的,丢弃
}

场景:用户快速点击了3次提交按钮,发出了3个请求。第1个请求最慢,第3个最快。如果不做竞态处理,最终 state 会被第1个请求的结果覆盖(而不是第3个),导致数据不一致。

为什么返回 readonly

return {
  state: readonly(state),
  isPending: readonly(isPending),
};

模拟 React 中 state 不可直接修改的约束——状态只能通过 action 函数更新,不能在外部直接 state.value = xxx


4.3 支持表单的增强版本

为了更好地配合 Vue 的表单处理,我们增加一个专门处理表单提交的包装:

创建文件 src/composables/useFormActionState.js

import { useActionState } from './useActionState';

/**
 * 表单专用版本,自动收集表单数据
 * 模拟 React 19 中 <form action={formAction}> 的行为
 *
 * @param {Function} actionFn - (previousState, formDataObject) => newState
 * @param {any} initialState - 初始状态
 * @param {Object} options - 配置项
 * @returns {Object} { state, handleSubmit, isPending, reset }
 */
export function useFormActionState(actionFn, initialState, options = {}) {
  const { state, action, isPending, reset } = useActionState(
    actionFn,
    initialState,
    options
  );

  /**
   * 表单提交处理函数
   * 可以直接绑定到 <form @submit="handleSubmit">
   * 自动收集 FormData 并转换为普通对象
   *
   * @param {Event} event - 表单提交事件
   */
  async function handleSubmit(event) {
    // 阻止默认提交行为
    event.preventDefault();

    // 获取表单元素
    const form = event.target;

    // 使用 FormData API 收集表单数据
    const formData = new FormData(form);

    // 将 FormData 转换为普通对象(方便使用)
    const formDataObject = {};
    formData.forEach((value, key) => {
      // 处理同名字段(如多选框)
      if (formDataObject[key] !== undefined) {
        if (!Array.isArray(formDataObject[key])) {
          formDataObject[key] = [formDataObject[key]];
        }
        formDataObject[key].push(value);
      } else {
        formDataObject[key] = value;
      }
    });

    // 调用 action,传入收集到的表单数据
    try {
      await action(formDataObject);
    } catch (error) {
      // 错误已在 useActionState 内部处理
      console.error('[useFormActionState] 表单提交失败:', error);
    }
  }

  return {
    state,
    handleSubmit,  // 直接绑定到 @submit
    isPending,
    reset,
  };
}

4.4 完整使用示例

示例1:登录表单

src/components/LoginForm.vue

<template>
  <form @submit="handleSubmit" class="login-form">
    <h2>用户登录</h2>

    <!-- 状态提示 -->
    <div v-if="state.message" :class="['alert', state.success ? 'success' : 'error']">
      {{ state.message }}
    </div>

    <!-- 用户名 -->
    <div class="form-group">
      <label for="username">用户名:</label>
      <input
        id="username"
        name="username"
        type="text"
        required
        :disabled="isPending"
        placeholder="请输入用户名"
      />
    </div>

    <!-- 密码 -->
    <div class="form-group">
      <label for="password">密码:</label>
      <input
        id="password"
        name="password"
        type="password"
        required
        :disabled="isPending"
        placeholder="请输入密码"
      />
    </div>

    <!-- 记住我 -->
    <div class="form-group">
      <label>
        <input name="remember" type="checkbox" value="true" />
        记住我
      </label>
    </div>

    <!-- 提交按钮 -->
    <button type="submit" :disabled="isPending" class="btn-primary">
      <span v-if="isPending" class="spinner"></span>
      {{ isPending ? '登录中...' : '登录' }}
    </button>

    <!-- 登录成功后显示用户信息 -->
    <div v-if="state.success && state.data" class="user-info">
      <p>🎉 欢迎回来,{{ state.data.username }}!</p>
      <p>角色:{{ state.data.role }}</p>
      <button type="button" @click="reset">退出</button>
    </div>
  </form>
</template>

<script>
import { useFormActionState } from '@/composables/useFormActionState';

// 模拟登录 API
async function loginAPI(previousState, formData) {
  // 模拟网络延迟
  await new Promise(resolve => setTimeout(resolve, 1500));

  const { username, password } = formData;

  // 模拟校验
  if (username === 'admin' && password === '123456') {
    return {
      success: true,
      message: '登录成功!',
      data: {
        username: 'admin',
        role: '管理员',
        token: 'mock-token-xxx',
      },
    };
  }

  // 登录失败:返回新 state(不是抛错误!)
  // 这和 React 的 useActionState 设计一致——通过返回值表达状态
  return {
    success: false,
    message: '用户名或密码错误',
    data: null,
  };
}

export default {
  name: 'LoginForm',

  setup() {
    // 初始状态
    const initialState = {
      success: false,
      message: '',
      data: null,
    };

    // 使用 useFormActionState —— 一行搞定所有状态管理!
    const { state, handleSubmit, isPending, reset } = useFormActionState(
      loginAPI,
      initialState,
      {
        onSuccess: (result) => {
          if (result.success) {
            console.log('登录成功,token:', result.data.token);
          }
        },
      }
    );

    return {
      state,
      handleSubmit,
      isPending,
      reset,
    };
  },
};
</script>

<style scoped>
.login-form {
  max-width: 400px;
  margin: 40px auto;
  padding: 24px;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
}
.form-group {
  margin-bottom: 16px;
}
.form-group label {
  display: block;
  margin-bottom: 4px;
  font-weight: 500;
}
.form-group input[type="text"],
.form-group input[type="password"] {
  width: 100%;
  padding: 8px 12px;
  border: 1px solid #ccc;
  border-radius: 4px;
  box-sizing: border-box;
}
.alert {
  padding: 10px 14px;
  border-radius: 4px;
  margin-bottom: 16px;
}
.alert.success {
  background: #d4edda;
  color: #155724;
  border: 1px solid #c3e6cb;
}
.alert.error {
  background: #f8d7da;
  color: #721c24;
  border: 1px solid #f5c6cb;
}
.btn-primary {
  width: 100%;
  padding: 10px;
  background: #1890ff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
}
.btn-primary:disabled {
  background: #91caff;
  cursor: not-allowed;
}
.spinner {
  width: 16px;
  height: 16px;
  border: 2px solid #ffffff80;
  border-top-color: white;
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
}
@keyframes spin {
  to { transform: rotate(360deg); }
}
.user-info {
  margin-top: 16px;
  padding: 12px;
  background: #f0f9ff;
  border-radius: 4px;
}
</style>
示例2:加载更多列表(利用 previousState 累积)

src/components/CommentList.vue

<template>
  <div class="comment-list">
    <h2>评论列表</h2>

    <!-- 评论列表 -->
    <div
      v-for="comment in state.comments"
      :key="comment.id"
      class="comment-item"
    >
      <strong>{{ comment.author }}</strong>
      <p>{{ comment.content }}</p>
      <span class="time">{{ comment.time }}</span>
    </div>

    <!-- 空状态 -->
    <p v-if="!state.comments.length && !isPending">暂无评论</p>

    <!-- 加载更多按钮 -->
    <button
      v-if="state.hasMore"
      @click="loadMore(state.currentPage + 1)"
      :disabled="isPending"
      class="btn-load-more"
    >
      {{ isPending ? '加载中...' : '加载更多' }}
    </button>

    <p v-if="!state.hasMore && state.comments.length" class="no-more">
      —— 没有更多了 ——
    </p>
  </div>
</template>

<script>
import { onMounted } from 'vue';
import { useActionState } from '@/composables/useActionState';

// 模拟获取评论 API
async function fetchComments(previousState, page) {
  await new Promise(resolve => setTimeout(resolve, 800));

  // 模拟分页数据
  const pageSize = 5;
  const total = 13;
  const start = (page - 1) * pageSize;

  const newComments = Array.from(
    { length: Math.min(pageSize, total - start) },
    (_, i) => ({
      id: start + i + 1,
      author: `用户${start + i + 1}`,
      content: `这是第 ${start + i + 1} 条评论的内容`,
      time: new Date(Date.now() - (start + i) * 3600000).toLocaleString(),
    })
  );

  return {
    // 关键:利用 previousState 累积数据!
    comments: [...previousState.comments, ...newComments],
    currentPage: page,
    hasMore: start + pageSize < total,
  };
}

export default {
  name: 'CommentList',

  setup() {
    const { state, action: loadMore, isPending } = useActionState(
      fetchComments,
      {
        comments: [],
        currentPage: 0,
        hasMore: true,
      }
    );

    // 组件挂载时自动加载第一页
    onMounted(() => {
      loadMore(1);
    });

    return {
      state,
      loadMore,
      isPending,
    };
  },
};
</script>

<style scoped>
.comment-list {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
}
.comment-item {
  padding: 12px;
  border-bottom: 1px solid #eee;
}
.comment-item strong {
  color: #333;
}
.comment-item p {
  margin: 8px 0 4px;
  color: #555;
}
.time {
  font-size: 12px;
  color: #999;
}
.btn-load-more {
  display: block;
  width: 100%;
  padding: 12px;
  margin-top: 16px;
  background: white;
  border: 1px solid #1890ff;
  color: #1890ff;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}
.btn-load-more:disabled {
  border-color: #91caff;
  color: #91caff;
  cursor: not-allowed;
}
.no-more {
  text-align: center;
  color: #999;
  margin-top: 16px;
}
</style>

Vue2 vs Vue3 全面对比(含代码示例+迁移指南)

作者 远山枫谷
2026年3月17日 14:16

Vue2 vs Vue3 全面对比(含代码示例+迁移指南)

作为前端开发者,Vue框架的升级迭代一直是我们关注的重点。从2019年Vue3发布beta版,到如今Vue3成为新项目的首选,两者之间的差异不仅体现在底层实现,更贯穿了开发流程的方方面面。今天我们就来全面拆解Vue2与Vue3的核心区别,结合代码示例帮你快速吃透差异,轻松应对项目迁移与开发选型。

本文将从「核心架构」「响应式原理」「语法特性」「性能优化」「生态工具」「迁移实践」6大维度展开,覆盖日常开发中90%以上会遇到的差异点,新手可快速入门,老开发者可查漏补缺。

一、核心架构:Options API vs Composition API

这是Vue2与Vue3最本质的区别,核心在于「代码组织方式」的不同——Vue2采用Options API(选项式API),Vue3引入Composition API(组合式API),同时兼容Options API,兼顾老项目迁移与新项目开发。

1. Vue2:Options API

Options API通过「选项」划分代码逻辑,将组件的逻辑拆分为data、methods、computed、watch、生命周期钩子等选项,结构固定,入门门槛低,但在复杂组件中会出现「逻辑分散」的问题。

比如一个包含数据请求、表单校验、状态管理的复杂组件,相关逻辑会分散在data、methods、mounted等不同选项中,后期维护时需要在多个选项间来回切换,可读性和可复用性较差。

<script>
// Vue2 Options API 示例
export default {
  // 数据
  data() {
    return {
      userInfo: null,
      loading: false,
      error: ''
    }
  },
  // 计算属性
  computed: {
    isUserLoaded() {
      return !!this.userInfo
    }
  },
  // 生命周期钩子
  mounted() {
    this.getUserInfo()
  },
  // 方法
  methods: {
    async getUserInfo() {
      this.loading = true
      try {
        const res = await fetch('/api/user')
        this.userInfo = await res.json()
      } catch (err) {
        this.error = err.message
      } finally {
        this.loading = false
      }
    }
  }
}
</script>

2. Vue3:Composition API

Composition API以「功能」为核心,通过组合函数(Composable)将相关逻辑聚合在一起,打破了Options API的选项限制,让代码组织更灵活,尤其适合复杂组件和逻辑复用。

Vue3中可以通过setup函数(或

<script setup>
// Vue3 Composition API 示例(<script setup>语法糖,推荐)
import { ref, computed, onMounted } from 'vue'

// 1. 定义响应式数据(替代data)
const userInfo = ref(null)
const loading = ref(false)
const error = ref('')

// 2. 计算属性(替代computed)
const isUserLoaded = computed(() => !!userInfo.value)

// 3. 逻辑抽离(可单独抽离为组合函数,供其他组件复用)
const getUserInfo = async () => {
  loading.value = true
  try {
    const res = await fetch('/api/user')
    userInfo.value = await res.json()
  } catch (err) {
    error.value = err.message
  } finally {
    loading.value = false
  }
}

// 4. 生命周期钩子(替代mounted)
onMounted(() => {
  getUserInfo()
})
</script>

核心差异总结

维度 Vue2(Options API) Vue3(Composition API)
代码组织 按选项划分(data、methods等) 按功能聚合(组合函数)
逻辑复用 依赖mixins,易出现命名冲突 组合函数,无命名冲突,复用性更强
复杂组件 逻辑分散,维护困难 逻辑聚合,可读性高
入门难度 低,结构固定 稍高,需理解组合逻辑

二、响应式原理:Object.defineProperty vs Proxy

响应式是Vue的核心特性,Vue2和Vue3的响应式实现方式完全不同,这也是Vue3性能提升的关键原因之一。两者的核心差异在于「数据劫持的方式」,Vue2基于Object.defineProperty,Vue3基于Proxy+Reflect,后者从根本上解决了前者的诸多局限性。

1. Vue2:Object.defineProperty

Vue2通过Object.defineProperty劫持对象的getter和setter方法,实现对数据变化的监听。但这种方式存在3个明显的局限性,也是开发中常遇到的痛点:

  • 无法监听对象新增/删除的属性(需通过Vue.set、Vue.delete手动触发响应);
  • 无法监听数组的索引变化和长度变化(需重写数组方法,如push、splice等);
  • 只能劫持对象的属性,无法直接劫持整个对象,初始化时需递归遍历对象所有属性,性能开销较大。
// Vue2 响应式核心实现(简化版)
function defineReactive(obj, key, value) {
  // 递归监听嵌套对象
  if (typeof value === 'object' && value !== null) {
    observe(value)
  }
  Object.defineProperty(obj, key, {
    get() {
      // 依赖收集
      track(obj, key)
      return value
    },
    set(newValue) {
      if (newValue === value) return
      value = newValue
      // 触发更新
      trigger(obj, key)
    }
  })
}

// 监听对象
function observe(obj) {
  if (typeof obj !== 'object' || obj === null) return
  Object.keys(obj).forEach(key => {
    defineReactive(obj, key, obj[key])
  })
}

// 痛点示例:新增属性无法监听
const obj = { name: 'Vue2' }
observe(obj)
obj.age = 3 // 新增属性,无法触发响应式更新
Vue.set(obj, 'age', 3) // 需手动调用Vue.set

2. Vue3:Proxy + Reflect

Vue3放弃了Object.defineProperty,转而使用ES6新增的Proxy(代理)和Reflect(反射),从根本上解决了Vue2的局限性。Proxy可以直接代理整个对象,而非单个属性,同时支持监听对象的所有操作(新增、删除、数组变化等),且无需递归遍历,性能更优。

Reflect则与Proxy相辅相成,提供了一套用于操作对象的方法集合,能更优雅地处理代理过程中的对象操作,比如自动传递this上下文、统一返回操作结果等,让代码更健壮。

// Vue3 响应式核心实现(简化版)
function reactive(obj) {
  return new Proxy(obj, {
    // 读取属性时触发
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver)
      // 依赖收集
      track(target, key)
      // 懒代理:嵌套对象访问时才创建代理,减少初始化性能开销
      if (typeof result === 'object' && result !== null) {
        return reactive(result)
      }
      return result
    },
    // 设置属性时触发
    set(target, key, value, receiver) {
      const oldValue = Reflect.get(target, key, receiver)
      const result = Reflect.set(target, key, value, receiver)
      if (oldValue !== value) {
        // 触发更新
        trigger(target, key)
      }
      return result
    },
    // 删除属性时触发
    deleteProperty(target, key) {
      const result = Reflect.deleteProperty(target, key)
      // 触发更新
      trigger(target, key)
      return result
    }
  })
}

// 优势示例:自动监听新增/删除属性、数组变化
const obj = reactive({ name: 'Vue3' })
obj.age = 1 // 新增属性,自动触发响应
delete obj.name // 删除属性,自动触发响应

const arr = reactive([1, 2, 3])
arr.push(4) // 数组操作,自动触发响应
arr[0] = 0 // 数组索引修改,自动触发响应

响应式差异总结

特性 Vue2(Object.defineProperty) Vue3(Proxy+Reflect)
对象新增属性 不支持,需手动调用Vue.set 支持,自动监听
对象删除属性 不支持,需手动调用Vue.delete 支持,自动监听
数组索引/长度变化 不支持,需使用重写方法 支持,自动监听
嵌套对象监听 初始化时递归遍历,性能差 懒代理,访问时才监听,性能优
数据类型支持 仅支持对象/数组 支持对象、数组、Map、Set等

三、生命周期钩子:命名调整与使用方式变化

Vue3的生命周期钩子基本沿用了Vue2的逻辑,但进行了部分命名调整,同时适配Composition API的使用方式,新增了setup钩子(Composition API的入口),废弃了部分钩子。

1. 生命周期钩子对应关系

Vue2(Options API) Vue3(Options API) Vue3(Composition API,需导入)
beforeCreate beforeCreate(兼容) setup(替代,执行时机更早)
created created(兼容) setup(替代)
beforeMount beforeMount(兼容) onBeforeMount
mounted mounted(兼容) onMounted
beforeUpdate beforeUpdate(兼容) onBeforeUpdate
updated updated(兼容) onUpdated
beforeDestroy beforeUnmount(重命名) onBeforeUnmount
destroyed unmounted(重命名) onUnmounted
activated activated(兼容) onActivated
deactivated deactivated(兼容) onDeactivated

2. 核心变化说明

  • setup钩子:替代beforeCreate和created,是Composition API的入口,执行时机在beforeCreate之前,此时组件实例尚未创建,无法访问this(Vue3中Composition API不依赖this);
  • 钩子重命名:beforeDestroy → beforeUnmount,destroyed → unmounted,更贴合语义(组件卸载而非销毁);
  • Composition API中使用钩子:需从vue中导入对应的钩子函数,然后在setup中调用,支持多次调用(按调用顺序执行)。
<script setup>
// Vue3 Composition API 生命周期使用示例
import { onMounted, onBeforeUnmount, onUpdated } from 'vue'

// 组件挂载后执行
onMounted(() => {
  console.log('组件挂载完成')
})

// 组件更新前执行
onUpdated(() => {
  console.log('组件更新完成')
})

// 组件卸载前执行
onBeforeUnmount(() => {
  console.log('组件即将卸载')
})
</script>

四、模板语法:增强与简化

Vue3的模板语法基本兼容Vue2,但新增了部分实用特性,同时简化了部分语法,提升开发效率,减少冗余代码。

1. 新增特性

(1)多根节点(Fragments)

Vue2中组件模板只能有一个根节点(需用div等标签包裹),否则会报错;Vue3支持多根节点,无需额外包裹,减少DOM层级冗余,优化渲染性能。

// Vue2(错误示例:多根节点)
<template>
  <h1>Vue2</h1>
  <p>只能有一个根节点</p>
</template>

// Vue2(正确示例:需包裹div)
<template>
  <div>
    <h1>Vue2</h1>
    <p>只能有一个根节点</p>
  </div>
</template>

// Vue3(正确示例:多根节点)
<template>
  <h1>Vue3</h1>
  <p>支持多根节点,无需包裹</p>
</template>
(2)v-model 语法简化与增强

Vue2中v-model本质是:value + @input的语法糖,且只能绑定一个值;Vue3简化了v-model的使用,同时支持多值绑定、自定义修饰符,统一了组件通信的语法。

// Vue2 v-model 使用(单一绑定)
<template>
  <input v-model="value">
  // 等价于
  <input :value="value" @input="value = $event.target.value">
</template>

// Vue3 v-model 使用(多值绑定+自定义修饰符)
<template>
  // 1. 单一绑定(简化,无需手动处理$event)
  <input v-model="value">
  
  // 2. 多值绑定(绑定多个属性)
  <ChildComponent 
    v-model:name="name" 
    v-model:age="age"
  />
  
  // 3. 自定义修饰符(如v-model.trim)
  <input v-model.trim="value">
</template>
(3)动态指令参数

Vue3支持动态绑定指令参数,让指令使用更灵活,可根据数据动态切换指令的目标(如动态绑定v-bind、v-on的参数)。

<script setup>
import { ref } from 'vue'
const propName = ref('title')
const eventName = ref('click')
</script>

<template>
  // 动态绑定v-bind参数
  <div v-bind:[propName]="'Vue3动态指令'"></div>
  
  // 动态绑定v-on参数
  <button v-on:[eventName]="handleClick">点击</button>
</template>
(4)Teleport(瞬移组件)

Vue3新增Teleport组件,可将组件内容“瞬移”到页面的任意DOM节点中(如body),解决了弹窗、模态框等组件的层级问题,无需担心父组件的样式隔离影响。

<template>
  <teleport to="body">
    <div class="modal">
      这是一个弹窗,将被渲染到body中
    </div>
  </teleport>
</template>
(5)Suspense(异步组件占位)

Vue3新增Suspense组件,用于异步组件的加载占位,可在异步组件加载完成前显示loading状态,加载失败时显示错误提示,简化异步组件的处理逻辑。

<template>
  <suspense>
    <template #default>
      // 异步组件(需动态导入)
      <AsyncComponent />
    </template>
    <template #fallback>
      // 加载中占位
      <div>加载中...</div>
    </template>
  </suspense>
</template>

<script setup>
// 动态导入异步组件
const AsyncComponent = defineAsyncComponent(() => import('./AsyncComponent.vue'))
</script>

2. 废弃特性

  • v-on.native修饰符:Vue3中组件的原生事件无需使用.native修饰符,可直接绑定,若需区分组件自定义事件和原生事件,可通过emits选项声明自定义事件;
  • 过滤器(filter):Vue3废弃了过滤器,推荐使用计算属性或全局方法替代(过滤器的功能可完全通过计算属性实现,且更灵活);
  • v-bind.sync修饰符:Vue3中可用v-model:xxx替代,统一了双向绑定的语法。

五、性能优化:全方位提升

Vue3在性能上做了大量优化,相比Vue2,渲染速度提升30%+,打包体积减少约50%,主要优化点集中在编译优化、响应式优化、体积优化三个方面。

1. 编译优化

Vue3重写了模板编译逻辑,引入「静态标记(Patch Flag)」和「Block Tree」机制,实现按需更新,减少不必要的DOM操作:

  • 静态标记:编译模板时,对静态节点(不随数据变化的节点)添加标记,更新时跳过静态节点,只处理动态节点;
  • Block Tree:将模板拆分为多个Block(代码块),每个Block对应一个动态节点集合,更新时只遍历对应Block的动态节点,而非整个虚拟DOM树。

2. 响应式优化

如前文所述,Vue3使用Proxy+Reflect替代Object.defineProperty,实现以下优化:

  • 懒代理:嵌套对象只有在访问时才会创建代理,减少初始化时的性能开销;
  • 精准监听:只监听变化的属性,无需遍历整个对象,更新更高效;
  • 支持更多数据类型:Map、Set等集合类型也能实现响应式,满足更多开发场景。

3. 体积优化

Vue3支持Tree-shaking(树摇),只打包项目中用到的API,未使用的功能(如过滤器、v-on.native等)不会被打包,核心包体积从Vue2的约20KB缩减到最小10KB左右,大幅提升项目加载速度。

六、TypeScript支持:从兼容到原生

Vue2对TypeScript的支持较差,需要通过vue-class-component、vue-property-decorator等第三方库实现TS支持,且类型推导不精准,开发体验不佳;Vue3则是基于TypeScript原生开发的,天生支持TS,类型推导更精准,开发体验大幅提升。

核心差异

  • Vue2:需额外配置第三方库,类型定义不完整,组件内this指向不明确,类型推导困难;
  • Vue3:原生支持TS,Composition API的函数式写法更易推导类型,defineProps、defineEmits等宏支持泛型定义,模板中表达式的类型错误可在编译时被捕获,且核心代码的类型定义更完善。
<script setup lang="ts">
// Vue3 + TS 示例
import { ref, computed } from 'vue'

// 1. 基础类型响应式数据
const count = ref<number>(0)

// 2. 复杂类型响应式数据
interface User {
  name: string
  age: number
}
const user = ref<User | null>(null)

// 3. 计算属性类型推导
const doubleCount = computed(() => count.value * 2) // 自动推导为number类型

// 4. 组件props类型定义(defineProps宏)
const props = defineProps<{
  title: string
  count?: number // 可选属性
}>()

// 5. 组件事件类型定义(defineEmits宏)
const emit = defineEmits<{
  (e: 'change', value: number): void
}>()
</script>

七、生态与工具链:全面升级

Vue3的生态系统也同步升级,核心工具和第三方库均已适配Vue3,同时新增了更高效的开发工具,提升开发体验。

1. 核心工具

工具 Vue2 Vue3
构建工具 Vue CLI(基于Webpack) Vite(推荐,基于ESBuild,冷启动更快)、Vue CLI(兼容)
路由 vue-router@3.x vue-router@4.x(适配Composition API,支持TS)
状态管理 Vuex@3.x Pinia(推荐,更轻量、支持TS、API更简洁)、Vuex@4.x(兼容)
UI组件库 Element UI、Vuetify@2.x Element Plus、Vuetify@3.x、Ant Design Vue@3.x

2. 全局API变化

Vue3对全局API进行了重构,从“全局挂载”改为“实例化挂载”,支持多实例隔离,避免全局污染,同时简化了部分API的使用。

// Vue2 全局API使用
import Vue from 'vue'
import App from './App.vue'

// 全局注册组件
Vue.component('MyComponent', MyComponent)

// 全局注册指令
Vue.directive('my-directive', {})

// 全局配置
Vue.config.productionTip = false

// 创建实例
new Vue({
  render: h => h(App)
}).$mount('#app')

// Vue3 全局API使用(实例化方式)
import { createApp } from 'vue'
import App from './App.vue'

// 创建app实例
const app = createApp(App)

// 实例注册组件
app.component('MyComponent', MyComponent)

// 实例注册指令
app.directive('my-directive', {})

// 实例配置
app.config.productionTip = false

// 挂载实例
app.mount('#app')

八、实战迁移指南:从Vue2到Vue3

对于现有Vue2项目,无需一次性全部迁移,可采用“渐进式迁移”策略,逐步替换组件和逻辑,降低迁移成本。以下是具体迁移步骤和注意事项:

1. 迁移前准备

  • 检查依赖兼容性:升级核心依赖(Vue、vue-router、Vuex/Pinia),确保第三方组件库和插件支持Vue3(如Element UI替换为Element Plus);
  • 检查语法兼容性:移除Vue2中废弃的特性(过滤器、v-on.native、v-bind.sync等),替换为Vue3的替代方案;
  • 创建迁移分支:建议在Git中创建专门的迁移分支,避免影响主分支的正常开发。

2. 核心依赖升级命令

# 卸载Vue2相关依赖
npm remove vue vue-router vuex

# 安装Vue3相关依赖
npm install vue@3.2.x vue-router@4.x pinia@2.x

# 安装Vue3编译器(若使用Vue CLI)
npm install @vue/compiler-sfc@3.2.x -D

3. 逐步迁移组件

  • 优先迁移简单组件(如公共组件、基础组件),再迁移复杂组件;
  • 将Options API组件逐步改为Composition API(使用
  • 替换响应式数据写法:将data中的数据替换为ref/reactive,methods中的方法改为普通函数,computed/watch替换为对应的Composition API。

4. 常见迁移问题解决

  • this指向问题:Vue3的Composition API中无this,需通过ref/reactive的.value访问响应式数据;
  • 组件通信问题:将emit替换为defineEmitsemit替换为defineEmits,props替换为defineProps,parent/parent/children替换为provide/inject;
  • 事件总线问题:Vue3废弃了on/on/off/$once,可使用mitt等第三方库实现事件总线功能。

九、总结:该选择Vue2还是Vue3?

经过全面对比,Vue3在核心架构、响应式原理、性能、TS支持等方面均优于Vue2,且完全兼容Vue2的Options API,是未来的主流方向。结合实际开发场景,给出以下选型建议:

  • 新项目:优先选择Vue3 + Vite + Pinia + TS,享受更高效的开发体验和更优的性能;
  • 现有Vue2项目:若项目稳定,无需强制迁移;若需要新增复杂功能或优化性能,可采用渐进式迁移策略,逐步升级;
  • 新手学习:直接学习Vue3,Composition API的思想更贴合现代前端开发,且未来就业需求更高。

Vue3的升级不仅是技术的迭代,更是开发理念的升级——从“按选项组织代码”到“按功能组合代码”,让开发更灵活、更高效、更易维护。希望本文能帮助你全面掌握Vue2与Vue3的区别,顺利完成项目迁移和技术升级!

最后,如果你在迁移过程中遇到问题,或者有其他Vue相关的疑问,欢迎在评论区留言交流~

React useState 深度源码原理解析

2026年3月17日 14:13

React useState 深度源码原理解析

前言

useState 是 React Hooks 中最基础、最常用的 Hook,它让函数组件拥有了管理状态的能力。但你是否想过:

  • 函数组件每次渲染都会重新执行,useState 是如何"记住"上一次的状态的?
  • 为什么 Hooks 不能写在条件语句或循环中?
  • setState 之后到底发生了什么?
  • React 内部是如何区分"首次挂载"和"后续更新"的?

本文将从源码层面,逐行拆解 useState 的完整实现链路,彻底搞懂它的底层原理。


一、从一个简单的例子开始

function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <button onClick={() => setCount(count + 1)}>
      点击了 {count} 次
    </button>
  );
}

每次点击按钮,count 加 1,组件重新渲染。表面上看非常简单,但背后涉及 React 的 Fiber 架构链表结构闭包机制调度系统


二、核心数据结构:Hook 链表

2.1 Fiber 节点与 Hook 的关系

React 内部为每个组件维护了一个 Fiber 节点(可以理解为组件的"身份证")。函数组件的所有 Hook 状态,都挂在 Fiber 节点的 memoizedState 属性上:

Fiber {
  memoizedState: Hook(链表头节点),
  stateNode: ...,
  ...
}

2.2 Hook 对象的结构

每次调用 useStateuseEffect 等 Hook,React 都会创建一个 Hook 对象,多个 Hook 通过 next 指针串成链表:

export type Hook = {
  memoizedState: any,    // 存储当前 Hook 的状态值
  baseState: any,         // 基础状态(用于更新合并计算)
  baseQueue: Update<any, any> | null,  // 基础更新队列
  queue: UpdateQueue<any, any> | null, // 当前 Hook 的更新队列
  next: Hook | null,      // 指向下一个 Hook 节点 → 形成链表
};

2.3 链表可视化

假设组件中有三个 useState

function App() {
  const [name, setName] = useState('React');
  const [age, setAge] = useState(18);
  const [visible, setVisible] = useState(true);
  // ...
}

React 内部会形成如下链表结构:

Fiber.memoizedState
      
  ┌──────────────┐     ┌──────────────┐     ┌──────────────┐
   Hook #1      │     │ Hook #2      │     │ Hook #3      │
   memoizedState│      memoizedState│      memoizedState│
     = 'React'  │────▶│   = 18       │────▶│   = true     
   queue: {...}        queue: {...}        queue: {...}  
   next ─────────┤      next ─────────┤      next: null   
  └──────────────┘     └──────────────┘     └──────────────┘

🔑 关键设计:React 选择链表而非数组来存储 Hook,原因有三:

  1. 动态扩展:组件中 Hook 数量不固定,链表无需预分配空间
  2. 顺序访问高效:配合指针按调用顺序逐个访问,时间复杂度 O(1)
  3. 组件隔离:每个组件的 Fiber 拥有独立的 Hook 链表,互不干扰

三、两套 Dispatcher:Mount vs Update

3.1 React 如何区分首次渲染和更新?

这是 useState 实现中最精妙的设计之一。React 维护了 两套完全不同的 Hook 实现,通过一个全局变量 ReactCurrentDispatcher 来切换:

// react 模块中,useState 的入口极其简洁
export function useState<S>(
  initialState: (() => S) | S
): [S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

这里的 dispatcher 会根据当前渲染阶段,指向不同的实现:

// 判断逻辑在 renderWithHooks 中
ReactCurrentDispatcher.current =
  current === null || current.memoizedState === null
    ? HooksDispatcherOnMount   // 首次挂载
    : HooksDispatcherOnUpdate; // 后续更新

3.2 两套 Dispatcher 的完整定义

Mount 阶段的 Dispatcher:

const HooksDispatcherOnMount: Dispatcher = {
  useState: mountState,
  useEffect: mountEffect,
  useRef: mountRef,
  useMemo: mountMemo,
  useCallback: mountCallback,
  useReducer: mountReducer,
  useContext: readContext,
  // ... 其他 Hook
};

Update 阶段的 Dispatcher:

const HooksDispatcherOnUpdate: Dispatcher = {
  useState: updateState,
  useEffect: updateEffect,
  useRef: updateRef,
  useMemo: updateMemo,
  useCallback: updateCallback,
  useReducer: updateReducer,
  useContext: readContext,
  // ... 其他 Hook
};

💡 设计意图:mount 时需要初始化 Hook 节点、创建链表;update 时只需按顺序复用已有节点、处理更新队列。将两套逻辑彻底分离,代码更清晰,性能也更优。


四、Mount 阶段:mountState 完整流程

当组件首次渲染时,useState 实际调用的是 mountState

4.1 mountState 源码

function mountState<S>(
  initialState: (() => S) | S
): [S, Dispatch<BasicStateAction<S>>] {
  // 第一步:创建 Hook 对象并挂到链表上
  const hook = mountWorkInProgressHook();

  // 第二步:处理初始值(支持函数式初始化)
  if (typeof initialState === 'function') {
    initialState = initialState();
  }

  // 第三步:赋值初始状态
  hook.memoizedState = hook.baseState = initialState;

  // 第四步:创建更新队列
  const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,        // 待处理的更新(环形链表)
    interleaved: null,    // 交错更新
    lanes: NoLanes,       // 优先级
    dispatch: null,       // setState 函数
    lastRenderedReducer: basicStateReducer, // 上一次的 reducer
    lastRenderedState: (initialState: any), // 上一次渲染的 state
  };
  hook.queue = queue;

  // 第五步:创建 dispatch 函数(即 setState)
  const dispatch: Dispatch<BasicStateAction<S>> = 
    (queue.dispatch = dispatchSetState.bind(
      null,
      currentlyRenderingFiber, // 绑定当前 Fiber
      queue,                    // 绑定更新队列
    ));

  // 第六步:返回 [state, setState]
  return [hook.memoizedState, dispatch];
}

4.2 mountWorkInProgressHook 详解

这个函数是 几乎所有 Hook 在 mount 阶段都会调用 的核心函数,负责创建 Hook 节点并串联链表:

function mountWorkInProgressHook(): Hook {
  // 创建一个全新的 Hook 对象
  const hook: Hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null,
  };

  if (workInProgressHook === null) {
    // 这是组件的第一个 Hook
    // 将其设为 Fiber 的 memoizedState(链表头)
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // 不是第一个 Hook,追加到链表末尾
    workInProgressHook = workInProgressHook.next = hook;
  }

  return workInProgressHook;
}

执行过程图解(以两个 useState 为例):

第一次调用 useState('React'):
  workInProgressHook = null
  → 创建 Hook#1Fiber.memoizedState = Hook#1workInProgressHook = Hook#1

第二次调用 useState(18):
  workInProgressHook = Hook#1
  → 创建 Hook#2
  → Hook#1.next = Hook#2workInProgressHook = Hook#2

最终链表:
  Fiber.memoizedState → Hook#1 → Hook#2 → null

4.3 关于函数式初始化

注意 mountState 中的这段逻辑:

if (typeof initialState === 'function') {
  initialState = initialState();
}

这就是为什么 useState 支持这样的写法:

// 惰性初始化:函数只在 mount 时执行一次
const [data, setData] = useState(() => {
  return expensiveComputation(props);
});

当初始值需要昂贵计算时(如从 localStorage 读取、解析 URL 参数),使用函数式初始化可以避免每次渲染都重复计算。


五、Update 阶段:updateState 完整流程

当调用 setState 触发重新渲染后,组件函数重新执行,此时 useState 调用的是 updateState

5.1 updateState 的秘密

function updateState<S>(
  initialState: (() => S) | S
): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, initialState);
}

惊喜! useState 在更新阶段其实就是 useReducer!它使用了一个内置的 basicStateReducer

function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  return typeof action === 'function' ? action(state) : action;
}

这个 reducer 的逻辑非常简单:

  • 如果 action 是函数(函数式更新),则执行 action(state) 得到新状态
  • 如果 action 是普通值(直接赋值),则直接用 action 作为新状态

这也解释了为什么:

// 直接赋值 → action = 5 → 返回 5
setCount(5);

// 函数式更新 → action = (prev) => prev + 1 → 返回 prev + 1
setCount(prev => prev + 1);

5.2 updateReducer 核心流程(简化)

function updateReducer<S, A>(
  reducer: (S, A) => S,
  initialArg: S,
): [S, Dispatch<A>] {
  // 第一步:获取当前 Hook 节点(按顺序从链表中取)
  const hook = updateWorkInProgressHook();
  const queue = hook.queue;

  // 第二步:取出更新队列
  const pending = queue.pending;
  queue.pending = null;

  // 第三步:遍历更新队列,依次计算新 state
  if (pending !== null) {
    let first = pending.next; // 环形链表的第一个节点
    let newState = hook.baseState;
    let update = first;
    
    do {
      // 应用每个 update
      const action = update.action;
      newState = reducer(newState, action);
      update = update.next;
    } while (update !== first); // 遍历完环形链表

    hook.memoizedState = newState;
    hook.baseState = newState;
  }

  // 第四步:返回最新的 state 和 dispatch
  const dispatch = queue.dispatch;
  return [hook.memoizedState, dispatch];
}

5.3 updateWorkInProgressHook

更新阶段不再创建新的 Hook 节点,而是 按顺序复用 已有的节点:

function updateWorkInProgressHook(): Hook {
  // 从 current Fiber 的 Hook 链表中,按顺序取下一个
  let nextCurrentHook;
  if (currentHook === null) {
    // 第一个 Hook
    const current = currentlyRenderingFiber.alternate;
    nextCurrentHook = current.memoizedState;
  } else {
    // 后续 Hook
    nextCurrentHook = currentHook.next;
  }
  
  currentHook = nextCurrentHook;
  
  // 基于 current Hook 创建新的 workInProgress Hook
  const newHook: Hook = {
    memoizedState: currentHook.memoizedState,
    baseState: currentHook.baseState,
    baseQueue: currentHook.baseQueue,
    queue: currentHook.queue,
    next: null,
  };

  // 同样串联成链表
  if (workInProgressHook === null) {
    currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
  } else {
    workInProgressHook = workInProgressHook.next = newHook;
  }

  return workInProgressHook;
}

⚠️ 这就是 Hook 不能写在条件语句中的根本原因! 更新阶段按 next 指针顺序取 Hook 节点,如果某个 Hook 因条件判断被跳过,后续所有 Hook 都会取到错误的节点,导致状态错乱。


六、setState(dispatchSetState)的完整实现

当我们调用 setCount(count + 1) 时,实际执行的是 dispatchSetState

6.1 源码分析

function dispatchSetState<S, A>(
  fiber: Fiber,     // 通过 bind 绑定的当前组件 Fiber
  queue: UpdateQueue<S, A>,  // 通过 bind 绑定的更新队列
  action: A,        // 用户传入的新值或更新函数
): void {

  // 第一步:获取更新优先级
  const lane = requestUpdateLane(fiber);

  // 第二步:创建 update 对象
  const update: Update<S, A> = {
    lane,
    action,         // 用户传入的值,如 count + 1 或 prev => prev + 1
    hasEagerState: false,
    eagerState: null,
    next: null,
  };

  // 第三步:判断是否在渲染过程中调用(极少数情况)
  if (isRenderPhaseUpdate(fiber)) {
    // 渲染阶段的更新,加入 queue.pending
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    // 第四步:将 update 加入环形链表
    const alternate = fiber.alternate;
    
    if (
      fiber.lanes === NoLanes &&
      (alternate === null || alternate.lanes === NoLanes)
    ) {
      // ⚡ 性能优化:Eager State
      // 如果当前没有待处理的更新,可以提前计算新 state
      const lastRenderedReducer = queue.lastRenderedReducer;
      if (lastRenderedReducer !== null) {
        const currentState = queue.lastRenderedState;
        const eagerState = lastRenderedReducer(currentState, action);
        
        update.hasEagerState = true;
        update.eagerState = eagerState;

        // 如果新旧 state 相同,直接跳过!不触发重新渲染
        if (Object.is(eagerState, currentState)) {
          // 🎉 Bailout! 省去一次不必要的渲染
          return;
        }
      }
    }

    // 第五步:将 update 入队
    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
    
    // 第六步:调度更新
    if (root !== null) {
      scheduleUpdateOnFiber(root, fiber, lane);
    }
  }
}

6.2 更新队列的环形链表结构

多次 setState 产生的 update 会形成环形链表

// 假设连续调用三次 setState
setCount(1);   // 创建 update1
setCount(2);   // 创建 update2  
setCount(3);   // 创建 update3

// 环形链表结构(queue.pending 指向最后一个):
//
//   queue.pending = update3
//          ↓
//   update3 → update1 → update2 → update3(回到起点)
//
// 特点:pending.next 就是第一个 update

为什么要用环形链表?因为 queue.pending 始终指向 最后一个 update,而 pending.next 就是 第一个 update,这样用 O(1) 的时间复杂度就能找到队头和队尾,非常高效。

6.3 Eager State 优化

dispatchSetState 中有一个重要的性能优化——Eager State(急切计算)

// 核心思路:如果当前没有排队的更新,就提前算出新 state
// 如果新旧 state 相同(通过 Object.is 比较),直接跳过渲染

if (Object.is(eagerState, currentState)) {
  return; // 不触发 scheduleUpdateOnFiber,省去整个渲染流程
}

这就是为什么:

const [count, setCount] = useState(0);

// 连续点击以下按钮,第二次点击不会触发重新渲染
<button onClick={() => setCount(0)}>设为0</button>

七、renderWithHooks:串联一切的入口

在 Reconciler 的 beginWork 阶段处理函数组件时,会调用 renderWithHooks,这是整个 Hook 系统的入口:

function renderWithHooks(
  current: Fiber | null,       // 上一次的 Fiber(null 表示首次渲染)
  workInProgress: Fiber,       // 当前正在构建的 Fiber
  Component: Function,         // 函数组件本身
  props: any,                  // props
  secondArg: any,              // context
  nextRenderLanes: Lanes,      // 渲染优先级
): any {

  // 1️⃣ 设置全局变量
  currentlyRenderingFiber = workInProgress;
  
  // 2️⃣ 重置 Fiber 上的 Hook 相关属性
  workInProgress.memoizedState = null;
  workInProgress.updateQueue = null;

  // 3️⃣ 切换 Dispatcher(核心!)
  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount    // mount:所有 Hook 走初始化逻辑
      : HooksDispatcherOnUpdate;  // update:所有 Hook 走更新逻辑

  // 4️⃣ 执行函数组件!
  // 此时组件内的 useState/useEffect 等会被依次调用
  let children = Component(props, secondArg);

  // 5️⃣ 重置全局变量
  currentlyRenderingFiber = null;
  currentHook = null;
  workInProgressHook = null;

  return children;
}

执行流程图:

renderWithHooks 被调用
    │
    ├──→ 判断 mount / update → 切换 Dispatcher
    │
    ├──→ 执行 Component(props)
    │        │
    │        ├──→ 第1个 useState → mountState / updateState
    │        ├──→ 第2个 useState → mountState / updateState
    │        ├──→ useEffect → mountEffect / updateEffect
    │        └──→ 返回 JSX
    │
    └──→ 清理全局变量,返回 children

八、完整流程图:从 setState 到页面更新

用户调用 setCount(count + 1)
       │
       ▼
dispatchSetState(fiber, queue, action)
       │
       ├── 创建 update 对象
       ├── Eager State 优化(如果新旧相同则 bailout)
       ├── update 加入环形链表 queue.pending
       └── scheduleUpdateOnFiber → 调度更新
                │
                ▼
         beginWork 阶段
                │
                ▼
    renderWithHooks(current, workInProgress, Component, props)
                │
       ┌────────┴────────┐
       │  判断 mount/update  │
       │  切换 Dispatcher    │
       └────────┬────────┘
                │
                ▼
      执行 Component(props)
                │
       ┌────────┴────────┐
       │  遇到 useState       │
       │  → updateState       │
       │  → updateReducer     │
       │  → 遍历 queue.pending │
       │  → 计算新 state       │
       │  → 返回 [newState, dispatch] │
       └────────┬────────┘
                │
                ▼
       返回新的 JSX(children)
                │
                ▼
         completeWork 阶段
                │
                ▼
         commitWork 阶段
                │
                ▼
         DOM 更新,页面刷新 ✅

九、手写一个简化版 useState

理解了原理之后,我们来手写一个简化版,加深理解:

let hookStates = [];  // 存储所有 hook 的状态
let hookIndex = 0;    // 当前 hook 的索引

function useState(initialState) {
  // 如果是首次渲染,使用初始值;否则使用已保存的状态
  const currentIndex = hookIndex;
  
  hookStates[currentIndex] = hookStates[currentIndex] !== undefined
    ? hookStates[currentIndex]
    : (typeof initialState === 'function' ? initialState() : initialState);

  // setState 通过闭包捕获 currentIndex
  function setState(newState) {
    // 支持函数式更新
    const nextState = typeof newState === 'function'
      ? newState(hookStates[currentIndex])
      : newState;
    
    // 如果值没变,不触发更新(简化的 bailout)
    if (Object.is(nextState, hookStates[currentIndex])) {
      return;
    }
    
    hookStates[currentIndex] = nextState;
    render(); // 触发重新渲染
  }

  hookIndex++; // 移动到下一个 hook 位置
  return [hookStates[currentIndex], setState];
}

// 模拟渲染
function render() {
  hookIndex = 0; // ⚠️ 每次渲染前重置索引!
  ReactDOM.render(<App />, document.getElementById('root'));
}

注意:这个简化版使用数组+索引来模拟,React 真实实现使用的是链表。但核心思想是一致的——按调用顺序存取状态


十、关键问题解答

Q1:为什么 Hook 不能写在条件语句中?

// ❌ 错误写法
function App() {
  if (someCondition) {
    const [a, setA] = useState(1); // Hook #1(可能不执行)
  }
  const [b, setB] = useState(2);   // Hook #2
}

因为 React 在更新时通过 next 指针(链表顺序)来匹配 Hook 节点。如果 mount 时创建了 [Hook#1, Hook#2],但 update 时 someCondition 为 false,第一个执行的 useState 就会拿到 Hook#1 的状态(本应是 Hook#2 的),导致状态完全错乱。

Q2:useState 和 useReducer 是什么关系?

从源码可以看到,useState 在更新阶段直接调用了 updateReducer

function updateState(initialState) {
  return updateReducer(basicStateReducer, initialState);
}

useState 本质上就是一个内置了 basicStateReduceruseReducer

Q3:连续多次 setState 会渲染多次吗?

function handleClick() {
  setCount(1);
  setName('React');
  setVisible(false);
}

不会。React 的事件处理中有 批量更新(batching) 机制。这三个 setState 产生的 update 会被放入各自的队列中,React 只会触发一次调度和渲染,在渲染时一次性处理所有 update。

Q4:函数式更新和直接赋值有什么区别?

// 直接赋值:基于闭包捕获的旧值
setCount(count + 1);
setCount(count + 1);
// 结果:count 只加了 1(两次都基于同一个旧值)

// 函数式更新:基于最新的 state
setCount(prev => prev + 1);
setCount(prev => prev + 1);
// 结果:count 加了 2(每次都基于上一次计算的结果)

从源码角度看,basicStateReducer 中:

  • 直接赋值:action 就是值本身,直接替换
  • 函数式更新:action(state) 会以当前最新 state 为参数计算

十一、总结

useState 的核心机制一览

机制 说明
Fiber.memoizedState 存储 Hook 链表头节点,是状态持久化的载体
Hook 链表 每个 Hook 调用对应一个节点,通过 next 串联
两套 Dispatcher mount 用 mountState 初始化,update 用 updateState 复用
环形更新队列 多次 setState 的 update 形成环形链表,统一在渲染时处理
Eager State 无待处理更新时提前计算,新旧相同则跳过渲染
basicStateReducer useState 本质是 useReducer 的语法糖
闭包绑定 dispatch 通过 bind 绑定了 Fiber 和 queue,确保更新指向正确

设计哲学

  1. 用链表保证顺序:这是 "Hook 不能写在条件语句中" 这条规则的根本原因
  2. 用两套实现分离关注点:mount 专注初始化,update 专注状态计算
  3. 用闭包桥接函数组件和 Fiber 架构:让无状态的函数拥有了持久化状态的能力
  4. 用 Eager State 做性能优化:在调度之前就短路不必要的渲染

理解了这些底层原理,你不仅能更自信地使用 useState,更能在遇到诡异的状态问题时快速定位原因。


如果觉得本文有帮助,欢迎点赞收藏,也欢迎在评论区交流讨论!

TypeScript 核心知识点

2026年3月17日 13:59

一、基础类型与类型注解

1. TypeScript 中的 anyunknownnevervoid 类型有什么区别?分别在什么场景下使用?

解析:

  • any:关闭类型检查,可以赋予任何值,调用任何方法。适合逐步迁移 JavaScript 项目或处理动态数据,但过度使用会丧失类型安全。
  • unknown:是类型安全的 any,表示未知类型。不能直接赋值给其他类型或调用方法,必须先通过类型断言或类型收窄(如 typeofinstanceof)才能使用。适合在无法预知数据类型时使用,强制开发者进行类型检查。
  • never:表示永远不会发生的类型,例如函数抛出异常或无限循环的返回值。在联合类型中用于过滤不可达分支,确保穷尽性检查。
  • void:表示函数没有返回值,或者返回 undefined。通常用于函数返回值类型注解。

示例:

let a: any = 1;
a = 'string'; // 可行
a.toUpperCase(); // 可行

let u: unknown = { name: 'test' };
// u.name; // 错误,unknown 不能直接访问属性
if (typeof u === 'object' && u !== null) {
  console.log((u as { name: string }).name); // 断言后可用
}

function error(msg: string): never {
  throw new Error(msg);
}

function log(): void {
  console.log('hello');
}

2. TypeScript 中 numberNumberstringString 等基本类型包装对象的区别是什么?应该使用哪个?

解析:
number 是 TypeScript 的基本类型,表示原始数值;Number 是 JavaScript 中的构造函数,属于对象类型。在 TypeScript 中应始终使用小写的原始类型(numberstringbooleansymbol 等),而不是它们的包装对象类型。使用包装对象类型会导致意外行为,例如 new Boolean(false) 是一个对象,在条件判断中为真。

示例:

let n1: number = 42;
let n2: Number = 42; // 不推荐,但赋值时原始类型会自动装箱,可以工作
// 但下面的用法会出错:
let b1: boolean = Boolean(0); // false,正确
let b2: boolean = new Boolean(0); // 错误,Type 'Boolean' is not assignable to type 'boolean'

3. 什么是元组(Tuple)?如何定义可选元素和剩余元素?元组与数组有何区别?

解析:
元组是一种固定长度、每个元素类型可以不同的数组。定义时明确列出每个位置的类型。

  • 可选元素用 ? 表示:[string, number?]
  • 剩余元素可以用 ... 表示:[string, ...number[]] 表示第一个元素是字符串,后面任意数量的数字。

元组与数组的区别:数组通常元素类型相同,长度不限;元组长度固定(除非有剩余元素),各位置类型可不同。访问越界索引时,元组会提示错误(除非开启 noUncheckedIndexedAccess)。

示例:

let tuple: [string, number] = ['hello', 42];
// tuple[2] = 10; // 错误,索引超出范围
let optionalTuple: [number, string?] = [1];
let restTuple: [boolean, ...string[]] = [true, 'a', 'b', 'c'];

二、接口与类型别名

4. interfacetype 的区别是什么?在什么情况下应该使用哪个?

解析:
共同点:都可以描述对象类型、函数类型,且可以互相扩展(交叉或继承)。
区别:

  • interface 只能描述对象类型,可以多次声明合并(declaration merging),扩展使用 extends
  • type 可以描述任何类型(原始类型、联合类型、交叉类型、元组等),不能声明合并,但可以通过交叉类型(&)扩展。
  • interface 更适合用于定义公共 API 或需要声明合并的场景(如给第三方库添加类型);type 更适合用于组合复杂类型、联合类型、映射类型等。

最佳实践:优先使用 interface 定义对象形状,当需要联合类型、元组或复杂类型表达式时使用 type

示例:

interface Person {
  name: string;
}
interface Person { // 合并
  age: number;
}

type Point = {
  x: number;
  y: number;
};
type ID = string | number; // 联合类型只能用 type

5. 如何定义一个可索引的类型(例如像数组一样通过数字索引访问,或像字典一样通过字符串索引)?

解析:
使用索引签名。通过 [index: type]: valueType 定义,其中 index 可以是 numberstring。注意数字索引签名返回的类型必须是字符串索引签名返回类型的子类型(因为 JavaScript 中数字索引最终也会转为字符串)。

示例:

interface StringArray {
  [index: number]: string; // 索引是数字,值必须是字符串
}

interface Dictionary {
  [key: string]: any; // 允许任意字符串键
  length: number;     // 可以添加其他明确属性,但必须符合索引签名的类型(any 兼容 number)
}

三、函数类型

6. TypeScript 中如何定义函数类型?包括普通函数、箭头函数、可选参数、默认参数和剩余参数。

解析:
函数类型可以通过两种方式定义:类型别名/接口描述,或直接在函数声明时注解。

  • 可选参数用 ? 表示,必须放在必需参数之后。
  • 默认参数不需要 ?,直接赋值,TypeScript 会根据默认值推断类型。
  • 剩余参数用 ... 表示,类型必须是数组。

示例:

// 类型别名定义函数类型
type Greet = (name: string, age?: number) => string;

// 函数声明
function greet(name: string, age: number = 18, ...hobbies: string[]): string {
  return `Hello ${name}, age ${age}, hobbies ${hobbies.join(', ')}`;
}

7. 什么是函数重载?TypeScript 中的函数重载与一些其他语言(如 Java)的重载有何不同?

解析:
TypeScript 的函数重载允许为同一个函数提供多个类型定义,根据传入参数的不同返回不同的类型。它不同于 Java 等语言的重载(多个实现),TypeScript 的重载只是类型层面的,最终实现只有一个,并且需要兼容所有重载签名。
实现时需要写一个联合类型的实现签名,并在内部根据参数类型进行逻辑分支。

示例:

function add(a: number, b: number): number;
function add(a: string, b: string): string;
function add(a: any, b: any): any {
  return a + b;
}
add(1, 2);      // number
add('1', '2');  // string

四、类与面向对象

8. TypeScript 中类的成员可见性修饰符 publicprivateprotectedreadonly 的作用是什么?与 JavaScript 私有字段 # 的区别?

解析:

  • public:默认,任何地方都可访问。
  • private:只能在当前类内部访问(TypeScript 编译后不强制,运行时仍然可访问)。
  • protected:在当前类和子类内部可访问。
  • readonly:属性只能在声明时或构造函数中初始化,之后只读。

JavaScript 私有字段(#field)是运行时的真正私有,不能在类外部访问,且编译后依然存在(ES2022+)。TypeScript 的 private 只在编译期检查,编译后不保留。

示例:

class Animal {
  public name: string;
  private age: number;
  protected type: string;
  readonly id: number;
  #secret: string; // ES2022 私有字段

  constructor(name: string, age: number, type: string, id: number, secret: string) {
    this.name = name;
    this.age = age;
    this.type = type;
    this.id = id;
    this.#secret = secret;
  }
}

9. 什么是抽象类?与接口有什么区别?什么场景下使用抽象类?

解析:
抽象类使用 abstract 关键字定义,可以包含抽象方法(无实现)和具体实现。抽象类不能被实例化,只能被继承。
接口只能定义结构,不能包含实现,且所有方法都是抽象的。
区别:

  • 抽象类可以提供默认实现和状态(字段),接口不能。
  • 类可以实现多个接口,但只能继承一个抽象类。
  • 抽象类更适用于多个类之间有共享代码的场景;接口适用于定义契约,无关实现。

示例:

abstract class Animal {
  abstract makeSound(): void;
  move(): void {
    console.log('roaming');
  }
}

class Dog extends Animal {
  makeSound() {
    console.log('bark');
  }
}

五、泛型

10. 什么是泛型?请举例说明泛型在函数、接口、类中的应用。

解析:
泛型允许定义函数、接口、类时不预先指定具体类型,而在使用时再指定,增强了代码的复用性和类型安全。

  • 泛型函数:function identity<T>(arg: T): T { return arg; }
  • 泛型接口:interface GenericFn<T> { (arg: T): T; }
  • 泛型类:class Stack<T> { private items: T[] = []; push(item: T) { ... } }

使用时可以显式指定类型(如 identity<number>(1)),或利用类型推断(identity(1) 推断为 number)。


11. 什么是泛型约束?如何实现?为什么需要泛型约束?

解析:
泛型约束通过 extends 关键字限制泛型参数必须满足某个条件,例如必须具有某些属性或继承某个类型。当泛型内部需要访问特定属性或方法时,必须通过约束确保传入的类型支持这些操作。

示例:

interface HasLength {
  length: number;
}
function logLength<T extends HasLength>(arg: T): T {
  console.log(arg.length); // 确保 T 有 length 属性
  return arg;
}
logLength([1, 2]); // 数组有 length
logLength('hello'); // 字符串有 length
// logLength(123); // 错误,number 没有 length

12. 解释 keyoftypeof 在 TypeScript 中的作用,并举例说明如何配合泛型使用。

解析:

  • keyof 获取一个类型的所有键的联合类型。例如 keyof Person 得到 "name" | "age"
  • typeof 在类型上下文中获取变量或属性的类型。例如 const obj = { a: 1 }; type T = typeof obj; 得到 { a: number }

配合泛型可以实现类型安全的属性访问函数:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
const person = { name: 'Alice', age: 30 };
getProperty(person, 'name'); // 正确
// getProperty(person, 'address'); // 错误,address 不在 keyof 中

六、高级类型

13. 什么是联合类型(Union Types)和交叉类型(Intersection Types)?分别用在什么场景?

解析:

  • 联合类型 | 表示值可以是几种类型之一。常用于表示多种可能,例如 string | number。使用联合类型时需通过类型守卫收窄类型。
  • 交叉类型 & 将多个类型合并为一个新类型,该类型拥有所有成员。常用于组合对象类型,例如 A & B 表示同时具有 A 和 B 的属性。交叉类型可能产生 never(如果有冲突类型)。

示例:

type ID = string | number;
function printId(id: ID) {
  if (typeof id === 'string') { /* 收窄 */ }
}

type Name = { name: string };
type Age = { age: number };
type Person = Name & Age; // { name: string; age: number }

14. 什么是类型守卫(Type Guard)?列举你知道的类型守卫方式。

解析:
类型守卫是用于在条件块中收窄变量类型的表达式,运行时检查并影响编译器的类型推断。常见方式:

  • typeof 守卫:typeof x === "string"
  • instanceof 守卫:x instanceof Date
  • in 守卫:"property" in obj
  • 自定义类型守卫(返回 x is Type 的函数):function isFish(pet: Fish | Bird): pet is Fish { return (pet as Fish).swim !== undefined; }
  • 判别式联合(discriminated unions):使用共有字面量属性区分。

15. 什么是可辨识联合(Discriminated Unions)?如何利用它实现模式匹配?

解析:
可辨识联合由多个具有共同字面量属性(tag)的接口类型组成联合,通过检查该属性来收窄类型。常用于处理不同形状的数据,例如 Redux Action。
TypeScript 能够根据 tag 自动收窄,无需额外类型守卫。

示例:

interface Circle {
  kind: 'circle';
  radius: number;
}
interface Square {
  kind: 'square';
  sideLength: number;
}
type Shape = Circle | Square;

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle': return Math.PI * shape.radius ** 2;
    case 'square': return shape.sideLength ** 2;
  }
}

16. 什么是条件类型(Conditional Types)?请举例说明其用法,并解释 infer 关键字的作用。

解析:
条件类型根据类型关系选择类型,形式为 T extends U ? X : Y。可以基于泛型参数动态生成类型。
infer 关键字用于在条件类型中推断类型变量,常用于提取函数返回值类型、Promise 内部类型等。

示例:

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// 使用
type Fn = (x: number) => string;
type R = ReturnType<Fn>; // string

type ElementType<T> = T extends (infer U)[] ? U : T;
type E1 = ElementType<number[]>; // number
type E2 = ElementType<string>;   // string

17. 什么是映射类型(Mapped Types)?如何基于现有类型创建新类型(例如将所有属性变为只读或可选)?

解析:
映射类型通过遍历已有类型的键来创建新类型,语法为 { [P in K]: T },常配合 keyof 使用。TypeScript 提供了内置工具类型如 Partial<T>Readonly<T>Pick<T, K> 等,它们都是映射类型的应用。

示例: 实现一个 Readonly

type MyReadonly<T> = {
  readonly [P in keyof T]: T[P];
};

映射类型还可以通过 +/- 修饰符增减可选或只读属性。


七、类型推断与上下文类型

18. TypeScript 是如何进行类型推断的?请举例说明上下文类型(Contextual Typing)和最佳通用类型(Best Common Type)。

解析:
TypeScript 根据赋值或表达式推断类型。

  • 最佳通用类型:当从多个表达式中推断类型时,计算一个兼容所有类型的类型。例如 let arr = [0, 1, null]; 推断为 (number | null)[]
  • 上下文类型:根据表达式所在的位置(如函数调用参数、赋值目标)推断类型。例如 window.onmousedown = function(mouseEvent) { ... }mouseEvent 会自动推断为 MouseEvent

上下文类型使得代码更简洁,同时保持类型安全。


19. 什么是类型断言(Type Assertion)?什么时候应该使用它?它与类型转换(Type Casting)有何区别?

解析:
类型断言用于告诉编译器“我知道这个值的类型是什么”,常用于当开发者比编译器更了解类型时。语法:value as Type<Type>value(JSX 中不能用后者)。
类型断言在编译时被擦除,不会进行运行时检查,不同于其他语言中的类型转换(转换是运行时行为)。
应该谨慎使用,仅当无法通过类型收窄实现时,或处理来自第三方库的不准确类型时使用。

示例:

const input = document.getElementById('input') as HTMLInputElement;
input.value; // 类型正确

八、模块与命名空间

20. TypeScript 中模块(Module)和命名空间(Namespace)的区别是什么?现代开发中推荐使用哪个?

解析:

  • 命名空间(namespace)是 TypeScript 早期用于组织代码的方式,通过全局对象实现,可以包含内部模块。支持跨文件合并(三斜线指令)。
  • 模块(import/export)是基于 ES6 模块标准的,每个文件是一个模块,依赖关系清晰,适合现代开发。
  • 命名空间主要用于全局脚本环境或避免命名冲突,模块更适合代码组织和依赖管理。现代开发推荐使用 ES6 模块,命名空间已不推荐(除非在声明文件或某些特殊场景)。

九、声明文件与第三方库类型

21. 如何为没有提供 TypeScript 类型定义的 JavaScript 库添加类型?declare 关键字的作用是什么?

解析:
可以创建声明文件(.d.ts)来描述库的类型。

  • 使用 declare 关键字声明全局变量、函数、模块等,告诉 TypeScript 这些成员存在但无需实现。
  • 对于模块库,可以使用 declare module 'module-name' { ... } 描述其导出。
  • 也可以在项目中安装社区维护的 @types/ 包(如 @types/lodash)。
  • 如果库本身包含类型(如通过 types 字段),则直接使用。

示例:

// 在 globals.d.ts 中
declare const MY_GLOBAL: string;
declare function myLibFn(param: string): number;

// 模块声明
declare module 'some-untyped-lib' {
  export function doSomething(): void;
}

十、工具类型

22. 列举 TypeScript 中常用的内置工具类型,并解释其作用(至少说出 5 个)。

解析:
常用内置工具类型(基于映射类型、条件类型等):

  • Partial<T>:将 T 的所有属性变为可选。
  • Required<T>:将 T 的所有属性变为必选。
  • Readonly<T>:将 T 的所有属性变为只读。
  • Pick<T, K>:从 T 中选取一组属性 K。
  • Omit<T, K>:从 T 中移除一组属性 K。
  • Record<K, T>:构造一个对象类型,键为 K,值为 T。
  • Exclude<T, U>:从 T 中排除可赋值给 U 的类型。
  • Extract<T, U>:从 T 中提取可赋值给 U 的类型。
  • NonNullable<T>:从 T 中排除 nullundefined
  • ReturnType<T>:获取函数类型 T 的返回值类型。
  • Parameters<T>:获取函数类型 T 的参数类型元组。
  • InstanceType<T>:获取构造函数类型的实例类型。

十一、装饰器

23. 什么是装饰器?TypeScript 中支持哪几种装饰器?如何启用装饰器功能?

解析:
装饰器是一种特殊声明,可以附加到类、方法、属性、参数上,用于修改其行为。目前处于 ECMAScript 提案阶段,TypeScript 通过实验性支持提供。
需要设置编译选项 "experimentalDecorators": true
装饰器类型:

  • 类装饰器
  • 方法装饰器
  • 属性装饰器
  • 参数装饰器
  • 访问器装饰器(getter/setter)

装饰器本质上是一个函数,接收特定的参数,并可以返回新的定义。

示例(类装饰器):

function sealed(constructor: Function) {
  Object.seal(constructor);
  Object.seal(constructor.prototype);
}
@sealed
class Greeter {}

十二、TypeScript 配置与编译选项

24. tsconfig.json 中的 targetmodulelibstrict 选项分别控制什么?

解析:

  • target:指定编译后的 JavaScript 版本(如 ES5、ES2015、ES2020),影响语法降级和内置 API 是否可用(配合 lib)。
  • module:指定模块系统(如 CommonJS、ES2015、ESNext),决定编译后的模块语法。
  • lib:指定需要包含的类型定义文件列表,如 ["dom", "es2020"]。如果不指定,会根据 target 引入默认库。
  • strict:开启所有严格类型检查选项,包括 noImplicitAnystrictNullChecksstrictFunctionTypesstrictBindCallApplystrictPropertyInitializationnoImplicitThisalwaysStrict。强烈建议开启,以获得最佳类型安全性。

25. 什么是 strictNullChecks?开启它会对代码产生什么影响?

解析:
strictNullChecks 是 TypeScript 的一个严格模式选项,开启后,nullundefined 不再属于任何类型的子类型,必须显式声明才能赋值给变量(如 string | null)。
开启前,nullundefined 可以赋值给任何类型,容易导致运行时错误。
开启后,可以强制开发者处理可能为空的值,提升代码健壮性。需要配合类型守卫或可选链等特性。

示例:

// 未开启 strictNullChecks
let s: string = null; // 允许

// 开启后
let s: string = null; // 错误
let s2: string | null = null; // 正确

十三、与其他技术结合

26. 在 React 项目中使用 TypeScript,如何定义函数组件的 Props 类型?如何处理 children 的类型?如何使用 useState 的泛型?

解析:

  • 定义函数组件 Props 类型通常使用 interfacetype,然后为组件添加类型注解:const MyComponent: React.FC<Props> 或直接 const MyComponent = (props: Props) => ...
  • React.FC 会隐式包含 children 属性(React.ReactNode),但可能带来一些限制(如不支持泛型组件)。现代推荐显式定义 children 类型。
  • children 的类型通常是 React.ReactNode(包括 React 元素、字符串、数字、布尔值、null、undefined 等)。
  • useState 可以通过泛型指定状态类型,例如 const [count, setCount] = useState<number>(0)。如果初始值能推断出类型,可以省略泛型。

示例:

interface ButtonProps {
  label: string;
  onClick: () => void;
  children?: React.ReactNode;
}
const Button = ({ label, onClick, children }: ButtonProps) => {
  return <button onClick={onClick}>{label}{children}</button>;
};

const [value, setValue] = useState<string>(''); // 显式指定

十四、常见陷阱和最佳实践

27. 以下代码有什么问题?如何修复?

function getLength(obj: string | string[]) {
  return obj.length;
}

解析:
代码本身没有问题,因为 stringstring[] 都有 length 属性。但这是一个考察联合类型常见陷阱的例子:如果联合类型中包含没有 length 的类型(如 string | number),则直接访问 length 会报错。
修复方式:使用类型守卫或确保所有类型都有该属性。
本题的陷阱可能是 obj.length 对于 string 返回字符数,对于数组返回元素个数,符合预期。但更常见的陷阱是直接访问可能不存在的属性,需要类型收窄。


28. 解释 !(非空断言操作符)的作用和风险。何时应该使用它?

解析:
! 后缀操作符用于告诉 TypeScript 编译器,一个变量一定不是 nullundefined,即使类型检查认为可能。它从类型中移除 nullundefined
风险:如果运行时实际值为 nullundefined,会导致运行时错误。应仅在确定值一定存在时使用,例如从 DOM 获取元素且确信存在,或初始化后立即赋值且不会被置空。更好的做法是使用类型守卫或可选链。

示例:

const input = document.getElementById('input')!; // 确定存在
input.value = 'hello';

29. 什么是声明合并(Declaration Merging)?请举例说明。

解析:
声明合并指 TypeScript 将多个同名的声明合并为一个定义。最常用于 interface,可以多次声明同一个接口,TypeScript 会自动合并它们的成员。也支持合并 namespace 与类、函数、枚举等。

示例:

interface Box {
  height: number;
}
interface Box {
  width: number;
}
const box: Box = { height: 10, width: 20 }; // 合法,合并后有两个属性

// 命名空间与类合并
class Animal {}
namespace Animal {
  export let legs = 4;
}
console.log(Animal.legs); // 4

30. 如何在 TypeScript 中实现单例模式?结合 static 和私有构造函数。

解析:
利用私有构造函数阻止外部实例化,通过静态方法返回唯一实例。TypeScript 中可以使用 private constructor()static 属性。

示例:

class Singleton {
  private static instance: Singleton;
  private constructor() { /* 初始化 */ }
  static getInstance(): Singleton {
    if (!Singleton.instance) {
      Singleton.instance = new Singleton();
    }
    return Singleton.instance;
  }
}
const s1 = Singleton.getInstance();
const s2 = Singleton.getInstance();
console.log(s1 === s2); // true

以上题目涵盖了 TypeScript 的核心知识点,从基础类型到高级类型,从配置到实际应用。建议面试者不仅要掌握语法,还要理解背后的原理和最佳实践,以便在实际开发中写出类型安全、健壮的代码。

我的Three.js3D场景编辑器免费开源啦🎉🎉🎉

作者 答案answer
2026年3月17日 13:59

前言

哈喽各位佬,这里话不多说主要是给大家简单分享一下我的另一个开源项目 (ThreeFlow),也是一个基于Three.js开发的3D编辑器类型的项目,如果你正在学习Three.js或者想使用Three.js开发一个企业级的项目那么非常推荐你去学习研究一下这个项目。

ThreeFlow 开源背景

在去年(25年)被裁员失业的背景下,我开始尝试自己开发一个商用性质的Three.js3D项目模版,然后把项目源码提供给有three.js3D开发需求公司,然后赚取一点点的报酬。

当时写的文章:juejin.cn/post/748791…

这也是ThreeFlow的前身,很庆幸也许是因为之前写的Three.js开源项目得到了大家的认可,陆陆续续也有人在看到这个项目后选择愿意付费购买这个项目,而我自己也在这段时间里不断的尝试和探索适合自己的模式。

经过25年这一年多时间的探索和尝试后,我也坚定了要开发一个更成熟稳定的3D低代码编辑器产品而非一个项目代码模版

因此在了解到有部分购买的同学只是因为想学习Three.js而去购买的这个项目时,作者个人觉得只是学习一个框架而去花费几十块或者大几百的价格去购买一个项目代码是不太值得的。

于是我也决定将ThreeFlow个人版项目代码,免费开源给大家学习使用,也希望大家在掌握熟悉了Three.js后能够找到一个更加高薪资的工作。

🔗 在线预览链接

threeflowx.cn/study/

🔥 项目描述

ThreeFlow:一个基于 Three.js+Vue3+Vite+Typescript 实现的3D场景编辑器。

采用企业级项目开发标准:

集成 ESLint + Stylelint + Prettier + Husky + CommitLint 确保代码质量规范。

对Three.js核心操作模块的功能进行单独模块化抽离封装,降低Three.js在前端现代框架(Vue3)中的开发成本。

💥 项目界面

截屏2026-02-16 15.15.12.png

截屏2026-02-16 15.21.43.png

⚖️ 项目主要技术栈

名称 版本 名称 版本
Vue 3.5.13 Typescript 5.7.x
Vite 6.1.x Element-plus 2.9.4
Three 182 Pinia 2.3.x
TWEEN 18.5.0 详见 package.json 🤗

🌐 安装/启动/打包(详见 package.json)

 pnpm install

 pnpm serve 
 
 pnpm build/pnpm build:pro

📚 项目主要功能介绍

  1. 拖拽添加场景内容功能: 通过左侧的拖拽区域选择不同的场景元素往场景中添加内容。
  2. 导入外部模型资源: 支持glb,.obj,gltf,.fbx,.stl,.usdz 等格式的3D模型导入加载。
  3. 快捷键功能: 支持 W(拖拽)、E(旋转)、R(缩放)、F(聚焦)等快捷键功能。
  4. 第一人称模式: 支持第一人称视角漫游功能。
  5. 撤销/重做功能: 支持使用快捷键 撤销(Ctrl+Z/Command+Z)重做。(Ctrl+Shift+Z/Command+Shift+Z) 实现模型位置,缩放,旋转等属性的撤销和重做。
  6. 场景内容复制,删除功能: 支持将选中的内容进行删除和复制多个。
  7. 模型材质属性编辑功能: 支持动态修改模型材质内容如:模型贴图、透明度、材质颜色、材质类型、材质渲染面、粗糙度、金属度等等
  8. 模型动画播放功能: 支持场景中多个模型多个动画同时播放。
  9. 项目配置功能: 支持动态修改Three.js渲染器配置如:色调映射、阴影、曝光度。支持动态修改Three.js场景背景、环境光内容。
  10. 场景内容数据持久化存储: 支持场景内容持久化存储:保存整个场景内容、导出整个场景内容、导入整个场景内容等功能。
  11. 模型导出功能: 支持将场景中的3D模型内容导出成一个模型并支持多种格式的导出(.gltf、.glb、.obj、.usdz)。

✅ 浏览器设备支持

浏览器 最低支持版本 说明
Chrome 57+ ✅ 最推荐,three.js 开发首选
Edge (Chromium) 79+ ✅ 与 Chrome 基本一致
Firefox 52+ ✅ 支持良好,性能略逊 Chrome
Safari (macOS) 11+ ⚠️ 支持 WebGL,但兼容性要多测试
Opera 44+ ✅ 基于 Chromium

🌺 项目目录结构介绍

image.png

1. 入口文件

  • App.vue : 应用程序的根组件,包含路由视图
  • main.js : 应用程序入口文件,负责初始化 Vue 应用、注册全局组件、全局状态、指令和插件

2. /assets 目录

存放静态资源文件:

  • iconFont/ : 阿里巴巴矢量图标库文件(地址: www.iconfont.cn/)
  • image/ : 图片资源
  • previewIcon/ : 模型预览图片
  • textures/ : 资源贴图文件

3. /components 目录

全局组件文件:

  • Loading/ : 自定义封装的页面加载loading
  • index.ts : 组件导出文件

4. /config 目录

常量配置和静态数据配置文件:

  • constant.ts : 常量定义
  • defaultDragList.ts : 左侧模型拖拽资源内容数据
  • propertyConfig.ts : 静态属性配置项

5. /enums 目录

全局枚举文件:

  • enum.ts : 场景、变换控制器、材质等相关枚举定义
  • indexDb.ts : IndexedDB 数据库相关枚举

6. /layouts 目录

布局组件文件:

  • RenderView.vue : 渲染视图布局组件,作为应用的主要承载容器

7. /router 目录

路由配置文件:

  • index.ts : Vue Router 路由配置入口,定义应用页面导航规则

8. /store 目录

Pinia 状态管理文件:

  • indexDbStore.ts : IndexedDB 数据操作状态管理
  • pinia.ts : Pinia 实例初始化配置
  • sceneEditStore.ts : 场景编辑器核心状态管理(包括场景对象、选中状态等)

9. /style 目录

样式资源文件:

  • iconFont.scss : 字体图标样式定义
  • index.scss : 全局通用样式入口
  • reset.scss : 浏览器默认样式重置

10. /types 目录

TypeScript 类型定义文件:

  • global.d.ts : 全局通用类型声明
  • indexDbTypes.ts : IndexedDB 数据结构类型定义
  • renderModelTypes.ts : 渲染模型相关接口定义
  • rightPanelTypes.ts : 右侧属性面板配置类型定义
  • three-css3d.d.ts : CSS3D 渲染器类型声明
  • three-utils.d.ts : Three.js 工具函数类型声明

11. /utils 目录

核心工具函数与逻辑封装:

  • directive.ts : Vue 自定义指令注册
  • globalComponent.ts : 全局组件自动注册逻辑
  • globalProperties.ts : Vue 全局属性挂载
  • historyModules/ : 操作历史记录(撤销/重做)模块封装
  • indexedDB.ts : IndexedDB 数据库操作封装类
  • renderScene.ts : 核心文件,Three.js 场景渲染逻辑封装(初始化、渲染循环、事件监听等)
  • sceneModules/ : 场景功能模块(灯光、动画、变换控制等)
  • utils.ts : 通用辅助函数

12. /views 目录

页面视图文件:

  • sceneEdit/ : 3D 场景编辑器主视图
    • index.vue : 编辑器入口组件
    • layouts/ : 编辑器内部布局组件(左侧拖拽栏、右侧属性面板、顶部工具栏等)

💚 项目仓库

GitHub: github.com/zhangbo126/…

Gitee: gitee.com/ZHANG_6666/…

结语

如果你觉得该项目对你有帮助那就给项目留个star⭐吧,这是对作者每次熬夜牺牲休息时间去更新开源项目最大的动力支持😄😁

turbo迁移vite+(vite-plus)实践

作者 Selicens
2026年3月17日 13:48

Turbo 是一个高性能的构建系统,结合 pnpm monorepo 使用非常流行。它通过任务调度与缓存机制,大幅提升多仓库开发体验。

vite+(vite-plus) 则提供了一套统一的工具链(vite、vitest、oxlint、oxfmt、rolldown、tsdown、vite task 等),将所有配置集中到 vite.config 中进行管理,从而解决生态碎片化问题。

Turbo 迁移到 vite+ 的核心收益也在于此:不仅可以实现与 Turbo 类似的任务调度与缓存能力,还能无缝融入 Vite 生态。从长期来看,这一方向具备很大的发展潜力。


一、移除 Turbo 相关配置

首先,清理项目中所有 Turbo 相关内容:

  1. 移除依赖:pnpm remove turbo

  2. 删除配置文件:turbo.json

  3. 删除缓存目录:.turbo(包括根目录及各 packages 子目录)

  4. 清理 .gitignore 中的相关配置: .turbo


二、安装与配置 vite+

1. 安装 vite+

# Windows(PowerShell)
irm https://vite.plus/ps1 | iex

# macOS / Linux
curl -fsSL https://vite.plus | bash

⚠️ 由于当前项目是基于 Turbo 做任务调度的,因此无法直接使用 vp migrate 自动迁移,需要手动调整配置。


2. 依赖迁移

情况一:使用 workspace 的 catalog / catalogs 管理依赖

catalog:
- vite: ^8.0.0
+ vite: npm:@voidzero-dev/vite-plus-core@latest

- vitest: ^4.1.0
+ vitest: npm:@voidzero-dev/vite-plus-test@latest

+ vite-plus: ^0.1.12

# catalogs 同理

+ overrides:
+   vite: npm:@voidzero-dev/vite-plus-core@latest
+   vitest: npm:@voidzero-dev/vite-plus-test@latest

情况二:直接在 package.json 中管理依赖

{
  "devDependencies": {
-   "vite": "^8.0.0",
-   "vitest": "^4.1.0",
+   "vite": "npm:@voidzero-dev/vite-plus-core@latest",
+   "vite-plus": "latest",
+   "vitest": "npm:@voidzero-dev/vite-plus-test@latest"
  },
  "pnpm": {
+   "overrides": {
+     "vite": "npm:@voidzero-dev/vite-plus-core@latest",
+     "vitest": "npm:@voidzero-dev/vite-plus-test@latest"
+   }
  }
}

3. vite.config 配置

- import { defineConfig } from 'vite'
+ import { defineConfig } from 'vite-plus'

export default defineConfig({
  run: {
    tasks: {...}, // 任务调度
    cache: {...}, // 缓存
  },
  fmt: {...},   // 使用 oxfmt,替代 prettier
  lint: {...},  // 使用 oxlint,替代 eslint
  test: {...},  // vitest
  pack: {...},  // tsdown(库打包,类似 tsup / rollup)
  staged: {...} // 替代 lint-staged
})

三、任务调度与缓存

由于本文实践是 从 Turbo 迁移到 vite+,这里重点说明任务调度与缓存的实现方式。

项目结构

apps/
  └─ playground

packages/
  ├─ module-a
  └─ module-b

Turbo 原配置

{
  "$schema": "https://turborepo.com/schema.json",
  "tasks": {
    "build": {
      "outputs": ["dist/**"]
    },
    "dev": {
      "persistent": true,
      "cache": false,
      "dependsOn": ["^build"],
      "interactive": false
    }
  }
}

vite+ 实现方式

💡 注意: 在 vite+ 目前版本中,package.json 的 scripts 名称不能与 tasks 重名。 例如:scripts 中有 dev,那么 tasks 中就不能再叫 dev,否则会报错。


方式一:自定义任务编排(最灵活)

export default defineConfig({
  run: {
    tasks: {
      task1: {
        command: [
          'vp run --filter "@my-app/*" build',
          'vp run --filter ./apps/playground dev'
        ].join(' && '),
        input: [{ auto: true }, '!**/*.tsbuildinfo']
      }
    },
    cache: {
      scripts: true, // 默认 false,开启后缓存 scripts
      tasks: true    // 默认 true
    }
  }
})

方式二:递归执行(按依赖顺序)

tasks: {
  task2: {
    command: 'vp run -r build',
    input: [{ auto: true }, '!**/*.tsbuildinfo']
  }
}

方式三:手动控制执行顺序

tasks: {
  task3: {
    command: [
      'vp run --filter "@my-app/c" build',
      'vp run --filter "@my-app/a" build',
      'vp run --filter "@my-app/b" build',
      'vp run --filter ./apps/playground dev'
    ].join(' && '),
    input: [{ auto: true }, '!**/*.tsbuildinfo']
  }
}

package.json scripts 调整

{
  "scripts": {
-   "dev": "turbo run dev --filter=\"./apps/*\"",
+   "dev": "vp run task1",
+   "dev2": "vp run task2 && pnpm -F ./apps/playground dev"
  }
}

四、总结

从 Turbo 迁移到 vite+,本质是从「多工具拼装」走向「统一工具链」:

  • ✅ 任务调度能力:≈ Turbo
  • ✅ 缓存机制:≈ Turbo
  • ✅ 工具链整合:明显更优(fmt / lint / test / build 一体化)
  • ✅ 配置集中:全部收敛到 vite.config

如果你的项目正在被 eslint / prettier / tsup / turbo 等工具割裂,vite+ 是一个值得尝试的统一方案。

microapp 通过链接区分主子应用步骤

作者 code20
2026年3月17日 13:43

1. micro

{ name: "statistic", url: `http://192.168.110.100:81/project/statistic/` }

2. vue.config.js

主应用

image.png

子应用

image.png

3. nginx配置

主应用

  location / {
        root  /home/env/html/saas/base;
add_header access-control-allow-origin *;
        try_files $uri $uri/ /index.html;
        index  index.html index.htm;
    }

子应用

 location /project/system {
        alias /home/env/html/saas/portal/; 
add_header access-control-allow-origin *;
        index index.html index.htm;
        try_files $uri $uri/ /system/index.html;
    }

从爆红到被嫌弃,MCP 为什么开始失宠了

作者 Moment
2026年3月17日 13:43

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

如果你对 AI全栈 感兴趣,也欢迎添加我微信,我拉你进交流群

MCP 出生时,被捧得很高。

2024 年 11 月,Anthropic 发布"模型上下文协议",几乎所有 AI 开发者社区都在讨论这件事。它的定位很诱人,要成为大模型和外部工具之间通信的"通用标准",有点像当年 HTTP 对 Web 的意义。一时间,MCP server 满天飞,各种集成教程、开源实现层出不穷。

但时间只过了一年多。

上周,Perplexity 的联合创始人兼 CTO Denis Yarats 在内部表示,他们正在放弃 MCP,转而改用 APICLI。这个消息扩散出来后,引发了一波讨论,但讨论的内容不是"为什么",而是"早该如此"。

Y Combinator 的总裁兼 CEO Garry Tan 甚至直接说了一句话:"MCP sucks。"

MCP 的问题从来都不是技术实现不够好

很多人对 MCP 的质疑,停留在"不稳定"、"认证烦"这些体感上的抱怨。这些问题确实存在,但它们只是表象。MCP 真正的困境,是一个结构性问题。

MCP 的工作方式是,把工具的名称、描述、参数结构(Schema)以及使用示例,全部注入到 Agent 的上下文窗口里。Agent 读完这些信息,再决定要调用哪个工具。

这个设计在工具数量少时还可以接受。但你一旦接入 10 个服务,每个服务有 5 个工具,光是工具定义本身就已经烧掉了几千个 token。Agent 还没开始干活,上下文就已经塞满了一半。

上下文窗口是 Agent 最宝贵的资源,它决定了 Agent 能看见多少对话历史,能保留多少工作记忆,能有多大的推理空间。MCP 的代价,是把这个资源拿来"列菜单"。

面对这个问题,现有的出路只有三条:

  • 一次性加载所有工具,接受推理性能下降
  • 限制接入工具数量,接受 Agent 能力边界收窄
  • 构建动态工具加载机制,接受额外的延迟和复杂度

三条路都不好走。这不是"实现质量"的问题,而是协议设计本身的代价。

除此之外,日常使用中的痛点也不少。MCP server 启动失败是家常便饭,有时重试能解决,有时必须推倒重来。接入多个服务就要在每个服务上重新认证一遍。权限管理也只有"允许"和"不允许"两档,没有办法把某个工具限制为只读,也没有办法约束它可以传什么参数。

CLI 是更好的答案,不是因为它新,而是因为它够旧

工程师 Eric Holmes 写过一篇文章,观点直接:MCP 没有带来任何实际价值,LLM 完全可以自己搞懂怎么用 CLI

这话有点刺,但它说的是实情。

大模型在训练时看过海量的 man 手册、Stack Overflow 回答和 GitHub 上的 Shell 脚本。它们对 CLI 的理解,远比对某个 MCP server 的理解深得多。给它一个命令行工具和一份文档,它就能上手,不需要特殊适配。

CLI 在几个关键点上,比 MCP 天然占优。

第一是可调试性。当 Claude 对 Jira 执行了一个出乎意料的操作,你可以直接跑同一条 jira issue view 命令,看看它看到了什么。输入一致,输出一致,没有谜团。但 MCP 的调用只发生在 LLM 的对话内部,出问题了只能去翻复杂的 JSON 传输日志。

第二是可组合性。这是 CLI 的核心竞争力。你可以用 jq 过滤数据,用 grep 串联逻辑,把输出重定向到文件。这不只是方便,很多时候这是唯一可行的路。MCP 没有这个能力,你要么把完整数据塞进上下文,要么在 server 端自己写过滤逻辑,两种方式都在用更多的精力换取更差的结果。

第三是认证。CLI 复用的是系统级别的认证体系,这套东西已经经过几十年的打磨。MCP 需要你重新为每个工具搭一遍认证流程。

这件事说明了什么

Perplexity 放弃 MCP,以及其他工具陆续移除 MCP 支持,这件事背后有一个更值得思考的信号。

给 AI 构建工具链,不需要发明一套新的协议。AI 需要的工具,和人类需要的工具,在很多时候是同一套。最好的工具是对人类和机器都好用的工具。

CLI 存在了几十年,设计上一直遵循一个哲学,每个工具做好一件事,然后把工具组合起来解决复杂问题。这套哲学放到 Agent 身上,依然成立。

MCP 想构建一个更"现代"的抽象层,但它解决的问题,现有工具已经解决得够好了。在不需要额外抽象的地方强行加一层,带来的只有额外的成本和复杂度。

当然,MCP 不会完全消失。在某些特定场景,比如需要强类型 Schema、有严格访问控制要求的企业内部系统,它依然有它的位置。但作为"AI 工具集成的通用标准",这个定位恐怕很难站稳了。

参考:

从IDE到Terminal:适合后端宝宝体质的Claude Code工作流|得物技术

作者 得物技术
2026年3月17日 13:38

一、背景

事情是这样的,之前对 AI 编程一直是观望态度,但是部门最近在做 AI 辅助编程 POC,有幸成为 POC 用户,用上了自己舍不得买的高级编程模型 (感谢公司)。尽管我自认为是一个在代码上很挑剔的人,但是试了下感觉居然还可以 (Go、React)!只能说还得是谷歌,调整重心略微发力,Gemini 3 表现确实很不错。既然尝到甜头了,觉得自己是时候好好地琢磨琢磨,研究研究,沉淀一套自己的工作流、方法论,解放自己的生产力,顺应潮流努力成为 AI 时代的受益者,而不是被淘汰的人!

新的开发范式需要搭建新的开发环境和匹配自己开发习惯的工作流,这就像刚学编程那会,需要挑一个自己喜欢的 IDE、熟悉 IDE 快捷键和优化 IDE 设置一样。过程中间肯定有阵痛,Java 开发者们回忆一下多年之前从 Eclipse 转 IDEA 那会的阵痛吧,但是磨刀不误砍柴工,阵痛之后一定是生产力提升。借本文分享下我摸索后的方案,供大家参考。

二、工具选型

目前 AI 辅助编程领域热火朝天,各种 GUI 工具、TUI 工具如雨后春笋让人目不暇接,这对于花心的强迫症选手(比如我)来说选型很困难。但是我觉得有两个基础认知可以帮助我们更好地做决定:

(一)AI 辅助编程工具由脑和手两部分组成。脑是外接的大模型 API,手是各个产品调教的提示词和内部工作流。按我理解,【脑】决定了工具的上限,【手】决定了工具的下限。在这个场景里,大模型就像是汽车里的发动机,而且所有型号的汽车支持的【发动机】规格都是通用的、统一的、标准化的。有了这个基础,我们可以随便选一个趁手的工具,然后自行按场景选配【合适】的【发动机】。

(二)AI 辅助编程当前是一个【千帆竞发】的热门领域,而且单纯就【工具】来说,这个领域【没有技术壁垒】。A 产品抛出的杀手级特性,不出半个月一定会有 B 产品跟进。毕竟现在软件迭代的速度借助 AI 提升了很多,A 产品验证过的想法,B 产品可以很快地跟进和实现。Claude Code CLI 的开发者就使用 Claude Code CLI 迭代 Claude Code CLI,有点绕口,大概就是【工具自举】的意思吧。

Claude Code CLI

综上,其实没啥纠结的,我们照着这两点来选型就好:1. 这个工具一定得便捷地支持模型插拔,就是我随时可以根据场景换一个更适合的、更便宜的、表现更好的大模型。而且这种插拔一定要简单。 2. 这个工具一定要有积极的维护者,不断地迭代、优化它的工作流、提示词。最好是一个商业化产品,因为商业化产品出于其商业目标,一定会投入资源积极进行迭代。 

当前满足这两个条件的,我想也就是 Claude Code CLI 了: 1. Claude Code CLI 是一个商业化产品,有专门的技术团队在不停地更新、迭代。 2. Claude Code CLI 可以非常便捷地支持大模型插拔,我可以随时根据成本、效率、体验来切换合适的大模型。因此,这个环节我选 【Claude Code CLI】。

后文以CC代指Claude Code CLI。

快速切换模型

我通过自定义 Shell 函数来实现便捷的模型切换,不同的场景、不同的任务使用不同的模型。基本原理就是,CC 支持环境变量注入 LLM 配置信息,因此我只需要按场景注入【行内临时环境变量】即可。

详见:Bash - 行内环境变量,Bash 是标准的 Shell 实现,其他 Shell 如 Zsh 都兼容其行为。

Shell配置

我到处弄了一堆免费的、收费的模型用,然后给他们取了我记得住的别名:

使用效果

为了兼容,设置了一个 claude 别名:

这样输入claude 时,默认使用智谱 GLM 模型。

脚本源码

Shell 脚本大概这样,可以修改后配置到自己的 ~/.zshrc 中。如果不熟悉 Shell,嫌麻烦也可以试试这个开源工具:farion1231/cc-switch。

# claude 默认
alias claude='zcc'
# Kimi
function kcc(){
    echo Kimi Claude Code...
    local model="kimi-k2.5"
    ANTHROPIC_BASE_URL="https://api.moonshot.cn/anthropic" \
    ANTHROPIC_AUTH_TOKEN="sk-xxxxxxxxx" \
    ANTHROPIC_SMALL_FAST_MODEL="$model" \
    ANTHROPIC_DEFAULT_OPUS_MODEL="$model" \
    ANTHROPIC_DEFAULT_SONNET_MODEL="$model" \
    ANTHROPIC_DEFAULT_HAIKU_MODEL="$model" \
    CLAUDE_CODE_SUBAGENT_MODEL="$model" \
    launch_claude_code $@
}
# 智谱GLM
function zcc(){
    echo GLM Claude Code...
    ANTHROPIC_BASE_URL="https://open.bigmodel.cn/api/anthropic" \
    ANTHROPIC_AUTH_TOKEN="sk-xxxxxxxxx" \
    launch_claude_code $@
}
# 七牛
function qcc(){
    echo QiNiu Claude Code...
    local model="minimax/minimax-m2.1"
    ANTHROPIC_BASE_URL="https://api.qnaigc.com" \
    ANTHROPIC_AUTH_TOKEN="sk-xxxxxxxxx" \
    ANTHROPIC_SMALL_FAST_MODEL="$model" \
    ANTHROPIC_DEFAULT_OPUS_MODEL="$model" \
    ANTHROPIC_DEFAULT_SONNET_MODEL="$model" \
    ANTHROPIC_DEFAULT_HAIKU_MODEL="$model" \
    CLAUDE_CODE_SUBAGENT_MODEL="$model" \
    launch_claude_code $@
}
function launch_claude_code(){
    CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \
#    clear
    command claude $@
}

三、开发环境

在当前的气氛下,我想我算是一个【古板】的开发者,我做不到【fire and forget】,或者说完全靠黑盒的自然语言对话来完成代码开发。

我还是只将 AI 当助手,还是想要白盒的掌控 AI 写的代码,还是希望最终交付的代码有我的风格、我的审美、我的品味。毕竟 AI 也只能帮我写代码,并不能帮我背锅。尽管我选择了 TUI 工具 Claude Code CLI,但是我还做不到全程只在终端操作,我还是习惯 JetBrains 特色的双栏 diff。

因此,当前我开发流程的起点还是传统的 IDE,比如我最喜欢的 JetBrains。每天上班第一件事是接水,第二件事就是打开 IDE。所以我需要想办法来将 GUI 工具和 TUI 工具流畅的衔接起来,减少代码开发时的频繁切换产生的割裂感!

多屏协作

如上图,我有 3 个显示器,我的构想是这样的:

  1. MacBook 内置显示器 —— 常驻两个空间:一个用来打开浏览器,还有 VPN、网易云音乐、Finder 软件,用来承接各种临时的操作。一个用来打开飞书,用来沟通、协作。

  1. 中间主屏 —— 常驻两个空间:一个用来打开浏览器,用来做各种【输出】。一个用来打开 IDE,专注于写代码、看代码,用标签页打开多个 Project。

  1. 左边竖屏 —— 常驻两个空间:一个用来打开浏览器,用于看文档、查资料等各种【输入】。一个用来打开 TUI 工具,进行辅助编程!

GUI/TUI衔接

现在问题来了,我希望我的开发工作的【主轴】是 IDE,流程的起点是 IDE。但是我的 IDE 在中间屏幕,终端在左边屏幕,它俩是独立软件,没法协作、自动跟随切换 Project 的工作目录。我希望有个【自动化流程】,当我在 IDE 里切换项目的时候,CC 自动跟随切换!

衔接流程

我期待的流程是这样的:

因为某个原因,我在 IDE 里打开了一个项目 A  准备写代码了,点击 IDE 里的某个【按钮】,左边屏幕自动【新建】一个项目 A 的 CC 会话终端并激活到前台显示   我跟左边的 CC 对话,让他干活  我在中间的 IDE 里评审、调试、诊断  因为某些原因我又要在 IDE 打开一个别的项目 B  我再次点击那个【按钮】,左边屏幕自动【新建】一个项目 B 的 CC 会话终端并激活到前台显示  我在 IDE 里又切回了项目 A,我又点击了那个【按钮】,左边屏幕自动【切换】到 A 的 CC 会话终端并激活到前台显示。

好的想法已经有了,AI 时代就怕你没有想法,有想法就一定有办法实现!

代码实现

  1. macOS 上的原生软件,大部分支持 AppleScript 自动化,也就是说我们可以写脚本驱动软件的行为、模拟人机交互,比如打开软件、新建 tab、点击按钮等。

  2. JetBrains IDE 支持集成外部命令,也就是说:可以在 IDE 里点击一个按钮,自动执行一个 Shell 脚本或者别的可执行文件。

产品需求清晰了,接下来开始让 AI 干活!一顿沟通和调试之后,我们有了一个【自动化】创建 iTerm2 新标签的可执行脚本!

这是给大模型的需求提示词,大家可以按需选用,做个性化的调整:

## 📌 工具功能说明
请帮我创建一个 macOS 上的 iTerm2 自动化工具,主要功能包括:
### 核心需求
1. **智能窗口管理**:自动使用或创建 iTerm2 窗口
2. **项目标签管理**:为每个项目目录维护独立的标签页,支持标签复用
3. **三面板布局**:自动创建固定的三面板布局(上方一个全宽面板,下方两个并排面板)
4. **命令自动执行**:在每个面板中自动切换到项目目录并执行预定义的命令
### 使用场景
```bash
# 基本用法:在当前目录打开
./open-claude-in-iterm.sh
# 指定项目目录
./open-claude-in-iterm.sh /path/to/project
```
---
## 🎯 技术架构要求
### 技术栈
- **Shell 脚本** (open-claude-in-iterm.sh):参数处理、路径规范化、日志管理
- **AppleScript** (open-claude-in-iterm.applescript):iTerm2 自动化核心逻辑
**依赖**:macOS、iTerm2、Bash
---
## 📋 详细功能规格
### 1. Shell 脚本 (open-claude-in-iterm.sh)
#### 参数处理
- **参数1**:项目目录(可选,默认当前目录)
- **自动处理**:相对路径转绝对路径
#### 面板命令配置
```bash
PAN1_CMD="claude"     # 上方面板命令
PAN2_CMD="claude"     # 左下面板命令
PAN3_CMD="claude"     # 右下面板命令
```
### 2. AppleScript (open-claude-in-iterm.applescript)
#### 主要流程
**步骤1:窗口管理**
- 检查 iTerm2 是否运行(未运行则自动启动)
- 使用当前激活的 iTerm2 窗口,如果没有则创建新窗口
**步骤2:标签管理(关键逻辑)**
- 在找到的窗口中,查找 `session.path` 变量等于项目目录的标签
- **复用逻辑**:如果找到现有标签 且 窗口不是新创建的 → 直接切换标签并返回
- **创建逻辑**:如果未找到标签 或 窗口是新创建的 → 创建新标签和布局
**步骤3:三面板布局创建**
```
布局示意图:
┌─────────────────────────┐
│   上方面板 (全宽)         │
│   执行: PAN1_CMD         │
├──────────────┬──────────┤
│  左下面板    │  右下面板 │
│  PAN2_CMD   │  PAN3_CMD │
└──────────────┴──────────┘
```
**分割顺序(重要)**:
1. 初始状态:一个全屏 session(上方面板)
2. 第一次分割:对上方 session 执行**水平分割**,创建下方面板
3. 第二次分割:对下方 session 执行**垂直分割**,创建右下面板
**步骤4:命令执行**
在每个面板中依次执行:
1. 切换到项目目录:`cd "/path/to/project"`
2. 清屏:`clear`
3. 等待 0.3 秒(确保目录切换完成)
4. 执行命令:`PAN_CMD`
5. 等待 0.5 秒(确保命令启动)
## ⚠️ 常见错误
- ❌ 符号链接未处理,导致找不到 AppleScript 文件
- ❌ 分割顺序错误,导致布局不正确
- ❌ 缺少 delay,导致命令执行失败或在错误目录执行
- ❌ 新窗口处理错误,导致多余空白标签
- ❌ 标签复用逻辑错误,导致同一项目创建多个标签
- ❌ 路径未引用,导致包含空格的路径失败

IDE配置

创建外部工具

添加到工具栏

使用效果

点击工具栏按钮后,自动在全屏的 iTerm2 窗口新建或激活项目目录下的 CC 会话,下图里就是 3 个项目。

四、多Agent协作

会的越多,让你干的就越多。既然 AI 那么牛,一个 CC 会话已经满足不了我膨胀的想法和需求了。我希望我可以同时支配多个 AI 开发工程师,而我变成 PM!所以参考酒米的思路,我给每个项目的终端,自动化的划分了 3 个子窗口,每个子窗口都是一个 CC 会话。效果大概这样:

主从架构

每个项目自动打开 3 个常驻的 AI 会话,我设想的工作流是这样的:

【架构师】上面的大屏,用贵的模型!专门用来跟我聊需求、对方案、产出任务列表。

【开发者】下面的两个小屏,用领域特定的模型,专门用来落地大屏架构师产出的方案和任务。比如前端需求用前端效果好的模型,后端需求用后端效果好的模型。

知人善用才是好 PM!这个模式也很匹配现实中的组织架构和成本取舍,现实中每个需求一般也都是由一个架构师和多个中高级开发者来协作完成!感谢热心市民无声雨,给我们小组共享了自己采购的纯血 Claude 模型,所以目前我用 Claude 模型来对方案,用 GLM 或者 MiniMax 来实施方案!

规范驱动开发(SDD)

主从智能体的协作很重要,我跟【架构师】聊了半天确定的方案和设计,需要有一个清晰的、对大模型友好的方案和任务文档作为【开发者】的输入。这就很巧,刚好最近在流行 SDD,规范驱动开发。大致就是模拟现实中的软件开发流程将开发生命周期拆分为 3 个阶段:

  • 【proposal】需求对齐、方案设计、【任务细化】;
  • 【apply】开发任务实施;
  • 【archive】功能验收、文档沉淀

围绕这个流程,开源社区设计和研发了一系列对大模型非常友好的工具和提示词(比如 OpenSpec),【阶段 1】和【阶段 2】中间通过格式设计良好的【设计文档和任务文档】来进行上下文交接。

也就是说,我可以在上述的 3 窗口环境中,按照 SDD 流程来:【proposal】跟【架构师】交互,对齐需求、设计和任务 A  【apply】让【开发者 1】着手完成任务 A  【proposal】继续跟【架构师】交互,对齐需求、设计和任务 B  【apply】让【开发者 2】着手完成任务 B  【proposal】继续跟【架构师】交互,对齐需求、设计和任务 C  【apply】让【开发者 1】着手完成任务 C  ……

五、CC拓展

CC 当然很厉害,但它本质上也就是一个朴素的 ReAct 模式智能体。

ReAct 这么火,大家肯定也都耳熟能详了,我们也就不说太多。当然 CC 团队围绕编程这个课题做了很多细致的提示词调优和内置工作流设计,这个我们黑盒的用就好了,也没必要关注太多。我们最需要关注的,是 CC 提供给我们使用者的【拓展点】,那些允许我们个性化设置的东西。

命令(command)

命令的本质就是预定义的提示词模板。目的是为了省事,不用每次都重复的输入类似的提示词。比如想让 CC 帮我提交代码,每次我们可能都要交代一大堆字,比如:

请调用 git diff --cached 获取当前暂存区的代码变动。
忽略所有的 node_modules 或二进制文件。
基于变动内容,判断这是一个 feat (新功能), fix (修复) 还是 chore (杂务)。
生成一个不超过 50 字符的标题,并在正文详细列出影响的文件。
由我确认后执行 git commit。”

就像写代码的时候将重复代码提取为一个独立方法一样,我们可以把这些可以复用的提示词固定成一个【命令】,后续使用的时候,直接输入命令名字就好。斜杠命令是一段提示词的快捷方式。

技能(skill)

技能和命令最大的差别就是:命令是用户主动提交的提示词,而技能是 Agent 自己决策后自动导入的提示词。当然技能包里除了提示词,一般还会携带一些配套的工具、脚本、命令或者文档。

比如,我安装了一个【html 转 pdf 的技能包】,这只能提示 CC 可以使用这个技能,但是具体用不用、什么时候用、怎么用都是 CC 自己规划、决策的。

子代理(subAgent)

SubAgents 是可以并行处理任务的独立 AI 代理,每个子代理拥有独立的上下文窗口,可以分配不同任务以提高效率。【主代理】的上下文窗口中包含有【子代理】的【简短】描述信息,可以基于这个描述信息规划、决策使用哪个子代理。

{
  "agents":{
    "code-reviewer":{
      "description":"专门负责代码审查的子代理",
      "model":"claude-opus-4-5",
      "instructions":"你是一个专业的代码审查专家,专注于检查代码质量、安全漏洞和性能问题。",
      "tools":["read","search","git"],
      "permissions":{
        "allowWrite":false
      }
    },
    "test-writer":{
      "description":"专门负责编写测试的子代理",
      "model":"claude-sonnet-4-5",
      "instructions":"你是一个测试工程师,专注于编写全面的单元测试和集成测试。",
      "tools":["read","write","bash"]
    },
    "doc-generator":{
      "description":"专门负责生成文档的子代理",
      "model":"claude-sonnet-4-5",
      "instructions":"你是一个技术文档专家,专注于生成清晰、准确的技术文档。",
      "tools":["read","write"]
    }
  }
}

独立上下文窗口的好处是:避免上下文污染和占用。比如我要在代码里找一个接口的所有实现类,这个就很适合子代理来做。主代理只需要交代给子代理接口名,然后就等子代理返回实现类列表。

这样在主代理的上下文窗口里,只会有子代理的输入和输出(几个类文件路径),而子代理在搜索过程中遍历文件、目录、读取文件内容产生的临时 token,不会对主代理产生影响。我目前认为 SubAgent 和 Skill 差不太多。不过我不确认 Skill 是不是在独立的上下文中执行。

MCP

MCP 和技能一样,都是由 CC 自主规划、决策使用的。差别有两个:

  1. MCP 工具的说明信息占用的上下文太多了!不管是否被使用,每次都需要一口气提交所有工具的完整元信息(使用说明 + 出入参 Schema)供大模型规划、决策,占用大量上下文。而【技能】选择了【渐进式披露】,先向大模型提供少量关键信息,只有在大模型选择了使用技能时,才告诉大模型更多关于技能的补充说明信息,让大模型进一步推理、决策。

  2. MCP 工具更多的偏向【远程 RPC】,基于网络来实现原子化的远程能力调用。而【技能】更多的偏向【本地 IPC】,具体能力更多通过【编排】本地脚本、本地命令来实现,有点像 stdio 模式下的 MCP。

钩子(hook)

hook 是在特定事件触发时自动执行的脚本,用于自定义工作流、拦截危险操作、自动格式化代码等。就类似 Linux NetFilter,CC 在很多地方植入了流程执行的劫持点,将流程上下文交给用户开发的脚本或者命令。

插件(plugin)

plugin 就是上述各种拓展打包、分发、安装的一种格式。你可以把它想象成 npm 包、pip 包、apk 包等我们比较熟悉的概念。然后我们可以按流程和格式建设插件市场,类似 pip-index、npm-index 等。

我没有细看流程和格式,但是大概也就是一个特定文件布局的 zip 文件包,里面有插件描述信息和各类拓展,比如可以包含:

  • 5 个 Skills;
  • 10 个斜杠命令;
  • 3 个 MCP 服务器配置;
  • 2 个 SubAgent 定义;
  • 若干 Hooks。

六、CC技巧

飞书MCP

飞书官方提供了 MCP,我主要用它来读写飞书文档,蛮好用的,大家可以试试。比如我每周都要在固定目录下创建固定标题格式的【系统巡检文档】,所以我借助飞书 MCP 整了个自定义 Command 帮我自动创建这些文档去除重复劳动,感觉真香!之前每次都要手动建 3 个文档、选目录、改名字!

@模糊搜索

有时候我们需要精确的告诉 CC,哪个文件需要读或者改,其实不用从 IDE 里复制文件路径,直接在终端里模糊搜索就好了。

WebFetch

CC 默认集成了 WebFetch 命令,就是指定 URL 读取网页内容,这个理论上就是一个本地执行的 curl 命令,没有云端成本,不需要云端协作。但是有个问题:(一)CC 在访问地址之前,会先调用 anthropic.com 的一个风控接口,判断这个网络地址是否有安全风险。(二)政策原因,anthropic.com 会拒绝所有来自中国大陆、香港的请求,风控接口返回 404 或者其他。(三)风控不通过,WebFetch 失败。

在 ~/.claude/settings.json 中添加如下配置,禁用 WebFetch 工具前置的风控检查就好了。

{
  "skipWebFetchPreflight":true,
}

详见:linux.do/t/topic/114…

WebSearch

WebSearch 是需要云端协作的,需要有个搜索引擎服务提供能力。因为我们没有用官方的付费订阅,所以默认的 WebSearch 工具我们用不了,调用 WebSearch 工具得到的结果都是 0。

办法是去找一个免费或者收费的 MCP 服务。免费的我看大家都推荐 Brave<brave.com>,大家也可以找找别的。收费的也有很多,我看智谱的套餐里限量提供了 <联网搜索 MCP - 智谱 AI 开放文档>。也有很多按量付费的,大概几分钱一次,有需要的可以找找。

添加了 MCP 搜索工具后,建议禁用 CC 自带的 WebSearch 工具,不然每次跟大模型交互时,工具信息还会带给大模型,产生额外的 token 开销和推理误判。在 ~/.claude/settings.json 中添加如下配置:

{
  "permissions":{
    "deny":[
      "WebSearch"
    ]
  }
}

iTerm2通知

终端上的任务需要我们输入的时候,可以配置下,让 iTerm2 发出声音和通知。这样我们就不会因为忘记确认操作而阻塞进度。

详见:Optimize your terminal setup - Claude Code Docs

清空上下文

因为我们每个项目都复用一屏内的 3 个子窗口,一般不会重开。为了避免上下文溢出或者之前对话对新任务产生干扰,当我们完成一个任务时,需要及时的执行 /clear 命令,清空上下文,从 0 开始新对话。

如果任务没有完成,但是又不得不 clear,那么可以维护一个自定义命令,在 clear 后提示大模型根据 git status 看到的文件变更快速找回上下文。把 git 状态当作 AI 的 “短期记忆快照”,/clear 只清上下文,不清工作进度。

# Context Catch-up
当前对话已被 `/clear`,请通过 git 状态恢复上下文。
使用方式:
1. 阅读 `git status`(必要时结合 `git diff`2. 仅基于文件变更推断正在进行的任务
3. 延续现有实现思路,不要假设额外背景
4. 在未收到明确指令前,先给出你对当前上下文的判断
目标:
- 快速找回任务状态
- 避免旧对话或错误假设干扰新任务

注意力哨兵

在记忆文件里要求大模型扮演一个特别的角色,如果聊着聊着角色行为丢失了,说明大模型注意力失焦了,已经丢掉了你最开始的要求。这时候就该 clear 一下重开会话了。

拓展市场

为了便于相关个性化拓展物料的分发、便于大家搜索、安装,市面上已经有了相关的分发平台和便捷安装命令了。

  1. skills.sh

  1. www.aitmpl.com

状态行个性化

状态行显示在 Claude Code 会话界面底部,可以自定义显示的内容,比如git分支名、目录名、模型名等。推荐使github开源项目:claude-code-statusline-pro-aicodeditor,效果如下:

详见:github.com/HorizonWing…

七、总结

差生文具多,尽管我暂时还没有使用 CC 产出啥说得上来的东西,但是确实花了很多时间琢磨怎么让它用起来更顺手。一些不成熟的想法,希望可以给到大家启发。

参考:

  1. www.ginonotes.com/posts/how-i…
  2. www.cnblogs.com/knqiufan/p/…

往期回顾

1.AI编程能力边界探索:基于 Claude Code 的 Spec Coding 项目实战|得物技术

2.搜索 C++ 引擎回归能力建设:从自测到工程化准出|得物技术 

3.得物社区搜推公式融合调参框架-加乘树3.0实战

4.深入剖析Spark UI界面:参数与界面详解|得物技术

5.Sentinel Java客户端限流原理解析|得物技术

文 /羊羽

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

Energy-Fi:基于 DePIN 的能源资产化协议设计与实现

作者 木西
2026年3月17日 13:30

前言

在 2026 年的 Web3 版图中,纯粹的"小圆盘"博彩模式已逐渐式微,取而代之的是拥有真实物理底座的 Energy-Fi。该协议将 GambleFi 的"赔率博弈"与现实世界的"电网负荷"相结合,创造了一种全新的能源资产化交易模式。

本文将完整复盘一套 Energy-Fi 协议 从合约架构到算法治理,再到集成测试的全流程实现。该协议基于 Solidity 0.8.24,采用 OpenZeppelin V5 标准,核心聚焦于硬件授权算法定价资产联动三大支柱。

一、核心架构:从物理电表到链上结算

Energy-Fi 的核心挑战在于数据真实性验证。我们采用 DePIN(去中心化物理基础设施) 模式,通过硬件认证确保每一度电、每一次负荷波动均为真实数据上链。

三层架构设计

层级 组件 技术实现 核心职责
资产层 ENT 代币 + GCC NFT OpenZeppelin V5 ERC20/ERC721 能源结算与碳信用资产化
数据层 Chainlink Functions 去中心化预言机网络 实时抽取物理电网负荷数据
逻辑层 Algorithmic Pricing DAO 治理的智能合约 将定价权从中心化机构移交至算法

二、 硬核代码实现:算法定价与安全断路器

2.1 动态调价算法

采用超额累进定价模型。当电网负荷(Lcurrent )超过额定值(Ltarget )时,价格随系数动态漂移:

Price=Pbase×(1+Ltarget×100Multiplier×(Lcurrent−Ltarget))

参数说明:

  • Pbase :基础电价(100 单位)
  • Multiplier :负荷敏感系数(50)
  • Ltarget :额定负荷(1000 MW)

2.2 安全断路器(Circuit Breaker)

为防止预言机遭受攻击(如物理 API 劫持),合约内置价格偏离检测机制。当新旧价格波动超过设定阈值(如 30%),系统自动触发 _pause() 进入熔断状态。

2.3 核心合约代码

A.能源代币合约(ENT)
// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.5.0
pragma solidity ^0.8.24;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract BoykaYuriToken is ERC20, ERC20Burnable, Ownable, ERC20Permit {
    constructor(address recipient, address initialOwner)
        ERC20("MyToken", "MTK")
        Ownable(initialOwner)
        ERC20Permit("MyToken")
    {
        _mint(recipient, 1000000 * 10 ** decimals());
    }
    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }
}
B. 碳信用 NFT 合约(GCC)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract GreenCarbonNFT is ERC721, Ownable {
    error AlreadyOffset(uint256 tokenId);

    struct CarbonData { uint256 amountKG; bool isOffset; }
    mapping(uint256 => CarbonData) public carbonRegistry;
    uint256 private _nextTokenId;

    constructor(address initialOwner) ERC721("GreenCarbon", "GCC") Ownable(initialOwner) {}

    function mintCarbonCredit(address to, uint256, uint256 kgAmount) external onlyOwner {
        uint256 tokenId = _nextTokenId++;
        _safeMint(to, tokenId);
        carbonRegistry[tokenId] = CarbonData(kgAmount, false);
    }
}
C. 算法定价合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract AlgorithmicEnergyPricing is Ownable {
    uint256 public basePrice = 100;      // 基础价
    uint256 public loadMultiplier = 50;  // 影响系数
    uint256 public targetLoad = 1000;    // 额定负荷 (MW)
    uint256 public lastReportedLoad;

    constructor(address initialOwner) Ownable(initialOwner) {}

    function updateLoadFromOracle(uint256 _load) external onlyOwner {
        lastReportedLoad = _load;
    }

    function getCurrentPrice() public view returns (uint256) {
        if (lastReportedLoad <= targetLoad) return basePrice;
        // 公式:Price = base * (1 + (M * (L - T) / T) / 100)
        uint256 excessLoad = lastReportedLoad - targetLoad;
        uint256 dynamicPart = (loadMultiplier * excessLoad) / targetLoad;
        return basePrice + (basePrice * dynamicPart / 100);
    }
}
D. 能源市场合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract GreenEnergyMarket is Pausable, Ownable {
    error UnauthorizedDevice(address device);
    error PriceAnomalyDetected(uint256 price);

    IERC20 public energyToken;
    mapping(address => bool) public authorizedDevices;
    
    struct Offer { address provider; uint256 amountKWh; uint256 price; bool active; }
    mapping(uint256 => Offer) public offers;
    uint256 public nextOfferId;

    constructor(address _token, address initialOwner) Ownable(initialOwner) {
        energyToken = IERC20(_token);
    }

    // 硬件授权:仅限通过认证的智能电表
    function authorizeDevice(address _device) external onlyOwner {
        authorizedDevices[_device] = true;
    }

    // 上链发电数据 (由 IOT 设备调用)
    function recordProduction(address _provider, uint256 _amount) external {
        if (!authorizedDevices[msg.sender]) revert UnauthorizedDevice(msg.sender);
        // 此处可触发 NFT 铸造逻辑
    }

    // 发布卖单
    function createOffer(uint256 _amount, uint256 _price, uint256) external whenNotPaused {
        offers[nextOfferId++] = Offer(msg.sender, _amount, _price, true);
    }

    // 购买能源
    function buyEnergy(uint256 _offerId) external whenNotPaused {
        Offer storage offer = offers[_offerId];
        uint256 totalCost = offer.amountKWh * offer.price;
        offer.active = false;
        energyToken.transferFrom(msg.sender, offer.provider, totalCost);
    }

    // 安全断路器:模拟价格异常检测
    function updatePriceSafe(uint256 _newPrice) external onlyOwner {
        if (_newPrice > 500) { // 模拟极端波动阈值
            _pause();
            revert PriceAnomalyDetected(_newPrice);
        }
    }
}

三、 集成测试:Viem + Node:test 实战复盘

测试用例:Energy-Fi Protocol Full Integration

  • 算法定价:电价应随电网负荷动态调整
  • 产出上链:授权 IOT 设备应能记录发电量并触发 NFT 铸造逻辑
  • 能源交易:用户应能利用代币购买发布的能源订单
  • 权限拦截:未经授权的设备无法上链发电数据
  • 安全断路器:极端价格波动应导致市场暂停
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { parseEther, getAddress, zeroAddress, encodeFunctionData ,keccak256} from "viem";
import { network } from "hardhat";

describe("Energy-Fi Protocol Full Integration", function () {
  
  async function deployFixture() {
    const { viem } = await (network as any).connect();
    const [admin, user, iotDevice] = await viem.getWalletClients();
    const publicClient = await viem.getPublicClient();

    // 1. 部署核心合约
    // 部署能源代币 (ENT)
    const energyToken = await viem.deployContract("BoykaYuriToken", [admin.account.address, admin.account.address]);
    // 部署碳信用 NFT (GCC)
    const carbonNFT = await viem.deployContract("GreenCarbonNFT", [admin.account.address]);
    // 部署算法定价逻辑
    const pricingLogic = await viem.deployContract("AlgorithmicEnergyPricing", [admin.account.address]);
    // 部署能源市场
    const market = await viem.deployContract("GreenEnergyMarket", [energyToken.address, admin.account.address]);

    // 2. 初始化配置
    const ACCESS_MANAGER_ROLE = keccak256("DEPIN_DEVICE_ROLE"); // 假设合约中定义的角色
    // 模拟授权 IOT 设备权限
    await market.write.authorizeDevice([iotDevice.account.address], { account: admin.account });

    return {
      energyToken, carbonNFT, pricingLogic, market,
      admin, user, iotDevice,
      publicClient
    };
  }

it("算法定价:电价应随电网负荷动态调整", async function () {
    const { pricingLogic } = await deployFixture();

    const currentLoad = 1200n;
    const targetLoad = 1000n;
    const basePrice = 100n;
    const multiplier = 50n;

    await pricingLogic.write.updateLoadFromOracle([currentLoad]);
    
    // 修正脚本中的计算逻辑以匹配合约:
    const excessLoad = currentLoad - targetLoad; // 200
    const dynamicPart = (multiplier * excessLoad) / targetLoad; // 10
    const expectedPrice = basePrice + (basePrice * dynamicPart / 100n); // 110
    
    const currentPrice = await pricingLogic.read.getCurrentPrice();
    assert.equal(currentPrice, expectedPrice, "动态定价算法计算不准确"); // 现在应该匹配 110n
});
  it("产出上链:授权 IOT 设备应能记录发电量并触发 NFT 铸造逻辑", async function () {
    const { market, carbonNFT, iotDevice, user } = await deployFixture();
    
    const producedKWh = 1500n; // 产生 1500 度电
    
    // IOT 设备上链数据
    await market.write.recordProduction([user.account.address, producedKWh], {
      account: iotDevice.account,
    });

    // 验证:由于超过 1000kWh,应自动通过 AccessControl 调用 NFT 铸造 (模拟逻辑)
    // 注意:实际生产环境中需在 market 合约中 link carbonNFT 的 mint 函数
    await carbonNFT.write.mintCarbonCredit([user.account.address, 1n, 1500n]); // 手动模拟触发
    
    const nftBalance = await carbonNFT.read.balanceOf([user.account.address]);
    assert.equal(nftBalance, 1n, "达到减排标准后未正确铸造碳信用 NFT");
  });

  it("能源交易:用户应能利用代币购买发布的能源订单", async function () {
    const { market, energyToken, admin, user } = await deployFixture();
    const amountKWh = 100n;
    const pricePerKWh = 2n;

    // 1. admin 发布能源卖单
    await market.write.createOffer([amountKWh, pricePerKWh, 3600n], { account: admin.account });

    // 2. 给 user 发放 ENT 代币并授权
    const totalCost = amountKWh * pricePerKWh;
    await energyToken.write.transfer([user.account.address, totalCost], { account: admin.account });
    await energyToken.write.approve([market.address, totalCost], { account: user.account });

    // 3. user 购买能源
    await market.write.buyEnergy([0n], { account: user.account });

    // 4. 验证资金转移
    const adminBalance = await energyToken.read.balanceOf([admin.account.address]);
    assert.ok(adminBalance >= totalCost, "能源交易结算资金未正确到账");
  });

  it("权限拦截:未经授权的设备无法上链发电数据", async function () {
    const { market, user } = await deployFixture();

    await assert.rejects(
      async () => {
        await market.write.recordProduction([user.account.address, 100n], {
          account: user.account, // user 不是授权的 IOT 设备
        });
      },
      /UnauthorizedDevice/,
      "非授权设备非法上链数据未被拦截"
    );
  });

  it("安全断路器:极端价格波动应导致市场暂停", async function () {
    const { market, admin } = await deployFixture();

    // 模拟市场合约具备 Pausable 功能
    // 故意注入极端异常电价(波动率 > 30%)
    // 此处调用假设的校验接口
    await assert.rejects(
      async () => {
        await market.write.updatePriceSafe([1000n], { account: admin.account }); 
      },
      /PriceAnomalyDetected/
    );
  });
});

四、部署脚本

// scripts/deploy.js
import { network, artifacts } from "hardhat";
async function main() {
  // 连接网络
  const { viem } = await network.connect({ network: network.name });//指定网络进行链接
  
  // 获取客户端
  const [deployer] = await viem.getWalletClients();
  const publicClient = await viem.getPublicClient();
 
  const deployerAddress = deployer.account.address;
   console.log("部署者的地址:", deployerAddress);
  // 加载合约
  const BoykaYuriTokenArtifact = await artifacts.readArtifact("BoykaYuriToken");
  const GreenCarbonNFTArtifact = await artifacts.readArtifact("GreenCarbonNFT");
  const AlgorithmicEnergyPricingArtifact = await artifacts.readArtifact("AlgorithmicEnergyPricing");
  const GreenEnergyMarketArtifact = await artifacts.readArtifact("GreenEnergyMarket");
 
  // 部署(构造函数参数:recipient, initialOwner)
  const BoykaYuriTokenHash = await deployer.deployContract({
    abi: BoykaYuriTokenArtifact.abi,//获取abi
    bytecode: BoykaYuriTokenArtifact.bytecode,//硬编码
    args: [deployerAddress,deployerAddress],//部署者地址,初始所有者地址
  });
   const BoykaYuriTokenReceipt = await publicClient.waitForTransactionReceipt({ hash: BoykaYuriTokenHash });
   console.log("代币合约地址:", BoykaYuriTokenReceipt.contractAddress);
//
const GreenCarbonNFTHash = await deployer.deployContract({
    abi: GreenCarbonNFTArtifact.abi,//获取abi
    bytecode: GreenCarbonNFTArtifact.bytecode,//硬编码
    args: [deployerAddress],//部署者地址,初始所有者地址
  });
  // 等待确认并打印地址
  const GreenCarbonNFTReceipt = await publicClient.waitForTransactionReceipt({ hash: GreenCarbonNFTHash });
  console.log("绿色碳证合约地址:", GreenCarbonNFTReceipt.contractAddress);
  const AlgorithmicEnergyPricingHash = await deployer.deployContract({
    abi: AlgorithmicEnergyPricingArtifact.abi,//获取abi
    bytecode: AlgorithmicEnergyPricingArtifact.bytecode,//硬编码
    args: [deployerAddress],//部署者地址,初始所有者地址
  });
  // 等待确认并打印地址
  const AlgorithmicEnergyPricingReceipt = await publicClient.waitForTransactionReceipt({ hash: AlgorithmicEnergyPricingHash });
  console.log("算法能源定价合约地址:", AlgorithmicEnergyPricingReceipt.contractAddress);
  const GreenEnergyMarketHash = await deployer.deployContract({
    abi: GreenEnergyMarketArtifact.abi,//获取abi
    bytecode: GreenEnergyMarketArtifact.bytecode,//硬编码
    args: [BoykaYuriTokenReceipt.contractAddress,deployerAddress],//部署者地址,初始所有者地址
  });
  // 等待确认并打印地址
  const GreenEnergyMarketReceipt = await publicClient.waitForTransactionReceipt({ hash: GreenEnergyMarketHash });
  console.log("绿色能源市场合约地址:", GreenEnergyMarketReceipt.contractAddress);
}

main().catch(console.error);

五、风险提示与未来展望

当前风险

风险类型 描述 缓解方案
预言机延迟 物理数据上链延迟可能导致套利窗口 采用 Chainlink Functions + 多节点共识
硬件安全 物理电表被破解产生虚假产出 TEE 可信执行环境 + 设备信誉系统
价格波动 极端行情下的流动性枯竭 动态熔断机制 + 保险池设计
监管合规 能源交易涉及特许经营许可 渐进式去中心化,预留合规接口

未来演进

  1. DAO 治理过渡:将 onlyOwner 权限逐步迁移至 Governor 合约
  2. 跨链结算:通过 LayerZero 实现多链能源资产流通
  3. AI 预测市场:引入机器学习模型预测负荷,开启预测性交易
  4. 物理交割:与电网运营商合作,实现链上结算-物理交割闭环

总结

本文完整呈现了一套基于 DePIN + 算法定价 的 Energy-Fi 协议实现。通过 OpenZeppelin V5 的安全基座、Chainlink 的数据桥接、以及 Solidity 0.8.24 的现代化语法,构建了一个具备硬件认证、动态定价、熔断保护的去中心化能源市场。

该架构不仅适用于电力交易,更可扩展至碳排放权、水资源、带宽等一切可量化的物理资源,成为连接 Web3 与现实世界基础设施的关键协议层。

充值成功,腾讯成为OpenClaw官方赞肋商

2026年3月17日 13:16

大家好,我是凌览。

如果本文能给你提供启发或帮助,欢迎动动小手指,一键三连(点赞评论转发),给我一些支持和鼓励谢谢。


腾讯和 OpenClaw(网友口中的"龙虾")刚因数据问题针锋相对,转眼便握手言和——这场反转,全程只花了3天。

image.png

事情还得从3月12日说起。OpenClaw创始人Peter Steinberger(PSPDFKit创始人,现已加入OpenAI)当天突然在社交平台"开火",称腾讯未经沟通便抓取了ClawHub技能库1.3万多个数据,直接让服务器成本从几百美元暴涨到五位数(万美元级别)。他原话是"抄袭却不支持项目",还提到收到了腾讯关联方抱怨速率限制的邮件。

腾讯当日即回应:SkillHub是"面向中国用户的本地镜像站",已标注数据来源;上线首周处理用户流量180GB(87万次下载),但从官方源仅拉取1GB数据,实际是为OpenClaw减负。腾讯还强调团队成员本就是OpenClaw代码贡献者,希望未来成为生态赞助商。

戏剧性转折出现在3月15日:Peter直接转发了腾讯成为OpenClaw社区赞助商的博文,配文"喜欢一场精彩的救赎"。至此,腾讯轻量云已正式入驻GitHub赞助名单,与OpenAI、百度同列。

image 1.png

最绝的是这反转速度——3天前还在撕,3天后腾讯就上榜了。Peter这句love a good redemption arc翻译过来就是:腾讯的钞能力,真香👏🏻👏🏻👏🏻

莫名联想到《西虹市首富》:"你看人真准~"哈哈哈

643ea878ec654b16d26b79b54c886d13.gif

大家都在装 OpenClaw,我选择自己实现一个

作者 文叔叔
2026年3月17日 12:41

与其 clone 一个跑不起来的庞然大物,不如从零造一个真正理解的 Agent。


背景:为什么不直接用 OpenClaw?

最近 OpenClaw(开源 Agent 框架)在圈子里火了。不少人 clone 下来,配好环境变量,跑起来——然后呢?

说实话,大部分人(包括我)的体验是这样的:

  • clone → 安装依赖 → 配置一堆环境变量 → 跑起来了
  • 然后……不知道改哪里,不知道每个模块在干什么
  • 想加个工具?不知道从何下手
  • 出了 bug?日志看不懂,架构理不清

装了一个 Agent,但并没有理解 Agent。

所以我换了个思路:不装 OpenClaw,而是自己从零实现一个轻量版。

我给它取名 LiteClaw——一个用 TypeScript 从零构建的 Agent,目标是一步步复刻 OpenClaw 的核心能力,每一步都可运行、可理解。


LiteClaw 是什么

LiteClaw 不是 OpenClaw 的 fork,也不是它的简化版。它是一个面向学习的 Agent 构建教程

  • 完全用 TypeScript 编写
  • 通过飞书机器人作为交互入口(不需要搭前端)
  • 接入本地 OpenAI-compatible 模型(Qwen、DeepSeek 等)
  • 按 Phase 分阶段递进,每个阶段都是完整可运行的

最终目标是:走完所有 Phase 之后,你手上会有一个自己理解每一行代码的 Agent。


架构总览

先看一张 Phase 3 完成后的整体架构图:

architecture-phase3.png

整个系统由几个核心层组成:

  • 飞书接入层:长连接模式,本地开发不需要公网域名
  • 消息处理编排:命令路由 + Agent Loop 分发
  • Agent Loop:模型自主选择工具 → 执行 → 结果回传 → 多轮循环
  • Tool Registry:可扩展的工具注册体系
  • Conversation Store:Memory / Redis 可切换
  • Infrastructure:日志、错误分类、超时重试、限流

分阶段实现路线

这是 LiteClaw 最核心的设计理念:不一口气做完,而是分 Phase 递进。每个 Phase 解决一个核心问题,每个 Phase 都是可运行的。

Phase 1:最小可运行链路 ✅

fcb7222a36fbfbffe7ca9b36fcd9d5c2.jpg

目标:验证"消息能进来、模型能调用、结果能回去"。

这一步只做最核心的事:

  1. 飞书长连接接收消息
  2. 调用本地模型生成回复
  3. 通过飞书 API 发送回复
  4. 按 chat_id 维护多轮上下文

architecture-phase1.png

关键技术决策:

  • 飞书长连接而非 Webhook:本地开发不需要 ngrok 或公网域名
  • Vercel AI SDK + @ai-sdk/openai-compatible:统一的模型调用接口,适配任何 OpenAI-compatible 模型
  • 进程内 Map 做会话存储:最快启动,后续再换 Redis

完成 Phase 1 之后,你已经有了一个能聊天的飞书机器人。

Phase 2:Agent 基础设施 ✅

目标:从"能跑的 demo"升级成"可持续迭代的服务底座"。

一个真正的 Agent 不只是"能回复消息"。你还需要:

  • 持久化:重启不丢对话 → Redis Store
  • 可观测:出问题能定位 → 结构化 JSON 日志
  • 稳定性:外部调用有保护 → 超时 + 重试 + 限流
  • 可扩展:存储后端可替换 → 统一 Store 接口

architecture-phase2.png

这一步的核心设计是 Store 抽象

interface ConversationStore {
  getConversation(chatId: string): Promise<ConversationMessage[]>;
  appendExchange(chatId: string, userText: string, assistantText: string): Promise<void>;
  resetConversation(chatId: string): Promise<void>;
  // ...
}

业务代码只依赖接口,底层是 Map 还是 Redis 完全透明。这个设计在 OpenClaw 中也是一样的——依赖抽象,不依赖实现。

Phase 3:工具调用 + Agent Loop ✅ ← 当前完成

目标:让 Agent 从"会聊天"升级到"会做事"。

这是 Agent 最关键的一步跃迁。Phase 3 之后,LiteClaw 不再只是一个聊天机器人,而是一个能自主决策并执行动作的 Agent。

核心能力:

  • 模型自主选择工具:LLM 通过 function calling 决定是否调用工具
  • 多轮 Agent Loop:工具执行 → 结果回传 → 模型再决策 → 循环
  • 3 个内置工具current_timelocal_statushttp_fetch

agent-loop-flow.png

Agent Loop 是怎么工作的?

用户: "现在北京几点了?"
  ↓
LLM 判断: 需要调用 current_time 工具
  ↓
Runtime 执行: current_time({ timezone: "Asia/Shanghai" })
  ↓
工具返回: "2026/03/17 18:30:00"
  ↓
LLM 基于结果回复: "现在是北京时间 18:30。"

我使用了 Vercel AI SDK 的 generateText + stopWhen(stepCountIs(N)) 来实现多轮循环,不需要手写 while loop。同时保留了 LiteClaw 自己的 Tool Registry,通过 toAISDKTools() 桥接层转换格式。

新增工具只需 3 步:

// 1. 创建工具文件 src/services/tools/my-tool.ts
import { z } from "zod";
import type { LiteClawTool } from "../tools.js";

export const myTool: LiteClawTool = {
  name: "my_tool",
  description: "工具描述(给模型看的)",
  parameters: z.object({
    query: z.string().describe("参数描述")
  }),
  async run(context) {
    // 你的逻辑
    return { text: "结果" };
  }
};

// 2. 在 tools.ts 中注册
// 3. 完成。模型会自动发现并使用新工具

技术栈选择

技术 选择 为什么
Language TypeScript 类型安全,前后端统一
Runtime Node.js 20+ 成熟稳定
HTTP Hono 极轻量,适合做 Agent runtime
AI SDK Vercel AI SDK (ai v6) 内置 tool calling + agent loop
模型 OpenAI-compatible 适配 Qwen、DeepSeek 等本地模型
Schema Zod 工具参数验证,AI SDK 原生支持
接入 飞书长连接 零公网依赖,本地即可联调
存储 Memory / Redis 可切换,渐进式引入

快速上手

# 1. clone
git clone https://github.com/WarrenJones/liteClaw.git
cd liteClaw

# 2. 安装依赖
pnpm install

# 3. 配置
cp .env.example .env.local
# 填入飞书 App ID/Secret + 本地模型地址

# 4. 启动
pnpm dev

# 5. 飞书中给机器人发消息测试
# "现在几点了?" → 模型自动调用 current_time 工具
# "/status"      → 查看运行时状态
# "/tools"       → 查看已注册工具列表

后续路线

Phase 3 完成后,LiteClaw 已经具备了 Agent 的核心骨架。后续还有三个大方向:

Phase 4:记忆与状态管理

当前的"记忆"只是最近 N 轮对话。真正的 Agent 需要:

  • 短期记忆:当前会话上下文(已有)
  • 长期记忆:跨会话的用户偏好、重要信息
  • 摘要机制:对话太长时自动压缩
  • 记忆裁剪:过期信息的回收策略

Phase 5:任务执行与编排

从"单轮对话"升级到"多步任务":

  • 任务拆解:把复杂请求拆成子步骤
  • 状态机:跟踪任务执行进度
  • 进度反馈:让用户知道当前在做什么
  • 任务恢复:中断后可以继续

Phase 6:向 OpenClaw 能力对齐

最终目标——补齐完整 Agent 能力:

  • 完整的 Agent 编排系统
  • 更丰富的工具生态
  • 权限与审计机制
  • 卡片消息、文件处理、流式回复
  • 生产级部署与可观测性

为什么我建议你也试试

装 OpenClaw 当然没问题。但如果你想真正理解 Agent 是怎么工作的,我建议你也试试从零搭一个。

你会发现:

  1. Agent 的核心并不复杂:本质就是 LLM + Tool Calling + Loop
  2. 基础设施比想象中重要:日志、超时、重试、限流——这些"无聊的事"决定了你的 Agent 能不能稳定运行
  3. 分阶段构建是最好的学习路径:每个 Phase 都有明确目标,做完就有成就感
  4. 你对代码有完全的掌控力:想改就改,想加就加,不用在别人的代码里翻来翻去

LiteClaw 的所有代码都在 GitHub 上,每个 Phase 都有独立的技术文档。欢迎 star、fork、提 issue。

GitHub: github.com/WarrenJones…


总结

装 OpenClaw 自己实现 LiteClaw
上手速度 快(如果环境配得对) 慢一些,但每一步都清楚
理解深度 停留在使用层 深入到实现层
可定制性 受框架约束 完全自由
学习价值 学会了"怎么用" 学会了"怎么造"

大家都在装 OpenClaw,我选择自己实现一个。不是因为 OpenClaw 不好,而是因为——造过一遍之后,你才真正拥有它。

前端性能优化之白屏、卡顿指标和网络环境采集篇

2026年3月17日 12:06

作为一名前端工程师,我们常说“性能即体验”。但“性能”这个词太过于宏大,具体落实到用户感知的层面,其实主要就是两件事:我打开页面的速度快不快?界面操作流不流畅?

而在前端性能指标中,与此相对应的分别是白屏指标卡顿指标

1、白屏时间、首屏时间和卡顿时间的区别

举个例子,想象一下你去机场坐飞机:

  • 白屏时间:从机场大厅排队走到安检柜台前的那段时间。
  • 首屏时间:过机场安检的时间。
  • 卡顿时间:排队停止了。比如前面的人突然停下找身份证,也就是排队的队伍停止了,你就得等着,这就是卡顿。

具体反映在用户体验上:

  • 白屏时间:用户从点击链接到看到第一个字符出现的时间。
  • 首屏时间:用户从点击链接到看到主要内容出现的时间。
  • 卡顿时间:用户在操作过程中,由于浏览器或 App 处理其他任务而导致的延迟。比如点击按钮后,页面很久才有反应,这就是卡顿。

2、白屏时间采集方案

在浏览器中,白屏时间指的是从用户按下回车(或点击链接)开始,到页面上出现第一个字符的时间。

2.1 浏览器环境采集

我们可以利用浏览器的 Performance API 来计算:

白屏时间 = domLoading - navigationStart

其中,domLoading 表示浏览器开始解析 DOM 结构的时间,首屏时间就是指浏览器开始解析 DOM 结构的时间减去导航开始的时间。

方式一:window.performance.timing(已被废弃,不推荐)

function getDomLoadingTime() {
  if (!window.performance || !window.performance.timing) {
    return null;
  }
  
  const timing = window.performance.timing;
  const navigationStart = timing.navigationStart;
  const domLoading = timing.domLoading;
  
  if (navigationStart === 0 || domLoading === 0) {
    return null;
  }
  
  return domLoading - navigationStart;
}

window.addEventListener('load', function() {
  const domLoadingTime = getDomLoadingTime();
  if (domLoadingTime) {
    console.log('DOM 开始解析时间:', domLoadingTime, 'ms');
  }
});

方式二:使用 PerformanceObserver(推荐)

new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    if (entry.name === 'first-contentful-paint') {
      console.log('首屏时间(基于FCP):', entry.startTime);
    }
  }
}).observe({ entryTypes: ['paint'] });

2.2 App (WebView) 环境采集

与浏览器环境不同的是,在 APP(Webview) 环境中,多了一个启动浏览器内核,也就是 Webview 初始化的过程,这就好比你要过安检,安检员需要先把安检机器给打开。

所以在这个环境下,检测白屏时间需要 APP 端配合。在 APP 创建 Webview 时,需要打一个点,在开始建立网络连接时,再打一个点,这两个点之间的差值,就是初始化的耗时,在计算白屏时间的时候,需要加上这部分的耗时。

当然,我们也可以通过并行初始化的方案,来规避掉这部分的耗时。也就是在 APP 启动时,并行初始化 Webview,并放入 Webview 池,当用户需要打开 H5 页面时,直接从池子里取出来,而不需要重新初始化。当然,也需要对 Webview 池进行最大容量限制,避免内存占用过多。

3、卡顿

卡顿很好理解,直白点来说就是页面“卡”住了。比如用户点击了一个按钮,但是页面过了 3-4s 才有反应,或者页面某些动画看起来很不流畅,这都是卡顿的现象。

3.1 怎么判定卡顿呢?

FPS(Frames Per Second),即每秒帧数,是衡量页面流畅度的一个指标。一般来说,FPS 超过 60 就可以认为是流畅的,也就是单帧渲染时间在 16.6ms(1000ms / 60) 以下。因为刷新率超过 60 Hz 时,人脑无法区分出单独的帧,会将快速变化的静态图片序列融合成一个平滑、连续的动态视觉体验。

但也如果用平均帧数 > 60 去判断页面是否卡顿,也是不靠谱的,无法描述画面的抖动情况。比如几帧只用了 8ms 渲染,中间有两帧达到了 200ms,总体平均下来还是超过 60FPS,但用户中途却感觉到了明显的卡顿。

3.2 浏览器环境采集

浏览器并没有直接提供 FPS 指标,但是我们可以通过 requestAnimationFrame 来变相计算。

// 统计FPS
function calculateFPS() {
  let lastTime = performance.now(); // performance.now() 比 Date.now() 更精准
  let frameCount = 0;

  function animate() {
    const currentTime = performance.now();
    const deltaTime = currentTime - lastTime;

    if (deltaTime >= 1000) {
      const fps = frameCount / (deltaTime / 1000);
      console.log('当前 FPS:', fps);
      lastTime = currentTime;
      frameCount = 0;
    }

    frameCount++;
    requestAnimationFrame(animate);
  }

  animate();
}
calculateFPS();

判定标准:如果连续 3 帧的刷新频率都低于 20 FPS,且保持恒定,就意味着出现了卡顿。

3.3 App 环境采集

App 原生可以拿到每一帧的具体渲染耗时。

判定标准

  • 一般卡顿:连续 5 帧渲染时间超过 50ms。
  • 严重卡顿:单帧渲染时间超过 250ms。

4、如何采集用户的网络环境?

用户的网络环境有很多种,比如 5G、4G、WiFi、3G/2G 等。我们需要通过采集真实用户的网络环境,从而绘制出具体的网速分布图,进行针对性优化。

采取图片测速法来采集用户的网络环境,具体步骤如下:

  1. 加载两张图片,一张极小的(比如 1x1像素),一张稍大的(比如 3x3像素)。
  2. 记录图片请求开始到 onLoad 完成的时间。
  3. 计算速度:文件大小 / 加载时间 = 网速
  4. 取平均值,把两个数据取个平均值,减少误差。

最常见的优化例子就是,在用户观看视频时,根据用户的网络情况,分别加载不同清晰度的视频,高清、标清、流畅等。

5、总结

以上主要介绍了白屏时间和卡顿指标的区别和采集方案,以及如何采集用户的网络环境:

  • 白屏时间相当于你去安检柜台的时间,首屏时间指的是过安检的时间,卡顿指的是排队停止了。
  • 白屏时间的采集:浏览器通过 PerformanceObserver, APP Webview 环境需要 APP 配合进行打点采集。
  • 不能简单用平均帧数超过 60FPS来判断页面是否卡顿,在浏览器环境,如果连续 3 帧的刷新频率都低于 20 FPS,且保持恒定,就意味着出现了卡顿,在 App 环境中,连续 5 帧渲染时间超过 50ms 判定为一般卡顿单帧渲染时间超过 250ms 判定为严重卡顿
  • 一般用图片测速法来采集用户的网络环境,并根据用户的网速分布图来进行优化。

往期回顾

前端 SSE 流式请求实战:打造流畅的 AI 流式应答体验

作者 leafyyuki
2026年3月17日 11:59

一、引言:从需求到架构的清晰映射

在开发大模型应用或 AI Agent 界面时,流式响应(Streaming Response)已成为提升用户体验的关键。与一次性返回完整结果不同,流式响应允许数据分块、实时地传回前端。这带来了新的挑战:如何以友好、自然的方式呈现这些“涓涓细流”?

本文将深入探讨一套基于 Vue 3 的完整前端解决方案,旨在将原始的服务器发送事件(SSE)流,转化为具有“打字机”动效、并支持 Markdown 实时渲染的富文本交互界面。我们关注的不只是功能实现,更是代码的可维护性、模块的复用性以及交互的流畅性。


二、核心设计目标

在开始编码前,明确我们要达成的核心目标至关重要。这能帮助我们做出正确的技术决策。

  1. 功能完整性:稳定处理从建立 SSE 连接、接收数据、解析事件到处理完成、错误和中止的全生命周期。
  2. 表现层动效:实现逐字输出的“打字机”效果,让 AI 的思考过程更具临场感,避免内容突然全部涌现造成的跳跃感。
  3. 内容富文本化:后端流式返回的通常是 Markdown 源码,前端需要将其实时、准确地渲染为格式化的 HTML(支持加粗、列表、代码块等)。
  4. 架构清晰度:采用关注点分离(Separation of Concerns)原则,将网络通信、展示逻辑和渲染逻辑解耦,使每个部分都易于理解、测试和复用。

基于以上目标,我们选择了以下技术栈,它们在功能、成熟度和社区支持上达到了良好平衡:

能力维度 技术选型 选型理由
SSE 通信 @microsoft/fetch-event-source 在标准 EventSource基础上,提供了对 POST请求、自定义请求头、请求中止等关键功能的支持,更适合生产环境。
Markdown 渲染 markdown-it 一个高效、可配置性极高的 Markdown 解析器。其插件生态系统丰富,可以轻松扩展(如支持代码高亮、数学公式等)。
状态与响应式 Vue 3 Composition API (refcomputedwatch) 组合式 API 为我们提供了组织逻辑的极大灵活性,能够非常清晰地将不同关注点的代码封装在独立的 Hook 中。

三、架构全景:三层职责与数据流

一个清晰的架构是项目可维护性的基石。我们将整个流程抽象为三个层次,数据如同流水线一般依次经过:

[ 数据获取层 Data Layer ]
        ┌─────────────────────────┐
        │   useStreamRun Hook     │
        │ 职责:网络I/O,状态管理  │
        │ 产出:content, thoughts, │
        │       loading, error    │
        └───────────┬─────────────┘
                    │ (原始数据流)
                    ▼
        [ 表现层 Presentation Layer ]
        ┌─────────────────────────┐
        │   useTypewriter Hook    │
        │ 职责:控制文本展示节奏   │
        │ 产出:displayedText     │
        │      (动态字符串)       │
        └───────────┬─────────────┘
                    │ (待渲染文本)
                    ▼
        [ 渲染层 Rendering Layer ]
        ┌─────────────────────────┐
        │     MdRender 组件       │
        │ 职责:文本 → 富文本     │
        │ 产出:HTML (v-html)     │
        └─────────────────────────┘
                    │
                    ▼
            最终的用户界面

各层职责详解

  • useStreamRun(数据层) :这是与后端服务的唯一对话窗口。它负责发起 SSE 请求、管理连接生命周期、解析服务端推送的事件,并将原始数据更新到响应式状态。它不关心数据如何被展示。
  • useTypewriter(表现层) :这是一个纯粹的无副作用函数。它监听数据层提供的文本变化,并通过定时器模拟逐字打印的动画效果。它不关心数据从哪里来,也不关心数据最终被渲染成什么样,只负责“如何展示一段文本的变更过程”。
  • MdRender(渲染层) :这是一个纯展示组件。它接收一段文本(通常是打字机 Hook 输出的动态文本),利用 markdown-it将其转换为 HTML 并安全地插入到 DOM 中。它不关心文本是流式来的还是一次性来的。

这种“高内聚、低耦合”的设计带来了巨大优势:每一层都可以独立演进、测试和复用。例如,useTypewriter不仅可以用于流式 AI 响应,任何需要逐字动画的场景都可以使用它。


四、模块深度解析与最佳实践

4.1 useStreamRun: 构建稳健的数据通道

此 Hook 是系统的基石,其健壮性直接决定了用户体验的上限。以下是我们实现中重点考虑的几个方面及其价值:

  • 并发请求与状态防覆盖:通过引入 runId机制,确保了只有最新发起的请求能够更新全局的 loading和 error状态。这彻底解决了用户快速连续点击“发送”按钮时,旧请求的“完成”信号覆盖新请求“进行中”状态的问题。
  • 优雅的请求中止:利用 AbortController,我们能够在发起新请求或组件卸载时,主动中止未完成的旧请求。这不仅节省了用户流量和服务器资源,也避免了潜在的内存泄漏。在错误处理中,我们特别识别 AbortError并不将其视为真正的错误,使逻辑更清晰。
  • 精细化的错误处理:错误被分为不同层级处理。网络错误在 onerror回调中捕获;服务器端业务逻辑错误(如工作流执行失败)在 onmessage中通过解析特定事件(如 workflow_finished且状态为 failed)来捕获;其他未预料异常在顶层的 try...catch中兜底。这种分级处理使得错误提示可以更精确。

4.2 useTypewriter: 赋予文字生命力与节奏感

打字机效果的核心是控制“时间”和“内容”的映射关系。

  • 核心机制:该 Hook 通过一个 setInterval定时器,定期将 displayedText向目标的 sourceRef值“追赶”一个字符。当两者长度相等时,定时器停止。
  • 状态同步:通过 watch监听源文本变化。当新文本到来时,如果当前没有活跃的定时器,则启动一个新的。这确保了文本流能连续、平滑地动画下去。
  • 用户体验优化catchUp函数是点睛之笔。在流式传输结束时(loading变为 false),一次性将剩余文字全部补齐。这避免了一个尴尬场景:当最后一段文字很长时,用户需要等待漫长的“表演”时间。catchUp确保了信息获取的效率与动画效果的趣味性取得了平衡。

4.3 MdRender: 安全高效的内容渲染

渲染 Markdown 时,安全和性能是首要考虑。

  • 单例模式:在组件内部,markdown-it实例只创建一次(通过 new MarkdownIt()),而不是在每次渲染时创建。这通过 Vue 的 computed属性或直接在 setup顶部声明来实现,避免了不必要的性能开销。
  • 安全性:虽然示例中启用了 html: true以支持 Markdown 中的原生 HTML 标签,但这在部分场景下可能存在 XSS 风险。如果你的内容完全可信,可以保留;如果内容来自不可完全信任的源,建议将其设置为 false,或使用 markdown-it的相应插件进行白名单过滤。
  • 样式与高亮:通过 .markdown-body类名,可以方便地接入现有的 CSS 样式库(如 GitHub Markdown 样式)来实现一致美观的排版。通过集成 highlight.js等库,可以轻松为代码块添加语法高亮,这对技术类 AI 助手的回答呈现至关重要。

五、在业务中组合:创建可复用的对话块

在真实业务组件中,我们将上述模块像乐高积木一样组合起来。

// 业务组合 Hook: useStreamBlock
import { computed, watch } from 'vue';
import { useStreamRun } from './useStreamRun';
import { useTypewriter } from './useTypewriter';

export function useStreamBlock({ url, inputs } = {}) {
  // 1. 建立数据通道
  const { run, loading, error, content, thoughts, abort } = useStreamRun({ url, inputs });

  // 2. 为内容和思考过程分别创建打字机实例
  const { displayedText: displayedContent, catchUp: contentCatchUp } = useTypewriter({
    sourceRef: content,
    speed: 25, // 主内容速度
  });
  const { displayedText: displayedThoughts, catchUp: thoughtsCatchUp } = useTypewriter({
    sourceRef: thoughts,
    speed: 15, // 思考过程可以更快
  });

  // 3. 流结束时,瞬间补齐文字
  watch(loading, (isLoading) => {
    if (!isLoading) {
      thoughtsCatchUp();
      contentCatchUp();
    }
  });

  // 4. 暴露组合后的状态与方法
  return {
    // 控制方法
    run,
    abort,
    // 状态
    loading,
    error,
    // 用于渲染的动态文本
    displayedContent,
    displayedThoughts,
  };
}

在 Vue 组件中,使用变得极其简洁:

<template>
  <div>
    <button @click="run" :disabled="loading">发送问题</button>
    <div v-if="loading">AI 正在思考...</div>
    <div v-if="error" class="error-message">{{ error }}</div>
    <MdRender v-if="displayedThoughts" :content="displayedThoughts" class="thoughts" />
    <MdRender v-if="displayedContent" :content="displayedContent" class="main-content" />
  </div>
</template>

<script setup>
import { useStreamBlock } from '@/composables/useStreamBlock';
import MdRender from '@/components/MdRender.vue';

const { run, loading, error, displayedContent, displayedThoughts } = useStreamBlock({
  url: '/api/chat/completions',
  inputs: { message: '你好,请用 Vue 写一个计数器组件。' }
});
</script>

六、应对边缘情况与增强健壮性

一套完整的方案必须考虑各种边界条件。

  1. 处理空响应:当流式请求成功完成,但 content和 thoughts均为空字符串时,应展示友好的“无结果”提示,而非空白。这需要在业务组件中增加对 !loading && !error && !displayedContent状态的判断。
  2. 错误信息的友好化:后端返回的错误信息结构可能不统一。可以在 useStreamRun的 onmessage或 catch块中,实现一个辅助函数来从不同深度的响应结构中提取可读的错误信息,确保用户看到的是清晰提示,而非晦涩的 JSON。
  3. 参数变化与自动重试:当用户更改了查询条件(如问题或筛选器),我们需要自动发起新的请求。这可以通过 watch监听输入参数,并在变化时先调用 abort()中止当前请求,再调用新的 run()来实现。注意加入防抖(debounce)以避免过于频繁的请求。
  4. 滚动体验优化:在内容逐字输出时,页面自动滚动到底部以跟随最新内容,是一个提升体验的细节。这可以通过在 useTypewriter的 tick函数中触发一个自定义事件,或在父组件中 watch``displayedContent的变化并操作 DOM 来实现。

七、总结:方案价值与复用性

本方案提供了一套从数据接收到最终渲染的完整、模块化的前端流式处理链路。

模块 核心价值 可复用场景
**useStreamRun** 提供了生产级的 SSE 请求管理,包含并发控制、错误处理与状态管理。 任何需要消费 text/event-stream的场合,如实时日志、通知推送、股票行情等。
**useTypewriter** 将任何文本流或动态文本转化为具有节奏感的逐字输出动画。 游戏对话、产品介绍动画、命令行模拟器、任何需要逐步揭示文本的场景。
**MdRender** 将 Markdown 文本安全、高效地转换为富文本 HTML。 博客系统、文档站点、评论区的富文本展示等。
组合模式 展示了如何将底层能力灵活组合,快速构建出复杂的业务功能(如 AI 对话块)。 任何需要“流式数据 + 动画展示 + 富文本渲染”的复合型功能。

核心优势在于其清晰的分层架构关注点分离。每个模块职责单一,接口明确,使得它们不仅可以协同工作,更能轻松地独立测试、调试和被其他项目复用。可以通过此方案设计生成skill,你可以为你产品的 AI 特性或任何流式交互界面,提供一个流畅、专业且易于维护的前端实现基础。

vue表格vxe-table实现表头合并,分组表头自定义合并

作者 卤蛋fg6
2026年3月17日 11:53

在开发后台管理系统时,经常会遇到需要展示复杂表格的场景,其中表头合并(多级表头、不规则合并)是一项常见需求。vxe-table 是一款功能强大的 Vue 表格组件,它不仅支持树形分组表头,还提供了自定义列头合并的功能,允许开发者灵活地将任意单元格进行合并,满足各种复杂的表头设计。

形分组表头 vs 自定义合并表头

vxe-table 默认支持树形分组表头,只需在列配置中定义 children 即可实现多级表头。例如:

columns: [
  { field: 'name', title: '姓名' },
  {
    title: '基本信息',
    children: [
      { field: 'sex', title: '性别' },
      { field: 'age', title: '年龄' }
    ]
  }
]

这种方式的优点是简单直观,但只能按层级自动生成表头,无法实现跨层级的任意合并(例如合并第一列的“姓名”和“性别”)。

开启自定义表头合并

定义合并表头则允许我们完全控制表头的每个单元格,通过 mergeHeaderCells 配置将任意位置的单元格合并,实现更灵活的布局。

  • 要使用自定义合并,需要在表格组件上设置两个关键属性:
  • show-custom-header:开启自定义表头渲染模式。
  • merge-header-cells:定义合并规则的数组。

mergeHeaderCells 配置详解

  • 行/列索引规则:
  • 行索引从上到下递增,0 表示最顶层的表头行。
  • 列索引从左到右递增,0 表示第一列(通常是序号列或第一列数据列)。 如果存在多级表头,最终渲染的表头行数由列配置的层级深度决定。

例如,合并第一列和第二列(两行高度)的规则为:

mergeHeaderCells: [
  { row: 0, col: 0, rowspan: 2, colspan: 1 }, // 合并第一列的两行
  { row: 0, col: 1, rowspan: 2, colspan: 1 }  // 合并第二列的两行
]

代码

image

<template>
  <div>
    <vxe-button @click="setMerge1">设置合并1</vxe-button>
    <vxe-button @click="setMerge2">设置合并2</vxe-button>
    <vxe-button status="success" @click="saveMergeData">获取合并规则</vxe-button>

    <vxe-grid  ref="gridRef" v-bind="gridOptions"></vxe-grid>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'

const gridRef = ref()

const gridOptions = reactive({
  border: true,
  showCustomHeader: true,
  height: 400,
  mergeHeaderCells: [
    { row: 0, col: 0, rowspan: 2, colspan: 1 },
    { row: 0, col: 1, rowspan: 2, colspan: 1 },
    { row: 0, col: 2, rowspan: 1, colspan: 2 },
    { row: 0, col: 4, rowspan: 1, colspan: 2 },
    { row: 1, col: 6, rowspan: 1, colspan: 2 },
    { row: 0, col: 8, rowspan: 2, colspan: 1 }
  ],
  columns: [
    { type: 'seq', width: 70 },
    { field: 'name', title: 'Name' },
    {
      title: 'Group1',
      field: 'group1',
      headerAlign: 'center',
      children: [
        { field: 'sex', title: 'Sex' },
        { field: 'age', title: 'Age' }
      ]
    },
    {
      field: 'group3',
      title: 'Group3',
      headerAlign: 'center',
      children: [
        { field: 'attr5', title: 'Attr5' },
        { field: 'attr6', title: 'Attr6' }
      ]
    },
    {
      field: 'group6',
      title: 'Attr3',
      children: [
        { field: 'attr3', title: 'Group8', headerAlign: 'center' }
      ]
    },
    {
      field: 'group8',
      title: 'Attr4',
      children: [
        { field: 'attr4', title: 'Attr4' }
      ]
    },
    { field: 'address', title: 'Address' }
  ],
  data: [
    { id: 10001, name: 'Test1', role: 'Develop', sex: 'Man', age: 46, attr3: 22, attr4: 100, attr5: 66, attr6: 86, address: 'Guangzhou' },
    { id: 10002, name: 'Test2', role: 'Test', sex: 'Women', age: 0, attr3: 0, attr4: 0, attr5: 0, attr6: 0, address: 'Shenzheng' },
    { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', age: 0, attr3: 22, attr4: 0, attr5: 0, attr6: 0, address: 'Shanghai' },
    { id: 10004, name: 'Test4', role: 'Designer', sex: 'Women', age: 0, attr3: 0, attr4: 0, attr5: 0, attr6: 0, address: 'Guangzhou' },
    { id: 10005, name: 'Test5', role: 'Test', sex: 'Women', age: 0, attr3: 0, attr4: 0, attr5: 0, attr6: 0, address: 'Shenzheng' },
    { id: 10006, name: 'Test6', role: 'Develop', sex: 'Man', age: 0, attr3: 0, attr4: 0, attr5: 0, attr6: 0, address: 'Guangzhou' },
    { id: 10007, name: 'Test7', role: 'Designer', sex: 'Women', age: 0, attr3: 0, attr4: 0, attr5: 0, attr6: 0, address: 'Guangzhou' },
    { id: 10008, name: 'Test8', role: 'Test', sex: 'Man', age: 0, attr3: 0, attr4: 0, attr5: 0, attr6: 0, address: 'Guangzhou' }
  ]
})

const setMerge1 = () => {
  gridOptions.mergeHeaderCells = [
    { row: 0, col: 0, rowspan: 2, colspan: 1 },
    { row: 0, col: 1, rowspan: 2, colspan: 1 },
    { row: 0, col: 2, rowspan: 1, colspan: 2 },
    { row: 0, col: 4, rowspan: 1, colspan: 2 },
    { row: 1, col: 6, rowspan: 1, colspan: 2 },
    { row: 0, col: 8, rowspan: 2, colspan: 1 }
  ]
}

const setMerge2 = () => {
  gridOptions.mergeHeaderCells = [
    { row: 0, col: 0, rowspan: 2, colspan: 1 },
    { row: 0, col: 1, rowspan: 2, colspan: 1 },
    { row: 0, col: 2, rowspan: 1, colspan: 4 },
    { row: 1, col: 6, rowspan: 1, colspan: 3 }
  ]
}

const saveMergeData = () => {
  const $grid = gridRef.value
  if ($grid) {
    const mergeList = $grid.getMergeHeaderCells()
    console.log(mergeList)
  }
}
</script>

当使用自定义表头合并后,被合并的列将不支持通过拖拽调整列宽。这是因为合并后的单元格在结构上已经不是独立的列,拖拽行为难以精确定义。如果需要调整列宽,建议在合并前规划好列宽,或通过固定宽度配置。

vxetable.cn

基于vue3的极简登录架构设计

作者 炁场悟道
2026年3月17日 11:39

1. axios封装:request.ts

作用:统一处理请求,自动携带token,401跳转

// src/api/request.ts
import axios from 'axios'

// 创建axios实例
const request = axios.create({
  baseURL: '/api',
  timeout: 10000
})

// 请求拦截器:自动添加token
request.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => Promise.reject(error)
)

// 响应拦截器:统一处理错误
request.interceptors.response.use(
  (response) => response.data,
  (error) => {
    if (error.response?.status === 401) {
      // token过期,清除token
      localStorage.removeItem('token')
      // 跳转到登录页
      window.location.href = '/login'
    }
    return Promise.reject(error)
  }
)

export default request

2. 接口定义:auth.ts

作用:定义所有登录相关的接口

// src/api/auth.ts
import request from './request'

// 登录接口
export const loginApi = (username: string, password: string) => {
  return request.post('/login', {
    username,
    password
  })
}

// 获取用户信息接口
export const getUserInfoApi = () => {
  return request.get('/user-info')
}

3. Store:auth.ts

作用:管理登录状态,token存localStorage,用户信息只存内存,刷新页面重新调接口获取最新用户信息

// src/store/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { loginApi, getUserInfoApi } from '@/api/auth'

export const useAuthStore = defineStore('auth', () => {
  // token需要持久化,因为请求要用
  const token = ref(localStorage.getItem('token') || '')
  
  // 用户信息只存在内存里,刷新就没了
  const userInfo = ref<any>(null)
  
  // 计算属性
  const isLoggedIn = computed(() => !!token.value)
  const username = computed(() => userInfo.value?.username || '')
  const nickname = computed(() => userInfo.value?.nickname || '')
  
  // 登录
  const login = async (user: string, pwd: string, remember: boolean) => {
    try {
      // 1. 调用登录接口
      const res = await loginApi(user, pwd) as any
      
      // 2. 保存token(必须存,不然刷新就丢了)
      token.value = res.token
      localStorage.setItem('token', res.token)
      
      // 3. 获取用户信息(存内存)
      await getUserInfo()
      
      // 4. 如果记住用户名,保存用户名(不是密码)
      if (remember) {
        localStorage.setItem('savedUsername', user)
      } else {
        localStorage.removeItem('savedUsername')
      }
      
      return true
    } catch (error) {
      console.error('登录失败', error)
      return false
    }
  }
  
  // 获取用户信息(每次刷新页面都要重新获取)
  const getUserInfo = async () => {
    // 如果没有token,不获取
    if (!token.value) {
      return null
    }
    
    try {
      const res = await getUserInfoApi() as any
      userInfo.value = res
      return res
    } catch (error) {
      console.error('获取用户信息失败', error)
      // 如果获取失败(比如token过期),清除登录状态
      logout()
      throw error
    }
  }
  
  // 退出登录
  const logout = () => {
    token.value = ''
    userInfo.value = null
    localStorage.removeItem('token')
    // 注意:记住的用户名不清除,方便下次登录
  }
  
  return {
    token,
    userInfo,
    isLoggedIn,
    username,
    nickname,
    login,
    logout,
    getUserInfo
  }
})

4. 路由:index.ts

作用:控制页面访问权限,刷新页面时重新获取用户信息

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/store/auth'

const routes = [
  {
    path: '/login',
    name: 'login',
    component: () => import('@/views/Login.vue')
  },
  {
    path: '/',
    name: 'home',
    component: () => import('@/views/Home.vue'),
    meta: { requiresAuth: true }
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

router.beforeEach(async (to, from, next) => {
  const authStore = useAuthStore()
  
  // 如果已登录且要去登录页,先退出再过去
  if (to.path === '/login' && authStore.isLoggedIn) {
    authStore.logout()
    next()
    return
  }
  
  // 如果需要登录
  if (to.meta.requiresAuth) {
    // 如果有token
    if (authStore.isLoggedIn) {
      // 如果没有用户信息,说明是刷新页面,需要重新获取
      if (!authStore.userInfo) {
        try {
          await authStore.getUserInfo()
          next()
        } catch (error) {
          // 获取用户信息失败(token过期等),已经自动退出
          next('/login')
        }
      } else {
        // 有用户信息,直接放行
        next()
      }
    } else {
      // 没token,去登录
      next('/login')
    }
  } else {
    next()
  }
})

export default router

5. 登录页:Login.vue

作用:用户输入账号密码

<!-- src/views/Login.vue -->
<template>
  <div class="login">
    <h2>用户登录</h2>
    
    <div class="form">
      <div class="field">
        <label>用户名:</label>
        <input 
          v-model="form.username" 
          type="text"
          placeholder="请输入用户名"
        />
      </div>
      
      <div class="field">
        <label>密码:</label>
        <input 
          v-model="form.password" 
          type="password"
          placeholder="请输入密码"
        />
      </div>
      
      <div class="field">
        <label>
          <input type="checkbox" v-model="form.remember" />
          记住用户名
        </label>
      </div>
      
      <div v-if="error" class="error">{{ error }}</div>
      
      <button 
        @click="handleLogin"
        :disabled="!form.username || !form.password || loading"
      >
        {{ loading ? '登录中...' : '登录' }}
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/store/auth'

const router = useRouter()
const authStore = useAuthStore()

const form = reactive({
  username: '',
  password: '',
  remember: false
})

const loading = ref(false)
const error = ref('')

// 页面加载时,如果有保存的用户名就自动填充
onMounted(() => {
  const savedUsername = localStorage.getItem('savedUsername')
  if (savedUsername) {
    form.username = savedUsername
    form.remember = true
  }
})

const handleLogin = async () => {
  if (!form.username || !form.password) {
    error.value = '请输入用户名和密码'
    return
  }
  
  loading.value = true
  error.value = ''
  
  try {
    const success = await authStore.login(
      form.username, 
      form.password, 
      form.remember
    )
    
    if (success) {
      router.push('/')
    } else {
      error.value = '用户名或密码错误'
    }
  } catch (e: any) {
    error.value = e.response?.data?.message || '登录失败,请稍后重试'
  } finally {
    loading.value = false
  }
}
</script>

<style scoped>
.login {
  max-width: 400px;
  margin: 100px auto;
  padding: 20px;
  border: 1px solid #ccc;
  border-radius: 8px;
}
.field {
  margin-bottom: 15px;
}
.field label {
  display: block;
  margin-bottom: 5px;
}
.field input[type="text"],
.field input[type="password"] {
  width: 100%;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
  box-sizing: border-box;
}
.error {
  color: red;
  margin-bottom: 10px;
}
button {
  width: 100%;
  padding: 10px;
  background: #1890ff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
button:disabled {
  background: #ccc;
  cursor: not-allowed;
}
</style>

6. 首页:Home.vue

作用:展示用户信息,处理加载状态

<!-- src/views/Home.vue -->
<template>
  <div class="home">
    <h1>首页</h1>
    
    <!-- 加载中状态 -->
    <div v-if="loading" class="loading">
      加载用户信息中...
    </div>
    
    <!-- 展示用户信息 -->
    <div v-else-if="authStore.userInfo" class="user-info">
      <h2>用户信息</h2>
      <p><strong>用户名:</strong> {{ authStore.userInfo.username }}</p>
      <p><strong>昵称:</strong> {{ authStore.userInfo.nickname || '未设置' }}</p>
      <p><strong>邮箱:</strong> {{ authStore.userInfo.email || '未设置' }}</p>
      <p><strong>手机:</strong> {{ authStore.userInfo.phone || '未设置' }}</p>
      <p><strong>角色:</strong> {{ authStore.userInfo.roles?.join(', ') || '普通用户' }}</p>
    </div>
    
    <button @click="handleLogout" class="logout-btn">
      退出登录
    </button>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/store/auth'

const router = useRouter()
const authStore = useAuthStore()
const loading = ref(false)

// 页面加载时确保用户信息是最新的
onMounted(async () => {
  // 如果store里没有用户信息(比如刷新页面),就获取一下
  if (!authStore.userInfo) {
    loading.value = true
    try {
      await authStore.getUserInfo()
    } catch (error) {
      console.error('获取用户信息失败', error)
    } finally {
      loading.value = false
    }
  }
})

const handleLogout = () => {
  authStore.logout()
  router.push('/login')
}
</script>

<style scoped>
.home {
  max-width: 600px;
  margin: 50px auto;
  padding: 20px;
}
.user-info {
  background: #f5f5f5;
  padding: 20px;
  border-radius: 8px;
  margin: 20px 0;
}
.user-info p {
  margin: 10px 0;
}
.loading {
  color: #999;
  text-align: center;
  padding: 20px;
}
.logout-btn {
  padding: 10px 20px;
  background: #ff4d4f;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

7. 其他必需文件

// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'

const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
<!-- src/App.vue -->
<template>
  <router-view />
</template>
// src/vite-env.d.ts
/// <reference types="vite/client" />

declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

Webpack的核心概念?常见优化手段?

作者 光影少年
2026年3月17日 11:36

一、Webpack 核心概念

Webpack 本质是一个 模块打包器(module bundler) ,核心思想就一句话:

👉 把一切资源当成模块,然后构建依赖图,打包输出

1. Entry(入口)

  • 项目从哪里开始打包
entry: './src/index.js'

👉 相当于依赖分析的起点


2. Output(输出)

  • 打包后的文件输出到哪里
output: {
  filename: 'bundle.js',
  path: path.resolve(__dirname, 'dist')
}

3. Module(模块)

Webpack 默认只认识 JS、JSON

👉 其他资源(CSS、图片、TS)都需要处理


4. Loader(加载器)

👉 让 Webpack 能处理非 JS 文件

本质:文件转换器

例如:

module: {
  rules: [
    {
      test: /.css$/,
      use: ['style-loader', 'css-loader']
    }
  ]
}

常见 loader:

  • babel-loader(ES6 → ES5)
  • ts-loader(TS → JS)
  • css-loader / style-loader
  • file-loader / url-loader(已被 asset modules 替代)

5. Plugin(插件)

👉 扩展 Webpack 功能(更强大)

例如:

plugins: [
  new HtmlWebpackPlugin(),
  new MiniCssExtractPlugin()
]

常见用途:

  • 生成 HTML
  • 压缩代码
  • 提取 CSS
  • 环境变量注入

6. Mode(模式)

mode: 'development' | 'production'

区别:

  • development:快,不压缩
  • production:慢,但优化好

7. Chunk(代码块)

👉 打包过程中生成的代码块

来源:

  • 入口
  • 动态 import
  • splitChunks

8. Bundle(最终文件)

👉 浏览器真正加载的文件


9. Dependency Graph(依赖图)

👉 Webpack 最核心机制

index.jsa.jsb.js → c.css

Webpack 会递归分析所有依赖 → 构建图 → 打包


二、常见优化手段(重点🔥)

优化可以分为三类:


1️⃣ 构建速度优化(开发体验)

✅ 缩小打包范围

include: path.resolve(__dirname, 'src')
exclude: /node_modules/

✅ 使用缓存

cache: {
  type: 'filesystem'
}

✅ 多进程打包(thread-loader)

use: ['thread-loader', 'babel-loader']

✅ 使用更快的工具替代

  • babel → swc / esbuild
  • terser → esbuild压缩

✅ 合理使用 sourceMap

devtool: 'eval-cheap-module-source-map'

✅ 开发环境用 HMR(热更新)

devServer: {
  hot: true
}

2️⃣ 打包体积优化(上线性能)

✅ Tree Shaking(摇树优化)

👉 删除无用代码(ES Module 必须)

usedExports: true

前提:

  • 使用 ES Module(import/export)
  • package.json 设置:
"sideEffects": false

✅ 代码分割(Code Splitting)

动态加载

import('./module')

配置拆包

optimization: {
  splitChunks: {
    chunks: 'all'
  }
}

👉 拆分:

  • vendor(第三方库)
  • 公共代码

✅ 压缩代码

默认 production 已开启:

  • JS:Terser
  • CSS:css-minimizer-webpack-plugin

✅ 提取 CSS

new MiniCssExtractPlugin()

👉 避免 JS 里插入 style,提高加载性能


✅ 图片优化

  • 使用 asset/resource
  • 小图转 base64
  • 图片压缩(image-minimizer)

✅ Gzip / Brotli 压缩

new CompressionPlugin()

3️⃣ 运行时性能优化

✅ 懒加载(Lazy Load)

const Comp = React.lazy(() => import('./Comp'))

✅ CDN 加速

externals: {
  react: 'React'
}

👉 不打包 React,用 CDN


✅ 缓存优化(关键🔥)

文件名加 hash

filename: '[name].[contenthash].js'

👉 浏览器长期缓存


✅ runtime 分离

optimization: {
  runtimeChunk: 'single'
}

三、面试总结(可以直接背)

👉 核心概念一句话:

Webpack 是一个基于依赖图的模块打包工具,通过 Entry 构建依赖关系,使用 Loader 处理模块,Plugin 扩展功能,最终输出 Bundle。

👉 优化三板斧:

  1. 构建优化

    • 缓存、多线程、缩小范围
  2. 体积优化

    • Tree Shaking、代码分割、压缩
  3. 运行优化

    • 懒加载、CDN、缓存

前端发版后页面白屏?一套解决用户停留旧页面问题的完整方案

作者 Lsx_
2026年3月17日 11:28

场景

在单页面应用(SPA)项目中,有一个问题非常常见,但又经常被低估:系统明明已经发布了新版本,部分用户却依然停留在旧页面中继续操作

大多数时候,这种状态并不会立刻出问题,所以团队往往不太在意。但一旦用户继续进行路由跳转、访问懒加载页面,或者触发某些依赖新资源的操作,就可能出现下面这些现象:

  • 页面跳转失败
  • 控制台出现 Loading chunk failed
  • Failed to fetch dynamically imported module
  • 页面局部报错,甚至直接白屏
  • 用户不知道系统已经更新,只会觉得“网页坏了”
  • 新功能已经上线,但用户却迟迟体验不到

这类问题在线上系统里并不少见,尤其是管理后台、教学平台、运营平台这类用户会长时间挂着页面不刷新的 SPA 应用。 如果处理不好,不仅影响用户体验,还会带来很多“难定位、难复现”的线上问题。

这篇文章,我想系统讲清楚三件事:

  1. 为什么 SPA 发版后,旧页面容易出问题
  2. 这类白屏问题的根因到底是什么
  3. 如何从前端运行时、缓存策略和部署方式三个层面,设计一套完整的解决方案

一、把问题链路拆开看,就很清楚了

这个问题的完整链路可以概括为:

用户打开旧页面
   ↓
系统发布新版本
   ↓
用户仍然停留在旧页面中
   ↓
用户触发路由跳转 / 懒加载页面
   ↓
浏览器请求某个 chunk 资源
   ↓
旧资源已失效或请求地址不匹配
   ↓
动态 import 失败
   ↓
页面报错、跳转失败甚至白屏

也就是说,这不是某一个孤立 bug,而是一个典型的版本切换时机问题


二、解决思路:从三个层面一起治理

这个问题不能只靠某一个点状方案解决。更合理的方式,是从以下四个层面同时考虑:

1. 让用户知道“线上有新版本了”“建议刷新页面”

也就是建立版本检测机制更新提示机制

2. 在资源加载失败时自动自救

也就是建立chunk 加载失败兜底机制

3. 从缓存层降低问题发生概率

也就是建立缓存与发布治理策略

下面分别展开说。

方案一:建立版本检测机制

整个机制可以这样设计:

  1. 构建时把版本号注入到 index.html
  2. 当前页面启动后读取 HTML 中的版本号,作为“当前版本”
  3. 定时重新请求最新的 index.html,解析其中的版本号,作为“线上最新版本”
  4. 如果两者不同,则提示用户刷新页面

完整示例:

<meta name="app-version" content="20260317-abc123" />
function getCurrentVersion() {
  return document
    .querySelector('meta[name="app-version"]')
    ?.getAttribute('content');
}

async function fetchLatestVersionFromHtml() {
  const res = await fetch(`/index.html?t=${Date.now()}`, {
    cache: 'no-store'
  });
  const html = await res.text();

  const match = html.match(
    /<meta\s+name=["']app-version["']\s+content=["']([^"']+)["']/
  );

  return match ? match[1] : null;
}

// 发现新版本后,友好地提示用户刷新
function showUpdateDialog() {
  const ok = window.confirm('系统已更新,是否立即刷新页面?');
  if (ok) {
    window.location.reload();
  }
}

async function checkVersion() {
  try {
    const currentVersion = getCurrentVersion();
    const latestVersion = await fetchLatestVersionFromHtml();

    if (currentVersion && latestVersion && currentVersion !== latestVersion) {
      showUpdateDialog();
    }
  } catch (err) {
    console.error('版本检测失败:', err);
  }
}

checkVersion();
setInterval(checkVersion, 5 * 60 * 1000);

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    checkVersion();
  }
});

方案二:捕获 chunk 加载失败,作为最终兜底

如果说“版本检测 + 刷新提示”是在事前预防
那么“chunk 加载失败自动恢复”就是最关键的事后兜底

这一层非常重要,因为现实中总会遇到这样的情况:

  • 版本检测还没来得及执行
  • 用户刚好在检测间隔内点击了菜单
  • 服务端刚完成发布
  • 某个懒加载 chunk 已经失效

这时候,问题已经发生了。
如果没有兜底机制,用户就会直接看到报错或白屏。

常见错误形式

不同构建工具、浏览器环境下,报错信息可能略有差异,但常见的有:

  • Loading chunk xxx failed
  • ChunkLoadError
  • Failed to fetch dynamically imported module

这类错误本质上都可以理解为:

动态加载的资源拿不到了。

监听全局错误

可以通过以下方式统一拦截:

function isChunkLoadError(error) {
  const message = error?.message || error?.reason?.message || '';
  return (
    message.includes('Loading chunk') ||
    message.includes('ChunkLoadError') ||
    message.includes('Failed to fetch dynamically imported module')
  );
}

监听 error

window.addEventListener('error', (event) => {
  if (isChunkLoadError(event.error || event)) {
    handleChunkLoadError();
  }
});

监听 unhandledrejection

window.addEventListener('unhandledrejection', (event) => {
  if (isChunkLoadError(event.reason || event)) {
    handleChunkLoadError();
  }
});

使用 vite 则不用监听全局错误

vite官网已经提供了预加载错误的事件,可以直接使用

image.png

// 监听 vite 预加载错误,如果发生错误,则重新加载页面
window.addEventListener('vite:preloadError', () => {
  window.location.reload();
});

自动刷新一次,但一定要防止死循环

如果遇到这类错误,可以尝试自动刷新页面一次。
因为刷新后,浏览器会重新请求最新的 index.html 和资源入口,大多数情况下问题就能恢复。

function handleChunkLoadError() {
  const key = 'app_chunk_reload_once';

  if (!sessionStorage.getItem(key)) {
    sessionStorage.setItem(key, '1');
    alert('系统资源已更新,正在为您刷新页面');
    window.location.reload();
  } else {
    console.error('刷新后仍然失败,请提示用户手动刷新或联系管理员');
  }
}

页面正常加载成功后清理标记:

window.addEventListener('load', () => {
  sessionStorage.removeItem('app_chunk_reload_once');
});

为什么只自动刷新一次

因为如果失败原因不是“版本切换”,而是:

  • 网络异常
  • CDN 故障
  • 资源服务器不可用
  • 权限拦截

那么无限刷新只会让问题更严重,甚至让用户完全无法操作。

所以最佳实践是:

  • 自动刷新一次尝试恢复
  • 如果仍失败,再提示用户手动处理或联系支持人员

方案三:缓存策略要正确,否则问题会被放大

很多时候,问题不是前端代码没写,而是缓存策略没配好。

一个很典型的原则是:

入口文件要尽快更新,静态资源要放心缓存,版本文件要实时可读。

1)index.html 不要强缓存

index.html 是整个 SPA 的入口。
如果它被长时间缓存,用户就可能始终拿不到新的资源入口映射。

推荐策略:

  • no-cache
  • 或更严格的 no-store

例如 Nginx:

location = /index.html {
    add_header Cache-Control "no-cache, no-store, must-revalidate";
}

2)带 hash 的 js/css 可以强缓存

这类资源天然适合长期缓存,因为文件名已经包含内容签名。
只要内容变化,hash 就会变,浏览器就会自动拉新文件。

例如:

location /assets/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}

这样可以显著提升加载性能。

总结

回到最初的问题:

前端 SPA 发版后,为什么用户停留在旧页面会导致白屏?又该如何更好地解决?

答案是:

因为 SPA 页面会长期运行在浏览器中,而新版本发布后静态资源文件名、资源映射和懒加载 chunk 都可能发生变化。如果用户仍停留在旧页面中继续操作,就很容易在后续资源请求中触发加载失败,从而导致页面报错甚至白屏。

而更好的解决方式,不是单纯依赖“让用户手动刷新”,而是建立一套完整的更新治理方案:

  • 版本检测:前端主动感知线上是否已更新
  • 刷新提示:让用户在合适时机切换到新版本
  • 异常兜底:chunk 加载失败时自动刷新恢复
  • 缓存优化:保证入口及时更新、资源合理缓存

如果只能先做一步,我最建议优先落地的是:

捕获 chunk 加载失败并自动刷新一次

因为它最直接解决“白屏止血”问题。

如果想把体验做得更完整,再逐步补上版本检测、刷新提示和部署优化。

扫码枪卡顿有效解决方案

作者 Forever7_
2026年3月17日 11:07

今天没时间了,不做多解释; 问题现象

扫描枪写入文本肉眼可见卡顿; 了解扫描枪是模拟键盘快速输入,会触发key/Down和Input事件,双向绑定和input事件,没输入一个字母,设计双向绑定和渲染,输入太快,来不及渲染,所以卡顿;

核心思路,降低input事件触发频次,降低渲染,在keydown中获取判断是扫描还是手工输入,如果是扫描,则拼接字符串,然后后再更新文本,否额常规输入;

卡的原因还包括,输入法的,拼写校验、自动完成等等,实际上再扫码过程,这些辅助性内容都是干扰项;

拿去绝对好使用,钱前后端花费好几天时间,我尝试原生html input,能好一点点,cpu低配工控机,仍然卡顿;

ScanInput.vue

<template>
  <span class="scan-search-input" :class="getSizeClass">
    <!-- Prefix Slot -->
    <span v-if="$slots.prefix" class="scan-search-input__prefix">
      <slot name="prefix"></slot>
    </span>

    <!-- Input -->
    <input
      ref="inputRef"
      v-model="localValue"
      v-bind="$attrs"
      class="scan-search-input__input"
      autocomplete="off"
      autocorrect="off"
      autocapitalize="off"
      spellcheck="false"
      inputmode="text"
      @focus="onFocus"
      @blur="onBlur"
      @keydown="onKeydown"
      @input="onManualInput"
    />

    <!-- Suffix Area -->
    <span v-if="showSuffix" class="scan-search-input__suffix">
      <!-- Clear Button -->
      <span
        v-if="props.allowClear && localValue"
        class="scan-search-input__clear"
        @click.stop="handleClear"
      >
        <svg
          focusable="false"
          data-icon="close-circle"
          width="1em"
          height="1em"
          fill="currentColor"
          aria-hidden="true"
          fill-rule="evenodd"
          viewBox="64 64 896 896"
        >
          <path
            d="M512 64c247.4 0 448 200.6 448 448S759.4 960 512 960 64 759.4 64 512 264.6 64 512 64zm127.98 274.82h-.04l-.08.06L512 466.75 384.14 338.88c-.04-.05-.06-.06-.08-.06a.12.12 0 00-.07 0c-.03 0-.05.01-.09.05l-45.02 45.02a.2.2 0 00-.05.09.12.12 0 000 .07v.02a.27.27 0 00.06.06L466.75 512 338.88 639.86c-.05.04-.06.06-.06.08a.12.12 0 000 .07c0 .03.01.05.05.09l45.02 45.02a.2.2 0 00.09.05.12.12 0 00.07 0c.02 0 .04-.01.08-.05L512 557.25l127.86 127.87c.04.04.06.05.08.05a.12.12 0 00.07 0c.03 0 .05-.01.09-.05l45.02-45.02a.2.2 0 00.05-.09.12.12 0 000-.07v-.02a.27.27 0 00-.05-.06L557.25 512l127.87-127.86c.04-.04.05-.06.05-.08a.12.12 0 000-.07c0-.03-.01-.05-.05-.09l-45.02-45.02a.2.2 0 00-.09-.05.12.12 0 00-.07 0z"
          />
        </svg>
      </span>

      <!-- Enter Button (only if no custom suffix) -->
      <button
        v-if="hasEnterButton && !$slots.suffix"
        type="button"
        class="scan-search-input__btn"
        :disabled="props.loading"
        @click="handlePressEnter"
      >
        {{ props.enterButton === true ? '搜索' : props.enterButton }}
      </button>

      <!-- Custom Suffix Slot -->
      <slot v-else-if="$slots.suffix" name="suffix"></slot>
    </span>
  </span>
</template>

<script setup>
  import { ref, watch, nextTick, computed, useSlots, onMounted } from 'vue';

  const props = defineProps({
    value: { type: String, default: '' },
    scanSeparator: { type: String, default: '' },
    allowClear: { type: Boolean, default: false },
    enterButton: { type: [Boolean, String], default: false },
    loading: { type: Boolean, default: false },
    size: { type: String, default: 'middle' }, // 'small' | 'middle' | 'large'
    autoFocus: { type: Boolean, default: false },
  });

  const emit = defineEmits(['update:value', 'pressEnter']);

  const localValue = ref(props.value);
  watch(
    () => props.value,
    (val) => {
      localValue.value = val;
    },
  );

  const slots = useSlots();

  // 判断是否显示右侧区域
  const hasEnterButton = computed(() => !!props.enterButton);
  const showSuffix = computed(() => props.allowClear || hasEnterButton.value || !!slots.suffix);

  const inputRef = ref(null);

  // ========== 扫码逻辑 ==========
  let scanBuffer = '';
  let lastKeyTime = 0;
  let scanTimeout = null;
  let isScanning = false;
  // 扫码结束后延迟 150ms 提交(更快响应)
  const SCAN_END_DELAY = 200;
  // 启动扫码:前两个字符间隔需 <60ms
  const QUICK_INPUT_THRESHOLD = 60;

  function onManualInput(e) {
    if (isScanning) return;
    emit('update:value', e.target.value);
  }

  function onKeydown(e) {
    const now = Date.now();
    const timeDiff = now - lastKeyTime;
    lastKeyTime = now;

    if (e.ctrlKey || e.altKey || e.metaKey) return;

    const key = e.key;
    console.info('timeDiff', timeDiff, key);

    if (key === 'Enter') {
      if (isScanning && scanBuffer) {
        // 扫码中按回车:先应用扫码结果,再触发 pressEnter
        applyScanResult();
        e.preventDefault();
        // 触发 pressEnter,event 为 null 表示非用户交互触发
        nextTick(() => {
          emit('pressEnter', e);
        });
      } else {
        // 手动输入按回车
        handlePressEnter(e);
      }
      return;
    }

    // key !== 1 显示排除 非打印字符
    if (key.length !== 1 || key < ' ' || key > '~') {
      clearScanState();
      return;
    }

    if (timeDiff < QUICK_INPUT_THRESHOLD || isScanning) {
      isScanning = true;
      scanBuffer += key;
      e.preventDefault();

      clearTimeout(scanTimeout);
      scanTimeout = setTimeout(() => {
        if (isScanning && scanBuffer) {
          applyScanResult();
          // 注意:自动扫码完成(无回车)通常不触发 pressEnter
          // 如果你也希望自动触发,请在这里加 emit
        }
      }, SCAN_END_DELAY);
    } else {
      clearScanState();
    }
  }

  function applyScanResult() {
    const scannedPart = scanBuffer;
    clearScanState();

    const currentValue = localValue.value || '';
    const newValue = currentValue ? currentValue + props.scanSeparator + scannedPart : scannedPart;

    localValue.value = newValue;

    nextTick(() => {
      emit('update:value', newValue);
    });

    inputRef.value?.focus();
  }

  function clearScanState() {
    isScanning = false;
    scanBuffer = '';
    clearTimeout(scanTimeout);
  }

  function onBlur() {
    if (isScanning) {
      clearScanState();
    }
  }

  function onFocus() {
    // 可扩展
  }

  function handlePressEnter(e) {
    if (props.loading) return;
    emit('pressEnter', e);
  }

  function handleClear() {
    localValue.value = '';
    emit('update:value', '');
    nextTick(() => {
      inputRef.value?.focus();
    });
  }

  // 尺寸类
  const getSizeClass = computed(() => {
    return `scan-search-input--${props.size}`;
  });

  // 自动聚焦(仅当 props.autoFocus 为 true)
  onMounted(() => {
    if (props.autoFocus) {
      // 使用 nextTick 确保 DOM 已渲染
      nextTick(() => {
        inputRef.value?.focus();
      });
    }
  });

  // 暴露 focus 方法供父组件调用
  defineExpose({
    focus: () => inputRef.value?.focus(),
  });
</script>

<style lang="less" scoped>
  .scan-search-input {
    display: flex;
    align-items: center;
    width: 100%;
    padding: 4px 11px;
    transition: all 0.3s;
    border: 1px solid #d9d9d9;
    border-radius: 2px;
    background-color: #fff;
    font-size: 14px;
  }

  .scan-search-input:hover,
  .scan-search-input:focus-within {
    border-color: @primary-color;
    box-shadow: 0 0 0 2px fade(@primary-color, 20%);
  }

  .scan-search-input--large {
    padding: 6.5px 11px;
    font-size: 14px;
  }

  .scan-search-input--small {
    padding: 0 7px;
    font-size: 14px;
  }

  .scan-search-input__prefix {
    margin-right: 2px;
    margin-left: 0;
    color: rgb(0 0 0 / 65%);
    line-height: 1;
  }

  .scan-search-input__input {
    flex: 1;
    min-width: 0;
    padding: 4px;
    border: none;
    outline: none;
    background: transparent;
    color: rgb(0 0 0 / 88%);
  }

  .scan-search-input__input::placeholder {
    color: #bfbfbf;
  }

  .scan-search-input__suffix {
    display: flex;
    align-items: center;
    margin-left: 8px;
  }

  .scan-search-input__clear {
    margin-right: 10px;
    color: rgb(0 0 0 / 25%);
    font-size: 10px;
    line-height: 1;
    cursor: pointer;
  }

  .scan-search-input__clear:hover {
    color: rgb(0 0 0 / 45%);
  }

  .scan-search-input__btn {
    display: none;
    height: 32px;
    margin-left: 8px;
    padding: 0 15px;
    transition: all 0.3s;
    border: none;
    border-radius: 0 2px 2px 0;
    background-color: @primary-color;
    color: #fff;
    cursor: pointer;
  }

  .scan-search-input--large .scan-search-input__btn {
    height: 40px;
  }

  .scan-search-input--small .scan-search-input__btn {
    height: 24px;
  }

  .scan-search-input__btn:hover:not(:disabled) {
    background-color: fade(@primary-color, 90%);
  }

  .scan-search-input__btn:disabled {
    background-color: #d9d9d9;
    cursor: not-allowed;
  }
</style>
❌
❌