
笔记应用加入 Markdown 预览后最先暴露的通常不是语法兼容而是三类互相缠绕的问题原文里夹着 HTML渲染器把它当页面节点链接看起来正常却能跳到团队不允许的域名用户连续输入时旧任务最后完成反而覆盖了更新内容。本文构造一个可复现的 HarmonyOS DemoNoteFence。页面名为NotePreviewPage笔记编号NOTE-0715第 23 代文本长度 18,642 字符示例渲染耗时 126ms拦截链接 3 个最终状态READY。数字用于说明工程协议不冒充真实线上统计。markdown-it 是三方解析器ArkUI 负责页面状态和预览容器两者的边界会在文中明确说明。一、预览正确不等于输入可信Markdown 预览很容易被写成两步md.render(source)再把结果交给显示组件。对于团队自己写的静态文档这种写法足够短一旦输入来自导入文件、同步内容或剪贴板就要回答更多问题原始 HTML 是否允许链接协议是否允许图片地址能否访问任意网络旧渲染是否会覆盖新文本页面离开后任务是否还会回写。NoteFence 的默认策略很克制禁用原始 HTML不自动识别裸链接不启用排版替换显式 Markdown 链接只允许https://developer.huawei.com/和应用内部的notefence://note/。其他链接仍显示文字但不生成可点击目标。代码块只做转义和静态样式不加载远程高亮脚本。这不是说 markdown-it 不安全也不是说所有 HTML 都危险。解析器提供能力应用定义信任边界。若团队确实需要受控 HTML应在独立规则中列出元素、属性和 URL 协议再用专门净化器处理不能简单把html: true当作“增强兼容”。Demo 固定了一组调试字段时间15:18当前代次23上一代次22原文长度18,642耗时126ms被拦截链接3。这些字段会同时出现在正文、HiLog、手机概览和诊断页中。先冻结事实再写界面比生成图片后倒推变量名可靠得多。二、渲染器的第一层不是主题而是能力开关这段代码解决什么问题创建一份默认拒绝原始 HTML 与自动链接的 markdown-it 实例并对显式链接做协议、域名白名单判断。importMarkdownItfrommarkdown-itconstALLOWED_HOSTS:SetstringnewSet([developer.huawei.com])exportfunctioncreateMarkdownEngine():MarkdownIt{constenginenewMarkdownIt({html:false,linkify:false,typographer:false,breaks:true})engine.validateLink(url:string):boolean{if(url.startsWith(notefence://note/))returntruetry{constparsednewURL(url)returnparsed.protocolhttps:ALLOWED_HOSTS.has(parsed.hostname)}catch(_){returnfalse}}returnengine}html: false的作用是让原始 HTML 不参与节点渲染而不是删除用户输入。编辑页仍保存原文预览页把它按普通文本语义处理。这样用户切回编辑模式时不会发现内容被工具擅自修改。linkify: false避免一段普通文本里的域名自动变成链接。显式写成[官方文档](https://developer.huawei.com/...)的链接仍会经过validateLink。白名单判断不能用startsWith(https://developer.huawei.com)因为developer.huawei.com.attacker.example也会通过字符串前缀。把 URL 解析成协议和 hostname再做精确匹配结论才可解释。真实项目还应处理子域名策略、端口、国际化域名和内部路由。本文只允许一个精确主机是为了把边界写窄。markdown-it 的版本应锁定在项目依赖文件中升级时重新跑链接、代码块、嵌套列表和长文档样例不能因为 API 名没变就假设输出完全一致。三、把“被拒绝的链接”做成可观察结果只返回 HTML 会丢失决策过程。用户看到链接没有反应不知道是语法写错、网络不可用还是策略主动拦截。NoteFence 在渲染前扫描 token把被拒绝目标记录为诊断项页面只显示数量详情页再列出原因。这段代码解决什么问题在不修改原文的前提下统计被拦截链接并返回结构化渲染结果。interfaceRenderResult{generation:numberhtml:stringsourceChars:numberblockedLinks:string[]elapsedMs:number}exportfunctionrenderNote(source:string,generation:number):RenderResult{conststartedDate.now()constenginecreateMarkdownEngine()consttokensengine.parse(source,{})constblocked:string[][]for(consttokenoftokens){if(token.type!link_open)continueconsthreftoken.attrGet(href)??if(!engine.validateLink(href))blocked.push(href)}return{generation,html:engine.renderer.render(tokens,engine.options,{}),sourceChars:source.length,blockedLinks:blocked,elapsedMs:Date.now()-started}}这里使用同一个 engine 完成 parse 与 render避免扫描规则和最终输出使用不同配置。若先用一份解析器统计再用另一份启用了插件的解析器生成 HTMLtoken 结构可能不一致诊断数量也会漂移。示例里的 3 个拦截目标分别代表非 HTTPS、非白名单主机和非法 URL。报告不把完整查询参数写进日志避免令牌、搜索词或个人信息泄露。正式工程可以只保存协议、主机和归一化路径摘要详情由用户在本地查看。Date.now()用于易读的演示耗时。性能分析更适合单调计时源并要区分解析、净化、模板拼装和 Web 内容装载。126ms 只是固定样例不应据此宣称某个设备上的性能结论。上图是与代码对应的演示配图不是实际 DevEco Studio 截图或测试证据。左侧工程树包含MarkdownEngine.ets、RenderCoordinator.ets和NotePreviewPage.ets中间标出html: false与 generation 比较右侧模拟器显示NOTE-0715HiLog 使用15:18、generation23、126ms和blocked3。四、输入越快越不能让 Promise 决定页面先后长文档解析可以放入异步任务但异步任务没有天然的“最新”概念。用户输入 A、B、C任务可能按 B、A、C 完成页面如果每次.then()都赋值预览会短暂回退。更隐蔽的是 A 在页面离开后完成仍试图更新已经销毁的页面状态。这段代码解决什么问题为每次渲染分配递增代次只允许当前代次且页面仍存活的结果提交。classRenderCoordinator{privategeneration:number0privatealive:booleantrueasyncrequest(source:string):PromiseRenderResult|undefined{constminethis.generationconstresultawaitPromise.resolve().then(()renderNote(source,mine))if(!this.alive||result.generation!this.generation){console.info([NoteFence] stale generation${result.generation})returnundefined}returnresult}invalidate():void{this.alivefalsethis.generation}}代次隔离不会真正取消正在运行的解析它只是阻止晚到结果污染页面。若解析任务占用明显 CPU还要配合可取消任务或输入防抖减少无意义工作。二者不能混为一谈防抖控制“何时开始”代次控制“谁有资格提交”。invalidate()同时改变 alive 和 generation是为了让页面离开前后两个条件都失效。若组件重新进入页面应创建新的 coordinator而不是把 alive 改回 true。复用旧对象容易让上一次页面实例的任务重新获得提交资格。NoteFence 在第 22 代尚未完成时收到第 23 代日志记录stale generation22 dropped第 23 代在 126ms 完成并进入 READY。这个判断只基于代次不依赖哪个任务更快因此换设备或换文档也能保持确定性。五、ArkUI 页面只消费结果不重新解释安全策略这段代码解决什么问题让页面负责状态转换和预览展示把链接策略与解析逻辑留在服务层。EntryComponentstruct NotePreviewPage{Statephase:IDLE|RENDERING|READY|FAILEDIDLEStatepreviewHtml:stringStateblocked:number0Stateelapsed:number0privatecoordinator:RenderCoordinatornewRenderCoordinator()privatecontroller:webview.WebviewControllernewwebview.WebviewController()aboutToDisappear():void{this.coordinator.invalidate()}privateasyncrefresh(source:string):Promisevoid{this.phaseRENDERINGtry{constresultawaitthis.coordinator.request(source)if(!result)returnthis.previewHtmlwrapDocument(result.html)this.blockedresult.blockedLinks.lengththis.elapsed126// 演示字段真实值使用 result.elapsedMsthis.phaseREADY}catch(_){this.phaseFAILED}}build(){Web({src:this.previewHtml,controller:this.controller})}}示例显式把 126 写成演示字段是为了让正文和配图稳定一致不伪装成真机测量。实际工程应展示result.elapsedMs测试则断言范围或阶段不要断言某台机器必须恰好 126ms。页面不再次判断域名也不从 HTML 字符串里数链接。安全策略只有一份来源。wrapDocument只拼接本地 CSS 与基础文档结构不插入远程脚本。若使用 Web 组件承载预览还应根据目标 SDK 文档核对 JavaScript、文件访问、网络访问和导航拦截配置本文不写未经核实的开关名来凑代码。WebviewController 的创建应和页面实例匹配不把同一个控制器交给多个并存的 Web 组件。页面销毁时停止业务回写缓存、历史和 Web 资源释放策略则按当前 ArkWeb 文档与实际版本处理不能仅凭一个 aboutToDisappear 就宣称所有底层资源已释放。运行页显示15:18、NOTE-0715、18,642 字符、126ms、拦截 3、状态 READY。红色说明指向 READY表达的是第 23 代结果通过提交条件不是“解析器绝对安全”。六、诊断页要解释为什么丢弃而不是只写失败晚到结果被丢弃是正常控制流不应记成错误。若把每个 stale 都上报为异常快速输入会制造大量噪声。诊断页把它分为两组策略决策和调度决策。前者包括 HTML 禁用、链接白名单和拦截数量后者包括当前代次、旧代次、页面存活状态和提交结果。诊断页使用同一组字段current generation 23、stale generation 22、HTML disabled true、allowed host developer.huawei.com、blocked 3、accepted 126ms。03 给用户看预览已就绪04 给开发者看旧结果为何没有覆盖两张图的信息职责不同。实际排查顺序也应分开。页面空白先看 wrapDocument 与 Web 加载事件链接不可点先看 blockedLinks 原因内容偶尔回退再看 generation 日志。把所有问题都归为“markdown-it 渲染失败”会让解析器背上页面装载、策略拦截和调度竞争三类责任。错误状态也要保留上一份可用内容还是清空需要产品决定。NoteFence 选择保留上一份 READY 内容并在顶部显示“新内容预览失败”。这样用户仍可阅读但必须清楚它不是最新版本。若内容涉及合同、订单或安全配置保留旧内容可能产生误导应改为遮罩并要求重新加载。七、长文档优化先从减少无效工作开始很多人看到 18,642 字符就立刻想到分片解析。Markdown 的块级结构可能跨行随意按字符切片会破坏围栏代码块、列表和引用。没有完整增量解析模型前最稳妥的优化是输入防抖、代次隔离、复用经过审查的 engine 配置以及避免每次渲染重复加载远程资源。如果文档确实达到数十万字符可以按稳定块边界构建段落索引但要保存围栏状态与上下文。增量结果还要保证锚点、脚注和跨块引用一致。性能目标不能只看解析耗时还要看 HTML 体积、页面装载、首次可见时间和滚动稳定性。缓存键至少包含原文摘要、解析器版本、规则版本和主题版本。只用 noteId 会在内容变化后命中旧 HTML只用原文摘要又可能在白名单更新后复用旧决策。安全规则变化应主动失效缓存这是比“多缓存一点”更重要的边界。三方库升级前要比较生成结果而不只是跑编译。准备包含 HTML、危险协议、超长链接、代码围栏、嵌套列表、中文标点和空白边界的固定语料保存结构化快照。差异必须由人确认特别是链接验证和转义行为。八、把预览功能收敛成可验收协议NoteFence 的验收清单很具体原始 HTML 不成为活动节点非白名单链接不可导航且计数准确显式官方链接可用第 22 代晚到时不覆盖第 23 代页面离开后不再回写解析异常有可见状态日志不记录敏感查询参数三方库升级会重新跑固定语料。还要测试看似无害的边界空文档、只有空格、未闭合代码围栏、超长单行、重复点击刷新、横竖屏切换和连续返回。它们不一定都是 markdown-it 的问题却都可能穿过同一条预览链路。最终结论并不复杂。markdown-it 负责把受约束的 Markdown 变成 HTML应用负责定义什么输入可信、什么链接可走、哪个异步结果最新ArkUI 页面负责把确定状态呈现出来。三层各守一条边界离线预览才能既有能力又不会把解析、导航和生命周期揉成一个不可解释的黑盒。九、规则真正落地时还要处理插件与资源路径markdown-it 的优势之一是插件生态但插件也是规则扩张入口。表格、任务列表、脚注和代码高亮都会改变 token 或生成 HTML。NoteFence 不允许业务页面随手engine.use()而是由一处工厂登记插件名称、版本、用途和输出标签。新增插件要先过固定语料与输出审查再进入公共实例。代码高亮尤其容易把离线预览变成远程执行链。若高亮器需要动态下载语言包、主题或脚本页面的离线与可控边界就被打破。更稳妥的做法是只打包确实需要的语言定义未知语言回退为纯文本并对代码内容完成 HTML 转义。高亮失败不应让整篇笔记 FAILED它只是局部增强失败。图片语法也需要单独策略。不是普通链接允许点击域名并不等于允许加载图片。图片会在用户没有主动点击时发起请求还可能暴露网络地址或带来超大资源。NoteFence 默认不加载远程图片只允许应用沙箱内经过映射的资源标识外部图片显示占位符、替代文本和“手动加载”入口。资源路径不能直接拼接到 HTML。笔记附件应先通过资源仓库解析为受控 URL并核对附件仍属于当前 noteId。用户删除附件后旧 HTML 缓存必须失效否则预览仍可能引用已经撤销的资源。缓存键因此还要包含附件清单版本而不只是 Markdown 摘要。内部链接notefence://note/也不是天然可信。路径部分需要解析为合法笔记 ID导航前再核对目标是否存在、当前用户是否有权访问。validateLink只决定生成链接标签不替代点击时的业务授权。解析层与导航层都检查是因为两层面对的威胁不同。CSS 是另一条容易被忽略的边界。主题样式由应用固定模板提供不从笔记正文读取style也不允许用户自定义任意 CSS。即便没有脚本CSS 仍可能隐藏内容、覆盖提示或制造超大布局。业务确实需要颜色和强调时可以通过有限 Markdown 扩展映射到预定义 class而不是放开原始样式。十、状态机要覆盖加载阶段而不只是解析阶段RENDERING 完成并不代表用户已经看到内容。HTML 交给 Web 容器后还有文档装载和首屏绘制。若页面在此阶段立即显示 READY用户可能看到短暂空白。更完整的状态可以拆为PARSING、LOADING、READY其中解析结果通过代次后进入 LOADING收到当前代次对应的页面完成事件才进入 READY。这里又会出现一次晚到问题。第 22 代 HTML 已经开始加载第 23 代随后提交旧页面的完成事件可能比新页面晚。事件处理必须携带或映射 generation不能收到任何 onPageEnd 就设 READY。可以在包装文档中写入不含敏感信息的 generation 标记或在控制层维护当前加载事务。错误也要区分解析失败与装载失败。解析失败意味着没有新 HTML装载失败可能是模板、资源或 Web 容器问题。两者给用户的恢复动作不同前者建议检查输入或重试解析后者可以重新装载同一结果。统一显示“预览失败”会让排查信息损失。页面快速前后台切换时不要为每次可见性变化重建 engine。markdown-it 实例本身不保存某篇笔记的业务状态可以在明确线程模型和插件行为后复用coordinator 与 WebviewController 则属于页面或会话。哪些对象可复用、哪些对象必须重建应写进资源归属表而不是靠开发者记忆。十一、测试不要只比较一整段 HTML 字符串整段快照能发现输出变化却经常因为属性顺序、空白或版本细节产生噪声。NoteFence 把验收分三层策略层断言某 URL 允许或拒绝token 层断言原始 HTML 没有变成活动标签、链接数量正确展示层只检查关键文本、链接可点击性和状态迁移。代次测试使用可控延迟让 generation 22 在 220ms 完成generation 23 在 126ms 完成断言最终页面是 23日志包含一次 stale 丢弃。再让页面在 80ms 时 invalidate两个任务都不能写回。不要用真实setTimeout猜时序测试适配器应显式控制完成顺序。安全语料至少包含javascript:、data:、大小写混合协议、带用户名的 URL、相似主机名、超长路径、编码后的控制字符和内部路由越权目标。测试不是为了证明 URL 类永远正确而是固定本项目允许与拒绝的协议。Markdown 语料还要覆盖未闭合标签、未闭合围栏、表格中的链接、图片嵌套、HTML 实体和大段 Unicode。一次升级如果让原本作为文字显示的内容变成节点应被差异报告捕获。差异确认后更新快照并记录原因不能看到 CI 变红就机械覆盖基线。性能测试使用多档文档而不是一份“最大文件”。短笔记关注启动开销中等笔记关注连续输入大笔记关注内存峰值和首次可见时间。126ms 只有在设备、版本、文档摘要和测量阶段都明确时才有比较意义本文把它固定为 Demo 字段正是为了避免混淆。十二、发布检查要把三方库当作持续依赖接入完成后还要保存许可证信息、锁定版本并订阅安全更新。版本升级不应和业务大版本混在一起单独变更更容易审查输出差异和回滚。若升级改变链接验证或转义行为应提升规则版本并失效旧缓存。依赖不可用时要有降级。解析器初始化失败不应让编辑功能消失用户仍可查看原文预览区域显示明确不可用状态。不要悄悄换成另一套解析器因为不同 Markdown 方言会让同一文本产生不同结构用户反而更难判断。监控指标也要克制。可以统计解析失败率、晚到丢弃次数、各文档长度档位的耗时和拦截原因类别不上传原始 Markdown、完整 URL 或渲染 HTML。blocked3 对排查有用三个链接的敏感参数不一定需要离开设备。当 stale 次数持续偏高时先检查输入防抖和页面是否重复订阅而不是把日志级别降到看不见。代次机制是最后防线不应长期掩盖上游每个字符都启动重任务的设计问题。同样缓存命中率低也可能是键包含了每次变化的无关时间戳。到这里NoteFence 才形成可维护闭环依赖版本有记录能力开关有来源链接与图片分策略异步结果有代次页面状态覆盖解析和装载测试按层次断言升级会失效缓存监控不泄露正文。一个预览功能看起来仍然只是两个页面背后的信任边界却已经清楚。参考资料markdown-it 官方项目与 API华为开发者文档ArkWeb华为开发者文档Web 组件