前言
你有没有遇到过这种情况:在 Markdown 中写 **加粗文本**,结果并没有被加粗?特别是在中文环境下,比如:
……那猹却将身一扭,反从他的胯下逃走了。**这少年便是闰土。**我认识他时,也不过十多岁……你会发现 "这少年便是闰土。" 并没有被渲染成粗体!
一、问题复现
1.1 失效的场景
# ❌ 加粗失效
这是**加粗**。这是中文句号。
# ❌ 加粗失效
这是**加粗**,这是逗号
# ❌ 加粗失效
这是**加粗**!这是感叹号
# ✅ 加粗生效(后面有空格)
这是**加粗** 后面有空格
# ✅ 加粗生效(后面是英文)
This is **bold** text.规律:当 ** 后面紧跟中文标点符号时,加粗会失效。
二、原因分析
2.1 CommonMark 规范
Markdown 使用 * 和 _ 作为强调指示符:
- 被单个
*或_包裹的文本 → 斜体(<em>) - 被两个
*或_包裹的文本 → 粗体(<strong>)
但是,何时被识别为强调,有一套复杂的规则定义在 CommonMark 规范 中。
2.2 核心概念:定界符序列(Delimiter Run)
定界符类指的是:
- 一个或一串非转义的
* - 一个或一串非转义的
_
左侧定界符序列(left-flanking)
一个定界符序列需要同时满足:
- 后面不能是空白
- 当前面没有空白或标点符号时,后面不能是标点符号
右侧定界符序列(right-flanking)
一个定界符序列需要同时满足:
- 前面不能是空白
- 当后面没有空白或标点符号时,前面不能是标点符号
2.3 问题根源
以 **这少年便是闰土。**我认识 为例:
……闰土。**我认识
^^
这里是两个星号
前面字符:。
后面字符:我判断是否为右侧定界符序列:
- ✅ 前面不是空白(是
。) - ❌ 后面不是空白或标点符号(是
我) - ❌ 前面是标点符号(
。)
结论:** 不满足右侧定界符序列的条件,不会被识别为结束粗体的标识符。
2.4 为什么要这样定义?
为了支持嵌套分隔符,例如:
**one **two two **three** two two** one**应该渲染为:
- 粗体开始
- one
- 粗体开始
- two two
- 粗体结束
- three
- 粗体开始
- two two
- 粗体结束
- one
- 粗体结束
如果规则不这么严格,就无法正确解析嵌套结构。
代价:使用非空格分词语言(如中文)的用户只能默默接受……
三、解决方案
方案1:手动加空格(不推荐)
这是**加粗** 。(在标点前加空格)方案2:使用零宽空格(推荐)⭐
零宽空格(Zero-Width Space, ZWSP) 是一种不可打印的 Unicode 字符,编码为 U+200B。
它的特殊之处:
- 肉眼看不见:显示时不会占用任何空间
- 不是 Unicode 空格字符:不属于
Zs类(分隔符-空格),而是属于Cf类(其他-格式) - 可以触发换行:只在需要换行时生效
验证:ZWSP 不是空格
console.log(' '.charCodeAt(0)); // 32 (普通空格)
console.log('\u200b'.charCodeAt(0)); // 8203 (ZWSP)
console.log(/\s/.test(' ')); // true
console.log(/\s/.test('\u200b')); // false
// Java 中也是如此
System.out.println(Character.isWhitespace(' ')); // true
System.out.println(Character.isWhitespace('\u200b')); // falseUnicode 类别
Unicode 字符按类别区分,主要大类用第一个字母表示,小类用第二个字母表示:
Zs 类(分隔符-空格 Separator, Space):17 个字符,包括:
- 普通空格
U+0020 - 不换行空格
U+00A0 - 其他 15 个空格字符
Cf 类(其他-格式 Other, Format):包括:
- 零宽空格 ZWSP
U+200B⭐(本文的主角) - 零宽非连接符 ZWNJ
U+200C - 零宽连接符 ZWJ
U+200D - 其他格式字符
关键点:ZWSP 属于 Cf 类,不属于 Zs 类,因此它不是 Unicode 定义的"空格字符"。这也是它能够绕过 CommonMark 规则限制的原因。
方案3:使用下划线(部分场景)
这是__加粗__。这是中文句号。__ 同样遵循 CommonMark 规范,也会遇到同样的问题。
四、代码实现
4.1 基础解决方案
/**
* 在加粗标记后的中文标点前插入零宽空格
*/
function fixChineseBold(content) {
return content.replace(
/\*\*([^*]+)\*\*([,。!?;:])/g,
'**$1**​$2'
)
}
// 使用
const markdown = '这是**加粗**。这是句号。'
const fixed = fixChineseBold(markdown)
// 输出:这是**加粗**​。这是句号。
// 渲染后:这是**加粗**。这是句号。(加粗生效)4.2 uni-app 项目实战
在我的花卷面试题项目中,就遇到了这样的问题;
核心实现
/**
* 为流式输出的 Markdown 内容添加零宽字符
* 避免渲染引擎在流式输出时误解析不完整的 Markdown 语法
*
* @param content Markdown 内容
* @returns 处理后的内容
*/
export function processStreamMarkdown(content: string): string {
if (!content) {
return content
}
let result = ''
let i = 0
while (i < content.length) {
// 1. 检测粗斜体 ***text***
if (i + 5 < content.length && content.substring(i, i + 3) === '***') {
const endPos = content.indexOf('***', i + 3)
if (endPos !== -1) {
const innerText = content.substring(i + 3, endPos)
if (innerText.length > 0) {
// 插入零宽空格:***\u200Btext\u200B***
result += '***\u200B' + innerText + '\u200B***'
i = endPos + 3
continue
}
}
}
// 2. 检测加粗 **text**
if (i + 3 < content.length && content.substring(i, i + 2) === '**') {
const endPos = content.indexOf('**', i + 2)
if (endPos !== -1 && content.substring(endPos + 2, endPos + 3) !== '*') {
const innerText = content.substring(i + 2, endPos)
if (innerText.length > 0 && !innerText.includes('*')) {
// 插入零宽空格:**\u200Btext\u200B**
result += '**\u200B' + innerText + '\u200B**'
i = endPos + 2
continue
}
}
}
// 3-6. 处理 __text__、___text___、*text*、_text_(类似逻辑)
// ...
// 普通字符,直接添加
result += content[i]
i++
}
return result
}组件中使用
<template>
<view class="markdown-view">
<mp-html
:markdown="true"
:content="contentAi"
:tag-style="tagStyle"
/>
</view>
</template>
<script>
import { processStreamMarkdown } from '@/utils/markdownHelpers'
export default {
computed: {
contentAi() {
if (!this.content) {
return
}
let htmlString = this.content
// 流式模式下,为常见的 Markdown 语法标记添加零宽字符
if (this.streamMode) {
htmlString = processStreamMarkdown(htmlString)
}
return htmlString
}
}
}
</script>4.3 通用解决方案
/**
* 通用的 Markdown 加粗修复函数
* 支持中文标点和多种场景
*/
function fixMarkdownBold(markdown) {
const patterns = [
// **text**后跟中文标点
[/\*\*([^*]+?)\*\*([,。!?;:、])/g, '**$1**​$2'],
// __text__后跟中文标点
[/__([^_]+?)__([,。!?;:、])/g, '__$1__​$2'],
// *text*后跟中文标点
[/\*([^*]+?)\*([,。!?;:、])/g, '*$1*​$2'],
// _text_后跟中文标点
[/ _([^_]+?)_ ([,。!?;:、])/g, '_$1_​$2'],
// 前面有中文标点的情况
[/([,。!?;:、])\*\*([^*]+?)\*\*/g, '$1**​$2**'],
[/([,。!?;:、])__([^_]+?)__/g, '$1__​$2__'],
]
let result = markdown
patterns.forEach(([pattern, replacement]) => {
result = result.replace(pattern, replacement)
})
return result
}五、原理深入
5.1 为什么 ZWSP 能解决问题?
回到 CommonMark 规则:
右侧定界符序列的条件:
- 前面不能是空白
- 当后面没有空白或标点符号时,前面不能是标点符号
插入 ZWSP 后:
……闰土。**•我认识
^^
这里插入 ZWSP
前面字符:• (ZWSP,不是标点)
后面字符:我判断:
- ✅ 前面不是空白(是 ZWSP)
- ✅ 后面不是空白或标点符号(是
我) - ✅ 前面不是标点符号(ZWSP 不是标点)
结论:** 满足右侧定界符序列的条件,可以被识别为结束粗体的标识符。
5.2 ZWSP 的其他用途
// 1. 控制长文本换行
const longText = 'LongLongLongWord\u200BBreakBeforeHereLongWord'
// 当容器宽度不足时,会在 ZWSP 处换行
// 2. 防止邮箱被采集
const email = 'contact\u200B@example.com'
// 显示为:contact@example.com
// 复制后:contact@example.com(但某些工具会看到 ZWSP)
// 3. URL 中隐藏信息(不推荐)
const url = 'https://example.com\u200B/path'
// 4. 绕过敏感词检查(某些场景)
// 各位后端大佬们注意了!实现功能时要考虑这种情况5.3 其他零宽字符
| 字符 | 编码 | 名称 | 用途 |
|---|---|---|---|
| 零宽空格 | U+200B | ZWSP | 可选换行点 |
| 零宽非连接符 | U+200C | ZWNJ | 阻止连字 |
| 零宽连接符 | U+200D | ZWJ | 强制连字 |
| 词分隔符 | U+2060 | WJ | 阻止换行 |
// 零宽非连接符示例(阿拉伯语)
// 某些语言需要字符连接显示,ZWNJ 可以阻止连接
const arabic = 'لا\u200Cفت' // لا + ZWNJ + فت
// 零宽连接符示例(emoji 组合)
const family = '👨\u200D👩\u200D👧\u200D👦'
// 显示为:👨•👩•👧•👦(家庭 emoji)六、最佳实践
6.1 何时使用零宽空格?
tabs
- AI 流式输出场景
- 用户输入的 Markdown
- 需要完美格式的地方
- 中文内容为主/tab
- 代码搜索场景(可能影响搜索)
- 需要复制粘贴的地方
- 数据库存储(可能影响索引)
- SEO 优化/tab
- 纯英文内容(不需要)
- 已经有空格的地方
- 代码块中的内容/tab
6.2 性能考虑
// ❌ 不推荐:每次都替换
function renderMarkdown(markdown) {
return marked(fixMarkdownBold(markdown))
}
// ✅ 推荐:缓存处理结果
const cache = new Map()
function renderMarkdownCached(markdown) {
if (cache.has(markdown)) {
return cache.get(markdown)
}
const result = marked(fixMarkdownBold(markdown))
cache.set(markdown, result)
return result
}6.3 流式输出场景
// AI 回答流式输出
async function streamAIResponse(prompt) {
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ prompt })
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
let markdown = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value)
markdown += chunk
// 渲染时处理零宽字符
const html = marked(processStreamMarkdown(markdown))
updateUI(html)
}
}6.4 Vue/React 组件封装
<!-- MarkdownRender.vue -->
<template>
<div ref="container" v-html="renderedHtml"></div>
</template>
<script>
import { marked } from 'marked'
import { processStreamMarkdown } from '@/utils/markdownHelpers'
export default {
props: {
markdown: String,
streamMode: {
type: Boolean,
default: false
}
},
computed: {
renderedHtml() {
if (!this.markdown) return ''
const processed = this.streamMode
? processStreamMarkdown(this.markdown)
: this.markdown
return marked(processed)
}
}
}
</script>
<!-- 使用 -->
<MarkdownRender
:markdown="aiResponse"
:stream-mode="true"
/>七、工具推荐
7.1 在线工具
- Unicode 表:复制零宽字符
- CommonMark 规范:查看详细规则
- Markdown 在线测试:测试 Markdown 渲染
7.2 VS Code 插件
// settings.json
{
"markdown.preview.breaks": true,
"markdown.preview.fontFamily": "Consolas, 'Microsoft YaHei'",
"markdown.extension.orderedList.autoRenumber": true
}7.3 npm 包
# 安装 marked(Markdown 解析器)
npm install marked
# 安装 markdown-it(更强大的解析器)
npm install markdown-it// 使用 markdown-it
import MarkdownIt from 'markdown-it'
import { fixMarkdownBold } from './markdown-utils'
const md = new MarkdownIt()
function render(markdown) {
return md.render(fixMarkdownBold(markdown))
}八、常见问题
Q1:零宽空格会影响复制粘贴吗?
A:可能会有影响。取决于复制的工具:
- 浏览器复制:通常会保留 ZWSP
- 某些编辑器:会过滤 ZWSP
- 终端粘贴:可能会显示为特殊字符
解决方案:
// 复制时清除零宽字符
function copyToClipboard(text) {
const cleanText = text.replace(/[\u200B-\u200D\uFEFF]/g, '')
navigator.clipboard.writeText(cleanText)
}Q2:零宽空格会影响搜索吗?
A:可能影响。取决于搜索引擎:
- 前端搜索:可以正常搜索
- 数据库 LIKE:可能受影响
- Elasticsearch:可能需要特殊配置
解决方案:
// 搜索时清除零宽字符
function search(keyword, content) {
const cleanKeyword = keyword.replace(/[\u200B-\u200D\uFEFF]/g, '')
const cleanContent = content.replace(/[\u200B-\u200D\uFEFF]/g, '')
return cleanContent.includes(cleanKeyword)
}Q3:有没有更简单的解决方案?
A:有!使用支持中文的 Markdown 解析器:
// 使用 marked.js + 自定义扩展
import { marked } from 'marked'
import { mangle } from 'marked-mangle'
const renderer = new marked.Renderer()
// 自定义加粗渲染
marked.setOptions({
renderer: renderer,
breaks: true,
gfm: true
})
// 或者使用支持中文的解析器
// 但主流解析器都遵循 CommonMark 规范九、总结
tabs
- CommonMark 规范的定界符序列规则
- 中文是"非空格分词语言"
- 规则设计考虑了嵌套结构,牺牲了中文体验/tab
- ✅ 使用零宽空格(U+200B)
- ✅ 在合适的场景下自动处理
- ✅ 注意对复制、搜索的影响
- ❌ 不建议手动加空格(破坏格式)/tab
- AI 流式输出场景必须处理
- 使用成熟的工具函数
- 考虑缓存和性能
- 做好降级方案/tab