每天,我們都在和各種文檔打交道,PRD、技術方案、個人筆記等等等。 其實文檔排版有很多學問,就像我,對排版有強迫症,見不得英文與中文之間不加空格。 所以,最近在做這麼一個谷歌擴展插件 chrome-extension-text-formatting,通過谷歌擴展,快速將選中文本,格式化為符合 中文文 ...
每天,我們都在和各種文檔打交道,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 步執行完畢後,在我們對替換後的文本進行任意操作時,譬如重新獲焦、重新編輯等,被修改的文本都會被進行替換複原,複原成修改前的狀態!
什麼意思呢?看看下麵這張實際的截圖:
總結一下,語雀這裡這個操作是什麼意思呢?
在腳本手動替換掉原選取文件後,當再次獲焦文本,修改的內容再會被覆原。
在一番測試後,我理清了語雀文檔的邏輯:
- 如果是用戶正常輸入內容,通過鍵盤敲入內容,或者正常的複製粘貼,文檔可以被正常修改,被保存;
- 如果文檔內容的修改是通過腳本插入、替換,或者文檔內容的修改是通過控制台手動修改 DOM,文檔的內容都將會被覆原;
- 利用腳本對內容進行任意修改後,即便不做任何操作,直接點擊保存按鈕,文檔仍然會被覆原為操作前的版本;
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);
上面的代碼,閱讀起來需要一點點時間。但是其本質是非常好理解的,我大致將其核心步驟列舉一下:
-
創建一個 MutationObserver 實例來觀察指定 DOM 元素的變化
-
定義一個配置對象
config
,用於指定觀察的選項。在這個例子中,配置對象中設置了childList: true
表示觀察子節點的變化subtree: true
表示觀察所有後代節點的變化characterData: true
表示觀察節點文本內容的變化
-
將變化的信息存儲在
changes
數組中 -
changes
數組中的每個元素記錄了一次 DOM 變化的信息。每個變化對象包含以下屬性:type
:表示變化的類型,可以是"attributes"
(屬性變化)、"characterData"
(文本內容變化)或"childList"
(子節點變化)。target
:表示發生變化的目標元素。addedNodes
:一個包含新增節點的數組,表示在變化中添加的節點。removedNodes
:一個包含移除節點的數組,表示在變化中移除的節點。realtimeText
:實時文本內容,可以根據具體需求進行設置。
如此一來,我們嘗試編輯 DOM 元素,打開控制台,看看每次 changes 輸出了什麼內容:
可以發現,每一次當 DIV 內的內容被更新,都會觸發一次 MutationObserver 的回調。
我們詳細展開數組中的兩處進行說明:
其中 type 表示這次觸發的是 MutationObserver 配置的 config 中的哪一類變化,命中了 characterData,也就是上面提到的文本內容的變化。而 addedNodes
和 removeDNodes
都為空,說明沒有結構上的變化。
兩組數據唯一的變化在於 realtimeText
我們利用了這個值記錄了可編輯 DOM 元素內文本值內容。
- 第一次刪除了一個句號
。
,所以realtimeText
文本相比初始文本少了個句號 - 二次操作刪除了一個
復
字,所以realtimeText
文本相比初始文本少了復。
後面的數據依次類推。可以看到,有了這個信息,其實我們相當於能夠實現整個 DOM 結構的操作堆棧!
在此基礎上,我們可以在整個監聽之前,在 changes
數組中首先壓入最開始未經過任何操作的數據。這也就意味著我們有能力將數據恢復到用戶的操作過程中的任意一步。
利用特征狀態,識別用戶是否是手動輸入
有了上面的changes
數組,我們相當於有了用戶操作的每一步的堆棧信息。
接下的核心就在於我們應該如何去運用它們。
在語雀這個例子中,它的核心點在於:
它能夠識別出內容的修改是常規正常操作,還是腳本、控制台修改等非常規操作。並且在非常規操作之後,回退到最近一次的正常操作版本。
因此,我們接下來探索的問題就變成瞭如何識別一個可輸入編輯框,它的內容修改是正常輸入修改,還是非正常輸入修改。
譬如,思考一下,當用戶正常輸入或者複製粘貼內容到編輯框,應該會有什麼特征信息:
- 可以通過
document.activeElement
拿到當前頁面獲焦的元素,因此可以在每次觸發 Mutation 變化的時,多存儲一份當前的獲焦元素信息,對比內容被修改時的頁面獲焦元素是否是當前輸入框 - 嘗試判斷輸入框的獲焦狀態,可以通過監聽
foucs
、blur
獲焦及失焦等事件進行判斷 - 用戶當文本內容改變時,是否有經過觸發過鍵盤事件,譬如
keydown
事件 - 用戶當文本內容改變時,是否有經過觸發過鍵盤事件的粘貼
paste
事件 - 對於直接修改控制台,則可能是除了文本內容外,有 DOM 子樹的其他變化,也就是會觸發 Mutation 的
childList
變化事件
有了上面的思路,下麵我們嘗試一下,為了儘可能讓 DEMO 好理解,我們稍微簡化需求,實現:
- 一個輸入框,用戶正常輸入可以改變內容
- 當輸入框內容通過控制台進行修改,則當元素再次獲焦時,恢復到最近一次的手動修改記錄
- 如果(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);
簡單解釋一下,大致流程如下
-
observeElementChanges 上文已經出現過,核心在於記錄每一次 DOM 元素的變化,將變化內容記錄在
changes
數組中- 多記錄了一個
activeElement
,表示每次 DOM 元素髮生變化時,頁面的焦點元素
- 多記錄了一個
-
每次
changes
更新後,倒序遍歷一次changes
數組- 如果當前頁面獲焦元素與當前發生變化的 DOM 元素不是同一個元素,則認為是一次非法修改,記錄兩個標誌位
isFixed
和data_fixed_flag
,此時繼續向前尋找最近一次正常修改記錄 isFixed
用於向前尋找最近一次正常修改記錄後,將最近一次修改的堆棧信息進行保存
- 如果當前頁面獲焦元素與當前發生變化的 DOM 元素不是同一個元素,則認為是一次非法修改,記錄兩個標誌位
-
data_fixed_flag
標誌位用於當元素被再次獲焦時(觸發 focus 事件),根據標誌位判斷是否需要回滾恢複數據
OK,此時,我們來看看整體效果:
這樣,我們就成功的實現了識別非正常操作,並且恢復到上一次正常數據。
當然,實際場景肯定比這個複雜,並且需要考慮更多的細節,這裡為了整體的可理解性,簡化了整個 DEMO 的表達。
完整的 DEMO 效果,你可以戳這裡體驗:[CodePen Demo -- Editable Text Fixed]
一些思考
至於這個功能有什麼用?這個就見仁見智了,至少對於開發擴展插件的我而言,是一個非常棘手的問題,當然從語雀的角度而言,更多也許是從安全方面進行考量的。
當然,我們不應該局限於這個場景,思考一下,這個方案其實可以應用在非常多其它場景,舉個例子:
- 前端頁面水印,實現當水印 DOM 的樣式、結構、或者內容被篡改時,立即進行水印恢復
當然,破解起來也有一些方式,對於擴展插件而言,我可以通過更早的向頁面註入我的 content script
,在頁面載入渲染前,對全局的 MutationObserver 對象進行劫持。
總而言之,可以通過本文提供的思路,嘗試進行更多有意思的前端交互限制。
最後
好了,本文到此結束,希望對你有幫助