模板里常常有一些「有数据才要」的行——补充说明、备注、可选的抄送人。数据没有时,理想的结果是这一行连同它占的位置一起消失,而不是留下一段空白把版面撑开。
另一个常见问题是模板的核查:当拿到一份他人提供的模板时,如何确定其中包含哪些合并域,以及这些域被划分成了哪几个区域?逐个展开域代码清点并不现实。
Spire.Doc for JavaScript 把这两件事都放在浏览器端完成:空段落与空区域由合并过程本身删掉,区域名与合并域名则直接从 MailMerge 上读出来。它基于 WebAssembly 运行,用虚拟文件系统(VFS)管理模板文档,不需要任何后端服务。
本文介绍两个核心功能点:
有关安装和项目配置,请参考 React 项目中集成 Spire.Doc for JavaScript 的方法。以下示例默认已安装 Spire.Doc 并完成 WebAssembly 模块初始化。
合并后自动隐藏空段落与空区域
准备模板
模板在普通联络单的基础上,加进了三种「可能为空」的写法:
To: «Contact Name»
From: 客户服务部
Subject: 需求确认函
Fax.: «Fax» Date: «Date»
«Optional Note»
«GroupStart:OptionalBlock»
«Optional Line 1»
«Optional Line 2»
«GroupEnd:OptionalBlock»
补充说明:«Optional Remark»
Message:
尊敬的 «Contact Name»:
您好,感谢贵司的回复。我们会在两个工作日内与您联系,确认具体的交付安排。
此致
敬礼
客户服务部
三处的写法不同,正好覆盖三种情况:
«Optional Note»独占一整段 —— 合并后段内一个字符都不剩;- 区域
OptionalBlock—— 由«GroupStart:OptionalBlock»与«GroupEnd:OptionalBlock»两句围起来,中间两段也都只有一个域。区域标记在 Word 里同样是MERGEFIELD域,只是域名写成GroupStart:区域名/GroupEnd:区域名,并且不带引号; 补充说明:«Optional Remark»—— 域和标签文字在同一段,合并后这一行还剩「补充说明:」四个字。
代码
function App() {
const HideEmptyRegions = async () => {
const docModule = window.wasmModule?.spiredoc;
if (!docModule) {
alert('Spire.Doc is not ready yet');
return;
}
// 将示例模板载入虚拟文件系统(VFS)
let inputFileName = "HideEmptyRegions.docx";
await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}static/data/`);
// 新建文档并载入模板
let doc = new docModule.Document();
doc.LoadFromFile(inputFileName);
// 只提供三个域的值,模板里的可选域留给空
let fieldNames = ["Contact Name", "Fax", "Date"];
let fieldValues = ["John Smith", "+1 (69) 123456", docModule.DateTime.get_Now().Date.ToString()];
// 合并后隐藏变空的段落与区域
doc.MailMerge.HideEmptyParagraphs = true;
doc.MailMerge.HideEmptyGroup = true;
// 执行邮件合并
doc.MailMerge.Execute(fieldNames, fieldValues);
// 定义输出文件名
const outputFileName = "HideEmptyRegions-result.docx";
// 保存文档
doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });
doc.Dispose()
// 读取保存的文件并转换为 Blob 对象
const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([modifiedFileArray], {type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>合并数据并隐藏空白区域</h1>
<button onClick={HideEmptyRegions}>开始</button>
</div>
);
}
export default App;
关键就是 Execute 之前的两行:
document.MailMerge.HideEmptyParagraphs = true;
document.MailMerge.HideEmptyGroup = true;
它们都是 MailMerge 上的布尔属性,默认值都是 false,必须显式打开。注意属性要在 Execute 之前设置——隐藏动作发生在合并过程中,合并完再改就没有意义了。
fieldNames 里只列了三个「主」域,Optional Note、Optional Line 1/2、Optional Remark 都没有提供值,因此合并后全部为空。
效果
关掉两个开关(对照组)与打开两个开关的输出,按段落逐条对比:
| 模板里的内容 | 两个开关都关 | 打开两个开关 |
|---|---|---|
«Optional Note»(独占一段) |
保留成空行 | 整段消失 |
区域 OptionalBlock(标记段 + 两个域段 + 结束段) |
保留成 4 个空行 | 四段一起消失 |
补充说明:«Optional Remark» |
补充说明: |
补充说明:(保留) |
打开的版本,正文只剩这些段落:
To: John Smith
From: 客户服务部
Subject: 需求确认函
Fax.: +1 (69) 123456 Date: 2026/10/10 上午12:00:00
补充说明:
Message:
尊敬的 John Smith:
您好,感谢贵司的回复。我们会在两个工作日内与您联系,确认具体的交付安排。
此致
敬礼
客户服务部
两条值得记住的规律:
- 判定依据是「合并后这一段还剩不剩文字」,而不是「域有没有取到值」。 第三种写法里
Optional Remark同样是空的,但因为同一段里还有「补充说明:」四个字,整行就会被保留下来,只是域的位置变成空白。要有条件地保留标签本身,就得把标签也做成域的值。 HideEmptyParagraphs才是真正执行删除的那个属性。 单独把HideEmptyGroup设为true、而保持HideEmptyParagraphs = false,实测输出与两个开关都不打开完全一致——区域照样留下四个空行。只有两者同时打开,整个区域才会随其内部段落一起消失。所以如果只想让空行消失、不打算使用区域,打开HideEmptyParagraphs就够了。

读取合并区域名与合并域名清单
思路
MailMerge 上有三个读取用的方法,分别对应不同的粒度:
| 方法 | 返回内容 |
|---|---|
GetMergeGroupNames() |
文档里所有区域名 |
GetMergeFieldNames({ groupName: "…" }) |
指定区域内部的合并域名 |
GetMergeFieldNames() |
文档里全部合并域名 |
第二个方法把区域名放在一个对象里当命名参数传入。这是 WebAssembly 版对重载方法的处理方式——同名方法按参数个数与参数名分派,所以要用 { groupName: "Products" } 这种带键名的写法,而不是直接传一个字符串。
准备模板
模板里放了两个区域,各含若干域:
«GroupStart:Products»
Name: «Contact Name»
Number: «Number»
Type: «Type»
Price: «Price»
Date: «Date»
Vendor: «Vendor»
«GroupEnd:Products»
«GroupStart:TeamRanking»
Name: «Name»
Score: «Score»
Ranking: «Ranking»
Award: «Award»
«GroupEnd:TeamRanking»
每个域各占一段,区域标记也单独成段。
代码
这个示例读的是文档结构,输出的是文本,所以结果写进一个 TXT 文件:
function App() {
const IdentifyMergeFieldName = async () => {
const docModule = window.wasmModule?.spiredoc;
if (!docModule) {
alert('Spire.Doc is not ready yet');
return;
}
// 将示例模板载入虚拟文件系统(VFS)
let inputFileName = "IdentifyMergeFieldNames.docx";
await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}static/data/`);
// 新建文档并载入模板
let doc = new docModule.Document();
doc.LoadFromFile(inputFileName);
// 读取全部区域名
let GroupNames = doc.MailMerge.GetMergeGroupNames();
// 读取指定区域内部的合并域名
let MergeFieldNamesWithinRegion = doc.MailMerge.GetMergeFieldNames({ groupName: "Products" });
// 读取文档中全部合并域名
let MergeFieldNames = doc.MailMerge.GetMergeFieldNames();
// 把结果拼成文本
let content = [];
content.push("----------------群组名称-----------------------------------------\n");
for (let i = 0; i < GroupNames.length; i++) {
content.push(GroupNames[i] + "\n");
}
content.push("----------------合并特定组内的字段名称-----------\n");
for (let j = 0; j < MergeFieldNamesWithinRegion.length; j++) {
content.push(MergeFieldNamesWithinRegion[j] + "\n");
}
content.push("----------------所有合并字段名称------------------------\n");
for (let k = 0; k < MergeFieldNames.length; k++) {
content.push(MergeFieldNames[k] + "\n");
}
// 写出 TXT 文件
const outputFileName = "IdentifyMergeFieldName-result.txt";
window.dotnetRuntime.Module.FS.writeFile(outputFileName, content.join("\n"));
// 关闭文档
doc.Close();
doc.Dispose()
// 读取保存的文件并转换为 Blob 对象
const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([modifiedFileArray], {type: "text/plain"});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>读取模板中的区域名与合并域名</h1>
<button onClick={IdentifyMergeFieldName}>开始</button>
</div>
);
}
export default App;
与前面几个示例的两点不同:
- 不需要调用
Execute。这里只读取模板的结构,不合并数据,所以载入后直接读三个列表即可。 - 输出走的是
window.dotnetRuntime.Module.FS.writeFile(文件名, 文本),直接把字符串写进虚拟文件系统,再用text/plain类型的 Blob 触发下载。收尾用的是document.Close(),因为这个示例全程没有改动文档内容。
三个方法都返回集合(GroupNames.length、下标访问),遍历方式与 Sections、Paragraphs 一致。
效果
输出文件的结构如下(---…--- 是分隔行):
----------------群组名称-----------------------------------------
Products
TeamRanking
----------------合并特定组内的字段名称-----------
Contact Name
Number
Type
Price
Date
Vendor
----------------所有合并字段名称------------------------
Products
Contact Name
Number
Type
Price
Date
Vendor
Products
TeamRanking
Name
Score
Ranking
Award
TeamRanking
三个列表的结果是:
区域名(2 个)
Products, TeamRanking
区域 Products 内部的域名(6 个)
Contact Name, Number, Type, Price, Date, Vendor
全部域名(14 项)
Products, Contact Name, Number, Type, Price, Date, Vendor, Products,
TeamRanking, Name, Score, Ranking, Award, TeamRanking
有两点需要留意:
- 不带参数的
GetMergeFieldNames()会把区域标记本身也算成域名。 因为GroupStart:Products在文档里就是一个MERGEFIELD域,所以每个区域名都会出现两次——一次是开标记、一次是闭标记。上面的 14 项里,Products和TeamRanking各出现了两次。要拿「真正的域名」,要么用带groupName的版本逐个区域取,要么从全量结果里把区域名过滤掉。 - 带
groupName的版本只返回该区域内部的真实域名,Products区域的 6 个结果里没有出现任何GroupStart:/GroupEnd:标记。
输出里每条之间为什么多一个空行? 拼字符串时
content.push(名字 + "\n")已经在每条末尾带了换行符,最后又用content.join("\n")在元素之间再补一次,于是出现了两个连续的换行。想输出紧凑的清单,把push里的"\n"去掉即可:
content.push(GroupNames[i]);

常见问题
打开了隐藏开关,空行还是留着
原因:多半是那一行里还有别的文字。隐藏的判定标准是「合并后这一段是否为空」,只要段内还剩哪怕一个字符(比如 补充说明: 这样的固定标签),整段就会被保留。
解决:确认要隐藏的那一行除域之外没有其他文字。如果确实需要「标签跟着域一起消失」,把标签也做成域的值一起传入:
let fieldNames = ["Contact Name", "Remark Label", "Remark"];
let fieldValues = ["John Smith", "补充说明:", ""];
单独设置 HideEmptyGroup = true 没有效果
原因:真正执行段落删除的是 HideEmptyParagraphs。区域之所以会整体消失,是因为它内部的段落被逐个删掉了,标签本身不触发额外的删除动作。
解决:两个属性一起打开。示例里之所以两句都写,就是这个原因:
document.MailMerge.HideEmptyParagraphs = true;
document.MailMerge.HideEmptyGroup = true;
隐藏开关应该在 Execute 之前还是之后设置
在 Execute 之前。隐藏空段落/空区域的动作发生在合并过程中,合并完成后再改属性,对已经生成的结果没有任何影响。
GetMergeFieldNames 传字符串参数报错或取不到数据
原因:这是重载方法。WebAssembly 版按参数名来分派,必须用带键名的对象传入区域名。
解决:写成对象形式:
let fields = document.MailMerge.GetMergeFieldNames({ groupName: "Products" });
区域名要与模板里 GroupStart: 后面的名字完全一致(区分大小写)。
GetMergeFieldNames() 的结果里混进了区域名
原因:区域的开标记与闭标记本身都是合并域,因此会被计进全量清单,每个区域名会出现两次。
解决:需要过滤时,把 GetMergeGroupNames() 的结果收集成集合再去掉:
let groups = document.MailMerge.GetMergeGroupNames();
let groupSet = {};
for (let i = 0; i < groups.length; i++) groupSet[groups[i]] = true;
let names = document.MailMerge.GetMergeFieldNames();
let realFields = [];
for (let i = 0; i < names.length; i++) {
if (!groupSet[names[i]]) realFields.push(names[i]);
}
如果只需要某一个区域内的域,直接用带 groupName 的版本更省事,它的结果里本来就不含标记。
文档里定义了区域,但 GetMergeGroupNames() 返回 0 个
原因:区域标记必须写成 MERGEFIELD GroupStart:区域名 的形式,域名不带引号。如果写成了 MERGEFIELD "GroupStart:Products"(带引号),会被当成一个普通的合并域名,不构成区域。
解决:检查模板里这两句的域代码:
{ MERGEFIELD GroupStart:Products \* MERGEFORMAT }
{ MERGEFIELD GroupEnd:Products \* MERGEFORMAT }
另外,GroupStart 与 GroupEnd 必须成对出现,区域名要写全、大小写一致,缺少配对的一端不会被识别为区域。
输出的 TXT 用某些编辑器打开是一整行
原因:这个示例写的是纯文本,如果后续要保存成 .txt 交给 Windows 记事本,需要留意换行符;Spire 写出的文本用的是 \n。
解决:用 VS Code、Notepad++ 等编辑器打开,或按需在拼接时把 "\n" 换成 "\r\n"。
获取免费许可证
如果您希望删除结果文档中的评估消息,或者摆脱功能限制,请该Email地址已收到反垃圾邮件插件保护。要显示它您需要在浏览器中启用JavaScript。获取有效期 30 天的临时许可证。









