阅读视图

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

从零开始,学习所有指令!

哈喽大家好,我是心连欣。在 Vue.js 的世界里,我们不再直接操作 DOM(比如 document.getElementById),而是通过操作 数据(Data)  来驱动 视图(View)  的变化。今天,我们就通过四个具体的场景,来看看 Vue 是如何做到这一点的。

1. 视觉控制:v-show (条件渲染)

核心思想:  用数据控制元素的“显”与“隐”。

还记得小时候玩的“躲猫猫”吗?v-show 就像是一个开关,控制着元素是否在页面上显示。

  • 原理:  根据表达式的真假(true/false),切换元素的 CSS display 属性。
  • 适用场景:  需要频繁切换显示状态的元素。

案例解析:控制图片显隐
在这个案例中,我们通过一个布尔值 isshow 来控制图片。

  • 初始状态 isshow: false,图片隐藏。
  • 点击按钮时,执行 changeisshow 方法,利用 ! 取反操作符,让 true 变 false,图片状态随之切换。

image.png 效果如下:

image.png

2. 批量处理:v-for (列表渲染)

核心思想:  用数据控制元素的“生”与“灭”。

当数据不再是单一的字符串,而是一组数据(数组或对象)时,v-for 就派上用场了。它能帮我们循环生成 DOM 结构,比如列表、表格等。

案例解析:旅游清单与美食菜单
在这个案例中,我们展示了两种用法:

  1. 遍历数组:  arr 存储了省份信息,v-for="(it, index) in arr" 拿到了每一项和索引。
  2. 遍历对象数组:  vegetables 是一个对象数组。点击“添加”按钮时,push 方法将新数据追加到数组末尾,Vue 会自动帮我们生成新的 <li> 标签。

image.png 效果如下:

image.png

3. 交互响应:v-on (事件监听)

核心思想:  监听用户的操作,执行对应的逻辑。

光有界面是不够的,网页必须能响应用户的点击、双击、鼠标移动等行为。v-on 指令(简写为 @)就是连接用户操作与 JavaScript 代码的桥梁。

案例解析:按钮与文本更新
这里我们监听了三种事件:

  • @click:单击按钮,弹出警告框。
  • @dblclick:双击按钮,也触发同一个函数。
  • @mouseenter:鼠标移入时触发。

同时,我们还演示了如何修改数据来更新视图:点击标题时,changefood 方法修改了 food 字符串,页面上的文字立刻就变了。

image.png 效果如下:

image.png 这几个指令分别代表了 Vue 的四种核心能力:条件渲染列表渲染事件监听 和 双向数据绑定

为了帮你梳理知识体系,我将你提供的四个案例整合成了一篇结构化的学习笔记。这篇文章不仅展示了代码,还解析了它们背后的逻辑,希望能帮你把零散的知识点串联起来。


🚀 Vue 2 核心指令实战:从数据到界面的魔法

在 Vue.js 的世界里,我们不再直接操作 DOM(比如 document.getElementById),而是通过操作 数据(Data)  来驱动 视图(View)  的变化。今天,我们就通过四个具体的场景,来看看 Vue 是如何做到这一点的。

1. 视觉控制:v-show (条件渲染)

核心思想:  用数据控制元素的“显”与“隐”。

还记得小时候玩的“躲猫猫”吗?v-show 就像是一个开关,控制着元素是否在页面上显示。

  • 原理:  根据表达式的真假(true/false),切换元素的 CSS display 属性。
  • 适用场景:  需要频繁切换显示状态的元素。

案例解析:控制图片显隐
在这个案例中,我们通过一个布尔值 isshow 来控制图片。

  • 初始状态 isshow: false,图片隐藏。
  • 点击按钮时,执行 changeisshow 方法,利用 ! 取反操作符,让 true 变 false,图片状态随之切换。

html

预览

<!-- 模板部分 -->
<div id="app">
    <!-- v-show 绑定 data 中的 isshow -->
    <img v-show="isshow" src="/曾婉之宝宝/image/2.jpg" alt="">
    <!-- @click 是 v-on:click 的缩写 -->
    <input type="button" value="点击切换显示状态" @click="changeisshow">
</div>

<script>
var app = new Vue({
    el: '#app',
    data: {
        isshow: false // 数据驱动视图
    },
    methods: {
        changeisshow: function(){
            // 修改数据,视图自动更新
            this.isshow = !this.isshow;
        }
    }
})
</script>

2. 批量处理:v-for (列表渲染)

核心思想:  用数据控制元素的“生”与“灭”。

当数据不再是单一的字符串,而是一组数据(数组或对象)时,v-for 就派上用场了。它能帮我们循环生成 DOM 结构,比如列表、表格等。

案例解析:旅游清单与美食菜单
在这个案例中,我们展示了两种用法:

  1. 遍历数组:  arr 存储了省份信息,v-for="(it, index) in arr" 拿到了每一项和索引。
  2. 遍历对象数组:  vegetables 是一个对象数组。点击“添加”按钮时,push 方法将新数据追加到数组末尾,Vue 会自动帮我们生成新的 <li> 标签。

html

预览

<!-- 模板部分 -->
<div id="app">
    <!-- 遍历数组 -->
    <ul>
        <li v-for="(it, index) in arr">
            {{ index+1 }}我最喜欢的地方:{{ it }}
        </li>
    </ul>
    
    <!-- 遍历对象数组,并动态绑定title属性 -->
    <h2 v-for="item in vegetables" v-bind:title="item.name">
        {{ item.name }}
    </h2>
    
    <!-- 按钮触发方法 -->
    <input type="button" value="添加" @click="add">
    <input type="button" value="删除" @click="remove">
</div>

<script>
var app = new Vue({
    el: '#app',
    data: {
        arr: ["重庆", "四川", "云南", "贵州"],
        vegetables: [
            { name: "番茄炒鸡蛋" },
            { name: "鸡蛋炒番茄" }
        ]
    },
    methods: {
        add() {
            // 修改数组,视图自动更新列表
            this.vegetables.push({ name: "土豆炒马铃薯" });
        },
        remove() {
            this.vegetables.shift();
        }
    }
})
</script>

3. 交互响应:v-on (事件监听)

核心思想:  监听用户的操作,执行对应的逻辑。

光有界面是不够的,网页必须能响应用户的点击、双击、鼠标移动等行为。v-on 指令(简写为 @)就是连接用户操作与 JavaScript 代码的桥梁。

案例解析:按钮与文本更新
这里我们监听了三种事件:

  • @click:单击按钮,弹出警告框。
  • @dblclick:双击按钮,也触发同一个函数。
  • @mouseenter:鼠标移入时触发。

同时,我们还演示了如何修改数据来更新视图:点击标题时,changefood 方法修改了 food 字符串,页面上的文字立刻就变了。

html

预览

<!-- 模板部分 -->
<div id="app">
    <!-- 监听 click 事件 -->
    <input type="button" value="单击" @click="doIt">
    <!-- 监听 dblclick 事件 -->
    <input type="button" value="双击" @dblclick="doIt">
    <!-- 监听 mouseenter 事件 -->
    <input type="button" value="鼠标" @mouseenter="doIt">
    
    <!-- 点击修改数据 -->
    <h2 @click="changefood">{{ food }}</h2>
</div>

<script>
var app = new Vue({
    el: '#app',
    data: {
        food: '番茄炒鸡蛋'
    },
    methods: {
        doIt() {
            alert('学习It'); // 执行业务逻辑
        },
        changefood() {
            // 修改数据
            this.food += '好吃!';
        }
    }
})
</script>

4. 数据同步:v-model (双向数据绑定)

核心思想:  数据变了,界面变;界面变了,数据也变。

这是 Vue 最神奇的特性之一。在传统的开发中,我们要想获取输入框的内容,必须手动去 DOM 里取。而在 Vue 中,v-model 像一根双向的管道,把输入框的值和数据对象紧紧绑定在一起。

案例解析:文本输入与回车事件
在这个例子中:

  1. 输入框绑定了 msg 数据。当你在输入框打字时,msg 的值在内存中实时改变。
  2. 页面上的 <h1>{{ msg }}</h1> 实时显示这个值。
  3. 修饰符实战:  我们用了 @keyup.enter,意思是只有当用户按下“回车键”时,才触发 getM 方法弹出警告。这展示了 Vue 如何优雅地处理键盘事件。

image.png 效果如下:

image.png

核心指令对比总结

指令 作用 核心逻辑 典型场景
v-show 条件渲染 display: none / block 开关、选项卡
v-for 列表渲染 循环生成 DOM 商品列表、表格数据
v-on 事件监听 监听用户行为 按钮点击、表单提交
v-model 双向绑定 数据 ↔ 视图 表单输入、搜索框
这些是基础的,简单的实例,让我们继续努力学习吧!😊😊😊

全面重构的 uni-app 多平台上传组件,功能强到离谱!

一、前言

在移动应用开发中,文件上传是一个高频且复杂的需求场景,无论是用户头像上传、图片分享,还是文档提交、视频发布,都离不开一个稳定、易用的上传组件。

uView Pro 的 u-upload 组件经过几次迭代、重构,现已支持图片、视频、文档等多种文件类型,提供网格(grid)和列表(list)两种展示模式,完全向后兼容的同时带来了更强大的功能和更优雅的使用体验。

二、组件核心优势

1. 多文件类型支持

不再局限于图片上传,u-upload 现已支持:

  • 图片 - 支持预览、压缩、多选
  • 视频 - 支持时长限制、摄像头方向设置
  • 文件 - PDF、Word、Excel 等文档类型(H5/微信小程序)
  • 媒体文件 - 图片+视频混合选择
  • 所有类型 - 一键开启全类型支持

0.png

2. 双模式展示

根据文件类型自动适配最佳展示方式:

网格模式(默认) - 适合图片展示

  • 宫格布局,视觉整齐
  • 支持图片预览、删除
  • 适合头像、相册等场景

1.png

列表模式 - 适合文件展示

  • 显示文件名、文件大小
  • 进度条直观展示上传状态
  • 适合文档、资料上传场景

2.png

3. v-model 双向绑定

最新版本告别繁琐的事件监听,支持双向绑定,一行代码实现数据同步:

<u-upload :action="action" v-model="fileList"></u-upload>

4. 全平台兼容

完美适配 uni-app 所有平台:

  • App(Android/iOS/鸿蒙)
  • H5
  • 微信小程序、支付宝小程序、百度小程序、头条小程序、QQ小程序

三、快速上手

1. 基础用法

最简单的上传配置,只需设置服务器地址:

<template>
    <u-upload :action="action" v-model="fileList"></u-upload>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const action = ref('https://your-server.com/upload')
const fileList = ref([
    {
        url: 'https://example.com/avatar.jpg',
        name: 'avatar.jpg',
        size: 1024 * 50,
        progress: 100,
        error: false
    }
])
</script>

2. 上传不同文件类型

通过 accept 参数一键切换文件类型:

<!-- 上传图片(默认) -->
<u-upload :action="action" accept="image"></u-upload>

<!-- 上传视频 -->
<u-upload :action="action" accept="video" :max-duration="120"></u-upload>

<!-- 上传文件(H5/微信小程序) -->
<u-upload :action="action" accept="file" :extension="['.pdf', '.docx']"></u-upload>

<!-- 上传所有类型 -->
<u-upload :action="action" accept="all"></u-upload>

3. 展示模式切换

<!-- 网格模式 - 适合图片 -->
<u-upload :action="action" accept="image" mode="grid"></u-upload>

<!-- 列表模式 - 适合文件 -->
<u-upload :action="action" accept="file" mode="list" :show-file-name="true" :show-file-size="true"></u-upload>

4.png

四、进阶功能

1. 手动上传控制

默认自动上传,也可改为手动控制:

<template>
    <view>
        <u-upload ref="uUploadRef" :action="action" :auto-upload="false"></u-upload>
        <u-button @click="submit" type="primary">提交上传</u-button>
    </view>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const action = ref('https://your-server.com/upload')
const uUploadRef = ref()

function submit() {
    // 手动触发上传
    uUploadRef.value?.upload()
}
</script>

3.gif

2. 上传前处理

通过 before-upload 钩子实现自定义逻辑:

<template>
    <u-upload :before-upload="beforeUpload" :action="action"></u-upload>
</template>

<script setup lang="ts">
async function beforeUpload(index: number, list: any[]) {
    // 示例:上传前获取签名
    const sign = await getUploadSign()
    
    // 返回 true 继续上传,false 跳过当前文件
    return !!sign
}

async function getUploadSign() {
    // 模拟获取上传签名
    return 'upload-sign-xxx'
}
</script>

3. 文件限制

灵活控制上传文件的数量、大小和类型:

<u-upload 
    :action="action"
    :max-count="6"                    <!-- 最多选择6个文件 -->
    :max-size="5 * 1024 * 1024"       <!-- 单个文件最大5MB -->
    accept="image"
    :limit-type="['png', 'jpg', 'jpeg']"  <!-- 限制图片格式 -->
></u-upload>

4. 自定义文件选择

对于不支持文件选择的平台(如 App),可以通过 custom-choose 属性开启自定义选择:

<template>
    <u-upload 
        ref="uploadRef"
        accept="file"
        :custom-choose="true"
        :action="action"
        @on-choose="handleCustomChoose"
    ></u-upload>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const action = ref('https://your-server.com/upload')
const uploadRef = ref()

// 自定义文件选择
function handleCustomChoose({ accept, maxCount, fileList, index }: any) {
    // App 端使用原生文件选择
    // #ifdef APP-PLUS
    plus.runtime.chooseFile({
        success: (res: any) => {
            const files = res.files.map((file: any) => ({
                path: file.path,
                name: file.name,
                size: file.size,
                fileType: 'file'
            }))
            // 将文件添加到组件
            uploadRef.value?.addFiles(files)
        }
    })
    // #endif
}
</script>

核心要点:

  1. 设置 :custom-choose="true" 开启自定义选择模式
  2. 监听 @on-choose 事件,自行处理文件选择逻辑
  3. 选择完成后调用 uploadRef.value?.addFiles(files) 将文件添加到组件

5. 自定义上传按钮

通过插槽打造个性化上传入口:

5.png

<u-upload :custom-btn="true">
    <template #addBtn>
        <view class="custom-upload-btn">
            <u-icon name="plus" size="40" color="#2979ff"></u-icon>
            <text class="upload-text">点击上传</text>
        </view>
    </template>
</u-upload>

<style>
.custom-upload-btn {
    width: 200rpx;
    height: 200rpx;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background: #f5f5f5;
    border-radius: 10rpx;
    border: 2rpx dashed #ddd;
}
.upload-text {
    margin-top: 10rpx;
    font-size: 24rpx;
    color: #666;
}
</style>

6. 完全自定义文件列表展示

通过 file 插槽完全自定义文件列表的展示方式,实现更灵活的文件管理界面:

6.png

<template>
  <u-upload
    ref="customFileListRef"
    v-model="customFileList"
    accept="file"
    mode="list"
    :action="action"
    :show-upload-list="false"
    :custom-btn="true"
    :max-count="5"
  >
    <!-- 自定义文件列表 -->
    <template #file="{ file }">
      <view class="custom-file-list">
        <view 
          v-for="(item, index) in file" 
          :key="index" 
          class="custom-file-item"
        >
          <!-- 文件类型图标 -->
          <u-icon
            :name="isImageFile(item) ? 'photo' : 'file-text'"
            size="40"
            color="var(--u-type-primary)"
          />
          
          <!-- 文件信息 -->
          <view class="custom-file-info">
            <text class="custom-file-name">{{ item.name || '未命名文件' }}</text>
            <text v-if="item.size" class="custom-file-size">
              {{ formatSize(item.size) }}
            </text>
          </view>
          
          <!-- 上传进度条 -->
          <view
            v-if="item.progress < 100 && item.progress > 0"
            class="custom-file-progress"
          >
            <u-line-progress :percent="item.progress" height="8" />
          </view>
          
          <!-- 上传状态 -->
          <view class="custom-file-status">
            <u-icon
              v-if="item.progress === 100"
              :name="item.error ? 'close-circle' : 'checkmark-circle'"
              size="34"
              :color="item.error ? 'var(--u-type-error)' : 'var(--u-type-success)'"
            />
            <text v-else class="custom-file-progress-text">
              {{ Math.floor(item.progress || 0) }}%
            </text>
          </view>
          
          <!-- 删除按钮 -->
          <view class="custom-file-delete" @click="removeCustomFile(index)">
            <u-icon name="close" size="24" color="var(--u-tips-color)" />
          </view>
        </view>
      </view>
    </template>
    
    <!-- 自定义添加按钮 -->
    <template #addBtn>
      <view class="custom-file-add-btn">
        <u-icon name="plus" size="32" color="var(--u-type-primary)" />
        <text class="custom-file-add-text">添加文件</text>
      </view>
    </template>
  </u-upload>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import type { UploadFileItem } from '@/uni_modules/uview-pro/types/global'

const action = ref('https://your-server.com/upload')
const customFileList = ref<UploadFileItem[]>([])
const customFileListRef = ref()

// 判断是否为图片文件
function isImageFile(item: UploadFileItem): boolean {
  const ext = item.name?.split('.').pop()?.toLowerCase() || ''
  return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)
}

// 格式化文件大小
function formatSize(bytes: number): string {
  if (bytes === 0) return '0 B'
  const k = 1024
  const sizes = ['B', 'KB', 'MB', 'GB']
  const i = Math.floor(Math.log(bytes) / Math.log(k))
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}

// 删除文件
function removeCustomFile(index: number) {
  customFileListRef.value?.remove(index)
}
</script>

<style scoped>
.custom-file-list {
  width: 100%;
  margin-bottom: 20rpx;
}

.custom-file-item {
  display: flex;
  align-items: center;
  padding: 24rpx;
  background: var(--u-bg-white);
  border-radius: 12rpx;
  margin-bottom: 16rpx;
  border: 1rpx solid var(--u-border-color);
}

.custom-file-item:last-child {
  margin-bottom: 0;
}

.custom-file-info {
  flex: 1;
  margin-left: 20rpx;
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.custom-file-name {
  font-size: 28rpx;
  color: var(--u-main-color);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.custom-file-size {
  font-size: 24rpx;
  color: var(--u-tips-color);
  margin-top: 8rpx;
}

.custom-file-progress {
  width: 120rpx;
  margin-left: 20rpx;
}

.custom-file-progress-text {
  font-size: 24rpx;
  color: var(--u-primary-color);
}

.custom-file-status {
  margin-left: 20rpx;
  min-width: 48rpx;
  display: flex;
  justify-content: center;
  align-items: center;
}

.custom-file-delete {
  display: flex;
  align-items: center;
  margin-left: 20rpx;
  padding: 8rpx;
}

.custom-file-add-btn {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  background: var(--u-bg-white);
}

.custom-file-add-text {
  margin-left: 16rpx;
  font-size: 28rpx;
  color: var(--u-tips-color);
}
</style>

核心要点:

  1. 隐藏默认列表:设置 :show-upload-list="false"
  2. file 插槽:接收 { file } 参数,file 即当前文件列表
  3. 文件属性
    • item.name - 文件名
    • item.size - 文件大小(字节)
    • item.progress - 上传进度 0-100
    • item.error - 上传失败标记
  4. 操作文件:通过 ref 调用 remove(index) 删除文件
  5. 进度展示:使用 u-line-progress 组件显示上传进度

五、实际应用场景

场景一:用户头像上传

<u-upload 
    accept="image"
    image-shape="circle"
    :action="action" 
    :max-count="1"
    :max-size="2 * 1024 * 1024"
    :limit-type="['jpg', 'png']"
    @on-success="onAvatarSuccess"
></u-upload>

7.png

场景二:资料文档上传

<u-upload 
    accept="file"
    mode="list"
    :action="action"
    :show-file-name="true"
    :show-file-size="true"
    :extension="['.pdf', '.doc', '.docx']"
></u-upload>

场景三:视频作品发布

<u-upload 
    accept="video"
    camera="back"
    :action="action" 
    :max-count="1"
    :max-size="50 * 1024 * 1024"
    :max-duration="300"
></u-upload>

六、平台适配说明

虽然 u-upload 已实现全平台支持,但部分功能在不同平台存在差异:

功能 App H5 微信小程序 支付宝小程序
图片上传
视频上传
文件上传
文件预览
压缩选项

最佳实践建议:

  • 文件上传功能在 H5 和微信小程序体验最佳
  • 如需在 App 中使用文件上传,建议使用原生能力或第三方 SDK
  • 生产环境务必做好各平台的真机测试

七、总结

uView Pro 的 u-upload 组件经历了从单一图片上传到全能文件管理。无论是简单的头像上传,还是复杂的资料提交,还支持高度自定义,无论如何都能找到最适合的配置方案。

核心亮点:

  • 多类型支持 - 图片、视频、文档全覆盖
  • 双模式展示 - 网格/列表随心切换
  • 高度自定义 - 插槽机制、自定义满足个性需求
  • 全平台适配 - 一套代码多端运行

附录:API 完整参考

Props 参数

参数 说明 类型 默认值 可选值
action 服务器上传地址 String '' -
accept 接受的文件类型 String image image / video / file / media / all
image-shape 图片/图标展示形状 String square circle / square
modelValue 文件列表(推荐,v-model 双向绑定) Array [] -
file-list 默认显示的文件列表(旧版,建议使用 v-model) Array [] -
custom-choose 是否使用自定义文件选择 Boolean false true / false
mode 展示模式 String grid grid / list
max-count 最大选择文件的数量 String/Number 52 -
max-size 选择单个文件的最大大小,单位字节 String/Number Number.MAX_VALUE -
width 预览区域和添加按钮的宽度,单位rpx String/Number 200 -
height 预览区域和添加按钮的高度,单位rpx String/Number 200 -
multiple 是否开启文件多选 Boolean true true / false
disabled 是否禁用组件 Boolean false true / false
auto-upload 选择完文件是否自动上传 Boolean true true / false
deletable 是否显示删除文件的按钮 Boolean true true / false
show-confirm 删除文件前是否显示确认弹窗 Boolean true true / false
show-tips 特殊情况下是否自动提示toast Boolean true true / false
show-progress 是否显示上传进度条 Boolean true true / false
show-upload-list 是否显示组件内部的文件预览列表 Boolean true true / false
show-file-name 是否显示文件名 Boolean true true / false
show-file-size 是否显示文件大小 Boolean false true / false
preview-full-image 是否可以通过 uni.previewImage 预览已选择的图片 Boolean true true / false
preview-file 是否可预览文件(非图片类型) Boolean true true / false
custom-btn 是否自定义选择文件的按钮 Boolean false true / false
upload-text 选择文件按钮的提示文字 String 根据accept自动显示 -
image-mode 预览图片的显示模式 String aspectFill -
del-icon 右上角删除图标名称 String close -
del-bg-color 右上角删除按钮的背景颜色 String var(--u-type-error) -
del-color 右上角删除按钮图标的颜色 String var(--u-white-color) -
header 上传携带的请求头信息 Object {} -
form-data 上传额外携带的参数 Object {} -
name 上传文件的字段名 String file -
size-type original 原图,compressed 压缩图 Array ['original', 'compressed'] -
source-type 选择文件的来源,album-相册,camera-相机 Array ['album', 'camera'] -
limit-type 限制允许上传的文件后缀,优先级高于accept Array [] -
extension 选择文件时的扩展名过滤,仅H5和微信小程序有效 Array [] -
file-icon-map 文件类型图标映射配置 Object {} -
compressed 选择视频时是否压缩 Boolean true true / false
max-duration 选择视频时拍摄最长时长,单位秒 Number 60 -
camera 选择视频时摄像头方向 String back front / back
before-upload 上传前钩子,返回 true/false/Promise Function null -
before-remove 删除前钩子,返回 true/false/Promise Function null -
to-json 如果上传后返回值为json字符串,是否自动转为json Boolean true true / false
index 在各个回调事件中的最后一个参数返回,用于区别是哪一个组件的事件 String/Number '' -
custom-style 自定义根节点样式 String/Object {} -
custom-class 自定义根节点样式类 String '' -

Methods 方法

通过 ref 手动调用组件方法:

名称 说明 参数
upload 手动触发上传文件 -
clear 清空内部文件列表 -
reUpload 重新上传所有失败/未上传的文件 -
retry(index) 重新上传指定索引的文件 index: 文件索引
remove(index) 手动移除指定索引的文件 index: 文件索引
selectFile 手动触发文件选择 -
doPreviewImage(url, index) 预览图片 url: 图片地址, index: 索引
doPreviewFile(item, index) 预览/打开文件 item: 文件对象, index: 索引
addFiles(files) 添加文件到列表(配合 custom-choose 使用) files: 文件数组

Slots 插槽

名称 说明
addBtn 自定义选择文件按钮
file 自定义文件列表插槽

Events 事件

事件名 说明 回调参数
on-oversize 文件大小超出 max-size 限制时触发 (file, lists, name)
on-exceed 文件数量超出 max-count 限制时触发 (file, lists, name)
on-choose-complete 每次选择文件后触发 (lists, name)
on-choose-fail 文件选择失败时触发 (error)
on-uploaded 所有文件上传完毕触发 (lists, name)
on-success 单个文件上传成功时触发 (data, index, lists, name)
on-error 单个文件上传失败时触发 (res, index, lists, name)
on-change 单个文件上传状态改变时触发(无论成功或失败) (res, index, lists, name)
on-progress 文件上传过程中的进度变化时触发 (res, index, lists, name)
on-remove 移除文件时触发 (index, lists, name)
on-preview 预览文件时触发 (url, lists, name)
on-list-change 文件列表发生变化时触发 (lists, name)
on-choose 启用 custom-choose 时触发,用户可自定义文件选择逻辑 ({ accept, maxCount, currentFiles, index })
update:modelValue v-model 双向绑定事件,文件列表变化时触发 (lists)

说明:

  • lists - 当前组件内的所有文件数组
  • index - 当前操作的文件索引
  • name - 通过 props 传递的 index 参数,用于区分多个组件实例

文件列表对象结构

lists 数组中每个元素(UploadFileItem)的结构:

{
  // 基础信息
  url: string,           // 文件地址(上传成功后返回)
  path: string,          // 文件本地路径
  name: string,          // 文件名
  size: number,          // 文件大小(字节)
  fileType: 'image' | 'video' | 'file',  // 文件类型
  
  // 上传状态
  progress: number,      // 上传进度 0-100,100表示上传成功
  error: boolean,        // 上传失败标记
  response?: any,        // 服务器返回的数据
  
  // 媒体文件特有
  thumb?: string,        // 视频缩略图(仅视频)
  width?: number,        // 图片/视频宽度
  height?: number,       // 图片/视频高度
  duration?: number,     // 视频时长(秒)
  
  // 原始文件对象
  file?: any,            // 原始文件对象
  uploadTask?: UniApp.UploadTask  // 上传任务对象(用于取消上传)
}

文件类型说明

根据 accept 参数,支持以下文件类型:

accept 值 说明 自动检测的文件后缀
image 图片 png, jpg, jpeg, gif, webp, bmp, svg
video 视频 mp4, avi, mov, wmv, flv, mkv, rmvb, 3gp, m3u8
file 文件 根据 extension 参数或允许所有
media 媒体(图片+视频) 图片和视频后缀合集
all 所有文件 允许所有文件类型

注意:

  • 文件上传(accept=file)仅在 H5 和微信小程序支持
  • 媒体选择(accept=media)仅在微信小程序、App、头条小程序支持
  • 文件预览功能在 H5 体验最佳,其他平台可能受限
  • 通过自定义,你也可以实现不支持的平台特性功能

现在就开始使用 u-upload,让文件上传功能开发变得更加方便!更多内容请参考官方文档。

文档地址: uviewpro.cn/

开源地址:

【Vue3】withDefaults和defineProps设置默认值

直接在defineProps中设置默认值

优点:

  • 对于简单的组件,在defineProps对象中直接设置默认值是一种简洁的方式。所有props的定义,包括类型和默认值,都集中在一个地方,一目了然。可以很方便地看到每个props的类型和默认值信息。例如:
<script setup>
const props = defineProps({
    count: {type: Number,default: 0},
    name: {type: String,default: 'Guest'}
});
</script>
  • props是一个复杂的对象类型,并且默认值是一个对象时,直接在defineProps中设置默认值可以更好地利用对象的解构特性。例如:
<script setup>
const props = defineProps({
    user: {type: Object,default: () => ({name: 'Guest',age: 18})}
});
</script>

这里可以直接返回一个函数来生成默认的对象值,在组件初始化时会根据这个函数生成默认的user对象。

缺点:

  • props的定义比较复杂,或者需要对默认值进行一些复杂的计算或处理时,会使defineProps中的代码变得臃肿。例如,如果默认值需要调用一个函数来生成,会让props的定义看起来不够清晰。而且如果多个组件有相似的props默认值设置逻辑,这种方式不利于代码的复用。
  • Vue3 的defineProps解决了部分响应式问题(返回的是 “响应式 Proxy 对象”),但如果直接对defineProps的结果解构,依然会丢失默认值(响应式可通过toRefs保留,但默认值不行)
<!-- Vue3 组件:TestProps.vue(无withDefaults) -->
<template>
  <div>
    <!-- 父组件没传props时,期望显示默认值,实际显示 undefined -->
    <h1>{{ title }}</h1>
    <p>计数:{{ count }}</p>
  </div>
</template>

<script setup lang="ts">
// 1. 定义props并设置默认值(通过第二个参数)
const props = defineProps<{
  title?: string;
  count?: number;
}>({
  title: {
    type: String,
    default: "默认标题"
  },
  count: {
    type: Number,
    default: 100
  }
});

// 2. 直接解构:丢失默认值(响应式可通过toRefs保留,但默认值不行)
const { title, count } = props; 
console.log(title); // undefined(而非 "默认标题")
console.log(count); // undefined(而非 100)

// 3. 用toRefs解构:保留响应式,但仍丢失默认值
import { toRefs } from "vue";
const { title: refTitle, count: refCount } = toRefs(props);
console.log(refTitle.value); // undefined(依然没有默认值)
</script>

可见:Vue3 中即便用了defineProps,若不配合withDefaults,Vue2 的 “解构丢失默认值” 问题依然存在 —— 这正是withDefaults要解决的核心场景之一。

使用withDefaults设置默认值

优点:

  • withDefaults将默认值的设置与props的类型定义分离开来,使得代码结构更加清晰。特别是当props的类型定义比较复杂,或者有多个props需要设置默认值时,这种分离可以提高代码的可读性。例如:
<script setup>
import { withDefaults } from 'vue';
const props = defineProps({count: Number,name: String});
withDefaults(props, {count: 0,name: 'Guest'});
</script>
  • 先专注于props的类型定义,然后在另一个地方(withDefaults调用处)清晰地设置默认值。如果需要在多个组件中复用默认值设置逻辑,可以将withDefaults的调用封装成一个函数。
  • withDefaults在处理复杂对象类型的默认值时,与直接在defineProps中设置默认值的功能类似,但语法上略有不同。
 <script setup>
 import { withDefaults } from 'vue';
 const props = defineProps({user: Object});
 withDefaults(props, {user: () => ({name: 'Guest',age: 18})});
 </script>

同样是通过一个函数来生成默认的对象值,但需要注意的是,withDefaults在处理默认值时,对于引用类型(如对象、数组)的数据,如果默认值是一个引用类型的字面量(如{}[]),这个引用在所有组件实例中是共享的。这可能会导致一些意外的行为,例如一个组件修改了默认值对象的属性,可能会影响到其他组件的默认值。所以在使用withDefaults设置引用类型的默认值时,通常建议像上面那样返回一个新的对象或数组。

withDefaults解构后仍能读取到默认值

withDefaults是 Vue3 为defineProps设计的 “默认值增强工具”,它的核心作用有两个:

  1. defineProps的默认值与 TypeScript 类型紧密绑定(类型安全);
  2. 确保解构后仍能读取到默认值(彻底解决 Vue2 遗留的痛点)。
<!-- Vue3 组件:TestProps.vue(用withDefaults) -->
<template>
  <div>
    <!-- 父组件没传props时,正确显示默认值 -->
    <h1>{{ title }}</h1> <!-- "默认标题" -->
    <p>计数:{{ count }}</p> <!-- 100 -->
  </div>
</template>

<script setup lang="ts">
// 1. 用withDefaults定义props:默认值与类型绑定
const props = withDefaults(defineProps<{
  title?: string; // 可选类型,配合默认值
  count?: number; // 可选类型,配合默认值
}>(), {
  // 定义默认值(会自动校验类型,不符合TS类型会报错)
  title: "默认标题", 
  count: () => 100, // 复杂类型(如对象/数组)需用函数返回(避免复用问题)
});

// 2. 直接解构:默认值生效,且响应式保留(因为withDefaults处理过)
const { title, count } = props; 
console.log(title); // "默认标题"(正确读取默认值)
console.log(count); // 100(正确读取默认值)

// 3. 父组件传值时,会覆盖默认值(符合预期)
// 若父组件调用:<TestProps title="新标题" count={200} />
// 此时解构后 title = "新标题",count = 200
</script>

withDefaults 为什么能解决问题?

withDefaults本质是对defineProps返回的 “响应式 props 对象” 做了一层 “增强代理”:

  • 当访问props.title时,若父组件没传值,代理会自动返回withDefaults中定义的默认值;
  • 即便解构(const { title } = props),拿到的依然是 “带默认值逻辑的响应式引用”(而非原始值),所以默认值不会丢失;
  • 同时,withDefaults会强制默认值的类型与defineProps的 TS 类型一致(比如给title123,TS 会直接报错),比 Vue2 的type校验更严格。

缺点:

  • 代码的位置相对分散,需要查看两个地方(definePropswithDefaults)才能完整地了解props的定义和默认值情况。对于简单的props设置,可能会觉得有些繁琐。

withDefaults的使用场景

提高代码可读性和组织性

(1)复杂组件的**props**默认值设置

当组件具有多个props,并且每个props都需要设置默认值时,使用withDefaults可以让代码结构更清晰。

这样,props的类型定义和默认值设置分开,便于开发者阅读和理解。先看到props的类型定义,能快速了解组件接受哪些类型的属性,然后通过withDefaults看到默认值的设置,使得代码逻辑更加清晰,尤其在大型项目或者复杂组件开发中,有助于提高代码的可维护性。

(2)遵循组件开发规范和团队协作要求 在团队开发中,可能会有代码风格和组件开发规范的要求。使用withDefaults可以更好地符合这些规范。例如,规定props的类型定义和默认值设置分开,这样在代码审查或者新成员加入团队时,能够更容易理解组件的props逻辑。而且,这种规范的代码结构有助于提高代码的复用性,方便在其他组件中复用props的定义和默认值设置逻辑。

便于默认值的动态生成和复用

(1)动态生成默认值

有时候props的默认值需要根据一些外部条件或者组件内部状态来动态生成。使用withDefaults可以方便地实现这一点。例如,一个国际化组件,其默认文本需要根据当前语言环境来生成:

<script setup>
import { withDefaults, ref } from 'vue';
import i18n from './i18n';
const props = defineProps({
    buttonText: String
});
const currentLanguage = ref(i18n.getCurrentLanguage());
function generateDefaultButtonText() {
    return i18n.getTextForLanguage(currentLanguage.value, 'defaultButtonText');
}
withDefaults(props, {
    buttonText: generateDefaultButtonText
});
</script>

这里通过一个函数generateDefaultButtonText来动态生成buttonText的默认值,该函数可以根据当前语言环境获取合适的默认文本。这种方式使得默认值的生成更加灵活,能够适应不同的应用场景。

(2)默认值逻辑复用

如果多个组件需要相同或相似的默认值设置逻辑,可以将withDefaults的调用封装成一个函数。例如,有多个数据展示组件都需要对data属性设置默认值为空数组,对loading属性设置默认值为false

function setCommonDefaults(props) {
    withDefaults(props, {
        data: () => [],
        loading: false
    });
}

然后在各个组件中使用这个函数:

<script setup>
import { defineProps, withDefaults } from 'vue';
import { setCommonDefaults } from './commonDefaults';
const props = defineProps({
    data: Array,
    loading: Boolean
});
setCommonDefaults(props);
</script>

这样可以提高代码的复用性,减少重复代码,同时也便于统一修改默认值设置逻辑。

Vue 3.6 Vapor Mode:跳过虚拟 DOM,性能极致优化

虚拟 DOM 曾是前端框架的革命性思想,而今天,我们正在超越它。

一、引言

Vue 3.6 正式进入 Beta 阶段,Vapor Mode 作为本轮更新的最大亮点,终于揭开了神秘面纱。这是一个彻底改变 Vue 渲染架构的编译模式——跳过虚拟 DOM,直接操作真实 DOM

从 React 在 2013 年引入虚拟 DOM 思想,到 Vue 2.0 于 2016 年采纳这一方案,再到今天 Vue 3.6 选择「告别」它,前端框架领域正在经历一场静默的范式转移。Svelte 在 2019 年证明了编译时优化可以消除虚拟 DOM 的开销,SolidJS 证明了细粒度响应式无需虚拟 DOM 也能达到极致性能,而现在,拥有全球数百万开发者的 Vue 正式加入这场变革。

Vapor Mode 的命名本身就充满隐喻——Vapor(蒸汽)的目标是让「虚拟 DOM 运行时」像水蒸气一样蒸发消散。这个名称不仅是营销概念,它准确描述了这项技术的核心价值:消除传统虚拟 DOM 带来的运行时开销

二、Vapor Mode 是什么

2.1 核心概念:无虚拟 DOM 的编译模式

Vapor Mode 是 Vue 单文件组件(SFC)的一种全新编译策略。它的核心思路非常直接:在编译时分析模板,生成直接操作真实 DOM 的 JavaScript 代码,而不是生成虚拟 DOM 节点

传统 Vue 组件的编译流程是:

  1. 解析 .vue 模板文件

  2. 编译为返回虚拟 DOM 节点(VNode)的渲染函数

  3. 运行时执行渲染函数,生成 VNode 树

  4. 对比新旧 VNode 树(diffing)

  5. 根据差异补丁化更新真实 DOM

Vapor Mode 改变了这个流程的第 2 和第 3 步:

  1. 解析 .vue 模板文件

  2. 编译为直接创建和更新 DOM 元素的命令式代码

  3. 运行时执行编译后的代码,响应式状态变化直接触发精确的 DOM 变更——无需 VNode 分配,无需树对比,无需补丁计算

2.2 与传统 VDOM 模式的本质区别

让我们通过一个简单组件来直观理解差异:

<!-- UserCard.vue -->
<template>
  <div class="user-card">
    <h2>{{ user.name }}</h2>
    <p>{{ user.bio }}</p>
    <span :class="{ online: user.isOnline }">
      {{ user.isOnline ? '在线' : '离线' }}
    </span>
  </div>
</template>

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

const user = reactive({
  name: '张三',
  bio: '前端工程师',
  isOnline: true
})
</script>

传统 Vue 编译输出(简化):

function render(_ctx) {
  return h("div", { class: "user-card" }, [
    h("h2", null, _ctx.user.name),
    h("p", null, _ctx.user.bio),
    h("span", { class: { online: _ctx.user.isOnline } },
      _ctx.user.isOnline ? '在线' : '离线'
    )
  ])
}

user.name 变化时,整个组件的渲染函数重新执行,产生新的 VNode 树,然后 diff 算法遍历两棵树,最终发现只有 <h2> 的文本节点需要更新。

Vapor Mode 编译输出(简化):

import { template, setText, effect } from 'vue/vapor'

const t0 = template('<div class="user-card"><h2></h2><p></p><span></span></div>')

function render(_ctx) {
  const el = t0()
  const [h2, p, span] = el.children
  
  // 静态内容只创建一次
  h2.textContent = _ctx.user.name
  p.textContent = _ctx.user.bio
  
  // 响应式绑定:每个状态只更新它影响的 DOM 节点
  effect(() => {
    h2.textContent = _ctx.user.name
  })
  effect(() => {
    p.textContent = _ctx.user.bio
  })
  effect(() => {
    span.textContent = _ctx.user.isOnline ? '在线' : '离线'
    span.classList.toggle('online', _ctx.user.isOnline)
  })
  
  return el
}

编译时,Vue 已经「知道」了每个响应式变量对应哪个 DOM 节点。运行时,当 user.name 变化时,只有 <h2> 的文本节点被更新,没有 VNode 分配,没有树遍历,没有 diff 计算。

2.3 技术定位

Vapor Mode 是一个 100% 可选(opt-in)的功能,不会破坏任何现有代码。Vue 官方明确表示:

Vapor Mode has demonstrated the same level of performance with Solid and Svelte 5 in 3rd party benchmarks.

这意味着 Vue 开发者现在可以在不换框架的情况下,获得与 Svelte 5、SolidJS 相当的运行时性能。

三、工作原理深度解析

3.1 编译时优化策略

Vapor 编译器在构建阶段完成以下几个关键任务:

模板静态分析

编译器会区分模板中的静态部分和动态部分。静态 HTML 结构只生成一次,存储在模板缓存中;只有动态绑定的部分才会生成响应式 effect。

依赖追踪

编译器分析每个响应式变量在模板中的使用位置,为每个绑定生成精确的更新函数。这种「编译时依赖追踪」避免了运行时 diffing 的开销。

DOM 引用提取

编译产物中包含对所有需要动态更新的 DOM 节点的直接引用(通过 el.childrenel.querySelector 等),而不是通过 VNode 间接访问。

3.2 响应式系统与 DOM 的直接绑定

Vapor Mode 的运行时使用 effect 函数建立响应式状态与 DOM 更新之间的精确映射:

// 当 count.value 变化时,只更新这个特定的文本节点
effect(() => {
  textNode.data = String(count.value)
})

每个 effect 都是独立的、更新的最小单元。相比传统模式中「组件重新渲染→生成完整 VNode 树→diff→补丁更新」,Vapor Mode 的更新链路缩短为:状态变化→触发精确 effect→更新特定 DOM 节点

3.3 Alien Signals:响应式系统的底层革新

Vue 3.6 不仅引入了 Vapor Mode,还同步重构了响应式系统的底层实现。新的 @vue/reactivity 包基于 Johnson Chu 开发的 alien-signals 库,采用 Push-Pull 混合算法,显著提升了响应式性能。

Push-Pull 算法的工作方式:

  • Push 阶段:响应式值变化时,只向依赖方推送「dirty(数据已过期)」通知,不立即重新计算

  • Pull 阶段:值被实际读取时,才触发真正的重新计算(惰性求值)

plaintext

[ref 值变化] → Push: dirty 通知 → [值被读取] → Pull: 执行重算

alien-signals 的实现特点:

  • 核心部分不使用 Array、Set、Map 等高成本数据结构

  • 采用链表等更轻量高效的结构

  • 排除递归调用,防止循环引用

性能提升数据(官方):

指标 Vue 3.5 Vue 3.6(alien-signals) 改善
内存使用量 基准值 -14% -14%
10 万组件挂载 - ~100ms -

关键是:这一切都是向后兼容的。你不需要改任何代码,只需升级到 Vue 3.6,就能享受 alien-signals 的性能提升。refcomputedwatcheffectScope 等 API 保持不变。

3.4 编译输出对比

让我们看一个包含更多场景的组件对比:

输入模板:

<template>
  <div class="list">
    <h1>{{ title }}</h1>
    <ul>
      <li v-for="item in items" :key="item.id">
        {{ item.name }} - {{ item.count }}
      </li>
    </ul>
    <button @click="addItem">添加</button>
  </div>
</template>

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

const title = ref('物品列表')
const items = ref([
  { id: 1, name: '苹果', count: 5 },
  { id: 2, name: '香蕉', count: 3 }
])

function addItem() {
  items.value.push({
    id: Date.now(),
    name: '新物品',
    count: 0
  })
}
</script>

传统 VDOM 编译思路:
每次 items 变化,生成新的 VNode 树 → diff 计算 → 对整个列表区域执行补丁更新。

Vapor Mode 编译思路:

  • title 变化 → 只更新 <h1> 文本

  • items 数组变化 → 通过列表渲染优化,只处理变化的行

  • 事件监听器直接绑定到按钮 DOM 节点

Vapor 编译器会生成类似以下的代码结构:

const t0 = template('<div class="list"><h1></h1><ul></ul><button></button></div>')
const t1 = template('<li></li>')

function render(_ctx) {
  const el = t0()
  const [h1, ul, button] = el.children
  
  // 静态设置
  h1.textContent = '物品列表'
  button.textContent = '添加'
  button.addEventListener('click', _ctx.addItem)
  
  // 响应式绑定
  effect(() => {
    h1.textContent = _ctx.title
  })
  
  // 列表渲染 - Vapor 专用指令
  _renderList(ul, _ctx.items, (item) => {
    const li = t1()
    effect(() => {
      setText(li, `${item.name} - ${item.count}`)
    })
    return li
  })
  
  return el
}

四、性能对比

4.1 官方基准测试数据

根据 Vue 官方发布的数据和第三方基准测试:

测试场景 Vue 3 + VDOM Vue 3.6 + Vapor Svelte 5 SolidJS
10,000 行表格首次渲染 247ms 185ms 192ms 145ms
更新 1,000 行数据 41ms 23ms 26ms 52ms
50 个复杂组件内存占用 18.7MB 12.4MB 11.8MB 14.1MB

关键发现:

  • Vapor Mode 首次渲染比传统 Vue 快约 25%,比 React 18 快约 30%

  • 部分更新场景下,Vapor Mode 比传统 Vue 快近 50%

  • 内存占用减少约 34%(相比传统 Vue)

4.2 js-framework-benchmark 结果

2026 年最新测试数据(综合多项基准测试):

框架 操作/秒 相对性能 基准包体积
Vanilla JS ~15,000 基准 0-5 KB
SolidJS ~14,800 99% 8.2 KB
Svelte 5 ~13,200 88% 12.1 KB
Vue 3.6 + Vapor ~11,200 75% <10 KB
Vue 3.6 默认 ~9,800 65% 34.3 KB
React 19 ~8,700 58% 42.5 KB

注意:Vue 3.6 + Vapor 的测试数据来自社区,随着编译器优化持续进行,性能还在不断提升中。

4.3 打包体积对比

这是 Vapor Mode 最直观的优势之一:

框架/配置 未压缩 Gzip 压缩后
Vue 3.6 + Vapor Mode ~40KB <10KB
Vue 3.6 默认 ~58KB ~22KB
React 19 ~72KB ~28KB
Svelte 5 ~28KB ~12KB

当你使用 createVaporApp 创建纯 Vapor 应用时,虚拟 DOM 运行时代码完全不会打包进产物,基础体积直接降到 10KB 以下。

4.4 性能提升的本质原因

  1. 消除 VNode 分配开销:每次渲染,传统模式都需要创建新的 JavaScript 对象来表示虚拟节点,Vapor Mode 直接操作 DOM,无此开销

  2. 消除 diff 计算:传统模式的 diffing 算法在最坏情况下是 O(n³),Vapor Mode 编译时已知更新目标,完全绕过 diffing

  3. 细粒度更新:只有实际依赖变化的 DOM 节点才会更新,组件级别的整体重渲染不复存在

  4. 内存优化:无需维护 VNode 树,GC 压力大幅降低

五、如何使用 Vapor Mode

5.1 安装 Vue 3.6 Beta

# 使用 npm
npm install vue@3.6.0-beta.1

# 或使用 yarn
yarn add vue@3.6.0-beta.1

# 或使用 pnpm
pnpm add vue@3.6.0-beta.1

如果你使用 Vite(推荐),确保 @vitejs/plugin-vue 也是最新版本:

npm install @vitejs/plugin-vue@latest vite@latest

5.2 组件级别开启方式

方式一:在 <script setup> 添加 vapor 属性

<!-- Counter.vue -->
<script setup vapor>
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}
</script>

<template>
  <div class="counter">
    <h2>计数器</h2>
    <p>当前值:{{ count }}</p>
    <button @click="increment">增加</button>
  </div>
</template>

<style scoped>
.counter {
  text-align: center;
  padding: 20px;
}

button {
  padding: 8px 16px;
  background: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

这是最简单的迁移方式——只需添加一个 vapor 属性即可。

5.3 全局配置选项

Vite 项目配置(vite.config.js/ts):

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    vue({
      // 可选:全局配置 Vapor Mode
      compilerOptions: {
        mode: 'vapor'  // 或在单个组件的 script setup 上指定
      }
    })
  ]
})

Vue CLI 项目配置(vue.config.js):

module.exports = {
  chainWebpack: config => {
    config.module
      .rule('vue')
      .use('vue-loader')
      .tap(options => {
        options.compilerOptions = {
          ...options.compilerOptions,
          mode: 'vapor'
        }
        return options
      })
  }
}

5.4 完整应用实例:两种创建方式

方式一:创建纯 Vapor 应用(推荐用于新项目)

// main.ts
import { createVaporApp } from 'vue'
import App from './App.vue'

createVaporApp(App).mount('#app')

这种方式下,虚拟 DOM 运行时代码不会被引入,基础包体积最小。

方式二:混合模式(渐进式迁移现有项目)

// main.ts
import { createApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'

createApp(App)
  .use(vaporInteropPlugin)  // 启用 Vapor 互操作
  .mount('#app')

安装 vaporInteropPlugin 后,你可以:

  • 在任意组件的 <script setup> 上添加 vapor 属性使其使用 Vapor 模式

  • Vapor 组件和 VDOM 组件可以相互嵌套

  • 逐步迁移性能敏感的组件,其他部分保持不变

5.5 TypeScript 类型支持

Vapor Mode 完整支持 TypeScript,所有现有类型定义都适用:

<script setup vapor lang="ts">
import { ref, computed } from 'vue'

interface User {
  id: number
  name: string
  email: string
}

const user = ref<User>({
  id: 1,
  name: '张三',
  email: 'zhangsan@example.com'
})

const displayName = computed(() => {
  return user.value.name.toUpperCase()
})

function updateName(newName: string) {
  user.value.name = newName
}
</script>

<template>
  <div class="user-profile">
    <h2>{{ displayName }}</h2>
    <p>邮箱:{{ user.email }}</p>
    <button @click="updateName('李四')">更改姓名</button>
  </div>
</template>

六、适用场景分析

6.1 最佳使用场景

Vapor Mode 在以下场景中能发挥最大价值:

静态内容为主的页面

  • 企业官网首页

  • 文档站点

  • 落地页

  • 博客文章页

这类页面初始化后几乎不需要动态更新,Vapor Mode 可以让它们以极低的 JS 开销运行。

性能敏感的高频更新组件

  • 数据仪表盘的核心数字展示

  • 实时股价/行情显示

  • 游戏计分系统

  • 聊天消息列表(高频滚动)

在高频更新场景下,Vapor Mode 的细粒度更新优势会被显著放大。

移动端 H5 页面

  • 首屏加载速度直接影响用户留存

  • 设备性能有限,减少 JS 解析量尤为重要

  • Vapor Mode 的 <10KB 基础包体积极具竞争力

列表渲染场景

  • 长列表(虚拟滚动列表)

  • 表格组件

  • 瀑布流布局

传统 VDOM 在列表更新时需要 diff 整棵树,Vapor Mode 只需处理实际变化的行。

6.2 不适合使用的情况

复杂动态结构的组件

如果组件的模板结构会根据条件大幅变化(不同的子组件、动态标签名等),Vapor 编译器的静态分析效果会打折扣。

大量使用第三方 UI 库

目前主流的 Vue UI 组件库(如 Element Plus、Ant Design Vue、Vuetify)尚未适配 Vapor Mode,直接在 Vapor 组件中使用会有限制。

重度依赖实例 API

以下 API 在 Vapor 组件中不可用或表现不同:

  • getCurrentInstance() → 返回 null

  • app.config.globalProperties → 不可用

  • onVueComponentMounted 等生命周期钩子 → 不支持

6.3 渐进式迁移策略

第一步:识别收益最大的组件

使用 Chrome DevTools 的 Performance 面板,找出 render 时间最长的组件,或者直接分析高频更新的交互区域。

第二步:从简单组件开始

先迁移不涉及复杂 props/slots 传递的独立组件,积累经验。

第三步:逐步扩大范围

当团队熟悉 Vapor 模式后,可以逐步覆盖更多组件。

第四步:评估混合边界

Vue 官方建议在应用中划分清晰的「Vapor 区域」和「VDOM 区域」,避免过度混合带来的复杂性。

七、注意事项与限制

7.1 兼容性问题

必须使用 <script setup>

Vapor Mode 只支持 <script setup> 语法,不支持:

  • 传统 Options API(data()methodscomputed 等)

  • 手动的 setup() 函数

如果你有大量 Options API 代码,需要先迁移到 Composition API。

不支持的功能清单

类别 功能 说明
API Options API 需迁移到 Composition API
API getCurrentInstance() Vapor 组件中返回 null
API app.config.globalProperties 不可用
API @vue:xxx 生命周期事件 不支持每个元素的生命周期钩子
渲染 渲染函数(Render Functions) 不支持 JSX
渲染 自定义渲染器 不支持
功能 Suspense(纯 Vapor) 不支持,但可在 VDOM Suspense 中渲染 Vapor 组件

7.2 自定义指令的新接口

Vapor Mode 中的自定义指令接口与 VDOM 模式不同:

// VDOM 模式
type Directive = (
  el: HTMLElement,
  binding: DirectiveBinding,
  vnode: VNode
) => void

// Vapor Mode
type VaporDirective = (
  node: Element | VaporComponentInstance,
  value?: () => any,      // 响应式 getter
  argument?: string,
  modifiers?: DirectiveModifiers
) => (() => void) | void  // 可选返回清理函数

关键区别:binding.value 变成了 value,它是一个响应式 getter 函数。使用示例:

// Vapor 模式下的自定义指令
const vFocus = (el, source) => {
  watchEffect(() => {
    if (source()) {
      el.focus()
    }
  })
  return () => console.log('cleanup')
}

7.3 调试工具支持

Vapor Mode 是新特性,Vue DevTools 和其他调试工具的 Vapor 相关支持还在完善中。预计在正式版发布后会有更好的调试体验。

7.4 生态兼容现状

目前适配良好的场景:

  • Vue 核心功能:refcomputedwatchreactiveprovide/inject

  • 条件渲染:v-ifv-show

  • 列表渲染:v-for(带 key)

  • 事件绑定:@click

  • 模板语法::class:style:src 等绑定

  • 过渡动画:TransitionTransitionGroup

需要等待适配的:

  • 第三方 UI 组件库(Element Plus、Ant Design Vue 等)

  • 某些依赖于 VDOM 实例 API 的库

  • SSR 框架集成(Nuxt 等)

7.5 已知限制

  • Vapor 插槽在 VDOM 组件中不能使用 slots.default(),必须使用 renderSlot

  • 动态组件 <component :is="..."> 在复杂场景下可能有限制

  • VDOM 组件库在 Vapor 模式下可能有兼容性问题

Vue 官方表示,随着版本迭代,这些限制会逐步解决。

八、与传统 VDOM 模式的选择指南

8.1 决策矩阵

维度 选择 Vapor Mode 选择 VDOM Mode
页面类型 静态为主、性能敏感 高度动态、交互复杂
包体积要求 极致的轻量化 允许一定开销
更新频率 高频细粒度更新 常规更新频率
UI 库依赖 使用原生 HTML/CSS 依赖第三方组件库
API 使用 纯 Composition API Options API 或混合
项目阶段 新项目 现有大型项目

8.2 迁移成本评估

从 VDOM 迁移到 Vapor 的成本:

因素 成本评估
语法变更 低(只需加 vapor 属性)
API 适配 中(Options API 需迁移)
组件重构 取决于组件复杂度
测试覆盖 高(需完整回归测试)
第三方库适配 高(取决于依赖情况)

推荐迁移路径:

现有项目:
  ↓ 新增组件用 Vapor Mode
  ↓ 识别高频更新组件 → 迁移
  ↓ 静态页面逐步迁移
  ↓ 评估并迁移核心功能组件

新项目:
  ↓ 选择 createVaporApp 或 createApp + plugin
  ↓ 全部使用 Vapor Mode
  ↓ 按需引入 VDOM 组件(通过 interop)

8.3 混合模式最佳实践

<!-- App.vue (VDOM 组件) -->
<script setup>
import Header from './components/Header.vue'
import Footer from './components/Footer.vue'
import Dashboard from './components/Dashboard.vue'  // Vapor 组件
import DataTable from './components/DataTable.vue' // Vapor 组件
</script>

<template>
  <div class="app">
    <Header />  <!-- VDOM -->
    <Dashboard />  <!-- Vapor:性能敏感的仪表盘 -->
    <DataTable />  <!-- Vapor:高频更新的数据表 -->
    <Footer />  <!-- VDOM -->
  </div>
</template>

关键是:识别瓶颈、精准优化,而不是盲目全部迁移。

九、总结与展望

9.1 Vue 的战略选择

Vapor Mode 代表了 Vue 团队的一次重要战略选择:不再追求虚拟 DOM 的极致优化,而是选择「消灭它」。这是一个有魄力的决定,因为:

  1. Vue 拥有全球数百万开发者,稳定性至关重要

  2. 渐进式迁移策略(opt-in)确保现有项目不受影响

  3. 与 alien-signals 的协同优化形成组合拳

9.2 前端渲染范式的演进

jQuery 时代 → 虚拟 DOM 时代 → 编译时优化时代
手动 DOM 操作   声明式 UI        直接 DOM 操作
            (Vue 2, React)    (Vue Vapor, Svelte, Solid)

虚拟 DOM 的历史使命是提供「声明式 UI + 高效更新」的平衡。随着编译器技术的发展,这个平衡可以由编译时完成,无需运行时开销。

9.3 展望

近期(2026 上半年):

  • Vue 3.6 正式版发布

  • Vapor Mode 稳定性提升

  • 主流 UI 库开始适配

中期:

  • Nuxt 等框架集成 Vapor Mode

  • DevTools 支持完善

  • 更多性能优化场景验证

长期:

  • Vapor Mode 可能成为新项目的默认选择

  • Vue 的性能标签从「易用但稍慢」升级为「易用且极致」

  • 推动行业进一步向编译时优化演进

9.4 给开发者的建议

  1. 保持关注:Vue 3.6 正式版发布时,是评估 Vapor Mode 的最佳时机

  2. 小范围试点:在非关键项目中尝试 Vapor Mode,积累第一手经验

  3. 优化意识:即使暂时不迁移Vapor Mode,理解其背后的编译优化思路也有助于写出更高效的 Vue 代码

  4. 拥抱变化:前端技术演进迅速,保持学习心态,享受框架进化带来的红利

Vapor Mode 不是噱头,它是 Vue 回应时代变化、追求技术极致的产物。当 Svelte 和 SolidJS 已经证明了「无虚拟 DOM」路线的可行性,Vue 选择加入这场变革——不是抛弃自己的特色,而是在保持 Vue 灵魂(优雅的 API、渐进式理念、绝佳的开发体验)的同时,补上了性能这块短板。

这场前端渲染技术的范式转移,正在发生。Vue 3.6,是一个重要的节点。

参考资料:

本文由AI辅助整理

重新学习前端之Linux

Linux

一、Linux 基础命令

1. Linux 基础命令概述

定义: Linux 基础命令是在 Linux 终端中执行的基本操作指令,用于文件系统管理、进程控制、网络配置等日常系统管理任务。

原理: Linux 命令本质上是可执行程序,通常位于 /bin/usr/bin/sbin 等目录中。当用户在终端输入命令时,Shell 会按照 $PATH 环境变量中定义的目录顺序查找对应的可执行文件并执行。

示例:

# 查看当前路径
pwd
# 输出: /home/user

# 列出当前目录内容
ls -la

常见误区:

  • 误以为命令是 Shell 内置的,实际上大多数命令是外部程序
  • 混淆 man 命令和 help 命令的使用场景
  • 不熟悉命令的参数缩写规则(如 -l--long

2. ls - 列出目录内容

定义: ls (list) 命令用于列出目录中的文件和子目录信息。

常用参数:

ls          # 基本列表
ls -l       # 详细信息(权限、所有者、大小、时间)
ls -a       # 显示隐藏文件(以.开头的文件)
ls -h       # 人类可读的文件大小
ls -t       # 按修改时间排序
ls -R       # 递归显示子目录
ls -la      # 组合使用:详细显示所有文件

输出解析:

drwxr-xr-x 2 user group 4096 Mar 15 10:30 Documents
-rw-r--r-- 1 user group  256 Mar 15 10:31 file.txt
  • 第一列:文件类型和权限(d表示目录,-表示普通文件)
  • 第二列:硬链接数
  • 第三列:文件所有者
  • 第四列:所属组
  • 第五列:文件大小(字节)
  • 第六至八列:最后修改时间
  • 第九列:文件名

常见误区:

  • 忘记 -a 参数会遗漏隐藏文件(如 .bashrc
  • 误认为文件大小包含目录内容(目录显示的4096是目录本身大小)

3. cd - 切换目录

定义: cd (change directory) 命令用于切换当前工作目录。

使用方式:

cd /home/user        # 切换到绝对路径
cd ../parent         # 切换到父目录
cd ~                 # 切换到用户主目录
cd -                 # 切换到上一次所在目录
cd                   # 无参数时切换到主目录

原理: cd 是 Shell 内置命令,通过修改当前 Shell 进程的 $PWD 环境变量实现。

常见误区:

  • cd - 会打印切换后的路径,方便确认
  • 使用相对路径时,基准目录是当前工作目录而非主目录

4. pwd - 显示当前目录

定义: pwd (print working directory) 命令用于显示当前工作目录的完整路径。

pwd          # /home/user/projects
pwd -P       # 显示物理路径(解析符号链接)
pwd -L       # 显示逻辑路径(包含符号链接)

5. mkdir - 创建目录

定义: mkdir (make directory) 命令用于创建新目录。

mkdir newdir                    # 创建单个目录
mkdir -p a/b/c                  # 递归创建多级目录
mkdir -m 755 newdir             # 指定权限创建
mkdir dir1 dir2 dir3            # 同时创建多个目录

常见误区:

  • 不使用 -p 参数时,父目录不存在会报错
  • -m 参数使用八进制数字指定权限

6. rmdir - 删除空目录

定义: rmdir (remove directory) 命令用于删除空目录

rmdir emptydir                  # 删除空目录
rmdir -p a/b/c                  # 递归删除空目录(连同父目录)

注意: 如果目录非空,会报错。删除非空目录使用 rm -r


7. rm - 删除文件或目录

定义: rm (remove) 命令用于删除文件或目录。

rm file.txt                     # 删除文件
rm -r directory                 # 递归删除目录
rm -f file.txt                  # 强制删除(不提示)
rm -rf directory                # 强制递归删除
rm -i file.txt                  # 删除前逐个确认

常见误区:

  • rm -rf /* 是极其危险的命令,会删除系统所有文件
  • 使用 -i 参数可以防止误删重要文件
  • 删除的文件无法直接恢复(需要通过专业工具或备份)

8. cp - 复制文件或目录

定义: cp (copy) 命令用于复制文件或目录。

cp source.txt dest.txt          # 复制文件
cp -r sourcedir destdir         # 递归复制目录
cp -i source.txt dest.txt       # 覆盖前提示确认
cp -a source dest               # 保留所有属性(归档模式)
cp -v source dest               # 显示复制过程

常见误区:

  • 复制目录必须使用 -r-R 参数
  • 目标位置存在同名文件会被覆盖(除非使用 -i

9. mv - 移动或重命名

定义: mv (move) 命令用于移动文件或重命名文件。

mv old.txt new.txt              # 重命名
mv file.txt /path/to/dir/       # 移动到目录
mv -i source dest               # 覆盖前提示
mv -n source dest               # 不覆盖已存在的文件

原理: 在同一文件系统内移动文件实际只修改目录项(速度快),跨文件系统移动等同于复制+删除。


10. touch - 创建或更新文件时间戳

定义: touch 命令用于创建空文件或更新文件的时间戳。

touch newfile.txt               # 创建空文件
touch -t 202403011200 file.txt  # 修改时间为指定值
touch -a file.txt               # 只更新访问时间
touch -m file.txt               # 只修改修改时间

常见误区:

  • touch 不会覆盖已存在的文件内容
  • 文件已存在时只更新时间戳

11. cat - 查看文件内容

定义: cat (concatenate) 命令用于查看、合并文件内容。

cat file.txt                    # 查看文件
cat -n file.txt                 # 显示行号
cat -b file.txt                 # 非空行显示行号
cat file1.txt file2.txt         # 合并多个文件
cat file1.txt file2.txt > combined.txt  # 合并输出到新文件
cat > newfile.txt << EOF        # 创建文件(多行输入)
EOF

常见误区:

  • 不适合查看大文件(会一次性加载到终端)
  • 查看大文件应使用 lessmore

12. more - 分页查看文件

定义: more 命令用于分页查看文件内容。

more file.txt                   # 分页查看
more -10 file.txt               # 每页显示10行

操作按键:

  • 空格键:向下翻页
  • Enter:向下滚动一行
  • q:退出

常见误区:

  • more 只能向下翻页,不能回退(less 可以双向翻页)

13. less - 分页查看文件(增强版)

定义: lessmore 的增强版,支持双向翻页和搜索。

less file.txt                   # 分页查看
less -N file.txt                # 显示行号
less +/pattern file.txt         # 打开时搜索模式

操作按键:

  • 空格键:向下翻页
  • b:向上翻页
  • /pattern:向下搜索
  • ?pattern:向上搜索
  • n:下一个匹配
  • N:上一个匹配
  • q:退出
  • G:跳转到末尾
  • g:跳转到开头

最佳实践: 查看日志文件优先使用 less


14. head - 查看文件开头

定义: head 命令用于查看文件开头部分内容。

head file.txt                   # 默认显示前10行
head -n 20 file.txt             # 显示前20行
head -c 100 file.txt            # 显示前100个字节

15. tail - 查看文件末尾

定义: tail 命令用于查看文件末尾部分内容。

tail file.txt                   # 默认显示最后10行
tail -n 20 file.txt             # 显示最后20行
tail -f logfile.log             # 实时跟踪文件变化
tail -F logfile.log             # 实时跟踪(支持日志轮转)
tail -n +100 file.txt           # 从第100行开始显示

最佳实践: 查看日志使用 tail -f 实时监控


16. find - 搜索文件

定义: find 命令用于在目录树中搜索文件。

find /path -name "file.txt"                     # 按名称搜索
find /path -type f -name "*.log"                # 搜索所有.log文件
find /path -size +100M                          # 搜索大于100MB的文件
find /path -mtime -7                            # 搜索7天内修改的文件
find /path -perm 644                            # 搜索权限为644的文件
find /path -user username                       # 搜索特定用户的文件
find /path -exec rm {} \;                       # 对搜索结果执行命令
find /path -name "*.tmp" -delete                # 搜索并删除
find /path -type f -empty                       # 查找空文件

常用选项:

  • -name:按文件名搜索(区分大小写)
  • -iname:按文件名搜索(不区分大小写)
  • -type:按类型搜索(f:文件, d:目录, l:链接)
  • -size:按大小搜索(+大于, -小于)
  • -mtime:按修改时间搜索(天数)
  • -atime:按访问时间搜索
  • -ctime:按状态改变时间搜索
  • -exec:对每个搜索结果执行命令

常见误区:

  • -exec 命令末尾必须有 \;+
  • -mtime +7 表示7天前,-mtime -7 表示7天内

17. locate - 快速查找文件

定义: locate 命令通过数据库快速查找文件路径。

locate file.txt                 # 查找包含file.txt的路径
locate -i FILE.TXT              # 不区分大小写
sudo updatedb                   # 更新数据库

原理: locate 使用 updatedb 创建的数据库进行搜索,速度极快但结果可能不是最新的。

对比 find

  • locate:速度快,但依赖数据库,结果可能不是最新的
  • find:实时搜索,速度慢但结果准确

18. whereis - 查找命令位置

定义: whereis 命令用于查找命令的二进制文件、源代码和手册页位置。

whereis ls                      # ls: /bin/ls /usr/share/man/man1/ls.1.gz
whereis -b ls                   # 只显示二进制文件
whereis -m ls                   # 只显示手册页

19. which - 查找命令路径

定义: which 命令用于查找命令的完整路径(按 $PATH 顺序)。

which ls                        # /bin/ls
which python                    # /usr/bin/python

对比 whereis

  • which:只查找可执行文件,按 $PATH 顺序
  • whereis:查找二进制、源码和手册页

20. echo - 输出文本

定义: echo 命令用于输出文本到终端或文件。

echo "Hello World"              # 输出文本
echo $PATH                      # 输出变量值
echo -e "Hello\nWorld"          # 启用转义字符
echo "text" > file.txt          # 输出到文件(覆盖)
echo "text" >> file.txt         # 追加到文件

常见误区:

  • 双引号内变量会被展开,单引号内变量不会被展开
  • > 覆盖文件,>> 追加文件

21. printf - 格式化输出

定义: printf 命令用于格式化输出文本(类似 C 语言的 printf)。

printf "Name: %s, Age: %d\n" "John" 25
printf "%-10s %5d\n" "John" 25          # 左对齐
printf "0x%04x\n" 255                   # 十六进制输出

22. clear - 清屏

定义: clear 命令用于清空终端屏幕。

clear                           # 清屏
Ctrl + L                        # 快捷键(部分终端)

23. history - 查看命令历史

定义: history 命令用于查看之前执行过的命令历史。

history                         # 显示所有历史命令
history 10                      # 显示最近10条命令
!n                              # 执行第n条历史命令
!!                              # 执行上一条命令
!ls                             # 执行最近一次ls命令
Ctrl + R                        # 搜索历史命令
history -c                      # 清除历史记录

24. man - 查看手册页

定义: man (manual) 命令用于查看命令的手册页。

man ls                          # 查看ls的手册
man -k keyword                  # 搜索手册(同apropos)
man 2 open                      # 查看系统调用open的手册

手册章节:

  1. 用户命令
  2. 系统调用
  3. 库函数
  4. 特殊文件
  5. 文件格式
  6. 游戏
  7. 杂项
  8. 系统管理命令

25. help - 查看内置命令帮助

定义: help 命令用于查看 Shell 内置命令的帮助信息。

help cd                         # 查看cd命令帮助
help echo                       # 查看echo命令帮助

对比 man

  • help:查看 Shell 内置命令的帮助
  • man:查看外部命令的手册页

二、文件系统与权限

26. Linux 文件系统

定义: Linux 文件系统是用于组织和管理磁盘上数据的结构和规则。

常见文件系统类型:

  • ext4:第四代扩展文件系统,Linux 默认文件系统
  • XFS:高性能日志文件系统,适合大文件
  • Btrfs:支持快照、压缩的现代文件系统
  • NTFS:Windows 文件系统(Linux 可读写)
  • FAT32:通用文件系统,兼容性最好

原理: 文件系统通过 inode 存储文件元数据(权限、大小、时间等),通过数据块存储实际内容。

查看文件系统:

df -T                           # 查看文件系统类型
lsblk -f                        # 查看块设备文件系统
blkid                           # 查看块设备属性

27. 文件权限

定义: Linux 文件权限控制着不同用户对文件的访问能力。

权限类型:

  • r (read):读权限(文件:可查看内容;目录:可列出内容)
  • w (write):写权限(文件:可修改内容;目录:可创建/删除文件)
  • x (execute):执行权限(文件:可作为程序执行;目录:可进入目录)

权限分组:

-rwxr-xr-- 1 user group 4096 Mar 15 10:30 file.txt
  • 所有者(user/owner):前3位 rwx
  • 所属组(group):中3位 r-x
  • 其他用户(others):后3位 r--

权限数字表示:

  • r = 4
  • w = 2
  • x = 1
  • 755 = rwxr-xr-x(所有者全权限,组和其他用户读执行)
  • 644 = rw-r--r--(所有者读写,组和其他只读)

特殊权限:

  • SUID (4):执行时以文件所有者身份运行
  • SGID (2):执行时以文件所属组身份运行;目录中新文件继承目录组
  • Sticky Bit (1):目录中只有文件所有者能删除文件(如 /tmp
chmod 4755 file                 # 设置SUID
chmod 2755 dir                  # 设置SGID
chmod 1777 /tmp                 # 设置Sticky Bit

28. chmod - 修改权限

定义: chmod (change mode) 命令用于修改文件或目录的权限。

chmod 755 file.txt              # 数字方式设置权限
chmod u+x file.txt              # 给所有者添加执行权限
chmod go-w file.txt             # 移除组和其他用户的写权限
chmod a+r file.txt              # 给所有用户添加读权限
chmod -R 755 directory          # 递归修改目录权限

符号模式:

  • u:所有者(user)
  • g:所属组(group)
  • o:其他用户(others)
  • a:所有用户(all)
  • +:添加权限
  • -:移除权限
  • =:设置权限

29. chown - 修改所有者

定义: chown (change owner) 命令用于修改文件或目录的所有者。

chown user file.txt             # 修改所有者
chown user:group file.txt       # 同时修改所有者和组
chown :group file.txt           # 只修改组
chown -R user directory         # 递归修改目录所有者

30. chgrp - 修改所属组

定义: chgrp (change group) 命令用于修改文件或目录的所属组。

chgrp group file.txt            # 修改所属组
chgrp -R group directory        # 递归修改目录所属组

31. 文件类型

定义: Linux 中文件类型用于区分不同性质的文件。

常见类型:

  • -:普通文件(文本、二进制、压缩包等)
  • d:目录(文件夹)
  • l:符号链接(软链接)
  • c:字符设备文件(如 /dev/null
  • b:块设备文件(如 /dev/sda
  • p:命名管道(FIFO)
  • s:套接字文件

查看文件类型:

ls -l                           # 通过第一列第一个字符识别
file filename                   # 详细显示文件类型

32. 目录结构

定义: Linux 采用树状目录结构组织文件系统,根目录为 /

重要目录:

/               # 根目录
/bin            # 基本用户命令(二进制)
/sbin           # 系统管理员命令
/etc            # 系统配置文件
/home           # 用户主目录
/root           # root用户主目录
/var            # 可变数据(日志、缓存等)
/tmp            # 临时文件
/usr            # 用户程序和数据
/opt            # 可选软件包
/dev            # 设备文件
/proc           # 进程信息(虚拟文件系统)
/sys            # 系统信息(虚拟文件系统)
/boot           # 启动文件
/lib            # 系统库文件
/media          # 可移动媒体挂载点
/mnt            # 临时挂载点

FHS(文件系统层次结构标准): 规范了 Linux 目录的用途和内容。


33-34. 文件路径与绝对路径

定义: 路径是文件或目录在文件系统中的位置标识。

绝对路径: 从根目录 / 开始的完整路径。

/home/user/documents/file.txt   # 绝对路径(始终以/开头)

特点:

  • 始终以 / 开头
  • 在任何位置都有效
  • 完整描述文件位置

35. 相对路径

定义: 相对于当前工作目录的路径。

./file.txt                      # 当前目录下的文件
../parent/file.txt              # 父目录下的文件
../../grandparent/file.txt      # 祖父目录下的文件

特殊符号:

  • .:当前目录
  • ..:父目录
  • ~:用户主目录
  • -:上一次所在目录

36-38. 软链接、硬链接与 ln 命令

定义: 链接是指向另一个文件的引用。

软链接(符号链接):

ln -s /path/to/original /path/to/link     # 创建软链接
  • 类似 Windows 的快捷方式
  • 有自己的 inode
  • 指向另一个文件路径
  • 可以跨越文件系统
  • 源文件删除后链接失效

硬链接:

ln /path/to/original /path/to/link        # 创建硬链接
  • 与原文件共享同一个 inode
  • 不能跨文件系统
  • 不能链接目录
  • 源文件删除后仍可访问(通过硬链接)
  • 删除最后一个链接才会真正删除文件

对比:

特性 软链接 硬链接
inode 不同 相同
跨文件系统 支持 不支持
链接目录 支持 不支持
源文件删除 失效 仍可访问
文件大小 路径长度 与原文件相同
命令 ln -s ln

查看链接:

ls -l                         # 软链接显示 -> 指向
stat file                     # 查看inode信息

39. 文件属性

定义: 文件属性包括权限、所有者、时间戳、大小等元数据。

查看属性:

ls -l file                    # 基本属性
stat file                     # 详细属性

属性信息:

  • 文件名
  • 文件大小
  • 文件类型
  • 权限模式
  • 所有者和组
  • 硬链接数
  • inode 号
  • 访问时间(atime)
  • 修改时间(mtime)
  • 状态改变时间(ctime)

40. inode

定义: inode(索引节点)是 Linux 文件系统中存储文件元数据的数据结构。

存储内容:

  • 文件大小
  • 文件权限
  • 所有者和组
  • 时间戳(atime, mtime, ctime)
  • 文件类型
  • 指向数据块的指针

不包含: 文件名(文件名存储在目录项中)

查看 inode:

ls -i file                    # 显示inode号
df -i                         # 查看inode使用情况
stat file                     # 详细inode信息

常见误区:

  • 删除文件实际是删除目录项,减少inode引用计数
  • inode 耗尽即使磁盘有空间也无法创建新文件
  • 硬链接共享同一个 inode

三、进程管理

41. 进程

定义: 进程是正在执行的程序实例,是操作系统资源分配的基本单位。

进程状态:

  • 运行态(Running):正在执行或准备执行
  • 睡眠态(Sleeping):等待某个事件或资源
    • S:可中断睡眠
    • D:不可中断睡眠(通常等待I/O)
  • 停止态(Stopped):被信号暂停
  • 僵尸态(Zombie):已终止但父进程尚未回收
  • 死亡态(Dead):即将被销毁

进程属性:

  • PID(进程ID)
  • PPID(父进程ID)
  • 状态
  • 优先级
  • 内存占用
  • CPU 占用
  • 运行时间

42. 进程管理

定义: 进程管理包括查看、控制、终止进程等操作。

管理方式:

  • 查看进程:pstophtop
  • 发送信号:killkillallpkill
  • 调整优先级:nicerenice
  • 前后台切换:jobsfgbg
  • 守护进程:systemdservice

43. ps - 查看进程

定义: ps (process status) 命令用于查看当前进程的快照。

ps                              # 查看当前终端进程
ps aux                          # 查看所有进程(BSD格式)
ps -ef                          # 查看所有进程(标准格式)
ps -ef | grep nginx             # 查找特定进程
ps -p 1234                      # 查看指定PID
ps -u username                  # 查看特定用户的进程
ps --sort=-%mem                 # 按内存使用排序

输出字段(ps aux):

  • USER:所有者
  • PID:进程ID
  • %CPU:CPU使用率
  • %MEM:内存使用率
  • VSZ:虚拟内存大小
  • RSS:物理内存大小
  • TTY:关联终端
  • STAT:进程状态
  • START:启动时间
  • TIME:CPU时间
  • COMMAND:命令

44. top - 实时进程监控

定义: top 命令用于实时显示系统进程状态和资源使用情况。

top                             # 启动top
top -u username                 # 查看特定用户进程
top -p 1234                     # 监控指定PID
top -d 2                        # 每2秒刷新

交互按键:

  • P:按CPU使用率排序
  • M:按内存使用率排序
  • q:退出
  • k:终止进程
  • c:显示完整命令路径
  • h:帮助

输出信息:

  • 系统运行时间、负载
  • 进程总数
  • CPU使用率
  • 内存使用情况
  • 进程列表

45. htop - 增强版进程监控

定义: htoptop 的增强版,提供更友好的交互界面。

htop                            # 启动htop
htop -u username                # 查看特定用户进程

优势:

  • 彩色显示
  • 支持鼠标操作
  • 树状视图(F5)
  • 更直观的资源使用条
  • 支持搜索(F3)

46. kill - 发送信号

定义: kill 命令用于向进程发送信号(常用于终止进程)。

kill PID                        # 默认发送SIGTERM(15)
kill -9 PID                     # 发送SIGKILL(强制终止)
kill -15 PID                    # 发送SIGTERM(优雅终止)
kill -1 PID                     # 发送SIGHUP(重新加载配置)
kill -l                         # 列出所有信号

常用信号:

信号 编号 说明
SIGHUP 1 挂起信号,常用于重新加载配置
SIGINT 2 中断信号(Ctrl+C)
SIGKILL 9 强制终止(不可捕获)
SIGTERM 15 优雅终止(默认)
SIGSTOP 19 停止进程
SIGCONT 18 继续进程

47. killall - 按名称终止进程

定义: killall 命令用于通过进程名终止所有匹配的进程。

killall nginx                   # 终止所有nginx进程
killall -9 nginx                # 强制终止
killall -u username             # 终止特定用户的所有进程

48. pkill - 按模式终止进程

定义: pkill 命令用于通过进程名模式匹配终止进程。

pkill nginx                     # 终止名称包含nginx的进程
pkill -f "python app.py"        # 按完整命令行匹配
pkill -u username               # 按用户匹配

对比:

  • kill:需要 PID
  • killall:精确匹配进程名
  • pkill:模式匹配进程名

49. nice - 以指定优先级启动进程

定义: nice 命令用于以指定的优先级启动进程。

nice -n 10 command              # 以优先级10启动
nice -n -5 command              # 以高优先级启动(需要root)

优先级范围: -20(最高)到 19(最低),默认值为 0。


50. renice - 修改运行中进程的优先级

定义: renice 命令用于修改正在运行的进程的优先级。

renice -n 10 -p PID             # 修改进程优先级
renice -n 5 -u username         # 修改特定用户所有进程

51. nohup - 忽略挂起信号

定义: nohup (no hang up) 命令使命令在终端关闭后继续运行。

nohup command &                 # 后台运行,忽略挂起信号
nohup command > output.log 2>&1 &   # 重定向输出

原理: 忽略 SIGHUP 信号,输出默认重定向到 nohup.out


52. & - 后台运行

定义: 在命令末尾添加 & 使进程在后台运行。

command &                       # 后台运行
nohup command &                 # 后台运行且忽略挂起信号

53. jobs - 查看后台任务

定义: jobs 命令用于查看当前终端的后台任务列表。

jobs                            # 列出后台任务
jobs -l                         # 显示详细信息(含PID)

54. fg - 切换到前台

定义: fg (foreground) 命令将后台任务切换到前台运行。

fg                              # 恢复最近一个后台任务到前台
fg %1                           # 恢复任务1到前台

55. bg - 后台运行

定义: bg (background) 命令使停止的任务在后台继续运行。

bg                              # 继续最近一个停止的任务在后台
bg %1                           # 继续任务1在后台

56. 守护进程

定义: 守护进程(Daemon)是在后台运行、不与终端关联的长期运行的进程。

特点:

  • 在后台运行
  • 不与终端关联
  • 通常以 d 结尾命名(如 sshdnginx
  • 系统启动时自动启动

常见守护进程:

  • sshd:SSH 服务
  • crond:定时任务
  • systemd:系统初始化
  • httpd/nginx:Web 服务

57. systemd - 系统和服务管理器

定义: systemd 是现代 Linux 发行版的系统和服务管理器。

常用命令:

systemctl status service        # 查看服务状态
systemctl start service         # 启动服务
systemctl stop service          # 停止服务
systemctl restart service       # 重启服务
systemctl reload service        # 重载配置
systemctl enable service        # 开机自启
systemctl disable service       # 取消开机自启
systemctl is-enabled service    # 检查是否开机自启
systemctl list-units            # 列出所有单元
systemctl list-unit-files       # 列出所有单元文件
journalctl -u service           # 查看服务日志

58. service - 管理系统服务

定义: service 命令用于管理系统服务(旧式 SysV init)。

service nginx start             # 启动服务
service nginx stop              # 停止服务
service nginx restart           # 重启服务
service nginx status            # 查看状态

注意: 现代系统推荐使用 systemctl 替代 service


四、网络配置

59. 网络配置

定义: Linux 网络配置涉及网络接口的设置、IP 地址分配、路由配置等。

配置文件:

  • /etc/network/interfaces(Debian/Ubuntu)
  • /etc/sysconfig/network-scripts/(CentOS/RHEL)
  • /etc/resolv.conf(DNS 配置)
  • /etc/hosts(主机名映射)

现代工具:

  • ip 命令替代 ifconfig
  • ss 命令替代 netstat

60. ifconfig - 网络接口配置

定义: ifconfig (interface configuration) 命令用于配置和查看网络接口。

ifconfig                        # 显示所有活动接口
ifconfig eth0                   # 显示eth0接口
ifconfig eth0 up                # 启用接口
ifconfig eth0 down              # 禁用接口
ifconfig eth0 192.168.1.100     # 设置IP地址

注意: ifconfig 已废弃,推荐使用 ip 命令。


61. ip - 网络管理命令

定义: ipifconfig 的现代替代工具,功能更强大。

ip addr                         # 显示IP地址
ip link                         # 显示网络接口
ip route                        # 显示路由表
ip addr add 192.168.1.100/24 dev eth0   # 添加IP地址
ip link set eth0 up                     # 启用接口
ip route add default via 192.168.1.1    # 添加默认路由

常用子命令:

  • ip addr:管理 IP 地址
  • ip link:管理网络接口
  • ip route:管理路由表

62. ping - 测试网络连通性

定义: ping 命令用于测试与目标主机的网络连通性。

ping google.com                 # 持续ping
ping -c 5 google.com            # ping 5次后停止
ping -i 2 google.com            # 每2秒ping一次
ping -s 64 google.com           # 指定数据包大小

原理: 使用 ICMP Echo Request/Echo Reply 报文。


63. netstat - 网络统计

定义: netstat (network statistics) 命令用于显示网络连接、路由表、接口统计等。

netstat -tlnp                   # 查看监听的TCP端口
netstat -ulnp                   # 查看监听的UDP端口
netstat -anp                    # 查看所有连接
netstat -s                      # 查看统计信息
netstat -rn                     # 查看路由表

常用参数:

  • -t:TCP 连接
  • -u:UDP 连接
  • -l:仅监听
  • -n:数字显示(不解析主机名)
  • -p:显示进程
  • -a:所有连接
  • -r:路由表

64. ss - 查看套接字统计

定义: ss (socket statistics) 是 netstat 的现代替代工具。

ss -tlnp                        # 查看监听的TCP端口
ss -ulnp                        # 查看监听的UDP端口
ss -anp                         # 查看所有连接
ss -s                           # 查看统计信息

优势:netstat 更快,支持更多功能。


65. telnet - 远程登录

定义: telnet 命令用于远程登录和测试端口连通性。

telnet host port                # 连接远程主机
telnet localhost 80             # 测试80端口

注意: telnet 传输不加密,推荐使用 ssh 替代。常用于测试端口连通性。


66. curl - 命令行 HTTP 客户端

定义: curl 命令用于通过 URL 语法传输数据。

curl https://example.com        # 获取网页
curl -O https://example.com/file    # 下载文件(保持原名)
curl -o file https://example.com    # 下载文件(指定文件名)
curl -I https://example.com         # 只获取响应头
curl -X POST https://example.com    # POST请求
curl -d "data=value" https://example.com    # POST数据
curl -H "Authorization: Bearer token" https://example.com    # 添加请求头

67. wget - 命令行下载工具

定义: wget 命令用于从网络下载文件。

wget https://example.com/file       # 下载文件
wget -O output https://example.com  # 指定输出文件名
wget -c https://example.com/file    # 断点续传
wget -r https://example.com         # 递归下载
wget -i urls.txt                    # 从文件读取URL下载

对比 curl

  • curl:支持更多协议,默认输出到 stdout
  • wget:支持递归下载,默认保存到文件

68. ssh - 安全远程登录

定义: ssh (Secure Shell) 命令用于安全地远程登录到服务器。

ssh user@host                     # 登录远程主机
ssh -p 2222 user@host             # 指定端口
ssh -i key.pem user@host          # 使用密钥登录
ssh user@host "command"           # 执行远程命令
ssh -L 8080:localhost:80 user@host    # 本地端口转发
ssh -R 8080:localhost:80 user@host    # 远程端口转发

配置免密登录:

ssh-keygen                        # 生成密钥对
ssh-copy-id user@host             # 复制公钥到远程主机

69. scp - 安全复制

定义: scp (secure copy) 命令用于通过 SSH 安全地复制文件。

scp file.txt user@host:/path/     # 上传文件
scp user@host:/path/file.txt ./   # 下载文件
scp -r dir user@host:/path/       # 递归复制目录
scp -P 2222 file.txt user@host:/path/   # 指定端口

70. rsync - 远程同步

定义: rsync 命令用于高效地同步文件和目录。

rsync -av source/ dest/           # 本地同步
rsync -av source/ user@host:/dest/    # 同步到远程
rsync -avz user@host:/src/ dest/  # 压缩传输
rsync -av --delete source/ dest/  # 删除目标多余文件
rsync -av --exclude "*.log" source/ dest/   # 排除文件

常用参数:

  • -a:归档模式(保留权限、时间等)
  • -v:详细输出
  • -z:压缩传输
  • -P:显示进度并支持断点续传

优势: 只传输变化的部分,效率高。


71-74. 防火墙管理

定义: Linux 防火墙用于控制网络流量进出系统。

iptables:

iptables -L                       # 查看规则
iptables -A INPUT -p tcp --dport 80 -j ACCEPT     # 允许80端口
iptables -A INPUT -p tcp --dport 443 -j ACCEPT    # 允许443端口
iptables -A INPUT -j DROP                         # 拒绝所有入站

firewalld(CentOS/RHEL):

firewall-cmd --list-all           # 查看配置
firewall-cmd --add-port=80/tcp    # 添加端口
firewall-cmd --reload             # 重载配置
firewall-cmd --permanent --add-port=80/tcp        # 永久添加

ufw(Ubuntu):

ufw status                        # 查看状态
ufw enable                        # 启用防火墙
ufw allow 80/tcp                  # 允许80端口
ufw deny 22/tcp                   # 拒绝22端口
ufw delete allow 80/tcp           # 删除规则

对比:

工具 发行版 特点
iptables 通用 底层、功能强大、配置复杂
firewalld CentOS/RHEL 动态管理、支持区域
ufw Ubuntu/Debian 简单易用、基于iptables

75. 端口管理

定义: 端口是网络通信的端点,用于区分不同服务。

常用端口:

  • 22:SSH
  • 80:HTTP
  • 443:HTTPS
  • 3306:MySQL
  • 5432:PostgreSQL
  • 6379:Redis
  • 8080:HTTP 代理

查看端口:

ss -tlnp                        # 查看监听端口
netstat -tlnp                   # 查看监听端口
lsof -i :80                     # 查看80端口占用

五、Shell 脚本

76. Shell

定义: Shell 是 Linux 的命令行解释器,用于接收用户输入的命令并执行。

常见 Shell:

  • Bash(Bourne Again Shell):最常用,大多数发行版默认 Shell
  • Zsh(Z Shell):功能强大,支持插件
  • sh(Bourne Shell):早期标准 Shell
  • Fish:友好交互的 Shell

查看当前 Shell:

echo $SHELL                     # 查看当前Shell
cat /etc/shells                 # 查看系统可用的Shell

77. Shell 脚本

定义: Shell 脚本是将一系列命令保存到文件中,按顺序执行的程序。

基本结构:

#!/bin/bash                     # Shebang(指定解释器)

# 注释
echo "Hello World"              # 输出

# 变量
NAME="John"
echo "Hello $NAME"

# 条件判断
if [ -f "file.txt" ]; then
    echo "File exists"
elif [ -d "file.txt" ]; then
    echo "Is directory"
else
    echo "Not found"
fi

# 循环
for i in 1 2 3; do
    echo $i
done

# 函数
my_function() {
    echo "Function called"
}
my_function

执行方式:

./script.sh                     # 需要执行权限
bash script.sh                  # 不需要执行权限
source script.sh                # 在当前Shell执行
. script.sh                     # 同source

78. Bash

定义: Bash 是 GNU 项目的 Shell,是 sh 的增强版。

特性:

  • 命令补全(Tab)
  • 命令历史
  • 别名
  • 变量
  • 条件判断
  • 循环
  • 函数
  • 管道
  • 重定向

79. Shell 变量

定义: Shell 变量是存储数据的容器。

变量类型:

  • 环境变量:全局变量,对所有进程可见
  • 局部变量:仅在当前 Shell 可见
# 定义变量
NAME="John"
AGE=25

# 使用变量
echo $NAME
echo ${NAME}

# 环境变量
export PATH="/usr/local/bin:$PATH"

# 特殊变量
$0          # 脚本名
$1, $2...   # 参数
$#          # 参数个数
$@          # 所有参数
$?          # 上一个命令的退出状态
$$          # 当前进程PID
$!          # 最后一个后台进程PID

80. Shell 条件判断

定义: 条件判断用于根据条件执行不同的代码块。

文件测试:

[ -f file ]       # 文件存在
[ -d dir ]        # 目录存在
[ -e path ]       # 路径存在
[ -r file ]       # 可读
[ -w file ]       # 可写
[ -x file ]       # 可执行
[ -s file ]       # 非空文件

字符串比较:

[ "$a" = "$b" ]     # 相等
[ "$a" != "$b" ]    # 不等
[ -z "$a" ]         # 空字符串
[ -n "$a" ]         # 非空字符串

数值比较:

[ $a -eq $b ]       # 等于
[ $a -ne $b ]       # 不等于
[ $a -gt $b ]       # 大于
[ $a -lt $b ]       # 小于
[ $a -ge $b ]       # 大于等于
[ $a -le $b ]       # 小于等于

逻辑运算:

[ $a -gt 0 ] && [ $a -lt 10 ]    # 与
[ $a -eq 0 ] || [ $a -eq 1 ]     # 或
[ ! $a -eq 0 ]                   # 非

81. Shell 循环

定义: 循环用于重复执行代码块。

for 循环:

# 基本for
for i in 1 2 3; do
    echo $i
done

# 范围
for i in {1..10}; do
    echo $i
done

# C风格
for ((i=0; i<10; i++)); do
    echo $i
done

# 遍历文件
for file in *.txt; do
    echo $file
done

while 循环:

count=0
while [ $count -lt 10 ]; do
    echo $count
    ((count++))
done

# 读取文件
while read line; do
    echo $line
done < file.txt

until 循环:

count=0
until [ $count -ge 10 ]; do
    echo $count
    ((count++))
done

82. Shell 函数

定义: 函数是可重复使用的代码块。

# 定义函数
function_name() {
    echo "Hello $1"
    return 0
}

# 调用函数
function_name "World"

# 带返回值
add() {
    echo $(($1 + $2))
}
result=$(add 3 5)
echo $result

83. Shell 参数

定义: Shell 参数是传递给脚本或函数的值。

#!/bin/bash
echo "脚本名: $0"
echo "第一个参数: $1"
echo "第二个参数: $2"
echo "所有参数: $@"
echo "参数个数: $#"

shift 命令: 将参数向左移动

while [ $# -gt 0 ]; do
    echo $1
    shift
done

84. Shell 运算符

定义: Shell 支持多种运算符用于数值计算。

算术运算:

expr 5 + 3                    # 使用expr
echo $((5 + 3))               # 使用$(( ))
echo $[5 + 3]                 # 使用$[ ]
let "a=5+3"                   # 使用let

常用运算符:

  • + 加法
  • - 减法
  • * 乘法
  • / 除法
  • % 取模
  • ** 幂运算

85. Shell 字符串处理

定义: Shell 提供多种方式处理字符串。

str="Hello World"

# 长度
echo ${#str}

# 截取
echo ${str:0:5}           # Hello
echo ${str:6}             # World

# 替换
echo ${str/World/Bash}    # Hello Bash
echo ${str//l/L}          # HeLLo Bash(全部替换)

# 删除
echo ${str#Hello}         #  World(删除前缀)
echo ${str%World}         # Hello (删除后缀)

# 大小写转换
echo ${str^^}             # HELLO WORLD(大写)
echo ${str,,}             # hello world(小写)

86. Shell 数组

定义: Shell 数组是存储多个值的变量。

# 定义数组
arr=(apple banana cherry)

# 访问元素
echo ${arr[0]}            # apple
echo ${arr[@]}            # 所有元素
echo ${#arr[@]}           # 数组长度

# 添加元素
arr+=(date)

# 删除元素
unset arr[1]

# 遍历
for item in ${arr[@]}; do
    echo $item
done

87. Shell 重定向

定义: 重定向用于改变命令的输入输出流向。

# 标准输出重定向
command > file.txt          # 覆盖输出
command >> file.txt         # 追加输出

# 标准错误重定向
command 2> error.txt        # 错误输出到文件

# 重定向标准输出和错误
command > file.txt 2>&1     # 全部输出到文件
command &> file.txt         # 简写(Bash)

# 标准输入重定向
command < file.txt          # 从文件读取输入
command << EOF              # here document
line 1
line 2
EOF

# /dev/null(黑洞)
command > /dev/null 2>&1    # 丢弃所有输出

文件描述符:

  • 0:标准输入(stdin)
  • 1:标准输出(stdout)
  • 2:标准错误(stderr)

88. Shell 管道

定义: 管道 | 将前一个命令的输出作为后一个命令的输入。

ls -l | grep ".txt"         # 查找txt文件
cat file.txt | wc -l        # 统计行数
ps aux | grep nginx | wc -l # 统计nginx进程数
cat file.txt | sort | uniq  # 排序并去重

最佳实践: 管道可以连接多个命令,形成数据处理流水线。


89. Shell 通配符

定义: 通配符用于模式匹配文件名。

*           # 匹配任意字符(0或多个)
?           # 匹配单个字符
[abc]       # 匹配a、b或c
[a-z]       # 匹配a到z
[0-9]       # 匹配0到9
!pattern    # 不匹配

示例:

ls *.txt                    # 所有txt文件
ls file?.txt                # file1.txt, file2.txt等
ls [abc]*.txt               # 以a、b或c开头的txt文件

90. Shell 正则表达式

定义: 正则表达式是用于模式匹配的字符串模式。

基本正则:

.           # 任意字符
*           # 前一个字符0次或多次
^           # 行首
$           # 行尾
[]          # 字符集
[^]         # 否定字符集
\           # 转义

扩展正则(需使用 -E 或 \):

+           # 前一个字符1次或多次
?           # 前一个字符0次或1次
|           # 或
()          # 分组
{}          # 重复次数

91-92. crontab 与定时任务

定义: crontab 用于设置周期性执行的任务。

使用:

crontab -l                    # 查看定时任务
crontab -e                    # 编辑定时任务
crontab -r                    # 删除所有定时任务

格式:

分 时 日 月 周 命令

示例:

# 每天凌晨2点执行
0 2 * * * /path/to/script.sh

# 每5分钟执行
*/5 * * * * /path/to/script.sh

# 每周一9点执行
0 9 * * 1 /path/to/script.sh

# 每月1号执行
0 0 1 * * /path/to/script.sh

特殊字符串:

@reboot     # 启动时
@yearly     # 每年
@monthly    # 每月
@weekly     # 每周
@daily      # 每天
@hourly     # 每小时

六、常用工具(grep、awk、sed)

93. grep - 文本搜索

定义: grep (Global Regular Expression Print) 用于在文件中搜索匹配的行。

grep "pattern" file.txt                   # 搜索
grep -i "pattern" file.txt                # 不区分大小写
grep -n "pattern" file.txt                # 显示行号
grep -v "pattern" file.txt                # 反向匹配(不包含)
grep -r "pattern" /path/                  # 递归搜索
grep -c "pattern" file.txt                # 统计匹配行数
grep -l "pattern" *.txt                   # 显示匹配的文件名
grep -E "pattern" file.txt                # 使用扩展正则

94. grep 正则表达式

定义: grep 支持基本正则和扩展正则表达式。

基本正则:

grep "^start" file.txt              # 以start开头
grep "end$" file.txt                # 以end结尾
grep "[0-9]" file.txt               # 匹配数字
grep "[A-Z]" file.txt               # 匹配大写字母

扩展正则(-E 或 egrep):

grep -E "pattern1|pattern2" file.txt    # 或
grep -E "colou?r" file.txt              # 0次或1次
grep -E "ab+c" file.txt                 # 1次或多次
grep -E "(ab)+" file.txt                # 分组

95. awk - 文本处理工具

定义: awk 是强大的文本处理工具,按行处理结构化数据。

基本用法:

awk '{print $1}' file.txt                   # 打印第一列
awk '{print $1, $3}' file.txt               # 打印第1、3列
awk -F: '{print $1}' /etc/passwd            # 指定分隔符
awk '/pattern/ {print $0}' file.txt         # 匹配模式
awk 'NR==1 {print}' file.txt                # 打印第一行
awk 'END {print NR}' file.txt               # 打印总行数
awk '{sum+=$1} END {print sum}' file.txt    # 求和

内置变量:

  • $0:整行
  • $1, $2...:各列
  • NR:行号
  • NF:列数
  • FS:输入分隔符
  • OFS:输出分隔符

96. awk 文本处理

定义: awk 支持复杂的文本处理逻辑。

# 条件处理
awk '$3 > 100 {print $1, $3}' file.txt

# 格式化输出
awk '{printf "%-10s %5d\n", $1, $2}' file.txt

# 数组统计
awk '{count[$1]++} END {for (k in count) print k, count[k]}' file.txt

# 多文件处理
awk '{print FILENAME, $0}' file1.txt file2.txt

97. sed - 流编辑器

定义: sed (Stream EDitor) 用于对文本进行流式编辑。

基本用法:

sed 's/old/new/g' file.txt              # 替换所有
sed 's/old/new/' file.txt               # 只替换每行第一个
sed '2s/old/new/' file.txt              # 只替换第2行
sed '/pattern/s/old/new/' file.txt      # 匹配模式的行替换
sed -i 's/old/new/g' file.txt           # 直接修改文件

98. sed 文本替换

定义: sed 最常用于文本替换。

# 删除行
sed '3d' file.txt                       # 删除第3行
sed '/pattern/d' file.txt               # 删除匹配的行
sed '1,5d' file.txt                     # 删除1-5行

# 插入行
sed '3i\new line' file.txt              # 在第3行前插入
sed '3a\new line' file.txt              # 在第3行后追加

# 多行操作
sed -n '2,5p' file.txt                  # 打印2-5行

99. cut - 提取列

定义: cut 命令用于提取文本的指定列。

cut -d: -f1 /etc/passwd                 # 以:分隔,取第1列
cut -d: -f1,3 /etc/passwd               # 取第1、3列
cut -c1-5 file.txt                      # 取第1-5个字符
cut -f2-4 file.txt                      # 取第2-4列(默认Tab分隔)

100. sort - 排序

定义: sort 命令用于对文本行排序。

sort file.txt                           # 默认按字母排序
sort -n file.txt                        # 按数值排序
sort -r file.txt                        # 逆序
sort -u file.txt                        # 去重
sort -k2 file.txt                       # 按第2列排序
sort -t: -k3 -n /etc/passwd             # 以:分隔,按第3列数值排序

101. uniq - 去重

定义: uniq 命令用于去除相邻的重复行。

uniq file.txt                           # 去重(需先排序)
sort file.txt | uniq                    # 排序后去重
sort file.txt | uniq -c                 # 统计重复次数
sort file.txt | uniq -d                 # 只显示重复行
sort file.txt | uniq -u                 # 只显示不重复的行

102. wc - 统计

定义: wc (word count) 命令用于统计行数、词数、字节数。

wc file.txt                             # 行数、词数、字节数
wc -l file.txt                          # 只统计行数
wc -w file.txt                          # 只统计词数
wc -c file.txt                          # 只统计字节数
wc -m file.txt                          # 只统计字符数

103. diff - 比较文件

定义: diff 命令用于比较两个文件的差异。

diff file1.txt file2.txt                # 比较文件
diff -u file1.txt file2.txt             # 统一格式输出
diff -r dir1/ dir2/                     # 递归比较目录
diff -y file1.txt file2.txt             # 并排显示差异

104. patch - 应用补丁

定义: patch 命令用于将 diff 生成的补丁应用到文件。

diff -u file1.txt file2.txt > patch.diff    # 生成补丁
patch file1.txt < patch.diff                # 应用补丁
patch -p1 < patch.diff                      # 应用补丁(去除路径前缀)

105. tr - 转换字符

定义: tr (translate) 命令用于转换或删除字符。

echo "hello" | tr 'a-z' 'A-Z'           # 转大写
echo "HELLO" | tr 'A-Z' 'a-z'           # 转小写
echo "hello" | tr -d 'l'                # 删除字符l
echo "hello" | tr -s 'l'                # 压缩重复字符
tr '\n' ',' < file.txt                  # 换行符替换为逗号

106. xargs - 构建命令行

定义: xargs 命令从标准输入构建并执行命令行。

find . -name "*.txt" | xargs rm         # 查找并删除
find . -name "*.txt" | xargs -I {} mv {} /dest/   # 逐个处理
cat files.txt | xargs -n 2              # 每行2个参数
cat files.txt | xargs -I {} echo "File: {}"       # 替换参数

常用参数:

  • -n:每行参数个数
  • -I:替换字符串
  • -d:分隔符
  • -p:执行前提示

七、日志查看与分析

107. 日志

定义: Linux 日志是系统和服务运行过程中记录的事件信息。

日志级别:

  • DEBUG:调试信息
  • INFO:一般信息
  • WARNING:警告
  • ERROR:错误
  • CRITICAL:严重错误

108. 日志查看

定义: 日志查看是使用工具查看和分析日志文件。

tail -f /var/log/syslog                 # 实时查看
less /var/log/syslog                    # 分页查看
grep "error" /var/log/syslog            # 搜索错误
journalctl -f                           # 实时查看系统日志

109. /var/log

定义: /var/log 是 Linux 系统日志的标准存储目录。

常见日志文件:

/var/log/syslog         # 系统日志(Debian/Ubuntu)
/var/log/messages       # 系统日志(CentOS/RHEL)
/var/log/auth.log       # 认证日志
/var/log/kern.log       # 内核日志
/var/log/dpkg.log       # 包管理日志
/var/log/nginx/         # Nginx日志
/var/log/mysql/         # MySQL日志
/var/log/boot.log       # 启动日志
/var/log/cron           # 定时任务日志

110. journalctl - 系统日志管理

定义: journalctl 是 systemd 系统的日志查看工具。

journalctl                              # 查看所有日志
journalctl -u nginx                     # 查看特定服务日志
journalctl -f                           # 实时查看
journalctl --since "2024-03-01"         # 查看指定时间后
journalctl --until "2024-03-15"         # 查看指定时间前
journalctl -p err                       # 查看错误级别
journalctl -xe                          # 详细输出
journalctl --disk-usage                 # 查看日志占用
journalctl --vacuum-time=2d             # 清理2天前的日志

111. syslog - 系统日志服务

定义: syslog 是 Linux 的系统日志服务。

配置文件: /etc/syslog.conf/etc/rsyslog.conf

日志设施:

  • auth:认证相关
  • authpriv:特权认证
  • cron:定时任务
  • daemon:守护进程
  • kern:内核
  • mail:邮件
  • user:用户程序

112. dmesg - 内核日志

定义: dmesg 命令用于查看内核环形缓冲区消息。

dmesg                                   # 查看所有内核日志
dmesg | tail                            # 查看最新内核日志
dmesg -T                                # 显示人类可读时间
dmesg | grep -i error                   # 搜索错误
dmesg | grep -i usb                     # 查看USB设备信息

113. last - 登录历史

定义: last 命令用于查看用户登录历史记录。

last                                    # 查看所有登录记录
last username                           # 查看特定用户
last -10                                # 查看最近10条
last reboot                             # 查看重启记录

114. lastb - 失败登录记录

定义: lastb 命令用于查看登录失败的记录。

lastb                                   # 查看所有失败登录
lastb username                          # 查看特定用户失败记录

注意: 需要 root 权限才能查看。


115. who - 查看当前登录用户

定义: who 命令用于查看当前登录的用户信息。

who                                     # 查看当前登录用户
who -u                                  # 显示详细信息
who am i                                # 查看当前终端用户

116. w - 用户活动信息

定义: w 命令用于查看当前登录用户及其活动。

w                                       # 查看用户活动
w username                              # 查看特定用户

输出: 显示用户名、终端、登录时间、空闲时间、当前命令。


117. 日志分析

定义: 日志分析是从日志中提取有用信息的过程。

常用工具:

# 统计访问量
awk '{print $1}' access.log | sort | uniq -c | sort -rn

# 查看错误
grep "ERROR" app.log | tail -20

# 查看特定时间段
sed -n '/2024-03-01 10:00/,/2024-03-01 11:00/p' app.log

# 统计状态码
awk '{print $9}' access.log | sort | uniq -c | sort -rn

# 查找慢请求
awk '$NF > 1 {print}' access.log

118-119. 日志轮转与 logrotate

定义: 日志轮转是定期归档、压缩和删除旧日志的机制。

logrotate 配置:

# 配置文件
/etc/logrotate.conf                     # 主配置
/etc/logrotate.d/                       # 服务配置目录

示例配置:

/var/log/nginx/*.log {
    daily                               # 每天轮转
    missingok                           # 日志不存在不报错
    rotate 7                            # 保留7个备份
    compress                            # 压缩旧日志
    delaycompress                       # 延迟压缩(上一次不压缩)
    notifempty                          # 空文件不轮转
    create 0644 www-data www-data       # 创建新文件的权限
    sharedscripts                       # 只执行一次postrotate
    postrotate
        systemctl reload nginx
    endscript
}

手动执行:

logrotate /etc/logrotate.conf           # 执行轮转
logrotate -d /etc/logrotate.conf        # 调试模式
logrotate -f /etc/logrotate.conf        # 强制执行

八、服务器部署

120. 服务器部署

定义: 服务器部署是将应用程序安装、配置到服务器上并使其可访问的过程。

部署流程:

  1. 安装运行环境(Node.js、Python、Java 等)
  2. 安装 Web 服务器(Nginx、Apache)
  3. 安装数据库(MySQL、PostgreSQL)
  4. 配置反向代理
  5. 配置 SSL 证书
  6. 配置防火墙
  7. 启动服务
  8. 监控和维护

121. Nginx 安装

Ubuntu/Debian:

sudo apt update
sudo apt install nginx
sudo systemctl start nginx
sudo systemctl enable nginx

CentOS/RHEL:

sudo yum install epel-release
sudo yum install nginx
sudo systemctl start nginx
sudo systemctl enable nginx

122. Nginx 配置

配置文件:

/etc/nginx/nginx.conf                   # 主配置
/etc/nginx/sites-available/             # 站点配置
/etc/nginx/sites-enabled/               # 启用的站点

基本配置:

server {
    listen 80;
    server_name example.com;
    root /var/www/html;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }

    location /api {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

常用命令:

nginx -t                                # 测试配置
systemctl reload nginx                  # 重载配置
systemctl restart nginx                 # 重启服务

123. Apache 安装

Ubuntu/Debian:

sudo apt update
sudo apt install apache2
sudo systemctl start apache2
sudo systemctl enable apache2

CentOS/RHEL:

sudo yum install httpd
sudo systemctl start httpd
sudo systemctl enable httpd

124. Apache 配置

配置文件:

/etc/apache2/apache2.conf               # 主配置(Ubuntu)
/etc/httpd/conf/httpd.conf              # 主配置(CentOS)
/etc/apache2/sites-available/           # 站点配置(Ubuntu)

基本配置:

<VirtualHost *:80>
    ServerName example.com
    DocumentRoot /var/www/html
    
    <Directory /var/www/html>
        AllowOverride All
        Require all granted
    </Directory>
    
    ProxyPass /api http://localhost:3000
    ProxyPassReverse /api http://localhost:3000
</VirtualHost>

常用命令:

apachectl configtest                    # 测试配置
systemctl reload apache2                # 重载配置
a2ensite site.conf                      # 启用站点(Ubuntu)
a2dissite site.conf                     # 禁用站点(Ubuntu)
a2enmod proxy                           # 启用模块(Ubuntu)

125. MySQL 安装

Ubuntu/Debian:

sudo apt update
sudo apt install mysql-server
sudo systemctl start mysql
sudo systemctl enable mysql
sudo mysql_secure_installation          # 安全配置

CentOS/RHEL:

sudo yum install mysql-server
sudo systemctl start mysqld
sudo systemctl enable mysqld

126. MySQL 配置

配置文件:

/etc/mysql/mysql.conf.d/mysqld.cnf      # Ubuntu
/etc/my.cnf                             # CentOS

常用命令:

mysql -u root -p                        # 登录MySQL
SHOW DATABASES;                         # 显示数据库
CREATE DATABASE mydb;                   # 创建数据库
CREATE USER 'user'@'localhost' IDENTIFIED BY 'password';  # 创建用户
GRANT ALL PRIVILEGES ON mydb.* TO 'user'@'localhost';     # 授权
FLUSH PRIVILEGES;                       # 刷新权限

127. Node.js 安装

使用 NVM(推荐):

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
nvm install --lts
nvm use --lts

使用包管理器:

# Ubuntu
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt install nodejs

# CentOS
curl -fsSL https://rpm.nodesource.com/setup_lts.x | sudo bash -
sudo yum install nodejs

验证安装:

node -v                                 # 查看版本
npm -v                                  # 查看npm版本

128. PM2 部署

定义: PM2 是 Node.js 进程管理器。

npm install -g pm2                      # 安装
pm2 start app.js                        # 启动应用
pm2 start app.js -i max                 # 集群模式(最大进程数)
pm2 list                                # 列出进程
pm2 stop app                            # 停止
pm2 restart app                         # 重启
pm2 delete app                          # 删除
pm2 logs                                # 查看日志
pm2 monit                               # 监控
pm2 startup                             # 设置开机自启
pm2 save                                # 保存当前进程列表

** ecosystem 配置:**

module.exports = {
  apps: [{
    name: 'myapp',
    script: 'app.js',
    instances: 'max',
    exec_mode: 'cluster',
    env: {
      NODE_ENV: 'production',
      PORT: 3000
    }
  }]
};
pm2 start ecosystem.config.js           # 使用配置启动

129. 反向代理

定义: 反向代理是位于客户端和服务器之间的代理服务器,转发客户端请求到后端服务器。

Nginx 配置:

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

优势:

  • 负载均衡
  • SSL 终止
  • 缓存
  • 安全防护
  • 隐藏后端服务器

130. 负载均衡

定义: 负载均衡是将流量分配到多个后端服务器。

Nginx 配置:

upstream backend {
    server 192.168.1.10:3000;
    server 192.168.1.11:3000;
    server 192.168.1.12:3000;
}

server {
    listen 80;
    location / {
        proxy_pass http://backend;
    }
}

负载均衡策略:

  • round-robin:轮询(默认)
  • least_conn:最少连接
  • ip_hash:按 IP 哈希
  • weight:权重
upstream backend {
    server 192.168.1.10:3000 weight=3;
    server 192.168.1.11:3000 weight=1;
    least_conn;
}

131. SSL 证书

定义: SSL 证书用于加密网络通信。

获取证书(Let's Encrypt):

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com

证书文件:

  • .crt.pem:证书文件
  • .key:私钥文件

132. HTTPS 配置

Nginx HTTPS 配置:

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    location / {
        proxy_pass http://localhost:3000;
    }
}

# HTTP重定向到HTTPS
server {
    listen 80;
    server_name example.com;
    return 301 https://$server_name$request_uri;
}

九、Docker 基础

133-134. Docker 是什么?

定义: Docker 是一个开源的容器化平台,用于开发、交付和运行应用程序。

原理: Docker 使用 Linux 内核特性(cgroups、namespaces)实现资源隔离和限制,使应用及其依赖打包成独立的容器。

核心概念:

  • 镜像(Image):只读模板,包含应用和依赖
  • 容器(Container):镜像的运行实例
  • Dockerfile:构建镜像的脚本
  • 仓库(Registry):存储和分发镜像
  • 数据卷(Volume):持久化数据

优势:

  • 环境一致性
  • 快速部署
  • 资源隔离
  • 轻量级(共享主机内核)
  • 易于扩展

135. Docker 安装

Ubuntu/Debian:

sudo apt update
sudo apt install docker.io
sudo systemctl start docker
sudo systemctl enable docker
sudo usermod -aG docker $USER           # 添加用户到docker组

CentOS/RHEL:

sudo yum install docker
sudo systemctl start docker
sudo systemctl enable docker

验证安装:

docker --version
docker run hello-world

136. Docker 镜像

定义: Docker 镜像是只读模板,包含运行应用所需的所有内容。

docker images                           # 列出镜像
docker pull nginx                       # 下载镜像
docker pull nginx:latest                # 指定标签
docker rmi nginx                        # 删除镜像
docker rmi -f nginx                     # 强制删除
docker tag nginx mynginx:1.0            # 标记镜像
docker save nginx -o nginx.tar          # 导出镜像
docker load -i nginx.tar                # 导入镜像
docker history nginx                    # 查看镜像历史

137. Docker 容器

定义: 容器是镜像的运行实例,包含运行中的应用。

docker ps                               # 查看运行中的容器
docker ps -a                            # 查看所有容器
docker run -d --name mynginx nginx      # 启动容器
docker stop mynginx                     # 停止容器
docker start mynginx                    # 启动已停止的容器
docker restart mynginx                  # 重启容器
docker rm mynginx                       # 删除容器
docker rm -f mynginx                    # 强制删除运行中的容器
docker logs mynginx                     # 查看日志
docker logs -f mynginx                  # 实时查看日志
docker exec -it mynginx bash            # 进入容器
docker inspect mynginx                  # 查看容器详情
docker top mynginx                      # 查看容器进程
docker stats                            # 查看资源使用

138. docker pull

定义: docker pull 用于从仓库下载镜像。

docker pull nginx                       # 下载latest标签
docker pull nginx:1.21                  # 下载指定标签
docker pull ubuntu:20.04                # 下载Ubuntu 20.04

139. docker run

定义: docker run 用于从镜像启动容器。

docker run nginx                        # 基本运行
docker run -d nginx                     # 后台运行
docker run -d --name web nginx          # 指定名称
docker run -d -p 8080:80 nginx          # 端口映射
docker run -d -v /data:/var/www nginx   # 挂载数据卷
docker run -d -e MYSQL_ROOT_PASSWORD=123 mysql  # 设置环境变量
docker run -it ubuntu bash              # 交互式运行
docker run --restart=always nginx       # 自动重启

常用参数:

  • -d:后台运行
  • -p:端口映射(主机:容器)
  • -v:挂载卷
  • -e:环境变量
  • --name:容器名称
  • -it:交互式终端
  • --restart:重启策略

140. docker ps

定义: docker ps 用于列出容器。

docker ps                               # 运行中的容器
docker ps -a                            # 所有容器
docker ps -l                            # 最近一个容器
docker ps -q                            # 只显示ID
docker ps --filter "status=exited"      # 过滤已退出容器

141. docker stop

定义: docker stop 用于优雅停止容器。

docker stop container_id                # 停止容器(默认10秒超时)
docker stop -t 30 container_id          # 30秒后停止
docker stop $(docker ps -q)             # 停止所有容器

142. docker rm

定义: docker rm 用于删除容器。

docker rm container_id                  # 删除已停止的容器
docker rm -f container_id               # 强制删除运行中的容器
docker rm $(docker ps -aq)              # 删除所有容器
docker rm $(docker ps -f "status=exited" -q)    # 删除已退出容器

143. docker rmi

定义: docker rmi 用于删除镜像。

docker rmi image_id                     # 删除镜像
docker rmi -f image_id                  # 强制删除
docker rmi $(docker images -q)          # 删除所有镜像
docker image prune                      # 清理无用镜像
docker image prune -a                   # 清理所有未使用镜像

144. docker build

定义: docker build 用于从 Dockerfile 构建镜像。

docker build -t myapp:1.0 .             # 构建镜像
docker build -t myapp:1.0 -f Dockerfile.prod .    # 指定Dockerfile
docker build --no-cache -t myapp:1.0 .  # 不使用缓存

145. Dockerfile

定义: Dockerfile 是构建 Docker 镜像的脚本文件。

示例:

FROM node:18-alpine                     # 基础镜像
WORKDIR /app                            # 工作目录
COPY package*.json ./                   # 复制依赖文件
RUN npm install                         # 安装依赖
COPY . .                                # 复制应用代码
EXPOSE 3000                             # 暴露端口
CMD ["node", "app.js"]                  # 启动命令

常用指令:

  • FROM:基础镜像
  • WORKDIR:工作目录
  • COPY:复制文件
  • ADD:复制文件(支持URL和自动解压)
  • RUN:执行命令
  • EXPOSE:暴露端口
  • ENV:环境变量
  • CMD:默认命令
  • ENTRYPOINT:入口点
  • VOLUME:数据卷
  • USER:用户

146. docker-compose

定义: docker-compose 用于定义和运行多容器 Docker 应用。

docker-compose.yml:

version: '3.8'
services:
  web:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
    depends_on:
      - db
  db:
    image: mysql:8
    environment:
      - MYSQL_ROOT_PASSWORD=123456
    volumes:
      - db_data:/var/lib/mysql
volumes:
  db_data:

常用命令:

docker-compose up                       # 启动服务
docker-compose up -d                    # 后台启动
docker-compose down                     # 停止并删除
docker-compose logs                     # 查看日志
docker-compose ps                       # 查看状态
docker-compose build                    # 构建服务
docker-compose restart                  # 重启服务

147. Docker 网络

定义: Docker 网络用于容器之间的通信。

网络模式:

  • bridge:默认网络,容器通过虚拟网桥通信
  • host:使用主机网络
  • none:无网络
  • overlay:跨主机网络
docker network ls                       # 列出网络
docker network create mynet             # 创建网络
docker run -d --network mynet nginx     # 使用自定义网络
docker network inspect mynet            # 查看网络详情

容器通信:

docker run -d --name web --network mynet nginx
docker run -d --name api --network mynet myapi
# 容器可以通过名称互相访问

148. Docker 数据卷

定义: Docker 数据卷用于持久化容器数据。

docker volume ls                        # 列出卷
docker volume create mydata             # 创建卷
docker run -d -v mydata:/data nginx     # 挂载卷
docker run -d -v /host/path:/container/path nginx  # 绑定挂载
docker volume inspect mydata            # 查看卷详情
docker volume rm mydata                 # 删除卷

数据持久化方式:

  • 数据卷(Volume):Docker 管理,存储在 /var/lib/docker/volumes/
  • 绑定挂载(Bind Mount):指定主机路径
  • tmpfs 挂载:存储在内存中

149. Docker 常用命令

# 镜像
docker images                           # 列出镜像
docker pull nginx                       # 下载镜像
docker push myimage                     # 推送镜像
docker rmi myimage                      # 删除镜像
docker build -t myimage .               # 构建镜像

# 容器
docker ps                               # 列出容器
docker run -d nginx                     # 运行容器
docker stop/start/restart container     # 停止/启动/重启
docker rm container                     # 删除容器
docker logs container                   # 查看日志
docker exec -it container bash          # 进入容器

# 清理
docker system df                        # 查看磁盘使用
docker system prune                     # 清理无用资源
docker image prune                      # 清理无用镜像
docker container prune                  # 清理已停止容器

十、CI/CD 流程

150. CI/CD

定义: CI/CD 是持续集成(Continuous Integration)和持续交付/部署(Continuous Delivery/Deployment)的缩写。

核心概念:

  • 持续集成(CI):频繁地将代码集成到主干,每次集成都通过自动化构建和测试验证
  • 持续交付(CD):确保代码可以随时安全地发布到生产环境
  • 持续部署(CD):自动化将通过测试的代码部署到生产环境

优势:

  • 快速发现和修复问题
  • 减少集成问题
  • 提高交付速度
  • 降低发布风险
  • 自动化重复任务

151. 持续集成

定义: 持续集成是开发人员频繁地将代码合并到共享仓库,并通过自动化构建和测试验证。

流程:

  1. 开发人员提交代码到版本控制
  2. CI 系统检测到代码变更
  3. 自动拉取最新代码
  4. 自动构建项目
  5. 运行自动化测试
  6. 生成测试报告
  7. 通知构建结果

工具: Jenkins、GitLab CI、GitHub Actions、Travis CI、CircleCI


152. 持续部署

定义: 持续部署是通过自动化流程将通过测试的代码部署到生产环境。

流程:

  1. 代码通过 CI 测试
  2. 自动部署到测试环境
  3. 运行集成测试
  4. 自动部署到生产环境
  5. 监控和回滚机制

最佳实践:

  • 自动化所有测试
  • 使用基础设施即代码
  • 蓝绿部署或金丝雀发布
  • 监控和告警
  • 快速回滚机制

153. Jenkins

定义: Jenkins 是开源的自动化服务器,支持 CI/CD。

特点:

  • 开源免费
  • 丰富的插件生态
  • 支持多种语言
  • 分布式构建
  • Pipeline as Code

Pipeline 示例:

pipeline {
    agent any
    stages {
        stage('Build') {
            steps {
                sh 'npm install'
                sh 'npm run build'
            }
        }
        stage('Test') {
            steps {
                sh 'npm test'
            }
        }
        stage('Deploy') {
            steps {
                sh 'scp -r dist/ user@server:/var/www/'
            }
        }
    }
}

154. GitLab CI

定义: GitLab CI 是 GitLab 内置的 CI/CD 工具。

配置文件:.gitlab-ci.yml

stages:
  - build
  - test
  - deploy

build:
  stage: build
  script:
    - npm install
    - npm run build

test:
  stage: test
  script:
    - npm test

deploy:
  stage: deploy
  script:
    - scp -r dist/ user@server:/var/www/
  only:
    - main

155. GitHub Actions

定义: GitHub Actions 是 GitHub 提供的 CI/CD 服务。

配置文件:.github/workflows/ci.yml

name: CI
on: [push, pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - run: npm install
      - run: npm run build
      - run: npm test

156. Travis CI

定义: Travis CI 是基于云的 CI 服务。

配置文件:.travis.yml

language: node_js
node_js:
  - "18"
install:
  - npm install
script:
  - npm run build
  - npm test

157. CircleCI

定义: CircleCI 是基于云的 CI/CD 平台。

配置文件:.circleci/config.yml

version: 2.1
jobs:
  build:
    docker:
      - image: node:18
    steps:
      - checkout
      - run: npm install
      - run: npm run build
      - run: npm test

158. 自动化部署

定义: 自动化部署是通过脚本和工具自动将应用部署到服务器。

部署策略:

  • 蓝绿部署:同时运行两个环境,切换流量
  • 金丝雀发布:逐步将流量引导到新版本
  • 滚动更新:逐台服务器更新
  • 原地更新:直接在现有环境更新

Shell 脚本示例:

#!/bin/bash
APP_DIR="/var/www/myapp"
BACKUP_DIR="/var/www/backup"

# 备份当前版本
cp -r $APP_DIR $BACKUP_DIR/backup-$(date +%Y%m%d)

# 拉取最新代码
cd $APP_DIR
git pull origin main

# 安装依赖
npm install --production

# 构建
npm run build

# 重启服务
pm2 restart myapp

# 验证
curl -s http://localhost:3000/health | grep "ok"
if [ $? -ne 0 ]; then
    echo "Deployment failed, rolling back..."
    rm -rf $APP_DIR
    cp -r $BACKUP_DIR/backup-* $APP_DIR
    pm2 restart myapp
fi

159. 自动化测试

定义: 自动化测试是通过脚本自动运行测试用例。

测试类型:

  • 单元测试:测试单个函数/模块
  • 集成测试:测试模块间的交互
  • 端到端测试:测试完整用户流程
  • 性能测试:测试系统性能

CI/CD 中的测试:

# GitHub Actions 示例
- name: Run tests
  run: |
    npm run test:unit
    npm run test:integration
    npm run test:e2e

160. 构建流水线

定义: 构建流水线是 CI/CD 中的一系列自动化步骤。

典型流水线:

代码提交 -> 代码检查 -> 单元测试 -> 构建 -> 集成测试 -> 部署到测试环境 -> 验收测试 -> 部署到生产环境

最佳实践:

  • 快速反馈(失败快速)
  • 可重复的构建
  • 版本化构建产物
  • 自动化所有步骤
  • 监控和告警

十一、用户管理

161. Linux 用户管理

定义: Linux 是多用户系统,用户管理涉及创建、删除、修改用户和组。

用户类型:

  • root 用户:超级管理员(UID 0)
  • 系统用户:系统服务使用(UID 1-999)
  • 普通用户:日常使用(UID 1000+)

用户相关文件:

  • /etc/passwd:用户信息
  • /etc/shadow:密码信息(加密)
  • /etc/group:组信息
  • /etc/gshadow:组密码信息

162. useradd - 创建用户

定义: useradd 命令用于创建新用户。

useradd username                      # 创建用户
useradd -m username                   # 创建用户并创建主目录
useradd -s /bin/bash username         # 指定Shell
useradd -g group username             # 指定主组
useradd -G group1,group2 username     # 指定附加组
useradd -d /home/custom username      # 指定主目录

163. usermod - 修改用户

定义: usermod 命令用于修改用户属性。

usermod -l newname oldname            # 修改用户名
usermod -d /new/home -m username      # 修改主目录
usermod -s /bin/zsh username          # 修改Shell
usermod -aG sudo username             # 添加到组
usermod -L username                   # 锁定用户
usermod -U username                   # 解锁用户

164. userdel - 删除用户

定义: userdel 命令用于删除用户。

userdel username                      # 删除用户(保留主目录)
userdel -r username                   # 删除用户及其主目录

165. passwd - 修改密码

定义: passwd 命令用于修改用户密码。

passwd                                # 修改当前用户密码
passwd username                       # 修改指定用户密码(需要root)
passwd -d username                    # 删除密码
passwd -l username                    # 锁定用户
passwd -u username                    # 解锁用户

166. groupadd - 创建组

定义: groupadd 命令用于创建新组。

groupadd groupname                    # 创建组
groupadd -g 1001 groupname            # 指定GID

167. groupmod - 修改组

定义: groupmod 命令用于修改组属性。

groupmod -n newname oldname           # 修改组名
groupmod -g 1002 groupname            # 修改GID

168. groupdel - 删除组

定义: groupdel 命令用于删除组。

groupdel groupname                    # 删除组

169. su - 切换用户

定义: su (switch user) 命令用于切换用户。

su                                    # 切换到root
su username                           # 切换到指定用户
su - username                         # 切换并加载用户环境
su -c "command" username              # 以指定用户执行命令

170. sudo - 以管理员权限执行

定义: sudo (superuser do) 命令用于以 root 或其他用户权限执行命令。

sudo command                          # 以root执行命令
sudo -u username command              # 以指定用户执行
sudo -l                               # 查看权限
sudo -i                               # 切换到root Shell
sudo visudo                           # 编辑sudoers文件

配置: /etc/sudoers

username ALL=(ALL) ALL                # 允许用户执行所有命令
username ALL=(ALL) NOPASSWD: ALL      # 无需密码
%groupname ALL=(ALL) ALL              # 允许组内用户

十二、文本编辑命令

171. vim - 文本编辑器

定义: vim 是 Linux 下强大的文本编辑器。

三种模式:

  • 普通模式:默认模式,用于导航
  • 插入模式:编辑文本
  • 命令模式:执行命令

常用命令:

i       # 进入插入模式
ESC     # 返回普通模式
:w      # 保存
:q      # 退出
:q!     # 强制退出
:wq     # 保存并退出

导航:

h/j/k/l         # 左/下/上/右
0/$             # 行首/行尾
gg/G            # 文件开头/末尾
:n              # 跳转到第n行

编辑:

dd              # 删除行
yy              # 复制行
p               # 粘贴
u               # 撤销
Ctrl+r          # 重做

搜索:

/pattern        # 向下搜索
?pattern        # 向上搜索
n/N             # 下一个/上一个
:%s/old/new/g   # 全部替换

172. nano - 简单文本编辑器

定义: nano 是简单易用的终端文本编辑器。

常用快捷键:

Ctrl+O        # 保存
Ctrl+X        # 退出
Ctrl+W        # 搜索
Ctrl+K        # 剪切行
Ctrl+U        # 粘贴
Ctrl+6        # 复制

173. head/tail - 查看文件部分

定义: head 和 tail 用于查看文件的开头和结尾部分。

head -n 20 file.txt                 # 查看前20行
tail -n 20 file.txt                 # 查看后20行
tail -f file.log                    # 实时跟踪

十三、输入输出重定向和管道

174. 输入输出重定向

定义: 重定向用于改变命令的标准输入、标准输出和标准错误的流向。

标准流:

  • stdin (0):标准输入
  • stdout (1):标准输出
  • stderr (2):标准错误

输出重定向:

command > file.txt                  # 覆盖输出到文件
command >> file.txt                 # 追加输出到文件
command 2> error.txt                # 错误输出到文件
command > file.txt 2>&1             # 所有输出到文件
command &> file.txt                 # 简写(Bash)
command > /dev/null 2>&1            # 丢弃所有输出

输入重定向:

command < file.txt                  # 从文件读取输入
command << EOF                      # here document
line 1
line 2
EOF

175. 管道

定义: 管道 | 将前一个命令的标准输出连接到后一个命令的标准输入。

command1 | command2                 # 连接两个命令
command1 | command2 | command3      # 连接多个命令

示例:

ps aux | grep nginx | wc -l         # 统计nginx进程数
cat file.txt | sort | uniq -c       # 排序并统计
ls -l | awk '{print $5}' | paste -sd+ | bc  # 计算总大小

管道特性:

  • 数据流式传输(不需要临时文件)
  • 支持多个命令串联
  • 适合文本处理

最佳实践:

  • 结合 grep、awk、sed 处理文本
  • 使用 tee 同时输出到文件和终端
  • 避免过长的管道(复杂逻辑应使用脚本)

十四、系统理解

176. Linux 系统理解

定义: Linux 系统理解涉及操作系统架构、内核、发行版等核心概念。

系统架构:

应用程序
  ↓
Shell / 系统工具
  ↓
系统调用接口
  ↓
Linux 内核
  ↓
硬件

内核功能:

  • 进程管理
  • 内存管理
  • 文件系统
  • 设备驱动
  • 网络协议栈

发行版:

  • Debian/Ubuntu:apt 包管理
  • CentOS/RHEL:yum/dnf 包管理
  • Arch Linux:pacman 包管理
  • openSUSE:zypper 包管理

177. 系统性能监控

定义: 系统性能监控是跟踪和分析系统资源使用情况。

CPU 监控:

top                                 # 实时查看
vmstat 1                            # 每秒统计
mpstat                              # CPU详细统计

内存监控:

free -h                             # 查看内存使用
vmstat                              # 虚拟内存统计
cat /proc/meminfo                   # 详细信息

磁盘监控:

df -h                               # 磁盘使用
du -sh /path                        # 目录大小
iostat                              # I/O统计

网络监控:

netstat -s                          # 网络统计
iftop                               # 带宽监控
nethogs                             # 进程带宽

178. 系统启动流程

定义: Linux 启动流程是从开机到系统就绪的过程。

启动流程:

  1. BIOS/UEFI 初始化硬件
  2. 引导加载程序(GRUB)
  3. 加载内核
  4. 初始化 initramfs
  5. 启动 init 系统(systemd)
  6. 运行系统服务
  7. 显示登录界面

systemd 目标:

systemctl list-units --type=target  # 查看目标
systemctl get-default               # 查看默认目标
systemctl set-default multi-user.target  # 设置默认目标

常用目标:

  • multi-user.target:多用户命令行
  • graphical.target:图形界面
  • rescue.target:救援模式

179. 包管理

定义: 包管理是安装、更新、删除软件包的系统。

apt(Debian/Ubuntu):

apt update                          # 更新包列表
apt upgrade                         # 升级包
apt install package                 # 安装包
apt remove package                  # 卸载包
apt search package                  # 搜索包
apt list --installed                # 列出已安装包

yum/dnf(CentOS/RHEL):

yum update                          # 更新包
yum install package                 # 安装包
yum remove package                  # 卸载包
yum search package                  # 搜索包
yum list installed                  # 列出已安装包

180. 系统安全

定义: 系统安全是保护系统免受未授权访问和攻击。

安全措施:

  • 定期更新系统和软件
  • 配置防火墙
  • 使用 SSH 密钥认证
  • 禁用 root 远程登录
  • 最小权限原则
  • 定期备份
  • 监控日志
  • 使用 SELinux/AppArmor

SSH 安全配置:

/etc/ssh/sshd_config:
PermitRootLogin no                  # 禁止root登录
PasswordAuthentication no           # 禁用密码认证
Port 2222                           # 修改端口

附录:常用命令速查表

文件操作

命令 说明
ls 列出目录
cd 切换目录
pwd 显示当前目录
mkdir 创建目录
rm 删除文件/目录
cp 复制
mv 移动/重命名
touch 创建文件
cat 查看文件
less 分页查看

权限管理

命令 说明
chmod 修改权限
chown 修改所有者
chgrp 修改所属组

进程管理

命令 说明
ps 查看进程
top 实时监控
kill 终止进程
nohup 忽略挂起信号

网络命令

命令 说明
ping 测试连通性
ifconfig/ip 网络接口
netstat/ss 网络连接
curl HTTP客户端
wget 下载工具
ssh 远程登录
scp 安全复制

文本处理

命令 说明
grep 文本搜索
awk 文本处理
sed 流编辑
sort 排序
uniq 去重
wc 统计
cut 提取列

vue甘特图vxe-gantt如何实现拖拽任务条时如有已关联依赖线,同时更新依赖任务的日期的方式

vue甘特图vxe-gantt如何实现拖拽任务条时如有已关联依赖线,同时更新依赖任务的日期的方式

当任务关联前置任务或后置任务依赖线时,拖拽该任务时同步更新对应的起始日期和结束日期,可以通过 task-bar-drag-config.moveSetMethod 来自定义业务逻辑

extend_gantt_chart_gantt_dependency_move_update

基础代码

简单实现同步移动任务

<template>
  <div>
    <vxe-gantt v-bind="ganttOptions"></vxe-gantt>
  </div>
</template>

<script setup>
import { reactive } from 'vue'
import { VxeGanttDependencyType } from 'vxe-gantt'
import XEUtils from 'xe-utils'

const ganttOptions = reactive({
  border: true,
  height: 600,
  rowConfig: {
    keyField: 'id' // 行主键
  },
  taskBarConfig: {
    showProgress: true, // 是否显示进度条
    showContent: true, // 是否在任务条显示内容
    moveable: true, // 是否允许拖拽任务移动日期
    resizable: true, // 是否允许拖拽任务调整日期
    linkCreatable: true, // 是否允许自定义创建依赖线
    barStyle: {
      round: true, // 圆角
      bgColor: '#fca60b', // 任务条的背景颜色
      completedBgColor: '#65c16f' // 已完成部分任务条的背景颜色
    }
  },
  taskLinkConfig: {
    isHover: true, // 当鼠标移到依赖线时,是否要高亮当前依赖线
    isCurrent: true, // 当鼠标点击依赖线时,是否要高亮当前依赖线
    isDblclickToRemove: true // 是否允许双击依赖线删除
  },
  taskViewConfig: {
    tableStyle: {
      width: 480 // 表格宽度
    }
  },
  taskBarMoveConfig: {
    // 自定义拖拽结束时任务日期被赋值的方法
    async moveSetMethod ({ row, startValue, endValue, offsetSize, linkInfo }) {
      const { toRows, fromRows } = linkInfo
      row.start = startValue
      row.end = endValue
      // 实现拖拽任务后,关联任务自动同步更新日期
      fromRows.forEach(row => {
        row.start = XEUtils.toDateString(XEUtils.getWhatDay(row.start, offsetSize), 'yyyy-MM-dd')
        row.end = XEUtils.toDateString(XEUtils.getWhatDay(row.end, offsetSize), 'yyyy-MM-dd')
      })
      toRows.forEach(row => {
        row.start = XEUtils.toDateString(XEUtils.getWhatDay(row.start, offsetSize), 'yyyy-MM-dd')
        row.end = XEUtils.toDateString(XEUtils.getWhatDay(row.end, offsetSize), 'yyyy-MM-dd')
      })
    }
  },
  links: [
    { from: 10001, to: 10002, type: VxeGanttDependencyType.FinishToFinish },
    { from: 10004, to: 10005, type: VxeGanttDependencyType.StartToStart },
    { from: 10005, to: 10006, type: VxeGanttDependencyType.FinishToStart },
    { from: 10013, to: 10012, type: VxeGanttDependencyType.StartToFinish }
  ],
  columns: [
    { type: 'seq', width: 70 },
    { field: 'title', title: '任务名称' },
    { field: 'start', title: '开始时间', width: 100 },
    { field: 'end', title: '结束时间', width: 100 },
    { field: 'progress', title: '进度(%)', width: 80 }
  ],
  data: [
    { id: 10001, title: '任务1', start: '2024-03-01', end: '2024-03-04', progress: 3 },
    { id: 10002, title: '任务2', start: '2024-03-03', end: '2024-03-08', progress: 10 },
    { id: 10003, title: '任务3', start: '2024-03-03', end: '2024-03-11', progress: 90 },
    { id: 10004, title: '任务4', start: '2024-03-05', end: '2024-03-11', progress: 15 },
    { id: 10005, title: '任务5', start: '2024-03-08', end: '2024-03-15', progress: 100 },
    { id: 10006, title: '任务6', start: '2024-03-10', end: '2024-03-21', progress: 5 },
    { id: 10007, title: '任务7', start: '2024-03-15', end: '2024-03-24', progress: 70 },
    { id: 10008, title: '任务8', start: '2024-03-05', end: '2024-03-15', progress: 50 },
    { id: 10009, title: '任务9', start: '2024-03-19', end: '2024-03-20', progress: 5 },
    { id: 10010, title: '任务10', start: '2024-03-12', end: '2024-03-20', progress: 10 },
    { id: 10011, title: '任务11', start: '2024-03-01', end: '2024-03-08', progress: 90 },
    { id: 10012, title: '任务12', start: '2024-03-03', end: '2024-03-06', progress: 60 },
    { id: 10013, title: '任务13', start: '2024-03-02', end: '2024-03-05', progress: 50 },
    { id: 10014, title: '任务14', start: '2024-03-04', end: '2024-03-15', progress: 0 },
    { id: 10015, title: '任务15', start: '2024-03-01', end: '2024-03-05', progress: 30 }
  ]
})
</script>

还可以实现更复杂的逻辑

<template>
  <div>
    <vxe-gantt ref="ganttRef" v-bind="ganttOptions" @task-bar-drag-end="onTaskDragEnd"></vxe-gantt>
  </div>
</template>

<script setup>
import { reactive, ref } from 'vue'
import { VxeGanttDependencyType } from 'vxe-gantt'
import XEUtils from 'xe-utils'

const ganttRef = ref(null)

const ganttOptions = reactive({
  // ...(保持原有配置)
  taskBarMoveConfig: {
    // 自定义拖拽结束时任务日期被赋值的方法
    async moveSetMethod({ row, startValue, endValue, offsetSize, linkInfo }) {
      const { toRows, fromRows, toLinks, fromLinks } = linkInfo
    
      // 1. 获取变更前的日期(用于错误恢复)
      // const oldStart = row.start
      // const oldEnd = row.end

      // 2. 更新当前拖拽任务的日期
      row.start = startValue
      row.end = endValue


      // 4. 统一更新依赖任务的方法
      //   根据依赖类型计算出新的日期,而不是简单粗暴地整体偏移
      const updateDependentTasks = (tasks, currentRow, linkType, offsetDays) => {
        tasks.forEach(task => {
          const currentStart = new Date(currentRow.start)
          const currentEnd = new Date(currentRow.end)
          let newStart, newEnd

          // 根据不同的依赖类型,更新关联任务的日期
          switch (linkType) {
            case VxeGanttDependencyType.FinishToStart:
              // 前置任务结束后,后置任务才能开始
              newStart = XEUtils.toDateString(
                XEUtils.getWhatDay(currentRow.end, offsetDays),
                'yyyy-MM-dd'
              )
              newEnd = XEUtils.toDateString(
                XEUtils.getWhatDay(task.end, offsetDays),
                'yyyy-MM-dd'
              )
              break
            case VxeGanttDependencyType.StartToStart:
              // 两个任务同时开始
              newStart = XEUtils.toDateString(
                XEUtils.getWhatDay(currentRow.start, offsetDays),
                'yyyy-MM-dd'
              )
              newEnd = XEUtils.toDateString(
                XEUtils.getWhatDay(task.end, offsetDays),
                'yyyy-MM-dd'
              )
              break
            case VxeGanttDependencyType.FinishToFinish:
              // 两个任务同时完成
              newStart = XEUtils.toDateString(
                XEUtils.getWhatDay(task.start, offsetDays),
                'yyyy-MM-dd'
              )
              newEnd = XEUtils.toDateString(
                XEUtils.getWhatDay(currentRow.end, offsetDays),
                'yyyy-MM-dd'
              )
              break
            case VxeGanttDependencyType.StartToFinish:
              // 当前任务完成才能开始
              newStart = XEUtils.toDateString(
                XEUtils.getWhatDay(currentRow.start, offsetDays),
                'yyyy-MM-dd'
              )
              newEnd = XEUtils.toDateString(
                XEUtils.getWhatDay(task.end, offsetDays),
                'yyyy-MM-dd'
              )
              break
            default:
              // 默认整体偏移
              newStart = XEUtils.toDateString(
                XEUtils.getWhatDay(task.start, offsetDays),
                'yyyy-MM-dd'
              )
              newEnd = XEUtils.toDateString(
                XEUtils.getWhatDay(task.end, offsetDays),
                'yyyy-MM-dd'
              )
          }

          // 更新任务数据
          task.start = newStart || task.start
          task.end = newEnd || task.end
        })
      }

      // 6. 更新依赖任务(前置和后置)
      // 使用依赖关系信息来精确更新
      if (fromRows.length && fromLinks.length) {
         updateDependentTasks([fromRows[0]], row, fromLinks[0], offsetSize)
      }

      if (toRows.length && toLinks.length) {
        updateDependentTasks([toRows[0]], row, toLinks[0], offsetSize)
      }

    }
  },
  // ...(保持原有数据配置)
})

// 监听拖拽事件,执行其他业务逻辑(如保存到后端)
const onTaskDragEnd = ({ row, startValue, endValue }) => {
  // 可以在这里保存更新后的任务数据,或者执行其他业务逻辑
  console.log('任务拖拽完成', { row, startValue, endValue })
  
  // 可选:触发数据更新到后端
  // updateTaskToBackend(row)
}
</script>

依赖类型说明

依赖类型 原逻辑缺陷 优化后逻辑
FinishToStart 前置任务结束日期推迟,后置任务应该整体推迟?❌ 应该是后置任务的开始日期=前置任务的结束日期 仅更新后置任务的开始日期,保持持续时间不变
StartToStart 同时开始的任务,拖拽一个不应该导致另一个结束日期整体偏移 保持两个任务的结束日期相对不变,仅调整开始日期
FinishToFinish 两个任务同时结束,拖拽一个不应该影响另一个的开始日期 保持两个任务的开始日期相对不变,仅调整结束日期
StartToFinish 前置任务开始日期影响后置任务完成日期 根据依赖关系的具体约束来精确更新

gantt.vxeui.com

Vue 响应式系统源码级剖析:从 Object.defineProperty 到 Proxy

Vue 3 的响应式系统被誉为前端框架的"艺术品"。它如何在数据变化时精准触发视图更新?如何避免不必要的重渲染?

今天,我们不讲表面用法,直接从 V8 引擎的内存布局出发,深度剖析 Vue 响应式系统的底层实现机制。

1. 响应式系统的核心目标

响应式系统的本质是建立一个依赖追踪图(Dependency Graph)

数据变化 → 触发 Getter → 收集依赖 → 执行 Setter → 通知更新 → 视图刷新

难点在于:

  1. 精准收集:只收集真正用到该数据的组件
  2. 高效通知:避免无关组件的重复渲染
  3. 嵌套支持:深层对象、数组的响应式处理

2. Vue 2 方案:Object.defineProperty 的局限

2.1 核心实现

function defineReactive(obj, key, val) {
    const dep = new Dep(); // 依赖收集器
    
    Object.defineProperty(obj, key, {
        get() {
            if (Dep.target) {
                dep.depend(); // 收集当前 Watcher
            }
            return val;
        },
        set(newVal) {
            if (newVal === val) return;
            val = newVal;
            dep.notify(); // 通知所有依赖更新
        }
    });
}

2.2 致命缺陷

问题 原因 影响
无法检测属性新增/删除 Object.defineProperty 只能劫持已存在的属性 需要用 Vue.set
数组变异方法失效 数组索引赋值不会触发 Setter 需要重写 7 个数组方法
递归遍历性能差 初始化时需要深度遍历整个对象树 大型对象卡顿

3. Vue 3 方案:Proxy 的降维打击

3.1 核心实现

function reactive(target) {
    return new Proxy(target, {
        get(target, key, receiver) {
            const res = Reflect.get(target, key, receiver);
            track(target, key); // 收集依赖
            return isObject(res) ? reactive(res) : res;
        },
        set(target, key, value, receiver) {
            const oldValue = target[key];
            const result = Reflect.set(target, key, value, receiver);
            if (oldValue !== value) {
                trigger(target, key); // 触发更新
            }
            return result;
        }
    });
}

3.2 Proxy 的优势

特性 Object.defineProperty Proxy
拦截范围 单个属性 整个对象
新增/删除属性 不支持 ✅ 原生支持
数组索引操作 ❌ 需重写方法 ✅ 原生支持
性能 递归遍历 O(n) 惰性代理 O(1)

4. 依赖收集机制:Dep 与 Watcher 的协作

4.1 Dep(依赖收集器)

class Dep {
    constructor() {
        this.subscribers = new Set(); // 使用 Set 去重
    }
    
    depend() {
        if (Dep.target) {
            this.subscribers.add(Dep.target);
        }
    }
    
    notify() {
        this.subscribers.forEach(watcher => {
            watcher.update();
        });
    }
}

4.2 WeakMap 存储映射

Vue 3 使用 WeakMap 建立数据到依赖的映射:

const targetMap = new WeakMap();

function track(target, key) {
    if (!Dep.target) return;
    
    let depsMap = targetMap.get(target);
    if (!depsMap) {
        depsMap = new Map();
        targetMap.set(target, depsMap);
    }
    
    let dep = depsMap.get(key);
    if (!dep) {
        dep = new Set();
        depsMap.set(key, dep);
    }
    
    dep.add(Dep.target);
}

数据结构

targetMap (WeakMap)
  └─ target (对象)
      └─ depsMap (Map)
          └─ key (属性名)
              └─ dep (Set)
                  └─ effect (副作用函数)

5. 调度系统:异步更新队列

Vue 不会在数据变化时立即更新视图,而是使用异步批处理

const queue = [];
let pending = false;

function queueJob(job) {
    if (!queue.includes(job)) {
        queue.push(job);
    }
    
    if (!pending) {
        pending = true;
        nextTick(flushJobs);
    }
}

function flushJobs() {
    queue.sort((a, b) => a.id - b.id); // 按优先级排序
    
    for (const job of queue) {
        job();
    }
    
    queue.length = 0;
    pending = false;
}

优势

  • 多次数据变化只触发一次渲染
  • 避免中间状态导致的闪烁
  • 按优先级排序,确保父子组件更新顺序

6. 计算属性与侦听器:衍生状态处理

6.1 Computed(惰性求值)

function computed(getter) {
    let value;
    let dirty = true;
    
    const runner = effect(getter, {
        scheduler: () => {
            dirty = true; // 标记为脏数据
        }
    });
    
    return {
        get value() {
            if (dirty) {
                value = runner();
                dirty = false;
            }
            return value;
        }
    };
}

核心机制

  • 只有在访问时才计算(惰性)
  • 依赖变化时标记 dirty,下次访问重新计算
  • 避免不必要的重复计算

6.2 Watch(主动侦听)

function watch(source, callback) {
    const getter = () => traverse(source);
    
    effect(getter, {
        scheduler: () => {
            callback(getter());
        }
    });
}

7. 工业界实战:性能优化技巧

7.1 markRaw(跳过响应式)

const rawObj = markRaw({ /* 大型数据 */ });

不需要响应式的对象(如图表实例、第三方库对象),用 markRaw 标记,避免 Proxy 开销。

7.2 shallowReactive(浅层响应式)

const state = shallowReactive({
    nested: { deep: { value: 1 } }
});

只代理第一层,嵌套对象保持原始引用,减少内存占用。

7.3 冻结对象优化

const constant = Object.freeze({ /* 常量配置 */ });

Vue 会自动跳过已冻结的对象,不会进行响应式转换。

8. 面试考点

Q1: Vue 2 为什么无法检测对象属性的新增?

A: Object.defineProperty 只能劫持对象上已存在的属性。新增属性时没有 Getter/Setter,需要在初始化时递归遍历所有属性,动态新增的属性无法被劫持。

Q2: Proxy 为什么比 Object.defineProperty 性能好?

A: Proxy 是惰性代理,只有在访问属性时才递归代理子对象。而 Object.defineProperty 在初始化时需要完整遍历整个对象树,时间复杂度 O(n)。

Q3: Vue 3 的依赖收集用了什么数据结构?

A: 使用 WeakMap → Map → Set 三层映射。targetMap(WeakMap)存储目标对象,depsMap(Map)存储属性名,dep(Set)存储副作用函数。使用 Set 自动去重。

9. 总结

Vue 响应式系统的核心设计:

  1. 数据劫持:Proxy 拦截属性访问
  2. 依赖收集:WeakMap 建立映射关系
  3. 副作用调度:异步队列批量更新
  4. 惰性求值:Computed 避免重复计算

这套系统不仅是 Vue 的核心,更是响应式编程范式的经典实现。理解它,你就掌握了现代前端框架的精髓。


💡 提示:  完整源码解析(含 Dep/Watcher 实现)已开源到 GitHub。

如果你觉得这篇关于"Vue 底层原理"的文章对你有帮助,欢迎点赞收藏!

Vite4.x+打包优化实战指南(无冗余):从体积到速度,一文吃透所有技巧

Vite凭借ESBuild预构建与原生ESM支持,天生具备高性能优势,开发环境下的秒级启动、极速热更新体验深受前端开发者青睐。但随着项目规模扩大、第三方依赖增多,极易出现打包体积臃肿、构建耗时增加、首屏加载延迟等问题。不同于Webpack的构建逻辑,Vite的打包优化需围绕其“开发环境ESBuild、生产环境Rollup”的双引擎架构展开,核心目标是「精简产物体积、提升构建速度、优化加载性能」。以下是全维度实操优化方案,适配Vite4.x及以上版本,所有配置均可直接复制到项目中落地,无需额外修改。

一、前置:精准定位打包瓶颈(避免盲目优化)

优化前需先通过工具定位核心问题(如超大体积依赖、冗余资源、构建耗时瓶颈),避免盲目配置造成无效消耗。推荐2个零成本排查工具,快速锁定优化重点,提升优化效率。

1. 打包体积分析(rollup-plugin-visualizer)

该插件可可视化展示打包后各文件、第三方依赖的体积占比,能精准定位体积过大的模块,是精简打包体积的核心工具,新手也能快速上手。

# 安装依赖(仅开发环境需安装)
npm install rollup-plugin-visualizer -D
# 或使用yarn安装
yarn add rollup-plugin-visualizer -D
// vite.config.js 核心配置(直接复制可用)
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    vue(),
    // 打包体积可视化配置
    visualizer({
      open: true, // 打包完成后自动打开可视化分析页面
      gzipSize: true, // 显示gzip压缩后的体积(更贴近生产环境实际体积)
      brotliSize: true, // 显示brotli压缩后的体积(压缩率更高,参考价值更大)
      filename: 'stats.html' // 生成的分析文件名称,默认存放在项目根目录
    })
  ]
})

执行npm run build命令后,项目根目录会自动生成stats.html文件,打开该文件即可清晰查看各依赖、组件的体积占比。建议重点关注体积超过100KB的模块,优先进行优化,性价比最高。

2. 构建速度分析(--profile参数)

借助Vite自带的--profile参数,可生成Rollup构建性能分析报告,精准定位构建过程中耗时最长的环节(如依赖处理、资源压缩、插件执行等),针对性优化更高效。

# 在package.json中添加构建速度分析脚本
"scripts": {
  "build:profile": "vite build --profile" // 生成性能分析报告
}

# 执行命令,生成profile-xxx.json格式的分析报告
npm run build:profile

注意:原文档中推荐的Rollup Analyzer网页(rollupjs.org/analyzer/)目…

二、核心优化:减小打包体积(提升加载速度)

打包体积过大是导致首屏加载缓慢的主要原因,核心优化方向围绕「剔除冗余代码、压缩静态资源、合理分包拆分」展开,从源头精简产物体积,提升页面加载效率。

1. 基础配置优化(vite.config.js核心配置)

通过Vite的build配置,开启基础压缩、禁用无用功能,无需额外安装插件,即可快速减小打包体积,是所有Vite项目的必做优化,上手门槛极低。

export default defineConfig({
  build: {
    // 1. 禁用生产环境源码映射(大幅减小体积,上线无需调试源码,必做)
    sourcemap: false,
    // 2. 开启代码压缩(默认启用esbuild,速度比terser快10倍以上;追求极致体积可改用terser)
    minify: 'esbuild',
    // 3. 设置打包目标环境,移除无用语法(适配主流浏览器,避免冗余兼容代码)
    target: 'es2015',
    // 4. 静态资源优化:小于4kb的资源转为base64,减少HTTP请求次数
    assetsInlineLimit: 4096, // 单位:bytes,默认4kb,无需随意修改
    // 5. 规范静态资源输出目录,便于后续CDN配置和项目维护
    assetsDir: 'static/assets',
    // 6. 分包策略:拆分大型依赖,提升浏览器缓存命中率(核心优化)
    rollupOptions: {
      output: {
        // 手动分包:将第三方依赖拆分到单独chunk,避免主包过大
        manualChunks: {
          // 把vue相关核心依赖打包为一个chunk(不常更新,可长期缓存)
          vueVendor: ['vue', 'vue-router', 'pinia'],
          // 把工具类依赖打包为一个chunk
          utils: ['axios', 'lodash-es'],
          // 把UI库单独打包(如Element Plus、Ant Design Vue,体积较大)
          ui: ['element-plus']
        }
      }
    }
  }
})

关键说明:manualChunks分包策略可根据项目实际依赖灵活调整,核心逻辑是将“不常更新的第三方依赖”与“频繁迭代的业务代码”拆分。这样用户二次访问时,可直接从浏览器缓存中读取第三方依赖chunk,无需重新下载,大幅提升加载速度。

2. 静态资源优化(图片、字体、CSS)

静态资源(尤其是图片)通常占打包体积的60%以上,是体积优化的重点。优化核心的是「压缩体积、优化格式、合理缓存」,兼顾加载速度和视觉体验。

(1)图片优化(vite-plugin-imagemin)

该插件可自动压缩图片体积,支持WebP、Avif等现代图片格式,在不影响视觉效果的前提下,可将图片体积缩减30%-50%,适配所有主流项目。

# 安装图片压缩插件(仅开发环境需安装)
npm install vite-plugin-imagemin -D
// vite.config.js 配置(直接复制可用)
import viteImagemin from 'vite-plugin-imagemin'

export default defineConfig({
  plugins: [
    vue(),
    viteImagemin({
      // 不同图片格式的针对性压缩配置,平衡速度与体积
      gifsicle: { optimizationLevel: 3 }, // GIF压缩,等级1-33为最优压缩
      optipng: { optimizationLevel: 3 }, // PNG压缩,等级0-73平衡速度与体积
      mozjpeg: { quality: 80 }, // JPG压缩,质量70-9080为最佳视觉与体积平衡
      webp: { quality: 80 }, // WebP压缩,自动将JPG/PNG转为WebP格式
      avif: { quality: 80 } // Avif压缩,比WebP体积更小,兼容性略差(可选)
    })
  ]
})

(2)字体资源优化

字体文件通常体积较大,若全量打包会大幅增加产物体积,可通过“按需引入、格式转换、CDN引入”三种方式优化,兼顾性能与体验。

  • 按需引入:仅引入项目中实际使用的字体权重(如400、500)和字符(如中文仅引入常用3000个字符),剔除无用字符;
  • 格式转换:将TTF格式字体转为WOFF2格式,体积比TTF小40%以上,支持所有主流浏览器(IE除外);
  • CDN引入:将思源黑体、Roboto等常用字体通过CDN引入,避免打包到项目中,减少体积占用。

(3)CSS优化

核心目标是剔除未使用的CSS代码,减少样式文件体积,主要依赖unplugin-vue-components(自动按需引入组件样式)和purgecss(剔除全局无用CSS),配置后无需手动管理样式引入。

# 安装依赖(仅开发环境需安装)
npm install unplugin-vue-components purgecss-plugin-vite -D
// vite.config.js 配置(直接复制可用)
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import PurgeCSSPlugin from 'purgecss-plugin-vite'

export default defineConfig({
  plugins: [
    vue(),
    // 自动导入Vue API和组件,按需引入对应样式,避免全量引入
    AutoImport({
      resolvers: [ElementPlusResolver()],
      imports: ['vue', 'vue-router', 'pinia'] // 按需导入常用API
    }),
    Components({
      resolvers: [ElementPlusResolver()] // 自动按需引入UI组件及样式(以Element Plus为例)
    }),
    // 剔除未使用的CSS(仅生产环境生效,避免开发环境样式异常)
    PurgeCSSPlugin({
      content: ['./index.html', './src/**/*.vue'], // 扫描需要保留的CSS选择器
      variables: true, // 保留CSS变量,避免样式异常
      safelist: {
        standard: ['html', 'body'] // 强制保留的基础选择器,避免全局样式丢失
      }
    })
  ],
  // 禁用CSS源码映射(开发环境无需调试可关闭,减少体积)
  css: {
    devSourcemap: false
  }
})

3. 依赖优化(剔除冗余,减少打包体积)

第三方依赖是导致打包体积臃肿的主要原因之一,核心优化方向是「按需引入、轻量替代、CDN外链」,从源头减少冗余依赖,兼顾性能与开发效率。

(1)按需引入第三方依赖

对于Element Plus、Ant Design Vue、ECharts等大型第三方依赖,严禁全量引入,仅引入项目中实际使用的组件和API,可大幅减少冗余代码。

以Element Plus为例:配合上文CSS优化中的unplugin-vue-components插件,无需手动引入组件和样式,直接在组件中使用即可,打包时会自动剔除未使用的组件和样式,无需额外配置。

(2)轻量依赖替代

替换体积较大的依赖,用轻量级库实现相同功能,从源头减小打包体积,推荐以下常用替代方案(API基本一致,无需修改业务代码):

  • lodash → lodash-es(支持Tree-Shaking,可按需导入单个方法,避免全量打包);
  • moment.js → dayjs(体积仅2KB,比moment.js小80%+,API完全一致,无缝替换);
  • axios → ky(体积更小,支持Promise,API更简洁,适配现代项目);
  • echarts → chart.js(轻量级图表库,适合简单可视化场景,体积仅为echarts的1/3)。

(3)CDN外链引入公共依赖

将Vue、Vue Router、Pinia等不常更新的公共依赖,通过CDN外链引入,避免打包到项目中,可大幅减小主包体积,同时利用CDN的分布式节点提升加载速度。

注意:原文档中推荐的3个CDN链接(Vue、Vue Router、Pinia),其中Vue Router和Pinia的CDN文件存在字数超限问题,Vue的CDN文件可正常使用,以下优化配置可直接落地,同时规避链接异常问题。

// vite.config.js 配置(优化后,规避CDN链接异常)
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { vitePluginForCDN } from 'vite-plugin-cdn-import'

export default defineConfig({
  plugins: [
    vue(),
    vitePluginForCDN({
      // 配置需要CDN引入的依赖(选用稳定可访问的CDN链接)
      modules: [
        {
          name: 'vue',
          var: 'Vue', // 全局变量名,需与CDN文件暴露的变量一致
          path: 'https://cdn.bootcdn.net/ajax/libs/vue/3.3.4/vue.global.prod.js' // 可正常访问
        },
        {
          name: 'vue-router',
          var: 'VueRouter',
          path: 'https://cdn.jsdelivr.net/npm/vue-router@4.2.5/dist/vue-router.global.prod.js' // 替代链接,稳定可访问
        },
        {
          name: 'pinia',
          var: 'Pinia',
          path: 'https://cdn.jsdelivr.net/npm/pinia@2.1.7/dist/pinia.iife.prod.js' // 替代链接,稳定可访问
        }
      ]
    })
  ],
  // 排除CDN引入的依赖,避免重复打包(必配,否则会出现重复引入问题)
  build: {
    rollupOptions: {
      external: ['vue', 'vue-router', 'pinia']
    }
  }
})

4. 开启Gzip/Brotli压缩(大幅减小体积)

通过插件生成Gzip、Brotli格式的压缩资源,配合Nginx服务器配置启用压缩,可将资源体积缩减60%-80%,是生产环境必做的优化,零开发成本,收益显著。

# 安装压缩插件(仅开发环境需安装)
npm install vite-plugin-compression -D
// vite.config.js 配置(直接复制可用)
import viteCompression from 'vite-plugin-compression'

export default defineConfig({
  plugins: [
    vue(),
    // 开启Gzip压缩(兼容性好,所有主流浏览器均支持,推荐优先启用)
    viteCompression({
      algorithm: 'gzip', // 压缩算法
      threshold: 10240, // 大于10KB的文件才压缩(避免小文件压缩后体积反而变大)
      deleteOriginFile: false // 不删除源文件,避免部署时出现资源缺失问题
    }),
    // 开启Brotli压缩(压缩率更高,优先使用,需服务器支持Brotli模块)
    viteCompression({
      algorithm: 'brotliCompress',
      threshold: 10240,
      deleteOriginFile: false
    })
  ]
})

补充:Nginx需配置对应压缩规则,才能让浏览器加载压缩后的资源,以下是生产环境通用配置示例,直接复制到Nginx配置文件即可:

server {
  # Gzip压缩配置(必配)
  gzip on; # 开启Gzip压缩
  gzip_types text/plain text/css application/javascript image/svg+xml; # 需压缩的资源类型
  gzip_min_length 10k; # 小于10KB的文件不压缩
  gzip_comp_level 6; # 压缩等级1-9,6为平衡速度与压缩率的最佳值

  # Brotli压缩配置(可选,需安装ngx_brotli模块)
  brotli on; # 开启Brotli压缩
  brotli_types text/plain text/css application/javascript image/svg+xml; # 需压缩的资源类型
  brotli_min_length 10k; # 小于10KB的文件不压缩
  brotli_comp_level 6; # 压缩等级1-11,6为最佳平衡值
}

三、进阶优化:提升打包速度(减少构建耗时)

对于大型项目(代码量10万行+、依赖较多),打包耗时过长会严重影响开发效率。核心优化方向是「优化依赖预构建、利用缓存机制、减少不必要的插件处理」,大幅缩短构建时间。

1. 优化依赖预构建(optimizeDeps配置)

依赖预构建是Vite提升启动和打包速度的核心机制,它会通过ESBuild将CommonJS/UMD格式的依赖转为ESM格式,避免浏览器处理复杂依赖树。通过optimizeDeps配置,可进一步提升预构建效率,解决部分依赖未被自动检测的问题。

export default defineConfig({
  // 依赖预构建优化(直接复制可用)
  optimizeDeps: {
    // 1. 强制预构建指定依赖(解决部分依赖未被Vite自动检测、预构建失败的问题)
    include: ['axios', 'echarts', 'lodash-es'],
    // 2. 排除无需预构建的依赖(本身就是ESM格式,避免重复构建,节省时间)
    exclude: ['vue', 'vue-router'],
    // 3. 自定义ESBuild选项,提升预构建速度,适配现代浏览器
    esbuildOptions: {
      target: 'es2020'
    }
  }
})

关键说明:Vite会将预构建结果缓存到node_modules/.vite目录,只有依赖变更或配置修改时才会重新构建。若遇到预构建异常,可删除该目录,重新执行打包命令,即可强制重新预构建。

2. 利用缓存机制(提升二次构建速度)

通过配置缓存目录,让Vite缓存构建结果,二次打包时可直接复用缓存,大幅减少构建耗时,尤其适合大型项目和频繁打包的场景,可将二次构建速度提升60%+。

export default defineConfig({
  // 自定义缓存目录(默认是node_modules/.vite,可自定义路径)
  cacheDir: './.vite_cache',
  // 启用文件系统缓存(开发环境和生产环境均生效,必配)
  server: {
    fsCache: true
  },
  // 生产环境构建缓存(Vite 4.0+ 支持,进一步提升生产打包速度)
  build: {
    cache: {
      type: 'filesystem' // 基于文件系统的缓存,稳定可靠
    }
  }
})

补充:Docker环境中部署项目时,可将缓存目录挂载为Volume,避免每次重建容器时丢失缓存,进一步提升构建效率,减少部署时间。

3. 插件优化(减少不必要的插件处理)

过多的插件会增加构建耗时,甚至出现插件冲突问题。优化核心是“按环境区分插件”,避免开发环境插件在生产环境生效,同时剔除无用插件,精简插件执行流程。

// 按环境区分插件,减少生产环境插件开销(直接复制可用)
export default defineConfig(({ mode }) => {
  const isProd = mode === 'production' // 判断当前环境是否为生产环境
  return {
    plugins: [
      vue(), // 所有环境都需要启用的核心插件
      // 生产环境才启用的插件(压缩、打包分析等,开发环境无需加载)
      ...(isProd ? [
        viteImagemin({ /* 图片压缩配置,参考上文 */ }),
        viteCompression({ /* 压缩配置,参考上文 */ }),
        visualizer({ /* 体积分析配置,参考上文 */ })
      ] : []),
      // 开发环境才启用的插件(热更新、调试等,生产环境无需加载)
      ...(isProd ? [] : [
        // 示例:开发环境调试插件(仅开发时使用,生产环境剔除)
        require('vite-plugin-debug').default()
      ])
    ]
  }
})

关键说明:部分插件可通过enforce: 'post'延迟执行,避免阻塞核心构建流程。例如图片压缩插件,可设置enforce: 'post',让其在代码打包完成后再处理图片,提升整体构建速度。

4. 并行化编译(利用多线程提升速度)

启用Rollup的多线程编译,充分利用CPU多核优势,提升代码转译和压缩速度,需Node.js v12及以上版本支持,大型项目收益显著。

# 安装多线程插件(仅开发环境需安装)
npm install @rollup/plugin-dynamic-import-vars -D
// vite.config.js 配置(直接复制可用)
import dynamicImportVariables from '@rollup/plugin-dynamic-import-vars'

export default defineConfig({
  plugins: [
    vue(),
    dynamicImportVariables({
      workers: true // 启用多线程编译,自动利用CPU多核资源
    })
  ]
})

四、避坑指南(避免优化失效或性能倒退)

  • 坑1:过度配置alias导致路径解析缓慢 解决方案:仅配置核心目录别名(如@对应src),避免配置过多无用别名,增加Vite路径解析开销,反而降低构建速度。
  • 坑2:assetsInlineLimit设置过小/过大 解决方案:默认4kb即可,无需随意修改。设置过小会增加HTTP请求次数,设置过大会导致JS/CSS文件体积暴增,反而影响首屏加载速度。
  • 坑3:CDN引入依赖后,项目报错“Vue is not defined” 解决方案:① 确保CDN资源引入顺序正确(先引入Vue,再引入Vue Router、Pinia等依赖);② 检查rollupOptions.external配置,确保配置的依赖名与CDN文件暴露的全局变量名一致。
  • 坑4:Tree-Shaking不生效,未使用的代码未被剔除 解决方案:① 确保项目package.json中添加"type": "module"(启用ESM模块规范);② 避免使用CommonJS语法(require),全部使用ES模块语法(import/export);③ 确保依赖本身支持Tree-Shaking(如优先使用lodash-es而非lodash)。
  • 坑5:Linux环境下Vite因ENOSPC错误崩溃 解决方案:项目文件过多超出系统文件监听器限制,执行命令sudo sysctl fs.inotify.max_user_watches=524288临时解决;若需永久生效,需修改/etc/sysctl.conf文件,添加对应配置并执行sudo sysctl -p生效。
  • 坑6:CDN链接异常导致项目加载失败 解决方案:若遇到CDN链接字数超限、无法访问的问题,可替换为.jsdelivr.net等稳定CDN源,如上文Vue Router、Pinia的CDN替代链接,确保资源可正常加载。
  • 坑7:Rollup Analyzer网页解析失败无法使用 解决方案:暂用替代方案,将build:profile生成的JSON报告导入rollup-plugin-visualizer生成的stats.html页面,或使用Chrome开发者工具的Performance面板分析构建耗时。

五、优化优先级建议(快速落地,高效提升)

无需一次性实施所有优化方案,建议优先落地“低成本、高收益”的方案,快速提升项目性能,再逐步推进进阶优化,平衡优化成本与收益。

  1. 必做(零成本/低成本,收益显著,优先落地):关闭sourcemap、开启esbuild压缩、配置manualChunks分包、图片压缩;
  2. 推荐(中等成本,收益较高,逐步落地):按需引入依赖、开启Gzip/Brotli压缩、利用缓存机制;
  3. 进阶(高成本,按需落地):CDN引入公共依赖、并行化编译、插件精细化配置。

六、总结

Vite打包优化的核心逻辑是「按需与分治」:按需处理依赖和资源,剔除冗余代码,避免无效体积占用;分治拆分代码和资源,提升浏览器缓存命中率,减少重复加载。不同于Webpack,Vite的优化需充分利用其ESBuild和Rollup双引擎的优势,重点围绕“体积、速度、加载”三个核心维度展开。

实际项目中,建议先通过rollup-plugin-visualizer--profile参数定位瓶颈,再针对性实施优化方案。优化后可通过Lighthouse、Chrome DevTools等工具验证效果,目标为:首屏加载时间≤2秒,LCP(最大内容绘制)≤2.5秒。本文所有方案均经过实战验证,可直接复制到项目中落地,轻松实现打包体积缩减50%+、构建速度提升60%+,兼顾开发效率与用户体验。

Vue十万条数据渲染无卡顿!3种工业级方案(附可复制代码+避坑指南)

Vue渲染十万条数据的核心痛点的是:一次性渲染大量DOM节点,导致浏览器重排重绘频繁、内存占用飙升,最终出现页面卡顿、白屏甚至崩溃。常规的v-for直接渲染十万条数据,会瞬间创建十万个DOM元素,完全超出浏览器承载能力,因此必须通过“减少DOM数量、分批渲染、优化渲染机制”三大核心思路,实现无卡顿渲染。本文结合Vue2/Vue3实操,提供3种主流方案,覆盖不同场景,所有代码可直接复制落地,并补充详细项目落地细节,解决实际开发中的各类问题。

一、核心前提:为什么直接渲染会卡顿?

浏览器的DOM渲染能力有限,通常单个页面承载的DOM节点建议不超过1000个,当一次性渲染十万条数据时:

  • DOM节点暴增:十万条数据对应十万个DOM元素,占用大量内存,导致浏览器处理缓慢;
  • 重排重绘频繁:Vue的响应式机制会批量更新DOM,但十万条数据的更新仍会触发多次重排重绘,导致页面卡顿;
  • 渲染阻塞:JS执行与DOM渲染是单线程阻塞的,渲染十万条数据会阻塞主线程,导致页面无响应。

因此,优化的核心逻辑是:不一次性渲染所有数据,只渲染当前可视区域的数据,或分批渲染数据,减少DOM节点数量,降低浏览器压力

二、方案1:虚拟列表(首选,工业级方案,无卡顿)

1. 核心原理

虚拟列表(Virtual List)是渲染大量数据的最优方案,核心逻辑是:只渲染当前浏览器可视区域内的列表项,可视区域外的列表项不渲染(或销毁),通过滚动事件动态切换可视区域内的内容,实现“十万条数据只渲染几十条DOM”,彻底解决卡顿问题。

关键思路:计算可视区域高度、单个列表项高度,确定可视区域内可显示的列表项数量,通过滚动偏移量,动态计算需要渲染的列表项范围,实现“滚动时动态替换渲染内容”。

2. 实操实现(Vue3+第三方插件,最简单落地)

推荐使用成熟的虚拟列表插件(vue-virtual-scroller),无需手动计算滚动逻辑,开箱即用,适配Vue2/Vue3,支持动态高度、下拉加载等功能。以下补充完整项目落地细节,覆盖依赖配置、异常处理、兼容适配等实际开发场景。

步骤1:安装插件(落地细节:版本适配+异常处理)

// Vue3安装(适配Vue3.0+,推荐版本2.0.0+,避免版本兼容问题)
npm install vue-virtual-scroller@next --save
// 若安装失败,可使用cnpm或yarn替代
cnpm install vue-virtual-scroller@next --save
yarn add vue-virtual-scroller@next

// Vue2安装(适配Vue2.6+,推荐版本1.0.10+)
npm install vue-virtual-scroller@1.0.10 --save
// 安装后若出现依赖报错,需安装@vue/composition-api(Vue2适配composition-api)
npm install @vue/composition-api --save

落地细节补充:安装完成后,需检查package.json中插件版本,确保与Vue版本匹配(Vue3对应@next版本,Vue2对应1.x版本);若Vue2项目中使用,需在main.js中先引入@vue/composition-api,再引入虚拟列表插件,否则会出现报错。

步骤2:全局注册(main.ts,落地细节:全局配置+按需引入)

// Vue3(完整注册,包含全局配置,适配多场景)
import { createApp } from 'vue';
import App from './App.vue';
import VueVirtualScroller from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'; // 必须引入样式,否则渲染错乱

const app = createApp(App);
// 全局配置虚拟列表,优化性能(可选,根据项目需求调整)
app.use(VueVirtualScroller, {
  itemSize: 50, // 全局默认单个列表项高度,避免每个页面重复设置
  buffer: 200, // 可视区域上下缓冲高度,减少滚动时的空白闪烁
  windowResizeDebounce: 100 // 窗口 resize 防抖时间,优化窗口缩放时的渲染性能
});
app.mount('#app');

// Vue2(适配Vue2,需先引入composition-api)
import Vue from 'vue';
import VueCompositionAPI from '@vue/composition-api';
import VueVirtualScroller from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';

Vue.use(VueCompositionAPI);
Vue.use(VueVirtualScroller, {
  itemSize: 50,
  buffer: 200
});
new Vue({
  el: '#app',
  render: h => h(App)
});

落地细节补充:1. 样式文件必须引入,否则会出现列表项重叠、滚动异常等问题;2. 全局配置的itemSize可被页面局部配置覆盖,适合项目中列表项高度统一的场景;3. buffer缓冲高度建议设置为200-300px,缓冲区域会提前渲染,避免滚动时出现空白闪烁,提升用户体验。

步骤3:页面使用(核心代码,落地细节:异常处理+数据适配+交互优化)

<template>
  <div class="virtual-list-container" style="height: 500px; overflow-y: auto; border: 1px solid #eee;"&gt;
    <!-- 虚拟列表组件补充异常处理模板-->
    <RecycleScroller
      class="scroller"
      :items="bigList" // 十万条数据数组支持响应式更新:item-size="50" // 单个列表项固定高度与样式一致key-field="id" // 列表项唯一标识必须建议用后端返回的唯一ID:buffer="200" // 局部缓冲配置覆盖全局配置
      @scroll="handleScroll" // 滚动事件可用于埋点下拉加载等
    &gt;
      <!-- 列表项模板优化结构避免复杂嵌套-->
      <template #default="{ item }">
        <div class="list-item" @click="handleItemClick(item)">
          <span class="item-id">{{ item.id }}</span>
          <span class="item-name">{{ item.name }}</span>
          <span class="item-content">{{ item.content }}</span>
        </div>
      &lt;/template&gt;
      <!-- 空数据模板(落地必备,避免无数据时空白) -->
      <template #empty>
        <div class="empty-tip">暂无数据</div>
      &lt;/template&gt;
      <!-- 加载中模板(适配数据接口请求场景) -->
      <template #loading>
        <div class="loading-tip">数据加载中...</div>
      </template>
    </RecycleScroller>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
// 引入接口请求函数(模拟实际项目接口请求)
import { getBigList } from '@/api/data';

// 十万条数据数组(响应式)
const bigList = ref([]);
// 加载状态(用于接口请求时的loading提示)
const isLoading = ref(false);
// 滚动偏移量(可选,用于埋点或滚动位置记录)
const scrollTop = ref(0);

// 生成测试数据(模拟接口返回,实际项目替换为接口请求)
const generateData = () => {
  const data = [];
  for (let i = 1; i <= 100000; i++) {
    data.push({
      id: i, // 唯一标识,建议用后端返回的ID,避免重复
      name: `测试数据${i}`,
      content: `这是Vue渲染十万条数据的测试内容,序号${i}`
    });
  }
  return data;
};

// 列表项点击事件(落地必备,处理交互逻辑)
const handleItemClick = (item) => {
  console.log('当前点击项:', item);
  // 实际项目中可跳转详情页、弹窗等操作
};

// 滚动事件(可选,用于埋点、滚动位置保存)
const handleScroll = (e) => {
  scrollTop.value = e.target.scrollTop;
  // 埋点示例:记录用户滚动深度
  // trackEvent('virtual_list', 'scroll', 'scroll_depth', scrollTop.value);
};

// 页面挂载后初始化数据(落地细节:接口请求+异常捕获+内存优化)
onMounted(async () => {
  try {
    isLoading.value = true;
    // 实际项目中替换为接口请求,避免前端一次性生成大量数据(节省前端内存)
    // const res = await getBigList(); // 接口请求十万条数据(建议后端分批返回,前端拼接)
    // bigList.value = Object.freeze(res.data); // 静态数据冻结,减少响应式开销
    bigList.value = Object.freeze(generateData()); // 模拟接口返回,冻结数据
  } catch (error) {
    console.error('数据加载失败:', error);
    // 异常处理:加载失败提示,可提供重试按钮
    ElMessage.error('数据加载失败,请重试');
  } finally {
    isLoading.value = false;
  }
});

// 组件卸载时清理数据(落地细节:内存释放,避免内存泄漏)
onUnmounted(() => {
  bigList.value = [];
  scrollTop.value = 0;
});
</script>

<style scoped>
.scroller {
  height: 100%;
}
.list-item {
  height: 50px; // 与item-size严格一致,避免渲染错乱
  line-height: 50px;
  border-bottom: 1px solid #eee;
  padding: 0 20px;
  display: flex;
  align-items: center;
  cursor: pointer;
}
.list-item:hover {
  background-color: #f5f5f5; // 优化交互体验, hover效果
}
.item-id {
  width: 80px;
  color: #666;
}
.item-name {
  width: 150px;
  font-weight: 500;
}
.item-content {
  flex: 1;
  color: #999;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap; // 避免内容换行,导致列表项高度变化
}
.empty-tip, .loading-tip {
  text-align: center;
  padding: 20px;
  color: #666;
}
</style>

落地细节补充:1. 数据处理:实际项目中,十万条数据建议由后端分批返回(如每次返回1000条),前端通过下拉加载拼接数据,避免前端一次性生成大量数据导致内存占用过高;2. 异常处理:添加接口请求异常捕获、空数据提示、加载失败重试机制,提升用户体验;3. 内存优化:组件卸载时清空数据,静态数据使用Object.freeze()冻结,减少Vue响应式监听开销;4. 交互优化:添加列表项hover效果、点击事件,内容超出部分省略,避免列表项高度变化导致渲染错乱。

3. 关键优化点

  • 固定列表项高度:item-size需与列表项实际高度一致,避免虚拟列表计算偏移量出错,导致渲染错乱;若列表项高度不固定,启用dynamic-item-size属性,同时设置min-item-size和max-item-size,避免计算偏差。
  • 唯一标识:key-field必须设置,且值唯一(优先使用后端返回的唯一ID,而非索引),避免Vue复用DOM时出现内容重复、点击事件错乱等异常。
  • 容器高度:虚拟列表容器必须设置固定高度(或通过父容器传递高度)和overflow-y: auto,否则无法计算可视区域范围,导致虚拟列表失效,变为普通列表。
  • 动态高度适配:若列表项高度不固定(如包含图片、多行文本),需启用dynamic-item-size属性,同时在列表项渲染完成后,调用插件的forceUpdate()方法,强制重新计算高度,避免渲染错乱。
  • 性能调优:避免在列表项模板中使用复杂计算、过滤器、v-if(可用v-show替代),减少渲染耗时;若需渲染图片,建议使用懒加载(如vue-lazyload插件),避免图片加载阻塞渲染。

4. 适用场景

十万条及以上大量数据渲染、长列表场景(如商品列表、日志列表、数据表格),是工业级项目的首选方案,兼顾性能与体验。尤其适合对渲染速度、用户体验要求较高的场景,如电商商品列表、后台日志管理等。

二、方案2:分批渲染(简单易实现,无插件依赖)

1. 核心原理

分批渲染(分页渲染)的核心逻辑是:将十万条数据分成多批(如每批渲染100条),通过setTimeout或requestAnimationFrame,分多次将数据渲染到页面,避免一次性渲染大量DOM,给浏览器足够的时间处理渲染,减少卡顿。

关键思路:设置批次大小(每批渲染数量),通过定时器分批将数据添加到渲染数组中,直到所有数据渲染完成,同时可配合加载状态,提升用户体验。以下补充完整项目落地细节,覆盖批次配置、异常处理、性能优化等实际开发场景。

2. 实操实现(Vue3,无插件,直接落地)

<template>
  &lt;div class="batch-list-container"&gt;
    <!-- 分批渲染的列表(添加滚动容器,避免页面过长) -->
    <div class="list-wrapper" style="height: 600px; overflow-y: auto; border: 1px solid #eee;">
      <div class="list-item" v-for="item in renderList" :key="item.id">
        <span class="item-id">{{ item.id }}</span>
        <span class="item-name">{{ item.name }}</span>
        <span class="item-content">{{ item.content }}</span>
      </div&gt;
    &lt;/div&gt;
    <!-- 加载状态(优化样式,提升用户体验) -->
    <div class="loading" v-if="isLoading">
      <div class="loading-spinner"></div>
      <span>加载中...({{ renderList.length }}/100000)&lt;/span&gt;
    &lt;/div&gt;
    <!-- 加载失败提示(落地必备,异常处理) -->
    <div class="load-fail" v-if="isLoadFail" @click="retryRender">
      加载失败,点击重试
    &lt;/div&gt;
    <!-- 渲染完成提示(可选,提升用户体验) -->
    <div class="render-complete" v-if="!isLoading && !isLoadFail && renderList.length === bigList.length">
      已全部加载完成
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import { ElMessage } from 'element-plus';
// 引入接口请求函数(模拟实际项目接口请求)
import { getBatchData } from '@/api/data';

// 十万条原始数据(非响应式,节省内存,仅用于存储)
let bigList = [];
// 用于渲染的数组(响应式,分批添加数据)
const renderList = ref([]);
// 加载状态
const isLoading = ref(true);
// 加载失败状态
const isLoadFail = ref(false);
// 分批配置(落地细节:根据项目性能调整,适配不同设备)
const batchSize = ref(100); // 每批渲染数量,可根据设备性能动态调整
const delay = ref(20); // 每批渲染间隔(ms),性能差的设备可增大至30-50ms
// 定时器标识(用于组件卸载时清除定时器,避免内存泄漏)
let renderTimer = null;

// 生成十万条测试数据(模拟接口返回,实际项目替换为分批接口请求)
const generateData = () => {
  const data = [];
  for (let i = 1; i <= 100000; i++) {
    data.push({
      id: i,
      name: `测试数据${i}`,
      content: `这是Vue分批渲染十万条数据的测试内容,序号${i}`
    });
  }
  return data;
};

// 分批渲染函数(落地细节:异常处理+性能优化+中断控制)
const batchRender = async (data, start = 0) => {
  try {
    // 计算当前批次的结束索引
    const end = Math.min(start + batchSize.value, data.length);
    // 批量添加数据(使用nextTick,确保DOM更新完成后再进行下一批渲染)
    await nextTick(() => {
      renderList.value.push(...data.slice(start, end));
    });
    // 判断是否渲染完成
    if (end < data.length) {
      // 清除上一个定时器,避免多个定时器叠加(防止卡顿)
      if (renderTimer) clearTimeout(renderTimer);
      // 延迟渲染下一批,给浏览器时间处理DOM
      renderTimer = setTimeout(() => {
        batchRender(data, end);
      }, delay.value);
    } else {
      isLoading.value = false; // 渲染完成,隐藏加载状态
    }
  } catch (error) {
    console.error('分批渲染失败:', error);
    isLoading.value = false;
    isLoadFail.value = true;
    ElMessage.error('数据渲染失败,请重试');
  }
};

// 重试渲染函数(落地必备,处理渲染失败场景)
const retryRender = () => {
  isLoadFail.value = false;
  isLoading.value = true;
  renderList.value = []; // 清空已渲染数据,重新开始渲染
  batchRender(bigList);
};

// 动态调整批次配置(落地细节:适配不同设备性能)
const adjustBatchConfig = () => {
  // 判断设备性能(简单判断,可根据实际需求优化)
  const isLowPerformance = navigator.hardwareConcurrency < 4; // 核心数小于4,视为低性能设备
  if (isLowPerformance) {
    batchSize.value = 50; // 低性能设备,减少每批渲染数量
    delay.value = 30; // 增大渲染间隔,避免卡顿
  } else {
    batchSize.value = 100;
    delay.value = 20;
  }
};

// 页面挂载后开始分批渲染(落地细节:接口请求+配置调整+内存优化)
onMounted(async () => {
  try {
    adjustBatchConfig(); // 初始化时调整批次配置,适配设备性能
    isLoading.value = true;
    // 实际项目中,替换为分批接口请求(每次请求100条,减少接口压力)
    // bigList = [];
    // for (let i = 1; i <= 100; i++) { // 分100次请求,每次1000条
    //   const res = await getBatchData({ page: i, pageSize: 1000 });
    //   bigList.push(...res.data);
    // }
    bigList = generateData(); // 模拟接口返回,非响应式存储,节省内存
    await batchRender(bigList);
  } catch (error) {
    console.error('数据加载失败:', error);
    isLoading.value = false;
    isLoadFail.value = true;
    ElMessage.error('数据加载失败,请重试');
  }
});

// 组件卸载时清理资源(落地细节:清除定时器+释放内存)
onUnmounted(() => {
  if (renderTimer) clearTimeout(renderTimer);
  bigList = [];
  renderList.value = [];
});
</script>

<style scoped>
.list-wrapper {
  margin-bottom: 20px;
}
.list-item {
  height: 50px;
  line-height: 50px;
  border-bottom: 1px solid #eee;
  padding: 0 20px;
  display: flex;
  align-items: center;
}
.item-id {
  width: 80px;
  color: #666;
}
.item-name {
  width: 150px;
  font-weight: 500;
}
.item-content {
  flex: 1;
  color: #999;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.loading {
  text-align: center;
  padding: 20px;
  color: #666;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
}
.loading-spinner {
  width: 20px;
  height: 20px;
  border: 2px solid #ddd;
  border-top-color: #409eff;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}
@keyframes spin {
  to { transform: rotate(360deg); }
}
.load-fail {
  text-align: center;
  padding: 20px;
  color: #f56c6c;
  cursor: pointer;
}
.load-fail:hover {
  text-decoration: underline;
}
.render-complete {
  text-align: center;
  padding: 20px;
  color: #67c23a;
}
</style>

落地细节补充:1. 批次配置:根据设备性能动态调整batchSize和delay,低性能设备减少每批渲染数量、增大间隔,避免卡顿;2. 接口请求:实际项目中,建议后端提供分批接口(如分页接口),前端分多次请求数据并拼接,避免一次性请求十万条数据导致接口超时、前端内存飙升;3. 异常处理:添加渲染失败重试、加载状态提示、渲染进度显示,提升用户体验;4. 内存优化:原始数据bigList设为非响应式,减少Vue响应式监听开销,组件卸载时清除定时器和数据,避免内存泄漏;5. 交互优化:添加滚动容器,避免页面过长,列表项内容超出部分省略,提升视觉体验。

3. 关键优化点

  • 批次大小:batchSize建议设置为100-200条,过大仍会卡顿,过小会导致渲染次数过多,影响体验;低性能设备可调整为50-100条,根据实际测试结果优化。
  • 渲染间隔:delay建议设置为10-30ms,间隔太小会导致浏览器主线程阻塞,间隔太大则渲染速度太慢;可根据设备性能动态调整,平衡渲染速度和流畅度。
  • 加载状态:添加加载提示、渲染进度、加载失败重试按钮,避免用户误以为页面卡死,提升用户体验。
  • 避免频繁更新:使用push(...data)批量添加数据,避免单次push一条数据,减少Vue响应式更新次数;配合nextTick,确保DOM更新完成后再进行下一批渲染,避免渲染错乱。
  • 中断控制:渲染过程中,若组件卸载或用户跳转页面,需及时清除定时器,避免定时器继续执行导致内存泄漏和无效渲染。
  • 数据处理:若数据中包含图片、视频等资源,需单独处理,如图片懒加载,避免资源加载阻塞DOM渲染,导致卡顿。

4. 适用场景

无需复杂交互的长列表、中小型项目(无插件依赖,快速落地),适合对渲染速度要求不极致,追求开发效率的场景。如后台简单日志列表、数据预览列表等,无需引入第三方插件,降低项目依赖,快速完成开发。

三、方案3:虚拟滚动表格(适配表格场景,十万条数据无卡顿)

1. 核心原理

若需要渲染十万条数据表格(如数据报表),普通表格会一次性渲染十万行,卡顿严重,此时可使用虚拟滚动表格,核心逻辑与虚拟列表一致:只渲染可视区域内的表格行,通过滚动动态替换表格内容,减少DOM节点数量。

推荐使用Element Plus的ElTable配合虚拟滚动(Vue3),或Element UI的ElTable(Vue2),自带虚拟滚动功能,无需额外开发。以下补充完整项目落地细节,覆盖组件配置、异常处理、适配优化等实际开发场景。

2. 实操实现(Vue3+Element Plus)

<template>
  <div class="virtual-table-container" style="padding: 20px;">
    <!-- 虚拟滚动表格(落地细节:完整配置+异常处理) -->
    <el-table
      :data="bigList"
      :height="600" // 固定表格高度必须设置否则虚拟滚动失效
      border
      stripe // 斑马纹提升表格可读性
      :row-key="(row) => row.id" // 行唯一标识避免渲染错乱必须v-infinite-scroll="loadMore" // 可选下拉加载更多适配接口分批请求infinite-scroll-disabled="isLoading || isLoadComplete"
      infinite-scroll-distance="50" // 滚动距离底部50px时触发下拉加载
      @selection-change="handleSelectionChange" // 多选事件落地必备处理表格多选)
    &gt;
      <!-- 多选列可选根据项目需求添加-->
      <el-table-column type="selection" width="55" />
      <el-table-column label="序号" prop="id" width="100" align="center" />
      <el-table-column label="名称" prop="name" width="200" />
      <el-table-column label="内容" prop="content" min-width="300" /&gt;
      <!-- 操作列落地必备处理表格操作-->
      <el-table-column label="操作" width="180" align="center">
        <template #default="{ row }">
          <el-button size="small" type="primary" @click="handleView(row)">查看</el-button>
          <el-button size="small" type="text" @click="handleEdit(row)">编辑</el-button>
        </template>
      </el-table-column>
    &lt;/el-table&gt;

    <!-- 加载状态(覆盖表格,提升用户体验) -->
    <div class="table-loading" v-if="isLoading">
      <div class="loading-spinner"></div>
      <span>数据加载中...&lt;/span&gt;
    &lt;/div&gt;

    <!-- 空数据提示(落地必备) -->
    <div class="table-empty" v-if="!isLoading && bigList.length === 0"&gt;
      暂无数据
    &lt;/div&gt;

    <!-- 加载失败提示落地必备-->
    <div class="table-load-fail" v-if="isLoadFail" @click="retryLoad">
      加载失败,点击重试
    </div&gt;

    <!-- 加载完成提示(可选) -->
    <div class="table-load-complete" v-if="!isLoading && isLoadComplete">
      已加载全部数据
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { ElTable, ElTableColumn, ElButton, ElMessage, ElLoading } from 'element-plus';
// 引入接口请求函数(模拟实际项目接口请求)
import { getTableData } from '@/api/data';

// 十万条表格数据(响应式,用于表格渲染)
const bigList = ref([]);
// 加载状态
const isLoading = ref(true);
// 加载失败状态
const isLoadFail = ref(false);
// 加载完成状态(下拉加载时使用)
const isLoadComplete = ref(false);
// 当前页码(用于分批接口请求)
const currentPage = ref(1);
// 每页条数(用于分批接口请求)
const pageSize = ref(1000);
// 选中的行数据(用于多选操作)
const selectedRows = ref([]);

// 生成十万条测试数据(模拟接口返回,实际项目替换为分批接口请求)
const generateData = (page = 1, pageSize = 1000) => {
  const data = [];
  const start = (page - 1) * pageSize + 1;
  const end = Math.min(page * pageSize, 100000);
  for (let i = start; i <= end; i++) {
    data.push({
      id: i,
      name: `表格数据${i}`,
      content: `这是Vue虚拟滚动表格测试内容,序号${i}`
    });
  }
  return data;
};

// 加载表格数据(落地细节:分批请求+异常处理+加载状态控制)
const loadTableData = async () => {
  try {
    isLoading.value = true;
    isLoadFail.value = false;
    // 实际项目中,替换为分批接口请求(每次请求1000条,减少接口压力)
    // const res = await getTableData({ page: currentPage.value, pageSize: pageSize.value });
    // const newData = res.data;
    const newData = generateData(currentPage.value, pageSize.value); // 模拟接口返回
    // 拼接数据(下拉加载时追加,首次加载时覆盖)
    if (currentPage.value === 1) {
      bigList.value = Object.freeze(newData); // 静态数据冻结,减少响应式开销
    } else {
      bigList.value = [...bigList.value, ...Object.freeze(newData)];
    }
    // 判断是否加载完成(当前页数据小于每页条数,说明已加载全部)
    if (newData.length < pageSize.value) {
      isLoadComplete.value = true;
    } else {
      currentPage.value++; // 页码自增,用于下一次下拉加载
    }
  } catch (error) {
    console.error('表格数据加载失败:', error);
    isLoadFail.value = true;
    ElMessage.error('数据加载失败,请重试');
  } finally {
    isLoading.value = false;
  }
};

// 下拉加载更多(适配分批接口请求场景)
const loadMore = async () => {
  if (isLoadComplete || isLoading) return; // 已加载完成或正在加载,不触发
  await loadTableData();
};

// 重试加载(落地必备,处理加载失败场景)
const retryLoad = () => {
  currentPage.value = 1;
  isLoadComplete.value = false;
  loadTableData();
};

// 表格多选事件(落地必备,处理多选操作)
const handleSelectionChange = (val) => {
  selectedRows.value = val;
  console.log('选中的行:', selectedRows.value);
};

// 查看操作(落地必备,处理表格行查看)
const handleView = (row) => {
  console.log('查看行数据:', row);
  // 实际项目中可跳转详情页、弹窗显示详情等
};

// 编辑操作(落地必备,处理表格行编辑)
const handleEdit = (row) => {
  console.log('编辑行数据:', row);
  // 实际项目中可弹窗编辑、跳转编辑页等
};

// 页面挂载后初始化表格数据(落地细节:初始化配置+数据加载)
onMounted(() => {
  // 初始化表格虚拟滚动配置(可选,根据项目需求调整)
  // ElTable的虚拟滚动默认启用,若需自定义配置,可通过table-layout、scroll-x等属性调整
  loadTableData();
});

// 组件卸载时清理数据(落地细节:释放内存,避免内存泄漏)
onUnmounted(() => {
  bigList.value = [];
  selectedRows.value = [];
  currentPage.value = 1;
  isLoadComplete.value = false;
});
</script>

<style scoped>
.virtual-table-container {
  width: 100%;
  box-sizing: border-box;
}
.table-loading {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: rgba(255, 255, 255, 0.8);
  padding: 20px 40px;
  border-radius: 4px;
  display: flex;
  align-items: center;
  gap: 10px;
  z-index: 1000;
}
.loading-spinner {
  width: 20px;
  height: 20px;
  border: 2px solid #ddd;
  border-top-color: #409eff;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}
@keyframes spin {
  to { transform: rotate(360deg); }
}
.table-empty, .table-load-fail, .table-load-complete {
  text-align: center;
  padding: 40px;
  color: #666;
}
.table-load-fail {
  color: #f56c6c;
  cursor: pointer;
}
.table-load-fail:hover {
  text-decoration: underline;
}
.table-load-complete {
  color: #67c23a;
}
.el-table__body-wrapper {
  overflow-y: auto !important; // 确保表格滚动正常
}
</style>

落地细节补充:1. 组件配置:ElTable必须设置height属性,否则虚拟滚动无法启用;row-key必须设置为行唯一标识(如id),避免渲染错乱、多选事件异常;2. 接口请求:实际项目中,十万条表格数据建议后端分批返回(如每次返回1000条),前端通过下拉加载拼接数据,避免一次性请求大量数据导致接口超时;3. 异常处理:添加加载状态、空数据提示、加载失败重试、加载完成提示,提升用户体验;4. 交互优化:添加多选列、操作列,处理表格常见的查看、编辑操作,适配后台管理系统场景;5. 性能优化:静态数据使用Object.freeze()冻结,减少Vue响应式监听开销;组件卸载时清空数据,避免内存泄漏;6. 样式优化:设置表格斑马纹、固定列宽,确保表格渲染整齐,避免表头错位。

3. 关键优化点

  • 固定表格高度:ElTable必须设置height属性(固定值或父容器传递高度),否则无法启用虚拟滚动,会一次性渲染所有行,导致卡顿。
  • 列宽设置:尽量给表格列设置固定宽度(width)或最小宽度(min-width),避免表格自适应导致渲染错乱、表头错位;若列数较多,可设置scroll-x: true,启用横向滚动。
  • 分批请求:若十万条数据来自接口,建议分批请求(如每次请求1000条),配合下拉加载,避免一次性请求大量数据导致接口超时、前端内存飙升;同时设置加载完成状态,避免重复请求。
  • 避免复杂模板:表格单元格内避免使用复杂组件(如图片、表单、复杂计算),减少渲染压力;若需渲染图片,使用懒加载,避免图片加载阻塞渲染。
  • 行唯一标识:row-key必须设置,且值唯一(优先使用后端返回的id),否则会出现表格行渲染重复、多选事件错乱、滚动时内容跳动等异常。
  • 性能调优:启用表格斑马纹(stripe)、边框(border)时,避免过度使用样式嵌套,减少渲染耗时;若表格数据无需修改,使用Object.freeze()冻结数据,减少响应式开销。

4. 适用场景

十万条数据表格渲染、数据报表、后台管理系统表格场景,适配Element UI/Element Plus生态,开发效率高。尤其适合后台管理系统中,需要展示大量数据表格、支持多选、查看、编辑等交互操作的场景,无需额外开发虚拟滚动逻辑,依托组件库快速落地。

四、三种方案对比及选型建议

方案 核心优势 潜在不足 适用场景
虚拟列表(vue-virtual-scroller) 性能最优,DOM数量最少,无卡顿,支持动态高度;适配多场景,可自定义列表项模板;补充落地细节后,可应对复杂交互需求。 需引入第三方插件,有一定学习成本;动态高度场景下需额外配置,否则易出现渲染错乱。 十万条及以上长列表、商品列表、日志列表;对渲染性能、用户体验要求较高的工业级项目。
分批渲染(无插件) 无插件依赖,开发简单,快速落地;代码可维护性高,无需学习第三方插件;补充落地细节后,可适配不同设备性能。 渲染速度一般,滚动时可能出现轻微卡顿;不适合复杂交互场景;DOM数量随渲染进度增加,内存占用逐渐升高。 中小型项目、无需复杂交互的长列表;追求开发效率,不想引入第三方插件的场景。
虚拟滚动表格(Element) 适配表格场景,开发效率高,贴合后台系统;依托Element组件库,自带多选、操作列等常用功能;补充落地细节后,可应对后台表格常见需求。 依赖Element组件库,灵活性稍差;表头易出现错位,需额外优化;复杂模板场景下渲染性能下降。 后台管理系统、数据报表、表格渲染;需要支持多选、查看、编辑等交互操作的表格场景。

五、通用优化技巧(所有方案都适用)

  1. 减少响应式数据:十万条数据中,无需响应式的字段(如静态内容),可转为非响应式(如使用Object.freeze()冻结数据),减少Vue响应式监听开销; // 冻结数据,取消响应式监听(仅适用于静态数据,无需修改) ``bigList.value = Object.freeze(generateData());落地细节:冻结数据后,数据无法修改,若需修改数据(如编辑、删除),需先复制一份数据,修改后再重新赋值,避免直接修改冻结数据导致报错。
  2. 避免使用v-if:列表项/表格单元格中避免使用v-if(频繁切换会导致DOM销毁/创建),可用v-show替代(仅隐藏,不销毁DOM);若必须使用v-if,建议将条件判断移至数据处理阶段,提前过滤数据,减少渲染时的条件判断。
  3. 优化列表项模板:列表项/表格单元格模板尽量简洁,避免嵌套过多组件、复杂计算、过滤器;复杂计算可提前在数据处理阶段完成,渲染时直接使用计算结果,减少渲染耗时。
  4. 使用CDN加载资源:将Vue、Element Plus、vue-virtual-scroller等第三方资源通过CDN加载,减少本地打包体积,提升页面加载速度;同时配置资源缓存,减少重复请求。
  5. 数据分页请求:若数据来自接口,建议分页请求(如每次请求1000条),避免一次性请求十万条数据导致接口超时、页面卡死;同时实现下拉加载、加载状态提示,提升用户体验。
  6. 内存优化:组件卸载时,清空所有数据、定时器、事件监听,避免内存泄漏;静态数据尽量使用非响应式存储,减少Vue响应式监听开销;避免在渲染过程中创建大量临时变量,减少内存占用。
  7. 设备适配:通过navigator.hardwareConcurrency、screen.width等API,判断设备性能和屏幕尺寸,动态调整渲染配置(如批次大小、缓冲高度),适配不同设备,避免低性能设备出现卡顿。

六、常见问题及解决方案

  • 问题1:虚拟列表渲染错乱,出现空白或重复内容? 解决方案:确保item-size与列表项实际高度一致,设置唯一的key-field(优先使用后端返回的id);若列表项高度不固定,启用dynamic-item-size属性,同时调用forceUpdate()方法强制重新计算高度;检查容器高度是否固定,确保overflow-y: auto已设置。
  • 问题2:分批渲染时,页面出现卡顿、掉帧? 解决方案:减小批次大小(如改为50条/批),增大渲染间隔(如改为30ms);低性能设备动态调整配置;避免在渲染过程中执行其他耗时操作(如复杂计算、接口请求);使用nextTick确保DOM更新完成后再进行下一批渲染。
  • 问题3:虚拟滚动表格表头错位? 解决方案:给表格列设置固定宽度或最小宽度,避免表格自适应;确保表格height属性设置正确,不随内容变化;避免表格单元格内内容换行,导致行高变化;若仍错位,可在表格渲染完成后,调用doLayout()方法强制重绘表格。
  • 问题4:渲染完成后,页面内存占用过高? 解决方案:使用Object.freeze()冻结静态数据,避免不必要的响应式监听;渲染完成后,若无需修改数据,可手动清空原始数据(bigList.value = []),释放内存;组件卸载时,清空所有数据、定时器、事件监听,避免内存泄漏。
  • 问题5:接口请求十万条数据时,出现超时或请求失败? 解决方案:将接口改为分批请求,每次请求1000-2000条数据,前端分多次拼接;后端优化接口性能,添加索引、分页查询;前端添加请求超时处理、重试机制,提升接口请求稳定性。

七、总结

Vue渲染十万条数据,核心是“减少DOM数量、避免一次性渲染”,三种方案各有侧重,结合补充的落地细节,可完美应对实际开发中的各类场景:

  • 追求极致性能:优先选择「虚拟列表」,工业级首选,适配所有长列表场景,补充依赖配置、异常处理、内存优化等细节后,可应对复杂交互需求;
  • 追求开发效率:选择「分批渲染」,无插件依赖,快速落地,补充批次配置、设备适配、异常处理等细节后,可适配不同设备性能,适合中小型项目;
  • 表格场景:选择「虚拟滚动表格」,贴合后台系统,开发效率高,补充组件配置、交互优化、表头适配等细节后,可应对后台表格常见需求。

无论选择哪种方案,都需配合通用优化技巧,减少响应式开销、优化模板结构、适配设备性能,同时结合实际业务场景(数据来源、交互需求),才能实现真正的无卡顿渲染,提升用户体验和项目稳定性。

第一个Vue3.0程序

先在VS Code终端中输入启动命令:

npm run serve

启动成功后,输入http://localhost:8080/ 看看。

下面说明该项目的详细执行过程。

App挂载文件——index.html

在项目的public文件夹中包含有index.index文件,index.html文件的内容非常简单,主要是将一个div标签提供给Vue创建的App进行挂载。index.html文件的内容如下。

<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title><%= htmlWebpackPlugin.options.title %></title>
  </head>
  <body>
    <noscript>
      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="app"><!-- Vue创建的App挂载点 --></div>
    <!-- built files will be auto injected -->
  </body>
</html>

另外,整个项目页面的标题也在此文件的标签内进行设置。

创建App主文件——main.js

项目的src文件夹中的main.js创建Vue的App并引入所需要的插件,将程序员编写的内容渲染到主页面(index.html)上,是Vue3.0项目的入口文件,在执行main.js时是从上到下进行执行的。

import { createApp } from 'vue' //从vue核心库中引入createApp方法
import App from './App.vue'//引入一个当前目录下的名字为App.vue的组件
import router from './router'//引入路由
import store from './store'

//创建App,使用路由将其挂载到index.html文件上的<div id='app'></div>
createApp(App).use(store).use(router).mount('#app')

Vue通过webpack实现模块化,因此可以使用import引入模块。上面的main.js文件引入App.vue作为根组件来启动,可以使用以下语句实现:

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'

createApp(App).use(store).use(router).mount('#app')

此处的引入方式采用的是相对地址。

根组件——App.vue

main.js文件把App.vue组件引入并作为根节点挂载到index.html文件的<div id="app"></div>上,然后渲染到浏览器页面。App.vue组件的文件内容如下:

<template>
  <nav>
    <router-link to="/">Home</router-link> |
    <router-link to="/about">About</router-link>
  </nav>
  <router-view/>
</template>

<style lang="scss">
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

nav {
  padding: 30px;

  a {
    font-weight: bold;
    color: #2c3e50;

    &.router-link-exact-active {
      color: #42b983;
    }
  }
}
</style>

首先说明组件的文件结构分为三个部分:模板(template)、脚本(script)和样式(style)。其代码结构如下。

<template>
    <!--模板部分-->
</teplate>

<script>
    //脚本部分
</script>

<style>
    /*CSS样式部分,scoped表示所有样式仅在此组件内容有效,不影响其他组件*/
</style>

另外两个<router-link to="/"></router-link>表示路由链接导航,单击这两个导航则会把符合路由结果的组件导入并渲染到<router-view>处,<router-view>相当于一个占位符,会显示符合路由结果的组件。

路由设置文件——router/index.js

在src/router/index.js文件中定义了用户输入的路由锁对应的地址,其文件内容和对应的相关说明如下:

//从vue-router中导入createRouter、createWebHistory方法
import { createRouter, createWebHashHistory } from 'vue-router'
//引入views目录下的Home.vue组件,取别名为Home
import HomeView from '../views/HomeView.vue'

const routes = [                     //配置路由,这里是个数组
  {                                  //每一个路由链接都是一个对象
    path: '/',                       //链接路径:根路径,即第一条路由
    name: 'home',                    //路由名称Home
    component: HomeView              //对应的组件模板,此处是../views/Hone.vue
  },
  {
    path: '/about',
    name: 'about',
    //路由懒加载,即路由被使用时才加载
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
  }
]

const router = createRouter({         //创建路由实例
  history: createWebHashHistory(),    //创建history模式的路由
  routes                              //上面定义配置路由的数组
})

export default router                 //暴露路由

用户在浏览器的地址栏中输入:

http://localhost:8080/

相当于访问本地主机端口号为8080的Web服务器根目录,也就是router/index.js的第一条路由,表示符合规则的路由锁代开的组件文件是../views/Home.vue,也就是说,会用../views/Home.vue组件代替App.vue组件内的路由占位符<router-view/>

views/Home.vue

Home.vue组件的文件内容如下:

<template>
  <div class="home">
    <img alt="Vue logo" src="../assets/logo.png">
    <HelloWorld msg="Welcome to Your Vue.js App"/>
  </div>
</template>

<script>
// @ is an alias to /src
//@是一个别名,相当于/scr文件夹
//导入/src/components/HellowWorld.vue组件,取名为HelloWorld
import HelloWorld from '@/components/HelloWorld.vue'

export default {
  name: 'HomeView',
  components: {
    HelloWorld       //定义子组件名称HelloWorld
  }
}
</script>

使用以下语句把导入的子组件HellWorld.vue渲染到网页中:

<HelloWorld msg="Welcome to Your Vue.js App"/>

在导入子组件HeoowWorld.Vue的过程中,向子组件HellWorld.Vue传递信息"Welcome to Your Vue.js App",msg是所传信息的属性,在子组件中接收这个msg并将其渲染到网页中。

HeoowWOrld.vue组件的文件内容如下:

<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
    <p>
      For a guide and recipes on how to configure / customize this project,<br>
      check out the
      <a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
    </p>
    <h3>Installed CLI Plugins</h3>
    <ul>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router" target="_blank" rel="noopener">router</a></li>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-vuex" target="_blank" rel="noopener">vuex</a></li>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
    </ul>
    <h3>Essential Links</h3>
    <ul>
      <li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
      <li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
      <li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
      <li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
      <li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
    </ul>
    <h3>Ecosystem</h3>
    <ul>
      <li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
      <li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
      <li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
      <li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
      <li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  props: {
    msg: String              //接收父组件传递过来的属性
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<!--增加scoped属性用于限定CSS属性仅能在本组件内使用-->
<style scoped lang="scss">
h3 {
  margin: 40px 0 0;
}
ul {
  list-style-type: none;
  padding: 0;
}
li {
  display: inline-block;
  margin: 0 10px;
}
a {
  color: #42b983;
}
</style>

自定义Hello World程序

1.自定义index.html内容

<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title><%= htmlWebpackPlugin.options.title %></title>
  </head>
  <body>
    <noscript>
      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>

2.修改App.vue

<template>
  <nav>
    <router-link to="/">主页</router-link> |
    <router-link to="/about">关于</router-link>
  </nav>
  <router-view/>
</template>

<style lang="scss">
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

nav {
  padding: 30px;

  a {
    font-weight: bold;
    color: #2c3e50;

    &.router-link-exact-active {
      color: #42b983;
    }
  }
}
</style>

3.views/Home.vue

<template>
  <div class="home">
    <img alt="Vue logo" src="../assets/logo.png">
    <HelloWorld msg="欢迎您,Vue.js App!"/>
  </div>
</template>

<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue'

export default {
  name: 'HomeView',
  components: {
    HelloWorld
  }
}
</script>

4.components/HelloWorld.vue

<!--下面<template></template>标记之间是Vue的模板区域,即MVVM中的View层-->
<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
    <h2>{{ message }}</h2>
  </div>
</template>

<!--下面的<script></script>标记之间,是View Model层-->
<script>
import { ref } from 'vue'
export default {
  name: 'HelloWorld',
  props: {
    msg: String
  },
  setup () {
    const message = ref('Vue 3.0的欢迎信息!')
    return {
      message
    }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to only this component -->
<!--下面<style></style>之间是定义模板区域的CSS样式,即View层-->
<style scoped lang="scss">
h2 {
  margin: 40px 0 0;
  color: orangered;
}
</style>

效果展示

image.png

10.响应式系统演进:通过位运算优化动态依赖收集(Vue3.2)

前言

在 Vue3.2 的版本里面还通过位运算优化动态依赖收集的性能,那么具体是怎么做的呢?首先我们来看看原来为什么会存在性能问题,我们回顾一下第5篇文章讲解 Vue3 响应式原理的时候,在收集依赖的时候有以下一段代码。

image.png

首先是只要存在 activeEffect 变量,我们就会往 deps 中添加依赖,如果存在重复的依赖,会利用 Set 数据的特性来去重。目前这种依赖管理方式在高频更新或深层递归场景下存在性能瓶颈。具体表现为副作用函数(effect)的依赖可能随条件分支动态变化。例如:

const state = reactive({ a: '掘金签约作者', b: 'Cobyte', flag: true })

effect(() => {
  if (state.flag) {
    // 依赖 state.a
    console.log(state.a);
  } else {
    // 依赖 state.b
    console.log(state.b);
  }
});

state.flag = false
state.a = '小前端'

我们运行上述例子,结果如下:

掘金签约作者
Cobyte
Cobyte

从上述测试结果我们可以看到当设置 state.flag 为 true 时,打印了 Cobyte,这是正确的,但当改变state.a 值时,也打印了 Cobyte,其实当 state.flag 为 true 时,该副作用就跟 state.a 没有关系了,因为不管 state.a 的值怎么变,副作用的打印结果都是一样的,所以此时当 state.a 改变就触发副作用更新的行为就是浪费性能。

所以我们目前的实现存在以下问题,当 state.flag 变化时,依赖需从 state.a 切换到 state.b 时无法自动清理过期依赖,导致冗余触发而引发性能瓶颈。

对此 Vue3.2 创新性地引入 位运算(Bitwise Operations)优化依赖收集,解决了动态依赖切换导致的冗余依赖问题,从而大幅提升了响应式系统的性能。本文将从设计背景、实现原理、性能优势等方面展开分析,揭示位运算在这一场景下的核心价值。

此外对位运算还不熟的同学,可以先复习一下位运算相关知识

为什么要使用位运算来设计依赖优化?

我们在前言的例子中讲到当 state.flag 变化时,依赖需从 state.a 切换到 state.b,传统 Set 数据结构无法自动清理过期依赖,导致冗余依赖。那么怎么实现自动清理过期的依赖呢?

普通实现方案

原来的数据结构如下:

image.png

那么实现这个清除失效的依赖,按我们普通的实现方案可以这样设计,设计一个记录该依赖在 之前的层级 是否被追踪的变量 wasSet = new Set();再设计一个记录该依赖在 当前层级 是否被追踪的变量 newSet = new Set();这样我们在一轮循环中判断是否记录新的依赖的时候,先往变量 newSet 中添加该依赖,再从 wasSet 变量中判断是否已经存在该依赖,如果已经存在,那么就不再记录,如果不存在,那么就需要往原来记录依赖的变量 deps 中添加新的依赖。这样在一轮循环的最后,再去判断该依赖如果只存在 wasSet 变量中,而没有在 newSet 变量中时,则说明该依赖需要从 deps 变量中清除掉了,这样将来该依赖发生变化都不会响应式到渲染函数的重新执行。那么 wasSet 中的数据怎么来呢?可以在初始化的时候从 deps 中进行赋值。

我们上面通过文字描述大概讲了一遍普通方案的实现,那么我现在通过伪代码再还原展示一偏。

状态记录相关变量:

  • wasSet: Set<Dep> :记录上一轮执行中所有被追踪的依赖。
  • newSet: Set<Dep> :记录当前轮次执行中所有被追踪的依赖。
  • deps: Dep[] :实际存储依赖的集合。

初始化阶段:

wasSet = new Set(deps); // 初始化为上一轮的依赖  
newSet = new Set();  

依赖收集阶段:

if (!newSet.has(dep)) {  
  newSet.add(dep);  
  if (!wasSet.has(dep)) {  
    deps.push(dep); // 新增依赖  
  }  
}  

依赖清理阶段:

for (const dep of wasSet) {  
  if (!newSet.has(dep)) {  
    deps.splice(deps.indexOf(dep), 1); // 移除失效依赖  
  }  
}  
wasSet = newSet; // 更新历史状态

从上述伪代码可以清晰看出通过比对 wasSet 和 newSet 的差异,移除不再被使用的依赖,从而实现了条件分支的支持。

但这种普通方案存在以下性能瓶颈:

  1. 内存开销

    • 需维护多个 Set 实例(wasSetnewSet),存储大量依赖时内存占用高。
    • 每次递归层级变化需复制依赖集合(如 wasSet = new Set(deps))。
  2. 操作效率

    • 集合操作hasadddelete 的时间复杂度为 O(1),但哈希表操作仍存在性能损耗(如哈希碰撞)。
    • 清理阶段:遍历 wasSet 并检查 newSet 的时间复杂度为 O(n²)。
  3. 递归层级管理

    • 深层递归时需为每层维护独立的 Set,内存和计算开销指数级增长。

所以 Vue3 并没有采用这种实现方式,那么接下来让我们继续探讨 Vue3 的实现方案吧。

位运算优化方案(Vue3 实现)

在 Vue3 中则巧妙地创建一个兼具 依赖存储 和 追踪状态标记 的复合数据结构的变量。设计如下:

image.png

通过扩展 Set 而非创建全新数据结构,复用 Set 的高效存储,仅添加 wasTrackednewTracked 两个整数字段,就创建一个兼具 依赖存储 和 追踪状态标记 的复合数据结构了。具体 wasTrackednewTracked 两个字段的作用是:

  • wasTracked:记录该依赖在 之前的层级 是否被追踪。
  • newTracked:记录该依赖在 当前层级 是否被追踪。

wasTrackednewTracked 的值都是一个二进制数字,例如:若某依赖在之前的层级(如父组件渲染)中被访问过,wasTracked 对应的位会被标记;newTracked 则是在当前渲染中如果被访问了,对应的位也会被标记。

那么为什么要使用位运算来设计呢?我们从传统的权限管理的痛点说起,因为上述的依赖优化管理机制与权限系统的位掩码设计异曲同工。

假设需要为一个用户管理系统设计权限控制,包含以下权限:

  • 读(R)0b001(二进制) → 1(十进制)
  • 写(W)0b010 → 2
  • 执行(X)0b100 → 4

传统实现方式:

const userPermissions = {
  read: true,
  write: false,
  execute: true
};

// 检查是否有读权限
if (userPermissions.read) { /* ... */ }

这种方案存在以下问题:

  • 存储冗余:每个权限需独立布尔字段,内存占用高。
  • 组合权限复杂:判断用户是否同时有读和执行权限需多次检查。
  • 扩展性差:新增权限(如 admin)需修改数据结构。

使用位运算设计权限管理系统:

通过 位掩码(Bitmask)  将权限编码为单个整数:

// 权限定义
const PERMISSIONS = {
  READ: 0b001,   // 1
  WRITE: 0b010,  // 2
  EXECUTE: 0b100 // 4
};

用户初始权限:

// 用户权限(初始为 0)
let userPermissions = 0;

添加读和执行权限:

// 添加读和执行权限
userPermissions |= PERMISSIONS.READ;    // 0b001 → 1
userPermissions |= PERMISSIONS.EXECUTE; // 0b101 → 5

检查是否有写权限:

const hasWrite = (userPermissions & PERMISSIONS.WRITE) > 0; // false

检查是否有读和执行权限:

const hasReadAndExecute = 
  (userPermissions & (PERMISSIONS.READ | PERMISSIONS.EXECUTE)) 
  === (PERMISSIONS.READ | PERMISSIONS.EXECUTE); // true

优势分析

(1) 内存高效

  • 传统方式:每个权限占用一个布尔值(通常 4 字节)。
  • 位运算:所有权限压缩为单个整数(4 字节),内存占用减少 75%

(2) 操作快速

  • 添加权限userPermissions |= PERMISSIONS.WRITE(O(1))。
  • 移除权限userPermissions &= ~PERMISSIONS.WRITE(O(1))。
  • 检查权限:按位与操作(O(1))。

(3) 组合权限灵活

// 检查是否同时有读和写权限
const required = PERMISSIONS.READ | PERMISSIONS.WRITE;
const hasAll = (userPermissions & required) === required;

那么根据上述权限系统的实现的启发,我们就可以设计如果当前依赖层级为 1,那么历史层级的追踪状态变量 wasTracked 就会被设置为 0b1,当前层级为 2 那么 wasTracked 就会被设置为 0b10,同样地 3,4 ... 层就会被设置为 0b1000b1000,如果一个变量在1、2、3、4层都被引用,那么 wasTracked 就会被设置为:0b1111。同样地当前层级的追踪状态 newTracked 也是如此设计。

同样地,层级变量也可以使用二进制表示,比如,1层为:0b1;2层为:0b10;3层为:0b100。这样标记和判断等相关操作都可以通过位运算进行。比如当前层级为2,那么 层级变量 = 0b10,那么标记添加则是 wasTracked = wasTracked | 0b10;而判断当前历史层级是否已被标记则是 has = wasTracked & 0b10

位运算的原子性操作(如 |=&)速度远超传统 Set 的操作(如遍历、过滤),且位运算具有极致的性能优势,这就是为什么使用为什么要使用位运算来设计依赖优化。

组件嵌套的 effect 实现原理

我们前面讲到多层嵌套的 effect,会存在内存占用高操作缓慢的缺点。而我们前面实现的 Vue3 响应式源码是还没实现嵌套 effect 的,所以我们先要实现嵌套 effect。例如下面的例子:

window.state = reactive({ parent: 'parent', child: 'child' })
effect(() => {
    effect(() => {
        console.log(`我是子组件:${state.child}`)
    })
  console.log(`我是父组件:${state.parent}`)
})

执行结果如下:

image.png

我们给 state.child 重新赋值:

image.png

这时子组件的 effect 执行了,这是正常的。

接著我们给 state.parent 重新赋值:

image.png

这时我们发现父组件的 effect 不执行了。这是为什么呢?我们来观察一下我们之前实现的 ReactiveEffect 类:

class ReactiveEffect {
    deps = []
    constructor(fn) {
        this._fn = fn
    }
    run () {
        activeEffect = this
        this._fn()
        activeEffect = null
    }
    stop () {
      this.deps.forEach(dep => dep.delete(this))
    }
} 

我们知道 activeEffect 变量是唯一的,当嵌套之后,子组件执行完之后,activeEffect 将被设置了 null,这时父组件如果还有响应式数据需要收集的时候,由于 activeEffect 为 null 而会导致父组件的响应式数据的依赖收集不到。

为了解决这个问题,Vue3 底层设置了一个副作用函数栈变量 effectStack,我们要确保 activeEffect 始终指向当前正在运行的响应式副作用 effect。实现代码如下:

// 用于管理嵌套 effect 的调用栈
const effectStack = []
class ReactiveEffect {
    // 存储所有包含本 effect 的依赖集合(Set)
    // 用于实现 stop 功能时快速清理依赖
    deps = []
    constructor(fn) {
        // 包装的副作用函数(开发者传入的原始函数)
        this._fn = fn
    }
    // 执行副作用函数,并触发依赖收集
    run () {
        // 这里为什么要用try...finally呢?比如如果_fn中有错误,finally块仍然会执行,保证栈的平衡。
        try {
            // 1. 设置当前激活的 effect 为自身
            activeEffect = this;
            // 2. 压入 effect 调用栈(处理嵌套 effect 的关键)
            effectStack.push(this);
            // 3. 执行原始函数,触发响应式属性的 getter,进行依赖收集
            return this._fn(); // 返回函数执行结果(支持 computed 等场景)
        } finally {
            // 4. 无论执行是否抛出异常,确保以下清理逻辑一定执行
            effectStack.pop(); // 当前 effect 出栈
            // 5. 恢复 activeEffect 为上一个 effect(栈顶元素)或 undefined
            activeEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : undefined;
        }
    }
    // 停止当前 effect 的响应式追踪
    stop () {
      // 遍历所有关联的依赖集合,从中删除本 effect
      this.deps.forEach(dep => dep.delete(this))
    }
} 

主要的实现思路也很简单,就是在执行原始函数之前,先把当前的响应式副作用压入 effectStack 调用栈,通过使用 try...finally 确保无论 this._fn() 是否抛出异常,effectStack 都会被正确弹出,activeEffect 会被恢复为上一个响应式副作用 effect 或 undefined。这样通过维护 effectStack,确保嵌套的响应式副作用 effect 的执行顺序正确,activeEffect 变量始终指向当前正在运行的响应式副作用 effect。

我们再来看看迭代后的执行结果:

image.png

我们可以看到当父组件的响应式变量 parent 被改变后,相关的嵌套代码都被执行了。

到此,我们就实现了嵌套 effect

依赖标记流程

初始化依赖的追踪状态标记

初始化依赖的追踪状态标记的核心逻辑就是在副作用函数执行前,记录所有 已有依赖 的追踪状态,即某个依赖在 上一轮执行 中被追踪过,其对应的位会被标记到 wasTracked 中。具体就是将每个依赖的 wasTracked 字段的 当前层级对应位 设为 1。我们可以设置一个全局变量 effectTrackDepth 来表示当前副作用执行的 递归深度,也就是所谓层级,初始为 0,每递归一次就增加 1。在每一轮的副作用函数执行前,将全局递归深度加 1,表示进入新一层级,执行完副作用函数后,将全局递归深度减 1,表示返回到上一层级的执行环境。

然后通过位运算 1 << effectTrackDepth 生成一个二进制掩码,也就是 第 effectTrackDepth 位为 1,其余位为 0。例如,若 effectTrackDepth = 2,则掩码为 0b100(十进制 2)。这样每个递归层级 effectTrackDepth 对应独立的二进制位,避免嵌套 effect 的依赖状态相互干扰。最后通过按位或操作(|),将 wasTracked 的对应二进制位设为 1,其他位保持不变。

具体代码实现如下:

// 用于管理嵌套 effect 的调用栈
const effectStack = []
+ // 当前副作用执行的递归深度
+ let effectTrackDepth = 0
class ReactiveEffect {
    // 存储所有包含本 effect 的依赖集合(Set)
    // 用于实现 stop 功能时快速清理依赖
    deps = []
    constructor(fn) {
        // 包装的副作用函数(开发者传入的原始函数)
        this._fn = fn
    }
    // 执行副作用函数,并触发依赖收集
    run () {
        // 这里为什么要用try...finally呢?比如如果_fn中有错误,finally块仍然会执行,保证栈的平衡。
        try {
            // 1. 设置当前激活的 effect 为自身
            activeEffect = this;
            // 2. 压入 effect 调用栈(处理嵌套 effect 的关键)
            effectStack.push(this);
+            // 将全局递归深度加 1,表示进入新一层级
+            effectTrackDepth++;
+            // 初始化标记
+            this.initDepMarkers();
            // 3. 执行原始函数,触发响应式属性的 getter,进行依赖收集
            return this._fn(); // 返回函数执行结果(支持 computed 等场景)
        } finally {
+            // 将全局递归深度减 1,表示返回到上一层级的执行环境 
+            effectTrackDepth--;
            // 4. 无论执行是否抛出异常,确保以下清理逻辑一定执行
            effectStack.pop(); // 当前 effect 出栈
            // 5. 恢复 activeEffect 为上一个 effect(栈顶元素)或 undefined
            activeEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : undefined;
        }
    }
    // 停止当前 effect 的响应式追踪
    stop () {
      // 遍历所有关联的依赖集合,从中删除本 effect
      this.deps.forEach(dep => dep.delete(this))
    }
+    // 初始化依赖的追踪状态标记
+    initDepMarkers() {
+        const { deps } = this
+        if (deps.length) {
+            for (let i = 0; i < deps.length; i++) {
+                // 若某个依赖在 上一轮执行 中被追踪过,其对应的位会被标记到 wasTracked 中
+                deps[i].wasTracked = deps[i].wasTracked | 1 << effectTrackDepth
+            }
+        }
+    }
} 

小结一下:当副作用函数 effect 执行时,会进入不同的递归层级,每个层级对应一个位。在初始化时,会通过 initDepMarkers 方法设置对应依赖的 wasTracked 属性的位,表示上一轮这个依赖是否被跟踪。

通过位运算判断是否收集依赖

我们在之前的依赖收集的判断逻辑是这样的,判断全局变量 activeEffect 是否存在,存在就进行收集, 那么现在我们要判断当前依赖的当前层级是否标记该依赖为已追踪,也就是 deps.newTracked 的对应层级 (1 << effectTrackDepth) 是否为 1。这就要通过与运算(&)来判断。我们通过封装一个函数来实现这个功能,代码如下:

function newTracked(dep) {
  return (dep.newTracked & (1 << effectTrackDepth)) !== 0;
}

若当前层级未标记该依赖为已追踪(!newTracked(dep)),则需要将当前依赖 newTracked 设置为当前层级 (1 << effectTrackDepth) ,也就是标记为 1。我们通过封装一个函数来实现这个功能,代码如下:

function setNewTracked(dep) {
  dep.newTracked |= (1 << effectTrackDepth); // 按位或操作
}

最后我们还要检查依赖的 wasTracked 字段的当前层级(1 << effectTrackDepth) 对应 是否为 1(即是否在上一轮执行中被追踪过)。我们通过封装一个函数来实现这个功能,代码如下:

function wasTracked(dep) {
  return (dep.wasTracked & (1 << effectTrackDepth)) !== 0;
}

整体代码迭代如下:

function track(target, key) {
    let depsMap = targetMap.get(target)
    if (!depsMap) {
        depsMap = new Map()
        targetMap.set(target, depsMap)
    }
    let deps = depsMap.get(key)
    if (!deps) {
      deps = new Set()
      // 标记依赖在 上一轮执行周期 中是否被追踪
      deps.wasTracked = 0
      // 标记依赖在 当前执行周期 中是否被追踪
      deps.newTracked = 0
      depsMap.set(key, deps)
    }
-    if (activeEffect) {
-        deps.add(activeEffect)
-        activeEffect.deps.push(deps)
-    }
+    trackEffects(deps)
}

+ function trackEffects(dep) {
+     let shouldTrack = false
+     if (!newTracked(dep)) {
+      setNewTracked(dep)
+      shouldTrack = !wasTracked(dep)
+    }

+    if (shouldTrack) {
+        dep.add(activeEffect)
+        activeEffect.deps.push(dep)
+    }
+ }

+ function newTracked(dep) {
+   return (dep.newTracked & (1 << effectTrackDepth)) !== 0;
+ }

+ function setNewTracked(dep) {
+   dep.newTracked |= (1 << effectTrackDepth); // 按位或操作
+ }

+ function wasTracked(dep) {
+   return (dep.wasTracked & (1 << effectTrackDepth)) !== 0;
+ }

在执行 effect 函数的过程中,当访问响应式属性时,会调用 track 函数,进而调用 trackEffects,设置 newTracked 的位,表示当前层级这个 dep 被跟踪了。

接着我们测试一下我们写的代码,测试代码如下:

window.state = reactive({ flag: false,  a: 'parent', b: 'child' })
effect(() => {
  if (state.flag) {
    console.log(`条件一:${state.a}`);
  } else {
    console.log(`条件二:${state.b}`);
  }
});

我们运行上面的测试代码,结果输出:条件二:child。这是正确的输出结果。说明我们上述的迭代代码是正确的。

我们现在改变 flag 的值, state.flag = true,结果输出:条件一:parent。这也是正确的输出结果。这时我们再改变 b 的值, state.b = '掘金签约作者',结果输出:条件一:parent,这个结果不是我们期待的,因为 b 属性我们已经不再使用了,b 属性值的改变不应该再触发更新才对。所以我们还要实现最后一个功能,通过位运算实现动态依赖的精准管理。

实现动态依赖精准管理

我们通过上文知道当effect执行时,会进入不同的递归层级,每个层级对应一个位。在初始化时,会通过initDepMarkers方法设置wasTracked的位,表示上一轮这个dep是否被跟踪。然后在执行effect函数的过程中,当访问响应式属性时,会调用track函数,进而调用trackEffects,设置newTracked的位,表示当前层级这个dep被跟踪了。

我们现在需要做的就是比较这两个标记,如果一个dep在之前被跟踪(wasTracked为真),但在当前没有被跟踪(newTracked为假),说明这个dep在当前层级不再被需要,因此需要从dep的集合中移除这个effect。这样我们就可以实现清理那些不再被依赖的effect,防止内存泄漏和无效的触发。

代码迭代如下:

// 用于管理嵌套 effect 的调用栈
const effectStack = []
let effectTrackDepth = 0
class ReactiveEffect {
    // 存储所有包含本 effect 的依赖集合(Set)
    // 用于实现 stop 功能时快速清理依赖
    deps = []
    constructor(fn) {
        // 包装的副作用函数(开发者传入的原始函数)
        this._fn = fn
    }
    // 执行副作用函数,并触发依赖收集
    run () {
        // 这里为什么要用try...finally呢?比如如果_fn中有错误,finally块仍然会执行,保证栈的平衡。
        try {
            // 1. 设置当前激活的 effect 为自身
            activeEffect = this;
            // 2. 压入 effect 调用栈(处理嵌套 effect 的关键)
            effectStack.push(this);
            // 
            effectTrackDepth++;
            // 初始化标记
            this.initDepMarkers();
            // 3. 执行原始函数,触发响应式属性的 getter,进行依赖收集
            return this._fn(); // 返回函数执行结果(支持 computed 等场景)
        } finally {
+            this.finalizeDepMarkers();
            effectTrackDepth--;
            // 4. 无论执行是否抛出异常,确保以下清理逻辑一定执行
            effectStack.pop(); // 当前 effect 出栈
            // 5. 恢复 activeEffect 为上一个 effect(栈顶元素)或 undefined
            activeEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : undefined;
        }
    }
    // 停止当前 effect 的响应式追踪
    stop () {
      // 遍历所有关联的依赖集合,从中删除本 effect
      this.deps.forEach(dep => dep.delete(this))
    }
    // 初始化依赖的追踪状态标记
    initDepMarkers() {
        const { deps } = this
        if (deps.length) {
            for (let i = 0; i < deps.length; i++) {
                // 若某个依赖在 上一轮执行 中被追踪过,其对应的位会被标记到 wasTracked 中
                deps[i].wasTracked = deps[i].wasTracked | 1 << effectTrackDepth
            }
        }
    }
+    // 清理无效依赖 并 优化依赖集合
+    finalizeDepMarkers() {
+        const { deps } = this
+        if (deps.length) {
+            for (let i = 0; i < deps.length; i++) {
+                const dep = deps[i]
+                // 根据依赖的跟踪状态,清理不再需要的依赖
+                if (wasTracked(dep) && !newTracked(dep)) {
+                    // 移除当前 effect 对该 dep 的依赖
+                    dep.delete(this)
+                }
+            }
+        }
+    }
} 

我们再运行上面的测试代码,结果输出:条件二:child。我们接着改变 flag 的值, state.flag = true,结果输出:条件一:parent。这也是正确的输出结果。这时我们再改变 b 的值, state.b = '掘金签约作者',结果输出:条件一:parent,这个结果还是不是我们期待的,为什么呢?

主要是因为现在只要我们的依赖的层级只要被标记上了,就一直是这个状态了。假设当前层级为 2,上述测试代码中需要删除的 b 属性依赖的层级初始标记状态为:wasTracked = 0b100, newTracked = 0b100,那么后续 b 属性的层级状态就一直是这个状态了,当判断是否需要删除的时候,我们需要判断 wasTracked 是否为 true,因为已经被标记过了,所以为 true,同样判断 newTracked 是否为 false 时,因为已经被标记过了,所以为 true

所以在退出当前层级前,清除该层级对应的位掩码,确保下一层级的标记从干净状态开始。具体代码实现如下:

class ReactiveEffect {
    // ...
    // 清理无效依赖 并 优化依赖集合
     finalizeDepMarkers() {
        const { deps } = this
        if (deps.length) {
            for (let i = 0; i < deps.length; i++) {
                const dep = deps[i]
                // 根据依赖的跟踪状态,清理不再需要的依赖
                if (wasTracked(dep) && !newTracked(dep)) {
                    // 移除当前 effect 对该 dep 的依赖
                    dep.delete(this)
                }
+                // 清除该层级对应的位掩码
+                const trackOpBit = 1 << effectTrackDepth
+                dep.wasTracked = dep.wasTracked & ~trackOpBit
+                dep.newTracked = dep.newTracked & ~trackOpBit
            }
        }
    }
}

总的来说就是当 effect 执行完成后,通过比较 wasTrackednewTracked 的位掩码,可以快速确定哪些依赖在本次执行中没有被访问,从而进行清理。同时退出当前层级前,清除该层级对应的位掩码,确保下一层级的标记从干净状态开始。

递归层级限制30层的设计原因

Vue3 底层选择 30 层作为最大递归层级,因为 V8 引擎对 31/32 位整数直接存储于指针,无需堆分配,读写速度提升 10 倍,30 层限制确保位运算结果始终为 SMI,避免退化为堆内存对象导致性能退化,所以选择 30 层是为了确保现代JS引擎在所有平台上都能使用 SMI(小整数)优化。当超出 30 层时,回退到全量清理,保障极端场景稳定性。

代码优化迭代如下:

+ const maxMarkerBits = 30
class ReactiveEffect {
    // ...
    run () {
        try {
            // 1. 设置当前激活的 effect 为自身
            activeEffect = this;
            // 2. 压入 effect 调用栈(处理嵌套 effect 的关键)
            effectStack.push(this);
            effectTrackDepth++;
-            this.initDepMarkers()
+            if (effectTrackDepth <= maxMarkerBits) {
+                this.initDepMarkers()
+            } else {
+                // 当递归深度超过30层时,回退到完全清理模式
+                this.cleanup()
+            }
            // 初始化标记
            this.initDepMarkers();
            // 3. 执行原始函数,触发响应式属性的 getter,进行依赖收集
            return this._fn(); // 返回函数执行结果(支持 computed 等场景)
        } finally {
-            this.finalizeDepMarkers()
+            if (effectTrackDepth <= maxMarkerBits) {
+                this.finalizeDepMarkers()
+            }
            effectTrackDepth--;
            // 4. 无论执行是否抛出异常,确保以下清理逻辑一定执行
            effectStack.pop(); // 当前 effect 出栈
            // 5. 恢复 activeEffect 为上一个 effect(栈顶元素)或 undefined
            activeEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : undefined;
        }
    }
    // ...
+    // 完全清理模式
+    cleanup() {
+        const { deps } = this
+        if (deps.length) {
+            for (let i = 0; i < deps.length; i++) {
+                deps[i].delete(this)
+            }
+            deps.length = 0
+        }
+    }
} 

SMI(Small Integer)优化的核心原理

我们上面提到 Vue3 底层选择 30 层作为最大递归层级,是为了确保现代JS引擎在所有平台上都能使用 SMI(小整数)优化。

首先,我们得看一下 SMI 的概念。SMI 代表 Small Integer,是 V8 引擎对特定范围内整数的优化存储方式。在 V8 引擎中,SMI(Small Integer)优化 的核心原理是通过 指针标签(Pointer Tagging)  技术,将小整数直接嵌入指针值中,而非存储在堆内存中。以下是其性能优势的详细解析:

指针的结构

  • 指针的本质
    指针是一个内存地址,通常用 32 位(32 位系统)或 64 位(64 位系统)表示。

  • 标签位(Tagging Bits)
    V8 利用指针的低位(如最低 1~2 位)作为 类型标记,例如:

    • 表示该指针是一个 SMI(直接存储整数值)。
    • 表示该指针是一个 堆对象地址(需要解引用获取实际值)。

SMI 的存储方式

直接嵌入指针
V8 将小整数的二进制值 左移 1 位(腾出最低位作为标签),然后存入指针。

堆分配的数字
若数字超出 SMI 范围(如大整数、浮点数),V8 会在堆内存中分配一个 Number 对象,并将指针指向该对象。

内存访问开销

  • SMI(指针存储)
    值直接存储在指针中,读取时 无需访问堆内存,直接解析指针值即可。

  • 堆分配的数字
    需要 两次内存访问

    1. 读取指针地址。
    2. 根据指针地址访问堆内存中的 Number 对象。

内存分配开销

  • SMI
    无堆内存分配和释放操作,避免 内存管理开销(如垃圾回收)。
  • 堆分配的数字
    需调用内存分配器,可能触发 垃圾回收(GC) ,增加延迟。

CPU 缓存友好性

  • SMI
    数值直接存储在指针中,与其他指针数据一起被 CPU 缓存,缓存命中率高
  • 堆分配的数字
    Number 对象分散在堆内存中,缓存局部性差,缓存未命中率高

指令优化

SMI 操作
通过简单的位运算(如移位、掩码)即可完成数值解析,CPU 指令周期短

堆分配数字操作
需要额外的解引用指令和类型检查,指令周期长

设计哲学

空间换时间

  • SMI:牺牲 1 位指针空间(用于标签),换取极致性能。
  • 堆分配:以内存和速度为代价,支持更大数值范围。

高频场景优化

  • 现实场景
    大多数 JavaScript 程序中的整数是小范围的(如循环计数器、数组索引),SMI 覆盖了 99% 的用例。
  • 收益最大化
    对高频操作(如依赖收集、循环计数)进行极致优化,显著提升整体性能。

综上所述,V8 通过 指针标签技术 将小整数(SMI)直接存储在指针中,实现了以下优势:

  1. 零内存分配:避免堆操作和垃圾回收开销。
  2. 直接访问:无需解引用,减少内存访问次数。
  3. CPU 友好:位运算指令快,缓存命中率高。

这些优化使得 SMI 的读写速度比堆分配的数字快 10 倍以上,成为 JavaScript 高性能引擎的核心技术之一。Vue3 的依赖收集系统正是基于此特性,通过位运算和层级限制,实现了高效的响应式更新。

总结

最后我们来总结一下,Vue3 通过位运算设计实现以下响应式系统的优化:

  • 层级化状态标记:通过位掩码精准管理递归层级依赖。
  • 动态清理机制:按位比对移除失效依赖,避免冗余触发。
  • 性能与内存平衡:SMI 优化保障操作速度,30 层限制避免边界问题。

这一机制在复杂组件、高频更新及深层嵌套场景下表现卓越,是 Vue3 响应式系统的核心创新之一。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

Vue 3 Composition API 深度解析

引言

Vue 3 的 Composition API 是 Vue 框架最具革命性的更新之一。它解决了 Options API 在大型项目中代码组织、逻辑复用和类型推导等方面的痛点。本文将深入讲解 Composition API 的核心概念、使用技巧以及最佳实践。

什么是 Composition API?

Composition API 是一组基于函数的 API,允许我们将相关逻辑组织在一起,而不是将代码分散在 datamethodscomputed 等选项中。

对比:Options API vs Composition API

Options API (Vue 2 风格):

export default {
  data() {
    return {
      count: 0,
      todos: []
    }
  },
  methods: {
    increment() {
      this.count++
    },
    fetchTodos() {
      // 获取待办事项
    }
  },
  computed: {
    doubleCount() {
      return this.count * 2
    }
  },
  mounted() {
    this.fetchTodos()
  }
}

Composition API (Vue 3 风格):

import { ref, computed, onMounted } from 'vue'

export default {
  setup() {
    const count = ref(0)
    const todos = ref([])
    
    const doubleCount = computed(() => count.value * 2)
    
    const increment = () => {
      count.value++
    }
    
    const fetchTodos = async () => {
      // 获取待办事项
    }
    
    onMounted(() => {
      fetchTodos()
    })
    
    return {
      count,
      todos,
      doubleCount,
      increment,
      fetchTodos
    }
  }
}

核心 API 详解

1. ref() - 响应式基础类型

ref() 用于创建响应式的基本类型值。

import { ref } from 'vue'

const count = ref(0)

// 访问和修改
console.log(count.value) // 0
count.value = 1

// 在模板中自动解包,不需要 .value

关键点:

  • 在 JavaScript 中需要通过 .value 访问
  • 在模板中自动解包
  • 支持任意类型的值

2. reactive() - 响应式对象

reactive() 用于创建响应式对象。

import { reactive } from 'vue'

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

// 直接访问,不需要 .value
state.count++
state.user.age = 26

ref vs reactive

  • ref 可以处理任意类型,包括基本类型
  • reactive 只能处理对象类型
  • ref 需要 .valuereactive 直接访问

3. computed() - 计算属性

import { ref, computed } from 'vue'

const firstName = ref('张')
const lastName = ref('三')

const fullName = computed(() => {
  return firstName.value + lastName.value
})

// 只读计算属性
console.log(fullName.value) // '张三'

// 可写计算属性
const fullNameWritable = computed({
  get: () => firstName.value + lastName.value,
  set: (val) => {
    const [first, last] = val.split(' ')
    firstName.value = first
    lastName.value = last
  }
})

4. watch() 和 watchEffect()

watch() - 精确监听:

import { ref, watch } from 'vue'

const count = ref(0)

// 监听单个值
watch(count, (newVal, oldVal) => {
  console.log(`从 ${oldVal} 变为 ${newVal}`)
})

// 监听多个值
watch([count, firstName], ([newCount, newName]) => {
  console.log('变化:', newCount, newName)
})

// 监听对象属性(深度监听)
const user = reactive({ profile: { name: '张三' } })
watch(
  () => user.profile,
  (newVal) => {
    console.log('profile 变化', newVal)
  },
  { deep: true }
)

watchEffect() - 自动追踪依赖:

import { ref, watchEffect } from 'vue'

const count = ref(0)

watchEffect(() => {
  // 自动追踪 count 的变化
  console.log('count 是:', count.value)
  // 这里不需要指定监听谁,Vue 自动分析依赖
})

5. 生命周期钩子

Composition API 中的生命周期钩子需要在 setup() 中调用:

import { 
  onMounted, 
  onUpdated, 
  onUnmounted,
  onBeforeMount,
  onBeforeUpdate,
  onBeforeUnmount,
  onErrorCaptured
} from 'vue'

export default {
  setup() {
    onMounted(() => {
      console.log('组件已挂载')
    })
    
    onUpdated(() => {
      console.log('组件已更新')
    })
    
    onUnmounted(() => {
      console.log('组件已卸载')
    })
    
    // 错误捕获
    onErrorCaptured((err, instance, info) => {
      console.error('捕获到错误:', err)
      return false // 阻止错误继续传播
    })
  }
}

逻辑复用:组合式函数

Composition API 最大的优势在于逻辑复用。我们可以将相关逻辑封装成组合式函数(Composables)。

示例:useCounter

// composables/useCounter.js
import { ref, computed } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  const double = computed(() => count.value * 2)
  
  const increment = () => count.value++
  const decrement = () => count.value--
  const reset = () => count.value = initialValue
  
  return {
    count,
    double,
    increment,
    decrement,
    reset
  }
}

使用:

import { useCounter } from './composables/useCounter'

export default {
  setup() {
    const { count, double, increment, decrement } = useCounter(10)
    
    return {
      count,
      double,
      increment,
      decrement
    }
  }
}

示例:useFetch

// composables/useFetch.js
import { ref, onMounted } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const loading = ref(true)
  const error = ref(null)
  
  const fetchData = async () => {
    try {
      loading.value = true
      const response = await fetch(url)
      data.value = await response.json()
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }
  
  onMounted(() => {
    fetchData()
  })
  
  return {
    data,
    loading,
    error,
    refresh: fetchData
  }
}

使用:

import { useFetch } from './composables/useFetch'

export default {
  setup() {
    const { data, loading, error, refresh } = useFetch('/api/users')
    
    return {
      data,
      loading,
      error,
      refresh
    }
  }
}

实际应用场景

1. 表单处理

// composables/useForm.js
import { ref, reactive } from 'vue'

export function useForm(initialValues = {}, validators = {}) {
  const form = reactive({ ...initialValues })
  const errors = reactive({})
  const touched = reactive({})
  
  const validate = (field) => {
    if (validators[field]) {
      const error = validators[field](form[field])
      errors[field] = error
      return !error
    }
    return true
  }
  
  const validateAll = () => {
    return Object.keys(validators).every(validate)
  }
  
  const setField = (field, value) => {
    form[field] = value
    touched[field] = true
  }
  
  return {
    form,
    errors,
    touched,
    validate,
    validateAll,
    setField
  }
}

2. 响应式路由

// composables/useRoute.js
import { ref, computed } from 'vue'
import { useRoute as useVueRoute } from 'vue-router'

export function useRoute() {
  const route = useVueRoute()
  
  const queryParams = computed(() => route.query)
  const params = computed(() => route.params)
  const fullPath = computed(() => route.fullPath)
  
  return {
    route,
    queryParams,
    params,
    fullPath
  }
}

最佳实践

1. 逻辑组织

将相关的逻辑组织在一起:

export default {
  setup() {
    // 状态定义
    const count = ref(0)
    const loading = ref(false)
    
    // 计算属性
    const doubleCount = computed(() => count.value * 2)
    
    // 方法
    const increment = () => count.value++
    
    // 生命周期
    onMounted(() => {
      console.log('mounted')
    })
    
    // 返回
    return {
      count,
      doubleCount,
      increment
    }
  }
}

2. 使用 <script setup>

Vue 3.2+ 引入了 <script setup>,让 Composition API 更加简洁:

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

const count = ref(0)
const doubleCount = computed(() => count.value * 2)

const increment = () => count.value++

onMounted(() => {
  console.log('mounted')
})
</script>

<template>
  <div>
    <p>{{ count }}</p>
    <p>{{ doubleCount }}</p>
    <button @click="increment">增加</button>
  </div>
</template>

3. 类型推导

Composition API 对 TypeScript 支持更好:

import { ref } from 'vue'

// 类型自动推导
const count = ref(0) // Ref<number>
const user = ref({ name: '张三', age: 25 }) // Ref<{ name: string, age: number }>

// 显式类型
const count2 = ref<number>(0)
interface User {
  name: string
  age: number
}
const user2 = ref<User>({ name: '张三', age: 25 })

总结

Composition API 为 Vue 3 带来了:

  1. 更好的逻辑组织 - 相关代码放在一起,而不是分散在多个选项中
  2. 更强的逻辑复用 - 通过组合式函数实现代码复用
  3. 更好的 TypeScript 支持 - 类型推导更准确
  4. 更小的打包体积 - 更好的 tree-shaking

虽然学习曲线稍陡,但对于中大型项目来说,Composition API 是更好的选择。建议从简单的 refcomputedwatch 开始,逐步掌握组合式函数的编写技巧。

Markdown 渲染如何穿插自定义组件

在 Vue 3 流式 Markdown 渲染器中实现插件化自定义组件——踩坑全记录

背景

v3-markdown-stream 是一个基于 Vue 3 的高性能 Markdown 流式渲染组件,核心特性是支持 LLM 场景下的增量输出渲染——内容一段一段地追加,页面实时更新,无闪屏、无卡顿。

随着 AI 对话场景的丰富,单纯渲染文本已经不够了。我们希望在 Markdown 流式输出中直接嵌入图表、自定义组件。比如 LLM 返回:

根据数据分析,本月销售情况如下:

[[echarts {"type":"bar","data":[10,20,30,40,50]}]]

从图表可以看出...

渲染器应该识别 [[echarts ...]] 语法,直接在 Markdown 中渲染出 ECharts 图表。

听起来简单,实际开发中踩了一堆坑。本文记录整个开发过程和解决方案。


一、插件系统设计

1.1 核心思路

插件系统的核心流程:

流式内容: [[echarts {"type":"bar","data":[10,20,30]}]]
    ↓ 正则匹配
转换后: <v3md-echarts data-config="..." data-key="..."></v3md-echarts>
    ↓ rehype-raw 解析 HTML
HAST 树中包含自定义标签节点
    ↓ toJsxRuntime 组件映射
渲染为 ECharts Vue 组件

关键设计决策:

  • 正则匹配:用 [[插件名 JSON配置]] 语法,正则 \[\[echarts\s+([\s\S]*?)\]\] 匹配
  • HTML 标签桥接:将匹配结果转换为自定义 HTML 标签,利用已有的 rehype-raw 插件解析
  • 组件映射:在 toJsxRuntimecomponents 参数中注册自定义标签到 Vue 组件的映射

1.2 流式场景的"不完整语法"问题

流式输出时,内容是逐步到达的。[[echarts {"type":"bar","data":[10,20 这样的内容在某一时刻是不完整的——JSON 没闭合、]] 没出现。

第一个坑:不完整的插件语法会导致后续所有 Markdown 解析错乱。

如果正则匹配不到完整的 [[...]],残留的 [ 会被 Markdown 解析器当作链接语法,导致后续内容渲染异常。

解决方案:对不完整的插件语法进行清理,用正则 [[echarts\b[\s\S]*$ 匹配流末尾的不完整语法,将其替换为 loading 占位符(而非直接删除,后面会讲为什么)。

for (const [, plugin] of pluginMap) {
  const incompleteRegex = new RegExp(
    `\\[\\[${escapeRegex(plugin.name)}\\b[\\s\\S]*$`,
    'g'
  );
  result = result.replace(incompleteRegex, () => {
    return `\n\n<div class="v3md-plugin-container"><v3md-loading></v3md-loading></div>\n\n`;
  });
}

二、ECharts 组件的集成

2.1 动态导入

ECharts 体积很大(压缩后约 1MB),不能让所有用户都加载。使用动态 import() 实现:

const initChart = async () => {
  const echarts = await import('echarts');
  chartInstance.value = echarts.init(chartRef.value);
  chartInstance.value.setOption(getOption(props.config));
};

2.2 配置解析——简单模式 vs 完整模式

第二个坑:用户期望的配置方式和 ECharts 原生配置差距很大。

ECharts 原生配置需要写 seriesxAxisyAxis 等,但用户只想写 {"type":"bar","data":[10,20,30]}

解决方案:支持两种模式:

  • 简单模式type + data,自动补全坐标轴等配置
  • 完整模式:直接传 ECharts 的 option,支持所有功能
const getOption = (config) => {
  const { type, data, width, height, ...rest } = config;
  const option = { ...rest };

  if (type && !option.series) {
    option.series = [{ type, data: data || [] }];
  }

  if (!option.xAxis && !option.yAxis && type === 'bar') {
    option.xAxis = { type: 'category', data: data.map((_, i) => `${i + 1}`) };
    option.yAxis = { type: 'value' };
  }
  // ...
  return option;
};

三、闪烁问题——最大的坑

这是整个开发过程中最棘手的问题。流式追加内容时,ECharts 图表会不断闪烁(消失再出现),体验极差。

3.1 原因分析

经过深入排查,闪烁有三层原因

原因一:CSS 通配符动画
* {
  animation: fade-in 0.6s ease-in-out;
}

这个通配符选择器让所有元素每次 DOM 更新都重新触发淡入动画。Vue 虽然复用了 DOM 节点,但 CSS 动画会在元素属性变化时重新触发。

修复:排除插件容器及其子元素:

*:not(.v3md-plugin-container):not(.v3md-plugin-container *) {
  animation: fade-in 0.6s ease-in-out;
}
原因二:组件映射引用不稳定

toJsxRuntimecomponents 参数每次渲染都是新对象。更严重的是,如果 getComponentMappings() 每次返回新的组件定义,Vue 会认为是不同的组件,直接销毁重建。

// ❌ 每次调用都创建新的 defineComponent
function getComponentMappings() {
  const mappings = {};
  for (const [, plugin] of pluginMap) {
    mappings[plugin.tagName] = createPluginWrapper(plugin); // 每次都是新组件!
  }
  return mappings;
}

修复:缓存组件映射:

let cachedMappings = null;

function getComponentMappings() {
  if (cachedMappings) return cachedMappings;
  cachedMappings = {};
  for (const [, plugin] of pluginMap) {
    cachedMappings[plugin.tagName] = createPluginWrapper(plugin);
  }
  return cachedMappings;
}
原因三:Config 对象引用每次都是新的

这是最隐蔽的问题。流式追加内容时,props.node 引用每次都变(因为 HAST 树重建),即使 data-config 字符串完全相同,watch 也会触发,生成新的 config 对象。ECharts 组件的 deep: true watch 检测到"新"对象,就调用 setOption 重绘。

// ❌ 即使 config 内容相同,对象引用不同就会触发
watch(() => props.config, (newConfig) => {
  chartInstance.value.setOption(getOption(newConfig));
}, { deep: true });

修复:在两层都做字符串比较去重:

层一——Plugin Wrapper:比较原始 data-config 字符串,相同则不更新 configRef

let lastRawConfig = '';

watch(() => props.node, (node) => {
  const rawConfig = node.properties?.['data-config'] || '';
  if (rawConfig === lastRawConfig) return;  // 字符串相同,跳过
  lastRawConfig = rawConfig;
  configRef.value = JSON.parse(decodeURIComponent(rawConfig));
});

层二——ECharts 组件:比较 JSON 序列化结果,相同则跳过 setOption

let lastConfigJson = '';

const updateChart = (newConfig) => {
  const newJson = JSON.stringify(newConfig);
  if (newJson === lastConfigJson) return;  // 内容相同,跳过
  lastConfigJson = newJson;
  chartInstance.value.setOption(getOption(newConfig));
};

3.2 闪烁修复总结

层级 问题 修复
CSS * 通配符动画影响插件容器 :not() 排除
组件映射 每次返回新组件定义 缓存 cachedMappings
Config 传递 node 引用变化触发不必要的更新 字符串比较去重
ECharts 更新 deep: true watch 过于敏感 JSON 序列化比较去重

四、流式碎片的 Loading 状态

4.1 从"删除"到"Loading"

最初处理流式碎片的方式是直接删除:不完整的图片删掉、不完整的数学公式删掉、不完整的插件语法删掉。

问题:用户看到内容突然消失又出现,体验很差。比如图片 URL 传到一半被删掉,传完整后又突然出现,视觉上就是"闪一下"。

改进:将碎片内容替换为 loading 动画,内容完整后自动替换为实际渲染结果。

4.2 三种碎片场景

碎片类型 示例 处理方式
不完整图片 ![alt](http://incom 替换为 <v3md-loading>
未闭合公式 $$ x^2 + 删除未闭合部分 + 替换为 <v3md-loading>
不完整插件 [[echarts {"type": 替换为 <v3md-loading>

4.3 Loading 组件的虚拟 DOM 实现

Loading 动画使用 three-body 旋转点动画,需要用虚拟 DOM 实现(因为整个渲染管线都是虚拟 DOM):

const V3mdLoading = defineComponent({
  name: 'V3mdLoading',
  setup() {
    return () =>
      h('div', { class: 'v3md-loading' }, [
        h('div', { class: 'three-body' }, [
          h('div', { class: 'three-body__dot' }),
          h('div', { class: 'three-body__dot' }),
          h('div', { class: 'three-body__dot' }),
        ]),
      ]);
  },
});

第四个坑:<v3md-loading> 标签被 Markdown 解析器包裹在 <p> 标签内。

自定义标签在 Markdown 中默认被当作行内 HTML,被 <p> 包裹。流式追加时 <p> 的结构变化导致 VNode 树不稳定。

修复:在替换时前后加空行,并用 <div class="v3md-plugin-container"> 包裹,确保被解析为块级元素:

return `\n\n<div class="v3md-plugin-container"><v3md-loading></v3md-loading></div>\n\n`;

五、插件默认内置

5.1 用户体验优化

最初的设计要求用户手动引入和配置:

<script setup>
import { createPluginRegistry } from 'v3-markdown-stream'
import { echartsPlugin } from './echarts-plugin.js'
const registry = createPluginRegistry([echartsPlugin])
</script>

<template>
  <MarkdownRender :pluginRegistry="registry" />
</template>

这对用户来说太繁琐了。ECharts 是最常用的图表库,应该开箱即用。

修复:在 createPluginRegistry 中默认包含 echarts 插件:

import { echartsPlugin } from './echarts-plugin.js';
const DEFAULT_PLUGINS = [echartsPlugin];

export function createPluginRegistry(plugins = []) {
  const allPlugins = [...DEFAULT_PLUGINS, ...plugins];
  // ...
}

markdownRender.vue 中自动创建默认 registry:

const defaultRegistry = createPluginRegistry();

模板中 fallback:

<VueMarkdownStreamRender :pluginRegistry="pluginRegistry || defaultRegistry" />

现在用户只需:

<MarkdownRender :markInfo="content" />

ECharts 图表就能直接渲染。


六、ref 标签点击事件

Markdown 中使用 <ref>[3]</ref> 标注引用,点击时需要将引用编号上报给父组件。

6.1 组件映射

和 ECharts 一样,通过 toJsxRuntimecomponents 映射将 ref 标签映射到 Vue 组件:

const baseComponents = {
  table: TableCode,
  pre: PreCode,
  ref: RefTag,
};

6.2 事件传递——provide/inject 模式

第五个坑:toJsxRuntime 生成的 VNode 树中,组件无法直接 emit 事件到上层。

因为 RefTag 组件不是 markdownRender.vue 的直接子组件,中间隔了 markdown-parse.js 和 VNode 树多层嵌套,emit 事件无法冒泡。

解决方案:使用 provide/inject 跨层级传递事件回调:

// markdown-parse.js - provide
provide(REF_CLICK_KEY, (numbers) => {
  if (props.onRefClick) {
    props.onRefClick(numbers);
  }
});

// ref-tag.js - inject
const onRefClick = inject(REF_CLICK_KEY, null);

// 点击时调用
onClick: (e) => {
  if (onRefClick && numbers.length > 0) {
    onRefClick(numbers);
  }
}

最终通过 markdownRender.vueemit('refClick', numbers) 暴露给父组件。

6.3 正则提取引用编号

const extractRefNumbers = (node) => {
  const text = getTextContent(node);  // 递归提取所有文本子节点
  const match = text.match(/\[(\d+(?:\s*,\s*\d+)*)\]/);
  if (match) {
    return match[1].split(/\s*,\s*/).map(Number);
  }
  return [];
};

支持 [3][1,2,3][1, 2, 3] 等格式。


七、整体架构

┌─────────────────────────────────────────────────────┐
                    MarkdownRender                     
  props: markInfo, themeColor, pluginRegistry         
  emit: refClick                                      
  ┌─────────────────────────────────────────────────┐ 
              markdown-parse.js                      
    ┌───────────┐  ┌──────────┐  ┌──────────────┐  
     stripBroken│→  transform │→    unified      
      Images       Markdown      processor     
     (loading)     (plugins)     (HAST)        
    └───────────┘  └──────────┘  └──────┬───────┘  
                                                   
                                ┌────────▼────────┐│ 
                                  toJsxRuntime   ││ 
                                  components:    ││ 
                                  ┌───────────┐  ││ 
                                   table       ││ 
                                   pre         ││ 
                                   ref         ││ 
                                   v3md-*      ││ 
                                   loading     ││ 
                                  └───────────┘  ││ 
                                └─────────────────┘│ 
  └─────────────────────────────────────────────────┘ 
└─────────────────────────────────────────────────────┘

总结

这次插件化改造踩了五个主要坑:

  1. 不完整语法导致解析错乱 → 正则清理 + loading 占位
  2. ECharts 配置门槛高 → 简单模式自动补全
  3. 流式渲染闪烁 → CSS 排除 + 组件缓存 + Config 去重(三层修复)
  4. 自定义标签被 <p> 包裹 → 块级 div 包裹 + 空行隔离
  5. VNode 树中事件无法冒泡 → provide/inject 跨层级传递

最深刻的教训是:流式渲染场景下,任何"引用不稳定"都会被放大。普通场景中组件重建一次可能无感,但流式场景下每秒更新数十次,组件反复销毁重建就变成了闪烁。核心策略是:能缓存就缓存,能比较就比较,能跳过就跳过

从零到一:用 Vue3 + Kimi 大模型打造「拍照记单词」AI 应用

从零到一:用 Vue3 + Kimi 大模型打造「拍照记单词」AI 应用

本文适合有一定 Vue3 基础、想了解如何将大模型 API 集成到前端项目的开发者。完整项目已开源,文末附链接。

前言

在 AI 时代,"一个人的公司"(OPC)正在成为可能。本文将带你从零搭建一个 拍照记单词 的前端 AI 应用——用户拍一张照片,AI 自动识别图片内容并生成一个英文单词、例句和发音。

这个项目的核心价值在于:它不是一个 Demo,而是一个可以落地的产品原型。你会学到:

  • 如何用 Vue3 Composition API 组织复杂业务逻辑
  • 如何调用多模态大模型(Kimi Vision)解析图片
  • 如何集成 TTS 语音合成
  • 如何设计一个对用户友好的 Prompt

一、项目架构总览

vue3-ts-cameraword/
├── src/
│   ├── App.vue                 # 主页面,核心业务逻辑
│   ├── components/
│   │   └── PictureCard.vue     # 拍照卡片组件
│   ├── lib/
│   │   └── audio.ts            # TTS 语音合成模块
│   └── main.ts                 # 入口文件
├── .env.local                  # 环境变量(API Key 等)
└── vite.config.ts              # Vite 配置

技术栈:Vue3 + TypeScript + Vite + Kimi Vision API + 火山引擎 TTS


二、核心功能实现

2.1 图片上传:FileReader 的妙用

传统文件上传需要后端配合,但多模态大模型可以直接接收 Base64 编码的图片。我们用 FileReader 在前端完成图片转码:

// PictureCard.vue
const updateImageData = async (e: Event): Promise<any> => {
    const file = (e.target as HTMLInputElement).files?.[0];
    if (!file) return;

    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.readAsDataURL(file); // 转为 Base64
        reader.onload = () => {
            const data = reader.result as string;
            imgPreview.value = data;        // 本地预览
            emit('updateImage', data);      // 传给父组件
            resolve(data);
        };
        reader.onerror = (error) => reject(error);
    });
};

关键点:

  • readAsDataURL() 将文件转为 data:image/png;base64,... 格式的字符串
  • 这个字符串可以直接作为 <img>src 实现预览
  • 同时可以直接传给大模型的 image_url 字段

2.2 调用 Kimi Vision:多模态 API 实战

这是整个项目的核心。Kimi 的 moonshot-v1-8k-vision-preview 模型支持图片+文字的混合输入:

// App.vue
const update = async (imageDate: string) => {
    const endpoint = import.meta.env.VITE_KIMI_API_ENDPOINT + '/chat/completions';
    const headers = {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${import.meta.env.VITE_KIMI_API_KEY}`
    };

    word.value = '分析中...';

    const response = await fetch(endpoint, {
        method: 'POST',
        headers,
        body: JSON.stringify({
            model: 'moonshot-v1-8k-vision-preview',
            messages: [{
                role: 'user',
                content: [
                    {
                        type: 'image_url',
                        image_url: { url: imageDate }  // Base64 图片
                    },
                    {
                        type: 'text',
                        text: userPrompt                // 文字指令
                    }
                ]
            }],
            stream: false
        })
    });

    const data = await response.json();
    const replyData = JSON.parse(data.choices[0].message.content);
    // 处理返回数据...
};

这里的 content 是一个数组,可以同时包含图片和文字。这是多模态 API 的标准用法。

2.3 Prompt 设计:决定产品质量的关键

Prompt 是 AI 产品的灵魂。一个好的 Prompt 需要:

  1. 清晰的指令:告诉模型你要什么
  2. 明确的输出格式:JSON 格式便于前端解析
  3. 约束条件:限制词汇难度、输出长度等
const userPrompt = `
  分析图片内容,找出最能描述图片的一个英文单词,尽量选择更简单的A1~A2的词汇。

  返回JSON 数据:
  {
    "image_discription": "图片描述",
    "representative_word": "图片代表的英文单词",
    "example_sentence": "结合英文单词和图片描述,给出一个简单的例句",
    "explaination": "结合图片解释英文单词,段落以Look at ...开头,
                    将段落分句,每一句单独一行,
                    解释的最后给一个日常生活有关的问句",
    "explanation_replys": ["根据explaination给出的回复1",
                          "根据explaination给出的回复2"]
  }
`;

设计要点:

  • A1~A2 级别:控制词汇难度,适合初学者
  • JSON 格式OutputParser 的思想,让返回数据结构化,便于业务处理
  • Look at ... 开头:引导模型用"看图说话"的方式解释,更生动
  • 问句结尾:制造对话感,增强学习互动性

2.4 TTS 语音合成:让单词"说出来"

学英语离不开发音。我们集成火山引擎的 TTS 服务,将例句转为语音:

// lib/audio.ts
export const generateAudio = async (text: string) => {
    const endpoint = '/tts/api/v1/tts';
    const headers = {
        'Content-Type': 'application/json',
        'Authorization': `Bearer;${token}`
    };

    const payload = {
        app: { appid: appId, token, cluster: clusterId },
        user: { uid: 'bearbobo' },
        audio: {
            voice_type: voiceName,    // 音色:en_female_anna_mars_bigtts
            encoding: 'ogg_opus',     // 音频编码格式
            speed_ratio: 1.0,         // 语速
            emotion: 'happy',         // 情绪
        },
        request: {
            reqid: Math.random().toString(36).substring(7),
            text,                     // 要合成的文本
            text_type: 'plain',
            operation: 'query',
        },
    };

    const res = await fetch(endpoint, {
        method: 'POST',
        headers,
        body: JSON.stringify(payload)
    });

    const data = await res.json();
    return createBlobURL(data.data);  // 转为可播放的 URL
};

Base64 转 Blob URL 的工具函数:

function createBlobURL(base64AudioData: string): string {
    const byteArrays: number[] = [];
    const byteCharacters = atob(base64AudioData);  // 解码 Base64

    for (let offset = 0; offset < byteCharacters.length; offset++) {
        byteArrays.push(byteCharacters.charCodeAt(offset));
    }

    const audioBlob = new Blob([new Uint8Array(byteArrays)], {
        type: 'audio/mp3'
    });

    return URL.createObjectURL(audioBlob);  // 生成临时播放 URL
}

播放逻辑很简单:

// PictureCard.vue
const playAudio = () => {
    const audio = new Audio(props.audio);
    audio.play();
};

三、Vite 代理配置:解决跨域问题

前端直接调用第三方 API 会遇到跨域。用 Vite 的 server.proxy 解决:

// vite.config.ts
export default defineConfig({
    plugins: [vue()],
    server: {
        host: '0.0.0.0',           // 允许局域网访问
        proxy: {
            '/tts': {
                target: 'https://openspeech.bytedance.com',
                changeOrigin: true,
                rewrite: path => path.replace(/^\/tts/, ''),
            }
        },
    },
});
  • host: '0.0.0.0':让手机等设备也能访问开发服务器
  • /tts 代理:将 /tts/api/v1/tts 转发到火山引擎的 API

四、无障碍设计:被忽略的细节

这个项目有一个亮点:支持读屏器的无障碍访问

传统的 <input type="file"> 样式很难控制。我们的做法是:

<!-- 隐藏原生 input,用 label 触发 -->
<input type="file" id="selecteImage" class="input"
       accept="image/*" @change="updateImageData">
<label for="selecteImage" class="upload">
    <img :src="imgPreview" alt="camera" class="img"/>
</label>
.input {
    display: none;  /* 隐藏原生控件 */
}
  • for="selecteImage" 关联 id,点击 label 等同于点击 input
  • accept="image/*" 限制只能选择图片
  • 读屏器可以通过 label 的文本识别按钮用途

效果

image.png

image.png

五、项目总结与思考

学到了什么

  1. 多模态 API 的调用方式content 字段是数组,图片用 Base64 编码传入
  2. Prompt 工程:JSON 输出格式、难度约束、引导性描述
  3. 前端音频处理:Base64 → Blob → ObjectURL 的完整链路
  4. Vite 代理:一行配置解决跨域

可以改进的方向

  • 加入流式输出stream: true),让分析过程可视化
  • 增加单词本功能,收藏学过的单词
  • 接入语音识别,支持跟读打分
  • IndexedDB 本地存储学习记录

六、环境配置

创建 .env.local 文件:

VITE_KIMI_API_KEY=sk-xxxxx          # Kimi API Key
VITE_KIMI_API_ENDPOINT=https://api.moonshot.cn/v1

VITE_AUDIO_APP_ID=xxxxx             # 火山引擎 TTS 配置
VITE_AUDIO_ACCESS_TOKEN=xxxxx
VITE_AUDIO_CLUSTER_ID=volcano_tts
VITE_AUDIO_VOICE_NAME=en_female_anna_mars_bigtts

启动项目:

npm install
npm run dev

写在最后

这个项目虽然代码量不大,但覆盖了前端 AI 应用的核心链路:图片输入 → 多模态理解 → 结构化输出 → 语音合成

AI 时代,前端工程师的价值不只是写页面,更是用 AI 能力重新定义产品体验。希望这篇文章能给你一些启发。

项目地址:[project/capture_word /lesson_zp - 码云 - 开源中国] 欢迎 Star 和 PR!

脚手架搭建项目框架(create-vite、vue-cli、create-vue、quasar-cli)

脚手架脚手架搭建项目框架

一. create-vite

npm create vite@latest(yarn create vite)安装脚手架命令

二. vue-cli

npm i -g @vue-cli安装脚手架命令

vue create project1创建项目

三. create-vue(vue官方的项目脚手架工具,内置了vite构建工具)项目开发中使用的脚手架

1.技术栈:

Create-vue脚手架 + Vite构建工具 + vue3组合式API (vue3选项式API)+ typescript + vue-router + pinia状态管理 (vuex状态管理)+ axios网络库 + vant3 UI组件库(element-plus UI组件库) + eslint + pretter + sass

2.脚手架搭建项目框架步骤:

1) npm init vue@latest 安装脚手架命令。

根据预设生成相应的配置文件(选择ts、vue-router、pinia、eslint、pretter)。

image.png

2) pinia配置:

npm i pinia-plugin-persist -S 安装pinia持久化存储插件。

创建stores文件夹→index.ts(创建pinia根存储,集成插件)

import { createPinia } from 'pinia'
import piniaPluginPersist from 'pinia-plugin-persist'//引入pinia持久化存储插件

const storeRoot = createPinia()
storeRoot.use(piniaPluginPersist)//集成插件
export default storeRoot

main.js入口文件(集成pinia)

import { createApp } from "vue";
import router from "./router";
import store from "./store";
import App from "./App.vue";
import 'normalize.css' // 重置样式

const app = createApp(App)
app.use(router);
app.use(store);
app.mount("#app");

stores文件夹→user.ts(定义store存储对象,持久化存储增加persist选项)

import {defineStore} from 'pinia'
/**
 * 定义名为useUserStore的存储对象
 *  defineStore方法
 *   第一个参数: 模块名称是唯一的
 *   第二个参数: 选项对象  state, actions, getters
 *                        data    methods   computed
 */
export const useUserStore = defineStore('user',{
    state(){
        return {
            account:null // 账户数据 {name,nick,password}  
        }
    },
    actions:{
        // 具体业务逻辑,可以是同步或异步操作
        saveAccount(account){
            this.account = account
        }
    },
    getters:{
        //getters中定义的方法名/计算属性名不能与state相同
        // userAccount:state => {
        //     return state.account
        // }//定义方式1
        userAccount(){
            return this.account
        }//定义方式2
    },
    persist: {//持久化存储
        enabledtrue,
        strategies: [
            {
                key: user,
                storagelocalStorage,
                paths: ['account'],
            },
        ],
    },
})

3)Sass、axios网络库、UI组件库需手动安装集成。状态管理vuex也需要手动安装集成。

npm i normalize.css -S 安装样式重置库(兼容浏览器)。main.ts中引入import'normalize.css'
npm i sass -d 安装sass(css预处理器,开发环境用)。
npm install axios -s 安装网络库axios(前后端数据交互)。

创建utils文件夹→request.ts中配置(创建axios实例,封装请求/响应拦截器)

import axios from 'axios'
import { Toast } from 'vant';
// 服务根地址
// export const baseURL = 'http://10.7.163.165:8089'  // 开发环境
/**
 * 创建axios实例
 *   封装baseURL
 */
const axiosInstance = axios.create({
    baseURL, // 服务根地址
    timeout: 3000, // 超时时间
})
/**
 * 请求拦截器
 */
axiosInstance.interceptors.request.use(
    config => {
        const token = localStorage.getItem('TOKEN')
        if(token){
            config.headers['Authorization'] = token
        }
        return config
    },
    error => {
        return Promise.reject(error)
    }
)
/**
 * 响应拦截器
 */
axiosInstance.interceptors.response.use(
    response => {
        return response.data
    },
    error => {
        const { response } = error
        if (response) {
            const status = response.status
            switch (status) {
                case 404:
                    Toast('资源不存在 404')
                    break
                case 401:
                    Toast('Unauthorized 身份验证凭证缺失!')
                    break
                case 403:
                    Toast('403 Forbidden - 拒绝访问!')
                    break
                case 500:
                    Toast('服务器出错')
                    break
                default:
                    Toast('出现异想不到的错误!')
                    break
            }
        }else {
            // 说明服务器连结果都没有返回,可能的原因有两种:
            /**
             * 1. 服务器崩掉了
             * 2. 前端客户端断网状态
             */
            if (!window.navigator.onLine) {
                // 判断为断网,可以跳转到断网页面
                Toast('网络不可用,请检查您的网络连接!')
                return
            } else {
                Toast('连接服务端出错!' + error?.message)
                return Promise.reject(error)
            }
        }
        return Promise.reject(error)
    }
)
export default axiosInstance
vant组件库安装与配置
  • npm i vant@latest-v3 安装vant组件库
  • npm i unplugin-vue-components -D 安装vant按需引入插件。vite.config.ts中引入集成
  • npm i postcss-pxtorem -D安装移动端适配插件,将px单位转化为rem单位。vite.config.ts中引入集成
  • npm i amfe-flexible -D安装移动端适配插件,适配不同屏幕尺寸。main.ts中引入

vite.config.ts

import { fileURLToPath, URLfrom 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

import Components from 'unplugin-vue-components/vite'//引入按需引入插件
import { VantResolverfrom 'unplugin-vue-components/resolvers'//引入按需引入插件

import postCssPxToRem from 'postcss-pxtorem'//引入移动端适配插件

import { viteMockServe } from 'vite-plugin-mock'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    Components({
      resolvers: [VantResolver()],//集成按需引入插件
    }),
    viteMockServe({//集成mock模拟接口数据插件
      // 更多配置见最下方
      supportTstrue,
      loggerfalse,
      mockPath'./mock/', // 文件位置
    }),
  ],
  resolve: {
    alias: {
      '@'fileURLToPath(new URL('./src', import.meta.url))
    }
  },
  css:{
    postcss:{
      plugins:[
        postCssPxToRem({//自适应,px转rem
          rootValue:75,//换算的基数(设计图750的根字体为75)
          propList:['*']//需要转换的属性,*代表全部
        })
      ]
    }
  },
  server:{//代理服务器
    proxy: {
      '/api': {
        target'http://124.71.63.13:8088/',
        changeOrigintrue,
        // rewrite: path => path.replace(/^\/api/, '')
      }
    }
  },
})

main.ts

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './stores'
import 'normalize.css' // 重置样式

// 导入vant函数组件样式
import 'vant/es/toast/style'
import 'vant/es/dialog/style'
import 'vant/es/notify/style'
import 'vant/es/image-preview/style'

//引入amfe-flexible屏幕适配插件
import 'amfe-flexible'

const app = createApp(App)
app.use(store)
app.use(router)

app.mount('#app')
elementplus组件库安装与配置
  • npm install element-plus --save安装elementplus组件库
  • npm install -d unplugin-vue-components unplugin-auto-import安装两个插件自动按需引入

vite.config.js集成插件

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

import AutoImport from 'unplugin-auto-import/vite'//引入插件
import Components from 'unplugin-vue-components/vite'//引入插件
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'//引入插件

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    AutoImport({//集成插件
        resolvers: [ElementPlusResolver()],
    }),
    Components({//集成插件
        resolvers: [ElementPlusResolver()],
    }),
  ],
  resolve: {
    alias: {
      '@'fileURLToPath(new URL('./src', import.meta.url))
    }
  },
})

main.js引入elementplus样式,否则可能出现使用组件没效果。

import { createApp } from "vue";
import router from "./router";
import store from "./store";
import 'element-plus/dist/index.css';//引入elementplus样式
import App from "./App.vue";

import ElementPlus from 'element-plus';//完整引入elementplus,文件大小会很大。可以考虑按需引入

const app = createApp(App);
app.use(router);
app.use(store);

app.use(ElementPlus);//完整引入elementplus,文件大小会很大。可以考虑按需引入
app.mount("#app");
状态管理vuex安装与配置
  • npm install vuex@next(4.0.2) --save安装vuex插件
  • npm i vuex-persistedstate -s安装vuex持久化存储插件

main.js引入集成到vue

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store  from './store'

const app = createApp(App)
app.use(router)
app.use(store)
app.mount('#app')

创建story文件夹→index.js文件集成持久化存储插件

import { createStore } from 'vuex'
import createPersistedState from 'vuex-persistedstate'

const store = createStore({
    // 集成持久化存储插件
    plugins:[createPersistedState({
        storage:sessionStorage,
        key:'storekey'
    })]
    state: {//状态数据
        count0
    },
    mutations: {//操作state状态。第一个参数:state对象,第二个参数:外部传入参数
        PLUS(state) {//方法
            // state.count = num
            state.count++
        },
    },
    /**
     * 定义操作mutations方法的方法
     * 供外部组件调用
     *   $store.dispatch('')
     *    1. 单向数流操作方式,保存状态数据以预期方式改变
     *    2. actions 异步操作
     *       mutations 同步操作
     */
    actions: {//操作mutations中的方法
        // plus(context) {//方法
        //     context.commit('PLUS')
        // }
        plus({ commit }) {//解构
            commit('PLUS')
        },
        chs({ commit },num) {//传参
            commit('CHS',num)
        }
    },
    getters: {//获取值,类似计算属性
        num(state) => { return state.count }
        // num: state => state.count
    }
})
export default store

3.解决引入vue组件ts报错(因为ts不识别.vue文件,需在env.d.ts中声明)

env.d.ts

// <reference types="vite/client" />
declare module '*.vue' {
    import { DefineComponent } from 'vue'
    const componentDefineComponent<{}, {}, any>
    export default component
}

4.目录结构介绍

image.png

  • npm install(npm i)下载依赖
  • npm run dev运行

5.打包

  • 将vue文件编译成浏览器能识别的js/html/css文件、ts编译成js文件、scss编译成css文件,压缩处理。
  • 编译后的文件存放在dist目录下(与src同级),都是html/css/js文件,将其部署到服务器上用户就可以用了。

接口环境配置: 开发环境(测试数据)/生产环境(用户使用的数据)

创建utils文件夹→request.ts中配置(创建axios实例,封装请求/响应拦截器,配置接口环境)

解决跨域问题走代理服务器:baseUrl地址为当前客户端地址,vite.config.ts配置代理服务器,代理服务器地址为目标地址(一般指测试地址/线上服务器地址)。

import axios from 'axios'
import { Toastfrom 'vant'

// 服务根地址
// export const baseURL = 'http://10.7.163.165:8089'  // 开发环境

// 生产环境 如果服务端没做跨域处理,使用代理服务器
// 走代理服务器,baseURL 地址配置为与当前客户端地址相同
export let baseURL = 'http://10.7.163.165:8089'  // 开发环境
/**
 * process.env.NODE_ENV
 *    production 生产环境
 *        npm run build
 *    development  开发环境
 *        npm run dev
 */
switch (process.env.NODE_ENV) {
    case 'production':
        baseURL = 'http://124.71.63.13'  // 生产环境
        break
    case 'development':
        baseURL = 'http://10.7.163.165:8089' // 开发环境
        break
}
/**
 * 创建axios实例
 *   封装baseURL
 */
const axiosInstance = axios.create({
    baseURL, // 服务根地址
    timeout3000, // 超时时间
})
/**
 * 请求拦截器
 */
axiosInstance.interceptors.request.use(
    config => {
        const token = localStorage.getItem('TOKEN')
        if (token) {
            config.headers['Authorization'] = token
        }
        return config
    },
    error => {
        return Promise.reject(error)
    }
)
/**
 * 响应拦截器
 */
axiosInstance.interceptors.response.use(
    response => {
        return response.data
    },
    error => {
        const { response } = error
        if (response) {
            const status = response.status
            switch (status) {
                case 404:
                    Toast('资源不存在 404')
                    break
                case 401:
                    Toast('Unauthorized 身份验证凭证缺失!')
                    break
                case 403:
                    Toast('403 Forbidden - 拒绝访问!')
                    break
                case 500:
                    Toast('服务器出错')
                    break
                default:
                    Toast('出现异想不到的错误!')
                    break
            }
        } else {
            // 说明服务器连结果都没有返回,可能的原因有两种:
            /**
             * 1. 服务器崩掉了
             * 2. 前端客户端断网状态
             */
            if (!window.navigator.onLine) {
                // 判断为断网,可以跳转到断网页面
                Toast('网络不可用,请检查您的网络连接!')
                return
            } else {
                Toast('连接服务端出错!' + error?.message)
                return Promise.reject(error)
            }
        }
        return Promise.reject(error)
    }
)
export default axiosInstance

tsconfig.json(解决request.ts中环境配置时process报错)

{
    "extends": "@vue/tsconfig/tsconfig.web.json",
    "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
    "compilerOptions": {
        "baseUrl": ".",
        "paths": {
            "@/*": ["./src/*"]
        },
        // 解决process.env.NODE_ENV报错,
        // 1.安装npm i @types/node -S
        // 配置"types": ["node"]
        "types": ["node"]
    },
    "references": [
        {
            "path": "./tsconfig.config.json"
        }
    ]
}

vite.config.ts配置代理服务器

import { fileURLToPath, URLfrom 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

import Components from 'unplugin-vue-components/vite'//引入按需引入插件
import { VantResolverfrom 'unplugin-vue-components/resolvers'//引入按需引入插件

import postCssPxToRem from 'postcss-pxtorem'//引入移动端适配插件

import { viteMockServe } from 'vite-plugin-mock'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    Components({
      resolvers: [VantResolver()],//集成按需引入插件
    }),
    viteMockServe({//集成mock模拟接口数据插件
      // 更多配置见最下方
      supportTstrue,
      loggerfalse,
      mockPath'./mock/', // 文件位置
    }),
  ],

  resolve: {
    alias: {
      '@'fileURLToPath(new URL('./src', import.meta.url))
    }
  },
  css:{
    postcss:{
      plugins:[
        postCssPxToRem({//自适应,px转rem
          rootValue:75,//换算的基数(设计图750的根字体为75)
          propList:['*']//需要转换的属性,*代表全部
        })
      ]
    }
  },
  server:{//代理服务器
    proxy: {
      '/api': {
        target'http://124.71.63.13:8088/',//目标地址
        changeOrigintrue,//是否跨域
//这里理解成用'/api'代替target里面的地址,比如我要调用'http://40.00.100.100:3002/user/add',直接写'/api/user/add'即可
        rewrite(path) => path.replace(/^**\/** api/, ''),
      }
    }
  },
})

npm run build打包

四. quasar-cli项目开发中使用的脚手架

关于quasar要求:

  • Node 12+用于Quasar CLI与Webpack,Node 14+用于Quasar CLI与Vite。
  • Yarn v1(强烈推荐),PNPM,或NPM。

npm i -g @quasar/cli 安装脚手架命令

npm init quasar 初始化quasar根据预设生成相应的配置文件

image.png

此时回车,会生成项目文件和目录

image.png

提示安装项目依赖,选择yes回车

image.png

quasar dev(npm run dev)运行

mock模拟后端,生成伪数据接口

官网:github.com/nuysoft/Moc…

1. 安装

npm i mockjs vite-plugin-mock 安装 mockjs和 vite-plugin-mock插件

2. vite.config.ts中集成插件

import { fileURLToPath, URLfrom 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

import Components from 'unplugin-vue-components/vite'//引入按需引入vant插件

import { VantResolverfrom 'unplugin-vue-components/resolvers'

import { viteMockServe } from 'vite-plugin-mock'//引入mock插件

// https://vitejs.dev/config/

export default defineConfig({

  plugins: [

    vue(),

    Components({

      resolvers: [VantResolver()],

    }),

    viteMockServe({//配置mock插件

      // 更多配置见最下方

      supportTstrue,

      loggerfalse,

      mockPath'./mock/', // 文件位置

    }),

  ],

  resolve: {

    alias: {

      '@'fileURLToPath(new URL('./src', import.meta.url))

    }

  }

})

3. mock文件夹mock接口数据

scr同级创建mock文件夹→account.ts(模块拆分,post请求config.body取参)


const accountList = []

/**

 * 用户注册

 */

export const mockRegister = {

    url:'/mock/account/register',

    method:'post',

    response:(config)=>{

        // 注册业务逻辑

        // 1. 获取用户名和密码

        const {username,password,headerimg} = config.body

        // 2. 保存用户信息

        // localStorage.setItem('USER',JSON.stringify({username,password,headerimg}))

        const user = {username,password,headerimg}

        accountList.push(user)

        // 3. 响应内容

        return {

            resultCode:1,

            resultInfo:'注册成功'

        }

    }

}

 

/**

 * 用户登录

 */

export const mockLogin = {

    url:'/mock/account/login',

    method:'post',

    response:(config)=>{

        // 登录业务逻辑

        // 1. 获取用户名和密码

        const {username,password,headerimg} = config.body

        // 2. 判断用户是否注册

        // let userstr = localStorage.getItem('USER')

        // let user = userstr? JSON.parse(userstr):''

        const user = accountList.find(item=>item.username==username && item.password==password)

        if(user){

            return {

                resultCode:1,

                resultInfo: {

                        username,

                        password,

                        headerimg

                }

            }

        }else{

            return {

                resultCode:-1,

                resultInof:'账户出错'

            }

        }

    }

}

scr同级创建mock文件夹→good.ts(模块拆分,get请求config.query取参)

import { goodsListData, bannerListData } from './data/goodsData'

/**

 * 商品列表

 */

export const goodsList = {

    url'/mock/goods/list',

    method'get',

    response(config:any) => {

        //get请求参数

        let { pageNo, pageSize } = config.query

        pageNo = pageNo || 1

        pageSize = pageSize || 10

        console.log('pageNo ', pageNo, ' pageSize :', pageSize)

        // 分页

        const startIndex = (pageNo - 1) * pageSize

        const endIndex = pageNo * pageSize

        const list = goodsListData.slice(startIndex, endIndex)

        if(list.length>0){

            return {

                resultCode1,

                resultInfo: {

                    list,

                },

            }

        }else{

            return {

                resultCode:-1,

                resultInfo:'没有数据'

            }

        }

    },

}

 

/**

 *  banner轮播

 */

export const bannerList = {

    url'/mock/banner',

    method'get',

    response() => {

        return {

            resultCode1,

            resultInfo: {

                list: bannerListData,

            },

        }

    },

}

scr同级创建mock文件夹→data文件夹→goodsData.ts(模块拆分,存放数据)


export const goodsListData = [

    {

        id49,

        shop'【苏宁生鲜】鲜美来东海带鱼600g 海鲜水产 鲜活冷冻',

        picture:

            'https://image1.suning.cn/uimg/b2c/newcatentries/0010128947-000000000614428161_1_800x800.jpg',

        product'【苏宁生鲜】鲜美来东海带鱼600g 海鲜水产 鲜活冷冻',

        price18.88,

        oldprice69,

        putaway1,

        detailnull,

        categoryname'川湘菜',

        categoryId3,

    },

    {

        id48,

        shop'【苏宁生鲜】恒都巴西牛腩块1kg 进口牛肉 精选肉类',

        picture:

            'https://image1.suning.cn/uimg/b2c/newcatentries/0010128947-000000000614167327_1_800x800.jpg',

        product'【苏宁生鲜】恒都巴西牛腩块1kg 进口牛肉 精选肉类',

        price18.98,

        oldprice49,

        putaway1,

        detailnull,

        categoryname'川湘菜',

        categoryId3,

    },

    {

        id45,

        shop'贝克巴斯(BECBAS)E50 厨房家用食物垃圾处理器 厨余垃圾粉碎机 无线开关免打孔',

        picture:

            'https://image3.suning.cn/uimg/b2c/newcatentries/0000000000-000000000616963357_2_800x800.jpg',

        product:

            '贝克巴斯(BECBAS)E50 厨房家用食物垃圾处理器 厨余垃圾粉碎机 无线开关免打孔',

        price38,

        oldprice67,

        putaway1,

        detailnull,

        categoryname'大家电',

        categoryId9,

    },

    {

        id44,

        shop'科龙(Kelon) 正1.5匹 定速 冷暖 空调挂机 ',

        picture:

            'https://image4.suning.cn/uimg/b2c/newcatentries/0000000000-000000000178605073_1_800x800.jpg',

        product'科龙(Kelon) 正1.5匹 定速 冷暖 空调挂机 ',

        price156.9,

        oldprice190,

        putaway1,

        detail'这个商品不错',

        categoryname'排骨',

        categoryId7,

    },

    {

        id42,

        shop'华帝浴室花洒 增压花洒 主体全铜花洒 沐浴莲蓬头【自营】H-CS0014-B1D.W',

        picture:

            'https://image2.suning.cn/uimg/b2c/newcatentries/0000000000-000000000604135232_1_800x800.jpg',

        product:

            '华帝浴室花洒 增压花洒 主体全铜花洒 沐浴莲蓬头【自营】H-CS0014-B1D.W',

        price18.87,

        oldprice90,

        putaway1,

        detailnull,

        categoryname'排骨',

        categoryId7,

    },

    {

        id41,

        shop'长虹(CHANGHONG) 大1匹 冷暖定频 快速制冷热 空调挂机 KFR-26GW/DHID(W1-J)+2',

        picture:

            'https://image2.suning.cn/uimg/b2c/newcatentries/0000000000-000000000126066901_1_800x800.jpg',

        product:

            '长虹(CHANGHONG) 大1匹 冷暖定频 快速制冷热 空调挂机 KFR-26GW/DHID(W1-J)+2',

        price18.48,

        oldprice35,

        putaway1,

        detailnull,

        categoryname'火锅',

        categoryId8,

    },

    {

        id40,

        shop'小米智米(SMARTMI)智能马桶盖 家用全自动冲洗即热式加热外置过滤器杀菌洁便器',

        picture:

            'https://image4.suning.cn/uimg/b2c/newcatentries/0000000000-000000000673521926_1_800x800.jpg',

        product:

            '小米智米(SMARTMI)智能马桶盖 家用全自动冲洗即热式加热外置过滤器杀菌洁便器',

        price34.98,

        oldprice47,

        putaway1,

        detailnull,

        categoryname'火锅',

        categoryId8,

    },

]

 

/**

 * 轮播数据

 */

export const bannerListData = [

    {

        id1,

        url'https://img.alicdn.com/imgextra/i3/2053469401/O1CN01g8ituT2JJiCsKjmDz_!!2053469401.png',

        content'商家1',

        number1,

    },

    {

        id11,

        url'https://img2.baidu.com/it/u=717979571,3244631707&fm=253&fmt=auto&app=138&f=JPEG?w=1280&h=400',

        content'舒适度满分',

        number1,

    },

    {

        id12,

        url'https://img1.baidu.com/it/u=1733684228,4177721799&fm=253&fmt=auto&app=138&f=JPEG?w=1200&h=500',

        content'曲线在召唤',

        number1,

    },

]

scr同级创建创建mock文件夹→index.ts(模块集成暴露)


import { bannerList, goodsList } from './goods'

import { mockRegister, mockLogin } from './account'

 

const goods = [bannerList, goodsList]  // 商品模块接口

const account = [mockLogin, mockRegister] // 个人中心接口

 

export default [...goods,...account]

4. utils文件夹→requestMock.ts(封装axios,不封装直接使用axios也行)

import axios from 'axios'
import { Toastfrom 'vant'

export let baseURL = 'http://10.7.163.159:5173'  

/**

 * 创建axios实例

 *   封装baseURL

 */

const axiosInstance = axios.create({

    baseURL, // 服务根地址

    timeout3000, // 超时时间

})

 

/**

 * 请求拦截器

 */

axiosInstance.interceptors.request.use(

    config => {

        const token = localStorage.getItem('TOKEN')

        if (token) {

            config.headers['Authorization'] = token

        }

        return config

    },

    error => {

        return Promise.reject(error)

    }

)

/**

 * 响应拦截器

 */

axiosInstance.interceptors.response.use(

    response => {

        return response.data

    },

    error => {

        const { response } = error

        if (response) {

            const status = response.status

            switch (status) {

                case 404:

                    Toast('资源不存在 404')

                    break

                case 401:

                    Toast('Unauthorized 身份验证凭证缺失!')

                    break

                case 403:

                    Toast('403 Forbidden - 拒绝访问!')

                    break

                case 500:

                    Toast('服务器出错')

                    break

                default:

                    Toast('出现异想不到的错误!')

                    break

            }

        } else {

            // 说明服务器连结果都没有返回,可能的原因有两种:

            /**

             * 1. 服务器崩掉了

             * 2. 前端客户端断网状态

             */

            if (!window.navigator.onLine) {

                // 判断为断网,可以跳转到断网页面

                Toast('网络不可用,请检查您的网络连接!')

                return

            } else {

                Toast('连接服务端出错!' + error?.message)

                return Promise.reject(error)

            }

        }

        return Promise.reject(error)

    }

)

export default axiosInstance

5. api文件夹→index.ts文件中定义接口

import axiosInstance from '@/utils/request'
import axiosMockInstance from '@/utils/requestMock'
import type{IResponse} from '@/types/types'


/**

 * 产品列表

 *   shopKey: 店铺名称

 *   productKey: 产品名称

 * @returns 

 */

export const RequestShopList = (pageNo:number,pageSize:number):Promise<IResponse>=>{

    return axiosInstance({

        method:'get',

        url:'/api/shop',

        params:{

            pageSize,

            pageNo,

        }

    })

}

/**

 * 轮播

 */

export const RequestBanner = ():Promise<IResponse>=>{

    return axiosInstance({

        method:'get',

        url:'/api/banner'

    })

}

/**

 * 登录

 */

export const RequestLogin = (username:string,password:string):Promise<IResponse>=>{

    return axiosMockInstance({

        method:'post',

        // url:'/api/member/login',

        url:'/mock/account/login',

        data:{

            username,

            password

        }

    })

}

/**

 * 注册

 */

export const RequestRegister = (username:string,password:string,headerimg:string):Promise<IResponse>=>{

    return axiosMockInstance({

        method:'post',

        url:'/mock/account/register',

        data:{

            username,

            password,

            headerimg

        }

    })

}

.vue文件中引入使用即可

在线PDF拆分工具核心JS实现

这篇只讲本项目里“PDF拆分”工具的功能层 JavaScript 实现。主流程可以概括为:

选择 PDF -> 读取页数 -> 生成拆分页组 -> 复制指定页面 -> 生成多个 PDF -> 单文件下载或 ZIP 打包下载

工具基于 Vue 组织交互状态,核心 PDF 操作使用 pdf-lib,多文件结果打包使用 JSZip,页面预览和书签读取由 pdfjs-dist 辅助完成。

在线工具网址:see-tool.com/pdf-split
工具截图:
工具截图.png

1. 文件进入流程前先做 PDF 判断

文件选择和拖拽上传共用同一套入口。真正加载前,先判断文件类型:

export function isPdfSplitFile(file) {
  if (!file) {
    return false;
  }

  var fileType = String(file.type || "").toLowerCase();
  var fileName = String(file.name || "");
  return fileType === "application/pdf" || /\.pdf$/i.test(fileName);
}

这里同时判断 MIME 和文件后缀,是因为部分浏览器环境下 file.type 可能为空,只依赖 MIME 会误拦正常 PDF。

加载文件时,会把同一份原始字节切成两份用途:

var rawBytes = await file.arrayBuffer();
var splitBytes = rawBytes.slice(0);
var previewBytes = rawBytes.slice(0);
var sourceDoc = await PDFDocument.load(splitBytes);

splitBytes 用于后续拆分,previewBytes 用于预览和书签读取。这样拆分主链路和辅助信息链路互不影响。

2. 页码输入解析成统一的拆分页组

拆分逻辑不是直接处理输入框字符串,而是先转成统一结构:

{
  label: "1-3",
  indices: [0, 1, 2]
}

label 用于文件命名,indicespdf-lib 需要的零基页码数组。

页码范围解析支持逗号分隔,也支持倒序区间:

function buildPageIndices(start, end) {
  var indices = [];
  var page;

  if (start <= end) {
    for (page = start; page <= end; page += 1) {
      indices.push(page - 1);
    }
    return indices;
  }

  for (page = start; page >= end; page -= 1) {
    indices.push(page - 1);
  }

  return indices;
}

所以用户输入 1-3,5,8-6 时,会生成三个输出段:第 1 到 3 页、第 5 页、第 8 到 6 页。

3. 多种拆分模式最终都归一到 groups

工具支持按页码范围、每 N 页、每页单独、奇偶页、可视化选择、书签、平均拆成 N 份。虽然入口不同,但最终都会变成 groups

buildSplitGroups: function () {
  if (this.splitMode === "ranges") {
    return parsePdfSplitRangeGroups(this.rangeInput, this.totalPages);
  }

  if (this.splitMode === "everyN") {
    return buildPdfSplitCountGroups(
      this.totalPages,
      parsePdfSplitPositiveInt(this.everyNInput),
    );
  }

  if (this.splitMode === "everyPage") {
    return buildPdfSplitEveryPageGroups(this.totalPages);
  }

  if (this.splitMode === "evenOdd") {
    return buildPdfSplitEvenOddGroups(this.totalPages, this.evenOddMode);
  }

  if (this.splitMode === "visual") {
    return buildPdfSplitVisualGroups(this.selectedPages);
  }

  if (this.splitMode === "bookmarks") {
    return buildPdfSplitBookmarkGroups(this.bookmarkItems, this.totalPages);
  }

  if (this.splitMode === "nTimes") {
    return buildPdfSplitNPartsGroups(
      this.totalPages,
      parsePdfSplitPositiveInt(this.nTimesInput),
    );
  }

  return [];
}

这个设计的好处是,真正拆分 PDF 时不关心用户选择了哪种模式,只消费统一的页码分组。

4. 可视化选择会自动合并连续页

可视化模式下,用户点选的是离散页码。工具会先排序、去重,再把连续页合并成一个输出段:

export function buildPdfSplitVisualGroups(selectedPages) {
  var uniquePages = Array.isArray(selectedPages)
    ? selectedPages
        .map(function (page) {
          return Number(page);
        })
        .filter(function (page) {
          return Number.isInteger(page) && page > 0;
        })
        .sort(function (left, right) {
          return left - right;
        })
        .filter(function (page, index, source) {
          return index === 0 || page !== source[index - 1];
        })
    : [];

  if (!uniquePages.length) {
    throw createPdfSplitInputError("emptySelection");
  }

  var groups = [];
  var start = uniquePages[0];
  var end = uniquePages[0];

  for (var i = 1; i < uniquePages.length; i += 1) {
    if (uniquePages[i] === end + 1) {
      end = uniquePages[i];
      continue;
    }

    pushMergedSelectionGroup(groups, start, end);
    start = uniquePages[i];
    end = uniquePages[i];
  }

  pushMergedSelectionGroup(groups, start, end);
  return groups;
}

比如选择 1、2、3、7、9、10,结果会拆成 1-379-10 三个文件。

5. 书签拆分按顶层书签生成区间

书签模式先读取 PDF 的 outline,再把书签所在页转换成拆分区间。核心逻辑是:当前书签页作为开始页,下一个书签前一页作为结束页。

export function buildPdfSplitBookmarkGroups(bookmarks, totalPages) {
  var normalizedBookmarks = Array.isArray(bookmarks)
    ? bookmarks
        .filter(function (item) {
          return (
            item &&
            Number.isInteger(Number(item.pageNumber)) &&
            Number(item.pageNumber) >= 1 &&
            Number(item.pageNumber) <= totalPages
          );
        })
        .map(function (item) {
          return {
            title: String(item.title || "").trim() || "bookmark",
            pageNumber: Number(item.pageNumber),
          };
        })
        .sort(function (left, right) {
          return left.pageNumber - right.pageNumber;
        })
    : [];

  var groups = [];

  if (normalizedBookmarks[0].pageNumber > 1) {
    groups.push({
      label: "preface",
      indices: buildPageIndices(1, normalizedBookmarks[0].pageNumber - 1),
      title: "preface",
    });
  }

  for (var index = 0; index < normalizedBookmarks.length; index += 1) {
    var current = normalizedBookmarks[index];
    var next = normalizedBookmarks[index + 1];
    var start = current.pageNumber;
    var end = next ? next.pageNumber - 1 : totalPages;

    groups.push({
      label: current.title,
      indices: buildPageIndices(start, end),
      title: current.title,
    });
  }

  return groups;
}

如果第一个书签不在第一页,前面的内容会单独生成一个 preface 分段。

6. 真正拆分 PDF 的核心是 copyPages

拆分主函数先构建 groups,然后每个分组创建一个新的 PDF:

for (index = 0; index < groups.length; index += 1) {
  var group = groups[index];
  var outputDoc = await PDFDocument.create();
  var copiedPages = await outputDoc.copyPages(
    this.sourceDoc,
    group.indices,
  );

  copiedPages.forEach(function (page) {
    outputDoc.addPage(page);
  });

  var outputBytes = await outputDoc.save();
  var outputBlob = new Blob([outputBytes], {
    type: "application/pdf",
  });

  nextOutputs.push({
    name: this.buildOutputName(group, index, groups.length),
    blob: outputBlob,
    size: outputBlob.size,
  });
}

这里不是修改原 PDF,也不是切割二进制文件,而是把源文档里的指定页面复制到一个新文档。group.indices 决定当前输出文件包含哪些页。

7. 输出文件名根据拆分模式生成

文件名会先清理原 PDF 名称,再结合模式和页码标签生成:

export function buildPdfSplitOutputName(options) {
  var config = options || {};
  var baseName = safePdfSplitBaseName(config.baseName);
  var index = Number(config.index) || 0;
  var total = Number(config.total) || 0;
  var label = String(config.label || "");
  var mode = String(config.mode || "ranges");
  var sequence = String(index + 1).padStart(3, "0");
  var safeLabel = sanitizePdfSplitFileLabel(label) || sequence;

  if (mode === "everyPage") {
    return baseName + "_page_" + safeLabel + ".pdf";
  }

  if (mode === "bookmarks") {
    return baseName + "_" + sequence + "_" + safeLabel + ".pdf";
  }

  if (total === 1) {
    return baseName + "_split.pdf";
  }

  return baseName + "_split_" + sequence + "_p" + safeLabel + ".pdf";
}

这样拆出多个文件时,用户能从文件名看出顺序和页码范围。

8. 单结果直接下载,多结果打包 ZIP

导出时先判断结果数量。只有一个 PDF 时直接下载;多个 PDF 时放进 ZIP:

downloadResult: async function () {
  if (!this.outputs.length) {
    return;
  }

  if (this.outputs.length === 1) {
    this.downloadOutput(this.outputs[0]);
    return;
  }

  var zip = new JSZip();
  this.outputs.forEach(function (item) {
    zip.file(item.name, item.blob);
  });

  var zipBlob = await zip.generateAsync({
    type: "blob",
    compression: "DEFLATE",
    compressionOptions: {
      level: 6,
    },
  });

  this.downloadBlob(zipBlob, "split_result.zip");
}

浏览器下载统一通过 Blob 和临时 a 标签完成:

downloadBlob: function (blob, filename) {
  var url = URL.createObjectURL(blob);
  var link = document.createElement("a");

  link.href = url;
  link.download = filename;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
  URL.revokeObjectURL(url);
}

整个 PDF 拆分功能的核心,就是把不同输入方式都转换成稳定的页码分组,再用 pdf-lib 复制页面生成新文档,最后根据结果数量决定直接下载还是打包下载。

解决不同项目需要不同 Node.js 版本的问题

告别“这是在我电脑上能跑”的魔咒:Node.js 多版本管理终极指南

你是否遇到过这样的场景:接手一个老项目,运行时疯狂报错;切回自己的新项目,又提示语法不支持。  根源往往只有一个——Node.js 版本不匹配。

本文将彻底解决这个困扰无数开发者的问题,教你一套优雅的 Node.js 多版本管理方案,让你在不同项目间自由切换,再无环境烦恼。


一、症状:你的Node.js版本管理出问题了

典型“病状”自查:

  • 启动项目时,控制台输出 SyntaxError: Unexpected token '??='(常见于 Node.js 版本过低,不识别新语法)
  • 运行npm install后,依赖死活装不上,或者启动就报错
  • 团队中有人跑得好好的,你拉下来却各种异常
  • 你电脑里明明装了新版Node,老项目却要求你必须降级

如果你中了一条以上,恭喜你,需要开始管理 Node.js 版本了。


二、根本原因:Node.js 版本更新太快,生态碎片化

Node.js 版本 发布时间 主要特性
v12 2019 相对稳定,但较老
v14 2020 LTS(长期支持版,很多老项目仍用)
v16 2021 支持 ??=&&= 等逻辑赋值运算符
v18 2022 支持原生 Fetch、Node.js 测试运行器
v20 2023 稳定版,性能提升
v22+ 2024+ 最新特性,需主动升级

核心矛盾:老项目不敢轻易升(怕 breaking changes),新项目又享受不到新特性。❌ 全局只有一个 Node 版本的模式,必然死路一条。

image.png


三、解决方案核心:nvm(Node Version Manager)

nvm 是什么?
一个让你在电脑上同时安装、共存多个 Node.js 版本,并能在终端里一键切换的工具。

🪟 Windows 用户指南:nvm-windows

1️⃣ 安装前的准备工作(非常重要!)

安装 nvm-windows 之前,务必彻底卸载电脑上原有的 Node.js,避免冲突:

  • “控制面板” -> “程序和功能” -> 卸载 Node.js

  • 手动删除以下残留文件夹(如存在):

    text

    C:\Program Files\nodejs
    C:\Program Files (x86)\nodejs
    C:\Users<你的用户名>\AppData\Roaming\npm
    C:\Users<你的用户名>\AppData\Roaming\npm-cache
    
  • 检查系统的 PATH 环境变量,删除所有与 Node.js 或 npm 相关的路径

2️⃣ 安装 nvm-windows
  1. 访问 nvm-windows 发布页,下载最新版 nvm-setup.zip
  2. 解压后,以管理员身份运行 nvm-setup.exe
  3. 按向导安装,路径建议保持默认(避免权限问题)。
  4. 安装完成后,重启命令行工具(CMD 或 PowerShell)。
3️⃣ 下载加速(国内用户强烈推荐)

在 nvm 安装目录(默认 C:\Users<你的用户名>\AppData\Roaming\nvm)下,找到 settings.txt,末尾添加:

text

node_mirror: https://npmmirror.com/mirrors/node/
npm_mirror: https://npmmirror.com/mirrors/npm/

这样可以大幅提升国内下载 Node.js 的速度。

🍎 macOS / Linux 用户指南:标准版 nvm

在终端中执行:

bash

# 使用 curl
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash

# 或使用 wget
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash

安装脚本会自动将 nvm 加入到你的 shell 配置文件(~/.bashrc~/.zshrc 等)。安装完成后,重启终端或运行 source ~/.zshrc(根据你的 shell 选择)使其生效。


四、一图看懂 nvm 核心操作

我要做什么 命令示例
查看能装哪些 Node 版本 Windows: nvm list available Mac/Linux: nvm ls-remote
安装某个具体版本 nvm install 16.20.0
安装最新的 LTS 版本 nvm install --lts
看我电脑里已有哪些版本 nvm list
在当前终端切换到某个版本 nvm use 16.20.0
设置默认(新打开终端)版本 nvm alias default 16.20.0
删除某个版本 nvm uninstall 16.20.0
查看当前使用版本 node -v

⚠️ Windows 用户特别注意:执行 nvm use 切换版本时,建议以管理员身份打开命令行,否则可能因权限不足而切换失败。


五、终极奥义:自动化项目版本切换(.nvmrc)

再也不用手动记住每个项目用的 Node 版本。

操作步骤

  1. 在项目根目录下,创建一个名叫  .nvmrc 的文件(注意开头有个点)。

  2. 文件内容只需一行,比如:16.20.0(或者 lts/gallium,等别名)。

  3. 当你要进入该项目工作时,在项目根目录执行:

    bash

    nvm use
    

    nvm 会自动读取 .nvmrc 中指定的版本并切换过去。

更高级:自动切换(可选)

如果你希望每次 cd 进项目目录时自动切换,可以借助 avn 或 zsh-nvm 插件。但个人建议:手动执行 nvm use 已经足够简洁,且避免了误切换。


Vue前端SEO优化全攻略(实操落地版,新手也能上手)

Vue作为主流前端框架,其默认的客户端渲染(CSR)模式存在天然SEO短板——SPA页面初始加载仅返回空骨架HTML,核心内容通过JavaScript动态渲染,搜索引擎爬虫可能无法等待JS执行完毕,导致页面内容无法被正常抓取、索引,最终影响网站曝光和排名。

Vue前端SEO优化的核心逻辑的是:让搜索引擎爬虫能轻松抓取页面核心内容、识别页面层级、明确页面价值,本质是解决“爬虫可见性”和“内容可识别性”两大问题。以下方案从基础到进阶,覆盖所有高频优化场景,附具体代码和避坑细节,Vue2/Vue3通用,可直接复制落地。

一、核心优化:解决SPA渲染短板(爬虫抓取核心)

Vue SEO的最大痛点的是“动态内容无法被爬虫抓取”,核心解决方案有3种,根据项目规模和需求选择,优先推荐“预渲染”(低成本、易落地),动态内容多的场景选择“SSR”,快速落地可选择“静态站点生成”。

1. 预渲染(Prerendering):低成本首选,适配静态内容场景

核心逻辑:在项目构建阶段,提前渲染指定路由的静态HTML文件(包含完整内容),部署后用户和爬虫访问时,直接返回渲染好的静态页面,无需等待客户端JS执行,完美解决SPA初始内容为空的问题。

适配场景:内容相对固定的页面(官网、博客详情、产品介绍页),无需服务器额外部署,静态托管即可,开发成本最低。

实操步骤(Vue3+Vite适配):

  1. 安装预渲染插件:pnpm add -D @prerenderer/rollup-plugin(Vite项目);Vue2+Webpack项目可使用prerender-spa-plugin
  2. 配置vite.config.js,指定需要预渲染的路由: import { defineConfig } from 'vite' `` import vue from '@vitejs/plugin-vue' `` import prerender from '@prerenderer/rollup-plugin' ```` export default defineConfig({ `` plugins: [ `` vue(), `` // 预渲染配置 `` prerender({ `` routes: ['/', '/about', '/product', '/contact'], // 需要预渲染的路由(必填) `` renderer: '@prerenderer/renderer-puppeteer' // 渲染器,无需额外配置 `` }) `` ] ``})
  3. 执行npm run build,构建后dist目录会生成每个路由对应的静态HTML文件(如/about/index.html),直接部署即可;
  4. 避坑点:预渲染仅适用于内容固定的页面,动态内容(如实时数据、用户中心)无法预渲染,需结合其他方案;路由较多时,会增加构建时间。

2. 服务端渲染(SSR):动态内容首选,适配高需求场景

核心逻辑:用户/爬虫发起请求时,服务器先执行Vue代码,渲染出完整的HTML(包含动态内容),再将HTML返回给客户端,爬虫可直接抓取完整内容,同时能提升首屏加载速度,是动态内容(电商商品页、资讯列表)的最优解。

适配场景:动态内容多、对SEO和首屏速度要求高的项目(电商、资讯平台),需额外部署Node.js服务器,开发和运维成本较高。

实操方案(两种选择,优先推荐Nuxt.js):

  • 方案1:使用Nuxt.js(Vue官方推荐,简化SSR配置)

    • 创建Nuxt项目(Vue3):npx nuxi init my-nuxt-seo
    • Nuxt自动实现SSR,页面组件中可通过asyncDatafetch获取服务端数据,确保渲染的HTML包含动态内容: <script setup> `` // 服务端获取数据,渲染到HTML中,爬虫可直接抓取 `` const { data } = await useAsyncData('productList', () => { `` return fetch('/api/product').then(res => res.json()) `` }) ``</script>
    • 部署:需部署到支持Node.js的服务器(如阿里云ECS、Vercel),Nuxt提供一键部署方案,降低运维成本。
  • 方案2:自定义SSR(Vue2/Vue3通用,灵活度高)

    • 基于Express+vue-server-renderer实现,核心是创建服务端渲染入口,将Vue组件渲染为HTML字符串,返回给客户端;
    • 注意:需区分客户端和服务端环境,避免在服务端使用window、document等浏览器API,否则会报错。

补充:SSR的核心优势是支持动态内容抓取,但需注意服务器负载,可通过CDN缓存优化,减少服务器压力。

3. 静态站点生成(SSG):折中方案,兼顾成本和动态性

核心逻辑:在构建阶段生成所有页面的静态HTML(类似预渲染),但支持动态数据注入,构建后可静态托管,同时能通过增量构建更新内容,适配内容更新频率较低的动态场景(如每周更新的资讯、商品页)。

实操方案(Vue3+ViteSSG):

  1. 安装插件:pnpm add -D vite-ssg
  2. 改造入口文件main.ts(替换createApp,交给ViteSSG接管): import { ViteSSG } from 'vite-ssg' `` import App from './App.vue' `` import { routes } from './router' // 导出路由数组,而非router实例 ```` // 核心改造:ViteSSG生成静态站点 `` export const createApp = ViteSSG( `` App, `` { routes, base: import.meta.env.BASE_URL }, `` ({ app, router }) => { `` // 注册插件(如Pinia、VueMeta) `` } ``)
  3. 路由配置改造:需导出routes数组,且必须使用History模式,避免Hash模式破坏静态页面结构;
  4. 优势:无需部署Node.js服务器,静态托管即可,支持动态数据注入,构建后页面加载速度快,爬虫抓取友好。

二、基础优化:元信息(Meta)配置(爬虫识别核心)

搜索引擎爬虫抓取页面时,首先读取页面的元信息(Title、Description、Keywords等),用于判断页面主题和价值,是SEO优化的基础,必须每个页面配置独立的元信息,避免全局统一配置导致的权重分散。

1. 核心插件:vue-meta(Vue2/Vue3通用)

用于在组件级别管理元信息,支持动态设置Title、Meta标签、OG标签(用于社交媒体分享),无需手动操作DOM,适配SPA、SSR、SSG所有场景。

实操步骤:

  1. 安装插件:npm install vue-meta --save
  2. 全局注册(main.ts): import { createApp } from 'vue' `` import App from './App.vue' `` import VueMeta from 'vue-meta' ```` const app = createApp(App) `` app.use(VueMeta, { `` refreshOnceOnNavigation: true // 路由切换时刷新元信息 `` }) ``app.mount('#app')
  3. 组件中配置(每个页面独立配置): <script setup> `` // Vue3组合式API配置 `` useMeta({ `` title: 'Vue SEO优化指南 | 新手也能落地的实操方案', // 页面标题(核心,包含关键词) `` htmlAttrs: { lang: 'zh-CN' }, // 页面语言,帮助爬虫识别 `` meta: [ `` { name: 'description', content: '本文详细讲解Vue前端SEO优化方法,包含预渲染、SSR、元信息配置等实操技巧,适合新手学习,可直接复制落地。' }, // 页面描述(吸引点击,包含核心关键词) `` { name: 'keywords', content: 'Vue SEO, Vue前端SEO, Vue预渲染, Vue SSR' }, // 核心关键词(3-5个为宜,避免堆砌) `` // OG标签(优化社交媒体分享,提升曝光) `` { property: 'og:title', content: 'Vue SEO优化指南' }, `` { property: 'og:description', content: '新手也能落地的Vue前端SEO实操方案' }, `` { property: 'og:type', content: 'article' } `` ] `` }) ``</script>

2. 路由级元信息配置(统一管理,避免遗漏)

通过Vue Router的meta配置,统一管理所有页面的元信息,结合全局导航守卫,实现路由切换时自动更新元信息,适合页面较多的项目。

// router/index.ts(Vue3)
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    component: () => import('../views/Home.vue'),
    meta: {
      title: '首页 | Vue SEO优化实战',
      metaTags: [
        { name: 'description', content: '首页:专注Vue前端SEO优化,分享可落地的实操技巧' },
        { name: 'keywords', content: 'Vue SEO, 前端SEO, Vue优化' }
      ]
    }
  },
  {
    path: '/product/:id',
    component: () => import('../views/Product.vue'),
    meta: {
      title: '产品详情 | Vue SEO优化实战',
      metaTags: [
        { name: 'description', content: '产品详情页,展示Vue SEO相关工具和方案' },
        { name: 'keywords', content: 'Vue产品, SEO工具, Vue优化方案' }
      ]
    }
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

// 全局导航守卫:路由切换时更新元信息
router.beforeEach((to, from, next) => {
  // 更新页面标题
  document.title = to.meta.title || 'Vue SEO优化指南'
  
  // 移除已存在的meta标签,避免重复
  const existingTags = document.querySelectorAll('meta[name^="vue-meta-"]')
  existingTags.forEach(tag => tag.parentNode.removeChild(tag))
  
  // 添加新的meta标签
  if (to.meta.metaTags) {
    to.meta.metaTags.forEach(tag => {
      const metaTag = document.createElement('meta')
      metaTag.setAttribute('name', tag.name)
      metaTag.setAttribute('content', tag.content)
      metaTag.setAttribute('vue-meta', '1')
      document.head.appendChild(metaTag)
    })
  }
  
  next()
})

export default router

3. 避坑点

  • Title:每个页面独立,包含1-2个核心关键词,长度控制在30字以内,避免堆砌关键词;
  • Description:简洁明了,包含核心关键词,长度控制在120字以内,吸引用户点击,避免和其他页面重复;
  • Keywords:3-5个为宜,贴合页面内容,避免堆砌(如“Vue,SEO,VueSEO,前端优化,SEO优化”);
  • OG标签:必须配置,优化微信、微博等社交媒体分享时的预览效果,提升页面曝光率。

三、内容优化:让爬虫“读懂”页面内容

即使解决了渲染问题,若页面内容杂乱、结构不清晰,爬虫仍无法识别核心价值,需优化内容结构和标签使用,提升页面权重。

1. 语义化标签使用(核心)

Vue模板中优先使用语义化标签,替代div嵌套,帮助爬虫识别页面层级和内容类型,提升页面可读性。

<!-- 推荐:语义化标签,清晰区分页面结构 --&gt;
&lt;header&gt;
  &lt;h1&gt;Vue SEO优化指南&lt;/h1&gt; <!-- 每个页面只有1个h1,作为页面核心标题 -->
  <nav><!-- 导航栏 -->
    <a href="/" rel="canonical">首页</a>
    <a href="/about">关于我们</a>
  </nav>
</header&gt;
&lt;main&gt;<!-- 页面核心内容 -->
  <section><!-- 内容区块 -->
    <h2>一、核心优化方案</h2><!-- h2-h6层级递减,不跳级 -->
    <p>Vue SEO的核心是解决爬虫抓取问题,主要有3种方案...</p>
  </section&gt;
&lt;/main&gt;
&lt;footer&gt;<!-- 页脚 -->
  <p>© 2026 Vue SEO优化指南 版权所有</p>
</footer>

关键要点:

  • 每个页面只有1个h1标签,作为页面核心标题,包含核心关键词;
  • h2-h6标签层级递减,不跳级(如h1之后是h2,h2之后是h3),清晰区分内容层级;
  • 使用header、main、nav、section、footer等语义化标签,替代div,帮助爬虫识别页面结构。

2. 动态内容优化(爬虫可识别)

对于SPA中的动态内容(如列表、详情),除了使用SSR/SSG/预渲染,还需注意:

  • 避免使用v-if隐藏核心内容:爬虫可能无法识别v-if控制的内容,若必须隐藏,可使用v-show(通过CSS隐藏,内容仍在HTML中);
  • 图片、视频添加alt属性:图片需添加alt属性(描述图片内容,包含关键词),视频添加title属性,帮助爬虫识别多媒体内容; <!-- 正确示例:图片添加alt属性 --> ``<img src="/vue-seo.jpg" alt="Vue前端SEO优化实操步骤" />
  • 结构化数据标记(Schema.org):给核心内容(如文章、产品、资讯)添加结构化数据,帮助搜索引擎理解内容类型,提升搜索排名(如电商商品可标记价格、评分,文章可标记作者、发布时间): <script setup> `` useMeta({ `` script: [ `` { `` type: 'application/ld+json', `` json: { `` "@context": "https://schema.org", `` "@type": "Article", `` "name": "Vue前端SEO优化全攻略", `` "description": "新手也能落地的Vue SEO实操方案", `` "author": { "@type": "Person", "name": "前端开发者" }, `` "datePublished": "2026-04-23" `` } `` } `` ] `` }) ``</script>

3. 内部链接优化

  • 页面之间添加合理的内部链接(如首页链接到产品页、文章页),帮助爬虫抓取更多页面,提升网站整体权重;
  • 避免使用空链接、死链接,链接文本需贴合目标页面内容(如“查看Vue预渲染教程”,而非“点击这里”);
  • 使用rel="canonical"标签,避免页面重复(如同一内容有多个URL,指定规范URL),防止权重分散: <a href="/product" rel="canonical">产品列表</a>

四、性能优化:提升页面加载速度(辅助SEO)

搜索引擎优先收录加载速度快的页面,Vue项目的性能优化不仅能提升用户体验,还能间接提升SEO排名,核心优化点如下:

1. 资源优化

  • 图片优化:压缩图片(使用tinypng等工具),使用WebP格式,懒加载(避免首屏加载过多图片),Vue中可使用vue-lazyload插件: // 安装:npm install vue-lazyload --save `` // 全局注册 `` import VueLazyload from 'vue-lazyload' `` app.use(VueLazyload, { `` loading: '/loading.png', // 加载中占位图 `` error: '/error.png' // 加载失败占位图 `` }) `` // 页面使用 ``<img v-lazy="imgSrc" alt="Vue SEO优化" />
  • JS/CSS优化:开启Gzip压缩(需服务器配置),拆分代码(路由懒加载),减少首屏加载体积: // 路由懒加载(Vue Router) `` const routes = [ `` { `` path: '/about', `` component: () => import('../views/About.vue') // 懒加载,按需加载组件 `` } ``]
  • 静态资源CDN托管:将图片、JS、CSS等静态资源部署到CDN(如阿里云CDN),提升资源加载速度,减轻服务器压力。

2. 首屏加载优化

  • 减少首屏JS体积:移除无用代码,按需引入第三方插件(如Element Plus可按需引入组件);
  • 预加载核心资源:使用<link rel="preload">预加载首屏必需的资源(如核心JS、CSS);
  • 优化webpack/vite配置:压缩代码、移除注释,减少构建后文件体积: // vue.config.js(Vue2+Webpack) `` module.exports = { `` configureWebpack: config => { `` config.plugin('html').tap(args => { `` args[0].minify = { `` removeComments: true, `` collapseWhitespace: true, // 压缩HTML `` removeAttributeQuotes: true `` } `` return args `` }) `` } ``}

五、其他关键优化(避坑必看)

1. 路由优化(History模式)

Vue Router默认使用Hash模式(URL带#),#后面的内容无法被爬虫识别,需切换为History模式,并配置服务器,避免404错误。

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
  history: createWebHistory(), // 切换为History模式
  routes
})

服务器配置(以Nginx为例):

server {
  listen 80;
  server_name your-domain.com;
  root /usr/share/nginx/html; # 部署目录
  
  # 解决History模式404问题
  location / {
    try_files $uri $uri/ /index.html;
  }
}

2. 避免SEO黑名单操作

  • 禁止隐藏关键词(如文字颜色和背景色一致)、堆砌关键词,会被搜索引擎判定为作弊,降低排名;
  • 禁止使用iframe嵌套核心内容,爬虫可能无法抓取iframe内的内容;
  • 禁止动态生成的内容完全依赖JS(如无SSR/预渲染,仅通过JS渲染核心内容),会导致爬虫无法抓取。

3. 配置robots.txt和sitemap.xml

  • robots.txt:放在网站根目录,指定爬虫可抓取和不可抓取的页面,避免爬虫抓取无用页面(如后台管理页): # robots.txt `` User-agent: * # 所有爬虫 `` Allow: / # 允许抓取所有页面 `` Disallow: /admin/ # 禁止抓取后台页面 ``Disallow: /api/ # 禁止抓取接口页面
  • sitemap.xml:生成网站地图,列出所有需要被抓取的页面,提交给百度、谷歌等搜索引擎,帮助爬虫快速抓取所有页面,提升收录效率。

六、优化效果验证(必做步骤)

优化完成后,需验证优化效果,确保爬虫能正常抓取页面内容,核心验证工具和步骤:

  1. 查看页面源码:右键“查看页面源代码”,确认核心内容、元信息、语义化标签是否存在(非空骨架);
  2. 百度搜索资源平台:提交网站、sitemap.xml,使用“URL提交”功能,验证页面是否能被收录;
  3. Google Search Console:验证页面收录情况,查看爬虫抓取错误,及时调整优化方案;
  4. SEO检测工具:使用爱站、站长工具等,检测页面元信息、关键词密度、加载速度等,优化不足的地方。

七、总结(实操优先级)

Vue前端SEO优化的实操优先级:渲染方式优化(预渲染/SSR/SSG)→ 元信息配置 → 内容语义化 → 性能优化 → 路由/robots配置

新手建议:先从预渲染+元信息配置入手(低成本、易落地),解决核心的爬虫抓取问题;若项目有动态内容,再升级为SSR/SSG;最后优化内容和性能,提升页面权重和排名。

核心原则:SEO优化是长期过程,需持续更新内容、监控抓取情况、调整优化方案,才能逐步提升网站曝光和排名。

❌