vue3 learning source code notes (newbie introductory series) ------ Key points! Line-by-line analysis of responsive principle code

Remark

This article will only cover the update of the main process of setup, watch computed, etc. will be analyzed later
. With questions, go to the source code to find answers.
When was reactive data created?
When is dependency collection performed?
How to distribute updates after responsive data update?

The test cases used in this article
must be debugged. Otherwise, it will be easy to get confused if you follow the debugging.

 it('should support runtime template compilation', async () => {
    
    
    const container = document.createElement('div')
    container.classList.add('app')
    const child = defineComponent({
    
    
      template: `
         <div><p>{
     
     {age}}---{
     
     {status?add:'hihi'}}</p></div>
      `,
      props:{
    
    
        age:{
    
    
          type: Number,
          default:20
        }
      },
      data(){
    
    
        return {
    
    
          add: '12',
          status: true
        }
      },
      mounted() {
    
    
          this.status = false
          this.add = '24'
      },
  })

    const App = {
    
    
      components:{
    
    child},
      beforeMount() {
    
    
        console.log('beforeMount');
        
      },
      data() {
    
    
        return {
    
    
        }
      },
      setup() {
    
    
        const count = ref(1)

        const age = ref('20')

        const obj  = reactive({
    
    name:'ws',address:'usa'})

        onMounted(()=>{
    
    
          obj.name = 'kd'
          count.value = 5
          age.value = '2'
        })

 
        return ()=>{
    
    
          return  h('div',[obj.name,h(child,{
    
    age:age.value})])
        }
      }
    }

  
    createApp(App).mount(container)
    await nextTick()
   
     expect(container.innerHTML).toBe(`0`)
  })

Responsive data creation

Do you still remember at which stage the initialization setup is executed in the previous article?

patch
processComponent
mountComponent
 // packages/runtime-dom/src/renderer.ts
  patch 阶段 组件首次挂载时
// mountComponent 方法
 
1. 先创建 组件 instance 实例
2. 初始化 setup props 等属性
3. 设置并运行带副作用的渲染函数

When the setup is initialized, responsive data will be created.
The setup function in the App component will be executed first in the test case.

 setup() {
    
    
        // 会创建一个 ref 响应式数据
        const count = ref(1)
        // 会创建一个 ref 响应式数据
        const age = ref('20')
        // 会创建一个 reactive 响应式数据
        const obj  = reactive({
    
    name:'ws',address:'usa'})

        onMounted(()=>{
    
    
          obj.name = 'kd'
          count.value = 5
          age.value = '2'
        })

 
        return ()=>{
    
    
          return  h('div',[obj.name,h(child,{
    
    age:age.value})])
        }
      }
    }

The core role of ref and reactive

Let’s talk about the conclusion first:
it is the bridge for data-driven view updates. Dependency collection (getter) and dispatch updates (setter) are both in it

There is not much difference between ref and reactive (proxy cannot act as a proxy for basic data types, so vue3 itself uses the get set in the class class to do the proxy work. The subsequent dependency collection and distribution update principles are basically the same as reactive). Only reactive will be analyzed below.

  1. First determine the type of proxy object and classify it
function targetTypeMap(rawType) {
    
    
  switch (rawType) {
    
    
    case 'Object':
    case 'Array':
      return TargetType.COMMON
    case 'Map':
    case 'Set':
    case 'WeakMap':
    case 'WeakSet':
      return TargetType.COLLECTION
    default:
      return TargetType.INVALID
  }
}
  1. Choose different getter setter methods according to different categories.
    Only analyze the most common Object Array proxies.
export function reactive(target: object) {
    
    
  // if trying to observe a readonly proxy, return the readonly version.
  if (isReadonly(target)) {
    
    
    return target
  }
  return createReactiveObject(
    target,
    false,
    mutableHandlers,
    mutableCollectionHandlers,
    reactiveMap
  )
}
。。。。

function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>
) {
    
    
  // 省略。。。
  const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
  )
  proxyMap.set(target, proxy)
  return proxy
}

Will go to baseHandlers, which is mutableHandlers

  1. How data proxies work in mutableHandlers

Let’s look at the core of get first, which relies on the collection track method.

function createGetter(isReadonly = false, shallow = false) {
    
    
  return function get(target: Target, key: string | symbol, receiver: object) {
    
    
    // 对 ReactiveFlags 的处理部分
    if (key === ReactiveFlags.IS_REACTIVE) {
    
    
      return !isReadonly
    } else if (key === ReactiveFlags.IS_READONLY) {
    
    
      return isReadonly
    } else if (key === ReactiveFlags.IS_SHALLOW) {
    
    
      return shallow
    } else if (
      key === ReactiveFlags.RAW &&
      receiver ===
        (isReadonly
          ? shallow
            ? shallowReadonlyMap
            : readonlyMap
          : shallow
          ? shallowReactiveMap
          : reactiveMap
        ).get(target)
    ) {
    
    
      return target
    }

    const targetIsArray = isArray(target)

    if (!isReadonly) {
    
    
      // 数组的特殊方法处理
      if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
    
    
        return Reflect.get(arrayInstrumentations, key, receiver)
      }
      // 对象 hasOwnProperty 方法处理
      if (key === 'hasOwnProperty') {
    
    
        return hasOwnProperty
      }
    }

    // 取值
    const res = Reflect.get(target, key, receiver)
    // Symbol Key 不做依赖收集
    if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
    
    
      return res
    }
    // 进行依赖收集
    if (!isReadonly) {
    
    
      track(target, TrackOpTypes.GET, key)
    }

    // 一个浅层响应式对象里只有根级别的属性是响应式的。属性的值会被原样存储和暴露
    if (shallow) {
    
    
      return res
    }

    if (isRef(res)) {
    
    
      //跳过数组、整数 key 的展开
      // ref unwrapping - skip unwrap for Array + integer key.
      return targetIsArray && isIntegerKey(key) ? res : res.value
    }

    if (isObject(res)) {
    
    
      // Convert returned value into a proxy as well. we do the isObject check
      // here to avoid invalid value warning. Also need to lazy access readonly
      // and reactive here to avoid circular dependency.
      // 如果res 是 对象 且不是 readonly 就继续处理成 reactive
      return isReadonly ? readonly(res) : reactive(res)
    }

    return res
  }
}

track dependency collection

export function track(target: object, type: TrackOpTypes, key: unknown) {
    
    
  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 = __DEV__
      ? {
    
     effect: activeEffect, target, type, key }
      : undefined
    // 将 activeEffect 存入到 dep 同时将 dep[] 存入到 activeEffect 中 deps 属性 上 
    trackEffects(dep, eventInfo)
  }
}



export function trackEffects(
  dep: Dep,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
    
    
  let shouldTrack = false
  if (effectTrackDepth <= maxMarkerBits) {
    
    
  // 如果本轮副作用函数执行过程中已经访问并收集过,则不用再收集该依赖
    if (!newTracked(dep)) {
    
    
      dep.n |= trackOpBit // set newly tracked 标识本轮已经被收集过
      shouldTrack = !wasTracked(dep)
    }
  } else {
    
    
    // Full cleanup mode. 判断现在有没有activeEffect  有activeEffect才发生依赖收集
    // activeEffect 每个组件初始化的时候会有一个activeEffect 
    // 这一步的作用 是为了避免多余的依赖收集 例如在setup 创建了 响应式数据 同步 访问 或者 修改 这个数据 这时候 都不会发生 依赖收集。只会在 执行render函数的时候 才发生依赖收集 
    shouldTrack = !dep.has(activeEffect!)
  }
  
  if (shouldTrack) {
    
    
    dep.add(activeEffect!)
    activeEffect!.deps.push(dep)
  }
}


reactiveEffect core code

// 用于记录位于响应上下文中的effect嵌套层次数
let effectTrackDepth = 0
// 二进制位,每一位用于标识当前effect嵌套层级的依赖收集的启用状态
export left trackOpBit = 1
// 表示最大标记的位数
const maxMarkerBits = 30

// 当前活跃的 effect
let activeEffect;

export class ReactiveEffect {
    
    
  // 用于标识副作用函数是否位于响应式上下文中被执行
  active = true
  // 副作用函数持有它所在的所有依赖集合的引用,用于从这些依赖集合删除自身
  deps = []
  // 指针为,用于嵌套 effect 执行后动态切换 activeEffect
  parent = undefined
  // ...
  run() {
    
    
    // 若当前 ReactiveEffect 对象脱离响应式上下文
    // 那么其对应的副作用函数被执行时不会再收集依赖
    if (!this.active) {
    
    
      return this.fn()
    }
    
    // 缓存是否需要收集依赖
    let lastShouldTrack = shouldTrack
    
    try {
    
    
      // 保存上一个 activeEffect 到当前的 parent 上
      this.parent = activeEffect
      // activeEffect 指向当前的 effect
      activeEffect = this
      // shouldTrack 置成 true
      shouldTrack = true
      // 左移操作符 << 将第一个操作数向左移动指定位数
      // 左边超出的位数将会被清除,右边将会补零。
      // trackOpBit 是基于 1 左移 effectTrackDepth 位
      trackOpBit = 1 << ++effectTrackDepth
      
      // 如果未超过最大嵌套层数,则执行 initDepMarkers
      if (effectTrackDepth <= maxMarkerBits) {
    
    
        initDepMarkers(this)
      } else {
    
    
        cleanupEffect(this)
      }
      // 这里执行了 fn
      return this.fn()
    } finally {
    
    
      if (effectTrackDepth <= maxMarkerBits) {
    
    
        // 用于对曾经跟踪过,但本次副作用函数执行时没有跟踪的依赖采取删除操作。
        // 新跟踪的 和 本轮跟踪过的都会被保留
        finalizeDepMarkers(this)
      }
      
      // << --effectTrackDepth 右移动 effectTrackDepth 位
      trackOpBit = 1 << --effectTrackDepth
      
      // 返回上个 activeEffect
      activeEffect = this.parent
      // 返回上个 shouldTrack
      shouldTrack = lastShouldTrack
      // 情况本次的 parent 指向
      this.parent = undefined
    }
  }
}

Insert image description here

illustrate:

The effect[] in depsMap is used to execute the reactiveEffect in the effect array every time an update is dispatched (actually calling the run method of the reactiveEffect instance)

In reactiveEffect, when the run method is executed, the initDepMarkers method will be added to each object in the deps array to add the w attribute to indicate that it has been collected and processed in the dependency collection track----> trackEffects will be added to the dep in depsMap (this dep and effect instance Corresponding to each object in deps) assign n (indicating that it is newly collected)

export const finalizeDepMarkers = (effect: ReactiveEffect) => {
    
    
  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 类型是 set<ReativeEffect>
        dep.delete(effect)
      } else {
    
    
        deps[ptr++] = dep
      }
      // clear bits
      dep.w &= ~trackOpBit
      dep.n &= ~trackOpBit
    }
    deps.length = ptr
  }
}

When the fn function registered in effect.run() is executed, finalizeDepMarkers will be called to delete the effects that have not been collected in this round of dep to avoid unnecessary triggering of update logic.

Then test cases to illustrate
Insert image description here

When does the first round of dependency collection occur?

When initializing the side effect function in the App component, reactiveEffect will be created first and mounted to app.instance
Insert image description here
Insert image description here
Insert image description here

The app will actively trigger instance.update() to mount the first component.
The previous chapter explained the component's first mounting process.
The actual call is reactiveEffect.run ----> execute componentUpdateFn —> render (generate subtree) ----> patch ----> processElement

The responsive data used in the render process is dependently collected.
At this time, this round of dependency collection of the app component is completed. Obj and age are used. At
Insert image description here
this time, the app component processElement is executed. Since there is a sub-component child, mountChild is executed ----> patch —> the processComponent of the child component. The child component will also be the same as the app component. Initialize the instance, create ReactiveEffect, trigger update, execute componentUpdateFn belonging to the child, and then execute the render function of the child component. The child component will collect dependencies.

Insert image description here
age status add

This round of dependency collection is complete.

总结:
组件的首次依赖收集 发生在 render阶段 顺序是 父组件 setup---->父组件 render ---->子组件 setup 
----> 子组件render

Will dependency collection occur if the reactive data is changed during the setup phase?

例如:
setup(){
    
    
   const age = ref(20)
   // 这里发生了访问操作
   const temp = age.value
   return ()=>{
    
    
      return h('div',[age.value]) 
   }
}

这时候会触发响应式数据的 get 操作 
但是由于 没有 activeEffect(这时候 组件还没开始设置副作用函数(SetupRenderEffectFn)所以没有activeEffect) 所以不会发生依赖收集 

扩展:
setup(){
    
    
   const age = ref(20)
   setTimeout(()=>{
    
    
   // 这里发生了访问操作
      console.log(age.value);  
   })
   return ()=>{
    
    
      return h('div',[age.value]) 
   }
}
这时候 也会触发响应式数据的 get 操作 ,也是没有activeEffect(组件已经完成 effect.run 方法了,这时候 activeEffect 已经被置为空) 所以也不会发生依赖收集

后续:
在setup函数之后的生命周期(如mounted、updated等钩子函数)中访问响应式数据会触发依赖收集 (后面再分析)

Distribute updates

When is the distribution update triggered?

After the above component is mounted, the modified responsive data operation I wrote in the mouted life cycle hook will trigger the setter. Let’s take a look at the reactive setter source code.


function createSetter(shallow = false) {
    
    
  return function set(
    target: object,
    key: string | symbol,
    value: unknown,
    receiver: object
  ): boolean {
    
    
    // 。。。 省略部分逻辑
    const hadKey =
      isArray(target) && isIntegerKey(key)
        ? Number(key) < target.length
        : hasOwn(target, key)
    const result = Reflect.set(target, key, value, receiver)
    // 如果target是原型链上的东西,不要触发
    if (target === toRaw(receiver)) {
    
    
      if (!hadKey) {
    
    
        // 新增操作
        trigger(target, TriggerOpTypes.ADD, key, value)
      } else if (hasChanged(value, oldValue)) {
    
    
        // 更新操作
        trigger(target, TriggerOpTypes.SET, key, value, oldValue)
      }
    }
    return result
  }
}


export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown,
  oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
    
    
  // 根据 target 查到对应的 depsMap
  const depsMap = targetMap.get(target)
  // 不存在depsMap 不触发更新
  if (!depsMap) {
    
    
    // never been tracked
    return
  }
  
  // 用于 暂存 effect
  let deps: (Dep | undefined)[] = []
  if (type === 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 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 TriggerOpTypes.DELETE:
        if (!isArray(target)) {
    
    
          deps.push(depsMap.get(ITERATE_KEY))
          if (isMap(target)) {
    
    
            deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
          }
        }
        break
      case TriggerOpTypes.SET:
        if (isMap(target)) {
    
    
          deps.push(depsMap.get(ITERATE_KEY))
        }
        break
    }
  }

  const eventInfo = __DEV__
    ? {
    
     target, type, key, newValue, oldValue, oldTarget }
    : undefined
 
  // 最终处理 在这里
  if (deps.length === 1) {
    
    
    if (deps[0]) {
    
    
      if (__DEV__) {
    
    
        triggerEffects(deps[0], eventInfo)
      } else {
    
    
        triggerEffects(deps[0])
      }
    }
  } else {
    
    
    const effects: ReactiveEffect[] = []
    for (const dep of deps) {
    
    
      if (dep) {
    
    
        effects.push(...dep)
      }
    }
    // 下面操作 是为了 去重 保证相同的effect 只会有一个
    if (__DEV__) {
    
    
      triggerEffects(createDep(effects), eventInfo)
    } else {
    
    
      triggerEffects(createDep(effects))
    }
  }
}


export function triggerEffects(
  dep: Dep | ReactiveEffect[],
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
    
    
  // spread into array for stabilization
  const effects = isArray(dep) ? dep : [...dep]
  for (const effect of effects) {
    
    
    if (effect.computed) {
    
    
      triggerEffect(effect, debuggerEventExtraInfo)
    }
  }
  for (const effect of effects) {
    
    
    if (!effect.computed) {
    
    
      triggerEffect(effect, debuggerEventExtraInfo)
    }
  }
}

function triggerEffect(
  effect: ReactiveEffect,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
    
    
  if (effect !== activeEffect || effect.allowRecurse) {
    
    
    if (__DEV__ && effect.onTrigger) {
    
    
      effect.onTrigger(extend({
    
     effect }, debuggerEventExtraInfo))
    }
    // 最终会执行 scheduler 是在 初始化的时候 创建的
    if (effect.scheduler) {
    
    
      effect.scheduler()
    } else {
    
    
      effect.run()
    }
  }
}

 //  在SetupRenderEffectFn 阶段中 create reactive effect for rendering
    const effect = (instance.effect = new ReactiveEffect(
      componentUpdateFn,
      () => queueJob(update),// 这个就是 scheduler
      instance.scope // track it in component's effect scope
    ))

Summary: When the responsive data is updated and the corresponding depsMap is not empty, the component update will be triggered (how to update will be answered in the next question)

Extension: Will the modification of responsive data in the setup phase trigger component updates?
setup(){
    
    
   const age = ref(20)
   // 修改操作
   age.value = 10
   return ()=>{
    
    
      return h('div',[age.value]) 
   }
}
会触发 setter 操作 由于 depsMap 为空 所以不会发生派发更新

How does vue trigger component update rendering based on dispatch updates?

Insert image description here
The core of dispatching updates is to trigger effect.scheduler (the conventional component writing method is to create a scheduler for activeEffect)

const effect = (instance.effect = new ReactiveEffect(
      componentUpdateFn,
      () => queueJob(update), // effect.schedule
      instance.scope // track it in component's effect scope
    ))

Analyze queueJob


export function queueJob(job: SchedulerJob) {
    
    
  // the dedupe search uses the startIndex argument of Array.includes() 确保不会重复设置 schedule
  // by default the search index includes the current job that is being run 默认包括正在运行的 schedule
  // so it cannot recursively trigger itself again. 避免递归触发自身再次运行
  // if the job is a watch() callback, the search will start with a +1 index to 运行在watch 中 重复运行
  // allow it recursively trigger itself - it is the user's responsibility to
  // 确保它不会陷入无限循环

  // 去重判断
  if (
    !queue.length ||
    !queue.includes(
      job,
      isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
    )
  ) {
    
    
  //添加到队列尾部
    if (job.id == null) {
    
    
      queue.push(job)
    } else {
    
    
      // 按照 job id 自增的顺序添加 (一般父组件的id 要小于子组件 保证 父组件永远先于子组件触发更新)
      // 这个id 是由 instance.uid 决定 就是在初始化组件实例 确定( 具体代码 runtime-core/src/component) 先初始化的 uid(每次创建组件实例 全局 uid会加1) 会小,
      queue.splice(findInsertionIndex(job.id), 0, job)
    }
    queueFlush()
  }
}

// 通过promise.then 创建 微任务(去执行flushjob)
function queueFlush() {
    
    
  if (!isFlushing && !isFlushPending) {
    
    
    isFlushPending = true
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}


function flushJobs(seen?: CountMap) {
    
    
  // 是否正在等待执行
  isFlushPending = false
  // 正在执行
  isFlushing = true


   // 在更新前,重新排序好更新队列 queue 的顺序
  // 这确保了:
  // 1. 组件都是从父组件向子组件进行更新的。(因为父组件都在子组件之前创建的
  // 所以子组件的渲染的 effect 的优先级比较低)
  // 2. 如果父组件在更新前卸载了组件,这次更新将会被跳过。
  queue.sort(comparator)

 

  try {
    
    
  // 遍历主任务队列,批量执行更新任务
    for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
    
    
      const job = queue[flushIndex]
      if (job && job.active !== false) {
    
    
        if (__DEV__ && check(job)) {
    
    
          continue
        }
        // 这个 job 就是 effect.run
        callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
      }
    }
  } finally {
    
    
   // 队列任务执行完,重置队列索引
    flushIndex = 0
     // 清空队列
    queue.length = 0
    // 执行后置队列任务
    flushPostFlushCbs(seen)
    // 重置队列执行状态
    isFlushing = false
    // 重置当前微任务为 Null
    currentFlushPromise = null
    // 如果主任务队列、后置任务队列还有没被清空,就继续递归执行
    if (queue.length || pendingPostFlushCbs.length) {
    
    
      flushJobs(seen)
    }
  }
}


Summary: The dispatch updates triggered by the mounted component will be collected into a microtask execution task queue. After the main process macro task is executed, the microtask task queue will be executed to start triggering the execution job (effect.run —> updateComponentFn)

How to ensure that the component will only trigger one update rendering when there are multiple responsive data updates when the component side effect function is executed?

With the above source code analysis, we can already get the answer to why multiple responsive data updates only trigger one component update when the side effect function of a component is executed. Demo
code:

setup(){
    
    
   const num1 = ref(20)
   
   const num2 = ref(10)

   onMounted(()=>{
    
    
     num1.value = 40
     num1.value = 50
     num2.value = 100
   })
   

   return ()=>{
    
    
      return h('div',[num1.value+num2.value]) 
   }
}

After updating three times in onMounted and triggering triggerEffect three times, there will be three update operations put into the microtask. Since the incoming job.id is the same, only one update task component will be created in the update queue and will only be updated once.

How are redundant component dependencies removed?

After each rendering, the component will clean up the subsequent effects that have not been collected (corresponding to the reactiveEffect in the dep(set) corresponding to each responsive data)

example:

const child = defineComponent({
    
    
      template: `
         <div><p>{
     
     {age}}---{
     
     {status?add:'hihi'}}</p></div>
      `,
      props:{
    
    
        age:{
    
    
          type: Number,
          default:20
        }
      },
      data(){
    
    
        return {
    
    
          add: '12',
          status: true
        }
      },
      mounted() {
    
    
          this.status = false
          this.add = '24'
      },
  })
// mounted 阶段 改变了 status 触发了 组件更新 重新 render 的 时候 会发生新的一轮依赖收集 
// 之前 组件 是有两个 dep 一个 属于 status 一个属于 add 但是,由于新的依赖收集 add 不会被用到 所以 在 effect.run 执行完 后 add 的 dep 会被清除掉 是根据 dep 赋值的 w 和 n 属性 去比较

Guess you like

Origin blog.csdn.net/weixin_45485922/article/details/132754955