阅读视图

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

🎨 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


📚 参考资源


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

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

鸿蒙应用开发从入门到实战(二十二):使用Stack实现层叠布局

大家好,我是潘Sir,持续分享IT技术,帮你少走弯路。《鸿蒙应用开发从入门到项目实战》系列文章持续更新中,陆续更新AI+编程、企业级项目实战等原创内容、欢迎关注!

ArkUI提供了各种布局组件用于界面布局,本文研究使用Stack组件实现层叠布局。

一、布局基础

1.1 概述

布局是指对页面组件进行排列和定位的过程,其目的是有效地组织和展示页面内容,会涉及到组件的大小、位置以及它们之间的相互关系等等。

1布局概述.png

1.2 盒子模型

在鸿蒙应用中,页面上的每个组件都可以看做是一个矩形的盒子,这个盒子包含了内容区域(content)、边框(border)、内边距(padding)和外边距(margin),各部分内容如下图所示

2盒子模型.png

其中marginpaddingborder均可使用同名的属性方法进行设置,各方法定义如下

  • margin
margin(value: { top?:Length, right?:Length, bottom?:Length, left?:Length } |  Length )

说明:

  1. Length=string | number | Resource
  2. 当参数类型为Length时,四个方向的边距同时生效
  • padding
padding(value: { top?:Length, right?:Length, bottom?:Length, left?:Length } |  Length )
  • border
border(value: {width?:Length, color?:ResourceColor, radius?:Length, style?:BorderStyle })

各属性含义如下

    • width

width属性表示边框宽度

    • color

color属性表示边框颜色

    • radius

radius属性表示边框圆角半径

    • style

style属性表示边框样式,可通过BorderStyle这一枚举类型进行设置,可选的枚举值有

3盒子模型属性.png

二、层叠布局Stack

2.1 概述

层叠布局是指将多个组件沿垂直于屏幕的方向堆叠在一起,类似于图层的叠加。以下效果都可以通过层叠布局实现

4层叠布局效果.png

层叠布局可通过Stack容器组件实现,其子元素会按照其添加顺序依次叠加在一起,后添加的子元素位于先添加的子元素之上。具体效果如下

Stack() {
  Row()
    .width(250)
    .height(250)
    .backgroundColor('#107B02') //绿色
    .shadow({radius:50})
  Row()
    .width(200)
    .height(200)
    .backgroundColor('#E66826') //橙色
    .shadow({radius:50})
  Row()
    .width(150)
    .height(150)
    .backgroundColor('#255FA7') //蓝色
    .shadow({radius:50})
}
.width(300)
.height(300)
.backgroundColor('#E5E5E5') //灰色

效果

5层叠布局.png

示例代码

pages/component目录下新建stack目录,新建StackPage.ets文件

@Entry
@Component
struct StackPage {
  build() {
    Column() {
      Stack() {
        Row()
          .width(250)
          .height(250)
          .backgroundColor('#107B02') //绿色
          .shadow({radius:50})

        Row()
          .width(200)
          .height(200)
          .backgroundColor('#E66826') //橙色
          .shadow({radius:50})
        Row()
          .width(150)
          .height(150)
          .backgroundColor('#255FA7') //蓝色
          .shadow({radius:50})
      }
      .width(300)
      .height(300)
      .backgroundColor('#E5E5E5')

    }.width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

2.2 参数

Stack组件的参数类型为{ alignContent?: Alignment }alignContent用于设置子组件的对齐方式,该属性可通过枚举类型Alignment进行设置,可选的枚举值及其效果如下图所示

6alignContent.png

该参数的一个实际使用场景如下:

7alignContent使用.png

示例代码

拷贝icon_v.png和img_avatar.png文件到目录resources/base/media目录

pages/component/stack目录,新建AlignContentPage.ets文件

@Entry
@Component
struct AlignmentContentPage {
  build() {
    Column() {
      Stack({alignContent:Alignment.BottomEnd}) {
        Image($r('app.media.img_avatar'))
          .width('100%')
          .height('100%')
        Image($r('app.media.icon_v'))
          .width(60)
          .height(60)
      }
      .width(200)
      .height(200)

    }.width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

2.3 使用技巧

2.3.1 子组件Z轴控制

Stack容器中子组件的层级除了可按照添加顺序决定,还能通过zIndex()进行手动的设置,zIndex的值越大,层级越高。

Stack() {
  Row()
    .width(150)
    .height(150)
    .backgroundColor('#255FA7') //蓝色
    .shadow({ radius: 50 })
    .zIndex(3)

  Row()
    .width(200)
    .height(200)
    .backgroundColor('#E66826') //橙色
    .shadow({ radius: 50 })
    .zIndex(2)
  
  Row()
    .width(250)
    .height(250)
    .backgroundColor('#107B02') //绿色
    .shadow({ radius: 50 })
    .zIndex(1)

}.width(300)
.height(300)
.backgroundColor('#E5E5E5') //灰色

效果

8z轴定位.png 示例代码

pages/component/stack目录,新建ZIndexPage.ets文件

@Entry
@Component
struct ZIndexPage {
  build() {
    Column() {
      Stack() {
        Row()
          .width(150)
          .height(150)
          .backgroundColor('#255FA7') //蓝色
          .shadow({ radius: 50 })
          .zIndex(3)

        Row()
          .width(200)
          .height(200)
          .backgroundColor('#E66826') //橙色
          .shadow({ radius: 50 })
          .zIndex(2)

        Row()
          .width(250)
          .height(250)
          .backgroundColor('#107B02') //绿色
          .shadow({ radius: 50 })
          .zIndex(1)

      }.width(300)
      .height(300)
      .backgroundColor('#E5E5E5')

    }.width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

2.3.2 子组件精确定位

Stack容器的子组件可使用position()方法进行更精确的定位,该方法可设置子组件左上角相对于Stack容器左上角的偏移量,具体效果如下

代码:

Stack() {
  Image($r('app.media.img_avatar'))
    .width('100%')
    .height('100%')
  Image($r('app.media.icon_v'))
    .width(60)
    .height(60)
    .position({ x: 140, y: 140 })
}
.width(200)
.height(200)

效果

9子组件精确定位.png

示例代码

pages/component/stack目录,新建PositionPage.ets文件

@Entry
@Component
struct PositionPage {
  build() {
    Column() {
      Stack() {
        Image($r('app.media.img_avatar'))
          .width('100%')
          .height('100%')
        Image($r('app.media.icon_v'))
          .width(60)
          .height(60)
          .position({ x: 140, y: 140 })
      }
      .width(200)
      .height(200)

    }.width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

《鸿蒙应用开发从入门到项目实战》系列文章持续更新中,陆续更新AI+编程、企业级项目实战等原创内容,防止迷路,欢迎关注!

在你的Rust类型里生成TypeScript的bindings!

你是否经常为前后端接口不一致而苦恼?改了文档改后端,改了后端改前端。为什么不直接从后端接口类型里生成前端接口呢?

当当当当!如果你在用 Rust 开发后端,用 TypeScript 开发前端,那你就有福了!今天给大家介绍一款 Rust 工具——gents。(generate ts, 优雅得像 gentleman )。

这个工具可以在你的 Rust 结构加入一点简单的宏:

#[derive(TS)]
#[ts(file_name = "person.ts", rename_all = "camelCase")]
pub struct Person {
    pub age: u16,
    pub en_name: String,
}

然后写一个 binary 或者测试函数就像这样:

#[test]
fn gents() {
    use gents::FileGroup;
    let mut group = FileGroup::new();
    // Add your root types; dependencies will be included automatically
    group.add::<Person>();
    // The second argument controls whether to generate index.ts
    group.gen_files("outdir", false);
}

运行一下你就能得到一个person.ts文件!还支持 enum 类型哦!

如果仅仅这样也太简单了!如果这个 Rust 类型使用到了别的 TS 类型,可以自动帮你搜集依赖,同时生成相应的 TypeScript 接口!这样,你就可以使用 JSON 格式在前后端通信了!是不是比 Swagger 或者 ProtoBuf 更加方便?超适合用在 monorepo 里,或者一个人包揽前后端。如果你在开发 WebAssembly 应用,那更加好了,因为你可以参考这个项目的用法!

感兴趣的朋友甚至可以研究一下这个 Rust 库的工作原理,个人感觉十分 Rustic!虽然 proc_macro 的代码真的很难读😂。如果有人对他的实现感兴趣,有机会可以再开一期!反正我看完这个代码,真的赞叹作者脑洞清奇!


我是 Rust 菜鸡,关注我,我让大家教我写代码!

极空间变身全能私有云+1Panel傻瓜式部署:cpolar内网穿透实验室第618个成功挑战

NO.618 极空间NAS+1Panel-01.png

软件名称:极空间 + 1Panel

操作系统支持

  • 极空间基于Linux内核(无需额外安装系统)
  • 1Panel兼容主流Linux发行版(如Ubuntu、Debian等)

** 软件介绍**:

  • 极空间:家用NAS设备,提供存储、备份和基础服务功能。
  • 1Panel:轻量级管理面板,通过图形化界面简化服务器运维,支持一键部署Docker应用(如Emby、Alist)、SSL证书自动签发、定时任务等,彻底告别命令行!
  • cpolar:内网穿透工具,无需公网IP或复杂配置即可将局域网服务暴露到互联网。

NO.618 极空间NAS+1Panel-02.png

极空间+1Panel=私有云“瑞士军刀” | 部署零命令

  • 一键部署全家桶:点击几下就能在极空间上架Emby(家庭影院)、Alist(文件网盘)、Nextcloud(云端协作)、Gitea(代码托管)等服务,连Docker容器管理都变成“拖拽游戏”。
  • 保姆级SSL证书:1Panel自动帮你申请免费Let’s Encrypt证书,网站秒变HTTPS加密,隐私保护拉满。

NO.618 极空间NAS+1Panel-03.png

实用场景举例

场景1:家庭影院自由人

  • 痛点:“NAS里存了2TB电影,但手机连不上局域网的Emby服务器!”
  • 爽点:用1Panel 5分钟部署Emby,配合cpolar生成隧道链接,出差时用手机App刷片就像在本地——再也不用依赖云盘流量!

场景2:远程办公救星

  • 痛点:“团队需要共享文件和文档,但公司服务器又贵又卡!”
  • 爽点:极空间+1Panel部署Nextcloud后,所有成员通过网页或App实时协作。用cpolar穿透外网访问,成本只有企业云盘的1/10!

NO.618 极空间NAS+1Panel-04.png

cpolar内网穿透技术带来的便利

  • 零门槛公网访问:无需设置路由器端口映射、不用申请静态IP,只需在1Panel后台一键安装cpolar插件,生成动态域名即可。
  • 场景举例:部署好Alist后,通过cpolar创建隧道,用手机浏览器输入链接就能在外网分享NAS里的文件——就像有个“随身U盘”。
  • 安全无忧:支持密码保护和IP白名单,外人想偷窥你的私有云?门都没有!

NO.618 极空间NAS+1Panel-05.png

总结

极空间+1Panel的组合,让一台家用NAS瞬间化身全能服务器,从存储到服务一应俱全;而cpolar则像一条“隐形光纤”,把局域网服务直接推送到互联网。这套方案适合所有想摆脱云服务商束缚、追求自由与隐私的家庭用户——这才是私有云该有的样子!

想要你的私有云也变得像上面收的那么好,赶紧按照教程安装吧!

接下来,带你一步步完成部署,解锁极空间的全部潜力。

1.在极空间上部署1panel

SSH终端部署是官方推荐的安装方式,只需运行官方提供的脚本,即可一键完成安装,操作简便,功能完整,并支持后续在线升级。唯一的门槛在于需要使用SSH工具连接服务器,对不熟悉命令行的新手来说可能稍显陌生,但只要按步骤操作,依然可以顺利完成。

未开通SSH的朋友们可以参考这篇文章:

使用SSH远程连接工具,连接到极空间,切换到root用户下:

98a39e0b9bf8f528f599e82a5d065f22

输入1Panel一键安装脚本并回车安装:

bash -c "$(curl -sSL https://resource.fit2cloud.com/1panel/package/v2/quick_start.sh)"

输入”2“:

28b55f0b91d081909a6540a36050712c

接下来进入1Panel的自定义安装界面。这里需要特别注意,安装目录应填写我们之前创建的“1panel”文件夹的实际路径。其他设置项,如面板端口、安全登录入口、管理员账号和密码,建议根据个人习惯进行修改,使用自己熟悉的配置,便于后续管理与记忆,同时也能提升安全性。

63491e042e0a4c895c2781daffb16fd3

安装完成后,用给的登录信息登录:

edc04eed21cf1c371e348853607c174f

访问完成:

46ca7ed2ee75a7f2f11c824758ca42a3

登录后,显示极空间基础信息:

f814810024518ddca0aabec5db21fb0f

2.配置docker-compose部署

1Panel用 docker-compose 部署,不是为了“炫技”,而是为了让每一个用户都能像专家一样,简单、安全、可靠地管理复杂的自建服务。

更新软件包列表:

sudo apt-get update

image-20250923172303171

下载最新版本的 Docker Compose:

sudo curl -L "https://github.com/docker/compose/releases/download/v2.17.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose

image-20250923172421707

赋予执行权限:

sudo chmod +x /usr/local/bin/docker-compose

验证安装是否成功:

docker-compose --version

image-20250923172502778

安装成功!

3.简单使用1Panel

我这里已安装alist为例简单使用一下1Panel。

在应用商店,搜索alist,点击安装:

064f4c5877d0248221112f9af2bdae7f

端口外部访问设为允许,端口填写的是极空间IP地址:

b1ac855520e9b878df21e2d3c3a98546

安装ing。

8bd17072f83ead2284ab746589fb4bc0

这里我们可以看到,已经安装成功:

297030c475fb311c817eaadfae9a7a72

点击跳转,可以直接跳转到alist登录页面:

34f648417f21da4acf64c7d2a200d66d

03727a026328a4bb53a5dddb0b46f41a

查看日志:

0b923fe8d4398f146a21072ed8457812

查看登录密码:

8d6e9bfb6abf727acd3cf8262a8a2d99

登录成功!

86583fbc5a95c6835fff9d7bda0f0a15

初始进去会有一个获取目录失败,直接点击下方管理配置即可。

86583fbc5a95c6835fff9d7bda0f0a15

驱动选择本机存储,目录填写容器目录:

image-20250923173848508

目录位置:

image-20250923174038160

保存成功!

image-20250923173909136

此时回到主页,即可正常访问网盘内容。

image-20250923173959259

配置完成后,你的 AList 就正式上线了!现在就可以通过它轻松管理所有云盘文件。常用功能一应俱全:

  • 拖拽上传本地文件
  • 浏览不同云存储的内容,支持缩略图和列表模式
  • 一键生成分享链接,快速外发文件
  • 删除、重命名、移动文件,操作像本地资源管理器一样简单

通过以上操作,你已借助1Panel在极空间上成功部署Alist。图形化界面让安装变得简单直观,省去了繁琐的命令行配置。现在,你可以通过Alist统一管理多个云盘文件,轻松实现高效访问与集中管理。

通过 1Panel 可视化部署服务后,再引入 cpolar 内网穿透,无需公网 IP 也能让本地应用安全暴露到公网。只需简单配置,即可远程访问家里的 NAS、Web 管理界面或自建服务,实现全天候可管可控。1Panel 负责易用,cpolar 负责连通,强强联合,打造真正的私有云体验。

4.安装cpolar实现随时随地开发

cpolar 可以将你本地电脑中的服务(如 SSH、Web、数据库)映射到公网。即使你在家里或外出时,也可以通过公网地址连接回本地运行的开发环境。

❤️以下是安装cpolar步骤:

官网在此:www.cpolar.com

使用一键脚本安装命令:

sudo curl https://get.cpolar.sh | sh

e464b045413a024674a93ea472511b0f

安装完成后,执行下方命令查看cpolar服务状态:(如图所示即为正常启动)

sudo systemctl status cpolar

aa03713b56d9eef12a4da6b99d2e46ac

Cpolar安装和成功启动服务后,在浏览器上输入虚拟机主机IP加9200端口即:【http://192.168.50.100:9200】访问Cpolar管理界面,使用Cpolar官网注册的账号登录,登录后即可看到cpolar web 配置界面,接下来在web 界面配置即可:

打开浏览器访问本地9200端口,使用cpolar账户密码登录即可,登录后即可对隧道进行管理。

3af79ad708cc47c5bbea0b63c2c7230d

5.配置公网地址

登录cpolar web UI管理界面后,点击左侧仪表盘的隧道管理——创建隧道:

  • 隧道名称:可自定义,本例使用了:panel,注意不要与已有的隧道名称重复
  • 协议:http
  • 本地地址:
  • 域名类型:随机域名
  • 地区:选择China Top

image-20250924104009545

创建成功后,打开左侧在线隧道列表,可以看到刚刚通过创建隧道生成了公网地址,接下来就可以在其他电脑或者移动端设备(异地)上,使用地址访问。

image-20250924105226583

访问成功。

cc9fb0edb96c6960d641c3b7b0ef1be2

6.保留固定公网地址

使用cpolar为其配置二级子域名,该地址为固定地址,不会随机变化。

image-20250918151358733

点击左侧的预留,选择保留二级子域名,地区选择china Top,然后设置一个二级子域名名称,我使用的是panel1,大家可以自定义。填写备注信息,点击保留。

image-20250924110742018

登录cpolar web UI管理界面,点击左侧仪表盘的隧道管理——隧道列表,找到所要配置的隧道panel,点击右侧的编辑

image-20250924110807746

修改隧道信息,将保留成功的二级子域名配置到隧道中

  • 域名类型:选择二级子域名
  • Sub Domain:填写保留成功的二级子域名
  • 地区: China Top

点击更新

image-20250924110847929

更新完成后,打开在线隧道列表,此时可以看到随机的公网地址已经发生变化,地址名称也变成了保留和固定的二级子域名名称。

image-20250924110920077

最后,我们使用固定的公网地址在任意设备的浏览器中访问,可以看到成功访问极空间的页面,这样一个永久不会变化的二级子域名公网网址即设置好了。

image-20250924110941554

这样,我们不管是在家,还是同事想访问都可以轻松做到啦!

总结

极空间不只是个存储盘,更是你的私人服务器!通过 SSH 开启权限,用 1Panel 图形化管理,一键部署 AList、Emby、Nextcloud 等应用,轻松实现多云文件整合、媒体管理、私有云盘等功能。结合 cpolar 内网穿透,还能随时随地远程访问。告别封闭生态,从“存数据”升级为“管服务”,极空间 + 1Panel,真正解锁家用 NAS 的全能潜力!

感谢您对本篇文章的喜爱,有任何问题欢迎留言交流。cpolar官网-安全的内网穿透工具 | 无需公网ip | 远程访问 | 搭建网站

10.15-1 Reader电子书管理神器搭配极空间私有云:cpolar内网穿透实验室第488个成功挑战

NO.488 Reader-01.png

软件名称:Reader

操作系统支持:跨平台(Windows、macOS、Linux、Android、iOS)

软件介绍:

Reader是开源电子书管理工具,结合极空间私有云存储与索引能力,通过封面识别和智能分类让杂乱文件秒变知识库。搭配cpolar内网穿透技术,实现远程访问——从此告别“收藏即读完”的尴尬,把数字负债变成随时可用的智慧弹药库!

NO.488 Reader-02.png

"Reader的魔法:让200本电子书秒变知识库!"

  • 自动分类:像图书馆管理员一样整理文件。输入“张三”,直接跳转作者目录;搜索“AI伦理”,标签分组立刻出现。
  • 全平台进度同步:手机看一半,电脑接着读,连翻页动画都无缝衔接(比微信读书还丝滑!)。
  • 封面识别与搜索:输入关键词,3秒定位到《时间简史》第12章的“黑洞熵值讨论”。

NO.488 Reader-03.png

实用场景

场景一:学生党救星——期末复习不翻山

  • 痛点:“我明明下载过这本教材PDF,怎么找不到了?”
  • 爽点:Reader全文搜索直接定位到“第2章公式推导”,配合极空间云端备份不怕硬盘炸。

场景二:职场人士的跨设备阅读——通勤党逆袭”进度大师“

  • 痛点:“手机看到一半,回家电脑怎么接着读?”
  • 爽点:极空间+Reader自动同步,换设备继续刷进度条就像切换微信对话一样自然。

NO.488 Reader-04.png

远程办公?度假旅行?cpolar让电子书库随身携带!

  • 痛点场景:出差时想看家里NAS内存储的资料,却因不在局域网连不上NAS。

  • 解决方案:

    1. 在极空间私有云配置文件共享路径。
    2. 使用cpolar创建隧道,将本地端口映射到公网(无需复杂配置!)。
    3. 手机/平板下载Reader客户端,远程访问就像在自家书架前翻阅。

NO.488 Reader-05.png

Reader+极空间私有云+cpolar组合,堪称“电子书管理铁三角”!

  • 安全隐私:数据存放在自家NAS,不用依赖亚马逊云端。
  • 跨平台自由:手机、平板、电脑一键同步,比云服务还流畅。
  • 远程访问零门槛:通过cpolar隧道,全球随时调用知识库——从此不再有“收藏即读完”,让每一本书真正被翻开!

行走的私人书库创建方法在下面呦,有兴趣的朋友按照教程安装即可。

1.在极空间利用docker部署reader

首先打开docker—镜像—仓库,搜索reader,下载hectorqin/reader这个镜像。

35276c283d82d817bb06bb05b388b1ee

获得reader的镜像后,我们需要给这个应用创建2个文件夹,用来存储系统的数据和书籍的文件。我们在根目录下先创建一个docker文件夹;在Docker文件夹下添加一个logs文件夹和一个storage文件夹。

d523db3076fe88cef93adbf5af54ab6e

回到docker的本地镜像界面,双击下载好的reader镜像,开始部署。

58ff7dbdc3bf6e7382c94e8b9bba5e5d

点击文件夹路径,添加刚才创建好的文件夹,装载路径如下:

4fa7b450b7d9548bbdc820108927c7c4

端口设置为9004(自定义),点击应用:

810c62fdc0444c6d7fcb3656b5c18400

部署完成后,用极空间ip+9004就可以打开网页啦!

c5c272b5835fd079298309b59432dca8

接下来我们可以导入一下书源(json格式):

10698b28782754368948b61cf78cfb80

小说书源导入,勾选全部导入0d832c23fd828466d8598f3074c27323

可以根据书源搜索小说了:

f5beaa76a251b010a72d101f3795da0e

也可以导入书籍(支持很多格式txt、epub、pdf等):

d66400f81fc76927b09feea14e1418b1

导入后,在书架就可以看见啦:

835ef9cdf4af076e4876314f4c5bfe92

服务搭好了,别只自己爽!装个 cpolar,打通外网,让朋友也能陪你一起摸鱼看小说,通勤路上不再孤单~

2.安装cpolar实现随时随地开发

cpolar 可以将你本地电脑中的服务(如 SSH、Web、数据库)映射到公网。即使你在家里或外出时,也可以通过公网地址连接回本地运行的开发环境。

❤️以下是安装cpolar步骤:

官网在此:www.cpolar.com

使用一键脚本安装命令:

sudo curl https://get.cpolar.sh | sh

e464b045413a024674a93ea472511b0f

安装完成后,执行下方命令查看cpolar服务状态:(如图所示即为正常启动)

sudo systemctl status cpolar

aa03713b56d9eef12a4da6b99d2e46ac

Cpolar安装和成功启动服务后,在浏览器上输入虚拟机主机IP加9200端口即:访问Cpolar管理界面,使用Cpolar官网注册的账号登录,登录后即可看到cpolar web 配置界面,接下来在web 界面配置即可:打开浏览器访问本地9200端口,使用cpolar账户密码登录即可,登录后即可对隧道进行管理。

3af79ad708cc47c5bbea0b63c2c7230d

3.配置公网地址

登录cpolar web UI管理界面后,点击左侧仪表盘的隧道管理——创建隧道:

  • 隧道名称:可自定义,本例使用了:reader,注意不要与已有的隧道名称重复
  • 协议:http
  • 本地地址:9004
  • 域名类型:随机域名
  • 地区:选择China Top

image-20250930111810095

创建成功后,打开左侧在线隧道列表,可以看到刚刚通过创建隧道生成了公网地址,接下来就可以在其他电脑或者移动端设备(异地)上,使用地址访问。

image-20250930111850012

访问成功。

image-20250930112553810

4.保留固定公网地址

使用cpolar为其配置二级子域名,该地址为固定地址,不会随机变化。

image-20250918151358733

点击左侧的预留,选择保留二级子域名,地区选择china Top,然后设置一个二级子域名名称,我使用的是reader1,大家可以自定义。填写备注信息,点击保留。

image-20250930112804245

登录cpolar web UI管理界面,点击左侧仪表盘的隧道管理——隧道列表,找到所要配置的隧道reader,点击右侧的编辑

image-20250930112831255

修改隧道信息,将保留成功的二级子域名配置到隧道中

  • 域名类型:选择二级子域名
  • Sub Domain:填写保留成功的二级子域名
  • 地区: China Top

点击更新

image-20250930112903993

更新完成后,打开在线隧道列表,此时可以看到随机的公网地址已经发生变化,地址名称也变成了保留和固定的二级子域名名称。

image-20250930112947876

最后,我们使用固定的公网地址在任意设备的浏览器中访问,可以看到成功访问极空间的页面,这样一个永久不会变化的二级子域名公网网址即设置好了。

image-20250930113428762

这样,不管是上班路上,还是朋友想看都可以轻松实现啦!

总结

一次部署,终身受用:

  • 自动整理杂乱文件
  • 私有云安全存储,多端同步
  • 外网通过 cpolar 随时访问
  • 还能分享给朋友,通勤路上一起追更
  • 从此,告别“收藏即读完”的假性阅读,

让每一本好书,都真正被翻开、被读完、被记住。

感谢您对本篇文章的喜爱,有任何问题欢迎留言交流。cpolar官网-安全的内网穿透工具 | 无需公网ip | 远程访问 | 搭建网站

我用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 你知道我这四年怎么过的吗

好事连连

  • React 脱离 Facebook,成立基金会(以前 React 被 Vercel Nestjs 夺舍,更新的都是无用功能,对于开发体验毫无提升)
  • React 19.2 更新 Activity、useEffectEvent 等新特性。终于不用手动封装 KeepAlive
  • React Compiler 发布 1.0 稳定版,告别手动 Memo

好事连连啊

image.png

那些年被 React 折磨的日子

从 React Forget 到 babel-plugin-react-compiler,这四年的等待终于有了结果

还记得 2021 年 React Conf 上,React 团队首次展示了那个让人眼前一亮的项目——React Forget。当时我就想,终于!React 终于要解决那个让人头疼的性能问题了。

四年过去了,从 React Forget 到现在的 babel-plugin-react-compiler 1.0,这四年的等待让我深刻体会到了什么叫"望眼欲穿"。

React 的"愚蠢":为什么我们需要手动优化?

那些年我们写过的"屎山"代码

在 React 的世界里,性能优化一直是个让人头疼的问题。每次组件的 stateprops 发生变化,React 都会从根节点开始对比,判断哪些节点需要更新。这种机制导致了大量不必要的重新渲染。

为了避免这些不必要的渲染,我们不得不在代码里写满这样的 memo "屎山":

const ExpensiveComponent = React.memo(({ data, onUpdate }) => {
  const processedData = useMemo(() => {
    return data.map(item => ({
      ...item,
      processed: expensiveCalculation(item)
    }))
  }, [data])

  const handleClick = useCallback(() => {
    onUpdate(processedData)
  }, [processedData, onUpdate])

  return (
    <div onClick={handleClick}>
      {processedData.map(item => (
        <Item key={item.id} data={item} />
      ))}
    </div>
  )
})

Vue 的"优雅":细粒度响应式更新

再看看 Vue,人家是怎么做的:

<template>
  <div @click="handleClick">
    <Item 
      v-for="item in processedData" 
      :key="item.id" 
      :data="item" 
    />
  </div>
</template>

<script setup>
const processedData = computed(() => {
  return data.value.map(item => ({
    ...item,
    processed: expensiveCalculation(item)
  }))
})

const handleClick = () => {
  onUpdate(processedData.value)
}
</script>

简洁、优雅、自动优化。Vue 的细粒度响应式更新机制能够自动追踪数据的变化,并仅更新受影响的组件。开发者无需手动进行复杂的优化操作,代码更加直观和高效。

React Compiler:四年的等待终于有了结果

从 React Forget 到 babel-plugin-react-compiler

2021 年,React 团队在 React Conf 上首次展示了 React Forget 项目。这个项目的目标很明确:通过编译器自动优化组件的渲染性能,让开发者无需手动添加 React.memouseCallbackuseMemo 等优化代码。

经过四年的打磨,这项技术终于以 babel-plugin-react-compiler 的形式与开发者见面,现已发布稳定版 1.0

image.png

image.png

自动化的性能优化

React Compiler 通过静态分析代码,在编译阶段自动为组件添加必要的优化:

这是我写的源代码

import { useState } from 'react'

export default function App() {
  const [count, setCount] = useState(0)

  return (
    <div className='h-screen w-screen flex justify-center items-center flex-col gap-8'>
      <Comp1 />
      <button onClick={ () => setCount(count + 1) }>+</button>
      <button onClick={ () => setCount(count - 1) }>-</button>
      <p>{ count }</p>
      <Comp2 />
    </div>
  )
}

function Comp1() {
  return <div className="text-red-500 size-16 bg-indigo-300">Comp1 { Math.random() }</div>
}

function Comp2() {
  return <div className="text-blue-500 size-16 bg-green-300">Comp2 { Math.random() }</div>
}

接下来配置一下 Vite,看看编译产物,先关闭代码混淆压缩

image.png

在开启 React Compiler 打包后的结果如下

image.png

关闭 React Compiler 打包后的结果如下

image.png

就这么简单!无需修改任何业务代码,编译器会自动处理所有的性能优化。

没有 React 19.2 以前,需要自己解决 KeepAlive 问题

过去实现组件状态保持(KeepAlive)是个老大难问题

很多人可以认为 display: none 能解决一切,那只能说明的开发经验太浅了

display: none 的局限

  • 组件仍会渲染和初始化
  • 获取不到正确的 DOM 尺寸
  • 会触发生命周期和副作用
  • 多个隐藏组件会造成性能浪费

以前我是自己封装 KeepAlive 组件,需要借助 Suspense 挂起渲染,或手动管理组件卸载,代码复杂且容易出问题,代码大致思路如下

import type { KeepAliveProps } from './type'
import { memo, Suspense, use } from 'react'
import { KeepAliveContext } from './context'

const Wrapper = memo<KeepAliveProps>(({ children, active }) => {
  const resolveRef = useRef<Function | null>(null)

  if (active) {
    resolveRef.current?.()
    resolveRef.current = null
  }
  else {
    throw new Promise((resolve) => {
      resolveRef.current = resolve
    })
  }

  return children
})

/**
 * 利用 Suspense 实现的 KeepAlive 组件
 * 当 active 为 false 时,抛异常,触发 Suspense 的 fallback
 * 当 active 为 true 时,resolve 异常,触发 Suspense 的正常渲染
 */
export const KeepAlive = memo(({
  uniqueKey: key,
  active,
  children,
}: KeepAliveProps & { uniqueKey?: keyof any }) => {
  const { findEffect } = use(KeepAliveContext)
  /**
   * 触发钩子
   */
  useEffect(() => {
    const { activeEffect, deactiveEffect } = findEffect(key)

    if (active) {
      activeEffect.forEach(fn => fn())
    }
    else {
      deactiveEffect.forEach(fn => fn())
    }
  }, [active, findEffect, key])

  return <Suspense fallback={ null }>
    <Wrapper active={ active }>
      { children }
    </Wrapper>
  </Suspense>
})

好起来了

四年的等待,从 React Forget 到 babel-plugin-react-compiler,React 终于迎来了自动化的性能优化时代。并且还带来了 React 19.2 各项好特性,终于不是 SHIT SSR

虽然 React 在性能优化方面曾经被诟病,虽然我们曾经写过无数的"屎山"代码,虽然 Vue 等框架在响应式更新方面确实更加优雅,但 React Compiler 的到来无疑为 React 开发者带来了福音。

它通过自动化的性能优化,简化了开发流程,降低了出错的风险。开发者可以专注于业务逻辑,而无需担心性能优化的细节。

四年的等待值得吗?我想说,值得。因为这意味着 React 正在迎头赶上,为开发者提供更高效、更便捷的开发体验。

JavaScript设计模式(十三)——责任链模式:构建灵活高效的请求处理链

引言与责任链模式概述

责任链模式是一种行为设计模式,它允许多个对象处理同一个请求,从而避免请求发送者与接收者之间的耦合。在JavaScript中,这种模式通过将请求沿着处理链传递,直到某个对象处理它为止,提供了极大的灵活性。其核心思想是将请求的发送者和接收者解耦,让多个对象都有机会处理请求,从而提高系统的可扩展性和灵活性。

责任链模式的结构与实现原理

责任链模式由三个核心组件构成:处理器接口、具体处理器和客户端。处理器接口定义了处理请求的方法和设置后继处理者的方法;具体处理器实现处理逻辑并决定是否传递请求;客户端负责创建链并启动处理流程。

责任链的工作流程是:客户端将请求发送给链的首个处理器,每个处理器判断自己能否处理该请求。若能处理,则执行处理逻辑并终止传递;若不能处理,则将请求传递给链中的下一个处理器,直到找到能处理的处理器或到达链尾。

链式结构构建通常通过设置后继处理器的方法完成,可采用构造函数或工厂方法创建处理器实例并链接它们。终止条件包括处理器成功处理请求或链中所有处理器都无法处理。异常处理可在处理器内部捕获并处理,或沿着链向上传递。

// 处理器基类
class Handler {
  constructor() { this.next = null; }
  setNext(handler) { this.next = handler; return handler; }
  handle(request) {
    if (this.canHandle(request)) return this.process(request);
    return this.next ? this.next.handle(request) : null;
  }
  canHandle() { return false; }
  process() { throw new Error('子类必须实现process方法'); }
}

// 构建和使用责任链
const authHandler = new AuthHandler();
authHandler.setNext(new ValidationHandler());
const result = authHandler.handle({ type: 'auth' });

JavaScript中的责任链模式实现

责任链模式将请求的发送方与接收方解耦,让多个对象有机会处理请求。使用ES6类语法实现基础框架:

class Handler {
  constructor(next = null) {
    this.next = next; // 指向下一个处理器
  }
  
  handle(request) {
    // 默认处理逻辑
    if (this.next) {
      return this.next.handle(request);
    }
    return null;
  }
}

通过工厂函数灵活构建责任链:

function createChain(handlers) {
  return handlers.reduceRight((next, current) => {
    return new current(next);
  }, null);
}

函数式实现更为简洁:

const chainOfResponsibility = (handlers) => (request) => 
  handlers.reduce((acc, handler) => acc || handler(request), null);

类实现适合复杂对象场景,支持状态管理和复杂逻辑;函数式实现简洁高效,适合函数组合和纯函数场景。选择取决于具体需求和项目复杂度。

责任链模式的实际应用案例

在Web事件处理中,责任链模式允许事件依次经过多个处理器,每个处理器可决定处理事件或传递给下一个。

class EventHandler {
  constructor() {
    this.next = null;
  }
  
  setNext(handler) {
    this.next = handler;
    return handler;
  }
  
  handle(event) {
    if (this.canHandle(event)) {
      this.process(event);
    } else if (this.next) {
      this.next.handle(event);
    }
  }
}

Express.js中间件是责任链的典型应用,每个中间件可处理请求并将控制权传递给下一个。

app.use((req, res, next) => {
  console.log('Request received');
  next(); // 传递给下一个中间件
});

app.use((req, res, next) => {
  if (req.user) next();
  else res.status(401).send('Unauthorized');
});

表单验证使用责任链,每个验证器负责特定规则,失败则停止,否则传递给下一个验证器。

class Validator {
  setNext(validator) {
    this.next = validator;
    return validator;
  }
  
  validate(data) {
    if (!this.check(data) && this.next) {
      return this.next.validate(data);
    }
    return true;
  }
}

日志处理系统中,不同级别的日志由不同处理器处理,形成责任链。

class Logger {
  constructor(level) {
    this.level = level;
    this.next = null;
  }
  
  setNext(logger) {
    this.next = logger;
    return logger;
  }
  
  log(message, level) {
    if (level <= this.level) this.write(message);
    if (this.next) this.next.log(message, level);
  }
}

责任链模式的变体与高级应用

责任链模式有多种变体和高级应用场景。双向责任链允许请求在链中双向流动,适用于需要前后双向处理的场景。通过实现handleForwardhandleBackward方法,处理器可以同时支持向前和向后传递请求。

动态责任链提供了运行时修改链的能力,通过添加或移除处理器来适应不同的业务需求。这种灵活性特别适合配置多变或需要动态调整的系统。

将责任链与观察者模式结合,可以实现事件驱动的请求处理机制。每个处理器处理请求时触发相应事件,观察者可以响应这些事件,实现松耦合的交互。

在异步编程中,责任链同样表现出色。通过异步处理器和async/await,可以构建优雅的异步请求处理流程,保持代码清晰且易于维护。这种模式特别适合处理复杂的异步业务逻辑和中间件系统。

性能优化与最佳实践

责任链模式的主要性能瓶颈在于链式遍历开销,每个处理器都可能执行导致不必要的计算。优化策略包括实现短路机制,一旦请求被处理立即终止链路传播,避免无谓的后续处理。可通过优先级排序减少无效遍历,并缓存常见处理结果提升性能。

内存管理方面,应避免在链中创建不必要的闭包,及时解除不再需要的处理器引用,实现对象池重用处理器实例。下面是一个优化的责任链实现示例:

class OptimizedHandler {
  // 使用静态属性缓存已创建的处理器
  static handlerPool = new Map();
  
  // 从对象池获取处理器,避免重复创建
  static getHandler(type) {
    if (!this.handlerPool.has(type)) {
      this.handlerPool.set(type, new this(type));
    }
    return this.handlerPool.get(type);
  }
  
  // 处理请求并优化链路传播
  handle(request) {
    if (this.canHandle(request)) {
      return this.process(request);
    }
    // 短路机制:如果next存在且当前处理器不处理,才继续链路
    return this.next ? this.next.handle(request) : null;
  }
}

高级调试可添加链路追踪工具,在关键节点记录处理路径和性能数据,使用单元测试验证每个处理器的边界条件和异常处理能力。

总结

责任链模式通过解耦请求发送者与接收者,实现了灵活的请求处理流程。其核心在于构建处理器链,使请求能够沿着链路传递直至被处理,为代码提供了高度的可扩展性和维护性。在现代JavaScript框架中,Redux中间件、Express路由等场景广泛应用了这一模式,展现了其在异步处理和请求拦截方面的强大能力。

后端接口又改了?让 Apifox MCP 帮你自动同步类型定义

一、引言:每个前端都经历过的“黑暗时刻”

作为前端开发者,我们的工作本应是构建流畅、美观的用户界面,与逻辑和创意共舞。但现实中,却总有一种无力感如影随形,它不来自于复杂的算法或诡异的设计稿,而来自于那看似简单、却永远在“变动”的后端接口。

场景一:“接口又双叒变了!”—— 来自聊天窗口的恐惧

想象一下这个你我都无比熟悉的场景:

你正专注地编写一个用户列表页面,根据三天前定好的 API 文档,userName 字段是字符串。你精心编写了类型定义,处理了所有边界情况,代码优雅得像一首诗。

突然,飞书响了。是后端小哥发来的消息,轻松得仿佛在讨论午饭吃什么:

“嘿,那个用户列表接口,userName 改名叫 name 了哈,顺便加了个 nickName 字段。”

“哦对了,分页参数从 pageNum 改成 page 了,返回的成功码统一成 200 了,之前那个 0 不用了。”

你的内心独白:

  • “我的 TypeScript Interface 白写了?”

  • “我刚写的请求参数解析函数要重构?”

  • “页面会不会直接白屏?我得赶紧全局搜索所有用到的地方...”

    结果就是 :你不得不中断当前的工作,像一只无头苍蝇一样,在代码库、API 文档(如果它更新了的话)、和后端的聊天窗口之间来回切换。一次“微小”的改动,消耗掉的却是半小时的上下文切换成本和满心的烦躁。

场景二:“文档?哦,我忘了更新了...”—— 薛定谔的 API 文档

有时候,你甚至连那条“死亡通知”都收不到。你坚信不疑地按照 Yapi 文档上的定义开发,满心欢喜地等待联调。

结果一跑起来,控制台一片血红:

  • Error: “success” is not defined... (实际返回字段是 isSuccess)
  • TypeError: Cannot read property 'list' of undefined... (实际数据结构嵌套多了一层)
  • 请求明明成功了,你的代码却走进了错误处理分支。(因为成功码从 200 变成了 code: 0)

你去质问后端,得到的回复很可能是:“啊,这个文档是上个版本的,最新的没来得及更新,我口头跟你说一下...”

手写接口代码的“三重罪”

  1. 同步之痛 :后端任何细微的改动,都需要前端手动、逐一地去查找和修改。这不是编程,这是“人肉差分”和“联调侦探”游戏。
  2. 信任危机 :你永远无法完全信任文档。手写的类型定义和请求代码,与真实的 API 之间,始终存在一道无法逾越的“信任鸿沟”。
  3. 效率黑洞 :大量宝贵的时间,没有用在创造性的业务逻辑和用户体验优化上,而是浪费在反复确认、修改这些机械、重复的代码上。

我们渴望的理想国

我们梦想着这样一种工作流:

当后端的 API 发生变更时,前端的类型定义、请求代码,甚至 Mock 数据,都能像被施了魔法一样,自动、准确、实时地同步更新。

这听起来像天方夜谭吗?就在不久前 Apifox MCP 的出现,正将这个理想照进现实。

二、Apifox MCP 是什么?为什么是前端开发的福音?

在经历了引言中的“黑暗时刻”后,我们不禁会想:有没有一种方法,能把我们从前端与 API 的“肉搏战”中解放出来?答案是肯定的,而 Apifox MCP 正是这把开启新世界大门的钥匙。

为了让大家快速理解,我们先来看一张对比图,它清晰地展示了传统模式与 MCP 模式的天壤之别:

1.png

2.png

官方的解释:Model Context Protocol

  • 官方定义 :MCP 是由 Anthropic 提出的一种开放协议,旨在 为 AI 模型提供安全、可控地连接到外部工具和数据源的标准方式
  • “人话”翻译
    • 你可以把 AI 助手(如通义灵码、Cursor)想象成一个功能强大但“失忆”的大脑。它知识渊博,却不知道你公司项目的具体情况。
    • MCP 就像一个 “标准化的外接 USB 接口”
    • Apifox MCP Server 则是一个 “专属于你项目的 API 信息 U 盘”
    • 当你把这个“U 盘”(Apifox MCP)通过“USB 接口”(MCP 协议)插入“大脑”(AI 助手)后,AI 立刻就读取了你项目中所有 API 的详细信息,从此它对你的提问了如指掌。

一句话总结:Apifox MCP 是将你的 API 知识库,变成 AI 编程助手“记忆体”的桥梁。

它如何解决我们的核心痛点?

结合上面的流程图,我们可以清晰地看到 MCP 如何精准打击前端开发的痛点:

  • 自动化取代“人肉差分”
    • Before :后端接口变更 -> 前端手动查找、对比、修改。
    • After :后端接口在 Apifox 中变更 -> AI 助手通过 MCP 自动感知 -> 前端直接生成新代码。你将从重复劳动中解放出来。
  • 精准化终结“信任危机”
    • Before :AI 基于过时的文档或通用知识生成代码,常出现字段名错误、类型不匹配的“幻觉”。
    • After :AI 的所有回答都基于 MCP 提供的、来自 Apifox(唯一可信源) 的实时数据。生成的类型和代码与后端 API 定义 100%准确同步 ,彻底杜绝幻觉。
  • 一体化打通“信息孤岛”
    • Before :API 文档、类型定义、请求代码、Mock 数据是分散的、手动维护的,极易出现不一致。
    • After :MCP 打通了“文档 -> 类型 -> 代码 -> Mock”的完整链路,形成一个一体化的、可追溯的单一数据源工作流。

三、手把手配置:配置你的 AI 驱动开发环境

“听起来是否已经心动了?接下来,我们就来手把手搭建这套梦幻般的工作环境,让你也能亲身感受这种‘科技驱魔’的快感。”

环境准备清单

在开始之前,请确保你的电脑上已经安装了以下三样东西:

  • Visual Studio Code (VSCode) :你的主编辑器。确保是最新稳定版。
  • 通义灵码插件 :在 VSCode 扩展商店中搜索 Lingma 并安装。这是阿里出的免费且强大的 AI 编程助手, 目前对 MCP 的支持非常好
  • Apifox 账号并拥有项目权限 :你需要一个 Apifox 账号,并且是某个 API 项目的成员(拥有查看接口的权限)。

第一步:在 Apifox 中获取 MCP 凭证

这个凭证就像是你的“门禁卡”,允许通义灵码访问你 Apifox 项目中的数据。

  1. 登录你的 Apifox 账号。
  2. 点击右上角头像选择「账号设置」,选择 API 访问令牌。
  3. 点击 「新建」按钮, 填写名称和失效时间。
  4. 点击确定后, 一串以 APS-开头的字符串就是你的 MCP Token请立即复制并妥善保存 ,因为它只显示这一次!

安全提示 :这个 Token 拥有你 API 项目的读取权限,请像保护你的密码一样保护它,不要泄露给他人。

第二步:在 VSCode 中配置通义灵码连接 MCP

这是最核心的一步。我们需要告诉通义灵码如何去连接我们刚刚创建的那个“API 信息 U 盘”。

  1. 在 VSCode 中,打开通义灵码会话框,点击个人设置,选择 MCP 服务。
  2. 在 MCP 广场搜索 apifox,点击安装,使用 STDIO 类型链接。
  3. 配置参数变量--project-id:【项目 ID
  4. 配置环境变量 Key:APIFOX_ACCESS_TOKEN、Value:【MCP Token

如何验证:点击快速体验,会执行 MCP 工具,如果配置无误能正确获取到项目中定义好的接口。

四、核心实战:Apifox MCP 助力前端开发三大场景

环境配置完毕,现在让我们进入激动人心的实战环节。你将亲眼目睹,如何将“联调侦探”的苦差事,变成“AI 助理”为你一键生成的爽快体验。

场景一:一键生成 100% 准确的 TypeScript 类型定义

传统痛点 :手写 interfaceuserName 还是 user_name?哪个字段是可选的?一不留神就写错,联调时才发现,又得回头修改。

场景二:自动生成“开箱即用”的接口请求函数

传统痛点 :每次都要手动拼装 axiosfetch 请求,写 urlmethodheaders,枯燥且容易拼写错误。

场景三:智能生成高度仿真的 Mock 数据

传统痛点 :后端接口没好,前端无法开发。手写的 Mock 数据要么太假,要么和后端最终返回的结构对不上。

  • 开发环境通过本地配置代理,链接到 apifox 中的 mock 云端地址,系统会自动生成真实有效的数据。

五、深度使用心得与最佳实践

当你成功体验了 Apifox MCP 的强大功能后,如何让它更稳定、更高效地融入你的日常开发流,就成了新的课题。以下是我在深度使用后总结出的“内功心法”。

1. 融入团队工作流的最佳实践

个人玩转是第一步,让整个团队因此受益才是终极目标。

通过拆解 apifox 生成接口和类型的流程,让每一次生成的都适用项目,整理出的提示词 api_prompt.md

请按照下面步骤编写接口

1. 首先调用 API 文档的 refresh_project_oas_w0xnjq 方法,获取最新的 json 文件,写入到 apifox-test/prompt/jsonMap.json2. 拿到 apifox-test/prompt/jsonMap.json,继续调用 API 文档的 read_project_oas_ref_resources_w0xnjq,请参考如下示例:获取接口名称、获取接口路径 path,获取请求方式 method,获取请求入参 request,获取请求出参 response 等;写入到 apifox-test/prompt/api/service.json 中。
{
  "name1": {
    "path": "userinfo/memberLevel",
    "method": "GET",
    "request": {
      "pageNo": "number",
    },
    "response": {
      "success": "boolean",
      "data": {
        "total": "number",
        "rows": [
          {
            "id": "number",
            "name": "string",
            "memberLevel": "string",
            "memberRetailType": "string",
            "triggerType": "string"
          }
        ]
      }
    }
  },
  "name2": {
    "path": "userinfo/memberLevel1",
    "method": "POST",
    "request": null,
    "response": null,
  }
}
3. 拿到 apifox-test/prompt/api/service.json,请参考如下示例:生成对应的接口调用方法和出入参数类型,写入到 apifox-test/prompt/api/service.ts 中。
import {request} from '@/umi/max';

export interface commonResponse<T> {
  success: boolean;
  error: string;
  code: string;
  data: T;
}

export interface xxxParams {
  pageNo: number;
  pageSize: number;
}

export interface xxxData {
  id: string;
}

export interface xxxResponse {
  xxx: string;
}

export const getXXXApi = (params: xxxParams) => {
  return request<commonResponse<xxxResponse[]>>('/api/xxx', {
    method: 'GET',
    params,
  });
};

export const setXXXApi1 = (data: xxxData) => {
  return request<commonResponse<xxxResponse>>('/api/xxx1', {
    method: 'POST',
    data,
  });
};

2. 权限管理与安全须知

“能力越大,责任越大。” 让 AI 访问你的核心 API 资产,安全是首要考虑。

  • 最小权限原则
    • 在 Apifox 创建 MCP Token 时, 只授予它需要访问的「特定项目」的权限 。不要图省事直接授予所有项目权限。
    • 如果你的项目分为“前端组”和“后端组”,可以只为前端组需要的 API 项目创建 Token。
  • Token 保管
    • MCP Token 一旦创建, 只在首次显示时可见 。请务必立即妥善保存。
    • 绝对不要 将包含真实 Token 的代码或配置文件提交到 Git 仓库。如果不慎泄露,请立即在 Apifox 中撤销该 Token。

前端技巧:检测到省略号文本自动显示 Tooltip

前言

在前端开发中,我们经常会遇到接口返回的文本内容过长,无法完全显示的问题。为了处理这一问题,通常会设置固定的宽度并使用省略号样式(text-overflow: ellipsis)来隐藏超出的文本。然而,有时产品需求还希望用户能够通过悬停查看完整内容,这时就需要引入 Tooltip 进行展示。(没被省略的时候不要显示Tooltip)

// tailwind的样式单行省略
.line-clamp-1 {  
    overflow: hidden;  
    display: -webkit-box;  
    -webkit-box-orient: vertical;  
    -webkit-line-clamp: 1;  
}

// 自行设置的css样式
single-line {
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
}

为了解决这个问题,我们实现了一个自定义 Hook,该 Hook 会监测文本元素是否因宽度限制而被省略。一旦检测到文本内容被省略,Hook 会自动为该元素添加 Tooltip,确保用户可以方便地查看完整信息。

代码实现

use-ellipsis.ts

import { useEffect, useRef, useState } from 'react';

type Options = {
  lines?: number; // 支持多行
};

export function useEllipsis<T extends HTMLElement>({
  lines = 1,
}: Options = {}) {
  const ref = useRef<T>(null);
  const [isEllipsis, setIsEllipsis] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const check = () => {
      if (lines === 1) {
        setIsEllipsis(el.scrollWidth > el.clientWidth);
      } else {
        setIsEllipsis(el.scrollHeight > el.clientHeight);
      }
    };

    check();
    window.addEventListener('resize', check);
    return () => {
      window.removeEventListener('resize', check);
    };
  }, [lines]);

  return { ref, isEllipsis };
}

ellipsis-tooltip.tsx

import { Tooltip } from '@arco-design/web-react'; // 或 antd / 你自己的库
import { useEllipsis } from '@/hooks/use-ellipsis.ts';
import { cn } from '@/lib/utils.ts';

type EllipsisTooltipProps = {
  text: string;
  className?: string;
  onClick?: () => void;
  lines?: number;
};

export const EllipsisTooltip: React.FC<EllipsisTooltipProps> = ({
  text,
  className,
  onClick,
  lines = 1,
}) => {
  const { ref, isEllipsis } = useEllipsis<HTMLDivElement>({ lines });

  const lineClass =
    lines === 1 ? 'truncate whitespace-nowrap' : `line-clamp-${lines}`;

  const content = (
    <div ref={ref} className={cn(lineClass, className)} onClick={onClick}>
      {text}
    </div>
  );

  return isEllipsis ? <Tooltip content={text}>{content}</Tooltip> : content;
};

使用

 <EllipsisTooltip
  text={text}
  className="text-blue-500 flex-1 min-w-0 hover:cursor-pointer"
  onClick={}
/>

Next.js 16 来了:引领全栈开发新潮流

2025 年 10 月,Next.js 16 正式发布,标志着全栈 React 应用开发进入一个高性能、高智能、高集成的新时代。作为 Vercel 团队倾力打造的现代 Web 框架,Next.js 16 不仅在构建速度、运行时性能上实现飞跃,更通过 Turbopack 默认化、React 编译器集成、缓存机制革新、路由系统优化 等核心能力,重新定义了“全栈开发者”的生产力边界。

下面将带你全面解读 Next.js 16 的 7 大核心新特性,并结合实战代码,助你快速掌握这一引领前端乃至全栈开发新潮流的技术栈。


一、Turbopack 成为默认打包器:构建速度提升 2–5 倍

关键词:极速开发、Fast Refresh、告别 Webpack(除非必要)

Next.js 16 正式将 Turbopack 设为默认打包器,取代 Webpack。Turbopack 基于 Rust 编写,具备增量编译、按需加载、智能缓存等能力。

✅ 开发者收益:

  • 初始启动速度提升 3 倍+
  • Fast Refresh 响应时间缩短至 < 50ms
  • 大型项目(10k+ 模块)构建不再卡顿

🛠️ 如需回退 Webpack(如使用自定义 loader):

# 开发
next dev --webpack

# 构建
next build --webpack

💡 趋势:Turbopack 已被 50%+ 开发会话采用,20%+ 生产构建使用,成为新一代构建标准。


二、React 编译器正式集成:自动记忆化,告别手动 useMemo

关键词:自动优化、零心智负担、性能开箱即用

Next.js 16 默认启用 React Compiler(React Forget),自动对组件进行记忆化处理,消除不必要的重渲染。

启用方式(next.config.ts):

const nextConfig = {
  reactCompiler: true, // 启用 React 编译器
};
export default nextConfig;

效果对比:

// 无需手动 useMemo/useCallback
function UserProfile({ user }) {
  const handleClick = () => {
    console.log(user.name); // 编译器自动 memoize
  };

  return <button onClick={handleClick}>View {user.name}</button>;
}

价值:减少样板代码,提升渲染性能,尤其在复杂列表、表单场景中效果显著。


三、文件系统缓存:大型项目开发体验飞跃

关键词:秒级启动、增量编译、团队协作加速

Next.js 16 引入 文件系统缓存,在开发模式下持久化编译结果,避免重复计算。

配置(next.config.ts):

const nextConfig = {
  experimental: {
    turbopackFileSystemCacheForDev: true,
  },
};

实测效果

  • 10 万行代码项目:冷启动从 45s → 8s
  • 修改单文件:热更新从 3s → 0.2s

🌐 适用场景:微前端、低代码平台、企业级中后台系统。


四、缓存 API 全面升级:精准控制数据新鲜度

关键词revalidateTagupdateTagrefresh、服务端状态同步

Next.js 16 对缓存机制进行精细化重构,提供更灵活的控制能力。

1. updateTag():用户操作后立即刷新缓存

// app/actions.ts
'use server'
import { updateTag } from 'next/cache'

export async function updateUserProfile(userId: string, data: Profile) {
  await db.users.update(userId, data)
  updateTag(`user-${userId}`) // 刷新该用户相关缓存
}

2. refresh():强制刷新未缓存数据

'use server'
import { refresh } from 'next/cache'

export async function markAsRead(id: string) {
  await db.notifications.markAsRead(id)
  refresh() // 刷新当前页面数据
}

3. cacheLife:声明式缓存过期策略

export const revalidate = { 
  cacheLife: { hours: 1 } // 或 days: 7, max: true(永久)
}

价值:实现“用户操作 → 数据更新 → UI 自动同步”的闭环,无需手动 router.refresh()


五、路由与预取优化:布局去重 + 增量预取

关键词:减少重复请求、节省带宽、提升 LCP

Next.js 16 对 App Router 的预取机制进行深度优化:

  • 布局去重:同一布局在多个页面间只加载一次
  • 增量预取:仅预取差异部分,而非整个页面

示例场景:

一个商品列表页有 50 个商品链接,点击任一链接进入详情页:

  • 旧版:每次预取都包含完整布局(50 次重复)
  • Next.js 16:布局仅预取 1 次,后续只加载详情数据

效果:网络传输量减少 60%+,首屏加载更快。


六、构建适配器 API:自定义部署目标

关键词:多平台部署、边缘函数、Serverless 适配

通过 构建适配器(Build Adapter),开发者可自定义构建输出,适配不同运行环境。

示例:自定义边缘适配器(my-adapter.js

// next.config.ts
const nextConfig = {
  experimental: {
    adapterPath: require.resolve('./my-edge-adapter.js'),
  },
};

应用场景

  • 部署到 Cloudflare Workers / Deno / Bun
  • 生成静态站点 + 动态 API 混合包
  • 企业私有云定制化部署

七、全面拥抱 React 19.2:视图过渡 + Effect 事件

关键词:View Transitions、useEffectEvent<Activity />

Next.js 16 与 React 19.2 深度集成,带来现代化交互体验。

1. 视图过渡(View Transitions)

// app/layout.tsx
import { ViewTransitions } from 'next-view-transitions'

export default function Layout({ children }) {
  return <ViewTransitions>{children}</ViewTransitions>
}

→ 页面切换自动添加平滑动画,无需 CSS 动画库。

2. useEffectEvent:分离响应式与非响应式逻辑

const onMessage = useEffectEvent((data) => {
  sendAnalytics('message_sent', data); // 不触发重渲染
});

useEffect(() => {
  socket.on('message', onMessage);
}, []);

价值:提升交互流畅度,简化复杂状态管理。


八、破坏性变更与升级建议

变更项 说明 迁移建议
Node.js ≥ 20.9 Node 18 已弃用 升级 Node 版本
TypeScript ≥ 5.1 旧版 TS 不兼容 npm install typescript@latest
AMP 移除 不再支持 AMP 页面 移除相关代码
next/image 默认值变更 width/height 非必填(本地图) 检查远程图是否配置 remotePatterns
middleware.ts 移除 统一使用 middleware.js 重命名文件

🔧 一键升级命令

npx @next/codemod@canary upgrade beta

Next.js 16 不再只是一个“前端框架”,而是全栈应用的操作系统——它统一了数据获取、UI 渲染、缓存策略、构建部署,让开发者聚焦于业务本身。

未来的全栈开发者,不再写“前端”或“后端”,而是在 Next.js 中写“应用”。
现在,就用 Next.js 16 开启你的全栈新纪元!🚀

客服机器人面向初学者的通俗版

一、为什么现在做聊天机器人很方便?

你肯定用过客服机器人、游戏里的 AI 伙伴吧?这些都是聊天机器人的常见用法。以前做个好用的机器人挺难的,但现在不一样了 —— 有了 OpenAI 的 “智能工具”(API)和 New API 平台的 “稳定后台”,咱们不用自己搭复杂的系统,就能做出能流畅聊天的机器人。

二、机器人怎么 “听懂” 和 “说话”?

其实机器人的核心能力来自 “自然语言处理(NLP)” 技术,简单说就是让机器像人一样理解语言、生成语言。比如用 GPT-3 这种 “提前学过很多知识” 的模型,机器人就能跟你顺畅对话;再加上 New API 平台给的 “现成接口”,咱们不用管后台怎么运行,只需要专注想 “机器人要实现什么功能” 就行。

三、手把手教你写个简单机器人(代码可直接用)

下面这段 Python 代码,已经连好稳定的服务了,你把 “your-api-key” 换成自己的密钥,就能运行试试:

python

运行

# 先导入需要的工具(openai库)
import openai

# 连接稳定的API服务(相当于给机器人找个“稳定的信号塔”)
client = openai.OpenAI(
    base_url='https://yunwu.ai/v1',  # 国内能用的稳定地址
    api_key='your-api-key'  # 这里填你的专属密钥
)

# 定义“聊天功能”:输入你说的话,输出机器人的回复
def chat_with_gpt(prompt):
    # 让机器人调用模型生成回复
    response = client.Completion.create(
        engine="davinci",  # 选一个好用的模型
        prompt=prompt,     # 把你说的话传给机器人
        max_tokens=150     # 控制机器人回复不要太长
    )
    # 把机器人的回复整理好返回
    return response.choices[0].text.strip()

# 来测试一下!比如问天气
user_input = "你好,今天的天气怎么样?"
robot_reply = chat_with_gpt(user_input)
print("机器人说:", robot_reply)

四、代码里的关键地方解释

  1. 连接服务base_url是 “信号塔地址”,选国内的能保证不卡顿;api_key是你的 “使用权限证明”,没有它用不了哦。
  2. 聊天函数chat_with_gpt就像机器人的 “大脑”,接收你的问题后,调用模型算出回复。
  3. 测试环节:用 “问天气” 举例子,运行后就能看到机器人怎么回复啦。

五、机器人能帮你做什么?怎么用得更好?

1. 能用到的地方

  • 客服:比如网店客服,机器人能秒回 “怎么退款”“快递多久到” 这种常见问题。
  • 营销:卖东西时,机器人能跟客户聊天,推荐适合的产品。
  • 学习:你问 “数学题怎么解”“英语单词怎么拼”,机器人能帮你解答。

2. 用得更好的小技巧

  • 多听用户意见:如果用户说 “机器人没懂我意思”,就调整对话逻辑。
  • 注意隐私:别让机器人保存用户的手机号、地址这些敏感信息。
  • 加更多功能:比如让机器人能 “看出来你开心还是生气”(情感分析),或者记住你的喜好(用户画像)。

如果操作时遇到问题,随时在评论区问大家哦!

小白也能懂的响应式布局:从 0 到 1 学会适配所有设备

什么是响应式布局?

想象一下:你做了一个网页,在电脑上看起来很完美,但用手机打开时,字小得看不清,图片还超出屏幕 —— 这就是没有做响应式布局的问题。

响应式布局就是让同一个网页,能像变形金刚一样,根据不同设备(手机、平板、电脑)自动调整样子:

  • 在手机上:内容从上到下排,字大一点
  • 在平板上:分成两列,布局更紧凑
  • 在电脑上:多列展示,充分利用大屏幕

简单说:一个网页,适配所有设备,不用为每种设备单独做一个版本

为什么需要响应式布局?

现在大家用手机上网的时间比电脑还多,如果你的网页在手机上体验差,用户会立刻关掉。

做响应式布局,能让你:

  • 只维护一个网页,节省时间和精力
  • 所有设备上的用户都有好体验
  • 对搜索引擎更友好(谷歌优先收录响应式网页)

响应式布局 3 大核心技术

1. 视口设置(必须第一步做)

这是响应式布局的 "地基",没有它,后面的工作都白费。

在 HTML 的<head>里加一句:

html

预览

<meta name="viewport" content="width=device-width, initial-scale=1.0">

作用:告诉手机浏览器 "请按实际屏幕宽度显示我的网页,不要缩放"。

没有这句话,手机会把网页当电脑页面处理,缩小后看不清;加了这句话,网页才能正确感知手机屏幕大小。

2. 相对单位(别再只用 px 了)

固定像素(px)就像买衣服只买 S 码,胖瘦都穿不上;相对单位则像弹性布料,能适应不同体型。

常用的 3 种相对单位:

单位 含义 用法举例
% 相对于父元素的百分比 width: 50%(占父容器一半宽)
rem 相对于根元素(html)的字体大小 font-size: 1.2rem(字体大小)
vw 相对于屏幕宽度的百分比(1vw=1% 屏幕宽) width: 80vw(占屏幕宽的 80%)

新手推荐用法

  • 字体大小用rem
  • 容器宽度用%vw
  • 元素间距用rem%

css

/* 基础设置 */
html {
  font-size: 16px; /* 1rem = 16px */
}

/* 响应式调整 */
@media (max-width: 767px) {
  html {
    font-size: 14px; /* 手机上1rem=14px,整体缩小 */
  }
}

3. 媒体查询(响应式的 "开关")

媒体查询就像一个智能开关,能根据屏幕尺寸自动切换不同样式。

基本语法

css

/* 当屏幕宽度≤767px时(手机),应用这些样式 */
@media screen and (max-width: 767px) {
  .box {
    width: 100%; /* 占满屏幕 */
    font-size: 1.2rem;
  }
}

常用断点设置(行业通用标准):

  • 手机:max-width: 767px
  • 平板:min-width: 768px and max-width: 991px
  • 电脑:min-width: 992px

新手友好的响应式布局步骤

  1. 先做手机版(移动优先)

    • 所有内容从上到下排列
    • 按钮和文字大一点,方便触摸
  2. 再扩展到平板

    • display: flexgrid分成 2 列
    • 调整间距和字体大小
  3. 最后优化电脑版

    • 增加列数(3-4 列)
    • 限制最大宽度,避免在超大屏幕上太松散

实战案例:响应式文章卡片

下面是一个简单案例,你可以复制代码保存为 HTML 文件,用浏览器打开后拖动窗口大小,观察变化:

响应式文章卡片案例

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <!-- 视口设置:必须要有 -->
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>响应式文章卡片</title>
    <style>
        /* 基础样式(手机版) */
        .card-container {
            max-width: 1200px;
            margin: 0 auto;
            padding: 20px;
        }
        
        .article-card {
            border: 1px solid #ddd;
            border-radius: 8px;
            padding: 15px;
            margin-bottom: 20px;
        }
        
        .article-img {
            width: 100%;
            height: 200px;
            object-fit: cover;
            border-radius: 4px;
        }
        
        .article-title {
            font-size: 1.5rem;
            margin: 15px 0;
        }
        
        .article-content {
            font-size: 1rem;
            color: #666;
        }
        
        /* 平板版(768px及以上) */
        @media (min-width: 768px) {
            .card-container {
                display: grid;
                grid-template-columns: 1fr 1fr; /* 分成2列 */
                gap: 20px;
            }
        }
        
        /* 电脑版(992px及以上) */
        @media (min-width: 992px) {
            .card-container {
                grid-template-columns: 1fr 1fr 1fr; /* 分成3列 */
            }
        }
    </style>
</head>
<body>
    <div class="card-container">
        <div class="article-card">
            <img src="https://picsum.photos/400/300?random=1" alt="风景照片" class="article-img">
            <h3 class="article-title">旅行日记:海边日出</h3>
            <p class="article-content">清晨五点,海边的日出真美,海浪拍打着礁石,阳光慢慢染红了天空...</p>
        </div>
        
        <div class="article-card">
            <img src="https://picsum.photos/400/300?random=2" alt="美食照片" class="article-img">
            <h3 class="article-title">家常菜谱:番茄炒蛋</h3>
            <p class="article-content">最简单的家常菜,却是最温暖的味道,分享我的独家做法...</p>
        </div>
        
        <div class="article-card">
            <img src="https://picsum.photos/400/300?random=3" alt="书籍照片" class="article-img">
            <h3 class="article-title">读书笔记:生活的艺术</h3>
            <p class="article-content">这本书教会我如何在平凡的生活中发现美好,珍惜每一个当下...</p>
        </div>
    </div>
</body>
</html>
    

新手常见问题

  1. 为什么我的响应式布局没效果?

    • 检查是否加了视口设置
    • 确认媒体查询的语法是否正确(括号、冒号等)
    • 用浏览器开发者工具(F12)的手机模式测试
  2. 应该用 Flex 还是 Grid?

    • 简单排列(如一行多个元素)用 Flex
    • 复杂网格布局(如不规则的多列)用 Grid
    • 新手可以先学 Flex,更简单直观
  3. 一定要记住所有断点吗?

    • 不用死记,常用的就 3 个(768px、992px、1200px)
    • 实际开发中可以用浏览器工具实时调整

总结

响应式布局没那么难,记住三个核心:

  1. 加视口设置(地基)
  2. 用相对单位(弹性尺寸)
  3. 写媒体查询(智能切换)

从简单案例开始练习,多调整浏览器窗口观察变化,很快就能掌握。下次做网页时,记得先想 "手机上会是什么样子",慢慢就会养成响应式思维啦!

【光照】UnityURP中的[HDR贴图]

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

HDR贴图的概念与特性

HDR(高动态范围)贴图是Unity URP中用于存储超出标准0-1范围光照信息的特殊纹理格式。与普通LDR(低动态范围)贴图相比,HDR贴图能够存储更广范围的亮度值,通常使用16位或32位浮点精度而非8位整数精度。

HDR贴图主要分为两类:

  • HDR环境贴图‌:用于天空盒和环境光照,通常采用.hdr或.exr格式
  • HDR光照贴图‌:用于烘焙光照信息,存储间接光照数据

HDR贴图解决的问题

亮度范围限制‌:

  • 传统LDR贴图只能表示0-1范围的亮度值,无法准确表现真实世界的光照强度(如阳光直射可能达到100,000尼特)。

细节丢失‌:

  • LDR在高亮区域会产生过曝,丢失细节;在暗部则会产生噪点和色带。

光照计算精度‌:

  • HDR贴图提供更高精度的光照数据,使后期处理(如Bloom、色调映射)效果更自然。

URP中HDR贴图的具体使用

导入HDR环境贴图

csharp
// 通过代码加载HDR贴图
public class HDRLoader : MonoBehaviour {
    public Texture2D hdrTexture;

    void Start() {
// 设置HDR贴图为天空盒
        RenderSettings.skybox.SetTexture("_MainTex", hdrTexture);
    }
}

配置HDR天空盒

  • 下载HDR贴图(推荐HDRI Hub等资源网站)
  • 导入Unity项目,确保纹理导入设置中"Texture Shape"设为Cube
  • 创建材质,选择Shader > Skybox > Cubemap
  • 将HDR贴图拖入材质
  • 在Lighting窗口中将材质指定为天空盒

烘焙HDR光照贴图

  • 确保场景物体:
    • 具有Mesh Renderer组件
    • 标记为Contribute GI
    • 启用了Generate Lightmap UVs
  • 在Lighting窗口调整Lightmap Resolution和Scale In Lightmap参数
  • 关闭自动烘焙,手动执行烘焙以获得最佳效果

HDR贴图的高级应用

Light Cookies‌:

  • 使用HDR贴图作为2D遮罩或立方体贴图,控制光源形状和衰减
  • HDR贴图作为Light Cookies可以精确控制光源的形状和衰减特性,相比普通LDR贴图能够表现更广范围的亮度值,特别适合模拟高亮度光源效果如聚光灯、霓虹灯等。URP中支持两种形式的Cookies:
    • 2D平面贴图‌:主要用于聚光灯(Spot Light)
    • 立方体贴图‌:主要用于点光源(Point Light)

2D HDR Cookie实现过程

  • 准备HDR贴图

    csharp
    // 创建HDR纹理
    Texture2D hdrCookie = new Texture2D(512, 512, TextureFormat.RGBAHalf, true);
    hdrCookie.wrapMode = TextureWrapMode.Clamp;
    hdrCookie.filterMode = FilterMode.Bilinear;
    
  • 应用到聚光灯

    csharp
    public class SpotLightCookieSetter : MonoBehaviour {
        public Texture2D hdrCookieTexture;
        public Light spotLight;
    
        void Start() {
            if(spotLight.type == LightType.Spot) {
                spotLight.cookie = hdrCookieTexture;
                spotLight.cookieSize = 10f;// 控制Cookie影响范围
            }
        }
    }
    
  • 材质设置

    在URP中需要确保材质使用支持HDR的Shader,如Universal Render Pipeline/Lit。

立方体HDR Cookie实现过程

  • 创建HDR立方体贴图
csharp
// 通过代码生成HDR立方体贴图
public Cubemap CreateHDRCubemap(int size) {
    Cubemap hdrCubemap = new Cubemap(size, TextureFormat.RGBAHalf, true);
// 填充各面数据...return hdrCubemap;
}
  • 应用到点光源
csharp
public class PointLightCookieSetter : MonoBehaviour {
    public Cubemap hdrCubemap;
    public Light pointLight;

    void Start() {
        if(pointLight.type == LightType.Point) {
            pointLight.cookie = hdrCubemap;
        }
    }
}
  • 完整示例:HDR Cookie光照场景
    • HDRCookieDemo.cs

      using UnityEngine;
      using UnityEngine.Rendering.Universal;
      
      public class HDRCookieDemo : MonoBehaviour {
          public Texture2D spotCookie;
          public Cubemap pointCookie;
          public Light spotLight;
          public Light pointLight;
      
          void Start() {
              // 设置聚光灯Cookie
              spotLight.type = LightType.Spot;
              spotLight.cookie = spotCookie;
              spotLight.cookieSize = 8f;
      
              // 设置点光源Cookie
              pointLight.type = LightType.Point;
              pointLight.cookie = pointCookie;
      
              // 确保使用HDR
              spotLight.useColorTemperature = true;
              pointLight.useColorTemperature = true;
          }
      }
      

关键参数调整

  • 亮度控制‌:HDR Cookie的亮度值可以超过1.0,实现超亮光源效果
  • 衰减调节‌:通过Cookie的边缘渐变控制光源衰减
  • 色彩范围‌:HDR支持更广的色域表现

性能优化建议

  • 合理控制HDR Cookie分辨率(通常512x512足够)
  • 对远处光源使用较低分辨率Cookie
  • 考虑使用压缩的HDR格式(如BC6H)减少内存占用
  • 动态加载和卸载HDR Cookie资源

反射探针‌:

  • 生成HDR立方体贴图用于高质量反射

  • HDR反射探针通过捕获场景环境生成高动态范围立方体贴图,为物体提供更真实的反射效果。相比传统LDR反射贴图,HDR立方体贴图能够存储更广范围的亮度值(0-65504),保留亮部和暗部细节,特别适合表现金属、玻璃等高反射材质。

  • 创建并配置反射探针

    csharp
    // 创建反射探针并设置HDR属性
    GameObject probeObj = new GameObject("HDR_ReflectionProbe");
    ReflectionProbe probe = probeObj.AddComponent<ReflectionProbe>();
    probe.mode = ReflectionProbeMode.Realtime;
    probe.hdr = true;// 启用HDR
    probe.resolution = 512;// 分辨率
    probe.shadowDistance = 50f;// 阴影距离
    probe.cullingMask = LayerMask.GetMask("Default");// 渲染层
    probe.boxProjection = true;// 启用盒投影
    
  • 烘焙HDR立方体贴图

    csharp
    // 手动触发烘焙
    IEnumerator BakeHDRProbe(ReflectionProbe probe) {
        probe.RenderProbe();
        while(probe.IsFinishedRendering(probe.GetInstanceID()) == false) {
            yield return null;
        }
        Debug.Log("HDR Cubemap烘焙完成");
    }
    
  • 在Shader中采样HDR反射贴图

    glsl
    // URP Shader中采样HDR反射探针
    half3 SampleHDRReflection(float3 worldPos, float3 worldNormal) {
        float3 reflectDir = reflect(-_WorldSpaceCameraPos.xyz, worldNormal);
        half4 encodedCubemap = SAMPLE_TEXTURECUBE(unity_SpecCube0, samplerunity_SpecCube0, reflectDir);
        half3 decodedColor = DecodeHDREnvironment(encodedCubemap, unity_SpecCube0_HDR);
        return decodedColor;
    }
    

完整示例

  • HDRReflectionDemo.cs

    using UnityEngine;
    using UnityEngine.Rendering.Universal;
    
    public class HDRReflectionDemo : MonoBehaviour {
        public Material reflectiveMaterial;
        public Light directionalLight;
    
        void Start() {
            // 1. 创建HDR反射探针
            var probeObj = new GameObject("HDR_Probe");
            var probe = probeObj.AddComponent<ReflectionProbe>();
            probe.size = new Vector3(20, 20, 20);
            probe.hdr = true;
            probe.resolution = 1024;
    
            // 2. 设置反射材质参数
            reflectiveMaterial.SetFloat("_Metallic", 1.0f);
            reflectiveMaterial.SetFloat("_Smoothness", 0.95f);
    
            // 3. 动态更新反射探针
            StartCoroutine(UpdateProbe(probe));
        }
    
        IEnumerator UpdateProbe(ReflectionProbe probe) {
            while(true) {
                probe.RenderProbe();
                while(!probe.IsFinishedRendering(probe.GetInstanceID())) {
                    yield return null;
                }
                yield return new WaitForSeconds(1f); // 每秒更新一次
            }
        }
    }
    

关键参数优化建议

  • 分辨率选择‌:根据目标平台性能选择256-2048分辨率,移动端建议512
  • 更新频率‌:实时探针可选择"Via Scripting"模式按需更新
  • 盒投影‌:室内场景务必启用Box Projection实现距离相关反射
  • 混合距离‌:多个探针间设置Blend Distance实现平滑过渡
  • HDR解码‌:确保Shader中正确使用DecodeHDREnvironment函数处理HDR数据

性能优化

  • 对静态物体使用Baked模式探针,动态物体使用Realtime模式
  • 通过Importance属性控制多个探针的混合权重
  • 使用Time Slicing分散探针更新负载
  • 合理设置Culling Mask避免渲染不必要对象
  • 移动平台可降低HDR精度至R11G11B10格式

全局光照‌:

  • HDR贴图提供更精确的间接光照计算基础

  • 准备HDR环境贴图

    首先需要获取HDR环境贴图(.hdr或.exr格式),推荐从HDRI Hub等资源网站下载高质量的HDR天空盒贴图。导入Unity后需将纹理类型设置为"Cube"并启用HDR选项:

    csharp
    // 通过代码验证HDR贴图设置
    void CheckHDREnvironment(Texture2D hdrTexture) {
        if(hdrTexture.format != TextureFormat.RGBAHalf) {
            Debug.LogError("请使用HDR格式贴图(RGBAHalf)");
        }
    }
    
  • 配置HDR全局光照

    • 在URP渲染管线中设置HDR环境光:
    csharp
    public class HDRGlobalIllumination : MonoBehaviour {
        public Cubemap hdrCubemap;
        public Material skyboxMaterial;
    
        void Start() {
    // 设置HDR天空盒
            RenderSettings.skybox = skyboxMaterial;
            skyboxMaterial.SetTexture("_Tex", hdrCubemap);
    
    // 启用HDR全局光照
            RenderSettings.ambientMode = AmbientMode.Skybox;
            DynamicGI.UpdateEnvironment();
        }
    }
    
  • 烘焙HDR光照贴图

    • 确保场景静态物体:

      • 具有Mesh Renderer组件
      • 标记为Contribute GI
      • 启用了Generate Lightmap UVs
    • 在Lighting窗口设置:

      csharp
      Lightmapping.lightingSettings.lightmapResolution = 40;// 每单位分辨率
      Lightmapping.lightingSettings.lightmapper = Lightmapper.ProgressiveGPU;
      Lightmapping.lightingSettings.filteringMode = LightmapFiltering.Advanced;
      
    • 执行烘焙:

      csharp
      Lightmapping.BakeAsync();// 异步烘焙避免卡顿
      

材质Shader适配

  • 在URP Lit Shader中增强HDR间接光采样:

    glsl
    // 在URP Shader中增加HDR间接光处理
    half3 SampleHDRIndirectLight(float3 worldPos, float3 normal) {
        half3 indirectDiffuse = SampleSH9(normal); // 球谐光照
        half4 encodedCubemap = SAMPLE_TEXTURECUBE_LOD(unity_SpecCube0, samplerunity_SpecCube0, reflect(-worldPos, normal), 0);
        half3 decodedColor = DecodeHDREnvironment(encodedCubemap, unity_SpecCube0_HDR);
        return indirectDiffuse * 0.5 + decodedColor * 0.5; // 混合光照
    }
    

实时更新策略

  • 对于动态物体,结合光照探针和反射探针:

    csharp
    public class DynamicHDRGI : MonoBehaviour {
        public ReflectionProbe hdrProbe;
        public LightProbeGroup lightProbes;
    
        void Update() {
    // 每2秒更新一次探针
    if(Time.frameCount % 120 == 0) {
                hdrProbe.RenderProbe();
                LightProbes.Tetrahedralize();
            }
        }
    }
    

性能优化

  • 静态物体使用烘焙光照贴图,动态物体使用HDR光照探针
  • 根据距离分级使用不同分辨率的HDR贴图(近处1024,远处256)
  • 使用BC6H压缩格式减少HDR贴图内存占用
  • 通过脚本控制探针更新频率避免性能峰值

性能优化建议

  • 合理控制HDR贴图分辨率,平衡质量与性能
  • 移动平台考虑使用压缩的HDR格式(如ASTC HDR)
  • 对远处或小物体使用较低分辨率的HDR光照贴图
  • 动态加载和卸载HDR贴图以管理内存

HDR贴图是URP实现高质量渲染的重要工具,正确使用可以显著提升场景的真实感和视觉冲击力


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

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

前言

提到 React 应用的页面更新优化策略,会有两个绕不开的概念,它们分别是双缓存架构和 diff 算法。

其中 React 利用双缓存架构在内存中生成下次要渲染的页面所对应的虚拟 DOM 树,并且一次性地将需要更新的地方提交到真实 DOM 上,以避免页面出现白屏的情况。 而 diff 算法则是负责实现高效的虚拟 DOM 对比。

本文将分享 React 中双缓存架构 + diff 算法是如何优化页面更新的过程,以及它们背后的逻辑究竟是怎么样的。

双缓存架构

React 维护了两棵 Fiber 树(即虚拟 DOM 树):current fiber treeworkInProgress fiber treecurrent树代表当前屏幕上显示的内容信息,workInProgress树代表下次更新需要展示的内容信息。基于这两颗 Fiber 树的页面渲染架构就称为双缓存架构。

之所以需要使用两颗 Fiber 树去辅助页面更新,是因为:

如果我们没有 workInProgress 树去记录下次页面更新时,需要执行哪些增删改的操作,那么就只可以将页面清空,然后执行新增操作,将整个页面元素重新生成,那么此过程不仅影响应用性能,还可能因为绘制时间过长,造成白屏的情况。

而基于双缓存树架构,我们可以使用当前页面的结构信息(即 current fiber tree)与下次更新后的页面结构信息(即 JSX 元素编译后的 React 元素)进行对比,从而生成 workInProgress fiber tree,它记录了下次更新时,基于当前页面,有哪些元素需要执行增删改的操作,力求通过复用旧元素,以最小代价更新页面。

workInProgress 树的生成过程

前一篇文章中提到,React 更新页面的过程,可以宏观地分解为 Render / Reconciliation 阶段Commit 阶段
Render 阶段的主要任务就是:高效对比 current 树与新的 React 元素,生成 workInProgress 树。

而如何高效对比 current 树与新的 React 元素,使用的就是所谓的 diff 算法。

diff 算法的输入: current fiber 节点 + 新的 React 元素
diff 算法的输出:workInProgress 树(记录下次页面更新需要基于当前 DOM 结构执行哪些操作)

简而言之:在开始协调生成 workInProgress 树的时候,workInProgress 会复制得到 current 树的副本,然后 current 树和新的 React 元素进行 diff 对比,在此过程中对 workInProgress 树进行标记(记录对应节点需要执行的操作)。

落实到关键代码中,主要体现在 performUnitOfWork 方法中。此方法接收 current 树的节点作为参数,进行 diff 对比生成 workInProgress 树节点后。最后将 workInProgress 指向下一个需要执行的 current 树的节点(由调度器决定是否继续对返回的节点执行 performUnitOfWork 方法,本文暂不展开)。

整个 performUnitOfWork 分为两个两个阶段,分别执行beginWorkcompleteWork方法。

两个方法的主要任务为:

  • beginWork:
    • 判断当前节点及其子孙节点是否发生了改变(使用 lanes 及 ChildLanes 属性),如果没有改变,则进行 bailout 优化,跳过整个子树的处理,直接复用之前的节点。
    • (如子孙节点发生了变化)使用 reconcileChildFibers 去生成直接子节点。而这个过程,则需要调用 diff 算法去优化 workInProgress 树的生成过程。 (reconcileChildFibers 返回 nextFiber,作为 beginWork 的返回值及下次 performUnitOfWork 的参数)
  • completeWork:
    • 负责完成节点的处理工作,包括 DOM 节点的创建、属性的设置等。
    • 在此阶段,React 会收集所有副作用标记(即 flags、subtreeFlags 属性),这些标记记录了组件在 commit 阶段需要执行的操作。

diff 算法详解

diff 算法的责任是高效地对两颗树进行对比,在保证对比开销的同时,尽可能地复用旧节点。

首先我们先了解一下,diff 算法判断旧节点可复用的标准是什么,然后再去理解 diff 算法是如何匹配新旧节点并进行对比的。

组件复用判断条件

为了降低算法复杂度,React 会预设三个限制来辅助判断组件是否能复用:

  • 同层对比:只在同一层级的节点之间进行 diff 对比。
  • 使用 key 属性作为元素的 唯一标识:key 值相同的两个节点会被视为同一个节点而进行精确匹配对比。
  • 类型不同的元素产生不同的树:如元素从 <div>变为 <p>,因为类型改变,默认不能复用旧 DOM 节点,直接删除旧 <div>节点及其子孙节点,并新建 <p>节点及其子孙节点。

注意:
在列表渲染的情况下,React 会提示必须为每一项设置 key 作为唯一标识,以便在重新渲染时能精确匹配进行对比。

而在非列表渲染的情况下,React 则没有要求必须为元素设置 key 值。但在必要的情况下,也可以使用 key 值作为优化手段。(在没有设置 key 值的时候,key 的默认值为 null。)

则组件复用的判断逻辑如下:

  1. 只对同级组件进行判断
  2. 优先判断 key 值是否相等
  3. key 值相等的情况下,组件类型 type 也相等,则组件可复用。

diff 入口

diff 的入口函数是 reconcileChildFibers。

其中需要关注的参数为:

  • currentFirstChild:current 树中将要进行对比的子节点的第一个子节点(其余子节点通过其 sibling 节点链接)
  • newChild:新渲染的 React 元素的子节点(们)

因为对于不同类型的 newChild 会有不同的处理函数,所以需要先判断其类型,常见类型为 Object 和 Array(分别代表单个子节点和多个子节点),还有如 number 和 string 等(本文暂不讨论)。

function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null, // current节点的子节点
  newChild: any, // 将要渲染的children节点
  lanes: Lanes
) {
  // 伪代码:判断newChild是否为单个节点
  if (isObject(newChild)) {
    reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes);
  }

  // 伪代码:判断newChild是否为多个节点
  if (isArray(newChild)) {
    reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
  }

  // 省略其他情况
}

接下来我们分别讨论 newChild 为单节点和多节点的情况。

单节点 diff

当 newChild 为单节点的时候,只会执行一轮遍历操作:

  • 优先对比 key 值(无显式设置则默认为 null)。
  • 如 key 值匹配成功,再对比元素类型,类型相等则可复用旧节点。如元素类型不相等,则证明旧节点不能复用,直接标记删除所有旧节点。diff 结束。
  • 如 key 值匹配不成功,则标记此旧节点需要删除(而不删除它的兄弟节点,因为其他兄弟节点的 key 值可能会匹配成功),继续使用下一个兄弟节点进行对比,直至对比完所有旧节点。

具体流程图如下所示: image.png

多节点 diff

当 newChild 为多节点的时候(后续使用 newChildren 表示),则需要使用新的对比逻辑,分为两轮遍历。

第一轮遍历

  • 遍历 newChildren,将 newChildren[i]与 oldFiber 比较,判断两者 key 值是否相等:

    • 如 key 值相同,则继续判断 type 是否相同,相同则可复用旧节点,否则将 oldFiber 标记为需要被删除,并标记为 newChildren[i]创建新节点使用。继续进行遍历对比 newChildren[++i]与 oldFiber.sibling。

    • 如 key 值不相同,则立即跳出整个遍历,第一轮遍历结束。

即第一轮遍历的的结束条件有两个:

  • 遍历时出现 key 值不匹配的情况。
  • newChildren 数组遍历完或者oldFiber 遍历完(即 oldFiber.sibling === null)。

具体流程如下图所示: image.png

第一轮遍历结束,即进行第二轮遍历流程

第二轮遍历

四种情况

进入第二轮遍历时,会有如下四种情况:

  • newChildren 和 oldFiber 同时遍历完: 新旧节点都遍历完,无需进行第二轮遍历,diff 结束。

  • newChildren 未遍历完,oldFiber 遍历完: 此时因为 oldFiber 都已遍历完,证明所有旧 DOM 节点都已复用,剩下的 newChildren 都需要创建新的 DOM 节点来使用,diff 结束。

  • newChildren 遍历完,oldFiber 未遍历完: 此时意味着所有新的节点都已拥有对应的 DOM 节点,而剩余的 oldFiber 节点则无需再使用,标记为需要被删除,diff 结束。

  • newChildren 和 oldFiber 都没遍历完: 此时意味着有节点在此次更新中更换了位置,需要进行第二轮遍历以找出可以复用的节点。

正式流程

在正式开始前,我们需要把 oldFiber 中剩余的节点放到一个 Map 中,形成形如{key: fiber}的形式。以便后续在遍历剩余 newChildren 时,能直接通过 key 值定位到对应的 oldFiber。

接下来有一个变量lastPlacedIndex,它记录了上一个不需要移动的节点的原始下标。第二轮遍历开始时,它的值为 0。

遍历逻辑如下所示:

  • 遍历 newChildren,寻找当前新节点 newChildren[i]的 key 值是否存在于 Map 中:

    • 如果 Map 中没有此 key 值,则证明没有能复用的旧节点,为此节点标记需要创建新节点。

    • 如果在 Map 中找到此 key 值对应的 oldFiber(假设 type 也相同),则证明此旧节点可以复用。然后判断其位置是否需要移动:使用此 oldFiber 在原列表中的下标(使用 oldIndex 表示)与 lastPlacedIndex 进行对比。

      • 如果 oldIndex >= lastPlacedIndex,则证明此 oldFiber 原本就在 lastPlacedIndex 的后面,无需移动。且将 lastPlacedIndex 赋值为 oldIndex。

      • 如果 oldIndex < lastPlacedIndex,则证明 oldFiber 原本在 lastPlacedIndex 的前面(而在新页面中,它应在 lastPlacedIndex 的后面),需要标记 oldFiber 为需要向右移动。

  • 然后 i++,继续执行上述循环,直至 newChildren 遍历完毕(如遍历完毕后,还有剩余 oldFiber,则将它们标记为需要被删除)。

流程图如下所示: image.png

例子

上述文字描述可能比较晦涩,接下来将使用一个例子进行介绍

使用节点的 key 值来代表节点:

  • 当前页面展示的节点:abcde
  • 下次更新展示的节点:adbc

=========第一轮遍历开始=========

---------第一个迭代开始---------

新节点 key 值:a
旧节点 key 值:a
两者 key 值相等(假设 type 也相等),则旧节点可以复用

---------第一个迭代结束---------

---------第二个迭代开始---------

新节点 key 值:d
旧节点 key 值:b
两者 key 值不相等,无需判断 type,直接结束第一轮遍历

---------第二个迭代结束---------

=========第一轮遍历结束=========

=========第二轮遍历开始=========

newChildren === dbc,没用完,不需要执行删除旧节点
oldFiber === bcde,没用完,不需要执行插入新节点

为 oldFiber 创建 map 结构,形如:
{
b:oldFiber-b
c:oldFiber-c
d:oldFiber-d
e:oldFiber-e
}

创建 lastPlacedIndex 变量,初始值为 0。

---------第一个迭代开始---------

查找 key 值是否存在:d in map // true

map 中存在目标 key 值,且假设 type 相同,则旧节点可以复用。

判断旧节点是否需要移动:
节点 d 在原队列中下标为 3(原队列为 abcde),lastPlacedIndex 为 0。
即 oldIndex > lastPlacedIndex,旧节点无需移动,且赋值 lastPlacedIndex = oldIndex = 3

---------第一个迭代结束---------

---------第二个迭代开始---------

查找 key 值是否存在:b in map // true

map 中存在目标 key 值,且假设 type 相同,则旧节点可以复用。

判断旧节点是否需要移动
节点 b 在原队列中下标为 1(原队列为 abcde),lastPlacedIndex 为 3。
即 oldIndex < lastPlacedIndex,旧节点需要标记为向右移动,lastPlacedIndex 无需重新赋值。

---------第二个迭代结束---------

---------第三个迭代开始---------

查找 key 值是否存在:c in map // true

map 中存在目标 key 值,且假设 type 相同,则旧节点可以复用。

判断旧节点是否需要移动
节点 c 在原队列中下标为 2(原队列为 abcde),lastPlacedIndex 为 3。
即 oldIndex < lastPlacedIndex,旧节点需要标记为向右移动,lastPlacedIndex 无需重新赋值。

---------第三个迭代结束---------

此时 newChildren 已遍历完毕。oldFiber 中还有 e 节点未使用,标记为需要删除。

=========第二轮遍历结束,diff 结束===========

从上述例子可以看出,我们将元素从 abcde 重新排序为 adbc,按道理来说,只需删除 e 节点,并将 d 节点移动到第二位即可。
而根据 React 的 diff 算法,会将 bc 节点往后移动,而 d 节点不变。
由此可知,为了性能考虑,我们应该尽量减少将节点从后往前移动的操作。

总结

本文介绍了 React 使用双缓存架构解决页面更新时出现白屏的情况,以及同时能够以最小代价更新真实 DOM,优化了渲染过程。
而对比 current 树和新 React 元素生成 workInProgress 树的过程是个复杂的过程,React 分别针对单节点对比和多节点对比设计了两套 diff 算法,提高整个对比过程的效率。

分析 diff 算法的特性,我们得出编写 React 代码时的最佳实践:

  1. 尽量减少将节点从后往前移动的操作。
  2. 有必要时(如大型组件位置频繁交换)使用 key 值标记元素,以便复用 DOM 元素。

参考文章

react.iamkasong.com/diff/prepar…

JavaScript继承与原型链:揭开对象传承的神秘面纱

前言

你是否曾经好奇过,为什么在JavaScript中我们可以调用Array.prototype.push()方法,或者为什么Object.prototype.toString.call([])能够正确返回[object Array]?这些看似简单的操作背后,其实隐藏着JavaScript最核心的设计理念之一——原型链继承机制

JavaScript的继承系统与传统的基于类(class-based)的面向对象语言(如Java、C++)有很大不同。它采用了一种基于原型(prototype-based)的继承方式,这种设计既有其独特的灵活性,也给初学者带来了不少困惑。在这篇文章中,我将带你深入浅出地理解JavaScript中的继承和原型链概念,从基础原理到高级应用,让你彻底掌握这一核心知识点。

一、理解JavaScript中的对象

在深入探讨继承和原型链之前,我们首先需要明确JavaScript中对象的基本概念。

1.1 什么是对象?

在JavaScript中,对象是属性的集合。这些属性可以是原始值、对象或函数。简单来说,对象就是一个存放相关数据和功能的容器。

// 创建一个简单的对象
const person = {
  name: "张三",
  age: 25,
  sayHello: function() {
    console.log(`你好,我是${this.name}`);
  }
};

person.sayHello(); // 输出: 你好,我是张三

JavaScript中的对象具有一个特殊的内置属性,通常被称为**[[Prototype]]**(在浏览器控制台中显示为__proto__)。这个属性指向了该对象的原型对象。

1.2 对象的创建方式

在JavaScript中,创建对象有多种方式:

  1. 对象字面量:最简单直接的方式

    const obj = { key: "value" };
    
  2. 构造函数:使用new关键字调用函数

    function Person() {}
    const person = new Person();
    
  3. Object.create():基于现有对象创建新对象

    const prototypeObj = { greet: () => "Hello" };
    const newObj = Object.create(prototypeObj);
    
  4. ES6的Class语法:更接近传统面向对象的语法糖

    class Person {
      constructor(name) {
        this.name = name;
      }
    }
    const person = new Person("张三");
    

无论使用哪种方式创建对象,它们都会与原型链建立联系。

二、原型链的基本原理

2.1 什么是原型链?

JavaScript中的每个对象都有一个原型对象,对象从原型对象继承方法和属性。当我们访问一个对象的属性或方法时,如果该对象本身没有这个属性或方法,JavaScript会沿着原型链向上查找,直到找到该属性或方法,或者到达原型链的末端(null)。

让我们通过一个简单的例子来理解原型链的工作原理:

// 创建一个构造函数
function Animal(name) {
  this.name = name;
}

// 在Animal的原型上添加方法
Animal.prototype.eat = function() {
  console.log(`${this.name}正在进食`);
};

// 创建一个Dog构造函数
function Dog(name, breed) {
  Animal.call(this, name); // 调用Animal构造函数
  this.breed = breed;
}

// 设置Dog的原型为Animal的实例
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

// 在Dog的原型上添加方法
Dog.prototype.bark = function() {
  console.log(`${this.name}汪汪叫`);
};

// 创建一个Dog实例
const myDog = new Dog("小黑", "拉布拉多");

myDog.bark(); // 输出: 小黑汪汪叫 (Dog.prototype)
myDog.eat();  // 输出: 小黑正在进食 (Animal.prototype)
myDog.toString(); // 输出: [object Object] (Object.prototype)

在这个例子中,myDog对象的原型链是:myDog -> Dog.prototype -> Animal.prototype -> Object.prototype -> null。当我们调用myDog.eat()时,JavaScript首先检查myDog对象本身是否有eat方法,没有找到就沿着原型链向上查找,最终在Animal.prototype上找到了这个方法。

2.2 构造函数、原型和实例的关系

在JavaScript中,构造函数、原型对象和实例之间存在着三角关系:

  1. 构造函数有一个prototype属性,指向原型对象
  2. 原型对象有一个constructor属性,指向构造函数
  3. 实例对象有一个内部的[[Prototype]]属性,指向原型对象
function Person() {}
const person = new Person();

console.log(Person.prototype === person.__proto__); // true
console.log(Person.prototype.constructor === Person); // true

三、JavaScript中的继承方式

JavaScript提供了多种实现继承的方式,每种方式都有其优缺点。让我们逐一探讨:

3.1 原型链继承

原型链继承是JavaScript中最基本的继承方式,通过将子类的原型设置为父类的实例来实现继承。

// 父类
function Parent() {
  this.name = "parent";
  this.colors = ["red", "blue", "green"];
}

Parent.prototype.sayName = function() {
  console.log(this.name);
};

// 子类
function Child() {
  this.age = 18;
}

// 继承 - 将子类的原型设置为父类的实例
Child.prototype = new Parent();
Child.prototype.constructor = Child;

// 创建实例
const child1 = new Child();
const child2 = new Child();

child1.colors.push("black");
console.log(child1.colors); // ["red", "blue", "green", "black"]
console.log(child2.colors); // ["red", "blue", "green", "black"] - 注意这里也被改变了

优点:简单直观,易于实现

缺点

  1. 所有实例共享父类实例的属性(如上面的colors数组)
  2. 无法向父类构造函数传递参数

3.2 构造函数继承

为了解决原型链继承的问题,我们可以使用构造函数继承,通过在子类构造函数中调用父类构造函数来实现。

// 父类
function Parent(name) {
  this.name = name;
  this.colors = ["red", "blue", "green"];
}

// 子类
function Child(name, age) {
  // 调用父类构造函数
  Parent.call(this, name);
  this.age = age;
}

// 创建实例
const child1 = new Child("张三", 18);
const child2 = new Child("李四", 20);

child1.colors.push("black");
console.log(child1.colors); // ["red", "blue", "green", "black"]
console.log(child2.colors); // ["red", "blue", "green"] - 这里没有被改变
console.log(child1.sayName); // undefined - 无法继承父类原型上的方法

优点

  1. 解决了实例共享父类属性的问题
  2. 可以向父类构造函数传递参数

缺点:无法继承父类原型上的方法

3.3 组合继承

组合继承结合了原型链继承和构造函数继承的优点,是JavaScript中最常用的继承模式。

// 父类
function Parent(name) {
  this.name = name;
  this.colors = ["red", "blue", "green"];
}

Parent.prototype.sayName = function() {
  console.log(this.name);
};

// 子类
function Child(name, age) {
  // 调用父类构造函数(第二次调用Parent)
  Parent.call(this, name);
  this.age = age;
}

// 设置原型链(第一次调用Parent)
Child.prototype = new Parent();
Child.prototype.constructor = Child;

// 添加子类自己的方法
Child.prototype.sayAge = function() {
  console.log(this.age);
};

// 创建实例
const child = new Child("张三", 18);
child.sayName(); // 输出: 张三
child.sayAge(); // 输出: 18

优点

  1. 解决了实例共享父类属性的问题
  2. 可以继承父类原型上的方法
  3. 可以向父类构造函数传递参数

缺点:父类构造函数被调用了两次,造成了一定的性能损耗

3.4 原型式继承

原型式继承是由道格拉斯·克罗克福德(Douglas Crockford)提出的一种继承方式,它基于已有的对象创建新对象,实现思路类似于Object.create()

// 原型式继承函数
function objectCreate(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

// 要继承的对象
const person = {
  name: "张三",
  friends: ["李四", "王五"],
  sayName: function() {
    console.log(this.name);
  }
};

// 创建新对象
const anotherPerson = objectCreate(person);
anotherPerson.name = "赵六";
anotherPerson.friends.push("钱七");

const yetAnotherPerson = objectCreate(person);
yetAnotherPerson.name = "孙八";

console.log(anotherPerson.friends); // ["李四", "王五", "钱七"]
console.log(yetAnotherPerson.friends); // ["李四", "王五", "钱七"] - 注意这里也被改变了

优点:不需要创建构造函数,直接基于现有对象创建新对象

缺点:所有实例共享原型对象的属性(如上面的friends数组)

3.5 寄生式继承

寄生式继承是在原型式继承的基础上,增强新创建的对象,为其添加属性和方法。

// 原型式继承函数
function objectCreate(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

// 寄生式继承函数
function createAnother(original) {
  const clone = objectCreate(original); // 创建新对象
  clone.sayHi = function() { // 增强对象
    console.log("Hi");
  };
  return clone;
}

// 要继承的对象
const person = {
  name: "张三",
  friends: ["李四", "王五"]
};

// 创建新对象
const anotherPerson = createAnother(person);
anotherPerson.sayHi(); // 输出: Hi

优点:可以增强新创建的对象

缺点

  1. 所有实例共享原型对象的属性
  2. 方法无法复用,每次创建都会创建新的方法函数

3.6 寄生组合式继承

寄生组合式继承结合了组合继承和寄生式继承的优点,是JavaScript中最理想的继承模式。

// 寄生组合式继承函数
function inheritPrototype(child, parent) {
  const prototype = Object.create(parent.prototype); // 创建父类原型的副本
  prototype.constructor = child; // 修复constructor指向
  child.prototype = prototype; // 设置子类的原型
}

// 父类
function Parent(name) {
  this.name = name;
  this.colors = ["red", "blue", "green"];
}

Parent.prototype.sayName = function() {
  console.log(this.name);
};

// 子类
function Child(name, age) {
  Parent.call(this, name); // 调用父类构造函数
  this.age = age;
}

// 继承父类的原型
inheritPrototype(Child, Parent);

// 添加子类自己的方法
Child.prototype.sayAge = function() {
  console.log(this.age);
};

// 创建实例
const child = new Child("张三", 18);
child.sayName(); // 输出: 张三
child.sayAge(); // 输出: 18

优点

  1. 解决了实例共享父类属性的问题
  2. 可以继承父类原型上的方法
  3. 可以向父类构造函数传递参数
  4. 避免了父类构造函数被调用两次的问题

缺点:实现相对复杂

3.7 ES6的Class继承

ES6引入了class关键字,提供了更接近传统面向对象语言的语法糖,使继承的实现更加简洁清晰。

// 父类
class Parent {
  constructor(name) {
    this.name = name;
    this.colors = ["red", "blue", "green"];
  }
  
  sayName() {
    console.log(this.name);
  }
}

// 子类 - 使用extends关键字继承
class Child extends Parent {
  constructor(name, age) {
    super(name); // 调用父类的constructor
    this.age = age;
  }
  
  sayAge() {
    console.log(this.age);
  }
}

// 创建实例
const child = new Child("张三", 18);
child.sayName(); // 输出: 张三
child.sayAge(); // 输出: 18

优点

  1. 语法简洁清晰,更接近传统面向对象语言
  2. 解决了之前所有继承方式的问题
  3. 可以使用super关键字调用父类的方法

缺点:本质上是原型继承的语法糖,理解底层原理仍然很重要

四、原型链的高级应用

了解原型链的原理后,我们可以利用它来实现一些高级功能。

4.1 扩展内置对象

我们可以通过扩展内置对象的原型来为其添加新的方法。

// 为Array扩展一个求和方法
Array.prototype.sum = function() {
  return this.reduce((total, current) => total + current, 0);
};

const numbers = [1, 2, 3, 4, 5];
console.log(numbers.sum()); // 输出: 15

注意:扩展内置对象的原型可能会导致命名冲突或影响第三方库的正常工作,因此在实际开发中应谨慎使用。

4.2 原型混入(Mixins)

原型混入是一种实现代码复用的技术,它允许我们将多个对象的属性和方法合并到一个对象中。

// 定义多个混入对象
const canEat = {
  eat: function() {
    console.log(`${this.name}正在进食`);
  }
};

const canSleep = {
  sleep: function() {
    console.log(`${this.name}正在睡觉`);
  }
};

// 定义一个构造函数
function Person(name) {
  this.name = name;
}

// 使用Object.assign实现混入
Object.assign(Person.prototype, canEat, canSleep);

// 创建实例
const person = new Person("张三");
person.eat(); // 输出: 张三正在进食
person.sleep(); // 输出: 张三正在睡觉

4.3 实现接口继承

虽然JavaScript没有内置的接口概念,但我们可以通过原型链来模拟接口继承。

// 定义一个接口
const Serializable = {
  serialize: function() {
    throw new Error("serialize方法必须被实现");
  }
};

// 定义一个构造函数并实现接口
function Person(name) {
  this.name = name;
}

// 继承接口
Person.prototype = Object.create(Serializable);
Person.prototype.constructor = Person;

// 实现接口方法
Person.prototype.serialize = function() {
  return JSON.stringify({ name: this.name });
};

// 创建实例
const person = new Person("张三");
console.log(person.serialize()); // 输出: {"name":"张三"}

五、原型链的常见问题与解决方案

在使用原型链时,我们可能会遇到一些常见问题,下面介绍这些问题及其解决方案。

5.1 原型链过长导致的性能问题

原型链过长会导致属性查找的时间增加,从而影响性能。解决方法是尽量保持原型链的简短,避免不必要的继承层次。

// 不推荐: 过长的原型链
function A() {}
function B() {}
B.prototype = new A();
function C() {}
C.prototype = new B();
function D() {}
D.prototype = new C();

// 推荐: 保持原型链简短
function Base() {}
function Derived() {}
Derived.prototype = Object.create(Base.prototype);

5.2 意外修改原型对象

直接修改原型对象可能会影响所有基于该原型创建的实例。解决方法是使用Object.freeze()Object.seal()来保护原型对象。

// 冻结原型对象,防止被修改
function Person() {}
Person.prototype.sayHello = function() {
  console.log("Hello");
};
Object.freeze(Person.prototype);

// 尝试修改原型对象(将会失败)
Person.prototype.sayGoodbye = function() {
  console.log("Goodbye");
};

const person = new Person();
console.log(typeof person.sayGoodbye); // 输出: undefined

5.3 判断属性是否属于对象自身

使用for...in循环遍历对象的属性时,会遍历到原型链上的所有可枚举属性。解决方法是使用hasOwnProperty()方法来判断属性是否属于对象自身。

function Person() {
  this.name = "张三";
}

Person.prototype.age = 18;

const person = new Person();

// 使用for...in循环遍历属性
for (const key in person) {
  if (person.hasOwnProperty(key)) {
    console.log(`自有属性: ${key} = ${person[key]}`);
  } else {
    console.log(`继承属性: ${key} = ${person[key]}`);
  }
}
// 输出:
// 自有属性: name = 张三
// 继承属性: age = 18

六、继承与原型链的最佳实践

在实际开发中,我们应该遵循一些最佳实践来正确使用继承和原型链。

6.1 优先使用组合而非继承

继承会导致子类与父类的强耦合,而组合则更加灵活。在设计系统时,我们应该优先考虑组合而非继承。

// 不推荐: 过度使用继承
class Animal {}
class Dog extends Animal {}
class PetDog extends Dog {}

// 推荐: 优先使用组合
class Animal {}
class Dog extends Animal {
  constructor(name, owner) {
    super();
    this.name = name;
    this.owner = owner; // 组合: Dog包含owner属性
  }
}

6.2 使用ES6的Class语法

ES6的Class语法提供了更清晰、更简洁的方式来实现继承,应该优先使用。

// 推荐: 使用ES6的Class语法
class Parent {
  constructor(name) {
    this.name = name;
  }
  
  sayName() {
    console.log(this.name);
  }
}

class Child extends Parent {
  constructor(name, age) {
    super(name);
    this.age = age;
  }
}

6.3 保护原型链

避免直接修改内置对象的原型,同时也要注意保护自定义对象的原型不被意外修改。

// 不推荐: 修改内置对象的原型
Array.prototype.myCustomMethod = function() {};

// 推荐: 创建自定义工具函数
function myCustomFunction(arr) {
  // 对数组进行操作
}

6.4 理解this的指向

在原型方法中,this的指向可能会因为调用方式的不同而改变,需要特别注意。

function Person(name) {
  this.name = name;
}

Person.prototype.sayName = function() {
  console.log(this.name);
};

const person = new Person("张三");
const sayName = person.sayName;
sayName(); // 输出: undefined (this指向全局对象或undefined)

解决方法是使用bind()call()apply()来确保this的正确指向,或者使用箭头函数(在ES6的Class中)。

// 使用bind
const sayName = person.sayName.bind(person);
sayName(); // 输出: 张三

// 使用箭头函数(在ES6的Class中)
class Person {
  constructor(name) {
    this.name = name;
  }
  
  sayName = () => {
    console.log(this.name);
  };
}

七、深入理解JavaScript的继承模型

JavaScript的原型继承模型与传统的基于类的继承模型有很大不同,理解这种差异对于掌握JavaScript至关重要。

7.1 基于原型 vs 基于类

在基于类的语言中,对象是类的实例,类定义了对象的结构和行为。而在JavaScript中,对象直接从其他对象继承属性和方法,不需要类的定义。

虽然ES6引入了class关键字,但它只是原型继承的语法糖,JavaScript的本质仍然是基于原型的语言。

7.2 委托 vs 复制

JavaScript的继承是通过委托(delegation)实现的,而不是通过复制。当我们访问一个对象的属性时,如果该对象本身没有这个属性,JavaScript会委托给它的原型对象来查找这个属性。

function Person() {}
Person.prototype.name = "张三";

const person1 = new Person();
const person2 = new Person();

console.log(person1.name); // 输出: 张三 (委托给Person.prototype)
console.log(person2.name); // 输出: 张三 (委托给Person.prototype)

// 修改原型对象的属性
Person.prototype.name = "李四";

console.log(person1.name); // 输出: 李四 (委托的结果也会改变)
console.log(person2.name); // 输出: 李四 (委托的结果也会改变)

7.3 构造函数的本质

在JavaScript中,构造函数只是一个普通的函数,当我们使用new关键字调用它时,它就变成了一个构造函数。new关键字会执行以下操作:

  1. 创建一个新的空对象
  2. 将这个空对象的原型指向构造函数的原型
  3. 将构造函数的this绑定到这个空对象
  4. 执行构造函数体
  5. 如果构造函数没有返回对象,则返回这个新对象
// 模拟new关键字的行为
function myNew(constructor, ...args) {
  // 创建一个新对象,原型指向构造函数的原型
  const obj = Object.create(constructor.prototype);
  // 调用构造函数,this绑定到新对象
  const result = constructor.apply(obj, args);
  // 如果构造函数返回了对象,则返回该对象,否则返回新对象
  return typeof result === 'object' && result !== null ? result : obj;
}

// 测试
function Person(name) {
  this.name = name;
}

const person1 = myNew(Person, "张三");
const person2 = new Person("李四");

console.log(person1.name); // 输出: 张三
console.log(person2.name); // 输出: 李四

八、总结

JavaScript的继承和原型链是这门语言的核心概念,理解它们对于掌握JavaScript至关重要。本文从基础原理到高级应用,全面介绍了JavaScript中的继承和原型链机制,包括:

  1. 对象、原型和构造函数的基本概念
  2. 原型链的工作原理
  3. 各种继承方式及其优缺点
  4. 原型链的高级应用
  5. 常见问题与解决方案
  6. 最佳实践
  7. JavaScript继承模型的深入理解

通过学习本文,相信你已经对JavaScript的继承和原型链有了更深入的理解。在实际开发中,你应该根据具体需求选择合适的继承方式,并遵循最佳实践来编写高质量的JavaScript代码。

记住,原型链是JavaScript的精髓所在,掌握它将帮助你更好地理解这门语言的设计理念和工作原理。

最后,创作不易请允许我插播一则自己开发的“数规规-排五助手”(有各种预测分析)小程序广告,感兴趣可以微信小程序体验放松放松,程序员也要有点娱乐生活,搞不好就中个排列五了呢?

感兴趣可以搜索微信小程序“数规规排五助手”体验体验!!

如果觉得本文有用,欢迎点个赞👍+收藏⭐+关注支持我吧!

Flutter 之魂 GetX🔥(二)全面解析路由管理

🪶序言

🧩什么是路由?

通俗地说:

路由(Route) 是页面跳转的规则和路径管理机制。

在任何应用中,你都会从一个页面跳到另一个页面,例如:

  • 登录页 → 首页
  • 首页 → 详情页
  • 设置页 → 关于页

系统需要知道:

“当我跳转到 /home 时,应该显示哪个页面?”
“当我从 /detail?id=10 返回时,应该回到哪个页面?”

这个“路径与页面对应关系”的管理系统,就是 路由系统(Routing System);它负责维护页面栈、管理页面入栈和出栈。

而如何使用和控制这个系统,让用户能够在应用中顺畅地跳转、传递参数、做权限检查,就是 路由管理(Route Management)

举例

术语 对应关系
路由系统 公交系统:提供车辆、线路和站点等基础设施
路由管理 调度员/司机:控制什么时候发车、走哪条路线、终点在哪

📖什么是路由栈?

路由栈(Route Stack)指的是 页面访问顺序形成的一种 栈式结构 ,类似数据结构中的栈(Stack:后进先出 LIFO)。

🔹 基本原理

  • 每次打开新页面,页面会 入栈(push)
  • 每次返回上一页,页面会 出栈(pop)
  • 栈顶的页面永远是当前显示的页面。

🔹 举例说明

假设应用有三个页面:首页详情页设置页。操作顺序如下:

  1. 打开首页 → 栈中:[首页]
  2. 首页跳转到详情页 → 栈中:[首页, 详情页]
  3. 详情页跳转到设置页 → 栈中:[首页, 详情页, 设置页]
  4. 设置页点击返回 → 栈中:[首页, 详情页],显示详情页

💡 栈顶的页面永远是当前显示的页面,返回操作就是出栈。

🔹 路由栈的作用

  • 管理页面顺序:确保用户按访问顺序返回。
  • 控制页面状态:栈中的页面保持状态,不会被销毁(除非手动移除)。
  • 方便跳转操作:可以实现“替换页面”或“清空栈跳转新页面”等复杂导航需求。

📚Flutter 中的路由机制

在 Flutter 中,每个 页面(Screen) 都被看作一个 Route(路由)对象

  • Flutter 默认使用 Navigator 来管理路由栈(Route Stack)。
  • 路由栈的概念类似浏览器的“前进”和“后退”按钮,Navigator 控制页面的入栈(push)和出栈(pop)。

🔹 常见操作

操作 方法 含义
打开页面 Navigator.push() 入栈一个新页面
返回页面 Navigator.pop() 出栈当前页面
替换页面 Navigator.pushReplacement() 用新页面替换当前页面
清空栈跳转 Navigator.pushNamedAndRemoveUntil() 清空历史页面栈后跳转新页面(如登录成功后)

🔹 示例:原生路由方式

Navigator.push(
  context,
  MaterialPageRoute(builder: (context) => DetailPage(id: 10)),
);

🔹 示例:使用命名路由

MaterialApp 中注册路由:

MaterialApp(
  initialRoute: '/',
  routes: {
    '/': (context) => HomePage(),
    '/detail': (context) => DetailPage(),
  },
);

跳转页面:

Navigator.pushNamed(context, '/detail');

⚠️ 注意:虽然原生路由功能强大,但在大型项目中使用 Navigator 管理命名路由和参数传递可能会比较繁琐,需要手动传 context 并处理返回值、参数和栈管理。

GetX - 路由管理(Route Management)

1. 基本概念

在 Flutter 中使用 GetX 可以大幅简化路由管理。相比原生 Navigator,具有如下特点:

  1. 无需 BuildContext:可全局访问路由,不依赖 widget 树。
  2. 简化栈操作:轻松控制页面入栈、替换或清空栈。
  3. 支持参数与返回值:传参和接收返回数据更直观。
  4. 中间件机制:可在路由跳转前后添加逻辑,如权限检查或登录拦截。
  5. 叠加路由统一管理:Snackbar、Dialog、BottomSheet 等覆盖页面属于叠加路由,它们不在 Navigator 栈中,而是通过 Overlay 层管理的临时 UI。

💡 小结
GetX 将原生 Navigator 的繁琐操作封装起来,使路由管理更加直观、高效,适合大型项目的页面跳转和全局控制需求。

2. 路由使用

💬 注意入口文件更改

class MyApp extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   // return MaterialApp( 替换
   return GetMaterialApp(
     title: 'GetX Demo',
     debugShowCheckedModeBanner: false,
     theme: ThemeData(primarySwatch: Colors.blue),
     home: HomePage(),    // 首页
   );
 }
}

①. 普通路由

普通路由指的是应用中最基础的页面跳转方式,只负责在页面之间切换,而 不携带任何参数 或仅依赖页面自身构造函数初始化。

(1)Get.to()
  • 作用:跳转到新页面,并将其入栈(Stack Push)。
  • 使用场景:常规页面跳转,如首页 → 详情页。
  • 原生对标Navigator.push()
Get.to(NextScreen());
(2)Get.off()
  • 作用:用新页面替换当前页面(Stack Replace Top)。
  • 使用场景:登录成功后替换登录页、完成表单后跳转结果页。
  • 原生对标Navigator.pushReplacement()
Get.off(NextScreen());
(3)Get.offAll()
  • 作用:清空整个页面栈,并跳转到新页面(Clear Stack + Push)。
  • 使用场景:退出登录后返回首页、重置应用导航。
  • 原生对标Navigator.pushAndRemoveUntil()
Get.offAll(NextScreen());
(4)Get.back()
  • 作用:返回上一页,将当前页面出栈(Stack Pop)。
  • 使用场景:用户点击返回按钮、完成操作后回退。
  • 原生对标Navigator.pop()
Get.back();
(5)await Get.to()
  • 作用:跳转到新页面,并在页面关闭时获取返回值。
  • 使用场景:用户在表单页、选择页、弹窗页操作完成后返回结果给上一个页面。
  • 原生对标var result = await Navigator.push(...)
// 跳转并等待返回值
var data = await Get.to(NextScreen());

// 在 NextScreen 中返回数据
Get.back(result: 'Hello World');

// 返回后 data = 'Hello World'

②. 命名路由

(1)概念

命名路由 是为每个页面指定一个 唯一的字符串标识(路由名),通过这个名字来进行页面跳转,而不是直接使用 页面类或 Widget

(2)命名路由实现

image.png

1.集中定义路由名

class Routes {
 static const splash = '/splash';
 static const login = '/login';
 static const bottomNav = '/bottomNav';
 static const train = '/train';
}

2.在 GetPage 中注册路由

final pages = [
 GetPage(name: Routes.splash, page: () => const SplashPage(), binding: SplashBinding()),
 GetPage(name: Routes.login, page: () => const LoginPage(), binding: LoginBinding()),
 GetPage(name: Routes.bottomNav, page: () => BottomNavView(), binding: BottomNavBinding()),
 GetPage(name: Routes.train, page: () => const TrainPage(), binding: TrainBinding()),
];

3.通过路由名跳转

// 跳转到登录页
Get.toNamed(Routes.login);

// 替换当前页
Get.offNamed(Routes.bottomNav);

// 清空栈并跳转
Get.offAllNamed(Routes.train);

// 返回上一页
Get.back();
(3)特点与优势
特点 说明
集中管理 所有路由名统一在一个类里,维护方便
无需直接引用 Widget 跳转只需要路由名,减少模块耦合
支持全局跳转 不需要 BuildContext,可在任意位置调用
方便传参与中间件 可在跳转时传递参数,也可在路由注册时加中间件
(4)对标原生 Navigator
GetX 命名路由 原生 Navigator
Get.toNamed('/login') Navigator.pushNamed(context, '/login')
无需 context 需要 BuildContext
支持中间件、绑定 需要手动封装逻辑
支持全局访问 受 widget 树约束

③. 传参路由

传参路由指在应用中从一个页面跳转到另一个页面时,同时携带数据(参数)的路由方式。

(1)Get.arguments 传参
  • 使用方法:通过 Get.arguments 传递任意对象或 Map。
  • 示例
// 跳转并传参
Get.to(DetailPage(), arguments: {'id': 10, 'name': 'Flutter'});

// 目标页面获取参数
class DetailPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final args = Get.arguments; // {'id': 10, 'name': 'Flutter'}
    return Scaffold(
      body: Center(
        child: Text('ID: ${args['id']}, Name: ${args['name']}'),
      ),
    );
  }
}
  • 使用场景:跨模块传参、临时数据、无需修改页面构造函数。
  • 原生对标
// 跳转并传参
Navigator.pushNamed(context, '/page', arguments: data);

// 目标页面获取参数
ModalRoute.of(context)!.settings.arguments;
(2)Get.parameters 传参
  • 使用方法:通过命名路由的 URL 查询参数形式传递,参数会作为字符串存储。
  • 示例
// 跳转并传递参数
Get.toNamed('/detail?id=10&name=Flutter');

// 目标页面获取参数
class DetailPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final params = Get.parameters; // {'id': '10', 'name': 'Flutter'}
    return Scaffold(
      body: Center(
        child: Text('ID: ${params['id']}, Name: ${params['name']}'),
      ),
    );
  }
}
  • 使用场景:跨模块、全局跳转、只需简单标识或筛选条件的场景。
  • 原生对标
// 跳转并传递参数
Navigator.pushNamed(context, '/detail?id=10&name=Flutter');

// 原生 Flutter 需要自己解析 URL 才能拿到参数
final uri = Uri.parse(ModalRoute.of(context)!.settings.name!);
final params = uri.queryParameters; // {'id': '10', 'name': 'Flutter'}

⚠️ 注意:Get.parameters 中的值都是 String 类型,如需其他类型需手动转换。


④. 中间件

(1)概念

中间件(Middleware) 在路由管理中指的是在页面跳转前或跳转后执行的一段 逻辑,它可以对路由行为 进行拦截、处理或增强,而不需要修改页面本身。

(2)主要作用
  1. 权限控制:如用户未登录时自动跳转到登录页。
  2. 路由拦截:根据条件阻止或修改跳转目标。
  3. 统一处理:日志记录、统计、参数检查、页面初始化等。
(3)使用中间件

1.创建中间件类

class AuthMiddleware extends GetMiddleware {
 @override
 RouteSettings? redirect(String? route) {
   return null; 
 }
}

2.在路由中绑定中间件

GetPage(
 name: '/profile',
 page: () => ProfilePage(),
 middlewares: [AuthMiddleware()],
),

3.执行流程

  1. 用户尝试访问 /profile
  2. 中间件的 redirect 会先被执行
  3. 根据条件决定后续逻辑

3. 叠加路由

叠加路由(Overlay Route) 指在当前页面之上显示新的页面或 UI 层,而不替换或关闭底层页面。其主要特点:

  • 底层页面保留状态:原页面仍可操作或返回。
  • 适用于临时 UI:如弹窗、对话框、底部弹层、全屏浮层等。
  • 支持多层叠加:可在已有浮层上继续叠加新的页面或组件。

① Snackbar

🔹 GetX VS 原生

// GetX
Get.snackbar(
  '标题',
  '内容',
  snackPosition: SnackPosition.BOTTOM,
  duration: Duration(seconds: 2),
);

// Flutter 原生
ScaffoldMessenger.of(context).showSnackBar(
  SnackBar(
    content: Text('内容'),
    duration: Duration(seconds: 2),
  ),
);

② Dialog

🔹 GetX VS 原生

// GetX
Get.dialog(
  AlertDialog(
    title: Text('提示'),
    content: Text('这是一个对话框'),
    actions: [
      TextButton(
        onPressed: () => Get.back(),
        child: Text('确定'),
      ),
    ],
  ),
);

// Flutter 原生
showDialog(
  context: context,
  builder: (_) => AlertDialog(
    title: Text('提示'),
    content: Text('这是一个对话框'),
    actions: [
      TextButton(
        onPressed: () => Navigator.pop(context),
        child: Text('确定'),
      ),
    ],
  ),
);

③. BottomSheet

🔹 GetX VS 原生

// GetX
Get.bottomSheet(Container(...));

// Flutter 原生 
showModalBottomSheet(
  context: context,
  builder: (_) => Container(...),
);

💡总结

维度 GetX Flutter 原生 优势
Context依赖 不依赖 依赖 逻辑层可直接调用,无需 context
代码简洁 一行即可 多行 builder + context 开发效率高
全局调用 可在控制器/服务调用 通常受 Widget 树限制 全局消息、提示更方便
自定义 位置、动画、按钮、颜色等 需要手动封装 灵活度高
路由整合 叠加路由体系 独立实现 生命周期管理方便

④. Overlay Route 原理

Overlay Route(叠加路由) 的核心原理是在现有页面上插入一个 新的渲染层(OverlayEntry) ,而不销毁或替换底层页面。

核心机制:

  1. Overlay Widget
    • Flutter 提供了 Overlay Widget,它本质上是一个 可堆叠的层级容器
    • 所有的叠加 UI(如弹窗、Tooltip、Snackbar、BottomSheet)都通过 OverlayEntry 插入到 Overlay 中。
  2. OverlayEntry
    • 每个叠加路由都会生成一个 OverlayEntry
    • OverlayEntry 是一个可插入 Overlay 树的独立 Widget,可以控制显示和移除。
  3. 渲染流程
    • 当叠加路由触发时:
      1. 系统创建对应的 OverlayEntry
      2. 插入到 Overlay 层级(通常在最上层)。
      3. 显示内容(Dialog、Snackbar 等)。
    • 移除时,OverlayEntry.remove() 将其从 Overlay 树中移除,不影响底层页面。
  4. 特点
    • 不影响底层页面状态:底层 Widget 树保持完整。
    • 支持多层叠加:可以在已有 OverlayEntry 上继续插入新的叠加层。
    • 灵活控制显示与消失:可通过动画、定时、手势等控制 OverlayEntry 的生命周期。

4. 高级应用

①. 路由守卫

1️⃣ 创建守卫类
import 'package:get/get.dart';

class AuthGuard extends GetMiddleware {
  // 权重,数字越大越先执行
  @override
  int? priority = 0;

  // 页面跳转前拦截
  @override
  RouteSettings? redirect(String? route) {
    bool isLoggedIn = false; // 模拟登录状态,可换成实际逻辑

    if (!isLoggedIn) {
      // 如果未登录,跳转到登录页
      return RouteSettings(name: '/login');
    }
    // 已登录,允许访问目标页面
    return null;
  }
}
2️⃣ 在 GetPage 中使用守卫
GetPage(
  name: '/home',
  page: () => HomePage(),
  middlewares: [AuthGuard()], // 添加守卫
),
GetPage(
  name: '/login',
  page: () => LoginPage(),
),
3️⃣ 页面跳转
// 用户尝试访问首页
Get.toNamed('/home'); // 如果未登录,会被 AuthGuard 重定向到 /login

说明

  • GetMiddleware 可以在路由跳转前/后做拦截。
  • redirect 方法用于在跳转前决定是否重定向到其他路由。
  • priority 控制多个守卫执行顺序,数字越大优先级越高。

②. 自定义动画

在页面跳转时,你可以自定义过渡动画,使路由切换更加流畅且符合应用风格。

1️⃣ 使用内置动画
Get.to(
  DetailPage(),
  transition: Transition.rightToLeft, // 内置动画类型
  transitionDuration: Duration(milliseconds: 500), // 动画时长
  curve: Curves.easeInOut, // 动画曲线
);

常用内置动画类型:

动画类型 描述
fade 渐隐渐现
fadeIn 页面淡入
rightToLeft 从右向左滑入
leftToRight 从左向右滑入
upToDown 从上向下滑入
downToUp 从下向上滑入
rightToLeftWithFade 右滑同时渐隐渐现
leftToRightWithFade 左滑同时渐隐渐现
zoom 缩放动画
zoomRotate 缩放+旋转
size 从零大小展开
circularReveal 圆形揭示动画(类似水波纹展开)
topLevel 顶层覆盖动画(全屏显示)
noTransition 无过渡动画
cupertino Cupertino 风格 iOS 页面切换动画
cupertinoDialog Cupertino 风格对话框动画
native 使用原生平台默认动画

展示:

屏幕截图 2025-10-14 220109.png

c7c7199c-e76f-49bc-adf1-19a945bbc1f8.gif

2️⃣ 自定义动画 (CustomTransition)

如果内置动画无法满足需求,可以自定义动画,通过 CustomTransition 完全控制动画效果。

Get.to(
  DetailPage(),
  customTransition: MyCustomTransition(),
  transitionDuration: Duration(milliseconds: 600),
);

自定义动画示例:

class MyCustomTransition extends CustomTransition {
  @override
  Widget buildTransition(BuildContext context, 
      Animation<double> animation, 
      Animation<double> secondaryAnimation, 
      Widget child) {
    // 这里使用 Fade + Slide 混合动画
    return SlideTransition(
      position: Tween<Offset>(
        begin: Offset(1, 0), // 从右边进入
        end: Offset.zero,
      ).animate(animation),
      child: FadeTransition(
        opacity: animation,
        child: child,
      ),
    );
  }
}

展示:

image.pnga1adf161-48aa-4b1a-aa47-da9040a46389.gif

说明:

  • animation:主动画曲线,可控制方向、透明度、缩放等。
  • secondaryAnimation:二级动画,可在返回时使用。
  • child:目标页面组件。

通过自定义 CustomTransition,你可以实现几乎任何动画效果,包括旋转、缩放、组合动画等。

一键搞定UniApp WiFi连接!这个Vue 3 Hook让你少走弯路

在移动应用开发中,设备连接功能是常见的需求场景。无论是智能家居配网、设备绑定,还是网络配置,WiFi功能都扮演着重要角色。本文将分享一个在UniApp中实现WiFi连接的全过程。

技术背景与挑战

在开发WiFi相关功能时,我们面临的主要挑战包括:

  • 平台差异:Android和iOS在WiFi权限和API支持上存在显著差异
  • 权限管理:微信小程序规范要求获取位置权限才能使用WiFi功能
  • 用户体验:需要处理各种异常情况,提供清晰的用户引导

核心实现代码解析

1. 模块初始化与版本检测

export function useWifi() {
  const toast = inject("toast")
  
  let info = uni.getDeviceInfo()
  const os = info.osName
  const osVersion = info.osVersion
  
  // 初始化wifi模块
  const startWifi = (getList = 1) => {
    // 版本兼容性检查
    if (os == 'android' && osVersion < 6) {
      toast('手机版本不支持')
      return;
    }
    if (os == 'ios' && osVersion < 11.2) {
      toast('手机版本不支持')
      return;
    }
    
    uni.showLoading({
      title: '初始化WIFI模块',
      mask: true
    })
    
    uni.startWifi({
      success(res) {
        console.log('wifi 初始化成功');
        if(getList == 1) {
          getUserAuthorize();
        }
      },
      fail(err) {
        // 错误处理逻辑
      }
    })
  }
}

关键点说明:

  • 通过ososVersion进行平台版本检测
  • 使用uni.showLoading提供操作反馈
  • 初始化成功后自动触发权限获取流程

2. 位置权限获取

const getUserAuthorize = async () => {
  uni.showLoading({
    title: '获取定位权限...',
    mask: true
  })
  
  // authorize内使用uni.getSetting和uni.authorize获取授权状态
  authorize('scope.userLocation')
  .then(res => {
    console.log("用户已提供权限")
    getWifiList();
  })
  .catch(err => {
    // 权限被拒绝时的引导流程
    uni.showModal({
      content: '请授权获取位置信息',
      showCancel: false,
      success: function(res) {
        uni.openSetting({
          success: (res) => {
            if (!res.authSetting['scope.userLocation']) {
              // 未授权时的提示
            } else {
              // 授权完成后的处理
              getWifiList();
            }
          }
        });
      }
    })
  })
}

权限获取策略:

  • 先检查当前权限状态
  • 无权限时先尝试获取授权
  • 用户拒绝后引导跳转设置页面

3. WiFi列表获取与平台差异处理

const wifiList = ref([])
const getWifiList = () => {
  uni.showLoading({
    title: '获取当前WiFi列表...',
    mask: true
  })
  
  uni.getWifiList({
    success: (res) => {
      uni.hideLoading();
      // iOS特殊处理
      if (os == 'ios') {
        setTimeout(() => {
          uni.showModal({
            content: 'IOS手机无法主动获取WiFi列表,请先进入系统的【设置-WiFi】页面...',
            showCancel: false,
          })
        }, 2000)
      }
    },
    fail: (err) => {
      // 失败处理逻辑
    },
  })
  
  // 监听WiFi列表返回
  uni.onGetWifiList((res) => {
    wifiList.value = res?.wifiList?.filter(item => item.SSID)
      ?.sort((a,b) => b.signalStrength - a.signalStrength) || []
  })
}

平台兼容性处理要点:

  • Android可直接获取WiFi列表
  • iOS需要用户手动在系统设置中选择WiFi
  • 使用信号强度排序提供更好的用户体验

4. WiFi连接实现

const connectWifi = (wifiName, success, fail) => {
  uni.connectWifi({
    SSID: wifiName,
    password: '', // 开放网络
    forceNewApi: true,
    success(res) {
      console.log('手动连接完毕', res)
      if(isFunction(success)) {
        success()
      }
    },
    fail(err) {
      uni.hideLoading()
      uni.showToast({
        title: 'wifi连接异常,请尝试手动连接',
        icon: 'error'
      })
      if(isFunction(fail)) {
        fail()
      }
    }
  })
}

实际应用中的坑与解决方案

1. iOS平台限制

问题:iOS系统出于隐私考虑,不允许应用直接获取WiFi列表。

解决方案

// 提供明确的用户引导
uni.showModal({
  content: 'IOS手机无法主动获取WiFi列表,请先进入系统的【设置-WiFi】页面,待加载完毕WiFi列表后,返回小程序完成后续配置',
  showCancel: false,
})

2. 权限获取流程优化

问题:用户可能在权限弹窗中选择拒绝。

解决方案

// 提供二次引导机会
uni.showModal({
  content: '请授权获取位置信息',
  showCancel: false,
  success: function(res) {
    uni.openSetting({
      success: (res) => {
        if (res.authSetting['scope.userLocation']) {
          getWifiList(); // 用户授权后继续流程
        }
      }
    });
  }
})

完整的使用示例

<template>
  <view class="container">
    <button @click="startWifi">搜索WiFi</button>
    <view v-for="wifi in wifiList" :key="wifi.SSID" 
          @click="connectWifi(wifi.SSID)">
      {{ wifi.SSID }} - 信号强度: {{ wifi.signalStrength }}
    </view>
  </view>
</template>

<script setup>
import { useWifi } from '@/composables/useWifi'

const { wifiList, startWifi, connectWifi } = useWifi()
</script>

总结与最佳实践

通过本文我们总结了以下最佳实践:

  1. 版本检测前置:在功能调用前进行充分的平台和版本检测
  2. 权限引导友好:为用户提供清晰的权限获取引导流程
  3. 平台差异处理:针对不同平台的限制提供相应的解决方案
  4. 错误处理完善:覆盖所有可能的异常情况,提供有用的错误信息
  5. 用户体验优先:通过加载状态和明确提示提升用户体验

WiFi连接功能虽然看似简单,但在实际开发中需要考虑众多细节和平台差异。希望本文的经验分享能够帮助你在UniApp项目中顺利实现WiFi相关功能。

Vite 构建工具

Vite 是一款由 Vue 作者尤雨溪开发的现代化前端构建工具,以极速的开发体验优化的构建输出为核心特点,自 2021 年正式发布以来,迅速成为前端工程化领域的热门选择。它主要解决了传统打包工具(如 Webpack)在开发环境下的性能瓶颈,同时兼顾生产环境的构建优化

一、Vite 的核心优势

  1. 开发环境极速启动传统工具(如 Webpack)在开发时需要先将所有模块打包成 bundle 才能启动开发服务器,项目越大,启动越慢(常达数十秒甚至分钟级)。Vite 则利用浏览器原生 ES 模块(ESM) ,在开发环境下无需打包:

    • 服务器启动时仅解析入口文件,按需编译依赖的模块(当浏览器请求某个模块时才实时处理)。
    • 配合 esbuild(Go 语言编写的超快 JavaScript 打包器)预构建第三方依赖,进一步提升启动速度。结果:大型项目的开发服务器启动时间从分钟级缩短到秒级,甚至毫秒级。
  2. 热模块替换(HMR)性能优异传统工具的 HMR 需遍历整个依赖树更新模块,大型项目中可能存在延迟。Vite 的 HMR 基于原生 ESM,直接精确更新修改的模块,无需重新打包整个依赖链,响应速度极快(通常 < 100ms)。

  3. 生产环境优化构建开发环境用 ESM 提升速度,生产环境则使用 Rollup 打包(比 Webpack 更轻量,输出代码更简洁),自动优化:

    • 代码分割(按路由、组件拆分 chunk)。
    • Tree-shaking(移除未使用代码)。
    • 压缩混淆(JS/CSS/HTML)。
    • 生成预加载指令(<link rel="modulepreload">)优化加载顺序。
  4. 开箱即用的功能支持无需复杂配置,默认支持:

    • TypeScript、JSX、CSS 预处理器(Sass/Scss/Less)、CSS Modules。
    • 静态资源处理(图片、字体等)。
    • 环境变量注入(.env 文件)。
    • 代理服务器(解决跨域问题)。

二、Vite 的核心原理

1. 开发环境:基于 ESM 的无打包开发
  • 浏览器原生支持 ESM:现代浏览器可直接通过 <script type="module"> 加载 ES 模块,Vite 利用这一特性,将项目文件作为原生 ESM 提供给浏览器
  • 依赖预构建:第三方依赖(如 node_modules 中的 reactlodash)通常不是 ESM 格式(可能是 CommonJS 或 UMD),且可能包含大量嵌套依赖Vite 启动时会用 esbuild 将这些依赖预构建为 ESM 格式的单文件(减少请求次数),并缓存到 node_modules/.vite 目录。
  • 按需编译:浏览器请求某个模块(如 ./src/App.vue)时,Vite 服务器实时编译该模块(如解析 Vue SFC、TypeScript 转译),并返回处理后的 ESM 代码。
2. 生产环境:基于 Rollup 的优化打包
  • 开发环境的 ESM 方式不适合生产(浏览器兼容性、请求数量过多),因此 Vite 生产构建使用 Rollup 打包:

    • 将代码合并为少数几个 chunk,减少网络请求。
    • 应用 Tree-shaking 移除死代码(依赖 ES 模块的静态结构)。
    • 对 CSS 单独提取、压缩,并生成 sourcemap。

三、基本使用流程

1. 创建项目
# 使用 npm
npm create vite@latest my-vite-app
# 使用 yarn
yarn create vite my-vite-app
# 使用 pnpm
pnpm create vite my-vite-app

创建时可选择框架(Vue、React、Svelte 等)或纯 JavaScript/TypeScript。

2. 开发环境启动
cd my-vite-app
npm install
npm run dev # 启动开发服务器,默认地址 http://localhost:5173

此时修改代码,浏览器会实时更新,无需等待打包。

3. 生产环境构建
npm run build # 生成优化后的生产包,输出到 dist 目录
4. 预览生产包
npm run preview # 启动本地服务器预览 dist 目录内容

四、核心配置(vite.config.js)

Vite 的配置文件为项目根目录的 vite.config.js(或 .ts),常用配置项如下:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' // Vue 插件(需单独安装)
import path from 'path'

export default defineConfig({
  // 项目根目录(默认 process.cwd())
  root: process.cwd(),
  // 开发服务器配置
  server: {
    port: 3000, // 端口
    open: true, // 自动打开浏览器
    proxy: { // 代理配置(解决跨域)
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^/api/, '')
      }
    }
  },
  // 构建配置
  build: {
    outDir: 'dist', // 输出目录
    assetsDir: 'assets', // 静态资源目录
    sourcemap: true, // 是否生成 sourcemap
    rollupOptions: { // 传递给 Rollup 的配置
      // 自定义代码分割
      output: {
        manualChunks: {
          vendor: ['vue', 'vue-router'], // 将 vue 相关依赖打包到 vendor chunk
          utils: ['lodash']
        }
      }
    }
  },
  // 解析配置
  resolve: {
    alias: { // 路径别名
      '@': path.resolve(__dirname, 'src')
    }
  },
  // 插件
  plugins: [vue()]
})

五、与 Webpack 的核心差异

特性 Vite Webpack
开发环境原理 基于浏览器 ESM,无打包,按需编译 预打包所有模块为 bundle
启动速度 极快(秒级,依赖 esbuild 预构建) 较慢(随项目规模增长显著变慢)
HMR 性能 极速(精确更新单个模块) 较慢(需更新依赖链)
生产构建工具 Rollup(输出更简洁) 内置打包器
配置复杂度 极简(默认支持多数功能) 较复杂(需配置 loader、plugin 实现功能)
生态成熟度 较新,但主流框架(Vue/React)已深度支持 非常成熟,插件生态丰富
适用场景 中小型项目、现代框架项目(开发体验优先) 大型复杂项目、需要高度定制化构建流程

六、适用场景与局限性

适用场景:
  • 现代前端框架项目(Vue、React、Svelte 等)。
  • 追求开发效率的中小型项目。
  • 以 ES 模块为基础的项目(避免大量 CommonJS 依赖)。
局限性:
  • 开发环境依赖浏览器对 ESM 的支持(不支持 IE 等旧浏览器,但生产构建可兼容)。
  • 对 CommonJS 依赖的处理需通过 esbuild 预构建,若依赖过于复杂(如动态 require),可能出现兼容性问题。
  • 生态插件数量不如 Webpack 丰富(但核心功能已覆盖大部分需求)。

总结

Vite 凭借基于 ESM 的无打包开发Rollup 优化构建,重新定义了前端开发体验,尤其适合追求快速迭代的现代前端项目。它的设计理念是 “扬长避短”:开发环境用原生 ESM 提升速度,生产环境用成熟工具保证输出质量,是对传统打包工具的一次重要革新。

Vite 作为现代前端前端构建工具,凭借其极速的开发体验和现代的设计理念,已成为面试中的高频考点。以下从基础概念常考面试题两方面整理核心内容:

常考面试题

一、Vite 基础核心概念

1. 什么是 Vite?

Vite 是由 Vue 作者尤雨溪开发的现代化前端构建工具,核心特点是开发环境基于浏览器原生 ES 模块(ESM)实现无打包开发,生产环境使用 Rollup 进行优化打包,旨在解决传统打包工具(如 Webpack)在开发阶段的性能瓶颈(启动慢、热更新延迟)。

2. Vite 核心优势
  • 极速开发启动:开发环境无需打包,利用浏览器 ESM 直接加载模块,启动速度毫秒级。
  • 极速热更新(HMR) :基于原生 ESM 精确更新修改的模块,避免全量重新打包,响应速度 < 100ms。
  • 按需编译:仅在浏览器请求模块时才实时编译,而非预打包所有模块。
  • 生产环境优化:使用 Rollup 打包,输出代码更精简(自动 Tree-shaking、代码分割等)。
  • 开箱即用:默认支持 TypeScript、JSX、CSS 预处理器、CSS Modules 等,无需复杂配置。
3. Vite 工作原理
  • 开发环境(基于 ESM)

    1. 浏览器通过 <script type="module"> 加载入口文件(如 main.js),Vite 启动开发服务器拦截请求。
    2. 对第三方依赖(如 node_modules 中的库),使用 esbuild 预构建为 ESM 格式(避免嵌套依赖导致的请求爆炸),并缓存到 node_modules/.vite
    3. 对项目源码(如 .vue.ts),服务器实时编译(如解析 Vue SFC、转译 TypeScript),返回处理后的 ESM 代码。
  • 生产环境(基于 Rollup) :开发环境的 ESM 不适合生产(浏览器兼容性、请求过多),因此用 Rollup 打包为优化后的 bundle,支持代码分割、压缩、Tree-shaking 等。

4. 核心配置文件(vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  root: './src', // 项目根目录
  base: '/', // 部署基础路径
  server: {
    port: 3000, // 开发服务器端口
    open: true, // 自动打开浏览器
    proxy: { '/api': { target: 'http://localhost:8080' } } // 跨域代理
  },
  build: {
    outDir: '../dist', // 输出目录
    assetsDir: 'assets', // 静态资源目录
    sourcemap: true // 生成 sourcemap
  },
  resolve: {
    alias: { '@': '/src' } // 路径别名
  },
  plugins: [vue()] // 插件(如 Vue 支持)
})

二、常考面试题及解析

1. Vite 为什么比 Webpack 开发启动快?
  • 核心差异:开发环境的工作模式不同。

    • Webpack 是 “预打包”:启动时需递归解析所有依赖,打包成 bundle 后才能启动服务器,项目越大启动越慢。

    • Vite 是 “无打包 + 按需编译”:

      1. 利用浏览器原生 ESM,直接加载模块,无需预打包。
      2. 第三方依赖用 esbuild(Go 语言编写,速度比 JS 工具快 10-100 倍)预构建为单文件,减少请求次数。
      3. 仅在浏览器请求模块时才实时编译,避免无效工作。
2. Vite 的热更新(HMR)原理是什么?与 Webpack 的 HMR 有何区别?
  • Vite 的 HMR 原理:基于原生 ESM 的模块依赖关系,当文件修改时:

    1. 服务器精确找到修改的模块,重新编译并推送更新事件(通过 WebSocket)。
    2. 浏览器接收事件后,仅更新该模块及其直接依赖,无需重新加载整个应用。
  • 与 Webpack HMR 的区别

    • Webpack 需维护模块依赖图,更新时可能需要遍历整个依赖链,大型项目延迟较高。
    • Vite 依赖浏览器 ESM 的原生模块系统,更新更精准、速度更快(无依赖链遍历开销)。
3. Vite 开发环境和生产环境的构建工具为什么不同?
  • 开发环境用 ESM + esbuild:追求 “快”,利用浏览器 ESM 实现无打包开发,esbuild 预构建依赖(速度优先)。但 ESM 存在浏览器兼容性问题(如不支持 IE),且大量模块会导致请求过多,不适合生产。
  • 生产环境用 Rollup:追求 “优”,Rollup 对 ES 模块的 Tree-shaking 更彻底,输出代码更精简,支持代码分割和压缩,且能生成兼容旧浏览器的代码(通过 Babel 转译)。
4. Vite 如何处理 CommonJS 模块?

Vite 开发环境基于 ESM,而第三方依赖可能是 CommonJS 格式(如 lodash)。处理方式:

  1. 启动时,esbuild 将 CommonJS 模块预构建为 ESM 格式(转换 require 为 import),并合并嵌套依赖为单文件(减少请求)。
  2. 预构建结果缓存到 node_modules/.vite,依赖不变时直接复用,避免重复处理。
5. Vite 与 Webpack 的核心区别?
维度 Vite Webpack
开发原理 浏览器 ESM + 按需编译 预打包为 bundle
启动速度 极快(毫秒级) 较慢(随项目规模增长变慢)
HMR 性能 精准更新,速度极快 依赖链更新,大型项目较慢
生产构建工具 Rollup(输出更精简) 内置打包器
配置复杂度 极简(默认支持多数功能) 较复杂(需配置 loader/plugin)
适用场景 中小型项目、现代框架项目 大型复杂项目、高度定制化需求
6. Vite 如何实现对 Vue/React 等框架的支持?

通过插件系统

  • Vue:@vitejs/plugin-vue 解析 .vue 单文件组件(模板、脚本、样式分离处理)。
  • React:@vitejs/plugin-react 处理 JSX 语法,集成 Fast Refresh 实现 HMR。插件本质是在 Vite 的构建流程中插入钩子,对特定文件类型进行编译处理。
7. Vite 的 esbuild 预构建解决了什么问题?
  • 问题 1:第三方依赖可能是 CommonJS 格式,浏览器无法直接通过 ESM 加载。
  • 问题 2:嵌套依赖会导致 “请求爆炸”(如一个库依赖 10 个子模块,需发送 10 次请求)。
  • 解决方案esbuild 将第三方依赖预构建为 ESM 格式的单文件,合并嵌套依赖,减少请求次数,同时兼容浏览器 ESM。
8. Vite 如何处理环境变量?
  • 通过 .env 文件定义环境变量,格式为 VITE_XXX=value(仅前缀为 VITE_ 的变量会被注入)。
  • 开发环境中,变量通过 import.meta.env 访问(如 import.meta.env.VITE_API_URL)。
  • 不同环境可使用 .env.development(开发)、.env.production(生产)、.env.test(测试)文件区分配置。

总结

Vite 的核心考点集中在开发原理(ESM 与预构建)性能优势(对比 Webpack)HMR 机制环境处理等方面。理解其 “开发环境无打包” 和 “生产环境 Rollup 优化” 的设计理念,是掌握 Vite 的关键。

❌