阅读视图

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

Event Loop 教你高效 “划水”:JS 单线程的“摸鱼”指南

前言

各位前端打工人,有没有过这种经历:明明写了 setTimeout(() => console.log('摸鱼')),结果同步代码还没跑完,摸鱼计划就被打断?其实 JS 单线程就像一个只能专注干一件事的打工人,而 Event Loop 就是它的 “高效摸鱼手册”—— 既能按时完成核心工作,又能把耗时任务 “挂起摸鱼”,今天咱们就一起好好聊聊这份手册!

一、先搞懂:JS 打工人为啥不能 “硬卷”?(进程线程的底层逻辑)

要想摸鱼,得先知道 “工作台” 的规矩:

  • 进程:好比公司的独立部门 —— 比如浏览器开个新标签页,就是开了个新部门,每个部门都有自己的办公资源(电脑、文件)。

  • 线程:部门里真正干活的打工人 —— 浏览器部门里就有三个核心员工:

    1. 渲染线程(负责画页面,比如给按钮上色、排版文字);
    2. JS 引擎线程(咱们的主角,负责跑代码);
    3. HTTP 请求线程(负责发接口,比如向服务器要数据)。

但这里有个 “办公室规定”:JS 引擎线程和渲染线程是 “互斥同事” ——JS 能修改 DOM(比如把按钮改成红色),要是它俩同时干活,页面就会出现 “排版错乱”(比如按钮画到一半被改成红色),所以必须 “你歇我干”。

更关键的是:JS 引擎线程是个 “独生子” (V8 引擎默认只开一个线程)。这就意味着:如果 JS 遇到一个耗时 10 秒的计算任务(比如统计 100 万条数据),它就会一直死磕这个任务,导致渲染线程没法干活,页面直接卡成 “PPT”—— 这就是 “硬卷” 的下场!

所以 JS 打工人的生存法则是:能摸鱼就不硬卷,耗时任务先 “挂起”,等核心工作做完再处理—— 这就是 “异步摸鱼” 的核心逻辑。

二、Event Loop:摸鱼任务的 “优先级排序”

JS 里的 “摸鱼任务”(异步任务) 分两类,就像公司里的 “紧急任务”“常规任务”,得按顺序处理,不能乱摸鱼:

  • 微任务:紧急摸鱼任务(优先级高)—— 比如 Promise.then()async/await 后续代码、process.nextTick()(Node 环境),相当于 “老板临时交代的小任务,必须在下班前做完”;
  • 宏任务:常规摸鱼任务(优先级低)—— 比如 setTimeoutsetInterval、ajax 请求、I/O 操作、UI 渲染,相当于 “下周要交的报告,先放一放”;
  • 还有个特殊角色:同步任务—— 核心工作(比如写代码、算结果),必须优先做完,相当于 “当天要交的核心 KPI”。

Event Loop 就是这套摸鱼规则的 “监督者”,它的工作流程就像打工人的一天,记好这 4,摸鱼不翻车:

  1. 先清核心 KPI:先把当天的同步任务 (核心工作) 全部做完,遇到异步任务 (摸鱼任务),就按类型扔进 “微任务队列” (紧急摸鱼) 和 “宏任务队列” (常规摸鱼)
  2. 再处理紧急摸鱼:核心 KPI 做完后,把 “微任务队列” 里的所有任务一次性清完(比如老板临时交代的 3 个小任务,必须连续做完,不能中途打断);
  3. 中场休息(渲染页面) :紧急摸鱼任务处理完,浏览器会进行 “页面渲染”(比如更新 DOM、刷新页面),相当于打工人喝杯咖啡歇一歇;
  4. 开启下一轮摸鱼:从 “宏任务队列” 里拿一个任务执行,然后重复 1-3 步,直到所有任务做完。

三、实战摸鱼:用代码例子验证规则

光说不练假把式,咱们用真实代码模拟 JS 打工人的 “摸鱼一天”,看看 Event Loop 是怎么安排任务的!

例子 1:setTimeout为啥 “跑不赢” 同步代码?

先看这串经典代码:

let a = 1;
setTimeout(() => {
    a = 2
}, 1000)
console.log(a);

分析摸鱼过程

  • 同步代码(属于宏任务)先跑:let a=1 → 执行console.log(a),此时a还是 1;
  • setTimeout是宏任务,被扔进 “宏任务队列” 排队;
  • 同步跑完后,微任务队列为空,直接执行下一个宏任务(也就是 1 秒后的a=2)。

所以结果是:先输出 1,1 秒后a才变成 2

image.png

例子 2:Promise.then的 “VIP 特权”

我们看一道经典面试题:

console.log(1);
new Promise((resolve) => {
    console.log(2);
    resolve();
})
.then(() => {
    console.log(3);
    setTimeout(() => {
        console.log(4);
    }, 0)
})
setTimeout(() => {
    console.log(5);
    setTimeout(() => {
        console.log(6);
    }, 0)
}, 0)
console.log(7);

是不是已经头皮发麻了?根本不清楚打印顺序是啥,但是这道面试题我们必须拿下!

摸鱼步骤拆解

  1. 常规摸鱼(宏任务)开跑

    • 先执行console.log(1) → 输出1
    • 遇到new PromisePromise 构造函数里的代码是同步的,执行console.log(2) → 输出2,然后resolve()
    • then是微任务,扔进 “微任务队列”;
    • 遇到外层setTimeout:宏任务,扔进 “宏任务队列”;
    • 最后执行console.log(7) → 输出7
  2. 紧急摸鱼(微任务)接棒

    • 微任务队列里只有then的回调,执行它:console.log(3) → 输出3
    • 回调里的setTimeout(4)是宏任务,扔进 “宏任务队列”。
  3. 宏任务队列开跑(下一轮摸鱼)

    • 先拿第一个宏任务(外层setTimeout):执行console.log(5) → 输出5
    • 里面的setTimeout(6)扔进宏任务队列;
    • 再拿下一个宏任务(then里的setTimeout(4)):执行console.log(4) → 输出4
    • 最后拿setTimeout(6):执行console.log(6) → 输出6

最终输出顺序1 → 2 → 7 → 3 → 5 → 4 → 6

image.png

上图更清晰:

image.png

例子 3:async/await 是 “优雅摸鱼” 的语法糖

async/await 本质是 Promise 的语法糖,相当于给摸鱼任务加了 “自动排队” 功能,先搞懂它的用法

console.log('script start');
async function async1() {
    await async2()
    console.log('async1 end');
}
async function async2() {
    console.log('async2 end');
}
async1();

关键规则

  • async函数本身相当于 “返回 Promise 的函数”;
  • await fn()的本质是:await后面的代码,塞进了fn()返回的 Promise 的then里(也就是微任务队列)

拿这段代码分析:

  1. 同步执行console.log('script start') → 输出;

  2. 执行async1()

    • 进入async1,遇到await async2() → 先执行async2()(同步),输出async2 end
    • await把后续的console.log('async1 end')扔进微任务队列
  3. 继续执行同步代码

image.png

OK既然知道了原理我们就实战摸鱼

// 模拟耗时任务:向服务器要数据(宏任务)
function fetchData() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('常规摸鱼:发接口请求(耗时 1 秒)');
            resolve('接口返回数据:用户列表');
        }, 1000);
    });
}
// 核心工作函数(async 标记为异步函数)
async function work() {
    console.log('核心工作:开始处理用户数据');
    // await 相当于“等待摸鱼任务完成,再继续核心工作”
    const data = await fetchData();
    // 这行代码会被扔进微任务队列,相当于“紧急摸鱼后的收尾工作”
    console.log(`核心工作:使用${data}完成报表`);
}
// 执行核心工作
work();
// 其他同步任务
console.log('核心工作:处理其他紧急事务');

摸鱼流程拆解:

  1. 执行同步任务:

    • 调用 work() 函数,打印 核心工作:开始处理用户数据
    • 遇到 await fetchData(),先执行 fetchData(),里面的 setTimeout 被扔进 “宏任务队列”(常规摸鱼);
    • await 会暂停 work 函数,跳出去执行其他同步任务,打印 核心工作:处理其他紧急事务 → 同步任务完成。
  2. 微任务队列为空,直接进入中场休息。

  3. 处理宏任务队列(常规摸鱼):

    • 1 秒后,执行 setTimeout 回调,打印 常规摸鱼:发接口请求(耗时 1 秒)Promise resolve 后,await 后面的代码被扔进 “微任务队列”。
  4. 再次处理微任务队列:

    • 执行 console.log(核心工作:使用 ${data} 完成报表) → 核心工作收尾。

image.png

这里的关键是:await 后面的代码会被自动塞进微任务队列,相当于 “摸鱼结束后,优先处理收尾工作”,不用手动写 then 回调,摸鱼更优雅!

大家可以复制代码去运行一下,时间延迟照片体现不出来~~

四、摸鱼避坑:这些误区千万别踩

  1. 误区 1:setTimeout 延迟时间是 “准确时间”

错! setTimeout(() => {}, 1000) 不是 “1 秒后立即执行”,而是 “1 秒后把任务扔进宏任务队列”,得等同步任务和微任务全部完成后才会执行。如果前面的任务耗时 2 秒,那摸鱼就得等 2 秒后才开始。

  1. 误区 2:Promise 构造函数里的代码是异步的

错! new Promise((resolve) => { 同步代码 }) 里的代码是同步执行的,只有 thencatch 回调才是微任务(异步)。比如下面的代码,会先打印 同步代码,再打印 微任务

new Promise((resolve) => {
    console.log('同步代码');
    resolve();
})
.then(() => {
    console.log('微任务')
});

image.png 3. 误区 3:async 函数返回值是 “原始数据”

错! async 函数默认返回一个 Promise 对象,哪怕你写 async function fn() { return 1; },调用 fn() 得到的也是 Promise { 1 },需要用 await 或 then 才能拿到值。

五、总结:Event Loop 摸鱼口诀(记熟直接用)

同步任务先干完,微任务队列清干净;

渲染页面歇一歇,宏任务来轮着干;

await 后藏微任务,Promise 构造是同步;

Event Loop 掌节奏,摸鱼工作两不误!

结语

其实 JS 单线程的 “摸鱼哲学”,本质是 “优先级管理”—— 核心工作优先做,耗时任务排队做,既不耽误事,又不浪费时间。掌握了 Event Loop,你不仅能看懂 JS 异步代码的执行顺序,还能写出更高效的代码,就像打工人掌握了摸鱼技巧,工作效率翻倍,摸鱼也不心慌!

Vue 3 Keep-Alive 深度实践:从原理到最佳实践

Vue 3 Keep-Alive 深度实践:从原理到最佳实践

前言

初入职场,我被安排用 Vue3 制作公司官网,有 5-6 个静态页面。开发完成后,领导在测试时提出一个问题:“为什么页面滑动后再切换到其它页面,返回时没有回到顶部?”调试后发现,是因为使用了 <keep-alive> 组件缓存页面导致的。这引发了我对 Vue 3 Keep-Alive 的浓厚兴趣。Keep-Alive 能帮助我们在页面间切换时保留组件的状态,使用户体验更加流畅。特别是在带有筛选和滚动列表的页面中,使用 Keep-Alive 可以在返回时保留用户之前的筛选条件和滚动位置,无需重新加载或初始化。

在本文中,我将结合实例,从基础到深入地解析 Vue 3 中的 Keep-Alive 组件原理、常见问题及最佳实践,帮助大家全面掌握这一功能。


一、了解 Keep-Alive:什么是组件缓存?

1.1 Keep-Alive 的本质

<keep-alive> 是 Vue 的内置组件,用于缓存组件实例,避免在切换时重复创建和销毁组件实例。换言之,当组件被包裹在 <keep-alive> 中离开视图时,它不会被销毁,而是进入缓存;再次访问时,该组件实例会被重新激活,状态依然保留。

示例场景:用户从列表页进入详情页后再返回列表页。

没有 Keep-Alive 的情况

  • 用户操作:首页 → 探索页 → 文章详情 → 探索页

  • 组件生命周期:

    • 首页:创建 → 挂载 → 销毁
    • 探索页:创建 → 挂载 → 销毁 → 重新创建 → 重新挂载
    • 文章详情:创建 → 挂载 → 销毁
    • 探索页(再次):重新创建 → 重新挂载(状态丢失)

有 Keep-Alive 的情况

  • 用户操作:首页 → 探索页 → 文章详情 → 探索页

  • 组件生命周期:

    • 首页:创建 → 挂载 → 停用(缓存)
    • 探索页:创建 → 挂载 → 停用(缓存)
    • 文章详情:创建 → 挂载 → 销毁
    • 探索页(再次):激活(从缓存恢复,状态保持)

使用 <keep-alive> 包裹的组件,在离开时不会销毁,而是进入「停用(deactivated)」状态;再次访问时触发「激活(activated)」状态,原先所有的响应式数据都仍然保留。这意味着,探索页中的筛选条件和滚动位置都还能保留在页面返回时显示,提高了用户体验。

1.2 Keep-Alive 的工作原理

Keep-Alive 通过以下机制来实现组件缓存:

  • 缓存机制:当组件从视图中被移除时,如果包裹在 <keep-alive> 中,组件实例不会被销毁,而是存放在内存中。下次访问该组件时,直接复用之前缓存的实例。
  • 生命周期钩子:被缓存组件在进入和离开时,会触发两个特殊的钩子 —— onActivated / onDeactivatedactivated / deactivated。可以在这些钩子中执行恢复或清理操作,例如刷新数据或保存状态。
  • 组件匹配<keep-alive> 默认会缓存所有包裹其中的组件实例。但如果需要精确控制,就会用到 includeexclude 属性,匹配组件的 name 选项来决定是否缓存。注意,这里的匹配依赖于组件的 name 属性,与路由配置无关。

1.3 核心属性

  • include:字符串、正则或数组,只有 name 匹配的组件才会被缓存。
  • exclude:字符串、正则或数组,name 匹配的组件将不会被缓存。
  • max:数字,指定最多缓存多少个组件实例,超过限制时会删除最近最少使用的实例。

注意:include/exclude 匹配的是组件的 name 选项。在 Vue 3.2.34 及以后,如果使用了 <script setup>,组件会自动根据文件名推断出 name,无需手动声明。


二、使用 Keep-Alive:基础到进阶

2.1 基础使用

最简单的使用方式是将动态组件放在 <keep-alive> 里面:

<template>
  <keep-alive>
    <component :is="currentComponent" />
  </keep-alive>
</template>

这样每次切换 currentComponent 时,之前的组件实例会被缓存,状态不会丢失。

2.2 在 Vue Router 中使用

在 Vue Router 配置中,为了让路由页面支持缓存,需要将 <keep-alive> 放在 <router-view> 的插槽中:

<template>
  <router-view v-slot="{ Component }">
    <keep-alive>
      <component :is="Component" />
    </keep-alive>
  </router-view>
</template>

这样 <keep-alive> 缓存的是路由对应的组件,而非 <router-view> 自身。不要包裹整个 <router-view>,而是通过插槽嵌套其渲染的组件。

2.3 使用 include 精确控制

如果只想缓存特定组件,可利用 include 属性:

<template>
  <router-view v-slot="{ Component }">
    <keep-alive include="Home,Explore">
      <component :is="Component" />
    </keep-alive>
  </router-view>
</template>

include 中的名称必须与组件的 name 完全一致,否则不起作用。

2.4 滑动位置缓存示例

以“探索”列表页为例:用户在该页设置筛选条件并滚动列表后,跳转到文章详情页,再返回“探索”页。如果没有使用 Keep-Alive,列表页组件会被重新创建,筛选条件和滚动位置会重置。

使用 <keep-alive> 缓存“探索”页后,返回时组件从缓存中激活,之前的 ref 值和 DOM 滚动位置依然保留。这保证了用户回到列表页时,能够看到原先浏览到的内容和筛选状态。

可以在组件中配合路由导航守卫保存和恢复滚动条位置:

  • onBeforeRouteLeave 钩子中记录 scrollTop
  • onActivated 钩子中恢复滚动条位置。

三、使用中的问题:Name 匹配的陷阱

3.1 问题场景

我们经常希望缓存某些页面状态,同时让某些页面不被缓存,例如:

  • “探索”列表页:需要缓存。
  • 登录/注册页:不需要缓存。
  • 文章详情页:通常不缓存。

3.2 第一次尝试:手动定义 Name

<script setup>
defineOptions({ name: 'Explore' })
</script>

然后在主组件中使用 include 指定名称:

<router-view v-slot="{ Component }">
  <keep-alive include="Home,Explore,UserCenter">
    <component :is="Component" />
  </keep-alive>
</router-view>

理论上只缓存 HomeExploreUserCenter

3.3 问题出现:为什么 Include 不生效?

  • 组件名称不匹配:include/exclude 匹配的是组件自身的 name 属性,而非路由配置中的 name
  • 自动生成的 Name:Vue 3.2.34+ 使用 <script setup> 会自动根据文件路径生成组件名,手动写的 name 可能与自动生成冲突。
  • 路由包装机制:Vue Router 渲染组件时可能进行包装,导致组件实际名称与原始组件不同。

依赖组件名匹配容易出错,需要更灵活的方法。


四、解决方式:深入理解底层逻辑

4.1 理解组件 Name 的生成机制

Vue 3.2.34+ 使用 <script setup> 的单文件组件会自动根据文件名推断组件的 name

  • src/pages/Explore/index.vue → 组件名 Explore
  • src/pages/User/Profile.vue → 组件名 Profile

无需手动定义 name,避免与自动推断冲突。

4.2 问题根源分析

  • 自动 Name 与路由名不一致。
  • Router 的组件包装可能导致 <keep-alive> 无法捕获组件原始 name。

4.3 解决方案:路由 Meta 控制缓存

  1. 移除手动定义的 Name
<script setup lang="js">
// Vue 会自动根据路径生成 name
</script>
  1. 在路由配置中设置 Meta
const routes = [
  {
    path: '/explore',
    name: 'Explore',
    component: () => import('@/pages/Explore/index.vue'),
    meta: { title: '探索', keepAlive: true }
  },
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/pages/Auth/index.vue'),
    meta: { title: '登录', keepAlive: false }
  },
  {
    path: '/article/:id',
    name: 'ArticleDetail',
    component: () => import('@/pages/ArticleDetail/index.vue'),
    meta: { title: '文章详情', keepAlive: false }
  }
]
  1. 在 App.vue 中根据 Meta 控制
<script setup lang="js">
import { computed } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
const shouldCache = computed(() => route.meta?.keepAlive !== false)
</script>

<template>
  <router-view v-slot="{ Component }">
    <keep-alive v-if="shouldCache">
      <component :is="Component" />
    </keep-alive>
    <component v-else :is="Component" />
  </router-view>
</template>

默认缓存所有页面,只有 meta.keepAlive 明确为 false 时才不缓存。

4.4 方案优势

  • 灵活性强:缓存策略直接写在路由配置中。
  • 可维护性好:缓存策略集中管理。
  • 避免匹配失败:不依赖手动 name。
  • 默认友好:设置默认缓存,仅对不需要缓存页面标记即可。

五、最佳实践总结

5.1 缓存策略建议

页面类型 是否缓存 缓存原因
首页(静态) ❌ 不缓存 内容简单,一般无需缓存
列表/浏览页 ✅ 缓存 保持筛选条件、分页状态、滚动位置等
详情页 ❌ 不缓存 每次展示不同内容,应重新加载
表单页 ❌ 不缓存 避免表单数据残留
登录/注册页 ❌ 不缓存 用户身份相关,每次重新初始化
个人中心/控制台 ✅ 缓存 保留子页面状态,提升体验

5.2 代码规范

  • 不要手动定义 Name,在 Vue 3.2.34+ 中自动推断。
<script setup>
// Vue 会自动推断 name
</script>
  • 使用路由 Meta 控制缓存。
  • 统一在 App.vue 中处理缓存逻辑。

5.3 生命周期钩子的使用

<script setup>
import { onActivated, onDeactivated } from 'vue'

onActivated(() => {
  console.log('组件被激活(从缓存恢复)')
})

onDeactivated(() => {
  console.log('组件被停用(进入缓存)')
})
</script>

5.4 性能考虑

  • 内存占用:不要无限制缓存过多页面,可使用 max 限制。
  • 数据刷新:在 onActivated 中进行必要更新。
  • 缓存清理:登出或不常用页面可手动清除缓存。
  • 动画与过渡:确保 <keep-alive><transition> 嵌套顺序正确。

六、总结

6.1 关键要点

  • <keep-alive> 缓存组件实例,通过停用保留状态。
  • include/exclude 功能依赖组件 name
  • 推荐使用路由 meta.keepAlive 控制缓存。
  • 缓存组件支持 onActivated / onDeactivated 钩子。
  • 默认缓存大部分页面,只对需刷新页面明确禁用。

6.2 技术演进

手动定义 Name → 自动 Name → Meta 控制

  • 冗长易错 → 简化代码 → 灵活可靠

6.3 最终方案

  • 利用自动生成的组件名取消手动命名。
  • 通过路由 meta.keepAlive 控制缓存。
  • 在根组件统一处理缓存逻辑。
  • 默认缓存,明确例外。

这样既保持了代码简洁,又实现了灵活可控的缓存策略,确保用户在页面切换时能获得更好的体验。


参考资料

  • Vue 3 Keep-Alive 官方文档
  • Vue Router 官方文档
  • Vue 3.2.34 更新日志

【基础】Unity着色器网格和计算对象介绍

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

Mesh网格定义与核心概念

顶点(Vertex)的本质与特性

顶点是构成3D模型的基本几何单元,每个顶点在三维空间中具有明确的坐标位置(x,y,z)。在Unity中,顶点不仅包含位置信息,还承载着模型渲染所需的多维数据:

  • 法线(Normal):垂直于表面的单位向量,决定光照计算的反射方向。平滑着色时,法线通过相邻面计算;硬边着色则直接使用面法线。
  • UV坐标:二维纹理映射坐标,将2D纹理精准贴合到3D表面。UV值范围通常为0-1,超出部分通过纹理环绕模式处理。
  • 顶点颜色:支持RGBA通道的颜色数据,常用于实现渐变纹理或动态光照效果。

程序化顶点生成

通过Shader Graph的Position节点和数学运算,可动态生成顶点位置。例如,创建波浪效果:

// 伪代码示例:顶点位置偏移

float4 position = TransformPosition(float4(input.position.x, sin(input.position.x * 10) * 0.1, input.position.z, 1));

此代码通过正弦函数沿X轴生成周期性波动,实现水面扭曲效果。

面(Face)的构成与渲染优化

三角形面片的优势

三角形作为3D建模的最小单位,具有以下核心特性:

  • 平面性:三个顶点必然共面,简化碰撞检测和光照计算。
  • 固定朝向:通过顶点顺序(顺时针/逆时针)定义正面/背面,支持背面剔除提升渲染效率。
  • 计算高效:三角形仅需3个顶点和3条边,比多边形更适合GPU并行处理。

多边形的实现原理

虽然多边形面片(如四边形)在建模中更直观,但渲染时会被分解为三角形。例如,Unity的网格渲染器会自动将四边形拆分为两个三角形,确保硬件兼容性。

URP Shader Graph中的网格数据处理

顶点属性节点详解

在Shader Graph中,通过以下节点访问顶点数据:

  • Position:获取模型空间或世界空间坐标。
  • Normal:读取法线向量,用于光照计算。
  • UV:访问纹理坐标,支持多通道UV(如UV1、UV2)。
  • Color:读取顶点颜色,支持与纹理混合。

示例:动态法线修改

创建凹凸效果时,可通过修改法线改变光照表现:

// 伪代码示例:法线扰动

float3 normal = normalize(input.normal + float3(0, sin(input.position.x * 10) * 0.1, 0));

此代码沿Y轴添加正弦波动,模拟表面起伏。

纹理映射与UV坐标实践

UV坐标的工作原理

UV坐标通过将3D表面展开为2D平面实现纹理映射。例如,立方体需6组UV坐标,而球体通常使用球形投影或立方体映射。

多通道UV应用

复杂模型可能使用多组UV坐标:

  • UV1:主纹理通道。
  • UV2:辅助纹理(如法线贴图)。
  • UV3:顶点动画或动态遮罩。

在Shader Graph中,通过UV节点选择通道,结合Sample Texture 2D实现多纹理混合。

顶点颜色与动态效果

顶点颜色的应用场景

  • 渐变纹理:通过顶点颜色控制材质过渡。
  • 动态光照:结合顶点颜色实现局部光照变化。
  • 调试工具:可视化法线或UV坐标。

示例:顶点颜色驱动透明度

创建渐隐效果时,可通过顶点颜色控制透明度:

// 伪代码示例:颜色驱动透明度

float4 color = input.color * float4(1, 1, 1, smoothstep(0.5, 0.8, input.color.a));

此代码根据顶点Alpha值平滑调整透明度,实现边缘渐隐。

URP Shader Graph的优化技巧

性能优化策略

  • 减少动态计算:将顶点属性计算移至顶点着色器。
  • 合并属性:通过Attributes节点打包数据,减少采样次数。
  • 使用LOD:根据距离简化网格复杂度。

移动端适配

  • 简化着色器:避免复杂数学运算。
  • 压缩纹理:使用ASTC或ETC2格式。
  • 动态批处理:启用URP的自动批处理功能。

进阶应用:程序化网格生成

动态网格创建

通过Create Mesh节点和Set Mesh节点,可在运行时生成网格:

// 伪代码示例:生成平面网格

Mesh mesh = new Mesh(); 
mesh.vertices = new Vector3[] {
          Vector3.zero,
          Vector3.right,
          Vector3.up,
          Vector3.right + Vector3.up
          };
mesh.triangles = new int[] { 0, 1, 2, 0, 2, 3 };

此代码创建了一个包含两个三角形的平面。

实例化渲染

使用Instancing节点和Set Mesh节点,可高效渲染大量相同网格:

// 伪代码示例:实例化渲染` 

MaterialPropertyBlock props = new MaterialPropertyBlock();
props.SetVector("_Color", Color.red);
Renderer renderer = GetComponent<Renderer>();
renderer.SetPropertyBlock(props); 
renderer.SetMaterial(material, 0);

此代码为所有实例设置统一颜色,减少Draw Calls。

常见问题与解决方案

法线错误

  • 现象:模型出现光照异常。
  • 解决:检查法线方向,使用Normalize节点修正。

UV拉伸

  • 现象:纹理在模型表面扭曲。
  • 解决:优化UV展开,或使用Tiling And Offset节点调整。

性能瓶颈

  • 现象:帧率下降。
  • 解决:简化着色器,减少动态计算,启用批处理。

总结与最佳实践

URP Shader Graph通过可视化节点系统,大幅降低了着色器开发门槛。掌握网格数据处理的核心要点:

  • 顶点属性:灵活运用位置、法线、UV和颜色。
  • 三角形优势:利用其平面性和计算效率优化渲染。
  • 程序化生成:通过动态创建实现复杂效果。
  • 性能优化:减少计算,合并数据,适配移动端。

结合URP的渲染管线特性和Shader Graph的节点化设计,开发者可快速实现从简单材质到复杂视觉效果的全方位创作。


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

Vue3 + Keep-Alive:实习中遇到的 window 滚动问题与实践

Vue3 + Keep-Alive:实习中遇到的 window 滚动问题与实践

前景:实习项目中的困扰

在实习期间,我参与了公司项目的前端开发,页面主要包括首页(Home)和探索页(Explore)。在项目中,这两个页面都使用 window 作为滚动容器。测试时发现一个问题:

首页和探索页都使用 window 作为滚动容器
↓
它们共享同一个 window.scrollY(全局变量)
↓
用户在探索页滚动到 500px
↓
window.scrollY = 500(全局状态)
↓
切换到首页(首页组件被缓存,状态保留)
↓
但 window.scrollY 仍然是 500(全局共享)
↓
首页显示时,看起来也在 500px 的位置 ❌

这个问题的原因在于:

  • <keep-alive> 只缓存组件实例和 DOM,不管理滚动状态。
  • window.scrollY 是全局浏览器状态,不会随组件缓存自动恢复。
  • 结果就是组件被缓存后,滚动位置被错误共享,导致用户体验不佳。

我的思路:滚动位置管理工具

为了在自己的项目中解决类似问题,我考虑了手动管理滚动位置的方案:

/**
 * 滚动位置管理工具
 * 用于在 keep-alive 缓存页面时,为每个路由独立保存和恢复滚动位置
 */
const scrollPositions = new Map()

export function saveScrollPosition(routePath) {
  const y = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop
  scrollPositions.set(routePath, y)
}

export function restoreScrollPosition(routePath, defaultY = 0) {
  const saved = scrollPositions.get(routePath) ?? defaultY
  requestAnimationFrame(() => {
    window.scrollTo(0, saved)
    document.documentElement.scrollTop = saved
    document.body.scrollTop = saved
  })
}

在组件中配合 Vue 生命周期钩子使用:

import { onActivated, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { saveScrollPosition, restoreScrollPosition } from './scrollManager'

export default {
  setup() {
    const route = useRoute()

    // 组件激活时恢复滚动
    onActivated(() => {
      restoreScrollPosition(route.path, 0)
    })

    // 组件离开前保存滚动
    onBeforeUnmount(() => {
      saveScrollPosition(route.path)
    })
  }
}

公司项目的简化处理

在公司项目中,由于页面结构简单,不需要为每个路由保存独立滚动位置,因此我采用了统一重置滚动到顶部的方式:

// 路由切换后重置滚动位置
router.afterEach((to, from) => {
  if (to.path !== from.path) {
    setTimeout(() => {
      window.scrollTo(0, 0)
      document.documentElement.scrollTop = 0
      document.body.scrollTop = 0
    }, 0)
  }
})

这样可以保证:

  • 切换页面时始终从顶部开始。
  • 简单易维护,符合公司项目需求。
  • 避免了 Keep-Alive 缓存滚动穿透的问题。

总结

  1. <keep-alive> 缓存组件实例,但不管理 window 滚动状态,导致全局滚动共享问题。
  2. 自己项目中,可以通过滚动位置管理工具为每个路由独立保存和恢复滚动。
  3. 公司项目中,为简化处理,只需在路由切换后重置滚动到顶部即可。
  4. 总体经验:滚动管理要根据项目复杂度和需求选择方案,既保证用户体验,又保证可维护性。

ElementUI组件出现大量重复样式

情况

image.png

点进去,是一个style标签,里面有六万多行样式 进去使用正则查找,发现有11处一模一样的样式

^.el-textarea__inner \{

image.png

过程

经过简单排查,发现问题在于element-variables.scss这个文件中,我框选的这一条代码。
image.png

但是把它注释掉,样式就没了,因为项目引入样式的方式是scss。
于是乎去查看官方文档,确实没啥问题。

image.png

于是我起了一个新的vue2+element-ui+scss项目,用同样的方式引入。
结果发现,是一样的,也有重复的样式说明这是Element的问题。

image.png

原因

element官方的scss文件中重复定义了样式 比如我引入以下样式 image.png 可以发现有两个重复样式

image.png

解决方法

Element早已停更,假如你不是迫不得已,应该停止使用这个UI库。
以下的所有方法都并不是一种优雅的解决方式,但是他们可以解决当前的问题。
解决方法来自github,但是位于以下文章的引用让我发现这个问题。
[vue.js - ElementUI重复引入样式问题 - 学习前端历程 - SegmentFault 思否] (segmentfault.com/a/119000002…)
令人遗憾的是,这篇文章里的方法根本不起作用。

postcss的cssnano(推荐)

github.com/ElemeFE/ele…
你只需要创建postcss.config.js文件,添加cssnano: {}即可去掉重复的样式。

// postcss.config.js
module.exports = {
  plugins: {
    autoprefixer: {},
    cssnano: {}
  },
};

fast-sass-loader(不推荐)

更换依赖为项目引入了额外的复杂性,所以这并不是推荐的方法

核心在于chainWebpack的配置,代码来自如下链接。
github.com/yibn2008/fa…
忽略下面的注释,这是我之前做的尝试。

const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true,
  chainWebpack: (config) => {
    config.module.rules.delete('scss')

    let scssRule = config.module.rule('scss')
      .test(/\.scss$/);

    [
      { name: 'vue-style-loader' },
      { name: 'css-loader' },
      { name: 'postcss-loader' },
      { name: 'fast-sass-loader' }
    ].forEach((load) => {
      scssRule
        .use(load.name)
        .loader(load.loader || load.name)
        .options(load.options || {})
    })
  },
  // configureWebpack: {
  //   module: {
  //     rules: [
  //       {
  //         test: /\.(scss|sass)$/,
  //         use: [
  //           'css-loader',
  //           {
  //             loader: 'fast-sass-loader',
  //             options: {
  //               // includePaths: [... ]
  //             }
  //           }
  //         ]
  //       },
  //       // other loaders ...
  //     ]
  //   }
  // }
})

fast-sass-loader解决了这个问题,但是官方并没有给出vue-cli中的合理使用方式。
我找了很久如何在vue中使用这个东西。
当我直接修改vue中的webpack配置,卸载了sass-loader,完全没有作用。
包括github issue中有部分人也尝试使用这个工具,他们的配置也失败了,说明这不是个例。
image.png

编译出css避开问题(不推荐)

假如我要新加一个scss变量呢?
不推荐这种削足适履的方式

我没有尝试这种方式,但这种方式在原理上是可行的,因为他完全避开了问题,当使用css文件时,就不会编译,自然也就不会引发重复样式的问题。

github.com/ElemeFE/ele…
github.com/ElemeFE/ele…

总结

如果可以,我真不想用vue2和element。

全栈项目:宠物用品购物系统及后台管理

基于Vue3和Node.js的宠物用品购物系统设计与实现

一、项目描述

随着互联网技术的快速发展和宠物经济的持续升温,宠物用品电商平台已成为宠物主人购买宠物用品的主要渠道。设计并实现了一个基于Vue3和Node.js的全栈宠物用品购物系统,该系统采用前后端分离架构,包含用户购物系统和后台管理系统两个子系统。

系统前端采用Vue 3框架,结合TypeScript、Pinia状态管理、Vue Router路由管理和Element UI Plus组件库,实现了响应式的用户界面和流畅的交互体验。后端采用Node.js和Express框架,使用MongoDB作为数据库,通过JWT实现用户身份认证,构建了RESTful风格的API接口。系统实现了用户注册登录、商品浏览搜索、购物车管理、订单处理、社交互动、后台管理等核心功能。

1. 项目截图

2. 技术栈

前端

  • Vue 3 + TypeScript
  • Vue Router 4 (路由管理)
  • Pinia (状态管理)
  • Element UI Plus (UI组件库)
  • Axios (HTTP请求)

后端

  • Node.js + Express (服务器框架)
  • MongoDB + Mongoose (数据库)
  • JWT (身份验证)
  • Multer (文件上传)
  • Bcryptjs (密码加密)

二、项目启动

前置要求

  • Node.js >= 16
  • pnpm >= 8
  • MongoDB >= 5.0

1.安装依赖

# 安装根目录依赖
pnpm install

2. 启动 MongoDB

确保 MongoDB 服务已启动并运行在 localhost:27017

3. 导入测试数据

pnpm run import

这将自动导入:

  • ✅ 4个测试用户(1个管理员 + 3个普通用户)
  • ✅ 完整的商品分类体系
  • ✅ 10个示例商品
  • ✅ 用户地址数据
  • ✅ 订单数据
  • ✅ 社交帖子数据

4. 启动开发服务器

pnpm run dev

启动后访问:

三、项目总体设计

1. 系统架构设计

1.1 架构模式选择

本系统采用前后端分离的架构模式,具有以下优势:

1. 职责分离

  • 前端专注于用户界面和交互体验
  • 后端专注于业务逻辑和数据处理
  • 前后端可以独立开发、测试、部署

2. 技术独立

  • 前端可以选择最适合的框架和技术
  • 后端可以选择最适合的语言和框架
  • 技术栈升级互不影响

3. 团队协作

  • 前端团队和后端团队可以并行开发
  • 通过API接口约定进行协作
  • 提高开发效率

4. 可扩展性

  • 前端和后端可以独立扩展
  • 支持多端应用(Web、移动端、小程序)
  • 便于向微服务架构演进
1.2 系统架构图
┌─────────────────────────────────────────────────────────┐
│                      客户端层                             │
│  ┌──────────────┐         ┌──────────────┐              │
│  │  用户系统     │         │  管理系统     │              │
│  │  (Vue 3)     │         │  (Vue 3)     │              │
│  └──────────────┘         └──────────────┘              │
└─────────────────────────────────────────────────────────┘
                          │
                          │ HTTP/HTTPS
                          │ RESTful API
                          ▼
┌─────────────────────────────────────────────────────────┐
│                      服务端层                             │
│  ┌──────────────────────────────────────────────────┐   │
│  │              Express 应用服务器                    │   │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────┐       │   │
│  │  │ 路由层    │  │ 中间件层  │  │ 控制器层  │       │   │
│  │  └──────────┘  └──────────┘  └──────────┘       │   │
│  └──────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘
                          │
                          │ Mongoose ODM
                          ▼
┌─────────────────────────────────────────────────────────┐
│                      数据层                               │
│  ┌──────────────────────────────────────────────────┐   │
│  │              MongoDB 数据库                        │   │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────┐       │   │
│  │  │ 用户集合  │  │ 商品集合  │  │ 订单集合  │       │   │
│  │  └──────────┘  └──────────┘  └──────────┘       │   │
│  └──────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘
1.3技术架构

前端架构

用户系统 / 管理系统
├── Vue 3 (核心框架)
├── TypeScript (类型系统)
├── Pinia (状态管理)
├── Vue Router (路由管理)
├── Element UI Plus (UI组件库)
├── Axios (HTTP客户端)
└── Vite (构建工具)

后端架构

API服务器
├── Node.js (运行环境)
├── Express (Web框架)
├── MongoDB (数据库)
├── Mongoose (ODM)
├── JWT (身份认证)
├── Bcrypt (密码加密)
└── Multer (文件上传)

2. 系统功能模块设计

用户购物系统功能模块

用户购物系统
├── 用户管理模块
│   ├── 用户注册
│   ├── 用户登录
│   ├── 个人信息管理
│   └── 收货地址管理
├── 商品展示模块
│   ├── 首页展示
│   ├── 商品列表
│   ├── 商品详情
│   └── 商品搜索
├── 购物功能模块
│   ├── 购物车管理
│   ├── 订单创建
│   ├── 订单查询
│   └── 订单评价
└── 社交功能模块
    ├── 动态发布
    ├── 动态浏览
    ├── 点赞评论
    └── 用户关注

后台管理系统功能模块

后台管理系统
├── 系统概览模块
│   ├── 数据统计
│   ├── 销售图表
│   └── 订单统计
├── 商品管理模块
│   ├── 商品列表
│   ├── 商品编辑
│   ├── 分类管理
│   └── 库存管理
├── 订单管理模块
│   ├── 订单列表
│   ├── 订单详情
│   ├── 发货处理
│   └── 退款处理
├── 用户管理模块
│   ├── 用户列表
│   ├── 用户详情
│   └── 用户状态管理
└── 数据统计模块
    ├── 销售统计
    ├── 商品排行
    └── 用户分析

3. 数据库设计

系统主要包含以下实体:

  1. 用户(User) :存储用户基本信息和统计数据
  2. 商品(Product) :存储商品信息、价格、库存等
  3. 订单(Order) :存储订单详情、支付信息、物流状态
  4. 动态(Post) :存储用户发布的社交动态
  5. 评论(Comment) :存储动态评论信息
  6. 地址(Address) :存储用户收货地址

实体关系:

  • 一个用户可以有多个订单(1:N)
  • 一个订单包含多个商品(N:M)
  • 一个用户可以发布多个动态(1:N)
  • 一个动态可以有多个评论(1:N)
  • 一个用户可以有多个收货地址(1:N)

四、用户认证模块设计

1. 功能流程图

用户注册流程:
用户填写信息 → 前端验证 → 发送注册请求 → 后端验证 → 密码加密 → 
存入数据库 → 生成 Token → 返回用户信息和 Token → 前端存储 Token → 
自动登录 → 跳转首页

用户登录流程:
用户输入账号密码 → 前端验证 → 发送登录请求 → 后端查询用户 → 
验证密码 → 生成 Token → 返回用户信息和 Token → 前端存储 Token → 
跳转首页

核心技术点:

  1. 密码加密(bcrypt)

bcrypt 是一种专门为密码存储设计的哈希算法,具有以下特点:

  • 加盐(Salt) :自动生成随机盐值,防止彩虹表攻击
  • 慢速哈希:计算速度慢,增加暴力破解难度
  • 自适应:可调整计算复杂度,应对硬件性能提升
// 密码加密实现
import bcrypt from 'bcryptjs';

// 注册时加密密码
const hashPassword = async (password) => {
  // 生成盐值,10 是成本因子(cost factor)
  // 成本因子越高,计算越慢,安全性越高
  const salt = await bcrypt.genSalt(10);
  
  // 使用盐值加密密码
  const hashedPassword = await bcrypt.hash(password, salt);
  
  return hashedPassword;
};

// 登录时验证密码
const verifyPassword = async (inputPassword, storedPassword) => {
  // bcrypt.compare 会自动提取盐值进行比较
  const isMatch = await bcrypt.compare(inputPassword, storedPassword);
  
  return isMatch;
};

为什么不使用 MD5 或 SHA?

  • MD5 和 SHA 是快速哈希算法,容易被暴力破解
  • 没有内置盐值机制,需要手动实现
  • bcrypt 专为密码设计,更安全

2. JWT 身份认证

JWT(JSON Web Token)是一种无状态的身份认证方案,特别适合前后端分离架构。

JWT 结构:

JWT = Header.Payload.Signature

Header(头部):
{
  "alg": "HS256",  // 签名算法
  "typ": "JWT"     // Token 类型
}

Payload(载荷):
{
  "userId": "64f8a1b2c3d4e5f6a7b8c9d0",
  "username": "testuser",
  "role": "user",
  "iat": 1704067200,  // 签发时间
  "exp": 1704672000   // 过期时间
}

Signature(签名):
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

JWT 工作流程:

1. 用户登录成功
   ↓
2. 服务器生成 JWT Token
   - 将用户信息编码到 Payload
   - 使用密钥签名,防止篡改
   ↓
3. 返回 Token 给客户端
   ↓
4. 客户端存储 Token(localStorage 或 sessionStorage)
   ↓
5. 后续请求携带 Token
   - 在 HTTP Header 中添加:Authorization: Bearer <token>6. 服务器验证 Token
   - 验证签名是否有效
   - 检查是否过期
   - 提取用户信息
   ↓
7. 处理业务逻辑

JWT 实现代码:

import jwt from 'jsonwebtoken';

// 密钥(生产环境应使用环境变量)
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';

// 生成 Token
const generateToken = (user) => {
  const payload = {
    userId: user._id,
    username: user.username,
    role: user.role
  };
  
  // 签发 Token,设置 7 天过期
  const token = jwt.sign(payload, JWT_SECRET, {
    expiresIn: '7d'
  });
  
  return token;
};

// 验证 Token 中间件
const authenticateToken = async (req, res, next) => {
  try {
    // 从请求头获取 Token
    const authHeader = req.headers.authorization;
    const token = authHeader && authHeader.split(' ')[1]; // Bearer <token>
    
    if (!token) {
      return res.status(401).json({
        success: false,
        message: '访问令牌缺失'
      });
    }
    
    // 验证 Token
    const decoded = jwt.verify(token, JWT_SECRET);
    
    // 查询用户是否存在且状态正常
    const user = await User.findById(decoded.userId);
    if (!user || user.status !== 'active') {
      return res.status(401).json({
        success: false,
        message: '用户不存在或已被禁用'
      });
    }
    
    // 将用户信息附加到请求对象
    req.user = {
      userId: decoded.userId,
      username: decoded.username,
      role: decoded.role
    };
    
    next();
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({
        success: false,
        message: '令牌已过期,请重新登录'
      });
    }
    
    return res.status(401).json({
      success: false,
      message: '无效的访问令牌'
    });
  }
};

JWT vs Session 对比:

特性 JWT Session
存储位置 客户端 服务器
扩展性 好(无状态) 差(需要共享 Session)
性能 好(无需查询) 一般(需要查询 Session)
安全性 一般(Token 泄露风险) 好(服务器控制)
适用场景 前后端分离、微服务 传统 Web 应用

JWT 安全注意事项:

  1. 不要在 Payload 中存储敏感信息(密码、信用卡号等)
  2. 使用 HTTPS 传输,防止 Token 被窃取
  3. 设置合理的过期时间
  4. 实现 Token 刷新机制
  5. 考虑使用 Refresh Token 提升安全性

LLM 原理 - 输入预处理

前言

最近在学习大模型的实现原理,为了更好地理解整个流程,我把学习中的关键概念和实现细节整理成了笔记。一方面帮助自己梳理思路、加深印象,另一方面也作为日后复习和查阅的基础。内容会按照模型的实际处理流程展开,希望能以清晰易懂的方式记录大模型的核心机制。

大模型原理概述

大模型最经典的架构图来自《Attention Is All You Need》,从这张图可以看到 Transformer 的基础结构是由“编码器”和“解码器”两部分组成的。虽然现在的大模型(像 GPT、LLaMA 这种)大多只保留了右侧的解码器结构,但它们整体的工作流程仍然遵循 Transformer 的思路。

整体原理可以简单理解成:

  1. 用户输入的文本会先经过 tokenizer 切成 token,再通过 embedding 转成向量。
  2. 这些向量会被送入 Transformer 的多层结构中处理。
    每一层都会做自注意力(Mulit-Head Attention,多头自注意力,让模型去关注上下文里的其他词)、前馈网络(Feed-Forward Network)、残差连接(Add)、层归一化(Norm)等操作,层数越多,模型对上下文的理解就越深。
  3. 最后一层会把处理后的向量经过线性变换,然后通过 softmax 得到一个概率分布。
    这个概率分布表示:“在所有 token 里,下一步最可能是哪个”。
  4. 模型会根据这个概率分布选出下一个 token(可能是选最高概率,也可能按概率采样)。
  5. 选出来的这个 token 会被加回当前输入,让模型继续推理下一个。
    模型就是这样不断循环:一步一步预测下一个 token,逐渐拼出完整的句子。
  6. 当所有 token 都生成完成后,再通过 tokenizer 解码,就得到了最终的可读文本。

整体来说,大模型的生成过程并不是一次性输出整段文本,而是每次只预测一个 token,然后把它接回去继续算,直到生成结束

输入预处理

输入预处理虽然在 Transformer 的架构图中只占了一小块,但如果把整个生成流程拆开来看,它其实是整个大模型的第一步,也是后面所有计算的基础。输入处理得好不好,直接影响到模型能不能正确理解你的话。

1. 训练阶段先要把词表准备好

在模型训练之前,会先收集海量的文本数据,然后训练一个 tokenizer。它的作用就是:

  • 把人类的自然语言拆成模型可接受的最小单位(叫 token)
  • 给每个 token 分配一个唯一的 token id
  • 形成一个固定的词表(vocab)

token 不一定是字,也不一定是词,更不是固定长度。现代 tokenizer 通常是“子词模式”,比如:

我 | 今天 | 吃 | 了 | 橙 | 子
happy → hap | py
unbelievable → un | believe | able

也就是说,词表中既可能有完整的词,也可能是词的一部分,这样可以极大减少词表大小,让模型处理能力更灵活。

2. 用户输入时,先把句子拆成 token

当用户输入一句话,比如:

我今天想吃火锅

模型不会直接拿这个句子处理,而是:

  • 按照训练好的 tokenizer 规则进行切分
  • 得到对应的 token 序列
  • 再查词表,把它们转成 token id

得到的结果类似这样的一个数组:

[123, 520, 11, 98, 8801]

也就是数字形式的 token 序列。

3. token id 需要转成向量(Embedding)

模型不能直接理解 token id,因为 token id 只是一个“编号”,不包含任何语义。所以下一步是通过 embedding table,把 token id 转换成对应的向量:

  • 每个 token 变成一个高维向量(例如 4096 维)
  • 所有 token 向量堆在一起就形成输入矩阵

向量的意义是:

让模型通过数字之间的关系来“理解”语言,比如相似的词向量更接近。

4. 生成位置 embedding 告诉模型位置顺序

Transformer 最大的特点是:

它的注意力机制没有顺序意识。

换句话说,如果没有额外的位置信息,它只知道有哪些 token,不知道谁在前、谁在后。

这会导致严重的问题,比如:

  • “我吃了橙子”
  • “橙子吃了我”

对模型来说,单看 token 本身完全一样,只是顺序不同,所以必须把位置告诉模型。

因此,模型会为每个 token 生成一个位置 embedding。

早期 Transformer 位置 embedding是正弦余弦序列,现代模型常用更先进的 RoPE(旋转位置编码)。但无论哪种方法,目的都是:

告诉模型“你现在看到的是第 1 个、第 2 个、第 3 个 token…”

5. token embedding 和 position embedding 合并

模型最终接收的是:

token 本身表达的含义(token embedding)
+ 
它在句子中的顺序(position embedding)

早期 Transformer 是直接做向量加法:

final_embedding = token_embedding + position_embedding

现代模型虽然底层机制更复杂(比如 RoPE 会作用到注意力的 Q、K 上),但整体来说:它们都是在让模型同时知道“词的语义”和“词的位置”。

这两个 embedding 合并之后,就是最终送入 Transformer Block 的输入。

6. 最终得到完整的输入矩阵

假设一句话拆成 10 个 token,每个 embedding 是 4096 维,那么模型的实际输入会是一个:

10 × 4096 的矩阵

这就是 Transformer 后面所有自注意力、多头机制和深层计算的起点。

总结一下

输入预处理的整个流程可以总结为:

把文本 → token → token id → token embedding → 加上位置 embedding → 得到最终的输入向量矩阵,送进 Transformer。

它解决了三件关键问题:

  1. 文本如何变成模型能算的数字
  2. 模型如何知道每个 token 的意思
  3. 模型如何知道 token 的顺序

当这三步都准备好了,Transformer 才真正进入“理解和生成”的阶段。

别让你那 5MB 的 JS 文件把用户吓跑:React 代码分割(Code Splitting)实战指南

前言:你的网页为什么像个吃撑了的胖子?

兄弟们,咱们先看一眼你们项目的 build 产物。 是不是有个 index.js 或者 main.js,体积高达 2MB、3MB 甚至更大?

这就好比你去餐厅吃饭,你只是想点一盘花生米(首屏登录页),结果服务员把后厨里所有的鱼翅燕窝鲍鱼(后台管理系统、富文本编辑器、Echarts 图表库)全部端上了桌,还把门堵住说:“不吃完不许走!”。

用户的 4G 信号在哭泣,手机 CPU 在发烫。 首屏加载时间(FCP)长达 5 秒,用户早就关掉页面去看抖音小姐姐了。

今天,我们要给你的 React 项目做个抽脂手术。我们要用到 Code Splitting(代码分割)Lazy Loading(懒加载),把那个巨大的 JS 文件切成无数个小块,只让用户加载他当前需要的东西


手术刀一:路由级别的“大卸八块”

绝大多数的 React 项目都是 SPA(单页应用)。 默认情况下,打包工具(Webpack/Vite)会把所有页面的代码打包进一个文件。 哪怕用户只访问首页,他也得下载“个人中心”、“设置”、“关于我们”的代码。

这是最大的浪费。

❌ 传统的梭哈写法(All in One):

import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// 💀 致命伤:静态引入。
// 只要 App.js 被加载,Dashboard 和 Settings 的代码也就跟着被下载了
import Home from './pages/Home';
import Dashboard from './pages/Dashboard';
import Settings from './pages/Settings';

const App = () => (
  <BrowserRouter>
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/dashboard" element={<Dashboard />} />
      <Route path="/settings" element={<Settings />} />
    </Routes>
  </BrowserRouter>
);

✅ 懒加载写法(按需取用):

我们要用 React.lazy 配合 import() 动态引入,再加个 Suspense 来处理加载过程中的空窗期。

import { BrowserRouter, Routes, Route } from 'react-router-dom';
import LoadingSpinner from './components/LoadingSpinner';

// ✨ 魔法在这里:动态引入
// 只有当路由匹配到 /dashboard 时,浏览器才会去下载 Dashboard.js
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

const App = () => (
  <BrowserRouter>
    {/* ⏳ Suspense 是必须的:在组件下载下来之前,先给用户看个转圈圈 */}
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  </BrowserRouter>
);

就改了这么几行代码,你的 main.js 体积可能瞬间减少 50% 以上。首屏速度直接起飞。

手术刀二:组件级别的“精细微雕”

切完路由就完事了吗? No No No。有些时候,同一个页面里也有巨大的胖子。

场景:你有一个“数据分析”页面,平时只展示列表。只有当用户点击“查看图表”按钮弹出一个 Modal 时,才需要渲染一个巨大的 ECharts 或者 Recharts 图表。 这玩意儿动不动就几百 KB。

如果用户根本不点那个按钮,这几百 KB 不就白下载了?

❌ 笨重写法:

// 💀 哪怕不渲染,import 进来了就会被打包
import HeavyChart from './components/HeavyChart'; 
import HeavyEditor from './components/HeavyEditor';

const AnalysisPage = () => {
  const [showChart, setShowChart] = useState(false);

  return (
    <div>
      <button onClick={() => setShowChart(true)}>看图</button>
      {/* 虽然条件渲染了,但代码早就下载好了 */}
      {showChart && <HeavyChart />}
    </div>
  );
};

✅ 极致懒人写法:


// ✨ 只有用到我的时候,才来喊我
const HeavyChart = lazy(() => import('./components/HeavyChart'));

const AnalysisPage = () => {
  const [showChart, setShowChart] = useState(false);

  return (
    <div>
      <button onClick={() => setShowChart(true)}>看图</button>
      
      {showChart && (
        <Suspense fallback={<div>图表加载中...</div>}>
          <HeavyChart />
        </Suspense>
      )}
    </div>
  );
};

注意:别切得太碎了(避坑指南)

听到切代码能优化性能,有些兄弟兴奋了,拿起刀就是一顿乱切。 把 ButtonIconText 全部懒加载。

千万别!

  1. HTTP 请求开销:每个 lazy 组件都会发起一个新的网络请求。如果你把一个 1KB 的按钮切出来,光是 HTTP 握手的时间都比下载它的时间长。
  2. 闪屏体验:如果页面全是 Suspense,用户一进来看到满屏的 Loading 转圈,体验比白屏还差。

切割原则

  • 按路由切:这是必须的。
  • 按“重型组件”切:富文本编辑器、图表库、3D 模型渲染、地图组件。
  • 按“交互后展示”切:弹窗(Modal)、侧边栏(Drawer)、折叠面板(Collapse)。

进阶技巧:预加载(Preload)—— 预判你的预判

懒加载有一个小缺点:用户点击的时候才开始下载,会有几百毫秒的延迟。 如果要在性能和体验之间求极致,我们可以玩预加载

比如:用户鼠标悬停在“查看图表”按钮上时,我们猜他大概率要点击了,这时候偷偷开始下载。


// 或者写个简单的函数
const prefetchChart = () => {
  const component = import('./components/HeavyChart');
};

<button 
  onMouseEnter={prefetchChart} // 鼠标放上去就开始下
  onClick={() => setShowChart(true)}
>
  看图
</button>

总结

现在的打包工具(Vite/Webpack)已经非常智能了,但它们不懂你的业务。它们不知道哪个页面是核心,哪个组件是冷门。

Code Splitting 就是把你对业务的理解告诉工具: “这个首页要最快速度出来!” “那个富文本编辑器,等用户真要写文章了再去加载!”

把你的应用从“一块大石头”变成“一堆小积木”,按需拿取。这才是现代前端工程化的精髓。

好了,我要去把那个引入了整个 lodash 却只用了一个 debounce 函数的屎山代码给优化了。


下期预告:你还在用 console.log 调试代码吗?你还在面对满屏的红字不知所措吗? 下一篇,我们要聊聊 “React 调试神技” 。带你深入 React DevTools,看看那些你从未点过的按钮,是如何让你像 X 光一样看穿组件的。

Rust入门系列(三):生命周期 - 编译器的"算命先生"

大家好,我是土豆,欢迎关注我的公众号:土豆学前端

前情回顾:在前两篇文章中,我们学习了Rust的所有权与借用机制,以及Copy、Clone、Send、Sync等trait。今天,我们要探索Rust中最让初学者"闻风丧胆"的概念——生命周期(Lifetime)。

Why - 为什么需要生命周期?

场景重现:悬垂引用的噩梦

想象你在一家图书馆借书。你拿到一张借书卡(引用),兴高采烈地准备去书架找书。结果走到半路,图书管理员突然把那本书给烧了(数据被释放)。你拿着借书卡傻眼了——这不是空指针吗?

fn main() {
    let r;
    {
        let x = 5;
        r = &x;  // 编译器:停!x马上要死了,你不能引用它!
    }  // x的生命在此结束
    println!("r: {}", r);  // 💥 悬垂引用!
}

在C/C++中,这段代码会编译通过,然后在运行时给你一个"惊喜"。但Rust编译器会直接拒绝编译:

error[E0597]: `x` does not live long enough

这就是生命周期存在的意义:在编译期就确保所有引用都是有效的,彻底消除悬垂引用、野指针等内存安全问题。

借用检查器的困惑

考虑这个看似简单的函数:

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

编译器看到这段代码会陷入沉思:

  • 返回值是一个引用,但我不知道它来自x还是y
  • 如果来自x,那返回值的生命周期应该跟x一样
  • 如果来自y,那返回值的生命周期应该跟y一样
  • 但我不能在编译时确定会走哪个分支...

编译器:我太难了😭

这时就需要我们显式地告诉编译器生命周期关系。

What - 生命周期到底是什么?

生命周期的本质

生命周期不是什么玄学,它就是引用保持有效的作用域范围。可以把它想象成:

  • 生命周期标注('a'b等):像给引用贴上有效期标签
  • 借用检查器:像一个严格的质检员,确保没有过期引用被使用
fn main() {
    let string1 = String::from("long string");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
        // result的生命周期不能超过string2
    }  // string2在这里结束
    // println!("{}", result);  // 💥 string2已经不在了!
}

生命周期标注语法

&i32        // 普通引用
&'a i32     // 带生命周期标注的引用
&'a mut i32 // 带生命周期标注的可变引用

'a读作"tick a",就像给引用贴了个标签:"嘿,我的有效期是'a"。

How - 如何正确使用生命周期?

1. 函数中的生命周期标注

回到之前的longest函数:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

这个标注告诉编译器:

  • xy都在生命周期'a内有效
  • 返回值也在生命周期'a内有效
  • 实际上'axy生命周期的交集(较短的那个)

用大白话说就是:"返回值的有效期不会超过两个参数中最短的那个"。

fn main() {
    let string1 = String::from("long string is long");
    
    {
        let string2 = String::from("xyz");
        let result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {}", result);
        // ✅ 在string2的作用域内使用result,完全OK
    }
}

2. 生命周期省略规则

好消息!大多数情况下不需要手动标注生命周期,编译器会自动推导。这得益于三条生命周期省略规则:

规则1: 每个引用参数都有自己的生命周期

// 你写的
fn first_word(s: &str) -> &str {

// 编译器理解的
fn first_word<'a>(s: &'a str) -> &str {

规则2: 如果只有一个输入生命周期参数,它被赋予所有输出生命周期

// 你写的
fn first_word(s: &str) -> &str {

// 编译器理解的
fn first_word<'a>(s: &'a str) -> &'a str {

规则3: 如果有多个输入生命周期参数,但其中一个是&self&mut self,那么self的生命周期被赋予所有输出生命周期

impl<'a> ImportantExcerpt<'a> {
    // 你写的
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention: {}", announcement);
        self.part
    }
    
    // 编译器理解的(self的生命周期赋给返回值)
    fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &'a str {
        println!("Attention: {}", announcement);
        self.part
    }
}

3. 结构体中的生命周期

结构体中包含引用时,必须标注生命周期:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().expect("Could not find a '.'");
    let excerpt = ImportantExcerpt {
        part: first_sentence,
    };
    // excerpt的生命周期不能超过novel
}

这个标注意味着:ImportantExcerpt的实例不能比它引用的part活得更久。

4. 多个生命周期参数

有时需要不同的生命周期参数:

fn announce<'a, 'b>(x: &'a str, y: &'b str) -> &'a str {
    println!("Announcement: {}", y);
    x  // 只返回x,所以返回值生命周期只跟'a关联
}

5. 静态生命周期 'static

'static是一个特殊的生命周期,表示"活到程序结束":

let s: &'static str = "I have a static lifetime.";
// 字符串字面量存储在程序的二进制文件中,永远有效

注意:不要滥用'static!看到生命周期错误就加'static是新手常犯的错误。

实战演练:常见模式

模式1: 返回引用

// ❌ 错误:返回局部变量的引用
fn dangle() -> &str {
    let s = String::from("hello");
    &s
}  // s在这里被释放,返回悬垂引用

// ✅ 正确:返回所有权
fn no_dangle() -> String {
    let s = String::from("hello");
    s
}

模式2: 结构体方法

struct Book<'a> {
    title: &'a str,
    author: &'a str,
}

impl<'a> Book<'a> {
    fn new(title: &'a str, author: &'a str) -> Self {
        Book { title, author }
    }
    
    fn get_title(&self) -> &str {
        // 省略了生命周期,编译器自动推导为&'a str
        self.title
    }
}

模式3: 生命周期边界

结合泛型使用:

use std::fmt::Display;

fn longest_with_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,
{
    println!("Announcement: {}", ann);
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

避坑指南

坑1: 过度使用 'static

// ❌ 错误思路
fn bad_fix<'a>(x: &'a str) -> &'static str {
    x  // 💥 生命周期不匹配!
}

// ✅ 正确思路
fn good_fix<'a>(x: &'a str) -> &'a str {
    x
}

坑2: 混淆生命周期和作用域

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {}", r); //          |
}                         // ---------+

'b'a短,所以r不能引用x

坑3: 结构体的自引用

// ❌ 这个不能直接编译
struct SelfRef<'a> {
    value: String,
    pointer: &'a String,  // 想引用自己的value
}

自引用需要使用Pin等高级技巧,初学者建议避免。

生命周期的哲学思考

生命周期本质上是所有权系统的延伸:

  • 所有权:确保资源有且只有一个主人
  • 借用:允许临时访问资源
  • 生命周期:确保借用在资源有效期内

它们共同构成了Rust内存安全的铁三角。

小结

生命周期是Rust的"杀手锏",也是初学者的"拦路虎"。但记住:

  1. 生命周期是编译期概念,运行时没有性能开销
  2. 大多数情况不需要手动标注,感谢生命周期省略规则
  3. 编译器错误是你的朋友,它阻止你犯错
  4. 实践是最好的老师,多写多改就能掌握

当你习惯了生命周期,你会发现它就像一位严格但负责的老师——虽然严厉,但确实让你写出更安全的代码。

下一篇我们将探索Rust的错误处理机制——如何优雅地处理ResultOption。Stay tuned!


练习题:试着理解并修复以下代码的生命周期问题

fn main() {
    let string1 = String::from("abcd");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    }
    println!("The longest string is {}", result);
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

提示:问题出在哪里?如何调整代码结构来修复?

AI First + Mobile First:用大模型重构下一代应用开发范式

在技术演进的浪潮中,我们正站在一个关键拐点上:AI 不再只是“辅助工具”,而是成为应用的核心驱动力。与此同时,移动设备早已超越 PC,成为用户与数字世界交互的第一入口。如何将 AI FirstMobile First 的理念深度融合,打造真正智能、高效、普惠的新一代应用?本文将从实践出发,结合真实代码案例,探讨一条可落地的技术路径。


一、什么是 AI First?

“AI First” 并非口号,而是一种产品设计哲学:以大语言模型(LLM)为核心引擎,重构用户交互逻辑和系统架构

场景示例:点一杯奶茶

想象这样一个场景:

“豆包,帮我点杯少糖热奶茶,在美团、抖音、淘宝上比价,用上优惠券,选最便宜的那家下单。”

这背后涉及:

  • 多平台商品信息抓取
  • 价格与优惠策略计算
  • 用户偏好理解(少糖、热饮)
  • 自动化下单流程

传统方式需要分别调用各平台 API、维护复杂的业务规则。而在 AI Agent 架构下,LLM 作为“调度中枢”,通过自然语言理解用户意图,动态调用工具链(Tool Calling),实现端到端自动化。

这就是 AI Native 应用的雏形——用户只需表达“做什么”,系统自动完成“怎么做”。


二、让 LLM 理解你的数据库:Text-to-SQL 的实战突破

要让 AI 操作业务数据,关键一步是 打通自然语言与结构化数据的鸿沟。Text-to-SQL 正是这一桥梁。

实战:用 DeepSeek 生成 SQL 查询

我们以一个员工管理系统为例:

# 表结构
CREATE TABLE EMPLOYEES (
    id INTEGER
    name TEXT
    department TEXT
    salary INTEGER
)

当用户问:“工程部门员工的姓名和工资是多少?

我们将表结构(Schema)作为上下文注入 Prompt:

这是一个数据库的Schema:
CREATE TABLE EMPLOYEES (
    id INTEGER
    name TEXT
    department TEXT
    salary INTEGER
)
根据这个Schema,请输出一个SQL查询来回答以下问题。
只输出SQL查询语句本身……
问题:工程部门员工的姓名和工资是多少

LLM 返回:

SELECT name, salary FROM employees WHERE department = '工程';

执行后得到结果:

[('宁宁', 75000), ('悦悦', 80000), ('呆鱼', 80000)]

更惊人的是,它还能处理 增删改 操作:

  • “在销售部门增加一个新员工,姓名为张三,工资为45000”
    INSERT INTO employees (name, department, salary) VALUES ('张三', '销售', 45000);
  • “删除市场部门的黄仁勋”
    DELETE FROM employees WHERE name = '黄仁勋' AND department = '市场';

这意味着:非技术人员也能安全地操作数据库。后台管理不再局限于程序员,运营、产品、小编均可参与——这就是“数据库平权”。


三、Mobile First:不是适配,而是优先

“Mobile First” 常被误解为“先做移动端,再适配 PC”。但真正的 Mobile First 是:

  • 以触控、小屏、弱网、碎片化使用场景为设计起点
  • 利用移动端特性(摄像头、GPS、通知、生物识别)构建核心体验
  • PC 端仅作为补充(如报表查看、批量操作)

技术实践建议:

  • 使用 CSS @media 实现响应式布局,但默认样式按手机设计
  • 小程序/App 承载 80% 功能,PC Web 仅保留 20% 高效操作
  • 结合 PWA 实现“类原生”体验,降低安装门槛

在 AI 赋能下,移动端还可集成语音输入、图像识别(如拍菜单点单),进一步降低交互成本。


四、生态支撑:ModelScope 与开源模型

阿里云的 ModelScope(魔搭) 为开发者提供了强大基础设施:

  • 大模型市场:一键部署 Qwen、DeepSeek 等开源模型
  • 数据集与微调工具:针对垂直领域(如电商、医疗)定制 LLM
  • Notebook 环境:快速实验 Text-to-SQL、Agent 等能力

例如,通过 ModelScope 微调一个“奶茶点单专用模型”,可显著提升对“少糖去冰加布丁”等口语化指令的理解准确率。


五、未来已来:AI + Mobile = 新操作系统

当 LLM 能理解用户意图、操作应用、调用服务、修改数据,传统的 App 界面可能不再是必需品

未来的交互可能是:

  • 语音/文字 → AI Agent → 自动完成任务
  • 用户只关心结果,不关心过程

而移动端,因其随身性、传感器丰富性、推送能力,将成为 AI Agent 的最佳载体。

我们正在从“人适应软件”走向“软件适应人”。


结语:开发者的新角色

在 AI First 时代,开发者不再是“功能实现者”,而是:

  • Prompt 工程师:设计高质量上下文与指令
  • Agent 架构师:编排工具链与安全边界
  • 体验设计师:在自然语言交互中创造流畅感

拥抱变化,从今天开始:
让你的下一个项目,先问一句——“AI 能怎么帮用户做得更好?”

前端er Go-Frame 的学习笔记:实现 to-do 功能(二),前端项目的开发,对接后端

效果

在这里插入图片描述

在这里插入图片描述

相关

前端er Go-Frame 的学习笔记:实现 to-do 功能(一)


目标

上一章已经把后端实现了大概的功能,目前写一下前端,在构建的过程中可能要改改后端不合理的地方,比如:

  • 请求的资源应该是复数,所以要修改后端的路由 todo => todos
  • 先把前端的页面画出来,alova 的使用要学习一下
  • 乐观更新是什么?

前端

前端的话,我想看一下 Alova 的使用方法,然后想一下如何简化前端,只用写资源名字,即可做到增删改查, 技术栈的话,选择 React 19 + Antd 6 + Alova


搭建前端基础代码

在项目根目录下,用 vite 的脚手架来搭建项目

pnpm create vite

然后输入项目名,选择框架等等 在这里插入图片描述 搭建好基础之后,把 antdalova 安装一下

在这里插入图片描述 再安装一些 eslint, antfu-eslint,具体的使用配置可以看这个:github.com/antfu/eslin…

pnpm i -D eslint @antfu/eslint-config

然后写一下 eslint-config.js

import { antfu } from '@antfu/eslint-config'

// 第一个对象是基础配置(你没写东西)
// 第二个对象是覆盖 antfu 内置规则
export default antfu({

}, {
    rules: {
        'no-console': 'off',                           // 允许使用 console.log,不再警告
        'prefer-const': 'off',                         // 允许使用 let,不强制要求用 const
        'ts/ban-ts-comment': 'off',                    // 允许使用 @ts-ignore / @ts-nocheck 等注释
        'no-case-declarations': 'off',                 // 允许在 switch/case 里直接写 const/let
        'ts/no-use-before-define': 'off',              // 允许变量或函数在定义前被使用
        'ts/no-unused-expressions': 'off',             // 允许类似条件 && 表达式的写法
        'ts/no-empty-object-type': 'off',              // 允许定义空对象类型 type A = {}
        'ts/no-unsafe-function-type': 'off',           // 允许使用 any 函数签名 (...args: any[]) => any
        'ts/consistent-type-definitions': 'off',       // 不强制只能用 type 或 interface,随便写
        'style/indent': ['error', 4],                  // 强制使用 4 空格缩进
        'style/jsx-indent-props': ['error', 4],        // JSX 属性缩进也是 4 空格
        'prefer-promise-reject-errors': 'off',         // 允许 reject('xxx'),不强制必须 new Error()
        'eslint-comments/no-unlimited-disable': 'off', // 允许写 /* eslint-disable */ 禁用所有规则
    },
})

ok,依赖安装完之后,来看看这个空的项目,配置一下环境,以及 vite 的代理 proxy,和 tsconfig.json

  • 配置 tsconfig.json,用于改变 ide 代码编辑器的配置,比方说 @ 这种 alias
  • 配置 vite.config.json 中的 @ 用于打包时候的描述,以及 proxy 代理请求后端接口

配置 tsconfig.json

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述


后端:统一接口为 v1 版本,路由分组

还记的写后端时遇到的疑问吗 goframe.org/quick/scaff…,为什么用 /api/v1 当借口的前缀

目前后端是没有接口路由分组的,所以来改一下后端,让前端以后通过 /api/v1/todo 来访问

在这里插入图片描述


前端:引入 alova,配置实例

前端的话,要改这几个文件

  • Alova 的实例的 baseUrl 改一下
  • .env 文件也改改

这些就算是前端的配置了

在这里插入图片描述

// api/alova.ts
import { createAlova } from 'alova'
import adapterFetch from 'alova/fetch'
import ReactHook from 'alova/react'

export const alovaInstance = createAlova({
    baseURL: '/api/v1', // 这里会自动拼接到每个接口的前面的
    requestAdapter: adapterFetch(),
    responded: response => response.json(),
    statesHook: ReactHook, // 如果写 react 的话,要引入这个,不然白屏
})

在这里插入图片描述

然后写一下页面

// 页面 todo.tsx
import { useRequest } from 'alova/client'
import { Button } from 'antd'
import { alovaInstance } from '@/api/alova'

export function PageTodo() {
    const { data } = useRequest(
        alovaInstance.Get('/todo'),
    )
    console.log('data:>>', data)
    return (
        <>
            <div>
                这是 todo 的页面
            </div>
            <Button>点我123312</Button>
        </>
    )
}

然后再浏览器中试一下,可以看到前端能请求到接口了!

这里其实并没有那么顺利 当我没有改后端的 /api/v1 分组之前,前端是请求不到后端的,我以为是后端没有配置跨域,(后来我也没去配置 go-frame 的跨域,因为不是这个问题) 是因为什么呢,就是 vite 的代理,我以为前端请求的 /api/v1/todo,就会走代理,之后代理会把 api/v1 给去掉,但是这个想法是错误的 在这里插入图片描述

在这里插入代码片


前端页面功能的实现

我准备把 tailwindcss 也安装一下,具体怎么安装可以看一下这个 https://tailwindcss.com/docs/installation/using-vite

然后让 AI 先写一个简单的页面

在这里插入图片描述

之后把其他功能实现一下

在这里插入图片描述

不过我看每次更新之后命中了 alova 的缓存了,导致刷新之后页面的数据没有更新(但是数据库已经更改了)

在这里插入图片描述

查了一下官方文档, 这里有个强制请求,我可以在 Alova 中配置一下这个接口

在这里插入图片描述

在这里插入图片描述


源仓库

github.com/Lovely-Ruby…

接下来准备把这两个项目放到 Docker

el-button源码解读4——props color和native-type

  <component
    :is="tag"
    ref="_ref"
    v-bind="_props"
    :class="buttonKls"
    :style="buttonStyle"
    @click="handleClick"
  >

:style="buttonStyle":用于在设置了 color 时,自动计算并应用按钮各状态(默认、悬停、激活、禁用)的颜色样式,无需手动设置每个状态的颜色。

const buttonStyle = useButtonCustomStyle(props)
/**
 * 获取实例中props为name的值
 */
export const useProp = <T>(name: string): ComputedRef<T | undefined> => {
  const vm = getCurrentInstance()
  return computed(() => (vm?.proxy?.$props as any)?.[name])
}


/**
 * 获取表单的disabled状态
 * @param fallback 默认值
 * @returns 表单的disabled状态
 */
export const useFormDisabled = (fallback?: MaybeRef<boolean | undefined>) => {
  const disabled = useProp<boolean>('disabled')
  const form = inject(formContextKey, undefined)
  // 如果是表单内部的button那么是有值的,如果是外部的button那么是undefined
  console.log('form', form)
  /**
   * 组件自身的 disabled prop 
      ↓ (如果没有)
      传入的 fallback 参数
      ↓ (如果没有)
      表单的 disabled 状态
      ↓ (如果没有)
      默认值 false
   */
  return computed(
    () => disabled.value || unref(fallback) || form?.disabled || false
  )
}

/**
 * 获取按钮自定义样式
 * @param props 
 * @returns 
 */
export function useButtonCustomStyle(props: ButtonProps) {
  // 获取按钮的disabled状态
  const _disabled = useFormDisabled()
  // 获取按钮的命名空间
  const ns = useNamespace('button')

  // calculate hover & active color by custom color
  // only work when custom color
  return computed(() => {
    let styles: Record<string, string> = {}

    let buttonColor = props.color

    if (buttonColor) {
      // 检测buttonColor是否为CSS变量格式 ,并提取变量名 如 var(--el-color-primary)
      const match = (buttonColor as string).match(/var\((.*?)\)/)
      if (match) {
        buttonColor = window
          .getComputedStyle(window.document.documentElement)
          .getPropertyValue(match[1])
      }
      // TinyColor: Fast, small color manipulation and conversion for JavaScript
      const color = new TinyColor(buttonColor)
      console.log('color', color)
      // tint - 变亮(添加白色)变亮20%
      // darken - 变暗(添加黑色)变暗20%
      const activeBgColor = props.dark
        ? color.tint(20).toString()
        : darken(color, 20)

      if (props.plain) {
        styles = ns.cssVarBlock({
          'bg-color': props.dark
            ? darken(color, 90)
            : color.tint(90).toString(),
          'text-color': buttonColor,
          'border-color': props.dark
            ? darken(color, 50)
            : color.tint(50).toString(),
          'hover-text-color': `var(${ns.cssVarName('color-white')})`,
          'hover-bg-color': buttonColor,
          'hover-border-color': buttonColor,
          'active-bg-color': activeBgColor,
          'active-text-color': `var(${ns.cssVarName('color-white')})`,
          'active-border-color': activeBgColor,
        })

        if (_disabled.value) {
          styles[ns.cssVarBlockName('disabled-bg-color')] = props.dark
            ? darken(color, 90)
            : color.tint(90).toString()
          styles[ns.cssVarBlockName('disabled-text-color')] = props.dark
            ? darken(color, 50)
            : color.tint(50).toString()
          styles[ns.cssVarBlockName('disabled-border-color')] = props.dark
            ? darken(color, 80)
            : color.tint(80).toString()
        }
      } else {
        const hoverBgColor = props.dark
          ? darken(color, 30)
          : color.tint(30).toString()
        const textColor = color.isDark()
          ? `var(${ns.cssVarName('color-white')})`
          : `var(${ns.cssVarName('color-black')})`
        styles = ns.cssVarBlock({
          'bg-color': buttonColor,
          'text-color': textColor,
          'border-color': buttonColor,
          'hover-bg-color': hoverBgColor,
          'hover-text-color': textColor,
          'hover-border-color': hoverBgColor,
          'active-bg-color': activeBgColor,
          'active-border-color': activeBgColor,
        })

        if (_disabled.value) {
          const disabledButtonColor = props.dark
            ? darken(color, 50)
            : color.tint(50).toString()
          styles[ns.cssVarBlockName('disabled-bg-color')] = disabledButtonColor
          styles[ns.cssVarBlockName('disabled-text-color')] = props.dark
            ? 'rgba(255, 255, 255, 0.5)'
            : `var(${ns.cssVarName('color-white')})`
          styles[ns.cssVarBlockName('disabled-border-color')] =
            disabledButtonColor
        }
      }
    }

    return styles
  })
}


==========================================

props:native-type
export const buttonNativeTypes = ['button', 'submit', 'reset'] as const

props:
  /**
   * @description native button type
   */
  nativeType: {
    type: String,
    values: buttonNativeTypes,
    default: 'button',
  },  

CSS的clamp()函数:一行代码让网页自适应如此简单

在前端开发中,实现响应式设计一直是个挑战。今天介绍一个能够大大简化响应式开发的CSS函数——clamp()

什么是clamp()?

简单来说,clamp()就像给CSS值设置了一个安全范围:无论屏幕怎么变化,这个值都不会超出你设定的最小和最大边界。

基本语法:

clamp(最小值, 理想值, 最大值)

假设调节空调温度:你设定了最低18℃、最高26℃,理想温度24℃。无论外面多热多冷,室内温度都会在这个舒适范围内——这就是clamp()的工作原理。

为什么需要clamp()?

传统方式的痛点

以前我们要实现响应式文字大小,得这样写:

.title {
  font-size: 16px;
}

@media (min-width: 768px) {
  .title {
    font-size: 18px;
  }
}

@media (min-width: 1024px) {
  .title {
    font-size: 20px;
  }
}

问题:

  • 要写很多媒体查询
  • 尺寸在断点处突然跳跃,不够平滑
  • 维护困难,改个尺寸要到处找

clamp()的解决方案

clamp()只需要一行:

.title {
  font-size: clamp(16px, 4vw, 20px);
}

意思是:

  • 最小不会小于16px
  • 最大不会超过20px
  • 理想大小是视口宽度的4%

案例

1. 响应式文字大小(最常用)

/* 标题响应式系统 */
h1 {
  font-size: clamp(2rem, 5vw, 4rem);
  /* 手机上看是2rem,平板上逐渐变大,桌面端最大到4rem */
}

h2 {
  font-size: clamp(1.5rem, 4vw, 3rem);
}

p {
  font-size: clamp(1rem, 2.5vw, 1.25rem);
}

效果对比:

  • 手机(375px宽):h1 ≈ 2.5rem
  • 平板(768px宽):h1 ≈ 3.2rem
  • 桌面(1200px宽):h1 = 4rem

2. 容器宽度

.container {
  width: clamp(300px, 80%, 1200px);
  /* 最小300px,最大1200px,平时占父元素80%宽度 */
}

.card {
  width: clamp(280px, 90vw, 400px);
  /* 在小屏幕上几乎全宽,大屏幕上固定400px */
}

3. 灵活的间距控制

.section {
  padding: clamp(1rem, 5%, 3rem);
  /* 内边距在1rem到3rem之间,平时是父元素宽度的5% */
  
  margin-bottom: clamp(2rem, 8vh, 5rem);
  /* 下边距在2rem到5rem之间,与视口高度相关 */
}

4. 图片自适应

.hero-image {
  width: clamp(300px, 60vw, 800px);
  height: clamp(200px, 40vh, 500px);
  /* 宽高都随视口变化,但有合理的限制 */
}

更多案例

打造完美响应式卡片

.product-card {
  width: clamp(280px, 30vw, 350px);
  padding: clamp(1rem, 3vw, 2rem);
  border-radius: clamp(0.5rem, 1.5vw, 1rem);
  gap: clamp(0.75rem, 2vw, 1.5rem);
}

.product-card h3 {
  font-size: clamp(1.25rem, 3vw, 1.75rem);
}

.product-card .price {
  font-size: clamp(1.1rem, 2.5vw, 1.5rem);
}

现代化导航栏

.navbar {
  height: clamp(60px, 10vh, 80px);
  padding: 0 clamp(1rem, 5vw, 4rem);
}

.nav-links {
  gap: clamp(1rem, 3vw, 3rem);
  font-size: clamp(0.9rem, 2vw, 1.1rem);
}

为什么clamp()如此强大?

1. 代码量大幅减少

原来需要几十行的媒体查询,现在可能只需要几行clamp()

2. 真正的流体响应

尺寸在不同屏幕间平滑过渡,没有突兀的跳跃感。

3. 更好的用户体验

元素尺寸根据视口智能调整,在任何设备上都有良好的可读性。

4. 维护性极佳

修改响应式行为只需要调整一个clamp()值。

注意事项

选择合适的单位

  • 相对单位:vw、vh、%、rem(推荐)
  • 绝对单位:px(用于设置明确的边界)

合理设置范围

/* 推荐:合理的范围设置 */
good-example {
  font-size: clamp(1rem, 2.5vw, 2rem);
}

/* 不推荐:范围设置不合理 */
bad-example {
  font-size: clamp(0.5rem, 2.5vw, 5rem); /* 最小和最大差距太大 */
}

浏览器兼容性

  • 现代浏览器(Chrome、Firefox、Safari、Edge)都支持
  • 如果需要支持老浏览器,记得准备回退方案:
.title {
  font-size: 18px; /* 回退值 */
  font-size: clamp(16px, 4vw, 20px);
}

什么时候不该用clamp()?

虽然clamp()很强大,但并不是万能的:

  1. 需要精确断点控制时——媒体查询更合适
  2. 复杂的布局变化——比如移动端和桌面端完全不同的布局
  3. 性能敏感的场景——复杂的计算可能影响性能

总结

clamp()是CSS中一个革命性的功能,它让我们能够用更少的代码实现更流畅的响应式设计。特别适合:

  • 字体大小响应式
  • 间距和内边距
  • 容器尺寸限制
  • 图片和媒体元素

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《SpringBoot+MySQL+Vue实现文件共享系统》

《这20条SQL优化方案,让你的数据库查询速度提升10倍》

《SpringBoot 动态菜单权限系统设计的企业级解决方案》

《Vue3和Vue2的核心区别?很多开发者都没完全搞懂的10个细节》

玩转小程序AR-基础篇

背景

某天需求方扔了个链接:【原神官方】AR 立牌,询问小程序是否能够实现类似的AR效果。

作为打工的人的我,于是被迫补起:早已过了风口(bushi)的AR知识。

调研

实体 AR 立牌

巨资在闲鱼淘了个多莉的角色立牌,实际体验了下完整的交互流程,AR的氛围感还是不错的。

ar

AR在线制作平台

KIVICUBE 提供了可视化的3D场景搭建和动画编排系统:零基础也可创造和发布酷炫的AR作品。

KIVICUBE

KIVICUBE

官方同时提供了大量创意模版供大家一键使用

KIVICUBE

技术方案

在微信小程序的技术体系下,官方提供了两种解决方案:

小程序也在基础库 2.20.0 版本开始提供了开发 AR 功能的能力,即 VisionKit。VisionKit 包含了 AR 在内的视觉算法

xr-frame是一套小程序官方提供的XR/3D应用解决方案,基于混合方案实现,性能逼近原生、效果好、易用、强扩展、渐进式、遵循小程序开发标准

简单来说:

  • VisionKit 是底层视觉引擎,提供能力但需要自己拼装。
  • XR-FRAME 是完整开发框架,整合了渲染、交互与视觉能力,适合快速实现产品化的 AR 效果。

虽然理论上我们完全可以基于 VisionKit 并结合 Three.js 或自研的 WebGL 渲染逻辑来实现 AR 效果,但这种方案开发成本较高:

开发者需要自行处理相机帧与渲染管线的衔接、追踪状态管理、姿态矩阵更新等底层逻辑,整体工程量大且维护复杂。(Kivicube 正是使用此方案,确保自研的 AR 可视化搭建平台的产物能兼容各大平台:微信小程序、原生网页H5)

而 XR-FRAME 在此基础上进一步封装了完整的 3D 渲染与交互体系,提供了大量开箱即用的能力,让开发者可以像写前端组件一样,以声明式方式快速构建 AR 场景,而不必深入到底层渲染细节。

因此最后决定:先用 XR-FRAME 快速交付第一版 MVP 小程序

XR-FRAME 指南

由于是小程序开发,所以学习 XR-FRAME 的前提,需要有小程序基础知识

而官方的两个在线文档,内容还是略显单薄,强烈建议直接参考官方 《xr-frame系统的示例集》源码进行实战学习

本教程配套代码:xr-frame-tutorial

XR 组件 模版代码

创建 XR 组件

1.png

{
  "component": true,
  "usingComponents": {},
  "renderer": "xr-frame"
}
<xr-scene>
  <xr-camera clear-color="0.4 0.8 0.6 1" />
</xr-scene>

使用 XR 组件

2.png

{
  "usingComponents": {
    "demo1": "../../components/demo1"
  },
  "disableScroll": true, 
  "renderer": "webview"
}
<view>
  <demo1
    disable-scroll
    id="main-frame"
    width="{{renderWidth}}"
    height="{{renderHeight}}"
    style="width:{{width}}px;height:{{height}}px;top:{{top}}px;left:{{left}}px;"
  />
</view>
Page({
  data: {
    left: 0,
    top: 0,
    width: 0,
    height: 0,
    renderWidth: 0,
    renderHeight: 0,
  },
  onLoad(options) {
    const info = wx.getSystemInfoSync()
    const width = info.windowWidth
    const height = info.windowHeight
    const dpi = info.pixelRatio

    this.setData({
      width, 
      height,
      renderWidth: width * dpi,
      renderHeight: height * dpi
    });
  },
})

渲染 XR 组件

3.png

渲染效果:绿色背景的画布 Canvas

4.png

3D 渲染

源码示例: 3D 渲染

最基础的 3D 渲染 思维导图

whiteboard_exported_image.png

添加物体

添加一个 立方体 (Cube)物体

<xr-scene>
  <xr-mesh node-id="cube" geometry="cube" />
  <!-- 设置 target 指向 cube,让相机始终看向这个立方体--> 
  <xr-camera clear-color="0.4 0.8 0.6 1" position="1 3 4" target="cube" camera-orbit-control />
</xr-scene>

物体黑色是因为在我们没有给xr-mesh指定材质时,用的是基于PBR效果的默认材质,需要光照

临时解决方案: 创建一个不需要光照的材质,并且使用这个材质

<xr-scene>
  <xr-asset-material asset-id="simple" effect="simple" uniforms="u_baseColorFactor:0.8 0.4 0.4 1" />
  <xr-mesh node-id="cube" geometry="cube" material="simple" />
  <xr-camera clear-color="0.4 0.8 0.6 1" position="1 3 4" target="cube" camera-orbit-control />
</xr-scene>

5.png

添加光照

<xr-scene>
  <xr-mesh node-id="cube" geometry="cube" uniforms="u_baseColorFactor:0.8 0.4 0.4 1" />
  <!-- 一个环境光 -->
  <xr-light type="ambient" color="1 1 1" intensity="1" />
  <!-- 一个主平行光 -->
  <xr-light type="directional" rotation="40 70 0" color="1 1 1" intensity="3" cast-shadow /> 
  <xr-camera clear-color="0.4 0.8 0.6 1" position="1 3 4" target="cube" camera-orbit-control />
</xr-scene>

6.png

添加纹理

纹理 Texture 是 GPU 中的图像,供着色器采样使用。在框架中其一般被作为材质的一部分uniforms使用

<xr-scene>
  <xr-assets bind:progress="handleAssetsProgress" bind:loaded="handleAssetsLoaded">
    <!-- 加载纹理 -->
    <xr-asset-load type="texture" asset-id="waifu" src="https://mmbizwxaminiprogram-1258344707.cos.ap-guangzhou.myqcloud.com/xr-frame/demo/waifu.png" />
    <!-- 材质中使用纹理 -->
    <xr-asset-material asset-id="mat" uniforms="u_baseColorMap: waifu" />
  </xr-assets>

  <!-- 使用材质 -->
  <xr-mesh node-id="cube" geometry="cube" material="mat" cast-shadow />
  
  <!-- 一个环境光 -->
  <xr-light type="ambient" color="1 1 1" intensity="1" />
  <!-- 一个主平行光 -->
  <xr-light type="directional" rotation="40 70 0" color="1 1 1" intensity="3" cast-shadow /> 
  <xr-camera clear-color="0.4 0.8 0.6 1" position="1 3 4" target="cube" camera-orbit-control />
</xr-scene>

7.png

我们继续新加一个 平面 (Plane) 物体

<xr-assets bind:progress="handleAssetsProgress" bind:loaded="handleAssetsLoaded">
    <xr-asset-load type="texture" asset-id="deepred" src="https://avatars.githubusercontent.com/u/13102815" />
    <xr-asset-material asset-id="mat2" uniforms="u_baseColorMap: deepred" />
</xr-assets>

<xr-mesh node-id="plane" geometry="plane" material="mat2" position="0 1 0" />

screenshot-20251111-001750.png

添加动画

{
  "setting": {
    "ignoreDevUnusedFiles": false,
    "ignoreUploadUnusedFiles": false
  }
}

8.png

添加一个无限旋转的动画

  • keyframe 中定义了帧动画具体的行为,本质上类似于 CSS 中的 keyframes
  • animation 中定义了帧动画的播放配置,本质上类似于 CSS 中的 animation

注意:旋转的值是弧度!!!

360度等于2π弧度,约等于 2 * 3.14

{
  "keyframe": {
    "logo": {
      "0": {
        "rotation": [0, 0, 0]
      },
      "100": {
         // 注意是弧度,360度等于2π,约等于6.28
        "rotation": [6.28, 0, 0]
      }
    }
  },
  "animation": {
    "logo": {
      "keyframe": "logo",
      "duration": 4,
      "ease": "linear",
      "loop": -1
    }
  }
}
<!-- 加载动画资源 -->
<xr-asset-load asset-id="anim" type="keyframe" src="/assets/animation.json"/>

<xr-mesh node-id="plane" geometry="plane" material="mat2" position="0 1 0" anim-keyframe="anim" anim-autoplay="clip:logo" />

kapture.gif

添加 3D 模型

支持 GLTF 格式模型

<!-- 加载 GLTF 模型 -->
<xr-asset-load type="gltf" asset-id="miku" src="https://mmbizwxaminiprogram-1258344707.cos.ap-guangzhou.myqcloud.com/xr-frame/demo/miku.glb" />

<!-- 渲染 GLTF 组件 -->
<xr-gltf model="miku" position="-0.15 0.75 1" scale="0.07 0.07 0.07" rotation="0 180 0" anim-autoplay />

kapture2.gif

AR 系统

AR 能力,需要调用摄像头权限

微信开发者工具无法模拟,请使用真机预览模式

9.png

Plane 识别

123

源码示例: Plane 模式

Plane 模式是 AR 中的平面检测与追踪功能

常见的交互流程:

  1. 扫描阶段
  • 用户移动手机摄像头
  • AR 系统分析画面,识别水平或垂直的平面
  • 常见平面:地板、桌面、墙壁、床面等
  1. 检测阶段
  • 找到平面后,显示视觉指示器(视频中的蓝色圆环)
  • 指示器跟随检测到的平面移动
  1. 放置阶段
  • 用户点击屏幕
  • 虚拟物体被"放置"到现实世界的平面上
<!-- AR system 系统开启 Plane 模式 -->
<xr-scene ar-system="modes:Plane" bind:ready="handleReady">
  <xr-assets bind:loaded="handleAssetsLoaded">
    <xr-asset-load type="gltf" asset-id="anchor" src="https://mmbizwxaminiprogram-1258344707.cos.ap-guangzhou.myqcloud.com/xr-frame/demo/ar-plane-marker.glb" />
    <xr-asset-load type="gltf" asset-id="miku" src="https://mmbizwxaminiprogram-1258344707.cos.ap-guangzhou.myqcloud.com/xr-frame/demo/miku.glb" />
    <xr-asset-load type="texture" asset-id="deepred" src="https://avatars.githubusercontent.com/u/13102815" />
    <xr-asset-material asset-id="mat" uniforms="u_baseColorMap: deepred" />
  </xr-assets>
  <xr-light type="ambient" color="1 1 1" intensity="1" />
  <xr-light type="directional" rotation="40 70 0" color="1 1 1" intensity="3" cast-shadow />
  <!-- AR 追踪器 对应也要开启 Plane 模式 -->
  <xr-ar-tracker mode="Plane">
    <!-- 找到平面后,显示视觉指示器 -->
    <xr-gltf model="anchor"></xr-gltf>
  </xr-ar-tracker>
  <!-- 首先隐藏虚拟物体 -->
  <xr-node node-id="setitem" visible="false">
    <xr-gltf model="miku" anim-autoplay scale="0.07 0.07 0.07" rotation="0 180 0" />
    <xr-mesh node-id="plane" geometry="plane" material="mat" scale="0.3 0.3 0.3" />
  </xr-node>
   <!-- camera 的 background 设置成 ar -->
  <xr-camera clear-color="0.4 0.8 0.6 1" background="ar" is-ar-camera />
</xr-scene>
Component({
  methods: {
    handleAssetsLoaded: function ({ detail }) {
      wx.showToast({ title: '点击屏幕放置模型' });
      this.scene.event.add('touchstart', () => {
        // 虚拟物体被"放置"到现实世界的平面
        this.scene.ar.placeHere('setitem', true);
      });
    },
    handleReady: function ({ detail }) {
      this.scene = detail.value;
    },
  }
})

Marker 识别

123

源码示例: Marker 模式

Marker 模式,能够识别预先设定的目标物体(定义为 Marker,包括2D平面物体和3D物体),进行视觉跟踪与定位,通过在目标物体周围渲染虚拟物体,从而实现AR功能。

模型与现实物体保持空间位置关系(即:3D 层级 效果)。

Marker 分类

  1. 2D Marker,仅适用于平面类物体,用户上传一张平面物体的俯视图像作为目标物体,算法运行时识别该平面物品,并渲染出相关虚拟物体。2D Marker可以理解为特殊的 3D Marker。
  2. 3D Marker,相比于2D Marker,能够识别3D物体,不局限于平面物体,具有更广的使用范围,算法运行前,需要手动制作3D Marker的识别目标文件(.map文件),然后算法运行时载入该文件用于识别
<xr-scene ar-system="modes:Marker" bind:ready="handleReady">
  <xr-assets bind:loaded="handleAssetsLoaded">
    <xr-asset-load type="video-texture" asset-id="hikari" options="loop:true" src="https://assets.xxxx.com/resources/cdn/20250925/1c85f5b5ecde29ea.mp4" />
    <xr-asset-load type="texture" asset-id="deepred2" src="https://assets.xxxx.com/resources/cdn/20250925/c737cf37d083d543.png" />
    <xr-asset-material asset-id="mat" effect="simple" uniforms="u_baseColorMap: video-hikari" />
    <xr-asset-material asset-id="mat3" effect="simple" uniforms="u_baseColorMap: deepred2" />
  </xr-assets>
  <xr-node wx:if="{{loaded}}">
    <xr-ar-tracker mode="Marker" bind:ar-tracker-switch="handleTrackerSwitch" src="https://assets.xxxx.com/resources/cdn/20250925/fe8dad0e9800ef81.jpg">
      <xr-mesh node-id="mesh-plane" geometry="plane" material="mat" scale="1.7778 1 1" position="0 0.1 0" />
      <xr-mesh node-id="plane" geometry="plane" material="mat3" position="0.1 0.8 0.1" scale="0.12 0.12 0.12" />
    </xr-ar-tracker>
  </xr-node>
  <xr-camera clear-color="0.4 0.8 0.6 1" background="ar" is-ar-camera />
</xr-scene>
Component({
  methods: {
    handleReady: function ({ detail }) {
      this.scene = detail.value;
    },
    handleAssetsLoaded: function ({ detail }) {
      this.setData({ loaded: true });
    },
    handleTrackerSwitch: function ({ detail }) {
      // 获取追踪状态
      const active = detail.value;
      const video = this.scene.assets.getAsset('video-texture', 'hikari');
      // 识别到物体,播放视频
      // 识别中,暂停视频
      active ? video.play() : video.stop();
    }
  }
})

OSD 识别

源码示例: OSD 模式

123

OSD(One-shot Detection)Marker 识别模式,可以看成是一种特殊的 Marker 模式 :在摄像头画面上“贴图叠加”的模式,主要以 屏幕坐标系(2D) 为主,不真正感知空间深度

<xr-scene ar-system="modes:OSD" bind:ready="handleReady">
  <xr-assets bind:loaded="handleAssetsLoaded">
    <xr-asset-material asset-id="mat" effect="simple" states="alphaMode:BLEND" />
    <xr-asset-material asset-id="text-simple" effect="simple" />
  </xr-assets>
  <xr-node>
    <xr-ar-tracker mode="OSD" src="https://assets.xxxx.com/resources/cdn/20250925/fe8dad0e9800ef81.jpg">
      <xr-node rotation="0 180 0">
        <xr-mesh node-id="text-wrap" geometry="plane" material="mat" position="0 1 0" scale="1 1 0.5" rotation="90 0 0" uniforms="u_baseColorFactor:0.875 0.616 0.624 1" />
        <xr-text node-id="text-name" position="-0.25 1.05 0" scale="0.1 0.1 0.1" material="text-simple" value="美食家蜜蜂" />
      </xr-node>
    </xr-ar-tracker>
  </xr-node>
  <xr-camera clear-color="0.4 0.8 0.6 1" background="ar" is-ar-camera />
</xr-scene>

人脸识别

源码示例: 人脸识别

<!-- AR system 系统开启 Face 模式 同时开启前置摄像头 -->
<xr-scene ar-system="modes:Face;camera:Front">
  <xr-assets>
    <xr-asset-load type="gltf" asset-id="mask" src="https://mmbizwxaminiprogram-1258344707.cos.ap-guangzhou.myqcloud.com/xr-frame/demo/jokers_mask_persona5.glb" />
  </xr-assets>
  <!-- AR 追踪器 对应也要开启 Face 模式 -->
  <!-- auto-sync 是一个数字数组,用于将对应顺序的子节点绑定到某个特征点上 -->
  <xr-ar-tracker mode="Face" auto-sync="43">
    <!-- 包含识别成功后需要展示的场景 -->
    <xr-gltf model="mask" rotation="0 180 0" scale="0.5 0.5 0.5" />
  </xr-ar-tracker>
  <xr-light type="ambient" color="1 1 1" intensity="1" />
  <xr-light type="directional" rotation="40 70 0" color="1 1 1" intensity="3" />
  <!-- AR 相机 -->
  <xr-camera background="ar" is-ar-camera />
</xr-scene>

除了人脸识别(Face),还有 人体识别(Body),人手识别(Hand),可以参考官方文档学习

总结

到这里,我们已经把 XR-FRAME 的基础能力都过了一遍:从创建场景、添加物体、设置光照,到加载模型和动画,再到四种 AR 识别模式(Plane、Marker、OSD、Face)。理论上,你已经可以做出一个简单的 AR 效果了。

但理论和实际总是有差距的。在下一篇实战篇中,我会分享更高阶的知识点和实际项目中的经验:

  • Shader 着色器:实现自定义视觉效果,让 AR 场景更炫酷
  • 同层渲染:XR 组件和小程序原生组件互相通信,实现复杂的 UI 交互
  • 企业级方案:性能优化、状态管理、多设备兼容等实战经验
❌