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

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

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

  • 思源笔记

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

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

    24980 引用 • 102909 回帖 • 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 进行销毁。

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

推荐标签 标签

  • PWA

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

    14 引用 • 69 回帖 • 175 关注
  • Maven

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

    186 引用 • 318 回帖 • 255 关注
  • LeetCode

    LeetCode(力扣)是一个全球极客挚爱的高质量技术成长平台,想要学习和提升专业能力从这里开始,充足技术干货等你来啃,轻松拿下 Dream Offer!

    209 引用 • 72 回帖
  • Openfire

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

    6 引用 • 7 回帖 • 102 关注
  • 开源

    Open Source, Open Mind, Open Sight, Open Future!

    410 引用 • 3588 回帖 • 1 关注
  • Mac

    Mac 是苹果公司自 1984 年起以“Macintosh”开始开发的个人消费型计算机,如:iMac、Mac mini、Macbook Air、Macbook Pro、Macbook、Mac Pro 等计算机。

    169 引用 • 595 回帖
  • frp

    frp 是一个可用于内网穿透的高性能的反向代理应用,支持 TCP、UDP、 HTTP 和 HTTPS 协议。

    20 引用 • 7 回帖 • 3 关注
  • 数据库

    据说 99% 的性能瓶颈都在数据库。

    345 引用 • 742 回帖
  • 友情链接

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

    24 引用 • 373 回帖
  • 互联网

    互联网(Internet),又称网际网络,或音译因特网、英特网。互联网始于 1969 年美国的阿帕网,是网络与网络之间所串连成的庞大网络,这些网络以一组通用的协议相连,形成逻辑上的单一巨大国际网络。

    99 引用 • 367 回帖
  • CodeMirror
    2 引用 • 17 回帖 • 157 关注
  • Excel
    31 引用 • 28 回帖 • 1 关注
  • 浅吟主题

    Jeffrey Chen 制作的思源笔记主题,项目仓库:https://github.com/TCOTC/Whisper

    1 引用 • 28 回帖
  • Thymeleaf

    Thymeleaf 是一款用于渲染 XML/XHTML/HTML5 内容的模板引擎。类似 Velocity、 FreeMarker 等,它也可以轻易的与 Spring 等 Web 框架进行集成作为 Web 应用的模板引擎。与其它模板引擎相比,Thymeleaf 最大的特点是能够直接在浏览器中打开并正确显示模板页面,而不需要启动整个 Web 应用。

    11 引用 • 19 回帖 • 387 关注
  • Latke

    Latke 是一款以 JSON 为主的 Java Web 框架。

    71 引用 • 535 回帖 • 824 关注
  • 外包

    有空闲时间是接外包好呢还是学习好呢?

    26 引用 • 233 回帖 • 2 关注
  • WiFiDog

    WiFiDog 是一套开源的无线热点认证管理工具,主要功能包括:位置相关的内容递送;用户认证和授权;集中式网络监控。

    1 引用 • 7 回帖 • 612 关注
  • 又拍云

    又拍云是国内领先的 CDN 服务提供商,国家工信部认证通过的“可信云”,乌云众测平台认证的“安全云”,为移动时代的创业者提供新一代的 CDN 加速服务。

    20 引用 • 37 回帖 • 573 关注
  • 脑图

    脑图又叫思维导图,是表达发散性思维的有效图形思维工具 ,它简单却又很有效,是一种实用性的思维工具。

    31 引用 • 97 回帖
  • Vim

    Vim 是类 UNIX 系统文本编辑器 Vi 的加强版本,加入了更多特性来帮助编辑源代码。Vim 的部分增强功能包括文件比较(vimdiff)、语法高亮、全面的帮助系统、本地脚本(Vimscript)和便于选择的可视化模式。

    29 引用 • 66 回帖
  • 宕机

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

    13 引用 • 82 回帖 • 80 关注
  • OpenCV
    15 引用 • 36 回帖
  • Ubuntu

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

    127 引用 • 169 回帖 • 1 关注
  • Postman

    Postman 是一款简单好用的 HTTP API 调试工具。

    4 引用 • 3 回帖 • 1 关注
  • SendCloud

    SendCloud 由搜狐武汉研发中心孵化的项目,是致力于为开发者提供高质量的触发邮件服务的云端邮件发送平台,为开发者提供便利的 API 接口来调用服务,让邮件准确迅速到达用户收件箱并获得强大的追踪数据。

    2 引用 • 8 回帖 • 493 关注
  • ZeroNet

    ZeroNet 是一个基于比特币加密技术和 BT 网络技术的去中心化的、开放开源的网络和交流系统。

    1 引用 • 21 回帖 • 643 关注
  • 心情

    心是产生任何想法的源泉,心本体会陷入到对自己本体不能理解的状态中,因为心能产生任何想法,不能分出对错,不能分出自己。

    59 引用 • 369 回帖 • 1 关注