从一个 Tapas 小 Demo 理清 HTML5 本地存储、表单事件和 this 指向
在前端里,"存储"和"状态"几乎无处不在:表单输入要暂存,页面刷新后数据希望还在,接口结果可能要缓存,用户偏好也需要保存。这个目录里的 Demo 名叫 LocalStorage,页面上有一个添加 Tapas 的表单,同时代码里还穿插了 this、事件处理函数、call/apply/bind、箭头函数等知识点。
不过先说结论:当前代码还没有真正实现 localStorage 存储逻辑,更多是在演示事件和 this。如果要把它变成一个完整的 HTML5 本地存储 Demo,需要在提交表单时把数据写入 localStorage,页面加载时再从 localStorage 读取并渲染。同时,用户输入不能直接拼进 HTML 字符串,否则会留下 XSS 风险。
- 前端常见存储方式有哪些
- 表单默认提交为什么常被拦截
- 链接点击为什么也可以阻止默认跳转
- 事件处理函数里的
this指向谁 call、apply、bind到底有什么区别- 箭头函数为什么能"继承"外层
this - 如何补全一个正确的
localStorage版本
1. 前端语境里的"存储"不只 localStorage
readme.md 里列了很多存储方式,比如 MySQL、Redis、本地文件、云盘、浏览器缓存、Embedding 存储等。它们都叫"存储",但解决的问题并不一样。
站在前端开发角度,可以先按位置粗略分成三类:
| 类型 | 例子 | 适合场景 |
|---|---|---|
| 浏览器端存储 | localStorage、sessionStorage、IndexedDB、Cookie |
保存用户偏好、草稿、离线数据、小体积状态 |
| 服务端存储 | MySQL、PostgreSQL、MongoDB、文件系统 | 持久化业务数据,多端共享 |
| 缓存型存储 | Redis、CDN、HTTP 缓存、浏览器缓存 | 提升读取速度,降低重复计算或请求 |
这里要注意一个容易混淆的点:浏览器缓存和 localStorage 不是一回事。
浏览器缓存通常由 HTTP 缓存策略控制,比如 Cache-Control,主要用于缓存 HTML、CSS、JS、图片等资源。localStorage 则是 JavaScript 可以直接读写的键值对存储,用来保存业务层面的少量数据。
2. 当前页面结构:一个提交表单
index.html 的核心结构如下:
html
<ul class="plates">
<li>Loading Tapas...</li>
<form class="add-items">
<input type="text" name="item" placeholder="Add a new tapas" required>
<input type="submit" value="+Add Item">
</form>
<a href="https://www.baidu.com" class="lnk">去百度</a>
</ul>
这段代码里有三个点值得关注。
第一,required 是 HTML5 表单校验能力,表示该输入框不能为空。点击提交按钮时,如果输入为空,浏览器会阻止提交并提示用户。
第二,form 默认会提交并刷新页面。传统表单会把数据提交到 action 指定的地址,如果没有写 action,通常会提交到当前页面。现代前端里,如果我们希望用 JavaScript 接管提交逻辑,就需要在 submit 事件里调用:
js
e.preventDefault();
第三,form 放在 ul 里面并不推荐。按照 HTML 语义,ul 的直接子元素应该主要是 li。更合理的结构是把 form 放到 ul 外面,或者用 li 包裹表单项。
3. submit 事件:为什么要阻止默认行为
common.js 里注册了表单提交事件:
js
const oForm = document.querySelector(".add-items");
const addItemBind = addItem.bind(obj);
oForm.addEventListener("submit", addItemBind);
function addItem(e) {
console.log(e);
console.log(this);
e.preventDefault();
}
这里的关键是 e.preventDefault()。
如果不阻止默认行为,点击提交按钮后页面会刷新。对于一个前端交互 Demo 来说,刷新会导致页面状态丢失,也会让你看不到后续 JavaScript 控制逻辑。
所以在现代前端项目中,表单提交常见流程是:
- 监听
submit事件 - 调用
e.preventDefault()阻止页面刷新 - 读取用户输入
- 更新内存状态
- 写入本地存储或发送接口请求
- 重新渲染 UI
同样的思路也出现在链接点击事件里:
js
document.querySelector(".lnk").addEventListener("click", goBaidu);
function goBaidu(e) {
console.log(this);
e.preventDefault();
}
a 标签的默认行为是跳转到 href 指定的地址。调用 e.preventDefault() 后,浏览器不会跳转,开发者就可以自己决定接下来做什么,比如弹确认框、统计埋点、用前端路由切换页面。
这也体现了前端事件模型的一个特点:代码不是从上到下一次性把所有交互都执行完,而是先注册事件处理函数,等用户提交表单、点击链接、输入内容时,再由浏览器触发对应回调。
4. 事件处理函数里的 this
在 JavaScript 中,普通函数的 this 不是看函数在哪里声明,而是看函数如何被调用。
当前代码里有一段注释是正确方向:
js
// 事件一定要有一个事件处理函数 this默认指向事件触发对象
如果直接这样注册:
js
oForm.addEventListener("submit", addItem);
那么 addItem 作为事件处理函数执行时,函数内部的 this 通常指向事件绑定的元素,也就是这个 form。
但是代码中实际注册的是:
js
const addItemBind = addItem.bind(obj);
oForm.addEventListener("submit", addItemBind);
bind(obj) 会返回一个新函数,并且把这个新函数执行时的 this 固定为 obj。所以此时 addItem 内部打印出来的 this 不是表单元素,而是:
js
{
name: "落月",
say: function () {},
speak: function () {}
}
这也是 bind 在事件处理里常见的用途:事件未来才会触发,但我们希望提前固定回调函数运行时的 this。
除了事件回调,this 还有几个常见场景。
作为对象方法调用时,this 指向调用它的对象:
js
const obj = {
name: "落月",
say: function () {
console.log(this.name);
}
};
obj.say(); // 落月
但如果把方法赋值给变量,再作为普通函数调用,原来的调用对象就丢了:
js
const fn = obj.say;
fn(); // 非严格模式下通常是 window,严格模式下是 undefined
作为构造函数调用时,this 指向新创建出来的实例对象:
js
function User(name) {
this.name = name;
}
const user = new User("落月");
console.log(user.name); // 落月
5. call、apply、bind 的区别
common.js 里还演示了这三种手动指定 this 的方式:
js
obj.say.call(obj2);
obj.say.apply(obj2);
obj.speak.call(obj2, "hello", "world");
obj.speak.apply(obj2, ["hello", "world"]);
const fn2 = obj.speak.bind(obj2);
fn2("hello", "world");
它们都能指定函数运行时的 this,区别在于是否立即执行,以及传参方式不同。
| 方法 | 是否立即执行 | 参数形式 |
|---|---|---|
call |
是 | 参数逐个传入 |
apply |
是 | 参数用数组或类数组传入 |
bind |
否 | 返回一个绑定好 this 的新函数 |
例如:
js
function speak(a, b) {
console.log(this.name, a, b);
}
const user = { name: "落月" };
speak.call(user, "hello", "world");
speak.apply(user, ["hello", "world"]);
const boundSpeak = speak.bind(user);
boundSpeak("hello", "world");
事件监听器需要的是"未来执行的函数",所以通常不能用 call 或 apply 直接传给 addEventListener:
js
// 错误:addItem 会立刻执行,而且这里没有事件对象 e,通常会直接报错
oForm.addEventListener("submit", addItem.call(obj));
// 正确:bind 返回一个新函数,等 submit 事件发生时再执行
oForm.addEventListener("submit", addItem.bind(obj));
6. 严格模式下普通函数的 this
common.js 顶部启用了严格模式:
js
"use strict";
这会影响普通函数调用时的 this。
在非严格模式下:
js
function fn() {
console.log(this);
}
fn(); // 浏览器中通常是 window
在严格模式下:
js
"use strict";
function fn() {
console.log(this);
}
fn(); // undefined
这也是现代 JavaScript 更推荐严格模式的原因之一:它减少了很多隐式全局对象带来的误操作。
另外,在浏览器的传统非 module 脚本里,var 在全局作用域中声明的变量会成为 window 的属性,而 let 和 const 不会:
js
var a = 1;
let b = 2;
const c = 3;
console.log(window.a); // 1
console.log(window.b); // undefined
console.log(window.c); // undefined
7. 箭头函数没有自己的 this
2.html 里演示了箭头函数:
js
var name = "落月";
let obj = {
name: "落叶",
say: function () {
console.log(this.name);
setTimeout(() => {
console.log(this.name);
}, 1000);
}
};
obj.say();
这段代码的正确输出应该是:
txt
落叶
落叶
原因是 obj.say() 调用时,say 里的 this 指向 obj。而 setTimeout 里面使用的是箭头函数,箭头函数没有自己的 this,它会捕获外层作用域的 this,也就是 say 执行时的 this。
所以这里不会变成 window.name,也不会输出 "落月"。
如果写成普通函数,情况就不同了:
js
let obj = {
name: "落叶",
say: function () {
setTimeout(function () {
console.log(this.name);
}, 1000);
}
};
obj.say();
在浏览器非严格模式下,setTimeout 里的普通函数通常由 window 调用,所以 this 可能指向 window。如果想让普通函数里的 this 仍然指向 obj,可以使用 bind:
js
let obj = {
name: "落叶",
say: function () {
setTimeout(function () {
console.log(this.name);
}.bind(this), 1000);
}
};
8. 补全真正的 localStorage 版本
当前 Demo 页面名叫 LocalStorage,但 common.js 还没有读写 localStorage。下面是一份更完整的实现。
HTML 可以调整成这样:
html
<div class="wrapper">
<h2>Local Tapas</h2>
<ul class="plates"></ul>
<form class="add-items">
<input type="text" name="item" placeholder="Add a new tapas" required>
<input type="submit" value="+ Add Item">
</form>
</div>
JavaScript:
js
"use strict";
const form = document.querySelector(".add-items");
const list = document.querySelector(".plates");
const STORAGE_KEY = "local-tapas-items";
const items = JSON.parse(localStorage.getItem(STORAGE_KEY)) || [];
function renderItems(items, listElement) {
listElement.textContent = "";
items.forEach((item, index) => {
const li = document.createElement("li");
const label = document.createElement("label");
const checkbox = document.createElement("input");
const text = document.createElement("span");
checkbox.type = "checkbox";
checkbox.dataset.index = index;
checkbox.checked = item.done;
text.textContent = item.text;
label.append(checkbox, text);
li.append(label);
listElement.append(li);
});
}
function addItem(e) {
e.preventDefault();
const input = form.elements.namedItem("item");
const text = input.value.trim();
if (!text) return;
items.push({
text,
done: false
});
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
renderItems(items, list);
form.reset();
}
function toggleItem(e) {
if (!e.target.matches("input[type='checkbox']")) return;
const index = Number(e.target.dataset.index);
items[index].done = !items[index].done;
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
renderItems(items, list);
}
form.addEventListener("submit", addItem);
list.addEventListener("click", toggleItem);
renderItems(items, list);
这段代码做了几件事:
- 页面初始化时,从
localStorage读取数据 - 表单提交时,把新项目加入数组
- 用
JSON.stringify把数组转成字符串后存入localStorage - 渲染列表
- 点击复选框时切换完成状态
- 每次状态变化后重新写回
localStorage
渲染列表时,这里没有把用户输入直接拼到 innerHTML 里,而是使用 textContent 写入文本。这样即使用户输入 <script> 或其他 HTML 片段,也只会被当作普通文本显示,不会被浏览器当成标签执行。
为什么要用 JSON.stringify?因为 localStorage 只能保存字符串。数组和对象必须先序列化成字符串,读取时再用 JSON.parse 转回来。
9. localStorage 的特点和限制
localStorage 很适合入门 HTML5 存储,但实际项目里要知道它的边界。
它的特点:
- 同源页面之间共享
- 数据没有默认过期时间
- 页面刷新后数据仍然存在
- API 简单,只有键值对读写
- 只能存字符串
- 读写是同步操作
常用 API:
js
localStorage.setItem("name", "落月");
localStorage.getItem("name");
localStorage.removeItem("name");
localStorage.clear();
不适合的场景:
- 保存敏感信息,比如 token、密码、身份证号
- 保存大量数据
- 高频读写大对象
- 需要复杂查询的数据
- 多端共享的核心业务数据
如果数据量更大、结构更复杂,可以考虑 IndexedDB。如果数据必须多端同步,就应该放到服务端数据库。
10. 小结
这个 Demo 虽然文件很少,但覆盖了几个前端基础知识点:
- 表单默认提交会刷新页面,前端接管时需要
preventDefault - 链接默认会跳转,也可以通过
preventDefault交给 JavaScript 接管 - 普通函数的
this取决于调用方式 - 事件处理函数里的
this默认指向绑定事件的元素 - 构造函数里的
this指向新创建的实例对象 call和apply会立即执行,bind返回新函数- 箭头函数没有自己的
this,会使用外层作用域的this localStorage适合保存少量、非敏感、本地持久化数据