阅读视图

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

你以为 Props 只是传参? 不,它是 React 组件设计的“灵魂系统”

90% 的 React 初学者,都低估了 Props。
他们以为它只是“从父组件往子组件传点数据”。

但真正写过复杂组件、设计过通用组件的人都知道一句话:

Props 决定了一个组件“好不好用”,而不是“能不能用”。

这篇文章,我们不讲 API 清单、不背概念,
而是围绕 Props 系统的 5 个核心能力,一次性讲透 React 组件化的底层逻辑:

  • Props 传递
  • Props 解构
  • 默认值(defaultProps / 默认参数)
  • 类型校验(PropTypes)
  • children 插槽机制(React 的核武器)

👉 看完你会明白:
React 真正厉害的不是 JSX,而是 Props 设计。


一、Props 的本质:组件的“对外接口”

先抛一个结论:

React 组件 ≈ 一个函数 + 一套 Props 接口

来看一个最简单的组件 👇

function Greeting(props) {
  return <h1>Hello, {props.name}</h1>
}

使用时:

<Greeting name="白兰地" />

很多人到这里就停了,但问题是:

name 到底是什么?

答案是:

name 不是变量,是组件对外暴露的能力。

Props 本质上是:

  • 父组件 👉 子组件的输入
  • 组件作者 👉 使用者的约定

二、Props 解构:不是语法糖,而是“设计声明”

对比两种写法 👇

❌ 不推荐

function Greeting(props) {
  return <h1>Hello, {props.name}</h1>
}

✅ 推荐

function Greeting({ name }) {
  return <h1>Hello, {name}</h1>
}

为什么?

解构不是为了少写字,而是为了表达意图。

当你看到函数签名:

function Greeting({ name, message, showIcon }) {}

你立刻就知道:

  • 这个组件“需要什么”
  • 组件的“输入边界”在哪里

👉 好的组件,从函数签名就能读懂。


三、Props 默认值:组件“健壮性”的第一步

看这个组件 👇

function Greeting({ name, message }) {
  return (
    <div>
      <h1>Hello, {name}</h1>
      <p>{message}</p>
    </div>
  )
}

如果使用者这么写:

<Greeting name="空瓶" />

会发生什么?

message === undefined

这时候就轮到 默认值 出场了。


方式一:defaultProps(经典)

Greeting.defaultProps = {
  message: 'Welcome!'
}

方式二:解构默认值(更推荐)

function Greeting({ name, message = 'Welcome!' }) {}

💡默认值不是兜底,而是组件设计的一部分。

它代表的是:

  • “在你不配置的情况下”
  • “组件应该表现成什么样”

四、Props 类型校验:组件的“自说明文档”

来看一段很多人忽略、但非常值钱的代码 👇

import PropTypes from 'prop-types'

Greeting.propTypes = {
  name: PropTypes.string.isRequired,
  message: PropTypes.string,
  showIcon: PropTypes.bool,
}

很多人会说:

“这不是可有可无吗?”

但在真实项目里,它解决的是:

  • ❌ 参数传错没人发现
  • ❌ 新人不知道组件怎么用
  • ❌ 组件一多,全靠猜

🔍 PropTypes 的真正价值

不是防 bug,而是“降低理解成本”。

当你看到 propTypes,就等于看到一份说明书:

  • 哪些 props 必须传?
  • 哪些是可选?
  • 类型是什么?

👉 一个没有 propTypes 的通用组件,本质上是“黑盒”。


五、children:React Props 系统的“王炸”

如果只能选一个 Props 机制,我会毫不犹豫选:

🧨 children

来看一个 Card 组件 👇

const Card = ({ children, className = '' }) => {
  return (
    <div className={`card ${className}`}>
      {children}
    </div>
  )
}

使用时:

<Card className="user-card">
  <h2>张三</h2>
  <p>高级前端工程师</p>
  <button>查看详情</button>
</Card>

这里发生了一件非常重要的事情:

组件不再关心“内容是什么”。


🧠 children 的设计哲学

组件负责“骨架”,使用者负责“填充”。

  • Card 只负责:边框、阴影、间距
  • children 决定:展示什么内容

这让组件具备了两个特性:

  • ✅ 高度复用
  • ✅ 永不过期

六、children + Props = 通用组件的终极形态

再看一个更高级的例子:Modal 👇

<Modal HeaderComponent={MyHeader} FooterComponent={MyFooter}>
  <p>这是一个弹窗</p>
  <p>你可以在这里显示任何 JSX</p>
</Modal>

Modal 的实现:

function Modal({ HeaderComponent, FooterComponent, children }) {
  return (
    <div>
      <HeaderComponent />
      {children}
      <FooterComponent />
    </div>
  )
}

这背后是一个非常高级的思想:

Props 不只是数据,也可以是组件。


七、请记住这 5 条 Props 设计铁律

🔥 如果你只能记住一段话,请记住这里

  1. Props 是组件的“对外接口”,不是随便传的变量
  2. 解构 Props,是在声明组件的能力边界
  3. 默认值,决定组件的“基础体验”
  4. 类型校验,让组件自带说明书
  5. children,让组件从“可用”变成“好用”

八、写在最后

当你真正理解 Props 之后,你会发现:

  • React 不只是 UI 库
  • 它在教你如何设计 API
  • 如何让别人“用得爽”

Props 写得好不好,决定了一个人 React 水平的上限。

从微信公众号&小程序的SDK剖析JSBridge

从微信公众号&小程序的SDK剖析JSBridge

引言

在移动互联网时代,Hybrid应用已成为主流开发模式之一。JSBridge作为连接JavaScript与Native的核心桥梁,让Web页面能够调用原生能力,实现了跨平台开发的完美平衡。微信作为国内最大的超级应用,其公众号JSSDK和小程序架构为我们提供了绝佳的JSBridge实践案例。本文将深入剖析这两套SDK的实现原理,帮助读者理解JSBridge的本质与设计思想。

一、JSBridge核心概念

1.1 什么是JSBridge

JSBridge是JavaScript与Native之间的通信桥梁,它建立了双向消息通道,使得:

  • JavaScript调用Native: Web页面可以调用原生能力(相机、地理位置、支付等)
  • Native调用JavaScript: 原生代码可以向Web页面传递数据或触发事件

1.2 JSBridge通信架构

graph TB
    subgraph WebView层
        A[JavaScript代码]
    end

    subgraph JSBridge层
        B[消息队列]
        C[协议解析器]
    end

    subgraph Native层
        D[原生API Handler]
        E[系统能力]
    end

    A -->|发起调用| B
    B -->|解析协议| C
    C -->|转发请求| D
    D -->|调用能力| E
    E -->|返回结果| D
    D -->|回调| C
    C -->|执行callback| A

    style A fill:#e1f5ff
    style E fill:#fff4e1
    style C fill:#f0f0f0

1.3 通信方式对比

JSBridge主要有三种实现方式:

方式 原理 优点 缺点
URL Schema拦截 通过iframe.src触发特定协议 兼容性好,iOS/Android通用 有URL长度限制,不支持同步返回
注入API Native向WebView注入全局对象 调用简单直接 Android 4.2以下有安全风险
MessageHandler WKWebView的postMessage机制 性能好,安全性高 仅iOS可用

二、微信公众号JSSDK实现原理

2.1 JSSDK架构设计

微信公众号的JSSDK基于WeixinJSBridge封装,提供了更安全和易用的接口。

sequenceDiagram
    participant H5 as H5页面
    participant SDK as wx-JSSDK
    participant Bridge as WeixinJSBridge
    participant Native as 微信客户端

    H5->>SDK: 调用wx.config()
    SDK->>Native: 请求签名验证
    Native-->>SDK: 返回验证结果

    H5->>SDK: 调用wx.chooseImage()
    SDK->>Bridge: invoke('chooseImage', params)
    Bridge->>Native: 转发调用请求
    Native->>Native: 打开相册选择
    Native-->>Bridge: 返回图片数据
    Bridge-->>SDK: 触发回调
    SDK-->>H5: success(res)

2.2 JSSDK初始化流程

JSSDK的初始化需要完成配置验证和ready状态准备:

// 步骤1: 引入JSSDK
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>

// 步骤2: 配置权限验证
wx.config({
  debug: false,
  appId: 'your-app-id',
  timestamp: 1234567890,
  nonceStr: 'random-string',
  signature: 'sha1-signature',
  jsApiList: ['chooseImage', 'uploadImage', 'getLocation']
});

// 步骤3: 监听ready事件
wx.ready(function() {
  // 配置成功后才能调用API
  console.log('JSSDK初始化完成');
});

wx.error(function(res) {
  console.error('配置失败:', res);
});

配置验证流程说明:

  1. 获取签名: 后端通过jsapi_ticket和当前URL生成SHA1签名
  2. 前端配置: 将签名等参数传入wx.config()
  3. 客户端验证: 微信客户端校验签名的合法性
  4. 授权完成: 验证通过后触发ready事件

2.3 WeixinJSBridge底层机制

WeixinJSBridge是微信内部提供的原生接口,不对外公开但可以直接使用:

// 检测WeixinJSBridge是否ready
function onBridgeReady() {
  WeixinJSBridge.invoke(
    'getBrandWCPayRequest',
    {
      appId: 'wx123456',
      timeStamp: '1234567890',
      nonceStr: 'randomstring',
      package: 'prepay_id=xxx',
      signType: 'MD5',
      paySign: 'signature'
    },
    function(res) {
      if (res.err_msg === 'get_brand_wcpay_request:ok') {
        console.log('支付成功');
      }
    }
  );
}

if (typeof WeixinJSBridge === 'undefined') {
  document.addEventListener('WeixinJSBridgeReady', onBridgeReady, false);
} else {
  onBridgeReady();
}

WeixinJSBridge与wx JSSDK的关系:

  • WeixinJSBridge: 底层原生接口,直接由微信客户端注入,无需引入外部JS
  • wx JSSDK: 基于WeixinJSBridge的高级封装,提供统一的API规范和安全验证
flowchart LR
    A[H5页面] -->|引入jweixin.js| B[wx JSSDK]
    B -->|封装调用| C[WeixinJSBridge]
    C -->|Native注入| D[微信客户端]
    D -->|系统能力| E[&#34;相机、支付、定位等&#34;]

    style B fill:#07c160
    style C fill:#ff9800
    style D fill:#576b95

2.4 典型API调用示例

以选择图片为例,展示完整的调用链路:

// 封装图片选择功能
function selectImages(count = 9) {
  return new Promise((resolve, reject) => {
    wx.chooseImage({
      count: count,          // 最多选择数量
      sizeType: ['original', 'compressed'],
      sourceType: ['album', 'camera'],
      success: function(res) {
        const localIds = res.localIds; // 返回本地图片ID列表
        resolve(localIds);
      },
      fail: function(err) {
        reject(err);
      }
    });
  });
}

// 使用示例
wx.ready(async function() {
  try {
    const imageIds = await selectImages(5);
    console.log('已选择图片:', imageIds);

    // 继续上传图片
    uploadImages(imageIds);
  } catch (error) {
    console.error('选择失败:', error);
  }
});

function uploadImages(localIds) {
  localIds.forEach(localId => {
    wx.uploadImage({
      localId: localId,
      isShowProgressTips: 1,
      success: function(res) {
        const serverId = res.serverId; // 服务器端图片ID
        // 将serverId发送给后端保存
        console.log('上传成功:', serverId);
      }
    });
  });
}

三、微信小程序双线程架构

3.1 小程序架构设计

微信小程序采用双线程模型,将渲染层与逻辑层完全隔离:

graph TB
    subgraph 渲染层[渲染层 View - WebView]
        A[WXML模板]
        B[WXSS样式]
        C[组件系统]
    end

    subgraph 逻辑层[逻辑层 AppService - JSCore]
        D[JavaScript代码]
        E[小程序API - wx对象]
        F[数据管理]
    end

    subgraph 系统层[Native - 微信客户端]
        G[JSBridge]
        H[网络请求]
        I[文件系统]
        J[设备能力]
    end

    A -.->|数据绑定| F
    C -.->|事件触发| D
    D -->|setData| G
    G -->|更新视图| A
    E -->|调用能力| G
    G -->|转发请求| H
    G -->|转发请求| I
    G -->|转发请求| J

    style 渲染层 fill:#e3f2fd
    style 逻辑层 fill:#f3e5f5
    style 系统层 fill:#fff3e0

架构设计的核心优势:

  1. 安全隔离: 逻辑层无法直接操作DOM,防止XSS攻击
  2. 多WebView支持: 每个页面独立WebView,支持多页面并存
  3. 性能优化: 逻辑层使用JSCore,不加载DOM/BOM,执行更快

3.2 小程序JSBridge通信机制

sequenceDiagram
    participant Logic as 逻辑层<br/>(JSCore)
    participant Bridge as JSBridge
    participant Native as Native层
    participant View as 渲染层<br/>(WebView)

    Note over Logic,View: 场景1: 数据更新
    Logic->>Bridge: setData({key: value})
    Bridge->>Native: 序列化数据
    Native->>View: 传递Virtual DOM diff
    View->>View: 更新页面渲染

    Note over Logic,View: 场景2: 事件响应
    View->>Bridge: bindtap事件触发
    Bridge->>Native: 序列化事件对象
    Native->>Logic: 调用事件处理函数
    Logic->>Logic: 执行业务逻辑

    Note over Logic,View: 场景3: API调用
    Logic->>Bridge: wx.request(options)
    Bridge->>Native: 转发网络请求
    Native->>Native: 发起HTTP请求
    Native-->>Bridge: 返回响应数据
    Bridge-->>Logic: 触发success回调

3.3 数据通信实现

setData是小程序中最核心的通信API,用于逻辑层向渲染层传递数据:

Page({
  data: {
    userInfo: {},
    items: []
  },

  onLoad: function() {
    // 通过setData更新数据,触发视图更新
    this.setData({
      userInfo: {
        name: '张三',
        avatar: 'https://example.com/avatar.jpg'
      },
      items: [1, 2, 3, 4, 5]
    });
  },

  // 优化建议: 只更新变化的字段
  updateUserName: function(newName) {
    this.setData({
      'userInfo.name': newName  // 使用路径语法,减少数据传输
    });
  },

  // 避免频繁setData
  handleScroll: function(e) {
    // 错误示范: 每次滚动都setData
    // this.setData({ scrollTop: e.detail.scrollTop });

    // 正确做法: 节流处理
    clearTimeout(this.scrollTimer);
    this.scrollTimer = setTimeout(() => {
      this.setData({ scrollTop: e.detail.scrollTop });
    }, 100);
  }
});

setData底层流程:

  1. 序列化数据: 将JS对象序列化为JSON字符串
  2. 通过JSBridge发送: Native层接收数据
  3. 传递到渲染层: Native将数据转发到WebView
  4. Virtual DOM Diff: 计算差异并更新视图

3.4 小程序API调用机制

小程序的wx对象是Native注入的JSBridge接口:

// 网络请求示例
function fetchUserData(userId) {
  return new Promise((resolve, reject) => {
    wx.request({
      url: `https://api.example.com/user/${userId}`,
      method: 'GET',
      header: {
        'content-type': 'application/json'
      },
      success(res) {
        if (res.statusCode === 200) {
          resolve(res.data);
        } else {
          reject(new Error(`请求失败: ${res.statusCode}`));
        }
      },
      fail(err) {
        reject(err);
      }
    });
  });
}

// 使用async/await优化
async function loadUserInfo() {
  wx.showLoading({ title: '加载中...' });

  try {
    const userData = await fetchUserData(123);
    this.setData({ userInfo: userData });
  } catch (error) {
    wx.showToast({
      title: '加载失败',
      icon: 'none'
    });
  } finally {
    wx.hideLoading();
  }
}

API调用流程图:

flowchart TD
    A[小程序调用 wx.request] --> B{JSBridge检查}
    B -->|参数校验| C[序列化请求参数]
    C --> D[Native接管网络请求]
    D --> E[系统发起HTTP请求]
    E --> F{请求结果}
    F -->|成功| G[回调success函数]
    F -->|失败| H[回调fail函数]
    G --> I[返回数据到逻辑层]
    H --> I
    I --> J[complete函数执行]

    style A fill:#07c160
    style D fill:#ff9800
    style E fill:#2196f3

四、自定义JSBridge实现

4.1 基础实现方案

基于URL Schema拦截实现一个简单的JSBridge:

class JSBridge {
  constructor() {
    this.callbacks = {};
    this.callbackId = 0;

    // 注册全局回调处理函数
    window._handleMessageFromNative = this._handleCallback.bind(this);
  }

  // JavaScript调用Native
  callNative(method, params = {}, callback) {
    const cbId = `cb_${this.callbackId++}`;
    this.callbacks[cbId] = callback;

    const schema = `jsbridge://${method}?params=${encodeURIComponent(
      JSON.stringify(params)
    )}&callbackId=${cbId}`;

    // 创建隐藏iframe触发schema
    const iframe = document.createElement('iframe');
    iframe.style.display = 'none';
    iframe.src = schema;
    document.body.appendChild(iframe);

    setTimeout(() => {
      document.body.removeChild(iframe);
    }, 100);
  }

  // Native回调JavaScript
  _handleCallback(callbackId, result) {
    const callback = this.callbacks[callbackId];
    if (callback) {
      callback(result);
      delete this.callbacks[callbackId];
    }
  }

  // 注册可被Native调用的方法
  registerHandler(name, handler) {
    this[name] = handler;
  }
}

// 使用示例
const bridge = new JSBridge();

// 调用Native方法
bridge.callNative('getLocation', {
  type: 'wgs84'
}, function(location) {
  console.log('位置信息:', location);
});

// 注册供Native调用的方法
bridge.registerHandler('updateTitle', function(title) {
  document.title = title;
});

4.2 Promise风格封装

将回调风格改造为Promise,提升开发体验:

class ModernJSBridge extends JSBridge {
  invoke(method, params = {}) {
    return new Promise((resolve, reject) => {
      this.callNative(method, params, (result) => {
        if (result.code === 0) {
          resolve(result.data);
        } else {
          reject(new Error(result.message));
        }
      });
    });
  }
}

// 现代化使用方式
const bridge = new ModernJSBridge();

async function getUserLocation() {
  try {
    const location = await bridge.invoke('getLocation', {
      type: 'wgs84'
    });
    console.log('经度:', location.longitude);
    console.log('纬度:', location.latitude);
  } catch (error) {
    console.error('获取位置失败:', error.message);
  }
}

4.3 Native端实现(以Android为例)

Android端需要拦截WebView的URL请求并解析协议:

// 这是伪代码示意,用JavaScript语法描述Android的WebViewClient逻辑

class JSBridgeWebViewClient {
  shouldOverrideUrlLoading(view, url) {
    // 拦截自定义协议
    if (url.startsWith('jsbridge://')) {
      this.handleJSBridgeUrl(url);
      return true;  // 拦截处理,不加载URL
    }
    return false;  // 正常加载
  }

  handleJSBridgeUrl(url) {
    // 解析: jsbridge://getLocation?params=xxx&callbackId=cb_1
    const urlObj = new URL(url);
    const method = urlObj.hostname;  // getLocation
    const params = JSON.parse(
      decodeURIComponent(urlObj.searchParams.get('params'))
    );
    const callbackId = urlObj.searchParams.get('callbackId');

    // 调用原生能力
    switch(method) {
      case 'getLocation':
        this.getLocation(params, (location) => {
          // 回调JavaScript
          this.callJS(callbackId, {
            code: 0,
            data: location
          });
        });
        break;
    }
  }

  callJS(callbackId, result) {
    const script = `window._handleMessageFromNative('${callbackId}', ${
      JSON.stringify(result)
    })`;
    webView.evaluateJavascript(script, null);
  }

  getLocation(params, callback) {
    // 调用Android LocationManager获取位置
    // 这里是伪代码,实际需要原生Java/Kotlin实现
    const location = {
      longitude: 116.404,
      latitude: 39.915
    };
    callback(location);
  }
}

五、性能优化与最佳实践

5.1 性能优化要点

graph TB
    A[JSBridge性能优化] --> B[通信优化]
    A --> C[数据优化]
    A --> D[调用优化]
    A --> E[内存管理]

    B --> B1[减少通信频次]
    B --> B2[批量传输数据]
    B --> B3[使用增量更新]
    B --> B4[避免大数据传输]

    C --> C1[JSON序列化优化]
    C --> C2[数据压缩]
    C --> C3[惰性加载]
    C --> C4[缓存机制]

    D --> D1[异步非阻塞]
    D --> D2[超时处理]
    D --> D3[失败重试]
    D --> D4[降级方案]

    E --> E1[及时释放回调]
    E --> E2[避免内存泄漏]
    E --> E3[限制队列长度]

    style A fill:#e3f2fd
    style B fill:#fff3e0
    style C fill:#f3e5f5
    style D fill:#e8f5e9
    style E fill:#ffe0b2

5.2 最佳实践

1. 合理使用setData(小程序场景):

// 不好的做法
for (let i = 0; i < 100; i++) {
  this.setData({
    [`items[${i}]`]: data[i]
  });  // 100次通信
}

// 好的做法
const updates = {};
for (let i = 0; i < 100; i++) {
  updates[`items[${i}]`] = data[i];
}
this.setData(updates);  // 1次通信

2. 实现超时与错误处理:

class SafeJSBridge extends ModernJSBridge {
  invoke(method, params = {}, timeout = 5000) {
    return Promise.race([
      super.invoke(method, params),
      new Promise((_, reject) => {
        setTimeout(() => {
          reject(new Error(`调用${method}超时`));
        }, timeout);
      })
    ]);
  }
}

// 使用
try {
  const result = await bridge.invoke('slowMethod', {}, 3000);
} catch (error) {
  if (error.message.includes('超时')) {
    console.error('请求超时,请检查网络');
  }
}

3. 权限与安全检查:

// JSSDK安全最佳实践
const secureConfig = {
  // 1. 签名在后端生成,前端不暴露secret
  getSignature: async function(url) {
    const response = await fetch('/api/wechat/signature', {
      method: 'POST',
      body: JSON.stringify({ url })
    });
    return response.json();
  },

  // 2. 动态配置jsApiList,按需授权
  init: async function() {
    const signature = await this.getSignature(location.href);
    wx.config({
      ...signature,
      jsApiList: ['chooseImage']  // 只申请需要的权限
    });
  }
};

六、调试技巧

6.1 调试流程

flowchart LR
    A[开发阶段] --> B{启用debug模式}
    B -->|wx.config debug:true| C[查看vconsole日志]
    B -->|Chrome DevTools| D[断点调试]

    C --> E[检查API调用]
    D --> E

    E --> F{定位问题}
    F -->|签名错误| G[检查后端签名逻辑]
    F -->|API调用失败| H[检查权限配置]
    F -->|通信异常| I[检查JSBridge实现]

    G --> J[修复并重测]
    H --> J
    I --> J

    style B fill:#ff9800
    style F fill:#f44336
    style J fill:#4caf50

6.2 常见问题排查

1. 微信JSSDK签名失败:

// 调试签名问题
wx.config({
  debug: true,  // 开启调试模式
  // ... 其他配置
});

wx.error(function(res) {
  console.error('配置失败详情:', res);
  // 常见错误:
  // invalid signature - 签名错误,检查URL是否一致(不含#hash)
  // invalid url domain - 域名未配置到白名单
});

// 检查点:
// 1. 确保URL不包含hash部分
const url = location.href.split('#')[0];

// 2. 确保timestamp是整数
const timestamp = Math.floor(Date.now() / 1000);

// 3. 确保签名算法正确(SHA1)
// 签名原串: jsapi_ticket=xxx&noncestr=xxx&timestamp=xxx&url=xxx

2. 小程序setData性能问题:

// 开启性能监控
wx.setEnableDebug({
  enableDebug: true
});

// 监控setData性能
const perfObserver = wx.createPerformanceObserver((entries) => {
  entries.getEntries().forEach((entry) => {
    if (entry.entryType === 'render') {
      console.log('渲染耗时:', entry.duration);
    }
  });
});

perfObserver.observe({ entryTypes: ['render', 'script'] });

七、总结

JSBridge作为Hybrid开发的核心技术,通过建立JavaScript与Native的通信桥梁,实现了Web技术与原生能力的完美融合。本文通过剖析微信公众号JSSDK和小程序SDK,深入理解了以下关键点:

  1. 通信机制: URL Schema拦截、API注入、MessageHandler三种主流方式
  2. 架构设计: 微信小程序的双线程模型提供了安全性和性能的最佳平衡
  3. 实现原理: 从JSSDK的签名验证到小程序的setData机制,理解了完整的调用链路
  4. 最佳实践: 性能优化、错误处理、安全防护等工程化经验

掌握JSBridge原理不仅能帮助我们更好地使用微信生态的各种能力,也为构建自己的Hybrid框架提供了坚实的理论基础。在实际项目中,应根据具体场景选择合适的实现方案,并持续关注性能与安全,打造更优质的用户体验。

参考资料

年终总结 - 2025 故事集

📕 如果您刚好点了进来,却不想完整阅读该文章但又想知道它记录了什么。可跳到文末总结

前言

时隔四个月,再执笔即将进入了新的一年 2026 年...

2025 & 2026

时间像往常一样无声息地流动,已近年尾,在过去的 2025 年,三百多天时间里面,发生了很多的事情,或喜,或悲,或静,或闹...此时,灯亮着,窗外偶尔有远处汽车的沙沙声。我在其中,开始回顾并记录撞进心底的瞬间和感受。

你好,世界

还是熟悉的四月份的一天凌晨,老妈跟我在走廊里踱步~

随着清脆的哭声响起,二宝如期而至。过了段时间,护士出来报出母女平安是我们听到的此刻最让人心安的话语。

为什么说是熟悉的四月份,因为老大也是四月份出生的

因为老婆在工作日凌晨分娩,所以我的休陪产的单也先提交了。在收到老婆产后无需我协助事情的话语后,我撤销了陪产单,屁颠屁颠地去上班赚奶粉钱了😄

嗯,从准奶爸到首次喜当爹至今,短短三年时间里面,自己已经是两个小孩的爸爸,真是一个让自己意想不到的速度。

自从当了父母之后,我们更加懂得自己父母的无私且伟大,孩子的天真和无知

相对于第一次喜当爹时候,自己慌张无措,老妈辛苦地忙前忙后,手慌脚乱。有了第一次的经验,我们对于二宝的处理还是挺稳定:

  • 在预产期临近的两三天,我们准备好了大包小包的待产包 -> alway stand by
  • 产后的三天时间,请护工照看老婆和新生儿,老妈在旁边陪同,老爸在家照看大宝
  • 出院后,老婆和二宝直接月子中心坐月子。老妈和我在家照看大宝,周末月子中心看二宝

daughters in nursing room

👆即将出月子中心,大宝和二宝的合影👆

在日常里接力的我们

每天,我们都觉得时间不够用,能留出些许空间和时间来放松,已经很满足😌

老婆来回奔波的工作日

在休完三个多月的产假之后,老婆就去复工了。因为二宝还小,老婆会每天中午都回来哺乳。从小孩三个多月到七个多月,雷打不动,公司和家两头跑。

那一台小电驴,隔三差五就需要去充电。小小电驴,已经超出了它的价值~

好不容易,让二宝断奶了。断奶是件很痛苦的事情,要熬夜,涨奶胸痛等。我还记得在成功断奶后的那天晚上,老婆还特意叫我出去买瓶酒回来庆祝一下✨

beer

👆5%-8% vol 的鸡尾酒👆

虽然二宝断奶了,但是老婆在工作不忙的时候,还是会中午回来看看。用我老婆的话说:有点讨厌,但是又有点舍不得二宝

工作日,爷爷奶奶的时光

老婆跟我,工作日都需要上班,嗯~赚奶粉钱😀

然后,两个宝宝,工作日的时候主要给爷爷和奶奶带。

有时候,两个宝宝都需要奶奶抱,这可苦了奶奶的腰板子了。爷爷更多的时候,是充当了厨师的角色,保证一家人的三餐伙食,嗯~老爸的厨艺真好👍

爷爷奶奶一天下来的流程:早上带娃出去晒太阳,遛娃(主要是让大宝动起来,中午好睡觉);中午喂饭,午休(大宝一般中午休息两个钟,下午三或四点起来);下午洗澡(怕冷着小孩,一般天黑前洗完),喂饭,陪玩;晚上,等老婆和我下班回来,爷爷和奶奶才有空闲的时间。一般这个时候,爷爷就喜欢下楼去周边逛,奶奶就会躺着床上直一下腰板子(有时会跟爷爷下楼逛街)。工作日的时候,如果奶奶晚上没有出去逛街,那么,会在九点多喂完奶给大宝,奶奶会哄大宝睡觉;如果奶奶外出,那么我就会哄大宝睡觉。

mother's birthday

👆奶奶生日的时候,两宝和爷爷奶奶合影👆

休息日,我们的时光

工作日,班上完了;休息日,该带娃了。爷爷奶奶休息日放假,想去哪里就去哪里,放松放松。

休息日带娃,我们的宗旨就是:尽量让娃多动。所以,我们基本都会外出。忙忙碌碌,嗯,我们分享两件事情:

我还记得,某个周末,我们在商场逛了一天,让大宝在商场里面走,她逛得贼开心(这可不,逛得有多累,睡得有多香),推着二宝。中午直接在商场里面解决吃饭的问题,大宝直接在婴儿车上解决了午睡的事情,二宝则是被老婆或者我背在身上睡觉。母婴室没人的时候,我们就会在里面小憩一会。等两宝醒来之后,再逛一下,一天的时间过得慢但是又很快

今年的国庆连着中秋,我们在这个长假期里面,会带他们在小区里面露营(在草坪上铺一垫子),让她们自己玩。大宝走路的年纪,这里走那里走,我得屁颠屁颠跟她后面,从这里把她抱过来那里,从那边把她哄过来这边,真想拿条链子绑着她。相反,二宝就淡定多了,只能在那块布那里爬来爬去,被她妈妈限制着。

Mid-Autumn Festival

👆中秋节当晚,在哄两娃睡着后,老婆跟我在阳台拜月👆

没有惊喜的工位

相对于上一年工作的惊吓,今年的工作可以用没有惊喜来形容。

至于为什么说上一年是惊吓,今年没有惊喜。后面有时间,会出一篇文章来分享下。

简简单单的工位,一水杯,一记事本,一台式电脑,一绿植。屁股一坐,一整天嗖一下就过去了~

在公司,让我活跃起来的,就是中午吃饭的时候。我们的小团体(一安卓,一产品和我)开车去周边吃饭。这段时间,是我们唠嗑的时间,无拘无束,即使我们偶尔会浪费掉午休的时间,但是我还是觉得挺不错的,时间花得值...

工作上糟心的事十根手指可数不过来,触动且温暖了心窝的事情屈指可数。

记得招进来的一个新人,我带了他几天,最后入职短短几天被某人恶心而离职了。他离职的前一天,点了一杯奶茶给我,虽然自己嘴里面说着别客气,但是心里面暖暖的。他才进来短短几天就走人了,自己心里莫名生气:为什么我自己招的人,自己带着熟悉项目后,一转手就被恶心到要离职了???最终他却还温柔地以自我问题作离职的原因。

colleague communication

👆点了份奶茶放我桌面后的对话👆

把明天轻轻放进心里

2026 年悄然将至。在对新的一年有所展望之前,我们先回顾下年终总结 - 2024 故事集中立下的两个 Flags 和完成的情况:

序号 目标 实际 完成率
1 分享文章 20+ 分享文章 18 90%
2 锻炼 30+ 锻炼 32 107%

嗯~ 目标完成率还不赖。

do execise

👆每次锻炼我都会在朋友圈记录,每次耗时 45 分钟左右👆

对于分享文章,一开始就是秉承着记录自己在工作中遇到的一些问题,方便自己和其他人查找的宗旨来进行记录,后面是因为平台搞了奖励而进行的一些创作。而现在,随着 chatgpt, deepseek 等大语言模型的机器人横空出世,浅显的分享和问题的记录都显得鸡肋。所以,在 2026 新的一年内,文章的分享要更加有目的性和实际的意义。2026 年,谁知道会有几篇文章会出炉,也许一篇,也许十篇,也许二十篇,也许零篇。

对于锻炼,这是我长期需要坚持的一件事情,也是最好量化的事情。在新的一年里面,锻炼的次数需 35+

为人父母,为人儿女。我们都有自己的那份责任,2026 年,希望自己更多的时间是回归家庭 - 去听听孩子的欢声笑语,去看看爸妈脸上的笑容,去体验大家聚在一起热热闹闹的氛围 and more

family gathering

👆老爸生日,大姐,二姐大家的娃聚在一起👆

总结

2025 年,简简单单却忙忙碌碌👇:

在生活方面,欢迎二宝加入大家庭。这让我们接下来的一年时间里面,时间安排更加充实紧凑,更感受到当爹妈的不容易,感恩自己的父母在以前那年代含辛茹苦带大了我们三姐弟。在工作方面,没有太多想记录的东西,平平淡淡地打卡上下班。

展望 2026,还是给自己制定了锻炼次数的量化目标。在这个人工智能逐渐成熟的环境下,希望自己能够使用它提升工作效率和帮助自己成长。在 2026 年,自己的重心会放在家庭这边,去感受孩子的成长和家的氛围。

完成于中国广东省广州市

2025 年 12 月 22 日

C# 正则表达式(2):Regex 基础语法与常用 API 全解析

一、IsMatch 入门

using System;
using System.Text.RegularExpressions;

class Program
{
    static void Main()
    {
        string input = "2025-12-18";
        string pattern = @"^\d{4}-\d{2}-\d{2}$";

        bool isValid = Regex.IsMatch(input, pattern);
        Console.WriteLine(isValid); // True
    }
}

解析:

  • pattern 是正则表达式,@"..." 是 C# 的逐字字符串字面量。
  • ^$:锚点,表示“从头到尾整串匹配”
  • \d{4}:4 位数字。
  • -:字面量“-”。
  • Regex.IsMatch:看字符串中是不是“满足这个模式”。

二、C# Regex 的 5 个核心方法

System.Text.RegularExpressions.Regex 里,最常用的就是这 5 个方法:

  1. IsMatch
  2. Match
  3. Matches
  4. Replace
  5. Split

三、Regex.IsMatch:最常用的“判断是否匹配”

IsMatch 是表单校验、输入合法性检查中使用频率最高的方法。

bool isEmail = Regex.IsMatch(email, pattern);

示例:

string input = "Order12345";
string pattern = @"\d{3}";

bool has3Digits = Regex.IsMatch(input, pattern); // True

注意点:

  • 默认只要“包含”满足 pattern 的子串,就返回 true,并不要求整个字符串都完全匹配。
  • 如果你想“整个字符串必须符合这个规则”,要在 pattern 外面加上 ^$
// 只允许由 3~5 位数字组成,不允许多一个字符
string pattern = @"^\d{3,5}$";

四、Regex.Match:获取第一个匹配

string text = "My phone is 123-456-7890.";
string pattern = @"\d{3}-\d{3}-\d{4}";

Match match = Regex.Match(text, pattern);
if (match.Success)
{
    Console.WriteLine(match.Value);  // "123-456-7890"
    Console.WriteLine(match.Index);  // 起始索引
    Console.WriteLine(match.Length); // 匹配的长度
}

常用成员:

  • match.Success:是否匹配成功。
  • match.Value:匹配到的字符串。
  • match.Index:匹配在原文本中的起始位置(从 0 开始)。
  • match.Length:长度。

Regex.Match 也有带起始位置、带 RegexOptions 的重载:


五、Regex.Matches:获取所有匹配结果(多个)

string text = "ID: 100, 200, 300";
string pattern = @"\d+";

MatchCollection matches = Regex.Matches(text, pattern);
foreach (Match m in matches)
{
    Console.WriteLine($"{m.Value} at {m.Index}");
}
// 输出:
// 100 at 4
// 200 at 9
// 300 at 14

解析:

  • 返回的是一个 MatchCollection,可以 foreach 遍历。
  • 每个 Match 和前面一样,有 ValueIndexGroups 等属性。

六、Regex.Replace:按模式搜索并替换

Regex.Replace 和字符串的 Replace 很像,但支持模式匹配。

1. 固定字符串替换匹配内容

string input = "2025/12/18";
string pattern = @"/";

string result = Regex.Replace(input, pattern, "-");
Console.WriteLine(result); // "2025-12-18"

这相当于“把所有 / 都换成 -”,和 input.Replace("/", "-") 类似,但 pattern 可以写得更复杂。

2.用捕获组重排内容

string input = "2025-12-18";
string pattern = @"(\d{4})-(\d{2})-(\d{2})";

// 把 yyyy-MM-dd 改成 dd/MM/yyyy
string result = Regex.Replace(input, pattern, "$3/$2/$1");
// result: "18/12/2025"

解析:

这里的 $1$2$3 是捕获组

3. 更高级的 MatchEvaluator 版本

string input = "Price: 100 USD, 200 USD";
string pattern = @"(\d+)\s*USD";

string result = Regex.Replace(input, pattern, m =>
{
    int value = int.Parse(m.Groups[1].Value);
    int converted = (int)(value * 7.2); // 假设汇率
    return $"{converted} CNY";
});

Console.WriteLine(result);
// "Price: 720 CNY, 1440 CNY"

七、Regex.Split:按“模式”切割字符串

可以实现多分隔符的切割

string input = "apple, banana; cherry|date";
string pattern = @"[,;|]\s*"; // 逗号;分号;竖线 + 可选空白

string[] parts = Regex.Split(input, pattern);

foreach (var p in parts)
{
    Console.WriteLine(p);
}
// apple
// banana
// cherry
// date

八、正则基础语法(一):字面字符与转义

1. 字面字符

绝大多数普通字符在正则里就是字面意思:

  • 模式:abc → 匹配文本中出现的 abc
  • 模式:hello → 匹配文本中出现的 hello

2. 特殊字符(元字符)

这些字符在正则中有特殊含义:

  • . ^ $ * + ? ( ) [ ] { } \ |

如果你要匹配其中任意一个“字面意义上的”字符,就要用 \ 转义。

例如:

  • 匹配一个点号 . → 模式 \.
  • 匹配一个星号 * → 模式 \*
  • 匹配一对括号 (abc) → 模式 \( + abc + \)

在 C# 中配合逐字字符串:

string pattern = @"\.";   // 匹配 "."
string pattern2 = @"\*";  // 匹配 "*"

如果不用 @

string pattern = "\\.";   // C# 字符串里写成 "\\." 才表示一个反斜杠+点

实践中几乎所有正则字符串都用 @"",可以少一半反斜杠。


九、正则基础语法(二):预定义字符类 \d / \w / \s

预定义字符类是正则里最常用的工具,它们代表一类字符。

1. \d / \D:数字与非数字

  • \d:digit,匹配 0–9 的任意一位数字,相当于 [0-9]
  • \D:非数字,相当于 [^0-9]

示例:匹配一个或多个数字

string pattern = @"\d+";

2. \w / \W:单词字符与非单词字符

  • \w:word,匹配字母、数字和下划线,相当于 [A-Za-z0-9_]
  • \W:非 \w

示例:匹配“单词”(一串字母数字下划线)

string pattern = @"\w+";

3. \s / \S:空白字符与非空白字符

  • \s:space,匹配空格、制表符、换行等所有空白字符。
  • \S:非空白。

示例:

string pattern = @"\s+"; // 匹配一个或多个空白

结语

点个赞,关注我获取更多实用 C# 技术干货!如果觉得有用,记得收藏本文

GIS 数据转换:使用 GDAL 将 TXT 转换为 Shp 数据

前言

TXT 作为一种文本格式,可以很方便的存储一些简单几何数据。在 GIS 开发中,经常需要进行数据的转换处理,其中常见的便是将 TXT 转换为 Shp 数据进行展示。

本篇教程在之前一系列文章的基础上讲解

如如果你还没有看过,建议从以上内容开始。

1. 开发环境

本文使用如下开发环境,以供参考。

时间:2025年

系统:Windows 11

Python:3.11.7

GDAL:3.11.1

2. 数据准备

TXT(纯文本文件)是一种最基本的文件格式,仅存储无格式的文本数据,适用于各种场景(如数据交换、日志记录、配置文件等)。

如下是全国省会城市人口 TXT 文本结构:

ID,Name,Longitude,Latitude,Population
1,Beijing,116.40,39.90,21712,Shanghai,121.47,31.23,24873,Guangzhou,113.26,23.12,18684,Shenzhen,114.05,22.55,17565,Tianjin,117.20,39.08,13736,Chongqing,106.50,29.53,32057,Chengdu,104.06,30.67,20948,Wuhan,114.30,30.60,11219,Hangzhou,120.15,30.28,119410,Nanjing,118.78,32.04,93111,Xi'an,108.93,34.27,129512,Changsha,112.97,28.20,83913,Zhengzhou,113.62,34.75,126014,Harbin,126.63,45.75,107615,Shenyang,123.43,41.80,83116,Qingdao,120.38,36.07,100717,Dalian,121.62,38.92,74518,Xiamen,118.08,24.48,51619,Ningbo,121.55,29.88,85420,Hefei,117.28,31.86,93721,Fuzhou,119.30,26.08,82922,Jinan,117.00,36.67,92023,Taiyuan,112.55,37.87,53024,Changchun,125.35,43.88,90625,Kunming,102.72,25.04,84626,Nanning,108.37,22.82,87427,Lanzhou,103.82,36.06,43528,Yinchuan,106.27,38.47,28529,Xining,101.77,36.62,26330,Urümqi,87.62,43.82,40531,Lhasa,91.11,29.65,8632,Haikou,110.20,20.05,287

3. 导入依赖

TXT作为一种矢量数据格式,可以使用矢量库OGR进行处理,以实现TXT数据从文本格式转换为Shp格式。其中还涉及坐标定义,所以还需要引入osr模块。

from osgeo import ogr,osr
import os
import csv

4. 数据读取与转换

定义一个方法Txt2Shp(txtPath,shpPath,encoding="UTF-8")用于将TXT数据转换为Shp数据。

"""
说明:将 TXT 文件转换为 Shapfile 文件
参数:
    -txtPath:TXT 文件路径
    -shpPath:Shp 文件路径
    -encoding:TXT 文件编码
"""
def Txt2Shp(txtPath,shpPath,encoding="UTF-8")

在进行TXT数据格式转换之前,需要检查数据路径是否存在。

# 检查文件是否存在
if os.path.exists(txtPath):
    print("TXT 文件存在。")
else:
    print("TXT 文件不存在,请重新选择文件!")
    return

通过GetDriverByName获取Shp数据驱动,并使用os.path.exists方法检查Shp文件是否已经创建,如果存在则将其删除。

# 注册所有驱动
ogr.RegisterAll()

# 添加Shp数据源
shpDriver = ogr.GetDriverByName('ESRI Shapefile')

if os.path.exists(shpPath):
    try:
        shpDriver.DeleteDataSource(shpPath)
        print("文件已删除!")
    except Exception as e:
        print(f"文件删除出错:{e}")
        return False

接着创建Shp数据源和空间参考,数据坐标系这里定义为4326。

# 创建Shp数据源
shpDataSource = shpDriver.CreateDataSource(shpPath)
if shpDataSource is None:
    print("无法创建Shp数据源,请检查文件!")
    return false
# 创建空间参考
spatialReference = osr.SpatialReference()
spatialReference.ImportFromEPSG(4326)

之后通过数据源方法CreateLayer创建Shp图层,使用图层方法CreateField添加属性字段,需要定义属性名称以及属性字段类型。

# 创建图层
shpLayer = shpDataSource.CreateLayer("points",spatialReference,ogr.wkbPoint)

# 添加图层字段
shpLayer.CreateField(ogr.FieldDefn("ID",ogr.OFTString))
shpLayer.CreateField(ogr.FieldDefn("Name",ogr.OFTString))
shpLayer.CreateField(ogr.FieldDefn("Longitude",ogr.OFTReal))
shpLayer.CreateField(ogr.FieldDefn("Latitude",ogr.OFTReal))
shpLayer.CreateField(ogr.FieldDefn("Population",ogr.OFTString))

读取TXT数据并将其转换为Shapefile数据,在打开数据时,根据TXT文件属性,使用逗号分隔符进行读取并跳过表头行数据。之后根据行数据进行属性遍历,将读取的字段值和几何属性写入到要素对象中。

# 读取TXT文件
with open(txtPath,"r",encoding=encoding) as txtFile:
    # 根据逗号分隔符进行读取
    reader = csv.reader(txtFile,delimiter=",")
    # 跳过表头
    header = next(reader)
    # 遍历记录
    for row in reader:
        print(f"要素记录:{row}")
        # 创建要素
        feature = ogr.Feature(shpLayer.GetLayerDefn())

        # 根据图层字段写入属性
        feature.SetField("ID",str(row[0]))
        feature.SetField("Name",str(row[1]))
        feature.SetField("Longitude",float(row[2]))
        feature.SetField("Latitude",float(row[3]))
        feature.SetField("Population",str(row[4]))

        # 创建几何对象
        wkt = f"POINT({float(row[2])} {float(row[3])})"
        pointGeom = ogr.CreateGeometryFromWkt(wkt)

        feature.SetGeometry(pointGeom)

        # 将要素添加到图层
        shpLayer.CreateFeature(feature)
        feature = None

CreateCpgFile2Encode(shpPath,encoding)
# 释放数据资源        
shpDataSource = None

其中CreateCpgFile2Encode方法用于创建字符编码文件,后缀名为.cpg

"""
说明:创建.cpg文件指定字符编码
参数:
    -shpPath:Shp文件路径
    -encoding:Shp文件字符编码
"""
def CreateCpgFile2Encode(shpPath,encoding):
    fileName = os.path.splitext(shpPath)[0]
    cpgFile = fileName + ".cpg"

    with open(cpgFile,"w",encoding=encoding) as f:
        f.write(encoding)
        print(f"成功创建编码文件: {cpgFile}")

程序成功转换数据显示如下:

使用ArcMap打开显示结果如下:

从 v5 到 v6:这次 Ant Design 升级真的香

2025 年 11 月底,Ant Design 正式发布了 v6 版本。

回顾过去,从 v3 到 v4 的断崖式升级,到 v5 引入 CSS-in-JS 带来的心智负担和性能压力,很多前端同学一提到“升级”就条件反射般护住发际线。但这一次,Ant Design 团队明显听到了社区的呼声。

v6 没有为了“创新”而搞大刀阔斧的重构,而是聚焦于解决长期痛点提升开发体验平滑迁移。本文结合一线业务开发中的真实场景,聊聊 v6 的核心变化,以及这次升级到底值不值得升。

样式覆盖不再是“玄学”

你一定深有体会:设计师要求改 Select 下拉框背景色、调整 Modal 头部内边距,或者给 Table 的某个单元格加特殊样式。在 v5 及之前,你只能打开控制台,一层层扒 DOM 结构,找到类似 .ant-select-selector 的 class,然后用 :global!important 暴力覆盖。一旦组件库内部 DOM 微调,你的样式就崩了。

全量 DOM 语义化 + 细粒度 classNames / styles API
v6 对所有组件进行了 DOM 语义化改造(如用 <header><main> 等代替无意义的 <div>),更重要的是引入了复数形式的 classNamesstyles 属性,让你直接通过语义化的 key 来定制关键区域。

// v6 写法:精准、安全、健壮
<Modal
  title="业务配置"
  open={true}
  classNames={{
    header: 'my-modal-header',
    body: 'my-modal-body',
    footer: 'my-modal-footer',
    mask: 'glass-blur-mask', // 甚至能直接控制遮罩
    content: 'my-modal-content',
  }}
  styles={{
    header: { borderBottom: '1px solid #eee', padding: '16px 24px' },
    body: { padding: '24px' },
  }}
>
  <p>内容区域...</p>
</Modal>

v5 vs v6 对比(Modal 头部样式定制)

// v5(hack 写法,易崩)
import { global } from 'antd'; // 或直接写 less
:global(.ant-modal-header) {
  border-bottom: 1px solid #eee !important;
}

v6 技术价值

  • 不再依赖内部 class 名:官方承诺这些 key(如 header、body)的存在,即使未来 DOM 结构变化,你的样式依然有效。
  • 支持动态样式styles 属性接受对象,方便结合主题或 props 动态生成。

原生 CSS 变量全面回归

v5 的 CSS-in-JS 方案虽然解决了按需加载和动态主题,但在大型后台系统里,运行时生成样式的 JS 开销仍然明显,尤其在低端设备上切换主题或路由时容易掉帧、闪烁。

v6 的解法:零运行时(Zero-runtime)CSS 变量模式
彻底抛弃 CSS-in-JS,默认使用原生 CSS Variables(Custom Properties)。

  • 体积更小:CSS 文件显著减小(官方称部分场景下减少 30%+)。
  • 响应更快:主题切换只需修改 CSS 变量值,浏览器原生处理,毫秒级生效,无需重新生成哈希类名。
  • 暗黑模式友好:直接通过 --antd-color-primary 等变量实现全局主题切换。

这对需要支持多品牌色、暗黑模式的 SaaS 平台来说,是巨大的性能红利。

高频场景官方接管

瀑布流布局、Drawer 拖拽调整大小、InputNumber 加减按钮等,都是业务中常见需求,但之前往往需要引入第三方库或自己手写,增加维护成本和打包体积。

v6 的解法:新增实用组件 & 交互优化

  • Masonry 瀑布流(内置)
import { Masonry } from 'antd';

<Masonry columns={{ xs: 1, sm: 2, md: 3, lg: 4 }} gutter={16}>
  {items.map(item => (
    <Card key={item.id} cover={<img src={item.cover} />} {...item} />
  ))}
</Masonry>
  • Drawer 支持拖拽:原生支持拖拽改变宽度,无需自己写 resize 逻辑。
  • InputNumber 支持 spinner 模式:加减按钮直接在输入框两侧,像购物车那样。
  • 其他:Tooltip 支持平移(panning)、弹层默认支持模糊蒙层(blur mask)等交互优化。

这些补齐了业务高频场景,减少了“自己造轮子”的痛苦。

升级建议:这次真的“平滑”吗?

v6 迁移关键事实

  • React 版本要求:必须升级到 React 18+(不再支持 React 17 及以下)。
  • 破坏性变更:部分 API 被废弃(如 borderedvariantheadStylestyles.header 等),v7 将彻底移除。
  • 兼容性:v5 项目绝大多数业务逻辑代码无需改动,但若大量使用了深层 hack 样式,可能需要调整。
  • 推荐工具:官方提供 Codemod 迁移脚本,可自动化处理大部分废弃 API。

建议

  1. 新项目:直接上 v6,享受更好的性能、体验和未来维护性。
  2. v5 项目:先在 dev 分支尝试升级。无大量 hack 样式的话,成本很低。
  3. v4 及更老项目:跨度较大,建议先逐步迁移到 v5,再升 v6;或在新模块中使用 v6(配合微前端或包隔离)。
  4. 升级前检查
    • 确认 React ≥ 18
    • 运行官方 Codemod
    • 验证目标浏览器支持 CSS 变量(IE 彻底不支持)

总结

Ant Design v6 是一次**“返璞归真”**的升级。它把控制权还给开发者(语义化 API),用现代浏览器特性解决性能问题(零运行时 CSS 变量),并补齐了业务高频组件。

升级核心收益

  • 更少的 hack 代码,更健壮的样式
  • 显著的性能提升(主题切换、渲染速度)
  • 官方接管高频业务组件,减少第三方依赖
  • 平滑迁移路径,真正降低了“升级火葬场”的风险

对于业务开发者来说,这意味着:更少的加班、更快的页面、更早下班

参考链接

WebCut前端视频编辑UI框架一周开源进度

自从上次发布了WebCut的开源信息之后,获得了非常多小伙伴的关注,也有很多小伙伴还不知道,所以我打算写一些更新进度文章,让关注的小伙伴了解WebCut的最新进展。

什么是WebCut?

对于还不了解的小伙伴,我先介绍一下WebCut。它是一款开源的前端(Web)视频编辑UI框架。说的简单点,就是它可以在你的网页上快速植入一个可以用来进行视频编辑的插件,这样你就可以以最低的成本快速实现相关需求。它以组件化思想对外开放,可以通过任意组合的形式,实现各种造型的视频编辑界面。

什么人需要WebCut?

  • 开发者:如果你的老板要你在你们的web产品中快速上线一个视频编辑功能,你需要它

  • 老网站系统维护者:如果你们的系统希望实现用户在系统中完成所有操作,包括视频的编辑,你需要它

  • 创作者:如果你在找剪映的免费替代品,你需要它

目前支持哪些功能?

  • 核心功能:

  • 视频播放/编辑画布

  • 时间轴素材管理器:不同类型素材的渲染效果、高度、分栏、滚动、时间刻度(含缩放)、时间游标、素材时长和位置拖拽调整等等

  • 处理工具:分割裁剪、水平翻转、两段视频合并连接

  • 视频导出:视频格式、分辨率、码率、音频导出等

  • 基础信息编辑:位置、大小、透明度、旋转

  • 信息编辑:音视频的声量调节

  • 外围功能

  • 长宽比调整

  • 播放器进度显示器

  • 暗黑主题和浅色主题切换

  • 多语言(8种),含语言切换组件

  • 进阶功能

  • 时间轴定制:可在时间轴上扩展各种附加元素

  • 媒体库管理:管理各种素材、导入(含文件夹导入)、添加、多选按顺序添加等等

  • 素材类型:视频、音频、图片(含gif)、文字(含文字样式编辑)

  • 媒体库定制:特定类型素材新增菜单添加功能、新增媒体类型等

  • 动画:可扩展的动画功能

  • 滤镜:可扩展的滤镜功能

  • 转场效果:可扩展的转场效果

  • VIP功能

  • 视频水印(含运动效果)

  • 文本转语音

本周开源日志

本周我们实现了动画、滤镜、转场这3个在视频编辑器技术实现中非常麻烦的功能,这里展开讲一下。

  • 动画

动画主要指素材在视频中的运动效果,这里的运动主要通过改变素材的基本属性来实现。例如通过改变素材的位置信息来实现位移,通过改变大小来实现运动中的缩放效果,通过改变透明度来实现闪动效果等等。同时,这些属性的变化可以两两组合,实现非常酷炫的动画。

在WebCut中,一个动画由两个部分组成,一个部分定义keyframe,它是一组key-value,其中key代表时间进度,例如5%, 40%, 90%,value代表在这个时间点上应该以什么属性展示时间点之间,则通过动画运算来进行补间;另一部分是参数,用于计算动画的速度。如果以前开发过css的小伙伴,应该对animation非常了解。

  • 滤镜

滤镜主要指对素材的画面表现进行变化,例如灰度、鲜亮、高斯模糊等效果。在WebCut中,主要通过对视频画面的帧进行滤镜处理,从而让整个视频拥有滤镜效果。

  • 转场

转场主要针对的是两段视频的连接方式,它的实现比较复杂,结合了动画、滤镜等效果,而且还要处理两段视频的衔接。其实现难度非常大。不过WebCut已经做了高度集成,提供了非常便捷的接口,开发者只需要根据接口文档,传入参数,即可完成自己的转场效果。

除了上面提到的3个非常重要的功能,本周,我们还对WebCut做了一项非常重要的设计实现,就是实现了“扩展包”功能。开发者可基于该功能,定义自己的扩展包,并把它注册到WebCut中,即可实现自己的复杂功能。我们的VIP功能就是基于该扩展包功能实现。简单讲,扩展包提供了非常明确的typescript类型接口,你只需要实现该接口,就可以轻松对视频编辑器进行扩展,以增加WebCut本身没有的功能。

另外,我们还做了一些小更新。例如增加了在媒体库中直接上传整个文件夹的能力,增加了多选多个素材并按顺序插入到画布中的能力等。更多功能,还需要你自己去发现~

👉传送门:webcut.top

🌹Github点赞:github.com/tangshuang/…

关注我的公众号 wwwtangshuangnet ,持续获取WebCut更新日志。

pnpm + monorepo 才是 AI 协同开发的最佳方案!🚀🚀🚀

前言

最近业余时间一直忙着开发 AudioDock:github.com/mmdctjj/Aud…

很少更新文章,但是今天抽空总结下最近开发时的一些思考,希望可以给大家带来新的开发思路!

pnpm + monorepo + AI = 效率翻倍

今天的主角是 pnpm ,不过还是得结合正在做的项目来说明!

我的项目是包含了桌面端、移动端的全栈项目,并且前后端分离,除此之外还包含了一些工具库。

整体使用 pnpm + monorepo 的形式管理项目。

soundX/
├── apps/                # 应用层 (前端/客户端)
│   ├── mobile/         # 移动端应用 (React Native / Expo)
│   ├── desktop/        # 桌面端应用 (Electron)
│   └── mini/           # 迷你端/小程序应用
├── services/            # 后端服务层
│   └── api/            # 核心后端 API 服务 (通常是 NestJS)
├── packages/            # 公共模块与包 (内部依赖)
│   ├── db/             # 数据库模型与 Prisma 配置
│   ├── ws/             # WebSocket 通讯协议与逻辑
│   ├── utils/          # 公共工具函数
│   └── test/           # 测试辅助工具
├── Dockerfile           # 容器化部署配置
├── docker-compose.yml   # 多服务编排配置
├── package.json         # 根目录配置与工作区管理
├── pnpm-workspace.yaml  # pnpm 工作区定义
└── nginx.conf           # Nginx 反向代理配置

最近一直重度使用 AI 开发,没想到这种结构,让我的开发更加流畅:

全量的上下文信息

AI 在开发时,始终是全量的上下文(前、后端)信息,让AI 生成一个请求函数和参数返回值类型,基本上可以很准确的实现

统一的类型管理

这是 pnpm + monorepo 最大的优点了,统一的类型管理和统一的构建范式,即使想让 AI 重构,很少会出现重构失败的情况

跨应用开发

我开发移动端的时候,如果对 AI 生成的效果不满意,经常让 AI 熟悉下桌面端的相同功能,在开发移动端的功能,效果会得到很大的提升!

遇到的问题

多模块复用

我们知道,一个标准的前后端分离项目,需要多个模块的配合:models、servies、views。

像我的这个项目移动端(React Native)和桌面端(Electron)都会出现这些模块,包括后续会实现的小程序和电视端,大概率还是需要这些的,所以,我将公共的部分抽离成了单独的包:db、services(后知后觉,还没抽离)!

让人摸不着头脑的 nestjs

我之前已经写过 nestjs 的文章了,介绍了他基础的开发范式,controller、servide、module。可以说是 spring boot 无痛切换 Node 的最佳方案了,但是在我第一次打包的时候傻眼了,打包之后一直报错:

Error: Cannot find module '@nestjs/core'
Require stack:
| - /usr/src/app/dist/main.js

一开始以为是我的 Dockerfile 配置的有问题,反复调整,折磨了我一个周末才发现这是在 pnpm monorepo 项目里开发必然会出现的问题。

解决方法很简单,构建的时候,在单独下载一次生产环境的依赖包就可以了!

# 2. 安装生产依赖 + 全局安装 prisma (用于 db push)
RUN apt-get update -y && apt-get install -y openssl
RUN npm i -g pnpm prisma@6.6.0 && pnpm install --prod --frozen-lockfile --ignore-scripts

出现这个问题的主要原因是 pnpm 的软连接机制在打包的时候会失效,所以找不到这个包。

Prisma 让我又爱又恨

解决完第一个问题,以为可以正常运行我的包了,结果没想到这时候是 Prisma 的问题了。

Prisma 最大的好处是可以自动生成类型文件、基础的业务查询!

但是真的没想到,这些便捷的后面隐藏着无数的坑,第二个问题是 Prisma@6.x 版本默认输出客户端文件在 pnpm 项目里也会找不到文件在哪。

node:internal/modules/cjs/loader:1386
  throw err;
  ^
Error: Cannot find module '.prisma/client/default'
Require stack: - /app/node_modules/.pnpm/@prisma+client@6.8.2_prisma@6.8.2_typescript@5.9.3__typescript@5.9.3/node_modules/@prisma/client/default.js

后来看到需要使用自定义输出路径才不会出现这个问题。

但是使用自定义文件输出的文件是 CMD 格式的文件,于是我将 Prisma 的版本升级到了最新的 7.x 版本,新版本要求必须使用自定义路径输出客户端文件,并且生成的是 ES 格式的文件,可以在前端直接引入类型。

但是玩玩没想到,输出之后的文件会有三个变量一直报错

apps/api dev: generated/prisma/internal/prismaNamespace.ts:114:14 - error TS2742: The inferred type of 'DbNull' cannot be named without a reference to '.pnpm/@prisma+client-runtime-utils@7.0.0/node_modules/@prisma/client-runtime-utils'. This is likely not portable. A type annotation is necessary.

apps/api dev: 114 export const DbNull = runtime.DbNull
apps/api dev:                  ~~~~~~

apps/api dev: generated/prisma/internal/prismaNamespace.ts:121:14 - error TS2742: The inferred type of 'JsonNull' cannot be named without a reference to '.pnpm/@prisma+client-runtime-utils@7.0.0/node_modules/@prisma/client-runtime-utils'. This is likely not portable. A type annotation is necessary.

apps/api dev: 121 export const JsonNull = runtime.JsonNull
apps/api dev:                  ~~~~~~~~

apps/api dev: generated/prisma/internal/prismaNamespace.ts:128:14 - error TS2742: The inferred type of 'AnyNull' cannot be named without a reference to '.pnpm/@prisma+client-runtime-utils@7.0.0/node_modules/@prisma/client-runtime-utils'. This is likely not portable. A type annotation is necessary.

apps/api dev: 128 export const AnyNull = runtime.AnyNull
apps/api dev:                  ~~~~~~~

没办法又回退到了 6.x 版本直接手写 modal 了。

最后

目前已经开发完了大部分功能了,大概集中开发了两三个周末的时间,整体来说 pnpm + monorepo 协同 AI 开发我感觉是个人或者小团队开发的最快形式了。

可能会有小伙伴担心是不是很费大模型的 token ?

我没试过小项目,但是如果真的免费 token 用完了,可以再注册一个新账号继续使用。

💪别再迷茫!一份让你彻底掌控 TypeScript 类型系统的终极指南

TypeScript现在的普及度已经很高了,虽然它是一种静态类型定义,但它的庞大已足够我们要向对待一门语言一样对待它了,去深入学习,以便更好的利用它的能力。

当然了,我们首先要明确为什么需要它,而不是把它当作一种负担:

  • 编译时类型检查,避免了运行时才检查的数据类型错误,导致系统奔溃。
  • 提升开发效率,IDE基于类型系统提供了精准的代码补全、接口提示。减少了查询文档、查询API的成本。
  • 增强了代码可读性,通过类型注解帮助成员快速理解函数输入、输出,降低了协作成本。

类型系统基石

像一门语言有基本的语法一样,TypeScript也有基本的类型定义,这些基本类型对应JavaScript中的基本数据类型,包括numberstringbooleannullundefinedsymbolbigintobject

const name: string = "hboot";
const age: number = 18;
const message: string = `hello, ${name}`
const uniqueKey: symbol = Symbol("key");

空类型、任意类型、未知类型

void表示空类型,any表示任意类型,unknown表示未知类型。

  • void 常用于函数无返回值时的返回类型声明;
  • any 表示任意类型,失去了类型检查的能力,非必要不要用;
  • unknown 表示未知类型,可以用来代替any,但是它不能直接用,必须通过类型断言或类型守卫明确具体类型才能操作。
  • never 表示没有任何类型,变量和函数返回不会存在任何实际值。它是所有类型的字类型,可以赋值给任意类型。
function noReturn(msg: string): void {
    console.log(msg);
}

let name: any = "admin";
// 可以任意赋值
name = 123;
// 甚至是当作函数调用,这导致开发不易发现,运行时才报错
name():

let age: unknown = 18;
// 类型为未知,无法直接操作
age += 10;
// 通过类型守卫,明确类型为 number
if(typeof age === 'number') { 
    age += 10;
}

复合类型

复合类型就是基础类型的组合,包括数组、对象、元组、枚举、类。

数组

数组的类型定义T[]Array<T>

// 数组
let names: string[] = ['admin', 'user'];
let names: Array<string> = ['admin', 'user'];

对象

对象类型定义对象的属性名、属性类型。属性支持可选、只读、索引签名。

// 对象
let user: { name: string; age: number } = {
    name: 'hboot',
    age: 18
}

元组

元组是明确了数组长度以及元素类型的。

// 元组
let userInfo: [string, number] = ['hboot', 18];

枚举

枚举用于定义一组有特殊含义的常量,比如状态码、类型标识等。

// 枚举
const enum Status { 
    Success = 200,
    Fail = 500
}

未赋值时,枚举值从 0 开始递增。如果自定义了开始值,则后面的值递增。

const enum Status { 
  Success, // 0
  Fail // 1
}

const enum Status { 
  Success = 1, // 1
  Fail // 2
}

可以通过const enum修饰枚举定义,在编译时仅保留常量值,减少代码体积。

Class

class是面向对象编程的核心。es6中已经增强了对类的支持,可以通过类定义对象,明确属性、方法。类不仅可以用来创建实例,也可以作为类型注解描述实例类型。

class Animal {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

类的定义包括实例属性、实例方法、静态属性、静态方法,同时还支持通过访问修饰符控制成员可见。

子类可以通过extends继承父类的非private成员,重写父类方法。仅能继承一个父类。

class Dog extends Animal {
  WangWang() {
    return "WangWang";
  }
}

类可以通过implements实现接口,用于约束类的实现,包括属性、方法,仅约束结构,不提供实现。可实现多个接口

interface Animal {
  name: string;
  speak(): void;
}

class Dog implements Animal {
  name: string;
  speak(): void {
    console.log("WangWang");
  }
  constructor(name: string) {
    this.name = name;
  }
}

通过abstract关键字可以声明抽象类,抽象类不能被实例化。它只定义了字段、方法结构,未实现具体逻辑,它仅可被子类继承,并需要实现它定义的所有抽象成员。

高级类型

基础类型可以满足绝大多数业务,但面对复杂业务需要类型复用、条件判断、属性筛选则需要高级类型。

复用类型

为了复用类型,可以通过type \ interface定义类型。type可以定义任意类型,包括基础类型、复合类型、联合类型等,比较灵活;interface 只能定义对象类型,但是可以继承同名合并

// type
type Age = number;
type User = { 
    name: string;
    age: Age;
}
type Status = 'success' | 200;

// interface
interface User { 
    name: string;
    age: number;
}

interface SuperUser extends User { 
    role: string;
}

interface 不同于类class,它可以继承多个接口。

联合/交叉类型

联合类型通过|表示变量可以是任意其中一种类型;交叉类型通过&表示变量必须同时满足多个类型。

联合类型使用时需要类型守卫明确类型后才能操作,如果变量已经赋值,则会自动推导出类型;

type Age = number | string;

let age: Age = 18;
age+=2;

function agePlus(age: Age){ 
    if(typeof age === 'number'){ 
        age+=2;
    }
    return age;
}

交叉类型常用合并对象类型,需要满足所有类型条件。如果无法满足所有类型条件,则该类型为never

type User = { 
    name: string;
}
type Address = { 
    address: string;
}

type UserAddress = User & Address;

泛型

泛型是将类型参数化,可以不指定具体类型来定义函数、类、接口。在使用时在传入具体类型,它可以高度抽象定义类型,保障类型复用和类型安全。

// 函数泛型
function getData<T>(data: T): T {
    return data;
}

// 接口泛型
interface IData<T> {
    data: T;
}

// 类泛型
class Data<T> {
    data: T[] = [];
}

因为不知道具体类型,就无法获知这个类型有什么属性,可以调用什么方法。为了使用某个属性或者某个方法,我们使用泛型约束来指定这个类型必须有哪些属性或者方法。

function getData<T extends { name: string }>(data: T): string {
    return data.name;
}

在调用具有泛型的类型时,需要传递具体类型,有时候这个泛型我们知道在很多情况下就是某一个类型,避免重复传入,我们可以指定泛型的默认类型,从而在使用时不再需要传入。

type User = { 
    name: string;
}

function getData<T extends User = User>(data: T): string {
  return data.name;
}

泛型约束和默认类型不需要同时出现,也可以定义其一,根据需求使用。

类型运算

除了定义具体类型,还可以通过类型运算从一个类型得到一个新的类型。也可以称之为推导,从一个类型推导为另一个类型。类型运算也是导致一些复杂类型产生的因素。

类型守卫

类型守卫是在运行时判断数据类型,它可以精准到具体类型,用于类型收窄,包括unknown、联合类型、类继承。关键字包括typeof instanceof in is

typeof 用于判断基础类型。但是无法判断 nulltypeof null 返回 'object',可通过value!==null 进行判断。

function agePlus(age: string | number) {
  if (typeof age === "number") {
    age += 2;
  }
  return age;
}

instanceof 判断是否为某个类实例。


class Animal {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

class Dog extends Animal {
  WangWang() {
    return "WangWang";
  }
}

class Cat extends Animal {
  MiaoMiao() {
    return "MiaoMiao";
  }
}

function speak(animal: Animal) {
  if (animal instanceof Dog) {
    return `${animal.name}:${animal.WangWang()}`;
  }
  if (animal instanceof Cat) {
    return `${animal.name}:${animal.MiaoMiao()}`;
  }

  return `${animal.name}`;
}

in 适用于对象类型定义,用于判断是否包含某个属性

type Dog = {
  a: string;
  b: number;
};
type Cat = {
  d: string;
  c: number;
};

function getAttr(animal: Dog | Cat) {
  if ("b" in animal) {
    return animal.a;
  }

  if ("d" in animal) {
    return animal.c;
  }

  return "unknown";
}

is 用于精准明确是什么类型,但是判断逻辑是我们自己写的,这就要求我们判断逻辑要精确,否则可能导致运行时错误。

type Dog = {
  a: string;
  b: number;
};
type Cat = {
  d: string;
  c: number;
};

// 内部逻辑自定义判断是否为某个类型
function isDog(animal: Dog | Cat): animal is Dog {
  return "b" in animal;
}

function getAttr(animal: Dog | Cat) {
  // 调用时如果为true,则推导为Dog
  if (isDog(animal)) {
    return animal.a;
  } else {
    return animal.c;
  }
}

类型断言

类型断言是将一个类型临时转为另一个类型,仅在编译阶段告诉TypeScript是什么类型,不能改变变量的实际类型。

类型断言需要开发者明确保证断言类型正确,否则可能会导致运行时异常。对于不确定的类型,因该优先使用类型守卫.

使用as断言类型,可以转unknown类型、父类型转子类型场景。

let age: unknown = 34;
// 可以断言类型为string,从而调用字符串的方法。
// 但是在运行时会报错
(age as string).toUpperCase();

还可以通过as const断言为只读常量,常用于固定值的类型约束。

// 类型为 string
const name = "hboot";

// 类型为字面量 "hboot" 类型
const name = "hboot" as const;

使用!断言非空,常用于访问某个可选属性时,断言非空

type Cat = {
  d?: string;
  c: number;
};

let cat: Cat = {
  c: 20,
};
// 未断言时调用报错属性 d 可能未定义
// cat.d.toUpperCase();

// 断言非空后则不会再提时报错,但是运行时报错 
cat.d!.toUpperCase();

所以,断言要谨慎使用、避免滥用,确保断言的类型是是实际数据类型的兼容类型。

还可以通过双层断言将一个明确的类型断言为另一个类型。

这个操作很危险,实例中将string类型断言为Cat对象类型去访问属性c,编译通过,运行时爆炸。

type Cat = {
  d?: string;
  c: number;
};

let name = "hboot";

(name as unknown as Cat).c;

运算符

运算符可以从一个类型的到另一个类型。类型安全,类型运算的操作有很多

keyof 得到对象类型的所有字段key类型构成的联合类型。

type Cat = {
  d?: string;
  c: number;
};
// 得到 Cat 的所有字段 key 类型 联合类型 "d" | "c"
type Keys = keyof Cat;

// 如果字段是索引签名,则返回索引签名的类型
type Cat = {
  [x: string]: unknown;
};
// 得到的是索引签名类型 string
type Keys = keyof Cat;

typeof 之前再类型守卫里已经介绍过了,它可以返回变量的类型(基本类型)。

索引访问(IndexedAccessType)获取类型,比如我们要获取对象类型里某个字段的类型,就可以使用索引获取。

type Cat = {
  d?: string;
  c: number;
};

// 获取字段类型为 string,但由于字段是可选 ?,
// 所以返回的是 string | undefined
type D = Cat["d"];

也可以通过联合类型获取到多个字段的类型,结果为一个联合类型。

type D = Cat["d"|"c"]

// 如果想要获取所有字段类型,可以通过keyof 获取到所有key的联合类型
type D = Cat[keyof Cat]

索引最重要的一点是可以对数组、元组元素的类型索引获取.

type names = [string, number];
// 索引第一个元素的类型
type Name = names[0];

type Names = Array<string>;
// 索引数组元素的类型
type Name = Names[number];

还可以搭配typeof 对变量的类型进行索引获取。

条件类型(ConditionalType),通过输入的类型,决定输出类型,通过extends关键字和三元表达式来标识。

type Name<T> = T extends string ? string : number;

// 输入泛型参数返回类型,
// 输出类型为 string
type Admin = Name<string>;
// 输出类型为 number
type User = Name<unknown>;

也可以声明一个函数书写更复杂的判断逻辑。既然通过extends关键字以及泛型参数,那么也可以增加泛型约束、泛型默认值。

映射类型(MappedType),从一个类型创建一个新类型,建立在索引签名语法之上,通过对对象的字段键值、字段类型进行转换操作。

type Cat = {
  name?: string;
  age: number;
};

type ToFunction<T> = {
  [K in keyof T]: () => T[K];
};

// 从基本类型转换成函数类型
type CatFunction = ToFunction<Cat>;
/**
 *  type CatFunction = {
 *    name?: (() => string | undefined) | undefined;
 *    age: () => number;
 *  }
 */

文本类型(LiteralType),通过扩展字符串生成文本类型,表现同字符串字面值相同。

type Name = "hboot";
type GoodName = `Good, ${Name}`;

利用文本类型扩展,可以结合映射类型,改变键值,得到一个全新的类型。

type ToFunction<T> = {
  [K in keyof T as `on_${string & K}`]: () => T[K];
};

// 就会得到不同键值、不同类型的新对象类型
type CatFunction = ToFunction<Cat>;
/**
 *  type CatFunction = {
 *    on_name?: (() => string | undefined) | undefined;
 *    on_age: () => number;
 *  }
 */

infer 类型推断,通常用于泛型参数推导;函数返回值类型推导

type Name<T> = T extends Array<infer U> ? U : T;

// 条件类型推导 得到数组元素类型 string
type str = Name<string[]>;
// 非数组类型,返回原类型 { name: string }
type info = Name<{ name: string }>;

内置工具函数

除了上述我们要手动去实现逻辑从而创建新的类型外,提供了内置函数可以直接使用,这些工具提供一些常用的类型转换逻辑。

Partial<T> 得到一个新类型,定义类型所有的字段都是可选的,与之相反的是Required<T>

type Cat = {
  name?: string;
  age: number;
};

type NewCat = Partial<Cat>;
/**
 * type NewCat = {
 *   name?: string;
 *   age?: number;
 * }
 */
type RequiredCat = Required<Cat>;
/**
 * type RequiredCat = {
 *   name: string;
 *   age: number;
 * }
 */

Pick<T,Keys> 从一个类型中选择某些字段得到一个新类型,Omit<T,Keys>从一个类型中删除某些字段得到一个新类型

type Cat = {
  name?: string;
  age: number;
};

type NewCat = Pick<Cat, 'name'>;
/**
 * type NewCat = {
 *   name?: string;
 * }
 */
type NewCat = Omit<Cat, 'age'>;
/**
 * type NewCat = {
 *   name?: string;
 * }
 */

Readonly<T> 得到一个新类型,定义类型所有的字段都是只读的,初始化后不可以修改

type Cat = {
  name?: string;
  age: number;
};

type ReadonlyCat = Readonly<Cat>;

let cat: ReadonlyCat = {
  name: "hboot",
  age: 18,
};

// 报错,不可以修改再赋值,属性是只读的
// cat.age = 20;

它没有与之相反定义的工具,怎么处理让所有只读属性都变为可编辑的呢?还记得上面讲过的映射类型吗?

在映射类型中通过使用-符号将所有字段的readonly修饰符都移除掉

type MutableType<T> = {
  -readonly [K in keyof T]: T[K];
};

let cat: MutableType<ReadonlyCat> = {
  name: "hboot",
  age: 18,
};

// 通过映射类型 -readonly 移除掉属性的只读修饰符
cat.age = 20;

Record<Keys,T> 可以创建一个由Keys组成的属性对应类型为T的对象类型

type Animal = Record<
  "dog" | "cat",
  {
    name: string;
    age: number;
  }
>;

/**
 * type Animal = {
 *   dog: {
 *     name: string;
 *     age: number;
 *   };
 *   cat: {
 *     name: string;
 *     age: number;
 *  }; 
 * }
 */

Exclude<U,T>从指定联合类型U排除某个满足 T类型的成员,得到新类型;与之相反的是Extract<U,T>

type Type = Exclude<"name" | "age" | 32, number | "name">;

/**
 * type Type = "age"
 */

type Type = Extract<"name" | "age" | 32, number | "name">;
/**
 * type Type = "name" | 32
 */

NonNullable<Type>排除nullundefined的成员,得到新类型

Parameters<Type> 从函数类型提取函数的参数类型,得到一个元组类型,非函数类型定义得到never类型。

type Fun = (name: string, age: number) => void;

type Params = Parameters<Fun>;
/**
 * type Params = [name: string, age: number]
 */

ConstructorParameters<Type> 从构造函数类型提取构造函数的参数类型,得到一个元组类型或数组类型

type FunConstructorParams = ConstructorParameters<FunctionConstructor>;
/**
 * type FunConstructorParams = string[]
 */

type ErrorConstructorParams = ConstructorParameters<ErrorConstructor>;
/**
 * type ErrorConstructorParams =  [message?: string, options?: ErrorOptions]
 */

ReturnType<Type> 从函数类型提取函数的返回值类型,对于非函数类型会得到any.

Awaited 用来获取异步函数的返回值类型

type Fun = () => Promise<{ data: string }>;

type FunType = Awaited<ReturnType<Fun>>;
/**
 * type FunType = {
 *   data: string;
 * }
 */

InstanceType<Type> 从构造函数类型提取构造函数的实例类型

type Fun = new () => { name: string };

type FunType = InstanceType<Fun>;
/**
 * type FunType = {
 *   name: string;
 * }
 */

class Animal {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

type AnimalType = InstanceType<typeof Animal>;
/**
 * type AnimalType = Animal
 */

NoInfer<T>阻止从该处推导泛型参数类型

function compare<T extends string>(a: T[], b?: NoInfer<T>) {
  console.log(a, b);
}

// 锁定了泛型 T 类型是 "admin" | "test" , 所以传入参数 "hboot" 会提示报错
// 如果没有 NoInfer 则不会报错,推断类型 string
compare(["admin", "test"], "hboot");

ThisParameterType<T> 提取函数类型中的this参数的类型,如果没有this参数则返回unknown

OmitThisParameter<Type> 移除函数类型中的this参数,得到一个新的函数类型,如果没有this参数或不是函数类型则返回类型本身。

ThisType<Type> 用于指定方法上下文中的this类型,不会返回新类型,指定后在方法中就可以通过this访问到指定属性。但是需要开启noImplicitThis(tsconfig.json)标志才可以使用。

interface Animal {
  name: string;
}

type Dog = {
  speak(): void;
} & ThisType<Animal>;

const dog: Dog = {
  speak() {
    // 通过this 可以访问到 name 属性
    console.log(this.name);
  },
};

文本类型工具,用于处理文本,包括:

  • Uppercase<StringType> 将字符串转换为大写
  • Lowercase<StringType> 将字符串转换为小写
  • Capitalize<StringType> 将字符串的第一个字符转换为大写
  • Uncapitalize<StringType> 将字符串的开头字符转换为小写

命名空间

通过关键字namespace 定义空间名,用于分组相关的类型和值,避免命名冲突。并可通过export导出供外部使用

namespace Hboot {
  //外部无法访问
  const name = "hboot" as const;

  // 外部可以访问
  export function getName() {
    return name;
  }
}

Hboot.getName();

Nuxt 3 项目自动化部署到宝塔服务器全攻略 (GitHub Actions + rsync)

本指南详细介绍了如何利用 GitHub Actions 持续集成工具,将 Nuxt 3 项目(静态生成 SSG 模式)自动化部署到宝塔面板服务器。


插件介绍

nuxt-web-plugin 是一款面向 Nuxt 3/4 的全能增强插件,旨在提升开发体验(DX)并为应用提供坚实的基础能力。

核心特性:

  • 🔐 深度安全防护: 集成 AES-GCM 对称加密、RSA 非对称加密及 SHA-256 哈希算法,支持加密存储(Storage/Cookie)。
  • 🛰️ 智能请求封装: 基于 $fetch 的统一网络层,内置 自动去重 (Dedupe)短时缓存 (Cache)并发锁 (Lock),有效防止重复请求。
  • 🖼️ 页面水印系统: 动态 Canvas 水印,支持防篡改监测(Anti-Tamper),保护页面内容版权。
  • 🔍 SEO & 设备检测: 自动元标签生成与移动端/平板/桌面端精准识别。
  • 🎨 玻璃拟态布局: 内置一套现代化的插件控制台模板,完美支持 Tailwind 暗色模式。

一、 准备工作

1.1 服务器环境

  • 确保服务器已安装 宝塔面板
  • 在宝塔面板中创建一个 静态网站(或 PHP 网站,但我们只需其静态能力)。
  • 记住你的网站根目录,例如:/www/wwwroot/nuxt.haiwb.com

1.2 生成 SSH 密钥对

在你的本地终端或服务器执行以下命令(建议在服务器执行):

# 生成密钥对 (ed25519 算法更安全且简短)
ssh-keygen -t ed25519 -C "github-actions-deploy"
  1. 公钥 (.pub): 将内容复制并添加到服务器的 ~/.ssh/authorized_keys 文件中。
  2. 私钥: 将内容完整复制,下一步使用。

二、 GitHub 仓库配置

进入你的 GitHub 项目仓库,点击 Settings -> Secrets and variables -> Actions,点击 New repository secret 添加以下变量:

变量名 说明 示例值
SERVER_SSH_KEY 刚才生成的 私钥 内容 -----BEGIN OPENSSH PRIVATE KEY----- ...
SERVER_HOST 服务器公网 IP 或域名 1.2.3.4nuxt.haiwb.com
SERVER_USER SSH 登录名 root (建议使用有权限的普通用户)
SERVER_TARGET 宝塔面板中的网站根目录 /www/wwwroot/nuxt.haiwb.com

三、 编写工作流文件

在项目根目录创建 .github/workflows/deploy-playground.yml 文件:

name: Deploy Playground to Baota

on:
  push:
    branches: [main] # 仅在代码推送到 main 分支时触发
  workflow_dispatch:  # 支持在 GitHub Actions 页面手动点击运行

jobs:
  deploy-to-baota:
    name: Upload to Server
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 9
          run_install: false

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: 24 # 关键:Node 版本需匹配项目要求 (v24.4.1+)
          cache: 'pnpm'

      - name: Install Dependencies
        run: pnpm install --no-frozen-lockfile

      - name: Build Playground (Static)
        run: |
          # 准备模块环境
          pnpm run dev:prepare
          cd playground
          # 生成静态文件 (SSG)
          npx nuxi generate 
          echo "Build Output Check:"
          ls -R .output/public/ # 打印构建结果,方便排查路径问题

      - name: Deploy to Server
        uses: easingthemes/ssh-deploy@main
        with:
          # SSH 私钥
          SSH_PRIVATE_KEY: ${{ secrets.SERVER_SSH_KEY }}
          # 远程主机信息
          REMOTE_HOST: ${{ secrets.SERVER_HOST }}
          REMOTE_USER: ${{ secrets.SERVER_USER }}
          TARGET: ${{ secrets.SERVER_TARGET }}
          # 部署源目录 (Nuxt 3 SSG 产物路径)
          SOURCE: "playground/.output/public/"
          # rsync 参数: r(递归), l(链接), g(组), o(所有者), D(设备), z(压缩), v(详细), c(校验), --delete(删除多余文件)
          ARGS: "-rlgoDzvc -i --delete"
          # 关键排坑:排除服务器系统锁定的文件,否则会报 Operation not permitted (rsync code 23)
          EXCLUDE: "/.user.ini, /.htaccess, /.well-known/, /cgi-bin/"

四、 核心避坑指南 (Troubleshooting)

4.1 Node 引擎版本报错

错误信息: Unsupported engine: wanted: {"node":">=24.4.1"} 原因: 项目 package.json 限制了高版本 Node,而 GitHub Actions 默认环境较低。 对策: 在 actions/setup-node 步骤中明确指定 node-version: 24

4.2 预渲染死链报错

错误信息: Exiting due to prerender errors 原因: Nuxt 3 在 generate 过程中会检查所有链接,如果发现指向 /docs 等不存在的内部路径会报错。 对策:

  1. nuxt.config.ts 中配置 nitro: { prerender: { failOnError: false } }
  2. 将外部链接或独立部署的链接改为绝对路径(如 https://...)。

4.3 rsync exited with code 23

错误信息: unlink(.user.ini) failed: Operation not permitted (1) 原因: 宝塔面板会自动在网站目录创建 .user.ini 并锁定(i 权限)。rsync 尝试删除该文件以便同步时会被拦截。 对策: 在部署脚本中使用 EXCLUDE 配置将其排除掉。


五、 Nginx 伪静态设置 (非常重要)

为了让 Nuxt 的客户端路由正常工作,请在宝塔面板的网站设置 -> 伪静态 中添加以下内容:

location / {
  # 优先寻找文件和目录,找不到则 fallback 到 index.html 让 Vue 处理路由
  try_files $uri $uri/ /index.html;
}

Spec-Kit应用指南

GitHub Spec-Kit 使用指南

规范驱动开发(Spec-Driven Development) - 让 AI 编码更可控、更高效

一、什么是 Spec-Kit?

1.1 简介

Spec-Kit 是 GitHub 官方开源的规范驱动开发工具包,旨在改变传统的 AI 编码方式。

  • 官方仓库: github.com/github/spec…
  • 支持的 AI 工具: Claude Code、GitHub Copilot、Cursor、Gemini CLI、Windsurf 等

1.2 核心理念

传统开发 Spec-Driven 开发
想法 → 直接写代码 → 调试 → 补文档 想法 → 写规范 → AI 生成方案 → AI 实现 → 验证
代码是源头,文档是副产品 规范是源头,代码是规范的实现
“Vibe Coding” - 凭感觉写 结构化、可预测、可追溯

1.3 为什么需要 Spec-Kit?

传统 AI 编码的问题:

  • AI 理解不准确,生成的代码与预期不符
  • 缺乏上下文,AI 无法理解项目架构约束
  • 多人协作时,AI 生成的代码风格不一致
  • 难以追溯需求和实现的对应关系

Spec-Kit 的解决方案:

  • 规范即合约:AI 必须按照规范生成代码
  • Constitution(章程):定义项目的架构约束和编码规范
  • 结构化流程:Specify → Plan → Tasks → Implement
  • 质量门禁:每个阶段都有验证点

二、安装与配置

2.1 前置要求

  • Node.js 18+ 或 Python 3.10+(用于 CLI)
  • Git
  • AI 编码工具(推荐 Claude Code)

2.2 安装方式

方式一:使用 uvx(推荐,无需安装)

# 直接运行,无需安装
uvx --from git+https://github.com/github/spec-kit.git specify init my-project --ai claude

方式二:使用 uv 全局安装

# 安装 CLI
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git

# 初始化项目
specify init my-project --ai claude

方式三:使用 npm/bun

# 使用 bun
bun install -g @spec-kit/cli

# 或使用 npm
npm install -g @spec-kit/cli

# 初始化项目
specify init my-project --ai claude

2.3 初始化项目

# 新项目 + Claude Code
specify init my-project --ai claude

# 在当前目录初始化
specify init . --ai claude

# 跳过 git 初始化
specify init my-project --ai claude --no-git

# 其他 AI 工具
specify init my-project --ai copilot      # GitHub Copilot
specify init my-project --ai cursor-agent  # Cursor
specify init my-project --ai gemini        # Gemini CLI

2.4 初始化后的目录结构

my-project/
├── .specify/                    # Spec-Kit 配置目录
│   ├── memory/
│   │   └── constitution.md      # ⭐ 项目章程(架构约束)
│   ├── scripts/
│   │   ├── bash/                # Bash 脚本
│   │   └── powershell/          # PowerShell 脚本
│   └── templates/
│       ├── spec-template.md     # 规范模板
│       ├── plan-template.md     # 方案模板
│       └── tasks-template.md    # 任务模板
├── specs/                       # 规范文档存放目录
│   └── 001-feature-name/
│       ├── spec.md              # 功能规范
│       ├── plan.md              # 技术方案
│       └── tasks.md             # 任务列表
└── .claude/commands/            # Claude Code 自定义命令
    ├── specify.md
    ├── plan.md
    ├── tasks.md
    └── implement.md

三、工作流程

image.png

3.1 六阶段流程

Constitution → Specify → Clarify → Plan → Tasks → Implement
    ↓            ↓         ↓        ↓       ↓         ↓
 项目章程    功能规范   需求澄清   技术方案  任务拆分   代码实现

3.2 核心命令

阶段 命令 作用 输出物
1. Specify /specify 定义功能规范(WHAT) spec.md
2. Clarify /clarify 澄清模糊需求 更新 spec.md
3. Plan /plan 生成技术方案(HOW) plan.md
4. Tasks /tasks 拆分可执行任务 tasks.md
5. Implement /implement 执行代码实现 源代码
6. Analyze /analyze 质量检查 分析报告

3.3 详细流程说明

阶段一:编写 Constitution(章程)

Constitution 是项目的"宪法",定义了:

  • 技术栈和架构约束
  • 编码规范和命名规则
  • 依赖策略
  • 设计原则

示例(.specify/memory/constitution.md):

# 项目章程

## 技术栈
- 后端:Spring Boot 2.7 + MyBatis Plus + Dubbo 3.3
- 数据库:MySQL 8.0 + Redis
- 前端:Vue 3 + Element Plus

## 架构约束
- 分层架构:Controller/DubboApi → Service → Mapper
- Entity 必须放在 xxx.api.entity 包下
- 禁止在 Controller/DubboApi 中写业务逻辑

## 编码规范
- 使用 Spring Java Format 格式化代码
- 方法必须有 JavaDoc 注释
- 增删改操作必须添加 @Transactional

## 命名规则
- Entity:大驼峰,如 UserInfo
- Service 接口:I{Entity}Service,如 IUserInfoService
- Mapper:{Entity}Mapper,如 UserInfoMapper
阶段二:Specify(功能规范)
# 在 Claude Code 中执行
/specify

输入功能描述后,AI 会生成:

  1. 功能分支(如 001-user-login
  2. 规范目录(specs/001-user-login/
  3. 规范文档(spec.md

spec.md 示例:

# 功能规范:用户登录

## 背景
当前系统没有用户登录功能,需要实现基于手机号+验证码的登录流程。

## 用户故事
- 作为用户,我希望通过手机号和验证码登录,以便访问我的个人中心。
- 作为用户,我希望在验证码错误时收到明确提示。

## 验收标准
1. [ ] 用户输入手机号,点击发送验证码,后端生成并发送(模拟)。
2. [ ] 验证码有效期 5 分钟。
3. [ ] 登录成功返回 JWT Token。
4. [ ] 登录失败提示具体原因(验证码错误/过期)。

## 业务规则
- 手机号必须是 11 位数字。
- 同一手机号 1 分钟内只能请求一次验证码。
阶段三:Plan(技术方案)

在明确了“做什么”之后,下一步是确定“怎么做”。

# 在 Claude Code 中执行
/plan

输入spec.md + constitution.md 或直接回车

输出plan.md

plan.md 示例:

# 技术方案:用户登录

## 架构决策
- 使用 Redis 存储验证码,Key 格式:`auth:code:{phone}`- 使用 JJWT 库生成 Token。
- 遵循 Controller -> Service -> Manager -> Mapper 分层。

## 数据库变更
- 无需新增表,复用 `user_info` 表。

## API 设计
1. POST /api/v1/auth/code
   - Req: { phone: string }
   - Res: { success: boolean }
2. POST /api/v1/auth/login
   - Req: { phone: string, code: string }
   - Res: { token: string, user: UserInfo }

## 模块设计
- `AuthController`: 处理 HTTP 请求。
- `AuthService`: 核心业务逻辑(校验、颁发 Token)。
- `SmsManager`: 对接短信网关(Mock 实现)。
阶段四:Tasks(任务拆分)

将技术方案拆解为 AI 可以独立执行的原子任务(Atomic Tasks)。

# 在 Claude Code 中执行
/tasks

输入plan.md 或直接回车

输出tasks.md

tasks.md 示例:

# 任务列表

## 1. 基础设施
- [ ] Task 1.1: 添加 JJWT 和 Redis 依赖到 `pom.xml`- [ ] Task 1.2: 配置 Redis 连接参数。

## 2. 核心逻辑
- [ ] Task 2.1: 创建 `SmsManager` 并实现发送模拟逻辑。
- [ ] Task 2.2: 创建 `AuthService` 接口及实现,编写 `sendCode` 方法。
- [ ] Task 2.3: 在 `AuthService` 中实现 `login` 方法(含 Token 生成)。

## 3. 接口层
- [ ] Task 3.1: 创建 `AuthController` 并暴露 REST 接口。
- [ ] Task 3.2: 编写 Controller 层单元测试。
阶段五:Implement(执行实现)

AI 逐个读取任务并执行。

# 在 Claude Code 中执行
/implement

执行逻辑:

  1. AI 读取 tasks.md 中第一个未完成的任务。
  2. 读取相关文件上下文。
  3. 编写代码。
  4. 运行测试(如果定义了验证步骤)。
  5. 标记任务为 [x]
  6. 重复上述步骤,直到所有任务完成。
阶段六:Analyze(质量检查)
# 在 Claude Code 中执行
/analyze

对生成的代码进行质量分析,检查是否符合 constitution.md 中的规范,例如:

  • 是否遗漏了 JavaDoc?
  • 是否使用了被禁止的类?
  • 事务注解是否添加?

四、最佳实践

4.1 什么时候使用 Spec-Kit?

  • 推荐:复杂功能开发、需要多人协作、对代码质量有严格要求。
  • 不推荐:简单的 Bug 修复、临时脚本、纯文案修改。

4.2 存量项目接入(Brownfield)

对于已有项目,不需要一次性补全所有文档。可以采用增量接入策略:

  1. 初始化 Spec-Kit。
  2. 配置 constitution.md 以反映当前项目的最佳实践。
  3. 在开发新功能时,按照 Specify -> Plan -> Tasks -> Implement 流程进行。
  4. 对于旧代码的重构,可以先让 AI 读取旧代码生成 spec.md(逆向工程),再进行修改。

五、高级特性

5.1 自定义规范模板

你可以创建自己的规范模板,以适应不同的项目需求或团队标准。

# spec_templates/my_template.py
TEMPLATE = """
项目名称: {project_name}
技术栈: {tech_stack}
功能模块: {features}
性能要求: {performance}
"""

5.2 集成 CI/CD

Spec-Kit 可以与 CI/CD 工具集成,确保所有提交的规范和代码都符合标准。

# .github/workflows/spec-kit.yml
name: Spec-Kit Validation
on: [push]
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Validate Specifications
        run: specify validate

5.3 团队协作模式

通过共享规范库实现团队协作,确保所有人使用统一的规范。

# 导出规范
specify export --output team-specs.json

# 导入团队规范
specify import team-specs.json

六、与现有工作流整合

6.1 与 Claude Code 集成

初始化后,Claude Code 自动获得 /specify/plan/tasks/implement 命令。

6.2 与 Git 工作流整合
# 1. 开始新功能
/specify "实现用户登录功能"

# AI 自动创建分支:001-user-login

# 2. 完成规范和方案
/plan
/tasks

# 3. 实现代码
/implement

# 4. 代码审查
/review-code

# 5. 提交 PR
git add .
git commit -m "feat: 实现用户登录功能"
git push
6.3 与现有项目规范整合

将公司现有规范整合到 Constitution 中:

# .specify/memory/constitution.md

## 引用公司规范
本项目遵循《城市停车微服务框架规范》,详见 CLAUDE.md

## 补充约束
- Entity 必须在 xxx.api.entity 包下
- 使用 ThreadPoolFactory 创建线程池
- 分页查询必须调用 startDubboPage()

七、常见问题 (FAQ)

Q1: Spec-Kit 支持哪些编程语言? Spec-Kit 是语言无关的,支持所有主流编程语言,包括 Python, Java, JavaScript, TypeScript, Go, Rust 等。它通过自然语言描述规范,因此不受限于特定编程语言。

Q2: 发现代码逻辑走不通怎么办?

千万不要直接改代码! 这会破坏“规范即源码”的原则,导致文档与代码脱节。 正确做法:

  1. 回滚:回到 Plan 阶段。
  2. 修改:更新 plan.md 中的技术决策或 tasks.md 中的任务拆分。
  3. 重生成:让 AI 重新生成受影响的代码。

Q3: 如何避免 Spec 文档腐烂?

  • 定期归档:Sprint 结束后,将完成的 specs/ 下的文档移动到 docs/archive/,保持工作区整洁。
  • 反哺章程:如果某个 Spec 引入了新的通用模式(例如确立了“新的权限控制方案”),应将其总结并更新到 .specify/memory/constitution.md 中,使其成为后续开发的标准。

Q4: 团队如何协作?

  • 产品经理 (PM): 负责 Review spec.md,重点关注验收标准(Acceptance Criteria)是否覆盖业务需求。
  • 架构师 / Tech Lead: 负责 Review plan.md,把控技术方案、数据库设计和 API 定义是否符合架构规范。
  • 开发者: 负责执行 tasks.md,并监督 AI 生成的代码质量,进行最终的 Code Review。

七、参考资料

  1. 官方仓库: github.com/github/spec…
  2. 官方博客: github.blog/ai-and-ml/g…
  3. Martin Fowler 文章: martinfowler.com/articles/ex…
  4. Microsoft 教程: developer.microsoft.com/blog/spec-d…
  5. 本文档参考来源: blog.csdn.net/a309220728/…

八、总结

Spec-Kit 不仅仅是一个工具,更是一种工程化思维的体现。它通过强制的结构化流程,解决了 AI 编程中常见的“幻觉”、“上下文丢失”和“不可控”问题。

  • 对于个人开发者:它是你的“外脑”,帮你理清思路,保持代码整洁。
  • 对于团队:它是无形的“架构师”,确保所有 AI 生成的代码都遵循统一的团队规范。

🔥 手写 Vue 自定义指令:实现内容区拖拽调整大小(超实用)

日常开发中经常遇到需要手动调整内容区大小的场景,比如侧边栏、弹窗、报表面板等。分享一个我写的「拖拽调整大小指令」,支持自定义最小尺寸、拖拽手柄样式,能监听尺寸变化

📌 先看效果

image.png

🛠 核心代码解析

指令文件 directives/resizable-full.js ,关键部分:

1. 指令钩子:初始化 + 更新 + 清理

Vue 指令的 3 个核心钩子,保证指令的生命周期完整:

js

export default {
  bind(el, binding) {
    // 指令绑定时初始化拖拽功能
    initResizable(el, binding);
  },
  update(el, binding) {
    // 禁用状态变化时,重新初始化
    if (binding.value?.disabled !== binding.oldValue?.disabled) {
      cleanupResizable(el); // 先清理旧的
      initResizable(el, binding); // 再初始化新的
    }
  },
  unbind(el) {
    // 指令解绑时,清理所有手柄和事件(避免内存泄漏)
    cleanupResizable(el);
  }
};

2. 初始化拖拽:创建手柄 + 核心逻辑

initResizable 是核心函数,主要做 2 件事:创建拖拽手柄、写拖拽逻辑。

(1)创建拖拽手柄

我只保留了「右下角」的拖拽手柄(其他方向注释掉了,需要的话自己解开),样式可自定义:

js

// 定义手柄配置(只留了bottom-right)
const handles = [
  { dir: 'bottom-right', style: { bottom: 0, right: 0, cursor: 'nwse-resize' } }
];

// 循环创建手柄元素
handles.forEach(handleConf => {
  const handle = document.createElement('div');
  handle.className = `resizable-handle resizable-handle--${handleConf.dir}`;
  handle.dataset.dir = handleConf.dir;
  
  // 手柄样式:小方块、半透明、hover高亮
  Object.assign(handle.style, {
    position: 'absolute',
    width: `${handleSize}px`,
    height: `${handleSize}px`,
    background: handleColor,
    opacity: '0.6',
    zIndex: 999,
    transition: 'opacity 0.2s',
    ...handleConf.style
  });

  // hover时手柄高亮
  handle.addEventListener('mouseenter', () => handle.style.opacity = '1');
  handle.addEventListener('mouseleave', () => handle.style.opacity = '0.6');

  el.appendChild(handle); // 把手柄加到目标元素上
  el._resizableConfig.handles.push(handle); // 存起来方便后续清理
});

(2)拖拽核心逻辑

分 3 步:按下鼠标(记录初始状态)→ 移动鼠标(计算新尺寸)→ 松开鼠标(触发回调 + 清理):

js

// 1. 按下鼠标:记录初始位置和尺寸
const mouseDownHandler = (e) => {
  const handle = e.target.closest('.resizable-handle');
  if (!handle) return;

  e.stopPropagation();
  e.preventDefault();
  
  const dir = handle.dataset.dir;
  const rect = el.getBoundingClientRect(); // 获取元素当前位置和尺寸

  // 存初始状态:鼠标位置、元素尺寸/位置
  startState = {
    dir,
    startX: e.clientX,
    startY: e.clientY,
    startWidth: rect.width,
    startHeight: rect.height
  };

  // 绑定移动/松开事件(绑在document上,避免拖拽时鼠标移出元素失效)
  document.addEventListener('mousemove', onMouseMove);
  document.addEventListener('mouseup', onMouseUp);
};

// 2. 移动鼠标:计算新宽高并赋值
const onMouseMove = (e) => {
  if (!startState) return;
  const { dir, startX, startY, startWidth, startHeight } = startState;
  let newWidth = startWidth;
  let newHeight = startHeight;

  // 只处理右下角拖拽:宽高都增加
  if (dir === 'bottom-right') {
    newWidth = startWidth + (e.clientX - startX);
    newHeight = startHeight + (e.clientY - startY);
  }

  // 限制最小宽高(避免拖到太小)
  newWidth = Math.max(minWidth, newWidth);
  newHeight = Math.max(minHeight, newHeight);

  // 给元素设置新尺寸
  el.style.width = `${newWidth}px`;
  el.style.height = `${newHeight}px`;
};

// 3. 松开鼠标:触发回调+清理事件
const onMouseUp = () => {
  // 拖拽结束,触发自定义回调,返回最新尺寸
  if (startState && el._resizableConfig.onResize) {
    el._resizableConfig.onResize({
      width: parseInt(el.style.width),
      height: parseInt(el.style.height)
    });
  }
  startState = null;
  // 移除事件(避免重复绑定)
  document.removeEventListener('mousemove', onMouseMove);
  document.removeEventListener('mouseup', onMouseUp);
};

// 给元素绑定按下事件
el.addEventListener('mousedown', mouseDownHandler);

3. 清理函数:避免内存泄漏

cleanupResizable 负责移除所有手柄元素和事件监听器,指令解绑时必执行:

js

function cleanupResizable(el) {
  if (el._resizableConfig) {
    // 移除所有手柄
    el._resizableConfig.handles.forEach(handle => {
      if (handle.parentNode === el) el.removeChild(handle);
    });
    // 移除所有事件监听器
    el.removeEventListener('mousedown', el._resizableConfig.mouseDownHandler);
    document.removeEventListener('mousemove', el._resizableConfig.mouseMoveHandler);
    document.removeEventListener('mouseup', el._resizableConfig.mouseUpHandler);
    // 删除配置(释放内存)
    delete el._resizableConfig;
  }
}

🚀 如何使用?

  1. 全局注册指令(main.js):

js

import resizableFull from './directives/resizable-full';
Vue.directive('resizable-full', resizableFull);

2. 页面中使用

vue

<template>
  <!-- 给需要拖拽的元素加指令 -->
  <div 
    v-resizable-full="{
      minWidth: 300, // 最小宽度
      minHeight: 200, // 最小高度
      handleSize: 10, // 手柄大小
      handleColor: '#409eff', // 手柄颜色
      onResize: handleResize // 拖拽结束回调
    }"
    style="position: relative; width: 400px; height: 300px; border: 1px solid #eee;"
  >
    我是可拖拽调整大小的内容区~
  </div>
</template>

<script>
export default {
  methods: {
    // 拖拽结束,拿到最新尺寸
    handleResize({ width, height }) {
      console.log('新尺寸:', width, height);
    }
  }
};
</script>

💡 关键注意点(避坑)

  1. 目标元素必须设 position: relative/absolute/fixed:因为手柄是绝对定位,依赖父元素的定位;
  2. 事件绑在 document 上:拖拽时鼠标可能移出目标元素,绑在 document 上才不会断;
  3. 一定要清理事件 / 元素:指令解绑时执行 cleanupResizable,避免内存泄漏;
  4. 最小尺寸限制:通过 minWidth/minHeight 避免元素被拖到太小,影响体验。

🎨 扩展玩法

  1. 解开注释的其他 7 个方向手柄,实现全方向拖拽;
  2. 给手柄加 hover 提示(比如 “拖拽调整大小”);
  3. 支持拖拽时实时触发回调(不止结束时);
  4. 自定义手柄样式(比如改成虚线、加图标)。

📝 总结

这个自定义指令核心是「创建拖拽手柄 + 监听鼠标事件 + 计算尺寸变化」,逻辑不复杂,可以根据自己的业务场景定制。亲测报表和弹窗都很适用~

如果觉得有用,可以点个赞收藏一下,下次需要直接翻出来用😜

深入理解 React Hooks:useState 与 useEffect 的核心原理与最佳实践

在现代 React 开发中,函数式组件配合 Hooks 已成为主流开发范式。其中,useStateuseEffect 是最基础、最常用的两个内置 Hook。它们分别负责管理组件的响应式状态和处理副作用逻辑。本文将结合代码示例与深入分析,带你全面掌握这两个核心 Hook 的使用方式、底层思想以及常见陷阱。


一、useState:让函数组件拥有“记忆”

1.1 基本用法

useState 是 React 提供的第一个 Hook,用于在函数组件中声明状态变量:

import { useState } from "react";

export default function App() {
  const [num, setNum] = useState(1);
  
  return (
    <div onClick={() => setNum(num + 1)}>
      {num}
    </div>
  );
}

这里 num 是当前状态值,setNum 是更新该状态的函数。每次调用 setNum 都会触发组件重新渲染,并使用新的状态值。

⚠️ 注意:不要直接修改状态(如 num++),必须通过 setNum 触发更新,否则 React 无法感知变化,也就无法触发视图的更新。

1.2 初始值支持函数形式

当初始状态需要复杂计算时,可以传入一个纯函数作为 useState 的参数:

const [num, setNum] = useState(() => {
  const num1 = 1 + 2;
  const num2 = 2 + 3;
  return num1 + num2; // 返回 6
});

这个函数只在组件首次渲染时执行一次,后续更新不会再次调用。这有助于避免不必要的性能开销。

✅ 关键点:该函数必须是同步的、无副作用的纯函数。不能包含 setTimeoutfetch 等异步操作,因为状态必须是确定的,如果是类似于fetch这种异步请求,它的状态是不确定的。

1.3 更新状态时使用函数式更新

当新状态依赖于前一个状态时,推荐使用函数式更新:

<div onClick={() => setNum(prevNum => prevNum + 1)}>
  {num}
</div>

prevNum会接收最新的num状态值,这种方式能确保你总是基于最新的状态值进行计算。


二、useEffect:处理副作用的“生命周期钩子”

如果说 useState 赋予组件“记忆”,那么 useEffect 就赋予组件“行动能力”——执行那些不属于纯渲染逻辑的操作,比如数据请求、订阅、定时器等。

2.1 基本结构

useEffect(() => {
  // 副作用逻辑
  return () => {
    // 清理函数(可选)
  };
}, [dependencies]); // 依赖数组
  • 第一个参数:副作用函数
  • 第二个参数:依赖项数组(决定何时重新执行)
  • 返回值(可选):清理函数,在下次 effect 执行前或组件卸载时调用

2.2 三种典型使用场景

场景一:模拟 componentDidMount(挂载时执行一次)

useEffect(() => {
  console.log('组件已挂载');
  queryData().then(data => setNum(data));
}, []); // 空依赖数组

📌 注意:空数组 [] 表示“仅在挂载时执行一次”。但如果组件被卸载后重新挂载,仍会再次执行。

场景二:监听状态变化(类似 watch

useEffect(() => {
  console.log('num 发生变化:', num);
}, [num]); // 依赖 num
  • 首次渲染时执行一次
  • 每当 num 变化时重新执行

场景三:无依赖项(每次渲染后都执行)

useEffect(() => {
  console.log('每次渲染后都会执行');
}); // 没有第二个参数

⚠️ 谨慎使用!容易引发无限循环或性能问题。

2.3 清理副作用:避免内存泄漏

很多副作用会创建持久资源(如定时器、事件监听器),必须在组件卸载或依赖变化时清理:

useEffect(() => {
  const timer = setInterval(() => {
    console.log(num); // 注意:这里打印的是 effect 创建时的 num(闭包)
  }, 1000);

  return () => {
    console.log('清理定时器');
    clearInterval(timer);
  };
}, [num]);
  • 每次 num 变化时,先执行上一次的清理函数(clearInterval),再创建新定时器。
  • 若不清理,会导致多个定时器同时运行,造成内存泄漏,每次新建的定时器那一块内存,没有办法回收了。

🔍 重要细节:console.log(num) 打印的是闭包中的旧值,不是最新状态!这是初学者常踩的坑。


三、纯函数 vs 副作用:React 的哲学基础

理解 useStateuseEffect 的设计,离不开对 纯函数副作用 的区分。

什么是纯函数?

  • 相同输入 → 相同输出
  • 无外部依赖(不修改外部变量)
  • 无 I/O 操作(如网络请求、DOM 操作)
// 纯函数 ✅
function add(x, y) {
  return x + y;
}

// 非纯函数 ❌(修改了外部数组)
function add(nums) {
  nums.push(3); // 副作用!
  return nums.reduce((a, b) => a + b, 0);
}

React 组件本身应尽量保持“纯”:输入 props,输出 JSX。而 useEffect 正是用来隔离副作用的机制。


四、常见误区与最佳实践

❌ 误区1:在 useState 初始值中使用异步函数

// 错误!useState 不支持异步
const [data, setData] = useState(async () => {
  const res = await fetch('/api');
  return res.json();
});

✅ 正确做法:用 useEffect 处理异步初始化:

useEffect(() => {
  fetch('/api').then(res => res.json()).then(setData);
}, []);

❌ 误区2:忘记清理定时器/监听器

会导致内存泄漏,尤其在路由切换或条件渲染组件时。

✅ 总是考虑是否需要返回清理函数。

❌ 误区3:依赖项遗漏或冗余

  • 遗漏依赖 → 使用旧值(闭包陷阱)
  • 冗余依赖 → 不必要的重复执行

五、总结

Hook 作用 关键特性
useState 管理响应式状态 支持函数式更新、惰性初始化
useEffect 处理副作用(数据请求、订阅等) 依赖控制、自动清理、闭包陷阱
  • 状态是组件的核心useState 让函数组件具备状态管理能力。
  • 副作用必须被隔离useEffect 是 React 对“纯组件”理念的优雅妥协。
  • 纯函数是基石,理解它才能写出可预测、可维护的 React 代码。

掌握 useStateuseEffect,就掌握了函数式组件的“灵魂”。在实际开发中,善用它们的特性,避开常见陷阱,你的 React 应用将更加健壮、高效。

📚 延伸阅读:React 官方文档 - Hooks


希望这篇文章能帮助你更深入地理解 React Hooks 的核心思想。如果你觉得有用,欢迎点赞、收藏并在评论区交流你的实践经验!

前端缓存深度解析:从基础到进阶的实现方式与实践指南

在前端开发中,缓存是提升页面性能、优化用户体验的关键技术之一。它通过将频繁访问的资源或数据存储在本地(浏览器)或中间节点,减少网络请求次数、降低服务器负载,同时实现更快的资源加载速度 —— 尤其在弱网、离线场景或高并发访问中,缓存的价值更为凸显。

前端缓存并非单一技术,而是一套覆盖 “服务器资源缓存”“前端数据持久化”“离线能力支持” 的完整体系。本文将从核心原理、实现方式、应用场景三个维度,系统拆解前端缓存的主流方案,并结合实际开发案例,帮助开发者精准选择合适的缓存策略。

一、HTTP 缓存:静态资源的 “性能基石”

HTTP 缓存是浏览器与服务器通过 HTTP 协议约定的缓存机制,主要针对静态资源(JS、CSS、图片、字体、静态 HTML 等),是前端性能优化的 “第一优先级” 方案。其核心逻辑是:首次请求时,服务器通过响应头告知缓存规则;后续请求时,浏览器先校验本地缓存,再决定是否发起网络请求。

HTTP 缓存分为强缓存协商缓存,优先级:强缓存 > 协商缓存。

1. 强缓存:无需网络请求,直接复用本地资源

强缓存的核心是 “本地缓存未过期则直接使用”,浏览器不会发起任何网络请求,资源加载速度最快(控制台状态码显示 200 OK (from disk cache) 或 200 OK (from memory cache))。

实现原理:响应头控制缓存有效期

服务器通过以下两个响应头定义强缓存规则(Cache-Control 优先级高于 Expires):

  • Cache-Control(HTTP/1.1 标准,推荐使用):通过指令组合指定缓存策略,常用指令:

    • max-age=xxx:缓存有效期(单位:秒),如 max-age=86400 表示缓存 1 天。
    • public:允许所有节点(浏览器、CDN、代理服务器)缓存该资源。
    • private:仅允许浏览器缓存(默认值),禁止中间节点缓存。
    • no-cache:禁用强缓存,直接进入协商缓存。
    • no-store:完全禁用缓存,每次必须请求服务器获取新资源。
    • immutable:声明资源永久不变,即使强缓存过期,浏览器也不会主动发起验证(需配合 max-age 使用)。
  • Expires(HTTP/1.0 兼容):指定缓存过期的绝对时间(如 Expires: Fri, 21 Nov 2025 23:59:59 GMT)。缺点是依赖客户端系统时间,若客户端时间篡改,会导致缓存失效或过期缓存复用。

应用场景:不频繁变动的静态资源

  • 打包后的 JS/CSS 文件(需配合文件指纹,如 app.[hash].js,更新时修改文件名即可失效旧缓存)。
  • 图片、字体、图标库(如 Logo、Iconfont、静态背景图)。
  • 第三方库(如 Vue、React 的 CDN 资源,版本号固定时可长期缓存)。

实践示例:Nginx 配置强缓存

nginx

server {
  listen 80;
  server_name example.com;

  # 对JS、CSS、图片等静态资源设置30天强缓存
  location ~* .(js|css|png|jpg|jpeg|gif|ico|woff2|svg)$ {
    root /usr/share/nginx/html;
    expires 30d; # 等价于 Cache-Control: max-age=2592000(30*24*3600)
    add_header Cache-Control "public, immutable"; # 声明资源不变,减少无效验证
  }
}

2. 协商缓存:与服务器确认,避免 “脏数据”

强缓存过期后,浏览器会发起 “协商请求”:携带本地缓存的资源标识,服务器判断资源是否更新。若未更新,返回 304 Not Modified,浏览器复用本地缓存;若已更新,返回 200 OK 和新资源。

实现原理:通过 “资源标识” 验证有效性

协商缓存的核心是 “资源标识”,分为两组成对使用的请求头 / 响应头(ETag 优先级高于 Last-Modified):

  • 组 1:Last-Modified + If-Modified-Since(基于文件修改时间)

    • 响应头 Last-Modified:服务器返回资源的最后修改时间(如 Last-Modified: Wed, 20 Nov 2024 14:30:00 GMT)。
    • 请求头 If-Modified-Since:浏览器下次请求时,携带本地缓存的 Last-Modified 值,告知服务器 “我本地资源的最后修改时间”。
    • 服务器逻辑:对比请求头时间与服务器资源当前修改时间,一致则返回 304,否则返回新资源和新 Last-Modified
    • 缺点:修改时间精度为秒级,1 秒内多次修改会失效;文件内容未变但修改时间变动(如重新部署),会误判为更新。
  • 组 2:ETag + If-None-Match(基于文件内容哈希)

    • 响应头 ETag:服务器对资源内容计算哈希值(如 ETag: "61a8a0f2"),内容不变则哈希值不变。
    • 请求头 If-None-Match:浏览器下次请求时,携带本地缓存的 ETag 值,告知服务器 “我本地资源的哈希值”。
    • 服务器逻辑:对比请求头哈希与服务器资源当前哈希,一致返回 304,否则返回新资源和新 ETag
    • 优点:精度更高,仅关注内容变化,不受修改时间影响。

应用场景:动态内容或频繁更新的静态资源

  • 博客文章、产品详情页等动态页面(内容可能更新,但更新频率不高)。
  • 频繁迭代的静态资源(如活动页 CSS,未使用文件指纹时)。
  • 需保证数据实时性,但可接受 “短时间缓存” 的资源(如首页公告、热门榜单)。

实践示例:Nginx 配置协商缓存

nginx

# 对HTML、PHP等动态资源启用协商缓存
location ~* .(html|php|jsp)$ {
  root /usr/share/nginx/html;
  expires -1; # 禁用强缓存
  add_header Cache-Control "no-cache"; # 强制进入协商缓存
  etag on; # 启用ETag
  if_modified_since on; # 启用Last-Modified
}

二、客户端存储缓存:前端数据的 “本地仓库”

HTTP 缓存聚焦 “服务器资源”,而客户端存储缓存用于将前端生成或获取的非资源数据(如用户偏好、登录状态、表单草稿)持久化在浏览器中,无需每次从服务器请求。

常用方案包括 Cookie、LocalStorage、SessionStorage、IndexedDB,各自适用于不同场景,核心区别集中在容量、生命周期、作用域等维度。

1. Cookie:小型会话数据的 “经典选择”

Cookie 是浏览器最早支持的本地存储方案,用于存储少量键值对数据(容量约 4KB),且会随每次 HTTP 请求自动发送到服务器

核心特性:

  • 容量限制:4KB,仅适合存储少量数据(如 Session ID、用户标识)。
  • 生命周期:可通过 expires(绝对时间)或 max-age(相对时间)设置过期时间;未设置则为 “会话 Cookie”,关闭浏览器失效。
  • 作用域:通过 domain(生效域名)和 path(生效路径)控制,如 domain=example.com 表示子域名 blog.example.com 可共享。
  • 安全性:支持 httpOnly(禁止 JS 读取,防御 XSS 攻击)、secure(仅 HTTPS 传输)、SameSite(防御 CSRF 攻击,取值:Strict/Lax/None)。

应用场景:

  • 存储用户登录态(如 Session ID、JWT 令牌,建议设置 httpOnly: true)。
  • 存储 CSRF 令牌(防御跨站请求伪造攻击)。
  • 存储用户偏好(如语言选择、是否记住登录状态)。
  • 第三方统计或广告跟踪(需遵守隐私法规,如 GDPR)。

实践示例:前端 / 服务器操作 Cookie

javascript

运行

// 1. 前端设置Cookie(简化版)
function setCookie(name, value, days = 7) {
  const date = new Date();
  date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
  document.cookie = `${name}=${encodeURIComponent(value)}; expires=${date.toUTCString()}; path=/; SameSite=Lax; secure=${window.location.protocol === 'https:'}`;
}

// 2. 前端读取Cookie
function getCookie(name) {
  return document.cookie.split('; ').find(row => row.startsWith(`${name}=`))?.split('=')[1] || null;
}

// 3. 服务器(Node.js/Express)设置Cookie
app.get('/login', (req, res) => {
  res.cookie('token', 'user-jwt-123', {
    maxAge: 7 * 24 * 60 * 60 * 1000,
    httpOnly: true, // 禁止JS读取,防XSS
    secure: process.env.NODE_ENV === 'production',
    SameSite: 'Lax' // 防CSRF
  });
  res.send('登录成功');
});

2. LocalStorage:永久存储的 “轻量数据库”

LocalStorage 是 HTML5 引入的本地存储方案,用于存储键值对数据(容量约 5-10MB),永久存储(除非手动删除或清除浏览器数据),仅在客户端生效,不随 HTTP 请求发送。

核心特性:

  • 容量限制:5-10MB(不同浏览器略有差异)。
  • 生命周期:永久有效,关闭浏览器、重启电脑后数据仍存在。
  • 作用域:同源策略(协议、域名、端口一致),同一域名下所有页面可共享。
  • 存储类型:仅支持字符串,存储对象需通过 JSON.stringify() 序列化,读取时用 JSON.parse() 反序列化。
  • 安全性:无内置安全机制,存储的数据可被同源 JS 读取,易受 XSS 攻击,禁止存储敏感信息。

应用场景:

  • 存储用户偏好设置(如深色 / 浅色主题、字体大小、语言选择)。
  • 存储搜索历史、浏览记录(如电商网站的搜索关键词)。
  • 存储非敏感的表单常用数据(如收货地址、常用联系人)。
  • 单页应用(SPA)的状态持久化(如 Vuex、Redux 的状态缓存)。

实践示例:LocalStorage 基础操作

javascript

运行

// 存储对象(需序列化)
const userSettings = { theme: 'dark', fontSize: '16px' };
localStorage.setItem('userSettings', JSON.stringify(userSettings));

// 读取数据(需反序列化)
const savedSettings = JSON.parse(localStorage.getItem('userSettings')) || { theme: 'light' };
console.log('当前主题:', savedSettings.theme); // 输出 "dark"

// 删除单个数据
localStorage.removeItem('userSettings');

// 清空所有数据
localStorage.clear();

3. SessionStorage:会话级别的 “临时缓存”

SessionStorage 与 LocalStorage API 完全一致,但生命周期和作用域不同,适用于临时存储会话数据

核心特性:

  • 容量限制:5-10MB(与 LocalStorage 一致)。
  • 生命周期:会话级有效,关闭标签页 / 浏览器后数据立即丢失(刷新页面不丢失)。
  • 作用域:比 LocalStorage 更严格 —— 同一域名下的不同标签页互不共享(同一标签页的 iframe 可共享)。
  • 存储类型:仅支持字符串,需序列化 / 反序列化。

应用场景:

  • 存储表单草稿(如用户填写注册信息、长文本编辑时,避免刷新页面丢失数据)。
  • 存储单页应用的路由参数(如当前页面的筛选条件、分页页码)。
  • 存储临时授权信息(如一次性验证码、临时访问令牌)。

实践示例:SessionStorage 存储表单草稿

javascript

运行

// 监听表单输入,实时存储草稿
document.getElementById('register-form').addEventListener('input', (e) => {
  const formDraft = {
    username: document.getElementById('username').value,
    email: document.getElementById('email').value,
    phone: document.getElementById('phone').value
  };
  sessionStorage.setItem('registerDraft', JSON.stringify(formDraft));
});

// 页面加载时恢复草稿
window.addEventListener('load', () => {
  const draft = JSON.parse(sessionStorage.getItem('registerDraft'));
  if (draft) {
    Object.keys(draft).forEach(key => {
      document.getElementById(key).value = draft[key];
    });
  }
});

4. IndexedDB:大量结构化数据的 “本地数据库”

IndexedDB 是浏览器提供的非关系型数据库(NoSQL),用于存储大量结构化数据(容量无明确限制,取决于硬盘空间),支持异步操作(不阻塞主线程)和事务,是客户端存储的 “终极方案”。

核心特性:

  • 容量:无硬性限制(浏览器通常限制为硬盘空间的 50%)。
  • 数据类型:支持对象、数组、字符串、数字、Blob(二进制数据,如图片、文件)等。
  • 操作方式:异步操作(通过回调或 Promise),避免阻塞 UI;支持事务(保证操作原子性,要么全部成功,要么全部失败)。
  • 作用域:同源策略,同一域名下所有页面可共享。

应用场景:

  • 离线应用数据存储(如离线博客、离线文档阅读器,存储文章内容、图片)。
  • 大量用户数据本地缓存(如电商 APP 的商品列表、购物车数据,离线时可操作,在线后同步服务器)。
  • 本地数据分析(如用户行为数据本地预处理,减少服务器压力)。

实践示例:IndexedDB 存储商品数据

javascript

运行

// 打开/创建数据库(数据库名:shopDB,版本号:1)
const request = indexedDB.open('shopDB', 1);

// 数据库初始化(首次创建或版本更新时触发)
request.onupgradeneeded = (e) => {
  const db = e.target.result;
  // 创建对象仓库(表),主键为id
  const productStore = db.createObjectStore('products', { keyPath: 'id' });
  // 创建索引(便于按分类查询)
  productStore.createIndex('category', 'category', { unique: false });
};

// 打开数据库成功
request.onsuccess = (e) => {
  const db = e.target.result;

  // 1. 新增数据(通过事务操作)
  const addTx = db.transaction('products', 'readwrite');
  const productStore = addTx.objectStore('products');
  productStore.add({ id: 1, name: '无线耳机', category: '数码', price: 999 });
  productStore.add({ id: 2, name: '机械键盘', category: '数码', price: 599 });

  // 2. 查询数据(按索引查询“数码”分类商品)
  const getTx = db.transaction('products', 'readonly');
  const productStore = getTx.objectStore('products');
  const categoryIndex = productStore.index('category');
  const cursor = categoryIndex.openCursor('数码');

  cursor.onsuccess = (e) => {
    const res = e.target.result;
    if (res) {
      console.log('商品:', res.value);
      res.continue(); // 遍历下一条
    }
  };

  // 关闭数据库
  db.close();
};

// 打开失败
request.onerror = (e) => {
  console.error('IndexedDB打开失败:', e.target.error);
};

三、进阶缓存方案:离线能力与极致优化

除基础方案外,前端还可通过「ServiceWorker + Cache API」「CDN 缓存」实现更复杂的需求,如离线访问、跨地域资源加速等。

1. ServiceWorker + Cache API:PWA 离线缓存的核心

ServiceWorker 是运行在浏览器后台的 “代理脚本”,独立于页面线程,可拦截网络请求、管理缓存资源,配合 Cache API(专门用于缓存网络资源),是实现 PWA(渐进式 Web 应用)离线功能的核心。

核心特性:

  • 独立线程:不阻塞页面渲染,可在后台执行缓存、数据同步等操作。
  • 拦截请求:能拦截所有同源网络请求,自定义缓存策略(如 “缓存优先”“网络优先”)。
  • 生命周期:安装(install)→ 激活(activate)→ 运行(activated),更新需手动处理。
  • 离线支持:缓存核心资源后,即使无网络,也能展示离线页面或缓存内容。

应用场景:

  • PWA 应用(如离线新闻 APP、离线文档阅读器、离线电商 APP)。
  • 弱网环境优化(缓存核心资源,减少加载失败概率)。
  • 离线数据同步(如表单提交失败后,网络恢复时自动同步)。

实践示例:ServiceWorker 缓存核心资源

javascript

运行

// 1. 页面注册ServiceWorker
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js');
      console.log('ServiceWorker注册成功:', registration.scope);
    } catch (err) {
      console.error('ServiceWorker注册失败:', err);
    }
  });
}

// 2. sw.js(ServiceWorker核心脚本)
const CACHE_VERSION = 'v1';
const CACHE_ASSETS = [ // 需缓存的核心资源
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/icon.png',
  '/offline.html' // 离线 fallback 页面
];

// 安装阶段:缓存核心资源
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_VERSION)
      .then(cache => cache.addAll(CACHE_ASSETS))
      .then(() => self.skipWaiting()) // 跳过等待,直接激活
  );
});

// 激活阶段:清理旧缓存
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      // 删除非当前版本的缓存
      return Promise.all(
        cacheNames.filter(name => name !== CACHE_VERSION)
          .map(name => caches.delete(name))
      );
    }).then(() => self.clients.claim()) // 控制所有打开的页面
  );
});

// 拦截请求,自定义缓存策略
self.addEventListener('fetch', (event) => {
  const request = event.request;

  // 策略1:HTML页面 → 网络优先(保证内容最新,离线时展示fallback)
  if (request.mode === 'navigate') {
    event.respondWith(
      fetch(request)
        .then(response => {
          // 更新缓存中的HTML
          caches.open(CACHE_VERSION).then(cache => cache.put(request, response.clone()));
          return response;
        })
        .catch(() => caches.match('/offline.html'))
    );
    return;
  }

  // 策略2:静态资源 → 缓存优先(优先用缓存,无缓存再请求网络)
  event.respondWith(
    caches.match(request)
      .then(cachedResponse => cachedResponse || fetch(request))
  );
});

2. CDN 缓存:跨地域资源的 “加速神器”

CDN(内容分发网络)是部署在全球各地的边缘节点集群,属于 “中间层缓存”—— 通过缓存静态资源,让用户从最近的节点获取资源,减少网络延迟和源站压力。

核心特性:

  • 跨地域加速:边缘节点覆盖全球,用户就近访问,降低跨运营商、跨地区的网络延迟。
  • 减轻源站压力:静态资源请求由 CDN 节点响应,源站仅处理动态请求(如接口调用)。
  • 弹性扩容:支持高并发场景(如秒杀、直播),避免源站带宽瓶颈。
  • 缓存策略:可按文件类型、路径、域名配置缓存过期时间,支持手动刷新缓存。

应用场景:

  • 大型网站的静态资源(图片、视频、JS/CSS、字体)。
  • 跨地域访问的网站(如跨境电商、全球新闻平台)。
  • 高并发场景(如电商秒杀、大型赛事直播的静态资源)。

前端配合方式:

  • 将静态资源路径指向 CDN 域名(如 https://cdn.example.com/app.[hash].js)。
  • 配合文件指纹(如哈希值、版本号),确保资源更新时 CDN 缓存失效。
  • 配置 CDN 缓存规则(如图片缓存 30 天,JS/CSS 缓存 7 天)。

示例:使用 CDN 引入第三方库

html

预览

<!-- 引入CDN上的Vue.js,配合版本号和文件指纹 -->
<script src="https://cdn.jsdelivr.net/npm/vue@3.4.0/dist/vue.global.prod.js"></script>

四、缓存策略选择与最佳实践

前端缓存的核心是 “平衡性能与数据一致性”,需根据资源类型、业务场景灵活选择方案。以下是落地时的关键指南:

1. 按资源 / 数据类型选择方案

数据 / 资源类型 推荐缓存方案 核心配置要点
静态资源(JS/CSS/ 图片) HTTP 强缓存 + CDN 缓存 设 30-90 天过期,配合文件指纹(hash)控制更新
动态页面(HTML / 接口) HTTP 协商缓存 禁用强缓存,启用 ETag/Last-Modified
登录态、CSRF 令牌 Cookie(httpOnly + secure) 设 7-30 天过期,避免存储敏感信息
用户偏好、搜索历史 LocalStorage 不存储敏感数据,定期清理过期内容
表单草稿、临时参数 SessionStorage 利用会话级生命周期,无需手动清理
离线数据、大量结构化数据 IndexedDB + ServiceWorker 缓存核心数据,在线后同步服务器
跨地域静态资源 CDN 缓存 指向 CDN 域名,配置合理过期时间

2. 关键优化技巧

  • 避免缓存脏数据:静态资源必须加文件指纹(如 app.[hash].js)或版本号,更新时修改标识即可失效旧缓存。
  • 敏感数据安全:密码、token 等敏感信息禁止存储在 LocalStorage/SessionStorage,优先使用 httpOnly Cookie;IndexedDB 存储敏感数据需加密。
  • 合理设置过期时间:频繁更新的资源(如活动页)设短缓存(1-7 天),稳定资源(如第三方库)设长缓存(30-90 天)。
  • 清理过期缓存:ServiceWorker 激活时清理旧版本缓存,LocalStorage/IndexedDB 定期清理过期数据。
  • 兼容离线场景:核心业务(如购物车、表单提交)需通过 ServiceWorker + IndexedDB 实现离线能力,避免弱网导致用户操作失败。

3. 常见问题排查

  • 缓存不更新:检查是否未加文件指纹,或 CDN 缓存未刷新;手动清除浏览器缓存测试,或通过 Ctrl+Shift+R 强制刷新。
  • 数据不一致:动态内容误用强缓存,需改为协商缓存;关键数据(如用户余额)禁止缓存,每次请求服务器。
  • 存储容量不足:避免 LocalStorage 存储大量数据,改用 IndexedDB;定期清理无用缓存。
  • 安全风险:Cookie 未设置 httpOnly/secure/SameSite,易受 XSS/CSRF 攻击;LocalStorage 存储的数据需过滤,避免注入攻击。

总结

前端缓存是一套 “分层协同” 的策略体系:HTTP 缓存负责静态资源的快速加载,客户端存储缓存解决前端数据的持久化需求,ServiceWorker 与 CDN 则实现离线能力和跨地域加速。

在实际开发中,无需局限于单一方案 —— 例如,一个 PWA 电商 APP 可采用 “HTTP 强缓存(静态资源)+ CDN(图片加速)+ Cookie(登录态)+ LocalStorage(用户偏好)+ IndexedDB(购物车)+ ServiceWorker(离线访问)” 的组合,既保证性能,又兼顾数据一致性和用户体验。

掌握前端缓存的核心原理和实践技巧,不仅能显著提升页面加载速度、降低服务器压力,更能在弱网、离线等复杂场景下保障用户体验 —— 这也是前端工程师从 “实现功能” 到 “优化体验” 的关键一步。

为什么vue中使用query可以保留参数

本质与原理

一句话回答
这是 Vue Router 将 query 对象序列化为 URL 查询字符串(Query String) ,并拼接到路径后面,形成完整的 URL(如 /user?id=123&name=alice),从而实现参数传递。


本质:前端路由对 URL 的构造与解析

Vue Router 并不“保存”参数,而是:

  1. 构造一个合法的 URL
  2. 通过浏览器 History API 或 hash 变更 URL
  3. 在路由匹配时反向解析该 URL

所以,query 的存在完全依赖于 URL 本身的结构


🛠 执行过程详解

当你调用:

this.$router.push({
  path: '/user',
  query: { id: 123, name: 'alice' }
});

Vue Router 内部会执行以下步骤:

1:序列化 query 对象

  • 使用类似 URLSearchParams 的机制,将 { id: 123, name: 'alice' } 转为字符串:
// 伪代码
const queryString = new URLSearchParams({ id: 123, name: 'alice' }).toString();
// 结果: "id=123&name=alice"

2:拼接完整 URL

  • pathqueryString 合并:
/user + ? + id=123&name=alice → /user?id=123&name=alice

3:触发 URL 变更

  • 根据当前模式(hashhistory):
    • Hash 模式:设置 location.hash = '#/user?id=123&name=alice'
    • History 模式:调用 history.pushState(null, '', '/user?id=123&name=alice')

✅ 此时,浏览器地址栏显示完整带参 URL,且页面不刷新

4:路由匹配与参数注入

  • Vue Router 监听到 URL 变化后:
    • 匹配路由(如 { path: '/user', component: User }
    • 解析查询字符串,还原为对象:

this.$route.query === { id: "123", name: "alice" }

⚠️ 注意:所有 query 值都是 字符串类型(HTTP 协议限制)


为什么可以“带上路径后面”?

因为这是 URL 标准的一部分

根据 RFC 3986,URL 结构如下:



https://example.com/user?id=123&name=alice
│          │        │     └───────────────┘
│          │        │           ↑
│          │        │     Query String(查询字符串)
│          │        └── Path(路径)
│          └── Host(主机)
└── Scheme(协议)
  • 查询字符串( ?key=value&... )是 URL 的合法组成部分
  • 浏览器天然支持它,刷新时会完整保留
  • 服务端和前端都可以读取它

💡 Vue Router 只是利用了这一标准机制,并没有发明新东西。


优势:为什么推荐用 query 传参?

特性 说明
可分享 完整 URL 可直接复制发送给他人
可刷新 刷新后参数仍在(因为 URL 没变)
可书签 用户可收藏带参链接
SEO 友好 搜索引擎能索引不同 query 的页面(如搜索结果页)
调试方便 地址栏直接可见参数

注意事项

  1. 值类型全是字符串

// 传入
query: { id: 123 } // number
// 接收
this.$route.query.id === "123" // string!

需要手动转换:parseInt(this.$route.query.id)

  1. 敏感信息不要放 query
    • 查询字符串会出现在:
      • 浏览器历史记录
      • 服务器日志
      • Referer 头(如果跳转到第三方)
    • 不适合放 token、密码等
  1. 长度有限制
    • URL 总长一般限制在 2048 字符以内(各浏览器不同)
    • 大量数据建议用 POST 或状态管理

对比:query vs params(非路径型)

方式 是否体现在 URL 刷新后保留 适用场景
query ✅ 是(?id=123 ✅ 是 公开、可分享、可刷新的参数
params(未在 path 声明) ❌ 否 ❌ 否 临时跳转(如表单步骤),但刷新丢失

总结

this.$router.push({ path: '/user', query: {...} }) 的本质是:
构造一个标准的、带查询字符串的 URL,并通过前端路由机制导航到该地址。

  • 它利用的是 URL 原生的查询参数机制
  • 参数被持久化在地址栏中,因此刷新不丢失
  • 这是 SPA 应用中最安全、最通用的传参方式之一

🌟 记住:只要参数需要“跨刷新”或“可分享”,优先用 query

Vue 转盘抽奖 transform

Vue 转盘抽奖 transform

简介:电商食用级转盘抽奖

讲在前面

在我们日常生活,电子购物已是必不可少的环节了。营销手段更是层出不穷,要数经典的还是转盘抽奖了,紧张又刺激(其实概率还不都是咱们程序猿弄的,刺激个der~)

虽说如此...

但 还是决定自己搞一个试试!

核心 transform

transform 属性向元素应用 2D 或 3D 转换。该属性允许我们对元素进行旋转、缩放、移动或倾斜。

但是既然我们说转盘,当然用到的是旋转啦:rotate

简单示例 顺时针旋转10deg

transform:rotate(10deg);

什么?你已经会这个css属性了? 那恭喜你,你已经能自己独立制作转盘抽奖啦~

核心代码

1. 转盘UI

<template>

    <view class="">

      
            <!-- 转盘包裹 -->
            <view class="rotate">
              <!-- 绘制圆点 -->
              <view :class="'circle circle_' + index" v-for="(item, index) in circleList" :key="index"
                :style="{ background: index % 2 == 0 ? colorCircleFirst : colorCircleSecond }"></view>
              <!-- 转盘图片 -->
              <image class="dish" src="/static/demo/pan.png" :style="{ transform: rotate_deg, transition: rotate_transition }" ></image>
              <!-- 指针图片 -->
              <image class="pointer" src="/static/demo/zhen.png" @click="start" ></image>
              
            
            </view>



      
    </view>

</template>


<style lang="scss" scoped>
.rotate {
  width: 600rpx;
  height: 600rpx;
  background: #ffbe04;
  border-radius: 50%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  position: absolute;
  top: 48%;
  left: 50%;
  transform: translate(-50%, -50%);
}

.rotate .dish {
  width: 550rpx;
  height: 550rpx;
}

.pointer {
  width: 142rpx;
  height: 200rpx;
  position: absolute;
  top: 46%;
  left: 50%;
  transform: translate(-50%, -50%);
}

/* 圆点 */
.rotate .circle {
  position: absolute;
  display: block;
  border-radius: 50%;
  height: 20rpx;
  width: 20rpx;
  background: black;
}

/*这里只写了一个点的位置,其他的自己补充一下 调调位置就好啦*/
.rotate .circle_0 {
  top: 2rpx;
  left: 284rpx;
}
    
</style>

2.让转盘转动起来

var light_timer; //灯光定时器

data() {
        return {
            circleList: [], //圆点列表
            colorCircleFirst: "#FF0000", //圆点闪烁颜色一
            colorCircleSecond: "#fff", //圆点闪烁颜色二
            cat: 45, //总共8个扇形区域,每个区域45度,这就取决去奖品池的UI图了
            isAllowClick: true, //是否能够点击
            rotate_deg: 0, //指针旋转的角度
            rotate_transition: "transform 3s ease-in-out" //过渡属性,渐入渐出

        };
    },
        
onLoad() {
    this.showcircleList();
},
    
    methods: {
        // 设置边缘一圈16个圆点,可以根据需要修改
        showcircleList() {
            let circleList = [];
            for (var i = 0; i < 16; i++) {
                circleList.push(i);
            }
            this.circleList = circleList;
            this.light();   
        },

        //设置边缘灯光闪动效果
        light: function() {
            var that = this;
            clearInterval(light_timer);
            light_timer = setInterval(function() {
                if (that.colorCircleFirst == "#FF0000") {
                    that.colorCircleFirst = "#fff";
                    that.colorCircleSecond = "#FF0000";
                } else {
                    that.colorCircleFirst = "#FF0000";
                    that.colorCircleSecond = "#fff";
                }
            }, 300); //设置圆点闪烁的间隔时间效果
        },
        //点击开始抽奖
        start() {
            this.rotating();
        },
        //旋转
        rotating() {
            if (!this.isAllowClick) return;
            this.isAllowClick = false;
            this.rotate_transition = "transform 3s ease-in-out";
            this.LuckyClick--;
            var rand_circle = 5; //默认多旋转5圈
            var winningIndex = this.set(); //设置概率
            console.log(winningIndex);
            var randomDeg = 360 - winningIndex * 45; //8个区域。一圈是360度,对应区域旋转度数就是顺时针的 360 - winningIndex*45°
            var deg = rand_circle * 360 + randomDeg; //把本来定义多转的圈数度数也加上
            this.rotate_deg = "rotate(" + deg + "deg)";

            var that = this;
            setTimeout(function() {
                that.isAllowClick = true;
                that.rotate_deg = "rotate(" + randomDeg + "deg)"; //定时器关闭的时候角度调回五圈之前相同位置,依照产品需求可以自己更改
            that.rotate_transition = "";

                if (winningIndex == 0) {
                    console.log("恭喜您,IphoneX");
                } else if (winningIndex == 1) {
                    console.log("恭喜您,获得10元现金");
                } else if (winningIndex == 2) {
                    console.log("很遗憾,重在参与");
                    uni.showToast({
                        title:"很遗憾,重在参与",
                        icon:"none"
                    })
                } else if (winningIndex == 3) {
                    console.log("恭喜您,获得30元现金");
                } else if (winningIndex == 4) {
                    console.log("恭喜您,获得20元现金");
                } else if (winningIndex == 5) {
                    console.log("恭喜您,获得50元现金");
                } else if (winningIndex == 6) {
                    console.log("恭喜您,获得5元现金");
                } else if (winningIndex == 7) {
                    console.log("恭喜您,获得100元现金");
                }
            }, 3500);
        },

        //设置概率
        set() {
            var winIndex;
            var __rand__ = Math.random();
            // 随机数 设置抽奖概率 winIndex 记得参考奖品池的UI图
            if (__rand__ < 0.30) winIndex = 2;
            else if (__rand__ < 0.55) winIndex = 6;
            else if (__rand__ < 0.75) winIndex = 1;
            else if (__rand__ < 0.85) winIndex = 4;
            else if (__rand__ < 0.92) winIndex = 3;
            else if (__rand__ < 0.97) winIndex = 5;
            else if (__rand__ < 0.99) winIndex = 7;
            else if (__rand__ == 0.99) winIndex = 0;
            return winIndex;
        },



}

最终效果展示

zhuanpan.png

总结

其实就是利用背景图进行旋转,设置好旋转角度!如果有兴趣的话就快速行动吧,冲冲冲!!!

vue中hash模式和history模式的区别

一句话总结

  • Hash 模式:利用 URL 中 # 后的内容变化实现前端路由,不触发页面刷新,兼容性好。
  • History 模式:基于 HTML5 的 history.pushState()popstate 事件,URL 更干净,但需要服务端配合。

一、Hash 模式(默认模式)

1. 基本形式

https://example.com/#/user/profilehash 部分

2. 工作原理

  • 核心机制:监听 window.onhashchange 事件。
  • 当用户点击链接或调用 router.push() 时,Vue Router 修改 location.hash(如 #/home#/about)。
  • 浏览器不会向服务器发起请求,因为 # 及其后的内容不会发送给服务器
  • 页面 URL 改变但不刷新,前端根据新的 hash 值匹配路由并渲染对应组件。

3. 特点

优点 缺点
✅ 兼容性极好(IE8+ 支持) ❌ URL 中带有 #,不够美观
✅ 无需服务端配置 ❌ SEO 友好性略差(部分爬虫可能忽略 hash)
✅ 天然避免 404 问题 ❌ 不符合传统 URL 语义

4. 示例代码(Vue Router 配置)

const router = new VueRouter({
  mode: 'hash', // 默认值,可省略
  routes: [...]
});

二、History 模式(推荐用于现代项目)

1. 基本形式

https://example.com/user/profile

URL 看起来和传统多页应用一致,无 # 符号。

2. 工作原理

  • 核心技术
    • history.pushState(state, title, url):在不刷新页面的情况下修改浏览器历史记录和 URL。
    • history.replaceState(...):替换当前历史记录。
    • window.onpopstate:监听浏览器前进/后退操作(如点击 ← → 按钮)。
  • 流程示例
    1. 用户访问 /home → 前端加载,Vue Router 渲染 Home 组件。
    2. 点击“关于”链接 → 调用 router.push('/about') → 执行 history.pushState(null, '', '/about')
    3. URL 变为 https://example.com/about,页面不刷新,About 组件被渲染。
    4. 用户刷新页面 → 浏览器向服务器请求 /about 资源。

3. 关键问题:刷新 404

  • 原因:服务器收到 /about 请求时,若未配置,会尝试查找物理路径下的 about.html 或目录,找不到则返回 404。
  • 解决方案服务端需配置“兜底路由” ,将所有前端路由请求重定向到 index.html

Nginx 配置示例:

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

4. 特点

优点 缺点
✅ URL 简洁美观,符合 REST 风格 ❌ 需要服务端支持(部署配置)
✅ 更好的 SEO(主流爬虫已支持) ❌ 在纯静态托管(如 GitHub Pages)中需额外处理
✅ 用户体验更接近原生 Web ❌ 旧浏览器(IE9 以下)不支持

5. 示例代码(Vue Router 配置)

const router = new VueRouter({
  mode: 'history',
  routes: [...]
});

🔁 三、对比总结

特性 Hash 模式 History 模式
URL 样式 example.com/#/path example.com/path
刷新是否 404 ❌ 不会(# 后内容不发给服务器) ✅ 会(需服务端配置兜底)
浏览器兼容性 IE8+ IE10+(HTML5 History API)
服务端要求 必须配置 fallback 到 index.html
SEO 友好性 一般 较好(现代爬虫支持)
使用场景 快速原型、老旧环境、无服务端控制权 正式项目、追求用户体验、有运维支持

💡 最佳实践建议

  • 开发阶段:两种模式均可,推荐 history 提前暴露部署问题。
  • 生产部署
    • 若使用 Nginx/Apache/Caddy → 优先选 history + 配置 fallback。
  • 无法控制服务端? → 用 hash 模式最稳妥。

补充知识

  • 为什么 hash 不发给服务器?
    根据 HTTP 规范,URL 中 #fragment 部分仅用于客户端定位(如锚点),不会包含在 HTTP 请求中
  • History API 安全限制
    pushState 只能修改同源下的路径,不能跨域篡改 URL,保障了安全性。

现代前端开发工程化:从 Vite 到 Vue 3 多页面应用实战

现代前端开发工程化:从 Vite 到 Vue 3 多页面应用实战

在当今快速迭代的前端开发环境中,工程化已成为构建高质量、可维护项目的基石。本文将结合实际项目结构与开发流程,带你深入理解如何使用 Vite 搭建一个现代化的 Vue 3 项目,并实现多页面路由功能,打造高效、优雅的前端开发体验。

一、什么是 Vite?为何它如此重要?

Vite 是由 Vue 作者尤雨溪主导开发的新一代前端构建工具,它颠覆了传统打包工具(如 Webpack)的“先打包再运行”模式,转而利用浏览器原生支持的 ES 模块(ESM),实现了:

  • 极速冷启动:无需等待打包,项目秒级启动;
  • 毫秒级热更新(HMR) :修改代码后浏览器自动刷新,开发效率翻倍;
  • 开箱即用的现代特性:对 TypeScript、CSS 预处理器、JSX 等天然支持;
  • 轻量且高性能:基于 Node.js 构建,但不干扰开发阶段的加载逻辑。

简单来说,Vite 是现代前端开发的“加速器” ,让开发者专注于业务逻辑,而非等待编译。

二、初始化项目:npm init vite

打开终端,执行以下命令创建新项目:

npm init vite@latest my-vue-app -- --template vue
cd my-vue-app
npm install

这会生成一个标准的 Vue 3 + Vite 项目模板。运行:

npm run dev

项目将在 http://localhost:5173 启动,并自动打开浏览器,进入开发环境。此时 Vite 已作为开发服务器运行:它不会打包整个应用,而是按需通过原生 ESM 加载模块。当你访问 localhost:5173 时,浏览器直接请求 /src/main.js,Vite 在后台实时解析 .vue 文件并提供模块服务——这正是“无需打包即可开发”的核心机制。

📌 注意:确保安装 Volar 插件(VS Code 官方推荐),以获得 Vue 3 的语法高亮、智能提示和代码补全;同时安装 Vue Devtools 浏览器插件用于调试组件状态。

三、项目架构解析

以下是典型的 Vite + Vue 3 项目结构:

vitevue.png

my-vue-app/
├── index.html              # 入口 HTML 文件
├── src/
│   ├── assets/             # 静态资源(图片、SVG 等)
│   ├── components/         # 可复用组件
│   │   └── HelloWorld.vue
│   ├── router/             # 路由配置
│   │   └── index.js
│   ├── views/              # 页面级组件
│   │   ├── Home.vue
│   │   └── About.vue
│   ├── App.vue             # 根组件
│   ├── main.js             # 应用入口
│   └── style.css           # 全局样式
├── public/                 # 公共静态资源(不会被构建处理)
├── package.json            # 依赖与脚本配置
├── vite.config.js          # Vite 配置文件(可选)
└── .gitignore

关键点说明:

Vue 应用的启动流程如下:浏览器加载 index.html → 执行 <script type="module" src="/src/main.js">main.js 调用 createApp(App) 创建实例 → 将根组件 App.vue 挂载到 #root 元素。整个过程由 Vite 提供的 ESM 环境驱动,无需传统打包步骤。

  • index.html:Vite 默认以此为入口,其中 <div id="root"></div> 是 Vue 应用的挂载点。
  • main.js:创建 Vue 实例并挂载到 #root
  • App.vue:整个应用的根组件,所有内容由此展开。
  • src/components/ :存放通用组件,如按钮、表单等。
  • src/views/ :存放页面级组件,每个页面对应一个 .vue 文件。
  • src/router/index.js:路由配置中心。

这种目录划分体现了现代前端工程化的核心思想

  • 关注点分离:页面(views)、通用组件(components)、路由(router)各司其职;
  • 可扩展性:新增功能只需在对应目录添加文件,不影响整体结构;
  • 团队协作友好:开发者可并行开发不同模块,降低耦合风险。

四、实现多页面:引入 Vue Router

在单页应用(SPA)中,“多页面”其实是通过路由切换不同的视图组件。我们使用 Vue Router 来实现这一功能。

1. 安装 vue-router

npm install vue-router@4

⚠️ 注意:Vue 3 必须搭配 vue-router v4。

2. 创建页面组件

src/views/ 下创建两个页面:

Home.vue

<template>
  <div>
    <h1>首页</h1>
    <p>欢迎来到主页!</p>
  </div>
</template>

About.vue

<template>
  <div>
    <h1>关于</h1>
    <p>这里是关于我们页面。</p>
  </div>
</template>

3. 配置路由

src/router/index.js 中配置路由:

import { createRouter, createWebHashHistory } from 'vue-router'
import Home from '../views/Home.vue'
import About from '../views/About.vue'

const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About }
]

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

export default router

💡 使用 createWebHashHistory() 可以避免服务器配置问题,适合本地开发。

4. 注册并使用路由

修改 main.js

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './style.css'

createApp(App).use(router).mount('#root')

修改 App.vue 添加导航和路由出口:

<template>
  <nav>
    <router-link to="/">首页</router-link> |
    <router-link to="/about">关于</router-link>
  </nav>
  <router-view />
</template>

现在,点击链接即可在不同页面间切换,URL 也会相应变化,完全符合 SPA 的交互体验。

五、总结:现代前端工程化的核心价值

  • 极速开发体验: 借助 Vite 利用浏览器原生 ES 模块(ESM)的能力,实现项目秒级冷启动和毫秒级热更新,大幅减少等待时间。

  • 组件化开发模式: Vue 3 的单文件组件(.vue)结构将模板、逻辑与样式封装在一起,提升代码复用性与可维护性。

  • 清晰的项目结构: 标准化的目录组织(如 src/views/src/components/src/router/)让项目职责分明,便于团队协作和长期维护。

  • 路由管理能力: 通过官方插件 vue-router 实现声明式路由配置,轻松支持多页面(视图)切换,构建完整的单页应用(SPA)。

  • 强大的工具生态支持:

    • Volar:提供 Vue 3 专属的语法高亮、智能提示和类型检查;
    • Vue Devtools:在浏览器中直观调试组件状态、路由和事件流。
  • 低门槛、高扩展性:npm init vite 一行命令即可生成完整项目骨架,后续可无缝集成 TypeScript、Pinia、单元测试、自动化部署等高级能力。

  • 面向未来的架构设计: 整套工程化方案基于现代 Web 标准构建,兼顾开发效率与生产性能,为构建复杂企业级应用打下坚实基础。

六、结语

前端工程化不是炫技,而是让开发更高效、更可靠、更可持续的过程。从 npm init vite 开始,你已经迈入了现代前端开发的大门。掌握 Vite、Vue 3 和 vue-router,你就拥有了构建复杂应用的核心能力。

🚀 接下来,不妨尝试添加一个表单、引入 Pinia 管理用户登录状态,或者部署到 GitHub Pages —— 让你的第一个现代前端项目真正落地!

代码是思想的体现,工程化是思想的容器。愿你在前端之路上越走越远。

跨域问题详解

引言:在一个前/后端分离的项目开发中,常常会出现前端向后端发送一个请求时,浏览器报错:Access to XMLHttpRequest at 'http://localhost:8080/' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.,也就是通常说的“跨域访问”的问题,由此导致前端代码不能读取到后端数据。

摘要:所谓“跨域问题”,本质上是浏览器在同源策略约束下,主动阻止 JavaScript 读取跨源请求响应的一种安全保护行为。解决跨域问题主要通过服务器端设置CORS(跨域资源共享)机制——浏览器放行跨域请求响应的数据;或者Nginx/网关的代理功能——跨域的请求实际由网关代发,浏览器端依旧是同源请求。

什么是跨域访问

跨域访问指的是:当前网页所在的“源(Origin)”去访问另一个“不同源”的资源,而该访问被浏览器安全策略所限制或拦截的情况。

在浏览器中一个“源”由三部分组成:协议(Protocol) + 域名(Host) + 端口(Port),只要有一个部分不一样就是跨源,也即跨域。例如:

URL 协议 域名 端口 是否同源
http://example.com http example.com 80 基准
http://example.com:8080 http example.com 8080 跨域(端口不同)
https://example.com https example.com 443 跨域(协议不同)
http://api.example.com http api.example.com 80 跨域(域名不同)

这里需要强调:对“跨域访问”进行限制是浏览器的安全策略导致的,并不是前端或后端技术框架引起的

为什么跨域访问请求“得不到”数据

这里就要展开说明为什么浏览器要对“跨域访问”进行限制,导致(尤其是)Web前端中发送HTTP请求会得不到数据,并在控制台报错。

出于安全性,浏览器会采用同源策略(Same-Origin Policy,SOP)限制脚本内发起的跨源 HTTP 请求,限制一个源的文档或者它加载的脚本如何与另一个源的资源进行交互。它能帮助阻隔恶意文档,减少可能被攻击的媒介。例如,它可以防止互联网上的恶意网站在浏览器中运行 JavaScript 脚本,从第三方网络邮件服务(用户已登录)或公司内网(因没有公共 IP 地址而受到保护,不会被攻击者直接访问)读取数据,并将这些数据转发给攻击者。

假设在没有同源限制的情况下:

  • 用户已登录银行网站 https://bank.com(Cookie 已保存)
  • 用户同时打开一个恶意网站 https://evil.com
  • evil.com 的 JavaScript 可以:
    • 直接读取 bank.com 的接口返回数据
    • 发起转账请求
    • 窃取用户隐私信息

这是非常严重的安全灾难。

同源策略将跨源之间的访问(交互)通常分为3种:

  • 跨源写操作(Cross-origin writes)一般是被允许的。例如链接、重定向以及表单提交。特定少数的 HTTP 请求需要添加预检请求
  • 跨源资源嵌入(Cross-origin embedding)一般是被允许的,比如<img src="..."><script src="..."><link href="...">
  • 跨源读操作(Cross-origin reads)一般是不被允许的。

再次强调:跨域限制是“浏览器行为”,不是后端服务器的限制。后端服务本身是可以接收来自任何来源的 HTTP 请求的。

比如前端访问fetch("https://api.example.com/data"),而当前页面来自http://localhost:8080,请求可以发出去,但浏览器会拦截响应,不让 JavaScript 读取。

要使不同源可以访问(交互),可以使用 CORS来允许跨源访问。CORSHTTP的一部分,它允许服务端来指定哪些主机可以从这个服务端加载资源。

怎么解决跨域访问的“问题”

CORS机制

跨源资源共享(Cross-Origin Resource Sharing,CORS,或通俗地译为跨域资源共享)是一种基于HTTP头的机制,该机制通过允许服务器标示除了它自己以外的其他源(域、协议或端口),使得浏览器允许这些源访问加载自己(服务器)的资源。跨源资源共享还通过一种机制来检查服务器是否会允许要发送的真实请求,该机制通过浏览器发起一个到服务器托管的跨源资源的“预检”请求。在预检中,浏览器发送的头中标示有 HTTP 方法和真实请求中会用到的头(Header)。

对那些可能对服务器数据产生副作用的 HTTP 请求方法(特别是GET以外的 HTTP 请求,或者搭配某些MIME类型(多用途互联网邮件扩展,是一种标准,用来表示文档、文件或一组数据的性质和格式)的POST请求),浏览器必须首先使用OPTIONS方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨源请求。服务器确认允许之后,才发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(例如Cookie和HTTP 认证相关数据)。

一般浏览器要检查的响应头有:

  • Access-Control-Allow-Origin:指示响应的资源是否可以被给定的来源共享。
  • Access-Control-Allow-Methods:指定对预检请求的响应中,哪些 HTTP 方法允许访问请求的资源。
  • Access-Control-Allow-Headers:用在对预检请求的响应中,指示实际的请求中可以使用哪些 HTTP 标头。
  • Access-Control-Allow-Credentials:指示当请求的凭据标记为 true 时,是否可以暴露对该请求的响应给脚本。
  • Access-Control-Max-Age:指示预检请求的结果能被缓存多久。

如:

Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true

可知,若使用CORS解决跨域访问中的问题要在服务器端(通常是后端)进行设置。以Spring Boot的后端为例:

  • 局部的请求:在对应的Controller类或指定方法上使用@CrossOrigin。如下

    @CrossOrigin(
        origins = "http://localhost:3000",
        allowCredentials = "true"
    )
    
  • 全局使用:新建一个配置类并注入Spring框架中。如下:

    @Configuration
    public class CorsConfig implements WebMvcConfigurer {
    
        @Override
        public void addCorsMappings(CorsRegistry registry) {
            registry.addMapping("/api/**")
                    .allowedOrigins(
                        "http://test.example.com"
                    )
                    .allowedMethods("GET","POST","PUT","DELETE")
                    .allowedHeaders("*")
                    .allowCredentials(true)
                    .maxAge(3600);
        }
    }
    

使用CORS 的优点:官方标准;安全、可控;与前后端分离完美匹配。缺点:需要服务端正确配置;初学者容易被预检请求困扰。

通过架构或代理手段

除了使用CORS的方式,还可以通过架构设计或代理的方式让跨域“变成”同源访问

比如通过Nginx / 网关代理浏览器(前端)请求,再由Nginx或网关访问服务器获取数据。

浏览器 → 前端域名 → Nginx → 后端服务

这样的话在浏览器(前端)看到将始终是对当前网站(前端域名)的访问(即使打开开发者工具的网络选项,请求的url地址也是前端域名)。

一个Nginx的配置示例:

server {
    listen 443;
    server_name www.example.com;

    location / {
        root /usr/share/nginx/html;
        index index.html;
        try_files $uri $uri/ /index.html;
    }

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

前端请求示例:axios.get('/api/user')

这是通过Nginx或网关这样的中间件实现的,如果在开发阶段想要快速解决跨域访问问题,可以在相应的项目构建的配置中设置代理。这里以Vite为构建工具的Vue项目为例,在vite.config.js中添加如下的配置项:

// vite.config.js
export default {
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true
      }
    }
  }
}

然后请求的URL采用这样的方式axios.get('/api/user'),不在使用axios.get('http://localhost:8080/api/user')

使用代理方式的优点:无跨域;性能好;适合生产环境。缺点:需要额外部署配置。

总结

跨域问题并不是请求被禁止,而是浏览器在同源策略约束下,出于安全考虑,限制前端 JavaScript 对跨源响应数据的访问行为。

跨域问题的根源是 浏览器实现的同源策略(Same-Origin Policy),而不是:

  • HTTP 协议限制
  • 后端服务器限制
  • 前端框架(Vue / React)的问题

浏览器阻止的是JS 获取结果,而不是“阻止请求发送”——跨域请求可以被发出,服务器可以正常返回(比如预检请求响应),浏览器阻止JavaScript访问响应数据。

“跨域问题”只存在于浏览器环境,例如:

  • Java / Node / Python 发 HTTP 请求——没有跨域问题
  • Postman / curl ——没有跨域问题
  • 微服务之间调用——没有跨域问题

因为这些环境不执行浏览器的同源策略跨域问题是浏览器安全模型的一部分,本质上是对跨源资源访问的“读权限控制”,而非通信能力限制。

使用CORS 并不是“绕过”同源策略——浏览器的同源策略始终存在;CORS 是 同源策略的“例外机制”;本质是:服务器显式授权浏览器放行。换句话说:没有 CORS,就没有“合法的跨域读取”

只要不产生跨域,就不会有跨域问题,所以可以使用代理或网关将请求进行转发,而不是由浏览器直接请求服务器端发生跨域问题。

❌