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


此文已由作者劉詩川授權網易雲社區發布。

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


最近我們的產品有一個需求是要在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健康發展


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM