欢迎光临
我们一直在努力

AI 纯前端对话:从 Markdown 占位符渲染到事件委托的零依赖架构实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

文章目录

    • 一、渲染层的真实挑战
    • 二、Markdown 渲染器:占位符保护策略
      • 2.1 列表解析的小巧思
      • 2.2 表格解析
      • 2.3 还原占位符(代码块 + 行内代码)
    • 三、语法高亮器:一次遍历的正则扫描
    • 四、单条消息的渲染与流式增量更新
    • 五、事件委托:一个监听器撑起整套 UI
    • 六、快捷指令:输入 `/` 触发的命令面板
    • 七、主题切换与数据备份
    • 八、全局快捷键与下拉交互的真实实现
    • 九、安全小结:纯前端产品的三道防线
    • 九、会话时间分组与友好时间显示
    • 十、导出为图片:html2canvas 的可选降级
    • 十一、欢迎屏与快捷操作入口
    • 十二、结语:这套代码能怎么扩展

一、渲染层的真实挑战

第一篇解决了"怎么把消息发给模型、怎么把回复流式拿回来"。但拿回来的只是一段** Markdown 文本**——里面可能混着 # 标题、**加粗**、表格、还有大量 ```````````代码块。把它直接塞进 innerHTML 会有两个灾难:

  • Markdown 没被解析,用户看到的是原始符号,体验崩塌。
  • XSS 风险——如果直接 innerHTML = content,模型输出里的 <script>、onerror= 等会被执行;更隐蔽的是,代码块里的 < > 必须转义,否则会破坏 DOM。
  • 难点在于:代码块里的特殊字符要原样展示(但需转义 <>),而代码块外的 Markdown 要被解析成 HTML。如果先做 Markdown 解析再转义,会破坏代码高亮要用的标签;如果先转义再解析,Markdown 的正则又会误伤转义后的实体。AI Nexus 的 MarkdownRenderer 用一套"占位符保护 → 转义 → 解析 → 还原"的流程干净地解决了这个矛盾。

    二、Markdown 渲染器:占位符保护策略

    看 markdown.js 的 render() 入口。它的核心设计是:先把代码块、行内代码抽出来用私有占位符(Unicode 私用区字符)替换掉,等文本转义和 Markdown 解析都做完后,再把它们按原样还原。

    const MarkdownRenderer = {
    render(text) {
    if (!text) return '';
    const PH_START = '\\uE000'; // Unicode 私用区起点
    const PH_END = '\\uE001'; // Unicode 私用区终点

    // 第1步:提取并保护代码块
    const codeBlocks = [];
    text = text.replace(/```(\\w*)\\n?([\\s\\S]*?)```/g, (_, lang, code) => {
    const id = codeBlocks.length;
    codeBlocks.push({ lang: lang || '', code: code.replace(/\\n$/, '') });
    return PH_START + 'CB' + id + PH_END;
    });

    // 第2步:提取并保护行内代码
    const inlineCodes = [];
    text = text.replace(/`([^`]+)`/g, (_, code) => {
    const id = inlineCodes.length;
    inlineCodes.push(code);
    return PH_START + 'IC' + id + PH_END;
    });

    // 第3步:HTML转义
    text = this.escapeHtml(text);

    // 第4步:处理Markdown语法(标题/加粗/列表/表格……)
    text = text.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
    text = text.replace(/^### (.+)$/gm, '<h3>$1</h3>');
    // … 其余 Markdown 规则
    }
    };

    几个设计要点:

    • 占位符用 \\uE000/\\uE001:这是 Unicode 私用区(Private Use Area),正常文本里几乎不会出现,因此不会和用户内容冲突。占位符格式如 \\uE000CB0\\uE001,CB 表示代码块,0 是索引。
    • 正则用非贪婪 \\s\\S*?:/```(\\w*)\\n?([\\s\\S]*?)```/g 能跨行匹配且只吃到第一个闭合的 ```````````,避免吞掉多个代码块。
    • code.replace(/\\n$/, ''):去掉代码块末尾多余的换行,渲染更整洁。

    代码块被"抽走"之后,剩下的纯文本才安全地经过 this.escapeHtml(text)。注意这个 escapeHtml 和 utils.js 里的略有不同——它只用字符串映射,不依赖 DOM,性能更好:

    escapeHtml(text) {
    const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' };
    return text.replace(/[&<>"']/g, c => map[c]);
    }

    转义之后,文本里已经没有 < > 能破坏结构,此时放心跑 Markdown 正则。比如标题、加粗、列表、引用、表格、分割线——都是简单的 String.replace:

    text = text.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
    text = text.replace(/^### (.+)$/gm, '<h3>$1</h3>');
    text = text.replace(/^## (.+)$/gm, '<h2>$1</h2>');
    text = text.replace(/^# (.+)$/gm, '<h2>$1</h2>');
    text = text.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');
    text = text.replace(/\\*(.+?)\\*/g, '<em>$1</em>');
    text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');

    链接处理里藏着一个安全闸——禁止 javascript: 和 data: 协议,防止 XSS:

    text = text.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, (_, linkText, url) => {
    if (/^(javascript|data):/i.test(url)) return linkText; // 危险协议直接丢弃链接
    return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + linkText + '</a>';
    });

    2.1 列表解析的小巧思

    纯正则处理列表有个麻烦:多个 <li> 要被同一个 <ul> 包裹。AI Nexus 用了"哨兵字符"过渡:先用不可见占位符 \\x00UL\\x00…\\x00/UL\\x00 标出每个列表项,再把相邻的项合并成 <ul><li>…:

    text = text.replace(/^[\\-\\*] (.+)$/gm, '\\x00UL\\x00$1\\x00/UL\\x00');
    text = text.replace(/\\x00\\/UL\\x00\\s*\\x00UL\\x00/g, '\\n');
    text = text.replace(/\\x00UL\\x00/g, '<ul><li>');
    text = text.replace(/\\x00\\/UL\\x00/g, '</li></ul>');

    \\x00 是空字符,介于"不会出现在用户输入里"和"便于正则识别"之间。先给每个项套上开闭哨兵,再把"闭合哨兵紧接着下一个开放哨兵"的情况(即相邻列表项)替换成换行,最后统一把哨兵换成真正的 <ul><li> 标签。有序列表 \\d+. 同理。这套写法虽然朴素,但足以覆盖 95% 的真实 Markdown 列表场景。

    2.2 表格解析

    表格用多行正则一次匹配"表头行 + 分隔行 + 表体行":

    text = text.replace(/^(\\|.+\\|)\\n(\\|[\\s\\-:|]+\\|)\\n((?:\\|.+\\|\\n?)*)/gm, (_, headerRow, sepRow, bodyRows) => {
    const headers = headerRow.split('|').filter(c => c.trim()).map(c => '<th>' + c.trim() + '</th>').join('');
    const rows = bodyRows.trim().split('\\n').map(row => {
    const cells = row.split('|').filter(c => c.trim()).map(c => '<td>' + c.trim() + '</td>').join('');
    return '<tr>' + cells + '</tr>';
    }).join('');
    return '<table><thead><tr>' + headers + '</tr></thead><tbody>' + rows + '</tbody></table>';
    });

    第 1、3 步拼装好 HTML 后,还有一段"清理空段落"的后处理,专门把 <p> 包裹在 <h2>/<pre>/<ul>/<table> 等块级元素外产生的 <p><h2> 这种非法嵌套拆掉:

    text = text.replace(/<p>\\s*<\\/p>/g, '');
    text = text.replace(/<p>\\s*(<h[2-4]>)/g, '$1');
    text = text.replace(/(<\\/h[2-4]>)\\s*<\\/p>/g, '$1');
    text = text.replace(/<p>\\s*(<pre>)/g, '$1');
    text = text.replace(/(<\\/pre>)\\s*<\\/p>/g, '$1');
    // … ul/ol/blockquote/hr/table 同理

    2.3 还原占位符(代码块 + 行内代码)

    所有 Markdown 规则跑完,最后把占位符换回真实内容。行内代码直接转义后包 <code>:

    inlineCodes.forEach((code, id) => {
    const placeholder = PH_START + 'IC' + id + PH_END;
    text = text.split(placeholder).join('<code>' + this.escapeHtml(code) + '</code>');
    });

    代码块则要调用语法高亮器,并拼出带"语言标签 + 复制按钮"的 <pre> 结构:

    codeBlocks.forEach((block, id) => {
    const placeholder = PH_START + 'CB' + id + PH_END;
    const highlighted = SyntaxHighlighter.highlight(block.code, block.lang);
    const langLabel = block.lang || 'code';
    const html = '<pre><div class="code-block-header"><span class="code-block-lang">' +
    this.escapeHtml(langLabel) +
    '</span><button class="code-copy-btn" data-action="copy-code">复制</button></div><code class="language-' +
    this.escapeHtml(block.lang) + '">' + highlighted + '</code></pre>';
    text = text.split(placeholder).join(html);
    });

    注意这里用的是 text.split(placeholder).join(xxx) 而不是 text.replace(placeholder, xxx)——因为 replace 默认只替换第一个匹配,而 split/join 能替换全部,且避免了占位符里特殊字符被正则转义的麻烦。

    三、语法高亮器:一次遍历的正则扫描

    多数高亮库(highlight.js、Prism)很重。AI Nexus 自己写了一个轻量到"零依赖"的 SyntaxHighlighter,核心思想极巧妙:把所有语法元素(注释、字符串、关键词、函数、数字)的正则拼成一个"或"组合的大正则,一次 exec 遍历,按捕获组判定类型,包上 <span class="token-xxx">。

    highlight(code, lang) {
    if (!code) return '';
    lang = (lang || '').toLowerCase().trim();
    const { regex, groupTypes } = this.buildRegex(lang);
    const result = [];
    let lastIndex = 0;
    let match;
    while ((match = regex.exec(code)) !== null) {
    if (match.index > lastIndex) {
    result.push(this.escapeHtml(code.slice(lastIndex, match.index))); // 普通文本先转义
    }
    let type = 'plain';
    for (let g = 0; g < groupTypes.length; g++) {
    if (match[g + 1] !== undefined) { type = groupTypes[g]; break; } // 命中第几个捕获组,就是哪种类型
    }
    if (type === 'plain') {
    result.push(this.escapeHtml(match[0]));
    } else {
    result.push('<span class="token-' + type + '">' + this.escapeHtml(match[0]) + '</span>');
    }
    lastIndex = match.index + match[0].length;
    if (match[0].length === 0) regex.lastIndex++; // 防零宽死循环
    }
    if (lastIndex < code.length) {
    result.push(this.escapeHtml(code.slice(lastIndex)));
    }
    let html = result.join('');
    html = this.postProcess(html, lang);
    return html;
    }

    遍历逻辑像"贪心切词":每次 regex.exec 抓住下一个语法片段,片段前的普通文本先转义入栈;用 match[g+1] 检测命中了第几个捕获组(注意 g 从 0 开始,捕获组从 1 开始),从而知道该片段是 comment / string / keyword / function / number 哪一种。

    buildRegex 负责按语言拼这个组合正则,并把捕获组顺序和类型数组对齐:

    buildRegex(lang) {
    if (this._regexCache[lang]) return this._regexCache[lang];
    const parts = [];
    const groupTypes = [];
    // 块注释
    parts.push('(\\\\/\\\\*[\\\\s\\\\S]*?\\\\*\\\\/)'); groupTypes.push('comment');
    // 行注释(按语言选 # 或 //)
    if (this.usesHashComments(lang)) { parts.push('(#[^\\\\n]*)'); groupTypes.push('comment'); }
    if (this.usesSlashComments(lang)) { parts.push('(\\\\/\\\\/[^\\\\n]*)'); groupTypes.push('comment'); }
    // 字符串(三种引号)
    parts.push('("(?:[^"\\\\\\\\]|\\\\\\\\.)*")'); groupTypes.push('string');
    parts.push("('(?:[^'\\\\\\\\]|\\\\\\\\.)*')"); groupTypes.push('string');
    parts.push('(`(?:[^`\\\\\\\\]|\\\\\\\\.)*`)'); groupTypes.push('string');
    // 关键词
    const kw = this.getKeywords(lang);
    if (kw) {
    const kwPattern = kw.split(' ').filter(w => w).map(w => w.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')).join('|');
    if (kwPattern) { parts.push('(\\\\b(?:' + kwPattern + ')\\\\b)'); groupTypes.push('keyword'); }
    }
    // 函数调用:标识符后紧跟 (
    parts.push('(\\\\b[a-zA-Z_]\\\\w*(?=\\\\s*\\\\())'); groupTypes.push('function');
    // 数字
    parts.push('(\\\\b\\\\d+\\\\.?\\\\d*\\\\b)'); groupTypes.push('number');
    const compiled = { regex: new RegExp(parts.join('|'), 'gm'), groupTypes };
    this._regexCache[lang] = compiled; // 缓存,避免重复编译
    return compiled;
    }

    三个值得记住的工程点:

    • 顺序即优先级:正则里块注释排最前、字符串其次、关键词再次。因为 (a|b|c) 的匹配按"在文本中的位置最早"而非"分支顺序",但在同一位置时按左到右——把注释放前面,能保证 // 注释里的 function 不会被误高亮成函数。这是经典的正则高亮陷阱,本工程靠排列顺序规避。
    • 关键词先转义再 | 拼接:用户提供的语言关键词可能含正则元字符,先 w.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') 才安全。
    • \\b…\\b 词边界:保证 class 不会高亮 classify 里的子串。
    • _regexCache 缓存编译结果:每种语言的正则只编译一次,重复渲染同一语言代码块零成本。

    postProcess 做语言专属增强,例如让 Python 的 def foo 里的 foo 高亮成函数名、class Foo 里的 Foo 高亮成类型名:

    postProcess(html, lang) {
    if (['python', 'py'].includes(lang)) {
    html = html.replace(/(<span class="token-keyword">def<\\/span>)(\\s+)(\\w+)/g, '$1$2<span class="token-function">$3</span>');
    html = html.replace(/(<span class="token-keyword">class<\\/span>)(\\s+)(\\w+)/g, '$1$2<span class="token-type">$3</span>');
    html = html.replace(/^(\\s*)(@\\w+)/gm, '$1<span class="token-function">$2</span>');
    }
    if (['html', 'xml', 'svg'].includes(lang)) {
    html = html.replace(/(&lt;\\/?)([\\w-]+)/g, '$1<span class="token-tag">$2</span>');
    }
    // … go / rust / js / typescript 类似
    return html;
    }

    最后这些 token-xxx 类由 CSS 上色。style.css 用 CSS 变量统一管理两套主题:

    .token-keyword { color: var(–hl-keyword); }
    .token-string { color: var(–hl-string); }
    .token-number { color: var(–hl-number); }
    .token-comment { color: var(–hl-comment); font-style: italic; }
    .token-function{ color: var(–hl-function); }

    而 –hl-keyword 等变量在暗色/亮色主题下取值不同(暗色 #ff7b72、亮色 #d73a49),所以高亮配色自动跟随主题切换——这是"配置即视图"思想的又一次体现。

    四、单条消息的渲染与流式增量更新

    回到 app.js,renderMessageHtml 决定一条消息长什么样:用户消息直接 escapeHtml(不需要 Markdown 解析),AI 消息走 MarkdownRenderer.render:

    function renderMessageHtml(msg, index) {
    const isUser = msg.role === 'user';
    const isError = msg.role === 'error';
    const avatar = isUser ? 'U' : 'AI';
    const roleName = isUser ? '你' : (isError ? '错误' : 'AI助手');
    const contentHtml = isUser ? escapeHtml(msg.content) : MarkdownRenderer.render(msg.content);
    return '<div class="message ' + (isError ? 'error' : msg.role) + '">' +
    '<div class="message-avatar">' + avatar + '</div>' +
    '<div class="message-body">' +
    '<div class="message-role">' + roleName + '</div>' +
    '<div class="message-content" id="msg_' + index + '">' + contentHtml + '</div>' +
    '<div class="message-actions">' +
    '<button class="msg-action-btn" data-action="copy-msg" data-index="' + index + '">复制</button>' +
    '<button class="msg-action-btn" data-action="export-msg-image" data-index="' + index + '">导图</button>' +
    '<button class="msg-action-btn" data-action="delete-msg" data-index="' + index + '">删除</button>' +
    (!isUser ? '<button class="msg-action-btn" data-action="regen-msg" data-index="' + index + '">重新生成</button>' : '') +
    '</div>' +
    '</div></div>';
    }

    每条消息都带 id="msg_${index}",这正是流式更新能精准改写单条 DOM 的钥匙(updateStreamingMessage 用 getElementById('msg_' + i) 定位)。注意代码里 data-index="${index}"——它把"消息在第几条"编码进 DOM,配合第四节讲的事件委托,点击"复制/删除/重新生成"时无需重新绑定监听。

    "正在生成中"的占位由 renderMessagesWithTyping 渲染一个三点跳动的打字指示器:

    function renderMessagesWithTyping(ti) {
    const chat = getActiveChat();
    const c = document.getElementById('chatMessages');
    if (!c || !chat) return;
    let h = '';
    chat.messages.forEach((m, i) => {
    if (i === ti && !m.content) {
    h += '<div class="message assistant"><div class="message-avatar">AI</div>' +
    '<div class="message-body"><div class="message-role">AI助手</div>' +
    '<div class="message-content" id="msg_' + i + '">' +
    '<div class="typing-indicator"><span></span><span></span><span></span></div>' +
    '</div></div></div>';
    } else {
    h += renderMessageHtml(m, i);
    }
    });
    c.innerHTML = h;
    scrollToBottom();
    }

    打字指示器三个 <span> 的动画在 CSS 里用 animation-delay 错开,形成"呼吸"效果,纯 CSS 实现,不耗 JS:

    .typing-indicator span { width: 7px; height: 7px; background: var(–accent-cyan); border-radius: 50%; animation: typing 1.2s infinite; }
    .typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
    .typing-indicator span:nth-child(3) { animation-delay: 0.4s; }

    五、事件委托:一个监听器撑起整套 UI

    传统写法是给每个按钮 addEventListener,但消息是动态渲染的,按钮每次都在变,逐个绑定既慢又易漏。AI Nexus 用全局事件委托:只在 document 上挂一个 click 监听,靠 data-action 属性分发:

    document.addEventListener('click', function(e) {
    const actionEl = e.target.closest('[data-action]');
    if (!actionEl) {
    const qcp = document.getElementById('quickCommandsPanel');
    if (qcp) qcp.classList.remove('show');
    return;
    }
    const action = actionEl.dataset.action;
    switch (action) {
    case 'new-chat': createNewChat(); break;
    case 'clear-chat': clearCurrentChat(); break;
    case 'copy-msg': { /* … nav.clipboard.writeText … */ break; }
    case 'delete-msg': { /* … chat.messages.splice … */ break; }
    case 'regen-msg': { /* 截断到该条并重发 */ break; }
    case 'toggle-theme': setTheme(getTheme() === 'dark' ? 'light' : 'dark'); break;
    case 'quick-command': applyQuickCommand(actionEl.dataset.cmd); break;
    // … 其余几十个 action
    }
    });

    e.target.closest('[data-action]') 是关键:点击发生在按钮内部的某个 <span> 或 <svg> 上时,closest 会向上找到最近的带 data-action 的祖先,从而正确识别意图。这带来三大好处:① 动态 DOM 无需重新绑定;② 全局只一个监听器,内存友好;③ action 与函数映射集中在一个 switch,可读可维护。

    regen-msg(重新生成)的实现很有意思——它把 messages 截到目标消息之前,再重新发一次请求:

    case 'regen-msg': {
    const chat3 = getActiveChat();
    const idx3 = parseInt(actionEl.dataset.index);
    if (chat3) { chat3.messages = chat3.messages.slice(0, idx3); saveState(); renderMessages(); sendToAPI(); }
    break;
    }

    六、快捷指令:输入 / 触发的命令面板

    输入框监听 input,当内容以 / 开头且不超过 20 字符时,弹出快捷指令面板:

    function handleQuickCommandInput(value) {
    const qcp = document.getElementById('quickCommandsPanel');
    if (!qcp) return;
    if (value.startsWith('/') && value.length <= 20) {
    const query = value.slice(1).toLowerCase();
    const filtered = QUICK_COMMANDS.filter(cmd =>
    cmd.name.toLowerCase().includes(query) || cmd.desc.includes(query)
    );
    if (filtered.length > 0) {
    let html = '';
    filtered.forEach((cmd, idx) => {
    html += '<div class="quick-command-item' + (idx === 0 ? ' active' : '') +
    '" data-action="quick-command" data-cmd="' + cmd.id + '">' +
    '<span class="cmd-icon">' + cmd.icon + '</span>' +
    '<div class="cmd-info"><div class="cmd-name">' + escapeHtml(cmd.name) +
    '</div><div class="cmd-desc">' + escapeHtml(cmd.desc) + '</div></div></div>';
    });
    qcp.innerHTML = html;
    qcp.classList.add('show');
    appState.quickCommandActive = 0;
    } else {
    qcp.classList.remove('show');
    appState.quickCommandActive = –1;
    }
    } else {
    qcp.classList.remove('show');
    appState.quickCommandActive = –1;
    }
    }

    QUICK_COMMANDS 在 config.js 里定义,每条带 template——选中后直接把模板填进输入框(例如 /代码审查 会填入"请审查以下代码…"加一对空代码围栏)。键盘上下键导航靠 navigateQuickCommand,Enter 选中当前 appState.quickCommandActive 项。这种设计把"常用 prompt"做成了可发现、可键盘操作的命令面板,体验接近 IDE。 在这里插入图片描述

    七、主题切换与数据备份

    主题切换是 CSS 变量 + data-theme 属性驱动,JS 只负责切属性并存 localStorage:

    function setTheme(theme) {
    document.documentElement.setAttribute('data-theme', theme);
    localStorage.setItem('ai_nexus_theme', theme);
    }
    function getTheme() {
    return localStorage.getItem('ai_nexus_theme') || 'dark';
    }

    style.css 里 :root 是暗色默认值,[data-theme="light"] 覆盖一组变量即可整站换肤,无需任何组件重渲染——这是"设计令牌(Design Token)"思路的最小实现。

    数据备份用 Blob + URL.createObjectURL 导出 JSON,用 FileReader 导入,全过程纯前端:

    function exportAllChats() {
    const d = { version: '2.0', exportTime: new Date().toISOString(), chats: appState.chats };
    const b = new Blob([JSON.stringify(d, null, 2)], { type: 'application/json' });
    const u = URL.createObjectURL(b);
    const a = document.createElement('a');
    a.href = u;
    a.download = 'ai_nexus_chats_' + new Date().toISOString().slice(0, 10) + '.json';
    a.click();
    URL.revokeObjectURL(u);
    showToast('对话已导出', 'success');
    }

    导入时用 Set 去重,已存在的会话不重复追加,避免覆盖或重复:

    const ids = new Set(appState.chats.map(c => c.id));
    let n = 0;
    d.chats.forEach(c => { if (!ids.has(c.id)) { appState.chats.push(c); n++; } });

    八、全局快捷键与下拉交互的真实实现

    事件委托不止用于点击。顶部工具栏的模型选择器下拉、以及全套全局快捷键,也都集中在 bindEvents() 里,和"单一监听器分发"是同一套哲学。先看模型下拉的"点外部关闭"——它用了两个监听配合,避免自己点开又立刻被关掉:

    document.getElementById('modelSelector').addEventListener('click', function(e) {
    if (document.getElementById('modelDropdown').contains(e.target)) return; // 点到下拉内部不算"外部"
    document.getElementById('modelDropdown').classList.toggle('show');
    });
    document.addEventListener('click', function(e) {
    if (!document.getElementById('modelSelector').contains(e.target)) {
    document.getElementById('modelDropdown').classList.remove('show'); // 点任意外部区域则收起
    }
    });

    第一个监听挂在 .modelSelector 上负责切换;第二个挂在 document 上负责"点外部收起"。关键防御是 if (modelDropdown.contains(e.target)) return 和 if (!modelSelector.contains(e.target))——因为两个监听都会触发,必须先判断点击是否落在下拉内部,否则会出现"点开后被 document 监听马上关掉"的闪烁。这种"内部忽略、外部收起"的配对模式,是所有自定义下拉菜单的标配写法。

    全局快捷键则挂在 document 的 keydown 上,用 e.ctrlKey / e.shiftKey 组合判定,全部 preventDefault 阻止浏览器默认行为:

    document.addEventListener('keydown', function(e) {
    if (e.ctrlKey && e.key === 'n') { e.preventDefault(); createNewChat(); }
    if (e.ctrlKey && e.key === 'b') { e.preventDefault(); toggleSidebar(); }
    if (e.ctrlKey && e.key === 'k') { e.preventDefault(); openSearchInChat(); }
    if (e.ctrlKey && e.shiftKey && e.key === 'S') { e.preventDefault(); setTheme(getTheme() === 'dark' ? 'light' : 'dark'); }
    if (e.key === 'Escape') {
    document.getElementById('rightPanel').classList.remove('open');
    closeModal('addProviderModal');
    }
    });

    Ctrl+Shift+S 里 e.key 是 'S'(大写),因为按住 Shift 后字母键返回大写形式,这点容易写错成 's' 而失效。输入框自身的 keydown 则处理 Enter 发送、Shift+Enter 换行,以及快捷指令面板打开时的上下键导航(ArrowDown/ArrowUp 调 navigateQuickCommand,Enter 选中、Esc 关闭)。把"发送/换行/命令导航"三种语义压缩进同一个 keydown,靠 e.shiftKey 和面板是否 show 两个状态分流,逻辑紧凑且不冲突。

    九、安全小结:纯前端产品的三道防线

    交付包虽然零后端,但安全不能零。它至少布了三道防线:

  • 所有用户可见文本走 escapeHtml:用户消息、消息标题、搜索结果、角色名等,凡进 innerHTML 的都先转义(见 renderMessageHtml、renderChatList)。
  • Markdown 链接禁危险协议:javascript:/data: 直接丢弃,防 XSS。
  • 单引号/双引号属性转义:escapeAttr 专门用于写进 value="…"、data-key="…" 的属性值,防止属性注入。
  • function escapeAttr(t) {
    if (!t) return '';
    return t.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#039;')
    .replace(/</g, '&lt;').replace(/>/g, '&gt;');
    }

    值得多说一句:工程里其实有两套 escapeHtml——utils.js 用的是"借助 DOM"的版本(const d = document.createElement('div'); d.textContent = t; return d.innerHTML;),而 markdown.js 里的是"字符串映射"版本(只替换五个字符、不碰 DOM)。前者更省心、绝不会漏转义,但每次调用都创建 DOM 节点;后者在渲染器里被高频调用(每条消息每个片段都跑),用纯字符串替换避免了频繁 DOM 操作的开销。这个"高频路径用轻量实现、低频路径用稳妥实现"的取舍,是手写渲染器时才需要考虑的细节——直接用库往往意识不到这层成本差异。而 escapeAttr 与 escapeHtml 职责也不同:前者专门处理"属性值上下文"(额外转义引号,防 data-key="x" 注入 截断属性),后者处理"元素内容上下文",两者不可互相替代。

    九、会话时间分组与友好时间显示

    渲染层不止 Markdown。左侧会话列表里的"今天 / 昨天 / 本周 / 更早"分组,靠的是 utils.js 里两个纯函数:getTimeGroup(ts) 把时间戳归类,formatTime(ts) 把时间戳变成"刚刚 / 3分钟前 / 今天 14:30"这种人类友好文案。

    function getTimeGroup(timestamp) {
    if (!timestamp) return '更早';
    const date = new Date(timestamp);
    const now = new Date();
    if (date.getDate() === now.getDate() && date.getMonth() === now.getMonth() && date.getFullYear() === now.getFullYear()) return '今天';
    const yesterday = new Date(now – 86400000);
    if (date.getDate() === yesterday.getDate() && date.getMonth() === yesterday.getMonth()) return '昨天';
    const weekStart = new Date(now);
    weekStart.setDate(now.getDate() – now.getDay());
    weekStart.setHours(0, 0, 0, 0);
    if (date >= weekStart) return '本周';
    return '更早';
    }

    renderChatList 拿到分组后,先按"置顶"挑出来,其余按 getTimeGroup 落入对应桶,再逐个渲染带 data-action="toggle-group" 的折叠头。点击头部会翻转 _collapsedGroups[name] 并整列表重绘,折叠态通过 CSS max-height: 0 过渡实现。这里有个细节:置顶项用 chat.pinned 标记,并在 togglePinChat 里翻转;渲染时置顶组永远排在最前,未置顶的则交给时间分组。整套逻辑零依赖、可单测,是非常好的"纯函数 + 渲染分离"范例。

    十、导出为图片:html2canvas 的可选降级

    "导出为图片"按钮调用 utils.js 的 exportAsImage(content, title)。它先把消息 HTML 塞进一个 left:-9999px 的离屏容器(带暗色背景和标题),然后判断 window.html2canvas 是否存在:

    function exportAsImage(content, title) {
    const tempDiv = document.createElement('div');
    tempDiv.style.cssText = 'position:fixed;left:-9999px;top:0;width:600px;…';
    tempDiv.innerHTML = '<div …>' + escapeHtml(title || 'AI对话') + '</div>' + '<div …>' + content + '</div>…';
    document.body.appendChild(tempDiv);
    if (window.html2canvas) {
    window.html2canvas(tempDiv, { backgroundColor: '#0a0e1a' }).then(canvas => {
    const link = document.createElement('a');
    link.download = (title || 'ai-chat') + '.png';
    link.href = canvas.toDataURL();
    link.click();
    document.body.removeChild(tempDiv);
    showToast('图片已导出', 'success');
    });
    } else {
    const textContent = tempDiv.innerText;
    navigator.clipboard.writeText(textContent).then(() => {
    showToast('已复制对话文本(安装html2canvas可导出图片)', 'info');
    });
    document.body.removeChild(tempDiv);
    }
    }

    这是典型的优雅降级(graceful degradation):html2canvas 是个外部库,交付包没强制引入,所以用 if (window.html2canvas) 探测。有就导出 PNG,没有就退化为"复制纯文本 + 提示装库"。注意 content 是已经过 Markdown 渲染的 HTML,所以导图里代码高亮、表格都能保留;而复制文本分支用 innerText 拿到无格式纯文本,互不干扰。离屏容器用完即 removeChild,不残留 DOM。

    十一、欢迎屏与快捷操作入口

    空会话不应该是空白。 getWelcomeHtml() 返回一个带 Logo、简介和 4 张快捷卡片的欢迎屏,每张卡片都是 data-action="quick-msg" 且带 data-text:

    function getWelcomeHtml() {
    return '<div class="welcome-screen">' +
    '<div class="icon">AI</div>' +
    '<h2>AI Nexus 对话平台</h2>' +
    '<p>连接 Qwen / DeepSeek / GLM 等国产大模型,开启智能对话…</p>' +
    '<div class="quick-actions">' +
    '<div class="quick-action" data-action="quick-msg" data-text="用简洁的语言介绍一下你自己">…</div>' +
    '<div class="quick-action" data-action="quick-msg" data-text="用Python写一个快速排序算法,并解释其原理">…</div>' +
    '</div></div>';
    }

    点击卡片时,事件委托命中 quick-msg,把 data-text 填进输入框并直接 sendMessage()——用户零输入即可发起一次示例对话。这个设计与第五节讲的 /快捷指令 互补:欢迎屏面向"第一次打开、不知道问什么"的新用户,命令面板面向"正在输入、想插入模板"的熟手。两者都复用同一套 data-action 分发,没有新增任何事件绑定。

    十二、结语:这套代码能怎么扩展

    通读两篇涉及的源码,你会看见一个清晰、可复刻的架构:配置驱动的多供应商层 + fetch/AbortController 通信层 + 占位符保护的 Markdown 渲染层 + 单遍正则高亮层 + 事件委托交互层 + localStorage 持久层。每一层都低耦合、零外部依赖。

    如果你想把它变成自己的产品,几个低风险扩展点:① 在 config.js 的 PRESET_PROVIDERS 加新厂商,通信层无需改动;② 在 AI_ROLES 加角色即获得新系统提示词预设;③ 给 SyntaxHighlighter.keywords 加语言即获得新语言高亮;④ 把 localStorage 换成 IndexedDB 即可支撑超长对话历史。

    赞(0)
    未经允许不得转载:171主机测评 » AI 纯前端对话:从 Markdown 占位符渲染到事件委托的零依赖架构实现
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址