阅读视图

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

前端工程化核心知识全面解析

一、构建工具演进:从任务执行到模块化打包

Grunt、Gulp:基于任务运行的工具

Grunt 和 Gulp 是前端工程化早期的代表性工具,它们的工作方式类似于工厂流水线:

  • 自动化任务执行:通过配置一系列任务,自动完成代码检查、编译、压缩等操作

  • 丰富的插件生态:拥有活跃的社区支持,提供大量功能插件

  • 灵活的工作流:可以按照需求定制完整的开发工作流程

Webpack:基于模块化打包的工具

Webpack 代表了新一代构建工具的思想转变:

  • 一切皆模块:将项目中的所有资源(JS、CSS、图片等)都视为模块

  • 依赖关系管理:递归构建依赖关系图,确保模块间的正确引用

  • 打包优化:将所有模块打包成少数几个 bundle 文件,优化加载性能

现代替代方案:npm script

随着技术的发展,现在更推荐使用 npm script 来替代传统的任务运行器:

{
  "scripts": {
    "build": "webpack --mode production",
    "dev": "webpack serve --mode development",
    "lint": "eslint src/"
  }
}

二、主流打包工具对比选型

Webpack:复杂应用的优选

优势特点:

  • 功能全面,支持代码分割、懒加载等高级特性

  • 生态丰富,拥有大量 loader 和 plugin

  • 适合大型复杂的前端项目

适用场景: 企业级应用、单页面应用(SPA)、需要复杂构建流程的项目

Rollup:库开发的利器

优势特点:

  • Tree-shaking 效果出色,打包体积小

  • 配置简单,专注于 ES6 模块打包

  • 输出格式多样(ESM、CJS、UMD 等)

适用场景: Vue、React 等开源库、工具库、组件库开发

Parcel:快速原型开发

优势特点:

  • 零配置开箱即用

  • 构建速度快

  • 适合初学者

适用场景: demo 项目、实验性项目、快速原型开发

三、Webpack 核心概念深入理解

常用 Loader 详解

Loader 就像是 Webpack 的"翻译官",负责处理各种类型的文件:

Loader 名称

主要功能

使用场景

babel-loader

ES6+ 转 ES5

现代 JavaScript 兼容

css-loader

解析 CSS 文件

CSS 模块化处理

style-loader

注入 CSS 到 DOM

开发环境样式热更新

file-loader

处理文件资源

图片、字体等静态资源

url-loader

小文件转 base64

优化小资源加载

重要特性: Loader 执行顺序为从右向左,符合函数式编程的 compose 理念。

常用 Plugin 功能解析

Plugin 赋予 Webpack 更强大的扩展能力:

  • DefinePlugin:定义全局常量,常用于环境变量配置

  • HtmlWebpackPlugin:自动生成 HTML 文件并注入资源引用

  • MiniCssExtractPlugin:提取 CSS 到独立文件,支持生产环境优化

  • WebpackBundleAnalyzer:可视化分析打包体积,助力性能优化

核心概念区分

  • Module:开发中的单个模块,对应源代码文件

  • Chunk:代码块,由多个模块组成

  • Bundle:最终的输出文件,可能包含多个 Chunk

四、Loader 与 Plugin 深度对比

对比维度

Loader

Plugin

作用范围

模块级别,处理单个文件

整个构建过程

配置位置

module.rules 数组

plugins 数组

本质功能

文件转换器

生命周期钩子监听器

执行时机

模块加载阶段

整个编译周期

五、热更新机制原理剖析

Webpack Hot Module Replacement (HMR) 是现代开发体验的重要保障:

工作流程

  1. 文件监控:Webpack 在 watch 模式下监控文件变化

  2. 内存编译:将重新编译的代码保存在内存中

  3. 消息推送:通过 WebSocket 向客户端推送更新信息

  4. 模块替换:客户端动态替换变更模块,保持应用状态

核心优势

  • 保持应用状态不丢失

  • 大幅提升开发效率

  • 支持样式、组件等粒度的热更新

六、Babel 编译原理探秘

Babel 的转译过程分为三个精密阶段:

1. 解析阶段(Parse)

// 源代码
const sum = (a, b) => a + b;

// 转换为 AST
{
  type: "VariableDeclaration",
  declarations: [
    {
      type: "VariableDeclarator",
      id: { type: "Identifier", name: "sum" },
      init: {
        type: "ArrowFunctionExpression",
        params: [...],
        body: {...}
      }
    }
  ]
}

2. 转换阶段(Transform)

遍历 AST,应用各种插件进行语法转换:

  • 箭头函数转普通函数

  • const/let 转 var

  • 类语法转换等

3. 生成阶段(Generate)

将转换后的 AST 重新生成目标代码。

七、版本控制系统深度对比

Git vs SVN 架构差异

特性对比

Git(分布式)

SVN(集中式)

存储方式

元数据存储,完整版本历史

文件存储,增量记录

网络需求

支持完全离线操作

必须连接服务器

分支管理

轻量级分支,快速切换

目录拷贝,开销较大

数据安全

SHA-1 哈希保证完整性

相对较弱

Git 的核心优势

  1. 性能卓越:本地操作,速度极快

  2. 分支灵活:创建、合并分支几乎无成本

  3. 数据可靠:完整的版本历史和内容校验

八、Git 常用命令手册

基础操作命令

# 仓库初始化
git init
git clone <url>

# 提交变更
git add .
git commit -m "commit message"

# 状态查看
git status
git diff
git log --oneline

分支管理

# 分支操作
git branch feature-xxx
git checkout feature-xxx
git merge main
git branch -d feature-xxx

远程协作

# 远程仓库
git remote add origin <url>
git push -u origin main
git pull origin main

九、Git 高级操作解析

git fetch vs git pull

# 安全更新:只下载不合并
git fetch origin

# 快捷更新:下载并合并
git pull origin main

最佳实践:推荐先 git fetch 查看变更,再决定是否合并。

git rebase vs git merge

操作方式

提交历史

适用场景

merge

保留完整合并历史

团队协作,公共分支

rebase

线性整洁的历史

个人特性分支

rebase 使用注意

# 正确的 rebase 流程
git checkout feature-branch
git rebase main
git checkout main
git merge feature-branch

重要原则:不要在公共分支上执行 rebase 操作!

总结

前端工程化是一个不断演进的技术领域,从早期的任务运行器到现代的模块化打包工具,从集中式版本控制到分布式协作开发,每一次技术变革都带来了开发效率和项目质量的显著提升。掌握这些核心知识,能够帮助我们在实际项目中做出更合理的技术选型,构建更健壮的前端应用架构。

随着技术的不断发展,前端工程化将继续向着更智能、更高效的方向演进,但扎实的基础知识和核心原理将始终是我们应对技术变化的坚实基础。

基于 Vue2 封装大华 RTSP 回放视频组件(PlayerControl.js 实现)

参考链接:基于 Vue3 封装大华 RTSP 回放视频组件(PlayerControl.js 实现)_vue playercontrol大华的使用-CSDN博客

官方教程: WEB无插件开发包使用说明-浙江大华技术股份有限公司

碰到的问题: 1、PlayerControl.js默认是在根目录使用,如果不是在根目录使用需要修改对应文件中的的路径(我的前缀是cockpit)不然会找不到对应的文件

image.png

image.png

2、我对接的大华的摄像头是H265格式的只能在canvas中展现出来,我这边的功能需要是对视频进行回放和参考链接类似,但是参考链接是能在video中展示因此不需要添加播放、暂停、音量开关、抓图、刷新、全屏功能,canvas中就需要手动添加

<template>
  <el-dialog :title="'文件预览'" class="prev-file-dialog" append-to-body :visible.sync="DialogVisible" :fullscreen="true">
    <template #title>
      <div class="vn-flex vn-flex-space-between vn-gap-8 vn-flex-y-center vn-fill-width">
        <span>{{ '文件预览' }}</span>
      </div>
    </template>
    <div class="preview-pdf">
      <canvas ref="canvasElement" :style="{ width: '100%', height: '100%' }"></canvas>
      <div class="operation">
        <div class="operation-left vn-flex vn-flex-y-center vn-gap-8">
          <div
            class="play icon vn-pointer"
            :class="canvasOperation.playState ? 'el-icon-video-pause' : 'el-icon-video-play'"
            @click="handlePlay"
          ></div>
          <div class="disconnect icon"></div>
          <button class="control-btn" @click.stop="toggleMute">
            <span v-if="!canvasOperation.muteState" class="icon-volume">🔊</span>
            <span v-else class="icon-muted">🔇</span>
          </button>

          <div class="timestamp vn-flex vn-flex-y-center vn-gap-4">
            <span class="first-time">{{ canvasOperation.firstTime }}</span>
            /
            <span class="total-time">{{ canvasOperation.totalTime }}</span>
            <el-input-number
              v-model="canvasOperation.backTime"
              size="mini"
              clearable
              type="number"
              :min="0"
              :max="canvasOperation.totalTime"
              :controls="false"
              class="number-input"
              :precision="0"
            ></el-input-number>
            <el-button size="mini" type="primary" @click="playerBack">跳转</el-button>
          </div>
        </div>
        <div class="operation-right vn-flex vn-flex-y-center vn-gap-12">
          <!-- 捕获截图 -->
          <div class="el-icon-crop icon" @click="handleCapture"></div>

          <!-- 刷新 -->
          <div class="el-icon-refresh-right icon" @click="handleRefresh"></div>
          <!-- 全屏按钮 -->
          <div class="el-icon-full-screen icon" @click.stop="toggleFullscreen"></div>
        </div>
      </div>
    </div>
  </el-dialog>
</template>

<script lang="ts">
import { Component, Vue, Ref, Prop, PropSync } from 'vue-property-decorator'

@Component({
  name: 'DaHuaVideoPreview',
  components: {}
})
export default class DaHuaVideoPreview extends Vue {
  @Ref() canvasElement!: any
  @PropSync('visible', { default: false }) DialogVisible!: boolean
  // 接收外部参数
  @Prop({
    default: () => {
      return {
        wsURL: 'ws://xxx.xxx.xxx.xxx:9527/rtspoverwebsocket',
        url: '',
        ip: 'xxx.xxx.xxx.xxx',
        port: '9527',
        channel: 1,
        username: 'admin',
        password: 'admin123',
        proto: 'Private3',
        subtype: 0,
        starttime: '2025_11_10_09_10_00',
        endtime: '2025_11_10_10_10_00',
        width: '100%',
        height: '220px'
      }
    }
  })
  props!: any

  player: any = null
  canvasOperation = this.initCanvasData()

  initCanvasData() {
    return {
      playState: false,
      muteState: false,
      isFullscreen: false,
      backTime: 1,
      totalTime: 0,
      firstTime: 0
    }
  }
  playerStop() {
    this.player?.close()
  }

  playerPause() {
    this.player?.pause()
  }

  playerContinue() {
    // this.player?.play()
    this.playerPlay()
  }

  playerCapture() {
    this.player?.capture('test')
  }

  playerPlay() {
    if (this.player) {
      this.player.stop()
      this.player.close()
      this.player = null
    }
    if (!window.PlayerControl) {
      console.error('❌ PlayerControl SDK 未加载,请在 index.html 中引入 /module/playerControl.js')
      return
    }

    this.closePlayer()

    var options = {
      wsURL: `ws://${this.props.ip}:${this.props.port}/rtspoverwebsocket`,
      rtspURL: this.buildRtspUrl(),
      username: this.props.username,
      password: this.props.password,
      h265AccelerationEnabled: true
    }
    this.player = new window.PlayerControl(options)
    let firstTime = 0
    this.player.on('WorkerReady', (rs: any) => {
      console.log('WorkerReady')
      this.player.connect()
    })

    this.player.on('Error', (rs: any) => {
      console.log('error')
      console.log(rs)
    })
    this.player.on('PlayStart', () => {
      console.log('PlayStart')
      this.canvasOperation.playState = true
    })

    this.player.on('UpdateCanvas', (res: any) => {
      if (firstTime === 0) {
        firstTime = res.timestamp //获取录像文件的第一帧的时间戳
      }
      this.canvasOperation.firstTime = res.timestamp - firstTime
    })
    this.player.on('GetTotalTime', (res: any) => {
      console.log(res, 'GetTotalTime')
      this.canvasOperation.totalTime = res || 0
    })

    this.player.on('FileOver', (res: any) => {
      console.log(res, 'FileOver')
      this.handleRefresh()
    })
    this.player.init(this.canvasElement, null)
    window.__player = this.player
  }

  mounted() {
    console.log(this.props, 'props')
    this.$nextTick(() => {
      this.playerContinue()
    })
  }

  beforeDestroy() {
    this.closePlayer()
  }

  playerBack() {
    this.player.playByTime(this.canvasOperation.backTime)
  }

  /** 拼接 RTSP URL 回放 */
  buildRtspUrl() {
    if (this.props?.url) return this.props?.url
    return `rtsp://${this.props.ip}:${this.props.port}/cam/playback?channel=${this.props.channel}&subtype=${this.props.subtype}&starttime=${this.props.starttime}&endtime=${this.props.endtime}`
  }

  closePlayer() {
    if (this.player) {
      try {
        this.player.close()
      } catch (e) {
        console.warn('旧播放器关闭异常:', e)
      }
      this.player = null
    }
  }

  toggleMute() {
    this.canvasOperation.muteState = !this.canvasOperation.muteState
    // 如果要关闭声音,将 val 参数设置为 0 即可。WEB SDK 播放时,默认音量是 0。需要声音时,必须调用该方法,并且参数大于 0
    this.player.setAudioVolume(Number(this.canvasOperation.muteState))
  }

  toggleFullscreen() {
    const videoWrapper = this.$el.querySelector('.preview-pdf') as HTMLElement

    if (!document.fullscreenElement) {
      // 进入全屏
      if (videoWrapper.requestFullscreen) {
        videoWrapper.requestFullscreen()
      }
      this.canvasOperation.isFullscreen = true
    } else {
      // 退出全屏
      if (document.exitFullscreen) {
        document.exitFullscreen()
      }
      this.canvasOperation.isFullscreen = false
    }
  }
  //
  handlePlay() {
    if (!this.canvasOperation.playState) {
      this.player.play()
    } else {
      this.player.pause()
    }
    this.canvasOperation.playState = !this.canvasOperation.playState
  }

  handleCapture() {
    this.player.capture(new Date().getTime())
  }

  handleRefresh() {
    this.canvasOperation = this.initCanvasData()
    this.playerPlay()
  }
}
</script>
<style lang="scss" scoped>
.preview-pdf {
  height: 100%;
  width: 100%;

  position: relative;

  .operation {
    width: 100%;
    height: 40px;
    position: absolute;
    bottom: 0;
    right: 0;
    z-index: 1;
    background-color: rgb(0, 0, 0, 0.5);

    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    padding: 0 16px;
    .play {
      color: #fff;
      font-size: 16px;
    }
    .icon {
      font-size: 20px;
      color: #fff;
      cursor: pointer;
    }
    .control-btn {
      background-color: transparent;
      span {
        font-size: 18px;
      }
    }
  }
  .timestamp {
    color: #fff;
    flex-shrink: 0;

    .first-time,
    .total-time {
      flex-shrink: 0;
    }
    .number-input {
      width: 100px;
    }
  }
}

.prev-file-dialog {
  width: 100vw;
  height: 100vh;
  ::v-deep {
    .el-dialog.is-fullscreen {
      height: 100%;
    }
    .el-dialog {
      min-width: 80vw;
      height: calc(70vh);
    }
    .el-dialog__body {
      max-height: 100%;
      min-height: 0;
      height: 100%;
      overflow: hidden;
      display: flex;
      justify-content: center;
      align-items: center;
      padding: 12px !important;
      background: #fff;
    }
  }
}
</style>

实现效果:

image.png

不限于Vue!vue-plugin-hiprint 打印插件完整使用指南

image.png

前言

vue-plugin-hiprint是作者基于hiprint二次开发的打印插件,虽然带着vue,但是插件为单纯的JavaScript【工具库】。hiprint是未开源的打印插件,可以在官方地址查看部分api的用法和实例项目操作。

vue-plugin-hiprint相关教程可以查看,不简说 的个人主页 - 文章的主页-文章中,最初的文章里包含了教程和常见问题的解答,vue-plugin-hiprint-start项目中有具体的使用实例

本教程整理了自己的使用过程经验和接触到的教程文档,旨在为初次接触到vue-plugin-hiprint开发的人,提供初步的了解和自定义开发的方向

概述

首先对整个插件有一个大概了解,整个打印插件分为三个部分,组件元素面板、设计器和属性面板(此处截图使用的为vue-plugin-hiprint-start示例项目)

image.png

组件元素面板中,已经注册的元素可以直接拖拽的到设计器中,调节大小。可以按住ctrl键进行多选,但打印属性只会显示最后选择的元素。

以下是一个简单的完整代码示例,需要注意几点:1.元素面板的结构是完全自定义的,只需要保证每个元素tid正确和dom可以传递给构建函数 2.hiprint.PrintTemplate和design时,页面中已经渲染了DOM

<div class="flex-row">
  <div class="flex-2 left">
    <!-- 元素容器,每一个元素是单独的容器,以数组形式传递给buildByHtml -->
     <div class="ep-draggable-item item" tid="defaultModule.text">
        <i class="iconfont sv-text" />
        <span>文本</span>
     </div>
  </div>
  <div class="flex-5 center">
    <!-- 设计器的 容器 -->
    <div id="hiprint-printTemplate"></div>
  </div>
  <div class="flex-2 right">
    <!-- 元素属性的 容器 -->
    <div id="PrintElementOptionSetting"></div>
  </div>
</div>
import { hiprint, defaultElementTypeProvider } from 'vue-plugin-hiprint';

/* 左侧元素面板 */
hiprint.init({ providers: [defaultElementTypeProvider()] }); // providers是一个数组,接受组件元素,Provider提供的组件元素,defaultElementTypeProvider是内置Provider。
const items = $('.ep-draggable-item');
hiprint.PrintElementTypeManager.buildByHtml(items);

// 属性面板配置
hiprint.setConfig();
// 自定义配置
// hiprint.setConfig(defaultPanelConfig); 

/* 设计器 */
$("#hiprint-printTemplate").empty(); // 先清空, 避免重复构建
hiprintTemplate = new hiprint.PrintTemplate({
settingContainer: "#PrintElementOptionSetting", // 属性面板容器
});
hiprintTemplate.design("#hiprint-printTemplate");
// 打印与预览
hiprintTemplate.print()
hiprintTemplate.getHtml()

左侧组件元素面板构建

组件元素的构建分成两部分,一是provider,用来给hiprint提供组件元素;二是dom构建与绑定。

简单构建

内置组件Provider为hiprint提供元素

import { hiprint, defaultElementTypeProvider } from "vue-plugin-hiprint"; 
// defaultElementTypeProvider是内置的provider,其中的元素具有tid的属性(具体可以参考自定义provider),比如text的tid属性就是defaultModule.text,这与dom中的tid对应

hiprint.init({ providers: [defaultElementTypeProvider()] })

dom构建与绑定

<!-- DOM的构建是完全自定义,只要保证 1.能够获取到dom 2.dom上存在tid属性 -->
<!-- 
1.class="ep-draggable-item",使用该类名获取真实dom,在buildByHtml绑定
2.tid的值与defaultElementTypeProvider()中元素的tid值对应
-->
<div class="ep-draggable-item item" tid="defaultModule.text">
<span>文本</span>
</div>
hiprint.PrintElementTypeManager.buildByHtml($(".ep-draggable-item")); // 绑定页面中的dom。($(".ep-draggable-item"),jquery获取dom的写法)

内置元素类型

插件有以下内置类型
text 文本
image 图片
longText 长文本
table 表格
html 
hline 横线
vline 竖线
rect 矩形
oval 圆形
barcode 条形码
qrcode 二维码

defaultElementTypeProvider中的tid属性即为 defaultModule.属性类型。比如defaultModule.text

自定义provider

provider的结构为provider = { addElementTypes },所以只需要关注 addElementTypes 的实现

addElementTypes

const addElementTypes = (context: any) => { // context是hiprint调用时传入的参数
    context.removePrintElementTypes('providerModule'); // providerModule自定义元素模组名称
    context.addPrintElementTypes('providerModule', [ // 添加元素类型的方法
        new hiprint.PrintElementTypeGroup('', [ // 创建元素类型
            {
              tid: 'providerModule.text', // 需要与html上对应的dom上的自定义属性tid对应,即
              title: '文本', // 拖拽时出现的文本
              type: 'text', // 元素类型,此处的text为内置元素类型
              options: {}, // 定义打印设计器上元素的样式、名称等等,这些属性可以在右侧的属性面板上显示,进行在线编辑
            },
        ])
    ]) 
}

options

对于大多数的类型可以参考,hiprint的官方-中文文档-左侧菜单从文本到长文。有几个注意点

1.简单说明一下field和fields属性。两个属性填充的都是在预览(getHtml)和打印(print)的获取具体数据字段名,testData在设计的时候显示的测试数据

{
field: 'name', 
fields: [{field:'name' ,text:'姓名' },{ field: 'sex', text: '性别' }]
}
const printData = {
    name: "123",
    sex: "男",
    object: {
        name: '456'
    }
}
hiprint.print(printData)// 对于打印函数,详情看打印模块

1.field属性的值用来填写数据(printData)中的字段名。fields提供可选择的字段名,会将属性面板中对应的字段的输入框变成选择框。
2.print和getHtml可以接受单一数据和数据数组。print([printData])
3.两者可以接受属性访问的方式,field: 'object.name'

2.二维码与条纹码,会根据测试数据或者实际的数据对应生成,二维码在无数据的时候会生成失败。

3.更多的属性

官网的html类型页面是打不开的,或者其他元素有一些属性文档中没有写,可以在设计器中添加该元素然后在console里打印模板的实例,按照printPanels-printElements-_printElementOptionTabs路径,可以在此处查看对应的属性。

(ps:元素的属性值全是默认的情况时,元素上没有printElementOptionTabs)

image.png

4.图片

图片元素,在它的属性面板中有一个图片地址的属性,对应options中的src字段,这个属性是在设计时模板上显示的图片地址。在属性面板上有一个选择按钮,点击选择按钮调用的是挂载在模板实例上的onImageChooseClick按钮,其中的参数为target,target.refresh(src)可以更新这个属性;

5.HTML

html实际上使用的options里formatter,返回具体html结构

formatter: "function(t,e,printData){return'<div style=\"height:50pt;width:50pt;background:red;border-radius: 50%;\"></div>';}"

ps:

1.html类型在打印和预览时会有一些出入,比如打印时背景色会被忽略,需要自行调整

@media print {
    div {
        -webkit-print-color-adjust: exact;
        print-color-adjust: exact;
    }
}

2.html的formatter与图片的formatter参数相同,与其他元素类型的formatter有一些区别

设计器

基础

$("#hiprint-printTemplate").empty(); // 先清空, 避免重复构建
hiprintTemplate = new hiprint.PrintTemplate({
    template: template
settingContainer: "#PrintElementOptionSetting", // 属性面板容器
});
hiprintTemplate.design("#hiprint-printTemplate");

相关概念解释:

模板,模板是由hiprint.PrintTemplate创建的实例,即为页面所展示的包括刻度尺在内的设计器,官网hiprint模板中有相关初始化的参数

面板,一个模板中可以包含多个面板,可以理解为新的一页纸张,但是同一个模板下同时只能展示一个面板。官网hiprint.io面板中有面板的相关参数。页面中的元素是存储在面板中,hiprint.io的demo页面下有一个生成json到textarea按钮。

可以看到template实际上就是一个包含panels字段的json数据,而panels是一个数组。打印模板实例,在原型上可以看到操作面板的方法addPrintPanel、selectPanel、deletePanel

image.png

selectPanel(index) 接受的是面板的索引
addPrintPanel(options) 接受的面板参数
deletePanel(index) 接受的是面板的索引

我们可以通过hiprintTemplate.printPanels查看到当前的模版,然后通过上述函数管理面板。也可以通过数组来管理panel,然后通过整体重复构建的,避免使用不清楚的函数,当然这样重复构建的性能可能比较差。

多模版

页面可以同时展示多个模板,只需要多创建一个模板实例,示例参见下方代码。模板容器需要多个,但是属性面板容器可以复用一个,并且元素都是共享的。

  $("#hiprint-printTemplate").empty(); // 先清空, 避免重复构建
  hiprintTemplate = new hiprint.PrintTemplate({
    template: template, // 模板json(object)
    settingContainer: "#PrintElementOptionSetting", // 元素参数容器
  });
  // 构建 并填充到 容器中
  hiprintTemplate.design("#hiprint-printTemplate", { grid: true }); // 0.0.46版本新增, 是否显示网格
  // ------ 构建多个设计器 ------
  // eslint-disable-next-line no-undef
  $("#hiprint-printTemplate2").empty(); // 先清空, 避免重复构建
  hiprintTemplate2 = new hiprint.PrintTemplate({
    template: template2, // 模板json(object)
    settingContainer: "#PrintElementOptionSetting", // 元素参数容器
  });
  // 构建 并填充到 容器中
  hiprintTemplate2.design("#hiprint-printTemplate2");

API补充

官方的文档中只有一部分API的文档,对于其它API在使用的过程中,需要自己去查看实例、原型和对应的源码。可以在console中打印模板实例或者hiprint,找到对应的方法(比如selectPanel),在[[FunctionLocation]]找到对应的方法。

image.png

image.png

ps:如果源码没有格式化,在控制台底部有格式化的功能

举一个例子来说,setPaper设置纸张大小,setPaper(width, height)

// A4纸张大小
{
    width: 210,
    height: 297,
},

但是,setPaper方法只能设置当前的展示的面板的纸张大小,不会影响其它面板。查看它的源码发现它只调用了editPanel.resize,只改变了当前编辑的panel,所以如果需要统一修改可以获取hiprintTemplate.printPanels,对所有的panel执行resize方法。

部分API补充简述

名称 参数 说明
setElsAlign type: left|right|vertical|top|horizontal|
bottom|distributeHor|distributeVer
对齐函数,在面板上选中元素时,调用此函数可以进行对齐操作。
zoom number: float 放大缩小当前面板
selectPanel number: int 切换当前显示的面板,入参是面板在printPanels中的索引
update template 更新模板,入参为满足template格式的json
getSelectEls 返回选中的元素,按住ctrl可以多选

属性面板

属性面板的各种配置都是在setConfig函数完成,不传参数则会使用默认参数。config中有两种字段:

1.optionItems,可以理解为属性组件库,为属性配置对应样式和dom

2.元素类型字段,在此处配置该元素选中是属性面板的显示

hiprint.setConfig(config);

config = { // 除了optionItems外,其它的字段名都是模板上显示的各种元素的类型(包括面板),
    optionItems: [], // 详情见下
    text: {
        tabs: [ // 属性面板具体每一个tab下的属性,按照printElementOptionTabs中的顺序
            { options: [] },
            { options: [] },
            { options: [] },
            {
              name: '基础',
              replace: true, // 可以替换掉原来的标签
              options: [// 属性面板上每一项的显示,默认可以在printElementOptionTabs中查找,自定义的name与optionItems中对应
                { name: 'textType', hidden: true }, 
                { name: 'tableTextType', hidden: true },
                { name: 'barcodeMode', hidden: true },
                { name: 'barWidth', hidden: true },
                { name: 'barAutoWidth', hidden: true },
                { name: 'qrCodeLevel', hidden: true },
              ],
            },
        ],
    },
    panel: {// 控制panel的属性在属性面板的显示,supportOptions下配置
        supportOptions: [
            { name: 'firstPaperFooter', hidden: true }, 
            { name: 'evenPaperFooter', hidden: true },
            { name: 'oddPaperFooter', hidden: true },
            { name: 'lastPaperFooter', hidden: true },
            { name: 'panelLayoutOptions', hidden: true },
        ]
    }
}

optionItems,以name做区分与被引用。如果与内置的name相同,则会替换掉原来的属性组件;自定义的name可以在tabs的options中引入使用。

其内部通过class="auto-submit"绑定事件,执行getValue或者setValue。

// optionItems
export default (function () {
  function t() {
    this.name = 'paperNumberDisabled';
  }

  return (
    (t.prototype.createTarget = function (_t, i) { // i可以访问元素的options
      this.target = $(
        `<div class="hiprint-option-item">\n        <div class="hiprint-option-item-label">\n        显示页码\n        </div>\n        <div class="hiprint-option-item-field">\n        <select class="auto-submit">\n        <option value="" >显示</option>\n        <option value="true" >隐藏</option>\n        </select>\n        </div>\n    </div>`,
      )
      return this.target;
    }),
    (t.prototype.getValue = function () { // getValue在每次赋值属性面板的时候,会被调用
      if ('true' == this.target.find('select').val()) return !0;
    }),
    (t.prototype.setValue = function (t) { // setValue每次展示属性绑定的元素的属性面板时,会执行一次
      this.target.find('select').val((null == t ? '' : t).toString());
    }),
    (t.prototype.destroy = function () {
      this.target.remove();
    }),
    t
  );
})();

打印与预览

单模板

单模板可以直接通过模板实例调用打印函数。打印数据,可以传递单一数据hiprintTemplate.print(printData),也可以像示例一样传递数组

// 打印数据,key 对应 元素的 字段名
let printData = { name: "CcSimple", src: "/favicon.ico", object: { name: "对象字段值" } };
// 参数: 打印时设置 左偏移量,上偏移量
let options = { leftOffset: -1, topOffset: -1 };
// 扩展
let ext = {
    callback: () => {
      console.log("浏览器打印窗口已打开");
    },
    styleHandler: () => {
      // 重写 文本 打印样式
      return `
        <link rel="stylesheet" href="/print-lock.css" />
        <style>
           @media print {
              div {
                  -webkit-print-color-adjust: exact;
                  print-color-adjust: exact;
              }
          }
        </style>
      `;
    },
};
hiprintTemplate.print([printData], options, ext);

需要注意的是,打印的时候可能出现元素都重叠在第一页上,这个时候需要引入print-lock.css文件,这个文件可以在node_modules/vue-plugin-hiprint/dist中找到

多模板

hiprint也是可以实现多模板打印的,创建两个模板实例,对应绑定不同的容器即可(元素属性面板可以绑定同一个)。

hiprint.print({
    templates: [
      { template: hiprintTemplate, data: printData, options: { topOffset: 100 } },
      { template: hiprintTemplate2, data: [printData2, printData3] },
    ],
});

多模版引入print-lock.css文件,需要在html中静态引入。

其它注意点

1.使用浏览器打印的时候,可能会出现浏览器自动添加的页脚与页眉。1.这说明内容没有填满整张纸,使用@page { margin: 0cm; }可以不展示自动的页脚与页眉。 2.panel中的纸张大小与浏览器打印设置中的纸张大小不匹配,不传递panel纸张大小或者让两者匹配,即可消除掉。

ps:panel中的纸张大小会被渲染为 @page { size: width height },实际上就是size与打印设置纸张的匹配

2.关于其它打印时的样式问题,在实际调用一次print后,浏览器会渲染一个id为hiwprint_iframe的iframe元素,这就是实际被打印的页面。可以拷贝出来寻找样式问题。

总结

核心价值:

  • 🖨️ 可视化拖拽设计
  • 🎨 高度可定制,从元素到属性面板都能自由扩展
  • 🔧 多模板支持,应对复杂打印场景

使用建议:

  • 初次使用建议从内置元素开始,逐步深入自定义
  • 遇到样式问题优先检查 print-lock.css 和打印媒体查询
  • 当前文档和官网文档中的API应该足够满足大多数的需求,但是还是需要善用浏览器控制台探索未文档化的 API

👍创作不易,如有错误请指正,感谢观看!记得点个赞哦!👍

react-grid-layout 原理拆解:布局引擎、拖拽系统与响应式设计

react-grid-layout是 React 生态中一个非常流行的、用于构建可拖拽可调整大小响应式网格布局的库。它的强大之处在于用简洁的 API 实现了复杂的布局管理。

一、 布局、坐标

react-grid-layout的实现基石在于它将组件的实际屏幕位置抽象的网格位置彻底分离

1. 布局数组

不直接存储组件的像素位置,而是维护一个名为 layout 的 JavaScript 对象数组

每个元素(item)在布局数组中都是一个对象,包含以下关键属性:

属性 类型 描述
i String 元素的唯一 ID,对应于其 key 或子组件的 key
x Number 元素在网格中的起始坐标(Grid X)。
y Number 元素在网格中的起始坐标(Grid Y)。
w Number 元素的宽度,占用的网格列数
h Number 元素的高度,占用的网格行数
static Boolean 如果为 true,则元素不可拖拽和调整大小。

2. 坐标转换

核心逻辑在于将上述抽象的 (x, y, w, h) 网格坐标实时转换成浏览器能理解的 CSS 像素坐标

该转换依赖于两个配置项:

  • cols: 网格的总列数
  • rowHeight: 每行网格的高度(像素值)
  • margin: 网格项之间的间隔(像素值)

Item Width (px)=(w×Cell Width)+((w1)×Margin)\text{Item Width (px)} = (w \times \text{Cell Width}) + ((w-1) \times \text{Margin})

Cell Width=Container Width((Cols+1)×Margin)Cols\text{Cell Width} = \frac{\text{Container Width} - ((\text{Cols} + 1) \times \text{Margin})}{\text{Cols}}

Item Height (px)=(h×Row Height)+((h1)×Margin)\text{Item Height (px)} = (h \times \text{Row Height}) + ((h-1) \times \text{Margin})

利用这些公式计算每个网格项的 topleftwidthheight,并通过 CSS transform: translate(x, y) 来定位组件,而不是传统的 top/left,这能带来更好的性能。

二、 核心组件结构

主要由以下三个 React 组件构成:

1. ResponsiveReactGridLayout

这是最外层的容器组件。它负责处理响应式逻辑

  • 监听窗口大小变化(resize 事件)
  • 根据当前的容器宽度,确定应该加载哪个断点(Breakpoint) (例如:lg, md, sm 等)
  • 根据断点和其对应的 layout 配置,渲染 ReactGridLayout

2. ReactGridLayout

这是网格渲染的核心组件,它负责:

  • 计算和设置容器的总高度(min-height),以确保所有网格项都能被容纳。
  • layout 数组遍历,为每一个网格项渲染一个 GridItem
  • 管理拖拽和调整大小操作的状态(影子/占位符)。

3. GridItem

这是每个可拖拽/调整大小的网格项的容器

  • 渲染一个内部的 div 来包裹用户传入的子组件
  • 注入拖拽句柄调整大小句柄
  • 通过监听鼠标事件(onMouseDown/onMouseMove/onMouseUp)实现交互

三、 拖拽和调整大小原理

拖拽和调整大小依赖于两个库react-draggablereact-resizable

1. 占位符与状态管理

当用户开始拖拽或调整大小时,不会立即更新 layout 状态,而是通过一种“影子”机制来优化性能和用户体验

  • 占位符(Placeholder) : 一个半透明的、与当前操作网格项具有相同尺寸的元素会出现在其下方,指示操作完成后网格项将占据的位置
  • 操作过程: 在 onMouseMove 过程中,RGL 实时计算鼠标位置对应的新网格坐标 (new_x, new_y, new_w, new_h)\text{(new\_x, new\_y, new\_w, new\_h)},并更新占位符的位置
  • 操作结束: 只有在 onMouseUp 释放时,RGL 才会调用 onLayoutChange,将最终的网格坐标更新到父组件中

2. 网格冲突解决算法

  1. 冲突检测: 检测新位置 A’\text{A'} 是否与任何其他网格项 B\text{B} 发生矩形重叠。
  2. 向下推 : 如果发生冲突,会尝试将 B\text{B} 以及与 B\text{B} 冲突的其他网格项向下(增大 y\text{y} 坐标)推动,直到不再发生冲突
  3. 紧凑化 : 在每次布局变化后,可以执行 Compaction 算法。它会尝试将所有非静态的网格项向上(减小 y\text{y} 坐标)或向左(减小 x\text{x} 坐标)移动到可用的最高/最左位置,从而消除网格中的不必要空白

你的图标还在用 PNG?看完这篇你就会换成 iconfont

为什么使用 iconfont 图标?

前端工程师遇到图标的时候通常会有两个反应:一是“干脆像素党一样把 PNG 全都搞定吧”,二是“又要改图啦,心累”。iconfont 的出现,就是为了让我们少点心累、多点生产力(和悠闲)。

统一管理 —— 不要再到处找图了

设计稿里点缀的小图标很多时候看起来微不足道,但它们分散在项目各处时,管理起来比追剧还复杂。传统做法大概是:

  • 下载图标文件放目录,然后用 <img>background 引入;
  • 为了减少 HTTP 请求,把很多小图拼成一张雪碧图,用 background-position 精确定位显示。

这两种方法都能解决问题,但每次换一个 icon 就像玩拼图:用 <img> 时不敢随便替换(怕别处也在用);用雪碧图则得重新切图、改定位,麻烦得要命。

用 iconfont 的好处是把图标交给“图标管理平台”去维护:你只需把需要的 icon 加到项目里,更新图标只要改 class 或 symbol,不用把整个雪碧图砸了重做。

矢量图 —— 放大也不拖泥带水

iconfont 本质上是矢量(font 或 svg),可以无限放大而不失真。相比之下,PNG/JPG 为了保证清晰度常常要切出更高分辨率的图,文件体积就会膨胀得像发霉的面包。

与传统图片的优点(简明版)

  • 易于内联:可以像文字一样放在行内,与按钮、文字自然对齐;
  • 强缓存策略:字体文件带 fingerprint(哈希),更新后能强制刷新,避免缓存坑;
  • 易于样式化:用 CSS 控制 colorfont-sizetext-shadow 等,hover、active 轻松搞定;
  • 减少请求次数:比起成百上千的图像请求,字体/符号只需几次请求。

使用方式(摘自官网并做了点小魔改)

下面三种常见引用方式各有侧重——选哪个,看你的兼容需求和图标需求(单色/多色)。

Unicode 引用(兼容性最强)

特点:兼容 IE6+ 和现代浏览器,像字体一样使用,大小颜色好调整,但类名语义不明显(直接写编码不太直观)。

步骤:

  1. 拷贝项目生成的 @font-face
 @font-face {
   font-family: 'iconfont';
   src: url('iconfont.eot');
   src: url('iconfont.eot?#iefix') format('embedded-opentype'),
        url('iconfont.woff') format('woff'),
        url('iconfont.ttf') format('truetype'),
        url('iconfont.svg#iconfont') format('svg');
 }
  1. 定义基础样式:
 .iconfont {
   font-family: "iconfont" !important;
   font-size: 16px;
   font-style: normal;
   -webkit-font-smoothing: antialiased;
   -webkit-text-stroke-width: 0.2px;
   -moz-osx-font-smoothing: grayscale;
 }
  1. 在页面使用(示例):
 <i class="iconfont">&#x33;</i>

说明:新版 iconfont 支持彩色字体图标,但只在现代浏览器里生效。

Font-class 引用(语义清晰)

特点:用类名表示某个图标,语义更直观,兼容 IE8+,本质仍是字体(单色为主)。

步骤:

  1. 引入平台给你的 CSS 链接,例如:
 <link rel="stylesheet" href="//at.alicdn.com/t/font_8d5l8fzk5b87iudi.css">
  1. 使用类名:
 <i class="iconfont icon-xxx"></i>

好处:当需要替换图标时,只要改类名对应的 unicode 即可,无需在 HTML 里改编码。

Symbol(SVG Symbol)引用(推荐,功能最强)

特点:支持多色、兼容性 IE9+,更灵活,是未来主流。平台推荐此方式。但注意:SVG 渲染在某些老设备上性能可能略逊于纯字体。

步骤:

  1. 在入口引入 iconfont.js(一次引入即可):
 <script src="//at.alicdn.com/t/font_8d5l8fzk5b87iudi.js"></script>
  1. 全局加一段基础样式:
 <style>
 .icon {
   width: 1em;
   height: 1em;
   vertical-align: -0.15em;
   fill: currentColor;
   overflow: hidden;
 }
 </style>
  1. 使用方式:
 <svg class="icon" aria-hidden="true">
   <use xlink:href="#icon-xxx"></use>
 </svg>

实践建议(我的两分钱)

  • 如果项目没有很苛刻的老浏览器兼容要求,优先使用 Symbol(SVG)方式。简单、支持多色,样式灵活。
  • 入口只需引入一次 iconfont.js;在需要“平铺图标供选择”的场景下,可以用 DOM API 把所有 symbol 收集成列表供展示或入库。
  • 上传图标前请遵守 iconfont 上传规范(平台有文档)。上传后建议手动微调图标在画布的位置和大小,保证通过 font-size 缩放后显示正常。
  • 绝大多数图标是单色的。如果上传时不去色,使用 color 修改颜色可能会失效 —— 所以管理页面上做去色操作很重要。

示例:从 iconfont.js 里取出所有 symbol 并生成可选列表

 const symbols = Array.from(document.querySelectorAll("svg symbol"));
 const svgList = symbols.map(s => ({
   id: s.id,
   viewBox: s.getAttribute("viewBox"),
   paths: Array.from(s.querySelectorAll("path")).map(p => p.getAttribute("d")),
 }));
 const icons = svgList.map(item => item.id.replace(/^icon-/, ""));
 export default icons;

小结(结尾彩蛋)

用 iconfont 就像把全站小图标放进一个“图标超市”——找货更快、换货更方便、打折时也不会把整个货架砸烂。除非你执着于每个图都用 PNG 手工打磨(并且乐于每次改图都掉头发),否则给 iconfont 一个机会,它会让你在图标问题上少挠头、多喝咖啡。

React 性能优化误区:结合实战代码,彻底搞懂 useCallback 的真正用途

在 React 开发中,useCallback 是最容易被误用(Overused)的 Hook 之一。很多开发者看到组件重渲染(Re-render),下意识地就想把所有函数都包上一层 useCallback,认为这样能提升性能。

但事实往往相反:在错误的地方使用 useCallback,不仅不能优化性能,反而会增加内存开销和代码复杂度。

今天我们结合 Hacker News 搜索代码,来拆解 useCallback 到底解决了什么问题,以及什么时候才应该用它。


1. 案发现场:代码真的需要优化吗?

让我们先看代码中的这一部分:

// App.js (原始代码)
export default function App() {
  const [searchTerm, setSearchTerm] = React.useState("js");

  // ❌ 疑问:这里是否需要 useCallback?
  const handleChange = (e) => {
    setSearchTerm(e.target.value);
  };

  return (
    <form>
      {/* 这里的 input 是原生 DOM 标签 */}
      <input onChange={handleChange} ... />
    </form>
  );
}

现状分析:

  1. 当用户输入字符,handleChange 执行 -> setSearchTerm 更新状态。
  2. App 组件触发重渲染(Re-render)。
  3. 在这次新的渲染中,handleChange 函数被重新创建(在内存中生成了一个全新的函数引用)。
  4. 这个新函数被传递给 <input> 标签。

结论:

在你的当前代码中,完全不需要 useCallback。

原因:

接收 handleChange 的是 <input>,这是一个原生 DOM 元素。原生元素不具备“通过对比 Props 来决定是否更新”的能力。无论你传给它的是旧函数还是新函数,只要父组件渲染,React 都会重新把事件绑定更新一遍。

在这里加 useCallback,就像是给一次性纸杯买保险——成本(缓存机制、依赖对比的计算量)支出了,但没有任何收益。


2. 核心概念:引用相等性 (Referential Equality)

要理解 useCallback,必须理解 JavaScript 中的一个基础概念:

const functionA = () => { console.log('hi'); };
const functionB = () => { console.log('hi'); };

console.log(functionA === functionB); // false ❌
console.log(functionA === functionA); // true ✅

在 React 函数组件中,每次渲染,组件内部定义的函数都会被重新创建。虽然代码逻辑没变,但在计算机内存里,它已经是一个全新的对象了。

useCallback 的唯一作用就是:在多次渲染之间,强行保留同一个函数引用,只要依赖项不变,它返回的永远是内存里的同一个地址。


3. 什么时候才需要它?(引入 React.memo)

只有当这个函数被传递给经过优化的子组件时,useCallback 才是必须的。

假设随着项目变大,你把 <input> 封装成了一个独立的、功能复杂的组件 FancyInput,并且为了性能,你使用了 React.memo

场景 A:有 memo,但没用 useCallback (无效优化)

// 这是一个被 memo 保护的组件
// 它的原则是:只有 props 变了,我才重新渲染
const FancyInput = React.memo(function FancyInput({ onChange, value }) {
  console.log("FancyInput 渲染了!"); 
  return <input className="fancy" onChange={onChange} value={value} />;
});

export default function App() {
  const [searchTerm, setSearchTerm] = React.useState("js");
  
  // 每次 App 渲染,这里都会生成一个新的函数地址
  const handleChange = (e) => setSearchTerm(e.target.value); 

  return (
    <>
       {/* 悲剧发生在这里:
         尽管 searchTerm 没变 (假设是其他 state 触发了 App 更新),
         但因为 handleChange 的内存地址变了,
         React.memo 认为 props.onChange 变了。
         结果:FancyInput 依然会强制重渲染!
       */}
      <FancyInput onChange={handleChange} value={searchTerm} />
    </>
  );
}

场景 B:memo + useCallback (黄金搭档)

这时候,useCallback 就要登场了。它是为了配合 React.memo 工作的。

export default function App() {
  const [searchTerm, setSearchTerm] = React.useState("js");

  // ✅ 正确使用:缓存函数引用
  const handleChange = React.useCallback((e) => {
    setSearchTerm(e.target.value);
  }, []); // 依赖项为空,永远不重建

  return (
    {/* 现在,当 App 因为其他原因重渲染时,
      handleChange 还是原来的内存地址。
      React.memo 发现 props 没变,于是跳过 FancyInput 的渲染。
      性能提升达成!
    */}
    <FancyInput onChange={handleChange} value={searchTerm} />
  );
}

4. 另一个场景:作为 useEffect 的依赖

代码中其实有一个潜在的地方可能需要 useCallback,那就是当函数本身被放在 useEffect 的依赖数组里时。

// 假设这是定义在组件内的函数
const fetchNews = async (query) => {
  const data = await searchHackerNews(query);
  setResults(data.hits);
};

useEffect(() => {
  fetchNews(debouncedSearchTerm);
}, [debouncedSearchTerm, fetchNews]); // ⚠️ fetchNews 是依赖项

如果fetchNews 不包裹 useCallback,每次渲染 fetchNews 都会变成新函数,导致 useEffect 认为依赖变了,从而无限循环或者不必要的频繁执行

在这种情况下,必须使用 useCallback 锁住 fetchNews


总结:决策清单

回到代码,请按照这个清单来决定是否使用 useCallback

  1. 这个函数是传给原生 DOM (div, button, input) 的吗?

    • 是 -> 不用 (用了也没用)。
    • 否 -> 看下一条。
  2. 这个函数是传给子组件的,且子组件用了 React.memo 吗?

    • 是 -> (为了让 memo 生效)。
    • 否 -> 不用 (大部分子组件都很轻量,不需要 memo)。
  3. 这个函数会被作为 useEffect 或其他 Hook 的依赖项吗?

    • 是 -> (防止死循环或频繁触发 Effect)。

最终建议:

在App 组件当前的状态下,保持原样是最好的选择。代码清晰、逻辑简单,没有任何不必要的性能开销。

Vue3+TS设计模式实战:5个场景让代码优雅翻倍

在Vue3+TypeScript开发中,写“能跑的代码”很容易,但写“优雅、可维护、可扩展”的代码却需要思考。设计模式不是银弹,但合理运用能帮我们解决重复出现的问题,让代码结构更清晰、逻辑更健壮。

本文结合5个真实业务场景,讲解单例模式、工厂模式、观察者模式、策略模式、组合模式在Vue3+TS中的实践,每个场景都附完整代码示例和优化思路。

场景1:全局状态管理 - 单例模式

场景痛点

项目中需要全局状态管理(如用户信息、主题配置),如果多次创建状态实例,会导致状态不一致,且浪费资源。

设计模式应用:单例模式

单例模式确保一个类只有一个实例,并提供一个全局访问点。Vue3的Pinia本质就是单例模式的实现,但我们可以自定义更灵活的单例逻辑。

代码实现


// stores/singletonUserStore.ts
import { reactive, toRefs } from 'vue'

// 定义用户状态接口
interface UserState {
  name: string
  token: string
  isLogin: boolean
}

class UserStore {
  private static instance: UserStore
  private state: UserState

  // 私有构造函数,防止外部new
  private constructor() {
    this.state = reactive({
      name: '',
      token: localStorage.getItem('token') || '',
      isLogin: !!localStorage.getItem('token')
    })
  }

  // 全局访问点
  public static getInstance(): UserStore {
    if (!UserStore.instance) {
      UserStore.instance = new UserStore()
    }
    return UserStore.instance
  }

  // 业务方法
  public login(token: string, name: string) {
    this.state.token = token
    this.state.name = name
    this.state.isLogin = true
    localStorage.setItem('token', token)
  }

  public logout() {
    this.state.token = ''
    this.state.name = ''
    this.state.isLogin = false
    localStorage.removeItem('token')
  }

  // 暴露响应式状态
  public getState() {
    return toRefs(this.state)
  }
}

// 导出单例实例
export const userStore = UserStore.getInstance()

优雅之处

  • 全局唯一实例,避免状态冲突

  • 封装性强,状态修改只能通过实例方法,避免直接篡改

  • 结合TS接口,类型提示完整,减少类型错误

场景2:动态组件渲染 - 工厂模式

场景痛点

表单页面需要根据不同字段类型(输入框、下拉框、日期选择器)渲染不同组件,如果用if-else判断,代码会臃肿且难以维护。

设计模式应用:工厂模式

工厂模式定义一个创建对象的接口,让子类决定实例化哪个类。在Vue中,我们可以创建“组件工厂”,根据类型动态返回对应组件。

代码实现


<template>
  <div class="form-container">
    <component 
      v-for="field in fields" 
      :key="field.id"
      :is="getFormComponent(field.type)"
      v-model="formData[field.key]"
      :label="field.label"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import InputComponent from './components/InputComponent.vue'
import SelectComponent from './components/SelectComponent.vue'
import DatePickerComponent from './components/DatePickerComponent.vue'

// 定义字段类型
type FieldType = 'input' | 'select' | 'date'

interface Field {
  id: string
  key: string
  label: string
  type: FieldType
  options?: { label: string; value: string }[]
}

// 组件工厂:根据类型返回组件
const getFormComponent = (type: FieldType) => {
  switch (type) {
    case 'input':
      return InputComponent
    case 'select':
      return SelectComponent
    case 'date':
      return DatePickerComponent
    default:
      throw new Error(`不支持的字段类型:${type}`)
  }
}

// 表单数据和字段配置
const formData = ref({
  username: '',
  gender: '',
  birthday: ''
})

const fields: Field[] = [
  { id: '1', key: 'username', label: '用户名', type: 'input' },
  { 
    id: '2', 
    key: 'gender', 
    label: '性别', 
    type: 'select',
    options: [{ label: '男', value: 'male' }, { label: '女', value: 'female' }]
  },
  { id: '3', key: 'birthday', label: '生日', type: 'date' }
]
</script>

优雅之处

  • 消除大量if-else,代码结构清晰

  • 新增组件类型只需修改工厂函数,符合开闭原则

  • 字段配置与组件渲染分离,便于维护

场景3:跨组件通信 - 观察者模式

场景痛点

非父子组件(如Header和Footer)需要通信(如主题切换),用Props/Emits太繁琐,用Pinia又没必要(仅单一事件通信)。

设计模式应用:观察者模式

观察者模式定义对象间的一对多依赖关系,当一个对象状态改变时,所有依赖它的对象都会收到通知。我们可以实现一个简单的事件总线。

代码实现


// utils/eventBus.ts
class EventBus {
  // 存储事件订阅者
  private events: Record<string, ((...args: any[]) => void)[]> = {}

  // 订阅事件
  on(eventName: string, callback: (...args: any[]) => void) {
    if (!this.events[eventName]) {
      this.events[eventName] = []
    }
    this.events[eventName].push(callback)
  }

  // 发布事件
  emit(eventName: string, ...args: any[]) {
    if (this.events[eventName]) {
      this.events[eventName].forEach(callback => callback(...args))
    }
  }

  // 取消订阅
  off(eventName: string, callback?: (...args: any[]) => void) {
    if (!this.events[eventName]) return

    if (callback) {
      this.events[eventName] = this.events[eventName].filter(cb => cb !== callback)
    } else {
      delete this.events[eventName]
    }
  }
}

// 导出单例事件总线
export const eventBus = new EventBus()

使用示例:


<!-- Header.vue -->
<script setup lang="ts">
import { eventBus } from '@/utils/eventBus'

const toggleTheme = () => {
  // 发布主题切换事件
  eventBus.emit('theme-change', 'dark')
}
</script>

<!-- Footer.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { eventBus } from '@/utils/eventBus'

const theme = ref('light')

const handleThemeChange = (newTheme: string) => {
  theme.value = newTheme
}

onMounted(() => {
  // 订阅主题切换事件
  eventBus.on('theme-change', handleThemeChange)
})

onUnmounted(() => {
  // 取消订阅,避免内存泄漏
  eventBus.off('theme-change', handleThemeChange)
})
</script>

优雅之处

  • 解耦组件,无需关注组件层级关系

  • 轻量级通信,比Pinia更适合简单场景

  • 支持订阅/取消订阅,避免内存泄漏

场景4:表单验证 - 策略模式

场景痛点

表单需要多种验证规则(必填、邮箱格式、密码强度),如果把验证逻辑写在一起,代码会混乱且难以复用。

设计模式应用:策略模式

策略模式定义一系列算法,把它们封装起来,并且使它们可相互替换。我们可以将不同验证规则封装为“策略”,动态选择使用。

代码实现


// utils/validator.ts
// 定义验证规则接口
interface ValidationRule {
  validate: (value: string) => boolean
  message: string
}

// 验证策略集合
const validationStrategies: Record<string, ValidationRule> = {
  // 必填验证
  required: {
    validate: (value) => value.trim() !== '',
    message: '此字段不能为空'
  },
  // 邮箱验证
  email: {
    validate: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
    message: '请输入正确的邮箱格式'
  },
  // 密码强度验证(至少8位,含字母和数字)
  password: {
    validate: (value) => /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/.test(value),
    message: '密码至少8位,包含字母和数字'
  }
}

// 验证器类
class Validator {
  private rules: Record<string, string[]> = {} // { field: [rule1, rule2] }

  // 添加验证规则
  addField(field: string, rules: string[]) {
    this.rules[field] = rules
  }

  // 执行验证
  validate(formData: Record<string, string>): Record<string, string> {
    const errors: Record<string, string> = {}

    Object.entries(this.rules).forEach(([field, rules]) => {
      const value = formData[field]
      for (const rule of rules) {
        const strategy = validationStrategies[rule]
        if (!strategy.validate(value)) {
          errors[field] = strategy.message
          break // 只要有一个规则不通过,就停止该字段验证
        }
      }
    })

    return errors
  }
}

export { Validator }

使用示例:


<script setup lang="ts">
import { ref } from 'vue'
import { Validator } from '@/utils/validator'

const formData = ref({
  email: '',
  password: ''
})

const errors = ref<Record<string, string>>({})

const handleSubmit = () => {
  // 创建验证器实例
  const validator = new Validator()
  // 添加验证规则
  validator.addField('email', ['required', 'email'])
  validator.addField('password', ['required', 'password'])
  // 执行验证
  const validateErrors = validator.validate(formData.value)
  
  if (Object.keys(validateErrors).length === 0) {
    // 验证通过,提交表单
    console.log('提交成功', formData.value)
  } else {
    errors.value = validateErrors
  }
}
</script>

优雅之处

  • 验证规则与业务逻辑分离,可复用性强

  • 新增规则只需扩展策略集合,符合开闭原则

  • 验证逻辑清晰,便于维护和测试

场景5:树形结构组件 - 组合模式

场景痛点

开发权限菜单、文件目录等树形组件时,需要处理单个节点和子节点的统一操作(如展开/折叠、勾选),递归逻辑复杂。

设计模式应用:组合模式

组合模式将对象组合成树形结构以表示“部分-整体”的层次结构,使得用户对单个对象和组合对象的使用具有一致性。

代码实现


// utils/treeNode.ts
// 定义节点接口
interface TreeNodeProps {
  id: string
  label: string
  children?: TreeNodeProps[]
  expanded?: boolean
  checked?: boolean
}

class TreeNode {
  public id: string
  public label: string
  public children: TreeNode[] = []
  public expanded: boolean
  public checked: boolean

  constructor(props: TreeNodeProps) {
    this.id = props.id
    this.label = props.label
    this.expanded = props.expanded ?? false
    this.checked = props.checked ?? false
    // 递归创建子节点
    if (props.children) {
      this.children = props.children.map(child => new TreeNode(child))
    }
  }

  // 展开/折叠节点
  toggleExpand() {
    this.expanded = !this.expanded
  }

  // 勾选节点(并联动子节点)
  toggleCheck() {
    this.checked = !this.checked
    this.children.forEach(child => {
      child.setChecked(this.checked)
    })
  }

  // 设置节点勾选状态
  setChecked(checked: boolean) {
    this.checked = checked
    this.children.forEach(child => {
      child.setChecked(checked)
    })
  }

  // 获取所有勾选的节点ID
  getCheckedIds(): string[] {
    const checkedIds: string[] = []
    if (this.checked) {
      checkedIds.push(this.id)
    }
    this.children.forEach(child => {
      checkedIds.push(...child.getCheckedIds())
    })
    return checkedIds
  }
}

export { TreeNode }

使用示例:


<template>
  <ul class="tree-list">
    <tree-node-item :node="treeRoot" />
  </ul>
</template>

<script setup lang="ts">
import { TreeNode } from '@/utils/treeNode'
import TreeNodeItem from './TreeNodeItem.vue'

// 初始化树形数据
const treeData = {
  id: 'root',
  label: '权限菜单',
  children: [
    {
      id: '1',
      label: '用户管理',
      children: [
        { id: '1-1', label: '查看用户' },
        { id: '1-2', label: '编辑用户' }
      ]
    },
    { id: '2', label: '角色管理' }
  ]
}

const treeRoot = new TreeNode(treeData)
</script>

<!-- TreeNodeItem.vue 递归组件 -->
<template>
  <li class="tree-node">
    <div @click="node.toggleExpand()" class="node-label">
      <span v-if="node.children.length">{{ node.expanded ? '▼' : '►' }}</span>
      <input type="checkbox" :checked="node.checked" @change="node.toggleCheck()">
      {{ node.label }}
    </div>
    <ul v-if="node.expanded && node.children.length" class="tree-children">
      <tree-node-item v-for="child in node.children" :key="child.id" :node="child" />
    </ul>
  </li>
</template>

<script setup lang="ts">
import { defineProps } from 'vue'
import { TreeNode } from '@/utils/treeNode'

defineProps<{
  node: TreeNode
}>()
</script>

优雅之处

  • 统一处理单个节点和子节点,无需区分“部分”和“整体”

  • 递归逻辑封装在TreeNode类中,组件只负责渲染

  • 树形操作(勾选、展开)职责单一,便于扩展

总结

设计模式不是“炫技”,而是解决问题的“方法论”。在Vue3+TS开发中:

  • 单例模式适合全局状态、工具类等唯一实例场景

  • 工厂模式适合动态创建组件、服务等场景

  • 观察者模式适合跨组件通信、事件监听场景

  • 策略模式适合表单验证、算法切换等场景

  • 组合模式适合树形结构、层级数据场景

合理运用这些模式,能让你的代码更优雅、更可维护。当然,设计模式也不是万能的,要根据实际业务场景选择合适的方案,避免过度设计。

揭秘 JS 继承的 “戏精家族” :原型、原型链与 new

前言

各位 前端er 朋友们,要搞懂 JS 的面向对象和继承逻辑,绕不开 原型(prototype)、隐式原型(proto)、原型链 这三个核心概念,而 new 关键字 正是串联起它们、实现实例创建的关键 “桥梁”。看到这里就已经觉得很绕了吧,没错。But!这些概念看似抽象,实则是 JS 引擎优化属性复用、实现继承的底层设计——就像家族里各有分工的成员,各司其职又紧密配合,才撑起了 JS 继承的 “大戏”。

一、函数的 “天赋”——prototype(显示原型)

咱先说说prototype,它就像函数天生带的 “天赋”,每个函数一出生就自带这个对象属性。你可以把它理解成一个 “公共仓库”,往里面放的属性和方法,所有通过这个函数创建的实例都能 “共享”

举个例子:

Array.prototype.abc = function(){
    return 'abc';
}
const arr = [];
console.log(arr.abc());

咱们给Arrayprototype(数组的 “公共仓库”)加了个abc方法,然后创建一个空数组arr,它就能直接调用这个方法。这就是因为实例对象的隐式原型和构造函数的显示原型是相通的,也就是说:

实例对象的隐式原型 == 构造函数的显示原型

结果也肯定了我们的结论,输出abc:

image.png

二、对象的 “隐形翅膀”—— proto(隐式原型)

每个对象(注意是所有对象,包括函数创建的实例)都有个__proto__,它就像 “隐形翅膀”,悄悄连接着自己的 “原型长辈”。V8 引擎在找属性的时候,是个 “势利眼”—— 先找对象自己的显示属性,找不到就顺着__proto__(隐式原型)去 “原型长辈” 那里扒拉。其实也很容易理解,我们找东西肯定先找放在桌子上看得见的,再去桌子的抽屉里面找。

咱还是拿su7举例子:

Car.prototype.name = 'su7-Ultra';
function Car(color){
    this.color = color; 
}
const car1 = new Car('pink');
console.log(car1.name); 

car1自己只有color属性,但它能通过__proto__找到Car.prototype里的name。这就是因为实例对象的__proto__ === 构造函数的 prototype,相当于car1.__proto__直接指向了Car.prototype,所以能拿到里面的name~

输出结果:

image.png

成功输出了我们的su7-Ultra

三、“造人机器” new 关键字的骚操作

new是啥,它干了啥呢?new关键字就像个 “造人机器”,它创建实例的过程堪称 “步骤大师”,咱们拆解一下:

  1. 创建空对象:先造一个 “空壳子” 对象,比如new Car()时,先弄一个{}
  2. 绑定 this:把构造函数里的this指向这个空对象,相当于告诉构造函数:“接下来给这个空壳子塞东西哈!”
  3. 执行构造函数代码:比如Car里的this.color = color,就是往空对象里加属性。
  4. 连接原型:把空对象的__proto__直接赋值为构造函数的prototype,让实例和 “公共仓库” 打通。
  5. 返回对象:最后把这个 “装修好” 的对象返回出去。

上代码更清晰:

function Car(){ 
    // const obj = {};  // 步骤1:创建空对象
    this.name = 'su7';  // 步骤2,3
    // obj.__proto__ = Car.prototype; // 步骤4:连接原型
    // return obj; // 步骤5:返回对象
}
const car = new Car();
console.log(car.constructor); // 能找到构造函数Car,因为原型链连起来了

结果也在我们意料之中:

image.png

这么一拆解,是不是觉得new其实就是个 “流水线包工头”,把创建对象的步骤安排得明明白白~

四、原型链:JavaScript 的 “族谱”

原型都搞定了,那原型链也就是顾名思义了。原型链就是把这些__proto__prototype串起来的 “族谱”。V8 找属性时,会沿着这个 “族谱” 往上扒,直到扒到null(族谱的 “老祖宗”,再往上没了)为止。

看这段 “祖孙三代” 的代码:

Grand.prototype.house = function(){
    console.log('四合院');
}
function Grand() {
    this.card = 10000;
}
Parent.prototype = new Grand(); // {card: 10000}.__proto__ = Grand.prototype.__proto__ = Object.prototype.__proto__ = null
function Parent() {
    this.lastName = 'harvest';
}
Child.prototype = new Parent(); // {lastName = 'harvest'}.__proto__ = arent.prototype
function Child() {
    this.age = 18;
}
const c = new Child(); // {age: 18}.__proto__ = Child.prototype
console.log(c.card);
c.house();
console.log(c.toString());

觉得很长很乱吧,没关系一起来,看看 “孙子c” 怎么凭着族谱 “蹭” 祖上的东西:

1. console.log(c.card); —— 输出:10000

咱一步步看 “认祖归宗” 的过程:

  • 先翻 c 自己的口袋:只有age:18,没card,掏族谱!
  • 顺着c.__proto__找爸爸的仓库(Child.prototype,也就是new Parent()的实例):爸爸的仓库里只有lastName: 'harvest',还没card,继续往上找!
  • 再顺着爸爸仓库的__proto__Parent.prototype.__proto__)找爷爷的仓库(Grand.prototype):爷爷的仓库里有card:10000(爷爷构造函数里的专属属性),找到了!
  • 所以直接输出爷爷给的 “启动资金” 10000—— 这就是 “戏精家族” 的传承,孙子能蹭到爷爷的银行卡💳!

2. c.house(); —— 输出:四合院

同样按族谱寻亲:

  • 先翻 c 自己的口袋:没house方法,掏族谱!
  • 找爸爸的仓库:只有lastName,没有house,继续往上!
  • 找爷爷的仓库(Grand.prototype):嘿,爷爷的祖传仓库里正好有house方法,直接调用!
  • 所以执行后输出 “四合院”—— 相当于孙子凭着族谱,直接用了爷爷的 “祖传房产” 技能!

3. console.log(c.toString()); —— 输出:[object Object]

这波是 “蹭到了家族的老老祖宗”(Object)的好处:

  • 先翻 c 自己的口袋:没toString方法,掏族谱!
  • 找爸爸仓库:没有,找爷爷仓库:也没有(爷爷只给了cardhouse),继续往上!
  • 顺着爷爷仓库的__proto__Grand.prototype.__proto__)找 “老老祖宗”Object的仓库(Object.prototype):这里藏着 JavaScript 所有对象都能共用的toString方法!
  • 调用后就输出默认格式[object Object]—— 相当于 “戏精家族” 的族谱最顶端,还连着所有对象的 “公共老祖宗”,好处能蹭到最上头!

输出结果和我们分析的一模一样:

image.png

OK,下课!

总结:“戏精家族” 的传承逻辑

  • prototype是每个 “家族长辈”(函数)的 “祖传仓库”,共享属性方法全在这;
  • __proto__是每个 “家族成员”(对象)的 “隐形族谱”,负责连接上一辈的仓库;
  • new是 “家族造人师”,不仅造新成员,还得给它上 “家族户口”(连族谱);
  • 原型链是 “完整族谱”,属性查找全靠它 “代代往上蹭”,直到蹭到null为止。

“戏精家族” 的传承逻辑,本质就是 JavaScript 的继承核心 —— 不用重复造轮子,晚辈凭着族谱就能共享祖上的 “资源”,既省空间又高效。每个属性和方法的查找,都是一场有趣的 “家族寻亲记”。

开启一场“寻亲之旅”吧!

[译]发布 Angular v21

1_xcM4SYWsG_hMHnDJABz8dw.png

g 对于开发者来说,这是一个多么令人兴奋的时代!随着 Web 开发中 AI 方面的所有激动人心的发展,感觉我们每天都在开启新的冒险。这与我们 v21 发布活动的主题完美契合,该活动提供了对 Angular v21 最佳功能的概述。

随着 v21 的发布,Angular 成为了您日常冒险的更强大伙伴——为您提供 Angular 框架的稳定性,同时使您能够构建可扩展且适合每个人的强大 AI 驱动应用程序。

Angular v21 为您提供了许多期待已久的工具,以丰富您的工具箱,并确保您拥有最佳的开发者体验,无论是使用代理和 AI 辅助进行编码,还是更喜欢仅与您的 IDE 一起编写、调试和测试代码。

亮点包括:

  • 我们正在推出实验性的 Signal Forms,提供一种基于 Signals 的新型可扩展、可组合和响应式表单体验。
  • Angular Aria 正式进入开发者预览版,为您提供以可访问性为优先考虑的 headless 组件,您可以自由进行样式定制。
  • 您的 AI 代理可以使用 Angular 的 MCP 服务器,现在包含七个稳定和实验性工具——使 LLMs 能够从第一天起就使用新的 Angular 功能。
  • Angular CLI 已将 Vitest 集成作为新的默认测试运行器。Vitest 支持现已稳定并可用于生产。
  • 新的 Angular 应用程序不再默认包含 zone.js

还有更多精彩内容——让我们深入探索!

实验性 Signal Forms 已上线

我们自豪地宣布,现在您可以试用 Signal Forms,这是一个实验性库,它基于 Signals 的响应式基础,让您能够管理表单状态!

使用 Signal Forms,表单模型由一个 Signal 定义,该 Signal 会自动与绑定到它的表单字段同步,从而提供一种符合人体工程学的开发者体验,并确保访问表单字段时的完全类型安全。

强大的、基于模式的集中式验证逻辑已内置 🎉

开始使用,创建一个表单模型并将其传递给 form() :

import { form, Field } from '@angular/forms/signals';

@Component({
  imports: [Field],
  template: `
    Email: <input [field]="loginForm.email">
    Password: <input [field]="loginForm.password">
  `
})
export class LoginForm {
  login = signal({
    email: '',
    password: ''
  });
  
  loginForm = form(this.login);
}

现在您可以使用 [field] 指令将字段绑定到模板。电子邮件验证或正则表达式匹配等典型验证模式已经内置,自定义验证器让您能够创建更强大的验证机制。

绑定到自定义组件是基于 Signals 的,比以往任何时候都更容易,不再需要 ControlValueAccessor

开始使用,请查看必备的 Signal Forms 指南完整文档

我们很高兴您能开始使用 Signal Forms 进行构建。Signal Forms API 目前仍处于实验阶段,我们将根据反馈进行迭代。请尝试使用并告诉我们您的想法。

可访问组件 — 使用 Angular Aria 打造您的专属风格

我们激动地宣布,我们正在发布我们新的现代库的开发者预览版,该库用于常见的 UI 模式。我们为 Angular Aria 将可访问性作为首要任务。为了开始,您将获得一套包含 8 种 UI 模式、涵盖 13 个组件的集合,这些组件完全未进行样式设置,并且可以自定义您的样式。

Angular Aria 使用现代 Angular 指令,基于 Signals,并建立在我们在构建响应式可访问组件方面的丰富经验之上。

我们推出的 8 种模式是:

  • Accordion 手风琴
  • Combobox 组合框
  • Grid 网格
  • Listbox 列表框
  • Menu 菜单
  • Tabs 选项卡
  • Toolbar 工具栏
  • Tree 树

Angular Aria 包含你可以自行定制的复杂组件,例如多级独立菜单:

通过运行 npm i @angular/aria 安装这个新库。然后,访问我们的完整 Angular Aria 指南,该指南提供所有组件的使用信息和代码示例,并展示你可以复制粘贴以尝试不同外观的皮肤。

Angular 团队现在提供三种不同的方式来帮助你使用和开发组件:

  • 使用 Angular Aria 创建可访问的、headless 组件,你可以根据自己的喜好自由进行样式设计
  • 使用 CDK 来包含你可以在自己构建的组件中使用的拖放等行为原语。
  • 使用 Angular Material 来获取一套遵循 Material Design 原则的、预先样式化的组件库。你可以通过自定义主题来调整这些组件。

我们期待你使用 Angular Aria 后的反馈,以及你用它将构建出什么。

为您的 AI 代理提供更多工具——借助 Angular 的 MCP 服务器

为了确保您准备好迎接 AI 时代,我们希望确保开发者拥有合适的工具。我们希望提供适合开发者当前工作方式的工具,并助力工作方式的演变。

在 v20.2 版本中,我们推出了 Angular CLI 内置的 MCP 服务器,以确保 AI 代理拥有 Angular 开发所需的所有上下文,我们自豪地宣布,MCP 服务器现已稳定!

Angular MCP 服务器为您提供一套工具,为 AI 代理提供关于现代 Angular 和您应用的正确上下文,甚至帮助您成为更好的开发者。您可以使用 MCP 服务器:

  • 获取基本背景get_best_practices 工具提供 Angular 最佳实践指南,而 list_projects 工具会查找工作区中的所有 Angular 项目。
  • 获取最新信息search_documentation 工具能够通过查询官方文档来回答 Angular 相关问题,find_examples 工具则提供现代 Angular 模式的最新示例——我们很快将添加更多示例,如基于 Signal 的表单和 Angular Aria,以便您的 AI 代理可以使用新的编码模式。
  • 更新您的应用程序onpush_zoneless_migration 工具能够分析您的代码,并提供迁移应用程序到 OnPush 和 zoneless 变更检测的计划。还有一个名为 modernize 的实验性工具,用于使用现有 schematic 进行代码迁移。
  • 学习 Angularai_tutor 工具启动一个交互式 AI 驱动的 Angular 导师,可以帮助您学习概念并获得反馈,建议在新的 Angular 应用程序中使用。

使用 MCP 服务器,您可以解决知识截止问题——您的 LLM 是在某个特定日期之前训练的,但通过使用 MCP 服务器,它能够学习使用全新的功能,例如 Signal Forms 和 Angular Aria——您只需要让您的代理去寻找示例并使用它们!

AI 导师工具正在使用中

Vitest 作为新的默认稳定测试运行器

由于 Karma 在 2023 年被弃用,Angular 团队已经探索了 Jest、Web Test Runner 和 Vitest 作为新的测试解决方案。

在收到社区的积极反馈后,我们决定将 Vitest 作为新的默认测试运行器,并在 Angular v21 中将其提升至稳定版 🎉

要在新的 Angular 应用程序中使用 Vitest,只需运行 ng test 命令。控制台输出将如下所示:

Vitest 在 Angular 中的示例终端输出

要了解更多关于使用 Vitest 进行测试的信息,请查看 angular.dev 上的文档

虽然 Vitest 已成为新项目的默认测试运行器,但 Angular 团队仍然全面支持 Karma 和 Jasmine,因此您无需立即迁移。

如果您已准备好将现有应用程序迁移到使用 Vitest,可以运行一个实验性迁移。在执行链接指南中描述的某些准备工作后,运行:

ng g @schematics/angular:refactor-jasmine-vitest

您的测试将自动重构以使用 Vitest。

由于 Vitest 支持已稳定,我们决定弃用 Web Test Runner 和 Jest 的实验性支持,并计划在 v22 版本中移除它们。对于希望继续使用 Jest 的团队,可以考虑使用现有的社区替代方案,例如 jest-preset-angularNx Jest 插件

Zoneless 已准备好正式上线

Angular 传统上使用 zone.js,这是一个修补浏览器 API 的库,用于跟踪应用程序中的变化。这实现了“神奇”的体验,即模板在用户执行应用程序操作时自动更改,然而 zone.js 存在性能问题,尤其是在高复杂度应用程序中

随着 Signals 驱动现代 Angular 状态管理,zone.js 不再需要用于变更检测。Zoneless 变更检测在 v18 中实验性引入,在 v20 中通过开发者预览,并在 v20.2 中达到稳定。

通过我们在 Google 的应用经验,我们越来越确信新的 Angular 应用程序在没有 zone.js 的情况下工作最佳。

  • 2024 年,谷歌内部超过一半的新 Angular 应用程序都是使用 Zoneless 变更检测策略构建的,我们在 2024 年中将其设为默认配置。
  • 目前谷歌内部已有数百个 Zoneless 应用程序在生产环境中运行。
  • The HTTP Archive 外部观察,我们看到超过 1400 个 Angular 应用程序在使用 Zoneless 变更检测,这只是那些无需登录即可公开访问的应用程序数量。

鉴于这些强烈的信号,zone.js 及其功能将不再默认包含在 v21 版本的 Angular 应用程序中

启用无区域变更检测可带来诸多好处,如改善核心网络指标、原生异步等待、生态兼容性、减少包体积、简化调试和更好的控制。

新应用将自动使用无区域变更检测,现有应用请遵循 angular.dev 上的迁移说明。您也可以在 Angular MCP 服务器中尝试新的 onpush_zoneless_migration 工具,该工具会创建一个逐步计划,指导您将应用迁移到 OnPush 变更检测策略。

虽然无区域是新的默认体验,但我们想承认 zone.js 在塑造 Angular 方面发挥了重要作用,并让开发者多年来能够创造神奇体验。我们向 zone.js 团队致以诚挚的感谢,特别是感谢 Jia Li 对 zone.js 的贡献。

全新的文档体验

如果你最近几周访问过 angular.dev,可能会注意到有一个新的首页。但这还不是 angular 发生的所有变化。

dev — 我们进行了重大调整,以确保文档体验现代化,并教授最新概念,让你始终能获取最新信息。

在 Google I/O 2025 上,我们推出了 angular.dev/ai — 你构建基于 Angular 的 AI 应用所需的所有资源。我们包含了最佳实践、代码示例、设计模式等。我们还包含了最佳实践提示和自定义规则文件,以帮助确保你的代码生成体验结果为现代化的 Angular 代码。我们一直在发布大量更新,请继续关注,获取构建 AI 应用的最新技巧和策略,以及利用最佳 AI 辅助编码方法。

如果你刚开始构建响应式应用的旅程,可以查看新的 Signals 教程,它提供了所有稳定 Signal API 的完整概述,包括 model()linkedSignal() 等。

我们在 angular.dev 上投入了大量精力更新开发者指南:

  • 路由文档已完全改版,提供了关于路由所有方面的详细信息。
  • 依赖注入指南已大幅改进,并使希望掌握这一强大功能核心概念的开发者更容易理解。
  • 我们新增了一份关于 Material 组件主题化方法的全面指南
  • 最后但同样重要的是,我们提供了一份完整指南,介绍如何使用 Angular 与 Tailwind CSS

我们致力于提升文档使用体验。对于使用 Angular MCP 服务器的开发者,新的 search_documentation 工具将使您的 AI 代理能够获取 angular.dev 上最新、最全面的信息。

还有更多……

我们一直很忙碌!除了我们之前重点介绍的优秀功能外,我们团队还交付了许多值得特别提及的其他成果:

@let isValidNumber = /\d+/.test(someValue);

@if (!isValidNumber) {
  <p>{{someValue}} is not a valid number!</p>
}

  • 您现在可以自定义与视口相关的 @defer 触发器的 IntersectionObserver 选项,例如:
@defer (on viewport({trigger, rootMargin: '100px'}) {
  <section>Content</section>
}

如果你错过了——这些只是自 Angular v20.2 以来的更新。如果你只关注每个主要版本,你可能错过了:

一如既往,完整的变化列表在我们的 Changelog 中。

Angular ❤️ 我们卓越的社区

没有我们开源社区,这个卓越的发布将不可能实现。

我们社区中有许多人通过大小不一的贡献推动着 Angular 的发展,无论是为其他开发者解答问题,组织聚会和会议,改进文档,还是通过提交 pull-request。

如果你是我们的贡献者——非常感谢!如果你还不是,我希望这能激励你!即使只是回答一个问题或帮助你的同事也是非常有帮助的!!

自 v20 以来,已有 215 人贡献了 Angular 代码库,我们想要突出一些具体的贡献:

非常感谢您参与 Angular v21!

您可能还记得我们在 Angular v20 版本中征求过您对吉祥物的意见!我们的吉祥物 RFC 收到了创纪录数量的提交,因此我们需要向您更新。我们知道您想见到我们的新吉祥物,请务必在 2025 年 11 月 20 日上午 9 点太平洋时间观看我们的发布活动,以获取正式宣布⭐

构建下一波应用程序的时刻

我们对这次发布感到无比自豪,但这只是 Angular 旅程中的一步。我们密切关注着未来 Web 应用程序开发中的新兴模式。

我们已经看到了 AI 的力量,并希望尽我们所能为您提供适合您工作方式的工具。无论是通过 vibe 编程、AI 代理还是传统开发。

我们的最新功能,如 Signal Forms 和 Angular Aria,是我们继续改进 API 表面,使 Angular 成为构建可扩展 Web 应用的信心之地的证明。

请务必运行 ng update 并创建您的用户会喜爱的应用程序。

【URP】Unity[RendererFeatures]渲染对象RenderObjects

【从UnityURP开始探索游戏渲染】专栏-直达

RenderObjects的定义与作用

RenderObjects是URP提供的RendererFeature之一,允许开发者在不编写代码的情况下对渲染管线进行定制。它通过配置参数实现选择性渲染特定层级的物体、控制渲染顺序、重载材质或渲染状态等功能57。其核心用途包括:

  • 层级过滤‌:仅渲染指定LayerMask的物体
  • 渲染时机控制‌:通过Event参数插入到渲染管线的不同阶段(如AfterRenderingOpaques)
  • 材质替换‌:使用Override Material覆盖原有材质
  • 多Pass渲染‌:配合Shader的LightMode标签实现描边等效果

发展历史

  • 初始版本(2020年前)作为LWRP实验性功能引入
  • 2020年URP 7.x版本正式集成,提供基础层过滤和材质替换
  • 2021年后增强深度/模板控制,支持透明物体处理
  • 2022年优化API结构,明确ScriptableRendererFeature与RenderPass的分离

原理

底层原理

  • 架构层级

    RenderObjects通过继承ScriptableRendererFeatureScriptableRenderPass实现管线扩展,核心逻辑在Execute()方法中通过CommandBuffer提交绘制指令。其本质是通过URP的ScriptableRenderContext调度GPU渲染命令,与内置管线不同之处在于采用可编程的轻量级渲染管线架构。

  • 渲染流程控制

    通过RenderPassEvent枚举插入到URP的固定管线阶段(如AfterRenderingOpaques),底层会触发以下操作:

    • 调用ConfigureTarget()设置渲染目标
    • 使用FilteringSettings过滤指定Layer的物体
    • 通过DrawingSettings配置Shader Pass和排序规则
  • 材质替换机制

    当启用Override Material时,URP会临时替换原始材质的Shader,但保留物体的顶点数据。该过程通过MaterialPropertyBlock实现动态参数传递,避免材质实例化开销。

实现示例

  • OutlineFeature.cs

    using UnityEngine;
    using UnityEngine.Rendering;
    using UnityEngine.Rendering.Universal;
    
    public class OutlineFeature : ScriptableRendererFeature {
        class OutlinePass : ScriptableRenderPass {
            private Material _outlineMat;
            private LayerMask _layerMask;
            private FilteringSettings _filteringSettings;
    
            public OutlinePass(Material mat, LayerMask mask) {
                _outlineMat = mat;
                _layerMask = mask;
                _filteringSettings = new FilteringSettings(RenderQueueRange.opaque, _layerMask);
                renderPassEvent = RenderPassEvent.AfterRenderingOpaques;
            }
    
            public override void Execute(ScriptableRenderContext context, ref RenderingData data) {
                var drawingSettings = CreateDrawingSettings(
                    new ShaderTagId("UniversalForward"), 
                    ref data, 
                    SortingCriteria.CommonOpaque
                );
                drawingSettings.overrideMaterial = _outlineMat;
                context.DrawRenderers(data.cullResults, ref drawingSettings, ref _filteringSettings);
            }
        }
    
        [SerializeField] private Material _outlineMaterial;
        [SerializeField] private LayerMask _outlineLayers = 1;
        private OutlinePass _pass;
    
        public override void Create() => _pass = new OutlinePass(_outlineMaterial, _outlineLayers);
        public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData data) 
            => renderer.EnqueuePass(_pass);
    }
    
  • Outline.shader

    Shader "Custom/Outline" {
        Properties {
            _OutlineColor("Color", Color) = (1,0,0,1)
            _OutlineWidth("Width", Range(0,0.1)) = 0.03
        }
        SubShader {
            Tags { "RenderType"="Opaque" "Queue"="Geometry+100" }
            Pass {
                Cull Front
                ZWrite Off
                CGPROGRAM
                #pragma vertex vert
                #pragma fragment frag
                #include "UnityCG.cginc"
    
                float _OutlineWidth;
                fixed4 _OutlineColor;
    
                struct appdata {
                    float4 vertex : POSITION;
                    float3 normal : NORMAL;
                };
    
                v2f vert(appdata v) {
                    v2f o;
                    v.vertex.xyz += v.normal * _OutlineWidth;
                    o.pos = UnityObjectToClipPos(v.vertex);
                    return o;
                }
    
                fixed4 frag(v2f i) : SV_Target {
                    return _OutlineColor;
                }
                ENDCG
            }
        }
    }
    

关键流程解析

  • 渲染指令提交

    DrawRenderers方法内部会构建BatchRendererGroup,将CPU侧的渲染数据批量提交至GPU,相比直接使用CommandBuffer更高效。

  • 深度测试控制

    示例中ZWrite Off禁用深度写入,使描边始终显示在原始物体表面,该技术也常用于解决透明物体渲染顺序问题。

  • 多Pass协作

    URP会先执行默认的Forward渲染Pass,再执行RenderObjects插入的Pass,通过RenderPassEvent控制执行顺序

完整实现流程示例

  • OutlineFeature.cs

    using UnityEngine;
    using UnityEngine.Rendering;
    using UnityEngine.Rendering.Universal;
    
    public class OutlineFeature : ScriptableRendererFeature {
        class OutlinePass : ScriptableRenderPass {
            private Material outlineMat;
            private LayerMask layerMask;
            private RenderTargetIdentifier source;
    
            public OutlinePass(Material mat, LayerMask mask) {
                outlineMat = mat;
                layerMask = mask;
                renderPassEvent = RenderPassEvent.AfterRenderingOpaques;
            }
    
            public override void Execute(ScriptableRenderContext context, ref RenderingData data) {
                CommandBuffer cmd = CommandBufferPool.Get("OutlinePass");
                var drawSettings = CreateDrawingSettings(
                    new ShaderTagId("UniversalForward"), 
                    ref data, SortingCriteria.CommonOpaque);
                var filterSettings = new FilteringSettings(RenderQueueRange.opaque, layerMask);
                context.DrawRenderers(data.cullResults, ref drawSettings, ref filterSettings);
                CommandBufferPool.Release(cmd);
            }
        }
    
        [SerializeField] private Material outlineMaterial;
        [SerializeField] private LayerMask outlineLayers;
        private OutlinePass pass;
    
        public override void Create() {
            pass = new OutlinePass(outlineMaterial, outlineLayers);
        }
    
        public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData data) {
            renderer.EnqueuePass(pass);
        }
    }
    
  • Outline.shader

    Shader "Custom/Outline" {
        Properties {
            _OutlineColor("Color", Color) = (1,1,1,1)
            _OutlineWidth("Width", Range(0,0.1)) = 0.05
        }
        SubShader {
            Tags { "RenderType"="Opaque" "LightMode"="UniversalForward" }
            Pass {
                CGPROGRAM
                // Vertex expansion logic...
                ENDCG
            }
        }
    }
    

参数详解与用例

参数 说明 应用场景
Event 渲染时机(如BeforeRenderingPostProcessing) 控制特效叠加顺序
LayerMask 目标渲染层级 仅对敌人/UI层描边
Override Material 替换材质 角色进入阴影区切换材质
Depth Test 深度测试模式 解决透明物体遮挡问题
Shader Passes 匹配的Shader LightMode标签 多Pass渲染(如"UniversalForward")

配置步骤

  • 创建URP Asset并启用Renderer Features
  • 添加RenderObjects Feature到Forward Renderer
  • 配置Event为AfterRenderingOpaques(不透明物体)或AfterRenderingTransparents(透明物体)
  • 指定目标Layer和替换材质
  • 调整Depth/Stencil参数解决遮挡问题

典型应用包括:角色描边、场景分块渲染、特殊效果叠加(如受伤高亮)等。通过组合不同Event和LayerMask可实现复杂的渲染管线控制


【从UnityURP开始探索游戏渲染】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

Vue3 源码深度解析:Computed 的完整实现机制

本系列前三篇我们已经拆解了 reactivecollection handlerseffect 体系,本篇继续深入 Vue3 响应式系统的第四个核心:computed 派生值
本文依旧沿用统一结构:概念 → 架构 → 核心流程 → 源码逐段解析 → 与 effect 的关系 → 使用示例 → 扩展 → 潜在问题。


一、Computed 是什么?(概念篇)

在 Vue3 的响应式系统中,computed 属于 派生类型(Derived State)

  • 由其他响应式值计算得来
  • 自动追踪依赖
  • 自动缓存(不重复执行 getter)
  • 支持可写/只读两种模式
  • effect 解耦,不再使用 effect 实现

在新版实现中,computed 更像一个 “双重身份单元”

  • 作为 被动目标:被外部 effect 读取时,需要收集依赖(自身被依赖)
  • 作为 主动订阅者:自己要订阅其他响应式源,以便在源更新时“变脏”

这种结构使得 computed 更轻、更快、更可控。


二、Computed 的总体架构

在 Vue3 中,computed 的主要结构如下:

ComputedRefImpl
 ├─ _value           // 缓存的计算结果
 ├─ fn               // getter
 ├─ setter           // 可写 computed 才有
 ├─ dep              // 哪些 effect 依赖 computed
 ├─ deps / depsTail  // computed 依赖哪些 ref/reactive(反向链)
 ├─ flags            // 当前 computed 是否 dirty
 ├─ globalVersion    // 版本号控制是否要重新计算
 ├─ isSSR            // SSR 模式不同逻辑

其中最关键的是 flags(dirty) + globalVersion(版本校验)
这两个共同控制 computed 的缓存刷新逻辑。


三、核心流程:Computed 的运行机制

Vue3 中 computed 工作流程可以分为四个步骤:


1)初始化

  • 创建 ComputedRefImpl
  • 记录 getter
  • 若有 setter 则为可写模式
  • 标记为 dirty(需要首次计算)

2)首次访问 .value

  • 触发 dep.track() 收集依赖
  • 触发 refreshComputed(this)
  • 执行 getter 得到新值
  • 缓存 _value
  • 清除 dirty / 更新版本号

3)依赖更新 → 触发 notify()

如果 computed 所依赖的数据改变:

  • computed.flags |= DIRTY
  • 下一次访问 .value 时会重新执行 getter

4)再次访问 .value

若 dirty = false 且版本号未变:

不重新计算,直接返回缓存

若 dirty = true 或版本号变化:

重新计算


四、源码逐段解析(与你前三篇风格完全一致)

以下代码全部来自你给的源码,我将按模块拆解并解释。


① 依赖导入

import { isFunction } from '@vue/shared'
import {
  DebuggerEvent,
  DebuggerOptions,
  EffectFlags,
  Subscriber,
  activeSub,
  batch,
  refreshComputed,
} from './effect'
import { warn } from './warning'
import { Dep, Link, globalVersion } from './dep'
import { ReactiveFlags, TrackOpTypes } from './constants'

解析

  • batch() → 批量通知 effect,避免频繁触发
  • refreshComputed() → computed 的核心刷新逻辑
  • Dep → 用于记录计算属性被哪些 effect 依赖
  • globalVersion → 全局版本号(优化)

computed 不再依赖 effect,而是依赖 Dep(更轻量)。


② ComputedRefImpl 类定义

export class ComputedRefImpl<T = any> implements Subscriber {

computed 自身是一个 Subscriber(订阅者)
它能订阅其他 reactive/ref。


③ 内部字段

_value: any = undefined
dep: Dep = new Dep(this)
__v_isRef = true
__v_isReadonly: boolean
deps?: Link = undefined
depsTail?: Link = undefined
flags: EffectFlags = EffectFlags.DIRTY
globalVersion: number = globalVersion - 1
isSSR: boolean

字段解析

字段 作用
_value 缓存的计算结果
dep 谁依赖了这个 computed
deps computed 依赖的响应式源(反向)
flags 是否 dirty,需要重新计算
globalVersion 避免重复计算的版本号控制

④ 构造函数

constructor(fn, setter, isSSR) {
  this[ReactiveFlags.IS_READONLY] = !setter
  this.isSSR = isSSR
}

没有 setter → readonly computed。
这部分设计和前三篇响应式系统一致。


⑤ notify:依赖发生变化时触发 dirty

notify() {
  this.flags |= EffectFlags.DIRTY

  if (
    !(this.flags & EffectFlags.NOTIFIED) &&
    activeSub !== this
  ) {
    batch(this, true)
    return true
  }
}

原理说明

当依赖更新时:

  • 标记本 computed 为 dirty
  • 通过 batch 通知依赖它的 effect
  • 避免 self-recursion(避免自己递归触发自己)

这与上一篇 Collection 里的触发机制一致。


⑥ get value:读取并刷新缓存

get value() {
  const link = this.dep.track()

  refreshComputed(this)

  if (link) {
    link.version = this.dep.version
  }
  return this._value
}

核心关键点(非常重要)

  1. dep.track()
    表示 “外部 effect 正在依赖我”
  2. refreshComputed(this)
    若 dirty 或版本号变化 → 执行 getter
  3. 返回缓存值

这是 computed 靠缓存优化性能的关键。


⑦ set value:支持可写 computed

set value(newValue) {
  if (this.setter) {
    this.setter(newValue)
  } else {
    warn('Write operation failed: computed value is readonly')
  }
}

这个很好理解。


⑧ computed() 工厂函数

export function computed(getterOrOptions, debugOptions, isSSR = false) {
  let getter
  let setter

  if (isFunction(getterOrOptions)) {
    getter = getterOrOptions
  } else {
    getter = getterOrOptions.get
    setter = getterOrOptions.set
  }

  const cRef = new ComputedRefImpl(getter, setter, isSSR)

  if (__DEV__ && debugOptions && !isSSR) {
    cRef.onTrack = debugOptions.onTrack
    cRef.onTrigger = debugOptions.onTrigger
  }

  return cRef
}

支持两种形式

  • computed(getter)
  • computed({ get, set })

同 Vue3 文档。


五、Computed 与 Effect 的关系

Vue3 中:

项目 Vue2 Vue3
computed 内部实现 基于 watcher 不再使用 effect
依赖收集 watcher.deps dep + version
缓存策略 lazy watcher version + dirty

Computed 更轻、更纯,不会参与 effect 的副作用队列。


六、实战示例

const count = ref(1)

const plusOne = computed(() => count.value + 1)

console.log(plusOne.value) // 2
count.value++
console.log(plusOne.value) // 3(重新计算)

使用体验不变,但实现更加高效。


七、扩展:Computed 为什么要分版本号?

原因:提升性能、减少不必要计算

例如:

多个 ref 改变但 getter 内只依赖其中一个时,版本号能避免无效计算。

Vue3 响应式系统中 version 机制广泛存在:

  • ref
  • reactive
  • computed

都依赖 version。


八、潜在问题与注意事项

  1. getter 必须是纯函数(不要做副作用)
  2. computed 不适合包含异步(会变成 Promise)
  3. 多层嵌套 computed 可能产生链式 dirty — Vue 已做优化
  4. setter 写入不要和 getter 逻辑矛盾

总结

本篇从 computed 的整体设计、数据结构、dirty 机制、版本号策略、依赖链结构,到源码逐段拆解,完整讲解了 Vue3 计算属性的底层实现,使其风格与前三篇完全保持一致。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

C#运算符与表达式终极指南:从入门到精通的万字长文

一、什么是运算符与表达式?

  • 运算符(Operator):是一种特殊的符号,用于执行特定的数学、逻辑或位运算。例如 + 是加法运算符,== 是相等运算符。
  • 操作数(Operand):是参与运算的数据。例如,在 5 + 3 中,53 就是操作数。
  • 表达式(Expression):是由操作数和运算符组成的序列,其计算结果会产生一个新值。例如 5 + 3 是一个表达式,它的计算结果是 8x > y 也是一个表达式,它的计算结果是 truefalse

表达式是C#程序的基本执行单元。 理解它们,就是理解程序如何思考。


二、基础运算符

1. 算术运算符

它们负责处理基本的数学计算,与你在小学数学课上学到的几乎一样。

运算符 名称 示例 结果
+ 加法 10 + 5 15
- 减法 10 - 5 5
* 乘法 10 * 5 50
/ 除法 10 / 5 2
% 取模 (求余数) 10 % 3 1
int a = 10;
int b = 3;

Console.WriteLine($"加法: {a + b}");       // 输出: 13
Console.WriteLine($"减法: {a - b}");       // 输出: 7
Console.WriteLine($"乘法: {a * b}");       // 输出: 30
Console.WriteLine($"除法: {a / b}");       // 输出: 3 (注意:整数除法)
Console.WriteLine($"取模: {a % b}");       // 输出: 1

注意事项:整数除法

当两个整数相除时,结果也是一个整数,小数部分会被直接截断(不是四舍五入)。例如 10 / 3 的结果是 3。如果你想得到精确的小数结果,至少要有一个操作数是浮点类型。

double result = 10.0 / 3; // 结果是 3.333...
double result2 = (double)10 / 3; // 结果也是 3.333...

2. 赋值运算符

赋值运算符用于给变量分配一个值。最基本的是 =,但C#提供了一系列复合赋值运算符,让代码更简洁。

运算符 示例 等价于
= x = 5 x = 5
+= x += 5 x = x + 5
-= x -= 5 x = x - 5
*= x *= 5 x = x * 5
/= x /= 5 x = x / 5
%= x %= 5 x = x % 5
int score = 100;
score += 10; // score 现在是 110
score -= 20; // score 现在是 90
score *= 2;  // score 现在是 180
score /= 3;  // score 现在是 60

使用复合赋值运算符不仅代码更短,而且可读性更好,是专业代码的标志之一。

3. 一元运算符

这些运算符只需要一个操作数。

运算符 名称 示例 描述
+ 正号 +5 表示一个正数 (通常省略)
- 负号 -5 表示一个负数 (取反)
++ 递增 x++++x 将变量的值加1
-- 递减 x----x 将变量的值减1

++-- 有两种形式:前缀后缀,它们的区别在于表达式求值的时机

  • 前缀 (Prefix) ++x: 先将 x 的值加1,然后返回加1后的值。
  • 后缀 (Postfix) x++: 先返回 x原始值,然后再将 x 的值加1。
int i = 5;
int j = 5;

// 前缀:先自增,再赋值
int prefixResult = ++i; // i 变成 6, prefixResult 也被赋值为 6
Console.WriteLine($"i: {i}, prefixResult: {prefixResult}"); // 输出: i: 6, prefixResult: 6

// 后缀:先赋值,再自增
int postfixResult = j++; // postfixResult 被赋值为 5, 然后 j 变成 6
Console.WriteLine($"j: {j}, postfixResult: {postfixResult}"); // 输出: j: 6, postfixResult: 5

三、逻辑与比较

1. 关系运算符

也叫比较运算符,用于比较两个操作数,其结果总是一个布尔值 (truefalse)。

运算符 名称 示例 结果为true的条件
== 等于 a == b a 和 b 的值相等
!= 不等于 a != b a 和 b 的值不相等
> 大于 a > b a 的值大于 b
< 小于 a < b a 的值小于 b
>= 大于等于 a >= b a 的值大于或等于 b
<= 小于等于 a <= b a 的值小于或等于 b
int age = 20;
bool isAdult = age >= 18; // isAdult 的值为 true
bool isVoter = age == 21; // isVoter 的值为 false

这些运算符是 if 语句、while 循环等所有控制流语句的核心。

2. 逻辑运算符

用于组合多个布尔表达式,构建更复杂的判断逻辑。

运算符 名称 示例 描述
! 逻辑非 (NOT) !isValid 如果操作数为true,结果为false;反之亦然
&& 逻辑与 (AND) isLogin && isAdmin 两个操作数都为true时,结果才为true
` ` 逻辑或 (OR) `isMember isVIP` 只要有一个操作数为true,结果就为true

短路求值 (Short-circuiting)

&&|| 有一个非常重要的特性叫“短路”。

  • 对于 expr1 && expr2:如果 expr1 的计算结果为 false,那么整个表达式的结果必定是 false,此时 expr2 将不会被计算
  • 对于 expr1 || expr2:如果 expr1 的计算结果为 true,那么整个表达式的结果必定是 true,此时 expr2 将不会被计算

这个特性非常有用,常用于避免空引用异常或减少不必要的计算:

string name = null;

// 如果不使用短路,当 name 为 null 时,name.Length 会抛出 NullReferenceException
// if (name != null & name.Length > 0) { ... }  // 注意这里是 &,非短路

// 使用短路 &&,当 name 为 null 时,第一个条件为 false,第二个条件根本不会执行,非常安全
if (name != null && name.Length > 0)
{
    Console.WriteLine("Name is not empty.");
}

四、位运算符

运算符 名称 描述
& 按位与 两个操作数中,对应位都为1时,结果位才为1
` ` 按位或 两个操作数中,对应位只要有一个为1,结果位就为1
^ 按位异或 两个操作数中,对应位不同时,结果位为1
~ 按位取反 单目运算符,将操作数的所有位反转 (0变1,1变0)
<< 左移 将操作数的所有位向左移动指定的位数,右侧补0
>> 右移 将操作数的所有位向右移动指定的位数

应用场景:权限管理 枚举经常与位运算符结合,用于管理一组开关状态(Flags)。

[Flags]
public enum Permissions
{
    None = 0,       // 0000
    Read = 1,       // 0001
    Write = 2,      // 0010
    Execute = 4,    // 0100
    All = Read | Write | Execute // 0111
}

Permissions userPermissions = Permissions.Read | Permissions.Write;

// 检查是否包含写权限
if ((userPermissions & Permissions.Write) == Permissions.Write)
{
    Console.WriteLine("User has Write permission.");
}

// 添加执行权限
userPermissions |= Permissions.Execute;

// 移除写权限
userPermissions &= ~Permissions.Write;

五、那些强大的“语法糖”

随着C#语言的演进,出现了许多新的运算符,它们极大地简化了代码,使其更具表现力和健壮性。

1. Null 合并运算符 (????=)

用于处理 null 值的利器。

  • ?? (Null-Coalescing Operator): a ?? b 如果 a 不为 null,则表达式的结果是 a;如果 anull,则结果是 b
string userName = null;
string displayName = userName ?? "Guest"; // displayName 的值将是 "Guest"

string userName2 = "Admin";
string displayName2 = userName2 ?? "Guest"; // displayName2 的值将是 "Admin"

这完美地替代了冗长的 if 或三元表达式 (userName != null) ? userName : "Guest"

  • ??= (Null-Coalescing Assignment Operator) (C# 8.0+) variable ??= value 仅当 variablenull 时,才将 value 赋给 variable
List<int> numbers = null;
numbers ??= new List<int>(); // 因为 numbers 是 null,所以为其创建一个新实例

numbers.Add(1);

numbers ??= new List<int>(); // 因为 numbers 不再是 null,所以这条语句什么也不做

这对于延迟初始化(Lazy Initialization)非常有用。

2. Null 条件运算符 (?.?[])

用于优雅地避免 NullReferenceException,告别层层嵌套的 if (obj != null) 检查。

  • ?. (Null-Conditional Member Access): 在访问对象成员(方法或属性)之前,检查对象是否为 null。如果是 null,整个表达式直接返回 null,而不会抛出异常。
string street = "";

User user = null; // GetUser();


// 传统方式,需要层层检查
if (user != null)
{
    if (user.UserAddress != null)
    {
        street = user.UserAddress.Street;
    }
}

// 使用 ?. 运算符,一行搞定!
// 如果 user 或 user.UserAddress 是 null,street 将被赋值为 null
string streetElegant = user?.UserAddress?.Street;


public class User { public Address UserAddress { get; set; } }
public class Address { public string Street { get; set; } }
  • ?[] (Null-Conditional Element Access): 用于访问数组或索引器。
List<string> names = null;
string firstName = names?[0]; // 如果 names 为 null,firstName 为 null,不抛异常

3. 条件运算符 (?:) - 三元表达式

它是 if-else 语句的紧凑形式,适用于简单的条件赋值。

语法: condition ? first_expression : second_expression; 如果 conditiontrue,则计算 first_expression 并将其作为结果;否则计算 second_expression

int age = 20;
string status = (age >= 18) ? "Adult" : "Minor"; // status 的值为 "Adult"

4. 类型相关运算符

运算符 名称 描述
is 类型判断 检查对象是否与给定类型兼容,返回布尔值。C# 7.0+支持模式匹配。
as 类型转换 尝试将对象转换为指定类型,如果转换失败,返回null而不是抛出异常。
typeof 获取类型 返回一个表示类型的 System.Type 对象。
sizeof 获取大小 (仅限非托管类型)返回给定类型值在内存中占用的字节数。
object obj = "Hello World";

if (obj is string)
{
    Console.WriteLine("It's a string.");
}

if (obj is string s) // 如果是string,则直接转换并赋值给 s
{
    Console.WriteLine($"The string has {s.Length} characters.");
}

string str = obj as string; // 转换成功,str 为 "Hello World"
StringBuilder sb = obj as StringBuilder; // 转换失败,sb 为 null

六、运算符的规则 - 优先级与结合性

当一个表达式中包含多个运算符时,谁先计算?这就是**优先级(Precedence)结合性(Associativity)**要解决的问题。

  • 优先级:决定了不同运算符的计算顺序。例如,*/ 的优先级高于 +-,所以 2 + 3 * 4 的结果是 14 而不是 20
  • 结合性:当多个具有相同优先级的运算符在一起时,决定它们的计算方向。
    • 左结合性 (Left-associative):从左到右计算。例如 a - b - c 等价于 (a - b) - c。大多数二元运算符都是左结合的。
    • 右结合性 (Right-associative):从右到左计算。例如赋值运算符 a = b = c 等价于 a = (b = c)。三元运算符也是右结合的。

C#运算符优先级(由高到低摘录)

  1. 主要: x.y, f(x), a[i], x++, x--, new, typeof, sizeof
  2. 一元: +, -, !, ~, ++x, --x, (T)x
  3. 乘法: *, /, %
  4. 加法: +, -
  5. 移位: <<, >>
  6. 关系: <, >, <=, >=, is, as
  7. 相等: ==, !=
  8. 位与: &
  9. 位异或: ^
  10. 位或: |
  11. 逻辑与: &&
  12. 逻辑或: ||
  13. Null合并: ??
  14. 条件: ?:
  15. 赋值与Lambda: =, *=, /=, +=, -=, =>

结语

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

Vue3 响应式系统源码解析:Map/Set Collection 响应式核心实现

本文解析 Vue3 reactivity 模块中 Collection(Map/Set/WeakMap/WeakSet) 的响应式处理逻辑,重点在设计思路与源码结构,而不是 API 使用。


1. 背景:为什么 Map/Set 要单独写代码?

Vue 对普通对象 {} 的响应式靠 get/set 即可,但对 Map/Set:

  • 方法是函数(如 .set().get()
  • 键可能是对象
  • 有迭代器keysvaluesentriesfor…of
  • 弱集合 WeakMap/WeakSet 无法遍历

因此,Vue 必须提供一套专门的“仪表方法”(instrumentations)来接管所有操作。

这一段源码就是专门为此而设计的。


2. createIterableMethod —— 迭代器逻辑封装

代码片段

function createIterableMethod(method, isReadonly, isShallow) {
  return function (...args) {
    const target = this[ReactiveFlags.RAW]
    const rawTarget = toRaw(target)
    const targetIsMap = isMap(rawTarget)
    const isPair = method === 'entries' || (method === Symbol.iterator && targetIsMap)
    const isKeyOnly = method === 'keys' && targetIsMap

    const innerIterator = target[method](...args)
    const wrap = isShallow ? toShallow : isReadonly ? toReadonly : toReactive

    !isReadonly && track(
      rawTarget,
      TrackOpTypes.ITERATE,
      isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY,
    )

    return {
      next() {
        const { value, done } = innerIterator.next()
        return done
          ? { value, done }
          : { value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value), done }
      },
      [Symbol.iterator]() {
        return this
      },
    }
  }
}

逐段解释:

① 获取原始对象
const target = this[ReactiveFlags.RAW]
const rawTarget = toRaw(target)

Map 可能是嵌套 reactive 包装,这里确保拿到真正的原对象。

② 判断返回值是 key/value/key+value
const isPair = method === 'entries' ...
const isKeyOnly = method === 'keys'
③ 执行原生迭代器
const innerIterator = target[method](...args)
④ 依赖收集
track(rawTarget, TrackOpTypes.ITERATE, ITERATE_KEY)

关键点:只要调用 keys、values、entries、for-of,都要追踪依赖。

这样当集合结构变化时才能触发更新。

⑤ 迭代器包装:将每个值转成 reactive
value: wrap(...)

Map 中存的值可能是对象,Vue 自动转换成 reactive 或 readonly。

这一部分是最核心的“响应式迭代器”实现。


3. createReadonlyMethod —— 用于 readonly 集合

代码片段

function createReadonlyMethod(type) {
  return function (...args) {
    warn(`${capitalize(type)} operation failed: target is readonly.`)
    return type === TriggerOpTypes.DELETE
      ? false
      : type === TriggerOpTypes.CLEAR ? undefined : this
  }
}

思路:

  • 任何修改(set/add/delete/clear)都会提示错误
  • 并返回合理的 fallback 值

举例:

  • delete()false
  • clear()undefined
  • set/add → 返回 this(保持链式调用)

4. createInstrumentations —— 生成 Map/Set 全量代理方法

它会根据参数 readonlyshallow 返回不同版本的方法表。


(1)get(key)

代码片段

get(key) {
  const target = this[ReactiveFlags.RAW]
  const rawTarget = toRaw(target)
  const rawKey = toRaw(key)

  if (!readonly) {
    if (hasChanged(key, rawKey)) track(rawTarget, TrackOpTypes.GET, key)
    track(rawTarget, TrackOpTypes.GET, rawKey)
  }

  const { has } = getProto(rawTarget)

  const wrap = shallow ? toShallow : readonly ? toReadonly : toReactive

  if (has.call(rawTarget, key)) {
    return wrap(target.get(key))
  } else if (has.call(rawTarget, rawKey)) {
    return wrap(target.get(rawKey))
  }
}

关键细节:

① key 和 rawKey 都要 track

原因:用户可能用 reactive(obj) 或 raw obj 作为 map key。

② 始终 wrap 返回值

所有 get 到的 value 必须返回 reactive/readonly 版本。


(2)size 属性

get size() {
  const target = this[ReactiveFlags.RAW]
  !readonly && track(toRaw(target), TrackOpTypes.ITERATE, ITERATE_KEY)
  return target.size
}

size 依赖整个集合结构,所以也要使用 ITERATE 依赖类型。


(3)has(key)

逻辑和 get 类似,也要对 key 与 rawKey 都进行 track。


(4)forEach

Vue 为回调参数做响应式包装:

callback.call(thisArg, wrap(value), wrap(key), observed)

确保:

  • value 是 reactive
  • key 是 reactive
  • this 是 reactive

5. Mutation(增删改清)操作

如果不是 readonly,则提供真正的 set/add/delete/clear。


(1)add (for Set)

add(value) {
  value = toRaw(value)
  const hadKey = target.has(value)
  if (!hadKey) {
    target.add(value)
    trigger(target, TriggerOpTypes.ADD, value, value)
  }
  return this
}

(2)set (for Map)

核心逻辑

  • 把 value 转 raw
  • 判断 key 是否存在
  • 触发 ADD 或 SET

(3)delete

触发 DELETE 类型依赖,并将旧值传递给 watcher。


(4)clear

触发 CLEAR,且如果是 Map 会克隆 oldTarget(仅 dev)。


6. iterator 方法统一挂载

['keys','values','entries',Symbol.iterator].forEach(method => {
  instrumentations[method] = createIterableMethod(method, readonly, shallow)
})

这是响应式迭代器的核心。


7. createInstrumentationGetter —— Proxy.get 捕获器

它定义了所有 Collection 的代理行为。

get(target, key, receiver) {
  if (key === ReactiveFlags.IS_REACTIVE) return !isReadonly
  if (key === ReactiveFlags.IS_READONLY) return isReadonly
  if (key === ReactiveFlags.RAW) return target

  return Reflect.get(
    hasOwn(instrumentations, key) && key in target
      ? instrumentations
      : target,
    key,
    receiver,
  )
}

逻辑:

  1. 读标识位(isReactive/isReadonly/raw)
  2. 如果有对应的 instrumentations 方法,则从那里取
  3. 否则取集合原本的方法

8. 最终导出的四种 handler

export const mutableCollectionHandlers
export const shallowCollectionHandlers
export const readonlyCollectionHandlers
export const shallowReadonlyCollectionHandlers

Vue 会根据:

  • reactive()
  • shallowReactive()
  • readonly()
  • shallowReadonly()

来使用不同的 handler。


总结

这段源码是 Vue3 响应式系统中最复杂的一部分之一,核心设计理念包括:

  • 以 instrumentations 表包装所有 Collection 方法
  • 迭代器统一代理,确保 for-of / keys / values 都能被追踪
  • 返回值自动 wrap 成 reactive 或 readonly
  • 同时支持 raw key 与 reactive key 的一致性检查
  • 对不同模式(shallow / readonly)进行统一抽象

它是 Vue 能优雅支持 Map/Set 响应式的关键。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

年轻人的第一个 GO 桌面应用:用 Wails 做个学习搭子计时器

告别控制台应用,用Go语言打造你的第一个桌面软件

🎯 痛点场景:为什么需要桌面应用开发技能?

你是否遇到这些问题?

  • 学了Go语言,却只会写命令行工具?
  • 想学桌面开发,却觉得C++/C#太复杂?
  • 面试官对你的“烂大街”项目不感兴趣?

你能学到什么?

  • ✅️ Wails 基础使用
  • ✅️ 实时事件通信
  • ✅️ 系统托盘集成
  • ✅️ 多线程编程
  • ✅️ 附完整源码

测试效果.gif

🛠️ 环境准备

步骤1:安装Go语言

# 官网下载安装包:https://golang.org/dl/
go version  # 验证安装

步骤2:安装Wails框架

go install github.com/wailsapp/wails/v2/cmd/wails@latest
wails version  # 验证安装

步骤3:创建项目骨架

wails init -n Punktime
cd Punktime

wailsinit.jpg

🚀 核心功能开发

设计 UI (前端)

  • 使用 HTML/CSS/JavaScript实现:

    • 时间/倒计时主界面
    • 倒计时设置遮罩层
  • 示例代码

<!-- ============================================================================
     * 主界面结构
     * 应用程序的主要显示区域
     * ============================================================================ -->

    <!-- 主容器:显示倒计时/时间 -->
    <div class="container">
      <div class="countdown" id="countdown"></div>
    </div>

    <!-- ============================================================================
     * 倒计时输入框模态对话框
     * 用于设置倒计时时长的弹出窗口
     * ============================================================================ -->

    <!-- 倒计时输入框遮罩层 -->
    <div
      id="countdownInputOverlay"
      class="countdown-input-overlay"
      style="display: none"
    >
      <div class="countdown-input-container">
        <div class="countdown-input-title"></div>
        <div>
          <!-- 分钟输入框 -->
          <input
            type="number"
            id="minutesInput"
            class="countdown-input"
            min="0"
            max="59"
            value="25"
            placeholder="分"
          />
          <span style="color: lightgreen">:</span>
          <!-- 秒数输入框 -->
          <input
            type="number"
            id="secondsInput"
            class="countdown-input"
            min="0"
            max="59"
            value="0"
            placeholder="秒"
          />
        </div>
        <div class="countdown-input-buttons">
          <!-- 确认按钮 -->
          <button id="confirmCountdown" class="countdown-input-button">
            确定
          </button>
          <!-- 取消按钮 -->
          <button id="cancelCountdown" class="countdown-input-button">
            取消
          </button>
        </div>
      </div>
    </div>

实现计时逻辑(Go后端)

  • 核心代码
/**
 * @description: 计时器更新循环,每秒执行一次的时间/倒计时更新逻辑
 * 函数作为计时器管理器的核心循环,负责:
 * 1. 每秒更新计时器状态和显示内容
 * 2. 根据当前显示模式(倒计时/时间)执行不同的更新逻辑
 * 3. 处理倒计时结束事件和状态转换
 * 4. 通过Wails事件系统向前端发送更新数据
 *
 * 倒计时模式逻辑:
 * - 运行中:计算剩余时间,倒计时结束时触发timerEnd事件
 * - 暂停中:显示暂停时的剩余时间
 * - 未开始:显示设置的倒计时总时长
 *
 * 时间模式逻辑:
 * - 显示当前系统时间(MM:SS格式)
 *
 * @example
 * // 每秒自动执行,无需手动调用
 * // 倒计时模式:显示"25:00" → "24:59" → ... → "00:00"(触发结束事件)
 * // 时间模式:显示当前时间如"14:30"
 *
 * @see runtime.EventsEmit 发送事件到前端
 * @see time.Since 计算时间间隔
 *
 * @note 使用互斥锁确保线程安全,避免并发访问计时器状态
 * @note 倒计时结束时自动重置状态并发送结束事件
 * @note 时间格式统一为两位数(如"05:09"而非"5:9")
 */
func (tm *TimerManager) updateLoop() {
    for range tm.ticker.C {
        tm.mu.Lock()
        if tm.isTimerRunning {
                tm.timerElapsed = time.Since(tm.timerStartTime)
        }
        switch tm.displayMode {
        case "countdown":
                if tm.isTimerRunning {
                        remaining := tm.countdownDuration - tm.timerElapsed
                        if remaining <= 0 {
                                remaining = 0
                                tm.isTimerRunning = true
                                tm.timerElapsed = tm.countdownDuration
                                tm.isPaused = false
                                runtime.EventsEmit(tm.ctx, "timerEnd")

                                timerText := "00:00"
                                runtime.EventsEmit(tm.ctx, "timerUpdate", timerText)
                        } else {
                                minutes := int(remaining.Minutes())
                                seconds := int(remaining.Seconds()) % 60
                                timerText := fmt.Sprintf("%02d:%02d", minutes, seconds)
                                runtime.EventsEmit(tm.ctx, "timerUpdate", timerText)
                        }

                } else {
                        if tm.isPaused {
                                remaining := tm.countdownDuration - tm.timerElapsed
                                if remaining < 0 {
                                        remaining = 0
                                }
                                minutes := int(remaining.Minutes())
                                seconds := int(remaining.Seconds()) % 60
                                timerText := fmt.Sprintf("%02d:%02d", minutes, seconds)
                                runtime.EventsEmit(tm.ctx, "timerUpdate", timerText)
                        } else {
                                minutes := int(tm.countdownDuration.Minutes())
                                seconds := int(tm.countdownDuration.Seconds()) % 60
                                timerText := fmt.Sprintf("%02d:%02d", minutes, seconds)
                                runtime.EventsEmit(tm.ctx, "timerUpdate", timerText)
                        }
                }

        case "time":
                currentTime := time.Now().Format("15:04")
                runtime.EventsEmit(tm.ctx, "timeUpdate", currentTime)

        }
        tm.mu.Unlock()
}
}
  • 前后端通信
    • 前端负责监听更新事件并渲染
    • 后端负责触发更新事件并发送数据
 // 监听倒计时更新事件
  window.runtime.EventsOn("timerUpdate", (data) => {
    document.getElementById("countdown").textContent = data;
  });
  // 监听时间显示更新事件
  window.runtime.EventsOn("timeUpdate", (data) => {
    document.getElementById("countdown").textContent = data;
  });
if tm.isTimerRunning {
    remaining := tm.countdownDuration - tm.timerElapsed
    if remaining <= 0 {
            remaining = 0
            tm.isTimerRunning = true
            tm.timerElapsed = tm.countdownDuration
            tm.isPaused = false
            runtime.EventsEmit(tm.ctx, "timerEnd")

            timerText := "00:00"
            // 发送事件显示更新事件
            runtime.EventsEmit(tm.ctx, "timerUpdate", timerText)
    } else {
            minutes := int(remaining.Minutes())
            seconds := int(remaining.Seconds()) % 60
            timerText := fmt.Sprintf("%02d:%02d", minutes, seconds)
            // 发送事件显示更新事件
            runtime.EventsEmit(tm.ctx, "timerUpdate", timerText)
    }

📦️ 打包发布

wails build

结尾.jpg

📖 学习资源

👋你还希望有哪些学习搭子?欢迎留言!

❤️如果对你有帮助,别忘了点赞 + 关注!

通用解法:栈+二分查找(Python/Java/C++/Go)

把 $2$ 改成 $k$ 怎么做?

更一般地,每个区间要包含的数字个数各有不同,怎么做?

这题是 2589. 完成所有任务的最少时间我的题解

class Solution:
    def intersectionSizeTwo(self, intervals: List[List[int]]) -> int:
        intervals.sort(key=lambda interval: interval[1])
        # 栈中保存闭区间左右端点,栈底到栈顶的区间长度的和
        st = [(-2, -2, 0)]  # 哨兵,保证不和任何区间相交
        for start, end in intervals:
            _, r, s = st[bisect_left(st, (start,)) - 1]
            d = 2 - (st[-1][2] - s)  # 去掉运行中的时间点
            if start <= r:  # start 在区间 st[i] 内
                d -= r - start + 1  # 去掉运行中的时间点
            if d <= 0:
                continue
            while end - st[-1][1] <= d:  # 剩余的 d 填充区间后缀
                l, r, _ = st.pop()
                d += r - l + 1  # 合并区间
            st.append((end - d + 1, end, st[-1][2] + d))
        return st[-1][2]
class Solution {
    public int intersectionSizeTwo(int[][] intervals) {
        Arrays.sort(intervals, (a, b) -> a[1] - b[1]);
        // 栈中保存闭区间左右端点,栈底到栈顶的区间长度的和
        List<int[]> st = new ArrayList<>();
        st.add(new int[]{-2, -2, 0}); // 哨兵,保证不和任何区间相交
        for (int[] t : intervals) {
            int start = t[0], end = t[1];
            int[] e = st.get(lowerBound(st, start) - 1);
            int d = 2 - (st.get(st.size() - 1)[2] - e[2]); // 去掉运行中的时间点
            if (start <= e[1]) { // start 在区间 st[i] 内
                d -= e[1] - start + 1; // 去掉运行中的时间点
            }
            if (d <= 0) {
                continue;
            }
            while (end - st.get(st.size() - 1)[1] <= d) { // 剩余的 d 填充区间后缀
                e = st.remove(st.size() - 1);
                d += e[1] - e[0] + 1; // 合并区间
            }
            st.add(new int[]{end - d + 1, end, st.get(st.size() - 1)[2] + d});
        }
        return st.get(st.size() - 1)[2];
    }

    // 开区间二分
    // 见 https://www.bilibili.com/video/BV1AP41137w7/
    private int lowerBound(List<int[]> st, int target) {
        int left = -1, right = st.size(); // 开区间 (left, right)
        while (left + 1 < right) { // 区间不为空
            // 循环不变量:
            // st[left] < target
            // st[right] >= target
            int mid = (left + right) >>> 1;
            if (st.get(mid)[0] < target) {
                left = mid; // 范围缩小到 (mid, right)
            } else {
                right = mid; // 范围缩小到 (left, mid)
            }
        }
        return right;
    }
}
class Solution {
public:
    int intersectionSizeTwo(vector<vector<int>>& intervals) {
        ranges::sort(intervals, {}, [](auto& a) { return a[1]; });
        // 栈中保存闭区间左右端点,栈底到栈顶的区间长度的和
        vector<array<int, 3>> st = {{-2, -2, 0}}; // 哨兵,保证不和任何区间相交
        for (auto& t : intervals) {
            int start = t[0], end = t[1];
            auto [_, r, s] = *--ranges::lower_bound(st, start, {}, [](auto& x) { return x[0]; });
            int d = 2 - (st.back()[2] - s); // 去掉运行中的时间点
            if (start <= r) { // start 在区间 st[i] 内
                d -= r - start + 1; // 去掉运行中的时间点
            }
            if (d <= 0) {
                continue;
            }
            while (end - st.back()[1] <= d) { // 剩余的 d 填充区间后缀
                auto [l, r, _] = st.back();
                st.pop_back();
                d += r - l + 1; // 合并区间
            }
            st.push_back({end - d + 1, end, st.back()[2] + d});
        }
        return st.back()[2];
    }
};
func intersectionSizeTwo(intervals [][]int) int {
    slices.SortFunc(intervals, func(a, b []int) int { return a[1] - b[1] })
    // 栈中保存闭区间左右端点,栈底到栈顶的区间长度的和
    type tuple struct{ l, r, s int }
    st := []tuple{{-2, -2, 0}} // 哨兵,保证不和任何区间相交
    for _, p := range intervals {
        start, end := p[0], p[1]
        i := sort.Search(len(st), func(i int) bool { return st[i].l >= start }) - 1
        d := 2 - (st[len(st)-1].s - st[i].s) // 去掉运行中的时间点
        if start <= st[i].r { // start 在区间 st[i] 内
            d -= st[i].r - start + 1 // 去掉运行中的时间点
        }
        if d <= 0 {
            continue
        }
        for end-st[len(st)-1].r <= d { // 剩余的 d 填充区间后缀
            top := st[len(st)-1]
            st = st[:len(st)-1]
            d += top.r - top.l + 1 // 合并区间
        }
        st = append(st, tuple{end - d + 1, end, st[len(st)-1].s + d})
    }
    return st[len(st)-1].s
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n)$,其中 $n$ 是 $\textit{intervals}$ 的长度。
  • 空间复杂度:$\mathcal{O}(n)$。

相似题目

见下面贪心题单的「§2.3 区间选点」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

.sync 修饰符 | vue前端知识

.sync 修饰符

在 Vue 2 中,.sync 修饰符是实现父子组件双向数据绑定的语法糖,特别适合用于弹窗这类需要子组件修改父组件状态的场景。

基本概念

.sync 修饰符,实际上就是将父组件的属性,通过 .sync 修饰符,映射到子组件的 props 属性中,并监听子组件的 props 属性的修改,将修改后的值,通过 $emit 事件发送给父组件,从而实现父子组件的数据同步。

本质是自动为你扩展了一个 v-on 监听器。

<!-- 使用 .sync 的写法 -->
<ChildComponent :visible.sync="dialogVisible" />

<!-- 等效于完整写法 -->
<ChildComponent
    :visible="dialogVisible"
    @update:visible="val => dialogVisible = val"
/>

举例

1. 引入并注册弹窗组件

import MyDialog from "./MyDialog.vue";

export default {
    components: {
        MyDialog,
    },
    data() {
        return {
            showDialog: false,
        };
    },
};

2. 注册点击事件并绑定弹窗组件

<template>
    <div class="parent">
        <button @click="showDialog = true">打开弹窗</button>

        <!-- 使用 .sync 修饰符 -->
        <MyDialog :visible.sync="showDialog" title="示例弹窗" />
    </div>
</template>

3. 子组件:弹窗逻辑

3.1 定义接收的 props
props: {
  visible: {
    type: Boolean,
    default: false
  },
  title: {
    type: String,
    default: '弹窗标题'
  }
},
3.2 使用 v-if 控制弹窗显示
<div class="dialog-overlay" v-if="visible">
    <div class="dialog-content">
        <div class="dialog-header">
            <h3>{{ title }}</h3>
            <button class="close-btn" @click="closeDialog">×</button>
        </div>
        <div class="dialog-body">
            <p>这是一个弹窗内容</p>
            <slot></slot>
            <!-- 支持插槽内容 -->
        </div>
        <div class="dialog-footer">
            <button @click="closeDialog">取消</button>
            <button @click="confirm">确定</button>
        </div>
    </div>
</div>
3.3 关闭弹窗逻辑
methods: {
  closeDialog() {
    // 关键:使用 update:visible 模式触发事件
    this.$emit('update:visible', false);
  },
  confirm() {
    console.log('确认操作');
    this.closeDialog();
  }
},
3.4 监听 visible 变化(可选)
watch: {
  // 监听 visible 变化,处理外部对弹窗的关闭
  visible(newVal) {
    if (!newVal) {
      // 弹窗关闭时的清理操作
    }
  }
}

详细代码

父组件(使用弹窗)
<template>
    <div class="parent">
        <button @click="showDialog = true">打开弹窗</button>

        <!-- 使用.sync修饰符 -->
        <MyDialog :visible.sync="showDialog" title="示例弹窗" />
    </div>
</template>

<script>
    import MyDialog from "./MyDialog.vue";

    export default {
        components: {
            MyDialog,
        },
        data() {
            return {
                showDialog: false,
            };
        },
    };
</script>
子组件弹窗(MyDialog.vue)
<template>
    <div class="dialog-overlay" v-if="visible">
        <div class="dialog-content">
            <div class="dialog-header">
                <h3>{{ title }}</h3>
                <button class="close-btn" @click="closeDialog">×</button>
            </div>
            <div class="dialog-body">
                <p>这是一个弹窗内容</p>
                <slot></slot>
                <!-- 支持插槽内容 -->
            </div>
            <div class="dialog-footer">
                <button @click="closeDialog">取消</button>
                <button @click="confirm">确定</button>
            </div>
        </div>
    </div>
</template>

<script>
    export default {
        props: {
            visible: {
                type: Boolean,
                default: false,
            },
            title: {
                type: String,
                default: "弹窗标题",
            },
        },
        methods: {
            closeDialog() {
                // 关键:使用update:visible模式触发事件
                this.$emit("update:visible", false);
            },
            confirm() {
                // 执行确认操作...
                console.log("确认操作");
                this.closeDialog();
            },
        },
        watch: {
            // 监听visible变化,处理外部对弹窗的关闭
            visible(newVal) {
                if (!newVal) {
                    // 弹窗关闭时的清理操作
                }
            },
        },
    };
</script>

<style scoped>
    // 样式
</style>

总结

  1. .sync 的作用

    • 实现父子组件双向绑定,简化代码。
    • 特别适合弹窗等需要频繁切换显示状态的场景。
  2. 核心逻辑

    • 父组件通过 .sync 将状态传递给子组件。
    • 子组件通过 $emit('update:visible', value) 修改父组件的状态。
  3. 可选功能

    • 如果需要在弹窗关闭时执行清理操作,可以使用 watch 监听 visible 的变化。
  4. 使用场景

    • 弹窗显示/隐藏控制

    • 表单编辑对话框

    • 确认对话框

    • 设置面板

    • 任何需要子组件修改父组件状态的场景

覆盖原始uniapp按钮样式

在 UniApp 中,  组件自带默认样式,不同平台(如微信小程序、H5、App)可能有所不同。以下是几种方法来清除默认样式并实现完全自定义。

方法 1:使用 plain 和 hover-class="none"

这种方法适用于快速清除基础样式。

    <button plain hover-class="none">无样式按钮</button>
  • plain: 去掉背景色和边框(适用于小程序和 H5)。
  • hover-class="none" : 禁用点击态效果(如背景色变化)。

方法 2:通过 CSS 重置样式

通过全局或局部 CSS 清除默认样式。


button {
margin: 0;
padding: 0;
border: none;
background: none;
line-height: 1;
font-size: inherit;
color: inherit;
border-radius: 0/* 清除圆角 */
-webkit-appearance: none; /* App 特有 */

}

/* 禁用点击态效果 */
button::after {
display: none;
}

方法 3:自定义类覆盖默认样式

为按钮添加自定义类,并使用  !important 覆盖默认样式。

custom-btn {
background: none !important;
border: none !important;
padding: 0 !important;
margin: 0 !important;
    
/\* 自定义样式 \*/
color: #333;
font-size: 14px;
}
custom-btn::after {
border: none !important; /\* 去掉小程序的 ::after 边框 \*/
}

注意事项

  1. 优先级问题:UniApp 默认样式优先级较高,必要时使用  !important
  2. 平台差异: 微信小程序:需额外处理 ::after 边框。 H5:可直接使用 all: unset。 App:需重置 -webkit-appearance。
  3. 测试兼容性:在目标平台(微信小程序、H5、App)分别测试效果。

通过以上方法,可以彻底清除 UniApp 中<button>的默认样式并实现完全自定义设计。

深入浅出 Async/Await:让异步编程更优雅

深入浅出 Async/Await:让异步编程更优雅

在当今的前端开发中,异步编程是每个开发者必须掌握的核心技能。从最早的 Callback Hell(回调地狱)到 Promise 链式调用,再到如今的 Async/Await,JavaScript 的异步处理方案不断演进,让代码变得更加清晰和易于维护。本文将带你深入理解 Async/Await 的工作原理和实际应用。

异步编程的演进历程

从回调函数到 Promise

在 ES6 之前,JavaScript 主要依靠回调函数来处理异步操作。但随着业务逻辑复杂度的增加,回调地狱问题日益突出:

getData(function(a) {
    getMoreData(a, function(b) {
        getMoreData(b, function(c) {
            // 更多的嵌套...
        });
    });
});

ES6 引入的 Promise 为我们提供了更优雅的解决方案:

fetch('https://api.github.com/users/shunwuyu/repos')
    .then(res => res.json())
    .then(data => {
        console.log(data);
    })
    .catch(error => {
        console.error('Error:', error);
    });

Promise 通过链式调用解决了回调地狱的问题,但then的链式调用仍然不够直观。

Async/Await 的诞生

ES8(ES2017)引入了 Async/Await,它可以说是目前 JavaScript 中最优雅的异步处理方案。Async/Await 基于 Promise,但使用同步的写法来处理异步操作,让代码更加清晰易懂。

Async/Await 基础用法

基本语法

让我们先来看一个简单的例子:

const main = async () => {
    const res = await fetch('https://api.github.com/users/shunwuyu/repos');
    console.log(111);
    const data = await res.json();
    console.log(data);
}

main();
console.log(222222);

在这段代码中,我们定义了一个异步函数 main,使用 async关键字修饰。函数内部使用 await关键字来等待 Promise 的解决。

执行顺序分析

理解 Async/Await 的执行顺序至关重要:

  1. 调用 main()函数,由于它是异步函数,不会阻塞后续代码执行
  2. 立即执行 console.log(222222)
  3. main函数内部,遇到第一个 await时,会暂停函数执行,等待 Promise 解决
  4. 当 Promise 解决后,继续执行后续代码

因此,上面的代码输出顺序将是:

222222
111
[数据内容]

Async/Await 工作原理深度解析

Async 函数的本质

当我们用 async关键字声明一个函数时,这个函数会始终返回一个 Promise 对象:

async function foo() {
    return "Hello";
}

// 等价于
function foo() {
    return Promise.resolve("Hello");
}

即使函数内部没有显式返回 Promise,async 函数也会将返回值包装成 Promise。

Await 的魔法

await关键字只能用在 async函数内部,它的作用是:

  1. 暂停 async 函数的执行
  2. 等待 Promise 解决
  3. 返回 Promise 解决的值
async function example() {
    // 等待 fetch 返回的 Promise 解决
    const response = await fetch('/api/data');
    
    // 等待 response.json() 返回的 Promise 解决
    const data = await response.json();
    
    return data;
}

错误处理机制

Async/Await 提供了更直观的错误处理方式,我们可以使用传统的 try/catch 语法:

async function fetchData() {
    try {
        const response = await fetch('https://api.github.com/users/shunwuyu/repos');
        if (!response.ok) {
            throw new Error('Network response was not ok');
        }
        const data = await response.json();
        console.log(data);
    } catch (error) {
        console.error('Fetch failed:', error);
    }
}

这种方式比 Promise 的链式 catch 更加直观,特别是当有多个异步操作时。

实战应用与最佳实践

并行执行优化

当有多个独立的异步操作时,我们可以使用 Promise.all来并行执行,提高效率:

async function fetchAllData() {
    try {
        const [userRepos, userInfo, followers] = await Promise.all([
            fetch('https://api.github.com/users/shunwuyu/repos').then(res => res.json()),
            fetch('https://api.github.com/users/shunwuyu').then(res => res.json()),
            fetch('https://api.github.com/users/shunwuyu/followers').then(res => res.json())
        ]);
        
        console.log('Repos:', userRepos);
        console.log('User Info:', userInfo);
        console.log('Followers:', followers);
    } catch (error) {
        console.error('Error fetching data:', error);
    }
}

在实际项目中的应用

在实际的前端项目中,Async/Await 可以大大简化代码逻辑。以下是一个用户登录流程的示例:

class AuthService {
    async login(username, password) {
        try {
            // 显示加载状态
            this.setLoading(true);
            
            // 验证输入
            if (!this.validateInput(username, password)) {
                throw new Error('Invalid input');
            }
            
            // 发送登录请求
            const response = await fetch('/api/login', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({ username, password })
            });
            
            if (!response.ok) {
                throw new Error(`Login failed with status: ${response.status}`);
            }
            
            const userData = await response.json();
            
            // 保存用户信息
            this.saveUserData(userData);
            
            // 跳转到首页
            this.redirectToDashboard();
            
            return userData;
        } catch (error) {
            console.error('Login error:', error);
            this.showError(error.message);
            throw error;
        } finally {
            this.setLoading(false);
        }
    }
}

常见问题与解决方案

循环中的 Async/Await

在循环中使用 Async/Await 需要特别注意执行顺序:

// 顺序执行 - 一个接一个
async function processArraySequentially(array) {
    for (const item of array) {
        await processItem(item);
    }
}

// 并行执行 - 同时进行
async function processArrayInParallel(array) {
    const promises = array.map(item => processItem(item));
    await Promise.all(promises);
}

避免常见的陷阱

  1. 不要在非 async 函数中使用 await
// 错误
function regularFunction() {
    await someAsyncFunction(); // SyntaxError
}

// 正确
async function asyncFunction() {
    await someAsyncFunction();
}
  1. 合理处理并发
// 低效 - 顺序执行
async function inefficient() {
    const a = await task1();
    const b = await task2(); // 等待 task1 完成才开始
    return a + b;
}

// 高效 - 并行执行
async function efficient() {
    const [a, b] = await Promise.all([task1(), task2()]);
    return a + b;
}

总结

Async/Await 是 JavaScript 异步编程的重大进步,它让我们能够以同步的方式编写异步代码,大大提高了代码的可读性和可维护性。通过本文的学习,你应该已经掌握了:

  1. Async/Await 的基本用法和工作原理
  2. 如何在实际项目中有效应用 Async/Await
  3. 常见的陷阱和最佳实践
  4. 错误处理和性能优化技巧

随着 JavaScript 语言的不断发展,异步编程的模式也在持续演进。掌握 Async/Await 不仅有助于你编写更高质量的代码,也为学习更先进的异步模式(如 Top-level Await)打下了坚实基础。 希望本文能帮助你在异步编程的道路上更进一步,写出更加优雅和高效的 JavaScript 代码!

前端开发者也能玩转大模型:使用HTTP请求调用DeepSeek全记录

无需后端支持,纯前端技术栈也能集成人工智能大模型

作为一名前端开发者,我最近探索了一个有趣的技术方案:如何在前端项目中直接调用大型语言模型。在这个过程中,我发现了不少值得分享的经验和技巧,今天就带大家一步步实现前端调用DeepSeek大模型的全过程。

项目背景与初衷

传统上,调用AI大模型通常需要后端服务的支持,前端通过API与自己的服务器交互,再由服务器调用AI服务。这样做主要是出于安全考虑,特别是为了保护API密钥。但有时候,我们只是想快速原型验证,或者构建简单的个人项目,这时候如果能直接从前端调用大模型,会大大简化开发流程。

我决定尝试使用纯前端技术栈调用DeepSeek大模型,并记录下整个过程。

项目初始化:从零搭建

为了简化开发流程,我使用了 Trae(一种 AI 辅助开发工具)帮我快速初始化了一个基于 Vite 的前端项目。V

选择构建工具

我选择了Vite作为项目构建工具,它不仅速度快,而且内置了丰富的功能,特别是对环境变量的支持,这对保护API密钥至关重要。

npm create vite@latest frontend-llm-demo
cd frontend-llm-demo
npm install

项目结构设计

保持简洁的项目结构:

frontend-llm-demo/
├── index.html
├── styles/
│   └── style.css
├── scripts/
│   └── app.js
└── .env.local

其中,index.html 是主页面,app.js 负责发起 API 请求,而 .env.local 将用于存放敏感的 API Key。

核心实现:HTTP请求调用LLM

构建API请求

app.js中,我实现了完整的API调用逻辑:

// DeepSeek API端点
const endpoint = 'https://api.deepseek.com/chat/completions'

// 设置请求头
const headers = {
  'Content-Type': 'application/json',
  'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
}

// 构建请求体
const payload = {
  model: 'deepseek-chat',
  messages: [
    {
      role: 'system',
      content: 'You are a helpful assistant.'
    },
    {
      role: 'user',
      content: '你好 DeepSeek'
    }
  ]
}

// 发送请求并处理响应
const response = await fetch(endpoint, {
  method: 'POST',
  headers: headers,
  body: JSON.stringify(payload)
})

const data = await response.json()
console.log(data)

// 将模型回复动态挂载到页面
document.getElementById('reply').textContent = data.choices[0].message.content

关键技术点解析

1. 请求方法选择

我使用了POST而非GET方法,原因有二:

  • POST请求更安全,参数不会暴露在URL中
  • 我们需要传递请求体,GET请求通常不包含请求体 而 LLM 调用需要传递复杂的 messages 结构,必须使用 POST

2. 请求头设置

请求头包含了两个关键信息:

  • Content-Type: application/json - 告诉服务器我们发送的是JSON格式数据
  • Authorization: Bearer ... - 用于身份验证的API密钥

3. 请求体构建

请求体是一个JSON对象,包含:

  • model - 指定要使用的模型版本
  • messages - 对话消息数组,包含角色和内容

4. 数据序列化

使用JSON.stringify()将JavaScript对象转换为JSON字符串,因为HTTP协议只能传输文本或二进制数据,不能直接传输对象。

安全考虑:保护API密钥

环境变量的重要性

直接将API密钥硬编码在前端代码中是极其危险的,任何用户都可以查看源代码获取密钥。为了解决这个问题,我使用了环境变量。

Vite环境变量配置

Vite使用特殊的import.meta.env对象来访问环境变量。以VITE_开头的变量会被嵌入到客户端代码中。

  1. 创建.env文件:
VITE_DEEPSEEK_API_KEY=your_api_key_here
  1. 在代码中访问:
const apiKey = import.meta.env.VITE_DEEPSEEK_API_KEY

注意:即使使用环境变量,前端代码中的API密钥仍然可能被有心人获取。对于生产环境,最佳实践仍然是使用后端服务作为代理。

异步处理:从Promise到async/await

传统的Promise链式调用

fetch(endpoint, options)
  .then(response => response.json())
  .then(data => {
    // 处理数据
  })
  .catch(error => {
    // 错误处理
  })

现代的async/await

我选择了async/await语法,因为它更简洁、更易读:

try {
  const response = await fetch(endpoint, options)
  const data = await response.json()
  // 处理数据
} catch (error) {
  // 错误处理
}

结果展示:动态更新DOM

获取到API响应后,需要将结果显示在页面上:

document.getElementById('reply').textContent = data.choices[0].message.content

这里使用了直接操作DOM的方式,在现代前端框架流行的今天,这种原生方法依然简单有效。

完整HTML结构

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="color-scheme" content="light dark">
  <title>前端调用大模型示例</title>
  <link rel="stylesheet" href="styles/style.css">
</head>
<body>
  <h1>Hello DeepSeek</h1>
  <div id="reply"></div>
  <script type="module" src="./scripts/app.js"></script>
</body>
</html>

开发心得与总结

通过这个项目,我获得了以下几点经验:

1. 前端调用LLM的可行性

事实证明,前端直接调用大模型API是完全可行的,这为快速原型开发和个人项目提供了便利。但需要注意安全性问题,不建议在生产环境中直接暴露API密钥。

2. HTTP请求的细节重要性

调用外部API时,请求的每个部分都很重要:

  • 正确的端点URL
  • 恰当的HTTP方法
  • 必要的请求头
  • 正确格式化的请求体

3. 现代JavaScript特性的价值

ES6+的特性让代码更加简洁:

  • 模板字符串
  • 箭头函数
  • 解构赋值
  • async/await

4. 构建工具的重要性

Vite等现代构建工具不仅提供开发服务器和打包功能,还解决了环境变量、模块化等工程化问题。

5. 安全意识的培养

即使是在个人项目中,也应该养成良好的安全习惯,使用环境变量管理敏感信息。

结语

前端技术日新月异,如今我们甚至可以直接在前端调用强大的人工智能模型。这个项目虽然简单,但展示了前端开发的强大能力和无限可能性。作为前端开发者,我们不应该将自己局限在传统的界面开发中,而应该积极探索前端技术的边界。

希望这篇文章能为想要在前端项目中集成AI能力的开发者提供一些参考和启发。前端的世界很精彩,让我们一起探索更多可能性!


注意:本文示例仅适用于开发和测试环境,生产环境中请务必通过后端服务调用第三方API,确保API密钥的安全性。

❌