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

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

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

  • 思源笔记

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

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

    26020 引用 • 107984 回帖
  • 插件
    103 引用 • 632 回帖 • 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 进行销毁。

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

推荐标签 标签

  • Solidity

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

    3 引用 • 18 回帖 • 436 关注
  • Android

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

    336 引用 • 324 回帖
  • 周末

    星期六到星期天晚,实行五天工作制后,指每周的最后两天。再过几年可能就是三天了。

    14 引用 • 297 回帖 • 2 关注
  • 996
    13 引用 • 200 回帖 • 4 关注
  • AngularJS

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

    12 引用 • 50 回帖 • 514 关注
  • WebComponents

    Web Components 是 W3C 定义的标准,它给了前端开发者扩展浏览器标签的能力,可以方便地定制可复用组件,更好的进行模块化开发,解放了前端开发者的生产力。

    1 引用 • 9 关注
  • 资讯

    资讯是用户因为及时地获得它并利用它而能够在相对短的时间内给自己带来价值的信息,资讯有时效性和地域性。

    56 引用 • 85 回帖
  • 小薇

    小薇是一个用 Java 写的 QQ 聊天机器人 Web 服务,可以用于社群互动。

    由于 Smart QQ 从 2019 年 1 月 1 日起停止服务,所以该项目也已经停止维护了!

    35 引用 • 468 回帖 • 760 关注
  • BND

    BND(Baidu Netdisk Downloader)是一款图形界面的百度网盘不限速下载器,支持 Windows、Linux 和 Mac,详细介绍请看这里

    107 引用 • 1281 回帖 • 36 关注
  • 钉钉

    钉钉,专为中国企业打造的免费沟通协同多端平台, 阿里巴巴出品。

    15 引用 • 67 回帖 • 271 关注
  • 叶归
    12 引用 • 56 回帖 • 20 关注
  • NGINX

    NGINX 是一个高性能的 HTTP 和反向代理服务器,也是一个 IMAP/POP3/SMTP 代理服务器。 NGINX 是由 Igor Sysoev 为俄罗斯访问量第二的 Rambler.ru 站点开发的,第一个公开版本 0.1.0 发布于 2004 年 10 月 4 日。

    315 引用 • 547 回帖 • 2 关注
  • DevOps

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

    59 引用 • 25 回帖 • 1 关注
  • 创业

    你比 99% 的人都优秀么?

    82 引用 • 1395 回帖
  • SpaceVim

    SpaceVim 是一个社区驱动的模块化 vim/neovim 配置集合,以模块的方式组织管理插件以
    及相关配置,为不同的语言开发量身定制了相关的开发模块,该模块提供代码自动补全,
    语法检查、格式化、调试、REPL 等特性。用户仅需载入相关语言的模块即可得到一个开箱
    即用的 Vim-IDE。

    3 引用 • 31 回帖 • 110 关注
  • MySQL

    MySQL 是一个关系型数据库管理系统,由瑞典 MySQL AB 公司开发,目前属于 Oracle 公司。MySQL 是最流行的关系型数据库管理系统之一。

    693 引用 • 537 回帖
  • OpenShift

    红帽提供的 PaaS 云,支持多种编程语言,为开发人员提供了更为灵活的框架、存储选择。

    14 引用 • 20 回帖 • 662 关注
  • Facebook

    Facebook 是一个联系朋友的社交工具。大家可以通过它和朋友、同事、同学以及周围的人保持互动交流,分享无限上传的图片,发布链接和视频,更可以增进对朋友的了解。

    4 引用 • 15 回帖 • 450 关注
  • jsDelivr

    jsDelivr 是一个开源的 CDN 服务,可为 npm 包、GitHub 仓库提供免费、快速并且可靠的全球 CDN 加速服务。

    5 引用 • 31 回帖 • 105 关注
  • Docker

    Docker 是一个开源的应用容器引擎,让开发者可以打包他们的应用以及依赖包到一个可移植的容器中,然后发布到任何流行的操作系统上。容器完全使用沙箱机制,几乎没有性能开销,可以很容易地在机器和数据中心中运行。

    497 引用 • 934 回帖 • 2 关注
  • OAuth

    OAuth 协议为用户资源的授权提供了一个安全的、开放而又简易的标准。与以往的授权方式不同之处是 oAuth 的授权不会使第三方触及到用户的帐号信息(如用户名与密码),即第三方无需使用用户的用户名与密码就可以申请获得该用户资源的授权,因此 oAuth 是安全的。oAuth 是 Open Authorization 的简写。

    36 引用 • 103 回帖 • 37 关注
  • 微信

    腾讯公司 2011 年 1 月 21 日推出的一款手机通讯软件。用户可以通过摇一摇、搜索号码、扫描二维码等添加好友和关注公众平台,同时可以将自己看到的精彩内容分享到微信朋友圈。

    133 引用 • 796 回帖
  • MyBatis

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

    173 引用 • 414 回帖 • 363 关注
  • Swagger

    Swagger 是一款非常流行的 API 开发工具,它遵循 OpenAPI Specification(这是一种通用的、和编程语言无关的 API 描述规范)。Swagger 贯穿整个 API 生命周期,如 API 的设计、编写文档、测试和部署。

    26 引用 • 35 回帖 • 3 关注
  • Git

    Git 是 Linux Torvalds 为了帮助管理 Linux 内核开发而开发的一个开放源码的版本控制软件。

    211 引用 • 358 回帖
  • 开源

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

    412 引用 • 3588 回帖
  • sts
    2 引用 • 2 回帖 • 241 关注