前几章完整的介绍了 Vue3 的响应式核心reactive
和effect
的实现原理,这一章我们来看看Vue3
的依赖收集和依赖触发是如何工作的。
根据之前的分析,我们知道依赖收集是在reactive
中的get
钩子中完成的(不是所有),而依赖触发是在set
钩子中完成的(也不是所有),依赖指的是effect
。
这里的特点是get
是在取值,set
是在赋值,那么Vue3
是如何正确的收集依赖和触发依赖的呢?接下来我们来一起看看。
依赖收集
在讲解之前的章节的时候,我们知道reactive
中的get
钩子中会调用track
方法,我特意的避开了这一块的内容,讲的很浅,因为讲解这个方法的时候,我们需要先了解Vue3
中的effect
是如何工作的;
而现在已经讲过了effect
的实现原理,我们来看看track
方法是如何工作的,会很方便的理解Vue3
中的依赖收集,我们还是来回一下get
钩子的代码:
function createGetter(isReadonly = false, shallow = false) {
return function get(target, key, receiver) {
// 判断是否是数组
const targetIsArray = isArray(target);
// 对数组原型上的方法进行特别对待
if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
return Reflect.get(arrayInstrumentations, key, receiver);
}
// 获取结果
const res = Reflect.get(target, key, receiver);
// 收集依赖
track(target, "get" /* TrackOpTypes.GET */, key);
// 返回结果
return res;
};
}
上述代码解释来自:【源码&库】 Vue3 的依赖收集,这里的依赖指代的是什么?
如果想看详细的具体源码解析可以看这一章:【源码&库】Vue3 的响应式核心 reactive 和 effect 实现原理以及源码分析
可以看到get
钩子中会在获取到值之后,调用track
方法,其中会传入三个参数,我们来看看track
方法的实现:
// 依赖映射表,用来存储对象的依赖
const targetMap = new WeakMap();
/**
*
* @param target 目标对象,指向的是当前操作的对象
* @param type 操作类型,有 get/has/iterate 三种,会面会讲到区别
* @param key 操作的 key,指向的是当前操作对象的属性名
*/
function track(target, type, key) {
// shouldTrack 和 activeEffect 在之前的章节中讲到过
// shouldTrack 用来判断当前是否需要收集依赖
// activeEffect 指向的是当前正在执行的 effect,收集依赖收集的就是它
// 如果 shouldTrack 为 false 或者 activeEffect 为 null,说明不需要收集依赖
if (shouldTrack && activeEffect) {
// 获取当前对象的依赖映射表,如果没有则创建一个
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
// 获取当前对象的依赖集合,如果没有则创建一个
let dep = depsMap.get(key);
if (!dep) {
depsMap.set(key, (dep = createDep()));
}
// 创建一个依赖收集的事件信息,只有在开发环境下才会用到
const eventInfo = {
effect: activeEffect,
target,
type,
key
}
// 收集依赖
trackEffects(dep, eventInfo);
}
}
这里的依赖收集的逻辑看着比较简单,但是实际会涉及到很多的细节,我们来一一分析:
首先是shouldTrack
和activeEffect
,这两个变量在effect
中都出现过,但是activeEffect
是在effect
中赋值的,shouldTrack
在effect
也有赋值操作,但是值并不是由effect
直接控制的。
activeEffect
在之前的文章中反复的提到过了,这里就不再赘述,我们来看看shouldTrack
是如何工作的;
shouldTrack
这个变量是用来判断当前是否需要收集依赖的,全局搜索一下它的赋值操作,可以找到如下代码:
// 当前执行的 effect 是否需要收集依赖
let shouldTrack = true;
// 调用链栈,用来处理嵌套的 effect 调用情况
// 记录这个链上的 effect 是否需要收集依赖
const trackStack = [];
// 暂停依赖收集
function pauseTracking() {
// 将当前的 shouldTrack 值压入栈中
trackStack.push(shouldTrack);
// 将 shouldTrack 设置为 false,表示暂停收集依赖
shouldTrack = false;
}
// 恢复依赖收集,没有找打具体的用法,暂时不关心
function enableTracking() {
trackStack.push(shouldTrack);
shouldTrack = true;
}
// 重置依赖收集
function resetTracking() {
// 弹出栈顶的 shouldTrack 值,表示当前的 shouldTrack 值已经失效,需要恢复上一个 shouldTrack 值
const last = trackStack.pop();
// 如果 last 为 undefined,说明栈中没有 shouldTrack 值,这时候将 shouldTrack 设置为 true
shouldTrack = last === undefined ? true : last;
}
这一块具体的使用场景在上一章已经讲过了,是在数组的push
、pop
、shift
、unshift
、splice
拦截器中使用的,可以去看:代理 Object | get 钩子
这里我们只需要知道,shouldTrack
是用来判断当前是否需要收集依赖的,如果shouldTrack
为false
,则不会收集依赖,如果shouldTrack
为true
,则会收集依赖。
依赖映射表 targetMap
首先我们来看看targetMap
,这个变量是用来存储对象的依赖的,它是一个WeakMap
,WeakMap
是弱引用的,具体的数据结构如下:
{
[target]: {
[key]: [new Set([effect1, effect2])]
}
}
这里的target
指向的是当前操作的对象,key
指向的是当前操作对象的属性名,key
对应的值是一个Set
,Set
中存储的是当前对象属性的所有依赖,也就是effect
。
这里说的可能不是很好理解,我们来模拟一下:
// 依赖映射表
const targetMap = new WeakMap();
const track = (target, type, key) => {
const depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
// 这里的 key 就是 name,也就是属性名
let dep = depsMap.get(key);
if (!dep) {
// 这里的 dep 就是 Set,也就是依赖集合
depsMap.set(key, (dep = new Set()));
}
// 添加到依赖集合中,这里的 activeEffect 就是当前正在执行的 effect
dep.add(activeEffect);
}
// 创建一个对象,用来模拟 target
const target = {
name: '田八',
age: 18
};
// 这里有两个 effect,用来模拟一个属性用在的两个地方
function effect1(fn) {
console.log(target.name);
}
function effect2(fn) {
console.log(target.name);
console.log(target.age);
}
let activeEffect = null;
// 只有 effect 在执行的时候才会收集依赖
// 实际情况是执行的过程中就会收集依赖,我们这里手动模拟
effect1();
activeEffect = effect1;
// 收集依赖
track(target, 'get', 'name');
activeEffect = null;
// 收集第二个依赖,流程同上
effect2();
activeEffect = effect2;
// 收集依赖
track(target, 'get', 'name');
// 这个会多一个 age 的依赖
track(target, 'get', 'age');
activeEffect = null;
经过上述的流程,最后targetMap
的数据结构如下:
{
[target]: {
name: [effect1, effect2]
age: [effect2]
}
}
依赖收集的逻辑就是这样,只是做收集,真正的执行逻辑是在trigger
方法中,这个后面会讲到。
trackEffects
上面已经了解到了shouldTrack
和targetMap
,这里我们来看看trackEffects
,这个方法是用来收集依赖的,它的具体实现如下:
// maxMarkerBits 用来记录最大的依赖收集的深度
const maxMarkerBits = 30;
// effectTrackDepth 用来记录当前的依赖收集的深度
let effectTrackDepth = 0;
// trackOpBit 用来记录当前的依赖收集的深度,这个是配合 maxMarkerBits 使用的
let trackOpBit = 1
function trackEffects(dep, debuggerEventExtraInfo) {
// shouldTrack 含义同上,但是不是全局的,而是局部的
let shouldTrack = false;
// 这一步是为了解决递归调用的问题
if (effectTrackDepth <= maxMarkerBits) {
// newTracked 用来判断当前的依赖是否新的依赖追踪
if (!newTracked(dep)) {
// 设置新的依赖追踪
dep.n |= trackOpBit; // set newly tracked
// wasTracked 用来判断当前的依赖是否已经被追踪过了
shouldTrack = !wasTracked(dep);
}
}
else {
// Full cleanup mode.
// 已经到了最大的依赖收集深度,这里就不再对相同的依赖进行收集了
shouldTrack = !dep.has(activeEffect);
}
// 如果 shouldTrack 为 true,说明当前的依赖是新的依赖,需要收集
if (shouldTrack) {
// 记录当前的依赖
dep.add(activeEffect);
// activeEffect.deps 记录的维度不同,稍后会讲到
activeEffect.deps.push(dep);
// 开发环境下才会用到
if (activeEffect.onTrack) {
activeEffect.onTrack(Object.assign({ effect: activeEffect }, debuggerEventExtraInfo));
}
}
}
这里设计很妙,shouldTrack
没啥好介绍的,重要的是shouldTrack
的值是如何确定的,这里有两种情况:
effectTrackDepth
小于maxMarkerBits
,这种情况代表当前的依赖收集的深度还没有达到最大值,这里的深度指的就是递归调用的深度,也可以是effect
的嵌套调用的深度,但是通常没人会手写嵌套调用到溢出;effectTrackDepth
大于maxMarkerBits
,这种情况代表当前的依赖收集的深度已经达到最大值,就判断当前的依赖是否已经被收集过了,这里的相同依赖指的就是相同的effect
。
在赖收集的深度还没有到达最大值的情况下,会有两个方法来判断当前的依赖是否是新的依赖,newTracked
和wasTracked
的实现如下:
function newTracked(dep) {
return (dep.n & trackOpBit) === 0;
}
function wasTracked(dep) {
return (dep.n & trackOpBit) > 0;
}
这里使用的是位运算,trackOpBit
的值是由effectTrackDepth
确定的,在effect
执行的过程中,effectTrackDepth
会进行自增,当effect
执行完成之后,effectTrackDepth
会进行自减;
下面就是trackOpBit
值的确定过程,在effect
的run
方法中,会有如下代码:
function run() {
// ...
try {
// ...
// 在执行 effect 中会对 trackOpBit 进行赋值
// 1 << ++effectTrackDepth 是位移运算,是将 1 左移 effectTrackDepth 位
// 这里可以将 1 理解为二进制数据,左移一位就是在二进制数据的最后面添加一个 0
// 例如:
// 1 << 0 = 1 = 0001
// 1 << 1 = 2 = 0010
// 1 << 2 = 4 = 0100
// 1 << 10 = 1024 = 10000000000
// 可自行在控制台进行验证,这里 effectTrackDepth 最大值是 30,由 maxMarkerBits 决定
trackOpBit = 1 << ++effectTrackDepth;
// 控制深度
if (effectTrackDepth <= maxMarkerBits) {
initDepMarkers(this);
} else {
cleanupEffect(this);
}
// 执行回调
return this.fn();
} finally {
// 执行完毕确定当前的依赖收集的深度标记
if (effectTrackDepth <= maxMarkerBits) {
finalizeDepMarkers(this);
}
// effectTrackDepth 自减,空出位置
trackOpBit = 1 << --effectTrackDepth;
// ...
}
}
trackOpBit
的值最终会映射到dep.n
和dep.w
上,这两个值在上面已经出现过了,现在来详细扒一扒,在上面的代码中,可以看到有两个方法initDepMarkers
和finalizeDepMarkers
,这两个方法的实现如下:
const initDepMarkers = ({ deps }) => {
if (deps.length) {
for (let i = 0; i < deps.length; i++) {
deps[i].w |= trackOpBit; // set was tracked
}
}
};
const finalizeDepMarkers = (effect) => {
const { deps } = effect;
if (deps.length) {
let ptr = 0;
for (let i = 0; i < deps.length; i++) {
const dep = deps[i];
if (wasTracked(dep) && !newTracked(dep)) {
dep.delete(effect);
}
else {
deps[ptr++] = dep;
}
// clear bits
dep.w &= ~trackOpBit;
dep.n &= ~trackOpBit;
}
deps.length = ptr;
}
};
这个东西到底有啥用呢?可以看到的是最终trackOpBit
的值会被赋值到dep.w
和dep.n
上,dep.w
的作用是用来记录当前的依赖是否被收集过的,dep.n
的作用是用来记录当前的依赖收集的深度的;
到这里其实整个流程已经串起来了,画个图来说明一下:
graph TB
A[依赖收集] -->|得到effect| B[effect]
C[依赖触发] -->|执行effect| B[effect]
B[effect] -->|执行:effectTrackDepth深度加深| D[effectTrackDepth]
D[++effectTrackDepth] -->|得到trackOpBit| E[trackOpBit]
E[trackOpBit] -->|赋值给dep.w| F[dep.w]
F -->|执行回调| G[回调:也是更新状态]
G -->|如果回调中有effect,这个时候层级会一直加深| B[effect]
G -->|执行完毕,确定最终的层级| H[finalizeDepMarkers]
H -->|当前effect执行完毕,层级变浅| I[--effectTrackDepth]
这个图忽略很多细节,但是可以看到整个流程是怎么走的,到这里我们回顾一下整个流程:
- 我们定义一个响应式对象,这个响应式对象会拦截你对属性的操作
- 当我们在
effect
中访问响应式对象的属性时,会触发get
方法,这个时候会进行依赖收集 effect
在执行的过程中,首先会将全局的activeEffect
设置为当前的effect
,effect
就是依赖收集中的依赖effect
在执行的过程中,会标记嵌套深度,这个可以防止effect
的嵌套过深,导致内存溢出effect
在执行的过程中,会将trackOpBit
的值赋值到dep.w
和dep.n
上,这个值会记录当前的依赖是否被收集过,以及当前的依赖收集的深度effect
最后会执行用户传入的回调,这个回调就是我们在effect
中传入的函数effect
执行完毕,会将effectTrackDepth
的值自减,相当于减少层级的深度- 当我们修改响应式对象的属性时,会触发
set
方法,这个时候会执行effect
- 这个时候就会回到第3步
自此整个流程就串起来了,我们已经知道了依赖是如何收集的,依赖收集的细节就是响应式对象的访问一定要在effect
中,这样才能收集到依赖;
如果不在effect
中访问响应式对象的属性,那么在track
过程中是不存在activeEffect
的,所以就不会进行依赖收集;
依赖触发
上面已经完整的了解到了依赖收集的流程,那么依赖触发的流程是怎么样的呢?我们先来看一下trigger
方法的实现:
function trigger(target, type, key, newValue, oldValue, oldTarget) {
const depsMap = targetMap.get(target);
if (!depsMap) {
// never been tracked
return;
}
let deps = [];
if (type === "clear" /* TriggerOpTypes.CLEAR */) {
// collection being cleared
// trigger all effects for target
deps = [...depsMap.values()];
}
else if (key === 'length' && isArray(target)) {
const newLength = Number(newValue);
depsMap.forEach((dep, key) => {
if (key === 'length' || key >= newLength) {
deps.push(dep);
}
});
}
else {
// schedule runs for SET | ADD | DELETE
if (key !== void 0) {
deps.push(depsMap.get(key));
}
// also run for iteration key on ADD | DELETE | Map.SET
switch (type) {
case "add" /* TriggerOpTypes.ADD */:
if (!isArray(target)) {
deps.push(depsMap.get(ITERATE_KEY));
if (isMap(target)) {
deps.push(depsMap.get(MAP_KEY_ITERATE_KEY));
}
}
else if (isIntegerKey(key)) {
// new index added to array -> length changes
deps.push(depsMap.get('length'));
}
break;
case "delete" /* TriggerOpTypes.DELETE */:
if (!isArray(target)) {
deps.push(depsMap.get(ITERATE_KEY));
if (isMap(target)) {
deps.push(depsMap.get(MAP_KEY_ITERATE_KEY));
}
}
break;
case "set" /* TriggerOpTypes.SET */:
if (isMap(target)) {
deps.push(depsMap.get(ITERATE_KEY));
}
break;
}
}
const eventInfo = (process.env.NODE_ENV !== 'production')
? { target, type, key, newValue, oldValue, oldTarget }
: undefined;
if (deps.length === 1) {
if (deps[0]) {
if ((process.env.NODE_ENV !== 'production')) {
triggerEffects(deps[0], eventInfo);
}
else {
triggerEffects(deps[0]);
}
}
}
else {
const effects = [];
for (const dep of deps) {
if (dep) {
effects.push(...dep);
}
}
if ((process.env.NODE_ENV !== 'production')) {
triggerEffects(createDep(effects), eventInfo);
}
else {
triggerEffects(createDep(effects));
}
}
}
代码量比较多,拆解来看,先看参数:
/**
* @param target 当前响应式对象
* @param type 触发类型
* @param key 属性名
* @param newValue 新值
* @param oldValue 旧值
* @param oldTarget 旧的响应式对象
*/
function trigger(target, type, key, newValue, oldValue, oldTarget) {
}
这些参数的大概作用就是用来确定要执行哪些effect
,接着往下看:
// 省略 trigger 的定义
// 获取当前响应式对象的依赖
const depsMap = targetMap.get(target);
if (!depsMap) {
// never been tracked
return;
}
targetMap
在上面已经介绍过了,targetMap
会将当前响应式对象作为key
,依赖作为value
存储起来,所以这里通过target
就可以获取到当前响应式对象的依赖;
接着往下看:
// 最终要执行的 effect 都会存储在 deps 中
let deps = [];
if (type === "clear" /* TriggerOpTypes.CLEAR */) {
// ...
} else if (key === 'length' && isArray(target)) {
// ...
} else {
// ...
}
deps
是一个数组,最终要执行的effect
都会存储在deps
中,这里会根据type
和key
的不同,将要执行的effect
存储到deps
中;
tpye
是操作类型,key
是当前操作的属性名,这里可以看到开始会有两个判断,一个是type='clear'
,一个是key='length'
;
这两个暂时先不管,先看else
中的代码:
// 如果 key 不为 undefined,将 key 对应的依赖存储到 deps 中
if (key !== void 0) {
deps.push(depsMap.get(key));
}
这一步就将key
对应的依赖存储到deps
中,例如:
const obj = reactive({
name: '田八',
age: 18
})
effect(() => {
console.log(obj.name)
})
effect(() => {
console.log(obj.name)
})
这里name
有两个effect
依赖,所以到这一步的时候,deps
中就会有两个effect
;
接着往下看:
// 根据 type 的不同,将对应的依赖存储到 deps 中
switch (type) {
// 如果 type 为 add
case "add" /* TriggerOpTypes.ADD */:
// 如果 target 不是数组
if (!isArray(target)) {
// 将 ITERATE_KEY 对应的依赖存储到 deps 中
deps.push(depsMap.get(ITERATE_KEY));
// 如果 target 是 Map
if (isMap(target)) {
// 将 MAP_KEY_ITERATE_KEY 对应的依赖存储到 deps 中
deps.push(depsMap.get(MAP_KEY_ITERATE_KEY));
}
}
// 如果 key 是数字
else if (isIntegerKey(key)) {
// new index added to array -> length changes
// 将 length 对应的依赖存储到 deps 中
deps.push(depsMap.get('length'));
}
break;
// 如果 type 为 delete
case "delete" /* TriggerOpTypes.DELETE */:
// 如果 target 不是数组
if (!isArray(target)) {
// 将 ITERATE_KEY 对应的依赖存储到 deps 中
deps.push(depsMap.get(ITERATE_KEY));
// 如果 target 是 Map
if (isMap(target)) {
// 将 MAP_KEY_ITERATE_KEY 对应的依赖存储到 deps 中
deps.push(depsMap.get(MAP_KEY_ITERATE_KEY));
}
}
break;
// 如果 type 为 set
case "set" /* TriggerOpTypes.SET */:
// 如果 target 是 Map
if (isMap(target)) {
// 将 MAP_KEY_ITERATE_KEY 对应的依赖存储到 deps 中
deps.push(depsMap.get(ITERATE_KEY));
}
break;
}
这里的信息量就比较大了,这里会根据type
的不同,将对应的依赖存储到deps
中;
这里的type
有三种,分别是add
、delete
、set
;
这里的add
和delete
是对数组和Map
的操作,set
是对Map
的操作;
这里的add
和delete
都会将ITERATE_KEY
对应的依赖存储到deps
中,如果是Map
,还会将MAP_KEY_ITERATE_KEY
对应的依赖存储到deps
中;
ITERATE_KEY
和MAP_KEY_ITERATE_KEY
见名知意,就是对迭代器的依赖;
思考一个问题,如果对数组执行push
或者其他修改数组长度的操作,那么如何触发类似于forEarch
、map
等迭代器的依赖呢?
这里就是通过ITERATE_KEY
和MAP_KEY_ITERATE_KEY
来实现的,这里会将ITERATE_KEY
和MAP_KEY_ITERATE_KEY
对应的依赖存储到deps
中,这样就可以触发迭代器的依赖了;
至于ITERATE_KEY
和MAP_KEY_ITERATE_KEY
是什么时候加入到depsMap
中的,这里在之前的章节中都有出现,但是没有深入讲解,这里建议大家可以回顾一下之前的章节,并且自己也跟着在源码中找一下,这样才能更好的理解;
接着往下看:
// 删除开发环境下才会执行的代码
// 如果 deps 中有依赖,并且只有一个
if (deps.length === 1) {
if (deps[0]) {
// 直接调用 triggerEffects 触发依赖
triggerEffects(deps[0]);
}
} else {
// 如果 deps 中有多个依赖,将多个依赖合并到一个数组中
const effects = [];
for (const dep of deps) {
if (dep) {
effects.push(...dep);
}
}
// 调用 triggerEffects 触发依赖,会有深度检测
triggerEffects(createDep(effects));
}
这里的逻辑就比较简单了,如果deps
中只有一个依赖,那么直接调用triggerEffects
触发依赖;
如果deps
中有多个依赖,那么将多个依赖合并到一个数组中,然后调用triggerEffects
触发依赖,createDep
就是为当前的依赖添加w
和n
属性,这个就是上面讲到过的;
然后就是triggerEffects
了,这个函数就是用来触发依赖的:
function triggerEffects(dep, debuggerEventExtraInfo) {
// spread into array for stabilization
// 将 dep 转换为数组
const effects = isArray(dep) ? dep : [...dep];
// 先执行 computed 依赖
for (const effect of effects) {
if (effect.computed) {
triggerEffect(effect, debuggerEventExtraInfo);
}
}
// 再执行非 computed 依赖
for (const effect of effects) {
if (!effect.computed) {
triggerEffect(effect, debuggerEventExtraInfo);
}
}
}
这里没有什么特别的,就是先执行computed
依赖,再执行非computed
依赖,主要是为了保证computed
依赖的执行顺序;
先执行computed
依赖的目的是为了防止后面非computed
依赖的执行,可能会修改computed
依赖的值,这样就会导致computed
依赖的值不正确;
再来看triggerEffect
:
// 删除开发环境下才会执行的代码
function triggerEffect(effect, debuggerEventExtraInfo) {
// 如果 effect 不是 activeEffect 或者 effect 允许递归
if (effect !== activeEffect || effect.allowRecurse) {
// 如果 effect 有调度器,就执行调度器
if (effect.scheduler) {
effect.scheduler();
}
// 否则就直接执行
else {
effect.run();
}
}
}
这里就非常好理解了,最开头的判断是为了防止死循环,如果effect
是activeEffect
,那么就不会执行effect
;
最终执行effect
的逻辑就是判断effect
是否有scheduler
,如果有就执行scheduler
,否则就直接执行effect
,调度器这一块在之前的章节中也有讲到;
总结
到此为止,我们总算将Vue3
的响应式原理讲完了,响应式核心我总共分了 4 篇文章来讲解,这里我再简单的总结一下:
第一篇Vue3 的响应式核心 reactive 和 effect 实现原理以及源码分析:介绍了Vue3
的响应式原理,以及Vue3
的响应式原理是如何实现的,这一篇是整体将响应式核心过了一遍,但是并不深入;
第二篇跟着 Vue3 的源码学习 reactive 背后的实现原理:详细的介绍了Vue3
的响应式拦截是如何实现的,主要介绍Vue3
是如何对可观察对象进行拦截的,例如Object
、Array
、Map
、Set
等;
第三篇Vue3 的依赖收集,这里的依赖指代的是什么?:主要介绍了effect
的调度器是如何使用的,已经effect
一些杂项的东西;
然后就是本篇,在之前几篇的铺垫下,这篇主要是将之前的内容串在一起,这样就构成了一个完整的响应式核心的流程;
响应式原理分开来看就是我写的第二篇和第三篇,响应式首先是响应式拦截,这一块就是reactive
的核心,然后是依赖收集,这一块就是effect
的核心;
最后将这两个核心串在一起,就是响应式核心,这样就构成了一个完整的响应式核心的流程;
宣传
大家好,这里是田八的【源码&库】系列,
Vue3
的源码阅读计划,Vue3
的源码阅读计划不出意外每周一更,欢迎大家关注。如果想一起交流的话,可以点击这里一起共同交流成长
系列章节:
本文正在参加「金石计划」
原文链接:https://juejin.cn/post/7219229724957982776 作者:田八