ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Vue 3 中使用 vue-quill-editor 实战指南:PC端富文本深度定制与优化

2026/9/30 8:08:32 拓冰建站 浏览量
Vue 3 中使用 vue-quill-editor 实战指南:PC端富文本深度定制与优化 1. 为什么选 vue-quill-editor 而不是其他富文本方案在 Vue 项目里接入富文本编辑器我踩过至少五种坑从原生 contenteditable 手搓到引入 TinyMCE、CKEditor、Quill 官方 Vue 封装再到各种社区魔改版。最后稳定下来用vue-quill-editor不是因为它“最好”而是它在真实业务场景中——尤其是 PC 端中后台系统——做到了三个关键平衡可控性、可维护性、可交付性。先说清楚vue-quill-editor 并不是 Quill 官方维护的 Vue 组件Quill 官方只提供原生 JS API而是由社区开发者基于 Quill.js 封装的 Vue 2/3 兼容组件。它的核心价值在于把 Quill 强大的底层能力用 Vue 的响应式思维重新组织了一遍。比如你改了 v-model 绑定的数据编辑器内容会同步更新你在编辑器里加粗一段文字v-model 对应的 HTML 字符串立刻变样——这种双向绑定不是靠轮询或 MutationObserver 黑科技硬怼出来的而是通过 Quill 的on(text-change)和setContents()两个原生事件方法被封装层精准桥接实现的。对比其他热门方案TinyMCE 体积大压缩后仍超 500KB、配置项爆炸式增长一个基础 toolbar 配置就要写 20 行 JSONCKEditor 5 的 Vue 封装对 Vue 3 的 Composition API 支持滞后半年以上且 license 在商用场景下有明确限制而纯手写 contenteditable光是处理 Safari 下的光标偏移、IE11 的 selection API 兼容、粘贴 Word 内容的样式清洗就能耗掉一个中级前端三天时间。vue-quill-editor 的 bundle size 控制在 80KB 左右含 Quill 核心支持按需加载 toolbar 模块API 层级干净文档虽不华丽但每行代码都有对应的真实 issue 讨论支撑——这才是中后台项目最需要的“稳”。我去年在做一个政府侧的公文协同平台时客户明确要求必须支持红头文件格式、段落首行缩进 2 字符、仿宋_GB2312 字体、自动编号列表、以及插入带水印的 PDF 预览图。这些需求看似简单实则直击富文本编辑器三大软肋字体控制、段落样式持久化、自定义 blot 插入。vue-quill-editor 的优势就体现在这里——它允许你直接操作 Quill 的Parchment抽象语法树而不是在 UI 层反复 hack。比如要强制所有段落默认首行缩进我不用改 CSS 或监听 input 事件而是注册一个自定义 Block blot在创建时自动注入styletext-indent: 2em要插入 PDF 预览图我扩展一个pdf-blots把 base64 或 URL 转成带 loading 状态和点击弹窗的可交互容器。这种深度可编程性是那些黑盒式编辑器根本做不到的。当然它也有明显短板移动端体验一般、不支持协作编辑OT 算法、图片拖拽上传需自行实现。但对我服务的绝大多数客户——政务系统、ERP 表单、CRM 备注栏、内部知识库——这些都不是优先级问题。真正卡住上线的永远是“客户发来一份 Word 公文粘贴进去后标题字号乱了”“导出 PDF 时中文换行错位”“多人同时编辑同一字段时内容覆盖”。vue-quill-editor 在这些问题上提供了足够清晰的干预入口和成熟社区方案而不是把你扔进一堆 undocumented 的 internal API 里自己摸索。所以如果你正在评估“vue pc富文本编辑器”别被 npm 下载量或 star 数迷惑。先问自己三个问题是否需要深度定制样式和行为比如强制某种段落格式是否要对接自有图片/附件服务而非直接走七牛云或阿里 OSS是否要求导出 HTML 后能被 wkhtmltopdf 或 Puppeteer 稳定渲染为 PDF如果三个答案都是“是”vue-quill-editor 就是当前阶段最务实的选择。它不炫技但每一步都踩在业务落地的实处。2. 从零开始Vue 3 vite 环境下的完整集成流程现在主流 Vue 项目基本都跑在 vite 构建工具上而 vue-quill-editor 的官方文档还停留在 webpack Vue 2 时代。这就导致很多新手照着 npm install 之后一跑就报Cannot find module quill或Quill is not defined。问题不在组件本身而在构建链路的模块解析逻辑发生了变化。下面我把整个流程拆成可复现的六步每一步都附带原理说明和避坑提示。2.1 安装依赖与版本锁定首先执行安装命令npm install vue-quill-editor4.5.0 quill1.3.7注意必须锁定 quill 版本为 1.3.7。这是目前与 vue-quill-editor4.5.0 兼容性最好的版本。Quill 2.x 虽已发布但其模块结构彻底重构从 UMD 变为 ESM而 vue-quill-editor 尚未适配。我试过强行升级到 quill2.0.0-dev结果编辑器初始化时直接抛Quill.register is not a function错误——因为新版本把 register 方法移到了Quill.modules下旧封装层根本找不到入口。另外vue-quill-editor4.5.0 是最后一个支持 Vue 3 的兼容版本。后续的 5.x 版本已转向纯 Composition API 实现但文档缺失严重GitHub issues 里大量用户反馈“import 后组件不渲染”。所以生产环境请严格使用 4.5.0别贪新。提示vite 默认开启依赖预构建optimizeDeps但 quill 的某些动态 require 语句会被 esbuild 误判为无效引用而剔除。因此安装后务必在 vite.config.ts 中显式声明export default defineConfig({ optimizeDeps: { include: [quill, quill/formats/align, quill/formats/size] } })2.2 创建可复用的封装组件直接在页面里 import vue-quill-editor 并使用会导致样式污染和重复初始化。我推荐新建一个RichTextEditor.vue组件把所有定制逻辑收拢进来template div classrich-text-editor-wrapper quill-editor refeditorRef v-model:contentinnerHtml :optionseditorOptions bluronBlur focusonFocus readyonReady changeonChange / /div /template script setup langts import { ref, watch, onMounted, nextTick } from vue import { QuillEditor } from vue-quill-editor import vue-quill-editor/dist/vue-quill-editor.css // 注册自定义模块稍后详解 import ./quill-custom-modules const props defineProps{ modelValue?: string placeholder?: string readOnly?: boolean height?: string // 如 300px 或 auto }() const emit defineEmits([update:modelValue, blur, focus, change]) const editorRef refInstanceTypetypeof QuillEditor | null(null) const innerHtml ref(props.modelValue || pbr/p) // 编辑器配置对象 const editorOptions { theme: snow, placeholder: props.placeholder || 请输入内容..., readOnly: props.readOnly, modules: { toolbar: [ [{ header: [1, 2, 3, 4, 5, 6, false] }], [bold, italic, underline, strike], [{ color: [] }, { background: [] }], [{ script: sub }, { script: super }], [{ list: ordered }, { list: bullet }, { indent: -1 }, { indent: 1 }], [{ direction: rtl }, { align: [] }], [link, image, video], [clean] ], clipboard: { matchVisual: false // 关键禁用此选项才能正确粘贴纯文本 } }, // 高度控制 bounds: document.body, scrollingContainer: .rich-text-editor-wrapper } // 同步父组件传入的 modelValue watch(() props.modelValue, (val) { if (val ! innerHtml.value) { innerHtml.value val || pbr/p } }) // 同步编辑器内容到父组件 const onChange (delta: any, oldDelta: any, source: string) { if (source user) { emit(update:modelValue, innerHtml.value) emit(change, innerHtml.value, delta, oldDelta) } } const onBlur () emit(blur) const onFocus () emit(focus) const onReady () { // 初始化完成后可安全调用 Quill 实例方法 const quill editorRef.value?.quill if (quill) { // 设置默认字体为 sans-serif避免中文字体渲染异常 quill.format(font, sans-serif) } } /script style scoped .rich-text-editor-wrapper { border: 1px solid #dcdfe6; border-radius: 4px; overflow: hidden; } .rich-text-editor-wrapper :deep(.ql-container) { min-height: v-bind(height); max-height: v-bind(height); } /style这个组件的关键设计点有三个第一用v-model:content而非v-model因为 vue-quill-editor 的 model 绑定的是 HTML 字符串不是 Delta 对象。早期版本用v-model会导致响应式失效4.5.0 后统一为content修饰符。第二modules.clipboard.matchVisual: false是必选项。Quill 默认开启视觉匹配matchVisual即粘贴时保留源格式。但在实际业务中用户从 Word 或网页复制内容90% 的情况需要“只保留文字结构清除所有样式”。设为 false 后粘贴行为等同于document.execCommand(insertText)干净利落。第三bounds和scrollingContainer的设置是为了防止下拉菜单如字体选择被父容器裁剪。很多新手发现 toolbar 下拉框显示不全根源就是没设这两个属性。2.3 解决图片上传的“最后一公里”vue-quill-editor 自带的 image 按钮点击后弹出的是本地文件选择框选完直接转 base64 插入。这在开发阶段没问题但上线后必然要对接自己的文件服务。我见过太多项目在这里翻车上传成功但图片不显示、点击图片无法删除、多图并发上传顺序错乱。正确的做法是重写 image 模块接管整个流程// src/utils/quill-image-handler.ts import { Quill } from quill const ImageBlot Quill.import(formats/image) // 扩展 ImageBlot添加自定义属性 class CustomImage extends ImageBlot { static create(value: string) { const node super.create(value) as HTMLElement node.setAttribute(data-custom, true) return node } } Quill.register(CustomImage, true) // 注册自定义 toolbar handler export function registerImageHandler(quill: Quill) { const toolbar quill.getModule(toolbar) if (!toolbar) return // 替换原有 image 按钮的 click 事件 toolbar.addHandler(image, async () { const input document.createElement(input) input.setAttribute(type, file) input.setAttribute(accept, image/*) input.click() input.onchange async () { const file input.files?.[0] if (!file) return try { // 这里调用你的上传接口返回图片 URL const imageUrl await uploadImageToServer(file) // 获取光标位置并插入图片 const range quill.getSelection() if (range) { quill.insertEmbed(range.index, image, imageUrl, user) } } catch (err) { console.error(图片上传失败, err) alert(图片上传失败请重试) } } }) } async function uploadImageToServer(file: File): Promisestring { const formData new FormData() formData.append(file, file) const res await fetch(/api/upload/image, { method: POST, body: formData, credentials: include // 若需携带 cookie }) if (!res.ok) throw new Error(上传接口返回错误) const data await res.json() return data.url // 假设后端返回 { url: https://xxx.com/abc.png } }然后在RichTextEditor.vue的onReady钩子中调用const onReady () { const quill editorRef.value?.quill if (quill) { quill.format(font, sans-serif) registerImageHandler(quill) // 注入自定义上传逻辑 } }这个方案的优势在于完全复用 Quill 的图片插入机制不需要修改 DOM 结构上传失败时可精确控制提示文案支持并发上传每个文件独立请求生成的img标签天然带有>import { Quill } from quill // 锁定字体 const Font Quill.import(formats/font) Font.whitelist [simhei, microsoft yahei, sans-serif] Quill.register(Font, true) // 锁定字号 const Size Quill.import(formats/size) Size.whitelist [12px, 14px, 16px, 18px, 24px] Quill.register(Size, true) // 自定义段落样式强制首行缩进 2 字符 const Block Quill.import(blots/block) class IndentedBlock extends Block { static create(value: any) { const node super.create(value) node.setAttribute(style, text-indent: 2em; margin-bottom: 16px;) return node } } Quill.register(IndentedBlock, true)这样做的好处是用户点击 toolbar 的字体按钮下拉菜单里只显示白名单中的字体插入新段落时自动带上text-indent: 2em样式导出 HTML 时这些内联 style 会原样保留确保下游 PDF 渲染一致。2.5 导出 HTML 的纯净化处理编辑器存入数据库的是 HTML 字符串但 Quill 生成的 HTML 常包含无意义的span、font、strong嵌套甚至pbr/p这样的空段落。直接存库会导致存储膨胀、搜索困难、SEO 不友好。我在onChange回调里加了一层清洗import { parse, serialize } from parse5 import { getElementsByTagName } from parse5-utils const cleanHtml (html: string): string { if (!html.trim()) return pbr/p const doc parse(html) const paragraphs getElementsByTagName(doc, p) paragraphs.forEach(p { // 移除空段落 if (p.childNodes.length 0 || (p.childNodes.length 1 p.childNodes[0].nodeName #text !p.textContent?.trim())) { p.parentNode?.removeChild(p) return } // 清理段落内无意义 span const spans getElementsByTagName(p, span) spans.forEach(span { if (!span.attributes.some(attr attr.name style)) { // 无 style 的 span 直接展开其子节点 while (span.firstChild) { p.insertBefore(span.firstChild, span) } p.removeChild(span) } }) }) return serialize(doc) } // 在 onChange 中调用 const onChange (delta: any, oldDelta: any, source: string) { if (source user) { const cleaned cleanHtml(innerHtml.value) innerHtml.value cleaned emit(update:modelValue, cleaned) } }这个清洗函数只做三件事删空段落、展平无样式的 span、保留所有带 style 的内联标签。它不碰strongema这些语义化标签也不动imgvideo确保富文本的核心表达能力不受损。实测一个 500 字的公文清洗后 HTML 字符数减少 37%DOM 节点数减少 22%对后续的全文检索和 PDF 渲染都有正向影响。2.6 高级功能插入自定义组件如 PDF 预览卡片有些业务需要插入非文本内容比如“此处插入合同扫描件”点击后弹出 PDF 预览。这不能靠img标签解决得用自定义 blot。// src/utils/pdf-blots.ts import { Quill } from quill class PdfBlot implements Quill.sources { static create(value: { id: string; name: string; url: string }) { const node document.createElement(div) node.className pdf-preview-card node.innerHTML div classpdf-icon/div div classpdf-info div classpdf-name${value.name}/div div classpdf-meta${value.id}/div /div div classpdf-actions button classview-btn查看/button button classdelete-btn删除/button /div // 绑定事件 node.querySelector(.view-btn)?.addEventListener(click, () { window.open(value.url, _blank) }) node.querySelector(.delete-btn)?.addEventListener(click, (e) { e.stopPropagation() const quill (node as any).quill const index quill.getIndex(node) quill.deleteText(index, 1) }) return node } static value(node: HTMLElement) { return { id: node.dataset.id || , name: node.dataset.name || , url: node.dataset.url || } } } Quill.register(formats/pdf, PdfBlot)然后在 toolbar 配置里加个按钮modules: { toolbar: [ // ...原有配置 [{ pdf: insert }] ] }这样插入的 PDF 卡片是真正的 blot支持撤销/重做、可被 Delta 序列化、能与其他格式混合排版。比用dangerouslyPasteHTML插入 div 安全十倍。3. 深度定制解决 PC 端高频痛点的实战技巧PC 端富文本编辑器的使用场景决定了它必须面对一些移动端不会出现的复杂问题长文档滚动卡顿、Word 粘贴样式混乱、打印预览错位、键盘快捷键冲突、多 tab 页编辑器复用。这些不是“锦上添花”的优化而是决定项目能否交付的硬门槛。下面分享我在三个大型项目中沉淀下来的实战技巧。3.1 长文档性能优化虚拟滚动 分块渲染当编辑器内容超过 5000 字Quill 的 DOM 更新会明显变慢尤其在 Chrome 下滚动时帧率跌破 30fps。根本原因是 Quill 默认为每个段落创建一个p元素长文档导致 DOM 树过深。我的解法不是换框架而是用“分块渲染”策略// 在 RichTextEditor.vue 中 const renderChunks computed(() { const html innerHtml.value if (!html) return [] // 按段落切分每 50 个 p 为一块 const parser new DOMParser() const doc parser.parseFromString(html, text/html) const paragraphs Array.from(doc.body.querySelectorAll(p)) const chunks: string[] [] for (let i 0; i paragraphs.length; i 50) { const chunk document.createElement(div) chunk.append(...paragraphs.slice(i, i 50)) chunks.push(chunk.innerHTML) } return chunks })然后在 template 中用v-for渲染template div classrich-text-editor-wrapper div v-for(chunk, index) in renderChunks :keyindex v-htmlchunk classchunk/div /div /template但这只是第一步。真正的性能瓶颈在 Quill 的事件监听器。Quill 为每个 blot 绑定了input、selectionchange、scroll等事件长文档下事件冒泡开销巨大。解决方案是在编辑器失去焦点时卸载所有非必要监听器获得焦点时再挂载。const quill editorRef.value?.quill if (quill) { // 失去焦点时精简监听 quill.root.removeEventListener(input, handleInput) quill.root.removeEventListener(scroll, handleScroll) // 获得焦点时恢复 quill.root.addEventListener(input, handleInput) quill.root.addEventListener(scroll, handleScroll) }实测效果12000 字文档滚动帧率从 18fps 提升至 58fps首次渲染时间缩短 63%。这个方案不改变 Quill 的核心逻辑只做轻量级调度适合所有存量项目快速接入。3.2 Word 粘贴终极清洗从源头截断样式污染用户从 Word 粘贴内容90% 的问题源于 Word 生成的冗余 HTMLspan stylefont-family: SimSun; color: #000000;、p classMsoNormal、!--[if !mso]条件注释。这些标签不仅增大体积更会导致 Quill 的 Delta 计算异常。vue-quill-editor 的clipboard模块提供了matchersAPI允许你注册自定义清洗规则// src/utils/quill-clipboard-cleaner.ts import { Quill } from quill export function setupClipboardCleaner(quill: Quill) { const clipboard quill.clipboard // 移除所有 style 属性除了 text-align 和 font-size clipboard.addMatcher(Node.ELEMENT_NODE, (node, delta) { if (node.hasAttribute(style)) { const styles node.getAttribute(style)?.split(;) || [] const keepStyles styles.filter(s s.trim().startsWith(text-align:) || s.trim().startsWith(font-size:) ) if (keepStyles.length 0) { node.setAttribute(style, keepStyles.join(;)) } else { node.removeAttribute(style) } } return delta }) // 移除所有 class 属性 clipboard.addMatcher(Node.ELEMENT_NODE, (node, delta) { if (node.hasAttribute(class)) { node.removeAttribute(class) } return delta }) // 将 b i u 转为语义化标签 clipboard.addMatcher(B, (node, delta) { return delta.compose(new Delta().retain(delta.length(), { bold: true })) }) }这个清洗器在粘贴瞬间生效比后端过滤更及时比前端正则替换更可靠。它不依赖浏览器的paste事件而是直接介入 Quill 的 clipboard 解析流程确保任何来源的粘贴CtrlV、右键菜单、拖拽都经过同一套规则。3.3 打印与 PDF 导出适配CSS 媒体查询实战客户验收时总爱问“这个内容能直接打印吗”、“导出 PDF 格式是否和屏幕显示一致” Quill 默认的 snow 主题 CSS 是为屏幕设计的打印时会出现toolbar 不隐藏、图片尺寸失真、字体模糊、分页错乱。解决方案是写一套专用的 print CSS并在打印前动态注入/* src/assets/print.css */ media print { .ql-toolbar { display: none !important; } .ql-container { border: none !important; padding: 0 !important; } .ql-editor { font-family: SimSun, Microsoft YaHei, sans-serif !important; line-height: 1.75 !important; font-size: 14px !important; } .ql-editor p { margin: 0.8em 0 !important; } .ql-editor img { max-width: 100% !important; height: auto !important; } .ql-editor .pdf-preview-card { page-break-inside: avoid !important; } }然后在导出 PDF 时const exportToPdf () { const printStyle document.createElement(style) printStyle.textContent media print { body * { visibility: hidden; } .ql-editor, .ql-editor * { visibility: visible; } .ql-editor { position: absolute; left: 0; top: 0; } } document.head.appendChild(printStyle) window.print() // 打印完成后清理 setTimeout(() { document.head.removeChild(printStyle) }, 1000) }这套方案经受住了某省级政务平台的验收测试打印 A4 纸张时页边距、行高、字体、图片比例全部符合《党政机关公文格式》GB/T 9704-2012 标准。关键点在于visibility: hidden的全局隐藏比display: none更可靠因为它不影响布局流避免打印时元素错位。3.4 键盘快捷键冲突处理CtrlS 保存与编辑器快捷键共存Quill 内置了CtrlB加粗、CtrlI斜体、CtrlU下划线等快捷键。但业务系统通常也有CtrlS保存草稿的功能。当用户在编辑器内按 CtrlS浏览器默认行为是触发保存但 Quill 也会捕获该事件并尝试执行“插入水平线”Quill 的默认映射。结果就是用户想保存却插入了一条hr。解决思路是在编辑器获得焦点时劫持keydown事件对特定组合键做拦截const handleKeyDown (e: KeyboardEvent) { // 拦截 CtrlS / CmdS if ((e.ctrlKey || e.metaKey) e.key s) { e.preventDefault() e.stopImmediatePropagation() // 触发自定义保存逻辑 emit(saveDraft) } } onMounted(() { const editor editorRef.value?.quill?.root if (editor) { editor.addEventListener(keydown, handleKeyDown) } })但要注意不能全局监听document否则会影响其他输入框。必须精确绑定到 Quill 的 root 元素。同时stopImmediatePropagation()比stopPropagation()更彻底能阻止 Quill 内部的事件监听器执行。3.5 多 Tab 页编辑器复用避免实例内存泄漏中后台系统常见“一个页面多个富文本字段”比如表单里有“正文”、“附件说明”、“审批意见”三个编辑器。如果每个都独立初始化 Quill 实例关闭 tab 时未销毁会导致内存持续增长。vue-quill-editor 提供了destroy()方法但官方文档没说怎么用。正确姿势是// 在组件 unmounted 钩子中 onUnmounted(() { if (editorRef.value?.quill) { // 先清空内容再销毁 editorRef.value.quill.setText() editorRef.value.quill.destroy() } })更进一步可以封装一个useQuillManagercomposable// composables/useQuillManager.ts import { onUnmounted } from vue import { QuillEditor } from vue-quill-editor export function useQuillManager(editorRef: RefInstanceTypetypeof QuillEditor | null) { onUnmounted(() { if (editorRef.value?.quill) { editorRef.value.quill.setText() editorRef.value.quill.destroy() // 清理所有事件监听器 const root editorRef.value.quill.root root?.removeEventListener(input, () {}) root?.removeEventListener(scroll, () {}) } }) }这样每个使用编辑器的组件只需调用useQuillManager(editorRef)就能保证实例被彻底释放。实测 10 个 tab 切换 50 次内存占用稳定在 80MB 以内无持续增长。4. 常见问题与排查技巧实录在真实项目交付过程中90% 的问题不是“功能不会做”而是“现象诡异找不到原因”。我把过去三年遇到的典型问题整理成速查表每一条都附带现场日志、定位路径和根治方案。这些不是文档里的标准答案而是从生产环境血泪中提炼的独家经验。问题现象日志线索定位路径根治方案经验备注编辑器初始化后内容为空但 v-model 绑定的值有数据控制台无报错editorRef.value?.quill?.root.innerHTML返回空字符串检查v-model:content是否拼写错误确认innerHtmlref 是否在onMounted前被赋值在onMounted后 nextTick 再设置初始值nextTick(() { innerHtml.value props.modelValue图片上传成功但编辑器里显示 broken imageNetwork 面板看到图片请求 200但img srcxxx的 src 属性是 base64 而非 URL检查registerImageHandler中quill.insertEmbed的第三个参数是否为 URL 字符串确保uploadImageToServer返回的是绝对 URL不是相对路径若后端返回/upload/abc.png需拼接为https://domain.com/upload/abc.pngQuill 对相对路径解析不稳定尤其在 history 模式路由下必须传绝对 URL粘贴 Word 内容后部分段落丢失粘贴前quill.getText()返回 1000 字粘贴后只剩 300 字查看clipboard.matchers是否被多次注册导致 delta 被重复清洗在setupClipboardCleaner函数开头加防重判断if (quill.__clipboardCleanerApplied) return;quill.__clipboardCleanerApplied trueQuill 的 matcher 是全局注册的组件重复创建时会叠加导致 delta 被清洗多次切换 tab 后编辑器 toolbar 按钮状态错乱如加粗按钮未高亮点击加粗按钮内容加粗但按钮图标未变蓝检查quill.getFormat()返回值是否为{ bold: true }但 toolbar 的 active 状态未更新在onChange回调中手动触发 toolbar 更新const toolbar quill.getModule(toolbar);toolbar.update();Quill 的 toolbar 状态依赖于 selection change 事件tab 切换可能导致 selection 丢失需主动刷新导出 HTML 后PDF 渲染时中文字体显示为方块wkhtmltopdf 日志显示fontconfig error: Cannot load default config file检查服务器是否安装了中文字体fc-list | grep -i simsun是否有输出在服务器执行sudo apt-get install fonts-wqy-zenheisudo fc-cache -fv并在 CSS 中指定font-family: WenQuanYi Zen Hei, sans-serifwkhtmltopdf 默认不带中文字体必须显式安装并配置 fontconfig除了表格里的问题还有几个高频陷阱值得单独强调陷阱一vite 的 CSS 预处理导致样式失效vite 默认用 postcss 处理 CSS而 vue-quill-editor 的dist/vue-quill-editor.css里有:global(.ql-toolbar)这样的穿透写法。postcss 会把它转成.ql-toolbar[data-v-xxx]导致样式不生效。解决方案是在 vite.config.ts 中排除该 CSSexport default defineConfig({ css: { postcss: { plugins: [ // 其他插件 ] } }, // 排除 quill 样式 resolve: { alias: { vue-quill-editor/dist/vue-quill-editor.css: path.resolve(__dirname, node_modules/vue-quill-editor/dist/vue-quill-editor.css) } } })陷阱二SSR 环境下 Quill 未定义在 Nuxt 或 Vite SSR 模式下服务端渲染时window对象不存在Qu