前端数据扁平化与持久化

16次阅读

共计 5113 个字符,预计需要花费 13 分钟才能阅读完成。

(PS: 时间就像海绵里的水,挤到没法挤,只能挤挤睡眠时间了~ 知识点还是需要整理的,付出总会有收获,tired but fulfilled~)
前言
最近业务开发,从零搭建网页生成器,支持网页的可视化配置。为了满足这种需求,需要将各种页面抽象成类似地模块,再将每个模块抽象成各个可配置的组件,有些组件还包含一些小部件。这样一来,页面配置的 JSON 数据就会深层级地嵌套,那么修改一个小组件的配置,要怎样来更新页面树的数据?用 id 一层一层遍历?这样做法当然是不推荐的,不仅性能差,代码写起来也麻烦。因此,就考虑能否像数据库一样,把数据范式化,将嵌套的数据展开,每条数据对应一个 id,通过 id 直接操作。Normalizr 就帮你做了这样一件事情。
另外考虑到页面编辑,就需要支持 撤销 与 重做的功能,那么要怎样来保存每一步的数据?页面编辑的数据互相关联,对象的可变性会带来很大的隐患。虽然 JS 中的 const(es6)、Object.freeze(es5) 可以防止数据被修改,但它们都是 shallow 处理,遇到嵌套多和深的结构就需要递归处理,而递归又存在性能上的问题。这时,用过 React 的童鞋就知道了,React 借助 Immutable 来减少 DOM diff 的比对,它就能够很好地解决上面这两个问题。Immutable 实现的原理是 Persistent Data Structure(持久化数据结构),也就是使用旧数据创建新数据时,要保证旧数据同时可用且不变。
那么为什么在 JS 中,诸如对象这样的数据类型是可变的呢?我们先来了解一下 JS 的数据类型。
JS 数据类型
JS 的数据类型包括基本类型和引用类型。基本类型包括 String、Number、Boolean、Null、Undefined,引用类型主要是对象(包括 Object、Function、Array、Data 等)。基础类型的值本身无法被改变,而引用类型,如 Object,是可以被改变的。本文讨论的数据不可变,就是指保持对象的状态不变。来看看下面的例子:
// 基本类型

var a = 1;
var b = a;
b = 3;
console.log(a); // 1
console.log(b); // 3

// 引用类型
var obj1 = {};
obj1.arr = [2,3,4];
var obj2 = obj1;
obj2.arr.push(5);

console.log(obj1.arr); // [2, 3, 4, 5]
console.log(obj2.arr); // [2, 3, 4, 5]
上面例子中,b 的值改变后,a 的值不会随着改变;而 obj2.arr 被修改后,obj1.arr 的值却跟着变化了。这是因为 JS 对象中的赋值是“引用赋值”,即在赋值的过程中,传递的是在内存中的引用。这也是 JS 中对象为什么有深拷贝和浅拷贝的用法,只有深拷贝后,对新对象的修改才不会改变原来的对象。浅拷贝只会将对象的各个属性进行依次复制,并不会进行递归复制,而 JavaScript 存储对象都是存地址的。上面代码中,只是执行了浅拷贝,结果导致 obj1 和 obj2 指向同一块内存地址。所以修改 obj2.arr,obj1.arr 的值也变了。如果是深拷贝 (如 Lodash 的 cloneDeep) 则不同,它不仅将原对象的各个属性逐个复制出去,而且将原对象各个属性所包含的对象也依次采用深拷贝的方法递归复制到新对象上,也就不会存在上面 obj1 和 obj2 中的 arr 属性指向同一个内存对象的问题。
为了更清晰地理解这个问题,还是得来了解下 javascript 变量的存储方式。
数据类型的存储
程序的运行都需要内存,JS 语言把数据分配到内存的栈(stack)和堆(heap)进行各种调用(注:内存中除了栈和堆,还有常量池)。JS 这样分配内存,与它的垃圾回收机制有关,可以使程序运行时占用的内存最小。
在 JS 中,每个方法被执行时,都会建立自己的内存栈,这个方法内定义的变量就会一一被放入这个栈中。等到方法执行结束,它的内存栈也自然地销毁了。因此,所有在方法中定义的变量都是放在栈内存中的。当我们在程序中创建一个对象时,这个对象将被保存到运行时数据区中,以便反复利用(因为对象的创建成本通常较大),这个运行时数据区就是堆内存。堆内存中的对象不会随方法的结束而销毁,即使方法结束后,这个对象还可能被另一个引用变量所引用。只有当一个对象没有任何引用变量引用它时,系统的垃圾回收机制才会在核实的时候回收它。
总的来说,栈中存储的是基础变量以及一些对象的引用变量,基础变量的值是存储在栈中,而引用变量存储在栈中的是指向堆中的对象的地址,这就是修改引用类型总会影响到其他指向这个地址的引用变量的原因。堆是运行时动态分配内存的,存取速度较慢,栈的优势是存取速度比堆要快,并且栈内的数据可以共享,但是栈中数据的大小与生存期必须是确定的,缺乏灵活性。

Normalizr 与范式化
范式化(Normalization)是数据库设计中的一系列原理和技术,以减少数据库中数据冗余,增进数据的一致性。直观地描述就是寻找对象之间的关系,通过某种方式将关系之间进行映射,减少数据之间的冗余,优化增删改查操作。Normalizr 库本身的解释就是 Normalizes nested JSON according to a schema),一种类似于关系型数据库的处理方法,通过建表建立数据关系,把深层嵌套的数据展开,更方便灵活的处理和操作数据。
来看个官网的例子,理解一下:
{
“id”: “123”,
“author”: {
“id”: “1”,
“name”: “Paul”
},
“title”: “My awesome blog post”,
“comments”: [
{
“id”: “324”,
“commenter”: {
“id”: “2”,
“name”: “Nicole”
}
}
]
}
这是一份博客的数据,一篇文章 article 有一个作者 author, 一个标题 title, 多条评论,每条评论有一个评论者 commenter,每个 commenter 又有自己的 id 和 name。这样如果我们要获取深层级的数据,如 commenter 时,就需要层层遍历。这时候,如果使用 Normalizr,就可以这样定义 Schema:
import {schema} from ‘normalizr’;

const user = new schema.Entity(‘users’);

const comment = new schema.Entity(‘comments’, {
commenter: user
});

const article = new schema.Entity(‘articles’, {
author: user,
comments: [comment]
});
然后调用一下 Normalize,就可以得到扁平化后的数据,如下:
{
“entities”: {
“users”: {
“1”: {
“id”: “1”,
“name”: “Paul”
},
“2”: {
“id”: “2”,
“name”: “Nicole”
}
},
“comments”: {
“324”: {
“id”: “324”,
“commenter”: “2”
}
},
“articles”: {
“123”: {
“id”: “123”,
“author”: “1”,
“title”: “My awesome blog post”,
“comments”: [“324”]
}
}
},
“result”: “123”
}
这样每个作者、每条评论、每篇文章都有对应的 id, 我们就不需要遍历,可以直接拿对应的 id 进行修改。
再来看下我们在项目中的示例代码:

分别定义 element、section 和 page 三张表,并指定它们之间的关系。这样范式化后,想对某个页面某个模块或者某个元素进行增删查改,就直接拿对应的 id,不需要再耗性能去遍历了。

Immutable 与持久化
Facebook 工程师 Lee Byron 花了 3 年时间打造 Immutable,与 React 同期出现。Immutable Data,维基百科上是这样定义的:
In computing, a persistent data structure is a data structure that always preserves the previous version of itself when it is modified. Such data structures are effectively immutable, as their operations do not (visibly) update the structure in-place, but instead always yield a new updated structure.
简单来说,Immutable Data 就是一旦创建,就不能再被更改的数据。对 Immutable 对象的任何修改或添加删除操作都会返回一个新的 Immutable 对象。Immutable 实现的原理是 Persistent Data Structure(持久化数据结构),也就是使用旧数据创建新数据时,要保证旧数据同时可用且不变。Immutable 使用了 Structural Sharing(结构共享),即如果对象树中一个节点发生变化,只修改这个节点和受它影响的父节点,其它节点则进行共享,这样就避免了深拷贝带来的性能损耗。
我们通过图片来理解一下:

Immutable 内部实现了一套完整的持久化数据结构,有很多易用的数据类型,如 Collection、List、Map、Set、Record、Seq(Seq 是借鉴了 Clojure、Scala、Haskell 这些函数式编程语言,引入的一个特殊结构)。它有非常全面的 map、filter、groupBy、reduce、find 等函数式操作方法。它的 Api 很强大,大家有兴趣可以去看下。这里简单列举 updateIn/getIn 来展示它带来的一些便捷操作:
var obj = {
a: {
b: {
list: [1, 2, 3]
}
}
};
var map = Immutable.fromJS(obj); // 注意 fromJS 这里实现了深转换
var map2 = Immutable.updateIn([‘a’, ‘b’, ‘list’], (list) => {
return list.push(4);
});

console.log(map2.getIn([‘a’, ‘b’, ‘list’]))
// List [1, 2, 3, 4]
代码中我们要改变数组 List 的值,不必一层一层获取数据,而是直接传入对应的路径修改就行。这种操作在数据嵌套越深时,优势更加明显。来看下我们业务代码的示例吧。

这里在多个页面的模块配置中,要更新某个页面的某个模块的数据,我们只需要在 updateIn 传入对应的 path 和 value,就可以达到预想的效果。篇幅有限,更多的示例请自行查看 api。
熟悉 React 的同学也基于它结构的不可变性和共享性,用它来能够快速进行数据的比较。原本 React 中使用 PureRenderMixin 来做 DOM diff 比较,但只是浅比较,当数据结构比较深的时候,依然会存在多余的 diff 过程。这里只提个点,不深入展开了,感兴趣的同学可以自行 google。
与 Immutable.js 类似的,还有个 seamless-immutable,它的代码库非常小,压缩后下载只有 2K。而 Immutable.js 压缩后下载有 16K。大家各取所需,根据实际情况,自己斟酌下使用哪个比较适合。
优缺点
什么事物都有利弊,代码库也不例外。这里列举下它们的优缺点,大家权衡利弊,一起来看下:
Normalizr 可以将数据扁平化处理,方便对深层嵌套的数据进行增删查改,但是文档不是很清晰,大家多查多理解,引入库文件也会增大。Immutable 有持久化数据解构,如 List/Map 等,并发安全。第二,它支持结构共享,比 cloneDeep 性能更优,节省内存。第三,它借鉴了 Clojure、Scala、Haskell 这些函数式编程语言,引入了特殊结构 Seq,支持 Lazy operation。Undo/Redo,Copy/Paste,甚至时间旅行这些功能对它来说都是小菜一碟。缺点方面,Immutable 源文件过大,压缩后有 15kb。其次,侵入性强,与原生 api 容易混淆。第三,类型转换比较繁琐,尤其是与服务器交互频繁时,这种缺点就更加明显。
总结
篇幅有限,时间也比较晚了,关于前端数据的扁平化与持久化处理先讲这么多了,有兴趣的同学可以关注下,后面有时间会多整理分享。
参考资料

前端数据范式化
Immutable 详解及 React 中实践
为什么需要 Immutable.js
facebook immutable.js 意义何在,使用场景?
一些链接, 关于不可变数据

正文完
 0