☰
拒绝 React/Vue:我们为什么用 37KB 原生 JS 做了一个 AI 聊天挂件
2026/9/25 22:29:14 网站建设 项目流程

拒绝 React/Vue:我们为什么用 37KB 原生 JS 做了一个 AI 聊天挂件

做一个普通的 Web 聊天页面,用 React、Vue 之类的框架当然没什么问题。

但 LeadChat 一开始就不是一个普通网页。

它最终要以一段<script>的形式,被别人塞进各种网站里。可能是企业官网,也可能是 SaaS 系统,甚至是一些比较老的内网 ERP。我们无法控制宿主网站用了什么框架、什么 CSS,也不能要求对方修改现有项目的构建配置。

所以在做前端的时候,我们反而遇到了一个挺现实的问题:

如果把一个完整的前端框架一起塞进去,会不会把问题搞得更复杂?

最后我们的答案是:先不用。

目前 LeadChat 的前端主要使用原生 JavaScript、Shadow DOM 和一套非常简单的构建脚本,最终压缩后的leadchat.min.js大约 37KB,没有运行时框架依赖。

这篇文章就聊聊这套东西是怎么做出来的,以及过程中一些并不算“高级”,但确实比较有用的工程取舍。

一、为什么没有使用 React/Vue?

最直接的原因其实不是“原生 JS 更先进”,而是Embed Script 的使用场景和普通 Web 应用不太一样。

假设我们的代码最终这样被别人引用:

<scriptsrc="https://example.com/leadchat.min.js"></script>

那么这个脚本运行的时候,对宿主环境基本是一无所知。

对方可能有自己的全局 CSS:

*{box-sizing:border-box;}button{font-size:14px;}div{line-height:1.2;}

甚至可能还有一些更激进的样式。

如果我们的聊天窗口直接挂在宿主页面的 DOM 里,这些 CSS 很容易影响组件显示。

另外还有依赖问题。

比如宿主项目本身使用 React,我们自己的组件又带了一套 React。即使最终打包工具能够处理好依赖,体积和运行时开销还是会增加,而且 Embed Script 本身并不需要一个完整的应用框架。

所以我们最后选择了一条比较朴素的路线:

不用 React/Vue,也不引入状态管理库,组件本身就用原生 JS 写。

目前代码主要拆成几个文件:

api.js md.js ui.js chat.js embed.js

最后再通过构建脚本合成一个文件。

这并不意味着原生 JS 在所有项目里都比 React/Vue 好。只是对于一个需要嵌入陌生网站的小型交互组件来说,我们觉得它的复杂度比较合适。

二、没有 Vite,构建脚本也自己写了

LeadChat 前端目前甚至没有引入 Vite、Webpack 之类的打包工具。

这里其实没什么特别的技术原因,主要是项目本身的规模比较小。

我们的源码文件数量不多,也没有复杂的模块依赖关系,所以最开始直接写了一个build.js。

核心思路就是:

  1. 按固定顺序读取 JS 文件;
  2. 拼接起来;
  3. 删除块注释;
  4. 把 CSS 读取成字符串;
  5. 最后统一包进一个 IIFE。

简化之后大概是这样:

constfiles=['api.js','md.js','ui.js','chat.js','embed.js'];letjs=files.map(f=>fs.readFileSync(f,'utf8')).join('\n');// 只剥离块注释,保留行注释和缩进js=js.replace(/\/\*[\s\S]*?\*\//g,'');constcss=fs.readFileSync('styles.css','utf8');constoutput=banner+"\n(function(){\n'use strict';\nvar LC_CSS = "+JSON.stringify(css)+";\n"+js+"\n})();\n";

它当然没有现代打包器那么完整。

比如模块分析、Tree Shaking、复杂的代码拆分,这些都没有。

但对于当前这个项目,构建过程足够简单,而且出了问题也比较容易定位。

我们唯一需要特别注意的是文件顺序。

比如chat.js依赖api.js里面的函数,那么api.js就必须先拼进去。

最终生成的代码全部包在 IIFE 里:

(function(){'use strict';// ...})();

这样内部的函数和状态就不会直接变成window上的全局变量。

对外我们只保留真正需要给宿主调用的 API,比如:

window.LeadChat

内部如果确实需要跨文件共享一些东西,则使用__lc前缀的约定,例如:

window.__lc_ui

这部分不是为了追求什么“零污染”的完美状态,而是尽量避免和宿主网站已有的变量撞名字。

三、真正麻烦的是 Shadow DOM

Embed Script 最容易踩坑的地方之一,其实是 CSS。

如果组件直接插入宿主页面:

<divclass="lc-root">...</div>

那么宿主网站的 CSS 很可能会影响它。

例如对方有:

button{border:0;padding:0;}*{box-sizing:border-box;}

你的按钮就可能莫名其妙发生变化。

所以 LeadChat 最终使用了 Shadow DOM。

大致流程是:

constroot=document.createElement('div');document.body.appendChild(root);constshadow=root.attachShadow({mode:'open'});

然后把组件自己的 DOM 和<style>都放到 Shadow Root 里面。

这样至少可以把大部分宿主页面的 CSS 隔离出去。

为什么没有使用:host?

Shadow DOM 里其实可以直接使用:

:host{...}

但我们目前没有这么做。

当时的考虑比较简单:这个组件需要面对一些比较老的浏览器和 WebView 环境,我们不太想为了一个根节点样式再增加兼容性上的变量。

所以现在根元素还是使用:

.lc-root{...}

主题色之类的配置,则从外面通过 CSS 变量传进去:

root.style.setProperty("--lc-primary",theme);

这套方式没什么“黑科技”,但够直接。

为什么没用adoptedStyleSheets?

同样是兼容性和实际收益的问题。

adoptedStyleSheets在现代浏览器里确实很好用,不过 LeadChat 的 CSS 本身并不大,目前没有明显到值得专门为了它调整实现方式的程度。

所以最终还是:

conststyle=document.createElement('style');style.textContent=css;shadow.appendChild(style);

简单,也容易调试。

Z-index 也没有直接拉到最大

第三方组件很容易犯一个错误:

z-index:2147483647;

这样确实能让自己的组件尽量显示在上面,但宿主网站也可能有自己的弹窗、全屏遮罩、客服组件。

如果大家都把 z-index 往上堆,最后反而不知道谁应该在谁上面。

所以我们没有刻意把 LeadChat 的 z-index 设置得特别夸张。

组件使用position: fixed,根节点挂在body后面,正常情况下已经够用了。

当然,如果宿主页面本身有一个非常高层级的遮罩把它盖住,那就只能算宿主页面自己的层叠关系了。

这也是第三方组件需要接受的一件事:

你不可能完全控制宿主页面。

四、Embed API 要解决一个很实际的问题

还有一个比较容易忽略的问题:

宿主网站不一定会等 LeadChat 加载完,才调用初始化代码。

我们希望用户可以这么写:

window.LeadChat=window.LeadChat||[];window.LeadChat.push(["init",{theme:"#1677ff"}]);

然后再加载真正的脚本。

这和 Google Analytics 一类脚本的思路比较接近。

脚本真正加载以后,先检查之前的window.LeadChat是不是数组。

简化后的代码:

varprev=window.LeadChat;varqueued=[];if(Array.isArray(prev)){for(varj=0;j<prev.length;j++){varitem=prev[j];// 标准形式:// LeadChat.push(["init", {...}])if(Array.isArray(item)&&item[0]==="init"&&item[1]){queued.push(item[1]);}// 也兼容直接 push 配置对象elseif(item&&typeofitem==="object"&&!Array.isArray(item)){queued.push(item);}}}queued.forEach(function(o){applyInit(o,false);});

处理完历史配置以后,再把window.LeadChat替换成真正的 API:

window.LeadChat={__lcReady:true,version:"0.5.2",init:function(o){applyInit(o,true);}// ...};

这样做的好处是,宿主网站不用关心脚本什么时候真正执行。

先把配置放进队列里就可以。

等 LeadChat 自己准备好,再统一处理。

五、AI 聊天里,Markdown 渲染比想象中麻烦

聊天组件还有一个比较核心的功能:显示模型返回的 Markdown。

最简单的办法当然是直接上marked.js之类的库。

但我们的需求其实没有那么复杂。

AI 回复主要需要支持:

  • 标题
  • 粗体
  • 斜体
  • 链接
  • 代码块
  • 引用
  • 表格
  • 一些常见的 GFM 格式

为了减少依赖和最终体积,我们最后自己写了一套比较小的 Markdown renderer。

当然,自己写 Markdown 解析器有一个很现实的问题:

安全。

如果模型返回:

<script>// ...</script>

不能因为它是 AI 返回的,就认为它一定是安全的。

我们的处理顺序是先做 HTML 转义:

functionlcMdEscape(s){returnString(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}

这样:

<script>

会先变成:

&lt;script&gt;

后续 Markdown 格式化处理的是已经转义后的文本,而不是原始 HTML。

整个处理过程大致是:

第一步,HTML escape。

先把原始文本里的 HTML 特殊字符转义。

第二步,处理代码块。

代码块里的**、#等内容不应该继续参与普通 Markdown 解析,所以会先替换成内部占位符。

第三步,处理行内 Markdown。

链接这里会额外检查协议。

目前只允许:

http https mailto 站内路径

类似:

javascript:...

这样的协议不会直接生成链接。

第四步,处理块级内容。

再去解析标题、引用、表格等。

这里我不会说它是一个完整的 Markdown 实现,因为它确实不是。

它的目标就是满足聊天窗口里最常见的 Markdown,同时把渲染范围控制在我们自己能够检查的 HTML 标签集合里。

六、SSE 流式输出:真正线上容易出问题的是代理

聊天接口如果每次等模型完整生成之后再返回,用户体验会比较差。

所以 LeadChat 支持 SSE 流式输出。

但这里不能直接使用浏览器的:

EventSource

因为我们的接口需要通过 POST 提交完整的消息体。

所以目前采用:

fetch()

配合:

response.body.getReader()

手动读取响应流。

然后按照 SSE 的事件边界进行解析。

这套方案本身并不复杂。

真正麻烦的是:你的服务器没问题,不代表中间的代理没问题。

例如某些 Nginx 或企业网关配置不正确时,会把本来应该实时返回的数据缓存起来。

最终浏览器看到的效果就可能是:

一直没有消息 → 过了一会儿突然一次性出来。

甚至连接被中途截断。

所以我们没有单独增加一个“先探测 SSE 是否可用”的请求。

而是直接尝试流式请求。

如果:

  • 当前环境没有getReader
  • 流读取过程中发生异常
  • 连接结束了但一直没有收到后端的done事件

就认为这次流式请求失败。

然后走普通 POST 接口。

核心逻辑类似这样:

.catch(function(){if(settled)return;if(streamRow&&streamRow.el.parentNode){streamRow.el.parentNode.removeChild(streamRow.el);}streamRow=null;acc="";showTypingAgain();returnfallbackNonStream().then(function(){hideTypingOnce();});})

这里有一个细节比较重要。

流式请求已经显示出来的那半截内容不能继续留在页面上。

否则用户可能先看到:

你好,我是一个 AI 助

然后下面又出现一条完整的:

你好,我是一个 AI 助手……

看起来就很奇怪。

所以 fallback 的时候,会先把流式气泡删掉,再重新显示 typing 状态,最后用普通接口拿完整结果。

用户最终看到的还是一条正常的回复。

七、还有一个比较小的细节:Hover 颜色怎么计算

主题色是用户可以配置的。

例如:

theme:"#1677ff"

那么按钮 Hover 状态最好也能根据这个颜色自动生成,而不是给每个主题色准备一套固定 CSS。

这里我们做的是 HEX → HSL。

然后保持:

  • Hue 不变
  • Saturation 基本不变
  • 根据当前亮度调整 Lightness

目前使用的逻辑是:

// 阈值 0.42,亮色减 12%,暗色加 14%l=l>0.42?l-0.12:Math.min(1,l+0.14);

为什么最后用了0.42?

坦白说,这并不是一个严格推导出来的“科学常数”。

它更多是我们在几组实际主题色上试出来的经验参数。

如果统一使用 50% 作为判断标准,有些颜色在 Hover 后的变化并不明显;调整到 42% 后,目前使用的主题色看起来会更自然一些。

所以这里没有什么特别高深的算法。

就是一个 UI 参数。

而且以后如果实际使用中发现某些颜色效果不好,这个数字完全可以继续调整。

八、最后说说为什么我们现在还愿意“手搓”

如果单看开发效率,自己写这些东西当然不一定比成熟框架舒服。

React/Vue 已经解决了大量问题,Markdown 也有成熟的库,SSE 同样有很多现成方案。

但 LeadChat 的情况比较特殊。

它不是一个完整的网站,而是一个需要进入别人网站内部的小型交互层。

所以我们更在意的是:

  • 加载文件尽量小;
  • 不依赖宿主项目的框架;
  • CSS 尽量和宿主隔离;
  • API 能够异步初始化;
  • 网络异常时有 fallback;
  • Markdown 渲染范围可控;
  • 出问题的时候方便直接看源码排查。

目前这套方案最终压缩后大约 37KB。

这个数字当然不是越小越好,也没有必要为了几十 KB 把所有东西都重新造一遍。

只是对于我们目前的使用场景来说,这个规模比较舒服。

所以,如果你问我:

“现在还值得不用 React/Vue,直接写原生 JS 吗?”

我的答案不会是“原生 JS 一定更好”。

更准确地说是:

如果你正在做一个普通 Web 应用,大概率没必要这么干;但如果你做的是一个需要嵌入各种陌生网站的 SDK/Widget,那么原生 JS + Shadow DOM 仍然是一套值得考虑的方案。

LeadChat 目前就是这么做的。

后面如果项目规模继续扩大,我们也不排除重新引入更完整的工程化方案。毕竟“少依赖”本身不是目的,能把产品稳定地跑起来才是。

开源地址:

GitHub:
https://github.com/XingTuLink/LeadChat

Gitee:
https://gitee.com/XingTuLink/lead-chat

AtomGit:

https://atomgit.com/XingTuLink/LeadChat

如果你对 Embed Script、Shadow DOM、SSE fallback 或者 Markdown 安全渲染这几个部分感兴趣,也欢迎直接看源码。

下一篇准备继续聊后端。

主题是《告别死板表单:如何用 LLM 在对话中“顺手”完成结构化数据采集与质量门禁?》。

这次会重点讲一个实际项目里经常遇到的问题:模型看起来什么都能理解,但真正要把自然语言变成可以进入业务系统的结构化数据时,事情就没那么简单了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询