deepfunc

deepfunc 查看完整档案

深圳编辑  |  填写毕业院校深圳超级猩猩  |  coder 编辑 github.com/deepfunc 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 该用户太懒什么也没留下

个人动态

deepfunc 发布了文章 · 10月5日

Vue2响应式原理解析(完结篇):侦听属性和总结

Hi 大家好,假期快乐鸭~ 咳咳,在前面两篇我们从设计出发讲了一下 Vue2 的响应式原理和实现,还有计算属性的详细解析等等。这一篇呢就是这个系列的最后一篇了,我们来聊一下侦听属性和 vm.$watch,再回到设计来总结一下 Vue2 的响应式。如果没有看过前面两篇的朋友先看了前面的再来哈,传送门:Vue2响应式原理解析(一):从设计出发Vue2响应式原理解析(二):计算属性揭秘

侦听属性 watch

关于侦听属性的使用我就不多了,相信大家都很熟练,无非就是定义个函数,当要监听的值发生变化时会回调这个函数。我们直接来康康关键代码是怎么实现的。打开 src/core/instance/state.js 文件,找到 initState

export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch) // 侦听属性的初始化是在最后。
  }
}

这个函数里面可以看到熟悉的 initDatainitComputed,前面已经讲过了。 initWatch 就是侦听属性初始化的函数了,这里注意一下 initWatch 是在最后调用的并传入了 vm 对象(其实就是要监听 vm 上的属性),这意味着侦听属性也是可以侦听到计算属性的变化哟initWatch 的内容很简单,我们只看关键的代码:

function initWatch (vm: Component, watch: Object) {
  for (const key in watch) {
    const handler = watch[key]
    if (Array.isArray(handler)) {
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i])
      }
    } else {
      // 看这里。
      createWatcher(vm, key, handler)
    }
  }
}

initWatch 遍历我们定义的 watch 对象属性,拿到每个属性的侦听函数 handler,并对其调用 createWatcher

function createWatcher (
  vm: Component,
  expOrFn: string | Function,
  handler: any,
  options?: Object
) {
  if (isPlainObject(handler)) {
    options = handler
    handler = handler.handler
  }
  if (typeof handler === 'string') {
    handler = vm[handler]
  }

  // 其实侦听属性最后就是用 $watch api 实现的。
  return vm.$watch(expOrFn, handler, options)
}

到这里我们发现原来侦听属性就是利用 vm.$watch 来实现的啦。

vm.$watch

&dollar;watch 函数是可以在 Vue 对象上调用的,所以定义在了 Vue 对象的原型上,具体在 stateMixin 函数中:

export function stateMixin (Vue: Class<Component>) {
  // ...

  Vue.prototype.$watch = function (
    expOrFn: string | Function,
    cb: any,
    options?: Object
  ): Function {
    const vm: Component = this
    // ...
    
    options = options || {}
    options.user = true
    const watcher = new Watcher(vm, expOrFn, cb, options)

    // 立即求值
    if (options.immediate) {
      try {
        cb.call(vm, watcher.value)
      } catch (error) {
        // ...
      }
    }
    return function unwatchFn () {
      watcher.teardown()
    }
  }
}

这里我们看到 vm.$watch 其实是生成了 Watcher 对象,有这几个地方要康康:

  • expOrFn,这里是需要监听的属性值。
  • options.user,表明是用户定义的,watcher 更新时会调用用户定义的回调函数 cb。
  • options.immediate,立即求值,一开始就会传递当前值到定义的侦听函数。
  • unwatchFn,可以手动关闭监听(当然定义的侦听属性不需要这个了)。

expOrFn

这里我重点说一下 expOrFn。expOrFn 是可以支持属性表达式的,按照 Vue 文档的说法:

观察 Vue 实例上的一个表达式或者一个函数计算结果的变化。

也就是说可以像这样去设置:

vm.$watch('a.b', function (newVal, oldVal) {
  // 属性 a 改变或者 a.b 改变都会触发
})

vm.$watch(
  function () {
    return this.c + this.d
  },
  function (newVal, oldVal) {
    // 属性 c 改变或者 d 改变都会触发
  }
)

第一种情况下 expOrFn 是表达式。在 src/core/observer/watcher.js 中找到 Watcher 的构造函数:

// ...
if (typeof expOrFn === 'function') {
  this.getter = expOrFn
} else {
  this.getter = parsePath(expOrFn)
  // ...
}
// ...

我们知道 watcher.getter 要求是个函数, parsePathsrc/core/util/lang.js 里面:

export function parsePath (path: string): any {
  // ...
  const segments = path.split('.')
  return function (obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]
    }
    return obj
  }
}

parsePath返回的确实是一个函数,内容是按照表达式的路径顺序去逐步获得路径上的属性(对象)以及最后的值。这里我们回忆下 defineReactive,按顺序去访问表达式路径上的属性会触发属性的 get,这样就建立了依赖关系(路径上的所有属性都会),当涉及的属性变化时就会通知 watcher 了~ 同理第二种情况下是一个函数也是一样的。剩下的怎么去通知 watcher 更新前面第一篇已经介绍过了,这里就不多说了。

设计总结

到这里 Vue2 响应式的主要内容解析就完结了,我们看了很多的代码,是时候来总结一下啦。不知道大家看源码学习的目的和感受是什么,我呢主要是关注作者的设计意图和权衡侧重点,当然还有一些实现的技巧之类。第一篇我们从设计出发,最后呢当然也要从设计来总结一下从 Vue2 响应式中学到了什么。

可重用的模块

Vue2 响应式实现的一个很赞的地方是把这套东西独立了出来,抽象出了 DepWatcher 等关键定义,基本上如果你是 Web 应用都可以直接拿去用。这告诉我们在实现之前要多考虑重用和抽象,这样你的实现才能发挥更大的价值。

巧妙的双向依赖设计与实现

在观察者模式中,一般我们只会在被观察者上记录观察者列表,等到需要时去通知观察者即可。而在 Vue2 中由于使用场景下依赖关系会发生变化(依赖关系收集在求值过程中),所以采用了双向依赖的设计与实现。这告诉我们设计模式不是死的,根据你想达到的目的去做灵活调整,我觉得这是 Vue2 响应式设计非常精彩的一个地方~

观察者模式的角色权重

从 Vue2 的设计和实现中我们可以看到,观察者模式中被观察者(属性和 Dep)和观察者(Watcher)的角色权重是不同的,从代码量也可以感受出来。被观察者主要是实现依赖的建立和通知机制,更抽象一些;而观察者则要根据实际场景加入更多的功能设计与实现,比如 Vue 中的计算属性缓存和 expOrFn 等。这也是提醒我们在实现自己的观察者模式场景中,具体的内容应该放在哪里有个参考。

最后

到这里 Vue2 响应式我想哔哔的东西已经全部讲完了,希望能给大家一点参考吧,水平有限,理解和解读难免会有错漏,欢迎指出哇。最近 Vue3.0 One Piece 也正式发布了,以后偶也会持续写一些 Vue3 的相关东东,下次再见~

欢迎 star 和关注我的 JS 博客:小声比比 JavaScript

查看原文

赞 1 收藏 1 评论 0

deepfunc 收藏了文章 · 8月31日

Vue2响应式原理解析(二):计算属性揭秘

Hi,大家好~ 在上一篇 Vue2响应式原理解析(一):从设计出发 中我讲了一下 Vue2 是如何抽象和设计响应式的, data 是如何实现响应式的,包括依赖收集和双向依赖记录的设计思路和关键代码。在这一篇中,我们来一起康康 Vue 中非常强大的响应式功能:计算属性。我主要会从功能需求的角度来分析计算属性的实现和关键代码,希望能带给大家一些在别的文章里看不到的东西吧。以下内容请先看过 第一篇 再来比较好~

计算属性 computed

在 Vue 的 文档 中有提到计算属性的设计初衷是为了解决模板内表达式过于复杂、难以理解。当然解决此问题还有一个方案就是用 methods 中定义的方法,但计算属性有个非常强大的特性:缓存。这意味着计算属性依赖的数据如果没有发生变化,则再次访问计算属性时就不会重新计算,直接返回缓存的结果,这对于计算复杂的场景非常实用。

那计算属性的实现怎么和前面我们讲过的响应式设计结合起来呢~?这里我们先看一张图:

image

这张图描述的就是当你声明了一个计算属性后(这里举的栗子就是声明 fullName 计算属性),Vue 转换成了图右边的 getter + watcher 的结构来实现计算属性的所有功能。如果看起来有点懵逼的话不要急,下面就来一一揭秘计算属性是如何实现和工作的。

实现细节

首先来到 src/core/instance/state.js 文件,有个 initComputed 函数,这个函数就是初始化计算属性的地方,下面我们来看看关键部分的代码:

function initComputed (vm: Component, computed: Object) {
  // vm 对象上增加了 _computedWatchers 存放计算属性对应的 watcher
  const watchers = vm._computedWatchers = Object.create(null)
  
  // ...
  
  for (const key in computed) {
    // 计算属性支持 setter,为了简洁说明重点我们只关注计算属性声明为函数的情况哈
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    
    // ...
    // 服务器端渲染的情况也先不关注哈
    if (!isSSR) {
      // 注意这里,每个计算属性对应生成了一个 watcher,并把计算属性的函数作为 getter 传进去了
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
      )
    }
    
    if (!(key in vm)) {
      // 这里就是在 vm 对象上定义计算属性的描述符了
      defineComputed(vm, key, userDef)
    }
    // ...
  }
}

这就是计算属性的主要实现过程。首先呢,我们先把视角拉高一点,只关注重点流程,不要陷入太多细节哈,细节后面会讲到。重点流程就是上面那张图上描述的:Vue 为每个计算属性生成了一个 watcher,并在 vm 对象上声明了跟计算属性同名的存取描述符,一会他们俩要配合使用。这里需要注意的是 Watcher 构造函数传入的 computedWatcherOptions,这个对象有个 lazy: true 的属性,待会就知道是干嘛用的了。

从计算属性的使用入手来讲缓存

下面就是计算属性实现的细节和精华部分了。首先我们来到上面说的 defineComputed 函数,依然去掉一些神马服务端渲染逻辑的干扰,只看主要实现细节的代码:

export function defineComputed (
  target: any,
  key: string,
  userDef: Object | Function
) {
  // 先不管服务器端渲染
  const shouldCache = !isServerRendering()
  if (typeof userDef === 'function') {
    // 注意这里调用了 createComputedGetter 来生成描述符的 getter
    sharedPropertyDefinition.get = shouldCache
      ? createComputedGetter(key)
      : createGetterInvoker(userDef)
    // ...
  }
  // ...
  
  // 在 vm 上生成计算属性同名的存取描述符
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

defineComputed 里我们看到,最终描述符的 get 是由 createComputedGetter 生成的,这个函数就是关键中的关键了~

在继续之前,我们先回想一下计算属性的使用场景和缓存的应用。通常我们定义好计算属性之后,就会在 template 里去使用。当界面第一次显示时,计算属性会计算值,除非计算属性的依赖项发生变化(比如:依赖的 data 对象的属性重新赋值了),否则后面的刷新不会导致计算属性重新计算,而是会直接返回上一次的缓存值。从这里可以看出,template 里去读取计算属性的值,实际上就是调用 vm 上计算属性描述符的 get 了。

理清了场景后,我们把 createComputedGetter 分为两部分来看,先关注跟缓存相关的前半部分代码:

function createComputedGetter (key) {
  return function computedGetter () {
    // 首先在这里取到 vm 上的 watcher
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate()
      }

      // ...
      return watcher.value
    }
  }
}

上面代码中的 watcher.dirty 就是表示计算属性当前的值是否需要重新计算,如果不需要重新计算就直接返回 watcher.value 了,这就是实现了缓存的作用。那么这里我们回想一下计算属性在初始化 watcher 的时候传入了一个 lazy: true,并且在 Watcher 的构造函数中有这样的逻辑:

export default class Watcher {
  // ...
  
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    // ...
    this.lazy = !!options.lazy
    // ...
    this.dirty = this.lazy // 是不是脏了需要求值,初始化的时候就是 true
    //...
    // 如果是计算属性,初始化 watcher 不会求值
    this.value = this.lazy
      ? undefined
      : this.get()
  }
}

这个意思就是说:如果是计算属性,初始化 watcher 时不会求值,只会标记脏了——缓存无效。那么在上面计算属性的 get 第一次被调用时 watcher.dirty(界面第一次显示),会调用 watcher.evaluate()

evaluate () {
  this.value = this.get()
  this.dirty = false
}

evaluate 里就执行 get() 去求值了,并且标记缓存有效。回想一下当依赖的 dep 发生 set 时,会执行 watcher.update()

update () {
  if (this.lazy) {
    // 如果是计算属性,这里就会标记为 dirty
    this.dirty = true
  }
  // ...
}

所以依赖发生变化,缓存就会失效,计算属性又会重新计算了~

以上就是计算属性缓存的设计和实现细节了,我尽量只摘取关键代码把关键的事情说清楚。这个地方我们需要注意的是,Vue 把计算属性这种场景抽象成一种 lazy watcher,lazy watcher 只在需要的时候计算值,并且有缓存功能!所以抽象能力是值得我们学习的地方~

依赖传递

我们回过头来看看 createComputedGetter 的后半部分代码:

function createComputedGetter (key) {
  return function computedGetter () {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      // 前面代码是判断缓存是否要更新
      // ...
      
      // 注意这里就是依赖传递了
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

前半部分的缓存已经讲过了,现在我们注意这里有个依赖传递的逻辑,这是什么意思呢?

这里我们还是用场景来举例吧。比如在计算属性的声明中你是可以引用另外一个计算属性的!因为计算属性在初始化 watcher 时不会求值,也就是 lazy watcher,所以这样是没问题的。比如下面的代码:

const vm = new Vue({
  el: '#demo',
  data: {
    a: 1,
    b: 2
  },
  computed: {
    c: function () {
      return a + b
    },
    d: function () {
      return c * 2
    }
  }
})

这里就用这个简单的例子来说明为什么需要依赖传递:计算属性 c 依赖了 data 上的 ab,计算属性 d 又依赖了 c。那么问题来了,当 ab 发生改变时 cdirty,当然 d 也需要 dirty,不然 d 就会有缓存不会重新求值了。那么 d 怎么得到通知呢?

关键的代码就是上面的 watcher.depend() 了。首先,d 取值时会调用 d 自身的 watcher.get(),这个时候会把 dwatcher 设置为 Dep.target;接着 cwatcher.get() 执行时也会把 cwatcher 设置为 Dep.target。这里注意了,设置 Dep.target 时是调用的 pushTarget,这个函数会调用 targetStack 数组把当前已经记录的 Dep.target 推入数组:

Dep.target = null
const targetStack = []

export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

export function popTarget () {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

// 当时说了 targetStack 数组将在以后的文章中解析,这里圆回来了

执行完了这些之后呢,cwatcher 首先和 abdep 建立了依赖关系,然后求值 watcher.evaluate()。求值完后也就是 c 执行完自己的 watcher.get(),注意在 get() 方法的最后执行了 popTarget(),也就是说 c 把自己的 watcher 弹出来了,目前的 Dep.target 又变成了 dwatcher

c 求值完后,如果当前还有 Dep.target 存在就会执行 watcher.depend() 来传递依赖了,我们来看看 watcher.depend() 到底做了什么:

depend () {
  let i = this.deps.length
  while (i--) {
    this.deps[i].depend()
  }
}

代码的意思很明确了,就是让 c 当前依赖的这些 deps 也去建立与 Dep.target 的依赖(也就是 d 了)。这样当 ab 发生改变时 d 也会 dirty 了。

通过上面这个场景的描述你应该明白为什么需要依赖传递了吧~

结尾

以上呢就是 Vue 计算属性的细节和我的解读,如果有不清楚的请结合 第一篇 来看看。

到这里 Vue2 的响应式大体讲的就差不多了。后面还会再写一篇说说侦听属性,然后再回到设计从整体上巩固一下。如果有说的不对或有其他见解欢迎留言讨论哇~

欢迎 star 和关注我的 JS 博客:小声比比 JavaScript

查看原文

deepfunc 发布了文章 · 8月30日

Vue2响应式原理解析(二):计算属性揭秘

Hi,大家好~ 在上一篇 Vue2响应式原理解析(一):从设计出发 中我讲了一下 Vue2 是如何抽象和设计响应式的, data 是如何实现响应式的,包括依赖收集和双向依赖记录的设计思路和关键代码。在这一篇中,我们来一起康康 Vue 中非常强大的响应式功能:计算属性。我主要会从功能需求的角度来分析计算属性的实现和关键代码,希望能带给大家一些在别的文章里看不到的东西吧。以下内容请先看过 第一篇 再来比较好~

计算属性 computed

在 Vue 的 文档 中有提到计算属性的设计初衷是为了解决模板内表达式过于复杂、难以理解。当然解决此问题还有一个方案就是用 methods 中定义的方法,但计算属性有个非常强大的特性:缓存。这意味着计算属性依赖的数据如果没有发生变化,则再次访问计算属性时就不会重新计算,直接返回缓存的结果,这对于计算复杂的场景非常实用。

那计算属性的实现怎么和前面我们讲过的响应式设计结合起来呢~?这里我们先看一张图:

image

这张图描述的就是当你声明了一个计算属性后(这里举的栗子就是声明 fullName 计算属性),Vue 转换成了图右边的 getter + watcher 的结构来实现计算属性的所有功能。如果看起来有点懵逼的话不要急,下面就来一一揭秘计算属性是如何实现和工作的。

实现细节

首先来到 src/core/instance/state.js 文件,有个 initComputed 函数,这个函数就是初始化计算属性的地方,下面我们来看看关键部分的代码:

function initComputed (vm: Component, computed: Object) {
  // vm 对象上增加了 _computedWatchers 存放计算属性对应的 watcher
  const watchers = vm._computedWatchers = Object.create(null)
  
  // ...
  
  for (const key in computed) {
    // 计算属性支持 setter,为了简洁说明重点我们只关注计算属性声明为函数的情况哈
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    
    // ...
    // 服务器端渲染的情况也先不关注哈
    if (!isSSR) {
      // 注意这里,每个计算属性对应生成了一个 watcher,并把计算属性的函数作为 getter 传进去了
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
      )
    }
    
    if (!(key in vm)) {
      // 这里就是在 vm 对象上定义计算属性的描述符了
      defineComputed(vm, key, userDef)
    }
    // ...
  }
}

这就是计算属性的主要实现过程。首先呢,我们先把视角拉高一点,只关注重点流程,不要陷入太多细节哈,细节后面会讲到。重点流程就是上面那张图上描述的:Vue 为每个计算属性生成了一个 watcher,并在 vm 对象上声明了跟计算属性同名的存取描述符,一会他们俩要配合使用。这里需要注意的是 Watcher 构造函数传入的 computedWatcherOptions,这个对象有个 lazy: true 的属性,待会就知道是干嘛用的了。

从计算属性的使用入手来讲缓存

下面就是计算属性实现的细节和精华部分了。首先我们来到上面说的 defineComputed 函数,依然去掉一些神马服务端渲染逻辑的干扰,只看主要实现细节的代码:

export function defineComputed (
  target: any,
  key: string,
  userDef: Object | Function
) {
  // 先不管服务器端渲染
  const shouldCache = !isServerRendering()
  if (typeof userDef === 'function') {
    // 注意这里调用了 createComputedGetter 来生成描述符的 getter
    sharedPropertyDefinition.get = shouldCache
      ? createComputedGetter(key)
      : createGetterInvoker(userDef)
    // ...
  }
  // ...
  
  // 在 vm 上生成计算属性同名的存取描述符
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

defineComputed 里我们看到,最终描述符的 get 是由 createComputedGetter 生成的,这个函数就是关键中的关键了~

在继续之前,我们先回想一下计算属性的使用场景和缓存的应用。通常我们定义好计算属性之后,就会在 template 里去使用。当界面第一次显示时,计算属性会计算值,除非计算属性的依赖项发生变化(比如:依赖的 data 对象的属性重新赋值了),否则后面的刷新不会导致计算属性重新计算,而是会直接返回上一次的缓存值。从这里可以看出,template 里去读取计算属性的值,实际上就是调用 vm 上计算属性描述符的 get 了。

理清了场景后,我们把 createComputedGetter 分为两部分来看,先关注跟缓存相关的前半部分代码:

function createComputedGetter (key) {
  return function computedGetter () {
    // 首先在这里取到 vm 上的 watcher
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate()
      }

      // ...
      return watcher.value
    }
  }
}

上面代码中的 watcher.dirty 就是表示计算属性当前的值是否需要重新计算,如果不需要重新计算就直接返回 watcher.value 了,这就是实现了缓存的作用。那么这里我们回想一下计算属性在初始化 watcher 的时候传入了一个 lazy: true,并且在 Watcher 的构造函数中有这样的逻辑:

export default class Watcher {
  // ...
  
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    // ...
    this.lazy = !!options.lazy
    // ...
    this.dirty = this.lazy // 是不是脏了需要求值,初始化的时候就是 true
    //...
    // 如果是计算属性,初始化 watcher 不会求值
    this.value = this.lazy
      ? undefined
      : this.get()
  }
}

这个意思就是说:如果是计算属性,初始化 watcher 时不会求值,只会标记脏了——缓存无效。那么在上面计算属性的 get 第一次被调用时 watcher.dirty(界面第一次显示),会调用 watcher.evaluate()

evaluate () {
  this.value = this.get()
  this.dirty = false
}

evaluate 里就执行 get() 去求值了,并且标记缓存有效。回想一下当依赖的 dep 发生 set 时,会执行 watcher.update()

update () {
  if (this.lazy) {
    // 如果是计算属性,这里就会标记为 dirty
    this.dirty = true
  }
  // ...
}

所以依赖发生变化,缓存就会失效,计算属性又会重新计算了~

以上就是计算属性缓存的设计和实现细节了,我尽量只摘取关键代码把关键的事情说清楚。这个地方我们需要注意的是,Vue 把计算属性这种场景抽象成一种 lazy watcher,lazy watcher 只在需要的时候计算值,并且有缓存功能!所以抽象能力是值得我们学习的地方~

依赖传递

我们回过头来看看 createComputedGetter 的后半部分代码:

function createComputedGetter (key) {
  return function computedGetter () {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      // 前面代码是判断缓存是否要更新
      // ...
      
      // 注意这里就是依赖传递了
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

前半部分的缓存已经讲过了,现在我们注意这里有个依赖传递的逻辑,这是什么意思呢?

这里我们还是用场景来举例吧。比如在计算属性的声明中你是可以引用另外一个计算属性的!因为计算属性在初始化 watcher 时不会求值,也就是 lazy watcher,所以这样是没问题的。比如下面的代码:

const vm = new Vue({
  el: '#demo',
  data: {
    a: 1,
    b: 2
  },
  computed: {
    c: function () {
      return a + b
    },
    d: function () {
      return c * 2
    }
  }
})

这里就用这个简单的例子来说明为什么需要依赖传递:计算属性 c 依赖了 data 上的 ab,计算属性 d 又依赖了 c。那么问题来了,当 ab 发生改变时 cdirty,当然 d 也需要 dirty,不然 d 就会有缓存不会重新求值了。那么 d 怎么得到通知呢?

关键的代码就是上面的 watcher.depend() 了。首先,d 取值时会调用 d 自身的 watcher.get(),这个时候会把 dwatcher 设置为 Dep.target;接着 cwatcher.get() 执行时也会把 cwatcher 设置为 Dep.target。这里注意了,设置 Dep.target 时是调用的 pushTarget,这个函数会调用 targetStack 数组把当前已经记录的 Dep.target 推入数组:

Dep.target = null
const targetStack = []

export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

export function popTarget () {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

// 当时说了 targetStack 数组将在以后的文章中解析,这里圆回来了

执行完了这些之后呢,cwatcher 首先和 abdep 建立了依赖关系,然后求值 watcher.evaluate()。求值完后也就是 c 执行完自己的 watcher.get(),注意在 get() 方法的最后执行了 popTarget(),也就是说 c 把自己的 watcher 弹出来了,目前的 Dep.target 又变成了 dwatcher

c 求值完后,如果当前还有 Dep.target 存在就会执行 watcher.depend() 来传递依赖了,我们来看看 watcher.depend() 到底做了什么:

depend () {
  let i = this.deps.length
  while (i--) {
    this.deps[i].depend()
  }
}

代码的意思很明确了,就是让 c 当前依赖的这些 deps 也去建立与 Dep.target 的依赖(也就是 d 了)。这样当 ab 发生改变时 d 也会 dirty 了。

通过上面这个场景的描述你应该明白为什么需要依赖传递了吧~

结尾

以上呢就是 Vue 计算属性的细节和我的解读,如果有不清楚的请结合 第一篇 来看看。

到这里 Vue2 的响应式大体讲的就差不多了。后面还会再写一篇说说侦听属性,然后再回到设计从整体上巩固一下。如果有说的不对或有其他见解欢迎留言讨论哇~

欢迎 star 和关注我的 JS 博客:小声比比 JavaScript

查看原文

赞 4 收藏 4 评论 0

deepfunc 发布了文章 · 8月17日

Vue2响应式原理解析(一):从设计出发

Vue 的响应式系统是 Vue 最有意思的特性之一,data 只需要返回一个普通的字面量对象,在运行时修改它的属性就会引起界面的更新。现在都是数据驱动界面开发,这种设计对于程序员开发来说非常爽,关注点只用放在数据变化的逻辑上。并且 Vue 把这个特性抽象成了一个独立的 observer 模块,可以单独剥离使用,比如小程序开发框架 Wepy 就采用了这个模块来实现响应式。

这段时间我看了 Vue 2.x 关于 observer 的源码,这里呢也谈一下我对 observer 设计与关键部分实现的理解,以下的内容是基于 Vue 2.x 的源码分析。虽然现在已经有很多分析 Vue 响应式的文章了,希望我的理解也能给读者一些启发吧,把这块儿的知识吸收为自己所用。

如何追踪数据改变?

响应式最核心的问题是:如何去追踪 data 对象的属性改变呢?如果这一点无法实现或者对于开发者来说编码体验不好,那么响应式设计后面的路就不好走了。Vue 2.x 这里是基于 Object.defineProperty 来做的,将这些属性全部包装上 getter/setter,也就是劫持对象的属性访问。这种实现方式对于开发者来说基本是完全无感的。下面我摘出关键的代码片段来说明一下是如何实现的(假定我们设置的 data 是一个普通的字面量对象),如果你只是想学习设计思路也没必要去看完整的源码。

首先在 src/core/instance/state.js 中有如下代码:

function initData (vm: Component) {
  // 这就是我们声明的 data 数据对象,vm 是 Vue 对象
  let data = vm.$options.data
  
  // ...
  
  // 观察 data
  observe(data, true /* asRootData */)
}

上面这段代码是初始化 Vue 对象的 data,然后对 data 对象进行了观察设置。我们跳转到 observe() 的部分:

export function observe (value: any, asRootData: ?boolean): Observer | void {
  // ...
  let ob: Observer | void
  // 先不关注
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    shouldObserve && // 这里有一堆的条件,先不关注...
  ) {
    // 关注这里!
    ob = new Observer(value)
  }  
}

这里出现了一个 Observer 类,并且传入了 value 参数,就是我们设置的 data 对象,这里就是怎么劫持属性访问的关键类了,我们跳转到 Observer 的源码,在 Observer 构造函数中会调用这么一段代码:

export class Observer {
    // ...
  
  constructor (value: any) {
    // 依然省略大量代码...
    
    if (Array.isArray(value)) {
      // 先不考虑数组
    } else {
      // 看这里
      this.walk(value)
    }
  }
}

这里出现了一个 walk() 方法,把 data 作为参数传递进去了,来看看 walk() 是什么:

walk (obj: Object) {
  const keys = Object.keys(obj)
  for (let i = 0; i < keys.length; i++) {
    // 终于到了这里...
    defineReactive(obj, keys[i])
  }
}

这个 walk() 里面去遍历了 data 的所有自身属性,然后对每个 key 调用了 defineReactive() 函数。defineReactive() 从这个名字来看就是“定义响应的”,并且第一个参数是传入的 data,第二个参数是属性 key,看来就是针对 data 的每个 key 去设置对应的属性如何进行响应式了,到这里我们终于找到了劫持的大门~

劫持

defineReactive() 函数主要通过重新定义 data 上每个 key属性描述符来达到挟持属性访问的目的。利用 Object.defineProperty 为每个属性定义新的存取描述符,然后利用存取描述符的 getter/setter 来劫持属性的存取。

到这里我们要思考一个问题,我们能直接在 setter 里面去触发界面的更新吗?

响应式设计有很多的应用场景,比如 Vue 提供了计算属性 computed 和侦听属性 watch。如果我们把更新界面的操作直接写到 setter 里面的话,当再出现多一种场景需求的时候就需要在 setter 里面多加一段代码。一旦这样做了,就会频繁的修改 setter,这段代码就会变得不稳定和脆弱。所以我们要避免这种坏设计(bad design),而应该将这段代码封闭起来,使它能适应未来的扩展

抽象

Abstraction is the Key.

方向确定了,就要寻找一种实现方案。这里我们先对响应式这种需求做一层抽象:某个用户代码需要在 data 的某些属性发生变化时得到通知,从而执行特定的任务(代码)

某个用户代码是未知的,得到通知后需要执行的任务代码也是未知的,我们唯一知道的是:用户代码关心 data 某些属性的变化并期望得到通知。基于以上的抽象,我们很容易就想到观察者模式了~

在 Vue 的响应式设计里,data 数据对象的属性是被观察者,刷新界面、计算属性和侦听属性都属于观察者。这些观察者订阅data 中他们感兴趣的部分(也就是 key 对应的对象属性),当这些属性发生变化后,被观察者发布通知,观察者就去执行他们自己的任务了~

首先假设一下,一旦这个模式被设计编码出来,是一个好的设计吗?针对响应式的场景需求,我们只需要为每个属性建立一个观察者的关联列表,当属性改变时去挨个通知这些观察者就好了,而这些观察者是谁我们并不需要关心,这样就实现了我们抽象的目的。

这里先给一张图看看 Vue 是怎么设计的:

image

Dep 类记录了依赖关系,Watcher 就是抽象的观察者。那 Vue 是怎么建立依赖关系的呢?下面就来逐一解析下。

记录依赖关系

我们先想一下依赖关系在什么时候去收集是合适的?

如上面讨论的,Vue 中的观察者比如计算属性等是一个函数,在函数的执行过程中会读取 data 的若干属性,这就意味会访问到属性的 getter。那么自然把收集依赖的任务放在 getter 里面是合适的,那具体要怎么设计怎么实现呢?

我们还是先从关键代码入手,来看看 defineReactive()getter 的代码:

const dep = new Dep()
// ...

get: function reactiveGetter () {
  const value = getter ? getter.call(obj) : val
  if (Dep.target) { // Dep.target 就是当前的观察者
    dep.depend() // 建立依赖关系
    // ...
  }
  return value
},

getter 里会判断 Dep.target 是不是有值(当前的观察者),如果有值的话就执行 dep.depend() 建立依赖关系。这里 Dep.target 为什么就是当前的观察者呢?

前面我们说过了观察者都被抽象成了 WatcherWatcher 的构造函数会传入一个 expOrFn(就是客户代码,比如:计算属性的定义函数),然后被保存为一个叫 getter 的成员。在 Watcher 构造函数的最后会执行 Watcherget() 方法,get() 的关键部分如下:

get () {
  pushTarget(this) // 这里设置 Dep.target
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (e) {
    // ...    
  } finally {
    popTarget() // 运行完 getter 后取消设置 Dep.target
    // ...
  }
  return value
}

看到这里我们就大致明白了,每个 Watcher 在执行客户代码以前,会把自己设置为 Dep.target,并在运行完客户代码后取消 Dep.target 的设置,pushTarget()popTarget() 的代码如下:

Dep.target = null
const targetStack = []

export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

export function popTarget () {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

// 为什么会有 targetStack 数组 将在以后的文章中解析

Watcher 中的 get() 方法结合 defineReactive()getter 的部分我们终于大致知道是如何收集依赖了:

每段客户代码都被抽象为 Watcher 中的 getter 并被包裹在 get() 中执行,在执行前 Dep.target 设置为 Watcher 自身,执行完后取消设置。

依赖关系发生变化

Vue 记录了依赖的双向关系。Dep 上有个 subs 数组记录了观察这个属性的观察者们,每个观察者 Watcher 有个 deps 数组记录了需要观察的属性相关 Dep 实例。这里有点奇怪的是为什么需要记录双向关系呢,如果只是通知观察者的话,只需要在 Dep 上的 subs 数组就好了呀~

这是因为 Watcher 需要在某些场景去除掉一些不再需要的依赖关系,那么就需要比对原先的依赖 deps 数组和当前最新的依赖数组之间的差异(所以 Watcher 有配合的 depIdsnewDepIdsnewDeps 来做这件事)。那什么场景会产生依赖关系的变化呢?比如,在计算属性的函数中,根据某些逻辑变量的判断来引用不同的 data 属性,那么这种情况下依赖关系就会产生变化,用一段代码来说明下:

const vm = new Vue({
  el: '#demo',
  data: {
    a: 1,
    b: 2,
    c: false
  },
  computed: {
    some: function () {
      if (c) {
        return a + b
      } else {
        return b
      }
    }
  }
})

如果 c 的值是 true,计算属性 some 依赖的 data 属性是 ab;如果 c 的值改变成了 false,则 some 就只依赖 b 了。所以在业务场景中,依赖关系确实有可能会发送变化的。

通知更新

记录了依赖关系后,当属性发生变化时去通知就很简单了。回到 defineReactive() 中的 setter 代码:

const dep = new Dep()
// ...

set: function reactiveSetter (newVal) {
  // ...
  
  // 前面的按下不表,以后再解释,先只关注这句话
  dep.notify()
}

当属性被赋值时,就执行 dep.notify(),里面会逐个去通知 Watcher 执行 update()。后面我们会详细再说下 update() 的过程,这里我们只需要知道更新通知是怎么发生的就行了。

接下来

以上就是我理解的 Vue 响应式是如何去设计的,以及依赖收集的关键代码解读。我觉得一个好的框架或者第三方库他们的设计思路和抽象方式是非常值得我们去学习的,我们常说的看源码学习主要也是学的这部分。其次要关注作者在实现上的一些技巧,比如这里的依赖收集方式就是比较有特点的。

水平有限,理解和解读难免会有错漏,欢迎指出哇~ 下一篇将写一下计算属性的一些设计、实现和其他部分。

欢迎 star 和关注我的 JS 博客:小声比比 JavaScript

查看原文

赞 2 收藏 0 评论 0

deepfunc 发布了文章 · 4月26日

微信小程序开发中自动更新 iconfont 样式文件的小工具:mp-iconfont-cli

在微信小程序开发中,使用 iconfont 需要引用本地的文件。每次 iconfont 项目发生变更时,需要去下载最新的 css 文件,并且还要手动删除掉里面对于小程序无用的 src url(*) 兼容节点,然后保存为 wxss 文件。整个过程比较繁琐,这个小工具可以帮你自动完成这些工作。
项目地址:https://github.com/deepfunc/mp-iconfont-cli

运行效果

使用指南

安装

npm i -D mp-iconfont-cli
请安装 Node.js 8+ 版本。

使用范例

目前仅支持 GitHub 账号登录 iconfont。安装完毕后,在你的项目根目录下运行:

npx iconfont-update

运行过程中会列出 iconfont 中我的项目列表,选择你需要的项目,按照后面的提示操作即可。

清除设定值

第一次运行完毕后,工具会记住你的选项,下次再运行时无需重复输入了。如果需要清除输入过的设定值,运行下面的命令:

npx iconfont-update --clear

显示详细错误内容

有时由于网络不太好或者其他情况会出现异常错误,如需要显示详细的异常信息,请在运行时加上选项 --trace

npx iconfont-update --trace

设计思路

查看原文

赞 0 收藏 0 评论 0

deepfunc 收藏了文章 · 2019-09-30

Mac 终端效率神技

增强各种预览的插件

  • 预览查看图片分辨率&大小
  • 代码语法高亮
  • 快速预览zip压缩包内容
  • 快速预览markdown格式内容
brew cask install qlcolorcode betterzipql qlimagesize qlmarkdown

iTerm2

具体的配置网上一大堆。贴一个本人亲身操刀操作过的教程

程序员经常与终端操作打交道,所以很多命令便是做成了命令行模式,在自带的 Terminal 命令都保存在 .bash_profile 文件中,使用了 iterm2,命令都保存在 .zshrc 中。

所以我们将很多命令保存且编辑。这里也是分享出我个人常用的配置。不断更新,喜欢的同学可以拿去直接使用

# 输入自己常用的命令
# finder 相关指令
alias co='code ./'
alias fo='open ./'

# pod 和 xcode 工程相关指令
alias o='open *.xcodeproj'
alias po='open *.xcworkspace'
alias pru='pod repo update'
alias pi='pod install'
alias pu='pod update'
alias piu='pod install --repo-update'
alias repoanalysis='specbackwarddependency /Users/liubinpeng/.cocoapods/repos/XXCompany_specs'
alias plint='pod lib lint --sources=git@git.***-inc.com:client/App-Specs.git,git@git.***-inc.com:client/CocoaPods-Specs.git --allow-warnings --verbose --use-libraries'
alias errorShow=' >1.log 2>&1'
# git 相关指令
alias gck='git checkout'
alias gm='git merge'
alias gb='git branch'
alias gbr='git branch -a'
alias gs='git status'
alias gc='git clone'
alias gl='git log'
alias ga='git add .'
alias gpull='git pull'
alias gpush='git push'
alias gcm='git commit -m'
alias glocalbranchPush='git push --set-upstream origin '
alias glg="git log --graph --pretty=format:'%Cred%h%Crest -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit --date=relative"
# npm 相关指令
alias ns='npm start'
alias ni='npm install'
alias nb='npm run build'
alias nig='npm install -g '
alias nt='npm test'

# Vue 相关命令
alias vc='vue-init webpack' # (vue-init webpack test1)用法 vc test1

# React 
alias rc='create-react-app' #(create-react-app todolist)用法 rc todolist

# React Native 命令
alias rnc='react-native init' #(react-native init todolist)用法 rnc todolist


# 终端打开应用程序
## 浏览器打开
alias OpenWithSafari='open -a "/Applications/Safari.app" '
alias OpenWithChrome='open -a "/Applications/Google Chrome.app" '
## 用 Typora 打开 markdown 文件预览写作效果。
alias OpenMDPreview='open -a "/Applications/Typora.app" '
## 用 DB Browser for SQLite 打开 db 文件
alias OpenDB='open -a "/Applications/DB Browser for SQLite.app" '
## 用 SourceTree 打开工程
alias openSourceTree='open -a "/Applications/Sourcetree.app/" '
# Flutter 环境变量

export PUB_HOSTED_URL=https://pub.flutter-io.cn
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
export PATH=/Users/liubinpeng/flutter/bin:$PATH


# Android SDK 路径

export ANDROID_HOME=~/Library/Android/sdk
export PATH=${PATH}:${ANDROID_HOME}/emulator
export PATH=${PATH}:${ANDROID_HOME}/tools
export PATH=${PATH}:${ANDROID_HOME}/platform-tools


# iOS 模拟器开启
alias iOSSimulator='open -a Simulator'

export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"  # This loads nvm bash_completion


# Node Version Manager
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"  # This loads nvm bash_completion
# Add RVM to PATH for scripting. Make sure this is the last PATH variable change.
export PATH="$PATH:$HOME/.rvm/bin"

export N_PREFIX=/usr/local/bin/node #根据你的安装路径而定
export PATH=$N_PREFIX/bin:$PATH
export PATH=/Users/liubinpeng/Desktop/Github/GitWorkflow/bin:$PATH


# chrome 源码探究
# export PATH=/Users/liubinpeng/Desktop/Tech-Research/iOS/depot_tools:$PATH


# 指定 pyhton 版本
# Setting PATH for Python 2.7
PATH="/System/Library/Frameworks/Python.framework/Versions/2.7/bin:${PATH}"
export PATH
# Setting PATH for Python 3.7.4
PATH="/usr/local/Cellar/python/3.7.4/bin:${PATH}"

alias python='/System/Library/Frameworks/Python.framework/Versions/2.7/bin/python2.7'
alias python3='/usr/local/Cellar/python/3.7.4/bin/python3'



# 禁止终端利用 homebrew 安装插件时候的自动更新

alias disableHomebrewUpdate="export HOMEBREW_NO_AUTO_UPDATE=true"


# 终端翻墙相关的设置(开关开启后稍微有点延迟)

function proxy_off(){
    unset ALL_PROXY
    echo -e "已关闭代理"
}

function proxy_on(){
    export ALL_PROXY=socks5://127.0.0.1:1081
    echo -e "已开启代理"
}

 
# PHP 包管理工具,composer
export PATH="~/.composer/vendor/bin:$PATH"

# python 版本切换工具,全局生效 
export PYENV_ROOT=~/.pyenv
export PATH=$PYENV_ROOT/shims:$PATH


# 效率
# 统计当前文件夹下文件的数量
alias showFilesCount='ls -l |grep "^-"|wc -l'

退出编辑,执行 source .zshrc

验证:在你的 git 项目所在的目录的终端下输入 glg
Git日志

为你的终端添加常用快捷键

我们经常在终端做着一些纯指令的事情,天天敲、月月敲这个时间的很浪费的,一天节约5分钟,一年节约365*5/60 = 30H。一算吓一跳。我们每年在一些终端的指令上浪费了这么多时间。今天记录下如何给自己的 Mac 终端添加快捷键。

如果是 zsh 的话,可以编辑 .zshrc 文件里面的内容。自带的终端则编辑 bash_profile
脚本具体看上一条。

输出文件目录结构

brew install tree

用法:

  1. 我们可以在目录遍历时使用 -L 参数指定遍历层级

    tree -L 2
  2. 如果你想把一个目录的结构树导出到文件 Readme.md ,可以这样操作

    tree -L 2 >README.md //然后我们看下当前目录下的 README.md 文件
  3. 只显示文件夹

    tree -d 
  4. 显示项目的层级,n表示层级数。例:显示项目三层结构,tree -l 3

    tree -L n 
  5. tree -I pattern 用于过滤不想要显示的文件或者文件夹。比如要过滤项目中的node_modules文件夹

    tree -I “node_modules”

浏览器相关

  1. 搜索

在指定的站点下搜索 inurl: jobbole.com intitle:Hybrid

百度云盘破解

  1. 会员体验一般为60秒,通过本代码可以一直以会员的速度下载。
git clone https://github.com/CodeTips/BaiduNetdiskPlugin-macOS.git && ./BaiduNetdiskPlugin-macOS/Other/Install.sh
  1. 百度网盘全速下载
  • 先将你需要下载的地址复制进浏览器
  • 然后在域名 baidu 后面拼接 wp
  • 回车。访问页面,选择下载地址1即可全速下载。
// 之前
https://pan.baidu.com/s/1ubcQH34m69hIjYu3CD2S2g
// 之后
https://pan.baiduwp.com/s/1ubcQH34m69hIjYu3CD2S2g

「安全与隐私」中系统不显示「任何来源」

在终端执行下面的命令

sudo spctl --master-disable

系统错误信息的集中展示

pod spec lint *** 2>&1|tee 1.log

经常在终端做操作,有个情况就是在 iOS 的组件库维护的时候去检测合法性。你会发现满屏幕都是信息,甚至好几页,但是事实上错了问题后我们去翻页的时候发现很不方便定位问题,所以想到的就是将该过程产生的任何输出,集中打印到一个地方去查看。代码如上。

几个概念:

  • 0 stdin,1 stdout,2 stderr
  • |:管道。管道的作用是提供一个通道,将上一个程序的标准输出重定向到下一个程序作为下一个程序的标准输入。
  • tee:从标准输入中读取,并将内容写到标准输出以及文件中。

终端查找文件

  1. 终端查找以‘.log’结尾的文件
find . -name '*.log'
  1. 安装 ack 包.
brew install ack

使用起来很简单,比如 ack + 你要查找的关键词,它可以将查到的结果展示在下面,有完整的文件路径.

效果图

终端每次执行 brew install 都会更新,非常耗时,如何禁止更新。

export HOMEBREW_NO_AUTO_UPDATE=true

Mac 翻墙环境和终端翻墙环境

强烈安利一个我用过最快速、最便宜也就是性价比最高的科学上网工具。链接 个人使用的也就是一年120元不到,一个月 60G 流量足够了,打开一些网站秒开。

另外很多开发都需要终端下载一些资源,但是终端走的通道和浏览器不一样,所以浏览器可以翻墙,终端还是不可以,所以可以在 .zshrc 或者 .bash_profile 下加下面的脚本

function proxy_off(){
    unset ALL_PROXY
    echo -e "已关闭代理"
}

function proxy_on(){
    export ALL_PROXY=socks5://127.0.0.1:1081
    echo -e "已开启代理"
}

使用 proxy_on 开启终端翻墙模式、proxy_off 关闭终端翻墙模式。

终端快捷键

很多情况下,我们会在终端编辑文件,为了提交效率就有了以下快捷键。

  • ESC + dd:删除当前一行的数据

持续更新中...

查看原文

deepfunc 收藏了文章 · 2019-09-20

webpack 中的 watch & cache (上)

我们在日常使用 webpack 或者是在以它为基础开发的时候,可能更多的时候关注的是配置以及配置的插件开发。在日常的开发过程中,会发现 watch 状态下的编译流程有一个规律是,第一次会较为缓慢,后续的编译会很快速,看起来像是有缓存的控制,那么具体内部的缓存流程存在哪些节点呢?下面进行一些探索总结,希望能为日常的插件 pluginloader 开发起到帮助。

webpack --watch

对于 cache 使用的入口,其实在我们日常构建中,大多是借助 webpack 启动一个构建 watch 服务

入口

最普通的相比于 webpack 不带参数直接执行的方式, webpack --watch 的执行逻辑存在较为明显的区别。

webpack/bin/webpack.js:

if(options.watch) {
  var primaryOptions = !Array.isArray(options) ? options : options[0];
  var watchOptions = primaryOptions.watchOptions || primaryOptions.watch || {};
  if(watchOptions.stdin) {
    process.stdin.on('end', function() {
      process.exit(0); // eslint-disable-line
    });
    process.stdin.resume();
  }
  compiler.watch(watchOptions, compilerCallback);
} else
  compiler.run(compilerCallback);

从执行文件中 webpack/bin/webpack.js 找到 --watch 逻辑,相比于直接 webpack 不带参数执行对应的是 compiler.run 方法,--watch 则对应的是 compiler.watch 方法。

除了 webpack --watch 调用,这里还可以关联一下在日常使用中很平常的 webpack-dev-middleware 模块。

webpack-dev-middleware/middleware.js:

if(!options.lazy) {
  var watching = compiler.watch(options.watchOptions, function(err) {
    if(err) throw err;
  });
}

从代码可以看到,在非 lazylazy 模式指的是根据请求来源情况来直接调用 compiler.run 进行构建)模式下,实际上也是同样通过 compiler.watch 方法进行文件的监听编译。印证了前面的

大多是借助 webpack 启动一个构建 watch 服务

更准确的说法是,通过 compiler.watch 来创建 watch 服务。

如图对应上文不同调用方式之间的差异。

watch 编译生命周期

上面小结的内容,在整个 webpack 的过程中,是处在完成 compiler = webpack(config) 函数调用之后,得到一个 Compiler 实例之后,进行正式编译流程之前的节点,详细的编译流程文章推荐 [][]Webpack 源码(二)—— 如何阅读源码细说 webpack 之流程篇 ,后续我们也会不断输出一些细节实现的文章。

对于 watch 这种需要不断进行触发编译的流程的情况,会出现不断重复地经历几个相同流程,可以称之为 watch 的 生命周期,而 cache 的出现和使用同样也融入了在这个生命周期中。

  1. 生成 Watching 实例 watching,将编译流程控制交给 watching

    webpack/lib/Compiler.js
    
    Compiler.prototype.watch = function(watchOptions, handler) {
      this.fileTimestamps = {};
      this.contextTimestamps = {};
      var watching = new Watching(this, watchOptions, handler);
      return watching;
    };

    无论是 webpack --watch,还是 webpack-dev-middleware 模块,都是调用 compiler.watch 方法进行初始化 watch 流程,在 Compiler.prototype.watch 逻辑中,与 Compiler.prototype.run在方法中完成具体编译流程不同的是,会通过生成 watching 实例来接管具体编译流程

    1. 构造实例,进行第一次编译初始化
      watching 作为 watch 监听流程中的最上层对象,满足了 watch 流程在逻辑最上层的各个阶段衔接。

      webpack/lib/Compiler.js
      
      function Watching(compiler, watchOptions, handler) {
        this.startTime = null;
        this.invalid = false;
        this.error = null;
        this.stats = null;
        this.handler = handler;
        if(typeof watchOptions === "number") {
          this.watchOptions = {
            aggregateTimeout: watchOptions
          };
        } else if(watchOptions && typeof watchOptions === "object") {
          this.watchOptions = Object.create(watchOptions);
        } else {
          this.watchOptions = {};
        }
        this.watchOptions.aggregateTimeout = this.watchOptions.aggregateTimeout || 200;
        this.compiler = compiler;
        this.running = true;
        this.compiler.readRecords(function(err) {
          if(err) return this._done(err);
      
          this._go();
        }.bind(this));
      }

      对于 Watching 构造函数,其实可以分成两个部分

      1. 基础属性设置

        1. startTime:执行每次编译时(Watching.prototype._go 方法调用) ,会赋值编译启动时间,在后续文件是否需要再次编译时,作为重要根据之一

        2. invalid:表明现在 watching 的调用状态,例如在 this.runing 为 true 时,表明运行正常,会赋值该属性为 true

        3. error:存放编译过程的错误对象,完成每次编译后会回传给 handler 回调

        4. stats :存放编译过程中的各个数值,同样也是会在每次编译后会回传给 handler 回调

        5. handler:指的是,每次编译完执行的回调函数,一个常见的例子是每次编译完在命令行中出现的资源列表就是通过这个函数实现

        6. watchOptionswatch 调用参数设置,其中 aggregateTimeout 参数代表的是每一次文件(夹)变化后在 aggregateTimeout 值内的变化都会进行合并发送

        7. compiler:生成 watching 对象的 Compiler 实例

        8. runningwatching 实例的运行状态

      2. 执行初始化编译
        this._go 调用开始,就会进入 编译 -> watch监听编译 -> 文件变更触发编译 -> 编译 的循环

    2. 执行编译
      作为执行编译的入口 Watching.prototype._go 函数的结构与 Compiler.prototype.run 的结构类似,都是调用 Compiler 提供的诸如 this.compile 、this.emitAssets 等方法完成编译过程。

      run 类似,_go 函数同样会调用 compiler.compile 方法进行编译,同时在完成 emitAssets (资源输出)、emitRecords (记录输出) 后,也就是完成这一次编译后,会调用 this.done 方法进行 watch 循环的最后一步

    3. 调用文件监听
      在完成编译后,为了在不重复启动编译进程的情况下,文件改动会自动重新编译。会在 Watching.prototype._done 中实时监听文件操作进行编译。

      Watching.prototype._done = function(err, compilation) {
       // 省略部分流程(结束状态值设置、结束事件触发等)
       if(!this.error)
           this.watch(compilation.fileDependencies, compilation.contextDependencies, compilation.missingDependencies);
      };

      这里在 _done 的最后一个步骤,会调用 Watching.prototype.watch 来进行文件监听:

      Watching.prototype.watch = function(files, dirs, missing) {
       this.watcher = this.compiler.watchFileSystem.watch(files, dirs, missing, this.startTime, this.watchOptions, function(err, filesModified, contextModified, missingModified, fileTimestamps, contextTimestamps) {
           this.watcher = null;
           if(err) return this.handler(err);
      
           this.compiler.fileTimestamps = fileTimestamps;
           this.compiler.contextTimestamps = contextTimestamps;
           this.invalidate();
       }.bind(this), function() {
           this.compiler.applyPlugins("invalid");
       }.bind(this));
      };

    Watching.prototype.watch 通过 compiler.watchFileSystemwatch 方法实现,可以大致看出在文件(夹)变化触发编译后,会执行传递的回调函数,最终会调用 Watching.prototype.invalidate 进行编译触发:

    Watching.prototype.invalidate = function() {
        if(this.watcher) {
            this.watcher.pause();
            this.watcher = null;
        }
        if(this.running) {
            this.invalid = true;
            return false;
        } else {
            this._go();
        }
    };

    到了 Watching.prototype.invalide 这个方法后,又去从 Watching.prototype._go 函数开始进行新一轮的编译,到这里整个 watch 的流程就串起来了。

在进入 watchFileSystem 之前,回顾上面的整个流程,webpack 中的 watch 流程大致就是 Watching.prototype._go -> Watching.prototype.watch -> Watching.prototype.invalidate 三个函数循环调用的过程。衔接初始化截图,大致如下图。

后续主要对 监听触发 两个部分所涉及的一些细节进行深入。

watchFileSystem

由上面内容看出对于 Watching.prototype.watch 实现文件监听的核心是 compiler.watchFileSystem 对象的 watch 方法。 watchFileSystemwebpack 中通过 NodeEnvironmentPlugin 来进行加载

webpack/lib/node/NodeEnvironmentPlugin.js

var NodeWatchFileSystem = require("./NodeWatchFileSystem");

NodeEnvironmentPlugin.prototype.apply = function(compiler) {
    compiler.inputFileSystem = new NodeJsInputFileSystem();
    var inputFileSystem = compiler.inputFileSystem = new        CachedInputFileSystem(compiler.inputFileSystem, 60000);
    compiler.resolvers.normal.fileSystem = compiler.inputFileSystem;
    compiler.resolvers.context.fileSystem = compiler.inputFileSystem;
    compiler.resolvers.loader.fileSystem = compiler.inputFileSystem;
    compiler.outputFileSystem = new NodeOutputFileSystem();
    compiler.watchFileSystem = new NodeWatchFileSystem(compiler.inputFileSystem);
    compiler.plugin("run", function(compiler, callback) {
        if(compiler.inputFileSystem === inputFileSystem)
            inputFileSystem.purge();
        callback();
    });
};

这里会设置很多的 fileSystem ,而这样做的好处可以关联到前面的 webpack-dev-middleware 模块,在本地调试等对编译性能有较高要求的场景下,需要尽量利用缓存的速度,而 webpack-dev-middleware 将物理 io 切换成缓存设置,通过修改 fileSystem 来实现。

webpack-dev-middleware/middleware.js

var fs = new MemoryFileSystem();

// the base output path for web and webworker bundles
var outputPath;

compiler.outputFileSystem = fs;
outputPath = compiler.outputPath;
    

compileroutputFileSystem 设置成内存 (MemoryFileSystem) 的方式,将资源编译文件不落地输出,大大提高编译性能。在 webpack 中存在文件系统的抽象处理,方便一些优秀的文件系统处理模块功能(例如读取缓存、内存读写)接入利用。

例如 webpack 默认采用的是 graceful-fs,本身基于 Node.js 中的 fs 模块进行了许多优化,而 webpack-dev-middleware 则是采用内存读取的 memory-fs

对照 NodeEnvironmentPlugin 的代码,可以看到 watchFileSystem 指向的是同目录下的 NodeWatchFileSystem.js 导出的构造函数生成的实例。

webpack/lib/node/NodeWatchFileSystem.js

var Watchpack = require("watchpack");

function NodeWatchFileSystem(inputFileSystem) {
    this.inputFileSystem = inputFileSystem;
    this.watcherOptions = {
        aggregateTimeout: 0
    };
    this.watcher = new Watchpack(this.watcherOptions);
}

NodeWatchFileSystem.js 中的实现再一次的依赖 watchpack 完成。通过封装 watchpack 的监听逻辑,完成绑定相应的文件变更事件,进行上层 compiler.invalidate 方法调用,触发再次编译流程。

webpack/lib/node/NodeWatchFileSystem.js

NodeWatchFileSystem.prototype.watch = function watch(files, dirs, missing, startTime, options, callback, callbackUndelayed) {
    // 省略异常处理
  
    if(callbackUndelayed)
        this.watcher.once("change", callbackUndelayed);

    this.watcher.once("aggregated", function(changes) {
        // 省略具体流程
        callback(...);
    }.bind(this));
      
      this.watcher.watch(files.concat(missing), dirs, startTime);
     // 省略返回
}

这里的 callback 就是 Watching.prototype.watch 方法中调用 this.compiler.watchFileSystem.watch 传递的回调函数,当用户触发了 watchpack 提供的文件(夹)变化事件,那么就会通过 callback 回调中 Watching.prototype.invalidate 进行再次编译。在进入 watchpack 细节之前总结一下 watch 调用层级。

webpack 中的 watch 调用,每一层都叫做 watch 方法,在每一个 watch 方法中,都通过逐步对下一层的依赖调用,完成从 watching 实例与 watcher 实例的衔接解耦。

  • watching 层,完成对重新编译的回调绑定

  • watchfileSystem 层,完成对下层监听文件(夹)触发逻辑之后信息返回的过滤处理,以及对上层回调的调用

  • watcer 层,只负责对文件(夹)的变化的事件监听

通过多个层级的划分,解耦逻辑,方便函数进行调整和功能横向扩展。

watchpack 监听

由上面 NodeWatchFileSystem.js 的代码截断中可以看到,对应的 watch 方法,核心逻辑是 watchpack 的实例 watcher 对应的 watch 方法。直接找到对应的 Watchpack.prototype.watch 方法

watchpack/lib/watchpack.js

var watcherManager = require("./watcherManager");
Watchpack.prototype.watch = function watch(files, directories, startTime) {
    this.paused = false;
    // 省略 old watchers 处理
  
    this.fileWatchers = files.map(function(file) {
        return this._fileWatcher(file, watcherManager.watchFile(file, this.watcherOptions, startTime));
    }, this);
    this.dirWatchers = directories.map(function(dir) {
        return this._dirWatcher(dir, watcherManager.watchDirectory(dir, this.watcherOptions, startTime));
    }, this);

};

衔接上一层在 NodeWatchFileSystem.jsthis.watcher.watch(files.concat(missing), dirs, startTime); 的调用,在 watchpack 实例的 watch 方法中可以看到会针对 文件文件夹 类型分别调用 watcherManager.watchFilewatcherManager.watchDirectory进行监听。

watchpack/lib/watcherManager.js

WatcherManager.prototype.watchFile = function watchFile(p, options, startTime) {
    var directory = path.dirname(p);
    return this.getDirectoryWatcher(directory, options).watch(p, startTime);
};
WatcherManager.prototype.watchDirectory = function watchDirectory(directory, options, startTime) {
    return this.getDirectoryWatcher(directory, options).watch(directory, startTime);
};

watcherManager.js 文件中的 watchFile 以及 watchDirectory 都传递了同类型的参数调用了 this.getDirectoryWatcher ,并在随后调用了返回实例的 watch 方法,并将 watch 方法的返回结果继续往上层 watchpack.jsthis._fileWatcherthis._dirWatcher 方法进行传递。

watchpack/lib/watcherManager.js

WatcherManager.prototype.getDirectoryWatcher = function(directory, options) {
 var DirectoryWatcher = require("./DirectoryWatcher");
 options = options || {};
 var key = directory + " " + JSON.stringify(options);
 if(!this.directoryWatchers[key]) {
  this.directoryWatchers[key] = new DirectoryWatcher(directory, options);
  this.directoryWatchers[key].on("closed", function() {
   delete this.directoryWatchers[key];
  }.bind(this));
 }
 return this.directoryWatchers[key];
};

getDirectoryWatcher 的具体实现,则是创建一个由 ./DirectoryWatcher 导出的构造函数所构造出来的实例。这里可以看到以文件夹路径(directory) 和配置 (options)两个属性作为实例的 key 并且在函数最后,将实例进行返回。

整个逻辑通过 watchManager 进行底层逻辑创建,通过 _dirWatcher_fileWatcher 完成对底层逻辑的处理封装。

DirectoryWatcher 实例创建

紧接着 wacthManagerwatchFilewatchDirectorygetDirectoryWatcher 调用完成后,则调用实例的 watch 方法,逻辑就走到了 DirectoryWatcher.js 文件。关联在 getDirectoryWatcher 的实例生成过程,对应 DirectoryWatcher 的构造函数

watchpack/lib/DirectoryWatcher.js

var chokidar = require("chokidar");

function DirectoryWatcher(directoryPath, options) {
    EventEmitter.call(this);
    this.path = directoryPath;
    this.files = {};
    this.directories = {};
    this.watcher = chokidar.watch(directoryPath, {
        ignoreInitial: true,
        persistent: true,
        followSymlinks: false,
        depth: 0,
        atomic: false,
        alwaysStat: true,
        ignorePermissionErrors: true,
        usePolling: options.poll ? true : undefined,
        interval: typeof options.poll === "number" ? options.poll : undefined
    });
    this.watcher.on("add", this.onFileAdded.bind(this));
    this.watcher.on("addDir", this.onDirectoryAdded.bind(this));
    this.watcher.on("change", this.onChange.bind(this));
    this.watcher.on("unlink", this.onFileUnlinked.bind(this));
    this.watcher.on("unlinkDir", this.onDirectoryUnlinked.bind(this));
    this.watcher.on("error", this.onWatcherError.bind(this));
    this.initialScan = true;
    this.nestedWatching = false;
    this.initialScanRemoved = [];
    this.doInitialScan();
    this.watchers = {};
    this.refs = 0;
}

找到这里,可以看到,监听文件(夹)采用的是 chokidar 的能力。关联前面的逻辑,可以大致看出,通过 chokidar 绑定对应 directoryPath 的目录的 addaddDirchangeunlinkunlinkDir 的事件,通过对应的事件回调函数来向上层逻辑传递文件(夹)变更信息。

除了 watcher 对应 chokidar 对象,这里还有一些辅助的属性来完成监听处理逻辑

  • files:保存文件改变状态(mtime)

  • directories:保存文件夹监听状态,以及嵌套文件夹监听实例

  • initialScan:初次文件扫描标识

  • nestedWatching:是否存在嵌套文件夹监听

  • initialScanRemoved: 首次查看过程中删除的文件(夹),对在首次查看过程中对已删除文件(夹)的过滤

  • watchers:以监听路径(filePath) 为 key 的 watcher 数组为值的 map 对象

  • refswatchers 的数量

在属性复制完成后,会类似 Compiler.jsWatching 实例在实例创建时会进行首次编译一样,会进行首次文件夹的查看(doInitalScan) ,这里会进行初始数据(this.filesthis.directories)的生成。

DirectoryWatcher.prototype.doInitialScan = function doInitialScan() {
    fs.readdir(this.path, function(err, items) {
        if(err) {
            this.initialScan = false;
            return;
        }
        async.forEach(items, function(item, callback) {
            var itemPath = path.join(this.path, item);
            fs.stat(itemPath, function(err2, stat) {
                if(!this.initialScan) return;
                if(err2) {
                    callback();
                    return;
                }
                if(stat.isFile()) {
                    if(!this.files[itemPath])
                        this.setFileTime(itemPath, +stat.mtime, true);
                } else if(stat.isDirectory()) {
                    if(!this.directories[itemPath])
                        this.setDirectory(itemPath, true, true);
                }
                callback();
            }.bind(this));
        }.bind(this), function() {
            this.initialScan = false;
            this.initialScanRemoved = null;
        }.bind(this));
    }.bind(this));
};

这里是一个 async.forEach 撑起的函数结构,主要对传入 directoryPath 下的文件(夹)通过 setFileTimesetDirectory 进行 DirectoryWatcher 实例的 filesdirectories 属性赋值。

  • 对于文件情况 (stat.isFiletrue) :

    调用 `setFileTime` 函数传入文件最后修改时间( `stat.mtime`),函数本身分为两个步骤,而这里主要是**存储文件的变更记录**,而另一部则是**变更事件的触发**,在后面的内容也会提到。
    
    DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) {
      var now = Date.now();
      var old = this.files[filePath];
      this.files[filePath] = [initial ? Math.min(now, mtime) : now, mtime];
      // 省略变更触发
    };

    这里会以数组的形式,存储 变更流程执行时间点文件最后修改时间点
    一般 setFileTime 的调用的时候,就认为触发了文件触发了变更,进行文件变更记录更新,而对于初始化情况,主要目的是为了初始化数据,并不为变更而调用 setFileTime,所以对于初始化的返回是进行比较 Math.min(now, mtime) 而不是直接返回当前时间。

  • 对于文件夹情况(stat.isDirectorytrue

    调用 setDirectory 来进行子文件夹标记,方便后续进行子文件夹监听的创建:

    DirectoryWatcher.prototype.setDirectory = function setDirectory(directoryPath, exist, initial) {
    var old = this.directories[directoryPath];
    if(!old) {
      if(exist) {
    if(this.nestedWatching) {
      this.createNestedWatcher(directoryPath);
    } else {
      this.directories[directoryPath] = true;
    }
      }
    } 
    // 省略文件夹删除事件触发
    }

    doInitalScan 的场景下,会判断 nestedWatching 的情况,如果为 false 则赋值 this.directories[directoryPath]true,表示文件夹没有创建对应的监听;或者是通过 this.createNestedWatcher 进行子文件夹监听的创建,最终也会赋值到 this.directories[directoryPath] 上的则是对应的内嵌 Watcher 实例。而这里的子文件夹的状态在后续也是可能发生变化的。
    完成赋值过程后, 会将 this.initialScan 设置成 false 表示首次查看结束,设置 this.initialScanRemovednull ,表示在首次查看过程中就删除的文件(夹)的处理也结束。

在完成基础 this.watcher 文件系统监听逻辑(chokidar )创建,基础属性 this.filesthis.directories 初始化后,则完成了整个 DirectoryWatcher 实例的生成。

搭建监听通道(创建内部 Watcher 实例)

getDirectoryWatcher 完成调用返回 DirectoryWatcher 的实例之后,调用实例的 watch 方法,传入文件(夹)路径。对最上层 Compiler 传入的 filesmissings 文件,dirs 文件夹进行循环调用,进行监听流程。watch 方法通过三个阶段完成底层到上层的监听信息通道的搭建。

  1. 生成 Watcher 实例
    第一个部分是针对传入的路径生成对应的 Watcher 实例,最终通过 WatcherManagerwatchFilewatchDirectory 返回到上层 watchpack 中的 watch 方法中 this._fileWatcherthis._dirname调用的返回结果,就是这个内部 Watcher 实例。

    watchpack/lib/DirectoryWatcher.js
    
    function Watcher(directoryWatcher, filePath, startTime) {
        EventEmitter.call(this);
        this.directoryWatcher = directoryWatcher;
        this.path = filePath;
        this.startTime = startTime && +startTime;
        this.data = 0;
    }
    
    DirectoryWatcher.prototype.watch = function watch(filePath, startTime) {
      this.watchers[withoutCase(filePath)] = this.watchers[withoutCase(filePath)] || [];
      this.refs++;
      var watcher = new Watcher(this, filePath, startTime);
      
      watcher.on("closed", function() {
        // 省略 closed 事件处理
      }.bind(this));
      
      this.watchers[withoutCase(filePath)].push(watcher);
      // 省略设置子文件内嵌监听
      // 省略已有数据处理
      return watcher;  
    };

    这里内部 Watcher 实例主要是通过继承 EventEmitter 来实现实例的事件支持,那么传递回上层例如 watchpack 时,就可以绑定该 Watcher 实例的事件,底层的文件改动触发实例的事件,上层对事件处理,通过这个对象建立数据传递的通道,完成监听数据的传递。在完成 watcher实例创建后,会将实例 pushthis.watchers 中以 filePath 为 key 的 watcher 数组,并将实例返回。

  2. 设置子文件夹内嵌监听
    watch 方法的另一部分,则是进行设置内嵌监听 setNestedWatching

    watchpack/lib/DirectoryWatcher.js
    
    DirectoryWatcher.prototype.watch = function watch(filePath, startTime) {
        // 省略内部 Watcher 实例生成
        var data;
        if(filePath === this.path) {
            this.setNestedWatching(true);
        }
          // 省略已有数据处理
    };
    
    DirectoryWatcher.prototype.setNestedWatching = function(flag) {
        if(this.nestedWatching !== !!flag) {
            this.nestedWatching = !!flag;
            if(this.nestedWatching) {
                Object.keys(this.directories).forEach(function(directory) {
                    this.createNestedWatcher(directory);
                }, this);
            } else {
                Object.keys(this.directories).forEach(function(directory) {
                    this.directories[directory].close();
                    this.directories[directory] = true;
                }, this);
            }
        }
    };

    在处理 filePath == this.path 的时候,也就是 DirectoryWatcher.prototype.watch 传入的路径与 Directory 生成实例的路径相同的时候(watchManager.js 中的 watchDirectory 方法的调用 this.getDirectoryWatcher(directory, options).watch(directory, startTime) 满足此条件)会在 watch 中调用 DirectoryWatcher.prototype.setNestedWatching 进行子文件夹的监听的创建。

    watchpack/lib/DirectoryWatcher.js
    
    DirectoryWatcher.prototype.createNestedWatcher = function(directoryPath) {
      this.directories[directoryPath] = watcherManager.watchDirectory(directoryPath, this.options, 1);
      this.directories[directoryPath].on("change", function(filePath, mtime) {
        if(this.watchers[withoutCase(this.path)]) {
          this.watchers[withoutCase(this.path)].forEach(function(w) {
            if(w.checkStartTime(mtime, false)) {
              w.emit("change", filePath, mtime);
            }
          });
        }
      }.bind(this));
    };

    子文件夹的监听同样是通过上层watchManager.js 中的 watchManager.watchDirectory 的调用实现,同时这里会多绑定一次 change 事件,实现当子文件夹变化的时候触发父文件夹的 change 事件。

  3. 处理已有数据
    在完成 watcher 实例创建之后,会针对在 watch实例创建过程中发生的文件(夹)变动进行处理,保证文件的变动能完备更新

    watchpack/lib/DirectoryWatcher.js
    
    DirectoryWatcher.prototype.watch = function watch(filePath, startTime) {
        // 省略内部 Watcher 实例生成
        var data;
          if(filePath === this.path) {
          // 省略设置子文件内嵌监听
          data = false;
          Object.keys(this.files).forEach(function(file) {
            var d = this.files[file];
            if(!data)
              data = d;
            else
              data = [Math.max(data[0], d[0]), Math.max(data[1], d[1])];
          }, this);
        } else {
          data = this.files[filePath];
        }
        process.nextTick(function() {
          if(data) {
            if(data[0] > startTime)
              watcher.emit("change", data[1]);
          } else if(this.initialScan && this.initialScanRemoved.indexOf(filePath) >= 0) {
            watcher.emit("remove");
          }
        }.bind(this));
    };

    处理已有数据也是分成两个步骤

    1. 读取数据
      这里对于文件、文件夹的处理,获取数据的方式也不同。
      对于监听文件夹路径的情况:

      Object.keys(this.files).forEach(function(file) {
      var d = this.files[file];
      if(!data)
        data = d;
      else
        data = [Math.max(data[0], d[0]), Math.max(data[1], d[1])];
      }, this);

      可以从对 this.files 的循环看出,这里实际上是取到的是该文件夹下所有文件中的变更流程执行时间点文件最后修改时间点 的最大值。
      对于单个文件路径的情况:

       data = this.files[filePath];

      则是直接取到当前监听文件路径的数据。

    2. 触发事件
      当数据完成获取后,就进入到 触发事件 的阶段,这个阶段会将前面取到的 变更流程执行时间点 与由 Watching.prototype._go 中设置的编译开始时间 startTime 进行比较:

      process.nextTick(function() {
      if(data) {
        if(data[0] > startTime)
       watcher.emit("change", data[1]);
      } else if(this.initialScan && this.initialScanRemoved.indexOf(filePath) >= 0) {
        watcher.emit("remove");
      }
      }.bind(this));

      变更流程执行时间点startTime 时间晚的时候说明,在编译开始后,针对文件夹的情况是文件夹其中的文件发生了变化,对于单个文件的情况,则是该文件发生变化。则触发 change 事件。
      这里还会有一个判断是:

      if(this.initialScan && this.initialScanRemoved.indexOf(filePath) >= 0) {
        watcher.emit("remove");
      }

      对于第一个条件 this.initialScan,上面提到在完成 doInitialScan 完成后会复制为 false

      完成赋值过程后, 会将 this.initialScan 设置成 false 表示首次查看结束,设置 this.initialScanRemovednull ,表示在首次查看过程中就删除的文件(夹)的处理也结束

      则这条判断是在 watch 进行的同时,doInitialScan 也还在进行的时候生效。
      对于第二个条件 this.initialScanRemoved.indexOf(filePath) ,这里主要落脚点在于 initialScanRemoved 对这个数组的操作

      watchpack/lib/DirectoryWatcher.js
       
      this.watcher.on("unlink", this.onFileUnlinked.bind(this));
      this.watcher.on("unlinkDir", this.onDirectoryUnlinked.bind(this));
       
      DirectoryWatcher.prototype.onFileUnlinked = function onFileUnlinked(filePath) {
        // 省略判断
        if(this.initialScan) {
         this.initialScanRemoved.push(filePath);
        }
      };
       
      DirectoryWatcher.prototype.onDirectoryUnlinked = function onDirectoryUnlinked(directoryPath) {
        // 省略判断
        if(this.initialScan) {
         this.initialScanRemoved.push(directoryPath);
        }
      };

      从事件绑定中可以看到,当在进行 doInitialScan 过程中,发生了文件(夹)删除的情况,则会将删除的路径 pushinitialScanRemoved 数组中。
      那么整合两个条件,在初始扫描的场景下,监听文件(夹)发生删除的情况时,则触发 remove 事件,避免增加无效的监听。

在整个数据监听通道的流程中,都是围绕 Watcher 实例进行开展,通过 Watcher 承上启下衔接上下逻辑的作用。

触发流程

在完成了从 Watchpack.prototype.watch -> WatcherManager.prototype.watchFileWatcherManager.prototype.watchDirectory -> Directory.prototype.watch 这条调用链之后,webpack --watch 就会等待文件的改动,进行编译的再次触发。

chokidar

目前 watchpack 中对文件(夹)的监听通过 chokidar 来实现,首先关联的逻辑就是 chokidar 的具体调用,关注到 DirectoryWatcher 中调用 chokidar 的部分

watchpack/lib/DirectoryWatcher.js

function DirectoryWatcher(directoryPath, options) {
    EventEmitter.call(this);
    this.watcher = chokidar.watch(directoryPath, {
        ignoreInitial: true,
        persistent: true,
        followSymlinks: false,
        depth: 0,
        atomic: false,
        alwaysStat: true,
        ignorePermissionErrors: true,
        usePolling: options.poll ? true : undefined,
        interval: typeof options.poll === "number" ? options.poll : undefined
    });
    this.watcher.on("add", this.onFileAdded.bind(this));
    this.watcher.on("addDir", this.onDirectoryAdded.bind(this));
    this.watcher.on("change", this.onChange.bind(this));
    this.watcher.on("unlink", this.onFileUnlinked.bind(this));
    this.watcher.on("unlinkDir", this.onDirectoryUnlinked.bind(this));
    this.watcher.on("error", this.onWatcherError.bind(this));
}

首先是 chokidar 的初始化,

  • ignoreInitial:默认为false, 设置为 true ,避免在 chokidar 自身初始化的过程中触发 addaddDir 事件

  • persistent:默认为 true,设置为 true,保持文件监听,为 false 的情况下,会在 ready 事件后不再触发事件

  • followSymlinks:默认为 true,设置为 false,对 link 文件不监听真实文件内容的变化

  • depth: 设置为 0 ,表明对子文件夹不进行递归监听

  • atomic:默认为 false,设置为 false,关闭对同一文件删除后 100ms 内重新增加的行为触发 change 事件,而不是 unlinkadd 事件的默认行为

  • alwaysStat:默认为false,设置为 true,保持传递 fs.Stats,即使可能存在不存在的情况

  • ignorePermissionErrors:默认为 false,设置为 true,忽略权限错误的提示

  • usePolling:默认为 false,根据实际配置来设置,是否开启 polling 轮询模式

  • interval:轮询模式的周期时间,根据实际配置来设置,轮询模式的具体时间

其次绑定对应的文件(夹)事件 addaddDirchangeunlinkunlinkDir

完成初始化和事件绑定后,通过各个事件的回调函数来进行监听逻辑的触发和向上层传递。

文件时间精确度数值(FS_ACCURENCY)确定

根据上面提到的 this.watcher.on("change", this.onChange.bind(this)); 当文件内容发生变化时,进入绑定的 onChange 回调函数

watchpack/lib/DirectoryWatcher.js

var FS_ACCURENCY = 10000;

DirectoryWatcher.prototype.onChange = function onChange(filePath, stat) {
    if(filePath.indexOf(this.path) !== 0) return;
    if(/[\\\/]/.test(filePath.substr(this.path.length + 1))) return;
    var mtime = +stat.mtime;
    if(FS_ACCURENCY > 1 && mtime % 1 !== 0)
        FS_ACCURENCY = 1;
    else if(FS_ACCURENCY > 10 && mtime % 10 !== 0)
        FS_ACCURENCY = 10;
    else if(FS_ACCURENCY > 100 && mtime % 100 !== 0)
        FS_ACCURENCY = 100;
    else if(FS_ACCURENCY > 1000 && mtime % 1000 !== 0)
        FS_ACCURENCY = 1000;
    else if(FS_ACCURENCY > 2000 && mtime % 2000 !== 0)
        FS_ACCURENCY = 2000;
    this.setFileTime(filePath, mtime, false, "change");
};

onChange 中,除了调用 this.setFileTime 进行文件变更数据更新、对应 watcher 实例事件触发之外,还会进行 FS_ACCURENCY 的校准逻辑。可以看到校准的规则是根据文件的修改时间取模的精度来确定值。关于这个变量值,这里从 issue 中找到 webpack 作者 sokra 的描述:

FS_ACCURENCY should automatically adjust to your file system accuracy

With low fs accuracy files could have changed even if mime is equal

其中说到,在文件系统数据低精确度的情况,可能出现 mime 相同,但也发生了改变的情况。通过在后面的变更判断中通过加入精确值的度量值计算,起到平衡数值的作用(例如var startTime = this.startTime && Math.floor(this.startTime / FS_ACCURENCY) * FS_ACCURENCY;)。

watcher 实例事件触发

之前提到,watcher 实例是文件变更信息的通道,通过在 watcher 上的事件绑定,将 chokidar 监听到的文件(夹)变更信息,传递到 watchpack 层的逻辑。进入 this.setFileTime 后,则进行对应事件的触发

watchpack/lib/DirectoryWatcher.js

DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) {
    var now = Date.now();
    var old = this.files[filePath];
    this.files[filePath] = [initial ? Math.min(now, mtime) : now, mtime];
      
    if(!old) {
        if(mtime) {
            if(this.watchers[withoutCase(filePath)]) {
               // 文件事件触发具体逻辑
            }
        }
    } else if(!initial && mtime && type !== "add") {
      // 文件事件触发具体逻辑
    } else if(!initial && !mtime) {
      // 文件事件触发具体逻辑
    }
    if(this.watchers[withoutCase(this.path)]) {
      // 文件目录事件触发
    }
};

事件触发分为两个大的阶段,第一个阶段为对于 filePath 文件的事件触发,第二个阶段为对于当前 DirectoryWatcher 对应 path 属性文件夹的事件触发。

1.filepath 文件的事件触发

watchpack/lib/DirectoryWatcher.js

DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) {
    var now = Date.now();
    var old = this.files[filePath];
    this.files[filePath] = [initial ? Math.min(now, mtime) : now, mtime];
    if(!old) {
        if(mtime) {
            if(this.watchers[withoutCase(filePath)]) {
                this.watchers[withoutCase(filePath)].forEach(function(w) {
                    if(!initial || w.checkStartTime(mtime, initial)) {
                        w.emit("change", mtime);
                    }
                });
            }
        }
    } else if(!initial && mtime && type !== "add") {
        if(this.watchers[withoutCase(filePath)]) {
            this.watchers[withoutCase(filePath)].forEach(function(w) {
                w.emit("change", mtime);
            });
        }
    } else if(!initial && !mtime) {
        if(this.watchers[withoutCase(filePath)]) {
            this.watchers[withoutCase(filePath)].forEach(function(w) {
                w.emit("remove");
            });
        }
    }
  
      // 省略文件夹触发
};

文件事件触发,实际会涉及到三个逻辑,单纯已有文件改变的触发,对应第二个逻辑

  • 对于 filePath 之前没有数据设置的情况 if(!old)

       这里穿插到前面初始化的逻辑,在前面 `doIntialScan` 中 `initial` 的参数为 `true`, 则进入 `checkStartTime` 函数判断
    
    watchpack/lib/DirectoryWatcher.js
    
    Watcher.prototype.checkStartTime = function checkStartTime(mtime, initial) {
    if(typeof this.startTime !== "number") return !initial;
    var startTime = this.startTime && Math.floor(this.startTime / FS_ACCURENCY) * FS_ACCURENCY;
    return startTime <= mtime;
    };
      会去比较编译开始时间 `statrTime` 与文件最后修改时间 `mtime` 来判断是否需要触发事件,`doInitialScan` 场景下,默认 `FS_ACCURENCY` 的值是 `10000` ,意思是在编译前的 10s 范围内的改动都会触发 `change` 事件,那么这样是否会存在初始化时多触发一次编译呢?在上面提到  [issue](https://github.com/webpack/watchpack/issues/25) 中,作者同样给出了解释
       > This may not happen fast enough if you have few files and the files are created unlucky on a timestamp modulo 10s
    
       > The watching may loop in a unlucky case, but this should not result in a different compilation hash. I. e. the webpack-dev-server doesn't trigger a update if the hash is equal.
    
       及时触发这样的 `unlucky case`,也只会在 `doInitailScan` 过程中文件内容真正发生变化导致 `hash` 变化的时候再次触发编译更新。
    
       这条判断同样适用当有新增文件,触发 `add` 事件的情况。
    
  • 对于已有文件变化(非 doInitial 过程中、add 新增文件事件触发,if(!initial && mtime && type !== "add")

      对应这种情况,则直接会触发 `change` 事件
    if(this.watchers[withoutCase(filePath)]) {
      this.watchers[withoutCase(filePath)].forEach(function(w) {
    w.emit("change", mtime);
      });
    }
       找到对应文件的监听 `watcher` 触发 `change` 事件,对应上层逻辑逻辑进行响应。
    
  • mtime 不存在的情况(文件删除)

    watchpack/lib/DirectoryWatcher.js
    
    this.watcher.on("unlink", this.onFileUnlinked.bind(this));
    DirectoryWatcher.prototype.onFileUnlinked = function onFileUnlinked(filePath) {
      // 省略其他操作
      this.setFileTime(filePath, null, false, "unlink");
    };
 当文件删除触发 `unlink` 事件时,调用 `setFileTime` 时,则会传递 `mtime` 为 `null`。则事件触发逻辑与第二种情况方式相同,只是从 `change` 事件变成了 `remove` 事件。

2.DirectoryWatcher 对应 path 属性文件夹的事件触发

DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) {
      // 省略文件触发
  
    if(this.watchers[withoutCase(this.path)]) {
        this.watchers[withoutCase(this.path)].forEach(function(w) {
            if(!initial || w.checkStartTime(mtime, initial)) {
                w.emit("change", filePath, mtime);
            }
        });
    }
};

因为是监听的是文件夹下的文件发生的变化,所以在完成了对应文件事件的触发之后,会进行监听文件夹(路径为实例化 DirectoryWatcher 时传入的 this.path)的触发,这里除了会将文件的最后修改时间 mtine 传递,还会将对应的文件路径 this.filePath 也当做参数一起传递到绑定的事件回调参数中。

在通过 watcher 这个继承了 EventEmitter 对象的实例触发事件后,就完成了底层文件(夹)监听触发的功能,紧接着就是上层对象对于 watcher 实例的事件触发的对应处理,最终关联上 webpack 的编译启动流程。

上层响应

watchpack.js

在上面有提到

watcherManager.js 文件中的 watchFile 以及 watchDirectory 都传递了同类型的参数调用了 this.getDirectoryWatcher ,并在随后调用了返回实例的 watch 方法,并将 watch 方法的返回继续往上层 watchpack.jsthis._fileWatcherthis._dirWatcher 方法。

watch 实例的上层响应的第一层在 watchpack.js 中的 Watchpack.prototype._fileWatcherWatchpack.prototype._dirWatcher 中完成,分别针对文件和文件夹的变更处理

watchpack/lib/watchpack.js

Watchpack.prototype._fileWatcher = function _fileWatcher(file, watcher) {
    watcher.on("change", this._onChange.bind(this, file));
    return watcher;
};

Watchpack.prototype._dirWatcher = function _dirWatcher(item, watcher) {
    watcher.on("change", function(file, mtime) {
        this._onChange(item, mtime, file);
    }.bind(this));
    return watcher;
};

这里 _fileWatcher_dirWatcherchange 的事件都是将逻辑导向了 Watchpack.prototype._onChange

watchpack/lib/watchpack.js

Watchpack.prototype._onChange = function _onChange(item, mtime, file) {
    file = file || item;
    this.mtimes[file] = mtime;
    if(this.paused) return;
    this.emit("change", file, mtime);
    if(this.aggregateTimeout)
        clearTimeout(this.aggregateTimeout);
    if(this.aggregatedChanges.indexOf(item) < 0)
        this.aggregatedChanges.push(item);
    this.aggregateTimeout = setTimeout(this._onTimeout, this.options.aggregateTimeout);
};

函数会首先触发 Watchpack 实例的 change 事件,传入触发的文件(夹)的路径,以及最后修改时间,供上层逻辑操作。

然后开始进行 aggregate 逻辑的触发,可以看到这里的大致含义是在文件(夹)发生变更 this.aggregateTimeout 后,进行 Watchpack.prototype._onTimeout 逻辑,在此之前,会将修改的文件(夹)路径暂存到 aggregatedChanges 数组中

watchpack/lib/watchpack.js

Watchpack.prototype._onTimeout = function _onTimeout() {
    this.aggregateTimeout = 0;
    var changes = this.aggregatedChanges;
    this.aggregatedChanges = [];
    this.emit("aggregated", changes);
};

Watchpack.prototype._onTimeout 则是当最后一次文件(夹)触发之后没有变更的 200ms 后,通过 this.aggregatedChanges 将接连不断的变更聚合通过 aggregated 事件传递给上层。

那么对应每一个变更,实际会牵涉触发一次 change 事件,以及关联一次 aggregated 事件,传给给上层,关联实际的编译重新触发逻辑。

NodeWatchFileSystem.js

前面提到

NodeWatchFileSystem.js 中的实现再一次的依赖 watchpack 完成。通过封装 watchpack 的监听逻辑,完成绑定相应的文件变更事件,进行上层 compiler.invalidate 方法调用,触发再次编译流程。

那么绑定 watchpack 实例的事件,来完成这一层的逻辑

webpack/lib/NodeWatchFileSystem.js

NodeWatchFileSystem.prototype.watch = function watch(files, dirs, missing, startTime, options, callback, callbackUndelayed) {
    // 省略参数合法性检测
  
    this.watcher = new Watchpack(options);

    if(callbackUndelayed)
        this.watcher.once("change", callbackUndelayed);

    this.watcher.once("aggregated", function(changes) {
          //1.
        if(this.inputFileSystem && this.inputFileSystem.purge) {
            this.inputFileSystem.purge(changes);
        }
          //2.
        var times = this.watcher.getTimes();
          //3.
        callback(null, changes.filter(function(file) {
            return files.indexOf(file) >= 0;
        }).sort(), changes.filter(function(file) {
            return dirs.indexOf(file) >= 0;
        }).sort(), changes.filter(function(file) {
            return missing.indexOf(file) >= 0;
        }).sort(), times, times);
    }.bind(this));

    this.watcher.watch(files.concat(missing), dirs, startTime);
  
    // 省略返回
};

与上面 watchpack 触发事件一致,在 NodeWatchFileSystem 这一层逻辑中,其实对下一层 Watchpack 的就是通过绑定主要的 changeaggregated 事件完成的。

对于 change 事件,会直接传递到上层的 callbackUndelayed

对于 aggregated 事件,

  1. 首先会调用 this.inputFileSystem.purge(changes) ,将文件系统中涉及到变更的文件的记录清空。

  2. 其次调用 Watchpack 实例的 getTimes() 方法获取监听文件(夹)的 变更流程执行时间点文件最后修改时间点 的最大值,便于在后续判断是否需要进行重新编译,例如 cacheModule.needRebuild(this.fileTimestamps, this.contextTimestamps);

  3. 最后在调用上层回调之前,会将变化的文件(夹)根据监听时传入参数通过挨个过滤的方式进行分发到每个参数中,完成之后,流程就会走到最后一层也是最初调用监听的一层 Compiler.js

Compiler.js

在上文中提过

Watching.prototype.watch 通过 compiler.watchFileSystemwatch 方法实现,可以大致看出在变化触发编译后,会执行传递的回调函数,最终会调用 Watching.prototype.invalidate 进行编译触发

从调用开始,通过最底层的 chokidar 完成文件(夹)监听事件的触发,通过事件传递的方式,又回到调用处,进行重新编译。

回顾整个触发流程,纵向 4 个逻辑层级之间进行传递,

  • DirectoryWatcher:完成对文件(夹)的监听实现,以及初步监听数据加工

  • watchpack:完成触发底层逻辑的封装,实现上层逻辑跟触发逻辑解耦

  • NodeWatchFileSystem:完成对监听数据业务逻辑处理,进行最后回调处理

  • Compiler:完成最终业务响应

总结 & 衔接

watch 流程利用事件模型,采用多个逻辑层的设计,对复杂的触发流程进行解耦拆分,实现了比较清晰可维护的代码结构。

在完成 watch 流程,触发重新编译后,与 run 流程相不同的是,webpack 为了提高编译速度,降低编译的时间消耗与提高编译性能,在重新编译的很多环节中都设置了缓存机制,让二次编译的速度得到大大提高。下一篇文章主要对 cache 的情况进行描述。

查看原文

deepfunc 发布了文章 · 2019-09-12

带你撸一个实用的 hook 模块

hook 模式在我们日常的代码中会经常用到,譬如我们会定义一系列的事件,然后在需要的时候通知这些事件的处理函数去逐个处理等等。

本质上来说 hook 模式类似与责任链模式,hook 建立了特定事件与事件处理程序之间的一对多关系,当事件需要处理时,沿着事件处理程序链条挨个执行。但不同的设计可能会有不同的处理行为,在本文中,链条靠前的处理程序有权决定是否需要交给下个处理程序接着处理还是直接返回最终结果。

这里遵循了开闭原则:对扩展开放,对修改封闭。

这意味着,在 hook 模式中,一旦有新的事件处理需求,我们只需要注册事件处理函数按照既定行为处理就好,不需要更改 hook 框架。

下面我们先来看下 hook 框架的创建方式和有哪些功能接口:

function createHook() {
  const _hooks = {};
  
  function register(key, fn) {...}
  
  function isRegistered(key) {...}
  
  function syncExec(key, ...args) {...}
  
  function asyncExec(key, ...args) {...}
  
  function unregisterAll(key) {...}
  
  return {
    register,
    isRegistered,
    syncExec,
    asyncExec,
    unregisterAll
  };
}

以上就是这个 hook 框架的概况,使用时我们使用 const hook = createHook(); 创建一个 hook 对象的实例来使用。下面我们就来逐一分析的一下每个功能的实现和使用。

register(key, fn)

这个函数的作用就是注册事件处理——key 是事件名称字符串,fn 就是事件处理函数。这里需要注意的是 fn 需要是一个高阶函数,格式如下:

const fn = function (next) {
  return function (...args) {
    return next({...});
    // return {...}
  }
};

这里注入的 next 是个函数:调用 next 即调用下个处理函数处理,如果不需要也可以直接返回处理结果,所以位置靠前的处理函数会有选择权。

回到 register 函数,实现如下:

function register(key, fn) {
  if (!_hooks[key]) {
    _hooks[key] = [];
  }
  _hooks[key].push(fn);

  return function unregister() {
    const fns = _hooks[key];
    const idx = fns.indexOf(fn);
    fns.splice(idx, 1);
    if (fns.length === 0) {
      delete _hooks[key];
    }
  };
}

注册逻辑很简单,_hooks 是一个对象,用来记录事件名称与事件处理函数的关系,因为有多个并且要按注册顺序执行,所以每一个 key 属性是一个数组。

这里值得注意的是,register 函数返回了一个 unregister 函数——用来注销事件处理,这样做的目的是用户不用记录 fn 的引用,但后续需要注销时,保留 unregister 并在适当的时候使用他。

isRegistered(key)

isRegistered 是用来判断某个事件是否存在对应的处理函数,内容很简单:

function isRegistered(key) {
  return (_hooks[key] || []).length > 0;
}

syncExec(key, ...args)

syncExec 是用来同步执行某个事件的处理函数链,并返回处理结果。使用示例如下:

const hook = createHook();

// 第一个处理函数
hook.register('process-test', function (next) {
  return function (num) {
    return next(num + 1);
  };
});
// 第二个处理函数
hook.register('process-test', function (next) {
  return function (num) {
    return next(num + 2);
  };
});

const rst = hook.syncExec('process-test', 1);
// rst 的结果是 4

如例子所示,每一个处理函数通过 next 将自己的处理结果交给下一个处理函数,或者直接 return 最终的结果。

下面我们来看看 syncExec 的具体实现:

function syncExec(key, ...args) {
  const fns = (_hooks[key] || []).slice();

  let idx = 0;
  const next = function (...args) {
    if (idx >= fns.length) {
      return (args.length > 1 ? args : args[0]);
    } else {
      const fn = fns[idx++].call(this, next.bind(this));
      return fn.apply(this, args);
    }
  };

  return next.apply(this, args);
}

首先,值得注意的是,当获取某个 key 对应的处理函数数组时用了 slice() 去拷贝,这是为了防止在执行过程中原数组发生变化。然后构造一个 next 函数注入到事件处理函数中(还记得事件处理函数是高阶函数吗?),并通过闭包记录下一个要执行的事件处理函数的索引 idx。然后通过第一个 next 的调用,整个处理链条就开始运转了。

这里有一个特殊的逻辑是返回结果的格式处理。如果 next 传递的是多个参数,那么返回结果是一个数组,包含所有的参数;如果 next 传递的是单个参数,那么返回结果就是那一个参数。

asyncExec(key, ...args)

既然有同步执行,那我们还需要有异步执行。asyncExec 的使用示例如下:

const hook = createHook();

hook.register('process-test', function (next) {
  return function (obj) {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve(next({ count: obj.count + 1 }));
      }, 100);
    });
  };
});
hook.register('process-test', function (next) {
  return function (obj) {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve(next({ count: obj.count + 2 }));
      }, 100);
    });
  };
});

hook.asyncExec('process-test', { count: 66 }).then(rst => {
  console.log(rst);
  // rst === 69
});

当事件处理函数需要异步执行时,我们可以调用 asyncExec 来处理,而且事件处理函数中就算返回的是同步结果,比如上面的第一个处理函数执行返回 return next({ count: obj.count + 1 }); 也是可以的。

下面我们来看看 asyncExec 是如何实现的:

function asyncExec(key, ...args) {
  const fns = (_hooks[key] || []).slice();
  let idx = 0;

  const next = function (...args) {
    if (idx >= fns.length) {
      return Promise.resolve(args.length > 1 ? args : args[0]);
    } else {
      try {
        const fn = fns[idx++].call(this, next.bind(this));
        const rst = fn.apply(this, args);
        return Promise.resolve(rst);
      } catch (err) {
        return Promise.reject(err);
      }
    }
  };

  return next.apply(this, args);
}

这里的核心思想就是 resolve 的参数如果是 Promise,那么原 Promise 的状态由里层的 Promise 决定,不熟悉的童鞋可以复习下 Promise 哈。这里还有个需要注意的地方是,处理了当事件处理函数执行时抛出的异常。然后对于返回结果的处理也是与 syncExec 同样的逻辑。

unregisterAll(key)

最后的 unregisterAll 就很简单了,注销掉某个事件上的所有处理函数:

function unregisterAll(key) {
  delete _hooks[key];
}

总结

以上就是 hook 模块的整个解析,源码在 这里。更详细的使用案例请参考 单元测试

我想有些童鞋可能会问:“为什么不把 Hook 写成一个 Class,然后业务系统去继承 hook 来使用,这样不就可以把 Hook 功能继承到业务对象里面了吗?”。

这里主要是考虑到遵循另一个设计原则:多用组合,少用继承。有时候使用组合方式具备更大的弹性,所以如果我们想把 hook 继承到自己的业务对象里面,简单点可以像下面这样做:

const app = {...};
const hook = createHook();

// mixin
Object.assign(app, hook);

如果对本篇有疑问或建议,欢迎在 这里 提出。

欢迎 star 和关注我的 JS 博客:小声比比 JavaScript

查看原文

赞 3 收藏 1 评论 0

deepfunc 收藏了文章 · 2019-08-22

可靠React组件设计的7个准则之终篇

翻译:刘小夕

原文链接:https://dmitripavlutin.com/7-...

本篇文章重点阐述可测试和富有意义。因水平有限,文中部分翻译可能不够准确,如果你有更好的想法,欢迎在评论区指出。

尽管 组合复用纯组件 三个准则阅读量不高,不过本着有始有终的原则,当然我个人始终还是觉得此篇文章非常优质,还是坚持翻译完了。本篇是最后 可靠React组件设计 的最后一篇,希望对你的组件设计有所帮助。

更多文章可戳: https://github.com/YvetteLau/...

———————————————我是一条分割线————————————————

如果你还没有阅读过前几个原则:

可测试和经过测试

经过测试的组件验证了其在给定输入的情况下,输出是否符合预期。

可测试组件易于测试。

如何确保组件按预期工作?你可以不以为然地说:“我通过手动验证其正确性”。

如果你计划手动验证每个组件的修改,那么迟早,你会跳过这个繁琐的环节,进而导致你的组件迟早会出现缺陷。

这就是为什么自动化组件验证很重要:进行单元测试。单元测试确保每次进行修改时,组件都能正常工作。

单元测试不仅涉及早期错误检测。 另一个重要方面是能够验证组件架构是否合理。

我发现以下几点特别重要:

一个不可测试或难以测试的组件很可能设计得很糟糕。

组件很难测试往往是因为它有很多 props、依赖项、需要原型和访问全局变量,而这些都是设计糟糕的标志。

当组件的架构设计很脆弱时,就会变得难以测试,而当组件难以测试的时候,你大概念会跳过编写单元测试的过程,最终的结果就是:组件未测试。

clipboard.png

总之,需要应用程序未经测试的原因都是因为设计不当,即使你想测试这样的应用,你也做不到。

案例学习:可测试意味着良好的设计

我们来测试一下 封装章节的两个版本的 <Controls> 组件。

import assert from 'assert';
import { shallow } from 'enzyme';

class Controls extends Component {
    render() {
        return (
            <div className="controls">
                <button onClick={() => this.updateNumber(+1)}>
                    Increase
                </button>
                <button onClick={() => this.updateNumber(-1)}>
                    Decrease
                </button>
            </div>
        );
    }
    updateNumber(toAdd) {
        this.props.parent.setState(prevState => ({
            number: prevState.number + toAdd
        }));
    }
}

class Temp extends Component {
    constructor(props) {
        super(props);
        this.state = { number: 0 };
    }
    render() {
        return null;
    }
}

describe('<Controls />', function () {
    it('should update parent state', function () {
        const parent = shallow(<Temp />);
        const wrapper = shallow(<Controls parent={parent} />);

        assert(parent.state('number') === 0);

        wrapper.find('button').at(0).simulate('click');
        assert(parent.state('number') === 1);

        wrapper.find('button').at(1).simulate('click');
        assert(parent.state('number') === 0);
    });
});

我们可以看到 <Controls> 测试起来很复杂,因为它依赖父组件的实现细节。

测试时,需要一个额外的组件 <Temp>,它模拟父组件,验证 <Controls> 是否正确修改了父组件的状态。

<Controls> 独立于父组件的实现细节时,测试会变得更加容易。现在我们来看看正确封装的版本是如何测试的:

import assert from 'assert';
import { shallow } from 'enzyme';
import { spy } from 'sinon';

function Controls({ onIncrease, onDecrease }) {
    return (
        <div className="controls">
            <button onClick={onIncrease}>Increase</button>
            <button onClick={onDecrease}>Decrease</button>
        </div>
    );
}

describe('<Controls />', function () {
    it('should execute callback on buttons click', function () {
        const increase = sinon.spy();
        const descrease = sinon.spy();
        const wrapper = shallow(
            <Controls onIncrease={increase} onDecrease={descrease} />
        );

        wrapper.find('button').at(0).simulate('click');
        assert(increase.calledOnce);
        wrapper.find('button').at(1).simulate('click');
        assert(descrease.calledOnce);
    });
});

良好封装的组件,测试起来简单明了,相反,没有正确封装的组件难以测试。

可测试性是确定组件结构良好程度的实用标准。

富有意义

很容易一个有意义的组件作用是什么。

代码的可读性是非常重要的,你使用了多少次模糊代码?你看到了字符串,但是不知道意思是什么。

开发人员大部分时间都在阅读和理解代码,而不是实际编写代码。我们花75%的时间理解代码,花20%的时间修改现有代码,只有5%编写新的代码。

在可读性方面花费的额外时间可以减少未来队友和自己的理解时间。当应用程序增长时,命名实践变得很重要,因为理解工作随着代码量的增加而增加。

阅读有意义的代码很容易。然而,想要编写有意义的代码,需要清晰的代码实践和不断的努力来清楚地表达自己。

组件命名

帕斯卡命名法

组件名是由一个或多个帕斯卡单词(主要是名词)串联起来的,比如:<DatePicker><GridItem>Application<Header>

专业化

组件越专业化,其名称可能包含的单词越多。

名为 <HeaderMenu> 的组件表示头部有一个菜单。 名称 <SidebarMenuItem> 表示位于侧栏中的菜单项。

当名称有意义地暗示意图时,组件易于理解。为了实现这一点,通常你必须使用详细的名称。那很好:更详细比不太清楚要好。

假设您导航一些项目文件并识别2个组件: <Authors><AuthorsList>。 仅基于名称,您能否得出它们之间的区别? 很可能不能。

为了获取详细信息,你不得不打开 <Authors> 源文件并浏览代码。字后,你知道 <Authors> 从服务器获取作者列表并呈现 <AuthorsList> 组件。

更专业的名称而不是 <Authors> 不会创建这种情况。更好的名称如:<FetchAuthors><AuthorsContainer><AuthorsPage>

一个单词 - 一个概念

一个词代表一个概念。例如,呈现项目概念的集合由列表单词表示。

每个概念对应一个单词,然后在整个应用程序中保持关系一致。结果是可预测的单词概念映射关系。

当许多单词表示相同的概念时,可读性会受到影响。例如,你定义一个呈现订单列表组件为:<OrdersList>,定义另一个呈现费用列表的组件为: <ExpensesTable>

渲染项集合的相同概念由2个不同的单词表示:listtable。 对于相同的概念,没有理由使用不同的词。 它增加了混乱并打破了命名的一致性。

将组件命名为 <OrdersList><ExpensesList> (使用 list)或 <OrdersTable><ExpensesTable>(使用 table )。使用你觉得更好的词,只要保持一致。

注释

组件,方法和变量的有意义的名称足以使代码可读。 因此,注释大多是多余的。

案例研究:编写自解释代码

常见的滥用注释是对无法识别和模糊命名的解释。让我们看看这样的例子:

// <Games> 渲染 games 列表
// "data" prop contains a list of game data
function Games({ data }) {
    // display up to 10 first games
    const data1 = data.slice(0, 10);
    // Map data1 to <Game> component
    // "list" has an array of <Game> components
    const list = data1.map(function (v) {
        // "v" has game data
        return <Game key={v.id} name={v.name} />;
    });
    return <ul>{list}</ul>;
}

<Games
    data=[{ id: 1, name: 'Mario' }, { id: 2, name: 'Doom' }]
/>

上例中的注释澄清了模糊的代码。 <Games>data, data1, v,,数字 10 都是无意义的,难以理解。

如果重构组件,使用有意义的 props 名和变量名,那么注释就可以被省略:

const GAMES_LIMIT = 10;

function GamesList({ items }) {
    const itemsSlice = items.slice(0, GAMES_LIMIT);
    const games = itemsSlice.map(function (gameItem) {
        return <Game key={gameItem.id} name={gameItem.name} />;
    });
    return <ul>{games}</ul>;
}

<GamesList
    items=[{ id: 1, name: 'Mario' }, { id: 2, name: 'Doom' }]
/>

不要使用注释去解释你的代码,而是代码即注释。(小夕注:代码即注释很多人未必能做到,另外因团队成员水平不一致,大家还是应该编写适当的注释)

表现力阶梯

我将一个组件表现力分为4个台阶。 组件在楼梯上的位置越低,意味着需要更多的努力才能理解。

clipboard.png

你可以通过以下几种方式来了解组件的作用:
  • 读取变量名 和 props
  • 阅读文档/注释
  • 浏览代码
  • 咨询作者

如果变量名和 props 提供了足够的信息足以让你理解这个组件的作用和使用方式,那就是一种超强的表达能力。 尽量保持这种高质量水平。

有些组件具有复杂的逻辑,即使是好的命名也无法提供必要的细节。那么就需要阅读文档。

如果缺少文档或没有文档中没有回答所有问题,则必须浏览代码。由于花费了额外的时间,这不是最佳选择,但这是可以接受的。

在浏览代码也无助于理解组件时,下一步是向组件的作者询问详细信息。这绝对是错误的命名,并应该避免进入这一步。最好让作者重构代码,或者自己重构代码。

持续改进

重写是写作的本质。专业作家一遍又一遍地重写他们的句子。

要生成高质量的文本,您必须多次重写句子。阅读书面文字,简化令人困惑的地方,使用更多的同义词,删除杂乱的单词 - 然后重复,直到你有一段愉快的文字。

有趣的是,相同的重写概念适用于设计组件。有时,在第一次尝试时几乎不可能创建正确的组件结构,因为:

  • 紧迫的项目排期不允许在系统设计上花费足够的时间
  • 最初选择的方法是错误的
  • 刚刚找到了一个可以更好地解决问题的开源库
  • 或任何其他原因。

组件越复杂,就越需要验证和重构。

clipboard.png

组件是否实现了单一职责,是否封装良好,是否经过充分测试?如果您无法回答某个肯定,请确定脆弱部分(通过与上述7个原则进行比较)并重构该组件。

实际上,开发是一个永不停止的过程,可以审查以前的决策并进行改进。

可靠性很重要

组件的质量保证需要努力和定期审查。这个投资是值得的,因为正确的组件是精心设计的系统的基础。这种系统易于维护和增长,其复杂性线性增加。

因此,在任何项目阶段,开发都相对方便。

另一方面,随着系统大小的增加,你可能忘记计划并定期校正结构,减少耦合。仅仅是是实现功能。

但是,在系统变得足够紧密耦合的不可避免的时刻,满足新要求变得呈指数级复杂化。你无法控制代码,系统的脆弱反而控制了你。错误修复会产生新的错误,代码更新需要一系列相关的修改。

悲伤的故事要怎么结束?你可能会抛弃当前系统并从头开始重写代码,或者很可能继续吃仙人掌。我吃了很多仙人掌,你可能也是,这不是最好的感觉。

解决方案简单但要求苛刻:编写可靠的组件。

结论

前文所说的7个准则从不用的角度阐述了同一个思想:

可靠的组件实现一个职责,隐藏其内部结构并提供有效的 props 来控制其行为。

单一职责和封装是 solid 设计的基础。(maybe你需要了解一下 solid 原则是什么。)

单一职责建议创建一个只实现一个职责的组件,并有一个改变的理由。

良好封装的组件隐藏其内部结构和实现细节,并定义 props 来控制行为和输出。

组合结构大的组件。只需将它们分成较小的块,然后使用组合进行整合,使复杂组件变得简单。

可复用的组件是精心设计的系统的结果。尽可能重复使用代码以避免重复。

网络请求或全局变量等副作用使组件依赖于环境。通过为相同的 prop 值返回相同的输出来使它们变得纯净。

有意义的组件命名和表达性代码是可读性的关键。你的代码必须易于理解和阅读。

测试不仅是一种自动检测错误的方式。如果你发现某个组件难以测试,则很可能是设计不正确。

成功的应用站在可靠的组件的肩膀上,因此编写可靠、可扩展和可维护的组件非常中重要。

在编写React组件时,你认为哪些原则有用?

最后谢谢各位小伙伴愿意花费宝贵的时间阅读本文,如果本文给了您一点帮助或者是启发,请不要吝啬你的赞和Star,您的肯定是我前进的最大动力。https://github.com/YvetteLau/...

推荐关注本人公众号

clipboard.png

查看原文

deepfunc 发布了文章 · 2019-08-16

Babel 7 转码的正确姿势

Babel 转码的配置是每位前端童鞋在日常工作中都会遇到的。刚开始我也是在网上搜索各种配置方法,升级到 Babel 7 的时候又折腾了一把,所以决定把自己的心得和理解记录下来,希望能帮助到有需要的童鞋。

这里呢不打算去讲每一个详细的配置项,毕竟官方文档是最权威的。这篇主要是说下 Babel 7 转码中会涉及到的几个主要库以及他们之间的关系,还有不同的项目类型怎么选择配置方案和一些技巧。

涉及到的主要库

首先呢先介绍一下 Babel 7 转码涉及到的“四大天王”:

  • @babel/preset-env
  • @babel/polyfill
  • @babel/runtime
  • @babel/plugin-transform-runtime

这四个库有什么作用和联系?相信很多童鞋跟我当初一样总是有点分不清,下面就来逐一简单解释下,当然最详细的内容还是要看官方文档。

@babel/preset-env

这个是 Babel 转码的环境预设,会根据你设定的目标环境(例如要支持的浏览器版本范围)来调整语法转换规则和选择环境垫片补丁,相比前任的优点是更智能,打包出来的体积也会更小。

@babel/polyfill

这个可以模拟基本完全的 ES6+ 环境(不能低于 Stage 4 的提案)。例如,新的 Class:Promise、Map,静态方法:Array.from,新的原型方法:Array.prototype.includes 等。但要注意的是,使用 polyfill 的话是会污染全局的,因为要提供原型方法的支持。

注意,这个库在 Babel 7.4.0 后已被弃用,用下面的代替:

import 'core-js/stable';
import 'regenerator-runtime/runtime';

但思想是一样的。

@babel/runtime

这个库提供了一些转码过程中的一些帮助函数。简单点来说就是在转码过程中,对于一些新语法,都会抽象一个个小的函数,在转码过程中完成替换。比如说我们写了一个 class Circle {...},转码后就会变成这样:

function _classCallCheck(instance, Constructor) {
  //...
}

var Circle = function Circle() {
  _classCallCheck(this, Circle);
};

所以在每次转换 class 新语法的时候,都会用 _classCallCheck 这个函数去替换。

@babel/plugin-transform-runtime

这个是和上面的 @babel/runtime 配合使用的。延续上面的那个例子,如果你的项目有多个 js 文件里面有 class 需要转码,那每个文件都会有一个重复的 _classCallCheck 函数定义,plugin-transform-runtime 的一个主要作用就是从统一的地方去引用这些帮助函数,消除代码冗余从而减少打包的体积:

var _classCallCheck = require("@babel/runtime/helpers/classCallCheck");

var Circle = function Circle() {
  _classCallCheck(this, Circle);
};

除此之外,他还提供了一个沙盒环境。如果我们使用 @babel/polyfill 来支持使用一些 ES6+ 的新特性的话(如:Promise、Map 等),会造成全局污染。通过配置 plugin-transform-runtime 的 corejs 选项可以开启沙盒环境支持,在当前需要转码的文件中单独引入所需的新功能。

安装说明

接下来我们看看上面的四大天王怎么安装:

npm install --save @babel/runtime, @babel/polyfill
npm install --save-dev @babel/preset-env, @babel/plugin-transform-runtime

这里也许有童鞋会有跟我当时同样的疑问:@babel/runtime 不是打包转码过程中用的吗,怎么会安装为运行环境依赖呢?——还记得 plugin-transform-runtime 会从统一的地方去引用这些帮助函数吗,这意味着这些代码会在运行时执行,所以当然是运行时依赖啦。

然后这里给大家提供一个小技巧。有时我们会安装配置 @babel/preset-stage-x 去使用一些新的提案,当在 Babel 7 中这些 preset-stage-x 已经被弃用了,我们必须一个个的安装所需的插件,还得去改 .babelrc 的配置,挺烦的,怎么简化呢?我们可以用下面的方法去简化安装,比如 stage-2:

  1. 首先:npm install --save-dev @babel/preset-stage-2
  2. 然后:npx babel-upgrade --write --install

这样就搞定了。babel-upgrade 这个工具会自动帮你安装所需的插件,并且把 package.json 和 .babelrc 文件相关的地方都改好,非常好用!

不同项目类型的配置建议

这里我们主要分为 npm 库项目和业务项目来建议配置,仅供大家参考。当然首先 preset-env 是都要安装的,然后根据你的目标环境做好配置。

npm 库项目

这个简单点来说就是你写了一个第三方库来给别人使用的,runtime 和 plugin-transform-runtime 肯定是都要安装上的。特别注意 polyfill 不要安装,我的建议是:由业务项目来负责垫片补丁,因为 polyfill 会污染全局。

业务项目

这个就是我们的具体业务项目如网站啦什么的。那么 runtime、plugin-transform-runtime、和 polyfill 都要安装上,并且 @babel/preset-env 要配置上 useBuiltIns: 'entry',这是为了在项目入口文件上根据目标环境来智能选择导入哪些 polyfill 而不是全部导入,这是 preset-env 会帮你做的事情(具体请参考 polyfill 文档),最后别忘记了在入口文件上 import '@babel/polyfill'

以上即是我总结的 Babel 7 转码姿势,如果对本篇有疑问或建议,欢迎在 这里 提出。

欢迎 star 和关注我的 JS 博客:小声比比 JavaScript

参考资料

查看原文

赞 3 收藏 0 评论 0

认证与成就

  • 获得 177 次点赞
  • 获得 4 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 4 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-11-10
个人主页被 1k 人浏览