Skip to content

Day 9: 技术亮点讲解

第九天重点:提炼项目中的技术亮点,准备深度讲解

更新时间: 2025-01

📋 目录

今日目标

  • 提炼 3-5 个技术亮点
  • 每个亮点能深入讲解原理
  • 准备亮点的演进过程
  • 能够回答深度追问

Part A: 技术亮点的定义

1. 什么是技术亮点?

┌─────────────────────────────────────────────────────────────┐
│                    技术亮点特征                              │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  1. 有深度                                                  │
│     - 不是简单的 CRUD                                       │
│     - 涉及到底层原理或复杂逻辑                              │
│     - 需要深入思考才能解决                                  │
│                                                              │
│  2. 有价值                                                  │
│     - 解决了实际问题                                        │
│     - 带来了可量化的收益                                    │
│     - 形成了可复用的方案                                    │
│                                                              │
│  3. 有创新                                                  │
│     - 不是照搬现成方案                                      │
│     - 有自己的思考和改进                                    │
│     - 或者将现有技术创新应用                                │
│                                                              │
│  4. 能展示能力                                              │
│     - 体现技术深度                                          │
│     - 体现解决问题的能力                                    │
│     - 体现学习和成长                                        │
│                                                              │
└─────────────────────────────────────────────────────────────┘

2. 亮点讲解结构

【亮点名称】简洁有力的标题

【背景价值】
- 为什么做这个?
- 解决什么问题?
- 带来什么价值?

【技术方案】
- 核心思路是什么?
- 关键技术点有哪些?
- 为什么选择这个方案?

【实现细节】
- 核心代码实现
- 难点处理
- 边界情况

【效果数据】
- 量化的结果
- 前后对比

【经验沉淀】
- 学到了什么?
- 可以复用什么?

Part B: 技术亮点案例

亮点1:高性能虚拟滚动组件

背景价值

业务需求:
- 后台系统需要展示大量数据(10万+)
- 传统分页无法满足需求,用户需要流畅滚动浏览
- 现有的虚拟滚动库无法满足复杂表格需求

技术挑战:
- 支持动态行高
- 支持表格合并单元格
- 支持固定列
- 性能要求:60fps 流畅滚动

技术方案

javascript
// 核心设计思路
// 1. 只渲染可视区域的元素
// 2. 使用 transform 代替修改 top 值
// 3. 动态计算可视区域范围
// 4. 缓冲区减少频繁更新

class VirtualScroller {
  constructor(options) {
    this.containerHeight = options.containerHeight;
    this.itemHeightGetter = options.itemHeightGetter;  // 支持动态高度
    this.buffer = options.buffer || 5;  // 缓冲区大小
    this.items = [];
    this.positions = [];  // 缓存每项位置

    this.init();
  }

  // 初始化位置缓存
  init() {
    this.positions = this.items.map((item, index) => {
      const height = this.itemHeightGetter(item, index);
      const prevPosition = this.positions[index - 1];
      const top = prevPosition ? prevPosition.bottom : 0;

      return {
        index,
        height,
        top,
        bottom: top + height,
      };
    });

    this.totalHeight = this.positions[this.positions.length - 1]?.bottom || 0;
  }

  // 二分查找起始索引
  findStartIndex(scrollTop) {
    let low = 0;
    let high = this.positions.length - 1;

    while (low <= high) {
      const mid = Math.floor((low + high) / 2);
      const midBottom = this.positions[mid].bottom;

      if (midBottom === scrollTop) {
        return mid + 1;
      } else if (midBottom < scrollTop) {
        low = mid + 1;
      } else {
        high = mid - 1;
      }
    }

    return low;
  }

  // 计算可视范围
  getVisibleRange(scrollTop) {
    const startIndex = Math.max(0, this.findStartIndex(scrollTop) - this.buffer);
    const endIndex = Math.min(
      this.positions.length - 1,
      this.findEndIndex(scrollTop + this.containerHeight) + this.buffer
    );

    return {
      startIndex,
      endIndex,
      startOffset: this.positions[startIndex]?.top || 0,
    };
  }

  // 获取可视数据
  getVisibleItems(scrollTop) {
    const { startIndex, endIndex, startOffset } = this.getVisibleRange(scrollTop);

    return {
      items: this.items.slice(startIndex, endIndex + 1),
      startOffset,
      totalHeight: this.totalHeight,
    };
  }
}

// Vue 组件封装
const VirtualList = defineComponent({
  props: {
    items: Array,
    itemHeight: [Number, Function],
    containerHeight: Number,
  },

  setup(props, { slots }) {
    const scrollTop = ref(0);
    const scroller = ref(null);

    watchEffect(() => {
      scroller.value = new VirtualScroller({
        items: props.items,
        itemHeightGetter: typeof props.itemHeight === 'function'
          ? props.itemHeight
          : () => props.itemHeight,
        containerHeight: props.containerHeight,
      });
    });

    const visibleData = computed(() =>
      scroller.value?.getVisibleItems(scrollTop.value)
    );

    const handleScroll = (e) => {
      scrollTop.value = e.target.scrollTop;
    };

    return () => (
      <div
        class="virtual-list-container"
        style={{ height: `${props.containerHeight}px`, overflow: 'auto' }}
        onScroll={handleScroll}
      >
        <div
          class="virtual-list-phantom"
          style={{ height: `${visibleData.value?.totalHeight}px` }}
        />
        <div
          class="virtual-list-content"
          style={{ transform: `translateY(${visibleData.value?.startOffset}px)` }}
        >
          {visibleData.value?.items.map((item, index) =>
            slots.default?.({ item, index })
          )}
        </div>
      </div>
    );
  },
});

效果数据

测试数据:10 万条记录

优化前(全量渲染):
- 首次渲染时间:8s+
- 滚动帧率:5fps
- 内存占用:1.2GB
- 页面崩溃率:30%

优化后(虚拟滚动):
- 首次渲染时间:200ms
- 滚动帧率:60fps
- 内存占用:80MB
- 页面崩溃率:0%

深度追问准备

Q: 为什么用 transform 而不是 absolute + top?
A: transform 不会触发重排(reflow),只触发重绘(repaint),
   而且可以开启 GPU 加速,性能更好。

Q: 动态高度是怎么处理的?
A: 通过位置缓存数组记录每一项的 top 和 bottom,
   滚动时用二分查找快速定位起始索引。

Q: 如果高度未知怎么办?
A: 可以先用预估高度渲染,渲染后获取真实高度更新位置缓存。

亮点2:前端权限系统设计

背景价值

业务需求:
- 系统有多种角色(管理员、运营、客服等)
- 需要控制页面级、按钮级权限
- 权限可能动态变化,需要热更新
- 前后端权限需要一致

技术挑战:
- 权限粒度要细
- 性能要好(不能每次都请求接口)
- 维护要方便

技术方案

javascript
// 权限架构设计
// 1. 基于 RBAC 模型
// 2. 前端三层控制:路由、组件、指令
// 3. 权限数据缓存 + 增量更新

// 权限服务
class PermissionService {
  constructor() {
    this.permissions = new Set();
    this.routes = [];
    this.initialized = false;
  }

  // 初始化权限
  async init() {
    if (this.initialized) return;

    const [permissionList, routeList] = await Promise.all([
      fetchPermissions(),
      fetchRoutes(),
    ]);

    this.permissions = new Set(permissionList);
    this.routes = routeList;
    this.initialized = true;
  }

  // 检查权限
  hasPermission(permission) {
    if (Array.isArray(permission)) {
      return permission.some(p => this.permissions.has(p));
    }
    return this.permissions.has(permission);
  }

  // 权限热更新
  async refresh() {
    const newPermissions = await fetchPermissions();
    this.permissions = new Set(newPermissions);
    // 触发路由重新生成
    this.regenerateRoutes();
  }

  // 生成动态路由
  generateRoutes() {
    return this.filterRoutes(this.routes);
  }

  filterRoutes(routes) {
    return routes.filter(route => {
      if (route.permission && !this.hasPermission(route.permission)) {
        return false;
      }
      if (route.children) {
        route.children = this.filterRoutes(route.children);
      }
      return true;
    });
  }
}

export const permissionService = new PermissionService();

// 路由守卫
router.beforeEach(async (to, from, next) => {
  await permissionService.init();

  if (to.meta?.permission) {
    if (!permissionService.hasPermission(to.meta.permission)) {
      return next('/403');
    }
  }

  next();
});

// 权限指令
app.directive('permission', {
  mounted(el, binding) {
    const { value, modifiers } = binding;

    if (!permissionService.hasPermission(value)) {
      if (modifiers.disable) {
        // 禁用模式:置灰
        el.disabled = true;
        el.style.opacity = '0.5';
        el.style.cursor = 'not-allowed';
      } else {
        // 隐藏模式:移除
        el.parentNode?.removeChild(el);
      }
    }
  },
});

// 组件级权限
const PermissionWrapper = defineComponent({
  props: {
    permission: [String, Array],
    fallback: {
      type: Object,
      default: null,
    },
  },

  setup(props, { slots }) {
    const hasPermission = computed(() =>
      permissionService.hasPermission(props.permission)
    );

    return () => {
      if (hasPermission.value) {
        return slots.default?.();
      }
      return props.fallback || slots.fallback?.();
    };
  },
});

// 使用示例
<template>
  <!-- 指令控制按钮 -->
  <button v-permission="'user:delete'">删除用户</button>
  <button v-permission.disable="'user:edit'">编辑用户</button>

  <!-- 组件控制区块 -->
  <PermissionWrapper permission="dashboard:view">
    <DashboardPanel />
    <template #fallback>
      <NoPermissionTip />
    </template>
  </PermissionWrapper>
</template>

效果数据

实现效果:
- 支持 100+ 个权限点的精细控制
- 权限变更实时生效,无需刷新页面
- 前后端权限一致性 100%

开发效率:
- 新增权限点只需配置,无需改代码
- 权限逻辑统一管理,维护成本低

亮点3:微前端架构落地

背景价值

业务背景:
- 公司有多个独立系统,技术栈不统一
- 需要整合到统一平台,用户一次登录访问所有系统
- 各子系统需要独立开发、独立部署

技术挑战:
- 不同技术栈(Vue2、Vue3、React)共存
- 子应用样式和 JS 隔离
- 应用间通信
- 公共依赖共享

技术方案

javascript
// 基于 qiankun 的微前端架构

// 主应用配置
import { registerMicroApps, start, initGlobalState } from 'qiankun';

// 子应用配置
const apps = [
  {
    name: 'vue-app',
    entry: '//localhost:8001',
    container: '#micro-container',
    activeRule: '/vue-app',
    props: {
      token: getToken(),
      user: getUserInfo(),
    },
  },
  {
    name: 'react-app',
    entry: '//localhost:8002',
    container: '#micro-container',
    activeRule: '/react-app',
  },
];

// 生命周期钩子
const lifeCycles = {
  beforeLoad: [
    app => {
      console.log('before load', app.name);
    },
  ],
  beforeMount: [
    app => {
      console.log('before mount', app.name);
    },
  ],
  afterUnmount: [
    app => {
      console.log('after unmount', app.name);
    },
  ],
};

registerMicroApps(apps, lifeCycles);

// 全局状态管理
const { onGlobalStateChange, setGlobalState } = initGlobalState({
  user: null,
  token: '',
});

// 监听全局状态变化
onGlobalStateChange((state, prev) => {
  console.log('Global state changed:', state, prev);
});

// 修改全局状态
setGlobalState({ token: 'new-token' });

start({
  sandbox: {
    strictStyleIsolation: true,  // 严格样式隔离
    experimentalStyleIsolation: true,
  },
  prefetch: 'all',  // 预加载所有子应用
});

// 子应用配置(Vue3)
// vue.config.js
module.exports = {
  devServer: {
    port: 8001,
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
  configureWebpack: {
    output: {
      library: 'vueApp',
      libraryTarget: 'umd',
      jsonpFunction: `webpackJsonp_vueApp`,
    },
  },
};

// main.js
let instance = null;

function render(props = {}) {
  const { container } = props;

  instance = createApp(App);
  instance.mount(container ? container.querySelector('#app') : '#app');
}

// 独立运行
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

// 导出生命周期钩子
export async function bootstrap() {
  console.log('vue app bootstrapped');
}

export async function mount(props) {
  console.log('vue app mounted');
  render(props);
}

export async function unmount() {
  console.log('vue app unmounted');
  instance.unmount();
  instance = null;
}

// 应用间通信
// 主应用
import { EventBus } from './utils/event-bus';

window.eventBus = new EventBus();

// 子应用
window.eventBus.emit('user:login', { userId: 123 });
window.eventBus.on('message:new', (data) => {
  // 处理消息
});

效果数据

架构效果:
- 成功整合 5 个子系统
- 支持 Vue2、Vue3、React 混合部署
- 子应用独立开发部署,互不影响

性能指标:
- 子应用加载时间 < 2s
- 应用切换无感知(预加载)
- 内存泄漏检测:切换 100 次无明显增长

开发效率:
- 新增子应用接入时间 < 1 天
- 复用公共组件和服务

亮点4:前端监控系统搭建

背景价值

业务需求:
- 线上问题难以复现和定位
- 缺乏性能数据支撑优化决策
- 用户行为分析需求

技术目标:
- 错误监控:JS 错误、资源加载错误、接口错误
- 性能监控:页面加载、接口耗时、渲染性能
- 用户行为:PV/UV、点击、停留时间

技术方案

javascript
// 监控 SDK 设计
class Monitor {
  constructor(options) {
    this.appId = options.appId;
    this.reportUrl = options.reportUrl;
    this.queue = [];
    this.timer = null;

    this.init();
  }

  init() {
    this.initErrorMonitor();
    this.initPerformanceMonitor();
    this.initBehaviorMonitor();
    this.startReportLoop();
  }

  // 错误监控
  initErrorMonitor() {
    // JS 运行时错误
    window.onerror = (msg, url, line, col, error) => {
      this.report({
        type: 'js_error',
        data: {
          message: msg,
          url,
          line,
          col,
          stack: error?.stack,
        },
      });
    };

    // Promise 未捕获错误
    window.onunhandledrejection = (event) => {
      this.report({
        type: 'promise_error',
        data: {
          message: event.reason?.message || event.reason,
          stack: event.reason?.stack,
        },
      });
    };

    // 资源加载错误
    window.addEventListener('error', (event) => {
      const target = event.target;
      if (target.tagName) {
        this.report({
          type: 'resource_error',
          data: {
            tagName: target.tagName,
            src: target.src || target.href,
          },
        });
      }
    }, true);

    // 接口错误(拦截 fetch 和 xhr)
    this.interceptFetch();
    this.interceptXHR();
  }

  interceptFetch() {
    const originalFetch = window.fetch;
    window.fetch = async (url, options) => {
      const startTime = Date.now();
      try {
        const response = await originalFetch(url, options);
        this.reportApi(url, startTime, response.status, response.ok);
        return response;
      } catch (error) {
        this.reportApi(url, startTime, 0, false, error.message);
        throw error;
      }
    };
  }

  reportApi(url, startTime, status, success, errorMsg) {
    this.report({
      type: 'api',
      data: {
        url,
        duration: Date.now() - startTime,
        status,
        success,
        errorMsg,
      },
    });
  }

  // 性能监控
  initPerformanceMonitor() {
    // 页面加载性能
    window.addEventListener('load', () => {
      setTimeout(() => {
        const timing = performance.timing;
        const paintTiming = performance.getEntriesByType('paint');

        this.report({
          type: 'performance',
          data: {
            // DNS 解析
            dns: timing.domainLookupEnd - timing.domainLookupStart,
            // TCP 连接
            tcp: timing.connectEnd - timing.connectStart,
            // 首字节时间
            ttfb: timing.responseStart - timing.requestStart,
            // DOM 解析
            domParse: timing.domInteractive - timing.responseEnd,
            // DOM Ready
            domReady: timing.domContentLoadedEventEnd - timing.navigationStart,
            // 页面完全加载
            load: timing.loadEventEnd - timing.navigationStart,
            // FP
            fp: paintTiming.find(e => e.name === 'first-paint')?.startTime,
            // FCP
            fcp: paintTiming.find(e => e.name === 'first-contentful-paint')?.startTime,
          },
        });
      }, 100);
    });

    // LCP、FID、CLS(Web Vitals)
    this.observeWebVitals();
  }

  observeWebVitals() {
    // LCP
    new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const lastEntry = entries[entries.length - 1];
      this.report({
        type: 'web_vitals',
        data: {
          name: 'LCP',
          value: lastEntry.startTime,
        },
      });
    }).observe({ entryTypes: ['largest-contentful-paint'] });

    // FID
    new PerformanceObserver((list) => {
      const entry = list.getEntries()[0];
      this.report({
        type: 'web_vitals',
        data: {
          name: 'FID',
          value: entry.processingStart - entry.startTime,
        },
      });
    }).observe({ entryTypes: ['first-input'] });

    // CLS
    let clsValue = 0;
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (!entry.hadRecentInput) {
          clsValue += entry.value;
        }
      }
      this.report({
        type: 'web_vitals',
        data: {
          name: 'CLS',
          value: clsValue,
        },
      });
    }).observe({ entryTypes: ['layout-shift'] });
  }

  // 用户行为监控
  initBehaviorMonitor() {
    // PV
    this.report({
      type: 'pv',
      data: {
        url: location.href,
        referrer: document.referrer,
      },
    });

    // 点击事件
    document.addEventListener('click', (e) => {
      const target = e.target;
      this.report({
        type: 'click',
        data: {
          tagName: target.tagName,
          className: target.className,
          id: target.id,
          text: target.innerText?.slice(0, 50),
          path: this.getXPath(target),
        },
      });
    });

    // 路由变化(SPA)
    this.listenRouteChange();
  }

  // 数据上报
  report(data) {
    this.queue.push({
      ...data,
      appId: this.appId,
      timestamp: Date.now(),
      url: location.href,
      userAgent: navigator.userAgent,
    });
  }

  startReportLoop() {
    this.timer = setInterval(() => {
      if (this.queue.length > 0) {
        const data = this.queue.splice(0, 10);
        this.send(data);
      }
    }, 5000);

    // 页面卸载时上报
    window.addEventListener('beforeunload', () => {
      if (this.queue.length > 0) {
        this.send(this.queue, true);
      }
    });
  }

  send(data, useBeacon = false) {
    if (useBeacon && navigator.sendBeacon) {
      navigator.sendBeacon(this.reportUrl, JSON.stringify(data));
    } else {
      fetch(this.reportUrl, {
        method: 'POST',
        body: JSON.stringify(data),
        headers: { 'Content-Type': 'application/json' },
      });
    }
  }
}

// 使用
const monitor = new Monitor({
  appId: 'my-app',
  reportUrl: '/api/monitor/report',
});

效果数据

监控覆盖:
- JS 错误捕获率:99%+
- 性能数据采集:100%
- 接口监控覆盖:100%

业务价值:
- 问题发现时间从用户反馈的 1 天缩短到 5 分钟
- 性能优化有数据支撑,首屏时间优化 40%
- 错误率从 2% 降低到 0.5%

亮点5:低代码表单引擎

背景价值

业务需求:
- 后台有大量表单页面,开发成本高
- 表单需求变化频繁,改动成本大
- 希望产品运营能自行配置表单

技术目标:
- 通过 JSON Schema 配置生成表单
- 支持复杂联动和校验规则
- 可视化拖拽配置

技术方案

javascript
// 表单引擎核心设计

// Schema 定义
const formSchema = {
  type: 'object',
  properties: {
    name: {
      type: 'string',
      title: '姓名',
      required: true,
      maxLength: 20,
      ui: {
        widget: 'input',
        placeholder: '请输入姓名',
      },
    },
    gender: {
      type: 'string',
      title: '性别',
      enum: ['male', 'female'],
      enumNames: ['男', '女'],
      ui: {
        widget: 'radio',
      },
    },
    age: {
      type: 'number',
      title: '年龄',
      minimum: 0,
      maximum: 150,
      ui: {
        widget: 'number',
      },
    },
    city: {
      type: 'string',
      title: '城市',
      ui: {
        widget: 'select',
        options: [],  // 动态加载
        dependencies: ['province'],  // 依赖省份
      },
    },
  },
  // 联动规则
  rules: [
    {
      when: { gender: 'female' },
      then: {
        age: { maximum: 120 },
      },
    },
    {
      when: { province: { $exists: true } },
      effect: 'loadCities',  // 触发加载城市
    },
  ],
};

// 表单渲染器
const FormRenderer = defineComponent({
  props: {
    schema: Object,
    value: Object,
    onChange: Function,
  },

  setup(props) {
    const formData = ref(props.value || {});
    const widgetRegistry = inject('widgetRegistry');

    // 递归渲染字段
    const renderField = (key, fieldSchema) => {
      const Widget = widgetRegistry.get(fieldSchema.ui?.widget || 'input');

      return (
        <FormItem
          key={key}
          label={fieldSchema.title}
          required={fieldSchema.required}
          rules={generateRules(fieldSchema)}
        >
          <Widget
            value={formData.value[key]}
            onChange={(val) => {
              formData.value[key] = val;
              handleFieldChange(key, val);
            }}
            {...fieldSchema.ui}
          />
        </FormItem>
      );
    };

    // 处理字段变化
    const handleFieldChange = async (key, value) => {
      // 执行联动规则
      await executeRules(key, value);
      props.onChange?.(formData.value);
    };

    // 执行联动规则
    const executeRules = async (changedKey, value) => {
      const { rules } = props.schema;
      if (!rules) return;

      for (const rule of rules) {
        if (matchCondition(rule.when, formData.value)) {
          if (rule.then) {
            // 修改其他字段
            applyThen(rule.then);
          }
          if (rule.effect) {
            // 执行副作用
            await executeEffect(rule.effect, formData.value);
          }
        }
      }
    };

    return () => (
      <Form>
        {Object.entries(props.schema.properties).map(([key, field]) =>
          renderField(key, field)
        )}
      </Form>
    );
  },
});

// 组件注册
const widgetRegistry = new Map([
  ['input', InputWidget],
  ['select', SelectWidget],
  ['radio', RadioWidget],
  ['checkbox', CheckboxWidget],
  ['date', DatePickerWidget],
  ['upload', UploadWidget],
  // 可扩展...
]);

// 校验规则生成
function generateRules(schema) {
  const rules = [];

  if (schema.required) {
    rules.push({ required: true, message: `${schema.title}不能为空` });
  }
  if (schema.maxLength) {
    rules.push({ max: schema.maxLength, message: `最多${schema.maxLength}个字符` });
  }
  if (schema.pattern) {
    rules.push({ pattern: new RegExp(schema.pattern), message: schema.patternMessage });
  }
  // 更多规则...

  return rules;
}

效果数据

开发效率:
- 简单表单配置时间:5 分钟
- 复杂表单配置时间:30 分钟
- 相比传统开发效率提升 5 倍

业务价值:
- 产品可自行配置 80% 的表单需求
- 表单变更从开发测试上线 3 天缩短到配置即生效

Part C: 亮点讲解技巧

1. 讲解层次

┌─────────────────────────────────────────────────────────────┐
│                    技术亮点讲解层次                          │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  第一层:概述(1分钟)                                      │
│  - 一句话说明做了什么                                       │
│  - 解决了什么问题                                           │
│  - 主要成果                                                 │
│                                                              │
│  第二层:方案(2-3分钟)                                    │
│  - 技术选型和原因                                           │
│  - 核心设计思路                                             │
│  - 关键技术点                                               │
│                                                              │
│  第三层:细节(按需深入)                                   │
│  - 具体实现代码                                             │
│  - 难点处理                                                 │
│  - 边界情况                                                 │
│                                                              │
│  第四层:反思(1分钟)                                      │
│  - 有什么不足                                               │
│  - 如果重做会怎么改进                                       │
│  - 学到了什么                                               │
│                                                              │
└─────────────────────────────────────────────────────────────┘

2. 回答追问的技巧

当面试官追问时:

1. 不要慌
   - 追问说明面试官感兴趣
   - 这是展示深度的机会

2. 结构化回答
   - 先总后分
   - 先主后次

3. 诚实回答
   - 不知道就说不知道
   - 可以说"这个我还没深入研究,但我的理解是..."

4. 主动延伸
   - 回答完可以补充相关的知识点
   - 展示知识面的广度

复习检查清单

  • 准备 3-5 个技术亮点
  • 每个亮点能讲 3-5 分钟
  • 准备好深度追问的回答
  • 有具体的量化数据
  • 能画出核心架构图

明日预告:Day 10 - 模拟面试

基于 VitePress 构建