# SSE实现逐字符展示

# 使用EventSource

const start = () => {
    const source = new EventSource('/proxyApi/events')
    source.addEventListener('message', (e) => {
        console.log('Message from server:', e.data)
        content.value += `${e.data}\n\n`
        const message = e.data
        if (message === '[DONE]') {
            source.close()
        }
    })
    return source.close
}
1
2
3
4
5
6
7
8
9
10
11
12

前端代码

<template>
    <div>
        <h3>server sent events</h3>
        <div id="sse">{{ content }}</div>
    </div>
</template>

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

defineOptions({ name: 'SSEView' })

const content = ref<string>('')

const source = new EventSource('http://127.0.0.1:3000/events')
source.addEventListener('message', (e) => {
    console.log('Message from server:', e.data)
    // content.value += `${e.data}\n\n`
    const message = e.data
    console.log('message: ', message)
    displayMessageWithEffect(message)
})

function displayMessageWithEffect(message: string) {
    let index = 0
    const container = document.getElementById('sse')
    if (container) {
        const newMessageElement = document.createElement('p')
        const intervalId = setInterval(() => {
            newMessageElement.textContent += message[index]
            index++

            // 当所有字符都显示完时,停止定时器
            if (index === message.length) {
                clearInterval(intervalId)
            }
        }, 100) // 每隔 50ms 显示一个字符
        container.appendChild(newMessageElement)
    }
}
</script>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

# 优化

使用requestAnimationFrame优化逐字符展示文本频率

function displayMessageWithEffect(message: string) {
    let index = 0
    const container = document.getElementById('sse')
    if (container) {
        const newMessageElement = document.createElement('p')
        container.appendChild(newMessageElement)

        const updateText = () => {
            if (index < message.length) {
                newMessageElement.textContent += message[index]
                index++
                requestAnimationFrame(updateText)
            }
        }
        updateText()
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

通过 requestAnimationFrame 替代 setInterval,requestAnimationFrame 会将更新逻辑合并到浏览器的渲染周期内,优化了 DOM 更新的时机,避免不必要的 DOM 重排和重绘。

# 为什么使用 requestAnimationFrame

  • requestAnimationFrame 会在下一次浏览器重绘之前执行,因此相较于 setInterval,它能减少不必要的渲染,提高效率。
  • 如果页面中有多个动画或需要高频更新的场景,使用 requestAnimationFrame 可以确保性能优化,避免阻塞主线程。

改完之后发现字符显示的效果变快了

这是因为 requestAnimationFrame 被触发的频率较高,通常它会在每个浏览器的重绘周期内执行,也就是通常每秒 60 次(每个帧大约 16.7 毫秒)。这个频率远远高于原来使用 setInterval(100) 的 100 毫秒一次的间隔,所以字符显示速度明显加快了。

# 文字逐字展示(打字机效果)

方案一:直接更新DOM

const start = () => {
    const source = new EventSource('/proxyApi/events')
    source.addEventListener('message', async (e) => {
        console.log('Message from server:', e.data)
        const chars: string[] = e.data.split('')
        for (const char of chars) {
            content.value += char
            await new Promise((resolve) => setTimeout(resolve, 20))
        }
        // content.value += `${e.data}\n\n`
        const message = e.data
        if (message === '[DONE]') {
            source.close()
        }
        // displayMessageWithEffect(message)
    })
    return source.close
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

优化方案:

# 使用文档片段(DocumentFragment)

const fragment = document.createDocumentFragment();
const tempEl = document.createElement('div');
fragment.appendChild(tempEl);

for (const char of chars) {
  tempEl.textContent += char;
  await delay(50);
}

outputEl.appendChild(fragment); // 单次DOM操作
1
2
3
4
5
6
7
8
9
10
const start = () => {
    const source = new EventSource('/proxyApi/events')
    source.addEventListener('message', async (e) => {
        console.log('Message from server:', e.data)
        const container = document.getElementById('sse')
        if (container) {
            const fragment = document.createDocumentFragment()
            const tempEl = document.createElement('p')
            fragment.appendChild(tempEl)
            container.appendChild(fragment)
            const chars: string[] = e.data.split('')
            for (const char of chars) {
                tempEl.textContent += char
                await sleep(20)
            }
        }
        const message = e.data
        if (message === '[DONE]') {
            source.close()
        }
    })
    return source.close
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 利用 CSS 动画优化渲染

#output {
  will-change: contents; /* 提前声明变化属性 */
  contain: content;       /* 限制浏览器重排范围 */
}
1
2
3
4

# 任务调度优化

function scheduleUpdate(char) {
  requestAnimationFrame(() => {
    outputEl.textContent += char;
  });
}
1
2
3
4
5

# 实现自定义打字机

这个打字机用于将纯文本逐字符展示

interface TypewriterOptions {
    /**
     * 每一帧输出多少个字符(一次“打”多少字)
     */
    chunkSize?: number
    /**
     * 每一帧的时间间隔(ms),越小越快
     */
    delay?: number
}

class Typewriter {
    private target: HTMLElement
    private chunkSize: number
    private delay: number
    private buffer: string[]
    private isRendering: boolean
    constructor(targetEl: HTMLElement, { chunkSize = 5, delay = 30 }: TypewriterOptions = {}) {
        this.target = targetEl
        this.chunkSize = chunkSize
        this.delay = delay
        this.buffer = []
        this.isRendering = false
    }

    addText(text: string) {
        this.buffer.push(...text.split(''))
        if (!this.isRendering) this.render()
    }

    async render(): Promise<void> {
        this.isRendering = true
        while (this.buffer.length > 0) {
            const chunk = this.buffer.splice(0, this.chunkSize)
            // 使用 DocumentFragment 避免多次 DOM 更新
            const fragment = document.createDocumentFragment()
            const span = document.createElement('span')
            span.innerHTML = chunk.join('')
            fragment.appendChild(span)
            this.target.appendChild(fragment)
            await new Promise((resolve) =>
                requestAnimationFrame(() => setTimeout(resolve, this.delay))
            )
        }
        this.isRendering = false
    }
}

export default Typewriter

// 使用
// const writer = new Typewriter(document.getElementById('output')!, { chunkSize: 10 })
// writer.addText('Hello, World!')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

# Vue中的实现

interface TypeWriterOptions {
    /**
     * 每一帧输出多少个字符(一次“打”多少字)
     */
    chunkSize?: number
    /**
     * 每一帧的时间间隔(ms),越小越快
     */
    delay?: number
    /**
     * 每次更新的回调
     */
    onUpdate?: (currentText: string) => void
}

class TypeWriter {
    private buffer: string[] = []
    private chunkSize: number
    private delay: number
    private isRendering = false
    private currentText = ''
    private onUpdate?: (text: string) => void
    constructor({ chunkSize = 2, delay = 80, onUpdate }: TypeWriterOptions = {}) {
        this.chunkSize = chunkSize
        this.delay = delay
        this.onUpdate = onUpdate
    }

    addText(text: string) {
        this.buffer.push(...text.split(''))
        if (!this.isRendering) this.render()
    }

    async render(): Promise<void> {
        this.isRendering = true
        while (this.buffer.length > 0) {
            const chunk = this.buffer.splice(0, this.chunkSize).join('')
            this.currentText += chunk
            this.onUpdate?.(this.currentText)
            await new Promise((resolve) =>
                requestAnimationFrame(() => setTimeout(resolve, this.delay))
            )
        }
        this.isRendering = false
    }

    destory() {
        this.buffer = []
        this.currentText = ''
        this.isRendering = false
    }
}

export default TypeWriter

// 使用
// const writer = new Typewriter(document.getElementById('output')!, { chunkSize: 10 })
// writer.addText('Hello, World!')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58

requestAnimationFrame有个问题:不会在后台运行,也就是切换浏览器标签页后,文字不会继续输出,会被暂停!!!

# 支持暂停和恢复

export interface TypeWriterOptions {
    /**
     * 每一帧输出多少个字符(一次“打”多少字)
     */
    chunkSize?: number
    /**
     * 每一帧的时间间隔(ms),越小越快
     */
    delay?: number
    /**
     * 每次更新的回调
     */
    onUpdate?: (currentText: string) => void
    onFinished?: () => void
}

class TypeWriter {
    private buffer: string[] = []
    private chunkSize: number
    private delay: number
    private isRendering = false
    private isPaused = false
    private currentText = ''
    private onUpdate?: (text: string) => void
    private onFinished?: () => void
    constructor({ chunkSize = 2, delay = 80, onUpdate, onFinished }: TypeWriterOptions = {}) {
        this.chunkSize = chunkSize
        this.delay = delay
        this.onUpdate = onUpdate
        this.onFinished = onFinished
    }

    addText(text: string) {
        this.buffer.push(...text.split(''))
        if (!this.isRendering) this.render()
    }

    async render(): Promise<void> {
        this.isRendering = true
        while (this.buffer.length > 0) {
            if (this.isPaused) {
                await new Promise((resolve) => setTimeout(resolve, 50))
                continue
            }
            const chunk = this.buffer.splice(0, this.chunkSize).join('')
            this.currentText += chunk
            this.onUpdate?.(this.currentText)

            await new Promise((resolve) => setTimeout(resolve, this.delay))
        }
        this.isRendering = false
        if (!this.buffer.length) {
            this.onFinished?.()
        }
    }

    pause() {
        this.isPaused = true
    }

    resume() {
        if (this.isPaused) {
            this.isPaused = false
            if (!this.isRendering) this.render()
        }
    }

    destory() {
        this.buffer = []
        this.currentText = ''
        this.isRendering = false
    }
}

export default TypeWriter

// 使用
// const writer = new Typewriter(document.getElementById('output')!, { chunkSize: 10 })
// writer.addText('Hello, World!')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79

关于render的优化

另一种实现方式:递归 + setTimeout 的“回调式写法”

render() {
    this.isRendering = true
    const step = () => {
        if (this.destroyed || this.buffer.length === 0) {
            this.isRendering = false
            if (!this.destroyed) this.onFinished?.()
            return
        }

        if (this.isPaused) {
            this.timer = setTimeout(step, 50)
            return
        }

        const chunk = this.buffer.splice(0, this.chunkSize).join('')
        this.currentText += chunk
        this.onUpdate?.(this.currentText)
        this.timer = setTimeout(step, this.delay)
    }

    step()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

setTimeout其实和requestAnimationFrame有同样的问题,切换标签页至后台时,setTimeout会有一个最小间隔限制(一般是1000ms):setTimeout(fn, delay) 在非活动标签页中,delay 会被强制提升到最小值

此时页面输出会变得很慢,所以此方案也不行。

最佳解决方案:

当标签切换至后台时,页面输出可以停止,但是文字仍然会推进缓存中,当切回来时,一次性输出缓存的页面数据

export interface TypeWriterOptions {
    /**
     * 每一帧输出多少个字符(一次“打”多少字)
     */
    chunkSize?: number
    /**
     * 每一帧的时间间隔(ms),越小越快
     */
    delay?: number
    /**
     * 每次更新的回调
     */
    onUpdate?: (currentText: string) => void
    onFinished?: () => void
}

class TypeWriter {
    private buffer: string[] = []
    private chunkSize: number
    private delay: number
    private isRendering = false
    private isPaused = false
    private destroyed = false
    private currentText = ''
    private onUpdate?: (text: string) => void
    private onFinished?: () => void
    private animationFrameId: number | null = null
    private timeoutId: number | null = null
    constructor({ chunkSize = 2, delay = 80, onUpdate, onFinished }: TypeWriterOptions = {}) {
        this.chunkSize = chunkSize
        this.delay = delay
        this.onUpdate = onUpdate
        this.onFinished = onFinished
        this.handleVisibilityChange = this.handleVisibilityChange.bind(this)
        document.addEventListener('visibilitychange', this.handleVisibilityChange)
    }

    private handleVisibilityChange() {
        if (document.hidden) {
            // Tab is hidden, process text without rendering to accumulate it
            // No need to set isPaused, as render will handle it
        } else {
            // Tab is visible again, immediately update with current accumulated text
            this.onUpdate?.(this.currentText)
            // Resume rendering if it was paused or not finished
            if (!this.isRendering && this.buffer.length > 0) {
                this.render()
            }
        }
    }

    addText(text: string) {
        this.buffer.push(...text.split(''))
        if (!this.isRendering) this.render()
    }

    async render(): Promise<void> {
        this.isRendering = true
        while (!this.destroyed && this.buffer.length > 0) {
            if (this.isPaused) {
                await new Promise((resolve) => setTimeout(resolve, 50))
                continue
            }

            const chunk = this.buffer.splice(0, this.chunkSize).join('')
            this.currentText += chunk

            if (!document.hidden) {
                this.onUpdate?.(this.currentText)
                await new Promise<void>((resolve) => {
                    this.animationFrameId = requestAnimationFrame(() => {
                        this.timeoutId = setTimeout(() => {
                            this.animationFrameId = null
                            this.timeoutId = null
                            resolve()
                        }, this.delay)
                    })
                })
            } else {
                // If hidden, just accumulate text, don't update UI or delay
                // The onUpdate will be called when tab becomes visible again
            }
        }
        this.isRendering = false
        if (!this.buffer.length && !document.hidden) {
            this.onFinished?.()
        }
    }

    pause() {
        this.isPaused = true
    }

    resume() {
        if (this.isPaused) {
            this.isPaused = false
            if (!this.isRendering) this.render()
        }
    }

    destroy() {
        this.destroyed = true
        this.buffer = []
        this.currentText = ''
        this.isRendering = false
        this.isPaused = false
        document.removeEventListener('visibilitychange', this.handleVisibilityChange)
        if (this.animationFrameId) {
            cancelAnimationFrame(this.animationFrameId)
            this.animationFrameId = null
        }
        if (this.timeoutId) {
            clearTimeout(this.timeoutId)
            this.timeoutId = null
        }
    }

    reset() {
        this.destroyed = false
        this.buffer = []
        this.currentText = ''
        this.isRendering = false
        this.isPaused = false
        if (this.animationFrameId) {
            cancelAnimationFrame(this.animationFrameId)
            this.animationFrameId = null
        }
        if (this.timeoutId) {
            clearTimeout(this.timeoutId)
            this.timeoutId = null
        }
    }
}

export default TypeWriter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135

# 使用时间驱动

export interface TypeWriterOptions {
    /**
     * 每一帧输出多少个字符(一次“打”多少字)
     */
    chunkSize?: number
    /**
     * 每一帧的时间间隔(ms),越小越快
     */
    delay?: number
    /**
     * 页面不可见时是否跳过延时
     * true = 页面隐藏时仍然触发 delay
     * false = 页面隐藏时快速输出到缓冲
     * 默认 false
     */
    respectVisibilityDelay?: boolean
    /**
     * 每次更新的回调
     */
    onUpdate?: (currentText: string) => void
    onFinished?: () => void
}

/**
 * 对外可读的打字机状态
 */
export interface TypeWriterState {
    isRendering: boolean
    isPaused: boolean
    isDestroyed: boolean
    currentText: string
}

class TypeWriter {
    private buffer: string[] = []
    private chunkSize: number
    private delay: number
    private respectVisibilityDelay: boolean
    private isRendering = false
    private isPaused = false
    private destroyed = false
    private currentText = ''
    private onUpdate?: (text: string) => void
    private onFinished?: () => void
    private renderPromise: Promise<void> | null = null
    private timeoutId: number | null = null

    private startTime: number | null = null
    private baseChars: number = 0
    private lastVisibleTime: number | null = null

    private visibilityHandler?: () => void
    constructor({
        chunkSize = 2,
        delay = 80,
        onUpdate,
        onFinished,
        respectVisibilityDelay = true
    }: TypeWriterOptions = {}) {
        if (chunkSize < 1 || !Number.isFinite(chunkSize)) {
            throw new Error('chunkSize must be a positive integer')
        }
        if (delay < 0 || !Number.isFinite(delay)) {
            throw new Error('delay must be a non-negative integer')
        }
        this.chunkSize = Math.floor(chunkSize)
        this.delay = Math.floor(delay)
        this.onUpdate = onUpdate
        this.onFinished = onFinished
        this.respectVisibilityDelay = respectVisibilityDelay

        if (typeof document !== 'undefined') {
            this.visibilityHandler = this.handleVisibilityChange.bind(this)
            document.addEventListener('visibilitychange', this.visibilityHandler)
        }
    }

    /**
     * 页面可见性变化
     */
    private handleVisibilityChange() {
        if (typeof document === 'undefined') return
        if (!document.hidden) {
            this.refreshCurrentText()
            this.onUpdate?.(this.currentText)
            if (!this.isRendering && this.buffer.length > 0) {
                this.render()
            }
        }
    }

    addText(text: string) {
        if (this.destroyed) return
        if (!text) return
        this.buffer.push(...text.split(''))
        // 如果是第一次开始写,记录时间基线
        if (!this.startTime) {
            this.startTime = Date.now()
            this.baseChars = this.currentText.length
        }
        if (!this.isRendering) this.render()
    }

    private async _doRender() {
        this.isRendering = true
        try {
            while (!this.destroyed && this.buffer.length > 0) {
                if (this.isPaused) {
                    await this.delayFor(50)
                    continue
                }

                if (!this.shouldRenderFrame()) {
                    // 页面隐藏时啥都不做,不拼接,不耗时
                    await this.delayFor(200)
                    continue
                }

                // 页面可见时,实时推算应该显示到哪里
                this.refreshCurrentText()
                this.onUpdate?.(this.currentText)

                await this.delayFor(this.delay)
            }
            if (!this.destroyed && this.buffer.length === 0 && this.shouldRenderFrame()) {
                this.onFinished?.()
            }
        } finally {
            this.isRendering = false
            this.renderPromise = null
        }
    }
    /**
     * 根据当前时间推断应该显示到多少字符
     */
    private refreshCurrentText() {
        if (!this.startTime) return
        const now = Date.now()
        const elapsed = now - this.startTime
        const totalSteps = Math.floor(elapsed / this.delay)
        const targetChars = this.baseChars + totalSteps * this.chunkSize

        // 组装所有走到的文字
        const fullText = this.currentText + this.buffer.join('')
        this.currentText = fullText.slice(0, targetChars)

        // 更新 buffer
        const remaining = fullText.slice(targetChars)
        this.buffer = remaining.split('')
    }
    /**
     * 开始渲染
     */
    async render(): Promise<void> {
        if (this.renderPromise || this.destroyed) return
        this.renderPromise = this._doRender()
        return this.renderPromise
    }

    pause() {
        if (this.destroyed) return
        this.isPaused = true
    }

    resume() {
        if (this.destroyed) return
        this.isPaused = false
        if (!this.isRendering) this.render()
    }

    /**
     * 销毁,彻底停止并清理
     */
    destroy() {
        if (this.destroyed) return
        this.destroyed = true
        this.buffer = []
        this.currentText = ''
        this.isRendering = false
        this.isPaused = false
        if (this.timeoutId) {
            clearTimeout(this.timeoutId)
            this.timeoutId = null
        }
        if (typeof document !== 'undefined' && this.visibilityHandler) {
            document.removeEventListener('visibilitychange', this.visibilityHandler)
        }
    }

    /**
     * 重置到初始状态
     */
    reset() {
        this.destroyed = false
        this.buffer = []
        this.currentText = ''
        this.isRendering = false
        this.isPaused = false
        if (this.timeoutId) {
            clearTimeout(this.timeoutId)
            this.timeoutId = null
        }
        if (typeof document !== 'undefined' && this.visibilityHandler) {
            document.removeEventListener('visibilitychange', this.visibilityHandler)
            document.addEventListener('visibilitychange', this.visibilityHandler)
        }
    }

    /**
     * 外部可读取状态
     */
    getState(): TypeWriterState {
        return {
            isRendering: this.isRendering,
            isPaused: this.isPaused,
            isDestroyed: this.destroyed,
            currentText: this.currentText
        }
    }

    private delayFor(ms: number) {
        return new Promise((resolve) => {
            this.timeoutId = setTimeout(resolve, ms)
        })
    }
    /**
     * 判断是否在当前状态下应该做 UI 渲染
     */
    private shouldRenderFrame(): boolean {
        if (typeof document === 'undefined') return true
        if (!document.hidden) return true
        return this.respectVisibilityDelay
    }
}

export default TypeWriter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236

# nodejs实现SSE

router.get('/events', async (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('Connection', 'keep-alive')

  const sendData = (event, data) => {
    res.write(`event: ${event}\n`)
    res.write(`data: ${data}\n\n`)
  }

  sendData('message', 'Connected')
  await sleep(1000)

  sendData(
    'message',
    '\u53ef\u80fd\u4f1a\u5728\u540e\u7eed\u7684\u6570\u636e\u6e32\u67d3\u65f6\u51fa\u73b0\u4e0d\u5fc5\u8981\u7684\u91cd\u590d\u64cd\u4f5c\uff0c'
  )
  await sleep(1000)

  sendData(
    'message',
    '如果你想要对消息进行更细粒度的控制,比如添加时间戳、作者信息等,可以将每个消息包装成一个更复杂的 HTML 元素。'
  )
  await sleep(2000)
  sendData('message', '[DONE]')

  // 处理客户端关闭连接
  req.on('close', () => {
    res.end()
  })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

使用

const writerInstance = ref<TypeWriter | null>(null)
let timeout = ref<number>()

const initWriterInstance = () => {
    writerInstance.value = new TypeWriter({
        chunkSize: 2,
        delay: 80,
        onUpdate: (text) => {
            content.value = text
        }
    })
}

const handleStartTypewriterDemo = () => {
    if (writerInstance.value) {
        writerInstance.value.destory()
        clearTimeout(timeout.value)
    }
    writerInstance.value?.addText('你好,这是一个打字机的演示!\n你可以在任意时刻追加更多文本。')
    timeout.value = setTimeout(() => {
        writerInstance.value?.addText('\n这是追加的新内容!支持多段输入。')
    }, 2000)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 封装成hooks

# 使用@microsoft/fetch-event-source

https://github.com/Azure/fetch-event-source

安装

pnpm add @microsoft/fetch-event-source
1

使用

import { fetchEventSource } from '@microsoft/fetch-event-source'

const ctrl = new AbortController()
const signal = ctrl.signal
fetchEventSource('/proxyApi/events', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json'
    },
    body: JSON.stringify({
        foo: 'bar'
    }),
    signal,
    onmessage: (e) => {
        console.log('Message from server:', e.data)
        // content.value += `${e.data}\n\n`
        const message = e.data
        if (message === '[DONE]') {
            ctrl.abort()
        }
        displayMessageWithEffect(message)
    }
})
const dispose = () => ctrl.abort()
return dispose
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25