普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月4日首页

你点的“刷新”是假刷新?前端路由的瞒天过海术

作者 kyriewen
2026年4月4日 11:32

为什么单页应用切换页面时,浏览器没有真正刷新?地址栏变了,页面却没白一下?今天我们来拆穿前端路由的“魔术”——它根本没去服务器要新页面,而是自己偷偷换了内容。看完这篇,你也能实现一个自己的前端路由。

前言

你有没有注意过,现在很多网站(比如知乎、B站、Github)点开一个新页面,地址栏变了,但页面没有那种“白屏-加载-闪现”的过程,而是瞬间切换内容。这就像你走进一家餐厅,菜单上写着“换桌”,你以为换了个房间,结果服务员只是把你桌上的桌布换了。

这就是前端路由干的“好事”。它让页面看起来跳转了,实际上只是JS在背后偷偷换了DOM,地址栏的变化也是骗你的。今天我们就来揭开这个魔术的奥秘,顺便自己写一个简单的路由。

一、什么是前端路由?

传统网站,点击链接会向服务器请求一个新HTML,浏览器刷新整个页面。这叫后端路由

单页应用(SPA)里,所有页面逻辑都在一个HTML里。切换“页面”时,不会请求新HTML,而是JS擦掉旧内容,画上新内容。同时,通过某种手段改变浏览器的地址栏URL,让用户感觉像换了个页面。这就是前端路由

前端路由的实现依赖两个“戏法”:

  • 改变URL但不刷新页面
  • 监听URL变化并渲染对应组件

二、Hash模式:带#号的“假跳转”

早期前端路由用的是hash(也就是URL里#后面的部分)。改变#后的值,不会触发页面刷新,也不会向服务器发请求。浏览器自己会记录历史(前进后退可用)。

// 改变hash
window.location.hash = 'home';

// 监听hash变化
window.addEventListener('hashchange', () => {
  const hash = window.location.hash.slice(1); // 去掉#
  renderPage(hash);
});

比如https://example.com/#/home,你改成#/about,页面不会刷新,但hashchange事件会触发,你可以在回调里根据hash渲染不同内容。

优点:兼容性好,IE也能用。
缺点:URL有个丑陋的#;服务端无法捕获#后面的内容(因为#之后的部分不会发到服务器)。

三、History模式:看起来像真的

HTML5新增了pushStatereplaceState,可以改变URL路径,同样不刷新页面。加上popstate事件监听,就能实现干净的路由(没有#)。

// 改变URL(添加一条历史记录)
history.pushState({ page: 'home' }, 'Home', '/home');

// 替换当前历史记录(不新增)
history.replaceState({ page: 'about' }, 'About', '/about');

// 监听前进后退
window.addEventListener('popstate', (event) => {
  const state = event.state; // pushState时传的数据
  renderPage(location.pathname);
});

优点:URL干净,像真实多页面。
缺点:需要服务端配合——因为刷新页面时,浏览器会按真实路径请求服务器,如果服务器没配置,会404。解决方案:所有路由都返回同一个HTML(即index.html)。

四、手写一个迷你前端路由

我们来实现一个最简单的Hash路由,包含三个“页面”:首页、关于、404。

<nav>
  <a href="#/home">首页</a>
  <a href="#/about">关于</a>
  <a href="#/nothing">不存在</a>
</nav>
<div id="app">内容会变</div>
function renderPage(path) {
  const app = document.getElementById('app');
  if (path === '/home') {
    app.innerHTML = '<h2>🏠 首页</h2><p>欢迎来到我的网站</p>';
  } else if (path === '/about') {
    app.innerHTML = '<h2>📖 关于</h2><p>这是一个前端路由演示</p>';
  } else {
    app.innerHTML = '<h2>❌ 404</h2><p>页面不存在</p>';
  }
}

// 监听hash变化
window.addEventListener('hashchange', () => {
  const hash = window.location.hash.slice(1); // 去掉#
  renderPage(hash || '/home');
});

// 页面加载时执行一次
window.addEventListener('load', () => {
  const hash = window.location.hash.slice(1);
  renderPage(hash || '/home');
});

就这么几行,你已经实现了一个前端路由。当然,实际框架里的路由更复杂(嵌套路由、动态参数、路由守卫等),但核心原理就是监听URL变化 + 渲染对应组件。

五、前端路由与后端路由的区别

特性 后端路由 前端路由
请求方式 每次跳转都请求服务器 不请求服务器(JS切换内容)
刷新页面 会重新下载HTML 会刷新但需要服务端配合(history模式)
首屏加载 只加载当前页面 通常要加载所有JS(可代码分割)
用户体验 有白屏、闪烁 切换流畅
SEO 友好 较差(需SSR或预渲染)

六、常见坑点与解决方案

1. History模式刷新404

配置Nginx将所有路由指向index.html:

location / {
  try_files $uri $uri/ /index.html;
}

2. 路由跳转但页面不滚动

单页切换时,滚动条位置可能保留在上一个页面的位置。需要在路由变化后手动window.scrollTo(0, 0)

3. 动态路由参数

比如/user/:id,你需要从路径中提取id。可以用正则或简单分割:

function matchRoute(path, routePath) {
  const pathParts = path.split('/');
  const routeParts = routePath.split('/');
  if (pathParts.length !== routeParts.length) return null;
  const params = {};
  for (let i = 0; i < pathParts.length; i++) {
    if (routeParts[i].startsWith(':')) {
      params[routeParts[i].slice(1)] = pathParts[i];
    } else if (routeParts[i] !== pathParts[i]) {
      return null;
    }
  }
  return params;
}

七、总结

  • 前端路由让单页应用切换页面时不刷新,体验流畅。
  • Hash模式# + hashchange,兼容性好,但URL丑。
  • History模式pushState + popstate,URL干净,需服务端配合。
  • 原理很简单:监听URL变化 → 根据路径渲染不同内容。
  • 现代框架(React Router、Vue Router)都是在此基础上增强。

下次再看到地址栏变了但页面没白,你就可以自信地说:“哼,不过是在演我。”

如果你喜欢今天的“魔术揭秘”,点个赞让更多人看到。明天我们将聊聊Webpack的Loader和Plugin原理,从零理解构建工具的核心。我们明天见!

本地存储全家桶:从localStorage到IndexedDB,把数据塞进用户浏览器

作者 kyriewen
2026年4月3日 20:36

你有没有想过,为什么刷新页面后,有些网站还能记住你的登录状态?为什么购物车里的商品关掉浏览器再打开还在?今天我们就来聊聊浏览器里的“记忆术”——本地存储。从简单的钥匙串localStorage,到能装下整个图书馆的IndexedDB,总有一款适合你。

前言

想象一下,你每次去网吧上网,都要重新登录所有账号、重新设置主题、重新添加购物车——是不是想砸电脑?还好,浏览器有“记忆功能”。它能在你的电脑里存点东西,下次再来时直接拿出来用。

这个“记忆功能”就是Web存储。今天我们就来盘点一下浏览器提供的几种存储方式:localStorage、sessionStorage、cookie,以及能存视频、存大文件的IndexedDB。看完你就能根据场景选对工具,再也不用担心数据“蒸发”了。

一、localStorage:永不过期的便利贴

localStorage是一个挂在window上的对象,它存的数据没有过期时间,除非你手动清除或者用户清理浏览器缓存,否则会一直待在那里。

基本用法

// 存数据(键值对,值必须是字符串)
localStorage.setItem('username', '张三');
localStorage.setItem('theme', 'dark');

// 取数据
const name = localStorage.getItem('username'); // '张三'

// 删除某条
localStorage.removeItem('theme');

// 全部清空
localStorage.clear();

// 获取存储数量
console.log(localStorage.length);

存对象怎么办?

localStorage只能存字符串,所以对象要先转成JSON:

const user = { name: '张三', age: 18 };
localStorage.setItem('user', JSON.stringify(user));

// 读取时解析
const stored = JSON.parse(localStorage.getItem('user'));

容量限制

大多数浏览器限制5MB~10MB,够存一些配置、用户信息、小量缓存。

特点总结

  • 同步:操作是同步的,会阻塞主线程(但一般很快)。
  • 同源:同一域名下所有页面共享(包括不同标签页)。
  • 永久:除非手动清除。
  • 仅客户端:不会自动发送到服务器。

二、sessionStorage:标签页关闭就消失的临时工

sessionStoragelocalStorage的API一模一样,但生命周期不同:它只存在于当前标签页。关掉标签页,数据就没了。刷新页面还在,但新开标签页(即使是同一个网站)会得到一个新的sessionStorage。

// 用法完全一样
sessionStorage.setItem('tempData', '临时值');

适用场景:表单临时草稿、当前页面的中间状态、不希望跨页面共享的敏感信息。

三、cookie:老前辈,但有点“重”

cookie是最早的浏览器存储机制,但如今除了会话管理(登录态)和少量用户追踪,大部分场景已被localStorage替代。

特点

  • 容量小:每个cookie 4KB 左右。
  • 自动携带:每次HTTP请求都会把cookie发给服务器(增加带宽消耗)。
  • 可设置过期时间。
  • 可标记HttpOnly(禁止JS读取,防XSS)、Secure(仅HTTPS)、SameSite(防CSRF)。
// 设置cookie(繁琐)
document.cookie = "username=张三; expires=Thu, 18 Dec 2026 12:00:00 UTC; path=/";

// 读取cookie(需要自己解析)
console.log(document.cookie);

现在主流做法:用localStorage存非敏感数据,用httpOnly cookie存登录凭证

四、IndexedDB:浏览器里的“小数据库”

如果你要存的东西很大(几百MB),或者需要复杂的查询、索引、事务,那么localStorage就不够用了。这时候请出IndexedDB——一个运行在浏览器里的非关系型数据库。

特点

  • 容量大:通常250MB+,甚至更多(取决于浏览器)。
  • 异步API:基于Promise或回调,不阻塞主线程。
  • 支持索引、游标、事务。
  • 可以存储File、Blob、ArrayBuffer等二进制数据。

快速上手

IndexedDB的API比较原始,不过我们可以封装一下。

// 1. 打开/创建数据库
const request = indexedDB.open('MyDatabase', 1);

request.onupgradeneeded = (event) => {
  const db = event.target.result;
  // 创建一个对象仓库(类似表),指定主键
  const store = db.createObjectStore('users', { keyPath: 'id' });
  // 创建索引,用于快速查询
  store.createIndex('name', 'name', { unique: false });
};

request.onsuccess = (event) => {
  const db = event.target.result;
  console.log('数据库打开成功');
  // 后续增删改查都用这个db对象
};

request.onerror = (event) => {
  console.error('数据库打开失败', event.target.error);
};

增删改查

// 添加数据(在onsuccess里拿到db)
const transaction = db.transaction(['users'], 'readwrite');
const store = transaction.objectStore('users');
const addRequest = store.add({ id: 1, name: '张三', age: 18 });

addRequest.onsuccess = () => console.log('添加成功');
addRequest.onerror = (e) => console.error('添加失败', e.target.error);

// 查询
const getRequest = store.get(1);
getRequest.onsuccess = () => console.log(getRequest.result);

// 更新(使用put,如果存在则覆盖)
store.put({ id: 1, name: '李四', age: 20 });

// 删除
store.delete(1);

使用游标遍历

const range = IDBKeyRange.bound(1, 10); // id从1到10
store.openCursor(range).onsuccess = (e) => {
  const cursor = e.target.result;
  if (cursor) {
    console.log(cursor.value);
    cursor.continue(); // 继续下一个
  }
};

现代封装:localForage

原生IndexedDB API太啰嗦,推荐用localForage这个库,它提供了类似localStorage的简洁API,但背后自动选择IndexedDB、WebSQL或localStorage。

// 使用localForage
import localforage from 'localforage';

await localforage.setItem('user', { name: '张三' });
const user = await localforage.getItem('user');

五、四种存储方式对比

特性 localStorage sessionStorage cookie IndexedDB
容量 5-10MB 5-10MB 4KB 几百MB
生命周期 永久 标签页关闭 可设置过期 永久
跨标签页
异步 同步 同步 同步 异步
自动发到服务器 是(每次请求)
数据类型 字符串 字符串 字符串 任意(结构化克隆)
查询能力 索引、游标

六、选型指南:到底用哪个?

  • 简单键值对,少量数据localStorage,比如用户偏好设置、主题、是否首次访问。
  • 临时数据,只在一个页面用sessionStorage,比如多步骤表单的暂存。
  • 登录凭证httpOnly cookie(安全)配合后端。
  • 大量结构化数据、离线应用IndexedDB,比如邮件客户端、笔记应用、缓存API数据。
  • 需要与后端自动同步cookieAuthorization头(用localStorage存token也行,但要注意XSS)。

七、避坑指南

1. localStorage 的同步阻塞

大量数据存取会阻塞UI,建议不要存超过几MB,或改用IndexedDB。

2. 隐私模式

Safari的隐私模式下,localStorage和IndexedDB可能不可用或容量极低,要写try-catch降级。

3. 序列化问题

localStorage存对象会丢失原型链、函数、Symbol、循环引用。用JSON.stringify前确保数据可序列化。

4. 安全提醒

永远不要把敏感信息(如密码、token)明文存在localStorage,因为任何JS都能读到(XSS攻击)。token建议用httpOnly cookie或短时效+refresh机制。

5. IndexedDB 版本升级

当修改数据库结构时,需要增加版本号,并在onupgradeneeded里处理旧数据迁移,否则会报错。

八、总结:存储就像选工具箱

  • localStorage:日常杂货,随手放。
  • sessionStorage:临时工,关窗走人。
  • cookie:老古董,特殊场合用。
  • IndexedDB:重武器,存大文件、复杂查询。

掌握了这些,你就可以在浏览器里随心所欲地存数据了。明天我们将继续前端工程化的旅程,聊聊Cookie与Session的区别,以及现代认证方案JWT。

如果你觉得今天的存储全家桶够实用,点个赞让更多人看到。我们明天见!

昨天以前首页

MutationObserver:DOM界的“卧底”,暗中观察每个风吹草动

作者 kyriewen
2026年4月2日 13:50

你想知道页面上的某个元素什么时候被偷偷改了吗?比如有个熊孩子脚本悄悄改了你的广告位,或者某个懒加载图片终于加载完了?今天我们就来请一位“卧底”——MutationObserver,让它24小时盯着DOM树,任何变化都逃不过它的眼睛。

前言

假设你开了一家便利店,店里装了监控。你想知道:什么时候有人进来?什么时候货架上的商品被拿走了?什么时候价格标签被换了?普通的监控只能录像,但你需要的是“智能警报”——一有变化就通知你。

这就是MutationObserver的活。它是浏览器提供的一个API,专门用来监听DOM树的变化:节点增删、属性修改、文本内容改变……统统能抓到。而且它不会像setInterval那样一直轮询,性能好得多。

一、MutationObserver是啥?

MutationObserver是一个构造函数,用来创建一个观察者对象。你可以给它指定一个回调函数,然后让它去“盯”某个DOM节点。一旦这个节点或它的子孙节点发生变化,回调函数就会被触发。

// 创建一个观察者实例,传入回调
const observer = new MutationObserver((mutationsList, observer) => {
  for (let mutation of mutationsList) {
    console.log(mutation.type, '发生了变化');
  }
});

// 指定要观察的节点
const targetNode = document.getElementById('watch-me');

// 开始观察
observer.observe(targetNode, {
  attributes: true,    // 观察属性变化
  childList: true,     // 观察子节点增删
  subtree: true,       // 观察所有后代节点
  characterData: true  // 观察文本内容变化
});

// 某天不想观察了
// observer.disconnect();

二、能观察到哪些变化?

配置选项决定了你关心哪些“风吹草动”:

  • attributes:属性变了(比如classstylesrc被改)
  • childList:子节点被增删(添加或删除元素、文本节点)
  • characterData:文本节点的内容变了
  • subtree:是否监听后代节点(默认false,只监听目标节点)
  • attributeFilter:只监听特定属性,比如['class', 'src']
  • attributeOldValue:是否记录旧属性值
  • characterDataOldValue:是否记录旧文本值

三、实战:监听广告位有没有被篡改

很多网站会在页面上放广告,但有些恶意脚本会偷偷把广告位换成自己的内容。用MutationObserver可以第一时间发现并报警。

<div id="ad-container">
  <img src="real-ad.jpg" alt="官方广告">
</div>
const adContainer = document.getElementById('ad-container');

const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.type === 'childList') {
      // 子节点被改了
      console.warn('⚠️ 广告位内容被篡改!');
      // 可以上报服务器,或者恢复内容
    } else if (mutation.type === 'attributes' && mutation.attributeName === 'src') {
      console.warn('⚠️ 广告图片被替换了!');
    }
  });
});

observer.observe(adContainer, {
  childList: true,
  subtree: true,
  attributes: true,
  attributeFilter: ['src', 'href']
});

四、实战:监听输入框内容变化(代替input事件?)

input事件已经能监听输入框变化,但MutationObserver可以监听更底层的文本节点变化,比如通过JS直接修改.valueinput事件可能不触发,但MutationObserver可以。

<input id="username" type="text">
const input = document.getElementById('username');

const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.type === 'attributes' && mutation.attributeName === 'value') {
      console.log('输入框的值被改了,新值:', input.value);
    }
  });
});

observer.observe(input, {
  attributes: true,
  attributeFilter: ['value']
});

注意:这种方式监听value属性变化,只对通过JS设置.value有效,用户手动输入不会触发(因为用户输入不改变value属性,而是改变元素的defaultValue和内部状态)。所以实际中监听输入框还是input事件更合适。这里只是演示能力。

五、实战:监听动态加载的图片,做懒加载

很多懒加载库用IntersectionObserver,但如果你想知道图片什么时候被添加到DOM,可以用MutationObserver。

const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    mutation.addedNodes.forEach((node) => {
      if (node.nodeType === 1 && node.tagName === 'IMG') {
        console.log('新图片出现了:', node.src);
        // 可以在这里做懒加载初始化
      }
    });
  });
});

observer.observe(document.body, {
  childList: true,
  subtree: true
});

六、性能注意事项

MutationObserver虽然比轮询好,但也不能滥用。以下几点要注意:

  1. 不要观察整个document:如果你observe(document.body, { subtree: true, childList: true, attributes: true }),那页面上的任何变化都会触发回调,频繁执行可能影响性能。尽量把观察范围缩小到具体容器。

  2. 回调里不要做太重的操作:MutationObserver的回调是在微任务中执行的,如果里面操作DOM或者计算太多,会阻塞后续渲染。

  3. 及时disconnect:如果不再需要观察,记得调用disconnect()释放资源。

  4. 使用takeRecords():在disconnect之前,可以调用observer.takeRecords()取出尚未处理的变化记录。

七、与旧API对比:Mutation Events的悲惨往事

很久以前,浏览器有一套Mutation Events(比如DOMNodeInsertedDOMAttrModified等)。它们的问题很多:

  • 性能差,每次变化都同步触发,容易导致重入和崩溃
  • 不支持批量观察
  • 被标记为废弃

MutationObserver是它们的完美替代,异步、批量、性能好。

八、总结:MutationObserver就是你的“鹰眼”

  • 它能监听DOM树的各种变化:属性、子节点、文本内容。
  • 配置灵活,可以精确到特定属性或是否包含后代。
  • 异步回调,批量返回变化记录,性能优秀。
  • 应用场景:监听动态内容加载、检测第三方脚本篡改、实现数据绑定(比如某些MVVM库的底层)、与React/Vue的虚拟DOM配合调试等。

有了MutationObserver,你就可以在DOM变化时第一时间响应,像一个隐形的守护者。明天我们将进入Web Storage的世界,看看localStorage、sessionStorage和IndexedDB怎么帮你把数据存到用户浏览器里。

如果你觉得今天的“卧底”够犀利,点个赞让更多人看到。我们明天见!

DOM树与节点操作:用JS给网页“动手术”

作者 kyriewen
2026年3月29日 12:02

你写的HTML页面,在浏览器眼里其实是一棵树。今天我们就来当一回“外科医生”,用JS给这棵树做手术——增、删、改、查,想怎么动就怎么动。看完这篇,你就能理解为什么说“JS能控制网页的一切”。

前言

你有没有想过,当你用document.getElementById拿到一个元素,然后改它的文字、换它的颜色时,背后发生了什么?

其实,浏览器把HTML解析成了一棵“树”,每个标签、属性、文本都是树上的一个“节点”。JS能做的,就是在这棵树上爬上爬下,找到某个节点,然后对它做各种操作——换个果子、摘掉枯枝、甚至嫁接新枝。

今天我们就来解剖这棵DOM树,学会用JS给网页“做手术”。

一、DOM树:网页的“族谱”

DOM(Document Object Model)把HTML文档表示成一棵树。比如这段HTML:

<!DOCTYPE html>
<html>
  <head>
    <title>我的网页</title>
  </head>
  <body>
    <div class="container">
      <h1>标题</h1>
      <p>一段文字</p>
    </div>
  </body>
</html>

在浏览器眼里,它长这样:

html
├── head
│   └── title
│       └── "我的网页"
└── body
    └── div.container
        ├── h1
        │   └── "标题"
        └── p
            └── "一段文字"

每个方框都是一个节点。节点之间是父子、兄弟关系。这棵树的根节点是document

节点有不同的类型,最常见的是:

  • 元素节点:比如<div><p>,类型是1
  • 文本节点:比如“标题”这两个字,类型是3
  • 属性节点:比如class="container",类型是2(但很少单独操作)

二、获取节点:找到你要动刀的位置

做手术第一步,得找到病灶。JS提供了好几种“找节点”的方法:

1. 单个元素

// 根据ID(最常用)
const header = document.getElementById('header');

// 根据CSS选择器(推荐,灵活)
const container = document.querySelector('.container');
const title = document.querySelector('#title');

// 根据类名(返回集合)
const items = document.getElementsByClassName('item'); // HTMLCollection,实时更新

2. 多个元素

// 获取所有匹配的元素
const allDivs = document.querySelectorAll('div'); // NodeList,静态快照

// 根据标签名
const paras = document.getElementsByTagName('p'); // HTMLCollection

3. 在节点之间“爬树”

拿到一个节点后,你可以在它周围爬来爬去:

const container = document.querySelector('.container');

// 往上爬
const parent = container.parentNode;

// 往下爬
const firstChild = container.firstChild; // 可能是文本节点(换行)
const firstElementChild = container.firstElementChild; // 只算元素

// 找兄弟
const prev = container.previousSibling; // 可能是文本节点
const prevElement = container.previousElementSibling;
const next = container.nextElementSibling;

坑点firstChildnextSibling这些会返回文本节点(包括换行和空格),所以大部分时候用firstElementChildnextElementSibling更安全。

三、修改节点:动手术的核心操作

找到目标后,就可以下手了。

1. 修改内容和属性

// 改文本内容
element.textContent = '新文本'; // 纯文本,安全
element.innerHTML = '<strong>新文本</strong>'; // 解析HTML,有XSS风险

// 改属性
element.id = 'newId';
element.className = 'newClass'; // 覆盖所有类
element.classList.add('active'); // 推荐,增删类
element.classList.remove('hidden');
element.classList.toggle('open');

// 改样式(内联样式)
element.style.color = 'red';
element.style.backgroundColor = '#f0f0f0'; // 驼峰命名

2. 创建新节点

// 创建元素
const newDiv = document.createElement('div');
newDiv.textContent = '我是新来的';

// 创建文本节点(很少单独用)
const textNode = document.createTextNode('一段文字');

3. 插入节点

// 追加到最后
parent.appendChild(newDiv);

// 插入到某个子节点之前
parent.insertBefore(newDiv, referenceNode);

// 现代插入方法(更灵活)
referenceNode.before(newDiv); // 插到前面
referenceNode.after(newDiv);  // 插到后面
parent.prepend(newDiv);       // 插到父元素开头
parent.append(newDiv);        // 插到父元素末尾(类似appendChild)

4. 删除节点

// 删除自己
element.remove();

// 通过父节点删除
parent.removeChild(child);

四、实战:动态添加待办事项

来做个简单待办列表,把上面的操作串起来:

<div id="todo-app">
  <input type="text" id="todo-input" placeholder="输入待办事项">
  <button id="add-btn">添加</button>
  <ul id="todo-list"></ul>
</div>
const input = document.getElementById('todo-input');
const addBtn = document.getElementById('add-btn');
const list = document.getElementById('todo-list');

function addTodo() {
  const text = input.value.trim();
  if (text === '') return;
  
  // 创建li元素
  const li = document.createElement('li');
  li.textContent = text;
  
  // 创建删除按钮
  const delBtn = document.createElement('button');
  delBtn.textContent = '删除';
  delBtn.onclick = function() {
    li.remove(); // 删除这一项
  };
  
  li.appendChild(delBtn);
  list.appendChild(li);
  
  input.value = ''; // 清空输入框
}

addBtn.addEventListener('click', addTodo);
// 按回车也添加
input.addEventListener('keypress', function(e) {
  if (e.key === 'Enter') addTodo();
});

就这几行代码,一个动态待办列表就有了。你看,增删改查全用上了。

五、节点集合:HTMLCollection vs NodeList

当你用getElementsByClassName时,拿到的是HTMLCollection;用querySelectorAll拿到的是NodeList。它们有啥区别?

  • HTMLCollection:实时的。DOM变了,它也跟着变。而且它只有元素节点,没有文本节点。
  • NodeList:大部分是静态快照(querySelectorAll返回的就是静态的)。但childNodes返回的NodeList是实时的。
const live = document.getElementsByClassName('item'); // 实时
const static = document.querySelectorAll('.item'); // 静态

// 如果你删除了一个.item元素,live会立刻变少,static还是原来的

遍历时,HTMLCollection没有forEach方法(但可以Array.from()转成数组),NodeList有forEach

六、性能小贴士:别频繁动DOM

DOM操作是“重活”,频繁操作会影响性能。记住几个原则:

  1. 批量操作:用document.createDocumentFragment()创建虚拟片段,一次性插入。
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
  const li = document.createElement('li');
  li.textContent = i;
  fragment.appendChild(li);
}
list.appendChild(fragment); // 只触发一次重排
  1. 减少重排:修改样式时,尽量用classList批量改,而不是一个个改style属性。

  2. 离屏操作:先把元素从DOM树上摘下来,改完再放回去。

七、总结:DOM就是你的“手术台”

  • DOM是HTML解析成的树,每个标签、文本都是节点。
  • document.querySelector等方法找到节点。
  • textContentinnerHTML改内容,用classList改样式。
  • createElement造新节点,用appendinsertBefore插入,用remove删除。
  • 注意HTMLCollection和NodeList的区别,实时和静态要分清。
  • 批量操作、减少重排,让页面更流畅。

掌握了这些,你就能用JS随心所欲地操控页面。明天我们将继续深入,聊聊事件流与事件委托——当用户点击按钮时,浏览器里到底发生了什么。

如果你觉得今天的“手术”课够实用,点个赞让更多人看到。我们明天见!

为什么我的代码在测试环境跑得好好的,一到用户电脑就崩?原来凶手躲在地址栏旁边

作者 kyriewen
2026年3月28日 21:23

引言

“Bug 无法复现,建议关闭。”

这是我上周在一个 issue 下面看到的回复。发 issue 的用户是个忠实用户,他说我们网站的某个按钮点完后页面就白屏了,但我们在测试环境、预发环境、甚至他的电脑上用无痕模式都试了一遍,愣是没复现。

就在我准备把这个 issue 标记为“无法复现,关闭”的时候,产品经理幽幽地说了一句:“要不你试试装几个浏览器插件?”

我当时心想:插件能影响我们代码?那不至于吧。

结果我装了 AdBlock、装了油猴脚本、装了某个购物比价插件,刷新页面,点击按钮——白屏了

那一刻我恍然大悟:原来我们的代码一直生活在“无菌实验室”里,而用户的浏览器,是一个充满了各种“妖魔鬼怪”的丛林。

今天,我们就来聊聊那些躲在地址栏旁边的“凶手”——浏览器扩展(Extensions),以及它们如何悄悄地破坏你的网页。

一、浏览器扩展:用户的朋友,开发者的噩梦

浏览器扩展(Chrome/Firefox/Edge 插件)本质上是在用户浏览器里运行的第三方代码。它们拥有各种权限:

  • 读取和修改当前页面的 DOM
  • 拦截和修改网络请求
  • 注入自己的 JS 和 CSS
  • 甚至操作本地存储、Cookie

这些权限对用户来说是“增强功能”,但对我们开发者来说,就是一颗不知道什么时候会炸的雷。

1.1 最常见的“作案手法”

手法一:往 DOM 里塞私货

很多广告拦截插件会扫描页面里的广告位,然后移除或隐藏它们。但如果你的代码恰好依赖某个被移除的 DOM 节点,就会报错。

// 你写的代码
const adBanner = document.getElementById('ad-banner');
adBanner.addEventListener('click', trackAdClick); // 如果 adBanner 被插件删了,这里就报错

手法二:修改全局变量

有些插件会往 window 对象上挂东西,比如 window.web3window.ethereum。如果插件代码有 bug,或者覆盖了你自己的变量,就会引发冲突。

手法三:拦截并修改网络请求

某些比价插件会在页面加载时修改 fetch 或 XMLHttpRequest,往请求里加参数、改返回值。如果你的代码对返回数据格式有严格校验,就可能崩。

手法四:注入大量 CSS 导致样式错乱

很多暗黑模式插件会强制给页面添加 filter: invert(1),然后你的精心设计的渐变、阴影、图片全部变成鬼片现场。

二、真实案例:一次被插件坑到怀疑人生的经历

去年有个用户反馈:我们网站的一个下拉菜单点不开。我们团队三台电脑都试了,没问题。后来让用户录屏,发现他的浏览器右上角有一排插件图标,大概七八个。

我让用户把插件一个个关掉试试。关到第三个——广告拦截器——菜单能点了。

后来排查发现,那个广告拦截器有一条规则,把我们的菜单按钮识别成了广告弹窗,给它加上了 display: none !important

解决方案?我们在 CSS 里给菜单按钮加了一个更高优先级的规则,并且改了 HTML 结构,避开了那个插件的检测规则。

从那以后,我养成了一个习惯:在调试“用户反馈但本地无法复现”的 bug 时,先问一句:“你装了哪些插件?”

三、常见的“凶手插件”类型

类型 典型代表 可能引发的问题
广告拦截器 AdBlock, uBlock Origin 移除 DOM 元素、阻止网络请求
安全/隐私插件 Privacy Badger, Ghostery 屏蔽第三方脚本、修改 Cookie
密码管理器 LastPass, 1Password 在密码框注入额外 UI,可能破坏表单提交逻辑
翻译插件 谷歌翻译、沙拉查词 修改 DOM 文本,可能破坏依赖文本内容的前端逻辑
暗黑模式插件 Dark Reader 注入全局 CSS,可能导致样式错乱
比价/购物助手 各种返利插件 修改商品价格、添加浮动按钮,可能遮挡你的 UI
油猴脚本 Tampermonkey 用户自定义脚本,什么都能干,什么都能坏

四、如何检测和防范“插件污染”?

4.1 开发阶段:用插件测试自己

在开发时,建议装几个常见的“破坏性”插件,时不时开着它们测试一下自己的页面。你会发现很多之前没想过的问题。

4.2 代码层面:防御性编程

  • 操作 DOM 前检查元素是否存在

    const el = document.getElementById('some-id');
    if (el) {
      el.addEventListener(...);
    }
    
  • 使用 !important 时要谨慎:插件经常用 !important 覆盖样式,如果你的样式也用 !important,可能会变成“谁的 !important 更厉害”的军备竞赛。

  • 避免依赖全局变量:如果一定要用,先检查是否存在冲突:

    if (typeof window.myGlobal !== 'undefined' && !window.myGlobal.__MY_APP__) {
      console.warn('全局变量 myGlobal 被第三方插件覆盖');
    }
    

4.3 异常捕获与上报

在代码里加上 try-catch,并上报错误信息。当用户反馈 bug 时,可以从错误日志里看出蛛丝马迹:

window.addEventListener('error', (event) => {
  // 上报错误,附带上用户安装了哪些插件(如果能检测到的话)
  reportError({
    message: event.message,
    filename: event.filename,
    // 可以尝试读取用户安装的插件,虽然不能完全读取,但部分插件会在 DOM 上留下痕迹
    extensions: detectExtensions()
  });
});

4.4 教用户“排除法”

当用户反馈 bug 时,可以提供一个标准操作:

  1. 打开无痕模式(默认禁用大部分插件)。
  2. 如果无痕模式正常,说明是插件的问题。
  3. 一个一个关掉插件,找出罪魁祸首。

这比你在本地猜来猜去要高效得多。

五、检测用户装了哪些插件(有限但有用)

虽然你不能直接读取用户安装的所有插件(隐私原因),但你可以通过一些“痕迹”来推测:

function detectExtensions() {
  const detected = [];
  
  // AdBlock 检测
  if (document.querySelector('.adblock-warning') || 
      typeof window.adblockDetector !== 'undefined') {
    detected.push('AdBlock (可能)');
  }
  
  // 暗黑模式检测
  if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
    // 这不一定是插件,也可能是系统设置
    detected.push('暗黑模式');
  }
  
  // 某些插件会在 body 上加特定 class
  if (document.body.classList.contains('darkreader')) {
    detected.push('Dark Reader');
  }
  
  return detected;
}

六、总结:拥抱不确定性

浏览器插件是用户自主安装的,我们无法禁止,也不应该禁止。但我们可以通过防御性编程 + 异常监控 + 用户沟通,让页面在面对这些“不速之客”时更加健壮。

下次当你遇到“测试环境正常,用户环境报错”的 bug 时,别急着怀疑自己的代码,先看看用户的地址栏旁边——可能有个小小的图标,正在悄悄给你的页面使绊子。


每日一问:你遇到过最离谱的“插件导致 bug”是什么?是广告拦截器把你的登录按钮给拦了?还是翻译插件把你的代码注释翻译成了英文导致报错?评论区分享你的“受害者”经历!

for...of 的秘密:迭代器与可迭代对象,你也能创造“可循环”的东西

作者 kyriewen
2026年3月28日 10:53

为什么数组可以用for...of循环?为什么对象不行?今天我们来揭开JS里“可循环”的秘密——迭代器(Iterator)和可迭代对象(Iterable)。弄懂它们,你就能让自己的对象也支持for...of,甚至还能写出像Python生成器那样优雅的代码。

前言

你有没有好奇过,为什么数组可以用for...of遍历,而对象不行?为什么...扩展运算符可以展开数组,却不能直接展开对象?这背后其实是迭代器协议在起作用。

今天我们就来彻底搞懂这套机制,然后亲手造一个可以for...of遍历的对象。看完你会感叹:原来JS的循环还有这么多骚操作!

一、什么是可迭代对象?

如果一个对象实现了可迭代协议,它就是可迭代对象。可迭代协议要求对象有一个[Symbol.iterator]方法,这个方法返回一个迭代器

简单来说:可迭代对象 = 有一个能返回迭代器的方法

数组、字符串、Map、Set、arguments、NodeList等都是原生可迭代对象。所以你可以:

for (let item of [1,2,3]) { console.log(item); } // 数组
for (let char of 'hello') { console.log(char); } // 字符串
for (let [key,val] of new Map([[1,2]])) { } // Map

对象不是可迭代对象,所以for...of直接遍历对象会报错。

二、迭代器长什么样?

迭代器是一个对象,它有一个next()方法。每次调用next(),会返回一个对象:{ value: 任意值, done: boolean }done表示是否遍历结束。

比如手动创建一个数组的迭代器:

const arr = ['a', 'b', 'c'];
const iterator = arr[Symbol.iterator]();

console.log(iterator.next()); // { value: 'a', done: false }
console.log(iterator.next()); // { value: 'b', done: false }
console.log(iterator.next()); // { value: 'c', done: false }
console.log(iterator.next()); // { value: undefined, done: true }

你看,这个迭代器就像个“读取器”,每次取一个值,直到取完。

三、自己实现一个可迭代对象

现在我们来造一个可以for...of遍历的对象。比如一个范围对象,能遍历从start到end的所有整数。

const range = {
  start: 1,
  end: 5,
  [Symbol.iterator]() {
    let current = this.start;
    const end = this.end;
    return {
      next() {
        if (current <= end) {
          return { value: current++, done: false };
        } else {
          return { value: undefined, done: true };
        }
      }
    };
  }
};

for (let num of range) {
  console.log(num); // 1,2,3,4,5
}

就这么简单!只要对象有[Symbol.iterator]方法,并且返回一个带有next的对象,它就能被for...of遍历。

四、扩展运算符、解构赋值背后的迭代器

很多JS语法都依赖迭代器:

  • ...扩展运算符:把可迭代对象展开成元素列表
  • 数组解构:[a, b, ...rest] = iterable
  • Array.from():把可迭代对象转成数组
  • for...of循环
  • Promise.all()Promise.race()的参数也是可迭代对象

所以,只要你的对象是可迭代的,它就能享受这些语法糖。

const numbers = [...range]; // [1,2,3,4,5]
const [first, second, ...rest] = range; // first=1, second=2, rest=[3,4,5]

五、生成器函数:迭代器的快捷方式

还记得昨天的Generator吗?生成器函数返回的就是迭代器!所以我们可以用Generator来简化上面的代码:

const range = {
  start: 1,
  end: 5,
  *[Symbol.iterator]() {
    for (let i = this.start; i <= this.end; i++) {
      yield i;
    }
  }
};

是不是简洁多了?*[Symbol.iterator]()就是Generator方法,每次yield一个值,for...of会自动调用next

六、无限迭代器:永不停止的循环

迭代器可以无限进行下去,比如生成斐波那契数列:

const fibonacci = {
  *[Symbol.iterator]() {
    let a = 0, b = 1;
    while (true) {
      yield a;
      [a, b] = [b, a + b];
    }
  }
};

const fib = fibonacci[Symbol.iterator]();
console.log(fib.next().value); // 0
console.log(fib.next().value); // 1
console.log(fib.next().value); // 1
// 想取多少取多少

但注意:用for...of遍历无限迭代器会死循环,所以要手动控制。

七、提前终止迭代器:return方法

如果迭代器被提前终止(比如for...of中遇到break,或者解构只取前几个值),JS会调用迭代器的return方法(如果有的话)。这可以用来做清理工作。

const specialIterable = {
  [Symbol.iterator]() {
    let i = 0;
    return {
      next() {
        if (i < 3) return { value: i++, done: false };
        return { done: true };
      },
      return() {
        console.log('提前终止了');
        return { done: true };
      }
    };
  }
};

for (let x of specialIterable) {
  console.log(x);
  if (x === 1) break; // 触发return
}
// 输出:0,1, 然后打印“提前终止了”

八、实际应用:让对象可迭代

假设你有一个用户列表对象,你想让它支持for...of直接遍历用户:

const userList = {
  users: [
    { name: '张三', age: 18 },
    { name: '李四', age: 20 },
    { name: '王五', age: 22 }
  ],
  *[Symbol.iterator]() {
    for (let user of this.users) {
      yield user;
    }
  }
};

for (let user of userList) {
  console.log(user.name); // 张三 李四 王五
}

这样,你的自定义对象就能像数组一样优雅地遍历了。

九、总结:迭代器无处不在

  • 可迭代对象:实现了[Symbol.iterator]方法,返回一个迭代器。
  • 迭代器:实现了next()方法,返回{ value, done }
  • 生成器函数:是迭代器最便捷的实现方式。
  • 很多JS语法(for...of、扩展运算符、解构)都依赖迭代器协议。

理解了这套机制,你就能:

  • 让自定义对象支持for...of
  • 创建无限序列
  • 深入理解JS语法糖背后的原理

下次你写for...of时,脑子里可以浮现出迭代器一步步next的画面——这才是真正掌握了JS的底层。

明天我们将进入DOM操作与事件流,从JS的核心走向与页面的交互。如果你觉得今天的文章够“可迭代”,点个赞让更多人看到。我们明天见!

你还在给每个图片父元素加类名?CSS :has() 让选择器“逆天改命”

作者 kyriewen
2026年3月27日 19:34

引言

“组长,这个需求我写不了。”

“什么需求?”

“产品经理说,所有包含图片的卡片,要在卡片上加一个‘带图标识’的边框。但是这些卡片是动态渲染的,图片可有可无,我总不能每个卡片都写个条件判断吧?”

组长瞥了我一眼:“你用 CSS 啊。”

“CSS 怎么选?CSS 又没办法判断一个元素里有没有图片……”

组长微微一笑:“那是以前的 CSS 了。你知道 :has() 吗?它能让父元素根据子元素的状态来改变自己。简单来说,就是 ‘子凭父贵’的反过来——父凭子贵。”

我当时一脸懵:还有这种操作?

那天下午,我学会了 :has(),然后发现——原来 CSS 早就不是当年的 CSS 了。它悄悄给自己装了个“逆向思维”的外挂,只是我们都不知道。

一、:has() 是什么?CSS 的“时光倒流”

在 CSS 选择器的历史上,我们一直只能从上往下选:父元素 → 子元素,兄弟元素 → 相邻兄弟。比如 div p 选择 div 里的所有 p,h1 + p 选择紧跟在 h1 后面的 p。

但从来没有人能根据子元素的状态来选择父元素。直到 :has() 出现。

:has() 是一个关系伪类,它允许你根据元素的后代或后续兄弟元素来匹配该元素。语法看起来就像是在问:“嘿,这个元素里面有没有符合某个条件的子元素?”

/* 选择所有包含 <img> 元素的 <figure> */
figure:has(img) {
  border: 2px solid gold;
}

/* 选择所有包含 .error-message 的表单 */
form:has(.error-message) {
  border: 1px solid red;
  background-color: #ffeeee;
}

更妙的是,:has() 里面可以写几乎任何复杂选择器,包括伪类、组合器,甚至可以嵌套 :has()

二、实战:那些让你拍大腿的场景

2.1 场景一:包含图片的卡片加特殊样式

终于不用 JS 了!

<div class="card">
  <h3>标题</h3>
  <p>一些文字...</p>
  <img src="photo.jpg" alt="配图">
</div>
<div class="card">
  <h3>标题</h3>
  <p>没有图片的卡片</p>
</div>
.card:has(img) {
  box-shadow: 0 4px 12px rgba(0,0,0,0.1);
  border-left: 4px solid #ff8800;
}

只有带图片的卡片才会获得橙色左边框,干净利落。

2.2 场景二:表单实时校验反馈(不用 JS 监听)

/* 如果有无效输入框,给表单加个红框 */
form:has(input:invalid) {
  border: 2px solid red;
  padding: 10px;
}

/* 如果有被选中的复选框,给父级加个标记 */
fieldset:has(input[type="checkbox"]:checked) {
  background-color: #e0ffe0;
}

这比以前用 JS 监听每个 input 然后给父级加类名优雅太多。

2.3 场景三:空状态提示

/* 如果列表里没有 li,显示空状态提示 */
ul:not(:has(li))::after {
  content: "暂无数据";
  display: block;
  color: #999;
  text-align: center;
}

:not(:has(...)) 这个组合很有用,表示“没有子元素满足条件”。

2.4 场景四:兄弟元素的影响

:has() 不仅可以选祖先,还可以选兄弟?

/* 如果 h2 后面紧跟着 p,给 h2 加下划线 */
h2:has(+ p) {
  text-decoration: underline;
}

这利用了 + 组合器,+ p 表示“后面紧邻的 p”,所以 h2:has(+ p) 就是“后面有 p 的 h2”。实际上 :has() 里的选择器可以往后看。

2.5 场景五:多级嵌套的“父选择”

/* 如果某个 section 里有一个 article,且 article 内有 img,给 section 加背景 */
section:has(article:has(img)) {
  background: #fafafa;
}

这就是嵌套 :has(),越看越像 XPath,但威力巨大。

三、:has() 的“阴暗面”:性能与兼容

这么强大的东西,有没有什么坑?

3.1 兼容性

:has()CSS 选择器 Level 4 的一部分。它在 Chrome 105+、Edge 105+、Firefox 121+、Safari 15.4+ 开始支持。也就是说,2023 年以后的主流浏览器基本都能用。但对于老浏览器,需要做降级处理(比如用 JS 回退)。

3.2 性能考虑

:has() 被称为“昂贵的选择器”,因为它需要检查元素的后代或后续兄弟,浏览器可能需要做更多工作。但现代浏览器已经做了大量优化,在合理使用下不会明显影响性能。不要滥用,比如不要给每个元素都加上 :has(*) 这种通配。

最佳实践:尽量限定范围,比如 nav:has(> a.active)*:has(a) 高效得多。

3.3 一些你不能做(或不应做)的事

  • 不能在 :has() 里使用 :has() 自身形成循环引用?理论上可以,但你会把自己绕晕。
  • 不能用 :has() 选择祖先的祖先?它可以,但性能会下降。
  • 不能用 :has() 来改变页面结构?它只是选择器,只能应用样式,不能添加或删除元素。

四、还有哪些“逆天”的新选择器?

:has() 同期或稍早,CSS 还引入了:

  • :where():优先级为 0,用于降低选择器权重。
  • :is():可以写一组选择器,比如 :is(header, main, footer) p
  • :not() 也升级了,可以接受复杂选择器列表。
  • @scope 实验性功能,可以限定样式的作用域。

这些新特性正在把 CSS 从“声明式样式表”变成“轻量级逻辑引擎”。

五、总结:CSS 不再是“语言残疾”

以前我们常开玩笑说:“CSS 不是编程语言。”现在,有了 :has(),CSS 居然能根据子元素来决定父元素样式,这几乎就是一种“条件判断”能力。

:has() 的出现,让我们可以少写很多 JavaScript 类名操作,让样式更纯粹、更内聚。虽然兼容性还没到 100%,但已经值得我们在现代项目中尝试。

下次产品经理再提“根据子元素内容改变父元素样式”的需求,你可以自信地说:“交给 CSS,不用写 JS。”


每日一问:你还遇到过哪些用 JS 实现很麻烦,但 CSS 新特性可以轻松解决的问题?评论区分享,一起刷新认知!

Generator 函数:那个能“暂停”的函数,到底有什么用?

作者 kyriewen
2026年3月27日 14:05

你有没有想过,如果函数可以“暂停”,等你想好了再继续,会是什么样?今天我们就来认识JavaScript里的“时间管理大师”——Generator函数。它能让你在执行到一半的时候停下来,等你喊“继续”再往下走。这听起来有点科幻,但它却是async/await的祖师爷。

前言

普通函数就像一支穿云箭,发射出去就直奔终点,中间绝不回头。但有时候我们需要更灵活的控制:比如我要分几步做一件事,每一步之间可能隔着十万八千里,或者我想让调用方决定什么时候继续。

Generator函数就是来解决这个问题的。它让你可以“暂停”函数执行,等会儿再“恢复”。这就像打游戏时按了暂停键,你去泡个面,回来继续打。

一、Generator长啥样?

Generator函数在function后面加个星号*,里面用yield关键字来“暂停”。

function* myGenerator() {
  console.log('第一步');
  yield '暂停一下';
  console.log('第二步');
  yield '再停一下';
  console.log('第三步');
  return '结束了';
}

调用这个函数并不会立即执行,而是返回一个迭代器对象。你通过调用next()来一步步执行。

const gen = myGenerator();

console.log(gen.next()); // 输出:第一步,{ value: '暂停一下', done: false }
console.log(gen.next()); // 输出:第二步,{ value: '再停一下', done: false }
console.log(gen.next()); // 输出:第三步,{ value: '结束了', done: true }
console.log(gen.next()); // { value: undefined, done: true }

每次next()都会执行到下一个yield,然后暂停。yield后面的值会作为value返回。等所有代码执行完,done就变成true

二、yield是“暂停键”,next是“播放键”

这个机制有点像你写文章写到一半,突然想喝杯咖啡。你把光标停在某个位置(yield),喝完咖啡回来,再敲一下键盘(next),继续往下写。

更神奇的是,next()还可以传参,这个参数会成为上一个yield的返回值。这就像你暂停时给函数塞了张纸条,告诉它接下来该怎么走。

function* talkGenerator() {
  const name = yield '你叫什么名字?';
  const age = yield `${name},你多大了?`;
  return `${name}今年${age}岁`;
}

const talk = talkGenerator();

console.log(talk.next());        // { value: '你叫什么名字?', done: false }
console.log(talk.next('张三'));   // { value: '张三,你多大了?', done: false }
console.log(talk.next(18));      // { value: '张三今年18岁', done: true }

看到没?第一次next()只是启动,第二次next('张三')把“张三”传给了name,第三次传年龄。这就是Generator的“对话”能力。

三、协程:Generator的底层哲学

Generator函数的这种“暂停/恢复”能力,其实是**协程(Coroutine)**思想的体现。协程是一种比线程更轻量级的并发单元,它可以在多个任务之间主动让出控制权。

在JavaScript里,Generator就是协程的一种实现。你可以用它来模拟多任务协作,比如交替执行两个任务:

function* task1() {
  yield '任务1: 第1步';
  yield '任务1: 第2步';
  return '任务1完成';
}

function* task2() {
  yield '任务2: 第1步';
  yield '任务2: 第2步';
  return '任务2完成';
}

const t1 = task1();
const t2 = task2();

console.log(t1.next().value); // 任务1: 第1步
console.log(t2.next().value); // 任务2: 第1步
console.log(t1.next().value); // 任务1: 第2步
console.log(t2.next().value); // 任务2: 第2步

这样两个任务就像在“交替执行”,但实际还是单线程,只是每次让出控制权。这就是“协作式多任务”。

四、Generator的“主战场”:异步流程控制

在async/await出现之前,Generator是处理异步的利器。比如你要按顺序发起三个网络请求,用Promise可以这么写:

function fetchUser() { return fetch('/user').then(r => r.json()); }
function fetchOrders(userId) { return fetch(`/orders?userId=${userId}`).then(r => r.json()); }
function fetchProducts(orderId) { return fetch(`/products?orderId=${orderId}`).then(r => r.json()); }

// 用Generator + 自动执行器
function* fetchFlow() {
  const user = yield fetchUser();
  const orders = yield fetchOrders(user.id);
  const products = yield fetchProducts(orders[0].id);
  return products;
}

// 需要一个自动执行器,让yield后面的Promise自动执行
function run(generator) {
  const gen = generator();
  function step(result) {
    if (result.done) return result.value;
    return result.value.then(
      res => step(gen.next(res)),
      err => step(gen.throw(err))
    );
  }
  return step(gen.next());
}

run(fetchFlow).then(products => console.log(products));

这个run函数就是传说中的自动执行器,它不断调用next,把Promise的结果传回去。这其实就是async/await的前身——用Generator模拟同步写法。

后来ES7直接把这种模式内置成了async/await,所以现在我们很少直接写Generator了,但它的思想深深影响了现代JS。

五、Generator的实用场景:不仅仅是异步

虽然有了async/await,Generator并没有被淘汰,它还在一些地方发光发热:

1. 无限数据结构

用Generator可以生成无限序列,比如斐波那契数列:

function* fibonacci() {
  let a = 0, b = 1;
  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}

const fib = fibonacci();
console.log(fib.next().value); // 0
console.log(fib.next().value); // 1
console.log(fib.next().value); // 1
console.log(fib.next().value); // 2
// 可以无限取下去

2. 状态机

Generator可以很方便地实现状态机,每个yield代表一个状态:

function* stateMachine() {
  let state = 'idle';
  while (true) {
    const action = yield state;
    switch (state) {
      case 'idle':
        if (action === 'start') state = 'running';
        break;
      case 'running':
        if (action === 'pause') state = 'paused';
        else if (action === 'stop') state = 'idle';
        break;
      case 'paused':
        if (action === 'resume') state = 'running';
        else if (action === 'stop') state = 'idle';
        break;
    }
  }
}

const sm = stateMachine();
console.log(sm.next().value); // idle
console.log(sm.next('start').value); // running
console.log(sm.next('pause').value); // paused
console.log(sm.next('resume').value); // running

3. 简化迭代器

如果一个对象需要实现[Symbol.iterator],用Generator可以省掉很多模板代码:

const myIterable = {
  *[Symbol.iterator]() {
    yield 1;
    yield 2;
    yield 3;
  }
};

for (const x of myIterable) {
  console.log(x); // 1,2,3
}

六、Generator vs async/await

既然async/await已经这么方便,为什么还要学Generator?

  • async/await:专注于异步,语法简洁,是处理异步任务的终极形态。
  • Generator:更底层,更灵活,可以暂停任何操作(不仅仅是Promise),还可以用于创建迭代器、状态机等。

async/await本质上就是Generator + 自动执行器的语法糖。所以理解Generator,就能更深刻理解async/await的运作原理。

七、总结:Generator是JS里的“时间胶囊”

Generator函数让我们能够:

  • 暂停函数执行,等以后再继续
  • 通过next传值,实现双向通信
  • yield实现惰性求值和无限序列
  • 模拟协程,实现协作式多任务
  • 为async/await打下基础

虽然现在很少直接写Generator做异步了,但它的思想无处不在。当你用for...of遍历数组时,背后有迭代器;当你用async/await时,底层有Generator的影子。

下次面试官问你“Generator有什么用”,你可以告诉他:这是JavaScript的“时间管理大师”,既能暂停时间,又能穿越时空,还能让异步代码看起来像同步。

明天我们将进入迭代器与可迭代对象,看看for...of、扩展运算符这些语法糖背后,到底藏着什么秘密。如果你觉得这篇文章够有趣,点个赞让更多人看到。我们明天见!

异步编程:从“回调地狱”到“async/await”的救赎之路

作者 kyriewen
2026年3月24日 18:03

JavaScript是单线程的,但它却能同时处理很多事情。这是怎么做到的?今天我们就来聊聊异步编程,看看JS是怎么一边听歌一边刷网页的。从最原始的回调函数,到Promise,再到优雅的async/await,这不仅是技术的演进,更是一场“程序员不熬夜”的运动。

前言

你有没有经历过这种绝望:写了一个网络请求,结果后面的代码先执行了,请求的数据还没回来,页面已经渲染完了,一片空白。或者你见过这样的代码:

getUser(function(user) {
  getOrders(user.id, function(orders) {
    getOrderDetails(orders[0].id, function(details) {
      getProductInfo(details.productId, function(product) {
        console.log(product);
      });
    });
  });
});

这就是传说中的回调地狱——代码像楼梯一样往右歪,看得人头晕眼花。

今天我们就来走一遍JS异步编程的进化史,看看前辈们是怎么从地狱里爬出来的。

一、为什么需要异步?

JavaScript是单线程的,也就是说同一时间只能做一件事。如果所有事情都排队等着,那遇到一个耗时操作(比如网络请求、读取文件),整个页面就得卡住,用户点哪儿都没反应。

异步就是解决方案:遇到耗时操作,先丢给浏览器或Node去“慢慢做”,JS主线程继续执行后面的代码。等耗时操作完成了,再通知JS:“嘿,我完事了,你处理一下结果吧。”

这就好比你点外卖:你不会站在店门口干等一小时,而是该干嘛干嘛,等外卖小哥打电话叫你,你再去取餐。异步就是这种“不干等”的机制。

二、回调函数:异步的原始形态

回调函数是最早的异步解决方案:把一个函数作为参数传给另一个函数,等异步操作完成后调用这个函数。

function fetchData(callback) {
  setTimeout(() => {
    callback('数据来了');
  }, 1000);
}

fetchData(function(data) {
  console.log(data); // 一秒后输出:数据来了
});

看起来还行,对吧?但一旦有多个依赖的异步操作,就出事了。

回调地狱长什么样?

// 先获取用户
getUser(function(user) {
  // 再根据用户ID获取订单
  getOrders(user.id, function(orders) {
    // 再获取第一个订单的详情
    getOrderDetails(orders[0].id, function(details) {
      // 再根据商品ID获取商品信息
      getProductInfo(details.productId, function(product) {
        // 终于拿到了
        console.log(product);
      });
    });
  });
});

代码往右飞,一眼看不到头。这还没算错误处理——每个回调都要处理错误,代码量直接翻倍。这种代码别说维护了,写的时候自己都要绕晕。

回调的痛点

  • 嵌套太深,代码可读性差
  • 错误处理困难,每个回调都要try-catch
  • 难以并行执行多个异步操作

三、Promise:打破地狱的“链式反应”

ES6引入了Promise,它像是一个“承诺”:现在还没有结果,但将来一定会有(要么成功,要么失败)。

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('数据来了');
    // 如果出错:reject('错误信息')
  }, 1000);
});

promise
  .then(data => {
    console.log(data);
  })
  .catch(error => {
    console.error(error);
  });

Promise最大的好处是链式调用,可以把嵌套的异步操作拍平:

getUser()
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetails(orders[0].id))
  .then(details => getProductInfo(details.productId))
  .then(product => console.log(product))
  .catch(error => console.error(error));

看,从“右飞”变成了“下飞”,代码清晰多了。

Promise的几个关键点

  1. 状态不可逆:Promise有三种状态:pending(进行中)、fulfilled(成功)、rejected(失败)。一旦从pending变成fulfilled或rejected,就不能再变了。

  2. 链式传递then返回的是一个新的Promise,所以可以一直链下去。

  3. 错误冒泡:只要链尾有一个catch,前面任何一个环节出错都会落进来。

  4. 并行操作Promise.all等待所有完成,Promise.race等待最快的一个。

// 并行请求
Promise.all([fetchUser(), fetchOrders(), fetchProduct()])
  .then(([user, orders, product]) => {
    console.log('全部完成', user, orders, product);
  });

Promise解决了回调地狱的问题,但还是有些繁琐——你需要写很多.then.catch,而且处理复杂的逻辑时,还是有点绕。

四、async/await:异步代码同步写

ES2017推出的async/await,是Promise的语法糖,让异步代码看起来像同步代码一样直观。

async function getProductInfo() {
  try {
    const user = await getUser();
    const orders = await getOrders(user.id);
    const details = await getOrderDetails(orders[0].id);
    const product = await getProductInfo(details.productId);
    console.log(product);
  } catch (error) {
    console.error(error);
  }
}

关键点

  • async标记的函数返回一个Promise
  • await后面跟一个Promise,它会“暂停”函数执行,直到Promise出结果
  • 错误处理直接用try/catch,和同步代码一模一样

这感觉就像:终于可以用写同步代码的姿势写异步了!不用再管什么then、catch,代码一下子就清爽了。

但注意:await会阻塞函数内部,但不阻塞外部

async function test() {
  console.log('1');
  await new Promise(resolve => setTimeout(resolve, 1000));
  console.log('2'); // 一秒后才输出
}
console.log('3');
test();
console.log('4');
// 输出顺序:1,3,4,(一秒后)2

await只阻塞它所在的async函数,外面的代码照常执行。这正是异步的精髓:不干等。

五、事件循环:异步背后的幕后黑手

说了这么多,你有没有想过一个问题:异步操作完成之后,回调是怎么被调用的?这就要提到**事件循环(Event Loop)**了。

JS的执行机制大概是这样的:

  1. 主线程执行同步代码,遇到异步任务(比如setTimeout、网络请求)就交给Web APIs(浏览器)或libuv(Node)去处理。
  2. 异步任务完成后,回调函数被放入任务队列
  3. 主线程的同步代码执行完后,会不断从任务队列里取回调来执行。
  4. 这个过程不断重复,就是事件循环。

任务队列还分宏任务微任务

  • 宏任务:setTimeout、setInterval、I/O操作、UI渲染
  • 微任务:Promise.then、MutationObserver、queueMicrotask

执行顺序是:一个宏任务 → 所有微任务 → 渲染(如果有) → 下一个宏任务。

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出:1,4,3,2

为什么?同步代码先执行(1,4)→ 微任务Promise.then(3)→ 下一个宏任务setTimeout(2)。

六、实战:封装一个带超时的fetch

我们来用async/await封装一个实用的网络请求函数:

async function fetchWithTimeout(url, timeout = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);
  
  try {
    const response = await fetch(url, { signal: controller.signal });
    clearTimeout(timeoutId);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    if (error.name === 'AbortError') {
      throw new Error('请求超时');
    }
    throw error;
  }
}

// 使用
try {
  const data = await fetchWithTimeout('https://api.example.com/data', 3000);
  console.log(data);
} catch (error) {
  console.error(error.message);
}

这个函数既支持超时控制,又有完善的错误处理,用起来就像同步代码一样简单。

七、异步编程的最佳实践

  1. 能用async/await就用:比原生Promise更易读,错误处理也更自然。

  2. 避免“忘掉await”:忘记await会得到一个Promise对象,而不是实际值,这个bug很难找。

  3. 并行任务用Promise.all:如果多个异步任务互不依赖,用Promise.all并行执行,而不是挨个await。

// 慢:串行执行,总耗时2秒
const user = await getUser();
const orders = await getOrders();

// 快:并行执行,总耗时1秒(如果每个请求1秒)
const [user, orders] = await Promise.all([getUser(), getOrders()]);
  1. 错误处理要完整:async/await用try/catch,Promise用.catch(),不要漏掉。

  2. 避免在循环里用await:除非你确实需要串行执行,否则可以用Promise.all或for...of配合异步。

// 这样会串行执行,很慢
for (const id of ids) {
  const item = await fetchItem(id);
  items.push(item);
}

// 并行执行,快很多
const items = await Promise.all(ids.map(id => fetchItem(id)));

八、总结:从地狱到天堂

JS异步编程的演进史,就是一部程序员与复杂性抗争的历史:

  • 回调函数:原始但容易陷入地狱
  • Promise:链式调用打破嵌套
  • async/await:让异步代码回归同步的直觉

现在,你应该能理解为什么异步这么重要,以及怎么优雅地处理异步了。记住:不要在回调里写回调,不要在地狱里挣扎,用Promise和async/await解救自己。

明天我们将深入JS的另一座大山——事件循环(Event Loop),彻底搞懂微任务、宏任务、渲染时机这些核心概念。到时候你会发现,那些让人头疼的异步面试题,不过是一层窗户纸。

如果你觉得今天的异步进化史讲得通透,点个赞让更多人看到。有疑问评论区见,我们明天见!

原型与原型链:JavaScript 的“家族关系”大揭秘

作者 kyriewen
2026年3月22日 15:02

有人说JavaScript里“万物皆对象”,但对象和对象之间怎么攀亲戚?今天我们就来扒一扒JS的“家族关系”——原型和原型链。看懂了它,你就理解了JS面向对象的核心,也能明白为什么一个数组能调用那么多方法。

前言

如果你第一次接触原型,可能会觉得它像个黑魔法:明明没在那个对象上定义方法,怎么就突然能用了?比如:

const arr = [1, 2, 3];
arr.push(4); // 哪里来的push?

这个push方法既不是我们手动加的,也不是数组本身自带的(其实数组本身也没有,不信你console.log(arr)看看)。它是从“祖先”那里继承来的。

今天我们就来扒一扒JavaScript这个家族的族谱,看看对象们是怎么“攀亲戚”的,以及怎么利用这门亲戚关系写出优雅的代码。

一、原型是个啥?

简单来说,原型就是一个普通的对象,它被别的对象当作“备用方案”。当你访问一个对象的属性或方法时,如果这个对象自己没有,JavaScript就会去它的原型上找。如果原型上也没有,就去原型的原型上找,直到找到或者到达尽头。

这个“备用方案”的链条,就是原型链

1. 每个函数都有个prototype属性

在JavaScript里,每个函数都有一个prototype属性(箭头函数除外)。这个属性指向一个对象,当这个函数被用作构造函数(用new调用)时,创建出来的实例会继承这个prototype对象上的所有属性和方法。

function Person(name) {
  this.name = name;
}

Person.prototype.sayHello = function() {
  console.log(`你好,我是${this.name}`);
};

const zhangsan = new Person('张三');
zhangsan.sayHello(); // 你好,我是张三

这里sayHello不在zhangsan自己身上,但它在Person.prototype上,zhangsan通过原型链找到了它。

2. 每个对象都有个__proto__属性

每个对象(除了null)都有一个__proto__属性(非标准,但几乎所有浏览器都实现),它指向该对象的原型(即构造函数的prototype)。

console.log(zhangsan.__proto__ === Person.prototype); // true

这个__proto__就是连接实例和原型的“脐带”。

3. 构造函数也有自己的原型

构造函数本身也是对象,所以它也有__proto__。它指向Function.prototype,因为所有函数都是Function的实例。

console.log(Person.__proto__ === Function.prototype); // true

二、原型链:从孙子到老祖宗

我们来看一个完整的查找链:

function Animal(name) {
  this.name = name;
}
Animal.prototype.eat = function() {
  console.log(`${this.name}在吃东西`);
};

function Dog(name, breed) {
  Animal.call(this, name);
  this.breed = breed;
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
Dog.prototype.bark = function() {
  console.log('汪汪汪');
};

const wangcai = new Dog('旺财', '土狗');
wangcai.bark(); // 汪汪汪 (自己原型上的)
wangcai.eat();  // 旺财在吃东西 (从Animal原型继承来的)
wangcai.toString(); // [object Object] (从Object原型来的)

当调用wangcai.eat()时,查找过程是这样的:

  1. 先看wangcai自己身上有没有eat方法 → 没有
  2. wangcai.__proto__(也就是Dog.prototype)上找 → 没有
  3. Dog.prototype.__proto__(也就是Animal.prototype)上找 → 找到了eat
  4. 如果还没找到,继续往上到Animal.prototype.__proto__(也就是Object.prototype
  5. 还没找到就去Object.prototype.__proto__ → 这是null,链条结束,返回undefined

这个链条就是原型链。它像一条家族血脉,从孙子到儿子到父亲到爷爷到祖宗,直到追溯到null

三、原型链的终点:Object.prototype

所有普通对象的原型链终点都是Object.prototypeObject.prototype本身的原型是null

console.log(Object.prototype.__proto__); // null

Object.prototype上定义了一些所有对象都有的方法,比如toString()hasOwnProperty()valueOf()等。这就是为什么你的数组、函数、正则都能用这些方法。

四、如何判断属性是自己的还是继承的?

有时候我们需要知道一个属性是对象自己拥有的,还是从原型链上继承来的。这时候可以用hasOwnProperty()

function Person(name) {
  this.name = name;
}
Person.prototype.age = 18;

const p = new Person('张三');
console.log(p.hasOwnProperty('name')); // true,自己的
console.log(p.hasOwnProperty('age'));  // false,继承的
console.log('age' in p);               // true,不管自己的还是继承的,只要能访问到就返回true

hasOwnProperty只检查自身属性,in操作符会检查整个原型链。

五、修改原型的影响:千万别乱动

原型是共享的,所以如果你修改了原型,所有继承自它的实例都会受影响。

function Person() {}
const p1 = new Person();
const p2 = new Person();

Person.prototype.say = function() {
  console.log('hello');
};

p1.say(); // hello
p2.say(); // hello,两个实例都有了

Person.prototype.say = function() {
  console.log('world');
};

p1.say(); // world,瞬间都变了

这个特性有时候很有用(比如给内置类型添加方法),但也非常危险。尤其是在多人协作的项目里,随便修改原型可能导致难以追踪的bug。

注意:千万不要修改内置对象的原型,比如Array.prototypeObject.prototype,除非你非常清楚自己在做什么。这会污染全局,导致不可预测的行为。

六、原型链实现继承:传统方式

在ES6的class出现之前,JS主要靠原型链实现继承。上面的Dog继承Animal就是经典写法:

// 1. 定义父类构造函数
function Animal(name) {
  this.name = name;
}
Animal.prototype.eat = function() {
  console.log(`${this.name}吃东西`);
};

// 2. 定义子类构造函数
function Dog(name, breed) {
  Animal.call(this, name); // 继承属性
  this.breed = breed;
}

// 3. 继承方法
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

// 4. 添加子类自己的方法
Dog.prototype.bark = function() {
  console.log('汪汪汪');
};

这三步是经典组合寄生继承,ES6的class语法就是它的语法糖。

七、ES6 class:原型的“糖衣”

现在写继承用class简单多了:

class Animal {
  constructor(name) {
    this.name = name;
  }
  eat() {
    console.log(`${this.name}吃东西`);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name); // 调用父类构造函数
    this.breed = breed;
  }
  bark() {
    console.log('汪汪汪');
  }
}

看着舒服多了吧?但其实它底层还是原型那一套,只是帮我们省去了手动操作prototype的麻烦。

八、常见坑点与最佳实践

1. 不要用__proto__

__proto__虽然能用,但不是标准(虽然现代浏览器都支持),而且性能也不太好。用Object.getPrototypeOf()Object.setPrototypeOf()替代。

console.log(Object.getPrototypeOf(zhangsan) === Person.prototype); // true

2. 小心原型链上的属性被覆盖

如果子类实例定义了与原型同名的属性,会“遮蔽”原型上的属性。

function Person() {}
Person.prototype.name = '祖先';

const p = new Person();
p.name = '自己';
console.log(p.name); // '自己',原型的被遮住了
delete p.name;
console.log(p.name); // '祖先',删除自己的,又露出来了

3. 用Object.create()创建对象

Object.create(proto)可以创建一个新对象,它的原型直接指向proto

const parent = { name: '父亲' };
const child = Object.create(parent);
child.age = 10;
console.log(child.name); // '父亲',从parent继承

这是创建原型关系最简单的方式。

4. 尽量用class,少手动操作原型

现代开发中,class语法足够应对绝大多数场景,代码更清晰,不容易出错。

九、总结:原型链就是JS的“家谱”

  • 每个函数都有prototype属性(指向原型对象)
  • 每个实例都有__proto__属性(指向构造函数的prototype
  • 访问属性时,先在自身找,找不到就沿着__proto__往上找,直到null
  • 这个链条就是原型链
  • Object.prototype是链条的终点,上面定义了所有对象都有的方法
  • ES6的class是原型的语法糖,写起来更清爽

理解了原型链,你就能理解JS的继承机制,也能更高效地利用这个“家族关系”来复用代码。明天我们将在此基础上,深入讲解继承的多种实现方式,从原型链继承到ES6 class,一次性帮你理清JS继承的所有姿势。

如果你觉得这篇文章讲得清楚明白,点个赞让更多人看到。有疑问欢迎评论区留言,我们明天见!

❌
❌