简介:chatwork-preview是一款基于JavaScript开发的轻量级聊天工作预览工具,支持在不进入完整聊天界面的情况下快速浏览结构化聊天内容。该工具通过解析特定标签如[title]、[info]和[hr],实现主题突出、信息分层与内容分隔,提升阅读效率。尽管暂不支持表情符号渲染,但其简洁高效的预览机制特别适用于任务描述、会议纪要等场景。项目可通过GitHub Pages(chatwork-preview-gh-pages)在线部署访问,具备良好的可测试性与前端兼容性,是前端动态内容解析与轻量工具设计的典型实践案例。
chatwork-preview:从零构建一个安全、可扩展的前端内容预览引擎
你有没有遇到过这样的场景?团队在 ChatWork 或类似协作工具里发了一大段“带格式”的消息,比如:
[title]本周发布计划[/title]
[info type="warning"]数据库迁移将在今晚23:00开始,请暂停所有写操作![/info]
[hr]
[title level=2]新功能说明[/title]
...
结果——全是纯文本 🤦♂️。标题没加粗,警告信息混在一堆文字里被忽略,连个分割线都没有。
这就是 chatwork-preview 存在的意义。它不依赖后端,也不需要复杂的框架, 仅用原生 JavaScript + 正则表达式 + DOM 操作,就能把一段看似普通的聊天文本,瞬间变成结构清晰、视觉友好、交互丰富的 Web 页面 。听起来像魔法?其实背后是一套精心设计的“编译器思维”。
我们今天就来深挖这个小而美的工具,看看它是如何一步步把 [title] 和 [info] 这种自定义标签,变成真正可访问、可交互、还防 XSS 的 DOM 节点的。准备好了吗?咱们直接开干!
从 [title] 开始:语法设计与正则匹配的艺术
一切的起点,是那个最简单的标签: [title] 。
但别小看它,一个看似“只是换个字体大小”的功能,背后藏着不少门道。我们先问自己几个问题:
- 用户怎么写才自然?
- 怎么确保不会误伤普通文本里的
[title]字样? - 如何支持不同层级的标题(h1~h6)?
- 能不能让用户少打几个字?
带着这些问题, chatwork-preview 给出了它的答案:
[title level=1]这是一级标题[/title]
[title level=2]这是二级标题[/title]
[title]默认就是一级[/title]
是不是很像 HTML,但又轻量得多?方括号 [] 是关键——它既不像 <tag> 那样容易和 Markdown 冲突,又能清晰地标记出“这里是个指令”。
正则表达式:精准捕获的艺术
接下来的问题是:怎么从一整段文本里,把这三个部分(开始标签、内容、结束标签)拎出来?
答案是正则表达式:
const TITLE_TAG_REGEX = /\[title(?:\s+level=(\d))?\](.*?)\[\/title\]/gi;
来,我们拆开看:
-
\[title→ 匹配字面量[title,注意左括号要转义。 -
(?:\s+level=(\d))?→ 这是一个非捕获组,表示“level=N”是可选的。里面的\d会被捕获,用来获取具体数值。 -
\]→ 匹配右括号]。 -
(.*?)→ 非贪婪匹配标题内容,避免跨标签错误捕获。 -
\[\/title\]→ 匹配闭合标签[/title]。
这个正则有两个捕获组:
1. 第一个是 level 值(如果没写就是 undefined );
2. 第二个是标题文本。
graph TD
A[原始输入字符串] --> B{是否存在匹配?}
B -->|是| C[执行正则exec循环]
C --> D[提取level值]
D --> E[提取标题内容]
E --> F[构建标题对象]
B -->|否| G[跳过处理]
F --> H[继续下一轮匹配]
通过 while (match = regex.exec(text)) 循环,我们可以遍历所有匹配项,哪怕有十个 [title] 标签也毫无压力。
⚠️ 小心回溯陷阱
正则虽强,但也可能翻车。如果用户恶意输入超长文本(比如 1MB 的 [title]...[/title] 中间夹杂各种符号),正则引擎可能会陷入“回溯失控”,导致页面卡死。
我们的对策很简单:
- 使用非贪婪模式 .*? ;
- 对输入长度做限制(比如 ≤ 10KB);
- 在生产环境考虑使用更安全的解析器(如 PEG.js)替代正则。
不过对于 chatwork-preview 这种轻量工具,正则是完全够用的。
多层级标题的语义优先级
你有没有想过,为什么 [title level=1] 就一定要比 [title level=2] 更“重要”?
这不是技术问题,而是 信息架构 问题。
假设你看到这段内容:
[title level=2]项目概述[/title]
[title]需求背景[/title]
[title level=3]目标用户分析[/title]
虽然“需求背景”出现在中间,但它是一级标题,应该在整个文档结构中占据顶层地位。这就要求我们在渲染时,不仅要关心“顺序”,更要理解“层级”。
我们建立一个映射表来统一管理:
| level | HTML标签 | 字体大小(rem) | 加粗程度 |
|---|---|---|---|
| 1 | <h1> | 2.0 | Bold |
| 2 | <h2> | 1.75 | SemiBold |
| 3 | <h3> | 1.5 | SemiBold |
| 4 | <h4> | 1.25 | Normal |
| 5 | <h5> | 1.1 | Normal |
| 6 | <h6> | 1.0 | Normal |
这样,不管标题出现在第几行,只要 level=1 ,它就应该拥有最大的视觉权重。
而且,这个表不只是给浏览器看的,更是给 屏幕阅读器 准备的!通过设置 aria-level 属性,视障用户也能清楚地知道:“我现在读的是章节标题,而不是某个子标题”。
当然,用户也可能手滑写成 level=7 ,怎么办?我们用一个简单的钳制函数搞定:
function clampLevel(level) {
return Math.max(1, Math.min(6, parseInt(level, 10) || 1));
}
超出范围就自动拉回到最近的有效值,既不影响整体流程,又能悄悄记录一条警告日志用于调试 👌。
解析流程:从字符串到数据结构的蜕变
识别出标签只是第一步。下一步,我们要把这些零散的匹配结果,变成结构化的数据,方便后续处理。
分块处理:为大文本优化性能
想象一下,有人贴了一篇 5000 字的产品需求文档,里面嵌了十几个 [title] 标签。如果你一次性全解析完再渲染,用户会感觉页面“卡了一下”。
更好的做法是: 边接收边处理,优先显示已确认的部分 。
这就是“分块扫描 + 流式输出”策略:
function* parseTitleSegments(text, chunkSize = 512) {
let offset = 0;
const regex = /\[title(?:\s+level=(\d))?\](.*?)\[\/title\]/gi;
while (offset < text.length) {
const chunk = text.slice(offset, offset + chunkSize);
let match;
regex.lastIndex = 0; // 重置 lastIndex,防止跨块错乱
while ((match = regex.exec(chunk)) !== null) {
const level = clampLevel(match[1]);
const content = match[2].trim();
yield { type: 'title', level, content };
}
offset += chunkSize;
}
}
看到了吗?我们用了 function* 生成器,让这个函数变成了“惰性求值”的管道。消费者可以一个个取结果,而不是等全部处理完。
这招特别适合配合 requestIdleCallback ,在浏览器空闲时慢慢解析,完全不影响主线程响应用户操作。用户体验直接起飞🚀!
动态属性提取:为未来扩展留后路
现在我们只支持 level 属性,但谁说得准明天会不会加个 id 或 class ?
所以,正则得改得更通用一点:
/\[title(?:\s+(\w+)=(?:"([^"]*)"|'([^']*)'|(\S+)))?\s*\](.*?)\[\/title\]/gi
这个正则能匹配:
- level=2
- class="highlight"
- id='section-1'
- data-theme=dark
然后我们写个通用解析函数:
function extractAttributes(attrStr) {
const attrs = {};
if (!attrStr) return attrs;
const pairs = attrStr.matchAll(/(\w+)=(?:"([^"]*)"|'([^']*)'|(\S+))/g);
for (const [, key, doubleQuoted, singleQuoted, unquoted] of pairs) {
const value = doubleQuoted || singleQuoted || unquoted;
attrs[key.toLowerCase()] = value;
}
return attrs;
}
这样一来,即使将来要支持锚点跳转 #sec-intro ,我们也只需要改 CSS 和 JS,不用动核心解析逻辑。 这才是可持续的设计!
安全第一:XSS 防护不是可选项
用户输入永远不可信。如果有人写了这么一句:
[title]<script>alert('xss')</script>[/title]
你直接 .innerHTML = content ,恭喜,你的页面就被人“接管”了。
解决方法很简单粗暴: HTML 实体编码 。
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
原理是利用浏览器的安全机制: textContent 只认文本,不会解析标签。然后再通过 innerHTML 把转义后的字符拿回来。
结果就是:
<script>alert('xss')</script>
在页面上显示的就是原文本,而不是执行脚本。
flowchart LR
Input[原始文本] --> Parser{是否含title标签?}
Parser -->|Yes| Extract[提取内容与属性]
Extract --> Sanitize[HTML实体编码]
Sanitize --> Build[构建安全DOM节点]
Build --> Output[渲染至页面]
Parser -->|No| Output
这条“安全链条”必须贯穿始终,哪怕只是一个小小的 [info] 提示框也不能例外。
DOM 渲染:从数据到可视界面的最后一跃
终于到了最激动人心的环节——把数据变成真正的 UI!
createElement vs innerHTML:安全与效率的权衡
创建 DOM 节点有两种主流方式:
| 方法 | 优点 | 缺点 |
|---|---|---|
createElement() | 安全、可控、利于事件绑定 | 写法冗长 |
innerHTML | 简洁、高效 | 易受 XSS 影响 |
对于静态内容,我们当然选前者:
function createTitleElement({ level, content }) {
const tag = `h${clampLevel(level)}`;
const element = document.createElement(tag);
element.textContent = content; // 自动防XSS
element.className = `cw-title cw-title-lv${level}`;
element.setAttribute('role', 'heading');
element.setAttribute('aria-level', level);
return element;
}
注意到我们用了 textContent 而不是 innerHTML ,双重保险!
如果是多个标题一起插入,为了避免频繁重排,可以用 DocumentFragment 批量操作:
const fragment = document.createDocumentFragment();
titles.forEach(title => {
fragment.appendChild(createTitleElement(title));
});
container.appendChild(fragment); // 单次 reflow
性能直接拉满 ⚡️
CSS 类名动态分配:打造一致的视觉语言
样式方面,我们采用 BEM 规范:
.cw-title {
margin: 1.5em 0 0.8em;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
line-height: 1.3;
color: #2c3e50;
}
.cw-title-lv1 { font-size: 2rem; font-weight: 700; }
.cw-title-lv2 { font-size: 1.75rem; font-weight: 600; }
/* ... */
然后动态绑定:
element.className = `cw-title cw-title-lv${level}`;
再加上响应式断点:
@media (max-width: 768px) {
.cw-title-lv1 { font-size: 1.6rem; }
.cw-title-lv2 { font-size: 1.4rem; }
}
确保手机上看也不挤。
可访问性:别忘了屏幕背后的用户
WCAG 规定:所有标题都必须正确暴露给辅助技术。
所以我们加上:
element.setAttribute('role', 'heading');
element.setAttribute('aria-level', level);
这样,屏幕阅读器就知道:“这是一个二级标题”,而不是“一段加粗的文字”。
还可以加个 tabindex="-1" ,让用户能用键盘跳转到特定标题:
element.tabIndex = -1;
配合 JS 实现“点击标题聚焦”功能,体验更完整。
实战演练:一个完整的渲染闭环
理论讲完,来点真家伙!
测试数据 & 预期输出
输入:
[title level=1]产品发布计划[/title]
[title level=2]发布时间线[/title]
[title]核心功能亮点[/title]
[title level=3]兼容性说明[/title]
期望生成:
<h1 class="cw-title cw-title-lv1" role="heading" aria-level="1">产品发布计划</h1>
<h2 class="cw-title cw-title-lv2" role="heading" aria-level="2">发布时间线</h2>
<h1 class="cw-title cw-title-lv1" role="heading" aria-level="1">核心功能亮点</h1>
<h3 class="cw-title cw-title-lv3" role="heading" aria-level="3">兼容性说明</h3>
控制台调试一把梭
主函数长这样:
function renderTitles(inputElement, outputContainer) {
const text = inputElement.value;
const titles = [];
const regex = /\[title(?:\s+level=(\d))?\](.*?)\[\/title\]/gi;
let match;
while ((match = regex.exec(text)) !== null) {
const level = clampLevel(match[1]);
const content = escapeHtml(match[2].trim());
titles.push({ level, content });
}
console.log('Parsed titles:', titles);
const fragment = document.createDocumentFragment();
titles.forEach(title => {
fragment.appendChild(createTitleElement(title));
});
outputContainer.innerHTML = '';
outputContainer.appendChild(fragment);
}
HTML 绑定:
<textarea id="source"></textarea>
<div id="preview"></div>
<button onclick="renderTitles(source, preview)">预览</button>
打开控制台, console.log 一看,数据对了;DOM 结构一查,完美匹配。✅
性能优化:告别卡顿
高频输入怎么办?别急,加个防抖:
let timeoutId;
source.addEventListener('input', () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => renderTitles(source, preview), 300);
});
300ms 内不再输入才触发渲染,丝般顺滑。
用 Chrome Performance Tab 录一下,帧率稳稳 60fps,无明显卡顿。🎯
[info] 标签:不只是个提示框
如果说 [title] 是骨架,那 [info] 就是血肉。
它专门用来展示通知、警告、提示这类“辅助信息”,避免它们淹没在正文里。
三种类型,三种情绪
我们定义了三种基本类型:
| 类型 | 场景 | 视觉 | 情绪 |
|---|---|---|---|
notice | 系统提醒 | 蓝色 + 铃铛 | 中性关注 |
warning | 操作风险 | 黄色 + 警告三角 | 警惕 |
tip | 使用技巧 | 绿色 + 灯泡 | 启发 |
[info type="warning"]请勿在生产环境执行该脚本![/info]
会被渲染成一个醒目的黄色警告框,一眼就能看到。
图标映射:视觉锚点的力量
图标我们用 SVG 内联:
const ICON_MAP = {
notice: '<svg>...</svg>',
warning: '<svg>...</svg>',
tip: '<svg>...</svg>'
};
好处是:
- 无需外链字体;
- 高清缩放;
- 颜色随主题变化(用 currentColor )。
graph TD
A[info标签输入] --> B{解析type属性}
B --> C[type=notice]
B --> D[type=warning]
B --> E[type=tip]
C --> F[蓝色+铃铛图标]
D --> G[黄色+警告图标]
E --> H[绿色+灯泡图标]
F --> I[渲染最终DOM]
G --> I
H --> I
数据建模:InfoBlock 对象登场
我们封装了一个 InfoBlock 类:
class InfoBlock {
constructor(rawContent, options = {}) {
this.raw = rawContent.trim();
this.type = options.type || 'notice';
this.collapsible = !!options.collapsible;
this.expanded = true;
this.id = 'info-' + Math.random().toString(36).substr(2, 9);
}
validate() { /* 类型校验 */ }
toHTML() { /* 生成HTML */ }
}
数据与视图分离,复用性大大提升。
全局配置:企业级定制能力
还支持全局配置:
ChatWorkPreview.configure({
info: {
defaultType: 'tip',
collapsible: true
}
});
内部部署时,统一设为“默认可折叠”,减少干扰。
classDiagram
class InfoBlock {
+String raw
+String type
+Boolean collapsible
+Boolean expanded
+String id
+validate() Boolean
+toHTML() String
}
class INFO_CONFIG {
<<enumeration>>
+String DEFAULT_TYPE
+Array ALLOWED_TYPES
+Boolean COLLAPSIBLE_BY_DEFAULT
}
InfoBlock --> INFO_CONFIG : references
整体流程:模拟 AST 构建与 DOM 注入
整个解析流程其实是 微型编译器 的简化版:
- 词法分析 :用正则切分标签;
- 语法分析 :构建类 AST 结构;
- 代码生成 :渲染成 DOM。
function parseChatText(input) {
const lines = input.split('\n');
const ast = [];
for (let line of lines) {
const match = /\[(\/?)(\w+)(?:=(\w+))?\]/.exec(line.trim());
if (match) {
const [, isClose, tagName, attr] = match;
const content = line.replace(/\[.*?\]/g, '').trim();
if (!isClose) {
ast.push({ type: tagName, props: { type: attr }, children: [content] });
}
} else {
ast.push({ type: 'text', value: line });
}
}
return ast;
}
虽然还没完全支持嵌套,但思路已经成型。未来加个栈结构,就能处理 [info][title][/title][/info] 这种复杂情况了。
其他细节:hr、emoji 回退、部署实践
[hr] 分割线:简洁有力
.cw-hr {
height: 1px;
background: #ddd;
margin: 1rem auto;
width: 90%;
max-width: 600px;
}
支持修饰类: [hr=dashed] → .cw-hr--dashed 。
emoji 回退:兼容老旧设备
检测是否支持 emoji:
function supportsEmoji() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.fillText('😀', 0, 0);
const imageData = ctx.getImageData(0, 0, 10, 10).data;
return imageData.some(channel => channel !== 0);
}
不支持?那就替换:
const emojiFallbackMap = {
'😊': '(微笑)',
'🚀': '(火箭)',
'🔥': '(火焰)'
};
用户体验不打折。
GitHub Pages 部署:一键上线
目录结构清晰:
/chatwork-preview/
├── index.html
├── css/style.css
├── js/preview.js
└── dist/bundled.js
构建脚本:
"scripts": {
"build": "cat js/*.js > dist/bundled.js",
"deploy": "git subtree push --prefix dist origin gh-pages"
}
推送到 gh-pages 分支,访问 https://<user>.github.io/chatwork-preview/ ,搞定!
最后的话
chatwork-preview 看似简单,实则融合了 语法设计、正则工程、安全防护、DOM 优化、可访问性、响应式布局 等多个前端核心技能点。
它告诉我们: 不需要 React/Vue,也能做出专业级的交互体验 。关键在于——用对的方法,解决对的问题。
这种高度集成的设计思路,正引领着轻量级工具向更可靠、更高效的方向演进。🌟
简介:chatwork-preview是一款基于JavaScript开发的轻量级聊天工作预览工具,支持在不进入完整聊天界面的情况下快速浏览结构化聊天内容。该工具通过解析特定标签如[title]、[info]和[hr],实现主题突出、信息分层与内容分隔,提升阅读效率。尽管暂不支持表情符号渲染,但其简洁高效的预览机制特别适用于任务描述、会议纪要等场景。项目可通过GitHub Pages(chatwork-preview-gh-pages)在线部署访问,具备良好的可测试性与前端兼容性,是前端动态内容解析与轻量工具设计的典型实践案例。

被折叠的 条评论
为什么被折叠?



