普通视图
2026年1月编程语言排行榜|C#拿下年度语言,Python稳居第一
大家好,我是凌览。
- 个人网站:blog.code24.top
- 去水印下载鸭:nologo.code.top
如果本文能给你提供启发或帮助,欢迎动动小手指,一键三连(点赞、评论、转发),给我一些支持和鼓励谢谢。
这是C#在三年内第二次被TIOBE指数评为年度编程语言。C#之所以获得这一殊荣,是因为其在排名上实现了同比最大增幅。
![]()
多年的迭代,C#已经脱胎换骨。语言设计上,C#常常是主流语言中新趋势的早期采纳者。更难得的是,它实现了两次重大的范式转变:一次从“Windows 专属”跃向“跨平台”,一次从“微软私有”走向全面开源,恰到好处,不早不晚。
在商业软件的地盘上,Java 与 C# 的缠斗从未停歇。我曾笃定 Java 会笑到最后,可十几年过去,胜负依旧悬而未决。Java 语法冗长、样板代码横飞,还要背负 Oracle 的所有权——这套组合拳下,Java 究竟还能不能顶住 C# 的步步紧逼?答案仍悬在半空。
![]()
2025 年的前十榜单里,C 与 C++ 互换座次。后者迭代再快,激进新特性——譬如模块——仍没逃过“叫好不叫座”的尴尬;而 C 凭一句“简单即快”,稳踞小型嵌入式增量市场,岿然不动。Rust 虽刷出历史最佳第 13,却在这片疆域依旧撞不破天花板
除了C#之外,2025年的其他赢家Perl,从第32名跃升至第11名,重新进入前20名。
![]()
另一个重返前10名的语言是R,这主要得益于数据科学和统计计算领域的持续增长。
![]()
当然也有输家,Go2025年跌出前10名中的位置,Ruby跌出了前20名其他语言
![]()
![]()
2026年1月编程语言排行榜
本月,排名前十的分别是: Python,C,Java,C++,C#,JavaScript,Visual Basic,SQL,Delphi/Object Pascal,R。
![]()
10-20排名如下:
![]()
历年获奖编程语言
奖项颁发给在一年内评分增长最高的编程语言。
![]()
最后
TIOBE 编程社区指数是衡量编程语言流行度的指标——每月翻新一次。分数拼的是全球熟练工程师的脑袋数、课程开班量与第三方厂商的吆喝声;Google、Amazon、Wikipedia、Bing 等二十余家热门站点共同充当计票器。先打预防针:它既不评选“最好”的语言,也不比拼谁“码”得最长。
Pinia 深度解析:现代Vue应用状态管理最佳实践
什么是 Pinia?
Pinia 是 Vue.js 官方推荐的状态管理库,它取代了 Vuex 成为 Vue 3 应用的首选状态管理方案。相比 Vuex,Pinia 提供了更简洁、直观的 API,并且具有出色的 TypeScript 支持。
Pinia 的核心优势
- 轻量级:体积更小,性能更好
- 直观:API 设计更简单,学习成本低
- TypeScript 支持:原生支持 TypeScript,无需额外配置
- 开发工具集成:与 Vue DevTools 完美集成
- 热更新:支持模块热替换,提升开发体验
Pinia 核心概念
1. Store(存储)
Store 是 Pinia 中保存状态的地方,它是使用 defineStore() 函数创建的。
import { defineStore } from 'pinia'
// 创建名为 counter 的 store
export const useCounterStore = defineStore('counter', {
// store 的实现
})
2. State(状态)
State 是存储数据的地方,相当于组件中的 data。
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Eduardo'
})
})
3. Getters(计算属性)
Getters 相当于组件中的 computed,用于计算派生状态。
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0
}),
getters: {
doubleCount: (state) => state.count * 2,
doubleCountPlusOne(): number {
return this.doubleCount + 1
}
}
})
4. Actions(操作)
Actions 相当于组件中的 methods,用于处理业务逻辑。
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0
}),
actions: {
increment() {
this.count++
},
async fetchData() {
const response = await api.getData()
this.data = response.data
}
}
})
Pinia 在实际项目中的应用
1. 安装和配置
npm install pinia
在 main.js 中安装 Pinia:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#app')
2. 创建 Store
让我们以一个酒店管理系统为例,创建一个存储订单相关数据的 store:
// stores/commonLists.js
import { defineStore } from 'pinia'
export const useCommonListsStore = defineStore('commonLists', {
// 状态
state: () => ({
orderSourceList: [],
orderCustomerList: [],
loading: {
orderSources: false,
orderCustomers: false
}
}),
// 计算属性
getters: {
getOrderSourceList: (state) => state.orderSourceList,
getOrderCustomerList: (state) => state.orderCustomerList,
isLoadingOrderSources: (state) => state.loading.orderSources,
isLoadingOrderCustomers: (state) => state.loading.orderCustomers
},
// 操作方法
actions: {
// 获取订单来源列表
async fetchOrderSources() {
if (this.orderSourceList.length > 0) {
return { success: true, data: this.orderSourceList }
}
this.loading.orderSources = true
try {
// 模拟 API 请求
const response = await getAllOrderSources()
if (response && response.records) {
this.orderSourceList = response.records.map(item => ({
value: item.id,
label: item.name
}))
return { success: true, data: this.orderSourceList }
}
} catch (error) {
console.error('获取订单来源失败:', error)
return { success: false, message: error.message }
} finally {
this.loading.orderSources = false
}
}
}
})
3. 在组件中使用 Store
基本使用方式
<script setup>
import { useCommonListsStore } from '@/stores/commonLists'
const commonListsStore = useCommonListsStore()
// 访问状态
console.log(commonListsStore.orderSourceList)
// 调用 action
await commonListsStore.fetchOrderSources()
</script>
在模板中使用
<template>
<div>
<select v-model="selectedSource">
<option
v-for="source in commonListsStore.orderSourceList"
:key="source.value"
:value="source.value"
>
{{ source.label }}
</option>
</select>
<div v-if="commonListsStore.isLoadingOrderSources">
正在加载...
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useCommonListsStore } from '@/stores/commonLists'
const commonListsStore = useCommonListsStore()
const selectedSource = ref('')
// 组件挂载时加载数据
commonListsStore.fetchOrderSources()
</script>
Pinia 高级特性
1. Store 之间的相互依赖
export const useUserStore = defineStore('user', {
state: () => ({
name: '',
age: 0
})
})
export const useMainStore = defineStore('main', {
state: () => ({
count: 99
}),
getters: {
// 使用其他 store 的状态
doubleCount: (state) => state.count * 2
},
actions: {
// 在 action 中使用其他 store
incrementWithUserAge() {
const userStore = useUserStore()
this.count += userStore.age
}
}
})
2. Store 持久化
使用 pinia-plugin-persistedstate 插件实现数据持久化:
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// 在 store 中配置持久化
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0
}),
persist: true // 启用持久化
})
3. Store 的模块化
对于大型应用,建议按功能模块划分 store:
stores/
├── index.js # 导出所有 store
├── user.js # 用户相关状态
├── product.js # 产品相关状态
├── cart.js # 购物车状态
└── commonLists.js # 公共列表数据
最佳实践
1. Store 命名规范
- 使用
use前缀命名 store 函数 - Store 名称应反映其功能
- 避免 store 名称冲突
2. 状态管理原则
- 单一数据源:每个数据片段只应在一处定义
- 状态不可变性:直接修改 state,而不是通过 setter
- 操作集中化:复杂的业务逻辑放在 actions 中
3. 异步操作处理
actions: {
async fetchUserData(userId) {
this.loading = true
try {
const response = await api.getUserById(userId)
this.user = response.data
this.error = null
} catch (error) {
this.error = error.message
} finally {
this.loading = false
}
}
}
4. 错误处理
actions: {
async saveData(data) {
try {
await api.saveData(data)
this.savedSuccessfully = true
} catch (error) {
this.errorMessage = error.message
throw error // 重新抛出错误以便上层处理
}
}
}
与 Vuex 的对比
| 特性 | Pinia | Vuex |
|---|---|---|
| API 复杂度 | 简单直观 | 相对复杂 |
| TypeScript 支持 | 原生支持 | 需要额外配置 |
| 体积 | 更小 | 较大 |
| Vue DevTools 支持 | 更好 | 基础支持 |
| 学习成本 | 低 | 中等 |
总结
Pinia 作为 Vue 生态的新一代状态管理解决方案,以其简洁的 API、出色的 TypeScript 支持和现代化的设计理念,成为构建 Vue 应用的理想选择。通过合理使用 Pinia,我们可以构建出结构清晰、易于维护的状态管理架构,提升开发效率和应用质量。
在实际项目中,建议根据业务需求合理设计 store 结构,遵循单一职责原则,将相关联的状态组织在一起,同时注意避免 store 之间的过度耦合,保持良好的可维护性。
前端首屏渲染性能优化小技巧
` export default defineNuxtPlugin(() => { if (process.client) { const optimizeCSSLoading = () => { const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]')) as HTMLLinkElement[]
links.forEach((link) => {
const href = link.getAttribute('href')
if (!href) return
if (href.includes('entry') && href.includes('.css')) {
link.setAttribute('media', 'print')
link.onload = function() {
const linkElement = this as HTMLLinkElement
linkElement.setAttribute('media', 'all')
}
}
})
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', optimizeCSSLoading)
} else {
setTimeout(optimizeCSSLoading, 0)
}
} }) `
plugins文件夹下创建async-css.client文件
vue3+ts+nuxt.js+sass+pina+vuetify+unocss+autoprefixer
这是一个用于优化 CSS 加载性能的 Nuxt 插件。 async-css.client.ts 实现了“异步 CSS 加载”优化,用于提升首屏渲染
工作原理
- 查找入口 CSS 文件:查找所有包含 entry 和 .css 的样式表链接(通常是 Nuxt 生成的入口 CSS)。
- 临时设置为打印媒体:将这些 CSS 的 media 属性设为 print,使其不阻塞渲染。
- 加载完成后恢复:CSS 加载完成后,将 media 改回 all,样式生效。
为什么这样做?
- 问题:CSS 是渲染阻塞资源,会阻塞页面渲染,导致白屏。
- 解决:通过 media="print" 让浏览器异步加载,不阻塞渲染,加载完成后再应用样式。
注意事项
- 优点:减少首屏阻塞,提升 FCP/LCP。
- 风险:可能出现短暂无样式(FOUC),但通常不明显。
-
建议
如果遇到样式闪烁或加载问题,可以:
- 移除该插件(删除文件)
- 或调整逻辑,只对非关键 CSS 应用此优化
lecen:一个更好的开源可视化系统搭建项目--组件和功能按钮的权限控制--全低代码|所见即所得|利用可视化设计器构建你的应用系统-做一
控制
我们通过多种方式来控制用户的权限,可以精准的针对不同的用户来达到想要的效果。
首先从权限维度来说,主要分为两方面:1. 用户拥有的角色,2. 动态配置的code
再从控制的手段来说,也主要分为两方面:1. 操作视图,2. 操作dom元素
由于有交叉部分,并且还有很多细化的地方,因此我们按照实现方式来对此进行说明。
对于权限的控制,我们可以通过在组件的json对象上添加 control 属性对象来实现。
每个组件都可以配置 control 属性,其中的 if 属性是个方法,通过返回值的 true 和 false 来控制组件的显示及隐藏。
比如在按钮组件上添加权限控制:
[{
col: [{
_span: 24,
default: [{
is:'lc-button',
default: [{
is: 'lc-text',
default: '新增'
}],
control: {
code: 'add',
permission: ['roleA', 'roleC'],
noPermission: ['roleM'],
if: function() {
//控制组件是否渲染
return true
},
call: function() {
//组件渲染完毕,可以执行操作
console.log('组件渲染完毕,可以执行操作')
},
dom: 'button_add',
view: 'button_add'
},
}]
}]
}]
code
我们在访问不同的页面的时候,可能会根据配置的不同,以及接口返回的数据不同,把得到的一个或多个 code 值存储起来,每个页面都可能是不一样的 code 组,当解析该组件的时候,会根据当前设定的 code 去对应的 code 组里面去查找,如果找到则进行渲染,如果找不到,则直接跳过该组件。
存储 code 的集合在 requestData 对象的 code 属性对象上。
例:
页面上有两个按钮:新增和删除
![]()
现在 requestData 对象下面的 code 对象是一个空对象
![]()
正常情况下,页面中的该 code 值应该是从接口返回的,或者根据其他配置进行设置的,现在为了方便,我们直接修改它的值
![]()
然后我们给新增和删除按钮分别配置上不同的 code
[{
col: [{
default: [{
is: 'lc-button',
control: {
code: 'add'
},
default: [{
is: 'lc-text',
default: '新增'
}]
}, {
is: 'lc-button',
_type: 'danger',
control: {
code: 'delete'
},
default: [{
is: 'lc-text',
default: '删除'
}]
}],
_span: 24
}]
}]
上面展示的是数据视图的代码形式,我们在设计的时候直接通过属性配置的方式进行设置即可
![]()
这时新增按钮通过 add 标识来控制,删除按钮通过 delete 标识来控制,现在我们去掉删除按钮的权限
![]()
那么页面上就没有了删除按钮
![]()
requestData.code 对象除了可以设置权限标识为 true 或 false 之外,还能够设置为权限对象
比如可以设置成这样
![]()
我们设置了一个 pageButton 的权限对象,里面包含了 add 和 delete 的控制
然后修改一下这两个按钮的 control 配置
[{
col: [{
default: [{
is: 'lc-button',
control: {
code: 'pageButton.add'
},
default: [{
is: 'lc-text',
default: '新增'
}]
}, {
is: 'lc-button',
control: {
code: 'pageButton.delete'
},
_type: 'danger',
default: [{
is: 'lc-text',
default: '删除'
}]
}],
_span: 24
}]
}]
这样页面上就只剩删除按钮了
![]()
对象的层级没有限制,也就是说可以任意的制定权限控制的规则和分类等。比如 pageButton.add.enable,只要属性值为逻辑true即可
permission
用户在登录成功之后,会获取到配置的对应身份组 identity,我们可以为 permission 字段设定具有一个或多个值的权限组,
如果 permission 中的至少一个字段能够在 identity 中找到,那么就会进行渲染,否则直接跳过该组件。
还是使用上面的例子,这次我们用 permission 字段来控制按钮的权限
[{
col: [{
default: [{
is: 'lc-button',
control: {
permission: ['roleA', 'roleC']
},
default: [{
is: 'lc-text',
default: '新增'
}]
}, {
is: 'lc-button',
_type: 'danger',
control: {
permission: ['roleB']
},
default: [{
is: 'lc-text',
default: '删除'
}]
}],
_span: 24
}]
}]
然后给我们的 identity 赋值上相应的身份标识
![]()
当前用户具有 roleA、roleB、roleC 三个身份,因此两个按钮都能够渲染出来
![]()
当我们去掉 roleA、roleB 两个身份时
![]()
相应的按钮权限也会实时生效
![]()
noPermission
我们可以为 noPermission 字段设定具有一个或多个值的无权限组,如果 noPermission 中的至少一个字段能够在身份组 identity 中找到,那么就表示该角色无权渲染该组件,将会直接跳过,否则一个都未找到的话,那么才会进行渲染。
同样,我们将上面配置的 permission 改为 noPermission
[{
col: [{
default: [{
is: 'lc-button',
control: {
noPermission: ['roleA', 'roleC']
},
default: [{
is: 'lc-text',
default: '新增'
}]
}, {
is: 'lc-button',
_type: 'danger',
control: {
noPermission: ['roleB']
},
default: [{
is: 'lc-text',
default: '删除'
}]
}],
_span: 24
}]
}]
这时用户只有 roleC 这一个身份,那么将只会渲染删除按钮,因为 roleC 身份没有新增按钮的权限
![]()
if
如果需要手动的去做判断,那么可以使用 if 字段,它的值是一个函数。
可以在函数内部执行某些逻辑,函数体内可以通过this获取到暴露出来的属性和方法等。
![]()
然后手动指定返回值为 true 或者 false
如果 if 返回为 false,将会跳过该数据视图的渲染,如果返回为true,那么表示有权限,则会进行渲染。
[{
col: [{
default: [{
is: 'lc-button',
control: {
if: function() {
// 这里是一些逻辑
return true
}
},
default: [{
is: 'lc-text',
default: '新增'
}]
}, {
is: 'lc-button',
_type: 'danger',
control: {
if: function() {
// 这里是一些逻辑
return false
}
},
default: [{
is: 'lc-text',
default: '删除'
}]
}],
_span: 24
}]
}]
此时只会渲染返回值为 true 的组件
![]()
call
当数据视图渲染完成之后,将会调用 call 对应的函数。同样在 call 的函数体内可以通过 this 获取到相应的变量和方法,并可额外通过$el获取到渲染完成的页面中对应的元素,可以进行一些处理操作,不需要返回值。
比如我们通过 describe 对象给新增按钮换一个类型,然后通过 $el 给删除按钮修改一下字体的大小
[{
col: [{
default: [{
is: 'lc-button',
control: {
call: function() {
// 这里是一些逻辑
this.describe._type = 'primary'
}
},
default: [{
is: 'lc-text',
default: '新增'
}]
}, {
is: 'lc-button',
_type: 'danger',
control: {
call: function() {
// 这里是一些逻辑
let el = this.$el.ref
el.style.fontSize = '18px'
}
},
default: [{
is: 'lc-text',
default: '删除'
}]
}],
_span: 24
}]
}]
这样就可以做到在组件渲染完成之后进行任意的处理
![]()
dom
根据设定的 dom 字段的值,当元素渲染完成后,可以通过 controlData 对象来访问对应的元素,如设定为 button_add,那么访问形式就可以这样写:controlData.button_addDom,就得到了对页面元素的引用。
[{
col: [{
default: [{
is: 'lc-button',
control: {
dom: 'button_add'
},
default: [{
is: 'lc-text',
default: '新增'
}]
}, {
is: 'lc-button',
_type: 'danger',
control: {
dom: 'button_delete'
},
default: [{
is: 'lc-text',
default: '删除'
}]
}],
_span: 24
}]
}]
新增和删除按钮的dom引用就被添加到了 controlData 对象中
![]()
view
根据设定的 view 字段的值,当元素渲染完成后,可以通过 controlData 对象来访问对应的视图,如设定为 button_add,那么访问形式就可以这样写:controlData.button_addView,就得到了对该视图的引用。
[{
col: [{
default: [{
is: 'lc-button',
control: {
view: 'button_add'
},
default: [{
is: 'lc-text',
default: '新增'
}]
}, {
is: 'lc-button',
_type: 'danger',
control: {
view: 'button_delete'
},
default: [{
is: 'lc-text',
default: '删除'
}]
}],
_span: 24
}]
}]
新增和删除按钮的视图引用就被添加到了 controlData 对象中
![]()
可以看到除了dom和view的引用被添加到了 controlData 对象中外,还有一个以DT结尾的对象引用也被添加到了 controlData 对象中。
dom和view需要手动指定后才会被收集到 controlData 对象中,所有被命名的数据视图都会自动的被收集到 controlData 对象中,并以DT结尾来标识
提示:
code、permission、noPermission、if可以同时存在,但是他们之间有一个优先级的关系,因此设置的时候尽量不要冲突,如permission设定为[roleA],noPermission也设定为[roleA],那么noPermission将不会生效。他们之间的优先级关系为:
code > permission > noPermission > if,当设定产生冲突时,将会按照这个优先级进行处理。
注意 permission、noPermission 必须要设定为数组的形式,不支持字符串的设定。
dom 和 view 字段都是字符串,他们不做任何逻辑处理,只是保留了元素和视图的引用,以备在其他地方引用。
【项目体验】
【节点】[Channel-Combine节点]原理解析与实际应用
Element UI 表格 show-overflow-tooltip 长文本导致闪烁的根本原因与解法
问题复现
在 Element UI (Vue 2) 项目中,el-table-column 开启 show-overflow-tooltip 展示超长文本(500字+)。
现象: 鼠标悬停单元格,Tooltip 疯狂闪烁(显示-消失-显示循环),侧边滚动条也跟随闪烁(副作用)。
关键环境信息: 页面原本就有滚动条(无布局重排),但依然闪烁。
排查与验证
初步排查: 曾怀疑是 Tooltip 撑开页面导致滚动条出现进而挤压布局。但经过验证,页面滚动条一直存在,布局并未发生位移,因此排除“重排(Reflow)”导致的坐标变化。
核心对照实验:
-
自动模式:使用
show-overflow-tooltip-> 闪烁。 -
手动模式:在
template中使用<el-tooltip>包裹内容,不限制宽高 -> 不闪烁。
根本原因分析
既然布局没动,为什么会自动关闭?答案是 Tooltip 自身的遮挡与事件逻辑缺陷。
1. 遮挡触发 (Occlusion)
由于文本极长,Tooltip 渲染尺寸巨大。在特定分辨率下,Popper.js 计算出的定位会导致 Tooltip 弹出的一瞬间,其 DOM 元素直接覆盖(Overlap)在了鼠标光标之上。
2. 机制差异
-
show-overflow-tooltip(Table 内置逻辑) :Element UI 的 Table 组件使用单例模式维护一个全局 Tooltip。它主要监听单元格(Cell)的mouseleave事件。Bug 流程: Tooltip 弹出盖住鼠标 -> 浏览器判定鼠标离开单元格(进入 Tooltip) -> 触发 Cell 的mouseleave-> Table 的处理逻辑较为脆弱,在判定“鼠标是否进入 Tooltip”时出现时序问题或逻辑漏洞 -> 直接关闭 Tooltip。 Tooltip 关闭 -> 鼠标重新落回单元格 -> 触发mouseenter-> 死循环。 -
手动
<el-tooltip>(独立组件) :手动模式下,每个单元格拥有独立的 Tooltip 实例。该组件内部对enterable(鼠标进入浮层)有完善的处理机制。 正常流程: Tooltip 弹出盖住鼠标 -> 组件检测到鼠标虽然离开了 Reference(触发源),但进入了 Popper(浮层) -> 保持显示状态。
结论与解决方案
show-overflow-tooltip 是一个为了性能牺牲了部分交互稳定性的“阉割版”实现,无法完美处理“弹出层直接遮挡触发源”的极端情况。
最佳解法: 放弃 show-overflow-tooltip,使用 Slot 手动接管。
<el-table-column label="详情" width="300">
<template slot-scope="scope">
<el-tooltip
effect="dark"
:content="scope.row.detail"
placement="top"
popper-class="my-popper"
>
<div class="ellipsis-cell">{{ scope.row.detail }}</div>
</el-tooltip>
</template>
</el-table-column>
建议优化: 虽然手动挡不限制宽高也不会闪烁,但为了阅读体验,建议通过 CSS 限制最大高度。
/* 全局样式 */
.my-popper {
max-width: 400px;
max-height: 300px;
overflow-y: auto;
}
总结
当排查“幽灵闪烁”问题时,如果页面布局未动,请重点关注层级遮挡导致的鼠标事件丢失。对于复杂场景,手动控制的组件永远比自动的语法糖更可靠。
鸿蒙5、6用户h5页面使用schemeURL跳转小程序失败
🌟 藏在 Vue3 源码里的 “二进制艺术”:位运算如何让代码又快又省内存?
自定义AI智能体扫描内存泄漏代码
引言
在日常开发时,由于部分代码不严谨,浏览器无法及时回收内存(GC),容易导致 内存泄漏 和页面卡顿。
传统排查内存泄漏需要通过浏览器 Memory 面板 多次快照、分析和定位,过程复杂且耗时。
结合 AI 技术,一些前端内存泄漏问题可以被快速发现并解决。使用 AI 扫描代码,可以自动识别潜在问题,提高排查效率。
本文将结合 CodeBuddy + Cloud 模型,通过自定义智能体对代码进行精准的内存泄漏分析,辅助开发者快速定位问题并进行修复。
CodeBuddy:AI助力内存泄漏扫描
什么是CodeBuddy
CodeBuddy 是腾讯云推出的多形态 AI 编程助手,覆盖编译器插件、AI IDE(类 Cursor)和命令行三种形态。其命令行模式可通过简短指令对整个项目进行快速扫描,自动执行内存泄漏分析,显著提升排查效率。
![]()
如何使用
安装
可以打开任意项目,在控制台输入:
npm install -g @tencent-ai/codebuddy-code
CodeBuddy需要node版本大于18.20.8, 使用前使用nvm切换node。
登录
安装完毕后,控制台输入codebuddy,即可使用。第一次使用,需要登录,有两种登录方式:
![]()
- Google / Github
使用这种方式登录,会打开海外版,内置Gemini-2.5-Pro、Gemini-2.5-Flash、GPT-5、GPT-5-codex、GPT-5-mini、GPT-5-nano、GPT-4o-mini等模型。
- 微信扫码登录
微信登录的是国内版,使用DeepSeek-V3.1模型。
强烈建议使用Google / Github登录,选择 GPT-5 或 GPT-5-codex 模型
使用说明
登录成功后,显示如下:
![]()
codebuddy的入门提示如下:
- 按 / 使用命令,按 @ 引用文件。
- 按 Esc 键两次可重置输入框。
- 输入时按 Shift + Enter 可以换行。
如果我们不想做任何配置,想快速得到一份内存泄漏报告,最简单的方式就是在输入框中写入简明提示词:
![]()
扫描 src 文件夹下的 views 和 components 目录,分析出存在内存泄漏的代码,并输出一个可直接打开的 HTML 报告,报告中包含详细的内存泄漏分析和修改建议。
如果项目体量很大,可以分文件扫描,避免token浪费,扫描出错。
命令
CodeBuddy的输入框中按 / 使用命令,可用命令如下:
| 命令 | 功能说明 |
|---|---|
| /add-dir | 添加一个新的工作目录 |
| /agents | 管理智能体(agent)配置 |
| /bashes | 列出并管理后台运行的任务 |
| /clear | 清除当前会话历史并释放上下文 |
| /compact | 清除会话历史,但保留摘要在上下文中(可选:/compact [摘要指令]) |
| /config | 打开配置面板 |
| /cost | 显示当前会话的总费用和持续时间 |
| /doctor | 诊断并验证 CodeBuddy 的安装与设置是否正常 |
| /exit | 退出 CodeBuddy |
| /export | 将当前会话导出为文件或复制到剪贴板 |
| /help | 显示帮助和所有可用命令 |
| /hooks | 管理工具事件(Tool Events)的钩子配置 |
| /ide | 管理 IDE 集成并显示状态 |
| /init | 分析你的代码库(初始化项目) |
| /install-github-app | 为某个仓库设置 GitHub Actions 集成 |
| /login | 登录或切换腾讯云 CodeBuddy 账号 |
| /logout | 登出腾讯云 CodeBuddy 账号 |
| /mcp | 管理 MCP 服务器(Model Control Protocol) |
| /memory | 编辑 CodeBuddy 的记忆文件 |
| /migrate-installer | 从全局 npm 安装迁移为本地安装 |
| /model | 设置 CodeBuddy 使用的 AI 模型 |
| /permissions | 管理工具权限规则(允许/拒绝) |
| /pr-comments | 获取 GitHub Pull Request 的评论 |
| /release-notes | 查看版本更新说明 |
| /resume | 恢复一个之前的会话 |
| /review | 审查一个 Pull Request(代码评审) |
| /status | 显示 CodeBuddy 状态(版本、模型、账号、API 连接和工具状态) |
| /terminal-setup | 安装 Shift+Enter 组合键用于输入换行 |
| /todos | 显示当前会话的待办事项列表 |
| /upgrade | 在浏览器中打开升级页面 |
| /vim | 切换 Vim 模式与普通编辑模式 |
| /workspace | 切换到其他工作文件夹 |
eq:输入/exit,可以退出当前工具。
![]()
费用
目前是免费使用,采用的是积分方式,可以在 www.codebuddy.ai/profile/usa… 查看自己的积分。
最佳实践
如果不做任何配置,扫描出的代码效果差强人意,尤其是在扫描量大的情况。可以按照下面的思路优化工作流程。
选择合适的模型
codebuddy命令行输入/model,选择合适的模型。
![]()
自定义智能体
命令行输入/agents进入智能体设置面板,选择Create new agent创建智能体,智能体可以针对所有项目Personal (~/.codebuddy/agents/) 生效,也可以只针对当前项目生效Project (.codebuddy/agents/) 。
我们可以选择Personal (~/.codebuddy/agents/)
![]()
创建后,在输入框输入智能体名称,codebddy会帮我们预设一个提示词promopt。生成完毕后,点击ESC可以退出当前页面。
然后,我们在命令行重新输入/agents,选择我们刚创建的智能体。
![]()
选择编辑
![]()
在弹出的md文件内,可以填入如下预设词:
预设词可以根据自己的情况喜好设置,下面是一个自定义示例
你是前端内存泄漏静态分析专家。
任务:扫描用户代码,识别潜在内存泄漏。
请遵循以下规则:
项目说明:
- 这是一个基于vue2+element的微前端子应用,在主应用可能会重复加载卸载。
- 打包时,vue,vuex,vue-router及element-ui已经做了依赖排除
- 目标是识别子应用卸载后可能残留的 DOM、事件、定时器、全局变量及其他潜在泄漏。
1️⃣ 扫描范围
- 扫描目录:src
- 文件类型:.js, .ts, .vue
- 排除目录:api, assets, theme, style
- 特殊处理:
- src/views 文件过多时,可按子目录拆分,使用 views 及子目录名称生成对应 JSON 文件
- 如果单次扫描接近输出限制,提示用户是否继续
- 仅扫描源码目录,不扫描第三方依赖
2️⃣ 内存泄漏规则:
A. 事件监听器泄漏
- addEventListener、on、subscribe、watch 等注册未解绑
- window / document / body / 子应用全局事件未清理
- 事件回调闭包捕获 DOM 节点或框架组件实例
B. 定时器 / 异步任务泄漏
- setTimeout / setInterval / requestAnimationFrame 未清理
- Promise / async 回调闭包持有 DOM 或组件实例
- Worker 内定时器 / Observer / fetch 等异步资源未清理
C. 全局变量 / 全局状态泄漏
- window.xxx / globalThis.xxx / global.xxx 保存 DOM / 组件 / 大对象
- 长期增长的 Map / Set / Array / 缓存对象未释放
D. 闭包持有 DOM 或组件
- 函数闭包捕获 DOM 节点或 Vue/React 组件实例
- 回调 / 定时器 / Promise / Worker message 闭包持有外部引用
E. 框架组件生命周期泄漏
- Vue: watch /custom event 未在 unmount / destroyed 阶段清理
F. 微前端 / 子应用卸载泄漏
- 子应用卸载时 DOM 未移除
- 全局事件 / window 注入变量未清理
- 重复加载 JS
- Worker 未 terminate
G. Observer 泄漏
- MutationObserver / ResizeObserver / IntersectionObserver / PerformanceObserver 未调用 disconnect
H. 第三方库 / 资源泄漏
- ECharts 等未 dispose / destroy
- AudioContext / MediaStream / Canvas / WebGL context 未释放
- Blob URL / ObjectURL 未 revoke
I. 其他可能导致内存泄漏问题
- 未销毁的自定义缓存或全局单例
- 被闭包引用的临时 DOM 或组件实例
- 未释放的文件引用、图像或网络资源
- 子应用间共享状态导致的引用残留
3️⃣ 输出要求
- 在项目根目录 生成一个 memory-analysis.json 文件。
- src下每个一级目录对应一个 JSON 对象,记录该目录下的扫描结果。
- 每条扫描结果包含该目录下 所有 JS / TS / Vue 文件 的潜在内存泄漏信息,数组形式存储。
- 记录格式::
[{
"file": "文件名或相对路径,包含父级完整路径",
"line": 23,
"sort": 1,
"type": "事件监听器 | 定时器 | 全局变量 | 闭包 | 框架组件 | 子应用卸载 | Observer | 第三方库",
"severity": "高 | 中 | 低",
"code_snippet": "相关代码片段,推荐保留2~10行,便于理解",
"description": "简明、易懂的内存泄漏原因说明",
"recommendation": "可直接参考的修复方法或示例,要非常详细。格式是一个可以md格式的文件,保证解析后代码可以展示。"
}]
- 字段说明:
- file:文件名或相对路径,包含父级全路径,便于快速定位。
- line:泄漏代码行号
- sort:问题序号,以此递增。
- type:泄漏类型
- severity:风险等级,高/中/低
- code_snippet:2~10 行代码片段,便于理解
- description:简明原因说明
- recommendation:可直接参考的修复方法或示例,要非常详细。格式是一个可以md格式的文件,保证解析后代码可以展示。
4️⃣ 输出规则
- 如果某目录或文件过多导致输出接近 AI 单次限制,应提示用户确认是否继续
- 排除非源码目录(api, assets, theme, style)
- 每条潜在泄漏必须包含 file、line、type、severity、code_snippet、description、recommendation
- 输出 JSON 数组。
5️⃣ 额外要求
- 分析闭包引用链,重点关注 DOM 节点和组件实例
- 按照文件目录以此分析,输出json文件。
- 不要展示思考过程,直接输出结果。
- 没问题的目录可以直接跳过,不用生成json文件。
- 分析文件时,可以查找文件引用链(可跨文件),保证结果准确性。
分批扫描
一次扫描所有文件,性能,结果都很差,可以分批扫描。
命令行输入@可以选择上下文文件夹
![]()
也可以在输入框明确提示,比如:
帮我扫描componets文件夹及其子目录,分析出内存泄漏的代码,输出一个html格式的内存泄漏报告。
结果示例
按照预设的prompt,可以按照提示词生成对应的JSON文件(方便二次加工处理)。
如
![]()
可以按照一定的提示词,生成html报告:
![]()
到底是用nuxt的public还是assets?一篇文章开悟
assets
Nuxt为你的资产提供了两种处理方式。
Nuxt使用两个目录来处理样式表、字体或图片等资产:
-
public/目录的内容会直接以服务器根路径的形式提供。 -
assets/目录按惯例包含你希望构建工具(Vite或webpack)处理的所有资产。
公共目录
public/目录用作静态资产的公共服务器,这些资产可以在你的应用定义的URL下公开访问。
你可以通过应用的代码或浏览器使用根URL/来获取public/目录中的文件。
示例
例如,引用位于public/img/目录中的图像文件,可通过静态URL/img/nuxt.png访问:
// app.vue
<template>
<img src="/img/nuxt.png" alt="探索 Nuxt" />
</template>
资产目录
Nuxt使用Vite(默认)或webpack来构建和打包你的应用。这些构建工具的主要功能是处理JavaScript文件,但可以通过插件(用于Vite)或加载器(用于webpack)扩展,以处理其他类型的资产,例如样式表、字体或SVG。这一过程主要为了性能或缓存目的转换原始文件(例如样式表压缩或浏览器缓存失败)。
按照惯例,Nuxt使用assets/目录来存储这些文件,但该目录没有自动扫描功能,你可以为它使用任何其他名称。
在应用代码中,可以通过~/assets/路径引用位于assets/目录中的文件。
示例
例如,引用一个图像文件,如果构建工具配置为处理此文件扩展名,该文件将被处理:
// app.vue
<template>
<img src="~/assets/img/nuxt.png" alt="探索 Nuxt" />
</template>
Nuxt不会以静态URL(如/assets/my-file.png)提供assets/目录中的文件。如果你需要静态URL,请使用public/目录。
区别 —— 纠结用哪个可以看这张表
| 特性 |
public/目录 |
assets/目录 |
|---|---|---|
| 目的 |
静态资源服务器 |
构建时处理的资源 |
URL访问 |
直接通过根路径/访问 |
需通过~/assets/路径引用 |
| 构建处理 | 不经过构建工具处理 | 经过Vite/webpack处理(压缩、优化等) |
| 更新方式 | 直接替换文件 | 构建后生成新文件(带哈希) |
| 适用场景 | 不常更改的资源(如favicon、robots.txt) |
需要构建优化的资源(图片、样式、字体) |
public/
// vue
<!-- 适用于: -->
<!-- 不常更新的静态文件 -->
<img src="/favicon.ico" alt="网站图标" />
<a href="/brochure.pdf">下载手册</a>
assets/
// vue
<!-- 适用于: -->
<!-- 需要优化处理的图片 -->
<img src="~/assets/images/hero.jpg" alt="英雄" />
<!-- 2. 样式文件(SCSS/SASS/LESS) -->
<style>
@import '~/assets/styles/main.scss';
</style>
<!-- 3. 字体文件 -->
<style>
@font-face {
font-family: 'CustomFont';
src: url('~/assets/fonts/custom.woff2') format('woff2');
}
</style>
实战目录结构
my-nuxt-app/
├── public/
│ ├── favicon.ico # 直接通过 /favicon.ico 访问
│ ├── robots.txt # 直接通过 /robots.txt 访问
│ └── downloads/
│ └── brochure.pdf # 直接通过 /downloads/brochure.pdf 访问
├── assets/
│ ├── images/
│ │ ├── logo.png # 构建优化后的图片
│ │ └── background.jpg
│ ├── styles/
│ │ ├── main.scss # 编译处理的SCSS文件
│ │ └── variables.scss
│ └── fonts/
│ └── custom.woff2 # 字体文件
└── components/
└── MyComponent.vue
<!-- 优先使用assets/以获得构建优化 -->
<img src="~/assets/images/product.jpg" alt="产品" />
<!-- 大尺寸或不需要优化的图片可以放在public -->
<img src="/documentation/large.png" alt="架构图" >
动态
<script setup>
// 使用 import 获取 assets 资源(会经过构建处理)
import logo from '~/assets/images/logo.png'
// 使用相对路径引用 public 资源
const publicImage = '/images/banner.png'
</script>
css中用
/* 在 CSS 中引用 assets 资源 */
.hero {
background-image: url('~/assets/images/bg.jpg');
}
/* 引用 public 资源 */
.external {
background-image: url('/external-bg.png');
}
提醒
-
缓存策略:
-
assets/中的文件通常会添加哈希值,便于缓存管理 -
public/中的文件使用原始名称,需手动管理缓存
-
-
部署考虑:
-
public/目录内容会原样复制到构建输出 -
assets/目录内容会被处理并打包
-
-
性能优化:
- 小图标建议使用
assets/以便打包优化 - 大文件(如视频)建议使用
public/避免构建过程变慢
- 小图标建议使用
通用语法校验器tree-sitter——C++语法校验实践
tree-sitter介绍
以下内容来自于官方文档:tree-sitter.github.io/tree-sitter…
Tree-sitter 是一个解析器生成工具和增量解析库,用于为源代码文件构建具体的语法树,并在源文件编辑时高效更新语法树。它旨在提供一个通用、快速且鲁棒的解决方案,用于解析编程语言,即使在存在语法错误的情况下也能正常工作。
主要特点:
- 通用性:能够解析任何编程语言。
- 高效性:足够快,可以在文本编辑器中每按一个键就进行解析。
- 鲁棒性:即使有语法错误,也能提供有用的结果。
- 无依赖:运行时库使用纯 C11 编写,可以嵌入到任何应用程序中。
工作原理:
Tree-sitter 生成解析器并维护一个增量解析库,随着源文件的编辑实时更新语法树,从而支持如文本编辑器中的实时解析。
支持的语言:
- 语言绑定:支持 C# (.NET)、C++、Crystal、D、Delphi、ELisp、Go、Guile、Janet、Java (JDK 8+ 和 11+)、Julia、Lua、OCaml、Odin、Perl、Pharo、PHP、R 和 Ruby 等语言的绑定(部分绑定可能不完整或过时)。
tree-sitter的缺点
Tree-sitter 不是利用编程语言(如 C++、JavaScript 等)的现有或官方解析器来进行解析的。它是一个独立的解析器生成工具,使用自己的框架和语法定义来为各种语言生成专属的解析器。这些解析器基于 GLR(广义 LR)算法构建,并通过 Tree-sitter 的工具包预先编译或运行时生成,而不是依赖语言的内置运行时环境(如 V8 对于 JavaScript)。这种设计允许 Tree-sitter 在编辑器中实现高效的增量解析,但也可能导致与官方解析器在某些边缘情况下的不一致。
因此,如果需要高质量的语法解析,请不要用tree-sitter。 虽然tree-sitter提供了api让开发者编写更精细的解析,但不如考虑其他wasm方案或Language Server Protocol (LSP)
测例
以下c++代码中有4处错误,包括使用了关键字/错误的声明/使用了未定义变量/错误语法(a+++),但只识别到了最后一个。
int main()
{
int int = 1;
inb a = 1;
int a = 0, b = 1, c = 2, d = 3, e = 4;
if (x > 1)
{
}
a++ + ;
if (a || (b < c && e >= d))
{ /* ... */
}
return 0;
}
playground
tree-sitter.github.io/tree-sitter…
在浏览器环境中使用tree-sitter
tree-sitter支持在浏览器环境中使用,方法也很简单。
安装依赖
npm install web-tree-sitter
将
生成wasm
语法校验需要对应语言的wasm,生成步骤如下:
- 安装依赖
npm install --save-dev tree-sitter-cli tree-sitter-cpp
将node_modules中的web-tree-sitter.wasm文件复制到public目录下 2. 执行命令,在当前目录下生成tree-sitter-cpp.wasm
npx tree-sitter build --wasm node_modules/tree-sitter-cpp
3. 将生成的tree-sitter-cpp.wasm放入public目录下
实践demo
代码如下:
import { code } from "./code";
import { Parser, Language, Query } from "web-tree-sitter";
async function main() {
await Parser.init({
locateFile(scriptName: string, scriptDirectory: string) {
return scriptName;
},
});
const cpp = await Language.load("tree-sitter-cpp.wasm");
const parser = new Parser();
parser.setLanguage(cpp);
const tree = parser.parse(code);
console.log(tree);
console.log(tree?.rootNode);
if (!tree!.rootNode.hasError) {
console.log("没有错误");
return;
} else {
console.log("存在错误");
}
// 异常查询
const queryString = "(ERROR) @error-node (MISSING) @missing-node";
const language = parser.language;
const query = new Query(language!, queryString);
const root = tree!.rootNode;
// Execute query and get matches
const matches = query.matches(root!);
const errorNodes = [];
for (const match of matches) {
for (const capture of match.captures) {
errorNodes.push(capture.node);
}
}
console.log(errorNodes);
if (errorNodes.length) {
const { row, column } = errorNodes[0]!.startPosition;
console.log(`${row + 1}行,${column + 1}列存在错误`);
}
}
main();
错误节点捕获:
![]()
[ECharts] Instance ec_1234567890 has been disposed
📋 目录
🔍 问题背景
在 Vue 3 项目中使用 ECharts 时,经常会遇到以下控制台警告:
[ECharts] Instance ec_1234567890 has been disposed
这个警告虽然不会影响功能,但表明存在潜在的内存泄漏问题。
问题原因
-
图表实例已销毁,但事件监听器仍在运行
- 调用
chart.dispose()销毁图表后 -
window.resize事件监听器仍然存在 - 监听器尝试调用已销毁实例的
chart.resize()方法 - 导致 ECharts 输出警告信息
- 调用
-
重复添加事件监听器
- 每次重新渲染图表时都添加新的
resize监听器 - 旧的监听器没有被清理
- 导致内存泄漏和事件堆积
- 每次重新渲染图表时都添加新的
⚠️ 常见问题
问题 1:直接销毁图表实例
// ❌ 错误做法
if (chartInstance) {
chartInstance.dispose(); // 直接销毁,但 resize 监听器还在
}
const chart = echarts.init(container);
window.addEventListener("resize", () => {
chart.resize(); // 监听器引用了图表实例
});
问题:
- 销毁图表后,
resize监听器仍然存在 - 监听器尝试调用已销毁实例的方法
- 产生 "has been disposed" 警告
问题 2:重复添加监听器
// ❌ 错误做法
function renderChart() {
const chart = echarts.init(container);
// 每次调用都添加新监听器
window.addEventListener("resize", () => {
chart.resize();
});
}
// 多次调用导致监听器堆积
renderChart(); // 添加第 1 个监听器
renderChart(); // 添加第 2 个监听器
renderChart(); // 添加第 3 个监听器
问题:
- 每次渲染都添加新的监听器
- 旧的监听器没有被清理
- 导致内存泄漏
✅ 解决方案
核心思路
在销毁图表实例前,先移除所有相关的事件监听器
实现步骤
1. 存储图表实例和监听器
import { ref } from "vue";
// 存储图表实例
const chartInstances = ref({});
// 存储每个图表的 resize 处理函数
const resizeHandlers = ref({});
2. 渲染图表时正确管理监听器
const renderChart = (data, containerId) => {
nextTick(() => {
const container = document.getElementById(containerId);
if (!container) return;
// ✅ 步骤 1:清理旧实例
if (chartInstances.value[containerId]) {
// 先移除旧的 resize 监听器
if (resizeHandlers.value[containerId]) {
window.removeEventListener("resize", resizeHandlers.value[containerId]);
}
// 再销毁图表实例
chartInstances.value[containerId].dispose();
}
// ✅ 步骤 2:创建新实例
const chartInstance = echarts.init(container);
chartInstances.value[containerId] = chartInstance;
// ✅ 步骤 3:配置并渲染图表
const option = {
// ... 图表配置
};
chartInstance.setOption(option);
// ✅ 步骤 4:添加 resize 监听器并存储
const resizeHandler = () => {
chartInstance.resize();
};
resizeHandlers.value[containerId] = resizeHandler;
window.addEventListener("resize", resizeHandler);
});
};
3. 组件卸载时完整清理
import { onBeforeUnmount } from "vue";
onBeforeUnmount(() => {
// ✅ 步骤 1:移除所有 resize 监听器
Object.entries(resizeHandlers.value).forEach(([containerId, handler]) => {
if (handler) {
window.removeEventListener("resize", handler);
}
});
// ✅ 步骤 2:销毁所有图表实例
Object.values(chartInstances.value).forEach(chart => {
if (chart) {
chart.dispose();
}
});
// ✅ 步骤 3:清空引用
chartInstances.value = {};
resizeHandlers.value = {};
});
💻 完整代码示例
Vue 3 组件示例
<script setup>
import { ref, watch, nextTick, onBeforeUnmount } from "vue";
import * as echarts from "echarts";
const props = defineProps({
data: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
});
// 存储图表实例
const chartInstances = ref({});
// 存储每个图表的 resize 处理函数
const resizeHandlers = ref({});
// 渲染图表
const renderChart = (chartData, containerId) => {
nextTick(() => {
const container = document.getElementById(containerId);
if (!container) return;
// 如果已存在图表实例,先清除监听器再销毁
if (chartInstances.value[containerId]) {
// 移除旧的 resize 监听器
if (resizeHandlers.value[containerId]) {
window.removeEventListener("resize", resizeHandlers.value[containerId]);
}
// 销毁图表实例
chartInstances.value[containerId].dispose();
}
// 初始化 ECharts 实例
const chartInstance = echarts.init(container);
chartInstances.value[containerId] = chartInstance;
// 配置图表选项
const option = {
title: { text: "示例图表" },
tooltip: { trigger: "axis" },
xAxis: { type: "category", data: chartData.map(item => item.name) },
yAxis: { type: "value" },
series: [
{
type: "bar",
data: chartData.map(item => item.value),
},
],
};
// 渲染图表
chartInstance.setOption(option);
// 监听窗口大小变化,自动调整图表大小
const resizeHandler = () => {
chartInstance.resize();
};
// 存储 resize 处理函数,以便后续清理
resizeHandlers.value[containerId] = resizeHandler;
window.addEventListener("resize", resizeHandler);
});
};
// 渲染所有图表
const renderAllCharts = () => {
props.data.forEach((chartData, index) => {
renderChart(chartData, `chart-${index}`);
});
};
// 监听数据变化
watch(
() => [props.data, props.loading],
([newData, newLoading]) => {
if (!newLoading && newData.length > 0) {
renderAllCharts();
}
},
{ deep: true, immediate: true },
);
// 组件卸载时销毁所有图表实例
onBeforeUnmount(() => {
// 先移除所有 resize 监听器
Object.entries(resizeHandlers.value).forEach(([containerId, handler]) => {
if (handler) {
window.removeEventListener("resize", handler);
}
});
// 再销毁所有图表实例
Object.values(chartInstances.value).forEach(chart => {
if (chart) {
chart.dispose();
}
});
// 清空引用
chartInstances.value = {};
resizeHandlers.value = {};
});
</script>
<template>
<div class="chart-container">
<div v-for="(chartData, index) in data" :key="index" :id="`chart-${index}`" class="chart"></div>
</div>
</template>
<style scoped>
.chart-container {
padding: 20px;
}
.chart {
width: 100%;
height: 400px;
margin-bottom: 20px;
}
</style>
📊 对比分析
错误做法 vs 正确做法
| 方面 | ❌ 错误做法 | ✅ 正确做法 |
|---|---|---|
| 监听器管理 | 直接添加,不存储引用 | 存储监听器函数引用 |
| 销毁顺序 | 直接销毁图表实例 | 先移除监听器,再销毁实例 |
| 重复渲染 | 监听器堆积 | 清理旧监听器后再添加新的 |
| 组件卸载 | 只销毁图表实例 | 先清理监听器,再销毁实例 |
| 内存泄漏 | ⚠️ 存在 | ✅ 无 |
| 控制台警告 | ⚠️ 有警告 | ✅ 无警告 |
🎯 最佳实践总结
1. 使用对象存储多个图表实例
// ✅ 推荐:使用对象存储,支持多个图表
const chartInstances = ref({});
const resizeHandlers = ref({});
// ❌ 不推荐:单个变量,不支持多图表
const chartInstance = ref(null);
2. 销毁顺序很重要
// ✅ 正确顺序
// 1. 移除事件监听器
window.removeEventListener("resize", resizeHandler);
// 2. 销毁图表实例
chart.dispose();
// ❌ 错误顺序
// 1. 销毁图表实例
chart.dispose();
// 2. 移除事件监听器(此时监听器可能已经触发)
window.removeEventListener("resize", resizeHandler);
3. 存储监听器函数引用
// ✅ 正确:存储函数引用
const resizeHandler = () => {
chart.resize();
};
resizeHandlers.value[containerId] = resizeHandler;
window.addEventListener("resize", resizeHandler);
// 后续可以精确移除
window.removeEventListener("resize", resizeHandlers.value[containerId]);
// ❌ 错误:匿名函数无法移除
window.addEventListener("resize", () => {
chart.resize();
});
// 无法移除这个监听器!
4. 组件卸载时完整清理
onBeforeUnmount(() => {
// ✅ 完整的清理流程
// 1. 移除所有监听器
Object.entries(resizeHandlers.value).forEach(([id, handler]) => {
if (handler) {
window.removeEventListener("resize", handler);
}
});
// 2. 销毁所有图表
Object.values(chartInstances.value).forEach(chart => {
if (chart) {
chart.dispose();
}
});
// 3. 清空引用
chartInstances.value = {};
resizeHandlers.value = {};
});
5. 使用 nextTick 确保 DOM 已渲染
// ✅ 推荐:使用 nextTick
const renderChart = (data, containerId) => {
nextTick(() => {
const container = document.getElementById(containerId);
if (!container) return;
// ... 渲染图表
});
};
// ❌ 不推荐:直接渲染可能找不到 DOM
const renderChart = (data, containerId) => {
const container = document.getElementById(containerId);
// container 可能为 null
};
🔧 其他解决方案
方案 1:禁用 ECharts 警告(不推荐)
// ⚠️ 治标不治本,不推荐
echarts.warn = function () {};
缺点:
- 只是隐藏警告,没有解决根本问题
- 内存泄漏依然存在
- 失去了 ECharts 的其他有用警告
方案 2:使用 try-catch 静默处理(不推荐)
// ⚠️ 不推荐
try {
chart.dispose();
} catch (e) {
// 忽略错误
}
缺点:
- 没有解决监听器泄漏问题
- 可能隐藏其他真正的错误
方案 3:正确管理监听器(✅ 推荐)
// ✅ 推荐:本文介绍的方案
// 1. 存储监听器引用
// 2. 销毁前先移除监听器
// 3. 组件卸载时完整清理
📚 参考资料
💡 总结
- 核心原则:在销毁图表实例前,先移除所有相关的事件监听器
- 存储引用:使用对象存储图表实例和监听器函数引用
- 正确顺序:先移除监听器 → 再销毁图表 → 最后清空引用
- 完整清理:组件卸载时确保所有资源都被正确释放
- 避免泄漏:每次重新渲染前清理旧的监听器
遵循这些最佳实践,可以完全避免 ECharts 的 "has been disposed" 警告,并确保没有内存泄漏问题。
基于PDF.js的安全PDF预览组件实现:从虚拟滚动到水印渲染
基于PDF.js的安全PDF预览组件实现:从虚拟滚动到水印渲染
本文将详细介绍如何基于Mozilla PDF.js实现一个功能完善、安全可靠的PDF预览组件,重点讲解虚拟滚动、双模式渲染、水印实现等核心技术。
前言
在Web应用中实现PDF预览功能是常见需求,尤其是在线教育、文档管理等场景。然而,简单的PDF预览往往无法满足实际业务需求,特别是在安全性方面。本文将介绍如何基于PDF.js实现一个功能完善的PDF预览组件,并重点讲解如何添加自定义防下载和水印功能,为文档安全提供保障。
功能概览
我们的PDF预览组件实现了以下核心功能:
- 基础功能:PDF文件加载与渲染、自定义尺寸控制、页面缩放规则配置、主题切换
- 安全增强:动态水印添加、防下载功能、右键菜单禁用、打印控制
- 用户体验:页面渲染事件通知、响应式布局适配、加载状态反馈
技术实现
1. 虚拟滚动加载
对于大型PDF文件,一次性渲染所有页面会导致严重的性能问题。我们通过虚拟滚动技术优化大文档的加载性能,只渲染当前可见区域和附近的页面:
// 页面缓存管理
class PDFPageViewBuffer {
#buf = new Set();
#size = 0;
constructor(size) {
this.#size = size; // 缓存页面数量限制
}
push(view) {
const buf = this.#buf;
if (buf.has(view)) {
buf.delete(view);
}
buf.add(view);
if (buf.size > this.#size) {
this.#destroyFirstView(); // 超出限制时销毁最早的页面
}
}
}
优势:
- 内存优化:只保留有限数量的页面在内存中
- 性能提升:减少不必要的渲染操作
- 流畅体验:滚动时动态加载页面
2. 双模式渲染:Canvas与HTML
PDF.js支持两种渲染模式,可根据不同需求选择。两种渲染方式在视觉效果和性能上有明显差异:
![]()
图:HTML渲染模式下的PDF显示效果
![]()
图:Canvas渲染模式下的PDF显示效果
Canvas渲染(默认)
// 创建Canvas元素
const canvas = document.createElement("canvas");
canvas.setAttribute("role", "presentation");
// 获取2D渲染上下文
const ctx = canvas.getContext("2d", {
alpha: false, // 禁用透明度通道,提高性能
willReadFrequently: !this.#enableHWA // 根据硬件加速设置优化
});
// 渲染PDF页面到Canvas
const renderContext = {
canvasContext: ctx,
transform,
viewport,
// 其他参数...
};
const renderTask = pdfPage.render(renderContext);
HTML渲染
// HTML渲染模式(文本层)
if (!this.textLayer && this.#textLayerMode !== TextLayerMode.DISABLE) {
this.textLayer = new TextLayerBuilder({
pdfPage,
highlighter: this._textHighlighter,
accessibilityManager: this._accessibilityManager,
enablePermissions: this.#textLayerMode === TextLayerMode.ENABLE_PERMISSIONS,
onAppend: (textLayerDiv) => {
this.#addLayer(textLayerDiv, "textLayer");
}
});
}
两种模式对比:
| 特性 | Canvas渲染 | HTML渲染 |
|---|---|---|
| 性能 | 高 | 中等 |
| 文本选择 | 不支持 | 支持 |
| 缩放质量 | 高 | 中等 |
| 内存使用 | 高 | 低 |
| 兼容性 | 好 | 极好 |
3. 水印渲染实现
水印是保护文档版权的重要手段。我们在PDF页面渲染完成后,直接在Canvas上添加水印,确保水印与内容融为一体:
// 在渲染完成后添加水印
const resultPromise = renderTask.promise.then(async () => {
showCanvas?.(true);
await this.#finishRenderTask(renderTask);
// 添加水印
createWaterMark({ fontText: warterMark, canvas, ctx });
// 其他处理...
});
// 水印绘制函数
function createWaterMark({
ctx,
canvas,
fontText = '默认水印',
fontFamily = 'microsoft yahei',
fontSize = 30,
fontcolor = 'rgba(218, 218, 218, 0.5)',
rotate = 30,
textAlign = 'left'
}) {
// 保存当前状态
ctx.save();
// 计算响应式字体大小
const canvasW = canvas.width;
const calfontSize = (fontSize * canvasW) / 800;
ctx.font = `${calfontSize}px ${fontFamily}`;
ctx.fillStyle = fontcolor;
ctx.textAlign = textAlign;
ctx.textBaseline = 'Middle';
// 添加多个水印
const pH = canvas.height / 4;
const pW = canvas.width / 4;
const positions = [
{ x: pW, y: pH },
{ x: 3 * pW, y: pH },
{ x: pW * 1.3, y: 3 * pH },
{ x: 3 * pW, y: 3 * pH }
];
positions.forEach((pos) => {
ctx.save();
ctx.translate(pos.x, pos.y);
ctx.rotate(-rotate * Math.PI / 180);
ctx.fillText(fontText, 0, 0);
ctx.restore();
});
// 恢复状态
ctx.restore();
}
水印技术亮点:
- 响应式设计:根据Canvas宽度自动调整水印尺寸
- 多点布局:四个位置分布水印,覆盖整个页面
- 旋转效果:每个水印独立旋转30度,增加覆盖范围
- 透明度处理:使用半透明颜色,不影响内容可读性
4. 防下载与打印控制
为了增强文档安全性,我们实现了全面的防下载和打印控制功能:
// 禁用右键菜单
document.addEventListener('contextmenu', function(e) {
e.preventDefault();
return false;
});
// 禁用文本选择
document.addEventListener('selectstart', function(e) {
e.preventDefault();
return false;
});
// 禁用拖拽
document.addEventListener('dragstart', function(e) {
e.preventDefault();
return false;
});
// 拦截Ctrl+P打印快捷键
window.addEventListener("keydown", function (event) {
if (event.keyCode === 80 && (event.ctrlKey || event.metaKey) &&
!event.altKey && (!event.shiftKey || window.chrome || window.opera)) {
// 自定义打印行为或完全禁用
event.preventDefault();
event.stopImmediatePropagation();
}
}, true);
Vue组件实现
基于以上技术,我们实现了一个功能完善的Vue3 PDF预览组件:
<template>
<iframe
:width="viewerWidth"
:height="viewerHeight"
id="ifra"
frameborder="0"
:src="`/pdfJs/web/viewer.html?file=${src}&waterMark=${waterMark}`"
@load="pagesRendered"
/>
</template>
<script setup>
import { computed } from 'vue'
import { useUserStore } from '~/store/user'
const props = defineProps({
src: String,
width: [String, Number],
height: [String, Number],
pageScale: [String, Number],
theme: String,
fileName: String
})
const emit = defineEmits(['loaded'])
// 默认值设置
const propsWithDefaults = withDefaults(props, {
width: '100%',
height: '100vh',
pageScale: 'page-width',
theme: 'dark',
fileName: ''
})
// 尺寸计算
const viewerWidth = computed(() => {
if (typeof props.width === 'number') {
return props.width + 'px'
} else {
return props.width
}
})
const viewerHeight = computed(() => {
if (typeof props.height === 'number') {
return props.height + 'px'
} else {
return props.height
}
})
// 用户信息和水印
const userStore = useUserStore()
const userInfo = computed(() => userStore.userInfo)
const waterMark = computed(() => {
const { userName, phoneNum } = userInfo.value
const phoneSuffix = phoneNum && phoneNum.substring(phoneNum.length - 4)
return userName + phoneSuffix
})
// 页面渲染事件
function pagesRendered(pdfApp) {
emit('loaded', pdfApp)
}
</script>
<style scoped>
#ifra {
max-width: 100%;
height: 100%;
margin-left: 50%;
transform: translateX(-50%);
}
</style>
使用方法
基本使用
<template>
<PDFViewer
src="path/to/your/pdf/file.pdf"
:width="800"
:height="600"
@loaded="handlePdfLoaded"
/>
</template>
<script setup>
import PDFViewer from '@/components/PDFViewer/index.vue'
function handlePdfLoaded(pdfApp) {
console.log('PDF已加载完成', pdfApp)
}
</script>
高级配置
<template>
<PDFViewer
src="path/to/your/pdf/file.pdf"
width="100%"
height="90vh"
page-scale="page-fit"
theme="light"
file-name="自定义文件名.pdf"
@loaded="handlePdfLoaded"
/>
</template>
性能优化
1. 渲染性能优化
// 设置合理的maxCanvasPixels
const maxCanvasPixels = isHighEndDevice ?
16777216 * 4 : // 4K显示器
8388608 * 2; // 普通显示器
const pdfViewer = new PDFViewer({
container: document.getElementById('viewer'),
maxCanvasPixels: maxCanvasPixels
});
2. 内存管理优化
// 限制缓存页面数量,防止内存溢出
pdfViewer.setDocument(pdfDocument);
pdfViewer.currentScaleValue = 'auto';
// 定期清理不可见页面
setInterval(() => {
const visiblePages = pdfViewer._getVisiblePages();
// 清理不可见页面的缓存
}, 30000);
3. 按需渲染
// 只渲染可见页面
pdfViewer.onPagesLoaded = () => {
const visiblePages = pdfViewer._getVisiblePages();
// 只渲染可见页面,延迟渲染其他页面
};
注意事项
- PDF.js版本:确保使用兼容的PDF.js版本,不同版本API可能有差异
- 跨域处理:PDF文件可能存在跨域问题,需确保服务器配置了正确的CORS头
- 大文件处理:对于大型PDF文件,考虑添加加载进度提示
- 移动端适配:在移动设备上可能需要额外的样式调整
- 安全限制:虽然实现了防下载和水印,但无法完全防止技术用户获取PDF内容
扩展功能建议
- 页面跳转:添加页面导航功能,支持直接跳转到指定页面
- 文本搜索:实现PDF内容搜索功能
- 注释工具:添加PDF注释、标记功能
- 水印样式自定义:支持更多水印样式和位置配置
- 访问控制:基于用户角色限制PDF访问权限
总结
本文介绍了如何基于Mozilla PDF.js实现一个功能完善的PDF预览组件,并重点讲解了如何添加自定义的防下载和水印功能。通过合理的技术选型和组件设计,我们实现了一个既美观又安全的PDF预览解决方案。
在实际应用中,您可以根据具体需求进一步扩展功能,如添加页面导航、文本搜索等高级特性,为用户提供更丰富的PDF阅读体验,同时确保文档内容的安全性。
希望本文对您在Vue3项目中实现安全PDF预览功能有所帮助!
需要源码的评论区回复6666
『NAS』中午煮什么?Cook
点赞 + 关注 + 收藏 = 学会了
整理了一个NAS小专栏,有兴趣的工友可以关注一下 👉 《NAS邪修》
cook(来做菜)是一款适合家里食材或厨具不多时用的免费菜谱工具,选好自己有的食材和厨具就能找到适配菜谱,还能随机挑菜谱,非常适合选择困难症用户。
选好菜式,点击会跳转到B站对应的做菜教学视频。
![]()
好,动手安装!
首先在 docker 目录下创建一个 cook 文件夹。
![]()
然后打开“Container Manager”,新增一个项目。
“路径”指向刚刚创建好的 cook 文件夹。
“来源”选择“创建 docker-compose.yml”。
![]()
接着输入以下代码。
services:
cook:
image: yunyoujun/cook:latest
container_name: autopiano
ports:
- 8080:80
restart: unless-stopped
在“网页门户设置”里启用“通过 Web Station 设置网页门户”。
![]()
接着打开“Web Station”,新增一个网络门户。
“服务“这项选择”cook“。
“端口”随便填,只要不跟其他项目冲突即可。
![]()
在浏览器输入你NAS的IP地址,再加上“cook”的端口号,比如本例设置的是 2342。就能看到 cook 的界面了。
![]()
以上就是本文的全部内容啦,想了解更多NAS玩法可以关注《NAS邪修》
点赞 + 关注 + 收藏 = 学会了
React基础框架搭建10-webpack配置:react+router+redux+axios+Tailwind+webpack
webpack配置
npm install --save-dev webpack webpack-cli webpack-dev-server
npm install --save-dev babel-loader @babel/core @babel/preset-env @babel/preset-react
npm install --save-dev html-webpack-plugin clean-webpack-plugin
npm install --save-dev css-loader style-loader
npm install --save-dev file-loader url-loader
npm install --save-dev mini-css-extract-plugin
npm install --save-dev dotenv-webpack
在根目录创建webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const Dotenv = require('dotenv-webpack');
module.exports = {
mode: 'development', // 开发模式
entry: './src/index.js', // 入口文件
output: {
path: path.resolve(__dirname, 'dist'), // 输出目录
filename: 'bundle.js', // 输出文件名
publicPath: '/', // 公共路径
},
resolve: {
extensions: ['.js', '.jsx'], // 解析的文件扩展名
alias: {
'@': path.resolve(__dirname, 'src'), // 设置路径别名
},
},
module: {
rules: [
{
test: /\.(js|jsx)$/, // 处理 JavaScript 和 JSX 文件
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'], // Babel 配置
},
},
},
{
test: /\.css$/, // 处理 CSS 文件
use: ['style-loader', 'css-loader'],
},
{
test: /\.(png|jpg|gif|svg)$/, // 处理图片文件
use: [
{
loader: 'file-loader',
options: {
name: '[path][name].[ext]', // 保持原有路径和文件名
},
},
],
},
],
},
devServer: {
static: {
directory: path.join(__dirname, 'dist'), // 更新为 static
},
compress: true, // 启用 gzip 压缩
port: 3000, // 端口号
historyApiFallback: true, // 支持 HTML5 History API
},
plugins: [
new CleanWebpackPlugin(), // 清理输出目录
new HtmlWebpackPlugin({
template: './public/index.html', // HTML 模板
filename: 'index.html', // 输出的 HTML 文件名
}),
],
};
在 package.json 中添加 Webpack 的构建和开发脚本:
"scripts": {
"start": "webpack serve --open", // 启动开发服务器
"build": "webpack --mode production" // 构建生产版本
}
Zustand 、Jotai和Valtio源码探析
一个核心的API:useSyncExternalStore
作用:安全地将React组件链接到外部状态管理库(如Redux、Zustand、浏览器storage),解决并发渲染下的撕裂问题
最核心的代码:
function useSyncExternalStore(subscribe, getSnapshot) {
const [state, setState] = useState(getSnapshot());
useEffect(() => {
const handleStoreChange = () => {
setState(getSnapshot());
};
// 1. 订阅状态变化(返回清理函数)
const unsubscribe = subscribe(handleStoreChange);
// 2. 返回清理函数(组件卸载时执行)
return unsubscribe;
}, [subscribe, getSnapshot]);
return state;
}
演示Zustand的订阅过程
// 1. 这是一个极其迷你的 Store
const store = {
// 这是那个名单本子 (Set)
listeners: new Set(),
// ✨重点在这里:订阅函数✨
subscribe: function(callback) {
// 动作:把你传进来的函数(联系方式),加到本子上
this.listeners.add(callback);
console.log(`✅ 成功追加一个监听!现在名单里有 ${this.listeners.size} 个人。`);
// 返回一个函数,用来取消订阅(以后再说)
return () => this.listeners.delete(callback);
},
// 假装数据变了,通知大家
setState: function() {
console.log("📢 只有一件事:数据变了!开始挨个通知...");
// 遍历 Set,执行每个函数
this.listeners.forEach(run => run());
}
};
// ==========================================
// 场景开始:两个“组件”来订阅了
// ==========================================
// 模拟组件 A(比如是页面顶部的 Header)
const componentA_Update = () => console.log(" -> 组件A收到通知:我要检查下用户名变没变");
// 模拟组件 B(比如是页面底部的 Footer)
const componentB_Update = () => console.log(" -> 组件B收到通知:我要检查下版权年份变没变");
// 动作 1:组件 A 出生了,请求订阅
store.subscribe(componentA_Update);
// 👉 结果:Set 内部现在是 { componentA_Update }
// 动作 2:组件 B 出生了,请求订阅
store.subscribe(componentB_Update);
// 👉 结果:Set 内部现在是 { componentA_Update, componentB_Update }
// ==========================================
// 动作 3:数据变了!
// ==========================================
store.setState();
演示Jotai的订阅过程
Jotai的核心区别在于“订阅是跟着Atom走的,而不是跟着Store走的”。在 Zustand 里,是你跑到大厅(Store)里喊一嗓子,所有人都会听到。 在 Jotai 里,是你分别跑到不同的房间(Atom)门口去留小纸条。
// ==========================================
// 1. 模拟一个迷你的 Jotai Store (Provider)
// ==========================================
const jotaiStore = {
// 这里的名单本子是【分门别类】的!
// Key 是 atom 本身,Value 是这个 atom 专属的粉丝名单(Set)
listeners: new Map(),
// ✨重点在这里:订阅函数✨
// 你必须告诉我:你要订阅【哪一个 Atom】?
subscribe: function(atom, callback) {
// 1. 如果这个 atom 还没人关注过,先给它建个新的空名单
if (!this.listeners.has(atom)) {
this.listeners.set(atom, new Set());
}
// 2. 拿到这个 atom 专属的名单
const fans = this.listeners.get(atom);
// 3. 把回调加上去
fans.add(callback);
console.log(`✅ 成功关注!Atom [${atom.key}] 现在有 ${fans.size} 个粉丝。`);
return () => fans.delete(callback);
},
// 假装这一颗具体的 Atom 变了
setAtom: function(atom, newValue) {
console.log(`📢 只有一件事:Atom [${atom.key}] 的值变成了 ${newValue}!开始通知粉丝...`);
// 1. 只找这个 Atom 的粉丝
const fans = this.listeners.get(atom);
if (fans) {
// 2. 精准通知,闲杂人等根本不会被吵醒
fans.forEach(run => run());
} else {
console.log(" (尴尬: 这个 atom 没有任何人订阅,无事发生)");
}
}
};
// ==========================================
// 场景开始:定义两个独立的 Atom
// ==========================================
const countAtom = { key: 'CountAtom', init: 0 }; // 房间 A
const textAtom = { key: 'TextAtom', init: 'hi' }; // 房间 B
// ==========================================
// 模拟组件
// ==========================================
// 模拟组件 A:只关心数字
// 对应代码: useAtom(countAtom)
const componentA_Update = () => console.log(" -> 组件A收到通知:我订阅的 Count 变了,我要重渲染!");
// 模拟组件 B:只关心文字
// 对应代码: useAtom(textAtom)
const componentB_Update = () => console.log(" -> 组件B收到通知:我订阅的 Text 变了,我要重渲染!");
// 动作 1:组件 A 订阅 countAtom
jotaiStore.subscribe(countAtom, componentA_Update);
// 动作 2:组件 B 订阅 textAtom
jotaiStore.subscribe(textAtom, componentB_Update);
// ==========================================
// 动作 3:修改 TextAtom (比如输入框打字)
// ==========================================
jotaiStore.setAtom(textAtom, 'hello world');
// 👉 结果:
// 只有组件 B 会打印日志。
// 组件 A 正在睡大觉,根本不知道发生了什么。这就是“原子化订阅”的威力。
演示Valtio的订阅过程
对于 Valtio,它的核心在于 “间谍 (Proxy)” 和 “快照 (Snapshot)”。它的订阅既不是去大厅喊(Zustand),也不是去房间留条(Jotai),而是 “给对象装个窃听器”。你以为你在随意修改对象 state.count++,其实你改的是一个装了窃听器的 Proxy。这个窃听器会自动通知 React:“嘿,版本号变了,快来拿新照片(Snapshot)”。
// ==========================================
// 1. 模拟一个迷你的 Valtio Proxy
// ==========================================
// 这是我们的“窃听器中心”
// Key 是 proxy 对象本身,Value 是订阅者名单
const listenersMap = new WeakMap();
// 这是我们的“版本记录中心”
const versionMap = new WeakMap();
// ✨ 造一个带窃听器的对象
function proxy(initialObj) {
// 初始版本号 0
let version = 0;
// 真正的核心:拦截器
const p = new Proxy(initialObj, {
// 拦截写入:你以为只有赋值,其实还触发了通知
set(target, prop, value) {
target[prop] = value;
// 1. 升级版本号 (Version Increment)
version++;
versionMap.set(p, version);
console.log(`📢 监测到写入:${prop} = ${value} (当前版本: v${version})`);
// 2. 只有在此刻,才通知订阅者
notify(p);
return true;
}
});
// 初始化记录
listenersMap.set(p, new Set());
versionMap.set(p, version);
return p;
}
// 辅助函数:通知
function notify(p) {
const fans = listenersMap.get(p);
fans.forEach(cb => cb());
}
// ==========================================
// 场景开始:创建一个可变状态
// ==========================================
const state = proxy({ count: 0, text: 'hello' });
// ==========================================
// 模拟组件 (使用 useSnapshot)
// ==========================================
// 模拟组件 A
const componentA_Update = () => {
// 每次组件渲染,都会检查版本号
const currentVer = versionMap.get(state);
// 如果版本变了,React 就会拿到一个新的 snapshot 从而更新
console.log(` -> 组件A收到通知:版本变成 v${currentVer} 了,我要去拉取新快照!`);
};
// 动作 1:组件订阅
// 在 Valtio 里,这一步通常发生在 useSnapshot 内部
const fans = listenersMap.get(state);
fans.add(componentA_Update);
// ==========================================
// 动作 2:直接修改属性 (Mutable!)
// ==========================================
console.log("--- 准备修改 count ---");
state.count++;
// 👉 结果:控制台打印 "监测到写入..." -> "组件A收到通知..."
console.log("--- 准备修改 text ---");
state.text = 'world';
// 👉 结果:同样触发通知。注意:这里是对象级别的通知。
// (真实的 Valtio 还有更高级的属性级优化,但原理就是这个 Loop)
核心对比
Zustand: store.subscribe(cb)
• 比喻:大喇叭广播。
• 机制:所有变更都会触发 cb,必须由 CB 内部自己决定是不是真的要更新 (Selector)。
• 适用:粗粒度、低频、全局状态。
Jotai: store.subscribe(atom, cb)
• 比喻:房间门口留条。
• 机制:只有 指定 Atom 变更才会触发 cb,不需要 Selector,天然精准。
• 适用:细粒度、高频、复杂依赖图(如节点编辑器)。
Valtio: subscribe(proxy, cb)
• 比喻:装了窃听器。
• 机制:写的时候自动触发通知,读的时候检查版本号 (Version Check)。哪怕你改的是深层嵌套属性 state.a.b.c = 1,也会通过递归 Proxy 冒泡上来触发更新。
• 适用:极高频交互、深层嵌套数据、游戏/3D开发(喜欢 Mutable 写法的场景)。
Flutter 零基础入门(八):Dart 类(Class)与对象(Object)
📘Flutter 零基础入门(八):Dart 类(Class)与对象(Object)
在前面的学习中,我们已经学会了:
- 使用
List存储一组数据 - 使用
Map描述一条结构化数据 - 使用函数封装逻辑
你现在可能已经写过类似这样的代码:
Map<String, dynamic> user = {
'name': 'Tom',
'age': 18,
};
这在学习阶段完全没问题,但在真实项目中,很快会暴露一些问题:
- key 写错了,编译器发现不了
- 数据结构不清晰
- 不利于维护和扩展
为了解决这些问题,Dart 提供了更强大的工具: 👉 类(Class)与对象(Object)
一、什么是类(Class)?
类可以理解为:
一个“模板”或“蓝图”,用来描述一类事物
例如:
- 用户
- 商品
- 订单
它描述的是:
- 这个事物有什么属性
- 这个事物能做什么事情
二、什么是对象(Object)?
对象是:
根据类创建出来的具体实例
类 ≈ 图纸 对象 ≈ 根据图纸造出来的房子
三、为什么要使用类?
相比 Map,类的优势非常明显:
- 结构清晰
- 有类型约束
- 编译期可检查错误
- 更符合真实业务建模
📌 Flutter 项目中几乎一定会用到类
四、定义一个最简单的类
class User {
String name;
int age;
User(this.name, this.age);
}
拆解理解:
-
class User:定义一个类 -
name、age:类的属性 -
User(...):构造函数,用于创建对象
五、创建对象(实例化)
User user = User('Tom', 18);
print(user.name);
print(user.age);
这里:
-
User是类 -
user是对象
📌 对象通过 . 访问属性
六、类中的方法(行为)
类不仅可以有属性,还可以有方法。
class User {
String name;
int age;
User(this.name, this.age);
void introduce() {
print('我叫$name,今年$age岁');
}
}
调用方法:
User user = User('Tom', 18);
user.introduce();
📌 方法本质上就是:
属于这个类的函数
七、类 vs Map(对比理解)
| 对比项 | Map | Class |
|---|---|---|
| 结构清晰度 | 一般 | 非常清晰 |
| 类型检查 | ❌ | ✅ |
| 自动补全 | 少 | 多 |
| 适合项目 | ❌ | ✅ |
📌 结论:
Map 用于临时数据,Class 用于项目结构
八、List + Class(真实项目结构)
List<User> users = [
User('Tom', 18),
User('Lucy', 20),
];
遍历:
for (var user in users) {
user.introduce();
}
📌 这已经是 Flutter 项目中非常常见的写法了。
九、类是 Flutter 的核心基础
在 Flutter 中:
- 页面是 Widget 类
- StatelessWidget / StatefulWidget 是类
- 页面状态、数据模型都是类
📌 你现在学的内容,将直接用于:
页面开发、数据模型、业务封装
十、总结
本篇你已经学会了:
- 什么是类(Class)
- 什么是对象(Object)
- 如何定义和使用类
- 为什么类比 Map 更适合项目
你已经完成了从:
“数据结构” → “业务建模” 的关键跃迁
🔜 下一篇预告
《Flutter 零基础入门(九):构造函数、命名构造函数与 this 关键字》
下一篇我们将学习:
- 构造函数的更多写法
- 命名构造函数的作用
-
this的真正含义 - 更规范地创建对象
从下一篇开始,你写的 Dart 代码将越来越像:
专业 Flutter 项目中的代码
从"请求地狱"到"请求天堂":alovajs 如何用 20+ 高级特性拯救前端开发者
写在前面:你可能每天都在重复这些工作
// 场景 1:基础请求
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/users')
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
// 场景 2:带重试的请求(你已经写了 50 行,还在考虑要不要加重试)
// 场景 3:分页加载(数据要拼接、缓存要管理、预加载要考虑...)
// 场景 4:表单提交(验证、持久化、提交后重置...)
如果你觉得这些场景似曾相识,那么这篇文章可能改变你对前端请求的认知。
alova 是什么?不是什么
❌ 不是简单的 axios/fetch 封装
很多人第一反应:"这不就是封装了 axios 吗?"
错。
alova 采用的是适配器模式,你可以选择任何底层请求库:axios、fetch、XHR、SuperAgent,甚至 Taro/UniApp 的跨平台适配器。它只是把不同库的接口转换成统一规范,核心是请求策略编排。
✅ 是一个请求策略引擎
alova 的野心更大:它要解决所有请求相关的痛点,让你像搭积木一样组合各种高级特性,用最少的代码实现最复杂的功能。
20+ 高级特性全景图
🎯 核心请求策略(3大基石)
1. useRequest - 请求状态自动化
// 传统写法:你需要手动管理 loading、data、error
// alova 写法:一行搞定
const { loading, data, error, send } = useRequest(getUserList);
魔法在哪里?
- 自动管理 loading 状态
- 自动响应式更新数据
- 自动错误处理
- 支持事件订阅(onSuccess、onError、onComplete)
2. useWatcher - 智能响应请求
// 搜索框防抖请求,传统写法:useEffect + 定时器
// alova 写法:
const { data } = useWatcher(
(keyword) => searchApi(keyword),
[keyword], // 监听 keyword 变化
{ debounce: 300 } // 内置防抖
);
3. useFetcher - 无组件数据获取
// 预加载数据,但不更新当前组件状态
const { fetch } = useFetcher();
useEffect(() => {
// 鼠标悬停时预加载详情页数据
fetch(getDetailApi(id));
}, []);
🚀 高级业务 Hooks(解决 80% 的复杂场景)
场景 1:分页列表(你写过 500 行,它用 5 行)
const {
data,
page,
pageCount,
isLastPage,
insert, // 插入列表项
remove, // 删除列表项
replace, // 替换列表项
refresh // 刷新指定页
} = usePagination(
(page, pageSize) => getUserList({ page, pageSize }),
{
initialPage: 1,
initialPageSize: 20,
preloadPreviousPage: true, // 自动预加载上一页
preloadNextPage: true // 自动预加载下一页
}
);
// 删除用户,自动更新列表、总数、预加载缓存
remove(userId);
它帮你做了什么:
- ✅ 自动拼接数据(下拉加载/翻页模式切换)
- ✅ 自动预加载上一页/下一页
- ✅ 智能缓存管理(删除某项,自动调整下一页缓存)
- ✅ 虚拟列表优化(只请求需要的数据)
- ✅ 跨页面状态同步
场景 2:表单管理(持久化、验证、提交流程)
const {
form,
updateForm,
reset,
send
} = useForm(
(formData) => submitForm(formData),
{
initialForm: { username: '', email: '' },
store: true, // 自动持久化到本地存储
resetAfterSubmiting: true, // 提交后自动重置
immediate: false
}
);
// 用户刷新页面,表单数据自动恢复
// 提交后自动重置,无需手动调用 reset()
场景 3:智能重试(指数退避、条件重试)
const { send, stop, onRetry, onFail } = useRetriableRequest(
unstableApi,
{
retry: 3, // 最多重试 3 次
backoff: { // 指数退避策略
delay: 1000,
multiplier: 2
}
}
);
// 重试时触发
onRetry(event => {
console.log(`第 ${event.retryTimes} 次重试,延迟 ${event.delay}ms`);
});
// 最终失败时触发
onFail(event => {
console.error('重试失败,原因:', event.error);
});
// 手动停止重试
stop();
场景 4:静默队列请求(断网重发)
const { send } = useSQRequest(
(data) => reportAnalytics(data),
{
maxQueue: 100, // 最多缓存 100 个请求
queueWhenDisconnected: true // 断网时入队
}
);
// 即使网络断开,请求也会缓存到队列中
// 网络恢复后自动按顺序发送
场景 5:串行请求(确保执行顺序)
const { send } = useSerialRequest(
(taskId) => {
return getTaskStatus(taskId);
},
{
// 确保每次只有一个请求在执行
// 新请求会排队等待
}
);
场景 6:实时推送(Server-Sent Events)
const {
data,
send,
onMessage,
onClose
} = useSSE(
() => new EventSource('/api/events'),
{
intercept: true, // 拦截消息,自定义处理
reconnect: true // 断线自动重连
}
);
onMessage((event) => {
console.log('实时消息:', event.data);
});
🛠️ 底层高级能力(隐形超级英雄)
1. 请求共享(避免重复请求)
// 组件 A
const { data: data1 } = useRequest(getUserList);
// 组件 B(同时渲染)
const { data: data2 } = useRequest(getUserList);
// 只会发送一个请求,两个组件共享响应
原理: 通过请求指纹识别,同一时间相同请求只发一次,后续请求等待或复用结果。
2. 多级缓存系统
createAlova({
cacheFor: {
getUserList: { mode: 'memory', expire: 60000 },
getDetail: { mode: 'storage', expire: 3600000 }
}
});
缓存模式:
- MEMORY:内存缓存,适合临时数据
- STORAGE:本地存储,适合持久化
- STORAGE_RESTORE:刷新页面后恢复
3. Token 自动认证
import { createTokenAuthentication } from 'alova/client';
createAlova({
// ... 配置
beforeRequest(method) {
// 自动注入 Token
tokenAuth.addTokenToHeader(method);
}
});
const tokenAuth = createTokenAuthentication({
login: (username, password) => loginApi(username, password),
logout: () => logoutApi(),
assignToken: (response) => response.token,
tokenRefresher: (refreshToken) => refreshApi(refreshToken)
});
自动处理:
- Token 过期自动刷新
- 多个请求同时过期只刷新一次
- 刷新失败自动重试登录
4. 中间件系统
useRequest(getUserList, {
middleware: (context, next) => {
// 请求前
console.log('开始请求');
return next().then(response => {
// 响应后
console.log('请求完成');
return response;
});
}
});
5. OpenAPI 自动生成接口
# 一键生成类型安全的接口代码
npx alova-codegen --url http://api.example.com/openapi.json
生成结果:
- 完整的 TypeScript 类型定义
- 自动化的接口调用方法
- 请求/响应类型推导
6. 跨标签页状态共享
// 标签页 A
const { data } = useRequest(getUserList);
// 标签页 B(用户在 A 中刷新数据)
// B 中自动同步最新数据,无需手动刷新
📊 对比:传统方案 vs alova
| 场景 | axios + React Query | alova | 减少代码量 |
|---|---|---|---|
| 基础请求 | 30 行 | 3 行 | 90% |
| 分页列表 | 200 行 | 20 行 | 90% |
| 表单管理 | 150 行 | 15 行 | 90% |
| 智能重试 | 100 行 | 10 行 | 90% |
| Token 认证 | 80 行 | 15 行 | 81% |
| 总计 | 560 行 | 63 行 | 89% |
💡 为什么选择 alova?
1. 开箱即用的高级特性
其他库需要你自己写中间件、插件,alova 已经帮你写好了。
2. 真正的跨框架
React/Vue/Svelte/Solid/Nuxt 同一套 API,技能复用率 100%。
3. 类型安全优先
从请求参数到响应数据,完整的 TypeScript 类型推导。
4. 性能极致优化
请求共享、多级缓存、智能预加载,这些都是内置的。
5. 开发效率提升 10 倍
从 560 行代码到 63 行代码,这就是差距。
🎬 快速上手
npm install alova @alova/client
import { createAlova } from 'alova';
import { useRequest } from '@alova/client';
import adapterFetch from 'alova/fetch';
const alova = createAlova({
baseURL: 'https://api.example.com',
requestAdapter: adapterFetch()
});
// 只需要这样
const { data, loading } = useRequest(alova.Get('/users'));
结语
前端开发不应该把时间浪费在重复的请求管理上。
alova 的 20+ 高级特性,本质上是对前端请求场景的深度抽象。它不是要替代 axios 或 fetch,而是要解决这些库无法解决的业务痛点。
你的时间应该花在产品逻辑上,而不是请求的加载、缓存、重试、错误处理这些重复工作中。
准备好了吗?从"请求地狱"到"请求天堂",只差一个 alova。