网上好像几乎没有研究ueditor源码的文章,原因可能是ueditor源码太复杂了,接近浏览器代码和word/excel源码。本文分析ueditor源码整体流程逻辑以及重点难点细节。
首先,编辑器是如何实现输入的?本人开始始终不得其解,在源码找不到输入事件绑定的处理函数,后来在白云峰同学的提醒下才顿悟,整个iframe网页就相当于是一个<textarea>元素:
<body class="view" contenteditable="true" spellcheck="false" style="overflow-y: hidden; height: 500px; cursor: text;">
</body>
页面调用ueditor:
<script id="editor" type="text/plain" style="width:100%;height:500px;" ></script> // iframe的container元素
var editor = UE.getEditor('editor');
多次调用可以多实例运行,每个实例都是单独的,编辑器实例保存在UE实例中,从UE.instants[]也可以获取到每个编辑器实例,0就是第一个实例,以此类推,
因此可以不用变量引用编辑器实例:
UE.getEditor('editor');
setTimeout(function(){
UE.instants.ueditorInstant0.setContent('<div>欢迎使用编辑器</div>');
},1000);
执行ueditor文件之后产生三个全局对象:
UEDITORUI - 所有工具按钮插件的api
UE - api入口
UEDITOR_CONFIG - 配置数据
先看ueditor的全局api接口:
window.UE = baidu.editor = window.UE || {}; // UE实例提供ueditor的入口接口,也就是api入口,调用UE的方法才创建真正的编辑器实例
var Editor = UE.Editor = function (options) { // 这是编辑器构造函数
/* 尝试异步加载后台配置 */
me.loadServerConfig(); //所谓异步加载就是用js构造<tag src=url加载文件,正常是直接在网页写<tag src=url 加载文件
UE.Editor.prototype.loadServerConfig = function(){
//用ajax请求http://localhost/plugins/ueditor/ueditor/php/controller.php?action=config&&noCache=1525847581688,返回后台php配置参数,主要是涉及upload的配置参数,其实前端的配置数据可以直接写一个js文件直接从网页写<script src=加载。
element.onload = element.onreadystatechange = function () { // 这是构造<tag src=url加载文件之后再通过onload事件触发执行一个回调
}
}
if(!utils.isEmptyObject(UE.I18N)){ // i18n是语言国际化,就是多语言包
//修改默认的语言类型
me.options.lang = checkCurLang(UE.I18N);
UE.plugin.load(me);
langReadied(me);
}else{
utils.loadFile(document, {
src: me.options.langPath + me.options.lang + "/" + me.options.lang + ".js", //加载zh-cn.js中英文对照语言包
}, function () { // 这个匿名函数回调会执行一次,具体是在哪一次加载文件时执行的不清楚
UE.plugin.load(me); // 这是加载内部插件,执行ueditor文件时会执行UE.plugin.register()注册所有的插件,然后在这里加载所有的插件
UE.plugin = function(){
var _plugins = {};
return {
register : function(pluginName,fn,oldOptionName,afterDisabled){
_plugins[pluginName] = {
optionName : oldOptionName || pluginName,
execFn : fn,
//当插件被禁用时执行
afterDisabled : afterDisabled
}
},
load : function(editor){ // 这就是load()函数,加载插件,所有的插件在执行ueditor文件时已经注册
utils.each(_plugins,function(plugin){ //_plugins是执行register方法产生的插件集合
var _export = plugin.execFn.call(editor); //execFn是plugin的构造函数,执行构造函数产生plugin object {}
utils.each(_export,function(v,k){ // 针对plugin的每一个属性处理一次,把plugin的方法函数保存到编辑器实例中
switch(k.toLowerCase()){
case 'commands':
utils.each(v,function(execFn,execName){
editor.commands[execName] = execFn //editor.commands{}含所有的按钮操作指令
});
});
utils.each(UE.plugins,function(plugin){ // 插件好像分两部分有两种写法,这是针对旧写法插件进行处理
plugin.call(editor); //执行插件构造函数
});
langReadied(me);
});
//loadFile代码:
return function (doc, obj, fn) { // fn就是传入的匿名函数,用<script src=url加载执行js文件之后再执行这个回调函数
doc.getElementsByTagName("head")[0].appendChild(element); // 构造<script src=加载执行zh-cn.js文件
}
}
UE.instants['ueditorInstant' + me.uid] = me; // 如果多实例运行,均存储在UE中,每个实例按id区分,多实例运行可以利用UE.instants[id]找实例,把每个编辑器实例保存在自己定义的全局对象中也可以
}
Editor.prototype = {
render: function (container) { // container是iframe holder,之前已经构造iframe相关的几个div插入网页,render方法构造iframe代码并且把iframe插入网页生效
var me = this,
options = me.options, // options是实例里面的参数,包含config参数
var html = 'iframe里面的html代码';
container.appendChild(domUtils.createElement(document, 'iframe', { // 插入iframe,并执行以下js代码
editor = window.parent.UE.instants['ueditorInstant0']; // ueditor实例保存在iframe的父窗口也就是当前网页窗口
editor._setup(document);
}));
},
_setup: function (doc) {
doc.body.contentEditable = true; // iframe html相当于一个input
}
}
先建立一个UE实例放在全局,入口初始化方法是getEditor。
再看编辑器初始化入口:
UE.getEditor = function (id, opt) {
var editor = instances[id];
if (!editor) {
editor = instances[id] = new UE.ui.Editor(opt); // UE是api入口实例,editor是编辑器实例
editor.render(id); //执行新的render,构造几个/几层container元素,再执行old render,构造iframe代码插入网页
}
return editor;
};
UE.ui.Editor = function (options) {
var editor = new UE.Editor(options); // 这是真正的编辑器实例
var oldRender = editor.render; // UE.editor的render方法(构造iframe插入网页)
editor.render = function (holder) { // 重新构造一个render,构造几个容器元素,然后再调old render构造iframe
utils.domReady(function () { //事件触发异步调度执行
editor.langIsReady ? renderUI() : editor.addListener("langReady", renderUI);
function renderUI() { //事件触发异步调度执行
new EditorUI(editor.options); // 没有接收实例,在其它程序位置不能引用这个实例,在实例的方法中用this引用实例,在事件handler中引用实例(实例“复制”到handler方法中),这是创建实例后如何使用实例的高级方法
function EditorUI(options) {
this.initOptions(options);
UIBase.prototype = {
initOptions:function (options) {
//把options复制到EditorUI实例中
}
this.initEditorUI();
EditorUI.prototype = {
initEditorUI:function () {
//用addeventlistener绑定鼠标操作事件和处理函数
}
}
var newDiv = document.createElement('div'); //在这里创建div插入网页替换<script>元素,并且复制css代码
holder.parentNode.insertBefore(newDiv, holder);
newDiv.style.cssText = holder.style.cssText;
holder = newDiv;
holder.innerHTML = 'xxx';
editor.ui.render(holder); //重新构造iframe外层html代码以及iframe元素代码,render方法代码如下:
UIBase.prototype = {
render:function (holder) {
}
opt.initialFrameHeight = opt.minFrameHeight = holder.offset; //是在这里设置跟随页面写的height
oldRender.call(editor, editor.ui.getDom('iframeholder')); //再执行oldrender,构造iframe插入网页
editor.fireEvent("afteruiready"); // 没有on这个事件的,有何用?
});
};
return editor; //如果没有返回语句,产生的实例是new UE.ui.Editor(opt)实例,由于有返回语句,产生的实例是返回的实例new UE.Editor(options)实例
}
执行ueditor文件时注册所有的插件,执行UE.getEditor()产生new UE.Editor(options)编辑器实例,初始化编辑器,加载插件,绑定鼠标操作事件handler,构造div和iframe。
ueditor的功能组件以plugin插件形式设计,插件代码是ueditor最主要的功能代码,其功能和复杂程度类似word/excel。
ueditor就是一个textarea框,输入文字内容,自动产生html元素,插入图片产生img元素,最终产生的数据是html代码串,提交到后台保存到数据库。
正常显示时是显示iframe里面的html网页,预览html代码时,是显示一个与iframe平级的div,隐藏iframe元素节点不显示:
<div class="CodeMirror-scroll cm-s-default" tabindex="-1" style="position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;">
插入html代码时,先构造一个div,然后用div.innerHTML=插入的html字符串 解析html字符串成为DOM元素对象,再把div.firstChild插入到网页中一个<p>元素里面
生效,比如插入<div>hello</div>,显示hello,不会把<div>显示出来。
再看结束编辑获取编辑器内容的代码:
getContent: function (cmd, fn,notSetCursor,ignoreBlank,formatter) {
var root = UE.htmlparser(me.body.innerHTML,ignoreBlank); // html -> root -> html 解析处理过程非常复杂
return root.toHtml(formatter);
var htmlparser = UE.htmlparser = function (htmlstr,ignoreBlank) {
var re_tag = /<(?:(?:\/([^>]+)>)|(?:!--([\S|\s]*?)-->)|(?:([^\s\/<>]+)\s*((?:(?:"[^"]*")|(?:'[^']*')|[^"'<>])*)\/?>))/g, //html标签的正则匹配表达式,比如<TD vAlign=top background=../AAA.JPG>
htmlstr = htmlstr.replace(new RegExp('[\\r\\t\\n'+(ignoreBlank?'':' ')+']*<\/?(\\w+)\\s*(?:[^>]*)>[\\r\\t\\n'+(ignoreBlank?'':' ')+']*','g'), function(a,b){ // 正则匹配替换
toHtml:function (formatter) {
var arr = [];
nodeToHtml(this, arr, formatter, 0);
function nodeToHtml(node, arr, formatter, current) {
switch (node.type) {
case 'root':
for (var i = 0, ci; ci = node.children[i++];) {
nodeToHtml(ci, arr, formatter, current) // 递归子节点
return arr.join('')
可见获取编辑器的内容就是获取iframe网页的内容body.innerHTML现成的html代码,很简单,但解析处理非常复杂,有内置过滤规则处理,有点类似框架的
template/vnode解析处理,要递归解析处理所有的子节点。
编辑器头部是工具按钮,都是以插件形式实现的,下面以点击“模板”工具按钮为例分析ueditor的工具按钮插件是如何实现的:
点击“模板”弹出dialog对话框,里面是一个iframe,template代码在config.js中,以数组形式,选取一个template时把template代码插入到左边显示预览,点击“确定”按钮关闭对话框把选取的template插入编辑器。
注意dialog会话弹窗层iframe不在编辑器层iframe里面,而是在当前网页里面,当前网页有几个container容器,其中一个放编辑器iframe,一个放dialog iframe,还有编辑器的头部/底部都是单独的容器,都不在编辑器容器里面。凡是跨iframe都有传递数据问题。
iframe网页会执行internal.js建立环境:
dialog = parent.$EDITORUI[window.frameElement.id.replace( /_iframe$/, '' )]; // dialog实例是从父网页获取的,含父网页中编辑器实例
editor = dialog.editor; //当前打开dialog的编辑器实例
dialog.onok = function () {
me.execCommand( "template", obj ); //执行template命令, obj.html就是选取的template代码,me是editor实例
//重写execCommand命令,用于处理框选时的处理
var oldExecCommand = me.execCommand;
me.execCommand = function (cmd, datatat) {
result = oldExecCommand.apply(me, arguments); // oldExecCommand代码如下
execCommand: function (cmdName) {
result = this._callCmdFn('execCommand', arguments);
_callCmdFn: function (fnName, args) {
return cmdFn.apply(this, args);
UE.plugins['template'] = function () {
UE.commands['template'] = {
execCommand:function (cmd, obj) { // cmd=template
obj.html && this.execCommand("inserthtml", obj.html);//再次递归editor实例的execCommand,但这次是执行inserthtml命令,会执行到以下代码
UE.commands['inserthtml'] = { //命令集合->命令->命令函数,命令函数名大都是execCommand,不同的命令函数是不同的代码,只是名字一样
execCommand: function (command,html,notNeedFilter){ // cmdFn,html是template/html代码
range = me.selection.getRange(); // 含start/end container占位元素
var root = UE.htmlparser(html); // 解析template生成类似vnode的元素对象,有children子节点嵌套
me.filterInputRule(root); // 过滤
html = root.toHtml() // 还原成html代码
div.innerHTML = utils.trim( html ); //去掉头尾空格插入到div,由浏览器解析html代码产生DOM
while ( child = div.firstChild ) {
//从第一个子节点开始递归所有的子节点一个一个插入,如果直接用range.insertnode(div)插入也是一样的,但外层有一个<p><br></p>
range.insertNode( child );//在当前选区的开始位置前插入节点,新插入的节点会被该range包含
insertNode:function (node) {
start.insertBefore(node, nextNode); //nextnode是换行元素
start.appendChild(node);
//start是<p>元素,插入到p里面换行元素之前或最后:<p>template<br></p>
}
}
div.innerHTML = utils.trim( html );
range.insertNode( child );
domUtils.remove(pre);
next.appendChild(me.document.createElement('br'));
domUtils.remove(nextNode);
inserthtml命令的函数代码就是把template插入编辑器网页,处理流程逻辑非常复杂深奥,涉及到很细小的细节比如空白换行符处理以及很细微的浏览器兼容性问题。经过长时间细致debug看数据研究源代码,最后发现插入子节点的过程原理如下:
插入div的第一个子节点时,占位元素是<p>​<br></p>,这是编辑器初始化之后以及点击换行之后自动产生的,是插入到<p>中的offset=0位置之前,也是就空白节点之前。
插入之后,把<p>节点分裂成两个<p>如下所示:
<p></p>node<p><br></p>
空白节点已经被删除,然后把空的<p>节点也删除,就变成了node<p><br></p>,之后再插入其它子节点时,是插入到<body>中,offset位置是<p>节点在<body>中的index,也就是插入到body中<p>节点之前,每次插入一个node之后,node的nextSibling就是<p>节点占位元素。
下面研究点击对话框“确定”按钮之后是如何处理的?如何能执行dialog.onok?
debug看dialog iframe网页代码中“确定”按钮代码是:
<div id="edui223_body" unselectable="on" class="edui-button-body edui-default" onmousedown="return $EDITORUI["edui223"]._onMouseDown(event, this);" onclick="return $EDITORUI["edui223"]._onClick(event, this);">
<div class="edui-box edui-icon edui-default"></div>
<div class="edui-box edui-label edui-default">确认</div>
</div>
点击“确定”按钮是执行$EDITORUI["edui223"]._onClick(event, this),这段代码是如何产生的?
dialog插件定义代码:
// ui/dialog.js
(function (){
Dialog = baidu.editor.ui.Dialog = function (options){
this.initOptions(utils.extend({
onok: function (){},
oncancel: function (){},
onclose: function (t, ok){
return ok ? this.onok() : this.oncancel();
},
},options));
this.initDialog();
};
Dialog.prototype = {
initDialog: function (){
},
_hide: function (){
wrapNode.style.display = 'none';
},
open: function (){
this.render();
this.open();
},
close: function (ok){
this._hide();
}
debug看工具按钮比如模板按钮是;
<div id="edui225_body" unselectable="on" title="模板" class="edui-button-body edui-default" onmousedown="return $EDITORUI["edui225"]._onMouseDown(event, this);" onclick="return $EDITORUI["edui225"]._onClick(event, this);">
<div class="edui-box edui-icon edui-default"></div>
<div class="edui-box edui-label edui-default"></div>
</div>
按钮html代码是由button对象的代码构造出来的:
Button = baidu.editor.ui.Button = function (options){}
Button.prototype = {
getHtmlTpl: function (){
return '<div id="##" class="edui-box %%">' +
'<div id="##_state" stateful>' +
'<div class="%%-wrap"><div id="##_body" unselectable="on" ' + (this.title ? 'title="' + this.title + '"' : '') +
' class="%%-body" onmousedown="return $$._onMouseDown(event, this);" onclick="return $$._onClick(event, this);">'
加debug看$$就是$EDITORUI['edui225']。
$EDITORUI['edui225']._onClick(event, this)代码是:
_onClick: function (){
if (!this.isDisabled()) {
this.fireEvent('click');
// ueditor自己的事件系统,对应editor.addListener,触发事件就是执行listener
var EventBase = UE.EventBase = function () {};
EventBase.prototype = { // ueditor自己的逻辑事件系统
addListener:function (types, listener) {
//把listener存储到listeners[]中
},
fireEvent:function () { // fireEvent就是到listeners[]中找listener执行
t = listeners[k].apply(this, arguments);
r = t.apply(this, arguments);
}
}
},
插件绑定了click事件;
UE.plugins['template'] = function () {
this.addListener("click", function (type, evt) { //在template操作过程中并没有执行这个handler
var el = evt.target || evt.srcElement,
range = this.selection.getRange();
var tnode = domUtils.findParent(el, function (node) {
if (node.className && domUtils.hasClass(node, "ue_t")) {
return node;
}
}, true);
tnode && range.selectNode(tnode).shrinkBoundary().select();
});
工具栏按钮点击事件绑定:
var btnCmds = ['undo', 'redo', 'formatmatch',
'bold', 'italic', 'underline', 'fontborder', 'touppercase', 'tolowercase',
'strikethrough', 'subscript', 'superscript', 'source', 'indent', 'outdent',
'blockquote', 'pasteplain', 'pagebreak',
'selectall', 'print','horizontal', 'removeformat', 'time', 'date', 'unlink',
'insertparagraphbeforetable', 'insertrow', 'insertcol', 'mergeright', 'mergedown', 'deleterow',
'deletecol', 'splittorows', 'splittocols', 'splittocells', 'mergecells', 'deletetable', 'drafts'];
for (var i = 0, ci; ci = btnCmds[i++];) {
editorui[ci] = function (cmd) {
var ui = new editorui.Button({
onclick:function () {
editor.execCommand(cmd);
},
但“模板”按钮不在其中,有dialog的按钮是在这儿定义的:
var dialogBtns = {
noOk:['searchreplace', 'help', 'spechars', 'webapp','preview'],
ok:['attachment', 'anchor', 'link', 'insertimage', 'map', 'gmap', 'insertframe', 'wordimage',
'insertvideo', 'insertframe', 'edittip', 'edittable', 'edittd', 'scrawl', 'template', 'music', 'background', 'charts']
};
var ui = new editorui.Button({
onclick:function () {
if (dialog) {
switch (cmd) {
default:
dialog.render();
UIBase.prototype = {
render:function (holder) {
holder.appendChild(el); //构造dialog el插入网页中占位元素(一个固定的浮动块)中
this.postRender();
postRender: function (){
this.addListener('show', function (){
me.modalMask.show(this.getDom().style.zIndex - 2);
});
this.buttons[i].postRender();
postRender: function (){
this.Stateful_postRender();
Stateful_postRender: function (){
if (this.disabled && !this.hasState('disabled')) {
this.addState('disabled');
this.setDisabled(this.disabled)
},
}
}
dialog.open();
open: function (){
this.showAtCenter(); // 执行这个方法显示会话弹窗
showAtCenter: function (){
//设置定位
this._show();
_show: function (){
//dialog和编辑器两个平级浮动块要比z-index,要高过编辑器的zindxe
this.editor.container.style.zIndex && (this.getDom().style.zIndex = this.editor.container.style.zIndex * 1 + 10);
this.fireEvent('show');
baidu.editor.ui.uiUtils.getFixedLayer().style.zIndex = this.getDom().style.zIndex - 4;
}
}
}
}
}