阅读视图

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

新版《人体生物监测质量保证规范》国家标准实施

记者今天(28日)了解到, 市场监管总局(国家标准委)批准发布《人体生物监测质量保证规范》国家标准。该标准由国家疾病预防控制局组织起草,自3月1日起正式实施。 《人体生物监测质量保证规范》修订后主要有四方面变化。 一是拓展适用范围。将伦理审查与人类遗传资源管理要求纳入标准体系,并对现场调查,包括组织实施、问卷调查、体格检查等环节进行了全面规范。 二是优化生物样本采集要求。更新了监测人群的确定原则,新增了生物安全、采样空白、样本分装、样本接收、样本入库等关键环节的质量控制要求,并对血样和尿样的采集方式进行了调整优化,同时删减了脂肪、粪便、呼出气及其他组织等样本采集的相关内容。 三是提升实验室分析要求。在实验室测定环节新增了空白试验要求,对方法检出限和定量限的确定原则、精密度、校正曲线绘制、准确度评价以及平行样分析等核心质控指标的评价内容和要求进行了系统性的变更与细化。 四是强化数据管理。首次对效应标志物检测、生物监测数据的采集、核查和处理提出了规范性要求。(e公司)

深圳:购买新能源乘用车补贴车价的12%

深圳市超长期特别国债资金支持消费品以旧换新提质增效实施方案(2026年)提到,支持汽车报废更新。个人消费者报废登记在本人名下的乘用车,并购买纳入《减免车辆购置税的新能源汽车车型目录》的新能源乘用车或2.0升及以下排量燃油乘用车的,给予汽车报废更新补贴支持,其中,购买新能源乘用车补贴车价的12%(最高不超过2万元),购买2.0升及以下排量燃油用车补贴车价的10%(最高不超过1.5万元)。(新浪财经)

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 执行: 刷新列表

四.如何使用?

参考: 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/…

老铺黄金2026年首轮涨价20%至30%,去年三次调价累计涨超45%

2月28日,老铺黄金正式实施2026年首轮提价。每经记者率先获悉,其单品涨幅为20%至30%,整体平均涨幅达25%。 据悉,老铺黄金在2025年2月、8月、10月均调价,涨幅分别为5%至10%、10%至12%、18%至25%。一位黄金珠宝行业人士告诉每经记者:“2025年老铺黄金三次调价的累计涨幅在45%左右,低于同期近60%的金价上涨幅度。”(每经网)

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

用过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)

在 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对话流式结构

本文章是基于当前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。

LV中国公司换帅

近日,LV中国关联公司路易威登(中国)商业销售有限公司发生工商变更,DAVID PONZO卸任法定代表人、董事长,由Hugues,Henri,Pierre BONNET-MASIMBERT接任。路易威登(中国)商业销售有限公司成立于2004年11月,注册资本5000万人民币,经营范围含出版物零售、食品销售、酒类经营、皮革制品销售、箱包销售、服装服饰批发、服装服饰零售等,由路易威登香港有限公司全资持股。(DoNews)

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

在移动互联网爆发的黄金年代,几乎所有前端和客户端同学都吵过一个架:运营要改个大促活动的规则、换个 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 为代表的「自绘引擎」架构。这也是我们下一节要深入探讨的,跨端技术的第二次革命性演进。

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

这个问题是 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安全红线

知情人士透露,五角大楼已同意OpenAI提出的、在涉密环境中安全部署其技术的相关规则,目前双方尚未签署合同。 连日来,五角大楼猛烈抨击OpenAI的竞争对手Anthropic,称其为AI在军事领域的应用划定的红线——禁止大规模监控和自主武器——纯属意识形态层面的“觉醒”做派。 而如今,五角大楼(暂未回应置评请求)似乎接受了OpenAI提出的极为相似的限制条件。(金融界)

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

老树发新芽:在 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)

2025年国民经济和社会发展统计公报发布

2月28日,国家统计局发布《中华人民共和国2025年国民经济和社会发展统计公报》。 初步核算,全年国内生产总值1401879亿元,比上年增长5.0%。其中,第一产业增加值93347亿元,比上年增长3.9%;第二产业增加值499653亿元,增长4.5%;第三产业增加值808879亿元,增长5.4%。第一产业增加值占国内生产总值比重为6.7%,第二产业增加值比重为35.6%,第三产业增加值比重为57.7%。最终消费支出拉动国内生产总值增长2.6个百分点,资本形成总额拉动国内生产总值增长0.8个百分点,货物和服务净出口拉动国内生产总值增长1.6个百分点。 分季度看,一季度国内生产总值同比增长5.4%,二季度增长5.2%,三季度增长4.8%,四季度增长4.5%。全年人均国内生产总值99665元,比上年增长5.1%。国民总收入1393700亿元,比上年增长5.1%。全员劳动生产率为184413元/人,比上年提高6.1%。(界面)

春晚后机器人行业大额融资频出 商业化大考仍在路上

在央视春晚上大放异彩之后,人形机器人行业接连传来大额融资的消息。2月23日,智平方斩获超10亿元B轮系列融资,公司估值超百亿元;2月24日,千寻智能宣布近期连续获得两轮融资,总额近20亿元,也正式进入“百亿俱乐部”。北京人形机器人创新中心、因时机器人、具微科技、中科第五纪等多家具身智能企业也在近期宣布完成最新一轮融资。热闹过后,春晚带来的流量能否转化为商业竞争力,能否在实际作业场景中获得客户认可,继续考验着人形机器人企业的商业化场景。(e公司)

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

二、核心分层设计(对标阿里 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 热更新实战指南

从零开始掌握 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 热更新的首选方案。通过本文的指引,相信你已经能够顺利上手。如果在实际操作中遇到问题,欢迎随时交流探讨。

奈飞证实收到派拉蒙支付的28亿美元终止费

据报道,知情人士透露,在奈飞放弃收购华纳兄弟探索部分业务的计划后,派拉蒙天舞已向其支付了28亿美元终止费。 奈飞于当地时间2月27日证实了这一消息。该公司在提交给美国证券交易委员会的文件中称:“华纳兄弟探索通知奈飞,其已根据合并协议的条款终止该协议,以便与派拉蒙天舞就后者提出的公司更优提案签署合并协议。在终止合并协议、并由华纳兄弟探索与派拉蒙天舞签订该协议的同时,派拉蒙天舞代表华纳兄弟探索向奈飞支付了根据合并协议条款应支付的28亿美元终止费。”(界面)

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

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

❌