几乎每个Web 应用程序都需要数据序列化。这种需求会在以下情况下出现:
在许多情况下,数据丢失或损坏可能会导致严重后果,因此必须提供一种方便且安全的序列化机制,以帮助在开发阶段检测尽可能多的错误。出于这些目的,在开发过程中使用JSON作为数据传输格式并使用 TypeScript 进行静态代码检查会很方便。
TypeScript 是 JavaScript 的超集,它应该能够无缝使用JSON.stringify
和JSON.parse
等函数,对吗?事实证明,尽管 TypeScript 有这么多好处,但它并不自然地理解 JSON 是什么以及哪些数据类型可以安全地序列化和反序列化为 JSON。
让我们用一个例子来说明这一点。
例如,考虑一个将一些数据保存到 LocalStorage 的函数。由于LocalStorage无法存储对象,因此我们这里使用JSON序列化:
interface PostComment { authorId: string; text: string; updatedAt: Date; } function saveComment(comment: PostComment) { const serializedComment = JSON.stringify(comment); localStorage.setItem('draft', serializedComment); }
我们还需要一个函数来从 LocalStorage 检索数据。
function restoreComment(): PostComment | undefined { const text = localStorage.getItem('draft'); return text ? JSON.parse(text) : undefined; }
这段代码有什么问题?第一个问题是,在恢复评论时,我们会得到一个string
类型,而不是updatedAt
字段的Date
类型。
发生这种情况是因为 JSON 只有四种基本数据类型( null
、 string
、 number
、 boolean
)以及数组和对象。无法在 JSON 中保存Date
对象,以及 JavaScript 中的其他对象:函数、Map、Set 等。
当JSON.stringify
遇到无法以 JSON 格式表示的值时,就会发生类型转换。对于Date
对象,我们得到一个字符串,因为Date
对象实现了toJson()方法,该方法返回一个字符串而不是Date
对象。
const date = new Date('August 19, 1975 23:15:30 UTC'); const jsonDate = date.toJSON(); console.log(jsonDate); // Expected output: "1975-08-19T23:15:30.000Z" const isEqual = date.toJSON() === JSON.stringify(date); console.log(isEqual); // Expected output: true
第二个问题是saveComment
函数返回PostComment
类型,其中日期字段的类型为Date
。但我们已经知道,我们将收到string
类型,而不是Date
。 TypeScript 可以帮助我们找到这个错误,但为什么不呢?
事实证明,在 TypeScript 的标准库中, JSON.parse
函数的类型为(text: string) => any
。由于使用了any
,类型检查基本上被禁用。在我们的示例中,TypeScript 只是接受了我们的说法,即该函数将返回包含Date
对象的PostComment
。
这种 TypeScript 行为既不方便又不安全。如果我们尝试将字符串视为Date
对象,我们的应用程序可能会崩溃。例如,如果我们调用comment.updatedAt.toLocaleDateString()
它可能会中断。
事实上,在我们的小示例中,我们可以简单地将Date
对象替换为数字时间戳,这对于 JSON 序列化非常有效。然而,在实际应用中,数据对象可能很广泛,类型可以在多个位置定义,并且在开发过程中识别此类错误可能是一项具有挑战性的任务。
如果我们可以增强 TypeScript 对 JSON 的理解会怎么样?
首先,让我们弄清楚如何让 TypeScript 了解哪些数据类型可以安全地序列化为 JSON。假设我们要创建一个函数safeJsonStringify
,其中 TypeScript 将检查输入数据格式以确保它是 JSON 可序列化的。
function safeJsonStringify(data: JSONValue) { return JSON.stringify(data); }
在这个函数中,最重要的部分是JSONValue
类型,它代表了所有可以用JSON格式表示的可能值。实现非常简单:
type JSONPrimitive = string | number | boolean | null | undefined; type JSONValue = JSONPrimitive | JSONValue[] | { [key: string]: JSONValue; };
首先,我们定义JSONPrimitive
类型,它描述了所有原始 JSON 数据类型。我们还包含undefined
类型,因为序列化时,具有undefined
值的键将被省略。在反序列化期间,这些键根本不会出现在对象中,这在大多数情况下是相同的。
接下来,我们描述JSONValue
类型。此类型使用 TypeScript 描述递归类型的能力,递归类型是引用自身的类型。这里, JSONValue
可以是JSONPrimitive
、 JSONValue
数组,也可以是所有值都是JSONValue
类型的对象。因此,这种JSONValue
类型的变量可以包含无限嵌套的数组和对象。还将检查其中的值是否与 JSON 格式兼容。
现在我们可以使用以下示例来测试safeJsonStringify
函数:
// No errors safeJsonStringify({ updatedAt: Date.now() }); // Yields an error: // Argument of type '{ updatedAt: Date; }' is not assignable to parameter of type 'JSONValue'. // Types of property 'updatedAt' are incompatible. // Type 'Date' is not assignable to type 'JSONValue'. safeJsonStringify({ updatedAt: new Date(); });
一切似乎都运转正常。该函数允许我们将日期作为数字传递,但如果我们传递Date
对象,则会产生错误。
但让我们考虑一个更实际的示例,其中传递给函数的数据存储在变量中并具有所描述的类型。
interface PostComment { authorId: string; text: string; updatedAt: number; }; const comment: PostComment = {...}; // Yields an error: // Argument of type 'PostComment' is not assignable to parameter of type 'JSONValue'. // Type 'PostComment' is not assignable to type '{ [key: string]: JSONValue; }'. // Index signature for type 'string' is missing in type 'PostComment'. safeJsonStringify(comment);
现在,事情变得有点棘手。 TypeScript 不允许我们将PostComment
类型的变量分配给JSONValue
类型的函数参数,因为“‘PostComment’类型中缺少类型‘string’的索引签名”。
那么,什么是索引签名以及为什么缺少索引签名?还记得我们如何描述可以序列化为 JSON 格式的对象吗?
type JSONValue = { [key: string]: JSONValue; };
在本例中, [key: string]
是索引签名。它的意思是“这个对象可以有任何字符串形式的键,其值具有JSONValue
类型”。那么,我们需要为PostComment
类型添加索引签名,对吗?
interface PostComment { authorId: string; text: string; updatedAt: number; // Don't do this: [key: string]: JSONValue; };
这样做意味着注释可以包含任何任意字段,这通常不是在应用程序中定义数据类型时所需的结果。
索引签名问题的真正解决方案来自映射类型,它允许递归地迭代字段,即使对于没有定义索引签名的类型也是如此。与泛型相结合,此功能允许将任何数据类型T
转换为另一种类型JSONCompatible<T>
,该类型与 JSON 格式兼容。
type JSONCompatible<T> = unknown extends T ? never : { [P in keyof T]: T[P] extends JSONValue ? T[P] : T[P] extends NotAssignableToJson ? never : JSONCompatible<T[P]>; }; type NotAssignableToJson = | bigint | symbol | Function;
JSONCompatible<T>
类型是一种映射类型,用于检查给定类型T
是否可以安全地序列化为 JSON。它通过迭代类型T
中的每个属性并执行以下操作来实现此目的:
T[P] extends JSONValue ? T[P] : ...
条件类型验证属性的类型是否与JSONValue
类型兼容,确保它可以安全地转换为 JSON。在这种情况下,属性的类型保持不变。T[P] extends NotAssignableToJson ? never : ...
条件类型验证属性的类型是否不可分配给 JSON。在这种情况下,属性的类型将转换为never
,从而有效地将属性从最终类型中过滤掉。
unknown extends T ? never :...
开头的检查用于防止unknown
类型转换为空对象类型{}
,它本质上相当于any
类型。
另一个有趣的方面是NotAssignableToJson
类型。它由两个 TypeScript 原语(bigint 和 symbol)和Function
类型组成,Function 类型描述了任何可能的函数。 Function
类型对于过滤掉任何不可分配给 JSON 的值至关重要。这是因为 JavaScript 中的任何复杂对象都是基于 Object 类型的,并且在其原型链中至少有一个函数(例如toString()
)。 JSONCompatible
类型会迭代所有这些函数,因此检查函数足以过滤掉任何不可序列化为 JSON 的内容。
现在,让我们在序列化函数中使用此类型:
function safeJsonStringify<T>(data: JSONCompatible<T>) { return JSON.stringify(data); }
现在,该函数使用通用参数T
并接受JSONCompatible<T>
参数。这意味着它采用T
类型的参数data
,该类型应该是 JSON 兼容的类型。现在我们可以使用没有索引签名的数据类型的函数。
该函数现在使用从JSONCompatible<T>
类型扩展的通用参数T
这意味着它接受T
类型的参数data
,该类型应该是 JSON 兼容的类型。因此,我们可以将该函数用于缺少索引签名的数据类型。
interface PostComment { authorId: string; text: string; updatedAt: number; } function saveComment(comment: PostComment) { const serializedComment = safeJsonStringify(comment); localStorage.setItem('draft', serializedComment); }
只要需要 JSON 序列化,例如通过网络传输数据、在 HTML 中嵌入数据、将数据存储在 localStorage 中、在工作程序之间传输数据等,都可以使用此方法。此外,当严格toJsonValue
对象没有需要将索引签名分配给JSONValue
类型的变量。
function toJsonValue<T>(value: JSONCompatible<T>): JSONValue { return value; } const comment: PostComment = {...}; const data: JSONValue = { comment: toJsonValue(comment) };
在此示例中,使用toJsonValue
让我们绕过与PostComment
类型中缺少索引签名相关的错误。
当涉及到反序列化时,挑战既简单又复杂,因为它涉及对接收数据格式的静态分析检查和运行时检查。
从 TypeScript 类型系统的角度来看,挑战非常简单。让我们考虑以下示例:
function safeJsonParse(text: string) { return JSON.parse(text) as unknown; } const data = JSON.parse(text); // ^? unknown
在本例中,我们用unknown
类型替换any
返回类型。为什么选择unknown
?本质上,JSON 字符串可以包含任何内容,而不仅仅是我们期望接收的数据。例如,数据格式可能会在不同的应用程序版本之间发生变化,或者应用程序的另一部分可能会将数据写入相同的 LocalStorage 键。所以, unknown
才是最安全、最精准的选择。
然而,使用unknown
类型不如仅仅指定所需的数据类型方便。除了类型转换之外,还有多种方法可以将unknown
类型转换为所需的数据类型。其中一种方法是利用Superstruct库在运行时验证数据,并在数据无效时抛出详细错误。
import { create, object, number, string } from 'superstruct'; const PostComment = object({ authorId: string(), text: string(), updatedAt: number(), }); // Note: we no longer need to manually specify the return type function restoreDraft() { const text = localStorage.getItem('draft'); return text ? create(JSON.parse(text), PostComment) : undefined; }
在这里, create
函数充当类型保护,将类型缩小到所需的Comment
接口。因此,我们不再需要手动指定返回类型。
实现安全的反序列化选项只是故事的一半。同样重要的是,在处理项目中的下一个任务时不要忘记使用它。如果一个大型团队正在处理该项目,这将变得尤其具有挑战性,因为确保遵循所有协议和最佳实践可能很困难。
Typescript-eslint可以帮助完成这项任务。该工具有助于识别any
不安全使用的所有实例。具体来说,可以找到JSON.parse
的所有用法,并且可以确保接收到的数据的格式得到检查。有关摆脱代码库中的any
类型的更多信息,请参阅《使 TypeScript 真正成为“强类型”》一文。
以下是旨在帮助安全 JSON 序列化和反序列化的最终实用函数和类型。您可以在准备好的TS Playground中测试这些。
type JSONPrimitive = string | number | boolean | null | undefined; type JSONValue = JSONPrimitive | JSONValue[] | { [key: string]: JSONValue; }; type NotAssignableToJson = | bigint | symbol | Function; type JSONCompatible<T> = unknown extends T ? never : { [P in keyof T]: T[P] extends JSONValue ? T[P] : T[P] extends NotAssignableToJson ? never : JSONCompatible<T[P]>; }; function toJsonValue<T>(value: JSONCompatible<T>): JSONValue { return value; } function safeJsonStringify<T>(data: JSONCompatible<T>) { return JSON.stringify(data); } function safeJsonParse(text: string): unknown { return JSON.parse(text); }
这些可用于任何需要 JSON 序列化的情况。
我已经在我的项目中使用这种策略好几年了,它通过在应用程序开发过程中及时检测潜在错误来证明其有效性。
我希望这篇文章能为您提供一些新的见解。感谢您的阅读!