几乎每个 都需要数据序列化。这种需求会在以下情况下出现: Web 应用程序 通过网络传输数据(例如 HTTP 请求、WebSockets) 在 HTML 中嵌入数据(例如,用于水合作用) 将数据存储在持久存储中(例如 LocalStorage) 在进程之间共享数据(例如 Web Worker 或 postMessage) 在许多情况下,数据丢失或损坏可能会导致严重后果,因此必须提供一种方便且安全的序列化机制,以帮助在开发阶段检测尽可能多的错误。出于这些目的,在开发过程中使用 作为数据传输格式并使用 TypeScript 进行静态代码检查会很方便。 JSON TypeScript 是 JavaScript 的超集,它应该能够无缝使用 和 等函数,对吗?事实证明,尽管 TypeScript 有这么多好处,但它并不自然地理解 JSON 是什么以及哪些数据类型可以安全地序列化和反序列化为 JSON。 JSON.stringify JSON.parse 让我们用一个例子来说明这一点。 TypeScript 中 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 只有四种基本数据类型( 、 、 、 )以及数组和对象。无法在 JSON 中保存 对象,以及 JavaScript 中的其他对象:函数、Map、Set 等。 null string number boolean Date 当 遇到无法以 JSON 格式表示的值时,就会发生类型转换。对于 对象,我们得到一个字符串,因为 对象实现了 方法,该方法返回一个字符串而不是 对象。 JSON.stringify 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 第二个问题是 函数返回 类型,其中日期字段的类型为 。但我们已经知道,我们将收到 类型,而不是 。 TypeScript 可以帮助我们找到这个错误,但为什么不呢? saveComment PostComment Date string Date 事实证明,在 TypeScript 的标准库中, 函数的类型为 。由于使用了 ,类型检查基本上被禁用。在我们的示例中,TypeScript 只是接受了我们的说法,即该函数将返回包含 对象的 。 JSON.parse (text: string) => any any Date PostComment 这种 TypeScript 行为既不方便又不安全。如果我们尝试将字符串视为 对象,我们的应用程序可能会崩溃。例如,如果我们调用 它可能会中断。 Date comment.updatedAt.toLocaleDateString() 事实上,在我们的小示例中,我们可以简单地将 对象替换为数字时间戳,这对于 JSON 序列化非常有效。然而,在实际应用中,数据对象可能很广泛,类型可以在多个位置定义,并且在开发过程中识别此类错误可能是一项具有挑战性的任务。 Date 如果我们可以增强 TypeScript 对 JSON 的理解会怎么样? 处理序列化 首先,让我们弄清楚如何让 TypeScript 了解哪些数据类型可以安全地序列化为 JSON。假设我们要创建一个函数 ,其中 TypeScript 将检查输入数据格式以确保它是 JSON 可序列化的。 safeJsonStringify function safeJsonStringify(data: JSONValue) { return JSON.stringify(data); } 在这个函数中,最重要的部分是 类型,它代表了所有可以用JSON格式表示的可能值。实现非常简单: JSONValue type JSONPrimitive = string | number | boolean | null | undefined; type JSONValue = JSONPrimitive | JSONValue[] | { [key: string]: JSONValue; }; 首先,我们定义 类型,它描述了所有原始 JSON 数据类型。我们还包含 类型,因为序列化时,具有 值的键将被省略。在反序列化期间,这些键根本不会出现在对象中,这在大多数情况下是相同的。 JSONPrimitive undefined undefined 接下来,我们描述 类型。此类型使用 TypeScript 描述递归类型的能力,递归类型是引用自身的类型。这里, 可以是 、 数组,也可以是所有值都是 类型的对象。因此,这种 类型的变量可以包含无限嵌套的数组和对象。还将检查其中的值是否与 JSON 格式兼容。 JSONValue JSONValue JSONPrimitive JSONValue JSONValue JSONValue 现在我们可以使用以下示例来测试 函数: 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’类型中缺少类型‘string’的索引签名”。 PostComment JSONValue 那么,什么是索引签名以及为什么缺少索引签名?还记得我们如何描述可以序列化为 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; }; 这样做意味着注释可以包含任何任意字段,这通常不是在应用程序中定义数据类型时所需的结果。 索引签名问题的真正解决方案来自 ,它允许递归地迭代字段,即使对于没有定义索引签名的类型也是如此。与泛型相结合,此功能允许将任何数据类型 转换为另一种类型 ,该类型与 JSON 格式兼容。 映射类型 T JSONCompatible<T> 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; 类型是一种映射类型,用于检查给定类型 是否可以安全地序列化为 JSON。它通过迭代类型 中的每个属性并执行以下操作来实现此目的: JSONCompatible<T> T T 条件类型验证属性的类型是否与 类型兼容,确保它可以安全地转换为 JSON。在这种情况下,属性的类型保持不变。 T[P] extends JSONValue ? T[P] : ... JSONValue 条件类型验证属性的类型是否不可分配给 JSON。在这种情况下,属性的类型将转换为 ,从而有效地将属性从最终类型中过滤掉。 T[P] extends NotAssignableToJson ? never : ... never 如果这两个条件都不满足,则递归检查类型,直到得出结论。这样,即使类型没有索引签名,它也可以工作。 开头的检查用于防止 类型转换为空对象类型 ,它本质上相当于 类型。 unknown extends T ? never :... unknown {} any 另一个有趣的方面是 类型。它由两个 TypeScript 原语(bigint 和 symbol)和 类型组成,Function 类型描述了任何可能的函数。 类型对于过滤掉任何不可分配给 JSON 的值至关重要。这是因为 JavaScript 中的任何复杂对象都是基于 Object 类型的,并且在其原型链中至少有一个函数(例如 )。 类型会迭代所有这些函数,因此检查函数足以过滤掉任何不可序列化为 JSON 的内容。 NotAssignableToJson Function Function toString() JSONCompatible 现在,让我们在序列化函数中使用此类型: function safeJsonStringify<T>(data: JSONCompatible<T>) { return JSON.stringify(data); } 现在,该函数使用通用参数 并接受 参数。这意味着它采用 类型的参数 ,该类型应该是 JSON 兼容的类型。现在我们可以使用没有索引签名的数据类型的函数。 T JSONCompatible<T> T data 该函数现在使用从 类型扩展的通用参数 这意味着它接受 类型的参数 ,该类型应该是 JSON 兼容的类型。因此,我们可以将该函数用于缺少索引签名的数据类型。 JSONCompatible<T> T T data 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 在本例中,我们用 类型替换 返回类型。为什么选择 ?本质上,JSON 字符串可以包含任何内容,而不仅仅是我们期望接收的数据。例如,数据格式可能会在不同的应用程序版本之间发生变化,或者应用程序的另一部分可能会将数据写入相同的 LocalStorage 键。所以, 才是最安全、最精准的选择。 unknown any unknown 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 序列化的情况。 我已经在我的项目中使用这种策略好几年了,它通过在应用程序开发过程中及时检测潜在错误来证明其有效性。 我希望这篇文章能为您提供一些新的见解。感谢您的阅读! 有用的链接 让 TypeScript 真正成为“强类型” 改进 TypeScript 的标准库类型 超级建筑图书馆 TS游乐场