Go 语言中的 channel

Go 语言中的通道(channel)是用于在多个 goroutine 之间通信,它就像是一个传送带或者队列,遵循先入先出(FIFO)的规则,保证数据收发的顺序。同时,每一个通道都是一个具体类型的管道,所以在声明 channel 的时候需要为其指定元素类型。 声明以及初始化 channel 在 Go 语言中,声明 channel 的语法为: var 变量名称 chan 元素类型 // 如 var ch1 chan int // 声明一个传递整型的通道 var ch2 chan string // 声明一个传递字符串的通道 var ch3 chan struct{} // 声明一个传递结构体的通道 只声明而未初始化的 channel,其默认零值是 nil,此时是不能够被使用的,只有对它初始化后方可使用。使用 make 关键字初始化 channel,格式如下: make(chan 元素类型, [缓冲大小]) 这个缓冲大小是可选的,其值是一个整型。所以我们可以这样定义 channel: ch1 := make(chan int) // 定义无缓冲的通道 ch2 := make(chan int, 1) // 定义一个缓冲区大小为 1 的通道 channel 操作 channel 有三种操作:发送(send)、接收(receive)和关闭(close)。而发送和接收操作,都是使用 <- 符号来完成。 当 channel 变量在 <- 左边时,表示将右边的数发送到通道中,当 channel 变量在 <- 右边时,表示从通道中接收数据。例如: func main() { ch := make(chan int, 1) // 定义一个缓冲区大小为 1 的通道 ch <- 3 // 将数字 3 发送到通道中 n := <-ch // 从通道中接收数字并赋值给变量 n fmt.Println(n) // 输出:3 // 如果不用变量接收值,也可以忽略接收值 <- ch } 使用 close 方法关闭通道: close(ch) 对已关闭的通道执行操作时,它的表现会有一些不同: 对已关闭的通道再发送值会导致 panic,如: ch := make(chan int, 2) // 为了证明不是缓冲大小导致的panic,这里设置缓冲大小为2 ch <- 3 close(ch) // 由于已经关闭通道,再发送值会导致 panic ch <- 4 // panic: send on closed channel 对已关闭的通道进行接收,会一直获取值直到通道为空,此时再接收会得到对应类型的零值,如: ch := make(chan int, 2) ch <- 3 ch <- 4 close(ch) fmt.Println(<-ch) // 3 fmt.Println(<-ch) // 4 fmt.Println(<-ch) // 0 fmt.Println(<-ch) // 0 对已关闭的通道再次执行关闭,也会导致 panic,如: ch := make(chan int, 1) ch <- 3 close(ch) // 正常关闭通道 close(ch) // 再次关闭,导致panic: close of closed channel 无缓冲通道 无缓冲通道也叫阻塞的通道,使用 make 初始化 channel 时,不指定缓冲大小,即 make(chan int),这样就创建了一个无缓冲的通道。 无缓冲通道的核心作用是通过强制同步机制来实现协程间的精准协作,特点就是要求发送和接收双方必须同时准备好,否则数据传递就会阻塞。其实也好理解,两个协程之间没有了缓冲区,数据的传递就需要两边同时做好准备。例如: ch := make(chan int) ch <- 3 // fatal error: all goroutines are asleep - deadlock! 由于 ch 通道没有缓冲区,同时又没有接收者,所以 3 就无法被传递,导致程序死锁错误了。解决办法就是添加一个接收者,且必须在一个新的协程中添加接收者,代码: func main() { var wg sync.WaitGroup ch := make(chan int) wg.Add(1) // 创建一个 goroutine 从通道接收值 go func() { defer wg.Done() ret := <-ch fmt.Printf("接收到值:%v\n", ret) }() ch <- 3 // 发送数据到通道中 wg.Wait() } 上面代码中,数据 3 被正确发送和接收,因为我们先创建了一个接收者,然后在数据发送时,接收者就已经准备好接收了。这里最重要的就是先创建的接收者必须要在一个新协程中,因为如果在同一协程中,不管是先接收还是先发送,都会导致程序阻塞,无法继续执行。 综上所述,无缓冲通道是同步的“桥梁”,适合实时数据的传递,避免延迟处理。 有缓冲通道 有缓冲通道顾名思义就是通道中有了缓冲区,就可以存放一些数据。使用 make 初始化 channel 时,只要指定了缓冲容量大于零,那么该通道就是有缓冲通道。 因为缓冲区的存在,可以允许收发存在时间差,是一个异步过程,能减少阻塞,更适合高频操作。但需要注意缓冲区满或者空时的操作,仍会发生阻塞。 有缓冲通道,只要通道有数据就可以随时接收。如: ch := make(chan int, 1) ch <- 1 fmt.Println(<-ch) // 1 需要注意2个会发生阻塞的场景: 当缓冲区满时,发送方阻塞; 当缓冲区空时,接收方阻塞。 ch1 := make(chan int, 1) ch1 <- 1 ch1 <- 2 // 缓冲区已被填满,无法再发送,导致阻塞。 ch2 := make(chan int, 1) ret := <- ch2 // 缓冲区为空,无法再接收,导致阻塞。 多返回值模式 通道支持多返回值模式,考虑到通道关闭了但还有值的情况,可以接收两个值,第一个值是通道的值,第二个值是通道是否关闭。如: value, ok := <- ch 其中: value :从通道中取出的值,如果通道关闭则返回对应类型的零值。 ok :通道关闭也无值时,返回 false,否则返回 true。 例如: func main() { ch := make(chan int, 2) ch <- 1 ch <- 2 close(ch) for { v, ok := <-ch if !ok { fmt.Println("channel is closed") break } fmt.Println(v) } } // 运行结果:// // 1 // 2 // channel is closed for range 接收值 可以使用 for range 循环从通道中接收值,当通道被关闭后,会在通道内的值全部被接收后自动退出循环。上面的例子可以改成如下: func main() { ch := make(chan int, 2) ch <- 1 ch <- 2 close(ch) for v := range ch { fmt.Println(v) } } 单向通道 单向通道用于限制通道的操作权限,使其仅支持发送或接收数据。这种通过类型系统强制约束程序的行为可以提高代码的安全性和可读性。 单向通道分为两种类型: 发送通道(chan <- T):只能向通道发送数据,无法接收。 接收通道(<- chan T):只能从通道接收数据,无法发送。 箭头 <- 和关键字 chan 的相对位置表明了当前通道允许的操作。这里举例生产者和消费者的角色分工: package main import "fmt" // 生产者,仅发送数据 func producer(out chan<- int) { for i := 0; i < 3; i++ { out <- i } close(out) } // 发送者,仅接收数据 func consumer(in <-chan int) { for v := range in { fmt.Println(v) } } func main() { ch := make(chan int) go producer(ch) consumer(ch) } select select 关键字是专为并发编程设计的控制结构,主要用于在多个通道操作之间进行非阻塞选择,实现协程间的高效同步与通信。 说白了就是select可以同时监听多个通道的读写操作,当任意一个通道就绪(数据可读或可写)时,自动执行对应的 case 分支。没错,select 也是搭配 case 使用,与 switch 结构一样。 以下是一个简单的例子,从两个协程分别读取通道值: package main import ( "fmt" "time" ) func g1(ch chan struct{}) { time.Sleep(1 * time.Second) ch <- struct{}{} } func g2(ch chan struct{}) { time.Sleep(2 * time.Second) ch <- struct{}{} } func main() { ch1 := make(chan struct{}) ch2 := make(chan struct{}) go g1(ch1) go g2(ch2) select { case <-ch1: fmt.Println("ch1") case <-ch2: fmt.Println("ch2") } } 上例中打印结果永远是 ch1,因为 g1 执行时间更短,ch1 总是会先到达。而如果 g1 和 g2 内部的等待时间设为一致,即触发多个 case 同时就绪的情况,此时 select 会随机选择一个执行,而不会执行先 case 的 ch1,这样设定是为了避免“饥饿”,确保程序行为的不可预测性和公平性。 select 还支持 default 分支,当没有 case 分支就绪时,default 分支会执行,避免阻塞。例如: func main() { ch := make(chan int, 1) ch <- 1 select { case ch <- 2: fmt.Println("发送成功") default: fmt.Println("通道已满") } } 上例中,当通道 ch 满时,default 分支会执行,这样就可以检测通道状态。 但是使用 default 分支会带来另一个问题,就是永远只执行 default 分支了。有些场景中,比如获取请求响应,一般会有些耗时的,我们希望程序等待响应,但是又不想程序等不到结果而阻塞住,此时更适合的方式是加上超时处理,如下: package main import ( "fmt" "time" ) func reponseData(ch chan struct{}) { time.Sleep(3 * time.Second) ch <- struct{}{} } func main() { ch := make(chan struct{}) go reponseData(ch) select { case <-ch: fmt.Println("reponse success") case <-time.After(5 * time.Second): // 设置 5 秒超时 fmt.Println("timeout") } } 使用 time.After() 可以设置超时分支,程序中设置了 5 秒的超时,如果协程 reponseData 中响应时间超过 5 秒,则会执行超时分支,否则会获取响应。
 · 12 min read 

Go 语言互斥锁与读写锁

Go 语言中“锁”的作用是保护共享资源。当多个协程(goroutine)同一时间访问(或修改)同一个数据资源时,会出现数据竞争的问题,导致最终获取的效果与期望不一致。所以需要一把“锁”,锁住这个数据,让它同一时刻只能被一个协程访问(或修改)。 Go 语言中提供了两种锁:互斥锁(Mutex)和读写锁(RWMutex)。本文主要介绍一下这两种锁机制的应用。阅读本文前需要先了解 Go 协程的知识。 互斥锁(Mutex) “互斥”的意思是协程之间对共享资源的独占访问控制,一旦某个协程获取锁,其他尝试获取锁的协程会被阻塞,直到锁被释放。 用简单的算术例子来演示一下锁的作用。首先看下面代码场景,循环 1000 个协程对变量 num 进行累加,运行代码后,会发现最后的输出结果不一定是 1000,且每次运行结果都不一样。 package main import ( "fmt" ) var ( num int wg sync.WaitGroup ) func add() { num += 1 wg.Done() } func main() { wg.Add(1000) for i := 0; i < 1000; i++ { go add() } wg.Wait() fmt.Print(num) } 此例子就是并发数据竞争导致的结果,多个协程同时修改共享变量 num时没有进行同步保护。就比如两个协程同时读取到 num=100,各自加 1 后都写回 101,实际只增加了一次。我们可以使用 -race 参数来运行代码来检测数据竞争: // 使用 -race 参数来运行代码 go run -race main.go 会输出类似下面这样的结果: ================== WARNING: DATA RACE Read at 0x000100802e10 by goroutine 10: main.add() /Users/wungjyan/self/goDemo/main.go:14 +0x28 Previous write at 0x000100802e10 by goroutine 6: main.add() /Users/wungjyan/self/goDemo/main.go:14 +0x40 Goroutine 10 (running) created at: main.main() /Users/wungjyan/self/goDemo/main.go:21 +0x44 Goroutine 6 (finished) created at: main.main() /Users/wungjyan/self/goDemo/main.go:21 +0x44 ================== 这就表示发生了数据竞争(DATA RACE),代码第 14 行处,goroutine 10 进行了“读”,而 goroutine 6 进行了“写”,两个操作针对同一内存地址 0x000100802e10,对应代码中的共享变量 num。 针对这个场景,就要使用互斥锁来解决了。互斥锁需要引入包 sync,并使用 sync.Mutex 类型来创建锁,提供了两个方法:Lock() 和 Unlock(),分别用来获取锁和释放锁。使用很简单,来修改下代码,加上锁机制: package main import ( "fmt" "sync" ) var ( num int wg sync.WaitGroup mu sync.Mutex // 定义全局锁 ) func add() { mu.Lock() // 加锁 defer mu.Unlock() // defer 延迟解锁 num += 1 // mu.Unlock() // 如果不用 defer,在这里解锁是一样的 wg.Done() } func main() { wg.Add(1000) for i := 0; i < 1000; i++ { go add() } wg.Wait() fmt.Print(num) } 如代码中的注释,简单就加上了锁,现在再运行代码,结果永远是输出 1000。我们用锁来保护 num += 1 这个操作,当多个协程访问时,只允许一个协程持有锁,其他协程被阻塞,直到锁被释放再继续让下一个协程获取锁,这样来保证了结果的正确计算。 说到这里,我想到了一个问题,既然锁会让协程阻塞,那是否就是去了协程多并发的优势?上面这个例子只是为了演示锁的作用,如果不使用协程和锁,程序计算其实要快得多。实际开发的场景,应该是要综合考虑,平衡执行效率,本文就不多探究了。 读写锁(RWMutex) 读写锁,也可以说是“读写互斥锁”,本质是区分了“写锁”和“读锁”。为什么要区分呢?上面说到的互斥锁,是完全互斥的,但实际场景是读多写少的场景多,当我们并发去读取一个资源而不修改资源时,是没有必要加互斥锁的,因为读操作本身是线程安全的,不管同时多少个“读”,获取的结果是一样的。 而读写锁的优势就是,当协程获取到读锁后,其他的协如果是获取读锁则继续获得锁,如果是获取写锁就会等待;当一个协程获取写锁后,其他的协程无论是获取读锁还是写锁都会等待。总结就是: 读读不互斥,多个协程同时获取读锁,无阻塞并行执行; 读写互斥,读时禁止写,写时禁止读; 写写互斥,写操作串行执行,保证数据一致性。 读写锁使用 sync.RWMutex 类型来创建锁,包含四个方法: Lock():获取写锁; Unlock():释放写锁; RLock():获取读锁; RUnlock():释放读锁。 读写锁的使用方式和互斥锁的使用基本一致,为了证明在“读多写少”的场景下,读写锁更具性能优势,我参考了网上一段测试代码,同样的逻辑,使用互斥锁和读写锁分别测试耗时,代码如下: package main import ( "fmt" "sync" "time" ) var ( num int wg sync.WaitGroup mu sync.Mutex rwMu sync.RWMutex ) // 使用互斥锁的 写操作 func writeWithLock() { mu.Lock() defer mu.Unlock() num += 1 time.Sleep(10 * time.Millisecond) // 假设写操作耗时 10 毫秒 wg.Done() } // 使用互斥锁的 读操作 func readWithLock() { mu.Lock() defer mu.Unlock() _ = num // 显示读操作 time.Sleep(time.Millisecond) // 假设写操作耗时 1 毫秒 wg.Done() } // 使用读写锁的 写操作 func writeWithRWLock() { rwMu.Lock() // 加写锁 defer rwMu.Unlock() // 释放写锁 num += 1 time.Sleep(10 * time.Millisecond) // 假设写操作耗时 10 毫秒 wg.Done() } // 使用读写锁的 读操作 func readWithRWLock() { rwMu.RLock() // 加读锁 defer rwMu.RUnlock() // 释放读锁 _ = num // 显示读操作 time.Sleep(time.Millisecond) // 假设写操作耗时 1 毫秒 wg.Done() } func runTest(wf, rf func(), wc, rc int) { start := time.Now() defer func() { fmt.Printf("程序耗时: %v\n", time.Since(start)) }() // 循环 wc 个写操作协程 for i := 0; i < wc; i++ { wg.Add(1) go wf() } // 循环 rc 个读操作协程 for i := 0; i < rc; i++ { wg.Add(1) go rf() } wg.Wait() } func main() { // 互斥锁,10个写,1000个读 // runTest(writeWithLock, readWithLock, 10, 1000) // 程序耗时: 1.255126125s // 读写锁,10个写,1000个读 runTest(writeWithRWLock, readWithRWLock, 10, 1000) // 程序耗时: 109.684875ms } 从上面代码的测试结果中,不难看出,在读多写少的场景下(读的单位耗时一般比写的单位耗时少),读写锁比互斥锁的性能高很多。可以修改这段代码来测试,如果读操作和写操作数量级差别不大,那么测试结果的差距则不明显。 总结 本文简单介绍了一下互斥锁和读写锁的区别和使用方式,它们的特性与使用场景总结如下: 互斥锁: 完全排他:无论是读还是写操作,同一时间只允许一个协程持有锁。 简单直接:无需区分操作类型,直接加锁/解锁。 适用场景:主要适用写操作频繁的场景(写占比超过 20%),如实时日志写入。 读写锁: ​读并行,写排他:允许多个读协程同时持有锁,但写锁完全排他。 ​性能优势:在读多写少的场景下,显著减少锁竞争。 适用场景:适合读操作远多于写操作的场景(读占比超过90%),如数据库查询。
 · 10 min read 

Go 语言defer详解

Go 中的 defer 是一种延迟执行机制,用于在函数返回前执行特定操作。它在资源管理、错误处理和性能调试等场景中具有重要作用。本文详细记录 defer 的用法。 基本用法 defer 的使用方法很简单,放在要执行的操作之前即可,会在函数返回前执行,如: func main() { defer fmt.Print("我是defer执行~") fmt.Println("我是main函数") // 执行结果 // 我是main函数 // 我是defer执行~ } 注意 defer 后面要接的是一个函数调用,如下代码: func main() { // 错误,这里应该是函数调用 // defer func(){ // fmt.Print("defer") // } // 修改为函数调用 defer func() { fmt.Print("defer") }() fmt.Print("main") } 执行顺序 多个 defer语句按后进先出(LIFO) 的顺序执行: func main() { defer fmt.Println(1) defer fmt.Println(2) defer fmt.Println(3) fmt.Println("main") // 执行结果 // main // 3 // 2 // 1 } 循环 defer,遵循后进先出原则: func main() { for i := 0; i < 5; i++ { defer fmt.Println(i) } // 执行结果 // 4 // 3 // 2 // 1 // 0 } 参数预计算 defer 执行方法的参数,在声明时即被固定,无法执行时动态取值,如: func main() { n := 10 defer fmt.Println(n) // 10 n = 20 } 代码 fmt.Println(n) 的 n在 defer 声明时已经固定为了 10,即使后面修改了值,也无法改变 defer 的执行结果。若需要动态取值,可以使用闭包: func main() { n := 10 defer func() { fmt.Println(n) // 20 }() n = 20 } 修改返回值 注意 defer 可以修改命名返回值,会影响最终结果: func getNum() (x int) { defer func() { x++ }() return 1 } func main() { fmt.Print(getNum()) // 2 } 代码中一个误区就是以为 return 1 之前执行 defer,其实是先将 x 赋值为 1,再执行 defer,所以 x 的值是 2。getNum代码等价以下形式: func getNum() (x int) { x = 1 func() { x++ }() return } 场景案例 资源释放 在资源管理场景中,defer 可以用于释放资源,如文件、数据库连接等,避免内存泄漏。如: func readFile() error { file, _ := os.Open("test.txt") defer file.Close() // 这里用defer延迟关闭,一目了然 // 下面执行文件操作 } 错误处理 defer 可以结合 recover 捕获 panic: func main() { defer func() { if r := recover(); r != nil { fmt.Println("捕获异常:", r) // 捕获异常: panic~ } }() panic("panic~") // 这里主动 panic } 性能分析 记录函数执行时间或调试信息: func main() { start := time.Now() defer func() { fmt.Printf("程序执行时间:%v\n", time.Since(start)) }() time.Sleep(1 * time.Second) }
 · 4 min read 

Go 语言切片的值传递

Go语言中的切片类型,在作为函数参数传递时,是“值传递”还是“引用传递”? 答案正如标题所言,是值传递。但是有时候的实际表现,会让人误以为是引用传递。本文详细分析一下这个问题。 引用传递的效果 之所以很多人觉得切片作为函数参数传递时是引用传递,原因就是在函数内部修改参数时,影响了原切片的值,这里有个例子: func changeSlice(s []int){ s[0] = 100 } func main(){ s1 := []int{1, 2, 3} changeSlice(s1) fmt.Print(s1) // [100 2 3] } 代码中,切片 s1 作为参数传递给函数 changeSlice,函数内部修改了切片的第一项值,最后打印切片 s1,发现确实是被修改了。这妥妥的就是引用传递的效果,但实际真的如此吗?来改一下函数changeSlice: func changeSlice(s []int){ s = append(s, 4) } 此时再打印 s1,就会发现其值还是 [1 2 3],并没有被追加元素,这显然表现出了值传递的效果。至此,人们对于切片作为函数参数传递时,是值传递还是引用传递,就有了分歧。这里我可以明确告知,本质就是值传递。 值传递的本质 切片作为函数参数传递时,传递的其实是切片本身的副本,而非原始切片的内存地址。切片底层结构包含了三个字段: array:指向底层数组的指针; len:当前元素的个数; cap:底层数组的容量。 当切片作为参数传递时,其实传递的是这三个字段的副本,而非整个底层数组的副本。函数修改切片元素时,由于是同一份底层数组的指针,所以会直接影响原切片,但是修改切片元信息(如扩容)是不会影响原切片的。 所以上面例子中,直接修改切片第一项元素值s[0] = 100时,由于切片副本的 array字段与原切片指向同一底层数组,所以修改元素影响了原数据。 而在给切片副本扩容时,即上例中 s = append(s, 4),由于扩容会修改底层数组地址,此时 s 的 array字段指向了新的地址,所以原切片 s1 没有被影响。 案例分析 上面说到,扩容会改变底层数组地址,此时原数据不会被影响。那如果在 append 时,切片的容量还够时呢?例如: func changeSlice(s []int) { s = append(s, 4) } func main() { s1 := make([]int, 3, 5) s1[0], s1[1], s1[2] = 1, 2, 3 changeSlice(s1) fmt.Print(s1) } 代码中使用 make 初始化了一个切片,指定了长度是 3,容量是 5,当三个长度被填充后,使用函数给切片追加第 4 个元素,此时 s1 的值是多少呢? 按照之前说的,只有扩容时才会改变底层数组,但此时追加一个元素时,容量还够,无需扩容,所以改变的还是原底层数组,所以 s1应该是 [1 2 3 4]。但其实最后 s1的值还是 [1 2 3]。 作为初学者,我就在这里困惑过,为什么不扩容还是不能修改原切片数据呢?原因其实是我们忽略了一个字段,那就是切片长度 len。此例中修改的确实还是同一份底层数组,但是原切片中的长度还是 3,它的可见范围还是前 3 个元素,所以 s1 最后的值还是 [1 2 3]。其实 s1 的底层数组已经被修改了,证明: fmt.Print(s1[:5]) // [1 2 3 4 0] 使用 s1[:5]截取底层数组所有元素,即可以看到被追加的元素 4,以及最后一个零值。
 · 5 min read 

开发 AI 对话功能的踩坑记录

最近给公司的 APP 开发了一个 AI 对话的功能,是直接做成 H5 页面内嵌到 APP 中的。功能本身不难,就是调用第三方 AI 接口,做点前端效果,只是过程中踩了点小坑,在此记录下。 Websocket的关闭与重连 我调用的 AI 接口是经过服务端转发了一层,最终调用方式是使用的 Websocket。因为需求的原因,会有很多场景需要重新组装连接地址,然后重新连接 Websocket,这里面就涉及连接关闭问题,只有旧的连接关闭了才能重新连接。 最开始我的做法是在每次连接 Websocket 前都先判断一下是否已有连接,有的话就先关闭,伪代码大致如下: connectWebSocket(){ // 如果有连接则关闭 if(this.socket){ this.socket.close(); this.socket = null; } // 创建新连接 let url = 'xxxx' this.socket = new WebSocket(url); } 这样做看似也没有问题,确实功能也能跑起来,但是发现总会报错一次,这个报错一直没找到原因。后来跟服务端沟通了下,才知道光前端这边关闭也不行,也需要等服务端那边关闭才行。也就是说我在前端关闭连接后,马上就重新连接了新的,此时服务端可能还来不及响应或者判断(这里服务端说的也比较含糊),但我理解了我这边关闭后应该等待一下再重新连接,因为 Websocket 关闭也会有一个“握手”的过程,只有服务端也响应了关闭帧才算真正关闭了这个连接。 等待连接,加个定时器是最简单的方式,但我不想这样干,因为这个等待的时间是不确定的,我不想给一个较长的时间,也不敢给一个太短的时间(其实应该会很快)。后来我想到了另一个方式,Websocket 关闭后是有关闭回调的,因为不熟悉它所以一开始没想到这个,有了这个回调那就好办了,我在关闭回调中判断是否需要重连即可。伪代码: // 开启连接 connectWebSocket(){ // ... // 连接关闭回调 this.socket.onclose = () => { this.socket = null // 判断是否需要重新连接,这个状态是在关闭前决定的 if (this.needReconnect) { setTimeout(() => { this.connectWebSocket(); }, 1000); } } } // 关闭连接 closeConnect(needReconnect) { if ( this.socket && this.socket.readyState !== WebSocket.CLOSED && this.socket.readyState !== WebSocket.CLOSING ) { this.needReconnect = needReconnect; this.socket.close(); this.socket = null; } } 这样在需要重新连接的时候,直接调用 closeConnect 方法,并根据当前情况决定是否要重新连接,传入对应的参数即可。 消息渲染 Websocket 返回的 AI 回复内容,是一段一段的,并不是一次性下发完整的内容,所以需要拼接消息,然后也做上了打字机效果。这里面有个问题就是回复的内容是 Markdown 格式的,你需要将文本转为 Html 格式再渲染。在转换格式的时机上,走了两次弯路导致效果不理想。 最开始我做的是每次接收到消息时,就直接转换为 html 文本再拼接,伪代码: // WebSocket 接收消息时调用 receiveMessage(content) { const newHtml = marked(content); // 将 Markdown 转为 HTML this.fullHtmlBuffer += newHtml; // 将 HTML 内容追加到缓冲区 // 拿 this.fullHtmlBuffer 内容做打字机效果,拼接所有消息 this.startTypingEffect(); } 上面这样写的话,问题很严重,因为 markdown 解析基本失败了。稍微思考下也发现了原因,就是 AI 回复的内容不仅是一段一段的,连成对出现的 markdown 符号可能都是拆散的,比如文本加粗,它的回复可能是 **hello 、 ,world* 和 * 这三段,显然单独解析哪一段都不能成功,所以后来调整了方式,就是先拼接 markdown 文本,再解析格式: receiveMessage(content){ // 拼接 markdown 文本 this.messageBuffer += content; // 拼接好的文本再解析成 html const fullHtml = marked(this.messageBuffer); } 最后利用打字机效果,将 fullHtml 文本慢慢渲染到页面上,我用的是 Vue 开发的,所有渲染 html 很简单,类似这样: <div v-html="answeringText"></div> 到这里,再加上定制的样式,渲染效果是做到了,但又出现另一个问题,就是打字机效果中,会闪现 html 的标签符合,如 <、> 这样,观感会不好。这个问题我在开发完整体功能后尝试解决,但一直没成功。直到快提测前突然想到换种渲染方式看看,于是真给解决了。做法也很简单,就是将 markdown 文本的解析,延后到渲染时,直接在 v-html 指令中解析 markdown,大致流程代码如下: // 接收消息拼接到 messageBuffer,再开启打字机 receiveMessage(content){ this.messageBuffer += content; this.startTypingEffect(); } // 打字机效果简单实现 startTypingEffect() { if (this.typingInterval) return; this.typingInterval = setInterval(() => { if (this.typingIndex < this.messageBuffer.length) { // 分割 markdown 文本 this.answeringText = this.messageBuffer.slice( 0, this.typingIndex + 1 ); this.typingIndex++; } else { clearInterval(this.typingInterval); this.typingInterval = null; } }, 30); } 再渲染: <div v-html="marked(answeringText)"></div> 此时就不会再有 html 标签闪现的问题了。问题的原因我猜测跟之前的分步解析 markdown 问题一样,就是渲染到 < 时还不能完整渲染出 html 内容,只有等渲染到 > 时才算完毕,所以会先闪。但是现在改进的写法,就可以避免分步解析 html 标签符号的问题。 数学公式的解析 AI 的回复内容中会包含数学公式,一开始我没处理,就被测试提出了 bug: 这个问题也好解决,我用的是 markdown-it 来解析 markdown 文本的,它有个插件 markdown-it-katex 就可以解析数学公式。按照文档加入了插件,确实可以正确解析数学公式,但是这个插件需要引入katex 的样式,官方是提供的 cdn 链接,用 link 引入的: <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.5.1/katex.min.css" /> 但正式项目中,我不敢直接用公开的 cdn 连接,于是我直接下载这个样式文件到项目中引入了,但是发现还需要一堆字体文件: 我不想下载这一堆字体,于是换个方法,就是直接 npm 安装 katex 这个插件,然后引入它暴露出的样式即可。但是!这样是不行的。 我不得不说,坑无处不在,npm 安装的 katex 版本是 v0.16.11,但是插件 markdown-it-katex 需要的是 v0.5.1 版本的样式,好,那我直接安装 katex@0.5.1 就好了,但是等我切换版本安装后,骤然发现这个版本的 npm 包根本没暴露样式文件!我真的是大写的无语了,最后我不得不下载了所有的字体,幸好每个字体文件也不大,都是十几 k 大小的,然后下载了 v0.5.1 版本的样式文件到项目中引入。 这个问题看似好解决,但是也耽误了我不少时间,在使用第三方插件包时,还是要注意版本更新问题啊,太久的包还是能不用就不用的好,就比如这个 markdown-it-katex 包最后一次更新还是 8 年前,但奈何周下载量还有几万的,不得不用。 总结 其实这个功能开发中,还有很多小细节,文章里无法细说。只不过这是个好的开始,对于我后续开发类似的功能,有了些许经验。后面我可能会开发一个自己的 AI 助手功能。
 · 10 min read