此文已由作者劉詩川授權網易雲社群釋出。
歡迎通路網易雲社群,了解更多網易技術産品營運經驗。
最近我們的産品有一個需求是要在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:
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自定義原生元件