阅读视图

发现新文章,点击刷新页面。

Vue3 的 v-model 双向绑定,90% 的人都用错了?(附 2026 最新避坑指南)

你的表单数据绑定了却不动?自定义组件 v-model 写了就是不生效?
而用 Vue3 正确的 v-model 写法一行代码搞定双向绑定,支持多字段同步、自定义事件、TS 完美兼容——再也不用手动写 $emit('input').sync 修饰符

如果你受够了:

  • 输入框改了值,页面没反应
  • 自定义组件传值像“猜谜游戏”
  • Vue2 转 Vue3 后 v-model 突然失效
  • 团队里有人写 :value + @input,有人写 v-model,代码风格混乱

那么,这篇 2026 年最新实操指南,就是为你写的——
不用翻文档,所有代码模板直接复制粘贴,今天就能写出零 bug 的双向绑定


一、先搞懂:Vue3 的 v-model,到底“新”在哪?

很多从 Vue2 过来的开发者,还在用老思维写 v-model,结果频频翻车。
Vue3 对 v-model 做了三大升级

特性 Vue2 Vue3
绑定属性 固定为 value 可自定义(如 titlecount
触发事件 input 统一为 update:xxx
多绑定支持 不支持 一个组件可绑多个 v-model
语法糖 需配合 .sync 原生支持,无需额外修饰符

一句话总结Vue3 的 v-model = 更灵活 + 更统一 + 更少代码


二、核心干货:v-model 3 大场景实战(附可运行模板)

场景1:基础表单绑定(覆盖 80% 日常开发)

适用于 <input><textarea><select>、复选框等。

【实操代码】(直接复制)

<template>
  <div class="form-demo">
    <!-- 文本输入 -->
    <input v-model="username" placeholder="账号" />
    
    <!-- 密码 -->
    <input v-model="password" type="password" placeholder="密码" />
    
    <!-- 多行文本 -->
    <textarea v-model="bio" placeholder="个人简介"></textarea>
    
    <!-- 复选框(布尔值) -->
    <label>
      <input type="checkbox" v-model="agree" />
      同意用户协议
    </label>

    <!-- 实时预览 -->
    <div class="preview">
      账号:{{ username }}<br/>
      密码:{{ password }}<br/>
      简介:{{ bio }}<br/>
      已同意:{{ agree ? '✅' : '❌' }}
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const username = ref('')
const password = ref('')
const bio = ref('')
const agree = ref(false)
</script>

避坑提醒v-model自动忽略元素上的 valuechecked 属性,不要混用


场景2:自定义组件 v-model(组件通信必备)

让自定义组件像原生表单一样使用 v-model

1. 创建组件:MyInput.vue

<template>
  <div class="my-input">
    <span>自定义:</span>
    <input
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
      placeholder="请输入..."
    />
  </div>
</template>

<script setup>
// 必须叫 modelValue!
const props = defineProps(['modelValue'])
// 必须 emit update:modelValue!
const emit = defineEmits(['update:modelValue'])
</script>

2. 父组件使用

<template>
  <MyInput v-model="customText" />
  <p>输入内容:{{ customText }}</p>
</template>

<script setup>
import { ref } from 'vue'
import MyInput from './MyInput.vue'

const customText = ref('')
</script>

效果:父组件 v-model="customText" → 子组件 modelValue 接收 → 输入时触发 update:modelValue → 父组件自动更新!


场景3:多 v-model 绑定(复杂表单神器)

一个组件同时绑定多个双向数据,比如姓名 + 年龄 + 邮箱。

父组件

<template>
  <UserForm 
    v-model:name="user.name"
    v-model:age="user.age"
    v-model:email="user.email"
  />
  <pre>{{ user }}</pre>
</template>

<script setup>
import { reactive } from 'vue'
import UserForm from './UserForm.vue'

const user = reactive({
  name: '',
  age: 0,
  email: ''
})
</script>

子组件:UserForm.vue

<template>
  <div>
    <input v-model="nameProxy" placeholder="姓名" />
    <input v-model.number="ageProxy" type="number" placeholder="年龄" />
    <input v-model="emailProxy" type="email" placeholder="邮箱" />
  </div>
</template>

<script setup>
const props = defineProps(['name', 'age', 'email'])
const emit = defineEmits(['update:name', 'update:age', 'update:email'])

// 使用计算属性代理,让 v-model 在子组件内也能用
import { computed } from 'vue'
const nameProxy = computed({
  get: () => props.name,
  set: (val) => emit('update:name', val)
})
const ageProxy = computed({
  get: () => props.age,
  set: (val) => emit('update:age', val)
})
const emailProxy = computed({
  get: () => props.email,
  set: (val) => emit('update:email', val)
})
</script>

优势:父组件只需写 v-model:xxx,逻辑清晰,维护成本极低!


三、实战避坑:90% 的人都会踩的 3 个致命错误

坑1:绑定非响应式数据

// 错误 
let text = '' // 普通变量
// v-model="text" → 修改无效!

// 正确 
const text = ref('') // 响应式

坑2:自定义组件命名不规范

// 错误(Vue2 写法)
defineProps(['value'])
defineEmits(['input'])

// 正确(Vue3 标准)
defineProps(['modelValue'])
defineEmits(['update:modelValue'])

坑3:v-model:value 混用

<!-- 错误  -->
<input v-model="msg" :value="defaultValue" />

<!-- 正确  -->
<input v-model="msg" />
<!-- 或初始化时:const msg = ref(defaultValue) -->

四、进阶技巧:用 TS 让 v-model 更安全

// MyInput.vue (TypeScript 版)
<script setup lang="ts">
interface Props {
  modelValue: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
  (e: 'update:modelValue', value: string): void
}>()
</script>

类型检查 + 智能提示,杜绝拼写错误!


五、谁在用 Vue3 的 v-model?

  • 字节跳动:所有内部表单系统强制使用多 v-model 模式
  • 腾讯文档:协作编辑组件通过 v-model:content 实时同步
  • Nuxt 3 官方模板:表单示例全部采用 Composition API + v-model
  • Vue 官方团队:在 RFC 中明确表示 “v-model 是未来组件通信的核心”

结语:双向绑定,本该如此优雅

Vue3 的 v-model 不是“小改动”,而是对组件通信范式的重新定义
当你能用 v-model:titlev-model:count 一行搞定复杂交互,你就知道——这波升级,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

Pinia 比 Vuex 好用 10 倍?Vue3 状态管理终于不折磨人了!(新手复制即用)

还在为 Vuex 的 statemutationactionmodule 四件套头疼?
而用 Pinia一行代码定义状态,直接修改数据,无需 commit,TS 完美支持,刷新页面还能自动持久化——小项目 5 分钟搞定,大项目维护成本直降 60%!

如果你受够了:

  • 写个计数器要建 3 个文件
  • 改个状态要绕 commit('SET_COUNT', 1) 半天
  • 调试时找不到数据在哪被改了
  • 刷新页面状态全丢,还得手动存 localStorage

那么,这篇手把手实操指南,就是为你写的——
不用看文档,所有代码模板直接复制粘贴,今天就能替换掉 Vuex


一、先说清:为什么 Pinia 是 Vue3 的“官方亲儿子”?

Vuex 是 Vue2 时代的产物,设计时没考虑 Composition API 和 TypeScript。
Pinia 由 Vue 核心团队打造,专为 Vue3 而生,直接解决 Vuex 所有痛点:

痛点 Vuex Pinia
配置复杂度 需创建 store/index.js + modules 一个文件就是一个仓库
修改状态 必须通过 mutation(commit 直接 this.count++
TS 支持 弱,需额外类型声明 原生完美支持
代码体积 ~10KB ~5KB(更轻)
调试体验 多层嵌套难追踪 DevTools 一目了然

大厂现状:字节、腾讯、阿里内部 Vue3 项目 100% 使用 Pinia,Vuex 已成历史。


二、核心干货:Pinia 3 步上手(附可运行模板)

第一步:安装(1 行命令)

# 推荐 pnpm
pnpm add pinia

# 或 npm / yarn
npm install pinia
yarn add pinia

第二步:全局注册(2 行代码)

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia' // ← 只需引入这个
import App from './App.vue'

const app = createApp(App)
app.use(createPinia()) // ← 注册
app.mount('#app')

避坑提醒不需要像 Vuex 那样写 new Store({}) 或分模块配置!


第三步:创建并使用仓库(核心!直接复制)

1. 创建仓库:src/store/counterStore.js

import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // state:直接返回对象(响应式)
  state: () => ({
    count: 0,
    name: 'Pinia测试'
  }),

  // getters:计算属性(自动缓存)
  getters: {
    doubleCount: (state) => state.count * 2
  },

  // actions:同步/异步方法(直接修改 state!)
  actions: {
    increment() {
      this.count++ // 不用 commit!
    },
    async incrementAsync() {
      await new Promise(r => setTimeout(r, 1000))
      this.count++
    }
  }
})

2. 在组件中使用

<template>
  <div>
    <h3>{{ counterStore.name }}</h3>
    <p>当前:{{ counterStore.count }}</p>
    <p>2倍:{{ counterStore.doubleCount }}</p>
    <button @click="counterStore.increment">+1</button>
    <button @click="counterStore.incrementAsync">异步+1</button>
  </div>
</template>

<script setup>
// 引入 + 实例化(关键!)
import { useCounterStore } from '@/store/counterStore'
const counterStore = useCounterStore() // ← 必须实例化!
</script>

效果:状态、计算属性、方法全部自动暴露,无需 mapStatemapActions


三、实战避坑:90% 新手都会踩的 3 个致命错误

坑1:只引入不实例化,导致 undefined

// 错误
import { useCounterStore } from '@/store/counterStore'
console.log(useCounterStore.count) // 报错!

// 正确
const counterStore = useCounterStore()
console.log(counterStore.count) // 正常

坑2:在组件里直接改状态,破坏可维护性

// 不推荐(小型 demo 可以,项目别这么干)
counterStore.count = 999

// 推荐(统一走 actions,便于调试和复用)
counterStore.increment()

坑3:多个仓库用相同 ID,数据互相污染

// 错误
defineStore('user', { ... })
defineStore('user', { ... }) // ID 重复!

// 正确
defineStore('user', { ... })
defineStore('cart', { ... }) // ID 唯一

四、进阶技巧:一行代码实现状态持久化(刷新不丢)

默认 Pinia 状态刷新就没了?用官方插件 pinia-plugin-persistedstate,轻松搞定!

1. 安装插件

pnpm add pinia-plugin-persistedstate

2. 配置插件

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate) // ← 启用插件
app.use(pinia)
app.mount('#app')

3. 仓库开启持久化

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  actions: { ... },
  persist: true // ← 就这一行!
})

效果count 自动存入 localStorage,刷新页面依然保留!


五、谁在用 Pinia?

  • 字节跳动:抖音 Web 端、飞书文档全面采用 Pinia
  • 腾讯:微信开放平台、腾讯文档 Vue3 项目标配
  • Nuxt 3:官方默认状态管理方案
  • Vue 官方生态:Vue Router、VitePress 示例均使用 Pinia

结语:状态管理,本该如此简单

Pinia 的价值,不只是“替代 Vuex”,而是让状态管理回归本质:直观、可维护、可扩展
当你不再为写 mutation 而烦恼,你就知道——这波升级,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

救命!Vue3 的 Composition API,居然能让我少写 80% 冗余代码?(新手也能直接抄)

你的 Vue 组件里是不是还在 datamethodscomputedwatch 之间来回跳转?
而用 Composition API一个 setup 函数搞定所有逻辑,代码量直降 80%,逻辑清晰到实习生都能看懂

如果你受够了:

  • Options API 里找某个变量要翻半天
  • 相同逻辑(比如表单校验)在多个组件里复制粘贴
  • 面试被问 “Vue3 和 Vue2 区别” 只能答“Proxy 更快”
  • 想复用逻辑却只能靠 Mixin(然后陷入命名冲突地狱)

那么,这篇手把手实操指南,就是为你写的——
不用死记硬背,所有代码模板直接复制粘贴,今天就能用上


一、先澄清一个误区:Composition API 不是“花里胡哨”,是真能救急

很多新手觉得:“Options API 能用,为啥换?”
但真相是:Options API 在复杂组件中,逻辑天然割裂

举个真实例子:写一个带防抖搜索 + 加载状态 + 错误提示的搜索框

  • Options API 写法

    • data 里定义 keyword, loading, error
    • methods 里写 search(), debounce()
    • watch 里监听 keyword 触发搜索
    • mounted 里可能还要初始化默认值
      同一个功能,散落在 4 个地方!
  • Composition API 写法

    const { keyword, loading, error, search } = useSearch()
    

一行代码,逻辑内聚,复用无痛

大厂现状:字节、腾讯、阿里内部 Vue3 项目 100% 强制使用 Composition API,面试必考。


二、核心干货:Composition API 3 个必学用法(附可运行模板)

1. script setup:所有逻辑的“入口”,一次搞定所有

这是 Vue3 官方推荐的写法,无需 return,自动暴露所有变量和方法

实操代码模板(直接复制到项目)

<template>
  <div>
    <input v-model="username" placeholder="请输入账号" />
    <button @click="login" :disabled="isLoading">
      {{ isLoading ? '登录中...' : '登录' }}
    </button>
  </div>
</template>

<script setup>
// 1. 定义响应式数据(替代 data)
import { ref } from 'vue'
const username = ref('')       // 响应式字符串
const isLoading = ref(false)   // 响应式布尔值

// 2. 定义方法(替代 methods)
const login = () => {
  isLoading.value = true
  // 模拟登录请求
  setTimeout(() => {
    console.log('登录成功,账号:', username.value)
    isLoading.value = false
  }, 1000)
}
// 3. 无需 return!<script setup> 自动暴露
</script>

避坑提醒:只有普通 setup() 函数才需要手动 return<script setup> 不用!


2. ref vs reactive:响应式数据的“两大神器”,别再用混了

记住口诀:**简单数据用 **ref复杂对象用 reactive

场景 推荐 API 修改方式 模板中使用
字符串、数字、布尔值 ref count.value = 1 {{ count }}
对象、数组 reactive user.name = 'Tom' {{ user.name }}

ref 实操示例】

import { ref } from 'vue'
const count = ref(0)

const increment = () => {
  count.value++ // 必须加 .value!
}

【reactive实操示例】

import { reactive } from 'vue'
const user = reactive({
  name: '',
  age: 0,
  hobbies: []
})

const updateUser = () => {
  user.name = 'Alice' // 直接修改,不加 .value
  user.hobbies.push('coding')
}

关键技巧:用 toRefs 解构 reactive 对象,保持响应式

import { reactive, toRefs } from 'vue'
const user = reactive({ username: '', password: '' })

// 解构后仍响应式
const { username, password } = toRefs(user)
username.value = 'test' // 有效!

3. 生命周期钩子:按需引入,不用写空方法

Vue3 生命周期需显式导入,更灵活,且避免无用代码。

【常用生命周期对照表】

Vue2 Vue3
mounted onMounted
updated onUpdated
beforeUnmount onBeforeUnmount

【实操示例:页面加载后请求数据】

<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'

const list = ref([])

onMounted(async () => {
  const res = await axios.get('/api/user/list')
  list.value = res.data
})
</script>

避坑提醒:生命周期钩子必须在 <script setup>setup() 内部调用,不能在外部!


三、实战避坑:90% 的人都会踩的 3 个致命错误

坑1:忘记给 ref.value,导致响应式失效

// 错误
const count = ref(0)
count = 1 // 页面不会更新!

// 正确
count.value = 1

坑2:用 reactive 创建简单数据

// 错误
const count = reactive(0) // reactive 只接受对象/数组
count = 1 // 响应式丢失!

// 正确
const count = ref(0)

坑3:为了“规范”强行封装,把简单逻辑搞复杂

正确姿势:只有跨组件复用的逻辑才封装成 Hook,否则直接写!


四、进阶技巧:用自定义 Hook 复用逻辑,效率拉满!

把重复代码(如表单校验、请求封装、本地存储)抽成 Hook,多个组件直接引入,少写 80% 代码

【实战示例:封装通用表单校验 Hook】

第一步:创建 hooks/useForm.js

// hooks/useForm.js
import { ref } from 'vue'

export const useForm = (rules) => {
  const form = ref({})
  const errors = ref({})

  const validate = () => {
    let isValid = true
    for (const key in rules) {
      const rule = rules[key]
      if (!form.value[key] && rule.required) {
        errors.value[key] = rule.message
        isValid = false
      } else {
        errors.value[key] = ''
      }
    }
    return isValid
  }

  return { form, errors, validate }
}

第二步:在组件中使用

<script setup>
import { useForm } from '@/hooks/useForm'

const { form, errors, validate } = useForm({
  username: { required: true, message: '请输入账号' },
  password: { required: true, message: '请输入密码' }
})

const login = () => {
  if (validate()) {
    console.log('提交数据:', form.value)
  }
}
</script>

<template>
  <div>
    <input v-model="form.username" />
    <span v-if="errors.username" class="error">{{ errors.username }}</span>
    
    <input type="password" v-model="form.password" />
    <span v-if="errors.password" class="error">{{ errors.password }}</span>
    
    <button @click="login">登录</button>
  </div>
</template>

效果:以后任何表单,只需 3 行代码引入,校验逻辑自动生效!


五、谁在用 Composition API?

  • 字节跳动:抖音 Web 端全量 Vue3 + Composition API
  • 腾讯文档:协同编辑组件基于自定义 Hook 构建
  • 阿里云控制台:复杂表单系统 100% 使用 useXXX 模式
  • Vue 官方生态:Pinia、Vue Router 4 全面拥抱 Composition

结语:少写代码,才是高级程序员的终极追求

Composition API 的价值,不只是“新语法”,而是用函数式思维组织逻辑,让代码可读、可测、可复用
当你不再为找变量翻遍整个文件,你就知道——这波升级,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

前端必懂!一文搞懂 WebAssembly:Web/Electron/RN 全通用,你天天用的软件,底层都靠它

对于前端开发者而言,WebAssembly(简称 Wasm)或许是一个"熟悉又陌生"的名词。

image.png

偶尔能够在技术文章中看到,却很少在日常开发中用到。

但事实上,它在 ElectronReact Native 等主流跨平台框架中,Wasm 现在已经成为了突破前端性能瓶颈的主要手段。

Wasm 到底是啥?

很多时候大家会把 Wasm 当成一种编程语言,其实这是一个常见误区。

Wasm 不是编程语言,而是一种二进制字节码格式,是 W3C 推荐的第四种 Web 核心技术(与 HTML、CSS、JavaScript 并列)。

image.png

用最直白的话来说:

Wasm 是开发者用 C/C++、Rust、Go 等语言编写高性能代码,再通过编译工具将其编译成 .wasm 二进制文件。

前端开发者无需关心底层实现,只需像调用 npm 包一样,通过Js加载并调用其中的功能。

核心优势很直接,就是"接近原生的性能"。

由于是二进制格式,解析速度比Js快 5-10 倍,运行速度可达原生代码的 70%~90%

而且同时具备安全沙箱(运行在隔离环境,不直接访问系统资源)、跨平台(一次编译,多端通用)、体积小(二进制文件比Js体积小得多)的特点。

这里需要注意:Wasm 不是来替代Js的,而是和Js合作的

  • Js 负责 DOM 操作、UI 交互、网络请求等灵活场景。
  • Wasm 负责计算密集型、CPU 高负载任务(如 3D 渲染、图像处理、加密、大数据计算)。

Wasm 在跨平台框架中使用

Wasm 不是只能在浏览器中运行。

无论是桌面端的 Electron,还是移动端的 React Native,都能完美支持 Wasm,甚至比在浏览器中使用更自由、更灵活。

Wasm 的运行是不依赖具体的浏览器环境,只要有对应的运行时(如 V8 引擎、Wasm3 引擎),就能在任何平台运行。

而主流跨平台框架,早已内置或支持集成 Wasm 运行时。

Electron使用

Electron 的架构是"Chromium + Node.js",而 Chromium 内核本身就原生支持 WebAssembly

image.png

因此在 Electron 中使用 Wasm,和在浏览器中几乎没有区别。

// 加载并调用 Wasm 模块(以加法功能为例)
async function loadWasm() {
  // 1. 加载编译好的 .wasm 文件(和前端资源放在同一目录)
  const res = await fetch("/add.wasm");
  const bytes = await res.arrayBuffer();
  
  // 2. 编译 + 实例化 Wasm 模块
  const { instance } = await WebAssembly.instantiate(bytes);
  
  // 3. 直接调用 Wasm 暴露的方法,和调用 npm 包一致
  const result = instance.exports.add(10, 20);
  console.log("Wasm 计算结果:", result); // 输出 30
}

// 执行调用
loadWasm();

实际上 VS Code、Figma、剪映专业版等主流 Electron 桌面应用,都大量使用 Wasm 处理核心计算逻辑。

比如 Figma 的矢量图形引擎、剪映的视频解码,都是通过 C++ 编译成 Wasm 实现的,既保证了性能,又实现了跨平台兼容。

React Native使用

由于 React Native(RN)本身不依赖浏览器环境,无法直接使用浏览器的 Wasm 运行时。

但可以使用 react-native-webassembly(简洁易用)和 wasm3(轻量引擎)插件就能在 RN 中调用 Wasm 模块。

// 安装依赖
// yarn add react-native-webassembly 或 npx expo install react-native-webassembly

// 配置 metro.config.js
module.exports = {
  resolver: {
    extensions: ['.js', '.jsx', '.ts', '.tsx', '.wasm'], // 新增 .wasm 后缀
  },
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineSourceMap: false,
      },
    }),
  },
};

// 调用 Wasm
import React, { useEffect } from 'react';
import { View, Text } from 'react-native';
import WebAssembly from 'react-native-webassembly';
// 导入本地 .wasm 文件(需放在项目可访问目录)
import addWasm from './add.wasm';

const WasmDemo = () => {
  useEffect(() => {
    // 加载并调用 Wasm
    const runWasm = async () => {
      const { instance } = await WebAssembly.instantiate(addWasm);
      const result = instance.exports.add(20, 30);
      console.log("RN 中 Wasm 计算结果:", result); // 输出 50
    };
    runWasm();
  }, []);

  return (
    <View>
      <Text>React Native + WebAssembly 示例</Text>
    </View>
  );
};

export default WasmDemo;

注意:RN 中使用 Wasm 时,需确保项目支持新架构(部分旧版本 RN 可能存在兼容问题)。

总结

其实可以把 Wasm 理解为一个"不挑平台、不挑框架的超级高性能工具包"。

当你在 Web、Electron、RN 等平台开发时,遇到 JS 无法承载的计算密集型任务(如图像处理、3D 渲染、加密、AI 推理),就可以考虑引入Wasm。

🚨 还在用 rem) 做大屏适配?用 vfit.js 一键搞定,告别改稿8版的噩梦!

 导读:欢迎来到《vfit.js 大屏适配指南》系列第 1 篇。在这个系列中,我们将带你彻底告别大屏适配的折磨,打通"痛点-解法-实战-优化-落地"的完整闭环。

上周,群里一个做政务大屏的兄弟心态崩了:"地图和图表用 rem 调了一整周,好不容易对齐了。结果去交付现场一看,客户的指挥中心用的是3840×2160 的超宽带鱼屏,整个页面全乱套了……"

底下瞬间刷出几十条"太真实了"。

做大屏开发的你,是不是也经常遭遇这些"社死时刻"?

  •  政务指挥中心:设计稿 1920×1080,现场屏幕长宽比极其奇葩,ECharts 图表直接挤成一团。
  • 工业监控大屏:用 rem 算来算去,DataV 的飞线动效死活偏了3 个像素。
  • 数据驾驶舱:老板在 iPad 竖屏打开大屏链接,质问你"为什么右边全白了?"

如果你中招了,请立刻停下手里写了一半的媒体查询!这篇指南,就是你的救命稻草!


🛑 为什么传统的适配方案,在大屏上必死无疑?

说到底,很多人没搞懂一个核心:可视化大屏,根本不是普通网页!

做普通后台管理系统,内容像"水流",用 flex、grid 响应式排版就行。
但做智慧城市、数字孪生这种大屏,页面是一幅"静态油画"。

📌 标题必须死死钉在正中间;
📌 3D 地图必须霸占绝对 C 位;
📌 两侧的数据面板哪怕字小点,也绝不能换行或错位。

所有元素的相对位置,必须焊死!

我们来看看你以前用的方案,为什么会翻车。

1️⃣ rem 方案:万恶的"单位转换地狱"

算比例、转 px,每次窗口一动就要重算。最要命的是,像 ECharts、高德地图、Three.js 这些第三方可视化库,底层全认 px!你用 rem,等于给自己挖了一个永远填不满的兼容坑。

2️⃣ vw/vh 方案:控制不住的"高度变形"

车联网驾驶舱时,宽度用 vw 撑满了,一旦屏幕比例从 16:9 变成 16:10,高度用 vh 就会拉伸,你的圆形仪表盘直接变成"椭圆",客户看着直摇头。


💡 终极解法:Scale 等比缩放,为什么你没早点用?

既然大屏是一幅画,那最完美的适配逻辑就是:把这幅画当成一个整体,等比例放大缩小!

就像在 PPT 里拖拽图片的对角线一样,不改变内部任何尺寸,只改变整体视野

  • • 设计稿是 px,你就写 px:零转换成本,所见即所得。
  • • 可视化库完美兼容:ECharts、DataV 闭眼用,再也不用担心偏移。
  • • 极致性能:利用 GPU 硬件加速,大屏不卡顿。

但手动写 Scale 有个巨坑:你要自己算比例、监听窗口、处理绝对定位失真……


🚀 登场:vfit.js,把你从加班中拯救出来

为了解决 Scale 方案的最后一公里痛点,vfit.js 诞生了。
这是一个专为 Vue 3 可视化大屏打造的轻量级适配神器。

不管你是做公安大屏、还是工厂看板,只需 3 行代码:

import { createApp } from 'vue'
import { createFitScale } from 'vfit'
import 'vfit/style.css'

const app = createApp(App)

// 告诉它设计稿尺寸,剩下交给 vfit
app.use(createFitScale({
  designWidth1920,   
  designHeight1080,  
  scaleMode'auto'    
}))

app.mount('#app')

就这么简单!代码一交,按时下班。


🎁 互动福利:大屏避坑资料包

你以前做大屏遇到过最奇葩的屏幕尺寸是多少?
👇 在评论区吐槽你的经历 👇

🔥 福利时间
关注公众号,后台回复【大屏模板】,即可免费领取:

1. vfit.js 开箱即用 Vue3 工程模板(带 ECharts 示例)

2. 大屏常见奇葩分辨率适配速查表

官方资源直达:


🔗 推荐阅读与下期预告

📚 推荐深度阅读

在开始源码探索前,强烈推荐先阅读这两份权威指南:


🔜 下期预告:别急着复制,先懂底层!

今天我们明确了:Scale 是大屏适配的唯一真理
但作为高级前端,只会调包可不行。万一在嵌套 iframe 的工业后台里失效了怎么办?

下一篇: 《02 - 5分钟看懂 vfit.js 大屏适配源码:政务/工业看板防变形黑科技,就这50行!》
我们将扒开 vfit 的底裤,带你搞懂 ResizeObserver 和 GPU 缩放的核心原理。我们下期见!

紧急安全警报:Axios npm 包被投毒事件详解与防护指南

🚨 事件概述

2026 年 3 月 31 日,安全研究机构 StepSecurity 披露了一起震惊开源社区的重大安全事件:主流 JavaScript 库 Axios 的两个 npm 版本(1.14.1 和 0.30.4)被恶意植入远程控制代码

由于 Axios 是全球使用最广泛的 HTTP 客户端库,周下载量超过 3 亿次,此次供应链攻击的影响范围极其巨大,几乎所有使用 Node.js 的项目都可能受到影响。

⏰ 攻击时间线(北京时间)

timeline
    title Axios 供应链攻击事件时间线
    section 3 月 30 日
        23:59 : 攻击者发布<br/>plain-crypto-js@4.2.1
    section 3 月 31 日
        00:00 : 劫持维护者账号<br/>发布被投毒的 Axios 版本
        00:05 : Socket.dev 检测到<br/>异常依赖包
        04:00 : npm 官方下架<br/>所有恶意包
        08:00 : 安全机构公开披露<br/>事件详情

🔍 攻击手法深度解析

1️⃣ 账号劫持

攻击者成功劫持了 Axios 核心维护者 "jasonsaayman" 的 npm 账号,并将账号邮箱替换为匿名的 ProtonMail 地址。这一操作使得攻击者能够完全控制包的发布流程。

2️⃣ 绕过 CI/CD 审核

正常情况下,npm 包的发布需要通过 GitHub Actions 自动化流程进行构建和验证。但攻击者利用维护者权限,直接通过 npm CLI 手动上传被污染的版本,绕过了所有自动化安全检查。

3️⃣ 虚假依赖注入

这是整个攻击中最狡猾的部分。攻击者并没有直接修改 Axios 源码,而是采用了更隐蔽的手法:

{
  "dependencies": {
    "axios": "^1.14.1",
    "plain-crypto-js": "4.2.1"  // ← 恶意依赖包
  }
}

plain-crypto-js@4.2.1 是一个从未在 Axios 代码中被引用的虚假依赖包,它唯一的作用就是执行 postinstall 脚本。

4️⃣ 双重伪装策略

为了规避安全检测,攻击者提前 18 小时发布了两个版本的伪装包:

版本号 类型 作用
plain-crypto-js@4.2.0 干净版本 用于打掩护,降低安全工具警惕
plain-crypto-js@4.2.1 恶意版本 携带木马脚本,执行攻击

这种策略使得恶意包看起来像是"已有包的正常更新",而非"全新可疑包"。

5️⃣ 自动执行机制

当开发者执行 npm install axios 命令时,会发生以下连锁反应:

# 开发者执行的命令
npm install axios

# 实际发生的过程
├── 安装 axios@1.14.1 (被投毒版本)
├── 自动安装 plain-crypto-js@4.2.1 (恶意依赖)
└── 触发 postinstall 脚本
    └── 执行 setup.js (恶意脚本)
        └── 连接 C2 服务器 (sfrclak.com)
            └── 下载并运行跨平台木马

💀 恶意行为分析

感染流程

一旦触发恶意脚本,会根据操作系统类型执行不同的攻击载荷:

Windows 系统

# 创建隐藏的 PowerShell 窗口
VBScript → 隐藏 cmd.exe → 保存木马到 %TEMP%\6202033.ps1

# 持久化驻留
复制到:%PROGRAMDATA%\wt.exe
伪装成:Windows Terminal 可执行文件

macOS 系统

# 藏匿位置
/Library/Caches/com.apple.act.mond

# 伪装方式
伪装成:macOS 系统缓存进程

Linux 系统

# 直接执行
/tmp/ld.py

# 后台驻留
nohup python3 /tmp/ld.py &

恶意功能

木马成功后会执行以下操作:

  1. 连接远程指挥服务器 - 域名:sfrclak.com
  2. 窃取敏感信息 - 环境变量、API 密钥、配置文件
  3. 下载额外载荷 - 根据系统架构下载更多恶意程序
  4. 建立持久后门 - 保持后台运行,长期潜伏
  5. 自我清理 - 删除恶意脚本,伪造干净的配置文件

🎯 影响范围评估

高危项目

以下类型的项目风险最高:

  • 使用 axios@1.14.1 或 0.30.4 的所有项目
  • OpenClaw("龙虾")AI 智能体工具用户
  • React/Vue 前端项目
  • Node.js 后端服务
  • CI/CD 工具和自动化脚本
  • MCP Server 和各种 AI 编程工具

传播途径

graph LR
    A[开发者] --> B[npm install axios]
    B --> C[安装被投毒版本]
    C --> D[自动执行恶意脚本]
    D --> E[连接 C2 服务器]
    E --> F[下载木马程序]
    F --> G[系统被完全控制]
    
    H[AI 编程工具] --> I[自动安装依赖]
    I --> C

特别警示:AI 编程工具风险

2026 年流行的 AI 编程工具(如 Claude Code、Codex CLI、OpenClaw 等)大幅扩大了 npm 的攻击面:

  • 🔴 自动安装依赖 - AI 可能在你不知情的情况下安装被投毒的包
  • 🔴 高系统权限 - AI 工具通常有文件读写、命令执行权限
  • 🔴 难以审计 - 你可能连自己安装了什么都不清楚

正如社区所言:"你自己不写 npm 命令,AI 替你写了,你可能连自己装了什么都不知道。"

🛡️ 紧急处置方案

第一步:立即自查

# 检查项目中是否使用了 axios
npm list axios

# 或使用 pnpm
pnpm list axios

# 查看详细版本
npm list axios --depth=0

如果看到以下版本,立即采取行动

  • axios@1.14.1
  • axios@0.30.4

第二步:紧急卸载

# 立即卸载被投毒版本
npm uninstall axios

# 删除 node_modules 和锁文件(可选但推荐)
rm -rf node_modules package-lock.json
# Windows PowerShell:
# Remove-Item -Recurse -Force node_modules, package-lock.json

# 重新安装安全版本
npm install axios@latest

第三步:检查失陷迹象

Windows 系统

# 检查可疑文件
Test-Path "$env:PROGRAMDATA\wt.exe"
Test-Path "$env:TEMP\6202033.ps1"

# 检查网络连接
netstat -ano | findstr sfrclak.com

macOS 系统

# 检查可疑目录
ls -la /Library/Caches/com.apple.act.mond

# 检查异常进程
ps aux | grep -i "act.mond"

Linux 系统

# 检查恶意脚本
ls -la /tmp/ld.py

# 检查 Python 进程
ps aux | grep ld.py

# 检查网络连接
netstat -tulpn | grep sfrclak.com

第四步:重置凭证

如果你确认安装了被投毒的版本,必须立即重置所有敏感凭证:

  • 🔑 所有 API 密钥(云服务、数据库、第三方服务)
  • 🔑 SSH 密钥和访问令牌
  • 🔑 数据库密码
  • 🔑 管理员账户密码
  • 🔑 任何存储在环境变量中的敏感信息

因为木马具备窃取环境变量的能力,即使你已经卸载了恶意包,之前泄露的信息也需要全部更换

🔒 长期防护策略

1. 锁定依赖版本

package.json 中避免使用模糊版本范围:

{
  "dependencies": {
    "axios": "1.13.0"     // ✅ 确切版本
    // 而不是 "axios": "^1.13.0"  ❌
  }
}

2. 禁用自动脚本执行

# 全局配置
npm config set ignore-scripts true

# 或在 .npmrc 文件中添加
ignore-scripts=true

3. 启用 npm 审计

# 安装时自动审计
npm audit

# 自动修复可修复的问题
npm audit fix

# 强制修复(可能破坏兼容性)
npm audit fix --force

4. 使用安全工具

# 安装 socket-security 等安全工具
npm install -g socket-security

# 使用 Snyk 进行持续监控
npm install -g snyk
snyk test

5. 实施依赖审查流程

对于企业级项目,建议:

  • ✅ 使用私有 npm 镜像(如 Verdaccio)
  • ✅ 实施依赖包白名单制度
  • ✅ 定期生成 SBOM(软件物料清单)
  • ✅ 使用 Sigstore 等签名验证机制

6. AI 编程工具使用规范

如果你使用 AI 编程工具:

  • ⚠️ 审查所有自动安装的依赖
  • ⚠️ 不要给 AI 过高的系统权限
  • ⚠️ 定期检查 node_modules 内容
  • ⚠️ 在隔离环境中运行 AI 生成的代码

📊 技术细节补充

恶意域名信息

  • C2 服务器: sfrclak.com
  • 注册时间: 2026 年 3 月 30 日
  • 注册商: 匿名注册服务

恶意包哈希值

供安全工具检测使用:

plain-crypto-js@4.2.1:
SHA-256: [已移除,避免传播]

axios@1.14.1 (被投毒版本):
SHA-256: [已移除,避免传播]

axios@0.30.4 (被投毒版本):
SHA-256: [已移除,避免传播]

网络特征

安全设备可以监控以下网络请求:

POST https://sfrclak.com/api/gateway
User-Agent: node-fetch/1.0 (+https://github.com/bitinn/node-fetch)
Content-Type: application/json

🎓 事件启示

供应链安全的脆弱性

这次事件再次暴露了现代软件供应链的脆弱性:

  1. 单点故障 - 一个维护者账号被劫持,影响数亿用户
  2. 信任链断裂 - 我们信任的知名库也可能被投毒
  3. 自动化风险 - CI/CD 流程被绕过,缺乏多层验证
  4. 依赖传递 - 你的依赖的依赖也可能有问题

开源安全的新挑战

随着 AI 编程工具的普及,攻击面正在急剧扩大:

  • 🤖 AI 自动决策 - AI 可能选择安装不安全的依赖
  • 🤖 权限放大 - AI 的高权限使得攻击后果更严重
  • 🤖 审计困难 - 自动生成的代码更难追溯和审查

开发者的责任

作为开发者,我们需要:

  • ✅ 保持安全意识,不盲目信任任何依赖
  • ✅ 实施最小权限原则
  • ✅ 建立完善的依赖管理和审计流程
  • ✅ 关注安全动态,及时响应漏洞预警

📝 总结

关键要点

  1. 受影响版本: axios@1.14.1axios@0.30.4
  2. 攻击手法: 劫持维护者账号 + 虚假依赖 + 自动执行脚本
  3. 影响范围: 周下载量 3 亿+,全平台受影响
  4. 恶意行为: 远程控制木马 + 信息窃取 + 持久化驻留
  5. 处置方案: 立即自查 → 紧急卸载 → 检查失陷 → 重置凭证

行动清单

  • 检查所有项目的 axios 版本
  • 如果中招,立即卸载并重装安全版本
  • 检查系统是否有失陷迹象
  • 重置所有可能泄露的凭证
  • 更新 package.json 锁定版本号
  • 配置 npm 忽略自动脚本
  • 安装安全审计工具
  • 学习 AI 编程工具安全使用规范

🔗 参考资料

  1. StepSecurity 官方报告:链接
  2. npm 安全公告:链接
  3. Socket.dev 检测分析:链接
  4. GitHub Issue 讨论:链接

沃尔沃汽车第一季度全球销量同比下降11%

沃尔沃汽车4月2日公布,2026年第一季度全球销量为153,316辆,同比下降11%。纯电动汽车占所有汽车销量的23.7%,插电式混合动力汽车占23.6%,本季度电动汽车销量占所有汽车销量的47.3%。(界面)

中信证券接手蔚能武汉电池科技公司

36氪获悉,爱企查App显示,近日,蔚能(武汉)电池科技有限公司发生工商变更,原股东武汉蔚能电池资产有限公司退出,新增中信证券股份有限公司为全资股东,同时,赖晓明卸任法定代表人、董事、经理,刘勇接任法定代表人、董事,王洲接任经理。 该公司成立于2025年10月,经营范围包括蓄电池租赁、新能源汽车废旧动力蓄电池回收及梯次利用、电动汽车充电基础设施运营等。

阶跃星辰上线Step 3.5 Flash 2603模型

36氪获悉,阶跃星辰正式上线新模型Step 3.5 Flash 2603。据了解,阶跃星辰Step 3.5 Flash 2603是基于Step 3.5 Flash持续优化的面向高频编程与日常Agent工作流的实用型开发者模型,围绕代码生成、调试、重构以及Agent工作流等场景进行了专项增强,Step Plan订阅用户可直接调用该模型API。

商务部:进一步发挥中美经贸磋商机制作用加强对话沟通

商务部新闻发言人何亚东在回答关于中美经贸关系的提问时表示,去年以来,在两国元首重要共识战略引领下,中美经过六轮经贸磋商,在经贸领域达成一系列磋商成果,为双边经贸关系和世界经济注入了更多稳定性和确定性。事实充分证明,坚持相互尊重、平等对话协商,是弥合分歧、解决问题的最好方式。中美双方应落实好两国元首重要共识以及前期经贸磋商成果,进一步发挥中美经贸磋商机制作用,加强对话沟通,妥善管控分歧,拓展务实合作,促进中美经贸关系健康、稳定、可持续发展。(新华社)

商务部回应Meta收购Manus及企业跨国经营问题

商务部4月2日举行例行新闻发布会,有记者提出中方对Meta收购Manus会采取哪些措施以及企业跨国经营的相关问题,商务部新闻发言人何亚东对此回应说,中国政府支持企业根据需要开展跨国经营与技术合作,相关行为需遵守中国法律法规,履行法定程序。(新华社)

泡泡玛特无需下一个LABUBU

3月25日,泡泡玛特交出了2025年的答卷。过去一年,泡泡玛特的收入为人民币371.2亿元,同比增长185%,净利润为130.1亿元,同比增长293%。

围绕泡泡玛特的讨论再次沸腾起来,只是相较于去年中报发布之际的欢欣鼓舞,此次与泡泡玛特亮眼财报成绩一并而至的,是资本市场的负面情绪。

对此泡泡玛特没有坐以待毙,连续选择四个交易日斥资12亿港元大手笔回购,以此向市场传递公司长期向好的信心。

股价过山车的背后仍是那些老生常谈的问题,泡泡玛特是否能维持住2025年的高速增长,以及其是否在依赖单一IP LABUBU。

从IP的角度来看,过去一年LABUBU及其所在的THE MONSTERS系列收入超百亿元,占比公司总收入的约38%;包括SKULLPANDA、CRYBABY、MOLLY等在内的6个IP收入超20亿元、17个IP收入超1亿元,加在一起已经与LABUBU相当。

新IP中,还有2024年年中推出的星星人,以16倍的增长成为了泡泡玛特IP矩阵的中坚力量。

这背后体现的是泡泡玛特作为一家IP公司的运营能力。

回到此次的股价波动上,有潮玩行业的资深从业者表示,情绪的原因更大,“公司财报的基本面是没问题的,大家心态都很稳。”

还有一位投资者如此表示,泡泡玛特CEO王宁其实一直在让公司降温,不追求规模和增速,而是要高质量的增长。只是LABUBU的光环过于耀眼,以至于鲜有人在意这些言论。

放眼更长的时间维度,IP集中度高,是所有内容公司的必经阶段——Disney是靠米老鼠开启童话王国,Marvel凭复联建立宇宙纪元,Sanrio则凭借Hello Kitty定义了全球的“可爱经济学”,没有一个IP公司是“平均分布起步”,关键在于如何在百年后仍让人看见米老鼠、复联和Hello Kitty。

泡泡玛特首席运营官司德曾向媒体如此表示,“我们和迪士尼学到最核心的点就是持续投入、持续运营。”如今泡泡玛特仍在大力投入LABUBU,这个已经诞生十年的IP,以让其成为一个“具有长生命周期的世界级IP”。

如何让IP活得更久、有更多的复利,也是泡泡玛特成为一家年收入超300亿的公司后,所要面对的考题。

 “这一个LABUBU”还会活很久

过去三年间,LABUBU先后在东南亚、中国以及欧美等地爆火,增长惊人。只是没有哪个IP可以做到一直高速增长,但好的IP会有更长久的生命力和更多复利,也更加值得投资。

正如段永平3月30日在投资平台表示“收回不投资泡泡玛特”的那样——经济学的“速度”实际上是物理里面的“加速度”。 投资买的是未来的总量,是物理里面的“速度”x“时间”得到的“总长度”,当然有点“加速度”会在单位时间里跑得更远。其未尽之语是,泡泡玛特确实能在未来走得更远,LABUBU的爆火不过是让现在的泡泡玛特跑得更快了。

2025年,泡泡玛特旗下的毛绒产品实现收入187亿元,同比增长560.6%,首次超过手办,成为泡泡玛特旗下最挣钱的品类,诠释了一个新的增长故事。

而这个故事的开头就是LABUBU。2022年,为了激发创意、提高生产效率,泡泡玛特的产品部门按照品类拆分出了MEGA组、毛绒组、积木组等,为如今毛绒品类的爆发埋下了伏笔。

2023年,毛绒组把搪胶毛绒形态的LABUBU样品一次又一次摆到高管面前,直到看到第一代产品,“这事儿成了”,彼时的司德如此评价它。

再后来的故事已经人尽皆知。在过去两年的财报里,与搪胶毛绒LABUBU字眼同步出现的是三位数的增长、破百亿的收入。在消费端,其更是永远在秒没、售罄。

搪胶毛绒这一工艺和LABUBU形象的完美结合,验证了品类、产品设计本身,对于延续一个IP生命力的重要作用,而这也是泡泡玛特一直在做的事情。过去一年的时间里,包括SKULLPANDA、CRYBABY在内的IP,也都迭代了毛绒形态。

产品形态的更新,通过视觉、设计等更直观的设计,反过来也可以让消费者更好地理解、喜爱IP。

从做出一个好的IP,到让这个IP触动消费者,线下是必要的一环。与之相对应,乐园、超级门店等具有艺术包裹感的场景,也可以更好地诠释、呈现IP。

于是,偶装形态的LABUBU走进了乐园,消费者们又多了一个看见、感受这个小精灵的场景。

2025年11月,THE MONSTERS家族还参与了梅西百货感恩节游行——后者是始于1924年的,全球最古老、最盛大的感恩节游行。

3月25日,司德在业绩会中表示,2025年乐园明星朋友开展外出特别活动达40场,正通过线下场景不断加深IP与粉丝的情感连接。

近期,LABUBU这位当红女明星还走到了大荧幕上。

3月19日,LABUBU官宣成为了电影主角,泡泡玛特与索尼影业达成合作,将由《帕丁顿熊》的导演保罗·金执导拍摄LABUBU真人动画电影。

消息公布后,已经有网友开始梳理THE MONSTERS家族中的人物关系。还有人许愿,希望也能在大荧幕上看到小野、星星人。

新的一年,LABUBU的新故事也开始了。

回望IP历史的长河,曾经的“LABUBU们”活得如何了?

上网冲浪强度大的网友,应该或多或少都见过一个头发卷曲、四肢瘦小,总是穿一件黄色卫衣的小男孩,他是诞生于1950年的《花生漫画》中的经典角色查理·布朗。小狗史努比也源自这部漫画。

《花生漫画》用日常生活承载普世情感,由此走过了半个多世纪仍具有活力。2025年,索尼以4.6亿美元控股Peanuts‌——即便在实景娱乐项目Wonderverse闭店的挑战下,Peanuts仍能通过授权体系贡献稳定现金流,反哺IP生态 。

类似的案例还有已经数十岁的宝可梦和三丽鸥——前者依托游戏、卡牌、动画持续迭代更新;后者靠年度角色票选与高频限定周边运营,二者均以全年龄情感共鸣和成熟授权生态,实现了跨代长青。

这某种程度上给了如今已经走过了10年光景,还要走更远的LABUBU以某种映射。

 “下一个LABUBU”还未出现吗?

LABUBU以外,泡泡玛特也在讲新IP的故事。

在2025年的财报中,泡泡玛特已经有6个IP收入超20亿元,去年同期这个数字是2个。

2025年年中,星星人的可爱偶装形象,一度成为泡泡玛特乐园乃至朝阳公园的必备打卡点,人们排上两个小时的队,只为和它互动。

2025年12月,星星人与喜茶推出联名产品,联名产品上线秒空。今年1月,IP情人节限定“怦然星动”系列线上首发即售罄。

有消费者如此表示,“原本以为自己不会买泡泡玛特,直到看到了星星人。”

事实上,这一IP是泡泡玛特2024年8月才推出的新IP。如今,其已经成长为收入超10亿元,增速超16倍的爆款IP。

还有PUCKY的焕活,2026年1月中旬,泡泡玛特推出的“PUCKY敲敲系列”毛绒挂件,被网友戏称为“电子木鱼”、“打工治愈神器”,同样在各大社交平台上引起关注。

一个小小的功能增添,让潮玩多了一个互动场景,且切中了当代年轻群体面对工作生活压力时,对低成本、即时性情绪释放与精神慰藉的需求,由此让PUCKY这一IP成功破圈。

在此之前,PUCKY一直不温不火,但泡泡玛特运营IP的逻辑并非只看数据反馈,而是像韩国培养练习生那样陪跑那些有潜力和设计的IP。

SKULLPANDA、DIMOO这些“老”IP,在2025年的财报中同样维持着三位数的增长,这背后是泡泡玛特长线运营一款IP的能力。

2026年是MOLLY诞生二十周年,MOLLY二十周年主题展览也在全球多地开启巡展。其中,在上海复星艺术中心举办的巡展,是迄今为止全球最大的MOLLY线下IP主题展 ,邀请了20余位艺术家及非遗匠人进行跨界创作,大幅提升了其艺术与文化价值。

在这一过程中,王宁始终在强调,要持续运营一个IP,不要过度消耗。业绩会上,他主动表示,公司2026年会有约20%的增速,更重要的是追求健康的增长,而不是增收不增利的扩张。

IP以外,泡泡玛特在乐园、零售上的步伐也在加快。

同样在3月25日的业绩会上,司德表示,当前泡泡玛特城市乐园1.5期施工正在顺利进行,预计在今年夏天与大家见面。2025年,乐园在关闭将近一大半区域的情况下,营收及客流表现均超预期,并成功打造星星人明星朋友。

在首饰品牌popop之后,泡泡玛特还推出了甜品品牌POP BAKERY。看似不沾边的业务背后,其实已有消费者基础——在此前品牌的多次展会之中,都出现过旗下IP形象的甜品售卖。背靠背、需要掰开吃的星星人雪糕,一度是潮玩爱好者们的必购选项。

此外,泡泡玛特管理层还在业绩会上表示,今年中国市场开店与改造数量均将提升,核心逻辑是通过“门店即乐园”的模式提升用户停留时间与体验感。过去一年,部分门店面积增加30%-40%后,店效实现了翻倍增长。

正如2025年年报的业绩会上,泡泡玛特CEO王宁表示的那样,“之前很多优秀的消费品牌做到了Fashion For All,满足了很多人的需求,希望有一天我们能做到Fun For All或者Happy For All。”

2026年了,你敢信一些知名的开源库都还不会正确使用防抖节流吗

摘要:防抖(debounce)和节流(throttle)是前端开发中高频使用的性能优化技巧,也是八股文的经典。但许多开发者甚至知名开源库的维护者都在误用它们。本文通过分析 vben 和 vue-office 两个热门项目的真实 PR,揭示常见的使用误区,并给出最佳实践。


Leader 大群 @ 我:"CSV 预览在 Mac 触控板上滑得太快了"

2026.3.16,那是一个普通的工作日,我正在专注地敲代码,突然 Leader 在公司大群里 @ 我:

image.png

"CSV 文件预览在 Mac 下触控板左右移动的速度好快,谁能调整一下?"

我心里一紧,我们的项目,用的是 vue-office 组件库预览office,印象中office组件很少会提供滚动。我打开代码debug了一下——果然是 throttle 的用法有问题。组件库里每次滚动事件都创建一个新的 throttle 函数,然后立即执行,根本没有节流效果。Mac 触控板的高频滚动事件直接穿透了,导致表格左右飞快移动。

项目中patch后,直接提了 PR。

这件事也让我想起了一年前给 vue-vben-admin 提的另一个类似 PR——那次是远程搜索的 debounce 用错了,也是每次输入都创建新实例

  • vue-vben-admin:一个拥有 32K+ Star 的现代化 Vue3 管理后台
  • vue-office:一个拥有 6K+ Star 的 Office 文件预览组件库

vben的项目维护者直接回复 "nice catch!",让我不禁思考:防抖和节流看似简单,但连这些知名库的资深开发者都会踩坑,说明这背后一定有什么容易忽视的细节。

本文就来复盘这两个案例,聊聊这些常见的使用误区。


vue-vben-admin 的远程搜索

问题案例

<template>
  <Input @search="useDebounceFn(onSearch, 300)" />
</template>

问题分析

核心错误@search 每次被调用时,都创建了一个新的 debounce 函数实例,然后立即执行它。

这意味着:

  1. 用户输入 "h",调用 debounceOptionsFn,创建 debounce A,立即执行 A,发起请求
  2. 用户继续输入 "he",再次调用 debounceOptionsFn,创建 debounce B,立即执行 B,再次发起请求
  3. 用户输入 "hel",再次调用,创建 debounce C,立即执行 C,又发起请求...

每个 debounce 实例都是全新的,内部的定时器逻辑完全没有机会发挥作用——防抖函数永远不会被触发(指延迟后的触发),而是每次都被立即执行。


vue-office 的 Excel 滚动优化

问题案例

vue-office 在处理 Excel 表格滚动时,想要使用 throttle 来优化性能,但代码存在类似的问题:

// ❌ 错误:在事件监听中直接使用 throttle
if (/Firefox/i.test(window.navigator.userAgent)) throttle(moveY(evt.detail), 50);
if (temp === tempX) throttle(moveX(deltaX), 50);
if (temp === tempY) throttle(moveY(deltaY), 50);

问题分析

这个错误的模式与上面的 debounce 案例如出一辙:

  1. 每次滚动事件触发,都创建一个新的 throttle 函数
  2. 新创建的 throttle 函数立即执行,没有任何节流效果

更严重的是,如果这是一个高频滚动场景,不断创建新的 throttle 函数还会带来内存泄漏的风险。


框架中的正确使用方式

Vue 组合式 API

<script setup>
import { debounce } from 'lodash-es'
import { onUnmounted } from 'vue'

// 在组件级别创建,保持引用稳定
const debouncedSearch = debounce(async (query) => {
  const results = await api.search(query)
  items.value = results
}, 300)

// 绑定到事件
function onInput(value) {
  debouncedSearch(value)
}

// 组件卸载时清理
onUnmounted(() => {
  debouncedSearch.cancel()
})
</script>

React Hooks

import { useMemo, useEffect } from 'react'
import { debounce } from 'lodash-es'

function SearchComponent() {
  // ✅ 使用 useMemo 保持 debounce 函数引用稳定
  const debouncedSearch = useMemo(
    () => debounce((query) => {
      api.search(query)
    }, 300),
    [] // 空依赖,只在组件挂载时创建
  )

  // 组件卸载时清理
  useEffect(() => {
    return () => {
      debouncedSearch.cancel()
    }
  }, [debouncedSearch])

  return (
    <input onChange={(e) => debouncedSearch(e.target.value)} />
  )
}

原生 JavaScript

import { throttle } from 'lodash-es'

class ScrollHandler {
  constructor() {
    // 在构造函数中创建,确保引用稳定
    this.throttledScroll = this.handleScroll.bind(this)
    this.throttledScroll = throttle(this.throttledScroll, 100)
    
    window.addEventListener('scroll', this.throttledScroll)
  }

  handleScroll() {
    // 处理滚动逻辑
  }

  destroy() {
    window.removeEventListener('scroll', this.throttledScroll)
    this.throttledScroll.cancel()
  }
}

结语

防抖和节流看起来简单,但实际使用中却暗藏陷阱。我在审查 vue-vben-admin 和 vue-office 代码时的经历告诉我:即使是经验丰富的开发者和知名开源项目,也可能在这些"基础"概念上栽跟头。

从那以后,我在代码审查时会多问一句写代码时多想一想函数引用,也养成了检查 debounce/throttle 使用模式的习惯。希望本文能帮助你写出更健壮、性能更优的代码。

如果你在项目中发现了类似的防抖节流误用,或者有其他最佳实践想分享,欢迎交流讨论!


参考资源


这篇文章记录了我发现并修复两个知名开源项目防抖节流问题的经历。如果你也遇到过类似的坑,欢迎在评论区聊聊你的故事。

A股三大指数集体收跌,贵金属板块走弱

36氪获悉,A股三大指数集体收跌,沪指跌0.74%,深成指跌1.6%,创业板指跌2.31%;电脑硬件、贵金属、半导体板块领跌,晓程科技跌超7%,国新科技跌超6%,湖南黄金、协创数据跌超5%;能源、林木、食品板块走强,和顺石油涨停,平潭发展涨超4%,牧原股份涨超3%。

kotlin安卓项目配置app横屏等方式

大家好,我的开源项目PakePlus可以将网页/Vue/React项目打包为桌面/手机应用并且小于5M只需几分钟,官网地址:pakeplus.com

在 Kotlin Android 项目中配置 App 固定横屏,在 AndroidManifest.xml 中为 Activity 添加 screenOrientation 属性:

<activity
    android:name=".MainActivity"
    android:screenOrientation="landscape"
    android:configChanges="orientation|screenSize">
</activity>

在 Android 中,screenOrientation 属性支持以下值:

常用方向

说明
unspecified 默认值,由系统选择方向
landscape 固定横屏(宽 > 高)
portrait 固定竖屏(高 > 宽)
reverseLandscape 反向横屏(屏幕倒转180度)
reversePortrait 反向竖屏(上下颠倒)
sensorLandscape 横屏,但允许根据传感器旋转180度
sensorPortrait 竖屏,但允许根据传感器旋转180度

LRU 缓存实现详解:双向链表 + 哈希表

LRU 缓存实现详解:双向链表 + 哈希表

摘要:本文深入剖析使用“双向链表 + 哈希表”实现 LRU(Least Recently Used)缓存的标准方法。从核心思想到代码实现,再到边界处理与复杂度分析,完整展示一个 O(1) 时间复杂度的 LRU 缓存如何工作。


1. 概述

LRU(最近最少使用)是一种常见的缓存淘汰策略:当缓存容量达到上限时,优先淘汰最长时间未被访问的数据。每次访问(读或写)都会将对应数据标记为“最近使用”。

为了支持 getput 操作均为 O(1) 时间复杂度,必须同时满足:

  • 快速查找:给定 key,能在 O(1) 时间内找到对应的数据。
  • 快速维护顺序:能够 O(1) 地将任意数据移动到“最近使用”的位置,并且 O(1) 删除“最久未使用”的数据。

数据结构组合哈希表 + 双向链表 完美达成上述要求。

为什么不能用数组或单向链表?

  • 数组移动元素 O(N)
  • 单向链表删除尾部需要遍历到前驱 O(N)
  • 双向链表 + 哈希表完美 O(1)

2. 核心数据结构

2.1 双向链表节点

每个节点存储键、值以及前驱和后继指针。其中存储 key 是为了在淘汰节点时能从哈希表中删除对应的键。

class ListNode {
  constructor(key, value) {
    this.key = key; // 存储 key 是为了淘汰时能从哈希表删除
    this.value = value;
    this.prev = null;   // 前驱指针
    this.next = null;   // 后继指针
  }
}

2.2 LRU 缓存类成员

class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;   // 最大容量
    this.size = 0;              // 当前存储的节点数
    this.map = new Map();       // 哈希表:key -> 节点引用
    
    // 虚拟头尾节点(哨兵),简化边界操作
    this.head = new ListNode(0, 0);
    this.tail = new ListNode(0, 0);
    this.head.next = this.tail;
    this.tail.prev = this.head;
  }
}

为什么使用虚拟头尾?

  • 避免对空指针的判断(如 if (node.prev) ...
  • 插入头部时,head.next 一定存在;删除尾部时,tail.prev 一定存在
  • 统一代码逻辑,减少边界错误

2.3 链表状态示意图

初始状态:

head <-> tail

插入一个有效节点后:

head <-> node1 <-> tail

多个节点按最近使用顺序排列:头部是最近使用的,尾部是最久未使用的。


3. 辅助方法(链表操作)

所有辅助方法的时间复杂度均为 O(1)

3.1 _addToHead(node):在头部插入节点

_addToHead(node) {
  node.prev = this.head;
  node.next = this.head.next;
  this.head.next.prev = node;
  this.head.next = node;
}

执行步骤(假设当前 head <-> A <-> ...):

  1. node.prev = this.head
  2. node.next = this.head.next (即 A)
  3. this.head.next.prev = node (A 的前驱指向 node)
  4. this.head.next = node (head 的后继指向 node)

结果:head <-> node <-> A <-> ...

3.2 _removeNode(node):删除任意节点

_removeNode(node) {
  node.prev.next = node.next;
  node.next.prev = node.prev;
}

原理:让 node 的前驱直接指向 node 的后继,node 的后继直接指向 node 的前驱,从而将 node 从链表中移除。node 自身的指针无需修改(因为节点即将被丢弃或移动)。

3.3 _moveToHead(node):将已有节点移到头部

_moveToHead(node) {
  this._removeNode(node);
  this._addToHead(node);
}

先删除,再插入头部,使该节点成为“最近使用”节点。

3.4 _removeTail():删除尾部真实节点(最久未使用)

_removeTail() {
  const tailNode = this.tail.prev;   // 虚拟 tail 的前一个才是真正的尾节点
  this._removeNode(tailNode);
  return tailNode;
}

返回被删除的节点,以便从哈希表中删除其键。


4. 主要操作实现

4.1 get(key)

get(key) {
  if (!this.map.has(key)) return -1;
  const node = this.map.get(key);
  this._moveToHead(node);   // 标记为最近使用
  return node.value;
}

流程

  • 哈希表查找 → O(1)
  • 不存在则返回 -1
  • 存在则移动节点到链表头部 → O(1)
  • 返回节点值

4.2 put(key, value)

put(key, value) {
  if (this.map.has(key)) {
    // 情况1:key 已存在 → 更新值并移到头部
    const node = this.map.get(key);
    node.value = value;
    this._moveToHead(node);
  } else {
    // 情况2:key 不存在 → 创建新节点
    const newNode = new ListNode(key, value);
    this.map.set(key, newNode);
    this._addToHead(newNode);
    this.size++;

    if (this.size > this.capacity) {
      // 淘汰最久未使用的节点
      const removed = this._removeTail();
      this.map.delete(removed.key);
      this.size--;
    }
  }
}

情况2详细步骤

  1. 创建新节点,存入哈希表
  2. 插入链表头部(成为最近使用)
  3. 缓存大小 +1
  4. 若超过容量:删除尾部真实节点,并从哈希表中删除其键,大小 -1

注意:先插入新节点,再淘汰旧节点,确保淘汰的一定是最久未使用的。


5. 完整代码

class ListNode {
  constructor(key, value) {
    this.key = key;
    this.value = value;
    this.prev = null;
    this.next = null;
  }
}

class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.size = 0;
    this.map = new Map();
    this.head = new ListNode(0, 0);
    this.tail = new ListNode(0, 0);
    this.head.next = this.tail;
    this.tail.prev = this.head;
  }

  _addToHead(node) {
    node.prev = this.head;
    node.next = this.head.next;
    this.head.next.prev = node;
    this.head.next = node;
  }

  _removeNode(node) {
    node.prev.next = node.next;
    node.next.prev = node.prev;
  }

  _moveToHead(node) {
    this._removeNode(node);
    this._addToHead(node);
  }

  _removeTail() {
    const tailNode = this.tail.prev;
    this._removeNode(tailNode);
    return tailNode;
  }

  get(key) {
    if (!this.map.has(key)) return -1;
    const node = this.map.get(key);
    this._moveToHead(node);
    return node.value;
  }

  put(key, value) {
    if (this.map.has(key)) {
      const node = this.map.get(key);
      node.value = value;
      this._moveToHead(node);
    } else {
      const newNode = new ListNode(key, value);
      this.map.set(key, newNode);
      this._addToHead(newNode);
      this.size++;
      if (this.size > this.capacity) {
        const removed = this._removeTail();
        this.map.delete(removed.key);
        this.size--;
      }
    }
  }
}

6. 执行示例

假设 capacity = 2

操作 链表状态(头部最近) 哈希表内容 说明
put(1, 1) head <-> 1 <-> tail {1→node1} 插入节点1
put(2, 2) head <-> 2 <-> 1 <-> tail {1→node1, 2→node2} 插入节点2,成为最近使用
get(1) head <-> 1 <-> 2 <-> tail {1→node1, 2→node2} 访问1,1移到头部
put(3, 3) head <-> 3 <-> 1 <-> tail {1→node1, 3→node3} 容量满,淘汰尾部的2
get(2) 不变 不变 返回 -1

7. 边界情况处理

情况 处理方式
capacity ≤ 0 通常题目保证 capacity ≥ 1;若需处理,可在构造时抛出错误或使所有 put 无效
get 不存在的 key 返回 -1
put 更新已存在的 key 更新 value,移到头部,不改变 size
put 时容量已满 先插入新节点,再淘汰尾部节点(确保淘汰的是最久未使用的)
链表中只有一个有效节点 虚拟头尾仍然存在,所有辅助方法正常工作
重复 put 同一 key 且值不变 依然执行 _moveToHead,更新使用顺序

8. 复杂度分析

  • 时间复杂度

    • get:O(1)(哈希查找 + 链表移动)
    • put:O(1)(哈希查找/插入 + 链表操作)
    • 所有辅助方法均为 O(1)
  • 空间复杂度

    • O(capacity),哈希表存储最多 capacity 个节点,链表同样存储 capacity 个节点。

9. 与“纯 Map”实现的对比

维度 双向链表 + 哈希表 纯 Map 版本(依赖插入顺序)
实现原理 手动维护链表顺序,完全可控 利用 Map 的键顺序特性
代码复杂度 较高(约60行) 极低(约20行)
时间复杂度 严格 O(1) 也是 O(1),但淘汰时需获取迭代器
空间开销 每个节点额外存储两个指针 无额外指针
可移植性 任何支持哈希表和指针的语言均可实现 依赖特定语言特性(如 JavaScript 的 Map 顺序)

结论:虽然纯 Map 版本更简洁,但双向链表 + 哈希表是标准、通用的实现,更能体现 LRU 的核心思想。


10. 常见问题

Q1:为什么必须用双向链表?单向链表不行吗?
A:单向链表删除一个节点时,如果只有该节点的引用,无法 O(1) 获得它的前驱。双向链表可以通过 node.prev 直接修改前驱的 next 指针,实现 O(1) 删除。

Q2:节点中为什么要存储 key?
A:淘汰尾部节点时,需要通过 removed.key 从哈希表中删除对应的键。如果节点不存 key,则无法知道删除哈希表中的哪个条目。

Q3:虚拟头尾节点占用额外空间,会影响容量计算吗?
A:不影响。size 只统计实际存储的数据节点,虚拟节点不计入容量。

Q4:如果 capacity = 0 怎么办?
A:可以规定构造时抛出异常,或者在 put 方法中直接返回(不存储任何数据)。实际工程中通常不会允许容量为0的缓存。

Q5:能否用其他数据结构代替双向链表?
A:可以使用有序字典(如 Python 的 OrderedDict 或 Java 的 LinkedHashMap),但这些本质上也是哈希表+双向链表的封装。手写双向链表更能理解底层机制。


11. 总结

  • LRU 缓存的核心是 哈希表 提供 O(1) 查找,双向链表 提供 O(1) 顺序维护。
  • 虚拟头尾节点极大简化了链表边界操作。
  • 所有操作(get / put)时间复杂度 O(1),空间复杂度 O(capacity)。
  • 该实现不依赖特定语言特性,具有很好的可移植性和教学意义。

最新版vue3+TypeScript开发入门到实战教程之插槽slot详解

插槽概述

Slot,可翻译中文为插槽、空槽、钥匙槽。以下为官方定义Solt(插槽)是 Vue 提供的一种内容分发机制,允许父组件向子组件指定位置注入内容。简单理解为大门样式已经设计好,钥匙空槽预留,使用大门的人可以按装指纹锁、物理锁等锁。 Slot插槽分三种类型

  • 默认插槽
  • 具名插槽
  • 作用于插槽

默认插槽

概述

默认插槽是具名插槽的一个特例,实际类型应分成两类:

  • 具名插槽
  • 作用于插槽

默认插槽实例

  • 创建Fish,Fish组件提供标题、尾部,中间插槽内容由使用者提供
  • 创建App组件,引用Fish组件

App组件代码

<template>
  <div class="app">
    <Fish>
      <div>游泳的鲫鱼</div>
    </Fish>
     <Fish>
      <template>
        <div>会飞的鱼</div>
      </template>
    </Fish>
    <Fish>
      <template v-slot:default>
        <div>跃龙门的鲤鱼</div>
      </template>
    </Fish>
  </div>
</template>
<script setup lang="ts">
import Fish from './view/Fish.vue';
</script>

Fish组件代码

<template>
  <div class="fish">
    <h2>头部</h2>
    <slot></slot>
    <h2>底部</h2>
  </div>

</template>

运行效果: 在这里插入图片描述 注意中间位置,会飞的鱼没有显示出来,默认插槽不需要使用template标签,若使用,必须给标签设置默认名称。

具名插槽

概述

在Fish组件中,可能会有很多个插槽, 如顶部、中部都可以设置一个插槽。使用名称来区分插槽:

  • 给slot设置名称
  • template标签设置slot名称

具名插槽实例

Fish组件代码

<template>
  <div class="fish">
    <h2>{{title}}</h2>
    <slot name="content">默认数据</slot>
    <slot name="footer">
      <h2>底部</h2>
    </slot>
  </div>
</template>
<script setup lang="ts">
defineProps(['title']);
</script>

App组件代码

<template>
  <div class="app">
     <Fish title="飞鱼">
      <template v-slot:content>
        <div>会飞的鱼</div>
      </template>
    </Fish>
      <Fish title="飞鱼">
      <template v-slot:footer>
        <h2>鲤鱼跃龙门</h2>
      </template>
      <template v-slot:content>
        <div>会飞的鱼</div>
      </template>
    </Fish>
     <Fish title="草鱼">
      <template #content>
        <div>吃草的鱼</div>
      </template>
      <template v-slot:footer>
        <h2>很爱水草</h2>
      </template>
      </Fish>
  </div>
</template>
<script setup lang="ts">
import Fish from './view/Fish.vue';
</script>

运行效果 在这里插入图片描述

  • 引用第一个Fish组件时,当不使用名称为footer的插槽时,它显示默认值
  • 引用第二个组件说明,template显示位置取决于所选Slot
  • 引用第三个组件说明,v-slot:可用#缩写,如v-slot:content缩写成#content

作用域插槽

概述

插槽实际分两类,一是具名插槽,一是作用域插槽,两者区别:

  • 具名插槽数据与显示都在使用者
  • 作用域插槽的数据是在被引用的组件当中,使用者只负责显示数据
  • 通过slot标签可以将数据传递给template

作用域插槽实例

Fish组件代码

<template>
  <div class="fish">
    <h2>{{title}}</h2>
    <slot name="content" :data="fishs">默认数据</slot>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';

defineProps(['title']);
let fishs = reactive([
  { name: '鲫鱼', price: 10 },
  { name: '草鱼', price: 33 },
  { name: '娃娃鱼', price: 88 },
])
</script>

App组件代码

<template>
  <div class="app">
     <Fish title="飞鱼">
      <template v-slot:content="params">
        <ul>
          <li v-for="item in params.data">
            鱼:{{ item.name }}--价格:{{ item.price }}
          </li>
        </ul>
      </template>
    </Fish>
    <Fish title="草鱼">
      <template #content="{data}">
          <h4 v-for="item in data">
            鱼:{{ item.name }}--价格:{{ item.price }}
          </h4>
      </template>
    </Fish>
  </div>
</template>
<script setup lang="ts">
import Fish from './view/Fish.vue';
</script>

运行代码查看效果 在这里插入图片描述 核心代码:

  • 传递数据:slot传递数据<slot name="content" :data="fishs">默认数据</slot>
  • 传递数据:template 接收数据<template v-slot:content="params">。 注意param结构赋值data

总结

类型语法特点使用场景
默认插槽<slot />一个组件只有一个主要内容区域
具名插槽<slot name="header" />

❌