阅读视图

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

Vue 3 + Vite 集成 Monaco Editor 开发笔记

背景:在 Vue 3 (JS/TS) + Vite 项目中集成代码编辑器 Monaco Editor。

目标:实现代码高亮、自定义主题、中文汉化、语言切换、双向绑定等功能。

1. 方案选择与安装

在 Vite 中集成 Monaco Editor 主要有两种方式:

  1. 原生 Worker 方式:最稳定,利用 Vite 的 ?worker 特性,但汉化极其困难。
  2. 插件方式 (vite-plugin-monaco-editor)推荐。配置简单,自带汉化支持,但需要处理导入兼容性问题。

安装依赖

Bash

# 核心库
npm install monaco-editor

# Vite 插件 (用于处理 Worker 和汉化)
npm install -D vite-plugin-monaco-editor

2. 核心配置 (Vite)

🔴 常见报错与修复

在使用插件时,可能会遇到以下报错:

  1. TypeError: monacoEditorPlugin is not a function
  2. TypeError: Cannot read properties of undefined (reading 'entry')

这是因为 ESM/CommonJS 模块导入兼容性问题。

✅ 最佳配置 (vite.config.js)

JavaScript

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

export default defineConfig({
  plugins: [
    vue(),
    // 🟢 核心修复:兼容写法,防止报错
    (monacoEditorPlugin.default || monacoEditorPlugin)({
        // 需要加载 Worker 的语言 (JSON, TS/JS, HTML, CSS 有独立 Worker)
        languageWorkers: ['json', 'editorWorkerService'],
        // 🟢 开启中文汉化
        locale: 'zh-cn', 
    })
  ],
})

注意SQL 属于 Basic Language(基础语言),没有独立的 Worker,不需要加到 languageWorkers 列表中。


3. 组件封装 (MonacoEditor.vue)

封装一个支持 双向绑定 (v-model)语言切换自定义主题 的通用组件。

核心逻辑点:

  1. 主题生效顺序:必须先 defineTheme,再 create 实例,并在配置中显式指定 theme
  2. 语言切换:使用 monaco.editor.setModelLanguage 动态切换。
  3. 双向绑定:同时支持内容 (v-model) 和 语言 (v-model:language)。

完整代码

代码段

<template>
  <div class="monaco-wrapper">
    <select :value="language" class="lang-select" @change="handleLanguageChange">
      <option value="json">JSON</option>
      <option value="sql">SQL</option>
      <option value="javascript">JS</option>
      <option value="css">CSS</option>
    </select>

    <div ref="editorContainer" class="editor-container"></div>
  </div>
</template>

<script setup>
import { onMounted, onBeforeUnmount, ref, watch, toRaw } from 'vue'
import * as monaco from 'monaco-editor'

// 定义 Props
const props = defineProps({
  modelValue: { type: String, default: '' },
  language: { type: String, default: 'json' },
  readOnly: { type: Boolean, default: false }
})

// 定义 Emits (支持双 v-model)
const emit = defineEmits(['update:modelValue', 'update:language', 'change'])

const editorContainer = ref(null)
let editorInstance = null

// 1. 切换语言逻辑
const handleLanguageChange = (e) => {
  const newLang = e.target.value
  emit('update:language', newLang) // 通知父组件
  if (editorInstance) {
    monaco.editor.setModelLanguage(editorInstance.getModel(), newLang)
  }
}

onMounted(() => {
  if (!editorContainer.value) return

  // 2. 定义自定义主题 (必须在 create 之前)
  monaco.editor.defineTheme('my-dark-theme', {
    base: 'vs-dark',
    inherit: true,
    rules: [
      { token: 'key', foreground: 'dddddd' },
      { token: 'string.key.json', foreground: 'dddddd' },
      { token: 'string.value.json', foreground: 'b4e98c' },
    ],
    colors: {
      'editor.background': '#0e1013', // 背景色
      'editor.lineHighlightBackground': '#1f2329',
    },
  })

  // 3. 创建编辑器实例
  editorInstance = monaco.editor.create(editorContainer.value, {
    value: props.modelValue,
    language: props.language,
    theme: 'my-dark-theme', // 🟢 显式引用主题
    readOnly: props.readOnly,
    automaticLayout: true, // 自动适应宽高
    minimap: { enabled: false }, // 关闭小地图
    scrollBeyondLastLine: false,
  })

  // 4. 监听内容变化 -> 通知父组件
  editorInstance.onDidChangeModelContent(() => {
    const value = editorInstance.getValue()
    emit('update:modelValue', value)
    emit('change', value)
  })
})

// 5. 监听 Props 变化 (外部修改 -> 同步到编辑器)
watch(() => props.modelValue, (newValue) => {
  if (editorInstance && newValue !== editorInstance.getValue()) {
    // toRaw 避免 Vue 代理对象干扰 Monaco 内部逻辑
    toRaw(editorInstance).setValue(newValue)
  }
})

watch(() => props.language, (newLang) => {
  if (editorInstance) {
    monaco.editor.setModelLanguage(editorInstance.getModel(), newLang)
  }
})

// 销毁
onBeforeUnmount(() => {
  editorInstance?.dispose()
})
</script>

<style scoped>
.monaco-wrapper {
  position: relative;
  width: 100%;
  height: 100%;
  min-height: 300px;
}
.editor-container {
  width: 100%;
  height: 100%;
}
.lang-select {
  position: absolute;
  right: 15px;
  top: 10px;
  z-index: 20;
  background: #1f2329;
  color: #ddd;
  border: 1px solid #555;
  border-radius: 4px;
}
</style>

4. 疑难杂症 (Q&A)

Q1: 为什么在 node_modules 里找不到 SQL 的 Worker 文件?

  • 原因:Monaco 将语言分为两类。

    • Rich Languages (JSON, TS, CSS, HTML):有独立 Worker,支持高级语法检查。路径在 esm/vs/language
    • Basic Languages (SQL, Python, Java 等):没有独立 Worker,只依靠主线程进行简单高亮。路径在 esm/vs/basic-languages
  • 结论:配置插件时,languageWorkers 不需要加 SQL。

Q2: 为什么 import 'monaco-editor/esm/nls.messages.zh-cn.js' 汉化不生效?

  • 原因:在 ESM 模式下,编辑器核心初始化往往早于语言包加载,或者直接被 Tree-shaking 忽略。
  • 解决:使用 vite-plugin-monaco-editor 并配置 locale: 'zh-cn',插件会在编译构建阶段自动注入语言包。

Q3: 为什么 Ctrl+点击 @/... 路径无法跳转?

  • 原因:VS Code 需要配置文件来理解别名。对于 Vue+JS 项目,根目录缺少 jsconfig.json

  • 解决:在根目录创建 jsconfig.json

    JSON

    {
      "compilerOptions": {
        "baseUrl": ".",
        "paths": { "@/*": ["src/*"] }
      },
      "include": ["src/**/*"]
    }
    

    设置完后记得重启 VS Code。


5. 最佳实践:父组件调用

使用 Vue 3 的多 v-model 特性,代码语义最清晰:

HTML

<template>
  <div class="page">
    <MonacoEditor 
      v-model="codeContent" 
      v-model:language="currentLang" 
    />
    
    <button @click="runCode">运行</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import MonacoEditor from '@/components/MonacoEditor/index.vue'

const codeContent = ref('SELECT * FROM users;')
const currentLang = ref('sql') // 切换下拉框会自动更新此变量

const runCode = () => {
  console.log(`正在运行 ${currentLang.value} 代码:`, codeContent.value)
}
</script>

uni-app使用非uni_modules的ucharts组件,本地运行没问题,部署到线上出问题?

问题背景:使用非uni_modules的ucharts组件,本地运行没用问题,发布为h5后未见报错,但ucharts却始终出不来。

步骤复现

  1. 手上有个需求,需要使用uni-app开发微信小程序,初始时使用h5作为演示系统,需求里面存在图表展示功能。这时,网上去找对应适合的charts组件,发现ucharts可以用于移动端图表展示。

  2. 引入官方非uni_modules组件(官方有说明文档),进行开发。 image.png

  3. 按照步骤引入qiun-data-charts.vue组件后,本地运行可以正常出来,也没见报错,这时build为h5后发布到服务器,神奇的一幕出现了,charts竟然出不来!!!赶紧某度,甚至上了AI,但都没解决问题,后面我就琢磨是否配置有问题,比如opts或者eopts出问题了,但对比了一下官方api均未发现问题,只能去翻ucharts源码。

  4. 看了一下源码后发现有一段代码用到了路径,就怀疑是不是路径解析出了问题,加了个打印再次部署查看,发现果真多了个./ 然后去找了一下为什么会多出这个东西,原因是打包的时候配置了指定资源打包路径,重新修改ucharts资源路径之后就可以正常出来了。 image.pngimage.pngimage.png

以上内容仅供参考

前端工程化 - Vite初始化Vue项目及代码规范配置

前端工程化是通过工具和规范,提升开发效率、代码质量和团队协作的系统化方案。大致包含以下内容:

  • 代码规范
  • Git Hooks
  • 环境变量
  • 构建优化

本文内容包含

  1. 使用 vite 创建 vue 项目
  2. 配置代码规范及相关格式化

一、使用vite创建vue项目

初始化项目

pnpm create vue

图片

按需完善项目结构

图片

设置别名

修改vite.config.ts

import path from 'path'

...
resolve: {
    alias: {
        '@': path.resolve(__dirname, 'src'),
    }
}
...

修改tsconfig.app.json

{
    "compilerOptions": {
        "baseUrl": ".",
        "paths": {
            "@/*": ["src/*"]
        },
    }
}

为项目添加自动导入

pnpm add -D unplugin-auto-import unplugin-vue-components

修改vite.config.ts

import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'

export default defineConfig({
    plugins: [
        vue(),
        // 新增
        AutoImport({
            imports: ['vue'],
            dts: './src/auto-imports.d.ts',
            eslintrc: {
                enabled: true,
                filepath: './src/.eslintrc-auto-import.json',
            }
        }),
        // 新增
        Components({
            dirs: ['src/components'],
            extensions: ['vue'],
            deep: true,
            dts: './src/components.d.ts',
            resolvers: []
        })
    ]
})

修改tsconfig.app.json

{
  ...
  "include": [
    "src/**/*.ts",
    "src/**/*.tsx",
    "src/**/*.vue",
    "src/auto-imports.d.ts",   // 新增
    "src/components.d.ts"     // 新增
  ]
}

二、配置代码规范及相关格式化

配置格式化校验

统一代码风格,自动检查常见错误和潜在问题

  • ESLint: 代码质量检查(语法、最佳实践)

    ESLint 9.x 不再支持 .eslintrc.*,需要使用新的扁平配置格式 eslint.config.js

  • Prettier: 代码格式化(缩紧、引号、分号等)

  • 依赖:

pnpm add -D \
eslint \
@typescript-eslint/parser \
@typescript-eslint/eslint-plugin \
eslint-plugin-vue \
@eslint/js \
vue-eslint-parser \
prettier \
eslint-config-prettier \
eslint-plugin-prettier
  • 配置文件: eslint.config.js.prettierrc.cjs.prettierignore
  • 脚本:在package.json中添加检验和格式化命令

添加eslint.config.js

import js from '@eslint/js'
import tsPlugin from '@typescript-eslint/eslint-plugin'
import tsParser from '@typescript-eslint/parser'
import vueParser from 'vue-eslint-parser'
import vuePlugin from 'eslint-plugin-vue'
import prettierConfig from 'eslint-config-prettier'
import prettierPlugin from 'eslint-plugin-prettier'

exportdefault [
    // 基础配置
    js.configs.recommended,

    // 全局忽略
    {
        ignores: ['node_modules/**', 'dist/**', '*.config.*', 'pnpm-lock.yaml'],
    },

    // vue文件配置
    {
        files: ['**/*.vue'],
        languageOptions: {
            parser: vueParser,
            parserOptions: {
                parser: tsParser,
                ecmaVersion: 'latest',
                sourceType: 'module',
            },
            globals: {
                console: 'readonly',
                process: 'readonly',
            },
        },
        plugins: {
            vue: vuePlugin,
            '@typescript-eslint': tsPlugin,
            prettier: prettierPlugin,
        },
        /**
         * "off" 或 0    ==>  关闭规则
         * "warn" 或 1   ==>  打开的规则作为警告(不影响代码执行)
         * "error" 或 2  ==>  规则作为一个错误(代码不能执行,界面报错)
         */
        rules: {
            ...prettierConfig.rules,

            // eslint 规则
            'no-var': 'error', // 要求使用 let 或 const 而不是 var
            'no-multiple-empty-lines': ['error', { max: 1 }], // 不允许多个空行
            'prefer-const': 'off', // 使用 let 关键字声明但在初始分配后从未重新分配的变量,要求使用 const
            'no-use-before-define': 'off', // 禁止在 函数/类/变量 定义之前使用它们
            'no-param-reassign': ['error', { props: false }], // 禁止修改函数参数
            'max-classes-per-file': 'off', // 禁止类超过一个文件

            // typescript 规则
            '@typescript-eslint/no-unused-vars': 'error', // 禁止定义未使用的变量
            '@typescript-eslint/no-empty-function': 'error', // 禁止空函数
            '@typescript-eslint/prefer-ts-expect-error': 'error', // 禁止使用 @ts-ignore
            '@typescript-eslint/ban-ts-comment': 'error', // 禁止 @ts-<directive> 使用注释或要求在指令后进行描述
            '@typescript-eslint/no-inferrable-types': 'off', // 禁止对初始化为数字、字符串或布尔值的变量或参数进行显式类型声明
            '@typescript-eslint/no-namespace': 'off', // 禁止使用 namespace 声明
            '@typescript-eslint/no-explicit-any': 'off', // 禁止使用 any 类型
            '@typescript-eslint/ban-types': 'off', // 禁止使用 any 类型
            '@typescript-eslint/no-var-requires': 'off', // 禁止使用 require 语句
            '@typescript-eslint/no-non-null-assertion': 'off', // 禁止使用 ! 断言
            '@typescript-eslint/no-use-before-define': [
              'error',
              {
                functions: false,
              },
            ],

            // vue 规则
            // 'vue/script-setup-uses-vars': 'error', // 要求在 script setup 中使用已定义的变量
            'vue/v-slot-style': 'error', // 要求 v-slot 指令的写法正确
            'vue/no-mutating-props': 'error', // 禁止修改组件的 props
            'vue/custom-event-name-casing': 'error', // 要求自定义事件名称符合 kebab-case 规范
            'vue/html-closing-bracket-newline': 'off', // 要求 HTML 闭合标签换行
            'vue/attribute-hyphenation': 'error', // 对模板中的自定义组件强制执行属性命名样式:my-prop="prop"
            'vue/attributes-order': 'off', // vue api使用顺序,强制执行属性顺序
            'vue/no-v-html': 'off', // 禁止使用 v-html
            'vue/require-default-prop': 'off', // 此规则要求为每个 prop 为必填时,必须提供默认值
            'vue/multi-word-component-names': 'off', // 要求组件名称始终为 “-” 链接的单词
            'vue/no-setup-props-destructure': 'off', // 禁止解构 props 传递给 setup
            'vue/max-len': 0, // 强制所有行都小于 80 个字符
            'vue/singleline-html-element-content-newline': 0, // 强制单行元素的内容折行
            
            // Prettier 规则
            'prettier/prettier': 'error', // 强制使用 prettier 格式化代码
        }
    },
    // js文件配置
    {
        files: ['**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts}'],
        languageOptions: {
            parser: tsParser,
            parserOptions: {
                ecmaVersion: 'latest',
                sourceType: 'module',
            },
            globals: {
                console: 'readonly',
                process: 'readonly',
            }
        },
        plugins: {
            '@typescript-eslint': tsPlugin,
            prettier: prettierPlugin,
        },
        rules: {
            ...prettierConfig.rules,

            // eslint 规则
            'no-var': 'error', // 要求使用 let 或 const 而不是 var
            'no-multiple-empty-lines': ['error', { max: 1 }], // 不允许多个空行
            'prefer-const': 'off', // 使用 let 关键字声明但在初始分配后从未重新分配的变量,要求使用 const
            'no-use-before-define': 'off', // 禁止在 函数/类/变量 定义之前使用它们
            'prettier/prettier': 'error', // 强制使用 prettier 格式化代码

            // TypeScript 规则
            '@typescript-eslint/no-unused-vars': 'error',
            '@typescript-eslint/no-empty-function': 'error',
            '@typescript-eslint/prefer-ts-expect-error': 'error',
            '@typescript-eslint/ban-ts-comment': 'error',
            '@typescript-eslint/no-inferrable-types': 'off',
            '@typescript-eslint/no-namespace': 'off',
            '@typescript-eslint/no-explicit-any': 'off',
            '@typescript-eslint/ban-types': 'off',
            '@typescript-eslint/no-var-requires': 'off',
            '@typescript-eslint/no-non-null-assertion': 'off',
            '@typescript-eslint/no-use-before-define': [
                'error',
                {
                    functions: false,
                },
            ],
            
            'prettier/prettier': 'error',
        }
    }
]

添加.prettierrc.cjs

/**
 * Prettier 代码格式化配置
 * 文档:https://prettier.io/docs/en/configuration.html
 */
module.exports= {
  // 是否在语句末尾添加分号
  semi: false,
  // 是否使用单引号
  singleQuote: true,
  // 设置缩进
  tabWidth: 2,
  // 尾随逗号
  trailingComma: 'es5',
  // 每行最大字符数
  printWidth: 120,
  // 箭头函数参数括号: avoid( 避免 ) | always( 总是 )
  arrowParens: 'avoid',
  // 文件行尾: lf( 换行 ) | crlf( 回车换行 ) | auto( 自动 )
  endOfLine: 'lf',
}

添加.prettierignore

node_modules
dist
*.specstory
*.local
pnpm-lock.yaml
package-lock.json
.DS_Store
coverage
.vscode
.idea
public

package.json中添加相关scripts

...
"scripts": {
  ...
    "lint": "eslint . --fix",
    "format": "prettier --write "src/**/*.{js,ts,vue,json,css,scss,md}"",
    "lint:check": "eslint .",
    "format:check": "prettier --check "src/**/*.{js,ts,vue,json,css,scss,md}""
},
...

配置css格式校验及其他

  • Stylelint: css/scss样式校验和格式化,统一样式代码风格,发现样式错误
  • EditorConfig: 统一编辑器配置,保证跨编辑器保持一致的编码风格
  • Commitlint: Git 提交信息格式校验,规范提交信息,便于追踪和生成changeling
  • Husky + lint-staged: Git hooks 自动化校验,代码提交前自动检查,避免提交不符合规范的代码
  1. 安装相关依赖

    # 基础依赖(必需)
    # stylelint-config-html: HTML/Vue模板样式格式化
    # stylelint-config-recess-order: css属性书写顺序
    # stylelint-config-recommended-vue: Vue推荐配置
    pnpm add -D \
      stylelint \
      stylelint-config-standard \
      stylelint-config-standard-vue \
      stylelint-config-prettier \
      stylelint-config-html \ 
      stylelint-config-recess-order \  
      stylelint-config-recommended-vue \ 
      @commitlint/cli \
      @commitlint/config-conventional \
      husky \
      lint-staged \
      postcss-html 
    
    
    # 可选依赖(根据项目需要)
    # 如果使用 Tailwind CSS
    pnpm add -D stylelint-config-tailwindcss
    
    # 如果使用SCSS
    pnpm add -D stylelint-config-standard-scss stylelint-scss
    
  1. 创建.stylelintrc.cjs

    module.exports= {
      // 继承规则
      extends: [
        'stylelint-config-standard', // 配置 stylelint 拓展插件
        'stylelint-config-html/vue', // 配置 vue 中 template 样式格式化
        'stylelint-config-recess-order', // 配置 stylelint css 属性书写顺序插件,
        'stylelint-config-standard-scss', // 配置 stylelint scss 插件
        'stylelint-config-recommended-vue/scss', // 配置 vue 中 scss 样式格式化
        'stylelint-config-tailwindcss',
      ],
      overrides: [
        // 扫描 .vue/html 文件中的 <style> 标签内的样式
        {
          files: ['**/*.{vue,html}'],
          // 使用 postcss-html 解析器
          customSyntax: 'postcss-html',
        },
      ],
      rules: {
        'keyframes-name-pattern': null, // 强制关键帧名称的格式
        'custom-property-pattern': null, // 强制自定义属性的格式
        'selector-id-pattern': null, // 强制选择器 ID 的格式
        'declaration-block-no-redundant-longhand-properties': null, // 禁止冗余的长属性
        'function-url-quotes': 'always', // URL 的引号 "always(必须加上引号)"|"never(没有引号)"
        'color-hex-length': 'long', // 指定 16 进制颜色的简写或扩写 "short(16进制简写)"|"long(16进制扩写)"
        'rule-empty-line-before': 'never', // 要求或禁止在规则之前的空行 "always(规则之前必须始终有一个空行)"|"never(规则前绝不能有空行)"|"always-multi-line(多行规则之前必须始终有一个空行)"|"never-multi-line(多行规则之前绝不能有空行)"
        'font-family-no-missing-generic-family-keyword': null, // 禁止在字体族名称列表中缺少通用字体族关键字
        'property-no-unknown': null, // 禁止未知的属性
        'no-empty-source': null, // 禁止空源码
        'selector-class-pattern': null, // 强制选择器类名的格式
        'value-no-vendor-prefix': null, // 关闭 vendor-prefix (为了解决多行省略 -webkit-box)
        'no-descending-specificity': null, // 不允许较低特异性的选择器出现在覆盖较高特异性的选择器
        // 禁止未知的伪类
        'selector-pseudo-class-no-unknown': [
          true,
          {
            ignorePseudoClasses: ['global', 'v-deep', 'deep'],
          },
        ],
        // 禁止未知的 at-rule
        'scss/at-rule-no-unknown': [
          true,
          {
            ignoreAtRules: ['tailwind', 'apply'],
          },
        ],
        // 禁止未知的函数
        'function-no-unknown': [
          true,
          {
            ignoreFunctions: ['constant'],
          },
        ],
      },
      ignoreFiles: ['**/*.js', '**/*.ts', '**/*.jsx', '**/*.tsx', 'node_modules/**', 'dist/**'],
    }
    
  1. 创建.editorconfig

    # EditorConfig 是帮助多个编辑器和 IDE 维护一致的编码样式的配置文件
    # https://editorconfig.org
    
    root = true
    
    [*] # 表示所有文件适用
    charset = utf-8 # 设置文件字符集为 utf-8
    end_of_line = lf # 设置文件行尾为 LF
    indent_style = space # 缩进风格(tab | space)
    indent_size = 2 # 缩进大小
    insert_final_newline = true # 在文件末尾插入一个新行
    trim_trailing_whitespace = true # 删除行尾的空格
    max_line_length = 130 # 最大行长度
    
    [*.md] # 表示仅对 md 文件适用以下规则
    max_line_length = off # 关闭最大行长度限制
    trim_trailing_whitespace = false # 关闭末尾空格修剪
    
    [*.{yml,yaml}]
    indent_size = 2 # 设置 yaml 文件的缩进大小为 2
    
    [Makefile]
    indent_style = tab # 设置 Makefile 文件的缩进风格为 tab
    
  1. 创建commitlint.config.js文件

    exportdefault {
        extends: ['@commitlint/config-conventional'],
        rules: {
            'type-enum': [
                2,
                'always',
                [
                    'feat', // 新功能
                    'fix', // 修复问题
                    'docs', // 文档更新
                    'style', // 代码格式(不影响代码运行的变动)
                    'refactor', // 重构代码(既不是新增功能,也不是修复问题的代码变动)
                    'perf', // 性能优化
                    'test', // 添加测试
                    'chore', // 构建过程或辅助工具的变动
                    'build', // 打包
                    'ci', // CI配置
                    'revert', // 回退
                    'release', // 发布
                    'wip', // 开发中
                ]
            ],
            // 类型必须小写
            'type-case': [
                2,
                'always',
                'lower-case'
            ],
            // 类型不能为空
            'type-empty': [2, 'never'],
            // 作用域必须小写
            'scope-case': [
                2,
                'always',
                'lower-case'
            ],
            // 主题必须小写
            'subject-case': [
                2,
                'always',
                'lower-case'
            ],
            // 头部最大长度为 100 个字符
            'header-max-length': [
                2,
                'always',
                100
            ],
            // 主体前面必须有一个空行
            'body-leading-blank': [
                2,
                'always'
            ],
        }
    }
    
  1. 创建.lintstagedrc.js

    exportdefault {
      '*.{js,jsx,ts,tsx,vue}': ['eslint --fix', 'prettier --write'],
      '*.{css,scss,less,styl}': ['stylelint --fix', 'prettier --write'],
      '*.{json,md,yml,yaml}': ['prettier --write'],
    }
    
  1. 更新package.json

    {
    ...
    "scripts": {
        "lint": "eslint . --fix",
        "format": "prettier --write "src/**/*.{js,ts,vue,json,css,scss,md}"",
        "lint:check": "eslint .",
        "format:check": "prettier --check "src/**/*.{js,ts,vue,json,css,scss,md}"",
    
        "lint:style": "stylelint "**/*.{css,scss,vue}" --fix",
        "lint:style:check": "stylelint "**/*.{css,scss,vue}"",
    
        "type-check": "vue-tsc --noEmit",
    
        "check": "pnpm lint:check && pnpm format:check && pnpm lint:style:check && pnpm type-check",
        "fix": "pnpm lint && pnpm format && pnpm lint:style",
    
        "prepare": "husky install"
    },
    ...
    }
    
  2. 初始化Husky(Git Hooks)

    pnpm prepare
    

    这会在根目录下生成.husky目录,其中包含了_子目录,将子目录下的commit-msgpre-commit文件拷贝到.husky目录下,并修改文件内容如下:

    .husky/commit-msg文件内容

    #!/usr/bin/env sh
    . "$(dirname -- "$0") /_/husky.sh"
    
    npx --no -- commitlint --edit $1
    

    .husky/pre-commit文件内容

    #!/usr/bin/env sh
    . "$(dirname -- "$0")/_/husky.sh"
    
    pnpm lint-staged
    
  3. 验证配置文件语法

    如果某些验证失败,请检查:

    • 依赖是否已正确安装

    • 配置文件语法是否正确

    • 文件路径是否正确

    # 1. 验证 ESLint 配置
    pnpm exec eslint --print-config src/App.vue > /dev/null && echo "✅ ESLint 配置正确" || echo "❌ ESLint 配置有误"
    
    # 2. 验证 Prettier 配置
    pnpm exec prettier --check . > /dev/null 2>&1 && echo "✅ Prettier 配置正确" || echo "⚠️  Prettier 发现格式问题(这是正常的)"
    
    # 3. 验证 Stylelint 配置
    pnpm exec stylelint --print-config src/style.css > /dev/null && echo "✅ Stylelint 配置正确" || echo "❌ Stylelint 配置有误"
    
    # 4. 验证 Commitlint 配置
    pnpm exec commitlint --help > /dev/null && echo "✅ Commitlint 已安装" || echo "❌ Commitlint 未安装"
    
    # 5. 验证 TypeScript 配置
    pnpm exec vue-tsc --version && echo "✅ vue-tsc 已安装" || echo "❌ vue-tsc 未安装"
    

    图片

  1. 运行检查命令

    # 1. 检查代码格式(ESLint)
    pnpm lint:check
    
    # 2. 检查代码格式(Prettier)
    pnpm format:check
    
    # 3. 检查样式格式(Stylelint)
    pnpm lint:style:check
    
    # 4. 检查 TypeScript 类型
    pnpm type-check
    
    # 5. 综合检查(运行所有检查)
    pnpm check
    
    # 6. 自动修复
    pnpm fix
    

配置文件保存时自动格式化

  1. 安装相关插件
    • Prettier - Code formatter
    • ESLint
    • Stylelint
    • Volar
    • TypeScript Vue Plugin

图片

  1. 创建.vscode/setting.json

    {
      // 编辑器基础配置
      "editor.formatOnSave": true,
      "editor.defaultFormatter": "esbenp.prettier-vscode",
      "editor.codeActionsOnSave": {
        "source.fixAll.eslint": "explicit",
        "source.fixAll.stylelint": "explicit"
      },
    
      // Vue 文件特殊配置 - 使用 Volar 格式化
      "[vue]": {
        "editor.defaultFormatter": "Vue.volar",
        "editor.formatOnSave": true,
        "editor.codeActionsOnSave": {
          "source.fixAll.eslint": "explicit",
          "source.fixAll.stylelint": "explicit"
        }
      },
    
      // Volar 配置
      "volar.formatting.printWidth": 120,
      "volar.formatting.singleQuote": true,
      "volar.formatting.semi": false,
      "volar.formatting.tabSize": 2,
      "volar.formatting.trailingComma": "es5",
      "volar.formatting.arrowParens": "avoid",
      "volar.formatting.endOfLine": "lf",
    
      // 或者使用 Prettier 格式化 Vue(需要配置)
      // "[vue]": {
      //   "editor.defaultFormatter": "esbenp.prettier-vscode",
      //   "editor.formatOnSave": true
      // },
    
      // 文件类型特定配置
      "[javascript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[javascriptreact]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[typescript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[typescriptreact]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[json]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[jsonc]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[css]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[scss]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[less]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[html]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[markdown]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
    
      // ESLint 配置
      "eslint.enable": true,
      "eslint.validate": [
        "javascript",
        "javascriptreact",
        "typescript",
        "typescriptreact",
        "vue"
      ],
      "eslint.format.enable": true,
      "eslint.codeAction.showDocumentation": {
        "enable": true
      },
    
      // Stylelint 配置
      "stylelint.enable": true,
      "stylelint.validate": [
        "css",
        "scss",
        "less",
        "vue"
      ],
    
      // Prettier 配置
      "prettier.enable": true,
      "prettier.requireConfig": true,
      "prettier.configPath": ".prettierrc.cjs",
    
      // 使用 Prettier 格式化 Vue(如果使用 Prettier 而不是 Volar)
      "prettier.documentSelectors": ["**/*.vue"],
    
      // 其他编辑器配置
      "files.eol": "\n",
      "files.insertFinalNewline": true,
      "files.trimTrailingWhitespace": true,
      "files.encoding": "utf8",
    
      // Vue 相关配置 - 禁用 Vetur(如果安装了)
      "vetur.format.enable": false,
      "vetur.validation.template": false,
      "vetur.validation.script": false,
      "vetur.validation.style": false,
    
      // TypeScript 配置
      "typescript.tsdk": "node_modules/typescript/lib",
      "typescript.enablePromptUseWorkspaceTsdk": true
    }
    
  1. 验证配置

    打开任意.vuets.js文件,故意写一些格式不规范的代码(例如:多余空格,缺少分号等),保存文件,检查代码是否自动格式化

常见问题:

1. 保存时格式化不生效

  • 检查 VSCode 扩展是否已安装
  • 检查 .vscode/settings.json 是否正确配置
  • 重启 VSCode 或重新加载窗口

2. ESLint 报错找不到模块

  • 运行 pnpm install 重新安装依赖
  • 检查 eslint.config.js 中的导入路径

3. Git Hooks 不生效

  • 检查 .husky/pre-commit.husky/commit-msg 文件是否存在且可执行
  • 运行 chmod +x .husky/pre-commit .husky/commit-msg 添加执行权限

总结

通过以上配置,我们已经为 Vue 3 + TypeScript + Vite 项目搭建了完整的代码规范体系:

代码质量检查:ESLint + TypeScript 类型检查

代码格式化:Prettier

样式规范:Stylelint + EditorConfig

提交规范:Commitlint + Husky + lint-staged

开发体验:VSCode 保存自动格式化

配置清单

项目根目录下应包含以下配置文件:

  • eslint.config.js - ESLint 配置
  • .prettierrc.cjs - Prettier 配置
  • .prettierignore - Prettier 忽略文件
  • .stylelintrc.cjs - Stylelint 配置
  • .editorconfig - 编辑器配置
  • commitlint.config.js - Commitlint 配置
  • .lintstagedrc.js - lint-staged 配置
  • .husky/pre-commit - Git pre-commit hook
  • .husky/commit-msg - Git commit-msg hook
  • .vscode/settings.json - VSCode 工作区配置

相关资源

📦 完整示例: GitHub 仓库地址

【性能优化】响应式图片

引言

在现代 Web 开发中,图片往往占据了页面总资源的 50% 以上。在移动设备和高分辨率屏幕普及的今天,如何让用户以最小的带宽成本获得最优的视觉体验,是性能优化中的关键课题。响应式图片正是解决这一问题的核心技术方案。

手段(从手段上通过格式优化与按需加载选图)

  1. 格式优化:优先使用现代高效格式(AVIF、WebP),不支持则降级到传统格式(JPG、PNG)
  2. 按需加载:根据图片显示尺寸(槽位分段适配)/视口宽度(视口分段适配)、设备像素比(DPR)选择最合适的图片资源

作用(从作用上减少带宽浪费、提升加载速度)

  1. 减少带宽浪费:避免在小屏设备上加载过大的图片,避免在低 DPR 设备上加载高清图片
  2. 提升加载速度:更小的图片体积意味着更快的首屏渲染和更流畅的用户体验

一、槽位适配(Slot-based Adaptation)

定义:以分段适配的方式,预先提供多档尺寸、多档 DPR 的候选图片,根据图片在页面中的实际显示宽度、**设备像素比(DPR)**选择最合适的一档资源。

技术实现:通过 HTML 的 <picture> + srcset + sizes 属性实现。

核心特性

  • srcset 定义了多个候选图片地址及宽度描述符(如 image-400w.jpg 400w
  • sizes 由多组「媒体条件 + 源尺寸值」组成,描述在不同视口下的槽位宽度(如 (max-width: 600px) 100vw, 50vw
  • 浏览器根据 媒体条件源尺寸值 得出槽位宽度,再结合 设备像素比(DPR)宽度描述符 选择最合适的图片

示例代码

① 槽位分段 + 多格式(完整用法):

<picture>
  <source 
    type="image/avif" 
    srcset="/images/hero-400w.avif 400w, /images/hero-800w.avif 800w, /images/hero-1200w.avif 1200w"
    sizes="(max-width: 600px) 100vw, 50vw"
  />
  <source 
    type="image/webp" 
    srcset="/images/hero-400w.webp 400w, /images/hero-800w.webp 800w, /images/hero-1200w.webp 1200w"
    sizes="(max-width: 600px) 100vw, 50vw"
  />
  <img 
    src="/images/hero-400w.jpg" 
    srcset="/images/hero-400w.jpg 400w, /images/hero-800w.jpg 800w, /images/hero-1200w.jpg 1200w"
    sizes="(max-width: 600px) 100vw, 50vw"
    alt="Hero Image"
  />
</picture>

② DPR + 格式适配(srcsetx 描述符,picture 做格式切换):

<picture>
  <source type="image/avif" srcset="/images/hero.avif 1x, /images/hero@2x.avif 2x" />
  <source type="image/webp" srcset="/images/hero.webp 1x, /images/hero@2x.webp 2x" />
  <img 
    src="/images/hero.jpg" 
    srcset="/images/hero.jpg 1x, /images/hero@2x.jpg 2x" 
    alt="Hero Image"
  />
</picture>

二、视口适配(Viewport-based Adaptation)

定义:以分段适配的方式,结合格式选择(AVIF/WebP)、媒体查询(视口宽度、设备像素比 DPR)为每一档指定图片,浏览器根据当前视口、DPR 及格式支持情况匹配到对应的一档并加载该资源。

技术实现

  • 视口 × DPR 分段:通过 CSS 媒体查询 @media 实现
  • 格式选择(AVIF/WebP 等):依赖 JS 检测——在应用启动时探测浏览器支持情况,给 <html> 添加 .avif.webp 等 class,再由 CSS 选择器覆盖对应格式的背景图。CSS 的 @supports 对图片格式不可靠,故采用 JS + class 方案

示例代码(视口 × DPR 分段 + 格式覆盖):

① 格式检测并往 document 加 class(仅示例 AVIF):

// 用 1x1 AVIF data URI 探测,支持则在根节点加 .avif
const avifDataUri = 'data:image/avif;base64,AAAAIGZ0eXBhdmlm...'
const img = new Image()
img.onload = () => { if (img.width > 0) document.documentElement.classList.add('avif') }
img.onerror = () => {}
img.src = avifDataUri

② 视口 × DPR 分段 + 用 .avif 覆盖格式:

.hero {
  // 降级格式(JPG):视口 × DPR
  @media (width >= 0) {
    @media (resolution >= 1dppx) {
      background-image: url('/images/hero-400w.jpg');
    }
  }
  @media (width >= 768px) {
    @media (resolution >= 2dppx) {
      background-image: url('/images/hero-800w@2x.jpg');
    }
  }

  .avif & {
    @media (width >= 0) {
      @media (resolution >= 1dppx) {
        background-image: url('/images/hero-400w.avif');
      }
    }
    @media (width >= 768px) {
      @media (resolution >= 2dppx) {
        background-image: url('/images/hero-800w@2x.avif');
      }
    }
  }
}

三、槽位适配与视口适配的对比

3.1 槽位适配更细腻精确

槽位适配在媒体查询的基础上,还依据图片在页面中的实际显示宽度选图,形成“二维精准匹配”。例如:视口 1920px 时,若图片只占 50vw(960px),通过 sizes="50vw" 浏览器会选约 1000w 的图,而视口适配只能按 1920px 选图,容易造成浪费。在复杂布局(多栏、网格等)中,这种差异更明显。

3.2 适用场景不同

槽位适配适用于页面中某些槽位的显示尺寸会随视口变化而变化的场景(如多栏、网格、响应式布局中宽度不固定的图片区域)。通过 sizes 声明各视口下的槽位宽度,浏览器按实际显示宽度选图,避免大视口下小槽位仍加载大图。

视口适配则适合整体随视口缩放的场景(如全屏头图、整页背景),只按视口宽度与 DPR 分段选图,不关心图片在页面中的实际占位大小。该方案在 H5 / 小程序 等移动端页面中应用广泛。

3.3 背景图

槽位适配依赖 HTML 的 sizes 来声明“图片在不同视口下的实际宽度”。CSS 的 background-image 没有等价语法,无法描述“背景在容器中的显示宽度”,只能通过媒体查询获知视口宽度与 DPR。因此背景图无法做槽位式选图,只能采用视口分段适配。

Vue 组件 API 覆盖率工具

前言

在组件开发中,我们经常面临一个问题:组件测试是否真正覆盖了组件的所有 API,传统的代码覆盖率工具只能告诉我们代码行数的覆盖情况,却无法准确反映组件的 Props、Events、Slots 和 Exposed Methods 是否被充分测试。

传统代码覆盖率的局限性

传统的代码覆盖率工具(如 Istanbul、nyc 等)虽然能够统计代码行的执行情况,但在组件测试场景下存在明显的不足。它们无法检查出以下这些问题:

  • 无法追踪对象的某个 key 是否被使用:这是根本性的限制。传统工具只能知道某行代码被执行了,但无法精确追踪对象的哪些属性被访问。例如,组件的 props 对象被传递了,但不知道具体哪些 prop 键被使用
  • 无法 测试 是否遗漏了 Slots 的 TS 类型定义:组件有测试 slots 的功能,但没有声明 slots 的类型
  • 无法找出是否存在冗余的 Props:定义了某些 props,但未被实际使用
  • 无法检查 Props 的所有枚举值是否都 测试 :例如 type: 'primary' | 'ghost' | 'dashed' 这种联合类型,可能只测试了 'primary',而遗漏了其他变体
  • 无法检查 Props 的所有类型是否都 测试 :例如 value 可能接受 Boolean | String | Number | Array,但测试中只传了字符串

这些问题在组件库开发中尤为突出。一个看似 90% 代码覆盖率的组件,实际上可能有大量未经测试的 API 边界情况。传统覆盖率工具基于代码执行行数统计,而组件 API 测试需要的是基于类型系统和对象属性的精确追踪

实践中的困境

在早期做公司内部组件库的时候,我们也开启过一轮对组件 API 覆盖率的人工检查。然而,由于组件 API 过多,检查过程极其困难,最终总会有许多漏写的单元测试。人工核对的方式不仅效率低下,而且容易遗漏,标准也难以统一。

为了解决这个问题,我在半年前用 AI 开发了 vc-api-coverage,一个专门为 Vue 3 TSX 组件设计的 API 覆盖率分析工具。本文将深入剖析这个工具的技术实现原理,分享如何利用 TypeScript 类型系统和 AST 分析来实现精准的 API 覆盖率检测。

核心设计思路

这个工具的核心理念是:通过静态分析组件定义和 测试 代码,建立组件 API 与测试用例之间的映射关系

设计理念

在大学学过的一门项目管理课程中,讲到了"设备点检",这是一种预防性设备维护管理制度。通过定期、定点、定标、定人、定法的方式对设备进行检查,以确保设备正常运行。

这个覆盖率工具的设计思路与"设备点检"有异曲同工之妙,主要对定标、定法、定期这3个环节进行了强化:

  • 定标:将原本模糊、可完成可不完成的测试标准,变成一个明确、量化、强制的标准(如:100% API 覆盖率)。
  • 定法:将对api覆盖率的手动检查,变成程序自动化的检查。
  • 定期:将原本一次性的检查,变成CI流水线的周期检查。

通过工具化的方式,我们把主观的人工检查转变为客观的自动化检测,把模糊的质量要求转变为精确的量化指标。

整体架构

整体架构分为三个核心模块:

  1. ComponentAnalyzer:分析组件定义,提取所有可用的 API
  2. UnitTestAnalyzer:分析测试代码,识别哪些 API 被测试覆盖
  3. Reporter:生成可视化的覆盖率报告(CLI、HTML、JSON)

image.png

技术选型

在开始介绍具体实现之前,先分享一下技术选型过程中的弯路和思考。

早期方案

最初设计这个覆盖率工具时,我的想法是通过 AST(抽象语法树)去分析组件代码,直接提取出 Props、Slots 和 Exposed Methods。这个方案看起来很直接,但在实践中遇到了巨大的挑战:

Vue 组件的写法复杂多变,静态分析难以覆盖所有场景:

  1. 多种 API 风格:组件既可以用 Composition API 的 setup 写法,也可以用 Options API 写法
  2. 运行时配置:组件可能配置了 mixinsextends 等,这些内容需要递归分析多个文件
  3. 动态计算的 Props:有些组件的 props 需要运行时才能确定,例如使用 lodash.pick 从另一个对象选取部分 props:
import { pick } from 'lodash';
const baseProps = { a: String, b: Number, c: Boolean };
const componentProps = pick(baseProps, ['a', 'b']); // 静态分析无法得知结果

4. 类型信息丢失:纯 AST 分析只能看到代码结构,很难准确推断出 union 类型、可选属性等类型信息

经过几次尝试,发现要覆盖所有 Vue 组件的写法,需要实现一个接近完整的 Vue 编译器,这显然不现实。

最终方案

后来换了一个思路:既然 Vue 3 组件本身就有完整的类型定义,为什么不直接利用 TypeScript 的类型系统呢?

这个方案的优势非常明显:

  • 统一的接口:无论组件怎么写(setup、options、mixins),最终都会生成统一的组件类型,TypeScript 编译器已经帮我们处理好了所有复杂情况
  • 完整的类型信息:可以直接获取 union 类型、可选属性、泛型参数等完整的类型信息
  • 简单快捷:通过 InstanceType<typeof Component>['$props'] 就能获取所有 props,无需关心组件内部实现
  • 零维护成本:随着 Vue 版本升级,只要类型定义更新了,工具就能自动适配

这就是为什么最终选择了"类型系统 + AST"的混合方案:

  • 类型系统提取 Props、Events、Slots(简单可靠)
  • AST 提取单元测试代码(类型系统无法覆盖的场景)

技术选型的启示:不要试图重新实现已有的轮子。TypeScript 编译器已经解决了类型推断的复杂问题,我们应该站在巨人的肩膀上。

技术实现详解

组件 API 提取

组件的 Props、Events 和 Slots 信息隐藏在 Vue 组件的类型定义中,见TS Playground示例。我们利用 ts-morph 库来访问 TypeScript 的类型系统:

1. Props/Events 提取

 // src/analyzer/ComponentAnalyzer.ts:30
analyzePropsAndEmits(instanceType: Type, exportedExpression: Expression) {
    // 通过 $props 属性获取组件的所有 props
    const dollarPropsSymbol = instanceType.getProperty('$props');
    if (!dollarPropsSymbol) returnconst dollarPropsType = dollarPropsSymbol.getTypeAtLocation(exportedExpression);
    dollarPropsType.getProperties().forEach(propSymbol => {
        const propName = propSymbol.getName();
        // 过滤内部属性
        if (!internalProps.includes(propName)) {
            this.props.add(propName);
        }
    });
}

核心原理:Vue 3 组件通过 InstanceType<typeof Component>['$props'] 暴露了所有 props 的类型信息。我们直接访问这个类型,遍历其所有属性,就能获得完整的 props 列表。

2. Slots 提取

 // src/analyzer/ComponentAnalyzer.ts:157
analyzeSlots(instanceType: Type, exportedExpression: Expression) {
    const dollarPropsSymbol = instanceType.getProperty('$slots');
    if (!dollarPropsSymbol) returnconst dollarPropsType = dollarPropsSymbol.getTypeAtLocation(exportedExpression);
    dollarPropsType.getProperties().forEach(propSymbol => {
        const propName = propSymbol.getName();
        this.slots.add(propName);
    });
}

核心原理:与 props 类似,通过 $slots 属性获取所有插槽的类型定义。

3. Exposed Methods 提取

Exposed methods无法从 TypeScript 类型系统中获取,我们采用了 AST 代码分析的方法:

 // src/analyzer/ComponentAnalyzer.ts:176
analyzeExposeContextCalls() {
    // 方法1: 检测 expose({ ... }) 调用
    const matches = this.code.match(/expose(\s*{([^}]+)}\s*)/g);

    if (matches && matches.length > 0) {
        for (const match of matches) {
            const propsStr = match.replace(/expose(\s*{/, '').replace(/}\s*)/, '');
            const propMatches = propsStr.match(/(\w+),?/g);

            if (propMatches) {
                for (const prop of propMatches) {
                    const cleanProp = prop.replace(/,/g, '').trim();
                    if (cleanProp) {
                        this.exposes.add(cleanProp);
                    }
                }
            }
        }
    }
}
 // src/analyzer/ComponentAnalyzer.ts:202
analyzeExposeArrayOption(exportedExpression: Expression) {
    // 方法2: 检测 defineComponent({ expose: ['method1', 'method2'] })
    const componentOptions = this.getComponentOptions(exportedExpression);
    if (!componentOptions) return;

    const exposeArray = this.getExposeArrayFromOptions(componentOptions);
    if (!exposeArray) return;

    const exposeItems = exposeArray.getElements();
    for (const item of exposeItems) {
        const itemName = this.getItemName(item);
        if (itemName) {
            this.exposes.add(itemName);
        }
    }
}

核心原理

  1. 通过正则表达式匹配 expose({ ... }) 调用
  2. 通过 AST 分析 defineComponentexpose 选项
  3. 支持多种写法:字符串字面量、标识符、枚举值等

测试覆盖分析

测试代码有多种写法,我们需要支持各种常见的测试模式。

模式 1:传统 mount 方法

 // 测试代码
mount(Button, {
  props: { variant: 'primary', disabled: true },
  slots: { default: 'Click me' }
});
 // src/analyzer/UnitTestAnalyzer.ts:186
processMountComponent(componentArgNode: Node, optionsNode?: ObjectLiteralExpression) {
    if (!optionsNode) returnconst componentName = componentArgNode.getText();
    const componentFile = this.resolveComponentPath(componentArgNode as Identifier);

    if (!this.result[componentFile]) {
        this.result[componentFile] = {};
    }

    // 提取 props、emits、slots
    this.extractProps(optionsNode, this.result[componentFile]);
    this.extractEmits(optionsNode, this.result[componentFile]);
    this.extractSlots(optionsNode, this.result[componentFile]);
}

模式 2:JSX 写法

 // 测试代码
render(<Button variant="primary" disabled onClick={handler}>
  Click me
</Button>);
 // src/analyzer/UnitTestAnalyzer.ts:678
private analyzeJSXElements(callExpression: CallExpression) {
    const jsxElements = this.findJsxInCallExpression(callExpression);

    for (const jsxElement of jsxElements) {
        const openingElement = Node.isJsxElement(jsxElement)
            ? jsxElement.getOpeningElement()
            : jsxElement;

        const tagName = openingElement.getTagNameNode().getText();
        const filePath = this.resolveComponentPath(openingElement.getTagNameNode());

        // 提取 JSX 属性作为 props
        this.extractJSXAttrs(openingElement, this.result[filePath]);

        // 提取 JSX 子元素作为 slots
        if (Node.isJsxElement(jsxElement)) {
            this.extractJSXSlots(jsxElement, this.result[filePath]);
        }
    }
}

模式 3:Template 字符串

 // 测试代码
mount({
  template: '<Button variant="primary" @click="handler">Click me</Button>',
  components: { Button }
});
 // src/analyzer/UnitTestAnalyzer.ts:269
private extractPropsFromTemplate(template: string, componentTagName: string, componentTestUnit: TestUnit) {
    // 使用正则表达式解析模板中的属性
    const tagRegex = new RegExp(`<${componentTagName}(\s+[^>]*?)?>`, 'ig');
    let match;
    const propsFound: string[] = [];

    while ((match = tagRegex.exec(template)) !== null) {
        const attrsString = match[1];
        if (!attrsString) continue;

        // 解析属性名
        const attrRegex = /([@:a-zA-Z0-9_-]+)(?:=(?:"[^"]*"|'[^']*'|[^\s>]*))?/g;
        let attrMatch;
        while ((attrMatch = attrRegex.exec(attrsString)) !== null) {
            let propName = attrMatch[1];

            // 处理 v-bind:, :, v-model: 等前缀
            if (propName.startsWith(':')) {
                propName = propName.substring(1);
            } else if (propName.startsWith('v-bind:')) {
                propName = propName.substring(7);
            }

            propsFound.push(propName);
        }
    }

    componentTestUnit.props = [...new Set([...(componentTestUnit.props || []), ...propsFound])];
}

Exposed Methods 检测

对于暴露的方法,我们采用了一个简单但有效的策略:方法名匹配

 // src/analyzer/UnitTestAnalyzer.ts:1381
private analyzeExposedMethods(testCall: CallExpression) {
    const calledMethods = new Set<string>();

    // 查找所有属性访问表达式 (xxx.methodName)
    const propertyAccesses = testCall.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression);

    for (const access of propertyAccesses) {
        const methodName = access.getName();

        // 检查是否为暴露的方法
        if (this.isExposedMethod(methodName)) {
            calledMethods.add(methodName);
        }
    }

    // 将这些方法添加到组件的覆盖记录中
    for (const componentFile in this.result) {
        if (!this.result[componentFile].exposes) {
            this.result[componentFile].exposes = [];
        }
        for (const method of calledMethods) {
            if (!this.result[componentFile].exposes.includes(method)) {
                this.result[componentFile].exposes.push(method);
            }
        }
    }
}

核心原理:扫描测试代码中的所有属性访问表达式(如 wrapper.vm.focus()),提取方法名,然后过滤掉 Vue 内置方法和测试工具方法。

Strict Mode

在严格模式下,我们不仅检测 prop 是否被测试,还会检测每个 union 类型的变体是否都被测试。

 // src/analyzer/ComponentAnalyzer.ts:42
if (this.strictMode) {
    const propType = propSymbol.getTypeAtLocation(exportedExpression);
    const nonNullableType = propType.getNonNullableType();
    const variants = this.extractVariantsFromType(nonNullableType);

    if (variants.length > 0) {
        this.propsWithVariants.push({
            name: propName,
            variants
        });
    }
}

Union 类型展开

 // src/analyzer/ComponentAnalyzer.ts:62
private extractVariantsFromType(type: Type): PropVariant[] {
    const variants: PropVariant[] = [];

    if (type.isUnion()) {
        const unionTypes = type.getUnionTypes();

        for (const unionType of unionTypes) {
            // 跳过 undefined 和 null
            if (unionType.isUndefined() || unionType.isNull()) {
                continue;
            }

            const variant = this.getVariantFromType(unionType);
            if (variant) {
                // 跳过 false(boolean 类型只展开 true)
                if (!(variant.type === 'literal' && variant.value === false)) {
                    variants.push(variant);
                }
            }
        }
    }

    return variants;
}

核心原理

  1. 检测 prop 类型是否为 union 类型
  2. 遍历所有 union 成员,提取字面量值
  3. Boolean 类型只展开 true(因为 false 通常是默认值)
  4. 过滤掉 undefinednull

测试值提取

在测试代码中,我们需要提取实际传递的值:

 // src/analyzer/UnitTestAnalyzer.ts:942
private extractPropValue(attr: Node): PropValue | null {
    if (!Node.isJsxAttribute(attr)) return null;

    const propName = attr.getNameNode().getText();
    const initializer = attr.getInitializer();

    if (!initializer) {
        // 布尔属性 <Button disabled />
        return { propName, value: true, type: 'literal' };
    }

    // 字符串字面量
    if (Node.isStringLiteral(initializer)) {
        return { propName, value: initializer.getLiteralValue(), type: 'literal' };
    }

    // JSX 表达式
    if (Node.isJsxExpression(initializer)) {
        const expression = initializer.getExpression();
        if (!expression) return null;

        // 数字、布尔等字面量
        if (Node.isNumericLiteral(expression)) {
            return { propName, value: Number(expression.getLiteralValue()), type: 'literal' };
        }

        // 处理变量:通过类型推断获取实际值
        const exprType = expression.getType();
        if (exprType.isLiteral()) {
            const literalValue = exprType.getLiteralValue();
            if (literalValue !== undefined) {
                return { propName, value: literalValue, type: 'literal' };
            }
        }
    }

    return null;
}

核心原理

  1. 直接提取字面量值
  2. 对于变量和表达式,利用 TypeScript 的类型推断获取值
  3. 支持 ref 值追踪、循环变量展开等复杂场景

组件路径解析

为了准确关联测试代码和组件定义,我们需要解析 import 语句,找到组件的真实路径:

 // src/analyzer/UnitTestAnalyzer.ts:89
private resolveComponentPath(identifier: Identifier, importSymbol?: Symbol) {
    try {
        let originalSymbol: Symbol | undefined = importSymbol;
        if (identifier) {
            const typeChecker = this.project.getTypeChecker();
            originalSymbol = typeChecker.getSymbolAtLocation(identifier);
        }
        if (!originalSymbol) return null;

        // 解析别名
        while (originalSymbol?.getAliasedSymbol()) {
            originalSymbol = originalSymbol.getAliasedSymbol();
        }

        if (!originalSymbol) return null;
        const declarations = originalSymbol.getDeclarations();
        const declarationNode = declarations[0];
        if (!declarationNode) return null;

        const declarationSourceFile = declarationNode.getSourceFile();
        const originalPath = declarationSourceFile.getFilePath();

        if (!isComponentFile(originalPath)) {
            // 继续解析转发导出
            return this.resolveTsPath(declarationNode);
        }

        return originalPath;
    } catch (error) {
        return null;
    }
}

核心原理

  1. 从 identifier 获取 symbol

  2. 递归解析 alias symbol(处理 export { Button as Btn } 等情况)

  3. 获取原始声明文件路径

  4. 处理中间层的转发导出

实际应用场景

1. CI/CD 集成

通过 onFinished 回调强制 100% 覆盖:

export default defineConfig({
  test: {
    reporters: [['vc-api-coverage', {
      onFinished: (data) => {
        for (const item of data) {
          if (item.total > item.covered) {
            throw new Error(`${item.name} API Coverage is not 100%`)
          }
        }
      }
    }]]
  }
})

2. 组件库开发

对于组件库,确保每个组件的所有 API 都有测试覆盖:

reporters: [['vc-api-coverage', {
  include: ['**/src/components/**/*.{tsx,vue}'],
  format: ['cli', 'html'],
  openBrowser: true
}]]

3. 严格模式下的全面测试

对关键组件使用严格模式,确保每个 prop 变体都被测试:

reporters: [['vc-api-coverage', {
  include: ['**/src/components/Button/**/*.tsx'],
  strict: true  // 开启严格模式
}]]

总结

vc-api-coverage 通过巧妙地结合 TypeScript 类型系统和 AST 分析,实现了对 Vue 组件 API 覆盖率的精准检测。核心技术点包括:

  1. 类型系统利用:通过 $props$slots 等类型属性提取组件 API
  2. 多模式识别:支持 JSX、模板字符串、mount 对象等多种测试写法
  3. 严格模式:细粒度追踪 union 类型的每个变体
  4. 路径解析:递归追踪 import/export,准确关联测试和组件

这个工具不仅提升了组件测试的质量,还为团队提供了可量化的测试指标,让"测试覆盖率"这个概念更加贴近前端组件开发的实际需求。

后记

在目前 AI 辅助开发、Markdown 文档泛滥的场景下,其实开发一个强约束的工具也是一个不错的方向。相比于只能提供建议的文档和规范,带有强制检查能力的工具能够真正保证代码质量的底线。就像这个 API 覆盖率工具,它不是告诉你“应该写测试”,而是确保“必须写哪些测试”。

最后,感谢 Cursor 和 Claude Code 帮我完成了这个覆盖率工具和这篇分享文档。在 AI 辅助开发的时代,借助这些强大的工具,我们能够快速将想法转化为可用的产品。当然 AI 也不是万能,在某些场景下 AI i写的单测并没有实际测试到组件的功能,所以 AI 写的单测还是要让 AI 去review的。

参考资源

v-bind 你用对了吗?

先极简总结(2 句话记死,终身受用)

  1. 绑 1 个属性:用缩写 :属性名="值"(原生标签 / Vue 组件通用,日常 90% 用这个);
  2. 绑 N 个属性:用 v-bind="属性对象"(无冒号、无属性名,属性越多越简洁,封装组件 / 配置驱动场景必用)。

核心前提先讲透(一句话干货)

  • v-bind是 Vue 用来给HTML 标签 / Vue 组件绑定动态属性值的指令,只有 2 种核心用法,本质都是「动态传值」,区别仅在于绑 1 个属性还是绑多个属性
  1. 单个属性绑定v-bind:属性名="值" → 缩写 :属性名="值"(日常 90% 高频用);

  2. 批量属性绑定:直接v-bind="属性对象"(无属性名、无冒号,把多个属性打包成对象一次性绑定);

关键:批量绑定的属性对象键 = HTML 标签 / Vue 组件的属性名值 = 要绑定的动态数据,Vue 会自动解析成「键 = 值」的单个属性,一一绑定到标签上。

例子 :原生img图片标签(最易理解的通用场景)

img标签的src(图片地址)、alt(占位文字)、width(宽度)是所有人都认识的原生属性,用它举例最直观,重点看单个绑定批量绑定的等价关系。

步骤 :先定义 Vue 里的动态数据(脚本部分,通用)

<script setup> 
    // Vue3基础响应式数据,不用纠结ref,知道是动态值就行 
    import { ref } from 'vue' 
    // 图片地址 
    const imgUrl = ref('https://picsum.photos/200/200')
    // 图片占位文字 
    const imgText = ref('风景图') 
    // 图片宽度 
    const imgW = ref(200)
</script>

用法 1:单个属性绑定(缩写:,逐个绑)

最常用的写法,给img逐个绑定动态属性,每个属性都用:缩写,模板清晰:

<template> 
    <!-- 核心:每个原生属性前加:,绑定对应的动态值 --> 
    <img :src="imgUrl" :alt="imgText" :width="imgW" /> 
</template>

等价于完整v-bind写法(繁琐,几乎没人用):

<script setup> 
    import { ref, reactive } from 'vue' 
    // 1. 先定义零散动态值 
    const imgUrl = ref('https://picsum.photos/200/200') 
    const imgText = ref('风景图') 
    const imgW = ref(200) 
    // 2. 打包成「属性对象」:键=img原生属性名,值=动态值 
    const imgProps = reactive({ src: imgUrl, alt: imgText, width: imgW }) 
</script> 
<template> 
    <!-- 核心:无属性名、无冒号,v-bind直接绑属性对象 --> 
    <img v-bind="imgProps" /> 
</template>

用法 2:批量属性绑定(v-bind="对象",打包绑)

img需要的所有动态属性打包成一个对象,用v-bind直接绑定这个对象,Vue 会自动解析:

<script setup> 
    import { ref, reactive } from 'vue' 
    // 1. 先定义零散动态值 
    const imgUrl = ref('https://picsum.photos/200/200') 
    const imgText = ref('风景图') 
    const imgW = ref(200) 
    // 2. 打包成「属性对象」:键=img原生属性名,值=动态值 
    const imgProps = reactive({ src: imgUrl, alt: imgText, width: imgW }) 
</script> 
<template> 
    <!-- 核心:无属性名、无冒号,v-bind直接绑属性对象 --> 
    <img v-bind="imgProps" /> 
</template>

完全等价于单个绑定的写法,Vue 会自动把imgProps里的src/alt/width逐个绑定到img标签上,效果一模一样。

单个和批量这两种用法是 Vue 最基础、最高频的语法,不用记复杂概念,只要知道「单个用:,多个用 v-bind = 对象」,就能搞定所有动态属性绑定场景。

Guigu 甑选平台第一篇:项目初始化与配置

第一章:项目创建 - 使用Create Vue的理由和步骤

步骤1:使用官方脚手架创建项目

使用npm create vue@latest是因为这是Vue团队官方维护的脚手架工具,能够确保项目结构与最新Vue特性完全兼容。它集成了Vue社区的最佳实践和推荐配置,减少了手动配置可能出现的错误。交互式命令行让开发者能够按需选择功能模块。

bash

复制下载

# 执行创建命令
npm create vue@latest

# 交互式配置
 Project name: ... guigu-zhenxuan-platform
 Add TypeScript? ... Yes  # 选择TypeScript是为了提供类型安全,减少运行时错误
 Add JSX Support? ... No  # 不使用JSX是因为Vue推荐使用模板语法,保持项目语法一致性
 Add Vue Router for Single Page Application development? ... Yes  # 添加Vue Router是因为SPA应用必须的路由管理
 Add Pinia for state management? ... Yes  # 选择Pinia是因为它是Vue官方推荐的状态管理库
 Add Vitest for Unit Testing? ... No  # 不先添加单元测试是为了先搭建项目基础架构,测试可以后期添加
 Add an End-to-End Testing Solution? ... No  # 不添加E2E测试是因为初期项目重点在功能开发
 Add ESLint for code quality? ... Yes  # 添加ESLint是为了统一代码风格,提高代码质量
 Add Prettier for code formatting? ... Yes  # 添加Prettier是为了自动格式化代码,避免团队成员间的格式争议

# 进入项目并安装基础依赖
cd guigu-zhenxuan-platform
npm install

初始化后的项目结构说明

text

复制下载

guigu-zhenxuan-platform/
├── src/
│   ├── components/    # components目录用于存放可复用的UI组件
│   ├── views/         # views目录用于存放页面级组件,这是Vue Router的惯例命名
│   ├── router/        # router目录用于集中管理路由配置
│   ├── stores/        # stores目录用于存放Pinia状态管理文件
│   └── main.ts        # main.ts是Vue应用的入口文件
├── public/            # public目录用于存放不需要构建处理的静态资源
├── .eslintrc.cjs      # ESLint配置文件,使用.cjs扩展名是因为需要CommonJS格式
├── .prettierrc        # Prettier代码格式化配置文件
├── index.html         # HTML入口文件,浏览器通过这个文件加载应用
├── package.json       # 项目配置文件,管理依赖和脚本
├── tsconfig.json      # TypeScript编译配置文件
└── vite.config.ts     # Vite构建工具配置文件

第二章:修改Package.json - 详细配置解析

步骤1:更新scripts配置

scripts配置决定了项目的开发工作流,合理的配置能提高开发效率。

打开package.json,修改scripts部分:

json

复制下载

"scripts": {
  "dev": "vite --open",
  // 配置vite --open是为了启动开发服务器后自动打开浏览器,提升开发体验
  
  "build": "run-p type-check "build-only {@}" --",
  // 这样配置build命令是为了并行执行类型检查和构建过程,提高构建速度
  
  "preview": "vite preview",
  // preview命令用于预览生产环境构建结果,验证构建效果是否符合预期
  
  "build-only": "vite build",
  // 单独的build-only命令用于纯构建操作,方便在组合命令中调用
  
  "type-check": "vue-tsc --build",
  // 使用vue-tsc是因为它专门针对Vue单文件组件进行TypeScript类型检查
  
  "lint": "run-s lint:*",
  // 使用run-s是为了顺序执行所有lint相关任务,确保代码检查的完整性
  
  "lint:oxlint": "oxlint . --fix",
  // 配置oxlint是因为它相比ESLint有更好的性能表现,检查速度更快
  
  "lint:eslint": "eslint . --fix --cache",
  // 保留ESLint是因为它有成熟的生态系统和丰富的插件支持
  
  "format": "prettier --write --experimental-cli src/",
  // 使用--experimental-cli参数是为了启用Prettier新版本的命令行特性
  
  "preinstall": "node ./scripts/preinstall.js"
  // preinstall脚本用于在安装依赖前检查开发环境是否符合要求
}

步骤2:添加生产依赖

生产依赖是项目运行时必须的包,每个依赖都有特定的业务用途。

执行以下安装命令:

bash

复制下载

# 安装Element Plus UI组件库
npm install element-plus
# 安装Element Plus是因为它提供了丰富的企业级UI组件,能显著加快开发速度

# 安装Element Plus图标库
npm install @element-plus/icons-vue
# 安装图标库是为了提供丰富的图标资源,提升用户界面视觉效果

# 安装Axios HTTP客户端
npm install axios
# 安装Axios是因为它是一个功能强大的HTTP客户端,支持请求拦截、响应拦截等高级特性

# 安装Mock.js数据模拟库
npm install mockjs
# 安装Mock.js是为了在开发阶段模拟后端API数据,实现前后端并行开发

package.json中的dependencies部分配置如下:

json

复制下载

"dependencies": {
  "@element-plus/icons-vue": "^2.3.2",  // Element Plus图标组件
  "axios": "^1.13.4",                    // HTTP请求库,用于API调用
  "element-plus": "^2.13.1",             // UI组件库,提供基础界面组件
  "mockjs": "^1.1.0",                    // 模拟数据生成器
  "pinia": "^2.1.7",                     // 状态管理库,已由create-vue安装
  "vue": "^3.4.21",                      // Vue核心框架,已由create-vue安装
  "vue-router": "^4.3.0"                 // 路由管理库,已由create-vue安装
}

步骤3:添加开发依赖

开发依赖只在开发阶段使用,用于提升开发体验和保证代码质量。

执行以下安装命令:

bash

复制下载

# 安装TypeScript相关配置
npm install --save-dev @tsconfig/node24
# 安装@tsconfig/node24是为了使用Node.js 24的TypeScript配置预设

npm install --save-dev @vue/tsconfig
# 安装@vue/tsconfig是为了使用Vue官方推荐的TypeScript配置预设

npm install --save-dev @types/node
# 安装@types/node是为了获取Node.js API的类型定义

# 安装Vite插件
npm install --save-dev vite-plugin-mock
# 安装vite-plugin-mock是为了将Mock数据集成到Vite开发服务器中

npm install --save-dev vite-plugin-svg-icons
# 安装vite-plugin-svg-icons是为了优化SVG图标的使用体验

npm install --save-dev vite-plugin-vue-devtools
# 安装vite-plugin-vue-devtools是为了增强Vue开发工具的功能

# 安装代码质量工具
npm install --save-dev eslint-config-prettier
# 安装eslint-config-prettier是为了集成Prettier和ESLint,避免规则冲突

npm install --save-dev eslint-plugin-oxlint
# 安装eslint-plugin-oxlint是为了在ESLint中使用oxlint规则

npm install --save-dev oxlint
# 安装oxlint是因为它提供了比ESLint更快的JavaScript代码检查

# 安装工具库
npm install --save-dev npm-run-all2
# 安装npm-run-all2是为了并行或顺序运行多个npm脚本

npm install --save-dev jiti
# 安装jiti是为了提供TypeScript文件的即时编译能力

完整的devDependencies配置如下:

json

复制下载

"devDependencies": {
  "@tsconfig/node24": "^24.0.4",           // Node.js 24的TypeScript配置预设
  "@types/node": "^20.12.7",              // Node.js API类型定义
  "@vitejs/plugin-vue": "^5.0.4",         // Vite的Vue单文件组件插件
  "@vue/eslint-config-typescript": "^13.0.0", // Vue项目的TypeScript ESLint配置
  "@vue/tsconfig": "^0.5.0",              // Vue项目的TypeScript配置
  "eslint": "^9.0.0",                     // JavaScript代码检查工具
  "eslint-config-prettier": "^9.1.0",     // 关闭与Prettier冲突的ESLint规则
  "eslint-plugin-oxlint": "~1.42.0",      // oxlint的ESLint插件
  "eslint-plugin-vue": "^9.23.0",         // Vue.js的ESLint插件
  "jiti": "^1.21.0",                      // TypeScript即时编译工具
  "npm-run-all2": "^8.0.4",               // 并行运行npm脚本的工具
  "oxlint": "~1.42.0",                    // 高性能JavaScript linter
  "prettier": "3.2.5",                    // 代码格式化工具
  "typescript": "~5.3.3",                 // TypeScript编译器
  "vite": "^5.2.0",                       // 前端构建工具
  "vite-plugin-mock": "^3.0.2",           // Vite的Mock数据插件
  "vite-plugin-svg-icons": "^2.0.1",      // Vite的SVG图标插件
  "vite-plugin-vue-devtools": "^7.3.0",   // Vite的Vue开发工具插件
  "vue-tsc": "^1.8.27"                    // Vue单文件组件的TypeScript检查器
}

步骤4:配置引擎要求和Prettier

在package.json末尾添加以下配置:

json

复制下载

"engines": {
  "node": "^20.19.0 || >=22.12.0"
},
// 配置engines是为了明确项目所需的Node.js版本范围,确保开发环境一致性

"prettier": {
  "ignorePath": ".prettierignore"
}
// 配置prettier是为了指定忽略文件配置,避免对特定文件进行格式化

第三章:创建环境检查脚本

步骤1:创建预安装脚本

预安装脚本在npm install之前执行,用于检查开发环境是否符合要求。

bash

复制下载

# 创建scripts目录
mkdir scripts

# 创建preinstall.js文件
touch scripts/preinstall.js

编辑scripts/preinstall.js文件:

javascript

复制下载

// 检查Node.js版本是否符合项目要求
const currentNodeVersion = process.versions.node;
const semver = currentNodeVersion.split('.');
const major = parseInt(semver[0], 10);

// 项目要求Node.js 20.19.0或更高版本
if (major < 20) {
  console.error(
    '你正在使用 Node.js ' +
      currentNodeVersion +
      '。\n' +
      '本项目需要 Node.js 20.19.0 或更高版本。\n' +
      '请升级你的 Node.js 版本。'
  );
  process.exit(1);  // 退出进程,阻止继续安装
}

console.log('✅ Node.js 版本检查通过');
// 版本检查通过后,npm install会继续执行

这个脚本的作用:确保所有开发者在一致的Node.js环境下工作,避免因版本差异导致的兼容性问题。

第四章:配置HTML入口文件

步骤1:修改index.html

index.html是Web应用的入口文件,浏览器通过加载这个文件启动整个应用。

编辑index.html文件:

html

复制下载运行

<!DOCTYPE html>
<html lang="zh-CN">
  <!-- 指定中文语言是为了更好的无障碍支持和SEO优化 -->
  
  <head>
    <meta charset="UTF-8">
    <!-- 设置UTF-8编码是为了支持中文等多语言字符 -->
    
    <link rel="icon" href="/favicon.ico">
    <!-- 设置网站图标,提升品牌识别度 -->
    
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <!-- 配置viewport是为了实现响应式设计,适配移动设备 -->
    
    <title>硅谷甑选平台</title>
    <!-- 设置页面标题,显示在浏览器标签页上 -->
  </head>
  
  <body>
    <div id="app"></div>
    <!-- Vue应用挂载点,所有Vue组件将在这个div内渲染 -->
    
    <script type="module" src="/src/main.ts"></script>
    <!-- 使用type="module"启用ES模块支持,加载应用入口文件 -->
  </body>
</html>

第五章:配置TypeScript和Vite

步骤1:修改tsconfig.json

TypeScript配置文件决定了TypeScript编译器如何工作。

打开tsconfig.json,确保配置正确:

json

复制下载

{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  // 继承Vue官方的TypeScript配置,减少手动配置工作量
  
  "compilerOptions": {
    "target": "ES2020",
    // 设置编译目标为ES2020,使用较新的JavaScript特性
    
    "useDefineForClassFields": true,
    // 使用ES2022的类字段定义方式
    
    "module": "ESNext",
    // 使用ES模块系统,支持tree-shaking
    
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    // 包含的库文件,提供类型提示
    
    "skipLibCheck": true,
    // 跳过库文件的类型检查,加快编译速度
    
    "moduleResolution": "bundler",
    // 使用bundler的模块解析策略,与Vite保持一致
    
    "allowImportingTsExtensions": true,
    // 允许导入TypeScript扩展名的文件
    
    "resolveJsonModule": true,
    // 允许导入JSON文件作为模块
    
    "isolatedModules": true,
    // 确保每个文件都能单独编译
    
    "noEmit": true,
    // 不输出编译文件,由Vite处理构建
    
    "jsx": "preserve",
    // 保留JSX语法,由其他工具处理
    
    "strict": true,
    // 启用所有严格类型检查
    
    "noUnusedLocals": true,
    // 检查未使用的局部变量
    
    "noUnusedParameters": true,
    // 检查未使用的函数参数
    
    "noFallthroughCasesInSwitch": true,
    // 检查switch语句的fallthrough情况
    
    "baseUrl": ".",
    // 设置基础路径为当前目录
    
    "paths": {
      "@/*": ["./src/*"]
    }
    // 配置路径别名,@表示src目录,简化导入路径
  },
  
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
  // 包含需要编译的文件类型
  
  "references": [
    {
      "path": "./tsconfig.node.json"
    }
    // 引用Node环境的TypeScript配置
  ]
}

步骤2:修改tsconfig.node.json

这个文件用于配置Node.js环境的TypeScript编译。

json

复制下载

{
  "extends": "@tsconfig/node24/tsconfig.json",
  // 继承Node.js 24的TypeScript配置预设
  
  "include": [
    "vite.config.ts",
    "scripts/**/*",
    "mock/**/*"
  ],
  // 包含Node环境下的TypeScript文件
  
  "compilerOptions": {
    "composite": true,
    // 启用复合编译,支持项目引用
    
    "noEmit": true,
    // 不输出编译文件
    
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
    // TypeScript构建信息文件位置
  }
}

步骤3:配置Vite构建工具

Vite配置文件决定了项目的构建行为和开发服务器配置。

打开vite.config.ts,修改为:

typescript

复制下载

import { fileURLToPath, URL } from 'node:url'
// 导入URL处理工具,用于处理文件路径
import { defineConfig } from 'vite'
// 导入Vite配置函数
import vue from '@vitejs/plugin-vue'
// 导入Vite的Vue插件,用于处理.vue文件
import { viteMockServe } from 'vite-plugin-mock'
// 导入Mock插件,用于开发阶段的数据模拟
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
// 导入SVG图标插件,优化图标使用
import VueDevTools from 'vite-plugin-vue-devtools'
// 导入Vue开发工具插件,增强调试能力
import path from 'path'
// 导入路径处理工具

export default defineConfig(({ command }) => ({
  // 根据命令模式(serve/build)返回不同配置
  
  plugins: [
    vue(),
    // Vue单文件组件插件,必须放在第一个
    
    viteMockServe({
      mockPath: 'mock',
      // Mock数据文件存放目录
      enable: command === 'serve',
      // 只在开发服务器启用Mock
    }),
    
    createSvgIconsPlugin({
      iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
      // SVG图标文件目录
      symbolId: 'icon-[dir]-[name]',
      // 图标ID生成规则
    }),
    
    VueDevTools(),
    // Vue开发工具插件
  ],
  
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
      // 配置路径别名,@指向src目录
    }
  },
  
  server: {
    port: 3000,
    // 开发服务器端口号
    open: true,
    // 启动后自动打开浏览器
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        // 代理目标地址
        changeOrigin: true,
        // 修改请求头中的Origin字段
        rewrite: (path) => path.replace(/^/api/, '')
        // 重写请求路径,移除/api前缀
      }
    }
  }
}))

第六章:配置代码质量和样式

步骤1:创建样式重置文件

样式重置文件用于统一不同浏览器的默认样式,提供一致的基准样式。

bash

复制下载

# 创建styles目录
mkdir src/styles

# 创建reset.css文件
touch src/styles/reset.css

编辑src/styles/reset.css文件:

css

复制下载

/* 重置所有元素的默认边距和内边距 */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  /* 使用border-box盒模型,更符合开发直觉 */
}

/* 设置根元素和body的高度 */
html, body {
  height: 100%;
  /* 确保页面能占满整个视口高度 */
  
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  /* 设置字体栈,优先使用系统字体 */
}

/* 设置Vue应用容器的样式 */
#app {
  height: 100%;
  /* 应用容器占满整个父元素高度 */
}

步骤2:修改ESLint配置

ESLint配置文件定义了代码检查规则,确保代码质量一致性。

打开.eslintrc.cjs,修改为:

javascript

复制下载

/* eslint-env node */
// 声明当前文件运行在Node.js环境中

require('@rushstack/eslint-patch/modern-module-resolution')
// 使用ESLint补丁,解决模块解析问题

module.exports = {
  root: true,
  // 指定为根配置文件,ESLint不会向上查找其他配置
  
  extends: [
    'plugin:vue/vue3-essential',
    // Vue 3基础规则集
    'eslint:recommended',
    // ESLint推荐规则
    '@vue/eslint-config-typescript',
    // Vue的TypeScript配置
    '@vue/eslint-config-prettier/skip-formatting'
    // 跳过Prettier的格式化规则
  ],
  
  parserOptions: {
    ecmaVersion: 'latest'
    // 使用最新的ECMAScript版本
  },
  
  rules: {
    'vue/multi-word-component-names': 'off'
    // 关闭Vue组件必须多单词命名的规则
    // 因为有些基础组件如Login、Home使用单单词更合适
  }
}

步骤3:创建.prettierignore文件

Prettier忽略文件指定了哪些文件不需要进行代码格式化。

bash

复制下载

# 创建Prettier忽略文件
touch .prettierignore

编辑.prettierignore文件:

plaintext

复制下载

node_modules
# 忽略node_modules目录,因为这是第三方依赖

dist
# 忽略构建输出目录

*.min.js
# 忽略压缩的JavaScript文件

*.min.css
# 忽略压缩的CSS文件

第七章:配置项目核心文件

步骤1:修改main.ts文件

main.ts是Vue应用的入口文件,负责初始化Vue应用并注册各种插件。

打开src/main.ts,修改为:

typescript

复制下载

import { createApp } from 'vue'
// 导入Vue的createApp函数,用于创建Vue应用实例

import './styles/reset.css'
// 导入重置样式,确保样式一致性

import App from './App.vue'
// 导入根组件

import router from './router'
// 导入路由配置

import { createPinia } from 'pinia'
// 导入Pinia的createPinia函数,用于创建状态存储

// 导入Element Plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
// 导入Element Plus及其样式

import * as ElementPlusIconsVue from '@element-plus/icons-vue'
// 导入Element Plus的所有图标组件

// 创建Vue应用实例
const app = createApp(App)

// 创建Pinia状态存储实例
const pinia = createPinia()

// 注册Element Plus插件
app.use(ElementPlus)
// 注册路由
app.use(router)
// 注册Pinia状态管理
app.use(pinia)

// 注册所有Element Plus图标组件
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
  // 将每个图标注册为全局组件
}

// 将Vue应用挂载到HTML中的#app元素
app.mount('#app')

步骤2:修改App.vue文件

App.vue是应用的根组件,所有其他组件都在这个组件内渲染。

打开src/App.vue,修改为:

vue

复制下载

<script setup lang="ts">
// 使用<script setup>语法糖,简化组合式API的使用
// lang="ts"指定使用TypeScript

import { RouterView } from 'vue-router'
// 导入RouterView组件,用于渲染当前路由对应的组件
</script>

<template>
  <!-- 路由视图容器,根据当前路由显示不同的页面 -->
  <RouterView />
</template>

<style scoped>
/* scoped样式,只作用于当前组件 */
/* 可以在这里添加全局的样式规则 */
</style>

步骤3:配置路由

路由配置文件定义了应用的路由结构和页面导航逻辑。

打开src/router/index.ts,确保基本配置:

typescript

复制下载

import { createRouter, createWebHistory } from 'vue-router'
// 导入Vue Router的创建函数
// createWebHistory使用HTML5 History API,URL更美观

import type { RouteRecordRaw } from 'vue-router'
// 导入路由记录类型定义

// 定义路由数组,每个路由对应一个页面
const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    // 根路径
    redirect: '/login'
    // 重定向到登录页面,作为默认首页
  },
  {
    path: '/login',
    // 登录页面路径
    name: 'Login',
    // 路由名称,用于编程式导航
    component: () => import('@/views/LoginView.vue')
    // 使用动态导入实现路由懒加载,提高首屏加载速度
  }
  // 可以在这里添加更多路由配置
]

// 创建路由实例
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  // 使用history模式,需要服务器配置支持
  // import.meta.env.BASE_URL获取基础URL
  
  routes
  // 传入路由配置
})

// 导出路由实例,供main.ts使用
export default router

步骤4:配置Pinia Store

Pinia配置文件定义了应用的状态管理结构。

打开src/stores/index.ts,修改为:

typescript

复制下载

import { createPinia } from 'pinia'
// 导入createPinia函数,用于创建Pinia实例

// 创建Pinia实例
const pinia = createPinia()

// 导出Pinia实例,供main.ts使用
export default pinia

// 在这里可以导出具体的store模块
// 例如:export { useUserStore } from './user'
// 这样可以集中管理所有store的导出

第八章:创建Mock数据

步骤1:创建Mock目录和文件

Mock数据用于在开发阶段模拟后端API响应,实现前后端并行开发。

bash

复制下载

# 创建mock目录
mkdir mock

# 创建user mock文件
touch mock/user.ts

步骤2:配置Mock数据

编辑mock/user.ts文件:

typescript

复制下载

/*
 * @Description: Stay hungry,Stay foolish
 * @Author: Huccct
 * @Date: 2024-03-21
 */

// 模拟用户列表数据
const userList = [
  {
    id: 1,
    username: 'admin',
    password: '123456',
    name: '超级管理员',
    phone: '13800138000',
    roleName: '超级管理员',
    createTime: '2024-03-21',
    updateTime: '2024-03-21',
    status: 1,
  },
  {
    id: 2,
    username: 'test',
    password: '123456',
    name: '测试用户',
    phone: '13800138001',
    roleName: '普通管理员',
    createTime: '2024-03-21',
    updateTime: '2024-03-21',
    status: 1,
  },
]

export default [
  // 用户登录接口
  {
    url: '/api/user/login',
    method: 'post',
    response: ({ body }) => {
      const { username, password } = body
      const checkUser = userList.find(
        (item) => item.username === username && item.password === password,
      )
      if (!checkUser) {
        return { code: 201, data: { message: '账号或者密码不正确' } }
      }
      return { code: 200, data: {token:'Admin Token' }}
    },
  },
  // 获取用户信息
  {
    url: '/api/user/info',
    method: 'get',
    response: (request) => {
      const token = request.headers.token
      if (token === 'Admin Token') {
        return {
          code: 200,
          data: {
            name: 'admin',
            avatar:
              'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif',
            roles: ['admin'],
            buttons: ['cuser.detail'],
            routes: [
              'home',
              'Acl',
              'User',
              'Role',
              'Permission',
              'Product',
              'Trademark',
              'Attr',
              'Spu',
              'Sku',
            ],
          },
          message: '获取用户信息成功',
        }
      }
      return {
        code: 201,
        data: null,
        message: '获取用户信息失败',
      }
    },
  },
  // 获取用户列表
  {
    url: '/api/acl/user/:page/:limit',
    method: 'get',
    response: ({ query }) => {
      const { username } = query
      let filteredList = userList
      if (username) {
        filteredList = userList.filter((user) =>
          user.username.includes(username),
        )
      }
      return {
        code: 200,
        data: {
          records: filteredList,
          total: filteredList.length,
        },
      }
    },
  },
  // 添加/更新用户
  {
    url: '/api/acl/user/save',
    method: 'post',
    response: ({ body }) => {
      const newUser = {
        ...body,
        id: userList.length + 1,
        createTime: new Date().toISOString().split('T')[0],
        updateTime: new Date().toISOString().split('T')[0],
        status: 1,
      }
      userList.push(newUser)
      return { code: 200, data: null, message: '添加成功' }
    },
  },
  {
    url: '/api/acl/user/update',
    method: 'put',
    response: ({ body }) => {
      const index = userList.findIndex((item) => item.id === body.id)
      if (index !== -1) {
        userList[index] = {
          ...userList[index],
          ...body,
          updateTime: new Date().toISOString().split('T')[0],
        }
      }
      return { code: 200, data: null, message: '更新成功' }
    },
  },
  // 删除用户
  {
    url: '/api/acl/user/remove/:id',
    method: 'delete',
    response: (request) => {
      const id = request.query.id
      if (!id) {
        return { code: 201, data: null, message: '参数错误' }
      }
      const index = userList.findIndex((item) => item.id === Number(id))
      if (index !== -1) {
        userList.splice(index, 1)
        return { code: 200, data: null, message: '删除成功' }
      }
      return { code: 201, data: null, message: '用户不存在' }
    },
  },
  // 批量删除用户
  {
    url: '/api/acl/user/batchRemove',
    method: 'delete',
    response: ({ body }) => {
      const { idList } = body
      idList.forEach((id) => {
        const index = userList.findIndex((item) => item.id === id)
        if (index !== -1) {
          userList.splice(index, 1)
        }
      })
      return { code: 200, data: null, message: '批量删除成功' }
    },
  },
  // 获取用户角色
  {
    url: '/api/acl/user/toAssign/:userId',
    method: 'get',
    response: () => {
      return {
        code: 200,
        data: {
          assignRoles: [
            {
              id: 1,
              roleName: '超级管理员',
              createTime: '2024-03-21',
              updateTime: '2024-03-21',
            },
          ],
          allRolesList: [
            {
              id: 1,
              roleName: '超级管理员',
              createTime: '2024-03-21',
              updateTime: '2024-03-21',
            },
            {
              id: 2,
              roleName: '普通管理员',
              createTime: '2024-03-21',
              updateTime: '2024-03-21',
            },
          ],
        },
      }
    },
  },
  // 分配用户角色
  {
    url: '/api/acl/user/doAssignRole',
    method: 'post',
    response: () => {
      return { code: 200, data: null, message: '分配角色成功' }
    },
  },
  // 用户登出接口
  {
    url: '/api/user/logout',
    method: 'post',
    response: () => {
      return { code: 200, data: null, message: '退出成功' }
    },
  },
]

第九章:总结

至此,你已经完成了Guigu致选平台项目的初始化配置。通过这个一步一步的教程,你应该能够:

  1. ✅ 使用create-vue脚手架创建项目
  2. ✅ 按照项目文档配置所有依赖
  3. ✅ 设置TypeScript和Vite配置
  4. ✅ 配置Element Plus和图标
  5. ✅ 设置Mock数据服务
  6. ✅ 创建基础的项目结构
  7. ✅ 启动并验证项目运行

前端 er 速码!TinyVue 全局动效实践指南,干货拉满

本文由TinyVue贡献者程锴原创。

一、前言:为什么要统一管理动效

在前端开发中,动画不仅是锦上添花的“视觉糖”,更是交互体验的重要组成部分: 它能引导用户关注、反馈操作结果、缓解等待焦虑、提升产品质感。

但当项目变大、组件增多后,你可能遇到这些问题:

  • 同样的淡入淡出,在不同组件中表现不一致
  • 想调整动画速度,却要修改多个文件
  • 动画样式难以复用、维护困难

这些问题的根源在于:动画定义分散、缺乏统一管理。 为此,TinyVue 引入了一套全新的 全局动效体系,基于 LESS + CSS 变量 实现集中配置与动态控制。

二、为什么选择 LESS + CSS 变量

常见的动画实现方式有两种:

方式 优点 缺点
1️⃣ 直接在组件中定义@keyframes 简单直观,局部可定制 无法统一、修改麻烦
2️⃣ 全局管理动画 可复用、风格一致 静态,难以动态调整

TinyVue 采用 LESS + CSS 变量结合方案,兼顾两者优势:

变量化控制 所有动效的时长、透明度、位移量都由 CSS 变量控制

可局部覆盖 组件可根据需求覆盖变量,灵活调整动画参数

主题可切换 只需在不同主题文件中修改变量,即可快速切换全局动效风格

三、环境搭建与示例预览

1. 拉取 TinyVue 仓库:

git clone https://github.com/opentiny/tiny-vue.git
cd tiny-vue
pnpm i

1.PNG

2. 启动TinyVue项目

pnpm dev

浏览器访问:http://localhost:7130

2.png

3. 打开配置文件:

/packages/theme/src/base/vars.less

3.png

1). 修改变量即可实时生效:

--tv-motion-slide-speed: 1.2s;

刷新页面后,可在抽屉(Drawer)组件中观察滑动动效速度变化。

4.gif

同样地:

--tv-motion-fade-offset-y: 100px;

会影响对话框(DialogBox)的淡入位移动画。

5.gif

四、全局动效的设计思路

1. 统一变量管理

所有动画相关参数集中在 /packages/theme/src/base/vars.less

:root {
  /* 淡入淡出 */
  --tv-motion-fade-speed: 0.3s;

  /* 滑动类 */
  --tv-motion-slide-speed: 0.4s;
  --tv-motion-slide-offset-left: -30px;
  --tv-motion-slide-offset-left-mid: -10px;
  --tv-motion-slide-opacity-mid: 0.5;

  /* 蚂蚁线 */
  --tv-motion-ants-shift: 8px;
  --tv-motion-ants-speed: 0.8s;
}

修改任意变量即可影响全局动效表现。

2. 按类型分类管理

为方便维护和扩展,动效按类型拆分为多个 LESS 文件:

motion/
  fade.less       // 淡入淡出
  slide.less      // 滑动
  zoom.less       // 缩放
  rotate.less     // 旋转
  bounce.less     // 弹跳
  ants.less       // 蚂蚁线
  ...
  index.less      // 汇总引入

每个文件独立维护一类动效,结构清晰,修改成本低。

3. 动效命名规范

统一命名规则: {type}-{direction}-{state}

示例:

  • fade-in:淡入
  • slide-left-in:从左滑入
  • zoom-in:放大进入
  • ants-x-rev:蚂蚁线反向滚动

保证语义清晰、全局唯一,方便引用与调试。

五、动效实现示例

1️⃣ 淡入淡出动效

@keyframes fade-in {
  0% { opacity: 0; }
  100% { opacity: 1; }
}
@keyframes fade-out {
  0% { opacity: 1; }
  100% { opacity: 0; }
}

调用方式:

.fade-enter-active {
  animation: fade-in var(--tv-motion-fade-speed) ease-out both;
}
.fade-leave-active {
  animation: fade-out var(--tv-motion-fade-speed) ease-in both;
}

2️⃣ 滑动动效

@keyframes slide-left-in {
  0% {
    opacity: 0;
    transform: translateX(var(--tv-motion-slide-offset-left));
  }
  50% {
    opacity: var(--tv-motion-slide-opacity-mid);
    transform: translateX(var(--tv-motion-slide-offset-left-mid));
  }
  100% {
    opacity: 1;
    transform: translateX(0);
  }
}

通过变量可灵活调整动画节奏和距离。

3️⃣ 蚂蚁线动画(Ants)

@keyframes ants-x {
  0% { background-position: 0 0; }
  100% { background-position: var(--tv-motion-ants-shift, 8px) 0; }
}

在组件中调用:

.copyed-borders {
  --tv-motion-ants-shift: 13px;
  .border-top {
    animation: ants-x var(--tv-motion-ants-speed) linear infinite;
  }
}

六、组件集成方式

方式 描述
全局引入 motion/index.less 统一引入所有动效,确保全局可用
局部调用 组件通过类名或 animation 属性使用对应动效
变量覆盖 通过覆盖 CSS 变量实现不同组件动效差异化

七、实践经验与优化建议

保持命名规范:保证语义清晰、避免重复
文件分类明确:不同类型动效分文件管理
加注释和示例:便于团队协作与复用

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue源码:github.com/opentiny/ti…

欢迎进入代码仓库 Star🌟TinyVue、TinyEngine、TinyPro、TinyNG、TinyCLI、TinyEditor 如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

Vue-深度解读代理技术:Object.defineProperty 与 Proxy

前言

在 Vue 的进化史中,从 Vue 2 到 Vue 3 的跨越,最核心的变革莫之过于响应式系统的重构。而这场重构的主角,正是 Object.definePropertyProxy。本文将带你从底层描述符到 Reflect 陷阱,深度拆解这两大对象代理技术。

一、 ES5 时代的功臣:Object.defineProperty

Object.defineProperty 用于在一个对象上定义或修改属性。Vue 2 的响应式基础正是建立在其“存取描述符”之上的。

1. 基础语法

Object.defineProperty(obj, prop, descriptor);

  • obj:目标对象
  • prop:要定义或修改的属性名(字符串或 Symbol)
  • descriptor:属性描述符,是一个配置对象(包含数据描述符与存取描述符)

2. descriptor描述符分类

它可分为两类,一类为数据描述符、一类为存取描述符

属性描述符不能同时包含 value/writable(数据描述符)和 get/set(存取描述符)。

  • 数据描述符

    字段 类型 默认值 说明
    value any undefined 属性的值
    writable boolean false 是否可写(能否被重新赋值)
    enumerable boolean false 是否可枚举(能否在 for...inObject.keys 中出现)
    configurable boolean false 是否可配置(能否被删除或修改描述符)
  • 存取描述符:

    字段 类型 说明
    get function 读取属性时调用的函数
    set function 设置属性时调用的函数

注意❗:一个描述符不能同时包含 value/writableget/set,否则会报错。

3. 局限性分析(Vue 2 的痛点)

  • 无法监听新增/删除:必须预先定义好属性,动态添加的属性(data.b = 2)无法响应。

  • 数组支持差:无法拦截索引修改(arr[0] = x)及 length 变更。

  • 性能开销:必须通过递归遍历对象的所有属性进行拦截。

4. 使用示例:

// 封装一个劫持对象所有属性的函数
function observe(obj) {
  // 遍历对象的自有属性
  Object.keys(obj).forEach((prop) => {
    let value = obj[prop]; // 存储原始值
    Object.defineProperty(obj, prop, {
      enumerable: true,
      configurable: true,
      get() {
        console.log(`读取 ${prop} 属性:${value}`);
        return value;
      },
      set(newValue) {
        console.log(`给 ${prop} 赋值:${newValue}`);
        value = newValue;
      },
    });
  });
}

// 测试
const person = { name: "张三", gender: "男" };
observe(person);

person.name = "李四"; // 输出:给 name 赋值:李四
console.log(person.gender); // 输出:读取 gender 属性:男 → 男

二、 ES6 时代的巅峰:Proxy

Proxy 是ES6引入的一个新对象,用于创建一个对象的代理,从而拦截并自定义这个对象的基本操作(比如属性读取、赋值、删除、遍历等)。它是 Vue 3 实现高效响应式的基石。

1. 基本语法

  • 语法:const proxy = new Proxy(target, handler);

    • target:要代理的目标对象(可以是普通对象、数组、函数,甚至是另一个 Proxy)。

    • handler:一个配置对象,包含多个陷阱函数(traps),每个陷阱函数对应一种对目标对象的操作(比如读取属性对应get陷阱,赋值对应set陷阱)

    • proxy:返回的代理对象,后续操作都通过这个代理对象进行,而非直接操作原对象。

1. 常见陷阱函数 (Traps)

Proxy 的强大在于它能拦截多种底层操作。

Trap 触发时机 示例
get(target, prop, receiver) 读取属性时 obj.foo
set(target, prop, value, receiver) 设置属性时 obj.foo = 'bar'
has(target, prop) 使用in 操作符时 'foo' in obj
deleteProperty(target, prop) 删除属性时 delete obj.foo
ownKeys(target) 获取自身属性名时 Object.keys(obj)
apply(target, thisArg, args) 调用函数时(仅当 target 是函数) fn()
construct(target, args) 使用new操作符时 new Obejct()

2. 使用示例

    // 1. 定义原始用户对象
    const user = {
      name: '张三',
      age: 20,
    };

    // 2. 创建 Proxy 代理对象
    const userProxy = new Proxy(user, {
      // 拦截属性读取操作(比如 userProxy.name)
      get(target, prop, receiver) {
        console.log(`读取属性${prop}`);
        // 核心逻辑:属性不存在时返回默认提示
        if (!Reflect.has(target, prop)) {
          return `属性${prop}不存在`;
        }
        return Reflect.get(target, prop, receiver); // 用 Reflect 保证 this 指向正确
      },

      // 拦截属性赋值操作(比如 userProxy.age = 25)
      set(target, prop, value, receiver) {
        console.log(`给属性${prop}赋值:${value}`);
        // 核心逻辑:属性合法性校验
        switch (prop) {
          case 'age':
            if (typeof value !== 'number' || value <= 0) {
              console.error(' 年龄必须是大于0的数字!');
              return false; // 返回 false 表示赋值失败
            }
            break;
          case 'name':
            if (typeof value !== 'string' || value.trim() === '') {
              console.error(' 姓名不能为空字符串!');
              return false;
            }
            break;
        }
        return Reflect.set(target, prop, value, receiver); // 合法则执行赋值,返回 true 表示成功
      },
    });

    // 3. 测试代理功能
    console.log('===== 测试属性读取 =====');
    console.log(userProxy.name); // 读取存在的属性
    console.log(userProxy.age); // 读取存在的属性
    console.log(userProxy.gender); // 读取不存在的属性

    console.log('\n===== 测试合法赋值 =====');
    userProxy.age = 25; // 合法的年龄赋值
    userProxy.name = '李四'; // 合法的姓名赋值
    console.log('赋值后 name:', userProxy.name);
    console.log('赋值后 age:', userProxy.age);

    console.log('\n===== 测试非法赋值 =====');
    userProxy.age = -5; // 非法的年龄(负数)
    userProxy.name = ''; // 非法的姓名(空字符串)
    console.log('非法赋值后 age:', userProxy.age); // 年龄仍为 25
   
// 打印结果:  
===== 测试属性读取 =====
 读取属性name
 张三
 读取属性age
 20
 读取属性gender
 属性gender不存在
===== 测试合法赋值 =====
 给属性age赋值:25
 给属性name赋值:李四
 读取属性name
 赋值后 name: 李四
 读取属性age
 赋值后 age: 25
 114 
===== 测试非法赋值 =====
 给属性age赋值:-5
 年龄必须是大于0的数字!
 给属性name赋值:
 姓名不能为空字符串!
 读取属性age
 非法赋值后 age: 25



三、 Reflect:Proxy 的最佳拍档

Reflect 是 ES6 引入的内置全局对象,不能通过 new 实例化(不是构造函数)。它的核心作用是把原本属于 Object 对象的底层操作(比如属性赋值、删除)提炼成独立的函数方法,同时能保证操作的 “正确性”—— 比如转发操作时保留正确的 this 指向。

1. 为什么一定要配合 Reflect?

核心原因:处理 this 指向问题。

当对象内部存在 getter 并依赖 this 时,如果直接使用 target[prop]this 将指向原始对象而非代理对象,导致后续的属性读取无法被 Proxy 拦截。

2. Reflect使用对比

const person = {
      _name: '张三',
      get name() {
        console.log('getter 被调用,this:', this === person ? 'person' : this);
        return this._name;
      },

      introduce() {
        console.log('this', this)
        return `我叫${this.name}`;
      },
    };

    // 错误代理
    const badProxy = new Proxy(person, {
      get(target, prop, receiver) {
        console.log(`拦截: ${prop}`);
        if (prop === 'introduce') {
          const original = target[prop]; // 错误:直接获取
          return function () {
            return original(); // this 指向 badProxy
          };
        }
        return target[prop];
      },
    });

    // 正确代理
    const goodProxy = new Proxy(person, {
      get(target, prop, receiver) {
        console.log(`拦截: ${prop}`);
        if (prop === 'introduce') {
          return function () {
            return Reflect.apply(target[prop], receiver, arguments); // 正确
          };
        }
        return Reflect.get(target, prop, receiver);
      },
    });

    console.log('=== 测试错误代理 ===');
    console.log(badProxy.introduce());

    console.log('\n=== 测试正确代理 ===');
    console.log(goodProxy.introduce()

3. 打印结果分析

  1. 首先执行console.log(badProxy.introduce())

    • 它会读取badProxy.introduce属性,触发badProxyget 陷阱,参数target = personprop = 'introduce'receiver = badProxy
  2. 接着进入badProxyget陷阱函数,此时返回的新函数被赋值给badProxy.introduce,然后执行这个新函数。

    console.log(`拦截: ${prop}`);  // 输出:拦截: introduce
    if (prop === 'introduce') {
      const original = target[prop]; // 拿到 person.introduce 函数
      return function () { // 返回一个新函数
        return original(); // 关键错误:裸调用 original
      };
    }
    
  3. 执行返回的新函数original()(即person.introduce()

    • original是裸调用(没有对象前缀),所以introduce方法里的this指向window(非严格模式);
    • 输出:this window
    • 执行this.namewindow.name,不会触发personnamegetter(因为this不是person/badProxy),所以window._name不存在,返回undefined
    • 最终返回我叫undefined,控制台输出:我叫

  4. 执行console.log(goodProxy.introduce())

    • 它会读取goodProxy.introduce属性,触发goodProxyget 陷阱,参数:
    • target = personprop = 'introduce'receiver = goodProxy
  5. 第一次触发get陷阱(拦截introduce),此时返回的新函数被赋值给goodProxy.introduce,然后执行这个新函数

    console.log(`拦截: ${prop}`); // 输出:拦截: introduce → 第一次拦截
    if (prop === 'introduce') {
      return function () { // 返回一个新函数
        return Reflect.apply(target[prop], receiver, arguments); // 正确绑定 this
      };
    }
    
  6. 执行返回的新函数,Reflect.apply(target[prop], receiver, arguments),其中

    • target[prop]=person.introduce 函数;
    • receiver=goodProxy(把introduce方法的this绑定到goodProxy);
    • 执行person.introduce方法,此时方法内的this = goodProxy
  7. 执行 introduce 方法内部代码

    console.log('this', this); // 输出:this Proxy(Object) { _name: '张三' }(即 goodProxy)
    return `我叫${this.name}`; // 关键:读取 this.name → goodProxy.name
    
  8. 第二次触发get陷阱(拦截name),因为this = goodProxy,所以this.name等价于goodProxy.name,需要读取goodProxy.name属性,再次触发goodProxyget 陷阱,参数:

    • target = personprop = 'name'receiver = goodProxy
    • 进入get陷进函数
console.log(`拦截: ${prop}`); // 输出:拦截: name → 第二次拦截
if (prop === 'introduce') { /* 不执行 */ }
return Reflect.get(target, prop, receiver); // 调用 Reflect.get 读取 person.name

9. 调用Reflect.get(target, prop, receiver),触发person.name的 getter,此时 getter 里的thisreceiver绑定为goodProxy

get name() {
  console.log('getter 被调用,this:', this === person ? 'person' : this); 
  // 输出:getter 被调用,this: Proxy(Object) { _name: '张三' }
  return this._name; // this = goodProxy → 读取 goodProxy._name
}

10. 返回this._name(不是name!),这时会第三次触发goodProxy的get陷阱(prop = '_name'

console.log(`拦截: ${prop}`); // 输出:拦截: _name
return Reflect.get(target, '_name', receiver); // 返回 person._name = '张三'

11. 最终返回结果 我叫张三

![](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/cb44628ee3904c759428efdadbba9e90~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5Y-R546w5LiA5Y-q5aSn5ZGG55Oc:q75.awebp?rk3s=f64ab15b&x-expires=1770714393&x-signature=VN5mF0OKtlLwwfknHvfBPYIqpVE%3D)

四、 总结:Proxy 的降维打击

  1. 全方位拦截:不仅能拦截读写,还能拦截删除、函数调用、new 操作等。
  2. 性能优势:无需遍历属性,直接代理整个对象。
  3. 原生支持数组:完美解决 Vue 2 中数组监听的各种奇技淫巧(如重写数组原型方法)。
  4. 配合 Reflect:通过 receiver 参数完美转发 this 绑定,保证了响应式系统的严密性。
❌