新人以为的 CRUD:存进去、读出来、删掉。实际上的 CRUD:你要先搞清楚存哪里、怎么存、谁在调、this 指向谁。
起因:一个平平无奇的 Tapas 菜单
需求很简单------做一个西班牙小吃菜单管理器:
- 输入菜名,点
+ Add Item,添加到列表 - 刷新页面,数据还在(不能每次刷新就丢光)
- 点击"去百度"链接不要真的跳走
听起来 10 分钟搞定。实际一头扎进去,发现背后牵出了存储选型、表单拦截、this 指向三座大山。这篇文章就是我的踩坑笔记。
页面长这样:
html
<div class="wrapper">
<h2>LOCAL TAPAS</h2>
<ul class="plates">
<li>Loading Tapas...</li>
</ul>
<form class="add-items">
<input type="text" name="item" placeholder="Add a new tapas">
<input type="submit" value="+ Add Item">
</form>
<a href="https://www.baidu.com" class="lnk">去百度</a>
</div>
三个交互点:
- 表单 → 添加菜品
- 列表 → 展示 + 删除
- 链接 → 阻止跳转
下面逐个拆解,每一层都比你想象的要深。
第一座山:你的数据到底存在哪?
"前端数据存 localStorage 不就完了?" ------ 这是对的,但也是不完整的。
一个完整的应用,数据是分层的。我把整个存储体系画成一张"金字塔":
markdown
┌──────────────┐
│ 云盘/OSS │ ← 用户上传的文件
├──────────────┤
│ MySQL │ ← 结构化业务数据,ACID 保障
├──────────────┤
│ Redis │ ← 热数据缓存,扛并发
├──────────────┤
│ LocalStorage│ ← 浏览器端持久化,刷新不丢
├──────────────┤
│ 浏览器缓存 │ ← HTTP 缓存,二次打开快
├──────────────┤
│ LLM Embedding│ ← 向量数据库,语义搜索
└──────────────┘
每一层解决什么问题?
1. 浏览器缓存(HTTP Cache)
你打开过的页面,第二次访问为什么快?浏览器把静态资源(HTML、CSS、JS、图片)缓存到了本地。强缓存(Cache-Control)+ 协商缓存(ETag)这套组合拳,让你的 common.js?v=11 这种带版本号的文件做到:
- 版本没变 → 直接用缓存,0 毫秒
- 版本变了 → 向服务器确认,下载新文件
注意代码里
common.js?v=11这个?v=11------ 这就是最朴素的缓存破坏(Cache Busting)手法。改了 JS 就改版本号,浏览器就认这是新文件。
2. LocalStorage(浏览器本地存储)
这才是 Tapas 应用的主力。5MB 的容量,存 JSON 字符串:
js
// 存
const items = ['火腿', '奶酪', '橄榄'];
localStorage.setItem('tapas', JSON.stringify(items));
// 取
const data = JSON.parse(localStorage.getItem('tapas') || '[]');
// 删一个
localStorage.removeItem('tapas');
// 全清
localStorage.clear();
关键点 :localStorage 只能存字符串。存对象/数组必须 JSON.stringify,取出来必须 JSON.parse。忘了这一步,你会在控制台看到 [object Object]。
3. Redis(服务端缓存)
Tapas 这个 demo 还没有后端。但如果有,架构大概率是:
markdown
用户请求 → Node.js → Redis 查缓存
↓ 没命中
MySQL 查数据 → 写入 Redis → 返回
为什么要加一层 Redis? 不是 MySQL 不行,而是:
- MySQL 走磁盘 IO,每次查文章列表都要 join、排序、翻页
- Redis 走内存,KV 结构,O(1) 拿出来
- 你的代码再快,数据库一句
SELECT * FROM plates ORDER BY created_at DESC LIMIT 20也要几十毫秒------在高并发下这就是瓶颈
缓存的核心思想不是"快",而是"减少不必要的重复计算"。
4. LLM Embedding 存储
这一层更偏向 AI 场景。把文本转成向量(一堆浮点数),存到向量数据库(Pinecone、Milvus、pgvector),做语义搜索。比如用户输入"适合夏天的清爽小吃",向量检索能匹配到"凉拌海蜇"------尽管这两个句子字面上毫无关联。这是传统关键词搜索做不到的。
| 存储层 | 速度 | 容量 | 典型用途 |
|---|---|---|---|
| HTTP Cache | 极快 | 小 | 静态资源 |
| LocalStorage | 快 | 5MB | 用户偏好、草稿 |
| Redis | 极快 | 大(内存) | 热数据、Session |
| MySQL | 中 | 极大(磁盘) | 业务数据 |
| 向量数据库 | 中 | 大 | 语义搜索、RAG |
第二座山:表单提交------从远古到现代
远古模式:form 原生提交
html
<form action="/api/add" method="POST">
<input type="text" name="item">
<input type="submit" value="提交">
</form>
点提交 → 浏览器把表单数据拼成 FormData → 发 HTTP 请求 → 页面刷新。
为什么体验差?
- 页面白一下(刷新)
- 用户当前滚动位置丢失
- 所有 JS 状态(变量、DOM 引用)全部销毁重建
- 在 SPA 时代,这等于把房子拆了重建
现代模式:JS 接管一切
js
const oForm = document.querySelector('.add-items');
oForm.addEventListener('submit', addItem);
function addItem(e) {
e.preventDefault(); // ← 这行是关键!阻止浏览器默认提交
console.log(this); // this → <form> 元素
// 用 fetch/axios 发请求,页面不刷新
}
e.preventDefault() 做了什么?它告诉浏览器:"别管了,我自己来。"
然后我们通过 fetch 或 axios 发异步请求,拿到数据后操作 DOM 更新页面------整个流程用户看不到任何闪烁。
js
function addItem(e) {
e.preventDefault();
const input = this.querySelector('[name="item"]');
const text = input.value.trim();
if (!text) return;
const items = JSON.parse(localStorage.getItem('tapas') || '[]');
items.push(text);
localStorage.setItem('tapas', JSON.stringify(items));
render(); // 重新渲染列表
input.value = ''; // 清空输入框
}
到这里,表单的逻辑闭环了。但你很快会撞上一个更隐蔽的问题------
第三座山:this 到底指向谁?
当我写出 oForm.addEventListener('submit', addItem) 的时候,addItem 函数里的 this 指向 <form> 表单元素。这没问题。
但当我写了这段代码,事情开始变得诡异:
js
let obj = {
name: "赖庆庆",
say: function () {
console.log(this.name);
},
speak: function (a, b) {
console.log(a, b);
console.log(this);
}
};
var name = "佳明"; // var 会挂到 window 上
obj.say(); // "赖庆庆" ------ this 指向 obj,没毛病
const fn = obj.say; // 引用式赋值
fn(); // "佳明" ------ this 指向 window???
发生了什么?
this 不是在函数定义 时决定的,而是在函数调用时决定的。
csharp
obj.say() → 调用者是 obj → this === obj
fn() → 调用者是 window(裸调) → this === window
const fn = obj.say 只是把函数的引用给了 fn,跟 obj 已经没关系了。fn() 是普通函数调用,在非严格模式下 this 指向全局 window。因为 var name 把 "佳明" 挂到了 window.name 上,所以输出 "佳明"。
第一行加
"use strict"是很好的习惯 ------严格模式下裸调函数的this是undefined,不会静默污染全局对象。
this 的四种绑定规则(一张表就够了)
| 绑定方式 | 调用形式 | this 指向 |
示例 |
|---|---|---|---|
| 默认绑定 | fn() |
window / undefined(严格) |
裸调函数 |
| 隐式绑定 | obj.fn() |
调用对象 obj |
方法调用 |
| 显式绑定 | fn.call(ctx) |
手动指定的 ctx |
call/apply/bind |
| new 绑定 | new Fn() |
新创建的实例 | 构造函数 |
call、apply、bind 三兄弟
回到事件监听的问题。事件处理函数默认 this 指向触发元素,但如果我想手动指定呢?
js
let obj = { name: "赖庆庆" };
const addItemBind = addItem.bind(obj);
oForm.addEventListener('submit', addItemBind);
为什么不直接用 call 或 apply?
因为事件监听需要的是一个函数引用,不是函数执行结果。
js
// ❌ 错误:call 立即执行了,返回 undefined
oForm.addEventListener('submit', addItem.call(obj));
// ✅ 正确:bind 返回绑定了 this 的**新函数**,等事件触发时才调用
oForm.addEventListener('submit', addItem.bind(obj));
三兄弟的区别:
| 方法 | 执行时机 | 传参方式 | 返回值 |
|---|---|---|---|
call(ctx, a, b) |
立即 | 逐个传 | 函数返回值 |
apply(ctx, [a, b]) |
立即 | 数组传 | 函数返回值 |
bind(ctx, a, b) |
不立即 | 逐个传 | 新函数 |
bind 可以部分传参(柯里化),也是一个实用技巧:
js
function speak(greeting, name) {
console.log(`${greeting}, ${name}`);
}
const sayHi = speak.bind(null, '你好');
sayHi('甜总'); // "你好, 甜总"
箭头函数:我没有 this,但我有更好的
接着看一个更微妙的场景------setTimeout 里的 this:
js
var name = "梅西";
let obj = {
name: "姆巴佩",
say: function () {
console.log(this.name); // "姆巴佩"
// 普通函数版本:
setTimeout(function () {
console.log(this.name); // "梅西" ← this 跑了!
}, 1000);
// 箭头函数版本:
setTimeout(() => {
console.log(this.name); // "姆巴佩" ← this 稳住了
}, 1000);
}
};
obj.say();
为什么会这样?
setTimeout里的普通函数 是在全局上下文被调用的,this指向window- 箭头函数没有自己的
this,它直接使用定义时外层作用域 的this
本质上:
js
// 箭头函数的 this 等价于在外部存了一个变量
say: function () {
const that = this; // 保存 this
setTimeout(function () {
console.log(that.name); // 用闭包访问
}, 1000);
}
箭头函数让你不用再写 const that = this 这种丑陋的代码了。
记忆口诀 :箭头函数的
this是静态的 ------在哪写的,this 就是哪的。普通函数的this是动态的------谁调用,this 就是谁。
把所有东西串起来
回到最初的 Tapas 应用。当你理解了这三座山,这个看似简单的小应用突然变得厚重了:
js
"use strict";
// ====== this 绑定:事件处理函数 ======
const oForm = document.querySelector('.add-items');
const addItemBind = addItem.bind(obj); // bind 不立即执行,完美匹配事件监听
oForm.addEventListener('submit', addItemBind);
// ====== 表单拦截:阻止默认刷新 ======
function addItem(e) {
e.preventDefault(); // 现代做法:JS 接管提交
// 此时 this 已经被 bind 固定为 obj
console.log(this.name); // "赖庆庆",而不是 <form> 元素
}
// ====== 链接拦截 ======
document.querySelector('.lnk').addEventListener('click', goBaidu);
function goBaidu(e) {
e.preventDefault(); // 阻止跳转
console.log(this); // <a> 元素,没有 bind 所以默认指向触发元素
}
// ====== call vs apply vs bind ======
obj.say.call(obj2); // this → 甜总,立即
obj.say.apply(obj2); // this → 甜总,立即
obj.speak.call(obj2, '你好', '我是甜总'); // 逐个传参
obj.speak.apply(obj2, ['你好', '我是甜总']); // 数组传参
const fn2 = obj.speak.bind(obj2); // 不立即,返回新函数
fn2('你好', '我是甜总');
一张图总结全篇
bash
Tapas 菜单应用
│
├── 📦 数据存哪里?(存储选型)
│ ├── 浏览器缓存 → 二次打开更快
│ ├── LocalStorage → 刷新不丢数据(主力)
│ ├── Redis → 缓存热数据,扛高并发
│ ├── MySQL → 持久化业务数据
│ └── 向量数据库 → AI 语义搜索
│
├── 📝 表单怎么提交?(交互模式)
│ ├── 传统 form submit → 刷新页面(简单但粗暴)
│ └── fetch + e.preventDefault() → 无刷新(现代标配)
│
└── 🎯 this 指向谁?(核心机制)
├── 普通函数 → 动态绑定,看调用者
├── 箭头函数 → 静态绑定,看定义处
├── call/apply → 手动指定 + 立即执行
├── bind → 手动指定 + 延迟执行
└── 事件处理 → 默认指向触发元素
写在最后
一个 Tapas 菜单的 CRUD,带你走完了:
- 存储选型:从浏览器到 Redis 到 MySQL,每一层有每一层的职责
- 表单拦截 :理解为什么现代 Web 不用
action="/api/add"了 - this 绑定:区分动态和静态、立即和延迟,这是 JS 面试必考也最容易翻车的点
做小项目最大的价值不是项目本身,而是它逼你停下来,把那些"好像懂但其实没真懂"的东西一个一个搞清楚。
哪怕你只写一个 Todo App,只要能说清楚里面的
this和存储链路,你就超过了 90% 的初学者。