从Tapas小Demo理清localStorage、事件与this

从一个 Tapas 小 Demo 理清 HTML5 本地存储、表单事件和 this 指向

在前端里,"存储"和"状态"几乎无处不在:表单输入要暂存,页面刷新后数据希望还在,接口结果可能要缓存,用户偏好也需要保存。这个目录里的 Demo 名叫 LocalStorage,页面上有一个添加 Tapas 的表单,同时代码里还穿插了 this、事件处理函数、call/apply/bind、箭头函数等知识点。

不过先说结论:当前代码还没有真正实现 localStorage 存储逻辑,更多是在演示事件和 this。如果要把它变成一个完整的 HTML5 本地存储 Demo,需要在提交表单时把数据写入 localStorage,页面加载时再从 localStorage 读取并渲染。同时,用户输入不能直接拼进 HTML 字符串,否则会留下 XSS 风险。

  • 前端常见存储方式有哪些
  • 表单默认提交为什么常被拦截
  • 链接点击为什么也可以阻止默认跳转
  • 事件处理函数里的 this 指向谁
  • callapplybind 到底有什么区别
  • 箭头函数为什么能"继承"外层 this
  • 如何补全一个正确的 localStorage 版本

1. 前端语境里的"存储"不只 localStorage

readme.md 里列了很多存储方式,比如 MySQL、Redis、本地文件、云盘、浏览器缓存、Embedding 存储等。它们都叫"存储",但解决的问题并不一样。

站在前端开发角度,可以先按位置粗略分成三类:

类型 例子 适合场景
浏览器端存储 localStoragesessionStorage、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 控制逻辑。

所以在现代前端项目中,表单提交常见流程是:

  1. 监听 submit 事件
  2. 调用 e.preventDefault() 阻止页面刷新
  3. 读取用户输入
  4. 更新内存状态
  5. 写入本地存储或发送接口请求
  6. 重新渲染 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");

事件监听器需要的是"未来执行的函数",所以通常不能用 callapply 直接传给 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 的属性,而 letconst 不会:

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 指向新创建的实例对象
  • callapply 会立即执行,bind 返回新函数
  • 箭头函数没有自己的 this,会使用外层作用域的 this
  • localStorage 适合保存少量、非敏感、本地持久化数据
相关推荐
用户938515635071 小时前
RAG 实战:从零搭建语义搜索系统,彻底告别关键词匹配的尴尬
javascript·人工智能
李明卫杭州1 小时前
Vue2 中 v-model 处理不同数据结构的技巧
前端·javascript·vue.js
李明卫杭州1 小时前
使用 computed 处理 v-model 复杂数据结构
前端·javascript·vue.js
丨我是张先生丨1 小时前
日语单词 Web Page
前端·css·css3
禅思院3 小时前
AI对话前端从入门到崩溃:一个长对话引发的五层优化战争【引子】
前端·面试·架构
TrisighT3 小时前
Electron 鸿蒙 PC 上点外链唤醒应用,我试了 6 种写法只有 1 种能跑
前端·electron·harmonyos
2501_930707784 小时前
如何将HTML文件转换为纯文本(详细步骤指南)
前端·html
天才熊猫君4 小时前
配置与数据分离:一种可视化搭建的属性编辑方案
前端·javascript
林希_Rachel_傻希希5 小时前
web性能之相关路径——AI总结
前端·javascript·面试