精读《Vue.js设计与实现》


前言

《Vue.js 设计与实现》:它不同于市场上纯粹的 “源码分析” 类的书籍。而是 从高层的设计角度,探讨框架需要关注的问题(-尤雨溪序),以 提出问题 - 分析思路 - 解决问题 的方式,来讲解 vue 3 的核心设计。其内部,没有读一行 vue3 的源码,但却可以让我们对整个 vue 3 的核心,拥有一个非常清楚的认知。

第一篇:框架设计概览

第一章:权衡的艺术

命令式和声明式

首先第一个方面就是:命令式和声明式 的概念。

所谓 命令式 指的就是:关注过程 的范式。

image-20230612192529928

而 声明式 指的就是: 关注结果 的范式。

image-20230612192601767

那么这里大家来想一下,vue 是声明式的?还是命令式的?

对于 vue 而言,它的内部实现一定是 命令式 的,而我们在使用 vue 的时候,则是通过 声明式 来使用的。

也就是说: vue 封装了命令式的过程,对外暴露出了声明式的结果

性能与可维护性的权衡

在明确好了命令式和声明式的概念之后。接下来咱们来看下从 性能 层面,vue 所体现出来的一种权衡的方式。

针对于性能的分析,主要从两个方面去说。

首先第一个方面:大家觉得 是命令式的性能更强,还是声明式的性能更强呢?

答案是:命令式的性能 > 声明式的性能。

其实原因非常简单,对于 命令式 的代码而言,它直接通过 原生的 JavaScript 进行实现,这是最简单的代码,没有比这个更简单的了,我们把它的性能比作 1。

而声明式,无论内部做了什么,它想要实现同样的功能,内部必然要实现同样的命令式代码。所以它的性能消耗一定是 1 + N 的。

那么既然如此,vue 为什么还要对外暴露出声明式的接口呢?

这其实是因为:声明式的可维护性,要远远大于命令式的可维护性。

当性能与可维护性产生冲突时,那么舍鱼而取熊掌者也。(注意:在 vue 的性能优化之下,它并不会比纯命令式的性能差太多)

而这样的一种权衡,在 template 模板中,更是体现的淋漓尽致。

在前端领域,想要使用 JavaScript 修改 html 的方式,主要有三种:原生 JavaScript、innerHTML、虚拟 DOM

很多小伙伴都会认为 虚拟 DOM 的性能是最高的,其实不是。

image-20230612192855594

从这个对比我们可以发现,虚拟 DOM 的性能,并不是最高的。

但是它的 心智负担(书写难度)最小, 从而带来了 可维护性最高。所以哪怕它的性能并不是最高的。vue 依然选择了 虚拟 DOM 来进行了渲染层的构建。

这个也是一种性能与可维护性的权衡。

运行时和编译时

第一章的最后一部分,主要讲解的就是 运行时和编译时。

这两个名词,各位小伙伴在日常开发中,应该是经常听到的。

它们两个都是框架设计的一种方式,可单独出现,也可组合使用。

那么下面咱们就分别来介绍一下它们。

首先是 运行时:runtime。

它指的是:利用 render 函数,直接把 虚拟 DOM 转化为 真实 DOM 元素 的一种方式。

在整个过程中,不包含编译的过程,所以无法分析用户提供的内容。

其次是 编译时:compiler:

它指的是:直接把 template 模板中的内容,转化为 真实 DOM 元素。

因为存在编译的过程,所以可以分析用户提供的内容。

同时,没有运行时理论上性能会更好。

目前该方式,有具体的实现库,那就是现在也非常火的 Svelte

但是这里要注意: 它的真实性能,没有办法达到理论数据。

最后是 运行时 + 编译时:

它的过程被分为两步:

  1. 先把 template 模板转化为 render 函数。也就是 编译时
  2. 再利用 render 函数,把 虚拟 DOM 转化为 真实 DOM。也就是 运行时

两者的结合,可以:

在 编译时,分析用户提供的内容
在 运行时,提供足够的灵活性

这也是 vue 的主要实现方式。

第二章:框架设计的核心要素

  1. 通过 环境变量 和 TreeShanking 控制打包之后的体积
  2. 构建不同的打包产物,以应用不同的场景
  3. 提供了 callWithErrorHandling 接口函数,来对错误进行统一处理
  4. 源码通过 TypeScript 开发,以保证可维护性。
  5. 内部添加了大量的类型判断和其他工作,以保证开发者使用时的良好体验。

第三章:Vue.js 3 的设计思路

在这一章中,作者站在一个高层的角度,以 UI 形式、渲染器、组件、编辑器 为逻辑主线进行的讲解。

下面咱们就来捋一捋这条线。

在 Vue 中 UI 形式主要分为两种:

  • 声明式的模板描述
    ![image-20230208170232727](…/…/…/书籍/一小时读完系列/book_read_quickly-master/Vue.js 设计与实现/Vue.js 设计与实现.assets/image-20230208170232727.png)
  • 命令式的 render 函数
    image-20230208170236795

而针对于 声明式的模板描述 而言,本质上就是咱们常用的 tempalte 模板。它会被 编辑器 编译,得到 渲染函数 render 。

渲染器与渲染函数,并 不是 一个东西。

渲染器是 函数 createRenderer 的返回值,是一个对象。被叫做 renderer。 renderer 对象中有一个方法 render,这个 render ,就是我们常说的渲染函数。

渲染函数接收两个参数 VNode 和 container。

其中 VNode 表示 虚拟 DOM,本质上是一个 JS 对象。container 是一个容器,表示被挂载的位置。而 render 函数的作用,就是: 把 vnode 挂载到 container 上。

同时,因为 Vue 以组件代表最小颗粒度,所以 vue 内部的渲染,本质上是:大量的组件渲染。

而组件本质上是一组 DOM 的集合,所以渲染一个一个的组件,本质上就是在渲染一组这一组的 DOM。也就是说,Vue 本质上是: 以组件作为介质,来完成针对于一组、一组的 DOM 渲染。

第二篇:响应式系统

第四章:响应系统的作用与实现

在这一章中,作者从 响应式数据的概念开始,讲解了响应式系统的实现。 然后针对于 计算属性与 watch 的实现原理,进行了分析。 在分析的过程中,也对其所设计到的 调度系统(scheduler) 和 惰性执行(lazy) 的原理进行了明确。 最后讲解了在 竞态问题下,关于过期的副作用的处理逻辑。

响应式数据

那么首先咱们先来看基本概念 副作用函数 与 响应式数据。

所谓 副作用函数 指的是 会产生副作用的函数,这样的函数非常的多。比如

image-20230612193528677

在这段代码中, effect 的触发会导致全局变化 val 发生变化,那么 effect 就可以被叫做副作用函数。而如果 val 这个数据的变化,导致了视图的变化,那么 val 就被叫做 响应式数据。

那么如果想要实现响应式数据的话,那么它的核心逻辑,必然要依赖两个行为:

  • 第一个是 getter 行为,也就是 数据读取
  • 第二个是 setter 行为,也就是 数据修改

在 vue 2 中,这样的两个行为通过 Object.defineProperty 进行的实现。

在 vue 3 中,这样的两个行为通过 Proxy 进行的实现。

那么具体的实现逻辑是什么呢?咱们来看下面的图示:

首先是 getter 形式:

image-20230208191120105

在该函数中,存在一个 effect 方法,方法内部触发了 getter 行为。一旦 getter 行为被触发,则把对应的 effect 方法保存到一个 “桶(数据对象)” 中

当触发 setter 行为时:

image-20230208191257788

则会从 “桶” 中取出 effect 方法,并执行。

那么此时因为 obj.text 的值发生了变化,所以 effect 被执行时 document.body.innerText 会被赋上新的值。从而导致视图发生变化。

调度系统(scheduler)

那么说完了基本的响应性之后,接下来咱们来看 调度系统(scheduler)

所谓调度系统,指的就是 响应性的可调度性。

而所谓的可调度,指的就是 当数据更新的动作,触发副作用函数重新执行时,有能力决定:副作用函数(effect)执行的时机、次数以及方式

比如,在这段打印中,决定打印的顺序

image-20230612193750814

而想要实现一个调度系统,则需要依赖 异步:Promise 和 队列:jobQueue 来进行实现。咱们需要 基于 Set 构建出一个基本的队列数组 jobQueue,利用 Promise 的异步特性,来控制执行的顺序

计算属性(computed)

当我们可以控制了执行顺序之后,那么就可以利用这个特性来完成 计算属性(computed) 的实现了。

计算属性本质上是: 一个属性值,当依赖的响应式数据发生变化时,重新计算

那么它的实现就需要彻底依赖于 调度系统(scheduler) 来进行实现。

惰性执行(lazy)

说完计算属性,那么下面我们来看下 watch 监听器。

watch 监听器本质上是 观测一个响应式数据,当数据发生变化时,通知并执行相应的回调函数

这也就意味着,watch 很多时候并不需要立刻执行。

那么此时,就需要使用到 惰性执行(lazy) 来进行控制。

惰性执行的实现要比调度系统简单。它本质上 是一个 boolean 型的值,可以被添加到 effect 函数中,用来控制副作用的执行。

if (!lazy) {
  // 执行副作用函数
}

过期的副作用

watch 监听器的实现非常广泛,有时候我们甚至可以在 watch 中完成一些异步操作。

但是大量的异步操作,极有可能会导致 竞态问题。

所谓的竞态问题,指的是 在描述一个系统或者进程的输出,依赖于不受控制的事件出现顺序或者出现时机。比如咱们来看这段代码

image-20230612193927515

这段代码完成的是一个异步操作。

如果 obj 连续被修改了两次,那么就会发起两个请求。我们最终的期望应该是 data 被赋值为 请求B 的结果。

但是,因为异步的返回结果我们无法预计。所以,如果 请求 B 先返回,那么最终 data 的值就会变为 请求 A 的返回值。

这个咱们的期望是不一样的。

那么这样的问题,就是 竞态问题

而如果想要解决这问题,那么就需要使用到 watch 回调函数的第三个参数 onInvalidate,它本身也是一个回调函数。并且 该回调函数(onInvalidate)会在副作用下一次重新执行前调用,可以用来清除无效的副作用,例如等待中的异步请求

而 onInvalidate 的实现原理也非常简单,只需要 在副作用函数(effct)重新执行前,先触发 onInvalidate 即可。

第五章:非原始值(对象)的响应性方案

书中的第五章整体而言非常简单,主要就介绍了两个接口,Proxy 和 Reflect。

这两个接口通常会一起进行使用,其中:

  • Proxy 可以 代理一个对象(被代理对象)的 getter 和 setter 行为,得到一个 proxy 实例(代理对象)
  • Reflect 可以 在 Proxy 中使用 this 时,保证 this 指向 proxy,从而正确执行次数的副作用

第六章:原始值(非对象)的响应性方案

如果大家熟悉 proxy 的话,那么可以知道,针对于 proxy 而言,它只能代理复杂数据类型。这就意味着,简单数据类型无法具备响应性。

但是,在 vue 中,我们可以通过 ref 构建简单数据类型的响应。

那么 ref 是如何进行实现的呢?

这里大家要注意:针对于最新的 vue 3.2 而言,书中在 《6.1 引入 ref 的概念》中所讲解的 ref 实现原理存在 “落后性”。 vue 3.2 已经修改了 ref 的实现,这得益于 @basvanmeurs 的贡献

在最新的 vue 3.2 代码中,vue 通过 **get 、set 函数标记符,让函数以属性调用的形式被触发。**这两个修饰符,可以让我们 像调用属性一样,调用方法。 所以当我们平时 访问 ref.value 属性时,本质上是 value() 函数的执行。

第三篇:渲染器

第七章:渲染器的设计

在之前咱们说过 渲染器与渲染函数不是一个东西

  • 渲染器 是 createRenderer 的返回值,是一个对象。
  • 渲染函数 是渲染器对象中的 render 方法

在 vue 3.2.37 的源码内部,createRenderer 函数的具体实现是通过 baseCreateRenderer 进行的。它的代码量非常庞大,涉及到了 2000 多行的代码。

总体可以被分为两部分:

  1. 在浏览器端渲染时,利用 DOM API 完成 DOM 操作:比如,如果渲染 DOM 那么就使用 createElement,如果要删除 DOM 那么就使用 removeChild。
  2. 渲染器不能与宿主环境(浏览器)产生强耦合:因为 vue 不光有浏览器渲染,还包括了 服务端 渲染,所以如果在渲染器中绑定了宿主环境,那么就不好实现服务端渲染了。

所谓 vnode 本身是 一个普通的 JavaScript 对象,代表了渲染的内容。对象中通过 type 表示渲染的 DOM。比如 type === div:则表示 div 标签、type === Framgnet 则表示渲染片段(vue 3 新增)、type === Text 则表示渲染文本节点。

第八章:挂载与更新

对于渲染器而言,它做的最核心的事情就是 对节点进行挂载、更新的操作。作者在第八章中,详细的介绍了对应的逻辑。

整个第八章分为两部分来讲解了这个事情:

  1. DOM 节点操作
  2. 属性节点操作

DOM 节点操作

首先先来看 DOM 节点操作。DOM 节点的操作可以分为三部分:

  • 挂载:所谓挂载表示节点的初次渲染。比如,可以直接通过 createElement 方法新建一个 DOM 节点,再利用 parentEl.insertBefore 方法插入节点。
  • 更新:当响应性数据发生变化时,可能会涉及到 DOM 的更新。此时的更新本质上是属于 属性的更新。咱们等到属性节点操作那里再去说。
  • 卸载:所谓卸载表示旧节点不在被需要了。那么此时就需要删除旧节点,比如可以通过 parentEl.removeChild 进行。

以上三种类型,是 vue 在进行 DOM 操作时的常见逻辑。基本上覆盖了 DOM 操作 90% 以上 的常见场景

属性节点操作

看完了 DOM 操作之后,接下来咱们来看属性节点操作。

针对于属性而言,大体可以分为两类:

  1. 属性:比如 class、id、value、src…
  2. 事件:比如 click、input…

那么咱们就先来看 非事件的属性部分。

想要了解 vue 中对于属性的处理,那么首先咱们需要先搞明白一个很重要的问题。那就是 浏览器中的属性分类。

在浏览器中 DOM 属性其实被分为了两类:

  • 第一类叫做 HTML Attributes:直接定义在 HTML 标签 上的属性,都属于这一类。
  • 第二类叫做 DOM Properties:它是拿到 DOM 对象后定义的属性。咱们接下来主要要说的就是它。

HTML Attributes 的定义相对而言比较简单和直观,但是问题在于 它只能在 html 中进行操作。

而如果想要在 JS 中操作 DOM 属性,就必须要通过 DOM Properties 来进行实现。但是因为 JS 本身特性的问题,会导致某些 DOM Properties 的设置存在特殊性。比如 class、type、value 这三个。

所以为了保证 DOM Properties 的成功设置,那么我们就必须要知道 **不同属性的 DOM Properties 定义方式 **。

下面咱们来看一下。

DOM Properties 的设置一共被分为两种:

  1. el.setAttribute('属性名', '属性值')
  2. . 属性赋值 : el.属性名 = 属性值 或者 el[属性名] = 属性值 都属于 .属性赋值

我们来看这段代码:

image-20230209093545078

在这段代码中,我们为 textarea 利用 DOM Properties 的方式设置了三个不同的属性:

  • 首先是 class: class 在属性操作中是一个非常特殊的存在。它有两个名字 class 和 className。如果我们直接通过 el.setAttribute 的话,那么必须要用 class 才可以成功,而如果是通过 . 属性 的形式,那么必须要使用 className 才可以成功。
  • 第二个是 type: type 仅支持 el.setAttribute 的方式,不支持 .属性的方式
  • 第三个是 value:value 不支持直接使用 el.setAttribute 设置,但是支持 .属性 的设置方式

除了这三个属性之外,其实还有一些其他的属性也需要进行特殊处理,咱们这里就不再一一赘述了。

事件

接下来,咱们来看 vue 对事件的处理操作。

事件的处理和属性、DOM 一样,也是分为 添加、删除、更新 三类。

  • 添加:添加比较简单,主要利用 el.addEventListener 进行实现即可。
  • 删除:主要利用 el.removeEventListener 进行处理。
  • 更新:但是对于更新来说,就比较有意思了。下面咱们主要来看的就是这个更新操作。

通常情况下,我们所认知的事件更新应该是 删除旧事件、添加新事件 的过程。但是如果利用 el.addEventListener 和 el.removeEventListener 来完成这件事情,是一件非常消耗性能的事。

那么怎么能够节省性能,同时完成事件的更新呢?

这时,vue 对事件的更新提出了一个叫做 vei 的概念,这个概念的意思是: 为 addEventListener 回调函数,设置了一个 value 的属性方法,在回调函数中触发这个方法。通过更新该属性方法的形式,达到更新事件的目的。

这个代码比较多,大家如果想要查看具体代码的话,可以 在 github 搜索 vue-next-mini,进入到 packages/runtime-dom/src/modules/events.ts 路径下查看。

第九、十、十一章:Diff 算法

整个渲染器最后的三个章节全部都用来讲解了 diff 算法。

针对于 diff 而言,它的本质其实就是一个对比的方法,其描述的核心就是: “旧 DOM 组”更新为“新 DOM 组”时,如何更新才能效率更高。

目前针对于 vue 3.2.37 的版本来说,整个的 diff 算法被分为 5 步(这 5 步不跟大家读了,因为咱们没头没尾的读一遍,其实对大家也没有什么帮助):

  1. sync from start:自前向后的对比
  2. sync from end:自后向前的对比
  3. common sequence + mount:新节点多于旧节点,需要挂载
  4. common sequence + unmount:旧节点多于新节点,需要卸载
  5. unknown sequence:乱序

而,针对于书中的这三章来说,本质上是按照 简单 diff 算法、双端 diff 算法、快速 diff 算法 的顺序把整个 diff 的前世今生基本上都说了一遍。里面涉及到了非常多的代码。

第四篇:组件化

第十二章:组件的实现原理

想要了解 vue 中组件的实现,那么首先我们需要知道什么是组件。

组件本质上就是一个 JavaScript 对象,比如,以下对象就是一个基本的组件

image-20230209105953064

而对于组件而言,同样需要使用 vnode 来进行表示,当 vnode 的 type 属性是一个 自定义对象 时,那么这个 vnode 就表示组件的 vnode

image-20230209110548502

而组件的渲染,本质上是 组件包含的 DOM 的渲染。 对于组件而言,必然会包含一个 render 渲染函数。如果没有 render 函数,那么 vue 会把 template 模板编译为 render 函数。而组件渲染的内容,其实就是 render 函数返回的 vnode。具体的渲染逻辑,全部都通过渲染器执行。

image-20230613164740214

vue 3 之后提出了 composition API,composition API 包含一个入口函数,也就是 setup 函数。 setup 函数包含两种类型的返回值:

  1. 返回一个函数:当 setup 返回一个函数时,那么该函数会被作为 render 函数直接渲染。
  2. 返回一个对象:当 setup 返回一个对象时,那么 vue 会直接把该对象的属性,作为 render 渲染时的依赖数据

同时,对于组件来说还有一个 插槽 的概念。插槽的实现并不神奇。插槽本质上 是一段 innerHTML 的内容,在 vnode 中以 children 属性进行呈现。当插槽被渲染时,只需要渲染 children 即可。

对于组件来说,除了咱们常用的 对象组件 之外,vue 还提供了额外的两种组件,也就是 异步组件与函数式组件。

第十三章:异步组件与函数式组件

所谓异步组件,指的是: 异步加载的组件 。

比如服务端返回一个组件对象,那么我们也可以拿到该对象,直接进行渲染。

异步组件在 优化页面性能、拆包、服务端下发组件 时,会比较有用。

而对于 函数式组件 来说,相对就比较冷僻了。函数式组件指的是 没有状态的组件。本质上是一个函数,可以通过静态属性的形式添加 props 属性 。在实际开发中,并不常见。

第十四章:内建组件和模块

这一章中,主要描述了 vue 的三个内置组件。

keepAlive

首先第一个是 KeepAlive。

这是我们在日常开发中,非常常用的内置组件。它可以 缓存一个组件,避免该组件不断地销毁和创建。

看起来比较神奇,但是它的实现原理其实并不复杂,主要围绕着 组件卸载 和 组件挂载 两个方面:

  • 组件卸载:当一个组件被卸载时,它并不被真正销毁,而是把组件保存在一个容器中
  • 组件挂载:因为组件被保存了。所以当这个组件需要被挂载时,就不需要在重新创建,而是直接从容器中获取即可。

Teleport

Teleport 是 vue 3 新增的组件,作用是 将 Teleport 插槽的内容渲染到其他的位置。比如我们可以把 dialog 渲染到 body 根标签之下。

它的实现原理,主要也是分为两部分:

  1. 把 Teleport 组件的渲染逻辑,从渲染器中抽离
  2. 在指定的位置进行独立渲染

Transition

Transition 是咱们常用的动画组件,作用是 实现动画逻辑。

其核心原理同样被总结为两点:

  1. DOM 元素被挂载时,将动效附加到该 DOM 元素上
  2. DOM 元素被卸载时,等在 DOM 元素动效执行完成后,执行卸载 DOM 操作

第五篇:编译器

第十五章:编译器核心技术概述

在编译器核心技术概述,主要包含两个核心内容:

  1. 模板 DSL 的编译器
  2. Vue 编译流程三大步

模板 DSL 的编译器

在任何一个编程语言中,都存在编译器的概念。 vue 的编译器是在 一种领域下,特定语言的编译器 ,那么这种编译器被叫做 DSL 编译器。

而编译器的本质是 通过一段程序,可以把 A 语言翻译成 B 语言。在 vue 中的体现就是 把 tempalte 模板,编译成 render 渲染函数

一个完整的编译器,一个分为 两个阶段、六个流程:

  • 编译前端:
    • 词法分析
    • 语法分析
    • 语义分析
  • 编译后端:
    • 中间代码生成
    • 优化
    • 目标代码生成

image-20230209113241592

而对于 vue 的编译器而言,因为它是一个特定领域下的编译器,所以流程会进行一些优化,一共分为三大步

image-20230209113421705

  1. parse:通过 parse 函数,把模板编译成 AST 对象
  2. transform:通过 transform 函数,把 AST 转化为 JavaScript AST
  3. generate:通过 generate 函数,把 JavaScript AST 转化为 渲染函数(render)

第十六章:解析器(parse)

这一章,主要详细讲解了 parse 解析逻辑。是在三大步中的 parse 逻辑的基础上,进行了一个加强。

所以这里咱们也按下不表

第十七章:编译优化

最后就是编译优化。

编译优化也是一个非常大的概念,其核心就是 通过编译的手段提取关键信息,并以此知道生成最优代码的过程。

它的核心优化逻辑,主要是 把节点分为两类:

  • 第一类是 动态节点:也就是会 受数据变化影响 的节点
  • 第二类是 静态节点:也就是 不受数据变化影响 的节点

优化主要的点,就是 动态节点。

优化的方式主要是通过 Block 树 进行优化。

Block 树 本质上就是一个 虚拟节点数对象,内部包含一个 dynamicChildren 属性,用来 收集所有的动态子节点,以达到提取关键点进行优化的目的。

除此之外,还有一些小的优化手段,比如:

  • 静态提升
  • 预字符串化
  • 缓存内联事件处理函数
  • v-once 指令
  • …

第六篇:服务端渲染

最后一篇只有一个章节,就是 同构渲染。

想要了解同构渲染,那么需要先搞明白 CSR、SSR 的概念。

  • CSR:所谓 CSR 指的是 客户端渲染。
    • 浏览器向服务器发起请求
    • 服务器查询数据库,返回数据
    • 浏览器得到数据,进行页面构建
  • SSR:表示 服务端渲染
    • 览器向服务器发起请求
    • 服务器查询数据库,根据数据,生成 HTML ,并进行返回
    • 浏览器直接渲染 HTML

两种方式各有利弊,所以同构渲染,指的就是 把 CSR 和 SSR 进行合并。既可以单独 CSR ,也可以单独 SSR,同时还可以 结合两者,在首次渲染时,通过 SSR,在非首次渲染时,通过 CSR。

以下是三者的对比图

image-20230209121227934

而针对 vue 的服务端渲染来说,它是 将虚拟 DOM 渲染为 HTML 字符串,本质上是 解析的 vnode 对象,然后进行的 html 的字符串拼接

最后又讲解了客户端激活的原理,大致分为两步:

  1. 为页面中的 DOM 元素与虚拟节点对象之间建立联系
  2. 为页面中的 DOM 元素添加事件绑定

这两步主要是通过 renderer.hydrate() 方法进行实现了。

视频出处:【一小时读完《Vue.js 设计与实现》】 https://www.bilibili.com/video/BV1K24y1q7eJ/?share_source=copy_web&vd_source=a9f0fd4630ebe41da19ca2c83eb295e6

视频文档出处:https://juejin.cn/post/7197980894363156540

作者:LGD_Sunday


文章作者: QT-7274
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 QT-7274 !
赏
评论
  目录