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

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

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

  • 思源笔记

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

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

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

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

推荐标签 标签

  • Logseq

    Logseq 是一个隐私优先、开源的知识库工具。

    Logseq is a joyful, open-source outliner that works on top of local plain-text Markdown and Org-mode files. Use it to write, organize and share your thoughts, keep your to-do list, and build your own digital garden.

    7 引用 • 69 回帖 • 8 关注
  • Spring

    Spring 是一个开源框架,是于 2003 年兴起的一个轻量级的 Java 开发框架,由 Rod Johnson 在其著作《Expert One-On-One J2EE Development and Design》中阐述的部分理念和原型衍生而来。它是为了解决企业应用开发的复杂性而创建的。框架的主要优势之一就是其分层架构,分层架构允许使用者选择使用哪一个组件,同时为 JavaEE 应用程序开发提供集成的框架。

    949 引用 • 1460 回帖
  • V2Ray
    1 引用 • 15 回帖
  • 单点登录

    单点登录(Single Sign On)是目前比较流行的企业业务整合的解决方案之一。SSO 的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。

    9 引用 • 25 回帖 • 5 关注
  • Lute

    Lute 是一款结构化的 Markdown 引擎,支持 Go 和 JavaScript。

    29 引用 • 202 回帖 • 28 关注
  • Swift

    Swift 是苹果于 2014 年 WWDC(苹果开发者大会)发布的开发语言,可与 Objective-C 共同运行于 Mac OS 和 iOS 平台,用于搭建基于苹果平台的应用程序。

    36 引用 • 37 回帖 • 552 关注
  • Visio
    1 引用 • 2 回帖 • 1 关注
  • ngrok

    ngrok 是一个反向代理,通过在公共的端点和本地运行的 Web 服务器之间建立一个安全的通道。

    7 引用 • 63 回帖 • 651 关注
  • 互联网

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

    99 引用 • 367 回帖
  • Vim

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

    29 引用 • 66 回帖 • 2 关注
  • 职场

    找到自己的位置,萌新烦恼少。

    127 引用 • 1708 回帖
  • 开源中国

    开源中国是目前中国最大的开源技术社区。传播开源的理念,推广开源项目,为 IT 开发者提供了一个发现、使用、并交流开源技术的平台。目前开源中国社区已收录超过两万款开源软件。

    7 引用 • 86 回帖
  • HTML

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

    108 引用 • 295 回帖 • 1 关注
  • Windows

    Microsoft Windows 是美国微软公司研发的一套操作系统,它问世于 1985 年,起初仅仅是 Microsoft-DOS 模拟环境,后续的系统版本由于微软不断的更新升级,不但易用,也慢慢的成为家家户户人们最喜爱的操作系统。

    227 引用 • 476 回帖
  • 持续集成

    持续集成(Continuous Integration)是一种软件开发实践,即团队开发成员经常集成他们的工作,通过每个成员每天至少集成一次,也就意味着每天可能会发生多次集成。每次集成都通过自动化的构建(包括编译,发布,自动化测试)来验证,从而尽早地发现集成错误。

    16 引用 • 7 回帖
  • MyBatis

    MyBatis 本是 Apache 软件基金会 的一个开源项目 iBatis,2010 年这个项目由 Apache 软件基金会迁移到了 google code,并且改名为 MyBatis ,2013 年 11 月再次迁移到了 GitHub。

    173 引用 • 414 回帖 • 368 关注
  • 生活

    生活是指人类生存过程中的各项活动的总和,范畴较广,一般指为幸福的意义而存在。生活实际上是对人生的一种诠释。生活包括人类在社会中与自己息息相关的日常活动和心理影射。

    229 引用 • 1432 回帖
  • Notion

    Notion - The all-in-one workspace for your notes, tasks, wikis, and databases.

    10 引用 • 77 回帖 • 1 关注
  • SSL

    SSL(Secure Sockets Layer 安全套接层),及其继任者传输层安全(Transport Layer Security,TLS)是为网络通信提供安全及数据完整性的一种安全协议。TLS 与 SSL 在传输层对网络连接进行加密。

    70 引用 • 193 回帖 • 413 关注
  • Linux

    Linux 是一套免费使用和自由传播的类 Unix 操作系统,是一个基于 POSIX 和 Unix 的多用户、多任务、支持多线程和多 CPU 的操作系统。它能运行主要的 Unix 工具软件、应用程序和网络协议,并支持 32 位和 64 位硬件。Linux 继承了 Unix 以网络为核心的设计思想,是一个性能稳定的多用户网络操作系统。

    954 引用 • 944 回帖 • 1 关注
  • CongSec

    本标签主要用于分享网络空间安全专业的学习笔记

    1 引用 • 1 回帖 • 34 关注
  • 阿里云

    阿里云是阿里巴巴集团旗下公司,是全球领先的云计算及人工智能科技公司。提供云服务器、云数据库、云安全等云计算服务,以及大数据、人工智能服务、精准定制基于场景的行业解决方案。

    84 引用 • 324 回帖 • 2 关注
  • Elasticsearch

    Elasticsearch 是一个基于 Lucene 的搜索服务器。它提供了一个分布式多用户能力的全文搜索引擎,基于 RESTful 接口。Elasticsearch 是用 Java 开发的,并作为 Apache 许可条款下的开放源码发布,是当前流行的企业级搜索引擎。设计用于云计算中,能够达到实时搜索,稳定,可靠,快速,安装使用方便。

    117 引用 • 99 回帖 • 198 关注
  • 爬虫

    网络爬虫(Spider、Crawler),是一种按照一定的规则,自动地抓取万维网信息的程序。

    106 引用 • 275 回帖 • 1 关注
  • DNSPod

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

    6 引用 • 26 回帖 • 534 关注
  • Hexo

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

    22 引用 • 148 回帖 • 9 关注
  • Scala

    Scala 是一门多范式的编程语言,集成面向对象编程和函数式编程的各种特性。

    13 引用 • 11 回帖 • 159 关注