查看原文
其他

【第1391期】前端数据扁平化与持久化

旭日云中竹 前端早读课 2019-07-30

前言

不知道你有涉及到这块吗?如果没有的话,就简单的了解了解。今日早读文章由@旭日云中竹投稿分享。

正文从这开始~~

最近从零搭建网页生成器,支持网页的可视化配置。为了满足这种需求,需要将各种页面抽象成类似地模块,再将每个模块抽象成各个可配置的组件,有些组件还包含一些小部件。这样一来,页面配置的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、Date等)。基础类型的值本身无法被改变,而引用类型,如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.arrobj1.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容易混淆。此外,类型转换比较繁琐,尤其是与服务器交互频繁时,这种缺点就更加明显。当然,也可以根据业务需求,衡量下是否用seamless-immutable,它使用 Object.defineProperty (因此只能在 IE9 及以上使用) 扩展了 JavaScript 的 Array 和 Object 对象来实现,只支持 Array 和 Object 两种数据类型。但是代码库非常小,压缩后下载只有 2K。

参考资料

  • 前端数据范式化

  • Immutable详解及React中实践

  • 为什么需要Immutable.js

  • facebook immutable.js 意义何在,使用场景?

  • 一些链接, 关于不可变数据


最后,为你推荐


【第1102期】编写扁平化的代码


【图书】React 状态管理与同构实战


【招聘】深圳迅雷招19届前端实习生

Modified on

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存