普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月12日首页

从原生 JS 到 Vue3 Composition API:手把手教你用现代 Vue 写一个优雅的 Todos 任务清单

作者 不会js
2025年12月12日 11:35

从原生 JS 到 Vue3 Composition API:手把手教你用现代 Vue 写一个优雅的 Todos 任务清单

大家好,今天用一个最经典的 Todos 应用,来带大家彻底搞清楚:

「为什么我们不再手动操作 DOM?Vue 到底替我们做了什么?」

很多初学者看完 Vue 文档后,会觉得「好像很简单啊」,但真正自己写的时候,又会不自觉地回到原来的命令式写法:

document.getElementById('app').innerHTML = xxx

这篇文章将通过一个逐步演进的过程,让你从「机械式 DOM 操作」进化到「数据驱动」的现代 Vue3 开发思维,彻底领悟响应式编程的魅力。

一、原生 JS 写 Todos:痛并痛苦着

先来看看传统写法(很多人还在这么写):

<h2 id="app"></h2>
<input type="text" id="todo-input">

<script>
  const app = document.getElementById('app');
  const todoInput = document.getElementById('todo-input');
  
  todoInput.addEventListener('change', function(event) {
    const todo = event.target.value.trim();
    if (!todo) return;
    app.innerHTML = todo; // 只能显示最后一个!
  })
</script>

这代码能跑,但问题一大堆:

  • 只能显示一条任务(innerHTML 被覆盖)
  • 要实现多条任务、删除、完成状态……需要写几百行 DOM 操作
  • 一旦需求变动,改起来就是灾难

这就是典型的命令式编程:我们的大脑一直在想「我要先找到哪个元素,然后怎么改它」。

而 Vue 的核心思想是:别管 DOM,你只管数据就行。

二、Vue3 + Composition API 完整实现

03998dfb2be956b19c909a672ec27e78.jpg

<!-- App.vue -->
<script setup>
import { ref, computed } from 'vue'

// 1. 响应式数据(重点!)
const title = ref('') // 输入框内容
const todos = ref([
  { id: 1, title: '吃饭', done: false },
  { id: 2, title: '睡觉', done: true }
])

// 2. 计算属性:统计未完成任务数量(带缓存!)
const active = computed(() => {
  return todos.value.filter(todo => !todo.done).length
})

// 3. 添加任务
const addTodo = () => {
  if (!title.value.trim()) return
  
  todos.value.push({
    id: Date.now(), // 推荐用时间戳,比 Math.random() 更可靠
    title: title.value.trim(),
    done: false
  })
  title.value = '' // 清空输入框
}

// 4. 高级技巧:全选/全不选(computed 的 getter + setter)
const allDone = computed({
  get() {
    if (todos.value.length === 0) return false
    return todos.value.every(todo => todo.done)
  },
  set(value) {
    todos.value.forEach(todo => {
      todo.done = value
    })
  }
})
</script>

<template>
  <div class="todos">
    <h2>我的任务清单</h2>
    
    <input 
      type="text" 
      v-model="title" 
      @keydown.enter="addTodo"
      placeholder="今天要做什么?按回车添加"
      class="input"
    />

    <!-- 任务列表 -->
    <ul v-if="todos.length" class="todo-list">
      <li v-for="todo in todos" :key="todo.id" class="todo-item">
        <input type="checkbox" v-model="todo.done">
        <span :class="{ done: todo.done }">{{ todo.title }}</span>
      </li>
    </ul>
    
    <div v-else class="empty">
      🎉 暂无任务,休息一下吧~
    </div>

    <!-- 统计 + 全选 -->
    <div class="footer">
      <label>
        <input type="checkbox" v-model="allDone">
        全选
      </label>
      <span>未完成:{{ active }} / 总数:{{ todos.length }}</span>
    </div>
  </div>
</template>

<style scoped>
.done{
  color: gray;
  text-decoration: line-through;
}
</style>

三、核心知识点深度拆解(建议反复看)

1. ref() 是如何做到响应式的?

const title = ref('')

这句话背后发生了什么?

  • Vue 在内部为 title 创建了一个响应式对象
  • 真正的数据存在 title.value 中
  • 当你读取 title.value 时,Vue 会记录「当前组件依赖了这个数据」
  • 当你修改 title.value 时,Vue 知道「哪些组件需要重新渲染」,自动更新 DOM

这就叫「依赖收集 + 自动更新」,你完全不用管 DOM!

2. 为什么 computed 比普通函数香?

// 普通函数写法(每次都会计算!)
const activeCount = () => todos.value.filter(...).length

// computed 写法(只有依赖变化才重新计算)
const active = computed(() => todos.value.filter(...).length)

性能差异巨大!当你有 1000 条任务时,普通函数会在每次渲染都执行 1000 次过滤,而 computed 可能只执行一次。

3. computed 的 getter + setter 神技(90%的人不知道)

const allDone = computed({
  get() {
    // 如果todos为空,返回false
    if (todos.value.length === 0) return false;
    // 如果所有todo都完成,返回true
    return todos.value.every(todo => todo.done);
  },
  set(value) {
    // 设置所有todo的done状态
    todos.value.forEach(todo => {
      todo.done = value;
    });
  }
})

这才是真正的「双向计算属性」!点击全选框时,v-model 会自动调用 setter,把所有任务的 done 状态同步修改。

4. v-for 一定要写 :key!不然会出大问题

<li v-for="todo in todos" :key="todo.id">

不写 key 的后果:

  • Vue 无法准确判断哪条数据变了,会导致整张列表重绘
  • 输入框焦点丢失、动画错乱、状态错位

推荐 key 使用:

id: Date.now() + Math.random() // 更稳妥
// 或使用 uuid 库

5. v-model 本质是 :value + @input 的语法糖

Vue 的双向绑定(v-model) = 数据 → 视图 的绑定 + 视图 → 数据的绑定

它让「数据」和「表单元素的值」始终保持同步,你改数据,界面自动更新;你改输入框,数据也自动更新。

<input v-model="title">
<!-- 等价于 -->
<input :value="title" @input="title = $event.target.value">

拆解一下:

方向 对应指令 作用
数据 → 视图 :value="msg" 把 msg 的值渲染到 input 上
视图 → 数据 @input="msg = $event.target.value" 用户输入时,把值重新赋值给 msg

而 @keydown.enter 是 Vue 提供的键位修饰符,超级好用:

@keydown.enter="addTodo"
@keydown.ctrl.enter="addTodo"
@click.prevent="submit" <!-- 阻止默认行为 -->

四、常见坑位避雷指南(血泪经验)

场景 错误写法 正确写法 说明
添加任务后输入框不清空 没重置 title.value title.value = '' v-model 是双向绑定,必须手动清空
全选状态不同步 用普通变量控制 用 computed({get,set}) 普通变量无法响应所有任务的变化
key 使用 index :key="index" :key="todo.id" index 会导致状态错乱
id 使用 Math.random() id: Math.random() id: Date.now() 可能重复,尤其快速添加时
computed 忘记 .value return todos.filter(...) return todos.value.filter(...) script setup 中 ref 要加 .value

五、细节知识点

fc962ce0cd306c49bc54248e80437e81.jpg

computed 是如何做到「又快又省」的?

一句话结论:
computed 只有在它的「依赖」真正发生变化时,才会重新计算一次,其他所有时间直接返回缓存结果。

这才是它比普通方法快 10~100 倍的根本原因!

一、最直观的对比实验
<script setup>
import { ref, computed } from 'vue'

const a = ref(1)
const b = ref(10)

// 场景1:普通方法(每次渲染都重新算)
const sum1 = () => {
  console.log('普通方法被调用了') 
  return a.value + b.value
}

// 场景2:computed(只有依赖变了才算)
const sum2 = computed(() => {
  console.log('computed 被调用了')
  return a.value + b.value
})
</script>

<template>
  <p>普通方法:{{ sum1() }}</p>
  <p>computed:{{ sum2 }}</p>
  <button @click="a++">a + 1</button>
  <button @click="b++">b + 1</button>
</template>

你会看到:

操作 普通方法打印几次 computed 打印几次
页面首次渲染 1 次 1 次
点击 a++ 再次打印 再次打印
点击 b++ 再次打印 再次打印
页面任意地方触发渲染(比如父组件更新) 又打印! 不打印!(直接用缓存)

这就是「缓存」带来的性能飞跃!

Vue 内部到底是怎么实现这个缓存的?(底层逻辑)

Vue 用了一个经典的「脏检查 + 依赖收集」机制(Vue3 用 Proxy 更优雅,但原理一致):

步骤 发生了什么
1. 创建 computed Vue 创建一个「计算属性对象」,里面有个 value(缓存值)和 dirty(是否脏)标志」
2. 第一次读取 computed 执行计算函数 → 同时收集所有用到的响应式数据(a、b、todos.length 等)作为依赖
3. 把依赖和这个 computed 关联起来 a.effect.deps.push(computed)
4. 依赖变化时 Vue 把这个 computed 的 dirty 标志设为 true(表示缓存失效了)
5. 下一次读取时 发现 dirty = true → 重新执行计算函数 → 更新缓存 → dirty = false
6. 之后再读取 dirty = false → 直接返回缓存值,不执行函数

图解:

首次读取 computed
     ↓
执行计算函数 → 依赖收集(记录依赖了 a 和 b)
     ↓
把结果缓存起来,dirty = false

a.value = 999(依赖变化)
     ↓
Vue 自动把所有依赖了 a 的 computed 的 dirty 设为 true

下次读取 computed
     ↓
发现 dirty = true → 重新计算 → 更新缓存 → dirty = false
哪些情况会打破缓存?(常见坑)
情况 是否重新计算 说明
依赖的 ref/reactive 变了 正常触发
依赖的普通变量(let num = 1) 不是响应式的!永远只算一次(大坑!)
依赖了 props props 也是响应式的
依赖了 store.state(Pinia/Vuex) store 是响应式的
依赖了 route.params $route 是响应式的(Vue Router 注入)
依赖了 window.innerWidth 不是响应式!要配合 watchEffectScope 手动处理
实战避雷清单
错误写法 正确写法 后果
computed(() => Date.now()) 改成普通方法或用 ref(new Date()) + watch 每一次读取都重新计算,缓存失效
computed(() => Math.random()) 同上 永远不缓存,性能灾难
computed(() => props.list.length) 完全正确 推荐写法
computed(() => JSON.parse(JSON.stringify(todos.value))) 不要这么做,深拷贝太重 浪费性能
六、一句话记住

computed 的高性能秘诀只有 8 个字:
「依赖不变,绝不重新计算」

现在你再也不用担心「用 computed 会不会影响性能」了,反而应该大胆用!
因为它比你手写任何缓存逻辑都要聪明、都要快!

六、总结:从「操作 DOM」到「操作数据」的思维跃迁

传统 JS 思维 Vue 响应式思维
先找元素 → 再改 innerHTML 只改数据 → Vue 自动更新 DOM
手动 addEventListener 用 v-model / @event 声明式绑定
手动计算未完成数量 用 computed 自动计算 + 缓存
全选要遍历 DOM 用 computed setter 一行搞定

当你真正理解了「数据驱动视图」后,你会发现:

写 Vue 代码不再是「怎么操作页面」,而是「数据怎么变化。

这才是现代前端开发的正确姿势!

vite+ts+monorepo从0搭建vue3组件库(五):vite打包组件库

作者 小胖霞
2025年12月12日 10:40

打包配置

vite 专门提供了库模式的打包方式,配置其实非常简单,首先全局安装 vite 以及@vitejs/plugin-vue

   pnpm add vite @vitejs/plugin-vue -D -w

在components下新建vite.config.ts。我们需要让打包后的结构和我们开发的结构一致,如下配置我们将打包后的文件放入dlx-ui 目录下,因为后续发布组件库的名字就是 dlx-ui,当然这个命名大家可以随意.具体代码在下方

然后在 components/package.json 添加打包命令scripts

 "scripts": {
    "build": "vite build"
  },

声明文件

到这里其实打包的组件库只能给 js 项目使用,在 ts 项目下运行会出现一些错误,而且使用的时候还会失去代码提示功能,这样的话我们就失去了用 ts 开发组件库的意义了。所以我们需要在打包的库里加入声明文件(.d.ts)。

全局安装vite-plugin-dts

pnpm add vite-plugin-dts -D -w

在vite.config.ts中引入,完整的配置文件如下:

// components/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
export default defineConfig({
  plugins: [
    vue(),
    dts({
      entryRoot: './src',
      outDir: ['../dlx-ui/es/src', '../dlx-ui/lib/src'],
      //指定使用的tsconfig.json为我们整个项目根目录下,如果不配置,你也可以在components下新建tsconfig.json
      tsconfigPath: '../../tsconfig.json',
    }),
  ],
  build: {
    //打包文件目录
    outDir: 'es',
    emptyOutDir: true,
    //压缩
    //minify: false,
    rollupOptions: {
      //忽略打包vue文件
      external: ['vue'],
      input: ['index.ts'],
      output: [
        {
          //打包格式
          format: 'es',
          //打包后文件名
          entryFileNames: '[name].mjs',
          //让打包目录和我们目录对应
          preserveModules: true,
          exports: 'named',
          //配置打包根目录
          dir: '../dlx-ui/es',
        },
        {
          //打包格式
          format: 'cjs',
          //打包后文件名
          entryFileNames: '[name].js',
          //让打包目录和我们目录对应
          preserveModules: true,
          exports: 'named',
          //配置打包根目录
          dir: '../dlx-ui/lib',
        },
      ],
    },
    lib: {
      entry: './index.ts',
    },
  },
})

执行pnpm run build打包,出现了我们需要的声明的文件

image.png

可以看到打包时打包了2种模式,一种是es模式,一种是cjs模式,当用户引入组件库时使用哪种呢?我们可以修改/components/package.json的代码:

  • main: 指向 lib/index.js,这是 CommonJS 模块的入口文件。Node.js 环境和不支持 ES 模块的工具会使用这个文件。
  • module: 指向 es/index.mjs,这是 ES 模块的入口文件。现代前端工具(如 Vite)会优先使用这个文件。
  "main": "lib/index.js", // CommonJS 入口文件
  "module": "es/index.mjs", // ES 模块入口文件

但是此时的所有样式文件还是会统一打包到 style.css 中,还是不能进行样式的按需加载,所以接下来我们将让 vite 不打包样式文件,样式文件后续单独进行打包。后面我们要做的则是让样式文件也支持按需引入,敬请期待。

vite+ts+monorepo从0搭建vue3组件库(四):button组件开发

作者 小胖霞
2025年12月12日 10:39

组件属性

button组件接收以下属性

  • type 类型
  • size 尺寸
  • plain 朴素按钮
  • round 圆角按钮
  • circle 圆形按钮
  • loading 加载
  • disabled禁用
  • text 文字

button组件全部代码如下:

// button.vue
<template>
  <button
    class="dlx-button"
    :class="[
      buttonSize ? `dlx-button--${buttonSize}` : '',
      buttonType ? `dlx-button--${buttonType}` : '',
      {
        'is-plain': plain,
        'is-round': round,
        'is-circle': circle,
        'is-disabled': disabled,
        'is-loading': loading,
        'is-text': text,
        'is-link': link,
      },
    ]"
    :disabled="disabled || loading"
    @click="handleClick"
  >
    <span v-if="loading" class="dlx-button__loading">
      <span class="dlx-button__loading-spinner"></span>
    </span>
    <span class="dlx-button__content">
      <slot></slot>
    </span>
  </button>
</template>

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

defineOptions({
  name: 'DlxButton',
})

const props = defineProps({
  // 按钮类型
  type: {
    type: String,
    values: ['primary', 'success', 'warning', 'danger', 'info'],
    default: '',
  },
  // 按钮尺寸
  size: {
    type: String,
    values: ['large', 'small'],
    default: '',
  },
  // 是否为朴素按钮
  plain: {
    type: Boolean,
    default: false,
  },
  // 是否为圆角按钮
  round: {
    type: Boolean,
    default: false,
  },
  // 是否为圆形按钮
  circle: {
    type: Boolean,
    default: false,
  },
  // 是否为加载中状态
  loading: {
    type: Boolean,
    default: false,
  },
  // 是否禁用
  disabled: {
    type: Boolean,
    default: false,
  },
  // 是否为文字按钮
  text: {
    type: Boolean,
    default: false,
  },
  // 是否为链接按钮
  link: {
    type: Boolean,
    default: false,
  },
})

const buttonSize = computed(() => props.size)
const buttonType = computed(() => props.type)

const handleClick = (evt: MouseEvent) => {
  if (props.disabled || props.loading) return
  emit('click', evt)
}

const emit = defineEmits(['click'])
</script>

<style lang="less" scoped>
.dlx-button {
  display: inline-flex;
  justify-content: center;
  align-items: center;
  line-height: 1;
  height: 32px;
  white-space: nowrap;
  cursor: pointer;
  color: #606266;
  text-align: center;
  box-sizing: border-box;
  outline: none;
  transition: 0.1s;
  font-weight: 500;
  padding: 8px 15px;
  font-size: 14px;
  border-radius: 4px;
  background-color: #fff;
  border: 1px solid #dcdfe6;

  &:hover,
  &:focus {
    color: #409eff;
    border-color: #c6e2ff;
    background-color: #ecf5ff;
  }

  &:active {
    color: #3a8ee6;
    border-color: #3a8ee6;
    outline: none;
  }

  // 主要按钮
  &--primary {
    color: #fff;
    background-color: #409eff;
    border-color: #409eff;

    &:hover,
    &:focus {
      background: #66b1ff;
      border-color: #66b1ff;
      color: #fff;
    }

    &:active {
      background: #3a8ee6;
      border-color: #3a8ee6;
      color: #fff;
    }
  }

  // 成功按钮
  &--success {
    color: #fff;
    background-color: #67c23a;
    border-color: #67c23a;

    &:hover,
    &:focus {
      background: #85ce61;
      border-color: #85ce61;
      color: #fff;
    }

    &:active {
      background: #5daf34;
      border-color: #5daf34;
      color: #fff;
    }
  }

  // 警告按钮
  &--warning {
    color: #fff;
    background-color: #e6a23c;
    border-color: #e6a23c;

    &:hover,
    &:focus {
      background: #ebb563;
      border-color: #ebb563;
      color: #fff;
    }

    &:active {
      background: #cf9236;
      border-color: #cf9236;
      color: #fff;
    }
  }

  // 危险按钮
  &--danger {
    color: #fff;
    background-color: #f56c6c;
    border-color: #f56c6c;

    &:hover,
    &:focus {
      background: #f78989;
      border-color: #f78989;
      color: #fff;
    }

    &:active {
      background: #dd6161;
      border-color: #dd6161;
      color: #fff;
    }
  }

  // 信息按钮
  &--info {
    color: #fff;
    background-color: #909399;
    border-color: #909399;

    &:hover,
    &:focus {
      background: #a6a9ad;
      border-color: #a6a9ad;
      color: #fff;
    }

    &:active {
      background: #82848a;
      border-color: #82848a;
      color: #fff;
    }
  }

  // 大尺寸
  &--large {
    height: 40px;
    padding: 12px 19px;
    font-size: 14px;
    border-radius: 4px;
  }

  // 小尺寸
  &--small {
    height: 24px;
    padding: 5px 11px;
    font-size: 12px;
    border-radius: 3px;
  }

  // 朴素按钮
  &.is-plain {
    background: #fff;

    // 不同类型按钮的默认状态
    &.dlx-button--primary {
      color: #409eff;
      border-color: #409eff;
    }

    &.dlx-button--success {
      color: #67c23a;
      border-color: #67c23a;
    }

    &.dlx-button--warning {
      color: #e6a23c;
      border-color: #e6a23c;
    }

    &.dlx-button--danger {
      color: #f56c6c;
      border-color: #f56c6c;
    }

    &.dlx-button--info {
      color: #909399;
      border-color: #909399;
    }

    &:hover,
    &:focus {
      background: #ecf5ff;
      border-color: #409eff;
      color: #409eff;
    }

    &:active {
      background: #ecf5ff;
      border-color: #3a8ee6;
      color: #3a8ee6;
    }

    // 为不同类型的朴素按钮添加对应的悬浮状态
    &.dlx-button--primary {
      &:hover,
      &:focus {
        background: #ecf5ff;
        border-color: #409eff;
        color: #409eff;
      }
      &:active {
        border-color: #3a8ee6;
        color: #3a8ee6;
      }
    }

    &.dlx-button--success {
      &:hover,
      &:focus {
        background: #f0f9eb;
        border-color: #67c23a;
        color: #67c23a;
      }
      &:active {
        border-color: #5daf34;
        color: #5daf34;
      }
    }

    &.dlx-button--warning {
      &:hover,
      &:focus {
        background: #fdf6ec;
        border-color: #e6a23c;
        color: #e6a23c;
      }
      &:active {
        border-color: #cf9236;
        color: #cf9236;
      }
    }

    &.dlx-button--danger {
      &:hover,
      &:focus {
        background: #fef0f0;
        border-color: #f56c6c;
        color: #f56c6c;
      }
      &:active {
        border-color: #dd6161;
        color: #dd6161;
      }
    }

    &.dlx-button--info {
      &:hover,
      &:focus {
        background: #f4f4f5;
        border-color: #909399;
        color: #909399;
      }
      &:active {
        border-color: #82848a;
        color: #82848a;
      }
    }
  }

  // 圆角按钮
  &.is-round {
    border-radius: 20px;
  }

  // 圆形按钮
  &.is-circle {
    border-radius: 50%;
    padding: 8px;
  }

  // 文字按钮
  &.is-text {
    border-color: transparent;
    background: transparent;
    padding-left: 0;
    padding-right: 0;

    &:not(.is-disabled) {
      // 默认文字按钮
      color: #409eff;

      &:hover,
      &:focus {
        color: #66b1ff;
        background-color: transparent;
        border-color: transparent;
      }

      &:active {
        color: #3a8ee6;
      }

      // 不同类型的文字按钮颜色
      &.dlx-button--primary {
        color: #409eff;
        &:hover,
        &:focus {
          color: #66b1ff;
        }
        &:active {
          color: #3a8ee6;
        }
      }

      &.dlx-button--success {
        color: #67c23a;
        &:hover,
        &:focus {
          color: #85ce61;
        }
        &:active {
          color: #5daf34;
        }
      }

      &.dlx-button--warning {
        color: #e6a23c;
        &:hover,
        &:focus {
          color: #ebb563;
        }
        &:active {
          color: #cf9236;
        }
      }

      &.dlx-button--danger {
        color: #f56c6c;
        &:hover,
        &:focus {
          color: #f78989;
        }
        &:active {
          color: #dd6161;
        }
      }

      &.dlx-button--info {
        color: #909399;
        &:hover,
        &:focus {
          color: #a6a9ad;
        }
        &:active {
          color: #82848a;
        }
      }
    }

    // 文字按钮的禁用状态
    &.is-disabled {
      color: #c0c4cc;
    }
  }

  // 链接按钮
  &.is-link {
    border-color: transparent;
    color: #409eff;
    background: transparent;
    padding-left: 0;
    padding-right: 0;

    &:hover,
    &:focus {
      color: #66b1ff;
    }

    &:active {
      color: #3a8ee6;
    }
  }

  // 禁用状态
  &.is-disabled {
    &,
    &:hover,
    &:focus,
    &:active {
      cursor: not-allowed;

      // 普通按钮的禁用样式
      &:not(.is-text):not(.is-link) {
        background-color: #fff;
        border-color: #dcdfe6;
        color: #c0c4cc;

        // 有颜色的按钮的禁用样式
        &.dlx-button--primary {
          background-color: #a0cfff;
          border-color: #a0cfff;
          color: #fff;
        }

        &.dlx-button--success {
          background-color: #b3e19d;
          border-color: #b3e19d;
          color: #fff;
        }

        &.dlx-button--warning {
          background-color: #f3d19e;
          border-color: #f3d19e;
          color: #fff;
        }

        &.dlx-button--danger {
          background-color: #fab6b6;
          border-color: #fab6b6;
          color: #fff;
        }

        &.dlx-button--info {
          background-color: #c8c9cc;
          border-color: #c8c9cc;
          color: #fff;
        }
      }
    }
  }

  // 有颜色的按钮禁用状态 - 直接选择器
  &.is-disabled.dlx-button--primary {
    background-color: #a0cfff;
    border-color: #a0cfff;
    color: #fff;
  }

  &.is-disabled.dlx-button--success {
    background-color: #b3e19d;
    border-color: #b3e19d;
    color: #fff;
  }

  &.is-disabled.dlx-button--warning {
    background-color: #f3d19e;
    border-color: #f3d19e;
    color: #fff;
  }

  &.is-disabled.dlx-button--danger {
    background-color: #fab6b6;
    border-color: #fab6b6;
    color: #fff;
  }

  &.is-disabled.dlx-button--info {
    background-color: #c8c9cc;
    border-color: #c8c9cc;
    color: #fff;
  }

  // 文字按钮禁用状态
  &.is-disabled.is-text {
    background-color: transparent;
    border-color: transparent;
    color: #c0c4cc;
  }

  // 链接按钮禁用状态
  &.is-disabled.is-link {
    background-color: transparent;
    border-color: transparent;
    color: #c0c4cc;
  }

  // 加载状态
  &.is-loading {
    position: relative;
    pointer-events: none;

    &:before {
      pointer-events: none;
      content: '';
      position: absolute;
      left: -1px;
      top: -1px;
      right: -1px;
      bottom: -1px;
      border-radius: inherit;
      background-color: rgba(255, 255, 255, 0.35);
    }
  }

  .dlx-button__loading {
    display: inline-flex;
    align-items: center;
    margin-right: 4px;
  }

  .dlx-button__loading-spinner {
    display: inline-block;
    width: 14px;
    height: 14px;
    border: 2px solid #fff;
    border-radius: 50%;
    border-top-color: transparent;
    animation: button-loading 1s infinite linear;
  }
}

@keyframes button-loading {
  0% {
    transform: rotate(0);
  }
  100% {
    transform: rotate(360deg);
  }
}
</style>

引用

在play的src下新建example,存放各个组件的代码,先在play下安装vue-router

pnpm i vue-router

目录结构如下

image.png

app.vue如下:

<template>
  <div class="app-container">
    <div class="sidebar">
      <h2 class="sidebar-title">组件列表</h2>
      <ul class="menu-list">
        <li
          v-for="item in menuItems"
          :key="item.path"
          :class="{ active: currentPath === item.path }"
          @click="handleMenuClick(item.path)"
        >
          {{ item.name }}
        </li>
      </ul>
    </div>
    <div class="content">
      <router-view></router-view>
    </div>
  </div>
</template>

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

const router = useRouter()
const currentPath = ref('/button')

const menuItems = [
  { name: 'Button 按钮', path: '/button' },
  // 后续添加其他组件...
]

const handleMenuClick = (path: string) => {
  currentPath.value = path
  router.push(path)
}
</script>

<style scoped>
.app-container {
  display: flex;
  min-height: 100vh;
}

.sidebar {
  width: 240px;
  background-color: #f5f7fa;
  border-right: 1px solid #e4e7ed;
  padding: 20px 0;
}

.sidebar-title {
  padding: 0 20px;
  margin: 0 0 20px;
  font-size: 18px;
  color: #303133;
}

.menu-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.menu-list li {
  padding: 12px 20px;
  cursor: pointer;
  color: #303133;
  font-size: 14px;
  transition: all 0.3s;
}

.menu-list li:hover {
  color: #409eff;
  background-color: #ecf5ff;
}

.menu-list li.active {
  color: #409eff;
  background-color: #ecf5ff;
}

.content {
  flex: 1;
  padding: 20px;
}
</style>

router/index.ts如下:

import { createRouter, createWebHistory } from 'vue-router'
import ButtonExample from '../example/button.vue'

const routes = [
  {
    path: '/',
    redirect: '/button',
  },
  {
    path: '/button',
    component: ButtonExample,
  },
]

const router = createRouter({
  history: createWebHistory(),
  routes,
})

export default router

play下执行pnpm run dev

运行效果:

image.png

vite+ts+monorepo从0搭建vue3组件库(三):开发一个组件

作者 小胖霞
2025年12月12日 10:39

1.在packages下新建components和utils文件夹,分别执行pnpm init,并将他们的包名改为@dlx-ui/components@dlx-ui/utils,目录结构如下:

组件目录

image.png

组件编写

button.vue

<!-- button组件 -->

<template>
  <button class="button" :class="typeClass" @click="handleClick">
    测试按钮
    <slot></slot>
  </button>
</template>

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

const props = defineProps({
  type: {
    type: String,
    default: 'default',
  },
})

const typeClass = ref('')

const handleClick = () => {
  console.log('click')
}
</script>

<style lang="less" scoped>
.button {
  display: inline-block;
  line-height: 1;
  white-space: nowrap;
  cursor: pointer;
  background: #fff;
  border: 1px solid #dcdfe6;
}
</style>

然后在button/index.ts将其导出

import Button from './button'

export { Button }

export default Button

因为我们后面会有很多组件的,比如 Icon,Upload,Select 等,所以我们需要在components/src/index.ts集中导出所有组件

// components/src/index.ts
export * from './button'

最后在components下的index.ts中,导出所有组件,供其他页面使用

export * from './src/index'

局部引用组件

在play项目中,安装@dlx-ui/components,并且在app.vue中使用

在play目录下执行pnpm add @dlx-ui/components

然后在app.vue中引入button

<template>
  <Button>按钮</Button>
</template>

<script setup lang="ts">
import { Button } from '@dlx-ui/components'
</script>

<style scoped>

</style>

image.png

全局挂载组件

有的时候我们使用组件的时候想要直直接使用 app.use()挂载整个组件库,其实使用 app.use()的时候它会调用传入参数的 install 方法,因此首先我们给每个组件添加一个 install 方法,然后再导出整个组件库,我们将 button/index.ts 改为

import _Button from './button.vue'

import type { App, Plugin } from "vue";
type SFCWithInstall<T> = T & Plugin;
const withInstall = <T>(comp: T) => {
  (comp as SFCWithInstall<T>).install = (app: App) => {
    const name = (comp as any).name;
    //注册组件
    app.component(name, comp as SFCWithInstall<T>);
  };
  return comp as SFCWithInstall<T>;
};
export const Button = withInstall(_Button);
export default Button;


components/index.ts修改为

import * as components from "./src/index";
export * from "./src/index";
import { App } from "vue";

export default {
  install: (app: App) => {
    for (let c in components) {
      app.use(components[c]);
    }
  },
};

组件命名

此时我们需要给button.vue一个name:dlx-button好在全局挂载的时候作为组件名使用 在setup语法糖中使用defineOptions

defineOptions({
  name: 'dlx-button',
})

main.ts全局挂载组件库

import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import dlxui from '@dlx-ui/components'
const app = createApp(App)
app.use(dlxui)

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

在app.vue中引入

<template>
  <dlx-button>全局挂载的按钮</dlx-button>
</template>

<script setup lang="ts"></script>

image.png

vite+ts+monorepo从0搭建vue3组件库(二):项目搭建

作者 小胖霞
2025年12月12日 10:38

安装依赖

在根目录下安装vue和ts 和 less

pnpm的-w 表示在根目录下安装

  pnpm add vue@next typescript less -D -w

初始化ts

跟目录执行 npx tsc --init,生成tsconfig.json,对其做一个更改如下:

{
  "compilerOptions": {
    "baseUrl": ".",
    "jsx": "preserve",
    "strict": true,
    "target": "ES2015",
    "module": "ESNext",
    "skipLibCheck": true,
    "esModuleInterop": true,
    "moduleResolution": "Node",
    "lib": ["esnext", "dom"],
    "types": ["vite/client"]
  }
}

搭建一个基于 vite 的 vue3 项目

创建一个vue3项目,在跟目录下执行以下命令:就创建了play文件夹,一个基于vue+ts+vite的vue3项目

pnpm create vite play --template vue-ts

因为 play 项目需要测试本地的组件库,所以也需要将 play 和我们的组件库关联在一起。修改一下pnpm-workspace.yaml文件

packages:
  - "packages/**"
  - "play"

此时 play 项目便可以安装本地 packages 下的包了

在play下执行pnpm run dev 就能运行play项目了,运行结果:

image.png

我们在根目录运行 play项目里面的dev 脚本

可以使用pnpm -F play dev 指定运行子目录里面的script中的脚本

这个是 pnpm 的能力。

Vue中级冒险:3-4周成为组件沟通大师 🚀

作者 JS_GGbond
2025年12月12日 09:56

欢迎使用我的小程序👇👇👇👇

small.png


欢迎回到你的Vue学习之旅!如果你已经跨过了基础门槛,那么接下来的3-4周将带你进入一个全新的世界——在这里,组件不再孤立,数据流动如交响乐般和谐,代码组织变得优雅而强大。

📅 第一周:与“时间魔法师”——生命周期成为好友

想象一下,每个Vue组件都像一个小生命,有自己的出生、成长和告别时刻。生命周期钩子就是这些关键时刻的提醒铃铛🔔。

// 以前你可能只认识created和mounted
// 现在来认识整个生命周期家族吧!
export default {
  beforeCreate() { console.log('我即将诞生!') },
  created() { console.log('我出生了!可以访问数据啦') },
  beforeMount() { console.log('准备挂载到DOM树上...') },
  mounted() { console.log('成功安家!现在可以操作DOM了') },
  beforeUpdate() { console.log('数据变了,我要准备更新啦') },
  updated() { console.log('更新完成!界面焕然一新') },
  beforeUnmount() { console.log('我即将离开这个世界...') },
  unmounted() { console.log('再见!清理工作完成') }
}

本周小挑战:写一个组件,在它生命的每个阶段都在控制台留下足迹👣,观察数据变化和DOM更新时的触发顺序。

📅 第二周:掌握组件间的“悄悄话”艺术

组件不会读心术,但它们有6种方式可以交流!让我们把它们想象成住在不同房间的室友:

1. Props:妈妈喊你吃饭式(父→子)

// 爸爸组件大喊:
<ChildComponent :dinner="'红烧肉'" />

// 孩子组件乖乖接收:
props: ['dinner']

2. $emit:孩子有事报告式(子→父)

// 孩子在房间里喊:
this.$emit('hungry', '想吃零食')

// 爸爸在外面监听:
<ChildComponent @hungry="handleHungry" />

3. Refs:直接敲门式

// 获取组件实例,直接调用方法
<ChildComponent ref="child" />
this.$refs.child.doSomething()

4. Event Bus:小区广播式(任意组件间)

// 创建一个中央事件总线
// 组件A:广播消息
eventBus.emit('news', '今天小区停水')

// 组件B:收听广播
eventBus.on('news', (msg) => {
  console.log(msg) // 今天小区停水
})

5. Provide/Inject:家族秘密传承式(跨层级)

// 爷爷辈组件:
provide() {
  return { familySecret: '传家宝的位置' }
}

// 孙子辈组件(跳过爸爸直接获取):
inject: ['familySecret']

6. Vuex/Pinia:社区公告栏式(全局状态)

// 任何组件都可以:
store.commit('setMessage', '社区通知:明天停电')

// 任何组件也都能看到:
store.state.message

本周实践:创建一个“家庭聊天室”应用,使用至少4种通信方式让祖孙三代的组件互相传递消息!

📅 第三、四周:解锁组合式API的“积木魔法”

还记得小时候搭积木的乐趣吗?组合式API让你重新体验这种快乐!

选项式API vs 组合式API

// 以前(选项式) - 像整理抽屉
export default {
  data() { return { count: 0 } },
  methods: { increment() { this.count++ } },
  computed: { doubleCount() { return this.count * 2 } }
}

// 现在(组合式) - 像搭积木
import { ref, computed } from 'vue'

export default {
  setup() {
    // 逻辑1:计数器
    const count = ref(0)
    const increment = () => { count.value++ }
    const doubleCount = computed(() => count.value * 2)
    
    // 逻辑2:用户信息
    const user = ref(null)
    const fetchUser = async () => { /* ... */ }
    
    // 像搭积木一样组合功能
    return { count, increment, doubleCount, user, fetchUser }
  }
}

超能力:自定义组合函数

// 创建一个可复用的“鼠标跟踪器”积木
import { ref, onMounted, onUnmounted } from 'vue'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)
  
  const update = (event) => {
    x.value = event.pageX
    y.value = event.pageY
  }
  
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))
  
  return { x, y }
}

// 在任何组件中轻松使用:
const { x, y } = useMouse()
// 看!鼠标坐标自动跟踪!

响应式进阶:reactive vs ref

// ref - 给单个值加响应式外衣
const count = ref(0) // 访问时:count.value

// reactive - 给对象加响应式外衣
const state = reactive({ 
  name: 'Vue', 
  version: 3 
}) // 访问时:state.name

// 小贴士:简单值用ref,复杂对象用reactive

终极挑战:用组合式API重构你之前的一个项目,把相关逻辑抽成自定义组合函数,体验“代码乐高”的快乐!

🎉 庆祝时刻:你已经成为Vue中级开发者!

经过这3-4周的冒险,你已经掌握了:

  • 生命周期管理:像时间旅行者一样掌控组件的一生
  • 6种组件通信:让组件间的对话流畅自然
  • 组合式API:用乐高式思维构建可维护的代码

现在你的Vue技能树已经枝繁叶茂🌳!这些技能不仅在面试中闪闪发光,更能让你在实际项目中游刃有余。

下一步冒险预告:高级路由管理、性能优化、服务端渲染... 但先给自己放个小假,用新技能做个有趣的小项目吧!


分享你的学习成果或遇到的问题,在评论区一起交流成长!你的3周挑战故事是什么? 💬

#Vue #前端开发 #编程学习 #JavaScript #组合式API

Vue3 组件入门:像搭乐高一样玩转前端!

作者 JS_GGbond
2025年12月12日 09:55

欢迎使用我的小程序👇👇👇👇

small.png


你好呀!如果你刚开始学习 Vue3 组件开发,那你来对地方了!想象一下,组件就像是前端世界的乐高积木——小巧、独立、可重复使用,还能组合成酷炫的东西。让我们花 1-2 周时间,轻松掌握组件开发的三大基石!

🎯 第一周:认识你的“乐高积木”

组件基本结构:Vue 的“基因代码”

每个 Vue 组件都像一个独立的小程序,有自己的模板、逻辑和样式:

<template>
  <!-- 这里是组件的“外貌” -->
  <div class="my-component">
    <h1>{{ title }}</h1>
    <button @click="handleClick">点我!</button>
  </div>
</template>

<script setup>
// 这里是组件的“大脑”
import { ref } from 'vue'

const title = ref('你好,我是组件!')

const handleClick = () => {
  console.log('按钮被点击啦!')
}
</script>

<style scoped>
/* 这里是组件的“穿搭” */
.my-component {
  border: 2px solid #42b983;
  padding: 20px;
  border-radius: 10px;
}
</style>

💡 小贴士<script setup> 是 Vue3 的语法糖,让代码更简洁!scoped 样式确保穿搭只影响自己,不会“撞衫”。

🔄 第二周:让积木“活”起来

Props:组件的“个性定制”

就像给乐高人仔换装一样,Props 让组件可以接收外部数据:

<!-- UserCard.vue -->
<template>
  <div class="user-card">
    <h2>{{ name }}</h2>
    <p>年龄:{{ age }}</p>
    <p v-if="isVip">⭐ VIP会员</p>
  </div>
</template>

<script setup>
// 定义组件可以接收哪些“定制参数”
const props = defineProps({
  name: {
    type: String,
    required: true  // 这个必须传!
  },
  age: {
    type: Number,
    default: 18     // 不传的话默认18岁
  },
  isVip: Boolean    // 简写形式
})
</script>

使用这个组件时:

<template>
  <UserCard name="小明" :age="20" :is-vip="true" />
  <UserCard name="小红" /> <!-- 小红自动18岁,不是VIP -->
</template>

🎭 有趣比喻:Props 就像点奶茶时的选项——甜度、冰度、加料,同一个奶茶组件,能调出千变万化的味道!

Events:组件的“悄悄话机制”

组件不能总是被动接收,有时也需要主动“说话”:

<!-- Counter.vue -->
<template>
  <div>
    <p>计数:{{ count }}</p>
    <button @click="increment">+1</button>
    <button @click="reset">归零</button>
  </div>
</template>

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

const emit = defineEmits(['count-change', 'reset-done'])

const count = ref(0)

const increment = () => {
  count.value++
  // 对外“喊话”:计数变化啦!
  emit('count-change', count.value)
}

const reset = () => {
  count.value = 0
  // 喊另一句话:重置完成啦!
  emit('reset-done')
}
</script>

父组件接收“悄悄话”:

<template>
  <Counter 
    @count-change="onCountChange"
    @reset-done="showAlert('已归零!')"
  />
</template>

<script setup>
const onCountChange = (newCount) => {
  console.log(`计数器变成${newCount}了!`)
}

const showAlert = (msg) => {
  alert(msg)
}
</script>

🔊 生动解释:Events 就像组件之间的“对讲机”。子组件按下按钮,父组件就能听到:“嘿!我这里发生事情了!”

插槽:组件的“留白艺术”

有时候,我们想在组件里留一块空地,让使用它的人自由发挥:

<!-- FancyBox.vue -->
<template>
  <div class="fancy-box">
    <div class="header">
      <slot name="header">默认标题</slot>
    </div>
    
    <div class="content">
      <!-- 匿名插槽:不写name的那个 -->
      <slot>默认内容</slot>
    </div>
    
    <div class="footer">
      <slot name="footer"></slot>
      <!-- 如果没提供footer,这里什么都不显示 -->
    </div>
  </div>
</template>

尽情发挥创意:

<template>
  <FancyBox>
    <!-- #header 是 v-slot:header 的简写 -->
    <template #header>
      <h1>🎉 我的个性化标题!</h1>
    </template>
    
    <!-- 这里是匿名插槽的内容 -->
    <p>这是放在主区域的内容...</p>
    <img src="/my-image.jpg" alt="我的图片">
    
    <template #footer>
      <button>确定</button>
      <button>取消</button>
    </template>
  </FancyBox>
</template>

🎨 精妙比喻:插槽就像相框——相框组件提供结构和样式(边框、材质),但你可以在里面放任何照片!

🚀 两周学习路线图

第一周:打好地基

  • 第1-2天:创建你的第一个组件,理解“单文件组件”概念
  • 第3-4天:玩转 Props,尝试各种类型验证
  • 第5-7天:组件通信初体验,父子组件互相“对话”

第二周:进阶组合

  • 第8-10天:掌握具名插槽和作用域插槽
  • 第11-12天:构建一个小项目(如用户卡片集)
  • 第13-14天:重构重复代码为可复用组件

💪 动手挑战!

试着创建一个 MessageBubble 组件:

  1. 通过 type prop 控制样式(成功、警告、错误)
  2. 点击气泡时发射 close 事件
  3. 使用插槽让内容可以包含任何 HTML
  4. 添加一个 icon 插槽,允许自定义图标

🌟 总结

Vue3 组件开发其实就像玩乐高:

  • 基本结构 = 积木的基础形状
  • Props = 给积木涂上不同颜色
  • Events = 积木之间的连接卡扣
  • 插槽 = 预留的特殊接口

记住,最好的学习方式就是动手去做!从今天起,试着把页面上的每个部分都想象成可复用的组件。两周后,你会惊讶地发现,自己已经能用“乐高思维”构建整个应用了!

有什么问题或有趣的组件创意吗?欢迎在评论区分享!一起在 Vue3 的世界里搭出炫酷的作品吧!✨


📅 学习进度提醒:标记你的日历,两周后回来看看自己构建了多少个酷炫组件!

从“拼字符串”到“魔法响应”:一场数据驱动页面的奇幻进化之旅

作者 AAA阿giao
2025年12月12日 09:47

引言

你有没有想过,为什么今天的网页能像变魔术一样——点一下按钮,列表自动刷新;输个名字,头像立刻出现?而十几年前,想换个内容却要整个页面“唰”地重载?

这一切的背后,是一场关于数据如何驱动页面的技术革命。今天,我们就穿越三段代码时空,跟随一份用户列表的命运,看看 Web 开发是如何从“手搓 HTML”一步步进化到“声明即渲染”的魔法世界的。


第一章:远古时代 —— 服务端“手工编织”页面(server.js

源代码链接:[vue/ref/demo/server.js · Zou/lesson_zp - 码云 - 开源中国](gitee.com/giaoZou/les… "vue/ref/demo/server.js · Zou/lesson_zp - 码云 - 开源中国")

时间回到 Web 初期,那时没有 Vue,没有 React,甚至连 AJAX 都还没普及。开发者们用最原始的方式:在服务器上把数据和 HTML 混在一起,直接“烤”成完整网页,再扔给浏览器

打开 server.js,你会看到这样一段代码:

const users = [
    {id: 1, name: '张三',email:'zhangsan@qq.com'},
    {id: 2, name: '李四',email:'lisi@qq.com'},
    {id: 3, name: '王五',email:'wangwu@qq.com'},
]

这是一份硬编码的用户名单,就像藏在厨房抽屉里的老式通讯录。

接着,一个叫 generateUsersHtml 的函数登场了:

function generateUsersHtml(users){
    const userRows = users.map(user => `
| ${user.id}|${user.name}|${user.email}|
| ---|---|---|
`).join('');
    return `
Users
| ID|Name|Email|
| ---|---|---|
${userRows}
            
    `
}

注意!这里用的不是标准 HTML 表格,而是 Markdown 表格语法(可能是为了简化演示)。但原理惊人地朴素:用 JavaScript 字符串拼接,把数据“缝”进模板里

当用户访问 /users 时:

if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/users') {
    res.statusCode = 200;
    res.setHeader('Content-Type', 'text/html;charset=utf-8'); 
    const html = generateUsersHtml(users);
    res.end(html);
}

服务器把拼好的“成品网页”通过 res.end() 发出去。浏览器收到后,直接显示——没有请求、没有等待、没有交互,一切都在服务端完成

这就像一位老裁缝,根据你的身材(数据)现场量体裁衣(生成 HTML),然后把做好的衣服(完整页面)递给你。但如果你胖了两斤,他得重新做一件——整个页面刷新!

优点:简单、快速、SEO 友好。
缺点:交互差、前后端耦合、改一点就要重刷。


第二章:工业革命 —— 前后端“分家”,API 登场(index.html + db.json + package.json

随着网站越来越复杂,开发者发现:“让前端和后端各干各的,效率更高!”于是,前后端分离成为新潮流。

完整项目结构及链接:[vue/ref/demo2 · Zou/lesson_zp - 码云 - 开源中国](gitee.com/giaoZou/les… "vue/ref/demo2 · Zou/lesson_zp - 码云 - 开源中国")

![](<> "点击并拖拽以移动")

项目准备

前后端分离需要进行项目初始化

第一步:对负责后端的文件夹 backend 进行初始化,在终端打开该文件夹,执行以下命令

# 初始化项目,生成 package.json(只需执行一次)
npm init -y
 
# 安装 json-server,用于基于 JSON 文件快速创建本地 REST API 服务。
npm i json-server

第二步:在前端文件夹 frontend 中添加文件 index.html ,在后端文件夹 backend 中添加 db.json 文件

第三步:修改 package.json 文件内容

将文件内的JavaScript语句修改为如下内容

  &#34;scripts&#34;: {
    &#34;dev&#34;: &#34;json-server --watch db.json&#34;
  },

这段代码定义了一个名为 dev 的 npm 脚本,作用是使用 json-server 工具监听(--watch)项目根目录下的 db.json 文件。当运行 npm run dev 时,会启动一个本地 RESTful API 服务器,自动将 db.json 中的数据暴露为 API 接口,并在文件内容变化时实时更新接口数据,常用于前端开发中的模拟后端服务。

静态骨架:index.html 的“空舞台”

看一眼 index.html

User List
Users
| ID|Name|Email|
| ---|---|---|

这简直是个“幽灵页面”——有标题、有表头,但没有一行真实数据!它就像一个空荡荡的剧院舞台,只搭好了布景,就等演员(数据)登场。

数据仓库:db.json 的“假数据库”

真正的数据藏在这里:

{
    &#34;users&#34;: [
        {
            &#34;id&#34;: 1,
            &#34;name&#34;: &#34;张三&#34;,
            &#34;email&#34;: &#34;zhangsan@qq.com&#34;
        },
        {
            &#34;id&#34;: 2,
            &#34;name&#34;: &#34;李四&#34;,
            &#34;email&#34;: &#34;lisi@qq.com&#34;
        }
        ,
        {
            &#34;id&#34;: 3,
            &#34;name&#34;: &#34;王五&#34;,
            &#34;email&#34;: &#34;wangwu@qq.com&#34;    
        }
    ]
}

这是一个纯 JSON 文件,结构清晰,但本身不会动。它需要一个“翻译官”把它变成 API。

自动化 API 工厂:json-server(来自 package.json

package.json

{
  &#34;scripts&#34;: {
    &#34;dev&#34;: &#34;json-server --watch db.json&#34;
  },
  &#34;dependencies&#34;: {
    &#34;json-server&#34;: &#34;^1.0.0-beta.3&#34;
  }
}

运行 npm run dev,神奇的事情发生了:json-server 自动监听 db.json,并启动一个本地服务器(默认 http://localhost:3000),将 users 数组暴露为 RESTful 接口:

  • GET /users → 返回全部用户
  • GET /users/1 → 返回 ID 为 1 的用户

现在,前端只需一句 fetch('/users'),就能拿到 JSON 数据!

舞台(index.html)不再自己造演员,而是打电话给经纪公司(API):“请派三位演员上台!” 演员来了,前台 JS 再手动把他们安排到座位上(操作 DOM)。

优点:前后端解耦、接口复用、便于测试。
痛点:前端仍需手动更新 DOM,代码冗长易错——“找到表格 → 清空 → 循环创建行 → 插入单元格……”

于是,人们渴望一种更智能的方式……


第三章:魔法纪元 —— Vue 的“响应式咒语”(App.vue

如果说前后端分离是“分工”,那么 Vue 就是“自动化”。它引入了一个颠覆性理念:你只管描述“页面应该长什么样”,数据变了,页面自动跟着变

项目准备

创建Vue3项目,在终端打开创建项目的文件夹,运行以下命令

npm init vite

回车后输入项目名称 ref-demo,选择 Vue + JavaScript 即可

![](<> "点击并拖拽以移动")

完整项目结构及 App.vue 文件链接:[vue/ref/demo3/ref-demo/src/App.vue · Zou/lesson_zp - 码云 - 开源中国](gitee.com/giaoZou/les… "vue/ref/demo3/ref-demo/src/App.vue · Zou/lesson_zp - 码云 - 开源中国")

![](<> "点击并拖拽以移动")

响应式数据:会“思考”的变量 —— Vue 的魔法心脏

App.vue 中,我们看到这样两行代码:

import { ref } from 'vue';
const users = ref([]);

别小看这短短两行——它们开启了一个自动同步数据与视图的魔法世界

ref([]) 不是普通数组,而是一个“活”的数据容器

乍一看,users 似乎只是个空数组。但其实,ref() 把它包装成了一个响应式引用对象(reactive reference) 。你可以把它想象成一个装着数据的“智能玻璃瓶”:

  • 瓶子里装的是你的真实数据(比如用户列表);
  • 瓶子本身会“监听”:只要有人往里放新东西(修改 .value),它就会立刻广播:“注意!内容变了!”
  • 所有“订阅”了这个瓶子的 UI 元素(比如模板中的 {{user.name}}),都会自动更新自己。

换句话说,users 不再是死气沉沉的变量,而是一个会呼吸、会通知、会联动的活体数据

技术小贴士:在 Vue 3 的 Composition API 中,ref 内部使用了 JavaScript 的 Proxygetter/setter 机制,实现对 .value 访问和赋值的拦截,从而建立“依赖追踪”系统。


生命周期钩子:组件的“出生仪式”

接下来这段代码,是组件的“成人礼”:

onMounted(() => {
  console.log('组件挂载完成,开始从后端获取数据');
  fetch('/users')
    .then(res => res.json())
    .then(data => {
      users.value = data;
      console.log('从后端获取到的数据:', users.value);
    })
})
什么是 onMounted

Vue 组件有自己的“生命周期”:创建 → 挂载 → 更新 → 卸载。
onMounted 就是那个关键的“我已上线”时刻——当组件的 DOM 已经被渲染到页面上,Vue 就会执行这个回调函数。

这就像一个新生儿睁开眼睛的第一秒,立刻说:“世界你好!我要开始干活了!”

为什么在这里发请求?

因为:

  • 页面结构已经存在(模板已解析);
  • 此时发起 fetch('/users'),既能拿到数据,又能确保有地方展示它;
  • 如果在组件还没挂载前就操作 DOM 或赋值,可能会失败或造成内存泄漏。

于是,组件一“睁眼”,就向后端(由 json-server 提供的 /users 接口)发出请求,拿到 JSON 数据后:

users.value = data;

Boom!魔法触发!


重点:这一行代码,如何让页面“活”起来?

当你写下 users.value = data,看似只是赋值,实则引爆了一连串精妙的连锁反应:

  1. Vue 的响应式系统检测到 users 的值发生了变化
    → 因为 users 是用 ref 创建的,任何对 .value 的写入都会被拦截。
  2. 系统立刻找出所有“依赖”这个数据的地方
    → 在编译阶段,Vue 已经悄悄记录下:模板中用了 users 的地方(比如 v-for=&#34;user in users&#34;)都需要被通知。
  3. 重新执行相关的渲染逻辑
    → Vue 并不会重绘整个页面,而是只重新计算“受影响的部分”——也就是用户列表区域。
  4. 生成新的虚拟 DOM(Virtual DOM)
    → Vue 先在内存中构建一个轻量级的 DOM 树副本。
  5. 与旧虚拟 DOM 对比(diff 算法)
    → 找出最小差异:比如新增了三行、删除了零行。
  6. 精准更新真实 DOM
    → 只修改浏览器中真正需要变动的节点,避免不必要的重排重绘。

 整个过程毫秒级完成,用户毫无感知,却看到了最新数据!


声明式模板:所想即所得的 UI 编程 

现在,看看 `` 部分的神奇之处:

<table>
  <tr>
    <th>id</th>
    <th>name</th>
    <th>email</th>
  </tr>
<tbody>
  <tr>
    <td>{{user.id}}</td>
    <td>{{user.name}}</td>
    <td>{{user.email}}</td>
  </tr>
</tbody>
</table>

这里藏着 Vue 两大核心法宝:

{{ }}:插值表达式 —— 数据的“透明窗口”
  • {{user.name}} 不是一段字符串,而是一个动态绑定
  • 它告诉 Vue:“请在这里显示 user.name 的当前值,并且当它变时,自动刷新。”
  • 你不需要手动拼接 HTML,也不用担心 XSS(Vue 默认会转义内容,安全可靠)。
 v-for:列表渲染指令 —— 自动化的“克隆工厂”
  • v-for=&#34;user in users&#34; 是 Vue 的循环指令。
  • 它会遍历 users 数组,为每个 user 自动生成一行 <tr>
  • :key=&#34;user.id&#34; 提供唯一标识,帮助 Vue 高效追踪每个节点的身份(比如移动、删除时保持状态)。

在传统开发中,你要写:

// 找到表格
const tbody = document.querySelector('#user-table tbody');
// 清空
tbody.innerHTML = '';
// 循环创建行
data.forEach(user => {
  const tr = document.createElement('tr');
  tr.innerHTML = `<td>${user.id}</td><td>${user.name}</td>...`;
  tbody.appendChild(tr);
});

而在 Vue 中,你只需说:

“我想显示一个用户列表,每一行包含 id、name 和 email。”

然后 Vue 就默默完成了剩下的所有工作。

这就是声明式编程的魅力:你描述“是什么”,框架负责“怎么做”。

从此,开发者从 DOM 操作的泥潭中解放出来,专注于业务逻辑与用户体验——而这,正是现代前端框架最伟大的馈赠。


终章:三次飞跃,一条主线

时代 核心思想 数据流向 开发体验 用户体验
服务端渲染 “我给你完整的饭” 数据 → 服务端 → 完整 HTML → 浏览器 简单但笨重 刷新卡顿,交互弱
前后端分离 “我给你食材,你自己做” 浏览器 → 请求 API → 获取 JSON → 手动更新 DOM 灵活但繁琐 局部更新,但依赖手动编码
响应式框架 “你告诉我菜谱,我自动做饭” 数据变化 → 自动驱动视图更新 声明式、高效、可维护 流畅、实时、无感

结语:技术的本质是“解放人”

server.js 的字符串拼接,到 App.vue 的响应式绑定,表面看是代码风格的变迁,实则是开发范式的跃迁

从“命令式”(怎么做)走向“声明式”(做什么)

今天的前端开发者,不再需要关心“如何插入一行表格”,而是专注“用户列表应该展示哪些字段”。这种抽象,让我们能更专注于业务逻辑与用户体验。

而这,正是技术进步最美的样子——让复杂消失,让创造浮现

🌟 下次当你看到一个动态刷新的列表时,不妨微笑一下:那背后,是一场跨越二十年的工程智慧结晶。

一键部署!一款开源自托管的照片画廊神器!

作者 Java陈序员
2025年12月12日 09:23

大家好,我是 Java陈序员

在这个数字时代,我们的手机和相机里存满了无数珍贵的照片 —— 家人的笑脸、旅行的风景、生活的点滴瞬间。但这些回忆往往被淹没在杂乱的相册里,要么受制于云存储的隐私风险,要么因格式兼容问题难以完整呈现。

这时候,我们可以搭建一个完全属于自己、能按时间和地点梳理回忆的照片画廊。

今天,给大家推荐一款专注于流畅体验的自托管个人画廊神器,支持一键部署!

项目介绍

chronoframe —— 一个丝滑的照片展示和管理应用,支持多种图片格式和大尺寸图片渲染。

功能特色

  • 强大的照片管理:支持通过网页界面轻松管理和浏览照片,并在地图上查看照片拍摄地点
  • 轻量部署体验:基于 Docker 一键部署,无需额外配置数据库(内置 SQLite),几分钟内即可完成私有化部署
  • 多存储后端适配:支持本地文件系统、S3 兼容存储多种存储方式,满足不同场景需求
  • 地图可视化浏览:自动提取照片 GPS 信息,使用 Mapbox 进行地理编码,在地图上展示照片拍摄位置
  • 响应式设计:完美适配桌面端和移动端,支持触摸操作和手势控制,提供原生应用般的体验
  • Live/Motion Photo 支持:完整支持 Apple LivePhoto 格式和 Google 标准的 Motion Photo,自动检测和处理 MOV 视频文件,保留动态照片效果

技术栈:Nuxt4 + TypeScript + TailwindCSS + Drizzle ORM

快速上手

配置信息

创建 .env 文件,下面是使用本地存储的最小示例。

# 管理员邮箱(必须)
CFRAME_ADMIN_EMAIL=
# 管理员用户名(可选,默认 ChronoFrame)
CFRAME_ADMIN_NAME=
# 管理员密码(可选,默认 CF1234@!)
CFRAME_ADMIN_PASSWORD=

# 站点信息(均可选)
NUXT_PUBLIC_APP_TITLE=
NUXT_PUBLIC_APP_SLOGAN=
NUXT_PUBLIC_APP_AUTHOR=
NUXT_PUBLIC_APP_AVATAR_URL=

# 地图提供器 (maplibre/mapbox)
NUXT_PUBLIC_MAP_PROVIDER=maplibre
# 使用 MapLibre 需要 MapTiler 访问令牌
NUXT_PUBLIC_MAP_MAPLIBRE_TOKEN=
# 使用 Mapbox 需要 Mapbox 访问令牌
NUXT_PUBLIC_MAPBOX_ACCESS_TOKEN=

# 存储提供者(local 或 s3 或 openlist)
NUXT_STORAGE_PROVIDER=local
NUXT_PROVIDER_LOCAL_PATH=/app/data/storage

# 会话密码(必须,32 位随机字符串)
NUXT_SESSION_PASSWORD=

# 是否开启允许 IP 直接访问
NUXT_ALLOW_INSECURE_COOKIE=true

如选择使用 S3 存储,请将存储部分的配置替换为:

NUXT_STORAGE_PROVIDER=s3
NUXT_PROVIDER_S3_ENDPOINT=
NUXT_PROVIDER_S3_BUCKET=chronoframe
NUXT_PROVIDER_S3_REGION=auto
NUXT_PROVIDER_S3_ACCESS_KEY_ID=
NUXT_PROVIDER_S3_SECRET_ACCESS_KEY=
NUXT_PROVIDER_S3_PREFIX=photos/
NUXT_PROVIDER_S3_CDN_URL=

若选择使用 OPENLIST,请将存储部分的配置替换为:

NUXT_STORAGE_PROVIDER=openlist
NUXT_PROVIDER_OPENLIST_BASE_URL=https://openlist.example.com
NUXT_PROVIDER_OPENLIST_ROOT_PATH=/115pan/chronoframe
NUXT_PROVIDER_OPENLIST_TOKEN=your-static-token

如果需要集成 Github 登录,需配置 GitHub OAuth 变量:

NUXT_OAUTH_GITHUB_CLIENT_ID=
NUXT_OAUTH_GITHUB_CLIENT_SECRET=

Docker 部署

1、拉取镜像

docker pull ghcr.io/hoshinosuzumi/chronoframe:latest

2、创建挂载目录和配置文件

mkdir -p /data/software/chronoframe/data

cd /data/software/chronoframe

# 配置文件参考前文的配置文件信息
vim .env

3、运行容器

docker run -d \
--name chronoframe \
  -p 3000:3000 \
  -v /data/software/chronoframe/data:/app/data \
  --env-file .env \
  ghcr.io/hoshinosuzumi/chronoframe:latest

Docker Compose 部署

1、创建 docker-compose.yml 文件

services:
  chronoframe:
    image: ghcr.io/hoshinosuzumi/chronoframe:latest
    container_name: chronoframe
    restart: unless-stopped
    ports:
      - '3000:3000'
    volumes:
      - ./data:/app/data
    env_file:
      - .env

2、启动服务

# 启动服务
docker compose up -d

# 查看日志
docker compose logs -f chronoframe

# 停止服务
docker compose down

# 更新到最新版本
docker compose pull
docker compose up -d

反向代理

如需要使用反向代理服务器(如 Nginx 或 Caddy)来处理 HTTPS 和域名解析,可参考如下配置。

server {
    listen 80;
    server_name your-domain.com;
    
    # HTTPS 重定向
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name your-domain.com;
    
    # SSL 证书配置
    ssl_certificate /path/to/your/certificate.crt;
    ssl_certificate_key /path/to/your/private.key;
    
    # SSL 安全配置
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    
    # 上传大小限制
    client_max_body_size 100M;
    
    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        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;
        proxy_cache_bypass $http_upgrade;
        
        # WebSocket 支持
        proxy_set_header Connection "upgrade";
        proxy_set_header Upgrade $http_upgrade;
        
        # 超时设置
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }
    
    # 静态资源缓存
    location ~* \.(jpg|jpeg|png|gif|webp|svg|css|js|ico|woff|woff2|ttf|eot)$ {
        proxy_pass http://localhost:3000;
        expires 1y;
        add_header Cache-Control "public, immutable";
        proxy_set_header Host $host;
    }
}

功能体验

首页

  • 明亮模式

  • 暗黑模式

  • 照片查看

  • 地球仪

  • 相簿

  • 筛选照片

控制台

  • 仪表盘

  • 照片库

  • 上传照片

  • 相簿

  • 队列管理

  • 系统日志

无论是摄影爱好者整理作品,还是个人珍藏生活片段,chronoframe 都能通过简单的部署方式,为你打造一个流畅、安全且充满温度的私人图片画廊。快去部署体验吧~

项目地址:https://github.com/HoshinoSuzumi/chronoframe

最后

推荐的开源项目已经收录到 GitHub 项目,欢迎 Star

https://github.com/chenyl8848/great-open-source-project

或者访问网站,进行在线浏览:

https://chencoding.top:8090/#/

大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!


Vite 到底能干嘛?为什么 Vue3 官方推荐它做工程化工具?

作者 刘大华
2025年12月12日 08:26

很多朋友在开发项目的时候其实都有用过Vite,也知道它是现代化构建工具,但却不清楚它是怎么用的。

只是知道项目里集成了Vite,开发的时候启动很快,配置文件也很清晰,但很少去了解它是什么,起到了什么作用。

这篇文章我们就来了解一下。

一、Vite 是什么?

它的名字来源于法语单词"vite",意思是"快速",由 Vue.js 作者尤雨溪开发的一款现代化前端构建工具

它的目标很简单:让开发体验更快、更简单

Vite不仅支持Vue,还原生支持React、Svelte、Preact、Lit等主流框架,甚至可以用于纯 HTML + JavaScript 项目。


二、Vite 的核心优势

Vite之所以火,是因为它解决了传统构建工具的几个痛点:

启动极快 开发服务器启动几乎秒开,不打包!

热更新飞快 修改代码后,浏览器只更新改动的部分,毫秒级响应

原生 ES 模块支持 直接利用现代浏览器的 ES Module 能力

零配置上手 创建项目只需一条命令,无需复杂配置

插件生态丰富 兼容 Rollup 插件,生态强大

开箱即用的丰富功能 支持 TypeScript、JSX、CSS 预处理器等,无需复杂配置。


三、与传统工具(如 Webpack)对比

1. 工作方式的根本不同

Webpack:开发时会先打包整个项目(把所有 JS、CSS 合并成 bundle),然后启动服务器。项目越大,打包越慢。

Vite:开发时不打包!直接利用浏览器原生支持的 ES 模块(<script type="module">),按需加载文件。

2. 举个实际例子

假设你有一个简单的项目结构:

src/
├── main.js
└── utils.js

使用 Webpack(传统方式)

// utils.js
export const add = (a, b) => a + b;

// main.js
import { add } from './utils.js';
console.log(add(1, 2));

Webpack 会: 1.把main.jsutils.js打包成一个bundle.js 2.启动本地服务器,返回这个bundle 3.浏览器加载整个bundle

即使你只改了utils.js中的一行,Webpack也要重新分析依赖、重新打包整个应用(虽然有缓存优化,但仍有延迟)。

使用 Vite

Vite 在开发时直接生成这样的 HTML:

<!-- index.html -->
<script type="module" src="/src/main.js"></script>

浏览器会: 1.请求 /src/main.js 2.发现里面 import './utils.js',自动再请求 /src/utils.js 3.按需加载,无需打包!

当你修改utils.js,Vite只告诉浏览器:“喂,utils.js更新了”, 浏览器只重新加载这一个文件,热更新速度接近实时

Vite 在开发阶段用原生 ESM,在生产阶段会用 Rollup 打包,兼顾速度和兼容性。


四、Vite 的工作原理:为什么这么快?

关键就两点:

1. 利用现代浏览器的原生 ES 模块(ESM)

现代浏览器(Chrome、Firefox、Edge 等)早就支持 <script type="module">,可以直接 import/export 模块,无需打包。

Vite 直接把这个能力用起来——开发时不打包,让浏览器自己去加载模块

2. 依赖预构建(Dependency Pre-bundling)

你可能会问:那 node_modules 里的包怎么办?它们很多不是 ESM 格式啊!

Vite会在首次启动时,用 esbuild(超快的 JS 打包器)把 node_modules 里的依赖预构建为 ESM 格式,并缓存起来。

esbuild 是用 Go 写的,比 Webpack 快 10~100 倍!

预构建只做一次,后续开发直接用缓存

这样既保证了兼容性,又不影响开发速度。


五、如何使用 Vite?

1. 创建项目

# 使用 npm
npm create vite@latest my-vue-app -- --template vue

# 使用 yarn
yarn create vite my-react-app --template react

# 使用 pnpm
pnpm create vite my-vanilla-app --template vanilla

2. 项目结构

my-vite-project/
├── index.html
├── package.json
├── vite.config.js
├── public/
│   └── favicon.ico
└── src/
    ├── main.js
    ├── App.vue
    ├── components/
    └── styles/

3. 开发服务器

# 进入项目目录
cd my-vue-app

# 安装依赖
npm install

# 启动开发服务器
npm run dev

访问 http://localhost:5173 即可看到你的应用!

在这里插入图片描述

4. 基础配置示例

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

export default defineConfig({
  // 插件配置
  plugins: [vue()],
  
  // 开发服务器配置
  server: {
    port: 5173,
    open: true // 自动打开浏览器
  },
  
  // 构建配置
  build: {
    outDir: 'dist',
    sourcemap: true
  },
  
  // 路径别名
  resolve: {
    alias: {
      '@': '/src'
    }
  }
})

5. 完整的Vue组件示例

<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>我的Vite应用</title>
</head>
<body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
</body>
</html>
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'

createApp(App).mount('#app')
<!-- src/App.vue -->
<template>
  <div class="app">
    <h1>欢迎使用 Vite!</h1>
    <p>当前计数: {{ count }}</p>
    <button @click="increment">点击我</button>
  </div>
</template>

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

<style>
.app {
  text-align: center;
  padding: 2rem;
}

button {
  padding: 10px 20px;
  font-size: 16px;
  cursor: pointer;
}
</style>
/* src/style.css */
body {
  margin: 0;
  font-family: Arial, sans-serif;
  background-color: #f5f5f5;
}

* {
  box-sizing: border-box;
}

6. 构建生产版本

# 构建生产版本
npm run build

# 预览生产版本
npm run preview

结语

总的来说,Vite 通过利用现代浏览器的原生能力,在开发阶段省去了打包步骤,大大提升了开发效率。同时,它配置简单、上手容易,还拥有强大的插件生态,非常适合现代前端项目。

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《async/await 到底要不要加 try-catch?异步错误处理最佳实践》

《如何查看 SpringBoot 当前线程数?3 种方法亲测有效》

《Java 开发必看:什么时候用 for,什么时候用 Stream?》

《这 10 个 MySQL 高级用法,让你的代码又快又好看》

Vue-TodoList 项目详解

作者 _一两风
2025年12月11日 22:55

Vue 3 Todo List 项目详解

1.图片展示

image.png

2. 核心数据结构

项目使用 ref 定义了核心的响应式数据 todos。数组中的每一项是一个对象,包含三个关键属性:

  • id: 唯一标识符(用于 Diff 算法优化)
  • context: 任务文本内容
  • done: 完成状态 (true/false)
const todos = ref([
  { id: 1, context: '打王者', done: false },
  { id: 2, context: '吃饭', done: true },
  // ...
])

3. 功能实现细节

A. 添加任务 (Add Todo)

逻辑

  1. 双向绑定:使用 v-model 绑定输入框与 newTodoText 变量。
  2. 事件监听:监听键盘的 Enter 键 (@keydown.enter)。
  3. 数据变更:校验非空后,向 todos 数组 push 新对象,并重置输入框。

亮点:使用了 nextId 自增变量,确保每个新任务都有独立的 ID,避免渲染时的 Key 冲突。

B. 列表渲染与性能优化

逻辑:

使用 v-for 指令循环渲染列表。

关键点:

必须绑定 :key="todo.id"。Vue 的虚拟 DOM 机制依赖这个 Key 来进行高效的 Diff 对比。如果数据项顺序改变,Vue 可以直接复用 DOM 元素,而不是销毁重建,从而提升性能。

C. 智能状态计算 (Computed)

项目中大量使用了 computed 计算属性,它的优势在于缓存——只有依赖的数据变化时才会重新计算。

  1. 剩余任务统计 (active):

    实时计算 !todo.done 的数量,用于底部显示 "X items left"。

    const active = computed(() => todos.value.filter(todo => !todo.done).length)
    
  2. 全选/反选 (allDone) - 高级用法:

    这是一个可写计算属性 (Writable Computed),它巧妙地实现了双向逻辑:

    • 读 (Get) :如果所有任务都完成了,全选框自动勾选。
    • 写 (Set) :当你点击全选框时,它触发 set 方法,将所有任务的 done 状态同步为当前全选框的状态。

D. 删除与清理

  • 删除单项:通过 filter 过滤掉指定 ID 的任务。

  • 清除已完成:通过 filter 过滤掉所有 done 为 true 的任务。

    这里的操作都是生成新数组替换旧数组,Vue 的响应式系统会自动检测到引用变化并更新视图。


4. Computed 讲解

计算属性 (Computed Properties):形式上是函数,结果是属性。

核心特性:
  1. 依赖追踪:自动感知它所使用的响应式数据(如 refreactive)。
  2. 缓存机制 (Caching) :这是它与普通函数(Methods)最大的区别。如果依赖的数据没变,多次访问 computed 会直接返回上一次计算的结果,不会重复执行函数体。
高级用法:可写的计算属性(Getter & Setter)

在 Vue 3 中,计算属性(computed)通常默认为“只读”的(即只传入一个 getter 函数)。但在需要实现双向绑定的场景下(例如“全选/反选”复选框),我们需要使用它的高级写法:传入一个包含 getset 的对象。

// 引入 computed
import { computed } from 'vue';

// 定义可写的计算属性
const allDone = computed({
  // getter: 读取值(决定全选框是否勾选)
  // 当依赖的 todos 数据变化时,会自动重新计算
  get() {
    // 逻辑:如果列表不为空,且每一项都已完成 (done === true),则返回 true
    return todos.value.length > 0 && todos.value.every(todo => todo.done)
  },

  // setter: 写入值(当用户点击全选框时触发)
  // val 是用户操作后的新值(true 或 false)
  set(val) {
    // 逻辑:遍历所有 todos,将它们的完成状态强制改为当前全选框的状态
    todos.value.forEach(todo => todo.done = val)
  }
})

在 Vue 中:

  • 使用 Methods (函数) : function getActive() { ... }。每次页面重新渲染(哪怕是无关的 DOM 更新),这个函数都会被执行一遍。如果计算量大,会浪费性能。
  • 使用 Computed (计算属性) : 只有依赖变了才算。对于像“过滤列表”、“遍历大数组”这种操作,computed 是性能优化的关键。

5. 项目源码 (src/App.vue)

<script setup>
  import { ref, computed } from 'vue';
  
  // 1. 响应式数据定义
  const title = ref("todos");
  const newTodoText = ref("");
  const todos = ref([
    { id: 1, context: '打王者', done: false },
    { id: 2, context: '吃饭', done: true }, 
    { id: 3, context: '睡觉', done: false },
    { id: 4, context: '学习Vue', done: false }
  ])

  let nextId = 5;

  // 2. 计算属性:统计未完成数量
  // 优势:computed 有缓存,性能优于 method
  const active = computed(() => {
    return todos.value.filter(todo => !todo.done).length
  })

  // 计算属性:统计已完成数量(用于控制清除按钮显示)
  const completedCount = computed(() => {
    return todos.value.filter(todo => todo.done).length
  })

  // 3. 核心业务:添加任务
  const addTodo = () => {
    const text = newTodoText.value.trim();
    if (!text) return;
    todos.value.push({
      id: nextId++, // 确保 ID 唯一
      context: text,
      done: false
    });
    newTodoText.value = "";
  }

  // 4. 核心业务:全选/反选 (可写计算属性)
  const allDone = computed({
    get() {
      return todos.value.length > 0 && todos.value.every(todo => todo.done)
    },
    set(val) {
      todos.value.forEach(todo => todo.done =val)
    }
  })

  // 5. 核心业务:删除任务
  const removeTodo = (id) => {
    todos.value = todos.value.filter(item => item.id !== id)
  }

  // 6. 核心业务:清除已完成
  const clearCompleted = () => {
    todos.value = todos.value.filter(todo => !todo.done)
  }
</script>

<template>
  <div class="todoapp">
    
    <header class="header">
      <h1>{{ title }}</h1>
      <input 
        class="new-todo" 
        type="text" 
        v-model="newTodoText" 
        @keydown.enter="addTodo" 
        placeholder="What needs to be done?"
        autofocus
      >
    </header>

    <section class="main" v-if="todos.length">
      <input id="toggle-all" class="toggle-all" type="checkbox" v-model="allDone">
      <label for="toggle-all">Mark all as complete</label>

      <ul class="todo-list">
        <li v-for="todo in todos" :key="todo.id" :class="{ completed: todo.done }">
          <div class="view">
            <input class="toggle" type="checkbox" v-model="todo.done">
            <label>{{ todo.context }}</label>
            <button class="destroy" @click="removeTodo(todo.id)"></button>
          </div>
        </li>
      </ul>
    </section>

    <footer class="footer" v-if="todos.length">
      <span class="todo-count">
        <strong>{{ active }}</strong> items left
      </span>
      
      <button class="clear-completed" @click="clearCompleted" v-show="completedCount > 0">
        Clear completed
      </button>
    </footer>

  </div>
</template>

Vue中mixin与mixins:全面解析与实战指南

作者 北辰alk
2025年12月11日 21:30

一、引言:为什么需要混入?

在Vue.js开发中,我们经常会遇到多个组件需要共享相同功能或逻辑的情况。例如,多个页面都需要用户认证检查、都需要数据加载状态管理、都需要相同的工具方法等。为了避免代码重复,提高代码的可维护性,Vue提供了混入(Mixin)机制。

今天,我将为你详细解析Vue中mixin和mixins的区别,并通过大量代码示例和流程图帮助你彻底理解这个概念。

二、基础概念解析

1. 什么是mixin?

mixin(混入) 是一个包含可复用组件选项的JavaScript对象。它可以包含组件选项中的任何内容,如data、methods、created、computed等生命周期钩子和属性。

2. 什么是mixins?

mixins 是Vue组件的一个选项,用于接收一个混入对象的数组。它允许组件使用多个mixin的功能。

三、核心区别详解

让我们通过一个对比表格来直观了解二者的区别:

特性 mixin mixins
本质 一个JavaScript对象 Vue组件的选项属性
作用 定义可复用的功能单元 注册和使用mixin
使用方式 被mixins选项引用 组件内部选项
数量 单个 可包含多个mixin

关系流程图

graph TD
    A[mixin定义] -->|混入到| B[Component组件]
    C[另一个mixin定义] -->|混入到| B
    D[更多mixin...] -->|混入到| B
    B --> E[mixins选项<br/>接收mixin数组]

四、代码实战演示

1. 基本mixin定义与使用

创建第一个mixin:

// mixins/loggerMixin.js
export const loggerMixin = {
  data() {
    return {
      logMessages: []
    }
  },
  
  methods: {
    logMessage(message) {
      const timestamp = new Date().toISOString()
      const logEntry = `[${timestamp}] ${message}`
      this.logMessages.push(logEntry)
      console.log(logEntry)
    }
  },
  
  created() {
    this.logMessage('组件/混入已创建')
  }
}

创建第二个mixin:

// mixins/authMixin.js
export const authMixin = {
  data() {
    return {
      currentUser: null,
      isAuthenticated: false
    }
  },
  
  methods: {
    login(user) {
      this.currentUser = user
      this.isAuthenticated = true
      this.$emit('login-success', user)
    },
    
    logout() {
      this.currentUser = null
      this.isAuthenticated = false
      this.$emit('logout')
    }
  },
  
  computed: {
    userRole() {
      return this.currentUser?.role || 'guest'
    }
  }
}

在组件中使用mixins:

<template>
  <div>
    <h1>用户仪表板</h1>
    <div v-if="isAuthenticated">
      <p>欢迎, {{ currentUser.name }} ({{ userRole }})</p>
      <button @click="logout">退出登录</button>
    </div>
    <div v-else>
      <button @click="login({ name: '张三', role: 'admin' })">登录</button>
    </div>
    <div>
      <h3>日志记录:</h3>
      <ul>
        <li v-for="(log, index) in logMessages" :key="index">{{ log }}</li>
      </ul>
    </div>
  </div>
</template>

<script>
import { loggerMixin } from './mixins/loggerMixin'
import { authMixin } from './mixins/authMixin'

export default {
  name: 'UserDashboard',
  
  // mixins选项接收mixin数组
  mixins: [loggerMixin, authMixin],
  
  created() {
    // 合并生命周期钩子
    this.logMessage('用户仪表板组件已创建')
  },
  
  methods: {
    login(user) {
      // 调用mixin的方法
      authMixin.methods.login.call(this, user)
      this.logMessage(`用户 ${user.name} 已登录`)
    }
  }
}
</script>

2. 选项合并策略详解

Vue在处理mixins时遵循特定的合并策略:

// mixins/featureMixin.js
export const featureMixin = {
  data() {
    return {
      message: '来自mixin的消息',
      sharedData: '共享数据'
    }
  },
  
  methods: {
    sayHello() {
      console.log('Hello from mixin!')
    },
    
    commonMethod() {
      console.log('mixin中的方法')
    }
  }
}
<template>
  <div>
    <p>{{ message }}</p>
    <p>{{ componentData }}</p>
    <button @click="sayHello">打招呼</button>
    <button @click="commonMethod">调用方法</button>
  </div>
</template>

<script>
import { featureMixin } from './mixins/featureMixin'

export default {
  mixins: [featureMixin],
  
  data() {
    return {
      message: '来自组件的消息', // 与mixin冲突,组件数据优先
      componentData: '组件特有数据'
    }
  },
  
  methods: {
    // 与mixin中的方法同名,组件方法将覆盖mixin方法
    commonMethod() {
      console.log('组件中的方法')
      // 如果需要调用mixin中的原始方法
      featureMixin.methods.commonMethod.call(this)
    },
    
    componentOnlyMethod() {
      console.log('组件特有方法')
    }
  }
}
</script>

3. 生命周期钩子的合并

生命周期钩子会被合并成数组,mixin的钩子先执行

// mixins/lifecycleMixin.js
export const lifecycleMixin = {
  beforeCreate() {
    console.log('1. mixin的beforeCreate')
  },
  
  created() {
    console.log('2. mixin的created')
  },
  
  mounted() {
    console.log('4. mixin的mounted')
  }
}
<script>
import { lifecycleMixin } from './mixins/lifecycleMixin'

export default {
  mixins: [lifecycleMixin],
  
  beforeCreate() {
    console.log('1. 组件的beforeCreate')
  },
  
  created() {
    console.log('3. 组件的created')
  },
  
  mounted() {
    console.log('5. 组件的mounted')
  }
}
</script>

// 控制台输出顺序:
// 1. mixin的beforeCreate
// 2. 组件的beforeCreate
// 3. mixin的created
// 4. 组件的created
// 5. mixin的mounted
// 6. 组件的mounted

4. 全局混入

除了在组件内使用mixins选项,还可以创建全局mixin:

// main.js或单独的文件中
import Vue from 'vue'

// 全局混入 - 影响所有Vue实例
Vue.mixin({
  data() {
    return {
      globalData: '这是全局数据'
    }
  },
  
  methods: {
    $formatDate(date) {
      return new Date(date).toLocaleDateString()
    }
  },
  
  mounted() {
    console.log('全局mixin的mounted钩子')
  }
})

五、高级用法与最佳实践

1. 可配置的mixin

通过工厂函数创建可配置的mixin:

// mixins/configurableMixin.js
export function createPaginatedMixin(options = {}) {
  const {
    pageSize: defaultPageSize = 10,
    dataKey = 'items'
  } = options
  
  return {
    data() {
      return {
        currentPage: 1,
        pageSize: defaultPageSize,
        totalItems: 0,
        [dataKey]: []
      }
    },
    
    computed: {
      totalPages() {
        return Math.ceil(this.totalItems / this.pageSize)
      },
      
      paginatedData() {
        const start = (this.currentPage - 1) * this.pageSize
        const end = start + this.pageSize
        return this[dataKey].slice(start, end)
      }
    },
    
    methods: {
      goToPage(page) {
        if (page >= 1 && page <= this.totalPages) {
          this.currentPage = page
        }
      },
      
      nextPage() {
        if (this.currentPage < this.totalPages) {
          this.currentPage++
        }
      },
      
      prevPage() {
        if (this.currentPage > 1) {
          this.currentPage--
        }
      }
    }
  }
}
<template>
  <div>
    <h1>用户列表</h1>
    <ul>
      <li v-for="user in paginatedData" :key="user.id">
        {{ user.name }}
      </li>
    </ul>
    
    <div class="pagination">
      <button @click="prevPage" :disabled="currentPage === 1">上一页</button>
      <span>第 {{ currentPage }} 页 / 共 {{ totalPages }} 页</span>
      <button @click="nextPage" :disabled="currentPage === totalPages">下一页</button>
    </div>
  </div>
</template>

<script>
import { createPaginatedMixin } from './mixins/configurableMixin'

export default {
  name: 'UserList',
  
  mixins: [createPaginatedMixin({ pageSize: 5, dataKey: 'users' })],
  
  data() {
    return {
      users: [] // 会被mixin处理
    }
  },
  
  async created() {
    // 模拟API调用
    const response = await fetch('/api/users')
    this.users = await response.json()
    this.totalItems = this.users.length
  }
}
</script>

2. 合并策略自定义

// 自定义合并策略
import Vue from 'vue'

// 为特定选项自定义合并策略
Vue.config.optionMergeStrategies.customOption = function(toVal, fromVal) {
  // 返回合并后的值
  return toVal || fromVal
}

// 自定义方法的合并策略:将方法合并到一个数组中
Vue.config.optionMergeStrategies.myMethods = function(toVal, fromVal) {
  if (!toVal) return [fromVal]
  if (!fromVal) return toVal
  return toVal.concat(fromVal)
}

六、mixin与mixins的完整执行流程

sequenceDiagram
    participant G as 全局mixin
    participant M1 as Mixin1
    participant M2 as Mixin2
    participant C as 组件
    participant V as Vue实例
    
    Note over G,M2: 初始化阶段
    G->>M1: 执行全局mixin钩子
    M1->>M2: 执行Mixin1钩子
    M2->>C: 执行Mixin2钩子
    C->>V: 执行组件钩子
    
    Note over G,M2: 数据合并
    V->>V: 合并data选项<br/>(组件优先)
    
    Note over G,M2: 方法合并
    V->>V: 合并methods选项<br/>(组件覆盖mixin)
    
    Note over G,M2: 钩子函数合并
    V->>V: 合并生命周期钩子<br/>(全部执行,mixin先执行)

七、替代方案与Composition API

虽然mixins非常有用,但在大型项目中可能导致一些问题:

  1. 命名冲突
  2. 隐式依赖
  3. 难以追踪功能来源

Vue 3引入了Composition API作为更好的替代方案:

<template>
  <div>
    <p>计数: {{ count }}</p>
    <p>双倍: {{ doubleCount }}</p>
    <button @click="increment">增加</button>
  </div>
</template>

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

// 使用Composition API复用逻辑
function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  const doubleCount = computed(() => count.value * 2)
  
  function increment() {
    count.value++
  }
  
  onMounted(() => {
    console.log('计数器已挂载')
  })
  
  return {
    count,
    doubleCount,
    increment
  }
}

export default {
  setup() {
    // 明确地使用功能,避免命名冲突
    const { count, doubleCount, increment } = useCounter(10)
    
    return {
      count,
      doubleCount,
      increment
    }
  }
}
</script>

八、总结与最佳实践

mixin vs mixins总结:

  • mixin是功能单元,mixins是使用这些功能单元的接口
  • 一个组件可以通过mixins选项使用多个mixin
  • 合并策略:组件选项通常优先于mixin选项
  • 生命周期钩子会合并执行,mixin钩子先于组件钩子

最佳实践:

  1. 命名规范:为mixin使用特定前缀,如mixinwith
  2. 单一职责:每个mixin只关注一个特定功能
  3. 明确文档:记录mixin的依赖和副作用
  4. 避免全局混入:除非确实需要影响所有组件
  5. 考虑Composition API:在Vue 3项目中优先使用

适用场景:

  • 适合使用mixin:简单的工具函数、通用的生命周期逻辑、小型到中型项目
  • 考虑替代方案:复杂的状态管理、大型企业级应用、需要明确依赖关系的场景

希望通过这篇文章,你已经全面理解了Vue中mixin和mixins的区别与用法。在实际开发中,合理使用混入可以显著提高代码复用性和可维护性,但也要注意避免过度使用导致的复杂性问题。

如果你觉得这篇文章有帮助,欢迎分享给更多开发者!

Vue3-全局组件 && 递归组件

作者 YaeZed
2025年12月11日 21:21

1.全局组件

全局组件是在 main.ts 中一次性注册,之后就可以在项目中的任何组件模板内直接使用,无需 import

组件本身就是一个标准的 SFC (单文件组件),使用 <script setup lang="ts"> 编写。

举个栗子,button组件

<template>
  <button class="base-button">
    <slot></slot>
  </button>
</template>

<script setup lang="ts">
// 这里可以定义 props, emits 等
// 例如:
// defineProps<{ type: 'primary' | 'secondary' }>()
</script>

<style scoped>
.base-button {
  padding: 8px 16px;
  border: 1px solid #ccc;
  border-radius: 4px;
  cursor: pointer;
}
.base-button:hover {
  background-color: #f0f0f0;
}
</style>

在创建 Vue 实例后、挂载 (.mount()) 之前来注册它。

app.component('组件名', 组件对象)

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

// 1. 导入要全局注册的组件
import BaseCard from './components/BaseCard.vue'
import BaseButton from './components/BaseButton.vue'
import SvgIcon from './components/SvgIcon.vue'

const app = createApp(App)

// 2. 使用 app.component() 进行全局注册
// app.component('组件名', 组件对象)
app.component('BaseCard', BaseCard)
app.component('BaseButton', BaseButton)
app.component('SvgIcon', SvgIcon)

// 3. 挂载应用
app.mount('#app')

注册后,在任何其他组件中都可以直接使用 <BaseButton>,无需导入。

<template>
  <div>
    <h1>欢迎!</h1>
    <BaseButton>点我</BaseButton>
    <BaseCard>
      <SvgIcon name="user" />
      <p>一些内容</p>
    </BaseCard>
  </div>
</template>

<script setup lang="ts">
// 无需 import BaseButton, BaseCard, SvgIcon
</script>
使用场景
  1. 基础 UI 组件: 这是最常见的场景。项目中使用频率极高的组件,例如 Button, Icon, Modal, Card, Input 等。通常会以 Base-App- 作为前缀,以示区分。
  2. UI 库集成: 当使用像 Element Plus, Naive UI 或 Vuetify 这样的库时,它们通常会提供一个 app.use(Library) 的方式,这背后其实就是全局注册了它们所有的组件。
  3. 布局组件: AppHeader, AppFooter, Sidebar 等几乎每个页面都会用到的布局框架。

⚠️ 注意: 全局注册会轻微增加应用的初始加载体积,因为所有全局组件都会被打包到主 chunk 中。因此,请对那些真正常用的组件使用全局注册,避免滥用。


2.递归组件

递归组件是指在其模板中调用自身的组件。

举个栗子:树形菜单

首先,定义数据结构 ( types.ts)

// types.ts
export interface TreeNodeData {
  id: string;
  label: string;
  children?: TreeNodeData[]; // 关键:children 数组的类型是它自身
}

然后,创建递归组件 (TreeNode.vue)

<template>
  <div class="tree-node">
    <div class="node-label">{{ node.label }}</div>
    <!-- 可选链操作符?.:如果 node.children 存在且有值,则渲染子节点列表,否则返回undefined,隐式转换为false -->
    <ul v-if="node.children && node.children.length > 0" class="children-list">
      <li v-for="child in node.children" :key="child.id">
        <!-- 递归 -->
        <TreeNode :node="child" />
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
// 导入我们定义的类型
import type { TreeNodeData } from './types'

// 1. 定义 Props,接收父组件传递的数据
interface Props {
  node: TreeNodeData;
}
defineProps<Props>()

</script>

<style scoped>
.tree-node {
  margin-left: 20px;
}
.node-label {
  font-weight: bold;
}
.children-list {
  list-style-type: none;
  padding-left: 15px;
  border-left: 1px dashed #ccc;
}
</style>

在父组件中导入并渲染“根节点”即可。

<template>
  <div>
    <h1>文件结构树</h1>
    <TreeNode :node="fileTree" />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import TreeNode from './components/TreeNode.vue'
import type { TreeNodeData } from './components/types' // 确保路径正确

// 准备一个符合 TreeNodeData 结构的 TS 数据
const fileTree = ref<TreeNodeData>({
  id: 'root',
  label: '项目根目录 (src)',
  children: [
    {
      id: 'c1',
      label: 'components',
      children: [
        { id: 'c1-1', label: 'TreeNode.vue' },
        { id: 'c1-2', label: 'BaseButton.vue' },
      ],
    },
    {
      id: 'c2',
      label: 'views',
      children: [
        { id: 'c2-1', label: 'HomePage.vue' },
      ],
    },
    {
      id: 'c3',
      label: 'App.vue',
      // 这个节点没有 children,递归将在此处停止
    },
  ],
})
</script>
使用场景
  1. 树形结构: 任何需要展示层级关系的数据。例如:文件浏览器、组织架构图、导航菜单(尤其是多级下拉菜单)。
  2. 嵌套评论: 社交媒体或论坛中的评论区,一条评论可以有“回复”(子评论),子评论又可以有回复。
  3. JSON 格式化器: 展示一个 JSON 对象,如果某个值是对象或数组,就递归地调用组件来展示其内部。

参考文章

小满zs 学习Vue3 第十五章(全局组件,局部组件,递归组件)xiaoman.blog.csdn.net/article/det…

昨天 — 2025年12月11日首页

从后端拼模板到 Vue 响应式:前端界面的三次进化

作者 鱼鱼块
2025年12月11日 18:00

从后端拼模板到 Vue 响应式:一场前端界面的进化史

当开始学习前端开发时,很多人都会遇到一个共同的困惑:
为什么有的项目让后端直接返回 HTML?

为什么后来大家都开始使用 fetch 拉取 JSON?

而现在又流行 Vue 的响应式界面,几乎不再手动操作 DOM?

这些不同的方式看似杂乱,其实背后隐藏着一条非常清晰的技术发展路径。后端渲染 → 前端渲染 → 响应式渲染它们不是独立出现的,而是前端能力逐步增强、分工越来越明确后的必然产物。

1. 🌱 第一阶段:后端拼模板 —— “厨师把菜做好端到你桌上”

让我们从最初的 Node.js 服务器代码说起。

    const http = require("http");// Node.js 内置模块,用于创建 HTTP 服务器或客户端
const url = require("url");// 用于解析 URL

const users = [
  { id: 1, name: '张三', email: '123@qq.com' },
  { id: 2, name: '李四', email: '1232@qq.com'},
  { id: 3, name: '王五', email: '121@qq.com' }
];

// 将 `users` 数组转换为 HTML 表格字符串
function generateUserHTML(users){
  const userRows = users.map(user => `
    <tr>
      <td>${user.id}</td>
      <td>${user.name}</td>
      <td>${user.email}</td>
    </tr>
  `).join('');

  return `
    <html>
      <body>
        <h1>Users</h1>
        <table>
          <tbody>${userRows}</tbody>
        </table>
      </body>
    </html>
  `;
}

// 创建一个 HTTP 服务器,传入请求处理函数
const server = http.createServer((req, res) => {
  if(req.url === '/' || req.url === '/users'){
    res.setHeader('Content-Type', 'text/html;charset=utf-8');
    res.end(generateUserHTML(users));
  }
});

//  服务器监听本地 1314 端口。
server.listen(1314);

访问1314端口,得到的结果:

image.png 这段代码非常典型,体现了早期 Web 的模式:

  • 用户访问 /users
  • 后端读取数据
  • 后端拼出 HTML
  • 后端把完整页面返回给浏览器

你可以把它理解成:

用户去餐馆点菜 → 厨房(后端)把菜做好 → 端到你桌上(浏览器)

整个过程中,浏览器不参与任何加工,它只是“展示已经做好的菜”。


🔍 后端拼模板的特点

特点 说明
后端掌控视图 HTML 是后端生成的
数据和页面耦合在一起 改数据就要改 HTML 结构
刷新页面获取新数据 无法局部更新
用户体验一般 交互不够流畅

这种方式在早期 Web 非常普遍,就是典型的 MVC:

  • M(Model): 数据
  • V(View): HTML 模板
  • C(Controller): 拼 HTML,返回给浏览器

“后端拼模板”就像饭店里:

  • 厨师(后端)把所有食材(数据)做成菜(HTML)
  • 顾客(浏览器)只能被动接受

这当然能吃饱,但吃得不灵活。

为了吃一个小菜,还要大厨重新做一桌菜!

这就导致页面每个小变化都得刷新整个页面。


2. 🌿 第二阶段:前后端分离 —— “厨师只给食材,顾客自己配菜”

随着前端能力提升,人们发现:

让后端拼页面太麻烦了。

于是产生了 前后端分离


🔸 后端从“做菜”变成“送食材”(只返回 JSON)

{
  "users": [
    { "id": 1, "name": "张三", "email": "123@qq.com" },
    { "id": 2, "name": "李四", "email": "1232@qq.com" },
    { "id": 3, "name": "王五", "email": "121@qq.com" }
  ]
}

JSON Server 会把它变成一个 API:

GET http://localhost:3000/users

访问该端口得到:

image.png

访问时返回纯数据,而不再返回 HTML。


🔸 前端浏览器接管“配菜”(JS 渲染 DOM)

<script>
fetch('http://localhost:3000/users')// 使用浏览器内置的 `fetch`() API 发起 HTTP 请求。
  .then(res => res.json())//  解析响应为 JSON
  // 渲染数据到页面
  .then(data => {
    const tbody = document.querySelector('tbody');
    tbody.innerHTML = data.map(user => `
      <tr>
        <td>${user.id}</td>
        <td>${user.name}</td>
        <td>${user.email}</td>
      </tr>
    `).join('');
  });
</script>

浏览器自己:

  1. 发送 fetch 请求
  2. 拿到 JSON
  3. 用 JS 拼 HTML
  4. 填入页面

image.png


这就好比:

  • 后端: 不做菜,只把干净的食材准备好(纯 JSON)
  • 前端: 自己按照 UI 要求把菜炒出来(DOM 操作)
  • 双方分工明确,互不干扰

这就是现代 Web 最主流的模式 —— 前后端分离


🚧 但问题来了:DOM 编程太痛苦了

你看这段代码:

tbody.innerHTML = data.map(user => `
  <tr>...</tr>
`).join('');

是不是很像在手工组装乐高积木?

DOM 操作会遇到几个痛点:

  • 代码又臭又长
  • 更新数据要重新操作 DOM
  • 状态多了之后难以维护
  • 页面结构和业务逻辑混在一起

前端工程师开始苦恼:

有没有一种方式,让页面自动根据数据变化?

于是,Vue、React、Angular 出现了。


3. 🌳 第三阶段:Vue 响应式数据驱动 —— “只要食材变化,餐盘自动变化”

Vue 的核心理念:

ref 响应式数据,将数据包装成响应式对象
界面由 {{}} v-for 进行数据驱动
专注于业务,数据的变化而不是 DOM

这是前端的终极模式 —— 响应式渲染


🔥 Vue 的思想

Vue 做了三件事:

  1. 把变量变成“会被追踪的数据”(ref / reactive)
  2. 把 HTML 变成“模板”(用 {{ }}、v-for)
  3. 让数据变化自动修改 DOM

你只需要像写伪代码一样描述业务:

<script setup>
import {
  ref,
  onMounted // 挂载之后
} from 'vue'
const users = ref([]);

// 在挂载后获取数据

onMounted(() =>{
   fetch('http://localhost:3000/users')
   .then(res => res.json())
   .then(data => {
    users.value = data;
   })
})
</script>

而页面模板:

<tr v-for="u in users" :key="u.id">
  <td>{{ u.id }}</td>
  <td>{{ u.name }}</td>
  <td>{{ u.email }}</td>
</tr>

得到的结果为:

image.png 你不再需要:

  • querySelector
  • innerHTML
  • DOM 操作

Vue 会自己完成这些工作。


如果 传统 DOM:

你要把所有食材手动摆到盘子里。

那么Vue:

你只需要放食材到盘子里(修改数据),
餐盘的摆盘会自动变化(界面自动更新)。

比如你修改了数组:

users.value.push({ id: 4, name: "新用户", email: "xxx@qq.com" });

页面会自动新增一行。

你删除:

users.value.splice(1, 1);

页面自动少一行。

你完全不用动 DOM。


4. 🌲 三个阶段的对比

阶段 数据从哪里来? 谁渲染界面? 技术特征
1. 后端渲染(server.js) 后端 后端拼 HTML 模板字符串、MVC
2. 前端渲染(index.html + db.json) API / JSON 前端 JS DOM Fetch、innerHTML
3. Vue 响应式渲染 API / JSON Vue 自动渲染 ref、{{}}、v-for

本质是渲染责任的迁移:

后端渲染 → 前端手动渲染 → 前端自动渲染

最终目标只有一个:

让开发者把时间花在业务逻辑,而不是重复性 DOM 操作上。


5. 🍁 为什么现代开发必须用前后端分离 + Vue?

最后,让我们用一句最通俗的话总结:

后端拼页面像“饭店厨师包办一切”,效率低。

前端手动拼 DOM 像“自己做饭”,累到爆。

Vue 像“智能厨房”,你只需要准备食材(数据)。


Vue 的三大优势

1)极大减少开发成本

业务逻辑变简单:

users.value = newUsers;

就够了,UI 自动更新。

2)更适合大型项目

  • 组件化
  • 模块化
  • 状态集中管理
  • 可维护性高

3)用户体验更好

  • 页面不刷新
  • 更新局部
  • 响应迅速

6. 🌏 文章总结:从“厨房”看前端的进化历史

最终,我们回到开头的类比:

阶段 类比
第一阶段:后端拼模板 厨房(后端)做好所有菜,直接端给你
第二阶段:前端渲染 厨房只提供食材,你自己炒
第三阶段:Vue 响应式 智能厨房:只要食材变,菜自动做好

前端技术每一次进化,都围绕同一个核心目标:

让开发者更轻松,让用户体验更好。

而你上传的代码正好构成了一个完美的演示链路:
从最原始的后端拼模板,到 fetch DOM 渲染,再到 Vue 响应式渲染。

理解了这三步,你就理解了整个现代前端技术的发展脉络。


从模板渲染到响应式驱动:前端崛起的技术演进之路

2025年12月11日 17:28

引言:界面是如何“动”起来的?

不论是用户看到的哪个页面,都不应该是一成不变的静态 HTML。

待办事项的增删、商品库存的实时变化,还是聊天消息的即时推送,都让页面指向了一个必需功能————界面必须要随着数据的变化而自动更新

而围绕着这一核心诉求,出现了两条主流路径:

  • 后端动态生成HTML(传统的 MVC 模式): 数据在服务端组装成完整页面,再一次性返还给浏览器。
  • 前端接管界面更新(现代响应式范式): 后端只提供原始数据(如JSON API),而前端通过响应式系统来驱动视图自动同步。

而在这两条路背后,反映着前后端职责划分,同时也催生了以VueReact为代表的前端技术框架革命。

时代一:纯后端渲染 —— MVC 模式主导

假如有一个需求如:写一个简单的 HTTP 服务器,当用户访问 //users 路径时,返回一个包含用户列表的 HTML 页面,其他路径则返回 404 错误。

Node.js早期,如果我想实现这个需求,那么后端渲染将是不二之选。

代码示例:早期 Node.js 实现简单用户列表页

首先就是引入 Node.js 内置模块httpurl,而使用的方法则是Node.js最早的CommonJS 模块系统中的 require()来“导入”

const http = require("http"); // commonjs 
const url = require("url");   // url
  • http 模块:用于创建 HTTP 服务器(处理请求和响应)。
  • url 模块:用于解析浏览器发来的 URL 字符串(如 /users?id=1)。

然后再准备一些模拟数据

const users = [
    { id: 1, name: '张三', email: '123@qq.com' },
    { id: 2, name: '李四', email: '123456@qq.com' },
    { id: 3, name: '王五', email: '121@qq.com' }
]

接下来就要创建生成 HTML 页面的函数了

先使用.map()方法来动态生成表格行,对每个用户生成一行 HTML 表格,用反引号来插入变量,使用 .join('')来拼接所有行,最后返还一个完整的 HTML 文档。

function generateUsersHtml(users) {
    const userRows = users.map(user => `
        <tr>
            <td>${user.id}</td>
            <td>${user.name}</td>
            <td>${user.email}</td>
        </tr>
    `).join('');
    
    return `
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>User List</title>
        <style>
            table { width: 100%; border-collapse: collapse; margin-top: 20px; }
            th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
            th { background-color: #f4f4f4; }
        </style>
    </head>
    <body>
        <h1>Users</h1>
        <table>
            <thead>
                <tr>
                    <th>ID</th>
                    <th>Name</th>
                    <th>Email</th>
                </tr>
            </thead>
            <tbody>
                ${userRows}
            </tbody>
        </table>
    </body>
    </html>
    `;
}

最后也是最重要的就是创建 HTTP 服务器了。

const server = http.createServer((req, res) => {
    const parsedUrl = url.parse(req.url, true);
    
    if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/users') {
        res.statusCode = 200;
        res.setHeader('Content-Type', 'text/html;charset=utf-8');
        const html = generateUsersHtml(users);
        res.end(html);
    } else {
        res.statusCode = 404;
        res.setHeader('Content-Type', "text/plain");
        res.end('Not Found');
    }
});

关键概念解释:

  • req(Request):用户的请求对象,包含 URL、方法、头信息等。
  • res(Response):你要返回给用户的内容,通过它设置状态码、头、正文。

解析 URL

// url.parse(pathname, query)
const parsedUrl = url.parse(req.url, true);
  • req.url即用户请求的 路径+参数部分

  • url.parse()将 URL 字符串“拆解”成结构化的对象,从而方便读取,其中:

    • pathname: 路径部分(如 /users
    • query: 查询参数(如 ?id=1就变成了{ id: '1' }),这里的true是用于判断你是否需要自动解析URL参数部分并转换为对象(通常为 true)

路由判断(简单路由)

if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/users')

如果路径是根路径 / 或 /users,就显示用户列表,否则返回 404。

成功响应(200)

res.statusCode = 200; // 设置状态码为 200
res.setHeader('Content-Type', 'text/html;charset=utf-8');
const html = generateUsersHtml(users);
res.end(html);

通过.setHeader告诉浏览器:“我返回的是 HTML,用 UTF-8 编码”。

然后利用函数 generateUsersHtml(users),传入用户数据,最后调用res.end()生成 HTML 并发送。

错误响应(404 Not Found)

res.statusCode = 404;
res.setHeader('Content-Type', "text/plain");
res.end('Not Found');

状态码 404 表示“页面不存在”,如果产生错误则返还Not Found

注意:url.parse() 是旧 API,现代开发基本弃用


Node.js HTTP服务器启动的最后一步

server.listen(1234, () => {
    console.log('Server is running on port 1234')
})

让服务器监听 1234 端口(任意修改)。此时就可以在浏览器访问:http://localhost:1234http://localhost:1234/users,而访问其他路径(如 /about)会显示 “Not Found”。

效果图:

image.png

核心缺点 + 时代局限性:

  1. 前后端高度耦合,协作效率低下

HTML 结构、样式、JavaScript 逻辑全部硬编码在一个函数里,如果要修改表格样式等操作,就得修改这个函数,并且无法复用。

而这也几乎将前后端工程师捆绑起来了:

  • 前端工程师无法独立开发或调试 UI,必须依赖后端接口和模板
  • 后端工程师被迫处理本应属于前端范畴的展示逻辑

阻碍了团队协作,让前后端工程师的开发体验都极差。

  1. 用户体验受限,交互能力弱

页面完全由服务端生成,每次跳转或操作都需整页刷新,无法实现局部更新、动态加载、表单实时校验等现代 Web 交互,即使只是点击一个按钮,也要重新请求整个 HTML 文档。

时代二:转折点 AJAX 与前后端分离的诞生

在 2005 年之前,Web 应用基本是:用户点击 → 浏览器发请求 → 后端生成完整 HTML → 返回 → 整页刷新 ,导致用户每次交互都像“重新打开一个页面”,体验感大打折扣。

转折事件:Google Maps(2005)首次大规模使用 XMLHttpRequest(XHR)

  • 地图拖拽时不刷新页面
  • 动态加载新区域数据
  • 用户体验飞跃 → 行业震动

这就是 AJAX(Asynchronous JavaScript and XML) 范式的诞生—— “让网页像桌面应用一样流畅”

范式对比再深化

维度 后端渲染(传统) 前后端分离(AJAX 时代)
职责划分 后端一家独大 前端负责 UI/交互,后端负责数据/API
开发模式 全栈一人干 前后端并行开发
部署方式 服务端部署 HTML 前端静态资源(CDN),后端 API(独立服务)
用户体验 卡顿、白屏、跳转 流畅、局部更新、SPA雏形
技术栈 PHP/Java/Node + 模板引擎 HTML/CSS/JS + REST API

代码示例:

已经配置好的环境

在后端 backend 文件夹中包含一个存储用户数据的db.json文件:

{
    "users": [
        {
            "id": 1,
            "name": "张三",
            "email": "123@qq.com"
        },
        {
            "id": 2,
            "name": "李四",
            "email": "1232@qq.com"
        },
        {
            "id": 3,
            "name": "王五",
            "email": "121@qq.com"
        }
    ]
}

注:json-server 会把 JSON 的顶层 key(如 "users")自动映射为 RESTful 路由

package.json 中的脚本

{
  "scripts": {
    "dev": "json-server --watch db.json"
  }
}

就使得运行 npm run dev 时,json-server 会监听 backend/db.json 文件变化(--watch),并且启动一个 HTTP 服务器,默认端口 3000。(别忘了启动后端服务哦~~)

前端代码(重头戏):

基础页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>User List</title>
    <style>
        table { width: 100%; border-collapse: collapse; margin-top: 20px; }
        th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
        th { background-color: #f4f4f4; }
    </style>
</head>
<body>
    <h1>Users</h1>
    <table>
        <thead>
            <tr>
                <th>ID</th>
                <th>Name</th>
                <th>Email</th>
            </tr>
        </thead>
        <tbody>
        </tbody>
    </table>
</body>
</html>

<script>中的内部逻辑

<script>
    // DOM 编程
    fetch('http://localhost:3000/users') // 发出请求
        .then(res => res.json()) // 将 JSON 字符串转为 JS 对象数组
        .then(data => {
            const tbody = document.querySelector('tbody');
            tbody.innerHTML = data.map(user => `
                <tr>
                    <td>${user.id}</td>
                    <td>${user.name}</td>
                    <td>${user.email}</td>
                </tr>
            `).join('');
        })
</script>

.then(data => ...)中的 data 就是上一步 res.json() 解析的结果,并且通过 .map()生成字符串数组,再用.join('') 拼接字符串,而通过 tbody.innerHTML 让浏览器重新解析并渲染表格。

tongyi-mermaid-2025-12-11-165610.png

这代表了“纯手工”前后端分离的起点

  • 前端不再依赖后端吐 HTML
  • 数据通过 JSON API 获取
  • 视图由 JavaScript 动态生成

image.png

但这种方法并非完美,仍然存在痛点:手动操作 DOM 繁琐且易错

举个“胶水代码灾难”的例子:

// 用户点击“删除”
button.onclick = () => {
  fetch(`/api/users/${id}`, { method: 'DELETE' })
    .then(() => {
      // 从列表中移除元素
      li.remove();
      // 更新
      countSpan.textContent = --totalCount;
      // 如果列表空了,显示“暂无数据”
      if (totalCount === 0) emptyMsg.style.display = 'block';
      // 可能还要发埋点、更新缓存、通知其他组件...
    });
};

视图更新逻辑散落在各处,难以维护,极易出错,删除数据要:

  • 找到 <tr> 并删除
  • 更新
  • 找到空状态提示并显示
  • 可能还要:更新侧边栏统计、刷新分页、清除搜索高亮……

每次 UI 变化都要手动找一堆 DOM 节点去修改,并且难以复用。

这时期的前端程序员内心都憋着一句话:我不想再写 document.getElementById 了!

AJAX 让网页活了过来,但也让前端开发者陷入了新的地狱(DOM)——直到框架降临

时代三:革命!响应式数据驱动界面的崛起

核心思想:

“你只管改数据,界面自动更新。”

关键技术:ref 与响应式系统

  • ref() 将普通值包装成响应式对象
  • 模板中通过 {{ }}v-for 声明式绑定数据
  • 数据变化会自动触发视图更新(无需手动 DOM 操作)

响应式(以 Vue 为例)

<template>
  <table>
    <thead>
        <tr>
            <th>ID</th>
            <th>Name</th>
            <th>Email</th>
        </tr>
    </thead>
    <tbody>
      <!-- 遍历数据渲染到界面 -->
      <tr v-for="user in users" :key="user.id">
        <td>{{ user.id }}</td>
        <td>{{ user.name }}</td>
        <td>{{ user.email }}</td>
      </tr>
    </tbody>
  </table>
</template>
  • v-for:声明遍历 users 数组
  • {{ }} :显示 user 的某个属性
  • :key:帮助 Vue 高效追踪列表变化(性能优化)

但是关键在于:我没有写任何 DOM 操作代码!

只是在“描述 UI 应该是什么样子”,而不是“怎么去修改 DOM”。

<script setup>
import { 
  ref,
  onMounted // 挂载之后的生命周期
} from 'vue'; // 响应式api(将数据包装成响应式对象)

// 用 ref() 将普通数组包装成一个 响应式引用对象
const users = ref([]);

// onMounted:确保 DOM 已创建后再发起请求(避免操作不存在的元素)
onMounted(() => {
  console.log('页面挂载完成');
  fetch('http://localhost:3000/users')
    .then(res => res.json())
    .then(data => {
      users.value = data; // 只修改数据
    })
})

// 定时器添加数据
setTimeout(() => {
  users.value.push({
    id: '4',
    name: '钱六',
    email: '12313@qq.com'
  })
}, 3000)
</script>

没有 innerHTML没有 createElement没有 getElementById

并且所有 UI 更新都是数据变化的自然结果,无需人工干预!

2025-12-11.gif

整个历史进程:

阶段 开发模式 核心关注点 开发体验
后端渲染 MVC 数据 → 模板 → HTML 前端边缘化
前后端分离 AJAX + DOM 手动同步数据与视图 繁琐、易错
响应式框架 数据驱动 聚焦业务逻辑 高效、声明式、愉悦

这段短短的 Vue 代码,浓缩了前端开发十年的演进:

  • 从“操作 DOM”到“描述 UI”
  • 从“分散状态”到“单一数据源”
  • 从“易错胶水”到“自动同步”

它让前端开发者终于认识到一个新的自己:前端不再只是“切图仔”,而是复杂应用的架构者与体验设计师。 这,就是 响应式数据驱动界面 的革命性所在。

zero-admin后台管理模板

2025年12月11日 17:24

zero-admin 管理后台模板

zero-admin 是一个后台前端解决方案,它基于 vue3 和 ant-design-vue 实现。它使用了最新的前端技术栈【vue3+vue-router+typescript+axios+ant-design-vue+pinia+mockjs+plopjs+vite+Vitest】实现了动态路由、权限验证;自定义 Vue 指令封装;规范项目代码风格;项目内置脚手架解决文件创建混乱,相似业务模块需要频繁拷贝代码或文件问题;Echarts 图形库进行封装;axios 请求拦截封装,请求 api 统一管理;通过 mockjs 模拟数据;对生产环境构建进行打包优化,实现了打包 gzip 压缩、代码混淆,去除 console 打印,打包体积分析等;提炼了典型的业务模型,提供了丰富的功能组件,它可以帮助你快速搭建企业级中后台产品原型

image.png

image.png

image.png

推荐 VsCode 编辑器插件

VSCode + Volar (并禁用 Vetur 扩展插件) + TypeScript Vue Plugin (Volar).

TS 中.vue导入的类型支持

默认情况下,TypeScript 无法处理“.vue”导入的类型信息,因此我们将“tsc”CLI 替换为“vue-tsc”进行类型检查。在编辑中,我们需要 TypeScript Vue Plugin (Volar) 以使 TypeScript 语言服务了解“.vue”类型。

如果你觉得独立的 TypeScript 插件不够快,Volar 还实现了一个 Take Over Mode that 这更有表现力。您可以通过以下步骤启用它:

  1. Disable the built-in TypeScript Extension
    1. Run Extensions: Show Built-in Extensions from VSCode's command palette
    2. Find TypeScript and JavaScript Language Features, right click and select Disable (Workspace)
  2. Reload the VSCode window by running Developer: Reload Window from the command palette.

项目获取

git clone gitee.com/zmmlet/zero…

自定义 Vite 配置

Vite 配置参考.

项目依赖安装

pnpm install

开发环境运行(编译和热重新加载)

pnpm dev

打包部署运行(生产打包添加类型检查、编译)

pnpm build

运行单元测试 Vitest

pnpm test:unit

语法规则和代码风格检查 ESLint

pnpm lint

功能列表

  • 项目创建
  • 配置项目代码风格 .prettierrc.json
  • 配置.vscode setting.json 文件,配置保存格式化
  • 添加 SCSS 到项目进行 CSS 预处理
  • 配置 vscode 别名跳转规则
  • 安装 ant design vue 并配置自动加载
  • 配置 less 预处理,并自定义 ant Design Vue UI 主题
  • 解决 vite 首屏加载缓慢问题
  • pinia 数据持久化
  • 解决 pinia 使用报错问题
  • Layout 布局
  • Axios 封装
  • 菜单图标动态绑定
  • Vitest 单元测试
  • 集成打印插件
  • import.meta.glob 批量导入文件夹下文件
  • 配置 .env
  • 自定义按钮权限指令
  • 动态路由
  • 路由权限
  • 按钮权限(目前和登录账号有关,和具体页面无关)
  • Echarts 集成
  • 路由懒加载
  • 项目打包优化
    • 打包 gzip 压缩
    • 代码混淆
    • 去除生产环境 console
    • 打包体积分析插件
    • 代码拆包,将静态资源分类
    • 传统浏览器兼容性支持
    • CDN 内容分发网络(Content Delivery Network)
  • 项目集成自定义 cli 解决项目重复复制代码问题
  • 集成 mockjs
  • 读取 makdown 文档,编写组件说明文档
  • maptalks + threejs demo 示例
  • 使用 postcss-pxtorem、autoprefixer 插件 px 自动转换为 rem 和自动添加浏览器兼容前缀
  • Monorepo
  • 基于 sh 脚本对项目进行一键部署
  • 图形编辑器
    • 流程图
  • 国际化
  • CI/CD
  • 自动化部署
  • 使用 commitizen 规范 git 提交,存在 plopfile.js 和 commitlint 提交规范 导入模式问题"type": "module"冲突问题,导致目前 commitizen 规范 git 提交验证暂时不可用
  • husky

项目创建

  1. 项目创建命令 pnpm create vite
  2. 选择对应初始配置项
Progress: resolved 1, reused 1, downloaded 0, added 1, done
√ Project name: ... zero-admin
√ Select a framework: » Vue
√ Select a variant: » Customize with create-vue ↗
Packages: +1

Vue.js - The Progressive JavaScript Framework

√ Add TypeScript? ... No / Yes
√ Add JSX Support? ... No / Yes
√ Add Vue Router for Single Page Application development? ... No / Yes
√ Add Pinia for state management? ... No / Yes
√ Add Vitest for Unit Testing? ... No / Yes
√ Add an End-to-End Testing Solution? » No
√ Add ESLint for code quality? ... No / Yes
√ Add Prettier for code formatting? ... No / Yes

Scaffolding project in D:\learningSpace\code\vue-project\zero-admin...

Done. Now run:

cd zero-admin
pnpm install
pnpm lint
pnpm dev

初始项目依赖安装

  1. pnpm add ant-design-vue --save
  2. pnpm add unplugin-vue-components -D
  3. pnpm add axios
  4. pnpm add sass-loader@7.2.0 sass@1.22.10 -D
  5. pnpm add less -D

配置项目代码风格 .prettierrc.json

{
  "stylelintIntegration": true,
  "eslintIntegration": true,
  "printWidth": 80, //单行长度
  "tabWidth": 2, //缩进长度
  "useTabs": false, //使用空格代替tab缩进
  "semi": true, //句末使用分号
  "singleQuote": false, //使用单引号
  "endOfLine": "auto"
}

配置保存(Ctrl + s)自动格式化代码

在项目中创建.vscode 文件夹中创建 setting.json 文件

{
  "editor.codeActionsOnSave": {
    "source.fixAll": true
  },
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "[vue]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  } // 默认格式化工具选择prettier
}

添加 SCSS 到项目进行 CSS 预处理

  1. pnpm add sass-loader@7.2.0 sass@1.22.10 -D

  2. 新建styles/scss 文件夹,新建 index.scss文件

  3. vite.config.ts 文件中配置

css: {
  preprocessorOptions: {
    // 配置 scss 预处理
    scss: {
      additionalData: '@import "@/style/scss/index.scss";',
    },
  },
},

项目根目录新建 jsconfig.json 文件

配置 vscode 别名跳转规则

{
  "compilerOptions": {
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "baseUrl": ".",
    "jsx": "react",
    "paths": {
      "@/*": ["./src/*"],
      "@components/*": ["./src/components/*"],
      "@setting/*": ["./src/setting/*"],
      "@views/*": ["./src/views/*"],
      "@assets/*": ["./src/assets/*"],
      "@config/*": ["./src/config/*"],
      "@api/*": ["./src/api/*"],
      "@utils/*": ["./src/utils/*"],
      "@styles/*": ["./src/styles/*"],
      "@store/*": ["./src/store/*"]
    }
  },
  "exclude": ["node_modules", "dist"]
}

vite.config.ts 文件中配置对应文件夹别名

resolve: {
  alias: {
    "@": fileURLToPath(new URL("./src", import.meta.url)),
    "@comp": path.resolve(__dirname, "./src/components"),
  },
  extensions: [".mjs", ".js", ".ts", ".jsx", ".tsx", ".json", ".vue"],
},

安装 ant design vue 并配置自动加载

安装 UI 和自动加载插件 pnpm add ant-design-vue --save pnpm add unplugin-vue-components -D 在 vite.config.ts 引入配置

// 引入 ant design vue 按需加载
import Components from "unplugin-vue-components/vite";
import { AntDesignVueResolver } from "unplugin-vue-components/resolvers";
export default defineConfig({
  // 插件
  plugins: [
    vue(),
    // ant design vue 按需加载
    Components({
      resolvers: [AntDesignVueResolver({ importStyle: "less" })],
    }),
  ],
});

配置 less 预处理,并自定义 ant Design Vue UI 主题

安装 pnpm add less -D 在 vite.config.ts 引入配置

export default defineConfig({
  // 插件
  plugins: [
    vue(),
    // ant design vue 按需加载
    Components({
      resolvers: [AntDesignVueResolver({ importStyle: "less" })],
    }),
  ],

  css: {
    preprocessorOptions: {
      // 自定义 ant desing vue 主题样式
      less: {
        modifyVars: {
          "@primary-color": "red",
          "@border-radius-base": "0px", // 组件/浮层圆角
        },
        javascriptEnabled: true,
      },
    },
  },
});

pinia 数据持久化

pnpm add pinia-plugin-persist --save

解决 pinia 使用报错问题

使用

import { userStore } from "@/stores/modules/user";
const usersto = userStore();
console.log("store :>> ", usersto);

报错 转存失败,建议直接上传图片文件

解决方法

import store from "@/stores/index";
import { userStore } from "@/stores/modules/user";
const usersto = userStore(store);
console.log("store :>> ", usersto);

Layout 布局

Axios 封装

pnpm add axios --save

菜单图标动态绑定

  1. 动态创建
// ICON.ts
import { createVNode } from "vue";
import * as $Icon from "@ant-design/icons-vue";

export const Icon = (props: { icon: string }) => {
  const { icon } = props;
  return createVNode($Icon[icon]);
};
  1. 引入使用
<template>
  <div class="about">about <Icon :icon="icon" /></div>
</template>
<script lang="ts" setup>
import { Icon } from "@/setting/ICON";
import { ref } from "vue";
const icon = ref("AppstoreOutlined");
</script>

打包 gzip 压缩

// 引入 gzip 压缩
import viteCompression from "vite-plugin-compression";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    vueJsx(),
    // 打包压缩,主要是本地gzip,如果服务器配置压缩也可以
    viteCompression({
      verbose: true,
      disable: false,
      threshold: 10240,
      algorithm: "gzip",
      ext: ".gz",
    }),
  ],
});
server {
  #端口号,不同的程序,复制时,需要修改其端口号
        listen      3031;
  #服务器地址,可以为IP地址,本地程序时,可以设置为localhost
        server_name  localhost;
        client_max_body_size 2G;

    # 开启gzip
        gzip on;
    # 启用gzip压缩的最小文件,小于设置值的文件将不会压缩
        gzip_min_length 1k;
    # gzip 压缩级别,1-9,数字越大压缩的越好,也越占用CPU时间,后面会有详细说明
        gzip_comp_level 1;
    # 进行压缩的文件类型。javascript有多种形式。其中的值可以在 mime.types 文件中找到。
        gzip_types text/html text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png application/vnd.ms-fontobject font/ttf font/opentype font/x-woff image/svg+xml;
    # 是否在http header中添加Vary: Accept-Encoding,建议开启
        gzip_vary on;
    # 禁用IE 6 gzip
        gzip_disable "MSIE [1-6]\.";
    # 设置压缩所需要的缓冲区大小
        gzip_buffers 32 4k;
    # 设置gzip压缩针对的HTTP协议版本
        gzip_http_version 1.0;

  #程序所在目录
        root D:/learningSpace/code/vue-project/zero-admin/dist;
        charset utf-8;
            index index.html;

        location / {
            try_files $uri $uri/ /index.html;
        }

        location @rewrites {
            rewrite ^(.+)$ /index.html last;
        }

  #程序映射地址,将【zero-service】改为你程序名称,将【proxy_pass】 改为你自己的后台地址
        location /zero-service {
            proxy_pass http://localhost:9099/zero-service;
            proxy_cookie_path / /zero-service;
        }
    }

代码混淆

pnpm add terser -D

export default defineConfig({
  // 打包配置
  build: {
    chunkSizeWarningLimit: 500, // hunk 大小警告的限制(以 kbs 为单位)
    minify: "terser", // 代码混淆  boolean | 'terser' | 'esbuild' ,当设置为 'terser' 时必须先安装 Terser pnpm add terser -D
  },
});

去除生产环境 console

export default defineConfig({
  // 打包配置
  build: {
    terserOptions: {
      compress: {
        // warnings: false,
        drop_console: true, // 打包时删除console
        drop_debugger: true, // 打包时删除 debugger
        pure_funcs: ["console.log", "console.warn"],
      },
      output: {
        comments: true, // 去掉注释内容
      },
    },
  },
});

打包体积分析插件

  1. 安装 pnpm add rollup-plugin-visualizer -D
  2. vite.config.ts 配置

传统浏览器兼容性支持

  1. 安装 pnpm add @vitejs/plugin-legacy -D
  2. 在 vite.config.ts 中配置
import legacyPlugin from "@vitejs/plugin-legacy";
export default ({ command, mode }: ConfigEnv): UserConfig => {
  return {
    plugins: [
      legacyPlugin({
        targets: ["chrome 52"], // 需要兼容的目标列表,可以设置多个
        // additionalLegacyPolyfills: ["regenerator-runtime/runtime"], // 面向IE11时需要此插件
      }),
    ],
  };
};
  1. 添加传统浏览器兼容性支持,打包后在 dist 文件夹下 index.html 文件中确认 转存失败,建议直接上传图片文件

CDN 内容分发网络(Content Delivery Network)

  1. 插件安装pnpm add vite-plugin-cdn-import -D -w
  2. vite.config.ts 配置

Vitest 单元测试

vitest 参考文章:juejin.cn/post/714837…

  1. Vitest 测试已经在项目初始化的时候添加
  2. vue 组件测试 pnpm add @vue/test-utils -D
  3. 测试规则,添加查看 vite.config.ts 文件
  4. 编写 vue 组件
<template>
  <div>
    <div>Count: {{ count }}</div>
    <div>name: {{ props.name }}</div>
    <h2 class="msg">{{ msg }}</h2>
    <button @click="handle">点击事件</button>
  </div>
</template>

<script setup lang="ts">
import { onMounted, ref } from "vue";

const props = defineProps({
  name: {
    type: String,
    default: "1111",
  },
});

const count = ref<number>(0);

let msg = ref<string>("hello");

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

onMounted(() => {
  console.log("props.message==", props.name);
});
</script>

<style scoped lang="scss"></style>
  1. 编写测试文件
import { test, expect } from "vitest";
import { mount } from "@vue/test-utils";
import Count from "../index.vue";

test.concurrent("基础js测试", () => {
  expect(1 + 1).toBe(2);
});

const Component = {
  template: "<div>44Hello world</div>",
};

// mount 的第二个参数,可以传一些配置项,比如props。这在测试组件时,很好用
test("mounts a component", () => {
  const wrapper = mount(Component, {});

  expect(wrapper.html()).toContain("Hello world");
});

// 测试 props 组件传参
test("测试 props 组件传参", () => {
  // 测试props 传参
  const wrapper = mount(Count, {
    props: {
      name: "Hello world",
    },
  });
  expect(wrapper.text()).toContain("Hello world");
  // 测试 ref指定初始值
  expect(wrapper.vm.count).toBe(0);
  // 测试点击事件
  const button = wrapper.find("button");
  button.trigger("click");
  expect(wrapper.vm.count).toBe(1);
  // 测试msg渲染
  expect(wrapper.find(".msg").text()).toBe("hello");
});
  1. 安装 vscode 插件,配置测试 debug 环境

    • 插件商店搜索 Vitest 安装
    • 点击 debug 选择 node 配置 .vscode 文件夹下 launch.json 文件
    {
      // Use IntelliSense to learn about possible attributes.
      // Hover to view descriptions of existing attributes.
      // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
      "version": "0.2.0",
      "configurations": [
        {
          "type": "node",
          "request": "launch",
          "name": "Debug Current Test File",
          "autoAttachChildProcesses": true,
          "skipFiles": ["<node_internals>/**", "**/node_modules/**"],
          "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
          "args": ["run", "${relativeFile}"],
          "smartStep": true,
          "console": "integratedTerminal"
        }
      ]
    }
    
  2. 点击 debug 运行 转存失败,建议直接上传图片文件转存失败,建议直接上传图片文件

集成打印插件

  1. 官网:printjs.crabbly.com/#documentat…
  2. 安装 pnpm add print-js

配置 .env

  1. vite.config.ts 读取
const root = process.cwd();
const env = loadEnv(process.argv[process.argv.length - 1], root);
// 读取值 env.VITE_APP_SERVICE;
  1. ts 文件读取import.meta.env.VITE_APP_TITLE

import.meta.glob 批量导入文件夹下文件

function addRouter(list) {
  const modules = import.meta.glob("./views/**.vue");
  for (const path in modules) {
    modules[path]().then((mod) => {
      const file = mod.default;
      if (list.map((a) => a.name).includes(file.name)) {
        router.addRoute({
          path: "/" + file.name,
          name: file.name,
          component: file,
        });
      }
    });
  }
}

解决 vite 首屏加载缓慢问题

参考文章:

采用判断是否为生产环境,生产环境打包,自动加载,开发环境全局引入,修改 vite.config.ts 文件

export default ({ command, mode }: ConfigEnv): UserConfig => {
  // 读取环境变量配置
  const root = process.cwd();
  const env = loadEnv(process.argv[process.argv.length - 1], root);
  // 判断是否为打包环境
  const isBuild = command === "build";

  return {
    plugins: [
      vue(),
      vueJsx(),
      // ant design vue 按需加载
      Components({
        resolvers: [
          AntDesignVueResolver({ importStyle: isBuild ? "less" : false }),
        ],
      }),
    ],
  };
};

全局引入在 main.ts 文件中,目前未实现环境变量的判断,如需打包,请手动注释掉全局引入的 ant-design-vue 样式

// 生产环境下,注释掉下面的全局样式引入
import "ant-design-vue/dist/antd.less";

利用 plop,自定义脚手架

Plop 是一个小而美的脚手架工具,它主要用于创建项目中特定类型的文件,Plop 主要集成在项目中使用,帮助我们快速生成一定规范的初始模板文件

  1. 安装 pnpm add plop -D
  2. 在项目根目录下创建 plopfile.js 文件
#!/usr/bin/env node
import componentsSetting from "./plop-templates/components/prompt.js";
import pageSetting from "./plop-templates/pages/prompt.js";

export default function (plop) {
  plop.setWelcomeMessage("请选择需要创建的模式:");
  plop.setGenerator("components", componentsSetting);
  plop.setGenerator("page", pageSetting);
}
  1. 在项目根目录下创建plop-templates文件夹

    • 新建 components 文件夹,添加文件prompt.js指令文件和index.hbs模板文件
    • prompt.js 指令内容
    import fs from "fs";
    function getFolder(path) {
      const components = [];
      const files = fs.readdirSync(path);
      files.forEach((item) => {
        const stat = fs.lstatSync(`${path}/${item}`);
        if (stat.isDirectory() === true && item !== "components") {
          components.push(`${path}/${item}`);
          components.push(...getFolder(`${path}/${item}`));
        }
      });
      return components;
    }
    
    const componentsSetting = {
      description: "创建组件",
      // 提示数组
      prompts: [
        {
          type: "confirm",
          name: "isGlobal",
          message: "是否为全局组件",
          default: false,
        },
        {
          type: "list",
          name: "path",
          message: "请选择组件创建目录",
          choices: getFolder("src/components"),
          when: (answers) => {
            return !answers.isGlobal;
          },
        },
        {
          type: "input",
          name: "name",
          message: "请输入组件名称",
          validate: (v) => {
            if (!v || v.trim === "") {
              return "组件名称不能为空";
            } else {
              return true;
            }
          },
        },
      ],
      // 行为数组
      actions: (data) => {
        let path = "";
        if (data.isGlobal) {
          path = "src/components/{{properCase name}}/index.vue";
        } else {
          path = `${data.path}/components/{{properCase name}}/index.vue`;
        }
        const actions = [
          {
            type: "add",
            path,
            templateFile: "plop-templates/components/index.hbs",
          },
        ];
        return actions;
      },
    };
    
    export default componentsSetting;
    
    • index.hbs 模板内容
    <template>
      <div>
        <!-- 布局 -->
      </div>
    </template>
    
    <script lang="ts" setup{{#if isGlobal}} name="{{ properCase name }}"{{/if}}>
    // 逻辑代码
    </script>
    
    <style lang="scss" scoped>
    // 样式
    </style>
    
  2. 在项目 package.json 添加 "cli": "plop", 命令


"scripts": {
    "cli": "plop",
  },
  1. 通过 pnpm cli 选择创建项目代码模板 转存失败,建议直接上传图片文件

集成 mockjs 模拟后台接口

  1. 安装依赖

    • pnpm add mockjs
    • pnpm add @types/mockjs -D
    • pnpm add vite-plugin-mock -D
  2. 配置 vite.config.ts 文件

    • 引入插件 import { viteMockServe } from "vite-plugin-mock";
    • 在 数组中进行配置
    export default ({ command, mode }: ConfigEnv): UserConfig => {
      // 读取环境变量配置
      const root = process.cwd();
      const env = loadEnv(process.argv[process.argv.length - 1], root);
    
      const isBuild = command === "build";
    
      return {
        plugins: [
          //....
          viteMockServe({
            mockPath: "src/mock",
            localEnabled: !isBuild,
            prodEnabled: isBuild,
            injectCode: `
          import { setupProdMockServer } from './mockProdServer';
          setupProdMockServer();
          `,
          }),
        ],
      };
    };
    
  3. 新建 src\mockProdServer.ts 文件与 main.ts 文件同级

import { createProdMockServer } from "vite-plugin-mock/es/createProdMockServer";

const mocks: any[] = [];
const mockContext = import.meta.glob("./mock/*.ts", {
  eager: true,
});
Object.keys(mockContext).forEach((v) => {
  mocks.push(...(mockContext[v] as any).default);
});

export function setupProdMockServer() {
  createProdMockServer(mocks);
}
  1. 新建 src\mock 文件夹,mock 文件下,新建业务模块 login.ts
export default [
  {
    url: "/api/sys/login", // 模拟登录接口
    method: "POST", // 请求方式
    timeout: 3000, // 超时事件
    statusCode: 200, // 返回的http状态码
    response: (option: any) => {
      // 返回的结果集
      return {
        code: 200,
        message: "登录成功",
        data: {
          failure_time: Math.ceil(new Date().getTime() / 1000) + 24 * 60 * 60,
          account: option.body.account,
          token: "@string",
        },
      };
    },
  },
];
  1. 利用封装的 api 调用 /api/sys/login 接口
postAction("/sys/login", { userName: userName, password: password }).then(
  (res: any) => {}
);

自定义按钮权限指令

  1. 在 directive 文件夹下,新建 permission.ts 文件。添加权限指令代码
// 引入vue中定义的指令对应的类型定义
import type { Directive } from "vue";
const permission: Directive = {
  // mounted是指令的一个生命周期
  mounted(el, binding) {
    // value 获取用户使用自定义指令绑定的内容
    const { value } = binding;
    // 获取用户所有的权限按钮
    // const permissionBtn: any = sessionStorage.getItem("permission");
    const permissionBtn: any = ["admin", "dashboard.admin"];
    // 判断用户使用自定义指令,是否使用正确了
    if (value && value instanceof Array && value.length > 0) {
      const permissionFunc = value;
      //判断传递进来的按钮权限,用户是否拥有
      //Array.some(), 数组中有一个结果是true返回true,剩下的元素不会再检测
      const hasPermission = permissionBtn.some((role: any) => {
        return permissionFunc.includes(role);
      });
      // 当用户没有这个按钮权限时,返回false,使用自定义指令的钩子函数,操作dom元素删除该节点
      if (!hasPermission) {
        // el.style.display = "none";
        el.parentNode && el.parentNode.removeChild(el);
      }
    } else {
      throw new Error(`传入关于权限的数组,如 v-permission="['admin','user']"`);
    }
  },
};

export default permission;
  1. 在 directive 文件夹下,新建 index.ts 批量注册指令
import type { Directive } from "vue";
import permission from "./permission";
// 自定义指令
const directives = { permission };

export default {
  install(app: any) {
    Object.keys(directives).forEach((key) => {
      // Object.keys() 返回一个数组,值是所有可遍历属性的key名
      app.directive(key, (directives as { [key: string]: Directive })[key]); //key是自定义指令名字;后面应该是自定义指令的值,值类型是string
    });
  },
};
  1. 在 main.ts 文件引入,注册自定义指令
import { createApp } from "vue";
import App from "./App.vue";
import directive from "./directive";

const app = createApp(App);
app.use(directive);

app.mount("#app");

处理 px 转 rem,和 css 自动添加浏览器前缀

  1. 安装pnpm add postcss-pxtorem autoprefixer -D
  2. vite.config.ts 配置
import postCssPxToRem from "postcss-pxtorem";
import autoprefixer from "autoprefixer";
export default ({ command, mode }: ConfigEnv): UserConfig => {
  return {
    css: {
      postcss: {
        plugins: [
          postCssPxToRem({
            // 自适应,px>rem转换
            rootValue: 16, // 1rem的大小
            propList: ["*"], // 需要转换的属性,这里选择全部都进行转换
          }),
          autoprefixer({
            // 自动添加前缀
            overrideBrowserslist: [
              "Android 4.1",
              "iOS 7.1",
              "Chrome > 31",
              "ff > 31",
              "ie >= 8",
              //'last 2 versions', // 所有主流浏览器最近2个版本
            ],
            grid: true,
          }),
        ],
      },
    },
  };
};

Monorepo

Monorepo 是一种项目管理方式,就是把多个项目放在一个仓库里面 juejin.cn/post/696432…

  1. 项目根目录新建 pnpm-workspace.yaml 文件
packages:
  # all packages in subdirs of packages/ and components/
  - "packages/**"
  1. @zero-admin/utils 安装 到 @zero-admin/chart 执行命令pnpm i @zero-admin/utils -r --filter @zero-admin/chart
  2. @zero-admin/chart 安装到根项目 package.json 文件中,执行命令 pnpm i @zero-admin/chart -w

图像编辑器

流程图

安装依赖
  1. 流程图核心包pnpm add @logicflow/core -w
  2. 流程图扩展包pnpm add @logicflow/extension -w
  3. 格式化展示 json 数据 pnpm add vue-json-pretty -w
初始化容器及 LogicFlow 对象
准备容器

国际化

  1. 安装pnpm add vue-i18n

读取 makdown 文档,编写组件说明文档

  1. 安装依赖 pnpm add @kangc/v-md-editor@next -D pnpm add prismjs -S pnpm add @types/prismjs -D
  2. 在 setting 文件夹下新建 mdEditor.ts 文件
import VueMarkdownEditor from "@kangc/v-md-editor";
import "@kangc/v-md-editor/lib/style/base-editor.css";
import vuepressTheme from "@kangc/v-md-editor/lib/theme/vuepress.js";
import "@kangc/v-md-editor/lib/theme/style/vuepress.css";

import Prism from "prismjs";

VueMarkdownEditor.use(vuepressTheme, {
  Prism,
});
export default VueMarkdownEditor;
  1. 在 main.ts 文件中引入挂载
import { createApp } from "vue";
import App from "./App.vue";

import VueMarkdownEditor from "@/setting/mdEditor";
const app = createApp(App);

app.use(VueMarkdownEditor);
app.mount("#app");
  1. 在组件中使用
<template>
  <v-md-editor
    v-model="markdownTable"
    height="calc(100vh - 293px)"
    mode="preview"
  ></v-md-editor>
</template>
<script setup lang="ts">
  import markdownTable from "./README.md?raw";
</script>

将资源引入为字符串:资源可以使用 ?raw 后缀声明作为字符串引入 官网:ckang1229.gitee.io/vue-markdow…

maptalks + threejs demo 示例

  1. 项目依赖 pnpm add three maptalks maptalks.three --save
  2. Demo 源码文件:文件路径: src\views\charts\smartCity.vue
  3. 访问 Demo
    • 启动项目 pnpm dev
    • 浏览器访问路径 http://localhost:3030/city

使用 commitizen 规范 git 提交

  1. 安装依赖 pnpm install commitizen @commitlint/config-conventional @commitlint/cli commitlint-config-cz cz-git -D

  2. 配置 package.json

{
  ...
  "scripts": {
    "git:comment": "引导设置规范化的提交信息",
    "git": "git pull && git add . && git-cz && git push",
  },

  "husky": {
    "hooks": {
      "pre-commit": "lint-staged",
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  },
  "lint-staged": {
    "src/**/*.{js,ts,vue}": [
      "prettier --write --ignore-unknown --no-error-on-unmatched-pattern",
      "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
    ],
    "package.json": [
      "prettier --write"
    ]
  },
  "config": {
    "commitizen": {
      "path": "node_modules/cz-git"
    }
  }
  ...
}
  1. 项目根目录新建 commitlint.config.js 添加配置
module.exports = {
  // 继承的规则
  extends: ["@commitlint/config-conventional", "cz"],
  // 定义规则类型
  rules: {
    // type 类型定义,表示 git 提交的 type 必须在以下类型范围内
    "type-enum": [
      2,
      "always",
      [
        "feature", // 新功能(feature)
        "bug", // 此项特别针对bug号,用于向测试反馈bug列表的bug修改情况
        "fix", // 修补bug
        "ui", // 更新 ui
        "docs", // 文档(documentation)
        "style", // 格式(不影响代码运行的变动)
        "perf", // 性能优化
        "release", // 发布
        "deploy", // 部署
        "refactor", // 重构(即不是新增功能,也不是修改bug的代码变动)
        "test", // 增加测试
        "chore", // 构建过程或辅助工具的变动
        "revert", // feat(pencil): add ‘graphiteWidth’ option (撤销之前的commit)
        "merge", // 合并分支, 例如: merge(前端页面): feature-xxxx修改线程地址
        "build", // 打包
      ],
    ],
    // <type> 格式 小写
    "type-case": [2, "always", "lower-case"],
    // <type> 不能为空
    "type-empty": [2, "never"],
    // <scope> 范围不能为空
    "scope-empty": [2, "never"],
    // <scope> 范围格式
    "scope-case": [0],
    // <subject> 主要 message 不能为空
    "subject-empty": [2, "never"],
    // <subject> 以什么为结束标志,禁用
    "subject-full-stop": [0, "never"],
    // <subject> 格式,禁用
    "subject-case": [0, "never"],
    // <body> 以空行开头
    "body-leading-blank": [1, "always"],
    "header-max-length": [0, "always", 72],
  },
  prompt: {
    alias: { fd: "docs: fix typos" },
    messages: {
      type: "选择你要提交的类型 :",
      scope: "选择一个提交范围(可选):",
      customScope: "请输入自定义的提交范围 :",
      subject: "填写简短精炼的变更描述 :\n",
      body: '填写更加详细的变更描述(可选)。使用 "|" 换行 :\n',
      breaking: '列举非兼容性重大的变更(可选)。使用 "|" 换行 :\n',
      footerPrefixesSelect: "选择关联issue前缀(可选):",
      customFooterPrefix: "输入自定义issue前缀 :",
      footer: "列举关联issue (可选) 例如: #31, #I3244 :\n",
      confirmCommit: "是否提交或修改commit ?(y/n/e/h)",
    },
    types: [
      { value: "feat", name: "feat:     新增功能 | A new feature" },
      { value: "fix", name: "fix:      修复缺陷 | A bug fix" },
      {
        value: "docs",
        name: "docs:     文档更新 | Documentation only changes",
      },
      {
        value: "style",
        name: "style:    代码格式 | Changes that do not affect the meaning of the code",
      },
      {
        value: "refactor",
        name: "refactor: 代码重构 | A code change that neither fixes a bug nor adds a feature",
      },
      {
        value: "perf",
        name: "perf:     性能提升 | A code change that improves performance",
      },
      {
        value: "test",
        name: "test:     测试相关 | Adding missing tests or correcting existing tests",
      },
      {
        value: "build",
        name: "build:    构建相关 | Changes that affect the build system or external dependencies",
      },
      {
        value: "ci",
        name: "ci:       持续集成 | Changes to our CI configuration files and scripts",
      },
      { value: "revert", name: "revert:   回退代码 | Revert to a commit" },
      {
        value: "chore",
        name: "chore:    其他修改 | Other changes that do not modify src or test files",
      },
    ],
    allowCustomScopes: true,
    skipQuestions: ["body", "footer"],
  },
};

Git hooks 工具

vue3 使用 husky + commitlint 强制码提交规范

  1. 安装依赖 pnpm add lint-staged husky -D -w
  2. 添加 package.json 脚本
"prepare": "husky install"
  1. 初始化 husky 将 git hooks 钩子交由 husky 执行pnpm run prepare
  2. npx husky add .husky/pre-commit "pnpm run eslint"
  3. pnpm husky add .husky/commit-msg 'pnpm commitlint --edit $1'

git 使用命令

  1. 克隆远程仓库代码 git clone https://gitee.com/zmmlet/zero-admin.git

  2. 第 1 步:同步远程仓库代码:git pull git add / git commit 代码之前首先 git pull,需先从服务器上面拉取代码,以防覆盖别人代码;如果有冲突,先备份自己的代码,git checkout 下远程库里最新的的代码,将自己的代码合并进去,然后再提交代码。

  3. 第 2 步:查看当前状态:git status 使用 git status 来查看当前状态,红色的字体显示的就是你修改的文件

  4. 第 3 步:提交代码到本地 git 缓存区:git add 情形一:如果你 git status 查看了当前状态发现都是你修改过的文件,都要提交,那么你可以直接使用 git add . 就可以把你的内容全部添加到本地 git 缓存区中 情形二:如果你 git status 查看了当前状态发现有部分文件你不想提交,那么就使用 git add xxx(上图中的红色文字的文件链接) 就可以提交部分文件到本地 git 缓存区。

  5. 第 4 步:推送代码到本地 git 库:git commit git commit -m "提交代码" 推送修改到本地 git 库中

  6. 第 5 步:提交本地代码到远程仓库:git push git push <远程主机名> <远程分支名> 把当前提交到 git 本地仓库的代码推送到远程主机的某个远程分之上

技术栈

Vue 3 统一面包屑导航系统:从配置地狱到单一数据源

2025年12月11日 17:13

本文分享我们在 Vue 3 + TypeScript 项目中重构面包屑导航系统的实践经验,通过将面包屑配置迁移到路由 meta 中,实现了配置的单一数据源,大幅降低了维护成本。

一、问题背景

1.1 原有架构的痛点

在重构之前,我们的面包屑系统采用独立的配置文件 breadcrumb.ts,存在以下问题:

// 旧方案:独立的面包屑配置文件(700+ 行)
export const breadcrumbConfigs: BreadcrumbItemConfig[] = [
  {
    path: '/export/booking',
    name: 'BookingManage',
    title: '订舱管理',
    showInBreadcrumb: true,
    children: [
      { path: '/export/booking/create', name: 'BookingCreate', title: '新建订舱' },
      { path: '/export/booking/edit', name: 'BookingEdit', title: '编辑订舱' },
      // ... 更多子页面
    ],
  },
  // ... 几十个类似的配置
]

主要痛点:

  1. 配置分散:路由定义在 router/modules/*.ts,面包屑配置在 config/breadcrumb.ts,新增页面需要修改两处
  2. 维护成本高:配置文件超过 700 行,嵌套结构复杂,容易出错
  3. 同步困难:路由变更后容易忘记更新面包屑配置,导致显示异常
  4. 类型安全差:配置与路由之间缺乏类型关联

1.2 期望目标

  • 单一数据源:面包屑配置与路由定义合并,一处修改全局生效
  • 类型安全:利用 TypeScript 确保配置正确性
  • 易于维护:新增页面只需在路由配置中添加一行
  • 向后兼容:平滑迁移,不影响现有功能

二、技术方案

2.1 核心思路

将面包屑路径配置到路由的 meta 字段中,通过 Composable 自动解析生成面包屑导航。

路由配置 (meta.breadcrumb) → useBreadcrumb() → BreadCrumb.vue

2.2 扩展路由 Meta 类型

首先,扩展 Vue Router 的 RouteMeta 接口:

// src/router/types.ts
import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    /** 页面标题 */
    title?: string
    /** 国际化 key */
    i18nKey?: string
    /** 面包屑路径(路由名称数组) */
    breadcrumb?: string[]
    /** 是否缓存 */
    keepAlive?: boolean
    // ... 其他字段
  }
}

/** 面包屑项类型 */
export interface BreadcrumbItem {
  title: string
  path: string
  name: string
  i18nKey?: string
  isClickable: boolean
}

2.3 路由配置示例

在路由模块中添加 breadcrumb meta:

// src/router/modules/export.ts
export const exportRoutes: RouteRecordRaw[] = [
  {
    path: '/export/booking',
    name: 'BookingManage',
    component: () => import('~/views/export/booking/index.vue'),
    meta: {
      title: '订舱管理',
      keepAlive: true,
      breadcrumb: ['Export', 'BookingManage'], // 出口 > 订舱管理
    },
  },
  {
    path: '/export/booking/create/:mode',
    name: 'BookingCreate',
    component: () => import('~/views/export/booking/create.vue'),
    meta: {
      title: '新建订舱',
      breadcrumb: ['Export', 'BookingManage', 'BookingCreate'], // 出口 > 订舱管理 > 新建订舱
    },
  },
]

配置规则:

  • 数组元素为路由名称(name)或虚拟节点名称
  • 按层级顺序排列:[一级菜单, 二级菜单, 当前页面]
  • 空数组 [] 表示不显示面包屑(如首页)

2.4 useBreadcrumb Composable

核心逻辑封装在 Composable 中:

// src/composables/useBreadcrumb.ts
import type { BreadcrumbItem } from '~/router/types'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'

/**
 * 虚拟路由配置(菜单分类节点)
 * 这些节点在路由系统中不存在,但需要在面包屑中显示
 */
const VIRTUAL_ROUTES: Record<string, { title: string, i18nKey: string }> = {
  Mine: { title: '我的', i18nKey: 'mine' },
  Export: { title: '出口', i18nKey: 'export' },
  Import: { title: '进口', i18nKey: 'import' },
  Finance: { title: '财务', i18nKey: 'finance' },
  BoxManage: { title: '箱管', i18nKey: 'boxManage' },
}

export function useBreadcrumb() {
  const route = useRoute()
  const router = useRouter()
  const { t } = useI18n()

  /** 根据路由名称获取路由信息 */
  function getRouteByName(name: string) {
    return router.getRoutes().find(r => r.name === name)
  }

  /** 获取面包屑项的标题(支持国际化) */
  function getTitle(name: string, routeRecord?: RouteRecordNormalized): string {
    // 优先使用虚拟路由配置
    if (VIRTUAL_ROUTES[name]) {
      return t(`system.routes.${VIRTUAL_ROUTES[name].i18nKey}`, VIRTUAL_ROUTES[name].title)
    }
    // 使用路由 meta 配置
    if (routeRecord?.meta?.i18nKey) {
      return t(`system.routes.${routeRecord.meta.i18nKey}`, routeRecord.meta.title || name)
    }
    return routeRecord?.meta?.title || name
  }

  /** 计算面包屑列表 */
  const breadcrumbs = computed<BreadcrumbItem[]>(() => {
    const routeName = route.name as string
    if (!routeName) return []

    // 从路由 meta 获取面包屑配置
    const breadcrumbPath = route.meta?.breadcrumb as string[]
    if (!breadcrumbPath || breadcrumbPath.length === 0) {
      return []
    }

    // 构建面包屑列表
    return breadcrumbPath.map((name, index) => {
      const routeRecord = getRouteByName(name)
      const isLast = index === breadcrumbPath.length - 1
      const isVirtual = !!VIRTUAL_ROUTES[name]

      return {
        title: getTitle(name, routeRecord),
        path: isLast ? route.path : (routeRecord?.path || ''),
        name,
        i18nKey: isVirtual ? VIRTUAL_ROUTES[name].i18nKey : routeRecord?.meta?.i18nKey,
        isClickable: !isLast && !isVirtual && !!routeRecord,
      }
    })
  })

  /** 是否应该显示面包屑 */
  const shouldShow = computed<boolean>(() => {
    // 首页、登录页等不显示面包屑
    const hiddenPaths = ['/', '/index', '/dashboard', '/login', '/register']
    if (hiddenPaths.includes(route.path)) {
      return false
    }
    return breadcrumbs.value.length > 0
  })

  return { breadcrumbs, shouldShow }
}

2.5 面包屑组件

组件只需调用 Composable 即可:

<!-- src/layout/components/BreadCrumb/BreadCrumb.vue -->
<script setup lang="ts">
import { useBreadcrumb } from '~/composables'

const { breadcrumbs, shouldShow } = useBreadcrumb()
</script>

<template>
  <el-breadcrumb v-if="shouldShow" separator="/">
    <el-breadcrumb-item
      v-for="item in breadcrumbs"
      :key="item.name"
      :to="item.isClickable ? item.path : undefined"
    >
      {{ item.title }}
    </el-breadcrumb-item>
  </el-breadcrumb>
</template>

三、迁移策略

3.1 渐进式迁移

为了确保平滑过渡,我们采用渐进式迁移策略:

  1. 阶段一:新增 useBreadcrumb Composable,支持从路由 meta 读取配置
  2. 阶段二:逐个模块添加 breadcrumb meta 字段
  3. 阶段三:验证所有页面面包屑正常后,删除旧配置文件

3.2 迁移清单

模块 文件 页面数
核心页面 core.ts 4
用户管理 user.ts 3
查询服务 search_service.ts 6
出口业务 export.ts 25+
进口业务 import.ts 4
财务结算 payment_settlement.ts 6
箱管业务 equipment-control.ts 12

3.3 国际化配置

确保所有菜单分类节点都有对应的国际化配置:

// src/i18n/zh/system.ts
export default {
  routes: {
    // 菜单分类节点
    mine: '我的',
    export: '出口',
    import: '进口',
    finance: '财务',
    boxManage: '箱管',
    
    // 具体页面
    bookingManage: '订舱管理',
    bookingCreate: '新建订舱',
    // ...
  },
}

四、效果对比

4.1 代码量对比

指标 重构前 重构后 变化
配置文件行数 737 行 0 行(已删除) -100%
新增页面修改文件数 2 个 1 个 -50%
类型安全

4.2 新增页面对比

重构前:

// 1. 修改路由配置
{ path: '/new-page', name: 'NewPage', component: ... }

// 2. 修改面包屑配置(容易遗漏!)
{ path: '/new-page', name: 'NewPage', title: '新页面', ... }

重构后:

// 只需修改路由配置
{
  path: '/new-page',
  name: 'NewPage',
  component: ...,
  meta: {
    title: '新页面',
    breadcrumb: ['ParentMenu', 'NewPage'],
  },
}

五、最佳实践

5.1 面包屑配置规范

// ✅ 推荐:使用路由名称数组
breadcrumb: ['Export', 'BookingManage', 'BookingCreate']

// ❌ 避免:使用路径
breadcrumb: ['/export', '/export/booking', '/export/booking/create']

5.2 虚拟节点使用场景

当菜单分类本身不是一个可访问的页面时,使用虚拟节点:

// "出口" 是菜单分类,不是实际页面
const VIRTUAL_ROUTES = {
  Export: { title: '出口', i18nKey: 'export' },
}

// 路由配置
breadcrumb: ['Export', 'BookingManage'] // 出口 > 订舱管理

5.3 动态路由处理

对于带参数的动态路由,Composable 会自动使用当前路由的完整路径:

// 路由定义
{ path: '/export/lading/edit/:id', name: 'BookingLadingEdit', ... }

// 面包屑配置
breadcrumb: ['Export', 'BookingLadingManagement', 'BookingLadingEdit']

// 实际显示:出口 > 提单管理 > 编辑提单
// 最后一项路径:/export/lading/edit/123(保留实际 ID)

六、总结

通过将面包屑配置迁移到路由 meta 中,我们实现了:

  1. 单一数据源:路由配置即面包屑配置,消除了配置分散的问题
  2. 维护成本降低:删除了 700+ 行的独立配置文件
  3. 开发效率提升:新增页面只需修改一处
  4. 类型安全增强:TypeScript 类型检查确保配置正确性
  5. 国际化支持:无缝集成 vue-i18n

这种方案特别适合中大型 Vue 3 项目,尤其是菜单结构复杂、页面数量多的企业级应用。


相关技术栈:

  • Vue 3.5+ (Composition API)
  • Vue Router 4
  • TypeScript 5+
  • vue-i18n

参考资料:

vue v-for列表渲染, 无key、key为index 、 有唯一key三种情况下的对比。 列表有删除操作时的表现

作者 niujiangyao
2025年12月11日 16:58

在 Vue3 的 v-for 列表渲染中,key 的使用方式直接影响列表更新时的 DOM 行为,尤其是包含删除操作时,不同 key 策略会呈现不同的表现(甚至异常)。下面从「无 key」「key 为 index」「key 为唯一值」三种场景逐一分析,并结合删除操作的示例说明差异。

核心原理铺垫

Vue 的虚拟 DOM 对比(diff 算法)依赖 key 来识别节点的唯一性:

  • 有唯一 key:Vue 能精准判断节点的增 / 删 / 移,只更新变化的 DOM;
  • 无 key/key 为 index:Vue 无法识别节点唯一性,会通过「就地复用」策略更新 DOM,可能导致 DOM 与数据不匹配。

场景复现准备

先定义基础组件,包含一个列表和删除按钮,后续仅修改 v-for 的 key

<template>
  <div>
    <div v-for="(item, index) in list" :key="xxx"> <!-- 重点:xxx 替换为不同值 -->
      <input type="text" v-model="item.name">
      <button @click="deleteItem(index)">删除</button>
    </div>
  </div>
</template>

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

// 初始化列表(每个项有唯一 id,模拟业务场景)
const list = ref([
  { id: 1, name: '张三' },
  { id: 2, name: '李四' },
  { id: 3, name: '王五' }
])

// 删除方法
const deleteItem = (index) => {
  list.value.splice(index, 1)
}
</script>

场景 1:无 key(不写 :key)

表现

删除某一项后,输入框的内容会「错位」,DOM 看似更新但数据与视图不匹配。

示例过程

  1. 初始状态:输入框分别输入「张三」「李四」「王五」;
  2. 删除索引 1(李四);
  3. 结果:列表只剩两项,但输入框显示「张三」「王五」→ 看似正常?✘ 实际异常点:若列表项包含「状态绑定 / 组件实例」(比如输入框焦点、自定义组件内部状态),会出现错位。(补充:无 key 时 Vue 会按「节点位置」复用 DOM,删除索引 1 后,原索引 2 的 DOM 会被移到索引 1 位置,仅更新文本内容,但组件 / 输入框的内部状态会保留。)

本质

Vue 认为「节点位置」即唯一标识,直接复用 DOM 节点,仅更新节点的文本 / 属性,忽略数据的唯一性,若列表项有「非响应式状态」(如输入框焦点、组件内部变量),会导致状态错位。

场景 2:key 为 index(:key="index")

表现

删除操作后,输入框内容错位更明显(比无 key 更易复现),是日常开发中最易踩的坑。

示例过程

  1. 初始状态:输入框分别输入「张三」「李四」「王五」;

  2. 删除索引 1(李四);

  3. 结果:

    • 数据层面:list 变为 [{id:1,name:'张三'}, {id:3,name:'王五'}]
    • 视图层面:输入框显示「张三」「李四」(而非「王五」),DOM 与数据完全错位。

原因分析(关键)

操作前 操作后(删除索引 1)
索引 0 → key0 → 张三 索引 0 → key0 → 张三(复用原 DOM,无变化)
索引 1 → key1 → 李四 索引 1 → key1 → 王五(复用原索引 1 的 DOM,仅更新文本,但输入框的 v-model 绑定的是 item.name,为何错位?)
索引 2 → key2 → 王五 索引 2 被删除

核心错位逻辑:当 key 为 index 时,删除索引 1 后,原索引 2 的项(id:3,name: 王五)会「占据」索引 1 的位置。Vue 的 diff 算法认为:

  • key0(索引 0)的节点不变,复用;
  • key1(索引 1)的节点需要更新,于是将原索引 1 的 DOM 节点的 item 替换为新的索引 1 项(王五),但输入框的 DOM 节点是复用的,v-model 的绑定是「事后更新」,导致视觉上输入框内容未同步(或出现延迟 / 错位)。

极端案例(含组件状态)

若列表项是自定义组件(有内部状态):

<!-- 自定义组件 -->
<template>
  <div>{{ item.name }} - 内部状态:{{ innerState }}</div>
</template>
<script setup>
const props = defineProps(['item'])
const innerState = ref(Math.random()) // 组件内部状态
</script>

<!-- 列表使用 -->
<div v-for="(item, index) in list" :key="index">
  <MyComponent :item="item" />
  <button @click="deleteItem(index)">删除</button>
</div>

删除索引 1 后,原索引 2 的组件会复用原索引 1 的组件 DOM,内部状态(innerState)不会重置,导致「王五」显示的是「李四」组件的内部状态,完全错位。

场景 3:key 为唯一值(:key="item.id")

表现

删除操作后,DOM 精准更新,无任何错位,输入框 / 组件状态与数据完全匹配。

示例过程

  1. 初始状态:输入框输入「张三」「李四」「王五」;

  2. 删除索引 1(李四,id:2);

  3. 结果:

    • 数据层面:list 变为 [{id:1,name:'张三'}, {id:3,name:'王五'}]
    • 视图层面:直接移除 id:2 对应的 DOM 节点,剩余节点的 DOM 完全保留(输入框内容、组件状态均无错位)。

原因分析

Vue 通过唯一 key(item.id)识别节点:

  • 删除 id:2 的项时,Vue 直接找到 key=2 的 DOM 节点并移除;
  • 剩余项的 key(1、3)与原节点一致,复用 DOM 且状态不变;
  • 无任何 DOM 复用错位,数据与视图完全同步。

本质

唯一 key 让 Vue 能精准匹配「数据项」和「DOM 节点」,diff 算法会:

  1. 对比新旧列表的 key 集合;
  2. 移除不存在的 key(如 2);
  3. 保留存在的 key(1、3),仅更新内容(若有变化);
  4. 新增的 key(若有)则创建新 DOM 节点。

三种场景对比表

场景 删除操作后的表现 底层逻辑 适用场景
无 key 文本看似正常,组件 / 输入框状态可能错位 按位置复用 DOM,无唯一性识别 仅纯文本列表,无状态 / 输入框
key 为 index 输入框 / 组件状态明显错位,数据与视图不匹配 按索引复用 DOM,索引变化导致错位 临时静态列表(无增删改)
key 为唯一值 无错位,DOM 精准更新 按唯一标识匹配节点,精准增删 所有有增删改的列表(推荐)

关键总结

  1. 禁止在有增删改的列表中使用 index 作为 key:这是 Vue 官方明确不推荐的做法,会导致 DOM 复用错位;
  2. 无 key 等同于 key 为 index:Vue 内部会默认使用 index 作为隐式 key,表现一致;
  3. 唯一 key 必须是数据本身的属性:不能是临时生成的唯一值(如 Math.random()),否则每次渲染都会认为是新节点,导致 DOM 全量重建,性能极差;
  4. 唯一 key 的选择:优先使用业务唯一标识(如 id、手机号、订单号),避免使用 index / 随机值。

扩展:Vue3 对 key 的优化

Vue3 的 diff 算法(PatchFlags)相比 Vue2 更高效,但key 的核心作用不变—— 唯一 key 仍是保证列表更新准确性的关键,Vue3 仅优化了「有 key 时的对比效率」,并未改变「无 key/index key 导致的错位问题」。

最终正确示例

<template>
  <div>
    <div v-for="(item, index) in list" :key="item.id">
      <input type="text" v-model="item.name">
      <button @click="deleteItem(index)">删除</button>
    </div>
  </div>
</template>

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

const list = ref([
  { id: 1, name: '张三' },
  { id: 2, name: '李四' },
  { id: 3, name: '王五' }
])

const deleteItem = (index) => {
  list.value.splice(index, 1)
}
</script>
❌
❌