JavaScript 的强语言之路—另类的 JSON 序列化与反序列化

date
Mar 13, 2020
slug
kvicxdkg
status
Published
tags
JavaScript
summary
type
Post
 
2020 年 01 月 17 日 更新 此篇文章代码 Demo:https://github.com/stelalae/node_demo ,yarn start:debug看控制台输出效果。
JSON(JavaScript Object Notation)是一种轻量级,完全独立于语言的数据交换格式。目前被广泛应用在前后端的数据交互中。在 JavaScript 中的应用随处可见,灵活性、扩展性、可读性也是最强的!对应的JSON.parseJSON.stringify就可以看做是对象的序列化和反序列化,将对象与字符串之间相互转换。

序列化与反序列化

互联网的产生带来了机器间通讯的需求,而互联通讯的双方需要采用约定的协议,序列化和反序列化属于通讯协议的一部分。通讯协议往往采用分层模型,不同模型每层的功能定义以及颗粒度不同,例如:TCP/IP 协议是一个四层协议,而 OSI 模型却是七层协议模型。在 OSI 七层协议模型中展现层(Presentation Layer)的主要功能是把应用层的对象转换成一段连续的二进制串,或者反过来,把二进制串转换成应用层的对象 – 这两个功能就是序列化和反序列化。一般而言,TCP/IP 协议的应用层对应与 OSI 七层协议模型的应用层,展示层和会话层,所以序列化协议属于 TCP/IP 协议应用层的一部分。本文对序列化协议的讲解主要基于 OSI 七层协议模型。
  • 序列化: 将数据结构或对象转换成二进制串的过程。
  • 反序列化:将在序列化过程中所生成的二进制串转换成数据结构或者对象的过程。
简单来说,序列化是将对象转换成字节流的过程,而反序列化的是将字节流恢复成对象的过程。两者的关系如下:
notion image
不同的计算机语言中,数据结构、对象以及二进制串的表示方式并不相同。如 Java/JavaScript 中使用的是对象(Object),来自类的实例化。而 C 是用 struct 去表示数据解构,或根据指针的偏移量在内存中读取数据。C++则是 Java 方式或 C 方式均可,因为 C++比 C 强化了 class 的概念。
从计算机语言的发展历史来看,序列化协议从通用性、健壮性、可读性、扩展性、安全性、性能等方面,设计出下面几种常见的协议:
  • 早期协议:COM、CORBA。
  • XML&SOAP:XML(可扩展标记语言,Extensible Markup Language)本质上是一种描述语言,具有自我描述的属性。SOAP(Simple Object Access protocol) 是基于 XML 为序列化和反序列化协议的结构化消息传递协议,在前期互联网阶段被广泛应用,对应的解决方案叫做 Web Service。
  • JSON:源于 JavaScript,赶上移动浪潮发展,现正在广泛使用在各种业务场景中。
  • Thrift:是 Facebook 开源的轻量级 RPC 服务框架,在大数据量、分布式、跨语言、跨平台的服务端使用较多。
  • Protobuf:来自 Google,因改善了 XML、JSON 的诟病,空间开销小、高解析性能、语言支持度高等亮点,许多公司将其作为后端之间通信、对象持久化等方面的首选方案。在终端上,IM 业务上也使用较多。
  • Avro:属于 Apache Hadoop 的子项目,提供两种序列化格式:JSON、Binary,即调试时用 JSON,上线后用 Binary。(个人觉得就是对 JSON 在传输时的改良版)
上述序列化协议的 Benchmark 请自行搜索与查询。在选型上也要根据具体业务场景,个人建议:JSON 适合 ① 有前端参与 ② 小型项目, Protobuf 适合 ① 高性能 ②T 级别数据。

JSON 的 parse 与 stringify

首先来看看 MDN 的介绍:JSON.stringify()JSON.parse
  • JSON.stringify(value[, replacer [, space]]):用来将一个 JavaScript 值(对象或者数组)转换为一个 JSON 字符串,如果指定了 replacer 是一个函数,则可以选择性地替换值,或者如果指定了 replacer 是一个数组,则可选择性地仅包含数组指定的属性。
  • JSON.parse(text[, reviver]):用来解析 JSON 字符串,构造由字符串描述的 JavaScript 值或对象。提供可选的 reviver 函数用以在返回之前对所得到的对象执行变换(操作)。
你可能已注意到,JSON 的 stringify 比 parse 多了一段描述
转换值如果有 toJSON()方法,该方法定义什么值将被序列化。非数组对象的属性不能保证以特定的顺序出现在序列化后的字符串中。布尔值、数字、字符串的包装对象在序列化过程中会自动转换成对应的原始值。undefined、任意的函数以及 symbol 值,在序列化过程中会被忽略(出现在非数组对象的属性值中时)或者被转换成 null(出现在数组中时)。函数、undefined 被单独转换时,会返回 undefined,如 JSON.stringify(function(){}) or JSON.stringify(undefined).对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误。所有以 symbol 为属性键的属性都会被完全忽略掉,即便 replacer 参数中强制指定包含了它们。Date 日期调用了 toJSON()将其转换为了 string 字符串(同 Date.toISOString()),因此会被当做字符串处理。NaN 和 Infinity 格式的数值及 null 都会被当做 null。其他类型的对象,包括 Map/Set/weakMap/weakSet,仅会序列化可枚举的属性。
这段描述说明了,在 JSON.stringify 序列化时哪些数据会被保留、转换、忽略。所以我的前端项目里一定会在接口请求层里对 undefined 和 null 进行屏蔽:
const body = JSON.stringify(params, (k, v) => {
    if (v !== null && v !== undefined) {
      return v;
    }
});
同样在 axios 请求响应后,对 response data 里的 undefined 和 null 进行屏蔽:
import axios from 'axios';
import { ResponseData } from '../defines';

// axios.defaults.timeout = 10000;

const parseJSON = (response: any) => {
  // 先对Object进行序列化,再有条件的反序列化
  const dataString = JSON.stringify(response);
  const dataObj = JSON.parse(dataString, (k: any, v: any) => {
    if (v === null) {
      return undefined;
    }
    return v;
  });
  return dataObj;
};

const parseResponse = (response: any) => {
  if (response.status >= 200 && response.status < 300) {
    return response.data;
  }
  return {};
};

export const request = async (options: any): Promise<ResponseData> => {
  try {
    const resp = await axios(options);
    const data = await parseResponse(resp);
    return parseJSON(data);
  } catch (err) {
    return Promise.resolve({
      code: 999,
      msg: '网络超时',
    });
  }
};

完善解构赋值

上面的 JSON.stringify、JSON.parse 是多余和浪费性能的???下面我们用代码来说话。
// types.ts
export class OrderBase {
  creatTime: string;
  payTime: string;
  // 其他省略
}

export class OrderDetail {
  orderNo: string;
  userNo: string;
  businessCode: string;
  imgList: string[];
  base: OrderBase;
}
鉴于目前 ts 越来越火,在中大型项目中使用越来越多,所以我这里也引入 ts,且不会提示 ts 错误。
// order.ts
function test() {
  const orderinfo: OrderDetail = JSON.parse('{}');
  const { imgList, base } = orderinfo;

  let imgurl = [];
  if (imgList && imgList.length > 0) {
    imgurl = imgList.map(item => (item += '?xxx'));
  }
  const { payTime } = base || {};

  let showpay = false;
  if (payTime && payTime.length > 0) {
    showpay = true;
  }
}
test();
在现代 JavaScript 项目中 ES6 写法已经广泛使用,如果你熟悉和习惯 ES6 写法,特别是解构赋值,刚才的代码一定会让你感到不舒服的!
function test() {
  const orderinfo: OrderDetail = JSON.parse('{}');
  const { imgList = [], base: { payTime = '' } = {} } = orderinfo;
  const imgurl = imgList.map(item => (item += '?xxx'));
  const showpay = payTime !== '';
}
test();
没错,这才是标准的 ES6 写法,解构赋值的默认值、嵌套解构等经常使用时,必须在解析接口响应处等源头,利用反序列化时将值为 null 的属性从对象中移除掉。
JavaScript 项目中最常见的十大错误。
notion image
我觉得解构赋值和解构默认值的出现,至少让前端少了 10%的代码量!!!ts 的出现,加上 es6、es2017、es2020 等发布,正确使用新特性,让我们逐步从低级 bug 中解脱出来。
不管接口规范定义得再好,也不要相信接口数据会 100%按照你的要求来。——前后端日常扯皮之接口部分

重构解构赋值

上面例子简单的说了解构赋值及默认值的好处,就是简化代码逻辑,少写 bug。但你也可能意识到,如果很多地方都要用解构,是不是都要写一遍默认值?!好像是的。
所以理想情况是,只在一处设置对象的默认值后,其他地方如嵌套解构、JSON 的序列化和反序列化等,就可以直接使用默认值了!下面开始记录改造过程。
TypeScript 项目里肯定要是从类型定义开始下手,即要在对象声明时定义默认值。因 ts 有类型推导,所以有了默认值的部分属性会被显示移除类型声明。注意,因添加了默认值,所以只能使用 class,且声明文件不会被注解,不能用 interface。
export class OrderBase {
  creatTime: string = '';
  payTime: string = '';
}

export class OrderDetail {
  orderNo: string = '';
  userNo = '';  // 根据默认值推导出userNo的类型为string
  imgList: string[] = [];
  base: OrderBase;
}
那现在测试效果,发现 userNo 居然是 undefined,意思是类型声明里的默认值未生效。
function test() {
  const orderinfo: OrderDetail = JSON.parse('{}');
  const { base: { payTime = '123' } = {}, userNo } = orderinfo;
  console.log(payTime, userNo); // 打印:123 undefined
}
test();
因为是 ts 项目,实际运行代码是编译之后的代码,而不是编写的代码。
// tsconfig.json中"target": "es2017"时的效果
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
function test() {
    const orderinfo = JSON.parse('{}');
    const { base: { payTime = '123' } = {}, userNo } = orderinfo;
    console.log(payTime, userNo);
}
test();


// tsconfig.json中"target": "es5"时的效果
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
function test() {
    var orderinfo = JSON.parse('{}');
    var _a = orderinfo.base, _b = (_a === void 0 ? {} : _a).payTime, payTime = _b === void 0 ? '123' : _b, userNo = orderinfo.userNo;
    console.log(payTime, userNo);
}
test();
经过编译之后,test 里的代码好像与声明文件没关系了,更别说使用定义的默认值。可以思考想一想这是为什么!
notion image
原因是:
  1. class 当做类型去声明变量时,用法和 interface 一样效果,编译后对应代码会被注解。
  1. 添加了类型声明的变量,数据类型并未发生变化,而不是变成类的一个实例。
正确做法时,让变量由一个 JSON 对象变成一个类实例。被类实例化后,就可以使用类的一切特性和自定义函数。

属性回填

const a = new OrderDetail();
console.log(a.userNo); // 打印:''
a.userNo = 'abc';
console.log(a.userNo); // 打印:'abc'
太阔怕了!

Object.assign

const obj = {
  userNo: 'a1',
  base: { creatTime: '2020-01-13 18:32:58' },
};
const a = Object.assign(new OrderDetail(), obj);
console.log(a.userNo); // 打印:'a1'
a.userNo = 'abc';
console.log(a.userNo); // 打印:'abc'
console.log(a.base.creatTime); // 打印:'2020-01-13 18:32:58'
console.log(a.base.payTime); // 打印:undefined
相比属性回填,大家应该更能接受通过 assign 去实例化。另外,assign 只能一层深拷贝,所以不要想着原型链上的操作。

immutable record

Record是一种仅记录 JS 对象中已注册过的成员的数据结构,且支持默认值。
A record is similar to a JS object, but enforces a specific set of allowed string keys, and has default values.
const { Record } = require('immutable')
const ABRecord = Record({ a: 1, b: 2 })
const myRecord = ABRecord({ b: 3,  x: 10 })     // ts中此处会提示错误

myRecord.size // 2
myRecord.get('a') // 1
myRecord.get('b') // 3
const myRecordWithoutB = myRecord.remove('b')   // remove后,b会被重置为2,而不是undefined
myRecordWithoutB.get('b') // 2
myRecordWithoutB.size // 2
myRecord.get('x') // undefined
immutable record 仅会记录初始化时已注册的 key,且能永远保证你 key-value 存在。 和 Object.assign 相比,Record 的数据结构更强势,自带的方法会为你的数据管理设计提供大放异彩的可能。
// 方式一:有类型声明
type PersonProps = {name: string, age: number};
const defaultValues: PersonProps = {name: 'Aristotle', age: 2400};
const PersonRecord = Record(defaultValues);
class Person extends PersonRecord<PersonProps> {
  getName(): string {
    return this.get('name')
  }

  setName(name: string): this {
    return this.set('name', name);
  }
}

// 方式二:无类型声明
class ABRecord extends Record({ a: 1, b: 2 }) {
  getAB() {
    return this.a + this.b;
  }
}

var myRecord = new ABRecord({b: 3})
myRecord.getAB() // 4
对比上面两种方式,区别在于是否是集成了 TypeScript。结合 Record 的文档,会发现 Record 其实是工厂模式的一种应用。先通过默认值方式注册到工厂,然后内部桥接了一些自定义函数。(本人未研究 Record 源码,若有不对请指出)
Record里也有专门说明 Choosing Records vs plain JavaScript objects
  • Runtime immutability:运行时不可变性,这符合 immutable 的一贯原则,数据的只读性。
  • Value equality:值相等,即 Record 提供 equals 来判断两个对象是否严格相等。
  • API methods:额外的 API 方法,如 getIn、equals、hashCode、toJS-深层、toObject-浅层、toJSON-浅层等。
  • Default values:默认值。为每个键提供默认值,即时被 remove 之后。
  • Serialization:序列化。即提供 toJS-深层、toObject-浅层、toJSON-浅层,和 JSON 对象之间的序列化和反序列化。
所以我在 React 项目中一直使用 immutable,为数据管理和页面 Render 设计了很多优化方案。

数据对象的类实例化

前面提到的重构解构赋值,目的就是将 JavaScript 中的 JSON Object 变为 Class Object,如标题所说的『另类』就是指将原有的 JSON 字符串与 JavaScript 值(对象或者数组)互转,变成 JSON 字符串或 JavaScript 值(对象或者数组)与 Class 互转。然后对 class 进行扩展,比如封装、继承、重载等,让 JavaScript 的强语言之路走得更远!
在 MVC 模型里,相比 utils 函数、String/Number 扩展函数等方式,使用 class 管理更容易减少项目的内部耦合度,也更方便扩展和维护。比如性别:
export enum SexType {
  none = 0,
  man = 1,
  woman = 2,
}

export class UserProfile {
  userNo: string = '';
  userName: string = '';
  sex: SexType = 0;

  static sexMap = {
    [SexType.none]: '保密',
    [SexType.man]: '男',
    [SexType.woman]: '女',
  };

  public get sexT(): string {
    return UserProfile.sexMap[this.sex] || '保密';
  }

  // 其他自定义函数
}

const myinfo = Object.assign(new UserProfile(), {
  userNo: '1',
  userName: 'test',
  sex: 1,
});

console.log(myinfo.sex, myinfo.sexT); // 打印:1   '男'
myinfo.userName = '1'; // ok - userName是可修改的
但是,如果引入 immutable-record 方式呢?
// index.ts - 声明文件
import { Record } from 'immutable';

export enum SexType {
  none = 0,
  man = 1,
  woman = 2,
}

interface IRecordInfo {
  createTime: string;
  updateTime?: string;
}

const IRecordInfoDefault: IRecordInfo = {
  createTime: '',
  updateTime: '',
};

export class RecordInfo extends Record(IRecordInfoDefault) {} // 建议使用class,即时没有扩展属性
// export const RecordInfo = Record(IRecordInfoDefault); // 不建议变量方式,否则不能把 RecordInfo 当做类型去声明变量

interface IUserProfile extends IRecordInfo {
  userNo: string;
  userName: string;
  sex: SexType;
}

const UserProfileDefault: IUserProfile = {
  userNo: '',
  userName: '',
  sex: SexType.none,
  ...IRecordInfoDefault,
};

export class UserProfile extends Record(UserProfileDefault) {
  sexMap = {
    [SexType.none]: '保密',
    [SexType.man]: '男',
    [SexType.woman]: '女',
  };

  public get sexT(): string {
    return this.sexMap[this.sex] || '保密';
  }

  // 其他自定义函数
}

export interface ITsTest {
  id: string;
}

const ITsTestDefault: ITsTest = {
  id: '',
};

export const TsTest = Record(ITsTestDefault);

const myinfo = new UserProfile({ userNo: '1', userName: 'test', sex: 1 });
console.log(myinfo.sex, myinfo.sexT); // 打印:1   '男'

myinfo.userName = '1'; // error - userName是只读的,不可修改
const myinfonew = myinfo.set('userName', '1'); // ok - 返回新的 immutable 对象
你应该注意到,引入 immutable record 后,在类型声明上可能会增加代码量,但我觉得这是值得的!原因有下:
  • 不影响声明类型,可读性强。
  • 从源头严格控制数据模型,防止无关数据。
  • 从 JSON 对象到 Class 对象,降低设计上的耦合度。
React 是以数据驱动页面的模式,即 MVC 中通过改变 M,自动去更新 V。所以引入 immutable record 能提升 M,减少 V、C 中的逻辑。对比自己日常的 React 代码,能看出哪些优化空间吗?!
// index.ts - 业务文件
import React from 'react';

import { RecordInfo, UserProfile, TsTest } from '../types';

interface DivBaseProps {
  data: RecordInfo;
}

const DivBase = (props: DivBaseProps) => {
  const { data } = props;
  console.log(data, data.hashCode()); // 如前面声明类型时,建议用class方式
  return (
    <div>
      <p>{data.createTime}</p>
      <p>{data.updateTime}</p>
    </div>
  );
};

interface HomeState {
  detail: UserProfile;
  extra: RecordInfo;
}

export default class Home extends React.PureComponent<any, HomeState> {
  constructor(props: any) {
    super(props);
    this.state = {
      detail: new UserProfile(), // ok
      extra: new RecordInfo(), // ok
      // detail: new RecordInfo(), // error - 缺失数据
      // extra: new UserProfile(), // ok - 因 UserProfile 从 RecordInfo继承而来
      // extra: new TsTest(), // error - 缺失数据
    };
  }

  componentDidMount() {
    setTimeout(() => {
      this.setState({ detail: new UserProfile({ sex: 1 }) });
    }, 1000);
  }

  render() {
    const { detail } = this.state;
    return <DivBase data={new RecordInfo(detail)}></DivBase>; // ok
    // return <DivBase data={{ ...detail }}></DivBase>; // error
  }
}

总结

本文先介绍 JavaScript 自带的 JSON 序列化和反序列化的基本使用,配合 ES6 中解构赋值及默认值,优化了在对空值判断的处理方式。然后利用类实例化,对 JSON 对象进一步反序列化,同时利用 class 进行高内聚低耦合的代码管理。
因为 TypeScript 提供编译基础,现在 JavaScript 的编码方式逐步向强语言靠拢,当然 Running time 还是未改变,期待 TypeScript 可输出 WebAssembly!
 

 

© 刘德华 2020 - 2023