阅读视图

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

基于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),就会觉得豁然开朗。

Vue 数据响应式探秘:如何让数组变化无所遁形?

一、问题的由来:为什么数组这么特殊?

让我们先来看一个常见的“坑”:

// 假设我们有一个 Vue 实例
new Vue({
  data() {
    return {
      items: ['苹果''香蕉''橙子']
    }
  },
  created() {
    // 这种修改方式,视图不会更新!
    this.items[0] = '芒果';
    this.items.length = 0;
    
    // 这种修改方式,视图才会更新
    this.items.push('葡萄');
  }
})

问题来了:为什么同样是修改数组,有的方式能触发更新,有的却不能?

二、Vue 2.x 的解决方案:拦截数组方法

1. 核心原理:方法拦截

Vue 2.x 中,通过重写数组原型上的 7 个方法来监听数组变化:

// Vue 2.x 的数组响应式核心实现(简化版)
const arrayProto = Array.prototype;
const arrayMethods = Object.create(arrayProto);

const methodsToPatch = [
  'push''pop''shift''unshift',
  'splice''sort''reverse'
];

methodsToPatch.forEach(function(method) {
  const original = arrayProto[method];
  
  def(arrayMethods, method, function mutator(...args) {
    // 1. 先执行原始方法
    const result = original.apply(this, args);
    
    // 2. 获取数组的 __ob__ 属性(Observer 实例)
    const ob = this.__ob__;
    
    // 3. 处理新增的元素(如果是 push/unshift/splice 添加了新元素)
    let inserted;
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args;
        break;
      case 'splice':
        inserted = args.slice(2);
        break;
    }
    
    // 4. 对新元素进行响应式处理
    if (inserted) ob.observeArray(inserted);
    
    // 5. 通知依赖更新
    ob.dep.notify();
    
    return result;
  });
});

// 在 Observer 类中
class Observer {
  constructor(value) {
    this.value = value;
    this.dep = new Dep();
    
    if (Array.isArray(value)) {
      // 如果是数组,修改其原型指向
      value.__proto__ = arrayMethods;
      this.observeArray(value);
    } else {
      this.walk(value);
    }
  }
  
  observeArray(items) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i]);
    }
  }
}

2. 支持的数组方法

Vue 能够检测变化的数组操作:

// 以下操作都能被 Vue 检测到
this.items.push('新元素')          // 末尾添加
this.items.pop()                  // 删除最后一个
this.items.shift()                // 删除第一个
this.items.unshift('新元素')       // 开头添加
this.items.splice(01'替换值'// 替换元素
this.items.sort()                 // 排序
this.items.reverse()              // 反转

3. 无法检测的变化

// 以下操作无法被检测到
this.items[index] = '新值';        // 直接设置索引
this.items.length = 0;            // 修改长度

// 解决方案:使用 Vue.set 或 splice
Vue.set(this.items, index, '新值');
this.items.splice(index, 1'新值');

三、实战代码示例

让我们通过一个完整的例子来理解:

<template>
  <div>
    <h3>购物清单</h3>
    <ul>
      <li v-for="(item, index) in shoppingList" :key="index">
        {{ item }}
        <button @click="removeItem(index)">删除</button>
        <button @click="updateItem(index)">更新</button>
      </li>
    </ul>
    
    <input v-model="newItem" placeholder="输入新商品">
    <button @click="addItem">添加商品</button>
    
    <button @click="badUpdate">错误更新方式</button>
    <button @click="goodUpdate">正确更新方式</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      shoppingList: ['牛奶''面包''鸡蛋'],
      newItem''
    }
  },
  methods: {
    addItem() {
      if (this.newItem) {
        // 正确方式:使用 push
        this.shoppingList.push(this.newItem);
        this.newItem = '';
      }
    },
    
    removeItem(index) {
      // 正确方式:使用 splice
      this.shoppingList.splice(index, 1);
    },
    
    updateItem(index) {
      const newName = prompt('请输入新的商品名:');
      if (newName) {
        // 正确方式:使用 Vue.set 或 splice
        this.$set(this.shoppingList, index, newName);
        // 或者:this.shoppingList.splice(index, 1, newName);
      }
    },
    
    badUpdate() {
      // 错误方式:直接通过索引修改
      this.shoppingList[0] = '直接修改的值';
      console.log('数据变了,但视图不会更新!');
    },
    
    goodUpdate() {
      // 正确方式
      this.$set(this.shoppingList0'正确修改的值');
      console.log('数据和视图都会更新!');
    }
  }
}
</script>

四、流程图解:Vue 数组响应式原理

开始
  │
  ▼
初始化数据
  │
  ▼
Observer 处理数组
  │
  ▼
修改数组原型链
  │
  ├─────────────────┬─────────────────┐
  ▼                 ▼                 ▼
设置 __ob__ 属性   重写7个方法     建立依赖收集
  │                 │                 │
  ▼                 ▼                 ▼
当数组方法被调用时
  │
  ├───────────────┐
  ▼               ▼
执行原始方法     收集新元素
  │               │
  ▼               ▼
新元素响应式处理
  │
  ▼
通知所有依赖更新
  │
  ▼
触发视图重新渲染
  │
  ▼
结束

五、Vue 3 的进步:Proxy 的魔力

Vue 3 使用 Proxy 重写了响应式系统,完美解决了数组检测问题:

// Vue 3 的响应式实现(简化版)
function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const res = Reflect.get(target, key, receiver);
      // 依赖收集
      track(target, key);
      // 如果获取的是数组或对象,继续代理
      if (typeof res === 'object' && res !== null) {
        return reactive(res);
      }
      return res;
    },
    
    set(target, key, value, receiver) {
      const oldValue = target[key];
      const result = Reflect.set(target, key, value, receiver);
      
      // 判断是新增属性还是修改属性
      const type = Array.isArray(target)
        ? Number(key) < target.length ? 'SET' : 'ADD'
        : Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD';
      
      // 触发更新
      trigger(target, key, type, value, oldValue);
      
      return result;
    }
  });
}

// 现在这些操作都能被检测到了!
const arr = reactive(['a''b''c']);
arr[0] = 'x';      // ✅ 可以被检测
arr.length = 0;    // ✅ 可以被检测
arr[3] = 'd';      // ✅ 新增索引可以被检测

六、最佳实践总结

在 Vue 2 中:

  1. 1. 使用变异方法:push、pop、shift、unshift、splice、sort、reverse
  2. 2. 修改特定索引:使用 Vue.set() 或 vm.$set()
  3. 3. 修改数组长度:使用 splice

在 Vue 3 中:

由于使用了 Proxy,几乎所有数组操作都能被自动检测,无需特殊处理。

实用工具函数:

// 创建一个数组修改工具集
const arrayHelper = {
  // 安全更新数组元素
  update(array, index, value) {
    if (Array.isArray(array)) {
      if (this.isVue2) {
        Vue.set(array, index, value);
      } else {
        array[index] = value;
      }
    }
  },
  
  // 安全删除数组元素
  remove(array, index) {
    if (Array.isArray(array)) {
      array.splice(index, 1);
    }
  },
  
  // 清空数组
  clear(array) {
    if (Array.isArray(array)) {
      array.splice(0, array.length);
    }
  }
};

七、常见问题解答

Q:为什么 Vue 2 不直接监听数组索引变化?
A:主要是性能考虑。ES5 的 Object.defineProperty 无法监听数组索引变化,需要通过重写方法实现。

Q:Vue.set 内部是怎么实现的?
A:Vue.set 在遇到数组时,本质上还是调用 splice 方法。

Q:为什么直接修改 length 不生效?
A:因为 length 属性本身是可写的,但改变 length 不会触发 setter。

结语

理解 Vue 如何检测数组变化,是掌握 Vue 响应式系统的关键一步。从 Vue 2 的方法拦截到 Vue 3 的 Proxy 代理,技术的进步让开发者体验越来越好。记住核心原则:在 Vue 2 中,始终使用变异方法修改数组;在 Vue 3 中,你可以更自由地操作数组。

希望这篇文章能帮助你彻底理解 Vue 的数组响应式原理!如果你有更多问题,欢迎在评论区留言讨论。

手写 Vue 模板编译(生成篇)

前言

写本文的背景 《鸽了六年的某大厂面试题:你会手写一个模板引擎吗?》

阅读本文前请务必先阅读解析篇 《手写 Vue 模板编译(解析篇)》

复习

在前文 《手写 Vue 模板编译(解析篇)》 中,我们已经知道,模板编译的过程分为三步:解析、优化、生成。

  1. 解析 parse:在这一步,Vue 会解析模板字符串,并生成对应的 AST
  2. 优化 optimize:这个阶段主要是通过标记静态节点来进行语法树优化。
  3. 生成 generate:利用前面生成 AST(抽象语法树)转换成渲染函数(render function)。

在前文中,我们已经学习了如何生成 AST 接下来我们需要学习如何 optimize 和 generate。

optimize:优化 AST

第一步:标记静态节点

如上文所说,这个阶段主要是通过标记静态节点来进行语法树优化,在进行优化后,Vue 会在 AST 中标记某个节点是否为静态节点。

在上一节生成 AST 中,我们定义了节点类型:

  • 元素节点 type=1
  • 表达式节点 type=2
  • 文本节点 type=3

在不存在子节点时:

  • 首先文本节点必为静态节点,因为文本内容固定不变。
  • 其次表达式则必不为静态节点,因为它的值依赖于引用的表达式。
  • 最后普通节点,如果有 v-ifv-for 指令就是动态节点,否则是静态节点

    注:实际 Vue 中还有 v-bind 等指令也会让节点变为动态,这里简化处理

/**
 * 判断一个节点是否为静态节点
 */
function isStatic(node) {
  // 如果节点是表达式节点,则不是静态节点
  if (node.type === 2) {
    return false
  }

  // 如果节点是文本节点,则是静态节点
  if (node.type === 3) {
    return true
  }

  // 如果节点没有 v-if 和 v-for 指令,则是静态节点
  return !node.if && !node.for
}

对于有子节点的情况:父节点必须满足以下两个条件才能成为静态节点:

  1. 自身是静态节点(没有 v-ifv-for 等动态指令)
  2. 所有子节点都是静态节点

接下来我们处理节点树

/**
 * 标记一个节点是否为静态节点
 */
function markStatic(node) {
  // 先用 isStatic 判断当前节点自身是否为静态
  node.static = isStatic(node)
  // 如果是元素节点,需要检查子节点
  if (node.type === 1) {
    // 遍历所有的子节点
    for (let i = 0, l = node.children.length; i < l; i++) {
      const child = node.children[i]
      // 先递归处理子节点
      markStatic(child)
      // 只要有一个子节点是动态的,父节点也必须是动态的
      if (!child.static) {
        node.static = false
      }
    }
  }
}

第二步:标记静态根节点

接下来是 Vue 优化系统的另一个关键部分 markStaticRoots

markStaticRoots 函数用于标记静态根节点,被标记为静态根节点的元素及其子树会在代码生成阶段被特殊处理:

  1. 提升为常量:将其渲染代码提升到 staticRenderFns 数组中,只生成一次
  2. 跳过 patch:更新时直接复用,不需要重新创建和对比 VNode
  3. 性能提升:减少运行时的计算开销
/**
 * 标记静态根节点
 * @param {Object} node - AST 节点
 */
function markStaticRoots(node) {
  if (node.type === 1) {
    // 只有元素节点才处理

    // 判断是否为静态根节点:必须是静态节点 + 有子节点
    if (node.static && node.children.length) {
      node.staticRoot = true
      // 找到静态根后直接返回,子节点会被整体提升,无需继续遍历
      return
    } else {
      node.staticRoot = false
    }

    // 递归处理所有子节点
    if (node.children) {
      for (let i = 0, l = node.children.length; i < l; i++) {
        markStaticRoots(node.children[i])
      }
    }

    // 处理 v-if 的其他分支(v-else-if、v-else)
    // 注意:从 i=1 开始,因为 ifConditions[0] 就是当前节点
    if (node.ifConditions) {
      for (let i = 1, l = node.ifConditions.length; i < l; i++) {
        markStaticRoots(node.ifConditions[i].block)
      }
    }
  }
}

现在我们来实现 optimize

function optimize(root) {
  if (!root) return
  // 第一步:标记静态节点
  markStatic(root)
  // 第二步:标记静态根
  markStaticRoots(root)
}

举个例子来看看效果:

// 要处理的模板字符串
const str = `<div><h1 v-if="true">hello</h1><h2>cookie</h2></div>`

// 解析为 ast
const ast = parse(str, {
  // ...
})
// 优化 ast
optimize(ast)

console.dir(ast, {
  depth: null,
})

打印结果:

<ref *2> {
  type: 1,
  tag: 'div',
  attrsList: [],
  attrsMap: {},
  children: [
    <ref *1> {
      type: 1,
      tag: 'h1',
      attrsList: [],
      attrsMap: { 'v-if': 'true' },
      children: [ { type: 3, text: 'hello', static: true } ],
      if: 'true',
      ifConditions: [ { exp: 'true', block: [Circular *1] } ],
      parent: [Circular *2],
      static: false,
      staticRoot: false
    },
    {
      type: 1,
      tag: 'h2',
      attrsList: [],
      attrsMap: {},
      children: [ { type: 3, text: 'cookie', static: true } ],
      parent: [Circular *2],
      static: true,
      staticRoot: true
    }
  ],
  static: false,
  staticRoot: false
}

可以观察到 <h2> 节点被标记了静态根节点。

生成代码前置知识

1、new Function

我们知道创建函数除了常用的函数声明、函数表达式、箭头函数以外,还有一个不常用的:构造函数。语法如下:

new Function(corpsFonction)
new Function(arg1, corpsFonction)
new Function(arg1, ...argN, corpsFonction)
// 例:
const addFn = new Function('a', 'b', 'return a + b')
const sum = addFn(1, 2) // 3
  • argN:可选,零个或多个,函数形参的名称,每个名称都必须是字符串
  • corpsFonction:一个包含构成函数定义的 JavaScript 语句的字符串。

2、with 语句

with 可以将一个对象的属性添加到作用域链的顶部,让我们在代码块内直接访问对象的属性。

with (对象) {
  // 在这里可以直接访问对象的属性
}

在 Vue 模板中我们写 {{message}},实际上访问的是 this.message。使用 with(this) 可以省略 this. 前缀,让生成的代码更简洁。

示例:

function foo() {
  let name = 'other' // 局部变量
  let obj = {
    name: 'cookie', // 对象属性
  }
  with (obj) {
    console.log(name) // 优先从 obj 中查找,输出 'cookie'
  }
}
foo() // 输出: cookie

generate 生成代码

终于到了模板编译的最后一步,生成代码!在这一步,我们将根据前面得到的 AST 生成 render 函数。

1、整体入口:generate

generate 是代码生成的入口函数,负责将 AST 转换为可执行的渲染代码:

  1. 递归生成代码:调用 genElement 遍历 AST,生成类似 _c('div', [...]) 的代码字符串
  2. 包装 with 作用域:用 with(this){} 包裹,让代码能访问 Vue 实例的属性
  3. 收集静态渲染函数:将静态根节点单独提取到 staticRenderFns 数组中
/**
 * 代码生成器入口
 * @param {Object} ast - 经过 parse 和 optimize 处理后的抽象语法树
 * @returns {Object} 返回包含 render 函数和 staticRenderFns 数组的对象
 */
function generate(ast) {
  // state 用于存储编译过程中的状态
  // staticRenderFns: 用于收集静态根节点的渲染函数,这些函数只需要生成一次,后续渲染可以直接复用,提升性能
  const state = { staticRenderFns: [] }

  // 递归生成核心渲染代码字符串
  // 例如:_c('div',[_v(_s(message))])
  const code = genElement(ast, state)

  return {
    // render: 主渲染函数的代码字符串
    // 使用 with(this) 包装后,模板中的变量(如 {{message}})能直接从 Vue 实例上获取
    // with(this) 会将 Vue 实例添加到作用域链顶部
    // 这样 message 会自动从 this.message 获取,而不需要在模板里写 this.message
    render: `with(this){return ${code}}`,
    // staticRenderFns: 静态根节点渲染函数的数组
    staticRenderFns: state.staticRenderFns,
  }
}

2、核心函数:genElement

genElement 是代码生成的调度中心,它根据节点类型来使用不同的处理函数。

/**
 * 生成元素的渲染代码
 * @param {Object} el - AST 元素节点
 * @param {Object} state - 渲染状态,包含 staticRenderFns 数组
 * @returns {string} 渲染代码字符串,如:_c('div', [...])
 */
function genElement(el, state) {
  // 1:处理静态根节点
  // el.staticRoot: 在 optimize 阶段标记的静态根节点
  // el.staticProcessed: 防止重复处理的标记
  if (el.staticRoot && !el.staticProcessed) {
    // 静态根节点会被提升为单独的函数存储在 staticRenderFns 中
    // 返回类似 _m(0) 的代码,0 是在 staticRenderFns 数组中的索引
    return genStatic(el, state)
  }
  // 2:处理 v-for 指令
  // el.for: 在 parse 阶段解析的 v-for 属性
  // el.forProcessed: 防止递归时重复处理
  else if (el.for && !el.forProcessed) {
    return genFor(el, state)
  }
  // 3:处理 v-if 指令
  // el.if: 在 parse 阶段解析的 v-if 条件表达式
  // el.ifProcessed: 防止递归时重复处理
  else if (el.if && !el.ifProcessed) {
    return genIf(el, state)
  }
  // 4:处理普通元素
  else {
    // 生成标签名字符串,如 'div'
    const tag = `'${el.tag}'`

    // 递归生成所有子节点的代码
    // 返回类似 [_v("hello"), _c('span')] 的数组代码
    const children = genChildren(el, state)

    // 最终调用 _c (createElement) 创建 VNode
    // 生成代码示例:_c('div', [_v("hello")])
    // 如果没有子节点,生成:_c('div')
    return `_c(${tag}${children ? `,${children}` : ''})`
  }
}

处理子节点:genChildren 和 genNode

genElement 中调用了 genChildren 来遍历子节点,genChildren 内部又使用 genNode 来处理每一个子节点,

/**
 * 生成子节点数组的渲染代码
 * @param {Object} el - 父元素节点
 * @param {Object} state - 渲染状态
 * @returns {string} 子节点数组代码,如:[_v("text"), _c('span')]
 */
function genChildren(el, state) {
  const children = el.children
  if (children.length) {
    // 获取子节点数组需要的规范化类型
    const normalizationType = getNormalizationType(children)
    // 遍历所有子节点,为每个子节点生成代码
    return `[${children.map((c) => genNode(c, state)).join(',')}]${
      normalizationType ? `,${normalizationType}` : ''
    }`
  }
}

/**
 * 根据节点类型调用对应的生成函数
 * @param {Object} node - AST 节点
 * @param {Object} state - 渲染状态
 * @returns {string} 节点渲染代码
 */
function genNode(node, state) {
  if (node.type === 1) {
    // type === 1: 元素节点,递归调用 genElement
    return genElement(node, state)
  } else {
    // type === 2/3: 文本节点或表达式节点,调用 genText
    return genText(node)
  }
}

为什么需要 getNormalizationType

因为 v-for 会生成数组,导致 children 出现嵌套数组的情况:

<div>
  <span>first</span>
  <span v-for="item in [1,2,3]">{{item}}</span>
  <span>last</span>
</div>

生成的子节点结构会是:

[span_first, [span_1, span_1, span_1], span_last]

Vue 的 createElement 需要知道如何处理这种嵌套,所以要告诉它规范化类型:

  • 不需要规范化(没有嵌套)- type = 0
  • 简单规范化(一层嵌套,如组件)- type = 1
  • 完全规范化(多层嵌套,如 v-for)- type = 2
/**
 * 确定子节点数组需要的规范化(normalization)类型
 * @param {Array} children - 子节点数组
 * @returns {number} 0 | 1 | 2 - 规范化类型
 */
function getNormalizationType(children) {
  let res = 0 // 默认不需要规范化

  // 遍历所有子节点,检测是否需要规范化
  for (let i = 0; i < children.length; i++) {
    const el = children[i]

    // 跳过非元素节点(type=2 表达式节点、type=3 文本节点)
    // 只有元素节点(type=1)才可能需要规范化处理
    if (el.type !== 1) continue

    // 检查是否需要完全规范化(优先级最高,返回 2)
    if (
      el.for !== undefined || // 当前节点有 v-for
      (el.ifConditions && // 或者 v-if 条件分支中有v-for
        el.ifConditions.some((c) => c.block.for !== undefined))
    ) {
      // 需要完全规范化:因为 v-for 会返回数组,需要递归扁平化
      // 例如:[_v(" "), _l(arr, ...), _v(" ")] 其中 _l 返回 [span1, span2, span3]
      // 实际结构是:[_v(" "), [span1, span2, span3], _v(" ")] 需要扁平化为一维数组
      res = 2
      break // 找到一个需要完全规范化的就可以退出,不需要继续检查
    }

    // 检查是否需要简单规范化 返回 1
    // 当前节点可能是组件时,组件可能返回多个根节点(数组)
    // 但组件的 render 函数已经返回规范化的 VNode,所以只需要一层扁平化
    // 我们的模板里不处理组件,这里也就不用考虑这种情况
    // res = 1
  }

  return res // 返回 0、1 或 2
}

3、分类处理函数

Vue 内部函数

首先在 Vue 内部,有一些简写函数,在后续生成代码的时候会用到,列表如下:

缩写 全称 作用 示例
_c createElement 创建元素 VNode _c('div', [...])
_v createTextVNode 创建文本 VNode _v("hello")
_s toString 将变量转为字符串 _v(_s(message))
_l renderList 渲染列表(v-for) _l(items, fn)
_m renderStatic 渲染静态内容 _m(0)
_e createEmptyVNode 创建空节点(v-if 失败时) _e()

静态节点 (genStatic)

静态根节点会被提升为单独的函数,提升性能:

/**
 * 生成静态根节点的渲染代码
 * @param {Object} el - 静态根节点
 * @param {Object} state - 渲染状态
 * @returns {string} 静态节点引用代码,如:_m(0)
 */
function genStatic(el, state) {
  // 标记已处理,防止重复处理
  el.staticProcessed = true

  // 递归生成静态节点的完整代码
  const code = genElement(el, state)

  // 将静态节点函数存储到 staticRenderFns 数组中
  state.staticRenderFns.push(`with(this){return ${code}}`)

  // 返回对静态函数的引用,_m(index) 表示调用 staticRenderFns[index]
  // 索引是当前数组长度减 1
  return `_m(${state.staticRenderFns.length - 1})`
}

静态渲染函数和主 render 函数(vm._render)独立,需要单独设置 with 作用域。

文本处理 (genText)

/**
 * 生成文本节点的渲染代码
 * @param {Object} text - 文本 AST 节点
 * @returns {string} _v('...') 或 _v(_s(variable))
 */
function genText(text) {
  // type 2 是带 {{}} 的表达式,type 3 是普通纯文本
  // 表达式直接使用解析好的 expression 纯文本需要用 JSON.stringify 转义
  // 比如 text 为 "hello":生成代码 `v(hello)` 此时 JS 会去找名为 hello 的变量,这会导致报错(ReferenceError)
  // JSON.stringify("hello") 会返回 "\"hello\""(即带双引号的字符串)。生成的代码会变成:_v("hello")
  const value = text.type === 2 ? text.expression : JSON.stringify(text.text)
  return `_v(${value})`
}

条件渲染 (v-if)

在 Vue 中我们可以使用 v-if/else-if/else 指令,比如下面的模板:

<div v-if="a">A</div>
<div v-else-if="b">B</div>
<div v-else>C</div>

我们生成的 AST 为:

conditions = [
  { exp: 'a', block: { AST节点A } }, // v-if
  { exp: 'b', block: { AST节点B } }, // v-else-if
  { exp: undefined, block: { AST节点C } }, // v-else (没有exp)
]

v-if/else-if/else 的本质就是多重条件判断,在 JavaScript 中最适合用嵌套三元表达式来表达:

// 目标:生成这样的代码
// (a) ? A节点 : (b) ? B节点 : C节点
a ? _c('div', 'A') : b ? _c('div', 'B') : _c('div', 'C')

接下来是代码实现,我们处理 conditions 时,每次弹出第一个条件块,如果为真,则展示当前模块,如果为假,则递归处理剩余的 conditions,如果没有条件(v-else),则直接展示该模块。

/**
 * 生成 v-if 指令的入口函数
 * @param {Object} el - 带有 v-if 的 AST 节点
 * @param {Object} state - 渲染状态
 * @returns {string} 三元表达式形式的渲染代码
 */
function genIf(el, state) {
  // 标记已处理,防止递归时重复处理
  el.ifProcessed = true
  // 使用 slice() 复制数组,避免 shift() 修改原始的 ifConditions 数组
  return genIfConditions(el.ifConditions.slice(), state)
}

/**
 * 生成 v-if/else-if/else 指令的渲染代码
 * @param {Array} conditions - 条件数组
 * @param {Object} state - 渲染状态
 * @returns {string} 三元表达式形式的代码
 */
function genIfConditions(conditions, state) {
  // 递归终止条件:所有分支都处理完了 返回空节点
  if (!conditions.length) return '_e()'

  // 取出第一个条件
  const condition = conditions.shift()
  if (condition.exp) {
    // 有条件表达式:v-if 或 v-else-if
    // 生成:(条件) ? 满足时的内容 : 递归处理剩余条件
    return `(${condition.exp})?${genElement(
      condition.block,
      state
    )}:${genIfConditions(conditions, state)}`
  } else {
    // 没有条件表达式:v-else
    // 兜底分支,直接生成节点
    return genElement(condition.block, state)
  }
}

列表渲染 (v-for)

最后我们处理 v-for 指令,考虑下面的例子:

<div v-for="(item, index) in items">{{ item.name }}</div>

在 parse 阶段,这个指令会被解析为 AST 节点的属性:

{
  for: "items",      // 要遍历的数据源
  alias: "item",     // 当前项的别名
  iterator1: "index" // 索引的别名(可选)
}

我们通过_l 来进行遍历。_l 是 Vue 的内部函数 renderList,它的作用是遍历数组/对象,为每一项调用渲染函数。我们要生成的代码格式是:

_l(数据源, function (item, index) {
  return 每一项的渲染代码
})

代码实现:

/**
 * 生成 v-for 指令的渲染代码
 * @param {Object} el - 带有 v-for 的 AST 节点
 * @param {Object} state - 渲染状态
 * @returns {string} _l() 函数调用代码
 */
function genFor(el, state) {
  // 1. 提取遍历的相关信息
  const exp = el.for // 遍历的对象
  const alias = el.alias // 别名 item

  // 2. 处理索引参数(可选)
  // 如果有索引,格式为 ",index";没有则为空字符串
  const iterator1 = el.iterator1 ? `,${el.iterator1}` : ''
  const iterator2 = el.iterator2 ? `,${el.iterator2}` : ''

  // 3. 标记已处理,防止递归时重复处理
  el.forProcessed = true

  // 4. 生成 _l() 函数调用
  return `_l((${exp}),function(${alias}${iterator1}${iterator2}){return ${genElement(
    el,
    state
  )}})`
}

最后会转化为代码:

_l(items, function (item, index) {
  return _c('div', [_v(_s(item.name))])
})

运行实例

我们已经写完了模板编译代码,完整的使用流程如下:

<!DOCTYPE html>
<html>
<head>
  <script src="https://unpkg.com/vue@2.7.16/dist/vue.min.js"></script>
</head>
<body>
  <div id="app"></div>

  <script>
    // 1. 模板字符串
    const template = `
      <div>
        <h1>恭喜!编译成功!</h1>
        <h1 v-if="true">随机数{{ Math.random() }}</h1>
        <h1 v-if="false">这里不会被展示</h1>
        <h2>
          <span>{{message}}</span>
          <span v-for="item in list">{{item}}</span>
        </h2>
      </div>
    `

    // 2. 三步编译:parse -> optimize -> generate
    // (函数实现见上文,这里省略)
    const ast = parse(template, {...})
    optimize(ast)
    const { render, staticRenderFns } = generate(ast)

    // 3. 使用生成的 render 函数创建 Vue 实例
    const vm = new Vue({
      el: '#app',
      data: {
        message: 'Hello',
        list: ['我不吃', '饼干', '🍪'],
      },
      render: new Function(render),
      staticRenderFns: staticRenderFns.map(fn => new Function(fn)),
    })
  </script>
</body>
</html>

最终页面展示效果如下:

image.png

后记

以前看大佬ssh_晨曦时梦见兮的文章,特别喜欢他那种把问题深入浅出讲明白的文章。可是当我自己开始写的时候,才发现想写好真的好难。

有任何问题都可以在评论区提出,感谢大家看到这里。

Vue真的是单向数据流?

Vue 单向数据流的深入解析:对象 Props 的陷阱

🔍 核心问题:对象 Props 的可变性

你指出的完全正确!这是 Vue 单向数据流中最大的陷阱

🚨 问题的本质

1. 对象/数组 Props 的引用传递

<!-- 父组件 -->
<script setup>
import { reactive } from 'vue'
import ChildComponent from './ChildComponent.vue'

// 父组件的响应式对象
const user = reactive({
  name: '张三',
  age: 25,
  address: {
    city: '北京',
    street: '长安街'
  }
})
</script>

<template>
  <!-- 传递对象 prop -->
  <ChildComponent :user="user" />
</template>

<!-- 子组件 ChildComponent.vue -->
<script setup>
const props = defineProps(['user'])

// ⚠️ 危险操作:可以直接修改父组件的数据!
const updateUser = () => {
  props.user.name = '李四'  // ✅ 生效,且修改了父组件
  props.user.age++         // ✅ 生效
  props.user.address.city = '上海'  // ✅ 生效
  props.user.address.street = '南京路'  // ✅ 生效
}

// 甚至可以直接添加新属性
const addProperty = () => {
  props.user.email = 'new@email.com'  // ✅ 会添加到父组件对象
}
</script>

📊 为什么会出现这个问题?

2. JavaScript 引用机制 + Vue 响应式系统

// 1. JavaScript 对象是引用传递
const parentObj = { name: 'Parent' }
const childProp = parentObj  // 同一个引用!

childProp.name = 'Modified'  // 修改了 parentObj
console.log(parentObj.name)  // 'Modified'

// 2. Vue 的响应式系统基于 Proxy
const reactiveParent = reactive({ data: 'original' })
// 子组件获得的是同一个 Proxy 对象

// 3. Vue 的开发模式警告有限制
// 直接赋值 props.user = {} ❌ 会警告
// 但修改属性 props.user.name = 'new' ⚠️ 不会警告!

🔄 Vue 官方态度

3. 文档中的说明

Vue 官方文档明确指出:

"注意在 JavaScript 中对象和数组是通过引用传入的,
所以对于一个数组或对象类型的 prop 来说,
在子组件中改变变更这个对象或数组本身将会影响到父组件的状态。"

"这在 Vue 中是不被推荐的,因为它破坏了单向数据流的清晰性。"

4. 警告机制的局限

// Vue 只能检测到这些情况:

// 情况1:直接重新赋值 ❌ 会警告
props.user = { name: 'new' }
// 警告: Attempting to mutate prop "user"

// 情况2:修改基本类型 prop ❌ 会警告
props.count = 2
// 警告: Attempting to mutate prop "count"

// 情况3:修改对象/数组的属性 ⚠️ 不会警告!
props.user.name = 'new'
props.list.push('item')
// 无警告,但破坏了单向数据流

🛡️ 如何避免这个问题?

5. 解决方案1:传递解构后的值

<!-- 父组件 -->
<template>
  <!-- 传递基本类型或深度解构 -->
  <ChildComponent 
    :user-name="user.name"
    :user-age="user.age"
    :user-city="user.address.city"
  />
  
  <!-- 或者使用 computed 创建只读副本 -->
  <ChildComponent :user="readonlyUser" />
</template>

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

const user = reactive({ name: '张三', age: 25 })

// 创建只读版本
const readonlyUser = computed(() => ({
  name: user.name,
  age: user.age
}))
</script>

6. 解决方案2:使用深度只读

<!-- 父组件 -->
<script setup>
import { readonly } from 'vue'

const user = reactive({ name: '张三', age: 25 })

// 使用 readonly 包装
const readonlyUser = readonly(user)
</script>

<template>
  <ChildComponent :user="readonlyUser" />
</template>

<!-- 子组件 -->
<script setup>
const props = defineProps(['user'])

const updateUser = () => {
  // ❌ 现在会触发警告
  props.user.name = '李四'
  // 警告: Set operation on key "name" failed: target is readonly.
}
</script>

7. 解决方案3:使用深度复制

<!-- 子组件处理 -->
<script setup>
import { ref, watch } from 'vue'

const props = defineProps(['user'])

// 深度复制到本地状态
const localUser = ref(JSON.parse(JSON.stringify(props.user)))

// 或者使用 lodash 的 cloneDeep
import { cloneDeep } from 'lodash-es'
const localUser = ref(cloneDeep(props.user))

// 监听 props 变化同步更新
watch(() => props.user, (newUser) => {
  localUser.value = cloneDeep(newUser)
}, { deep: true })
</script>

📈 最佳实践模式

8. 工厂函数模式

// composables/useSafeProps.js
import { ref, watch, toRaw } from 'vue'

export function useSafeProp(propValue, options = {}) {
  const { deep = true, immediate = true } = options
  
  // 创建本地副本
  const localValue = ref(structuredClone(toRaw(propValue)))
  
  // 监听 props 变化
  watch(() => propValue, (newValue) => {
    localValue.value = structuredClone(toRaw(newValue))
  }, { deep, immediate })
  
  return localValue
}

// 在组件中使用
const props = defineProps(['user', 'list'])
const localUser = useSafeProp(props.user)
const localList = useSafeProp(props.list)

9. 类型安全的深度只读

// types/utilities.ts
export type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object 
    ? T[P] extends Function 
      ? T[P] 
      : DeepReadonly<T[P]>
    : T[P]
}

// 父组件使用
import type { DeepReadonly } from '@/types/utilities'

const user = reactive({ 
  name: '张三', 
  profile: { age: 25, address: { city: '北京' } } 
})

const readonlyUser = user as DeepReadonly<typeof user>

// 传递给子组件
<ChildComponent :user="readonlyUser" />

// 子组件中 TypeScript 会阻止修改
props.user.name = 'new'  // ❌ TypeScript 错误
props.user.profile.age = 30  // ❌ TypeScript 错误

🎯 结论:Vue 真的是单向数据流吗?

正确答案

  1. 设计理念上:Vue 设计为单向数据流 ✅
  2. 语法层面:通过 props + events 强制执行单向流 ✅
  3. 实现层面:由于 JavaScript 限制,存在对象引用漏洞 ⚠️
  4. 实践层面:需要开发者自觉遵守规范 ⚠️

关键认知

  • Vue 的单向数据流是"约定大于强制"
  • 框架提供了基础,但开发者需要负责具体实现
  • 对象/数组 props 的易变性是已知的设计取舍
  • 通过工具、规范和最佳实践可以避免问题

最终建议

如果你想保持严格单向数据流:

  1. 始终使用 readonly() 包装对象 props
  2. 使用 TypeScript 的只读类型
  3. 配置 ESLint 严格规则
  4. 通过事件通信,而不是直接修改
  5. 对于复杂对象,传递解构后的基本类型 但也要理解 Vue 的设计哲学:"给开发者选择权,而不是强制约束"

Vue 的设计哲学是"渐进式"和"灵活" ,它提供了单向数据流的基础,但也允许在需要时绕过限制。这既是优点(灵活性),也是挑战(需要团队规范)。

深入浅出 Vue3 defineModel:极简实现组件双向绑定

深入浅出 Vue3 defineModel:极简实现组件双向绑定

在 Vue3 从 Options API 向 Composition API 演进的过程中,组件双向绑定的实现方式也经历了迭代优化。defineModel 作为 Vue3.4+ 版本推出的新语法糖,彻底简化了传统 v-model 双向绑定的实现逻辑,让开发者无需手动声明 props 和 emits 就能快速实现组件内外的数据同步。本文将从核心原理、使用场景、进阶技巧等维度,全面解析 defineModel 的使用方式。

一、为什么需要 defineModel?

在 Vue3.4 之前,实现组件双向绑定需要手动声明 props + 触发 emits,步骤繁琐且代码冗余:

<!-- 传统 v-model 实现(Vue3.4 前) -->
<template>
  <input :value="modelValue" @input="handleInput" />
</template>

<script setup>
// 1. 声明接收的 props
const props = defineProps(['modelValue'])
// 2. 声明触发的事件
const emit = defineEmits(['update:modelValue'])

// 3. 手动触发事件更新值
const handleInput = (e) => {
  emit('update:modelValue', e.target.value)
}
</script>

这种写法需要维护 props 和 emits 的一致性,且多字段双向绑定时代码量会成倍增加。而 defineModel 正是为解决这一痛点而生 —— 它将 props 声明、事件触发的逻辑封装为一个极简的 API,大幅降低双向绑定的开发成本。

二、defineModel 核心用法

1. 基础使用(单字段绑定)

defineModel 是一个内置的组合式 API,调用后会返回一个可响应的 ref 对象,既可以读取值,也可以直接修改(修改时会自动触发 update:modelValue 事件)。

<!-- 简化后的双向绑定 -->
<template>
  <!-- 直接绑定 ref 对象,无需手动处理事件 -->
  <input v-model="modelValue" />
</template>

<script setup>
// 一行代码实现双向绑定核心逻辑
const modelValue = defineModel()
</script>

父组件使用方式不变,依然是标准的 v-model

<template>
  <MyInput v-model="username" />
</template>

<script setup>
import { ref } from 'vue'
const username = ref('')
</script>

2. 自定义绑定名称(多字段绑定)

当组件需要多个双向绑定字段时,可给 defineModel 传入参数指定绑定名称,配合父组件的 v-model:xxx 语法实现多字段同步:

<!-- 子组件:多字段双向绑定 -->
<template>
  <input v-model="name" placeholder="姓名" />
  <input v-model="age" type="number" placeholder="年龄" />
</template>

<script setup>
// 自定义绑定名称:name 和 age
const name = defineModel('name')
const age = defineModel('age')
</script>

父组件使用带参数的 v-model

<template>
  <UserForm v-model:name="userName" v-model:age="userAge" />
</template>

<script setup>
import { ref } from 'vue'
const userName = ref('张三')
const userAge = ref(20)
</script>

3. 配置默认值与类型校验

defineModel 支持传入配置对象,设置 props 的默认值、类型校验等,等价于传统 defineProps 的配置:

<template>
  <input v-model="count" type="number" />
</template>

<script setup>
// 配置默认值、类型、必填项
const count = defineModel({
  type: Number,
  default: 0,
  required: false
})
</script>

三、defineModel 核心原理

defineModel 本质是 Vue 提供的语法糖,其底层依然是基于 props + emits 实现的,Vue 会自动完成以下操作:

  1. 声明一个名为 modelValue(或自定义名称)的 prop;

  2. 声明一个名为 update:modelValue(或 update:自定义名称)的 emit 事件;

  3. 返回一个 ref 对象:

    • 读取值时,取的是 props 中的值;
    • 修改值时,自动触发对应的 update 事件更新父组件数据。

四、注意事项与使用场景

1. 版本要求

defineModel 是 Vue3.4+ 新增的 API,若项目版本低于 3.4,需先升级 Vue 核心包:

运行

# npm
npm install vue@latest

# yarn
yarn add vue@latest

2. 与 v-model 修饰符结合

defineModel 支持获取父组件传入的 v-model 修饰符(如 .trim.number),通过 modelModifiers 属性访问:

<template>
  <input 
    :value="modelValue" 
    @input="handleInput"
  />
</template>

<script setup>
const modelValue = defineModel()
// 获取修饰符
const { modelModifiers } = defineProps({
  modelModifiers: { default: () => ({}) }
})

const handleInput = (e) => {
  let value = e.target.value
  // 处理 trim 修饰符
  if (modelModifiers.trim) {
    value = value.trim()
  }
  // 直接修改 ref,自动触发更新
  modelValue.value = value
}
</script>

3. 适用场景

  • 表单组件(输入框、选择器、开关等)的双向绑定;
  • 需同步父子组件状态的通用组件(如弹窗的显隐、滑块的数值等);
  • 多字段联动的复杂组件(如表单卡片、筛选面板)。

五、总结

  1. defineModel 是 Vue3.4+ 为简化组件双向绑定推出的语法糖,替代了传统 props + emits 的冗余写法;
  2. 核心用法:调用 defineModel() 返回 ref 对象,直接绑定到模板,修改 ref 自动同步父组件数据;
  3. 支持自定义绑定名称、配置 props 校验规则,兼容 v-model 修饰符,满足复杂场景需求;
  4. 底层仍基于 Vue 原生的 props 和 emits 实现,无额外性能开销,是 Vue3 组件双向绑定的首选方案。

相比于传统写法,defineModel 大幅减少了模板代码量,让开发者更聚焦于业务逻辑,是 Vue3 组件开发中提升效率的重要特性。

vue2和vue3响应式原理的区别

一、核心原理对比(先看本质) Vue 响应式的核心目标是:监听数据的读取和修改,自动更新视图。两者的实现思路一致,但底层 API 的能力差异导致了最终效果的不同: 特性 Vue2(Object.def

前端实现进度条

后端处理数据处理逻辑特别多的时候,并不会很及时返回数据,一般情况后端给前端返回进度,这个目前是前端自己返回进度到90,等到接口返回完成再到100% 1、设置全局样式 2、当触发的时候就调用进度条展示方

浅谈 import.meta.env 和 process.env 的区别

这是一个前端构建环境里非常核心、也非常容易混淆的问题。下面我们从来源、使用场景、编译时机、安全性四个维度来谈谈 import.meta.envprocess.env 的区别。


一句话结论

process.env 是 Node.js 的环境变量接口 import.meta.env 是 Vite(ESM)在构建期注入的前端环境变量


一、process.env 是什么?

1️⃣ 本质

  • 来自 Node.js
  • 运行时读取 服务器 / 构建机的系统环境变量
  • 本身 浏览器里不存在
console.log(process.env.NODE_ENV);

2️⃣ 使用场景

  • Node 服务
  • 构建工具(Webpack / Vite / Rollup)
  • SSR(Node 端)

3️⃣ 前端能不能用?

👉 不能直接用

浏览器里没有 process

// 浏览器原生环境 ❌
Uncaught ReferenceError: process is not defined

4️⃣ 为什么 Webpack 项目里能用?

因为 Webpack 帮你“编译期替换”了

process.env.NODE_ENV
// ⬇️ 构建时被替换成
"production"

本质是 字符串替换,不是运行时读取。


二、import.meta.env 是什么?

1️⃣ 本质

  • Vite 提供
  • 基于 ES Module 的 import.meta
  • 构建期 + 运行期可用(但值是构建期确定的)
console.log(import.meta.env.MODE);

2️⃣ 特点

  • 浏览器里 原生支持
  • 不依赖 Node 的 process
  • 更符合现代 ESM 规范

三、两者核心区别对比(重点)

维度 process.env import.meta.env
来源 Node.js Vite
标准 Node API ESM 标准扩展
浏览器可用 ❌(需编译替换)
注入时机 构建期 构建期
是否运行时读取
推荐前端使用

⚠️ 两者都不是“前端运行时读取服务器环境变量”


四、Vite 中为什么不用 process.env

1️⃣ 因为 Vite 不再默认注入 process

// Vite 项目中 ❌
process.env.API_URL

会直接报错。

2️⃣ 官方设计选择

  • 避免 Node 全局污染
  • 更贴近浏览器真实环境
  • 更利于 Tree Shaking

五、Vite 环境变量的正确用法(非常重要)

1️⃣ 必须以 VITE_ 开头

# .env
VITE_API_URL=https://api.example.com
console.log(import.meta.env.VITE_API_URL);

❌ 否则 不会注入到前端


2️⃣ 内置变量

import.meta.env.MODE        // development / production
import.meta.env.DEV         // true / false
import.meta.env.PROD        // true / false
import.meta.env.BASE_URL

六、安全性

⚠️ 重要警告

import.meta.env 里的变量 ≠ 私密

它们会:

  • 打进 JS Bundle
  • 可在 DevTools 直接看到

❌ 不要这样做

VITE_SECRET_KEY=xxxx

✅ 正确做法

  • 前端:只放“公开配置”(API 域名、开关)
  • 私密变量:只放在 Node / 服务端

七、SSR / 全栈项目里怎么区分?

在 Vite + SSR(如 Nuxt / 自建 SSR):

Node 端

process.env.DB_PASSWORD

浏览器端

import.meta.env.VITE_API_URL

两套环境变量是刻意分开的

  1. 为什么必须分成两套?(设计原因)

1️⃣ 执行环境不同(这是根因)

位置 运行在哪 能访问什么
SSR Server Node.js process.env
Client Bundle 浏览器 import.meta.env

浏览器里 永远不可能安全地访问服务器环境变量


2️⃣ SSR ≠ 浏览器

很多人误解:

“SSR 是不是浏览器代码先在 Node 跑一遍?”

不完全对

SSR 实际是:

Node.js 先跑一份 → 生成 HTML
浏览器再跑一份 → hydrate

这两次执行:

  • 环境不同
  • 变量来源不同
  • 安全级别不同

  1. 在 Vite + SSR 中,变量的“真实流向”

1️⃣ Node 端(SSR Server)

// server.ts / entry-server.ts
const dbPassword = process.env.DB_PASSWORD;

✔️ 真实运行时读取

✔️ 不会进 bundle

✔️ 只存在于服务器内存


2️⃣ Client 端(浏览器)

// entry-client.ts / React/Vue 组件
const apiUrl = import.meta.env.VITE_API_URL;

✔️ 构建期注入

✔️ 会打进 JS

✔️ 用户可见


3️⃣ 中间那条“禁止通道”

// ❌ 绝对禁止
process.env.DB_PASSWORD → 浏览器

SSR 不会、也不允许,自动帮你“透传”环境变量


  1. SSR 中最容易踩的 3 个坑(重点)


❌ 坑 1:在“共享代码”里直接用 process.env

// utils/config.ts(被 server + client 共用)
export const API = process.env.API_URL; // ❌

问题:

  • Server OK
  • Client 直接炸(或被错误替换)

✅ 正确方式:

export const API = import.meta.env.VITE_API_URL;

或者:

export const API =typeof window === 'undefined'
    ? process.env.INTERNAL_API
    : import.meta.env.VITE_API_URL;

❌ 坑 2:误以为 SSR 可以“顺手用数据库变量”

// Vue/React 组件里
console.log(process.env.DB_PASSWORD); // ❌

哪怕你在 SSR 模式下,这段代码:

  • 最终仍会跑在浏览器
  • 会被打包
  • 是严重安全漏洞

❌ 坑 3:把“环境变量”当成“运行时配置”

// ❌ 想通过部署切换 API
import.meta.env.VITE_API_URL

🚨 这是 构建期值

build 时确定
→ CDN 缓存
→ 所有用户共享

想运行期切换?只能:

  • 接口返回配置
  • HTML 注入 window.CONFIG
  • 拉 JSON 配置文件

  1. SSR 项目里“正确的分层模型”(工程视角)

┌──────────────────────────┐
│        浏览器 Client       │
│  import.meta.env.VITE_*   │ ← 公开配置
└───────────▲──────────────┘
            │
        HTTP / HTML
            │
┌───────────┴──────────────┐
│        Node SSR Server     │
│      process.env.*        │ ← 私密配置
└───────────▲──────────────┘
            │
        内部访问
            │
┌───────────┴──────────────┐
│        DB / Redis / OSS    │
└──────────────────────────┘

这是一条 单向、安全的数据流


  1. Nuxt / 自建 SSR 的对应关系

类型 用途
runtimeConfig Server-only
runtimeConfig.public Client 可见
process.env 仅 server

👉 Nuxt 本质也是在帮你维护这条边界


八、常见误区总结

❌ 误区 1

import.meta.env 是运行时读取

,仍是构建期注入


❌ 误区 2

可以用它动态切换环境

不行,想动态只能:

  • 接口返回配置
  • 或运行时请求 JSON

❌ 误区 3

Vite 里还能继续用 process.env

❌ 除非你手动 polyfill(不推荐)


九、总结

  • 前端(Vite)只认 import.meta.env.VITE_*
  • 服务端(Node)只认 process.env
  • 永远不要把秘密放进前端 env

Vue 3 中 v-for 动态组件 ref 收集失败问题排查与解决

Vue 3 中 v-for 动态组件 ref 收集失败问题排查与解决

问题描述

在开发部门管理页面的搜索栏功能时,遇到了一个奇怪的问题:在 v-for 循环中渲染的动态组件,无法正确收集到 ref 数组中。

image.png

问题现象

// schema-search-bar.vue
const searchComList = ref([]);

const getValue = () => {
  let dtoObj = {};
  console.log("searchComList", searchComList.value); // 输出: Proxy(Array) {}
  searchComList.value.forEach((component) => {
    dtoObj = { ...dtoObj, ...component.getValue() };
  });
  return dtoObj; // 返回: {}
};

现象:

  • searchComList.value 始终是空数组 []
  • 无法获取到任何子组件的实例
  • 导致搜索功能无法正常工作

代码结构

<template>
  <el-form v-if="schema && schema.properties" :inline="true">
    <el-form-item v-for="(schemaItem, key) in schema.properties" :key="key">
      <!-- 动态组件 -->
      <component 
        :ref="searchComList"  <!-- ❌ 问题所在 -->
        :is="SearchItemConfig[schemaItem.option?.comType]?.component" 
        :schemaKey="key"
        :schema="schemaItem">
      </component>
    </el-form-item>
  </el-form>
</template>

<script setup>
const searchComList = ref([]);
</script>

排查过程

1. 初步怀疑:打印时机问题

最初怀疑是打印时机不对,组件还没有挂载完成。但即使使用 nextTick 或在 onMounted 中打印,searchComList.value 仍然是空数组。

2. 对比其他正常工作的代码

在同一个项目中,发现 schema-view.vue 中类似的代码却能正常工作:

<!-- schema-view.vue - ✅ 正常工作 -->
<component 
  v-for="(item, key) in components" 
  :key="key" 
  :is="ComponentConfig[key]?.component" 
  ref="comListRef"  <!-- ✅ 使用字符串形式 -->
  @command="onComponentCommand">
</component>

<script setup>
const comListRef = ref([]);
// comListRef.value 能正确收集到所有组件实例
</script>

3. 发现关键差异

对比两个文件的代码,发现了关键差异:

文件 ref 写法 结果
schema-view.vue ref="comListRef" (字符串) ✅ 正常工作
schema-search-bar.vue :ref="searchComList" (绑定对象) ❌ 无法收集

根本原因

Vue 3 中 v-for 使用 ref 的机制

在 Vue 3 中,v-for 中使用 ref 时,两种写法的行为完全不同

1. 字符串形式的 ref(自动收集到数组)
<component v-for="item in list" ref="comListRef" />

行为:

  • Vue 会自动将 ref 的值设置为一个数组
  • 数组中的元素按顺序对应 v-for 中的每一项
  • 这是 Vue 3 的特殊处理机制
2. 绑定 ref 对象(不会自动收集)
<component v-for="item in list" :ref="comListRef" />

行为:

  • :ref 绑定的是一个 ref 对象,Vue 会直接赋值
  • v-for 中,不会自动收集到数组
  • 每次循环都会覆盖上一次的值
  • 最终只会保留最后一个组件的引用

官方文档说明

根据 Vue 3 官方文档:

当在 v-for 中使用 ref 时,ref 的值将是一个数组,包含所有循环项对应的组件实例。

关键点: 这个特性只适用于字符串形式的 ref,不适用于 :ref 绑定。

解决方案

方案一:使用字符串形式的 ref(推荐)

<template>
  <el-form-item v-for="(schemaItem, key) in schema.properties" :key="key">
    <component 
      ref="searchComList"  <!-- ✅ 去掉冒号,使用字符串形式 -->
      :is="SearchItemConfig[schemaItem.option?.comType]?.component" 
      :schemaKey="key"
      :schema="schemaItem">
    </component>
  </el-form-item>
</template>

<script setup>
const searchComList = ref([]);
// 现在 searchComList.value 会自动收集到所有组件实例
</script>

方案二:使用函数形式的 ref(更灵活)

如果需要更精细的控制(比如去重、按 key 索引等),可以使用函数形式:

<template>
  <el-form-item v-for="(schemaItem, key) in schema.properties" :key="key">
    <component 
      :ref="(el) => handleRef(el, key)"  <!-- ✅ 函数形式 -->
      :is="SearchItemConfig[schemaItem.option?.comType]?.component" 
      :schemaKey="key"
      :schema="schemaItem">
    </component>
  </el-form-item>
</template>

<script setup>
const searchComList = ref([]);
const componentMap = new Map();

const handleRef = (el, key) => {
  if (el) {
    // 如果已经存在,先移除旧的(避免重复)
    if (componentMap.has(key)) {
      const oldIndex = searchComList.value.indexOf(componentMap.get(key));
      if (oldIndex > -1) {
        searchComList.value.splice(oldIndex, 1);
      }
    }
    // 添加新的组件实例
    componentMap.set(key, el);
    searchComList.value.push(el);
  } else {
    // 组件卸载时,从 Map 和数组中移除
    if (componentMap.has(key)) {
      const oldEl = componentMap.get(key);
      const index = searchComList.value.indexOf(oldEl);
      if (index > -1) {
        searchComList.value.splice(index, 1);
      }
      componentMap.delete(key);
    }
  }
};
</script>

技术要点总结

1. Vue 3 ref 在 v-for 中的行为

写法 在 v-for 中的行为 适用场景
ref="xxx" 自动收集到数组 ✅ 推荐,简单场景
:ref="xxx" 不会自动收集,会覆盖 ❌ 不适用于 v-for
:ref="(el) => fn(el)" 手动控制收集逻辑 ✅ 需要精细控制时

2. 最佳实践

  1. 在 v-for 中使用 ref 时,优先使用字符串形式

    <component v-for="item in list" ref="comListRef" />
    
  2. 如果需要按 key 索引或去重,使用函数形式

    <component v-for="(item, key) in list" :ref="(el) => handleRef(el, key)" />
    
  3. 避免在 v-for 中使用 :ref="refObject"

    <!-- ❌ 不推荐 -->
    <component v-for="item in list" :ref="comListRef" />
    

3. 调试技巧

当遇到 ref 收集问题时,可以:

  1. 检查 ref 的写法:确认是字符串还是绑定对象
  2. 使用 nextTick 延迟检查:确保组件已挂载
  3. 对比正常工作的代码:找出差异点
  4. 查看 Vue DevTools:检查组件实例是否正确创建

相关资源

总结

这个问题看似简单,但实际上涉及到 Vue 3 中 refv-for 中的特殊处理机制。关键点在于:

  1. 字符串形式的 ref 在 v-for 中会自动收集到数组
  2. 绑定形式的 :ref 在 v-for 中不会自动收集
  3. 函数形式的 :ref 可以手动控制收集逻辑

记住这个规则,可以避免很多类似的坑。在开发过程中,如果遇到 ref 收集问题,首先检查是否在 v-for 中使用了错误的 ref 写法。

❌