阅读视图
从vue3 watch开始理解Vue的响应式原理
Vue3响应式API-reactive的原理
解析ElementPlus打包源码(五、copyFiles)
还有最后一个copyFiles,虽然有点水,还是记录下
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快速开始
🌟 藏在 Vue3 源码里的 “二进制艺术”:位运算如何让代码又快又省内存?
基于PDF.js的安全PDF预览组件实现:从虚拟滚动到水印渲染
基于PDF.js的安全PDF预览组件实现:从虚拟滚动到水印渲染
本文将详细介绍如何基于Mozilla PDF.js实现一个功能完善、安全可靠的PDF预览组件,重点讲解虚拟滚动、双模式渲染、水印实现等核心技术。
前言
在Web应用中实现PDF预览功能是常见需求,尤其是在线教育、文档管理等场景。然而,简单的PDF预览往往无法满足实际业务需求,特别是在安全性方面。本文将介绍如何基于PDF.js实现一个功能完善的PDF预览组件,并重点讲解如何添加自定义防下载和水印功能,为文档安全提供保障。
功能概览
我们的PDF预览组件实现了以下核心功能:
- 基础功能:PDF文件加载与渲染、自定义尺寸控制、页面缩放规则配置、主题切换
- 安全增强:动态水印添加、防下载功能、右键菜单禁用、打印控制
- 用户体验:页面渲染事件通知、响应式布局适配、加载状态反馈
技术实现
1. 虚拟滚动加载
对于大型PDF文件,一次性渲染所有页面会导致严重的性能问题。我们通过虚拟滚动技术优化大文档的加载性能,只渲染当前可见区域和附近的页面:
// 页面缓存管理
class PDFPageViewBuffer {
#buf = new Set();
#size = 0;
constructor(size) {
this.#size = size; // 缓存页面数量限制
}
push(view) {
const buf = this.#buf;
if (buf.has(view)) {
buf.delete(view);
}
buf.add(view);
if (buf.size > this.#size) {
this.#destroyFirstView(); // 超出限制时销毁最早的页面
}
}
}
优势:
- 内存优化:只保留有限数量的页面在内存中
- 性能提升:减少不必要的渲染操作
- 流畅体验:滚动时动态加载页面
2. 双模式渲染:Canvas与HTML
PDF.js支持两种渲染模式,可根据不同需求选择。两种渲染方式在视觉效果和性能上有明显差异:
图:HTML渲染模式下的PDF显示效果
图:Canvas渲染模式下的PDF显示效果
Canvas渲染(默认)
// 创建Canvas元素
const canvas = document.createElement("canvas");
canvas.setAttribute("role", "presentation");
// 获取2D渲染上下文
const ctx = canvas.getContext("2d", {
alpha: false, // 禁用透明度通道,提高性能
willReadFrequently: !this.#enableHWA // 根据硬件加速设置优化
});
// 渲染PDF页面到Canvas
const renderContext = {
canvasContext: ctx,
transform,
viewport,
// 其他参数...
};
const renderTask = pdfPage.render(renderContext);
HTML渲染
// HTML渲染模式(文本层)
if (!this.textLayer && this.#textLayerMode !== TextLayerMode.DISABLE) {
this.textLayer = new TextLayerBuilder({
pdfPage,
highlighter: this._textHighlighter,
accessibilityManager: this._accessibilityManager,
enablePermissions: this.#textLayerMode === TextLayerMode.ENABLE_PERMISSIONS,
onAppend: (textLayerDiv) => {
this.#addLayer(textLayerDiv, "textLayer");
}
});
}
两种模式对比:
| 特性 | Canvas渲染 | HTML渲染 |
|---|---|---|
| 性能 | 高 | 中等 |
| 文本选择 | 不支持 | 支持 |
| 缩放质量 | 高 | 中等 |
| 内存使用 | 高 | 低 |
| 兼容性 | 好 | 极好 |
3. 水印渲染实现
水印是保护文档版权的重要手段。我们在PDF页面渲染完成后,直接在Canvas上添加水印,确保水印与内容融为一体:
// 在渲染完成后添加水印
const resultPromise = renderTask.promise.then(async () => {
showCanvas?.(true);
await this.#finishRenderTask(renderTask);
// 添加水印
createWaterMark({ fontText: warterMark, canvas, ctx });
// 其他处理...
});
// 水印绘制函数
function createWaterMark({
ctx,
canvas,
fontText = '默认水印',
fontFamily = 'microsoft yahei',
fontSize = 30,
fontcolor = 'rgba(218, 218, 218, 0.5)',
rotate = 30,
textAlign = 'left'
}) {
// 保存当前状态
ctx.save();
// 计算响应式字体大小
const canvasW = canvas.width;
const calfontSize = (fontSize * canvasW) / 800;
ctx.font = `${calfontSize}px ${fontFamily}`;
ctx.fillStyle = fontcolor;
ctx.textAlign = textAlign;
ctx.textBaseline = 'Middle';
// 添加多个水印
const pH = canvas.height / 4;
const pW = canvas.width / 4;
const positions = [
{ x: pW, y: pH },
{ x: 3 * pW, y: pH },
{ x: pW * 1.3, y: 3 * pH },
{ x: 3 * pW, y: 3 * pH }
];
positions.forEach((pos) => {
ctx.save();
ctx.translate(pos.x, pos.y);
ctx.rotate(-rotate * Math.PI / 180);
ctx.fillText(fontText, 0, 0);
ctx.restore();
});
// 恢复状态
ctx.restore();
}
水印技术亮点:
- 响应式设计:根据Canvas宽度自动调整水印尺寸
- 多点布局:四个位置分布水印,覆盖整个页面
- 旋转效果:每个水印独立旋转30度,增加覆盖范围
- 透明度处理:使用半透明颜色,不影响内容可读性
4. 防下载与打印控制
为了增强文档安全性,我们实现了全面的防下载和打印控制功能:
// 禁用右键菜单
document.addEventListener('contextmenu', function(e) {
e.preventDefault();
return false;
});
// 禁用文本选择
document.addEventListener('selectstart', function(e) {
e.preventDefault();
return false;
});
// 禁用拖拽
document.addEventListener('dragstart', function(e) {
e.preventDefault();
return false;
});
// 拦截Ctrl+P打印快捷键
window.addEventListener("keydown", function (event) {
if (event.keyCode === 80 && (event.ctrlKey || event.metaKey) &&
!event.altKey && (!event.shiftKey || window.chrome || window.opera)) {
// 自定义打印行为或完全禁用
event.preventDefault();
event.stopImmediatePropagation();
}
}, true);
Vue组件实现
基于以上技术,我们实现了一个功能完善的Vue3 PDF预览组件:
<template>
<iframe
:width="viewerWidth"
:height="viewerHeight"
id="ifra"
frameborder="0"
:src="`/pdfJs/web/viewer.html?file=${src}&waterMark=${waterMark}`"
@load="pagesRendered"
/>
</template>
<script setup>
import { computed } from 'vue'
import { useUserStore } from '~/store/user'
const props = defineProps({
src: String,
width: [String, Number],
height: [String, Number],
pageScale: [String, Number],
theme: String,
fileName: String
})
const emit = defineEmits(['loaded'])
// 默认值设置
const propsWithDefaults = withDefaults(props, {
width: '100%',
height: '100vh',
pageScale: 'page-width',
theme: 'dark',
fileName: ''
})
// 尺寸计算
const viewerWidth = computed(() => {
if (typeof props.width === 'number') {
return props.width + 'px'
} else {
return props.width
}
})
const viewerHeight = computed(() => {
if (typeof props.height === 'number') {
return props.height + 'px'
} else {
return props.height
}
})
// 用户信息和水印
const userStore = useUserStore()
const userInfo = computed(() => userStore.userInfo)
const waterMark = computed(() => {
const { userName, phoneNum } = userInfo.value
const phoneSuffix = phoneNum && phoneNum.substring(phoneNum.length - 4)
return userName + phoneSuffix
})
// 页面渲染事件
function pagesRendered(pdfApp) {
emit('loaded', pdfApp)
}
</script>
<style scoped>
#ifra {
max-width: 100%;
height: 100%;
margin-left: 50%;
transform: translateX(-50%);
}
</style>
使用方法
基本使用
<template>
<PDFViewer
src="path/to/your/pdf/file.pdf"
:width="800"
:height="600"
@loaded="handlePdfLoaded"
/>
</template>
<script setup>
import PDFViewer from '@/components/PDFViewer/index.vue'
function handlePdfLoaded(pdfApp) {
console.log('PDF已加载完成', pdfApp)
}
</script>
高级配置
<template>
<PDFViewer
src="path/to/your/pdf/file.pdf"
width="100%"
height="90vh"
page-scale="page-fit"
theme="light"
file-name="自定义文件名.pdf"
@loaded="handlePdfLoaded"
/>
</template>
性能优化
1. 渲染性能优化
// 设置合理的maxCanvasPixels
const maxCanvasPixels = isHighEndDevice ?
16777216 * 4 : // 4K显示器
8388608 * 2; // 普通显示器
const pdfViewer = new PDFViewer({
container: document.getElementById('viewer'),
maxCanvasPixels: maxCanvasPixels
});
2. 内存管理优化
// 限制缓存页面数量,防止内存溢出
pdfViewer.setDocument(pdfDocument);
pdfViewer.currentScaleValue = 'auto';
// 定期清理不可见页面
setInterval(() => {
const visiblePages = pdfViewer._getVisiblePages();
// 清理不可见页面的缓存
}, 30000);
3. 按需渲染
// 只渲染可见页面
pdfViewer.onPagesLoaded = () => {
const visiblePages = pdfViewer._getVisiblePages();
// 只渲染可见页面,延迟渲染其他页面
};
注意事项
- PDF.js版本:确保使用兼容的PDF.js版本,不同版本API可能有差异
- 跨域处理:PDF文件可能存在跨域问题,需确保服务器配置了正确的CORS头
- 大文件处理:对于大型PDF文件,考虑添加加载进度提示
- 移动端适配:在移动设备上可能需要额外的样式调整
- 安全限制:虽然实现了防下载和水印,但无法完全防止技术用户获取PDF内容
扩展功能建议
- 页面跳转:添加页面导航功能,支持直接跳转到指定页面
- 文本搜索:实现PDF内容搜索功能
- 注释工具:添加PDF注释、标记功能
- 水印样式自定义:支持更多水印样式和位置配置
- 访问控制:基于用户角色限制PDF访问权限
总结
本文介绍了如何基于Mozilla PDF.js实现一个功能完善的PDF预览组件,并重点讲解了如何添加自定义的防下载和水印功能。通过合理的技术选型和组件设计,我们实现了一个既美观又安全的PDF预览解决方案。
在实际应用中,您可以根据具体需求进一步扩展功能,如添加页面导航、文本搜索等高级特性,为用户提供更丰富的PDF阅读体验,同时确保文档内容的安全性。
希望本文对您在Vue3项目中实现安全PDF预览功能有所帮助!
需要源码的评论区回复6666
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应用
-
src/main.js
- 这是Vue应用的入口JavaScript文件
- 使用
createApp创建Vue应用实例 - 导入并挂载 App.vue 组件到
#app元素上 - 导入全局样式文件
./assets/main.css
- src/App.vue
整体流程:index.html 加载 main.js → main.js 创建Vue应用并挂载 App.vue → App.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/, ''),
}
}
}
发送请求和响应请求的逻辑以及结构
-
请求发送流程
- 使用
axios创建 request 实例,设置基础URL为/api - 通过 request 实例的 HTTP 方法(get/post/put/delete)发送请求
- 请求会自动加上
/api前缀,例如request.get('/depts')实际访问/api/depts
-
响应处理流程
-
request.interceptors.response.use()设置了响应拦截器 - 成功响应时,拦截器直接返回
response.data,即只返回实际数据部分 - 失败响应时,拦截器将错误通过
Promise.reject(error)向上抛出
-
API 调用示例
以 queryAllApi 为例:
-
调用
request.get('/depts')发送 GET 请求 -
请求地址实际为
/api/depts -
响应拦截器处理后,只返回数据部分给调用方
处理数据部分时,在代码中采用async和await进行数据接收,对接受过来的数据进行处理,如果返回状态吗正确,输出相应提示信息
ElementPlus组件
参考文档
表格组件
<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 路径显示哪些组件。
-
Route
-
route指的是单个路由规则,即你在routes数组里定义的对象 - 每个 route 包含
path(路径)、name(名称)、component(对应渲染的组件) 等属性 - 例如
{path: '/login', name: 'login', component: LoginView}就是一个具体的 route 配置
-
-
Router View
-
<router-view>是一个 Vue 组件,作为路由出口,用来显示当前路由匹配到的组件 - 当 URL 改变时,
<router-view>会自动更新为对应的组件内容 - 在嵌套路由中(如你配置中的 children),子组件也会渲染在父级的
<router-view>内
-
-
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
})
生命周期流程:
-
onMounted→ 组件挂载完成后执行 - 异步加载初始数据
- 确保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 知识点总结
- ES6+ 语法:箭头函数、解构赋值、模板字符串
- 模块化:import/export 模块导入导出
- Promise 和异步编程:async/await 错误处理
- 数组方法:map、forEach、splice、push
- 对象操作:属性访问、方法调用
- 条件判断:if/else、三元运算符
- 函数作用域:闭包、this 指向
- 事件循环:宏任务、微任务执行顺序
这个组件展示了现代前端开发的典型模式:响应式数据绑定、组件化开发、异步数据流、表单处理等核心概念。
vxe-table 个性化列自定义列弹出层修改高度、修改最大高度不自动适应表格高度的方法
vxe-table 个性化列自定义列弹出层修改高度、修改最大高度不自动适应表格高度的方法
默认情况下,在表格设置高度或最小高度的情况下个性化列弹出层默认内部模式(自适应表格高度),表格多高就最大多高;未设置高度情况下默认外部模式(不跟随表格高度)
自适应高度时
当 custom-config.poupuOptions.mode='auto' 时,且同时设置高度时
不设置高度时
<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 自定义最大高度
<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>
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-compat 在 sass-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 插件,通过
chainWebpack在sass-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 的标志。
让我们看一个实际的例子。假设我们需要两个不同的按钮组件:Button 和 AnotherButton。
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"
}
核心机制:
- 编译转换:构建工具读取 CSS 文件,将类名作为 Key。
-
哈希生成:工具会根据文件名、类名和文件内容,生成一个唯一的 Hash 字符串(例如
3a8f),将其拼接成新的类名作为 Value。 -
替换引用:在 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)会做两件事:
-
HTML 标记:给模板中的每个 DOM 元素添加一个独一无二的自定义属性,通常以
data-v-开头,例如data-v-7ba5bd90。 - 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 文件。如果你想根据组件的状态(比如 primary、disabled、active)来改变样式,通常需要动态拼接类名。
但在 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。
- 删除无用代码:既然样式是绑定在组件上的,如果组件没被使用,样式也不会被打包。
- 维护性:你永远不用去寻找“这个类名定义在哪里”,因为它就在组件的代码里。
五、 总结:如何选择?
在现代前端开发中,我们有多种武器来对抗样式冲突:
-
CSS Modules (React 推荐)
- 适用场景:大型 React 项目,团队习惯传统的 CSS/SCSS 编写方式,追求极致的性能(编译时处理)。
- 特点:通过 Hash 类名实现隔离,输出 JS 对象。
-
关键词:
.module.css,import styles, 安全, 零冲突。
-
Vue Scoped Styles (Vue 默认)
- 适用场景:绝大多数 Vue 项目。
-
特点:通过
data-v-属性选择器实现隔离,代码更简洁,可读性更高。 -
关键词:
<style scoped>, 属性选择器, 简单易用。
-
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。
三、实际执行流程:从创建到更新全梳理
- 创建调用器:Vue 解析模板里的 @click="handleClick" 时,调用 createInvoker 传入 handleClick,生成 invoker,此时 invoker.value = handleClick;
- 绑定到 DOM:Vue 将 invoker 通过 addEventListener 绑定到对应的 DOM 元素上(DOM 绑定的是 invoker,而非直接绑定 handleClick);
- 事件触发执行:用户触发事件时,invoker 被执行,内部调用 invoker.value(e),最终执行我们写的 handleClick(e);
- 动态更新逻辑:需要修改事件回调时,直接修改 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(0, 1, '替换值') // 替换元素
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.shoppingList, 0, '正确修改的值');
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. 使用变异方法:push、pop、shift、unshift、splice、sort、reverse
- 2. 修改特定索引:使用
Vue.set()或vm.$set() - 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 模板编译(解析篇)》 中,我们已经知道,模板编译的过程分为三步:解析、优化、生成。
- 解析 parse:在这一步,Vue 会解析模板字符串,并生成对应的 AST
- 优化 optimize:这个阶段主要是通过标记静态节点来进行语法树优化。
- 生成 generate:利用前面生成 AST(抽象语法树)转换成渲染函数(render function)。
在前文中,我们已经学习了如何生成 AST 接下来我们需要学习如何 optimize 和 generate。
optimize:优化 AST
第一步:标记静态节点
如上文所说,这个阶段主要是通过标记静态节点来进行语法树优化,在进行优化后,Vue 会在 AST 中标记某个节点是否为静态节点。
在上一节生成 AST 中,我们定义了节点类型:
- 元素节点 type=1
- 表达式节点 type=2
- 文本节点 type=3
在不存在子节点时:
- 首先文本节点必为静态节点,因为文本内容固定不变。
- 其次表达式则必不为静态节点,因为它的值依赖于引用的表达式。
- 最后普通节点,如果有
v-if或v-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
}
对于有子节点的情况:父节点必须满足以下两个条件才能成为静态节点:
- 自身是静态节点(没有
v-if、v-for等动态指令) - 所有子节点都是静态节点
接下来我们处理节点树
/**
* 标记一个节点是否为静态节点
*/
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 函数用于标记静态根节点,被标记为静态根节点的元素及其子树会在代码生成阶段被特殊处理:
-
提升为常量:将其渲染代码提升到
staticRenderFns数组中,只生成一次 - 跳过 patch:更新时直接复用,不需要重新创建和对比 VNode
- 性能提升:减少运行时的计算开销
/**
* 标记静态根节点
* @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 转换为可执行的渲染代码:
-
递归生成代码:调用
genElement遍历 AST,生成类似_c('div', [...])的代码字符串 -
包装 with 作用域:用
with(this){}包裹,让代码能访问 Vue 实例的属性 -
收集静态渲染函数:将静态根节点单独提取到
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>
最终页面展示效果如下:
后记
以前看大佬ssh_晨曦时梦见兮的文章,特别喜欢他那种把问题深入浅出讲明白的文章。可是当我自己开始写的时候,才发现想写好真的好难。
有任何问题都可以在评论区提出,感谢大家看到这里。
vue 表格 vxe-table 加载数据的几种方式,更新数据的用法
Vue 响应式原理深度解析
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 真的是单向数据流吗?
正确答案:
- 设计理念上:Vue 设计为单向数据流 ✅
- 语法层面:通过 props + events 强制执行单向流 ✅
- 实现层面:由于 JavaScript 限制,存在对象引用漏洞 ⚠️
- 实践层面:需要开发者自觉遵守规范 ⚠️
关键认知:
- Vue 的单向数据流是"约定大于强制"
- 框架提供了基础,但开发者需要负责具体实现
- 对象/数组 props 的易变性是已知的设计取舍
- 通过工具、规范和最佳实践可以避免问题
最终建议:
如果你想保持严格单向数据流:
- 始终使用
readonly()包装对象 props - 使用 TypeScript 的只读类型
- 配置 ESLint 严格规则
- 通过事件通信,而不是直接修改
- 对于复杂对象,传递解构后的基本类型 但也要理解 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 会自动完成以下操作:
-
声明一个名为
modelValue(或自定义名称)的 prop; -
声明一个名为
update:modelValue(或update:自定义名称)的 emit 事件; -
返回一个 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. 适用场景
- 表单组件(输入框、选择器、开关等)的双向绑定;
- 需同步父子组件状态的通用组件(如弹窗的显隐、滑块的数值等);
- 多字段联动的复杂组件(如表单卡片、筛选面板)。
五、总结
-
defineModel是 Vue3.4+ 为简化组件双向绑定推出的语法糖,替代了传统props + emits的冗余写法; - 核心用法:调用
defineModel()返回 ref 对象,直接绑定到模板,修改 ref 自动同步父组件数据; - 支持自定义绑定名称、配置 props 校验规则,兼容 v-model 修饰符,满足复杂场景需求;
- 底层仍基于 Vue 原生的 props 和 emits 实现,无额外性能开销,是 Vue3 组件双向绑定的首选方案。
相比于传统写法,defineModel 大幅减少了模板代码量,让开发者更聚焦于业务逻辑,是 Vue3 组件开发中提升效率的重要特性。