普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月28日掘金 前端

tanstack query的基本使用

2026年2月28日 11:00

一.为什么要使用tanstack query?

显式的来看:主要是可以简化代码,可以看下面的一个例子,这是一个我们经常用的发送请求的一个过程,包括了请求数据,加载处理,还有错误展示。
隐式的来看:除了简化代码,还有自动缓存,可配置的数据过期时间,请求去重,乐观更新,并行和依赖查询等等好处

import { Button } from "antd";
import { useState, useEffect } from "react";

function Home() {
  const [data, setData] = useState<{ id: number, name: string }[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  useEffect(() => {
    setIsLoading(true);
    fetch('https://jsonplaceholder.typicode.com/users').then(res => res.json()).then(data => {
      setData(data);
    }).catch(err => {
      setError(err as Error);
    }).finally(()=>{
      setIsLoading(false);
    })
  },[])
  return (
    <>
      <div>Home页面</div>
      <Button type="primary">Button</Button>
      <div className="mt-10 ml-10">
        {isLoading ? <div>Loading...</div> : null}
        {
          data?.map((item) => {
            return <div key={item.id}>{item.name}</div>
          })
        }
        {error ? <div>Error: {error.message}</div> : null}
      </div>
    </>
  );
}
export default Home;

使用tanstack query之后,代码量减少了很多很多

import { Button } from "antd";
import { useQuery } from "@tanstack/react-query";

function Home() {
  const {data,isLoading,error} = useQuery<{ id: number, name: string }[]>({
    queryKey: ['users'],
    queryFn: () => fetch('https://jsonplaceholder.typicode.com/users').then(res => res.json())
  })

  return (
    <>
      <div>Home页面</div>
      <Button type="primary">Button</Button>
      <div className="mt-10 ml-10">
        {isLoading ? <div>Loading...</div> : null}
        {
          data?.map((item) => {
            return <div key={item.id}>{item.name}</div>
          })
        }
        {error ? <div>Error: {error.message}</div> : null}
      </div>
    </>
  );
}
export default Home;

二.学习网址

官方地址:tanstack.com/query/lates…

三.tanstack query的好处:

a.自动缓存

在第一次调用的时候,都会去后台拿数据。但是在再次调用的时候,tanstack因为有缓存,所以会先从缓存中把之前的数据拿出来渲染,同时去请求新的数据,然后把新的数据替换掉data。

// 不使用 TanStack Query
const [data, setData] = useState(null);
useEffect(() => {
  fetchData().then(setData);
}, []);

// 使用 TanStack Query
const { data } = useQuery(['key'], fetchData);
// 自动缓存!后续调用瞬间返回

b.可配置的数据过期时间和重新获取间隔

a.重新获取间隔staleTime

默认情况下,通过 useQuery 或 useInfiniteQuery 查询实例将缓存的数据视为过时的数据。
但是在配置了staleTime之后,比如将 staleTime 设置为例如 30 * 1000,会确保从缓存中读取数据,而不触发任何类型的重新获取,持续 30秒,或直到 Query 手动失效。意思就是30s内再去请求这个接口,不会从后台拿数据,而是从缓存中拿数据。

import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
  const { data, isLoading } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    // 数据在30秒内被视为"新鲜",不会重新获取
    staleTime: 1000 * 30, // 30秒
    // 数据在5分钟后会被视为"过期",下次访问时会后台重新获取
    gcTime: 1000 * 60 * 5, // 5分钟 (v5版本以前叫 cacheTime)
    // 窗口重新聚焦时,如果数据已过期,重新获取
    refetchOnWindowFocus: true,
    // 组件重新挂载时,如果数据已过期,重新获取
    refetchOnMount: true,
    // 网络重新连接时重新获取
    refetchOnReconnect: true,
  });
}

b.数据过期时间gcTime

这个指的是数据存在缓存中的时间。有一点要注意:只有组件卸载后<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">gcTime</font> 才会开始倒计时,倒计时结束后缓存被清理。下次组件挂载时如果没有缓存,才会重新请求。
比如以下代码:

const {data,isLoading,error} = useQuery<{ id: number, name: string }[]>({
    queryKey: ['users'],
    queryFn: () => fetch('https://jsonplaceholder.typicode.com/users').then(res => res.json()),
    staleTime: 1000 * 30,
    gcTime: 1000,
  })

staleTime为30s,gcTime为1s。这个是在home页面上的, 如果离开home页面1s以上,再进入到home页面,则会从新请求。 如果在离开home页面1s以内重新进入,那么还是会从缓存中取数据(因为1s以内缓存还没被清理)。

c.并行和依赖查询

a.并行查询

不需要其他的操作,直接写发起请求的就会同时去请求(主要是告诉你useQuery有这个特性(这样子写就会并行查询))

function App () {
  // The following queries will execute in parallel
  const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
  const teamsQuery = useQuery({ queryKey: ['teams'], queryFn: fetchTeams })
  const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: fetchProjects })
  ...
}

也可以使用useQueries来批量查询

queries接受的是一个查询的数组,返回的userQueries是一个查询结果的数组

function App({ users }) {
  const userQueries = useQueries({
    queries: users.map((user) => {
      return {
        queryKey: ['user', user.id],
        queryFn: () => fetchUserById(user.id),
      }
    }),
  })
}

b.依赖查询eabled

这里主要用到了enabled这个属性,这个属性接受的是一个boolean的变量。以下的意思为先去请求users的数据,然后data有数据之后再去请求articleList的数据。这个还是挺重要的,比如跳转到详情页,需要先拿到id,有id才能去查询数据

const {data,isLoading,error} = useQuery<{ id: number, name: string }[]>({
    queryKey: ['users'],
    queryFn: () => fetch('https://jsonplaceholder.typicode.com/users').then(res => res.json()),
    staleTime: 1000 * 30,
    gcTime: 1000,
  })
  console.log('data',data);

  const { data: articleList } = useQuery<{ id: number, title: string }[]>({
    queryKey: ['articleList'],
    queryFn: () => fetch('https://jsonplaceholder.typicode.com/posts').then(res => res.json()),
    enabled: !!data
  })
  console.log('articleList',articleList);

d.完整的加载状态管理

这里有很多状态,但是一般就是用到data和isLoading最多,但是这里列出来让大家都知道

function DataComponent() {
  const query = useQuery({
    queryKey: ['data'],
    queryFn: fetchData,
  });

  // 丰富的状态信息
  const {
    data,           // 成功后的数据
    isLoading,      // 首次加载中
    isFetching,     // 任何请求进行中(包括后台)
    isPending,      // 没有数据且没有加载
    isError,        // 是否错误
    isSuccess,      // 是否成功
    error,          // 错误对象
    status,         // 'pending' | 'error' | 'success'
    fetchStatus,    // 'fetching' | 'paused' | 'idle'
    dataUpdatedAt,  // 最后更新时间
    errorUpdatedAt, // 最后错误时间
    failureCount,   // 失败次数
    failureReason,  // 失败原因
    errorUpdateCount, // 错误更新次数
    isPaused,       // 是否因网络暂停
    isRefetching,   // 是否正在重新获取
  } = query;

  if (isLoading) return <Skeleton />;
  if (isError) return <Error message={error.message} />;
  
  return (
    <div>
      <DataView data={data} />
      {isFetching && <BackgroundRefreshIndicator />}
    </div>
  );
}

e.请求去重

就是多个页面同时都用到了这个请求的话,会主动去重,不会同一时间多次请求

// API 函数
const fetchProducts = async (category) => {
  console.log('📦 发起商品请求', new Date().toLocaleTimeString());
  await new Promise(resolve => setTimeout(resolve, 1000)); // 模拟慢请求
  const res = await fetch(`/api/products/${category}`);
  return res.json();
};

// 商品列表组件
function ProductList({ category }) {
  const { data: products } = useQuery({
    queryKey: ['products', category],
    queryFn: () => fetchProducts(category),
  });

  return <div>{products?.map(p => <ProductCard key={p.id} product={p} />)}</div>;
}

// 商品数量统计组件
function ProductCount({ category }) {
  const { data: products } = useQuery({
    queryKey: ['products', category],
    queryFn: () => fetchProducts(category),
  });
  return <span>总数: {products?.length}</span>;
}

// 商品分类导航组件
function CategoryNav({ category }) {
  const { data: products } = useQuery({
    queryKey: ['products', category],
    queryFn: () => fetchProducts(category),
  });

  return <div>分类: {category} ({products?.length}件商品)</div>;
}

// 页面:三个组件同时渲染
function CategoryPage({ category }) {
  return (
    <div>
      <CategoryNav category={category} />
      <ProductCount category={category} />
      <ProductList category={category} />
      {/* 🔥 尽管三个组件都请求相同数据 */}
      {/* ✅ 但只会发起一次API请求 */}
      {/* ✅ 节省带宽,提高性能 */}
    </div>
  );
}

f.乐观更新

什么是乐观更新?

乐观更新就是:假设操作会成功,先更新UI,再发请求。

简单说就是:先给用户看结果,再去服务器确认。

传统方式:
点击按钮 ──> 显示加载 ──> 等待500ms ──> 服务器响应 ──> 更新UI
             用户感觉:慢,有延迟

乐观更新:
点击按钮 ──> 立即更新UI ──> 后台发送请求 ──> 服务器响应
             用户感觉:瞬间响应,像本地操作
const queryClient = useQueryClient()

useMutation({
  // 1. mutationFn: 实际发送请求的函数
  mutationFn: updateTodo,  // 调用API更新待办
  
  // 2. onMutate: 乐观更新的核心!
  onMutate: async (newTodo, context) => {
    // ⚠️ 注意:这里的参数是 (newTodo, context)
    // newTodo: 要更新的数据
    // context: 包含 client (QueryClient 实例)
    
    // 2.1 取消任何正在进行的查询
    // 为什么要取消?避免旧数据覆盖我们的乐观更新
    await context.client.cancelQueries({ queryKey: ['todos'] })
    
    // 2.2 保存当前数据的快照
    // 为什么要保存?如果失败需要回滚到这个状态
    const previousTodos = context.client.getQueryData(['todos'])
    
    // 2.3 乐观更新:立即更新UI
    // 不等服务器响应,先把新待办加到列表里
    context.client.setQueryData(['todos'], (old) => [...old, newTodo])
    
    // 2.4 返回快照,供onError使用
    return { previousTodos }
  },
  
  // 3. onError: 如果请求失败
  onError: (err, newTodo, onMutateResult, context) => {
    // ⚠️ 参数:(错误, 变量, onMutate返回的结果, context)
    
    // 用之前保存的快照恢复数据
    context.client.setQueryData(['todos'], onMutateResult.previousTodos)
    // 效果:新添加的待办消失,回到之前的状态
  },
  
  // 4. onSettled: 无论成功失败都会执行
  onSettled: (data, error, variables, onMutateResult, context) => {
    // 重新获取最新的待办列表,确保与服务器同步
    context.client.invalidateQueries({ queryKey: ['todos'] })
    // 这样即使乐观更新成功了,也会再次确认服务器数据
  },
})
// 假设初始待办列表
const initialTodos = [
  { id: 1, title: '学习React', completed: false },
  { id: 2, title: '写代码', completed: false },
]

// 用户添加新待办
addTodoMutation.mutate({ title: '新任务', completed: false })

// 时间线:
时间 0ms: 用户点击"添加"按钮
        ↓
时间 0ms: onMutate 执行
        • 取消进行中的查询
        • 保存快照: [{id:1}, {id:2}]
        • 立即更新UI: [{id:1}, {id:2}, {title:'新任务'}]
        • 用户立即看到新任务 ✓
        ↓
时间 0ms: mutationFn 开始发送请求到服务器
        ↓
时间 100ms: 请求成功 ✅
        onSettled 执行: 刷新列表
        ↓
时间 100ms: 列表刷新,确认数据同步

// 如果请求失败 ❌
时间 100ms: 请求失败
        ↓
时间 100ms: onError 执行
        • 用快照恢复: [{id:1}, {id:2}]
        • 新任务消失
        • 用户看到错误提示
        ↓
时间 100ms: onSettled 执行: 刷新列表

四.如何使用?

参考: tanstack.com/query/lates…

1.安装

npm i @tanstack/react-query

2.导入

import { createRoot } from 'react-dom/client'
import '@/assets/css/index.css'
import '@ant-design/v5-patch-for-react-19'
import router from '@/router'
import { RouterProvider } from 'react-router-dom'
import { Provider } from 'react-redux'
import store from '@/store'

import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
const queryClient = new QueryClient()

createRoot(document.getElementById('root')!).render(
  <QueryClientProvider client={queryClient}>
    <Provider store={store}>
      <RouterProvider router={router}></RouterProvider>
    </Provider>
  </QueryClientProvider>
)

3.使用

a.使用useQuery查询数据

import { Button } from "antd";
import { useQuery } from "@tanstack/react-query";

function Home() {
  const {data,isLoading,error} = useQuery<{ id: number, name: string }[]>({
    queryKey: ['users'],
    queryFn: () => fetch('https://jsonplaceholder.typicode.com/users').then(res => res.json())
  })

  return (
    <>
      <div>Home页面</div>
      <Button type="primary">Button</Button>
      <div className="mt-10 ml-10">
        {isLoading ? <div>Loading...</div> : null}
        {
          data?.map((item) => {
            return <div key={item.id}>{item.name}</div>
          })
        }
        {error ? <div>Error: {error.message}</div> : null}
      </div>
    </>
  );
}
export default Home;

b.使用useMutation来更新数据

其实就是在useMutation定义好mutation的function,成功的回调,失败的回调。 然后真的用户做保存动作时,去调用返回的实例中的mutate方法传入参数,然后就会去调用mutationFn的方法,调用成功后走onSuccess的逻辑。

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { FormEvent, useState } from 'react';

interface Todo {
  title: string;
  completed: boolean;
  userId: number;
}

export default function AddTodo() {
  const [title, setTitle] = useState('');
  const queryClient = useQueryClient();

  // 基础 mutation(解构出常用状态)
  const { mutate, isPending, isError, isSuccess, error } = useMutation({
    // 1. mutationFn: 执行实际操作的函数
    mutationFn: (newTodo: Todo) => 
      fetch('https://jsonplaceholder.typicode.com/todos', {
        method: 'POST',
        body: JSON.stringify(newTodo),
        headers: {
          'Content-type': 'application/json; charset=UTF-8',
        },
      }).then(res => res.json()),

    // 2. onSuccess: 成功后的回调
    onSuccess: (data) => {
      console.log('添加成功:', data);
      // 刷新待办列表
      queryClient.invalidateQueries({ queryKey: ['todos'] });
      // 清空输入框
      setTitle('');
    },

    // 3. onError: 失败后的回调
    onError: (error) => {
      console.error('添加失败:', error);
    },

    // 4. onSettled: 无论成功失败都会执行
    onSettled: (data, error) => {
      console.log('操作完成', { data, error });
    },
  });

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (!title.trim()) return;
    
    // 执行 mutation
    mutate({
      title: title,
      completed: false,
      userId: 1,
    });
  };

  return (
    <div className="add-todo">
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="输入待办事项"
          disabled={isPending}
        />
        <button 
          type="submit"
          disabled={isPending}
        >
          {isPending ? '添加中...' : '添加待办'}
        </button>
      </form>
      
      {/* 显示状态 */}
      {isPending && (
        <div className="loading-indicator">⏳ 正在添加...</div>
      )}
      
      {isError && (
        <div className="error-message">
          ❌ {error?.message}
        </div>
      )}
      
      {isSuccess && (
        <div className="success-message">✅ 添加成功!</div>
      )}
    </div>
  );
}

4.使用全局自定义配置(可选)

在main.ts中可以全局自定义配置(所有的请求在自己没配置的时候会使用全局配置。)

import { createRoot } from 'react-dom/client'
import '@/assets/css/index.css'
import '@ant-design/v5-patch-for-react-19'
import router from '@/router'
import { RouterProvider } from 'react-router-dom'
import { Provider } from 'react-redux'
import store from '@/store'
import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // 失败后重试次数(默认 3 次),设为 1 或 false 可减少不必要的请求
      retry: 1,
      // 重试延迟(默认指数退避),可自定义
      // retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),

      // 数据过期时间(默认 0,即立即过期)。5 分钟内相同 queryKey 的请求会直接用缓存
      staleTime: 1000 * 60 * 5,
      // 非活跃数据在缓存中保留的时间(默认 5 分钟),超时后垃圾回收
      gcTime: 1000 * 60 * 10,

      // 窗口重新聚焦时是否自动重新请求(默认 true)
      refetchOnWindowFocus: false,
      // 网络重新连接时是否自动重新请求(默认 true)
      refetchOnReconnect: true,
      // 组件重新挂载时是否自动重新请求(默认 true)
      refetchOnMount: true,
    },
    mutations: {
      // mutation 失败后重试次数(默认 0,即不重试)
      retry: 0,
      // 全局 mutation 错误处理
      // onError: (error) => {
      //   console.error('全局 mutation 错误:', error);
      // },
    },
  },
})

createRoot(document.getElementById('root')!).render(
  <QueryClientProvider client={queryClient}>
    <Provider store={store}>
      <RouterProvider router={router}></RouterProvider>
    </Provider>
  </QueryClientProvider>
)

五.常用的hook

1.useQuery - 数据获取之王

import { useQuery } from '@tanstack/react-query';

function UserProfile({ userId }) {
  // 最基础的数据查询
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 5 * 60 * 1000, // 5分钟内不重新获取
  });

  if (isLoading) return <Loading />;
  if (error) return <Error />;
  return <div>{data.name}</div>;
}

// 常用属性
const {
  data,           // 返回的数据
  isLoading,      // 首次加载中
  isFetching,     // 任何请求进行中
  error,          // 错误对象
  isError,        // 是否错误
  isSuccess,      // 是否成功
  status,         // 'pending' | 'error' | 'success'
  refetch,        // 手动重新获取
  remove,         // 移除缓存
} = useQuery(...);

2. useMutation - 数据修改之王

import { useMutation, useQueryClient } from '@tanstack/react-query';

function AddTodo() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (newTodo) => 
      fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(newTodo),
      }).then(res => res.json()),
    
    onSuccess: () => {
      // 成功后刷新列表
      queryClient.invalidateQueries(['todos']);
      toast.success('添加成功!');
    },
    
    onError: (error) => {
      toast.error(`失败:${error.message}`);
    },
  });

  return (
    <button 
      onClick={() => mutation.mutate({ title: '新任务' })}
      disabled={mutation.isLoading}
    >
      {mutation.isLoading ? '添加中...' : '添加待办'}
    </button>
  );
}

// 常用属性
const {
  mutate,         // 执行 mutation
  mutateAsync,    // 异步执行
  isLoading,      // 是否加载中
  isError,        // 是否错误
  isSuccess,      // 是否成功
  data,           // 返回数据
  error,          // 错误对象
  reset,          // 重置状态
} = useMutation(...);

3.QueryClient

new这个QueryClient会生成一个queryClient的实例,这个实例就包含了各种的方法。

可以配置全局自定义配置,可以清除缓存。这里一般在main.tsx中生成这个实例,然后使用QueryClientProvider传递给子孙组件,后代组件可以使用useQueryClient拿到这个实例对象,从而操作它的方法。

import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // 失败后重试次数(默认 3 次),设为 1 或 false 可减少不必要的请求
      retry: 1,
      // 重试延迟(默认指数退避),可自定义
      // retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
      // 数据过期时间(默认 0,即立即过期)。5 分钟内相同 queryKey 的请求会直接用缓存
      staleTime: 1000 * 60 * 5,
      // 非活跃数据在缓存中保留的时间(默认 5 分钟),超时后垃圾回收
      gcTime: 1000 * 60 * 10,
      // 窗口重新聚焦时是否自动重新请求(默认 true)
      refetchOnWindowFocus: false,
      // 网络重新连接时是否自动重新请求(默认 true)
      refetchOnReconnect: true,
      // 组件重新挂载时是否自动重新请求(默认 true)
      refetchOnMount: true,
    },
    mutations: {
      // mutation 失败后重试次数(默认 0,即不重试)
      retry: 0,
      // 全局 mutation 错误处理
      // onError: (error) => {
      //   console.error('全局 mutation 错误:', error);
      // },
    },
  },
})
createRoot(document.getElementById('root')!).render(
  <QueryClientProvider client={queryClient}>
    <Provider store={store}>
      <RouterProvider router={router}></RouterProvider>
    </Provider>
  </QueryClientProvider>
)

链接:tanstack.com/query/lates…

当用户退出或者切换公司的时候需要去清除缓存。

queryClient.clear()

4.useQueryClient - 缓存操作神器

使用useQueryClient拿到queryClient实例。

import { useQueryClient } from '@tanstack/react-query';

function TodoManager() {
  const queryClient = useQueryClient();

  // 常用方法
  const handleRefresh = () => {
    // 1. 刷新特定查询
    queryClient.invalidateQueries(['todos']);
    
    // 2. 直接设置缓存
    queryClient.setQueryData(['todos'], newTodos);
    
    // 3. 获取缓存数据
    const todos = queryClient.getQueryData(['todos']);
    
    // 4. 取消进行中的查询
    queryClient.cancelQueries(['todos']);
    
    // 5. 预加载数据
    queryClient.prefetchQuery({
      queryKey: ['todos', 'next'],
      queryFn: fetchNextTodos,
    });
    
    // 6. 重置查询
    queryClient.resetQueries(['todos']);
    
    // 7. 移除缓存
    queryClient.removeQueries(['todos']);
    
    // 8. 清空所有缓存
    queryClient.clear();
  };

  return <button onClick={handleRefresh}>刷新</button>;
}

六.demo地址

地址:gitee.com/rui-rui-an/…

别再混用了!import.meta.env 与 process.env 的本质差异一次讲透

2026年2月28日 10:59

用过vue3的小伙伴,相比对import.meta.envprocess.env都有过多过少的了解,但是你有去真正的了解过吗,今天,勇宝就带着大家一个来聊聊。

先说结论:import.meta.env 更偏“现代前端构建工具(Vite)语义”,process.env 更偏“Node 语义(Webpack/Node 运行时)”

在纯前端项目里,它们看起来都能“读环境变量”,但本质来源、注入时机、可见范围和迁移成本都不一样。

如果现在正在构建 Vue3/Vite 或 React/Vite 项目的话,优先用 import.meta.env;如果是 Webpack 老项目、Node 脚本或服务端代码,process.env 依然是主角。


1)import.meta.env 是什么?

import.meta.envESM + Vite 提供的环境变量访问方式。它不是 Node 原生对象,而是由构建工具在开发/打包阶段注入。

常见特征

  • 内置变量:MODEDEVPRODBASE_URL
  • 自定义变量默认要有前缀(Vite 默认 VITE_),例如:VITE_API_BASE
  • 能在前端代码中直接访问(最终会被构建替换)
// .env.development
VITE_API_BASE=/api
VITE_APP_TITLE=Demo

// 业务代码
const baseURL = import.meta.env.VITE_API_BASE
const isDev = import.meta.env.DEV

适用场景

  1. Vite 项目的前端业务代码
  2. 按环境切换 API 地址、开关日志、控制埋点
  3. 希望享受更清晰的前端变量约束(前缀暴露机制)

2)process.env 是什么?

process.envNode.js 运行时里的环境变量对象。

在服务端(Node)代码中,它天然存在;在前端项目中能不能用,取决于打包器是否做了注入/替换(如 Webpack 的 DefinePlugin)。

常见特征

  • Node 端“原生可用”
  • 前端中常见于旧工程(Vue CLI/Webpack)
  • 常见变量:process.env.NODE_ENVprocess.env.VUE_APP_XXX
// Vue CLI / Webpack 常见
if (process.env.NODE_ENV === 'production') {
  // 生产逻辑
}
const baseURL = process.env.VUE_APP_BASE_API

适用场景

  1. Node 服务端代码(Express、Nest、脚本工具)
  2. Webpack 系项目前端代码
  3. CI/CD 中通过系统环境变量注入配置

3)核心区别(重点)

下面这张表抓住最关键差异:

维度 import.meta.env process.env
本质来源 Vite/ESM 注入 Node 运行时对象(或被打包器替换)
典型生态 Vite Node / Webpack / Vue CLI
前端可见变量前缀 默认 VITE_ Vue CLI 常见 VUE_APP_
内置标识 DEV/PROD/MODE 常见 NODE_ENV
类型体验 在 TS 中更容易做类型增强 常被视作 string | undefined
迁移风险 旧项目需改写变量名与访问方式 在 Vite 前端中直接用可能报错或行为异常

4)代码对比案例

案例 A:按环境切 API 地址

Vite 写法:

const requestBaseURL = import.meta.env.VITE_API_BASE

Webpack/Vue CLI 写法:

const requestBaseURL = process.env.VUE_APP_BASE_API

案例 B:开发环境打印日志

Vite:

if (import.meta.env.DEV) {
  console.log('dev log')
}

Webpack/Node:

if (process.env.NODE_ENV !== 'production') {
  console.log('dev log')
}

案例 C:从 Vue CLI 迁移到 Vite 的典型坑

很多人会直接把旧代码搬过来:

// 旧代码
const url = process.env.VUE_APP_BASE_API

在 Vite 前端中应改为:

const url = import.meta.env.VITE_API_BASE

并把 .env 变量从 VUE_APP_BASE_API 改成 VITE_API_BASE


5)实践建议(避免踩坑)

  1. 前后端变量分层

    • 前端可见:只放“可公开配置”,用 VITE_ 前缀
    • 服务端敏感项(密钥/私钥):只放 process.env(Node 端),不要暴露给前端
  2. 不要混用语义

    • Vite 前端代码统一 import.meta.env
    • Node 脚本、SSR 服务端逻辑统一 process.env
  3. 迁移时一次性改全

    • 变量名前缀、读取方式、构建脚本、文档一起更新
    • 建议加一条 lint/代码审查规则,禁止在 Vite 前端里继续写 process.env.xxx

结语

import.meta.env 是“面向前端构建时”的环境注入接口,process.env 是“面向 Node 运行时”的环境变量接口。

它们都能“读配置”,但不在同一个语义层。把语义边界划清,项目会更稳定,迁移成本也会更低。

好啦!今天的知识点就分享到这里吧,希望读完对你的职业素养有一个质的提升。

js中,什么是快速排序(Quick Sort)

作者 我是何平
2026年2月28日 10:52

在 JavaScript 中,快速排序(Quick Sort) 是一种高效的、基于分治思想(Divide and Conquer) 的递归排序算法。它通过选择一个“基准值”(pivot),将数组划分为两个子数组:

  • 一个包含所有 小于等于 pivot 的元素,
  • 另一个包含所有 大于 pivot 的元素,

然后递归地对这两个子数组进行排序,最终合并得到有序数组。


✅ 快速排序的核心步骤(JS 版)

  1. 选基准(pivot):通常选中间、首尾或随机元素。
  2. 分区(partition):将数组按 pivot 分成左右两部分。
  3. 递归排序左右子数组
  4. 合并结果(在非原地实现中)。

🧩 简洁版(函数式风格,非原地排序)

这是 JavaScript 中最常见、易读的快排写法(不修改原数组):

function quickSort(arr) {
  if (arr.length <= 1) {
    return arr;
  }

  const pivot = arr[Math.floor(arr.length / 2)]; // 选中间元素为 pivot
  const left = [];
  const right = [];
  const equal = [];

  for (let element of arr) {
    if (element < pivot) {
      left.push(element);
    } else if (element > pivot) {
      right.push(element);
    } else {
      equal.push(element);
    }
  }

  return [...quickSort(left), ...equal, ...quickSort(right)];
}

// 示例
const arr = [38, 27, 43, 3, 9, 82, 10];
console.log(quickSort(arr)); 
// 输出: [3, 9, 10, 27, 38, 43, 82]

✅ 优点:代码简洁、易理解、不会改变原数组。
❌ 缺点:不是原地排序,空间开销较大(每次创建新数组)。


🔧 原地排序版(In-place,更高效)

如果你希望直接修改原数组以节省内存,可以使用索引和分区函数:

function quickSortInPlace(arr, low = 0, high = arr.length - 1) {
  if (low < high) {
    const pi = partition(arr, low, high);       // 获取 pivot 正确位置
    quickSortInPlace(arr, low, pi - 1);         // 排序左半部分
    quickSortInPlace(arr, pi + 1, high);        // 排序右半部分
  }
  return arr;
}

function partition(arr, low, high) {
  const pivot = arr[high]; // 选最后一个为 pivot
  let i = low - 1;         // 小于 pivot 区域的边界

  for (let j = low; j < high; j++) {
    if (arr[j] <= pivot) {
      i++;
      [arr[i], arr[j]] = [arr[j], arr[i]]; // 交换
    }
  }
  [arr[i + 1], arr[high]] = [arr[high], arr[i + 1]]; // pivot 归位
  return i + 1;
}

// 示例
const arr = [38, 27, 43, 3, 9, 82, 10];
quickSortInPlace(arr);
console.log(arr); 
// 输出: [3, 9, 10, 27, 38, 43, 82]

✅ 优点:原地排序,空间复杂度更低(O(log n) 递归栈)。
⚠️ 注意:会修改原始数组。


⏱️ 时间复杂度(JavaScript 中同样适用)

情况 时间复杂度
平均情况 O(n log n)
最好情况 O(n log n)
最坏情况 O(n²)(如已排序 + 固定 pivot)

💡 实际中可通过随机选 pivot三数取中 来避免最坏情况。


🆚 与 Array.prototype.sort() 对比

  • JS 内置的 arr.sort() 在现代引擎(V8)中通常使用 TimsortIntrosort(快排+堆排+插入排序混合),性能更优且稳定。
  • 手写快排主要用于学习算法原理或特定场景(如自定义比较逻辑)。
// 内置排序(推荐日常使用)
arr.sort((a, b) => a - b);

✅ 总结

特性 快速排序(JS)
是否稳定 ❌ 不稳定(它在分区(partition)过程中可能会改变相等元素的相对顺序。)
是否原地 可实现(看具体写法)
平均时间复杂度 O(n log n)
适用场景 学习分治思想、面试、自定义排序逻辑

基于SSE的AI对话流式结构

作者 小王同志i
2026年2月28日 10:50

本文章是基于当前AI业务项目梳理的一份SSE流式结构,简单介绍了一下,当前我们实现的AI流式消息的思路,其中可能有很多不合理的地方,欢迎大佬指正和建议🌹

一、整体架构

image.png

二、流式消息字段

首先提供一段完整的处理的消息格式

[
    {
        "msg_id": "xxx",
        "content": [
            {
                "content": "调用联网搜索工具,查询北京近期天气。",
                "is_finished": false,
                "type": "text",
                "type_end": true
            },
            {
                "content": [
                    {
                        "content": "我来帮您查询北京最近的天气情况。",
                        "is_finished": false,
                        "type": "text",
                        "type_end": true
                    },
                    {
                        "content": "联网搜索",
                        "is_finished": false,
                        "params": {
                            "click": true,
                            "icon": "https://xxxx/xxxx.png",
                            "id": "web_search",
                            "status": "end",
                            "data_detail": {
                                "input": "北京天气 2026年2月13日",
                                "output": [
                                    {
                                        "content": [
                                            {
                                                "desc": "2026年02月13日北京天气预报",
                                                "source": "搜狐",
                                                "title": "2026年02月13日北京天气预报",
                                                "url": "xxxx"
                                            },
                                            {
                                                "desc": "2026年02月13日京山天气预报.",
                                                "source": "百度",
                                                "title": "2026年02月13日京山天气预报",
                                                "url": "xxxx"
                                            },
                                            {
                                                "desc": "北京市气象台13日6时发布天气预报,白天晴间多云。",
                                                "source": "中华网新闻频道",
                                                "title": "北京13日白天晴间多云",
                                                "url": "xxxx"
                                            }
                                        ],
                                        "type": "web_search"
                                    }
                                ]
                            }
                        },
                        "type": "complex_tool",
                        "type_end": true
                    },
                    {
                        "content": "联网搜索",
                        "is_finished": false,
                        "params": {
                            "click": true,
                            "icon": "xxx",
                            "id": "web_search",
                            "status": "end",
                            "data_detail": {
                                "input": "北京未来一周天气预报 具体温度 降水",
                                "output": [
                                    {
                                        "content": [
                                            {
                                                "desc": "xxxx.",
                                                "source": "网易",
                                                "title": "xxxx",
                                                "url": "xxxx"
                                            },
                                            {
                                                "desc": "xxx.",
                                                "source": "xxx",
                                                "title": "2026年02月13日北京天气预报",
                                                "url": "xxx"
                                            },
                                            {
                                                "desc": "xxxx",
                                                "source": "腾讯网",
                                                "title": "今明两天,北京持续回暖!_腾讯新闻",
                                                "url": "xxx"
                                            },
                                            {
                                                "desc": "xxx",
                                                "source": "腾讯网",
                                                "title": "北京明天白天晴间多云,最高气温15°C_腾讯新闻",
                                                "url": "xxx"
                                            },
                                            {
                                                "desc": "xxxx",
                                                "source": "网易新闻客户端",
                                                "title": "今明两天,北京持续回暖!",
                                                "url": "xxx"
                                            }
                                        ],
                                        "type": "web_search"
                                    }
                                ]
                            }
                        },
                        "type": "complex_tool",
                        "type_end": true
                    }
                ],
                "is_finished": false,
                "title": "使用联网搜索工具查询北京2026年2月13日及最近几天的天气信息,获取温度、天气状况、风力、降水概率等具体数据",
                "type": "thought_chain",
                "type_end": true,
                "params": {}
            },
            {
                "content": "xxxx",
                "is_finished": false,
                "type": "text",
                "type_end": true
            },
            {
                "content": "任务已完成",
                "is_finished": false,
                "type": "text",
                "type_end": true
            }
        ],
        "is_finished": false,
        "finish_reason": "",
        "type": "think",
        "type_end": true,
        "params": {}
    },
    {
        "msg_id": "xxxx",
        "content": "根据最新的天气信息",
        "is_finished": false,
        "finish_reason": "",
        "type": "text",
        "type_end": true
    }
]

整体,最外层是一个大数组,数组里面第一层是一个一个的json对象,一个json代表一个类型

[
  {
    "msg_id": "xxx",
    "content": "根据最新的天气信息",
    "is_finished": false,
    "finish_reason": "",
    "type": "text",
    "params": {},
    "type_end": true
  },
  ....
]

字段解释如下:

  • msg_id:消息id
  • content:消息内容
  • is_finished:整段流式消息是否结束
  • finish_reason:流式消息结束原因
  • type:消息类型
  • params: 配置信息字段 (可不传,当组件支持的时候会支持对应的功能)
  • type_end:当前类型消息是否结束  

每个类型的消息基本都遵守这个字段组合,无论是文本还是复杂的嵌套关系,每一个类型都遵循。  

三、流式消息类型规范

截止至今,我们总共支持的消息类型有:

  • image 图片
  • conf 配置字段
  • think 思考
  • step 步骤
  • text 文本
  • tool 工具调用
  • complexTool 复杂工具
  • thought_chain 思维链

八种消息类型,后续迭代可能继续新增。

根据我们处理的逻辑,这一系列类型可以分为内容为Array、Object、String三大类,主要类型是根据其内容的类型和最终处理的结果来定。主要划分如下


    // 内容类型为对象的类型
    const CONTENT_OBJECT_MAP = [
      STREAM_TYPES.IMAGE, 
      STREAM_TYPES.CONFIG
    ]
    // 内容类型为数组的类型
    const CONTENT_ARRAY_MAP = [
      STREAM_TYPES.THINK, 
      STREAM_TYPES.THOUGHTCHAIN
    ]
    // 内容类型为字符串的类型
    const CONTENT_INSTEAD_MAP = [
      STREAM_TYPES.STEP,
      STREAM_TYPES.TEXT,
      STREAM_TYPES.TOOL,
      STREAM_TYPES.COMPLEX_TOOL
    ]
    // 类型定义
    export enum STREAM_TYPES {
      IMAGE = 'image',
      CONFIG = 'conf',
      THINK = 'think',
      STEP = 'step',
      TEXT = 'text',
      TOOL = 'tool',
      COMPLEX_TOOL = 'complex_tool',
      THOUGHTCHAIN = 'thought_chain'
    }

流式消息的处理过程有两个阶段,流式中和流式结束,下面我们按照三种不同的 content 结构来进行梳理

2.1、Array类型

内容为Array的类型是表示我们渲染的时候支持嵌套的组件,例如:在思考中,他有自己的内容区域,而在这个内容区域还支持渲染text类型,tool工具类型等。所以这种消息类型用Array数据结构

2.1.1、Think类型

think类型是主要用于我们的思考部分,分为两部分,外层展开收起容器,内嵌组合式消息内容。流式处理分为两个阶段,流式中,流式完成。

1、流式中

此时是接口SSE返回阶段,通过charles抓包,数据结构如下:

    data: {"msg_id":"xxx","content":{"content":"调用","is_finished":false,"type":"text","type_end":false},"is_finished":false,"finish_reason":"","type":"think","type_end":false}
    data: {"msg_id":"xxx","content":{"content":"联网","is_finished":false,"type":"text","type_end":false},"is_finished":false,"finish_reason":"","type":"think","type_end":false}
    data: {"msg_id":"xxx","content":{"content":"搜索,","is_finished":false,"type":"text","type_end":false},"is_finished":false,"finish_reason":"","type":"think","type_end":false}
    data: {"msg_id":"xxx","content":{"content":"","is_finished":false,"type":"text","type_end":true},"is_finished":false,"finish_reason":"","type":"think","type_end":false}
    data: {"msg_id":"xxx","content":{"content":"调用搜索工具","is_finished":false,"type":"tool","type_end":true},"is_finished":false,"finish_reason":"","type":"think","type_end":false}
    data: {"msg_id":"xxx","content":{"content":"","is_finished":false,"type":"text","type_end":true},"is_finished":false,"finish_reason":"","type":"think","type_end":true}

这一系列类型的content在流式过程中,返回的是json对象,此时的content里面包裹的其实是另外一个类型的消息,这个内容需要包含对应类型的消息的字段,所以流式过程中返回的是json,通过这种方式,我们可以在think类型中,嵌套多个其他类型的消息。

同时,当一个类型消息结束的时候,最后都会返回一个内容为空的,type_end为true的标识,表示当前类型消息结束

2、流式完成

流式完成就是将流式过程中返回的数据进行组合,组合成用于渲染的格式

    [
      {
        "msg_id": "xxx",
        "content": [
          {
            "content": "调用联网搜索",
            "is_finished": false,
            "type": "text",
            "type_end": true
          },
          {
            "content": "调用搜索工具",
            "is_finished": false,
            "type": "tool",
            "type_end": true
          }
        ],
        "is_finished": false,
        "finish_reason": "",
        "type": "think",
        "type_end": true
      }
    ]

think类型支持一个params字段 cost_time:用于渲染耗时

具体用法

{

    "msg_id": "xxx",

    "content": [...],

    "is_finished": false,

    "finish_reason": "",

    "type": "think",

    "type_end": true,

    "params": {
         "cost_time": "38s"

    }

}

2.1.2、thought_chain类型

thought_chain代表着一个思维链,是一个带状态的连续性的消息,可以用于渲染agent执行某个plan或者具体的环节,其结构与think类型一致,不过它支持更多的params字段配置,同样分为两个阶段。

1、流式中

    data: {"msg_id":"xxx","content":{"content":{"content":"xxx","is_finished":false,"type":"text","type_end":false},"is_finished":false,"title":"使用联网搜索工具查询...","type":"thought_chain","type_end":false},"is_finished":false,"finish_reason":"","type":"think","type_end":false}
    data: {"msg_id":"xxx","content":{"content":{"content":"联网搜索","is_finished":false,"params":{"click":true,"data_detail":{"input":"xxx","output":[{"content":[{"desc":"xxx","source":"xxx","title":"2026年02月13日京山天气预报","url":"xxx"},{"desc":"xxx","source":"中华网新闻频道","title":"新闻频道_中华网","url":"xxx"}],"type":"web_search"}]},"icon":"xxx","id":"web_search"},"type":"complex_tool","type_end":true},"is_finished":false,"title":"使用联网搜索工具查询","type":"thought_chain","type_end":false},"is_finished":false,"finish_reason":"","type":"think","type_end":false}
    data: {"msg_id":"xxx","content":{"content":{"content":"联网搜索","is_finished":false,"params":{"icon":"xxx","id":"web_search","status":"end"},"type":"complex_tool","type_end":true},"is_finished":false,"title":"使用联网搜索工具查询","type":"thought_chain","type_end":true},"is_finished":false,"finish_reason":"","type":"think","type_end":false}

这里有一堆的字段,看起来很麻烦,实际上这里是在think中嵌套了thought_chain,thought_chain内部又嵌套了其他类型组件,简单一点来看,其实和think是一样的,无非是换了一个类型

    data: {"msg_id":"xxx","content":{"content":"调用","is_finished":false,"type":"text","type_end":false},"is_finished":false,"finish_reason":"","type":"thought_chain","type_end":false}
    data: {"msg_id":"xxx","content":{"content":"联网","is_finished":false,"type":"text","type_end":false},"is_finished":false,"finish_reason":"","type":"thought_chain","type_end":false}
    data: {"msg_id":"xxx","content":{"content":"搜索,","is_finished":false,"type":"text","type_end":false},"is_finished":false,"finish_reason":"","type":"thought_chain","type_end":false}
    data: {"msg_id":"xxx","content":{"content":"","is_finished":false,"type":"text","type_end":true},"is_finished":false,"finish_reason":"","type":"thought_chain","type_end":false}
    data: {"msg_id":"xxx","content":{"content":"调用搜索工具","is_finished":false,"type":"tool","type_end":true},"is_finished":false,"finish_reason":"","type":"thought_chain","type_end":false}
    data: {"msg_id":"xxx","content":{"content":"","is_finished":false,"type":"text","type_end":true},"is_finished":false,"finish_reason":"","type":"thought_chain","type_end":true}

2、流式完成

流式完成也和think一样,就是换了一个类型

    [
      {
        "msg_id": "xxx",
        "content": [
          {
            "content": "调用联网搜索",
            "is_finished": false,
            "type": "text",
            "type_end": true
          },
          {
            "content": "调用搜索工具",
            "is_finished": false,
            "type": "tool",
            "type_end": true
          }
        ],
        "is_finished": false,
        "finish_reason": "",
        "type": "thought_chain",
        "type_end": true
      }
    ]

2.2、Object类型

目前就两种类型conf和image类型,本质上就是类型不同和字段不同的区别

image类型是存在两个字段

字段 类型 作用
url string 图片资源路径
preview 1|0 是否可预览

conf类型可以支持去配其他字段,比如在图文消息中会返回

字段 类型 作用
no_share 1|0 是否支持分享

2.2.1、image类型

1、流式中

    // 图片类型
    data: {"msg_id":"xxx","content":{"preview":1,"url":"xxx"},"is_finished":false,"finish_reason":"","type":"image","type_end":true}
    data: {"msg_id":"xxx","content":"","is_finished":true,"finish_reason":"done","type":"text","type_end":false}

2、流式完成

    [
      {
          "msg_id": "xxx",
          "content": {
              "preview": 1,
              "url": "xxx"
          },
          "is_finished": false,
          "finish_reason": "",
          "type": "image",
          "type_end": true
      }
    ]

2.2.2、conf类型

当前场景为图文消息的时候,一般出现在画图技能,任务队列繁忙的时候

1、流式中

    data: {"msg_id":"xxx","content":"xxx","is_finished":false,"finish_reason":"","type":"text","type_end":true}
    data: {"msg_id":"xxx","content":{"preview":0,"url":"xxx"},"is_finished":false,"finish_reason":"","type":"image","type_end":true}
    data: {"msg_id":"xxx","content":{"no_share":1},"is_finished":false,"finish_reason":"","type":"conf","type_end":true}
    data: {"msg_id":"xxx","content":"","is_finished":true,"finish_reason":"done","type":"text","type_end":false}

2、流式完成

    [
        {
            "msg_id": "xxx",
            "content": {
                "no_share": 1
            },
            "is_finished": false,
            "finish_reason": "",
            "type": "conf",
            "type_end": true
        },
        {
            "msg_id": "xxx",
            "content": "xxx。",
            "is_finished": false,
            "finish_reason": "",
            "type": "text",
            "type_end": true
        },
        {
            "msg_id": "xxx",
            "content": {
                "preview": 0,
                "url": "xxx"
            },
            "is_finished": false,
            "finish_reason": "",
            "type": "image",
            "type_end": true
        }
    ]

2.3、String类型

这一分类指的是content为字符串string的一系列消息类,主要包括:

  1. step
  2. text
  3. tool
  4. complex_tool

四个类型,其中比较特殊的就是complex_tool,支持params进行配置

2.3.1、step类型

step类型主要用于步骤纯文案的渲染,

其采用的不是拼接而是替换,例如第一次内容返回的是“正在搜索中”,第二次返回“搜索完成”,渲染出来的ui会是先显示“正在搜索中”,然后显示“搜索完成”,不会进行组合拼接。并且一旦后续有其他类型消息进入,则不在显示

1、流式中

    data: {"msg_id":"xxx","content":"正在搜索中","is_finished":false,"finish_reason":"","type":"step","type_end":false}
    data: {"msg_id":"xxx","content":"搜索完成","is_finished":false,"finish_reason":"","type":"step","type_end":true}

2、流式完成

    [
      {
        "msg_id":"xxx",
        "content":"搜索完成",
        "is_finished":false,
        "finish_reason":"",
        "type":"step",
        "type_end":true
      }
    ]

2.3.2、text类型

text类型就是我们最常见的文本,也是正文,主要采用markdown进行渲染,采用的是拼接逻辑

1、流式中

    data: {"msg_id":"xxx","content":"这","is_finished":false,"finish_reason":"","type":"text","type_end":false}
    data: {"msg_id":"xxx","content":"是","is_finished":false,"finish_reason":"","type":"text","type_end":false}
    data: {"msg_id":"xxx","content":"文","is_finished":false,"finish_reason":"","type":"text","type_end":false}
    data: {"msg_id":"xxx","content":"本","is_finished":false,"finish_reason":"","type":"text","type_end":false}
    data: {"msg_id":"xxx","content":"","is_finished":false,"finish_reason":"","type":"text","type_end":true}

2、流式完成

    [
      {
        "msg_id":"xxx",
        "content":"这是文本",
        "is_finished":false,
        "finish_reason":"",
        "type":"text",
        "type_end":true
      }
    ]

2.3.3、tool类型

最基础的工具类型,一次返回,没有过程,直接渲染

1、流式中

    data: {"msg_id":"xxx","content":"正在调用xxx工具","is_finished":false,"finish_reason":"","type":"tool","type_end":true}

2、流式完成

    [
      {
        "msg_id":"xxx",
        "content":"正在调用xxx工具",
        "is_finished":false,
        "finish_reason":"",
        "type":"tool",
        "type_end":true
      }
    ]

2.3.4、complex_tool类型

工具类型的进阶版,支持params字段配置额外的能力,目前支持:完成状态,点击侧边,耗时展示。

与工具类型的主要区分就是params字段

其params字段目前支持

字段 类型 作用
id string 工具id,必传,用于更新工具状态
click boolean 是否支持点击侧边
data_detail object 侧边详情数据
icon string 工具图标
status “begin” “end” 工具调用状态
cost_time string 工具耗时

如果需要点击,首次返回需要返回click字段

1、流式中

    // 开始调用工具 支持点击首次返回click: true
    data: {"content":"联网搜索","is_finished":false,"params":{"click":true,"icon":"xxx","id":"web_search","status":"begin"},"type":"complex_tool","type_end":true}
    // 获取到结果
    data: {"content":"联网搜索","is_finished":false,"params":{"click":true,"data_detail":{"input":"xxx","output":[{"content":[{"desc":"xxx","source":"xxx","title":"xxx","url":"xxx"}],"type":"web_search"}]},"icon":"xxxx","id":"web_search"},"type":"complex_tool","type_end":true}
    // 调用完成
    data: {"content":"联网搜索","is_finished":false,"params":{"icon":"xxxx","id":"web_search","status":"end"},"type":"complex_tool","type_end":true}

2、流式完成

    [
      {
          "content": "联网搜索",
          "is_finished": false,
          "params": {
              "click": true,
              "icon": "xxx",
              "id": "web_search",
              "status": "end",
              "data_detail": {
                  "input": "2222",
                  "output": [
                      {
                          "content": [
                              {
                                  "desc": "xxx",
                                  "source": "xxx",
                                  "title": "xxx",
                                  "url": "xxx"
                              },
                              {
                                  "desc": "2222",
                                  "source": "2222",
                                  "title": "2222",
                                  "url": "2222"
                              }, 
                          ],
                          "type": "web_search"
                      }
                  ]
              }
          },
          "type": "complex_tool",
          "type_end": true
      }
    ]

然后data_detail也有一套规范,下面进行补充

2.3.5、data_detail规范

data_detail是params 中的一个可配置字段,主要作用是用于侧边栏的详情数据。

这个字段目前是直接返回,直接用的,所以没有流式状态和完成态,前后是保持一致

其有两个字段

字段
input string
output Array
1、input

是顶部输入的字段,展示的是用户的querry问题,目前只支持string。

2、output

output表示的智能体返回的输出部分,数组中接收的是一个个object,每一个object代表一个类型,对象的结构如下

字段
type string
content Array Object

type字段代表返回的类型,content则代表返回的详细内容

当前有三个类型做了处理:web_search、knowledge_recall、create_schedule

web_search,knowledge_recall :这两个类型会被渲染成卡片

create_schedule:对敏感信息进行了隐藏

类型 对应content
web_search Array(卡片)
knowledge_recall Array(卡片)
create_schedule object
其他 object (直接展示)

卡片消息类型字段一致为:

    // web_search
    {
      "content": [
        {
            "desc": "描述",
            "source": "源头",
            "title": "标题",
            "url": "源链接"
        },
        ...
      ],
      "type": "web_search"
    }
    // knowledge_recall
    {
        "content": [
            {
               "desc": "描述",
              "source": "源头",
              "title": "标题",
              "url": "源链接"
            },
            {
               "desc": "描述",
              "source": "源头",
              "title": "标题",
              "url": "源链接"
            }
        ],
        "type": "knowledge_recall"
    }

create\_schedule 创建日程 的output

    {
        "content": {
            "end_time": "2026-02-25 15:00:00", // 结束时间
            "participants": [ // 参与人
                {
                    "id": 123, // id
                    "name": "xxx", // 名字
                    "pic": "xxx", // 头像
                    "workcode": "xxx" // 工号
                },
                {
                    "id": 123,
                    "name": "xxx",
                    "pic": "xxx",
                    "workcode": "xxx"
                }
            ],
            "start_time": "2026-02-25 14:00:00",// 开始时间
            "title": "xxx" // 会议标题
        },
        "type": "create_schedule"
    }

其他类型都是直接返回json,并且直接渲染json。

前端向架构突围系列 - 跨端技术 [11 - 1]:JSBridge 原理与 Hybrid设计

2026年2月28日 10:29

在移动互联网爆发的黄金年代,几乎所有前端和客户端同学都吵过一个架:运营要改个大促活动的规则、换个 banner 位,前端改完代码刷新页面就上线了,客户端却要走「打包→提审→等苹果 / 安卓商店审核→用户下载更新」的完整流程,顺利的话 3 天,遇到审核被打回,一周都搞不定 —— 等功能上线,活动都快结束了。

原生开发的静态化短板,和业务对「动态化、快迭代」的强需求,形成了不可调和的矛盾。也正是在这个背景下,Hybrid 混合开发架构成了行业的标准答案:用原生做 App 外壳和底层能力底座,用 Web 承载高频迭代的业务 UI 和逻辑,兼顾原生的能力边界和 Web 的动态灵活性。

而能让两个完全隔离、语言不通的运行环境顺畅对话的核心纽带,就是我们这一节要拆解的主角 ——JSBridge。它不是什么复杂的黑科技,却是整个混合开发时代最核心的底层基建,哪怕到了今天的小程序、跨端框架时代,它的核心设计思想依然在被沿用。

image.png

1. 为什么我们必须要有 JSBridge?

搞懂 JSBridge 的前提,是先理解 Hybrid 架构里「两个世界的绝对隔离」。

在 Hybrid 架构中,所有 Web 页面都运行在原生提供的 WebView 容器里,而 Web 的 JavaScript 运行环境,和原生的 Java/Kotlin(安卓)/Objective-C/Swift(iOS)运行环境,是两个完全独立、相互隔离的沙箱:

  • Web 端(JS) :天生自带极致的动态化能力,代码改完实时生效,不用发版不用审核;但被浏览器沙箱牢牢限制,无法直接访问设备底层硬件(摄像头、蓝牙、陀螺仪、本地文件系统),也无法调用系统级的原生 UI 组件和能力,能做的事被死死框在浏览器的能力边界里。
  • Native 端:手握设备的所有权限,能调用所有系统 API,渲染性能拉满;但代码是静态编译的,只要改一行逻辑,就必须走完整的发版审核流程,完全跟不上业务的快节奏迭代。

一边是动态性拉满但能力受限的 Web,一边是能力拉满但动态性为零的 Native,想要让两者结合发挥最大价值,就必须在两个隔离的沙箱之间,架起一座能双向通行、能翻译两端语言的桥梁 —— 这就是 JSBridge 的核心价值:打通 Web 与 Native 的通信壁垒,实现双向的方法调用和数据传递

2. JSBridge 的核心通信原理

JSBridge 的双向通信,本质上就是两个方向的问题拆解:Native 调用 JS,以及 JS 调用 Native。所有 Hybrid 架构的底层逻辑,都绕不开这两个核心方向的实现。

2.1 Native 调用 JavaScript:简单直接的代码执行

这个方向的实现逻辑非常朴素,甚至可以说没有什么技术门槛:Native 作为 WebView 的宿主,本身就拥有直接在 WebView 的 JS 上下文里执行代码的权限,本质就是「原生拼接一段 JS 代码字符串,交给 WebView 去执行」。

只是随着系统版本的迭代,有了更高效、更完善的实现方案:

  • 安卓端:早期安卓 API 19 之前,只能用webView.loadUrl("javascript:methodName(params)")实现。这个方案坑非常多:不仅会触发页面刷新,还无法获取 JS 方法的返回值,多次高频调用还会出现阻塞和丢消息的问题,踩过这个坑的老安卓开发应该深有体会。从 API 19(安卓 4.4)开始,官方推出了webView.evaluateJavascript("methodName(params)", callback),不仅执行效率大幅提升,还能通过回调异步获取 JS 执行后的返回值,成了现在的主流方案。
  • iOS 端:早期的 UIWebView 性能差、内存泄漏问题严重,早已被淘汰;目前主流的 WKWebView,通过evaluateJavaScript:completionHandler:方法执行 JS 代码,同样支持异步获取执行结果,稳定性和性能都有质的提升。

这里要提一个工业级实现里的小细节:Native 调用 JS 时,一定要保证执行时机是在 WebView 的页面加载完成之后(也就是onPageFinished/didFinishNavigation回调之后),否则会出现 JS 上下文还没初始化、方法找不到的问题,这是新手最容易踩的坑之一。

2.2 JavaScript 调用 Native:从兼容到高效的三大方案

和 Native 调 JS 不同,Web 端没有直接执行原生代码的权限,所以这个方向的实现会更复杂。行业里经过多年的迭代,从「兼容优先」到「性能优先」,最终沉淀出了三种主流实现方案,每一种都带着鲜明的时代特征。

方案一:URL Scheme 拦截(早期行业主流,兼容性天花板)

这是 Hybrid 发展早期最经典、兼容性最强的方案,也是当年微信、手淘等超级 App 最早用的方案。

它的核心逻辑非常巧妙:

  1. JS 端通过创建隐藏的 iframe,或者直接修改window.location.href,发起一个自定义协议的请求,比如myapp://camera/open?callbackId=123&params={"quality":1080}
  2. Native 端在 WebView 的导航拦截回调里(安卓的shouldOverrideUrlLoading、iOS 的decidePolicyForNavigationAction),捕获到这个请求;
  3. 原生端解析这个自定义 URL 的协议、方法名、参数,执行对应的原生能力,再通过回调把结果返回给 JS 端。

这个方案的优势是全版本兼容,哪怕是非常老旧的系统版本,也能完美支持,没有安全风险;但缺点也很明显:URL 有长度限制,参数太长会被截断,而且每次发起请求都有一定的性能开销,高频调用场景下会有明显的延迟。

方案二:拦截 JS 全局弹窗方法(小众补位方案)

这是一个偏门的补位方案,核心原理是:Web 端调用alert()confirm()prompt()这三个全局弹窗方法时,Native 端可以通过 WebView 的回调拦截到调用内容和参数,其中prompt()支持字符串返回值,刚好能满足通信的需求。

但这个方案的缺点非常致命:需要侵入浏览器的全局方法,可能会影响页面的正常业务逻辑,而且通信性能一般,所以行业里几乎不会把它作为主力通信方案,只会作为极端场景下的兜底兼容方案。

方案三:API 对象注入(现代主流,性能天花板)

这是目前行业里的绝对主流方案,也是性能最高、开发体验最好的方案。核心逻辑是:Native 端直接向 WebView 的 JS 执行上下文,注入一个挂载在 window 上的全局原生 API 对象,JS 端可以像调用普通 JS 方法一样,直接调用这个对象上的原生能力,几乎没有额外的性能开销。

  • 安卓端:通过addJavascriptInterface方法注入全局对象。很多人听说过这个方案有安全漏洞,其实是在 API 17(安卓 4.2)之前,没有严格的方法注解限制,会导致恶意页面通过反射执行任意原生代码,出现严重的安全问题;但在 API 17 之后,官方引入了@JavascriptInterface注解,只有加了注解的方法才能被 JS 调用,安全问题已经被彻底解决。
  • iOS 端(WKWebView) :通过WKScriptMessageHandler协议注入消息处理对象,JS 端通过window.webkit.messageHandlers.<自定义名称>.postMessage()就能把消息发送给原生端,没有安全风险,性能也拉满。

3. 工业级 Hybrid 架构设计:从能用,到稳定好用

搞懂了底层的通信原理,只是跨进了 Hybrid 开发的门槛。在微信、手淘这类亿级用户的超级 App 里,一套成熟的 Hybrid 架构,绝不是简单的方法调用就能搞定的 —— 我们需要设计一套稳定、安全、易扩展、可排查的完整架构体系。

一个经过工业级验证的 Hybrid 架构,从上到下分为 5 个核心层级,每一层都有明确的职责边界和设计考量:

第一层:业务层(Web App)

这一层就是前端开发者最熟悉的部分:基于 Vue/React 等框架开发的业务页面,比如电商的活动页、资讯的详情页。业务代码不需要关心底层的通信细节,只需要引入封装好的 JSBridge SDK,像调用普通前端 API 一样调用原生能力即可。

第二层:JS SDK 封装层(Hybrid 架构的前端核心)

这一层是整个架构的前端门面,也是保证开发体验和稳定性的关键,绝不是简单的方法透传。一个成熟的 SDK,必须包含这些核心能力:

  1. 异步回调管理:JSBridge 通信本质上是异步的,SDK 需要维护一个全局的请求池,每次 JS 调用原生能力时,生成一个唯一的callbackId,把回调函数和 ID 绑定后存入请求池,再把 ID 和参数传给原生;原生执行完毕后,带着callbackId回调 JS,SDK 再通过 ID 找到对应的 Promise,执行 resolve/reject,完美适配前端的异步开发习惯。
  2. 消息队列与防抖:在页面初始化、高频操作等场景,会出现短时间内大量调用 Bridge 的情况,为了防止消息丢失、Native 线程阻塞,SDK 会维护一个消息队列,把并发的调用打包成批量消息,在空闲时间统一发送给原生。
  3. 超时与错误重试:针对原生调用超时、失败的场景,SDK 需要内置超时机制和重试策略,避免业务 Promise 一直 pending,同时给出明确的错误提示,方便业务做兜底处理。
  4. 参数序列化与版本兼容:处理两端的参数类型兼容问题,同时针对不同 App 版本的 Bridge 能力差异,做优雅的降级兼容,避免低版本 App 出现方法找不到的报错。

第三层:Native Bridge 层(Hybrid 架构的原生核心)

这一层是原生侧的调度中心,负责承接 JS 端的所有请求,核心职责有 3 个:

  1. 协议解析与分发:接收 JS 端传来的 JSON 格式数据,解析出目标模块、方法名和参数,分发到对应的原生能力模块执行,同时把执行结果封装成统一格式返回给 JS 端。
  2. 严格的权限校验:这是整个架构的安全生命线!必须做两层校验:一是校验当前加载的页面域名,是否在 App 的白名单内,防止恶意第三方页面调用 Bridge 能力;二是校验当前页面是否有权限调用对应的 API,比如非核心业务页面,不能调用通讯录、短信等敏感权限,从根源上避免用户隐私泄露。
  3. 生命周期管理:和 WebView 的生命周期绑定,页面销毁时,清空对应的回调池和消息队列,防止内存泄漏和无效回调。

第四层:原生能力插件层

这一层是 Hybrid 架构的能力底座,我们会把所有原生能力拆成独立的插件模块:比如设备信息、网络请求、摄像头、文件系统、原生 UI 组件(导航栏、弹窗、Loading)等等。

插件化设计的核心优势是解耦和易扩展:新增一个原生能力,只需要新增一个插件模块,不用修改 Bridge 核心层的代码;同时可以按需加载,避免核心包体积过大,这对超级 App 来说至关重要。

第五层:监控与埋点层

这一层是线上稳定性的保障,也是很多新手会忽略的一层。工业级的架构里,必须内置完整的监控能力:Bridge 调用的成功率、耗时、错误类型、TOP 报错场景,都要做完整的埋点上报。一旦线上出现问题,我们能快速定位是前端参数问题,还是原生执行出错,而不是两眼一抹黑。

4. 总结

JSBridge 从来都不是什么高深的技术,它的核心价值,是用最巧妙的方式,打破了 Web 和 Native 两个隔离世界的壁垒。它就像一个优秀的翻译官,让动态灵活的 Web,和能力强大的 Native,能顺畅对话、各司其职,也让 Hybrid 架构成了移动互联网时代,解决「动态化」需求的最优解。

但 Hybrid 架构从诞生的那天起,就有一个无法逾越的天花板:渲染性能。它的 UI 渲染始终依赖 WebView 浏览器引擎,在长列表滚动、复杂交互动画等场景下,DOM 操作的开销、JS 线程和渲染线程的互斥,导致它的流畅度始终无法和纯原生相提并论。

为了彻底突破 WebView 渲染的性能瓶颈,跨端技术开始向着两个全新的方向演进:一个是以 React Native 为代表的「JS 驱动原生渲染」架构,另一个是以 Flutter 为代表的「自绘引擎」架构。这也是我们下一节要深入探讨的,跨端技术的第二次革命性演进。

说说闭包的理解和应用场景?

作者 光影少年
2026年2月28日 10:21

这个问题是 JS 面试高频题 👍
而且如果你有 7 年前端经验,面试官会期待你说到:

  • 原理
  • 作用域链
  • 内存机制
  • 实际应用场景
  • 优缺点

我给你一套「中高级面试回答版本」。


一、什么是闭包?

一句话定义:

闭包是函数和其词法作用域的组合。

通俗一点:

函数可以记住并访问它定义时的作用域
即使函数在作用域外执行

二、核心本质(一定要讲)

闭包产生的本质原因:

函数执行后,其内部变量没有被销毁
因为有外部引用在使用它

本质是:

函数 + 作用域链 + 垃圾回收机制

三、最经典例子

function outer() {
  let count = 0;

  return function inner() {
    count++;
    console.log(count);
  }
}

const fn = outer();

fn(); // 1
fn(); // 2

为什么 count 没被销毁?

因为:

inner 函数引用了 outer 的变量
outer 的执行上下文不能被回收

这就是闭包。


四、从作用域链角度解释(进阶说法)

JS 在创建函数时,会保存:

[[Environment]]

也就是函数定义时所在的词法环境。

即使 outer 执行结束:

inner 依然持有 outer 的作用域引用

五、闭包的应用场景(面试重点 ⭐⭐⭐)


1️⃣ 数据私有化(最经典)

function createCounter() {
  let count = 0;

  return {
    add() { count++ },
    get() { return count }
  }
}

const counter = createCounter();

实现:

私有变量

这在早期 JS 中是实现“类私有属性”的方式。


2️⃣ 防抖 / 节流

function debounce(fn, delay) {
  let timer = null;

  return function() {
    clearTimeout(timer);
    timer = setTimeout(fn, delay);
  }
}

timer 被闭包保存。


3️⃣ 循环绑定事件(经典面试题)

for (var i = 0; i < 3; i++) {
  (function(i){
    setTimeout(() => {
      console.log(i);
    }, 1000)
  })(i)
}

利用闭包保存每次的 i。

(当然现在可以用 let)


4️⃣ 模块化(早期 IIFE)

const module = (function(){
  let privateVar = 1;

  return {
    get() {
      return privateVar;
    }
  }
})();

5️⃣ React Hooks 本质

比如:

useState()

内部就是通过闭包保存状态。


六、闭包的优缺点

优点

  • 数据私有
  • 延长变量生命周期
  • 实现函数式编程

缺点

  • 容易造成内存泄漏
  • 滥用会增加内存占用

七、什么时候会导致内存泄漏?

如果闭包引用了:

DOM 节点
大对象
长期不释放

就会导致 GC 无法回收。

比如:

function fn() {
  const dom = document.getElementById('box');

  return function() {
    console.log(dom);
  }
}

只要返回函数没释放,dom 就不会被回收。


八、面试标准回答模板(你可以直接背)

闭包是函数和其词法作用域的组合。
当内部函数引用外部函数变量时,外部函数执行完后变量不会被销毁,从而形成闭包。
本质是作用域链和垃圾回收机制的结果。
常见应用包括数据私有化、防抖节流、模块化封装、事件绑定等。

如何在老项目中使用AI实现智能问答

作者 菜鸟shuai
2026年2月28日 10:07

老树发新芽:在 Vue 2 老项目中优雅落地 AI 流式对话 (SSE)

前言:随着 DeepSeek、ChatGPT 等大模型的爆火,给现有业务系统装上“AI 大脑”已成为刚需。但对于许多仍坚守在 Vue 2 + Webpack 时代的企业级老项目来说,引入 AI 能力往往面临着技术栈陈旧、依赖冲突等挑战。本文将以实战代码为例,复盘如何在不破坏原有架构的前提下,利用原生技术栈实现丝滑的 AI 流式问答体验。

一、 痛点分析:为什么不用 Axios?

在老项目中,我们通常封装了统一的 Axios 拦截器来处理 Token、错误码和全局 Loading。但在对接 AI 流式接口(Server-Sent Events, SSE)时,Axios 显得有些“水土不服”:

  1. 流式支持弱:老版本 Axios 对 onDownloadProgress 的支持更多是为了进度条,而非真正的流式解析,容易出现“等一坨数据回来再一次性渲染”的伪流式现象。
  2. 配置繁琐:为了通过 Axios 获取原始流,往往需要修改 responseType 并绕过原有的响应拦截器,代码侵入性强。

破局思路:返璞归真。利用浏览器原生支持的 Fetch API + ReadableStream,既能实现真正的流式读取,又能与项目原有的 Axios 逻辑完全解耦,做到零侵入集成。

二、 核心方案:Fetch + ReadableStream 组合拳

1. 封装通用的流式请求器

我们在项目中通过 AbortController 实现了超时控制和请求中断,配合 TextDecoder 完美解决了二进制流转文本时的乱码问题(特别是中文被截断时)。

/**
 * 发起流式请求的核心方法
 * @param {string} url - 接口地址
 * @param {object} payload - 请求参数
 * @param {function} onMessage - 接收消息的回调
 * @param {function} onThinking - 接收思考过程的回调
 */
async function streamRequest(url, payload, onMessage, onThinking) {
  // 1. 也是老项目常被忽略的细节:请求中断控制器
  const controller = new AbortController();
  // 设置 5 分钟超长超时,适应 AI "慢思考" 的特性
  const timeoutId = setTimeout(() => controller.abort(), 300000);

  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        // 如果需要鉴权,在此处添加 Token
        // 'Authorization': 'Bearer ' + getToken() 
      },
      body: JSON.stringify(payload),
      signal: controller.signal,
    });

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    // 2. 获取流读取器
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    // 3. 循环读取流数据
    while (true) {
      const { done, value } = await reader.read();
      
      if (done) break;

      // 关键点:流式解码,自动处理 UTF-8 多字节字符被切断的情况
      const chunk = decoder.decode(value, { stream: true });
      buffer += chunk;

      // 4. 处理 SSE 格式数据 (假设以 "data:" 开头)
      // 这一步是为了解决 TCP 粘包问题:一次可能收到多条消息,或者一条消息分两次收到
      if (buffer.includes('data:')) {
        const dataBlocks = buffer.split(/(?=data:)/);
        // 保留最后一个可能未传输完整的块,留到下一次循环处理
        buffer = dataBlocks.pop() || '';

        for (const block of dataBlocks) {
          if (!block.trim()) continue;
          processSSEBlock(block, onMessage, onThinking);
        }
      }
    }
  } catch (error) {
    if (error.name === 'AbortError') {
      console.warn('请求已取消或超时');
    } else {
      throw error;
    }
  } finally {
    clearTimeout(timeoutId);
  }
}

2. 解析 SSE 协议数据

后端通常会定义自定义的事件协议,例如区分“思考中(Think)”和“回答中(Message)”。我们需要精准解析这些状态,给用户更好的反馈。

function processSSEBlock(block, onMessage, onThinking) {
  try {
    // 提取 JSON 内容
    const match = block.match(/data:(.+?)(?=data:|$)/s);
    if (match) {
      const jsonStr = match[1].trim();
      const jsonData = JSON.parse(jsonStr);

      // 状态机分发
      if (jsonData.event === 'think') {
        // AI 正在深度思考
        onThinking(jsonData.answer); 
      } else if (jsonData.event === 'message') {
        // AI 开始输出正文
        onMessage(jsonData.answer);
      }
    }
  } catch (e) {
    // 容错处理:非 JSON 格式的纯文本流
    const textContent = block.replace(/^data:/, '').trim();
    if (textContent && !textContent.startsWith('{')) {
      onMessage(textContent);
    }
  }
}

三、 体验优化:让 AI 更像“人”

在老旧的 UI 框架(如 Element UI)中,如何让 AI 的交互显得现代且灵动?

1. 视觉上的“心跳”机制

AI 的思考过程往往是静默的。为了缓解用户的等待焦虑,我们设计了一个“思考中”的状态机。

  • 痛点:有时候 AI 思考完了,但生成正文还有延迟,导致中间出现尴尬的空白期。
  • 优化:引入智能防抖机制。
// 监听思考流
if (event === 'think') {
  this.isThinking = true;
  this.thinkingContent += content;
  
  // 如果 1.5 秒内没有新的思考内容,自动判定思考结束,转入生成状态
  // 避免后端漏发 'think_end' 事件导致一直显示"思考中"
  clearTimeout(this.thinkTimer);
  this.thinkTimer = setTimeout(() => {
    this.endThinking();
  }, 1500);
}

2. 自动跟随滚动的视窗

当生成内容很长时,用户希望视窗能自动跟随到底部,但在查看上面内容时又不希望被强制拉到底部。

startAutoScroll() {
  // 记录上一次的内容长度
  this.lastLength = 0;
  
  this.scrollInterval = setInterval(() => {
    const currentLength = this.currentContent.length;
    // 只有当内容真正增加时才滚动,避免无意义的 DOM 操作
    if (currentLength > this.lastLength) {
      this.scrollToBottom();
      this.lastLength = currentLength;
    }
  }, 1000); // 1秒的节流频率,既流畅又不占用过多主线程
}

四、 架构思考:Vue 2 老项目的“渐进式”改造

sbs-upms-ui 这种典型的企业级 Vue 2 项目中,我们没有选择重构整个 HTTP 模块,而是采用了插件式的打法:

  1. 独立文件:将 AI 相关的流式请求逻辑封装在单独的 .js 文件中,不污染原有的 API 封装。
  2. 组件复用:将 Markdown 渲染(vue-markdown-it)、打字机效果封装为独立的 Vue 组件,可以在任何业务页面按需引入。
  3. 数据驱动:利用 Vue 的响应式系统,将流式数据直接绑定到 el-inputdiv 上,无需手动操作 DOM 插入文本。

五、 结语

技术不仅仅是追逐新框架。在现有的 Vue 2 老系统中,通过合理利用原生 Fetch API 和流式处理思想,我们依然可以构建出不输给 Next.js/React 的现代化 AI 交互体验。这不仅是功能的叠加,更是对老项目生命力的延续。


相关技术栈:Vue 2.6, Element UI, Fetch API, Server-Sent Events (SSE)

微前端架构下的平台级公共组件资源体系设计

2026年2月28日 09:54

二、核心分层设计(对标阿里 Fusion / 字节 Semi / 腾讯 TDesign)

L0 — Design Token(设计令牌层)

职责:统一视觉语言,与 UI 框架解耦

@cmc/design-tokens
├── colors.css          # CSS Variables
├── spacing.css
├── typography.css
├── shadows.css
├── tokens.json         # Style Dictionary 源文件(可生成多端产物)
└── index.ts            # JS/TS 常量导出
  • Style Dictionary 管理,一份源数据 → 输出 CSS Variables / SCSS / TS / Figma Plugin
  • Element Plus 主题通过覆盖 --el-* 变量接入,各子应用无需各自配置
  • 由基座注入全局 CSS Variables,子应用继承

L1 — 基础增强组件层

职责:Element Plus 二次封装 + 原子级通用组件

@cmc/ui
├── CmcTable/           # 增强表格(列配置化、分页内聚、虚拟滚动)
├── CmcForm/            # 配置化表单(JSON Schema 驱动)
├── CmcUpload/          # 统一上传(OSS/本地/断点续传)
├── CmcDialog/          # 增强弹窗(拖拽/全屏/promise化)
├── CmcSearch/          # 搜索栏(折叠/展开/记忆)
├── CmcDescription/     # 详情描述列表
├── CmcPermission/      # 权限指令/组件
└── ...

关键设计原则

  • Props 透传v-bind="$attrs" 全量透传 Element Plus 原生属性,不做阉割
  • 插槽穿透:暴露原组件所有 slot,保证可扩展性
  • 类型完备:每个组件导出 Props / Emits / Expose 类型定义
  • 无业务逻辑:纯 UI 层,不含接口调用

L2 — 业务组件层

职责:跨系统复用的业务功能单元

@cmc/biz-components
├── ShipmentSelector/      # 船期选择器
├── PortPicker/            # 港口选择器(含模糊搜索+常用)
├── CustomerSearch/        # 客户搜索组件
├── ApprovalFlow/          # 审批流展示
├── FilePreview/           # 统一文件预览
└── ...
  • 允许内置接口调用,但通过 依赖注入 抽象 API 层(不硬编码域名/路径)
  • 通过 provide/inject 或 props 传入 API adapter

L3 — Pro 区块/模板层

职责:页面级可复用布局模式

@cmc/pro-components
├── ProTable/          # 搜索 + 表格 + 分页 + 工具栏 一体化
├── ProForm/           # 分步表单 / 弹窗表单 / 抽屉表单
├── ProLayout/         # 标准页面布局框架
├── ProDetail/         # 标准详情页
└── CrudTemplate/      # CRUD 页面生成器

三、微前端共享机制(核心难点)

方案:Module Federation + npm 包双轨制

┌──────────────────────────────────────────────────────┐
│                    分发策略矩阵                        │
├──────────────┬──────────────┬────────────────────────┤
│   组件层级    │   分发方式    │        理由            │
├──────────────┼──────────────┼────────────────────────┤
│ L0 Token     │ npm 包       │ 构建时确定,极少变更     │
│ L1 基础组件   │ npm 包       │ 稳定,需要类型推导      │
│ L2 业务组件   │ MF Remote    │ 变更频繁,需要热更新     │
│ L3 Pro 区块   │ npm 包       │ 需要 Tree-shaking      │
├──────────────┼──────────────┼────────────────────────┤
│ 紧急热修复    │ MF Remote    │ 一次发布,全部生效       │
└──────────────┴──────────────┴────────────────────────┘

Module Federation 关键架构

                    ┌───────────────────────┐
                    │   Component Service    │
                    │   (独立部署的组件服务)   │
                    │                       │
                    │  remoteEntry.js        │
                    │  ├── CmcUpload        │
                    │  ├── ApprovalFlow     │
                    │  └── ShipmentSelector │
                    └───────────┬───────────┘
                                │
              ┌─────────────────┼─────────────────┐
              ▼                 ▼                   ▼
     ┌────────────┐    ┌────────────┐      ┌────────────┐
     │  供应商门户  │    │  运营后台   │      │  客户门户   │
     │  (Consumer) │    │  (Consumer) │      │  (Consumer) │
     └────────────┘    └────────────┘      └────────────┘

Vite 集成配置@module-federation/vite):

// Component Service(Remote 端)
// vite.config.ts
import { federation } from '@module-federation/vite'

export default defineConfig({
  plugins: [
    federation({
      name: 'cmc_shared_ui',
      filename: 'remoteEntry.js',
      exposes: {
        './CmcUpload': './src/components/CmcUpload/index.vue',
        './ApprovalFlow': './src/components/ApprovalFlow/index.vue',
      },
      shared: {
        vue: { singleton: true },
        'element-plus': { singleton: true },
        pinia: { singleton: true },
      },
    }),
  ],
})
// 子应用(Consumer 端)
// vite.config.ts
federation({
  name: 'supplier_portal',
  remotes: {
    cmc_shared_ui: {
      type: 'module',
      name: 'cmc_shared_ui',
      // 通过 manifest 实现版本管理 + 灰度
      entry: 'https://static.cmclink.com/shared-ui/remoteEntry.js',
    },
  },
  shared: {
    vue: { singleton: true },
    'element-plus': { singleton: true },
  },
})

降级兜底策略(生产必备)

// useRemoteComponent.ts — 加载远程组件,失败回退本地
import { defineAsyncComponent, h } from 'vue'

export function useRemoteComponent(
  remoteName: string,
  localFallback: () => Promise<any>,
) {
  return defineAsyncComponent({
    loader: async () => {
      try {
        const module = await import(/* @vite-ignore */ `cmc_shared_ui/${remoteName}`)
        return module.default || module
      } catch (e) {
        console.warn(`[MF] Remote ${remoteName} 加载失败,回退本地版本`, e)
        const fallback = await localFallback()
        return fallback.default || fallback
      }
    },
    loadingComponent: () => h('div', { class: 'animate-pulse h-8 bg-gray-100 rounded' }),
    timeout: 5000,
  })
}

// 使用
const CmcUpload = useRemoteComponent(
  'CmcUpload',
  () => import('@cmc/ui/CmcUpload'), // npm 包兜底
)

四、版本治理与自动升级

┌───────────┐     push      ┌───────────┐    publish    ┌──────────┐
│ @cmc/ui   │ ──────────▶  │   CI/CD    │ ──────────▶  │ 私有 npm  │
│ 组件仓库   │              │ changesets │              │ Registry │
└───────────┘              └─────┬─────┘              └────┬─────┘
                                 │                          │
                                 │ trigger                  │ Renovate Bot
                                 ▼                          ▼
                          ┌─────────────┐          ┌──────────────┐
                          │ Storybook   │          │ 各子应用仓库   │
                          │ 自动部署     │          │ 自动提 PR      │
                          └─────────────┘          │ patch 自动合并  │
                                                   │ minor 人工审核  │
                                                   └──────────────┘

关键配置

  • changesets:语义化版本 + 自动 CHANGELOG
  • Renovate Bot:每日扫描依赖更新,自动 PR + 自动跑 CI
  • patch 自动合并:配置 Renovate automerge: true for patch
  • minor/major:需人工审核 PR 后合并

五、质量保障体系

环节 工具 说明
单元测试 Vitest + Vue Test Utils 每个 L1/L2 组件 ≥80% 覆盖率
视觉回归 Chromatic / Percy Storybook 截图对比,防止样式劣化
文档验收 Storybook / Histoire 每个组件必须有在线可交互 Demo
类型检查 vue-tsc --noEmit CI 卡口,类型不通过不允许发布
Bundle 分析 rollup-plugin-visualizer 防止包体积膨胀
API 兼容性 api-extractor 导出 API 变更自动检测 + 审批

六、仓库结构建议(pnpm workspace monorepo)

cmc-platform-ui/
├── pnpm-workspace.yaml
├── turbo.json                    # Turborepo 任务编排
├── .changeset/                   # changesets 配置
│
├── packages/
│   ├── design-tokens/            # @cmc/design-tokens (L0)
│   │   ├── tokens.json
│   │   └── package.json
│   │
│   ├── ui/                       # @cmc/ui (L1)
│   │   ├── src/
│   │   │   ├── CmcTable/
│   │   │   ├── CmcForm/
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsup.config.ts
│   │
│   ├── biz-components/           # @cmc/biz-components (L2)
│   │   ├── src/
│   │   └── package.json
│   │
│   ├── pro-components/           # @cmc/pro-components (L3)
│   │   ├── src/
│   │   └── package.json
│   │
│   └── shared/                   # @cmc/shared (工具函数/类型/常量)
│       ├── src/
│       └── package.json
│
├── apps/
│   ├── storybook/                # 组件文档站
│   └── mf-host/                  # Module Federation 组件服务
│       ├── src/
│       └── vite.config.ts
│
└── configs/                      # 共享配置
    ├── eslint-config/
    ├── tsconfig/
    └── tailwind-config/

七、与大厂方案对标

维度 阿里(Fusion/IceWork) 字节(Semi/Garfish) 本方案
设计令牌 Fusion Token Semi Token @cmc/design-tokens
基础组件 Fusion Next Semi Design @cmc/ui
业务组件 金融云物料 内部 Biz @cmc/biz-components
区块模板 IceWork 物料 Semi Pro @cmc/pro-components
微前端 qiankun Garfish Module Federation
共享策略 npm + CDN npm + MF npm + MF 双轨
自动升级 内部机器人 内部机器人 Renovate Bot
文档 Fusion Site Semi Site Storybook/Histoire

八、落地路径(推荐 3 个阶段)

Phase 1(1~2 周):基座搭建

  • 建 monorepo、配置 pnpm workspace + Turborepo
  • 迁移 5~10 个最高频 L1 组件
  • 接通 changesets + 私有 npm 发布

Phase 2(3~4 周):生态完善

  • Storybook 文档站上线
  • 接入 Renovate Bot 自动升级
  • 各子应用替换本地 copy → npm 依赖

Phase 3(5~8 周):高阶能力

  • L2 业务组件接入 Module Federation
  • 视觉回归测试
  • ProTable/ProForm 等 L3 组件建设

从零开始掌握 Shorebird:Flutter 热更新实战指南

作者 赤心Online
2026年2月28日 09:49

从零开始掌握 Shorebird:Flutter 热更新实战指南

在 Flutter 开发中,热更新一直是个让人又爱又恨的难题。Shorebird 的出现,很好地解决了这个痛点。本文将从环境搭建到生产实践,手把手带你掌握 Shorebird 的核心用法,并特别针对国内开发者关心的网络和平台问题给出实用建议。

一、Shorebird 是什么?

Shorebird 是一个为 Flutter 提供代码推送服务的平台,由 Flutter 创始团队成员创立,被公认为最接近官方的热更新方案。

核心优势:

  • 性能无损:Android 端保持 AOT 运行,iOS 端修改代码通过解释器执行(性能保留 90% 以上)
  • 平台合规:技术绕过应用商店限制,符合 Google Play 和 App Store 政策
  • 低侵入性:日常开发仍用标准 Flutter 命令,仅在发布时使用 Shorebird CLI

二、三步上手 Shorebird

🛠️ 第一步:环境配置与项目初始化

在开始前,请确保你的网络可以稳定访问海外服务(Shorebird CLI 和默认服务托管在海外)。

1. 安装 Shorebird CLI

curl --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/shorebirdtech/install/main/install.sh -sSf | bash

安装后运行验证:

shorebird doctor

2. 登录账号

shorebird login

浏览器会自动打开授权页面,使用 Google 或 Microsoft 账号登录即可。

3. 初始化项目 进入 Flutter 项目根目录:

shorebird init

这个命令会自动完成:

  • 创建 shorebird.yaml 配置文件(含唯一 app_id)
  • 添加 shorebird_code_push 依赖
  • 配置 Android 网络权限和 .gitignore
  • 在 Shorebird 控制台创建应用记录

💡 国内网络小贴士:如果登录或安装缓慢,可以尝试为终端配置代理,或复制链接在代理模式下手动访问。

📦 第二步:发布第一个版本

在发布补丁前,需要先发布一个通过 Shorebird 构建的“基础版本”。

Android 版本发布

# 生成 AAB(默认,用于 Google Play)
shorebird release android

# 如需生成 APK
shorebird release android --artifact apk

iOS 版本发布

shorebird release ios

⚠️ 注意:发布 iOS 版本前,请确保已在 Xcode 中完成证书和配置文件设置。

执行成功后,Shorebird 会上传符号文件到服务器,生成的安装包(AAB/IPA)即可上传至应用商店。

🩹 第三步:发布热更新补丁

应用上线后,假设需要修复一个小 Bug:

1. 修改 Dart 代码(如修复逻辑或调整 UI)

2. 发布补丁

# Android 补丁
shorebird patch android

# iOS 补丁
shorebird patch ios

Shorebird 会自动对比新旧代码,生成极小(通常几十到几百 KB)的二进制补丁包并上传。

更新生效机制:

  • 用户首次打开 App → 后台静默下载补丁
  • 用户第二次打开 App → 更新自动生效(不中断当前会话)

三、进阶技巧:精细化控制

通过 shorebird_code_push 包提供的 API,可以实现更灵活的控制:

手动检查更新

import 'package:shorebird_code_push/shorebird_code_push.dart';

final _shorebirdCodePush = ShorebirdCodePush();

void checkForUpdate() async {
  // 1. 检查是否有可下载更新
  final isUpdateAvailable = await _shorebirdCodePush.isNewPatchAvailableForDownload();
  if (isUpdateAvailable) {
    // 2. 下载更新
    await _shorebirdCodePush.downloadUpdateIfAvailable();
  }
}

void applyUpdate() async {
  // 3. 在合适时机(如点击“重启应用”按钮)安装更新并重启
  if (await _shorebirdCodePush.isNewPatchReadyToInstall()) {
    await _shorebirdCodePush.installUpdateAndRestart();
  }
}

结合 CI/CD 自动化

Shorebird 可以无缝集成到 Codemagic 等 CI/CD 工具中,实现版本发布和补丁更新的全自动化流程。

四、重要注意事项

📌 更新范围限制

Shorebird 补丁只能更新 Dart 代码,以下变更必须通过新的应用商店版本发布:

  • ❌ 原生代码修改(android/ios/ 目录)
  • pubspec.yaml 依赖变更
  • ❌ 新增资源文件(图片、字体等)

🌐 国内生产环境优化

Shorebird 默认服务在海外,国内使用时建议:

自定义 CDN 方案

  1. 将补丁包托管到国内云存储(如阿里云 OSS、七牛云)
  2. 修改客户端代码,从你的 CDN 地址下载补丁
  3. 大幅提升用户下载速度和成功率

📱 平台支持现状

平台 支持情况 说明
Android ✅ 完美支持 性能无损,完全合规
iOS ✅ 完美支持 遵守 App Store 规则
鸿蒙 (HarmonyOS NEXT) ❌ 不支持 官方暂无计划,需关注后续动态

五、总结与最佳实践

核心命令速查

# 生命周期命令
shorebird init          # 初始化
shorebird release       # 发布版本(基础包)
shorebird patch         # 发布补丁(热更新)

适用场景建议

  • ✅ 推荐使用:仅覆盖 Android + iOS 的中大型 App,追求高性能和低接入成本
  • ⚠️ 暂不适用:必须覆盖鸿蒙系统的项目(需等待官方支持)
  • 💪 最佳实践:国内生产环境务必配置自定义 CDN,确保补丁下载体验

Shorebird 以其优秀的性能和合规性,已成为 Flutter 热更新的首选方案。通过本文的指引,相信你已经能够顺利上手。如果在实际操作中遇到问题,欢迎随时交流探讨。

【节点】[LinearBlendSkinning节点]原理解析与实际应用

作者 SmalBox
2026年2月28日 09:41

【Unity Shader Graph 使用与特效实现】专栏-直达

Linear Blend Skinning(线性混合蒙皮)节点是Unity URP Shader Graph中专门用于实现骨骼动画效果的重要工具。该节点通过将网格顶点与骨骼变换矩阵相结合,实现平滑的骨骼变形效果,是现代实时角色动画的核心技术之一。在游戏开发、虚拟角色创建和三维动画制作中,线性混合蒙皮技术被广泛应用,它能够使三维模型随着骨骼的移动而自然变形,创造出逼真的角色动画。

线性混合蒙皮的基本原理是将每个顶点与一个或多个骨骼关联,并根据骨骼的变换矩阵对顶点位置、法线和切线进行加权变换。与传统刚性蒙皮不同,线性混合蒙皮允许多个骨骼同时影响同一个顶点,通过权重值控制各骨骼的影响程度,从而实现更加平滑自然的变形效果。这种技术在处理关节弯曲等复杂变形时表现出色,能够有效避免模型撕裂或折叠等视觉问题。

Unity中的Linear Blend Skinning节点专为Entities Graphics系统设计,这是Unity的面向数据技术栈(DOTS)的一部分。Entities Graphics采用数据导向的设计理念,能够高效处理大量动画实体,特别适合需要同时渲染成千上万个动画角色的场景,如大规模人群模拟、战略游戏中的单位群组等。

节点概述与兼容性

Linear Blend Skinning节点是Shader Graph中较为特殊的节点,它不适用于传统的GameObject渲染流程,而是专门为基于ECS(Entity Component System)的Entities Graphics系统设计。这意味着要使用此节点,项目必须启用Entities Graphics包并采用ECS架构进行开发。

Entities Graphics包是Unity DOTS技术栈的核心组成部分,它将渲染系统重新设计为数据导向的架构,能够充分利用多核处理器的并行计算能力。与传统的GameObject/MonoBehaviour系统相比,Entities Graphics在处理大量相似实体时具有显著的性能优势,因为它避免了缓存未命中和虚函数调用等性能开销。

使用Linear Blend Skinning节点前,开发者需要确保项目满足以下条件:

  • 安装并启用Entities Graphics包(版本1.0.0或更高)
  • 使用Unity 2022.2或更高版本
  • 项目已配置为使用Hybrid Renderer V2
  • 网格数据包含适当的蒙皮信息(骨骼权重和骨骼索引)

值得注意的是,Linear Blend Skinning节点仅适用于顶点着色阶段,因为它需要对每个顶点进行蒙皮变换计算。该节点不处理片段着色阶段的操作,如光照计算或纹理采样,这些仍需通过Shader Graph中的其他节点实现。

端口详解

输入端口

Position输入端口接收对象空间中的顶点位置坐标。这个位置是网格的原始顶点位置,尚未经过任何蒙皮变换。在典型的蒙皮流程中,这个输入通常来自Position节点或经过初步变换的顶点位置。

Normal输入端口接收对象空间中的顶点法线向量。法线在蒙皮过程中同样需要变换,以确保光照计算正确。如果法线不经过正确的蒙皮变换,在骨骼动画播放时,模型的明暗效果会出现异常,导致视觉上的不连贯。

Tangent输入端口接收对象空间中的顶点切线向量。切线主要用于法线映射和某些高级着色效果,如各向异性高光。与法线类似,切线也需要经过蒙皮变换以保持与变形后表面的一致性。

所有输入端口都只在顶点着色阶段有效,因为蒙皮计算是在每个顶点上执行的。输入数据的精度通常为float3,即包含三个32位浮点数的向量,这提供了足够的精度用于高质量的动画渲染。

输出端口

Position输出端口提供经过蒙皮变换后的顶点位置。这个位置已经根据关联的骨骼变换矩阵和权重进行了混合计算,可以直接用于后续的变换流程,如模型-视图-投影变换。

Normal输出端口提供经过蒙皮变换后的顶点法线。变换后的法线保持了与变形后表面的垂直关系,确保光照计算准确。法线的变换需要使用骨骼变换矩阵的逆转置矩阵,以保持正确的方向。

Tangent输出端口提供经过蒙皮变换后的顶点切线。切线变换与法线类似,但不需要使用逆转置矩阵,因为切线是方向向量而非法向量。

所有输出端口的数据类型与对应的输入端口一致,均为Vector 3,并且仅在顶点着色阶段有效。输出的数据可以连接到Shader Graph中的其他节点,如Transform节点或光照节点,以完成完整的着色器计算。

技术实现原理

线性混合蒙皮算法

Linear Blend Skinning节点的核心算法基于标准的线性混合蒙皮公式。对于每个顶点,其变换后的位置通过以下公式计算:

P_skinned = Σ(w_i × M_i × P_original)

其中:

  • P_skinned是蒙皮变换后的顶点位置
  • w_i是第i根骨骼的权重值(满足Σw_i = 1)
  • M_i是第i根骨骼的变换矩阵
  • P_original是原始顶点位置

类似的公式也应用于法线和切线的变换,但对于法线,需要使用变换矩阵的逆转置矩阵以保持正确方向:

N_skinned = Σ(w_i × (M_i^(-1))^T × N_original)

在实际实现中,Unity对标准算法进行了优化,以提升在Entities Graphics环境下的性能。这些优化包括:

  • 使用SOA(Structure of Arrays)数据布局提高缓存利用率
  • 利用SIMD指令进行并行矩阵运算
  • 采用分支预测优化减少GPU线程分歧

骨骼矩阵缓冲区

Linear Blend Skinning节点依赖于_SkinMatrices缓冲区,这是一个存储所有骨骼变换矩阵的GPU缓冲区。缓冲区的组织方式对性能有重要影响,通常采用按照骨骼索引顺序排列的线性布局。

每个网格实例通过_SkinMatrixIndex属性确定其在_SkinMatrices缓冲区中的起始位置。这个索引值与网格的骨骼数量结合,用于定位处理该网格所需的所有骨骼矩阵。

在Entities Graphics系统中,_SkinMatrices缓冲区由动画系统在每帧更新,反映当前帧中所有骨骼的变换状态。缓冲区更新通常发生在作业系统中,利用多核CPU并行计算所有骨骼的最终变换矩阵。

权重与索引处理

蒙皮数据中的骨骼权重和索引通常存储在网格的顶点属性中。标准的蒙皮网格通常为每个顶点提供:

  • 最多4个骨骼权重(和为1)
  • 对应的4个骨骼索引

Linear Blend Skinning节点内部处理这些数据,根据骨骼索引从_SkinMatrices缓冲区中获取相应的变换矩阵,然后根据权重进行混合计算。

对于超过4个骨骼影响的顶点,通常需要在建模阶段进行优化,或者使用高级蒙皮技术如双四元数蒙皮(Dual Quaternion Skinning)来避免变形问题。

配置与使用流程

前置条件设置

在使用Linear Blend Skinning节点前,需要进行一系列项目配置:

  • 安装Entities Graphics包:通过Package Manager安装最新版本的Entities Graphics包,并确保在Project Settings中已启用。
  • 配置Hybrid Renderer:在Entities Graphics配置中启用Hybrid Renderer V2,这是支持蒙皮网格渲染的必要组件。
  • 准备蒙皮网格:确保使用的网格包含蒙皮数据,包括骨骼权重和骨骼索引。这些数据通常在三维建模软件(如Blender、Maya)中创建并导出为FBX格式。
  • 设置动画系统:配置ECS动画系统,确保骨骼变换矩阵能够正确更新到_SkinMatrices缓冲区中。

在Shader Graph中的集成

将Linear Blend Skinning节点集成到Shader Graph中的基本步骤:

  1. 创建新的Shader Graph或打开现有图表
  2. 在节点库中找到Linear Blend Skinning节点(位于Animation类别下)
  3. 将节点拖放到图形编辑器中
  4. 连接输入端口:
    • 将Position节点的输出连接到Linear Blend Skinning的Position输入
    • 将Normal节点的输出连接到Normal输入
    • 将Tangent节点的输出连接到Tangent输入
  5. 连接输出端口:
    • 将Position输出连接到Vertex Position主节点输入
    • 将Normal输出连接到后续光照计算节点
    • 将Tangent输出连接到需要切线空间的节点(如法线贴图节点)

与实体组件的协同工作

在ECS环境中,Linear Blend Skinning节点与以下组件协同工作:

  • RenderMesh组件:存储网格和材质引用
  • LocalToWorld组件:存储实体的世界变换
  • SkinMatrixRenderer组件:标记需要蒙皮渲染的实体
  • BoneEntity组件:链接骨骼实体与渲染实体

动画系统会每帧更新骨骼实体的变换,这些变换通过Hybrid Renderer传递到_SkinMatrices缓冲区,最终被Linear Blend Skinning节点使用。

性能优化与最佳实践

缓冲区管理优化

_SkinMatrices缓冲区的管理对性能至关重要:

  • 骨骼矩阵应按照访问频率排序,将同一动画中经常同时使用的骨骼矩阵放置在相邻内存位置
  • 使用动态缓冲区而不是固定大小数组,以适应不同骨骼数量的网格
  • 对于静态或很少变化的骨骼动画,考虑使用常量缓冲区而非每帧更新

矩阵计算优化

在CPU端准备骨骼矩阵时可以采用多种优化策略:

  • 使用矩阵池减少内存分配
  • 利用Burst编译器加速矩阵计算
  • 采用层次化更新,只更新发生变化的骨骼
  • 使用增量更新策略,避免每帧完全重新计算所有矩阵

权重数据处理

优化权重数据可以提升蒙皮质量和性能:

  • 在建模阶段优化骨骼影响范围,减少每个顶点受影响的骨骼数量
  • 使用权重压缩技术减少内存占用
  • 对于移动平台,考虑使用半精度浮点数存储权重
  • 实现权重阈值裁剪,忽略影响微小的骨骼权重

平台特定优化

不同硬件平台可能需要不同的优化策略:

  • 在PC和主机平台,可以利用更复杂的蒙皮算法和更高的骨骼数量限制
  • 在移动平台,应限制最大骨骼数量并使用简化蒙皮算法
  • 对于WebGL目标,特别注意内存使用和矩阵计算复杂度

常见问题与解决方案

蒙皮变形异常

当蒙皮网格出现不自然的变形时,可能的原因和解决方案:

  • 骨骼权重不正确:检查三维软件中的权重绘制,确保关节处的权重过渡平滑
  • 骨骼矩阵计算错误:验证动画系统中骨骼矩阵的计算逻辑,特别是层级变换的处理
  • 缓冲区索引错误:确认_SkinMatrixIndex是否正确设置,与_SkinMatrices缓冲区中的位置对应

性能问题

蒙皮渲染性能不佳时的排查方向:

  • 分析GPU性能,确认瓶颈是否在顶点处理阶段
  • 检查骨骼数量是否过多,考虑使用LOD系统减少远处角色的骨骼数量
  • 评估权重计算复杂度,尝试减少每个顶点受影响的骨骼数量
  • 使用GPU分析工具(如RenderDoc)检查_SkinMatrices缓冲区的更新频率和大小

与光照和阴影的交互问题

蒙皮网格与光照系统交互时的常见问题:

  • 法线变换不正确导致光照异常:确保法线使用正确的变换矩阵(逆转置矩阵)
  • 阴影映射出现acne现象:调整深度偏移或使用更好的阴影过滤技术
  • 动态光照下的性能问题:对于大量蒙皮角色,考虑使用轻量级光照模型或烘焙光照

高级应用与扩展

自定义蒙皮算法

虽然Linear Blend Skinning节点实现了标准的线性混合蒙皮,但开发者可以通过自定义节点扩展其功能:

  • 实现双四元数蒙皮(Dual Quaternion Skinning)以减少关节弯曲处的体积损失
  • 添加蒙皮变形校正,如基于关节角的权重调整
  • 集成物理基的蒙皮,将物理模拟与骨骼动画结合

与其他动画技术结合

Linear Blend Skinning可以与其他动画技术结合使用:

  • 与顶点动画结合,在骨骼动画基础上添加细节运动
  • 与形状键(Blend Shapes)结合,实现面部表情等精细动画
  • 与 tessellation 结合,在GPU上增加几何细节并应用蒙皮

大规模人群渲染

利用Entities Graphics和Linear Blend Skinning实现大规模人群:

  • 使用GPU实例化渲染大量共享相同网格和材质的角色
  • 实现动画LOD,根据距离简化骨骼数量和蒙皮计算
  • 使用动画纹理(Animation Textures)批量处理骨骼矩阵,减少CPU-GPU数据传输

Linear Blend Skinning节点是Unity现代渲染管线中强大的动画工具,结合Entities Graphics架构,能够高效处理复杂的骨骼动画场景。通过深入理解其工作原理和优化策略,开发者可以创建出视觉震撼、性能优异的动画效果,为游戏和交互应用增添活力。随着Unity DOTS技术的不断发展,Linear Blend Skinning节点将在未来的实时图形应用中扮演更加重要的角色。


【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

首屏轮播图使用cdn加载webp图片的代码案例

作者 MQliferecord
2026年2月28日 09:27

目标:基于 CDN 加载 WebP 图片,且能自动降级为 PNG/JPG(兼容不支持 WebP 的浏览器)

  1. 第一步:封装 WebP 检测工具,在用户浏览器不支持webp的情况下加载png/jpg图片
// src/utils/image.ts
let webpSupportCache: boolean | null = null;

/**
 * 检测浏览器是否支持 WebP(带缓存)
 */
export async function checkWebPSupport(): Promise<boolean> {
  if (webpSupportCache !== null) return webpSupportCache;

  return new Promise((resolve) => {
    const img = new Image();
    img.src = 'data:image/webp;base64,UklGRkoAAABXRXJjUgAAABAAAAAQAAABAAAAAQAAABgAAAAEAAAAA==';
    img.onload = () => resolve(img.width === 1);
    img.onerror = () => resolve(false);
  })
}

/**
 * 生成图片 URL(自动切换 WebP/原图)
 * @param cdnBase CDN 基础路径
 * @param imgName 图片名称(不含后缀)
 * @param ext 原图后缀(如 png/jpg)
 */
export function getImageUrl(cdnBase: string, imgName: string, ext: string = 'png'): string {
  if (webpSupportCache) {
    // 支持 WebP 则拼接 webp 后缀
    return `${cdnBase}/${imgName}.webp`;
  }
  // 不支持则用原图后缀
  return `${cdnBase}/${imgName}.${ext}`;
}
  1. 首屏轮播图组件



双端 Diff 算法详解

作者 wuhen_n
2026年2月28日 07:46

在上一篇文章中,我们学习了 Diff 算法的基础原理和 key 的重要性。今天,我们将深入 Vue2 中经典的双端比较算法——这个算法通过四个指针的巧妙移动,实现了高效的节点更新。理解这个算法,不仅有助于掌握Vue2的diff原理,也为理解 Vue3 的更优算法打下基础。

前言:为什么需要双端比较?

我们还是以积木为例,假如我们有这样一排积木:

A B C D

然后我们想把它变成这样:

D A B C

也就是仅仅把 D 提到 A 的前面,如果我们用上一篇文章学的简单 Diff 算法,会怎么做呢?

  1. 比较位置0:A vs D,节点不同,更新为 D
  2. 比较位置1:B vs A,节点不同,更新为 A
  3. 比较位置2:C vs B,节点不同,更新为 B
  4. 比较位置3:D vs C,节点不同,更新为 C

上述 4 次更新操作中,没有复用任何节点。但实际上,这些节点除了顺序变化外,内容根本没有变。我们其实只需要通过移动 DOM 就复用它们,而且只需要移动一次(把 D 移动到 A 前面),就可以达到我们想要的效果。

双端 Diff 的核心思想

四个指针的设计

双端 Diff 算法在旧子节点数组和新子节点数组的两端各设置两个指针:

// 四个指针
let oldStartIdx = 0;              // 旧节点起始索引
let oldEndIdx = oldChildren.length - 1;   // 旧节点结束索引
let newStartIdx = 0;              // 新节点起始索引
let newEndIdx = newChildren.length - 1;    // 新节点结束索引

// 对应的节点
let oldStartVNode = oldChildren[oldStartIdx];
let oldEndVNode = oldChildren[oldEndIdx];
let newStartVNode = newChildren[newStartIdx];
let newEndVNode = newChildren[newEndIdx];

这四个指针的布局如图所示: 四个指针布局图

四种比较情况

双端比较的核心是进行四种比较:

1. 旧开始 vs 新开始

if (isSameVNodeType(oldStartVNode, newStartVNode)) {
  // 节点相同,直接复用
  patch(oldStartVNode, newStartVNode);
  oldStartIdx++;
  newStartIdx++;
}

2. 旧结束 vs 新结束

if (isSameVNodeType(oldEndVNode, newEndVNode)) {
  // 节点相同,直接复用
  patch(oldEndVNode, newEndVNode);
  oldEndIdx--;
  newEndIdx--;
}

3. 旧开始 vs 新结束

if (isSameVNodeType(oldStartVNode, newEndVNode)) {
  // 节点相同,但位置不同,需要移动
  patch(oldStartVNode, newEndVNode);
  // 将旧开始节点移动到旧结束节点之后
  insertBefore(oldStartVNode.el, oldEndVNode.el.nextSibling);
  oldStartIdx++;
  newEndIdx--;
}

4. 旧结束 vs 新开始

if (isSameVNodeType(oldEndVNode, newStartVNode)) {
  // 节点相同,但位置不同,需要移动
  patch(oldEndVNode, newStartVNode);
  // 将旧结束节点移动到旧开始节点之前
  insertBefore(oldEndVNode.el, oldStartVNode.el);
  oldEndIdx--;
  newStartIdx++;
}

通过 key 查找复用

为什么需要key查找?

当四种指标的比较都不匹配时,即非理想状况下,说明节点位置发生了较大变化。这时就需要通过 key 在旧节点中查找可复用的节点,如以下示例:

旧: A - B - C - D
新: C - A - D - B

第1轮比较时,四种指针比较都不匹配。这时就需要通过 key 查找,查找新开始节点 C 在旧节点中的位置,找到位置 2,就移动旧节点的 C 到开始位置。

// 在循环开始前建立key索引表
const keyToOldIndexMap = new Map();
for (let i = 0; i < oldChildren.length; i++) {
  const child = oldChildren[i];
  if (child.key != null) {
    keyToOldIndexMap.set(child.key, i);
  }
}

// 在四种比较都不匹配时使用
const idxInNew = keyToOldIndexMap.get(oldStartVNode.key);
if (idxInNew !== undefined) {
  // 找到了可复用的节点
  const vnodeToMove = newChildren[idxInNew];
  patch(oldStartVNode, vnodeToMove, container);
  // 移动节点
  container.insertBefore(oldStartVNode.el, oldStartVNode.el);
  // 标记该位置已处理
  newChildren[idxInNew] = undefined;
}

key查找的性能影响

场景 无key查找 有key查找 优势
头部插入 全量比较 直接定位 O(n) vs O(1)
节点移动 难以复用 精确复用 减少DOM操作
列表重排 性能差 性能优 差距可达10倍

完整的双端 Diff 实现

class DoubleEndedDiff {
  constructor(options = {}) {
    this.options = options;
  }
  
  /**
   * 执行双端比较
   */
  patchChildren(oldChildren, newChildren, container) {
    
    // 初始化指针
    let oldStartIdx = 0;
    let oldEndIdx = oldChildren.length - 1;
    let newStartIdx = 0;
    let newEndIdx = newChildren.length - 1;
    
    let oldStartVNode = oldChildren[oldStartIdx];
    let oldEndVNode = oldChildren[oldEndIdx];
    let newStartVNode = newChildren[newStartIdx];
    let newEndVNode = newChildren[newEndIdx];
    
    // 创建key索引表
    const keyToOldIndexMap = this.createKeyMap(oldChildren);
    
    // 记录移动次数
    let moveCount = 0;
    let patchCount = 0;
    let mountCount = 0;
    let unmountCount = 0;
    
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      // 跳过已处理的节点
      if (!oldStartVNode) {
        oldStartVNode = oldChildren[++oldStartIdx];
      } else if (!oldEndVNode) {
        oldEndVNode = oldChildren[--oldEndIdx];
      }
      // 情况1: 旧开始 = 新开始
      else if (this.isSameNode(oldStartVNode, newStartVNode)) {
        this.patch(oldStartVNode, newStartVNode, container);
        oldStartVNode = oldChildren[++oldStartIdx];
        newStartVNode = newChildren[++newStartIdx];
        patchCount++;
      }
      // 情况2: 旧结束 = 新结束
      else if (this.isSameNode(oldEndVNode, newEndVNode)) {
        this.patch(oldEndVNode, newEndVNode, container);
        oldEndVNode = oldChildren[--oldEndIdx];
        newEndVNode = newChildren[--newEndIdx];
        patchCount++;
      }
      // 情况3: 旧开始 = 新结束
      else if (this.isSameNode(oldStartVNode, newEndVNode)) {
        this.patch(oldStartVNode, newEndVNode, container);
        container.insertBefore(
          oldStartVNode.el,
          oldEndVNode.el.nextSibling
        );
        oldStartVNode = oldChildren[++oldStartIdx];
        newEndVNode = newChildren[--newEndIdx];
        moveCount++;
        patchCount++;
      }
      // 情况4: 旧结束 = 新开始
      else if (this.isSameNode(oldEndVNode, newStartVNode)) {
        this.patch(oldEndVNode, newStartVNode, container);
        container.insertBefore(
          oldEndVNode.el,
          oldStartVNode.el
        );
        oldEndVNode = oldChildren[--oldEndIdx];
        newStartVNode = newChildren[++newStartIdx];
        moveCount++;
        patchCount++;
      }
      // 情况5: 都不匹配,通过key查找
      else {
        const idxInOld = keyToOldIndexMap.get(newStartVNode.key);
        
        if (idxInOld !== undefined) {
          const vnodeToMove = oldChildren[idxInOld];
          this.patch(vnodeToMove, newStartVNode, container);
          container.insertBefore(
            vnodeToMove.el,
            oldStartVNode.el
          );
          oldChildren[idxInOld] = undefined;
          moveCount++;
          patchCount++;
        } else {
          this.mount(newStartVNode, container, oldStartVNode.el);
          mountCount++;
        }
        newStartVNode = newChildren[++newStartIdx];
      }
    
    // 处理剩余节点
    if (oldStartIdx > oldEndIdx) {
      for (let i = newStartIdx; i <= newEndIdx; i++) {
        const newVNode = newChildren[i];
        if (newVNode) {
          this.mount(newVNode, container, newChildren[newEndIdx + 1]?.el);
          mountCount++;
        }
      }
    } else if (newStartIdx > newEndIdx) {
      for (let i = oldStartIdx; i <= oldEndIdx; i++) {
        const oldVNode = oldChildren[i];
        if (oldVNode) {
          this.unmount(oldVNode);
          unmountCount++;
        }
      }
    }
  }
  
  /**
   * 创建key索引表
   */
  createKeyMap(children) {
    const map = new Map();
    for (let i = 0; i < children.length; i++) {
      const child = children[i];
      if (child?.key != null) {
        map.set(child.key, i);
      }
    }
    return map;
  }
  
  /**
   * 判断两个节点是否相同
   */
  isSameNode(n1, n2) {
    return n1 && n2 && n1.type === n2.type && n1.key === n2.key;
  }
  
  /**
   * 更新节点
   */
  patch(oldVNode, newVNode, container) {
    if (oldVNode.el) {
      newVNode.el = oldVNode.el;
      if (newVNode.children !== oldVNode.children) {
        newVNode.el.textContent = newVNode.children;
      }
    }
  }
  
  /**
   * 挂载新节点
   */
  mount(vnode, container, anchor) {
    const el = document.createElement(vnode.type);
    vnode.el = el;
    el.textContent = vnode.children;
    if (anchor) {
      container.insertBefore(el, anchor);
    } else {
      container.appendChild(el);
    }
  }
  
  /**
   * 卸载节点
   */
  unmount(vnode) {
    if (vnode.el && vnode.el.parentNode) {
      vnode.el.parentNode.removeChild(vnode.el);
    }
  }
}

源码对标:Vue2的双端 Diff

Vue2 的双端 Diff 算法实现位于 src/core/vdom/patch.js 中:

// Vue2源码中的双端比较(简化版)
function updateChildren(parentElm, oldCh, newCh) {
  let oldStartIdx = 0;
  let oldEndIdx = oldCh.length - 1;
  let newStartIdx = 0;
  let newEndIdx = newCh.length - 1;
  
  let oldStartVnode = oldCh[oldStartIdx];
  let oldEndVnode = oldCh[oldEndIdx];
  let newStartVnode = newCh[newStartIdx];
  let newEndVnode = newCh[newEndIdx];
  
  let oldKeyToIdx, idxInOld, vnodeToMove;
  
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (oldStartVnode == null) {
      oldStartVnode = oldCh[++oldStartIdx];
    } else if (oldEndVnode == null) {
      oldEndVnode = oldCh[--oldEndIdx];
    } else if (newStartVnode == null) {
      newStartVnode = newCh[++newStartIdx];
    } else if (newEndVnode == null) {
      newEndVnode = newCh[--newEndIdx];
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      patchVnode(oldStartVnode, newStartVnode);
      oldStartVnode = oldCh[++oldStartIdx];
      newStartVnode = newCh[++newStartIdx];
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode);
      oldEndVnode = oldCh[--oldEndIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (sameVnode(oldStartVnode, newEndVnode)) {
      patchVnode(oldStartVnode, newEndVnode);
      api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm));
      oldStartVnode = oldCh[++oldStartIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (sameVnode(oldEndVnode, newStartVnode)) {
      patchVnode(oldEndVnode, newStartVnode);
      api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
      oldEndVnode = oldCh[--oldEndIdx];
      newStartVnode = newCh[++newStartIdx];
    } else {
      if (oldKeyToIdx === undefined) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
      }
      idxInOld = oldKeyToIdx[newStartVnode.key];
      if (isUndef(idxInOld)) {
        api.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm);
      } else {
        vnodeToMove = oldCh[idxInOld];
        if (sameVnode(vnodeToMove, newStartVnode)) {
          patchVnode(vnodeToMove, newStartVnode);
          oldCh[idxInOld] = undefined;
          api.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
        } else {
          api.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm);
        }
      }
      newStartVnode = newCh[++newStartIdx];
    }
  }
  
  if (oldStartIdx > oldEndIdx) {
    // 挂载剩余新节点
  } else if (newStartIdx > newEndIdx) {
    // 卸载剩余旧节点
  }
}

结语

双端比较算法是 Vue2 响应式系统的核心之一,理解它不仅能帮助我们写出更高效的代码,也为理解 Vue3 的更优算法打下基础。虽然 Vue3 采用了新的算法,但双端比较的思想仍然值得我们深入学习。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

前端组件库开发实践:从零到发布

作者 codingWhat
2026年2月27日 23:53

为什么要搞自己的组件库?

先说说一些开发的名场面,看看你有没有中招:

  • 新开一个项目,复制粘贴上个项目的 components 文件夹,改改名字接着用;
  • 三个项目里同一套「表格 + 表单」各写各的,改一处要改三处;
  • 用现成 UI 库吧,按需加载配到怀疑人生,不用吧,自己从零写又太慢;
  • 产品说「这里要跟 XX 项目长得一样」,你发现那边早改版了,根本对不齐。

把共用的表格、表单、图表、布局、指令、工具函数收拢成一个库,统一维护、按需引用,多项目复用同一套体验和逻辑——这就是自研或二次封装组件库要解决的事。


一、先搭骨架:工程长什么样?

组件库不是 SPA,而是一个「被别的项目 import 的包」。举个实际项目为例:

my-ui-kit/
├── src/
│   ├── main.ts              # 库的唯一起点:样式、组件、指令、Composables 都从这里出去
│   ├── components/          # 按业务或功能分子目录Form、Table、Menu…
│   ├── directives/          # 自定义指令:复制、防抖、长按、拖拽等
│   ├── modules/             # 公共模块,比如 i18n、主题
│   ├── utils/               # 纯工具函数
│   └── vite-plugin.ts       # 可选:给使用方用的「按需解析」插件
├── playground/              # 本地调试用的示例项目,改完库立刻能看效果
├── dist/                    # 构建产物,不提交,发布时只发这个
├── locales/                 # 若有多语言,可随库一起发布
├── vite.config.ts
├── package.json
└── tsconfig.json

入口文件 main.ts 干三件事

  1. 样式:引入 Reset、UnoCSS/Tailwind 等,保证打出来的 dist/style.css 一份就够。
  2. 统一导出:组件、指令、Composables(如 useFormuseMenu)全部 export,方便 使用方 按需 import。
  3. 插件形态:导出一个带 install 的对象,使用方可以 app.use(MyUiKit) 一把梭全局注册。

入口示例:

// 样式最先
import '@unocss/reset/tailwind-compat.css'
import 'virtual:uno.css'

// 公共模块:i18n、指令安装函数等
import * as I18n from './modules/i18n'
export { I18n as I18nModule }
import { setupDirectives } from './directives'
export { setupDirectives }

// 组件:表格、表单、图表、菜单等
import Table from './components/Table/Table.vue'
import Form from './components/Form/Form.vue'
// ... 其余组件

// 指令
import XxxDirectiveCopy from './directives/modules/copy'
import XxxDirectiveDebounce from './directives/modules/debounce'
// ...

// 全局注册插件
export const globalPlugin = {
  install(app) {
    app.component('XxxTable', Table)
    app.component('XxxForm', Form)
    // ...
  }
}

// 具名导出:按需引用 + 友好 tree-shaking
export default globalPlugin
export { Table as XxxTable, Form as XxxForm, useForm, useMenu /* ... */ }
export { XxxDirectiveCopy, XxxDirectiveDebounce, /* ... */ }

这样使用方既可以「全量 + 全局注册」,也可以「只 import 用到的组件和 hooks」。


二、配 Vite 库模式:打出「可被 import 的包」

骨架有了,下一步是让 Vite 把项目打成库,而不是打成一个能跑的网页。

2.1 基础 lib 配置

vite.config.ts 里加上 build.lib

import { resolve } from 'path'
import pkg from './package.json'

export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'src/main.ts'),
      name: pkg.name,   // UMD 时挂到 window 上的名字
      formats: ['es', 'umd'],
      fileName: (format) => `${pkg.name}.${format}.js`,
    },
  },
})
  • es:给 Vite/Webpack/Rollup 用,支持 tree-shaking;
  • umd:给 script 标签或老环境兜底。

一执行 vite builddist/ 里就会出现 my-ui-kit.es.jsmy-ui-kit.umd.js,别的项目就能 import 了。

2.2 依赖别打包进来:external 是亲兄弟

组件库会用到 Vue、Element Plus、ECharts、vue-router 等——这些必须由使用方项目提供,不能打进你的 bundle,否则会出现「两份 Vue」「包体积爆炸」等问题。

rollupOptions 里把它们 external 掉,并告诉 UMD:「这些模块对应的是哪个全局变量」:

rollupOptions: {
  external: [
    'vue',
    'element-plus',
    'vue-router',
    'echarts',
    'vue-echarts',
    '@vueuse/core',
    'vue-i18n',
    // 若库里会 import 自己的子路径(如 locales),也要写进来,避免被打进 bundle
    'my-ui-kit/locales/zh-cn.json',
    'my-ui-kit/locales/en.json',
  ],
  output: {
    globals: {
      vue: 'Vue',
      'element-plus': 'ElementPlus',
      'vue-router': 'VueRouter',
      echarts: 'echarts',
      'vue-echarts': 'VueEcharts',
      'vue-i18n': 'VueI18n',
    },
    exports: 'named',  // 具名导出,方便 tree-shaking
  },
}

这样打出来的库又小又干净,运行时和业务项目共用同一套依赖,快去试试吧!


三、配 package.json:告诉 npm「入口在哪、发布什么」

3.1 主入口与类型

{
  "name": "my-ui-kit",
  "version": "1.0.0",
  "private": false,
  "main": "./dist/my-ui-kit.es.js",
  "module": "./dist/my-ui-kit.es.js",
  "types": "./dist/index.d.ts",
  "type": "module"
}
  • main / module:分别给 require 和 import 用(若只发 ESM,可都指到 es 产物)。
  • types:TS 声明入口。

3.2 使用 exports 一把梭

exports 可以精细控制「主入口、样式、多语言、Vite 插件」等子路径:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/my-ui-kit.es.js",
      "require": "./dist/my-ui-kit.umd.js"
    },
    "./style": "./dist/style.css",
    "./dist/style.css": "./dist/style.css",
    "./locales/*": "./dist/locales/*",
    "./vite": {
      "types": "./dist/vite-plugin.d.ts",
      "import": "./dist/vite-plugin.js",
      "require": "./dist/vite-plugin.cjs"
    }
  }
}

这样使用方可以:

  • import X from 'my-ui-kit' → 主包;
  • import 'my-ui-kit/style' → 样式;
  • import zh from 'my-ui-kit/locales/zh-cn.json' → 多语言;
  • import { XxxComponentsResolver } from 'my-ui-kit/vite' → 按需解析插件(若你提供了)。

3.3 只发 dist,依赖交给使用方

{
  "files": ["dist"],
  "peerDependencies": {
    "vue": "^3.3.11",
    "element-plus": "^2.4.4"
  }
}
  • files:只把 dist 发上去,源码、playground、测试都不发。
  • peerDependencies:声明「我依赖这些,但请您自己装嘞」,避免重复安装、版本打架等问题。

四、TypeScript 类型:让用你库的人也有提示

源码是 .ts / .vue,构建出来是 .js,使用方在 TS 项目里要类型提示和类型检查,就得有一份 .d.tsvite-plugin-dts 会在 build 时根据源码自动生成声明文件:

import dts from 'vite-plugin-dts'

export default defineConfig({
  plugins: [
    dts({
      rollupTypes: true,  // 把零散的 .d.ts 滚成少量文件,发布更清爽
    }),
  ],
  build: { /* ... */ },
})

构建完 dist/ 里会有 index.d.ts 等,使用方装你的包就能自动获得类型补全。


五、样式与静态资源:别漏了 CSS 和 locales

  • 样式:在 main.ts 最上面引入 UnoCSS/Reset 等,构建后会生成 dist/style.css。在 package.jsonexports 里暴露 ./style,使用方 import 'my-ui-kit/style' 即可。
  • 多语言 / 静态资源:若库内用到了 locales/zh-cn.json 等,构建时要把它们拷到 dist,否则发布后引用会 404。用 rollup-plugin-copywriteBundle 阶段拷贝:
import copy from 'rollup-plugin-copy'

plugins: [
  copy({
    targets: [{ src: './locales', dest: 'dist/' }],
    hook: 'writeBundle',
  }),
]

记得在 exports 里加上 "./locales/*": "./dist/locales/*",使用方才能正确引用。


六、构建脚本与体积分析

构建和包信息都齐了,接下来把日常用的脚本配好,顺带加个体积分析,避免悄悄打进不该打的东西。

  • 先类型检查再构建:避免带着类型错误发布。例如 "build": "run-p type-check \"build-only\" --"build-only 里只跑 vite build
  • 体积分析:可以用 rollup-plugin-visualizer,构建时生成依赖占比图:
import { visualizer } from 'rollup-plugin-visualizer'

const isAnalysis = process.env.ANALYSIS === 'true'
plugins: [
  visualizer({ open: isAnalysis }),
]

脚本里加一条:"analysis": "cross-env ANALYSIS=true npm run build-only",需要时跑一下,可以心里有数。


七、本地联调:不发布也能在业务项目里用

在真正发 npm 之前,先用 link 在业务项目里跑一跑,确保装包、引用、样式、类型都没问题。

在组件库目录:

pnpm build
pnpm link --global

在业务项目目录:

pnpm link --global my-ui-kit

之后业务项目里的 import ... from 'my-ui-kit' 会直接指向你本地的 dist。改完库再执行一次 pnpm build,刷新页面就能看到效果——再也不用手动拷 dist 了,但是吧,这个地方还是会有缓存问题,没辙。


八、按需引入:既省心又省体积

全量 app.use(MyUiKit) 会把所有组件都打进 bundle;更推荐按需引用,让打包器帮你 tree-shake 掉没用的。

8.1 使用方自己按需 import

import { XxxTable, XxxForm, useForm } from 'my-ui-kit'
import 'my-ui-kit/style'

只要库是 ES Module + 具名导出,未用到的组件会被自然摇掉。

8.2 自动按需:unplugin-vue-components + 自定义 Resolver

如果希望使用方不用手写 import,直接在模板里写 <XxxTable /> 就自动从库里拉对应组件,可以给 unplugin-vue-components 提供一个自定义 Resolver。做法是:在库里导出一份「组件名 → 从哪个包、用什么名字引入」的规则。

例如在库的 src/vite-plugin.ts 里(包名、前缀已泛化):

import { ComponentResolver } from 'unplugin-vue-components/types'

// 可选:Composables 自动从库里引入,避免使用方到处写 import
export const XxxAutoImports = {
  'my-ui-kit': ['useForm', 'useMenu', 'useDrag']
}

export const XxxComponentsResolver = [
  {
    type: 'component',
    resolve: (componentName) => {
      if (componentName.startsWith('Xxx'))
        return { name: componentName, from: 'my-ui-kit' }
    },
  },
  {
    type: 'directive',
    resolve: (name) => {
      const map = {
        Copy: { importName: 'XxxDirectiveCopy' },
        Debounce: { importName: 'XxxDirectiveDebounce' },
        Draggable: { importName: 'XxxDirectiveDraggable' },
        WaterMarker: { importName: 'XxxDirectiveWaterMarker' },
        // ...
      }
      const item = map[name]
      if (!item) return
      return { name: item.importName, from: 'my-ui-kit' }
    },
  },
]

使用方在 vite.config.ts 里:

import Components from 'unplugin-vue-components/vite'
import { XxxComponentsResolver } from 'my-ui-kit/vite'

export default defineConfig({
  plugins: [
    Components({
      resolvers: [XxxComponentsResolver],
    }),
  ],
})

这样模板里用到的 Xxx* 组件和指令都会自动按需从 my-ui-kit 引入。注意:样式通常还是整份引入一次 my-ui-kit/style 即可。


九、发布到 npm

类型、构建、package.json、本地 link 都验证过了,就可以发版了:

  1. 版本号npm version patch(或改 package.json 里的 version)。
  2. 登录npm login(私有 registry 就按你们流程来)。
  3. 发布npm publish。若是 scoped 包且首次发,记得 npm publish --access public

发完,别人就能 pnpm add my-ui-kit ,然后愉快地使用啦。

总结:

把业务中积累的「components」 抽成公共包,从「复制粘贴」升级成「库模式构建 + 类型 + 规范发布 + 按需使用」,在多项目里复用同一套组件和指令就会稳很多。组件再也不是拖累,而是资源宝库!
后面还可以加上单元测试、文档站(如 VitePress)、Changelog 和语义化版本等等,一步步做成团队级的组件库产品。有坑一起踩,与掘友们共勉!

从 LocalStorage 待办清单到 CSS 核心机制:一次搞懂数据持久化、继承与盒模型陷阱

2026年2月27日 23:45

从 LocalStorage 待办清单到 CSS 核心机制:一次搞懂数据持久化、继承与盒模型陷阱

摘要:本文通过一个完整的 LocalStorage 待办事项(Todo List)实战案例,深入剖析前端数据持久化的实现顺序与工程化思维。同时,结合 MDN 权威文档,彻底讲透 CSS 中易混淆的继承机制outline 与 border 的本质区别以及overflow 的裁剪原理。拒绝碎片化知识,带你从“写出代码”进阶到“写好代码”。


📦 第一部分:LocalStorage 实战——构建专业的待办清单

在之前的代码片段中,我们看到了 addItemtoggleDone 两个核心函数。现在,我们将它们放入完整的 HTML 上下文中,解析一个生产级的 LocalStorage 应用是如何构建的。

1. 核心代码执行顺序解析

一个健壮的 LocalStorage 应用,其初始化与交互逻辑必须遵循严格的时序。以下是基于完整代码的执行流分析:

第一步:DOM 就绪与数据初始化(同步)
const addItems = document.querySelector('.add-items');
const itemsList = document.querySelector('.plates');
// 关键行:尝试从 localStorage 获取数据,若无则初始化为空数组
const items = JSON.parse(localStorage.getItem('todos')) || []; 
  • 原理解析
    • 代码加载时,首先缓存 DOM 元素引用,避免后续重复查询。
    • localStorage.getItem('todos') 返回的是字符串或 null
    • JSON.parse() 将字符串转回对象数组。注意:如果 localStorage 中没有数据,getItem 返回 nullJSON.parse(null) 会报错吗?不会,它返回 null,但为了安全,通常使用 || [] 确保 items 始终是一个数组。
第二步:首次渲染(页面加载即展示)
populateList(items, itemsList);
  • 专业体现:很多新手会忘记这一步,导致页面加载时列表为空,直到用户添加第一项后才显示。正确的做法是:数据加载后,立即渲染一次初始状态。
第三步:事件监听注册(等待用户交互)
addItems.addEventListener('submit', addItem);
itemsList.addEventListener('click', toggleDone);
  • 事件委托(Event Delegation):注意 toggleDone 是绑定在 itemsList<ul>)上,而不是每个 <li><input> 上。
    • 优势:无论列表中有多少项,只占用一个事件监听器,内存效率极高。
    • 动态支持:后续通过 JS 动态添加的 <li> 自动拥有点击响应能力,无需重新绑定事件。
第四步:用户交互循环(添加/修改 -> 持久化 -> 重绘)

当用户操作时,进入以下闭环:

  1. 修改内存items.push()items[index].done = !...
  2. 持久化存储localStorage.setItem('todos', JSON.stringify(items))关键点:每次数据变动都必须立即同步到 localStorage,防止刷新丢失。
  3. 视图更新:调用 populateList() 重新生成整个列表 HTML。

2. 代码专业化与封装的体现

这段代码虽然简短,但体现了几个重要的工程化思想:

  • 函数式封装(Functional Encapsulation)
    • 代码没有写成流水账,而是将“渲染列表”、“添加项目”、“切换状态”拆分为独立的函数 (populateList, addItem, toggleDone)。
    • 好处:逻辑清晰,便于单元测试,也方便后续复用(比如将来要把这个列表组件用到其他地方)。
  • 单一职责原则(SRP)
    • addItem 只负责处理表单提交和数据新增。
    • toggleDone 只负责处理点击逻辑和状态翻转。
    • populateList 只负责将数据转换为 HTML 字符串。
  • 防御性编程
    • event.preventDefault():阻止表单默认提交刷新页面,这是单页应用(SPA)的基础。
    • .trim():去除用户输入的首尾空格,避免脏数据。
    • || []:防止因 localStorage 为空或格式错误导致程序崩溃。

🎨 第二部分:CSS 深度解析——继承、轮廓与溢出

在构建了功能之后,样式决定了用户体验。我们结合提供的 CSS 代码和 MDN 文档,深入三个核心概念。

1. CSS 继承(Inheritance):哪些会自动传下去?

在提供的 HTML 示例中:

<div style="...; font-size:28px; color:pink;">你好
    <p style="background-color:red; height:inherit;">大唐诡事录</p>
</div>
  • 现象<p> 标签虽然没有设置 font-sizecolor,但它会显示为 28px粉色。这就是继承
  • MDN 权威定义
    • 可继承属性:通常是与文本排版相关的属性。例如:color, font-size, font-family, line-height, text-align, visibility 等。
    • 不可继承属性:通常是与盒模型布局相关的属性。例如:background, border, width, height, margin, padding, overflow 等。
  • inherit 关键字的作用
    • 对于默认不继承的属性(如 background-color),如果子元素显式设置 background-color: inherit,它可以强制继承父元素的背景色。
    • 在示例中,height: inherit<p> 强制继承了父级 div300px 高度(尽管 <p> 默认高度由内容决定)。

💡 避坑指南:不要指望 backgroundborder 会自动继承。如果需要子元素拥有相同的背景,必须显式编写样式或使用 inherit

2. Outline vs Border:看似相同,本质不同

在待办清单的 CSS 中,输入框使用了 outline

.add-items input {
    outline: 5px solid rgba(14, 14, 211, 0.8);
    border: 1px solid rgba(0,0,0,0.1);
}

为什么同时存在?它们有什么区别?

特性 Border (边框) Outline (轮廓)
盒模型地位 属于盒模型的一部分。 不属于盒模型,绘制在元素外部。
空间占用 占据空间。增加 border 会增加元素总宽高,可能推挤周围元素。 不占据空间。无论多粗,都不会影响布局,不会推挤邻居。
形状支持 支持圆角 (border-radius)。 不支持圆角,始终是矩形框。
单边控制 支持 (border-top, border-left 等)。 不支持,必须四边同时设置。
主要用途 布局装饰、分割线。 焦点提示(浏览器默认聚焦效果)、调试高亮。
  • 代码解读
    • border: 1px ...:作为输入框的结构边框,占据微小空间,保持布局稳定。
    • outline: 5px ...:作为聚焦时的高亮效果。因为 outline 不占空间,当用户点击输入框时,蓝色的光晕浮现,不会导致页面其他元素发生位移(Layout Shift),这对于用户体验至关重要。

⚠️ 注意outline 可能会溢出父容器的 overflow: hidden 区域,因为它绘制在盒模型之外。

3. Overflow:管住越界的子元素

在示例代码中:

<div style="overflow:hidden; ...">
    <!-- 子元素内容超出时会被裁剪 -->
</div>
  • MDN 核心概念overflow 属性定义了当内容溢出其块级容器时的行为。
  • 常用值解析
    • visible(默认):内容溢出部分可见,可能会覆盖其他元素。
    • hidden:溢出部分被裁剪,不可见,且不显示滚动条。常用于创建固定大小的卡片或隐藏多余文字。
    • scroll始终显示滚动条(即使内容没溢出)。
    • auto:仅在内容溢出时自动显示滚动条。
  • 实际应用场景
    • 在待办清单中,如果列表项非常多,我们可能会给 .plates 设置 max-heightoverflow-y: auto,这样列表就可以内部滚动,而不会让整个页面变长。
    • 示例中的 overflow: hidden 配合 height: 300px,确保了无论 <p> 里的文字多少,父容器高度固定,多余文字直接隐藏,保持页面整洁。

🚀 总结与进阶建议

通过今天的实战与理论结合,我们不仅完成了一个功能完备的 Todo List,还打通了 CSS 的几个任督二脉:

  1. 数据流闭环:内存操作 -> LocalStorage 持久化 -> 视图重绘,这是前端状态管理的雏形。
  2. 封装思维:将功能拆分为独立函数,是迈向组件化开发(如 Vue/React)的第一步。
  3. CSS 细节掌控
    • 利用继承减少重复代码(如字体颜色)。
    • 利用 outline 做无侵入的交互反馈。
    • 利用 overflow 控制布局边界。

🔥 下一步挑战

  • 尝试为 Todo List 添加“删除”功能(提示:需要给删除按钮绑定事件,并在 items 数组中使用 splice 方法)。
  • 尝试使用 classList.toggle 代替重新渲染整个列表,优化性能。
  • 研究 CSS 变量(Custom Properties),将颜色提取出来统一管理。

前端开发不仅是写代码,更是对细节的极致追求。希望这篇文章能助你在掘金之路上更进一步!


参考文档:MDN Web Docs - CSS Inheritance, CSS Box Model (outline), CSS Overflow

Elpis 框架 npm 包抽离思路

作者 飞雪飘摇
2026年2月27日 21:29

前言

最近完成了 elpis 的 npm 包抽离工作,说实话,把框架从业务代码里抽出来做成 npm 包,听起来好像挺简单的,但真正动手的时候才发现,路径问题、加载顺序、构建配置...每一个都是坑。

架构设计

抽离后的结构:

elpis (框架包)              elpis-demo (业务项目)
├── elpis-core/            ├── app/
├── app/                   │   ├── controller/
└── index.js               │   └── router/
                           └── server.js

框架提供基础能力,业务项目通过 npm 依赖使用。

核心难点

1. 路径解析问题

这是最大的坑。框架需要同时加载两个位置的文件:

  • 框架自身的文件(在 node_modules 中)
  • 业务项目的文件(在项目根目录)
module.exports = {
  start(options = {}) {
    const app = new Koa();
    
    // 业务项目根目录
    app.baseDir = process.cwd();
    
    // 业务代码目录
    app.businessPath = path.resolve(app.baseDir, `./app`);
    
    return app;
  }
}

关键点:

  • process.cwd() 定位业务项目
  • __dirname 定位框架内部文件
  • 统一使用 path.sep 处理跨平台路径

2. 动态加载器的优先级

框架和业务都有 Controller、Service,如何处理?

采用"框架先行,业务覆盖"策略:

module.exports = (app) => {
  const controller = {};

  // 1. 先加载框架的
  const elpisFileList = glob.sync(path.resolve(__dirname, `../../app/controller/**/*.js`));
  elpisFileList.forEach(file => handleFile(file));

  // 2. 再加载业务的(会覆盖同名)
  const businessFileList = glob.sync(path.resolve(app.businessPath, `./controller/**/*.js`));
  businessFileList.forEach(file => handleFile(file));

  app.controller = controller;
};

3. Webpack 构建配置

需要同时扫描框架和业务的入口文件:

// 扫描框架入口
const elpisEntryList = glob.sync(
  path.resolve(__dirname, '../../pages/**/entry.*.js')
);

// 扫描业务入口
const businessEntryList = glob.sync(
  path.resolve(process.cwd(), './app/pages/**/entry.*.js')
);

module.exports = {
  entry: Object.assign({}, elpisPageEntries, businessPageEntries),
  
  module: {
    rules: [{
      test: /\.js$/,
      include: [
        path.resolve(__dirname, '../../pages'),
        path.resolve(process.cwd(), './app/pages'),
      ],
      use: { loader: require.resolve('babel-loader') }
    }]
  },
  
  resolve: {
    alias: {
      '$elpisWidgets': path.resolve(__dirname, '../../pages/widgets'),
      // 业务扩展配置(不存在则指向空模块)
      '$businessConfig': fs.existsSync(businessConfigPath)
        ? businessConfigPath
        : path.resolve(__dirname, '../libs/blank.js')
    }
  }
};

关键点:

  • 使用 require.resolve 确保 loader 路径正确
  • 通过 alias 提供统一引用路径
  • 业务扩展不存在时指向空模块

4. 中间件注册顺序

// 先注册框架中间件
const elpisMiddleware = require(path.resolve(__dirname, `../app/middleware.js`));
elpisMiddleware(app);

// 再注册业务中间件(可选)
try {
  require(`${app.businessPath}/middleware.js`)(app);
} catch (error) {
  console.log('no global businessMiddleware file');
}

5. 对外 API 设计

保持简洁:

// elpis/index.js
module.exports = {
  Controller: {
    Base: require('./app/controller/base.js')
  },
  Service: {
    Base: require('./app/service/base.js')
  },
  frontEndBuild(env) {
    if (env === 'local') FEBuildDev();
    else if (env === 'production') FEBuildProd();
  },
  serverStart(options = {}) {
    return ElpisCore.start(options);
  }
};

业务项目使用:

// server.js
const { serverStart } = require('@gordonlzg/elpis');
const app = serverStart({ name: 'ElpisDemo' });

// build.js
const { frontEndBuild } = require('@gordonlzg/elpis');
frontEndBuild(process.env._ENV);

// controller/business.js
const { Controller } = require('@gordonlzg/elpis');
module.exports = (app) => {
  return class BusinessController extends Controller.Base {
    async list(ctx) { /* ... */ }
  }
}

踩坑点

1. require.resolve 的妙用

// ❌ 错误:业务项目找不到
use: { loader: 'vue-loader' }

// ✅ 正确:从框架包中解析
use: { loader: require.resolve('vue-loader') }

2. glob 路径处理

// ❌ 错误:Windows 上会失败
glob.sync('./app/**/*.js')

// ✅ 正确:使用 path.resolve
glob.sync(path.resolve(app.businessPath, `./**/*.js`))

3. 文件不存在的处理

// ❌ 直接 require 会报错
const config = require(`${app.businessPath}/config.js`);

// ✅ 先判断是否存在
if (fs.existsSync(configPath)) {
  const config = require(configPath);
}

4. Webpack alias 空模块技巧

alias: {
  '$businessConfig': fs.existsSync(businessConfigPath)
    ? businessConfigPath
    : path.resolve(__dirname, '../libs/blank.js')  // 空模块
}

// blank.js
module.exports = {};

总结

抽离 npm 包的核心难点:

  1. 路径解析 - 正确处理框架和业务的路径关系
  2. 加载顺序 - 框架先行,业务覆盖
  3. 构建配置 - Webpack 的路径和 loader 处理
  4. API 设计 - 简洁易用

React中类似于Vue中Pinia的轻量级状态管理神器——Zustand

作者 zhqiok
2026年2月27日 20:24

Zustand 是一个轻量级、简洁的React状态管理库,核心特点是无样板代码、hooks风格、不依赖Context。用法很像Vue生态中的Pinia,以下是详细使用指南:

一、基础使用(计数器示例)

1. 创建store

// store/counterStore.ts
import { create } from 'zustand'

interface CounterState {
  count: number
  increment: () => void
  decrement: () => void
  reset: () => void
}

const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}))

2. 组件中使用

// Counter.tsx
import useCounterStore from './store/counterStore'

const Counter = () => {
  // 自动订阅状态变化 - 当count变化时组件重渲染
  const { count, increment, decrement } = useCounterStore()
  
  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  )
}

// 兄弟组件同步更新
const Display = () => {
  const count = useCounterStore((state) => state.count) // 选择性订阅
  
  return <div>Current count: {count}</div>
}

二、进阶特性

1. 异步操作

// store/userStore.ts
interface UserState {
  user: User | null
  loading: boolean
  fetchUser: (id: string) => Promise<void>
}

const useUserStore = create<UserState>((set) => ({
  user: null,
  loading: false,
  
  fetchUser: async (id: string) => {
    set({ loading: true })
    try {
      const response = await fetch(`/api/users/${id}`)
      const user = await response.json()
      set({ user, loading: false })
    } catch (error) {
      set({ loading: false })
      throw error
    }
  },
}))

2. 深层嵌套状态更新

// 使用immer简化嵌套更新(需安装immer)
import { produce } from 'immer'

const useTodoStore = create<{
  todos: Todo[]
  addTodo: (text: string) => void
  toggleTodo: (id: number) => void
}>((set) => ({
  todos: [],
  addTodo: (text) => set(produce((state) => {
    state.todos.push({ id: Date.now(), text, completed: false })
  })),
  toggleTodo: (id) => set(produce((state) => {
    const todo = state.todos.find(t => t.id === id)
    if (todo) todo.completed = !todo.completed
  })),
}))

3. 性能优化:选择性订阅

// ✅ 只订阅需要的状态,避免不必要的重渲染
const TodoList = () => {
  const todos = useTodoStore((state) => state.todos) // 仅当todos变化时重渲染
  
  const addTodo = useTodoStore((state) => state.addTodo) // action不会触发重渲染
  
  return (
    <div>
      {todos.map(todo => <TodoItem key={todo.id} />)}
      <AddTodo onAdd={addTodo} />
    </div>
  )
}

// TodoItem组件 - 独立订阅
const TodoItem = ({ id }) => {
  const todo = useTodoStore(
    (state) => state.todos.find(t => t.id === id)
  )
  const toggleTodo = useTodoStore((state) => state.toggleTodo)
  
  // 每个TodoItem只在自己的todo变化时重渲染
  return <li onClick={() => toggleTodo(id)}>{todo.text}</li>
}

4. 中间件使用

// 持久化存储(需安装zustand/middleware)
import { persist, createJSONStorage } from 'zustand/middleware'

const useAuthStore = create(
  persist(
    (set, get) => ({
      token: null,
      user: null,
      login: (credentials) => { /* ... */ },
      logout: () => set({ token: null, user: null }),
    }),
    {
      name: 'auth-storage', // localStorage key
      storage: createJSONStorage(() => localStorage),
    }
  )
)

// 开发工具中间件
import { devtools } from 'zustand/middleware'

const useStore = create(
  devtools(
    (set) => ({
      /* state & actions */
    }),
    { name: 'MyStore' } // Redux DevTools中的显示名称
  )
)

三、最佳实践

1. 拆分store(按领域划分)

store/
├── authStore.ts      # 认证相关
├── cartStore.ts      # 购物车
├── uiStore.ts        # UI状态(主题、弹窗)
└── productStore.ts   # 商品数据

2. TypeScript完整示例

// store/authStore.ts
interface User {
  id: string
  name: string
  email: string
}

interface AuthState {
  user: User | null
  isAuthenticated: boolean
  login: (email: string, password: string) => Promise<void>
  logout: () => void
  initialize: () => Promise<void>
}

export const useAuthStore = create<AuthState>((set, get) => ({
  user: null,
  isAuthenticated: false,
  
  login: async (email: string, password: string) => {
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify({ email, password }),
    })
    
    const user = await response.json()
    set({ user, isAuthenticated: true })
  },
  
  logout: () => {
    localStorage.removeItem('token')
    set({ user: null, isAuthenticated: false })
  },
  
  initialize: async () => {
    const token = localStorage.getItem('token')
    if (token) {
      // 验证token并获取用户信息
      const user = await fetchCurrentUser()
      set({ user, isAuthenticated: true })
    }
  },
}))

3. 在类组件中使用(兼容方案)

// withStore HOC
import { useCounterStore } from './store/counterStore'

const withCounterStore = (Component) => {
  return (props) => {
    const store = useCounterStore()
    return <Component {...props} store={store} />
  }
}

class LegacyComponent extends React.Component {
  render() {
    const { store } = this.props
    return <div>Count: {store.count}</div>
  }
}

export default withCounterStore(LegacyComponent)

四、对比其他方案

特性 Zustand Redux Toolkit Context
包大小 ~1KB ~8KB 内置
学习曲线 极低 中等
样板代码 极少 中等
性能 自动优化 手动优化 需要Memo
DevTools 插件支持 内置完整
异步 原生支持 RTK Query 需手动处理

五、与Pinia的开发体验对比

方面 Zustand Pinia 评价
学习成本 极低 Zustand更简单
代码简洁度 极高 Zustand更函数式
Vue集成 不适用 完美 Pinia是Vue生态一部分
React集成 原生hooks 不适用 Zustand是React专用
调试体验 需要中间件 开箱即用 Pinia胜出
社区生态 增长快 Vue官方 Pinia更稳定

六、快速开始

# 安装
npm install zustand

# 可选中间件
npm install immer @types/immer  # 不可变更新
npm install zustand/middleware  # 官方中间件

总结:Zustand通过极简的API提供了完整的全局状态管理能力,适合大多数React项目。其核心优势是零样板、直观、高性能,避免了Redux的复杂性和Context的性能陷阱。

React Compiler 来了:少写 useMemo,照样稳

作者 兆子龙
2026年2月27日 19:14

React Compiler 来了:少写 useMemo,照样稳

编译期自动分析依赖、帮你做 memoization,从此不用再纠结「这段要不要包 useMemo」——用愉悦分享的语气,带你认识 React 官方的这份新礼物。


一、先说说我们都在纠结啥

写 React 的时候,咱们多少都经历过这种灵魂拷问:这个算出来的值要不要包一层 useMemo?这个回调要不要 useCallback?这个子组件要不要 React.memo? 不包怕重渲染,包多了又觉得代码啰嗦,还容易依赖数组写错。

有没有一种可能——我们只管写逻辑,谁来帮我们自动做「该记的记一下」? 有,这就是 React Compiler 想做的事。


二、React Compiler 是什么

React Compiler(以前叫 React Forget)是 React 官方出的一个 编译期插件:在构建时分析你的组件代码,搞清楚「谁依赖谁」,然后在需要的地方自动插入 memoization,相当于帮你自动加 useMemo / useCallback / React.memo,而不是你自己一个个手写。

  • 是什么:Babel 插件(或与 Vite / Next 等集成),跑在构建阶段。
  • 解决啥:减少手写 useMemo/useCallback/React.memo 的心智负担,同时尽量保持「只在该变的时候重算、重渲染」。
  • 和手写区别:你写的是「普通」React 代码,编译器在背后做优化;依赖分析由工具完成,更一致、也少踩依赖数组的坑。

一句话:写得更爽,性能交给编译器。


三、它有什么用、适合谁

  • 典型场景:中大型列表、表单、多状态联动组件,以前你要反复想「这里要不要 memo」的地方,可以优先交给编译器试一试。
  • 适用人群:用 React 17/18/19 的团队,尤其是已经在用 Vite、Next.js、Babel 的;想统一优化策略、减少 useMemo 样板代码的。
  • 你能得到:更少的样板代码、更少的依赖数组 bug、以及(在编译器覆盖到的路径上)更可预期的重渲染行为。注意:它不会取代所有手写优化,但在很多场景下能覆盖大部分需求。

四、官方链接(方便你溯源)

建议先看官方 Learn 里的介绍,再看 Installation,按你的构建工具选一条路即可。


五、从零跑起来(以 Vite + React 为例)

环境要求

  • Node 18+
  • React 17 / 18 / 19 均可(React 19 体验最佳)
  • 已有 Vite + React 项目(或 npm create vite@latest 选 React)

安装

pnpm add -D babel-plugin-react-compiler
# 若 React 版本 < 19,再装运行时
pnpm add react-compiler-runtime

Vite 里怎么开

@vitejs/plugin-react 时,在 vite.config.ts 里打开 compiler 开关即可:

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
    plugins: [
        react({
            babel: {
                plugins: [['babel-plugin-react-compiler', {}]],
            },
        }),
    ],
});

保存后重新跑 pnpm dev,编译器就会参与构建。注意:Babel 里若有其他插件,官方建议把 React Compiler 放在第一个,这样它能拿到更完整的源码信息做分析。

用 Next.js 的话

Next.js 15+ 已内置支持,在 next.config.js 里开:

// next.config.js
const nextConfig = {
    experimental: {
        reactCompiler: true,
    },
};

六、写一小段「不写 useMemo」的代码试试

下面是一段没写任何 useMemo / useCallback 的组件——在开启 React Compiler 后,编译器会在编译期分析依赖,并在需要时自动做 memoization,你可以对比开启前后的重渲染行为(例如用 React DevTools 的 Profiler)。

function TodoList({ todos, onToggle }) {
    const [filter, setFilter] = useState('all');
    const filtered = todos.filter((t) =>
        filter === 'done' ? t.done : filter === 'pending' ? !t.done : true
    );
    return (
        <div>
            <select value={filter} onChange={(e) => setFilter(e.target.value)}>
                <option value="all">全部</option>
                <option value="done">已完成</option>
                <option value="pending">未完成</option>
            </select>
            <ul>
                {filtered.map((t) => (
                    <li key={t.id} onClick={() => onToggle(t.id)}>
                        {t.title}
                    </li>
                ))}
            </ul>
        </div>
    );
}

以前我们可能会给 filtered 包一层 useMemo、给 onToggle 包 useCallback;在开启 React Compiler 之后,这类简单依赖可以交给编译器处理,你先专注把逻辑写清楚就行。


七、一点注意与小结

  • 兼容性:推荐 React 19;若用 17/18,需安装 react-compiler-runtime 并按官方文档配置。
  • 渐进使用:可以先在部分页面或分支开启,观察构建与运行是否稳定,再逐步铺开。
  • 不是银弹:极端性能敏感、或已有精细手写 memo 的地方,可以保留;编译器是「默认帮你省心」,而不是禁止你手写。

小结几句:React Compiler 用「编译期分析依赖 + 自动 memoization」的方式,让我们少写 useMemo/useCallback/React.memo,代码更干净、心智负担更小。如果你一直在纠结「这段要不要包 useMemo」,不妨在项目里开一次 React Compiler,用愉悦的心态试一把——说不定你会喜欢上这种「写逻辑、优化交给编译器」的感觉。

如果这篇对你有帮助,欢迎点赞 / 收藏;你有在项目里用过 React Compiler 吗?欢迎在评论区聊聊你的体验。


标签建议React前端性能优化ViteReact Compiler

深度拆解 Grass 模式:基于 EIP-712 与 DePIN 架构的奖励分发系统实现

作者 木西
2026年2月27日 18:47

前言

2026 年的 Web3 赛道中,以 GrassDawn 为代表的 DePIN(去中心化物理基础设施网络)项目,开创了 “带宽即挖矿” 的全新范式。这类项目的核心技术难点,并非数据采集本身,而是如何安全、低成本地将链下贡献转化为链上代币奖励。本文将从架构设计到智能合约实现,完整还原一套工业级的 Grass 奖励分发系统。特此声明:本文不构成任何项目推荐与投资建议,仅对行业主流模式与核心运行逻辑做技术拆解与原理分析。

一、Grass概述

1. 项目本质

Solana 链上 DePIN+AI 项目,核心是用户共享家庭闲置带宽,为 AI 企业提供分布式、合规的数据采集服务,用户以带宽贡献获取 $GRASS 代币奖励

2. 核心亮点

  • 商业模式清晰:98% 收入来自 AI 模型训练数据,客户群体明确

  • 资方优质:总融资 450 万美元,种子轮由 Polychain Capital、Tribe Capital 领投

  • 技术可靠:采用 zk-SNARK 技术,保障数据真实性与可追溯性

3. 关键风险

  • 合规风险:严打多账号刷量、非住宅 IP 违规操作,违规会取消奖励资格

  • 行业依赖风险:收入高度依赖 AI 训练数据市场,行业波动影响生态收益

  • 代币与参与风险:$GRASS 价格受市场波动影响,空投、解锁规则以官方公告为准;节点需稳定在线,产生电费成本

二、 核心架构:链下计算,链上验证

Grass 的运作并非全过程上链,其架构分为三个关键层级:

  1. 感知层 (链下) :用户运行浏览器插件,贡献闲置带宽。
  2. 验证层 (后端) :项目方服务器(或验证节点)统计用户贡献,将其转化为累计积分(Total Earned)。
  3. 结算层 (合约) :用户发起提现请求,后端生成 EIP-712 签名凭证,合约验证签名并拨付代币。

三、 工业级合约实现 (Solidity 0.8.24)

基于 OpenZeppelin V5,我们构建了具备防重放攻击角色权限控制结构化签名验证的核心合约。

代币合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MockToken is ERC20 {
    constructor(string memory name, string memory symbol) ERC20(name, symbol) {
        // 初始给部署者薄利 100 万个,方便测试
        _mint(msg.sender, 1000000 * 10**decimals());
    }
}

GrassDistributor合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract GrassDistributor is EIP712, AccessControl {
    using ECDSA for bytes32;
    using MessageHashUtils for bytes32;

    bytes32 public constant VERIFIER_ROLE = keccak256("VERIFIER_ROLE");
    // EIP-712 结构化类型哈希
    bytes32 private constant CLAIM_TYPEHASH = 
        keccak256("ClaimReward(address user,uint256 totalEarned,uint256 nonce)");

    IERC20 public immutable rewardToken;
    mapping(address => uint256) public claimedAmount;
    mapping(address => uint256) public nonces;

    event RewardClaimed(address indexed user, uint256 amount, uint256 nonce);

    constructor(address _token, address _initialVerifier) 
        EIP712("GrassNetwork", "1") 
    {
        rewardToken = IERC20(_token);
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(VERIFIER_ROLE, _initialVerifier);
    }

    /**
     * @notice 领取累计奖励
     * @param totalEarned 后端验证的该用户历史总赚取量
     * @param signature 后端 Verifier 的 EIP-712 签名
     */
    function claim(uint256 totalEarned, bytes calldata signature) external {
        uint256 currentNonce = nonces[msg.sender];
        uint256 alreadyClaimed = claimedAmount[msg.sender];
        uint256 amountToClaim = totalEarned - alreadyClaimed;

        require(amountToClaim > 0, "Grass: No rewards to claim");

        // 1. 构建 EIP-712 结构化哈希
        bytes32 structHash = keccak256(
            abi.encode(CLAIM_TYPEHASH, msg.sender, totalEarned, currentNonce)
        );
        bytes32 hash = _hashTypedDataV4(structHash);

        // 2. 验证签名者是否有 VERIFIER_ROLE 权限
        address signer = hash.recover(signature);
        require(hasRole(VERIFIER_ROLE, signer), "Grass: Invalid verifier signature");

        // 3. 更新状态(先更新后转账,防重入)
        nonces[msg.sender] = currentNonce + 1;
        claimedAmount[msg.sender] = totalEarned;

        // 4. 转账
        require(rewardToken.transfer(msg.sender, amountToClaim), "Grass: Transfer failed");

        emit RewardClaimed(msg.sender, amountToClaim, currentNonce);
    }
}


四、 自动化签名分发 (后端逻辑)

基于 Viem 的后端签名实现 (signReward.ts)

import { createWalletClient, http, type Address } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { arbitrum } from 'viem/chains';

/**
 * Grass 自动化签名分发服务 (Viem 版)
 * @param userAddress 领奖用户地址
 * @param totalEarned 数据库中的累计积分 (单位: wei)
 * @param currentNonce 合约中该用户的最新 Nonce
 */
async function generateViemSignature(
  userAddress: Address, 
  totalEarned: bigint, 
  currentNonce: bigint
) {
  // 1. 初始化 Verifier 账户 (从环境变量获取私钥)
  const privateKey = process.env.VERIFIER_PRIVATE_KEY as `0x${string}`;
  const account = privateKeyToAccount(privateKey);

  // 2. 创建 Wallet Client (仅用于签名,无需连接真实节点)
  const client = createWalletClient({
    account,
    chain: arbitrum,
    transport: http()
  });

  // 3. 定义 EIP-712 结构 (必须与 Solidity 合约完全一致)
  const domain = {
    name: 'GrassNetwork',
    version: '1',
    chainId: 42161, // Arbitrum One
    verifyingContract: '0xYourContractAddress' as Address,
  } as const;

  const types = {
    ClaimReward: [
      { name: 'user', type: 'address' },
      { name: 'totalEarned', type: 'uint256' },
      { name: 'nonce', type: 'uint256' },
    ],
  } as const;

  // 4. 执行签名
  const signature = await client.signTypedData({
    domain,
    types,
    primaryType: 'ClaimReward',
    message: {
      user: userAddress,
      totalEarned: totalEarned,
      nonce: currentNonce,
    },
  });

  return {
    signature,
    user: userAddress,
    totalEarned: totalEarned.toString(),
    nonce: Number(currentNonce)
  };
}


五、 自动化测试 (Viem + Node:Assert)

测试用例

  1. 正常领取:验证 totalEarned 模式下余额增量是否正确。
  2. 防重放校验:确保旧签名(Nonce 0)在 Nonce 已变为 1 时无法再次被合约接受。
  3. 权限校验:普通用户伪造签名应被 VERIFIER_ROLE 机制拦截。
import assert from "node:assert/strict";
import { describe, it, beforeEach } from "node:test";
import { network } from "hardhat"; 
import { parseEther, type Address } from "viem";

describe("Grass DePIN 奖励系统全流程测试", function () {
    let token: any, distributor: any;
    let admin: any, user: any, verifier: any;
    let vClient: any, pClient: any;

    beforeEach(async function () {
        // 连接本地环境
        const { viem } = await (network as any).connect();
        vClient = viem;
        [admin, user, verifier] = await vClient.getWalletClients();
        pClient = await vClient.getPublicClient();

        // 1. 部署模拟 ERC20 (假设已存在 MockToken)
        token = await vClient.deployContract("MockToken", ["Grass Token", "GRASS"]);
        
        // 2. 部署分配器,初始化 verifier 角色
        distributor = await vClient.deployContract("GrassDistributor", [
            token.address, 
            verifier.account.address
        ]);

        // 3. 给分配器合约注入 10000 个代币作为奖池
        await token.write.transfer([distributor.address, parseEther("10000")]);
    });

    it("用例 1: 验证 EIP-712 签名并成功领取奖励", async function () {
        const totalEarned = parseEther("150"); // 后端统计该用户总共赚了 150
        const nonce = 0n;
        const chainId = await pClient.getChainId();

        // --- 模拟后端签名逻辑 ---
        const domain = {
            name: 'GrassNetwork',
            version: '1',
            chainId,
            verifyingContract: distributor.address as Address,
        } as const;

        const types = {
            ClaimReward: [
                { name: 'user', type: 'address' },
                { name: 'totalEarned', type: 'uint256' },
                { name: 'nonce', type: 'uint256' },
            ],
        } as const;

        // 使用 verifier 私钥签名
        const signature = await verifier.signTypedData({
            domain,
            types,
            primaryType: 'ClaimReward',
            message: {
                user: user.account.address,
                totalEarned,
                nonce,
            },
        });

        // --- 前端发起领取 ---
        const txHash = await distributor.write.claim([totalEarned, signature], { 
            account: user.account 
        });
        await pClient.waitForTransactionReceipt({ hash: txHash });

        // --- 断言校验 ---
        const userBalance = await token.read.balanceOf([user.account.address]);
        assert.strictEqual(userBalance, parseEther("150"), "用户应收到 150 个代币");
        
        const nextNonce = await distributor.read.nonces([user.account.address]);
        assert.strictEqual(nextNonce, 1n, "Nonce 应该自增");
    });
    
    it("用例 2: 防重放测试 (尝试使用旧签名再次领取)", async function () {
    const totalEarned = parseEther("150");
    const nonce = 0n;
    const chainId = await pClient.getChainId();

    const domain = { name: 'GrassNetwork', version: '1', chainId, verifyingContract: distributor.address };
    const types = { ClaimReward: [{ name: 'user', type: 'address' }, { name: 'totalEarned', type: 'uint256' }, { name: 'nonce', type: 'uint256' }] };
    
    // 1. 第一次正常领取
    const signature = await verifier.signTypedData({
        domain,
        types,
        primaryType: 'ClaimReward',
        message: { user: user.account.address, totalEarned, nonce }
    });
    
    const tx1 = await distributor.write.claim([totalEarned, signature], { account: user.account });
    await pClient.waitForTransactionReceipt({ hash: tx1 });

    // 2. 第二次尝试使用完全相同的签名再次领取
    // 注意:此时合约内的 nonces[user] 已经是 1 了,但签名里的 nonce 还是 0
    try {
        await distributor.write.claim([totalEarned, signature], { account: user.account });
        // 如果运行到这里,说明没有报错,测试应该失败
        assert.fail("应该因为 Nonce 不匹配而回滚");
    } catch (e: any) {
        // 打印错误看看具体信息,有助于调试
        // console.log(e.message); 
        
        // 修改断言:只要捕获到错误即代表拦截成功
        // 或者匹配具体的错误字符串 "Grass: Invalid nonce"
        assert.ok(true, "成功拦截了重放攻击");
    }
});

    it("用例 3: 权限测试 (非法签名者签名)", async function () {
        const totalEarned = parseEther("50");
        // 使用普通用户 user 代替 verifier 进行签名
        const signature = await user.signTypedData({
            domain: { name: 'GrassNetwork', version: '1', chainId: await pClient.getChainId(), verifyingContract: distributor.address },
            types: { ClaimReward: [{ name: 'user', type: 'address' }, { name: 'totalEarned', type: 'uint256' }, { name: 'nonce', type: 'uint256' }] },
            primaryType: 'ClaimReward',
            message: { user: user.account.address, totalEarned, nonce: 0n }
        });

        try {
            await distributor.write.claim([totalEarned, signature], { account: user.account });
            assert.fail("非 Verifier 签名应被拦截");
        } catch (e: any) {
            assert.ok(e.message.includes("Grass: Invalid verifier signature"));
        }
    });
});

六、部署脚本

// scripts/deploy.ts
import { network, artifacts } from "hardhat";
import {parseEther} from "viem"
async function main() {
   // 连接网络
  const { viem } = await network.connect({ network: network.name });//指定网络进行链接
  // 获取客户端
  const [deployer] = await viem.getWalletClients();
  const publicClient = await viem.getPublicClient();
  // 部署代币
  const MockTokenRegistry = await artifacts.readArtifact("MockToken");

  const MockTokenRegistryHash = await deployer.deployContract({
    abi: MockTokenRegistry.abi,//获取abi
    bytecode: MockTokenRegistry.bytecode,//硬编码
    args: ["Grass Token", "GRASS"],//部署者地址作为初始治理者
  });
  // 等待确认并打印治理代币地址
  const MockTokenRegistryReceipt = await publicClient.getTransactionReceipt({ hash: MockTokenRegistryHash });
  console.log("代币合约地址:", MockTokenRegistryReceipt.contractAddress);


  // 部署时间锁合约
  const GrassDistributorRegistry  = await  artifacts.readArtifact("GrassDistributor");

  const GrassDistributorHash = await deployer.deployContract({
    abi: GrassDistributorRegistry.abi,//获取abi
    bytecode: GrassDistributorRegistry.bytecode,//硬编码
    args: [MockTokenRegistryReceipt.contractAddress,deployer.account.address],//
  });
    // 等待确认并打印地址
  const GrassDistributorReceipt = await publicClient.getTransactionReceipt({ hash: GrassDistributorHash });
  console.log("GrassDistributor合约地址:", await GrassDistributorReceipt.contractAddress);
 
  
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

七、2026 年的技术演进思考

  • Gas 优化:由于使用了 Solidity 0.8.24,项目方可以结合 Transient Storage (TSTORE)  在批量分发(Airdrop)时极大降低成本。
  • 隐私增强:未来的 Grass 可能引入 zk-SNARKs。用户证明自己贡献了带宽,而无需向项目方暴露具体的抓取内容,合约只需验证 ZK Proof 即可发放奖励。
  • 多链结算:通过 Chainlink CCIP 等跨链协议,实现用户在 Arbitrum 挖矿,但在 Base 链领取奖励。

结语

Grass 的成功不仅在于其“零成本”的营销,更在于其背后这套成熟的、基于 EIP-712 的“链下计算+链上结算”技术栈。对于开发者而言,掌握这套架构是进入 DePIN 和 RWA 赛道的入场券。

浏览器模块加载与 Webpack 打包原理

作者 charmson
2026年2月27日 18:41

一、原生 ESM:浏览器如何加载模块

1.1 三阶段流程

浏览器处理 ESM 严格按照三个阶段顺序执行:

① 构建(Construction)   → 静态分析依赖,下载所有模块,构建模块依赖图
② 实例化(Linking)      → 为所有导出变量分配内存空间(此时无值)
③ 求值(Evaluation)     → 按依赖顺序执行模块代码,填入真实值

关键特性:

  • 遇到 <script type="module"> 等同于 defer,不阻塞 HTML 解析
  • 同一模块只执行一次,多次 import 返回同一实例(幂等性)
  • 模块下载并行,但执行顺序遵循依赖拓扑排序

1.2 Live Binding(动态绑定)

ESM 的 import 不是值拷贝,而是对原始变量内存地址的引用绑定

// counter.js
export let count = 0;
export function increment() { count++; }

// main.js
import { count, increment } from './counter.js';
increment();
console.log(count); // 1,而不是 0

count 始终指向 counter.js 中那块内存,值变化会实时反映。

1.3 浏览器网络层细节

  • 每个模块对应一个独立 HTTP 请求
  • 响应头必须是合法的 JS MIME 类型(application/javascript
  • 跨域模块受 CORS 限制,需服务器配置 Access-Control-Allow-Origin
  • 相同 URL 的模块全局只加载一次(浏览器 Module Map 缓存)

二、静态 vs 动态:import 的两种形态

理解这个区别是理解整个模块系统的核心。

2.1 什么是"编译时"与"运行时"

浏览器没有离线的编译阶段,这里的区分是相对时序

概念 含义
"编译时" / 静态 代码执行之前,JS 引擎解析 AST 阶段
"运行时" / 动态 模块顶层代码真正开始执行之后

2.2 静态 import 语句

// ✅ 合法:路径是字面量,解析 AST 时就能确定
import { foo } from './foo.js'

// ❌ 非法:路径是表达式,解析阶段无法求值
import { foo } from './' + name + '.js'

import 语句在解析 AST 时就被提取,此时代码尚未执行,所以路径必须是静态字符串。这保证了浏览器能在执行任何代码前构建完整的依赖图。

2.3 动态 import() 函数

// 执行到这一行时,才发起请求
const mod = await import('./foo.js')
// 路径可以是任意表达式
const mod = await import(`./locales/${lang}.js`)

import() 本质是一个运行时函数调用,返回 Promise,适合按需加载、懒加载场景。

2.4 总结对比

时机 本质 用途
import 语句 解析 AST 时(执行前) 静态声明 常规依赖
import() 函数 执行到该行时 运行时调用 懒加载、条件加载
Live Binding 的值 求值阶段填入 运行时赋值

一句话:ESM 的依赖关系是静态的,但变量的值是运行时的。


三、ESM vs CommonJS

ESM CommonJS
依赖分析 静态,执行前确定 动态,运行时执行 require()
导出绑定 Live Binding(引用) 值拷贝
循环依赖 可处理(binding 已建立) 可能拿到未完成的对象
异步加载 原生支持 同步阻塞
Tree Shaking 天然支持 难以静态分析

四、Webpack:将模块编译为函数

Webpack 在构建时将所有模块编译成函数,自行实现一套模块加载系统,不依赖浏览器原生 ESM。

4.1 核心结构

(function(modules) {

  var installedModules = {}; // 模块缓存

  function __webpack_require__(moduleId) {
    // 命中缓存直接返回,保证每个模块只执行一次
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }

    // 创建模块对象并缓存
    var module = installedModules[moduleId] = { exports: {} };

    // 执行模块函数,注入 module、exports、require
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

    return module.exports;
  }

  return __webpack_require__('./src/index.js'); // 从入口启动

})({
  './src/index.js': function(module, exports, __webpack_require__) {
    const foo = __webpack_require__('./src/foo.js');
    console.log(foo);
  },
  './src/foo.js': function(module, exports) {
    exports.default = 'hello';
  }
});

4.2 Live Binding 的模拟

Webpack 模拟的是 CJS 语义,导出本质是值拷贝。但为了兼容 ESM 的 Live Binding,Webpack 用 Object.defineProperty 的 getter 做了补丁:

// 你写的 ESM
export let count = 0;

// Webpack 编译后
Object.defineProperty(exports, 'count', {
  get: function() { return count; } // 每次读取都重新取值,模拟引用
});

4.3 缓存机制对比

Webpack 原生 ESM
缓存位置 installedModules 对象(JS 堆内存) 浏览器 Module Map
缓存 Key 模块路径字符串 模块完整 URL
执行次数 只执行一次 只执行一次
缓存清除 不能(除非 HMR 介入) 不能(页面级别)

4.4 HMR 的本质

Hot Module Replacement 正是利用了这套缓存机制:

检测到文件变更
    ↓
删除 installedModules 中对应模块的缓存
    ↓
注入新的模块函数
    ↓
重新执行该模块 → 更新界面

不需要刷新页面,只替换变更的那块缓存。


五、Webpack 异步加载(动态 import)

当你写 import() 时,Webpack 会把对应模块拆成独立的 chunk 文件,运行时按需加载。

5.1 核心机制:JSONP

// 你写的
const mod = await import('./foo.js')

// Webpack 编译后
__webpack_require__.e('chunk-foo')           // 异步加载 chunk
  .then(() => __webpack_require__('./src/foo.js')) // 从缓存同步取模块

5.2 webpack_require.e 的实现

__webpack_require__.e = function(chunkId) {
  // 已加载,直接返回
  if (installedChunks[chunkId] === 0) return Promise.resolve();

  // 加载中,返回同一个 Promise(防止重复请求)
  if (installedChunks[chunkId]) return installedChunks[chunkId][2];

  // 首次加载:创建 Promise + 动态插入 <script>
  var promise = new Promise((resolve, reject) => {
    installedChunks[chunkId] = [resolve, reject];
  });
  installedChunks[chunkId][2] = promise;

  var script = document.createElement('script');
  script.src = chunkId + '.bundle.js';
  document.head.appendChild(script);

  return promise;
};

5.3 chunk 文件结构

// chunk-foo.bundle.js
(self["webpackChunk"] = self["webpackChunk"] || []).push([
  ['chunk-foo'],
  {
    './src/foo.js': function(module, exports) {
      exports.default = 'hello'
    }
  }
]);

主 bundle 中拦截了 webpackChunk.push,chunk 文件执行时自动触发:

self["webpackChunk"].push = function([chunkIds, modules]) {
  Object.assign(__webpack_modules__, modules); // 注册新模块
  chunkIds.forEach(id => {
    installedChunks[id] = 0;    // 标记已加载
    installedChunks[id][0]();   // resolve Promise
  });
};

5.4 完整时序

import('./foo.js')
    ↓
__webpack_require__.e('chunk-foo')
    ↓
installedChunks 无缓存 → 创建 Promise + 插入 <script> 标签
    ↓
浏览器下载 chunk-foo.bundle.js
    ↓
chunk 执行 → webpackChunk.push() 被拦截
    ↓
模块注册进 __webpack_modules__ → resolve Promise
    ↓
.then(() => __webpack_require__('./src/foo.js'))
    ↓
从 installedModules 缓存同步取出 → 返回模块

5.5 其他细节

  • 防重复请求:同一 chunk 并发多次 import(),共享同一个 Promise
  • 预加载/* webpackPrefetch: true */ 会生成 <link rel="prefetch"> 提前加载资源
  • 错误处理script.onerror 触发时 reject Promise,可被 try/catch 捕获

总结

原生 ESM
  静态分析依赖 → 并行下载 → 分配内存 → 顺序执行
  Live Binding = 真实内存引用
  import()     = 运行时动态请求

Webpack
  编译阶段:所有模块 → 函数 + installedModules 缓存
  同步加载:__webpack_require__ + 缓存命中
  异步加载:动态插入 <script> + JSONP 回调 + Promise
  HMR:     删缓存 → 注入新函数 → 重执行
❌
❌