一个 Tapas 菜单 App,逼我啃下了前端存储和 this 绑定两座大山

新人以为的 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>

三个交互点:

  1. 表单 → 添加菜品
  2. 列表 → 展示 + 删除
  3. 链接 → 阻止跳转

下面逐个拆解,每一层都比你想象的要深。


第一座山:你的数据到底存在哪?

"前端数据存 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() 做了什么?它告诉浏览器:"别管了,我自己来。"

然后我们通过 fetchaxios 发异步请求,拿到数据后操作 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" 是很好的习惯 ------严格模式下裸调函数的 thisundefined,不会静默污染全局对象。


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);

为什么不直接用 callapply

因为事件监听需要的是一个函数引用,不是函数执行结果。

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,带你走完了:

  1. 存储选型:从浏览器到 Redis 到 MySQL,每一层有每一层的职责
  2. 表单拦截 :理解为什么现代 Web 不用 action="/api/add"
  3. this 绑定:区分动态和静态、立即和延迟,这是 JS 面试必考也最容易翻车的点

做小项目最大的价值不是项目本身,而是它逼你停下来,把那些"好像懂但其实没真懂"的东西一个一个搞清楚。

哪怕你只写一个 Todo App,只要能说清楚里面的 this 和存储链路,你就超过了 90% 的初学者。


相关推荐
xiaominlaopodaren1 小时前
three.js地图数学基础(二):Web Mercator
javascript·gis·three.js
谷哥的小弟1 小时前
TypeScript接口
javascript·typescript
RobinDevNotes1 小时前
轻量级动画引擎Anime.js迎来V4大版本更新
开发语言·前端·javascript·ecmascript·动画·c4前端
渣波1 小时前
React 性能优化实战:从 memo 到 Hooks 的底层原理与进阶封装
前端·javascript
触底反弹1 小时前
别再 Prop Drilling 了!一文彻底搞懂 React 组件通信的 5 种方案
前端·javascript·react.js
两只羊ovo1 小时前
listToTree 速通:一维数组怎么变出多级菜单?Map 和 reduce 两种全解
前端·javascript
何时梦醒1 小时前
React 进阶必修:彻底搞懂受控组件与非受控组件
前端·javascript·react.js
今日无bug2 小时前
JS 同步与异步:从单线程到 Promise
javascript·promise
sugar__salt2 小时前
深入理解 React useContext:跨层级组件通信的核心利器
前端·javascript·react.js·前端框架·框架