阅读视图

发现新文章,点击刷新页面。

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

为什么数组可以用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() 让选择器“逆天改命”

引言

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

“什么需求?”

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

组长瞥了我一眼:“你用 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 函数:那个能“暂停”的函数,到底有什么用?

你有没有想过,如果函数可以“暂停”,等你想好了再继续,会是什么样?今天我们就来认识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”的救赎之路

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 的“家族关系”大揭秘

有人说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继承的所有姿势。

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

❌