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 实例
ts 复制代码
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() 传入插件对象和自定义参数即可。
js 复制代码
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 这个方法。

js 复制代码
import { LeaferTypeCreator } from '@leafer/interface'

LeaferTypeCreator.register('background', backgroundType)

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

使用 LeaferType

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

js 复制代码
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 中使用 用户传入的 config 与 LeaferUI 时,必须将 LeaferUI 与 config 挂载到当前对象上。

ts 复制代码
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。

ts 复制代码
/**
 * @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

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

ts 复制代码
/**
 * 创建提示元素
 * @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 中想要使用,只能通过挂载到当前插件对象上实现。

示例代码

ts 复制代码
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 参数, 当连续注册多个相同插件时,最后一次注册的插件参数会覆盖前面的参数。

js 复制代码
// 模拟一个插件
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 不同,也只有最后配置一个生效的问题。

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

js 复制代码
// 第一次注册插件: 
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 的回复,这块的逻辑可能会进一步修改。

结语

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

相关推荐
沙漠之主1 分钟前
C++编程教学设计资料:从入门到实战的完整课程方案
java·前端·c++
hasty5 分钟前
不上传新包,也能改变用户拿到的版本:npm dist-tag 的 OIDC 权限治理
前端·npm·node.js
Csvn35 分钟前
diff 算法(虚拟 DOM Reconciliation)
前端
前端snow2 小时前
ai agent --- 文件存储
前端
Frag0ut2 小时前
Chrome四大版本获取及共存指南:Stable/Beta/Dev/Canary
前端·chrome·浏览器·dev·beta·canary·共存版
IT_陈寒3 小时前
SpringBoot自动配置坑了我一把,原来是这样绕过去的
前端·人工智能·后端
广州华水科技3 小时前
单北斗GNSS变形监测在大坝安全监测中的应用与优势
前端
zeng不错3 小时前
B站 173 个投稿活动看到眼瞎?我写了个扩展,主打一个精准打击
前端
Latchh3 小时前
前端导出的JPEG红字发糊,质量要拉到100才清楚
前端·图像处理·人工智能·计算机视觉
默_笙4 小时前
🚦 请三个 AI 考官给 RAG 打分:量化评估实战(下)
前端·javascript