阅读视图

发现新文章,点击刷新页面。

初探 Vue 3响应式源码(五):Ref

为什么需要ref

我们前两章讲讲解了reactive源码解析effect源码解析,并且知道了它们是如何实现响应式的,还没看过的小伙伴可以先阅读一下。

我们回顾一下,reactive函数可以创建通过Proxy实现的响应式对象,响应式对象需要在effect中使用才能收集到依赖,在更改响应式对象时,代理会通过trigger通知所有依赖的effect对象,并执行effect的监听方法。

正因为reactive创建的响应式对象是通过Proxy来实现的,所以传入数据不能为基础类型,比如numberstringboolean

什么是ref

ref对象是对reactive不支持的数据的一个补充,让如基础数据响应式进行支持,以及更方便的对象替换操作推出的。下面我们先了解一下ref的特性。

  • 使用refshallowRef函数创建ref对象,ref通过value属性进行访问和修改传入参数。

  • reactive不同,ref的参数没有任何限制。

  • 使用reactive可接受的对象为ref参数对象时,isReactive(ref.value)true

  • refeffect监听函数中使用可响应式

  • refeffect中只有value属性是可响应式的

  • customRef可以创建自定义gettersetterref,创建时需要提供一个创建get, set工厂方法,工厂方法会传入收集方法和触发方法,由用户主动触发。如

    let value = 1
    const custom = customRef((track, trigger) => ({
      get() {
        track()
        return value
      },
      set(newValue: number) {
        value = newValue
        trigger()
      }
    }))
    
  • 使用toRef可以通过proxy的某个属性生成为可以有默认值的ref对象

  • 使用toRefs可以通过proxy的数据结构以及所有属性,生成与proxy数据结构一致的,所有属性值为ref对象的对象

  综合上面的特性和之前讲解effect的实现原理,能猜得到ref对象会对value属性的修改和获取时进行拦截,在valueget的时候收集依赖,在set的时候获取依赖关联的effect再触发依赖函数。ref对属性修改和获取时不能通过proxy来实现,ref支持基础类型而proxy不支持。收集依赖时不能使用effect文件中的targetMap关联effecttargetMapWeakMap类型,WeakMap类型仅支持对象作为key,不支持基础类型。

ref和shallowRef的具体实现

  接下来我们看看refshallowRef的具体实现:

// 是否是ref根据属性的__v_isRef决定
export function isRef(r: any): r is Ref {
  return Boolean(r && r.__v_isRef === true)
}


export function ref(value?: unknown) {
  return createRef(value, false)
}

export function shallowRef(value?: unknown) {
  return createRef(value, true)
}

// 创建ref对象,传入raw和是否是shallow
function createRef(rawValue: unknown, shallow: boolean) {
  // 如果之前时ref则直接返回
  if (isRef(rawValue)) {
    return rawValue
  }
  return new RefImpl(rawValue, shallow)
}

  与reactive一样ref创建的响应式对象也分为是否是shallowref对象支持对value深度响应式,也就是说ref.value.a.b中的修改都能被拦截,shallowRef对象只支持对value值的响应式。

  refshallowRef函数都使用createRef来创建ref对象,只是参数的区别。创建的ref对象会附加__v_isRef属性来标识是否是ref对象。在创建ref对象之前会检查入参是否是ref如果是就直接返回入参参数。

  我们看到ref函数创建的真实对象是RefImpl,采用了class写法,将rawshallow作为构造函数,下面我们看看这个class的实现:

// Ref对象类
class RefImpl<T> {
  // 存放 reactive(raw) 后的proxy
  private _value: T
  // 存放 raw
  private _rawValue: T

  // 建立与effect的关系
  public dep?: Dep = undefined
  // 是否ref的标识
  public readonly __v_isRef = true

  // 构造,传入raw 和 shallow
  constructor(value: T, public readonly _shallow: boolean) {
    // 存储 raw 
    this._rawValue = _shallow ? value : toRaw(value)
    // 如果是不是shallow则 存储 reactive proxy 否则存储传入参数
    this._value = _shallow ? value : toReactive(value)
  }

  // getter value拦截器
  get value() {
    // track Ref 收集依赖
    trackRefValue(this)
    return this._value
  }

  // setter value拦截器
  set value(newVal) {
    // 如果是需要深度响应的则获取 入参的raw
    newVal = this._shallow ? newVal : toRaw(newVal)

    // 查看要设置值是否与当前值是否修改
    if (hasChanged(newVal, this._rawValue)) {
      // 存储新的 raw
      this._rawValue = newVal
      // 更新value 如果是深入创建的还需要转化为reactive代理
      this._value = this._shallow ? newVal : toReactive(newVal)
      // 触发value,更新关联的effect
      triggerRefValue(this, newVal)
    }
  }
}

  如果不是shallow传入的value会通过toReactive转化为reactive,然后存在ref._value中。在get的时候直接返回这个reactive,这就是使用reactive可接受的对象为ref参数对象时,isReactive(ref.value)true的原因,也是为什么能深度响应的原因。

  ref还会存储入参和set的原始值,如果不是shallow则通过toRaw获取,存储在_rawValue属性中,存储这个值是为了能正确的判断值是否被修改。所以下方这种情况是不会调用triggerRefValue的,因为原始值是一样的。

const target = { name: 'bill' }
const reTarget = reactive(target)
const targetRef = ref(reTarget)

targetRef.value = target

  ref对象还有个非常重要的属性depreactive对象是通过targetMapDep关联的。reactive收集时通过track函数获取dep,然后通过dep对象调用trackEffects函数来将effectDep关联。

  reactive触发时通过trigger函数整理相关联的多个dep最终合并成一个dep,然后通过dep调用triggerEffects获取关联的effect收集函数并触发。

  dep中的具体细节管理是通过trackEffects函数和effect对象管理的,将depeffect是由trackEffects函数处理的, 触发是由triggerEffects函数执行的。

  也就是说基于现有effect的基础上,创建响应式对象只需要收集时获取dep并调用trackEffects(dep), 触发时获取收集时的dep并调用triggerEffects(dep) dep属性就是ref能成为响应式对象的根本原因。

  接下来我们看看ref是如何实现trackEffects(dep)triggerEffects(dep)的。refget value时会调用trackRefValue,在set value时,如果value值发生了更改则调用triggerRefValue。可以猜到这两个方法就是实现响应式的关键,接下来我们看看他们的具体实现

// 收集 ref 依赖 调用trackEffects(dep)
export function trackRefValue(ref: RefBase<any>) {
  // 如果当前开启了跟踪
  if (isTracking()) {
    // 获取raw ref数据
    ref = toRaw(ref)
    // 如果当前ref还未初始化dep则创建
    if (!ref.dep) {
      ref.dep = createDep()
    }
    // 如果是开发环境,则传入track细节, 
    if (__DEV__) {
      trackEffects(ref.dep, {
        target: ref,
        type: TrackOpTypes.GET,
        key: 'value'
      })
    } else {
      trackEffects(ref.dep)
    }
  }
}

// 触发 ref 调用trackEffects(dep)
export function triggerRefValue(ref: RefBase<any>, newVal?: any) {
  // 获取raw ref数据
  ref = toRaw(ref)
  // 如果当前ref 有关联的dep
  if (ref.dep) {
    // 如果当前是开发环境则发送具体触发细节
    if (__DEV__) {
      triggerEffects(ref.dep, {
        target: ref,
        // SET引起的变化
        type: TriggerOpTypes.SET,
        key: 'value',
        newValue: newVal
      })
    } else {
      triggerEffects(ref.dep)
    }
  }
}

  trackRefValue会在适当的时候初始化dep并调用trackEffectstriggerRefValue会获取refdep并调用triggerEffects,就是我们上面说的内容。

  大家注意到传入的ref会调用toRaw方法来重新赋值,这个方法是获取reactive的原始数据的。因为用户可能使用reactive(ref(raw))来获取数据,如果直接使用可能会收集到dep属性的依赖。另外大家思考一下下面这段代码的effect监听函数会触发几次?

const countRef = ref(0)
const reCount = reactive(countRef)

effect(() => {
  console.log(reCount.value)
})

reCount.value = 3

  答案是四次,第一次是首次收集依赖,reactive会收到value的获取,存储value属性的dep附加到targetMap。然后调用ref.valueref在获取value时会调用trackRefValue,创建dep附加到自身属性上。注意ref.value返回this._value,这时候reactive收到_value属性的获取,存储_value属性的dep,附加到targetMap中。所以创建了三个dep。当发生更改新值存储到ref._value中,而对于reactive来说value_value是完全没关联的所以会触发两次,而ref自身会触发一次没所以一共是四次。

customRef

  接下来我们看看自定义ref方法customRef是如何实现的:

// 自定义ref对象类
class CustomRefImpl<T> {
  // 依赖dep 存储effets
  public dep?: Dep = undefined

  // 缓存getter setter
  private readonly _get: ReturnType<CustomRefFactory<T>>['get']
  private readonly _set: ReturnType<CustomRefFactory<T>>['set']

  // mark ref
  public readonly __v_isRef = true

  // 传入ref工厂函数
  constructor(factory: CustomRefFactory<T>) {
    // 构建getter setter,传入track trigger函数
    const { get, set } = factory(
      () => trackRefValue(this),
      () => triggerRefValue(this)
    )
    this._get = get
    this._set = set
  }

  get value() {
    return this._get()
  }

  set value(newVal) {
    this._set(newVal)
  }
}

// 创建自定义ref
export function customRef<T>(factory: CustomRefFactory<T>): Ref<T> {
  return new CustomRefImpl(factory) as any
}

  customRef会创建CustomRefImpl的一个实例并返回,CustomRefImpl的实现和Ref差不多,使用trackRefValuetriggerRefValuedepeffect关联实现响应式。不过CustomRefImpl会在工厂函数中传入trackRefValuetriggerRefValue,将收集依赖和触发执行权交给用户。让用户在适当的时候调用。在使用value时候调用生产的get方法,在设置value是调用生产的set方法。一般是在get的时候调用收集函数,set的时候触发函数。

toRefs和ObjectRefImpl

  我们在使用reactive时通过缓存属性值很可能会失去响应式特性。因为属性值可能是reactive不支持深入响应的值,这时候缓存属性值,或者是通过ES6解构出来的值是不具备响应特性的。比如在下面这两种使用方式:

const reuser = reactive({ name: 'bill', sex: '男' })
const { name } = reuser
const sex = reuser.sex

  这样的话就得一直使用reuser.name的方式来进行访问,vue有两个api能很好的解决这个问题,就是toReftoRefs

  toRef通过reactive代理和代理的某个属性生成为ref并且可以携带默认值。而toRefs根据reactive代理生成所有属性值为ref的对象。生成的refvalue是代理属性值的映射,两端更改都会实时同步,我们看看是如何使用的:

const reuser = reactive({ name: 'bill', sex: '男' })
const name = toRef(reuser, 'name', '未命名')
const { sex } = toRefs(reuser)

console.log(reuser.name, reuser.sex)  //bill 男
console.log(name.value, sex.value)    //bill 男

name.value = 'lzb'
sex.value = '女'

console.log(reuser.name, reuser.sex)  //lzb 女
console.log(name.value, sex.value)    //lzb 女

delete reuser.name

console.log(reuser.name, reuser.sex)  //undefined 女
console.log(name.value, sex.value)    //未命名 女

  接下来我们看看toReftoRefs的具体实现:

// 将proxy对象和目标的属性 转化为ref 并拥有默认值
class ObjectRefImpl<T extends object, K extends keyof T> {
  public readonly __v_isRef = true

  constructor(
    // 记录代理
    private readonly _object: T,
    // 要辅助的key
    private readonly _key: K,
    // 默认值
    private readonly _defaultValue?: T[K]
  ) {}

  // 获取value
  get value() {
    const val = this._object[this._key]
    return val === undefined ? (this._defaultValue as T[K]) : val
  }

  set value(newVal) {
    this._object[this._key] = newVal
  }
}

// 将proxy对象和目标的属性 转化为ref 并拥有默认值
export function toRef<T extends object, K extends keyof T>(
  object: T,
  key: K,
  defaultValue?: T[K]
): ToRef<T[K]> {
  const val = object[key]
  return isRef(val)
    ? val
    : (new ObjectRefImpl(object, key, defaultValue) as any)
}

// 将proxy对象所有属性转化为ref值
export function toRefs<T extends object>(object: T): ToRefs<T> {
  // 只有内部代理才能toRefs
  if (__DEV__ && !isProxy(object)) {
    console.warn(`toRefs() expects a reactive object but received a plain one.`)
  }
  // 分别对所有属性toRef
  const ret: any = isArray(object) ? new Array(object.length) : {}
  for (const key in object) {
    ret[key] = toRef(object, key)
  }
  return ret
}

  toRef会先查看proxy[key]是否是ref如果是的话直接返回,如果不是则创建ObjectRefImpl并且将参数传入,ObjectRefImpl会标识当前对象是ref类型 (通过__v_isRef属性) ,并且缓存proxykey和默认值。get value时直接通过proxy[key]来获取并返回,如果回去的值是undefined则使用默认值。set value时则通过proxy[key] = newVal来设置。

  toRefs则是将每个属性都调用一次没有默认值的toRef,并且返回与proxy一致的数据结构。

  为什么这里的ObjectRefImpl类不需要dep属性和收集依赖和触发更改呢?这是因为_object属性本身是proxy类型,当我们在使用proxy[key]就实现了收集依赖,在proxy[key] = newVal是就触发了更改。

其他辅助方法

  ref文件中还声明了其他辅助方法,比如triggerRef手动触发ref的更改使关联的effect重新执行收集函数;unref获取ref的原始值。这两个方法比较简单直接看源码即可,这里就不再讲解了。

// 手动触发 ref
export function triggerRef(ref: Ref) {
  // 开发环境用当前值做最新值变化
  triggerRefValue(ref, __DEV__ ? ref.value : void 0)
}

// 解构ref,直接返回value
export function unref<T>(ref: T | Ref<T>): T {
  return isRef(ref) ? (ref.value as any) : ref
}

  还有一个辅助方法proxyRefs,这个方法将一个对象直属属性内的所有ref属性值解构访问 (不需要通过value下标访问) 。什么是直属属性就是第一层属性,比如下方的代码:

const name = ref('bill')
const unUser = proxyRefs({
  name: name,
  adderss: {
    city: ref('珠海')
  }
})

console.log(unUser.name)  // bill
console.log(unUser.address.city)  // Ref

unUser.name = 'lzb'

console.log(unUser.name)  // lzb 

proxyRefs只对第一层属性的ref解构。我们看看它的源码:

// 浅解构ref处理器
const shallowUnwrapHandlers: ProxyHandler<any> = {
  // getter将unref方便访问
  get: (target, key, receiver) => {
    return unref(Reflect.get(target, key, receiver))
  },
  // setter先查看是否是ref,如果是则更新value
  set: (target, key, value, receiver) => {
    const oldValue = target[key]
    // 如果新数据不是ref但旧数据是则更新value
    if (isRef(oldValue) && !isRef(value)) {
      oldValue.value = value
      return true
    } else {
      return Reflect.set(target, key, value, receiver)
    }
  }
}

// 创建代理ref 解构方便使用
export function proxyRefs<T extends object>(
  objectWithRefs: T
): ShallowUnwrapRef<T> {
  // 如果是reactive对象则无需解构
  return isReactive(objectWithRefs)
    ? objectWithRefs
    : new Proxy(objectWithRefs, shallowUnwrapHandlers)
}

  当proxyRefs入参是reactive对象时则直接返回,reactive对象本身会对ref解构,而且是深度的,这里就不需要处理。有个特殊情况shallowReactive对象不会对ref解构,但是也直接返回了,也就是说这个方法对shallowReactive对象时无效的!

  如果proxyRefs入参不是reactive对象,则创建代理,get拦截器通过unref来获取值返回,set拦截器通过判断当前要更新的是否是ref如果是则更新value

  到这里我们ref的所有内容就已经讲完了,接下来日常小结。

小结

  • ref对象自身附加了dep,在收集依赖时通过trackEffects函数,触发时通过triggerEffects函数
  • ref能够创建深度响应式是依赖了reactive
  • Proxy代理对象可以通过toReftoRefs辅助方法保持对单个属性的引用,赋值修改会映射到Proxy
  • customRef函数可以创建自由度极高的响应式对象

如何使用javascript模拟鼠标滚动事件?我在自动化测试中的实践与坑

最近在做一些自动化测试的时候,遇到了一个需求:需要模拟用户在浏览网页时的滚动操作。我的目标是通过 JavaScript 模拟鼠标滚动事件,从而触发页面上的懒加载或滚动监听器。我发现页面的内容区域是通过一个有固定高度的容器滚动的,window.scrollTop 完全没有任何反应。我才意识到,不能总是监听 window 的滚动,很多情况下我们监听的是某个具体的元素。又引发了一些新的问题,能不能触发事件,但是不滚动元素?如何判断是否已经滚动到了页面的最底部?如何判断一个元素是否可以滚动呢?

场景一:直接调用滚动API,实现页面或元素的实际滚动

在我进行自动化测试或实现某些交互效果时,最常用、最直接的做法就是调用浏览器提供的滚动API,比如 scrollByscrollToscrollIntoView。这些 API 无需模拟用户行为,直接改变页面或元素的滚动位置,具备良好的兼容性和控制力。

下面是我经常用的一个简单例子,用于让页面平滑滚动:

const deltaY = 100;
window.scrollBy({
  top: deltaY,
  left: 0,
  behavior: 'smooth' // 可选参数,实现平滑滚动
});

这个方法的原理很简单:直接改变页面或元素的滚动位置,适合控制页面滚动条、实现锚点跳转、滚动动画等需求。也可以替换掉 window,针对具体的 DOM 元素进行滚动:

const container = document.getElementById('scrollableContainer');
container.scrollTop += 100;

但是,会不会触发 scroll 事件?:

这点经常被误解。其实只要滚动位置发生了变化(即 scrollTop 或 scrollLeft 改变),scroll 事件就会触发,无论是用户滚动还是代码控制。

📚 官方说明:

  • MDN - Element.scrollTo():“This method triggers the scroll event if the element’s scroll position changes.”
  • MDN - scroll event:“The scroll event is fired when the document view or an element has been scrolled.”

但是有时候会在调用滚动 API 后未看到事件触发,我给大家一些思考的方向:

  • scroll 事件绑定的元素跟实际触发滚动的元素不是同一个元素
  • 元素本身没有实际发生滚动;
  • 滚动事件被节流(debounce/throttle)过滤了;
  • 懒加载逻辑使用的是 IntersectionObserver 而不是 scroll

这就引出了我思考的问题:如果我只是想触发滚动监听器,而不是实际滚动页面,应该怎么做?


场景二:模拟滚动事件,触发监听器但不移动页面

后来我发现,在某些测试场景下,我们只需要“让代码以为用户滚动了”,并不要求实际滚动页面。比如测试埋点逻辑是否触发、验证动画联动是否响应,或者检查某些组件在监听滚动时的表现。这种情况下,用事件模拟是更优雅的选择。

我们可以通过构造 WheelEvent 的方式,来模拟鼠标滚动操作,并显式触发:

function simulateMouseScroll(element, deltaY) {
    // 创建一个模拟的WheelEvent
    var event = new WheelEvent('wheel', {
        deltaY: deltaY, // 正数表示向下滚动,负数表示向上滚动
        bubbles: true, // 事件是否应该冒泡
        cancelable: true, // 事件是否可以取消
    });

    // 触发这个事件
    element.dispatchEvent(event);
}

// 使用示例
var myElement = document.getElementById('yourElementId');
simulateMouseScroll(myElement, 100); // 模拟向下滚动100个

注意:

模拟 WheelEvent 只会触发 wheel 事件监听器,不会自动引起元素滚动,也不会触发 scroll 事件,除非你在 wheel 的处理器中显式调用了 scrollTo / scrollBy 等方法。

📚 官方说明:

MDN - WheelEvent :“Dispatching a WheelEvent manually does not automatically scroll the page unless your code explicitly does so.”

在现代浏览器中,为了防止脚本模拟用户行为带来安全问题,WheelEvent 可能被限制,无法完全模拟真实的滚动行为。因为这些事件通常由用户交互触发,且浏览器的安全策略可能会阻止非用户触发的这类事件。

请注意,由于安全策略和浏览器差异,这种方法可能不会在所有场景下都能按预期工作,尤其是当涉及到跨域iframe或受到严格安全策略限制的环境。在实际应用中,应当首先考虑使用元素的滚动属性和方法来直接改变滚动位置。


两种滚动模拟方式,选哪种?

  • 想让页面真的滚动,就用原生滚动 API(推荐)。
  • 只想触发 wheel 相关逻辑(如交互响应、监听测试),可以模拟事件。
  • 注意:懒加载通常依赖的是 IntersectionObserver,而不是 scroll 事件。

滚动相关的常见判断逻辑:我怎么判断滚动到底了?

另一个让我经常用到的逻辑是:页面是否滚动到底了?

最常见的场景是懒加载、无限滚动、滚动触底提示等。我的实现是比较常规的三段式判断:当前滚动距离 + 可视区域高度 与页面总高度的对比。

function isBottomReached() {
    // 获取窗口的滚动位置
    var scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop;

    // 获取整个文档的高度(可视区域加上滚动出去的部分)
    var docHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);

    // 获取可视窗口的高度
    var winHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;

    // 判断是否滚动到底部
    return scrollTop + winHeight >= docHeight;
}

配合滚动事件监听器使用,就能实现动态判断了:

window.addEventListener("scroll", function() {
    if (isBottomReached()) {
        console.log("滚动到了页面最底部!");
        // 可以在这里触发懒加载、打点等逻辑
    }
});

怎么判断某个元素是否支持滚动?

还有一个需求也很常见——我需要判断一个元素是否具有滚动能力。这个判断在做组件封装时尤其有用,比如判断内容区域是否超出容器、是否该展示滚动条或加载更多。

以下是我自己封装的判断函数:

function canElementScroll(selector) {
    // 根据传入的selector(类名或ID)查找DOM元素
    const element = document.querySelector(selector);

    // 检查是否找到了元素
    if (!element) {
        console.error(`无法找到匹配的元素:${selector}`);
        return false;
    }

    // 检查元素是否可以在水平方向上滚动
    const canScrollHorizontally = element.scrollWidth > element.clientWidth;

    // 检查元素是否可以在垂直方向上滚动
    const canScrollVertically = element.scrollHeight > element.clientHeight;

    // 如果元素可以在任一方向上滚动,则返回true
    return canScrollHorizontally || canScrollVertically;
}

// 使用示例
// 通过类名判断
const canScrollByClass = canElementScroll('.scrollable-class');
if (canScrollByClass) {
    console.log('通过类名找到的元素支持滚动');
} else {
    console.log('通过类名找到的元素不支持滚动');
}

// 通过ID判断
const canScrollById = canElementScroll('#scrollable-id');
if (canScrollById) {
    console.log('通过ID找到的元素支持滚动');
} else {
    console.log('通过ID找到的元素不支持滚动');
}

你可以传入 .class#id 来判断任意元素的滚动能力。

一分钟解决一道高频面试算法题——移动零(双指针)

前置知识(可跳过)

1.splice() 方法

splice() 方法用于修改数组的内容,可以删除、替换或添加元素。它会直接修改原始数组。

语法:

array.splice(start, deleteCount, item1, item2, ...)

参数:

  • start: 必需。指定修改开始的位置(索引)。

    • 如果 start 超出数组的长度,则从数组末尾开始添加元素。
    • 如果 start 是负数,则从数组末尾开始计算,例如 -1 表示数组的最后一个元素。
  • deleteCount: 可选。指定要删除的元素数量。

    • 如果 deleteCount 为 0,则不删除任何元素。
    • 如果 deleteCount 大于 start 之后的元素总数,则从 start 开始删除所有元素。
    • 如果省略 deleteCount,则从 start 开始删除所有元素。
  • item1, item2, ...: 可选。要添加到数组中的元素。从 start 位置开始插入。

返回值:

splice() 方法返回一个包含被删除元素的数组。如果没有删除任何元素,则返回一个空数组。

2.push() 方法

push() 方法用于将一个或多个元素添加到数组的末尾,并返回修改后的数组的新长度。

语法:

  
array.push(item1, item2, ...);

参数:

  • item1, item2, ...: 要添加到数组末尾的元素。

返回值:

push() 方法返回修改后的数组的新长度。

二、题目描述——移动零

给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。

请注意 ,必须在不复制数组的情况下原地对数组进行操作。

示例 1:

输入: nums = [0,1,0,3,12]
输出: [1,3,12,0,0]

示例 2:

输入: nums = [0]
输出: [0]

提示:

  • 1 <= nums.length <= 104
  • -231 <= nums[i] <= 231 - 1

进阶: 你能尽量减少完成的操作次数吗?

三、题解

本人错误解法

var moveZeroes = function(nums) {
    let count = 0;
    for(let i=0;i<nums.length;i++){
        if(nums[i]===0){
            nums.splice(i,1);
            count++;
        }
    }
    for(let i=0; i<count;i++){
            nums.push(0)
    }
    return nums;
};

为何错误

1.splice 导致索引错乱:  这是最主要的问题。 当你在循环中使用 splice(i, 1) 删除一个元素后,数组的长度会减小,并且从 i 之后的所有元素的索引都会向前移动一位。 这会导致以下问题:

-   **跳过元素:**  如果 `nums[i]` 是 0, 你删除了它。然后,原来在 `nums[i+1]` 的元素会移动到 `nums[i]`。 但是,循环会直接进入 `i+1` 的下一轮迭代。 这样就跳过了原来 `nums[i+1]` (现在在 `nums[i]` 的位置) 的元素,导致可能没有正确处理它。
-   **越界访问:**  极端情况下,当数组末尾有连续的 0 时, 可能会导致数组越界访问。

2.未完全满足原地操作的要求:  虽然结果是在原数组上修改的,但在循环中使用 splice 操作涉及到数组的重构,严格意义上来说,不算是最高效的原地操作 (虽然题目没有明确禁止使用)。

正确题解一

/**
 * @param {number[]} nums
 * @return {void} Do not return anything, modify nums in-place instead.
 */
var moveZeroes = function(nums) {
    let insertPos = 0; //指向下一个要插入非零元素的位置

    // 第一次遍历:将所有非零元素移动到数组的前面
    for (let i = 0; i < nums.length; i++) {
        if (nums[i] !== 0) {
            nums[insertPos] = nums[i];
            insertPos++;
        }
    }

    // 第二次遍历:将数组剩余的位置填充为 0
    while (insertPos < nums.length) {
        nums[insertPos] = 0;
        insertPos++;
    }
};

  • 思路

    1. 把所有非零的元素挪到数组的前面,保持它们的相对顺序。
    2. 剩下的位置都用零填充。
  • 具体步骤

    1.第一次遍历(收集非零元素)

    • insertPos 相当于一个指针,它始终指向下一个应该放置非零元素的位置。
    • 当 nums[i] 不是 0 时,把它放到 nums[insertPos] 的位置上。 然后, insertPos 向后移动一位,准备存放下一个非零元素。
    • 循环结束后, insertPos 前面的所有元素都变成了非零元素,并且保持了它们在原数组中的相对顺序。 关键是,insertPos 也记录了非零元素有多少个。

    2.第二次遍历(填充零)

    • 从 insertPos 开始到数组的末尾,把所有元素都设置为 0。 因为第一次遍历后,insertPos 指向的位置以及后面的所有位置,原来都是零或者被非零元素覆盖掉的位置,现在应该填充为零.

正确题解二(最优题解)

function moveZeroes(nums) {
  if (!nums || nums.length === 0) {
    return;
  }

  let left = 0;
  for (let right = 0; right < nums.length; right++) {
    if (nums[right] !== 0) {
      if (left !== right) { // 避免不必要的交换
        nums[left] = nums[right];
        nums[right] = 0; // 直接将 right 位置设为 0
      }
      left++;
    }
  }
}
  • 核心思路

    1. 使用两个指针 left 和 rightleft 指向下一个非零元素应该放置的位置, right 用于遍历数组。
    2. right 指针遍历数组遇到非零元素时,将其移动到 left 指针指向的位置。
    3. 移动后,right 原来的位置设置为0。 这就保证了所有非零元素都在数组的前面,而所有零都在数组的后面。
    4. 关键优化: 避免不必要的交换。 只有当 left 和 right 指针不相等时,才进行交换操作。 这可以减少当非零元素已经在正确位置上时的操作。
  • 具体步骤分解

    1. 初始化

      • left = 0left 指针初始化为 0,表示数组的起始位置,也就是下一个非零元素应该放的位置。
    2. 遍历数组

      • right 指针从数组的起始位置开始,向右移动,遍历整个数组。

      • 如果 nums[right] !== 0 (遇到非零元素)

        • 检查是否需要交换if (left !== right) 。 如果 left 和 right 指向不同的位置,说明 right 指针当前指向的非零元素需要移动到 left 指针指向的位置。
        • 交换元素nums[left] = nums[right]; nums[right] = 0; 将 right 指针指向的非零元素移动到 left 指针指向的位置,并将 right 指针指向的位置设置为 0。 这一步是关键,它保证了零被移动到数组的末尾。
        • left++left 指针向右移动一位,指向下一个非零元素应该放置的位置。
    3. 遍历完成: 当 right 指针遍历完整个数组后,所有非零元素都被移动到了数组的前面,而所有零都被移动到了数组的末尾。

  • 为什么这个解法更优?

    • 一次遍历: 只需要一次遍历即可完成操作,提高了效率。
    • 减少交换次数: 通过 if (left !== right) 的判断,避免了不必要的交换操作,进一步提高了效率。 例如,如果数组前几个元素都是非零元素,left 和 right 会同步前进,此时不需要进行任何交换。
    • 原地修改: 直接在原数组上操作,空间复杂度为 O(1)。
  • 对比前一个解法

    • 前一个解法使用了两次遍历,第一次遍历找到非零元素并移动,第二次遍历填充零。
    • 这个解法只使用一次遍历,通过一次遍历完成元素的移动和零的填充。

实例与展示

image.png

image.png

屏幕截图 2025-04-19 092628.png

image.png

四、结语

再见

webpack 检出图 第 四 节 ChunkGraph.js

🔧 模块资源类型相关(SourceTypes)

  • setChunkModuleSourceTypes(chunk, module, sourceTypes)
    为某个 chunk 中的某个模块设置 sourceTypes,并更新缓存。
  • getChunkModuleSourceTypes(chunk, module)
    获取某个 chunk 中某个模块的 sourceTypes,优先从缓存中取,默认使用模块自身的值。
  • getModuleSourceTypes(module)
    获取某个模块在其所有 chunk 中合并后的 sourceTypes,用于跨 chunk 判断。
  • _getOverwrittenModuleSourceTypes(module)
    内部方法:合并所有包含该模块的 chunk 中的 sourceTypes,如果类型不同就合并成一个新集合。

📦 Chunk 模块集合操作

  • getOrderedChunkModulesIterable(chunk, comparator)
    获取某个 chunk 的模块集合(可迭代),并按给定排序函数排序。
  • getOrderedChunkModulesIterableBySourceType(chunk, sourceType, comparator)
    获取指定 chunk 中指定 sourceType 类型的模块集合(可迭代、已排序)。
  • getChunkModules(chunk)
    获取 chunk 中所有模块的数组(无序、缓存版本,不可修改)。
  • getOrderedChunkModules(chunk, comparator)
    获取 chunk 中所有模块的数组(有序、缓存版本,不可修改)。

🔗 Chunk 模块 ID/Hash 映射

  • getChunkModuleIdMap(chunk, filterFn, includeAllChunks = false)
    遍历 chunk 的所有异步子 chunk(或所有引用 chunk),获取符合条件的模块 ID 映射表。
    格式:{ chunkId: [moduleId, ...] }
  • getChunkModuleRenderedHashMap(chunk, filterFn, hashLength = 0, includeAllChunks = false)
    获取 chunk 的模块 ID 到模块渲染 hash 的映射表,可截断 hash 长度。
    格式:{ chunkId: { moduleId: hash } }
/**
 * 设置指定 chunk 中指定模块的 sourceTypes(资源类型集合)
 * @param {Chunk} chunk chunk 实例
 * @param {Module} module 模块实例
 * @param {Set<string>} sourceTypes 模块的资源类型集合
 */
setChunkModuleSourceTypes(chunk, module, sourceTypes) {
// 获取与 chunk 相关联的内部结构
const cgc = this._getChunkGraphChunk(chunk);
// 如果还未初始化 sourceTypesByModule,就创建一个 WeakMap
if (cgc.sourceTypesByModule === undefined) {
cgc.sourceTypesByModule = new WeakMap();
}
// 设置模块对应的资源类型集合
cgc.sourceTypesByModule.set(module, sourceTypes);
// 通过 modulesBySourceType 工具函数重新计算反向映射缓存
cgc._modulesBySourceType = modulesBySourceType(cgc.sourceTypesByModule);
}

/**
 * 获取某个 chunk 中某个模块的 sourceTypes
 * @param {Chunk} chunk chunk 实例
 * @param {Module} module 模块实例
 * @returns {SourceTypes} 模块对应的资源类型集合
 */
getChunkModuleSourceTypes(chunk, module) {
const cgc = this._getChunkGraphChunk(chunk);
// 如果没有缓存映射,则返回模块默认的资源类型
if (cgc.sourceTypesByModule === undefined) {
return module.getSourceTypes();
}
// 否则优先使用缓存的 sourceTypes,如果不存在则 fallback
return cgc.sourceTypesByModule.get(module) || module.getSourceTypes();
}

/**
 * 获取某个模块所有 chunk 中合并后的 sourceTypes
 * @param {Module} module 模块实例
 * @returns {SourceTypes} 合并后的资源类型集合
 */
getModuleSourceTypes(module) {
// 优先返回合并覆盖值,否则返回模块默认 sourceTypes
return (
this._getOverwrittenModuleSourceTypes(module) || module.getSourceTypes()
);
}

/**
 * 遍历所有包含该模块的 chunk,合并其 sourceTypes
 * @param {Module} module 模块实例
 * @returns {Set<string> | undefined} 合并后的资源类型集合
 */
_getOverwrittenModuleSourceTypes(module) {
let newSet = false; // 是否需要创建新的 Set 以合并 sourceTypes
let sourceTypes; // 合并后的结果

// 遍历包含该模块的所有 chunk
for (const chunk of this.getModuleChunksIterable(module)) {
const cgc = this._getChunkGraphChunk(chunk);
// 如果没有定义 sourceTypesByModule,则说明未设置,直接返回
if (cgc.sourceTypesByModule === undefined) return;
const st = cgc.sourceTypesByModule.get(module); // 获取该 chunk 上该模块的 sourceTypes
if (st === undefined) return;

if (!sourceTypes) {
// 第一次赋值,直接使用引用
sourceTypes = st;
continue;
} else if (!newSet) {
// 如果之前未创建新 Set,检查是否需要扩展集合
for (const type of st) {
if (!sourceTypes.has(type)) {
// 一旦发现有新增的类型,就新建 Set 并标记
newSet = true;
sourceTypes = new Set(sourceTypes);
sourceTypes.add(type);
}
}
} else {
// 如果已经是新 Set,则直接添加所有类型
for (const type of st) sourceTypes.add(type);
}
}

return sourceTypes; // 返回合并后的资源类型
}

/**
 * 获取某个 chunk 中模块列表的可迭代对象,并根据传入的 comparator 排序
 * @param {Chunk} chunk chunk 实例
 * @param {function(Module, Module): -1|0|1} comparator 排序函数
 * @returns {Iterable<Module>} 排序后的模块集合
 */
getOrderedChunkModulesIterable(chunk, comparator) {
const cgc = this._getChunkGraphChunk(chunk);
// 对模块集合进行排序
cgc.modules.sortWith(comparator);
// 返回排序后的模块列表
return cgc.modules;
}

/**
 * 获取指定 sourceType 的模块列表,并排序
 * @param {Chunk} chunk chunk 实例
 * @param {string} sourceType 要筛选的资源类型
 * @param {function(Module, Module): -1|0|1} comparator 排序函数
 * @returns {Iterable<Module> | undefined} 对应资源类型的排序模块列表
 */
getOrderedChunkModulesIterableBySourceType(chunk, sourceType, comparator) {
const cgc = this._getChunkGraphChunk(chunk);
// 通过缓存中的 sourceType → modules 获取指定类型模块集合
const modulesWithSourceType = cgc.modules
.getFromUnorderedCache(cgc._modulesBySourceType)
.get(sourceType);
if (modulesWithSourceType === undefined) return;
// 对该模块集合排序
modulesWithSourceType.sortWith(comparator);
return modulesWithSourceType;
}

/**
 * 获取某个 chunk 中所有模块(缓存版本,不排序,不能修改)
 * @param {Chunk} chunk chunk 实例
 * @returns {Module[]} 模块列表数组
 */
getChunkModules(chunk) {
const cgc = this._getChunkGraphChunk(chunk);
// 获取缓存数组(无序)
return cgc.modules.getFromUnorderedCache(getArray);
}

/**
 * 获取某个 chunk 中排序后的模块列表(缓存版本,不可修改)
 * @param {Chunk} chunk chunk 实例
 * @param {function(Module, Module): -1|0|1} comparator 排序函数
 * @returns {Module[]} 排序后的模块数组
 */
getOrderedChunkModules(chunk, comparator) {
const cgc = this._getChunkGraphChunk(chunk);
const arrayFunction = createOrderedArrayFunction(comparator);
// 获取排序后的模块数组(缓存中)
return cgc.modules.getFromUnorderedCache(arrayFunction);
}

/**
 * 生成 chunk → 模块 ID 数组 的映射表
 * @param {Chunk} chunk 源 chunk
 * @param {ModuleFilterPredicate} filterFn 模块过滤函数
 * @param {boolean} includeAllChunks 是否包含所有引用的 chunk(true)或仅 async chunks(false)
 * @returns {Record<string|number, (string|number)[]>} 映射表
 */
getChunkModuleIdMap(chunk, filterFn, includeAllChunks = false) {
const chunkModuleIdMap = Object.create(null);

// 遍历所有异步 chunk 或所有引用的 chunk
for (const asyncChunk of includeAllChunks
? chunk.getAllReferencedChunks()
: chunk.getAllAsyncChunks()) {
let array;

// 遍历该 chunk 中的模块(按 ID 排序)
for (const module of this.getOrderedChunkModulesIterable(
asyncChunk,
compareModulesById(this)
)) {
// 模块满足过滤条件
if (filterFn(module)) {
if (array === undefined) {
array = [];
// 初始化该 chunk 对应的模块 ID 列表
chunkModuleIdMap[asyncChunk.id] = array;
}
// 获取模块 ID 并加入数组
const moduleId = this.getModuleId(module);
array.push(moduleId);
}
}
}

return chunkModuleIdMap;
}

/**
 * 获取 chunk → moduleId → 渲染 hash 的映射表
 * @param {Chunk} chunk 源 chunk
 * @param {ModuleFilterPredicate} filterFn 模块过滤函数
 * @param {number} hashLength hash 截断长度(0 表示不截断)
 * @param {boolean} includeAllChunks 是否包含所有引用的 chunk
 * @returns {Record<string|number, Record<string|number, string>>} 最终映射表
 */
getChunkModuleRenderedHashMap(
chunk,
filterFn,
hashLength = 0,
includeAllChunks = false
) {
const chunkModuleHashMap = Object.create(null);

for (const asyncChunk of includeAllChunks
? chunk.getAllReferencedChunks()
: chunk.getAllAsyncChunks()) {
let idToHashMap;

for (const module of this.getOrderedChunkModulesIterable(
asyncChunk,
compareModulesById(this)
)) {
if (filterFn(module)) {
if (idToHashMap === undefined) {
idToHashMap = Object.create(null);
chunkModuleHashMap[asyncChunk.id] = idToHashMap;
}
const moduleId = this.getModuleId(module);
const hash = this.getRenderedModuleHash(module, asyncChunk.runtime);
// 可能对 hash 进行截断(用于缩短输出)
idToHashMap[moduleId] = hashLength
? hash.slice(0, hashLength)
: hash;
}
}
}

return chunkModuleHashMap;
}

Lodash源码阅读-baseSetToString

Lodash 源码阅读-baseSetToString

概述

baseSetToString 是一个内部工具函数,用于设置函数的 toString 方法。它通过 defineProperty 为函数添加一个自定义的 toString 方法,如果 defineProperty 不可用,则返回原函数。这个函数在 Lodash 中主要用于为内部函数提供更好的调试信息。

前置学习

依赖函数

  • defineProperty:定义对象属性的方法
  • identity:返回第一个参数的函数
  • constant:返回固定值的函数

技术知识

  • 函数 toString:函数的字符串表示
  • 属性描述符:对象属性的配置选项
  • 函数元数据:函数的附加信息

源码实现

var baseSetToString = !defineProperty
  ? identity
  : function (func, string) {
      return defineProperty(func, "toString", {
        configurable: true,
        enumerable: false,
        value: constant(string),
        writable: true,
      });
    };

实现思路

baseSetToString 函数的主要目的是为函数设置自定义的 toString 方法。它通过以下步骤实现:

  1. 检查 defineProperty 是否可用
  2. 如果不可用,返回原函数
  3. 如果可用,使用 defineProperty 为函数添加 toString 方法
  4. 使用 constant 函数确保 toString 始终返回相同的字符串

这种实现方式确保了函数在调试时能够提供有用的信息,同时保持了向后兼容性。

源码解析

函数签名:

function baseSetToString(func, string)
  • func: 要设置 toString 方法的函数
  • string: toString 方法要返回的字符串

实现细节:

var baseSetToString = !defineProperty
  ? identity
  : function (func, string) {
      return defineProperty(func, "toString", {
        configurable: true,
        enumerable: false,
        value: constant(string),
        writable: true,
      });
    };
  • 检查 defineProperty 是否可用
  • 如果不可用,返回原函数
  • 如果可用,使用 defineProperty 为函数添加 toString 方法
  • 设置 toString 方法的属性描述符:
    • configurable: true,允许重新定义
    • enumerable: false,不可枚举
    • value: 使用 constant 函数返回固定字符串
    • writable: true,允许修改

应用场景

  1. 函数调试
// 为函数添加调试信息
function add(a, b) {
  return a + b;
}

const addWithInfo = baseSetToString(
  add,
  "function add(a, b) { return a + b; }"
);
console.log(addWithInfo.toString()); // 'function add(a, b) { return a + b; }'
  1. 函数元数据
// 为函数添加元数据
function processData(data) {
  // 处理数据
}

const processWithMeta = baseSetToString(
  processData,
  "processData: 数据处理函数 v1.0"
);
console.log(processWithMeta.toString()); // 'processData: 数据处理函数 v1.0'
  1. 函数标识
// 为函数添加唯一标识
function createUniqueId() {
  return Math.random().toString(36).substr(2, 9);
}

const id = createUniqueId();
const identifiedFunction = baseSetToString(
  createUniqueId,
  `createUniqueId #${id}`
);
console.log(identifiedFunction.toString()); // 'createUniqueId #xxx'
  1. 兼容性处理
// 处理不支持 defineProperty 的环境
function safeSetToString(func, string) {
  return baseSetToString(func, string);
}

const func = function () {};
const result = safeSetToString(func, "custom function");
console.log(result === func); // 在不支持 defineProperty 的环境中为 true

总结

通过学习 baseSetToString 函数,我们可以看到以下设计原则:

  1. 兼容性:处理不支持 defineProperty 的环境。

  2. 可配置性:允许自定义函数的 toString 方法。

  3. 元数据:为函数添加有用的调试信息。

  4. 安全性:使用 defineProperty 安全地设置属性。

  5. 代码复用:将通用的函数元数据设置逻辑抽象出来。

baseSetToString 函数虽然简单,但它在 Lodash 中扮演着重要角色,为内部函数提供了更好的调试信息和元数据支持。

Lodash源码阅读-constant

Lodash 源码阅读-constant

概述

constant 是一个简单的工具函数,用于创建一个始终返回固定值的函数。这个函数在函数式编程中非常有用,特别是在需要提供默认值或占位符函数的场景中。

前置学习

依赖函数

无直接依赖函数。

技术知识

  • 闭包:JavaScript 中的闭包概念
  • 高阶函数:返回函数的函数
  • 函数式编程:纯函数和不变性概念

源码实现

function constant(value) {
  return function () {
    return value;
  };
}

实现思路

constant 函数的实现非常简单,它通过闭包捕获传入的值,并返回一个新的函数。这个新函数在每次调用时都会返回被捕获的原始值。这种实现方式体现了函数式编程中的纯函数特性,因为返回的函数总是返回相同的值,没有任何副作用。

源码解析

函数签名:

function constant(value)
  • value: 要返回的固定值

实现细节:

function constant(value) {
  return function () {
    return value;
  };
}
  • 函数接收一个参数 value
  • 返回一个新的函数
  • 新函数通过闭包访问 value
  • 每次调用新函数都返回相同的 value

应用场景

  1. 默认值处理
// 创建一个返回默认配置的函数
const defaultConfig = constant({
  timeout: 5000,
  retry: 3,
  debug: false,
});

const config = defaultConfig();
console.log(config); // { timeout: 5000, retry: 3, debug: false }

总结

通过学习 constant 函数,我们可以看到以下设计原则:

  1. 简单性:函数实现简单明了,只做一件事。

  2. 纯函数:返回的函数是纯函数,没有副作用。

  3. 闭包应用:利用闭包特性保存值。

  4. 实用性:虽然简单,但在很多场景下非常有用。

Lodash源码阅读-defineProperty

Lodash 源码阅读-defineProperty

概述

defineProperty 是一个内部工具函数,用于安全地获取和使用 Object.defineProperty 方法。它通过尝试获取并测试原生 defineProperty 方法来确保其可用性,如果不可用则返回 undefined。这个函数在 Lodash 中主要用于定义对象属性的特性。

前置学习

依赖函数

  • getNative:获取对象的原生方法

技术知识

  • Object.defineProperty:定义对象属性的方法
  • 属性描述符:对象属性的配置选项
  • 错误处理:try-catch 的使用
  • 立即执行函数:IIFE 的使用

源码实现

var defineProperty = (function () {
  try {
    var func = getNative(Object, "defineProperty");
    func({}, "", {});
    return func;
  } catch (e) {}
})();

实现思路

defineProperty 函数的主要目的是安全地获取和使用 Object.defineProperty 方法。它通过以下步骤实现:

  1. 使用 getNative 获取原生的 Object.defineProperty 方法
  2. 尝试使用该方法定义一个空对象的属性
  3. 如果成功则返回该方法,如果失败则返回 undefined

这种实现方式确保了返回的是可用的 defineProperty 方法,避免了在不支持的环境中出错。

源码解析

实现细节:

var defineProperty = (function () {
  try {
    var func = getNative(Object, "defineProperty");
    func({}, "", {});
    return func;
  } catch (e) {}
})();
  • 使用立即执行函数(IIFE)创建闭包
  • 使用 getNative 获取原生的 defineProperty 方法
  • 尝试使用该方法定义一个空对象的属性
  • 如果成功则返回该方法,如果失败则返回 undefined
  • 使用 try-catch 处理可能的错误

应用场景

  1. 定义不可枚举属性
// 定义不可枚举的属性
const obj = {};
if (defineProperty) {
  defineProperty(obj, "hidden", {
    value: "secret",
    enumerable: false,
  });
}
console.log(Object.keys(obj)); // []
console.log(obj.hidden); // 'secret'
  1. 定义只读属性
// 定义只读属性
const config = {};
if (defineProperty) {
  defineProperty(config, "apiKey", {
    value: "12345",
    writable: false,
  });
}
config.apiKey = "67890"; // 静默失败
console.log(config.apiKey); // '12345'
  1. 定义 getter/setter
// 定义 getter/setter
const person = {};
if (defineProperty) {
  let _age = 0;
  defineProperty(person, "age", {
    get: function () {
      return _age;
    },
    set: function (value) {
      _age = value > 0 ? value : 0;
    },
  });
}
person.age = 25;
console.log(person.age); // 25
person.age = -10;
console.log(person.age); // 0
  1. 兼容性处理
// 处理不支持 defineProperty 的环境
function definePropertySafe(obj, key, descriptor) {
  if (defineProperty) {
    defineProperty(obj, key, descriptor);
  } else {
    obj[key] = descriptor.value;
  }
}

总结

通过学习 defineProperty 函数,我们可以看到以下设计原则:

  1. 安全性:确保获取的是可用的 defineProperty 方法。

  2. 健壮性:处理不支持 defineProperty 的环境。

  3. 兼容性:提供回退机制处理不兼容的情况。

  4. 错误处理:使用 try-catch 优雅地处理可能的错误。

  5. 代码复用:将通用的属性定义逻辑抽象出来。

webpack 检出图 第 三 节 ChunkGraph.js

这段代码是 Webpack 构建阶段“模块与输出代码结构之间关系图”的核心实现之一,提供了模块替换、关系查询、chunk 构成等核心功能支持。

🌐 核心概念

  • Module: 表示一个模块(通常是一个 JS 文件)。
  • Chunk: 表示一组打包后的模块代码块(可能异步加载)。
  • ChunkGraphModule (cgm): 模块在图中的元信息(如所属 chunk、是否是入口模块等)。
  • ChunkGraphChunk (cgc): chunk 在图中的元信息(如包含的模块、运行时模块等)。
  • RuntimeModule: 专用于 runtime 的模块(如 webpack_require 的实现)。
  • Entrypoint: webpack 的入口定义,表示某个模块是 chunk 的起始点。

🧠 核心方法功能总结

✅ 1. 模块替换逻辑

  • replaceModule(oldModule, newModule)

    • oldModule 在所有 chunk 中替换为 newModule,确保 graph 中所有引用和结构正确更新。

    • 涉及:

      • 普通模块 (modules)
      • 入口模块 (entryModules)
      • 运行时模块 (runtimeModules, fullHashModules, dependentHashModules)

🔍 2. 查询模块是否在某处

  • isModuleInChunk(module, chunk):模块是否在指定 chunk 中
  • isModuleInChunkGroup(module, chunkGroup):模块是否在某个 chunkGroup(多个 chunk)中
  • isEntryModule(module):模块是否是某个 chunk 的入口模块

🔄 3. 获取模块关联的 chunks

  • getModuleChunksIterable(module):获取某模块对应的 chunk 集合(可迭代)
  • getOrderedModuleChunksIterable(module, sortFn):按指定排序方式返回 chunk 集合
  • getModuleChunks(module):获取模块的 chunk(缓存版本)
  • getNumberOfModuleChunks(module):获取模块关联 chunk 的数量

⚙️ 4. 获取模块的运行时环境

  • getModuleRuntimes(module):返回模块对应的运行时环境集合

📦 5. 获取 chunk 内模块的信息

  • getNumberOfChunkModules(chunk):chunk 中模块的总数
  • getNumberOfChunkFullHashModules(chunk):chunk 中用于 full hash 的模块数量
  • getChunkModulesIterable(chunk):chunk 中的模块集合
  • getChunkModulesIterableBySourceType(chunk, sourceType):按 sourceType 获取 chunk 中的模块集合

🧩 设计亮点

  • 使用了 _getChunkGraphModule_getChunkGraphChunk 来统一获取抽象结构,封装性强。
  • 模块和 chunk 的关系被抽象成图状结构,极易扩展、分析依赖。
  • 全面缓存(getFromCache, getFromUnorderedCache)优化性能,适合大项目构建场景。
  • 支持运行时代码模块(RuntimeModule)的替换与追踪,对 HMR 和 runtime 拆分支持好。
/**
 * @param {Module} oldModule the replaced module
 * @param {Module} newModule the replacing module
 * @returns {void}
 */
replaceModule(oldModule, newModule) { // 将旧模块 oldModule 替换为新模块 newModule
const oldCgm = this._getChunkGraphModule(oldModule); // 获取旧模块对应的 ChunkGraphModule 对象
const newCgm = this._getChunkGraphModule(newModule); // 获取新模块对应的 ChunkGraphModule 对象

for (const chunk of oldCgm.chunks) { // 遍历旧模块所在的所有 chunk
const cgc = this._getChunkGraphChunk(chunk); // 获取 chunk 对应的 ChunkGraphChunk 对象
cgc.modules.delete(oldModule); // 从 chunk 的模块集合中移除旧模块
cgc.modules.add(newModule); // 将新模块添加到该 chunk 的模块集合中
newCgm.chunks.add(chunk); // 将 chunk 添加到新模块的 chunk 集合中
}
oldCgm.chunks.clear(); // 清空旧模块的 chunk 集合

if (oldCgm.entryInChunks !== undefined) { // 如果旧模块在某些 chunk 中是入口模块
if (newCgm.entryInChunks === undefined) { // 如果新模块还没有 entryInChunks 属性
newCgm.entryInChunks = new Set(); // 初始化 entryInChunks 集合
}
for (const chunk of oldCgm.entryInChunks) { // 遍历旧模块是入口模块的 chunk 集合
const cgc = this._getChunkGraphChunk(chunk); // 获取 chunk 对应的 ChunkGraphChunk
const old = /** @type {Entrypoint} */ (cgc.entryModules.get(oldModule)); // 获取旧模块对应的入口配置
/** @type {Map<Module, Entrypoint>} */
const newEntryModules = new Map(); // 创建新的 entryModules 映射
for (const [m, cg] of cgc.entryModules) { // 遍历原有的 entryModules 映射
if (m === oldModule) { // 如果是旧模块
newEntryModules.set(newModule, old); // 替换为新模块
} else {
newEntryModules.set(m, cg); // 否则保持原样
}
}
cgc.entryModules = newEntryModules; // 替换 entryModules
newCgm.entryInChunks.add(chunk); // 将该 chunk 添加到新模块的 entryInChunks 集合
}
oldCgm.entryInChunks = undefined; // 清空旧模块的 entryInChunks
}

if (oldCgm.runtimeInChunks !== undefined) { // 如果旧模块被当作 runtime module 使用
if (newCgm.runtimeInChunks === undefined) { // 如果新模块没有 runtimeInChunks 属性
newCgm.runtimeInChunks = new Set(); // 初始化 runtimeInChunks 集合
}
for (const chunk of oldCgm.runtimeInChunks) { // 遍历旧模块所在的 runtime chunk
const cgc = this._getChunkGraphChunk(chunk); // 获取 chunk 对应的 ChunkGraphChunk
cgc.runtimeModules.delete(/** @type {RuntimeModule} */ (oldModule)); // 从 runtimeModules 中移除旧模块
cgc.runtimeModules.add(/** @type {RuntimeModule} */ (newModule)); // 添加新模块
newCgm.runtimeInChunks.add(chunk); // 将 chunk 添加到新模块的 runtimeInChunks

if (
cgc.fullHashModules !== undefined &&
cgc.fullHashModules.has(/** @type {RuntimeModule} */ (oldModule))
) { // 如果 chunk 的 fullHashModules 包含旧模块
cgc.fullHashModules.delete(/** @type {RuntimeModule} */ (oldModule)); // 删除旧模块
cgc.fullHashModules.add(/** @type {RuntimeModule} */ (newModule)); // 添加新模块
}
if (
cgc.dependentHashModules !== undefined &&
cgc.dependentHashModules.has(/** @type {RuntimeModule} */ (oldModule))
) { // 如果 chunk 的 dependentHashModules 包含旧模块
cgc.dependentHashModules.delete(
/** @type {RuntimeModule} */ (oldModule)
);
cgc.dependentHashModules.add(
/** @type {RuntimeModule} */ (newModule)
);
}
}
oldCgm.runtimeInChunks = undefined; // 清空旧模块的 runtimeInChunks
}
}

/**
 * @param {Module} module the checked module
 * @param {Chunk} chunk the checked chunk
 * @returns {boolean} true, if the chunk contains the module
 */
isModuleInChunk(module, chunk) { // 判断模块是否存在于指定 chunk 中
const cgc = this._getChunkGraphChunk(chunk); // 获取 chunk 对应的 ChunkGraphChunk
return cgc.modules.has(module); // 判断模块集合中是否包含该模块
}

/**
 * @param {Module} module the checked module
 * @param {ChunkGroup} chunkGroup the checked chunk group
 * @returns {boolean} true, if the chunk contains the module
 */
isModuleInChunkGroup(module, chunkGroup) { // 判断模块是否存在于指定 ChunkGroup 中
for (const chunk of chunkGroup.chunks) { // 遍历 chunkGroup 中的所有 chunk
if (this.isModuleInChunk(module, chunk)) return true; // 只要有一个 chunk 包含模块则返回 true
}
return false; // 所有 chunk 都不包含模块则返回 false
}

/**
 * @param {Module} module the checked module
 * @returns {boolean} true, if the module is entry of any chunk
 */
isEntryModule(module) { // 判断模块是否为入口模块
const cgm = this._getChunkGraphModule(module); // 获取 ChunkGraphModule
return cgm.entryInChunks !== undefined; // 判断是否存在 entryInChunks 集合
}

/**
 * @param {Module} module the module
 * @returns {Iterable<Chunk>} iterable of chunks (do not modify)
 */
getModuleChunksIterable(module) { // 获取模块所在的 chunk 集合(可迭代)
const cgm = this._getChunkGraphModule(module); // 获取 ChunkGraphModule
return cgm.chunks; // 返回 chunks 集合
}

/**
 * @param {Module} module the module
 * @param {function(Chunk, Chunk): -1|0|1} sortFn sort function
 * @returns {Iterable<Chunk>} iterable of chunks (do not modify)
 */
getOrderedModuleChunksIterable(module, sortFn) { // 获取排序后的模块 chunk 集合(可迭代)
const cgm = this._getChunkGraphModule(module); // 获取 ChunkGraphModule
cgm.chunks.sortWith(sortFn); // 使用传入的排序函数进行排序
return cgm.chunks; // 返回排序后的 chunks 集合
}

/**
 * @param {Module} module the module
 * @returns {Chunk[]} array of chunks (cached, do not modify)
 */
getModuleChunks(module) { // 获取模块所在的 chunk 数组(使用缓存)
const cgm = this._getChunkGraphModule(module); // 获取 ChunkGraphModule
return cgm.chunks.getFromCache(getArray); // 从缓存中获取数组结果
}

/**
 * @param {Module} module the module
 * @returns {number} the number of chunk which contain the module
 */
getNumberOfModuleChunks(module) { // 获取包含该模块的 chunk 数量
const cgm = this._getChunkGraphModule(module); // 获取 ChunkGraphModule
return cgm.chunks.size; // 返回 chunk 集合的大小
}

/**
 * @param {Module} module the module
 * @returns {RuntimeSpecSet} runtimes
 */
getModuleRuntimes(module) { // 获取模块关联的运行时环境集合
const cgm = this._getChunkGraphModule(module); // 获取 ChunkGraphModule
return cgm.chunks.getFromUnorderedCache(getModuleRuntimes); // 从缓存中获取运行时集合
}

/**
 * @param {Chunk} chunk the chunk
 * @returns {number} the number of modules which are contained in this chunk
 */
getNumberOfChunkModules(chunk) { // 获取 chunk 中的模块数量
const cgc = this._getChunkGraphChunk(chunk); // 获取 ChunkGraphChunk
return cgc.modules.size; // 返回模块集合大小
}

/**
 * @param {Chunk} chunk the chunk
 * @returns {number} the number of full hash modules which are contained in this chunk
 */
getNumberOfChunkFullHashModules(chunk) { // 获取参与 chunk full hash 的模块数量
const cgc = this._getChunkGraphChunk(chunk); // 获取 ChunkGraphChunk
return cgc.fullHashModules === undefined ? 0 : cgc.fullHashModules.size; // 返回集合大小或 0
}

/**
 * @param {Chunk} chunk the chunk
 * @returns {Iterable<Module>} return the modules for this chunk
 */
getChunkModulesIterable(chunk) { // 获取 chunk 中模块的可迭代集合
const cgc = this._getChunkGraphChunk(chunk); // 获取 ChunkGraphChunk
return cgc.modules; // 返回模块集合
}

/**
 * @param {Chunk} chunk the chunk
 * @param {string} sourceType source type
 * @returns {Iterable<Module> | undefined} return the modules for this chunk
 */
getChunkModulesIterableBySourceType(chunk, sourceType) { // 获取指定 sourceType 的模块集合
const cgc = this._getChunkGraphChunk(chunk); // 获取 ChunkGraphChunk
const modulesWithSourceType = cgc.modules // 从缓存中获取按 sourceType 分类的模块集合
.getFromUnorderedCache(cgc._modulesBySourceType)
.get(sourceType); // 获取指定类型的模块集合
return modulesWithSourceType; // 返回结果
}

解析noble-winrt

背景 在js项目中需要操作蓝牙设备。找到一个window上使用的js类库(noble-winrt - npm). 今天就来扒一扒noble-winrt的实现方式,以备查看。 如何在js中启动BleSe

React-router v7 第六章(路由操作)

路由操作

路由的操作是由两个部分组成的:

  • loader
  • action

在平时工作中大部分都是在做增刪查改(CRUD)的操作,所以一个界面的接口过多之后就会使逻辑臃肿复杂,难以维护,所以需要使用路由的高级操作来优化代码。

loader

useLoaderData速查文档

只有GET请求才会触发loader,所以适合用来获取数据

在之前的话我们是 RenderComponent(渲染组件)-> Fetch(获取数据)-> RenderView(渲染视图)

有了loader之后是 loader(通过fetch获取数据) -> useLoaderData(获取数据) -> RenderComponent(渲染组件)

//router/index.tsx
import { createBrowserRouter } from "react-router";
const router = createBrowserRouter([
  {
    path: "/",
    Component: App,
    loader: async () => {
      const data = await response.json();
      const response = await getUser(data); // [!code highlight] 获取数据
      return {
        data: response.list,
        message: "success",
      }
    },
  },
]);
//App.tsx
import { useLoaderData } from "react-router";
const App = () => {
  const { data, message } = useLoaderData(); // [!code highlight] 获取数据
  return <div>{data}</div>;
}

action

一般用于表单提交,删除,修改等操作。

useSubmit速查文档 useActionData速查文档

只有POST DELETE PATCH PUT等请求才会触发action,所以适合用来提交表单

//router/index.tsx
import { createBrowserRouter } from "react-router";
const router = createBrowserRouter([
    {
        // path: '/index',
        Component: Layout,
        children: [
            {
                path: 'about',
                Component: About,
                action: async ({ request }) => {
                    const formData = await request.formData();
                    await createUser(formData); // [!code highlight] 创建用户
                    return {
                        data: table,
                        success: true
                    }
                }
            },
        ],
    },
]);
//App.tsx
import { useSubmit } from 'react-router';
import { Card, Form, Input, Button } from 'antd';
export default function About() {
  const submit = useSubmit();
  return <Card>
    <Form onFinish={(values) => {
      submit(values, { method: 'post'}) // [!code highlight]  提交表单
    }}>
      <Form.Item name='name' label='姓名'>
        <Input />
      </Form.Item>
      <Form.Item name='age' label='年龄'>
        <Input />
      </Form.Item>
      <Button type='primary' htmlType='submit'>提交</Button>
    </Form>
  </Card>;
}

状态变更

我们可以配合useNavigation来管理表单提交的状态

useNavigation速查文档

  1. GET提交会经过以下状态:
idle -> loading -> idle
  1. POST提交会经过以下状态:
idle -> submitting ->loading -> idle

所以我们可以根据这些状态来控制disabled loading 等行为

import { useNavigation, useSubmit } from "react-router";
const submit = useSubmit();
const navigation = useNavigation();

return (
    <div>
         {navigation.state === 'loading' && <div>loading...</div>}
        <button disabled={navigation.state === 'submitting'}>提交</button>
    </div>
)

CSS考点之权重计算🧑🏻‍💻

引言💭

在 CSS 中,权重决定了哪条规则会应用到 HTML 元素。当多个规则作用于同一元素时,浏览器会根据权重来判断哪一条规则优先执行。

这也是一个面试高频考题,赶紧码住。✍🏻

什么是 CSS 权重?🤔

CSS 权重是一个数值,用来表示某个选择器的优先级。浏览器根据该优先级来决定哪些规则被应用。CSS 权重的计算是根据选择器的类型(例如:元素、类、ID 选择器等)来确定的。每种选择器类型对应一个特定的权重值,而这些权重值会按照一定的规则叠加。

权重通常用四个数字表示,形式为 (a, b, c, d)。这四个数字分别代表不同类型选择器的权重:

  • a: 内联样式的权重,优先级最高。
  • b: ID 选择器的权重。
  • c: 类选择器、属性选择器和伪类选择器的权重。
  • d: 元素选择器和伪元素选择器的权重。

CSS 权重计算的详细规则📑

  1. 内联样式style="..."):

    • 权重:(1, 0, 0, 0),内联样式的优先级最高。
    • 示例:<div style="color: red;">内容</div>
  2. ID 选择器

    • 权重:(0, 1, 0, 0),每个 ID 选择器的权重为 1
    • 示例:#header { color: red; }#menu { color: blue; }
  3. 类选择器、属性选择器和伪类选择器

    • 权重:(0, 0, 1, 0),每个类选择器、属性选择器和伪类选择器的权重为 1
    • 示例:.menu { color: green; }[type="text"] { color: orange; }:hover { color: purple; }
  4. 元素选择器和伪元素选择器

    • 权重:(0, 0, 0, 1),每个元素选择器和伪元素选择器的权重为 1
    • 示例:div { color: blue; }p { color: red; }::before { content: ''; }

权重计算示例📌

假设有以下的 CSS 规则:

#header { color: red; }
.menu { color: green; }
div { color: blue; }

对应的 HTML 元素为:

<div id="header" class="menu">Hello</div>

根据权重计算,浏览器会按照以下方式决定哪个规则生效:

  1. #header { color: red; }

    • 权重:(0, 1, 0, 0),使用了 ID 选择器。
  2. .menu { color: green; }

    • 权重:(0, 0, 1, 0),使用了类选择器。
  3. div { color: blue; }

    • 权重:(0, 0, 0, 1),使用了元素选择器。

由于 ID 选择器的权重最高((0, 1, 0, 0)),最终文本的颜色会是 红色,即 #header 规则生效。

权重递增与选择器的组合🌈

多个选择器组合时,它们的权重会叠加。例如:

#header .menu div { color: purple; }

此时,#header .menu div 的权重是 (0, 1, 1, 1),它包含了一个 ID 选择器(#header)、一个类选择器(.menu)和一个元素选择器(div)。它的权重为:

  • ID 选择器:1
  • 类选择器:1
  • 元素选择器:1

因此,最终权重为 (0, 1, 1, 1)

内联样式的优先级🎢

内联样式具有最高的优先级,权重为 (1, 0, 0, 0)。即使在外部样式表中存在其他选择器,内联样式也会覆盖它们。

例如,以下 HTML 元素的内联样式将覆盖任何外部样式:

<div id="header" class="menu" style="color: yellow;">Hello</div>

即使外部 CSS 中有 #header { color: red; }.menu { color: green; },内联样式 style="color: yellow;" 仍然会应用,并使文本的颜色变为 黄色

选择器优先级的具体比较🚀

假设以下 CSS 规则:

#header { color: red; }
.menu { color: green; }
div { color: blue; }

以及 HTML 元素:

<div id="header" class="menu">Hello</div>

在此情况下,CSS 规则的权重:

  • #header { color: red; } 的权重为 (0, 1, 0, 0)
  • .menu { color: green; } 的权重为 (0, 0, 1, 0)
  • div { color: blue; } 的权重为 (0, 0, 0, 1)

因此,最终会应用 #header { color: red; },因为它的权重最大,红色会覆盖其他颜色。

结语✒️

在编写 CSS 时,尽量避免使用过多的高权重选择器,尤其是 ID 选择器,以保持样式的可维护性。如果你遇到样式覆盖问题,首先检查选择器的权重,确保它们按正确的顺序排列。🫰🏻

猫抓爱心 (2).gif

vue与react(自定义)中计算属性对比

一、核心概念对比

特性 Vue (Computed) React (useMemo/useCallback)
定义方式 声明式(基于依赖自动缓存) 命令式(手动声明依赖数组)
缓存机制 自动缓存(依赖不变则复用) 手动控制(依赖数组变化时重新计算)
响应式触发 依赖变更自动触发 需严格定义依赖数组
语法复杂度 低(模板中直接使用) 中(需配合Hooks使用)
适用场景 模板渲染优化、派生状态 性能优化、复杂计算避免重复执行

二、原理剖析

1. Vue Computed 原理

实现方式‌:基于响应式系统的依赖追踪

// Vue 3 源码简化版
function computed(getter) {
  let value;
  let dirty = true; // 标记是否需要重新计算

  const runner = effect(getter, {
    lazy: true,
    scheduler() {
      dirty = true; // 依赖变化时标记为脏数据
    }
  });

  return {
    get value() {
      if (dirty) {
        value = runner(); // 重新计算
        dirty = false;
      }
      return value;
    }
  };
}

关键点‌:

  • 依赖变更时通过scheduler标记数据为dirty
  • 下次访问时按需重新计算(惰性求值)

2. React useMemo 原理

实现方式‌:依赖数组的浅比较(Object.is)

// React 源码简化逻辑
function useMemo(factory, deps) {
  const hook = getCurrentHook();
  const nextDeps = deps || [];

  if (hook.memoizedState) {
    const [prevValue, prevDeps] = hook.memoizedState;
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevValue; // 依赖未变化时返回缓存值
    }
  }

  const newValue = factory();
  hook.memoizedState = [newValue, nextDeps];
  return newValue;
}

关键点‌:

  • 每次渲染时对比依赖数组
  • 依赖变化时重新执行工厂函数

三、代码实战对比

场景1:基础计算属性

Vue 实现

<template>
  <div>
    FullName: {{ fullName }} <!-- 自动缓存 -->
  </div>
</template>

<script>
export default {
  data() {
    return { firstName: '张', lastName: '三' };
  },
  computed: {
    fullName() {
      return this.firstName + ' ' + this.lastName; 
    }
  }
};
</script>

React 实现

function UserCard({ firstName, lastName }) {
  const fullName = useMemo(
    () => `${firstName} ${lastName}`,
    [firstName, lastName] // 需手动声明依赖
  );

  return <div>FullName: {fullName}</div>;
}

场景2:复杂数据过滤

Vue 实现

<template>
  <ul>
    <li v-for="user in activeUsers" :key="user.id">
      {{ user.name }}
    </li>
  </ul>
</template>

<script>
export default {
  data() {
    return {
      users: [
        { id: 1, name: 'Alice', isActive: true },
        { id: 2, name: 'Bob', isActive: false }
      ]
    };
  },
  computed: {
    activeUsers() {
      return this.users.filter(u => u.isActive); // 自动缓存结果
    }
  }
};
</script>

React 实现

function UserList({ users }) {
  const activeUsers = useMemo(
    () => users.filter(u => u.isActive),
    [users] // 注意:如果users引用不变但内容变化,需深度比较
  );

  return (
    <ul>
      {activeUsers.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

四、复杂场景处理

场景1:依赖动态变化

Vue 的自动依赖追踪

vueCopy Code
<script>
export default {
  data() {
    return { a: 1, b: 2, useA: true };
  },
  computed: {
    result() {
      return this.useA ? this.a : this.b; // 自动识别依赖切换
    }
  }
};
</script>

React 的显式依赖管理

function Calculator({ a, b, useA }) {
  const result = useMemo(
    () => (useA ? a : b),
    [useA, useA ? a : b] // 必须明确所有可能依赖
  );
  return <div>Result: {result}</div>;
}

场景2:计算属性链式调用

Vue 的链式计算

<script>
export default {
  data() {
    return { price: 100, taxRate: 0.1 };
  },
  computed: {
    tax() {
      return this.price * this.taxRate;
    },
    total() { // 可依赖其他计算属性
      return this.price + this.tax;
    }
  }
};
</script>

React 的逐层缓存

function Invoice({ price, taxRate }) {
  const tax = useMemo(() => price * taxRate, [price, taxRate]);
  const total = useMemo(() => price + tax, [price, tax]); // 需手动传递依赖

  return <div>Total: {total}</div>;
}

五、性能优化对比

Vue 的优势

  1. 自动缓存‌:无需手动维护依赖,适合模板中的派生数据
  2. 细粒度更新‌:依赖变更时精准触发相关组件更新

React 的优势

  1. 灵活控制‌:可结合useCallback缓存函数,避免子组件无效渲染
  2. 跨组件复用‌:通过自定义Hook共享计算逻辑
// React 自定义Hook复用
function useTotal(price, taxRate) {
  return useMemo(() => {
    const tax = price * taxRate;
    return price + tax;
  }, [price, taxRate]);
}

// 多个组件共享同一逻辑
function Cart() {
  const total = useTotal(100, 0.1);
  return <div>Total: {total}</div>;
}

六、使用场景推荐

框架 推荐场景
Vue 1. 模板中的复杂表达式优化 2. 需要自动追踪依赖的派生数据 3. 表单联动计算
React 1. 需要手动控制缓存时机 2. 跨组件共享计算逻辑 3. 结合Context的复杂状态派生

  • Vue Computed‌:适合声明式UI场景,‌ "省心" ‌但灵活性较低
  • React useMemo‌:适合精细控制,‌ "强大" ‌但需手动维护依赖
  • 终极选择‌:Vue适合快速开发,React适合大型应用状态管理

✈️ Colipot Agent + 🔥 MCP Tools = 让你的编程体验直接起飞🚀

前言

上篇文章主要介绍了使用 Mcp ts Sdk 搭建一个 MCP 应用,我们已经知道下面的概念和基本使用!

复习下 MCP 架构

我们并且在建立 Server 时添加了一个tool,根据关键字 公理 返回三体黑暗森林法则的两个公理!

命中了关键词

往期精彩推荐

正文

今天我们的主要目标是:将这个工具注册到 VsCode 中,并可以在 Agent 模式中使用它!

添加 Mcp Server

Ctrl+Shift+PCmd+Shift+P,输入并选择 MCP: Add Server

Ctrl+Shift+P

或者你也可以在 Colipot 中点击工具图标进入:

点击工具图标

添加 server

选择合适的类型

接着会让你选择服务类型,如果你是在本地,推荐使用 Stdio

选择合适的类型

  • Stdio 的优势Stdio 传输适合本地开发,避免网络复杂性,适合测试和调试。
  • 其他传输方式:虽然 VSCode 还支持 SSE(Server-Sent Events),但 Stdio 更适合本地场景

配置文件

根据自己的服务类型输入启动命令,比我上次我用的 js,这里会输入: node

node 或者 python

连续回车两次,第一次是自动生成的tool唯一标识,第二次会让你选择设置,建议选择workspace settings

workspace settings

VSCode 会在根目录下生成.vscode/mcp.json如下的配置文件:

配置文件

args 对应的是参数,比如执行文件目标地址!

使用

打开 Copilot Agent 视图,然后使用自然语言调用 MCP 服务器的工具。

识别到 tool

我们输入框里输入相关语句,VsCode 会自动查找对应的 tool

运行之后的结果

最后

通过 VsCode Colipot Agent 提供的能力,我们可以让我们的编辑期更加智能的为我们提供服务!

好了今天的分享就些了,十分感谢大家的支持,如果文章后中有错误的地方,欢迎指正!

往期精彩推荐

谷歌浏览器插件 录制菜单路由跳转行为 事件重复解决方案

背景

开发谷歌插件的过程中,监听用户浏览器操作行为,vue3 项目使用hash路由,发现在 background 脚本中通过chrome.tabs.onUpdated和chrome.webNavigation.onHistoryStateUpdated事件监听时,点击切换菜单路由时都会触发一个链接跳转事件,导致监听事件重复。

这里说明一下为什么在chrome.tabs.onUpdated和chrome.webNavigation.onHistoryStateUpdated事件都写了那个链接跳转事件,是因为发现在某些网站上有时候chrome.tabs.onUpdated会触发,有时候chrome.webNavigation.onHistoryStateUpdated会单独触发,为了兼容处理各种情况,所以就在这两个事件里都处理了链接跳转事件,结果引发了文章开头描述的问题

思考过程

引发上述问题可能的原因包括:

  1. 事件触发条件重叠:当Hash变化时,chrome.tabs.onUpdated可能因为URL变化而触发,而chrome.webNavigation.onHistoryStateUpdated则因为历史状态改变而触发,两者同时发生。
  2. 事件监听器的重复注册:可能在插件的不同地方多次注册了相同的监听器,导致重复触发。
  3. 事件传播或页面加载阶段的问题:比如在页面加载的不同阶段,两个事件可能被多次触发。

接下来,我需要考虑如何解决这个问题。可能的解决方案包括:

  • 合并事件监听:将两个事件的监听逻辑合并,避免重复处理。例如,将两个事件的回调函数统一到一个处理函数中,并添加防重机制。
  • 条件判断过滤:在事件回调中添加条件判断,比如检查URL变化是否是Hash变化,或者是否是用户主动触发的路由跳转,从而决定是否执行逻辑。
  • 使用防重或节流:在回调函数中使用防重(如setTimeout和clearTimeout)或节流来避免短时间内多次触发。
  • 选择更精确的事件监听:如果Hash路由主要通过hashchange事件触发,可能只需要监听chrome.webNavigation.onHistoryStateUpdated,而chrome.tabs.onUpdated可能包含更多的触发场景,可以考虑移除其中一个监听。(PASS,原因看开头背景说明)

解决方案

1. 合并事件监听逻辑,避免重复处理

// 合并后的处理函数
function handleUrlChange(details) {
  if (isHandling) return; // 防止重复触发
  isHandling = true;

  // 具体处理逻辑(如获取当前URL的Hash值,上报动作)
  
  // 具体处理逻辑

  // 延迟重置标志位,避免短时间内重复触发
  setTimeout(() => {
    isHandling = false;
  }, 200); // 根据需求调整延迟时间
}

// 监听 chrome.tabs.onUpdated
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
  if (changeInfo.url && changeInfo.url.includes('#')) {
    handleUrlChange({ url: changeInfo.url });
  }
});

// 监听 chrome.webNavigation.onHistoryStateUpdated
chrome.webNavigation.onHistoryStateUpdated.addListener((details) => {
  handleUrlChange(details);
});

2. 过滤非必要的触发条件

在回调函数中添加条件判断,比如 仅在Hash值变化时执行逻辑,根据自己项目实际情况进行判断。

function handleUrlChange(details) {
  const currentUrl = details.url || chrome.tabs.getSelected().url;
  const newHash = new URL(currentUrl).hash;

  // 获取上一次记录的Hash值(需维护一个变量)
  if (lastHash === newHash) return; // 如果Hash未变化,直接返回
  lastHash = newHash;

  // 执行实际逻辑
  console.log('Hash changed:', newHash);
}

关键注意事项

  1. 防重机制

    • 使用标志位(如isHandling)或节流函数(如setTimeout)防止短时间内重复触发。
  2. 状态维护

    • 通过lastHash记录上一次的Hash值,避免重复处理相同Hash的事件。

🚀🚀🚀 MCP SDK 快速接入 DeepSeek 并添加工具!万万没想到MCP这么简单好用!

前言

上次发的文章因为遇到 bug 没有执行完,现在正常了,其实是我传错参数了,所以重新修正下!

这篇文章记录一下我用 MCP TypeScript SDK 实现一个自包含的 AI 聊天应用的过程:内部包含 MCP 服务器提供上下文,客户端拿上下文再去调 LLM 接口拿回答!

往期精彩推荐

正文

MCP 是什么?

简单说,MCP 是一个给 AI 应用提供上下文的标准协议。你可以把它理解成一个服务标准,它规定了“资源”和“工具”的接口规范,然后通过客户端连接这些接口,就可以组合出丰富的上下文数据。比如说资源可以是“当前时间”、“用户历史记录”,工具可以是“数据库搜索”、“调用外部 API”。

它采用的是客户端-服务器架构,Server 暴露上下文能力,Client 拉取这些上下文,再拿去调语言模型生成回答,而 Transport 负责 ServerClient 的通信部分!

MCP 架构

(AI 帮我画的图)

其中图片中的 Transport 层还分为:

  • StdioServerTransport:用于 CLI 工具对接 stdin/stdout
  • SSEServerTransport:用于HTTP通信
  • StdioClientTransport:客户端以子进程方式拉起服务端,这个不常用

另外,Server 层分为:

  • Server 基本类:原始的类,适合自己定制功能!
  • McpServer基于Server 封装好了可以快速使用的方法!

注意:基本类和封装类的接口有很大不同,具体请参看 README 文件!

安装依赖

用的是官方的 TypeScript SDK

仓库:github.com/modelcontex…

官网:modelcontextprotocol.io

npm install @modelcontextprotocol/sdk axios

DeepSeek 没有官方 SDK,用的是 HTTP API,所以需要 axios

记得把 API Key 放到 .env 或直接配置成环境变量,我用的 DEEPSEEK_API_KEY

实现一个 McpServer

我们先实现一个本地 McpServer,实现两个东西:

  • 当前时间(资源)
  • 本地“知识库”搜索(工具)

代码如下:

// src/server.js
import {
  McpServer,
  ResourceTemplate,
} from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const facts = [
  "公理1: 生存是文明的第一需要.",
  "公理2: 文明不断增长和扩张,但宇宙中的物质总量保持不变.",
].map((f) => f.toLowerCase());
try {
  const server = new McpServer({
    name: "mcp-cli-server",
    version: "1.0.0",
  });

  // 使用 Zod 定义工具的输入模式
  server.tool(
    "search_local_database",
   {
      query: z.string(),
    },
    async ({ query }) => {
      console.log("Tool called with query:", query);
      const queryTerms = query.toLowerCase().split(/\s+/);
      const results = facts.filter((fact) =>
        queryTerms.some((term) => fact.includes(term))
      );
      return {
        content: [
          {
            type: "text",
            text: results.length === 0 ? "未找到相关公理" : results.join("\n"),
          },
        ],
      };
    }
  );


  // 定义资源
  server.resource(
    "current_time",
    new ResourceTemplate("time://current", { list: undefined }),
    async (uri) => ({
      contents: [{ uri: uri.href, text: new Date().toLocaleString() }],
    })
  );

  await server.connect(new StdioServerTransport());
  console.log("Server is running...");
} catch (err) {
  console.error("Server connection failed:", err);
}

这样一来,我们的服务端就能通过 MCP 协议对外暴露两个上下文能力了。

配置 MCP Client

MCP 的客户端用来连接服务器并获取资源或调用工具:

// src/client.js;
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

export async function createClient() {
  const client = new Client({
    name: "Demo",
    version: "1.0.0",
  });

  const transport = new StdioClientTransport({
    command: "node",
    args: ["src/server.js"],
  });

  try {
    await client.connect(transport);
    console.log("Client connected successfully");
  } catch (err) {
    console.error("Client connection failed:", err);
    throw err;
  }

  // 可选:添加客户端方法调用后的调试
  return client;
}

连上之后,我们就可以开始调用服务端的资源和工具了。

获取上下文

我们设定一个简单的逻辑:每次用户提问,客户端都会获取当前时间;如果问题里包含 公理,那就调用搜索工具查一下本地知识库:

async function getContext(client, question) {
  let currentTime = "";
  let additionalContext = "";

  try {
    const resources = await client.readResource(
      { uri: "time://current" },
      { timeout: 15000 }
    ); // 增加超时时间
    console.log("Resources response:", resources);
    currentTime = resources.contents[0]?.text ||
      new Date().toLocaleString(); // 注意:resources 直接包含 contents
  } catch (err) {
    console.error("Resource read error:", err);
    currentTime = new Date().toLocaleString();
  }

  if (question.toLowerCase().includes("公理")) {
    console.log("Searching for axioms...", question);
    try {
      const result = await client.getPrompt({
        name: "search_local_database",
        arguments: { query: question },
      });
      console.log("Tool result:", result);
      additionalContext = result?.[0]?.text || "No results found.";
    } catch (err) {
      console.error("Tool call error:", err);
      additionalContext = "Error searching database.";
    }
  }

  return { currentTime, additionalContext };
}

集成 DeepSeek,开始问答

DeepSeek 使用的是标准 OpenAI 接口风格,HTTP POST 请求即可。这里我们用 axios 调用:

import axios from "axios";

async function askLLM(prompt) {
  try {
    console.log("Calling LLM with prompt:", prompt);
    const res = await axios.post(
      "https://api.deepseek.com/chat/completions",
      {
        model: "deepseek-chat",
        messages: [{ role: "user", content: prompt }],
        max_tokens: 2048,
        stream: false,
        temperature: 0.7,
      },
      {
        headers: {
          Authorization: `Bearer ${process.env.DEEPSEEK_API_KEY}`,
          "Content-Type": "application/json",
        },
        timeout: 1000000,
      }
    );
    console.log("LLM response:", res.data);
    return res.data.choices[0].message.content;
  } catch (err) {
    console.error("LLM error:", err);
    return "Error calling LLM.";
  }
}

完整的代码,包含用命令行做一个简单的交互界面:

// src/index.js
import readline from "readline";
import axios from "axios";
import { createClient } from "./client.js";
import { DEEPSEEK_API_KEY } from "./config.js";

async function askLLM(prompt) {
  try {
    console.log("Calling LLM with prompt:", prompt);
    const res = await axios.post(
      "https://api.deepseek.com/chat/completions",
      {
        model: "deepseek-chat",
        messages: [{ role: "user", content: prompt }],
        max_tokens: 2048,
        stream: false,
        temperature: 0.7,
      },
      {
        headers: {
          Authorization: `Bearer ${DEEPSEEK_API_KEY}`,
          "Content-Type": "application/json",
        },
        timeout: 1000000,
      }
    );
    return res.data.choices[0].message.content;
  } catch (err) {
    console.error("LLM error:", err);
    return "Error calling LLM.";
  }
}

async function getContext(client, question) {
  let currentTime = "";
  let additionalContext = "";

  try {
    const resources = await client.readResource(
      { uri: "time://current" },
      { timeout: 15000 }
    ); // 增加超时时间
    currentTime = resources.contents[0]?.text || new Date().toLocaleString(); // 注意:resources 直接包含 contents
  } catch (err) {
    console.error("Resource read error:", err);
    currentTime = new Date().toLocaleString();
  }

  if (question.toLowerCase().includes("公理")) {
    try {
      // const result = await client.getPrompt({
      //   name: "search_local_database",
      //   arguments: { query: question },
      // });
      
      const toolResult = await client.callTool({
        name: "search_local_database",
        arguments: { query: question },
      });
      console.log("Tool result:", toolResult);
      additionalContext = toolResult?.content?.[0]?.text || "No results found.";
    } catch (err) {
      console.error("Tool call error:", err);
      additionalContext = "Error searching database.";
    }
  }

  return { currentTime, additionalContext };
}

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});

const client = await createClient();

while (true) {
  const question = await new Promise((resolve) =>
    rl.question("You: ", resolve)
  );
  if (question.toLowerCase() === "exit") {
    console.log("Exiting...");
    rl.close();
    process.exit(0);
  }
  // 使用上下文
  const context = await getContext(client, question);
  // 不使用上下文
  // const context = {};
  const prompt = `Time: ${context.currentTime}\nContext: ${context.additionalContext}\nQ: ${question}\nA:`;
  console.log("Prompt:", prompt);
  const answer = await askLLM(prompt);
  console.log('Assistant:', answer);
}

接着在终端运行:

# 启动服务器
node src/server.js
# 启动客户端
node src/index.js

运行结果:

未命中关键词

命中了关键词

一些注意点

这个项目虽然小,但也踩了些坑,顺便分享几点:

  • MCP SDK 的 server 和 client 都是异步启动的,别忘了加上 await connect()
  • 工具的入参和 schema 必须严格匹配,否则会抛错。

下面是我的目录结构,做个参考吧!

mcp-mini/
├── package.json
├── src/
│   ├── client.js
│   ├── server.js
│   └── index.js

最后

总的来说,MCP TypeScript SDK 用起来还是挺顺的,适合做一些轻量、模块化、支持上下文的 AI 应用。这种服务 + 客户端 + LLM 的组合模式挺适合本地测试,也方便后续扩展别的服务。

今天的分享就到这了,如果文章中有啥错误,欢迎指正!

往期精彩推荐

❌