普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月9日技术

2026年1月编程语言排行榜|C#拿下年度语言,Python稳居第一

2026年1月9日 14:40

大家好,我是凌览。

如果本文能给你提供启发或帮助,欢迎动动小手指,一键三连(点赞评论转发),给我一些支持和鼓励谢谢。

这是C#在三年内第二次被TIOBE指数评为年度编程语言。C#之所以获得这一殊荣,是因为其在排名上实现了同比最大增幅。

image.png

多年的迭代,C#已经脱胎换骨。语言设计上,C#常常是主流语言中新趋势的早期采纳者。更难得的是,它实现了两次重大的范式转变:一次从“Windows 专属”跃向“跨平台”,一次从“微软私有”走向全面开源,恰到好处,不早不晚。

在商业软件的地盘上,Java 与 C# 的缠斗从未停歇。我曾笃定 Java 会笑到最后,可十几年过去,胜负依旧悬而未决。Java 语法冗长、样板代码横飞,还要背负 Oracle 的所有权——这套组合拳下,Java 究竟还能不能顶住 C# 的步步紧逼?答案仍悬在半空。

image 1.png

2025 年的前十榜单里,C 与 C++ 互换座次。后者迭代再快,激进新特性——譬如模块——仍没逃过“叫好不叫座”的尴尬;而 C 凭一句“简单即快”,稳踞小型嵌入式增量市场,岿然不动。Rust 虽刷出历史最佳第 13,却在这片疆域依旧撞不破天花板

除了C#之外,2025年的其他赢家Perl,从第32名跃升至第11名,重新进入前20名。

image 2.png

另一个重返前10名的语言是R,这主要得益于数据科学和统计计算领域的持续增长。

image 3.png

当然也有输家,Go2025年跌出前10名中的位置,Ruby跌出了前20名其他语言

image 4.png

image 5.png

2026年1月编程语言排行榜

本月,排名前十的分别是: Python,C,Java,C++,C#,JavaScript,Visual Basic,SQL,Delphi/Object Pascal,R。

image 6.png

10-20排名如下:

image 7.png

历年获奖编程语言

奖项颁发给在一年内评分增长最高的编程语言。

image 8.png

最后

TIOBE 编程社区指数是衡量编程语言流行度的指标——每月翻新一次。分数拼的是全球熟练工程师的脑袋数、课程开班量与第三方厂商的吆喝声;Google、Amazon、Wikipedia、Bing 等二十余家热门站点共同充当计票器。先打预防针:它既不评选“最好”的语言,也不比拼谁“码”得最长。

Pinia 深度解析:现代Vue应用状态管理最佳实践

2026年1月9日 14:29

什么是 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 之间的过度耦合,保持良好的可维护性。

前端首屏渲染性能优化小技巧

2026年1月9日 14:07

` 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 加载”优化,用于提升首屏渲染

工作原理

  1. 查找入口 CSS 文件:查找所有包含 entry 和 .css 的样式表链接(通常是 Nuxt 生成的入口 CSS)。
  2. 临时设置为打印媒体:将这些 CSS 的 media 属性设为 print,使其不阻塞渲染。
  3. 加载完成后恢复:CSS 加载完成后,将 media 改回 all,样式生效。

为什么这样做?

  • 问题:CSS 是渲染阻塞资源,会阻塞页面渲染,导致白屏。
  • 解决:通过 media="print" 让浏览器异步加载,不阻塞渲染,加载完成后再应用样式。

注意事项

  • 优点:减少首屏阻塞,提升 FCP/LCP。
  • 风险:可能出现短暂无样式(FOUC),但通常不明显。
  • 建议

如果遇到样式闪烁或加载问题,可以:

  1. 移除该插件(删除文件)
  2. 或调整逻辑,只对非关键 CSS 应用此优化

lecen:一个更好的开源可视化系统搭建项目--组件和功能按钮的权限控制--全低代码|所见即所得|利用可视化设计器构建你的应用系统-做一

作者 晴虹
2026年1月9日 14:05

控制

我们通过多种方式来控制用户的权限,可以精准的针对不同的用户来达到想要的效果。

首先从权限维度来说,主要分为两方面: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 值应该是从接口返回的,或者根据其他配置进行设置的,现在为了方便,我们直接修改它的值

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
  }]
}]

上面展示的是数据视图的代码形式,我们在设计的时候直接通过属性配置的方式进行设置即可

配置control

这时新增按钮通过 add 标识来控制,删除按钮通过 delete 标识来控制,现在我们去掉删除按钮的权限

只有新增code

那么页面上就没有了删除按钮

只有新增按钮

requestData.code 对象除了可以设置权限标识为 truefalse 之外,还能够设置为权限对象

比如可以设置成这样

设置为权限对象

我们设置了一个 pageButton 的权限对象,里面包含了 adddelete 的控制

然后修改一下这两个按钮的 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 赋值上相应的身份标识

identity赋值

当前用户具有 roleAroleBroleC 三个身份,因此两个按钮都能够渲染出来

新增和删除按钮

当我们去掉 roleAroleB 两个身份时

只有roleC身份

相应的按钮权限也会实时生效

只有新增按钮

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获取到暴露出来的属性和方法等。

if能获取的变量

然后手动指定返回值为 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
  }]
}]

这样就可以做到在组件渲染完成之后进行任意的处理

通过call处理

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 对象中

dom引用

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 对象中

view引用

可以看到除了dom和view的引用被添加到了 controlData 对象中外,还有一个以DT结尾的对象引用也被添加到了 controlData 对象中。

dom和view需要手动指定后才会被收集到 controlData 对象中,所有被命名的数据视图都会自动的被收集到 controlData 对象中,并以DT结尾来标识

提示:

codepermissionnoPermissionif 可以同时存在,但是他们之间有一个优先级的关系,因此设置的时候尽量不要冲突,如 permission 设定为 [roleA]noPermission 也设定为 [roleA],那么 noPermission 将不会生效。

他们之间的优先级关系为: code > permission > noPermission > if,当设定产生冲突时,将会按照这个优先级进行处理。

注意 permissionnoPermission 必须要设定为数组的形式,不支持字符串的设定。

domview 字段都是字符串,他们不做任何逻辑处理,只是保留了元素和视图的引用,以备在其他地方引用。

【项目体验】

系统管理端地址www.lecen.top/manage

系统用户端地址www.liudaxianer.com/user

系统文档地址www.lnsstyp.com/web

Element UI 表格 show-overflow-tooltip 长文本导致闪烁的根本原因与解法

2026年1月9日 13:55

问题复现

在 Element UI (Vue 2) 项目中,el-table-column 开启 show-overflow-tooltip 展示超长文本(500字+)。 现象: 鼠标悬停单元格,Tooltip 疯狂闪烁(显示-消失-显示循环),侧边滚动条也跟随闪烁(副作用)。 关键环境信息: 页面原本就有滚动条(无布局重排),但依然闪烁。

排查与验证

初步排查: 曾怀疑是 Tooltip 撑开页面导致滚动条出现进而挤压布局。但经过验证,页面滚动条一直存在,布局并未发生位移,因此排除“重排(Reflow)”导致的坐标变化。

核心对照实验:

  1. 自动模式:使用 show-overflow-tooltip -> 闪烁
  2. 手动模式:在 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;
}

总结

当排查“幽灵闪烁”问题时,如果页面布局未动,请重点关注层级遮挡导致的鼠标事件丢失。对于复杂场景,手动控制的组件永远比自动的语法糖更可靠。

🌟 藏在 Vue3 源码里的 “二进制艺术”:位运算如何让代码又快又省内存?

2026年1月8日 09:39
前言 Hello~大家好。我是秋天的一阵风 在前端框架竞争白热化的今天,Vue3 能稳坐主流框架宝座,除了更简洁的 API 设计,其底层藏着的 “性能黑科技” 功不可没 ——位运算 就是其中最亮眼的一

自定义AI智能体扫描内存泄漏代码

作者 石小石Orz
2026年1月8日 09:32

引言

在日常开发时,由于部分代码不严谨,浏览器无法及时回收内存(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 文件夹下的 viewscomponents 目录,分析出存在内存泄漏的代码,并输出一个可直接打开的 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报告:

image.png

到底是用nuxt的public还是assets?一篇文章开悟

作者 江湖文人
2026年1月9日 11:03

assets

Nuxt为你的资产提供了两种处理方式。

Nuxt使用两个目录来处理样式表、字体或图片等资产:

  • public/目录的内容会直接以服务器根路径的形式提供。
  • assets/目录按惯例包含你希望构建工具(Vitewebpack)处理的所有资产。

公共目录

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');
}

提醒

  1. 缓存策略

    • assets/ 中的文件通常会添加哈希值,便于缓存管理
    • public/ 中的文件使用原始名称,需手动管理缓存
  2. 部署考虑

    • public/ 目录内容会原样复制到构建输出
    • assets/ 目录内容会被处理并打包
  3. 性能优化

    • 小图标建议使用 assets/ 以便打包优化
    • 大文件(如视频)建议使用 public/ 避免构建过程变慢

通用语法校验器tree-sitter——C++语法校验实践

2026年1月9日 10:44

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,生成步骤如下:

  1. 安装依赖
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();

错误节点捕获:

image.png

[ECharts] Instance ec_1234567890 has been disposed

2026年1月9日 10:30

📋 目录


🔍 问题背景

在 Vue 3 项目中使用 ECharts 时,经常会遇到以下控制台警告:

[ECharts] Instance ec_1234567890 has been disposed

这个警告虽然不会影响功能,但表明存在潜在的内存泄漏问题。

问题原因

  1. 图表实例已销毁,但事件监听器仍在运行

    • 调用 chart.dispose() 销毁图表后
    • window.resize 事件监听器仍然存在
    • 监听器尝试调用已销毁实例的 chart.resize() 方法
    • 导致 ECharts 输出警告信息
  2. 重复添加事件监听器

    • 每次重新渲染图表时都添加新的 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. 组件卸载时完整清理

📚 参考资料


💡 总结

  1. 核心原则:在销毁图表实例前,先移除所有相关的事件监听器
  2. 存储引用:使用对象存储图表实例和监听器函数引用
  3. 正确顺序:先移除监听器 → 再销毁图表 → 最后清空引用
  4. 完整清理:组件卸载时确保所有资源都被正确释放
  5. 避免泄漏:每次重新渲染前清理旧的监听器

遵循这些最佳实践,可以完全避免 ECharts 的 "has been disposed" 警告,并确保没有内存泄漏问题。

基于PDF.js的安全PDF预览组件实现:从虚拟滚动到水印渲染

作者 大鸡爪
2026年1月9日 10:26

基于PDF.js的安全PDF预览组件实现:从虚拟滚动到水印渲染

本文将详细介绍如何基于Mozilla PDF.js实现一个功能完善、安全可靠的PDF预览组件,重点讲解虚拟滚动、双模式渲染、水印实现等核心技术。

前言

在Web应用中实现PDF预览功能是常见需求,尤其是在线教育、文档管理等场景。然而,简单的PDF预览往往无法满足实际业务需求,特别是在安全性方面。本文将介绍如何基于PDF.js实现一个功能完善的PDF预览组件,并重点讲解如何添加自定义防下载和水印功能,为文档安全提供保障。

功能概览

我们的PDF预览组件实现了以下核心功能:

  1. 基础功能:PDF文件加载与渲染、自定义尺寸控制、页面缩放规则配置、主题切换
  2. 安全增强:动态水印添加、防下载功能、右键菜单禁用、打印控制
  3. 用户体验:页面渲染事件通知、响应式布局适配、加载状态反馈

技术实现

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();
  // 只渲染可见页面,延迟渲染其他页面
};

注意事项

  1. PDF.js版本:确保使用兼容的PDF.js版本,不同版本API可能有差异
  2. 跨域处理:PDF文件可能存在跨域问题,需确保服务器配置了正确的CORS头
  3. 大文件处理:对于大型PDF文件,考虑添加加载进度提示
  4. 移动端适配:在移动设备上可能需要额外的样式调整
  5. 安全限制:虽然实现了防下载和水印,但无法完全防止技术用户获取PDF内容

扩展功能建议

  1. 页面跳转:添加页面导航功能,支持直接跳转到指定页面
  2. 文本搜索:实现PDF内容搜索功能
  3. 注释工具:添加PDF注释、标记功能
  4. 水印样式自定义:支持更多水印样式和位置配置
  5. 访问控制:基于用户角色限制PDF访问权限

总结

本文介绍了如何基于Mozilla PDF.js实现一个功能完善的PDF预览组件,并重点讲解了如何添加自定义的防下载和水印功能。通过合理的技术选型和组件设计,我们实现了一个既美观又安全的PDF预览解决方案。

在实际应用中,您可以根据具体需求进一步扩展功能,如添加页面导航、文本搜索等高级特性,为用户提供更丰富的PDF阅读体验,同时确保文档内容的安全性。

希望本文对您在Vue3项目中实现安全PDF预览功能有所帮助!

需要源码的评论区回复6666

『NAS』中午煮什么?Cook

2026年1月9日 10:20

点赞 + 关注 + 收藏 = 学会了

整理了一个NAS小专栏,有兴趣的工友可以关注一下 👉 《NAS邪修》

cook(来做菜)是一款适合家里食材或厨具不多时用的免费菜谱工具,选好自己有的食材和厨具就能找到适配菜谱,还能随机挑菜谱,非常适合选择困难症用户。

选好菜式,点击会跳转到B站对应的做菜教学视频。

01.png

好,动手安装!

首先在 docker 目录下创建一个 cook 文件夹。

02.png

然后打开“Container Manager”,新增一个项目。

“路径”指向刚刚创建好的 cook 文件夹。

“来源”选择“创建 docker-compose.yml”。

03.png

接着输入以下代码。

services:
  cook:
    image: yunyoujun/cook:latest
    container_name: autopiano
    ports:
      - 8080:80
    restart: unless-stopped

在“网页门户设置”里启用“通过 Web Station 设置网页门户”。

04.png

接着打开“Web Station”,新增一个网络门户。

“服务“这项选择”cook“。

“端口”随便填,只要不跟其他项目冲突即可。

05.png

在浏览器输入你NAS的IP地址,再加上“cook”的端口号,比如本例设置的是 2342。就能看到 cook 的界面了。

01.png


以上就是本文的全部内容啦,想了解更多NAS玩法可以关注《NAS邪修》

点赞 + 关注 + 收藏 = 学会了

React基础框架搭建10-webpack配置:react+router+redux+axios+Tailwind+webpack

作者 Eadia
2026年1月9日 10:11

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源码探析

作者 清风乐鸣
2026年1月9日 10:11

一个核心的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)

作者 LawrenceLan
2026年1月9日 10:08

📘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:定义一个类
  • nameage:类的属性
  • 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+ 高级特性拯救前端开发者

2026年1月9日 10:00

写在前面:你可能每天都在重复这些工作

// 场景 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。

❌
❌