Js 中 async-await 和 事件循环

上篇详细讲解了 js 中的生成器和迭代器. 包括迭代器, 可迭代协议, 可迭代对象, 生成器, 生成器协议, 生成器函数, 生成器对象, 生成器是特殊迭代器, 生成器可部分替代迭代器等诸多概念. 难点主要在概念理解上, 应用层面像 for...of, 展开运算符, 对象初始化, Array.from() 等都还算相对简单.

本篇将继续围绕迭代器和生成器, 再加上 Promise, 回调函数等知识, 推导出最优的异步代码解决方案, 即本篇的标题的 async-await 语法糖. 虽然平时也用过, 但却对它并不懂, 跟着瞎写一通, 能跑就行. 而本次则是希望从原理层面一步步推导出来, 这样才能真正掌握它.

异步代码处理方案

回顾之前模拟异步获取网络数据的的案例, 从最初的回调函数到用 Promise 改写:

// 异步代码

// 用户传入什么, 则服务器返回什么
function requestData(url) {
  return new Promise((resolve, reject) => {
    // 模拟网络请求
    setTimeout(() => {
      resolve(url)
    }, 2000);
  })
}

// 获取异步数据
requestData("youge").then(res => {
  console.log("res: ", res) // youge
})

这时相对简单的异步任务处理情况, 现在开始有新的需求:

  • 向服务器请求数据, 一共要发 多次请求
  • 第二次请求的 url 依赖于第一次的结果
  • 第三次请求的 url 依赖于第二次的结果
  • ... 依此类推
// 需求升级 

1. url: youge -> res: youge
2. url: res + 'aa' -> res: yougeaa
3. url: res + 'bb' -> res: yougeaabb
// ..

核心诉求就是 一个任务由多个异步任务组成, 后一个要等前一个异步任务拿到结果, 再请求 (异步任务队列).

// 需要多次发请求, 每次异步的, 后一次依赖前一次的结果.
user_id -> usr_info.department_id -> department_info

先来看第一种方案: 通过多次回调, 按照现在的 Promise 的写法实现:

// 方案一: 层层回调

requestData("youge").then(res => {
  // 拿到第一次的结果够, 再发送第二次请求
  requestData(res + 'aa').then(res => {
    // 拿到第二次请求的结果, 再发送第三次请求
    requestData(res + 'bb').then(res => {
      console.log(res) // 等 6秒后, 输出: yougeaabb
    })
  })
})


这个阅读性和可维护性都极低, 就是加强版的 "回调地狱" . 这种方案肯定是不行的, 维护起来太难了.

来看第二种方案: 通过 Promise 中 then 的返回值 解决, 理由 then() 返回的也是 Promise 的链式调用特性

// 方案2: 通过 Promise 返回值, 也是 Promise 的特性

requestData("youge").then(res => {
  return requestData(res + 'aa')
}).then(res => {
  return requestData(res + 'bb')
}).then(res => {
  console.log(res) // 等待 6秒 输出 yougeaabb
})

比回调地狱好一点, 但不多. 至少没有层层嵌套了, 但是一定要处理 res 的异常的话, 那这个代码也是难搞得很嘞.

Promise + Generator

来看第三种方案: Promise + Generator 实现, 通过生成器来控制回调函数的执行节奏.

// 方案3: Promise + Generator
function* getData() {
  yield requestData('youge')
}

const generator = getData()
// 每调用一次 next(), 则 requestData() 执行一次
// 返回值形式是 { value, done }
generator.next().then(res => {
  console.log(res)
})
  • 首先是定义了一个生成器函数 getData(), 在 yield 时会调用请求函数, 其返回值是 Promise
  • 调用 getData() 则会返回一个生成器, 每次进行 .next() 则会触发 yield , 则执行请求函数
  • yield 同时会将请求函数的结果返回, 它是一个 {value, done} 的结果, 这里 value 就是返回的 Promise
  • 因此通过 generator.next().value.then(res => {}) 能拿到异步请求的结果

拿到这个 res 之后, 我们传给 yield 的地方, 通过下一次的 .next(res)

// 上一次结果, 作为参数传递给下一次

function* getData() {
  const res1 = yield requestData('youge')
  console.log('执行第二段代码, 打印上一次的 res1 结果')
  yield res1
}

const generator = getData()
generator.next().value.then(res => {
  // next(res) 会传递给上面的 const res1 的地方, 然后在下一次的 yield 前能获取到
  console.log(generator.next(res))
})

这样就实现了结果作为参数的传递, 通过 next() 传递给了 yield . 于是就形成这样了.

// 用户传入什么, 则服务器返回什么

function requestData(url) {
  return new Promise((resolve, reject) => {
    // 模拟网络请求
    setTimeout(() => {
      resolve(url)
    }, 2000);
  })
}

// 方案3: Promise + Generator
function* getData() {
  const res1 = yield requestData('youge')
  const res2 = yield requestData(res1 + 'aa')
  const res3 = yield requestData(res2 + 'bb')
  const res4 = yield requestData(res3 + "cc")
  console.log(res4) // 等8秒后输出 yougeaabbcc
}

const generator = getData()
generator.next().value.then(res => {
  generator.next(res).value.then(res => {
    generator.next(res).value.then(res => {
      generator.next(res).value.then(res => {
        generator.next(res) // 比上面多一次
      })
    })
  })
})

可以看到这种方式:

  • 生成器中就没有 嵌套 了, 从形式上看 res1, res2, res3, res4 非常像依次从上到下的顺序代码
  • 但本质上 res1, res2 ... 是前后依赖的异步逻辑, 表现上很 亲民
  • 下面的生成器部分, 虽然是层层嵌套, 但是它都是重复的, 有规律可以自动化

现在如果将生成器的部分, 能够实现从 手动挡 -> 自动挡 那就非常完美!

function execGenerator(genFn) {
  const generator = genFn()
  // 自动依次执行生成器函数
  // 停止条件: 返回 undefined 
  
  // 不确定调多少次, 但有停止条件, 因此是一个递归
}

execGenerator(getData)

则这个递归函数实现如下:

// const generator = getData()
// generator.next().value.then(res => {
//   generator.next(res).value.then(res => {
//     generator.next(res).value.then(res => {
//       generator.next(res).value.then(res => {
//         generator.next(res)
//       })
//     })
//   })
// })

function execGenerator(genFn) {
  const generator = genFn()
  // 调用生成器的 next() 方法并传入上一个结果 res
  function exec(res) {
    const ret = generator.next(res)
    // 边界条件, yield 不返回值时
    if (ret.done) {
      return ret.value 
    } 
 
    ret.value.then(res => {
      exec(res) // 递归调用
    })
  }

  exec() // 立即调用
}

execGenerator(getData)

这个递归理解写起来有点无从下手, 但是看大佬写出来后, 阅读上还是有点感觉. 当然在实际中也不用自己写, 但凡我等小菜鸡能想到大多复杂的东西, 社区大神早就封装为工具类库了的. 就安心躺平调用 API 即可, 不要有心里负担, 因为也学不会.

通过这样的一番操作, 原来复杂的异步操作代码, 层层嵌套, 或者不断返回等方式, 最后从形式上变成了简洁的外观:

// 看着就像同步代码, 但实际上是异步代码

function* getData() {
  const res1 = yield requestData('youge')
  const res2 = yield requestData(res1 + 'aa')
  const res3 = yield requestData(res2 + 'bb')
  const res4 = yield requestData(res3 + "cc")
  console.log(res4) // 等8秒后输出 yougeaabbcc
}

**其实最终推导的这个过程, 就是模拟 async-await 的本质, 它其实就是咱们上面推导执行过程的 语法糖. **

异步函数 async

编程里面的同步和异步, 和现实世界是 相反 的, 也不知道是翻译问题, 还是我理解不对, 难受.

  • 异步 async: 表示同时做多件事. 我一边拉屎, 一边刷手机
  • 同步 sync : 表示依次 做多件事. 我先脱裤子, 然后在放屁.
// 异步函数, 写法就是在前面加个 async 关键字

async function foo1() { }

const foo2 = async () => { }

class Foo {
  async bar() {
    
  }
}

异步函数里面的代码执行过程基本和普通函数一致, 默认同步执行, 但当有返回值, 返回值 是 Promise 时这不同:

  • 返回值是普通值, 则会被包裹在 Promise.resovle() 中
  • 返回值是 Promise, 则 Promise 的状态将由 Promise 决定
  • 返回是是实现了 thenable 的对象, 则会由对象的 then 方法决定
  • 过程中出现异常, 不会像普通函数报错, 而是会作为 Promise 的 reject() 透传
// 异步函数执行流程, 默认和普通函数一致

async function foo() {

  console.log('内部代码执行, 1')
  console.log('内部代码执行, 2')
  console.log('内部代码执行, 3')

}

console.log("start...")
foo()
console.log("end...")
start...
内部代码执行, 1
内部代码执行, 2
内部代码执行, 3
end...

无返回值情况下, 默认和普通函数并无区别.

有返回值时, 异步函数的返回值一定是一个 Promise

// 异步函数的返回值, 一定是 Promise

async function foo(params) {

  console.log('foo start...')
  console.log('foo end...')
  
  // 1. 返回普通值
  // return 111  // 默认 undefined

  // 2. 返回 thenable  
  // return {
  //   then: function(resolve, reject) {
  //     resolve('okk')
  //   }
  // }

  // 3. 返回 Promise 
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject('err')
    }, 2000);
  })

}

foo().then(res => {
  // 等 异步函数 return 时获取到值, 加入微任务队列
  console.log(res, "Promise then 执行")

}).catch(err => {
  console.log("err: ", err)
})

异步函数中出现异常会通过 Promise 的 reject 透传, 而不像同步函数直接程序崩溃.

// 同步函数中出现异常, 则直接崩盘

function foo() {
  console.log('start')
  console.log('doing')

  throw new Error("异常");

  console.log(end)  // 不执行
  
}

console.log(111)  // 111
foo()
console.log(222) // 不执行了, 前面就报错了

异步中就要求要处理异常.

// 异步函数中出现异常, 传递给 Promise 的 catch 处理

async function foo() {
  console.log('start')
  console.log('doing')

  throw new Error("异常");

  console.log(end)  // 不执行
  
}

console.log(111)  

foo().catch(err => console.log('处理异常')) // 最后才执行 (微任务)

console.log(222) 

异步函数中 await

async 函数内部可以使用 await 关键字, 功能类似于前面生成器中的 yield, 用来控制异步函数执行顺序.

注意是要和 async 搭配使用, 不能单独用于普通函数.

  • 写法上, await 后面会跟上一个表达式, 表达式会返回一个 Promise
  • 执行上, await 会等 Promise 状态变为 fulfilled 后, 才会继续执行异步函数
// await 表达式

function requestData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(111)
    }, 2000);
  })
}

async function getData() {
  // 看是同步, 其实是异步, 异步的部分在自动 generator
  const res1 = await requestData()

  console.log('相当于上面 Promise then 的代码 01', res1)
  console.log('相当于上面 Promise then 的代码 02')

  const res2 = await requestData()
  console.log('res2 后面的代码执行', res2)
}

getData() // Promise
// 这里就是让异步代码,看起来像同步代码的 背后苦力

const generator = getData()
generator.next().value.then(res => {
  generator.next(res).value.then(res => {
    generator.next(res).value.then(res => {
      generator.next(res).value.then(res => {
        generator.next(res)
      })
    })
  })
})

await 后面除了跟表达式外, 也可以跟普通值.

// await 普通值

async function foo() {
  const res = await 123
  return res 
}

foo().then(res => {
  console.log(res) // 123
})

await 也可以跟一个对象 (实现了 thenable )

// await 对象 thenable

async function foo() {
  const res = await {
    then: function(resolve, reject) {
      resolve(111)
    }
  }

  return res 
}

foo().then(res => {
  console.log(res) // 111
})

await 也可以跟一个 Promise, 注意点就是, 当 reject 的时候, 则后续不会再继续执行, 类似 Promise.all()

// await 对象 thenable

async function foo() {
  const res = await new Promise((resolve, reject) => {
    reject('err')
  })

  // 前面 reject 了, 后面是不会调用的, 有点像 Promise.all()
  console.log(222)
  
  return res 
}

foo().then(res => {
  console.log(res) 

}).catch(err => {
  console.log(err) // err
})

只要是理解了前面我们用 **generator + yield 来模拟 async + await ** 的过程, 以及对 Promise 的深刻理解, 肯定深刻, 前面我们都手写 Promise A+ 了, 这些东西就轻轻松松. 后面直接无心智负担, 复制粘贴就行.

事件循环

要引出 事件循环, 这可需要先补充一下计算机的基本常识. 尤其像我这种没学过计算机的, 还是得补一下为好.

进程, 线程, 协程

  • 进程 (Process): 它是操作系统管理程序的一种方式, 一个程序的运行, 至少有一个进程
  • 线程 (Thread) : 一个程序进程, 至少包含一个线程, 它是操作系统能运行调度的最小单位
  • 协程 (Coroutine): 它是用户程序或者运行时库的轻量级并发单元, 大多建在单个线程上, 也可多个线程上

可以更直观去理解, 当我们去启动一个程序, 则至少会默认启动一个进程, 或者多个进程;

在每个进程中, 都至少会会启动一个线程来执行程序中的代码, 也称为 主线程, 也可以说,进程是线程的容器

而对于协程来说, 它并非是由操作系统调度的, 而是有程序或者运行时库, 比如我比较熟悉的 Go 和 Python:

概念 线程 (Thread) 协程 (Coroutine)
管理方 操作系统内核 (Kernel) 用户程序或运行时库 (Runtime)
切换开销 高 (涉及内核态/用户态切换、保存大量寄存器) 极低 (纯用户态函数调用,只保存少量上下文)
创建成本 高 (内存占用大,通常几MB) 极低 (内存占用小,通常几KB)
数量限制 有限 (通常几百到几千) 极多 (可轻松创建成千上万甚至百万)
调度 抢占式 (Preemptive) - OS 决定何时切换 协作式 (Cooperative) - 协程主动让出执行权
阻塞影响 一个线程阻塞,不会影响同进程内其他线程 **一个协程阻塞,可能阻塞其所在的整个线程 **

Go 语言将协程称为 Goroutine:

  • Go 运行时 (Runtime) 实现了一个复杂的 M:N调度器, M 个协程, 会被映射到 N 个线程上去运行
  • Goroutine 是轻量的, go func() 语法即可启动一个协程, 开销极小, 可以轻松创建几千几万, 天然高并发
  • 单个 Goroutine **不会阻塞所在的 OS 线程 **, 并发效率极度提高

Python 语言将协程, 则通过 async/awaitasyncio 等库进行实现

  • 它大多采用的是 1:N模型, 在单个线程上创建 N 个协程, 通过事件循环来进行任务调度
  • 它表现为一个任务, 通过 async def 定义协程函数, 返回协程对象, 然后等待被事件循环调度执行
  • 它的阻塞是致命的, 遇到阻塞任务 (查数据库, CPU密集计算等) 会阻塞整个事件循环所在的 OS线程.

而且 Python 还是全局锁, 就用起来是超级简单, 但是性能是真的垃圾.

Go 让你感觉像是在创建“轻量线程”,运行时帮你处理了线程和协程的复杂映射。

而Python 的 async/await 是一种编程范式,让你在单个线程内通过“暂停-恢复”机制实现并发, 还要人工运维

OS 的进程调度

我们平时用手机或者电脑时, 可以同时一边听歌, 一边回工作消息, 一边上网等, 这看上去是同时进行的, 其实并不是!

因为 CPU的运算速度非常快, 以至于我们根本察觉不到它其实在多个进程中来回速度切换!

当进程中的线程获取到时间片时, 就可以快速执行我们编写的代码, 这个量级的速度, 用户根本无感知.

所以嘛, 还是这句老话: "天下武功, 唯快不破".

操作系统进程调度是一个复杂的权衡过程:

  • 目标: 公平、高效、低延迟、高吞吐量。

  • 手段: 通过抢占式调度复杂的算法(如 MLFQ, CFS)来实现.

  • 趋势: 从简单的算法发展到自适应、多层次的调度器,能够智能地识别交互式任务(如 GUI、shell)并给予高优先级,同时保证后台任务也能获得资源.

  • 关键: 现代调度器(如 Linux CFS)不再追求“平均”,而是追求“公平”,并利用数据结构(红黑树)和动态调整来优化性能.

过多的就不补充了, 还是回到 js 这里来, 不然就跑偏了

浏览器中的 js 线程

首先来说浏览器, 以 Chrome 为例, 它本身也是一个用 C++ 编写的程序, 当启动浏览器时, 它会同时启动多个进程.

比如我们新开一个 tab 页面, 就会开启一个新进程, 当前页面卡死了, 并不会造成所有其他 tab 页面无法响应, 而造成浏览器崩溃.

它每个进程中又有很多个线程, 这些线程中, 就包含有 执行 js 代码的线程.

我们常说 js 核心执行环境是单线程的, 但是 js 的线程是有自己的容器进程: 浏览器或者 Node. (js 要依赖这俩老哥的环境)

  • 单线程对于 js 来说, 在同一个时刻只能做一件事情, 若这件事是耗时的, 则当前线程就会阻塞.
  • 但是 js 的执行环境是 浏览器 或者 Node , 他们是多线程, 多进程的, 可以创建额外的线程来执行 js

先说结论, js 是单线程语言, 但可以运行在多线程!

问题 回答
JS 主线程是单线程的吗? 是的。 默认的执行上下文只有一个主线程。
JS 代码能运行在多线程上吗? 是的! 通过 Web Workers (浏览器)Worker Threads (Node.js),你可以创建额外的线程来运行 JavaScript 代码。
async/await 是多线程吗? 不是。 它是单线程事件循环上的异步编程,依赖后台线程池处理 I/O,但 JS 逻辑本身仍在主线程执行。

所以真正耗时的操作, 如网络请求, 定时器, IO 读写等, 实际上并非要由 js 主线程执行的, 完全可以借助它的浏览器爸爸和 Node 爸爸的强大能力, 去跪求一些别的线程来异步完成多任务.

JavaScript 的核心执行环境是单线程的,但它提供了强大的 API(Web Workers / Worker Threads)来创建额外的线程,从而让 JavaScript 代码能够真正地在多线程上并行运行,以应对现代应用的性能需求.

这样就可以理解 js 这门语言和 java 或者 go 的本质区别了. 就 js 是一个寄生的, 需要跪舔它的浏览器爸爸, 或者 Node 爸爸, 不然没法存活. 同时执行代码环境也是单线程的, 轻量化, 适合客户端 好处就是数据流唯一, 不好就是不支持并发. 但因为有两个强大的爸爸, 也能通过跪舔能获的一些爸爸的资源来搞一些额外的线程, 如 web worker 等实现异步的任务执行.

而像 java 或者 go 这种, 他们是更相对独立的个体, 有自己能去直接操作 OS 的能力, 这不就是可以进程, 线程随便搞, 自由得很嘞! 因而更适用于重量级系统程序, 服务端程序等场景, 搞客户端就有点大材小用了.

浏览器的事件循环

js 代码执行是单线程的, 则意味着同一个时间段只能执行一个任务, 那比如:

  • setTimeout()
  • axios.get(url)
  • Promise.then(() => { })
  • onclick = function()
  • ...

等这些可能会阻塞主线程的任务, 它的执行顺序又该是怎样的呢? 比如这个 setTimeout, 假设我们设定 3秒钟之后再执行一个任务, 那谁来进行计时? 显然不能是这个当前的主线程, 不然就停住了. 则必然是去找它的浏览器或者 Node 爸爸搞点别的线程来支援一下.

于是, 当主线程遇到这些可能会阻塞的任务, 通过跪舔浏览器的其他线程来支援后, 如何将其他线程的结果, 回调回主线程, 则这个调度的机制, 就称为 浏览器事件循环.

// 事件循环

console.log('main start')

// 计时操作, 是由别的线程完成的, 不阻塞主线程
// 会将回调函数保存在其他地方, 开始计时
setTimeout(() => {
  console.log('回调函数执行')
}, 1000);

console.log('main end')

main start
main end

// 等1秒后
回调函数执行

这个存在别地方, 这个地方就是浏览器在维护的一个 事件任务队列, 队列是先进先出的哈.

  • 主线程遇到 setTimeout (() => {}, 1000), 是阻塞任务, 给它加到队列中去
  • 继续往后执行主线程代码
  • 等主线程执行完后, js 引擎从事件队列中, 去取出刚入队的任务, 进行执行

宏任务 与 微任务

在事件循环中, 浏览器其实维护的是 两个队列

  • 宏任务队列: ajax, setTimeout, setInterval, Dom 监听, UI 渲染等
  • 微任务队列: Promise.then(), queueMiscrotask() 等

他们和主线程之间的执行顺序是这样:

  • 先执行主线程中的代码先执行 (顶层的 js 代码)
  • 再检查微任务队列中是否有任务要执行, 有则先执行微任务
  • 等微任务清空后, 再从宏任务队列中, 去执行宏任务

宏任务执行之前, 必须保证微任务队列是空的

若微任务不为空, 则优先执行微任务队列中的任务 (回调)

// 宏任务与微任务


// 宏任务: 会被添加到宏队列, 最后执行
setTimeout(() => {
  console.log('宏任务开始: setTimeout')
}, 1000);

// 微任务: 会被添加到微队列, 在宏任务前执行
queueMicrotask(() => {
  console.log('微任务开始: queueMicrotask')
})

// main script, 这些会在主线程执行
function foo() {
  console.log('main-foo')
}

function bar() {
  console.log('main-bar')
  foo()
}

bar()

console.log('main-其他代码')
main-bar
main-foo
main-其他代码

微任务开始: queueMicrotask

宏任务开始: setTimeout

就记住规范就好了, main > 微任务 > 宏任务, 最常用的 Promise 是微, setTimeout , AJAX, UI是宏

Promise 相关练习题

case 01:

// 001
setTimeout(function () {
  console.log("setTimeout1");
  new Promise(function (resolve) {
    resolve();
  }).then(function () {
    new Promise(function (resolve) {
      resolve();
    }).then(function () {
      console.log("then4");
    });
    console.log("then2");
  });
});

new Promise(function (resolve) {
  console.log("promise1");
  resolve();
}).then(function () {
  console.log("then1");
});

setTimeout(function () {
  console.log("setTimeout2");
});

console.log(2);

queueMicrotask(() => {
  console.log("queueMicrotask1")
});

new Promise(function (resolve) {
  resolve();
}).then(function () {
  console.log("then3");
});

看上去有点复杂, 但只要按 main > 微 > 宏 逐个分析出代码的执行顺序即可

main 微任务队列 宏任务队列
promise1 then1 setTimeout1
log(2) queueMicrotask1 setTimeout2
then3

查看执行打印过程:

主:  promise1, 2,  then1, queueMicrotask1, then3, setTimeout1,then2, then4, setTime2

这里比较难的地方是这个 setTimeout1, 往下遇到 Promise.resovle(), 则到了 then(), 又遇到 Promise.resovle(), 则继续到 then(), 但此时并不会执行时 then4 哦, 而是加到 微任务, 而外层的 log(then2) 和 上一个 then 是平级的, 会先执行 then2, 才是 then4.

case 02:

async function async1 () {
  console.log('async1 start')
  await async2();
  console.log('async1 end')
}

async function async2 () {
  console.log('async2')
}

console.log('script start')

setTimeout(function () {
  console.log('setTimeout')
}, 0)
 
async1();
 
new Promise (function (resolve) {
  console.log('promise1')
  resolve();
}).then (function () {
  console.log('promise2')
})

console.log('script end')

做之前还是先回顾一下异步函数 aysnc 的特点是, 默认情况下, 和同步函数一样执行. await 才是 Promise 的

// 复习

async function foo() {
  console.log(111)
  await bar()
  console.log(333) // 微任务
}

function bar() {
  console.log(222)
  return new Promise((resolve, reject) => {
    resolve()
  })
}

foo()
console.log(444)

// 执行顺序: 111, 222, 444, 333

现在来正式分析 case2 的情况 :

main 微任务队列 宏任务队列
script start aysnc1 end setTimeout
async1 start promise2
async2
promise1
script end

因此最终顺序就是:

script start, async1 start, async2, promise1, script end, aysnc1 end, promise2, setTimeout

这个题相比第一个例子还是要简单一些, 没有那么多的 Promise 嵌套, 注意: then() 一定要加入 微队列

练习题就不再继续做了, 对我而已主要是区分理解宏任务和微任务相关代码执行顺序, 尤其这个 Promise 是可以出很多较难的题目的, 我并不是搞前端的, 因此大致了解即可, 不用太深入了.

至此, 关于异步函数 async-await 的用 generator + yield 的推导过程, 语法糖的使用, 以及浏览器的事件循环机制, 还有宏任务队列, 微任务队列等知识就差不多了, 本篇重要的是理解为主哈.

posted @ 2025-09-03 22:12  致于数据科学家的小陈  阅读(21)  评论(0)    收藏  举报