阅读视图

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

苹果新品连发一个月?打头阵的 M5 MacBook Pro 马上就来

今天凌晨,苹果全球营销高级副总裁 Greg Joswiak 发布了一条推文,正式官宣了苹果新品即将发布:

五个「m」,加上动图里的「V」字笔记本剪影,就差把「M5 MacBook Pro」几个字公之于众了。

不过苹果的新品可不止这一款,有爆料称,接下来苹果将开启「周更」模式,一周推出一款新品,持续四周。

第一周:M5 MacBook Pro,但只有 M5

根据之前的爆料,搭载 M5 系列处理器的 MacBook Pro 原本定于明年年初进行发布,不过苹果已经改变了计划,将在本周内发布 M5 MacBook Pro。

但很大概率我们在今年只会见到 M5 基础款的 MacBook Pro 新品,M5 Pro、M5 Max 版本依旧要等到明年年初上市。

发布节奏的变更,一部分原因可能是台积电产量跟不上,也有可能是因为苹果想要提振基础款 MacBook Pro 的存在感——在两位大哥的衬托下,基础款 MacBook Pro 几乎年年都是「小透明」,讨论度和销量都不高。

M5 芯片采用 A19 Pro 同款台积电 3nm 制程 N3P 节点打造(另有说法是前代的 N3E),MacBook Pro 上的 M5 大概率会是 10 CPU + 10 GPU 的配置。

根据此前全球首曝 M5 iPad Pro 的跑分来看,M5 对比 M4 在 GPU 上的提升更显著,CPU 则有 15% 左右的性能提升。

除了 CPU 和 GPU 的提升,有爆料称 M5 在能耗表现上也会有 10% 的提升,并将配备更先进的神经网络引擎,提升提高带有 AI 类功能应用的体验,比如修图软件、游戏插帧,以及 Apple 智能。

M5 MacBook Pro 预计也只是一次单纯的「换芯」升级,继续沿用当前的模具,不过从 Joswiak 官宣新品的动图来看,MacBook Pro 很可能会和这几年的苹果新品一样获得新的蓝色配色,可能会更接近 iPhone 17 Pro 那种更深邃的蓝色。

还有一些预测,认为 M5 MacBook Pro 可能会换用苹果全新自研的 N1 通讯芯片,支持蓝牙 6 和 Wi-Fi 7。越多元器件自主化,意味着利润空间越大。

总体来说,今年的 MacBook Pro 还是一次常规更新,而明年年底发布的 M6 MacBook Pro,很有可能会是近年来苹果笔记本电脑产品线的一次重大变革:换用无刘海可触控 OLED 屏幕,机身将更轻薄,并有可能支持 5G 蜂窝网络。

和 M5 MacBook Pro 一同发布的还有苹果全新的 Studio Display,将采用 mini-LED 显示,搭载 A19 Pro 处理器,可能会搭载一个轻量级的智能家居系统。

除了 M5 MacBook Pro,传闻已久的「廉价版」A 系列芯片 MacBook 也有可能会一同发布,将采用 A18 Pro/A19 Pro 处理器,采用 13 寸 MacBook Air 同款模具,价格 699 美元,折合人民币还不到 5000 元。

第二周:只差正式开卖的 M5 iPad Pro

由于俄罗斯博主 Wylsacom 已经偷跑开箱全新 iPad Pro,这款产品的外观和芯片信息已经处于公开状态,就等着苹果发售了。

▲ 图源:YouTube @Wylsacom

目前,M4 iPad Pro 一些型号在美国官网的缺货情况尤为明显,这一般来说就是迭代新品即将发布的信号。美国运营商 AT&T 官网甚至已经有了「iPad Pro M5 预购即将开启」的相关页面。

从外观上看,M5 iPad Pro 和上一代基本一致,延续了上一代的超薄铝金属机身和单摄像头,正面并没有传闻中的窄边框设计。

▲ 图源:YouTube @Wylsacom

iPad Pro 上的 M5 芯片拥有 9 核心 CPU,与上一代保持一致,Benchmark 单核跑分 4133,多核 15437,对比 M4 分别约有 10% 和 16% 的提升。

而 GPU 方面,M5 挤了一大管牙膏:Benchmark Metal 跑分 74568,对比前代 55702 大幅上涨了 34% 左右。

▲ 图源:YouTube @Wylsacom

除了 Benchmark,Wylsacom 也用 M5 iPad Pro 运行了安兔兔,跑分为 3137936 分,比前代高出了 8%。

偷跑的 iPad Pro 为 256GB 版本,Benchmark 显示配备运行内存 12GB,作为对比,M4 iPad Pro 的 256GB 和 512GB 配备了 8GB 运存。

视频没有公布 iPad Pro 的其他方面升级,目前还不清楚新 iPad Pro 是否如传闻中配备「双前置摄像头」设计。

虽然今年恰逢 iPad Pro 诞生十周年,但新的 M5 iPad Pro 在硬件上更多只是一次常规迭代,称不上「iPad Pro X」。

比起硬件参数,iPadOS 26 对于 iPad Pro 来说意义更加重大,虽然 iPad 依旧没能成为真正意义上的「电脑」,但比以往更具有生产力潜力。

第三周:新款 Vision Pro,但不是 Vision Pro 2

MacBook Pro 和 iPad Pro 不是唯一的 M5 新品,Vision Pro 头显也会用上这颗强大的处理器,取代目前的 M2。

有消息称,Vision Pro 将采用的是去年的 M4 芯片,但不管是哪一颗,对于目前的 Vision Pro 来说都算绰绰有余。

美国联邦通信委员会(FCC)最近公布的文件中,有一份提到了一款苹果设计的「头戴式设备」,配图基本证实了是 Vision Pro 新品。

但文件本身除了无线和传输测试结果外,几乎没有提供任何信息。

这个新品并非真正的「Vision Pro 2」,除了处理器,头显的其他硬件配置以及设计都不会进行更新,不过很可能会配备新的头带来改善佩戴体验,以及全新「深空黑」配色。

▲ 深空黑 Vision Pro 假想图

这个小改款也传递出一个信号:Vision Pro 2 将是一次变化非常大的迭代,但可能在两年内都不会问世。彭博社爆料,苹果已经基本暂停了所有 Vision 头显的开发,全力打造智能眼镜新品。

第四周:Apple TV 4K & HomePod mini

苹果在本周将自家的电视流媒体服务「Apple TV+」改名为「Apple TV」,并更新了应用图标,似乎也正是在为新款 Apple TV 盒子铺路。

全新的 Apple TV 4K 的也同样聚焦在性能上:从目前的 iPhone 13 同款 A15 芯片一跃到 A17 Pro,很可能是 iPad mini 同款,也就是比 iPhone 15 Pro 少一个 GPU 核心的版本。

这同样也很可能意味着,Apple TV 将获得 Apple 智能的支持。

A17 Pro 的强大性能,特别是图形方面更上一层楼的处理能力,很可能也将为 Apple TV 带来更多大型游戏的支持,尤其是那些有性能需求的 iOS 移植 3A 大作,让机顶盒直接变身游戏主机——相较手机来说,Apple TV 确实是更合适的游戏场景。

除了 A17 Pro,新款 Apple TV 还可能会搭载 iPhone Air 上初次亮相的蓝牙和 Wi-Fi 芯片 N1。

有网友猜测,既然流媒体改名为 Apple TV,那原本的「Apple TV」盒子,说不定会改名为「Apple Box」?

HomePod mini 也是一次常规的更新:从目前的 S5 芯片升级到 Apple Watch S9 同款的 S9 芯片,以及 N1 芯片,将支持 Wi-Fi 6E。

有消息称,苹果也将会增强新款 HomePod mini 的 Wi-Fi 连接性能,大幅减少目前产品上的延迟问题。

▲ 图源:Forbes

苹果正在进一步加强对元器件的把控力,而诸如 Apple TV、HomePod mini 这样的非核心产品线,正是一片绝佳的试验田。

结合此前关于苹果桌面机器人的爆料来看,或许在苹果未来的产品版图中,HomePod 这条产品线将会获得更加核心的位置。

还有一个 AirTag 2

从去年年底开始,几乎每一次苹果新品集体爆料都会提到 AirTag 2,但至今仍未发布,今年 10 月也是一个非常适合的时机。

AirTag 2 将采用第二代超宽带芯片,和当前的版本相比,预计将会提升 3 倍的跟踪距离。作为参考,搭载这个芯片的 iPhone 15 可以在 60 米范围内找到朋友。

由于第一代产品的安全争议,AirTag 2 也会加强内置扬声器的拆卸难度,以阻止不法分子改装后用于悄无声息地跟踪他人。

也有新的传言称,AirTag 2 将改进与 Apple Vision Pro 的集成,成为空间计算生态系统的一部分,但目前还没有具体的细节,很可能将会是利用 Vision Pro 的空间显示效果,在现实世界画面中直接标出 AirTag 的位置。

比起 9 月款款都可圈可点的新 iPhone,10 月份这波苹果新品要平淡不少,大部分都只是换芯升级。

特别是 MacBook Pro 和智能家居新品,基本可以确定明年苹果都有大动作,今年更像是过渡的一年。

你最期待上面哪一款苹果新品?欢迎在留言区告诉爱范儿,我们将会第一时间上手评测这些新品,敬请期待。

题图来自 Tech Radar

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


🎨 SCSS 高级用法完全指南:从入门到精通

🚀 想让 CSS 写得更爽?本文手把手教你 SCSS 的各种实用技巧,让你的样式代码又好写又好管理!

📚 目录


为了实时查看,我这边使用工程化来练习:

企业微信截图_17604966056743.png

1. 变量与作用域

1.1 局部变量与全局变量

// 全局变量
$primary-color: #3498db;

.container {
  // 局部变量
  $padding: 20px;
  padding: $padding;

  .item {
    // 可以访问父级局部变量
    margin: $padding / 2;
    color: $primary-color;
  }
}

// $padding 在这里不可用

1.2 !global 标志

.element {
  $local-var: 10px;

  @if true {
    // 使用 !global 将局部变量提升为全局
    $local-var: 20px !global;
  }
}

// 现在可以在外部访问
.another {
  padding: $local-var; // 20px
}

1.3 !default 标志

// 设置默认值,如果变量已存在则不覆盖
$base-font-size: 16px !default;
$primary-color: #333 !default;

// 这在创建主题或库时非常有用

1.4 Map 变量

// 定义颜色系统
$colors: (
  primary: #3498db,
  secondary: #2ecc71,
  danger: #e74c3c,
  warning: #f39c12,
  info: #9b59b6,
);

// 使用 map-get 获取值
.button {
  background: map-get($colors, primary);

  &.danger {
    background: map-get($colors, danger);
  }
}

// 深层嵌套的 Map
$theme: (
  colors: (
    light: (
      bg: #ffffff,
      text: #333333,
    ),
    dark: (
      bg: #1a1a1a,
      text: #ffffff,
    ),
  ),
  spacing: (
    small: 8px,
    medium: 16px,
    large: 24px,
  ),
);

// 获取深层值
.dark-mode {
  background: map-get(map-get(map-get($theme, colors), dark), bg);
}

2. 嵌套与父选择器

2.1 父选择器 & 的高级用法

// BEM 命名法
.card {
  padding: 20px;

  &__header {
    font-size: 18px;
  }

  &__body {
    margin: 10px 0;
  }

  &--featured {
    border: 2px solid gold;
  }

  // 伪类
  &:hover {
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
  }

  // 父选择器在后面
  .dark-theme & {
    background: #333;
  }
}

2.2 嵌套属性

.button {
  // 嵌套属性值
  font: {
    family: 'Helvetica', sans-serif;
    size: 14px;
    weight: bold;
  }

  border: {
    top: 1px solid #ccc;
    bottom: 2px solid #999;
    radius: 4px;
  }

  transition: {
    property: all;
    duration: 0.3s;
    timing-function: ease-in-out;
  }
}

2.3 @at-root 跳出嵌套

.parent {
  color: blue;

  @at-root .child {
    // 这会在根级别生成 .child 而不是 .parent .child
    color: red;
  }

  @at-root {
    .sibling-1 {
      color: green;
    }
    .sibling-2 {
      color: yellow;
    }
  }
}

3. Mixins 高级技巧

3.1 带参数的 Mixin

// 基础 Mixin
@mixin flex-center($direction: row) {
  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: $direction;
}

// 使用
.container {
  @include flex-center(column);
}

3.2 可变参数 (...)

// 接收任意数量的参数
@mixin box-shadow($shadows...) {
  -webkit-box-shadow: $shadows;
  -moz-box-shadow: $shadows;
  box-shadow: $shadows;
}

// 使用
.card {
  @include box-shadow(0 2px 4px rgba(0, 0, 0, 0.1), 0 4px 8px rgba(0, 0, 0, 0.05));
}

// 传递多个值
@mixin transition($properties...) {
  transition: $properties;
}

.button {
  @include transition(background 0.3s ease, transform 0.2s ease-out);
}

3.3 @content 指令

// 响应式 Mixin
@mixin respond-to($breakpoint) {
  @if $breakpoint == 'mobile' {
    @media (max-width: 767px) {
      @content;
    }
  } @else if $breakpoint == 'tablet' {
    @media (min-width: 768px) and (max-width: 1023px) {
      @content;
    }
  } @else if $breakpoint == 'desktop' {
    @media (min-width: 1024px) {
      @content;
    }
  }
}

// 使用
.sidebar {
  width: 300px;

  @include respond-to('mobile') {
    width: 100%;
    display: none;
  }

  @include respond-to('tablet') {
    width: 200px;
  }
}

3.4 高级响应式 Mixin

$breakpoints: (
  xs: 0,
  sm: 576px,
  md: 768px,
  lg: 992px,
  xl: 1200px,
  xxl: 1400px,
);

@mixin media-breakpoint-up($name) {
  $min: map-get($breakpoints, $name);
  @if $min {
    @media (min-width: $min) {
      @content;
    }
  } @else {
    @content;
  }
}

@mixin media-breakpoint-down($name) {
  $max: map-get($breakpoints, $name) - 1px;
  @if $max {
    @media (max-width: $max) {
      @content;
    }
  }
}

// 使用
.container {
  padding: 15px;

  @include media-breakpoint-up(md) {
    padding: 30px;
  }

  @include media-breakpoint-up(lg) {
    padding: 45px;
  }
}

3.5 主题切换 Mixin

@mixin theme($theme-name) {
  @if $theme-name == 'light' {
    background: #ffffff;
    color: #333333;
  } @else if $theme-name == 'dark' {
    background: #1a1a1a;
    color: #ffffff;
  }
}


// 更灵活的主题系统
$themes: (
  light: (
    bg: #ffffff,
    text: #333333,
    primary: #3498db,
  ),
  dark: (
    bg: #1a1a1a,
    text: #ffffff,
    primary: #5dade2,
  ),
);

@mixin themed() {
  @each $theme, $map in $themes {
    .theme-#{$theme} & {
      $theme-map: $map !global;
      @content;
      $theme-map: null !global;
    }
  }
}

@function t($key) {
  @return map-get($theme-map, $key);
}

// 使用
.card {
  @include themed() {
    background: t(bg);
    color: t(text);
    border-color: t(primary);
  }
}

4. 函数的妙用

4.1 自定义函数

// 计算 rem
@function rem($pixels, $base: 16px) {
  @return ($pixels / $base) * 1rem;
}

.title {
  font-size: rem(24px); // 1.5rem
  margin-bottom: rem(16px); // 1rem
}

4.2 颜色操作函数

// 创建颜色变体
@function tint($color, $percentage) {
  @return mix(white, $color, $percentage);
}

@function shade($color, $percentage) {
  @return mix(black, $color, $percentage);
}

$primary: #3498db;

.button {
  background: $primary;

  &:hover {
    background: shade($primary, 20%);
  }

  &.light {
    background: tint($primary, 30%);
  }
}

4.3 字符串操作

@function str-replace($string, $search, $replace: '') {
  $index: str-index($string, $search);

  @if $index {
    @return str-slice($string, 1, $index - 1) + $replace + str-replace(str-slice($string, $index +
            str-length($search)), $search, $replace);
  }

  @return $string;
}

// 使用
$font-family: str-replace('Arial, sans-serif', 'Arial', 'Helvetica');

4.4 深度获取 Map 值

@function deep-map-get($map, $keys...) {
  @each $key in $keys {
    $map: map-get($map, $key);
  }
  @return $map;
}

$config: (
  theme: (
    colors: (
      primary: (
        base: #3498db,
        light: #5dade2,
      ),
    ),
  ),
);
    
.element {
  color: deep-map-get($config, theme, colors, primary, base);
}

5. 继承与占位符

5.1 基础继承

.message {
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.success-message {
  @extend .message;
  border-color: #2ecc71;
  background: #d5f4e6;
}

.error-message {
  @extend .message;
  border-color: #e74c3c;
  background: #fadbd8;
}

5.2 占位符选择器 %

// 占位符不会单独生成 CSS
%flex-center {
  display: flex;
  justify-content: center;
  align-items: center;
}

%text-truncate {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.card-title {
  @extend %text-truncate;
  font-size: 18px;
}

.modal {
  @extend %flex-center;
  min-height: 100vh;
}

5.3 多重继承

%bordered {
  border: 1px solid #ddd;
}

%rounded {
  border-radius: 8px;
}

%shadowed {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.card {
  @extend %bordered;
  @extend %rounded;
  @extend %shadowed;
  padding: 20px;
}

6. 控制指令

6.1 @if / @else

@mixin theme-color($theme) {
  @if $theme == 'light' {
    background: white;
    color: black;
  } @else if $theme == 'dark' {
    background: black;
    color: white;
  } @else {
    background: gray;
    color: white;
  }
}

.app {
  @include theme-color('dark');
}

6.2 @for 循环

// 生成网格系统
@for $i from 1 through 12 {
  .col-#{$i} {
    width: percentage($i / 12);
  }
}

// 生成间距工具类
$spacing: (5, 10, 15, 20, 25, 30);

@for $i from 1 through length($spacing) {
  $space: nth($spacing, $i);

  .m-#{$space} {
    margin: #{$space}px;
  }
  .p-#{$space} {
    padding: #{$space}px;
  }
  .mt-#{$space} {
    margin-top: #{$space}px;
  }
  .pt-#{$space} {
    padding-top: #{$space}px;
  }
  .mb-#{$space} {
    margin-bottom: #{$space}px;
  }
  .pb-#{$space} {
    padding-bottom: #{$space}px;
  }
}

6.3 @each 循环

// 遍历列表
$colors: primary, secondary, success, danger, warning, info;

@each $color in $colors {
  .btn-#{$color} {
    background: var(--#{$color}-color);
  }
}

// 遍历 Map
$social-colors: (
  facebook: #3b5998,
  twitter: #1da1f2,
  instagram: #e4405f,
  linkedin: #0077b5,
  youtube: #ff0000,
);

@each $name, $color in $social-colors {
  .btn-#{$name} {
    background-color: $color;

    &:hover {
      background-color: darken($color, 10%);
    }
  }
}

// 多重值遍历
$sizes: (small, 12px, 500, medium, 14px, 600, large, 16px, 700);

@each $size, $font-size, $font-weight in $sizes {
  .text-#{$size} {
    font-size: $font-size;
    font-weight: $font-weight;
  }
}

6.4 @while 循环

// 生成渐进式字体大小
$i: 6;
@while $i > 0 {
  h#{$i} {
    font-size: 2em - ($i * 0.2);
  }
  $i: $i - 1;
}

7. 模块化系统

7.1 @use 和 @forward

// _variables.scss
$primary-color: #3498db;
$secondary-color: #2ecc71;

// _mixins.scss
@mixin flex-center {
  display: flex;
  justify-content: center;
  align-items: center;
}

// _functions.scss
@function rem($px) {
  @return ($px / 16px) * 1rem;
}

// main.scss - 新的模块系统
@use 'variables' as vars;
@use 'mixins' as mix;
@use 'functions' as fn;

.container {
  @include mix.flex-center;
  color: vars.$primary-color;
  padding: fn.rem(20px);
}

7.2 命名空间

// _config.scss
$primary: #3498db;

@mixin button {
  padding: 10px 20px;
  border-radius: 4px;
}

// styles.scss
@use 'config' as cfg;

.btn {
  @include cfg.button;
  background: cfg.$primary;
}

// 或者移除命名空间前缀
@use 'config' as *;

.btn {
  @include button;
  background: $primary;
}

7.3 @forward 创建索引文件

// styles/_index.scss
@forward 'variables';
@forward 'mixins';
@forward 'functions';

// main.scss
@use 'styles';

.element {
  color: styles.$primary-color;
  @include styles.flex-center;
}

8. 内置函数库

8.1 颜色函数

$base-color: #3498db;

.color-demo {
  // 颜色调整
  color: adjust-hue($base-color, 45deg);

  // 亮度
  background: lighten($base-color, 20%);
  border-color: darken($base-color, 15%);

  // 饱和度
  &.vibrant {
    background: saturate($base-color, 30%);
  }

  &.muted {
    background: desaturate($base-color, 20%);
  }

  // 透明度
  box-shadow: 0 2px 8px rgba($base-color, 0.3);
  border: 1px solid transparentize($base-color, 0.5);

  // 混合颜色
  &.mixed {
    background: mix(#3498db, #e74c3c, 50%);
  }

  // 补色
  &.complement {
    background: complement($base-color);
  }
}

8.2 数学函数

.math-demo {
  // 基础运算
  width: percentage(5 / 12); // 41.66667%
  padding: round(13.6px); // 14px
  margin: ceil(10.1px); // 11px
  height: floor(19.9px); // 19px

  // 最大最小值
  font-size: max(14px, 1rem);
  width: min(100%, 1200px);

  // 绝对值
  top: abs(-20px); // 20px

  // 随机数
  opacity: random(100) / 100;
}

8.3 列表函数

$list: 10px 20px 30px 40px;

.list-demo {
  // 获取长度
  $length: length($list); // 4

  // 获取元素
  padding-top: nth($list, 1); // 10px
  padding-right: nth($list, 2); // 20px

  // 索引
  $index: index($list, 20px); // 2

  // 追加
  $new-list: append($list, 50px);

  // 合并
  $merged: join($list, (60px 70px));
}

8.4 Map 函数

$theme: (
  primary: #3498db,
  secondary: #2ecc71,
  danger: #e74c3c,
);

.map-demo {
  // 获取值
  color: map-get($theme, primary);

  // 合并 Map
  $extended: map-merge(
    $theme,
    (
      success: #27ae60,
    )
  );

  // 检查键是否存在
  @if map-has-key($theme, primary) {
    background: map-get($theme, primary);
  }

  // 获取所有键
  $keys: map-keys($theme); // primary, secondary, danger

  // 获取所有值
  $values: map-values($theme);
}

8.5 字符串函数

$text: 'Hello World';

.string-demo {
  // 转大写
  content: to-upper-case($text); // "HELLO WORLD"

  // 转小写
  content: to-lower-case($text); // "hello world"

  // 字符串长度
  $length: str-length($text); // 11

  // 查找索引
  $index: str-index($text, 'World'); // 7

  // 切片
  content: str-slice($text, 1, 5); // "Hello"

  // 插入
  content: str-insert($text, ' Beautiful', 6); // "Hello Beautiful World"

  // 去引号
  font-family: unquote('"Arial"'); // Arial
}

9. 实战技巧

9.1 响应式字体大小

@function strip-unit($value) {
  @return $value / ($value * 0 + 1);
}

@mixin fluid-type($min-vw, $max-vw, $min-font-size, $max-font-size) {
  $u1: unit($min-vw);
  $u2: unit($max-vw);
  $u3: unit($min-font-size);
  $u4: unit($max-font-size);

  @if $u1 == $u2 and $u1 == $u3 and $u1 == $u4 {
    & {
      font-size: $min-font-size;

      @media screen and (min-width: $min-vw) {
        font-size: calc(
          #{$min-font-size} + #{strip-unit($max-font-size - $min-font-size)} *
            ((100vw - #{$min-vw}) / #{strip-unit($max-vw - $min-vw)})
        );
      }

      @media screen and (min-width: $max-vw) {
        font-size: $max-font-size;
      }
    }
  }
}

h1 {
  @include fluid-type(320px, 1200px, 24px, 48px);
}

9.2 深色模式切换

$themes: (
  light: (
    bg: #ffffff,
    text: #333333,
    border: #e0e0e0,
    primary: #3498db,
  ),
  dark: (
    bg: #1a1a1a,
    text: #f0f0f0,
    border: #404040,
    primary: #5dade2,
  ),
);

@mixin themed-component {
  @each $theme-name, $theme-colors in $themes {
    [data-theme='#{$theme-name}'] & {
      $theme-map: $theme-colors !global;
      @content;
      $theme-map: null !global;
    }
  }
}

@function theme-color($key) {
  @return map-get($theme-map, $key);
}

.card {
  @include themed-component {
    background: theme-color(bg);
    color: theme-color(text);
    border: 1px solid theme-color(border);
  }

  &__button {
    @include themed-component {
      background: themed-component {
      background: theme-color(primary);
      color: theme-color(bg);
    }
  }
}

9.3 原子化 CSS 生成器

$spacing-map: (
  0: 0,
  1: 0.25rem,
  2: 0.5rem,
  3: 0.75rem,
  4: 1rem,
  5: 1.25rem,
  6: 1.5rem,
  8: 2rem,
  10: 2.5rem,
  12: 3rem,
  16: 4rem,
  20: 5rem,
);

$directions: (
  '': '',
  't': '-top',
  'r': '-right',
  'b': '-bottom',
  'l': '-left',
  'x': (
    '-left',
    '-right',
  ),
  'y': (
    '-top',
    '-bottom',
  ),
);

@each $size-key, $size-value in $spacing-map {
  @each $dir-key, $dir-value in $directions {
    // Margin
    .m#{$dir-key}-#{$size-key} {
      @if type-of($dir-value) == 'list' {
        @each $d in $dir-value {
          margin#{$d}: $size-value;
        }
      } @else {
        margin#{$dir-value}: $size-value;
      }
    }

    // Padding
    .p#{$dir-key}-#{$size-key} {
      @if type-of($dir-value) == 'list' {
        @each $d in $dir-value {
          padding#{$d}: $size-value;
        }
      } @else {
        padding#{$dir-value}: $size-value;
      }
    }
  }
}

9.4 三角形生成器

@mixin triangle($direction, $size, $color) {
  width: 0;
  height: 0;
  border: $size solid transparent;

  @if $direction == 'up' {
    border-bottom-color: $color;
  } @else if $direction == 'down' {
    border-top-color: $color;
  } @else if $direction == 'left' {
    border-right-color: $color;
  } @else if $direction == 'right' {
    border-left-color: $color;
  }
}

.tooltip {
  position: relative;

  &::after {
    content: '';
    position: absolute;
    top: 100%;
    left: 50%;
    transform: translateX(-50%);
    @include triangle(down, 8px, #333);
  }
}

9.5 网格系统生成器

$grid-columns: 12;
$grid-gutter-width: 30px;
$container-max-widths: (
  sm: 540px,
  md: 720px,
  lg: 960px,
  xl: 1140px,
  xxl: 1320px,
);

@mixin make-container($padding-x: $grid-gutter-width / 2) {
  width: 100%;
  padding-right: $padding-x;
  padding-left: $padding-x;
  margin-right: auto;
  margin-left: auto;
}

@mixin make-row($gutter: $grid-gutter-width) {
  display: flex;
  flex-wrap: wrap;
  margin-right: -$gutter / 2;
  margin-left: -$gutter / 2;
}

@mixin make-col($size, $columns: $grid-columns) {
  flex: 0 0 auto;
  width: percentage($size / $columns);
  padding-right: $grid-gutter-width / 2;
  padding-left: $grid-gutter-width / 2;
}

.container {
  @include make-container;

  @each $breakpoint, $width in $container-max-widths {
    @include media-breakpoint-up($breakpoint) {
      max-width: $width;
    }
  }
}
.row {
  @include make-row;
}

@for $i from 1 through $grid-columns {
  .col-#{$i} {
    @include make-col($i);
  }
}

9.6 长阴影效果

@function long-shadow($length, $color, $opacity) {
  $shadow: '';

  @for $i from 0 through $length {
    $shadow: $shadow +
      '#{$i}px #{$i}px rgba(#{red($color)}, #{green($color)}, #{blue($color)}, #{$opacity})';

    @if $i < $length {
      $shadow: $shadow + ', ';
    }
  }

  @return unquote($shadow);
}

.text-shadow {
  text-shadow: long-shadow(50, #000, 0.05);
}

9.7 动画关键帧生成器

@mixin keyframes($name) {
  @keyframes #{$name} {
    @content;
  }
}

@include keyframes(fadeIn) {
  from {
    opacity: 0;
    transform: translateY(-20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.animate-fade {
  animation: fadeIn 0.5s ease-out;
}

9.8 清除浮动

@mixin clearfix {
  &::after {
    content: '';
    display: table;
    clear: both;
  }
}

.container {
  @include clearfix;
}

🎯 总结

SCSS 的高级特性让我们能够:

  1. 提高代码复用性 - 通过 mixin、函数和继承
  2. 增强可维护性 - 使用变量、模块化和命名空间
  3. 提升开发效率 - 利用循环、条件判断自动生成样式
  4. 保持代码整洁 - 嵌套、占位符和模块系统
  5. 创建强大的工具库 - 自定义函数和 mixin 集合

最佳实践建议

  1. 变量命名要语义化

    // Good
    $primary-color: #3498db;
    $spacing-unit: 8px;
    
    // Bad
    $blue: #3498db;
    $var1: 8px;
    
  2. 避免嵌套层级过深(建议不超过 3-4 层)

    // Good
    .card {
      &__header {
      }
      &__body {
      }
    }
    
    // Bad - 嵌套太深
    .card {
      .wrapper {
        .inner {
          .content {
            .text {
            }
          }
        }
      }
    }
    
  3. 优先使用 @use 而不是 @import

// Modern
@use 'variables';
@use 'mixins';

// Legacy
@import 'variables';
@import 'mixins';
  1. 使用占位符代替类继承

    // Good
    %btn-base {
    }
    .btn {
      @extend %btn-base;
    }
    
    // Less optimal
    .btn-base {
    }
    .btn {
      @extend .btn-base;
    }
    
  2. 合理组织文件结构 styles/ ├── abstracts/ │ ├── _variables.scss │ ├── _functions.scss │ └── _mixins.scss ├── base/ │ ├── _reset.scss │ └── _typography.scss ├── components/ │ ├── _buttons.scss │ └── _cards.scss ├── layout/ │ ├── _header.scss │ └── _footer.scss └── main.scss


📚 参考资源


如果这篇文章对你有帮助,欢迎点赞收藏! 👍

有任何问题或补充,欢迎在评论区讨论~ 💬

我用JavaScript复刻了某宝的小游戏动物大迁徙消消乐

按照惯例线上预览图。

xxl.gif

前段时间在某宝的养鸡小游戏里面发现一个年代感小游戏,为了小鸡饲料特意点进去玩了一段时间,然后小游戏里面有个"动物大迁移"的消消乐特别上头,但是这个游戏在里面只有活动的时间才能玩,而且每次这个活动要等一个月,还只能玩三天。为了我后面能畅玩,就想着自己能不能也写一个,这样就不用等活动来了,然后就有了这次的游戏,想用JavaScript实现,应该挺有意思的!于是说干就干,开始了我的复刻之旅。

项目背景

其实我一直对游戏开发很感兴趣,但总觉得门槛太高。看到到玩到这个消消乐,发现它的规则简单但很有策略性:不同长度的动物方块、特殊的野牛机制、冰冻技能...这简直就是完美的新手练手项目!

游戏设计思路

首先是游戏设计,我的目标是复刻核心玩法,或许后面也要加入一些自己的特色:比如自由模式或者AI?

动物方块设计

  • 1格:鸵鸟(基础方块)
  • 2格:斑马 / 麋鹿(中等长度)
  • 3格:大象 / 狮子(较长方块)
  • 4格:北极熊(特殊技能方块)
  • 5格:野牛(BOSS级方块)

核心机制

  • 整行消除得分,BOSS野牛方块消除是累计的,不是一次性消除
  • 连击倍数奖励,连击分数有加成效果
  • 技能点积累,技能点不能是无限制的,需要消除获取,最多可以储存2次,越到后期技能获取的条件越高
  • 冰冻模式和每个动物都有的独特技能,通过后期使用技能来解决较长的方块
  • 预加载方块,可以在底部查看到下次出来的是那些方块,以便后续布局
  • 迁徙动画,可以在顶部看到动物消除之后,对应的动物从左到右奔跑出来

核心架构

我将游戏分为四个核心模块,采用面向对象的设计思想:

GameState:游戏状态管理

负责维护游戏的所有状态数据,包括棋盘状态、分数、技能点数等。

GameRenderer:渲染系统

处理所有视觉相关的逻辑,包括方块渲染、动画效果等。

GameLogic:游戏逻辑

实现游戏的核心规则,包括消除判断、连击系统等。

GameController:控制层

处理用户输入,协调各个模块的协作。

GameSkill:技能输出

负责处理方块对应技能,确保每个方块输出的技能。

GameSound:音频管理

负责整个游戏的音频输出,可以设置背景音乐和点击音效等。

各模块代码结构清晰,各模块职责单一,用于后面维护和扩展。

开发中的设计和技术难点

设计

动物方块技能

每个长度的动物都有自己的设计,这里我主要说一下 boss 野牛北极熊 设计。

作为BOSS级别的存在,野牛方块的消除机制和其他的消除不同,需要多次累计消除才能完全清除,而且越到后期野牛的出现几率就越大,解决野牛的最好办法就是使用北极熊冰冻技能,是控制场上所有的北极熊使移动回合暂停,让所有的 boss野牛 变的温顺变为一格!

技能点数系统

技能系统在游戏中也是非常重要的,根据游戏设计完整的技能点数积累机制。

// 技能状态管理
this.skill = {
    currentPoints: 0,      // 当前积累点数
    maxPoints: 2500,      // 点数上限
    threshold: 1000,      // 每个技能点需要的点数
    skillPoint: 0         // 可用的技能点数
}

开局只有1000点的积分点,通过消除获取积分点得到技能点,超过1000积分累计一个技能,默认最大2500积分,超过积分不累计,直到第一次使用积分。使用积分,默认最大积分和第一积分点会累加。

核心逻辑,点击使用机会按钮后:

  • 每个技能点需要的点数的阈值变为1500(即2500-1000)
  • 点数上限的最大值变为3500(即2500+1000)
handleSkillPointsClick() {
  if (this.state.skill.skillPoint <= 0) {
    this.renderer.showMessage({ message: '技能点不足!' })
    return
  }
  if (this.isSelectingSkillTarget || this.state.isFreezeMode) {
    return
  }
  // 更新阈值和最大值
  const newThreshold = this.state.skill.maxPoints - this.state.skill.threshold
  this.state.skill.maxPoints = this.state.skill.maxPoints + this.state.skill.threshold
  this.state.skill.currentPoints = this.state.skill.currentPoints - this.state.skill.threshold
  this.state.skill.threshold = newThreshold

  this.soundManager.play('falling')

  // 进入“等待选择技能目标”模式
  this.isSelectingSkillTarget = true
  this.gameMaskElement.classList.add('show')

  this.renderer.updateScore()
}

预加载方块

通过预加载可以提前知道接下来生成的方块位置和大小,方便后续提前移动布局。这里面在示例中设置的是9x11的大小棋盘,但是实际渲染的是9x12大小,多出来的一行是预加载行,样式上设置overflow:hidden隐藏,通过生成动画加载向上移动一行。

generateNewRow() {
  // 检查并更新下一个野牛生成回合
  if (this.round === this.nextBuffaloRound) {
    if (this.buffaloIndex < this.buffaloPattern.length - 1) {
      this.buffaloIndex++
    }
    this.nextBuffaloRound += this.buffaloPattern[this.buffaloIndex]
  }
  // 检查是否需要生成野牛行
  if (this.round + 1 === this.nextBuffaloRound) {
    return this.generateBuffaloRow()
  }
  // 默认动物
  const animals = {
    1: 'ostrich', // 鸵鸟
    2: 'zebra,deer', // 斑马,麋鹿
    3: 'elephant,lion', // 大象,狮子
    4: 'bear' // 北极熊
  }
  // 创建新行数组
  const newRow = Array(this.boardSizeX).fill(null)
  // 随机生成方块组个数
  const groupCount = this.getRandomInt(2, 4)
  // 生成随机起始位置[0,2],避免每次都是从第一个开始
  let usedCells = this.getRandomInt(0, 2)

  for (let i = 0; i < groupCount; i++) {
    if (usedCells >= this.boardSizeX) break
    // 使用智能几率生成方块长度
    const weightLength = this.getWeightedRandomLength()
    // 随机生成方块组的随机长度,最大不超过4格
    const maxLength = Math.min(4, this.boardSizeX - usedCells)
    // 随机生成方块组长度,最小为1,最大为maxLength
    const length = Math.min(weightLength, maxLength)
    const animalArray = animals[length].split(',')
    const animal = animalArray[Math.floor(Math.random() * animalArray.length)]
    const startCol = usedCells

    // 创建方块组
    const blockId = this.nextBlockId++
    for (let j = 0; j < length; j++) {
      newRow[startCol + j] = {
        id: blockId,
        length: length,
        startCol: startCol,
        animal
      }
    }
    // 生成后续间隔的格子数,随机间隔0-2格
    usedCells += length + this.getRandomInt(0, 2)
  }

  return newRow
}

这里为了增加游戏难度,在越到后期,方块生成的类型肯定是不能随机出来,所以在生成的时候加入了生成方块的概率判断,通过 getWeightedRandomLength() 函数创建生成长度权重来增加游戏难度和可玩性😄。

// 根据权重随机生成方块长度
getWeightedRandomLength() {
  // 基础几率配置
  const chances = {
    1: 35, // 1格方块35%几率
    2: 30, // 2格方块30%几率
    3: 25, // 3格方块25%几率
    4: 10 // 4格方块10%几率
  }

  // 根据游戏进度调整几率(回合数越多,大方块几率越高)
  const progressFactor = Math.min(1, this.round / this.allRound) // 500回合后达到最大调整

  // 调整后的几率
  const adjustedChances = {
    1: Math.max(10, chances[1] - progressFactor * 20), // 1格几率减少
    2: Math.max(25, chances[2] - progressFactor * 10), // 2格几率减少
    3: Math.min(35, chances[3] + progressFactor * 10), // 3格几率增加
    4: Math.min(30, chances[4] + progressFactor * 20) // 4格几率增加
  }

  // 计算总几率
  const totalChance = adjustedChances[1] + adjustedChances[2] + adjustedChances[3] + adjustedChances[4]

  // 生成随机数
  const randomValue = Math.random() * totalChance

  // 根据几率选择方块长度
  let cumulative = 0

  cumulative += chances[1]
  if (randomValue <= cumulative) return 1

  cumulative += chances[2]
  if (randomValue <= cumulative) return 2

  cumulative += chances[3]
  if (randomValue <= cumulative) return 3

  return 4
}

动物的奔跑动画

这里采用的是 css 的关键帧样式,因为原版里面有很多动画js实现非常困难,索性直接用的css 加帧图片配合 keyframes 的连续移动做出动物奔跑动作。

使用padding-bottom 设置相对画布的百分比高度,计算公式 h = (图片高 H / 图片宽 W) * 相对宽度 w ,然后通过伪类 before 设置百分百宽高加上动画帧就可以了。

.animal-buffalo {
  position: absolute;
  bottom: 20%;
  right: 100%;
  width: 120px;
  height: 0;
  padding-bottom: calc(210 / (4950 / 15) * 120px);
  animation: buffalo 3s forwards ease-out;
}

.animal-buffalo::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: url('../img/buffalo.png');
  background-size: 1500% 100%;
  animation: buffalo1 0.8s steps(15) infinite;
}

@keyframes buffalo {
  to {
    right: -120px;
  }
}

@keyframes buffalo1 {
  from {
    background-position: 0 0;
  }
  to {
    background-position: -1500% 0;
  }
}

技术难点

这次开发有相对较多的技术小技巧,这里这是拿出比较重要关键的节点来说。

元素动画(核心)

在开始代码初期,使用的动画是 transition 过渡,但是在做的过程中发现,这个监听动画结束是非常不可控制,比如元素的创建到销毁开始是监听不到 transitionend 的事件的,必须要配合写 setTimeout 延迟才可以,这样的做法有点“丑陋”,我是看不得代码里面都是 setTimeout 控制动画结束做回调,思索之后选择自己写一个动画效果,在翻阅资料后,找到大佬张鑫旭的文章「如何使用Tween.js各类原生动画运动缓动算法」,事实上很早就看过这篇,现在迅速再翻一遍。具体实现步骤和原理这里不多介绍,有兴趣可以翻看文章。

根据文章提供的思路写了一个 animate 的初始函数。

function animate(options) {
  return new Promise((resolve) => {
    const startTime = performance.now();
    const { ele, begin, change, duration } = options;
    const [p1, p2, p3, p4] = [0.175, 0.885, 0.32, 1.275]; // cubic-bezier参数

    function frame(currentTime) {
      const elapsed = currentTime - startTime;
      const progress = Math.min(elapsed / duration, 1);
      
      // 使用精确的CSS缓动计算
      const easedProgress = preciseCubicBezier(progress, p1, p2, p3, p4);
      const currentValue = begin + change * easedProgress;

      ele.style.transform = `translateX(${currentValue}px)`;

      if (progress < 1) {
        requestAnimationFrame(frame);
      } else {
        resolve();
      }
    }

    requestAnimationFrame(frame);
  });
}

可以看出上面的函数还是有很大的局限性的,最大的问题是在游戏中需要消除时有抖动的效果的,这时候抖动是 0 -> 0 的过程,在这个过程中使用函数实际上不是运动的,所以针对抖动需要添加帧动画的模式。

dd.gif

Tween 的缓动方法做了其他思路的改变,加上 keyframe 的实现,因为在本游戏中有位移为0,但是中间做的偏移动画,例如首图中方块元素消除之后的左右摆动,开始位置是0,结束也是0,所以在原始的 Tween 是不奏效的,针对这个问题做了如下改动。

// 根据关键帧计算当前值的核心函数
getValueFromKeyframes(progress, keyframes, defaultEasing) {
  // 确保关键帧是按offset排序的
  const sortedKeyframes = [...keyframes].sort((a, b) => a.offset - b.offset)

  // 处理边界情况
  if (progress <= 0) return sortedKeyframes[0].value
  if (progress >= 1) return sortedKeyframes[sortedKeyframes.length - 1].value

  // 1. 定位段落:找到当前进度所在的关键帧段落
  let segmentStartFrame = sortedKeyframes[0]
  let segmentEndFrame = sortedKeyframes[sortedKeyframes.length - 1]
  for (let i = 0; i < sortedKeyframes.length - 1; i++) {
    if (progress >= sortedKeyframes[i].offset && progress <= sortedKeyframes[i + 1].offset) {
      segmentStartFrame = sortedKeyframes[i]
      segmentEndFrame = sortedKeyframes[i + 1]
      break
    }
  }

  // 2. 计算局部进度
  const segmentDuration = segmentEndFrame.offset - segmentStartFrame.offset
  // 避免除以零的错误
  if (segmentDuration === 0) return segmentEndFrame.value

  const localProgress = (progress - segmentStartFrame.offset) / segmentDuration

  // 3. 应用缓动
  // 优先使用段落指定的缓动,否则使用全局默认缓动
  const easing = segmentStartFrame.easing || defaultEasing
  const easedLocalProgress = this.preciseCubicBezier(localProgress, ...easing)

  // 4. 计算最终值 (线性插值)
  const valueChange = segmentEndFrame.value - segmentStartFrame.value
  const currentValue = segmentStartFrame.value + valueChange * easedLocalProgress

  return currentValue
}

animate({
  begin,
  end,
  keyframes,
  duration = this.options.duration,
  cubicBezier = this.options.cubicBezier,
  onUpdate,
  onEnd,
  onBefore
}) {
  return new Promise((resolve) => {
    // --- 兼容性处理 ---
    // 如果传入了 begin 和 change,则动态生成 keyframes
    if (begin !== undefined && end !== undefined && !keyframes) {
      keyframes = [
        { offset: 0, value: begin },
        { offset: 1, value: end }
      ]
    }
    // 如果没有有效的关键帧,则报错
    if (!keyframes || keyframes.length < 2) {
      console.error('关键帧最短需要两个或更多')
      resolve(false)
      return
    }

    const startTime = performance.now()

    const frame = (currentTime) => {
      const elapsed = currentTime - startTime
      const totalProgress = Math.min(elapsed / duration, 1)

      // 使用新的核心计算函数
      const currentValue = this.getValueFromKeyframes(totalProgress, keyframes, cubicBezier)

      onUpdate && onUpdate(currentValue)

      if (totalProgress < 1) {
        requestAnimationFrame(frame)
      } else {
        onEnd && onEnd()
        resolve(true)
      }
    }

    onBefore && onBefore()
    requestAnimationFrame(frame)
  })
}

在使用关键帧的时候可以加上 keyframes 字段,duration 时间以及自定义你的缓动动画 cubicBezier,非常自由。

animate({
  keyframes: [
    { offset: 0, value: 0 },
    { offset: 0.2, value: 3 },
    { offset: 0.4, value: -3 },
    { offset: 0.6, value: 4 },
    { offset: 0.8, value: -5 },
    { offset: 1, value: 0 }
  ],
  duration: 300,
  cubicBezier: [0.175, 0.885, 0.32, 1.275],
  onUpdate: (value) => {
    blockDom.style.left = `${value}px`
  }
})

方块拖动与碰撞检测

在方块的拖动时要确保只能在有空隙的地方拖拽,所以需要做平滑拖动检测。由于动物方块的占格长度不同,传统的网格碰撞检测无法直接使用,这个方法确保了方块只能在空位上移动,不会与其他方块重叠,同时保证了拖动的流畅性。

我设计了一套基于位置预测的碰撞系统:

const blockId = Number(block.dataset.blockId)
const row = Number(block.dataset.row)

let startCol = this.state.boardSizeX
let endCol = -1

// 找到整个方块组的起始和结束位置索引
for (let col = 0; col < this.state.boardSizeX; col++) {
  if (this.state.board[row][col] !== null && this.state.board[row][col].id === blockId) {
    startCol = Math.min(startCol, col)
    endCol = Math.max(endCol, col)
  }
}

this.currentBlockGroup = {
  id: blockId,
  row: row,
  startCol: startCol,
  length: endCol - startCol + 1
}

// 返回能移动的距离
calculateMaxLeftMove(blockGroup = this.currentBlockGroup) {
  let maxLeft = blockGroup.startCol
  for (let col = blockGroup.startCol - 1; col >= 0; col--) {
    if (this.state.board[blockGroup.row][col] === null) {
      maxLeft--
    } else {
      break
    }
  }
  return blockGroup.startCol - maxLeft
}

方块的下落检测

在方块下落的时候需要检测下面是否有空隙掉落,需要通过 do while 循环来判断,因为在下落的过程中程序是不知道自己要下落多上行,而且遍历循环是从下往上循环一次的,在这个过程中循环一次查到元素下面是空的就标记 `true,表示继续需要下落,如此往复,等对应起始元素下面有遮挡为止标记 false 跳出循环。

// 应用重力(返回是否有方块掉落)
applyGravity() {
  return new Promise(async (resolve) => {
    let moved
    let blocks = []
    do {
      moved = false

      // 从下往上检查
      for (let row = this.state.boardSizeH - 2; row >= 0; row--) {
        for (let col = 0; col < this.state.boardSizeX; col++) {
          // 只处理每个方块组的第一个格子
          if (this.state.board[row][col] === null || this.state.board[row][col].startCol !== col) continue

          const blockData = this.state.board[row][col]
          const blockLength = blockData.length

          // 检查下方是否有足够连续的空位
          let canFall = true
          for (let c = col; c < col + blockLength; c++) {
            if (this.state.board[row + 1][c] !== null) {
              canFall = false
              break
            }
          }

          // 如果可以下落,移动整个方块组
          if (canFall) {
            // 移动数据
            for (let c = col; c < col + blockLength; c++) {
              this.state.board[row + 1][c] = this.state.board[row][c]
              this.state.board[row][c] = null
            }

            // 记录移动的方块组
            const block = blocks.find((b) => b.blockId === blockData.id)
            if (block) {
              block.endRow = row + 1
            } else {
              blocks.push({
                blockId: blockData.id,
                startRow: row,
                endRow: row + 1,
                startCol: col,
                endCol: col,
                length: blockLength
              })
            }

            moved = true
            col += blockLength - 1 // 跳过已处理的方块组
          }
        }
      }
    } while (moved)

    await this.renderer.animateBlock(blocks, 'falling')
    resolve(moved)
  })
}

通过上面的程序就可以检测出需要下落的起始位置和结束位置了,最后再用统一用动画 animateBlock(blocks, 'falling') 函数处理动画过程。

方块消除

方块消除可以分成两个步骤,第一步检测需要消除的方块,有没有包含 boss 野牛 ,没有则整个删除,这样是最简单的,如果包含那么久要考虑消除除 boss 野牛 以外的方块,当然 boss 野牛 长度不能为1,否则也要视为普通方块。

检测消除之后还需要通过积分系统关联积分累计,然后等所有动画完成之后,再需要重新重复上面一步的下落检测。

// 检查并执行消除(返回是否有消除发生)
checkEliminations() {
  return new Promise(async (resolve) => {
    let blocks = []
    let blocks2 = []
    let elimination = false
    // 本次消除获得的积分
    let pointsEarned = 0
    let pointsEarned2 = 0

    for (let row = 0; row < this.state.boardSizeH; row++) {
      // 该行有空格,跳过
      if (this.state.board[row].some((cell) => cell === null)) continue
      // 该行无空格,执行消除
      elimination = true
      this.state.currentCombo++

      // 消除该行
      for (let col = 0; col < this.state.boardSizeX; col++) {
        if (!this.state.board[row][col]) continue

        // 计算连击倍数
        const index = Math.min(this.state.currentCombo - 1, this.state.multipliers.length - 1)
        const blockData = this.state.board[row][col]

        // 检查是否是野牛标记的方块
        if (blockData.animal !== 'buffalo') {
          if (!blocks.find((b) => b.blockId === blockData.id)) {
            const comboMultiplier = blockData.length * 10 * this.state.multipliers[index]
            blocks.push({
              blockId: blockData.id,
              startRow: row,
              endRow: row,
              startCol: col,
              endCol: col,
              length: blockData.length,
              animal: blockData.animal,
              comboMultiplier
            })
            // 计算积分:方块长度 × 10 × 连击倍数
            pointsEarned += comboMultiplier
            pointsEarned2 += blockData.length * 10
          }
          this.state.board[row][col] = null
        } else {
          if (!blocks2.find((b) => b.blockId === blockData.id)) {
            // 找到野牛方块的最后一个格子
            const lastCol = blockData.startCol + blockData.length - 1
            // 更新野牛方块数据
            this.state.board[row][lastCol] = null
            const data = {
              blockId: blockData.id,
              startRow: row,
              endRow: row,
              startCol: col,
              endCol: col,
              startLength: blockData.length,
              endLength: blockData.length - 1,
              animal: blockData.animal
            }
            // 如果野牛消除只剩下一格,积分固定200
            if (data.startLength === 1) {
              const comboMultiplier = 200 * this.state.multipliers[index]
              blocks.push({
                ...data,
                length: 200,
                comboMultiplier
              })
              pointsEarned += comboMultiplier
              pointsEarned2 += 200
            } else {
              blocks2.push(data)
            }
          }
          // 只减少长度
          if (this.state.board[row][col]) {
            this.state.board[row][col].length = blockData.length - 1
          }
        }
      }
    }

    if (elimination) {
      const messages = {
        2: '双连击!',
        3: '三连击!!',
        4: '四连击!!!',
        5: '五连击!!!!超神!'
      }

      const message = messages[this.state.currentCombo] || `${this.state.currentCombo}连击!`
      if (messages[this.state.currentCombo]) {
        this.renderer.showMessage({ message })
      }
      // 添加积分
      this.state.addPoints(pointsEarned, pointsEarned2)

      // 更新分数显示
      this.renderer.updateScore()
      const animations = [this.renderer.animateBlock(blocks, 'eliminating')]
      if (blocks2.length) {
        animations.push(this.renderer.animateBlock(blocks2, 'buffalo'))
      }

      await Promise.all(animations)
    }
    resolve(elimination)
  })
}

方块消除循环检测

在上面拿到两个函数之后,其实循环在检测就简单,根据上面说的 do while 是个很好用的循环,每次函数执行会自动调用一次,如果循环体里面为 true 则表示还有需要下落的方块或者需要消除的行。

// 处理游戏效果(掉落、消除等)
async processGameEffects() {
  let hasChanges
  do {
    hasChanges = false

    // 应用重力
    const fell = await this.applyGravity()

    // 检查消除
    const eliminated = await this.checkEliminations()

    hasChanges = fell || eliminated
  } while (hasChanges)
}

结尾

中间还加了游戏需要背景音乐和音效,确保游戏进程不单调,总体游戏算是完成了绝大部分,还有一些技能后续也会补上,后续也会考虑怎么改成canvas版再加上,自由模式自由添加,包括AI辅助功能。

做这个项目也收获挺多,整体素材和游戏玩法都是扣原版的,最麻烦的地方是素材都是我一个个ps整的,这就花了大部分的时间,事实上代码逻辑不复杂,组合起来主要的点却又很多,中间也是边看录得视频一边琢磨玩法才到现在的完成版,这个动物消消乐项目从一个偶然的灵感开始,最终成为了我技术成长的重要里程碑。整个过程中遇到的每个挑战都成为了宝贵的学习机会。

最后大家完了觉得还不错或者说不足建议,可以在评论区留言指出,如果觉得这篇文章有帮助,请点个赞支持一下哦!

项目源码GitHub链接

在线体验预览链接


注:本文仅分享技术学习经验,相关游戏素材和机制已进行差异化设计,如有侵权请联系删除

React 双缓存架构与 diff 算法优化

提到 React 应用的页面更新优化策略,会有两个绕不开的概念,它们分别是双缓存架构和 diff 算法。 其中 React 利用双缓存架构在内存中生成下次要渲染的页面所对应的虚拟 DOM 树,并

别再被闭包坑了!React 19.2 官方新方案 useEffectEvent,不懂你就 OUT!

useEffectEvent:优雅解决 React 闭包陷阱的终极方案

在 React 开发中,闭包陷阱是开发者最常遇到的困扰之一。当组件状态更新时,我们希望某些逻辑能始终使用最新状态,却不想触发不必要的重渲染。React 19.2 引入的 useEffectEvent 正是为解决这一问题而生,它让代码更简洁、更安全,彻底告别闭包困扰。

闭包陷阱:问题根源

让我们从一个经典示例开始:

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      showNotification('Connected!', theme); // 闭包捕获了旧的 theme 值
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, theme]); // theme 变化会导致不必要的重连
}

theme 状态变化时,useEffect 会重新执行,导致聊天室连接被重置。这并非我们想要的——我们只想更新通知主题,而非重连。

传统解决方案的痛点

过去,我们常使用 useRef 解决这个问题:

function ChatRoom({ roomId, theme }) {
  const themeRef = useRef(theme);
  themeRef.current = theme; // 手动更新 ref

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      showNotification('Connected!', themeRef.current); // 读取最新值
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
}

这种方式虽有效,但需要手动维护 ref,增加了代码复杂度和出错风险。

useEffectEvent:优雅的终极解决方案

React 19.2 引入的 useEffectEvent 让这一切变得简单:

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('Connected!', theme); // ✅ 始终获取最新 theme
  });

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', onConnected);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // 只依赖 roomId,无需 theme
}

为什么 useEffectEvent 是革命性的?

✨ 无需手动维护 ref

useEffectEvent 内部自动处理了最新值的捕获,无需再写 themeRef.current = theme

✨ 代码简洁度提升

依赖数组更短,逻辑更清晰,无需担心闭包陷阱,让代码更易读、易维护。

✨ 与 DOM 事件一致的行为

useEffectEvent 的行为类似于 DOM 事件,始终能获取最新的状态,无需额外处理。

实际应用场景:让代码更优雅

场景 1:自动滚动到底部

function ChatRoom() {
  const [messages, setMessages] = useState([]);
  const messagesEndRef = useRef(null);
  
  const scrollToBottom = useEffectEvent(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  });

  useEffect(() => {
    scrollToBottom();
  }, [messages]);
}

场景 2:WebSocket 消息处理

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  
  const handleMessage = useEffectEvent((message) => {
    setMessages(prev => [...prev, message]);
  });

  useEffect(() => {
    const socket = new WebSocket(`wss://example.com/${roomId}`);
    socket.onmessage = (event) => {
      handleMessage(JSON.parse(event.data));
    };
    return () => socket.close();
  }, [roomId]);
}

场景 3:表单自动保存

function Form() {
  const [input, setInput] = useState('');
  const [saved, setSaved] = useState(false);
  
  const saveForm = useEffectEvent(() => {
    if (input.length > 0) {
      setSaved(true); // 保存表单逻辑
    }
  });

  useEffect(() => {
    const timeout = setTimeout(() => {
      saveForm();
    }, 2000);
    return () => clearTimeout(timeout);
  }, [input]);
}

useEffectEvent 与 useRef 的全面对比

特性 useRef useEffectEvent
代码复杂度 高(需手动更新 ref) 低(自动处理)
依赖管理 需要额外管理 ref 更新 无需额外管理
闭包问题 需要额外处理 自动解决
适用场景 通用状态保存 专门用于副作用中的事件处理
代码可读性 降低 提升

使用注意事项

  1. 实验性功能useEffectEvent 仍处于实验阶段,目前仅在 React 19.2 的 Canary 版本中可用。
  2. 仅限副作用useEffectEvent 必须在 useEffect 内部使用。
  3. 不用于事件处理:不要将其直接作为 JSX 事件处理函数。
  4. 依赖数组useEffectEvent 本身不需要依赖数组,但其返回的函数必须在 useEffect 的依赖数组中声明。

结语

useEffectEvent 是 React 19.2 中真正解决闭包陷阱的革命性特性。它通过将事件逻辑与副作用解耦,让我们能写出更简洁、更安全的代码,避免不必要的重渲染,显著提升应用性能。

随着 React 的持续发展,这类工具将越来越完善,帮助我们更高效地构建 React 应用。现在就尝试在你的项目中使用 useEffectEvent,体验 React 开发的全新境界!

💡 现在就行动:确保你的 React 版本 >= 19.2,并安装 eslint-plugin-react-hooks@6.1.0 以获得最佳的 lint 支持。让闭包陷阱成为过去式,享受更优雅的 React 开发体验!

React 架构重生记:从递归地狱到时间切片

本文参考卡颂老师的《React 技术揭秘》,并结合小dora个人理解与源码阅读编写的一篇博客。
目标是让你看懂:React 为什么要重写架构、Fiber 到底解决了什么问题。


一、React15:一个“全力以赴但不会刹车”的系统

React15 的架构只有两层:

  • 🧩 Reconciler(协调器) :负责计算哪些组件要更新;
  • 🖼️ Renderer(渲染器) :把更新同步到对应平台(浏览器、原生、测试环境等)。

听起来没问题,但问题出在它的更新策略——
React15 在更新时使用的是递归调用

每次调用 setState() 时,React 会自上而下递归遍历整棵组件树。

我们可以用伪代码看看它的本质:

function updateComponent(component) {
  component.render(); // 渲染当前组件
  component.children.forEach(updateComponent); // 递归子组件
}

简单粗暴,效率直接。
但问题是——一旦递归开始,就停不下来


🧠 举个例子:

假设你有一棵很深的组件树,当用户点击按钮触发更新时,
React 就会一路递归更新下去:

App
 ├─ Header
 ├─ Main
 │   ├─ List
 │   │   ├─ Item #1
 │   │   ├─ Item #2
 │   │   └─ Item #3
 │   └─ Sidebar
 └─ Footer

当层级很深、每个组件都要执行 render() 时,
整个递归过程会持续超过 16ms(一帧的理想渲染时间)。

这意味着在更新的过程中,浏览器完全没有机会响应用户操作

想点击?等我更新完再说。
想输入?我还在 render 呢。

这,就是 React15 最大的痛点——同步更新不可中断


二、如果在中途强行“打断”会发生什么?

假设我们有个 Demo:

function List({ items }) {
  return (
    <ul>
      {items.map((num) => (
        <li key={num}>{num * 2}</li>
      ))}
    </ul>
  );
}

用户希望看到 [1, 2, 3] → [2, 4, 6]

如果中途在更新到第二个 <li> 时被中断,就可能出现半成品页面:

<li>2</li>
<li>2</li>
<li>3</li>

React15 没法处理这种情况。因为它没有保存中间状态,也没有“恢复机制”。
它只能一口气跑完。

这时候 React 团队意识到:

我们需要一个可以「暂停、恢复、甚至丢弃」任务的架构。


三、React16:Fiber——让 React 学会「调度」

于是,在 React16 中,React 团队重写了整个协调层,设计了新的架构:

+------------------+
|   Scheduler      | 调度器:分配优先级,安排执行顺序
+------------------+
|   Reconciler     | 协调器:找出变化的组件(Fiber)
+------------------+
|   Renderer       | 渲染器:将变化反映到宿主环境
+------------------+

新增的那一层 Scheduler(调度器) 就是关键!


🧬 Fiber 是什么?

简单来说,Fiber 是对「组件更新单元」的抽象
每个组件都会对应一个 Fiber 对象,它保存:

{
  type: Component,
  pendingProps: newProps,
  child: firstChildFiber,
  sibling: nextFiber,
  return: parentFiber
}

它就像是一个链表节点,连接整棵组件树。
通过 Fiber,React 可以记录任务执行的进度


🔁 可中断的循环

React16 的更新逻辑不再是递归,而是循环:

function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    workInProgress = performUnitOfWork(workInProgress);
  }
}

每次只处理一个 Fiber 单元,然后问一句:

if (shouldYield()) pause();

shouldYield() 就是核心判断:
👉 当前帧的时间是否用完?
👉 有没有更高优任务进来?

如果答案是“是”,就中断执行,把控制权交还给浏览器。

React 会在下一帧或空闲时间里继续从中断点恢复


四、Scheduler:React 的「时间管理大师」

Fiber 可以被打断,但谁来决定打断时机

这就轮到 Scheduler 登场了。

浏览器有个原生 API requestIdleCallback()
可以在浏览器空闲时执行任务,但它兼容性和触发频率都不稳定。

于是 React 自己实现了一个更强的版本:

📦 scheduler
它模拟浏览器空闲回调,并为任务赋予多种优先级。

每个任务都带有权重,比如:

优先级 说明 示例
Immediate 立即执行 错误边界恢复
UserBlocking 用户输入 输入框响应
Normal 常规更新 列表渲染
Low 低优任务 动画或日志
Idle 空闲任务 后台预加载

通过这种优先级机制,React 终于可以像操作系统一样分配 CPU 时间。


五、渲染:内存标记 + 批量提交

Fiber 负责协调,Renderer 才是执行者。
在 React16 中,Reconciler 不再边遍历边渲染,而是先打标记、后统一提交

比如:

export const Placement = 0b0000000000010;
export const Update = 0b0000000000100;
export const Deletion = 0b0000000001000;

每个 Fiber 节点在内存中被打上这些标签。
等所有标记完成后,Renderer 一次性提交所有 DOM 变更。

这就保证了即使中途被中断,DOM 始终保持一致性


六、可视化理解:React15 vs React16

对比项 React15 React16 (Fiber)
架构层次 Reconciler + Renderer Scheduler + Reconciler + Renderer
更新机制 递归 循环
可中断性 ❌ 不可中断 ✅ 可中断
DOM 一致性 更新中可能闪烁 内存标记后统一提交
优先级调度 有(Scheduler)
源码模块 ReactDOM react-reconciler + scheduler

📊 可以把这两者比喻成:

  • React15:单线程跑完一场马拉松,中途谁也拦不住;
  • React16:多任务分片执行,随时暂停、恢复、插队。

七、总结:从渲染引擎到时间调度系统

React16 的架构重写并非简单的性能优化,
而是一种“调度哲学的引入”。

React 不再只是「渲染 DOM 的库」,
而是一个「管理任务优先级的调度系统」。

Fiber 让任务可中断;
Scheduler 让任务有先后;
Renderer 让任务有结果。

React 的底层逻辑已经从:

同步执行异步调度

演化成一套“以用户体验为核心的调度架构”。


📘 参考资料

  • 卡颂,《React 技术揭秘》
  • React 官方源码(react-reconciler / scheduler)
  • React 团队公开设计文档

WebSocket服务封装实践:从连接管理到业务功能集成

现代Web应用中的实时通信需求

最近项目中需要将先科的广播系统管理平台移植到系统中,经过不断反复的推翻修改,终于有了这篇文章。主要分享一下在设计websocket过程中的一些小技巧与实践方法。

image.png

前言: 在当今的Web应用开发中,实时通信功能已成为许多系统的核心需求。无论是即时聊天、实时数据监控还是广播通知系统,WebSocket技术都扮演着至关重要的角色。然而,直接使用原生的WebSocket API往往会导致代码重复、状态管理混乱和错误处理困难等问题。本文将介绍如何封装一个健壮的WebSocket服务,展示从基础连接管理到高级业务功能集成的最佳实践。

123.png

1. 连接管理:建立可靠的双向通信通道

WebSocket服务封装了完整的连接生命周期管理:

// --- 全局变量声明(WebSocket连接状态管理) ---
let connectPromise = null        // 核心:保存连接的Promise,用于共享连接状态
let socket = null                // 当前的 WebSocket 实例
let connectionStatus = 'disconnected'    // 连接状态:'idle'|'connecting'|'connected'|'error'|'disconnected'
let shouldReconnect = true       // 控制是否允许自动重连
let reconnectAttempts = 0        // 当前重连尝试次数

// 可配置的重连策略
const MAX_RECONNECT_ATTEMPTS = 5 // 最大重连次数
const RECONNECT_INTERVAL = 3000  // 重连间隔时间(毫秒)
let timer = null                 // 心跳定时器句柄

连接初始化的关键特性

  • 单例模式实现:避免重复创建连接
  • Promise封装:提供异步操作接口
  • 自动重连机制:在连接断开时自动尝试恢复
  • 状态跟踪:实时监控连接状态
/**
 * 初始化 WebSocket 连接(Promise 版本)
 * 
 * 该函数用于建立与 WebSocket 服务器的连接,并在连接成功后自动执行用户登录和心跳机制。
 * 使用 Promise 封装连接过程,避免重复创建连接,支持自动重连机制。
 * 
 * @param {string} wsUrl - WebSocket 服务器地址(如:ws://localhost:8080)
 * @param {function} onMessage - 可选的消息回调函数(此处未使用,但可扩展)
 * @param {number} timeout - 可选:连接超时时间(毫秒),默认 10 秒(当前未实现超时控制)
 * @returns {Promise<WebSocket>} - 成功时 resolve,返回 WebSocket 实例(实际 resolve 无参数)
 *                                  失败时 reject,携带错误信息
 */

export const initWebSocket = (wsUrl = 'ws://') => {
    // 如果已经有连接或正在连接,则直接返回同一个 Promise
    // 避免多次调用 initWebSocket 时创建多个连接
    if (connectPromise) {
        return connectPromise;
    }

    // 创建一个新的 Promise 来管理 WebSocket 的连接过程
    connectPromise = new Promise((resolve, reject) => {
        // 情况 1:如果 WebSocket 已经打开,直接 resolve,无需重复连接
        if (socket && socket.readyState === WebSocket.OPEN) {
            console.log('WebSocket 已连接,跳过初始化');
            return resolve(socket); // 可选择返回 socket 实例
        }

        // 情况 2:如果 WebSocket 正在连接中,不重复创建,但此处未 reject 或 resolve
        // 注意:这里没有处理正在连接的情况,可能导致 Promise 悬挂(潜在问题)
        // 建议:可以 reject 或 resolve 等待现有连接完成
        if (socket && socket.readyState === WebSocket.CONNECTING) {
            console.log('WebSocket 正在连接中...');
            // 当前未处理,connectPromise 会一直等待 onopen 或 onclose
            // 可优化:监听现有 socket 的 onopen 并 resolve
            return; // 不执行后续连接逻辑
        }
        
        shouldReconnect = true; // 设置重连标志为 true,表示允许自动重连
        reconnectAttempts = 0; // 重置重连尝试次数
        socket = new WebSocket(wsUrl); // 创建新的 WebSocket 实例
        connectionStatus = 'connecting'; // 更新连接状态

        /**
         * WebSocket 连接成功打开时触发
         */
        socket.onopen = () => {
            console.log('WebSocket 连接已建立');
            connectionStatus = 'connected';

            // 连接成功后尝试用户登录(根据实际业务自行封装)
            userLogin('xxx', 'xxx')
                .then(() => {
                    console.log('用户登录成功,开始心跳');
                    startHeartbeat(); // 登录成功后启动心跳机制,维持连接
                    resolve(socket);  // 登录成功才认为初始化完成,resolve Promise
                })
                .catch((err) => {
                    console.error('用户登录失败:', err);
                    reject(new Error('登录失败')); // 登录失败则 reject
                });
        };

        /**
         * 接收到服务器消息时触发
         * 假设消息为 JSON 格式
         */
        socket.onmessage = (event) => {
            let data;
            try {
                data = JSON.parse(event.data);
                
                // 特殊处理:如果收到心跳响应且 result 不为 0,表示心跳失败,关闭连接
                if (data.command === 'heartbeat' && data.result !== 0) {
                    console.warn('心跳响应失败,关闭连接');
                    closeWebSocket(); // 调用关闭函数,可能触发重连
                }
            } catch (e) {
                console.error('无法解析消息为 JSON:', event.data);
                return; // 解析失败,忽略该消息
            }

            // 将正常消息通过事件机制广播给其他模块处理
            notifyMessage(data);
        };

        /**
         * WebSocket 发生错误时触发
         * 注意:error 事件并不一定会导致连接关闭,但应记录日志
         */
        socket.onerror = (error) => {
            console.error('WebSocket 错误:', error);
            connectionStatus = 'error';
            // 注意:此处不 reject,因为连接可能仍会通过 onclose 触发重连
        };

        /**
         * WebSocket 连接关闭时触发
         * 可能是网络断开、服务端关闭、手动关闭等
         */
        socket.onclose = () => {
            console.log('WebSocket 连接已关闭');
            connectionStatus = 'disconnected';
            clearInterval(timer); // 清除心跳定时器

            // 判断是否需要自动重连
            if (shouldReconnect && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
                reconnectAttempts++;
                console.log(`尝试重连... (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);

                // 延迟一段时间后尝试重新连接
                setTimeout(() => {
                    connectPromise = null; // 清除旧的 Promise,允许重新调用 initWebSocket
                    // 递归调用自身进行重连
                    initWebSocket(wsUrl).catch((err) => {
                        console.error('重连失败:', err);
                    });
                }, RECONNECT_INTERVAL);
            } else {
                // 超过最大重连次数或不允许重连
                console.warn('达到最大重连次数或已禁止重连,停止重连');
            }
        };
    });

    // 返回连接 Promise,调用者可通过 .then().catch() 处理结果
    return connectPromise;
};

2. 消息处理:事件总线与命令分发

高效的消息处理是WebSocket服务的核心能力:

import { notifyMessage } from '@/utils/eventBus'; // 引入消息总线

/**
* 接收到服务器消息时触发
* 假设消息为 JSON 格式
*/
socket.onmessage = (event) => {
    let data;
    try {
        data = JSON.parse(event.data);
        
        // 特殊处理:如果收到心跳响应且 result 不为 0,表示心跳失败,关闭连接
        if (data.command === 'heartbeat' && data.result !== 0) {
            console.warn('心跳响应失败,关闭连接');
            closeWebSocket(); // 调用关闭函数,可能触发重连
        }
    } catch (e) {
        console.error('无法解析消息为 JSON:', event.data);
        return; // 解析失败,忽略该消息
    }

    // 将正常消息通过事件机制广播给其他模块处理
    notifyMessage(data);
};

利用eventBus事件总线通知全局订阅者,接收消息

// eventBus.js
import mitt from 'mitt'
const subscribers = []

// 订阅消息
export const subscribe = (callback) => {
    if (typeof callback === 'function') {
        subscribers.push(callback)
    }
    // 返回取消订阅函数
    return () => {
        const index = subscribers.indexOf(callback)
        if (index > -1) {
            subscribers.splice(index, 1)
        }
    }
}

// 通知所有订阅者
export const notifyMessage = (data) => {
    subscribers.forEach((callback) => {
        try {
            callback(data)
        } catch (error) {
            console.error('消息回调执行出错:', error)
        }
    })
}

export default mitt()

消息处理策略

  • JSON数据解析与错误处理
  • 特殊解析与错误处理
  • 特殊命令的优先处理(如心跳、登录响应)
  • 通用消息通过事件总线广播
  • 命令路由机制(根据command字段分发处理)

3. Promise封装:管理异步操作

利用Promise管理异步操作使代码更清晰:

// 发送消息的Promise封装
/**
 * 发送消息到 WebSocket 服务器(异步安全版本)
 *
 * 该函数用于向 WebSocket 服务端发送消息。在发送前会确保连接已建立(自动初始化连接),
 * 并对消息格式、连接状态进行检查,确保消息可靠发送。
 *
 * @param {Object|string} message - 要发送的消息内容,通常为对象(将被 JSON.stringify)
 * @returns {Promise<boolean>} - 发送成功返回 true,失败返回 false
 *
 * @example
 * const success = await sendMessage({ command: 'chat', data: 'Hello' });
 * if (success) {
 *   console.log('消息发送成功');
 * } else {
 *   console.log('消息发送失败');
 * }
 */
export const sendMessage = async (message) => {
    // 参数校验:禁止发送空消息
    if (!message) {
        console.warn('无法发送空消息:message 为 null、undefined 或空值');
        return false;
    }

    try {
        // 确保 WebSocket 连接已建立
        // 如果尚未连接,initWebSocket 会尝试建立连接并完成登录流程
        // 如果连接失败或登录失败,initWebSocket 会 reject,此处捕获并返回 false
        await initWebSocket();
    } catch (error) {
        // initWebSocket 失败(如连接超时、网络问题、登录失败等)
        console.warn('WebSocket 初始化失败,无法发送消息:', error.message);
        return false;
    }

    // 再次检查 WebSocket 的当前状态是否为 OPEN(已打开)
    // 即使 initWebSocket 成功,网络可能在发送前断开,因此需要二次确认
    if (socket && socket.readyState === WebSocket.OPEN) {
        try {
            // 将消息序列化为 JSON 字符串并发送
            socket.send(JSON.stringify(message));
            console.log('消息已发送:', message);
            return true; // 发送成功
        } catch (error) {
            // send() 方法在某些异常情况下可能抛出异常(如序列化失败、底层错误)
            console.error('WebSocket send() 方法调用失败:', error);
            return false;
        }
    } else {
        // WebSocket 未连接或处于 CONNECTING/CLOSING/CLOSED 状态
        console.warn('WebSocket 未处于 OPEN 状态,无法发送消息');
        return false;
    }
};

Promise使用场景

  • 连接初始化:确保连接就绪
  • 用户登录:处理认证流程
  • 业务操作:如广播寻呼、获取设备信息等
  • 错误处理:统一捕获和报告异常

4. 通用方法封装示例

/**
 * 获取设备信息
 *
 * 该函数用于向指定设备发送指令,以获取与指定账户关联的区域(zone)信息。
 * 它通过调用 sendMessage 函数发送一个包含设备唯一标识和目标账户的命令。
 * @param {string} device_type - 0:分区设备 1:寻呼台设备 
 *
 * @description
 * 发送的消息格式如下:
 * {
 *   command: "get_user_zone",  获取用户的分区:get_user_zone
 *   dest_account: "目标账户" // 目标账户名称(自己或子用户)
 * }
 */
export const getDeviceInfo = (type) => {
    return new Promise(async (resolve, reject) => {
        // 如果 WebSocket 未连接,直接拒绝
        if (!socket || socket.readyState !== WebSocket.OPEN) {
            await initWebSocket()
            console.warn('WebSocket 未连')
            return reject(new Error('WebSocket 未连接'))
        }
        const Message = {
            uuid: '登录返回的uuid',
            command: 'get_device_info',
            device_type: type, // 0:分区设备 1:寻呼台设备 
            all_zone: true, // 是否请求全部分区
            page: 1
        }
        try {
            socket.send(JSON.stringify(Message))
            resolve()
        } catch (error) {
            return reject(new Error('发送消息失败: ' + error.message))
        }
    })
}

5. 请求示例

<script setup>
import { onMounted, onBeforeUnmount } from 'vue'
import { initWebSocket, closeWebSocket, getDeviceInfo } from '@/utils/WebSocket'
import { subscribe } from '@/utils/eventBus'

// 订阅消息
subscribe((ev) => {
    if (ev.command == 'get_device_info') {
        // 这里处理订阅的消息
    }
})

onMounted(async () => {
    await initWebSocket() // 初始化websocket
    await getDeviceInfo('3') // 获取设备信息
})
onBeforeUnmount(() => {
    closeWebSocket() // 关闭websocket
})
</script>

<template>
    <div></div>
</template>

<style lang="scss" scoped></style>

6. 完整代码

// websocketService.js
// websoket链接(用于IP广播)
import { notifyMessage } from '@/utils/eventBus' // 引入消息总线
let socket = null
let connectionStatus = 'disconnected'
let connectPromise = null // 核心:保存连接的 Promise,用于共享连接状态

// 可配置的最大重试次数和重连间隔
const MAX_RECONNECT_ATTEMPTS = 5
const RECONNECT_INTERVAL = 3000 // 3秒

let reconnectAttempts = 0
let shouldReconnect = false
let onMessageCallback = null
let timer = null

/**
 * 初始化 WebSocket 连接(Promise 版本)
 *
 * @param {string} wsUrl - WebSocket 服务器地址
 * @param {function} onMessage - 可选的消息回调函数
 * @param {number} timeout - 可选:连接超时时间(毫秒),默认 10 秒
 * @returns {Promise<WebSocket>} - 成功时返回 socket 实例
 */
export const initWebSocket = (wsUrl = 'ws://') => {
    // 如果已经有连接或正在连接,直接返回同一个 Promise
    if (connectPromise) {
        return connectPromise
    }

    // 创建新的连接 Promise
    connectPromise = new Promise((resolve, reject) => {
        // 如果已经连接,直接 resolve
        if (socket && socket.readyState === WebSocket.OPEN) {
            console.log('WebSocket 已连接,跳过初始化')
            return resolve()
        }

        // 正在连接或手动关闭后不再自动重连,则拒绝
        if (socket && socket.readyState === WebSocket.CONNECTING) {
            console.log('WebSocket 正在连接中...')
            return
        }

        shouldReconnect = true
        reconnectAttempts = 0

        socket = new WebSocket(wsUrl)
        connectionStatus = 'connecting'

        socket.onopen = () => {
            console.log('WebSocket 连接成功')
            connectionStatus = 'connected'
            sessionStorage.removeItem('storage-token')
            // 连接成功后尝试登录
            userLogin('admin', 'admin')
                .then(() => {
                    console.log('自动登录成功')
                    startHeartbeat() // 登录成功后开始心跳
                    resolve() // 登录成功才认为初始化完成
                })
                .catch((err) => {
                    console.error('自动登录失败:', err)
                    reject(new Error('登录失败'))
                })
        }

        socket.onmessage = (event) => {
            let data
            try {
                data = JSON.parse(event.data)
                if (data.command == 'heartbeat' && data.result != 0) closeWebSocket()
            } catch (e) {
                console.error('无法解析消息:', event.data)
                return
            }

            // 处理登录响应
            if (data.command === 'user_login') {
                handleLoginResponse(data, resolve, reject)
                return
            }

            // 广播其他消息
            notifyMessage(data)
        }

        socket.onerror = (error) => {
            console.error('WebSocket 错误:', error)
            connectionStatus = 'error'
        }

        socket.onclose = () => {
            console.log('WebSocket 连接关闭')
            connectionStatus = 'disconnected'
            clearInterval(timer)

            if (shouldReconnect && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
                reconnectAttempts++
                console.log(`尝试重连... (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`)
                setTimeout(() => {
                    connectPromise = null // 允许重新连接
                    initWebSocket(wsUrl).catch(() => {})
                }, RECONNECT_INTERVAL)
            } else {
                console.warn('停止重连')
            }
        }
    })

    return connectPromise
}

// 2. 发送消息函数
export const sendMessage = async (message) => {
    if (!message) {
        console.warn('无法发送空消息')
        return false
    }

    try {
        // 确保连接已建立
        await initWebSocket()
    } catch (error) {
        console.warn('连接失败,无法发送消息:', error.message)
        return false
    }

    if (socket.readyState === WebSocket.OPEN) {
        try {
            socket.send(JSON.stringify(message))
            return true
        } catch (error) {
            console.error('发送消息失败:', error)
            return false
        }
    } else {
        console.warn('WebSocket 未处于 OPEN 状态,无法发送')
        return false
    }
}

// 3. 关闭连接函数
export const closeWebSocket = () => {
    shouldReconnect = false
    if (socket) {
        socket.close()
    }
    if (timer) clearInterval(timer)
    connectPromise = null // 允许下次重新连接
    sessionStorage.removeItem('storage-token')
}

// 登录响应处理
let loginResolve = null
let loginReject = null

function handleLoginResponse(data, resolve, reject) {
    if (data.result === 0) {
        try {
            sessionStorage.setItem('storage-name', data.user_name || '')
            sessionStorage.setItem('storage-password', data.password || '')
            sessionStorage.setItem('storage-token', data.uuid || '')
            sessionStorage.setItem('storage-userType', data.user_type || '')
        } catch (err) {
            console.error('存储登录信息失败:', err)
        }
        if (loginResolve) loginResolve(data)
        if (resolve) resolve()
    } else {
        const error = new Error(data.msg || '登录失败')
        if (loginReject) loginReject(error)
        if (reject) reject(error)
    }
    loginResolve = null
    loginReject = null
}
/**
 * 用户登录函数
 *
 * 该函数用于处理用户登录请求
 * @param {string} account - 用户的登录账户名(可以是用户名、邮箱或手机号等)。
 * @param {string} password - 用户的登录密码。建议在调用此函数前对密码进行加密处理,避免明文传输。
 * @description
 * 发送的消息格式如下:
 * {
 *   command: "user_login ",     // 指定操作为用户登录(注意:末尾有多余空格)
 *   account: "用户账户",
 *   password: "用户密码"
 * }
 */
export const userLogin = (account, password) => {
    return new Promise((resolve, reject) => {
        if (!socket || socket.readyState !== WebSocket.OPEN) {
            return reject(new Error('WebSocket 未连接'))
        }

        const token = sessionStorage.getItem('storage-token')
        if (token) {
            return resolve({ status: 'success', msg: 'already logged in' })
        }

        loginResolve = resolve
        loginReject = reject

        const loginMessage = {
            command: 'user_login',
            account: '',
            password: ''
        }

        try {
            socket.send(JSON.stringify(loginMessage))
        } catch (error) {
            reject(new Error('发送登录消息失败: ' + error.message))
        }
    })
}

/**
 * 心跳检测
 */
const startHeartbeat = () => {
    clearInterval(timer)
    timer = setInterval(() => {
        if (socket.readyState === WebSocket.OPEN) {
            const heartbeatMsg = {
                uuid: sessionStorage.getItem('storage-token'),
                command: 'heartbeat'
            }
            try {
                socket.send(JSON.stringify(heartbeatMsg))
            } catch (e) {
                console.error('心跳发送失败')
            }
        }
    }, 60000)
}
/**
 * 4.2 获取设备信息
 *
 * 该函数用于向指定设备发送指令,以获取与指定账户关联的区域(zone)信息。
 * 它通过调用 sendMessage 函数发送一个包含设备唯一标识和目标账户的命令。
 * @param {string} device_type - 0:分区设备 1:寻呼台设备
 *
 * @description
 * 发送的消息格式如下:
 * {
 *   command: "get_user_zone",  获取用户的分区:get_user_zone
 *   dest_account: "目标账户" // 目标账户名称(自己或子用户)
 * }
 */
export const getDeviceInfo = (type) => {
    return new Promise(async (resolve, reject) => {
        // 如果 WebSocket 未连接,直接拒绝
        if (!socket || socket.readyState !== WebSocket.OPEN) {
            await initWebSocket()
            console.warn('WebSocket 未连')
            return reject(new Error('WebSocket 未连接'))
        }
        // 发送登录消息(注意:原代码 command 末尾有空格,按原逻辑保留)
        const Message = {
            uuid: sessionStorage.getItem('storage-token'),
            command: 'get_device_info',
            device_type: type, // 0:分区设备 1:寻呼台设备 
            all_zone: true, // 是否请求全部分区
            page: 1
            // zone_mac:'' //  指定分区的mac地址:all_zone=0时此字段有效
        }
        try {
            socket.send(JSON.stringify(Message))
            resolve()
        } catch (error) {
            return reject(new Error('发送消息失败: ' + error.message))
        }
    })
}

深入解析 Cursor 规则:为团队打造统一的 AI 编程规范

掌握 Cursor 规则功能,让 AI 编程助手真正理解你的项目需求

在 AI 编程时代,我们经常面临一个挑战:如何让 AI 生成的代码符合团队的技术栈和编码规范?Cursor 的规则功能正是为了解决这一痛点而设计。本文将基于官方文档,为你全面解析 Cursor 规则的使用方法和最佳实践。

规则的核心价值:持久化的上下文指导

大型语言模型在多次补全之间不会保留记忆,而规则正是在提示层面提供持久且可复用的上下文。当规则启用时,其内容会被置于模型上下文的开头,为 AI 在生成代码、解释编辑或协助工作流时提供一致的指导。

Cursor规则主要作用于Agent(聊天)和Inline Edit(Cmd+K)功能。这意味着当你使用Chat对话或行内编辑时,规则会自动生效,确保AI生成的代码符合预定规范。

四种规则类型详解

Cursor 支持四种不同类型的规则,每种都有其特定的适用场景:

1. 项目规则(Project Rules)

项目规则位于 .cursor/rules 目录中,每条规则都是一个独立的文件,并纳入版本控制。这是团队协作中最常用的规则类型。

核心特性:

  • 通过路径模式限定作用范围
  • 支持手动执行或按相关性自动包含
  • 子目录下可以有各自的 .cursor/rules,仅作用于该文件夹

使用场景:

  • 固化与代码库相关的领域知识
  • 自动化项目特定的流程或模板
  • 规范化风格或架构决策

2. 团队规则(Team Rules)

Team 和 Enterprise 计划可以通过 Cursor 控制台在整个组织范围内创建并强制执行规则。

管理特性:

  • 管理员可以配置每条规则对团队成员是否为必选
  • 支持“强制执行”模式,防止用户关闭重要规则
  • 优先级最高:Team Rules → Project Rules → User Rules

适用场景:

  • 跨项目的统一编码标准
  • 组织级的安全和合规要求
  • 确保所有项目遵循相同的最佳实践

3. 用户规则(User Rules)

用户规则是在 Cursor Settings → Rules 中定义的全局偏好,适用于所有项目。它们为纯文本格式,适合设置沟通风格或个人编码偏好。

例如所有问题使用中文回答, 可以这样设置。

Always respond in Chinese-simplified

4. AGENTS.md

AGENTS.md 是一个用于定义代理指令的简单 Markdown 文件,将其放在项目根目录,可作为 .cursor/rules 的替代方案,适用于简单、易读指令且不想引入结构化规则开销的场景。

Cursor 支持在项目根目录和子目录中使用 AGENTS.md。

# 项目说明

## 代码风格

- 所有新文件使用 TypeScript
- React 中优先使用函数组件
- 数据库列使用 snake_case 命名

## 架构

- 遵循仓储模式
- 将业务逻辑保持在服务层中

规则文件结构与编写规范

规则文件格式

每个规则文件使用 MDC(.mdc) 格式编写,这是一种同时支持元数据与内容的格式。通过规则类型下拉菜单控制规则的应用方式:

下面是一个 typescript 的规则文件示例

---
description: TypeScript Patterns
globs: *.ts,*.tsx
---
# TypeScript Patterns

## Type Definitions

### API Response Types
Use consistent API response wrapper types:
```typescript
// For array responses
type TArrayResult<T = unknown> = {
  code: number;
  result: T[];
  message?: string;
  msg?: string;
};

// For single item responses  
type TResult<T = unknown> = {
  code: number;
  result: T;
  message?: string;
  msg?: string;
};

规则类型配置

规则类型在 cursor 中通过下拉框选择, 目前支持四种类型:

类型 描述 适用场景
Always Apply 始终包含在模型上下文中 核心技术栈声明、全局编码规范
Apply Intelligently 根据文件类型和内容智能判断是否包含 根据文件内容智能判断是否包含
Apply to Specific Files 仅在文件被 globs 匹配时应用 根据文件名、路径、内容等智能判断是否包含
Apply Manually 仅在使用 @ruleName 明确提及时才包含 需要特殊处理的场景

嵌套规则机制

Cursor 支持在项目中的各级目录下设置规则,实现精细化的控制:

project/
  .cursor/rules/        # 项目级规则
  backend/
    server/
      .cursor/rules/    # 后端专用规则
  frontend/
    .cursor/rules/      # 前端专用规则

当引用某个目录中的文件时,该目录下的嵌套规则会自动生效,为不同模块提供针对性的指导。

团队协作中的规则管理策略

1. 版本控制集成

.cursor/rules 目录纳入 Git 仓库是确保团队一致性的基础。这样可以:

  • 保证所有成员使用相同的规则配置
  • 方便追踪规则的变更历史
  • 支持代码审查流程应用于规则修改

2. 分层规则设计

针对大型项目,建议采用分层规则结构:

基础层规则(项目根目录):

  • 技术栈和框架约束
  • 全局编码规范
  • 项目架构约定

模块层规则(子目录中):

  • 特定模块的专用规则
  • 业务领域的特殊约定
  • 模块间的接口规范

3. 团队规则强制执行

对于关键的组织标准,使用团队规则的“强制执行”功能:

  • 安全规范:SQL 注入防护、认证授权要求
  • 合规要求:数据隐私、行业规范
  • 质量门禁:代码审查标准、测试覆盖要求

规则创建与优化实践

创建规则的方法

  1. 命令创建:执行 New Cursor Rule 命令或在 Cursor Settings > Rules 中创建

  2. AI 生成:在对话中使用 /Generate Cursor Rules 命令直接生成规则。

  3. 手动编写:基于项目需求手动创建和优化规则文件

Generate Cursor Rules 不仅可以为已存在的项目升成完整的规则文件, 也可以通过添加描述对规则进行优化。

社区有大量成熟的规则模板可供参考,能帮你快速起步:

  • 官方规则库cursor.directory):提供Python、FastAPI、Django、Next.js、TypeScript等多种主流语言或框架的预设规则。
  • Awesome CursorRules:GitHub上的高星开源项目,收集了针对不同场景的大量规则模板。

使用社区规则时,复制内容后根据项目实际情况进行调整是关键,包括修改技术栈版本、更新项目结构描述等。

规则优化最佳实践

根据实战经验,以下是让规则更高效的关键技巧:

精简内容,避免重复

  • 合并重复的技术栈描述,删除冗余信息
  • 避免在规则中写入大量示例代码,除非特别重要

精确控制生效范围

  • 不要所有规则都设为Always,这会浪费token并引入噪声
  • 使用Specific Files按文件类型匹配,或Manual模式按需调用

避免“假大空”的要求

  • 规则应具体可行,如“使用TypeScript接口定义props”
  • 删除像“提高性能”等模糊表述,代之以具体实践

实战技巧:让规则真正生效

增加过程决策机制

在user rule中要求AI在遇到不确定时主动暂停并寻求确认,而不是自行决策。这能避免AI基于错误理解继续生成代码。

采用渐进式开发

将大需求拆解为多个小步骤,逐步完成并验证。任务粒度越小,AI完成度越高,也便于及时发现问题。

明确修改范围

要求AI遵守最小范围修改原则,指哪打哪,避免“画蛇添足”修改无关代码。

.cursorrules

项目根目录中的 .cursorrules(旧版)文件仍受支持,但建议迁移到 Project Rules 或 AGENTS.md。

总结

Cursor 规则功能为团队提供了一种强大的方式来统一 AI 编程助手的行为。通过合理配置项目规则、团队规则和用户规则,团队可以确保 AI 生成的代码符合组织的技术标准和质量要求。

关键要点总结:

  1. 规则提供持久化的上下文,弥补了 AI 模型在多次交互间的记忆空白
  2. 四种规则类型各司其职,满足从个人偏好到组织标准的各种需求
  3. 嵌套规则机制支持精细化的模块级控制
  4. 版本控制集成是团队协作的基础保障
  5. 渐进式优化让规则随着团队成长而不断完善

通过系统性地应用 Cursor 规则,你的团队将能够充分发挥 AI 编程的潜力,同时保持代码质量和风格的一致性。现在就开始为你的项目配置规则,体验智能化协作开发的新高度吧!

公众号会持续输出更多技术文章,欢迎关注。

Canvas 入门及常见功能实现

Canvas 绘制基础图形详解

Canvas 是 HTML5 核心绘图 API,支持在网页中动态绘制矢量图形。本文将系统讲解 Canvas 基础图形(线条、三角形、矩形、圆形)及组合图形(笑脸)的绘制方法,并附带完整代码与关键说明。

一、基础环境搭建(HTML + CSS + 初始化)

首先创建 Canvas 容器与绘图上下文,设置基础样式确保绘图区域清晰可见。

<style>
  /* 容器样式:优化布局与视觉效果 */
  .canvas-container {
    background-color: #f8fafc; /* 浅灰背景,区分页面其他区域 */
    padding: 20px;
    max-width: 600px;
    margin: 20px auto; /* 水平居中 */
    border-radius: 8px; /* 圆角优化 */
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 轻微阴影增强层次感 */
  }
  /* Canvas 样式:明确绘图边界 */
  #basic-canvas {
    border: 4px dashed #cbd5e1; /* 虚线边框,区分画布区域 */
    background-color: #ffffff; /* 白色画布,便于观察图形 */
    border-radius: 4px;
  }
</style>

<!-- 画布容器 -->
<div class="canvas-container">
  <!-- Canvas 核心元素:width/height 需直接设置(非CSS),确保图形不失真 -->
  <canvas id="basic-canvas" width="500" height="200"></canvas>
</div>

<script>
  // 1. 获取 Canvas 元素与 2D 绘图上下文(核心对象)
  const canvas = document.getElementById('basic-canvas')
  const ctx = canvas.getContext('2d') // 所有绘图操作都通过 ctx 实现

  // 2. 设置公共样式(避免重复代码)
  ctx.lineWidth = 2 // 线条宽度(所有图形通用)
  ctx.strokeStyle = '#2d3748' // 线条颜色(深灰,比黑色更柔和)

  // 3. 页面加载完成后执行绘图(确保 Canvas 已渲染)
  window.addEventListener('load', () => {
    drawLine() // 绘制线条
    drawTriangle() // 绘制三角形
    drawRectangle() // 绘制矩形(原 Square 更准确的命名)
    drawCircle() // 绘制圆形
    drawSmilingFace() // 绘制笑脸(组合图形)
  })
</script>

二、Canvas 路径绘制核心 API

在绘制路径之前先介绍几个常用的canvas的api。

  1. beginPath() 新建一条路径,生成之后,图形绘制命令被指向到路径上生成路径。
  2. closePath() 闭合路径之后图形绘制命令又重新指向到上下文中。
  3. stroke() 通过线条来绘制图形轮廓。
  4. fill() 通过填充路径的内容区域生成实心的图形。
  5. moveTo(x, y) 将笔触移动到指定的坐标 x 以及 y 上。
  6. lineTo(x, y) 绘制一条从当前位置到指定 x 以及 y 位置的直线。

三、具体图形绘制实现

1. 绘制直线(基础入门)

通过 moveTo() 定位起点,lineTo() 绘制线段,最后用 stroke() 渲染轮廓。

function drawLine() {
  ctx.beginPath() // 开启新路径(避免与其他图形混淆)
  ctx.moveTo(25, 25) // 起点:(25,25)(Canvas 左上角为原点 (0,0))
  ctx.lineTo(105, 25) // 终点:(105,25)(水平向右绘制)
  ctx.stroke() // 渲染直线轮廓
}

2. 绘制三角形(空心 + 实心)

三角形由三条线段组成,空心需手动闭合路径,实心可直接填充(自动闭合)。

function drawTriangle() {
  // 1. 绘制空心三角形
  ctx.beginPath()
  ctx.moveTo(150, 25) // 顶点1
  ctx.lineTo(200, 25) // 顶点2(水平向右)
  ctx.lineTo(150, 75) // 顶点3(向左下方)
  ctx.closePath() // 闭合路径(连接顶点3与顶点1)
  ctx.stroke() // 渲染空心轮廓

  // 2. 绘制实心三角形(位置偏移,避免与空心重叠)
  ctx.beginPath()
  ctx.moveTo(155, 30) // 顶点1(右移5px,下移5px)
  ctx.lineTo(185, 30) // 顶点2(缩短宽度,更美观)
  ctx.lineTo(155, 60) // 顶点3(上移15px,避免超出范围)
  ctx.fillStyle = '#4299e1' // 单独设置填充色(蓝色)
  ctx.fill() // 填充实心(无需 closePath(),自动闭合)
}

3. 绘制矩形(专用 API,更高效)

Canvas 为矩形提供了专用方法,无需手动写路径,直接指定位置与尺寸即可。

function drawRectangle() {
  // 1. 空心矩形:strokeRect(x, y, 宽度, 高度)
  ctx.strokeRect(10, 100, 50, 50) // 位置(10,100),尺寸50x50

  // 2. 实心矩形:fillRect(x, y, 宽度, 高度)(偏移避免重叠)
  ctx.fillStyle = '#48bb78' // 填充色(绿色)
  ctx.fillRect(15, 105, 40, 40) // 位置(15,105),尺寸40x40

  // 3. 清除矩形区域:clearRect(x, y, 宽度, 高度)(生成“镂空”效果)
  ctx.clearRect(25, 115, 20, 20) // 清除中间20x20区域,变为透明
}

4. 绘制圆形(arc () 方法详解)

圆形通过 arc() 方法绘制,核心是理解「弧度制」与「绘制方向」。

arc () 方法语法: arc(x, y, radius, startAngle, endAngle, anticlockwise)

  • x, y:圆心坐标
  • radius:圆的半径
  • startAngle/endAngle:起始 / 结束角度(必须用弧度制,公式:弧度 = (Math.PI / 180) * 角度)
  • anticlockwise:是否逆时针绘制(布尔值,默认 false 顺时针)
function drawCircle() {
  // 1. 绘制完整圆形(360° = 2π 弧度)
  ctx.beginPath()
  ctx.arc(100, 125, 25, 0, Math.PI * 2, false) // 圆心(100,125),半径25
  ctx.stroke()

  // 2. 绘制上半圆(逆时针,180° = π 弧度)
  ctx.beginPath()
  ctx.arc(100, 125, 15, 0, Math.PI, true) // 半径15,逆时针绘制上半圆
  ctx.stroke()

  // 3. 绘制实心下半圆(顺时针)
  ctx.beginPath()
  ctx.arc(100, 130, 10, 0, Math.PI, false) // 圆心下移5px,半径10
  ctx.fillStyle = '#f6ad55' // 填充色(橙色)
  ctx.fill()
}

注意事项:为了保证新的圆弧不会追加到上一次的路径中,在每一次绘制圆弧的过程中都需要使用beginPath()方法。

5. 绘制组合图形(笑脸)

通过组合「圆形(脸)+ 小圆(眼睛)+ 半圆(嘴巴)」,实现复杂图形。

function drawSmilingFace() {
  // 1. 绘制脸部轮廓(圆形)
  ctx.beginPath()
  ctx.arc(170, 125, 25, 0, Math.PI * 2, false) // 圆心(170,125),半径25
  ctx.stroke()

  // 2. 绘制左眼(小圆)
  ctx.beginPath()
  ctx.arc(163, 120, 3, 0, Math.PI * 2, false) // 左眼位置:左移7px,上移5px
  ctx.fillStyle = '#2d3748' // 眼睛颜色(深灰)
  ctx.fill() // 实心眼睛,无需 stroke()

  // 3. 绘制右眼(小圆,与左眼对称)
  ctx.beginPath()
  ctx.arc(178, 120, 3, 0, Math.PI * 2, false) // 右眼位置:右移8px,上移5px
  ctx.fill()

  // 4. 绘制微笑嘴巴(下半圆,顺时针)
  ctx.beginPath()
  ctx.arc(170, 123, 18, 0, Math.PI, false) // 圆心(170,123),半径18,180°
  ctx.stroke()
}

完整效果展示:

四、常见问题与注意事项

  1. Canvas 尺寸设置: width 和 height 必须直接在 Canvas 标签上设置,若用 CSS 设置会导致图形拉伸失真。
  2. 路径隔离: 每次绘制新图形前,务必调用 beginPath(),否则新图形会与上一次路径叠加。
  3. 弧度与角度转换: arc() 方法仅支持弧度制,需用 (Math.PI / 180) * 角度 转换(如 90° = Math.PI/ 2)。
  4. 样式优先级: 若单个图形需要特殊样式(如不同颜色),需在 stroke()/fill() 前单独设置(如 ctx.fillStyle),否则会继承公共样式。

Canvas 实现电子签名功能

电子签名功能在现代 Web 应用中非常常见,从在线合同签署到表单确认都有广泛应用。本文将带你从零开始,使用 Canvas API 实现一个功能完备的电子签名组件。

一、实现思路与核心技术点

实现电子签名的核心思路是追踪用户的鼠标或触摸轨迹,并在 Canvas 上将这些轨迹绘制出来。

核心技术点:

  • Canvas API:用于在网页上动态绘制图形
  • 事件监听:监听鼠标 / 触摸的按下、移动和松开事件
  • 坐标转换:将鼠标 / 触摸事件的坐标转换为 Canvas 元素内的相对坐标
  • 线条优化:通过设置线条属性实现平滑的签名效果

二、HTML 结构设计

这是一份简单到爆的html结构,没错,就是这样简单...

<div class="container">
  <p>电子签名</p>
  <canvas id="signatureCanvas" class="signature-border"></canvas>
</div>

三、CSS 样式设置

为 Canvas 添加一些基础样式,使其看起来像一个签名板。

.container {
  background-color: #fff;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.signature-border {
  width: 98%;
  height: 300px;
  border: 4px dashed #cbd5e1;
  border-radius: 10px;
  cursor: crosshair;
}

四、JavaScript 核心实现

这是实现签名功能的关键部分,主要包含以下几个步骤:

  1. 获取 Canvas 元素和上下文
  2. 设置 Canvas 的实际绘制尺寸
  3. 定义变量存储签名状态和坐标
  4. 实现坐标转换函数
  5. 编写事件处理函数
  6. 绑定事件监听器
// 获取Canvas元素和上下文
const canvas = document.getElementById('signatureCanvas')
const ctx = canvas.getContext('2d', { willReadFrequently: true })

// 签名状态变量
let isDrawing = false
let lastX = 0
let lastY = 0
let lineColor = '#000000'
let lineWidth = 2

// 初始化Canvas
function initCanvas() {
  // 设置Canvas样式
  ctx.strokeStyle = lineColor
  ctx.lineWidth = lineWidth
  ctx.lineJoin = 'round'
  ctx.lineCap = 'round'

  resizeCanvas()
  window.addEventListener('resize', resizeCanvas)
}

// 响应窗口大小变化
function resizeCanvas() {
  const rect = canvas.getBoundingClientRect()
  const { width, height } = rect
  // 保存当前画布内容
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
  // 调整Canvas尺寸
  canvas.width = width
  canvas.height = height
  // 恢复画布内容
  ctx.putImageData(imageData, 0, 0)
  // 重新设置绘图样式
  ctx.strokeStyle = lineColor
  ctx.lineWidth = lineWidth
  ctx.lineJoin = 'round'
  ctx.lineCap = 'round'
}

// 获取坐标(适配鼠标和触摸事件)
function getCoordinates(e) {
  const rect = canvas.getBoundingClientRect()
  if (e.type.includes('mouse')) {
    return [e.clientX - rect.left, e.clientY - rect.top]
  } else if (e.type.includes('touch')) {
    return [e.touches[0].clientX - rect.left, e.touches[0].clientY - rect.top]
  }
}

// 开始绘制
function startDrawing(e) {
  isDrawing = true
  lastX = getCoordinates(e)[0]
  lastY = getCoordinates(e)[1]
}

// 绘制中
function draw(e) {
  if (!isDrawing) return
  const [currentX, currentY] = getCoordinates(e)
  ctx.beginPath()
  ctx.moveTo(lastX, lastY)
  ctx.lineTo(currentX, currentY)
  ctx.stroke()
  // 解释: 这里是将当前移动的坐标赋值给下一次绘制的起点,实现线条的流畅。
  ;[lastX, lastY] = [currentX, currentY]
}

// 结束绘制
function stopDrawing() {
  isDrawing = false
}

// 绑定事件监听
function bindEvents() {
  canvas.addEventListener('mousedown', startDrawing)
  canvas.addEventListener('mousemove', draw)
  canvas.addEventListener('mouseup', stopDrawing)
  canvas.addEventListener('mouseout', stopDrawing)
  // 触摸事件(移动设备)
  canvas.addEventListener('touchstart', e => {
    e.preventDefault() // 防止触摸事件被浏览器默认处理
    startDrawing(e)
  })
  canvas.addEventListener('touchmove', e => {
    e.preventDefault()
    draw(e)
  })
  canvas.addEventListener('touchend', e => {
    e.preventDefault()
    stopDrawing()
  })
}

// 初始化
window.addEventListener('load', () => {
  initCanvas()
  bindEvents()
})

五、功能亮点与设计思路

  1. 流畅的绘制体验:通过设置lineCap: 'round'lineJoin: 'round'让线条更加平滑自然。
  2. 响应式设计:监听窗口resize事件,动态调整 Canvas 尺寸,确保在不同设备和屏幕尺寸下都能正常工作。
  3. 跨设备支持:同时支持鼠标和触摸事件,兼容桌面和移动设备。

六、完整的代码

七、下一步可以探索的方向

  1. 颜色和粗细选择:增加 UI 控件让用户自定义签名的颜色和笔触粗细。
  2. 清空签名和保存签名:增加 UI 控件让用户清空当前的签名,同时支持保存和下载签名。

canvas 实现滚动序列帧动画

前言

在现代网页设计中,滚动触发的动画能极大增强用户体验,其中 Apple 官网的 AirPods Pro 产品页动画堪称经典 —— 通过滚动进度控制序列帧播放,营造出流畅的产品展示效果。本文将简单的实现一下这个动画效果。

一、动画核心逻辑

  1. 页面分为 3 个楼层:楼层 1(灰色背景)、楼层 2(黑色背景,核心动画区)、楼层 3(灰色背景)
  2. 楼层 2 高度为200vh(2 倍视口高度),内部有一个sticky定位的容器,包含文字和 Canvas
  3. 当用户滚动页面时,仅在楼层 2 进入并完全离开视口的过程中,Canvas 会根据滚动进度播放 147 帧 AirPods 序列图
  4. 窗口尺寸变化时,Canvas 会自动适配,保证动画显示比例正确

二、核心技术栈及原理拆解

要实现滚动序列帧动画,需要解决 3 个核心问题:序列帧加载与管理、滚动进度计算、Canvas 渲染与适配。

  1. HTML 部分的核心是三层 section 结构和Canvas 动画容器,结构清晰且语义化:
<!-- 楼层1:引导区 -->
<section class="floor1-container floor-container">
  <p>楼层一</p>
</section>
<!-- 楼层2:核心动画区(目标楼层) -->
<section class="floor2-container floor-container" id="targetFloor">
  <!-- sticky容器:滚动时"粘住"视口 -->
  <div class="sticky">
    <p>楼层二</p>
    <!-- Canvas:用于渲染序列帧 -->
    <canvas class="canvas" id="hero-lightpass"></canvas>
  </div>
</section>
<!-- 楼层3:结束区 -->
<section class="floor3-container floor-container">
  <p>楼层三</p>
</section>
  1. CSS 的核心作用是控制三层布局、实现 sticky 定位、保证 Canvas 适配,代码注释已标注关键逻辑:
/* 重置默认margin,避免布局偏移 */
body,
p {
  margin: 0;
}

/* 楼层1和楼层3样式:灰色背景+居中文字 */
.floor1-container,
.floor3-container {
  background-color: #474646; /* 深灰色背景 */
  height: 500px; /* 固定高度,模拟常规内容区 */
  display: flex; /* Flex布局:实现文字水平+垂直居中 */
  justify-content: center; /* 水平居中 */
  align-items: center; /* 垂直居中 */
}

/* 楼层1/3文字样式:响应式字体 */
.floor3-container p,
.floor1-container p {
  font-size: 5vw; /* 5vw:相对于视口宽度的5%,实现响应式字体 */
  color: #fff; /* 白色文字,与深色背景对比 */
}

/* 楼层2样式:黑色背景+高高度(动画触发区) */
.floor2-container {
  height: 200vh; /* 200vh:2倍视口高度,保证有足够滚动空间触发动画 */
  background-color: black; /* 黑色背景,突出产品图片 */
  color: #fff; /* 白色文字 */
}

/* 楼层2文字:水平居中 */
.floor2-container p {
  text-align: center;
}

/* 核心:sticky定位容器 */
.sticky {
  position: sticky; /* 粘性定位:滚动到top:0时固定 */
  top: 0; /* 固定在视口顶部 */
  height: 500px; /* 与楼层1/3高度一致,保证视觉连贯 */
  width: 100%; /* 占满视口宽度 */
}

/* Canvas样式:宽度自适应 */
.canvas {
  width: 100%; /* 宽度占满容器 */
  height: auto; /* 高度自动,保持图片比例 */
}
  1. JS 部分是整个动画的核心,负责预加载序列帧、计算滚动进度、控制 Canvas 渲染和窗口适配,我们分模块解析:

模块 1:初始化变量与 DOM 元素

首先定义动画所需的核心变量,包括序列帧数量、图片数组、Canvas 上下文等:

// 1. 动画核心配置
const frameCount = 147 // 序列帧总数(根据实际图片数量调整)
const images = [] // 存储所有预加载的序列帧图片
const canvas = document.getElementById('hero-lightpass') // 获取Canvas元素
const context = canvas.getContext('2d') // 获取Canvas 2D渲染上下文
const airpods = { frame: 0 } // 存储当前播放的帧序号(用对象便于修改)

// 2. 获取目标楼层(楼层2)的DOM元素,用于后续计算滚动位置
const targetFloor = document.getElementById('targetFloor')

// 3. 序列帧图片地址模板(Apple官网的AirPods序列帧地址)
// 作用:通过索引生成每帧图片的URL(如0001.jpg、0002.jpg...)
const currentFrame = index =>
  `https://www.apple.com/105/media/us/airpods-pro/2019/1299e2f5_9206_4470_b28e_08307a42f19b/anim/sequence/large/01-hero-lightpass/${(index + 1).toString().padStart(4, '0')}.jpg`

模块 2:预加载所有序列帧图片

序列帧动画需要所有图片加载完成后才能流畅播放,因此必须先预加载图片:

// 循环生成147帧图片,存入images数组
for (let i = 0; i < frameCount; i++) {
  const img = new Image() // 创建Image对象
  img.src = currentFrame(i) // 给图片设置URL(通过模板生成)
  images.push(img) // 将图片存入数组
}

// 当第一张图片加载完成后,执行首次渲染(避免页面空白)
images[0].onload = render

为什么要预加载:

  1. 如果不预加载,用户滚动时图片可能还在加载,导致动画卡顿或跳帧
  2. 监听第一张图片的onload事件:保证页面初始化时至少有一张图显示,提升首屏体验

模块 3:Canvas 渲染函数

定义render()函数,负责将当前帧图片绘制到 Canvas 上:

function render() {
  // 1. 清除Canvas画布(避免上一帧残留)
  context.clearRect(0, 0, canvas.width, canvas.height)

  // 2. 绘制当前帧图片
  // 参数:图片对象、绘制起点X、Y、绘制宽度、绘制高度
  context.drawImage(images[airpods.frame], 0, 0, canvas.width, canvas.height)
}

模块 4:Canvas 窗口适配函数

当窗口尺寸变化时,需要重新调整 Canvas 的宽高,避免图片拉伸或变形:

function resizeCanvas() {
  // 1. 获取Canvas元素的实际位置和尺寸(包含CSS样式的影响)
  const rect = canvas.getBoundingClientRect()

  // 2. 设置Canvas的实际宽高(Canvas的width/height是像素尺寸,而非CSS样式)
  canvas.width = rect.width
  canvas.height = rect.height

  // 3. 重新渲染当前帧(避免尺寸变化后画布空白)
  render()
}

易错点提醒:

  1. Canvas 有两个 "尺寸":一个是 HTML 属性width/height(实际像素尺寸),另一个是 CSS 样式width/height(显示尺寸)
  2. 如果只改 CSS 样式而不改canvas.width/height,图片会拉伸变形;因此必须通过getBoundingClientRect()获取实际显示尺寸,同步设置 Canvas >的像素尺寸

模块 5:滚动进度计算与帧控制(核心中的核心)

这是整个动画的逻辑核心 —— 根据用户的滚动位置,计算当前应播放的帧序号,实现 "滚动控制动画":

function handleScroll() {
  // 1. 获取关键尺寸数据
  const viewportHeight = window.innerHeight // 视口高度(浏览器可见区域高度)
  const floorTop = targetFloor.offsetTop // 目标楼层(楼层2)距离页面顶部的距离
  const floorHeight = targetFloor.offsetHeight // 目标楼层自身的高度(200vh)
  const currentScrollY = window.scrollY // 当前滚动位置(页面顶部到视口顶部的距离)

  // 2. 计算"滚动结束点":当目标楼层底部进入视口时,动画应播放到最后一帧
  const scrollEnd = floorTop + floorHeight - viewportHeight

  // 3. 计算滚动进度(0~1):0=未进入楼层2,1=完全离开楼层2
  let scrollProgress = 0
  if (currentScrollY < floorTop) {
    // 情况1:滚动位置在楼层2上方→进度0(显示第一帧)
    scrollProgress = 0
  } else if (currentScrollY > scrollEnd) {
    // 情况2:滚动位置在楼层2下方→进度1(显示最后一帧)
    scrollProgress = 1
  } else {
    // 情况3:滚动位置在楼层2内部→计算相对进度
    const scrollDistanceInFloor = currentScrollY - floorTop // 进入楼层2后滚动的距离
    const totalScrollNeeded = scrollEnd - floorTop // 楼层2内需要滚动的总距离(触发完整动画的距离)
    scrollProgress = scrollDistanceInFloor / totalScrollNeeded // 进度=已滚动距离/总距离
  }

  // 4. 根据进度计算当前应显示的帧序号
  // 公式:目标帧 = 进度 × (总帧数-1) → 保证进度1时显示最后一帧(避免数组越界)
  const targetFrame = Math.floor(scrollProgress * (frameCount - 1))

  // 5. 优化性能:仅当帧序号变化时才重新渲染
  if (targetFrame !== airpods.frame) {
    airpods.frame = targetFrame
    render() // 重新绘制当前帧
  }
}

模块 6:事件监听与初始化

最后,通过事件监听触发上述逻辑,完成动画初始化:

window.addEventListener('load', () => {
  // 1. 监听滚动事件:用户滚动时触发进度计算
  window.addEventListener('scroll', handleScroll)

  // 2. 监听窗口 resize 事件:窗口尺寸变化时适配Canvas
  window.addEventListener('resize', resizeCanvas)

  // 3. 初始化Canvas尺寸(页面加载完成后首次适配)
  resizeCanvas()
})

三、完成代码展示

更多canvas功能敬请期待...

JS核心知识-Ajax

在现代Web开发中,用户体验已经成为衡量应用成功与否的关键指标。回想早期的互联网,每次与服务器交互都需要刷新整个页面,这种"白屏-等待-刷新"的体验显然无法满足当今用户对流畅操作的需求。

在这样的背景下,Ajax技术应运而生。它如同为网页装上了隐形翅膀,让数据交互在后台静默进行,用户无需等待页面刷新即可获取最新内容。从Gmail的无刷新操作到Google Maps的流畅拖动,从社交媒体的实时更新到电商网站的动态加载,Ajax已成为现代Web应用的基石技术。

本文将深入探索Ajax的核心原理,从概念到底层机制,从简单使用到企业级封装,逐步揭开这项改变Web开发格局的技术面纱。

什么是Ajax

Ajax(Asynchronous JavaScript and XML)是一种创建交互式网页应用的开发技术。它允许网页在不重新加载整个页面的情况下,与服务器交互数据并更新部分页面内容。

Ajax这个术语最早在2005年由Jesse James Garrett提出,但相关技术在此之前已经存在。它的出现标志着Web 2.0时代的到来,让网页应用具备了与桌面应用相媲美的交互体验。

核心特点:

  • 异步通信:浏览器可以在不阻碍用户操作的情况下与服务器通信
  • 局部更新:只更新页面中需要变化的部分,而不是整个页面
  • 更好的用户体验:用户操作几乎无感知,页面响应更加流畅

Ajax的底层机制

Ajax的核心在于XMLHttpRequest对象,它充当了浏览器与服务器之间的中间人角色。让我们深入了解其底层运作机制:

整体架构

image.png

XMLHttpRequest与网络栈中的各个模块协同配合完成与服务器的交互,主要包含以下模块:

  • HTTP处理器:处理HTTP协议相关的所有逻辑
  • DNS解析器:将域名转换为IP地址
  • 安全引擎:处理HTTPS加密通信
  • 套接字管理器:管理TCP连接和网络I/O
  • 缓存管理器:管理HTTP缓存,提高性能
  • Cookie管理器:管理HTTP Cookie的存储和发送

请求发送流程

image.png

响应处理流程

image.png

Ajax使用详解

创建XMLHttpRequest对象

var xhr = new XMLHttpRequest();

配置请求

xhr.open('GET', 'https://api.example.com/data', true);

通过XMLHttpRequest对象的open方法配置请求,接收三个参数:

  • 请求方法:GET、POST、PUT、DELETE等
  • 请求地址:获取服务器数据的地址
  • 是否异步:true为异步,false为同步请求(现代开发基本都使用异步请求)

设置请求头(可选)

// 设置需要的请求类型
xhr.setRequestHeader('Content-Type', 'application/json');

处理响应

xhr.onreadystatechange = function() {
// 判断请求/响应处理完成阶段
  if (xhr.readyState === 4) {
    // 判断响应HTTP状态  304 Not Modified 也表示成功(缓存有效)
    if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
        // 处理响应成功
        console.log('请求成功:', xhr.responseText);
    } else {
        // 处理响应失败
        console.error('请求失败:', xhr.status, xhr.statusText);
    }
  }
};

通过XMLHttpRequest对象的onreadystatechange函数监听请求/响应阶段,然后判断HTTP状态来处理业务逻辑。readyState的可能值:

  • 0:未初始化。尚未调用open方法
  • 1:已打开。已调用open方法,尚未调用send方法
  • 2:已发送。已调用send方法,尚未收到响应
  • 3:接收中。已收到部分响应
  • 4:完成。已收到所有响应,可以使用了

在XMLHttpRequest Level 2中,可以使用onload事件替代onreadystatechange,无需判断readyState属性:

xhr.onload = function() {
  // 判断响应HTTP状态 304 Not Modified 也表示成功(缓存有效)
  if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
    // 处理响应成功
  } else {
    // 处理响应失败
  }
};

发送请求

xhr.send(null); // GET请求
// 如果是POST请求:xhr.send(data),data是服务器需要的参数

其他事件

XMLHttpRequest对象还提供其他实用事件:

  • ontimeout:处理请求超时
xhr.timeout = 5000;
xhr.ontimeout = function() {
    // 处理超时情况
};
  • onerror:处理请求错误
  • abort() :取消请求
  • onprogress:监听请求进度
xhr.onprogress = function(event) {
  // event中包含三个重要属性:
  // lengthComputable - 布尔值,表示进度信息是否可用
  // loaded - 已接收字节数
  // total - 响应的Content-Length头部定义的总字节数
  if (event.lengthComputable) {
    const percentComplete = (event.loaded / event.total) * 100;
    console.log(`进度: ${percentComplete.toFixed(2)}%`);
  }
};

注意:为确保正确执行,必须在调用open之前添加onprogress事件。

Ajax的企业级封装

原生Ajax使用较为繁琐,封装势在必行。下面逐步封装一个功能完整的Ajax库。

基础结构搭建

/**
 * Ajax请求类 - 企业级封装
 * 提供完整的HTTP请求功能,支持并发控制、重试机制、错误处理等
 */
class AjaxRequest {
    /**
     * 构造函数
     * @param {Object} baseConfig 基础配置
     */
    constructor(baseConfig = {}) {
        // 默认配置,用户配置会覆盖这些默认值
        this.defaultConfig = {
            baseURL: '',                    // 基础URL路径
            timeout: 10000,                 // 请求超时时间(毫秒)
            headers: {                      // 默认请求头
                'Content-Type': 'application/json'
            },
            responseType: 'text',           // 响应类型:text, json, blob, arraybuffer
            withCredentials: false,         // 是否携带跨域cookie
            retry: 0,                       // 重试次数
            retryDelay: 1000,               // 重试延迟时间(毫秒)
            maxPendingRequests: 50,         // 最大并发请求数
            requestTimeout: 30000,          // 请求超时自动清理时间(毫秒)
            validateStatus: (status) => status >= 200 && status < 300, // 状态码验证函数
            shouldRetry: (error) => {       // 重试条件判断函数
                // 只在网络错误或5xx服务器错误时重试
                return error.type === 'NETWORK_ERROR' || 
                       (error.status >= 500 && error.status < 600);
            },
            xsrfCookieName: 'XSRF-TOKEN',   // CSRF token的cookie名称
            xsrfHeaderName: 'X-XSRF-TOKEN', // CSRF token的请求头名称
            ...baseConfig
        };

        // 存储进行中的请求,用于并发控制和请求取消
        this.pendingRequests = new Map();

        // 请求ID计数器,用于生成唯一请求标识
        this.requestIdCounter = 0;
    }

    /**
     * 创建新的AjaxRequest实例
     * @param {Object} config 实例配置
     * @returns {AjaxRequest} 新的实例
     */
    create(config = {}) {
        return new AjaxRequest({ ...this.defaultConfig, ...config });
    }

    /**
     * 设置默认配置
     * @param {Object} config 配置对象
     * @returns {AjaxRequest} 当前实例(支持链式调用)
     */
    setConfig(config) {
        this.defaultConfig = { ...this.defaultConfig, ...config };
        return this;
    }
}

核心请求方法实现

class AjaxRequest {
    // ... 之前的代码 ...

    /**
     * 核心请求方法
     * @param {Object} config 请求配置
     * @returns {Promise} 请求Promise对象
     */
    async request(config) {
        // 1. 验证配置合法性
        this.validateConfig(config);

        // 2. 合并配置(默认配置 + 用户配置)
        const mergedConfig = { ...this.defaultConfig, ...config };

        // 3. 生成请求唯一标识
        const requestKey = this.generateRequestKey(mergedConfig);

        // 4. 清理过期的请求,防止内存泄漏
        this.cleanupExpiredRequests();

        // 5. 检查并发数限制
        if (this.pendingRequests.size >= mergedConfig.maxPendingRequests) {
            throw this.createError('同时发起的请求过多,请稍后重试', 'TOO_MANY_REQUESTS');
        }

        // 6. 防重复请求检查(相同URL、参数、方法的请求)
        if (this.pendingRequests.has(requestKey)) {
            console.warn('重复请求已被阻止:', requestKey);
            return this.pendingRequests.get(requestKey).promise;
        }

        let lastError; // 记录最后一次错误

        // 7. 重试机制:尝试请求(初始请求 + 重试次数)
        for (let attempt = 0; attempt <= mergedConfig.retry; attempt++) {
            try {
                // 7.1 非首次请求时添加延迟(指数退避)
                if (attempt > 0) {
                    console.log(`第${attempt}次重试请求: ${mergedConfig.url}`);
                    await this.delay(mergedConfig.retryDelay * attempt);
                }

                // 7.2 发送单次请求
                const requestPromise = this.sendSingleRequest(mergedConfig, requestKey);

                // 7.3 只在第一次尝试时存储到pendingRequests(避免重复存储)
                const requestInfo = {
                  promise: requestPromise,
                  timestamp: Date.now(),
                  timeout: mergedConfig.requestTimeout,
                  config: mergedConfig,
                  xhr: xhr  // 存储xhr实例用于取消操作
                };
                this.pendingRequests.set(requestKey, requestInfo);

                // 7.4 等待请求结果
                const result = await requestPromise;
                return result;

            } catch (error) {
                lastError = error; // 记录错误

                // 7.5 检查是否应该继续重试
                if (attempt < mergedConfig.retry && mergedConfig.shouldRetry(error)) {
                    console.log(`请求失败,进行第${attempt + 1}次重试:`, error.message);
                    continue; // 继续重试
                }
                break; // 不再重试,退出循环
            }
        }

        // 8. 所有重试都失败,抛出最后一次错误
        throw lastError;
    }

    /**
     * 发送单次请求(不包含重试逻辑)
     * @param {Object} config 请求配置
     * @param {string} requestKey 请求唯一标识
     * @returns {Promise} 请求Promise
     */
    sendSingleRequest(config, requestKey) {
        return new Promise((resolve, reject) => {
            // 1. 创建新的XMLHttpRequest实例(每次请求都是独立的)
            const xhr = new XMLHttpRequest();

            const { 
                method = 'GET', 
                url, 
                data = null, 
                headers = {}, 
                timeout,
                responseType,
                withCredentials
            } = config;

            // 2. 构建完整URL(处理baseURL)
            const fullUrl = config.baseURL ? `${config.baseURL}${url}` : url;

            // 3. 初始化请求
            xhr.open(method.toUpperCase(), fullUrl, true);

            // 4. 配置XHR对象
            if (responseType) xhr.responseType = responseType;
            if (withCredentials) xhr.withCredentials = true;

            // 5. 设置请求头(包含CSRF保护)
            this.setHeaders(xhr, headers, config);

            // 6. 设置超时时间
            xhr.timeout = timeout;

            // 7. 注册事件监听器

            // 7.1 请求成功完成
            xhr.onload = () => {
                // 从pendingRequests中移除已完成的请求
                this.pendingRequests.delete(requestKey);

                // 验证状态码
                if (config.validateStatus(xhr.status)) {
                    resolve(this.handleResponse(xhr, config));
                } else {
                    reject(this.handleError(xhr, config));
                }
            };

            // 7.2 网络错误
            xhr.onerror = () => {
                this.pendingRequests.delete(requestKey);
                reject(this.handleError(xhr, config));
            };

            // 7.3 请求超时
            xhr.ontimeout = () => {
                this.pendingRequests.delete(requestKey);
                reject(this.createError(`请求超时: ${timeout}ms`, 'TIMEOUT_ERROR', xhr));
            };

            // 7.4 请求被取消
            xhr.onabort = () => {
                this.pendingRequests.delete(requestKey);
                reject(this.createError('请求已被取消', 'ABORT_ERROR', xhr));
            };

            // 8. 进度事件监听(可选)
            if (config.onUploadProgress) {
                xhr.upload.onprogress = config.onUploadProgress;
            }

            if (config.onDownloadProgress) {
                xhr.onprogress = config.onDownloadProgress;
            }

            // 9. 发送请求数据
            try {
                xhr.send(this.processData(data, headers));
            } catch (sendError) {
                this.pendingRequests.delete(requestKey);
                reject(this.createError(`请求发送失败: ${sendError.message}`, 'SEND_ERROR', xhr));
            }
        });
    }

    /**
     * 生成请求唯一标识
     * @param {Object} config 请求配置
     * @returns {string} 请求唯一标识
     */
    generateRequestKey(config) {
        const { method = 'GET', url, data } = config;
        // 使用请求方法、URL、数据生成唯一key
        const dataStr = data ? JSON.stringify(data) : '';
        return `${method}:${url}:${dataStr}`;
    }

    /**
     * 清理过期的请求
     */
    cleanupExpiredRequests() {
        const now = Date.now();
        for (const [key, request] of this.pendingRequests) {
            // 检查请求是否超时
            if (now - request.timestamp > request.timeout) {
                this.pendingRequests.delete(key);
                console.warn(`请求超时自动清理: ${key}`);
            }
        }
    }

    /**
     * 延迟函数
     * @param {number} ms 延迟时间(毫秒)
     * @returns {Promise} 延迟Promise
     */
    delay(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
}

数据处理和错误处理

class AjaxRequest {
        // ... 之前的代码 ...
        
        /**
         * 设置请求头
         * @param {XMLHttpRequest} xhr XHR对象
         * @param {Object} headers 请求头对象
         * @param {Object} config 请求配置
         */
        setHeaders(xhr, headers, config) {
            // 1. 添加CSRF保护(如果启用)
            if (config.withCredentials) {
                const xsrfValue = this.getCookie(config.xsrfCookieName);
                if (xsrfValue && config.xsrfHeaderName) {
                    xhr.setRequestHeader(config.xsrfHeaderName, xsrfValue);
                }
            }
            
            // 2. 设置其他请求头
            Object.keys(headers).forEach(key => {
                if (headers[key] !== undefined && headers[key] !== null) {
                    // 检查是否为危险头信息(浏览器禁止设置的请求头)
                    if (!this.isDangerousHeader(key)) {
                        xhr.setRequestHeader(key, headers[key]);
                    } else {
                        console.warn(`跳过设置危险请求头: ${key}`);
                    }
                }
            });
        }
        
        /**
         * 处理响应数据
         * @param {XMLHttpRequest} xhr XHR对象
         * @param {Object} config 请求配置
         * @returns {Object} 响应对象
         */
        handleResponse(xhr, config) {
            let data;
            
            // 根据responseType获取数据
            switch (xhr.responseType) {
                case 'json':
                    data = xhr.response; // 浏览器自动解析JSON
                    break;
                case 'blob':
                    data = xhr.response; // Blob对象
                    break;
                case 'arraybuffer':
                    data = xhr.response; // ArrayBuffer对象
                    break;
                case 'document':
                    data = xhr.response; // Document对象
                    break;
                default:
                    // 默认text类型,需要手动处理JSON
                    data = xhr.responseText;
                    // 自动JSON解析(如果内容是JSON格式)
                    const contentType = xhr.getResponseHeader('content-type') || '';
                    if (contentType.includes('application/json') && data) {
                        try {
                            data = JSON.parse(data);
                        } catch (e) {
                            console.warn('JSON解析失败,返回原始数据:', e.message);
                            // 解析失败时保持原始数据
                        }
                    }
            }
            
            // 构建标准化的响应对象
            return {
                data,                    // 响应数据
                status: xhr.status,      // 状态码
                statusText: xhr.statusText, // 状态文本
                headers: this.parseHeaders(xhr.getAllResponseHeaders()), // 响应头
                config,                  // 请求配置
                xhr,                     // 原始XHR对象(用于高级操作)
                requestId: this.generateRequestId() // 请求ID(用于追踪)
            };
        }
        
        /**
         * 处理请求错误
         * @param {XMLHttpRequest} xhr XHR对象
         * @param {Object} config 请求配置
         * @returns {Error} 错误对象
         */
        handleError(xhr, config) {
            const error = new Error(this.getErrorMessage(xhr.status));
            error.name = 'AjaxError';
            error.status = xhr.status;
            error.statusText = xhr.statusText;
            error.config = config;
            error.xhr = xhr;
            error.timestamp = new Date().toISOString();
            error.requestId = this.generateRequestId();
            
            // 分类错误类型
            if (xhr.status === 0) {
                error.type = 'NETWORK_ERROR'; // 网络错误
            } else if (xhr.status >= 400 && xhr.status < 500) {
                error.type = 'CLIENT_ERROR'; // 客户端错误
            } else if (xhr.status >= 500) {
                error.type = 'SERVER_ERROR'; // 服务器错误
            } else {
                error.type = 'UNKNOWN_ERROR'; // 未知错误
            }
            
            return error;
        }
        
        /**
         * 创建错误对象
         * @param {string} message 错误消息
         * @param {string} type 错误类型
         * @param {XMLHttpRequest} xhr XHR对象
         * @returns {Error} 错误对象
         */
        createError(message, type, xhr = null) {
            const error = new Error(message);
            error.name = 'AjaxError';
            error.type = type;
            error.timestamp = new Date().toISOString();
            error.requestId = this.generateRequestId();
            
            if (xhr) {
                error.xhr = xhr;
                error.status = xhr.status;
                error.statusText = xhr.statusText;
            }
            
            return error;
        }
        
        /**
         * 根据状态码获取错误消息
         * @param {number} status HTTP状态码
         * @returns {string} 错误消息
         */
        getErrorMessage(status) {
            const messages = {
                0: '网络连接失败,请检查网络设置',
                400: '请求参数错误,请检查输入',
                401: '未授权访问,请先登录',
                403: '访问被禁止,没有权限',
                404: '请求的资源不存在',
                408: '请求超时,请稍后重试',
                500: '服务器内部错误',
                502: '网关错误',
                503: '服务不可用,请稍后重试',
                504: '网关超时'
            };
            return messages[status] || `请求失败 (${status})`;
        }
        
        /**
         * 处理请求数据
         * @param {any} data 请求数据
         * @param {Object} headers 请求头
         * @returns {any} 处理后的数据
         */
        processData(data, headers) {
            if (!data) return null;
            
            const contentType = headers['Content-Type'] || '';
            
            // JSON数据序列化
            if (contentType.includes('application/json') && typeof data === 'object') {
                return JSON.stringify(data);
            }
            
            // URL编码表单数据
            if (contentType.includes('application/x-www-form-urlencoded') && typeof data === 'object') {
                const params = new URLSearchParams();
                Object.keys(data).forEach(key => {
                    params.append(key, data[key]);
                });
                return params.toString();
            }
            
            // FormData、Blob、ArrayBuffer等特殊对象直接返回
            if (data instanceof FormData || data instanceof Blob || data instanceof ArrayBuffer) {
                return data;
            }
            
            // 其他类型数据直接返回
            return data;
        }
        
        /**
         * 解析响应头字符串为对象
         * @param {string} headersString 响应头字符串
         * @returns {Object} 响应头对象
         */
        parseHeaders(headersString) {
            const headers = {};
            if (headersString) {
                headersString.split('\r\n').forEach(line => {
                    const [key, ...valueParts] = line.split(': ');
                    const value = valueParts.join(': ');
                    if (key && value) {
                        headers[key] = value;
                    }
                });
            }
            return headers;
        }
        
        /**
         * 获取Cookie值
         * @param {string} name Cookie名称
         * @returns {string|null} Cookie值
         */
        getCookie(name) {
            const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
            return match ? decodeURIComponent(match[2]) : null;
        }
        
        /**
         * 检查是否为危险请求头
         * @param {string} name 请求头名称
         * @returns {boolean} 是否为危险头
         */
        isDangerousHeader(name) {
            // 浏览器禁止设置的请求头列表
            const dangerousHeaders = [
                'accept-charset', 'accept-encoding', 'access-control-request-headers',
                'access-control-request-method', 'connection', 'content-length',
                'cookie', 'cookie2', 'date', 'dnt', 'expect', 'host', 'keep-alive',
                'origin', 'referer', 'te', 'trailer', 'transfer-encoding', 'upgrade',
                'via'
            ];
            return dangerousHeaders.includes(name.toLowerCase());
        }
        
        /**
         * 生成请求ID
         * @returns {string} 唯一请求ID
         */
        generateRequestId() {
            return `req_${Date.now()}_${++this.requestIdCounter}`;
        }
    }

便捷API和请求管理


    class AjaxRequest {
        // ... 之前的代码 ...
        
        // ========== 便捷HTTP方法 ==========
        
        /**
         * GET请求
         * @param {string} url 请求URL
         * @param {Object} config 请求配置
         * @returns {Promise} 请求Promise
         */
        get(url, config = {}) {
            return this.request({ ...config, method: 'GET', url });
        }
        
        /**
         * POST请求
         * @param {string} url 请求URL
         * @param {any} data 请求数据
         * @param {Object} config 请求配置
         * @returns {Promise} 请求Promise
         */
        post(url, data = null, config = {}) {
            return this.request({ ...config, method: 'POST', url, data });
        }
        
        /**
         * PUT请求
         * @param {string} url 请求URL
         * @param {any} data 请求数据
         * @param {Object} config 请求配置
         * @returns {Promise} 请求Promise
         */
        put(url, data = null, config = {}) {
            return this.request({ ...config, method: 'PUT', url, data });
        }
        
        /**
         * DELETE请求
         * @param {string} url 请求URL
         * @param {Object} config 请求配置
         * @returns {Promise} 请求Promise
         */
        delete(url, config = {}) {
            return this.request({ ...config, method: 'DELETE', url });
        }
        
        /**
         * PATCH请求
         * @param {string} url 请求URL
         * @param {any} data 请求数据
         * @param {Object} config 请求配置
         * @returns {Promise} 请求Promise
         */
        patch(url, data = null, config = {}) {
            return this.request({ ...config, method: 'PATCH', url, data });
        }
        
        /**
         * 文件上传专用方法
         * @param {string} url 上传URL
         * @param {FormData} formData 表单数据
         * @param {Object} config 请求配置
         * @returns {Promise} 请求Promise
         */
        upload(url, formData, config = {}) {
            return this.request({
                ...config,
                method: 'POST',
                url,
                data: formData,
                // FormData会自动设置Content-Type为multipart/form-data
                headers: {
                    ...config.headers
                }
            });
        }
        
        // ========== 请求管理方法 ==========
        
        /**
         * 取消特定请求
         * @param {string} requestKey 请求唯一标识
         * @param {string} reason 取消原因
         * @returns {boolean} 是否取消成功
         */
        cancelRequest(requestKey, reason = '手动取消') {
            const requestInfo = this.pendingRequests.get(requestKey);
            if (requestInfo) {
                // 如果有XHR实例,调用abort方法真正取消请求
                if (requestInfo.xhr) {
                    requestInfo.xhr.abort();
                }
                this.pendingRequests.delete(requestKey);
                console.log(`请求已取消: ${requestKey}`, reason);
                return true;
            }
            return false;
        }
        
        /**
         * 取消所有进行中的请求
         * @param {string} reason 取消原因
         * @returns {number} 取消的请求数量
         */
        cancelAllRequests(reason = '批量取消') {
            const cancelledCount = this.pendingRequests.size;
            for (const [key, requestInfo] of this.pendingRequests) {
                if (requestInfo.xhr) {
                    requestInfo.xhr.abort();
                }
            }
            this.pendingRequests.clear();
            console.log(`已取消所有请求 (${cancelledCount}个)`, reason);
            return cancelledCount;
        }
        
        /**
         * 按条件取消请求
         * @param {Function} conditionFn 条件函数
         * @param {string} reason 取消原因
         * @returns {number} 取消的请求数量
         */
        cancelRequestsByCondition(conditionFn, reason = '条件取消') {
            let cancelledCount = 0;
            for (const [key, requestInfo] of this.pendingRequests) {
                if (conditionFn(requestInfo)) {
                    if (this.cancelRequest(key, reason)) {
                        cancelledCount++;
                    }
                }
            }
            return cancelledCount;
        }
        
        /**
         * 获取进行中的请求数量
         * @returns {number} 请求数量
         */
        getPendingRequestCount() {
            return this.pendingRequests.size;
        }
        
        /**
         * 获取所有进行中的请求信息
         * @returns {Array} 请求信息数组
         */
        getPendingRequests() {
            return Array.from(this.pendingRequests.entries()).map(([key, info]) => ({
                key,
                timestamp: info.timestamp,
                config: info.config,
                age: Date.now() - info.timestamp
            }));
        }
        
        // ========== 配置验证 ==========
        
        /**
         * 验证配置合法性
         * @param {Object} config 请求配置
         * @throws {Error} 配置验证失败时抛出错误
         */
        validateConfig(config) {
            const errors = [];
            
            // 验证URL
            if (!config.url || typeof config.url !== 'string') {
                errors.push('url必须是非空字符串');
            }
            
            // 验证HTTP方法
            const validMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
            if (config.method && !validMethods.includes(config.method.toUpperCase())) {
                errors.push(`method必须是以下值之一: ${validMethods.join(', ')}`);
            }
            
            // 验证超时时间
            if (config.timeout && (typeof config.timeout !== 'number' || config.timeout < 0)) {
                errors.push('timeout必须是大于等于0的数字');
            }
            
            // 验证重试次数
            if (config.retry && (typeof config.retry !== 'number' || config.retry < 0 || !Number.isInteger(config.retry))) {
                errors.push('retry必须是大于等于0的整数');
            }
            
            // 验证请求头
            if (config.headers && (typeof config.headers !== 'object' || Array.isArray(config.headers))) {
                errors.push('headers必须是对象');
            }
            
            // 如果有错误,抛出异常
            if (errors.length > 0) {
                throw this.createError(`配置验证失败: ${errors.join(', ')}`, 'CONFIG_ERROR');
            }
        }
    }

导出和使用

/**
 * 创建默认的AjaxRequest实例
 * 这是主要的导出对象,应用程序通常使用这个实例
 */
const ajax = new AjaxRequest({
    // 全局默认配置
    baseURL: process.env.API_BASE_URL || '', // 可从环境变量读取
    timeout: 15000,
    retry: 2,
    retryDelay: 1000
});

// 导出默认实例和类
export default ajax;
export { AjaxRequest };

// 如果是在浏览器环境,挂载到window对象(可选)
if (typeof window !== 'undefined') {
    window.AjaxRequest = AjaxRequest;
    window.ajax = ajax;
}

技术总结

通过本文的深入学习,我们不仅理解了Ajax的核心原理,还亲手打造了一个功能完整、健壮可靠的企业级Ajax封装库。

🎯 核心价值

  • 生产就绪:具备企业级应用所需的所有功能
  • 开发者友好:直观的API设计和详细的错误信息
  • 安全可靠:多重安全防护和健壮的错误处理

🛠 技术特色

  • 现代事件模型:使用onload等现代事件,代码更简洁
  • 完整生命周期:从请求创建到清理的全流程管理
  • 智能重试机制:可配置的重试策略,提高请求成功率
  • 强大请求管理:支持请求取消、并发控制等高级功能

📈 最佳实践

  1. 错误处理:分类处理不同错误类型,提供友好提示
  2. 性能优化:并发控制、内存管理、防重复请求
  3. 安全防护:CSRF保护、输入验证、危险头过滤
  4. 可维护性:清晰的代码结构、详细的注释、标准化响应

🔄 演进建议

虽然这个封装已经相当完善,但在实际项目中还可以:

  • 添加TypeScript类型定义
  • 集成请求缓存机制
  • 添加请求/响应转换器
  • 支持请求优先级调度
  • 添加性能监控和统计

这个Ajax封装库不仅是一个可用的工具,更是一个学习现代前端架构的优秀范例。理解其设计思想和实现细节,将为你构建更复杂的前端应用打下坚实基础。

重要提示:虽然我们实现了功能完整的Ajax封装,但在生产环境中,根据具体需求选择成熟的库(如axios)仍然是更稳妥的选择。这个练习的价值在于深入理解底层原理和封装思想!

RN 的初版架构——UI 布局与绘制

我们知道 RN 之所以受欢迎的其中一个原因就是把之前只有在 React 中有的 jsx 带进了 Native 开发的世界

在这一个篇章中,我们会深入了解 RN 是如何将 <View><Text> 标签转换成 UIView(IOS)、ViewGroup(Android)

当然,还有 yoga 究竟在其中做了什么?以及为什么要有 yoga


但是,在深入之前,我们要先聊聊一个方法:runApplication

runApplication 顾名思义,这是一个启动应用的方法,但这里启动的不是原生应用,而是在 JS bundle 在加载完之后,由 RN 在原生应用中启动 React 应用,它的调用过程涵盖了三个线程,其调用流程如下:

可以看到,我们的 RN 程序启动以后,会在客户端的 RootView 中调用 runApplication 方法,这个方法的调用会通过我们在通信机制中讲到的 Instance -> Bridge -> JSCExecutor 这条通道一路走进 JS 程序

当 JS 接收到 AppRegistry.runApplication 的调用后,它会去找到我们 RN 项目根目录的 index.js 注册的组件(默认在 App.js),最后调用 ReactNative.jsrender 方法

ReactNative.js 中包含着 RN 在 JS 侧的核心代码,他的主要任务是将 React diff 完的 fiber 转换成为一系列的 UIManager.xxxxx 调用

这些调用最后会触发 Native 中的 UIManager(UIManager 也是一个 Native module) 的逻辑生成原生元素(UIView,ViewGroup 等等) ,最后在 yoga 这个布局引擎的帮助下完成原生页面的渲染

createView, setChildren 与 yoga 布局

接下来我们以一个简单的例子来聊聊我们写的 RN jsx 是如何最后转变为原生元素并显示在屏幕中的

<View>
<Text>Hello world!</Text>
</View>

当我们这个组件被 ReactNative.jsrender 执行后,会有以下方法被调用:

题外话,在具体的场景中,上述例子可能不止有下述方法被调用了,被调用的方法也可能会有区别,但是他们的目的与功能是类似的,本文为了方便理解做了部份简化

  1. UIManager.createView(tagV, 'RCTView', rootTag, {})
  2. UIManager.createView(tagT, 'RCTText', rootTag, { text: 'Hello world!' })
  3. UIManager.setChildren(tagV, [tagT])

createView 接受 4 个参数:

  • 第一个参数是一个自增的数字,会唯一标识一个创建的元素
  • 第二个参数是需要创建的元素类型,因为我们需要的是 View 元素,其对应的是 RCTView (在原生平台中,它是一个继承自各自平台 View 元素的类,其中定义了一些 RN 需要的方法)
  • 第三个参数是根容器(root container)的唯一标识符,根容器在 native 侧创建,是 RN 创建的元素的根结点。由于一个 APP 中可以创建多个根容器,createView 需要确保当前创建的元素被归类到正确的容器中
  • 最后一个参数代表元素的属性

setChildren 接受 2 个参数:

  • 第一个参数与 createView 一致,唯一标识着一个父元素
  • 第二个参数是一个数组,其中包含子元素的标签

当这两个方法通过 bridge 最后进入 Native 侧的 UIManager 时,会根据 IOS 与 Android 的平台特性区分为两套实现,分别是:

  • RCTUIManager.m:IOS 中 UIManager 的实现
  • UIManagerModule.java:Android 中 UIManager 的实现

下面我们分别聊聊这两者都做了些什么

UIManager in IOS

在 IOS 的 createView 实现中,主要做了 3 件事:

  1. 根据 RCTView 这个类型分别创建了一个 shadowView 以及一个离屏的 UIKit UIViewRCTText 类也同理,后不赘述)
  2. 根据 RCTView 这个类型的规则,从元素的属性中筛选了部份 shadowView 需要的属性赋值给 shadowViewprops
  3. 将当前的 shadowView 放进 _shadowViewsWithUpdatedProps 中等待后续消费

其中,shadowView 是 RN 为了方便 yoga 计算布局而设计的类型,而 UIView 是 IOS 正儿八经在屏幕上渲染的元素

两者的区别在于 shadowView 负责接受元素布局相关的属性(如 width, height, border, margin 等),然后交给 yoga 计算布局;UIView 只需要处理布局之外的 backgroundColor, opacity, shadow 等等属性就好

属性的分类依据每个类型不同而不同,比如 RCTView 的定义在 RCTViewManager.m 中

这样做的好处在于可以将计算量较大的布局工作交给另外一个线程防止 IOS 的主线程阻塞


在 IOS 的 setChildren 实现中,主要做了 3 件事:

  1. 将子元素的 shadowView 插入成为父元素 shadowViewsubView
  2. 将子元素插入成为父元素的 subView
  3. 将当前的 shadowView 放进 _shadowViewsWithUpdatedChildren 中等待后续消费

最后,我们在之前讲 runApplication 的调用流程的时候留了一个伏笔:在 JSCExecutor.cpp 中调用 JS 的方法用的是 callFunctionReturnFlushedQueue ,以下是它的实现:

callFunctionReturnFlushedQueue(module: string, method: string, args: any[]) {
    this.__guard(() => {
      // 调用对应的 js 方法
      this.__callFunction(module, method, args);
    });

  // 返回到目前为止积压在 queue 中的 native module 调用请求
    return this.flushedQueue();
  }

可以看到在执行完 js 侧的 runApplication 后,该方法会将执行过程中累积的 native module 调用一下子清空,明确告知 native 侧:我这个方法调用过程中发生的请求已经全部给你了

当 native 侧接受到这个信息之后,它会去轮询所有注册过 batchDidComplete 方法的 native module(UIManager 也是其中一员)并执行他们的 batchDidComplete 方法

UIManagerbatchDidComplete 调用了最重要的一个方法:_layoutAndMount

我们来看看实现:

// in RCTUIManager.m

- (void)_layoutAndMount
{
  // 消费上述 _shadowViewsWithUpdatedProps:把有变化的 props 经过转换后赋值给 yogaNode(后续 yoga 会根据这些节点的属性来计算布局
  [self _dispatchPropsDidChangeEvents];
  // 消费上述 _shadowViewsWithUpdatedChildren:根据不同的 view 类型做不同处理(shadowView 场景的话什么都不做)
  [self _dispatchChildrenDidChangeEvents];

  // 遍历所有的 root container(reactTag)
  for (NSNumber *reactTag in _rootViewTags) {
    // 找到每一个 root container 的 shadowView(也就是 rootShadowView),由于 view 跟 shadowView 是一一对应的关系,所以 rootShadowView 也有可能有多个)
    RCTRootShadowView *rootView = (RCTRootShadowView *)_shadowViewRegistry[reactTag];
    // 触发 yoga 的布局计算,并且把布局结果包装到一个代码片段中返回,返回的代码片段会被加到一个等待队列中等待被主线程执行(因为在 ios 中只有主线程能操纵 UIKit)
    [self addUIBlock:[self uiBlockWithLayoutUpdateForRootView:rootView]];
  }

  // 执行上述的代码片段,将计算好的布局应用给元素
  [self flushUIBlocksWithCompletion:^{}];
}

补充一点,我们说到 uiBlockWithLayoutUpdateForRootView 方法除了计算新的元素布局之外,还会返回一个代码片段,这个代码片段除了在普通情况下将计算好的布局应用给元素之外,还负责判断该元素是否需要动画,如果需要的话,还会将对应的动画效果应用给对应元素

至此,我们完成了对 IOS 中 UIManager 的部份方法与核心机制讲解

UIManager in Android

UIManager 在 Android 中的目标跟在 IOS 中是一致的,主要区别在于加入了一个 NativeViewHierarchyOptimizer 的优化机制

至于加入的原因我们会在后文描述,现在我们先来看看 Android 是如何实现 createView, setChildren, batchDidComplete

在 Android createView 的实现中,RN 也做了三件事:

  1. 根据 RCTView 这个类型创建了一个 shadowView ,并将其保存至 mShadowNodeRegistry(一个用来保存所有 shadowView 的类)
  2. 将元素属性中 shadowView 需要的属性赋值给新创建的 shadowView
  3. 将创建原生 View 元素的任务交给 NativeViewHierarchyOptimizer,它会在符合条件的情况下创建原生元素

NativeViewHierarchyOptimizer 就是 Android 与 IOS 在 UIManager 中最大的区别,它的工作主要就是将元素用是否为布局专用元素进行区分:如果是布局专用元素它将不会创建真正的原生元素;反之则会跟 IOS 一样创建原生元素


setChildren 中,则是:

  1. 将子元素的 shadowView 插入成为父元素 shadowViewmChildren(对应 IOS 中的 subView
  2. 将插入原生子元素的任务交给 NativeViewHierarchyOptimizer,在其中会判断父元素是否为布局专用元素,如果是,则会将子元素插入到最近的不是布局专用元素的父元素上

最后,在 JS 侧所有请求结束后,Android 会执行 dispatchViewUpdates 方法(对应 IOS 中的 _layoutAndMount

// in UIImplementation.java

public void dispatchViewUpdates(int batchId) {
    try {
      // 1. 调用 yoga 计算布局
      // 2. 将布局结果转换成一些对元素的操作并将这些操作入栈等待执行
      // 3. 执行 JS 侧的 onLayout 回调
      updateViewHierarchy();
      // 清理布局过程中使用到的一些标识
      mNativeViewHierarchyOptimizer.onBatchComplete();
      // 将操作一一出栈并应用布局(调用元素的 measure 以及 layout 方法)
      mOperationsQueue.dispatchViewUpdates(batchId, commitStartTime, mLastCalculateLayoutTime);
    }
  }

Android vs IOS

在上文中,我们说到 Android 会比 IOS 多一个 NativeViewHierarchyOptimizer 用来防止为一些布局专用元素创建真正的元素,为什么呢?

首先,什么是布局专用元素?布局专用元素需要同时满足个条件:

  1. 该元素是 RCTView
  2. 该元素的 collapsable 元素是 true(也就是默认值)
  3. 该元素所有属性都是布局专用属性(LAYOUT_ONLY_PROPS),包含:
// in ViewProps.java

private static final HashSet<String> LAYOUT_ONLY_PROPS =
      new HashSet<>(
          Arrays.asList(
              ALIGN_SELF,
              ALIGN_ITEMS,
              COLLAPSABLE,
              FLEX,
              FLEX_BASIS,
              FLEX_DIRECTION,
              FLEX_GROW,
              FLEX_SHRINK,
              FLEX_WRAP,
              JUSTIFY_CONTENT,
              ALIGN_CONTENT,
              DISPLAY,

              /* position */
              POSITION,
              RIGHT,
              TOP,
              BOTTOM,
              LEFT,
              START,
              END,

              /* dimensions */
              WIDTH,
              HEIGHT,
              MIN_WIDTH,
              MAX_WIDTH,
              MIN_HEIGHT,
              MAX_HEIGHT,

              /* margins */
              MARGIN,
              MARGIN_VERTICAL,
              MARGIN_HORIZONTAL,
              MARGIN_LEFT,
              MARGIN_RIGHT,
              MARGIN_TOP,
              MARGIN_BOTTOM,
              MARGIN_START,
              MARGIN_END,

              /* paddings */
              PADDING,
              PADDING_VERTICAL,
              PADDING_HORIZONTAL,
              PADDING_LEFT,
              PADDING_RIGHT,
              PADDING_TOP,
              PADDING_BOTTOM,
              PADDING_START,
              PADDING_END));

在这种情况下,NativeViewHierarchyOptimizer 将不会创建真正的原生元素

为什么要在 Android 中应用这个优化呢?这个我们要从 Android 的 Choreographer 开始说起:

对于非 RN 的 Android app来说,当 app 接受到硬件传来的 vsync 信号之后,他会启动 choreographer 程序:

ChoreographerViewRootImpl.performTraversals()// 开始从程序根节点向下遍历所有元素performMeasure()   // 执行元素 measure 方法performLayout()    // 计算元素布局performDraw()      // 绘制元素

其中 measure 以及 layout 这两步只有当元素本身判断需要(元素调用了 requestLayout )之后才会启动,由于 RN 引入了 yoga 引擎来计算布局(取代了 performMeasure 与 performLayout 的功能),所以 RN 的目标就是让 Android 本身的 performMeasure 以及 performLayout 尽可能少的被启动

所以在 Android 中才需要 NativeViewHierarchyOptimizer 来尽可能减少多余的节点被挂在渲染树上


那么为什么 IOS 不需要呢?

因为 IOS 用的是完全不同的机制,IOS 提供了两种渲染机制:Frame-Based Layout 和 Constraint-Based Layout

RN 选用了 Frame-Based Layout,它的好处就是:系统会直接根据我们计算好的结果来渲染下一帧,不会有多余的操作

刚刚,马斯克二代星舰最后一飞成功了!彩蛋:黄仁勋亲自上门送超算

星舰 V2 的谢幕演出,来得比预想中更加顺利。

就在刚刚,星舰第 11 次飞行任务圆满完成——15 号助推器再次征战,8 颗星链模拟器完美部署,隔热瓦被故意移除接受极限测试,飞船在印度洋上空完成最后的爆炸溅落。

这是星舰 V2 版本的最后一飞,也是 SpaceX 迈向星舰 V3 时代的转折点。马斯克此前多次表示,星舰是一个持续迭代的系统,而 V3 则是未来实现登陆火星任务的关键版本。

插个题外话,英伟达 CEO黄仁勋也来到了美国德克萨斯州 Starbase 基地,把即将发货的 DGX Spark 个人超算交到马斯克手上,而早在 2016 年,马斯克就是首批从黄仁勋手中接过 DGX-1 的团队成员之一。

星舰 V2 的终点,V3 的起跑线

本次任务使用的是超重型助推器 15 号 (B15-2) 和星舰飞船 38 号 (S38)。

值得注意的是,15 号助推器是一枚经过飞行验证的飞行器,配备了 24 台来自先前任务的、经过飞行验证的猛禽发动机。此前在第八次任务中成功飞行,并完成了「筷子夹火箭」的任务。

此次测试的主要目标是验证一种新型着陆点火发动机配置,并将应用于下一代「超级重型」助推器。

让我们一起来回顾此次发射的全部过程。

位于星舰下方的超重型火箭助推器点燃了全部发动机,开始向太空爬升。

发射约 2 分半后,星舰成功完成热级间分离。上方的星舰飞船点燃自身的 6 台发动机并完成分离。据 SpaceX 介绍,这些火箭发动机产生的推力相当于 64 架波音 747 客机的总和。

而「超级重型」助推器开始执行返回推进,朝预定溅落点飞行,准备进行着陆点火实验。

具体来说,当星舰 (上级飞船) 和助推器分离后,助推器需要返回地球并尝试着陆。第一步是进行姿态翻转,让发动机朝向正确方向,以便点火减速。

翻转后,助推器点燃发动机进行反向推力。这相当于制动,让助推器逐渐脱离上升轨迹,转向预定的下降轨迹。

在此次助推器着陆点火阶段,首先点燃 13 台发动机,随后切换为 5 台发动机进行转向。此前这一阶段使用 3 台发动机,而下一代 V3 版「超级重型」计划使用 5 台发动机,以增强在发动机意外关闭时的冗余能力。

此次着陆在美国墨西哥湾近海区域进行,不会返回发射场捕捉,实验成功,现场工作人员爆发出热烈掌声。

一次发射顶 20 次,马斯克押注星链 V3

星舰上级在太空中同样需要执行多个任务,包括部署 8 颗星链模拟器。这些模拟器大小与下一代星链卫星相仿,本质上也是为未来正式发射 V3 卫星进行的实战演练。

每个模拟器重约 2000 公斤,总载荷质量约 16000 公斤。这些模拟器将与星舰处于相同的亚轨道轨迹,并将随飞船一同再入大气层销毁。

整个部署过程非常顺利,每次部署耗时约 1 分钟。

飞船侧面的大型舱板——被称为「有效载荷门」的舱口打开后,开始释放模拟卫星。与其他火箭通常通过鼻锥释放卫星不同,星舰采用侧边舱门设计,必须打开这道侧门才能将卫星释放到太空。

如果看过之前的测试,会记得以前卫星释放时有些卡顿,但由于星舰团队对滑轨系统进行了改进,所以这次释放过程相当流畅。

按照规划,SpaceX 希望星舰能够尽快接手卫星发射任务,取代目前用于此任务的猎鹰 9 号,成为主力运载工具。

未来星舰将部署更先进的 Starlink V3 卫星,运载效率更高,每公斤货物入轨成本更低,每次发射能为整个网络增加 60 Tbps 的容量,以及是目前猎鹰 9 号单次发射容量的 20 倍。

除了卫星部署,本次飞行还成功完成另一项重要测试——在太空环境下重新点燃一台猛禽发动机。整个过程旨在模拟星舰如何执行「离轨点火」操作,也就是在完成太空任务后,通过机动将飞船引导返回地面的过程。

故意移除的隔热瓦,是对极限的最好尊重

星舰表面覆盖着数千块隔热瓦,它们彼此紧挨着排列,中间留有微小缝隙。

之所以要留缝隙,是因为下方的金属结构在受热时会膨胀和收缩,这样可以避免瓦片之间挤压碰撞造成破裂。但问题是,这些缝隙有时会让高温等离子体渗入,导致瓦片边缘和下方的金属区域被过度加热。

上次第十次飞行,星舰表面出现了局部烧蚀与表皮翘起。经查明是因为推进剂排放过程中有少量固体推进剂堆积,被静电放电或等离子体点燃,烧损了部分躯体和部分襟翼。

这一次,SpaceX 依然故意从飞行器的脆弱区域移除部分隔热瓦,使底层结构暴露在再入热流中。甚至,部分被移除隔热瓦的区域没有备用烧蚀层,也让测试风险显著增加。

基于第十次飞行中热量从瓦片间隙渗入的教训,此次飞行更广泛地应用了一种名为「Crunch Wrap」的材料,简单来说,这是一种耐高温毡材料,包裹在瓦片之间的缝隙处。

这样当瓦片排布在一起时,缝隙之间就有了一层保护,能够有效阻挡高温等离子体的渗透。

这些努力都是为了实现最终目标——打造一艘完全、快速可重复使用的飞行器。以前在多艘星舰上测试过这项技术,但今天可能是首次将其覆盖到整艘飞行器上,这也是本次任务的重要看点。

在未来星舰每天多次飞行的场景中,将需要成千上万块隔热瓦。

SpaceX 解说表示,目前美国佛罗里达发射场的全自动制作工坊每天能生产约 1000 块瓦片。

但其设计产能是每月为 10 艘星舰提供足够的瓦片,相当于每天生产 7000 块,或者平均每 13 秒就能下线一块瓦片,目标是朝着为火星任务甚至更远目标全面配备星舰隔热瓦的方向发展。

星舰的迭代哲学,就是用失败换进步

为了给未来的返回发射场着陆 (RTLS) 收集数据,飞船的再入剖面比以往的飞行要复杂得多。

在其轨迹的最后阶段,飞船将执行一次「动态倾斜机动」。

也就是说,在仍处于超音速甚至高超音速状态时,飞行器会故意进行一定幅度的侧倾偏航,模拟从海上再入后,为精准对准陆地发射场而必须执行的横向机动过程。

进入亚音速阶段后,飞船还会在「腹部着陆」姿态开始前,再次进行一次幅度更大的转向,以测试接近塔架着陆所需的最终修正能力。据解说表示,这一整套飞行路径,基本就是未来星舰完成降落时将采用的程序。

不过,由于本次任务不涉及回收,星舰最终还是按计划在印度洋溅落,并在触水后发生爆炸。

简言之,此次飞行是 V2 版本星舰的最后一次任务,但本质上都是在为 V3 乃至更远的版本铺路。

比如收集下一代「超级重型」助推器的数据、对星舰隔热瓦进行极限测试, 以及验证未来返回发射场时上级飞行器所需的机动动作。

此外, 本次发射是 Starbase 基地现有发射台在当前配置下的最后一次使用。之后该发射台将进行大规模改造, 以支持未来更大规模的 V3 和 V4 星舰发射任务。

这种「边飞边改」(即通过实际飞行来测试和验证技术, 而不是在地面进行漫长的模拟)的策略风险很高, 但效率也更高。迭代速度, 在传统航天领域也几乎是不可想象的。

这或许就是马斯克式创新的核心——用更快的失败和迭代, 换取更快的进步。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


Vue 与 React 应用初始化机制对比 - 前端框架思考笔记

Vue 与 React 应用初始化机制对比 - 前端框架思考笔记

引子:从挂载点开始的思考

最近在准备前端面试时,我一直在思考一个问题:为什么 Vue 和 React 都需要一个挂载点?这个看似简单的 <div id="app"></div> 到底在框架中扮演什么角色?

我当时想:这不就是一个普通的 div 吗?为什么非要指定它?直接往 body 里塞内容不行吗?

通过深入理解,我发现这背后涉及到现代前端框架的核心设计理念。

什么是挂载点?为什么需要它?

挂载点就是一个特定的 DOM 元素,作为我们应用的渲染容器。在 Vue 或 React 中,我们通过指定挂载点来告诉框架:"请把整个应用的内容都渲染到这个元素内部"。

<body>
  <!-- 这就是挂载点 -->
  <div id="app"></div>
  
  <script src="main.js"></script>
</body>

我当时疑惑:如果不指定挂载点会怎样?框架会把内容直接插入到 body 中吗?

确实如此!如果没有明确的挂载点,Vue 或 React 可能会直接把内容插入到 body 或其他 DOM 元素中,造成页面结构混乱。想象一下,你的应用内容散落在 body 的各个角落,没有统一的容器,管理和定位 DOM 元素会变得极其困难。

Vue 的应用初始化过程

createApp 和 mount 的分离

在 Vue 3 中,应用初始化分为两个清晰的步骤:

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

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

我当时不理解:为什么要分 createApp 和 mount 两步?直接像 React 那样渲染不行吗?

深入思考后我明白了:

  • createApp(App):创建 Vue 应用实例
  • .mount('#app'):将实例挂载到 DOM

这种分离设计让 Vue 在挂载前可以进行各种配置,比如注册全局组件、插件等。

Vue 的组件解析过程

我当时问:Vue 是怎么把模板变成实际页面的?

Vue 的模板编译过程是这样的:

  1. 模板解析:Vue 将 .vue 文件中的模板代码转换成 JavaScript 对象
  2. 生成虚拟 DOM:这些对象构成了虚拟 DOM(VNode),描述页面结构
  3. 渲染到实际 DOM:虚拟 DOM 通过比对算法更新实际页面
// 模板
<template>
  <div>{{ message }}</div>
</template>

// 被编译成渲染函数
render() {
  return createVNode('div', null, this.message)
}

我当时想:为什么要经过虚拟 DOM 这个中间步骤?

虚拟 DOM 的优势在于性能优化。Vue 通过比较新旧虚拟 DOM 的差异,只更新发生变化的部分,而不是重新渲染整个页面。

React 的应用初始化

直接的渲染方式

React 的初始化相对直接:

import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'

ReactDOM.render(<App />, document.getElementById('app'))

我当时对比:React 为什么不需要像 Vue 那样先创建应用实例?

这与两个框架的设计哲学有关。React 更专注于组件本身的渲染,而 Vue 强调应用级别的管理和配置。

JSX 与 Vue 模板的差异

我当时注意到:React 的组件导出看起来比 Vue 简单很多:

// React 组件
function App() {
  return (
    <div>
      <h1>Welcome to My React App</h1>
    </div>
  )
}

export default App
<!-- Vue 组件 -->
<template>
  <div>
    <h1>Welcome to My Vue App</h1>
  </div>
</template>

<script>
export default {
  data() {
    return {
      message: 'Hello, world!'
    }
  }
}
</script>

这种差异源于 Vue 的响应式系统需要更明确的数据声明。

设计哲学的深层差异

React:专注于组件渲染的简洁性

我当时困惑:React 是从根组件开始构建虚拟 DOM 树,而 Vue 是组件级框架自底向上构建,这和我前文的这两个框架的设计哲学:React 更专注于组件本身的渲染,而 Vue 强调应用级别的管理和配置。总觉得有哪里矛盾?

通过深入研究,我发现 React 的设计哲学是:

组件树作为应用核心

  • React 将整个应用视为组件树,根组件是起点
  • 通过 ReactDOM.render() 从根组件开始渲染整个树结构
  • 每个组件在渲染和状态管理上保持独立性
  • 不需要显式的应用实例,简化了配置

我当时理解:React 的简洁性体现在它把应用管理隐藏在组件树中,让开发者更专注于组件本身的实现。

Vue:应用实例与组件化的平衡

Vue 采取了不同的路径:

明确的应用实例概念

  • 通过 createApp() 创建明确的应用实例
  • 应用实例负责全局配置、插件、状态管理
  • 在保持组件化的同时,提供应用级别的管理能力

我当时对比:Vue 的设计既照顾了大型应用的需求(通过应用实例),又保持了组件级别的灵活性。

引用GPT的精彩理解image.png

单页应用(SPA)与多页应用(MPA)

我当时困惑:什么叫做"页面本身只有一个 HTML 文件"?我们不是有 index.html 还有各种 .vue 文件吗?

这里的关键区别在于:

单页应用(SPA)

  • 只有一个 HTML 文件(通常是 index.html
  • 页面切换通过 JavaScript 动态渲染内容
  • 不会重新加载整个页面
  • 用户体验更流畅

多页应用(MPA)

  • 每个页面都有独立的 HTML 文件
  • 页面切换需要重新加载
  • 传统的网页开发方式

我当时恍然大悟:原来 .vue 文件在构建时会被打包工具处理,最终都合并到同一个 HTML 中!

构建工具的作用

我当时问:Vue 的模板编译是通过什么工具完成的?

现代前端开发离不开构建工具:

  • Webpack/Vite:模块打包和构建
  • Babel:JavaScript 代码转换
  • Vue Loader:处理 .vue 文件

Vue 的模板编译器会将模板转换成抽象语法树(AST),然后生成渲染函数。这个过程在构建阶段完成,而不是在浏览器中运行时。

多个 Vue 实例的情况

我当时好奇:什么情况下需要多个 Vue 实例?

虽然在单页应用中通常只有一个 Vue 实例,但在某些场景下可能需要多个:

// 不同功能模块使用不同实例
createApp(App1).mount('#app1')
createApp(App2).mount('#app2')

这种情况常见于:

  • 老项目渐进式迁移
  • 页面中有多个独立的功能模块
  • 微前端架构

虚拟 DOM 的重要性

我当时不理解:为什么要用虚拟 DOM?直接操作真实 DOM 不行吗?

虚拟 DOM(VNode)的本质是 JavaScript 对象,它描述了页面的结构。优势在于:

  1. 性能优化:通过 Diff 算法最小化 DOM 操作
  2. 跨平台能力:同一套虚拟 DOM 可以渲染到不同平台
  3. 开发体验:让开发者更关注业务逻辑而不是 DOM 操作

总结与面试要点

通过这番探索,我对 Vue 和 React 的初始化机制有了更深入的理解:

Vue 的特点

  • 明确的应用实例概念
  • 模板编译在构建时完成
  • 响应式数据系统
  • 配置灵活,适合大型应用

React 的特点

  • 专注于组件渲染
  • JSX 语法更接近 JavaScript
  • 函数式编程思想
  • 生态丰富,社区活跃

面试中如何描述

当被问到 Vue 和 React 的区别时,我可以这样回答:

"两者都是优秀的现代前端框架,但在设计理念上有所不同。Vue 通过 createApp 创建明确的应用实例,提供了更多的配置和管理能力;而 React 更专注于组件本身的渲染,通过 ReactDOM.render 直接渲染组件。这种差异体现在开发体验、性能优化和适用场景上。"

我的最终感悟:前端框架的每一个设计选择都有其深层考量。从简单的挂载点开始,深入理解框架的设计哲学,才能真正掌握前端开发的精髓。

可怕!我的Nodejs系统因为日志打印了Error 对象就崩溃了😱 Node.js System Crashed Because of Logging

本文为中英文双语,需要英文博客可以滑动到下面查看哦 | This is a bilingual article. Scroll down for the English version.

小伙伴们!今天我在本地调试项目的过程中,想记录一下错误信息,结果程序就"啪"地一下报出 "Maximum call stack size exceeded" 错误,然后项目直接就crash了。但是我看我用的这个开源项目,官方的代码里好多地方就是这么用的呀?我很纳闷,这是为什么呢?

Snipaste_2025-10-10_00-28-45.png

报错信息


[LOGGER PARSING ERROR] Maximum call stack size exceeded
2025-10-13T17:06:59.643Z debug: Error code: 400 - {'error': {'message': 'Budget has been exceeded! Current cost: 28.097367900000002, Max budget: 0.0', 'type': 'budget_exceeded', 'par... [truncated]
{
  unknown: [object Object],
}
2025-10-13T17:06:59.643Z debug: [api/server/middleware/abortMiddleware.js] respondWithError called
2025-10-13T17:06:59.644Z error: There was an uncaught error: Cannot read properties of undefined (reading 'emit')
2025-10-13T17:06:59.645Z debug: [indexSync] Clearing sync timeouts before exiting...
[nodemon] app crashed - waiting for file changes before starting...

报错截图

image

错误分析

晚上下班以后,晚上躺在床上,我翻来覆去睡不着,干脆打开电脑一番探究,想要知道 ,这个错误到底为何触发,实质原因是什么,以及如何解决它。让我们一起把这个小调皮鬼揪出来看看它到底在搞什么鬼吧!👻

场景复现

想象一下这个场景,你正在开心地写着代码:

app.get('/api/data', async (req, res) => {
  try {
    // 一些可能会出小差错的业务逻辑
    const data = await fetchDataFromAPI();
    res.json(data);
  } catch (error) {
    // 记录错误信息
    logger.debug('获取数据时出错啦~', error); // 哎呀!这一行可能会让我们的程序崩溃哦!
    res.status(500).json({ error: '内部服务器出错啦~' });
  }
});

看起来是不是很正常呢?但是当你运行这段代码的时候,突然就出现了这样的错误:

[LOGGER PARSING ERROR] Maximum call stack size exceeded

更神奇的是,如果你把代码改成这样:

console.log(error); // 这一行却不会让程序崩溃哦,但是上prod的系统,不要这么用哦

它就能正常工作啦!这是为什么呢?🤔

小秘密大揭秘!🔍

console.log虽好,但请勿用它来记录PROD错误!

console.log 是 Node.js 原生提供的函数,它就像一个经验超级丰富的大叔,知道怎么处理各种"调皮"的对象。当 console.log 遇到包含循环引用的对象时,它会聪明地检测这些循环引用,并用 [Circular] 标记来代替实际的循环部分,这样就不会无限递归啦!

简单来说,Node.js 的 console.log 就像一个超级厉害的武林高手,知道如何闪转腾挪,避开各种陷阱!🥋

日志库的"小烦恼"

但是我们自己封装的日志系统(比如项目中使用的 Winston)就不一样啦!为了实现各种炫酷的功能(比如格式化、过滤敏感信息等),日志库通常会使用一些第三方库来处理传入的对象。

在我们的案例中,日志系统使用了 [traverse] 库来遍历对象。这个库在大多数情况下工作得都很好,但当它遇到某些复杂的 Error 对象时,就可能会迷路啦!

Error 对象可不是普通对象那么简单哦!它们可能包含各种隐藏的属性、getter 方法,甚至在某些情况下会动态生成属性。当 [traverse] 库尝试遍历这些复杂结构时,就可能陷入无限递归的迷宫,最终导致调用栈溢出。

什么是循环引用?🌀

在深入了解这个问题之前,我们先来了解一下什么是循环引用。循环引用指的是对象之间相互引用,形成一个闭环。比如说:

const objA = { name: '小A' };
const objB = { name: '小B' };

objA.ref = objB;
objB.ref = objA; // 哎呀!形成循环引用啦!

当尝试序列化这样的对象时(比如用 JSON.stringify),就会出现问题,因为序列化过程会无限递归下去,就像两只小仓鼠在滚轮里永远跑不完一样!🐹

Error 对象虽然看起来简单,但内部结构可能非常复杂,特别是在一些框架或库中创建的 Error 对象,它们可能包含对 request、response 等对象的引用,而这些对象又可能包含对 Error 对象的引用,从而形成复杂的循环引用网络,就像一张大蜘蛛网一样!🕷️

怎样才能让我们的日志系统乖乖听话呢?✨

1. 只记录我们需要的信息

最简单直接的方法就是不要把整个 Error 对象传递给日志函数,而是只传递我们需要的具体属性:

// ❌ 不推荐的做法 - 会让日志系统"生气"
logger.debug('获取数据时出错啦~', error);

// ✅ 推荐的做法 - 让日志系统开心地工作
logger.debug('获取数据时出错啦~', {
  message: error.message,
  stack: error.stack,
  code: error.code
});

2. 使用专门的错误序列化函数

你可以创建一个专门用于序列化 Error 对象的函数,就像给 Error 对象穿上一件"安全外套":

function serializeError(error) {
  return {
    name: error.name,
    message: error.message,
    stack: error.stack,
    code: error.code,
    // 添加其他你需要的属性
  };
}

// 使用方式
logger.debug('获取数据时出错啦~', serializeError(error));

3. 使用成熟的错误处理库

有些库专门为处理这类问题而设计,比如 serialize-error,它们就像专业的保姆一样,会把 Error 对象照顾得好好的:

const { serializeError } = require('serialize-error');

logger.debug('获取数据时出错啦~', serializeError(error));

4. 配置日志库的防护机制

如果你使用的是 Winston,可以配置一些防护机制,给它穿上"防弹衣":

const winston = require('winston');

const logger = winston.createLogger({
  format: winston.format.combine(
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  // ... 其他配置
});

最佳实践小贴士 🌟

  1. 永远不要直接记录原始的 Error 对象:它们可能包含复杂的循环引用结构,就像一个调皮的小恶魔。

  2. 提取关键信息:只记录我们需要的错误信息,比如 message、stack 等,就像挑选糖果一样只拿最喜欢的。

  3. 使用安全的序列化方法:确保我们的日志系统能够处理各种边界情况,做一个贴心的小棉袄。

  4. 添加防护措施:在日志处理逻辑中添加 try-catch 块,防止日志系统本身成为故障点,就像给程序戴上安全帽。

  5. 测试边界情况:在测试中模拟各种错误场景,确保日志系统在极端情况下也能正常工作,做一个负责任的好孩子。

image

Terrifying! My Node.js System Crashed Because of Logging an Error Object 😱

Fellow developers! Today, while debugging a project locally, I wanted to log some error information, but suddenly the program threw a "Maximum call stack size exceeded" error and crashed the entire project. But when I look at the open-source project I'm using, I see that the official code does this in many places. I was puzzled, why is this happening?

Error Message


[LOGGER PARSING ERROR] Maximum call stack size exceeded
2025-10-13T17:06:59.643Z debug: Error code: 400 - {'error': {'message': 'Budget has been exceeded! Current cost: 28.097367900000002, Max budget: 0.0', 'type': 'budget_exceeded', 'par... [truncated]
{
  unknown: [object Object],
}
2025-10-13T17:06:59.643Z debug: [api/server/middleware/abortMiddleware.js] respondWithError called
2025-10-13T17:06:59.644Z error: There was an uncaught error: Cannot read properties of undefined (reading 'emit')
2025-10-13T17:06:59.645Z debug: [indexSync] Clearing sync timeouts before exiting...
[nodemon] app crashed - waiting for file changes before starting...

Error Screenshot

image

Error Analysis

After work, I couldn't resist investigating why this error was triggered, what the root cause was, and how to solve it. Let's together catch this little troublemaker and see what it's up to! 👻

Reproducing the Scenario

Imagine this scenario, you're happily coding:

app.get('/api/data', async (req, res) => {
  try {
    // Some business logic that might go wrong
    const data = await fetchDataFromAPI();
    res.json(data);
  } catch (error) {
    // Log the error
    logger.debug('Error fetching data~', error); // Oops! This line might crash our program!
    res.status(500).json({ error: 'Internal server error~' });
  }
});

Doesn't this look normal? But when you run this code, suddenly this error appears:

[LOGGER PARSING ERROR] Maximum call stack size exceeded

What's even more神奇 is, if you change the code to this:

console.log(error); // This line won't crash the program, but don't use this in production systems

It works fine! Why is that? 🤔

The Big Reveal of Little Secrets! 🔍

console.log is Good, But Don't Use It to Log PROD Errors!

console.log is a native Node.js function. It's like an extremely experienced uncle who knows how to handle all kinds of "naughty" objects. When console.log encounters objects with circular references, it cleverly detects these circular references and replaces the actual circular parts with [Circular] markers, so it won't recurse infinitely!

Simply put, Node.js's console.log is like a super skilled martial arts master who knows how to dodge and avoid all kinds of traps! 🥋

The "Little Troubles" of Logging Libraries

But our custom logging systems (like Winston used in the project) are different! To implement various cool features (like formatting, filtering sensitive information, etc.), logging libraries often use third-party libraries to process incoming objects.

In our case, the logging system uses the [traverse] library to traverse objects. This library works well in most cases, but when it encounters certain complex Error objects, it might get lost!

Error objects are not as simple as ordinary objects! They may contain various hidden properties, getter methods, and in some cases, dynamically generated properties. When the [traverse] library tries to traverse these complex structures, it may fall into an infinite recursion maze, ultimately causing a stack overflow.

What Are Circular References? 🌀

Before diving deeper into this issue, let's first understand what circular references are. Circular references refer to objects that reference each other, forming a closed loop. For example:

const objA = { name: 'A' };
const objB = { name: 'B' };

objA.ref = objB;
objB.ref = objA; // Oops! Circular reference formed!

When trying to serialize such objects (like with JSON.stringify), problems arise because the serialization process will recurse infinitely, like two hamsters running forever in a wheel! 🐹

Although Error objects look simple, their internal structure can be very complex, especially Error objects created in some frameworks or libraries. They may contain references to request, response, and other objects, and these objects may in turn contain references to the Error object, forming a complex circular reference network, like a giant spider web! 🕷️

How to Make Our Logging System Behave? ✨

1. Only Log the Information We Need

The simplest and most direct method is not to pass the entire Error object to the logging function, but to pass only the specific properties we need:

// ❌ Not recommended - will make the logging system "angry"
logger.debug('Error fetching data~', error);

// ✅ Recommended - makes the logging system work happily
logger.debug('Error fetching data~', {
  message: error.message,
  stack: error.stack,
  code: error.code
});

2. Use a Dedicated Error Serialization Function

You can create a dedicated function for serializing Error objects, like putting a "safety coat" on the Error object:

function serializeError(error) {
  return {
    name: error.name,
    message: error.message,
    stack: error.stack,
    code: error.code,
    // Add other properties you need
  };
}

// Usage
logger.debug('Error fetching data~', serializeError(error));

3. Use Mature Error Handling Libraries

Some libraries are specifically designed to handle these kinds of issues, such as serialize-error. They're like professional nannies who will take good care of Error objects:

const { serializeError } = require('serialize-error');

logger.debug('Error fetching data~', serializeError(error));

4. Configure Protective Mechanisms for Logging Libraries

If you're using Winston, you can configure some protective mechanisms to give it "bulletproof armor":

const winston = require('winston');

const logger = winston.createLogger({
  format: winston.format.combine(
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  // ... other configurations
});

Best Practice Tips 🌟

  1. Never log raw Error objects directly: They may contain complex circular reference structures, like a mischievous little devil.

  2. Extract key information: Only log the error information we need, such as message, stack, etc., like picking candy - only take your favorites.

  3. Use safe serialization methods: Ensure our logging system can handle various edge cases, be a thoughtful companion.

  4. Add protective measures: Add try-catch blocks in the logging logic to prevent the logging system itself from becoming a failure point, like giving the program a safety helmet.

  5. Test edge cases: Simulate various error scenarios in testing to ensure the logging system works properly under extreme conditions, be a responsible good child.

Conclusion | 结语

  • That's all for today~ - | 今天就写到这里啦~

  • Guys, ( ̄ω ̄( ̄ω ̄〃 ( ̄ω ̄〃)ゝ See you tomorrow~ | 小伙伴们,( ̄ω ̄( ̄ω ̄〃 ( ̄ω ̄〃)ゝ我们明天再见啦~~

  • Everyone, be happy every day! 大家要天天开心哦

  • Welcome everyone to point out any mistakes in the article~ | 欢迎大家指出文章需要改正之处~

  • Learning has no end; win-win cooperation | 学无止境,合作共赢

  • Welcome all the passers-by, boys and girls, to offer better suggestions! ~ | 欢迎路过的小哥哥小姐姐们提出更好的意见哇~~

❌