本文介绍 JavaScript 处理二进制数据的三种 API:ArrayBuffer 是底层数据容器,TypedArray 适合同构数组访问,DataView 适合按任意偏移、类型和字节序读写。结合 MP4 Box 结构可知,因其字段类型混杂、偏移不对齐且采用大端序,DataView 最适合解析字段,Uint8Array 最适合切片、拷贝和扫描。
JavaScript 处理二进制数据的 API 主要有三种:`ArrayBuffer`、`TypedArray` 和 `DataView`。在 MP4 Box 的解析和处理过程中,这些工具非常有用。本文结合实际的 MP4 box 结构,聊聊它们各自的定位和取舍。 ## 三者的关系 ### ArrayBuffer **ArrayBuffer** 是一块原始的、固定长度的二进制内存,你不能直接读写它,必须通过"视图"来操作。当你 fetch 一个 fMP4 文件后,对其解析会先转为 ArrayBuffer。 ```typescript const response = await fetch('video.m4s'); const buffer = await response.arrayBuffer(); console.log(buffer); ```  ### TypedArray **TypedArray** 是一组类型化数组视图(Uint8Array、Uint16Array、Uint32Array、Float32Array 等),它把 ArrayBuffer 当作同构的数组来访问——所有元素类型相同、等宽排列。它只是在已有的 ArrayBuffer 上建立一个视图,本身不复制也不额外分配数据内存。 ```typescript const response = await fetch('video.m4s'); const buffer = await response.arrayBuffer(); const uint8 = new Uint8Array(buffer); // 创建视图,不会占用额外内存 console.log(uint8); ```  ### DataView **DataView** 也是建立在 ArrayBuffer 上的视图,同样不复制数据。与 TypedArray 不同的是,它不把 buffer 当数组看,而是提供了一组方法让你在任意偏移位置、以任意类型和字节序来读写数据——没有对齐限制,也不要求字段类型统一。 ```typescript const response = await fetch('video.m4s'); const buffer = await response.arrayBuffer(); const view = new DataView(buffer); const size = view.getUint32(0); const type = String.fromCharCode( view.getUint8(4), view.getUint8(5), view.getUint8(6), view.getUint8(7) ); console.log(size, type); ```  三者的关系可以这样理解:ArrayBuffer 是仓库,TypedArray 和 DataView 是两种不同的取货方式——前者像传送带,只能运同一规格的货物;后者像叉车,想取什么取什么。 ## MP4 Box 的结构特点 MP4 文件遵循 ISO 14496-12(ISOBMFF)规范,整个文件由嵌套的 box 组成。每个 box 的基本结构是: ``` [4 bytes] size (uint32) [4 bytes] type (4个ASCII字符) [可选] largesize (uint64,当 size==1 时) [可选] version (uint8) + flags (uint24) [...] payload (各种混合类型字段) ``` 这里有几个关键特征: 1. **字段类型混杂**:一个 box 里 uint8、uint16、uint24、uint32、uint64 混着来,没法用单一的 TypedArray 类型映射整个 box 2. **字段偏移不对齐**:比如 1 字节的 version 后面紧跟 3 字节的 flags,再接 uint32 字段——中间穿插了奇数长度的字段后,后续偏移很容易不是 2、4、8 的倍数,TypedArray 的对齐要求就满足不了 3. **大端字节序**:MP4 规范要求所有多字节整数使用大端字节序,而 TypedArray 使用平台原生字节序(绝大多数设备是小端),直接读出来的值是反的 这三条,直接决定了各 API 的适用程度。 ## Uint8Array 数据搬运和字节级操作 在 MP4 解析中,Uint8Array 是用得最多的 TypedArray。它没有对齐和字节序的问题——每个元素就是一个字节,强项是数据搬运(切片、拷贝、拼接)和逐字节扫描。 ```js // 切出某个 box 的 payload const boxPayload = new Uint8Array(buffer, boxOffset + 8, boxSize - 8); // 拼接两段 segment 数据 const merged = new Uint8Array(a.length + b.length); merged.set(a, 0); merged.set(b, a.length); // 逐字节匹配 box type,找到 mdat box 的位置 const bytes = new Uint8Array(buffer); for (let i = 0; i < bytes.length - 7; i++) { if (bytes[i+4] === 0x6D && bytes[i+5] === 0x64 && bytes[i+6] === 0x61 && bytes[i+7] === 0x74) { console.log('mdat box at offset', i); break; } } ``` ## Uint32Array 为什么在 MP4 解析中几乎没用 直觉上,MP4 box 里到处是 uint32 字段,Uint32Array 应该很适合?实际上它在 MP4 解析里几乎没有用武之地,最核心的原因是它的字节序不对。 MP4 是大端,而 Uint32Array 使用平台原生字节序。绝大多数设备(x86、ARM)是小端,这意味着直接读出来的值是字节反转的。 举个例子,要把值 23 写入 buffer,按 MP4 大端格式应该是 `00 00 00 17`: ```typescript // DataView:直接写 23,指定大端 const view = new DataView(buf); view.setUint32(0, 23, false); // buffer: 00 00 00 17 ✓ // Uint32Array:写 23 会变成小端排列 const arr = new Uint32Array(buf); arr[0] = 23; // 小端机器上 buffer: 17 00 00 00 ✗ // 要得到正确的字节排列,你得手动翻转 arr[0] = 0x17000000; // 即 23 × 2²⁴ = 385875968 // buffer: 00 00 00 17 ✓ ``` 把一个简单的 23 转成 385875968 才能写入,这样就太复杂了。 ## DataView 的灵活性 DataView 完美解决了上述问题。它允许你在任意偏移位置,以任意类型和字节序来读写数据。 DataView 提供了 10 对 getter/setter: | 方法 | 字节数 | 说明 | |------|-------|------| | `getInt8` / `getUint8` | 1 | 无字节序参数(单字节不需要) | | `getInt16` / `getUint16` | 2 | 第二参数控制字节序 | | `getInt32` / `getUint32` | 4 | 第二参数控制字节序 | | `getFloat32` / `getFloat64` | 4 / 8 | IEEE 754 浮点数 | | `getBigInt64` / `getBigUint64` | 8 | 返回 BigInt | 每个 getter 都有对应的 setter,setter 多一个 value 参数。所有多字节方法的最后一个参数 `littleEndian` 默认为 `false`(大端),恰好和 MP4 的字节序一致。 ## 总结 | API | MP4 解析中的角色 | 典型场景 | |-----|----------------|---------| | ArrayBuffer | 底层数据容器 | 承载所有二进制数据 | | Uint8Array | 高频工具 | 切片、拷贝、扫描、读 box type | | Uint32Array 等 | 几乎不用 | 字节序/对齐/混合类型三重限制 | | DataView | 核心解析工具 | 读写任意类型、任意偏移、可控字节序 | 写 MP4 parser 时,一个简单的原则:**搬运数据用 Uint8Array,解析字段用 DataView**。
最近尝试接触前端音视频领域。在开发 HLS(fMP4)播放器的过程中,遇到一个比较棘手的问题: 部分 fmp4 分片的 `tfdt.baseMediaDecodeTime` 并不是从 0 开始连续递增。在直接通过 mediaSource 播放时,容易出现时间轴错位,甚至播放器卡死的情况。这篇文章记录一下修复过程。 ## 踩坑过程 今天在写 fmp4 player 的时候发现部分 M3U8 播放不出来,这边用 ffprobe 看了下发现它的 start 比较诡异: ```bash ffprobe /Users/suemor/Downloads/xxxx.m4s 2>&1 | grep Duration Duration: 00:00:08.35, start: 4.189002, bitrate: 2071 kb/s ``` 可以看到 start 直接从第 4 秒开始了,这会导致浏览器 mediaSource 播放出现问题。 开始想了一个比较 hack 方式,但这有些问题,当用户 seek 到 0-4 秒之间会被强制 seek 到第 4 秒,体验上欠妥。 ``` typescript const { buffered } = state.sourceBuffer const { currentTime } = state.media const GAP_TOLERANCE = 0.5 // 容忍 0.5s 的误差 const JUMP_OFFSET = 0.1 // 跳入 Buffer 内部 0.1s 以确保安全 if (buffered.length > 0) { const bufferStart = buffered.start(0) if ( currentTime < bufferStart && bufferStart - currentTime > GAP_TOLERANCE ) { state.media.currentTime = bufferStart + JUMP_OFFSET return } } ``` 为了解决这个问题,我打算在播放链路中引入了一个简单的 transmuxer,在解封装阶段对 `baseMediaDecodeTime` 进行修正。 ## Box 结构 要开发一个 transmuxer 需要先了解下目前手里这个 fmp4 box 结构: ```basic [File] (3s Segment) ├── [ftyp] ├── [moov] ├── [moof] (Fragment 1) │ ├── [mfhd] │ ├── [traf] (Track Fragment - 视频轨?) │ │ ├── [tfhd] (Track ID: 1) │ │ └── [tfdt] (Decode Time) -> 需要修正! │ └── [traf] (Track Fragment - 音频轨?) │ ├── [tfhd] (Track ID: 2) │ └── [tfdt] (Decode Time) -> 需要修正! ├── [mdat] (Media Data 1) ├── [moof] (Fragment 2) │ ├── [mfhd] │ ├── [traf] ... │ └── [traf] ... ├── [mdat] (Media Data 2) └── ... (重复多次) ``` 我查阅资料后发现:`TFDT` Box 里的 `baseMediaDecodeTime` 决定了这个片段的**绝对解码时间**。 后续需要读取整个流中**第一个** `moof` 的 `baseMediaDecodeTime` 作为基准偏移量,对于分片内的**每一个** `moof`(不仅仅是第一个),都执行 `当前时间 - Offset`,强行把时间轴整体“平移”回 0 起点。 ## Box 解析器 要修改二进制数据,首先得能读懂它。JavaScript 的 `DataView` 是处理二进制数据的神器,它允许我们直接操作内存并控制大端序。 下面实现了一个极简的 `MP4Parser` 类。它的核心逻辑是:读取 Box 的 Header(大小和类型),并提供访问内容的视图。 ```typescript // mp4-parser.ts export type BufferLike = ArrayBuffer | SharedArrayBuffer export class MP4Parser { private readonly buffer: BufferLike private readonly fileEnd: number public readonly offset: number public readonly size: number public readonly type: number public readonly headerSize: number constructor(buffer: BufferLike, offset: number, fileEnd?: number) { this.buffer = buffer this.offset = offset this.fileEnd = fileEnd ?? buffer.byteLength // 读取前 16 字节来解析 Header const view = new DataView( buffer, offset, Math.min(16, this.fileEnd - offset), ) const size32 = view.getUint32(0) this.type = view.getUint32(4) // Box 类型 // 处理 size 的不同情况 (标准 MP4 协议) if (size32 === 1) { // size 为 1 表示这是个 Large Box,真实大小在后面 8 字节 this.size = Number(view.getBigUint64(8)) this.headerSize = 16 } else if (size32 === 0) { // size 为 0 表示一直到文件末尾 this.size = this.fileEnd - offset this.headerSize = 8 } else { this.size = size32 this.headerSize = 8 } } /** * 获取 Box 的内容部分(不包含 Header) * 返回 DataView,适合读取内部的具体数值(如 TrackID, TimeStamp) */ public getContentDataView(): DataView { return new DataView( this.buffer, this.offset + this.headerSize, this.size - this.headerSize, ) } /** * 获取 Box 的内容部分(不包含 Header) * 返回 Uint8Array,适合作为下一次 findBoxes 的输入,或者进行字节级操作 */ public getContentView(): Uint8Array { return new Uint8Array( this.buffer, this.offset + this.headerSize, this.size - this.headerSize, ) } } ``` ## 查找 Box 有了解析器,我们还需要开发一个快速查找指定 Box 的工具。 在下方 `BoxUtils` 中,实现了一个 `findBoxes` 函数。为了性能最大化,它采用了**跳跃式遍历**:读取一个 Box 的 header 拿到 `size`,如果不匹配,直接跳过 `size` 长度的字节,而不是逐字节扫描。 ```typescript // box-utils.ts import { MP4Parser } from './mp4-parser' // 辅助函数:统一转为 Uint8Array function asU8(data: BytesLike): Uint8Array { return data instanceof Uint8Array ? data : new Uint8Array(data) } export const BoxUtils = { /** * 在给定的数据范围内查找指定类型的 Box */ findBoxes( data: BytesLike, type: number, start = 0, end?: number, ): MP4Parser[] { const u8 = asU8(data) const buf = u8.buffer const base = u8.byteOffset const limit = end ?? u8.byteLength const boxes: MP4Parser[] = [] let offset = start // 循环遍历,直到范围结束 while (offset < limit) { // 剩余数据不足 header 长度,退出 if (offset + 8 > limit) { break } const box = new MP4Parser(buf, base + offset, base + limit) const boxSize = box.size === 0 ? limit - offset : box.size // 异常检查 if (boxSize < box.headerSize || offset + boxSize > limit) { break } // 找到目标 Box,加入结果列表 if (box.type === type) { boxes.push(box) } // 关键:直接跳过整个 Box 的大小,进入下一个 Box offset += boxSize } return boxes }, } ``` ## 完成修正 我们先梳理接下来查找 tfdt box 的链路。 根据下方链路图片可以发现:我们需要对 moof -> traf -> tfhd -> tfdt 进行解析。 ```bash [File] (3s Segment) ├── [moof] (Fragment 1) │ ├── [mfhd] │ ├── [traf] (Track Fragment - 视频轨?) │ │ ├── [tfhd] (Track ID: 1) │ │ └── [tfdt] (Decode Time) -> 需要修正! │ └── [traf] (Track Fragment - 音频轨?) │ ├── [tfhd] (Track ID: 2) │ └── [tfdt] (Decode Time) -> 需要修正! ├── [moof] (Fragment 2) │ ├── [mfhd] │ ├── [traf] ... │ └── [traf] ... └── ... (重复多次) // 对应的 16 进制表示 MOOF: 0x6d6f6f66 TRAF: 0x74726166 TFHD: 0x74666864 TFDT: 0x74666474 ``` ### 解析 tfhd trackId 对于 traf 的 trackId 它位于 tfhd box body 的第 4 个字节处,它占据 4 个字节。 ```bash [TFHD Body Layout] +---------+---------+-----------+ | Version | Flags | Track ID | ... +---------+---------+-----------+ | 1 byte | 3 bytes | 4 bytes | +---------+---------+-----------+ ^ ^ offset 0 offset 4 (读取位置) ``` ```typescript parseTfhdTrackId(tfhdBox: MP4Parser): number { const view = tfhdBox.getContentDataView() // 偏移量 4 = Version(1) + Flags(3) // 紧接着就是 TrackID (4 bytes) return view.getUint32(4) }, ``` ### 解析 tfdt baseMediaDecodeTime `tfdt` (Track Fragment Decode Time) 存储了该分片的绝对解码时间。这里有一个必须注意的**版本兼容性**问题: - **Version 0**:使用 32 位整数(`UInt32`)。 - **Version 1**:使用 64 位整数(`UInt64`)。 视频时间一长,时间戳很容易超过 32 位整数的范围(约 42 亿),因此现代 HLS 流大多使用 Version 1。为了防止精度丢失,我在代码中统一将其转换为 JavaScript 的 **`BigInt`**。 ```typescript parseTfdtBaseMediaDecodeTime(tfdtBox: MP4Parser): bigint { const view = tfdtBox.getContentDataView() const version = view.getUint8(0) // 读取第1个字节:version 用于判断是否 32 位溢出 if (version === 0) { // Version 0: 32位,转为 BigInt 统一处理 return BigInt(view.getUint32(4)) } // Version 1: 64位,必须用 getBigUint64 return view.getBigUint64(4) }, ``` ### 修正 tfdt baseMediaDecodeTime 同理,这里也需要注意 32 位溢出的问题,需要用 version 进行额外判断 ```typescript updateTfdtBaseMediaDecodeTime( tfdtBox: MP4Parser, newBaseMediaDecodeTime: bigint, ) { const view = tfdtBox.getContentDataView() const version = view.getUint8(0) if (version === 0) { // 32位写入:需要将 BigInt 转回 Number view.setUint32(4, Number(newBaseMediaDecodeTime)) } else { // 64位写入:直接写入 BigInt view.setBigUint64(4, newBaseMediaDecodeTime) } }, ``` ### 调用 最后,我们需要在主类中调用上述方法。由于我的流结构包含多个 `moof` 且每个 `moof` 包含多个 `traf`,代码采用了双层循环结构。 这里需要注意,我的 fmp4 包含两个 traf 分别对于视频和音频,他们的 decodeTime 并不是一致的,所以需要解析 tfhd box 获取当前的 track id,来区分当前 traf 属于哪个轨道,从而能够使用到对于轨道的 decodeTime。 ```typescript // transmuxer.ts import { BoxUtils } from './box-utils' export class Transmuxer { private baseTimestampOffset: Record<number, bigint> = {} // TrackID -> Offset private fmp4: Fmp4 constructor(fmp4: Fmp4) { this.fmp4 = fmp4 } processTimeOffset(data: ArrayBuffer, isFirstSegment: boolean) { // 第一层循环:遍历分片内所有的 MOOF BoxUtils.findBoxes(data, BoxUtils.types.MOOF).forEach((moofBoxes) => { const trafBoxes = BoxUtils.findBoxes(moofBoxes.getContentView(), BoxUtils.types.TRAF) // 第二层循环:遍历 MOOF 内所有的 TRAF (通常是 Video 和 Audio) for (const trafBox of trafBoxes) { const trafContent = trafBox.getContentView() // 1. 获取 Track ID (音频/视频分开处理) const tfhdBoxes = BoxUtils.findBoxes(trafContent, BoxUtils.types.TFHD) if (tfhdBoxes.length === 0) continue const trackId = BoxUtils.parseTfhdTrackId(tfhdBoxes[0]) // 2. 找到 TFDT 读取时间 const tfdtBoxes = BoxUtils.findBoxes(trafContent, BoxUtils.types.TFDT) if (tfdtBoxes.length === 0) continue const tfdtBox = tfdtBoxes[0] // 使用 BigInt 读取,防止大整数溢出 const baseMediaDecodeTime = BoxUtils.parseTfdtBaseMediaDecodeTime(tfdtBox) // 3. 仅在全流的第一个分片记录基准 Offset if (isFirstSegment && this.baseTimestampOffset[trackId] === undefined) { this.fmp4.logger.log(`[Transmuxer] Track ${trackId} set offset: ${baseMediaDecodeTime}`) this.baseTimestampOffset[trackId] = baseMediaDecodeTime } // 4. 修正时间:当前时间 - Offset const offset = this.baseTimestampOffset[trackId] ?? 0n const newBaseMediaDecodeTime = baseMediaDecodeTime - offset if (newBaseMediaDecodeTime < 0n) continue // 5. 原地修改 Buffer BoxUtils.updateTfdtBaseMediaDecodeTime(tfdtBox, newBaseMediaDecodeTime) } }) return data } } ```
这篇主要来聊一聊前端常见的跨域问题,以及后端如何处理 CORS 和预检请求 (Preflight)。 ## 浏览器在什么情况下会发生跨域 浏览器通过“同源策略”限制不同源之间的资源交互,以保护用户隐私和安全。其中**源**由三个部分组成:**协议**、**域名** 和 **端口**。只有这三者同时满足才是**同源**。否则,就是**跨域**,向服务端发送请求时会触发浏览器的跨域限制,报以下错误: ```text Access to fetch at 'https://server.suemor.com/api/posts' from origin 'https://suemor.com' CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled. ``` 具体看下方 4 个例子。 ### 跨域 以下是三种跨域情况: #### 不同协议 ```http https://suemor.com http://suemor.com/api/posts ``` #### 不同域名 ```http https://suemor.com https://server.suemor.com/api/posts //tips: 下方这个也是跨域 http://127.0.0.1:3000 -> http://localhost:3000/api/posts ``` #### 不同端口 ```http http://localhost:3000 http://localhost:5050/api/posts ``` #### 同源 下方这个是同源,没有跨域问题。 ```http https://suemor.com https://suemor.com/api/posts ``` ## 解决跨域 跨域问题通常在服务端解决,通过配置反向代理或修改后端代码。 跨域请求分为**简单请求**和**复杂请求**: ### 简单请求 对于同时满足以下三个条件的即为简单请求,服务器只需返回正确的 CORS 头,即 **Access-Control-Allow-Origin**: - **请求方法**:GET、POST 或 HEAD。 - **Content-Type**:限于 application/x-www-form-urlencoded、multipart/form-data 或 text/plain。 - **不包含自定义头**:如 Authorization。 看以下 Express 示例: ```typescript // 假设 web 端位于 http://localhost:3000 // 假设 server 端位于 http://localhost:5050 // web fetch("http://localhost:5000/api/posts", { method: "GET" }); // server app.use((req: Request, res: Response, next: NextFunction) => { res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000"); // 或者 res.setHeader("Access-Control-Allow-Origin", ""); next(); }); ``` ### 复杂请求 符合以下任意一条,即为复杂请求: - 使用 PUT、DELETE 、PATCH 方法。 - Content-Type: application/json。 - 包含自定义请求头(如 Authorization)。 复杂请求比较特殊,浏览器会先发送一个 OPTIONS 方法的**预检请求**(Preflight),检查服务器是否允许该跨域请求。如果不允许,则直接抛出 CORS 错误,不再发送实际请求。  因此,我们需要单独处理这个**预检请求**(Preflight): ```typescript // 假设 web 端位于 http://localhost:3000 // 假设 server 端位于 http://localhost:5050 // web fetch("http://localhost:5050/api/data", { method: "PUT", headers: { "Content-Type": "application/json", Authorization: "Bearer token", }, body: JSON.stringify({ data: "example" }), }); // server app.use((req: Request, res: Response, next: NextFunction) => { res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000"); if (req.method === "OPTIONS") { res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000"); res.header("Access-Control-Allow-Methods", "PUT"); res.header("Access-Control-Allow-Headers", "Content-Type, Authorization"); res.status(200).end(); return; } next(); }); ``` ### 设置 Access-Control-Max-Age 复杂请求在无缓存或缓存失效时会发送两次请求:Preflight(OPTIONS)和实际请求,这会增加网络开销。为此,服务器可以通过设置 Access-Control-Max-Age 响应头来控制浏览器缓存预检结果的时长。这个头字段的值表示缓存的有效期(以秒为单位)。在缓存有效期内,浏览器会复用之前的预检结果,跳过对相同接口的 Preflight 请求,从而提升性能。 ```typescript res.header('Access-Control-Max-Age', '86400'); // 缓存 1 天 ``` ## 支持跨域 Cookie 的配置 在跨域场景下,如果后端响应头返回 Set-Cookie,默认不会生效,因为设置 Cookie 需要额外配置以绕过浏览器的安全限制。核心是启用 Access-Control-Allow-Credentials 并明确指定 Access-Control-Allow-Origin。以下是一个 Express 示例: ```typescript app.use((req: Request, res: Response, next: NextFunction) => { res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000"); // 一定要指定具体地址,不能为 * res.setHeader("Access-Control-Allow-Credentials", "true"); //添加这个 if (req.method === "OPTIONS") { res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000");// 一定要指定具体地址,不能为 * res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); res.setHeader("Access-Control-Max-Age", "86400"); res.status(200).end(); return; } next(); }); app.post("/api/posts", (req: Request, res: Response) => { res.cookie("sessionId", "123456789", { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "none", maxAge: 24 * 60 * 60 * 1000, }); res.json({ success: true, message: "Cookie set" }); }); ``` 注意这里 Access-Control-Allow-Origin 一定要指定具体的地址,不能设置为 `Access-Control-Allow-Origin: *`,否则 Cookie 无效。 前端如果使用 fetch 调用,则一定要加上 ` credentials: "include"`否则无法设置 Cookie。如果是 axios 则加上 ` withCredentials: true`。 ```typescript //fetch fetch("http://localhost:5050/api/posts", { method: "POST", credentials: "include", // 允许携带和接收 Cookie }).then((res) => res.json()); //axios axios({ url: "http://localhost:5050/api/posts", method: "POST", withCredentials: true, // 允许携带和接收 Cookie }) .then((res) => res.data) .catch((err) => console.error(err)); ```
最近在写一个[自动匹配弹幕的动漫播放器](https://github.com/marchen-dev/MarchenPlay),里面需要使用 FFmpeg 对视频进行解析,但发现如何根据不同的平台打包不同 FFmpeg 到 Electron 里,是个挺麻烦的问题,这篇文章就来讲述下我的解决思路。 ## 安装 用户的电脑很有可能没有安装 FFmpeg,所以我们需要把 FFmpeg 打包进我们的应用里面。 想要在 Electron 开发环境里面导入 FFmpeg 还是比较简单的,只需要安装下面的包,然后就能够在 Electron 中使用了。 ```bash pnpm add -D @ffmpeg-installer/ffmpeg @ffprobe-installer/ffprobe fluent-ffmpeg ``` 创建 `ffmpeg.ts`,具体使用方式可以阅读 [fluent-ffmpeg](https://www.npmjs.com/package/fluent-ffmpeg)。 ```typescript import ffmpegPath from '@ffmpeg-installer/ffmpeg' // 安装 ffmpeg 的二进制文件 import ffprobePath from '@ffprobe-installer/ffprobe' // 安装 ffprobe 的二进制文件 import ffmpeg from 'fluent-ffmpeg' // 一个封装了 ffmpeg API 的库,当然可以选择不安装,直接使用字符串拼接的方式调用 ffmpeg.setFfmpegPath(ffmpegPath.path) ffmpeg.setFfprobePath(ffprobePath.path) export default class FFmpeg { ffmpeg: ffmpeg.FfmpegCommand constructor(inputPath: string) { this.ffmpeg = ffmpeg(inputPath) } } ``` 之后我们在开发环境里面就能正常使用 FFmpeg 了。 ## 打包 ### 路径问题 我的项目是使用 electron builder 进行打包的(具体的 `electron-builder.yml`配置可以在 [Electron 代码签名和公证](https://www.suemor.com/posts/programming/electron-code-signing-and-notarization) 中查看),打包之后你会发现项目是无法正确使用 FFmpeg,但在 dev 环境下到是正常的。 这是因为 ffmpeg 是二进制文件,会被打包进 `app.asar.unpacked` 而非 `app.asar` 从而导致 setFfmpegPath 路径出现问题,所以修改对应的 path 即可,这个问题在 [@ffmpeg-installer/ffmpeg](https://www.npmjs.com/package/@ffmpeg-installer/ffmpeg#wrong-path-under-electron-with-asar-enabled) 中也有提到。 ```typescript import ffmpegPath from '@ffmpeg-installer/ffmpeg' import ffprobePath from '@ffprobe-installer/ffprobe' import ffmpeg from 'fluent-ffmpeg' ffmpeg.setFfmpegPath(ffmpegPath.path.replace('app.asar', 'app.asar.unpacked')) // 修改 ffmpeg.setFfprobePath(ffprobePath.path.replace('app.asar', 'app.asar.unpacked')) // 修改 export default class FFmpeg { ffmpeg: ffmpeg.FfmpegCommand constructor(inputPath: string) { this.ffmpeg = ffmpeg(inputPath) } } ``` > 我使用的电脑是 MacBook Pro M1 Pro 即 macOS ARM64 打包完成之后,此时运行 ARM64 版本的 .app 是没有问题的,FFmpeg 也能正确运行。 ### FFmpeg 的架构版本问题 #### 启动报错 但可不要高兴的太早,我们换一台运行 macOS x64 的电脑,运行刚才用 macOS ARM64 电脑打包出来的 x64 版本的 .app 就会直接报错,然后显示一个完全摸不到头脑的错误。  #### 原因分析 我一开始看到这个错误也是完全懵逼的,使用 [debugtron](https://github.com/pd4d10/debugtron) 对主线程进行调试也完全没有输出。之后尝试对包进行分析,发现 x64 版本的 .app 打包的 FFmpeg 竟然是 ARM64 版本的,这不报错才怪呢。  这里我便对 `@ffmpeg-installer/ffmpeg`的实现感到了好奇,他是如何匹配不同的平台,从而安装对应其平台的 FFmpeg 二进制文件。通过阅读其源码: ```json { "name": "@ffmpeg-installer/ffmpeg", "optionalDependencies": { "@ffmpeg-installer/darwin-arm64": "4.1.5", "@ffmpeg-installer/darwin-x64": "4.1.0", "@ffmpeg-installer/linux-arm": "4.1.3", "@ffmpeg-installer/linux-arm64": "4.1.4", "@ffmpeg-installer/linux-ia32": "4.1.0", "@ffmpeg-installer/linux-x64": "4.1.0", "@ffmpeg-installer/win32-ia32": "4.1.0", "@ffmpeg-installer/win32-x64": "4.1.0" } } ``` ```json { "name": "@ffmpeg-installer/darwin-x64", "os": [ "darwin" ], "cpu": [ "x64" ], } ``` 发现`@ffmpeg-installer/ffmpeg` 封装了多个平台 FFmpeg 依赖,然后放入 `optionalDependencies` 中。每个平台的 FFmpeg 包再通过设置 `cpu + os` 字段,从而实现用户安装 `@ffmpeg-installer/ffmpeg` 即可匹配用户系统,来安装对应的 ffmpeg,这也让我涨知识了。 因此也难怪 x64 版本的 .app 打包的 FFmpeg 是 ARM64 版本的,因为我们在最开始 pnpm install 的时候,就只安装了对应操作系统的 FFmpeg,build 的时候也只能打包当前安装的 FFmpeg。 举个例子,我是 ARM64 macOS, pnpm install 的时候只会安装 ARM 版本 FFmpeg,打包 x64 的时候,当然也只能打包 ARM 版本 FFmpeg 了,从而导致的错误。  #### 整理思路 那么我们的思路就很明确了: * ARM64 macOS -> 打包 ARM64 应用 -> 使用 ARM64 FFmpeg * ARM64 macOS -> 打包 x64 应用 -> 使用 x64 FFmpeg 同理: * x64 macOS -> 打包 ARM64 应用 -> 使用 ARM64 FFmpeg * x64 macOS -> 打包 x64 应用 -> 使用 x64 FFmpeg 那么如何实现呢? > 这里思路完全是自己想的,或许有更好的方法,也请多多指教。 说一下我的思路,首先我们在 pnpm install 的时候只安装当前操作系统的 FFmpeg 是不变的,这样可以节约我们电脑的空间和安装依赖的速度。 之后只需要在执行 `pnpm build:mac`的时候,执行一个安装 mac 平台全部架构的 FFmpeg 依赖脚本就可以了。 #### 解决问题 编写 `scripts/install-darwin-deps.js` ```javascript /* eslint-disable no-console */ import { exec } from 'node:child_process' import os from 'node:os' const platform = os.platform() if (platform === 'darwin') { console.log('Detected macOS, installing darwin dependencies...') // 为了在 macos arm64 架构下进行打包 x64 架构的 APP, 所以需要同时安装 x64 arm64 架构的 ffmpeg 和 ffprobe exec( 'pnpm i @ffmpeg-installer/darwin-x64@^4.1.0 @ffprobe-installer/darwin-x64@^5.1.0 @ffmpeg-installer/darwin-arm64@^4.1.5 @ffprobe-installer/darwin-arm64@^5.0.1 -D', (err, stdout, stderr) => { if (err) { console.error(`Error installing optional dependencies: ${stderr}`) throw new Error('Error installing optional dependencies') } else { console.log(`Optional dependencies installed: ${stdout}`) } }, ) } else { console.log('Non-macOS platform detected, skipping optional darwin installation.') } ``` 之后在 package.json 里面加上 ` "build:mac": "node scripts/install-darwin-deps.js && electron-vite build && electron-builder --mac --publish never"` 即可。 执行打包命令之后,macOS x64 也是正确运行 macOS ARM64 打包出来的 x64 版本的 xxx.app ,不再会出现之前那个摸不着头脑报错了。 ### 不同平台只打包对应的 FFmpeg 这里新的问题有又出现了,我们发现当前 xxx.dmg 包体积大了很多,那是因为所有平台的 FFmpeg 都被打包进去了。例如,ARM64 版本 xxx.app 把 x64 和 ARM64 FFmpeg 都打包进去了。  这里我们得写一个脚本,在 electron builder 打包之后,把与目标平台不相符的 FFmpeg 给删除掉,编写 `scripts/cleaned-unused-arch-deps.js` ```javascript /* eslint-disable no-console */ import fs from 'node:fs' import path from 'node:path' export default async function cleanDeps(context) { const { packager, arch, appOutDir } = context const platform = packager.platform.nodeName if (platform !== 'darwin') { return } const archMap = { 1: 'x64', 3: 'arm64', } const currentArch = archMap[arch] if (!currentArch) { return } const unpackedPath = path.resolve( appOutDir, 'Marchen.app', 'Contents', 'Resources', 'app.asar.unpacked', 'node_modules', ) if (!fs.existsSync(unpackedPath)) { return } const ffmpegPath = path.resolve(unpackedPath, '@ffmpeg-installer') const ffprobePath = path.resolve(unpackedPath, '@ffprobe-installer') if (!fs.existsSync(ffmpegPath) || !fs.existsSync(ffprobePath)) { return } const removeUnusedArch = (basePath, unusedArch) => { const unusedPath = path.resolve(basePath, `darwin-${unusedArch}`) if (fs.existsSync(unusedPath)) { fs.rmSync(unusedPath, { recursive: true }) } } if (currentArch === 'x64') { removeUnusedArch(ffmpegPath, 'arm64') removeUnusedArch(ffprobePath, 'arm64') } else if (currentArch === 'arm64') { removeUnusedArch(ffmpegPath, 'x64') removeUnusedArch(ffprobePath, 'x64') } console.log('Cleaned unused arch dependencies.') } ``` 之后在 electron-builder.yml 里面使用 `afterPack: scripts/cleaned-unused-arch-deps.js` 导入脚本。 然后执行 `pnpm build:mac`就实现了不同平台只打包对应的 FFmpeg,并且运行都正常了。查看包内容,发现确实只包含了目标平台的 FFmpeg。 
> 本文的方式仅限用于 macOS 最近尝试入门了下 Electron 开发,写了一个可以[自动匹配弹幕的动漫播放器](https://github.com/marchen-dev/MarchenPlay),期间遇到挺多坑的,写篇文章来记录一下。 ## 代码签名和公证 想要在 macOS 上运行一个桌面端应用,那就必须对它代码进行签名,否则是无法打开,会出现如下错误。  ### Apple Developer 注册 想要进行代码签名就得花 688 元去注册苹果开发者,这里注册也比较玄学,注册过程中运气不好就容易出现 `联系我们以继续流程`的弹窗,这我在用 MacBook 注册时候出现过一次,然后换 iPhone 上注册就没有这个问题了。 注册到最后一步,付完费用之后,你会发现还是没办法使用,打开 Developer APP 账户页面里面,会显示一个灰色的现在注册按钮,然后显示**将很快收到相关邮件**,打开邮箱会发现两封名为`你的订阅确认`和 `Apple 提供的收据`的邮件,但这个其实并不是上文 Apple 所提到的`相关邮件`。这里不用慌张,这其实就是 Apple 正在审核的意思,我是晚上注册的,等隔天早上 9 点之后,就会收到一份 `欢迎加入 Apple Developer Program`的邮件,这才表明注册成功了。 ### 代码签名 关于具体如何生成和上传证书,网上相关教程有很多,这里就不展开说明了。之后就是把 Developer ID certificates 的私钥 .p12 文件,配置到终端环境变量中,填写 `CSC_LINK`和 `CSC_KEY_PASSWORD` 。 这里 Electron 打包我选择使用 [electron-builder](https://www.electron.build/index.html) ,执行 `electron-vite build && electron-builder --mac --publish never` 的时候,它会自动读取上方配置好的两个环境变量从而就行签名,无需进行额外配置。 下面是我的 `electron-builder.yml` ```yaml appId: com.suemor.Marchen productName: Marchen directories: buildResources: build files: - '!**/.vscode/*' - '!src/*' - '!electron.vite.config.{js,ts,mjs,cjs}' - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' asarUnpack: - resources/** win: executableName: Marchen nsis: artifactName: ${productName}-${version}-setup.${ext} shortcutName: ${productName} uninstallDisplayName: ${productName} createDesktopShortcut: always allowToChangeInstallationDirectory: true oneClick: false mac: entitlementsInherit: build/entitlements.mac.plist extendInfo: - NSCameraUsageDescription: Application requests access to the device's camera. - NSMicrophoneUsageDescription: Application requests access to the device's microphone. - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. notarize: false target: - target: dmg arch: - arm64 - x64 - target: zip arch: - arm64 - x64 dmg: artifactName: ${productName}-${version}-${arch}.${ext} linux: target: - target: AppImage arch: - arm64 - x64 maintainer: github.com/suemor233 category: Utility appImage: artifactName: ${productName}-${version}-${arch}.${ext} npmRebuild: false publish: provider: github owner: marchen-dev repo: MarchenPlay afterSign: scripts/notarize.js releaseInfo: releaseNotes: | 本次更新: 可以切换动漫内嵌字幕和手动导入字幕 新增视频播放器设置功能 可以对历史记录动漫进行删除和从新识别弹幕库 ``` ### 公证 代码签完名之后,我们需要把打包后的程序上传给苹果,来确保我们程序的安全性和没有被其他人篡改。 这里我们安装 `@electron/notarize` 和 `dotenv`这两个包, 然后创建一个 `scripts/notarize.js`文件,写法如下,他可以读取我们 .env 里面的相关变量,然后进行公证。 ```javascript import { notarize } from '@electron/notarize' import { config } from 'dotenv' config() export default async function notarizing(context) { if (context.electronPlatformName !== 'darwin') { return } const appBundleId = process.env.APPLE_APP_BUNDLE_ID // 随便填一个,类似 com.suemor.Marchen const appleId = process.env.APPLE_ID // 你的 Apple ID const appleIdPassword = process.env.APPLE_APP_SPECIFIC_PASSWORD // https://account.apple.com/account/manage 然后点击 App 专用密码 const teamId = process.env.APPLE_TEAM_ID // https://developer.apple.com/account 里面 会员资格详细信息 卡片里面,有个 团队 ID if (!appBundleId || !appleId || !appleIdPassword || !teamId) { return } const appName = context.packager.appInfo.productFilename const appPath = `${context.appOutDir}/${appName}.app` // eslint-disable-next-line no-console console.log('Notarizing app:', appPath) await notarize({ appPath, appBundleId, appleId, appleIdPassword, teamId, }) } ``` 之后在 `electron-builder.yml` 里面导入这个路径 `afterSign: scripts/notarize.js`。 然后执行` "build:mac": "electron-vite build && electron-builder --mac --publish never",`就可以完成公证了。 这里公证速度一般都挺慢的,因为要把你的应用上传到苹果服务器上去,取决于你宽带的上传速度,得耐心等待。另外我看别人说公证成功之后收到 Apple Developer 的相关邮件,不清楚什么原因,我从来没有收到过。 那么如何校验我们这个应用是否公证成功了呢?安装完 App 之后,我们只要去终端执行下方的命令,显示`The validate action worked!`就代表成功了。 ```bash stapler validate /Applications/xxxx.app ``` ### 配置 GitHub Actions 为了方便发布版本,我们可能会利用 Github Actions 进行自动化打包,为了 Github Actions 能够在打包过程中进行签名和公证,如下图所示,我们需要把上文所配置的环境变量放到 Github Repository secrets 里面。 ```bash CSC_LINK // 填写 base64 CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID APPLE_APP_BUNDLE_ID ```  这里有个麻烦的地方,就是 CSC_LINK 这个字段的填写。之前我们在配置终端环境变量的时候,是直接使用绝对路径的方式来链接到 .p12 文件的,但这里的 Repository secrets 是不支持上传文件的,所以得把之前 Developer ID certificates 的私钥 .p12 文件转换为 base64 然后复制到 Repository secrets 的 CSC_LINK 的变量里面去。 ```bash base64 -i xxxx.p12 | pbcopy ``` 下方是我完整的 `release.yml` ```yaml name: Build/release Electron app on: push: tags: - v*.*.* jobs: release: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] node-version: [20.x] steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 lfs: true - name: Checkout LFS objects run: git lfs checkout - name: Setup pnpm uses: pnpm/action-setup@v4.0.0 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' - name: Install Dependencies run: pnpm install - name: Install snapcraft if: matrix.os == 'ubuntu-latest' run: sudo snap install snapcraft --classic - name: Build for Linux if: matrix.os == 'ubuntu-latest' run: pnpm run build:linux - name: Build for macOS if: matrix.os == 'macos-latest' run: pnpm run build:mac env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_APP_BUNDLE_ID: ${{ secrets.APPLE_APP_BUNDLE_ID }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} GH_TOKEN: ${{ secrets.GH_TOKEN }} - name: Build for Windows if: matrix.os == 'windows-latest' run: pnpm run build:win - name: Generate Changelog if: github.event_name == 'push' run: npx @suemor/changelogithub env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Create Release uses: softprops/action-gh-release@v2 with: draft: false prerelease: true files: | dist/*.exe dist/*.zip dist/*.dmg dist/*.AppImage dist/*.snap dist/*.deb dist/*.rpm dist/*.tar.gz dist/latest*.yml env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` ## 下期预告 下篇文章准备来讲一下如何打包 FFmpeg 到 Electron 里面,这里面的坑还是挺多的,特别是利用 Github Actions 打包的时候,得实现根据不同的平台打包不同 FFmpeg 才行,比如用 arm64 mac 打包 x64 的应用,如何正确打包 x64 版本的 FFmpeg。
主要讲下 NestJS 中 IOC 和 五种 AOP 的相关知识点,即 `Middleware, Guard, Pipe, Interceptor, ExceptionFilter` 的使用和概念 ## Provider 可以先创建下项目 ```bash nest new xxx ``` providers 是可以注入的对象,我们可以把带有 `@Injectable() `的 class 放到 Module 的 providers 里声明,因为 Nest 实现了 IOC,这样就会被它给识别到,从而实现依赖注入。 ```typescript import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @Module({ imports: [], controllers: [AppController], providers: [AppService], }) export class AppModule {} ``` 当然这是一种简写,原本是这样: ```typescript import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @Module({ imports: [], controllers: [AppController], providers: [ { provide: AppService, useClass: AppService, }, ], }) export class AppModule {} ``` 这样就实现了依赖注入,我们就可以通过 `@Inject()` 在其他地方使用了,比如在 Controller 里使用 ```typescript import { Controller, Get, Inject } from '@nestjs/common'; import { AppService } from './app.service'; @Controller() export class AppController { constructor(@Inject(AppService) private readonly appService: AppService) {} //或者直接写,不用构造器 // @Inject(AppService) private readonly appService: AppService @Get() getHello(): string { return this.appService.getHello(); } } ``` 但因为我们的 provide 和 useClass 是相同的,所以可以省略,所以可以简写成如下: ```typescript import { Controller, Get } from '@nestjs/common'; import { AppService } from './app.service'; @Controller() export class AppController { constructor(private readonly appService: AppService) {} @Get() getHello(): string { return this.appService.getHello(); } } ``` 我们可以实验一下,把 provide 的值改为一个字符串 ```typescript import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @Module({ imports: [], controllers: [AppController], providers: [ { provide: 'suemor', useClass: AppService, }, ], }) export class AppModule {} ``` 这样就没办法省略了,就得老老实实写 `@Inject()` ```typescript import { Controller, Get, Inject } from '@nestjs/common'; import { AppService } from './app.service'; @Controller() export class AppController { constructor(@Inject('suemor') private readonly appService: AppService) {} @Get() getHello(): string { return this.appService.getHello(); } } ``` 除了在 providers 里指定 class,我们也可以指定值或动态的对象,分别对应 `useValue` 和 `useFactory`,如下为 `useFactory`写法 ```typescript import { Inject, Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @Module({ imports: [], controllers: [AppController], providers: [ { provide: 'suemor', useClass: AppService, }, { provide: 'suemor2', useFactory(appService: AppService) { return { age: 19, gender: 'male', say: appService.getHello(), }; }, inject: ['suemor'], }, ], }) export class AppModule {} ``` 修改 controller ```typescript import { Controller, Get, Inject } from '@nestjs/common'; import { AppService } from './app.service'; @Controller() export class AppController { constructor( @Inject('suemor') private readonly appService: AppService, @Inject('suemor2') private readonly user: { age: number; gender: string; say: string }, ) {} @Get() getHello() { return this.user; } } ```  ## 五种 AOP 因为 NestJS 使用的 MVC 架构,所以有 AOP 的能力,其中 Nest 的实现主要包括如下五种(**按执行顺序排列**) * Middleware * Guard * Interceptor * Pipe * ExceptionFilte ### Middleware 即中间件,但这并不是 Nest 独有的,你用 Express 或者 Fastify 当请求的库他们本身也都拥有 middleware,概念基本相同,我们一般会用它处理些通用逻辑(如日志)。 ```bash nest g middleware logger --no-spec --flat ``` 应该会生成如下代码,这里的 req 和 res 都是 any 是因为 Nest 并不知道你用的是 Express 还是 Fastify 当请求库,所以类型得自行补全。 ```typescript import { Injectable, NestMiddleware } from '@nestjs/common'; @Injectable() export class LoggerMiddleware implements NestMiddleware { use(req: any, res: any, next: () => void) { next(); } } ``` 修改类型,并添加下 console.log,然后我们得去注册这个 middleware ```typescript import { Injectable, NestMiddleware } from '@nestjs/common'; import { Request, Response } from 'express'; @Injectable() export class LoggerMiddleware implements NestMiddleware { use(req: Request, res: Response, next: () => void) { console.log('start'); next(); } } ``` 去 `app.module.ts`里全局注册 ```typescript import { MiddlewareConsumer, Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { NestModule } from '@nestjs/common'; import { LoggerMiddleware } from './logger.middleware'; @Module({ imports: [], controllers: [AppController], providers: [ { provide: 'suemor', useClass: AppService, }, { provide: 'suemor2', useFactory(appService: AppService) { return { age: 19, gender: 'male', say: appService.getHello(), }; }, inject: ['suemor'], }, ], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer.apply(LoggerMiddleware).forRoutes('*'); } } ``` 然后去浏览器访问,我们可以看到控制台正确输出了  ### Guard 即路由守卫的意思,一般在 Controller 之前鉴权,返回 true 和 false ```bash nest g guard roles --no-spec --flat ``` ```typescript import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Observable } from 'rxjs'; @Injectable() export class RolesGuard implements CanActivate { canActivate( context: ExecutionContext, ): boolean | Promise<boolean> | Observable<boolean> { return true; } } ``` 修改 Controller,UseGuards 和 SetMetadata。 ```typescript import { Controller, Get, Inject, SetMetadata, UseGuards, } from '@nestjs/common'; import { AppService } from './app.service'; import { RolesGuard } from './roles.guard'; @Controller() export class AppController { constructor( @Inject('suemor') private readonly appService: AppService, @Inject('suemor2') private readonly user: { age: number; gender: string; say: string }, ) {} @Get() @UseGuards(RolesGuard) @SetMetadata('roles', ['admin']) getHello() { return this.appService.getHello(); } } ``` 修改 RolesGuard,这里我们会引 Reflector 的 Metadata 概念,这目前还没有被 ES 标准化,还处于草案阶段,Nest 应该是使用 reflect-metadata 这个 polyfill 包,这可以说是 Nest 的核心了,它的 IOC 基本都是靠这个实现的,这里大意就是从 ExecutationContext 取到 handle,然后注入 reflector,通过 reflector.get 取出 handler 上的 metadata,这样就可以获取到当前路由的权限了。 ```typescript import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Observable } from 'rxjs'; @Injectable() export class RolesGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate( context: ExecutionContext, ): boolean | Promise<boolean> | Observable<boolean> { console.log(this.reflector.get<string[]>('roles', context.getHandler())); // [ 'admin' ] return true; } } ``` 我们现在可以获得当前路由的权限了,那接下来就要用这个与请求的权限进行比较了,关于解析请求的权限要用到 `@nestjs/passport`库,就是通过 token 获取到当前的 user,但这不是本文重点,我们假设已经在 req 赋上 user 字段了,就可以通过 `context.switchToHttp().getRequest().user` 获取到当前的请求的 user 了,然后进行 if 判断下就可以了。 ```typescript import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Observable } from 'rxjs'; @Injectable() export class RolesGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate( context: ExecutionContext, ): boolean | Promise<boolean> | Observable<boolean> { const routerRole = this.reflector.get<string[]>( 'roles', context.getHandler(), ); const ReqUser = context.switchToHttp().getRequest().user; console.log(routerRole, ReqUser); if (routerRole[0] == ReqUser.role) { return true; } return false; } } ```  ### Interceptor 即拦截器,它可以在 Controller 方法前后加入一些逻辑,其实和 Middleware 挺像的,也可以用来记录日志等,不过它也可以在 Controller 之后进行拦截,比如统一返回结果之类的。 ```bash nest g interceptor logging --flat --no-spec ``` 它还有些优势就是可以使用 rxjs 的各种 operator,且也可以使用 reflector 和 ExecutionContext。 ```typescript import { CallHandler, ExecutionContext, Injectable, NestInterceptor, } from '@nestjs/common'; import { Observable, tap } from 'rxjs'; @Injectable() export class LoggingInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { console.log('before'); return next.handle().pipe(tap(() => console.log('after'))); } } ``` 然后去 app 注册 ```typescript import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { LoggingInterceptor } from './logging.interceptor'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalInterceptors(new LoggingInterceptor()); await app.listen(3000); } bootstrap(); ``` ### Pipe 即管道,可以对参数进行校验,比如我们说 age 不能是字符串,就可以这样写。 ```typescript import { Controller, Get, Inject, ParseIntPipe, Query } from '@nestjs/common'; import { AppService } from './app.service'; @Controller() export class AppController { constructor(@Inject('suemor') private readonly appService: AppService) {} @Get() getHello2(@Query('age', ParseIntPipe) age: number) { return age; } } ``` 但实际业务我们一般会用 `class-validator` 这个库,它就是利用的 Pipe。 ### ExceptionFilter 即异常过滤器,它是很强大的,除了 middleware 以外,只要出现异常,都可以被它给捕获到。 ```typescript import { ArgumentsHost, ExceptionFilter } from '@nestjs/common'; import { Catch, HttpException } from '@nestjs/common'; @Catch(HttpException) export class HttpExceptionFilter implements ExceptionFilter { catch(exception: HttpException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); const status = exception.getStatus(); response.status(status).json({ statusCode: status, timestamp: new Date().toISOString(), path: request.url, }); } } ``` 去 app 注册 ```typescript import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { HttpExceptionFilter } from './any-exception.filter'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalFilters(new HttpExceptionFilter()); await app.listen(3000); } bootstrap(); ``` 我们 throw 个异常 ```typescript import { BadRequestException, Controller, Get, Inject } from '@nestjs/common'; import { AppService } from './app.service'; @Controller() export class AppController { constructor(@Inject('suemor') private readonly appService: AppService) {} @Get() getHello2() { throw new BadRequestException(); return this.appService.getHello(); } } ``` 被正确捕获 
我们向后端发起 http 请求时,会要求指定数据传输的方式,通常情况下包含如下 5 种 * query * url param * form-urlencoded * form-data * json 下面我会讲述之间的区别,并同时列举 axios 和 fetch 的写法。 ## url param 这个是最简单的,一般用在 GET 请求中,就是直接在 url 后面加上参数,例如: ```bash http://localhost:3000/post/7231564187255881765 ``` 这种一般用于请求某篇文章之类的。 ### axios ```javascript axios.get("http://localhost:3000/post/7231564187255881765") ``` ### fetch ```javascript fetch("http://localhost:3000/post/7231564187255881765") ``` ## query 这种也一般用在 GET 请求中,就是利用 `&`来分割数据,例如: ```bash http://localhost:3000/posts?page=1&size=10 ``` 一般 GET 请求需要传递多个参数都会用这种,比如上方例子中的分页请求。 这种请求如果涉及到中文或特殊字符需要进行编码才行,一般可以用 JavaScript `encodeURLComponent`API 进行处理, 或者 `query-string`或 `qs` 这两个 npm 库。 不过现在浏览器都比较智能了,已经会自动帮你进行特殊编码了,所以就算你不进行处理也没关系。 ### axios axios 已经帮你做好编码处理了,你可以直接写。 ```javascript axios.get("http://localhost:3000/user", { params: { name: "史蒂夫", }, }); ``` ### fetch fetch 已经帮你做好编码处理了,但为了兼容性和可靠性,也可以显式地使用 `encodeURIComponent` 对 URL 的各个部分进行编码。 ```javascript fetch(`http://localhost:3000/person/?name=${encodeURIComponent("史蒂夫")}`); // 或者 fetch(`http://localhost:3000/person/?name=史蒂夫`); ``` ## form-urlencoded 这种也一般用在 POST 请求中,form 直接提交数据就是这种,它其实和 query 差不多,都是利用 `&`来拼接数据,也都要进行编码,只不过 `form-urlencoded` 把这个字符串放在 body 里了,然后指定 Content-Type 是 `application/x-www-form-urlencoded`,不过这种方式用的相对较少,大多数后端都会要求传 JSON。  ### axios axios 会自动帮你把 Content-Type 指定为 `application/x-www-form-urlencoded`,所以直接在 body 里放入数据即可 ```javascript axios.post(`http://localhost:3000/user`, "id=123&name=suemor"); ``` 但这么写不太优雅,一般会配合 qs 这个库来使用 ```bash pnpm i qs ``` ```javascript import qs from "qs"; axios.post( `http://localhost:3000/user`, qs.stringify({ id: 123, name: "suemor" }) ); ``` ### fetch 注意 fetch 并没有帮你指定 Content-Type,需要手动指定为 `application/x-www-form-urlencoded` ```javascript import qs from "qs"; fetch(`http://localhost:3000/user`, { method: "POST", body: "id=123&name=suemor", headers: { "Content-Type": "application/x-www-form-urlencoded", }, }); // 或者 fetch(`http://localhost:3000/user`, { method: "POST", body: qs.stringify({ id: 123, name: "suemor" }), headers: { "Content-Type": "application/x-www-form-urlencoded", }, }); ``` ## json 这种也一般用在 POST 请求中,是我们 POST 请求最常见的一种传输方式,直接传递 JSON,且无需编码,需要指定 Content-Type 为 `application/json`。  ### axios axios 会自动帮你把 Content-Type 指定为 `application/json`,且自动包了一层 `JSON.stringify`,你只要在第二参数中传入 JavaScript 对象即可。 ```javascript axios.post(`http://localhost:3000/user`, { id: 123, name: "suemor" }); // 等价于 axios.post(`http://localhost:3000/user`, `{"id":123,"name":"suemor"}`, { headers: { "Content-Type": "application/json", }, }); ``` ### fetch fetch 就没 axios 那么智能了,需要手动指定`Content-Type` 和 `JSON.stringify` ```javascript fetch(`http://localhost:3000/user`, { method: "POST", body: JSON.stringify({ id: 123, name: "suemor" }), headers: { "Content-Type": "application/json", }, }); ``` ## form-data 这种也用在 POST 请求中,一般用于文件上传,`--------- + 随机数`做为分隔符,无需编码,需要指定 Content-Type 为 `multipart/form-data`。  ### axios axios 会自动添加 Content-Type 为 `multipart/form-data` ```typescript function App() { const upLoadHandler = (event: ChangeEvent<HTMLInputElement>) => { const formData = new FormData(); const fileList = event.target.files; if (!fileList) return; formData.set("File", fileList[0]); formData.set("id", "123"); axios.post(`http://localhost:3000/file`, formData); }; return <input type="file" name="file" onChange={upLoadHandler} />; } ``` ### fetch fetch 会自动添加 Content-Type 为 `multipart/form-data`,写法与 axios 类似 ```typescript function App() { const upLoadHandler = (event: ChangeEvent<HTMLInputElement>) => { const formData = new FormData(); const fileList = event.target.files; if (!fileList) return; formData.set("File", fileList[0]); formData.set("id", "123"); fetch(`http://localhost:3000/file`, { method: "POST", body: formData, }); }; return <input type="file" name="file" onChange={upLoadHandler} />; } ```
NextJS 最近发布了 13.4 版本,使 App Router “稳定”下来,同时官方 CLI 也将 App Router 变更为默认且推荐的方案,但是 App Router 中引入了 React Server Components (以下简称 rsc )的概念,且变更了相当多的 API,这使其学习难度更加陡峭。 ## 服务端组件 在 App Router 中,NextJS 将会区分 Client Components和 Server Components, Server Components 是一种特殊的 React 组件,它不是在浏览器端运行,而是只能在服务器端运行。又因为它们没有状态,所以不能使用只存在于客户端的特性(也就是说 useState、useEffect 那些都是用不了的),所以一般我们可以用于获取数据,或者对组件进行渲染(比如你要渲染 markdown 那对应的 JavaScript 依赖就只存在于服务端),从而达到减少客户端体积的作用。 同时 App Router 中的文件默认都是服务端组件,如果你要使用客户端组件那就需要加上 `use client`,但实际上这个命令时候影响到子组件的,也就是说如果你父组件加上了 `use client`,那么这个文件下所有的子组件就算不加上这个指令,那它也是客户端组件了,为此我们需要合理规划组件。 如下的 `<MyComponent />` 实际上是客户端组件。 ```typescript "use client"; import { useState } from "react"; import MyComponent from "./MyComponent"; export default function Home() { const [num, setNum] = useState(0); return ( <main> <h1>{num}</h1> <button onClick={() => setNum(num + 1)}>+1</button> <MyComponent /> </main> ); } ``` ```typescript import { useEffect } from "react"; const MyComponent = () => { useEffect(() => { console.log("client component"); }, []); return <div>123</div>; }; export default MyComponent; ``` 另外目前很大一部分第三方库都没有支持 `use client`,为此会出现如下错误。  为了能够正常在 rsc 中使用我们需要进行一些特殊处理,下面以 framer-motion 为例,因为它一般都会包裹在组件的最外层。 ```typescript "use client" import { FC, PropsWithChildren } from "react"; import { motion } from "framer-motion"; const MotionLayout: FC<PropsWithChildren> = ({ children }) => { return ( <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.5 }} > {children} </motion.div> ); }; export default MotionLayout; ``` 或者可以封装一下 ```typescript "use client" import { motion } from "framer-motion"; export const MotionDiv = motion.div; ``` ## 数据获取 fetch 数据是 rsc 中重要的一环,我们可以如下编写 ```typescript export default async function Home() { const data = await fetch("https://api.github.com/repos/vercel/next.js").then( (res) => res.json() ); return <div>{data.id}</div>; } ``` 但注意这么写默认是会缓存数据的,因为NextJS 对原生 fetch 进行了些修改,如果要想变为动态的话,我们得添加下 `cache` 的配置 ```typescript export default async function Home() { const data = await fetch("https://api.github.com/repos/vercel/next.js", { cache: "no-store", }).then((res) => res.json()); return <div>{data.id}</div>; ``` 也可以变为 每 10 秒重新获取一次 ```typescript // 每 10 秒重新获取一次 export default async function Home() { const data = await fetch("https://api.github.com/repos/vercel/next.js", { next: { revalidate: 10, }, }).then((res) => res.json()); return <div>{data.id}</div>; } ``` ## 路由 App Router 的路由在原先的基础上进行了增强,我们可以通过 `(folderName)` 对路由进行分组,在括号中的组名并不会被映射到实际的路由上,在提高代码可读性的同时,也可以共享 Layout。  动态路由方面和之前差不多通过 `[folderName]` 来定义,不过现在可以直接在 props 中获取对于的值  我们也可以创建 `loading.tsx` ,其就是包了一层 Suspense,当我们在 `page.tsx` fetch 数据的过程中,可以显示 `loading.tsx` 中的内容。 同时也有 `error.tsx`,当页面渲染出现错误时,也可以及时兜底,避免那串「白屏黑字」。  还有一种 `Parallel Routes` 我们可以把`@folderName`给映射到到 layout 的 props 里,当成「插槽」来使用,一般可以适用于网页顶部的 header,和底部 footer 之类的。  ```typescript export default function Layout(props: { children: React.ReactNode; analytics: React.ReactNode; team: React.ReactNode; }) { return ( <> {props.children} {props.team} {props.analytics} </> ); } ``` ## 最后 总之 App Router 中的 API 相当的多,目前列举的也只是一小部分而已,具体的还是以官方文档为主吧。 [官方文档](https://nextjs.org/docs)
> 我也是最近刚接触到了这些知识,文章可能有些错误,希望大佬多多指点( 对于学习 TypeScript 了解类型的逆变、协变、双向协变和不变是很重要的,但你只要明白类型的父子级关系,这些概念理解起来就会容易许多,因此在讲述这些之前我们必须先学会类型的父子级关系。 ## 类型的父子级 首先明确一个概念,对于 TypeScript 而言,只要类型结构上是一致的,那么就可以确定父子关系,这点与 Java 是不一样的(Java 必须通过 extends 才算继承)。 我们可以看下面的例子: ```typescript interface Person { name: string; age: number; } interface Suemor { name: string; age: number; hobbies: string[] } ``` 你应该可以发现这两个类型是有继承关系,此时你可以去思考到底谁是父级、谁是子级? 你可能会觉得 Suemor 是 Person 的父类型(毕竟 Person 有 2 个属性,而 Suemor 有 3 个属性且包含 Person),如果是这么理解的话那就错。 在类型系统中,属性更多的类型是子类型,也就是说 Suemor 是 Person 的**子类型**。 因为这是反直觉的,你可能很难理解(我当时也理解不了),你可以尝试这样去理解:**因为 A extends B , 于是 A 就可以去扩展 B 的属性,那么 A 的属性往往会比 B 更多,因此 A 就是子类型**。或者你记住一个特征,**子类型比父类型更加具体**。 另外判断联合类型父子关系的时候, 'a' | 'b' 和 'a' | 'b' | 'c' 哪个更具体? 'a' | 'b' 更具体,所以 'a' | 'b' 是 'a' | 'b' | 'c' 的子类型。 ## 协变 ### 对象中运用 协变理解起来很简单,你可能在平日里开发经常用到,例如: ```typescript interface Person { name: string; age: number; } interface Suemor { name: string; age: number; hobbies: string[] } let person: Person = { // 父级 name: '', age: 20 }; let suemor: Suemor = { // 子级 name: 'suemor', age: 20, hobbies: ['play game', 'codeing'] }; //正确 person = suemor; //报错,如果你的编辑器没有报错,请打开严格模式,至于为什么后面双向协变会讲 suemor = person; ``` 这俩类型不一样,但是 suemor 却可以赋值给 person,也就是子级可以赋值给父级,反之不行(至于为什么,你可以想想假如 person 能够正确赋值给 suemor,那么调用 `suemor.hobbies`你的程序就坏到了)。 因此得出结论: **子类型可以赋值给父类型的情况就叫做协变。** ### 函数中运用 同样的函数中也可以用到协变,例如: ```typescript interface Person { name: string; age: number; } function fn(person: Person) {} // 父级 const suemor = { // 子级 name: "suemor", age: 19, hobbies: ["play game", "codeing"], }; fn(suemor); fn({ name: "suemor", age: 19, // 报错 // 这里补充个知识点(因为当时我学的时候脑抽了),这里的 hobbies 会报错,是因为它是直接赋值,并没有类型推导。 hobbies: ["play game", "codeing"] }) ``` 这里我们多给一个 hobbies,同理因为协变,子类型可以赋值给父类型。 因此我们平日的`redux`,在声明 `dispatch` 类型的时候,可以这样去写: ```typescript interface Action { type: string; } function dispatch<T extends Action>(action: T) { } dispatch({ type: "suemor", text:'测试' }); ``` 这样约束了传入的参数一定是 `Action` 的子类型,也就是说必须有 `type`,其他的属性有没有都可以。 ### 双向协变 我们再看一下上上节的例子: ```typescript interface Person { name: string; age: number; } interface Suemor { name: string; age: number; hobbies: string[] } let person: Person = { // 父级 name: '', age: 20 }; let suemor: Suemor = { // 子级 name: 'suemor', age: 20, hobbies: ['play game', 'codeing'] }; //正确 person = suemor; //报错 -> 设置双向协变可以避免报错 suemor = person; ``` `suemor = person`的报错我们可以在 `tsconfig.json`设置 `strictFunctionTypes:false`或者关闭严格模式,此时我们父类型可以赋值给子类型,子类型可以赋值给父类型,这种情况我们便称为**双向协变**。 因此双向协变就是: **父类型可以赋值给子类型,子类型可以赋值给父类型**。 但是这明显是有问题的,不能保证类型安全,因此我们一般都会打开严格模式,避免出现双向协变。 ## 不变 不变是最简单的。如果没有继承关系(A 和 B 没有一方包含对方全部属性)那它就是不变,因此非父子类型之间只要类型不一样就会报错: ```typescript interface Person { name: string; age: number; } interface Suemor { name: string; sex:boolean } let person: Person = { name: "", age: 20, }; let suemor: Suemor = { name: 'suemor', sex:true }; // 报错 person = suemor; ``` ## 逆变 逆变相对难理解一点,看下方例子: ```typescript let fn1: (a: string, b: number) => void = (a, b) => { console.log(a); }; let fn2: (a: string, b: number, c: boolean) => void = (a, b, c) => { console.log(c); }; fn1 = fn2; // 报错 fn2 = fn1; // 这样可以 ``` 你会发现:fn1 的参数是 fn2 的参数的父类型,那为啥能赋值给子类型? 这就是逆变,父类型可以赋值给子类型,函数的参数有逆变的性质(而返回值是协变的,也就是子类型可以赋值给父类型)。 至于为什么,如果`fn1 = fn2`是正确的话,我们只能传入`fn1('suemor',123)`,但 `fn1`调却要输出 `c`,那就坏掉了。 因此我感觉逆变一般会出现在: 父函数参数与子函数参数之间赋值的时候(注意是函数与函数之间,而不是调用函数的时候,我是这么理解的,不知道对不对)。 因为逆变相对在类型做运算时用的会多一点,因此我们再看一个稍微难一点例子: ```typescript // 提取返回值类型 type GetReturnType<Func extends Function> = Func extends ( ...args: unknown[] ) => infer ReturnType ? ReturnType : never; type ReturnTypeResullt = GetReturnType<(name: string) => "suemor">; ```  这里`GetReturnType`使用来提取返回值类型,这里`ReturnTypeResullt`原本应当是`suemor`,但如上代码却得出结果为`never`。 因为函数参数遵循逆变,也就是只能父类型赋值给子类型,但很明显这里的 `unknown` 是 `{name: string}` 的父类型,所以反了,应该把`unknown`改为`string`的子类型才行,所以应该把 unknown 改为`any或者never`,如下为正确答案: ```typescript type GetReturnType<Func extends Function> = Func extends ( ...args: any[] ) => infer ReturnType ? ReturnType : never; type ReturnTypeResullt = GetReturnType<(name: string) => "suemor">; ``` 
最近尝试学习了下 TypeScript 的高级应用,正好接触到了模式匹配,写一篇文章来记录下所学内容。 ## 介绍 在 TypeScript 中 extends、infer 关键字是相当重要的,extends 除了可以约束类型以外,还可以进行模式匹配,然后通过 infer 声明变量来进行值的存储,例如下方定义的 Promise 类型: ```typescript type Result = Promise<'suemor'>; ``` 我们要实现一个类型,把 `suemor`提取出来,使 Result 的值为 `suemor`,我们可以这样写: ```typescript type GetResult<T> = T extends Promise<infer Value> ? Value : never; ```  ## 函数 我们在函数中同样可以进行类型匹配,提取它的参数,返回值类型。例如下方提取函数的参数类型:  ```typescript type GetResult<T extends Function> = T extends (...args:infer Args) => unknown ? Args : never type Result = GetResult<(name: string, age: number) => string>; ``` 通过 infer 将参数类型保存到变量 Args 里面。 ## 数组类型 数组也同样适用类型匹配,例如我们想提取它第一个元素的类型:  ```typescript type Result = GetResult<[1, 2, 3]>; type GetResult<T extends unknown[]> = T extends [infer First, ...unknown[]] ? First : never; ``` 利用 infer 获取第一个元素的类型,剩余的元素类型用存放在 `...unknown[]`中。 ## 字符串类型 字符串类型也可以进行模式匹配,把匹配的字符串,存放在 infer 的局部变量中,如下实现字符串替换:  ```typescript type Result = GetResult<"suemor and mike", "suemor", "jack">; type GetResult< Str extends string, From extends string, To extends string > = Str extends `${infer Prefix}${From}${infer Suffix}` ? `${Prefix}${To}${Suffix}` : Str; ``` 把要替换的值(From)前后的字符串都存放在 Prefix 和 Suffix 两个变量中,再把 From 换成目标值(To)即可。 还有个例子,判断字符串是否以某个前缀开头:  ```typescript type Result = GetResult<"suemor and mike", "suemor">; type GetResult< Str extends string, Prefix extends string > = Str extends `${Prefix}${string}` ? true : false; ``` 把 Prefix 放在字符串开头进行匹配即可。
> 完整代码: https://codesandbox.io/s/awesome-margulis-q2xibv?file=/src/index.ts ## 潜在的问题 在我们平常写代码的时候,可能需要对一个相同的数据进行各种转换,以便满足业务的需求,例如下方社交账号的例子: ```typescript const social = { bilibili: '291833916', netease: '345345345', weibo: '436453345', } if (social[type]) { // do something } ``` 这样可能可以满足当时的需求,但如果哪天业务变更,要求加入社交账号的 icon,你可能会改成这样: ```typescript export const social = [ { name: 'bilibili', account: '291833916', icon: 'bilibili.svg', }, { name: 'netease', account: '345345345', icon: 'netease.svg', }, { name: 'weibo', account: '436453345', icon: 'weibo.svg', }, // ... ]; ``` 但这样改的话,你之前的代码可能会报错。当然你也可能会重新创建一个对象来避免错误,但这样代码就越来越臃肿了。 又如果你以后需要用到所有社交账号的名称或者账号(下方的格式),你可能又需要通过 map 来获取到。 ```typescript ['bilibili', 'netease', 'weibo'] ['291833916', '345345345', '436453345'] ``` 这样的代码又臃肿又混乱,而且还容易出错。因此我们完全可以封装一个工具函数,将一份定义转换成多种格式,从而实现如下效果。 ```typescript { SOCIAL_TYPE_KEYS: [ 'bilibili', 'netease', 'weibo' ], SOCIAL_TYPE_VALUES: [ '291833916', '345345345', '436453345' ], SOCIAL_TYPE_KV: { bilibili: '291833916', netease: '345345345', weibo: '436453345' }, SOCIAL_TYPE_VK: { '291833916': 'bilibili', '345345345': 'netease', '436453345': 'weibo' }, SOCIAL_TYPE_MAP_BY_KEY: { bilibili: { key: 'bilibili', value: '291833916', icon: 'bilibili.svg' }, netease: { key: 'netease', value: '345345345', icon: 'netease.svg' }, weibo: { key: 'weibo', value: '436453345', icon: 'weibo.svg' } }, SOCIAL_TYPE_MAP_BY_VALUE: { '291833916': { key: 'bilibili', value: '291833916', icon: 'bilibili.svg' }, '345345345': { key: 'netease', value: '345345345', icon: 'netease.svg' }, '436453345': { key: 'weibo', value: '436453345', icon: 'weibo.svg' } }, SOCIAL_TYPE_KEY_MAP: { bilibili: { key: 'bilibili', value: '291833916', icon: 'bilibili.svg' }, netease: { key: 'netease', value: '345345345', icon: 'netease.svg' }, weibo: { key: 'weibo', value: '436453345', icon: 'weibo.svg' } }, SOCIAL_TYPE_MAP: { bilibili: '291833916', netease: '345345345', weibo: '436453345' }, SOCIAL_TYPE_LIST: [ { key: 'bilibili', value: '291833916', icon: 'bilibili.svg' }, { key: 'netease', value: '345345345', icon: 'netease.svg' }, { key: 'weibo', value: '436453345', icon: 'weibo.svg' } ] } ``` ## 函数编写 我们可以编写如下函数: ```typescript export const social = [ { key: 'bilibili', value: '291833916', icon: 'bilibili.svg', }, { key: 'netease', value: '345345345', icon: 'netease.svg', }, { key: 'weibo', value: '436453345', icon: 'weibo.svg', }, // ... ] function defineConstants(list) { return { SOCIAL_KEYS: list.map((item) => item.key), SOCIAL_KV: list.reduce( (map, item) => ({ ...map, [item.key]: item.value, }), {}, ), .... } } const data = defineConstants(social) console.log(data); //{ // SOCIAL_KEYS: [ 'bilibili', 'netease', 'weibo' ], // SOCIAL_KV: { bilibili: '291833916', netease: '345345345', weibo: '436453345' } //} ``` 大致思路就是这样,但为了代码的通用性我们应当再传递一个参数,来当我们的前缀。 ```typescript function defineConstants(list, namespace) { const prefix = namespace ? `${namespace}_` : '' return { [`${prefix}KEYS`]: list.map((item) => item.key), [`${prefix}KV`]: list.reduce( (map, item) => ({ ...map, [item.key]: item.value, }), {}, ), } } ``` 但这样的函数是没有类型提示的,因此我们需要使用 TypeScript 来进行类型定义: ```typescript interface IBaseDef { key: PropertyKey; value: string | number; } function defineConstants<T extends IBaseDef[], N extends string>( defs: T, namespace?: N, ) { const prefix = namespace ? `${namespace}_` : ''; return { [`${prefix}KEYS`]: defs.map((item) => item.key), }; } ``` 这样传入的参数便有了类型,我们还需要对返回值进行定义: ```typescript type ToProperty<Property extends string, N extends string = ""> = N extends "" ? Property : `${N}_${Property}`; export type MergeIntersection<A> = A extends infer T ? { [Key in keyof T]: T[Key] } : never; type ToKeyValue<T> = T extends readonly [infer A, ...infer B] ? B["length"] extends 0 ? ToSingleKeyValue<A> : MergeIntersection<ToSingleKeyValue<A> & ToKeyValue<B>> : []; type ToKeys<T> = T extends readonly [infer A, ...infer B] ? A extends { readonly key: infer K; } ? B["length"] extends 0 ? [K] : [K, ...ToKeys<B>] : never : []; type ToSingleKeyValue<T> = T extends { readonly key: infer K; readonly value: infer V; } ? K extends PropertyKey ? { readonly [Key in K]: V; } : never : never; function defineConstants<T extends readonly IBaseDef[], N extends string = "">( defs: T, namespace?: N ) { const prefix = namespace ? `${namespace}_` : ""; return { [`${prefix}KEYS`]: defs.map((item) => item.key), [`${prefix}KV`]: defs.reduce( (map, item) => ({ ...map, [item.key]: item.value, }), {} ), } as MergeIntersection<{ [Key in ToProperty<"KV", N>]: ToKeyValue<T>; }> & { [Key in ToProperty<"KEYS", N>]: ToKeys<T>; }; } ``` TypeScript 涉及的知识点过多,就不再叙述了,最终代码可以看开头 `codesandbox`。 ## 参考 * [使用 TypeScript 定义业务字典](https://juejin.cn/post/7187963986875252795)