使用 RenderJS 是在 uni-app 的 App 端利用 H5 API(如 EventSource)处理 SSE 流的一个非常巧妙且官方支持的方案。

这个方案的核心思想是:将网络通信和流式数据处理的任务“外包”给 RenderJS 运行的视图层 WebView,因为它拥有一个完整的浏览器环境。然后,RenderJS 将处理好的数据片段通过特定机制传递回逻辑层(Service 层)进行业务处理和界面渲染。

下面我将根据你提供的思路,给出一个完整的、可运行的代码实现和详细讲解。

核心原理讲解

  1. <script module="xxx" lang="renderjs">: 这段脚本不运行在 App 的主 JS 引擎(V8 或 JSCore)中,而是运行在渲染页面的 WebView 里。因此,它可以访问 windowdocumentEventSource 等所有标准的 Web API。
  2. 逻辑层与视图层通信:
    • 逻辑层 -> RenderJS: 通过给挂载了 RenderJS 模块的 DOM 元素传递 props 或直接调用 RenderJS 内部的方法。
    • RenderJS -> 逻辑层: 这是关键。RenderJS 脚本可以通过 this.ownerInstance.callMethod('methodName', args) 来调用 Vue 组件实例(逻辑层)中定义的方法。
  3. 流程:
    • 用户在界面点击“发送”按钮,触发逻辑层的一个方法。
    • 逻辑层调用 RenderJS 模块暴露出的 startStream 方法,并传入必要的参数(如请求 URL)。
    • RenderJS 模块收到指令后,创建 EventSource 实例,开始监听来自服务器的 SSE 数据流。
    • 每当 EventSource 收到一个数据片段(onmessage 事件),RenderJS 就会调用 this.ownerInstance.callMethod('emits', ...) 将这个数据片段“发射”回逻辑层。
    • 逻辑层的 emits 方法被触发,接收到数据片段,然后更新 data 中的 msgList,从而驱动界面实时更新,实现打字机效果。

具体代码实现

假设我们有一个聊天页面 pages/ai-chat-renderjs/ai-chat-renderjs.vue

Step 1: template 结构

我们需要一个 DOM 元素来挂载 RenderJS 模块。同时,通过 :propRenderJS 传递动态数据(比如请求的 URL 或触发信号),并通过 ref 来方便地调用它的方法。

<template>
	<view class="chat-container">
		<!-- 消息列表 -->
		<view v-for="(msg, index) in msgList" :key="index" class="message">
			<text class="role">{{ msg.role }}: </text>
			<text class="content">{{ msg.content }}</text>
		</view>

		<!-- 操作按钮 -->
		<button @click="start" :disabled="isLoading">发送消息</button>
		<button @click="stop" v-if="isLoading">停止接收</button>

		<!-- 
            关键部分:
            1. 创建一个 view 元素用于挂载 RenderJS 模块。
            2. 使用 `module="sse"` 来命名这个模块。
            3. 使用 `ref="sseRef"` 以便在 <script> 中通过 this.$refs.sseRef 访问它。
            4. `:prop="reqData"` 把逻辑层的数据传递给 RenderJS。
            5. `@update="onRenderJSUpdate"` 是一个示例,用于接收 RenderJS 的主动更新(此例中我们用 callMethod,不用这个)。
         -->
		<view :prop="reqData" module="sse" ref="sseRef"></view>
	</view>
</template>
Step 2: RenderJS 脚本

这是处理 SSE 的核心,它运行在 WebView 中。

<script module="sse" lang="renderjs">
export default {
    data() {
        return {
            eventSource: null // 保存 EventSource 实例
        }
    },
    methods: {
        /**
         * @description: 由逻辑层调用,用于启动 SSE 连接
         * @param {object} newReqData - 包含 URL 等请求信息
         */
        startStream(newReqData) {
            // 防止重复创建
            if (this.eventSource) {
                this.eventSource.close();
            }

            if (!newReqData || !newReqData.url) {
                console.error('[RenderJS] URL is missing.');
                return;
            }

            // 1. 使用 H5 的 EventSource API
            this.eventSource = new EventSource(newReqData.url);

            // 2. 监听消息
            this.eventSource.onmessage = (event) => {
                if (event.data === '[DONE]') {
                    this.eventSource.close();
                    // 通知逻辑层数据流已结束
                    this.emitToLogic({
                        type: 'done'
                    });
                    return;
                }
                try {
                    const data = JSON.parse(event.data);
                    // 通知逻辑层收到了新的数据片段
                    this.emitToLogic({
                        type: 'message',
                        content: data.text
                    });
                } catch (e) {
                    console.error('[RenderJS] JSON parse error:', e);
                }
            };

            // 3. 监听错误
            this.eventSource.onerror = (err) => {
                console.error('[RenderJS] EventSource failed:', err);
                this.eventSource.close();
                // 通知逻辑层发生了错误
                this.emitToLogic({
                    type: 'error',
                    error: 'Stream connection error'
                });
            };
        },
        
        /**
         * @description: 由逻辑层调用,用于手动停止 SSE 连接
         */
        stopStream() {
            if (this.eventSource) {
                this.eventSource.close();
                this.eventSource = null;
                console.log('[RenderJS] Stream manually stopped.');
            }
        },

        /**
         * @description: 封装向逻辑层发射数据的方法
         * @param {object} data - 要发送的数据
         */
        emitToLogic(data) {
            // this.ownerInstance 是 RenderJS 访问 Vue 组件实例(逻辑层)的桥梁
            // callMethod 用于调用 Vue 组件实例上的方法
            if (this.ownerInstance) {
                this.ownerInstance.callMethod('handleRenderJSEvent', data);
            }
        }
    }
}
</script>
Step 3: 逻辑层 script

这是 Vue 组件的主脚本,负责管理状态、业务逻辑和与 RenderJS 交互。

<script>
export default {
    data() {
        return {
            msgList: [], // 聊天消息列表
            isLoading: false,
            // 这个对象将作为 prop 传递给 RenderJS
            // 虽然在这个例子中我们直接通过 callMethod 传递 URL,但 prop 机制也很有用
            reqData: {}
        };
    },
    beforeDestroy() {
        // 页面销毁前,确保停止 SSE 连接,防止内存泄漏
        this.stop();
    },
    methods: {
        // 按钮点击事件:开始接收
        start() {
            if (this.isLoading) return;
            this.isLoading = true;
            
            // 为新的 AI 回复创建一个占位消息
            const newMsgIndex = this.msgList.push({
                role: 'AI',
                content: ''
            }) - 1; // 获取新消息的索引

            const requestData = {
                url: 'http://localhost:3000/ai-stream', // 你的 SSE 接口
                // 把新消息的索引也传过去,方便 RenderJS 回传时我们知道要更新哪条消息
                targetIndex: newMsgIndex
            };
            
            // 调用 RenderJS 模块中的 startStream 方法
            this.$refs.sseRef.startStream(requestData);
        },

        // 按钮点击事件:停止接收
        stop() {
            if (!this.isLoading) return;
            // 调用 RenderJS 模块中的 stopStream 方法
            this.$refs.sseRef.stopStream();
            this.isLoading = false;
        },

        /**
         * @description: 这个方法专门用于接收来自 RenderJS 的事件
         * @param {object} event - 从 RenderJS 发射过来的数据对象
         */
        handleRenderJSEvent(event) {
            const lastMsg = this.msgList[this.msgList.length - 1];
            if (!lastMsg || lastMsg.role !== 'AI') return;

            switch (event.type) {
                case 'message':
                    // 收到了新的消息片段,追加到最后一条 AI 消息的内容上
                    lastMsg.content += event.content;
                    this.scrollToBottom(); // 滚动到底部
                    break;
                case 'done':
                    // 数据流结束
                    this.isLoading = false;
                    console.log('Stream finished.');
                    break;
                case 'error':
                    // 发生错误
                    lastMsg.content += `\n[错误: ${event.error}]`;
                    this.isLoading = false;
                    break;
            }
        },

        // 辅助方法:滚动到页面底部
        scrollToBottom() {
            this.$nextTick(() => {
                uni.pageScrollTo({
                    scrollTop: 99999, // 一个足够大的值
                    duration: 100
                });
            });
        }
    }
}
</script>

总结与对比

使用 RenderJS 方案的优势:

  1. 原生 API 支持:可以直接利用浏览器稳定、高效的 EventSource API,无需任何第三方库。
  2. 代码清晰:将网络层的复杂性(如流式解析)完全隔离在 RenderJS 中,逻辑层代码只关心业务状态,职责分明。
  3. 官方方案:这是 uni-app 官方提供的用于在 App 端扩展 Web能力的机制,兼容性有保障。

需要注意的点:

  1. 通信开销:逻辑层和视图层(RenderJS)之间的数据传递存在一定的性能开销。对于 AI 对话这种中等频率的更新,完全可以接受。但对于极高频(如每秒几百次)的通信场景,需要进行性能测试。
  2. 环境隔离RenderJS 中无法访问 uni 对象(如 uni.showToast),也无法访问 Vuex。它的世界里只有标准的 Web API。所有需要 uni API 的操作都必须通过 callMethod 通知逻辑层来完成。
  3. 调试RenderJS 中的 console.log 会输出在 WebView 的控制台,而不是 App 的主控制台。调试时需要在 HBuilderX 中开启 WebView 调试。

总的来说,对于在 App 端实现 SSE 流式请求,RenderJS 方案是一个非常优雅和强大的选择,比 enableChunked 手动解析要简单可靠,也避免了引入额外的 fetch polyfill 库。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐