此文已由做者刘诗川受权网易云社区发布。css
欢迎访问网易云社区,了解更多网易技术产品运营经验。html
最近咱们的产品有一个需求是要在PC端作一个面向用户的书评编辑器,让用户和编辑在蜗牛读书上能方便快捷的编辑和产出一些优质的文章,它的主要难点就是富文本编辑器部分。前端
这虽然是个业务需求,可是作业务的同时也要兼顾技术,因此在跟需求商量好不支持IE8以后,决定采用Vue来做为前端部分的技术架构。vue
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
})
],
} }]
为了将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的项目,它比较轻量,源码也比较清晰便于二次开发。
要想在WEB端实现富文本编辑,通过我踩的一些坑,我以为最终仍是要回归于DOM的,Vue或者其余MVVM框架确实给开发和维护带来很大的遍历,可是在富文本编辑这块,仍是没有DOM API来的可控。个人方案是根据服务端提供的一篇书评的items,组织出相应的HTML,而后再交给富文本编辑器进行初始化。
当一个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-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图片的DOM节点渲染成图片的状况下,向CDN代理节点请求图片资源反而会比咱们直接向静态资源源站点请求要来的慢,其实这也很好理解:
为了将含有跨域CDN图片的DOM利用HTML5``canvasAPI渲染成图片,咱们就须要为该图片的添加crossOrigin="anonymous"属性,而且为图片的请求URL添加一个时间戳
若是咱们访问的是CDN域名下的图片,同时又为URL添加了一个全新的时间戳,那么这个图片资源的请求对于CDN代理节点来讲确定是全新的,也就是会认为本节点上没有这个资源的缓存
CDN代理节点遇到一个本身没有缓存的资源,它就会向静态资源的源站点去请求,获得结果后再转发给用户,这等于说咱们这个带有时间戳的图片URL的请求,不但没能利用的CDN的缓存提速,反而由CDN代理节点充当了一次中介,这显然会增长资源的返回耗时
上面两图分别就是请求CDN域名图片的耗时和请求源站点图片的耗时,通过屡次测试,能够发现请求CDN域名图片的耗时基本在200ms以上,而向源站点的请求基本都在100ms如下,因此,有的时候,好比这种特殊状况下,请求CDN域名下的资源可能反而会增长请求的耗时。
根据上面提到的流程,须要我把从服务端拿到的一个包含各类类型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自定义原生组件