普通视图

发现新文章,点击刷新页面。
今天 — 2025年10月15日掘金 前端

她问我::is-logged 是啥?我说:前面加冒号,就是 Vue 在发暗号

作者 洛小豆
2025年10月15日 13:04

深夜代码系列 · 第4期

关注我,和小豆一起在掘金看小说

🔥 开篇引爆

周五下午,我刚想摸鱼打开掘金,水篇小说,她突然走过来,一脸困惑地指着我屏幕上的代码。

“豆子,你看看这个,冒号和 @ 都是啥意思?我知道它们是 Vue 的语法糖,但具体怎么理解?我 Vue2 写到吐,Vue3 一升级全不会了!”

我一看,正是我们项目里最常见的 Header 组件调用:

<Header
  :is-logged-in="isLoggedIn"
  :username="username"
  @logout="handleLogout"
  @login-success="handleLoginSuccess"
/>

我放下鼠标,给她倒了杯水,笑眯眯地说:“这三个符号,就像是父子组件之间的三条秘密通道,它们分别负责传递数据接收信号。”


🎯 初步分析:父子组件通信的“传声筒”原理

父组件需要向子组件传递数据(如登录状态),子组件需要向父组件发送事件(如用户点击登出),实现双向通信。

核心概念:

  1. props(父 → 子):父组件通过属性向子组件传递数据。
  2. emit(子 → 父):子组件通过事件向父组件发送消息。

:is-logged-in:它负责“传递数据

我指着代码中的冒号,开始解释:

“你看这个 :,它是 v-bind 的简写。你可以把它想象成一个单向快递。”

<!-- 动态绑定 prop -->
:is-logged-in="isLoggedIn"  // 等价于 v-bind:is-logged-in="isLoggedIn"

“父组件(我们现在所在的这个页面)是快递公司,isLoggedIn 是一个包裹,里面装着‘用户是否登录’这个信息。我们用 :is-logged-in 这个‘快递单’,把这个包裹寄给了子组件 Header。”

“所以,当父组件里的 isLoggedIn 变量从 false 变成 true 时,这个包裹里的内容也会自动更新,子组件就会立刻收到最新的状态。”

小汐若有所思地点点头:“我懂了,这个冒号就是把父组件的数据动态地‘喂’给子组件,对吧?”

“没错,”我打了个响指,“这就是 props 传值 的过程。父组件通过 props 把数据传递给子组件,让子组件知道‘现在是什么情况’。”

Prop 命名规范

  • 父组件模板中使用 kebab-case:is-logged-in
  • 子组件中使用 camelCaseisLoggedin

类型安全

defineProps({
  isLoggedin: Boolean,
  username: {
    type: String,
    required: true,
    default: '游客'
  }
})

@logout@login-success:它们负责“接收信号

我继续指着 @ 符号,解释道:

“如果说冒号是快递,那么 @ 就是一个对讲机。”

<!-- 监听自定义事件 -->
@logout="handleLogout"       // 等价于 v-on:logout="handleLogout"

“当用户在 Header 组件里点击了‘登出’按钮,子组件会对着对讲机喊一声:‘logout’!而父组件这边一直开着对讲机,听到这个信号后,就会立即调用 handleLogout 方法,把 isLoggedIn 设为 false,清空 username。”

@login-success 也是同理,当子组件完成登录操作后,它会对着对讲机喊:‘login-success’,甚至还会顺便把用户信息作为‘暗号’一起发送过来。父组件接收到信号和暗号后,就能调用 handleLoginSuccess 方法来更新用户信息了。”

小汐听完,露出了恍然大悟的表情:“所以,@ 就是 v-on 的简写,用来监听子组件发出的自定义事件。这就像是子组件在告诉父组件:‘我干完活了,你来处理一下吧!’”

事件命名规范

  • 使用 kebab-case@login-success
  • 事件名要有动词:login-successupdate-userdelete-item

事件声明

defineEmits(['logout', 'login-success'])
// 或带验证
defineEmits({
  logout: null,
  'login-success': (user) => {
    return user && typeof user.name === 'string'
  }
})

三兄弟身份档案(必背)

符号 长写 身份 方向 场景
: v-bind: 动态绑定 父 → 子(prop) 变量塞给子组件
@ v-on: 事件监听 子 → 父(emit) 子组件喊"爸,有人点我!"
. 修饰符 语法糖plus —— 如 @click.stop

记住口诀:

"有冒号传变量,无冒号传字面量;有 @ 等孩子喊妈。 "


示例代码

父组件 (App.vue):状态的“总指挥官”

<template>
  <div>
    <Header
      :is-logged-in="isLoggedIn"
      :username="username"
      @logout="handleLogout"
      @login-success="handleLoginSuccess"
    />
    <p>当前登录状态: {{ isLoggedIn ? '已登录' : '未登录' }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import Header from './Header.vue';

// 定义父组件的状态
const isLoggedIn = ref(false);
const username = ref('');

// 定义处理子组件发出的事件的方法
const handleLogout = () => {
  isLoggedIn.value = false;
  username.value = '';
  console.log('✅ 父组件收到登出信号,状态已更新!');
};

const handleLoginSuccess = (user) => {
  isLoggedIn.value = true;
  username.value = user.name;
  console.log(`✅ 父组件收到登录成功信号,用户:${user.name}!`);
};
</script>

子组件 (Header.vue):事件的“执行者”

<template>
  <header>
    <div v-if="isLoggedIn">
      <span>欢迎,{{ username }}</span>
      <button @click="logout">登出</button>
    </div>
    <div v-else>
      <button @click="login">登录</button>
    </div>
  </header>
</template>

<script setup>
import { defineProps, defineEmits } from 'vue';

// 接收父组件传递的props
defineProps({
  isLoggedIn: Boolean,
  username: String,
});

// 声明子组件将要发出的事件
const emit = defineEmits(['logout', 'login-success']);

// 触发登出事件
const logout = () => {
  console.log('➡️ 子组件发出登出信号...');
  emit('logout');
};

// 触发登录成功事件,并传递参数
const login = () => {
  const user = { name: '小明' };
  console.log('➡️ 子组件发出登录成功信号,并附带用户信息...');
  emit('login-success', user);
};
</script>

流程图

image.png


⚠️ 常见坑点:

  • 坑1:在子组件中直接修改 prop

    // ❌ 错误做法
    props.isLoggedin = false // 会报警告
    
    // ✅ 正确做法
    emit('update:isLoggedin', false) // 或使用 v-model
    
  • 坑2:忘记声明 emits

    // ❌ 未声明的事件在 strict 模式下会报警告
    emit('logout')
    
    // ✅ 正确做法
    const emit = defineEmits(['logout'])
    
  • 坑3:事件名大小写错误

    <!-- ❌ 模板中不能用 camelCase -->
    @loginSuccess="handleLoginSuccess"
    
    <!-- ✅ 必须用 kebab-case -->
    @login-success="handleLoginSuccess"
    
  • 坑4:静态字符串导致布尔值失效

    <!-- ❌ 恒为真,变量失效 -->
    is-logged-in="true"
    
    <!-- ✅ 使用绑定,让 Vue 知道这是 JS 表达式 -->
    :is-logged-in="true"
    
  • 坑5:emit 名称与声明大小写不一致

    // ❌ 与声明不符,控制台警告
    emit('loginSuccess')
    
    // ✅ 与模板保持一致
    emit('login-success')
    
  • 坑6:prop 类型对不上,dev 爆红

    // ❌ 类型对不上,dev 直接爆红
    defineProps({ isLoggedIn: String })
    
    // ✅ 类型保持一致
    defineProps({ isLoggedIn: Boolean })
    

🌙 温馨收尾:凌晨两点的顿悟

小汐兴奋地拍了拍我的肩膀:“原来如此!这样一讲,我感觉整个组件的通信逻辑都清晰了。怪不得你总是说,理解了 propsemit,就掌握了 Vue 的精髓!”

我看着她远去的背影,心里默默想道:今天下午的摸鱼时间没了,掘金我都还没看呢,这波真是亏大了

JavaScript 循环与对象:深入理解 for、for...in、for...of、不可枚举属性与可迭代对象

作者 鬼谷中妖
2025年10月15日 11:33

在 JavaScript 的世界里,有多种方式可以遍历数据和操作对象。本文将深入探讨 for 循环、for...infor...of 三种循环的区别,并介绍如何创建具有特殊行为(如不可枚举属性)的对象,以及如何自定义可迭代对象,让你的代码更加灵活和强大。

1. 循环的演变:forfor...in 与 for...of

这三种循环各自有不同的设计初衷和最佳应用场景,了解它们的差异对于写出高效且健壮的代码至关重要。

a. for 循环:最传统的遍历方式

  • 功能: 通过手动控制初始化、条件和迭代器,提供对循环过程最细粒度的控制。
  • 迭代目标: 通常用于遍历数组,通过索引访问元素。
  • 迭代内容: 循环变量是数组的索引
  • 最佳实践: 当你需要精确控制循环的开始、结束、步长,或在循环中频繁操作索引时,for 循环是最佳选择。在处理大型数组时,其性能通常优于其他循环。
const arr = ['苹果', '香蕉', '橙子'];
for (let i = 0; i < arr.length; i++) {
  console.log(`索引 ${i} 的值是 ${arr[i]}`);
}
// 输出:
// 索引 0 的值是 苹果
// 索引 1 的值是 香蕉
// 索引 2 的值是 橙子

b. for...in:遍历对象的键

  • 功能: 遍历一个对象所有可枚举的字符串属性,包括原型链上的属性。
  • 迭代目标: 主要用于对象
  • 迭代内容: 循环变量是对象的键(属性名)
  • 重要提示不推荐用于遍历数组。由于其会遍历原型链,且遍历顺序不确定,可能导致不可预测的行为。若需要遍历对象自身的属性,应配合 hasOwnProperty() 方法进行过滤。
const obj = { name: 'Alice', age: 30 };
for (const key in obj) {
  if (Object.prototype.hasOwnProperty.call(obj, key)) {
    console.log(`${key}: ${obj[key]}`);
  }
}
// 输出:
// name: Alice
// age: 30

c. for...of:遍历可迭代对象的值

  • 功能: 遍历可迭代对象(Iterable Object)的。这是 ES6 引入的现代循环方式,旨在解决 for...in 遍历数组时的弊端。
  • 迭代目标: 适用于数组、字符串、MapSet 等所有可迭代对象。
  • 迭代内容: 循环变量是可迭代对象的
  • 优点: 语法简洁,直接访问值,并且支持 break 和 continue 控制流。
const arr = ['苹果', '香蕉', '橙子'];
for (const value of arr) {
  console.log(value);
}
// 输出:
// 苹果
// 香蕉
// 橙子

const str = "hello";
for (const char of str) {
  console.log(char);
}
// 输出:
// h
// e
// l
// l
// o

2. 精确控制属性:创建不可枚举属性

在某些场景下,我们希望给对象添加一些内部使用的属性,但又不想让它们在常规遍历中暴露。这时,可以使用 Object.defineProperty() 方法来创建**不可枚举(non-enumerable)**属性。

使用 Object.defineProperty()

Object.defineProperty() 允许你精确地配置属性的特性,包括其可枚举性(enumerable)、可写性(writable)和可配置性(configurable)。

const user = {
  name: 'Alice',
  age: 30
};

// 使用 Object.defineProperty() 添加一个不可枚举的属性 'id'
Object.defineProperty(user, 'id', {
  value: 12345,        // 属性的值
  writable: false,     // 不可被重新赋值
  enumerable: false,   // 不可被枚举(例如:for...in, Object.keys())
  configurable: false  // 不可被删除或更改特性
});

// 验证不可枚举性
for (const key in user) {
  console.log(key); // 输出: 'name', 'age'。忽略了 'id'。
}
console.log(Object.keys(user)); // 输出: ['name', 'age']。忽略了 'id'。
console.log(user.id); // 输出: 12345。仍然可以通过点或方括号正常访问。

3. 自定义迭代行为:创建可迭代对象

要使一个自定义对象能够被 for...of 循环遍历,你需要让它成为一个可迭代对象(Iterable) 。这意味着你需要在对象上实现一个 Symbol.iterator 方法,该方法返回一个符合迭代器协议的对象。

方法一:使用常规函数

手动实现 [Symbol.iterator] 方法,并返回一个带有 next() 方法的对象。

const myCustomObject = {
  data: ['一', '二', '三'],
  [Symbol.iterator]: function() {
    let index = 0;
    const data = this.data;
    return {
      next: function() {
        if (index < data.length) {
          return { value: data[index++], done: false };
        } else {
          return { value: undefined, done: true };
        }
      }
    };
  }
};

for (const item of myCustomObject) {
  console.log(item);
}
// 输出:
// 一
// 二
// 三

方法二:使用生成器函数(更简洁)

生成器函数(function*)是创建迭代器的更现代、更简洁的方法。yield 关键字会自动为你管理迭代状态。

const myCustomObject = {
  data: ['红', '黄', '蓝'],
  *[Symbol.iterator]() {
    for (const item of this.data) {
      yield item;
    }
  }
};

for (const color of myCustomObject) {
  console.log(color);
}
// 输出:
// 红
// 黄
// 蓝

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

2025年10月15日 11:19

🚀 想让 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实现层叠布局

2025年10月15日 11:17

大家好,我是潘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菜鸡
2025年10月15日 11:03

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

当当当当!如果你在用 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复刻了某宝的小游戏动物大迁徙消消乐

作者 ZHOUYUANN
2025年10月15日 10:52

按照惯例线上预览图。

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链接

在线体验预览链接


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

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

作者 Asort
2025年10月15日 10:30
引言与责任链模式概述 责任链模式是一种行为设计模式,它允许多个对象处理同一个请求,从而避免请求发送者与接收者之间的耦合。在JavaScript中,这种模式通过将请求沿着处理链传递,直到某个对象处理它为

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

作者 _Mya_
2025年10月15日 10:19
一、引言:每个前端都经历过的“黑暗时刻” 作为前端开发者,我们的工作本应是构建流畅、美观的用户界面,与逻辑和创意共舞。但现实中,却总有一种无力感如影随形,它不来自于复杂的算法或诡异的设计稿,而来自于那

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

2025年10月15日 10:14

前言

在前端开发中,我们经常会遇到接口返回的文本内容过长,无法完全显示的问题。为了处理这一问题,通常会设置固定的宽度并使用省略号样式(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={}
/>

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

作者 星链引擎
2025年10月15日 10:02

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

你肯定用过客服机器人、游戏里的 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. 用得更好的小技巧

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

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

❌
❌