本文正在参加「金石计划」
前言
大家好,我是前端贰货道士。最近比较忙,我们前端组沉迷于对外分销系统的开发中无法自拔。此前在整理面试不面试,你都必须得掌握的vue知识的过程中,由于超出掘金文字限制,对vue2
响应式原理的总结不够准确而且过于散乱。故新建一篇文章,来记录我对vue2
响应式原理的理解。两篇文章知识点存在差异之处,以这篇文章为准。
由于时间比较仓促,放入草稿箱的这篇文章初现雏形, 目前仅对vue2
响应式的核心原理做探讨。本文保质期时间较长,后续会抽空持续更新,有兴趣的小伙伴可以放入收藏夹吃灰。因为是自己的理解,所以难免会出现错误。如果大家发现了错误,或者有任何问题需要交流,欢迎
在评论区下留言。如果本篇文章对您有帮助,烦请大家一键三连哦, 蟹蟹大家~
a. 明目张胆引流:
金石计划3.0 第一篇
b. 省流(全剧终):
** 科学上网浏览器:github.com/Alvin9999/n…**
** chatGpt免费体验网站:poe.com/ChatGPT**
chatGpt
免费体验地址(相较于真实网址,少了停止继续生成答案的按钮)
Gpt4
免费体验地址(1天1条
)
1. vue2
与vue3
响应式原理的对比:
a. vue2
响应式原理分析
(1) 简单实现vue2
响应式原理(未引入订阅器Dep
,最后串在一起时会加上)
简单实现vue2
中对象的响应式,vue2
对数组的处理是另外一套逻辑,这点会在下一小节重点描述。
`1. vue2响应式原理简单实现:`
`依赖收集方法:`
function defineReactive(obj, key, value) {
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
console.log(`获取了${key}属性`)
return value
},
set(val) {
if (value === val) return
console.log(`${key}属性的值被设置为${val}`)
value = val
}
})
}
`递归响应式方法:`
function observer(obj) {
for (const [key, value] of Object.entries(obj)) {
defineReactive(obj, key, value)
if (typeof value === 'object' && value !== null) observer(value)
}
}
`2. 对于对象:`
const obj = { name: 'cxk', age: 18, info: { sex: 'male' } }
observer(obj)
obj.name // 获取了name属性 'cxk'
obj.name = 'cxk1' // name属性的值被设置为cxk1 'cxk1'
obj.sex = 'male' // male,此时并没有触发set方法
`如果我们对更新后的obj, 手动observe。此时再获取sex的值,是会触发set方法的`
`此时只会提示: 获取了name属性 获取了age属性。这是因为:我们开始支队obj的name和age属性挂载了get和set方法`
observer(obj) // 手动observer
obj.sex // 获取了sex属性 'male'
obj.sex = 'female' // sex属性的值被设置为female 'female'
obj.info `打印:获取了info属性,并显示对应的value`
obj.info.sex `打印:获取了info属性 获取了sex属性 并显示对应的value` // 此处触发了两次get方法
obj.info.hobby = 'rap' `此处只打印获取了info属性,触发了一次set方法,因为info是响应式而非新增的hobby属性`
obj.info.hobby `此处同样只打印获取了info属性,触发一次get方法,因为info是响应式而非新增的hobby属性`
`3. 对于数组:数组的key可以看做是索引,value可以看做是索引对应的值`
const arr = [1, 2, 3]
observe(arr)
arr[0] // 获取了0属性 1
arr[2] // 获取了2属性 3
arr[0] = 4 // 0属性的值被设置为4 4
arr[0] // 获取了0属性 4
arr[3] = 5 // 5 同样可以发现未触发set方法,用push方法也同样不会触发,因为新增了属性
delete arr[0] // true, 删除成功返回true,此时同样未触发set方法
arr[0] // undefined, arr[0]此时已不再是响应式
`
defineProperty监测数组下标变化的情况总结:
a. 对于存在的索引,通过索引访问或者设置对应元素的值时,可以触发getter和setter方法;
b. 通过原生数组的push或unshift方法会为数组增加索引。对于新增的索引,需要手动observe才能触发getter和setter方法;
c. 通过原生数组的pop或shift删除元素,会删除并更新索引,对于存在的索引,也可以触发getter和setter方法;
`
(2) 重点: vue2
对数组的响应式处理
a. vue2
对数组进行响应式监听的源码分析
`查询node_modules打包下的vue文件夹,找到vue下对应的package.json这个文件,module对应的包即为项目加载的文件`
`因此,源码来源于dist文件下的vue.runtime.esm.js这个文件`
var Observer = function Observer (value) {
this.value = value
this.dep = new Dep()
this.vmCount = 0
def(value, '__ob__', this)
if (Array.isArray(value)) {
if (hasProto) {
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
`对于数组的响应式监听,无论如何都会触发observeArray这个方法`
this.observeArray(value)
} else {
this.walk(value)
}
}
`对对象的每一项进行深度响应式监听`
Observer.prototype.walk = function walk (obj) {
var keys = Object.keys(obj)
for (var i = 0; i < keys.length; i++) {
defineReactive$$1(obj, keys[i])
}
}
Observer.prototype.observeArray = function observeArray (items) {
for (var i = 0, l = items.length; i < l; i++) {
`将key对应的value传入observe进行监听`
observe(items[i])
}
`
重点来了:
1. 如果在源码加上这一行代码,vue其实可以对数组中的每一项进行深度监听,这也是我们开始封装响应式原理方法的思路。
因为对于数组来说,key就是索引,value就是索引对应的值,defineProperty是可以监测到数组下标的变化的。
2. 但是vue为什么没有做这个操作呢?是vue没想到吗?个人理解是,vue从性能和体验角度的性价比考虑,放弃了这个特性。
因为使用我们之前定义的方法处理时,会为数组的每一项索引都添加一个getter和setter方法,如果我们请求后端接口,
后端返回的数据量巨大,且存在多级嵌套数组对象的复杂结构时,此时再将获取的值赋给定义在data中的响应式变量,
会严重影响性能和体验。
`
// this.walk(items)
}
function observe (value, asRootData) {
`重点来了:如果value不是对象,就直接return,不进行后续的响应式处理`
if (!isObject(value) || value instanceof VNode) {
return
}
var ob
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
`如果value上已经存在__ob__,且__ob__是Observe的实例,说明已经收集过了,直接返回实例`
ob = value.__ob__
} else if (
shouldObserve &&
!isServerRendering() &&
(Array.isArray(value) || isPlainObject(value)) &&
Object.isExtensible(value) &&
!value._isVue
) {
`如果value未收集过,则为value添加Observer实例`
ob = new Observer(value)
}
if (asRootData && ob) {
ob.vmCount++
}
return ob
}
var Observer = function Observer (value) {
`这个value就是最开始的value,即数组索引对应的值`
this.value = value
this.dep = new Dep()
this.vmCount = 0
`为value添加__ob__属性,并将Observer添加到__ob__属性上,只不过__ob__属性不可枚举`
def(value, '__ob__', this)
if (Array.isArray(value)) {
if (hasProto) {
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
`相当于递归处理多维数组,先处理整个数组,再逐一处理内层数组,最后回归到整个数组。`
`如果value还是数组,则将这个数组传到最初的方法中,并开始新一轮方法的执行:`
`1. 如果数组中的某一项值不为对象,则返回`
`2. 如果数组中的某一项值收集过,则返回收集的依赖`
`3. 如果数组中的某一项值未收集依赖,则收集依赖,并将收集的依赖挂载到数组这一项的__ob__属性上`
`a. 如果这一项的值还是数组,则重新把这一项值传到最初的方法中,进行递归调用`
`b. 如果这一项的值是对象,则对对象中的每一个属性进行响应式监听`
` 所以,vue对数组监听的出口是对数组中的对象的每一个key进行响应式监听`
this.observeArray(value)
} else {
`如果value是对象,则为value上的每一项key都进行深度响应式监听`
this.walk(value)
}
}
`def方法相当于为obj对象添加key属性,并赋值val。属性默认可写可配置,是否可枚举由传入的参数决定`
function def (obj, key, val, enumerable) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true
});
}
b.vue2
对会改变数组本身的7
个方法进行了重写, 让运用这些重写后的方法的数组具有响应式
分析:
splice
、push
和unshift
方法可能会增加数组的索引,vue
无法监听到新增的key
值;shift
和pop
方法会删掉数组中的部分数据,vue
无法监听到删除的key
值;reverse
和sort
方法会改变原数组,vue
无法准确追踪数据变化,导致视图不能正确更新;vue
无法监测到数组中某一项值的变化,需要借助splice
方法间接或者使用$set
这个api
直接实现。
`重写数组原型链上的方法,先将数组转换为响应式数组,触发watcher监听,并返回数组方法的执行结果`
var arrayProto = Array.prototype;
var arrayMethods = Object.create(arrayProto);
var methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
methodsToPatch.forEach(function (method) {
var original = arrayProto[method];
def(arrayMethods, method, function mutator () {
var args = [], len = arguments.length;
while ( len-- ) args[ len ] = arguments[ len ];
var result = original.apply(this, args);
var ob = this.__ob__;
var inserted;
switch (method) {
`对可能会导致增加数组索引的操作,找到新增的数据,并重新手动Observer触发响应式`
case 'push':
case 'unshift':
inserted = args;
break
`splice插入的数据是第三个参数,找到新增的数据, 并重新手动Observer`
case 'splice':
inserted = args.slice(2);
break
}
`因为inserted可能不存在,只有inserted有值才手动Observer`
if (inserted) { ob.observeArray(inserted); }
`重点:每次使用数组的响应式方法,都会触发watcher监听,更新数组的值,并在vue的下一个周期渲染dom。`
ob.dep.notify();
return result
});
});
c. vue2
触发响应式监听数据的所有情况
-
使用
$set
、$delete
这些api
方法,或者触发定义在data
响应式变量的getter
(比如给响应式对象重新分配地址)和setter
方法; -
对数组使用上述
7
种重写的响应式方法,会调用并通知watcher
进行监听;
(3) vue2
非响应式情况分析及案例说明
分析:
vue2
不能监测到对象或数组上新增的key
或索引
。因为在vue
初始化时,响应式对象或数组上存在的key
或索引
就已经深度添加了getter
和setter
方法。而新增的key
或索引
不存在对应的getter
和setter
,除非再次手动Observer
。解决方法如下:
- 针对对象:使用
this.$set(this.person, 'name', 'cxk')
实现响应式; - 针对数组:可以用两种方式实现响应式,第一种使用
this.$set(this.idList, 1 , 2)
,第二种使用this.idList.splice(1, 1, 2)
。
vue2
不能监测到对象或数组上删除的key
或索引
。 因为直接使用delete
进行操作,并未触发定义在响应式变量上的setter
方法。解决方法如下:
- 针对对象,使用
this.$delete(this.person, 'name')
; - 针对数组,使用
this.$delete(this.idList, 1)
。
a. 为对象新增key
值的案例:
- 假定一个
vue
文件的结构:
<template>
<div class="app-container">
{{ obj.name }}
{{ obj.age }}
<el-button type="primary" size="mini" @click="clickHandler">change</el-button>
</div>
</template>
<script>
export default {
data() {
return {
obj: {
name: 'cxk'
}
}
},
methods: {
clickHandler() {
this.obj.age = 20
}
}
}
</script>
- 如果我们使用
created
钩子函数给obj
添加age
字段
created() {
`由于在created生命周期,页面dom还没开始渲染。新增字段后,在mounted生命周期会渲染dom,所以初始化能显示age的值`
`此时如果点击按钮,虽然obj中的age发生了变化,但由于这种方法不是响应式,所以能获取但无法渲染最新的数据`
this.obj.age = 18
`用vue内置的api方法$set可以达到响应式的效果,在点击按钮后,能获取并渲染最新的数据`
this.$set(this.obj, 'age', 18)
}
- 如果我们使用
mounted
钩子函数给obj
添加age
字段
`注意与created钩子的区别`
mounted() {
`页面初始化就能获取但无法渲染age的值,这是因为mounted生命周期已经完成dom的渲染,此时再新增字段,由于不是响应式`
`所以无法在页面初始化时,通知页面重新实时渲染age的值,同样由于非响应式的特点,在点击按钮时也无法渲染最新的age值`
this.obj.age = 18
`而使用$set则可以达到响应式的效果`
this.$set(this.obj, 'age', 18)
}
b. 梅开七度--修改数组中某一项值的案例:
- 1. 数组中每一项都是基本数据类型
`这种情况不会触发响应式更新,vue面对数组中的value不为对象的情况下,会直接返回,不进行后续的响应式处理`
<template>
<div class="app-container">
{{ idList[1] }}
<el-button size="small" @click="clickHandler">change</el-button>
</div>
</template>
<script>
export default {
data() {
return {
idList: [0, 1, 2]
}
},
methods: {
clickHandler() {
this.idList[1] = 3
}
}
}
</script>
- 2. 数组中每一项都是引用数据类型,且
key
对应的value
都为基本数据类型
`这种情况会触发响应式更新,因为vue有针对数组中对象的key值进行监听`
<template>
<div class="app-container">
{{ personList[1].name }}
<el-button size="small" @click="clickHandler">change</el-button>
</div>
</template>
<script>
export default {
data() {
return {
personList: [
{ name: 'cxk1' },
{ name: 'cxk2' }
]
}
},
methods: {
clickHandler() {
this.personList[1].name = 'cxk3'
}
}
}
</script>
- 3. 直接为数组的某一项重新分配地址:
`这种情况不会触发响应式更新,vue虽然有针对数组中对象的key值进行监听,但是它并不会监听对象的最外层`
<template>
<div class="app-container">
{{ arr[1].name }}
<el-button size="small" @click="clickHandler">change</el-button>
</div>
</template>
<script>
export default {
data() {
return {
arr: [{ name: 'cxk1' }, { name: 'cxk2' }]
}
},
methods: {
clickHandler() {
this.arr[1] = { name: 'cxk3' }
}
}
}
</script>
- 4. 为数组添加新的
key
和value
:
`这种情况不会触发响应式更新,vue对数组的处理,出口是对对象每一项key的深度监听,无法监听不存在的key`
<template>
<div class="app-container">
{{ list }}
<el-button size="small" @click="clickHandler">change</el-button>
</div>
</template>
<script>
export default {
data() {
return {
list: [
{
id: 1,
name: 'cxk1',
size: ['S', 'M', 'L']
},
{
id: 2,
name: 'cxk2',
size: ['S', 'L']
}
]
}
},
methods: {
clickHandler() {
this.list[2] = 3
}
}
}
</script>
- 5. 为嵌套数组的
key
对应的value
重新分配地址
`这种情况会触发响应式更新,vue对数组的处理,出口是对对象每一项key的深度监听,监听到了size字段`
<template>
<div class="app-container">
{{ list }}
<el-button size="small" @click="clickHandler">change</el-button>
</div>
</template>
<script>
export default {
data() {
return {
list: [
{
id: 1,
name: 'cxk1',
size: ['S', 'M', 'L']
},
{
id: 2,
name: 'cxk2',
size: ['S', 'L']
}
]
}
},
methods: {
clickHandler() {
this.list[1].size = ['M', 'L']
}
}
}
</script>
- 6. 修改嵌套数组的某一个索引的值
`这种情况不会触发响应式更新,因为出口是对象`
<template>
<div class="app-container">
{{ list }}
<el-button size="small" @click="clickHandler">change</el-button>
</div>
</template>
<script>
export default {
data() {
return {
list: [
{
id: 1,
name: 'cxk1',
size: ['S', 'M', 'L']
},
{
id: 2,
name: 'cxk2',
size: ['S', 'L']
}
]
}
},
methods: {
clickHandler() {
this.list[1].size[0] = 'M'
}
}
}
</script>
- 7. 修改嵌套对象中某一项索引的值
`这种情况会触发响应式更新,因为出口是对象, 会对对象中的每一项key进行深度监听`
<template>
<div class="app-container system-home">
{{ list }}
<el-button size="small" @click="clickHandler">change</el-button>
</div>
</template>
<script>
export default {
data() {
return {
list: [
{
id: 1,
name: 'cxk1',
size: ['S', 'M', 'L']
},
{
id: 3,
name: 'cxk3',
skill: {
sing: true,
dance: true,
rap: true
}
}
]
}
},
methods: {
clickHandler() {
this.list[1].skill.sing = false
}
}
}
</script>
扩展:
`在源码打包文件vue.runtime.esm.js中,如果添加this.walk(items), 将会对数组的每一项索引进行深度监听。`
`则上述的案例都会响应式变化,它和我们之前定义的简单实现响应式原理的方法有异曲同工之妙`
`我们已经在上文讨论过,故不再赘述vue为什么没有采用这种方式`
Observer.prototype.observeArray = function observeArray (items) {
for (var i = 0, l = items.length; i < l; i++) {
`将key对应的value传入observe进行监听`
observe(items[i])
}
this.walk(items)
}
(4) 从源码角度考虑$set
的特殊情况
`在vue的原型对象上挂载了$set方法`
Vue.prototype.$set = set
function set (target, key, val) {
`判断传入的目标对象是否为undefined、null, 是否为js原始数据类型,包括布尔值、数字或字符串`
`判断当前环境是否位于生产环境,这段代码的意义主要是用来过滤,只针对传入的对象或者数组进行考量`
if (process.env.NODE_ENV !== 'production' &&
(isUndef(target) || isPrimitive(target))
) {
warn(("Cannot set reactive property on undefined, null, or primitive value: " + ((target))))
}
`判断是否传入的参数是否为数组,且是否为有效索引`
if (Array.isArray(target) && isValidArrayIndex(key)) {
`因为新增的key值可能大于数组的当前长度,所以要进行判断,防止后续的splice方法报错`
target.length = Math.max(target.length, key)
`splice是响应式方法,直接返回就好了`
target.splice(key, 1, val)
return val
}
`如果key已经存在于target对象上,且未挂载在Object.prototype上,则直接return`
`这样做的目的是为了避免重复收集`
if (key in target && !(key in Object.prototype)) {
target[key] = val
return val
}
`此处是对象的处理,获取target上的Observe对象`
var ob = (target).__ob__
`避免在vue实例或者$data根数据对象上使用$set`
if (target._isVue || (ob && ob.vmCount)) {
process.env.NODE_ENV !== 'production' && warn(
'Avoid adding reactive properties to a Vue instance or its root $data ' +
'at runtime - declare it upfront in the data option.'
)
return val
}
`如果target上的Observe对象不存在,则说明target不是响应式的,则直接为target添加key和value并返回`
if (!ob) {
target[key] = val
return val
}
`如果是对象,且是响应式,则添加key和value并定义响应式`
defineReactive$$1(ob.value, key, val)
ob.dep.notify()
return val
}
- 考虑
$set
的一种特殊情况:为响应式对象上已存在的非响应key
进行$set
<template>
<div class="app-container">
<div class="mb10">{{ obj.sex }}</div>
<el-button size="small" type="primary" @click="changeHandler">change</el-button>
</div>
</template>
<script>
export default {
data() {
return {
obj: {
name: 'cxk'
}
}
},
mounted() {
`直接给对象增加sex属性是非响应式的`
this.obj.sex = 'male'
},
methods: {
changeHandler() {
`$set无法对对象上已经存在的key做响应式处理`
this.$set(this.obj, 'sex', 'female')
`此时obj虽然发生了变化,但无法在dom上响应式变化`
`但是,如果我们在mounted阶段,去掉给对象增加sex属性的操作,再用$set增加字段,是可以达到响应式效果的`
`为什么vue要做判断对象上是否已存在key值的判断呢?这是为了提高vue性能所作出的举措`
`只要这个key已经存在在对象上,则默认vue已经收集过了,已经双向绑定过了,无需再次收集`
}
}
}
</script>
- 数组中
$set
索引断层的情况考量:
`对于数组,$set是先考虑数组的长度,再通过重写的splice方法进行响应式处理,因此可以监听到中断索引的响应式变化`
`页面是可以监听到数组中第三项和第四项的响应式变化的:`
<template>
<div class="app-container">
{{ list }}
<el-button size="small" @click="clickHandler">change</el-button>
</div>
</template>
<script>
export default {
data() {
return {
list: [1, 2]
}
},
methods: {
clickHandler() {
this.$set(this.list, 3, 4)
this.list[2] = 3
}
}
}
</script>
(5) 从源码角度考虑$delete
`注意:因为删除后,有重新响应式的操作。所以对象上删掉的key,后续是无法进行响应式监听的,除非使用this.$set重新挂载`
`在vue的原型对象上挂载了$delete方法`
Vue.prototype.$delete = del;
`1. 和$set的判断差不多,只针对传入的对象或者数组进行处理`
`2. 如果目标是数组,且传入的是有效的索引,则直接调用重写的splice方法实现响应式`
`3. 如果目标是对象,且为根数据对象,则弹出警告并返回`
`4. 如果目标是对象,且本身就不存在这个key值,则直接返回`
`5. 拿到对象上的Observer对象,如果目标是响应式对象,则删除对应的value,并进行notify通知,实现响应式`
`6. 如果目标不是响应式对象,则只删除对应的value并返回`
function del (target, key) {
if (process.env.NODE_ENV !== 'production' &&
(isUndef(target) || isPrimitive(target))
) {
warn(("Cannot delete reactive property on undefined, null, or primitive value: " + ((target))));
}
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.splice(key, 1);
return
}
var ob = (target).__ob__;
if (target._isVue || (ob && ob.vmCount)) {
process.env.NODE_ENV !== 'production' && warn(
'Avoid deleting properties on a Vue instance or its root $data ' +
'- just set it to null.'
);
return
}
if (!hasOwn(target, key)) {
return
}
delete target[key];
if (!ob) {
return
}
ob.dep.notify();
}
(6) vue2
响应式的缺点分析:
1. vue2
初始化时的递归遍历会造成性能损失;
2. 新增或删除属性需要使用$set
或者$delete
这些vue
内置的api
才能达到响应式效果;
3. 响应式效果不适用于es6
新增的Map
、Set
这些数据结构;
b. vue3
响应式原理分析
`使用递归方法封装简单实现下vue3的响应式原理:`
`考虑到对象中可能嵌套对象属性的情况,获取值的时候,如果值是对象类型,则继续获取,直到它不再为对象为止`
`所以递归的出口为不是对象类型,且不为null,并返回对应的值`
function observer(target) {
`递归的出口`
if (typeof target !== 'object' || target == null) return target
`配置代理`
const proxy = {
get(target, key, receiver) {
`Reflect.get中的第一个参数为源对象,第二个参数为源对象的key,第三个参数可以省略,为当前this的指向`
`在这个方法中,receiver参数代表代理后的对象,其实源对象和代理后的对象指向是一样的,那为什么不省略呢`
`这是因为,源对象可能也是另一个代理的代理对象,为了避免污染,就将this指向到代理后的对象,及receiver上`
const result = Reflect.get(target, key, receiver)
console.log(`获取了${key}属性`)
`深度代理,递归循环,直到value不再为对象类型且不为null就返回`
return observer(result)
},
set(target, key, val, receiver) {
if (val === target[key]) return
console.log(`设置了${key}属性,值为${val}`)
return Reflect.set(target, key, val, receiver)
},
deleteProperty(target, key) {
console.log('delete property', key)
return Reflect.deleteProperty(target, key)
}
}
// 生成代理对象
return new Proxy(target, proxy)
}
可以发现,使用proxy
作为响应式原理的实现方法,可以监测到新增的属性,这是defineProperty
所不具备的特性。与此同时,我们还可以在代理中对传入的数据做一系列譬如删除之类的拦截操作
。
2.vue2.x
底层响应式原理解析(从控制台打印中get
响应式原理)
a. 对于MVVM
模式的理解
总结: 在MVVM
框架下视图和模型是不能直接通信的,但是它们可以借助ViewModel
来通信, 而MVVM
中的View
和ViewModel
却可以互相通信。ViewModel
要实现一个Observer
观察者,当数据发生变化,ViewModel
能够监听到数据的这种变化,然后通知到对应的视图做自动更新;而当用户操作视图,ViewModel
也能监听到视图的变化,然后通知数据做改动,这实际上就实现了数据的双向绑定。
b. 从打印截图上体会vue2
的响应式原理
c. 关于$watch
的探讨
d. 三种watch
底层原理的比较
e. 响应式原理从头到尾的运作串烧
结语
原文链接:https://juejin.cn/post/7223055227465089082 作者:前端贰货道士