阅读视图

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

toRef 和 toRefs 详解及应用

1. 引言

为什么需要 toReftoRefs

在 Vue 3 中,响应式系统是核心特性之一。refreactive 是创建响应式数据的两种主要方式。然而,在某些场景下,我们需要更灵活地处理响应式数据,例如:

  • 局部响应式:将对象的某个属性转换为响应式引用。
  • 解构响应式对象:在解构响应式对象时保持其响应性。

toReftoRefs 正是为了解决这些问题而设计的工具函数。

toReftoRefs 的应用场景

  • 表单处理:将表单字段转换为响应式引用。
  • 状态管理:在组件之间共享状态时保持响应性。
  • 组件通信:在父组件和子组件之间传递响应式数据。

2. Vue 3 响应式系统简介

响应式系统的核心概念

Vue 3 的响应式系统基于 Proxy 实现,具有以下核心概念:

  • 响应式对象:通过 reactive 创建的对象,其属性是响应式的。
  • 响应式引用:通过 ref 创建的值,其本身是响应式的。

refreactive 的基本用法

  • ref:用于创建响应式引用。
    import { ref } from 'vue';
    
    const count = ref(0);
    console.log(count.value); // 0
    
  • reactive:用于创建响应式对象。
    import { reactive } from 'vue';
    
    const state = reactive({ count: 0 });
    console.log(state.count); // 0
    

3. toRef 详解

toRef 的定义与作用

toRef 用于将对象的某个属性转换为响应式引用。它的定义如下:

function toRef<T extends object, K extends keyof T>(object: T, key: K): Ref<T[K]>;

toRef 的使用场景

  • 局部响应式:将对象的某个属性转换为响应式引用。
    import { reactive, toRef } from 'vue';
    
    const state = reactive({ count: 0 });
    const countRef = toRef(state, 'count');
    console.log(countRef.value); // 0
    
  • 表单处理:将表单字段转换为响应式引用。
    const form = reactive({ username: '', password: '' });
    const usernameRef = toRef(form, 'username');
    

toRef 的源码解析

toRef 的源码实现如下:

export function toRef<T extends object, K extends keyof T>(
  object: T,
  key: K
): Ref<T[K]> {
  return {
    get value() {
      return object[key];
    },
    set value(newValue) {
      object[key] = newValue;
    },
  };
}

4. toRefs 详解

toRefs 的定义与作用

toRefs 用于将响应式对象的所有属性转换为响应式引用。它的定义如下:

function toRefs<T extends object>(object: T): ToRefs<T>;

toRefs 的使用场景

  • 解构响应式对象:在解构响应式对象时保持其响应性。
    import { reactive, toRefs } from 'vue';
    
    const state = reactive({ count: 0, name: 'Vue' });
    const { count, name } = toRefs(state);
    console.log(count.value); // 0
    console.log(name.value); // Vue
    
  • 组件通信:在父组件和子组件之间传递响应式数据。
    const state = reactive({ count: 0 });
    const { count } = toRefs(state);
    

toRefs 的源码解析

toRefs 的源码实现如下:

export function toRefs<T extends object>(object: T): ToRefs<T> {
  const ret: any = {};
  for (const key in object) {
    ret[key] = toRef(object, key);
  }
  return ret;
}

5. toReftoRefs 的区别

功能对比

  • toRef:将对象的某个属性转换为响应式引用。
  • toRefs:将对象的所有属性转换为响应式引用。

使用场景对比

  • toRef:适用于局部响应式场景。
  • toRefs:适用于解构响应式对象场景。

6. 实战:toReftoRefs 的应用

项目初始化

使用 Vue CLI 或 Vite 创建一个新的 Vue 3 项目:

npm create vite@latest my-vue-app --template vue-ts
cd my-vue-app
npm install

使用 toRef 实现局部响应式

在组件中使用 toRef 将对象的某个属性转换为响应式引用:

import { reactive, toRef } from 'vue';

export default {
  setup() {
    const state = reactive({ count: 0 });
    const countRef = toRef(state, 'count');
    return { countRef };
  },
};

使用 toRefs 解构响应式对象

在组件中使用 toRefs 解构响应式对象:

import { reactive, toRefs } from 'vue';

export default {
  setup() {
    const state = reactive({ count: 0, name: 'Vue' });
    const { count, name } = toRefs(state);
    return { count, name };
  },
};

结合 Composition API 使用 toReftoRefs

在 Composition API 中使用 toReftoRefs

import { reactive, toRef, toRefs } from 'vue';

export default {
  setup() {
    const state = reactive({ count: 0, name: 'Vue' });
    const countRef = toRef(state, 'count');
    const { name } = toRefs(state);
    return { countRef, name };
  },
};

7. 进阶:toReftoRefs 的常见应用场景

表单处理

将表单字段转换为响应式引用:

const form = reactive({ username: '', password: '' });
const { username, password } = toRefs(form);

状态管理

在组件之间共享状态时保持响应性:

const state = reactive({ count: 0 });
const { count } = toRefs(state);

组件通信

在父组件和子组件之间传递响应式数据:

// 父组件
const state = reactive({ count: 0 });
const { count } = toRefs(state);

// 子组件
export default {
  props: {
    count: {
      type: Number,
      required: true,
    },
  },
};

8. 常见问题与解决方案

toReftoRefs 的性能问题

  • 问题toReftoRefs 可能会影响性能。
  • 解决方案:避免在大型对象上频繁使用 toReftoRefs

toReftoRefs 的兼容性问题

  • 问题toReftoRefs 在某些环境下可能无法正常使用。
  • 解决方案:确保 Vue 3 版本兼容,并测试不同环境下的兼容性。

toReftoRefs 的使用误区

  • 问题:误用 toReftoRefs 可能导致响应性丢失。
  • 解决方案:理解 toReftoRefs 的作用,避免误用。

9. 总结与展望

toReftoRefs 的最佳实践

  • 明确使用场景:根据需求选择合适的工具函数。
  • 优化性能:避免在大型对象上频繁使用 toReftoRefs
  • 确保响应性:理解 toReftoRefs 的作用,确保响应性不丢失。

未来发展方向

  • 更强大的工具函数:支持更复杂的响应式场景。
  • 更好的性能优化:提供更高效的响应式处理方式。

通过本文的学习,你应该已经掌握了 toReftoRefs 的用法和应用场景。希望这些内容能帮助你在实际项目中更好地处理响应式数据!

什么是 Vue 3 中的 `defineEmits`?

1. 引言

Vue 3 的 Composition API 简介

Vue 3 引入了 Composition API,旨在解决 Options API 在复杂组件中的局限性。Composition API 提供了一种更灵活的方式来组织和复用逻辑代码。

defineEmits 的作用与优势

defineEmits 是 Vue 3 中用于定义组件事件的方法,它允许开发者在 setup() 函数中定义和触发事件。defineEmits 的优势包括:

  • 类型安全:支持 TypeScript,提供更好的类型推断和代码提示。
  • 灵活性:允许动态定义事件,适应复杂的业务场景。
  • 代码简洁:通过 defineEmits 定义事件,减少冗余代码。

本文的目标与结构

本文旨在全面解析 Vue 3 中的 defineEmits,并通过详细的代码示例帮助读者掌握这些技巧。文章结构如下:

  1. 介绍 defineEmits 的基础知识和用法。
  2. 探讨 defineEmits 在组件通信中的应用。
  3. 提供性能优化建议和实战案例。

2. defineEmits 的基础

defineEmits 的定义与使用

defineEmits 是 Vue 3 中用于定义组件事件的方法,通常在 setup() 函数中使用。

示例代码

<template>
  <button @click="increment">Increment</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['increment']);

    const increment = () => {
      emit('increment');
    };

    return {
      increment,
    };
  },
};
</script>

defineEmits 的参数与返回值

defineEmits 接收一个事件名称数组作为参数,返回一个 emit 函数。

示例代码

<template>
  <button @click="increment">Increment</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['increment']);

    const increment = () => {
      emit('increment');
    };

    return {
      increment,
    };
  },
};
</script>

示例:简单的 defineEmits 使用

通过 defineEmits 定义一个 increment 事件,并在按钮点击时触发。

示例代码

<template>
  <button @click="increment">Increment</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['increment']);

    const increment = () => {
      emit('increment');
    };

    return {
      increment,
    };
  },
};
</script>

3. defineEmits 与组件通信

使用 defineEmits 实现父子组件通信

defineEmits 用于定义子组件的事件,父组件通过监听这些事件实现通信。

示例代码

<!-- 父组件 -->
<template>
  <div>
    <ChildComponent @increment="handleIncrement" />
    <p>Count: {{ count }}</p>
  </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue';

export default {
  components: {
    ChildComponent,
  },
  data() {
    return {
      count: 0,
    };
  },
  methods: {
    handleIncrement() {
      this.count++;
    },
  },
};
</script>

<!-- 子组件 -->
<template>
  <button @click="increment">Increment</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['increment']);

    const increment = () => {
      emit('increment');
    };

    return {
      increment,
    };
  },
};
</script>

使用 defineEmits 实现跨组件通信

通过 provideinject 实现跨组件通信,结合 defineEmits 触发事件。

示例代码

<!-- 祖先组件 -->
<template>
  <div>
    <ChildComponent />
    <p>Count: {{ count }}</p>
  </div>
</template>

<script>
import { provide, ref } from 'vue';
import ChildComponent from './ChildComponent.vue';

export default {
  components: {
    ChildComponent,
  },
  setup() {
    const count = ref(0);

    const increment = () => {
      count.value++;
    };

    provide('increment', increment);

    return {
      count,
    };
  },
};
</script>

<!-- 后代组件 -->
<template>
  <button @click="increment">Increment</button>
</template>

<script>
import { inject } from 'vue';

export default {
  setup() {
    const increment = inject('increment');

    return {
      increment,
    };
  },
};
</script>

示例:在 setup() 中使用 defineEmits

通过 defineEmits 定义事件,并在 setup() 中触发。

示例代码

<!-- 父组件 -->
<template>
  <div>
    <ChildComponent @increment="handleIncrement" />
    <p>Count: {{ count }}</p>
  </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue';

export default {
  components: {
    ChildComponent,
  },
  data() {
    return {
      count: 0,
    };
  },
  methods: {
    handleIncrement() {
      this.count++;
    },
  },
};
</script>

<!-- 子组件 -->
<template>
  <button @click="increment">Increment</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['increment']);

    const increment = () => {
      emit('increment');
    };

    return {
      increment,
    };
  },
};
</script>

4. defineEmits 与 TypeScript

defineEmits 中使用 TypeScript

TypeScript 提供了强大的类型支持,可以在 defineEmits 中使用。

示例代码

<template>
  <button @click="increment">Increment</button>
</template>

<script lang="ts">
import { defineEmits, defineComponent } from 'vue';

export default defineComponent({
  setup() {
    const emit = defineEmits<{
      (e: 'increment'): void;
    }>();

    const increment = () => {
      emit('increment');
    };

    return {
      increment,
    };
  },
});
</script>

类型推断与类型安全

TypeScript 可以自动推断 defineEmits 的类型,减少类型错误。

示例代码

<template>
  <button @click="increment">Increment</button>
</template>

<script lang="ts">
import { defineEmits, defineComponent } from 'vue';

export default defineComponent({
  setup() {
    const emit = defineEmits<{
      (e: 'increment'): void;
    }>();

    const increment = () => {
      emit('increment');
    };

    return {
      increment,
    };
  },
});
</script>

示例:类型化的 defineEmits

通过 TypeScript 增强 defineEmits 的类型安全。

示例代码

<template>
  <button @click="increment">Increment</button>
</template>

<script lang="ts">
import { defineEmits, defineComponent } from 'vue';

export default defineComponent({
  setup() {
    const emit = defineEmits<{
      (e: 'increment'): void;
    }>();

    const increment = () => {
      emit('increment');
    };

    return {
      increment,
    };
  },
});
</script>

5. defineEmits 的高级用法

使用 defineEmits 实现复杂事件处理

通过 defineEmits 定义复杂事件,并在 setup() 中处理。

示例代码

<template>
  <button @click="handleClick">Click Me</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['click', 'custom-event']);

    const handleClick = () => {
      emit('click');
      emit('custom-event', 'Hello from child');
    };

    return {
      handleClick,
    };
  },
};
</script>

使用 defineEmits 实现自定义事件

通过 defineEmits 定义自定义事件,并在父组件中监听。

示例代码

<!-- 父组件 -->
<template>
  <div>
    <ChildComponent @custom-event="handleCustomEvent" />
    <p>Message: {{ message }}</p>
  </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue';

export default {
  components: {
    ChildComponent,
  },
  data() {
    return {
      message: '',
    };
  },
  methods: {
    handleCustomEvent(payload) {
      this.message = payload;
    },
  },
};
</script>

<!-- 子组件 -->
<template>
  <button @click="handleClick">Click Me</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['custom-event']);

    const handleClick = () => {
      emit('custom-event', 'Hello from child');
    };

    return {
      handleClick,
    };
  },
};
</script>

示例:在 setup() 中实现复杂事件处理

通过 defineEmits 定义多个事件,并在 setup() 中处理。

示例代码

<template>
  <button @click="handleClick">Click Me</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['click', 'custom-event']);

    const handleClick = () => {
      emit('click');
      emit('custom-event', 'Hello from child');
    };

    return {
      handleClick,
    };
  },
};
</script>

6. defineEmits 的性能优化

避免不必要的事件触发

通过条件判断避免不必要的事件触发。

示例代码

<template>
  <button @click="handleClick">Click Me</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['click']);

    const handleClick = () => {
      if (shouldEmit) {
        emit('click');
      }
    };

    return {
      handleClick,
    };
  },
};
</script>

使用 defineEmits 优化事件处理性能

通过 defineEmits 优化事件处理逻辑,减少不必要的渲染。

示例代码

<template>
  <button @click="handleClick">Click Me</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['click']);

    const handleClick = () => {
      emit('click');
    };

    return {
      handleClick,
    };
  },
};
</script>

示例:优化 defineEmits 的性能

通过条件判断和优化事件处理逻辑,提升性能。

示例代码

<template>
  <button @click="handleClick">Click Me</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['click']);

    const handleClick = () => {
      if (shouldEmit) {
        emit('click');
      }
    };

    return {
      handleClick,
    };
  },
};
</script>

7. defineEmits 的测试与调试

使用 Vitest 测试 defineEmits

通过 Vitest 测试 defineEmits 的功能。

示例代码

import { mount } from '@vue/test-utils';
import MyComponent from '@/components/MyComponent.vue';

test('测试 defineEmits', async () => {
  const wrapper = mount(MyComponent);
  await wrapper.find('button').trigger('click');
  expect(wrapper.emitted('click')).toBeTruthy();
});

使用 Vue Devtools 调试 defineEmits

通过 Vue Devtools 调试 defineEmits 的事件触发。

示例代码

// 在组件中使用 console.log 调试
export default {
  setup() {
    const emit = defineEmits(['click']);

    const handleClick = () => {
      console.log('Click event emitted');
      emit('click');
    };

    return {
      handleClick,
    };
  },
};

示例:测试与调试 defineEmits

通过 Vitest 和 Vue Devtools 测试与调试 defineEmits

示例代码

import { mount } from '@vue/test-utils';
import MyComponent from '@/components/MyComponent.vue';

test('测试 defineEmits', async () => {
  const wrapper = mount(MyComponent);
  await wrapper.find('button').trigger('click');
  expect(wrapper.emitted('click')).toBeTruthy();
});

8. 实战案例

案例一:实现一个计数器组件

通过 defineEmits 实现一个计数器组件,支持点击按钮增加计数。

示例代码

<!-- 父组件 -->
<template>
  <div>
    <ChildComponent @increment="handleIncrement" />
    <p>Count: {{ count }}</p>
  </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue';

export default {
  components: {
    ChildComponent,
  },
  data() {
    return {
      count: 0,
    };
  },
  methods: {
    handleIncrement() {
      this.count++;
    },
  },
};
</script>

<!-- 子组件 -->
<template>
  <button @click="increment">Increment</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['increment']);

    const increment = () => {
      emit('increment');
    };

    return {
      increment,
    };
  },
};
</script>

案例二:实现一个表单验证组件

通过 defineEmits 实现一个表单验证组件,支持提交表单时触发验证事件。

示例代码

<!-- 父组件 -->
<template>
  <div>
    <ChildComponent @submit="handleSubmit" />
    <p>Validation Message: {{ message }}</p>
  </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue';

export default {
  components: {
    ChildComponent,
  },
  data() {
    return {
      message: '',
    };
  },
  methods: {
    handleSubmit(isValid) {
      this.message = isValid ? 'Valid' : 'Invalid';
    },
  },
};
</script>

<!-- 子组件 -->
<template>
  <form @submit.prevent="submit">
    <input v-model="input" placeholder="Enter something" />
    <button type="submit">Submit</button>
  </form>
</template>

<script>
import { defineEmits, ref } from 'vue';

export default {
  setup() {
    const input = ref('');
    const emit = defineEmits(['submit']);

    const submit = () => {
      const isValid = input.value.length > 0;
      emit('submit', isValid);
    };

    return {
      input,
      submit,
    };
  },
};
</script>

Vue createRenderer 自定义渲染器从入门到实战

Vue createRenderer 自定义渲染器从入门到实战

🔥 Vue 3它不仅能高效渲染浏览器 DOM,还能实现小程序、Native 等多端运行。而支撑这一切的核心,就是 createRenderer 函数。它允许我们自定义渲染逻辑,摆脱 Vue 内置 DOM 渲染的限制,打造适配任意平台的渲染器

一、自定义 DOM 渲染器

示例重点实现支持事件绑定的 patchProp 方法,还会加入虚拟节点更新案例,直观看到渲染器的更新流程。

完整可运行代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Vue 自定义渲染器入门示例</title>
  <!-- 引入 Vue 3 完整版,方便浏览器直接运行 -->
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>
<body>
  <!-- 渲染挂载容器 -->
  <div id="app"></div>

  <script>
    // 从 Vue 中解构出 createRenderer 和 h 函数
    const { createRenderer, h } = Vue;

    // 1. 创建自定义渲染器:传入平台渲染配置对象
    const renderer = createRenderer({
      // 创建元素节点:根据标签名创建 DOM 元素
      createElement(tag) {
        console.log(`[渲染步骤] 创建元素节点:<${tag}>`);
        return document.createElement(tag);
      },

      // 更新元素属性:核心改造!支持普通属性 + 事件绑定(onXXX 格式)
      patchProp(el, key, prevValue, nextValue) {
        // 判断是否是事件属性(以 on 开头,且第二个字母大写,如 onClick、onInput)
        const isEvent = key.startsWith('on') && /^on[A-Z]/.test(key);
        
        if (isEvent) {
          // 提取事件名(去掉 on 前缀,转为小写,如 onClick -> click)
          const eventName = key.slice(2).toLowerCase();
          
          // 移除旧的事件监听(如果有旧值)
          if (prevValue) {
            el.removeEventListener(eventName, prevValue);
          }
          
          // 绑定新的事件监听(如果有新值)
          if (nextValue) {
            el.addEventListener(eventName, nextValue);
            console.log(`[渲染步骤] 绑定事件:${eventName},回调函数已挂载`);
          }
        } else {
          // 普通属性:直接用 setAttribute 处理
          if (nextValue === undefined || nextValue === null) {
            el.removeAttribute(key);
            console.log(`[渲染步骤] 移除普通属性:${key}`);
          } else {
            el.setAttribute(key, nextValue);
            console.log(`[渲染步骤] 更新普通属性:${key} = ${nextValue}`);
          }
        }
      },

      // 插入元素:将子元素插入到父元素的指定位置
      insert(el, parent, anchor) {
        console.log(`[渲染步骤] 插入元素:将 <${el.tagName.toLowerCase()}> 插入到 <${parent.tagName.toLowerCase()}>`);
        parent.insertBefore(el, anchor || null);
      },

      // 移除元素:从父节点中移除当前元素
      remove(el) {
        console.log(`[渲染步骤] 移除元素:<${el.tagName.toLowerCase()}>`);
        el.parentNode.removeChild(el);
      },

      // 创建文本节点:创建 DOM 文本节点
      createText(text) {
        console.log(`[渲染步骤] 创建文本节点:${text}`);
        return document.createTextNode(text);
      },

      // 更新文本节点:修改文本节点的内容
      setText(node, text) {
        console.log(`[渲染步骤] 更新文本节点:${node.nodeValue}${text}`);
        node.nodeValue = text;
      }
    });

    // 2. 获取挂载容器
    const app = document.getElementById('app');

    // 3. 初始虚拟节点(无事件)
    const vnode1 = h('div', { title: '初始节点' }, 'Hello initial vnode');

    // 4. 1秒后更新的虚拟节点(带 onClick 事件)
    const vnode2 = h(
      'div',
      {
        onClick() {
          console.log('更新了!点击事件触发成功~');
        },
        title: '更新后节点(带点击事件)' // 同时更新普通属性
      },
      'hello world'
    );

    // 5. 先渲染初始虚拟节点
    renderer.render(vnode1, app);

    // 6. 1秒后更新虚拟节点,触发 patchProp 处理事件和属性更新
    setTimeout(() => {
      console.log('==== 开始更新虚拟节点 ====');
      renderer.render(vnode2, app);
    }, 1000);
  </script>
</body>
</html>

运行效果

  1. 打开浏览器运行该 HTML 文件,页面先显示 Hello initial vnode,鼠标悬浮弹出「初始节点」提示;
  2. 1秒后,文本自动更新为 hello world,悬浮提示变为「更新后节点(带点击事件)」;
  3. 点击文本所在的 div,控制台打印 更新了!点击事件触发成功~
  4. 全程控制台会清晰打印渲染、更新、事件绑定的日志,直观看到自定义渲染器的完整执行流程。

二、核心拆解:这段代码到底在做什么?

我们逐部分拆解代码,理解 createRenderer 的核心组成和工作逻辑,重点解析新增的虚拟节点更新案例。

1. 核心引入:createRendererh 函数

const { createRenderer, h } = Vue;

这两个函数是实现自定义渲染的关键,各自承担核心职责:

  • createRenderer:Vue 3 提供的渲染器工厂函数,接收一套「平台渲染接口」,返回一个具备完整渲染能力的自定义渲染器实例。这个实例拥有 createApprender 方法,和 Vue 默认的 DOM 渲染器功能一致,只是渲染逻辑由我们自定义。
  • h 函数:全称 createVNode,核心作用是构建虚拟 DOM 节点(VNode)。它接收标签名/组件、属性对象、子节点/文本内容,返回一个标准的 VNode 对象,作为渲染器的输入数据。

2. 核心步骤:创建自定义渲染器(createRenderer

const renderer = createRenderer({ /* 渲染配置对象 */ });

createRenderer 接收一个配置对象作为唯一参数,这个对象必须实现 6 个核心方法,它们是渲染器与「目标平台」的交互桥梁,负责将 VNode 转换为目标平台的真实节点(这里是浏览器 DOM)。

6 个核心渲染方法详解(DOM 平台)
方法名 核心作用 入参说明
createElement 创建元素节点 tag:标签名(如 'div'、'p'),返回创建好的 DOM 元素
patchProp 更新元素属性 el:真实 DOM 元素、key:属性名、prevValue:旧属性值、nextValue:新属性值
insert 插入元素 el:要插入的 DOM 元素、parent:父 DOM 元素、anchor:插入参考节点(null 则插入末尾)
remove 移除元素 el:要移除的 DOM 元素
createText 创建文本节点 text:文本内容,返回创建好的 DOM 文本节点
setText 更新文本节点 node:真实 DOM 文本节点、text:新的文本内容
关键亮点:patchProp 支持事件绑定

本次改造的核心是 patchProp 方法,它不仅能处理 title 这类普通属性,还能识别 onClick 这类事件属性,实现 DOM 事件的绑定与移除:

  • 先判断属性是否为 onXXX 格式的事件;
  • 提取原生事件名(onClickclick);
  • 遵循「先清后绑」原则,避免重复绑定导致多次触发。

3. 新增亮点:虚拟节点更新案例(核心解析)

自定义渲染器如何处理 VNode 更新,这也是 Vue 响应式更新的底层缩影:

// 2. 获取挂载容器
const app = document.getElementById('app');

// 3. 初始虚拟节点(无事件)
const vnode1 = h('div', { title: '初始节点' }, 'Hello initial vnode');

// 4. 1秒后更新的虚拟节点(带 onClick 事件)
const vnode2 = h(
  'div',
  {
    onClick() {
      console.log('更新了!点击事件触发成功~');
    },
    title: '更新后节点(带点击事件)' // 同时更新普通属性
  },
  'hello world'
);

// 5. 先渲染初始虚拟节点
renderer.render(vnode1, app);

// 6. 1秒后更新虚拟节点,触发 patchProp 处理事件和属性更新
setTimeout(() => {
  console.log('==== 开始更新虚拟节点 ====');
  renderer.render(vnode2, app);
}, 1000);
这段代码的核心逻辑:
  1. 初始渲染:调用 renderer.render(vnode1, app),渲染器将 vnode1 转换为真实 DOM,插入到挂载容器中,完成首次渲染;
  2. 延迟更新:1 秒后调用 renderer.render(vnode2, app),渲染器会自动对比 vnode1vnode2 的差异(属性、文本内容);
  3. 差异更新
    • 对于 title 属性:触发 patchProp 方法,将旧值「初始节点」更新为新值「更新后节点(带点击事件)」;
    • 对于 onClick 事件:触发 patchProp 方法,绑定新的点击事件回调;
    • 对于文本内容:触发 setText 方法,将「Hello initial vnode」更新为「hello world」;
  4. 无全量重建:整个更新过程没有删除旧 DOM 再创建新 DOM,而是只更新有差异的部分,这也是 Vue 渲染高效的核心原因。

4. 挂载应用的两种方式

案例使用 renderer.render(vnode, container) 直接渲染 VNode,除此之外,也可以通过 renderer.createApp(component).mount(container) 挂载组件,两种方式均有效:

  • 直接渲染 VNode:更灵活,适合手动控制渲染流程(如本次的延迟更新案例);
  • 通过 createApp 挂载:更贴近日常 Vue 开发,适合组件化开发场景。

三、深入理解:自定义渲染器的工作流程

整个渲染与更新过程可以总结为 4 个核心步骤,形成一个完整的闭环:

  1. 生成 VNode:通过 h 函数创建标准 VNode,提供渲染的数据源;
  2. 首次渲染:渲染器调用 6 个核心方法,将 VNode 转换为真实节点,插入到挂载容器中;
  3. VNode 对比:更新时,渲染器对比新旧 VNode,找出属性、文本等差异;
  4. 差异更新:针对差异部分,调用对应的 patchPropsetText 等方法,更新真实节点,无需全量重建。

computed、watch 与 watchEffect 的使用边界与实战指南

Vue3 中 computed、watch 与 watchEffect 的使用边界与实战指南

在 Vue3 的响应式系统中,computed、watch 和 watchEffect 是处理响应式数据依赖的核心 API。它们看似都能监听数据变化并执行相应逻辑,但各自的设计初衷、适用场景和使用边界存在显著差异。很多开发者在实际开发中容易混淆三者的用法,导致出现性能冗余、逻辑混乱甚至响应式失效的问题。本文将从“边界”视角出发,深入剖析三者的核心定位、适用场景、禁忌用法及实战技巧,帮助开发者精准把握其使用边界,写出更优雅、高效的响应式代码。

一、核心定位:明确边界的前提

要掌握三者的使用边界,首先需要明确它们的核心定位——Vue 团队在设计这三个 API 时,赋予了它们截然不同的职责:

  • computed(计算属性) :核心定位是“派生状态”,用于基于已有响应式数据生成新的响应式数据。它的本质是“数据的加工者”,专注于数据的转换与派生,而非执行副作用。
  • watch(监听器) :核心定位是“数据变化的响应器”,用于监听特定响应式数据的变化,并在变化时执行自定义逻辑(通常是副作用)。它的特点是“精准监听、主动触发”,需要明确指定监听目标。
  • watchEffect(副作用监听器) :核心定位是“隐式依赖的副作用执行器”,用于自动追踪函数内部的响应式依赖,当依赖变化时重新执行函数(副作用)。它的特点是“隐式监听、自动触发”,无需指定监听目标,依赖由函数内部使用自动收集。

简单来说:computed 管“数据派生”,watch 管“精准副作用”,watchEffect 管“隐式依赖副作用”。这一定位差异,是划分它们使用边界的根本依据。

二、computed 的使用边界:只做派生,不做副作用

computed 的设计初衷是为了简化“基于已有数据生成新数据”的场景,它具有缓存机制(只有依赖变化时才重新计算),能有效提升性能。但它的边界也非常明确:仅用于数据派生,禁止在其中执行副作用

2.1 适用场景

  • 基于多个响应式数据的组合/转换生成新数据(如拼接字符串、计算总和、过滤数组);
  • 需要对数据进行格式化处理(如日期格式化、金额千分位处理);
  • 依赖数据变化时需要自动更新的派生状态(如购物车总价、列表筛选结果)。

示例:购物车总价计算(典型的派生状态场景)

import { ref, computed } from 'vue';

const cartItems = ref([
  { id: 1, name: '手机', price: 5999, quantity: 1 },
  { id: 2, name: '耳机', price: 1299, quantity: 2 }
]);

// 正确:computed 用于派生购物车总价
const totalPrice = computed(() => {
  return cartItems.value.reduce((sum, item) => {
    return sum + item.price * item.quantity;
  }, 0);
});

2.2 禁忌边界(绝对不能做的事)

  1. 禁止执行副作用操作:如修改 DOM、发送网络请求、修改其他响应式数据、打印日志等。computed 的回调函数应是“纯函数”(输入不变则输出不变,无副作用),否则会导致逻辑混乱、响应式追踪异常。
  2. 禁止依赖非响应式数据:computed 仅能追踪响应式数据(ref/reactive)的变化,依赖非响应式数据(如普通变量、全局变量)会导致计算结果无法自动更新。
  3. 禁止过度复杂的计算逻辑:computed 适合简单的数据派生,若包含大量循环、复杂算法,会阻塞页面渲染。复杂计算应拆分到方法中,或使用防抖/节流处理。

错误示例(computed 中执行副作用):

// 错误:在 computed 中发送网络请求(副作用)
const userInfo = computed(async () => {
  const res = await fetch(`/api/user/${userId.value}`); // 副作用
  return res.json();
});

// 错误:在 computed 中修改其他响应式数据(副作用)
const count = ref(0);
const doubleCount = computed(() => {
  count.value += 1; // 修改其他响应式数据,导致死循环
  return count.value * 2;
});

2.3 实战注意点

  • 利用缓存机制:computed 的缓存特性可以避免重复计算,但若依赖的响应式数据未变化,多次访问 computed 会直接返回缓存结果,无需重新计算。
  • 避免循环依赖:两个 computed 相互依赖会导致无限循环,应重构逻辑,拆分依赖关系。
  • 只读与可写 computed:默认 computed 是只读的,若需要修改 computed 的值,可通过 set 方法定义可写 computed,但需确保逻辑清晰,避免破坏派生关系。

三、watch 的使用边界:精准监听,副作用可控

watch 是 Vue 中最常用的监听 API,它的核心优势是“精准控制”——可以明确指定监听目标、深度监听、控制执行时机。其使用边界在于:仅用于监听特定数据变化并执行可控的副作用,避免过度监听或监听不明确的目标

3.1 适用场景

  • 监听特定响应式数据变化,执行副作用(如发送网络请求、修改 DOM、更新全局状态);
  • 需要获取数据变化前后的值(oldValue 和 newValue);
  • 需要控制监听时机(如初始执行、深度监听对象/数组内部变化);
  • 需要条件性执行副作用(如仅当数据变化满足特定条件时执行)。

示例:监听用户 ID 变化,重新获取用户信息(精准监听 + 副作用)

import { ref, watch } from 'vue';

const userId = ref(1);
const userInfo = ref(null);

// 正确:watch 监听 userId 变化,发送网络请求(副作用)
watch(userId, async (newId, oldId) => {
  console.log(`用户 ID 从 ${oldId} 变为 ${newId}`);
  const res = await fetch(`/api/user/${newId}`);
  userInfo.value = res.json();
}, {
  immediate: true, // 初始执行一次(页面加载时获取默认用户信息)
  deep: false // 基本类型无需深度监听,默认 false
});

3.3 禁忌边界(绝对不能做的事)

  1. 禁止监听非响应式数据:watch 无法追踪普通变量、全局变量的变化,监听这些数据会导致回调函数永远不执行。
  2. 禁止过度深度监听:对大型对象/数组进行深度监听(deep: true)会严重影响性能,因为 Vue 会递归遍历整个数据结构。应尽量监听对象的具体属性(如 watch(() => obj.xxx))。
  3. 禁止在 watch 中修改监听目标本身:这会导致无限循环(数据变化 → watch 执行 → 修改数据 → 再次触发 watch)。
  4. 禁止监听过多目标:一个 watch 监听多个不相关的目标会导致逻辑混乱,应拆分多个 watch,每个 watch 专注于一个监听目标。

错误示例(过度深度监听 + 循环修改):

const largeObj = ref({ /* 大型对象,包含几十层嵌套 */ });

// 错误:过度深度监听,严重影响性能
watch(largeObj, (newObj) => {
  console.log('大型对象变化', newObj);
}, { deep: true });

// 错误:watch 中修改监听目标,导致无限循环
const count = ref(0);
watch(count, (newVal) => {
  count.value = newVal + 1; // 修改监听目标,触发无限循环
});

3.3 实战注意点

  • 监听对象属性:对于 reactive 对象的单个属性,应使用函数形式指定监听目标(watch(() => obj.xxx, ...)),避免直接监听 obj.xxx(无法正确追踪)。
  • 清理副作用:若 watch 中包含异步操作(如定时器、网络请求),应在回调函数中返回清理函数,避免内存泄漏(如组件卸载时取消未完成的请求)。
  • 控制初始执行:通过 immediate: true 控制是否在初始时执行回调,避免重复编写初始化逻辑。

四、watchEffect 的使用边界:隐式依赖,简化副作用

watchEffect 是 Vue3 新增的 API,它的核心优势是“简化”——无需指定监听目标,自动追踪函数内部的响应式依赖。其使用边界在于:仅用于副作用逻辑简单、依赖明确且无需获取旧值的场景,避免依赖模糊导致的逻辑不可控

4.1 适用场景

  • 副作用逻辑依赖多个响应式数据,且无需区分具体哪个数据变化;
  • 无需获取数据变化前后的旧值,只需在依赖变化时重新执行副作用;
  • 简单的副作用操作(如更新 DOM、打印日志、同步状态)。

示例:监听搜索关键词和分页变化,重新获取列表数据(多依赖简化监听)

import { ref, watchEffect } from 'vue';

const keyword = ref('');
const page = ref(1);
const list = ref([]);

// 正确:watchEffect 自动追踪 keyword 和 page 的变化
watchEffect(async () => {
  const res = await fetch(`/api/list?keyword=${keyword.value}&page=${page.value}`);
  list.value = res.json();
});

上述场景若用 watch 实现,需要监听 [keyword, page] 两个目标,而 watchEffect 只需在函数内部使用依赖,即可自动追踪,代码更简洁。

4.2 禁忌边界(绝对不能做的事)

  1. 禁止依赖模糊的逻辑:若函数内部包含大量条件判断,导致依赖关系不明确,会增加调试难度(无法直观知道哪些数据会触发副作用)。
  2. 禁止需要获取旧值的场景:watchEffect 无法获取数据变化前后的旧值,若需要对比新旧值,必须使用 watch。
  3. 禁止在函数内部创建未清理的长期副作用:如未清除的定时器、未取消的事件监听,会导致内存泄漏(需使用 onInvalidate 清理)。
  4. 禁止过度复杂的逻辑:watchEffect 适合简单的副作用,复杂逻辑应拆分,避免函数体积过大、可读性差。

错误示例(需要旧值却用 watchEffect):

const count = ref(0);

// 错误:需要对比新旧值,watchEffect 无法实现
watchEffect((onInvalidate) => {
  console.log(`count 变化了,旧值:?,新值:${count.value}`); // 无法获取旧值
});

// 正确:使用 watch 获取新旧值
watch(count, (newVal, oldVal) => {
  console.log(`count 变化了,旧值:${oldVal},新值:${newVal}`);
});

4.3 实战注意点

  • 清理副作用:通过 onInvalidate 函数清理长期副作用(如定时器、网络请求),确保组件卸载时不会残留资源。
  • 控制执行时机:默认 watchEffect 在组件渲染前执行,可通过 flush: 'post' 配置改为渲染后执行(避免修改 DOM 影响渲染)。
  • 手动停止监听:watchEffect 返回一个停止函数,若需要条件性停止监听(如某个状态满足后不再监听),可调用该函数。

五、三者核心差异对比与选择指南

为了更清晰地划分使用边界,我们整理了三者的核心差异,并给出具体的选择指南:

5.1 核心差异对比

特性 computed watch watchEffect
核心定位 派生状态(数据 → 数据) 精准监听(数据 → 副作用) 隐式监听(副作用 → 自动追踪数据)
是否需要指定目标 无需(自动追踪依赖) 需要(明确监听目标) 无需(自动追踪函数内依赖)
是否能获取旧值 不能 能(newVal, oldVal) 不能
是否执行副作用 禁止 允许(核心用途) 允许(核心用途)
缓存机制 有(依赖不变则缓存) 无(变化即执行) 无(依赖变化即执行)

5.2 选择指南(一句话总结)

  • 当需要派生新的响应式数据时,用 computed;
  • 当需要精准监听特定数据变化,且可能需要新旧值对比控制执行时机时,用 watch;
  • 当需要执行副作用,且副作用依赖的响应式数据较多,无需区分具体目标、无需新旧值对比时,用 watchEffect。

六、实战避坑:常见边界错误与修复方案

结合实际开发场景,我们整理了以下常见的边界错误及对应的修复方案,帮助开发者快速避坑:

6.1 错误 1:用 computed 发送网络请求

错误原因:违反 computed 禁止副作用的边界。

// 错误
const userInfo = computed(async () => {
  const res = await fetch(`/api/user/${userId.value}`);
  return res.json();
});

修复方案:改用 watch 或 watchEffect(根据是否需要精准监听)。

// 正确(需要精准监听 userId,用 watch)
const userInfo = ref(null);
watch(userId, async (newId) => {
  const res = await fetch(`/api/user/${newId}`);
  userInfo.value = res.json();
}, { immediate: true });

6.2 错误 2:用 watch 监听整个大对象,开启 deep: true

错误原因:过度深度监听,性能损耗大。

// 错误
const form = ref({ name: '', age: '', address: { province: '', city: '' } });
watch(form, (newForm) => {
  console.log('表单变化', newForm);
}, { deep: true });

修复方案:监听对象的具体属性,避免深度监听整个对象。

// 正确(监听具体属性)
watch(
  () => [form.value.name, form.value.address.city],
  ([newName, newCity]) => {
    console.log('关键属性变化', newName, newCity);
  }
);

6.3 错误 3:用 watchEffect 却需要获取旧值

错误原因:违反 watchEffect 无法获取旧值的边界。

// 错误
const count = ref(0);
watchEffect(() => {
  console.log(`count 从 ${?} 变为 ${count.value}`); // 无法获取旧值
});

修复方案:改用 watch。

// 正确
watch(count, (newVal, oldVal) => {
  console.log(`count 从 ${oldVal} 变为 ${newVal}`);
});

七、总结

Vue3 的 computed、watch、watchEffect 虽同属响应式依赖处理 API,但边界清晰:computed 专注“数据派生”,watch 专注“精准副作用”,watchEffect 专注“简化隐式依赖副作用”。掌握它们的使用边界,核心在于理解其设计初衷——避免“用错工具”导致的性能问题和逻辑混乱。

在实际开发中,应遵循“数据派生用 computed,精准监听用 watch,多依赖副作用用 watchEffect”的原则,同时避开各自的禁忌边界(如 computed 不做副作用、watch 不过度深度监听、watchEffect 不依赖模糊逻辑)。只有精准把握边界,才能充分发挥 Vue3 响应式系统的优势,写出更高效、可维护的代码。

读懂 Tailwind v4:为什么它是现代前端项目的必选项?

摘要:当原子化 CSS 已经成为事实标准,Tailwind CSS 还能如何进化?答案不是更多的 Utility Class,而是彻底重构底层引擎。本文将带你深入了解 Tailwind v4 (代号 Oxy),看它如何通过 Rust 和“去 PostCSS 化”,将开发体验提升到全新维度。


在过去几年里,Tailwind CSS 几乎凭一己之力改变了前端开发者编写样式的方式。它赢得了“原子化 CSS vs 语义化 CSS”的战争,成为了现代前端项目的标配。

然而,随着项目规模的扩大,Tailwind v3 的局限性也逐渐显现:在包含数千个组件的大型 Monorepo 中,开发服务器的启动和 HMR(热更新)速度开始变慢。这并非 Tailwind 的设计缺陷,而是其依赖的 JavaScript 和 PostCSS 工具链的性能天花板。

站在 2026 年初,Tailwind CSS v4 的正式发布,标志着这个天花板被彻底击碎。这是一次**“为了速度而重写,为了简单而重构”**的革命性升级。

一、速度的质变:Rust 引擎登场

Tailwind v4 最大的变化在于其内部代号为 "Oxy" 的全新引擎。

告别 JavaScript 的束缚

在 v3 版本中,Tailwind 本质上是一个复杂的 PostCSS 插件。每当你保存文件,它都需要通过 JavaScript 解析你的代码,扫描类名,然后生成 CSS。

而在 v4 中,核心引擎完全使用 Rust 重写。

结果是惊人的。在大型项目中,构建速度和 HMR 速度提升了 10 倍以上。这种感觉就像是从机械硬盘升级到了 NVMe SSD,曾经需要几秒钟的重新编译现在几乎是瞬间完成的。你甚至感觉不到构建过程的存在。

二、架构的极简:再见,PostCSS

对于许多开发者来说,v4 最令人兴奋的改动或许不是速度,而是它不再依赖 PostCSS

工具链的解耦

长久以来,配置 Tailwind 意味着你必须配置 PostCSS。你需要一个 postcss.config.js,里面塞着 tailwindcssautoprefixer。如果你的项目构建链比较复杂(比如以前的 Webpack),这层依赖往往是痛苦的来源。

Tailwind v4 变成了一个独立的 CLI 工具(当然也提供了极其优秀的 Vite 插件)。它自带了解析和前缀添加功能。

这意味着什么?

  • • 你的项目根目录少了一个配置文件。
  • • 你的 package.json 少了一堆依赖。
  • • 构建流程少了一个中间环节,更加健壮。

三、配置的范式转移:CSS-First

v4 带来了配置方式的重大变革。它试图摆脱对 tailwind.config.js 的重度依赖,转而拥抱原生的 CSS 变量。这是一个非常现代化的理念:让 CSS 的归 CSS。

对比:自定义主题颜色

在 Tailwind v3 中,你需要修改 JavaScript 配置文件:


    
    
    
  // tailwind.config.js (旧版)
module.exports = {
  theme: {
    extend: {
      colors: {
        brand: {
          DEFAULT: '#0070f3',
          dark: '#024dbc',
        },
      },
    },
  },
  // ...
}

在 Tailwind v4 中,你可以直接在你的主 CSS 文件中定义,利用新的 @theme 指令:


    
    
    
  /* globals.css (新版 v4) */
@tailwind base;
@tailwind components;
@tailwind utilities;

@theme {
  /* 直接使用 CSS 变量定义主题 */
  --color-brand#0070f3;
  --color-brand-dark#024dbc;

  /* 甚至可以定义字体和断点 */
  --font-sans"Inter", sans-serif;
  --breakpoint-3xl1920px;
}

Tailwind v4 的引擎会自动读取这些 CSS 变量,并生成对应的工具类(如 bg-brand, text-brand-dark)。

这种变化的优势在于:

    1. 更符合 Web 标准: 配置就在 CSS 里,而不是 JS 里。
    1. 动态性: 你可以在运行时通过 JS 修改这些 CSS 变量,Tailwind 的样式会自动响应(虽然工具类名是静态的,但它们引用的值是动态的)。
    1. 零配置启动: 对于大多数简单项目,你甚至完全不需要 tailwind.config.js 文件。引擎会自动扫描你的文件并开始工作。

四、极其丝滑的 Vite 集成

Tailwind v4 团队与 Vite 团队进行了深度合作,推出了全新的官方 Vite 插件 @tailwindcss/vite

这个插件绕过了许多中间环节,直接介入 Vite 的构建流程。它带来的体验是:

  • 零配置: 安装插件,在 vite.config.ts 中引入,结束。它会自动找到你的 CSS 入口并开始工作。
  • 瞬间 HMR: 无论你的项目多大,修改一个 class 名,浏览器里的样式几乎是立刻更新。

    
    
    
  // vite.config.ts
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [
    tailwindcss(), // 就这么简单
  ],
})

五、总结:成熟的标志

Tailwind CSS v4 并不是一次简单的功能叠加,而是一次成熟的标志。

它不再满足于仅仅改变我们写 CSS 的方式,它开始深入底层,优化整个前端工具链的性能和体验。通过拥抱 Rust,它解决了性能瓶颈;通过抛弃 PostCSS,它简化了架构;通过转向 CSS-First 配置,它拥抱了 Web 标准。

如果说 v3 让 Tailwind 成为了主流,那么 v4 则让它成为了**“不可替代”**的基础设施。对于任何追求极致开发体验和性能的团队来说,升级到 v4 都是一个无需犹豫的选择。

开发一个美观的 VitePress 图片预览插件

前言

笔者维护的 VitePress 博客主题已经集成了非常多的功能,为便于在主题之外复用,因此有计划将其一部分功能分离出来,形成独立的插件。

现在又有AI加持,再已经有通用插件模板前提下,使用AI就能完成95%的插件工作量!

分离的 图片预览插件,效果如下:

组件样式实现参考了 Element Plus Image Viewer

接下来先简单介绍一下用法,再快速讲解核心原理。

插件开发基于之前创建的一个通用模板,vitepress-plugin-slot-inject-template,在模板的基础上,插件95%的代码由 Gemini 3.0 生成。

如何使用

只需要 2 步:

  1. 安装插件
pnpm add vitepress-plugin-image-preview
  1. 配置插件

引入插件在 .vitepress/config.mts VitePress 配置文件中

import { defineConfig } from 'vitepress'
import { ImagePreviewPlugin } from 'vitepress-plugin-image-preview'

export default defineConfig({
  vite: {
    plugins: [
      ImagePreviewPlugin()
    ]
  }
})

实现原理

这里只阐述关键点,细节与之前的公告插件类似,这里不做赘述。

VitePress 默认主题 Layout.vue 组件预设的一些插槽,只需将实现自定义组件注入到对应插槽为止即可。

所有的 slotsVitePress 文档里也有介绍

注入自定义组件

利用插件的 transform 钩子,将我们的 <ImagePreview /> 组件插入到 Layout.vue 的特定插槽位置

图片预览组件我这里使用的是 doc-beforepage-top 两个插槽。

使用 alias 保证引入组件的路径正确映射。

// 仅包含关键代码
const componentName = 'ImagePreview'
const componentFile = `${componentName}.vue`
const aliasComponentFile = `${getDirname()}/components/${componentFile}`
function ImagePreviewPlugin(options = {}) {
  return {
    // 添加alias
    config: () => {
      return {
        resolve: {
          alias: {
            [`./${componentFile}`]: aliasComponentFile
          }
        }
      }
    },
    transform(code, id) {
      // 筛选出 Layout.vue
      if (id.endsWith('vitepress/dist/client/theme-default/Layout.vue')) {
        let transformResult = code

        // 插入组件
        const slots = [options.slots || ['doc-before', 'page-top']].flat()
        for (const slot of slots) {
          const slotPosition = `<slot name="${slot}" />`
          // 添加 ClientOnly 目的是避免组件在SSG的时候被渲染
          transformResult = transformResult.replace(slotPosition, `${slotPosition}\n<ClientOnly><${componentName} /></ClientOnly>`)
        }

        // 导入组件
        const setupPosition = '<script setup lang="ts">'
        transformResult = transformResult.replace(setupPosition, `${setupPosition}\nimport ${componentName} from './${componentFile}'`)
        return transformResult
      }
    },
  }
}

插件配置传递

采用虚拟模块的方式传递配置。

组件中导入配置:

import options from 'virtual:image-preview-options'

插件中处理虚拟模块:

const virtualModuleId = 'virtual:image-preview-options'
const resolvedVirtualModuleId = `\0${virtualModuleId}`
function ImagePreviewPlugin(options = {}) {
  return {
    // 省略其它无关代码...
    resolveId(id) {
      if (id === virtualModuleId) {
        return resolvedVirtualModuleId
      }
    },
    load(this, id) {
      if (id === resolvedVirtualModuleId) {
        return `export default ${stringify(options)}`
      }
    },
  }
}

核心交互实现

图片预览的核心逻辑在于监听图片的点击事件,获取图片列表,并显示预览遮罩。

  1. 事件监听:在 onMounted时,给内容的容器注册点击事件,在点击的时候获取容器中所有的图片元素,然后做后续操作。
onMounted(() => {
  const wrapperId = imagePreviewOptions?.wrapperId || '#VPContent'
  const docDomContainer = document.querySelector(wrapperId)
  docDomContainer?.addEventListener('click', previewImage)
})

function previewImage(e: Event) {
  const target = e.target as HTMLElement
  const currentTarget = e.currentTarget as HTMLElement
  if (target.tagName.toLowerCase() === 'img') {
    const selector = imagePreviewOptions?.selector || '.content-container .main img,.VPPage img'
    const imgs = currentTarget.querySelectorAll<HTMLImageElement>(selector)
    const idx = Array.from(imgs).findIndex(el => el === target)
    const urls = Array.from(imgs).map(el => el.src)
    // 省略其它逻辑
  }
}
  1. 预览组件:参考了 Element Plus 的 图片预览组件的样式与功能,这部分完全由 AI 实现(Gemini 3.0),还原度非常高。

插件模板介绍

在开发插件的过程中,笔者把此类基于 slot 位置注入的插件分离了一个模板 vitepress-plugin-slot-inject-template

有相关诉求的朋友,可以基于此模板,配合 AI 快速的开发各种基于插槽就可以实现的组件能力。

最后

插件完整源码 vitepress-plugin-image-preview

最后再感叹一句,AI 太牛逼了,效率起飞。

欢迎评论区交流&指导。

jsx/tsx使用cssModule和typescript-plugin-css-modules

1,前言

vite/webpack搭建的项目中,不管是vue还是react,都可以写jsx/tsx,为了避免样式污染,常用的方式有两种。一种是每个组件都用一个唯一类名class包裹,使用less/scss嵌套样式。另一种是使用cssModule模块化。本文就分享一下如何使用cssModule,并推荐一个好用的插件:typescript-plugin-css-modules,让你在vscode中,能拥有typeScript一样的智能提示。

2,效果图

效果图

类型提示

类型提示

3,如何使用

注:本文各种配置均使用vscode编辑器。

3.1,安装

  • yarn
yarn add -D typescript-plugin-css-modules
  • npm
npm install -D typescript-plugin-css-modules

3.2,配置

配置后需要重启vscode,然后项目中使用cssMoudule时,就可以享受到typeScript提示的class类名了,配置如下:

  • 配置tsconfig.json
{
  "compilerOptions": {
    "plugins": [{"name": "typescript-plugin-css-modules"}]
  }
}
  • 配置settings.json

在项目根目录新建.vscode文件夹,在文件夹中新建settings.json,并写入如下配置,用于指明使用typescript.tsdk的位置以及开启提示,如果vscode有提示,记得同意。

{
  "typescript.tsdk": "node_modules/typescript/lib",
  "typescript.enablePromptUseWorkspaceTsdk": true
}

注意:cssModule可以用于css,less,scss等,使用时,css/less/scss文件后缀必须由.css/.less/.scss变为 .module.css/.module.less/.module.scss

4,示例

  • index.tsx
import { defineComponent } from 'vue'
import styles from './index.module.scss'

export default defineComponent({
  name: 'notFound',
  setup() {
    return () => (
      <div class={styles.main_box}>11111</div >
    )
  }
})
  • index.module.scss
.main_box {
  width: 100%;
  height: 100%;
  position: relative;
  overflow: hidden;
  text-align: center;
  background-color: #ffffff;
}

5,插件错误处理

截止本文发布之时,typescript-plugin-css-modules的版本为3.4.0,此插件有一个bug,会导致cssModule类型提取失败,模块类型是一个{}的情况,如下所示:

Property '' does not exist on type {}

类型提取错误

issues地址

5.1,错误触发原因

这个bug目前有两个方式都会触发:

  • 1,当你项目中使用less/scss@include/@mixin等等指令的时候

  • 2,当你的项目使用/ deep /这样的深度选择器语法的时候

5.2,解决办法

  • 1,在需要使用@include/@mixin等等指令的时候,在cssModule文件的头上引入样式,就可以解决(之前是全局引入),如下所示:
@use "../../../static/styles/common.scss" as *;
  • 2,换一种深度选择器写法,如下所示:
.main{
  & ::deep .el-button{
    background-image: linear-gradient(-90deg, #29bdd9 0%, #276ace 100%);
    &:hover{
      opacity: 0.8;
    }
  }
}

本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

Vue3响应式API-reactive的原理

这段代码实现了一个简化版的响应式系统(类似 Vue 3 的 reactive),包含依赖收集和触发更新的核心机制。让我详细讲解每个部分:

一、核心结构

1. reactive 函数

function reactive<T extends object>(target: T) {
  return createReactiveObject(target);
}
  • 入口函数,接收一个普通对象
  • 返回该对象的响应式代理
  • 使用泛型 T extends object 确保只能处理对象类型

2. createReactiveObject 函数

创建 Proxy 代理对象的核心函数:

function createReactiveObject<T extends object>(target: T) {
  const handler = {
    // get 拦截器
    get(target: object, key: keyof object, receiver: () => void) {
      const result = Reflect.get(target, key, receiver);
      track(target, key);  // 依赖收集
      if (typeof result === "object" && result !== null) {
        return createReactiveObject(result);  // 深度响应式
      }
      return result;
    },
    
    // set 拦截器
    set(target: object, key: keyof object, value: unknown, receiver: () => void) {
      const oldValue = target[key];
      const result = Reflect.set(target, key, value, receiver);
      if (oldValue !== value) {
        trigger(target, key);  // 触发更新
      }
      return result;
    },
  };
  return new Proxy(target, handler);
}

关键点:

  • 深度响应式:当访问的属性值是对象时,递归创建响应式代理
  • 惰性转换:只有在访问嵌套对象时才进行响应式转换
  • Reflect API:使用 Reflect.get/set 确保正确的 this 绑定

二、依赖管理系统

1. 存储结构

const targetMap = new WeakMap<object, Map<string, Set<Function>>>();
let activeEffect: null | Function = null;

结构说明:

WeakMap
  key: 原始对象 (target)
  value: Map
    key: 属性名 (string)
    value: Set<Function>  // 依赖该属性的 effect 集合

为什么用 WeakMap?

  • 键是对象,不影响垃圾回收
  • 当原始对象不再使用时,对应的依赖关系会自动清除

2. track - 依赖收集

function track(target: object, key: string) {
  if (!activeEffect) return;  // 没有 activeEffect 时不收集
  
  // 获取或创建 target 对应的 depsMap
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()));
  }
  
  // 获取或创建 key 对应的 dep(effect 集合)
  let dep = depsMap.get(key);
  if (!dep) {
    depsMap.set(key, (dep = new Set()));
  }
  
  dep.add(activeEffect);  // 将当前 effect 加入依赖集合
}

3. trigger - 触发更新

function trigger(target: object, key: keyof object) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  
  const dep = depsMap.get(key);
  if (!dep) return;
  
  // 创建副本避免无限循环
  const effects = new Set(dep);
  effects.forEach((effect) => {
    if (effect !== activeEffect) {  // 避免当前 effect 触发自身
      effect();
    }
  });
}

三、Effect 系统

export function effect(fn: Function) {
  const effectFn = () => {
    const prevEffect = activeEffect;
    activeEffect = effectFn;  // 设置当前活跃的 effect

    try {
      return fn();
    } finally {
      activeEffect = prevEffect;  // 恢复之前的 effect
    }
  };
  
  effectFn();  // 立即执行一次,进行初始依赖收集
  return effectFn;
}

执行流程:

  1. 创建 effectFn 包装函数
  2. 执行时设置 activeEffect = effectFn
  3. 执行用户传入的 fn()
  4. 在 fn() 执行期间,所有对响应式属性的访问都会调用 track
  5. track 将当前 activeEffect 收集为依赖
  6. 执行完成后恢复之前的 activeEffect

四、使用示例

const state = reactive<{ todos: string[] }>({ todos: [] });

// 创建一个 effect
effect(() => {
  console.log('todos 改变了:', state.todos);
  // 首次执行时,访问 state.todos,触发 get
  // track 收集当前 effect 作为 todos 的依赖
});

// 修改状态
state.todos.push('学习响应式原理');  // 触发 set -> trigger -> 执行 effect

五、完整工作流程

  1. 初始化响应式对象

    const state = reactive({ todos: [] });
    // 创建 Proxy 代理
    
  2. 创建 effect

    effect(() => console.log(state.todos));
    // 1. 设置 activeEffect = 当前 effect
    // 2. 执行回调,访问 state.todos
    // 3. Proxy.get 触发,track 收集依赖
    
  3. 数据变更

    state.todos = ['新任务'];
    // 1. Proxy.set 触发
    // 2. trigger 查找依赖的 effects
    // 3. 执行所有依赖的 effect 函数
    

解析ElementPlus打包源码(五、copyFiles)

还有最后一个copyFiles,虽然有点水,还是记录下

image.png

copyTypesDefinitions 我们之前打包类型已经写过。这里我们只看copyFiles

export const copyFiles = () =>
  Promise.all([
    copyFile(epPackage, path.join(epOutput, 'package.json')),
    copyFile(
      path.resolve(projRoot, 'README.md'),
      path.resolve(epOutput, 'README.md')
    ),
    copyFile(
      path.resolve(projRoot, 'typings', 'global.d.ts'),
      path.resolve(epOutput, 'global.d.ts')
    ),
  ])

这里复制了三个文件

packages/element-plus/package.json

packages/element-plus/package.json这个文件复制到打包后的项目下当作打包后的package.json

package.json里面涉及了一些package.json的基础信息,例如:

{
  "name": "element-plus",        // 包名,安装时使用 npm install element-plus
  "version": "0.0.0-dev.1",     // 版本号
  "description": "A Component Library for Vue 3", // 包的描述
  "keywords": ["element-plus", "vue", ...], // 关键词,方便 npm 搜索
  "homepage": "https://element-plus.org/", // 官网地址
  "bugs": { "url": "..." },      // 问题反馈地址
  "license": "MIT",              // 开源协议
  "repository": { ... }         // 代码仓库地址
  "publishConfig": { ... }    // 发布配置:`access: public` 表示该包是公开发布的(npm 私有包需付费,此配置确保包公开可访问)
}

还有一些模块导出规则的信息,main/module/types这三个是早期 npm 包的 “基础配置”,只能定义 “根路径” 的单一入口,无法精细化控制子路径,现在的exports优先级更高

"main": "lib/index.js",
"module": "es/index.mjs",
"types": "es/index.d.ts",
"exports": {
  // 1. 根路径引入:import 'element-plus' / require('element-plus')
  ".": {
    "types": "./es/index.d.ts",  // TS 类型文件(优先匹配)
    "import": "./es/index.mjs",  // ESM 引入(import 语法)加载 es 目录的 mjs 文件(ESM 模块)
    "require": "./lib/index.js"  // CommonJS 引入(require 语法)加载 lib 目录的 js 文件(CJS 模块)
  },
  // 2. 全局类型引入:import 'element-plus/global'
  "./global": {
    "types": "./global.d.ts"     // 仅导出全局类型(无 js 代码,用于全局类型声明)
  },
  // 3. 直接引入 es 目录:import 'element-plus/es'
  "./es": {
    "types": "./es/index.d.ts",
    "import": "./es/index.mjs"   // 仅支持 ESM 引入(es 目录只存 ESM 模块)
  },
  // 4. 直接引入 lib 目录:require('element-plus/lib')
  "./lib": {
    "types": "./lib/index.d.ts",
    "require": "./lib/index.js"  // 仅支持 CommonJS 引入(lib 目录只存 CJS 模块)
  },
  // 5. 按需引入 es 目录下的 mjs 文件:import 'element-plus/es/button.mjs'
  "./es/*.mjs": {
    "types": "./es/*.d.ts",      // 匹配对应 ts 类型文件(如 button.d.ts)
    "import": "./es/*.mjs"       // 加载对应 mjs 文件(ESM 按需引入)
  },
  // 6. 按需引入 es 目录下的模块:import 'element-plus/es/button'(无后缀)
  "./es/*": {
    "types": ["./es/*.d.ts", "./es/*/index.d.ts"], // 类型匹配优先级:先找 button.d.ts,再找 button/index.d.ts
    "import": "./es/*.mjs"       // 自动补全 mjs 后缀,适配开发者省略后缀的习惯
  },
  // 7. 按需引入 lib 目录下的 js 文件:require('element-plus/lib/button.js')
  "./lib/*.js": {
    "types": "./lib/*.d.ts",
    "require": "./lib/*.js"      // CJS 按需引入(带后缀)
  },
  // 8. 按需引入 lib 目录下的模块:require('element-plus/lib/button')(无后缀)
  "./lib/*": {
    "types": ["./lib/*.d.ts", "./lib/*/index.d.ts"], // 类型匹配规则同 es 目录
    "require": "./lib/*.js"      // 自动补全 js 后缀
  },
  // 9. 兜底规则:匹配所有未定义的路径(如 import 'element-plus/package.json'"./*": "./*"
}

package.json中还有相关的peerDependencies(要求项目中安装符合版本的依赖,否则会进行提醒)、dependencies、devDependencies

还有其他的相关配置可自行查看文档

README.md

根路径下的README文档,复制到打包后的包中

global.d.ts

这个文件就是Element Plus 的全局 TypeScript 类型声明文件

主要就是全局引入组件的时候,方便通过引入这个类型文件,在整个项目中都有提示

可见文档说明 ElementPlus快速开始

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

基于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

Vue以及ElementPlus学习

Vue常用指令

指令:HTML标签上带有v-前缀的特殊属性,不同的指令具有不同的含义,可以实现不同的功能

v-for

作用:列表渲染,遍历容器的元素或者对象的属性

语法:

<tr v-for="(item,index) in items":key="item.id">{{item}}</tr>

items:要遍历的数组

item:为遍历出来的元素

index:索引/下标,从0开始;

key:

作用:为元素添加唯一标识,便于vue进行列表项的正确排序复用,提升渲染性能

推荐使用id作为key(唯一)

v-bind

作用:动态为HTML标签绑定属性值,如设置href,src,style样式等

语法:v-bind:属性名="属性值"

简化::属性名=“属性值”

v-if &v-show

作用:这两类指令,都是用来控制元素的显示与隐藏的

v-if

  • 语法v-if="表达式",表达式的值为true,显示:false,隐藏
  • 原理:基于条件判断,来控制创建或移除元素节点
  • 场景:要么显示,要么不显示, 不频繁切换的场景

v-show

  • 语法:v-show="表达式",表达式的值为true,显示:false,隐藏
  • 原理:基于CSS样式display来控制显示与隐藏
  • 场景:频繁切换显示隐藏的场景

v-on

作用:为html标签绑定事件(添加事件监听)

语法:

v-on:事件名=“方法名”

简写为 @事件名="..."

v-model

  • v-model指令可以在表单 input、textarea以及select元素上创建双向数据绑定;
  • 它会根据控件类型自动选取正确的方法来更新元素;
  • 尽管如此, v-model 本质上是语法糖,它负责监听用户的输入事件来更新数据,并在某种极端场景下进行一些特殊处理;

Ajax

作用:

数据交换:通过Ajax可以给服务器发送请求,并获取服务器响应的数据

异步交互:可以在不重新加载整个页面的情况下,与服务器交换数据并更新部分网页的技术,如:搜索联想,用户名是否可用的校验等等

同步与异步

同步:客户端发起请求服务器,服务器处理,客户端等待,处理后返回客户端,客户端解除等待

异步:客户端发出请求后可以执行其他操作,服务器处理后返回

Axios

对原生的Ajax进行了封装,简化书写,快速开发

Ajax
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
    //发送GET请求
    document.querySelector('#btnGet').addEventListener('click',()=>{
        //axios发布异步请求
        axios({
            url:'https://mock.apifox.cn/m1/3083103-0-default/emps/list',
            method:'GET'
        }).then((result)=>{//成功回调函数
            console.log(result);
        }).catch((err)=>{//失败回调函数
            console.log(err);
        })

    })
    //发送POST请求
    document.querySelector('#btnPost').addEventListener('click',()=>{
        axios({
            url:'https://mock.apifox.cn/m1/3083103-0-default/emps/upda',
            method:'POST',
            data:{id:1}//Post请求方式
        }).then((result)=>{//成功回调函数
            console.log(result);
        }).catch((err)=>{//失败回调函数
            console.log(err);
        })
    })
</script>

项目结构

根组件

<script setup>

</script>

<template>
  <ElementDemo></ElementDemo>
  
</template>

<style scoped>

</style>

index.html

    • 这是项目的入口HTML文件
    • 包含基本的HTML结构和元信息
    • 通过 <script type="module" src="/src/main.js"></script> 引入了 main.js 文件
    • 提供了一个挂载点 <div id="app"></div> 用于渲染Vue应用
  1. src/main.js
    • 这是Vue应用的入口JavaScript文件
    • 使用 createApp 创建Vue应用实例
    • 导入并挂载 App.vue 组件到 #app 元素上
    • 导入全局样式文件 ./assets/main.css
  2. src/App.vue
    • 这是Vue应用的根组件
    • 使用 <script setup> 语法定义组件逻辑
    • 包含一个响应式数据 message
    • <template> 中显示 message 的值
    • 通过 main.js 被挂载到页面上

整体流程:index.html 加载 main.jsmain.js 创建Vue应用并挂载 App.vueApp.vue 组件被渲染到 index.html#app 容器中。

只有在需要字符串插值或换行时才会使用反引号(模板字符串)。

API

组合式API

<template>
  <div>
    <p>{{ count }}</p>
    <p>{{ doubledCount }}</p>
    <button @click="increment">增加</button>
    <button @click="decrement">减少</button>
  </div>
</template>

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

// 响应式数据
const count = ref(0)
const message = ref('Hello')

// 计算属性
const doubledCount = computed(() => count.value * 2)

// 方法
const increment = () => {
  count.value++
}

const decrement = () => {
  count.value--
}

// 生命周期
onMounted(() => {
  console.log('组件已挂载')
})

// 监听器
watch(count, (newVal, oldVal) => {
  console.log(`count从${oldVal}变为${newVal}`)
})
</script>

选项式API

<template>
  <div>
    <p>{{ count }}</p>
    <p>{{ doubledCount }}</p>
    <button @click="increment">增加</button>
    <button @click="decrement">减少</button>
  </div>
</template>

<script>
export default {
  // 数据选项
  data() {
    return {
      count: 0,
      message: 'Hello'
    }
  },
  
  // 计算属性
  computed: {
    doubledCount() {
      return this.count * 2
    }
  },
  
  // 方法
  methods: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    }
  },
  
  // 生命周期
  mounted() {
    console.log('组件已挂载')
  },
  
  // 监听器
  watch: {
    count(newVal, oldVal) {
      console.log(`count从${oldVal}变为${newVal}`)
    }
  }
}
</script>

为了避免出现域名问题,需要在配置文件中指定访问的IP和端口号,且需要在请求路径前加前缀,避免访问到静态资源

server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        secure: false,
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''),
      }
    }
  }

发送请求和响应请求的逻辑以及结构

  1. 请求发送流程

  • 使用 axios 创建 request 实例,设置基础URL为 /api
  • 通过 request 实例的 HTTP 方法(get/post/put/delete)发送请求
  • 请求会自动加上 /api 前缀,例如 request.get('/depts') 实际访问 /api/depts
  1. 响应处理流程

  • request.interceptors.response.use() 设置了响应拦截器
  • 成功响应时,拦截器直接返回 response.data,即只返回实际数据部分
  • 失败响应时,拦截器将错误通过 Promise.reject(error) 向上抛出
  1. API 调用示例

queryAllApi 为例:

  • 调用 request.get('/depts') 发送 GET 请求

  • 请求地址实际为 /api/depts

  • 响应拦截器处理后,只返回数据部分给调用方

    处理数据部分时,在代码中采用async和await进行数据接收,对接受过来的数据进行处理,如果返回状态吗正确,输出相应提示信息

ElementPlus组件

参考文档

一个 Vue 3 UI 框架 | Element Plus

表格组件

<el-table :data="tableData" border style="width: 100%">
    <el-table-column prop="date" label="Date" width="180" align="center" />
    <el-table-column prop="name" label="Name" width="180" align="center" />
    <el-table-column prop="address" label="Address" align="center"/>
  </el-table>

prop为列属性,label为标签名字

弹窗表格

<div class="button-row">
    <el-button plain @click="dialogVisible = true"> Click to open the Dialog</el-button>
    <el-dialog v-model="dialogVisible" title="收获表格" width="800">
    <el-table :data="tableData">
      <el-table-column property="date" label="Date" width="150" />
      <el-table-column property="name" label="Name" width="200" />
      <el-table-column property="address" label="Address" />
    </el-table>
  </el-dialog>

分页组件

<div class="button-row">
    <el-pagination
      v-model:current-page="currentPage4"
      v-model:page-size="pageSize4"
      :page-sizes="[100, 200, 300, 400]"
      
      :background="background"
      layout="total, sizes, prev, pager, next, jumper"
      :total="400"
      @size-change="handleSizeChange"
      @current-change="handleCurrentChange"
    />
   </div>

Vue Router

Vue Router 是 Vue 官方的客户端路由解决方案。

客户端路由的作用是在单页应用 (SPA) 中将浏览器的 URL 和用户看到的内容绑定起来。当用户在应用中浏览不同页面时,URL 会随之更新,但页面不需要从服务器重新加载。

Vue Router 基于 Vue 的组件系统构建,你可以通过配置路由来告诉 Vue Router 为每个 URL 路径显示哪些组件。

  1. Route
    • route 指的是单个路由规则,即你在 routes 数组里定义的对象
    • 每个 route 包含 path(路径)、name(名称)、component(对应渲染的组件) 等属性
    • 例如 {path: '/login', name: 'login', component: LoginView} 就是一个具体的 route 配置
  2. Router View
    • <router-view> 是一个 Vue 组件,作为路由出口,用来显示当前路由匹配到的组件
    • 当 URL 改变时,<router-view> 会自动更新为对应的组件内容
    • 在嵌套路由中(如你配置中的 children),子组件也会渲染在父级的 <router-view>
  3. Router Link
    • <router-link> 是一个特殊的组件,用于创建导航链接
    • 使用它可以在不重新加载整个页面的情况下切换不同的路由
    • 典型用法是设置 to 属性指向目标路由的路径或命名路由,例如 <router-link to="/login">Login</router-link>

watch

Watch监听函数主要用于:

  • 在数据变化时执行异步操作
  • 在数据变化时执行开销较大的操作
  • 监听特定数据的变化并执行相应逻辑
  • 实现数据验证、数据联动等复杂业务逻辑

基本语法

// 选项式API
watch: {
  // 简单监听
  被监听的数据(newValue, oldValue) {
    // 响应数据变化的逻辑
  },
  
  // 深度监听
  被监听的数据: {
    handler(newValue, oldValue) {
      // 响应数据变化的逻辑
    },
    deep: true, // 深度监听对象内部值的变化
    immediate: true // 立即执行一次handler
  }
}

// 组合式API
import { watch } from 'vue'

watch(
  被监听的数据,
  (newValue, oldValue) => {
    // 响应数据变化的逻辑
  },
  {
    deep: true,
    immediate: true
  }
)

1. Vue 3 Composition API 核心概念

setup 语法糖

javascript

<script setup>
// 所有内容都在setup中,无需return
import { ref, watch, onMounted } from 'vue'
  • 原理<script setup> 是编译时语法糖,内部声明的变量、函数自动暴露给模板
  • 优势:代码更简洁,无需手动返回响应式数据

响应式系统

javascript

const empList = ref([])
const searchEmp = ref({ name: '', gender: '' })

原理分析

  • ref() 将基本类型包装为响应式对象,通过 .value 访问
  • 在模板中自动解包,无需 .value
  • Vue 3 使用 Proxy 实现响应式,比 Vue 2 的 Object.defineProperty 更强大

2. 生命周期管理

javascript

onMounted(() => {
  search()           // 初始化数据
  queryAllDepts()    // 加载部门数据
  getToken()         // 获取认证token
})

生命周期流程

  1. onMounted → 组件挂载完成后执行
  2. 异步加载初始数据
  3. 确保DOM已渲染,可以安全操作DOM

3. 数据侦听器 (Watch)

简单侦听

javascript

watch(() => searchEmp.value.date, (newValue, oldValue) => {
  // 处理日期范围变化
})

深度侦听

javascript

watch(() => employee.value.exprList, (newValue, oldValue) => {
  // deep: true 启用深度侦听
  employee.value.exprList.forEach(item => {
    item.begin = item.exprDate[0]
    item.end = item.exprDate[1]
  })
}, { deep: true })

侦听器原理

  • 第一个参数:要侦听的响应式数据
  • 第二个参数:回调函数,数据变化时执行
  • 第三个参数:配置选项(deep, immediate等)

4. 异步编程与 API 调用

async/await 模式

javascript

const search = async () => {
  const result = await queryPageApi(
    searchEmp.value.name, 
    searchEmp.value.gender,
    searchEmp.value.begin,
    searchEmp.value.end,
    currentPage.value,
    pageSize.value
  )
  if (result.code) {
    empList.value = result.data.rows
    total.value = result.data.total
  }
}

异步编程知识点

  • async 函数返回 Promise
  • await 暂停异步函数执行,等待 Promise 完成
  • 错误处理通过 try-catch 或条件判断

5. 数组操作与函数式编程

数组方法应用

javascript

// 1. map - 数据转换
selectIds.value = val.map((item) => item.id)

// 2. forEach - 遍历操作
employee.value.exprList.forEach(item => {
  item.begin = item.exprDate[0]
  item.end = item.exprDate[1]
})

// 3. splice - 删除数组元素
employee.value.exprList.splice(index, 1)

// 4. push - 添加数组元素
employee.value.exprList.push({company:'', job:'', begin:'', end:'', exprDate:[]})

6. 表单处理与验证

Element Plus 表单验证

javascript

const rules = ref({
  username: [
    { required: true, message: '用户名是必填项', trigger: 'blur' },
    { min: 2, max: 10, message: '用户名的长度应该在2-10位之间', trigger: 'blur' },
  ],
  phone: [
    { required: true, message: '请输入手机号', trigger: 'blur' },
    { pattern: /^1[3-9]\d{9}$/, message: '请输入有效的手机号', trigger: 'blur' },
  ]
})

验证规则详解

  • required: 必填字段
  • min/max: 长度限制
  • pattern: 正则表达式验证
  • trigger: 触发时机(blur、change)

表单提交验证

javascript

const save = async () => {
  empFormRef.value.validate(async (valid) => {
    if (valid) {
      // 验证通过,提交数据
      let result = employee.value.id 
        ? await updateApi(employee.value)
        : await addApi(employee.value)
      
      if (result.code) {
        ElMessage.success('保存成功')
        dialogVisible.value = false
        search()
      }
    } else {
      ElMessage.error('请填写必要的表单数据!')
    }
  })
}

7. 文件上传处理

javascript

// 上传成功回调
const handleAvatarSuccess = (response) => {
  employee.value.image = response.data
}

// 上传前验证
const beforeAvatarUpload = (rawFile) => {
  // 文件类型验证
  if (rawFile.type !== 'image/jpeg' && rawFile.type !== 'image/png') {
    ElMessage.error('只支持上传图片')
    return false
  }
  // 文件大小验证
  else if (rawFile.size / 1024 / 1024 > 10) {
    ElMessage.error('只能上传10M以内图片')
    return false
  }
  return true
}

8. 条件渲染与列表渲染

动态样式类

vue

<el-icon class="avatar-uploader-icon">
  <Plus />
</el-icon>

条件渲染

vue

<img v-if="employee.image" :src="employee.image" class="avatar">
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>

列表渲染

vue

<el-option v-for="g in genders" :key="g.value" :label="g.name" :value="g.value"></el-option>

<el-row v-for="(expr, index) in employee.exprList" :key="index">
  <!-- 动态生成工作经历表单项 -->
</el-row>

9. 事件处理

方法定义与调用

javascript

// 方法定义
const remove = (index) => {
  employee.value.exprList.splice(index, 1)
}

// 事件绑定
<el-button @click="remove(index)">- 删除</el-button>

事件修饰符

  • @click - 点击事件
  • @change - 值变化事件
  • @success - 成功事件(上传组件)

10. 组件通信与引用

模板引用

javascript

const empFormRef = ref()  // 创建引用

<el-form ref="empFormRef">  // 绑定引用

通过 ref 操作子组件

javascript

empFormRef.value.validate((valid) => {
  // 调用子组件方法
})

11. 本地存储操作

javascript

const getToken = async () => { 
  const loginToken = JSON.parse(localStorage.getItem('loginUser'))
  if (loginToken && loginToken.token) { 
    token.value = loginToken.token
  }
}

localStorage 操作

  • getItem(key) - 获取存储数据
  • setItem(key, value) - 设置存储数据
  • JSON.parse() - 解析JSON字符串
  • JSON.stringify() - 转换为JSON字符串

12. 弹窗与用户交互

确认对话框

javascript

const deleteID = async (id) => { 
  ElMessageBox.confirm(
    '您确认删除该部门吗?',
    '提示',
    {
      confirmButtonText: 'OK',
      cancelButtonText: 'Cancel',
      type: 'warning',
    }
  ).then(async () => {
    // 用户确认
    const result = await deleteApi(id)
    if (result.code) {
      ElMessage.success("删除成功")
      search()
    }
  }).catch(() => {
    // 用户取消
    ElMessage.info('您已经取消删除')
  })
}

关键 JavaScript 知识点总结

  1. ES6+ 语法:箭头函数、解构赋值、模板字符串
  2. 模块化:import/export 模块导入导出
  3. Promise 和异步编程:async/await 错误处理
  4. 数组方法:map、forEach、splice、push
  5. 对象操作:属性访问、方法调用
  6. 条件判断:if/else、三元运算符
  7. 函数作用域:闭包、this 指向
  8. 事件循环:宏任务、微任务执行顺序

这个组件展示了现代前端开发的典型模式:响应式数据绑定、组件化开发、异步数据流、表单处理等核心概念。

vxe-table 个性化列自定义列弹出层修改高度、修改最大高度不自动适应表格高度的方法

vxe-table 个性化列自定义列弹出层修改高度、修改最大高度不自动适应表格高度的方法

默认情况下,在表格设置高度或最小高度的情况下个性化列弹出层默认内部模式(自适应表格高度),表格多高就最大多高;未设置高度情况下默认外部模式(不跟随表格高度)

vxetable.cn

自适应高度时

当 custom-config.poupuOptions.mode='auto' 时,且同时设置高度时

image

不设置高度时

image

<template>
  <div>
    <vxe-radio-group v-model="gridOptions.height">
      <vxe-radio-button checked-value="200" content="高度200"></vxe-radio-button>
      <vxe-radio-button checked-value="" content="不设置高度"></vxe-radio-button>
    </vxe-radio-group>
    <vxe-grid v-bind="gridOptions"></vxe-grid>
  </div>
</template>

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

const gridOptions = reactive({
  border: true,
  height: '',
  columnConfig: {
    resizable: true
  },
  toolbarConfig: {
    custom: true
  },
  columns: [
    { type: 'seq', width: 70 },
    { field: 'name', title: 'Name' },
    { field: 'role', title: 'Role' },
    { field: 'sex', title: 'Sex' },
    { field: 'age', title: 'Age' },
    { field: 'attr1', title: 'Attr1' },
    { field: 'attr2', title: 'Attr2' },
    { field: 'attr3', title: 'Attr3' },
    { field: 'attr4', title: 'Attr4' },
    { field: 'address', title: 'Address' }
  ],
  data: [
    { id: 10001, name: 'Test1', role: 'Develop', sex: 'Man', age: 28, address: 'test abc' },
    { id: 10002, name: 'Test2', role: 'Test', sex: 'Women', age: 22, address: 'Guangzhou' },
    { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' }
  ]
})
</script>

强制渲染弹出层为外部模式

强制渲染弹出层为外部模式,可以通过 custom-config.poupuOptions.mode='outside' 来设置,不管有没有设置高度都能超出表格显示,再配置 maxHeight 自定义最大高度

image

<template>
  <div>
    <vxe-radio-group v-model="gridOptions.height">
      <vxe-radio-button checked-value="200" content="高度200"></vxe-radio-button>
      <vxe-radio-button checked-value="" content="不设置高度"></vxe-radio-button>
    </vxe-radio-group>
    <vxe-grid v-bind="gridOptions"></vxe-grid>
  </div>
</template>

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

const gridOptions = reactive({
  border: true,
  height: '',
  columnConfig: {
    resizable: true
  },
  customConfig: {
    popupOptions: {
      mode: 'outside'
    }
  },
  toolbarConfig: {
    custom: true
  },
  columns: [
    { type: 'seq', width: 70 },
    { field: 'name', title: 'Name' },
    { field: 'role', title: 'Role' },
    { field: 'sex', title: 'Sex' },
    { field: 'age', title: 'Age' },
    { field: 'attr1', title: 'Attr1' },
    { field: 'attr2', title: 'Attr2' },
    { field: 'attr3', title: 'Attr3' },
    { field: 'attr4', title: 'Attr4' },
    { field: 'address', title: 'Address' }
  ],
  data: [
    { id: 10001, name: 'Test1', role: 'Develop', sex: 'Man', age: 28, address: 'test abc' },
    { id: 10002, name: 'Test2', role: 'Test', sex: 'Women', age: 22, address: 'Guangzhou' },
    { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' }
  ]
})
</script>

gitee.com/x-extends/v…

node-sass 迁移 sass(dart-sass) 后样式报错?用 loader 先把构建救回来

Vue CLI 老项目迁移 dart-sass:用一个插件兼容 /deep/>>>calc(100%-16px)

仓库:vue-cli-plugin-sass-compat
GitHub:github.com/myltx/vue-c…

老的 Vue CLI 项目升级 Node/依赖后,经常会被迫从 node-sass(libsass) 迁移到 sass(dart-sass)。真正卡人的往往不是“装上 sass 就完事”,而是项目里存在大量历史写法,导致迁移后构建直接报错或样式编译不符合预期。

这篇笔记记录一个“过渡期方案”:通过 vue-cli-plugin-sass-compatsass-loader 后插入一个轻量 loader,对源码做字符串级兼容替换,让你先把构建跑起来,再逐步治理样式。

你可能遇到的两类典型坑

1) 深度选择器旧写法:/deep/>>>

在一些链路/组合下,旧写法可能触发解析问题(例如 dart-sass 报 Expected selector),或者在升级过程中需要统一成 Vue 推荐的写法。

目标:将这些旧写法尽量自动转换为 ::v-deep

2) calc() 运算符空格:calc(100%-16px)

sass(dart-sass)calc() 表达式更严格,常见历史写法例如:

.a { width: calc(100%-16px); }

可能需要改成:

.a { width: calc(100% - 16px); }

目标:在迁移过渡期,自动补上二元运算符(+/-)两侧空格,避免全仓库手工替换造成巨大 diff。

方案:vue-cli-plugin-sass-compat

这个插件做的事情很克制:

  • 作为 Vue CLI Service 插件,通过 chainWebpacksass-loader 后插入一个 loader
  • 只处理你项目内的 .scss/.sass 文件(默认跳过 node_modules
  • 以“迁移过渡”为目标做最小替换:
    • /deep/>>>::v-deep
    • calc(...) 中的二元 +/- 自动补空格(尽量避开一元运算等场景)

使用前置:先完成依赖迁移(必做)

本插件不负责替你替换依赖。使用前请先把项目从 node-sass 迁移到 sass(dart-sass)

npm rm node-sass
npm i -D sass

然后正常跑一遍安装:

rm -rf node_modules
npm i

安装与使用

方式 A:已发布到 npm(推荐)

npm i -D vue-cli-plugin-sass-compat

可选:迁移检查命令(doctor)

插件在 serve/build 首次执行时会做一次轻量检查:如果检测到仍存在 node-sass 或尚未安装 sass,会打印提示。

也可以手动运行:

vue-cli-service sass-compat:doctor

可选配置(vue.config.js

默认两项修复都开启(true)。需要精细控制时可以这样写:

module.exports = {
  pluginOptions: {
    sassCompat: {
      fixDeep: true,
      fixCalc: true
    }
  }
}
  • fixDeep:是否将 /deep/>>> 等旧写法转换为 ::v-deep
  • fixCalc:是否修复 calc(100%-16px)calc()+/- 运算符空格

转换示例

深度选择器

输入:

.a /deep/ .b {}
.a >>> .b {}

输出(示意):

.a ::v-deep .b {}
.a ::v-deep .b {}

calc() 空格

输入:

.a { width: calc(100%-16px); }

输出:

.a { width: calc(100% - 16px); }

工作原理(简述)

  • index.js:通过 api.chainWebpack,在 sass/scss 规则里找到 sass-loader,并在其后插入 sass-compat-loader
  • sass-compat-loader.js:拿到每个样式文件的源码做字符串替换
    • 跳过 node_modules
    • /deep/ 直接替换为 ::v-deep
    • >>> 替换为 ::v-deep,并尽量补齐必要空格
    • calc(...) 做一次括号配对扫描,只在 calc() 内尝试给二元 +/- 补空格

边界与建议

  • 这是“迁移过渡”工具:建议你在构建恢复稳定后,逐步把业务代码里真正的历史写法治理掉,最终可以移除该插件
  • calc() 修复目前只处理二元 +/-,不会尝试覆盖 */ 等更复杂场景
  • 如果你项目里对 ::v-deep 的使用有更严格的团队规范,建议在过渡期结束后统一做一次规范化替换

最后

如果你也在做 Vue CLI 老项目的 node-sass -> sass(dart-sass) 迁移,欢迎直接试用这个插件;也欢迎提 issue 描述你遇到的“历史写法”,我会优先考虑把高频场景纳入兼容范围。

告别全局污染:深入解析现代前端的模块化 CSS 演进之路

在前端开发的蛮荒时代,CSS(层叠样式表)就像一匹脱缰的野马。它的“层叠”特性既是强大的武器,也是无数 Bug 的根源。每个前端工程师可能都经历过这样的噩梦:当你为了修复一个按钮的样式而修改了 .btn 类,结果却发现隔壁页面的导航栏莫名其妙地崩了。

这就是全局命名空间污染

随着现代前端工程化的发展,React 和 Vue 等框架的兴起,组件化成为了主流。既然 HTML 和 JavaScript 都可以封装在组件里,为什么 CSS 还要流落在外,互相打架呢?今天,我们就结合实际代码,深入探讨前端界是如何通过模块化 CSS 来彻底解决“样式冲突”这一世纪难题的。

一、 从 Bug 说起:为什么我们需要模块化?

在传统的开发模式中,CSS 是没有“作用域”(Scope)概念的。所有的类名都暴露在全局环境下。

1.1 命名冲突的灾难

想象一下,在一个大型多人协作的项目中。

  • 开发 A 负责写一个通用的提交按钮,他给按钮起名叫 .button,设置了蓝底白字。
  • 开发 B 负责写一个侧边栏的开关按钮,他也随手起名叫 .button,设置了红底黑字。

当这两个组件被引入到同一个页面(App)时,CSS 的“层叠”规则(Cascading)就会生效。谁的样式在最后加载,或者谁的优先级(Specificity)更高,谁就会赢。结果就是:要么 A 的按钮变红了,要么 B 的按钮变蓝了。

1.2 传统的妥协:BEM 命名法

为了解决这个问题,以前我们发明了 BEM(Block Element Modifier)命名法,比如写成 .article__button--primary。这种方法虽然有效,但它本质上是靠开发者的自觉冗长的命名来模拟作用域。这并不是真正的技术约束,而是一种君子协定。

我们需要更硬核的手段:让工具帮我们生成独一无二的名字

二、 React 中的解决方案:CSS Modules

React 社区对于这个问题的标准答案之一是 CSS Modules。它的核心思想非常简单粗暴:既然人取名字会重复,那就让机器来取名字。

2.1 什么是 CSS Modules?

在你的项目中,你可能看到过后缀为 .module.css 的文件。这不仅仅是一个命名约定,更是构建工具(如 Webpack 或 Vite)识别 CSS Module 的标志。

让我们看一个实际的例子。假设我们需要两个不同的按钮组件:ButtonAnotherButton

Button.module.css:

.button {
    background-color: lightblue;
    color: black;
    padding: 10px 20px;
}

.txt {
    color: red;
}

AnotherButton.module.css:

.button {
    background-color: #008c8c;
    color: white;
    padding: 10px 20px;
}

请注意,这两个文件中都定义了 .button 类。在传统 CSS 中,这绝对会冲突。但在 CSS Modules 中,这两个 .button 是完全隔离的。

2.2 编译原理:哈希(Hash)魔法

当我们在 React 组件中引入这些文件时,并没有直接引入 CSS 字符串,而是引入了一个对象

Button.jsx:

// module.css 是 css module 的文件
// react 将 css 文件 编译成 js 对象
import styles from './Button.module.css';

console.log(styles); // 让我们看看这里打印了什么

export default function Button() {
    return (
        <>
            <h1 className={styles.txt}>你好,世界!!!</h1>
            <button className={styles.button}>My Button</button>
        </>
    )
}

如果你在浏览器控制台查看 console.log(styles),你会发现输出的是类似这样的对象:

{
  button: "Button_button__3a8f",
  txt: "Button_txt__5g9d"
}

核心机制:

  1. 编译转换:构建工具读取 CSS 文件,将类名作为 Key。
  2. 哈希生成:工具会根据文件名、类名和文件内容,生成一个唯一的 Hash 字符串(例如 3a8f),将其拼接成新的类名作为 Value。
  3. 替换引用:在 JSX 中,我们使用 {styles.button},实际上渲染到 HTML 上的是 <button class="Button_button__3a8f">

2.3 真正的样式隔离

现在我们再看看 AnotherButton.jsx

import styles from './antherButton.module.css';

export default function AnotherButton() {
    // 这里的 styles.button 对应的是完全不同的哈希值
    return <button className={styles.button}>Another Button</button>
}

App.jsx 中同时引入这两个组件:

import Button from './components/Button.jsx';
import AnotherButton from './components/AnotherButton.jsx';

export default function App() {
  return (
    <>
      {/* 这里的样式互不干扰,因为它们的最终类名完全不同 */}
      <Button />
      <AnotherButton />
    </>
  )
}

总结 CSS Modules 的优势:

  • 安全性:彻底杜绝了全局污染,每个组件的样式都是私有的。
  • 零冲突:多人协作时,你完全不需要担心你的类名和同事的重复。
  • 自动化:不需要人工维护复杂的 BEM 命名,构建工具自动处理。

三、 Vue 中的解决方案:Scoped CSS

Vue 采用了另一种更符合直觉的策略。Vue 的设计哲学是“单文件组件”(SFC),即 HTML、JS、CSS 全部写在一个 .vue 文件中。为了实现样式隔离,Vue 提供了 scoped 属性。

3.1 scoped 的工作原理

看看这个 HelloWorld.vue 组件:

<template>
  <h1 class="txt">你好,世界!!!</h1>
  <h2 class="txt2">一点点</h2>
</template>

<style scoped>
.txt {
  color: pink;
}
.txt2 {
  color: palevioletred;
}
</style>

当你给 <style> 标签加上 scoped 属性时,Vue 的编译器(通常是 vue-loader@vitejs/plugin-vue)会做两件事:

  1. HTML 标记:给模板中的每个 DOM 元素添加一个独一无二的自定义属性,通常以 data-v- 开头,例如 data-v-7ba5bd90
  2. CSS 重写:利用 CSS 的属性选择器,将样式规则重写。

编译后的 CSS 变成了这样:

.txt[data-v-7ba5bd90] {
  color: pink;
}
.txt2[data-v-7ba5bd90] {
  color: palevioletred;
}

编译后的 HTML 变成了这样:

<h1 class="txt" data-v-7ba5bd90>你好,世界!!!</h1>

3.2 样式穿透与父子组件

Vue 的 Scoped 样式有一个有趣的特性。看 App.vue 的例子:

<template>
<div>
  <h1 class="txt">Hello world in App</h1>
  <HelloWorld />
</div>
</template>

<style scoped>
.txt {
  color: #008c8c;
}
</style>

这里 App.vue 也有一个 .txt 类。但是,由于 App.vue 会生成一个不同的 data-v-hash ID,它的 CSS 选择器会变成 .txt[data-v-app-hash],而 HelloWorld 组件内部的 .txt 只有 .txt[data-v-helloworld-hash] 才能匹配。

这意味着:父组件的样式默认不会泄露给子组件,子组件的样式也不会影响父组件。

Vue Scoped 的优势:

  • 可读性好:类名在开发工具中依然保持原样(.txt),只是多了一个属性,调试起来比 CSS Modules 的乱码类名更友好。
  • 性能:只生成一次 Hash ID,利用浏览器原生的属性选择器,性能开销极低。
  • 开发体验:无需像 React 那样 import styles,直接写类名即可,符合传统 HTML/CSS 开发习惯。

四、 进阶玩法:CSS-in-JS (Styled-Components)

如果我们再激进一点呢?既然 JavaScript 统治了世界,为什么不把 CSS 也变成 JavaScript 的一部分?这就诞生了 CSS-in-JS,其中最著名的库就是 styled-components

这种方案在 React 社区非常流行,它将“组件”和“样式”彻底融合了。

4.1 万物皆组件

在提供的 APP.jsx (Styled-components 版本) 示例中,我们不再写 .css 文件,而是直接定义带样式的组件:

import styled from 'styled-components';

// 创建一个名为 Button 的样式组件
// 这是一个包含了样式的 React 组件
const Button = styled.button`
  background: ${props => props.primary ? 'blue' : 'white'};
  color: ${props => props.primary ? 'white' : 'blue'};
  border: 1px solid blue;
  padding: 8px 16px;
  border-radius: 4px;
`;

注意到了吗?这里的 CSS 是写在反引号(` `)里的,这在 ES6 中叫做标签模板字符串(Tagged Template Literals)

4.2 动态样式的威力

CSS Modules 和 Vue Scoped 虽然解决了作用域问题,但它们本质上还是静态的 CSS 文件。如果你想根据组件的状态(比如 primarydisabledactive)来改变样式,通常需要动态拼接类名。

但在 styled-components 中,CSS 变成了逻辑

background: ${props => props.primary ? 'blue' : 'white'};

这行代码意味着:如果在使用组件时传递了 primary 属性,背景就是蓝色,否则是白色。

function App() {
  return (
    <>
      <Button>默认按钮</Button>
      <Button primary>主要按钮</Button>
    </>
  )
}

当 React 渲染这两个按钮时,styled-components 会动态生成两个不同的 CSS 类名,并将对应的样式注入到页面的 <style> 标签中。

CSS-in-JS 的优势:

  • 动态性:样式可以像 JS 变量一样灵活,直接访问组件的 Props。
  • 删除无用代码:既然样式是绑定在组件上的,如果组件没被使用,样式也不会被打包。
  • 维护性:你永远不用去寻找“这个类名定义在哪里”,因为它就在组件的代码里。

五、 总结:如何选择?

在现代前端开发中,我们有多种武器来对抗样式冲突:

  1. CSS Modules (React 推荐)

    • 适用场景:大型 React 项目,团队习惯传统的 CSS/SCSS 编写方式,追求极致的性能(编译时处理)。
    • 特点:通过 Hash 类名实现隔离,输出 JS 对象。
    • 关键词.module.css, import styles, 安全, 零冲突。
  2. Vue Scoped Styles (Vue 默认)

    • 适用场景:绝大多数 Vue 项目。
    • 特点:通过 data-v- 属性选择器实现隔离,代码更简洁,可读性更高。
    • 关键词<style scoped>, 属性选择器, 简单易用。
  3. CSS-in-JS (Styled-components / Emotion)

    • 适用场景:需要高度动态主题、复杂的交互样式,或者团队偏好“All in JS”的 React 项目。
    • 特点:样式即逻辑,运行时生成 CSS。
    • 关键词styled.div, 动态 Props, 逻辑复用。

回到开头的问题:

不管是 CSS Modules 的哈希乱码,还是 Vue 的属性标记,或者是 Styled-components 的动态注入,它们的终极目标都是一样的——让样式为组件服务,而不是让组件去迁就样式。

在你的下一个项目中,请务必抛弃全局 CSS,拥抱模块化。这不仅是为了避免 Bug,更是为了写出更优雅、更健壮、更易于维护的代码。

希望这篇文章能帮你彻底理解前端样式的模块化演进! Happy Coding!

# Vue 事件系统核心:createInvoker 函数深度解析

Vue 事件系统核心:createInvoker 函数深度解析

🔥 用过 Vue 的都知道,写 @click、@input 这种事件绑定很简单,但你有没有想过:背后 Vue 是怎么处理这些事件的?尤其是当事件回调需要动态变化时,它是怎么做到不频繁绑定/解绑 DOM 事件,还能保证性能的?

答案就藏在 createInvoker 这个函数里。它是 Vue(特别是 Vue3)事件系统里的“事件调用器工厂”,核心作用就是创建一个能灵活更新逻辑的调用器。本文从代码结构开始,一步步把它扒明白。

一、先看核心代码:极简但藏玄机

先上 createInvoker 的核心实现(简化版,保留最关键的逻辑),我们逐行看它到底在做什么:

function createInvoker(value) { 
  // 1. 定义一个调用器函数,用箭头函数写的
  const invoker = (e) => { 
    invoker.value(e)  // 调用器内部,会去执行自己身上的 value 属性
  } 

  // 2. 给这个调用器函数挂个 value 属性,指向传入的事件回调
  invoker.value = value 

  // 3. 把调用器返回出去(函数末尾没写 return ,默认返回这个 invoker)
}

这段代码看着特别简单,但其实就做了三件核心事,理解了这三件事,就懂了一半:

  • 造一个“中间层”:invoker 是个箭头函数,后续 DOM 事件实际绑的就是它;
  • 存真实逻辑:把我们写的事件回调(比如 onClick 里的 handleClick),挂在 invoker 的 value 属性上;
  • 返回中间层:把这个 invoker 返回出去,用于后续的 DOM 事件绑定。

二、三个关键设计:为啥这函数这么好用?

createInvoker 之所以能成为 Vue 事件系统的核心,全靠三个特别巧妙的设计。这些设计不是凭空来的,都是为了解决实际开发中的问题。

1. 函数居然也是对象?这是基础

首先要明确一个 JavaScript 里的核心知识点:函数本质上也是对象。正因为函数是对象,我们才能给它“挂属性”——就像上面代码里,给 invoker 挂了个 value 属性。

所以在 createInvoker 里,invoker 其实有两个身份:

  • 作为“函数”:它是 DOM 事件的回调入口,点击、输入这些事件触发时,第一个被执行的就是它;
  • 作为“对象”:它身上能存东西,这里的 value 就是用来存我们真正要执行的业务回调(比如 handleClick);
  • 这个设计的妙处在于:把“事件触发的入口”和“真实的处理逻辑”分开了。后面要改逻辑的时候,不用动入口,只改存的逻辑就行。

2. 箭头函数:解决 this 乱指的坑

invoker 用箭头函数定义,而不是普通函数,核心目的就是保证 this 能正确指向组件实例。

用过普通函数当事件回调的同学都知道,this 很容易乱指——比如绑在 DOM 上的普通函数,this 会指向触发事件的 DOM 元素,而不是我们的 Vue 组件。但箭头函数没有自己的 this,它会“继承”外层作用域的 this。

在 Vue 里,这个外层作用域的 this 就是组件实例。所以用箭头函数写 invoker,就能确保事件触发时,this 刚好指向我们的组件,不用再手动用 bind 绑定,也不用在业务代码里额外处理 this 问题。

举个反例:如果 invoker 是普通函数,点击 DOM 时 this 会指向那个 DOM 元素,这时候在回调里想访问 this.data、this.methods 都会报错,完全不符合我们的开发预期。

3. 闭包 + 动态更新:不用反复操作 DOM

这是 createInvoker 最核心的优势——支持动态更新事件逻辑,还不用频繁绑解绑 DOM 事件。

我们知道,DOM 操作是前端性能的大瓶颈。如果每次事件回调变了,都要先 removeEventListener 解绑旧的,再 addEventListener 绑定新的,频繁操作下来性能会很差。

而 createInvoker 用了个巧招:因为 invoker 是闭包(内部引用了自身的 value 属性),当我们需要更新事件逻辑时,直接改 invoker.value 的指向就行,不用动 DOM 上的事件绑定。

比如原来 invoker.value 指向 handleClick1,现在要改成 handleClick2,直接写 invoker.value = handleClick2 就搞定了。后续事件触发时,invoker 会自动执行新的 handleClick2,全程不用碰 addEventListener 和 removeEventListener。

三、实际执行流程:从创建到更新全梳理

  1. 创建调用器:Vue 解析模板里的 @click="handleClick" 时,调用 createInvoker 传入 handleClick,生成 invoker,此时 invoker.value = handleClick;
  2. 绑定到 DOM:Vue 将 invoker 通过 addEventListener 绑定到对应的 DOM 元素上(DOM 绑定的是 invoker,而非直接绑定 handleClick);
  3. 事件触发执行:用户触发事件时,invoker 被执行,内部调用 invoker.value(e),最终执行我们写的 handleClick(e);
  4. 动态更新逻辑:需要修改事件回调时,直接修改 invoker.value = 新回调函数即可,无需重新绑定 DOM 事件。

四、简单实用案例:看完就能上手

不用搞复杂的源码场景,这两个简单案例,帮你快速理解 createInvoker 在实际开发中的用法:

案例 1:按钮点击逻辑动态切换

这是最基础的用法,模拟 Vue 里动态改事件回调的场景:

// 先实现 createInvoker 函数
function createInvoker(value) {
  const invoker = (e) => {
    invoker.value(e)
  }
  invoker.value = value
  return invoker
}

// 准备两个不同的点击逻辑
const clickLogic1 = (e) => {
  alert('点击逻辑1:你点了按钮')
}
const clickLogic2 = (e) => {
  alert('点击逻辑2:按钮被点击啦')
}

// 给按钮绑事件
const btn = document.querySelector('#myBtn')
// 创建调用器,初始用逻辑1
const btnInvoker = createInvoker(clickLogic1)
btn.addEventListener('click', btnInvoker)

// 2秒后自动切换成逻辑2(不用解绑事件)
setTimeout(() => {
  btnInvoker.value = clickLogic2
  console.log('已切换点击逻辑,再点按钮试试')
}, 2000)

效果:页面加载后点按钮弹“逻辑1”,2秒后点按钮弹“逻辑2”,全程只绑了一次点击事件。

案例 2:开关控制滚动监听

高频事件(比如 scroll)用这个方式优化特别香,不用反复绑解绑:

function createInvoker(value) {
  const invoker = (e) => {
    invoker.value(e)
  }
  invoker.value = value
  return invoker
}

// 滚动监听逻辑:打印滚动位置
const scrollLogic = () => {
  console.log('滚动位置:', window.scrollY)
}
// 空逻辑:暂停监听时用
const emptyLogic = () => {}

// 创建调用器,初始监听滚动
const scrollInvoker = createInvoker(scrollLogic)
window.addEventListener('scroll', scrollInvoker)

// 开关按钮:点一下暂停/恢复监听
const toggleBtn = document.querySelector('#toggleScroll')
let isListening = true
toggleBtn.onclick = () => {
  isListening = !isListening
  toggleBtn.textContent = isListening ? '暂停滚动监听' : '恢复滚动监听'
  // 只改 invoker.value 就行
  scrollInvoker.value = isListening ? scrollLogic : emptyLogic
}

效果:默认滚动页面会打印位置,点按钮就能暂停,再点恢复,不用动 scroll 事件的绑定状态。

五、最后总结一下

createInvoker 函数看着简单,但核心是三个设计巧思:利用“函数是对象”存逻辑、用箭头函数保 this、靠闭包实现动态更新。最终实现了“高效、灵活、低性能损耗”的事件处理机制,这也是 Vue 事件系统的灵魂。

记住三个关键点,就算真的懂了:

  • invoker 既是事件回调入口(函数),也是逻辑存储容器(对象);
  • 更新事件逻辑,直接改 invoker.value 就行,不用碰 DOM;
  • 箭头函数确保 this 指向组件实例,不用额外处理 this 问题。

理解了 createInvoker 之后,再去看 Vue 源码里和事件相关的部分(比如 patchEvent),就会觉得豁然开朗。




六、最后总结一下

createInvoker 函数看着简单,但核心是三个设计巧思:利用“函数是对象”存逻辑、用箭头函数保 this、靠闭包实现动态更新。最终实现了“高效、灵活、低性能损耗”的事件处理机制,这也是 Vue 事件系统的灵魂。

记住三个关键点,就算真的懂了:

- invoker 既是事件回调入口(函数),也是逻辑存储容器(对象);

- 更新事件逻辑,直接改 invoker.value 就行,不用碰 DOM;

- 箭头函数确保 this 指向组件实例,不用额外处理 this 问题。

理解了 createInvoker 之后,再去看 Vue 源码里和事件相关的部分(比如 patchEvent),就会觉得豁然开朗。
❌