普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月6日技术

LLM 原理 - 输入预处理

作者 子洋
2025年12月6日 16:01

前言

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

大模型原理概述

大模型最经典的架构图来自《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)实战指南

2025年12月6日 15:46

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

兄弟们,咱们先看一眼你们项目的 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入门系列(三):生命周期 - 编译器的"算命先生"

作者 土豆1250
2025年12月6日 14:51

前情回顾:在前两篇文章中,我们学习了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:用大模型重构下一代应用开发范式

2025年12月6日 14:34

在技术演进的浪潮中,我们正站在一个关键拐点上: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 功能(二),前端项目的开发,对接后端

作者 Lovely_Ruby
2025年12月6日 14:29

效果

在这里插入图片描述

在这里插入图片描述

相关

前端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

作者 Joie
2025年12月6日 14:21
  <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()函数:一行代码让网页自适应如此简单

作者 刘大华
2025年12月5日 14:46

在前端开发中,实现响应式设计一直是个挑战。今天介绍一个能够大大简化响应式开发的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个细节》

⚪️ 五子棋加入道具系统是一种什么体验?我用 TRAE SOLO 实现了!

作者 xiaohe0601
2025年12月5日 08:42

最近刷到 不如摸鱼去 使用 TRAE SOLO 复刻了坦克大战,他说仿佛捉住一只嘶鸣的蝉,便攥紧了整个童年的夏天。

现在已经是冬天了,四川冬天的冷就像是“魔法攻击“,虽然温度不如北方那么低,但是寒意总会穿透衣服渗入到你的身体里。每天早上蹬共享单车上班的我,在寒风中总会想如果现在是夏天就好了,我也要用 TRAE SOLO 做一个游戏,我也想要抓住整个夏天!

奈何没有 TRAE SOLO 资格,一直不能实践我的想法。

……

终于,TRAE SOLO 中国版正式上线,我也解锁了 SOLO 资格!

那么今天就来做一个大家都熟知的五子棋游戏吧,不过我希望可以加入道具系统(海克斯大乱斗玩的)!

🤓 什么是 TRAE SOLO

“过去,我们努力把 AI 做进工具,提升补全效率与开发体验。 如今,我们把工具反向集成于 AI 之中,由它统一调度任务、理解上下文、组织工作。 TRAE SOLO 正是在这个思路下诞生 —— 致力于实践上下文工程,构建真正由 AI 驱动的开发闭环” —— TRAE 官网

TRAE SOLO 是一种高度自动化的开发方式,以 AI 为主导,可理解目标、承接上下文并调度工具,独立推进各阶段开发任务。

SOLO Coder 不止于代码编写,更能进行深度需求分析,精准执行。你可以创建自定义智能体,由 SOLO Coder 自主编排,专属 AI 专家团队协同开发,灵活处理你交代的每个任务。

点击左上角的「TRAE」图标即可切换到 SOLO 模式,不同于普通模式,SOLO 模式由 AI 模块占据主要地位,这也符合 TRAE 对 SOLO 模式的定位 —— AI 主导开发。

🚀 开始 SOLO !

首先我们需要整理需求,将想要做的东西用文字描述给 TRAE,要确保尽量详细准确,这样 AI 才能清晰理解任务目标。可是,对于大多数开发者来说写文档是一件很痛苦的事情,宁写 1000 行代码也不愿写 100 行文档,这可怎么办呢?

幸运的是,TRAE 提供了 AI 自动润色优化输入功能,事情一下就变简单了!

现在,我们只需要简单编写一句话描述需求,先开发一款基础版的五子棋游戏:

使用 HTML5 Canvas 和 TypeScript 开发一款五子棋游戏。

然后点击输入框右下角的 ✨ 图标,就可以 AI 自动润色啦!

稍等片刻,TRAE 就自己编写了一段详细的需求说明。

我们稍加检查,看看自动生成的文字是否符合需求 ……

嗯,完全符合需求!(我自己可写不出这么详细的说明 🥹)

由于这是从 0 开始开发,可以启用输入框右上角的「Plan」开关,让 TRAE 先梳理一个开发计划文档,这会让 AI 对需求的理解更加深刻,有效提升代码的输出质量。

TRAE 经过一系列的自动反复思考,最终生成了一篇完整的《五子棋游戏开发计划》并输出到 .trae/documents 目录中。确认无误后点击「执行」按钮,TRAE 就会开始自动编码。

如同一个经验老道的高级开发工程师,TRAE 会自动思考、规划、创建文件、编写代码以及错误修复等工作,全程无需人工干预。

我们只需要静静地看着他工作,或者去做点其他事情 ~

很快,喝杯水的功夫(没有说是多大的杯子 😁),TRAE 就完成了第一版游戏的开发,还贴心总结了项目结构、技术要点以及操作说明等内容,帮助我们快速理解工程。

TRAE 自动运行起了开发服务,然后切换到内置浏览器模块,可以实时预览页面。

啊哦,控制台日志中有一个报错,导致页面只有一片白屏!但是不用担心,点击「✨添加到对话」按钮,然后让 TRAE 帮我们修复一下就好啦 ~

现在,一个基础版本的五子棋游戏就可以正常运行啦!

简单体验一下,可以正常交替落子、悔棋以及五子连珠检测,具备了五子棋游戏的基本要素。

不过还是有一些小问题,「开始新游戏」按钮和「重新开始」按钮功能重复了,并且点击切换棋盘大小没有反应,让 TRAE 帮我们修复一下吧!

TRAE 不负所托,修复成功!只保留了「重新开始」按钮,并且棋盘大小也能正确切换。

可是,现在只能自己一个人交替落子进行游戏,难免会少了一些乐趣,如果能有一个「电脑」选手与我们对弈就好了,那就让 TRAE 继续帮我们做一个「电脑」选手吧!

现在就切换到「玩家 VS 电脑」模式,开始一场人类与 AI 的较量!💪

好吧,又发现了一个小问题,点击一下「悔棋」按钮,只撤回了一次落子,当撤回到「电脑」回合的时候,玩家就不能正常落子。TRAE 实现 PVE 模式的时候没有考虑到这一点,那么就得靠我们来提醒他咯 ~(人类扳回一局 🥳)

等待 TRAE 修复后,在 PVE 模式下点击「悔棋」按钮将撤回上一回合电脑及玩家的落子,游戏可以正常进行了。

体验一番下来,这个电脑也太弱 👎 了吧!玩家可以轻松取胜,没有一点挑战性。

把我们的诉求告诉 TRAE,让他优化电脑落子算法,增强游戏性。

经过一番思考,TRAE 设计了一套基于位置评分的智能算法,考虑了进攻和防守策略,能够评估不同位置的优先级。

小何赛博下棋偶遇超强电脑选手,行云流水强如怪物,拼尽全力也无法战胜。☹️

至此,TRAE 帮我们完成了一个功能完整的五子棋游戏,成功达成最初功能清单所设定的目标,接下来终于可以进入正题啦!为游戏加入盲盒道具机制,提升游戏的趣味性。

我设计了分为「强化类」和「弱化类」的 8 个不同道具,「强化类」道具可以用于改善自己的棋局,而「弱化类」道具则会破坏自己的棋局或者为对手提供有利的效果。

由于「弱化类」道具的存在,玩家不能一味地选择触发道具而不故现有局势,每一次决定占领道具对于棋局的影响都是未知的,请小心作出你的抉择!

这是一个全新的道具系统,让我们多给 TRAE 一些时间。

……

又经过若干轮的思考、规划和编码后,开发服务重新启动,内置浏览器自动打开,TRAE 完成了道具系统的开发!

让我们一起来体验加入了盲盒道具机制的五子棋游戏吧 ~

TRAE 实现了道具的随机刷新、自动触发以及道具说明弹窗功能,尽管道具的效果存在问题,但是完成度已经非常高啦!

接下来就要靠我们为 TRAE 指出问题所在,将体验过程中遇到的问题简单总结后发给 TRAE 即可。

经过一番修复,对于部分影响落子次数或顺序的道具连续触发所导致的计数问题 TRAE 依然不能正确处理,此时需要我们提醒他应该设计一个「落子计数系统」。

一个完整的「道具五子棋」游戏诞生啦!🎉

无论是「玩家 VS 玩家」还是「玩家 VS 电脑」模式,游戏都能按照预期正常刷新和触发道具,并且还提供了游戏道具日志。

最后,再让 TRAE 优化一下游戏界面的布局,现在的游戏界面实在是太长了。

游戏界面优化结果非常完美,我宣布 TRAE 正式 SOLO 出道!🫰

作为一个没有任何游戏开发经验的小白,能够不写一行代码完全通过聊天实现一个完整的游戏(虽然只是一个简单的棋牌游戏),是很酷的一件事情!😎

🎮 在线体验

「道具版五子棋」已通过 Netlify 部署到线上,欢迎体验!

👉 Gomoku Next (可能需要魔法 🪄 上网)

🖥️ 源码

项目的完整代码可以在 gomoku-next 仓库中查看。

赠人玫瑰,手留余香,如果对你有帮助可以给我一个 ⭐️ 鼓励,这将是我继续前进的动力,谢谢大家 🙏!

🍵 写在最后

我是 xiaohe0601,热爱代码,目前专注于 Web 前端领域。

欢迎关注我的微信公众号「小何不会写代码」,我会不定期分享一些开发心得、最佳实践以及技术探索等内容,希望能够帮到你!

📚 推荐阅读

Vue Router 组件内路由钩子全解析

作者 北辰alk
2025年12月6日 11:38

一、什么是组件内路由钩子?

在 Vue Router 中,组件内路由钩子(也称为导航守卫)是在路由变化时自动调用的特殊函数,它们允许我们在特定时机执行自定义逻辑,比如:

  • • 权限验证(是否登录)
  • • 数据预加载
  • • 页面离开确认
  • • 滚动行为控制
  • • 动画过渡处理
// 一个简单的示例
export default {
  name'UserProfile',
  beforeRouteEnter(to, from, next) {
    console.log('组件还未创建,但即将进入...')
    next()
  }
}

二、三大核心钩子函数详解

Vue Router 提供了三个主要的组件内路由钩子,它们组成了一个完整的导航生命周期:

1. beforeRouteEnter - 进入前的守卫

调用时机:在组件实例被创建之前调用,此时组件还未初始化。

特点

  • • 不能访问 this(因为组件实例还未创建)
  • • 可以通过回调函数访问组件实例
export default {
  beforeRouteEnter(to, from, next) {
    // ❌ 这里不能使用 this
    console.log('from'from.path// 可以访问来源路由
    
    // ✅ 通过 next 的回调访问组件实例
    next(vm => {
      console.log('组件实例:', vm)
      vm.loadData(to.params.id)
    })
  },
  
  methods: {
    loadData(id) {
      // 加载数据逻辑
    }
  }
}

适用场景

  • • 基于路由参数的权限验证
  • • 预加载必要数据
  • • 重定向到其他页面

2. beforeRouteUpdate - 路由更新守卫

调用时机:在当前路由改变,但组件被复用时调用。

常见情况

  • • 从 /user/1 导航到 /user/2
  • • 查询参数改变:/search?q=vue → /search?q=react
export default {
  data() {
    return {
      usernull
    }
  },
  
  beforeRouteUpdate(to, from, next) {
    // ✅ 可以访问 this
    console.log('路由参数变化:'from.params.id'→', to.params.id)
    
    // 重新加载数据
    this.fetchUserData(to.params.id)
    
    // 必须调用 next()
    next()
  },
  
  methods: {
    async fetchUserData(id) {
      const response = await fetch(`/api/users/${id}`)
      this.user = await response.json()
    }
  }
}

实用技巧:使用这个钩子可以避免重复渲染,提升性能。

3. beforeRouteLeave - 离开前的守卫

调用时机:在离开当前路由时调用。

重要特性

  • • 可以阻止导航
  • • 常用于保存草稿或确认离开
export default {
  data() {
    return {
      hasUnsavedChangesfalse,
      formData: {
        title'',
        content''
      }
    }
  },
  
  beforeRouteLeave(to, from, next) {
    if (this.hasUnsavedChanges) {
      const answer = window.confirm(
        '您有未保存的更改,确定要离开吗?'
      )
      
      if (answer) {
        next() // 允许离开
      } else {
        next(false// 取消导航
      }
    } else {
      next() // 直接离开
    }
  },
  
  methods: {
    onInput() {
      this.hasUnsavedChanges = true
    },
    
    save() {
      // 保存逻辑
      this.hasUnsavedChanges = false
    }
  }
}

三、完整导航流程图

让我们通过一个完整的流程图来理解这些钩子的执行顺序:

是

否

是

next

next false

beforeRouteEnter 特殊处理
无法访问 this通过 next 回调访问实例开始导航组件是否复用?调用 beforeRouteUpdate调用 beforeRouteEnter组件内部处理确认导航 next创建组件实例执行 beforeRouteEnter 的回调渲染组件用户停留页面用户触发新导航?调用 beforeRouteLeave允许离开?执行新导航停留在当前页面

四、实际项目中的应用案例

案例1:用户权限验证系统

// UserProfile.vue
export default {
  beforeRouteEnter(to, from, next) {
    // 检查用户是否登录
    const isAuthenticated = checkAuth()
    
    if (!isAuthenticated) {
      // 未登录,重定向到登录页
      next({
        path: '/login',
        query: { redirect: to.fullPath }
      })
    } else if (!hasPermission(to.params.id)) {
      // 没有权限,重定向到403页面
      next('/403')
    } else {
      // 允许访问
      next()
    }
  },
  
  beforeRouteLeave(to, from, next) {
    // 如果是管理员,记录操作日志
    if (this.user.role === 'admin') {
      logAdminAccess(from.fullPath, to.fullPath)
    }
    next()
  }
}

案例2:电商商品详情页优化

// ProductDetail.vue
export default {
  data() {
    return {
      productnull,
      relatedProducts: []
    }
  },
  
  beforeRouteEnter(to, from, next) {
    // 预加载商品基础信息
    preloadProduct(to.params.id)
      .then(product => {
        next(vm => {
          vm.product = product
          // 同时开始加载相关商品
          vm.loadRelatedProducts(product.category)
        })
      })
      .catch(() => {
        next('/404'// 商品不存在
      })
  },
  
  beforeRouteUpdate(to, from, next) {
    // 商品ID变化时,平滑过渡
    this.showLoading = true
    this.fetchProductData(to.params.id)
      .then(() => {
        this.showLoading = false
        next()
      })
      .catch(() => {
        next(false// 保持当前商品
      })
  },
  
  methods: {
    async fetchProductData(id) {
      const [product, related] = await Promise.all([
        api.getProduct(id),
        api.getRelatedProducts(id)
      ])
      this.product = product
      this.relatedProducts = related
    },
    
    loadRelatedProducts(category) {
      // 异步加载相关商品
    }
  }
}

五、高级技巧与最佳实践

1. 组合式API中的使用

import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'

export default {
  setup() {
    const unsavedChanges = ref(false)
    
    // 使用组合式API守卫
    onBeforeRouteLeave((to, from) => {
      if (unsavedChanges.value) {
        return confirm('确定要离开吗?')
      }
    })
    
    onBeforeRouteUpdate(async (to, from) => {
      // 处理路由参数更新
      await loadData(to.params.id)
    })
    
    return { unsavedChanges }
  }
}

2. 异步操作的优雅处理

export default {
  beforeRouteEnter(tofromnext) {
    // 使用async/await
    const enterGuard = async () => {
      try {
        const isValid = await validateToken(to.query.token)
        if (isValid) {
          next()
        } else {
          next('/invalid-token')
        }
      } catch (error) {
        next('/error')
      }
    }
    
    enterGuard()
  }
}

3. 避免常见的坑

坑1:忘记调用 next()

// ❌ 错误示例 - 会导致导航挂起
beforeRouteEnter(to, from, next) {
  if (checkAuth()) {
    // 忘记调用 next()
  }
}

// ✅ 正确示例
beforeRouteEnter(to, from, next) {
  if (checkAuth()) {
    next()
  } else {
    next('/login')
  }
}

坑2:beforeRouteEnter 中直接修改数据

// ❌ 错误示例
beforeRouteEnter(to, from, next) {
  next(vm => {
    // 避免直接修改响应式数据
    vm.someData = 'value' // 可能导致响应式问题
  })
}

// ✅ 正确示例
beforeRouteEnter(to, from, next) {
  next(vm => {
    vm.$nextTick(() => {
      vm.someData = 'value' // 在下一个tick中修改
    })
  })
}

六、与其他导航守卫的配合

组件内守卫还可以与全局守卫、路由独享守卫配合使用:

// 全局前置守卫
router.beforeEach((to, from, next) => {
  console.log('全局守卫 → 组件守卫')
  next()
})

// 路由配置中的独享守卫
const routes = [
  {
    path'/user/:id',
    componentUserProfile,
    beforeEnter(to, from, next) => {
      console.log('路由独享守卫 → 组件守卫')
      next()
    }
  }
]

执行顺序

    1. 导航被触发
    1. 调用全局 beforeEach
    1. 调用路由配置中的 beforeEnter
    1. 调用组件内的 beforeRouteEnter
    1. 导航被确认
    1. 调用全局的 afterEach

七、性能优化建议

1. 懒加载守卫逻辑

export default {
  beforeRouteEnter(to, from, next) {
    // 按需加载验证模块
    import('@/utils/auth').then(module => {
      if (module.checkPermission(to.meta.requiredRole)) {
        next()
      } else {
        next('/forbidden')
      }
    })
  }
}

2. 缓存验证结果

let authCache = null

export default {
  beforeRouteEnter(to, from, next) {
    if (authCache === null) {
      // 首次验证
      checkAuth().then(result => {
        authCache = result
        handleNavigation(result, next)
      })
    } else {
      // 使用缓存结果
      handleNavigation(authCache, next)
    }
  }
}

总结

Vue Router 的组件内路由钩子为我们提供了强大的导航控制能力。通过合理使用这三个钩子函数,我们可以:

  1. 1. beforeRouteEnter:在组件创建前进行权限验证和数据预加载
  2. 2. beforeRouteUpdate:优化动态参数页面的用户体验
  3. 3. beforeRouteLeave:防止用户意外丢失未保存的数据

记住这些钩子的调用时机和限制,结合实际的业务需求,你就能构建出更加健壮、用户友好的单页应用。

零硬件交互:如何用纯前端把摄像头变成 4000 个粒子的魔法棒?

作者 兔子零1024
2025年12月6日 10:58

关键词:Vue 3 / Three.js / MediaPipe / AI / WebGL / 创意编程 / 前端工程化

引言:当哈利波特的魔法棒变成了一行 URL

想象这样一个场景:

你不需要购买昂贵的 Apple Vision Pro,也不需要戴上笨重的 VR 头盔,甚至不需要安装任何 App。 你只需要打开一个网页,允许摄像头权限,对着屏幕伸出手。

握拳,屏幕上 4000 个原本漂浮的粒子瞬间向中心坍缩,聚集成一个紧密的能量球; 张手,这些粒子仿佛受到冲击,瞬间向四周炸裂,如同烟花般绚烂; 挥动,粒子流随着你的指尖起舞,从地球变成爱心,从爱心变成土星。

这就是我最近做的一个开源小项目 —— Hand Controlled 3D Particles

在过去,这种级别的体感交互往往意味着:专业的深度摄像头(Kinect)、高性能的本地显卡、以及复杂的 C++/Unity 开发环境。 但现在,得益于 Web AI 和 WebGL 的进化,我们用纯前端技术就能复刻这种魔法。

一、效果:指尖上的粒子宇宙

在这个项目里,你的手就是控制一切的遥控器。

1. 实时手势识别

基于 Google MediaPipe Hands 模型,浏览器能以惊人的速度(60fps+)捕捉你手部的 21 个关键点。 这不是简单的"动量检测",而是真正的"骨骼识别":

  • 拇指与食指捏合/靠近 → 触发引力场
  • 手掌完全张开 → 触发斥力场
  • 左右手势切换 → 切换 3D 模型形态

2. 视觉形态演变

粒子不仅仅是散乱的点,它们按照数学公式排列成 5 种形态:

  • Earth(地球):经典的 Fibonacci 球面分布
  • Heart(爱心):浪漫的心形数学曲线
  • Saturn(土星):带光环的行星系统
  • Tree(圣诞树):圆锥螺旋分布
  • Fireworks(烟花):完全随机的爆炸效果

这一切,都运行在一个普通的 Chrome 浏览器里。

二、技术解构:三驾马车如何协同?

整个项目使用 Vue 3 + TypeScript 构建,核心逻辑其实非常简单,主要依赖三个技术的有机结合。

1. The Eye: MediaPipe Hands

这是 Google 开源的轻量级机器学习模型。它的特点是:快,极快。 它不需要将视频流上传到云端处理,而是直接在浏览器端(WASM)利用 GPU 加速推理。

核心逻辑只需要关注两个关键点:

  • Landmark 4 (拇指尖)
  • Landmark 8 (食指尖)
// 计算拇指和食指的欧几里得距离
const distance = Math.sqrt(
  (thumbTip.x - indexTip.x) ** 2 + 
  (thumbTip.y - indexTip.y) ** 2
)

// 设定阈值进行状态判断
if (distance < 0.08) {
  // 触发"握拳/收缩"状态
  emit('contract')
} else if (distance > 0.16) {
  // 触发"张开/爆炸"状态
  emit('explode')
}

简单的高中数学,就能把连续的模拟信号转化为离散的交互指令。

2. The Canvas: Three.js 粒子系统

4000 个粒子在 Three.js 中并不是 4000 个独立的 Mesh(那样浏览器会卡死),而是一个 THREE.Points 系统。 所有的粒子共享一个 Geometry,通过 BufferAttribute 来管理每个粒子的位置。

不同形态的切换,本质上是目标位置(Target Position)的计算。比如心形曲线:

// 心形参数方程
const x = 16 * Math.pow(Math.sin(t), 3)
const y = 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t)
const z = t * 2 // 增加一点厚度

3. The Motion: 简单的物理插值

为了让动画看起来自然,粒子不是"瞬移"到新位置的,而是每一帧都向目标位置"滑行"一点点。

// 每一帧的渲染循环中
for (let i = 0; i < particleCount; i++) {
  // 当前位置 += (目标位置 - 当前位置) * 阻尼系数
  positions[i] += (targetPositions[i] - positions[i]) * 0.08
}

这个简单的公式(Lerp,线性插值)赋予了粒子重量感和惯性。

三、Web AI 的启示:算力下沉与隐私红利

做完这个项目,我有几个关于 Web 技术演进的强烈感受。

1. 算力正在向端侧回流

在云计算主导的十年里,我们习惯了把一切丢给服务器。但 MediaPipe 这类 Web AI 技术的成熟,标志着端侧算力的觉醒。 现在的手机和笔记本显卡足够强大,可以在浏览器里跑百亿参数以下的小模型。 这意味着:零延迟的交互体验。你的手一动,粒子立马跟着动,不需要等待网络请求往返。

2. 隐私是最大的护城河

用户越来越介意"上传摄像头视频"。 纯前端实现的 AI 有一个天然优势:所有计算都在本地发生。 视频流只在内存里流转,从未离开过用户的设备。这种"隐私安全感"是云端 AI API 无法比拟的。

3. 创意的门槛在指数级降低

五年前,做这个效果需要懂 OpenCV、懂 C++、懂 Shader 编程。 现在,你只需要会写 JavaScript,调几个 npm 包。 技术在变得越来越平民化(Democratization),这让开发者能把精力从"怎么实现"转移到"做什么好玩的东西"上。

四、未来:还可以怎么玩?

这个 Demo 只是一个起点,它展示了 Web 交互的一种新范式——自然用户界面(NUI)

基于这个架子,我们完全可以扩展出更多玩法:

  • WebAR 营销:在电商页面,用手势"隔空"旋转商品模型;
  • 无接触展示:在博物馆或展厅的大屏上,观众挥手就能翻页、缩放展品;
  • 音乐可视化:让粒子不仅仅跟随手势,还随着背景音乐的频谱跳动;
  • 多人远程互动:结合 WebRTC,让异地的两个人通过手势共同控制同一个 3D 场景。

五、最后

项目已开源,欢迎 Fork 玩耍,或者直接点个 Star ⭐️。

GitHub: github.com/wangmiaozer…

❌
❌