把富文本封装在 shadow DOM 中,要注意些啥?

1,675 阅读3分钟

一、前言

在富文本编辑器的使用过程中,是否常常因为全局 CSS 污染的问题让你很难受,并且在这个点上你了解到了 shadow DOM,你多半会有把富文本放在 shadow DOM 中的可爱想法。

二、shadow DOM 和 shadowRoot

在文章正式开始之前,我们需要先了解下什么是 shadow DOM
shadow DOM 的挂载点 ShadowRoot

我简单总结了下我了解的一些 shadow 特点:

1. shadowRoot 相当于一个阉割的 document,但它只有独立的 css 作用域而没有独立的 js 作用域

独立的 css 域怎么理解呢?其实就相当于给 shadow DOM 内的所有元素都设置了 all: initial css 样式。内部元素的样式在未单独做修饰的情况向,就是浏览器默认样式,完全不受外部样式的影响。

没有独立的 js 域?shadowRootdocument 访问到的是同一个 window 对象,并且 shadowRoot 内部是不支持 <script src="xxx.js"></script> 的形式进行 js 引入的。

2. MutationObserver 监听不到 shadow DOM 的变化

比如:我们定义了一个自定义元素 <is-editable> 并开启了 shadowRoot,只要 <is-editable> 本身没有发生变化,无论其内部的 shadow DOM 如何变化都不会触发 MutationObserver。就像 <video controls></video> 一样,无论你怎么拖进度条都不会触发 MutationObserver 一样的道理。

如果你想查看 video 的 shadow DOM,你可以打开控制台,然后打开设置,然后勾选 Show user agent shadow DOM 即可。

当然,这里说的监听不到是指无法从外部进行监听,如果你能直接拿取到 shadow DOM 中的元素进行 MutationObserver 绑定,那也是可以监听到变化的

试验代码

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <script src="https://cdn.jsdelivr.net/npm/m-observer@0.3.1/dist/index.min.js" type="text/javascript" charset="utf-8"></script>
    </head>
    <body>
        <div class="demo">
            <h3>shadowRoot 中的编辑区</h3>
            <is-editable></is-editable>
            <h3>.demo 中的编辑区</h3>
        </div>
        
        <!-- 使用 <template> 构建 shadow DOM 模板 -->
        <template id="tpl">
            <style type="text/css">
                :host {
                    display: inline-block;
                    width: 100%;
                    margin: 0 0 15px 0;
                }
                .editable {
                    border: 1px solid #999999;
                    box-sizing: border-box;
                    padding: 5px 10px;
                }
                .editable:focus {
                    outline: none;
                }
            </style>
            <div class="editable" contenteditable="true">
                <h3>标题三,<code>h3</code></h3>
                <p>常用正文,<b>加粗</b><i>斜体</i></p>
                <p>常用正文,<b>加粗</b><i>斜体</i></p>
            </div>
        </template>

        <script type="text/javascript">
            const tpl = document.querySelector('#tpl')
            const demo = document.querySelector('.demo')
            demo.append(tpl.cloneNode(true).content)
            
            MObserver.observeAll('.demo', function() {
                console.log('外部的 MutationObserver 监听');
            })
        </script>

        <script type="text/javascript">
            customElements.define('is-editable', class EditableElement extends HTMLElement {
                constructor() {
                    super()
                    this.attachShadow({ mode: "open" })
                    // 找到 <template> 模板
                    const tpl = document.querySelector('#tpl')
                    // 克隆模板并将模板内容添加到当前 shadowRoot 中
                    this.shadowRoot.append(tpl.cloneNode(true).content)
                }

                mutationCallback() {
                    console.log('内部的 MutationObserver 监听');
                }

                // 当自定义元素第一次被连接到文档DOM时被调用
                connectedCallback() {
                    const target = this.shadowRoot.querySelector('.editable')
                    MObserver.observeAll(target, this.mutationCallback)
                }

                // 当自定义元素与文档DOM断开连接时被调用
                disconnectedCallback() {
                    const target = this.shadowRoot.querySelector('.editable')
                    MObserver.remove(target, this.mutationCallback)
                }
            })
        </script>
    </body>
</html>

试验结果

i748y-m7oqt.gif

三、CSS 加载

在前文中我们提到 shadowRoot 是有独立的 CSS 作用域的,那么每一个自定义元素下的 shadow DOM 相关的 CSS 我们都需要进行相关依赖的管理。

例如:我们需要将 katex 封装成自定义元素,那么它的 js 部分我们可以直接用 webpack 打包或用 CDN 直接在 document 中引入,而它的 CSS 部分我们则需要手动进行管理

katex 示例

<!DOCTYPE html>
<head>
    <meta charset="UTF-8">
    <script src="https://cdn.jsdelivr.net/npm/m-observer@0.3.1/dist/index.min.js" type="text/javascript" charset="utf-8"></script>
    <script defer src="https://cdn.jsdelivr.net/npm/katex@0.13.13/dist/katex.min.js" integrity="sha384-pK1WpvzWVBQiP0/GjnvRxV4mOb0oxFuyRxJlk6vVw146n3egcN5C925NCP7a7BY8" crossorigin="anonymous"></script>
</head>
<body>
    <template id="tpl">
        <!-- <link> 引入 css -->
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.13.13/dist/katex.min.css" integrity="sha384-RZU/ijkSsFbcmivfdRBQDtwuwVqK7GMOw6IMvKyeWL2K5UAlyp6WonmB8m7Jd0Hn" crossorigin="anonymous">
        <div class="root"></div>
    </template>

    <script>
        customElements.define('is-katex', class KatexElement extends HTMLElement {
            constructor() {
                super()
                this.attachShadow({ mode: "open" })
                const tpl = document.querySelector('#tpl')
                this.shadowRoot.append(tpl.cloneNode(true).content)
            }

            render() {
                const text = this.getAttribute('data-text')
                katex.render(text, this.shadowRoot.querySelector('.root'))
            }

            // 当自定义元素第一次被连接到文档DOM时被调用
            connectedCallback() {
                this.render()
                MObserver.attributeFilter(this, this.render, ['data-text'])
            }

            // 当自定义元素与文档DOM断开连接时被调用
            disconnectedCallback() {
                MObserver.remove(this, this.render)
            }
        })
    </script>
</body>
</html>

而富文本编辑器如果考虑了扩展性进行了插件化设计,那么我们就无法简单的引入方式将无法达到需求,因此我们需要对 CSS 的依赖进行一套系统的管理。

在之前的尝试中,为此设计了一套简单的 CSS 依赖加载机制,有兴趣的可以翻源码,详解放在此处篇幅就有点长了

register.ts
gcss.ts
mount-gcss.ts

同时,值得注意的是,如果你在 shadowRoot 下使用的依赖库,它的 CSS 是放在 js 中的,而不是独立的 .css 文件,那么你可以寻找替换库了。因为这种库会直接把 css 加载到 document 中,而且你拿取不到!

四、兼容处理 selection

还不知道 Selection?看这里

如果你的富文本编辑器依赖于浏览器选区的支持,那肯定逃不过这部分的坑!

Chromium 内核浏览器的 shadowRoot 中,我们是没法通过 document.getSelectinwindow.getSelection 拿取到有效选区的,我们只能通过 shadowRoot.getSelection 来拿取有效的选区。

但是在其它浏览器上,无论是在 document 中还是在 shadowRoot 中,我们都必须使用 document.getSelectinwindow.getSelection 来获取当前的有效选区。

image.png

试验代码

<!DOCTYPE html>
<html>
    <body>
        <is-editable></is-editable>
        
        <script type="text/javascript">
            customElements.define('is-editable', class EditableElement extends HTMLElement {
                constructor() {
                    super()
                    const shadow = this.attachShadow({ mode: 'open'})
                    shadow.innerHTML = `
                    <div class="root">
                        <p><i>计算机的</i>分开了的<del>据库看圣诞节分</del><u>就开始减肥</u>分开了束带结发来看</p>
                        <p>健身卡积分<b>接口数据的反馈了世界上的</b>借口了决定是否了</p>
                        <p>减税降费积分开始了</p>
                    </div>`
                }
            })
            document.addEventListener('mouseup', function() {
                // 使用 document.getSelection 获取选区
                console.log(`document.getSelection().getRangeAt(0)`);
                console.log(document.getSelection().getRangeAt(0));
                
                // 使用 shadowRoot.getSelection 获取选区
                const el = document.querySelector('is-editable')
                console.log(`el.shadowRoot.getSelection().getRangeAt(0)`);
                console.log(el.shadowRoot.getSelection().getRangeAt(0));
            })
        </script>
    </body>
</html>

试验结果

image.png

image.png

结语

之前的研究就这么多,更深入的细节就只有靠各位自己啦。