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

此文已由做者劉詩川受權網易雲社區發佈。css

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

 

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

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

 

前端架構

webpack配置

Vue是一個很是優秀的前端MVVM框架,輕量、快速、文檔友好又詳細,代碼組織也很是優雅,是我比較偏心的MVVM架構。Vue官方提供了很是方便快速上手的腳手架Vue-cli,可是因爲跟咱們這邊使用的Java Web架構有一些不太適合的地方,因此我並無使用它,不過我也是對Vue-cli作了一番詳細的學習後來搭建本身的webpack配置。node

下面是個人生產環境的部分webpack配置,其實並不複雜,由於個人業務場景也並不複雜,如今的各類插件功能也足夠強大。react

webpack.prod.config.jswebpack

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',去掉了代碼壓縮等。git

須要注意的一點是,我在生成環境下的webpack配置中使用了vue-loader附帶的postcss預處理器中的cssnano插件進行css部分的代碼壓縮,可是這個插件打包時會將z-index:10壓縮成z-index:1,須要添加設置zindex: false才能避免這個問題,並且cssnano插件默認還有一個特性就是會刪除沒有使用到的css部分,好比咱們爲CSS3動畫所需構建的keyframes,竟然也會被cssnano認爲是沒有被使用的css,壓縮過程當中也刪掉了,這個就有點費解了,因此爲了不這種狀況,咱們須要增長設置discardUnused: false:github

webpack.prod.config.jsweb

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的特性,可使咱們的代碼變得更增強大而簡潔。

以上就是我在開發特定業務需求的富文本編輯器中遇到的一些問題和總結的一些經驗,可能會有一些錯誤,但願幫忙指正。 其餘一些常見的富文本編輯中會遇到的問題,能夠經過學習一些開源的成熟富文本編輯器項目來獲得解答。

 

免費領取驗證碼、內容安全、短信發送、直播點播體驗包及雲服務器等套餐

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

 

相關文章:
【推薦】 利用反向代理測應用的流量
【推薦】 react-native自定義原生組件

相關文章
相關標籤/搜索