ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

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

拒绝 React/Vue:我们为什么用 37KB 原生 JS 做了一个 AI 聊天挂件 拒绝 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 应用不太一样。假设我们的代码最终这样被别人引用scriptsrchttps://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。核心思路就是按固定顺序读取 JS 文件拼接起来删除块注释把 CSS 读取成字符串最后统一包进一个 IIFE。简化之后大概是这样constfiles[api.js,md.js,ui.js,chat.js,embed.js];letjsfiles.map(ffs.readFileSync(f,utf8)).join(\n);// 只剥离块注释保留行注释和缩进jsjs.replace(/\/\*[\s\S]*?\*\//g,);constcssfs.readFileSync(styles.css,utf8);constoutputbanner\n(function(){\nuse strict;\nvar LC_CSS JSON.stringify(css);\njs\n})();\n;它当然没有现代打包器那么完整。比如模块分析、Tree Shaking、复杂的代码拆分这些都没有。但对于当前这个项目构建过程足够简单而且出了问题也比较容易定位。我们唯一需要特别注意的是文件顺序。比如chat.js依赖api.js里面的函数那么api.js就必须先拼进去。最终生成的代码全部包在 IIFE 里(function(){use strict;// ...})();这样内部的函数和状态就不会直接变成window上的全局变量。对外我们只保留真正需要给宿主调用的 API比如window.LeadChat内部如果确实需要跨文件共享一些东西则使用__lc前缀的约定例如window.__lc_ui这部分不是为了追求什么“零污染”的完美状态而是尽量避免和宿主网站已有的变量撞名字。三、真正麻烦的是 Shadow DOMEmbed Script 最容易踩坑的地方之一其实是 CSS。如果组件直接插入宿主页面divclasslc-root.../div那么宿主网站的 CSS 很可能会影响它。例如对方有button{border:0;padding:0;}*{box-sizing:border-box;}你的按钮就可能莫名其妙发生变化。所以 LeadChat 最终使用了 Shadow DOM。大致流程是constrootdocument.createElement(div);document.body.appendChild(root);constshadowroot.attachShadow({mode:open});然后把组件自己的 DOM 和style都放到 Shadow Root 里面。这样至少可以把大部分宿主页面的 CSS 隔离出去。为什么没有使用:hostShadow DOM 里其实可以直接使用:host{...}但我们目前没有这么做。当时的考虑比较简单这个组件需要面对一些比较老的浏览器和 WebView 环境我们不太想为了一个根节点样式再增加兼容性上的变量。所以现在根元素还是使用.lc-root{...}主题色之类的配置则从外面通过 CSS 变量传进去root.style.setProperty(--lc-primary,theme);这套方式没什么“黑科技”但够直接。为什么没用adoptedStyleSheets同样是兼容性和实际收益的问题。adoptedStyleSheets在现代浏览器里确实很好用不过 LeadChat 的 CSS 本身并不大目前没有明显到值得专门为了它调整实现方式的程度。所以最终还是conststyledocument.createElement(style);style.textContentcss;shadow.appendChild(style);简单也容易调试。Z-index 也没有直接拉到最大第三方组件很容易犯一个错误z-index:2147483647;这样确实能让自己的组件尽量显示在上面但宿主网站也可能有自己的弹窗、全屏遮罩、客服组件。如果大家都把 z-index 往上堆最后反而不知道谁应该在谁上面。所以我们没有刻意把 LeadChat 的 z-index 设置得特别夸张。组件使用position: fixed根节点挂在body后面正常情况下已经够用了。当然如果宿主页面本身有一个非常高层级的遮罩把它盖住那就只能算宿主页面自己的层叠关系了。这也是第三方组件需要接受的一件事你不可能完全控制宿主页面。四、Embed API 要解决一个很实际的问题还有一个比较容易忽略的问题宿主网站不一定会等 LeadChat 加载完才调用初始化代码。我们希望用户可以这么写window.LeadChatwindow.LeadChat||[];window.LeadChat.push([init,{theme:#1677ff}]);然后再加载真正的脚本。这和 Google Analytics 一类脚本的思路比较接近。脚本真正加载以后先检查之前的window.LeadChat是不是数组。简化后的代码varprevwindow.LeadChat;varqueued[];if(Array.isArray(prev)){for(varj0;jprev.length;j){varitemprev[j];// 标准形式// LeadChat.push([init, {...}])if(Array.isArray(item)item[0]inititem[1]){queued.push(item[1]);}// 也兼容直接 push 配置对象elseif(itemtypeofitemobject!Array.isArray(item)){queued.push(item);}}}queued.forEach(function(o){applyInit(o,false);});处理完历史配置以后再把window.LeadChat替换成真正的 APIwindow.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(snull?:s).replace(//g,amp;).replace(//g,lt;).replace(//g,gt;);}这样script会先变成lt;scriptgt;后续 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(streamRowstreamRow.el.parentNode){streamRow.el.parentNode.removeChild(streamRow.el);}streamRownull;acc;showTypingAgain();returnfallbackNonStream().then(function(){hideTypingOnce();});})这里有一个细节比较重要。流式请求已经显示出来的那半截内容不能继续留在页面上。否则用户可能先看到你好我是一个 AI 助然后下面又出现一条完整的你好我是一个 AI 助手……看起来就很奇怪。所以 fallback 的时候会先把流式气泡删掉再重新显示 typing 状态最后用普通接口拿完整结果。用户最终看到的还是一条正常的回复。七、还有一个比较小的细节Hover 颜色怎么计算主题色是用户可以配置的。例如theme:#1677ff那么按钮 Hover 状态最好也能根据这个颜色自动生成而不是给每个主题色准备一套固定 CSS。这里我们做的是 HEX → HSL。然后保持Hue 不变Saturation 基本不变根据当前亮度调整 Lightness目前使用的逻辑是// 阈值 0.42亮色减 12%暗色加 14%ll0.42?l-0.12:Math.min(1,l0.14);为什么最后用了0.42坦白说这并不是一个严格推导出来的“科学常数”。它更多是我们在几组实际主题色上试出来的经验参数。如果统一使用 50% 作为判断标准有些颜色在 Hover 后的变化并不明显调整到 42% 后目前使用的主题色看起来会更自然一些。所以这里没有什么特别高深的算法。就是一个 UI 参数。而且以后如果实际使用中发现某些颜色效果不好这个数字完全可以继续调整。八、最后说说为什么我们现在还愿意“手搓”如果单看开发效率自己写这些东西当然不一定比成熟框架舒服。React/Vue 已经解决了大量问题Markdown 也有成熟的库SSE 同样有很多现成方案。但 LeadChat 的情况比较特殊。它不是一个完整的网站而是一个需要进入别人网站内部的小型交互层。所以我们更在意的是加载文件尽量小不依赖宿主项目的框架CSS 尽量和宿主隔离API 能够异步初始化网络异常时有 fallbackMarkdown 渲染范围可控出问题的时候方便直接看源码排查。目前这套方案最终压缩后大约 37KB。这个数字当然不是越小越好也没有必要为了几十 KB 把所有东西都重新造一遍。只是对于我们目前的使用场景来说这个规模比较舒服。所以如果你问我“现在还值得不用 React/Vue直接写原生 JS 吗”我的答案不会是“原生 JS 一定更好”。更准确地说是如果你正在做一个普通 Web 应用大概率没必要这么干但如果你做的是一个需要嵌入各种陌生网站的 SDK/Widget那么原生 JS Shadow DOM 仍然是一套值得考虑的方案。LeadChat 目前就是这么做的。后面如果项目规模继续扩大我们也不排除重新引入更完整的工程化方案。毕竟“少依赖”本身不是目的能把产品稳定地跑起来才是。开源地址GitHubhttps://github.com/XingTuLink/LeadChatGiteehttps://gitee.com/XingTuLink/lead-chatAtomGit:https://atomgit.com/XingTuLink/LeadChat如果你对 Embed Script、Shadow DOM、SSE fallback 或者 Markdown 安全渲染这几个部分感兴趣也欢迎直接看源码。下一篇准备继续聊后端。主题是《告别死板表单如何用 LLM 在对话中“顺手”完成结构化数据采集与质量门禁》。这次会重点讲一个实际项目里经常遇到的问题模型看起来什么都能理解但真正要把自然语言变成可以进入业务系统的结构化数据时事情就没那么简单了。
返回列表