一文带你入门 tinymce 架构与源码

1,133 阅读11分钟

仓库结构

使用lerna多仓管理,包的目录是modules,整个项目基于ts + gulp 构建

├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── Gruntfile.js
├── Jenkinsfile
├── LICENSE.TXT
├── README.md
├── SECURITY.md
├── bin
├── build.properties
├── dist -> modules/tinymce/dist
├── js -> modules/tinymce/js
├── lerna.json
├── modules # 多包目录
├── node_modules
├── package.json
├── patches
├── tsconfig.demo.json
├── tsconfig.json
├── tsconfig.shared.json
├── versions.txt
└── yarn.lock

modules 里面除了核心tinymce源码目录,还有其他自包含包,如oxide皮肤与oxide-icons-default图标

./modules
├── acid
├── agar
├── alloy
├── boss
├── boulder
├── bridge
├── darwin
├── dragster
├── jax
├── katamari
├── katamari-assertions
├── mcagar
├── oxide # 图标
├── oxide-icons-default
├── phoenix
├── polaris
├── porkbun
├── robin
├── sand
├── snooker
├── sugar
└── tinymce # tinymce 核心源码与demo

主要指令

yarn build # 构建生产包,如zip,cdn,以及tinymce一些静态依赖,如样式,表情,图标
yarn dev # 图标,皮肤构建,tinymce核心源码ts传js, gulp dev 处理依赖的静态资源
yarn start # 启动本地webpack多页环境

了解源码直接的方式,是看怎么使用,从入口开始

从TinyMceDemo里面看到

declare let tinymce: any;

export default () => {
  const textarea = document.createElement("textarea");
  textarea.innerHTML = "<p>Bolt</p>";
  
  textarea.classList.add("tinymce");
  document.querySelector("#ephox-ui").appendChild(textarea);
  tinymce.init({
    // imagetools_cors_hosts: ["moxiecode.cachefly.net"],
    // imagetools_proxy: "proxy.php",
    // imagetools_api_key: '123',
    
    // images_upload_url: 'postAcceptor.php',
    // images_upload_base_path: 'base/path',
    // images_upload_credentials: true,
    skin_url: "../../../../js/tinymce/skins/ui/oxide",
    setup: (ed) => {
      ed.ui.registry.addButton("demoButton", {
        text: "Demo",
        onAction: () => {
          ed.insertContent("Hello world!");
        },
      });
    },
    
    selector: "textarea.tinymce",
    toolbar1: "demoButton bold italic",
    menubar: false,
  });
};

我们可以发现调用了全局注入的tinymce类进行实例化

查看注入的源代码,会发现这里是针对不同环境进行了模块的暴露: 全局注入,commonjs导出

import { tinymce, TinyMCE } from './Tinymce';

declare const module: any;
declare const window: any;

const exportToModuleLoaders = (tinymce: TinyMCE) => {
  if (typeof module === 'object') {
    try {
      module.exports = tinymce;
    } catch (_) {
      // It will thrown an error when running this module
      // within webpack where the module.exports object is sealed
    }
  }
};

const exportToWindowGlobal = (tinymce: TinyMCE) => {
  window.tinymce = tinymce;
  window.tinyMCE = tinymce;
};

exportToWindowGlobal(tinymce);
exportToModuleLoaders(tinymce);

针对暴露的tinymce与TinyMCE,一个是继承了公共API的EditorManager 对象,一个是该对象的类型定义

回到上面的demo代码,我们会看到调用tinymce.int(options) 完成了编辑器的初始化并渲染视图

进入到源码看,就是调用了EditorManager.init(options)

EditorManager

这个类负责了多个Editor实例的创建与管理工作,并基于提供的事件管理将多个Editor类的创建延迟到了视图dom构建完成的时机

  init(options: RawEditorOptions) {
    const self: EditorManager = this;
    let result;

    const createId = (elm: HTMLElement & { name?: string }): string => {
      let id = elm.id;

      if (!id) {
        id = Obj.get(elm, 'name').filter((name) => !DOM.get(name)).getOrThunk(DOM.uniqueId);
        elm.setAttribute('id', id);
      }
      return id;
    };

    const execCallback = (name: string) => {
      const callback = options[name];

      if (!callback) {
        return;
      }

      return callback.apply(self, []);
    };

    const findTargets = (options: RawEditorOptions): HTMLElement[] => {
      if (Env.browser.isIE() || Env.browser.isEdge()) {
        return [];
      } else if (isQuirksMode) {
        return [];
      } else if (Type.isString(options.selector)) {
        return DOM.select(options.selector);
      } else if (Type.isNonNullable(options.target)) {
        return [ options.target ];
      } else {
        return [];
      }
    };

    let provideResults = (editors) => {
      result = editors;
    };

    const initEditors = () => {
      let initCount = 0;
      const editors = [];
      let targets: HTMLElement[];

      const createEditor = (id: string, options: RawEditorOptions, targetElm: HTMLElement) => {
        const editor: Editor = new Editor(id, options, self);
        editors.push(editor);

        editor.on('init', () => {
          if (++initCount === targets.length) {
            provideResults(editors);
          }
        });

        editor.targetElm = editor.targetElm || targetElm;
        editor.render();
      };

      DOM.unbind(window, 'ready', initEditors);
      execCallback('onpageload');

      targets = Arr.unique(findTargets(options));

      Tools.each(targets, (elm) => {
        purgeDestroyedEditor(self.get(elm.id));
      });

      targets = Tools.grep(targets, (elm) => {
        return !self.get(elm.id);
      });

      if (targets.length === 0) {
        provideResults([]);
      } else {
        each(targets, (elm) => {
            createEditor(createId(elm), options, elm);
        });
      }
    };

    DOM.bind(window, 'ready', initEditors);

    return new Promise((resolve) => {
      if (result) {
        resolve(result);
      } else {
        provideResults = (editors) => {
          resolve(editors);
        };
      }
    });
  },

去除一些针对入参的校验代码,我们会发现初始化很简洁

简单来说:等到dom ready之后,调用了initEditors方法,查找可能存在的dom挂载点(可能有多个),针对每个dom挂载点创建Edtior实例 ,并进行对应的dom挂载,之后调用editor.render() 完成视图渲染,针对脱离文档流的Editor进行销毁

Editor

  public constructor(id: string, options: RawEditorOptions, editorManager: EditorManager) {
    this.editorManager = editorManager;

    // Patch in the EditorObservable functions
    extend(this, EditorObservable);
    const self = this;

    this.id = id;
    this.hidden = false;
    const normalizedOptions = normalizeOptions(editorManager.defaultOptions, options);

    this.options = createOptions(self, normalizedOptions);
    Options.register(self);

    this.setDirty(false);

    this.inline = Options.isInline(self);

    this.shortcuts = new Shortcuts(this);
    this.editorCommands = new EditorCommands(this);
    Commands.registerCommands(this);


    this.ui = {
      registry: registry(),
      styleSheetLoader: undefined,
      show: Fun.noop,
      hide: Fun.noop,
      setEnabled: Fun.noop,
      isEnabled: Fun.always
    };

    this.mode = createMode(self);

    // Call setup
    editorManager.dispatch('SetupEditor', { editor: this });
    const setupCallback = Options.getSetupCallback(self);
    if (Type.isFunction(setupCallback)) {
      setupCallback.call(self, self);
    }
  }

去除一些上下文的代码与针对不同环境部署的处理代码,实现编辑器主要能力的为几个模块

Options

对于一个支持丰富插件与配置机制的开源编辑器,如何管理并规范化配置的变更是一个重要的事情,编辑器内部实现了一个Options模块,负责配置的注册,变更与类型校验

Options这个模块,接近1000行的代码,担任起了编辑器上百个开关配置的管理工作

Shortcuts

基于内部封装的事件监听回调,统一处理编辑器的keyup keypress keydown事件,利用策略模式,统一针对注册好的快捷键完成派发,完成自定义的执行逻辑/注册好的command指令

editor.addShortcut('ctrl+a', 'description of the shortcut', () => {
// 执行自定义的逻辑
});
editor.addShortcut('meta+b', '', 'Bold');
// 指定提前注册好的command
editor.on('keyup keypress keydown', (e) => {
  if ((self.hasModifier(e) || self.isFunctionKey(e)) && !e.isDefaultPrevented()) {
    // 尝试对注册好的快捷键事件描述对象进行匹配
    each(self.shortcuts, (shortcut) => {
      if (self.matchShortcut(e, shortcut)) {
        self.pendingPatterns = shortcut.subpatterns.slice(0);

        if (e.type === 'keydown') {
          self.executeShortcutAction(shortcut);
        }

        return true;
      }
    });
// pendingPatterns生效的前提是shortcut.subpatterns,前提是注册pattern 为 xx > xx 分隔
// 搜索整个仓库并没有在快捷键注册的地方见到带 > 的pattern
    if (self.matchShortcut(e, self.pendingPatterns[0])) {
      if (self.pendingPatterns.length === 1) {
        if (e.type === 'keydown') {
          self.executeShortcutAction(self.pendingPatterns[0]);
        }
      }

      self.pendingPatterns.shift();
    }
  }
});

事件管理

贯穿整个Editor的事件的,包含三个模块,最基础的是EventDispatcher实现了一个事件发布订阅器, 第二个是基于EventDispatcher实现了Observable 监听器,提供给其他模块更加抽象的API,并提供模拟了事件冒泡的能力,第三个是基于Observable的EdtiorObservable扩充了部分支持Editor Dom的方法,为Editor继承

EventDispatcher

常见的事件订阅器,并支持外部通过setting的方式传入首次绑定的触发事件

Observable

Observable一般是作为一个基础对象被其他需要使用到的对象继承,提供了dispatch,on,off,once,hasEventListeners 等被业务常用的方法,每个集成了Observer对象的实例,只有出发相关方法后才会创建独属于它的EventDispatcher 实例,对于外部来说,这个EventDispatcher实例是透明的

EditorObservable

继承自Observable,提供了基于dom(根据事件不同,绑定不同的根结点)原生事件的代理 ,并支持自定义事件的发布订阅,通过pendingEvents,实现了native事件的延迟绑定

Command

该类负责了编辑器核心操作指令与指令状态的注册,执行,管理,并配合Shortcut模块完成快捷键到指令执行的调用

编辑器本身提供了一些内置指令与指令状态的注册,从而丰富了编辑器的各种能力

export const registerCommands = (editor: Editor): void => {
  AlignCommands.registerCommands(editor);
  ClipboardCommands.registerCommands(editor);
  HistoryCommands.registerCommands(editor);
  SelectionCommands.registerCommands(editor);
  ContentCommands.registerCommands(editor);
  LinkCommands.registerCommands(editor);
  IndentCommands.registerCommands(editor);
  NewlineCommands.registerCommands(editor);
  ListCommands.registerCommands(editor);
  FormatCommands.registerCommands(editor);
  registerExecCommands(editor);
};

const registerExecCommands = (editor: Editor): void => {
  editor.editorCommands.addCommands({
    mceRemoveNode: (_command, _ui, value) => {
      const node = value ?? editor.selection.getNode();

      // Make sure that the body node isn't removed
      if (node !== editor.getBody()) {
        const bm = editor.selection.getBookmark();
        editor.dom.remove(node, true);
        editor.selection.moveToBookmark(bm);
      }
    },

    mcePrint: () => {
      editor.getWin().print();
    },

    mceFocus: (_command, _ui, value?: boolean) => {
      EditorFocus.focus(editor, value);
    },

    mceToggleVisualAid: () => {
      editor.hasVisual = !editor.hasVisual;
      editor.addVisual();
    }
  });
};

做完以上模块的创建与注册之后

调用Render模块,加载该编辑器的资源

Render

渲染这块,分为JS资源与CSS的加载与执行

针对CSS资源,使用StyleSheetLoader进行加载处理

针对JS资源,使用ScriptLoader 进行加载处理

StyleSheetLoader

针对每个挂载点所在的文档模型,如shadowRoot/document ,提供针对外部css的加载与卸载管理,针对传入的css url会创建对应的stylesheetloader并返回成功/失败的promise结果,支持单个跟批量操作

// 传入url返回promise,会在插入stylesheet成功解析后resolve,失败时拒绝
const load = (url: string): Promise<void> =>
    new Promise((success, failure) => {
      let link: HTMLLinkElement;

      const urlWithSuffix = Tools._addCacheSuffix(url);

      const state = getOrCreateState(urlWithSuffix);
      loadedStates[urlWithSuffix] = state;
      state.count++;

      const resolve = (callbacks: Array<() => void>, status: number) => {
        Arr.each(callbacks, Fun.call);

        state.status = status;
        state.passed = [];
        state.failed = [];

        if (link) {
          link.onload = null;
          link.onerror = null;
          link = null;
        }
      };

      const passed = () => resolve(state.passed, 2);
      const failed = () => resolve(state.failed, 3);

      // Calls the waitCallback until the test returns true or the timeout occurs
      const wait = (testCallback: () => boolean, waitCallback: () => void) => {
        if (!testCallback()) {
          // Wait for timeout
          if ((Date.now()) - startTime < maxLoadTime) {
            setTimeout(waitCallback);
          } else {
            failed();
          }
        }
      };

      // Workaround for WebKit that doesn't properly support the onload event for link elements
      // Or WebKit that fires the onload event before the StyleSheet is added to the document
      const waitForWebKitLinkLoaded = () => {
        wait(() => {
          const styleSheets = documentOrShadowRoot.styleSheets;
          let i = styleSheets.length;

          while (i--) {
            const styleSheet = styleSheets[i];
            const owner = styleSheet.ownerNode;
            if (owner && (owner as Element).id === link.id) {
              passed();
              return true;
            }
          }

          return false;
        }, waitForWebKitLinkLoaded);
      };

      if (success) {
        state.passed.push(success);
      }

      if (failure) {
        state.failed.push(failure);
      }

      // Is loading wait for it to pass
      if (state.status === 1) {
        return;
      }

      // Has finished loading and was success
      if (state.status === 2) {
        passed();
        return;
      }

      // Has finished loading and was a failure
      if (state.status === 3) {
        failed();
        return;
      }

      // Start loading
      state.status = 1;
      const linkElem = SugarElement.fromTag('link', doc.dom);
      Attribute.setAll(linkElem, {
        rel: 'stylesheet',
        type: 'text/css',
        id: state.id
      });
      const startTime = Date.now();

      if (settings.contentCssCors) {
        Attribute.set(linkElem, 'crossOrigin', 'anonymous');
      }

      if (settings.referrerPolicy) {
        // Note: Don't use link.referrerPolicy = ... here as it doesn't work on Safari
        Attribute.set(linkElem, 'referrerpolicy', settings.referrerPolicy);
      }

      link = linkElem.dom;
      link.onload = waitForWebKitLinkLoaded;
      link.onerror = failed;

      addStyle(linkElem);
      Attribute.set(linkElem, 'href', urlWithSuffix);
    });

// 传入url,移除stylesheet
  const unload = (url: string) => {
    const urlWithSuffix = Tools._addCacheSuffix(url);
    Obj.get(loadedStates, urlWithSuffix).each((state) => {
      const count = --state.count;
      if (count === 0) {
        delete loadedStates[urlWithSuffix];
        removeStyle(state.id);
      }
    });
  };

ScriptLoader

与styleloader一样,支持多个与单个script url的加载与卸载管理,并对于提供单个或批量接口,返回promise成功/失败代表执行结果。

与styleloader不同的是,script在加载成功后便会执行,会将其移出文档模型(猜测可以减少内存占用 ?),而对于stylesheet要一直存在于文档模型才能生效。

同时,scriptloader支持直接加载script的同时,也支持将其添加进队列,实现延迟加载

AddOnManager

当plugins,model,theme的js静态资源被scriptLoader加入队列加载之后,他们的入口函数会调用对应的addonManager实例,将其初始化函数(指令注册,快捷键注册,options注册)加入注册管理,使用一个name -> fn 的map保存,这有利于针对model,theme,plugins 根据文件级别做动态加载,同时将其暴露的构造函数纳入管理。

同时,在完成scriptLoader相关资源的加载后,保证model跟theme的基础资源被装载进来后,才会进入Init模块的初始化流程。

可以这么认为,插件是拓展了编辑器的基础能力,没有插件编辑器也能跑,但是插件赋予了编辑器更强大更能想象的空间。

如Model的加载

// ./Model.ts
import Editor from 'tinymce/core/api/Editor';
import ModelManager, { Model } from 'tinymce/core/api/ModelManager';

import * as Table from './table/Table';

const DomModel = (editor: Editor): Model => {
  const table = Table.setupTable(editor);

  return {
    table
  };
};

// 加载调用ModelManage注册的模块,到Init阶段才进行调用
export default () => {
  ModelManager.add('dom', DomModel);
};

// 被scriptLoader加载的模块
import Model from './Model';

Model();

/** *****
 * DO NOT EXPORT ANYTHING
 *
 * IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
 *******/

loadModel -> 调用scriptLoader 加载了 model 的模块代码: Model.ts -> Model.ts 调用ModelManger注册dom的DomDodel方法 -> 加载完其他资源后 -> 调用init -> initModel 注册指令/options/快捷键/添加菜单

这里对于AddOnManager还支持另外一种加载方式,就是在webpack打包环境,考虑自部署环境,不依赖于tinymce的cdn环境,那么可以直接针对plugins,model,theme等模块引入自执行,在init阶段

思考🤔:

借助scriptLoader 与 AddOnManager ,实现加载模块对应的bundle.js并管理对应的注册逻辑 。

如果对于react Plugin组件,有没有好办法去引入呢,目前一个思路,通过单独维护一个 react plugins 数组,来动态import React Plugin组件进行加载,变相实现plugin的按需引入

这么做会不会有加载顺序的问题呢 ? 可以通过一个批量状态管理这些插件初始化后才真正加载编辑器


介绍完三个重要的模块后,针对Render整个流程做一个赘述

在创建完editor实例后,调用Render.render(this)进入加载流程

初始化stylesheetLoader, windowManager,notificationManager,针对不同模式一些兼容处理,将editor纳入editorManager管理,至此你才能从外部通过EdiorManager.get(id)获取到这个editor实例,紧接着调用loadScripts 进入load流程

const loadScripts = (editor: Editor, suffix: string) => {
  const scriptLoader = ScriptLoader.ScriptLoader;

  const initEditor = () => {
    // If the editor has been destroyed or the theme and model haven't loaded then
    // don't continue to load the editor
    if (!editor.removed && isThemeLoaded(editor) && isModelLoaded(editor)) {
      Init.init(editor);
    }
  };

  loadTheme(editor, suffix);
  loadModel(editor, suffix);
  loadLanguage(scriptLoader, editor);
  loadIcons(scriptLoader, editor, suffix);
  loadPlugins(editor, suffix);
  scriptLoader.loadQueue().then(initEditor, initEditor);
};

每个模块会有对应的manager进行管理,同时都基于scriptLoader 实现了按需加载,当scriptLoader.loadQueue() 承诺被完成的时候,所有模块都已经完成了注册,这下进入了init的逻辑

如果是我们自己实现的icon,plugins等要在这个时机之前完成注册,保证之后在Init能够顺利执行

Init

init 模块在最开始,会调用编辑器派发ScriptsLoaded事件,这方便我们外部得知已经完成了加载,我们可以从这个时候进行模块初始化,会依次执行icons,theme,model,plugins的注册函数,构造UI,注册指令,注册options,并根据是否为inline模式创建真正的容器元素,如普通的div元素/iframe元素,并将其编辑器生成的dom节点插入该容器内,再将容器插入文档,视图真正展示

Init 模块根据外部传入的inline,决定调用不同模式的Render,如Inline 与Iframe,根据各自的特性,实现了tinymce的几个主要模块,如 顶部toolbar,中间编辑器模块(如果是Iframe渲染,则是通过iframe创建的标签实现了编辑器区域,实现了js与样式隔离,如果是inline渲染,则是直接实现了基于div.contenteditable的元素实现各种交互行为),侧边栏,底部状态栏,Sink模块(负责管理popup,如菜单,dialog的显示与行为)

Alloy

用于构建编辑器的整体UI布局与交互的原生UI框架,通过声明式实现了代码的约束下,可维护性。

使得tinymce这个编辑器在多个平台环境如React,Vue,Angular,原生环境都能接入使用

但是alloy 约束的配置与UI,过于抽象与复杂,是专门服务于tinymce的类型系统,以及抽象交互行为的。

相关资料

www.tiny.cloud/docs/tinymc…

github.com/tinymce/tin…