阅读视图
深圳:购买新能源乘用车补贴车价的12%
tanstack query的基本使用
一.为什么要使用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 执行: 刷新列表
四.如何使用?
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>
)
当用户退出或者切换公司的时候需要去清除缓存。
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地址
老铺黄金2026年首轮涨价20%至30%,去年三次调价累计涨超45%
别再混用了!import.meta.env 与 process.env 的本质差异一次讲透
用过vue3的小伙伴,相比对
import.meta.env与process.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.env 是 ESM + Vite 提供的环境变量访问方式。它不是 Node 原生对象,而是由构建工具在开发/打包阶段注入。
常见特征
- 内置变量:
MODE、DEV、PROD、BASE_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
适用场景
- Vite 项目的前端业务代码
- 按环境切换 API 地址、开关日志、控制埋点
- 希望享受更清晰的前端变量约束(前缀暴露机制)
2)process.env 是什么?
process.env 是 Node.js 运行时里的环境变量对象。
在服务端(Node)代码中,它天然存在;在前端项目中能不能用,取决于打包器是否做了注入/替换(如 Webpack 的 DefinePlugin)。
常见特征
- Node 端“原生可用”
- 前端中常见于旧工程(Vue CLI/Webpack)
- 常见变量:
process.env.NODE_ENV、process.env.VUE_APP_XXX
// Vue CLI / Webpack 常见
if (process.env.NODE_ENV === 'production') {
// 生产逻辑
}
const baseURL = process.env.VUE_APP_BASE_API
适用场景
- Node 服务端代码(Express、Nest、脚本工具)
- Webpack 系项目前端代码
- 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)实践建议(避免踩坑)
-
前后端变量分层
- 前端可见:只放“可公开配置”,用
VITE_前缀 - 服务端敏感项(密钥/私钥):只放
process.env(Node 端),不要暴露给前端
- 前端可见:只放“可公开配置”,用
-
不要混用语义
- Vite 前端代码统一
import.meta.env - Node 脚本、SSR 服务端逻辑统一
process.env
- Vite 前端代码统一
-
迁移时一次性改全
- 变量名前缀、读取方式、构建脚本、文档一起更新
- 建议加一条 lint/代码审查规则,禁止在 Vite 前端里继续写
process.env.xxx
结语
import.meta.env 是“面向前端构建时”的环境注入接口,process.env 是“面向 Node 运行时”的环境变量接口。
它们都能“读配置”,但不在同一个语义层。把语义边界划清,项目会更稳定,迁移成本也会更低。
好啦!今天的知识点就分享到这里吧,希望读完对你的职业素养有一个质的提升。
js中,什么是快速排序(Quick Sort)
在 JavaScript 中,快速排序(Quick Sort) 是一种高效的、基于分治思想(Divide and Conquer) 的递归排序算法。它通过选择一个“基准值”(pivot),将数组划分为两个子数组:
- 一个包含所有 小于等于 pivot 的元素,
- 另一个包含所有 大于 pivot 的元素,
然后递归地对这两个子数组进行排序,最终合并得到有序数组。
✅ 快速排序的核心步骤(JS 版)
- 选基准(pivot):通常选中间、首尾或随机元素。
- 分区(partition):将数组按 pivot 分成左右两部分。
- 递归排序左右子数组。
- 合并结果(在非原地实现中)。
🧩 简洁版(函数式风格,非原地排序)
这是 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)中通常使用 Timsort 或 Introsort(快排+堆排+插入排序混合),性能更优且稳定。 - 手写快排主要用于学习算法原理或特定场景(如自定义比较逻辑)。
// 内置排序(推荐日常使用)
arr.sort((a, b) => a - b);
✅ 总结
| 特性 | 快速排序(JS) |
|---|---|
| 是否稳定 | ❌ 不稳定(它在分区(partition)过程中可能会改变相等元素的相对顺序。) |
| 是否原地 | 可实现(看具体写法) |
| 平均时间复杂度 | O(n log n) |
| 适用场景 | 学习分治思想、面试、自定义排序逻辑 |
基于SSE的AI对话流式结构
本文章是基于当前AI业务项目梳理的一份SSE流式结构,简单介绍了一下,当前我们实现的AI流式消息的思路,其中可能有很多不合理的地方,欢迎大佬指正和建议🌹
一、整体架构
二、流式消息字段
首先提供一段完整的处理的消息格式
[
{
"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的一系列消息类,主要包括:
- step
- text
- tool
- 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设计
在移动互联网爆发的黄金年代,几乎所有前端和客户端同学都吵过一个架:运营要改个大促活动的规则、换个 banner 位,前端改完代码刷新页面就上线了,客户端却要走「打包→提审→等苹果 / 安卓商店审核→用户下载更新」的完整流程,顺利的话 3 天,遇到审核被打回,一周都搞不定 —— 等功能上线,活动都快结束了。
原生开发的静态化短板,和业务对「动态化、快迭代」的强需求,形成了不可调和的矛盾。也正是在这个背景下,Hybrid 混合开发架构成了行业的标准答案:用原生做 App 外壳和底层能力底座,用 Web 承载高频迭代的业务 UI 和逻辑,兼顾原生的能力边界和 Web 的动态灵活性。
而能让两个完全隔离、语言不通的运行环境顺畅对话的核心纽带,就是我们这一节要拆解的主角 ——JSBridge。它不是什么复杂的黑科技,却是整个混合开发时代最核心的底层基建,哪怕到了今天的小程序、跨端框架时代,它的核心设计思想依然在被沿用。
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 最早用的方案。
它的核心逻辑非常巧妙:
- JS 端通过创建隐藏的 iframe,或者直接修改
window.location.href,发起一个自定义协议的请求,比如myapp://camera/open?callbackId=123¶ms={"quality":1080}; - Native 端在 WebView 的导航拦截回调里(安卓的
shouldOverrideUrlLoading、iOS 的decidePolicyForNavigationAction),捕获到这个请求; - 原生端解析这个自定义 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,必须包含这些核心能力:
-
异步回调管理:JSBridge 通信本质上是异步的,SDK 需要维护一个全局的请求池,每次 JS 调用原生能力时,生成一个唯一的
callbackId,把回调函数和 ID 绑定后存入请求池,再把 ID 和参数传给原生;原生执行完毕后,带着callbackId回调 JS,SDK 再通过 ID 找到对应的 Promise,执行 resolve/reject,完美适配前端的异步开发习惯。 - 消息队列与防抖:在页面初始化、高频操作等场景,会出现短时间内大量调用 Bridge 的情况,为了防止消息丢失、Native 线程阻塞,SDK 会维护一个消息队列,把并发的调用打包成批量消息,在空闲时间统一发送给原生。
- 超时与错误重试:针对原生调用超时、失败的场景,SDK 需要内置超时机制和重试策略,避免业务 Promise 一直 pending,同时给出明确的错误提示,方便业务做兜底处理。
- 参数序列化与版本兼容:处理两端的参数类型兼容问题,同时针对不同 App 版本的 Bridge 能力差异,做优雅的降级兼容,避免低版本 App 出现方法找不到的报错。
第三层:Native Bridge 层(Hybrid 架构的原生核心)
这一层是原生侧的调度中心,负责承接 JS 端的所有请求,核心职责有 3 个:
- 协议解析与分发:接收 JS 端传来的 JSON 格式数据,解析出目标模块、方法名和参数,分发到对应的原生能力模块执行,同时把执行结果封装成统一格式返回给 JS 端。
- 严格的权限校验:这是整个架构的安全生命线!必须做两层校验:一是校验当前加载的页面域名,是否在 App 的白名单内,防止恶意第三方页面调用 Bridge 能力;二是校验当前页面是否有权限调用对应的 API,比如非核心业务页面,不能调用通讯录、短信等敏感权限,从根源上避免用户隐私泄露。
- 生命周期管理:和 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 为代表的「自绘引擎」架构。这也是我们下一节要深入探讨的,跨端技术的第二次革命性演进。
浙江新增2款已完成备案的生成式人工智能服务
说说闭包的理解和应用场景?
这个问题是 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 就不会被回收。
八、面试标准回答模板(你可以直接背)
闭包是函数和其词法作用域的组合。
当内部函数引用外部函数变量时,外部函数执行完后变量不会被销毁,从而形成闭包。
本质是作用域链和垃圾回收机制的结果。
常见应用包括数据私有化、防抖节流、模块化封装、事件绑定等。
五角大楼抛弃Anthropic后 批准OpenAI的AI安全红线
如何在老项目中使用AI实现智能问答
老树发新芽:在 Vue 2 老项目中优雅落地 AI 流式对话 (SSE)
前言:随着 DeepSeek、ChatGPT 等大模型的爆火,给现有业务系统装上“AI 大脑”已成为刚需。但对于许多仍坚守在 Vue 2 + Webpack 时代的企业级老项目来说,引入 AI 能力往往面临着技术栈陈旧、依赖冲突等挑战。本文将以实战代码为例,复盘如何在不破坏原有架构的前提下,利用原生技术栈实现丝滑的 AI 流式问答体验。
一、 痛点分析:为什么不用 Axios?
在老项目中,我们通常封装了统一的 Axios 拦截器来处理 Token、错误码和全局 Loading。但在对接 AI 流式接口(Server-Sent Events, SSE)时,Axios 显得有些“水土不服”:
-
流式支持弱:老版本 Axios 对
onDownloadProgress的支持更多是为了进度条,而非真正的流式解析,容易出现“等一坨数据回来再一次性渲染”的伪流式现象。 -
配置繁琐:为了通过 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 模块,而是采用了插件式的打法:
-
独立文件:将 AI 相关的流式请求逻辑封装在单独的
.js文件中,不污染原有的 API 封装。 -
组件复用:将 Markdown 渲染(
vue-markdown-it)、打字机效果封装为独立的 Vue 组件,可以在任何业务页面按需引入。 -
数据驱动:利用 Vue 的响应式系统,将流式数据直接绑定到
el-input或div上,无需手动操作 DOM 插入文本。
五、 结语
技术不仅仅是追逐新框架。在现有的 Vue 2 老系统中,通过合理利用原生 Fetch API 和流式处理思想,我们依然可以构建出不输给 Next.js/React 的现代化 AI 交互体验。这不仅是功能的叠加,更是对老项目生命力的延续。
相关技术栈:Vue 2.6, Element UI, Fetch API, Server-Sent Events (SSE)
2025年国民经济和社会发展统计公报发布
春晚后机器人行业大额融资频出 商业化大考仍在路上
微前端架构下的平台级公共组件资源体系设计
二、核心分层设计(对标阿里 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: truefor 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 热更新实战指南
从零开始掌握 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 方案
- 将补丁包托管到国内云存储(如阿里云 OSS、七牛云)
- 修改客户端代码,从你的 CDN 地址下载补丁
- 大幅提升用户下载速度和成功率
📱 平台支持现状
| 平台 | 支持情况 | 说明 |
|---|---|---|
| Android | ✅ 完美支持 | 性能无损,完全合规 |
| iOS | ✅ 完美支持 | 遵守 App Store 规则 |
| 鸿蒙 (HarmonyOS NEXT) | ❌ 不支持 | 官方暂无计划,需关注后续动态 |
五、总结与最佳实践
核心命令速查
# 生命周期命令
shorebird init # 初始化
shorebird release # 发布版本(基础包)
shorebird patch # 发布补丁(热更新)
适用场景建议
- ✅ 推荐使用:仅覆盖 Android + iOS 的中大型 App,追求高性能和低接入成本
- ⚠️ 暂不适用:必须覆盖鸿蒙系统的项目(需等待官方支持)
- 💪 最佳实践:国内生产环境务必配置自定义 CDN,确保补丁下载体验
Shorebird 以其优秀的性能和合规性,已成为 Flutter 热更新的首选方案。通过本文的指引,相信你已经能够顺利上手。如果在实际操作中遇到问题,欢迎随时交流探讨。
奈飞证实收到派拉蒙支付的28亿美元终止费
【节点】[LinearBlendSkinning节点]原理解析与实际应用
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中的基本步骤:
- 创建新的Shader Graph或打开现有图表
- 在节点库中找到Linear Blend Skinning节点(位于Animation类别下)
- 将节点拖放到图形编辑器中
- 连接输入端口:
- 将Position节点的输出连接到Linear Blend Skinning的Position输入
- 将Normal节点的输出连接到Normal输入
- 将Tangent节点的输出连接到Tangent输入
- 连接输出端口:
- 将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 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)