前言

你有没有遇到过这种情况:在 Markdown 中写 **加粗文本**,结果并没有被加粗?特别是在中文环境下,比如:

……那猹却将身一扭,反从他的胯下逃走了。**这少年便是闰土。**我认识他时,也不过十多岁……

你会发现 "这少年便是闰土。" 并没有被渲染成粗体!

先说结论:这不是 bug,这是 CommonMark 规范的规定!是由于自然语言之间的差异导致的。

一、问题复现

1.1 失效的场景

# ❌ 加粗失效
这是**加粗**。这是中文句号。
​
# ❌ 加粗失效
这是**加粗**,这是逗号
​
# ❌ 加粗失效
这是**加粗**!这是感叹号
​
# ✅ 加粗生效(后面有空格)
这是**加粗** 后面有空格
​
# ✅ 加粗生效(后面是英文)
This is **bold** text.

规律:当 ** 后面紧跟中文标点符号时,加粗会失效。

二、原因分析

2.1 CommonMark 规范

Markdown 使用 *_ 作为强调指示符:

  • 单个 *_ 包裹的文本 → 斜体(<em>
  • 两个 *_ 包裹的文本 → 粗体(<strong>

但是,何时被识别为强调,有一套复杂的规则定义在 CommonMark 规范 中。

2.2 核心概念:定界符序列(Delimiter Run)

定界符类指的是:

  • 一个或一串非转义的 *
  • 一个或一串非转义的 _

左侧定界符序列(left-flanking)

一个定界符序列需要同时满足:

  1. 后面不能是空白
  2. 当前面没有空白或标点符号时,后面不能是标点符号

右侧定界符序列(right-flanking)

一个定界符序列需要同时满足:

  1. 前面不能是空白
  2. 当后面没有空白或标点符号时,前面不能是标点符号

2.3 问题根源

**这少年便是闰土。**我认识 为例:

……闰土。**我认识
         ^^
         这里是两个星号
​
前面字符:。
后面字符:我

判断是否为右侧定界符序列

  1. ✅ 前面不是空白(是
  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

它的特殊之处:

  1. 肉眼看不见:显示时不会占用任何空间
  2. 不是 Unicode 空格字符:不属于 Zs 类(分隔符-空格),而是属于 Cf 类(其他-格式)
  3. 可以触发换行:只在需要换行时生效

验证: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')); // false

Unicode 类别

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**&#8203;$2'
  )
}
​
// 使用
const markdown = '这是**加粗**。这是句号。'
const fixed = fixChineseBold(markdown)
// 输出:这是**加粗**&#8203;。这是句号。
// 渲染后:这是**加粗**。这是句号。(加粗生效)

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**&#8203;$2'],
​
    // __text__后跟中文标点
    [/__([^_]+?)__([,。!?;:、])/g, '__$1__&#8203;$2'],
​
    // *text*后跟中文标点
    [/\*([^*]+?)\*([,。!?;:、])/g, '*$1*&#8203;$2'],
​
    // _text_后跟中文标点
    [/ _([^_]+?)_ ([,。!?;:、])/g, '_$1_&#8203;$2'],
​
    // 前面有中文标点的情况
    [/([,。!?;:、])\*\*([^*]+?)\*\*/g, '$1**&#8203;$2**'],
    [/([,。!?;:、])__([^_]+?)__/g, '$1__&#8203;$2__'],
  ]
​
  let result = markdown
  patterns.forEach(([pattern, replacement]) => {
    result = result.replace(pattern, replacement)
  })
​
  return result
}

五、原理深入

5.1 为什么 ZWSP 能解决问题?

回到 CommonMark 规则:

右侧定界符序列的条件

  1. 前面不能是空白
  2. 当后面没有空白或标点符号时,前面不能是标点符号

插入 ZWSP 后:

……闰土。**•我认识
          ^^
          这里插入 ZWSP
​
前面字符:• (ZWSP,不是标点)
后面字符:我

判断

  1. ✅ 前面不是空白(是 ZWSP)
  2. ✅ 后面不是空白或标点符号(是
  3. ✅ 前面不是标点符号(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+200BZWSP可选换行点
零宽非连接符U+200CZWNJ阻止连字
零宽连接符U+200DZWJ强制连字
词分隔符U+2060WJ阻止换行
// 零宽非连接符示例(阿拉伯语)
// 某些语言需要字符连接显示,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 在线工具

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

核心要点:零宽空格是解决 Markdown 加粗失效的利器,但要在合适的场景使用,并注意其对复制、搜索的影响。

十、参考资源

最后修改:2026 年 01 月 26 日
如果觉得我的文章对你有用,请随意赞赏