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

本贴最后更新于 278 天前,其中的信息可能已经时移俗易

如果你是用 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"), }); }

  • 思源笔记

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

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

    25204 引用 • 103945 回帖 • 1 关注
  • 插件
    102 引用 • 627 回帖 • 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 进行销毁。

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

推荐标签 标签

  • WebSocket

    WebSocket 是 HTML5 中定义的一种新协议,它实现了浏览器与服务器之间的全双工通信(full-duplex)。

    48 引用 • 206 回帖 • 296 关注
  • JSON

    JSON (JavaScript Object Notation)是一种轻量级的数据交换格式。易于人类阅读和编写。同时也易于机器解析和生成。

    52 引用 • 190 回帖 • 1 关注
  • 书籍

    宋真宗赵恒曾经说过:“书中自有黄金屋,书中自有颜如玉。”

    78 引用 • 396 回帖
  • Ruby

    Ruby 是一种开源的面向对象程序设计的服务器端脚本语言,在 20 世纪 90 年代中期由日本的松本行弘(まつもとゆきひろ/Yukihiro Matsumoto)设计并开发。在 Ruby 社区,松本也被称为马茨(Matz)。

    7 引用 • 31 回帖 • 246 关注
  • 友情链接

    确认过眼神后的灵魂连接,站在链在!

    24 引用 • 373 回帖 • 1 关注
  • Kubernetes

    Kubernetes 是 Google 开源的一个容器编排引擎,它支持自动化部署、大规模可伸缩、应用容器化管理。

    116 引用 • 54 回帖 • 2 关注
  • 七牛云

    七牛云是国内领先的企业级公有云服务商,致力于打造以数据为核心的场景化 PaaS 服务。围绕富媒体场景,七牛先后推出了对象存储,融合 CDN 加速,数据通用处理,内容反垃圾服务,以及直播云服务等。

    28 引用 • 226 回帖 • 133 关注
  • Spark

    Spark 是 UC Berkeley AMP lab 所开源的类 Hadoop MapReduce 的通用并行框架。Spark 拥有 Hadoop MapReduce 所具有的优点;但不同于 MapReduce 的是 Job 中间输出结果可以保存在内存中,从而不再需要读写 HDFS,因此 Spark 能更好地适用于数据挖掘与机器学习等需要迭代的 MapReduce 的算法。

    74 引用 • 46 回帖 • 568 关注
  • QQ

    1999 年 2 月腾讯正式推出“腾讯 QQ”,在线用户由 1999 年的 2 人(马化腾和张志东)到现在已经发展到上亿用户了,在线人数超过一亿,是目前使用最广泛的聊天软件之一。

    45 引用 • 557 回帖
  • SQLite

    SQLite 是一个进程内的库,实现了自给自足的、无服务器的、零配置的、事务性的 SQL 数据库引擎。SQLite 是全世界使用最为广泛的数据库引擎。

    5 引用 • 7 回帖 • 1 关注
  • FreeMarker

    FreeMarker 是一款好用且功能强大的 Java 模版引擎。

    23 引用 • 20 回帖 • 462 关注
  • AngularJS

    AngularJS 诞生于 2009 年,由 Misko Hevery 等人创建,后为 Google 所收购。是一款优秀的前端 JS 框架,已经被用于 Google 的多款产品当中。AngularJS 有着诸多特性,最为核心的是:MVC、模块化、自动化双向数据绑定、语义化标签、依赖注入等。2.0 版本后已经改名为 Angular。

    12 引用 • 50 回帖 • 505 关注
  • Hexo

    Hexo 是一款快速、简洁且高效的博客框架,使用 Node.js 编写。

    22 引用 • 148 回帖 • 16 关注
  • Maven

    Maven 是基于项目对象模型(POM)、通过一小段描述信息来管理项目的构建、报告和文档的软件项目管理工具。

    187 引用 • 318 回帖 • 256 关注
  • JRebel

    JRebel 是一款 Java 虚拟机插件,它使得 Java 程序员能在不进行重部署的情况下,即时看到代码的改变对一个应用程序带来的影响。

    26 引用 • 78 回帖 • 679 关注
  • Ubuntu

    Ubuntu(友帮拓、优般图、乌班图)是一个以桌面应用为主的 Linux 操作系统,其名称来自非洲南部祖鲁语或豪萨语的“ubuntu”一词,意思是“人性”、“我的存在是因为大家的存在”,是非洲传统的一种价值观,类似华人社会的“仁爱”思想。Ubuntu 的目标在于为一般用户提供一个最新的、同时又相当稳定的主要由自由软件构建而成的操作系统。

    127 引用 • 169 回帖
  • Office

    Office 现已更名为 Microsoft 365. Microsoft 365 将高级 Office 应用(如 Word、Excel 和 PowerPoint)与 1 TB 的 OneDrive 云存储空间、高级安全性等结合在一起,可帮助你在任何设备上完成操作。

    5 引用 • 34 回帖
  • Solidity

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

    3 引用 • 18 回帖 • 437 关注
  • OpenStack

    OpenStack 是一个云操作系统,通过数据中心可控制大型的计算、存储、网络等资源池。所有的管理通过前端界面管理员就可以完成,同样也可以通过 Web 接口让最终用户部署资源。

    10 引用 • 5 关注
  • 招聘

    哪里都缺人,哪里都不缺人。

    189 引用 • 1057 回帖 • 5 关注
  • Node.js

    Node.js 是一个基于 Chrome JavaScript 运行时建立的平台, 用于方便地搭建响应速度快、易于扩展的网络应用。Node.js 使用事件驱动, 非阻塞 I/O 模型而得以轻量和高效。

    139 引用 • 269 回帖
  • Gitea

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

    5 引用 • 16 回帖 • 4 关注
  • 架构

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

    143 引用 • 442 回帖
  • CSS

    CSS(Cascading Style Sheet)“层叠样式表”是用于控制网页样式并允许将样式信息与网页内容分离的一种标记性语言。

    199 引用 • 542 回帖 • 1 关注
  • ReactiveX

    ReactiveX 是一个专注于异步编程与控制可观察数据(或者事件)流的 API。它组合了观察者模式,迭代器模式和函数式编程的优秀思想。

    1 引用 • 2 回帖 • 183 关注
  • 工具

    子曰:“工欲善其事,必先利其器。”

    298 引用 • 763 回帖 • 1 关注
  • SQLServer

    SQL Server 是由 [微软] 开发和推广的关系数据库管理系统(DBMS),它最初是由 微软、Sybase 和 Ashton-Tate 三家公司共同开发的,并于 1988 年推出了第一个 OS/2 版本。

    21 引用 • 31 回帖