1. 为什么“替换Word占位符”这件事远比想象中麻烦做过Java后端的人几乎都绕不开一个需求生成一份格式规范的Word文档。合同、通知书、报表、简历、项目验收单业务方给过来的永远是一个排版精美的.docx模板里面写着${userName}、{{date}}、#amount#这类占位符然后要求程序把数据填进去输出一份“看起来跟手工做的一模一样”的文件。第一次接到这种需求很多人的第一反应是不就是字符串替换吗把docx当zip解开找到document.xml读出来replace一下再塞回去收工。我当年也是这么想的直到被现实按在地上摩擦了好几轮。问题在于Word文档的底层结构根本不是“一段连续的文本”。你在Word里看到的一行${userName}在document.xml里可能被拆成了好几个w:rrun节点中间还夹杂着拼写检查标记、语言属性、字体切换、修订记录。比如${user在一个run里Name}在另一个run里你直接对整个XML字符串做替换根本匹配不到。更坑的是模板里如果有表格、页眉页脚、文本框、批注占位符可能散落在header1.xml、footer1.xml、footnotes.xml等好几个部件里只处理document.xml必然漏掉。所以这个需求的核心难点从来不是“替换”这个动作而是如何在保留原有格式的前提下准确地定位并替换跨节点、跨部件的占位符。这也是为什么社区里反复出现两个名字Apache POI和docx4j。它们代表了两种截然不同的技术路线选错了后面全是坑选对了很多问题会迎刃而解。这篇内容我打算把这两条路线彻底讲透它们各自的底层模型是什么、什么场景该用哪个、跨run占位符到底怎么处理、页眉页脚和表格怎么覆盖、性能上有什么差异以及我在实际项目里踩过的那些“文档能打开但格式全乱”“替换后字体变了”“循环表格行不会复制”的真实问题。如果你正在做或者即将做Word模板填充这篇可以当作一份避坑地图来用。2. 先搞懂docx的“零件结构”否则选型全靠猜2.1 docx本质是一个装着XML的压缩包不管用POI还是docx4j第一步都得建立正确的心理模型一个.docx文件就是一个ZIP包把后缀改成.zip解压你会看到这样的目录结构word/ document.xml // 正文主体 header1.xml // 页眉 footer1.xml // 页脚 footnotes.xml // 脚注 styles.xml // 样式定义 numbering.xml // 编号定义 media/ // 图片资源 [Content_Types].xml正文里你看到的每一个字符都藏在document.xml的w:t标签里而w:t又被w:rrun包裹w:r再往上归w:pparagraph管。run是格式的最小单位——同一段文字里只要字体、字号、颜色、加粗有任何一处不同Word就会把它拆成多个run。这就解释了那个经典现象模板里明明写的是${name}代码里document.xml打开一看却是w:rw:t${na/w:t/w:r w:rw:tme}/w:t/w:r为什么会被拆开因为你在Word里编辑的时候可能中途改过字体或者输入法切换导致Word自动分段甚至只是光标移动过Word就悄悄拆了run。这是Word的正常行为不是bug但对做替换的程序来说就是灾难。2.2 占位符可能藏在哪些“角落”很多人只处理document.xml结果测试时发现正文替换成功了但页眉里的公司名没变、表格里的金额没变、页脚页码旁边的日期没变。原因就是占位符散落在多个部件里。我整理了一张常见位置对照表占位符位置所在XML部件容易漏掉的原因正文段落document.xml基本都会处理页眉header1.xml等常被忽略页脚footer1.xml等常被忽略表格单元格document.xml内跨run问题更严重文本框document.xml内结构特殊遍历易漏脚注/尾注footnotes.xml几乎没人处理批注comments.xml特殊场景才需要提示一个docx里可能有多个header和footer首页不同、奇偶页不同命名是header1.xml、header2.xml……处理时要用通配匹配不能写死。理解了这层结构你就能明白任何“读字符串直接replace”的方案都是不可靠的因为你要面对的是树形结构而不是纯文本。接下来看POI和docx4j分别怎么应对这个树。3. Apache POI的XWPF模型轻量、直接但跨run要自己动手3.1 POI处理docx的核心类Apache POI对.docx的支持集中在XWPF系列类里常用的几个XWPFDocument整个文档对象入口。XWPFParagraph段落。XWPFRun段落里的run真正装文本的地方。XWPFTable/XWPFTableRow/XWPFTableCell表格三件套。XWPFHeader/XWPFFooter页眉页脚。POI的API设计很“Java”直观、上手快。替换正文占位符的基本思路是遍历所有段落遍历段落里所有run拿到文本做替换。但正如前面说的占位符可能跨run所以不能逐个run替换必须先把整段的文本拼起来判断再回填。3.2 跨run占位符的处理合并-替换-回填这是POI方案里最核心的一段逻辑我把它拆成三步讲清楚。第一步把段落里所有run的文本拼成一个完整字符串。用paragraph.getText()就能拿到整段文本它内部会把所有run的w:t拼起来。这一步解决了“占位符被拆开”的定位问题。第二步在完整字符串上做正则匹配找出所有占位符及其位置。比如用\$\{([^}])\}匹配${xxx}格式。第三步把替换后的文本重新写回run。这里有个关键取舍如果占位符恰好落在单个run里直接改那个run的文本最安全格式完全不动如果跨了多个run就得把第一个run的文本设成替换结果然后把后续涉及的run文本清空。public void replaceInParagraph(XWPFParagraph paragraph, MapString, String data) { ListXWPFRun runs paragraph.getRuns(); if (runs null || runs.isEmpty()) return; // 拼接整段文本同时记录每个字符属于哪个run StringBuilder fullText new StringBuilder(); Listint[] runRanges new ArrayList(); // [start, end, runIndex] for (int i 0; i runs.size(); i) { String t runs.get(i).text(); if (t null) t ; int start fullText.length(); fullText.append(t); runRanges.add(new int[]{start, fullText.length(), i}); } String text fullText.toString(); Matcher matcher Pattern.compile(\\$\\{([^}])\\}).matcher(text); if (!matcher.find()) return; // 从后往前替换避免位置偏移 Listint[] matches new ArrayList(); matcher.reset(); while (matcher.find()) { matches.add(new int[]{matcher.start(), matcher.end()}); } for (int i matches.size() - 1; i 0; i--) { int[] m matches.get(i); String key text.substring(m[0] 2, m[1] - 1); String value data.getOrDefault(key, ); // 找到覆盖该区间的run把值写进第一个其余清空 boolean first true; for (int[] range : runRanges) { if (range[1] m[0] || range[0] m[1]) continue; XWPFRun run runs.get(range[2]); if (first) { run.setText(value, 0); first false; } else { run.setText(, 0); } } } }这段代码有几个细节值得说。从后往前替换是因为替换会改变字符串长度如果从前往后后面匹配到的位置就全乱了。run.setText(value, 0)的第二个参数是位置索引传0表示从第一个字符开始覆盖这样能保留run本身的格式属性字体、颜色等只是换了文字内容。3.3 POI方案的边界表格行复制和图片替换是硬伤POI在纯文本替换上够用但遇到两个场景就很吃力。第一个是动态表格行。业务需求经常是“模板里有一行明细数据有N条就复制N行”。POI的XWPFTable没有现成的“复制行”API你得手动new一个XWPFTableRow然后逐个单元格、逐个run去复制样式和内容代码又长又容易丢格式。我见过有人为了复制一行写了上百行代码最后还是字体对不上。第二个是图片占位符。如果模板里${logo}位置要插入一张图片POI需要你手动创建XWPFRun、调用addPicture、处理图片关系ID还要保证图片尺寸和原占位符位置协调。能做但繁琐。所以POI的定位很清晰结构简单、以文本替换为主的场景它是最轻量的选择。依赖小、上手快、不引入额外概念。但一旦涉及复杂表格、图片、内容控件就该考虑docx4j了。4. docx4j的JAXB模型重但能啃硬骨头4.1 docx4j的底层是完整的OOXML对象树docx4j走的是另一条路它用JAXB把整个OOXML规范映射成了Java对象。你操作的WordprocessingMLPackage本质上就是document.xml那棵树的完整Java镜像。每个w:r对应一个R对象每个w:p对应一个P对象层级关系和XML一模一样。这个设计带来的最大好处是你能对文档结构做任何操作包括复制任意节点、深度遍历、修改任意属性。POI做不到的“复制表格行”在docx4j里就是把一个Tr对象深拷贝一份插到原位置后面几行代码搞定。代价是学习曲线陡。你得理解ObjectFactory、JAXBElement、ContentAccessor这些概念还得知道getContent()返回的列表里混着P、Tbl、R各种类型遍历时要instanceof判断。第一次用docx4j的人大概率会被JAXBElement的泛型绕晕。4.2 用docx4j做文本替换的标准姿势docx4j官方提供了一个工具类TextUtils专门用来处理跨run的文本替换这是它比POI省心的地方WordprocessingMLPackage pkg WordprocessingMLPackage.load(new File(template.docx)); MapString, String replacements new HashMap(); replacements.put(userName, 张三); replacements.put(date, 2024-01-01); TextUtils.replaceText(pkg, replacements); pkg.save(new File(output.docx));TextUtils.replaceText内部会自动处理跨run的情况把分散在多个R里的文本合并后再替换。这比POI手写合并逻辑省事得多。但要注意它的替换粒度是整个文档级别如果你的占位符有特殊格式要求比如替换后要保留某种样式可能需要自己扩展。4.3 表格行复制docx4j真正的主场动态表格是docx4j最能体现价值的地方。假设模板里有一个明细表表头下面有一行${itemName}、${qty}、${price}数据有5条需要生成5行。思路是找到表格对象Tbl。定位到模板行Tr通常是表头后的第一行。对每条数据深拷贝一份Tr替换其中的占位符插入到表格中。删除原始模板行。Tbl table findTable(pkg); ListTr rows table.getContent().stream() .filter(o - o instanceof Tr).map(o - (Tr) o) .collect(Collectors.toList()); Tr templateRow rows.get(1); // 假设第0行是表头 int insertIndex table.getContent().indexOf(templateRow); for (int i 0; i dataList.size(); i) { Tr newRow XmlUtils.deepCopy(templateRow); // 在新行里替换占位符 replaceInRow(newRow, dataList.get(i)); table.getContent().add(insertIndex i, newRow); } table.getContent().remove(templateRow);XmlUtils.deepCopy是关键它保证了新行和模板行的结构、样式完全一致。替换占位符时遍历新行里的所有R对象用TextUtils或自己写的逻辑处理即可。这套流程在POI里要费很大劲在docx4j里就是“拷贝-替换-插入”三步。4.4 docx4j的“重”体现在哪里天下没有免费的午餐。docx4j的代价是依赖体积大引入docx4j会带进来一堆JAXB、XML相关依赖打包后jar包明显变大。内存占用高整个文档树都在内存里处理几十MB的大文档时要注意堆内存。API偏底层很多操作要直接面对OOXML的类名比如CTSectPr、CTPPr不熟悉规范的人会懵。版本兼容docx4j不同版本对JDK和JAXB的要求不一样JDK 11以上要额外引入JAXB实现否则启动就报ClassNotFoundException。注意如果你的项目跑在JDK 11用docx4j前务必确认JAXB依赖是否齐全这是新手最容易卡住的地方报错信息还特别不直观。5. 两条路线的正面PK一张表看清怎么选讲完两边该做决断了。我把实际项目里最关心的几个维度拉出来对比对比维度Apache POIdocx4j学习成本低API直观高需懂OOXML依赖体积小大跨run文本替换需手写合并逻辑TextUtils直接支持表格行复制困难需手动复制样式简单deepCopy即可图片替换繁琐相对方便页眉页脚处理支持需单独遍历支持遍历headerPart大文档性能较好一般内存占用高社区活跃度高中适合场景简单文本填充复杂结构、动态表格、图片我的选型经验是这样的如果模板结构固定、只做文本替换、没有动态行用POI轻量省心如果涉及动态表格、图片插入、复杂格式保留直接上docx4j别在POI上硬扛。我见过团队为了用POI实现动态表格写了几百行复制样式的代码维护成本极高最后重构换成docx4j代码量砍掉一大半。还有一个折中方案用POI读取和保存用docx4j处理复杂结构。但两者对象模型不通用转换起来也麻烦一般不推荐混用除非你有明确的性能瓶颈需要POI来兜底。6. 页眉页脚、表格、文本框那些“替换不到”的坑怎么填6.1 页眉页脚必须单独遍历这是最高频的漏替换问题。POI里页眉页脚不在document.getParagraphs()里要单独拿for (XWPFHeader header : doc.getHeaderList()) { for (XWPFParagraph p : header.getParagraphs()) { replaceInParagraph(p, data); } } for (XWPFFooter footer : doc.getFooterList()) { for (XWPFParagraph p : footer.getParagraphs()) { replaceInParagraph(p, data); } }docx4j里则是遍历headerPart和footerPart思路类似。关键是要意识到它们和正文是平级的独立部件不能指望一次遍历全覆盖。6.2 表格单元格里的段落要递归处理表格里的文本在XWPFTableCell的段落里而单元格还可能嵌套表格。所以处理表格不能只遍历doc.getTables()要递归public void processTable(XWPFTable table, MapString, String data) { for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { for (XWPFParagraph p : cell.getParagraphs()) { replaceInParagraph(p, data); } for (XWPFTable nested : cell.getTables()) { processTable(nested, data); // 递归嵌套表格 } } } }漏掉递归嵌套表格里的占位符就永远替换不了。6.3 文本框是“隐藏关卡”文本框Text Box在OOXML里是画布结构POI的getParagraphs()默认拿不到里面的段落。需要用底层XML遍历找到w:txbxContent节点再处理。这个场景不常见但一旦模板里有文本框不处理就是明显的bug。docx4j在这方面稍好因为它的对象树更完整但同样需要专门遍历。提示拿到模板后先别急着写代码用解压工具打开docx把每个XML部件都搜一遍占位符列个清单。这样能提前发现占位符到底藏在哪避免写完代码才发现漏了页眉。7. 实测中的性能与格式问题几个真实教训7.1 替换后字体变了多半是run合并的锅前面讲跨run替换时我把替换结果写进第一个run清空后续run。如果第一个run的字体和后续run不一致替换后的文字就会用第一个run的字体视觉上和原来不一样。解决办法是替换前先确认占位符涉及的run格式是否一致如果不一致要么统一格式要么把替换结果按原run边界拆分回填。后者复杂但格式最保真适合对格式要求极高的合同类文档。7.2 大文档批量生成的内存问题docx4j把整个文档加载进内存如果一次生成几百份文档每份都new一个WordprocessingMLPackage内存会飙升。我的做法是模板只加载一次复用同一个package对象每次生成时deepCopy一份再操作。这样避免了重复解析模板的开销。POI同理XWPFDocument也可以复用模板流。7.3 并发下的线程安全XWPFDocument和WordprocessingMLPackage都不是线程安全的。多线程环境下要么每个线程独立加载模板要么用ThreadLocal缓存。我踩过一次坑多个线程共享一个document对象做替换结果生成的文档内容串了排查了半天才发现是并发写同一个对象。7.4 占位符格式不统一导致的匹配失败模板作者可能一会儿写${name}一会儿写{{name}}一会儿写#name#。代码里如果只匹配一种另一种就漏了。建议在项目规范里强制统一占位符格式或者在替换前用正则同时匹配多种格式。我一般推荐${}因为它和大多数模板引擎一致不容易和正文内容冲突。8. 一套可直接复用的替换工具设计思路把前面的经验收拢一下我通常会封装一个工具类对外只暴露一个方法传入模板路径、数据Map、输出路径内部自动处理正文、页眉、页脚、表格、嵌套表格。核心设计要点统一入口WordTemplateFiller.fill(template, data, output)。递归遍历正文段落、表格含嵌套、页眉页脚全部覆盖。跨run替换用“拼接-匹配-回填”三步法从后往前替换。格式保真优先写回单个run跨run时尽量保留首个run格式。异常兜底占位符找不到对应数据时替换为空字符串还是保留原样做成可配置。数据Map的key就是占位符里的名字value是替换值。对于动态表格可以约定一个特殊key比如items对应一个List工具内部识别后走表格复制逻辑。这样业务代码只需要准备数据不用关心底层是POI还是docx4j。如果你用的是docx4j工具类内部可以直接调TextUtils.replaceText处理文本再单独处理表格复制如果用POI就把前面那段合并替换逻辑封装进去。两条路线可以共用同一套对外接口底层实现按需切换这对后期维护很友好。最后分享一个我用了很久的调试技巧替换完成后不要只看生成的文档能不能打开一定要用解压工具再搜一遍占位符关键字。如果还能搜到${说明有地方漏替换了比肉眼翻文档靠谱得多。这个习惯帮我提前发现过好几次页眉漏替换的问题。