普通视图
LLM 原理 - 输入预处理
前言
最近在学习大模型的实现原理,为了更好地理解整个流程,我把学习中的关键概念和实现细节整理成了笔记。一方面帮助自己梳理思路、加深印象,另一方面也作为日后复习和查阅的基础。内容会按照模型的实际处理流程展开,希望能以清晰易懂的方式记录大模型的核心机制。
大模型原理概述
大模型最经典的架构图来自《Attention Is All You Need》,从这张图可以看到 Transformer 的基础结构是由“编码器”和“解码器”两部分组成的。虽然现在的大模型(像 GPT、LLaMA 这种)大多只保留了右侧的解码器结构,但它们整体的工作流程仍然遵循 Transformer 的思路。
整体原理可以简单理解成:
- 用户输入的文本会先经过 tokenizer 切成 token,再通过 embedding 转成向量。
-
这些向量会被送入 Transformer 的多层结构中处理。
每一层都会做自注意力(Mulit-Head Attention,多头自注意力,让模型去关注上下文里的其他词)、前馈网络(Feed-Forward Network)、残差连接(Add)、层归一化(Norm)等操作,层数越多,模型对上下文的理解就越深。 -
最后一层会把处理后的向量经过线性变换,然后通过 softmax 得到一个概率分布。
这个概率分布表示:“在所有 token 里,下一步最可能是哪个”。 - 模型会根据这个概率分布选出下一个 token(可能是选最高概率,也可能按概率采样)。
-
选出来的这个 token 会被加回当前输入,让模型继续推理下一个。
模型就是这样不断循环:一步一步预测下一个 token,逐渐拼出完整的句子。 - 当所有 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。
它解决了三件关键问题:
- 文本如何变成模型能算的数字
- 模型如何知道每个 token 的意思
- 模型如何知道 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>
);
};
注意:别切得太碎了(避坑指南)
听到切代码能优化性能,有些兄弟兴奋了,拿起刀就是一顿乱切。 把 Button、Icon、Text 全部懒加载。
千万别!
- HTTP 请求开销:每个 lazy 组件都会发起一个新的网络请求。如果你把一个 1KB 的按钮切出来,光是 HTTP 握手的时间都比下载它的时间长。
-
闪屏体验:如果页面全是
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
}
}
这个标注告诉编译器:
-
x和y都在生命周期'a内有效 - 返回值也在生命周期
'a内有效 - 实际上
'a是x和y生命周期的交集(较短的那个)
用大白话说就是:"返回值的有效期不会超过两个参数中最短的那个"。
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的"杀手锏",也是初学者的"拦路虎"。但记住:
- 生命周期是编译期概念,运行时没有性能开销
- 大多数情况不需要手动标注,感谢生命周期省略规则
- 编译器错误是你的朋友,它阻止你犯错
- 实践是最好的老师,多写多改就能掌握
当你习惯了生命周期,你会发现它就像一位严格但负责的老师——虽然严厉,但确实让你写出更安全的代码。
下一篇我们将探索Rust的错误处理机制——如何优雅地处理Result和Option。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 First 与 Mobile 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
然后输入项目名,选择框架等等
搭建好基础之后,把 antd 和 alova 安装一下
再安装一些 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 中配置一下这个接口
![]()
![]()
源仓库
接下来准备把这两个项目放到 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',
},
前端下载多个文件/浏览器批量下载文件
手写一个webpack插件(plugin)
HarmonyOS中开发高德地图第五篇:定位蓝点功能
HarmonyOS中高德地图第一篇:高德地图SDK集成与初始化
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()很强大,但并不是万能的:
- 需要精确断点控制时——媒体查询更合适
- 复杂的布局变化——比如移动端和桌面端完全不同的布局
- 性能敏感的场景——复杂的计算可能影响性能
总结
clamp()是CSS中一个革命性的功能,它让我们能够用更少的代码实现更流畅的响应式设计。特别适合:
- 字体大小响应式
- 间距和内边距
- 容器尺寸限制
- 图片和媒体元素
本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!
📌往期精彩
《SpringBoot+MySQL+Vue实现文件共享系统》
玩转小程序AR-基础篇
🌏 父子组件 i18n(国际化)架构设计方案
⚪️ 五子棋加入道具系统是一种什么体验?我用 TRAE SOLO 实现了!
最近刷到 不如摸鱼去 使用 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 前端领域。
欢迎关注我的微信公众号「小何不会写代码」,我会不定期分享一些开发心得、最佳实践以及技术探索等内容,希望能够帮到你!
📚 推荐阅读
前端面试题-JavaScript高级篇
React 史诗级漏洞: SSR Server Action 协议导致服务器远程代码执行
Vue Router 组件内路由钩子全解析
一、什么是组件内路由钩子?
在 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 {
user: null
}
},
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 {
hasUnsavedChanges: false,
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 {
product: null,
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(to, from, next) {
// 使用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',
component: UserProfile,
beforeEnter: (to, from, next) => {
console.log('路由独享守卫 → 组件守卫')
next()
}
}
]
执行顺序:
-
- 导航被触发
-
- 调用全局
beforeEach
- 调用全局
-
- 调用路由配置中的
beforeEnter
- 调用路由配置中的
-
- 调用组件内的
beforeRouteEnter
- 调用组件内的
-
- 导航被确认
-
- 调用全局的
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. beforeRouteEnter:在组件创建前进行权限验证和数据预加载
- 2. beforeRouteUpdate:优化动态参数页面的用户体验
- 3. beforeRouteLeave:防止用户意外丢失未保存的数据
记住这些钩子的调用时机和限制,结合实际的业务需求,你就能构建出更加健壮、用户友好的单页应用。
零硬件交互:如何用纯前端把摄像头变成 4000 个粒子的魔法棒?
关键词: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…