缓存,计算,渲染,三招优化 vue3 + naiveui + vite + electron 应用,一些思路清奇的性能优化实践

2,682 阅读9分钟

这篇文章很长,推荐用 pc 观看 🥰

源起

膨胀

由于最近本地项目数量膨胀,管理项目效率越来越低。

例如我不能很快地打开项目对应的文件夹,或者用 vscode 打开。

又例如,我不能很快地查询到本地是否有该项目,还有项目的占用大小,用的什么运行时,以及最后的更新时间和项目的创建时间等等信息。

所以为了提高项目管理效率,最近搞了个开源的 Vue3 + NaiveUI + Vite + Electron 的本地项目管理器 👉 dishait/x-pm: 通用项目管理器

感兴趣的靓仔可以在 Releases 中获取安装包。

演示

以下是 x-pm 的效果演示,欢迎 pr 或者提 issue

导入

导入工作区,就会自动分析和保存所有项目信息

bulk-import.gif

有时候我们可能有多个工作区

m-workspace.gif

暗黑模式

vue-dark-switch: 多合一的开箱即用 vue3 暗黑模式开关组件 提供支持

theme.gif

搜索

ctrl+f 模糊搜索,由 Fuse: Lightweight fuzzy-search, in JavaScript 提供支持

search.gif

排序

默认以更新时间作为排序基准,也支持以创建时间或者文件名作为排序基准

sort.gif

筛选

支持项目类型筛选,目前支持 nodedenogo,以及未知项目 unknown

screen.gif

启动

支持 vscode 打开项目,以及打开对应项目目录

open.gif


性能优化

缓存

你可能会发现,我们不是需要对项目做很多 io 才能知道项目的具体类型,大小,更新时间以及创建时间吗?为啥动图里对工作区的导入那么快呢?

其中一个原因就是我们对表内几乎所有的内容都做了缓存,如果 依赖 本身不变,那么会直接走缓存。

缓存由 file-computed: 文件型计算属性,当且仅当文件变化时才重新做计算 提供支持。

基础

file-computed 的使用非常简单,分为 依赖 和待计算函数两部分。

import { createFsComputed } from 'file-computed'

const deps = ['package.json'] // 依赖,表示是否走缓存取决于 package.json 变不变

const FC = createFsComputed() // 创建文件型计算属性

// 待计算函数,模拟复杂计算
function _total() {
    let n = 0
    let t = 10000
    while (t--) {
            n++
    }
    return n
}

function total() {
      return FC(deps, _total)
}

await total() // 只会跑一次 total,后边会直接获取缓存中的结果

再次执行就会命中缓存,即使进程重新执行,除非 依赖 package.json 被修改了。

await total() // 命中缓存

// 退出进程后重新执行
await total() // 命中缓存

原理

file-computed 的对比机制大抵是这样的 👇

微信截图_20230207163911.png

大部分 依赖mtime 是不会改变的,所以我们能减少了很多直接生成内容 hash 的开销。

相同的方案被用在 elk 依赖的 stale-dep 库中。 具体可见 👉 feat: check modifyTimeStamp before check cotent hash by markthree · Pull Request #5 · sxzz/stale-dep

应用

而在 x-pm 中,缓存是这样的 👇

state.png

  1. 同一颜色表示在同一缓存结果下
  2. 红色框框绑定工作区,其余颜色的缓存绑定每个项目

优化

流式缓存

file-computed 早前的 api

createFsComputedcreateFsComputedSync 对每个 key 都生成缓存文件,这种方式简单,不容易出错,在需要小型的缓存中是非常有优势的。

key = hash([依赖,待计算函数])

但这在 x-pm 就出现了问题

缓存文件就像这样👇

微信截图_20230207173802.png

这里只是 部分 缓存文件,下边还有,一屏幕都截不完 😂

可想而知,每次走缓存都得进行多少次 io,跑完这些 ionodejs 估计都麻了 🥵

如果是这样的话,像获取更新时间和创建时间不如不走缓存,反正都是单纯的 io。缓不存缓存都无所谓了。

那有没有可能把这些缓存汇聚成一个缓存文件呢?

当然是可以的,但是我们要考虑当缓存文件非常大时,读取不要卡住。

所以就有了适合大型缓存的流 createFsComputedWithStream

优化后的缓存文件只有一个 👇

微信截图_20230207173851.png

io 次数指数级下降,最佳情况下只会跑一次 io

序列化

在我们将缓存数据结构写入到文件时,是需要进行 序列化 的 (在这里就是转字符串)

createFsComputedWithStream 中,我们用 fastify/fast-json-stringify 来做序列化,它比原生的 JSON.stringify() 快两倍左右。

后置写

我们在更新缓存的过程中,其实并不需要确保写入缓存后才返回结果,完全可以 后置写

伪代码 👇

async function refresh(cachePath, fn) {
    const result = await fn()
    // await write(cachePath, result)
    write(cachePath, result) // 不需要 await 锁住流程
    return result // 尽快返回结果
}

计算

跨语言

如果你仔细观察会发现,我们是在有缓存的情况下进行工作区导入的,所以很快。

那没缓存的情况下呢?

像判断项目类型,获取更新时间,创建时间,这些当然可以很快。

但是获取项目大小,这种需要递归获取文件大小再不断累加的操作应该很慢吧 🤔

确实是,但是当我们在无缓存时进入 x-pm,你会发现也不是很慢嘛 👇

no-cache.gif

这是因为我用 go 重写了该递归计算,可以流畅跑在 node 中 👉 markthree/go-get-folder-size

原生 node 其实是不快的。

例如我们同样用 cli 计算 184,046 个文件,35,185 个文件夹,共 7 GB 左右的目录

原生 node,大概 11.5s 👇

node-get.gif

go 重写后,只需要 1.7s 👇

go-get.gif

不过这里 nodeget-folder-size 累加操作没上 worker-pool 线程池的,没有发挥 node 完全的性能。

不过就算上了线程池估计也打不过 gogoroutine。即使能打过,文件数量级再上一个层次后也会被 go 赶超。

灵感

go-get-folder-size 的实现灵感来自 👇

原理

wasm

如何在 node 中调用 go 呢,大家第一时间可能就想到 go 编译成 wasm 嘛,然后再用 node 去调用 wasm 👇

微信截图_20230207202529.png

确实可以,但是在这个例子中,wasm 反而比 node 原生还要慢很多 😅

很神奇,有知道怎么回事的靓仔靓女可以评论区说下为什么 🫡

二进制

另外一种方式就是将 go 编译成二进制执行文件,node 再起子进程调用二进制执行文件。

微信截图_20230207202928.png

不过这种方式仍然存在资源浪费的问题,因为跑完二进制后我们的进程就被销毁了,而创建和销毁进程是很浪费资源的。

理想状态下,应该是只起一个子进程,然后进行 ipc 通讯 👇

微信截图_20230207203910.png

go-get-folder-size 目前只实现了第一种单纯的调用,未来可能会像 gluon-framework/gluon 一样起常驻进程,提供 websocketstdio 等通讯方式。

渲染

虚拟滚动

对于大数据表,按照惯例我们都得开虚拟滚动,在 naive-ui 中,只需对数据表开启 virtual-scroll 并设置 max-height 即可开启虚拟滚动。

<template>
     <!-- 省略 props-->
    <NDataTable virtual-scroll :max-height="500" />
</template>

延迟加载

naive-ui 的标签页组件支持 show:lazy,可以仅加载当前显示的标签页内容,并且有 keep-alive 效果,不会重新渲染,设置 display-directive="show:lazy" 即可。

<template>
    <!-- 省略其他 tabs -->
    <n-tab-pane name="show:lazy" display-directive="show:lazy" tab="show:lazy">
      <!-- 这里的内容可能会延迟加载直到标签页内容可见 -->
    </n-tab-pane>
</template>

注意看下边无缓存时两个标签页的 loading 👇

show-lazy.gif

keep-alive 的效果,并且只在标签页可见时加载。

因为这个特性,我们在无缓存初次加载时进行了按需的 io,渲染和计算,只有点开了的标签页才会加载。

静态提升

naive-ui 中,绝大部分自定义需要我们手写渲染函数,就像下边的数据表配置一样 👇

import { h } from "vue"

// 列配置
const columns = [{
    // 省略其他字段
    // 每一行都会调用渲染函数
    render() {
        // 生成 loading 的 VNode
        return h('div', "loading...")
    }
}]

但是所有行的 loading 都是一样的,不需要每次都生成,只需要生成一次就可以了。

import { h } from "vue"

const loading = h('div', "loading...")  // 生成 loading 的 VNode

const columns = [{
    // 省略其他字段
    // 每一行都会调用渲染函数
    render() {
        return loading // 每次都返回一样的 VNode 
    }
}]

这个就是 hoistStatic,我们姑且翻译为 静态提升 吧🫣

所以大家看到的所有的 loading... 都是同一个 VNode,只生成了一次 🥰

微信截图_20230207213157.png

而这种优化在 vue3sfc 单文件 .vue 组件里已经自动帮我们做了。

只有在手写渲染函数或者 jsx 时需要考虑。

memo

你会敏锐的发现,我们的类型不是也有很多相同的吗?这能不能做静态提升呢?

微信截图_20230207213851.png

其实也可以,但我们不可能人工写所有类型吧,项目里也有可能存在混合类型的,例如 go-get-folder-size 这种既有 node 也有 go 的。

这时就要用到记忆函数了 👉 sindresorhus/mem

经由它包装后的函数,如果输入的参数是一样的就会命中缓存,返回相同的结果,而不会重新执行函数。

const Tags = ... // 组件
const createTags = mem(tags => h(Tags, tags))

createTags(['node']) // 生成 VNode

createTags(['node']) // 命中缓存,返回缓存中的 VNode

createTags(['node', 'go']) // 生成 VNode

就这样,我们就减少了很多 VNode 的生成开销。

异步组件

异步组件 能不阻塞进行异步加载,如果使用了 ES 模块动态导入 语法,在 vitewebpack 中还支持在生产环境将其拆分为小的 ,这两种特性能加快首屏的渲染。

结合 Suspense 能做到很好的体验效果。

例如,在 x-pm 中 👇

<script setup lang="ts">
const Table = defineAsyncComponent(
    // ES 模块动态导入组件,生产环境会拆分成块
    () => import('./components/Table.vue') 
)
</script>

<template>
    <Supense>
        <!-- 数据表组件将被异步加载,而不会阻塞其他的内容 -->
        <Table /> 
        <!-- 加载时的 loading -->
        <template #fallback> Loading... </template> 
    </Supense>
</template>

生产环境下,也会被有效拆分出来 👇

微信截图_20230207220641.png

另外 fallback 的布局尺寸应该尽量保持跟最终显示的一样。

<template>
    <Supense>
        <!-- 假设 Table 组件的宽高都是 500 px  -->
        <Table /> 
        <template #fallback> 
            <!-- fallback 的宽高也应该更 Table 一致 -->
            <div style="width: 500px; height: 500px;"> Loading... </div>
        </template> 
    </Supense>
</template>

除了切换前后视觉体验更好,另外切换时可能会影响其他内容的布局,导致不必要的大面积重排 reflow

编译时预设

暗黑模式组件 vue-dark-switch 会在运行时将预设样式和代码挂到 head 中。

例如 👇

<script>
import { Switch } from 'vue-dark-switch'
</script>

<template>
    <Switch /> <!-- 挂载样式和代码到 head -->
</template>

但是我们都知道这种需要操作 dom 的行为其实是很慢的。

所以有了编译时的预设挂载 👇

<script>
    import { Switch } from 'vue-dark-switch'
</script>

<template>
    <!-- unmount-persets 关闭运行时预设挂载 -->
    <Switch :unmount-persets="true" />
</template>
// vite.config.js
import { defineConfig } from 'vite'
import { HtmlPolyfill } from 'vue-dark-switch/vite'

export default defineConfig({
    plugins: [
        HtmlPolyfill() // 注入编译时预设
    ]
})

这样我们就避免了复杂场景下首次启动白屏,性能更好。

尾声

以上就是我在开发 dishait/x-pm: 通用项目管理器 时所用到的性能优化。

如果对后续其他的开源感兴趣,也欢迎关注 👇

文中提到的我在管理的部分仓库 👇

也欢迎提 prissue 🤗