思源插件开发 | 使用前端框架要小心内存泄漏风险

如果你是用 svelte、vue、react 等前端框架来开发思源的插件,那么一定要小心这个问题。

现代的前端框架中,往往有两个重要的钩子事件(各个框架的具体命名可能有所不同)

  • onMount
  • onDestroy

而如果在开发的时候符合下面的情况:

  1. 在 Dialog、Tab 中渲染了一个组件
  2. 组件中使用了副作用代码(例如监听器、setInverval 等),并且在 onDestroy 中清理副作用

那就一定注意了:一个不留神可能存在内存泄漏的风险

一个简单的案例

这是一个非常简单的案例,使用 solid 编写(不知道这个的,可以把它当成 react 看,在这里差别不大),组件中使用 setInterval 来更新颜色,然后在 onCleanUp 中清理 timer。

import { showMessage, fetchPost, Protyle, type App } from "siyuan";
import { createEffect, createSignal, onCleanup, onMount } from "solid-js";

const randInt = () => Math.floor(255 * Math.random());

const Component = () => {
    //略

    let [color, setColor] = createSignal({r: 255, g: 255, b: 255});

    let timer = setInterval(() => {
        setColor({
            r: randInt(),
            g: randInt(),
            b: randInt(),
        });
        console.log('update color', color());
    }, 1000);

    onMount(async () => {
        //略
        showMessage("Hello mount", 1500);
    });

    onCleanup(() => {
        showMessage("Hello panel closed", 1500);
        clearInterval(timer);
    });

    return (
        <div class="b3-dialog__content">
            <div class="plugin-sample__time">
                System current time: <span id="time">{time}</span>
            </div>
            {/*略*/}
            <style jsx dynamic>
                {`
                    .plugin-sample__time {
                        background-color: rgb(${color().r}, ${color().g}, ${color().b});
                    }
                `}
            </style>
        </div>
    );
}

export default Component;

然后我们在某个 Dialog 中显示这个组件:

import Component from './hello.tsx';

const simpleDialog = (args: {
    title: string, ele: HTMLElement | DocumentFragment,
    width?: string, height?: string,
    callback?: () => void;
}) => {
    const dialog = new Dialog({
        title: args.title,
        content: `<div class="dialog-content" style="display: flex; height: 100%;"/>`,
        width: args.width,
        height: args.height,
        destroyCallback: args.callback
    });
    dialog.element.querySelector(".dialog-content").appendChild(args.ele);
    return dialog;
}

const showDialog = () => {
    let container = document.createElement('div')
    container.style.display = 'contents';
    /* 渲染组件
       相当于 svelte 的 new Component({target: container})
       vue 的 createApp(Component).mount(container)
    */
    render(() => Component(), container);
    return simpleDialog({...args, ele: container, callback: () => {
        console.log("Bye!");
    }});
}

你注意到什么问题了吗?

如果你没注意到:恭喜你,踩坑了——Component 的 onCleanup 不会被自动调用,timer 也无法被正确清理,你踩中了一个内存泄漏的坑

这里的原因在于,思源的 Dialog 销毁对于框架来说是外部的脚本行为,不涉及到前端组件的生命周期,他们也无法对这种“意外情况”进行响应。

这个很好理解,想想你用脚手架创建的 SPA 代码样例,是不是一般都是在顶层创建一个 App 组件,然后在 HTML 中引入一个顶层的 js 脚本,将 App mount 到一个 #app 元素上?

image

只有把各种自定义的组件放到这个 App 下方,框架才能正确管理各种创建、销毁的声明周期——而在思源中写插件显然不符合这种情况。

如何解决?

处理方法非常简单:你必须要在 Dialog 的 destroyCallback 当中显式的调用组件的销毁方法。

比如在 solidjs 中,render 函数会返回一个 dispose 函数(销毁),我们只要在 callback 里调用 dispose 就完事大吉。

const showDialog = () => {
    let container = document.createElement('div')
    container.style.display = 'contents';
  
    let disposer = render(() => Component(), container);
    return simpleDialog({...args, ele: container, callback: () => {
        disposer(); //必须显式地调用销毁,来触发组件的 Destroy 生命周期
        console.log("Bye!");
    }});
}

其他框架也是类似操作,例如在 vite-svelte 模板中,通过调用 pannel.$destroy 来明确地销毁组件。

openDIYSetting(): void {
    let dialog = new Dialog({
        title: "SettingPannel",
        content: `<div id="SettingPanel" style="height: 100%;"></div>`,
        width: "800px",
        destroyCallback: (options) => {
            console.log("destroyCallback", options);
            //You'd better destroy the component when the dialog is closed
            pannel.$destroy();
        }
    });
    let pannel = new SettingExample({
        target: dialog.element.querySelector("#SettingPanel"),
    });
}

  • 思源笔记

    思源笔记是一款隐私优先的个人知识管理系统,支持完全离线使用,同时也支持端到端加密同步。

    融合块、大纲和双向链接,重构你的思维。

    22335 引用 • 89368 回帖
  • 插件
    98 引用 • 575 回帖 • 3 关注
  • 插件开发
    2 引用 • 7 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...
  • player 1

    感谢!我也是用你的插件模版

    这个发早一点,或者写到插件模版就好了。

    写插件一段时间后,我发现有些地方存在内存泄露,不过这点泄露其实不影响,毕竟页面也是开开关关的,总会释放内存。

    但作为一个开发者,发现了内存泄漏肯定不能忍。

    我这里遇到的情况比较复杂,

    简单说就是需要销毁一组资源。

    不一样的是销毁的发起方,不是固定的。

    可以是 dialog 关闭,也可以是 svelte 里面的按钮触发,比如完成某给逻辑后关闭。

    要求是销毁要干净,还不重复销毁。

    我的处理方法是引入一个辅助工具 DestroyManager

    比如这样使用:

            if (!this.dm) {
                this.dm = new DestroyManager();
                const id = newID();
                const dialog = new Dialog({
                    title: "🍅⏰ " + this.plugin.i18n.setDateTitle,
                    content: `<div id="${id}"></div>`,
                    width: events.isMobile ? "90vw" : "700px",
                    height: events.isMobile ? "180vw" : null,
                    destroyCallback: () => {
                        this.dm.destroyBy("1")
                    },
                });
                const d = new ScheduleDialog({
                    target: dialog.element.querySelector("#" + id),
                    props: {
                        plugin: this.plugin,
                        blockID,
                        dialog,
                        dm: this.dm,
                    }
                });
                this.dm.add("1", () => { dialog.destroy() })
                this.dm.add("2", () => { d.$destroy() })
                this.dm.add("set2null", () => { this.dm = null })
            } else {
                this.dm?.destroyBy();
                await copyID(blockID);
                console.info(document.querySelectorAll(`[${DATA_NODE_ID}="${blockID}"]`));
            }
    

    在 svelte 内,发起关闭的情况:

    image.png

    大多数情况,我是在 svelte 内的按钮,触发:dm.destroyBy(null)直接全部关闭,释放内存。

    export class DestroyManager {
        private destroied = false;
        private cbs = new Map<string, Func>();
        private actions: Func[] = [];
        private showMsg: boolean;
        private prefix: string;
        constructor(showMsg = false, prefix: string = "DestroyManager") {
            this.prefix = prefix;
            this.showMsg = showMsg;
        }
        action(cb: Func) {
            this.actions.push(cb);
        }
        run() {
            this.actions.forEach(i => i());
        }
        add(name: string, cb: Func) {
            this.cbs.set(name.trim(), cb);
        }
        destroyBy(name: string = null) {
            if (!this.destroied) {
                this.destroied = true;
                const lst = [...this.cbs.entries()];
                if (name == null) {
                    lst.forEach(([k, v]) => {
                        if (this.showMsg) console.log(`[${this.prefix}] DESTROY [${k}] BY NONE`);
                        v();
                    });
                } else {
                    name = name.trim();
                    lst.filter(([k]) => k !== name).forEach(([k, v]) => {
                        if (this.showMsg) console.log(`[${this.prefix}] DESTROY [${k}] BY [${name}]`);
                        v();
                    });
                }
            }
        }
    }
    
    1 回复
  • 感谢分享。关于内存泄漏,其实如果没有额外创建副作用,一般也不存在这个问题。毕竟 DOM 删掉了,引用计数器归零,自然会被回收。

    出问题的主要就是那些创建了副作用,然后在 Unmount 生命周期钩子函数里做清理的。这种情况就必须 destroy 来手动触发 Unmount 生命周期的了。

    我这两天又更新了一下模板,增加了一个 svelteDialog 方法,基本就是做了一个简单的包装,默认在回调中调用 destroy 销毁组件。

    https://github.com/siyuan-note/plugin-sample-vite-svelte/blob/main/src/libs/dialog.ts

  • 我看了一下链接。销毁组件的销毁是通过,dialog 来完成的。

    我之前也是这样处理的。

    如果是,销毁的动作是从组件发出的,并所有销毁统一由 dialog 完成,就需要把 dialog 的引用传给组件,从组件里面调用 dialog.destroy,关闭窗口的同时销毁资源。

    那创建组件和 dialog 的时候,双方都需要对方的引用。这种写法怪怪的。

    所以,我借用了另外的工具来做销毁工作。除了释放资源,还可以做其他逻辑上相关的工作,比如把某个变量设置为 null。

    1 回复
  • 可能你遇到的场景比较复杂吧。

    我之前也遇到过需要组件主动销毁自己并关闭 Dialog。不过我习惯的做法是向上传一个 close 事件,然后在外部的 js 里通过 component.$on('event') 监听这个事件,并引用 dialog 进行销毁。

    你提到的是一个不同的思路,对我还是有些启发的,感谢分享 👍 。

推荐标签 标签

  • Unity

    Unity 是由 Unity Technologies 开发的一个让开发者可以轻松创建诸如 2D、3D 多平台的综合型游戏开发工具,是一个全面整合的专业游戏引擎。

    25 引用 • 7 回帖 • 174 关注
  • 面试

    面试造航母,上班拧螺丝。多面试,少加班。

    325 引用 • 1395 回帖
  • HTML

    HTML5 是 HTML 下一个的主要修订版本,现在仍处于发展阶段。广义论及 HTML5 时,实际指的是包括 HTML、CSS 和 JavaScript 在内的一套技术组合。

    107 引用 • 295 回帖 • 1 关注
  • Android

    Android 是一种以 Linux 为基础的开放源码操作系统,主要使用于便携设备。2005 年由 Google 收购注资,并拉拢多家制造商组成开放手机联盟开发改良,逐渐扩展到到平板电脑及其他领域上。

    334 引用 • 323 回帖 • 1 关注
  • 宕机

    宕机,多指一些网站、游戏、网络应用等服务器一种区别于正常运行的状态,也叫“Down 机”、“当机”或“死机”。宕机状态不仅仅是指服务器“挂掉了”、“死机了”状态,也包括服务器假死、停用、关闭等一些原因而导致出现的不能够正常运行的状态。

    13 引用 • 82 回帖 • 52 关注
  • 996
    13 引用 • 200 回帖 • 6 关注
  • Openfire

    Openfire 是开源的、基于可拓展通讯和表示协议 (XMPP)、采用 Java 编程语言开发的实时协作服务器。Openfire 的效率很高,单台服务器可支持上万并发用户。

    6 引用 • 7 回帖 • 95 关注
  • Log4j

    Log4j 是 Apache 开源的一款使用广泛的 Java 日志组件。

    20 引用 • 18 回帖 • 31 关注
  • React

    React 是 Facebook 开源的一个用于构建 UI 的 JavaScript 库。

    192 引用 • 291 回帖 • 384 关注
  • V2Ray
    1 引用 • 15 回帖
  • etcd

    etcd 是一个分布式、高可用的 key-value 数据存储,专门用于在分布式系统中保存关键数据。

    5 引用 • 26 回帖 • 529 关注
  • PWA

    PWA(Progressive Web App)是 Google 在 2015 年提出、2016 年 6 月开始推广的项目。它结合了一系列现代 Web 技术,在网页应用中实现和原生应用相近的用户体验。

    14 引用 • 69 回帖 • 154 关注
  • H2

    H2 是一个开源的嵌入式数据库引擎,采用 Java 语言编写,不受平台的限制,同时 H2 提供了一个十分方便的 web 控制台用于操作和管理数据库内容。H2 还提供兼容模式,可以兼容一些主流的数据库,因此采用 H2 作为开发期的数据库非常方便。

    11 引用 • 54 回帖 • 654 关注
  • DNSPod

    DNSPod 建立于 2006 年 3 月份,是一款免费智能 DNS 产品。 DNSPod 可以为同时有电信、网通、教育网服务器的网站提供智能的解析,让电信用户访问电信的服务器,网通的用户访问网通的服务器,教育网的用户访问教育网的服务器,达到互联互通的效果。

    6 引用 • 26 回帖 • 510 关注
  • 微软

    微软是一家美国跨国科技公司,也是世界 PC 软件开发的先导,由比尔·盖茨与保罗·艾伦创办于 1975 年,公司总部设立在华盛顿州的雷德蒙德(Redmond,邻近西雅图)。以研发、制造、授权和提供广泛的电脑软件服务业务为主。

    8 引用 • 44 回帖
  • Firefox

    Mozilla Firefox 中文俗称“火狐”(正式缩写为 Fx 或 fx,非正式缩写为 FF),是一个开源的网页浏览器,使用 Gecko 排版引擎,支持多种操作系统,如 Windows、OSX 及 Linux 等。

    8 引用 • 30 回帖 • 407 关注
  • HBase

    HBase 是一个分布式的、面向列的开源数据库,该技术来源于 Fay Chang 所撰写的 Google 论文 “Bigtable:一个结构化数据的分布式存储系统”。就像 Bigtable 利用了 Google 文件系统所提供的分布式数据存储一样,HBase 在 Hadoop 之上提供了类似于 Bigtable 的能力。

    17 引用 • 6 回帖 • 73 关注
  • Sphinx

    Sphinx 是一个基于 SQL 的全文检索引擎,可以结合 MySQL、PostgreSQL 做全文搜索,它可以提供比数据库本身更专业的搜索功能,使得应用程序更容易实现专业化的全文检索。

    1 引用 • 211 关注
  • Gitea

    Gitea 是一个开源社区驱动的轻量级代码托管解决方案,后端采用 Go 编写,采用 MIT 许可证。

    4 引用 • 16 回帖 • 4 关注
  • DevOps

    DevOps(Development 和 Operations 的组合词)是一组过程、方法与系统的统称,用于促进开发(应用程序/软件工程)、技术运营和质量保障(QA)部门之间的沟通、协作与整合。

    47 引用 • 25 回帖
  • Solidity

    Solidity 是一种智能合约高级语言,运行在 [以太坊] 虚拟机(EVM)之上。它的语法接近于 JavaScript,是一种面向对象的语言。

    3 引用 • 18 回帖 • 399 关注
  • NetBeans

    NetBeans 是一个始于 1997 年的 Xelfi 计划,本身是捷克布拉格查理大学的数学及物理学院的学生计划。此计划延伸而成立了一家公司进而发展这个商用版本的 NetBeans IDE,直到 1999 年 Sun 买下此公司。Sun 于次年(2000 年)六月将 NetBeans IDE 开源,直到现在 NetBeans 的社群依然持续增长。

    78 引用 • 102 回帖 • 680 关注
  • 架构

    我们平时所说的“架构”主要是指软件架构,这是有关软件整体结构与组件的抽象描述,用于指导软件系统各个方面的设计。另外还有“业务架构”、“网络架构”、“硬件架构”等细分领域。

    142 引用 • 442 回帖
  • Vue.js

    Vue.js(读音 /vju ː/,类似于 view)是一个构建数据驱动的 Web 界面库。Vue.js 的目标是通过尽可能简单的 API 实现响应的数据绑定和组合的视图组件。

    266 引用 • 665 回帖 • 1 关注
  • Wide

    Wide 是一款基于 Web 的 Go 语言 IDE。通过浏览器就可以进行 Go 开发,并有代码自动完成、查看表达式、编译反馈、Lint、实时结果输出等功能。

    欢迎访问我们运维的实例: https://wide.b3log.org

    30 引用 • 218 回帖 • 629 关注
  • 安全

    安全永远都不是一个小问题。

    199 引用 • 816 回帖
  • 程序员

    程序员是从事程序开发、程序维护的专业人员。

    567 引用 • 3532 回帖