Objext完全手册
前言
Objext是一个现代js对象模型,它通过丰富的api扩展js原生模型,使一个对象拥有更丰富的操作和数据读写能力,同时可以使之更符合响应式编程的需要。
之所以开发Objext,一开始是为了解决在表单中创建一个可编辑和撤销编辑的对象。在这个场景中,当一个对象被表单打开时,它进入可编辑状态,当提交表单时,它会被更新,当放弃提交表单时,它恢复到编辑前的数据。在这个原始需求的基础上,我增加,当一个属性发生变动时,可以执行某些操作,就像vue可以更新视图一样,通过提供一个$watch方法,这种能力被封装的近乎完美。除了解决属性变动的监听,我还想到表单的校验问题,提交表单的时候需要对表单数据进行校验。最后,一个包含如下功能的Objext对象就诞生了:
- 通过keyPath获取、设置数据
- 响应式,可以通过$watch监听某个keyPath
- 数据版本控制,通过$commit和$reset,可以随时创建快照或恢复数据
- 数据校验
- 数据锁,锁住之后,不能做任何数据修改
- 计算属性和依赖收集
上述功能,都被集中于Objext对象一体,一个看上去像是普通js对象的对象,却可以隐藏上述所有功能。
本手册将会介绍上述这些功能如何使用,以及它们背后的实现原理,通过了解这些原理,可以避免一些错误的使用场景。
快速上手
本节让你快速使用Objext创建一个对象,这个对象几乎和普通的js对象的用法一模一样,只不过你可以监听某个属性的变化。
安装和使用
objext通过npm进行安装:
npm i objext
安装之后,可以通过不同的方式在项目中引入:
// ES6 import Objext from 'objext' // CommonJS const { Objext } = require('objext')
浏览器中也可以直接引入使用:
<script src="node_moudules/objext/dist/objext.js"></script> <script> const { Objext } = window.objext </script>
创建实例
创建一个Objext实例超级简单,只需要传入一个等同结构的js原生对象即可。
const objx = new Objext({ name: 'susan', children: [ { name: 'tomy', age: 9 }, { name: 'lily', age: 10 }, ], })
这样就创建了一个Objext的实例。它的使用几乎可以和普通的js对象一样:
const child1 = objx.children[0] child1.age = 10
你可能会问,这有什么意思呢?单纯这样用当然没意思,你可以通过$watch方法监听某个属性的变化来实现同步更新:
objx.$watch('children[0].age', ({ newValue }) => { objx.children[1].age = newValue + 1 })
这样可以保持两个孩子的年龄同步增长。这是一种响应式的方法,你可以试试。还有一种响应式的方法,是“计算属性”,它的用法和js原生的计算属性一致。
const some = new Objext({ weight: 120, get height() { return this.weight + 50 }, })
这样,height就是一个计算属性,它依赖于weight,当weight发生变化的时候,height也发生变化,但是和原生的计算属性相比,objext实例的计算属性使用来缓存,当weight发生变化的时候,缓存被更新,获取height时,直接读取缓存。
这样看来,是不是有点意思了?没关系,我们后面还有更好玩的东西。
基于keyPath的数据操作
keyPath是指获取一个多层级数据节点的属性链的字符串表达形式,例如obx.body.hands.left这个属性节点,它的keyPath就是'body.hands.left',而在objext中,我们可以使用keyPath来获取或更新数据。
之所以提供这种方式,是因为,我们直接obx.body.hands.left会遇到问题,在某些情况下,这个属性节点并不存在,比如obx.body根本就不存在,那么这个操作就会报错,影响程序执行。而通过objext的keyPath进行操作,则不会影响。
$get
通过keyPath获取属性节点。
- keyPath 要获取的属性节点的keyPath
const left = objx.$get('body.hands.left')
当body这个属性节点不存在时,直接返回undefined,而不会报错。
需要注意的是,$get得到的,不是一个数据的原始值,而是objext实例的属性节点,如果该节点对应的原始数据是一个对象,那么这个节点本身将会是一个objext实例,同时具备了一切objext的特性。这涉及到objext的内部层级问题,后文会更深入的讲解。
另外,如果你直接在objext实例上添加一个属性,例如 objx.age = 10 这种,$get是获取不到这种属性的。这是一个比较忌讳的做法,因此,一定要记住,永远不要直接在objext实例对象上添加属性,也不要用delete操作符直接删除属性,而要使用objext的实例方法$set和$remove。
$set
通过keyPath更新或添加一个属性节点。
- keyPath 要设置的属性节点的keyPath
- value 值
objx.$set('body.hands.left', true')
当节点不存在时,这个节点会被添加。即使body这个节点不存在,body, hands, left都会被逐一添加,这样可以保证开发者真正的目的实现,而且还不会报错。
objext的特点是响应式的,可以通过$watch对属性的变化进行响应。当你使用$set进行数据更改或添加时,对应的watcher会被触发。注意,添加数据也会触发watcher哦。
注意:不要直接在objext实例上添加一个新属性,而要使用$set添加。这样做有两个原因:
1.直接添加属性不具备响应式,即使watch,也不会响应;2.直接添加的属性,在使用$put时,会被删掉,因为它不存在于内部的记录中。
那如果要添加一个不是响应式的,但是又不会被删掉的属性怎么办呢?需要用到后面的$define,后文会详细介绍。
而另外一种情况是,如果你想在单个操作中,仅仅更新属性值,而不触发watcher怎么办呢?$set的第三个参数设置为false即可:
- dispatch 是否要触发watcher,默认为true
objx.$set('body.hands.left', true, false)
这样就不会触发通过$watch绑定的watcher。
$has
判断是否存在一个属性节点。
- keyPath 要判断的属性节点keyPath
if (objx.$has('body.hands')) { // ... }
需要注意的是,如果这个属性是直接通过js原生的属性赋值的方式添加上去的,那么$has也是判断不到的,会返回fasle,这和$get的道理是一样的。
$update
批量更新/添加属性节点。
- object 将要更新的内容通过一个对象的形式组织起来
objx.$update({ name: 'susan', age: 34, })
通过这种方式可以一次性写入多个属性。当一个属性不存在时,会自动创建该属性。
使用$update的好处是批量。这听上去是废话,但是并不是,普通$set一个一个更新属性,不仅写法上比较麻烦,而且每一次$set都会触发watcher去执行,这会影响后面的$set的值。但是$update会在一次更新完之后,才会去执行所有涉及到的所有watcher。这就可能出现比较有趣的现象,当你使用一组$set和使用$update的时候,虽然他们都是更新相同的一批数据,但是有可能执行完毕之后,整个objx的值不同,这是因为watcher的执行顺序可能不同导致的。因此,在使用的时候,你需要注意。
$put
重置整个objext实例的数据为传入的新数据,之前的所有数据会被清空。
- object 要使用的新数据
看上去$update和$put很像,但实际上,完全不同,$update简单理解是多个$set同时执行,而$put则是现$remove所有已经存在的数据,然后在$set给的数据。
在删除阶段,objext实例上所有的属性都会被删除,这也就是为什么不能通过js原生赋值的形式新增属性的原因之一。
const age = objx.$get('age') objx.$put({ name: 'susan', })
上面这段代码执行之后,age没有了,因此要提前把age取出来。
$remove
从objext实例上删除一个属性。
- keyPath 要移除的属性节点keyPath
objx.$remove('body.hands')
有人可能会问,移除直接delete不就行了嘛。听上去很简单,实际上,在objext内部存在依赖关系,如果一个属性依赖另外一个属性,那么当被依赖的属性被移除时,应该解除这种依赖关系。单纯delete做不到,因此,必须使用$remove来移除所有你需要移除的属性。
响应式
Objext的一个特征是完全的响应式,它可以塑造多种响应式方式,而且脱离了任何业务场景,是一个纯粹的对象响应式的库。它不考虑界面响应的问题,它可以用来解决任何需要观察数据变化而跟随变化的场景,虽然需要自己编写被响应的代码。
计算属性
计算属性被大规模应用是vue出现的时候,在vue中,我们需要将普通的数据和计算属性数据分开写,这不是没有道理的,因为计算属性意味着不可以手动去修改它的值。在objext中也是一样的,如果一个属性是计算属性,就不能通过$set或者赋值的方式去修改它的值。
所以,到底应该怎么使用计算属性呢?
创建计算属性
创建计算属性超级简单:
const objx = new Objext({ age: 10, get weight() { return age * 10 }, })
就像js原生对象的getter一样。但是需要注意的是,如果你还设置了一个setter,那setter会被直接忽略,你不可以在objext中使用setter,setter应该被$watch替代。
使用计算属性
使用计算属性和其他普通属性是一模一样的,不同之处在于,计算属性本身会根据所依赖的属性的变化自动变化。
console.log(objx.weight) // 100 objx.age = 11 console.log(objx.weight) // 110
和普通的对象不同的是,当对属性赋值时,objext实例内部是响应式的,赋值时会自动更新计算属性的缓存,因此,在修改属性值之后,依赖它的其他属性值也就跟着变化了。
计算器中的this
计算器是指创建计算属性时,给的计算函数。计算函数可以是含有this的作用域函数。这个this,在正常情况下代表当前所在的objext对象,例如:
const objx = new Objext({ age: 10, get weight() { return this.age * 10 }, })
weight计算器中的this指代的就是objx。但是,在一些情况下,这个this可以被bind到其他objext对象上。
const objx2 = new Objext({ age: 20, }) objx.$bind(objx2)
使用$bind方法,使objx的weight计算器中的this换绑到objx2上面,这样,当objx2.age发生变化的时候,objx.weight就会自动更新。但是,需要注意的是,$bind方法会使所有的计算器的this都换绑,而不是只换绑一个计算器。因此,在使用$bind时,也要理解它的运作规律才使用。
当一个计算属性依赖另外一个objext对象的某个属性时,可以使用$depend方法创建这种依赖关系:
const objx = new Objext({ get weight() { return objx2.age * 10 // 依赖objx2的age属性 }, })
这里,objx依赖objx2.age,objx2也是一个objext对象,但是objx2的age属性发生变化的时候,并不能直接触发objx的weight发生变化,为了解决这个问题,你需要使用$depend方法:
objx.$depend(objx)
这需要你手动调用,而不能靠objext自动实现。
$watch
$watch方法为objext实例提供一个观察者,对特定keyPath的属性进行观察,当该属性发生变化时,执行传入的回调函数。它的使用非常像angular的$watch方法。
- keyPath 要观察的属性
- fn 属性发生变化时要执行的函数
- deep 是否要进行深度观察,当keyPath对应的是对象或数组时可以设为true
objx.$watch('body.hands.left', ({ newValue }) => { console.log(newValue) })
$watch方法是实现objext内部响应式功能的基础方法,因此,它会被广泛用到,包括objext内部的响应式实现方式,例如计算属性的自动更新。
回调函数fn的参数包括:
- e 一个包含属性变化信息的对象
- newValue 新值
- oldValue 老值
其中e,也就是第一个参数,会包含如下信息:
- match $watch时的keyPath
- deep $watch时的deep
- path 当前变化的真实节点的keyPath
- target 当前objext对象
- newValue 新值
- oldValue 老值
- stopPropagation 阻止冒泡
- preventDefault 阻止继续执行其他回调函数
- stack 调用栈
stopPropagation
objext的$watch的第三个参数deep让你可以对一个本身是对象的属性进行深度观察。当深层次的节点发生变化的时候,代表着被观察的节点也发生了变化。它的内部实现机理和DOM的事件冒泡一样,一层一层向上传递,触发对应的回调函数。但是,如果你在向上冒泡的过程中,调用了stopPropagation方法的话,冒泡就会停止,向上的后续回调函数就不会执行了。例如:
objx.$watch('body.hands', ({ newValue }) => { console.log(newValue) }, true) objx.$watch('body.hands.left', ({ newValue, stopPropagation }) => { if (newValue === true) { stopPropagation() } })
上面这段代码中,如果body.hands.left被设置为true,那么第一个$watch的回调函数不会被触发。
preventDefault
而preventDefault方法也和DOM事件中的preventDefault一致,它会阻止剩下的其他回调函数被执行:
objx.$watch('body.hands.left', ({ newValue, preventDefault }) => { if (newValue === true) { preventDefault() } }) objx.$watch('body.hands.left', (e, newValue) => { console.log(newValue) })
上面的代码中,如果body.hands.left为true,那么第二个回调函数就不会被执行了。
特殊符号
特殊符号*表示对所有的变化进行观察:
objx.$watch('*', ({ path, match }) => { console.log('观察' + match) console.log('发生变化的是' + path) })
$unwatch
$unwatch是$watch的反函数,用于对某个观察者进行解绑,解绑之后回调函数就不会被调用了。
objx.$watch('body', callback) // ... objx.$unwatch('body', callback)
$silent
是否开启安静模式更新数据,当安静模式开启后,所有对数据的修改都不会触发watcher。
- status 使用状态true/false,true表示开启安静模式,false表示关闭安静模式。
$isSilent
判断是否处于安静模式中。
数据版本控制
Objext对象的一个特点是可以随意的修改和恢复数据,特别是在一些编辑的时候,你可以提供临时保存和恢复的能力。当你放弃进行编辑的时候,一个方法就可以让数据恢复到编辑之初的状态。
$commit
创建一个名为tag的快照。
- tag 快照名称
objx.$commit('tag1')
打完一个tag之后,会在内部保存当时的所有数据,并建立一个快照,后续对objext对象的修改,不会影响快照内容。
不能存在同名快照,如果已经存在了同名快照,会被新快照覆盖。
$reset
恢复到名为tag的快照的数据。
- tag 快照的名称。如果不传的话,直接恢复到最后一个创建的快照,即使已经存在同名快照,也会被认为是最后一个。
objx.name = 'tomy' objx.$commit('version1') objx.name = 'jimy' objx.$reset('version1') console.log(objx.name) // => 'tomy'
reset之后名为tag的快照还是存在的,只是当前的数据被设置为快照的内容。
$revert
删除名为tag的快照。
- tag 快照的名称。如果不传,表示删除上一个创建的快照,规则和reset相同。
objx.name = 'tomy' objx.$commit('version1') objx.name = 'jimy' objx.$commit('version2') objx.name = 'gofei' objx.$revert() // 删除version2 objx.$reset() // 恢复到version1 console.log(objx.name) // => 'tomy'
数据锁
在一些情况下,我们可以锁死objext实例,使它不能进行任何修改。
$lock
锁死数据。
objx.name = 'digou' objx.$lock() objx.name = 'ximeon' console.log(objx.name) // digou
锁死后,属性不能被修改。
objext具有继承性,父级属性锁死后,子属性也不能修改。
objx.body.$lock() objx.body.hands.left = true // 无法进行修改
$unlock
解锁数据,使返回可修改状态。
$isLocked
判断是否被锁死。
数据校验
objext支持对数据进行校验,校验的时间点有两种:1.修改数据时;2.调用$validate方法一次性全部校验。
$formulate
设置校验规则。
- validators 可以是一个数组,也可以是单个对象,对象内容为:
- path 要校验的keyPath
- determine 一个函数,用以决定是否要执行当前这个校验规则
- validate 校验器函数,返回true/false
- message 校验不通过时,会返回一个Error,该message将作为错误信息
- warn 一个函数,用以接受校验失败时返回的error,然后进行下一步处理
- deferred 是否异步校验,为true时表示异步校验,异步校验不会阻塞校验过程,需要在warn中接受error进行后续处理
- order 校验顺序值,值越大越早校验
objx.$formulate({ path: 'body.hands.left', validate: value => typeof value === 'boolean', message: '必须为true/false', warn: error => window.toast(error.message), })
objx.$strict() objx.body.hands.left = 'true' // 报错
determine, validate, warn中都可以使用this,指向当前context,当前context通过$bind方法绑定,没有绑定时,表示objext对象自身。
$validate
执行校验器。
- keyPath 要校验的keyPath,可选,不传的时候,表示一次性执行整个objext对象的所有校验器
- nextData 该keyPath将要使用的值,可选
其中,nextData表示提前进行校验,比如,你准备要对某个keyPath进行设置新值时,可以提前通过nextData来校验一下,看看是否符合校验规则。
objx.$validate('name', 'Timy')
$strict
切换严格模式。严格模式下,修改属性值也会进行校验。默认情况下,严格模式是关闭的。在前面的代码中已经有演示,这里不赘述。
- status 设为true表示开启,false表示关闭
和$silent很像,也具有继承性。
$isStrict
判断是否处于严格模式下。
高级用法
除了以上已经列出的接口和功能,objext还有一些高级用法,使用使会费些脑子,但是在一些特定场合下可以帮助你实现一些特殊的要求。
批量更新
我们可能会一次性更新多个值,普通模式下,每一次更新,都会触发watcher,一次watcher执行,可能带来不同的结果,特别是有依赖性时。一般来讲,我们使用$update进行批量更新,但是,你也可以自己构造批量更新。
$start
开启批量更新模式,开启后,watcher不会被触发,直到调用$end。
$end
完成批量更新,关闭批量更新模式,并且触发涉及到的所有watcher。
objx.$start() objx.name = 'tom' objx.age = 10 objx.name ='tomi' objx.$end()
上面的代码中,我们执行了两次objx.name赋值操作,但是由于处于批量更新模式中,最终objx.name的watcher只会被触发一次。
实例依赖
普通对象中的this仅会指向自身,但是由于objext具备响应式能力,因此,有更丰富的依赖接口。例如objx1依赖objx2,那么当objx2中的某个发生变化时,objx1对应的属性也应该发生变化。
$depend
跨实例的属性依赖。
- keyPath 当前实例的哪一个keyPath
- target 依赖于另外的哪一个objext实例
- dependency 被依赖的实例的那个属性是被依赖的,可选,不传的情况下,会被自动识别
const objx1 = new Objext({ get weight() { return objex2.age }, }) objx1.$depend('weight', objx2)
上面的代码表示,objx1.weight依赖于objx2.age,当objx2.age发生变化时,objx1.weight也会发生变化。
这里需要注意的是,由于现实的限制,上面的代码中,如果没有$depend那一句,objx1.weight将永远使用第一次初始化的缓存,而不会被修改。因此,如果要使之根据实际情况进行修改,那么必须调用$depend绑定响应式关系。
$undepend
$depen的反函数,解除依赖关系。
$bind
修改当前objext实例的context。在objext实例的计算器、校验器等函数中的this,将会使用$bind的参数取代。
- target 必须是objext实例,用以替代this的指向
const objx1 = new Objext({ age: 10, get weight() { return this.age + 10 }, }) const objx2 = new Objext({ age: 11, }) objx1.$bind(objx2)
上面代码中的$bind导致objx1中的计算属性中的this指代objx2。
要使得解除绑定关系,恢复this指代当前实例,$bind参数为null即可。
对比
通过objext进行数据对比超级方便。
$hash
objext实例的$hash属性保存了它的当前hash值,两个objext对象的hash值相同,表示他们拥有相同的数据内容。
objx1.$hash === objx2.$hash
静态方法
Objext提供了几个静态方法,可帮助开发者快速实现一些功能。
isEqual
判断两个对象/值是否具有相同的内容。
Objext.isEqual(obj1, obj2)
isEmpty
判断是否为空内容值。所谓空内容,是指“空数组、空对象、空字符串、null、undefined、NaN”这几种。
isArray
判断是否为数组。
isObject
判断是否为纯对象。所谓纯对象,是指非实例的字面量对象,比如 o = {} 这种。
inArray
判断一个值是否存在于数组中。
Objext.inArray(item, items)
inObject
判断一个key是否存在于对象中。只有可枚举的key被识别。
Object.inObject(key, obj)
isInstanceOf
判断一个对象是否是一个类的实例。
Objext.isInstanceOf(obj, ClassObj)
parse
通过keyPath解析获得属性值。
let value = Objext.parse(obj, 'body.hands.left')
assign
通过keyPath设置值。
Objext.assign(obj, 'some.name.len', 10)
clone
克隆一个对象。
结语
Objext是一个具有丰富思想的扩展包,它主要用于对数据进行管理的场景,但是它不是纯粹的数据管理工具,而是对碎片数据的扩展,使得一个数据拥有更丰富的接口,可进行各类操作。
基于Objext,你可以扩展出更有意思的工具出来,它提供了一个思想,让你可以对自己的对象数据为所欲为。
如果你觉得本书对你有帮助,通过下方的二维码向我打赏吧,帮助我写出更多有用的内容。
2019-02-10 | 响应式