关于vue.js:petitevue源码剖析逐行解读vuereactivity之effect

当咱们通过effect将副函数向响应上下文注册后,副作用函数内拜访响应式对象时即会主动收集依赖,并在相应的响应式属性发生变化后,主动触发副作用函数的执行。

// ./effect.ts

export funciton effect<T = any>(
  fn: () => T,
  options?: ReactiveEffectOptions
): ReactiveEffectRunner {
  if ((fn as ReactiveEffectRunner).effect) {
    fn = (fn as ReactiveEffectRunner).effect.fn
  }

  const _effect = new ReactiveEffect(fn)
  if (options) {
    extend(_effect, options)
    if (options.scope) recordEffectScope(_effect, options.scope)
  }
  // 默认是马上执行副作用函数收集依赖,但可通过lazy属性提早副作用函数的执行,提早依赖收集。
  if (!options || !options.lazy) {
    _effect.run()
  }
  // 类型为ReactiveEffectRunner的runner是一个绑定this的函数
  const runner = _effect.run.bind(_effect) as ReactiveEffectRunner
  runner.effect = _effect
  return runner
}

effect函数的代码非常少,次要流程是

  1. 将基于副作用函数构建ReactiveEffect对象
  2. 若为默认模式则马上调用ReactiveEffect对象的run办法执行副作用函数。

不过这里咱们有几个疑难

  1. ReactiveEffectRunner是什么?
  2. ReactiveEffect生成的对象到底是什么?显然ReactiveEffectrun办法才是梦开始的中央,到底它做了些什么?
  3. 针对配置项scoperecordEffectScope的作用?

ReactiveEffectRunner是什么?

// ./effect.ts

// ReactiveEffectRunner是一个函数,而且有一个名为effect的属性且其类型为RectiveEffect
export interface ReactiveEffectRunner<T = any> {
  (): T
  effect: ReactiveEffect
}

ReactiveEffect生成的对象到底是什么?

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

const effectStack: ReactiveEffect[] = []
let activeEffect: ReactiveEffect | undefined

export class ReactiveEffect<T = any> {
  // 用于标识副作用函数是否位于响应式上下文中被执行
  active = true
  // 副作用函数持有它所在的所有依赖汇合的援用,用于从这些依赖汇合删除本身
  deps: Dep[] = []
  // 默认为false,而true示意若副作用函数体内遇到`foo.bar += 1`则有限递归执行本身,直到爆栈
  allowRecurse?: boolean

  constructor(
    public fn: () => T,
    public scheduler: EffectScheduler | null = null,
    scope?: EffectScope | null
  ) {
    recordEffectScope(this, scope)
  }

  run() {
    /**
     * 若以后ReactiveEffect对象脱离响应式上下文,那么其对应的副作用函数被执行时不会再收集依赖,并且其外部拜访的响应式对象发生变化时,也会主动触发该副作用函数的执行
     */
    if (!this.active) {
      return this.fn()
    }
    // 若参加响应式上下文则须要先压栈
    if (!effectStack.includes(this)) {
      try {
        // 压栈的同时必须将以后ReactiveEffect对象设置为沉闷,即程序栈中以后栈帧的意义。
        effectStack.push(activeEffect = this)
        enableTracking()

        trackOpBit = 1 << ++effectTrackDepth

        if (effectTrackDepth <= maxMarkerBits) {
          // 标记已跟踪过的依赖
          initDepMarkers(this)
        }
        else {
          cleanupEffect(this)
        }

        return this.fn()
      }
      finally {
        if (effectTrackDepth <= maxMarkerBits) {
          /**
           * 用于对已经跟踪过,但本次副作用函数执行时没有跟踪的依赖,采取删除操作。
           * 即,新跟踪的 和 本轮跟踪过的都会被保留。
           */
          finalizeDepMarkers(this)
        }

        trackOpBit = 1 << --effectTrackDepth
        resetTracking()
        // 最初当然弹栈,把控制权交还给上一个栈帧咯
        effectStack.pop()
        const n = effectStack.length
        activeEffect = n > 0 ? effectStack[n - 1] : undefined  
      }
    }

    /**
     * 让以后ReactiveEffect对象脱离响应式上下文,请记住这是一去不回头的操作哦!
     */ 
    stop() {
      if (this.active) {
        cleanupEffect(this)
        this.active = false
      }
    }
  }
}

为应答嵌套effect外部将以后位于响应上下文的ReactiveEffect对象压入栈构造effectStack: ReactiveEffect[],当以后副作用函数执行后再弹出栈。另外,尽管咱们通过effect函数将副作用函数注册到响应上下文中,但咱们仍能通过调用stop办法让其脱离响应上下文。

function cleanupEffect(effect: ReactiveEffect) {
  const { deps } = effect
  if (deps.length) {
    // 将以后ReactiveEffect对象从它依赖的响应式属性的所有Deps中删除本人,那么当这些响应式属性发生变化时则不会遍历到以后的ReactiveEffect对象
    for (let i = 0; i < deps.length; ++i) {
      deps[i].delete(effect)
    }
    // 以后ReactiveEffect对象不再参加任何响应了
    deps.length = 0
  }
}

在执行副作用函数前和执行后咱们会看到别离调用了enableTracking()resetTracking()函数,它们别离示意enableTracking()执行后的代码将启用依赖收集,resetTracking()则示意前面的代码将在复原之前是否收集依赖的开关执行上来。要了解它们必须联合pauseTracking()和理论场景阐明:

let shouldTrack = true
const trackStack: boolean[] = []

export function enableTracking() {
  trackStack.push(shouldTrack)
  shouldTrack = true
}

export function resetTracking() {
  const last = trackStack.pop()
  shouldTrack = last === undefined ? true : last
}

export function pauseTracking() {
  trackStack.push(shouldTrack)
  shouldTrack = false
}

假如咱们如下场景

const values = reactive([1,2,3])
effect(() => {
  values.push(1)
})

因为在执行push时外部会拜访代理对象的length属性,并批改length值,因而会导致一直执行该副作用函数直到抛出异样Uncaught RangeError: Maximum call stack size exceeded,就是和(function error(){ error() })()一直调用本身导致栈空间有余一样的。而@vue/reactivity是采纳如下形式解决

;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => {
  instrumentations[key] = function (this: unknown[], ...args: unknown[]) {
    pauseTracking()
    const res = (toRaw(this) as any)[key].apply(this, args)
    resetTracking()
    return res
  }
})

即通过pauseTracking()暂停push外部的发生意外的依赖收集,即push仅仅会触发以其余模式依赖length属性的副作用函数执行。而后通过resetTracking()复原到之前的跟踪状态。

最初在执行副作用函数return this.fn()前,竟然有几句难以了解的语句

try {
  trackOpBit = 1 << ++effectTrackDepth

  if (effectTrackDepth <= maxMarkerBits) {
    initDepMarkers(this)
  }
  else {
    cleanupEffect(this)
  }

  return this.fn()
}
finally {
  if (effectTrackDepth <= maxMarkerBits) {
    finalizeDepMarkers(this)
  }

  trackOpBit = 1 << --effectTrackDepth
}

咱们能够将其简化为

try {
  cleanupEffect(this)
  
  return this.fn()
}
finally {}

为什么在执行副作用函数前须要清理所有依赖呢?咱们能够考虑一下如下的状况:

const state = reactive({ show: true, values: [1,2,3] })
effect(() => {
  if (state.show) {
    console.log(state.values)
  }
})
setTimeout(() => {
  state.values.push(4)
}, 5000)

setTimeout(() => {
  state.show = false
}, 10000)

setTimeout(() => {
  state.values.push(5)
}, 15000)

一开始的时候副作用函数将同时依赖showvalues,5秒后向values追加新值副作用函数马上被触发从新执行,再过10秒后show转变为false,那么if(state.show)无论如何运算都不成立,此时再对values追加新值若副作用函数再次被触发显然除了占用系统资源外,别无用处。
因而,在副作用函数执行前都会先清理所有依赖(cleanupEffect的作用),而后在执行时从新收集。

面对上述情况,先清理所有依赖再从新收集是必须的,但如下状况,这种清理工作反而减少无谓的性能耗费

const state = reactive({ show: true, values: [1,2,3] })
effect(() => {
  console.log(state.values)
})

@vue/reactivity给咱们展现了一个十分优良的解决形式,那么就是通过标识每个依赖汇合的状态(新依赖和曾经被收集过),并对新依赖和曾经被收集过两个标识进行比照筛选出已被删除的依赖项。

优化无用依赖清理算法

export type Dep = Set<ReactiveEffect> & Trackedmarkers

type TrackedMarkers = {
  /**
   * wasTracked的缩写,采纳二进制格局,每一位示意不同effect嵌套层级中,该依赖是否已被跟踪过(即在上一轮副作用函数执行时曾经被拜访过)
   */ 
  w: number
  /**
   * newTracked的缩写,采纳二进制格局,每一位示意不同effect嵌套层级中,该依赖是否为新增(即在本轮副作用函数执行中被拜访过)
   */ 
  n: number
}

export const createDep = (effects) => {
  const dep = new Set<ReactiveEffect>(effects) as Dep
  // 尽管TrackedMarkers标识是位于响应式对象属性的依赖汇合上,但它每一位仅用于示意以后执行的副作用函数是否已经拜访和正在拜访该响应式对象属性
  dep.w = 0
  dep.n = 0

  return dep
}

export const wasTracked = (dep: Dep): boolean => (dep.w & trackOpBit) > 0

export const newTracked = (dep: Dep): boolean => (dep.n & trackOpBit) > 0

/**
 * 将以后副作用函数的依赖标记为 `曾经被收集`
 */
export const initDepMarkers = ({ deps }: ReactiveEffect) => {
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].w |= trackOpBit
    }
  }
}

/**
 * 用于对已经跟踪过,但本次副作用函数执行时没有跟踪的依赖,采取删除操作。
 * 即,新跟踪的 和 本轮跟踪过的都会被保留。
 */
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.delete(effect)
      }
      else {
        // 放大依赖汇合的大小
        deps[ptr++] = dep
      }
      // 将w和n中对应的嵌套层级的二进制地位零,如果短少这步后续副作用函数从新执行时则无奈从新收集依赖。
      dep.w &= ~trackOpBit
      dep.n &= ~trackOpBit
    }
    // 放大依赖汇合的大小
    deps.length = ptr
  }
}
// 在位于响应式上下文执行的副作用函数内,拜访响应式对象属性,将通过track收集依赖
export function track(target: object, type: TrackOpTypes, key: unknown) {
  if (!isTracking()) {
    return
  }

  // targetMap用于存储响应式对象-对象属性的键值对
  // depsMap用于存储对象属性-副作用函数汇合的键值对
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    target.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = createDep()))
  }

  trackEffects(dep)
}

// 收集依赖
export function trackEffects(
  dep: Dep
) {
  let shouldTrack = false
  if (effectTrackDepth <= maxMarkerBits) {
    // 如果本轮副作用函数执行过程中曾经拜访并收集过,则不必再收集该依赖
    if (!newTracked(dep)) {
      dep.n |= trackOpBit
      shouldTrack = !wasTracked(dep)
    }
  }
  else {
    // 对于全面清理的状况,如果以后副作用函数对应的ReactiveEffect对象不在依赖汇合中,则标记为true
    shouldTrack = !dep.has(activeEffect!)
  }

  if (shouldTrack) {
    dep.add(activeEffect!)
    activeEffect!.deps.push(dep)
  }
}

单单从代码实现角度能难了解这个优化形式,不如咱们从理论的例子登程吧!

const runAync = fn => setTimeout(fn, 1000)

const state = reactive({ show: true, values: [1,2,3] })
// 1
effect(() => {
  if (state.show) {
    console.log(state.values)
  }
})

// 2
runAync(() => {
  state.values.push(4)
})

// 3
runAync(() => {
  state.show = false
})
  1. 首次执行副作用函数
    a. effectTrackDepth为0,因而1 << ++effectTrackDepth失去的effectTrackDepthtrackOpBit均为1,但因为此时副作用函数还没有收集依赖,因而initDepMarkers函数没有任何成果;
    b. 拜访state.show时因为之前没有收集过响应式对象stateshow属性,因而会调用createDep创立wn均为0的依赖汇合,并调用trackEffects发现newTracked(dep)为未跟踪过,则将n设置为1,而后开始收集依赖;
    c. 拜访state.values会反复第2步的操作;
    d. 因为state.showstate.values都是新跟踪的(n为1),因而在finalizeDepMarkers解决后依然将副作用函数保留在这两个属性对应的依赖汇合中。
  2. 执行state.values.push(4)触发副作用函数变动
    a. effectTrackDepth为0,因而1 << ++effectTrackDepth失去的effectTrackDepthtrackOpBit均为1,此时副作用函数曾经收集过依赖,因而initDepMarkers将该副作用函数所在的依赖汇合都都标记为已收集过(w为1);
    b. 拜访state.show时会调用trackEffects发现newTracked(dep)为未跟踪过(在finalizeDepMarkers中已被置零),则将n设置为1,而后开始收集依赖;
    c. 拜访state.values会反复第2步的操作;
    d. 因为state.showstate.values都是新跟踪的(n为1),因而在finalizeDepMarkers解决后依然将副作用函数保留在这两个属性对应的依赖汇合中。
  3. 执行state.show = false触发副作用函数变动
    a. effectTrackDepth为0,因而1 << ++effectTrackDepth失去的effectTrackDepthtrackOpBit均为1,此时副作用函数曾经收集过依赖,因而initDepMarkers将该副作用函数所在的依赖汇合都都标记为已收集过(w为1);
    b. 拜访state.show时会调用trackEffects发现newTracked(dep)为未跟踪过(在finalizeDepMarkers中已被置零),则将n设置为1,而后开始收集依赖;
    c. 因为state.values没有标记为新跟踪的(n为0),因而在finalizeDepMarkers解决后会将副作用函数从state.values对应的依赖汇合中移除,仅保留在state.values对应的依赖汇合中。

到这里,我想大家曾经对这个优化有更深的了解了。那么接下来的问题自然而然就是为什么要硬编码将优化算法启动的嵌套层级设置为maxMarkerBits = 30

SMI优化原理

首先maxMarkerBits = 30示意仅反对effect嵌套31层,正文中形容该值是因为想让JavaScript影响应用SMI。那么什么是SMI呢?

因为ECMAScript规范约定number数字须要转换为64位双精度浮点数解决,但所有数字都用64位存储和解决是非常低效的,所以V8外部采纳其它内存示意形式(如32位)而后向外提供64位体现的个性即可。其中数组非法索引范畴是[0, 2^32 - 2],V8引擎就是采纳32位的形式来存储这些非法的下标数字。另外,所有在[0, 2^32 - 2]内的数字都会优先应用32位二进制补码的形式存储。

针对32位有符号位范畴内的整型数字V8为其定义了一种非凡的表示法SMI(非SMI的数字则被定义为HeapNumber),而V8引擎针对SMI启用非凡的优化:当应用SMI内的数字时,引擎不须要为其调配专门的内存实体,并会启用疾速整型操作

对于非SMI的数字

let o = {
  x: 42, // SMI
  y: 4.2 // HeapNumber
}

内存构造为HeapNumber{ value: 4.2, address: 1 }JSObject{ x: 42, y: 1 },因为x值类型为SMI因而间接存储在对象上,而y为HeapNumber则须要调配一个独立的内存空间寄存,并通过指针让对象的y属性指向HeapNumber实例的内存空间。

然而在批改值时,而后x为SMI所以能够原地批改内存中的值,而HeapNumber为不可变,因而必须再调配一个新的内存空间寄存新值,并批改o.y中的内存地址。那么在没有启用Mutable HeapNumber时,如下代码将产生1.11.21.33个长期实例。

let o = { x: 1.1 }
for (let i = 0; i < 4; ++i) {
  o.x += 1;
}

SMI是带符号位的,那么理论存储数字是31位,因而设置maxMarkerBits = 30且通过if (effectTrackDepth <= maxMarkerBits)判断层级,即当effec嵌套到31层时不再应用无用依赖清理优化算法。而优化算法中采纳的是二进制位对上一轮已收集和本轮收集的依赖进行比拟,从而清理无用依赖。若nw值所占位数超过31位则外部会采纳HeapNumber存储,那么在位运算上性能将有所降落。

其实咱们还看到若effectTrackDepth等于31时还会执行trackOpBit = 1 << ++effectTrackDepth,这会导致trackOpBitSMI的存储形式转换为HeapNumber,那是不是能够加个判断批改成上面这样呢!

const maxMarkerBit = 1 << 30

if (trackOpBit & maxMarkerBit !== 1) {
  trackOpBit = 1 << ++effectTrackDepth
}

副作用函数触发器-trigger

因为在解说”优化无用依赖清理算法”时曾经对track进行了分析,因而当初咱们间接剖析trigger就好了。

export function trigger(
  target: object,
  // set, add, delete, clear
  type: TriggerOpTypes,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown,
  oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
  const depsMap = targetMap.get(target)
  if (!depsMap) {
    // 该属性没有被任何副作用函数跟踪过,所以间接返回就好了
    return
  }

  /**
   * 用于存储将要被触发的副作用函数。
   * 为什么不间接通过相似depsMap.values().forEach(fn => fn())执行副作用函数呢?
   * 那是因为副作用函数执行时可能会删除或减少depsMap.values()的元素,导致其中的副作用函数执行异样。
   * 因而用另一个变量存储将要执行的副作用函数汇合,那么执行过程中批改的是depsMap.values()的元素,而正在遍历执行的副作用函数汇合构造是稳固的。
   */
  let deps: (Dep | undefined)[] = []
  if (type === TriggerOpTypes.CLEAR) {
    // 对象的所有属性值清空,所有依赖该响应式对象的副作用函数都将被触发
    deps = [...depsMap.values()]
  }
  else if (key === 'length' && isArray(target)) {
    // 若设置length属性,那么依赖length属性和索引值大于等于新的length属性值的元素的副作用函数都会被触发
    depsMap.forEach((dep, key) => {
      if (key === 'length' || key >= (newValue as number)) {
        deps.push(dep)
      }
    })
  }
  else {
    // 将依赖该属性的
    if (key !== void 0) {
      // 即便插入的是undefined也没有关系
      deps.push(depsMap.get(key))
    }

    /**
     * 增加间接依赖的副作用函数
     * 1. 新增数组新值索引大于数组长度时,会导致数组容量被裁减,length属性也会发生变化
     * 2. 新增或删除Set/WeakSet/Map/WeakMap元素时,须要触发依赖迭代器的副作用函数
     * 3. 新增或删除Map/WeakMap元素时,须要触发依赖键迭代器的副作用函数
     * 4. 设置Map/WeakMap元素的值时,须要触发依赖迭代器的副作用函数
     */ 
    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)) {
          // 对数组插入新元素,则须要触发依赖length的副作用函数
          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:
        // 对于Map/WeakMap须要触发依赖迭代器的副作用函数
        if (isMap(target)) {
          deps.push(depsMap.get(ITERATE_KEY))
        }
    }

    if (deps.length === 1) {
      // 过滤掉undefined
      if (deps[0]) {
        triggerEffects(deps[0])
      }
    }
    else {
      const effects: ReactiveEffect[] = []
      // 过滤掉undefined
      for (const dep of deps) {
        if (dep) {
          effects.push(...dep)
        }
      }
      triggerEffects(createDep(effects))
    }
  }
}

export function triggerEffects(
  dep: Dep | ReactiveEffect[]
) {
  for (const effect of isArray(dep) ? dep : [...dep]) {
    /**
     * 必须保障将要触发的副作用函数(effect)不是以后运行的副作用函数(activeEffect),否则将嵌入有限递归。
     * 假如存在如下状况
     * let foo = reactive({ bar: 1 })
     * effect(() => {
     *   foo.bar = foo.bar + 1
     * })
     * 若没有上述的保障,则将会一直递归上来间接爆栈。
     * 
     * 如果ReactiveEffect对象的allowRecurse设置为true,那么示意不对上述问题作进攻。
     */ 
    if (effect !== activeEffect || effect.allowRecurse) {
      if (effect.scheduler) {
        // 若设置有调度器则调用调用器
        effect.scheduler()
      }
      else {
        // 立刻执行副作用函数
        effect.run()
      }
    }
  }
}

调度器

在上一节的triggerEffects中咱们看到默认采纳同步形式执行副作用函数,若要同步执行数十个副作用函数那么势必会影响以后事件循环主逻辑的执行,这时就是调度器闪亮退场的时候了。咱们回顾以下petite-vue中提供的调度器吧!

import { effect as rawEffect } from '@vue/reactivity'

const effect = (fn) => {
  const e: ReactiveEffectRunner = rawEffect(fn, {
    scheduler: () => queueJob(e)
  })
  return e
}
// ./scheduler.ts

let queued = false
const queue: Function[] = []
const p = Promise.resolve()

export const nextTick = (fn: () => void) => p.then(fn)

export const queueJob = (job: Function) => {
  if (!queue.includes(job)) queue.push(job)
  if (!queued) {
    queued = true
    nextTick(flushJobs)
  }
}

const flushJobs = () => {
  for (const job of queue) {
    job()
  }
  queue.length = 0
  queued = false
}

副作用函数压入队列中,并将遍历队列执行其中的副作用函数后清空队列的flushJobs压入micro queue。那么以后事件循环主逻辑执行完后,JavaScript引擎将会执行micro queue中的所有工作。

什么是EffectScope

Vue 3.2引入新的Effect scope API,可主动收集setup函数中创立的effectwatchcomputed等,当组件被销毁时主动销毁作用域(scope)和作用域下的这些实例(effectwatchcomputed等)。这个API次要是提供给插件或库开发者们应用的,日常开发不须要用到它。

还记得petite-vue中的context吗?当遇到v-ifv-for就会为每个子分支创立新的block实例和新的context实例,而子分支下的所有ReactiveEffect实例都将对立被对应的context实例治理,当block实例被销毁则会对对应的context实例下的ReactiveEffect实例通通销毁。

block实例对应是DOM树中动静的局部,能够大略对应上Vue组件,而context实例就是这里的EffectScope对象了。

应用示例:

cosnt scope = effectScope()
scope.run(() => {
  const state = reactive({ value: 1 })
  effect(() => {
    console.log(state.value)
  })
})
scope.stop()

那么effect生成的ReactiveEffect实例是如何和scope关联呢?
那就是ReactiveEffect的构造函数中调用的recordEffectScope(this, scope)

export function recordEffectScope(
  effect: ReactiveEffect,
  scope?: EffectScope | null
) {
  // 默认将activeEffectScope和以后副作用函数绑定
  scope = scope || activeEffectScope
  if (scope && scope.active) {
    scope.effects.push(effect)
  }
}

总结

petite-vue中应用@vue/reactivity的局部算是分析实现了,兴许你会说@vue/reactivity可不止这些内容啊,这些内容我将会在后续的《vue-lit源码分析》中更详尽的梳理剖析,敬请期待。
下一篇咱们将看看eval中是如何应用new Functionwith来结构JavaScript解析执行环境的。
尊重原创,转载请注明来自:https://www.cnblogs.com/fsjoh…肥仔John

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理