1. 程式人生 > >開發富文字編輯器的一些經驗教訓

開發富文字編輯器的一些經驗教訓

此文已由作者劉詩川授權網易雲社群釋出。

歡迎訪問網易雲社群,瞭解更多網易技術產品運營經驗。


最近我們的產品有一個需求是要在PC端做一個面向使用者的書評編輯器,讓使用者和編輯在蝸牛讀書上能方便快捷的編輯和產出一些優質的文章,它的主要難點就是富文字編輯器部分。

這雖然是個業務需求,但是做業務的同時也要兼顧技術,所以在跟需求商量好不支援IE8之後,決定採用Vue來作為前端部分的技術架構。


前端架構

webpack配置

Vue是一個非常優秀的前端MVVM框架,輕量、快速、文件友好又詳細,程式碼組織也非常優雅,是我比較偏愛的MVVM架構。Vue官方提供了非常方便快速上手的腳手架Vue-cli,但是由於跟我們這邊使用的Java Web架構有一些不太適合的地方,所以我並沒有使用它,不過我也是對Vue-cli做了一番詳細的學習後來搭建自己的webpack配置。

下面是我的生產環境的部分webpack配置,其實並不複雜,因為我的業務場景也並不複雜,現在的各種外掛功能也足夠強大。

webpack.prod.config.js

devtool: 'source-map', plugins: [     new CleanWebpackPlugin(['dist']),     new ExtractTextPlugin('[name].css'),     new webpack.DefinePlugin({         'process.env': {             NODE_ENV: '"production"'         }     }),     new webpack.optimize.CommonsChunkPlugin({         name: 'vendor',         minChunks: function(module, count) {             return (                 module.resource &&                 /\.js$/.test(module.resource) &&                 module.resource.indexOf('node_modules') >= 0             )         }     }),     new webpack.optimize.CommonsChunkPlugin({         name: 'manifest',         filename: 'manifest.js',         chunks: ['vendor']     }),     new webpack.optimize.UglifyJsPlugin({         sourceMap: true,         compress: {             warnings: false         }     }), ]

主要就是借鑑了Vue-cli中的code split思路,開發環境的webpack配置區別不大,只是sourcmap設定改為了devtool: '#cheap-module-eval-source-map',去掉了程式碼壓縮等。

需要注意的一點是,我在生成環境下的webpack配置中使用了vue-loader附帶的postcss前處理器中的cssnano外掛進行css部分的程式碼壓縮,但是這個外掛打包時會將z-index:10壓縮成z-index:1,需要新增設定zindex: false才能避免這個問題,而且cssnano外掛預設還有一個特性就是會刪除沒有使用到的css部分,比如我們為CSS3動畫所需構建的keyframes,居然也會被cssnano認為是沒有被使用的css,壓縮過程中也刪掉了,這個就有點費解了,所以為了避免這種情況,我們需要增加設定discardUnused: false:

webpack.prod.config.js

rules: [{     test: /\.vue$/,     loader: 'vue-loader',     options: {         loaders: {             css: ExtractTextPlugin.extract({                 use: 'css-loader',                 fallback: 'vue-style-loader'             }),             scss: ExtractTextPlugin.extract({                 use: ['css-loader','sass-loader'],                 fallback: 'vue-style-loader'             })         },         postcss: [             require('autoprefixer')({                 browsers: ['> 1%']             }),             require('cssnano')({                 zindex: false,                 discardUnused: false             })         ],      } }]


與Java Web的結合

為了將css檔案抽離出來,我在開發環境也沒有使用Hot Module Reload機制(使用了ExtractTextPlugin抽離css檔案後,修改css樣式不能通過HMR自動更新,需手動重新整理)。

我們部門這邊的Java Web除了一些簡單的靜態活動頁,主要頁面的承載頁都會配置在另外的一個存放freeMarker的ftl檔案的資料夾中,有別於靜態檔案的存放位置,這是部門中的Java Web一直沿用的檔案結構,不好也沒太大必要去改變它。

這就使得Vue-cli或者一些常見的webpack配置中的根據檔案hash生成打包檔案再使用html-webpack-plugin自動注入承載頁的功能不太好實現,所以就需要結合部門自己的情況定製比較符合自己專案的打包流程。

我們有個網站應用自動部署平臺,它的功能除了解析和編譯後端工程程式碼,還會自動分析頁面引用的靜態資源,然後將資源的URL替換為對應的CDN域名的下的資源連結並新增資源MD5值相關的查詢值字尾,比如/static/js/app.js會在自動部署後變成//yuedust.yuedu.126.net/snail_st/static/js/app.js?a63ed8a8。

所以既然目前專案中已經有了CDN域名替換和檔案hash計算的功能,我在webpack打包中就沒必要再多此一舉了,而且,我還可以利用這一特性,固定的設定承載頁引用的靜態資源的URL,部分程式碼如下:

index.ftl

<!doctype html> <html> <head>     <meta charset="utf-8">     <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">     <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">     <link rel="shortcut icon" href="/static/images/favicon.ico" />     <title>蝸牛閱讀-書評編輯</title>        <link rel="stylesheet" href="/static/bookreview/dist/app.css"> </head> <body>     <input type="hidden" id="csrfToken" name="csrfToken" value="${csrfToken!?html}" />     <div id="app"></div>         <script src="/static/bookreview/dist/manifest.js"></script>     <script src="/static/bookreview/dist/vendor.js"></script>     <script src="/static/bookreview/dist/app.js"></script> </body> </html>

這樣設定好後無論本地開發還是部署線上都不需要再修改ftl檔案的內容了,既有效的利用到了Code Split加快打包速度和快取利用率高的優點,也使得開發和部署變得簡單,頁面引用的靜態資源一旦新增,就不需要再去更改路徑了。

當然,這只是結合自己專案的Java Web工程結構和特點設定的一套webpack使用方式,僅供參考


開發富文字編輯器的教訓

由於專案的時間較緊張,我在頁面上應用了Vue框架的背景下,想當然的想要把Vue也應用於富文字編輯器的開發,事實證明這是不太可行的。

富文字中的資料渲染

Vue是資料和展現雙向繫結的,這使得特定格式的資料渲染成對應的html非常的方便。

但是網頁上的富文字編輯器普遍都是利用的是元素的contenteditable屬性,這個屬性是無法實現雙向繫結的,要想實時儲存富文字資料,只能監控元素的輸入事件,然後讀取元素的innerText後再去修改資料,但是一旦修改了資料,就會觸發Vue的檢視更新,導致你編輯元素的innerText被重新渲染,元素一旦被重新渲染,使用者輸入時的獲取的游標焦點就消失了,而且在windows和mac os下的輸入法實現有些不一樣,mac下的輸入法輸入中文會先將使用者輸入的拼寫填充到輸入元素中,導致獲取的innerText不準確,所以想要利用Vue的資料雙向繫結機制來開發富文字部分,又想要實現資料的實時儲存,存在很多問題。

富文字中的不可編輯區域

我們的書評內容的資料結構是一個各種item型別組成陣列,item的型別有:文字、圖片、書籍和筆記,富文字編輯器需要將這些資料展現出來並且可編輯,其中書籍和筆記的資料結構只能新增或者刪除,而不能修改,這就與傳統的富文字編輯器存在一定的區別,即富文字編輯器區域需要插入或者刪除不能修改的元素。這個需求使得一個普通的富文字編輯器變得特殊起來,一開始我的思路是在contenteditable="true"的編輯器主體內插入contenteditable="false"的dom結構,這導致插入部分的文字無法與編輯器很好的互動,包括刪除、撤銷、選中等,最後找到了另外一種比較理想的解決辦法。


開發富文字編輯器的一些經驗

以下是我在開發一個本業務場景下的富文字編輯器的一些經驗:

在開源富文字編輯器的基礎上開發

知乎上有個問題,叫做為什麼都說富文字編輯器是天坑?,裡面提到的很多開發富文字編輯器會遇到的一些難點,而我的第一版也是想著自己從頭開始開發,但是的確碰到了很多沒想到的問題,修修補補最終結果還是不滿意。

所以如果是需要一個常規功能的富文字編輯器,儘量選擇成熟穩定的開源專案,保證穩定可靠,如果需要像我一樣開發一個符合特定業務場景的富文字編輯器,也儘量在開源專案的基礎上進行二次開發,這樣雖然會有一些程式碼冗餘,但是能幫助你避開許多前人已經踩過的坑,而且也能從閱讀這些專案的原始碼中學習到不少忽視的知識和特性。

我選擇的是國內的一個個人開發者維護的叫做wangEditor的專案,它比較輕量,原始碼也比較清晰便於二次開發。

基於DOM的資料渲染

要想在WEB端實現富文字編輯,經過我踩的一些坑,我覺得最終還是要回歸於DOM的,Vue或者其他MVVM框架確實給開發和維護帶來很大的遍歷,但是在富文字編輯這塊,還是沒有DOM API來的可控。我的方案是根據服務端提供的一篇書評的items,組織出相應的HTML,然後再交給富文字編輯器進行初始化。

基於瀏覽器的document.execCommand API進行開發

當一個HTML文件處於設計模式(designMode)或者一個HTML元素設定了contentEditable="true"時,我們可以使用execCommand方法,執行一些命令來操縱可編輯區域的內容,這個API可以快速可靠的對富文字區域的選區內容進行一系列的操作,最關鍵是,支援撤銷和重做功能,並且在撤銷和重做的過程中能夠完美的保持選區的狀態,這一點非常重要,我們可以通過儲存html來實現內容的撤銷和重做,但是選區或者說游標的撤銷和重做,用Javascript很難完美的控制,如果只是儲存之前選區的range物件,是不能復原選區或者游標的。

具體支援的API可以參考MDN的文件。

即使對於一些文件中不支援的API,也建議通過以上API來組合實現,比如一段HTML內容的替換,應該先通過Javascript建立相應的選區,然後執行delete命令刪除該段內容,再通過insertHTML來插入所需的HTML,這樣才能充分的利用瀏覽器的撤銷和重做功能,並且與其他的操作串聯起來。

富文字中的換行

富文字編輯器中的換行是一個值得注意的問題,我在開發書評編輯器的時候,遇到了一些問題:

富文字中展示換行看起來很容易,有幾個方案,比如設定CSS的white-space再配合換行符,或者在DOM中新增<br>元素,看起來都能達到目的。但是書評編輯器特殊的地方在於,這是一個已經制定好了資料結構並且在客戶端上也有編輯器,這就涉及到Web、iOS、Andorid三個端的一致性問題。

  • 因為在客戶端上是沒有<br>概念的,客戶端編輯器上需要換行位置插入的都是回車符,也就是\n,而這些換行符在WEB上如果需要顯示成換行,就需要設定white-space為pre或者pre-line

  • 如果設定為white-space: pre;,確實可以原樣顯示文字換行,但是如果是這樣一條資料:



這是書評中的一條文字資料,其中有兩個換行符,代表要展示成三行,其中有一個空行,實際需要展示的效果是下圖這樣的:



這樣的資料如果要展示在一個DOM節點中,設定為white-space: pre;,換行雖然保留了,但是由於第一行資料是連續的,white-space: pre;原樣保持了資料的換行,導致了第一行超出了DOM的最大寬度,這樣的方式顯然就行不通了。



  • 如果設定成white-space: pre-line,pre-line可以在正確顯示換行符的同時讓超出一行的文字自動換到下一行,看起來很完美。但是,一旦在換行符之後(比如中間空的那行)輸入文字,問題又出現了,在white-space: pre-line的元素中,如果在換行符之後輸入文字,換行符會被刪除,文字將會跳動到上一行繼續顯示,這樣顯然是不行的。

  • 最終的方案只有剩插入<br>元素來實現換行了,通過<br>實現的換行,不會出現輸入文字換行失效的問題,也不需要父元素設定white-space: pre;,所以我們需要將客戶端在文字中插入的\n轉換成<br>,最後把HTML結構重新解析成書評資料的時候,又需要將它們轉換回來以便保證客戶端編輯和展示的一致性,當然這中間還有一系列的轉換邏輯,包括針對客戶端老版本的編輯器的一些BUG做的相容,最後為了實現一致還是廢了一番功夫的。


富文字中的不可編輯區域

如上面兩圖,我們的書評中有一部分內容是使用者引用的某一本書籍、或是使用者在閱讀時記錄的書籍原文,這些資料結構都是不能被修改的,只能插入或者刪除,一開始我的思路是把該部分DOM結構設定為contenteditable="false",但是這樣的設定程式碼上不管怎麼去彌補體驗上都不夠好。

後來我轉變了思路,既然這就是一段不可編輯只能觀看的DOM,而富文字編輯器裡插入的圖片是能夠很好的與文字一起被很好的操作和維護的,那麼為什麼不把不可編輯的展示區域直接轉換為圖片插入到富文字區域呢,事實證明這個思路最後的體驗非常好,除了一個小的技術問題,下面一點會說明。

將DOM轉換為圖片

要將一個DOM轉化為圖片,社群裡已經有不少很成熟的開源庫可以使用,比如我使用的是dom-to-image,需要注意的就是一個問題:DOM轉化為圖片,基本都利用到了canvas的toDataUrl()功能將圖片轉化轉化為base64編碼的URL,這裡面有一個安全策略,就是如果canvas中繪製的DOM結構中有圖片,而該圖片與當前頁面的域名不一樣(這在我們的開發場景中很常見),出於安全策略的限制,此時瀏覽器是不允許呼叫canvas的toDataUrl()方法的,而我們的書籍卡片中必定會有書籍的封面,該封面的域名是我們的CDN域名,所以轉換成圖片被限制了。



要想解決這個辦法,就涉及到一個前端的IMG標籤的屬性:crossOrigin,如果將這個屬性設定為anonymous,瀏覽器就會為這張圖片的請求的Request Headers 中附帶Origin為當前域名的這一行資訊,告訴圖片所在的靜態資源伺服器,這張圖片我需要跨域訪問以及我的域名,請在圖片的Response Headers中附加Access-Control-Allow-Methods和Access-Control-Allow-Origin這兩行資訊,如下圖:



這樣請求得到的圖片渲染到canvas中,瀏覽器才不會限制該canvas轉化為base64的URL。

這一特性需要服務端的支援,有的服務端就算附加了這個Request Headers欄位依然不會返回想要的Response。

但是在支援這一特性的服務端,有時候設定了crossOrigin="anonymous"依然顯示這個錯誤,不是這個屬性沒生效,而是我們的圖片一般是存放在CDN上的,而CDN為了更快的返回使用者的請求,會把圖片的響應快取下來,而這些快取下來的響應顯然是沒有Access-Control-Allow-Methods和Access-Control-Allow-Origin這兩行資訊的,所以這時候即使我們認為自己的請求包含了crossOrigin="anonymous",CDN伺服器不認為這是一個不同的請求,所以返回給我們的響應是之前就快取好的,導致了這個問題的發生。

這種情況就需要我們為我們請求的圖片URL後新增一個時間戳來避免CDN伺服器的快取。

避免使用CDN來提高渲染速度

前端開發中說到提高頁面的載入速度,一般都會提到最大限度的利用CDN快取靜態資源,以提高靜態資源的訪問速度,從而更快的將網頁內容呈現給使用者。

但是,我上面提到的將含有跨域CDN圖片的DOM節點渲染成圖片的情況下,向CDN代理節點請求圖片資源反而會比我們直接向靜態資源源站點請求要來的慢,其實這也很好理解:

  • 為了將含有跨域CDN圖片的DOM利用HTML5``canvasAPI渲染成圖片,我們就需要為該圖片的新增crossOrigin="anonymous"屬性,並且為圖片的請求URL新增一個時間戳

  • 如果我們訪問的是CDN域名下的圖片,同時又為URL添加了一個全新的時間戳,那麼這個圖片資源的請求對於CDN代理節點來說肯定是全新的,也就是會認為本節點上沒有這個資源的快取

  • CDN代理節點遇到一個自己沒有快取的資源,它就會向靜態資源的源站點去請求,得到結果後再轉發給使用者,這等於說我們這個帶有時間戳的圖片URL的請求,不但沒能利用的CDN的快取提速,反而由CDN代理節點充當了一次中介,這顯然會增加資源的返回耗時




上面兩圖分別就是請求CDN域名圖片的耗時和請求源站點圖片的耗時,經過多次測試,可以發現請求CDN域名圖片的耗時基本在200ms以上,而向源站點的請求基本都在100ms以下,所以,有的時候,比如這種特殊情況下,請求CDN域名下的資源可能反而會增加請求的耗時。

Promise大法好

根據上面提到的流程,需要我把從服務端拿到的一個包含各種型別item的陣列解析成一個HTML字串,其中包含了書籍和筆記型別的item需要轉化成的base64格式的圖片,這就出現了時序上的問題:

文字和圖片型別的item,可以直接得到對應的HTML字串,而書籍和筆記型別的item,則需要通過網路請求和canvas轉換,但是最終我又需要得到整個的初始HTML內容來初始化富文字編輯器,然後再讓使用者可以去在這些HTML DOM節點上進行編輯,這就需要用到Promise.all這個API了,程式碼示例如下:

App.vue

/**  * 將服務端返回的書評items轉換為html string傳輸給富文字編輯器  * @param  {json array} items 書評items  * @return {promise}       所有items處理好後返回resolve(htmlStr), 否則reject(error)  */ convertItemsToHtml(items){     return new Promise ( (resolve, reject) => {         let htmlStr = '';         let itemStr = '';         let itemPromises = items.map( item => {             return new Promise( (resolve, reject) => {                 switch(item.resourceType){                     case 'Text':                         itemStr =  `<p>"Text">${item.text}</p>`;                         resolve(itemStr);                         break;                     ...                     case 'BookNote':                         let $BookNoteEle = $(`<div>${item.bookNote.markText}</div>`).appendTo($('body'));                         domtoimage.toPng($BookNoteEle[0], {style: {opacity: 1, zIndex: 1}})                             .then(function (dataUrl) {                                 itemStr =  `<p>"BookNote"><img >"BookNote" >'${escape(JSON.stringify(item))}' src="${dataUrl}"></p>`;                                 $BookNoteEle.remove();                                 resolve(itemStr);                             })                             .catch(function (error) {                                 console.error('圖片生成失敗', error);                                 reject(error);                             });                         break;                 }             })         })         Promise.all(itemPromises).then( ([...itemStrs]) => {             htmlStr = itemStrs.reduce( (acc, val) => {                 return acc + val             }, '');             resolve(htmlStr);         }).catch( (error) => {             reject(error);         })     }) },

利用Promise.all和其他一些ES6的特性,可以使我們的程式碼變得更加強大而簡潔。

以上就是我在開發特定業務需求的富文字編輯器中遇到的一些問題和總結的一些經驗,可能會有一些錯誤,希望幫忙指正。 其他一些常見的富文字編輯中會遇到的問題,可以通過學習一些開源的成熟富文字編輯器專案來得到解答。


免費領取驗證碼、內容安全、簡訊傳送、直播點播體驗包及雲伺服器等套餐

更多網易技術、產品、運營經驗分享請點選


相關文章:
【推薦】 網易雲易盾中標浙報反作弊服務 助力浙江新聞App健康發展