avatar
SuemorのBlog
  • 首页
  • 文章
  • 归档
  • 更多
    • 友链
    • 关于
    • 友链
关于 >关于友链关于此项目

@2025 - 2026 Suemor

Powered by Marchen
|所谓自由就是可以说二加二等于四的自由

文章列表

  • Cover image for JavaScript 解析 MP4 二进制:ArrayBuffer、TypedArray 与 DataView
    JavaScript 解析 MP4 二进制:ArrayBuffer、TypedArray 与 DataView

    本文介绍 JavaScript 处理二进制数据的三种 API:ArrayBuffer 是底层数据容器,TypedArray 适合同构数组访问,DataView 适合按任意偏移、类型和字节序读写。结合 MP4 Box 结构可知,因其字段类型混杂、偏移不对齐且采用大端序,DataView 最适合解析字段,Uint8Array 最适合切片、拷贝和扫描。

    JavaScriptMP4二进制数据
    2 小时前
  • Cover image for ArrayBuffer、TypedArray 和 DataView 在 MP4 Box 解析中的运用
    ArrayBuffer、TypedArray 和 DataView 在 MP4 Box 解析中的运用

    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); ``` ![arrayBuffer result](https://y.suemor.com/CleanShot%202026-02-21%20at%2018.54.31%402x.png) ### 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); ``` ![typedArray result](https://y.suemor.com/CleanShot%202026-02-21%20at%2019.01.12%402x.png) ### 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); ``` ![dataView result](https://y.suemor.com/CleanShot%202026-02-21%20at%2019.14.33%402x.png) 三者的关系可以这样理解: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**。

    TypeScript前端
    2 个月前
  • Cover image for 记一次 HLS 视频流 fMP4 时间戳对齐问题的排查与修复
    记一次 HLS 视频流 fMP4 时间戳对齐问题的排查与修复

    最近尝试接触前端音视频领域。在开发 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 } } ```

    4 个月前
  • Cover image for 理解 CORS、预检请求 (Preflight) 和跨域
    理解 CORS、预检请求 (Preflight) 和跨域

    这篇主要来聊一聊前端常见的跨域问题,以及后端如何处理 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 错误,不再发送实际请求。 ![CORS 错误](https://y.suemor.com/202504200146315.png) 因此,我们需要单独处理这个**预检请求**(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)); ```

    TypeScriptjavascript
    1 年前
  • Cover image for Electron 安装和打包不同平台的 FFmpeg
    Electron 安装和打包不同平台的 FFmpeg

    最近在写一个[自动匹配弹幕的动漫播放器](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 就会直接报错,然后显示一个完全摸不到头脑的错误。 ![启动报错](https://y.suemor.com/202411240121558.png) #### 原因分析 我一开始看到这个错误也是完全懵逼的,使用 [debugtron](https://github.com/pd4d10/debugtron) 对主线程进行调试也完全没有输出。之后尝试对包进行分析,发现 x64 版本的 .app 打包的 FFmpeg 竟然是 ARM64 版本的,这不报错才怪呢。 ![x64版本安装包](https://y.suemor.com/202411240128746.png) 这里我便对 `@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 了,从而导致的错误。 ![node_modules目录](https://y.suemor.com/202411240200776.png) #### 整理思路 那么我们的思路就很明确了: * 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 都打包进去了。 ![ARM64 版本 xxx.app 把 x64 和 ARM64 FFmpeg 都打包进去了](https://y.suemor.com/202411240212215.png) 这里我们得写一个脚本,在 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。 ![不同平台只打包对应的 FFmpeg](https://y.suemor.com/202411240221470.png)

    TypeScript前端Electron
    1 年前
  • Cover image for Electron 代码签名和公证
    Electron 代码签名和公证

    > 本文的方式仅限用于 macOS 最近尝试入门了下 Electron 开发,写了一个可以[自动匹配弹幕的动漫播放器](https://github.com/marchen-dev/MarchenPlay),期间遇到挺多坑的,写篇文章来记录一下。 ## 代码签名和公证 想要在 macOS 上运行一个桌面端应用,那就必须对它代码进行签名,否则是无法打开,会出现如下错误。 ![错误提示](https://y.suemor.com/202411221823021.png) ### 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 ``` ![ Github Repository secrets ](https://y.suemor.com/202411231545930.png) 这里有个麻烦的地方,就是 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。

    前端reactTypeScriptElectron
    1 年前
  • Cover image for NestJS 核心概念
    NestJS 核心概念

    主要讲下 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; } } ``` ![image-20230531005247226](https://y.suemor.com/imagesimage-20230531005247226.png) ## 五种 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('*'); } } ``` 然后去浏览器访问,我们可以看到控制台正确输出了 ![image-20230531204334476](https://y.suemor.com/imagesimage-20230531204334476.png) ### 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; } } ``` ![image-20230531220921807](https://y.suemor.com/imagesimage-20230531220921807.png) ### 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(); } } ``` 被正确捕获 ![image-20230531225850021](https://y.suemor.com/imagesimage-20230531225850021.png)

    前端
    3 年前
  • Cover image for HTTP 的数据传输方式和编码格式
    HTTP 的数据传输方式和编码格式

    我们向后端发起 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。 ![image-20230525192242285](https://y.suemor.com/imagesimage-20230525192242285.png) ### 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`。 ![image-20230525205107048](https://y.suemor.com/imagesimage-20230525205107048.png) ### 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`。 ![image-20230525212808051](https://y.suemor.com/imagesimage-20230525212808051.png) ### 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} />; } ```

    前端
    3 年前
  • Cover image for NextJS 13.4 App Router 初体验
    NextJS 13.4 App Router 初体验

    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`,为此会出现如下错误。 ![error-image](https://fastly.jsdelivr.net/gh/suemor233/static@main/img/image-20230510191228604.png) 为了能够正常在 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。 ![Route Groups with Opt-in Layouts](https://nextjs.org/_next/image?url=%2Fdocs%2Flight%2Froute-group-opt-in-layouts.png&w=3840&q=75) 动态路由方面和之前差不多通过 `[folderName]` 来定义,不过现在可以直接在 props 中获取对于的值 ![20230511011319912](https://fastly.jsdelivr.net/gh/suemor233/static@main/img/image-20230511011319912.png) 我们也可以创建 `loading.tsx` ,其就是包了一层 Suspense,当我们在 `page.tsx` fetch 数据的过程中,可以显示 `loading.tsx` 中的内容。 同时也有 `error.tsx`,当页面渲染出现错误时,也可以及时兜底,避免那串「白屏黑字」。 ![image-20230511005515155](https://fastly.jsdelivr.net/gh/suemor233/static@main/img/image-20230511005515155.png) 还有一种 `Parallel Routes` 我们可以把`@folderName`给映射到到 layout 的 props 里,当成「插槽」来使用,一般可以适用于网页顶部的 header,和底部 footer 之类的。 ![Parallel Routes Diagram](https://nextjs.org/_next/image?url=%2Fdocs%2Flight%2Fparallel-routes.png&w=3840&q=75) ```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)

    NextJSAppRouter
    3 年前
  • Cover image for TypeScript 类型父子级、逆变、协变、双向协变和不变
    TypeScript 类型父子级、逆变、协变、双向协变和不变

    > 我也是最近刚接触到了这些知识,文章可能有些错误,希望大佬多多指点( 对于学习 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">; ``` ![image-20230203205737963](https://y.suemor.com/imagesimage-20230203205737963.png) 这里`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">; ``` ![image-20230203205711934](https://y.suemor.com/imagesimage-20230203205711934.png)

    前端TypeScript
    3 年前
  • Cover image for TypeScript 模式匹配
    TypeScript 模式匹配

    最近尝试学习了下 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; ``` ![image-20230126230306694](https://y.suemor.com/imagesimage-20230126230306694.png) ## 函数 我们在函数中同样可以进行类型匹配,提取它的参数,返回值类型。例如下方提取函数的参数类型: ![image-20230126230329512](https://y.suemor.com/imagesimage-20230126230329512.png) ```typescript type GetResult<T extends Function> = T extends (...args:infer Args) => unknown ? Args : never type Result = GetResult<(name: string, age: number) => string>; ``` 通过 infer 将参数类型保存到变量 Args 里面。 ## 数组类型 数组也同样适用类型匹配,例如我们想提取它第一个元素的类型: ![image-20230126224340001](https://y.suemor.com/imagesimage-20230126224340001.png) ```typescript type Result = GetResult<[1, 2, 3]>; type GetResult<T extends unknown[]> = T extends [infer First, ...unknown[]] ? First : never; ``` 利用 infer 获取第一个元素的类型,剩余的元素类型用存放在 `...unknown[]`中。 ## 字符串类型 字符串类型也可以进行模式匹配,把匹配的字符串,存放在 infer 的局部变量中,如下实现字符串替换: ![image-20230126225708009](https://y.suemor.com/imagesimage-20230126225708009.png) ```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)即可。 还有个例子,判断字符串是否以某个前缀开头: ![image-20230126225131765](https://y.suemor.com/imagesimage-20230126225131765.png) ```typescript type Result = GetResult<"suemor and mike", "suemor">; type GetResult< Str extends string, Prefix extends string > = Str extends `${Prefix}${string}` ? true : false; ``` 把 Prefix 放在字符串开头进行匹配即可。

    TypeScript前端
    3 年前
  • Cover image for 封装一个字典函数
    封装一个字典函数

    > 完整代码: 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)

    前端
    3 年前