Leafer 插件开发教程

前言

Leafer 是新开源的一个 2D 图形绘制库,目前已经有了 1.1k 的 Star 数,成长趋势非常不错,本篇不对 Leafer 过多介绍,不了解的同学可以直接阅读官网介绍,或者阅读我在之前写的 LeaferJS 尝鲜 及 应用数学 文章中也做了简单的介绍

由于目前 Leafer 尚在成长期,现在仍然还只是一个单纯的 图引擎,许多功能可能还需要社区来支持,前段时间写了一个 Leafer 插件,整理了一下 Leafer 插件的开发方法,本篇主要是教大家如何为 Leafer 实现一个简单的 Tooltip 插件,以供社区开发者参考。

本来本篇文章应当更早一些就发出来了,但是为了保证严谨,一边阅读 Leafer 插件的源码,一遍撰写,在撰写过程中发现了一个问题,排查了很久才确定,提交 issue 后才继续撰写本文,这也就导致了延期发布。

插件系统介绍

插件系统是现在开源库很多都会实现的一种能力,为了方便社区提供更灵活的功能,插件系统必不可少。

在 Leafer 发布 v1.0.0-beta.8 发布之前,Leafer 插件具有很大的限制,一个实例只能使用一个插件,但在 beta.8 中,加入了全局实例的生命周期钩子,不必单独在实例中指定,如此灵活性得到了大幅提升。

创建插件

如果要将插件发布 npm 包,则约定命名方式为 leafer-xxx-plugin , 这样方便后续其他开发者进行搜索。

插件属性

属性 说明 类型 默认值
name 可选参数,插件的 npm 包名 string -
import 指定需要从 leafer-ui 中导入的方法 string[] []
importVersion 当前使用的 leafer-ui 版本号,用于检查用户 leafer-ui 版本是否兼容 string -

插件方法

方法 说明 回调参数
run 插件的主程序,自动运行, 支持传入配置对象 config LeaferUI: IObject, config?: IObject
onLeaefer leafer 实例创建钩子,当 leafer 实例创建完成后,会调用 leafer: ILeafer

创建示例

创建插件

@leafer-ui/interface 包含了所有类的接口,如 ILeafer 对应 Leafer,需要单独安装。

  • run 函数是插件的主函数
    • 第一个参数 LeaferUI: 是 import 属性中声明导入的方法和类
    • 第二个参数 config: 是用户使用插件时传入的参数,插件可以指定需要哪些参数。
  • onLeafer 函数是 Leafer 创建时的生命周期钩子
    • 该函数是为了解决插件无法获取所有 Leafer 实例的问题
    • 参数 leafer 传入的是当前创建的 Leafer 实例
import { IPlugin, IObject, ILeafer } from '@leafer-ui/interface'

export const plugin: IPlugin = {
    importVersion: '1.0.0.beta.2',
    import: ['Leafer'],
    run(LeaferUI: IObject, config?: IObject): void {
        console.log(LeaferUI, config) // { Leafer }, { }
    },
    onLeafer(leafer: ILeafer): void {
        console.log(leafer) // on created leafer
    }
}

使用插件

  • 使用插件时只需要拿到 plugin 对象,并通过 usePlugin() 传入插件对象和自定义参数即可。
import { usePlugin, Leafer } from 'leafer-ui'

import { plugin } from './create'

const config = {}

usePlugin(plugin, config)

new Leafer({ view: window })

usePlugin 源码

以下是 usePlugin 中的实现

PluginManager

PluginManager 是维护了全局的插件

  • power: Leafer 中所有的方法
  • list: 当前已注册的插件
  • onLeafer: leafer create 钩子

这里就不贴注入 PluginManager.power 与调用 PluginManager.onLeafer 的方法了,有兴趣的可以在 leafer-ui 仓库中自行查阅。

usePlugin

usePlugin 中,首先将 plugin 注册到 PluginManager.list 中,要注意到这时 PluginManager 其实就是一个简单的发布-订阅模式,由 usePlugin 进行订阅,在 leafer 创建时执行 PluginManager.onLeafer(leafer) 进行通知发布。

PluginManager.power 在程序运行一开始就被注入了平台相关的所有能力,如 web 端注入了 @leafer-ui/web, node 端注入了 /packages/platform/node 相关能力。

通过 plugin.import 获取插件开发者所需依赖,将 Leafer 方法按需将依赖注入到 realParams 中转换为真实参数,再调用 plugin.run 传入转换后的真实参数和用户配置。

实现一个提示框插件

接下来,将会讲解如何实现一个提示框插件。

在正式开始前,我们先介绍一个方法:LeaferTypeCreator

LeaferTypeCreator

LeaferTypeCreator 是用于提供 Leafer 实例额外的能力,其实类似与插件。

创建一个 LeaferType

下面注册了一个名为 background 的 LeaferType, 当有 Leafer 类型指定为 background 时,会调用 backgroundType 这个方法。

import { LeaferTypeCreator } from '@leafer/interface'

LeaferTypeCreator.register('background', backgroundType)

function backgroundType(leafer: ILeafer) {
    leafer.fill = 'red';
}

使用 LeaferType

在实例化 Leafer 时,指定 type 为 background, 此时 Leafer 背景色会变为 红色。

const leafer = new Leafer({ view: window, type: 'background' })

可以看到,我们通过 LeaferTypeCreator 也可以实现类似于插件的能力,但缺点是每个 Leafer 只能指定一个类型,不能同时作用于多个 LeaferType。

实现提示框

我将整个实现过程分为三步进行讲解,让大家更容易理解这个插件具体要做的事情。

1. 需求分析

一个很常见的场景,一个页面中有很多元素,要展示各种各样的信息,为了避免画布上文字过多,且不易于直观阅读,我们需要一个小工具,使我们能在数据悬浮时展示相应的信息。

如下图是一个 Echarts 示例,鼠标放到数据点上,展示当前点位更详细的信息。

2. 实现思路

提示框可以完全使用 Leafer 实现,但使用 HTML 实现更加通用,且更容易控制样式。

我们可以监听用户鼠标在 Leafer 实例当前的位置,同时监听 Leafer.view 鼠标移动事件,这样,当我们确定要触发事件时,只需要使用 dom 位置即可,不需要再进行位置换算。

如下图,我们只需要拿到 pageX 与 pageY , 就能确定当前鼠标的位置,如果只依赖 Leafer 坐标则还需要进行大量的判断如:画布平移、缩放等等。

3. 具体实现

我们实例只实现关键部分,不对扩展功能进行具体实现。

创建 Plugin 对象

在上面我们讲了,onLeafer 会在所有的 Leafer 实例创建时调用,而 LeaferTypeCreator 可以指定 Leafer 使用时才生效,这样,我们就可以根据用户参数,判断这个插件是需要全局生效还是指定的 Leafer 实例生效。

这样做的好处是,插件的作用域是可控的,不会因此导致所有的 Leafer 都必须使用插件。

由于我们只有在 run 函数中才能接收到插件调用者传入的 config, 所以当我们想在 onLeafer 中使用 用户传入的 configLeaferUI 时,必须将 LeaferUIconfig 挂载到当前对象上。

import { ILeafer, IObject, IPlugin, IUI } from '@leafer-ui/interface';

// 用户配置参数类型
type UserConfig = {
  type?: registerType,
  getContent: (node: IUI) => string,
}

export const plugin: IPlugin = {
    name: 'tooltip-plugin',
    importVersion: '1.0.0-beta.8',
    import: ['LeaferTypeCreator', 'LeaferEvent', 'PointerEvent'],
    config: null,
    LeaferUI: {},
    run(LeaferUI: IObject, config: UserConfig): void {
	    // 将 config 与 导入的 LeaferUI 挂载到当前插件对象
        this.config = config;
        this.LeaferUI = LeaferUI;
        // 如果用户传入了自定义的注册类型,则注册一个类型插件
        if (config.type) {
            const LeaferTypeCreator = LeaferUI.LeaferTypeCreator;
            LeaferTypeCreator.register(config.type, (leafer: ILeafer) =>
                tooltipPluginType(leafer, LeaferUI, config)
            );
        }
    },
    onLeafer(leafer: ILeafer) {
	    // 只有当用户没有传入类型时才会全局生效
        if (!this.config?.type) {
            tooltipPluginType(leafer, this.LeaferUI, this.config);
        }
    },
};

tooltipPluginType

通过 PointerEvent.MOVE 可以监听 leafer 中的鼠标移动事件,我们可以根据这个事件捕获画布上的元素。

通过 LeaferEvent.VIEW_READY 可以监听 Leafer 实例化时的事件,这时可以通过 event.view 拿到画布的DOM 元素,我们可以直接监听 DOM 的鼠标移动事件,当 PointerEvent.MOVE 捕获到画布元素时进行展示 Tooltip。

/**
 * @param { ILeafer } leafer leafer 实例
 * @param { IObject } LeaferUI Leafer UI
 * @param { UserConfig } config 用户自定义配置
 */
function tooltipPluginType(
    leafer: ILeafer,
    LeaferUI: IObject,
    config: UserConfig
) {
    const { PointerEvent, LeaferEvent } = LeaferUI;
    const domId = Math.random().toString(32).slice(2, 10);

    let mouseoverNode: IUI | null = null;
    // leafer 鼠标移动事件,用于捕获节点
    leafer.on(PointerEvent.MOVE, (evt) => {
        const node = evt.target;
        // 如果是 Leaefer 实例,则不进行展示,同时隐藏弹窗
        if (node.isLeafer) {
            mouseoverNode = null;
            const tooltipDOM = document.getElementById(domId);
            if (tooltipDOM) {
                tooltipDOM.style.display = 'none';
            }
            return;
        }
        mouseoverNode = node;
    });

    // 挂载画布事件
    leafer.on(LeaferEvent.VIEW_READY, () => {
        if (!(leafer.view instanceof HTMLElement)) return;
		// 监听 dom 鼠标移动事件,如果同时 leafer 中捕获到了元素,则进行展示。
        leafer.view.addEventListener('mousemove', (event: MouseEvent) => {
            if (!mouseoverNode) return;
            createTooltip(domId, event, mouseoverNode, config);
        });
    });
}

createTooltip

创建弹框的部分就比较简单了,这里就不展开讲了,代码中关键步骤都加入了注释。

/**
 * 创建提示元素
 * @param { string } domID dom id
 * @param { MouseEvent } event DOM 事件
 * @param { IUI } node 触发事件的元素
 * @param { UserConfig } config 用户传入的配置
 * @returns
 */
function createTooltip(
    domId: string,
    event: MouseEvent,
    node: IUI,
    config: UserConfig
) {
	// 拿到需要显示的 html 字符串
    const content = config.getContent(node);
    
	// 判断当前插件是否生成 dom, 如果生成了,则复用之前的,否则就创建一个
    let container: HTMLElement | null = document.getElementById(domId);
    const isExists = container !== null;
    if (!isExists) {
        container = document.createElement('div');
    }
    if (container === null) return;

    container.setAttribute('id', domId);
    container.innerHTML = content;
    // 添加样式
    const cssStyle =  {
        display: 'block',
        position: 'absolute',
        left: event.pageX + 4 + 'px',
        top: event.pageY + 4 + 'px',
		padding: '8px 10px',
		backgroundColor: '#fff',
		borderRadius: '2px',
		boxShadow: '0 0 4px #e2e2e2',
    }
    Object.keys(cssStyle).forEach((prop: any) => {
	  container.style[prop] = cssStyle[prop]; 
    });
    // 如果之前不存在,则将创建的 dom 追加到 body
    if (!isExists) {
        document.body.appendChild(container);
    }
}

至此,一个简单的 Tooltip 弹框插件就实现好了,不过这个示例缩减了部分功能和逻辑判断,更完整的实现在文末给出。

一个重复注册插件引发血案

这是在撰写本文时发现的一个问题,同一个插件 重复注册 会引起一个 bug。

在上文中我们已经知道了,我们只有在 run 函数中才能拿到用户传入的 config, 如果我们在 onLeafer 中想要使用,只能通过挂载到当前插件对象上实现。

示例代码

export const plugin: IPlugin = {
    name: PLUGIN_NAME,
    importVersion: '1.0.0-beta.8',
    import: ['LeaferTypeCreator', 'LeaferEvent', 'PointerEvent'],
    config: null,
    run(LeaferUI: IObject, config: UserConfig): void {
        // config 挂载到当前对象
        this.config = config;
    },
    onLeafer(leafer: ILeafer) {
       // onLeafer 中拿到 config
       console.log(this.config)
    },
};

如果这个插件只会使用一次没有问题,如果当注册多次时会覆盖之前的插件参数

异常代码

下面提供了一个示例代码, 插件接收一个 myType 参数, 当连续注册多个相同插件时,最后一次注册的插件参数会覆盖前面的参数。

// 模拟一个插件
export const plugin: IPlugin = {
    importVersion: '1.0.0-beta.8',
    import: ['LeaferTypeCreator', 'LeaferEvent', 'PointerEvent'],
    config: null,
    run(LeaferUI: IObject, config: UserConfig): void {
        this.config = config;
    },
    onLeafer(leafer: ILeafer) {
		console.log(this.config)
    },
};


// 第一次注册插件: 
usePlugin(plugin, {
	myType: 'plugin-1',
});
// 此时打开控制台,检查 `PluginManager.list` , config 为: { myType: 'plugin-1' }

// -----------------------------------------------------------------------------

// 第二次注册插件:参数发生修改
usePlugin(plugin, {
	myType: 'plugin-2',
});

// 此时打开控制台,检查 `PluginManager.list` 会发现第一个插件的 `config` 变为第二个注册的参数

注册第一个插件

PluginManager.list 仅有一条,同时 config 为插件注册的 {myType:'plugin-1'}

注册第二个插件

PluginManager.list 有两条数据,两个插件的 config 都是第二个插件注册的 {myType:'plugin-2'}

原因及解决方法

其实产生这个问题的原因很简单,就是由于在 plugin 实现中,直接使用了用户传入的插件对象,在 run 函数执行时所有相同插件对象的 this 指向的都是同一个,这就造成了即便 config 不同,也只有最后配置一个生效的问题。

如我们需要解决这个问题,也很简单,当注册多个相同的插件时,每次注册都对当前的对象进行浅拷贝即可

// 第一次注册插件: 
usePlugin(plugin, {
	myType: 'plugin-1',
});

const plugin2 = { ...plugin };

// 第二次注册插件
usePlugin(plugin2, {
	myType: 'plugin-2',
});

//...注册多次相同插件...

const pluginMore = { ...plugin };

// 第 n 次注册插件
usePlugin(pluginMore, {
	myType: 'plugin-more',
});

对于该问题,已向 leafer 提交了 pr ,在注册插件时,使其内部浅拷贝解决,但根据 leafer 在 issue 的回复,这块的逻辑可能会进一步修改。

结语

这篇文章的主要目的是为弥补官网插件示例过于简单的问题,同时将我在开发插件时的一些思考整理成文章以供社区开发者参阅。

posted @ 2023-08-10 22:14  子洋丶  阅读(74)  评论(3编辑  收藏  举报