阅读视图

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

异步编程:从“回调地狱”到“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继承的所有姿势。

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

闭包:那个“赖着不走”的家伙,到底有什么用?

昨天我们认识了闭包——那个“虽然离开了家,但还记得家里密码”的神奇函数。今天咱们来深挖一下:闭包这玩意儿到底能干啥?有没有什么副作用?怎么防止它把内存吃光?看完这篇,你不仅知道闭包怎么用,还能在面试官面前侃侃而谈。

前言

闭包就像一个“赖着不走”的租客。你以为人走了,结果他还留着你的钥匙,时不时回来拿点东西。这在JavaScript里有时候特别好用,有时候又特别坑。

今天我们就来盘点闭包的几个经典应用场景,顺便聊聊怎么让它“体面退场”,别把你的内存吃光。

一、闭包的应用场景:这个“赖着不走”的家伙还挺有用

1. 模块化:私有变量与公共方法

没有ES6模块之前,闭包是JS实现模块化的主要手段。它能把内部细节藏起来,只暴露需要公开的接口。

const counter = (function() {
  let count = 0; // 私有变量,外面访问不到
  
  function increment() {
    count++;
    console.log(count);
  }
  
  function decrement() {
    count--;
    console.log(count);
  }
  
  function getCount() {
    return count;
  }
  
  return {
    increment,
    decrement,
    getCount
  };
})();

counter.increment(); // 1
counter.increment(); // 2
console.log(counter.count); // undefined,拿不到
console.log(counter.getCount()); // 2

这个模式叫IIFE(立即执行函数),它创建了一个闭包,里面的count变量被返回的方法“记住”了,外部无法直接修改,只能通过提供的接口操作。像不像一个“保险箱”?钥匙只给了你几个特定的人。

2. 函数工厂:批量生产定制函数

闭包可以用来创建带有特定“预设”的函数,比如一个能记录调用次数的函数。

function createCounter(initial = 0) {
  let count = initial;
  return function() {
    count++;
    return count;
  };
}

const counterA = createCounter(10);
console.log(counterA()); // 11
console.log(counterA()); // 12

const counterB = createCounter(0);
console.log(counterB()); // 1

每个计数器都独立拥有自己的count变量,互不干扰。这个工厂就像是做定制蛋糕,每个客户拿到的是自己专属的那一份。

3. 防抖与节流:控制函数执行频率

防抖和节流是前端性能优化的常见手法,它们的核心都依赖闭包来保存计时器和状态。

防抖:用户连续触发事件时,只有最后一次等待结束后才执行(比如搜索框输入)。

function debounce(fn, delay) {
  let timer = null; // 闭包保存timer
  return function(...args) {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, args);
      timer = null;
    }, delay);
  };
}

// 使用
const search = debounce(() => console.log('搜索中...'), 500);

节流:限制函数在单位时间内最多执行一次(比如滚动事件)。

function throttle(fn, delay) {
  let last = 0;
  return function(...args) {
    const now = Date.now();
    if (now - last >= delay) {
      last = now;
      fn.apply(this, args);
    }
  };
}

这两个函数返回的都是闭包,里面的timerlast被“记住”了,所以每次调用都能访问到上一次的状态。

4. 柯里化:提前固定参数

柯里化是把多参数函数变成一系列单参数函数的技术,本质也是闭包。

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    } else {
      return function(...more) {
        return curried.apply(this, args.concat(more));
      };
    }
  };
}

function add(a, b, c) {
  return a + b + c;
}

const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6

每次返回新函数时,原来的args被闭包保存,直到参数凑齐才执行。就像是你给一家餐厅留了订单,每次打电话加菜,最后一起结算。

5. 事件监听中的回调

在事件回调里访问外部变量,其实也是闭包。比如一个简单的计数器按钮:

let count = 0;
document.getElementById('btn').addEventListener('click', function() {
  count++;
  console.log(count);
});

这里的匿名函数“记住”了外部的count变量,每次点击都能访问到最新的值。

二、闭包的“阴暗面”:内存泄漏与性能

闭包这么香,为什么还有人说它不好?因为它会“赖着不走”——那些被记住的变量,即使外部函数已经执行完了,也不会被垃圾回收,只要闭包函数还活着,它们就一直存在。

1. 什么是内存泄漏?

内存泄漏就是程序用完了内存,但系统没有及时回收,导致内存占用越来越大,最后浏览器变卡、甚至崩溃。

闭包导致泄漏的典型场景:

function leak() {
  let bigData = new Array(1000000).fill('leak');
  return function() {
    console.log('I am a closure');
    // 虽然没有直接使用bigData,但闭包还是引用了它
  };
}

const closureFn = leak(); // 泄漏了100万个元素的数组

上面这个例子中,返回的函数虽然没有用到bigData,但因为bigData和它在同一个作用域,闭包会保留整个作用域链上的所有变量。所以如果闭包一直存在,那些无用的变量也一直占用内存。

2. 如何避免闭包导致的内存泄漏?

  • 用完后解除引用:把闭包函数的变量置为null
closureFn = null; // 这样bigData就可以被回收了
  • 只保留需要的变量:如果闭包中只用到部分变量,可以用let声明在闭包外部提前“过滤”。
function good() {
  let bigData = new Array(1000000).fill('data');
  let needed = 'only me';
  return function() {
    console.log(needed); // 只引用needed,bigData会被回收
  };
}

因为闭包只引用了needed,引擎可以优化,把bigData标记为不可达。

  • 避免在循环中创建闭包(除非必要),因为循环中的闭包可能会意外持有大量变量。

3. 弱引用:救星Map和Set

ES6引入了WeakMapWeakSet,它们的键是弱引用的——如果键对象不再被其他地方引用,那么即使还在WeakMap里,也会被垃圾回收。

这在闭包中可以用来缓存数据,而不阻止回收。

const cache = new WeakMap();

function process(obj) {
  if (!cache.has(obj)) {
    const result = heavyComputation(obj);
    cache.set(obj, result);
  }
  return cache.get(obj);
}

如果obj在其他地方被销毁了,cache里的键值对也会自动消失,不会造成泄漏。

三、实战:闭包的最佳实践

  1. 用闭包封装私有数据:在不需要完全隔离的情况下,闭包是模块化的好帮手。但现代开发可以用ES6模块(import/export)替代IIFE,更清晰。

  2. 防抖节流用闭包保存状态:这是闭包的经典应用,没啥好纠结的。

  3. 谨慎使用返回闭包的高阶函数:如果闭包持有大量数据,确保及时清理。

  4. 善用let替代varlet有块级作用域,能避免一些意外的闭包问题。

  5. 在DevTools里监控内存:用Chrome的Memory面板,可以拍快照,看看哪些闭包对象一直存在,帮助定位泄漏。

四、总结:闭包是个好员工,但别让它996

闭包是JavaScript的强大特性,它让函数拥有了“记忆”,能实现模块化、柯里化、防抖节流等高级功能。但也要注意它的副作用:被记住的变量不会自动消失,如果不注意,容易造成内存泄漏。

记住几个原则:

  • 用完闭包,及时解除引用。
  • 在闭包里只引用需要的变量,减少内存占用。
  • 现代开发中,能用ES6模块就用模块,减少手动闭包模式。
  • 遇到缓存场景,优先考虑WeakMap

掌握了闭包,你就掌握了JS高级编程的核心钥匙。明天我们将走进JS的另一个灵魂领域——原型和原型链,看看那个让新手望而生畏的概念,到底是怎么一回事。

如果你觉得今天的闭包应用和内存管理讲得透彻,点个赞让更多人看到。有疑问评论区见,我们明天见!

❌