本系列一共七章,Github 地址请查阅这里,原文地址请查阅这里。javascript
这是编写 JavaScript 框架系列的第五章。本章将会阐述如何使用 ES6 代理建立一个简单且强大的数据绑定库vue
ES6 让 JavaScript 更加优雅,可是其中大多数新功能只是一个语法糖。代理是少数几个不须要垫片的功能之一。若是你不熟悉它们,那么在继续以前请快速阅读 MDN Proxy docs。java
有 ES6 的 Reflection API ,Set,Map 和 WeakMap 的基础知识将会有所帮助。git
nx-observe 是一个 140 行代码的数据绑定方案。它公开了 observable(obj)
和 observe(fn)
函数,用来建立可监听对象和监听函数。监听函数会在被监听对象的属性值发生改变的时候自动执行。以下例子演示了这个过程。es6
// this is an observable object
const person = observable({name: 'John', age: 20})
function print() {
console.log(`${person.name}, ${person.age}`)
}
// this creates an observer function
// outputs 'John, 20' to the console
observe(print)
// outputs 'Dave, 20' to the console
setTimeout(() => person.name = 'Dave', 100)
// outputs 'Dave, 22' to the console
setTimeout(() => person.age = 22, 200)
复制代码
每当 person.name
或者 person.age
值改变的时候,传入 observe()
的 print
函数就会从新运行。print
被称为监听函数。github
若是你想要更多的示例,能够查看 GitHub readme 或者 NX home page 以找到更多的生动的例子。数组
本小节,我将会阐述 nx-observe 的底层实现。首先,我将向您展现如何检测到可观察到的属性的变化并与观察者配对。而后我将会阐述怎么运行这些由改变所触发的监听函数方法。浏览器
变化是经过把被监听对象封装到 ES6 代理来注册的。这些代理使用 Reflection API 无缝地拦截 get 和 set 操做。bash
如下代码使用 currentObserver
变量和 queueObserver()
,可是只会在下一小节中进行解释。如今只须要知道的是 currentObserver
老是指向目前运行的监听函数,而 queueObserver()
把将要执行的监听函数插入队列。数据结构
/* 映射被监听对象属性到监听函数集,监听函数集会使用监听对象属性 */
const observers = new WeakMap()
/* 指向当前运行的监听函数能够为 undefined */
let currentObserver
/* 利用把对象封装为一个代理来把对象转换为一个可监听对象,
它也能够添加一个空白映射,用做之后保存被监听对象-监听函数对。
*/
function observable (obj) {
observers.set(obj, new Map())
return new Proxy(obj, {get, set})
}
/* 这个陷阱拦截 get 操做,若是当前没有执行监听函数它不作任何事 */
function get (target, key, receiver) {
const result = Reflect.get(target, key, receiver)
if (currentObserver) {
registerObserver(target, key, currentObserver)
}
return result
}
/* 若是一个监听函数正在运行,这个函数会配对监听函数和当前取得的被
监听对象属性,并保存到一个监听函数映射之中 */
function registerObserver (target, key, observer) {
let observersForKey = observers.get(target).get(key)
if (!observersForKey) {
observersForKey = new Set()
observers.get(target).set(key, observersForKey)
}
observersForKey.add(observer)
}
/* 这个陷阱拦截 set 操做,它把每一个关联当前 set 属性的监听函数加入队列以备以后执行 */
function set (target, key, value, receiver) {
const observersForKey = observers.get(target).get(key)
if (observersForKey) {
observersForKey.forEach(queueObserver)
}
return Reflect.set(target, key, value, receiver)
}
复制代码
若是没有设置 currentObserver
, get
陷阱不作任何事。不然,它配对获取的可监听属性和目前运行的监听函数,而后把它们保存入监听者 WeakMap。监听者会被存入每一个被监听对象属性的 Set
之中。这样能够保证没有重复的监听函数。
set
陷阱函数得到全部改变了值的被监听者属性配对的监听函数,而且把他们插入队列以备以后执行。
在下面,你能够找到一个图像和逐步的描述来解释 nx-observe 的示例代码。
person
currentObserver
为 print
print
开始执行print
中得到 person.name
person
中的 代理 get
陷阱函数被调用observers.get(person).get('name')
得到属于 (person, name)
对的监听函数集合currentObserver
(print) 被加入监听集合中person.age
再次执行步骤 4-7${person.name}, ${person.age}
print
结束运行currentObserver
被设置为 undefined
person.age
被赋值为 22person
中的 set
代理 陷阱被调用observers.get(person).get('age')
得到 (person, age)
对中的监听集合print
)被插入队列以运行print
再次运行在一个批处理中,异步执行队列中的监听函数,会带来很好的性能。在注册阶段,监听函数被同步加入 queuedObservers
Set
。一个 Set
不会有有重复的监听函数,因此屡次加入同一个 observer 也不会致使重复执行。若是以前 Set
是空的,那么会加入一个新任务在一段时间后迭代并执行全部排队的 observer。
/* contains the triggered observer functions,
which should run soon */
const queuedObservers = new Set()
/* points to the currently running observer,
it can be undefined */
let currentObserver
/* the exposed observe function */
function observe (fn) {
queueObserver(fn)
}
/* adds the observer to the queue and
ensures that the queue will be executed soon */
function queueObserver (observer) {
if (queuedObservers.size === 0) {
Promise.resolve().then(runObservers)
}
queuedObservers.add(observer)
}
/* runs the queued observers,
currentObserver is set to undefined in the end */
function runObservers () {
try {
queuedObservers.forEach(runObserver)
} finally {
currentObserver = undefined
queuedObservers.clear()
}
}
/* sets the global currentObserver to observer,
then executes it */
function runObserver (observer) {
currentObserver = observer
observer()
}
复制代码
以上代码确保不管什么时候运行一个监听函数,全局的 currentObserver
就指向它。设置 currentObserver
切换 get
陷阱函数,以便监听和配对 currentObserver
和其运行时使用的全部的可监听的属性。
迄今为止,咱们的模型在单层数据结构运行得很好,可是要求咱们手工把每一个新对象-值属性封装为可监听。例如,以下代码将不能按预期运行。
const person = observable({data: {name: 'John'}})
function print () {
console.log(person.data.name)
}
// outputs 'John' to the console
observe(print)
// does nothing
setTimeout(() => person.data.name = 'Dave', 100)
复制代码
为了让代码运行,咱们不得不把 observable({data: {name: 'John'}})
替换为 observable({data: observable({name: 'John'})})
。幸运的是,咱们能够经过稍微修改 get
陷阱函数来去除这种不便。
function get (target, key, receiver) {
const result = Reflect.get(target, key, receiver)
if (currentObserver) {
registerObserver(target, key, currentObserver)
if (typeof result === 'object') {
const observableResult = observable(result)
Reflect.set(target, key, observableResult, receiver)
return observableResult
}
}
return result
}
复制代码
若是返回值是一个对象的时候,上面的 get
陷阱函数会在返回以前把返回值设置为一个可监听的代理对象。从性能的角度来看,这也是至关完美的方案,由于可监听对象仅当监听函数须要的时候才建立。
除了 ES6 代理,还能够用 ES5 的属性存取器(getter/setter)来实现相似的数据绑定技术。许多流行框架使用这类技术,好比 MobX 和 Vue。使用代理而不是存取器主要有两个优势和一个主要的缺点。
Expando 属性指的是在 JavaScript 中动态添加的属性。ES5 中不支持 expando 属性,每一个属性的访问器都必须预先定义才能实现拦截操做。这是为何如今预约义的键值集合成为趋势的技术缘由。
另外一方面,代理技术支持 expando 属性,由于每一个对象定义代理,而且他们为每一个对象的属性拦截操做。
expando 属性是很是重要,一个经典的例子就是使用数组。若是不能从数组中添加或者删除数组元素 JavaScript 中的数组就会很鸡肋。ES5 数据绑定技术常常经过提供自定义或者重写 Array
方法来解决这个问题。
使用 ES5 方法的库使用一些特殊的语法来提供 computed 绑定属性。这些属性拥有相应的原生实现,即 getters and setters。然而 ES5 方法内部使用 getters/setters 来建立数据绑定逻辑,因此不可以和属性存取器一块儿工做。
代理拦截各类属性访问和改变包括 getters 和 setters,因此它不会给 ES6 方法带来问题。
使用代理最大的缺点即为浏览器支持。只有最新的浏览器才会支持,而且Proxy API 的最好的部分却没法经过 polyfill 实现。
这里介绍的数据绑定方法只是一个可运行的版本,可是为了让其易懂我作了一个简化。你能够在下面找到一些由于简化而忽略的主题的说明。
内存泄漏是恼人的。这里的代码在某种意义上避免了这一问题,由于它使用 WeakMap
来保存监听函数。这意味着可监听对象相关联的监听函数会和被监听对象一块儿被垃圾回收。
然而,一个可能的用例是一个中心化,持久化的存储,伴随着频繁的 DOM 变更。在这个状况下, DOM 节点在内存垃圾回收前必须释放全部的注册的监听函数。示例中没有写上这个功能,但你能够在 nx-observe code 中检查 unobserve()
是如何实现的。
代理是透明的,这意味着没有原生的方法来肯定对象是代理仍是简单对象。还有,它们能够无限嵌套,若不进行必要的预防,最终可能致使不停地对 observable 对象进行包装。
有不少种聪明的方法来把代理对象和普通对象区分开来,可是我没有在例子中写出。一个方法便是把代理添加入 WeakSet
并命名为 proxies
,而后在以后检查是否包含。若是对 nx-observe 是如何实现 isObservable
,有兴趣的能够查看这里。
nx-observe 也支持原型链继承。以下例子演示了继承是如何运做的。
const parent = observable({greeting: 'Hello'})
const child = observable({subject: 'World!'})
Object.setPrototypeOf(child, parent)
function print () {
console.log(`${child.greeting} ${child.subject}`)
}
// outputs 'Hello World!' to the console
observe(print)
// outputs 'Hello There!' to the console
setTimeout(() => child.subject = 'There!')
// outputs 'Hey There!' to the console
setTimeout(() => parent.greeting = 'Hey', 100)
// outputs 'Look There!' to the console
setTimeout(() => child.greeting = 'Look', 200)
复制代码
对原型链的每一个成员调用get
操做,直到找到属性为止,所以在须要的地方都会注册监听器。
有些极端状况是由一些极少见的状况引发的,既 set
操做也会遍历原型链(偷偷摸摸地),但这里将不会阐述。
代理也能够拦截内部属性访问。你的代码可能使用了许多一般不考虑的内部属性。好比 well-known Symbols 便是这样的属性。这样的属性一般会被代理正确地拦截,可是也有一些错误的状况。
当拦截set
操做时,能够同步运行监听器。这将会带来几个优势,如减小复杂性,精肯定时和更好的堆栈追踪,可是它在一些状况下会引发巨大的麻烦。
想象一下,在一个循环中往一个被监听的数组插入 1000 个对象。数组长度会改变 1000 次并,且与之相关的监听器会也会紧接着连续执行 1000 次。这意味着运行彻底相同的函数集 1000 次,这是毫无用处的。
const observable1 = observable({prop: 'value1'})
const observable2 = observable({prop: 'value2'})
observe(() => observable1.prop = observable2.prop)
observe(() => observable2.prop = observable1.prop)
复制代码
另外一个有问题的场景,即一个双向监听。若是监听函数同步执行,如下代码将会是一个无限循环。
const observable1 = observable({prop: 'value1'})
const observable2 = observable({prop: 'value2'})
observe(() => observable1.prop = observable2.prop)
observe(() => observable2.prop = observable1.prop)
复制代码
基于这些缘由, nx-observe 不重复地把监听函数插入队列,而后把它们做为微任务批量运行以防止 FOUC。若是你不熟悉微任务的概念,能够查阅以前关于浏览器中的定时的文章。