从 Markdown 到公众号:基于原生 DOM 实现跨平台内容复制

546 阅读7分钟

很多人选择了markdown语法来写文章,因为它可以在纯文字的基础上添加少量的语法,就能渲染出更美观的样式,并且可以自己扩展样式。

更重要的是它的生态十分丰富,基本上所有平台、框架都支持markdown语法,再加上开源插件的协助,可以满足绝大部分展示需求。

以前我写文章的流程是这样的:先在本地的某个写作App上把文章写完,确认没问题后,再打开某个自己还算信赖的markdown转换网站/App,一键复制内容,然后打开目标平台编辑器,粘贴进去,看看样式有没有问题,然后点击预览、发布。

直接有几次半夜写完了文章想发布的时候,markdown转换失败了,要么是代码丢了高亮,要么是有些样式错乱了。而我别无他法,只能再找另一个网站去转换,但是经常写文章的话,某些样式可能是自己自定义的,某些主题别的网站可能还没集成,非常无奈。

我一直觉得这是个问题,但碍于我也没解决,讲出来顶多是大家一起吐槽一下,所以就不了了之。

直到前一阵,我又又又开始自建博客站,这次没有找现成的模板,因为我想基于本地文件直接生成博客文章,之前搬家搬的真的累,这次要一举让我写文章这个工作流达到完美。

基于本地文件生成文章,我用nuxt/content实现了,于是问题来到「如何一键复制到公众号」上。

要实现这个功能,有几点需要明确,思路才能理顺。

复制的内容是什么

我要想保持各平台样式一致,肯定是从自己博客上复制内容+样式,然后到其他平台上。

所以,那些markdown转换的网站,他们复制的是什么内容?为什么可以带有样式?

打开以前用过的网站,写一段markdown,然后点复制。粘贴进VSCODE看看到底是啥。

如果能正常粘贴出来的话,你可能会看到如下内容(以下是我在一个mdx编辑器内粘贴出来的内容):

<section data-tool="mdx editor" data-website="https://editor.runjs.cool/" class="oneLight"><section data-tool="mdx 编辑器" data-website="https://editor.runjs.cool/" class="markdown-body" components="[object Object]" style="font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue',
    'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei',
    Arial, sans-serif; word-break: break-word; line-height: 1.7; font-weight: 400; font-size: 16px; overflow-x: hidden; color: #212122;"><p data-line="6" style="font-size: 16px; text-align: start; white-space: normal; text-size-adjust: auto; line-height: 2; margin-top: 16px; margin-bottom: 16px;"><strong data-line="6">搞严重。</strong></p>
<blockquote data-line="8" style="color: #777777; padding: 1px 16px; margin: 24px 0; border-left: 4px solid #c6c4c4; background-color: #f1f1f1; transition: all 0.3s ease-out; border-radius: 4px;">
<p data-line="8" style="font-size: 16px; text-align: start; white-space: normal; text-size-adjust: auto; line-height: 2; margin-top: 16px; margin-bottom: 16px; margin: 10px 0;">1231231</p>
<span style="display: block;"></span></blockquote>
<p data-line="10" style="font-size: 16px; text-align: start; white-space: normal; text-size-adjust: auto; line-height: 2; margin-top: 16px; margin-bottom: 16px;">在别人<code data-line="10" style="font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; word-break: break-word; border-radius: 2px; overflow-x: auto; background-color: #f1f1f1; color: #ef7060; font-size: 14px; padding: 0.065em 6px; max-width: unset;">的框</code></p>
<h1 data-line="12" style="line-height: 1.5; margin-bottom: 8px; padding-bottom: 8px; font-size: 18px; padding: 5px 10px; text-align: center; width: fit-content; font-weight: 800; border-bottom: 3px solid #212122; margin: 40px auto;">先有文章再有博客</h1>
<pre data-line="16" class="language-typescript" style="overflow-x: auto; -webkit-overflow-scrolling: touch; position: relative; background: hsl(230, 1%, 98%); color: hsl(230, 8%, 24%); font-family: 'Fira Code', 'Fira Mono', Menlo, Consolas, 'DejaVu Sans Mono', monospace; direction: ltr; text-align: left; white-space: pre; word-spacing: normal; word-break: normal; line-height: 1.5; -moz-tab-size: 2; -o-tab-size: 2; tab-size: 2; -webkit-hyphens: none; -moz-hyphens: none; -ms-hyphens: none; hyphens: none; margin: 0.5em 0; overflow: auto; border-radius: 0.3em; padding: 0;"><section class="code__header" style="display: flex; justify-content: space-between; align-items: center; font-size: 12px; height: 30px; line-height: 30px;"><span class="code__tools" style="display: flex; align-items: center; padding: 10px 12px; width: 75px;"><span class="red code__circle" style="display: inline-block; align-items: center; width: 9px; height: 9px; margin-right: 8px; padding: 1px; border-radius: 50%; background-color: #ff605c;"></span><span class="yellow code__circle" style="display: inline-block; align-items: center; width: 9px; height: 9px; margin-right: 8px; padding: 1px; border-radius: 50%; background-color: #ffbd44;"></span><span class="green code__circle" style="display: inline-block; align-items: center; width: 9px; height: 9px; margin-right: 8px; padding: 1px; border-radius: 50%; background-color: #00ca4e;"></span></span><span class="code__tools_right" style="width: 75px;"> </span></section><pre style="overflow-x: auto; font-family: Operator Mono, Consolas, Monaco, Menlo, monospace; overflow: auto; -webkit-overflow-scrolling: touch; position: relative; line-height: 1.75; margin: 0;" data-title="true"><code class="language-typescript code-highlight" data-line="16" style="float: left; background: hsl(230, 1%, 98%); color: hsl(230, 8%, 24%); font-family: 'Fira Code', 'Fira Mono', Menlo, Consolas, 'DejaVu Sans Mono', monospace; direction: ltr; text-align: left; white-space: pre; word-spacing: normal; line-height: 1.5; -moz-tab-size: 2; -o-tab-size: 2; tab-size: 2; -webkit-hyphens: none; -moz-hyphens: none; -ms-hyphens: none; hyphens: none; min-width: 100%; font-size: 12px; word-break: normal; display: block; flex: none; padding: 12px; padding-top: 0; max-width: unset;"><span class="code-line" style="display: block; padding-left: 16px; padding-right: 16px; margin-left: -16px; margin-right: -16px; border-left: 4px solid rgba(0, 0, 0, 0);"><span class="token keyword" style="color: hsl(301, 63%, 40%);">const</span> a <span class="token operator" style="color: hsl(221, 87%, 60%);">=</span> <span class="token number" style="color: hsl(35, 99%, 36%);">1</span><span class="token punctuation" style="color: hsl(230, 8%, 24%);">;</span>
</span><span class="code-line" style="display: block; padding-left: 16px; padding-right: 16px; margin-left: -16px; margin-right: -16px; border-left: 4px solid rgba(0, 0, 0, 0);"><span class="token template-string"><span class="token template-punctuation string" style="color: hsl(119, 34%, 47%);">`</span><span class="token template-punctuation string" style="color: hsl(119, 34%, 47%);">`</span></span>`x
</span></code></pre></pre></section></section>

而这是它的渲染结果是这样:

1-img-20241103171168.png

所以粘贴进其他编辑器的内容是什么? html

更准确的说是具有内联样式的html

那为什么可能你复制完再去粘贴,可能看不到这个html内容

这是因为navigator.clipboard 同时设置了两种类型的文本,你在支持富文本的编辑器内粘贴,就会使用html内容,你在不支持富文本的文件内粘贴,就会只保留文本。

const htmlData = new Blob([yourHTML], { type: 'text/html' })
const textData = new Blob([yourText], { type: 'text/plain' })
const clipboardItem = new ClipboardItem({ 'text/html': htmlData, 'text/plain': textData})

然后使用navigator.clipboard写入到粘贴板:

await navigator.clipboard.write([clipboardItem])

从使用技术手段实现的角度:navigator.clipboard.write可以把带有内联样式的html代码写入到粘贴板,目标编辑器就可以粘贴出带有样式的内容。

所以问题变成了:怎么把自己的博客里的文章转换为带有内联样式的html代码

如何获取到文章的样式

获取文章样式这个操作,让任何一个前端都能写出来,但这里明显不能用简单的style属性

因为影响样式的css,可能是内联样式,也可能是通过外部引入的css。

所以这里我用了 getComputedStyle 这个方法,传入DOM,它可以获取到DOM元素最终的样式

什么意思? 意思就是仅靠这一个方法就可能实现这个功能!

所以方案就很明确了:用getComputedStyle获取到样式,转换为style="xxx" 这样的内联样式,插入到原有的html中。

这一步转换有没有插件?有,我搜罗了几个开源项目,基本都是使用了 juice 这个插件。

它可以让你传入html,在传入css,然后帮你拼接成具有内联样式的html。所以它适合在你知道了自己的css在哪里的场景,也就是一个在线的markdown编辑器里。

我要是解决的就是脱离在线编辑器,所以肯定是不能走这个路子。

虽然getComputedStyle获取到样式有几百个至多,而又有那么多的元素,直接原封不动的拼接,内容肯定是太多太大了。

但好在用markdown写文章的人,一般追求的都是简洁大气低调极客,对吧?彦祖。

所以平时用到的markdow语法,其实也是有限的几种。

而渲染后的文章,通常也只有这几个元素:paspanblockquotestrongcode等。

它们分别对应了:段落、超链接、代码块、标注、加粗等。

所以只需要把影响样式的样式属性限制一下,从getComputedStyle里只取这几个!

通过调试得到样式的全部覆盖

先思考一下那些样式影响了文章的样式,列出来:

// 对元素有影响的属性
export const EffectCssAttrs = [
  // 'fontFamily',
  'fontSize',
  'fontWeight',
  'color',
  'textAlign',
  'lineHeight',
  'whiteSpace',
  'textSizeAdjust',
  'overflowX',
  'padding',
  'paddingTop',
  'paddingBottom',
  'paddingLeft',
  'paddingRight',
  ...
]

然后在通过 getComputedStyle 获取到dom的全部样式时,使用此列表过滤:

	const computedCssStyles = getComputedStyle(childDom, null)
    // console.log(`computedCssStyles`, computedCssStyles)
    const _effectCssAttrs = pointCssAttrs.length > 0 ? pointCssAttrs : EffectCssAttrs
    _effectCssAttrs.forEach( cssAttr => {
          const value = computedCssStyles[cssAttr]
          if (value) {
            curCssStyles[cssAttr] = value
          }
        })

这样,我们只需要拿到文章最外层的Dom,循环所有子元素,获取到其有效样式,组合成内联样式

然后再把全部Dom整合起来,就得到了一个带有样式的html字符串

然后再衔接上一小节的navigator.clipboard Api,就已经实现了功能。

但是测试下来,还是有很多需要填补和优化的地方。

比如影响样式的属性列举的不太全,导致有些渲染的不对劲; 比如fontFamily这个明显不需要每个元素都获取一遍的样式需要单独处理; 比如文章太长时,元素太多,复制出来的内容太大,也许精简一下也能得到相同的效果; 比如代码块 pre 元素内,每个span其实只需要color; 比如博客自定义了图片组件用于放大查看,其他展示平台只需要img单个标签等等类似的问题。 比如在A平台有效,在B平台有些样式不支持,需要单独处理。

这些问题列出来看着有点多,但基本都是先把主要功能打通后,逐个解决的,问题不大。

最后再来理一遍思路:

  • 拿到文章最外层的元素,循环处理
  • 封装一个整合单个元素的递归函数(getDomCssStyle),放在循环内,获取到处理后的带有内联样式的html字符串
    • 处理各种特殊情况:无dom、忽略某些nodeType、忽略某些无用的标签(tagName)、忽略某些无用的class(classList)
    • 特殊处理某些组件,如图片 img
    • 设置缓存,优化代码。(针对文章又长又复杂时)
    • 深度优先,有子元素时,先去递归组装好全部子元素
    • 组装最终的dom字符串,return
    • 优化:抽离函数、常量等
  • 使用 ClipboardItem (两种类型 text/plaintext/html )创建实例
  • 使用 navigator.clipboard.write 实例
  • 粘贴进其他编辑器

以上全部语法均基于原生DOM浏览器原生对象

结语

以上就是我为了脱离第三方markdown编辑网站而做出的一个小小功能,没有依赖任何第三方插件,目前已经应用在了我的博客站上,用来往公众号同步。但我的博客站还没搞完,所以先不贴出来了。

功能比较简单,相信大部分人都能实现,但我就是没搜到有类似的插件。大家都是画地为牢,做了一个个功能完全一样的markdown编辑网站...

幸好我解决了这个问题,再也不用发愁啦!

对自建博客或是此插件感兴趣,欢迎关注~