普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月1日掘金 前端

JSyncQueue——一个开箱即用的鸿蒙异步任务同步队列

作者 江澎涌
2026年1月31日 22:55

零、JSyncQueue

JSyncQueue 是一个开箱即用的鸿蒙异步任务同步队列。

项目地址:github.com/zincPower/J…

一、JSyncQueue 有什么作用

在鸿蒙应用开发中,有时需要让多个异步任务按顺序执行,例如状态的转换处理,如果不加控制,会因为执行顺序混乱而产生一些莫名其妙的问题。 所以 JSyncQueue 提供了一个简洁的解决方案:

  • 保证顺序执行:所有任务严格按照入队顺序执行,即使任务内部有异步操作也能保证顺序
  • 两种执行模式:支持 "立即执行" 和 "延时执行" 两种模式,可以满足不同场景需求
  • 两种任务类型:支持向同步队列添加 "Message 类型任务" 和 "Runnable 类型任务"
  • 任务取消和管理:可随时取消指定任务或清空整个队列
  • 获取任务结果:通过任务的 getResult() 获取执行结果

项目架构如下图所示:

二、如何安装 JSyncQueue

第一种方式: 在需要使用 JSyncQueue 的模块中运行以下命令

ohpm install jsyncqueue

第二种方式: 在需要使用 JSyncQueue 的模块 oh-package.json5 中添加以下依赖

{
  "name": "sample",
  "version": "1.0.0",
  "description": "Please describe the basic information.",
  "main": "",
  "author": "",
  "license": "",
  "dependencies": {
    "jsyncqueue": "1.0.0" // 添加这一行,请根据需要修改版本号
  }
}

三、JSyncQueue API 介绍

3-1、JSyncQueue 类

构造函数

constructor(queueName: string)

创建一个同步队列实例。

  • queueName: 队列名称,用于标识和调试

方法

方法 参数 返回值 说明
post(runnable) runnable: (taskId: number) => Promise<Any> Task 立即执行闭包
postDelay(runnable, delay) runnable: (taskId: number) => Promise<Any>, delay: number Task 延时 delay 毫秒执行闭包
sendMessage(message) message: Message Task 立即发送消息
sendMessageDelay(message, delay) message: Message, delay: number Task 延时 delay 毫秒发送消息
cancel(taskId) taskId: number void 取消指定任务
clear() - void 清空队列中所有等待的任务
dumpInfo() - string 获取队列调试信息
onHandleMessage(message, taskId) message: Message, taskId: number Promise<Any> 消息处理方法,子类可重写

属性

属性 类型 说明
queueName string 队列名称(只读)
length number 当前队列中的任务数量(只读)

3-2、Message 接口

interface Message {
  what: string   // 消息类型
  data: Any      // 消息数据
}

3-3、Task 接口

所有添加的任务,包括“Message 类型任务”和“Runnable 类型任务”,均会返回该类型实例,通过该实例可以“取消任务”、“获取任务结果”、“任务 Id”。

interface Task {
  cancel(): void                  // 取消任务
  getResult(): Promise<Any>       // 获取任务结果
  getTaskId(): number             // 获取任务 ID
}

3-4、异常类型

JSyncQueueCancelException

当任务被取消时,会抛出该类型的异常。

interface JSyncQueueCancelException {
  message: string
}

JSyncQueueException

当 JSyncQueue 内部发生异常时,会抛出该类型的异常。

值得注意:使用者编写的逻辑中抛出的异常会原封不动的抛到 Task.getResult().catch 中,而不是以 JSyncQueueException 类型抛出

interface JSyncQueueException {
  message: string
}

四、如何使用 JSyncQueue

4-1、使用 JSyncQueue 创建同步队列

如果你处理的场景均是简单的一次性任务,那么直接使用 JSyncQueue 创建一个同步队列,并压入 Runnable 闭包即可。

以下代码展示的逻辑细节:

  • 代码中使用了 delay 函数模拟了两次耗时操作,并且返回结果
  • 外部通过 Task 类型实例接收返回结果,并且打印
  • 在第四次循环(即 i 为 3)的时候,会模拟抛出异常,异常内容会原封不动的抛到 catch

值得注意:

  • 立即执行任务会严格按入队顺序执行
  • 任务结果的接收处理(即对 Task.getResult() 的处理)和 JSyncQueue 对任务的处理是不保证顺序的,因为 Task.getResult() 的处理已不在队列范围内
immediatelyJSyncQueue: JSyncQueue = new JSyncQueue("ImmediatelyJSyncQueue")
for (let i = 0; i < 5; ++i) {
  const task = this.immediatelyJSyncQueue.post(async () => {
    const delayTime1 = Math.round(Math.random() * 500)
    Log.i(TAG, `【添加5个Runnable】执行逻辑 i=${i} 第一段 将会模拟耗时=${delayTime1}`)
    await this.delay(delayTime1)

    if (i == 3) {
      throw { message: "模拟异常" } as Error
    }

    const delayTime2 = Math.round(Math.random() * 500)
    Log.i(TAG, `【添加5个Runnable】执行逻辑 i=${i} 第二段 将会模拟耗时=${delayTime2}`)
    await this.delay(delayTime2)

    return `jiangpengyong-添加5个Runnable ${i}`
  })
  task.getResult()
    .then((result) => {
      Log.i(TAG, `【添加5个Runnable-执行成功】i=${i} result=${result}`)
    })
    .catch((e: Error) => {
      Log.e(TAG, `【添加5个Runnable-执行异常】i=${i} e=${JSON.stringify(e)}`)
    })
    .finally(() => {
      Log.i(TAG, `【添加5个Runnable-执行结束】i=${i}`)
    })
}

// ========================================= 输出日志 =========================================
// 【添加5个Runnable】执行逻辑 i=0 第一段 将会模拟耗时=239
// 【添加5个Runnable】执行逻辑 i=0 第二段 将会模拟耗时=315
// 【添加5个Runnable】执行逻辑 i=1 第一段 将会模拟耗时=379
// 【添加5个Runnable-执行成功】i=0 result=jiangpengyong-添加5个Runnable 0
// 【添加5个Runnable-执行结束】i=0
// 【添加5个Runnable】执行逻辑 i=1 第二段 将会模拟耗时=391
// 【添加5个Runnable】执行逻辑 i=2 第一段 将会模拟耗时=499
// 【添加5个Runnable-执行成功】i=1 result=jiangpengyong-添加5个Runnable 1
// 【添加5个Runnable-执行结束】i=1
// 【添加5个Runnable】执行逻辑 i=2 第二段 将会模拟耗时=395
// 【添加5个Runnable】执行逻辑 i=3 第一段 将会模拟耗时=478
// 【添加5个Runnable-执行成功】i=2 result=jiangpengyong-添加5个Runnable 2
// 【添加5个Runnable-执行结束】i=2
// 【添加5个Runnable】执行逻辑 i=4 第一段 将会模拟耗时=166
// 【添加5个Runnable-执行异常】i=3 e={"message":"模拟异常"}
// 【添加5个Runnable-执行结束】i=3
// 【添加5个Runnable】执行逻辑 i=4 第二段 将会模拟耗时=33
// 【添加5个Runnable-执行成功】i=4 result=jiangpengyong-添加5个Runnable 4
// 【添加5个Runnable-执行结束】i=4

取消同步任务

通过返回的 Task 类型实例调用 cancel 方法可以进行取消任务。

下面的代码会取消第四次任务,所以在日志中会看到对应的取消异常,并且不会执行该任务。

let task: Task | undefined
for (let i = 0; i < 5; ++i) {
  const tempTask = this.immediatelyJSyncQueue.post(async () => {
    const delayTime1 = Math.round(Math.random() * 500)
    Log.i(TAG, `【移除Runnable】执行逻辑 i=${i} 第一段 将会模拟耗时=${delayTime1}`)
    await this.delay(delayTime1)

    const delayTime2 = Math.round(Math.random() * 500)
    Log.i(TAG, `【移除Runnable】执行逻辑 i=${i} 第二段 将会模拟耗时=${delayTime2}`)
    await this.delay(delayTime2)

    if (i == 2) {
      throw { message: "模拟异常" } as Error
    }
    return `jiangpengyong-移除Runnable ${i}`
  })
  tempTask.getResult().then((result) => {
    Log.i(TAG, `【移除Runnable】执行成功 i=${i} result=${result}`)
  }).catch((e: Any) => {
    Log.e(TAG, `【移除Runnable】执行异常 i=${i} e=${JSON.stringify(e)}`)
  }).finally(() => {
    Log.i(TAG, `【移除Runnable】执行完成 i=${i}`)
  })
  if (i == 3) {
    task = tempTask
  }
}
Log.i(TAG, `【移除Runnable】取消任务 task=${JSON.stringify(task)}`)
task?.cancel()

// ========================================= 输出日志 =========================================
// 【移除Runnable】执行逻辑 i=0 第一段 将会模拟耗时=263
// 【移除Runnable】取消任务 task={"taskId":13,"queue":{},"promise":{}}
// 【移除Runnable】执行异常 i=3 e={"message":"Cancel task by cancel function."}
// 【移除Runnable】执行完成 i=3
// 【移除Runnable】执行逻辑 i=0 第二段 将会模拟耗时=474
// 【移除Runnable】执行逻辑 i=1 第一段 将会模拟耗时=318
// 【移除Runnable】执行成功 i=0 result=jiangpengyong-移除Runnable 0
// 【移除Runnable】执行完成 i=0
// 【移除Runnable】执行逻辑 i=1 第二段 将会模拟耗时=6
// 【移除Runnable】执行逻辑 i=2 第一段 将会模拟耗时=406
// 【移除Runnable】执行成功 i=1 result=jiangpengyong-移除Runnable 1
// 【移除Runnable】执行完成 i=1
// 【移除Runnable】执行逻辑 i=2 第二段 将会模拟耗时=212
// 【移除Runnable】执行逻辑 i=4 第一段 将会模拟耗时=226
// 【移除Runnable】执行异常 i=2 e={"message":"模拟异常"}
// 【移除Runnable】执行完成 i=2
// 【移除Runnable】执行逻辑 i=4 第二段 将会模拟耗时=439
// 【移除Runnable】执行成功 i=4 result=jiangpengyong-移除Runnable 4
// 【移除Runnable】执行完成 i=4

延时执行 Runnable 类型任务

添加延时任务只需改用 postDelay 方法并传入延时参数

  • 下面代码记录了添加任务到真正执行的延时,通过 realDelay 参数可以查看
  • 使用了 delay 函数模拟了两次耗时操作,并模拟返回了处理结果
  • 第四次任务抛出了异常,异常消息会原封不动的在 catch 的日志展示
  • 因为延时任务的添加是按索引进行累加的,所以添加顺序其实并没变化,从最后的日志输出可以看到保证了执行顺序
for (let i = 0; i < 5; ++i) {
  const startTime = systemDateTime.getTime(false)
  const delayTime = i * 100
  const task = this.delayJSyncQueue.postDelay(async () => {
    const endTime = systemDateTime.getTime(false)
    const realDelay = endTime - startTime
    const delayTime1 = Math.round(Math.random() * 500)
    Log.i(TAG, `【添加5个Runnable】执行逻辑 delay=${delayTime} realDelay=${realDelay} i=${i} 第一段 将会模拟耗时=${delayTime1}`)
    await this.delay(delayTime1)

    const delayTime2 = Math.round(Math.random() * 500)
    Log.i(TAG, `【添加5个Runnable】执行逻辑 i=${i} 第二段 将会模拟耗时=${delayTime2}`)
    await this.delay(delayTime2)

    if (i == 3) {
      throw { message: "模拟异常" } as Error
    }
    return `jiangpengyong-添加5个Runnable ${i}`
  }, delayTime)
  task.getResult()
    .then((result) => {
      Log.i(TAG, `【添加5个Runnable】执行成功 i=${i} result=${result}`)
    })
    .catch((e: Error) => {
      Log.e(TAG, `【添加5个Runnable】执行异常 i=${i} e=${JSON.stringify(e)}`)
    })
    .finally(() => {
      Log.i(TAG, `【添加5个Runnable】执行结束 i=${i}`)
    })
}

// ========================================= 输出日志 =========================================
// 【添加5个Runnable】执行逻辑 delay=0 realDelay=1 i=0 第一段 将会模拟耗时=473
// 【添加5个Runnable】执行逻辑 i=0 第二段 将会模拟耗时=410
// 【添加5个Runnable】执行逻辑 delay=100 realDelay=888 i=1 第一段 将会模拟耗时=178
// 【添加5个Runnable】执行成功 i=0 result=jiangpengyong-添加5个Runnable 0
// 【添加5个Runnable】执行结束 i=0
// 【添加5个Runnable】执行逻辑 i=1 第二段 将会模拟耗时=204
// 【添加5个Runnable】执行逻辑 delay=200 realDelay=1272 i=2 第一段 将会模拟耗时=410
// 【添加5个Runnable】执行成功 i=1 result=jiangpengyong-添加5个Runnable 1
// 【添加5个Runnable】执行结束 i=1
// 【添加5个Runnable】执行逻辑 i=2 第二段 将会模拟耗时=36
// 【添加5个Runnable】执行逻辑 delay=300 realDelay=1721 i=3 第一段 将会模拟耗时=475
// 【添加5个Runnable】执行成功 i=2 result=jiangpengyong-添加5个Runnable 2
// 【添加5个Runnable】执行结束 i=2
// 【添加5个Runnable】执行逻辑 i=3 第二段 将会模拟耗时=483
// 【添加5个Runnable】执行逻辑 delay=400 realDelay=2686 i=4 第一段 将会模拟耗时=9
// 【添加5个Runnable】执行异常 i=3 e={"message":"模拟异常"}
// 【添加5个Runnable】执行结束 i=3
// 【添加5个Runnable】执行逻辑 i=4 第二段 将会模拟耗时=395
// 【添加5个Runnable】执行成功 i=4 result=jiangpengyong-添加5个Runnable 4
// 【添加5个Runnable】执行结束 i=4

取消延时任务

延时任务的取消操作和立即执行的取消操作是完全一样的,都是通过返回的 Task 实例调用 cancel 方法,这里就不再赘述。

4-2、继承 JSyncQueue 创建同步队列

如果你的同步逻辑需要集中管理或进行复用,可以考虑 Message 类型任务。

处理 Message 类型任务,需要继承 JSyncQueue 实现 onHandleMessage 方法,在该方法中会按入队顺序接收到 Message

  • 通过 Message.what 属性区分不同类别消息实现不同处理逻辑
  • 通过 Message.data 属性可以获取外部传入的数据,数据类型是 Any 可以是任意类型数据,使用者自行转换为真实类型进行逻辑处理

具体操作如下:

  • 定义一个 ImmediatelyQueue 类继承 JSyncQueue ,实现 onHandleMessage 方法
  • 创建一个 ImmediatelyQueue 实例,并通过这个实例进行发送 Message 消息,同步队列会按入队顺序一个个进行分发给该实例的 onHandleMessage 方法进行处理
// 自定义 JSyncQueue
export class ImmediatelyQueue extends JSyncQueue {
  private count = 0

  async onHandleMessage(message: Message): Promise<Any> {
    switch (message.what) {
      case "say_hello": {
        const name = message.data["name"]
        this.count += 1

        const delayTime1 = Math.round(Math.random() * 500)
        Log.i("ImmediatelyQueue", `【say_hello】执行逻辑 第一段 将会模拟耗时=${delayTime1}`)
        await this.delay(delayTime1)

        const delayTime2 = Math.round(Math.random() * 500)
        Log.i("ImmediatelyQueue", `【say_hello】执行逻辑 第二段 将会模拟耗时=${delayTime2}`)
        await this.delay(delayTime2)

        if (this.count % 10 == 5) {
          throw { message: "模拟异常" }
        }
        return `你好,${name}。这是第${this.count}次打招呼。`
      }
      // ... 其他 what 处理逻辑
    }
    return undefined
  }

  private async delay(ms: number) {
    return new Promise<Any>(resolve => setTimeout(resolve, ms))
  }
}

// 使用逻辑
immediatelyQueue: JSyncQueue = new ImmediatelyQueue("ImmediatelyQueue")
for (let i = 0; i < 5; ++i) {
  const tempTask = this.immediatelyQueue.sendMessage({
    what: `say_hello`,
    data: { name: '江澎涌', age: 20 + i },
  })
  tempTask.getResult()
    .then((result) => {
      Log.i(TAG, `【添加5个Message】执行成功 i=${i} result=${result}`)
    })
    .catch((e: Error) => {
      Log.e(TAG, `【添加5个Message】执行异常 i=${i} e=${JSON.stringify(e)}`)
    })
    .finally(() => {
      Log.i(TAG, `【添加5个Message】执行结束i=${i}`)
    })
}
// ========================================= 输出日志 =========================================
// onHandleMessage message={"what":"say_hello","data":{"name":"江澎涌","age":20}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=92
// 【say_hello】执行逻辑 第二段 将会模拟耗时=143
// onHandleMessage message={"what":"say_hello","data":{"name":"江澎涌","age":21}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=276
// 【say_hello】执行逻辑 第二段 将会模拟耗时=377
// onHandleMessage message={"what":"say_hello","data":{"name":"江澎涌","age":22}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=120
// 【say_hello】执行逻辑 第二段 将会模拟耗时=223
// onHandleMessage message={"what":"say_hello","data":{"name":"江澎涌","age":23}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=424
// 【say_hello】执行逻辑 第二段 将会模拟耗时=444
// onHandleMessage message={"what":"say_hello","data":{"name":"江澎涌","age":24}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=181
// 【say_hello】执行逻辑 第二段 将会模拟耗时=402

移除 Message 消息

使用 sendMessage 方法压入 “Message 类型任务” 同样会返回 Task 类型实例,调用该实例的 cancel 方法就可以取消该任务。

下列代码会取消第二个任务,所以不会看到 "age":11 的消息。

let task: Task | undefined
for (let i = 0; i < 5; ++i) {
  const tempTask = this.immediatelyQueue.sendMessage({
    what: `remove_message`,
    data: { name: 'jiang peng yong', age: 10 + i },
  })
  tempTask.getResult().then((result) => {
    Log.i(TAG, `【移除Message】执行成功 i=${i} result=${result}`)
  }).catch((e: Any) => {
    Log.e(TAG, `【移除Message】执行异常 i=${i} e=${JSON.stringify(e)}`)
  }).finally(() => {
    Log.i(TAG, `【移除Message】执行完成 i=${i}`)
  })
  if (i == 1) {
    task = tempTask
  }
}
Log.i(TAG, `【移除Message】取消任务 task=${JSON.stringify(task)}`)
task?.cancel()
// ========================================= 输出日志 =========================================
// onHandleMessage message={"what":"remove_message","data":{"name":"jiang peng yong","age":10}}
// 【remove_message】执行逻辑 第一段 将会模拟耗时=497
// 【remove_message】执行逻辑 第二段 将会模拟耗时=397
// onHandleMessage message={"what":"remove_message","data":{"name":"jiang peng yong","age":12}}
// 【remove_message】执行逻辑 第一段 将会模拟耗时=162
// 【remove_message】执行逻辑 第二段 将会模拟耗时=283
// onHandleMessage message={"what":"remove_message","data":{"name":"jiang peng yong","age":13}}
// 【remove_message】执行逻辑 第一段 将会模拟耗时=193
// 【remove_message】执行逻辑 第二段 将会模拟耗时=93
// onHandleMessage message={"what":"remove_message","data":{"name":"jiang peng yong","age":14}}
// 【remove_message】执行逻辑 第一段 将会模拟耗时=359
// 【remove_message】执行逻辑 第二段 将会模拟耗时=145

延时执行 Message 类型任务

  • 定义一个 DelayQueue 类继承 JSyncQueue ,主要重写 onHandleMessage 方法,用于接收处理 Message
  • 创建 DelayQueue 实例,通过这个实例调用 sendMessageDelay 方法即可达到相应的延时效果
export class DelayQueue extends JSyncQueue {
  private count = 0

  async onHandleMessage(message: Message): Promise<Any> {
    Log.i("DelayQueue", `onHandleMessage message=${JSON.stringify(message)}`)
    switch (message.what) {
      case "say_hello": {
        const name = message.data["name"]
        this.count += 1

        const delayTime1 = Math.round(Math.random() * 500)
        Log.i("DelayQueue", `【say_hello】执行逻辑 第一段 将会模拟耗时=${delayTime1}`)
        await this.delay(delayTime1)

        const delayTime2 = Math.round(Math.random() * 500)
        Log.i("DelayQueue", `【say_hello】执行逻辑 第二段 将会模拟耗时=${delayTime2}`)
        await this.delay(delayTime2)

        if (this.count % 10 == 5) {
          throw { message: "模拟异常" }
        }
        return `Hello,${name}. This is the ${this.count} th greeting.`
      }
    }
    return undefined
  }

  private async delay(ms: number) {
    return new Promise<Any>(resolve => setTimeout(resolve, ms))
  }
}

delayQueue: JSyncQueue = new DelayQueue("DelayQueue")
for (let i = 0; i < 5; ++i) {
  const delayTime = i * 100
  const task = this.delayQueue.sendMessageDelay({
    what: `say_hello`,
    data: { name: '江澎涌', age: 20 + i },
  }, delayTime)
  task.getResult()
    .then((result) => {
      Log.i(TAG, `【添加5个Message】执行成功 i=${i} result=${result}`)
    })
    .catch((e: Error) => {
      Log.e(TAG, `【添加5个Message】执行异常 i=${i} e=${JSON.stringify(e)}`)
    })
    .finally(() => {
      Log.i(TAG, `【添加5个Message】执行结束i=${i}`)
    })
}
// ========================================= 输出日志 ========================================= 
// onHandleMessage message={"what":"say_hello","data":{"name":"江澎涌","age":20}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=356
// 【say_hello】执行逻辑 第二段 将会模拟耗时=302
// onHandleMessage message={"what":"say_hello","data":{"name":"江澎涌","age":21}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=67
// 【say_hello】执行逻辑 第二段 将会模拟耗时=344
// onHandleMessage message={"what":"say_hello","data":{"name":"江澎涌","age":22}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=339
// 【say_hello】执行逻辑 第二段 将会模拟耗时=384
// onHandleMessage message={"what":"say_hello","data":{"name":"江澎涌","age":23}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=442
// 【say_hello】执行逻辑 第二段 将会模拟耗时=392
// onHandleMessage message={"what":"say_hello","data":{"name":"江澎涌","age":24}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=443
// 【say_hello】执行逻辑 第二段 将会模拟耗时=199

取消延时的 Message 类型任务

延时任务的取消操作和立即执行的取消操作是完全一样的,都是通过返回的 Task 实例调用 cancel 方法,这里就不再赘述。

同一队列压入 Message 类型任务和 Runnable 类型任务

JSyncQueue 同一实例压入 MessageRunnable 两种类型任务是支持的,会按照压入顺序进行执行和分发。

// ImmediatelyQueue 源码就不再展示,需要可以移步 Github 上查阅
immediatelyQueue: JSyncQueue = new ImmediatelyQueue("ImmediatelyQueue")
for (let i = 0; i < 10; ++i) {
  if (i % 2 == 0) {
    this.immediatelyQueue.post(async () => {
      const delayTime1 = Math.round(Math.random() * 500)
      Log.i(TAG, `【添加10个Message和Runnable】执行逻辑 i=${i} 第一段 将会模拟耗时=${delayTime1}`)
      await this.delay(delayTime1)

      const delayTime2 = Math.round(Math.random() * 500)
      Log.i(TAG, `【添加10个Message和Runnable】执行逻辑 i=${i} 第二段 将会模拟耗时=${delayTime2}`)
      await this.delay(delayTime2)

      if (i / 2 == 3) {
        throw { message: "模拟异常" } as Error
      }
      return `小朋友-添加10个Message和Runnable ${i}`
    })
  } else {
    this.immediatelyQueue.sendMessage({
      what: `say_hello`,
      data: { name: '小朋友', age: i },
    })
  }
}
// ========================================= 输出日志 ========================================= 
// 【添加10个Message和Runnable】执行逻辑 i=0 第一段 将会模拟耗时=416
// 【添加10个Message和Runnable】执行逻辑 i=0 第二段 将会模拟耗时=41
// onHandleMessage message={"what":"say_hello","data":{"name":"小朋友","age":1}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=184
// 【say_hello】执行逻辑 第二段 将会模拟耗时=63
// 【添加10个Message和Runnable】执行逻辑 i=2 第一段 将会模拟耗时=451
// 【添加10个Message和Runnable】执行逻辑 i=2 第二段 将会模拟耗时=223
// onHandleMessage message={"what":"say_hello","data":{"name":"小朋友","age":3}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=99
// 【say_hello】执行逻辑 第二段 将会模拟耗时=27
// 【添加10个Message和Runnable】执行逻辑 i=4 第一段 将会模拟耗时=273
// 【添加10个Message和Runnable】执行逻辑 i=4 第二段 将会模拟耗时=193
// onHandleMessage message={"what":"say_hello","data":{"name":"小朋友","age":5}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=20
// 【say_hello】执行逻辑 第二段 将会模拟耗时=231
// 【添加10个Message和Runnable】执行逻辑 i=6 第一段 将会模拟耗时=46
// 【添加10个Message和Runnable】执行逻辑 i=6 第二段 将会模拟耗时=198
// onHandleMessage message={"what":"say_hello","data":{"name":"小朋友","age":7}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=179
// 【say_hello】执行逻辑 第二段 将会模拟耗时=0
// 【添加10个Message和Runnable】执行逻辑 i=8 第一段 将会模拟耗时=131
// 【添加10个Message和Runnable】执行逻辑 i=8 第二段 将会模拟耗时=401
// onHandleMessage message={"what":"say_hello","data":{"name":"小朋友","age":9}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=452
// 【say_hello】执行逻辑 第二段 将会模拟耗时=40

4-3、取消队列中所有任务

JSyncQueue 实例调用 clear 方法,就会把队列中等待执行的任务,包括延时执行和立即执行的任务,全都取消。同时会抛出 JSyncQueueCancelException 类型异常。

for (let i = 0; i < 5; ++i) {
  const task = this.immediatelyQueue.post(async () => {
    const delayTime1 = Math.round(Math.random() * 500)
    Log.i(TAG, `【清空队列】执行逻辑 i=${i} 第一段 将会模拟耗时=${delayTime1}`)
    await this.delay(delayTime1)

    const delayTime2 = Math.round(Math.random() * 500)
    Log.i(TAG, `【清空队列】执行逻辑 i=${i} 第二段 将会模拟耗时=${delayTime2}`)
    await this.delay(delayTime2)

    return `小朋友-清空队列 ${i}`
  })
  task.getResult()
    .then((result) => {
      Log.i(TAG, `【清空队列】执行成功 i=${i} result=${result}`)
    })
    .catch((e: Error) => {
      Log.e(TAG, `【清空队列】执行异常 i=${i} e=${JSON.stringify(e)}`)
    })
    .finally(() => {
      Log.i(TAG, `【清空队列】执行结束 i=${i}`)
    })
}
this.immediatelyQueue.clear()
// ========================================= 输出日志 ========================================= 
// 【清空队列】执行逻辑 i=0 第一段 将会模拟耗时=14
// 【清空队列】执行异常 i=1 e={"message":"Cancel task by clear function."}
// 【清空队列】执行异常 i=2 e={"message":"Cancel task by clear function."}
// 【清空队列】执行异常 i=3 e={"message":"Cancel task by clear function."}
// 【清空队列】执行异常 i=4 e={"message":"Cancel task by clear function."}
// 【清空队列】执行结束 i=1
// 【清空队列】执行结束 i=2
// 【清空队列】执行结束 i=3
// 【清空队列】执行结束 i=4
// 【清空队列】执行逻辑 i=0 第二段 将会模拟耗时=125
// 【清空队列】执行成功 i=0 result=小朋友-清空队列 0
// 【清空队列】执行结束 i=0

五、作者博客

掘金:juejin.im/user/5c3033…

csdn:blog.csdn.net/weixin_3762…

公众号:微信搜索 "江澎涌"

Vue-Data 属性避坑指南

2026年1月31日 22:34

前言

在 Vue 开发中,我们经常会遇到“明明修改了数据,视图却不更新”的尴尬场景。这通常与 Vue 的初始化顺序及响应式实现原理有关。本文将从 Data 属性的本质出发,解析响应式“丢失”的根本原因及解决方案。

一、 组件中的 Data 为什么必须是函数?

在 Vue 2 中,根实例的 data 可以是对象,但组件中的 data 必须是函数

核心原因:数据隔离

  • 对象形式:JavaScript 中的对象是引用类型。如果 data 是对象,所有组件实例将共享同一个内存地址。修改实例 A 的数据,实例 B 也会跟着变。
  • 函数形式:当 data 是一个函数时,每次创建新实例,Vue 都会调用该函数,返回一个全新的数据对象拷贝。这保证了每个组件实例数据的独立性。

二、 Props 与 Data 的优先级之争

在组件初始化时,Vue 会按照特定的顺序处理选项。

初始化顺序

PropsMethodsDataComputedWatch

因为 Props 最先被初始化,所以我们可以在 data 中直接引用 props 传来的值

// Vue 3 + TS 示例
const props = defineProps<{ initialCount: number }>();
const count = ref(props.initialCount); // 合法,因为 props 优先初始化

三、 Vue2动态添加新属性的“失效”困局

1. 故障场景

vue2中当我们直接给对象添加一个原本不存在的属性时,视图不会产生任何变化。

<p v-for="(value,key)in item" :key="key">
    {{ value }}
</p>
<button@click="addProperty">动态添加新属性</button>

const app = new Vue({
  el: '#app',
  data: {
    item: {
      oldProperty: 'l日属性'
    }
  },
  methods: {
    addProperty() {
      this.items.newProperty = '新属性'; // 为items添加新属性
      console.log(this.items); // 输出带有newProperty的items
    }
  }
})

2. 原因剖析

  • Vue 2 局限性:使用 Object.defineProperty 实现响应式。它只能劫持对象已有的属性。对于后来新增的属性,Vue 无法感知其 getter/setter,因此无法触发视图更新。
  • Vue 3 的进化:改用 Proxy 代理整个对象。Proxy 可以拦截到属性的新增与删除,因此 Vue 3 不再有这个问题。

四、 解决方案(Vue 2 必备技巧)

如果你仍在使用 Vue 2,可以通过以下三种方式解决:

1. 推荐方案:Vue.set / this.$set

这是最正统的方法,它会手动将新属性转为响应式,并触发依赖更新。

语法: this.$set(target, propertyName/index, value)

  • target:data中要修改的对象或者数组
  • propertyName/index:要添加或修改的属性名称(对于对象)或索引(对于数组)
  • value:要设置的值
addProperty() {
   this.$set(this.item, 'newProperty', '新属性'); 
}

2. 对象整体替换:Object.assign

通过创建一个包含新属性的新对象,并将这个新对象赋值给原有对象,触发 Vue 对原对象引用的变更感知。

addProperty() {
   this.item = Object.assign({}, this.item, { newProperty: '新属性' });
   // 或者使用展开运算符
   this.item = { ...this.item, newProperty: '新属性' };
}

3. 暴力方案:$forceUpdate

迫使 Vue 重新渲染组件。

  • 注意:这只是“治标”。虽然视图刷新了,但该属性依然不是响应式的。后续再次修改 newProperty 时,视图依然不会动。

五、 Vue 3 + TS 最佳实践

在 Vue 3 中,借助 TypeScript 的类型定义,我们可以规避大部分因“动态添加”导致的逻辑混乱。

<script setup lang="ts">
import { reactive } from 'vue';

// 定义接口,提前声明可选属性
interface Item {
  oldProperty: string;
  newProperty?: string; // 声明可选属性
}

const item = reactive<Item>({
  oldProperty: '旧属性'
});

const addProperty = () => {
  // Vue 3 Proxy 自动处理响应式,无需 $set
  item.newProperty = '新属性'; 
};
</script>

FossFLOW:开源等距图表工具,为技术文档注入立体活力!

作者 修己xj
2026年1月31日 20:12

文章简介:FossFLOW是一款创新的开源等距图表工具,专为技术文档设计。它通过立体视角将复杂的系统架构转化为直观的3D图表,支持拖放式操作和离线使用,让技术图表变得生动易懂。无需注册,数据安全存储在本地,并提供JSON导入导出功能。无论是Docker快速部署还是在线体验,FossFLOW都能为架构图、流程图注入立体活力,是提升技术文档表现力的得力助手。

你是否曾经为了绘制清晰的技术架构图或系统流程图而烦恼?是否觉得传统的平面图表难以表达复杂的层次关系?今天,我要向大家介绍一款令人惊艳的开源工具——FossFLOW,它能让你的技术图表瞬间变得立体、生动!

🌟 什么是FossFLOW?

FossFLOW 是一款功能强大的、开源的渐进式 Web 应用(PWA),专为创建精美的等距图表而设计。它基于 React 和 Isoflow(现已 fork 并以 fossflow 名称发布到 NPM)库构建,完全在浏览器中运行,并支持离线使用,让你随时随地都能创作出专业级的技术图表!

github地址:github.com/stan-smith/…

在线地址:stan-smith.github.io/FossFLOW/

该项目目前在github上已有17k ⭐️star

✨ 主要特性

🎨 立体图表,视觉升级

  • • 创建令人惊叹的3D风格技术图表
  • • 等距视角让复杂的系统架构一目了然
  • • 拖放式操作,简单直观

🔒 隐私优先,安全可靠

  • • 所有数据都存储在您的浏览器中
  • • 无需注册,无需上传
  • • 完全控制你的数据

🔄 导入导出,轻松分享

  • • JSON格式导入导出
  • • 快速分享你的设计
  • • 完整备份功能

🚀 快速上手

🐳Docker部署

创建docker-compose.yml文件,内容如下:

services:
  fossflow:
    image: stnsmith/fossflow:latest
    container_name: fossflow
    ports:
      - "5010:80"
    volumes:
      # 如果要禁用服务端存储,可以注释掉这行
      - ./diagrams:/data/diagrams
    environment:
      - TZ=Asia/Shanghai
      # 如果要启用服务端存储,注释掉下面这行
      # - ENABLE_SERVER_STORAGE="false"
    restart: unless-stopped

在docker-compose.yml 同级命令下使用以下命令启动

docker-compose up -d

到此,我们就部署完了,在浏览器中输入地址就可以访问了

🌐在线体验

直接访问:stan-smith.github.io/FossFLOW/

📱本地启动

# 克隆仓库
git clone https://github.com/stan-smith/FossFLOW
cd FossFLOW

# 安装依赖
npm install

# 启动开发服务器
npm start

🛠️ 使用指南

📈1. 创建图表

  • • 点击右上角"+"按钮打开组件库
  • • 从左侧拖放组件到画布
  • • 或右键网格选择"Add node"

🧩2. 连接组件

  • • 使用连接器显示组件关系
  • • 智能对齐,保持图表整洁
  • • 多层连接,表达复杂关系

✏️3. 自定义样式

  • • 更改颜色、标签和属性
  • • 调整位置和大小
  • • 添加说明文字

🎨4. 导航操作

  • • 鼠标滚轮放大缩小
  • • 点击拖动平移画布
  • • Ctrl+Z撤销,Ctrl+Y重做

🏗️ 技术栈

  • • React - 现代化的UI框架
  • • TypeScript - 类型安全的开发体验
  • • Isoflow - 强大的等距图表引擎
  • • PWA - 离线优先的Web应用架构

🚨缺点与不足

虽然该工具在基础功能方面表现良好,但在实际使用过程中仍存在一些明显的局限性与不足之处:

  • • 3D节点资源严重匮乏

    官方提供的3D节点类型极为有限,仅包含基础的几何形状和少数预设模型,无法满足复杂三维场景的构建需求。

  • • 第三方节点生态发展不完善

    第三方插件多为2D节点,在构建复杂三维场景时可能面临节点素材不足的问题。

  • • 快捷操作方式还有待改进

📝 最后的话

在技术文档越来越重要的今天,一个清晰、直观的图表往往胜过千言万语。FossFLOW以其独特的等距视角,为技术图表带来了全新的可能性。无论你是架构师、开发者、技术作家还是项目经理,这款工具都值得一试。

最重要的是,它是完全免费和开源的!你可以在GitHub上找到所有源代码,自由使用、学习和改进。

一天一个开源项目(第8篇):UI/UX Pro Max Skill - AI设计智能助手,让AI帮你构建专业UI/UX

作者 冬奇Lab
2026年1月31日 19:43

引言

"当AI能够理解设计系统、色彩理论、用户体验原则时,它就不再是简单的代码生成器,而是真正的设计伙伴。"

这是"一天一个开源项目"系列的第8篇文章。今天带你了解的项目是 UI/UX Pro Max SkillGitHub)。

如果你正在使用Claude Code、Cursor、Windsurf等AI编程助手,并且希望它们能够生成专业、美观、符合设计规范的UI/UX代码,那么UI/UX Pro Max Skill绝对值得你深入了解。它通过24.7k+ Stars的社区认可,证明了AI设计智能的巨大价值。

你将学到什么

  • UI/UX Pro Max Skill的核心架构和工作原理
  • 如何为不同AI助手安装和配置这个技能
  • 设计系统自动生成的机制和原理
  • 智能推荐系统如何工作
  • 如何构建分层设计系统(Master + Overrides)
  • 与手动设计相比的优势和局限性
  • 如何在实际项目中应用这个技能

前置知识

  • 对AI编程助手有基本了解(Claude Code、Cursor等)
  • 熟悉UI/UX设计的基本概念
  • 了解设计系统的基本组成
  • 对命令行工具有基本使用经验

项目背景

项目简介

08-01-ui-pro-website.png

UI/UX Pro Max Skill 是一个为AI编程助手提供设计智能的开源技能,通过内置的设计系统生成引擎和智能推荐系统,让AI助手能够生成专业、美观、符合设计规范的UI/UX代码。它不仅仅是一个代码生成工具,而是一个完整的设计智能系统。

项目解决的核心问题

  • AI生成的UI代码缺乏设计规范,样式混乱
  • 缺乏对色彩理论、排版、间距等设计原则的理解
  • 无法根据产品类型推荐合适的设计风格
  • 缺少设计系统的概念,代码难以维护
  • 不同AI助手平台需要重复配置设计知识

面向的用户群体

  • 使用AI编程助手的开发者
  • 需要快速生成专业UI的前端开发者
  • 缺乏设计背景但需要构建美观界面的开发者
  • 希望提升AI生成代码质量的团队
  • 需要统一设计系统的项目

作者/团队介绍

团队:nextlevelbuilder

  • 背景:专注于AI工具和设计智能的开源团队
  • 理念:让AI真正理解设计,而不仅仅是生成代码
  • 项目定位:AI设计智能的标准解决方案
  • 官网:ui-ux-pro-max-skill.nextlevelbuilder.io

项目创建时间:2024年(从GitHub活动来看是持续活跃的项目)

项目数据

  • GitHub Stars: 24.7k+(持续快速增长)
  • 🍴 Forks: 2.5k+
  • 📦 版本: v2.2.1(最新版本,2026年1月26日发布)
  • 📄 License: MIT(完全开源,自由使用)
  • 🌐 官网: ui-ux-pro-max-skill.nextlevelbuilder.io
  • 📚 文档: 包含完整的使用指南和API文档
  • 💬 社区: GitHub Issues和Discussions活跃
  • 👥 贡献者: 27位贡献者,活跃的社区参与

项目发展历程

  • 2024年:项目创建,开始构建核心设计智能引擎
  • 2024年中:添加多平台支持,扩展到20+AI助手
  • 2024年底:引入分层设计系统(Master + Overrides)
  • 2025年:完善CLI工具,优化安装体验
  • 2026年:持续优化,社区活跃度持续提升

主要功能

核心作用

UI/UX Pro Max Skill的核心作用是为AI编程助手注入设计智能,主要功能包括:

  1. 自动生成设计系统:根据产品类型自动生成完整设计系统(色彩、字体、间距、组件规范)
  2. 智能样式推荐:基于产品领域推荐最佳样式方案
  3. 代码质量保障:生成符合UI/UX最佳实践的代码,自动检测设计反模式
  4. 多平台支持:支持Web(HTML+Tailwind、React、Vue)、移动端(SwiftUI、Jetpack Compose)、跨平台(React Native、Flutter)
  5. 分层设计系统:支持Master设计系统和页面级覆盖

快速开始

安装方式

UI/UX Pro Max Skill支持多种安装方式:

方式1:通过Claude Marketplace(Claude Code)

# 在Claude Code中直接安装
/plugin marketplace add nextlevelbuilder/ui-ux-pro-max-skill
/plugin install ui-ux-pro-max@ui-ux-pro-max-skill

方式2:使用CLI工具(推荐)

# 安装CLI工具
npm install -g uipro-cli

# 为你的AI助手安装技能
cd /path/to/your/project

# 支持多种AI助手
uipro init --ai claude      # Claude Code
uipro init --ai cursor      # Cursor
uipro init --ai windsurf    # Windsurf
uipro init --ai antigravity # Antigravity
uipro init --ai copilot     # GitHub Copilot
uipro init --ai codex       # Codex CLI
uipro init --ai gemini       # Gemini CLI
uipro init --ai opencode    # OpenCode
uipro init --ai all         # 所有支持的助手

其他CLI命令

uipro versions      # 查看可用版本
uipro update        # 更新到最新版本
uipro init --offline  # 离线安装(使用本地资源)

前置要求

Python 3.x是必需的(用于搜索脚本):

# 检查Python版本
python3 --version

# macOS安装
brew install python3

# Ubuntu/Debian安装
sudo apt update && sudo apt install python3

# Windows安装
winget install Python.Python.3.12

最简单的使用示例

Skill模式(自动激活)

支持的平台:Claude Code, Windsurf, Antigravity, Codex CLI, Continue, Gemini CLI, OpenCode, Qoder, CodeBuddy

# 直接对话,技能会自动激活
Build a landing page for my SaaS product

Workflow模式(斜杠命令)

支持的平台:Cursor, Kiro, GitHub Copilot, Roo Code

# 使用斜杠命令
/ui-ux-pro-max Build a landing page for my SaaS product

示例提示词

Build a landing page for my SaaS product
Create a dashboard for healthcare analytics
Design a portfolio website with dark mode
Make a mobile app UI for e-commerce
Build a fintech banking app with dark theme

我通过一行提示词生成的项目管理网站首页UI界面

08-02-ui-pro-demo.png

核心特性

  • 自动设计系统生成:根据产品类型生成完整设计系统,支持10+个专业领域知识库
  • 智能推荐引擎:基于产品类型推荐最佳样式,考虑用户体验和可访问性
  • 多技术栈支持:Web(HTML+Tailwind、React、Vue等)、iOS(SwiftUI)、Android(Jetpack Compose)、跨平台(React Native、Flutter)
  • 分层设计系统:Master设计系统 + 页面级覆盖,智能检索和优先级管理
  • 设计规范检查:自动检测UI/UX反模式,提供改进建议
  • CLI工具:统一安装管理,支持离线安装和版本管理
  • 模板系统:基于模板的代码生成,平台特定模板支持

项目优势

对比项 UI/UX Pro Max Skill 手动设计 其他AI设计工具
设计智能 ✅ 内置完整设计系统 ❌ 需要手动设计 ⚠️ 有限设计知识
多平台支持 ✅ 20+AI助手 ❌ 无 ⚠️ 单一平台
自动推荐 ✅ 智能样式推荐 ❌ 需要经验 ⚠️ 基础推荐
设计系统 ✅ 自动生成 ❌ 手动构建 ⚠️ 部分支持
代码质量 ✅ 符合最佳实践 ⚠️ 依赖经验 ⚠️ 不一致
学习曲线 ✅ 开箱即用 ❌ 需要学习 ⚠️ 需要配置
社区支持 ✅ 24.7k+ Stars ❌ 无 ⚠️ 有限

为什么选择UI/UX Pro Max Skill?

相比手动设计和其他AI设计工具,UI/UX Pro Max Skill提供完整的设计系统生成引擎、智能推荐、20+AI助手支持、10+领域知识库,开箱即用,社区活跃(24.7k+ Stars)。


项目详细剖析

架构设计

UI/UX Pro Max Skill采用模块化、可扩展的架构,主要包含以下几个核心模块:

核心架构

UI/UX Pro Max Skill
├── CLI工具层
│   ├── 安装器(uipro-cli)
│   ├── 模板生成系统
│   └── 版本管理
├── 技能层(.claude/skills/ui-ux-pro-max)
│   ├── 技能定义文件
│   ├── 提示词模板
│   └── 平台适配器
├── 数据层(data/)
│   ├── 设计知识库(CSV格式)
│   ├── 样式数据库
│   ├── 色彩方案库
│   └── 字体库
├── 脚本层(scripts/)
│   ├── search.py(搜索和推理引擎)
│   ├── 设计系统生成器
│   └── 推荐算法
└── 模板层(templates/)
    ├── 平台特定模板
    ├── 组件模板
    └── 页面模板

设计系统生成引擎

核心是Python脚本search.py,实现设计系统的自动生成。流程包括:1) 分析产品类型和需求;2) 从知识库检索设计知识(色彩、字体、样式、组件、领域特定知识);3) 应用推理引擎生成设计系统;4) 验证和优化设计系统。

智能推荐系统

推荐系统基于产品类型和领域知识进行智能匹配,推荐维度包括:色彩方案(基于产品类型、目标用户、品牌调性、可访问性)、字体方案(基于可读性和品牌)、间距系统(基于平台和内容)、组件风格(基于交互需求)。通过相似度计算返回Top 3推荐方案。

分层设计系统(Master + Overrides)

支持分层设计系统,Master系统包含全局色彩、字体、间距、组件规范,页面覆盖只包含与Master不同的规则。检索逻辑:如果指定页面,先检查页面覆盖文件;如果存在,合并Master和页面规则;否则只使用Master规则。

知识库系统

UI/UX Pro Max Skill的核心是设计知识库,包含10+个领域的专业设计知识:

知识库结构

data/
├── colors.csv          # 色彩方案库
├── typography.csv      # 字体方案库
├── styles.csv          # 样式方案库
├── components.csv      # 组件规范库
└── domain/             # 领域特定知识
    ├── saas.csv        # SaaS产品设计
    ├── ecommerce.csv   # 电商设计
    ├── fintech.csv     # 金融科技设计
    ├── healthcare.csv  # 医疗健康设计
    ├── education.csv   # 教育设计
    └── ...

知识库内容示例

色彩方案库(colors.csv)

product_type,industry,tone,primary_color,secondary_color,accent_color,background_color,text_color
SaaS,B2B,professional,#2563EB,#1E40AF,#3B82F6,#FFFFFF,#1F2937
SaaS,B2C,friendly,#10B981,#059669,#34D399,#F9FAFB,#111827
Ecommerce,Retail,vibrant,#EC4899,#DB2777,#F472B6,#FFFFFF,#1F2937
Fintech,Finance,trustworthy,#1E40AF,#1E3A8A,#3B82F6,#F8FAFC,#0F172A
Healthcare,Medical,calm,#059669,#047857,#10B981,#FFFFFF,#064E3B

字体方案库(typography.csv)

product_type,heading_font,body_font,font_size_scale,line_height_scale
SaaS,Inter,Inter,1.25,1.5
Ecommerce,Poppins,Inter,1.2,1.6
Fintech,Roboto,Roboto,1.15,1.5
Healthcare,Open Sans,Open Sans,1.3,1.7

领域特定知识

每个领域都有专门的设计知识库,包含:

  • 行业最佳实践:该领域的UI/UX最佳实践
  • 用户行为模式:目标用户的使用习惯
  • 设计趋势:当前流行的设计风格
  • 可访问性要求:行业特定的可访问性标准
  • 合规要求:法律法规要求(如金融、医疗)

多平台适配系统

UI/UX Pro Max Skill通过模板系统支持多种AI助手平台:

平台适配架构

通过PlatformAdapter接口实现平台适配,每个AI助手(Claude Code、Cursor等)都有独立的适配器。Claude Code支持自动激活,Cursor使用斜杠命令。CLI工具使用模板系统动态生成平台特定文件,从templates/目录加载模板并生成技能文件、配置文件和脚本。

设计规范检查系统

UI/UX Pro Max Skill内置设计规范检查,自动检测常见反模式:

反模式检测

设计规范检查系统自动检测常见反模式:色彩对比度不足(WCAG AA标准)、字体大小过小、间距不一致、组件使用不当、可访问性问题。检测器解析代码中的设计元素,对照设计系统规范,返回问题列表和改进建议。

工作流程

UI/UX Pro Max Skill的完整工作流程:

用户请求UI/UX任务
    ↓
技能自动激活(Skill模式)或通过命令激活(Workflow模式)
    ↓
分析产品类型和需求
    ↓
从知识库检索相关设计知识
    ↓
应用推理引擎生成设计系统
    ↓
智能推荐最佳样式方案
    ↓
生成符合设计系统的代码
    ↓
执行设计规范检查
    ↓
返回生成的代码和改进建议

项目地址与资源

官方资源

适用人群

UI/UX Pro Max Skill特别适合:需要快速生成专业UI代码的前端开发者、缺乏设计背景的全栈开发者、需要建立设计系统的项目、使用Claude Code/Cursor等AI助手的开发者、需要统一设计规范的团队。

不适合:不需要AI辅助的资深设计师、只需要简单代码生成的用户、不使用AI编程助手的开发者。


欢迎来我中的个人主页找到更多有用的知识和有趣的产品

昨天 — 2026年1月31日掘金 前端

【节点】[VertexID节点]原理解析与实际应用

作者 SmalBox
2026年1月31日 17:51

【Unity Shader Graph 使用与特效实现】专栏-直达

在Unity的可编程渲染管线中,Shader Graph为开发者提供了可视化编写着色器的能力,而Vertex ID节点则是其中一个功能强大但常被忽视的重要工具。Vertex ID节点允许着色器访问当前处理的顶点或片元的唯一标识符,为各种高级渲染技术提供了基础支持。

Vertex ID节点概述

Vertex ID节点的核心功能是输出当前正在处理的顶点或片元在网格中的索引值。这个索引值从0开始,按照网格顶点缓冲区的顺序递增。在顶点着色器阶段,它代表顶点的索引;在片元着色器阶段,它代表生成该片元的顶点的索引。

工作原理与底层机制

Vertex ID的实现依赖于GPU的顶点着色器输入语义。在HLSL中,这通常对应着SV_VertexID系统值语义。当Unity提交绘制调用时,GPU会为每个处理的顶点分配一个唯一的ID,这个ID基于顶点在顶点缓冲区中的位置。

在传统的编写着色器代码方式中,开发者会这样声明和使用Vertex ID:

HLSL

truct appdata
{
    uint vertexID : SV_VertexID;
};

而在Shader Graph中,这个过程被简化为简单地添加和连接Vertex ID节点,大大降低了使用门槛。

节点特性与限制

Vertex ID节点有几个重要特性需要注意:

  • 输出值为浮点数类型,范围从0到网格顶点数减1
  • 在顶点着色器和片元着色器中均可使用
  • 值在单个绘制调用中保持唯一性和连续性
  • 不受网格变形或动画影响,始终反映原始网格的顶点顺序

同时也有一些使用限制:

  • 不能用于计算着色器
  • 在某些移动设备上可能有限制或性能考虑
  • 对于动态批处理的物体,Vertex ID可能不会按预期工作

Vertex ID节点的应用场景

Vertex ID节点在Shader Graph中有着广泛的应用场景,从简单的效果到复杂的渲染技术都能发挥作用。

顶点级动画与变形

利用Vertex ID可以实现基于顶点索引的动画效果,比如波浪效果、随机偏移等。由于每个顶点都有唯一的ID,可以基于ID计算不同的变换参数。

HLSL

// 伪代码示例:基于Vertex ID的波浪动画
float wave = sin(_Time.y * _WaveSpeed + vertexID * _WaveDensity);
float3 offset = float3(0, wave * _WaveHeight, 0);
position.xyz += offset;

程序化纹理坐标生成

当网格缺乏合适的UV坐标时,可以使用Vertex ID来生成程序化的纹理映射。这在处理程序化生成的几何体时特别有用。

HLSL

// 伪代码示例:基于Vertex ID生成UV
float2 uv = float2(frac(vertexID * _UVScale), floor(vertexID * _UVScale) / _GridSize);

实例化与批量渲染优化

在GPU实例化场景中,Vertex ID可以与其他系统值(如Instance ID)结合使用,实现高效的批量渲染和数据索引。

调试与可视化工具

Vertex ID是强大的调试工具,可以用于:

  • 可视化顶点分布和顺序
  • 检测顶点缓冲区问题
  • 理解网格拓扑结构

实际应用示例

下面通过几个具体的Shader Graph设置示例,展示Vertex ID节点的实际应用。

波浪地形效果

创建一个基于Vertex ID的波浪地形效果:

  • 首先在Shader Graph中创建Vertex ID节点
  • 将输出连接到Custom Function节点进行波浪计算
  • 使用Time节点提供动画参数
  • 将计算结果连接到Position节点的偏移量

关键节点设置:

  • Vertex ID → Custom Function (波浪计算) → Add to Position
  • Time → Multiply (控制速度) → Custom Function
  • 参数输入:波浪幅度、频率、传播速度

这种设置可以实现流畅的波浪动画,每个顶点基于其ID产生相位偏移,形成自然的波浪传播效果。

顶点颜色渐变

使用Vertex ID创建沿着顶点顺序的颜色渐变:

  • Vertex ID节点输出除以网格顶点总数,归一化到[0,1]范围
  • 将归一化值输入到Gradient节点
  • 将Gradient输出连接到Base Color

这种方法特别适合线框渲染或几何可视化,可以清晰展示顶点的顺序和分布。

程序化网格变形

结合Vertex ID和数学节点创建复杂的网格变形:

  • 使用Vertex ID作为噪声函数的输入种子
  • 通过不同的数学运算(sin、cos、fract等)创建各种变形模式
  • 将变形结果应用到顶点位置

这种技术可以创建有机的、程序化的形状变化,无需额外的纹理或顶点数据。

性能优化与最佳实践

正确使用Vertex ID节点需要考虑性能因素和最佳实践。

性能考虑

  • 在移动平台上,尽量减少基于Vertex ID的复杂计算
  • 避免在片元着色器中使用Vertex ID进行每帧重计算
  • 考虑使用顶点着色器计算并将结果传递给片元着色器

兼容性处理

  • 使用Shader Graph的节点功能检查目标平台的兼容性
  • 为不支持Vertex ID的平台提供fallback方案
  • 测试在不同图形API下的行为一致性

调试技巧

  • 使用Vertex ID可视化来理解网格结构
  • 结合RenderDoc等工具分析实际的Vertex ID分布
  • 创建调试着色器来验证Vertex ID的预期行为

高级应用技巧

与其他系统值的结合

Vertex ID可以与其他系统值结合使用,创造更复杂的效果:

  • 结合Instance ID实现每实例的顶点变形
  • 与Primitive ID配合实现基于图元的特效
  • 和Screen Position结合创建屏幕相关的顶点动画

自定义函数封装

对于复杂的Vertex ID应用,可以创建自定义HLSL函数节点:

HLSL

void VertexIDAnimation_float(float VertexID, float Time, float Amplitude, float Frequency, out float3 Offset)
{
    float phase = VertexID * Frequency + Time;
    Offset = float3(0, sin(phase) * Amplitude, 0);
}

这样可以在多个Shader Graph中重用复杂的Vertex ID逻辑。

数据驱动的方法

将Vertex ID与外部数据结合:

  • 使用Compute Buffer存储每顶点的动画参数
  • 通过MaterialPropertyBlock传递顶点级别的数据
  • 结合Scriptable Renderer Features实现更高级的渲染管线集成

故障排除与常见问题

Vertex ID输出异常

当Vertex ID不按预期工作时,可能的原因包括:

  • 网格被动态批处理,改变了顶点顺序
  • 使用了不支持的渲染路径
  • 图形API限制

解决方案:

  • 禁用动态批处理
  • 检查目标平台的图形API支持
  • 使用Shader Variant收集器确保所有需要的变体都被编译

性能问题

基于Vertex ID的效果导致性能下降时的优化策略:

  • 将计算从片元着色器移到顶点着色器
  • 使用LOD系统在远距离简化效果
  • 预计算静态效果到顶点颜色或纹理中

平台兼容性

处理不同平台的兼容性问题:

  • 为OpenGL ES 2.0等老旧平台提供简化版本
  • 使用Shader Graph的Keyword系统管理平台特定代码
  • 进行充分的跨平台测试

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

Nest 的中间件 Middleware ?

作者 前端付豪
2026年1月31日 17:30

新建项目

nest new middleware-demo

创建一个中间件

nest g middleware aaa --no-spec --flat

image.png

加下打印

import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';

@Injectable()
export class AaaMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: () => void) {
    console.log('brefore');
    next();
    console.log('after');
  }
}

在 Module 里这样使用

import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AaaMiddleware } from './aaa.middleware';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(AaaMiddleware).forRoutes('*');
  }
}

跑起来看看

image.png

可以指定更精确的路由,添加几个 handler

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('hello')
  getHello(): string {
    console.log('hello');
    return this.appService.getHello();
  }

  @Get('hello2')
  getHello2(): string {
    console.log('hello2');
    return this.appService.getHello();
  }

  @Get('hi')
  getHi(): string {
    console.log('hi');
    return this.appService.getHello();
  }

  @Get('hi1')
  getHi1(): string {
    console.log('hi1');
    return this.appService.getHello();
  }
}

module 匹配更新下

import { AaaMiddleware } from './aaa.middleware';
import {
  MiddlewareConsumer,
  Module,
  NestModule,
  RequestMethod,
} from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(AaaMiddleware)
      .forRoutes({ path: 'hello/*path', method: RequestMethod.GET });
    consumer
      .apply(AaaMiddleware)
      .forRoutes({ path: 'hi1', method: RequestMethod.GET });
  }
}

image.png

Nest 为什么要把 Middleware 做成 class 呢?

为了依赖注入!

通过 @Inject 注入 AppService 到 middleware 里

import { AppService } from './app.service';
import { Inject, Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';

@Injectable()
export class AaaMiddleware implements NestMiddleware {
  @Inject(AppService)
  private readonly appService: AppService;

  use(req: Request, res: Response, next: () => void) {
    console.log('brefore');
    console.log('-------' + this.appService.getHello());
    next();
    console.log('after');
  }
}

image.png

这就是 Nest 注入的依赖

Vue2(三)——模板语法

2026年1月31日 17:18

v2.cn.vuejs.org/v2/guide/sy…

Vue.js 使用了基于 HTML 的模板语法,允许开发者声明式地将 DOM 绑定至底层 Vue 实例的数据。所有 Vue.js 的模板都是合法的 HTML,所以能被遵循规范的浏览器和 HTML 解析器解析。

在底层的实现上,Vue 将模板编译成虚拟 DOM 渲染函数。结合响应系统,Vue 能够智能地计算出最少需要重新渲染多少组件,并把 DOM 操作次数减到最少。

如果你熟悉虚拟 DOM 并且偏爱 JavaScript 的原始力量,你也可以不用模板,直接写渲染 (render) 函数,使用可选的 JSX 语法。


上面这段原封不动,来自官方,我比较菜,看不懂,下面AI的分析

第一层:写什么?—— 声明式模板 (Declarative Templates)

原文: “Vue.js 使用了基于 HTML 的模板语法... 声明式地将 DOM 绑定至数据... 所有模板都是合法的 HTML。”

这讲的是 Vue 的**“门面”**。

  1. 声明式 (Declarative) vs 命令式 (Imperative):
    • 命令式(如 jQuery): 你需要一步步告诉浏览器怎么做(“找到 id 为 app 的 div,清空内容,插入一个 span,设置 span 的文字...”)。
    • 声明式(Vue): 你只告诉 Vue 你想要什么结果(“这里要显示 {{ message }}”),具体的脏活累活(DOM 操作)交给 Vue 去处理。
  1. 基于 HTML:
    • 这大大降低了学习门槛。设计师或后端开发人员也能看懂 Vue 代码,因为它长得就像普通的 HTML。
    • 这也意味着现有的 HTML 解析器都能处理它,不会像某些非标语法那样导致编辑器报错。

第二层:怎么变?—— 编译与虚拟 DOM (Compilation & VDOM)

原文: “在底层的实现上,Vue 将模板编译成虚拟 DOM 渲染函数。”

这讲的是 Vue 的**“转换过程”**。浏览器其实看不懂 v-if{{ }},所以 Vue 在代码运行前(或运行时)做了一次“翻译”。

  1. 编译 (Compile):

Vue 有一个编译器,它会把你的 HTML 模板字符串“翻译”成一段 JavaScript 代码。这段代码就叫 渲染函数 (Render Function)

  1. 虚拟 DOM (Virtual DOM):

渲染函数执行后,不会直接去动真的 DOM(因为操作真 DOM 很慢),而是生成一个 JavaScript 对象树,这个对象树就是虚拟 DOM。它就像是真实 DOM 的一份“轻量级蓝图”。

举个例子:

  • 你的模板:

HTML

<div :id="dynamicId">Hello</div>
  • Vue 编译后的渲染函数 (伪代码):

JavaScript

function render() {
  return h('div', { id: this.dynamicId }, 'Hello')
}

第三层:怎么跑?—— 响应式与智能更新 (Reactivity & Diffing)

原文: “结合响应系统,Vue 能够智能地计算出... 把 DOM 操作次数减到最少。”

这讲的是 Vue 的**“超能力”**。

  1. 响应系统 (Reactivity):

Vue 会“监视”你的数据。当数据变化时,它不仅知道数据变了,还精确地知道哪个组件依赖了这个数据。

  1. Diff 算法 (最小化更新):

当数据变化,渲染函数会重新执行,生成新的虚拟 DOM 树。

Vue 会拿着 新树旧树 做对比(Diff)。

    • Vue 发现: “哦,只有这个 div class 变了,其他都没变。”
    • 结果:Vue 只去更新真实 DOM 里那个 divclass,其他不动。

这就是为什么 Vue 即使在处理庞大页面时依然很快的原因。


第四层:给高手的“后门” —— Render 函数 & JSX

原文: “如果你熟悉虚拟 DOM 并且偏爱 JavaScript 的原始力量... 直接写渲染 (render) 函数...”

这段话是说,模板虽然好用,但有时候不够灵活。

  • 模板的局限: 比如你要写一个组件,根据 props 动态生成 h1h6 标签。用模板写,你可能得写 6 个 v-if
  • JS 的力量: 如果用渲染函数,你只需要写一行 JS:return h('h' + this.level, ...)

Vue 并不强迫你用模板,它完全支持你像 React 那样写代码(JSX),这给了高级开发者极大的灵活性。

总结分析

这段话其实揭示了 Vue 的架构分层

层次 作用 对应原文
顶层 (API) 易用性 基于 HTML 的模板,声明式绑定
中间层 (Compiler) 转化 模板 \rightarrow 渲染函数 \rightarrow 虚拟 DOM
底层 (Engine) 性能 响应式系统 + 智能 Diff 计算
扩展层 (Flexibility) 灵活性 可选 JSX / Render 函数

插值

文本

双大括号 Mustache

<span>Message: {{ msg }}</span>

Mustache 标签将会被替代为对应数据对象上 msg property 的值。无论何时,绑定的数据对象上 msg property 发生了改变,插值处的内容都会更新。

可以使用v-once ,只执行一次插值,后面值变化不会更新

<span v-once>这个将不会改变: {{ msg }}</span>

原始HTML

v-html

<p>Using mustaches: {{ rawHtml }}</p>
<p>Using v-html directive: <span v-html="rawHtml"></span></p>

整个span 的内容 会被替换成 property 值 rawHtml直接作为 HTML

注意:XSS 攻击,并且只支持常规html,不支持解析自定义组件

Attribute

v-bind 执行 绑定 Attribute

<div v-bind:id="dynamicId"></div>

注意与 HTML的不同 ( https://juejin.cn/post/7598447519823101993 里面的布尔属性提到了)

对于布尔 attribute (它们只要存在就意味着值为 true),v-bind 有不同,在这个例子中:

<button v-bind:disabled="isButtonDisabled">Button</button>

如果 isButtonDisabled 的值是 nullundefinedfalse,则 disabled attribute 甚至不会被包含在渲染出来的 <button> 元素中。

使用 JavaScript 表达式

插值模板还支持三目运算符和数学表达式,以及库函数操作

{{ number + 1 }}

{{ ok ? 'YES' : 'NO' }}

{{ message.split('').reverse().join('') }}

<div v-bind:id="'list-' + id"></div>

错误例子

<!-- 这是语句,不是表达式 -->
{{ var a = 1 }}

<!-- 流控制也不会生效,请使用三元表达式 -->
{{ if (ok) { return message } }}

模板表达式都被放在沙盒中,只能访问全局变量的一个白名单,如 MathDate 。你不应该在模板表达式中试图访问用户定义的全局变量。

大白话就是指定访问 new Vue({}) 时传入的对象里面的 data,methods computed等,以及库函数,外部定义的变量,只要没传入 new Vue({}) 里面就不能访问

指令

指令 (Directives) 是带有 v- 前缀的特殊 attribute。

指令 attribute 的值预期是单个 JavaScript 表达式 (v-for 是例外情况,稍后我们再讨论)。

当表达式的值改变时,将影响DOM

即 Model 影响 -> DOM

<p v-if="seen">现在你看到我了</p>

v-if 指令将根据表达式 seen 的值的真假来插入/移除 <p> 元素

参数

一些指令能够接收一个“参数”,在指令名称之后以冒号表示。

例如,v-bind 指令可以用于响应式地更新 HTML attribute:

<a v-bind:href="url">...</a>

在这里 href 是参数,告知 v-bind 指令将该元素的 href attribute 与表达式 url 的值绑定。

另一个例子是 v-on 指令,它用于监听 DOM 事件:

<a v-on:click="doSomething">...</a>

在这里参数是监听的事件名。我们也会更详细地讨论事件处理。

动态参数

用方括号括起来的 JavaScript 表达式作为一个指令的参数:

<!--
注意,参数表达式的写法存在一些约束,如之后的“对动态参数表达式的约束”章节所述。
-->
<a v-bind:[attributeName]="url"> ... </a>

这里的 attributeName 会被作为一个 JavaScript 表达式进行动态求值,求得的值将会作为最终的参数来使用。例如,如果你的 Vue 实例有一个 data property attributeName,其值为 "href",那么这个绑定将等价于 v-bind:href

使用动态参数为一个动态的事件名绑定处理函数:

<a v-on:[eventName]="doSomething"> ... </a>

在这个示例中,当 eventName 的值为 "focus" 时,v-on:[eventName] 将等价于 v-on:focus

动态参数值的约束

  • 预期是求出一个字符串
  • 异常情况为null,null表示移除该绑定
  • 动态参数表达式有一些语法约束,因为某些字符,如空格和引号,放在 HTML attribute 名里是无效的。
<!-- 这会触发一个编译警告 -->
<a v-bind:['foo' + bar]="value"> ... </a>

使用没有空格或引号的表达式,或者计算属性替代。

在 DOM 中使用模板时 (直接在一个 HTML 文件里撰写模板),还需要避免使用大写字符来命名键名,因为浏览器会把 attribute 名全部强制转为小写:

<!--
在 DOM 中使用模板时这段代码会被转换为 `v-bind:[someattr]`。
除非在实例中有一个名为“someattr”的 property,否则代码不会工作。
-->
<a v-bind:[someAttr]="value"> ... </a>

例子如下

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <!-- 开发环境版本,包含了有帮助的命令行警告 -->
    <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  </head>
  <body>
    <div id="app">
      <a v-bind:[someAttr]="value">掘金</a>
    </div>
  </body>
  <script>
    var app = new Vue({
      el: "#app",
      data: {
        value: "https://https://juejin.cn/",
        someAttr: "href",
      },
    });
  </script>
</html>

修改之后的代码

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <!-- 开发环境版本,包含了有帮助的命令行警告 -->
    <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  </head>
  <body>
    <div id="app">
      <a v-bind:[someAttr]="value">掘金</a>
    </div>
  </body>
  <script>
    var app = new Vue({
      el: "#app",
      data: {
        value: "https://https://juejin.cn/",
        someattr: "href",
      },
    });
  </script>
</html>

修饰符

修饰符 (modifier) 是以半角句号 . 指明的特殊后缀

可以用于阻止事件冒泡或者阻止表单提交

例如,.prevent 修饰符告诉 v-on 指令对于触发的事件调用 event.preventDefault()

<form v-on:submit.prevent="onSubmit">...</form>

缩写

缩写不是必须的

v-bind 缩写

<!-- 完整语法 -->
<a v-bind:href="url">...</a>

<!-- 缩写 -->
<a :href="url">...</a>

<!-- 动态参数的缩写 (2.6.0+) -->
<a :[key]="url"> ... </a>

v-on 缩写

<!-- 完整语法 -->
<a v-on:click="doSomething">...</a>

<!-- 缩写 -->
<a @click="doSomething">...</a>

<!-- 动态参数的缩写 (2.6.0+) -->
<a @[event]="doSomething"> ... </a>

CSS动画的灵动之吻:从代码到情感的生动演绎

作者 Lee川
2026年1月31日 17:01

CSS动画的灵动之吻:从代码到情感的生动演绎

在Web前端的世界里,CSS不仅仅是定义颜色和布局的工具,它更是一门能够赋予静态元素生命与情感的艺术。今天,我们将通过一个名为“双球亲吻”的生动案例,深入剖析如何利用CSS关键帧动画(@keyframes)和精细的类名设计,让两个简单的圆形化身为含情脉脉的男女主角,上演一场温馨的互动。

一、 动画蓝图:整体构思与HTML结构

任何精彩的动画都始于一个清晰的故事板。我们的故事很简单:画面中央,代表“女主”的白色圆球(#l-ball)在原地微微靠近又远离,而代表“男主”的圆球(#r-ball)则会主动“亲吻”女主。

HTML结构(文档1)体现了“面向对象CSS(OOCSS)”的思想,这是代码严谨性的基石:

<div class="container">
    <!-- 女主 -->
    <div class="ball" id="l-ball">...</div>
    <!-- 男主 -->
    <div class="ball" id="r-ball">...</div>
</div>

设计解析:

  • 容器居中.container使用了经典的“绝对定位 + transform: translate”方法实现水平垂直居中,这是一种与元素尺寸无关的优雅居中方案。
  • 类名复用:两个球都使用了 .ball基类,定义了它们共有的样式:尺寸、圆形边框、白色背景等。这遵循了“不要重复自己(DRY)”原则,避免了代码冗余。
  • ID与角色:通过 #l-ball#r-ball这两个ID,我们为两个角色赋予了独立的“身份”,以便后续为它们定制不同的动画行为。

二、 角色塑造:面相与情绪的CSS实现

角色的“面部”细节(眼睛、嘴巴、腮红)是传递情绪的关键。这里大量运用了CSS伪元素和“多态”类名。

1. 面部基类与“腮红”特效

.face类是所有面部元素的定位上下文。其最巧妙的设计在于使用 ::before::after伪元素来创建腮红。

.face::after, .face::before {
    content: ""; /* 必须存在 */
    position: absolute;
    width: 18px;
    height: 8px;
    background-color: #badc58; /* 腮红颜色 */
    top: 20px;
    border-radius: 50%;
}
  • 为什么用伪元素? 腮红是纯粹的装饰性内容,不应污染HTML结构。伪元素完美解决了这个问题,使得HTML保持简洁。
  • 定位:通过 right: -8pxleft: -5px将两个腮红定位在面部元素之外,模拟出球体上的红晕。

2. 眼睛与嘴巴的“多态”

眼睛(.eye)通过边框(border-bottom)巧妙地画出了下半圆,营造出可爱的感觉。而男主的眼睛需要表现出与女主不同的神态(比如挑眉),这里通过“多态”类名 .eye-r-p来实现。

.eye {
    border-bottom: 5px solid; /* 默认向下看 */
}
.eye-r-p {
    border-top: 5px solid; /* 改为向上看 */
    border-bottom: 0px solid;
}

设计解析.eye是基类,定义了眼睛的基本形状。.eye-r-p是一个修饰类,通过覆盖边框样式,改变了眼睛的方向。这种设计使得我们只需在HTML中为男主的眼睛添加这个类,就能轻松改变其神态,这正是“多态”的威力。

三、 生命注入:核心动画的时序与协作逻辑

整个动画的精髓在于四个核心动画的精密配合,它们共享一个4秒(4s)的周期并无限循环(infinite),但各自的节奏(关键帧百分比)不同。

1. 女主的矜持:靠近与停留(#l-ball.face-l

  • 身体动画 (#l-ball- close)

    • 0%-20%: 从原点平滑向右移动20px(向男主靠近)。
    • 20%-35%在右侧停留。这个停留至关重要,它为男主的亲吻动作创造了“目标”和“时机”。
    • 35%-55%: 平滑返回原点。
    • 55%-100%: 在原点长时间停留,等待下一个周期。
  • 面部动画 (.face-l- face)

    这个动画让女主的头部在移动时伴有轻微的转动(rotate),增加了拟人化的生动感。其关键帧与身体动画同步,在20%35%(即身体停留时)触发头部微调,仿佛在害羞地调整姿态。

为什么这样设计? 将身体和面部的动画分离,符合“单一职责原则”。#l-ball控制宏观位移,.face-l控制细微表情。两者协同,共同塑造出一个完整、生动的角色。

2. 男主的主动:亲吻的爆发(#r-ball及其组件)

男主的动画是故事的高潮,由三个部分精密协作完成:

  • 身体冲刺 (#r-ball- kiss)

    • 40%-50%: 在女主停留的期间(20%-35%之后),男主迅速向右移动并旋转,做出“探头亲吻”的动作。
    • 50%-60%: 快速向左回弹(translate(-33px)),这个回弹的幅度甚至超过了初始位置,模拟出亲吻的冲击力。
    • 67%-77%: 缓慢移回原点。
  • 嘴巴消失与爱心出现(.mouth-r.kiss-m

    这是最精彩的部分,通过透明度(opacity 的切换实现“变脸”。

    • 嘴巴 (.mouth-r - mouth-m) : 在 55%关键帧瞬间变为透明(opacity: 0),在 66%关键帧瞬间恢复。这意味着在亲吻动作发生时,男主的嘴巴“消失”了。
    • 爱心 (.kiss-m - kiss-m) : 它的显示(opacity: 1)时间被精确地设置在 66%,仅持续0.1%的时间(约0.004秒)。同时,嘴巴在 66%恢复。

为什么这样设计? 这创造了一个视觉魔术:在 55%66%之间,嘴巴消失,爱心出现。虽然爱心只闪现了一瞬间,但由于人眼的“视觉暂留”效应,我们会感觉在亲吻的刹那,男主的嘴巴变成了一颗爱心。这种通过极短时间显示替代元素来制造特效的手法,在CSS动画中非常经典且高效。

四、 深度优化:Z轴与时序的严谨性

  1. 层级控制 (Z-index) : 为 #l-ball设置了较大的 z-index: 100,确保女主始终在前景。这样在男主亲吻回弹时,不会出现不合理的重叠穿帮,保证了视觉逻辑的正确。
  2. 时序的严谨性: 所有动画的 animation-timing-function都设置为 ease,这使得动作的开始和结束更平滑自然,符合真实物体的运动规律。关键帧百分比的设定需要反复调试,以达到动作间无缝衔接的效果。

总结与复习指南

这个“双球亲吻”动画是一个绝佳的CSS动画综合练习案例。要复习此项目,您可以遵循以下步骤:

  1. 重构HTML: 根据文档1,仅凭记忆写出结构清晰的HTML,注意基类与修饰类的应用。

  2. 还原静态样式: 先实现两个球的静态样式,包括居中、基本形状、面部五官。重点练习伪元素(腮红)和边框画图法(眼睛)。

  3. 分步添加动画

    • 第一步:实现女主球的水平移动动画(close)。
    • 第二步:为女主添加面部微动画(face),注意与身体动画的同步。
    • 第三步:实现男主球的冲刺与回弹动画(kiss)。
    • 第四步(关键) :实现嘴巴和爱心的透明度切换动画,仔细体会 mouth-mkiss-m中关键帧百分比的设计意图,理解“视觉暂留”特效的实现原理。

通过这样的分解与重构,您不仅能牢固掌握这个动画的制作过程,更能深刻理解CSS动画设计的核心思想:将复杂动作拆解为独立的、可复用的属性变化,并通过精确的时序控制将它们组合起来,最终赋予元素生命。

从远程组件到极致性能:一次低代码架构的再思考

作者 小蜗1号
2026年1月31日 16:21

前段时间突然回顾了一下之前做过的一件事:上一份工作的核心任务之一,其实就是一个可视化 / 低代码平台。

当时受限于时间和复杂度,整体方案基本是基于 vue-sfc-playground 这一套思路,通过 iframe + 浏览器端编译的方式来实现远程组件的扩展能力。虽然最终把功能跑通了,但在真实使用过程中,这套方案逐渐暴露出不少问题。

比较典型的有:

  1. iframe 性能较差,通信成本高,调试也不友好
  2. 引入的模块必须支持 ESM,且兼容性受限
  3. 模块之间存在前置依赖,需要人工维护依赖关系
  4. 代码补全、Lint、插件能力受限,开发体验远不如本地 IDE
  5. 以及一系列零散但很消耗心智的问题

站在现在这个时间点回看,我觉得这个问题本身并不复杂,只是当时的实现方式并不优雅——它其实是有更好的解法的。

需求假设与目标拆解

为了方便后续讨论,我们先假定一个明确的需求:

低代码平台需要支持挂载任意自定义组件,用来组合实现业务功能,而不是只能使用平台内置的物料。

在这个前提下,我给自己定了几个明确的目标:

  • 开发阶段:本地 IDE 编写组件,修改后可以快速预览
  • 生产环境:极致的运行性能和尽可能小的包体积
  • 整体策略:放弃“纯在线编写组件”,全部基于本地开发来解决问题

原因也很现实:
在线写组件这条路,优化成本太高了,一旦引入复杂依赖、真实业务代码,维护难度会指数级上升。

开发阶段:追求极致的反馈速度

开发阶段整体的数据流和职责关系如下:

flowchart LR
  IDE[本地 IDE]
  IDE -->|文件变更| Rsbuild
  Rsbuild -->|HMR / WS| Renderer
  Renderer -->|defineAsyncComponent| VueRuntime

开发阶段的核心诉求其实很简单:

我在本地改代码,页面要立刻有反馈。

如果你看过 Vue 3 的官方文档,会发现它提供了一个非常关键的能力:defineAsyncComponent,用于异步加载组件。

import { defineAsyncComponent } from "vue";

const AsyncComp = defineAsyncComponent(() => {
  return new Promise((resolve, reject) => {
    // 从服务端获取组件
    resolve(/* 组件对象 */);
  });
});

推荐阅读: 给我 5 分钟,保证教会你在 Vue3 中动态加载远程组件 这篇文章可以帮助理解如何结合本地 Node 服务来挂载远程组件。

需要注意的一点是: resolve 返回的并不是字符串形式的源码,而是经过编译后的组件对象,本质上类似于 vue-loader 处理后的产物。

compiler-remote-comp

到这里,背景铺垫就结束了。

开发态整体架构设计

在开发阶段,我选择 Rsbuild 作为构建核心,整体思路大致如下:

  1. 维护一个模板工程(template) 用于提前约定好组件开发所需的基础配置。
  2. 编写 Rsbuild 插件
    • 插件会结合用户的 API KeyProject ID
    • 在本地开发阶段,通过 HMR / WebSocket 将组件的最新编译结果推送到平台面板
    • 在生产阶段,则负责将源码和构建产物上传到 OSS
  3. 渲染器(低代码核心)
    • 渲染器内部会固定一个 Vue 版本,作为所有组件的公共依赖
    • 接收由 Rsbuild 编译后的 JS 模块
    • 再通过 defineAsyncComponent 动态注入组件

约定与约束

除此之外,还需要提前定义一些结构和规范,例如:

  • global.css:用于声明全局样式
  • composes/:用于存放多个自定义组件

dev 模式启动后,插件会:

  • 开启 CORS
  • 向渲染器推送当前可用的组件列表及版本号
  • 文件变更后重新计算版本,并通知渲染器刷新

两个关键限制

这里有两个非常重要的点:

  1. 样式约束 在 Vue SFC 中禁止书写全局 style,一旦检测到直接报错,防止出现难以审查和回滚的样式污染。
  2. 依赖处理策略 在开发阶段,仅将 vue 本身 external 掉。 其他依赖(如 dayjs、UI 框架等)直接打进包里。

虽然这样会导致 JS 体积偏大,但这是开发态,为了效率和稳定性,这个代价是完全可以接受的。

为什么不用 vue3-sfc-loader

一个常见的问题是: 为什么不直接使用 vue3-sfc-loader 在浏览器端加载 SFC?

核心原因在于依赖管理。

vue3-sfc-loader 需要手动维护 moduleCache,而一旦涉及真实项目,就必然要引入大量第三方包。这就意味着你需要结合 importmap 来管理依赖关系和版本冲突。

例如:

<script type="importmap">
  {
    "imports": {
      "vue": "https://play.vuejs.org/vue.runtime.esm-browser.js",
      "vue/server-renderer": "https://play.vuejs.org/server-renderer.esm-browser.js"
    }
  }
</script>

这种方式在 Demo 场景下还可以接受,但在真实低代码平台中,维护成本会非常高,几乎不可控。

Build 阶段:为极致性能服务

flowchart TB
  subgraph Dev[开发阶段]
    IDE --> Rsbuild
    Rsbuild --> Renderer
    Renderer --> Browser
  end

  subgraph Prod[生产阶段]
    Config[JSON / DSL]
    Config --> Monorepo
    Monorepo --> Build
    Build --> OSS
    OSS --> Browser
  end

生产环境的目标只有一个:性能。

整体思路是结合 Monorepo,将低代码平台中的配置还原为一个真实可构建的工程。

工程还原策略

flowchart TB
  Root[monorepo]
  Root --> Composes[composes/* 组件包]
  Root --> Apps[apps/platform]
  Apps --> Pages[页面还原]

  • 每一个自定义组件,都被放置在 components/ 目录下,作为 Monorepo 的子包
  • apps/platform 中,根据平台生成的 JSON 配置:
    • 还原页面结构
    • 将对应的子包注册到 package.json 依赖中

随后直接执行 build

由于使用的是基于 Rust 的构建工具(如 Rsbuild / Rspack),即使是全量构建,耗时也基本控制在 30 秒以内

带来的收益

  • Tree Shaking:未使用代码会被自动移除
  • 更小的包体积
  • 更好的缓存命中率:结合分包策略,可最大化利用 HTTP 缓存

多页面与路由加载

对于支持多页面的低代码平台来说,结合这种拆分方式,每一个页面本质上只包含自己的业务代码。

const routes = [
  {
    path: "/remote-js",
    component: () => import("https://my-server.com/assets/RemoteComponent.js"),
  },
];

浏览器原生已经支持通过 import() 加载远程 ESM 模块,只要返回的是一个 Promise,Vue Router 就可以正常工作。

参考文档: Vue Router 路由懒加载

最后

这套方案更多是一次架构思路的延展,以及一些关键落地点的总结。

真正落地时,依然会有很多细节需要打磨,例如:

  • 沙箱与安全隔离
  • 组件版本管理
  • 发布与回滚策略

不过整体主线是清晰的:

开发态为体验让路,生产态为性能让路。

如果你对其中某些设计有不同的想法,或者有类似的实践经验,也欢迎交流一波。

构建无障碍组件之Breadcrumb Pattern

作者 anOnion
2026年1月31日 16:32

Breadcrumb Pattern 详解:构建无障碍面包屑导航

面包屑导航是 Web 页面中不可或缺的导航组件,它以层级链接的形式展示当前页面在网站结构中的位置,帮助用户快速了解自己所处的位置并轻松返回上级页面。根据 W3C WAI-ARIA Breadcrumb Pattern 规范,正确实现的面包屑导航不仅要提供清晰的层级导航路径,更要确保所有用户都能顺利使用,包括依赖屏幕阅读器等辅助技术的用户。本文将深入探讨 Breadcrumb Pattern 的核心概念、实现要点以及最佳实践。

一、面包屑导航的定义与核心功能

面包屑导航(Breadcrumb Trail)是由一系列指向父级页面的链接组成的导航路径,按照层级顺序展示当前页面在网站架构中的位置。它的主要功能是帮助用户了解自己在网站中的位置,并在需要时快速返回上级页面。面包屑导航通常水平放置在页面主体内容之前,为用户提供清晰的导航参考。

在实际应用中,面包屑导航广泛应用于内容层级较深的网站,例如电商平台的产品分类页、博客文章的分类归档页、企业官网的产品介绍页等。一个设计良好的面包屑导航能够显著提升用户体验,降低用户的迷失感,同时也有利于搜索引擎更好地理解网站的结构层次。

二、键盘交互规范

面包屑导航的键盘交互具有特殊性。由于面包屑导航仅由静态链接组成,用户不需要进行任何特殊的键盘操作来与之交互。链接本身已经支持标准的键盘导航行为,用户可以通过 Tab 键在各个链接之间切换,通过 Enter 键激活链接进行页面跳转。这种标准的行为已经满足了键盘可访问性的要求,无需额外的键盘事件处理。

因此,Breadcrumb Pattern 规范明确指出,面包屑导航不需要特殊的键盘交互支持。开发者只需要确保链接元素是标准的 HTML 元素,即可获得完整的键盘可访问性支持。这一特点使得面包屑导航的实现相对简单,重点应放在正确的语义标记和 ARIA 属性使用上。

三、WAI-ARIA 角色、状态和属性

正确使用 WAI-ARIA 属性是构建无障碍面包屑导航的技术基础。虽然面包屑导航不涉及复杂的交互逻辑,但正确的语义标记对于屏幕阅读器用户理解导航结构至关重要。

3.1 导航容器标记

面包屑导航必须放置在导航地标区域(Navigation Landmark)内。这可以通过使用 nav 元素或为其他元素添加 role="navigation" 来实现。导航地标区域需要通过 aria-labelaria-labelledby 进行标记,以便屏幕阅读器向用户描述这个导航区域的用途。

示例:使用 nav 元素包裹面包屑导航:

<nav aria-label="面包屑导航">
  <ol>
    <li><a href="/">首页</a></li>
    <li><a href="/products/">产品</a></li>
    <li><a href="/products/electronics/">电子产品</a></li>
    <li aria-current="page">笔记本电脑</li>
  </ol>
</nav>

示例:使用 role 属性定义导航地标:

<div
  role="navigation"
  aria-label="面包屑导航">
  <ul>
    <li><a href="/">首页</a></li>
    <li><a href="/blog/">博客</a></li>
    <li aria-current="page">2025 年技术趋势</li>
  </ul>
</div>

3.2 当前页面状态标记

面包屑导航中的当前页面链接需要使用 aria-current 属性来标识。aria-current="page" 明确告诉辅助技术当前元素代表的是用户正在浏览的页面。这一属性对于屏幕阅读器用户理解面包屑导航的结构非常重要,使他们能够区分可导航的父级页面和当前所在页面。

值得注意的是,如果代表当前页面的元素不是链接(例如使用 span 或其他元素呈现),那么 aria-current 属性是可选的。但为了保持一致性,建议始终为当前页面元素添加 aria-current="page"。

示例:当前页面为链接时的标记:

<nav aria-label="面包屑导航">
  <ol>
    <li><a href="/">首页</a></li>
    <li><a href="/docs/">文档</a></li>
    <li><a href="/docs/guides/">指南</a></li>
    <li>
      <a
        href="/docs/guides/getting-started/"
        aria-current="page"
        >快速入门</a
      >
    </li>
  </ol>
</nav>

示例:当前页面为非链接元素时的标记:

<nav aria-label="面包屑导航">
  <ol>
    <li><a href="/">首页</a></li>
    <li><a href="/shop/">商店</a></li>
    <li><a href="/shop/clothing/">服装</a></li>
    <li aria-current="page">春季新品</li>
  </ol>
</nav>

四、完整示例

以下是使用不同方式实现面包屑导航的完整示例,展示了标准的 HTML 结构、ARIA 属性应用以及样式设计:

4.1 基础面包屑导航实现

<nav aria-label="面包屑导航">
  <ol class="breadcrumb">
    <li><a href="/">首页</a></li>
    <li><a href="/products/">所有产品</a></li>
    <li><a href="/products/electronics/">电子产品</a></li>
    <li aria-current="page">智能手表</li>
  </ol>
</nav>

4.2 带分隔符的面包屑导航

<nav
  aria-label="面包屑导航"
  class="breadcrumb-nav">
  <ol class="breadcrumb-list">
    <li class="breadcrumb-item">
      <a
        href="/"
        class="breadcrumb-link"
        >首页</a
      >
    </li>
    <li
      class="breadcrumb-separator"
      aria-hidden="true">
      /
    </li>
    <li class="breadcrumb-item">
      <a
        href="/categories/"
        class="breadcrumb-link"
        >分类</a
      >
    </li>
    <li
      class="breadcrumb-separator"
      aria-hidden="true">
      /
    </li>
    <li class="breadcrumb-item">
      <a
        href="/categories/books/"
        class="breadcrumb-link"
        >图书</a
      >
    </li>
    <li
      class="breadcrumb-separator"
      aria-hidden="true">
      /
    </li>
    <li
      class="breadcrumb-item"
      aria-current="page">
      <span class="breadcrumb-current">编程指南</span>
    </li>
  </ol>
</nav>

4.3 电商网站产品页面包屑导航

<nav
  aria-label="商品位置"
  class="product-breadcrumb">
  <ol>
    <li><a href="https://www.example.com/">首页</a></li>
    <li><a href="https://www.example.com/electronics/">家用电器</a></li>
    <li><a href="https://www.example.com/electronics/kitchen/">厨房电器</a></li>
    <li>
      <a href="https://www.example.com/electronics/kitchen/coffee/">咖啡机</a>
    </li>
    <li aria-current="page">全自动咖啡机 X2000</li>
  </ol>
</nav>

五、最佳实践

5.1 语义化 HTML 结构

面包屑导航应使用语义化的 HTML 元素构建。使用 nav 元素定义导航区域,使用 ol 或 ul 元素创建有序或无序列表,使用 li 元素包含各个导航项。这种结构不仅对搜索引擎友好,也便于屏幕阅读器向用户传达导航的层级关系。

在列表的选择上,ol 元素更适合面包屑导航,因为它能够传达各个项之间的顺序关系。但如果网站没有强制的层级顺序要求,ul 元素同样可以接受。无论选择哪种列表元素,都要确保列表项之间的逻辑顺序与面包屑导航的层级结构保持一致。

<!-- 推荐做法:使用语义化元素 -->
<nav aria-label="面包屑导航">
  <ol>
    <li><a href="/">首页</a></li>
    <li><a href="/docs/">文档</a></li>
    <li aria-current="page">当前页面</li>
  </ol>
</nav>

5.2 正确使用 ARIA 属性

面包屑导航的 ARIA 属性使用相对简单,但有几个关键点需要注意。首先,导航容器必须使用 aria-labelaria-labelledby 进行标记,以便用户了解这个导航区域的用途。其次,当前页面项必须使用 aria-current="page" 进行标记。最后,确保分隔符元素使用 aria-hidden="true",以避免辅助技术用户听到不必要的标点符号朗读。

<!-- ARIA 属性使用规范 -->
<nav aria-label="面包屑导航">
  <ol>
    <li><a href="/">首页</a></li>
    <li aria-hidden="true">/</li>
    <li><a href="/blog/">博客</a></li>
    <li aria-hidden="true">/</li>
    <li aria-current="page">文章标题</li>
  </ol>
</nav>

5.3 响应式设计考虑

面包屑导航在移动设备上可能面临空间受限的挑战。对于层级较深的导航,可以考虑以下策略:一是使用省略号隐藏中间层级,只保留首页和最后几级;二是允许用户展开查看完整路径;三是将面包屑导航改为垂直布局。无论采用哪种策略,都要确保用户能够访问完整的导航信息。

/* 响应式面包屑导航 */
@media (max-width: 768px) {
  .breadcrumb-list {
    flex-direction: column;
    gap: 4px;
  }

  .breadcrumb-separator {
    transform: rotate(90deg);
  }
}

六、面包屑导航与主导航的区别

面包屑导航和主导航栏虽然都是网站导航的重要组成部分,但它们服务于不同的目的,理解这种区别对于正确使用这两种导航组件至关重要。

面包屑导航的主要作用是展示用户在网站层级结构中的当前位置,它反映的是网站的逻辑结构,而非用户的浏览历史。面包屑导航通常只出现在层级较深的页面中,帮助用户理解当前位置与网站整体结构的关系。相比之下,主导航栏提供的是网站的整体架构概览,允许用户直接跳转到任何主要版块,而不考虑当前的层级位置。

从实现角度来看,面包屑导航强调的是层级关系,因此通常使用有序列表(ol)来体现这种顺序性。而主导航栏更强调功能分区的划分,可能使用无序列表(ul)或更复杂的布局结构。两者在 ARIA 属性使用上也不同:面包屑导航需要明确标记当前页面(aria-current="page"),而主导航栏通常不需要这种标记。

七、总结

构建无障碍的面包屑导航需要关注语义化结构、ARIA 属性应用和视觉样式三个层面的细节。从语义化角度,应使用 nav 元素定义导航区域,使用 ol 或 ul 元素创建列表结构。从 ARIA 属性角度,需要使用 aria-label 为导航区域提供标签,使用 aria-current="page" 标记当前页面。从视觉样式角度,应确保链接易于识别,分隔符清晰,当前页面状态明确。

WAI-ARIA Breadcrumb Pattern 为我们提供了清晰的指导方针,遵循这些规范能够帮助我们创建更加包容和易用的 Web 应用。每一个正确实现的面包屑导航组件,都是提升用户体验和网站可访问性的重要一步。

CSS伪元素:给HTML穿上"隐形斗篷"的魔法

作者 Lee川
2026年1月31日 15:46

CSS伪元素:给HTML穿上"隐形斗篷"的魔法

引言:一场看不见的化妆舞会

想象一下,你在参加一场化妆舞会,但规则很特别:你不能给自己戴上面具或穿上戏服,只能请一位"魔法化妆师"(CSS)在你身上直接绘制妆容。这位化妆师手法高超,他能凭空在你耳边画上耳环,在额头点上装饰——而这一切都不需要你真正佩戴任何实物。

这就是CSS伪元素的魔法!它们就像HTML元素的"隐形斗篷",让我们在不修改HTML结构的情况下,为页面添加各种装饰效果。

第一章:认识"双胞胎幽灵"——::before和::after

伪元素中最常用的就是这对"双胞胎兄弟":::before::after。它们不是HTML中真实存在的元素,而是CSS创造的"幽灵元素"。

/* 魔法咒语的基本格式 */
.元素::before {
    content: ""; /* 魔法的核心:必须念出咒语 */
    /* 其他样式属性 */
}

.元素::after {
    content: ""; /* 同样需要咒语 */
    /* 其他样式属性 */
}

魔法规则第一条:content咒语

content属性是开启伪元素魔法的钥匙。即使你想创建空的内容,也必须写上 content: "",就像念咒语一样,不念就没魔法!

第二章:伪元素的实战魔法——创建笑脸表情

让我们回到文章开头的代码,看看这个"笑脸魔法"是如何实现的:

/* 基础的脸部 */
.face {
    width: 100px;
    height: 100px;
    background-color: #ffdd59; /* 阳光黄色 */
    border-radius: 50%; /* 变成圆形 */
    position: relative; /* 重要:为伪元素设置舞台 */
}

/* 召唤两只"幽灵眼睛" */
.face::after, .face::before {
    content: ""; /* 念咒语:创造存在 */
    position: absolute; /* 让它们漂浮在脸部上 */
    width: 18px;  /* 眼睛宽度 */
    height: 8px;  /* 眼睛高度 */
    background-color: #badc58; /* 清新的黄绿色 */
    top: 20px; /* 从头顶往下20像素 */
    border-radius: 50%; /* 椭圆形的眼睛 */
}

/* 右眼:调皮地往右看 */
.face::before {
    right: -8px; /* 从右边往左偏移8px,稍微突出 */
}

/* 左眼:俏皮地往左看 */
.face::after {
    left: -5px; /* 从左边往右偏移5px,稍微突出 */
}

魔法效果可视化:

👁️          😊         👁️
      (::before)   (.face)    (::after)
       右眼幽灵      笑脸主体    左眼幽灵
       向右突出                向左突出

这个笑脸最妙的地方在于:HTML只需要一个简单的 <div class="face"></div>,所有的眼睛装饰都由CSS凭空创造!

第三章:为什么不用真实元素?——魔法的智慧

你可能会问:"为什么不用真实的 <span>元素来当眼睛呢?"

让我们对比一下两种方式:

方式A:使用真实元素(麻瓜方法)

<div class="face">
    <span class="eye left"></span>
    <span class="eye right"></span>
</div>

方式B:使用伪元素(巫师方法)

<div class="face"></div>

巫师的三大理由:

  1. HTML保持纯净:HTML应该只关心内容结构,而不是表现装饰。眼睛是装饰,不是内容。
  2. 维护更容易:想改变眼睛样式?只需修改CSS,完全不用碰HTML。
  3. 性能更优:伪元素不会增加真实的DOM节点,浏览器渲染起来更轻快。

第四章:伪元素的更多魔法应用

伪元素的魔法远不止画眼睛!它们在Web设计中无处不在:

魔法1:优雅的引用标记

blockquote::before {
    content: "“"; /* 左引号 */
    font-size: 3em;
    color: #ccc;
}

blockquote::after {
    content: "”"; /* 右引号 */
    font-size: 3em;
    color: #ccc;
}

魔法2:自定义列表图标

li::before {
    content: "✨"; /* 星星图标 */
    margin-right: 10px;
}

魔法3:工具提示的小箭头

.tooltip::after {
    content: "";
    position: absolute;
    /* 绘制三角形箭头 */
    border: 10px solid transparent;
    border-top-color: #333;
}

第五章:伪元素的工作原理——魔法的科学

虽然叫"伪元素",但它们在浏览器中表现得像真实元素一样:

<!-- 浏览器眼中的伪元素 -->
<div class="face">
    <!-- 这是::before -->
    😉
    <!-- 这是元素的实际内容(如果有的话) -->
    😊
    <!-- 这是::after -->
    😉
</div>

位置关系三兄弟:

/* 源代码顺序 */
.元素::before { /* 大哥:在最前面 */ }
.元素 { /* 二弟:实际内容 */ }
.元素::after { /* 三弟:在最后面 */ }

第六章:伪元素 vs 伪类——不要搞混的魔法

新手常把伪元素和伪类搞混,记住这个简单区别:

  • 伪元素::before::after::first-line

    • 创造新的虚拟元素
    • 双冒号 ::(CSS3规范,兼容单冒号:
  • 伪类:hover:focus:nth-child()

    • 选择元素的特定状态
    • 单冒号 :
/* 伪类:鼠标悬停时变色 */
button:hover {
    background-color: blue;
}

/* 伪元素:添加装饰性内容 */
button::before {
    content: "👉 ";
}

第七章:魔法的局限与禁忌

虽然伪元素很强大,但也不是万能的:

可以用伪元素(装饰性内容):

✅ 装饰性图标、角标

✅ 工具提示的箭头

✅ 引用符号

✅ 清除浮动的空元素

我们的笑脸眼睛

不要用伪元素(重要内容):

❌ 重要的导航链接

❌ 需要被搜索引擎收录的文字

❌ 需要JavaScript交互的元素

❌ 对可访问性重要的内容

第八章:现代魔法进阶

组合魔法:多重伪元素

/* 创建更复杂的装饰 */
.icon::before {
    /* 背景层 */
}
.icon::after {
    /* 前景层 */
}

动态魔法:结合CSS变量

.face::before {
    content: "";
    width: var(--eye-size, 18px);
    height: calc(var(--eye-size, 18px) * 0.444);
}

结语:成为CSS魔法师

CSS伪元素就像Web开发中的"隐形画笔",让我们能够:

  1. 保持HTML的语义纯净——内容就是内容,装饰交给CSS
  2. 实现优雅的视觉效果——无需污染DOM结构
  3. 提高代码维护性——所有样式集中管理
  4. 优化页面性能——减少不必要的DOM节点

记住这个魔法口诀:

"content不可少,定位要记牢,装饰用伪元,内容用标签"

下次当你想要添加一些装饰性元素时,先问问自己:"这可以用伪元素实现吗?" 很多时候,答案都是肯定的!

现在,拿起你的CSS魔杖(键盘),开始用伪元素创造更多神奇的Web魔法吧!🎩✨


魔法小测验:你能只用CSS伪元素创建一个完整的太阳系动画吗?提示:一个div代表太阳,伪元素代表行星... 无限创意等着你!

uni-appD4(uni-forms学习与回顾)

2026年1月31日 15:33

1.补充小程序路由跳转方式

·wx.navigateTo: 按照顺序向页面栈中放入打开过的页面

image.png·wx.redierctTo: 先把上一个页面删掉,再把自己页面放入栈内

image.png

·wx.reLaunch: 清空页面栈

image.png

2. uni-file-picker:实现文件上传功能

2.1 开通uniCloud Web云存储空间

dev.dcloud.net.cn/

image.png

2.2 在项目中与云空间关联

先创建云开发环境 image.png 再点击关联

image.pngimage.png

2.3 使用uni-file-picker

uni-app官网 上传结果可以使用v-model获取

image.png

2.3.1 验证

image.png
上面的数组和下方的数组必须至少都要有一个单元,否则‘提交’按钮不高亮

2.3.1.1 定义上凭证与下凭证数组

image.png做判断,当两个数组都有数据时才不禁用button image.png

2.4 接口只需要url,所以要处理掉其他的字段

image.pngimage.png

image.png

image.png

2.5 当用户点击提交的时候,调用接口,提交数组的url

2.5.1 封装接口

image.png

2.5.2 监听按钮

image.png

image.png

3 在途

注意:是一对一运送,所以在途最多只能有一条任务

image.png 实现组件一加载就执行逻辑 获取接口数据 image.png渲染数据

image.png

image.png

4 异常上报

image.png

4.1 异常时间

使用到uni-datetime-picker和uni-list组件

image.png

image.png

image.png

4.2 上报位置

需要调用wx.chooseLocation和wx.chooseLocation

image.png 去腾讯申请key

image.png

image.png 拿到用户选择的地址,渲染到页面上 image.png

4.3 异常类型

使用change事件监听用户有没有选择或者有没有取消

image.png

image.png 监听事件

image.png

image.png

image.png

image.png

4.4 异常描述

使用v-model获取即可

image.png

image.png

4.5 异常图片

image.png

image.png

4.6 调用接口用于提交数据

image.png

image.png

4.7 用户选择异常类型,并且回显到页面

image.png渲染回显 image.png

image.png

image.png

4.8 调用接口

image.png

image.png

image.png 其中传过去的id要从url中获取,所以又要使用onLoad,来获取id(这里的id对应的参数名为transportTaskId)

image.png

image.png

image.png

5 异常信息存在就显示,没有则隐藏

渲染数据 image.png

vue视频播放器:基于vue-video-player的自定义视频播放器实现

作者 zhaodan105
2026年1月31日 15:32

引言

目标

实现类似el-image组件的视频查看器,支持预览和切换。但 element -ui 中没有封装对于视频的查看组件,在多方调研后,引入vue-video-player实现这一功能。

功能介绍

vue-video-player 是一个基于 Video.js 封装的 Vue 组件库,旨在为 Vue 开发者提供一套简洁、可复用的视频播放器集成方案。其本质是将 Video.js 的强大功能(如 HLS 支持、字幕加载、全屏控制等)通过 Vue 的组件化机制进行封装,从而实现声明式调用和响应式更新。

官方文档

  1. vue-video-player:github.com/surmon-chin…
  2. video.js:docs.videojs.com/docs/api/pl…

安装

版本兼容性

随着 Vue3 的发布及其 Composition API 的普及,vue-video-player 的维护团队逐步将开发重心转向 Vue3 生态。对于新版本有如下改变:

  • 6.x 及以上版本开始依赖 Vue3 的 runtime-core 和新的组件模型;
  • 不再支持Vue.use()这种全局注册方式;
  • 使用了 Vue3 特有的响应式系统(Proxy 代替 defineProperty);
  • 构建工具链升级至 Vite,导致与 Vue2 项目的 webpack 配置存在冲突风险。

版本选择策略

需求场景 建议版本 安装命令 引入方式
Vue2 项目 ^5.0.2 npm install vue-video-player@^5.0.2 Vue.use(VueVideoPlayer)
Vue3 项目 ^6.0.0 npm install vue-video-player@latest app.use(VueVideoPlayer)

错误使用案例

  1. 在安装依赖时未注意版本约束,导致运行时报错:
[Vue warn]: Unknown custom element: <video-player>
Did you register the component correctly?

2. Vue2 版本最佳实践建议:

// package.json 中显式锁定版本
"dependencies": {
  "vue-video-player": "^5.0.2",
  "video.js": "^7.10.2"
}

// main.js 中正确引入
import Vue from 'vue'
import VueVideoPlayer from 'vue-video-player'
import 'vue-video-player/node_modules/video.js/dist/video-js.css'

Vue.use(VueVideoPlayer)

使用

基本用法

  1. 属性配置

我们可以通过playerOptions配置自定义属性,关键属性包括src(视频地址)、:controls(是否显示控制栏)、:autoplay(自动播放)、:loop(循环播放)以及:volume(音量设置)等。

<template>
  <div v-if="visible" class="video-mask-wrapper" tabindex="0">
    <div class="viewer-wrapper" @click.self="handleClose">
         ……
      <div class="video-player-wrapper" :style="videoBoxStyle">
        <video-player
          :key="`${index}-${viewerData.subLink}`"
          ref="videoPlayer"
          class="video-player"
          :options="playerOptions"
        />
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    ……
  },
  computed: {
    playerOptions() {
      return {
        autoplay: true, // 自动播放
        controls: true, // 显示播放控制条
        preload: 'metadata',
        fluid: false, // 自适应容器,设为false,使用自定义css样式控制
        sources: [{ src: this.viewerData.subLink, type: 'video/mp4' }],
        controlBar: {
          volumePanel: { inline: false }, // 音量面板,inline置为false时:点击音量图标时弹出独立的垂直滑块
          playToggle: true, // 控制条的播放暂停按钮
        },
        bigPlayButton: false, // 隐藏大播放按钮
      };
    },
  },
};
</script>

<style scoped lang="scss">
.video-mask-wrapper {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 2000;
  outline: none;
  .viewer-wrapper {
    position: absolute;
    inset: 0;
    z-index: 2002;
    display: flex;
    align-items: center;
    justify-content: center;
    .video-player-wrapper {
      position: relative;
      max-width: 1000px;
      max-height: 850px;
      .video-player {
        width: 100%;
        height: 100%;
      }
    }
  }
}
::v-deep .video-js {
  width: 100% !important;
  height: 100% !important;
}
::v-deep .video-js .vjs-tech {
  width: 100% !important;
  height: 100% !important;
  object-fit: contain;
  background-color: transparent;
}
</style>

2. 方法

const player = this.$refs.videoPlayer && this.$refs.videoPlayer.player;

player.pause();
player.load();
player.src([{ src: newSrc, type: 'video/mp4' }]);
  • addTextTrack():向音频/视频添加新的文本轨道。
  • canPlayType():检测浏览器是否能播放指定的音频/视频类型。
  • load():重新加载音频/视频元素。
  • play():开始播放音频/视频。
  • pause():暂停当前播放的音频/视频。
  • ……
  1. 事件
  • waiting:当视频由于需要缓冲下一帧而停止时触发。
  • canplay:当浏览器可以开始播放音频/视频时触发。
  • error:当在音频/视频加载期间发生错误时触发。
  • loadedmetadata:当浏览器已加载音频/视频的元数据时触发。
  • ……
  1. 支持的视频格式
  • 可参考文档【测试说明】部分,cloud.tencent.com/developer/a…
  • 若要播放m3u8视频流:1、需要引入video.js并绑定到window上;2、安装依赖videojs-contrib-hls并引入;3、sources 要指定type: application/x-mpegURL

二次封装

基于用户操作习惯,我们需要对播放器进行二次封装,主要包括:

播放器动态宽高

  1. 解决什么问题
    • 播放器配置项中自带的fluid 属性,可以调整视频比例来自适应容器大小,但这会导致与原始比例严重失调,比如在网页上通常是宽〉高,但如果视频是竖屏的,这时就会压缩视频高度适应容器,视觉效果大打折扣。
    • 视频不足以撑满整个容器时,会存在黑边
  2. 解决方案:如下流程图所示,基于当前传入的视频原始尺寸、视窗宽高、设定的最大宽高,动态计算当前视频下播放器的宽高,实现在设定的最大宽高范围内:
    • 视频宽或者高大于设定最大宽高,基于比例缩放视频宽高
    • 视频宽和高都不超过设定的最大宽高,使用原始视频宽高
  3. 实现效果
    • 视频宽高和播放器宽高完全一致,避免存在黑边的现象
    • 缩放后依然保持视频原始比例,保证视觉效果
    • 通过CSS 样式调整,可以将播放器背景设置为transparent,当视频加载时,就不会一直呈现黑色背景

动态计算播放器尺寸.jpg 4. 部分代码

 computed: {
    // 将动态计算的视频宽高绑定到播放容器
    getVideoBoxStyle() {
      const w = this.boxWidth || 0;
      const h = this.boxHeight || 0;
      const style = {};

      if (w > 0 && h > 0) {
        style.width = w + 'px';
        style.height = h + 'px';
      }

      return style;
    },
  },    
methods:{
    // 基于原始尺寸和当前可用空间,计算播放器尺寸    
    handleBoxSizeResize() {
      const { w, h } = this.naturalVideo || {};
      const { LIMIT_W, LIMIT_H } = this;

      // 取视窗宽高与设置的最大宽高的最小值,作为播放器的最大宽高
      const maxW = Math.min(window.innerWidth, LIMIT_W);
      const maxH = Math.min(window.innerHeight, LIMIT_H);

      // 取宽度、高度缩放比例的最小值,保证视频完整显示
      const scale = Math.min(1, Math.min(maxW / w, maxH / h));

      this.boxWidth = Math.max(0, Math.round(w * (isFinite(scale) ? scale : 1)));
      this.boxHeight = Math.max(0, Math.round(h * (isFinite(scale) ? scale : 1)));
    },

    // 基于最大宽高,动态计算视频宽高
    getVideoContainerSize() {
      const player = this.$refs.videoPlayer.player;
      const node = player.el().querySelector('video');

      const compute = () => {
        const originVideoWidth = node.videoWidth;
        const originVideoHeight = node.videoHeight;
        this.naturalVideo = { w: originVideoWidth, h: originVideoHeight };
        this.handleBoxSizeResize();
      };

      // 若已加载元数据,直接计算;否则监听到加载后,执行compute
      if (node && node.readyState >= 1) {
        compute();
      } else if (node) {
        node.addEventListener('loadedmetadata', compute, { once: true });
      }
    },
}

多视频切换播放

  1. 解决什么问题:当前业务背景下,多个视频在弹窗内按顺序排列,如果要查看其他视频,需要退出当前视频后,再点击另一个视频查看,操作麻烦
  2. 解决方案:
    • 在视频预览页面增加左、右箭头icon,绑定click事件,基于当前视频索引,当切换上一条视频时,父组件将index-1索引的视频信息传入播放器组件,播放器重新渲染;切换下一条视频时,同理。
    • 监听keyDown事件,按下键盘左箭头、右箭头时,同上面逻辑。
    • 增加watch监听,当监听到视频数据 viewerData 更新时,重置视频预览数据,同时进行视频切源
  3. 实现效果
    • 点击左右箭头,支持上一条/下一条切换视频
    • 监听键盘事件,支持键盘左右箭头事件来切换视频
  4. 部分代码
// 重置视频状态
    handleVideoStateReset() {
      this.boxWidth = 0;
      this.boxHeight = 0;
      this.naturalVideo = { w: 0, h: 0 };
      this.isLoading = true;
      this.loadError = false;
    },
    // 视频切源
    handleVideoCutResource() {
      const player = this.$refs.videoPlayer && this.$refs.videoPlayer.player;
      const newSrc = this.viewerData && this.viewerData.subLink;

      if (player && newSrc) {
        player.pause();
        player.src([{ src: newSrc, type: 'video/mp4' }]);
        player.load();

        // 监听视频事件
        this.bindVideoEvents(player);

        player.one('loadedmetadata', () => {
          this.getVideoContainerSize();
        });
      }
    },    
    // 上一个视频
    handlePreVideoChange(index) {
      this.handleVideoSwitch(index, -1, this.dialogData.carveUrlList.length);
    },
    // 下一个视频
    handleNextVideoChange(index) {
      this.handleVideoSwitch(index, 1, this.dialogData.carveUrlList.length);
    },
    // 键盘左右箭头切换视频
    handleVideoKeyDown(event) {
      const length = this.dialogData.carveUrlList.length;
      const index = Number(this.videoSafeAreaViewer.index) || 0;

    // 视频查看器未渲染、视频列表为空、只有一个视频时,不执行切换事件
      if (!this.videoSafeAreaViewer.visible || !length || length === 1) {
        return;
      }

      if (event.key === 'ArrowRight') {
        this.handleVideoSwitch(index, 1, length);
      } else if (event.key === 'ArrowLeft') {
        this.handleVideoSwitch(index, -1, length);
      }
    },
    // 切换视频
    handleVideoSwitch(index, step, len) {
      const videoList = this.dialogData.carveUrlList || [];
      const idx = (index + step + len) % len;

      this.handleVideoPreview(videoList[idx], idx, this.dialogData.carveUrlList);
    },

视频加载提示

  1. 解决什么问题
    • 视频加载时,页面没有内容显示,用户对视频加载无感知
  2. 解决方案:在视频播放器容器中,增加提示块
    • 视频加载中,设置 isLoading: true,渲染提示块,提示内容:视频正在加载中,请稍后……
    • 视频加载失败,设置 loadingError: true,渲染提示块,提示内容:视频加载失败;增加el-icon-refresh-left图标,绑定click事件支持重新加载
    • 重新加载事件包括:1、重置预览数据,2、视频切源
    • 视频加载完成,提示块不可见,播放视频
  3. 实现效果
    • 视频加载中、加载失败提示,用户可感知视频加载进度
    • 加载失败时支持重新加载,避免因偶发网络原因导致的失败,用户无需刷新/退出就能再次尝试加载视频
  4. 部分代码
 <div class="video-player-wrapper" :style="getVideoBoxStyle">
        <!-- 视频加载提示 -->
        <div v-show="isLoading || loadingError" class="video-status-tip">
          <div class="status-content">
            <!-- 加载中 -->
            <div v-if="isLoading && !loadingError" class="loading-state">
              <i class="el-icon-loading status-icon"></i>
              <span class="status-text">视频正在加载中,请稍后...</span>
            </div>
            <!-- 加载失败 -->
            <div v-else-if="loadingError" class="error-state">
              <span class="status-text" style="color: #f56c6c"
                >视频加载失败<i class="el-icon-refresh-left" @click.stop="handleVideoReload"></i
              ></span>
            </div>
          </div>
        </div>
        <!-- 视频播放器 -->
        <video-player
          :key="`${index}-${viewerData.subLink}`"
          ref="videoPlayer"
          class="video-player"
          :options="playerOptions"
          @dblclick.native="toggleFullscreen"
          @waiting="handleVideoWaiting"
          @canplay="handleVideoCanPlay"
          @loadeddata="handleVideoLoadedData"
          @error="handleVideoError"
        />
      </div>

双击进入/退出全屏

  1. 解决什么问题:video-player组件未显示配置双击进入/退出全屏事件,需要手动绑定dbclick事件
  2. 解决方案:为视频播放器绑定dbclick事件
  3. 实现效果:非全屏状态下双击全屏播放,反之退出全屏状态
  4. 部分代码
  // 双击切换全屏
    toggleFullscreen() {
      const videoPlayer = this.$refs.videoPlayer;
      const player = videoPlayer && videoPlayer.player;

      if (!player) {
        console.warn('[MKVideoSafeAreaViewer] toggleFullscreen: player not ready');
        return;
      }

      if (player.isFullscreen()) {
        player.exitFullscreen();
      } else {
        player.requestFullscreen();
      }
    },

什么是"阻塞渲染"?如何避免 JavaScript 代码阻塞页面渲染?

作者 Smilezyl
2026年1月31日 15:24

什么是"阻塞渲染"?如何避免 JavaScript 代码阻塞页面渲染?

核心答案

阻塞渲染是指浏览器在解析 HTML 构建 DOM 树的过程中,遇到 <script> 标签时会暂停 DOM 解析,等待脚本下载并执行完毕后才继续解析。这是因为 JavaScript 可能会修改 DOM 结构(如 document.write),浏览器必须确保 DOM 的正确性。

避免阻塞的核心方法:

  1. 使用 async 属性:脚本异步下载,下载完立即执行
  2. 使用 defer 属性:脚本异步下载,DOM 解析完成后按顺序执行
  3. 将脚本放在 </body>
  4. 动态创建 script 标签

深入解析

浏览器渲染流程

HTML → DOM Tree
                  → Render Tree → Layout → Paint
CSS  → CSSOM

阻塞机制详解

1. JavaScript 阻塞 DOM 解析

HTML解析 → 遇到<script> → 暂停解析 → 下载JS → 执行JS → 继续解析

2. CSS 也会间接阻塞

  • CSS 本身不阻塞 DOM 解析,但阻塞渲染
  • 如果 JS 在 CSS 之后,JS 会等待 CSSOM 构建完成(因为 JS 可能访问样式)

async vs defer 的区别

特性 async defer
下载 异步,不阻塞解析 异步,不阻塞解析
执行时机 下载完立即执行 DOM 解析完成后执行
执行顺序 不保证顺序 保证顺序
适用场景 独立脚本(统计、广告) 有依赖关系的脚本

常见误区

  1. 误区:async 和 defer 可以同时使用

    • 实际:同时存在时,现代浏览器优先使用 async
  2. 误区:内联脚本可以使用 async/defer

    • 实际:async/defer 只对外部脚本有效
  3. 误区:放在 body 底部就不会阻塞

    • 实际:仍会阻塞,只是此时 DOM 已基本解析完成,影响较小

代码示例

<!-- 1. 阻塞渲染(默认行为) -->
<script src="app.js"></script>

<!-- 2. async:异步下载,下载完立即执行 -->
<script async src="analytics.js"></script>

<!-- 3. defer:异步下载,DOM 解析后按顺序执行 -->
<script defer src="vendor.js"></script>
<script defer src="app.js"></script>  <!-- 保证在 vendor.js 之后执行 -->

<!-- 4. 动态加载脚本 -->
<script>
  const script = document.createElement('script');
  script.src = 'lazy-module.js';
  script.async = false; // 保证顺序执行
  document.body.appendChild(script);
</script>

<!-- 5. 模块脚本(默认 defer 行为) -->
<script type="module" src="app.mjs"></script>

<!-- 6. 预加载关键资源 -->
<link rel="preload" href="critical.js" as="script">

现代优化方案

// 使用 Intersection Observer 懒加载脚本
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const script = document.createElement('script');
      script.src = entry.target.dataset.src;
      document.body.appendChild(script);
      observer.unobserve(entry.target);
    }
  });
});

// 使用 requestIdleCallback 在空闲时加载
requestIdleCallback(() => {
  const script = document.createElement('script');
  script.src = 'non-critical.js';
  document.body.appendChild(script);
});

面试技巧

可能的追问方向

  1. "async 和 defer 的执行时机具体是什么?"

    • async:下载完成后立即执行,可能在 DOMContentLoaded 之前或之后
    • defer:在 DOMContentLoaded 事件之前执行
  2. "CSS 会阻塞 JS 执行吗?"

    • 会。如果 <script><link> 之后,JS 会等待 CSSOM 构建完成
  3. "如何检测和量化阻塞时间?"

    • Performance API、Lighthouse、Chrome DevTools Performance 面板
  4. "type="module" 的脚本有什么特点?"

    • 默认 defer 行为、严格模式、独立作用域、支持 import/export

展示深度的回答技巧

  • 提及浏览器的预解析器(Preload Scanner)会提前扫描并下载资源
  • 讨论 Critical Rendering Path 优化策略
  • 结合实际项目经验,如 Webpack 的代码分割、动态 import

一句话总结

JS 阻塞 DOM 解析是因为可能修改 DOM;用 defer 保顺序、async 求速度、动态加载最灵活。

在 HTML 中引入 JavaScript 有哪几种方式?它们各自的优缺点是什么?

作者 Smilezyl
2026年1月31日 15:22

在 HTML 中引入 JavaScript 有哪几种方式?它们各自的优缺点是什么?

核心答案

在 HTML 中引入 JavaScript 有 3 种主要方式

方式 语法 主要场景
1. 行内式 <div onclick="alert('hi')"> 极少使用,不推荐
2. 内嵌式 <script>alert('hi')</script> 小型脚本、单页应用
3. 外链式 <script src="app.js"></script> 生产环境首选

核心原则:生产环境优先使用外链式,配合 deferasync 优化加载性能。


深入解析

1. 三种方式详解

方式一:行内式(Inline)
<!-- 直接在 HTML 属性中写 JS -->
<button onclick="alert('点击了!')">点击我</button>
<a href="javascript:void(0)" onmouseover="console.log('悬停')">链接</a>

优点:

  • 快速测试、简单直观

缺点:

  • ❌ HTML 和 JS 强耦合,难以维护
  • ❌ 无法复用逻辑
  • ❌ 代码混乱,可读性差
  • ❌ 存在 XSS 安全风险
  • ❌ 无法利用浏览器缓存

方式二:内嵌式(Internal / Embedded)
<!DOCTYPE html>
<html>
<head>
    <script>
        // JS 代码写在 <script> 标签内
        function init() {
            console.log('页面初始化');
        }
    </script>
</head>
<body>
    <h1>内嵌式示例</h1>
</body>
</html>

优点:

  • ✓ 适合单页应用或小型项目
  • ✓ HTML 和 JS 在同一文件,便于调试
  • ✓ 可以访问页面中的所有元素

缺点:

  • ❌ HTML 文件体积变大
  • ❌ 无法被浏览器缓存(每次加载 HTML 都要重新加载 JS)
  • ❌ 多个页面无法共享同一份 JS 代码
  • ❌ 不符合关注点分离原则

方式三:外链式(External)⭐ 推荐
<!-- 基础用法 -->
<script src="js/app.js"></script>

<!-- 推荐用法:配合 defer -->
<script src="js/app.js" defer></script>

<!-- 或者 async(取决于场景) -->
<script src="js/analytics.js" async></script>

优点:

  • HTML 与 JS 分离,结构清晰
  • 可复用:多个页面共享同一个 JS 文件
  • 可缓存:浏览器缓存 JS 文件,提升加载速度
  • 便于维护:代码独立管理
  • 支持模块化:方便团队协作

缺点:

  • ⚠️ 需要额外的 HTTP 请求(但可通过缓存和打包优化)

2. <script> 标签的关键属性

deferasync 的区别
页面解析流程对比:

无属性(默认):
HTML解析 → 遇到script → 停止解析 → 下载JS → 执行JS → 继续解析HTML
                ↑ 阻塞页面渲染 ↑

defer:
HTML解析 → 并行下载JSHTML解析完成 → 按顺序执行JSDOMContentLoaded
            ↓ 不阻塞解析 ↓

async:
HTML解析 → 并行下载JS → 下载完立即执行 → 继续解析HTML
            ↓ 执行时机不确定 ↓
属性 执行时机 顺序保证 适用场景
无属性 立即执行,阻塞解析 ✅ 按顺序
defer HTML 解析完成后,DOMContentLoaded ✅ 按顺序 DOM 操作脚本
async 下载完成后立即执行 ❌ 无顺序保证 独立脚本(如统计、广告)
<!-- defer 推荐用法 -->
<script src="main.js" defer></script>
<script src="utils.js" defer></script>
<!-- 保证:utils.js 一定在 main.js 之前执行 -->

<!-- async 用法 -->
<script src="analytics.js" async></script>
<script src="ads.js" async></script>
<!-- 不保证执行顺序,谁先下载完谁先执行 -->
其他重要属性
属性 作用 示例
type 指定脚本类型 type="module"(ES 模块)
crossorigin CORS 配置 crossorigin="anonymous"
integrity SRI(子资源完整性校验) integrity="sha384-..."
nomodule 不支持模块的浏览器才执行 <script nomodule src="legacy.js"></script>

3. 底层机制:浏览器如何加载和执行脚本

┌─────────────────────────────────────────────────────────┐
│                    浏览器渲染流程                         │
├─────────────────────────────────────────────────────────┤
│                                                          │
│  1. HTML Parser ──→ 构建 DOM 树                          │
│         ↓                                                 │
│  2. CSS Parser ──→ 构建 CSSOM 树                         │
│         ↓                                                 │
│  3. 合并 ──→ 渲染树(Render Tree)                        │
│         ↓                                                 │
│  4. Layout(布局)                                        │
│         ↓                                                 │
│  5. Paint(绘制)                                         │
│                                                          │
└─────────────────────────────────────────────────────────┘

遇到 <script> 时:

默认行为:
┌─────────┐
│ 停止解析 │ ← 阻塞 DOM 构建
└────┬────┘
     ↓
┌─────────┐
│ 下载 JS │ ← 如果是外链脚本
└────┬────┘
     ↓
┌─────────┐
│ 执行 JS │ ← 阻塞渲染
└────┬────┘
     ↓
┌─────────┐
│ 继续解析 │
└─────────┘

使用 defer/async:
┌─────────┐      ┌─────────┐
 │继续解析 │ ←→   │并行下载 │  ← 不阻塞
└─────────┘      └─────────┘

4. 最佳实践

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>最佳实践示例</title>

    <!-- CSS 放在 head 中 -->
    <link rel="stylesheet" href="styles.css">

    <!-- 预加载关键脚本 -->
    <link rel="preload" href="critical.js" as="script">
</head>
<body>
    <!-- 页面内容 -->

    <!-- 方案1:现代浏览器推荐 -->
    <script src="main.js" defer></script>
    <script src="app.js" defer></script>

    <!-- 方案2:需要立即执行的脚本(如 polyfill) -->
    <script>
        // 同步执行的小型脚本
    </script>

    <!-- 方案3:独立第三方脚本 -->
    <script src="analytics.js" async></script>

    <!-- 方案4:ES 模块 -->
    <script type="module" src="module.js"></script>

    <!-- 方案5:模块降级方案 -->
    <script type="module" src="modern.js"></script>
    <script nomodule src="legacy.js"></script>
</body>
</html>

5. 常见误区

误区1deferasync 功能一样

纠正defer 保证顺序且在 DOMContentLoaded 前执行,async 不保证顺序

误区2:把所有 <script> 都放在 <head>

纠正:传统放 </body> 前,现代用 defer 可放 head

误区3defer 的脚本一定在 DOMContentLoaded 前执行

纠正:大部分情况是的,但如果脚本很大或网络慢,可能在之后

误区4:多个 async 脚本按书写顺序执行

纠正async 脚本按下载完成顺序执行,顺序不可控


代码示例

示例1:三种引入方式对比

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>JS 引入方式对比</title>

    <!-- 方式1:行内式(不推荐) -->
    <button onclick="handleClick()">行内式按钮</button>

    <!-- 方式2:内嵌式 -->
    <script>
        function handleClick() {
            console.log('内嵌式函数被调用');
        }

        // 内嵌式可以直接操作页面
        document.addEventListener('DOMContentLoaded', function() {
            console.log('DOM 加载完成');
        });
    </script>

    <!-- 方式3:外链式(推荐) -->
    <script src="js/utils.js" defer></script>
</head>
<body>
    <h1>三种引入方式</h1>

    <!-- 行内式的完整示例 -->
    <div onmouseover="this.style.background='yellow'"
         onmouseout="this.style.background='white'">
        鼠标悬停变色
    </div>
</body>
</html>

示例2:defer vs async 实际效果

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>defer vs async</title>
</head>
<body>
    <h1>页面标题</h1>
    <p>内容...</p>

    <script>
        // 同步脚本:阻塞后续渲染
        console.log('1. 同步脚本开始');
        // 模拟耗时操作
        const start = Date.now();
        while (Date.now() - start < 2000) {}
        console.log('2. 同步脚本结束(阻塞了2秒)');
    </script>

    <p>这行内容被延迟显示了</p>

    <!-- defer 脚本 -->
    <script src="defer1.js" defer></script>
    <script src="defer2.js" defer></script>
    <!-- 保证:defer1.js 在 defer2.js 之前执行 -->

    <!-- async 脚本 -->
    <script src="async1.js" async></script>
    <script src="async2.js" async></script>
    <!-- 不保证:谁先下载完谁先执行 -->

    <script>
        document.addEventListener('DOMContentLoaded', function() {
            console.log('3. DOMContentLoaded 触发');
        });

        window.addEventListener('load', function() {
            console.log('4. 页面完全加载完成');
        });
    </script>
</body>
</html>

示例3:现代项目的标准引入方式

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>现代项目</title>

    <!-- 预连接到 CDN -->
    <link rel="preconnect" href="https://cdn.example.com">

    <!-- 预加载关键资源 -->
    <link rel="preload" href="critical.css" as="style">
    <link rel="preload" href="critical.js" as="script">

    <!-- 关键 CSS -->
    <link rel="stylesheet" href="critical.css">

    <!-- Polyfill:需要立即执行且不依赖 DOM -->
    <script>
        // 检测和添加必要的 polyfill
        if (!window.Promise) {
            document.write('<script src="polyfills/promise.js"><\/script>');
        }
    </script>
</head>
<body>
    <div id="app"></div>

    <!-- 主要应用脚本:使用 defer -->
    <script src="vendors.js" defer></script>
    <script src="main.js" defer></script>

    <!-- 第三方统计:使用 async -->
    <script src="analytics.js" async></script>

    <!-- ES 模块 + 降级方案 -->
    <script type="module" src="modern-app.js"></script>
    <script nomodule src="legacy-app.js"></script>
</body>
</html>

示例4:动态加载脚本

// 动态创建 script 标签
function loadScript(url, options = {}) {
    return new Promise((resolve, reject) => {
        const script = document.createElement('script');
        script.src = url;

        // 设置属性
        if (options.async) script.async = true;
        if (options.defer) script.defer = true;
        if (options.type) script.type = options.type;

        // 事件监听
        script.onload = () => resolve(script);
        script.onerror = () => reject(new Error(`Failed to load ${url}`));

        document.head.appendChild(script);
    });
}

// 使用示例
async function initApp() {
    try {
        await loadScript('/utils.js', { defer: true });
        await loadScript('/main.js', { defer: true });
        console.log('所有脚本加载完成');
    } catch (error) {
        console.error('脚本加载失败:', error);
    }
}

// 条件加载
if ('IntersectionObserver' in window) {
    // 支持,加载现代版本
    loadScript('/modern-image-lazy-load.js');
} else {
    // 不支持,加载 polyfill
    loadScript('/polyfills/intersection-observer.js')
        .then(() => loadScript('/legacy-image-lazy-load.js'));
}

面试技巧

面试官可能的追问

  1. "为什么传统建议把 <script> 放在 </body> 之前?"

    • 避免阻塞页面渲染,让用户先看到内容
  2. "现在有了 defer,还需要放 </body> 前吗?"

    • 不需要,defer 可放 head,效果相同且更早开始下载
  3. "什么情况下用 async?"

    • 独立脚本:统计代码、广告脚本、不依赖其他代码的库
  4. "多个 defer 脚本的执行顺序?"

    • 按在 HTML 中的出现顺序执行
  5. "deferDOMContentLoaded 的关系?"

    • defer 脚本在 DOMContentLoaded 之前执行
  6. "什么是脚本阻塞(render blocking)?"

    • 解释浏览器解析 HTML 时遇到 <script> 停止渲染的机制

如何展示深度理解

  1. 谈性能优化

    • 关键渲染路径优化
    • 资源预加载(preload/prefetch)
    • 代码分割(code splitting)
  2. 谈实际项目经验

    • 如何处理第三方脚本(如 Google Analytics)
    • 如何优化首屏加载时间
    • 使用过哪些构建工具的优化
  3. 谈浏览器兼容性

    • defer/async 的浏览器支持情况
    • 如何为老浏览器做降级处理
  4. 谈安全

    • SRI(Subresource Integrity)
    • nonce/CSP(Content Security Policy)

一句话总结

外链式 + defer 是现代网页引入 JavaScript 的最佳实践,它实现了代码分离、可缓存、不阻塞渲染的完美平衡。

type-challenges(ts类型体操): 15 - 最后一个元素

作者 fxss
2026年1月31日 13:49

15 - 最后一个元素

by Anthony Fu (@antfu) #中等 #array

题目

在此挑战中建议使用TypeScript 4.0

实现一个Last<T>泛型,它接受一个数组T并返回其最后一个元素的类型。

例如

type arr1 = ['a', 'b', 'c']
type arr2 = [3, 2, 1]

type tail1 = Last<arr1> // 应推导出 'c'
type tail2 = Last<arr2> // 应推导出 1

在 Github 上查看:tsch.js.org/15/zh-CN

代码

/* _____________ 你的代码 _____________ */

type Last<T extends any[]> = T extends [...infer _, infer R] ? R : never

关键解释:

  • T extends [...infer _, infer R]:通过 infer 提取数组的最后一个元素 R
  • ? R : never:如果数组非空,返回 R;否则返回 never

相关知识点

extends

使用维度 核心作用 示例场景
类型维度 做类型约束或条件判断(类型编程核心) 限定泛型范围、判断类型是否兼容、提取类型片段
语法维度 做继承(复用已有结构) 接口继承、类继承
extends 做类型约束或条件判断
  1. 泛型约束:限定泛型的取值范围
// 约束 T 必须是「拥有 length 属性」的类型(比如 string/数组)
function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

// 合法调用(符合约束)
getLength("hello"); // ✅ string 有 length,返回 5
getLength([1, 2, 3]); // ✅ 数组有 length,返回 3

// 非法调用(超出约束)
getLength(123); // ❌ 报错:number 没有 length 属性
  1. 条件类型:类型版 三元运算符
// 基础示例:判断类型是否为字符串
type IsString<T> = T extends string ? true : false;

type A = IsString<"test">; // true(符合)
type B = IsString<123>; // false(不符合)

分布式条件类型(联合类型专用): 当 T 是联合类型时,extends 会自动拆分联合类型的每个成员,逐个判断后再合并结果。

type Union = string | number | boolean;

// 拆分逻辑:string→string,number→never,boolean→never → 合并为 string
type OnlyString<T> = T extends string ? T : never;
type Result = OnlyString<Union>; // Result = string

注意:只有泛型参数是 裸类型(没有被 []/{} 包裹)时,才会触发分布式判断:

// 包裹后不触发分布式,整体判断 [string|number] 是否兼容 [string]
type NoDist<T> = [T] extends [string] ? T : never;
type Result2 = NoDist<Union>; // never(整体不兼容)
  1. 配合 infer:提取类型片段(黄金组合)
// 提取 Promise 的返回值类型
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;

type C = UnwrapPromise<Promise<string>>; // string(提取成功)
type D = UnwrapPromise<number>; // number(不满足条件,返回原类型)
extends 做继承(复用已有结构)
  1. 接口继承:复用 + 扩展属性
// 基础接口
interface User {
  id: number;
  name: string;
}

// 继承 User,并扩展新属性
interface Admin extends User {
  role: "admin" | "super_admin"; // 新增权限属性
}

// 必须包含继承的 + 扩展的所有属性
const admin: Admin = {
  id: 1,
  name: "张三",
  role: "admin"
};

// 多接口继承
interface HasAge { age: number; }
interface Student extends User, HasAge {
  className: string; // 同时继承 User + HasAge
}
  1. 类继承:复用父类的属性 / 方法
class Parent {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  sayHi() {
    console.log(`Hi, ${this.name}`);
  }
}

// 继承 Parent 类
class Child extends Parent {
  age: number;
  constructor(name: string, age: number) {
    super(name); // 必须调用父类构造函数(初始化父类属性)
    this.age = age;
  }
  // 重写父类方法
  sayHi() {
    super.sayHi(); // 调用父类原方法
    console.log(`I'm ${this.age} years old`);
  }
}

const child = new Child("李四", 10);
child.sayHi(); // 输出:Hi, 李四 → I'm 10 years old

补充:类实现接口用 implements(不是 extends

// 定义接口(契约:规定必须有 id、name 属性,以及 greet 方法)
interface Person {
  id: number;
  name: string;
  greet(): void; // 仅定义方法签名,无实现
}

// 类实现接口(必须严格遵守契约)
class Employee implements Person {
  // 必须实现接口的所有属性
  id: number;
  name: string;

  // 构造函数初始化属性
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 必须实现接口的 greet 方法(具体实现由类自己定义)
  greet() {
    console.log(`Hi, I'm ${this.name}, ID: ${this.id}`);
  }
}

// 实例化使用
const emp = new Employee(1, "张三");
emp.greet(); // 输出:Hi, I'm 张三, ID: 1


// 接口1:基础信息
interface Identifiable {
  id: number;
  getId(): number;
}

// 接口2:可打印
interface Printable {
  printInfo(): void;
}

// 类同时实现两个接口(必须实现所有接口的成员)
class Product implements Identifiable, Printable {
  id: number;
  name: string; // 类可扩展接口外的属性

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 实现 Identifiable 的方法
  getId(): number {
    return this.id;
  }

  // 实现 Printable 的方法
  printInfo() {
    console.log(`Product: ${this.name}, ID: ${this.getId()}`);
  }
}

const product = new Product(100, "手机");
console.log(product.getId()); // 100
product.printInfo(); // Product: 手机, ID: 100

infer

infer 是 TypeScript 在条件类型中提供的关键字,用于声明一个 待推导的类型变量(类似给类型起一个临时名字),只能在 extends 子句中使用。它的核心作用是:从已有类型中提取 / 推导我们需要的部分,而无需手动硬编码类型。

infer 必须配合条件类型使用,语法结构如下:

// 基础结构:推导 T 的类型为 U,若能推导则返回 U,否则返回 never
type InferType<T> = T extends infer U ? U : never;

type Example = InferType<string>; // Example 类型为 string
type Example2 = InferType<number[]>; // Example2 类型为 number[]

高频使用场景:

1. 提取函数的返回值类型
// 定义类型工具:提取函数的返回值类型
type GetReturnType<Fn> = Fn extends (...args: any[]) => infer R ? R : never;

// 测试用函数
const add = (a: number, b: number): number => a + b;
const getUser = () => ({ name: "张三", age: 20 });

// 使用类型工具
type AddReturn = GetReturnType<typeof add>; // AddReturn 类型为 number
type UserReturn = GetReturnType<typeof getUser>; // UserReturn 类型为 { name: string; age: number }
2. 提取数组的元素类型
// 定义类型工具:提取数组元素类型
type GetArrayItem<T> = T extends (infer Item)[] ? Item : never;

// 测试
type NumberArray = GetArrayItem<number[]>; // NumberArray 类型为 number
type StringArray = GetArrayItem<string[]>; // StringArray 类型为 string
type MixedArray = GetArrayItem<[string, number]>; // MixedArray 类型为 string | number
3. 提取 Promise 的泛型参数类型
// 定义类型工具:提取 Promise 的泛型类型
type GetPromiseValue<T> = T extends Promise<infer Value> ? Value : never;

// 测试
type PromiseString = GetPromiseValue<Promise<string>>; // PromiseString 类型为 string
type PromiseUser = GetPromiseValue<Promise<{ id: number }>>; // PromiseUser 类型为 { id: number }
4. 提取函数的参数类型
// 定义类型工具:提取函数参数类型
type GetFunctionParams<Fn> = Fn extends (...args: infer Params) => any ? Params : never;

// 测试
const fn = (name: string, age: number): void => {};
type FnParams = GetFunctionParams<typeof fn>; // FnParams 类型为 [string, number]

// 进一步:提取第一个参数的类型
type FirstParam = GetFunctionParams<typeof fn>[0]; // FirstParam 类型为 string

never

never 表示永不存在的类型

  1. 没有任何类型能赋值给 never(除了 never 自身);
  2. never 可以赋值给任意类型(因为它是所有类型的子类型);
  3. 不会有任何实际值属于 never 类型。
let n: never;
let num: number = 123;
let u: unknown = "hello";
let v: void = undefined;

// 1. 任何类型都不能赋值给 never(除了自身)
n = num;   // ❌ 报错:number 不能赋值给 never
n = u;     // ❌ 报错:unknown 不能赋值给 never
n = v;     // ❌ 报错:void 不能赋值给 never
n = undefined; // ❌ 报错:undefined 也不行
n = n;     // ✅ 仅自身可赋值

// 2. never 可以赋值给任意类型
num = n;   // ✅ 正常
u = n;     // ✅ 正常
v = n;     // ✅ 正常
  1. 泛型的边界约束: 通过泛型约束让不满足条件的泛型类型变为 never,从而达到限制类型范围的目的。
// 定义泛型:仅允许 T 为 string 类型,否则 T 为 never
type OnlyString<T> = T extends string ? T : never;

// 满足条件:T 为 string,结果正常
type Str1 = OnlyString<"hello">; // Str1 = "hello"
type Str2 = OnlyString<string>;  // Str2 = string

// 不满足条件:T 为非 string,结果为 never
type Num = OnlyString<number>;   // Num = never
type Bool = OnlyString<boolean>; // Bool = never
type Unk = OnlyString<unknown>;  // Unk = never

// 实际使用:强制函数参数只能是 string 类型
function printStr<T>(val: OnlyString<T>) {
  console.log(val);
}

printStr("hello"); // ✅ 正常
printStr(123);     // ❌ 报错:number 不能赋值给 never

测试用例

/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'

type cases = [
  Expect<Equal<Last<[]>, never>>,
  Expect<Equal<Last<[2]>, 2>>,
  Expect<Equal<Last<[3, 2, 1]>, 1>>,
  Expect<Equal<Last<[() => 123, { a: string }]>, { a: string }>>,
]

相关链接

分享你的解答:tsch.js.org/15/answer/z… 查看解答:tsch.js.org/15/solution… 更多题目:tsch.js.org/zh-CN

下面是我的公众号,欢迎关注。关注后有新的功能点会及时收到推送。

实战为王!专注于汇总各种功能点,致力于打造一系列能够帮助工程师实现各种功能的想法思路的优质文章。

前端功能点

type-challenges(ts类型体操): 14 - 第一个元素

作者 fxss
2026年1月31日 13:44

14 - 第一个元素

by Anthony Fu (@antfu) #简单 #array

题目

实现一个First<T>泛型,它接受一个数组T并返回它的第一个元素的类型。

例如:

type arr1 = ['a', 'b', 'c']
type arr2 = [3, 2, 1]

type head1 = First<arr1> // 应推导出 'a'
type head2 = First<arr2> // 应推导出 3

在 Github 上查看:tsch.js.org/14/zh-CN

代码

/* _____________ 你的代码 _____________ */

type First<T extends unknown[]> = T extends [] ? never : T[0]

关键解释:

  1. T extends unknown[] 用于约束 T 必须是一个数组类型。
  2. T extends [] 用于判断数组是否为空。
  3. T[0] 用于获取数组的第一个元素。
  4. never 用于表示空数组的情况。

相关知识点

extends

使用维度 核心作用 示例场景
类型维度 做类型约束或条件判断(类型编程核心) 限定泛型范围、判断类型是否兼容、提取类型片段
语法维度 做继承(复用已有结构) 接口继承、类继承
extends 做类型约束或条件判断
  1. 泛型约束:限定泛型的取值范围
// 约束 T 必须是「拥有 length 属性」的类型(比如 string/数组)
function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

// 合法调用(符合约束)
getLength("hello"); // ✅ string 有 length,返回 5
getLength([1, 2, 3]); // ✅ 数组有 length,返回 3

// 非法调用(超出约束)
getLength(123); // ❌ 报错:number 没有 length 属性
  1. 条件类型:类型版 三元运算符
// 基础示例:判断类型是否为字符串
type IsString<T> = T extends string ? true : false;

type A = IsString<"test">; // true(符合)
type B = IsString<123>; // false(不符合)

分布式条件类型(联合类型专用): 当 T 是联合类型时,extends 会自动拆分联合类型的每个成员,逐个判断后再合并结果。

type Union = string | number | boolean;

// 拆分逻辑:string→string,number→never,boolean→never → 合并为 string
type OnlyString<T> = T extends string ? T : never;
type Result = OnlyString<Union>; // Result = string

注意:只有泛型参数是 裸类型(没有被 []/{} 包裹)时,才会触发分布式判断:

// 包裹后不触发分布式,整体判断 [string|number] 是否兼容 [string]
type NoDist<T> = [T] extends [string] ? T : never;
type Result2 = NoDist<Union>; // never(整体不兼容)
  1. 配合 infer:提取类型片段(黄金组合)
// 提取 Promise 的返回值类型
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;

type C = UnwrapPromise<Promise<string>>; // string(提取成功)
type D = UnwrapPromise<number>; // number(不满足条件,返回原类型)
extends 做继承(复用已有结构)
  1. 接口继承:复用 + 扩展属性
// 基础接口
interface User {
  id: number;
  name: string;
}

// 继承 User,并扩展新属性
interface Admin extends User {
  role: "admin" | "super_admin"; // 新增权限属性
}

// 必须包含继承的 + 扩展的所有属性
const admin: Admin = {
  id: 1,
  name: "张三",
  role: "admin"
};

// 多接口继承
interface HasAge { age: number; }
interface Student extends User, HasAge {
  className: string; // 同时继承 User + HasAge
}
  1. 类继承:复用父类的属性 / 方法
class Parent {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  sayHi() {
    console.log(`Hi, ${this.name}`);
  }
}

// 继承 Parent 类
class Child extends Parent {
  age: number;
  constructor(name: string, age: number) {
    super(name); // 必须调用父类构造函数(初始化父类属性)
    this.age = age;
  }
  // 重写父类方法
  sayHi() {
    super.sayHi(); // 调用父类原方法
    console.log(`I'm ${this.age} years old`);
  }
}

const child = new Child("李四", 10);
child.sayHi(); // 输出:Hi, 李四 → I'm 10 years old

补充:类实现接口用 implements(不是 extends

// 定义接口(契约:规定必须有 id、name 属性,以及 greet 方法)
interface Person {
  id: number;
  name: string;
  greet(): void; // 仅定义方法签名,无实现
}

// 类实现接口(必须严格遵守契约)
class Employee implements Person {
  // 必须实现接口的所有属性
  id: number;
  name: string;

  // 构造函数初始化属性
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 必须实现接口的 greet 方法(具体实现由类自己定义)
  greet() {
    console.log(`Hi, I'm ${this.name}, ID: ${this.id}`);
  }
}

// 实例化使用
const emp = new Employee(1, "张三");
emp.greet(); // 输出:Hi, I'm 张三, ID: 1


// 接口1:基础信息
interface Identifiable {
  id: number;
  getId(): number;
}

// 接口2:可打印
interface Printable {
  printInfo(): void;
}

// 类同时实现两个接口(必须实现所有接口的成员)
class Product implements Identifiable, Printable {
  id: number;
  name: string; // 类可扩展接口外的属性

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 实现 Identifiable 的方法
  getId(): number {
    return this.id;
  }

  // 实现 Printable 的方法
  printInfo() {
    console.log(`Product: ${this.name}, ID: ${this.getId()}`);
  }
}

const product = new Product(100, "手机");
console.log(product.getId()); // 100
product.printInfo(); // Product: 手机, ID: 100

unknown

作用是替代 any 处理 类型未知 的场景,同时保证类型检查的安全性。

  1. 所有类型(基本类型、对象、函数、数组等)都可以赋值给 unknown 类型的变量;

  2. unknown 类型的变量不能随意赋值给其他类型(仅能赋值给 unknownany);

  3. 也不能直接操作 unknown 类型的变量(比如调用方法、访问属性、做算术运算),必须先通过类型收窄确定其具体类型,这是它比 any 安全的关键。

  4. any 的区别

any任意类型,会关闭 TypeScript 的类型检查,而 unknown未知类型,保留类型检查,仅允许在确定类型后操作。两者的规则对比如下:

规则 unknown any
所有类型可赋值给它 ✅ 支持 ✅ 支持
它可赋值给其他类型 ❌ 仅能赋值给 unknown/any ✅ 可赋值给任意类型(无限制)
直接操作变量(调用方法 / 访问属性) ❌ 不允许(必须类型收窄) ✅ 允许(关闭类型检查)
// 1. 所有类型都能赋值给 unknown/any
let u: unknown = 123;
u = "hello";
u = [1,2,3];

let a: any = 123;
a = "hello";
a = [1,2,3];

// 2. unknown 仅能赋值给 unknown/any(赋值给其他类型报错)
let num: number = u; // ❌ 报错:Type 'unknown' is not assignable to type 'number'
let u2: unknown = u; // ✅ 正常
let a2: any = u;     // ✅ 正常

// any 可赋值给任意类型(无报错,即使类型不匹配)
let num2: number = a; // ✅ 无报错(但运行时可能出问题,类型不安全)

// 3. 直接操作 unknown 报错,操作 any 无限制
u.toFixed(); // ❌ 报错:Object is of type 'unknown'
a.toFixed(); // ✅ 无报错(即使 a 可能是字符串,TS 不检查)
  1. 类型收窄

2.1 typeof检查(适用于基本类型:number/string/boolean/undefined/null/symbol/bigint

function handleUnknown(val: unknown) {
  // 先通过 typeof 收窄为数字类型
  if (typeof val === "number") {
    console.log(val.toFixed(2)); // ✅ 正常:val 已确定是 number
  }
  // 收窄为字符串类型
  else if (typeof val === "string") {
    console.log(val.toUpperCase()); // ✅ 正常:val 已确定是 string
  }
  // 收窄为布尔类型
  else if (typeof val === "boolean") {
    console.log(val ? "真" : "假"); // ✅ 正常:val 已确定是 boolean
  }
}

handleUnknown(123.456); // 输出 123.46
handleUnknown("hello"); // 输出 HELLO
handleUnknown(true);    // 输出 真

2.2 instanceof检查(适用于引用类型:数组 / 类实例 / RegExp/Date 等)

function handleUnknown2(val: unknown) {
  // 收窄为数组类型
  if (val instanceof Array) {
    console.log(val.push(4)); // ✅ 正常:val 已确定是 Array
  }
  // 收窄为 Date 类型
  else if (val instanceof Date) {
    console.log(val.toLocaleString()); // ✅ 正常:val 已确定是 Date
  }
}

handleUnknown2([1,2,3]); // 输出 4(数组长度)
handleUnknown2(new Date()); // 输出当前时间字符串

2.3 类型断言

let u: unknown = "这是一个字符串";

// 断言为 string 类型后操作
let str = u as string;
console.log(str.length); // ✅ 正常:输出 7

// 错误断言(运行时报错)
let num = u as number;
console.log(num.toFixed()); // ❌ 运行时报错:num.toFixed is not a function

never

never 表示永不存在的类型

  1. 没有任何类型能赋值给 never(除了 never 自身);
  2. never 可以赋值给任意类型(因为它是所有类型的子类型);
  3. 不会有任何实际值属于 never 类型。
let n: never;
let num: number = 123;
let u: unknown = "hello";
let v: void = undefined;

// 1. 任何类型都不能赋值给 never(除了自身)
n = num;   // ❌ 报错:number 不能赋值给 never
n = u;     // ❌ 报错:unknown 不能赋值给 never
n = v;     // ❌ 报错:void 不能赋值给 never
n = undefined; // ❌ 报错:undefined 也不行
n = n;     // ✅ 仅自身可赋值

// 2. never 可以赋值给任意类型
num = n;   // ✅ 正常
u = n;     // ✅ 正常
v = n;     // ✅ 正常
  1. 泛型的边界约束: 通过泛型约束让不满足条件的泛型类型变为 never,从而达到限制类型范围的目的。
// 定义泛型:仅允许 T 为 string 类型,否则 T 为 never
type OnlyString<T> = T extends string ? T : never;

// 满足条件:T 为 string,结果正常
type Str1 = OnlyString<"hello">; // Str1 = "hello"
type Str2 = OnlyString<string>;  // Str2 = string

// 不满足条件:T 为非 string,结果为 never
type Num = OnlyString<number>;   // Num = never
type Bool = OnlyString<boolean>; // Bool = never
type Unk = OnlyString<unknown>;  // Unk = never

// 实际使用:强制函数参数只能是 string 类型
function printStr<T>(val: OnlyString<T>) {
  console.log(val);
}

printStr("hello"); // ✅ 正常
printStr(123);     // ❌ 报错:number 不能赋值给 never

测试用例

/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'

type cases = [
  Expect<Equal<First<[3, 2, 1]>, 3>>,
  Expect<Equal<First<[() => 123, { a: string }]>, () => 123>>,
  Expect<Equal<First<[]>, never>>,
  Expect<Equal<First<[undefined]>, undefined>>,
]

type errors = [
  // @ts-expect-error
  First<'notArray'>,
  // @ts-expect-error
  First<{ 0: 'arrayLike' }>,
]

相关链接

分享你的解答:tsch.js.org/14/answer/z… 查看解答:tsch.js.org/14/solution… 更多题目:tsch.js.org/zh-CN

下面是我的公众号,欢迎关注。关注后有新的功能点会及时收到推送。

实战为王!专注于汇总各种功能点,致力于打造一系列能够帮助工程师实现各种功能的想法思路的优质文章。

前端功能点

Vue-组件通信全攻略

2026年1月31日 13:38

前言

在 Vue 开发中,组件通信是构建复杂应用的基础。随着 Vue 3 的普及,通信方式发生了不少变化(如 defineProps 的引入、EventBus 的退场)。本文将对比 Vue 2 与 Vue 3,带你梳理最常用的 5 种通信方案。

一、 父子组件通信:最基础的单向数据流

这是最常用的通信方式,遵循“Props 向下传递,Emit 向上通知”的原则。

1. Vue 2 经典写法

  • 接收:使用 props 选项。
  • 发送:使用 this.$emit

2. Vue 3 + TS 标准写法

在 Vue 3 <script setup> 中,我们使用 definePropsdefineEmits

父组件:Parent.vue

<template>
  <ChildComponent :id="currentId" @childEvent="handleChild" />
</template>

<script setup lang="ts">
import { ref } from 'vue';
import ChildComponent from './Child.vue';

const currentId = ref<string>('001');
const handleChild = (msg: string) => {
  console.log('接收到子组件消息:', msg);
};
</script>

子组件:Child.vue

<script setup lang="ts">
// 使用 TS 类型定义 Props
const props = defineProps<{
  id: string
}>();

// 使用 TS 定义 Emits,具备更好的类型检查
const emit = defineEmits<{
  (e: 'childEvent', args: string): void;
}>();

const sendMessage = () => {
  emit('childEvent', '这是来自子组件的参数');
};
</script>

二、 跨级调用:通过 Ref 访问实例

有时父组件需要直接调用子组件的内部方法。

1. Vue 2 模式

直接通过 this.$refs.childRef.someMethod() 调用。

2. Vue 3 模式(显式暴露)

Vue 3 的组件默认是关闭的。如果父组件想访问子组件的方法,子组件必须使用 defineExpose

子组件:Child.vue

<script setup lang="ts">
const childFunc = () => {
  console.log('子组件方法被调用');
};

// 必须手动暴露,父组件才能访问
defineExpose({
  childFunc
});
</script>

父组件:Parent.vue

<template>
  <Child ref="childRef" />
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import Child from './Child.vue';

// 这里的类型定义有助于获得代码提示
const childRef = ref<InstanceType<typeof Child> | null>(null);

onMounted(() => {
  childRef.value?.childFunc();
});
</script>

三、 非父子组件通信:事件总线 (EventBus)

1. Vue 2 做法

利用一个新的 Vue 实例作为中央调度器。

import Vue from 'vue';
export const EventBus = new Vue();

// 组件 A 发送
EventBus.$emit('event', data);
// 组件 B 接收
EventBus.$on('event', (data) => { ... });

2. Vue 3 重要变更

Vue 3 官方已移除了 $on$off$once 方法,因此不再支持直接通过 Vue 实例创建 EventBus。

  • 官方推荐方案:使用第三方库 mitttiny-emitter
  • 补充:如果逻辑简单,可以使用 Vue 3 的 provide / inject 实现跨级通信。

provide / inject 示例:

  1. 祖先组件:提供数据 (App.vue)
<template>
  <div class="ancestor">
    <h1>祖先组件</h1>
    <p>当前主题:{{ theme }}</p>
    <Middle />
  </div>
</template>

<script setup lang="ts">
import { ref, provide } from 'vue';
import Middle from './Middle.vue';

// 1. 定义响应式数据
const theme = ref<'light' | 'dark'>('light');

// 2. 定义修改数据的方法(推荐在提供者内部定义,保证数据流向清晰)
const toggleTheme = () => {
  theme.value = theme.value === 'light' ? 'dark' : 'light';
};

// 3. 注入 key 和对应的值/方法
provide('theme', theme);
provide('toggleTheme', toggleTheme);
</script>
  1. 中间组件:无需操作 (Middle.vue)

    中间组件不需要显式接收 theme,直接透传即可

  2. 后代组件:注入并使用 (DeepChild.vue)

<template>
  <div class="descendant">
    <h3>深层子组件</h3>
    <p>接收到的主题:{{ theme }}</p>
    <button @click="toggleTheme">切换主题</button>
  </div>
</template>

<script setup lang="ts">
import { inject } from 'vue';

// 使用 inject 获取,第二个参数为默认值(可选)
const theme = inject('theme');
const toggleTheme = inject<() => void>('toggleTheme');
</script>

四、 集中式状态管理:Vuex 与 Pinia

当应用变得庞大,组件间的关系交织成网时,我们需要一个“单一事实来源”。

  • Vuex:Vue 2 时代的标准。基于 Mutation(同步)和 Action(异步)。

  • Pinia:Vue 3 的官方推荐。

    • 优势:更完美的 TS 支持、没有 Mutation 的繁琐逻辑、极其轻量。
    • 核心stategettersactions

Pinia 示例:

import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({
    name: '张三',
    age: 18
  }),
  actions: {
    updateName(newName: string) {
      this.name = newName;
    }
  }
});

五、 总结与纠错

  1. 安全性建议:在使用 defineExpose 时,尽量只暴露必要的接口,遵循最小暴露原则。
  2. EventBus 警示:Vue 3 开发者请注意,不要再尝试使用 new Vue() 来做事件总线,应当转向 Pinia 或全局状态。

type-challenges(ts类型体操): 12 - 可串联构造器

作者 fxss
2026年1月31日 13:38

12 - 可串联构造器

by Anthony Fu (@antfu) #中等 #application

题目

在 JavaScript 中我们经常会使用可串联(Chainable/Pipeline)的函数构造一个对象,但在 TypeScript 中,你能合理的给它赋上类型吗?

在这个挑战中,你可以使用任意你喜欢的方式实现这个类型 - Interface, Type 或 Class 都行。你需要提供两个函数 option(key, value)get()。在 option 中你需要使用提供的 key 和 value 扩展当前的对象类型,通过 get 获取最终结果。

例如

declare const config: Chainable

const result = config
  .option('foo', 123)
  .option('name', 'type-challenges')
  .option('bar', { value: 'Hello World' })
  .get()

// 期望 result 的类型是:
interface Result {
  foo: number
  name: string
  bar: {
    value: string
  }
}

你只需要在类型层面实现这个功能 - 不需要实现任何 TS/JS 的实际逻辑。

你可以假设 key 只接受字符串而 value 接受任何类型,你只需要暴露它传递的类型而不需要进行任何处理。同样的 key 只会被使用一次。

在 Github 上查看:tsch.js.org/12/zh-CN

代码

/* _____________ 你的代码 _____________ */

/**
 * 定义可串联构造器的类型
 * @template T 当前构造器对象的状态,默认为空对象
 */
type Chainable<T = {}> = {
  /**
   * 用于扩展或修改当前对象的方法
   * @template K 要添加或修改的键,必须是字符串类型
   * @template V 要添加或修改的值的类型
   * @param key 要添加或修改的键,根据情况可能为 never 或 K
   * @param value 要添加或修改的值
   * @returns 一个新的 Chainable 实例,包含更新后的对象状态
   */
  option: <K extends string, V>(key: K extends keyof T ? V extends T[K] ? never : K : K, value: V) => Chainable<Omit<T, K> & Record<K, V>>
  /**
   * 获取当前构造器对象的最终状态
   * @returns 当前对象的状态
   */
  get(): T
}

关键解释:

  • Chainable<T>:泛型参数,代表当前构造器对象的状态,默认为空对象;
  • option(key, value):方法,用于扩展或修改当前对象的状态;
    • K extends string:约束 K 必须是字符串类型;
    • V:要添加或修改的值的类型;
    • key: K extends keyof T ? V extends T[K] ? never : K : K:约束 key 必须是 T 中不存在的属性名,或者 value 类型与 T[K] 不同的属性名;
    • value: V:要添加或修改的值;
    • Chainable<Omit<T, K> & Record<K, V>>:返回一个新的 Chainable 实例,包含更新后的对象状态;
  • get():方法,用于获取当前构造器对象的最终状态;
    • T:当前构造器对象的状态。

相关知识点

extends

使用维度 核心作用 示例场景
类型维度 做类型约束或条件判断(类型编程核心) 限定泛型范围、判断类型是否兼容、提取类型片段
语法维度 做继承(复用已有结构) 接口继承、类继承
extends 做类型约束或条件判断
  1. 泛型约束:限定泛型的取值范围
// 约束 T 必须是「拥有 length 属性」的类型(比如 string/数组)
function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

// 合法调用(符合约束)
getLength("hello"); // ✅ string 有 length,返回 5
getLength([1, 2, 3]); // ✅ 数组有 length,返回 3

// 非法调用(超出约束)
getLength(123); // ❌ 报错:number 没有 length 属性
  1. 条件类型:类型版 三元运算符
// 基础示例:判断类型是否为字符串
type IsString<T> = T extends string ? true : false;

type A = IsString<"test">; // true(符合)
type B = IsString<123>; // false(不符合)

分布式条件类型(联合类型专用): 当 T 是联合类型时,extends 会自动拆分联合类型的每个成员,逐个判断后再合并结果。

type Union = string | number | boolean;

// 拆分逻辑:string→string,number→never,boolean→never → 合并为 string
type OnlyString<T> = T extends string ? T : never;
type Result = OnlyString<Union>; // Result = string

注意:只有泛型参数是 裸类型(没有被 []/{} 包裹)时,才会触发分布式判断:

// 包裹后不触发分布式,整体判断 [string|number] 是否兼容 [string]
type NoDist<T> = [T] extends [string] ? T : never;
type Result2 = NoDist<Union>; // never(整体不兼容)
  1. 配合 infer:提取类型片段(黄金组合)
// 提取 Promise 的返回值类型
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;

type C = UnwrapPromise<Promise<string>>; // string(提取成功)
type D = UnwrapPromise<number>; // number(不满足条件,返回原类型)
extends 做继承(复用已有结构)
  1. 接口继承:复用 + 扩展属性
// 基础接口
interface User {
  id: number;
  name: string;
}

// 继承 User,并扩展新属性
interface Admin extends User {
  role: "admin" | "super_admin"; // 新增权限属性
}

// 必须包含继承的 + 扩展的所有属性
const admin: Admin = {
  id: 1,
  name: "张三",
  role: "admin"
};

// 多接口继承
interface HasAge { age: number; }
interface Student extends User, HasAge {
  className: string; // 同时继承 User + HasAge
}
  1. 类继承:复用父类的属性 / 方法
class Parent {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  sayHi() {
    console.log(`Hi, ${this.name}`);
  }
}

// 继承 Parent 类
class Child extends Parent {
  age: number;
  constructor(name: string, age: number) {
    super(name); // 必须调用父类构造函数(初始化父类属性)
    this.age = age;
  }
  // 重写父类方法
  sayHi() {
    super.sayHi(); // 调用父类原方法
    console.log(`I'm ${this.age} years old`);
  }
}

const child = new Child("李四", 10);
child.sayHi(); // 输出:Hi, 李四 → I'm 10 years old

补充:类实现接口用 implements(不是 extends

// 定义接口(契约:规定必须有 id、name 属性,以及 greet 方法)
interface Person {
  id: number;
  name: string;
  greet(): void; // 仅定义方法签名,无实现
}

// 类实现接口(必须严格遵守契约)
class Employee implements Person {
  // 必须实现接口的所有属性
  id: number;
  name: string;

  // 构造函数初始化属性
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 必须实现接口的 greet 方法(具体实现由类自己定义)
  greet() {
    console.log(`Hi, I'm ${this.name}, ID: ${this.id}`);
  }
}

// 实例化使用
const emp = new Employee(1, "张三");
emp.greet(); // 输出:Hi, I'm 张三, ID: 1


// 接口1:基础信息
interface Identifiable {
  id: number;
  getId(): number;
}

// 接口2:可打印
interface Printable {
  printInfo(): void;
}

// 类同时实现两个接口(必须实现所有接口的成员)
class Product implements Identifiable, Printable {
  id: number;
  name: string; // 类可扩展接口外的属性

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 实现 Identifiable 的方法
  getId(): number {
    return this.id;
  }

  // 实现 Printable 的方法
  printInfo() {
    console.log(`Product: ${this.name}, ID: ${this.getId()}`);
  }
}

const product = new Product(100, "手机");
console.log(product.getId()); // 100
product.printInfo(); // Product: 手机, ID: 100

keyof

keyof 运算符用于获取一个类型(接口、类型别名、对象类型等)的所有公共属性名,并返回这些属性名组成的联合类型。

例如:

interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoKeys = keyof Todo // "title" | "description" | "completed"

Omit

Omit<T, K> 用于从类型 T 中排除 K 中的属性,返回一个新类型。

例如:

interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoPreview = Omit<Todo, 'description'>
// TodoPreview 类型为:
// {
//   title: string
//   completed: boolean
// }

&

& 交叉类型运算符用于将多个类型合并为一个新类型,它会将所有属性合并到新类型中。

例如:

interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoPreview = Omit<Todo, 'description'> & {
  time: Date
}
// TodoPreview 类型为:
// {
//   title: string
//   completed: boolean
//   time: Date
// }

基础类型的交叉,只有类型完全一致时才会保留原类型,类型不一致时会得到 never

type A = number & string // never
type B = number & boolean // never
type C = number & symbol // never
type D = string & boolean // never
type E = string & symbol // never
type F = boolean & symbol // never

同名属性的类型冲突时,会得到 never

interface A {
  x: string; // 同名属性,类型 string
}
interface B {
  x: number; // 同名属性,类型 number
}

type C = A & B;
// C 的 x 类型为 string & number → never
const c: C = {
  x: 123, // 报错:类型 number 不能赋值给 never
  x: "abc" // 同样报错
};

Record

Record<K, T> 是用于定义键值对结构对象类型,能快速指定对象的键类型和值的统一类型。

第一个参数 K(键类型):必须是 string | number | symbol 及其子类型(比如字符串字面量、数字字面量、联合类型),否则会报错。 第二个参数 T(值类型):可以是任意类型(基础类型、对象类型、函数类型等)。

  1. 字符串键 + 基础类型值
// 用 Record 定义:键是 string,值是 number
type ScoreMap = Record<string, number>;
// 等价于手动写索引签名:{ [key: string]: number }
type ScoreMap2 = { [key: string]: number };

// 正确使用:所有键的值必须是数字
const studentScores: ScoreMap = {
  2: 90, // 数字字面量键会自动转为字符串,合法
  "李四": 85,
  wangwu: 95
};
  1. 字面量联合键 + 基础类型值:用字符串 / 数字字面量联合类型作为键,定义固定键、统一值类型的映射表(如状态码、枚举映射、地区编码),TS 会严格校验键的合法性(只能是联合类型中的值)
// 固定键:联合类型(字符串字面量)
type UserRole = "admin" | "editor" | "visitor";
// Record 定义:键只能是 UserRole 中的值,值是 string(角色描述)
type RoleDesc = Record<UserRole, string>;

// 正确使用:必须包含所有固定键,值为字符串
const roleDescription: RoleDesc = {
  admin: "超级管理员,拥有所有权限",
  editor: "内容编辑,可修改文章",
  visitor: "游客,仅可查看内容"
};

// 错误示例1:缺少键(editor)→ TS 报错
const err1: RoleDesc = { admin: "xxx", visitor: "xxx" };
// 错误示例2:多余键(test)→ TS 报错
const err2: RoleDesc = { admin: "xxx", editor: "xxx", visitor: "xxx", test: "xxx" };
// 错误示例3:值类型错误(数字)→ TS 报错
const err3: RoleDesc = { admin: 123, editor: "xxx", visitor: "xxx" };

Record + Partial → 固定键,值类型可选(部分赋值)

type UserRole = "admin" | "editor" | "visitor";
// 需求:固定角色键,允许部分赋值(不是所有角色都需要写描述)
type PartialRoleDesc = Partial<Record<UserRole, string>>;

// 正确使用:可包含任意数量的键(0个、1个、多个、全部)
const emptyDesc: PartialRoleDesc = {}; // 正常
const partialDesc: PartialRoleDesc = { admin: "超级管理员" }; // 正常
const fullDesc: PartialRoleDesc = { admin: "xxx", editor: "xxx", visitor: "xxx" }; // 正常

测试用例

/* _____________ 测试用例 _____________ */
import type { Alike, Expect } from '@type-challenges/utils'

declare const a: Chainable

const result1 = a
  .option('foo', 123)
  .option('bar', { value: 'Hello World' })
  .option('name', 'type-challenges')
  .get()

const result2 = a
  .option('name', 'another name')
  // @ts-expect-error
  .option('name', 'last name')
  .get()

const result3 = a
  .option('name', 'another name')
  .option('name', 123)
  .get()

type cases = [
  Expect<Alike<typeof result1, Expected1>>,
  Expect<Alike<typeof result2, Expected2>>,
  Expect<Alike<typeof result3, Expected3>>,
]

type Expected1 = {
  foo: number
  bar: {
    value: string
  }
  name: string
}

type Expected2 = {
  name: string
}

type Expected3 = {
  name: number
}

相关链接

分享你的解答:tsch.js.org/12/answer/z… 查看解答:tsch.js.org/12/solution… 更多题目:tsch.js.org/zh-CN

下面是我的公众号,欢迎关注。关注后有新的功能点会及时收到推送。

实战为王!专注于汇总各种功能点,致力于打造一系列能够帮助工程师实现各种功能的想法思路的优质文章。

前端功能点

❌
❌