这篇文章很长,推荐用 pc 观看 🥰
源起
膨胀
由于最近本地项目数量膨胀,管理项目效率越来越低。
例如我不能很快地打开项目对应的文件夹,或者用 vscode 打开。
又例如,我不能很快地查询到本地是否有该项目,还有项目的占用大小,用的什么运行时,以及最后的更新时间和项目的创建时间等等信息。
所以为了提高项目管理效率,最近搞了个开源的 Vue3 + NaiveUI + Vite + Electron 的本地项目管理器 👉 dishait/x-pm: 通用项目管理器。
感兴趣的靓仔可以在 Releases 中获取安装包。
演示
以下是 x-pm 的效果演示,欢迎
pr或者提issue
导入
导入工作区,就会自动分析和保存所有项目信息
有时候我们可能有多个工作区
暗黑模式
由 vue-dark-switch: 多合一的开箱即用 vue3 暗黑模式开关组件 提供支持
搜索
ctrl+f 模糊搜索,由 Fuse: Lightweight fuzzy-search, in JavaScript 提供支持
排序
默认以更新时间作为排序基准,也支持以创建时间或者文件名作为排序基准
筛选
支持项目类型筛选,目前支持 node,deno 和 go,以及未知项目 unknown
启动
支持 vscode 打开项目,以及打开对应项目目录
性能优化
缓存
你可能会发现,我们不是需要对项目做很多 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 的对比机制大抵是这样的 👇
大部分 依赖 的 mtime 是不会改变的,所以我们能减少了很多直接生成内容 hash 的开销。
相同的方案被用在 elk 依赖的 stale-dep 库中。 具体可见 👉 feat: check modifyTimeStamp before check cotent hash by markthree · Pull Request #5 · sxzz/stale-dep
应用
而在 x-pm 中,缓存是这样的 👇
- 同一颜色表示在同一缓存结果下
- 红色框框绑定工作区,其余颜色的缓存绑定每个项目
优化
流式缓存
file-computed 早前的 api
createFsComputed 和 createFsComputedSync 对每个 key 都生成缓存文件,这种方式简单,不容易出错,在需要小型的缓存中是非常有优势的。
key=hash([依赖,待计算函数])
但这在 x-pm 就出现了问题
缓存文件就像这样👇
这里只是 部分 缓存文件,下边还有,一屏幕都截不完 😂
可想而知,每次走缓存都得进行多少次 io,跑完这些 io,nodejs 估计都麻了 🥵
如果是这样的话,像获取更新时间和创建时间不如不走缓存,反正都是单纯的 io。缓不存缓存都无所谓了。
那有没有可能把这些缓存汇聚成一个缓存文件呢?
当然是可以的,但是我们要考虑当缓存文件非常大时,读取不要卡住。
所以就有了适合大型缓存的流 createFsComputedWithStream。
优化后的缓存文件只有一个 👇
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,你会发现也不是很慢嘛 👇
这是因为我用 go 重写了该递归计算,可以流畅跑在 node 中 👉 markthree/go-get-folder-size。
原生 node 其实是不快的。
例如我们同样用 cli 计算 184,046 个文件,35,185 个文件夹,共 7 GB 左右的目录
原生 node,大概 11.5s 👇
用 go 重写后,只需要 1.7s 👇
不过这里 node 的 get-folder-size 累加操作没上 worker-pool 线程池的,没有发挥 node 完全的性能。
不过就算上了线程池估计也打不过 go 的 goroutine。即使能打过,文件数量级再上一个层次后也会被 go 赶超。
灵感
go-get-folder-size 的实现灵感来自 👇
原理
wasm
如何在 node 中调用 go 呢,大家第一时间可能就想到 go 编译成 wasm 嘛,然后再用 node 去调用 wasm 👇
确实可以,但是在这个例子中,wasm 反而比 node 原生还要慢很多 😅
很神奇,有知道怎么回事的靓仔靓女可以评论区说下为什么 🫡
二进制
另外一种方式就是将 go 编译成二进制执行文件,node 再起子进程调用二进制执行文件。
不过这种方式仍然存在资源浪费的问题,因为跑完二进制后我们的进程就被销毁了,而创建和销毁进程是很浪费资源的。
理想状态下,应该是只起一个子进程,然后进行 ipc 通讯 👇
go-get-folder-size 目前只实现了第一种单纯的调用,未来可能会像 gluon-framework/gluon 一样起常驻进程,提供 websocket 和 stdio 等通讯方式。
渲染
虚拟滚动
对于大数据表,按照惯例我们都得开虚拟滚动,在 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 👇
有 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,只生成了一次 🥰
而这种优化在 vue3 的 sfc 单文件 .vue 组件里已经自动帮我们做了。
只有在手写渲染函数或者 jsx 时需要考虑。
memo
你会敏锐的发现,我们的类型不是也有很多相同的吗?这能不能做静态提升呢?
其实也可以,但我们不可能人工写所有类型吧,项目里也有可能存在混合类型的,例如 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 模块动态导入 语法,在 vite 或 webpack 中还支持在生产环境将其拆分为小的 块,这两种特性能加快首屏的渲染。
结合 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>
生产环境下,也会被有效拆分出来 👇
另外 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: 通用项目管理器 时所用到的性能优化。
如果对后续其他的开源感兴趣,也欢迎关注 👇
文中提到的我在管理的部分仓库 👇
- dishait/x-pm: 通用项目管理器
- dishait/vue-dark-switch: 多合一的开箱即用 vue3 暗黑模式开关组件
- dishait/file-computed: 文件型计算属性,当且仅当文件变化时才重新做计算 (github.com)
- markthree/go-get-folder-size: 递归获取文件夹大小,使用 go,足够快,可以跑在 node 中
也欢迎提 pr 和 issue 🤗