分类 基础知识 下的文章

前言

前文提到了Observer类和observe过程,由于遇到的场景简单,暂时没有去使用Observer类来解决问题。通过observe过程的子过程observeObject和observeArray来特殊处理引用类型的可观察拦截。结合使用Vue的经验,假设有关于数组类型数据array的列表循环渲染,也有关于array的watch。那么当按照下标对array赋值时,列表渲染会被更新,但是普通的watch并无法被触发;使用push等数组变异方法操作array时,列表渲染会被更新,watch也可以被触发。

function observeArray(value: any[]) {
    const oldPush = Array.prototype.push
    Object.defineProperty(value, 'push', {
        value: function (...args) {
            const watchers: Watcher[] = this['watchers']
            oldPush.apply(this, args)
            watchers.forEach(watcher => watcher.update())
        }
    })
    for (let i = 0; i < value.length; i++) {
        let val = value[i]
        defineProperty(value, i, val)
        observe(val)
    }
}

通过循环的方式,按下标为数组定义getter和setter,这样使得那些观察数组元素的watcher可以在按下标赋值时得到通知。但是考虑实际的使用情况,往往是对数组本身的观察,需要在按下标赋值时被观察。push和sort这种数组编译的方法,能够触发对数组的观察,因为对变异数组方法进行了封装。对数组元素的修改,由于无法被set拦截,并不能触发数组上的watch。

简单思索一下什么是变化

先来探讨一下observe过程的目的:当被观察数据发生变化时通知观察者。那么这个发生变化可能有很多含义。对于基本类型数据来说,值发生变化就是变化,但是对于引用类型数据来说,可能会稍微复杂一些。

指针变化

const data = {
    obj: {
        name: 'obj'
    }
}

data.obj = {
    name: 'obj2'
}

这种情况obj已经发生了翻天覆地的变化,也是最直观的变化。由于observe(data)时,obj属性有set拦截,所以指针变化可以直接被观察到。

属性值变化

const data = {
    obj: {
        name: 'obj',
        objInner: {
            id: 1
        }
    }
}

data.obj.name = 'obj1'
// 或者
data.obj.objInner = {
    id: 2
}

这两种情况都是引用类型的属性值发生变化,换句话说“=”赋值操作的右边,总是obj的直接属性。由于之前编写的observe的递归性,obj上施加了对name和objInner的setter,因此对obj.name的观察和obj.innerObj的观察是可以被触发的,但是对obj的观察无法被触发。再通俗点说就是ojb属性的属性值没有发生变化。还要注意,对data.obj.objInner的赋值,可以归化到指针变化的范围内,data.obj.objInner.id的变化无法触发objInner和obj的观察的原因就不细说了。

数组的情况

由于数组可以在一定程度上当做对象来看待:

const array = ['obj', {id: 1}]
// 看做
const arrayToObj = {
    0: 'obj',
    1: {
        id: 1
    }
}

因此讨论起来和对象类似,只不过访问的属性形式不同罢了。

但是数组比对象要多一些方法,push之类的数组变异方法,在调用前后,array指针没有变化,内部元素或多或少的发生了变化,这些变化也可以归到指针变化或者属性值变化。

array.push([1,2,3])
// 相当于
array[2] = [1,2,3]
// 假设array预先申请了10个位置,array[2]的值是undefined,因此在赋值后相当于属性值变化。

虽然Vue中并不支持‘array[2]’这种形式的观察,但是从通用的概念上来说和对象的种种赋值行为是类似的。

observe过程的重新思考

之前实现的observe过程,非常朴素的附加了getter和setter,观察者通过对观察目标(计算过程)的求值,访问并触发相关属性的getter,getter就负责将正在求值的观察者记录下来作为观察关系,以便在setter阶段进行通知。那么问题来说,如果观察obj,观察者的求值只会触及到obj的getter,也只有obj指针指向发生变化(也就是指针类型变量的值发生变化)时才能通知到观察者。

至于引用类型内部的变化到底需不需要触发观察,有时也是根据需求来定的,所以Vue中watch支持deep属性,虽然观察的是obj,但是内部的变化也是关心的。那么deep属性让watcher在求值过程中,不止步于目标本身的访问,还决定了是否进行更深层次的访问,毕竟在当前体系中,访问代表了建立观察关系。本阶段解决的用例:

const v = new Vue({
    data() {
        return {
            obj: {
                name: 'obj',
                address: {
                    street: 'St.V',
                    no: '117'
                },
                keys: [1, 9, 21]
            },
            array: [
                1,
                {
                    id: 2
                },
                [3, 10]
            ]
        }
    },
    watch: {
        obj: {
            handler(newValue, oldValue) {
                console.log('obj is changed:', oldValue, newValue)
            },
            deep: true
        },
        array: {
            handler(newValue, oldValue) {
                console.log('array is changed:', oldValue, newValue)
            },
            deep: true
        },
    }
})

test('Obj nasted', () => {
    v.obj.name = 'newObj'
    v.obj.address.no = '200'
    v.obj.address = {
        street: 'St.V',
        no: '118'
    }
    expect(v.objWatchCount).toBe(3)
})

test('array nasted', () => {
    v.array[0] = 99
    v.array[1].id = 99
    v.array[2][0] = 99
    expect(v.arrayWatchCount).toBe(3)
    expect(v.array[0]).toBe(99)
    expect(v.array[1].id).toBe(99)
    expect(v.array[2][0]).toBe(99)
})

Watcher的deep属性

访问的越深,建立的观察关系就越多,这是显而易见的。deep属性决定了在观察某个数据时,是否也关心其内部数据的变化,因此在watcher内部求值的过程,是否进行更深层次的遍历是关键。

注:引用类型数据可能存在循环引用,如果在深层次遍历访问时遇到这种情况,可能出现死循环,因此参照Vue源码使用Set防止该问题。

// watcher.ts代码片段
get() {
    let value = this.getter.call(this.vm)
    if (this.deep) {
        // 遍历这个value,尝试触发更深层次数据上的setter
        traverse(value)
    }
    return value
}

// traverse.ts
const unique = new Set()

export function traverse(val: any) {
    _traverse(val)
    // 递归完了要清空
    unique.clear()
}

/**
 * 递归操作
 * @param val
 */
function _traverse(val) {
    if (isArray(val)) {
        for (let i = 0; i < val.length; i++) {
            const child = val[i]

            if (!unique.has(child)) {
                unique.add(child)
                _traverse(child)
            }
        }
    }

        // 注意:要么这里使用else区分,要么isObject判断剔除数组
    if (isObject(val)) {
        Object.keys(val).forEach(key => {
            const child = val[key]

            if (!unique.has(child)) {
                unique.add(child)
                _traverse(child)
            }
        })
    }
}

为了方便说明,观察obj的watcher成为objWatcher,obj.name的setter叫做nameSetter。

在deep属性为true的情况下,观察obj的watcher就会进一步观察深层次数据的情况。那么当执行obj.name = 'obj2'时,nameSetter会通知objWatcher进行更新。Watcher的更新逻辑是先对观察目标(计算过程)进行求值,然后判断新旧值,引用类型浅比较导致并不会顺利的执行cb。那么就在update中区分一下,如果objWatcher设置了deep,那就不进行新旧值对比,而是信任nameSetter发来的通知信号。

update() {
    // 取得观察目标的新值
    const newValue = this.get()

    // 更新值
    const oldValue = this.value
    this.value = newValue

        // 条件要把deep考虑进去
    if (newValue !== oldValue || this.deep) {
        // 仅在结果发生变化时触发cb,减少不必要的动作
        this.cb.call(this.vm, newValue, oldValue)
    }
}

这个时候又一个问题来了,oldValue和newValue其实是指向同一个对象的两个指针,实际上并无法得到更新了内部属性前的对象。

总结

Watcher通过求值(执行计算过程)的方式来触发可被观察数据的getter,从而建立观察关系。那么求值的深浅,决定了观察关系的深浅,对于引用类型的数据,其内部变化在很多业务中,也被视作其自身发生了变化,因此Vue中为watch设计了deep属性,进行更深层次的求值从而建立更深层次的观察关系。

注意:本案例之所以能够在v.array[0] = 99时正确触发watch,是因为observeArray中按下标定义了getter和setter(这是一个hack的实现,请勿参考)。

前言

前两篇文章让Watcher可以处理单一状态的观察和多个状态计算过程的观察,都是针对基础类型数据,对于引用类型,甚至更深层次的数据结构应当如何处理。直观的来看,引用类型的状态,其包含的数据字段也应当通过getter/setter的包裹,从而能被深层次的观察。所以为属性设置getter和setter拦截,需要作为一个通用过程,处理引用类型数据的时候可以在递归处理时进行正常的工作

Observer的基本说明

梳理一下数据访问

首先,初始化Vue实例的options中,data属性提供一个生成初始状态集合的函数,防止组件复用时不必要的数据错乱。initData过程中调用该函数得到初始化状态集合,并将其存放在Vue实例内部的_data属性上,当通过实例访问属性字段,会通过get拦截从_data中读取响应数据。

// 注意getters['a']只是一种表达,并不是实际代码
this.a => getters['a'] => this._data.a

如果对a设置watch,就是在this上对a施加的getter中收集依赖,_data单纯是一个数据源头。

假如需要一个Observer类

Observer类的源码注释:

/**
 * Observer class that is attached to each observed
 * object. Once attached, the observer converts the target
 * object's property keys into getter/setters that
 * collect dependencies and dispatch updates.
 */

Observer类被附加到每一个可观察的对象。一旦附加,Observer会将目标对象的属性转化为getter/setter模式用于收集依赖和触发更新。

也就是说Observer类的作用,是让目标对象的属性转化成getter/setter模式,并且将自身附加到目标对象上。这个操作过程在之前的代码中,类似initData的处理过程。也就是说Observer可以是这样的:

class Observer {
    constructor(value: Object) {
        const keys = Object.keys(value)

        keys.forEach(key => {
            let watchers: Watcher[] = []
                        // 提前把值拿出来,用闭包的形式保存起来,避免循环
            let val = value[key]
            Object.defineProperty(value, key, {
                get() {
                    Watcher.target && watchers.push(Watcher.target)
                    return val
                },
                set(v) {
                    val = v
                    watchers.forEach(watcher => watcher.update())
                }
            })
        })
    }
}

仅仅是为了复用设置getter/setter的逻辑,似乎用一个类有些浪费,把这个流程先用一个observe过程呈现:

function observe(value: any) {
    const keys = Object.keys(value)

    keys.forEach(key => {
        let watchers: Watcher[] = []
        let val = value[key]
        Object.defineProperty(value, key, {
            get() {
                Watcher.target && watchers.push(Watcher.target)
                return val
            },
            set(v) {
                val = v
                watchers.forEach(watcher => watcher.update())
            }
        })
    })
}

现在根据这个形式,再来思考一下数据访问。getter中包含两个动作:1.收集依赖(或者说观察者提交注册);2.取值并返回。那么访问value.property,是在value上追加的getter收集依赖,并从value中获取对应值返回。按照之前的数据访问流程,访问this.a,在this上追加getter收集对a的依赖,从_data中获取对应值返回。

转念一想,_data本质是个朴素的对象,this.athis._data.a是等价的,让_data成为可观察的,this上的getter只作为访问转移,那就可以让Observer的逻辑得到复用。initData的逻辑应该转化成:

initData() {
    let data = this._data = this.$options.data()

    // 获取state的key
    let keys = Object.keys(data)
    
    // Vue实例上将状态的访问交给_data,真正可观察的是_data
    keys.forEach(key => {
        Object.defineProperty(this, key, {
            get() {
                return data[key]
            },
            set(v: any) {
                data[key] = v
            }
        })
    })

    observe(data)
}

进一步丰富observe过程

实际使用中,data往往包含着层次更深,结构更复杂的引用类型数据,比如字典或者列表。那么假设下一步要解决的问题是这样的:

const v = new Vue({
  data() {
      return {
          rooms: [
              {
                  id: 1,
                  occupied: false
              },
              {
                  id: 2,
                  occupied: true
              }
          ]
      }
  },
  computed: {
      canCheckIn() {
          return this.rooms.some(room => !room.occupied)
      }
  },
  watch: {
      canCheckIn(newValue, oldValue) {
          if (!newValue && oldValue) {
              console.log("Last room has been occupied")
          }

          if (newValue && !oldValue) {
              console.log("There are some new rooms can use")
          }
      }
  }
})

// @ts-ignore
expect(v.canCheckIn).toBe(true)
// @ts-ignore
v.rooms[0].occupied = true
// @ts-ignore
expect(v.canCheckIn).toBe(false)
// @ts-ignore
v.rooms = [{id: 3, occupied: false}]
// @ts-ignore
expect(v.canCheckIn).toBe(true)

rooms表示房间列表,occupied表示是否有人,计算属性canCheckIn表示是否可以入住(是否有房间没有被占用)。那么期望在v.rooms[0].occupied = true时canCheckIn能够被重新计算,并且被watch到输出相应的提示。

计算属性canCheckIn对应的计算过程中,拆解来看,首先是this.rooms,如果对this.rooms重新赋值,canCheckIn要重新检查是否有房间可用;然后是this.rooms.some,是对this.rooms的一种迭代,注意!即使some会在条件满足时结束迭代,但是在最坏的情况下,每一个元素都会被访问到。所以room的occupied属性发生变化时,也应当重新执行计算过程(即使可能对结果不产生影响,本例中由于some具有特殊性)。上述分析过程中略过了this.rooms[1] = {id: 3, occupied: false}这种情形,有意而为之,后续会进行说明。

这样看来,rooms中的每个对象都将成为可观察对象,而且会发现如果只靠执行canCheckIn计算过程,可能无法完全访问每一个对象,那就要求在访问rooms时,得知其是数组类型,就直接对其进行完整遍历并完成可观察改造。

function observe(value: any) {
    const keys = Object.keys(value)

    keys.forEach(key => {
        let watchers: Watcher[] = []
        let val = value[key]
        Object.defineProperty(value, key, {
            get() {
                Watcher.target && watchers.push(Watcher.target)
                return val
            },
            set(v) {
                val = v
                watchers.forEach(watcher => watcher.update())
            }
        })

        // 先只处理数组类型
        if (Array.isArray(val)) {
            val.forEach(i => observe(i))
        }
    })
}

这样处理之后,可以向this.rooms提交注册,也可以向每个room的各个属性提交注册,但是对this.rooms[0]的赋值,计算属性并不会重新计算,watch也不会观察到变化。那么就探究一下数组下标访问应该向谁提注册。

this.rooms[0]

对象属性的访问可以是a.b或者a['b'],那数组的下标访问,是不是也可以当做是类似的。

let a = [1, 2, 3]

for (let i = 0; i < a.length; i++) {
    let val = a[i]

    Object.defineProperty(a, i, {
        get() {
            console.log(`get a[${i}]:${val}`)
            return val

        },
        set(v) {
            console.log(`get a[${i}]:${v}`)
            val = v
        }
    })
}

a[1] = 5

放心大胆的写出这样的代码,跑一下感觉很不错,加入到observe过程中:

function observe(value: any) {
    if (Array.isArray(value)) {
        observeArray(value)
    }

    if (typeof value === 'object' && value !== null) {
        observeObject(value)
    }
}

function observeObject(value: any) {
    const keys = Object.keys(value)

    keys.forEach(key => {
        let val = value[key]
        defineProperty(value, key, val)
        observe(val)
    })
}

function observeArray(value: any[]) {
    for (let i = 0; i < value.length; i++) {
        let val = value[i]
        defineProperty(value, i, val)
        observe(val)
    }
}

function defineProperty(target, key, val) {
    let watchers: Watcher[] = []
    Object.defineProperty(target, key, {
        get() {
            Watcher.target && watchers.push(Watcher.target)
            return val
        },
        set(v) {
            val = v
            watchers.forEach(watcher => watcher.update())
        }
    })
}

至此,observe过程似乎已经可以通过测试用例,也并没有用到Observer类,这一点后面再说。下面要讨论的问题和数组类型有关。observeArray过程,按照数组的长度分别定义index访问的getter,但是数组在使用过程中,通常长度不是固定的,假如后续向数组中添加元素或者移除元素,就不太适用了。

数组变异方法的代理

测试用例建立在数组长度不变,且操作已知范围内元素,这是很狭隘的。业务逻辑中,很多情况下会在mounted回调中调用接口对一些数据进行初始化,或者点击按钮时请求新的数据追加到已有的数据集合中,删除元素也是类似的,这些动作对于数组来说通常被称为“数组变异”。

const v = new Vue({
    data() {
        return {
            rooms: [
                {
                    id: 2,
                    occupied: true
                }
            ]
        }
    },
    computed: {
        canCheckIn() {
            return this.rooms.some(room => !room.occupied)
        }
    },
    watch: {
        canCheckIn(newValue, oldValue) {
            if (!newValue && oldValue) {
                console.log("Last room has been occupied")
            }

            if (newValue && !oldValue) {
                console.log("There are some new rooms can use")
            }
        }
    },
    mounted() {
        setTimeout(() => {
            this.rooms.push({
                id: 1,
                occupied: false
            })
        }, 100)
    }
})

这个用例,主要是在mounted中模拟一个异步请求,增加一个未被占用的房间,应当引起canCheckIn的重新计算,并且watch输出相应的提示内容。为了方便测试,mounted选项的处理简单做一下。

/**
 * 模拟挂载组件,这里只是为了触发hook
 */
$mount() {
    const mounted = this.$options.mounted
    if (mounted) {
        mounted.call(this)
    }
}

为了能在数组实例执行push时进行通知,要么用一种方式拦截push操作,要么在observeArray时,用原始数组的数据创建一个继承Array的自定义封装类,后者还要保证封装类在做类型判断时不会产生影响。Vue源码中使用后者,且只包装了push、pop、shift、unshift、splice、sort和reverse六大方法,这些方法也是数组变异的六种手段。先来尝试实现push:

function observeArray(value: any[]) {
    const oldPush = Array.prototype.push
    Object.defineProperty(value, 'push', {
        value: function (...args) {
            console.log("proxy push")
            oldPush.apply(this, args)
        }
    })
    for (let i = 0; i < value.length; i++) {
        let val = value[i]
        defineProperty(value, i, val)
        observe(val)
    }
}

现在确实可以在数组执行push的时候进行一些操作了,目标是通知那些访问数组的观察者,那就还需要一个保存观察者的集合,并且和数组绑定。这就很尴尬了,访问rooms的watcher是在observeObject的时候收集的,由于闭包的关系,并没有办法在observeArray中访问到。

拦截了数组的push方法,但是拿不到和数组直接相关的watcher,怎么办,直观的方法是把watcher集合直接加到数组实例上:

function defineProperty(target, key, val) {
    let watchers: Watcher[] = []
        // 直接把watchers数组追加到数组实例上
    if (Array.isArray(val)) val['watchers'] = watchers
    Object.defineProperty(target, key, {
        get() {
            Watcher.target && watchers.push(Watcher.target)
            return val
        },
        set(v) {
            val = v
            watchers.forEach(watcher => watcher.update())
        }
    })
}

function observeArray(value: any[]) {
    const oldPush = Array.prototype.push
    Object.defineProperty(value, 'push', {
        value: function (...args) {
                        // watchers这里就可用与通知了
            const watchers: Watcher[] = this['watchers']
            oldPush.apply(this, args)
            watchers.forEach(watcher => watcher.update())
        }
    })
    for (let i = 0; i < value.length; i++) {
        let val = value[i]
        defineProperty(value, i, val)
        observe(val)
    }
}

对普通对象类型的状态来说,也可以进行类似的操作,一旦显式地附加了watchers,相当于污染了原始数据,用户编写的程序内部可见,这是极具侵略性的行为。换种隐式的附加方式(配置watchers属性的不可迭代性),适当的能够减轻这种侵略性,一定程度上不会被用户所感知。

总结

其他数组变异方法的取代方式差不多,这里就不具体展开了。有针对性的解决问题,总是会有疏漏,比如按照数组下表对数组施加getter,无法处理数组变异方法,也无法处理变长数组的问题。通过附加包装后的数组变异方法,以及将watchers集合显式或隐式的绑定到被观察目标身上,来实现数组变异时向外通知。这也许很hack,但是思路就是这样一步一步梳理出来的。

前言

前一篇文章中,通过watch属性的工作来简要说明Watcher类的最最基本结构,以及最简单工作流程,除此之外,Vue.prototype.$watch的工作原理也是类似的。同时也看到了状态变动时,watch指定的函数被执行,照这个思路延伸下去,computed属性也可以用这种方式实现。先执行一遍computed指定的函数(实际上就是一个运算过程),在运算的过程中会访问到所依赖的数据,这些依赖的数据发生变化的时候,在执行一下这个运算过程得到新值,大体看来就是computed属性的作用了。所以本文的目标用例:

const v = new Vue({
    data() {
        return {
            a: 1,
            b: 2
        }
    },
    computed: {
        sum() {
            return this.a + this.b
        }
    }
})

expect(v.sum).toBe(3)
v.b = 3
expect(v.sum).toBe(4)

分析

computed选项提供计算属性的相关算法,计算属性是基于实例基本状态以及其他计算属性的扩展,或者说是多个可计算属性的聚合。这个聚合通过函数的方式呈现,也可以理解成一个计算过程,计算过程产生的结果,就是计算属性。因此,为了得到计算属性的结果,必须要在需要计算的时机执行响应的计算过程,而这个时机也通过订阅/通知体系来完成。

回顾一下watch选项的动机,想要在某个属性发生变化时执行一些操作,观察的目标是单一的。如果扩展一下:

const v = new Vue({
    data() {
        return {
            a: 1,
            b: 1,
            sum: 2
        }
    },
    watch: {
        a(newValue) {
            this.sum = this.b + newValue
        },
        b(newValue) {
            this.sum = this.a + newValue
        }
    }
})

期望的是sum作为a和b求和的结果,a和b变化的时候可以重新计算。假设watch可以处理这样的情形:

watch: {
    'a+b'(newValue) {
        this.sum = newValue
    }
}

也是可以解决,但是对于复杂计算,Watcher还要具备解析表达式的功能,对于计算属性这个需求来说就太重了。那么关注点回到sum上,访问this.sum的时候通过getter,除了收集依赖的逻辑,就是从数据源拿数据返回出去,如果在getter中知道sum的结果总是通过a+b来确定的,那么就不需要那么多观察,只需要在访问sum的时候去求一下值即可。除此之外,计算属性通常是为了解决多个状态聚合使用时的代码复用性,依赖的属性是响应式的,计算属性也应当具备响应式能力。但是计算属性的结果由计算过程决定,不像普通的属性可以直接复制操作,因此通知并不能在setter中完成(甚至应当屏蔽计算属性的setter过程),考虑到与被聚合状态的一致性,被聚合状态发生改变时会触发计算过程的重新执行,因此计算过程的执行可以作为通知的重要时机。

编写

处理computed选项

在之前代码的基础上,要为Vue增加处理computed选项的能力。计算属性是可以通过实例直接访问到,所以第一步应该是这样的:

/**
 * 处理options中的computed部分
 */
initComputed() {
    let computed = this.$options.computed

    for(let key in computed) {
        const fn = computed[key]

        Object.defineProperty(this, key, {
            get() {
                return fn()
            }
        })
    }
}

这样只实现了一半,只有在主动访问计算属性时,才能计算最新值,这个时候就需要扩展一下Watcher的思路。之前实现的Watcher只能处理单一属性,可以理解为Watcher实例与目标属性建立一对一关系;计算过程包含对多个属性的访问,按照访问就是提交订阅的思路,计算过程会与接触到的属性建立一对多的关系。与单一属性建立关系的Watcher被通知,执行用户绑定的回调;与多个属性建立关系的Watcher被通知,重新执行一下计算过程,然后通知那些订阅了计算属性的Watcher。

扩展Watcher

Watcher可以观察单一属性,现在要让它能够观察一组属性,这一组属性之所以成组,是因为它们在同一个计算过程中被使用。按照这个思路,构造Watcher时,观察目标就需要进行扩展:

!!!注:观察单一属性时,可以理解为只访问该属性的计算过程。因此expOrFn虽然传入的形式不同,但是可以转化成相同的逻辑。

constructor(vm: Vue, expOrFn: string | Function, cb: Function)

被观察的一组属性发生变化时,Watcher实例被通知,首先要重新执行计算过程,那这个执行过程的结果需要进行保存。构造的时候求值是为了提交一组订阅,更新的时候先求值再对外通知,求值的过程可以提出来:

constructor(vm: Vue, expOrFn: string | Function, cb: Function) {
    this.vm = vm
    this.expOrFn = expOrFn
    this.cb = cb

    // 将要观察的目标,不管是单一属性,还是计算过程(相当于一组属性)
    // 统一化保存起来
    if (typeof expOrFn === 'string') {
        // 将观察单一属性的要求,也转换成一个计算过程
        this.getter = function () {
            return vm[expOrFn]
        }
    }

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

    // 兜底
    if (!this.getter) {
        this.getter = noop
    }

    // 已知对vm.expOrFn进行求值相当于一个订阅
    // 先把自身记录在一个全局的范围内,标记自己是正在求值的观察者
    pushTarget(this)
    this.value = this.get()
    popTarget()
}

/**
 * 求观察目标的值
 * 由于已经将expOrFn转化成统一的形式,这里只需要合理的call一下
 */
get() {
    return this.getter.call(this.vm)
}

扩展后的Watcher已经观察一个计算过程,所以在处理computed属性时,将计算过程提取出来构造一个Watcher实例(后面叫它computedWatcher)。计算过程中用到的那些属性发生变化时,computedWatcher会执行update重新求值,此时应当通知观察了计算属性的那些Watcher。如果Watcher本身也可以观察,那么对计算属性的观察,就可以代理到computedWatcher上。

如果Watcher也能被观察

计算属性可以认为是一个Watcher实例的替身(通过闭包保持一致),因此对计算属性的求值可以借助Watcher的求值;对计算属性的观察,也可以认为是对Watcher的观察。那么Watcher需要一个集合来维护观察者们,并提供一个提交订阅的入口:

// 新增成员
watchers: Watcher[]

// 新增方法
addWatcher(watcher: Watcher) {
    this.watchers.push(watcher)
}

这样就可以让计算属性也被观察:

/**
 * 处理options中的computed部分
 */
initComputed() {
    let computed = this.$options.computed

    for (let key in computed) {
        const fn = computed[key]
        const computedWatcher = new Watcher(this, fn, noop)
        Object.defineProperty(this, key, {
            get() {
                Watcher.target && computedWatcher.addWatcher(Watcher.target)
                return computedWatcher.get()
            }
        })
    }
}

总结

Watcher的基本职责就是,观察一个目标,在目标发生变化时进行更新。而这个目标的概念,在前一篇文章中,是具体的单一状态;如果多个状态相互联系,但结果是单一的,那么这多个状态与单一的结果是等价的,而这个结果是多个状态相互联系的静态表达,这个“联系”才是动态的,有意义的。因此多个状态相互的关联,也可以被当做观察的目标。在此基础上扩展了Watcher的目标。

多个状态的关联,总是在任一状态变化时变化,计算属性就是关联结果。Watcher实例和观察目标之间是一一对应的,同时Watcher通过对目标求值提交注册,因此Watcher在一定程度上和观察目标是统一的。那么Watcher在观察单一状态时,本身就是一对一的关系,而观察多个状态的关系(计算过程)时,和计算结果一一对应,通过Watcher实例可以直达单一状态,或者多个状态的计算结果,而后者则成为computed计算属性的支撑。

通过computed属性指定了一个计算过程,也就是多个状态的相关关系(当然也有可能是单一状态的装饰过程),Watcher将这个计算过程包裹起来,与计算过程的结果直接对应,那么对于computed属性的访问,就是对Watcher实例的访问。而computed属性也具有响应式特征,遂在Watcher上也增加了订阅/通知的行为模式。

前言

虽然了解过生成器,也了解过生成器函数和promise的关系,可迭代协议和迭代器协议也都看过,可是面试的时候遇到了,还是没有顺利的解决,于是决定再学一次。

迭代协议

本章节依赖《MDN-迭代协议》一章,做简要总结。

是协议而不是语法

迭代协议是ECMAScript 2015的一组补充规范,所以可以有多种具体的实现。迭代协议包含了两个具体协议:可迭代协议和迭代器协议。

可迭代协议

可迭代协议其实就是一组约束JavaScript对象迭代行为的规则。而迭代行为包括且不限于for-of结构的循环等,针对可以被遍历的值进行约束。Array和Map是ECMAScript 2015体系中包含的两个常用的可迭代对象,也就是说他们具有默认的迭代行为,符合可迭代协议的约束。

在协议中要求,可迭代对象必须实现@@iterator方法,不管怎样,整个原型链系统中必须要有可访问的@@iterator属性。之所以这个属性由两个@开头,是由于这个属性必须由Symbol.iterator属性进行访问,也就是[Symbol.iterator]形式访问。(这里只需要知道Symbol是一个新的基本数据类型,Symbol.iterator是一个内建的Symbol值,是一个唯一标量。)@@iterator属性提供一个无参函数值,用于返回一个符合迭代器协议的对象。也就是说,在对一个可迭代对象进行跌到操作时,会先调用@@iterator提供的函数得到迭代器,然后通过迭代器来获取具体迭代的值。

所以可迭代协议的约束核心就是@@iterator属性提供的函数,可以是普通函数,也可以是生成器函数,由于调用的时候是作为可迭代对象的方法进行调用的,所以内部this的指向是可访问该可迭代对象的。

迭代器协议

迭代器协议是一组约束如何产生一系列值的规则。注意,产生一系列的值,可以是无限个。如果产生有限个值,那么额外要求再产生最后一个值后,要返回换一个默认的用于标记结束的值。

根据要求,一个对象拥有一个可访问的next方法,该方法无参数,返回如下结构对象:

{
    done: boolean,
    value?: T
}

其中done表示迭代器是否可以产生下一个值,字面上理解就是迭代器是否迭代完成。当done为true是,value可以不存在,否则应当指定value为默认返回值。value就是迭代器产生的一些列值的具体返回。next方法如果返回的是非对象值,会产生TypeError异常。

从迭代器协议的要求来看,无法准确的确认一个对象是否实现了迭代器协议,因为无法预知next方法的返回值结构。但是,同时实现可迭代协议和迭代器协议的对象,直观感受就是这样的:

var myIterator = {
    next: function() {
        // 返回结构正确
    },
    [Symbol.iterator]: function() { return this }
}

迭代器协议,拥有next方法,且假定返回结构正常;可迭代协议,拥有[Symbol.iterator]属性,返回自身,由于其自身满足迭代器协议,故满足了可迭代协议。

常见的迭代协议的例子

字符串是一个常用的实现了迭代协议的对象,可以通过typeof someString[Symbol.iterator]的方式来检查。通常迭代字符串中的元素,可以通过for循环进行输出,在支持Symbol的环境下,也可以通过可迭代协议要求的属性来获取对应的迭代器。

注意:[Symbol.iterator]属性是可读可写的,所以在环境支持的情况下是可以进行复写的。比如MDN提供的例子:

// 必须构造 String 对象以避免字符串字面量 auto-boxing
var someString = new String("hi");
someString[Symbol.iterator] = function() {
  return { // 只返回一次元素,字符串 "bye",的迭代器对象
    next: function() {
      if (this._first) {
        this._first = false;
        return { value: "bye", done: false };
      } else {
        return { done: true };
      }
    },
    _first: true
  };
};

// 结果如下
[...someString];                              // ["bye"]
someString + "";                              // "hi"

除了字符串,数组(包括TypedArray类数组)、Map和Set,原型对象都实现了@@iterator方法,它们都是可迭代对象,都可以使用for循环和展开语法等结构。前面提到可迭代协议的@@iterator方法可以是普通方法,也是可以是生成器函数,所以可以像这样定制自己的可迭代对象:

var myIterable = {};
myIterable[Symbol.iterator] = function* () {
    yield 1;
    yield 2;
    yield 3;
};
[...myIterable]; // [1, 2, 3]

其实迭代协议从本质上讲作为一种行为约束,可以看做是具有接口特性,因此在构造Map或者Set时,会发现可以依赖一个可迭代对象生成Map或Set,所以在文档中通常可以看到这样的函数签名:

new Map([iterable])

除了for循环结构和展开语法,yield*和解构赋值,都需要可迭代对象。更多的迭代器范例可以参加MDN文档,这里不做过多列举。

本文主要内容生成器和迭代协议有什么关系呢?生成器或者叫生成器对象,是生成器函数(Generator Function)调用返回的对象,符合可迭代协议和迭代器协议,因此简单说生成器是一个满足迭代协议的对象,即同时具备@@iterator属性和next方法的对象。

生成器

生成器函数-Generator Function

生成器函数调用并返回生成器对象,这很好理解。生成器函数叫Genterator Function,返回的生成器对象叫Genterator对象,简称生成器。

生成器函数的基本语法:

function* generator() {//...}

即function关键字后带一个*号。同样的,你还可以使用生成器函数构造函数,生成新的生成器函数对象,通过Object.getPrototypeOf(function*(){}).constructor来获取生成器函数构造函数

生成器函数在执行时可以暂停,并且还能从暂停的地方恢复执行。好了,最绕的地方来了。注意以下描述:

  • 调用生成器函数,不会立即执行函数体
  • 调用生成器函数,会得到一个迭代器对象(即实现了迭代协议的对象)
  • 迭代器对象的next()方法调用时,函数体从上次结束的地方开始,执行到下一个yield关键词(这里具体的位置以及next方法稍后介绍)
  • yield*表示移交执行权

通过官方例子进行说明:

function *gen(){
    yield 10;
    x=yield 'foo';
    yield x;
}

var gen_obj=gen();
console.log(gen_obj.next());// 执行 yield 10,返回 10
console.log(gen_obj.next());// 执行 yield 'foo',返回 'foo'
console.log(gen_obj.next(100));// 将 100 赋给上一条 yield 'foo' 的左值,即执行 x=100,返回 100
console.log(gen_obj.next());// 执行完毕,value 为 undefined,done 为 true
  • gen是一个生成器函数
  • gen_obj是这个生成函数的迭代器对象
  • 第一次执行。迭代器第一次调用next()方法,函数体从0开始,往下执行,直到遇到了包含yield的语句,yield 10暂停执行,并且将10返回。
  • 第二次执行。从yield 10恢复执行。x = yield 'foo'; 是一个赋值语句,先对等号右边进行求值,yield ‘foo’先暂停执行并返回‘foo’,而整体的求值要等到下一次恢复执行。
  • 第三次执行。从yield ‘foo’恢复执行,向next方法传入了参数100,即yield ‘foo’表达式本身求值为100。因此,恢复执行后,等号右边求值100,然后执行赋值x为100,遇到下一个yield x 暂停执行并将x的值100返回出去。
  • 第四次执行。从yield x恢复执行,函数体结束,将value为undefined,done为true的对象返回,标志着生成器不再生成值。

yield

从官方的文档中,对于yield语法的描述是这样的:

[rv] = yield [expression];

其中expression求值后用于生成器函数返回的迭代器调用next时返回。允许expression为空,即next()方法返回的结构中value为undefined。其中rv为yield表达式求值结果,也就是传递给next()方法的参数值。

yield关键字用于生成器函数体的执行暂停,不妨把他看成一个return结构。根据文档的专业描述,yield关键字返回IteratorResult对象(其实就是迭代器协议要求返回的特定结构)。从上面的简单流程分析,生成器的暂停与恢复由yield和next()的调用相关。每次next()的调用,生成器开始或者恢复执行,直到:1.yield关键词,生成器暂停。2.throw抛出异常,生成器终止。3.生成器函数体结尾,生成器终止,返回value为undefined,done为true的结果。4.return语句,生成器终止,返回value是return指定的值,done是true的结果。

根据上面例子的执行顺序,不难发现生成器函数体代码,是分段执行的。yield是其分段标志,next()方法调用是执行标志。如果将调用next()方法的代码片段称为生成器外部,那么next()就是将执行顺序矫正到生成器外部的标志;当执行顺序来到生成器内部,遇到了yield,执行顺序又被矫正到生成器外部。

yield是将执行顺序从生成器内部转到生成器外部,yield也类似,只不过将执行顺序从本生成器内部转到目标生成器内部。yield只能操作可迭代对象,下面是一个迭代器对象的例子,因为迭代器调用next()方法才能将执行顺序转到生成器内部,yield将执行顺序转交。甚至可以看做是yield将另一个生成器融合到自身中。

function* g1() {
  yield 2;
  yield 3;
  yield 4;
}

function* g2() {
  yield 1;
  yield* g1();
  yield 5;
}

var iterator = g2();

console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }
console.log(iterator.next()); // { value: 3, done: false }
console.log(iterator.next()); // { value: 4, done: false }
console.log(iterator.next()); // { value: 5, done: false }
console.log(iterator.next()); // { value: undefined, done: true }

假如把生成器g2改写成下面这样:

function* g2() {
  yield 1;
    { // 这里只用作强调区分,没有语句块的作用
      yield 2;
      yield 3;
      yield 4;
    }
  yield 5;
}

似乎也没有什么问题。除此之外,委托给数组等可迭代对象也是类似的:

function* g3() {
  yield* [1, 2];
  yield* "34";
}

// 合并起来似乎也没有什么问题
function* g3() {
    yield 1
    yield 2
    yield '3'
    yield '4'
}

注意!yield*和返回值的关系比较复杂:

function* g4() {
  yield* [1, 2, 3];
  return "foo";
}

// 不妨改写一下g4
function* g4() {
  yield 1;
    yield 2;
  yield 3;
  return "foo";
}

var result;

function* g5() {
  result = yield* g4();
}

var iterator = g5();

console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }
console.log(iterator.next()); // { value: 3, done: false }
console.log(iterator.next()); // { value: undefined, done: true }, 
                              // 此时 g4() 返回了 { value: "foo", done: true }

console.log(result);          // "foo"

第一次执行iterator.next(),执行顺序来到yield g4(),转交到yield [1, 2, 3],再转交到yield 1(将数组的迭代过程用yield代替),暂停,执行顺序回到console.log,并且将1带了出来。依次类推,第四次执行iterator.next(),执行顺序来到yield g4(),转交到return "foo",这个时候要注意,并没有yield所以并没有暂停,执行顺序也没有回到console.log,而是正常结束g4的迭代,由于是return,所以返回为{ value: "foo", done: true }。也就是说yield表达式求值完毕,是{ value: "foo", done: true },将其赋值给result,此时g5生成器函数体完毕,执行顺序回到console.log,并返回{ value: undefined, done: true }表示不再产生下一个迭代值。所以yield*表达式也是有具体值的。

从上面的例子不难看出,不管怎么转交,只要看到yield,就要将执行顺序矫正到最外部,因为yield*相当于将生成器嵌入生成器,所以执行顺序直接去最外面准没错。

注意

yield只能在生成器函数体内,而生成器函数体内如果有其他闭包(在直接点就是其他独立的程序块),都不算做生成器函数体。比如:

function* gen() {
    setTimeout(()=>{
        yield 1
    })
    yield 2
}

yield 1是不被允许的,因为其存在于一个非生成器函数体内。

小结

大概了解了生成器函数,迭代器和yield后,对执行顺序的变化有了初步的概念。next()将执行顺序矫正到生成器函数体内部,yield又将执行顺序矫正到生成器函数体外部。然后还要知道生成器函数体内部的return语句不会改变执行顺序,基本上能够理清一个生成器的工作过程。

再仔细想一下,Promise的使用,提供一个任务,再注册一个回调,当任务完成时调用resolve了,注册的回调会被执行。这个过程居然和next()调用了执行生成器函数体出奇的相似,两者是否存在联系,下一篇文章将讨论相关问题。

原文地址:

Debouncing and Throttling Explained Through Examples | CSS-Tricks

本文中斜体部分为阅读时的个人理解说明,请注意区分。并且代码中使用的debounce函数和throtte函数均来自于lodash库。

Debounce(bounce意为弹跳,de前缀表示”否定“,所以被称为防抖)和Throttle(作名词讲意为”节流阀“,指控制油料进入发动机量大小的装置)是两种极其相似却又大不相同的技术,用于控制一个函数随着时间的流失执行多少次。

将业务函数的防抖版本或节流版本用在处理DOM事件上是非常有用的。为什么?因为这样可以在事件机制和业务函数执行之间进行控制。毕竟无法那些被动触发的DOM事件的评率不在我们的控制范围内。

举个例子:滚动事件

var i = 0;
var $counter = $('#counter');
$(document).ready(function(){
  $(document).on('scroll', function(){
    $counter.html(i);
    i++; 
  });
});

当使用触控板,鼠标滚轮或者拖拽滚动条进行滚动操作是,每秒将会触发30个滚动事件。但是在手机上,即使是缓慢的滚动(滑动屏幕),每秒也会触发多达100个事件。你的滚动处理函数能够应对这样的执行速率吗?

2011年,推特的网站上出现一个问题:当你不断滚动信息流,网页会变得越来越慢无法响应。John Resig在文章《a blog post about the problem》中阐释了,将一个执行代价很高的函数直接绑定到scroll事件,是一件多么愚蠢的做法。

在那篇文章中,John建议的解决方案是:在onScroll event以外使用一个每250ms执行一次的循环。这种方式实现handler和event的解耦合。通过这种简单的技术,可以避免给用户带来的糟糕体验。

最近又出现了一些稍微复杂一点的方式处理事件。让我来介绍一下Debounce,Throttle和rAF。我们还将研究具体的使用场景

Debounce

防抖技术允许我们将多个顺序的调用,聚合到单次调用。

https://i.loli.net/2020/07/28/DN6pv2uiQ1hGCxT.png

想象你在一个电梯里。电梯门开始关闭,突然另一个人想要进入电梯。电梯不能进行工作去别的楼层,电梯门重新打开。又有另一个人要上电梯,这样的事情还会发生。虽然电梯的行动(上下楼)被突然上人延迟了,但是明显优化了资源利用。

这是一个mousemove事件的例子:

https://codepen.io/dcorb/pen/KVxGqN

你能看到单个防抖事件,是如何代替快速发生的顺序事件的。如果事件发生的间隔事件,超过防抖设置的延迟,那么就不会进行(也没必要进行)防抖处理。

在进行下面的文章之前,要插入介绍一下lodash中debounce函数:

*_.debounce(func, [wait=0], [options={}])*

options的三个配置说明如下:

  • leading:默认false,指定在延迟开始前调用
  • maxWait:允许被延迟的最大值。也就是说抖动持续超过这个时间,函数还是要被触发的
  • trailing:默认true,指定在延迟结束后调用

Leading属性(或者“立即”)

你会发现令人头疼的一点,在触发函数执行之前,防抖事件总是等待,直到事件不再快速的重复发生。为何不立即触发让函数执行,让它的行为和非防抖handler一样?而是在快速的重复调用过程中不会触发,直到出现一个较长的时间间隔再触发。

你当然可以值么做!设置leading选项:

https://i.loli.net/2020/07/28/XKYeLf1H7qdMhBy.png

上面的例子可以理解成防抖处理的函数,在抖动开始时就执行一次,后续的抖动过程中不再执行。没有leading选项(默认trailing选项)时,就相当于抖动结束了后再执行。而抖动结束的判断条件是第二个参数决定的延迟。

https://codepen.io/dcorb/pen/GZWqNV

防抖的实现

我(原文作者)第一次看到JavaScript版本防抖的实现,是在2009年John Hann的博文中(原文已经找不到了),防抖的概念也是从这篇文章开始的。之后很快,Ben Alman开发了一个jQuery插件,再然后,Jeremy Ashkenas将其添加的underscore.js库中。再之后又被加入到lodash库中。这三种实现(jQuery插件,underscore.js和lodash)内部有细微的不同,但是对外暴露的接口几乎一致。

之前有一段时间underscore.js采纳了lodash的防抖/节流实现,但是在2013年,我发现了_.debounce的一个bug(这个bug也无从查证了),从那之后,两个库的实现方式彻底分开。

lodash的debounce函数和throttle函数增加了很多特性,比如leading属性和trailing属性联合取代了immediate属性,默认是trailing模式,但也可以设置成头尾都执行。另外增加了maxWait选项,很有用,实际上节流函数就是借助debounce+maxWait实现的。

源码分析单独写一篇文章,这里不做过多的介绍

防抖的应用场景

resize事件

当调整浏览器窗口尺寸时,会频繁触发resize事件,用于处理拖动过程。

$(window).on('resize', function(){
    display_info($left_panel);
  });
  
$(window).on('resize', _.debounce(function() {
  display_info($right_panel);
}, 400));

使用了默认的trailing模式,因为只关心resize的最终结果。从打印结果上不难发现,不使用防抖,会打印出很多拖拽过程中的尺寸信息,假如有一个业务逻辑需要根据尺寸重新布局,或者进行复杂的计算,一方面可能造成卡顿,一方面执行的结果可能只会展示一瞬间, 对用户来说也许并没有意义。

键入信息与异步请求自动补全

用户持续输入时,为何还要每50ms发送一次异步请求?防抖避免了输入过程中不必要的网络工作,当用户结束输入延迟一段时间后(认为是抖动结束)才请求数据。在这个业务中,使用leading模式将导致无法按照预期工作,因为我们关注的是用户输入结束后输入的内容,而不是输入的过程。

如何使用防抖和节流,以及常见错误

作者推荐使用lodash或者underscore.js库提供的相关工具函数,这里略过

常见的错误:重复调用debounce函数。debounce函数用于产生一个防抖版的handler,将这个防抖版的handler绑定到事件处理,而不是在事件处理中调用debounce。

// WRONG
$(window).on('scroll', function() {
   _.debounce(doSomething, 300); 
});

// RIGHT
$(window).on('scroll', _.debounce(doSomething, 200));

根据lodash的文档说明,debounce创建的函数带有cancel函数,可以用于取消防抖限制:

var debounced_version = _.debounce(doSomething, 200);
$(window).on('scroll', debounced_version);

// If you need it
debounced_version.cancel();

Throttle

使用节流技术,我们不允许我们的函数每X毫秒执行超过1次。节流和防抖的主要区别,是节流保证了函数是周期执行的。(同样是应对”抖动“场景,防抖意味着不抖动了在执行,忽略所有抖动过程;节流意味着通过自己定义一个频率,让无规律的抖动变成有规律的”抖动“,即可以关注抖动过程中的某些状态,也可以减少多余的工作量

节流的应用场景

无极滚动

无极滚动是一个很常见的场景。用户在你的页面上会不断的向下滚动。你需要检查用户滚动离底部的距离。如果用户滚动接近了底部,我们就需要请求更多的数据追加到页面上。这个时候防抖显然不那么合适。防抖只会在用户停止滚动后触发一次,但需求是在到达底部之前就要准备好更多数据。使用节流我们可以保证是在持续不断的检查到底部的距离。

// Very simple example.
// Probably you would want to use a 
// full-featured plugin like
// https://github.com/infinite-scroll/infinite-scroll/blob/master/jquery.infinitescroll.js
$(document).ready(function(){
  
  // Check every 300ms the scroll position
  $(document).on('scroll', _.throttle(function(){
    check_if_needs_more_content();
  }, 300));

  function check_if_needs_more_content() {     
    pixelsFromWindowBottomToBottom = 0 + $(document).height() - $(window).scrollTop() -$(window).height();
    
  // console.log($(document).height());
  // console.log($(window).scrollTop());
  // console.log($(window).height());
  //console.log(pixelsFromWindowBottomToBottom);
    
    
    if (pixelsFromWindowBottomToBottom < 200){
      // Here it would go an ajax request
      $('body').append($('.item').clone()); 
      
    }
  }

requestAnimationFrame (rAF)

requestAnimationFrame是另一种限制函数执行频率的方式。它可以被认为是_.throttle(dosomething, 16) 。因为它是浏览器为了更好的准确性而提供的原生的API,所以保真度更高。我们可以使用rAF作为节流方式,它具有以下优缺点:

优点

  • 针对浏览器60fps渲染频率,每一帧16ms,理想情况下是每一帧执行一次,但实际上浏览器还是会选择合适的时机执行。
  • 相当简单且标准的API,未来也不一定会有大的变更,可以减少维护量。

缺点

  • rAF的启停都必须手动处理,而防抖和节流都在内部通过定时器进行管理。
  • 如果浏览器标签页处于未激活状态,rAF是不会执行的。对于滚动或者鼠标移动等行为,这一点并不影响。
  • IE9,Opera Mini和老版本的安卓不提供该API。可能需要polyfill。
  • rAF仅限于浏览器环境,node.js无法使用,无法使用rAF来节流服务端文件系统事件。

根据经验,如果你的JavaScript函数是用于绘制或者动画,那么我推荐使用rAF,对于那些包含重新计算元素位置的操作都很适用。而为了进行Ajax请求,或者决定添加/移除样式名(这可能会引起CSS动画),我会考虑使用debounce和throttle,这样你可以设置一个比较低的执行频率。

rAF的应用场景

https://codepen.io/dcorb/pen/pgOKKw

通过一个滚动的例子,对比节流和rAF的效果。由于场景比较简单,所以感觉不出有什么差异,但是在复杂的场景下,可能rAF有更加精确地表现。

总结

使用防抖,节流和rAF都可以优化你的事件处理。每一个技术都有些许差异,但是他们都很有用,并且可以相互补充。

总而言之:

  • 防抖:将一系列突发的事件爆发(比如连击按键)聚合成一个单独的事件。
  • 节流:保证了一个周期为X毫秒的持续的执行流。比如每200ms检查一次滚动位置。
  • rAF:可选的节流方案。当你的函数涉及到重绘和渲染,且想要一定的流畅性或者动画,可以使用rAF。注意不支持IE9。

写在最后:当有人问你一个问题,你回答到一半又问了你一个问题,你停下来回答新的问题,到一半时又被问了第三个问题...每个问题可能都没有答完,这样你整个人的精神状态就可以被称为”抖动“。防抖,也就是拒绝抖动,那我可以在回答问题之前稍微等一下,看你会不会再问下一个问题,要是问了我就干脆不回答了,等到我发现你不会突然打断我了,我再回答问题。这样就不会出现话说一半的尴尬。节流,降低抖动的频率,一个问题都不回答好像也有一些不礼貌,那我就自己心里默念,每过10个数进行一次回答。简单而形象的理解,具体实现可能还有细节,至少这样不会搞得太混。Peace