ckeditor + vue联合踩坑

背景

系统使用的vue 1.0, 这里使用的 所见即所得的 ckeditor编辑器作为文本内容输入使用的编辑器。

我们知道ckeditor是一个不错的文本编辑器,但是他的文档配置的使用都并不那么简单。

首先他有一套自由的构建手段,和 cmd/amd 加载都不同,其中其数据和交互使用方面都有自己一套规则,和其他编辑器一样,使用了一些特有的操作模式。

在初期选用ckeditor作为编辑器,纯粹只是偷懒,但是没想到的是没有好好选型编辑器带来的坑有这么大,这篇笔记一来是做个记录,二来是为了防止后面使用ckeditor的不要踩相同的坑

功能实现和问题

实现一个即时显示

需要实现一个数据即时显示在预览区的功能,所以这里呢使用如下功能显示:

export default  {
    props: {
        value: {}
    },
    data () {
        return {
            lock: false,
            editor: null,
        }
    },
    ready () {
        CKEDITOR.replace(this.id, this.config)
        this.editor = CKEDITOR.instances[this.id]
        this.editor.setData(this.value)
        this.editor.on('blur', (event) => {
            this.lock = false
            this.value = this.editor.getData()
            this.$emit('blur', event, this.editor.getData())
        })
        this.editor.on('change', (event) => {
            this.value = this.editor.getData()
            this.$emit('change', event, this.editor.getData())
        })
        this.editor.on('focus', (event) => {
            this.lock = true
            this.$emit('focus', event, this.editor.getData())
        })
    },
    watch: {
        'value'(newVal, oldVal){
            if (!this.lock) {
                CKEDITOR.instances[this.id].setData(newVal)
            }
        }
    }
    
}

将value设置为 sync双向绑定即可,如果有vuex的大可直接用vuex来控制。如果是2.0,又没有使用vuex的可以选择用callback更新即可

拷贝上传图片/附件

由于搜索了一圈上传组件,基本没有一个组件可以使用在这里,所以这里拷贝上传图片的功能只能自己来实现了。

目前word的拷贝有专门的方法可以获取,但是这里没有时间研究了,所以就不多写过程了。

对于简单的paste的情况,我大致分为两种情况

第一种是图片,第二种是附件,无论是图片还是附件,都需要将内容上传到服务器,这里选用锚点的方式,在html中定锚,随后使用替换src/href的方式来更新内容

首先锚点需要定一个独立的guid,这里就用最常见的guid的方式取生成,这个随机生成一个一下就好了,发生碰撞的概率很低,可以大胆使用

export default function guid () {
    function S4 () {
        return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1)
    }
    return (S4() + S4() + S4() + S4() + S4() + S4() + S4() + S4())
}

随后在ckeditor上绑定paste,并通过emit调给父级

export default {
    data: {
        // 如果需要可以用progress生成进度条
        progress: 0,
        max: 0,
        editor: null,
        cb: null
    },
    methods: {
        isBase64 (dataUrl) {
            return /^data:image\/[\w\W]+;base64,/.test(dataUrl.substr(0, 100))
        },
        paste (event, done) {
        // 注意done是类似于 cb 的操作,接受格式为: {data:<image|attach>, id: <uuid>, type: <'image'|'attach'> ,[name: <file.name>]}
        // 走图片上传通道处理
        if (event.data.dataTransfer.getFilesCount() > 0) {
            let templateDom = document.createElement('div')
            let fileUploadList = []
            this.progress = 0
            this.max = event.data.dataTransfer.getFilesCount()
            for (let i = 0; i < event.data.dataTransfer.getFilesCount(); i++) {
                // 这里可以直接从ck的event时间里面获取到file,并且直接走的二进制流
                // 不需要转换
                let file = event.data.dataTransfer.getFile(i)
                let fd = new FormData()
                // 上id
                let id = 'guid' + guid()
                fd.append('FromFile', file, file.name)
                // 有file的先检查是不是图片,是图片的直接转img,不是的转附件
                if (file.type.indexOf("image/") !== -1) {
                    let img = document.createElement('img')
                    img.id = id
                    templateDom.appendChild(img)
                    // 走图片接口,并插入img标签
                    fileUploadList.push(uploadImg(fd)
                        .then(data => {
                            this.progress += 1
                            return window.Promise.resolve({
                                data: data,
                                id,
                                type: 'image'
                            })
                        })
                    )
                } else {
                    // 走附件接口,并插入a标签
                    let attach = document.createElement('a')
                    attach.id = id
                    templateDom.appendChild(attach)
                    fileUploadList.push(uploadAttach(fd)
                     .then(data => {
                         this.progress += 1
                         return window.Promise.resolve({
                             data: data,
                             id,
                             type: 'attach',
                             name: file.name
                         })
                     })
                    )
                }
                // 后面加个空格!防止连续的附件看不清楚间隙
                let space = document.createElement('span')
                space.innerHTML = ' '
                templateDom.appendChild(space)
            }
            event.data.dataValue = templateDom.innerHTML
            window.Promise.all(fileUploadList)
                .then((list) => {
                    done(list)
                    templateDom.remove()
                })
                .catch((e) => {
                    this.$alert('图片或附件上传失败!', 'center', 'fail')
                })
            return
        }
        // 走富文本通道处理
        let item = event.data.dataTransfer.getData('text/html')
        let templateDom = document.createElement('div')
        templateDom.innerHTML = item
        // 这里偷懒用了jquery,用原声也是可以的,比如queryselectall
        let $templateDom = $(templateDom)
        let imgs = $templateDom.find('img')
        this.progress = 0
        this.max = imgs.length
        let imglist = []
        imgs.each((i, item) => {
            let src = item.src
            if (!this.isBase64(src)) {
                // 不是base64不处理
                return
            }
            let id = 'guid' + guid()
            item.id = id
            let arr = src.split(','),
                mime = arr[0].match(/:(.*?;)/)[1],
                bstr = atob(arr[1]),
                n = bstr.length,
                u8arr = new Uint8Array(n)
            // 先转Unit8Array在通过blob封装mime最后走formdata上传
            while (n--) {
                u8arr[n] = bstr.charCodeAt(n)
            }
            let fd = new FormData()
            fd.append('FromFile', new Blob([u8arr], {type: mime}), 'img.png')
            imglist.push(uploadImg(fd)
                .then(data => {
                    item.src = data.url
                    this.progress += 1
                    return window.Promise.resolve({
                        data: data,
                        id,
                        type: 'image'
                    })
                })
            )
        })
        event.data.dataValue = templateDom.innerHTML
        if (imgs.length === 0) {
            // 如果imgs是空的直接返回就可以,就是没有插入任何图片
            return $templateDom.remove()
        }
        window.Promise.all(imglist)
            .then((list) => {
                done(list)
                $templateDom.remove()
            })
            .catch(e => {
                console.error(e)
                this.$alert('图片上传失败!', 'center', 'fail')
            })
        }
    }
}

我们在ckeditor.vue中可以这样绑定paste,当然如果上传流程是确定的话也不需要这样绑定,直接吧上述内容放置在ckeditor中即可

// this.editor 就是CKEDITOR.instance[id]取出来的内容
// 可以选择在合适的时机绑定即可
this.editor.on('paste', (event) => {
    this.$emit('paste', event, (list) => {
        for (let item of list) {
            if (item.type === 'image') {
                let obj = this.editor.document.findOne('img#' + item.id)
                obj.setAttribute('id', '')
                obj.setAttribute('src', item.data.url)
                obj.data('cke-saved-src', item.data.url)
            }
            if (item.type === 'attach') {
                let obj = this.editor.document.findOne('a#' + item.id)
                obj.setAttribute('id', '')
                obj.setAttribute('href', item.data.path)
            }
        }
        this.value = this.editor.getData()
    })
})

插入连接

这个功能是最困难的一步,好在在Stack Overflow小伙伴的帮助下成功找到了可以借鉴的组件link

由于我们需要插入连接,但是实际上问题最大的是编写一个合适plugin去处理这些操作,从我的角度来讲,ck 呼出的plugin有他自有的规则,最好的方案是通过按钮呼出到vue,执行完成后,使用callback回调那就是最好的。

不得不说,文档全还是有好处的ck这里提供了比较详细的plugin介绍让我们来学习,大体可以参见 官方文档

反正声明一个 plugin 大致使用这种方法:

CKEDITOR.plugins.add( 'timestamp', {
    icons: 'timestamp',
    init: function( editor ) {
        //Plugin logic goes here.
    }
});

照着葫芦画瓢还是简单的


CKEDITOR.plugins.add('linkage', {
    icons: 'linkage', 
    lang: 'zh', 
    hidpi: true,
    init: function (editor) {
         // 这里添加一个命令到时候用来执行内容
         editor.addCommand('linkage', {
            exec: function () {
                editor.fire('linkage', function (link) {
                    // 这里是linkage的回调了
                });
            }
         });
         // 这里添加一个按钮
         editor.ui.addButton && editor.ui.addButton('LinkageToken', {
             label: '链接',
             command: 'linkage',
             toolbar: 'insert,8',
             icon: this.path + 'icons/linkage.png'
         });

    }
});

然后不难使用,类似于这种方式怼给vue就可以了,不难操作,我们通过回调来将link更新到ckeditor

this.editor.on('linkage', (event, cb) => {
    this.$emit('linkage', event, cb)
})

然后下面就是最大的难点了,怎么划分一个a标签,我们知道html可以这么写

<span>aaa</span><span><span>bbbu <u>sss</u><sup>ssss</sup></span></span><span>sss</span>

我们可以从aaa的第二个字开始一路划到sss的的第二个字,还有跨层划分等等。

不过好在,我们有一个相对现成的组件可以借鉴,就是系统中自带的link组件,这个组件复杂度很高,非常难借鉴,但是要想抄过来勉强用下还是办得到的

这个组件提供了比较多不同类型的上a标签的形式,我们不排除以后会用,最好的手段就是保留那些功能。

首先他提供了一个基础的plugin的组件,叫 link,

这个组件我们可以直接拿来用,里面提供一些基础方法,然后非常暴力的抄袭一下link中找到的一些方法

   var plugin = CKEDITOR.plugins.link,
        initialLinkText;

function insertLinksIntoSelection (editor, data) {
        var attributes = plugin.getLinkAttributes(editor, data),
            ranges = editor.getSelection().getRanges(),
            style = new CKEDITOR.style({
                element: 'a',
                attributes: attributes.set
            }),
            rangesToSelect = [],
            range,
            text,
            nestedLinks,
            i,
            j;
        style.type = CKEDITOR.STYLE_INLINE; // need to override... dunno why.
        for (i = 0; i < ranges.length; i++) {
            range = ranges[i];
            // Use link URL as text with a collapsed cursor.
            if (range.collapsed) {
                // Short mailto link text view (http://dev.ckeditor.com/ticket/5736).
                text = new CKEDITOR.dom.text(data.linkText ||
                    (data.type == 'email' ? data.email.address : data.default || attributes.set['data-cke-saved-href'] ), editor.document);
                // text.setAttribute("style","text-decoration:none;")
                range.insertNode(text);
                range.selectNodeContents(text);
            } else if (initialLinkText !== data.linkText) {
                text = new CKEDITOR.dom.text(data.linkText, editor.document);
                // Shrink range to preserve block element.
                range.shrink(CKEDITOR.SHRINK_TEXT);
                // Use extractHtmlFromRange to remove markup within the selection. Also this method is a little
                // smarter than range#deleteContents as it plays better e.g. with table cells.
                editor.editable().extractHtmlFromRange(range);
                range.insertNode(text);
            }
            // Editable links nested within current range should be removed, so that the link is applied to whole selection.
            nestedLinks = range._find('a');
            for (j = 0; j < nestedLinks.length; j++) {
                nestedLinks[j].remove(true);
            }
            // Apply style.
            style.applyToRange(range, editor);
            rangesToSelect.push(range);
        }
        editor.getSelection().selectRanges(rangesToSelect);
    }
    function createRangeForLink (editor, link) {
        var range = editor.createRange();
        range.setStartBefore(link);
        range.setEndAfter(link);
        return range;
    }
    function editLinksInSelection (editor, selectedElements, data) {
        var attributes = plugin.getLinkAttributes(editor, data),
            ranges = [],
            element,
            href,
            textView,
            newText,
            i;
        for (i = 0; i < selectedElements.length; i++) {
            // We're only editing an existing link, so just overwrite the attributes.
            element = selectedElements[i];
            href = element.data('cke-saved-href');
            textView = element.getHtml();
            element.setAttributes(attributes.set);
            element.removeAttributes(attributes.removed);
            if (data.linkText && initialLinkText != data.linkText) {
                // Display text has been changed.
                newText = data.linkText;
            } else if (href == textView || data.type == 'email' && textView.indexOf('@') != -1) {
                // Update text view when user changes protocol (http://dev.ckeditor.com/ticket/4612).
                // Short mailto link text view (http://dev.ckeditor.com/ticket/5736).
                newText = data.type == 'email' ? data.email.address : attributes.set['data-cke-saved-href'];
            }
            if (newText) {
                element.setText(newText);
            }
            ranges.push(createRangeForLink(editor, element));
        }
        // We changed the content, so need to select it again.
        editor.getSelection().selectRanges(ranges);
    }

接下来我们在应用以上代码前需要对内容作出一定的预处理

 editor.addCommand('linkage', {
// var plugin = CKEDITOR.plugins.link,
 // initialLinkText;
    exec: function () {
        // 获得选区
        var selection = editor.getSelection(),
            // 直接调用plugin的元素获得element
            elements = plugin.getSelectedLink(editor, true),
            firstLink = elements[0] || null;
        // 下面良好照抄的
        if (firstLink && firstLink.hasAttribute('href')) {
            // Don't change selection if some element is already selected.
            // For example - don't destroy fake selection.
            if (!selection.getSelectedElement() && !selection.isInTable()) {
                selection.selectElement(firstLink);
            }
        }
        // 我尝试用这个plugin提供的方法去获得一下内容,实际上并没有什么用
        var data = plugin.parseLinkAttributes(editor, firstLink);
        initialLinkText = editor.getSelection().getSelectedText();
        // 响应一个linkage事件
        editor.fire('linkage', function (link, defaultTitle) {
            // 这里是linkage的回调了
            // 实际上data应该是取不到任何数据的,直接就是{}
            if (data.url) {
                data.url = {}
            }
            data.url.protocol = ''
            data.url.url = link
            // 有默认名称的时候上默认名称,没有那就直接上url,和link保持一致即可
            data.default = defaultTitle
            data.linkText = initialLinkText
            data.type = 'url'
        });
    }
 });

此后使用只需要在响应一次linkage event后,记下cb,随后,cb(url) 即可

这里同样也有一个问题,那就是:

当url是这样的时候:

<a href="aaa">aaa</a><span>ccc</span><a href="ddd">ddd</a>

试图对该行作出修改时将会碰到一个问题,第一个a标签将永远无法获得,这里,在花费一定时间阅读源码后断定问题是出现在_find这个函数这里

/**
 * Looks for elements matching the `query` selector within a range.
 *
 * @since 4.5.11
 * @private
 * @param {String} query
 * @param {Boolean} [includeNonEditables=false] Whether elements with `contenteditable` set to `false` should
 * be included.
 * @returns {CKEDITOR.dom.element[]}
 */
_find: function( query, includeNonEditables ) {
    var ancestor = this.getCommonAncestor(),
        boundaries = this.getBoundaryNodes(),
        // Contrary to CKEDITOR.dom.element#find we're returning array, that's because NodeList is immutable, and we need
        // to do some filtering in returned list.
        ret = [],
        curItem,
        i,
        initialMatches,
        isStartGood,
        isEndGood;

    if ( ancestor && ancestor.find ) {
        initialMatches = ancestor.find( query );

        for ( i = 0; i < initialMatches.count(); i++ ) {
            curItem = initialMatches.getItem( i );

            // Using isReadOnly() method to filterout non editables. It checks isContentEditable including all browser quirks.
            if ( !includeNonEditables && curItem.isReadOnly() ) {
                continue;
            }

            // It's not enough to get elements from common ancestor, because it might contain too many matches.
            // We need to ensure that returned items are between boundary points.
            isStartGood = ( curItem.getPosition( boundaries.startNode ) & CKEDITOR.POSITION_FOLLOWING ) || boundaries.startNode.equals( curItem );
            isEndGood = ( curItem.getPosition( boundaries.endNode ) & ( CKEDITOR.POSITION_PRECEDING + CKEDITOR.POSITION_IS_CONTAINED ) ) || boundaries.endNode.equals( curItem );

            if ( isStartGood && isEndGood ) {
                ret.push( curItem );
            }
        }
    }

    return ret;
}
};

问题出在此处 curItem.getPosition 和 boundaries.startNode.equals( curItem ) 这条两处判断全部失效导致,第一个a标签没有被准确的加入

由于实在无法理解 CKEDITOR.POSITION_FOLLOWING 和 boundaries获取的判断依据,所以这个问题最后选择搁置

blur 的错误使用和click的冲突

原先使用blur时为了确保在离开编辑区的时候,必然会触发一次更新操作,以确保内容是最新的。但是这里带来了一个问题:

由于左侧存在一个列表区域,列表区域内容可以点击后切换内容,在这里操作时, blur优先级在多数情况下低于 click

/**
 * Object used to store private stuff.
 *
 * @private
 * @class
 * @singleton
 */
CKEDITOR.focusManager._ = {
    /**
     * The delay (in milliseconds) to deactivate the editor when a UI DOM element has lost focus.
     *
     * @private
     * @property {Number} [blurDelay=200]
     * @member CKEDITOR.focusManager._
     */
    blurDelay: 200
};

同时由于ck调用blur也是通过focusManager来实现的,本身存在 约200ms的延迟,不过我这边由于中间本身更新有一定的耗时操作,所以这里最终表现为如果api请求速度够快,在100ms内返回值,那么将会出现以下情况

触发click->触发数据加载->触发blur->触发定时器->数据加载完成,并更新到dom—>定时器函数触发,并更新data到value->触发watch->更新value到ckeditor

非常完美的残留的数据,所以凡是使用blur来更新数据的情况下,都需要在其他操作进行前主动触发blur,并不设置任何延迟

if ( CKEDITOR &&  CKEDITOR.instances &&  CKEDITOR.instances[id]) {
    CKEDITOR.instances[id].focusManager.blur(true)
}

还是没有吸取教训,其实还是对页面上用户行为的可控程度不够高,没有完整的控制器去控制整个编辑器的状态,使得大量异常情况的产生大致也是因为这个原因。

内存溢出

其中最严重的溢出需要检讨一下自己,使用了随机id生成ckedior实例,但是在使用完成后并没有销毁带来的问题就是内存爆炸式的增长,这个完全是个人的疏忽导致的错误,并且本可以避免,却最后产生。确实不太应该,好在这个问题还好解决。

第一种方法是接destroy阶段销毁

CKEDITOR.instances[id].destroy(true)

这样就可以了

第二种是选择不销毁,直接复用,注意setdata的时机要对就可以了

此外就是ckeditor本身的内存溢出问题,其plugin中的filter在许多情况下不能正常回收,并且目前还尚未能确定filter具体产生无法回收内容的原因

结语

本次项目踩得坑很多一方面是在构建初期的规划并不良好,一路下来基本上最后全部选择了使用计划方案中b或c方案,并没有使用相对更好的解决策略,此外经验方面的问题也尤为突出,并没有能够很好的预料到一些写法可能产生的巨坑,操作流程的考虑不周全也是本次产生问题的核心,

最为致命的问题是,由于中途更换了主键方式,将一个外键+lang作为复合主键,导致原先基于 主键 的操作,在累计的修改下混乱甚至崩盘的情况,也是在初期没有完善思考项目整体架构的原因,最后在花费一整个下午打草稿理清整个创建/更新/删除的流程下才将逻辑理顺。

而其中大量的坑都是在更换外键 + lang 作为符合主键中埋下的。

对于操作复杂,需要横跨多个组件通讯,涉及巨量状态,不同状态间相互影响的情况下,果断的使用vuex去集合处理全局的状态更新,而不要使用冒泡的方式去处理。这样能显著降低整体项目的耦合程度,减小代码复杂程度。

此外对于具有可能共性的东西,尽量花时间抽象,泛化,而不要选择跟着需求走,因为需求是在快速变更的,如果直接照抄需求制作项目,显然会出现下一次添加内容会变得繁琐的问题。

本文标题:ckeditor + vue联合踩坑

本文链接:https://iceprosurface.com/2018/01/18/2018/ckeditor/index.html

作者授权:除特别说明外,本文由 icepro 原创编译并授权刊载发布。

版权声明:本文使用「署名-非商业性使用-相同方式共享 4.0 国际」创作共享协议,转载或使用请遵守署名协议。