9
头图

每天,我们都在和各种文档打交道,PRD、技术方案、个人笔记等等等。

其实文档排版有很多学问,就像我,对排版有强迫症,见不得英文与中文之间不加空格

所以,最近在做这么一个谷歌扩展插件 chrome-extension-text-formatting,通过谷歌扩展,快速将选中文本,格式化为符合 中文文案排版指北 的文本。

emmm,什么是排版指南?简单来说它的目的在于统一中文文案、排版的相关用法,降低团队成员之间的沟通成本,增强网站气质。

举个例子:

中英文之间需要增加空格

正确:

在 LeanCloud 上,数据存储是围绕 AVObject 进行的。

错误:

在LeanCloud上,数据存储是围绕AVObject进行的。

在 LeanCloud上,数据存储是围绕AVObject 进行的。

完整的正确用法:

在 LeanCloud 上,数据存储是围绕 AVObject 进行的。每个 AVObject 都包含了与 JSON 兼容的 key-value 对应的数据。数据是 schema-free 的,你不需要在每个 AVObject 上提前指定存在哪些键,只要直接设定对应的 key-value 即可。

例外:「豆瓣FM」等产品名词,按照官方所定义的格式书写。

中文与数字之间需要增加空格

正确:

今天出去买菜花了 5000 元。

错误:

今天出去买菜花了 5000元。

今天出去买菜花了5000元。

当然,整个排版规范不仅仅局限于此,上面只是简单列出部分规范内容。而且,这玩意属于建议,很难强迫推广开来。所以,我就想着实现这么一个谷歌插件扩展,一键实现选中文本的格式化。

看个示意图:

适用于各种文本编辑框,当然 Excel 也可以:

当然,这都不是本文的重点

兼容语雀文档遇到的异常场景

因为各个文档平台存在一定的差异性,所以在扩展的制作过程,需要去兼容不同的文档平台(当然,更多的是我自己比较常用的一些文档平台,譬如谷歌文档、语雀、有道云、Github 等等)。

整体来说,整个扩展的功能非常简单,一个极简流程如下:

需要注意的是,上面的操作,大部分都是基于插入到页面的 JavaScript 脚本文件进行执行。

在兼容语雀文档的时候,遇到了这么个有趣的场景。

在上面的第 4 步执行完毕后,在我们对替换后的文本进行任意操作时,譬如重新获焦、重新编辑等,被修改的文本都会被进行替换复原,复原成修改前的状态

什么意思呢?看看下面这张实际的截图:

总结一下,语雀这里这个操作是什么意思呢?

在脚本手动替换掉原选取文件后,当再次获焦文本,修改的内容再会被复原

在一番测试后,我理清了语雀文档的逻辑:

  1. 如果是用户正常输入内容,通过键盘敲入内容,或者正常的复制粘贴,文档可以被正常修改,被保存;
  2. 如果文档内容的修改是通过脚本插入、替换,或者文档内容的修改是通过控制台手动修改 DOM,文档的内容都将会被复原;
  3. 利用脚本对内容进行任意修改后,即便不做任何操作,直接点击保存按钮,文档仍然会被复原为操作前的版本;

Oh,这个功能确实是非常的有意思。它的强悍之处在于,它能够识别出内容的修改是常规正常操作,还是脚本、控制台修改等非常规操作。并且在非常规操作之后,回退到最近一次的正常操作版本

那么,语雀它是如何做到这一点的呢?

由于线上编译混淆后的代码比较难以断点调试,所以我们大胆的猜测一下,如果我们需要去实现一个类似的功能,可能从什么方向入手。

MutationObserver 实现文档内容堆栈存储

首先,我们肯定需要用到 MutationObserver

MutationObserver 是一个 JavaScript API,用于监视 DOM 的变化。它提供了一种异步观察 DOM 树的能力,并在发生变化时触发回调函数。

我们来构建一个在线文档的最小化场景:

<div id="g-container" contenteditable>
    这是 Web 云文档的一段内容,如果直接编辑,可以编辑成功。如果使用控制台修改,数据将会被恢复。
</div>
#g-container {
    width: 400px;
    padding: 20px;
    line-height: 2;
    border: 2px dashed #999;
}

这里,我们利用 HTML 的 contenteditable 属性,实现了一个可编辑的 DIV 框:

接下来,我们就可以利用 MutationObserver,实现对这个 DOM 元素的监听,实现每当此元素的内容发生改变,就触发 MutationObserver 的事件回调,并且通过一个数组,记录下每一次元素改动的结果。

其大致代码如下:

const targetElement = document.getElementById("g-container");
// 记录初始数据
let cacheInitData = '';

function observeElementChanges(element) {
    const changes = []; // 存储变化的数组
    const targetElementCache = element.innerText;

    // 缓存每次的初始数据
    cacheInitData = targetElementCache;
    
    // 创建 MutationObserver 实例
    const observer = new MutationObserver((mutationsList, observer) => {
        // 检查当前是否存在焦点
        mutationsList.forEach((mutation) => {
            console.log('observer', observer);
            const { type, target, addedNodes, removedNodes } = mutation;
            let realtimeText = "";
            
            const change = {
                type,
                target,
                addedNodes: [...addedNodes],
                removedNodes: [...removedNodes],
                realtimeText,
            };
            
            changes.push(change);
        });
        
        console.log("changes", changes);
    });

    // 配置 MutationObserver
    const config = { childList: true, subtree: true, characterData: true };

    // 开始观察元素的变化
    observer.observe(element, config);
}

observeElementChanges(targetElement);

上面的代码,阅读起来需要一点点时间。但是其本质是非常好理解的,我大致将其核心步骤列举一下:

  1. 创建一个 MutationObserver 实例来观察指定 DOM 元素的变化
  2. 定义一个配置对象 config,用于指定观察的选项。在这个例子中,配置对象中设置了

    1. childList: true 表示观察子节点的变化
    2. subtree: true 表示观察所有后代节点的变化
    3. characterData: true 表示观察节点文本内容的变化
  3. 将变化的信息存储在 changes 数组中
  4. changes 数组中的每个元素记录了一次 DOM 变化的信息。每个变化对象包含以下属性:

    1. type:表示变化的类型,可以是 "attributes"(属性变化)、"characterData"(文本内容变化)或 "childList"(子节点变化)。
    2. target:表示发生变化的目标元素。
    3. addedNodes:一个包含新增节点的数组,表示在变化中添加的节点。
    4. removedNodes:一个包含移除节点的数组,表示在变化中移除的节点。
    5. realtimeText:实时文本内容,可以根据具体需求进行设置。

如此一来,我们尝试编辑 DOM 元素,打开控制台,看看每次 changes 输出了什么内容:

可以发现,每一次当 DIV 内的内容被更新,都会触发一次 MutationObserver 的回调。

我们详细展开数组中的两处进行说明:

其中 type 表示这次触发的是 MutationObserver 配置的 config 中的哪一类变化,命中了 characterData,也就是上面提到的文本内容的变化。而 addedNodesremoveDNodes 都为空,说明没有结构上的变化。

两组数据唯一的变化在于 realtimeText 我们利用了这个值记录了可编辑 DOM 元素内文本值内容。

  • 第一次删除了一个句号 ,所以 realtimeText 文本相比初始文本少了个句号
  • 二次操作删除了一个 字,所以 realtimeText 文本相比初始文本少了 复。

后面的数据依次类推。可以看到,有了这个信息,其实我们相当于能够实现整个 DOM 结构的操作堆栈

在此基础上,我们可以在整个监听之前,在 changes 数组中首先压入最开始未经过任何操作的数据。这也就意味着我们有能力将数据恢复到用户的操作过程中的任意一步

利用特征状态,识别用户是否是手动输入

有了上面的changes 数组,我们相当于有了用户操作的每一步的堆栈信息。

接下的核心就在于我们应该如何去运用它们

在语雀这个例子中,它的核心点在于:

它能够识别出内容的修改是常规正常操作,还是脚本、控制台修改等非常规操作。并且在非常规操作之后,回退到最近一次的正常操作版本

因此,我们接下来探索的问题就变成了如何识别一个可输入编辑框,它的内容修改是正常输入修改,还是非正常输入修改。

譬如,思考一下,当用户正常输入或者复制粘贴内容到编辑框,应该会有什么特征信息:

  1. 可以通过 document.activeElement 拿到当前页面获焦的元素,因此可以在每次触发 Mutation 变化的时,多存储一份当前的获焦元素信息,对比内容被修改时的页面获焦元素是否是当前输入框
  2. 尝试判断输入框的获焦状态,可以通过监听 foucsblur 获焦及失焦等事件进行判断
  3. 用户当文本内容改变时,是否有经过触发过键盘事件,譬如 keydown 事件
  4. 用户当文本内容改变时,是否有经过触发过键盘事件的粘贴 paste 事件
  5. 对于直接修改控制台,则可能是除了文本内容外,有 DOM 子树的其他变化,也就是会触发 Mutation 的 childList 变化事件

有了上面的思路,下面我们尝试一下,为了尽可能让 DEMO 好理解,我们稍微简化需求,实现:

  1. 一个输入框,用户正常输入可以改变内容
  2. 当输入框内容通过控制台进行修改,则当元素再次获焦时,恢复到最近一次的手动修改记录
  3. 如果(2)找不到最近一次的手动修改记录,将数据恢复到初始状态

基于此,下面我给出大致的伪代码:

<div id="g-container" contenteditable>这是 Web 云文档的一段内容,如果直接编辑,可以编辑成功。如果使用控制台修改,数据将会被恢复。</div>
const targetElement = document.getElementById("g-container");
// 记录初始数据
let cacheInitData = '';
// 数据复位标志位
let data_fixed_flag = false; 
// 复位缓存对象
let cacheObservingObject = null;
let cacheContainer = null;
let cacheData = '';

function eventBind() {
    targetElement.addEventListener('focus', (e) => {        
        if (data_fixed_flag) {
            cacheContainer.innerText = cacheData;
            cacheObservingObject.disconnect();
            observeElementChanges(targetElement);
            
            data_fixed_flag = false;
        }
    });
}

function observeElementChanges(element) {
    const changes = []; // 存储变化的数组
    const targetElementCache = element.innerText;

    // 缓存每次的初始数据
    cacheInitData = targetElementCache;
    
    // 创建 MutationObserver 实例
    const observer = new MutationObserver((mutationsList, observer) => {
        mutationsList.forEach((mutation) => {
            // console.log('observer', observer);
            const { type, target, addedNodes, removedNodes } = mutation;
            let realtimeText = "";
            
            if (type === "characterData") {
                realtimeText = target.data;
            }
            
            const change = {
                type,
                target,
                addedNodes: [...addedNodes],
                removedNodes: [...removedNodes],
                realtimeText,
                activeElement: document.activeElement
            };
            changes.push(change);
        });
        
        let isFixed = false;
        let container = null;
        
        for (let i = changes.length - 1; i >= 0; i--) {
            const item = changes[i];
            // console.log('i', i);
            if (item.activeElement === element) {
                if (isFixed) {
                    cacheData = item.realtimeText;
                }
                break;
            } else {
                if (!isFixed) {
                    isFixed = true;
                    container = item.target.nodeType === 3 ? item.target.parentElement : item.target;
                    cacheContainer = container;
                    data_fixed_flag = true;
                }
            }
        }
        
        if (data_fixed_flag && cacheData === '') {
            cacheData = cacheInitData;
        }
        
        cacheObservingObject = observer;
    });

    // 配置 MutationObserver
    const config = { childList: true, subtree: true, characterData: true };

    // 开始观察元素的变化
    observer.observe(element, config);
    eventBind();
    
    // 返回停止观察并返回变化数组的函数
    return () => {
        observer.disconnect();
        return changes;
    };
}

observeElementChanges(targetElement);

简单解释一下,大致流程如下

  1. observeElementChanges 上文已经出现过,核心在于记录每一次 DOM 元素的变化,将变化内容记录在 changes 数组中

    1. 多记录了一个 activeElement,表示每次 DOM 元素发生变化时,页面的焦点元素
  2. 每次 changes 更新后,倒序遍历一次 changes 数组

    1. 如果当前页面获焦元素与当前发生变化的 DOM 元素不是同一个元素,则认为是一次非法修改,记录两个标志位 isFixeddata_fixed_flag,此时继续向前寻找最近一次正常修改记录
    2. isFixed 用于向前寻找最近一次正常修改记录后,将最近一次修改的堆栈信息进行保存
  3. data_fixed_flag 标志位用于当元素被再次获焦时(触发 focus 事件),根据标志位判断是否需要回滚恢复数据

OK,此时,我们来看看整体效果:

这样,我们就成功的实现了识别非正常操作,并且恢复到上一次正常数据。

当然,实际场景肯定比这个复杂,并且需要考虑更多的细节,这里为了整体的可理解性,简化了整个 DEMO 的表达。

完整的 DEMO 效果,你可以戳这里体验:[[CodePen Demo -- Editable Text Fixed]](https://codepen.io/Chokcoco/pen/abXNOyx)

一些思考

至于这个功能有什么用?这个就见仁见智了,至少对于开发扩展插件的我而言,是一个非常棘手的问题,当然从语雀的角度而言,更多也许是从安全方面进行考量的。

当然,我们不应该局限于这个场景,思考一下,这个方案其实可以应用在非常多其它场景,举个例子:

  1. 前端页面水印,实现当水印 DOM 的样式、结构、或者内容被篡改时,立即进行水印恢复

当然,破解起来也有一些方式,对于扩展插件而言,我可以通过更早的向页面注入我的 content script,在页面加载渲染前,对全局的 MutationObserver 对象进行劫持。

总而言之,可以通过本文提供的思路,尝试进行更多有意思的前端交互限制。

最后

好了,本文到此结束,希望对你有帮助 :)

想 Get 到最有意思的 CSS 资讯,千万不要错过我的公众号 -- iCSS前端趣闻 😄

如果还有什么疑问或者建议,可以多多交流,原创文章,文笔有限,才疏学浅,文中若有不正之处,万望告知。


chokcoco
12.3k 声望18.5k 粉丝