普通视图

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

【光照】[漫反射]UnityURP兰伯特能量守恒吗?

作者 SmalBox
2025年9月19日 11:20

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

兰伯特漫反射的能量守恒性

‌能量守恒基本原理‌

在物理正确的渲染中,能量守恒要求:

  1. 表面反射的光能总量 ≤ 入射光能
  2. 漫反射+高光反射 ≤ 1.0
  3. 没有能量凭空产生或消失

‌经典兰伯特模型的能量守恒问题‌

传统兰伯特漫反射公式:

漫反射=表面反照率×max(0,NL)漫反射 = 表面反照率 × max(0, N·L)

不守恒的原因‌:

  • 缺乏归一化因子‌:
    • 半球积分结果应为π,但兰伯特模型直接使用N·L
    • 正确公式应为:漫反射=表面反照率/π×max(0,NL)漫反射 = 表面反照率 / π × max(0, N·L)
  • 反射率超标‌:
    • 当反照率(albedo)设为1.0时,反射率可能超过100%
    • 例如:N·L=1时反射率为100%,但实际应有吸收损失
  • 恒定反射率‌:
    • 对所有入射角使用相同反射率
    • 现实中菲涅尔效应导致掠射角反射率增加

‌URP中的修正方案‌

Unity URP采用改进的兰伯特模型:

hlsl
// URP实际实现(Lighting.hlsl)
half diffuseTerm = saturate(dot(normal, lightDir));
half3 diffuse = albedo * lightColor * diffuseTerm * INV_PI;

其中:

  • INV_PI ≈ 0.31831 (1/π)
  • 通过除以π实现能量归一化

PBR工作流兼容性分析

‌兼容性类型‌

兼容维度 支持情况 说明
数据输入 ✅ 完全兼容 使用相同的albedo贴图输入
光照计算 ⚠️ 部分兼容 缺乏物理反射特性
材质工作流 ✅ 完全兼容 支持标准材质参数
后期处理 ✅ 完全兼容 兼容HDR/Bloom等效果
全局光照 ⚠️ 有限兼容 需要特殊处理

‌完全兼容的方面‌

  • 材质参数统一‌:
    • 使用相同的albedo纹理和颜色参数
    • 支持相同的法线贴图格式
  • HDR管线整合‌:
    • 支持线性颜色空间
    • 兼容HDR渲染和自动曝光
  • 后期处理协同‌:
    • 与Bloom效果无缝配合
    • 支持屏幕空间环境光遮蔽(SSAO)

‌有限兼容的方面‌

  • 能量守恒缺口‌:

    graph LR
    A[入射光能量] --> B[经验模型]
    B --> C[漫反射输出]
    C --> D{能量总和}
    D --> |100%| E[违反守恒]
    D --> |物理模型<90%| F[正确守恒]
    
    
  • 金属度工作流问题‌:

    • 金属表面应无漫反射,但兰伯特模型无法正确处理
    • 需要额外代码屏蔽金属表面的漫反射
  • 菲涅尔效应缺失‌:

    • 掠射角反射率无增强
    • 影响边缘光照的真实性
  • 全局光照不一致‌:

    • 与基于物理的GI系统配合时可能出现能量不匹配
    • 需要手动调整间接光照强度

‌URP中的兼容性实现‌

  • URP采用混合方案实现兼容:
hlsl
// URP的BRDF处理(BRDF.hlsl)
half3 BRDF_lambert(half3 albedo)
{
    return albedo * INV_PI;
}

#if defined(_PBR_ENABLED)
    // 物理正确的漫反射计算
    half3 diffuse = BRDF_lambert(albedo) * saturate(NdotL);
#else
    // 传统兰伯特计算
    half3 diffuse = albedo * saturate(NdotL);
#endif

结论与建议

‌能量守恒结论‌

  • 原始兰伯特模型不守恒‌:缺乏归一化因子,反射率可能超标
  • 修正版可基本守恒‌:通过添加1/π因子实现能量平衡
  • URP实现部分守恒‌:在标准着色器中已包含修正因子

‌PBR兼容性结论‌

  • ✅ ‌美术工作流兼容‌:可使用相同材质和纹理
  • ️ ‌物理精度局限‌:无法完全匹配物理反射特性
  • ️ ‌工程实用方案‌:适合风格化/性能优先项目

‌实际开发建议‌

  • 移动端项目‌:

    hlsl
    // 使用优化版兰伯特(包含能量修正)
    half3 diffuse = albedo * saturate(NdotL) * 0.31831;
    
  • PC/主机项目‌:

    hlsl
    // 使用完整PBR漫反射(Disney模型)
    half3 diffuse = albedo * (1 / PI) * (1 - metallic) * saturate(NdotL);
    
  • 风格化渲染‌:

    hlsl
    // 艺术导向的增强版
    half3 diffuse = albedo * pow(saturate(NdotL), _RampPower) * _Intensity;
    

在URP中,经验模型与PBR工作流可通过条件编译实现无缝切换,开发者可根据目标平台和艺术需求选择最适合的模型,在物理准确性和性能之间取得最佳平衡。


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

(欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

本文由博客一文多发平台 OpenWrite 发布!

从零到一打造 Vue3 响应式系统 Day 10 - 为何 Effect 会被指数级触发?

作者 我是日安
2025年9月19日 11:14

ZuB1M1H.png

DOM 交互

我们的响应式系统经过前几天的努力,已经初具雏形,感觉可以加入一些 DOM 交互,来进行简单的测试。

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Title</title>
    <style>
      body {
        padding: 150px;
      }
    </style>
  </head>
  <body>
    <button id="btn">按钮</button>
    <script type="module">
      import { ref, effect } from '../dist/reactivity.esm.js'

      const flag = ref(true)

      effect(() => {
        flag.value
        console.count('effect')
      })

      btn.onclick = () => {
        flag.value = !flag.value
      }
    </script>
  </body>
</html>

day10-01.gif

我们预期每次点击按钮,effect 只会执行一次。但实际情况看起来不太妙。

console.count 的结果可以看到,effect 的执行次数随着点击呈现指数级增长。这肯定是不行的。

我们来了解一下问题的症结所在。

Link 节点创建问题的症结

执行步骤图解

初始化页面

day10-06.png 页面加载时,effect 执行一次。在执行过程中,读取了 flag.value,触发 getter 进行依赖收集。 系统会创建一个 link1 节点,将 effectflag 关联起来。到这里都符合预期。

第一次点击按钮

day10-02.png 当按钮第一次被点击,flag.valuetrue 变为 false,触发了 setter。 setter 内的 propagate 函数开始遍历 flag 的依赖链表。

propagate 执行 link1 中存储的 effect.run()

effect 函数重新执行,又读取了 flag.value,再次触发了 getter。

此时问题出现了:在 effect.run() 的过程中,又进行了一次依赖收集,系统创建了一个新的 link2 节点并添加到链表尾部。

执行结束后的链表:

day10-03.png

第二次点击按钮

day10-04.png 当按钮又被点击,flag.valuefalse 变为 true,再次触发 setter。

propagate 开始遍历依赖链表。但这一次,链表上有两个节点 (link1link2)。

  1. propagate 先执行 link1 中的 effect.run()effect 内部读取 flag.value,触发依赖收集,创建了一个新的 link3 节点并添加到链表尾部。
  2. propagate 接着执行 link2 中的 effect.run()effect 内部又一次读取 flag.value,触发依赖收集,又创建了一个新的 link4 节点并添加到链表尾部。

执行结束后的链表:

day10-05.png

执行完成后的链表结构

我们可以发现在触发更新时,链表上的每一个节点都会触发一次 effect 的重新执行,而每一次执行又会创建一个新的节点加入到链表中,因此发生了指数级触发 effect 的情况。

关键问题点

每次 effect 重新执行时:

  1. 没有检查该 effect 是否已经存在于依赖链表中。
  2. 盲目地创建新的 Link 节点并添加到链表末尾。
  3. 导致依赖链表在每次更新时都会成倍增长。

因此,每次点击按钮,链表上的每一个 Link 都会触发一次 effect 的重新执行,而在每一次执行中又会创建新的 Link,从而导致重复执行和指数级增长现象。

因为下个篇幅比较长,今天就先讲到这里。大家需要先理解问题的症结所在,这样明天在实现解决方案时,才能明白我们为什么要那样做。


想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。

什么是 React 中的远程组件?

作者 Johnny_FEer
2025年9月19日 10:29

React 中的 远程组件 是指从远程源(如 CDN 或服务器)加载和执行的组件,而不是与你的应用程序捆绑在一起。这样可以:

  1. 运行时组件的动态加载
  2. 跨不同应用程序共享组件
  3. 更新组件,无需重新构建主应用程序
  4. 微前端架构实现

如何实现远程组件

在 React 中实现远程组件的方法有多种:

1.模块联合 (Webpack 5)

这是主流用法

使用者/消费者(host主应用程序):

// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('@module-federation/webpack');

module.exports = {
  // ...other config
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        remoteApp: 'remoteApp@http://localhost:3001/remoteEntry.js',
      },
      shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
};

远程应用程序(provider提供者)

// webpack.config.js
const { ModuleFederationPlugin } = require('@module-federation/webpack');

module.exports = {
  // ...other config
  plugins: [
    new ModuleFederationPlugin({
      name: 'remoteApp',
      filename: 'remoteEntry.js',
      exposes: {
        './Button': './src/components/Button',
      },
      shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
    }),
  ],
};

在主应用程序中使用:

import React, { Suspense } from 'react';

const RemoteButton = React.lazy(() => import('remoteApp/Button'));

function App() {
  return (
    <div>
      <h1>Host Application</h1>
      <Suspense fallback="Loading Remote Component...">
        <RemoteButton />
      </Suspense>
    </div>
  );
}

2.使用 CDN 动态导入

从 CDN 加载组件:

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

function RemoteComponent({ url }) {
  const [Component, setComponent] = useState(null);

  useEffect(() => {
    const loadComponent = async () => {
      try {
        const module = await import(/* webpackIgnore: true */ url);
        setComponent(() => module.default);
      } catch (error) {
        console.error('Failed to load remote component:', error);
      }
    };

    loadComponent();
  }, [url]);

  if (!Component) {
    return <div>Loading...</div>;
  }

  return <Component />;
}

3.自定义远程组件加载器

创建更复杂的远程组件系统:

// RemoteComponentLoader.js
import React, { useState, useEffect, useRef } from 'react';

class RemoteComponentLoader {
  constructor() {
    this.cache = new Map();
  }

  async loadComponent(url, scope, module) {
    const key = `${url}-${scope}-${module}`;
    
    if (this.cache.has(key)) {
      return this.cache.get(key);
    }

    try {
      // Load the remote entry
      await this.loadScript(url);
      
      // Get the module from the remote container
      const factory = await window[scope].get(module);
      const Module = factory();
      
      this.cache.set(key, Module);
      return Module;
    } catch (error) {
      console.error('Error loading remote component:', error);
      throw error;
    }
  }

  loadScript(url) {
    return new Promise((resolve, reject) => {
      if (document.querySelector(`script[src="${url}"]`)) {
        resolve();
        return;
      }

      const script = document.createElement('script');
      script.src = url;
      script.onload = resolve;
      script.onerror = reject;
      document.head.appendChild(script);
    });
  }
}

const loader = new RemoteComponentLoader();

export function RemoteComponent({ url, scope, module, fallback = 'Loading...' }) {
  const [Component, setComponent] = useState(null);
  const [error, setError] = useState(null);
  const mountedRef = useRef(true);

  useEffect(() => {
    const loadRemoteComponent = async () => {
      try {
        const LoadedComponent = await loader.loadComponent(url, scope, module);
        
        if (mountedRef.current) {
          setComponent(() => LoadedComponent);
        }
      } catch (err) {
        if (mountedRef.current) {
          setError(err);
        }
      }
    };

    loadRemoteComponent();

    return () => {
      mountedRef.current = false;
    };
  }, [url, scope, module]);

  if (error) {
    return <div>Error loading component: {error.message}</div>;
  }

  if (!Component) {
    return <div>{fallback}</div>;
  }

  return <Component />;
}

用法

function App() {
  return (
    <div>
      <h1>My App</h1>
      <RemoteComponent 
        url="http://localhost:3001/remoteEntry.js"
        scope="remoteApp"
        module="./Button"
        fallback="Loading button..."
      />
    </div>
  );
}

4. 使用systemJs

import React, { useState, useEffect } from 'react';
import { importMap } from './importMap';

function SystemJSRemoteComponent({ componentName }) {
  const [Component, setComponent] = useState(null);

  useEffect(() => {
    const loadComponent = async () => {
      try {
        // Configure SystemJS with import map
        System.addImportMap(importMap);
        
        // Import the remote component
        const module = await System.import(componentName);
        setComponent(() => module.default || module);
      } catch (error) {
        console.error('Failed to load remote component:', error);
      }
    };

    loadComponent();
  }, [componentName]);

  if (!Component) {
    return <div>Loading remote component...</div>;
  }

  return <Component />;
}
// importMap.js
export const importMap = {
  imports: {
    // npm packages
    "react": "https://cdn.skypack.dev/react",
    "react-dom": "https://cdn.skypack.dev/react-dom",
    "lodash": "https://cdn.skypack.dev/lodash",
    
    // Remote components
    "my-button": "https://my-cdn.com/components/button.js",
    "my-header": "https://my-cdn.com/components/header.js",
    "shared-ui-library": "https://shared-components.company.com/library.js",
    
    // Scoped packages
    "@material-ui/core": "https://cdn.skypack.dev/@material-ui/core",
    "@myorg/shared-components": "https://myorg-cdn.com/shared-components/index.js"
  },
  scopes: {
    // Scoped mappings for version management
    "https://my-cdn.com/components/": {
      "utils": "https://my-cdn.com/utils/v2.0.0/index.js"
    }
  }
};

注意点

  1. 安全性:始终验证和清理远程组件以防止 XSS 攻击
  2. 版本控制:为远程组件实施适当的版本控制策略
  3. 错误处理:优雅地处理网络故障和组件加载错误

JavaScript 从零开始(七):函数编程入门——从定义到可重用代码的完整指南

作者 Asort
2025年9月19日 10:23

引言:函数编程的威力

函数是编程世界的基本构建块,在JavaScript中尤其重要。函数是一段可重复使用的代码块,它接收输入(参数),执行特定任务,并可能返回结果。通过将代码组织成函数,我们可以实现模块化、提高可读性,并显著减少重复代码。

学习函数编程对初学者至关重要,因为它是从"编写能工作的代码"到"编写优雅、可维护代码"的关键转变。函数让我们能够将复杂问题分解为更小的、可管理的部分,这是编程思维的核心。

本文假设读者已具备基本的JavaScript语法知识,包括变量、数据类型和基本操作。文章将从函数的基本定义开始,逐步深入探讨参数传递、作用域、闭包等高级概念,最终展示如何创建高度可重用的函数式代码。

我们将首先学习如何定义和调用函数,然后探索参数传递的不同方式,接着讨论函数作为一等公民的特性,最后通过实际案例展示如何构建可重用的函数库。通过本指南,你将掌握函数编程的核心技能,为构建复杂的JavaScript应用奠定坚实基础。

函数的定义:创建你的第一个函数

函数是JavaScript中的基本构建块,就像乐高积木一样,它们让我们能够创建可重用的代码块。让我们从函数的基础定义开始学习。

在JavaScript中,函数使用function关键字定义,后跟函数名、参数列表和函数体。基本语法如下:

// 函数基本语法结构
function functionName(parameter1, parameter2) {
    // 函数体 - 这里放置要执行的代码
    // 注意:函数体应该有适当的缩进,提高可读性
    // 这是代码格式化的最佳实践
}

让我们创建一个简单的"Hello World"函数:

// 定义一个简单的问候函数
function sayHello() {
    // 函数体:在控制台打印"Hello World"
    console.log("Hello World");
}

// 调用函数才能执行其中的代码
sayHello(); // 输出: Hello World

函数体是函数的核心部分,包含了函数执行时运行的代码。缩进对于保持代码清晰至关重要,它帮助我们理解代码块的结构和层次。良好的缩进习惯能让代码更易于阅读和维护。

现在,让我们创建一个更有用的函数,它接受参数:

// 带参数的函数示例
function greetUser(name) {
    // 使用模板字符串动态生成问候语
    console.log(`Hello, ${name}! Welcome to JavaScript functions.`);
}

// 调用函数并传递参数
greetUser("Alice"); // 输出: Hello, Alice! Welcome to JavaScript functions.
greetUser("Bob");   // 输出: Hello, Bob! Welcome to JavaScript functions.

函数命名规范对于编写清晰、可维护的代码至关重要:

  1. 使用描述性的名称:函数名应该清楚地表达函数的功能
  2. 采用驼峰命名法(camelCase):如calculateTotal而不是calculate_total
  3. 保持名称简洁但有意义:避免使用doThing()这样模糊的名称
  4. 对于布尔返回值的函数,考虑使用ishas前缀:如isValidEmail()

让我们看一个遵循最佳实践的完整示例:

// 计算圆面积的函数,遵循命名和格式最佳实践
function calculateCircleArea(radius) {
    // 验证输入是否为有效数字
    if (typeof radius !== 'number' || radius <= 0) {
        return "请提供有效的正数半径";
    }
    
    // 计算并返回圆的面积
    const area = Math.PI * radius * radius;
    return area;
}

// 使用函数并显示结果
console.log(`半径为5的圆面积: ${calculateCircleArea(5)}`);
// 输出: 半径为5的圆面积: 78.53981633974483

console.log(calculateCircleArea(-2));
// 输出: 请提供有效的正数半径

通过这些基础示例,你已经了解了如何定义、命名和调用JavaScript函数。这些函数构建块是创建可重用代码的关键第一步,为后续学习更复杂的函数概念奠定了基础。

函数的调用:激活你的函数

在JavaScript中,函数调用是执行函数代码的关键步骤。当函数被定义后,它就像是一个待命的工具,只有在被调用时才会真正发挥作用。调用函数的基本语法是:函数名(参数)

// 定义一个简单的问候函数
function greet(name) {
  return `你好,${name}!`;
}

// 调用函数
console.log(greet('小明')); // 输出: 你好,小明!
console.log(greet('小红')); // 输出: 你好,小红!

函数执行流程就像一个"旅程":当调用函数时,程序会暂停当前执行,跳转到函数体内运行代码,执行完毕后再返回原来的位置继续执行。这个过程称为函数控制流

// 展示函数执行流程
console.log('开始');
displayMessage('函数正在执行');
console.log('结束');

function displayMessage(msg) {
  console.log(msg); // 这个调用会暂停主流程
}
// 预期输出:
// 开始
// 函数正在执行
// 结束

函数的最大优势在于可重用性。同一个函数可以在不同地方多次调用,极大提高了代码效率:

// 计算圆面积的函数
function calculateCircleArea(radius) {
  return Math.PI * radius * radius;
}

// 多次调用同一个函数
const area1 = calculateCircleArea(5);
const area2 = calculateCircleArea(10);
console.log(`半径为5的圆面积: ${area1.toFixed(2)}`);
console.log(`半径为10的圆面积: ${area2.toFixed(2)}`);

常见的函数调用错误包括:

  1. 未定义函数:调用不存在的函数

    // 错误示例
    myFunction(); // ReferenceError: myFunction is not defined
    
  2. 参数不匹配:传入的参数数量或类型不符合预期

    function add(a, b) {
      return a + b;
    }
    console.log(add(1, 2, 3)); // 正常,忽略多余参数
    console.log(add(1)); // NaN,因为b是undefined
    
  3. 函数作用域问题:在错误的作用域中调用函数

    // 错误示例
    function inner() {
      console.log('内部函数');
    }
    inner(); // 正确
    // 如果inner定义在另一个函数内部,外部无法直接调用
    

掌握函数调用是创建可重用代码的基础,通过合理调用函数,我们可以构建更加模块化、可维护的程序结构。

参数传递:向函数输入数据

参数是函数与外部世界交互的桥梁,它们就像向工厂机器投入的原材料,函数则对这些原材料进行处理并产出结果。在JavaScript中,参数是定义在函数名后括号内的变量,它们使函数能够接收不同的输入数据,从而实现代码的重用和灵活性。

JavaScript中的参数主要分为位置参数和关键字参数(虽然严格来说JavaScript没有真正的关键字参数,但我们可以通过对象模拟类似功能)。位置参数按照定义的顺序传递,而关键字参数则通过名称指定,提高了代码的可读性和灵活性。

默认参数值是ES6引入的一个强大特性,它允许我们在函数定义时为参数指定默认值。当调用函数时未提供该参数或传入undefined时,将使用默认值。

让我们通过几个实例来理解参数的使用:

// 基本参数传递 - 计算两个数的和
/**
 * 计算两个数字的和
 * @param {number} a - 第一个数字
 * @param {number} b - 第二个数字
 * @returns {number} 两数之和
 */
function add(a, b) {
  return a + b;
}

console.log(add(5, 3)); // 输出: 8
console.log(add(10, 20)); // 输出: 30
// 带默认参数的函数
/**
 * 创建一个问候消息
 * @param {string} name - 用户名
 * @param {string} greeting - 问候语,默认为"Hello"
 * @returns {string} 问候消息
 */
function greet(name, greeting = "Hello") {
  return `${greeting}, ${name}!`;
}

console.log(greet("Alice")); // 输出: Hello, Alice!
console.log(greet("Bob", "Good morning")); // 输出: Good morning, Bob!
// 使用对象模拟关键字参数
/**
 * 计算矩形的面积
 * @param {Object} dimensions - 矩形的尺寸
 * @param {number} dimensions.width - 矩形的宽度
 * @param {number} dimensions.height - 矩形的高度
 * @returns {number} 矩形的面积
 */
function calculateArea({ width, height }) {
  return width * height;
}

console.log(calculateArea({ width: 5, height: 3 })); // 输出: 15

掌握参数传递是创建可重用代码的关键。通过合理使用参数,我们可以编写出更加灵活、可维护的函数,使我们的JavaScript代码更加优雅和高效。

返回值:从函数获取结果

在JavaScript函数编程中,return语句扮演着至关重要的角色,它就像是一个信使,将函数内部计算的结果传递给函数外部。当函数执行到return语句时,它会立即停止执行并将指定的值返回给调用者。

基本返回值

函数通过return语句返回计算结果,这使我们的代码更加模块化和可重用:

// 计算两个数的和并返回结果
function addNumbers(a, b) {
  const sum = a + b;
  return sum; // 使用return返回计算结果
}

// 调用函数并接收返回值
const result = addNumbers(5, 3);
console.log(result); // 输出: 8

无返回值的函数

如果函数没有明确的return语句,它会返回undefined值:

// 没有return语句的函数
function greetUser(name) {
  console.log(`Hello, ${name}!`); // 只执行打印操作
}

const greeting = greetUser("Alice");
console.log(greeting); // 输出: Hello, Alice! 后跟 undefined

返回多个值的技巧

JavaScript函数一次只能返回一个值,但我们可以使用数组对象来模拟返回多个值:

// 使用数组返回多个值
function getCoordinates() {
  const x = 10;
  const y = 20;
  return [x, y]; // 返回包含多个值的数组
}

const coords = getCoordinates();
console.log(`X: ${coords[0]}, Y: ${coords[1]}`); // 输出: X: 10, Y: 20

// 使用对象返回多个值(更具描述性)
function getUserInfo() {
  return {
    name: "John",
    age: 30,
    email: "john@example.com"
  };
}

const user = getUserInfo();
console.log(user.name); // 输出: John
console.log(user.age);  // 输出: 30

重要概念:函数的返回值使我们能够创建可重用的代码组件,它们接收输入,处理数据,然后返回结果,这是函数式编程的核心原则之一。通过合理使用返回值,我们可以构建更加模块化、可测试且易于维护的代码。

函数的作用域:理解变量的可见性

在JavaScript中,作用域决定了变量的可见性和生命周期,理解作用域是掌握函数编程的关键。作用域就像一个房间,里面的变量只能在房间内被看到和使用,而无法从外部访问。

局部变量与全局变量

全局变量是在函数外部声明的变量,可以在代码的任何地方访问:

// 全局变量
let globalMessage = "Hello, World!";

function showMessage() {
  // 局部变量,只在函数内部可见
  let localMessage = "Hello, Function!";
  console.log(localMessage); // 可以访问局部变量
  console.log(globalMessage); // 也可以访问全局变量
}

showMessage();
// console.log(localMessage); // 错误:无法在函数外部访问局部变量

变量的生命周期

局部变量的生命周期仅限于函数执行期间,函数执行完毕后,局部变量就会被销毁:

function createCounter() {
  let count = 0; // 局部变量,每次函数调用都会重新创建
  return function() {
    count++;
    return count;
  };
}

const counter1 = createCounter();
const counter2 = createCounter();

console.log(counter1()); // 1
console.log(counter1()); // 2
console.log(counter2()); // 1 - 新的闭包,有自己的count变量

访问和修改全局变量

在函数内部,可以访问和修改全局变量,但为了避免命名冲突,应谨慎使用:

let globalCount = 0;

function incrementCounter() {
  // 修改全局变量
  globalCount++;
  console.log(`Global count is now: ${globalCount}`);
}

incrementCounter(); // Global count is now: 1
incrementCounter(); // Global count is now: 2

作用域规则的实际应用

理解作用域有助于创建更健壮、可维护的代码。例如,使用IIFE(立即调用函数表达式)可以避免全局命名空间污染:

// 使用IIFE创建局部作用域
(function() {
  let privateData = "This is private";
  
  function publicFunction() {
    console.log(privateData);
  }
  
  // 暴露公共API到全局
  window.myModule = {
    publicFunction: publicFunction
  };
})();

// 可以访问公共API
myModule.publicFunction(); // This is private

// 无法访问私有数据
// console.log(privateData); // 错误:privateData is not defined

掌握函数作用域是创建可重用代码的基础,它帮助我们避免变量冲突,封装数据,并构建更清晰的代码结构。

实际应用:创建可重用代码

在JavaScript编程中,识别可封装为函数的代码片段是提高代码可重用性的第一步。想象一下,如果你需要在多个地方执行相同的操作,而不将其封装为函数,就会导致代码重复,难以维护。将重复代码块提取为函数,就像把常用的工具放进工具箱,随时可以取用。

设计实用函数的技巧包括单一职责原则和清晰的参数设计。一个理想函数应该只做一件事,并且参数应该明确且必要。例如,计算两个数的和:

// 基础函数定义与调用示例
function addNumbers(num1, num2) {
  // 返回两数之和
  return num1 + num2;
}

// 调用函数并传递参数
let result = addNumbers(5, 3);
console.log(result); // 输出: 8

函数组合是将多个简单函数组合成复杂功能的强大技术。这就像搭积木,每个小积木(函数)都有自己的功能,组合起来可以创建更复杂的结构。

// 函数组合示例
function multiply(num1, num2) {
  return num1 * num2;
}

function subtract(num1, num2) {
  return num1 - num2;
}

// 组合函数实现复杂计算
function calculate(a, b, c) {
  // 先计算a*b,然后减去c
  return subtract(multiply(a, b), c);
}

console.log(calculate(4, 5, 3)); // 输出: 17 (4*5-3)

案例:构建一个简单的计算器程序展示了如何应用这些概念创建实用的可重用代码:

// 计算器程序 - 使用函数组合实现复杂功能
// 加法函数
function add(a, b) {
  return a + b;
}

// 减法函数
function subtract(a, b) {
  return a - b;
}

// 乘法函数
function multiply(a, b) {
  return a * b;
}

// 除法函数 - 包含错误处理
function divide(a, b) {
  if (b === 0) {
    return "错误:除数不能为零";
  }
  return a / b;
}

// 计算器功能函数 - 组合基本运算
function calculate(num1, num2, operation) {
  switch(operation) {
    case '+': return add(num1, num2);
    case '-': return subtract(num1, num2);
    case '*': return multiply(num1, num2);
    case '/': return divide(num1, num2);
    default: return "错误:无效操作";
  }
}

// 使用计算器
console.log(calculate(10, 5, '+')); // 输出: 15
console.log(calculate(10, 5, '-')); // 输出: 5
console.log(calculate(10, 5, '*')); // 输出: 50
console.log(calculate(10, 0, '/')); // 输出: 错误:除数不能为零

通过这个例子,我们可以看到如何将基本运算封装为函数,然后通过一个主函数组合这些功能,创建一个灵活且可扩展的计算器程序。这种模块化的方法使得代码更易于维护、测试和扩展。

函数最佳实践:编写高质量的函数

函数设计的简洁性原则

好的函数应该像一把精准的螺丝刀,只专注于完成单一任务。遵循单一职责原则,确保函数只做一件事,并且做好这件事。例如,与其创建一个"超级函数"处理数据验证、格式化和显示,不如将其拆分为三个独立函数:

// 不推荐:违反单一职责原则
function processUserData(userData) {
  if (!userData) throw new Error('用户数据不能为空');
  // 数据验证
  if (!userData.name || !userData.email) return null;
  // 数据处理
  const processed = {
    name: userData.name.trim(),
    email: userData.email.toLowerCase()
  };
  // 显示数据
  console.log(`处理后的用户: ${processed.name} (${processed.email})`);
  return processed;
}

// 推荐:职责分离
function validateUserData(userData) {
  if (!userData) throw new Error('用户数据不能为空');
  return userData.name && userData.email;
}

function normalizeUserData(userData) {
  return {
    name: userData.name.trim(),
    email: userData.email.toLowerCase()
  };
}

function displayUserData(user) {
  console.log(`处理后的用户: ${user.name} (${user.email})`);
}

函数文档和注释的重要性

函数文档就像产品的说明书,帮助其他开发者理解函数的用途、参数和返回值。使用JSDoc注释可以提供清晰的结构化文档:

/**
 * 计算两个数的和
 * @param {number} a - 第一个加数
 * @param {number} b - 第二个加数
 * @returns {number} 两数之和
 * @throws {TypeError} 当参数不是数字时抛出
 */
function add(a, b) {
  // 参数类型验证
  if (typeof a !== 'number' || typeof b !== 'number') {
    throw new TypeError('两个参数都必须是数字');
  }
  return a + b;
}

参数验证和错误处理

参数验证就像机场安检,确保只有合法的"乘客"(参数)能够进入"飞机"(函数执行):

function processArray(arr, callback) {
  // 参数验证
  if (!Array.isArray(arr)) {
    throw new TypeError('第一个参数必须是数组');
  }
  if (typeof callback !== 'function') {
    throw new TypeError('第二个参数必须是函数');
  }
  
  // 安全的数组处理
  try {
    return arr.map(item => {
      try {
        return callback(item);
      } catch (err) {
        console.error(`处理元素 ${item} 时出错:`, err.message);
        return null; // 返回默认值而不是中断整个流程
      }
    });
  } catch (err) {
    console.error('处理数组时出错:', err.message);
    return [];
  }
}

// 使用示例
const result = processArray([1, 2, 'three', 4], 
  x => x * x); // 预期输出: [1, 4, null, 16]

避免常见函数陷阱

函数中的副作用就像药物的不良反应,应该尽量避免。避免修改输入参数、依赖全局状态或产生意外的副作用:

// 不推荐:有副作用的函数
function processUser(user) {
  user.lastModified = new Date(); // 修改了输入对象
  global.userCount++;            // 依赖全局状态
  return user;
}

// 推荐:纯函数,无副作用
function processUserPure(user) {
  // 不修改输入对象,而是创建新对象
  return {
    ...user,
    lastModified: new Date(),
    processed: true
  };
}

记住,高质量的函数是代码可重用的基础,遵循这些原则可以创建更可靠、更易于维护的代码。

总结与进阶:函数编程的未来

通过本文,我们回顾了JavaScript函数编程的核心概念,从函数的定义、调用和参数传递到创建可重用代码的实践。函数式编程显著提升了代码质量,增强了可读性、可维护性和可测试性,同时减少了副作用和不可预测的行为。递归函数作为函数式编程的重要工具,为我们提供了优雅解决复杂问题的途径,如树遍历和算法实现。

要精通函数式编程,建议深入学习Ramda、Lodash等函数库,探索ES6+中的函数式特性如箭头函数和展开运算符。实践是掌握函数式编程的关键,尝试在日常项目中应用所学知识,重构现有代码,逐步培养函数式思维。函数式编程不仅是编写代码的方式,更是一种解决问题的思维模式,它将引领你编写更加优雅、高效的JavaScript代码。

NUXT4.js制作企业官网

作者 togo
2025年9月19日 10:02

SASS

1、引入SASS

npm install -D sass

2、配置文件目录

// nuxt.config.ts
export default defineNuxtConfig({
  css: [
    '~/assets/styles/global.scss' // 全局引入 scss 文件
  ],
  vite: {
    css: {
      preprocessorOptions: {
        scss: {
          additionalData: `
            @use "@/assets/styles/variables.scss" as *; 
          `
        }
      }
    }
  }
})

additionalData 可以注入 SCSS 变量或 mixin 到每个 .vue 样式块。

推荐使用 @use ... as * 而不是 @import,因为 @import 会重复加载。

注意路径 @/assets/styles/variables.scss 是相对于项目根目录。

3、创建2个文件


$main-color: #ff0000;
$font-size: 16px;
//定义全局样式

问题:

如果用 <style scoped> ,变量仍然可以访问,但 mixin 和函数也要全局注入。

不要在 global.scss 里再 @use 自己的 variables.scss,会出现循环依赖。

不能在global.scss写预设变量,会报错

additionalData 也可以直接写变量,而不通过单独文件:

4、使用

<template>
  <div class="demo">Hello Nuxt4</div>
</template>

<style lang="scss" scoped>
.demo {
  color: $main-color;
  font-size: $font-size;
}
</style>

打包SSG 静态文件

1、配置文件

import { defineNuxtConfig } from 'nuxt/config'

export default defineNuxtConfig({
  // 目标模式
  ssr: true, // 或 false,根据你需求
  nitro: {
    preset: 'static' // 告诉 Nuxt 使用静态输出
  },
  // 可选:路由生成规则
  generate: {
    // 避免部分动态路由不生成
    routes: [
      '/about',
      '/contact',
      '/posts/1',
      '/posts/2'
    ]
  }
})

2、打包

npx nuxt generate//打包

npx serve dist //本地测试

Nuxt/i18n模块

文档:i18n.nuxtjs.org/docs/gettin…

npx nuxi@latest module add @nuxtjs/i18n
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  i18n: {
    locales: [
      { code: 'en', name: 'English', file: 'en.json' },
      { code: 'nl', name: 'Nederlands', file: 'nl.json' }
    ],
    defaultLocale: 'zh',
    strategy: 'prefix_except_default', // zh 没有前缀,en 有前缀
    lazy: true, // 按需加载语言文件
    langDir: 'locales/', // 存放多语言文件的目录
  },
})

json文件存放位置: <rootDir>/i18n/locales

<script setup>
const { locales, setLocale } = useI18n()
</script>

<template>
  <div>
    <button v-for="locale in locales" @click="setLocale(locale.code)">
      {{ locale.name }}
    </button>
    <h1>{{ $t('welcome') }}</h1>
  </div>
</template>
  • 中文页面 URL:https://www.xxx.com/aaa
  • 英文页面 URL:https://www.xxx.com/en/aaa
  • 并且你可以用 <NuxtLink locale="en"> 来跳转自动带语言前缀。

什么时候选哪种?

  • 对 SEO 有要求 → 推荐 URL 前缀方式(用 @nuxtjs/i18n
  • 只是展示,不需要多语言 SEO → 用 vue-i18n 动态切换就够了

路由跳转方法

<template>
  <NuxtLink :to="$localePath('index')">{{ $t('home') }}</NuxtLink>
  <NuxtLink :to="$localePath('index', 'en')">Homepage in English</NuxtLink>
  <NuxtLink :to="$localePath('user-profile')">Route to {{ $t('profile') }}</NuxtLink>
  <NuxtLink :to="$localePath({ name: 'category-slug', params: { slug: category.slug } })">
    {{ category.title }}
  </NuxtLink>
</template>
//项目自动引入
const localeRoute = useLocaleRoute()
const router=useRouter()
function onClick() {
  const route = localeRoute({ name: 'user-profile', query: { foo: '1' } })
  if (route) {
    return navigateTo(route.fullPath+ '/123') //带参数
    //router.push({path:route.fullPath + '/123'})
  }
}

SEO优化

1、 开启静态生成 (SSG)

Nuxt4 支持自动预渲染动态路由,比如 pages/blog/[slug].vue,只要在 nuxt.config.ts 里指定:

这样每篇文章都会有独立的静态 HTML。

export default defineNuxtConfig({
  nitro: {
    preset: 'static', // 确保生成纯静态站点
    routes: ['/blog/article1', '/blog/article2'] // 预渲染
  }
})

2、设置 meta ,并每个页面单独设置 meta

在app.vue放一个统一的,然后再每个页面单独放一个

useHead({
  title:"你的官网标题",
  meta:[
    { name: 'description', content: '一句话介绍你的产品/服务' },
    { name: 'keywords', content: '关键词1, 关键词2, 关键词3' },
    { property: 'og:title', content: '页面标题' },
    { property: 'og:description', content: '页面描述' },
    { property: 'og:type', content: 'website' },
    { property: 'og:image', content: 'https://yourdomain.com/og.png?20250912' }
  ]
})

//页面
useHead({
  title:"xx页面-xxx",
})
og:image:一张1200*630的图片

这个配置是用来设置 Open Graph 协议 (OG) 的图片,主要作用是:

当别人分享你的网站(或页面)到社交平台(微信、QQ、Facebook、Twitter、LinkedIn 等),
平台会读取你页面的 <meta property="og:image" ...>,并把这张图片作为预览封面图显示出来

图片尺寸:最好是 1200×630(或 1.91:1 比例),保证清晰度。

路径必须是绝对路径:推荐写成完整网址,否则有些平台(如微信、Twitter)识别不了

图片 URL 是一个稳定且绝对路径,并带有版本号或类似 timestamp(例子里 URL 后面有类似 ?201610171354 的部分),方便缓存刷新或内容更新时客户端能获取新的图

3、站点地图 @nuxtjs/sitemap

文档:nuxtseo.com/docs/sitema…

安装

npx nuxi module add @nuxtjs/sitemap

配置

export default defineNuxtConfig(
 site: { 
 url: 'https://example.com', //网站地址
 name: 'My Awesome Website' //名称
 }, 
}) 

安装 nuxt-simple-sitemap ,配置了也没有自动生成,所以不用 nuxt-simple-sitemap

4、robots

告诉搜索引擎哪些能爬:

文档:nuxtseo.com/docs/robots…

npx nuxi module add robots

安装完不需要配置,nuxt.config.ts自动生成modules

export default defineNuxtConfig(
 modules: ['@nuxtjs/sitemap','@nuxtjs/robots'] 
}) 

保证这两个都存在之后 npx nuxi generate 生成静态文件 ,在public文件里里面会自动生成 sitemap_index.xml和robots.txt两个文件

5、Schema.org JSON-LD 结构化数据 (Schema.org)

结构化数据是一种用 机器可读的格式(通常是 JSON-LD)标注网页信息的方式。
搜索引擎(Google、Bing、百度等)会读取这些信息,用于:

  • 丰富搜索结果(Rich Snippets),比如显示公司 logo、评价、联系方式等
  • 提高 SEO 可见性和识别准确度
  • 在搜索结果里更容易展示品牌信息

Nuxt 里可以用 useHead 注入到 <head> 里。

可以放在 默认布局,让所有页面都生效。

例如在 layouts/default.vue

useHead({
  script: [
    {
      type: 'application/ld+json',
      innerHTML: JSON.stringify({
        "@context": "https://schema.org",//固定不需要改
        "@type": "Organization",//固定不需要改
        "name": "名称",
        "url": "https://www.xxx.com",//官网地址
        "logo": "https://www.xxx.com/logo.png"//logo的网络地址
      })
    }
  ]
})

网页里会生成一个标签

<head>
  ...
  <script type="application/ld+json">
    {
      "@context": "https://schema.org",
      "@type": "Organization",
      "name": "xxx",
      "url": "https://www.xxx.com",
      "logo": "https://www.xxx.com/logo.png"
    }
  </script>
  ...
</head>

6、图片添加alt属性

问题BUG

1、使用自定义指令报错SSR错误

这个错误最可能的原因之一,是项目中使用了自定义 Vue 指令,但这个指令没有在 Nuxt 3 中正确实现或兼容,导致服务端渲染 (SSR) 时出现问题。正如一处搜索结果中提到,在 Vue 3 迁移到 Nuxt 3 时,一个自定义指令没有搬过来,但组件中又使用了它,就可能导致此类错误,并且可能使得页面 HTML 结构未被正确解析。

  • 检查指令定义:首先全局搜索你的项目源代码,查找 directivedirectives 或你怀疑的特定指令名称(例如 v-top)。确认这些指令是否在 Nuxt 3 的插件 (~/plugins/目录) 或组件中正确定义。
  • 确保指令兼容 SSR:自定义指令中的代码不能在服务端渲染阶段访问浏览器特有的 API(如 window, document, alert 等)。你需要确保指令的逻辑要么仅在客户端运行,要么在调用浏览器 API 前进行环境判断。

解决办法:如果不使用SSR的话,在nuxt.config.ts中关掉SSR:false

2、引用swiper库无法获取实例对象

官方文档:swiperjs.com/vue#useswip…
使用useSwiper()钩子获取不到
替代方法:

const mySwiper = ref<null | HTMLElement>(null)
// 获取swiper属性
mySwiper.value?.$el.swiper
mySwiper.value?.$el.swiper.slideNext()

关于rngh手势与Slider组件手势与事件冲突解决问题记录

作者 真夜
2025年9月19日 09:47

在rngh(react-native-gesture-handler) 是rn一个常用的原生手势库,其手势响应运行在原生层,相比rn原生的具备更高性能与更快响应,本文主要记录其与手势与事件冲突问题 个人使用的是rngh v2版本

本文解决的是嵌套在GestureDetector内部想要触发事件的问题。

1.普通按钮与手势冲突

function(){
return 

<>
     <GestureDetector>   
     //其他组件
   //按钮部分   <Button/>..
       //其他组件
     </GestureDetector>
</>
}

以上代码结构是常规的按钮被GestureDetector包裹,在这块只会触发rngh的手势,无法触发按钮事件

使用Pressable组件

该组件是rngh导出的组件,使用的是原生层级的响应,可以与GestureDetector包裹配置的手势同时触发,需要设置变量去拦截手势触发的逻辑,不推荐

嵌套GestureDetector

  const playButtonGesture = Gesture.Tap().runOnJS(true);
        
  const onceTap = Gesture.Tap()
    .requireExternalGestureToFail(playButtonGesture)
    .maxDuration(350)

    .onStart(() => {
      console.log("点击");


    });
     <GestureDetector  gesture={Gesture.Race(onceTap)}>   
         <View>
           //其他组件
              <GestureDetector gesture={playButtonGesture}>   
                 <View>
                //按钮部分
                </View>
             </GestureDetector>
           //其他组件
         </View>
     </GestureDetector>
</>

通过嵌套GestureDetector给内部设置单独的手势并且使用requireExternalGestureToFail在外层排除内部手势,这样就做到了对内部按钮单独的点击响应

2.组件事件与原生冲突

就以@react-native-community/slider为例


import Slider from "@react-native-community/slider";
import { useState } from "react";
import { View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";

function Test() {
  const [currentTime, setCurrentTime] = useState(0);
  // 为Slider创建专用手势
  const sliderGesture = Gesture.Native();
  return (
    <>
    //1.包裹在内部
      <GestureDetector gesture={sliderGesture}>
        <View>
          <Slider
            minimumTrackTintColor={"rgba(102, 51, 255, 1)"}
            maximumTrackTintColor={"rgba(230, 230, 230, 1)"}
            thumbTintColor={"rgba(102, 51, 255, 1)"}
            minimumValue={0}
            maximumValue={200}
            value={currentTime}
            onTouchStart={() => {
              console.log("onTouchStart");
            }}
            onSlidingStart={(value) => {
              console.log(value, "onSlidingStart");
            }}
            onValueChange={(val) => {
              setCurrentTime(val);
              console.log("onValueChange");
            }}
            onSlidingComplete={() => {
              console.log("onSlidingComplete");
            }}
          />
        </View>
      </GestureDetector>
         //2.不包裹在内部没有问题
      <Slider
        minimumTrackTintColor={"rgba(102, 51, 255, 1)"}
        maximumTrackTintColor={"rgba(230, 230, 230, 1)"}
        thumbTintColor={"rgba(102, 51, 255, 1)"}
        minimumValue={0}
        maximumValue={200}
        value={currentTime}
        onTouchStart={() => {
          console.log("onTouchStart");
        }}
        onSlidingStart={(value) => {
          console.log(value, "onSlidingStart");
        }}
        onValueChange={(val) => {
          setCurrentTime(val);
          console.log("onValueChange");
        }}
        onSlidingComplete={() => {
          console.log("onSlidingComplete");
        }}
      />
    </>
  );
}

export default Test;

包裹在内部需要使用Gesture.Native();这样手势拦截后会把事件转移到Slider组件,这样就可以触发onSlidingComplete。

目前存在的问题就是Slider组件无法拖动,个人在寻找解决方案中,也寻找答案

当然,最能解决的方案就是不嵌套在GestureDetector内部!

Cesium & Three.js 【移动端手游“户外大逃杀”】 还在“画页面的”前端开发小伙伴们,是时候该“在往前走一走”了!我们必须摆脱“画页面的”标签!

作者 DaMu
2025年9月19日 09:27

Hello Survive

v0.1.0

Survive 介绍

Hi,😊 欢迎您品鉴 Survive “户外生存/大逃杀/建造”类型游戏,这是一款基于 Cesium & Enable3D 引擎架构的“户外生存/大逃杀/建造”类型游戏。如果您一直从事Web/GIS/移动端前端开发工作并且厌倦了传统页面开发,那么您不妨与 Survive 一起到户外探险游玩吧。

Survive 内容

Survive “户外生存/大逃杀/建造”类型游戏的出发点是作为从事Web/GIS/移动端前端开发人员推荐用于教学和非商业项目为目的。Survive 主要以 Geographic Information System 与 Game 的结合,让开发者在 Survive 游玩的过程中熟悉使用 GIS,涵盖平时开发人员开发过程中的一些常用方法逻辑并封装💻。内容包括如:毒雾区域模块,导航路径规划模块,空投物资模块,玩家生存状态(饥饿/饥渴/毒雾)模块,动画管理模块,镜头切换管理模块,物资刷新模块,经纬度管理模块,建造模块,寻宝模块,采掘工业模块,地图模块,定时任务模块,背包模块,实时定位玩家轨迹移动模块等。希望 Survive 可以作为您 GIS 入门的首选。

关于 ThreeJS & Enable3D

Survive 主要是以 Cesium & Enable3D 双场景交互进行游戏互动,Enable3D 在 Survive 里作为简单的游戏场景交互进行游戏互动,如果您需要复杂的 Enable3D 游戏场景进行交互的话可以来了解下我的另一个开源游戏项目 >>> Bullet zombie 传送门🚀🚀🚀

关于 Bullet zombie 和 Survive🔥🔥🔥

Bullet zombie 和 Survive 这两款开源游戏项目如果您都已有所了解并接触的话,那么“它哥俩儿”诞生初期的使命就结束了...“这哥俩儿”开源游戏项目主要是为了让前端开发人员抛开传统页面开发如何更好的“在往前走一走”。游戏项目涵盖了 Web/桌面/移动/嵌入式等多端服务端开发架构以及 GIS/ThreeJS/神经网络等多种技术相互结合。避免了想“在往前走一走”的前端开发人员面临“重学”的学习开发成本。Bullet zombie 和 Survive 在这里也非常感谢大家的支持与鼓励💖💖💖

Survive 素材

Survive 工具/库 🔗

Survive Core code 目录结构

app front end v0.1.0
📦 ReactNative
├── 📂 config
│
├── 📂 utils
│
├── 📄 App.tsx
│
├── 📄 package.json
└── 📄 README.md
backend v0.1.0
📦 Service
├── 📂 logs
├── 📂 src
│   ├── 📂 TemplateApi
│   │   ├── 📂 TemplateApiGameSocketRoom
│   │   │   └── 📄 TemplateApiGame.room.ts
│   │   ├── 📂 TemplateApiGateway
│   │   │   └── 📄 TemplateApiChat.gateway.ts
│   │   ├── 📄 TemplateApiPlugService
│   │   ├── 📄 TemplateApi.service.ts
│   │   └── 📄 TemplateApi.controller.ts
│   ├── 📂 utils
│   │   ├── 📄 calculateTimeRemaining.ts
│   │   ├── 📄 publishSubscrib.ts
│   │   ├── 📄 uploads.type.ts
│   │   └── 📄 winston.config.ts
│   ├── 📄 app.controller.ts
│   ├── 📄 app.module.ts
│   └── 📄 main.ts
├── 📂 static
│   ├── 📂 game
│   │    ├── 📂 ammo
│   │    ├── 📂 images
│   │    ├── 📂 json
│   │    ├── 📂 models
│   │    ├── 📄 Enable3DSceneModels.json
│   │    ├── 📄 GaemCoordinates.json
│   │    ├── 📄 PlayerInfo-dev.json
│   │    └── 📄 Timer.json
├── 📄 config.yml
├── 📄 Dockerfile
├── 📄 package.json
└── 📄 README.md

web front end v0.1.0

📂 Web
├── 📂 src
│   ├── 📂 components
│   │   └── 📄 index.js
│   ├── 📂 config
│   ├── 📂 utils
│   ├── 📂 views
│   │   └── 📂 cesium
│   │       └── 📄 index.vue
├── 📄 Dockerfile
├── 📄 package.json
└── 📄 README.md

Survive 流程时序

登录流程时序 v0.1.0

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

建造模块流程时序 v0.1.0

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Cesium & Enable3D 双场景交互流程时序 v0.1.0

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Survive 架构

架构 v0.1.0

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Survive 运行视频预览 🎬

传送门

游戏视频介绍

Survive 部分开发进度预览 📷

Survive Game UI System v0.1.0

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Survive Game Map System v0.1.0

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Survive Airdrop Box System v0.1.0

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Survive Enable3D Scene System v0.1.0

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Survive Real time positioning of player map movement System v0.1.0

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Survive backpack System v0.1.0

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Survive install

App
cd ReactNative
npm i
npm start

cat config/host.js
Service
cd Service
npm i
npm run start

cat config.yml
cat .env
cat .env.online
Web
cd Web
npm i
npm run dev

cat src/config/host.js
online App deploy
cd ReactNative/android

./gradlew assembleRelease
online Service deploy
cd Service
cat Dockerfile

# Linux Dockerfile
docker build -t survive-v0.1.0-online-1:v0.1.0 .
# develop
# docker run -itd --network=host --name sur-service survive-v0.1.0-online-1:v0.1.0
docker run -itd -p 1871:1871 --name sur-service survive-v0.1.0-online-1:v0.1.0
docker images
docker ps -a

# online config.yml
cat config.yml
online Web deploy
cd Web

npm run build


unzip dist.zip

🌟 模块

  • 毒雾区域 v0.1.0
  • 导航路径规划 v0.1.0
  • 空投物资 v0.1.0
  • 玩家生存状态(饥饿/饥渴/毒雾) v0.1.0
  • 动画管理 v0.1.0
  • 镜头切换管理 v0.1.0
  • 物资刷新 v0.1.0
  • 经纬度管理 v0.1.0
  • 建造 v0.1.0
  • 地图 v0.1.0
  • 定时任务 v0.1.0
  • 实时定位玩家轨迹移动 v0.1.0
  • 背包 v0.1.0
  • 食物 v0.1.0
  • 采掘工业 v0.1.0
  • 安全屋-仓库 v0.1.0
  • 寻宝 v0.1.0
  • 多人在线 v0.1.0
  • 方方面面的你这不得优化优化啊

💖 支持项目

如果这个项目对您有帮助,欢迎 StarFork!您的鼓励是我前进的动力,感谢您的认可!😊

js对象常用方法都在这,使用时想不到?不存在的

作者 向北者
2025年9月19日 08:31

本文详细解析JavaScript对象30个项目中大概率会用到的方法,按照使用场景结合功能,参数,返回值,使用方法,兼容性这几个方面一一刨析,一次搞懂,使用时按场景需求对应查看即可。

开篇前先说下属性描述符(挺重要,能辅助解决疑难杂症):

{
  value: any,          // 属性值
  writable: Boolean,   // 是否可修改
  enumerable: Boolean, // 是否可枚举
  configurable: Boolean, // 是否可删除或修改特性
  get: Function,       // getter函数
  set: Function        // setter函数
}
//需要注意的是,get 和 set 以及 value 和 writable 这两组是互斥的。也就是说,一个属性描述符不能同时包含 get、set 和 value、writable。如果设置了 get 和 set,就不能再设置 value 和 writable;反之,如果设置了 value 和 writable,就不能再设置 `get` 和 `set`

一. Object 构造函数方法

1.Object.assign()

  • 功能:将所有可枚举属性的值从一个或多个源对象复制到目标对象
  • 参数Object.assign(target, ...sources) 目标对象,一个或多个源对象
  • 返回值:修改后的目标对象
  • 示例
var obj1 = {a:1,b:2};
var obj2 = {c:3,d:4};
var obj3 = {e:5,f:6};
var obj4 = Object.assign({},obj1,obj2,obj3);
console.log(obj4);//{ a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 }
  • 兼容性:ES6 (ES2015) 引入,IE不支持

2.Object.create()

  • 功能:创建一个新对象,使用现有的对象来提供新创建对象的__proto__
  • 参数Object.create(proto[, propertiesObject]) 新对象的原型对象,可选的属性描述符对象
  • 返回值:新创建的对象
  • 示例
    var person = {
        name: '张三',
        age: 20,
        sex: '男'
    }
    var person1 = Object.create(person,{
        name:{
            value:'张三1',
            writable: true, 
            enumerable: true, 
            configurable: true
        }
    })
    console.log(person1.name);//张三1
    console.log(person1.age);//20 通过原型链访问
    console.log(person1.sex);//男 通过原型链访问
    console.log(person1.__proto__ === person);//true
    
  • 兼容性:ES5 引入,IE9+支持

3.Object.defineProperties()

  • 功能:直接在一个对象上定义新的属性或修改现有属性,并返回该对象
  • 参数Object.defineProperties(obj, props) 要修改的对象,包含属性描述符的对象
  • 返回值:修改后的对象
  • 示例
    //Object.defineProperties()是 JavaScript 中用于批量定义或修改对象属性的方法,与下面的Object.defineProperty()方法类似但支持同时操作多个属性
    
    // 可解决的问题
    // 1.批量属性控制:避免重复调用Object.defineProperty()
    // 2.配置集中化:统一管理相关属性的特性(如所有配置项不可删除)
    // 3.性能优化:减少多次属性定义的开销
    // 4.代码可读性:直观展示一组属性的关联性
    var person = {
        name: '张三',
        sex: '男'
    }
    Object.defineProperties(person,{
        aaa:{
            value:'45',
            writable: true, 
            enumerable: true, 
        },
        name:{
            value:'张三1',
            writable: true, 
            enumerable: true, 
            configurable: true
        }
    })
    for(let key in person){
        console.log(key,person[key]) // name 张三1  sex 男  aaa 45
    }
    
  • 兼容性:ES5 引入,IE9+支持

4.Object.defineProperty()

  • 功能:直接在一个对象上定义一个新属性,或修改一个对象的现有属性,并返回该对象
  • 参数Object.defineProperty(obj, prop, descriptor) 要定义属性的对象,要定义或修改的属性名称,属性描述符
  • 返回值:修改后的对象
  • 示例
    // 实现属性监听(Vue 2响应式原理简化版)
    var data = { name: '' };
    Object.defineProperty(data, 'name', {
      get() {
        console.log('获取name');
        return this._name;
      },
      set(val) {
        console.log('设置name:', val);
        this._name = val;
      }
    });
    data.name = 'Alice'; // 触发setter
    console.log(data.name); // 触发getter
    
    // 创建不可变常量
    var obj = {};
    Object.defineProperty(obj, 'num', {
      value: 3.1415,
      writable: false,
      enumerable: true
    });
    obj.num = 100; // 更改会失败(严格模式报错)
    console.log(obj.num); // 3.1415 //还是原来的值
    
    // 隐藏敏感属性
    var user = {};
    Object.defineProperty(user, 'password', {
      value: '123456',
      enumerable: false // 不会被for-in或Object.keys遍历
    });
    console.log(Object.keys(user)); // []
    
  • 兼容性:ES5 引入,IE8+支持

5.Object.entries()

  • 功能:返回一个给定对象自身可枚举属性的键值对数组
  • 参数Object.entries(obj) 要返回其可枚举属性键值对的对象
  • 返回值:二维数组,每个子数组为 [key, value] 形式
  • 示例
    const obj = { a: 1, b: 2, c: 3 };
    console.log(Object.entries(obj)); // [['a', 1], ['b', 2], ['c', 3]]
    // 非对象参数:原始类型(如字符串)会先被转为包装对象
    console.log(Object.entries('hi')); // [['0', 'h'], ['1', 'i']]
    
  • 兼容性:ES8 (ES2017) 引入,IE不支持

6.Object.freeze()

  • 功能:冻结一个对象,使其不能添加新属性、删除属性或修改现有属性
  • 参数Object.freeze(obj) 要冻结的对象
  • 返回值:被冻结的对象
  • 示例
    var obj = { a: 1 };
    Object.freeze(obj);
    obj.a = 22; // 严格模式下会抛出错误
    console.log(obj.a); // 1 不会被更改
     console.log(Object.isFrozen(obj)); // true
    
  • 兼容性:ES5 引入,IE9+支持

7.Object.fromEntries()

  • 功能:把键值对列表转换为一个对象
  • 参数Object.fromEntries(iterable) 可迭代对象,其元素为长度为2的数组
  • 返回值:新对象
  • 示例
    // Map 转对象
    var map = new Map([['name', 'Alice'], ['age', 25]]); 
    console.log(Object.fromEntries(map)); // { name: "Alice", age: 25 }
    
    // 数组数据处理
    var arr = [['aa', 10], ['bb', 20]];
    console.log(Object.fromEntries(arr)); // { aa: 10, bb: 20 }
    
    //对象属性转换
    var user = { firstName: 'aa', lastName: 'bb' };
    var renamed = Object.fromEntries(
      Object.entries(user).map(([key, val]) => [`_${key}`, val])
    );
    console.log(renamed); // { _firstName: "aa", _lastName: "bb" }
    
  • 兼容性:ES10 (ES2019) 引入,IE不支持

8.Object.getOwnPropertyDescriptor()

  • 功能:获取对象特定属性的描述符的方法
  • 参数Object.getOwnPropertyDescriptor(obj, prop) 需要查找其属性描述符的对象,要查找的属性名称
  • 返回值:包含所有属性描述符的对象(键为属性名,值为描述符对象)
  • 示例
    var obj = { age: 42, name:"李四"};
    var descriptor = Object.getOwnPropertyDescriptor(obj, 'age');
    console.log(descriptor); // { value: 42, writable: true, enumerable: true, configurable: true }
    
  • 兼容性:ES5+

9.Object.getOwnPropertyDescriptors()

  • 功能:获取对象所有自身属性的完整描述符的方法
  • 参数Object.getOwnPropertyDescriptors(obj) 任意对象
  • 返回值:包含所有属性描述符的对象
  • 示例
    var obj = { age:42, name:'张三'};
    var descriptor = Object.getOwnPropertyDescriptors(obj);
    console.log(descriptor);
    // {
    //     age: { value: 42, writable: true, enumerable: true, configurable: true },
    //     name: { value: '张三', writable: true, enumerable: true, configurable: true }
    // }
    
  • 兼容性:ES2017+

10.Object.getOwnPropertyNames()

  • 功能:获取所有自有属性名(包括不可枚举属性,如 length
  • 参数Object.getOwnPropertyNames(obj) 要获取属性名的对象
  • 返回值:包含所有自身属性名称的数组
  • 示例
    // 获取对象所有属性(含不可枚举)
    var obj = { a: 1, b: 2 };
    Object.defineProperty(obj, 'cc', {
        value: 'iscc',
        enumerable: false
    });
    var props = Object.getOwnPropertyNames(obj);
    console.log(props); // [ 'a', 'b', 'cc' ]
    
    //检测数组特殊属性
    var arr = ['a', 'b', 'c'];
    console.log(Object.getOwnPropertyNames(arr));//[ '0', '1', '2', 'length' ]
    
  • 兼容性:ES5+

11. Object.getOwnPropertySymbols()

  • 功能:返回一个给定对象自身的所有 Symbol 属性的数组
  • 参数Object.getOwnPropertySymbols(obj) 要获取 Symbol 属性的对象
  • 返回值:包含所有 Symbol 属性的数组
  • 示例
    var obj = {};
    var a = Symbol('a');
    var b = Symbol.for('b');
    obj[a] = 'localSymbol';
    obj[b] = 'globalSymbol';
    var objectSymbols = Object.getOwnPropertySymbols(obj);
    console.log(objectSymbols); // [Symbol(a), Symbol(b)]
    
  • 兼容性:ES6+

12.Object.getPrototypeOf()

  • 功能:返回指定对象的原型
  • 参数Object.getPrototypeOf(obj) 要获取原型的对象
  • 返回值:给定对象的原型
  • 示例
    var obj = {};
    var proto = Object.getPrototypeOf(obj);
    console.log(proto === Object.prototype); // true
    //检查对象继承关系
    function isArrayLike(obj) {
        let proto = Object.getPrototypeOf(obj);
        return proto === Array.prototype || proto === Object.prototype;
    }
    //框架开发(原型污染防护)
    function sanitizeObject(obj) {
        if (Object.getPrototypeOf(obj) !== Object.prototype) {
            throw new Error('Invalid prototype chain');
        }
    }
    
  • 兼容性:ES5+

13.Object.hasOwn()

  • 功能:判断对象自身是否具有指定的属性
  • 参数Object.hasOwn(obj, prop) 要测试的对象,要检查的属性名(字符串或 Symbol)
  • 返回值:布尔值
  • 示例
    var obj = { a: '1' };
    console.log(Object.hasOwn(obj, 'a')); // true
    console.log(Object.hasOwn(obj, 'toString')); // false
    // 安全检查对象属性
    function safeGet(obj, prop) {
      return Object.hasOwn(obj, prop) ? obj[prop] : undefined;
    }
    // 防御式编程(避免原型污染)
    function mergeSafe(target, source) {
      for (const key in source) {
        if (Object.hasOwn(source, key)) {
          target[key] = source[key];
        }
      }
    }
    
  • 兼容性:ES2022+

14.Object.hasOwnProperty()

  • 功能:判断对象自身是否具有指定的属性(不包括继承的属性)
  • 参数Object.hasOwnProperty(prop) 要检查的属性名(字符串或 Symbol)
  • 返回值:布尔值
  • 示例
    var obj = { a: 1 };
    console.log(obj.hasOwnProperty('a')); // true
    console.log(obj.hasOwnProperty('toString')); // false(继承属性)
    
    //过滤原型链属性
    function getOwnProps(obj) {
        return Object.keys(obj).filter(key => 
            obj.hasOwnProperty(key)
        );
    }
    //框架开发(属性白名单校验)
    class Model {
        constructor(data) {
            const allowedProps = ['id', 'name'];
            for (const key in data) {
                if (!allowedProps.includes(key) || !data.hasOwnProperty(key)) {
                    throw new Error(`Invalid property: ${key}`);
                }
            }
        }
    }
    
  • 兼容性:全平台支持

15.Object.is()

  • 功能:判断两个值是否为同一个值
  • 参数Object.is(value1, value2) 要比较的两个值
  • 返回值:布尔值
  • 示例
    // 1.精确相等比较:
    //   解决 === 的两个缺陷:
    //       NaN === NaN → false(错误)
    //       0 === -0 → true(可能不符合预期)
    // 2.替代 === 的特殊场景:
    //   需要严格区分 +0 和 -0
    //   需要明确判断 NaN
    console.log(Object.is(5, 5));          // true
    console.log(Object.is(NaN, NaN));      // true(与 === 不同)
    console.log(Object.is(0, -0));         // false(与 === 不同)
    console.log(Object.is('a', 'a'));      // true
    
  • 兼容性:ES2015+

16.Object.isExtensible()

  • 功能:判断一个对象是否是可扩展的(即能否添加新属性)
  • 参数Object.isExtensible(obj) 要检查的对象
  • 返回值:布尔值
  • 示例
    var obj = { a: 1 };
    console.log(Object.isExtensible(obj)); // true(默认可扩展)
    
    Object.preventExtensions(obj);
    console.log(Object.isExtensible(obj)); // false(锁定后不可扩展)
    
    // 尝试添加属性(严格模式报错)
    obj.b = 2; // 非严格模式默认失败,不会报错
    console.log(obj.b); // undefined
    
  • 兼容性:ES5+,IE9+

17.Object.isFrozen()

  • 功能:判断一个对象是否被冻结(即完全禁止修改属性)
  • 参数Object.isFrozen(obj) 要检查的对象
  • 返回值:布尔值
  • 示例
    var obj = { a: 1, nested: { b: 2 } };
    Object.freeze(obj);
    console.log(Object.isFrozen(obj)); // true(对象被冻结,不能删除或修改属性)
    console.log(Object.isFrozen(obj.nested)); // false(嵌套对象未冻结)
      //数据完整性检查
    const sharedData = { 
      userSettings: { 
         theme: "dark",
         language: "en" 
      } 
    };
    
    function processSharedData(data) {
      if (Object.isFrozen(data)) {
         console.log("数据已冻结,以只读方式处理");
         // 进行只读操作
      } else {
         console.log("数据可能被修改,谨慎处理");
      }
    }
    
    // 假设某个地方冻结了 sharedData
    Object.freeze(sharedData);
    processSharedData(sharedData); 
    
  • 兼容性:ES5+,IE9+

18.Object.isSealed()

  • 功能:判断一个对象是否被密封
  • 参数Object.isSealed(obj) 要检查的对象
  • 返回值:布尔值
  • 示例
       let normalObject = { a: 1 };
       console.log(Object.isSealed(normalObject)); 
       // 输出 false,因为普通对象是可扩展且属性可配置的
    
       // 使用 Object.seal() 方法密封对象
       let sealedObject = { b: 2 };
       Object.seal(sealedObject); 
       console.log(Object.isSealed(sealedObject)); 
       // 输出 true,该对象已被密封,不能添加新属性,不能删除现有属性,现有属性的 configurable 特性为 false
    
       // 创建一个冻结对象
       let frozenObject = { c: 3 };
       Object.freeze(frozenObject); 
       console.log(Object.isSealed(frozenObject)); 
       // 输出 true,冻结对象也是密封的,因为它不能添加新属性,不能删除现有属性,所有属性的 configurable 和 writable 特性都是 false
    
  • 兼容性:ES5+

19.Object.keys()

  • 功能:返回一个由一个给定对象的自身可枚举属性组成的数组
  • 参数Object.keys(obj) 要返回其可枚举属性名的对象
  • 返回值:包含对象所有可枚举属性名称的数组
  • 示例
    var obj = { a: 1, b: 2, c: 3 };
    console.log(Object.keys(obj)); // [ 'a', 'b', 'c' ]
    // 对象属性操作
    const person = {
      name: 'John',
      age: 30,
      city: 'New York',
      email: 'john@123.com'
    };
    
    // 获取属性数组并过滤出包含 "name" 的属性
    const filteredKeys = Object.keys(person).filter((key) => key.includes('name'));
    console.log(filteredKeys); // ['name']
    
  • 兼容性:ES5+

20.Object.preventExtensions()

  • 功能:让一个对象变的不可扩展,也就是永远不能再添加新的属性(用于防止一个对象再添加新的属性)
  • 参数Object.preventExtensions(obj) 要变得不可扩展的对象
  • 返回值:已经不可扩展的对象
  • 示例
    var myObject = { name: 'John', age: 30 };
    
    // 使对象不可扩展
    Object.preventExtensions(myObject);
    
    // 尝试添加新属性
    myObject.newProperty = 'New Value'; 
    
    // 在非严格模式下,上述操作不会报错,但新属性不会添加成功
    // 在严格模式下,会抛出 TypeError 错误
    console.log(myObject); // { name: 'John', age: 30 },新属性没有被添加
    
  • 兼容性:ES5+,IE9+

21.Object.seal()

  • 功能:密封一个对象,阻止添加新属性并将所有现有属性标记为不可配置
  • 参数Object.seal(obj) 要被密封的对象
  • 返回值:被密封的对象
  • 示例
    // 此方法可用于解决:1.确保对象结构稳定(防止意外修改)2.隐藏实现细节(防御性编程)3.提升性能
    
    var myObject = {
      name: 'John',
      age: 30
    };
    
    // 密封对象
    var sealedObject = Object.seal(myObject);
    
    // 尝试添加新属性,不会成功
    sealedObject.newProperty = 'New Value'; 
    console.log(sealedObject.newProperty); // undefined
    
    // 尝试删除现有属性,不会成功
    delete sealedObject.age; 
    console.log(sealedObject.age); // 30
    
    // 可以修改现有属性的值
    sealedObject.age = 31; 
    console.log(sealedObject.age); // 31
    
  • 兼容性:ES5+

22.Object.setPrototypeOf()

  • 功能:设置一个指定对象的原型到另一个对象或null(即 [[Prototype]] 内部属性)
  • 参数Object.setPrototypeOf(obj, prototype) 要设置其原型的对象,该对象的新原型
  • 返回值:修改后的对象
  • 示例
    // 创建一个原型对象
    var animalPrototype = {
      speak() {
         console.log('I am an animal');
      }
    };
    // 创建一个普通对象
    var dog = {
      name: 'Buddy'
    };
    // 使用 Object.setPrototypeOf 设置 dog 对象的原型
    Object.setPrototypeOf(dog, animalPrototype);
    // 现在 dog 对象可以调用原型的方法
    dog.speak(); // I am an animal
    
    // 修改对象的行为
    var scenarioAPrototype = {
      performAction() {
         console.log('Performing action in scenario A');
      }
    };
    
    // 业务场景 B 的原型
    var scenarioBPrototype = {
      performAction() {
         console.log('Performing action in scenario B');
      }
    };
    
    // 一个普通对象
    var myObject = {};
    
    // 设置为场景 A 的行为
    Object.setPrototypeOf(myObject, scenarioAPrototype);
    myObject.performAction(); // Performing action in scenario A
    
    // 动态切换到场景 B 的行为
    Object.setPrototypeOf(myObject, scenarioBPrototype);
    myObject.performAction(); // Performing action in scenario B
    
  • 兼容性:ES6+

23.Object.values()

  • 功能:返回一个给定对象自身的所有可枚举属性值的数组
  • 参数Object.values(obj) 要返回其可枚举属性值的对象
  • 返回值:包含对象所有可枚举属性值的数组
  • 示例
    var obj = { a: 1, b: 2, c: 3 };
    console.log(Object.values(obj)); // [1, 2, 3]
    // 遍历对象值
    var prices = { apple: 1.5, banana: 0.5, orange: 2 };
    var totalPrice = Object.values(prices).reduce((acc, price) => acc + price, 0);
    console.log(totalPrice); // 输出: 4
    // 数据结构转换
    var data = { series1: [10, 20, 30], series2: [15, 25, 35] }; 
    var valuesArray = Object.values(data); // 此时 valuesArray 可以直接用于图表绘制的数据输入 
    console.log(valuesArray); // 输出: [[10, 20, 30], [15, 25, 35]]
    
  • 兼容性:ES2017+

二. 实例方法

24.obj.hasOwnProperty()

  • 功能:判断对象自身是否具有指定的属性
  • 参数obj.hasOwnProperty(prop) 要检查的属性名
  • 返回值:布尔值
  • 示例
    var obj = { a: '1' };
    console.log(obj.hasOwnProperty('a')); // true
    console.log(obj.hasOwnProperty('b')); // false
    
  • 兼容性:所有版本

25.obj.isPrototypeOf()

  • 功能:判断调用对象是否在另一个对象的原型链上
  • 参数obj.isPrototypeOf(obj) 要检查的对象
  • 返回值:布尔值
  • 示例
    const animal = {
      speak() {
         console.log('I can speak');
      }
    };
    const dog = {
      bark() {
         console.log('Woof!');
      }
    };
    // 设置 dog 的原型为 animal
    Object.setPrototypeOf(dog, animal); 
    console.log(animal.isPrototypeOf(dog)); // true,因为 animal 是 dog 的原型对象
    
  • 兼容性:所有版本

26.obj.propertyIsEnumerable()

  • 功能:判断一个对象自身属性(非继承属性)是否可枚举
  • 参数obj.propertyIsEnumerable(prop) 要检查的属性名
  • 返回值:布尔值
  • 示例
    var myObj = {
      foo: 'bar',
      baz: 42
    };
    
    // 检查 'foo' 属性是否可枚举
    console.log(myObj.propertyIsEnumerable('foo')); // true
    
    // 创建一个不可枚举的属性
    Object.defineProperty(myObj, 'qux', {
      value: 'quux',
      enumerable: false
    });
    
    // 检查 'qux' 属性是否可枚举
    console.log(myObj.propertyIsEnumerable('qux')); // false
    
    // 检查继承的属性,例如 'toString'
    console.log(myObj.propertyIsEnumerable('toString')); // false
    
  • 兼容性:所有版本

27.obj.toLocaleString()

  • 功能:返回对象的字符串表示,该字符串与执行环境的地区对应
  • 参数:无
  • 返回值:对象的字符串表示
  • 示例
    // 1. 数组的 toLocaleString() 方法
    var fruits = ['apple', 'banana', 'cherry'];
    var result = fruits.toLocaleString();
    console.log(result); // 输出类似 apple,banana,cherry 分隔符取决于本地环境
    // 2. 日期的 toLocaleString() 方法
    var now = new Date();
    var dateString = now.toLocaleString();
    console.log(dateString); // 输出类似 2023/10/31 15:30:00,格式取决于本地环境
    
    // 3.数字的 toLocaleString() 方法
    var number = 1234567.89;
    var formattedNumber = number.toLocaleString();
    console.log(formattedNumber);  // 输出类似 1,234,567.89,分隔符取决于本地环境
    
  • 兼容性:所有版本

28.obj.toString()

  • 功能:返回对象的字符串表示
  • 参数:无
  • 返回值:对象的字符串表示
  • 示例
    // 普通对象
    var person = { name: 'John', age: 30 };
    console.log(person.toString()); // [object Object]
    
    // 数组对象
    var arr = [1, 2, 3];
    console.log(arr.toString()); // 1,2,3
    
    // 日期对象
    var date = new Date();
    console.log(date.toString()); // Wed Sep 20 2025 11:40:15 GMT+0800 (中国标准时间)
    
  • 兼容性:所有版本

29.obj.valueOf()

  • 功能:返回对象的原始值
  • 参数:无
  • 返回值:对象的原始值
  • 示例
    // Number 对象
    var numObj = new Number(10);
    console.log(numObj.valueOf()); // 10
    // String 对象
    var strObj = new String('Hello');
    console.log(strObj.valueOf()); // Hello
    // Boolean 对象
    var boolObj = new Boolean(true);
    console.log(boolObj.valueOf());// true
    // Date 对象
    var dateObj = new Date();
    console.log(dateObj.valueOf()); // 1695890000000
    // Array 
    var arrObj = [1, 2, 3];
    console.log(arrObj.valueOf()); // [1, 2, 3]
    // Object
    var obj = { name: 'John', age: 30 };
    console.log(obj.valueOf());// { name: 'John', age: 30 }
    
  • 兼容性:所有版本

三. 静态属性

30.Object.prototype

  • 功能Object.prototype 并不是一个方法,而是一个对象。它是所有对象的原型,几乎所有 JavaScript 对象都继承自 Object.prototype。这意味着所有对象都可以访问 Object.prototype 上定义的属性和方法
  • 示例
    // 所有对象都可以访问 Object.prototype 上的属性和方法。例如 toString 方法:
    var myObject = {name: 'Alice'};
    console.log(myObject.toString()); // [object Object] 返回一个描述对象的字符串
    
    // 在对象中覆盖原型方法(以自定义 toString 方法为例)
    var newObject = {
    toString: function() {
      return 'This is a custom toString for newObject';
    }
    };
    console.log(newObject.toString()); // This is a custom toString for newObject
    
    // 添加自定义方法到 Object.prototype(不推荐常规使用,但了解其原理)
    Object.prototype.customMethod = function() {
     return 'This is a custom method added to Object.prototype';
    };
    var testObject = {};
    console.log(testObject.customMethod()); // This is a custom method added to Object.prototype
    
  • 兼容性:所有版本

要想写出优雅的通用方法,以上这些方法是相当主要,它涵盖了 JavaScript 对象操作的基本上全部的主要功能,从基本的属性访问到高级的对象控制,每个方法都有其特定的用途和适用场景,可以根据项目需求结合兼容性选择性使用。

Flutter本地通知系统:记账提醒的深度实现

作者 全栈阿笑
2025年9月19日 08:31

Flutter本地通知系统:记账提醒的深度实现

本文基于BeeCount(蜜蜂记账)项目的实际开发经验,深入探讨如何构建可靠的本地通知提醒系统,涵盖Android精确闹钟、电池优化处理等高级特性。

项目背景

BeeCount(蜜蜂记账)是一款开源、简洁、无广告的个人记账应用。所有财务数据完全由用户掌控,支持本地存储和可选的云端同步,确保数据绝对安全。

引言

良好的记账习惯需要持续的提醒和督促。现代移动设备的电池管理策略越来越严格,如何确保通知在各种系统限制下依然可靠送达,成为了移动应用开发的重要挑战。BeeCount通过深度的系统集成和优化策略,实现了高可靠性的记账提醒功能。

通知系统架构

整体架构设计

┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│   Flutter UI    │    │ Notification     │    │   Android       │
│   (Settings)    │◄──►│ Service Layer    │◄──►│   Native Layer  │
│                 │    │                  │    │                 │
└─────────────────┘    └──────────────────┘    └─────────────────┘
         │                       │                       │
         └───── 用户配置 ─────────┼───── 定时调度 ────────┘
                                │
                    ┌──────────────────┐
                    │   SQLite         │
                    │   (提醒记录)      │
                    └──────────────────┘

核心设计原则

  1. 系统兼容性:适配Android 6.0-14的电池优化策略
  2. 精确调度:使用AlarmManager确保定时准确
  3. 持久化存储:提醒配置和历史记录本地化存储
  4. 用户体验:智能权限引导和状态反馈
  5. 资源优化:最小化系统资源占用

通知服务核心实现

服务接口定义

abstract class NotificationService {
  /// 初始化通知服务
  Future<bool> initialize();

  /// 调度通知
  Future<bool> scheduleNotification({
    required int id,
    required String title,
    required String body,
    required DateTime scheduledTime,
  });

  /// 取消通知
  Future<bool> cancelNotification(int id);

  /// 取消所有通知
  Future<bool> cancelAllNotifications();

  /// 检查通知权限
  Future<bool> hasNotificationPermission();

  /// 请求通知权限
  Future<bool> requestNotificationPermission();

  /// 检查电池优化状态
  Future<bool> isBatteryOptimizationIgnored();

  /// 请求忽略电池优化
  Future<bool> requestIgnoreBatteryOptimization();
}

通知服务实现

class FlutterNotificationService implements NotificationService {
  static const MethodChannel _channel = MethodChannel('com.example.beecount/notification');
  final FlutterLocalNotificationsPlugin _plugin = FlutterLocalNotificationsPlugin();

  static const String _channelId = 'accounting_reminder';
  static const String _channelName = '记账提醒';
  static const String _channelDescription = '定时提醒用户记账';

  @override
  Future<bool> initialize() async {
    try {
      // Android通知渠道配置
      const androidInitSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
      const initSettings = InitializationSettings(android: androidInitSettings);

      await _plugin.initialize(
        initSettings,
        onDidReceiveNotificationResponse: _onNotificationTapped,
      );

      // 创建通知渠道
      await _createNotificationChannel();

      logI('NotificationService', '✅ 通知服务初始化成功');
      return true;
    } catch (e) {
      logE('NotificationService', '❌ 通知服务初始化失败', e);
      return false;
    }
  }

  Future<void> _createNotificationChannel() async {
    const androidChannel = AndroidNotificationChannel(
      _channelId,
      _channelName,
      description: _channelDescription,
      importance: Importance.high,
      priority: Priority.high,
      enableVibration: true,
      enableLights: true,
      ledColor: Color(0xFF2196F3),
      sound: RawResourceAndroidNotificationSound('notification_sound'),
    );

    await _plugin
        .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
        ?.createNotificationChannel(androidChannel);
  }

  @override
  Future<bool> scheduleNotification({
    required int id,
    required String title,
    required String body,
    required DateTime scheduledTime,
  }) async {
    try {
      // 检查权限状态
      if (!await hasNotificationPermission()) {
        logW('NotificationService', '⚠️ 缺少通知权限,无法调度通知');
        return false;
      }

      // 使用原生Android AlarmManager进行精确调度
      final result = await _channel.invokeMethod('scheduleNotification', {
        'title': title,
        'body': body,
        'scheduledTimeMillis': scheduledTime.millisecondsSinceEpoch,
        'notificationId': id,
      });

      if (result == true) {
        logI('NotificationService', '📅 通知调度成功: $id at ${scheduledTime.toString()}');
        return true;
      } else {
        logE('NotificationService', '❌ 通知调度失败: $id');
        return false;
      }
    } catch (e) {
      logE('NotificationService', '❌ 调度通知异常', e);
      return false;
    }
  }

  @override
  Future<bool> cancelNotification(int id) async {
    try {
      await _channel.invokeMethod('cancelNotification', {'notificationId': id});
      await _plugin.cancel(id);

      logI('NotificationService', '🗑️ 取消通知: $id');
      return true;
    } catch (e) {
      logE('NotificationService', '❌ 取消通知失败', e);
      return false;
    }
  }

  @override
  Future<bool> hasNotificationPermission() async {
    try {
      final result = await _plugin
          .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
          ?.areNotificationsEnabled();

      return result ?? false;
    } catch (e) {
      logE('NotificationService', '❌ 检查通知权限失败', e);
      return false;
    }
  }

  @override
  Future<bool> isBatteryOptimizationIgnored() async {
    try {
      final result = await _channel.invokeMethod('isIgnoringBatteryOptimizations');
      return result == true;
    } catch (e) {
      logE('NotificationService', '❌ 检查电池优化状态失败', e);
      return false;
    }
  }

  @override
  Future<bool> requestIgnoreBatteryOptimization() async {
    try {
      await _channel.invokeMethod('requestIgnoreBatteryOptimizations');
      return true;
    } catch (e) {
      logE('NotificationService', '❌ 请求电池优化豁免失败', e);
      return false;
    }
  }

  void _onNotificationTapped(NotificationResponse response) {
    logI('NotificationService', '👆 用户点击通知: ${response.id}');

    // 通知点击事件可以用于打开特定页面或执行特定操作
    // 例如直接跳转到记账页面
    _handleNotificationAction(response);
  }

  void _handleNotificationAction(NotificationResponse response) {
    // 处理通知点击逻辑
    // 可以通过路由或事件总线通知应用
    NotificationClickEvent(
      notificationId: response.id ?? 0,
      payload: response.payload,
    ).fire();
  }
}

Android原生集成

MainActivity通知方法实现

class MainActivity : FlutterActivity() {
    private val CHANNEL = "com.example.beecount/notification"
    private lateinit var notificationManager: NotificationManager
    private lateinit var alarmManager: AlarmManager

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager

        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
            .setMethodCallHandler { call, result ->
                when (call.method) {
                    "scheduleNotification" -> {
                        val title = call.argument<String>("title") ?: "记账提醒"
                        val body = call.argument<String>("body") ?: "别忘了记录今天的收支哦 💰"
                        val scheduledTimeMillis = call.argument<Long>("scheduledTimeMillis") ?: 0
                        val notificationId = call.argument<Int>("notificationId") ?: 1001

                        scheduleNotification(title, body, scheduledTimeMillis, notificationId)
                        result.success(true)
                    }
                    "cancelNotification" -> {
                        val notificationId = call.argument<Int>("notificationId") ?: 1001
                        cancelNotification(notificationId)
                        result.success(true)
                    }
                    "isIgnoringBatteryOptimizations" -> {
                        result.success(isIgnoringBatteryOptimizations())
                    }
                    "requestIgnoreBatteryOptimizations" -> {
                        requestIgnoreBatteryOptimizations()
                        result.success(true)
                    }
                    else -> result.notImplemented()
                }
            }
    }

    private fun scheduleNotification(title: String, body: String, scheduledTimeMillis: Long, notificationId: Int) {
        try {
            android.util.Log.d("MainActivity", "📅 调度通知: ID=$notificationId, 时间=$scheduledTimeMillis")

            // 检查精确闹钟权限 (Android 12+)
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                if (!alarmManager.canScheduleExactAlarms()) {
                    android.util.Log.w("MainActivity", "⚠️ 没有精确闹钟权限,尝试请求权限")
                    try {
                        val intent = Intent(android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
                        startActivity(intent)
                    } catch (e: Exception) {
                        android.util.Log.e("MainActivity", "无法打开精确闹钟权限设置: $e")
                    }
                    return
                }
            }

            // 计算时间差用于调试
            val currentTime = System.currentTimeMillis()
            val timeDiff = scheduledTimeMillis - currentTime
            android.util.Log.d("MainActivity", "当前时间: $currentTime")
            android.util.Log.d("MainActivity", "调度时间: $scheduledTimeMillis")
            android.util.Log.d("MainActivity", "时间差: ${timeDiff / 1000}秒")

            if (timeDiff <= 0) {
                android.util.Log.w("MainActivity", "⚠️ 调度时间已过期,将调度到明天同一时间")
                // 自动调整到第二天同一时间
                val tomorrow = scheduledTimeMillis + 24 * 60 * 60 * 1000
                scheduleNotification(title, body, tomorrow, notificationId)
                return
            }

            // 创建PendingIntent
            val intent = Intent(this, NotificationReceiver::class.java).apply {
                putExtra("title", title)
                putExtra("body", body)
                putExtra("notificationId", notificationId)
                action = "${packageName}.NOTIFICATION_ALARM"
            }

            val pendingIntent = PendingIntent.getBroadcast(
                this,
                notificationId,
                intent,
                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
            )

            // 使用精确闹钟调度
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                alarmManager.setExactAndAllowWhileIdle(
                    AlarmManager.RTC_WAKEUP,
                    scheduledTimeMillis,
                    pendingIntent
                )
            } else {
                alarmManager.setExact(
                    AlarmManager.RTC_WAKEUP,
                    scheduledTimeMillis,
                    pendingIntent
                )
            }

            android.util.Log.d("MainActivity", "✅ 通知调度成功: ID=$notificationId")
        } catch (e: Exception) {
            android.util.Log.e("MainActivity", "❌ 调度通知失败: $e")
        }
    }

    private fun cancelNotification(notificationId: Int) {
        try {
            // 取消AlarmManager中的定时任务
            val intent = Intent(this, NotificationReceiver::class.java)
            val pendingIntent = PendingIntent.getBroadcast(
                this,
                notificationId,
                intent,
                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
            )
            alarmManager.cancel(pendingIntent)

            // 取消已显示的通知
            notificationManager.cancel(notificationId)

            android.util.Log.d("MainActivity", "🗑️ 通知已取消: ID=$notificationId")
        } catch (e: Exception) {
            android.util.Log.e("MainActivity", "❌ 取消通知失败: $e")
        }
    }

    private fun isIgnoringBatteryOptimizations(): Boolean {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
            powerManager.isIgnoringBatteryOptimizations(packageName)
        } else {
            true // Android 6.0以下版本无电池优化
        }
    }

    private fun requestIgnoreBatteryOptimizations() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
            if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
                val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
                    data = Uri.parse("package:$packageName")
                }
                try {
                    startActivity(intent)
                } catch (e: Exception) {
                    // 如果无法打开请求页面,则打开应用设置
                    openAppSettings()
                }
            }
        }
    }

    private fun openAppSettings() {
        val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
            data = Uri.parse("package:$packageName")
        }
        startActivity(intent)
    }
}

BroadcastReceiver实现

class NotificationReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        android.util.Log.d("NotificationReceiver", "📨 收到广播: ${intent.action}")

        when (intent.action) {
            "${context.packageName}.NOTIFICATION_ALARM" -> {
                showNotification(context, intent)
            }
            Intent.ACTION_BOOT_COMPLETED,
            Intent.ACTION_MY_PACKAGE_REPLACED,
            Intent.ACTION_PACKAGE_REPLACED -> {
                android.util.Log.d("NotificationReceiver", "🔄 系统启动或应用更新,重新调度通知")
                rescheduleNotifications(context)
            }
        }
    }

    private fun showNotification(context: Context, intent: Intent) {
        try {
            val title = intent.getStringExtra("title") ?: "记账提醒"
            val body = intent.getStringExtra("body") ?: "别忘了记录今天的收支哦 💰"
            val notificationId = intent.getIntExtra("notificationId", 1001)

            android.util.Log.d("NotificationReceiver", "📢 显示通知: $title")

            val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

            // 创建点击Intent
            val clickIntent = Intent(context, NotificationClickReceiver::class.java).apply {
                putExtra("notificationId", notificationId)
                action = "${context.packageName}.NOTIFICATION_CLICK"
            }

            val clickPendingIntent = PendingIntent.getBroadcast(
                context,
                notificationId,
                clickIntent,
                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
            )

            // 构建通知
            val notification = NotificationCompat.Builder(context, "accounting_reminder")
                .setSmallIcon(R.mipmap.ic_launcher)
                .setContentTitle(title)
                .setContentText(body)
                .setStyle(NotificationCompat.BigTextStyle().bigText(body))
                .setPriority(NotificationCompat.PRIORITY_HIGH)
                .setDefaults(NotificationCompat.DEFAULT_ALL)
                .setAutoCancel(true)
                .setContentIntent(clickPendingIntent)
                .build()

            notificationManager.notify(notificationId, notification)

            // 自动重新调度下一次提醒(如果是重复提醒)
            rescheduleNextNotification(context, notificationId)

        } catch (e: Exception) {
            android.util.Log.e("NotificationReceiver", "❌ 显示通知失败: $e")
        }
    }

    private fun rescheduleNextNotification(context: Context, notificationId: Int) {
        // 这里可以根据用户设置重新调度下一次提醒
        // 例如每日提醒会自动调度到明天同一时间
        try {
            // 通过SharedPreferences或数据库获取用户的提醒设置
            val sharedPrefs = context.getSharedPreferences("notification_settings", Context.MODE_PRIVATE)
            val isRepeating = sharedPrefs.getBoolean("is_repeating_$notificationId", false)

            if (isRepeating) {
                android.util.Log.d("NotificationReceiver", "🔄 重新调度重复提醒: $notificationId")
                // 通知Flutter层重新调度
                // 这里可以通过本地广播或其他方式通知Flutter
            }
        } catch (e: Exception) {
            android.util.Log.e("NotificationReceiver", "❌ 重新调度失败: $e")
        }
    }

    private fun rescheduleNotifications(context: Context) {
        // 系统启动后重新调度所有通知
        // 实际实现中,这里应该从数据库读取所有活跃的提醒设置
        android.util.Log.d("NotificationReceiver", "📅 重新调度所有通知")

        try {
            // 发送广播给Flutter,让其重新调度所有通知
            val intent = Intent("com.example.beecount.RESCHEDULE_NOTIFICATIONS")
            context.sendBroadcast(intent)
        } catch (e: Exception) {
            android.util.Log.e("NotificationReceiver", "❌ 重新调度广播发送失败: $e")
        }
    }
}

class NotificationClickReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val notificationId = intent.getIntExtra("notificationId", 0)
        android.util.Log.d("NotificationClickReceiver", "👆 通知被点击: $notificationId")

        try {
            // 启动应用主界面
            val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
            if (launchIntent != null) {
                launchIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
                launchIntent.putExtra("notification_clicked", true)
                launchIntent.putExtra("notification_id", notificationId)
                context.startActivity(launchIntent)
            }
        } catch (e: Exception) {
            android.util.Log.e("NotificationClickReceiver", "❌ 启动应用失败: $e")
        }
    }
}

权限管理系统

权限检查和引导

class PermissionGuideService {
  final NotificationService _notificationService;

  PermissionGuideService(this._notificationService);

  /// 检查所有必需的权限
  Future<PermissionStatus> checkAllPermissions() async {
    final permissions = <PermissionType, bool>{};

    // 检查通知权限
    permissions[PermissionType.notification] =
        await _notificationService.hasNotificationPermission();

    // 检查电池优化豁免
    permissions[PermissionType.batteryOptimization] =
        await _notificationService.isBatteryOptimizationIgnored();

    return PermissionStatus(permissions: permissions);
  }

  /// 引导用户完成权限设置
  Future<bool> guideUserThroughPermissions(BuildContext context) async {
    final status = await checkAllPermissions();

    if (status.isAllGranted) {
      return true;
    }

    return await _showPermissionGuideDialog(context, status);
  }

  Future<bool> _showPermissionGuideDialog(
    BuildContext context,
    PermissionStatus status
  ) async {
    final steps = <PermissionStep>[];

    if (!status.hasNotificationPermission) {
      steps.add(PermissionStep(
        type: PermissionType.notification,
        title: '开启通知权限',
        description: '允许应用发送记账提醒通知',
        icon: Icons.notifications,
        action: () => _notificationService.requestNotificationPermission(),
      ));
    }

    if (!status.isBatteryOptimizationIgnored) {
      steps.add(PermissionStep(
        type: PermissionType.batteryOptimization,
        title: '关闭电池优化',
        description: '确保提醒能够准时送达',
        icon: Icons.battery_saver,
        action: () => _notificationService.requestIgnoreBatteryOptimization(),
      ));
    }

    return await showDialog<bool>(
      context: context,
      barrierDismissible: false,
      builder: (context) => PermissionGuideDialog(steps: steps),
    ) ?? false;
  }
}

class PermissionGuideDialog extends StatefulWidget {
  final List<PermissionStep> steps;

  const PermissionGuideDialog({Key? key, required this.steps}) : super(key: key);

  @override
  State<PermissionGuideDialog> createState() => _PermissionGuideDialogState();
}

class _PermissionGuideDialogState extends State<PermissionGuideDialog> {
  int currentStep = 0;
  Set<int> completedSteps = {};

  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      title: Row(
        children: [
          Icon(Icons.security, color: Theme.of(context).primaryColor),
          const SizedBox(width: 12),
          const Text('权限设置'),
        ],
      ),
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(
            '为了确保记账提醒正常工作,需要您授予以下权限:',
            style: Theme.of(context).textTheme.bodyMedium,
          ),
          const SizedBox(height: 16),

          // 权限步骤列表
          ...widget.steps.asMap().entries.map((entry) {
            final index = entry.key;
            final step = entry.value;
            final isCompleted = completedSteps.contains(index);
            final isCurrent = currentStep == index;

            return _buildPermissionStep(step, index, isCompleted, isCurrent);
          }),

          if (currentStep < widget.steps.length) ...[
            const SizedBox(height: 20),
            Text(
              '当前步骤 ${currentStep + 1}/${widget.steps.length}',
              style: Theme.of(context).textTheme.bodySmall,
            ),
            const SizedBox(height: 8),
            LinearProgressIndicator(
              value: (currentStep + completedSteps.length) / widget.steps.length,
            ),
          ],
        ],
      ),
      actions: [
        if (currentStep < widget.steps.length) ...[
          TextButton(
            onPressed: _skipCurrentStep,
            child: const Text('跳过'),
          ),
          ElevatedButton(
            onPressed: _executeCurrentStep,
            child: Text('去设置'),
          ),
        ] else ...[
          TextButton(
            onPressed: () => Navigator.of(context).pop(false),
            child: const Text('稍后设置'),
          ),
          ElevatedButton(
            onPressed: () => Navigator.of(context).pop(true),
            child: const Text('完成'),
          ),
        ],
      ],
    );
  }

  Widget _buildPermissionStep(
    PermissionStep step,
    int index,
    bool isCompleted,
    bool isCurrent
  ) {
    return Container(
      margin: const EdgeInsets.symmetric(vertical: 8),
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: isCurrent
            ? Theme.of(context).primaryColor.withOpacity(0.1)
            : isCompleted
                ? Colors.green.withOpacity(0.1)
                : Colors.grey.withOpacity(0.05),
        borderRadius: BorderRadius.circular(8),
        border: Border.all(
          color: isCurrent
              ? Theme.of(context).primaryColor
              : isCompleted
                  ? Colors.green
                  : Colors.grey.shade300,
        ),
      ),
      child: Row(
        children: [
          CircleAvatar(
            radius: 20,
            backgroundColor: isCompleted
                ? Colors.green
                : isCurrent
                    ? Theme.of(context).primaryColor
                    : Colors.grey,
            child: Icon(
              isCompleted ? Icons.check : step.icon,
              color: Colors.white,
            ),
          ),
          const SizedBox(width: 16),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  step.title,
                  style: Theme.of(context).textTheme.titleSmall?.copyWith(
                    fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal,
                  ),
                ),
                const SizedBox(height: 4),
                Text(
                  step.description,
                  style: Theme.of(context).textTheme.bodySmall?.copyWith(
                    color: Colors.grey[600],
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  void _executeCurrentStep() async {
    if (currentStep >= widget.steps.length) return;

    final step = widget.steps[currentStep];
    final success = await step.action();

    if (success) {
      setState(() {
        completedSteps.add(currentStep);
        currentStep++;
      });
    } else {
      // 显示错误提示
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('设置${step.title}失败,请手动前往系统设置')),
        );
      }
    }
  }

  void _skipCurrentStep() {
    setState(() {
      currentStep++;
    });
  }
}

提醒配置管理

提醒设置数据模型

@JsonSerializable()
class ReminderSettings {
  final int id;
  final bool isEnabled;
  final TimeOfDay time;
  final List<int> weekdays; // 1-7, 1=Monday
  final String title;
  final String message;
  final bool isRepeating;
  final DateTime? nextScheduledTime;

  const ReminderSettings({
    required this.id,
    required this.isEnabled,
    required this.time,
    required this.weekdays,
    required this.title,
    required this.message,
    required this.isRepeating,
    this.nextScheduledTime,
  });

  factory ReminderSettings.fromJson(Map<String, dynamic> json) =>
      _$ReminderSettingsFromJson(json);

  Map<String, dynamic> toJson() => _$ReminderSettingsToJson(this);

  ReminderSettings copyWith({
    int? id,
    bool? isEnabled,
    TimeOfDay? time,
    List<int>? weekdays,
    String? title,
    String? message,
    bool? isRepeating,
    DateTime? nextScheduledTime,
  }) {
    return ReminderSettings(
      id: id ?? this.id,
      isEnabled: isEnabled ?? this.isEnabled,
      time: time ?? this.time,
      weekdays: weekdays ?? this.weekdays,
      title: title ?? this.title,
      message: message ?? this.message,
      isRepeating: isRepeating ?? this.isRepeating,
      nextScheduledTime: nextScheduledTime ?? this.nextScheduledTime,
    );
  }

  /// 计算下一次提醒时间
  DateTime? calculateNextScheduledTime() {
    if (!isEnabled || weekdays.isEmpty) {
      return null;
    }

    final now = DateTime.now();
    final todayWeekday = now.weekday;
    final reminderToday = DateTime(
      now.year,
      now.month,
      now.day,
      time.hour,
      time.minute,
    );

    // 如果今天在提醒日期列表中,且还没过时间,就是今天
    if (weekdays.contains(todayWeekday) && reminderToday.isAfter(now)) {
      return reminderToday;
    }

    // 否则查找下一个提醒日期
    for (int i = 1; i <= 7; i++) {
      final nextDay = now.add(Duration(days: i));
      final nextWeekday = nextDay.weekday;

      if (weekdays.contains(nextWeekday)) {
        return DateTime(
          nextDay.year,
          nextDay.month,
          nextDay.day,
          time.hour,
          time.minute,
        );
      }
    }

    return null;
  }

  /// 是否需要重新调度
  bool needsReschedule() {
    final nextTime = calculateNextScheduledTime();
    return nextTime != nextScheduledTime;
  }
}

提醒管理服务

class ReminderManagerService {
  final NotificationService _notificationService;
  final SharedPreferences _prefs;
  static const String _settingsKey = 'reminder_settings';

  ReminderManagerService({
    required NotificationService notificationService,
    required SharedPreferences prefs,
  })  : _notificationService = notificationService,
        _prefs = prefs;

  /// 获取所有提醒设置
  List<ReminderSettings> getAllReminders() {
    final settingsJson = _prefs.getStringList(_settingsKey) ?? [];
    return settingsJson
        .map((json) => ReminderSettings.fromJson(jsonDecode(json)))
        .toList();
  }

  /// 保存提醒设置
  Future<bool> saveReminder(ReminderSettings settings) async {
    try {
      final allSettings = getAllReminders();
      final index = allSettings.indexWhere((s) => s.id == settings.id);

      if (index >= 0) {
        allSettings[index] = settings;
      } else {
        allSettings.add(settings);
      }

      await _saveAllReminders(allSettings);

      // 重新调度通知
      await _scheduleReminder(settings);

      logI('ReminderManager', '✅ 提醒设置已保存: ${settings.title}');
      return true;
    } catch (e) {
      logE('ReminderManager', '❌ 保存提醒设置失败', e);
      return false;
    }
  }

  /// 删除提醒设置
  Future<bool> deleteReminder(int id) async {
    try {
      final allSettings = getAllReminders();
      allSettings.removeWhere((s) => s.id == id);

      await _saveAllReminders(allSettings);
      await _notificationService.cancelNotification(id);

      logI('ReminderManager', '🗑️ 提醒设置已删除: $id');
      return true;
    } catch (e) {
      logE('ReminderManager', '❌ 删除提醒设置失败', e);
      return false;
    }
  }

  /// 启用/禁用提醒
  Future<bool> toggleReminder(int id, bool enabled) async {
    final allSettings = getAllReminders();
    final index = allSettings.indexWhere((s) => s.id == id);

    if (index < 0) return false;

    final updatedSettings = allSettings[index].copyWith(
      isEnabled: enabled,
      nextScheduledTime: enabled ? allSettings[index].calculateNextScheduledTime() : null,
    );

    return await saveReminder(updatedSettings);
  }

  /// 重新调度所有活跃的提醒
  Future<void> rescheduleAllReminders() async {
    final allSettings = getAllReminders().where((s) => s.isEnabled);

    for (final settings in allSettings) {
      await _scheduleReminder(settings);
    }

    logI('ReminderManager', '🔄 已重新调度${allSettings.length}个提醒');
  }

  /// 调度单个提醒
  Future<void> _scheduleReminder(ReminderSettings settings) async {
    if (!settings.isEnabled) {
      await _notificationService.cancelNotification(settings.id);
      return;
    }

    final nextTime = settings.calculateNextScheduledTime();
    if (nextTime == null) {
      logW('ReminderManager', '⚠️ 无法计算下次提醒时间: ${settings.title}');
      return;
    }

    final success = await _notificationService.scheduleNotification(
      id: settings.id,
      title: settings.title,
      body: settings.message,
      scheduledTime: nextTime,
    );

    if (success) {
      // 更新下次调度时间
      final updatedSettings = settings.copyWith(nextScheduledTime: nextTime);
      final allSettings = getAllReminders();
      final index = allSettings.indexWhere((s) => s.id == settings.id);
      if (index >= 0) {
        allSettings[index] = updatedSettings;
        await _saveAllReminders(allSettings);
      }
    }
  }

  Future<void> _saveAllReminders(List<ReminderSettings> settings) async {
    final settingsJson = settings.map((s) => jsonEncode(s.toJson())).toList();
    await _prefs.setStringList(_settingsKey, settingsJson);
  }

  /// 检查并处理过期的提醒
  Future<void> handleExpiredReminders() async {
    final allSettings = getAllReminders();
    bool hasChanges = false;

    for (final settings in allSettings) {
      if (settings.isEnabled && settings.needsReschedule()) {
        await _scheduleReminder(settings);
        hasChanges = true;
      }
    }

    if (hasChanges) {
      logI('ReminderManager', '🔄 已处理过期的提醒设置');
    }
  }
}

用户界面设计

提醒设置页面

class ReminderSettingsPage extends ConsumerStatefulWidget {
  @override
  ConsumerState<ReminderSettingsPage> createState() => _ReminderSettingsPageState();
}

class _ReminderSettingsPageState extends ConsumerState<ReminderSettingsPage> {
  @override
  Widget build(BuildContext context) {
    final reminders = ref.watch(reminderManagerProvider).getAllReminders();
    final permissionStatus = ref.watch(permissionStatusProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('记账提醒'),
        actions: [
          IconButton(
            icon: const Icon(Icons.add),
            onPressed: _addNewReminder,
          ),
        ],
      ),
      body: Column(
        children: [
          // 权限状态卡片
          _buildPermissionStatusCard(permissionStatus),

          // 提醒列表
          Expanded(
            child: reminders.isEmpty
                ? _buildEmptyState()
                : ListView.builder(
                    itemCount: reminders.length,
                    itemBuilder: (context, index) {
                      return _buildReminderItem(reminders[index]);
                    },
                  ),
          ),
        ],
      ),
    );
  }

  Widget _buildPermissionStatusCard(AsyncValue<PermissionStatus> statusAsync) {
    return statusAsync.when(
      data: (status) {
        if (status.isAllGranted) {
          return Card(
            color: Colors.green.shade50,
            child: ListTile(
              leading: CircleAvatar(
                backgroundColor: Colors.green,
                child: Icon(Icons.check, color: Colors.white),
              ),
              title: Text('权限设置完成'),
              subtitle: Text('提醒功能可以正常使用'),
              trailing: Icon(Icons.notifications_active, color: Colors.green),
            ),
          );
        } else {
          return Card(
            color: Colors.orange.shade50,
            child: ListTile(
              leading: CircleAvatar(
                backgroundColor: Colors.orange,
                child: Icon(Icons.warning, color: Colors.white),
              ),
              title: Text('需要完成权限设置'),
              subtitle: Text('某些权限未授予,可能影响提醒功能'),
              trailing: TextButton(
                onPressed: _openPermissionGuide,
                child: Text('去设置'),
              ),
            ),
          );
        }
      },
      loading: () => Card(
        child: ListTile(
          leading: CircularProgressIndicator(),
          title: Text('检查权限状态中...'),
        ),
      ),
      error: (error, _) => Card(
        color: Colors.red.shade50,
        child: ListTile(
          leading: CircleAvatar(
            backgroundColor: Colors.red,
            child: Icon(Icons.error, color: Colors.white),
          ),
          title: Text('权限检查失败'),
          subtitle: Text('请手动检查应用权限设置'),
        ),
      ),
    );
  }

  Widget _buildReminderItem(ReminderSettings reminder) {
    return Card(
      child: ListTile(
        leading: CircleAvatar(
          backgroundColor: reminder.isEnabled
              ? Theme.of(context).primaryColor
              : Colors.grey,
          child: Icon(
            Icons.alarm,
            color: Colors.white,
          ),
        ),
        title: Text(reminder.title),
        subtitle: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(reminder.message),
            const SizedBox(height: 4),
            Text(
              '${_formatTime(reminder.time)}${_formatWeekdays(reminder.weekdays)}',
              style: TextStyle(
                fontSize: 12,
                color: Colors.grey[600],
              ),
            ),
            if (reminder.nextScheduledTime != null) ...[
              const SizedBox(height: 2),
              Text(
                '下次提醒: ${_formatDateTime(reminder.nextScheduledTime!)}',
                style: TextStyle(
                  fontSize: 11,
                  color: Colors.blue[600],
                ),
              ),
            ],
          ],
        ),
        trailing: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            Switch(
              value: reminder.isEnabled,
              onChanged: (enabled) => _toggleReminder(reminder.id, enabled),
            ),
            PopupMenuButton<String>(
              onSelected: (value) => _handleReminderAction(reminder, value),
              itemBuilder: (context) => [
                PopupMenuItem(value: 'edit', child: Text('编辑')),
                PopupMenuItem(value: 'delete', child: Text('删除')),
              ],
            ),
          ],
        ),
        isThreeLine: true,
      ),
    );
  }

  Widget _buildEmptyState() {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(
            Icons.alarm_off,
            size: 80,
            color: Colors.grey[400],
          ),
          const SizedBox(height: 16),
          Text(
            '还没有设置任何提醒',
            style: Theme.of(context).textTheme.headlineSmall?.copyWith(
              color: Colors.grey[600],
            ),
          ),
          const SizedBox(height: 8),
          Text(
            '点击右上角的 + 号添加第一个记账提醒',
            style: Theme.of(context).textTheme.bodyMedium?.copyWith(
              color: Colors.grey[500],
            ),
          ),
          const SizedBox(height: 24),
          ElevatedButton.icon(
            onPressed: _addNewReminder,
            icon: Icon(Icons.add),
            label: Text('添加提醒'),
          ),
        ],
      ),
    );
  }

  String _formatTime(TimeOfDay time) {
    final hour = time.hour.toString().padLeft(2, '0');
    final minute = time.minute.toString().padLeft(2, '0');
    return '$hour:$minute';
  }

  String _formatWeekdays(List<int> weekdays) {
    if (weekdays.length == 7) return '每日';
    if (weekdays.length == 5 && weekdays.every((w) => w >= 1 && w <= 5)) {
      return '工作日';
    }
    if (weekdays.length == 2 && weekdays.contains(6) && weekdays.contains(7)) {
      return '周末';
    }

    const weekdayNames = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日'];
    return weekdays.map((w) => weekdayNames[w]).join('、');
  }

  String _formatDateTime(DateTime dateTime) {
    final now = DateTime.now();
    final today = DateTime(now.year, now.month, now.day);
    final targetDate = DateTime(dateTime.year, dateTime.month, dateTime.day);

    if (targetDate == today) {
      return '今天 ${_formatTime(TimeOfDay.fromDateTime(dateTime))}';
    } else if (targetDate == today.add(Duration(days: 1))) {
      return '明天 ${_formatTime(TimeOfDay.fromDateTime(dateTime))}';
    } else {
      return '${dateTime.month}/${dateTime.day} ${_formatTime(TimeOfDay.fromDateTime(dateTime))}';
    }
  }

  void _addNewReminder() {
    Navigator.of(context).push(
      MaterialPageRoute(
        builder: (context) => ReminderEditPage(),
      ),
    );
  }

  void _toggleReminder(int id, bool enabled) {
    ref.read(reminderManagerProvider).toggleReminder(id, enabled);
  }

  void _handleReminderAction(ReminderSettings reminder, String action) {
    switch (action) {
      case 'edit':
        Navigator.of(context).push(
          MaterialPageRoute(
            builder: (context) => ReminderEditPage(reminder: reminder),
          ),
        );
        break;
      case 'delete':
        _deleteReminder(reminder);
        break;
    }
  }

  void _deleteReminder(ReminderSettings reminder) async {
    final confirmed = await showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('删除提醒'),
        content: Text('确定要删除「${reminder.title}」提醒吗?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(context).pop(false),
            child: Text('取消'),
          ),
          TextButton(
            onPressed: () => Navigator.of(context).pop(true),
            child: Text('删除'),
          ),
        ],
      ),
    );

    if (confirmed == true) {
      await ref.read(reminderManagerProvider).deleteReminder(reminder.id);
    }
  }

  void _openPermissionGuide() async {
    final permissionGuide = ref.read(permissionGuideServiceProvider);
    await permissionGuide.guideUserThroughPermissions(context);

    // 重新检查权限状态
    ref.refresh(permissionStatusProvider);
  }
}

性能优化和最佳实践

电池优化适配

class BatteryOptimizationHelper {
  /// 检查不同厂商的电池优化设置
  static Future<BatteryOptimizationInfo> getBatteryOptimizationInfo() async {
    final info = await _channel.invokeMethod('getBatteryOptimizationInfo');
    return BatteryOptimizationInfo.fromMap(info);
  }

  /// 提供厂商特定的设置指引
  static String getManufacturerSpecificGuide(String manufacturer) {
    final lowerManufacturer = manufacturer.toLowerCase();

    switch (lowerManufacturer) {
      case 'xiaomi':
        return '''
小米设备设置指南:
1. 进入「设置」→「电池与性能」→「省电优化」
2. 找到「蜜蜂记账」→选择「无限制」
3. 进入「设置」→「通知管理」→「蜜蜂记账」
4. 开启「通知管理」和「锁屏通知」
''';

      case 'huawei':
      case 'honor':
        return '''
华为/荣耀设备设置指南:
1. 进入「设置」→「电池」→「启动管理」
2. 找到「蜜蜂记账」→开启「手动管理」
3. 允许「自启动」、「关联启动」、「后台活动」
4. 进入「设置」→「通知」→「蜜蜂记账」→开启通知
''';

      case 'oppo':
        return '''
OPPO设备设置指南:
1. 进入「设置」→「电池」→「省电模式」
2. 找到「蜜蜂记账」→选择「智能后台冻结:关」
3. 进入「设置」→「应用管理」→「蜜蜂记账」
4. 开启「允许关联启动」和「允许后台活动」
''';

      case 'vivo':
        return '''
VIVO设备设置指南:
1. 进入「设置」→「电池」→「后台高耗电」
2. 找到「蜜蜂记账」→选择「允许后台高耗电」
3. 进入「设置」→「应用与权限」→「蜜蜂记账」
4. 开启「自启动」和「允许关联启动」
''';

      default:
        return '''
原生Android设置指南:
1. 进入「设置」→「电池」→「电池优化」
2. 找到「蜜蜂记账」→选择「不优化」
3. 确保通知权限已开启
''';
    }
  }
}

class BatteryOptimizationInfo {
  final bool isIgnoring;
  final bool canRequest;
  final String manufacturer;
  final String model;
  final String androidVersion;

  BatteryOptimizationInfo({
    required this.isIgnoring,
    required this.canRequest,
    required this.manufacturer,
    required this.model,
    required this.androidVersion,
  });

  factory BatteryOptimizationInfo.fromMap(Map<String, dynamic> map) {
    return BatteryOptimizationInfo(
      isIgnoring: map['isIgnoring'] ?? false,
      canRequest: map['canRequest'] ?? false,
      manufacturer: map['manufacturer'] ?? '',
      model: map['model'] ?? '',
      androidVersion: map['androidVersion'] ?? '',
    );
  }

  String get deviceInfo => '$manufacturer $model (Android $androidVersion)';

  bool get needsManualSetup {
    final problematicManufacturers = ['xiaomi', 'huawei', 'honor', 'oppo', 'vivo'];
    return problematicManufacturers.contains(manufacturer.toLowerCase());
  }
}

通知调试工具

class NotificationDebugService {
  static const String _debugLogKey = 'notification_debug_log';
  final SharedPreferences _prefs;

  NotificationDebugService(this._prefs);

  /// 记录通知调试日志
  void logNotificationEvent(String event, Map<String, dynamic> data) {
    final logEntry = {
      'timestamp': DateTime.now().toIso8601String(),
      'event': event,
      'data': data,
    };

    final logs = getDebugLogs();
    logs.add(logEntry);

    // 只保留最近100条记录
    if (logs.length > 100) {
      logs.removeAt(0);
    }

    _saveLogs(logs);
  }

  /// 获取调试日志
  List<Map<String, dynamic>> getDebugLogs() {
    final logsJson = _prefs.getStringList(_debugLogKey) ?? [];
    return logsJson.map((log) => jsonDecode(log) as Map<String, dynamic>).toList();
  }

  /// 清除调试日志
  Future<void> clearDebugLogs() async {
    await _prefs.remove(_debugLogKey);
  }

  /// 导出调试日志
  String exportDebugLogs() {
    final logs = getDebugLogs();
    final buffer = StringBuffer();

    buffer.writeln('=== BeeCount 通知调试日志 ===');
    buffer.writeln('导出时间: ${DateTime.now()}');
    buffer.writeln('日志条数: ${logs.length}');
    buffer.writeln('');

    for (final log in logs) {
      buffer.writeln('[${log['timestamp']}] ${log['event']}');
      if (log['data'].isNotEmpty) {
        log['data'].forEach((key, value) {
          buffer.writeln('  $key: $value');
        });
      }
      buffer.writeln('');
    }

    return buffer.toString();
  }

  void _saveLogs(List<Map<String, dynamic>> logs) {
    final logsJson = logs.map((log) => jsonEncode(log)).toList();
    _prefs.setStringList(_debugLogKey, logsJson);
  }

  /// 测试通知功能
  Future<NotificationTestResult> testNotification() async {
    final result = NotificationTestResult();

    try {
      // 1. 检查权限
      final hasPermission = await NotificationService.instance.hasNotificationPermission();
      result.addTest('权限检查', hasPermission, hasPermission ? '有通知权限' : '缺少通知权限');

      // 2. 检查电池优化
      final isBatteryIgnored = await NotificationService.instance.isBatteryOptimizationIgnored();
      result.addTest('电池优化', isBatteryIgnored, isBatteryIgnored ? '已忽略电池优化' : '受电池优化影响');

      // 3. 测试即时通知
      final immediateSuccess = await NotificationService.instance.scheduleNotification(
        id: 99999,
        title: '测试通知',
        body: '这是一条测试通知,用于验证通知功能是否正常',
        scheduledTime: DateTime.now().add(Duration(seconds: 2)),
      );
      result.addTest('即时通知', immediateSuccess, immediateSuccess ? '通知已调度' : '通知调度失败');

      // 4. 测试延迟通知
      final delayedSuccess = await NotificationService.instance.scheduleNotification(
        id: 99998,
        title: '延迟测试通知',
        body: '这是一条延迟测试通知,应该在30秒后显示',
        scheduledTime: DateTime.now().add(Duration(seconds: 30)),
      );
      result.addTest('延迟通知', delayedSuccess, delayedSuccess ? '延迟通知已调度' : '延迟通知调度失败');

    } catch (e) {
      result.addTest('测试异常', false, e.toString());
    }

    return result;
  }
}

class NotificationTestResult {
  final List<TestItem> tests = [];

  void addTest(String name, bool success, String message) {
    tests.add(TestItem(name: name, success: success, message: message));
  }

  bool get allPassed => tests.every((test) => test.success);
  int get passedCount => tests.where((test) => test.success).length;
  int get totalCount => tests.length;

  String get summary => '$passedCount/$totalCount 项测试通过';
}

class TestItem {
  final String name;
  final bool success;
  final String message;

  TestItem({required this.name, required this.success, required this.message});
}

实际应用效果

在BeeCount项目中,完善的通知提醒系统带来了显著的用户价值:

  1. 用户粘性提升:定时提醒帮助用户养成记账习惯,应用日活跃度提升35%
  2. 跨设备兼容性:适配主流Android厂商的电池优化策略,通知送达率达95%+
  3. 用户体验优化:智能权限引导减少了用户配置困扰,设置完成率提升60%
  4. 系统资源优化:精确的AlarmManager调度和合理的权限管理,避免了过度耗电

结语

构建可靠的移动应用通知系统需要深入理解Android系统特性,合理处理各种权限和优化策略。通过系统化的架构设计、完善的权限管理和细致的用户体验优化,我们可以在系统限制下为用户提供准时可靠的提醒服务。

BeeCount的通知系统实践证明,技术实现与用户体验的平衡是移动应用成功的关键。这套方案不仅适用于记账类应用,对任何需要定时提醒功能的应用都具有重要的参考价值。

关于BeeCount项目

项目特色

  • 🎯 现代架构: 基于Riverpod + Drift + Supabase的现代技术栈
  • 📱 跨平台支持: iOS、Android双平台原生体验
  • 🔄 云端同步: 支持多设备数据实时同步
  • 🎨 个性化定制: Material Design 3主题系统
  • 📊 数据分析: 完整的财务数据可视化
  • 🌍 国际化: 多语言本地化支持

技术栈一览

  • 框架: Flutter 3.6.1+ / Dart 3.6.1+
  • 状态管理: Flutter Riverpod 2.5.1
  • 数据库: Drift (SQLite) 2.20.2
  • 云服务: Supabase 2.5.6
  • 图表: FL Chart 0.68.0
  • CI/CD: GitHub Actions

开源信息

BeeCount是一个完全开源的项目,欢迎开发者参与贡献:

参考资源

官方文档

学习资源


本文是BeeCount技术文章系列的第4篇,后续将深入探讨主题系统、数据可视化等话题。如果你觉得这篇文章有帮助,欢迎关注项目并给个Star!

用了这么久React,你真的搞懂useEffect了吗?

2025年9月19日 07:34

你是不是也遇到过这样的场景?页面刚加载时数据一片空白,需要手动刷新才能显示;组件里设置了定时器,结果切换页面后还在后台疯狂运行;甚至有时候,明明代码写得没问题,却出现了奇怪的内存泄漏问题……

别担心,这些问题我都遇到过!今天我就用一个超详细的指南,带你彻底搞懂React中的useEffect,让你从此告别这些烦人的坑。

读完本文,你将掌握useEffect的所有核心用法,包括数据获取、订阅机制、DOM操作,还能学会如何避免常见的内存泄漏问题。我会用大量代码示例和详细注释,让你一看就懂,一学就会!

什么是useEffect?为什么我们需要它?

简单来说,useEffect就是React函数组件中处理"副作用"的利器。什么是副作用?就是那些会影响组件外部世界的操作,比如数据获取、设置订阅、手动修改DOM等。

在类组件时代,我们通常在componentDidMount、componentDidUpdate和componentWillUnmount这些生命周期方法中处理这些操作。但现在有了函数组件和Hooks,useEffect就能帮我们统一处理所有这些场景。

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

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

function Example() {
  const [count, setCount] = useState(0);

  // 类似于componentDidMount和componentDidUpdate
  useEffect(() => {
    // 更新文档标题
    document.title = `你点击了 ${count} 次`;
  });

  return (
    <div>
      <p>你点击了 {count} 次</p>
      <button onClick={() => setCount(count + 1)}>
        点我
      </button>
    </div>
  );
}

这段代码中,useEffect会在每次组件渲染后执行,更新文档的标题。这就是useEffect最基本的用法。

三种不同的依赖数组配置

useEffect最强大的地方在于它的第二个参数——依赖数组。通过不同的配置,我们可以控制effect的执行时机。

1. 每次渲染都执行

如果不传第二个参数,useEffect会在每次组件渲染后都执行:

useEffect(() => {
  console.log('这个effect在每次渲染后都会执行');
});

这种用法要谨慎,因为频繁执行可能会影响性能。

2. 只在首次渲染时执行

如果传递一个空数组[],effect只会在组件挂载时执行一次:

useEffect(() => {
  console.log('这个effect只在组件挂载时执行一次');
}, []); // 空依赖数组

这种模式非常适合数据获取操作,我们通常在这里发起API请求。

3. 在特定值变化时执行

如果数组中包含了某些值,effect会在这些值发生变化时执行:

useEffect(() => {
  console.log('这个effect在count变化时执行');
}, [count]); // count变化时重新执行

这种用法可以让我们在特定状态变化时执行相应的操作。

实际应用场景代码示例

场景一:数据获取

数据获取是useEffect最常见的用法之一。让我们看看如何正确地在组件中获取数据:

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

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // 定义一个异步函数来获取数据
    const fetchData = async () => {
      try {
        setLoading(true);
        const response = await fetch(`https://api.example.com/users/${userId}`);
        if (!response.ok) {
          throw new Error('网络请求失败');
        }
        const userData = await response.json();
        setUser(userData);
        setError(null);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [userId]); // 当userId变化时重新获取数据

  if (loading) return <div>加载中...</div>;
  if (error) return <div>错误: {error}</div>;
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

注意这里我们将userId作为依赖项,这样当userId变化时,组件会自动重新获取对应用户的数据。

场景二:订阅和取消订阅

在处理实时数据或事件监听时,我们需要正确设置订阅和取消订阅:

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

function OnlineStatus() {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    // 模拟一个订阅函数
    function handleStatusChange(status) {
      setIsOnline(status);
    }

    // 创建订阅
    console.log('创建订阅');
    // 这里通常是建立WebSocket连接或事件监听
    const subscription = {
      unsubscribe: () => console.log('取消订阅')
    };

    // 设置初始状态
    handleStatusChange(true);

    // 返回清理函数,在组件卸载时执行
    return () => {
      console.log('执行清理');
      subscription.unsubscribe();
    };
  }, []); // 空数组表示只在挂载和卸载时执行

  if (isOnline === null) {
    return <div>加载中...</div>;
  }

  return <div>用户{isOnline ? '在线' : '离线'}</div>;
}

关键点是我们在effect中返回了一个清理函数,这个函数会在组件卸载时自动调用,确保我们不会留下任何内存泄漏。

场景三:手动操作DOM

虽然React推荐使用声明式的方式操作UI,但有时候我们确实需要直接操作DOM:

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

function AutoFocusInput() {
  const inputRef = useRef(null);

  useEffect(() => {
    // 组件挂载后自动聚焦到输入框
    if (inputRef.current) {
      inputRef.current.focus();
      console.log('输入框已获得焦点');
    }
  }, []); // 空依赖数组,只在挂载时执行

  return <input ref={inputRef} type="text" placeholder="自动获得焦点的输入框" />;
}

这个例子展示了如何使用useRef和useEffect配合来实现自动聚焦功能。

常见坑点及如何避免

坑点一:忘记清理工作

这是最常见的useEffect错误之一。如果我们设置了订阅、定时器或事件监听,但忘记清理,就会导致内存泄漏:

// ❌ 错误示例:设置了定时器但没有清理
useEffect(() => {
  const interval = setInterval(() => {
    console.log('定时器运行中...');
  }, 1000);
  // 忘记返回清理函数!
}, []);

// ✅ 正确示例:返回清理函数
useEffect(() => {
  const interval = setInterval(() => {
    console.log('定时器运行中...');
  }, 1000);
  
  // 返回清理函数
  return () => {
    clearInterval(interval);
    console.log('定时器已清理');
  };
}, []);

坑点二:错误的依赖数组

依赖数组配置错误会导致各种奇怪的问题:

function Counter() {
  const [count, setCount] = useState(0);
  
  // ❌ 错误:缺少count依赖
  useEffect(() => {
    console.log(`Count: ${count}`);
  }, []); // 缺少count依赖,effect不会在count变化时重新执行
  
  // ✅ 正确:包含所有依赖
  useEffect(() => {
    console.log(`Count: ${count}`);
  }, [count]); // 正确包含count依赖
  
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>增加</button>
    </div>
  );
}

如果你使用的是ESLint,强烈建议启用react-hooks/exhaustive-deps规则,它会自动检测缺失的依赖项。

坑点三:无限循环

不正确的依赖项配置可能导致无限重新渲染:

// ❌ 错误示例:导致无限循环
const [count, setCount] = useState(0);

useEffect(() => {
  setCount(count + 1); // 每次effect执行都更新状态,导致重新渲染
}); // 没有依赖数组,每次渲染后都执行

// ✅ 正确示例:使用函数式更新避免无限循环
useEffect(() => {
  setCount(prevCount => prevCount + 1);
}, []); // 只在挂载时执行一次

高级技巧和最佳实践

1. 使用多个useEffect分离关注点

与类组件中所有生命周期逻辑混在一起不同,我们可以使用多个useEffect来分离不同的逻辑:

function FriendStatus({ friendId }) {
  const [status, setStatus] = useState(null);
  const [profile, setProfile] = useState(null);

  // 处理状态订阅
  useEffect(() => {
    const subscription = subscribeToStatus(friendId, setStatus);
    return () => subscription.unsubscribe();
  }, [friendId]);

  // 处理资料获取
  useEffect(() => {
    let ignore = false;
    
    async function fetchProfile() {
      const profileData = await getProfile(friendId);
      if (!ignore) {
        setProfile(profileData);
      }
    }
    
    fetchProfile();
    
    return () => {
      ignore = true;
    };
  }, [friendId]);

  // 更多独立的effect...
}

这样让代码更加清晰,每个effect只负责一个特定的功能。

2. 使用自定义Hook抽象effect逻辑

如果某个effect逻辑在多个组件中都需要,我们可以将其提取为自定义Hook:

// 自定义Hook:useApi
function useApi(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true);
        const response = await fetch(url);
        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [url]); // 当url变化时重新获取

  return { data, loading, error };
}

// 在组件中使用自定义Hook
function UserProfile({ userId }) {
  const { data: user, loading, error } = useApi(`/api/users/${userId}`);

  if (loading) return <div>加载中...</div>;
  if (error) return <div>错误: {error}</div>;
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

3. 使用useCallback和useMemo优化性能

当effect依赖于函数或对象时,为了避免不必要的重新执行,我们可以使用useCallback和useMemo:

function ProductList({ category, sortBy }) {
  const [products, setProducts] = useState([]);
  
  // 使用useCallback记忆化函数,避免每次渲染都创建新函数
  const fetchProducts = useCallback(async () => {
    const response = await fetch(`/api/products?category=${category}&sort=${sortBy}`);
    const data = await response.json();
    setProducts(data);
  }, [category, sortBy]); // 依赖项变化时重新创建函数
  
  useEffect(() => {
    fetchProducts();
  }, [fetchProducts]); // 现在effect依赖于记忆化的函数
  
  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

还在硬邦邦跳转页面?Vue这3招让应用丝滑如德芙!

2025年9月19日 07:33

你是不是也遇到过这种情况?页面切换生硬得像老式电视机换台,数据加载时用户一脸懵逼不知道发生了什么,列表操作毫无反馈让人怀疑到底点没点上...

别急!今天我就带你用Vue的过渡动画三招,让你的应用瞬间从"机械僵硬"变身"丝滑流畅",用户体验直接提升一个level!

第一招:基础CSS过渡,简单又高效

先来看看最基础的CSS过渡效果。Vue提供了<transition>组件,包裹一下就能让元素动起来!

<template>
  <div>
    <button @click="show = !show">切换显示</button>
    
    <transition name="fade">
      <p v-if="show">你好呀!我会淡入淡出哦~</p>
    </transition>
  </div>
</template>

<script>
export default {
  data() {
    return {
      show: true
    }
  }
}
</script>

<style>
/* 定义进入和离开时的动画 */
.fade-enter-active, .fade-leave-active {
  transition: opacity 0.5s ease;
}

/* 定义进入开始和离开结束时的状态 */
.fade-enter, .fade-leave-to {
  opacity: 0;
}
</style>

这里有个小秘密:Vue会自动帮我们在不同阶段添加不同的class:

  • fade-enter:进入动画开始前(第一帧)
  • fade-enter-active:进入动画过程中
  • fade-enter-to:进入动画结束后
  • fade-leave:离开动画开始前
  • fade-leave-active:离开动画过程中
  • fade-leave-to:离开动画结束后

第二招:CSS动画,让效果更丰富

如果觉得简单的过渡不够酷,试试CSS动画吧!用法和过渡差不多,但能做出更复杂的效果。

<template>
  <div>
    <button @click="show = !show">蹦出来!</button>
    
    <transition name="bounce">
      <p v-if="show" class="animated-text">看我弹跳登场!</p>
    </transition>
  </div>
</template>

<style>
/* 弹跳动画 */
.bounce-enter-active {
  animation: bounce-in 0.5s;
}

.bounce-leave-active {
  animation: bounce-in 0.5s reverse;
}

@keyframes bounce-in {
  0% {
    transform: scale(0);
  }
  50% {
    transform: scale(1.25);
  }
  100% {
    transform: scale(1);
  }
}

.animated-text {
  background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
  padding: 10px;
  border-radius: 5px;
  color: white;
}
</style>

这个效果特别适合重要提示或者操作反馈,让用户一眼就能注意到!

第三招:列表过渡,让数据动起来

实际项目中我们经常要处理列表数据,<transition-group>就是专门为列表设计的动画组件。

<template>
  <div>
    <button @click="addItem">添加项目</button>
    <button @click="removeItem">删除项目</button>
    
    <transition-group name="list" tag="ul">
      <li v-for="item in items" :key="item.id" class="list-item">
        {{ item.text }}
      </li>
    </transition-group>
  </div>
</template>

<script>
export default {
  data() {
    return {
      items: [
        { id: 1, text: '第一项' },
        { id: 2, text: '第二项' },
        { id: 3, text: '第三项' }
      ],
      nextId: 4
    }
  },
  methods: {
    addItem() {
      this.items.push({
        id: this.nextId++,
        text: `新项目 ${this.nextId}`
      })
    },
    removeItem() {
      this.items.pop()
    }
  }
}
</script>

<style>
.list-item {
  transition: all 0.5s;
  margin: 5px 0;
  padding: 10px;
  background: #f8f9fa;
  border-left: 4px solid #4ecdc4;
}

.list-enter-active, .list-leave-active {
  transition: all 0.5s;
}

.list-enter, .list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

/* 让删除的项目先收缩再消失 */
.list-leave-active {
  position: absolute;
}
</style>

注意这里有两个重点:一是必须给每个列表项设置唯一的key,二是<transition-group>默认渲染为span,可以用tag属性指定为其他标签。

进阶玩法:JavaScript钩子函数

有时候CSS动画满足不了复杂需求,这时候就需要JavaScript钩子出场了!

<template>
  <div>
    <button @click="show = !show">切换</button>
    
    <transition
      @before-enter="beforeEnter"
      @enter="enter"
      @after-enter="afterEnter"
      @enter-cancelled="enterCancelled"
      @before-leave="beforeLeave"
      @leave="leave"
      @after-leave="afterLeave"
      @leave-cancelled="leaveCancelled"
    >
      <div v-if="show" class="js-box">JS控制的动画</div>
    </transition>
  </div>
</template>

<script>
export default {
  data() {
    return {
      show: false
    }
  },
  methods: {
    // 进入动画开始前
    beforeEnter(el) {
      el.style.opacity = 0
      el.style.transform = 'scale(0)'
    },
    
    // 进入动画中
    enter(el, done) {
      // 使用requestAnimationFrame保证流畅性
      let start = null
      const duration = 600
      
      function animate(timestamp) {
        if (!start) start = timestamp
        const progress = timestamp - start
        
        // 计算当前进度(0-1)
        const percentage = Math.min(progress / duration, 1)
        
        // 应用动画效果
        el.style.opacity = percentage
        el.style.transform = `scale(${percentage})`
        
        if (progress < duration) {
          requestAnimationFrame(animate)
        } else {
          done() // 动画完成,调用done回调
        }
      }
      
      requestAnimationFrame(animate)
    },
    
    // 进入动画完成后
    afterEnter(el) {
      console.log('进入动画完成啦!')
    },
    
    // 进入动画被中断
    enterCancelled(el) {
      console.log('进入动画被取消了')
    },
    
    // 离开动画相关钩子...
    beforeLeave(el) {
      el.style.opacity = 1
      el.style.transform = 'scale(1)'
    },
    
    leave(el, done) {
      // 类似的实现离开动画...
      done()
    }
  }
}
</script>

<style>
.js-box {
  width: 100px;
  height: 100px;
  background: linear-gradient(135deg, #667eea, #764ba2);
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  margin-top: 10px;
}
</style>

JavaScript钩子虽然复杂一些,但是能实现任何你能想到的动画效果!

终极武器:集成第三方动画库

如果你想快速实现酷炫效果,又不想自己写太多CSS,那么第三方动画库就是你的最佳选择!

先安装Animate.css:

npm install animate.css

然后在项目中引入:

import 'animate.css'

使用起来超级简单:

<template>
  <div>
    <button @click="show = !show">来点炫酷的!</button>
    
    <transition
      enter-active-class="animate__animated animate__bounceIn"
      leave-active-class="animate__animated animate__bounceOut"
    >
      <div v-if="show" class="demo-box">哇!好酷!</div>
    </transition>
  </div>
</template>

<style>
.demo-box {
  width: 150px;
  height: 150px;
  background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 10px;
  margin-top: 20px;
  font-size: 18px;
  font-weight: bold;
}
</style>

Animate.css提供了超多现成动画效果,比如:

  • animate__bounceIn(弹跳进入)
  • animate__fadeInUp(淡入上浮)
  • animate__flip(翻转效果)
  • animate__zoomIn(缩放进入)

想要什么效果,换个class名就行了,简直是懒人福音!

实战技巧:避免这些常见坑

用了这么久Vue动画,我也踩过不少坑,分享几个实用技巧:

  1. 动画闪烁问题:在初始渲染时避免使用v-if,可以用v-show或者通过CSS控制初始状态

  2. 列表动画优化:对于长列表,可以给<transition-group>设置tag="div",避免生成太多DOM节点

  3. 性能注意:尽量使用transform和opacity做动画,这两个属性不会触发重排,性能更好

  4. 移动端适配:在移动端注意动画时长,0.3s左右比较合适,不要太长

  5. 减少同时动画:同一时间不要有太多元素做动画,会影响性能

总结

好了,今天分享了Vue过渡动画的四大招式:从基础的CSS过渡,到更丰富的CSS动画,再到处理列表的<transition-group>,最后是强大的JavaScript钩子和第三方库集成。

记住,好的动画不是为了炫技,而是为了提升用户体验。适当的动画能让用户知道发生了什么,引导注意力,让操作更有反馈感。

实现@imput支持用户输入最多三位整数,最多一位小数的数值

作者 知觉
2025年9月19日 00:23

实现@imput支持用户输入最多三位整数,最多一位小数的数值

template 模板视图

<template>
  <div>
    <label for="work-hours">工时:</label>
    <input
      id="work-hours"
      v-model="hours"
      type="text"
      placeholder="请输入工时(如:8.5)"
      @input="handleInput"
      @blur="handleBlur"
    />
    <p>当前值:{{ hours }}</p>
  </div>
</template>

script 脚本部分

import { ref } from 'vue'

// 使用字符串类型以便更好地控制输入
const hours = ref('')

// 处理输入事件
const handleInput = (event) => {
  let value = event.target.value

  // 只允许数字和一个小数点
  const regex = /^\d{0,3}(.\d{0,1})?$/
  if (regex.test(value) || value === '') {
    hours.value = value
  } else {
    // 如果不符合规则,则截取到符合的部分
    const cleaned = cleanInput(value)
    hours.value = cleaned
    // 可选:同步 DOM 值(防止视图不更新)
    event.target.value = cleaned
  }
}

// 辅助函数:清理非法输入
const cleanInput = (value) => {
  // 移除非数字和非小数点字符(保留第一个小数点)
  let cleaned = ''
  let decimalFound = false

  for (let char of value) {
    if (/\d/.test(char)) {
      // 检查是否超过三位整数部分
      const parts = cleaned.split('.')
      if (!decimalFound && parts[0].length >= 3) continue // 整数部分超限
      if (decimalFound && parts.length > 1 && parts[1].length >= 1) continue // 小数部分超限
      cleaned += char
    } else if (char === '.' && !decimalFound) {
      decimalFound = true
      const parts = cleaned.split('.')
      if (parts[0] === '') cleaned = '0' // 自动补零,如 .5 -> 0.5
      cleaned += '.'
    }
    // 忽略其他字符
  }

  return cleaned
}

// 失焦时格式化最终值
const handleBlur = () => {
  let val = hours.value

  if (val === '.') {
    hours.value = ''
    return
  }

  // 去掉开头的多余 0,比如 007 -> 7
  if (val.startsWith('0') && val !== '0' && !val.startsWith('0.')) {
    const trimmed = val.replace(/^0+/, '')
    hours.value = trimmed ? parseFloat(trimmed).toFixed(1).replace(/.?0+$/, '') : '0'
  }

  // 格式化显示(可选)
  if (val.includes('.')) {
    const [int, dec] = val.split('.')
    hours.value = `${int}.${dec.slice(0, 1)}`
  }
}

AI 生成

在 Vue 中,要实现一个输入框(<input>)来限制用户只能输入最多三位整数位和最多一位小数位的数值(即最大值为 999.9),可以通过多种方式实现。以下是一个完整的解决方案,使用 Vue 3 的 Composition API 和 <script setup> 语法:

✅ 需求分析

  • 允许输入:0, 12, 123, 123.4, .5 等。
  • 不允许:
    • 超过三位整数:如 1234
    • 超过一位小数:如 12.34
    • 非数字字符(除小数点外)
  • 实时校验并修正输入

✅ 示例代码(Vue 3 + Composition API)

<template>
  <div>
    <label for="work-hours">工时:</label>
    <input
      id="work-hours"
      v-model="hours"
      type="text"
      placeholder="请输入工时(如:8.5)"
      @input="handleInput"
      @blur="handleBlur"
    />
    <p>当前值:{{ hours }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'

// 使用字符串类型以便更好地控制输入
const hours = ref('')

// 处理输入事件
const handleInput = (event) => {
  let value = event.target.value

  // 只允许数字和一个小数点
  const regex = /^\d{0,3}(\.\d{0,1})?$/
  if (regex.test(value) || value === '') {
    hours.value = value
  } else {
    // 如果不符合规则,则截取到符合的部分
    const cleaned = cleanInput(value)
    hours.value = cleaned
    // 可选:同步 DOM 值(防止视图不更新)
    event.target.value = cleaned
  }
}

// 辅助函数:清理非法输入
const cleanInput = (value) => {
  // 移除非数字和非小数点字符(保留第一个小数点)
  let cleaned = ''
  let decimalFound = false

  for (let char of value) {
    if (/\d/.test(char)) {
      // 检查是否超过三位整数部分
      const parts = cleaned.split('.')
      if (!decimalFound && parts[0].length >= 3) continue // 整数部分超限
      if (decimalFound && parts.length > 1 && parts[1].length >= 1) continue // 小数部分超限
      cleaned += char
    } else if (char === '.' && !decimalFound) {
      decimalFound = true
      const parts = cleaned.split('.')
      if (parts[0] === '') cleaned = '0' // 自动补零,如 .5 -> 0.5
      cleaned += '.'
    }
    // 忽略其他字符
  }

  return cleaned
}

// 失焦时格式化最终值
const handleBlur = () => {
  let val = hours.value

  if (val === '.') {
    hours.value = ''
    return
  }

  // 去掉开头的多余 0,比如 007 -> 7
  if (val.startsWith('0') && val !== '0' && !val.startsWith('0.')) {
    const trimmed = val.replace(/^0+/, '')
    hours.value = trimmed ? parseFloat(trimmed).toFixed(1).replace(/\.?0+$/, '') : '0'
  }

  // 格式化显示(可选)
  if (val.includes('.')) {
    const [int, dec] = val.split('.')
    hours.value = `${int}.${dec.slice(0, 1)}`
  }
}
</script>

<style scoped>
input {
  padding: 8px;
  font-size: 16px;
  border: 1px solid #ccc;
  border-radius: 4px;
}
</style>

🔍 功能说明

特性 说明
v-model 绑定字符串 更容易处理中间状态(如正在输入 .5
正则 /^\d{0,3}(\.\d{0,1})?$/ 保证不超过 3 位整数、1 位小数
@input 实时过滤 防止非法字符或超出长度输入
cleanInput() 函数 手动解析字符串,精确控制每一位输入
@blur 处理 输入完成时进行规范化

✅ 支持的输入示例

用户输入 实际结果 说明
1 1 ✅ 正常
123 123 ✅ 最大整数
123.4 123.4 ✅ 合法小数
123.45 123.4 ❌ 第二位小数被截断
1234 123 ❌ 第四位整数被忽略
.5 0.5 ⚠️ 可自动补 0 或保留 .5(本例中保留)
abc12.3xyz 12.3 ✅ 非法字符被过滤

💡 进阶建议

  • 若用于表单提交,建议在提交前再次校验 parseFloat(hours.value) 是否在 0 ~ 999.9 范围内。
  • 可改为 type="number" 并配合 step="0.1",但对输入控制较弱,仍需 JS 辅助。
  • 移动端可考虑使用 pattern="[0-9]*" 配合软键盘优化。

这个方案可以确保用户只能输入合法的工时数据,适合用于填报系统、考勤系统等场景。

前端性能优化之图片预加载

作者 bug_kada
2025年9月18日 23:37

当我们谈论前端性能优化时,预加载是一个经常被提及但容易被忽视的技术。想象一下:用户点击某个功能时,页面立即响应,无需等待加载——这种无缝体验正是预加载技术所能带来的。本文将带你全面了解预加载,掌握几种实用的实现方法,让你的应用流畅如丝。

1. 什么是预加载?

预加载,顾名思义,就是在实际需要之前提前加载资源。它是一种基于预期行为的性能优化策略,通过在浏览器空闲时提前获取后续可能需要的资源,从而减少用户等待时间。

2. 为什么要用预加载?

2.1 提升用户体验

用户最讨厌等待。研究表明,页面加载时间每增加1秒,转化率就会下降7%。预加载可以显著减少用户感知的等待时间,让操作变得流畅自然。

2.2 优化资源加载时机

没有预加载时,资源通常在遇到相应标签时才开始加载。这会导致关键路径上的串行加载,延长页面可交互时间。预加载允许我们提前告知浏览器哪些资源很重要,从而优化加载优先级和时机。

2.3 减少交互延迟

对于需要用户交互后才加载资源的场景(如点击按钮后显示弹窗),预加载可以提前获取这些资源,使交互后的响应几乎 instantaneous(即时)。

3. 实现预加载的几种方法

3.1 使用 link[rel="preload"]

这是W3C官方推荐的预加载方式,专门为资源预加载设计:

<link rel="preload" href="important.js" as="script">
<link rel="preload" href="critical.css" as="style">
<link rel="preload" href="heavy-font.woff2" as="font" type="font/woff2" crossorigin>

注意事项:

  • 必须指定as属性来声明资源类型
  • 字体文件需要设置crossorigin属性
  • 预加载不等于执行,CSS和JS会被缓存但不会立即应用/执行

3.2 基于JavaScript的预加载

对于需要更多控制权的场景,可以使用JavaScript动态创建资源:

// 预加载图片
function preloadImage(url) {
  const img = new Image();
  img.src = url;
}

// 预加载JS和CSS
function preloadResource(url, type) {
  const link = document.createElement('link');
  link.rel = 'preload';
  link.href = url;
  link.as = type;
  document.head.appendChild(link);
}

// 使用示例
preloadImage('hero-banner.jpg');
preloadResource('important-module.js', 'script');

3.3 使用XMLHttpRequest/Fetch预加载

对于需要预加载但不想执行的内容,可以使用AJAX请求:

// 使用fetch预加载数据
function preloadData(url) {
  fetch(url, { 
    method: 'GET',
    priority: 'high' 
  })
  .then(response => {
    if (!response.ok) throw new Error('Network response was not ok');
    // 这里我们不处理响应,只是缓存它
    return response;
  })
  .catch(error => {
    console.log('Preload failed:', error);
  });
}

3.4 资源提示:prefetch、preconnect和dns-prefetch

除了preload,还有其他资源提示技术:

<!-- DNS预解析 -->
<link rel="dns-prefetch" href="https://api.example.com">

<!-- 预连接:提前完成DNS查找、TCP握手和TLS协商 -->
<link rel="preconnect" href="https://api.example.com">

<!-- 预获取:低优先级加载下一个页面可能需要的资源 -->
<link rel="prefetch" href="next-page.html" as="document">

这些技术与preload的区别在于优先级和用途:

  • preload:当前页面高优先级资源
  • prefetch:下一个页面可能需要的低优先级资源
  • preconnect:提前建立连接
  • dns-prefetch:仅提前进行DNS查询

3.5 使用Service Worker缓存资源

Service Worker可以拦截请求并缓存资源,实现高级预加载策略:

// service-worker.js
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open('app-shell').then(cache => {
      return cache.addAll([
        '/styles/main.css',
        '/scripts/app.js',
        '/images/logo.png'
        // 其他需要预缓存的资源
      ]);
    })
  );
});

浏览器与 Node.js 全局变量体系详解:从 window 到 global 的核心差异

作者 子兮曰
2025年9月18日 23:17

在JavaScript中,全局变量是代码运行的基础环境载体,但由于执行环境(浏览器/Node.js)的定位不同,其全局变量体系存在显著差异。本文将系统解析两种环境下的全局变量核心对象、特性及差异,帮助理解JavaScript在不同场景的运行机制。

一、浏览器环境:以window为核心的全局变量体系

浏览器作为JavaScript的前端运行环境,全局变量的载体是window对象,它集成了与页面交互、浏览器控制相关的所有核心能力。

1. 顶层全局对象window的核心特性

  • 自指性window.window === window,自身指向自身,是全局作用域的顶层对象。
  • 全局变量自动挂载:通过var在全局作用域声明的变量、函数,会自动成为window的属性(let/const声明的全局变量不会挂载,但仍在全局作用域)。
  • 浏览器能力集成:包含DOM操作、BOM控制、存储、交互等所有浏览器特有API。

2. window的常用属性与方法

功能类别 典型成员 说明
全局变量载体 window.xxx 全局var声明的变量自动挂载于此
DOM操作 window.document 文档对象模型入口,用于操作页面元素
浏览器信息 window.navigator 包含浏览器类型、版本等信息(如userAgent
页面导航 window.location 控制URL跳转、获取页面地址信息
定时器 setTimeout()setInterval() 延迟/定时执行函数(与window绑定)
交互弹窗 alert()confirm()prompt() 浏览器内置交互对话框
本地存储 localStoragesessionStorage 持久化/会话级数据存储

3. 浏览器环境的其他全局对象

  • self:在窗口环境中与window等价,在Web Worker中作为全局对象(Worker中无window)。
  • top:指向最顶层窗口(适用于嵌套框架场景,如<iframe>多层嵌套时的最外层)。
  • parent:指向当前窗口的父窗口(框架中当前页面的直接上层窗口)。
  • frames:页面中所有框架的集合,frames[0]表示第一个框架窗口。

4. 浏览器全局变量的声明规则

// 1. var声明的全局变量自动挂载到window
var globalVar = "var全局变量";
console.log(window.globalVar); // 输出:"var全局变量"

// 2. let/const声明的全局变量不挂载到window,但在全局作用域可见
let globalLet = "let全局变量";
console.log(window.globalLet); // 输出:undefined

// 3. 未声明直接赋值的变量自动成为window属性(不推荐,易污染全局)
undeclaredVar = "未声明变量";
console.log(window.undeclaredVar); // 输出:"未声明变量"

二、Node.js环境:以global为核心的全局变量体系

Node.js作为服务器端JavaScript运行环境,全局变量体系围绕global对象构建,侧重服务器能力(如进程管理、文件操作),无浏览器相关API。

1. 顶层全局对象global的核心特性

  • 自指性global.global === global,与window类似,是Node.js的顶层全局对象。
  • 显式挂载规则:全局变量不会自动挂载到global,需显式赋值才能成为全局共享变量。
  • 服务器能力集成:包含进程控制、模块管理、文件操作等服务器端特有API。

2. global的常用属性与方法

功能类别 典型成员 说明
全局变量载体 global.xxx 需显式赋值才会成为全局共享变量
模块信息 __dirname__filename 当前模块的目录路径、文件路径(模块级伪全局)
进程控制 process Node.js进程对象,包含环境变量(process.env)、退出控制等
定时器 setTimeout()setInterval() 行为与浏览器类似,但绑定到global
控制台输出 console.log() 向终端输出信息
错误处理 Errorglobal.Error 错误构造函数,用于抛出异常

3. Node.js的“伪全局”模块级变量

Node.js中每个文件是独立模块,以下变量看似全局,实则为模块作用域(非global属性):

  • module:当前模块的引用,module.exports用于导出模块内容。
  • exportsmodule.exports的引用(默认指向同一对象,注意赋值会切断关联)。
  • require():导入其他模块的函数,仅在模块内部可用。

示例:

// a.js(模块导出)
module.exports = { msg: "来自a模块" };

// b.js(模块导入)
const a = require('./a.js');
console.log(a.msg); // 输出:"来自a模块"

4. Node.js全局变量的声明规则

// 1. 模块内var声明的变量不会挂载到global(仅模块内可见)
var nodeVar = "模块内变量";
console.log(global.nodeVar); // 输出:undefined

// 2. 显式赋值给global的变量才会全局共享(所有模块可访问)
global.sharedVar = "全局共享变量";

// 其他模块中
console.log(global.sharedVar); // 输出:"全局共享变量"

三、浏览器与Node.js全局变量的核心差异对比

对比维度 浏览器环境 Node.js环境
顶层全局对象 window global
全局变量挂载规则 var声明自动挂载到window 需显式赋值给global才全局共享
模块系统 无原生模块系统(依赖ES Module/AMD) 基于CommonJS(require/module
特有核心API DOM/BOM(documentlocation等) 进程(process)、文件系统(fs)等
this指向 全局作用域中this === window 模块中this === module.exports
运行载体 浏览器窗口/标签页 独立的Node.js进程

四、如何检测当前运行环境?

通过判断全局对象的存在性可快速区分环境:

if (typeof window !== 'undefined') {
  console.log("运行在浏览器环境");
} else if (typeof global !== 'undefined') {
  console.log("运行在Node.js环境");
}

总结

浏览器与Node.js的全局变量体系差异,本质是环境定位不同导致的设计分化:

  • 浏览器以window为核心,聚焦前端页面交互,集成DOM/BOM能力,全局变量易通过var自动挂载。
  • Node.js以global为核心,聚焦服务器端能力,采用模块隔离设计,全局变量需显式声明。

理解这些差异,是编写跨环境兼容JavaScript代码的基础,也是深入掌握JavaScript运行机制的关键。

【零成本高效编程】VS Code必装的5款免费AI插件,开发效率飙升!

2025年9月18日 22:53

对于前端开发者来说, VSCode 是必需的代码编辑器,插件生态丰富,给开发者带来了极大的便利,提升了开发效率。

今天这篇给大家分享 5 个免费的 VSCode AI 代码编码插件 !!!

1.Codegeex

CodeGeeX是智谱 AI 旗下的一款基于大模型的智能编程助手,它可以实现代码的生成与补全,自动为代码添加注释,不同编程语言的代码间实现互译,针对技术和代码问题的智能问答,当然还包括代码解释,生成单元测试,实现代码审查,修复代码bug等非常丰富的功能。

3c581290a7c6ab7429a4be8f4728cc1.png

这款插件我最常使用的是它的代码补全和自动补全注释的功能,只要你在敲代码,它会根据上下文给你合理的代码提示,有时候你都不用自己写,按tab键就即可。就像下面这样:

image.png

不仅如此,安装之后在右侧找到插件按钮,点击可以看到聊天与 AI 的聊天界面,有 chatAgent 两种模式:

2cf87abddc721d91a677e48d033ce37.png

2.通义灵码

fd9ad31ad88a111150775410a870225.png

通义灵码是阿里云旗下的一款基于通义大模型的 AI 编程助手,与其他不同编程助手不同的是,它针对阿里云的云服务使用场景进行了调优,安装之后,登录通义灵码的账号即可使用,登录之后的界面是这样的:

8478f09389cf4a5672bc897797cdf3f.png

3.Gemini

Gemini CLI 是 Google 推出的开源命令行 AI 助手插件,基于 Gemini 2.5 Pro 语言模型,支持百万级上下文处理和多模态输入(包括代码、文档、图像等)。它通过终端集成,提供代码生成、自动化脚本执行、多模态文件处理等功能。

需要 Node.js ≥18 版本,完全免费,但注意,这款使用需要 ti,并使用 Google 账号登录。

在插件里搜索 gemini cli 就可以找到这款插件了。

image.png

安装之后长这样:

image.png

4.Tencent Cloud CodeBuddy

腾讯云代码助手,CodeBuddy 提供 Craft 开发智能体、AI 对话、代码补全、单元测试、智能评审、知识库、工程理解、MCP Server 等能力,覆盖超 200+ 编程语言及框架辅助开发者、开发团队提升编码效率和代码质量。

7f3b6975b5725422c1c7a8d018aaa8a.png

安装之后界面是这样:

1758162962182.jpg

最后,多说一句,如果你使用 AI 编码工具的 Agent 模式,一定记得先将你之前的改动的代码 git add,否则之前的改动和 Agent 执行命令之后的改动难免会混淆,撤回代码的成本变高。

你用过docker部署前端项目吗?Tell Me Why 为何要用docker部署前端项目呢?

作者 水冗水孚
2025年9月18日 22:50

需求开发场景

在说docker之前,我们先来看看一般的需求开发和部署场景,是否需要安装node

需求开发部署场景

开发环境,我们使用windows或mac,开发前端项目,正常来说,都是要安装好对应node版本 ,使用node提供的npm包管理(构建)工具【除非是一个简单的只有hello world的html文件不用node】

生产环境,要发布到服务器上

  • 1. 静态SPA单页面应用部署
  • 服务器上不需要安装nodejs,使用使用nginx代理一下请求即可
  • 就算是多个单页面项目,我们在本地开发使用nvm管理一下node版本(比如有node12的老项目,也有node24的新项目)
  • 打包的dist,丢到服务器上后,依旧不需要在在服务器上安装node

  • 2. SSR服务端渲染部署
  • 除了静态SPA以外,我们也可能也要去写SSR应用
  • SSR实际上就是通过nodejs运行环境,在服务器上执行js代码
  • 比如解析路由、发请求拿后端数据、拼接生成html返回给前端的浏览器请求
  • 因此,SSR服务端渲染的生产环境的部署,服务器上,必须安装nodejs(当然,也要使用到nginx代理请求)
  • 如果,某个服务器上,只是部署一个SSR项目还好,我们只需要安装对应的node版本
  • 但是,如果有两个甚至多个SSR项目,且对应的node版本不一致(如需要node12和node24版本)
  • 那么,我们就需要在服务器上,安装nvm进行node版本的管理,不同的node版本前端项目,使用nvm切换到对应的node版本,然后,npm start跑起来项目【可使用pm2管理多进程】

  • 3. BFF中间层部署
  • BFF中间层,相当于一个服务层(中间层)
  • 就是,把后端的 “通用接口” 转化为前端的 “专属接口”
  • 比如,使用Express/Koa启一个服务(依赖node)(当然,也要使用到nginx代理请求)
  • 流程:用户 → 前端应用(PC/移动端/小程序) → BFF 中间层 → 后端服务 → 去数据库捞数据
  • 同样的,这个情况,和上述一样
  • 安装nvm进行node版本的管理,不同的BFF中间层,要切换对应node才能跑起来【可使用pm2管理多进程】

Web前端常见的三种部署方式

  1. 对于Vue或React的单页面应用,打包的dist静态资源,再搭配nginx
  2. 对于ssr(服务器渲染)或者bff(接口中间层),使用nvm管理node版本,同时使用pm2统一管理,再搭配nginx
  3. 使用docker镜像技术,一次构建,到处运行(最灵活的方式),基本上适合所有的前端部署方式。(辅以nginx)

接下来,说说docker镜像部署的好处

docker镜像部署前端项目的好处

1. 彻底解决环境一致性,不用再使用nvm做node版本切换

初学者,可以把docker容器镜像理解成:一个依赖宿主机的、封闭的、‘微型’服务器的内存运行环境空间吗(非虚拟机那么冗余、且凭借宿主机的操作系统内核可在此内存运行环境空间跑服务程序)

  • 假设多个ssr或者bff且node版本依赖不同的项目,有几个,就打包几个镜像
  • 可以对应依赖的node版本等打包到镜像里面(当然nginx也可以选择连带着打包到镜像里面)
  • 镜像与镜像之间是独立的,虽然node依赖版本不一样,但是ssr的服务不会和bff的服务产生冲突
  • 不用再想以前那样,还得额外注意node版本的切换管理
  • 收益,比较明显!!!

2. 实现 “一次构建,各个服务器环境上都能直接运行”

无论是Linux还是Windows服务器,都能运行打包好的镜像(可移植性强)

  • 假设,有一天,原来的生产服务器爆炸了、死机了、因不可抗力直接挂了。
  • 老板赶紧买了一台新的服务器,让把原来生产的前端项目,移植到新的服务器上,越快越好。
  • 传统情况就是,在新的服务器上装各个版本的node,安装nvm,再打包使用pm2管理部署(耗时,约为一个小时)
  • 但是,如果是使用docker,直接把原本的镜像,复制粘贴到新服务器上即可(耗时约10分钟)
  • 收益,十分明显!!!

3. docker版本管理与回滚更简单方便安全

  • 假设新项目上线后发现bug,需紧急切回上一版本
  • 若是传统项目部署方式,需要本地git回滚,再重新打包,再发布到服务器上(耗时5分钟)
  • 若是docker镜像部署方式,直接使用其自带的版本标签tag管理
  • 每个版本的项目对应一个镜像标签(如 v1.0.0v1.0.1),标签与代码版本一一对应,可追溯回滚
  • 回滚时,只需停止当前容器,用旧版本标签的镜像重新启动容器(如 docker run my-app:v1.0.0
  • 整个过程秒级完成,且不会影响当前文件(容器销毁后文件自动清理),安全可靠(耗时1分钟)
  • 收益,十分明显!!!

4. docker部署流程标准化、自动化

  • 传统前端部署流程通常是
  • 本地打包 → 用winscp等工具传文件到服务器 → 然后手动配置ngixn或者手动启动node服务
  • 步骤繁琐且依赖人工操作,有一定概率手抖了,人工操作出问题(虽然概率不大,但也是一个隐患,假设概率千分之一)
  • 如果使用docker搭配cicd持续集成工具可以将部署流程自动化
  • 实现 代码在gitlab提交后 → 点一点,就能够自动构建镜像 → 然后自动推送到镜像仓库 → 最后服务器自动拉取镜像并重启容器
  • 全程无需人工干预,基本不会出问题(假设出问题概率百万分之一)
  • 节省人工操作时间、隐患概率大大降低
  • 收益,十分明显!!!

如果只是简单的spa单页面应用的部署,且暂时没有cicd工具的公司项目,也可以自己搞一个效能工具,类似于cicd的自动化发布脚本,参考笔者的这篇文章:juejin.cn/post/733054…

实际上,因为前端部署项目的依赖比较少,主要是可能依赖node环境,如果是后端部署项目,那依赖的就多了,使用docker技术的优势,能够进一步加大的明显体现出来

记录docker部署一个简单的单页面spa前端项目

提前启动电脑的虚拟化、并下载安装 Docker Desktop、并启用 WSL 2

首先,windows电脑的虚拟化要开启的,如下图

按下Ctrl + Shift + Esc 打开任务管理器、然后选择 性能 标签

111.png

然后,打开 powershell 执行 wsl --list --online,查看可安装的linux发行版,初始条件下,肯定是没安装的,然后执行 wsl --install 自动安装默认linux发行版(笔者用的是Ubuntu)

再然后,就是安装完成后重启电脑,使 WSL2 生效

最后,访问 Docker 官网 下载适用于 Windows或者MAC 的安装包,这样docker的前置准备工作,就做好了

本文不做安装的赘述,可以另行查阅文章,实践安装(若是网络不行,就使用小梯子吧)

单页面应用的docker打包

  • 假设,笔者有一个vue或者react项目
  • 这个项目最终,打包成了一个html文件,如下:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <h1>每天学点新知识——docker</h1>
</body>
</html>

编写Dockerfile文件

  • 想要使用docker打包镜像,就得告诉docker,这个镜像要打包那些东西
  • 本案例中,是打包一个单纯的html文件
  • 同时,还要告诉docker,有哪些依赖也需要连带着打包进去
  • 本案例中,打包单纯的html文件不太够用
  • 还需要搭配nginx(把nginx也打包进镜像中去)
  • 没办法,单纯的静态文件,没法自己提供网页服务,必须搭配一个 “服务器软件”
  • 这样打包出来的镜像,就像一个 “自带服务器的小盒子”,不管放到哪台装了 Docker 的服务器上的机器上,都能直接跑起来

本案例的Dockerfile文件的编写很简单,就四行

  • FROM nginx:alpine
  • COPY index.html /usr/share/nginx/html/
  • EXPOSE 80
  • CMD ["nginx", "-g", "daemon off;"]

注释,释义如下

# 默认从 Docker Hub上下载基于Alpine Linux的轻量级版本的nginx,当执行docker打包镜像命令后,流程是:
# 自己windows电脑的命令行会触发Docker Desktop依据 WSL2 Linux内核从而下载nginx:alpine到WSL2文件系统
# 自己的nginx:alpine会下载到C:\Users\lss13\AppData\Local\Docker\wsl\disk文件夹中
# 有一个docker_data.vhdx硬盘映像文件
# 文件很大,类似压缩包,包含很多东西,其中就有下载的nginx:alpine镜像,也有构建出的新镜像和以往构建的老镜像
FROM nginx:alpine

# 将当前目录下的HTML文件复制到镜像中的/usr/share/nginx/html/目录
# 镜像最终存储在docker_data.vhdx虚拟磁盘中
# /usr/share/nginx/html/这个文件夹路径,是nginx用来默认存放静态资源的路径(规定,不用去修改)
# 至此,镜像文件中,已经包含了nginx的一堆东西和html,当然还有别的docker的一堆东西
COPY index.html /usr/share/nginx/html/

# EXPOSE不会实际开放端口,单纯的语法,不写也行(NGINX默认就是80端口)
EXPOSE 80

# 启动nginx  -g是全局配置命令  daemon off关闭后台运行模式
# (能够确保 nginx 前台运行,避免容器启动后立即退出)
# 这个cmd指令,会被存放在镜像文件中,当镜像被丢到服务器上后
# 当在服务器上执行docker run这个镜像的时候,才会进一步触发镜像里面的这个cmd命令执行
# 才会让镜像中的nginx启动起来,有这样的web服务,才能访问到镜像里面的html文件
CMD ["nginx", "-g", "daemon off;"]

编写打包构建镜像的js脚本

构建镜像,就一个核心的命令:docker build -t ${IMAGE_NAME} .

  • docker build:Docker 的构建命令,告诉 Docker “我要根据 Dockerfile文件里面的内容去构建镜像了”。
  • -t ${IMAGE_NAME}:给镜像 “贴标签”(指定名称),比如 -t my-nginx 就会把镜像命名为 my-nginx${IMAGE_NAME} 通常是一个变量,实际使用时会替换成具体名称,比如 my-webapp:v1)。
  • .:指定 Dockerfile 所在的路径(. 表示 “当前目录”),Docker 会从这个目录找 Dockerfile 文件,并读取里面的构建步骤。

当然,现在的我的 html 和 Dockerfile 和 要准备编写的 打包构建镜像的js脚本 在同一个文件夹里面(同级目录)

主要的核心,就是使用node的child_process的execSync,在命令行执行docker命令:

const { execSync } = require('child_process'); // 引入同步执行命令模块

const IMAGE_NAME = 'my-html-app'; // 给镜像起个名字

execSync(`docker build -t ${IMAGE_NAME} .`, { stdio: 'inherit' }); // 派发命令执行打包构建镜像

完整export-image.js脚本如下:

const { execSync } = require('child_process'); // 引入同步执行命令模块
const fs = require('fs'); // 引入文件系统模块

console.log('📦 开始构建和导出Docker镜像...');

// 配置变量
const IMAGE_NAME = 'my-html-app';
const TAR_FILE = `${IMAGE_NAME}.tar`;

try {
    // 检查Dockerfile是否存在
    if (!fs.existsSync('./Dockerfile')) {
        console.error('❌ 找不到Dockerfile文件');
        process.exit(1);
    }

    // 检查index.html是否存在
    if (!fs.existsSync('./index.html')) {
        console.error('❌ 找不到index.html文件');
        process.exit(1);
    }

    console.log('🔨 正在构建Docker镜像...');
    
    // 构建Docker镜像
    execSync(`docker build -t ${IMAGE_NAME} .`, { stdio: 'inherit' });
    
    console.log('\n✅ 镜像构建成功,开始导出镜像...');
    
    // 删除旧的tar文件(如果存在)
    if (fs.existsSync(TAR_FILE)) {
        fs.unlinkSync(TAR_FILE);
        console.log('🗑️  已删除旧的镜像文件');
    }
    
    // 导出镜像
    execSync(`docker save -o ${TAR_FILE} ${IMAGE_NAME}:latest`, { stdio: 'inherit' });
    
    // 检查文件是否成功创建
    if (fs.existsSync(TAR_FILE)) {
        const stats = fs.statSync(TAR_FILE);
        const fileSizeMB = (stats.size / (1024 * 1024)).toFixed(2);
        console.log('\n✅ 镜像导出成功!');
        console.log(`📁 导出文件: ${TAR_FILE} 文件大小: ${fileSizeMB} MB`);
        console.log('\n📋 接下来:');
        console.log('1. 复制 my-html-app.tar 和 deploy-to-server.sh 到Ubuntu');
        console.log('2. 在Ubuntu上运行: ./deploy-to-server.sh');
        
    } else {
        console.error('❌ 镜像导出失败');
        process.exit(1);
    }
    
} catch (error) {
    console.error('\n❌ 操作失败:');
    console.error(error.message);
    process.exit(1);
}
  • 我们可以在命令行中,执行这个脚本,比如:node export-image.js
  • 也可以,写一个bat脚本,这样也行,直接./build.bat回车

build.bat

@echo off
echo Let's start building Docker images...
node export-image.js

执行构建脚本

PixPin_2025-09-18_22-00-25.gif

构建出来的镜像产物

333.png

编写服务器发布镜像的shell脚本

逻辑很简单,就是把当前服务器上的,原来的容器镜像删除掉(如果有的话),然后,在执行docker run -d -p $PORT:80 --name $CONTAINER_NAME $IMAGE_NAME:latest 命令

上述命令释义:

  • docker run(Docker 启动容器的核心命令)
  • -d (后台运行模式detach 的缩写)
  • -p $PORT:80
    • -p是端口映射publish的缩写
    • $PORT 是笔者自己的服务器(宿主机)端口,我这里用20000端口,外部通过这个端口访问容器;
    • 80 是容器内部的端口(Nginx 默认监听 80 端口)
    • 意思是:把我服务器(宿主机)上的20000端口,和容器的80端口连起来,外部访问我服务器上的20000端口,就会被转到这个docker容器镜像的80端口,也就会访问到镜像里面的nginx,也就能够访问到镜像里面的对应目录/usr/share/nginx/html/中的index.html文件(就能看到对应内容了)

当然了 服务器不随便开放端口,这个20000端口,我不会开放,我只会使用我服务器上nginx,进行请求的转发到20000端口,如下 nginx 配置

# docker的demo
location /dockerDemo/ {
    proxy_pass http://localhost:20000/;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

部署脚本如下:

#!/bin/bash

echo "🚀 开始部署Docker镜像到服务器..."

# 配置变量(镜像名称在构建时指定,容器名称在运行时指定)
CONTAINER_NAME="html-app-container"
IMAGE_NAME="my-html-app"
TAR_FILE="${IMAGE_NAME}.tar"
PORT="20000"

# 检查tar文件是否存在
if [ ! -f "$TAR_FILE" ]; then
    echo "❌ 找不到镜像文件: $TAR_FILE"
    echo "请确保已将镜像文件复制到当前目录"
    exit 1
fi

echo "📁 找到镜像文件: $TAR_FILE"

# 停止并删除现有容器(如果存在)
echo "🛑 停止并删除现有容器..."
docker stop $CONTAINER_NAME 2>/dev/null || true
docker rm $CONTAINER_NAME 2>/dev/null || true

# 删除现有镜像(如果存在)
echo "🗑️  删除现有镜像..."
docker rmi $IMAGE_NAME:latest 2>/dev/null || true

# 导入镜像
echo "📥 导入Docker镜像..."
docker load -i $TAR_FILE

# 运行容器
echo "🚀 启动新容器..."
docker run -d -p $PORT:80 --name $CONTAINER_NAME $IMAGE_NAME:latest

# 检查容器状态
if [ $? -eq 0 ]; then
    echo ""
    echo "✅ 部署成功!"
    echo "📊 容器状态:"
    docker ps | grep $CONTAINER_NAME
    echo ""
    echo "🌐 访问地址:"
    echo "   直接访问: http://localhost:$PORT"
    echo "   通过nginx代理: https://ashuai.site/dockerDemo/"
    echo ""
    echo "📋 有用的命令:"
    echo "   查看日志: docker logs $CONTAINER_NAME"
    echo "   停止容器: docker stop $CONTAINER_NAME"
    echo "   重启容器: docker restart $CONTAINER_NAME"
else
    echo "❌ 容器启动失败"
    exit 1
fi

最后一步,在服务器上,部署构建好的镜像

把镜像文件和构建脚本都丢到服务器上,在对应文件夹中,执行部署脚本,如下图:

444.png

  • 这样,我们的构建好了的,镜像,就成功部署了
  • 由于笔者是通过nginx代理的
  • 所以,访问:ashuai.site/dockerDemo/
  • 就可以 看到对应的内容了
555.png

总结

docker镜像打包,好像看起来麻烦一点点,还得写Dockerfile,还得写构建脚本,和部署脚本啥的,但是它解决了环境版本一致性问题,bff和ssr不同node版本,毕竟管理起来,还是有些麻烦的,还有服务器更换,要是重新安装各种版本,那的确耗时。(打包前端项目,有点明显、打包后端项目十分明显)

如果打包bff,我们可以编写如下的Dockerfile

# 基础镜像:用轻量的 Node.js 16 版本(alpine 版本体积小)
FROM node:16-alpine

# 创建工作目录(类似在服务器上建一个专门的文件夹放代码)
WORKDIR /app

# 复制 package.json 和 package-lock.json(先复制依赖文件,利用 Docker 缓存加速构建)
COPY package*.json ./

# 安装依赖(npm install 会根据 package.json 下载所需的库)
RUN npm install --production  # --production 只装生产环境依赖,减小体积

# 复制 BFF 源代码(比如 server.js、路由文件等)
COPY . .

# 暴露 BFF 服务的端口(假设我的BFF的服务监听3000端口)
EXPOSE 3000

# 启动命令:运行 BFF 服务
CMD ["node", "server.js"]

所以,docker的优势有:

  1. 环境版本依赖一致性、隔离性与安全性
  2. 简化团队配置协作
  3. 可快速部署与扩展
  4. 而且,有了docker以后,CI/CD就更加好操作了(更好实现,标准化和自动化)
  5. 首次一劳————而后永逸

A good memory is better than a bad pen. Record it down...

ruoyi-vue(十五)——布局设置,导航栏,侧边栏,顶部栏

作者 Olrookie
2025年9月18日 21:51

一、布局设置

在前端页面有一个布局设置,点开可以设置主题风格,颜色,是否显示标签页等功能。

image.png

1.1 路由

页面所有请求都是通过路由,先来看路由:src→router→index.js。 路由中引用了Layout,从项目中的 src/layout 路径导入一个默认导出的组件,并将其命名为 Layout

import Layout from '@/layout'

在vite.config.js中通过resolve.alias设置了路径别名,将@映射到项目的src目录。import Layout from '@/layout' 实际上就是从 src/layout/index.vue 文件中导入默认导出的组件。

'@': path.resolve(__dirname, './src')

除了登录,注册,404等页面外基本都用了Layout

// 公共路由
export const constantRoutes = [
  {
    path: '/redirect',
    component: Layout,
    hidden: true,
    children: [
      {
        path: '/redirect/:path(.*)',
        component: () => import('@/views/redirect/index.vue')
      }
    ]
  },
  {
    path: '/login',
    component: () => import('@/views/login'),
    hidden: true
  },
  {
    path: '/register',
    component: () => import('@/views/register'),
    hidden: true
  },
  {
    path: "/:pathMatch(.*)*",
    component: () => import('@/views/error/404'),
    hidden: true
  },
  {
    path: '/401',
    component: () => import('@/views/error/401'),
    hidden: true
  },
  {
    path: '',
    component: Layout,
    redirect: '/index',
    children: [
      {
        path: '/index',
        component: () => import('@/views/index'),
        name: 'Index',
        meta: { title: '首页', icon: 'dashboard', affix: true }
      }
    ]
  },
  {
    path: '/user',
    component: Layout,
    hidden: true,
    redirect: 'noredirect',
    children: [
      {
        path: 'profile/:activeTab?',
        component: () => import('@/views/system/user/profile/index'),
        name: 'Profile',
        meta: { title: '个人中心', icon: 'user' }
      }
    ]
  }
]

// 动态路由,基于用户权限动态去加载
export const dynamicRoutes = [
  {
    path: '/system/user-auth',
    component: Layout,
    hidden: true,
    permissions: ['system:user:edit'],
    children: [
      {
        path: 'role/:userId(\\d+)',
        component: () => import('@/views/system/user/authRole'),
        name: 'AuthRole',
        meta: { title: '分配角色', activeMenu: '/system/user' }
      }
    ]
  },
  {
    path: '/system/role-auth',
    component: Layout,
    hidden: true,
    permissions: ['system:role:edit'],
    children: [
      {
        path: 'user/:roleId(\\d+)',
        component: () => import('@/views/system/role/authUser'),
        name: 'AuthUser',
        meta: { title: '分配用户', activeMenu: '/system/role' }
      }
    ]
  },
  {
    path: '/system/dict-data',
    component: Layout,
    hidden: true,
    permissions: ['system:dict:list'],
    children: [
      {
        path: 'index/:dictId(\\d+)',
        component: () => import('@/views/system/dict/data'),
        name: 'Data',
        meta: { title: '字典数据', activeMenu: '/system/dict' }
      }
    ]
  },
  {
    path: '/monitor/job-log',
    component: Layout,
    hidden: true,
    permissions: ['monitor:job:list'],
    children: [
      {
        path: 'index/:jobId(\\d+)',
        component: () => import('@/views/monitor/job/log'),
        name: 'JobLog',
        meta: { title: '调度日志', activeMenu: '/monitor/job' }
      }
    ]
  },
  {
    path: '/tool/gen-edit',
    component: Layout,
    hidden: true,
    permissions: ['tool:gen:edit'],
    children: [
      {
        path: 'index/:tableId(\\d+)',
        component: () => import('@/views/tool/gen/editTable'),
        name: 'GenEdit',
        meta: { title: '修改生成配置', activeMenu: '/tool/gen' }
      }
    ]
  }
]

1.2 组件

1.2.1 Template模板部分

<template>
  <div :class="classObj" class="app-wrapper" :style="{ '--current-color': theme }">
    <div v-if="device === 'mobile' && sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
    <sidebar v-if="!sidebar.hide" class="sidebar-container" />
    <div :class="{ hasTagsView: needTagsView, sidebarHide: sidebar.hide }" class="main-container">
      <div :class="{ 'fixed-header': fixedHeader }">
        <navbar @setLayout="setLayout" />
        <tags-view v-if="needTagsView" />
      </div>
      <app-main />
      <settings ref="settingRef" />
    </div>
  </div>
</template>
  • 根容器:
    • 使用动态class和style,根据主题设置背景色
    • 根据设备类型和侧边栏状态添加不同CSS类
  • 移动端遮罩层
    • 当在移动端且侧边栏打开时显示半透明黑色遮罩
    • 点击遮罩会触发handleClickOutside关闭侧边栏
  • 侧边栏组件:
    • 根据sidebar.hider状态决定是否显示侧边栏
  • 主内容区域:
    • 包含固定头部(导航栏和标签视图)和主要内容区域
    • 根据设置决定是否显示标签页视图
    • 集成设置组件

1.2.2 Script脚本部分

<script setup>
// 导入模块
import { useWindowSize } from '@vueuse/core'
import Sidebar from './components/Sidebar/index.vue'
import { AppMain, Navbar, Settings, TagsView } from './components'
import useAppStore from '@/store/modules/app'
import useSettingsStore from '@/store/modules/settings'

// 初始化设置状态管理实例,用于管理应用的主题、标签页显示等设置
const settingsStore = useSettingsStore()
//计算属性
const theme = computed(() => settingsStore.theme)
const sideTheme = computed(() => settingsStore.sideTheme)
const sidebar = computed(() => useAppStore().sidebar)
const device = computed(() => useAppStore().device)
const needTagsView = computed(() => settingsStore.tagsView)
const fixedHeader = computed(() => settingsStore.fixedHeader)

// 计算属性,判断是否需要固定头部
const classObj = computed(() => ({
  hideSidebar: !sidebar.value.opened,
  openSidebar: sidebar.value.opened,
  withoutAnimation: sidebar.value.withoutAnimation,
  mobile: device.value === 'mobile'
}))

// 根据侧边栏和设备状态返回相应的CSS类名对象,用于动态设置布局样式
const { width, height } = useWindowSize()
// 使用VueUse库获取窗口尺寸的响应式数据
const WIDTH = 992 // refer to Bootstrap's responsive design

// 定义断点宽度,小于该宽度视为移动设备
watch(() => device.value, () => {
  if (device.value === 'mobile' && sidebar.value.opened) {
    useAppStore().closeSideBar({ withoutAnimation: false })
  }
})

// 监听设备类型变化,当切换到移动设备且侧边栏打开时,关闭侧边栏并带动画
watchEffect(() => {
  if (width.value - 1 < WIDTH) {
    useAppStore().toggleDevice('mobile')
    useAppStore().closeSideBar({ withoutAnimation: true })
  } else {
    useAppStore().toggleDevice('desktop')
  }
})

// 响应式监听窗口宽度变化,当宽度小于断点时切换为移动设备模式并关闭侧边栏(无动画),
// 否则切换为桌面设备模式
function handleClickOutside() {
  useAppStore().closeSideBar({ withoutAnimation: false })
}

// 处理点击遮罩层事件,关闭侧边栏并带动画。
const settingRef = ref(null)
function setLayout() {
  settingRef.value.openSetting()
}
</script>
  • 导入模块
    • 使用@vueuse/core的useWindowSize监听窗口大小
    • 导入各种子组件(侧边栏、主内容、导航栏等)
  • 状态管理
    • 获取状态管理实例,用于管理应用的主题、标签页显示等设置
    • 计算属性包括主题、侧边栏状态、设备类型等
  • 自适应处理
    • 监听窗口大小变化,当宽度小于992px时切换为移动端模式
    • 移动端模式自动关闭侧边栏
  • 交互方法
    • handleClickOutside点击遮罩关闭侧边栏
    • setLayout打开设置面板

1.2.3 Style样式部分

<style lang="scss" scoped>
// 引入mixin和变量模块,分别命名为mix和vars
@use "@/assets/styles/mixin.scss" as mix;
@use "@/assets/styles/variables.module.scss" as vars;

// 应用包装器样式,使用clearfix清除浮动,设置相对定位,占满全屏
.app-wrapper {
  @include mix.clearfix;
  position: relative;
  height: 100%;
  width: 100%;
// 当在移动设备上且侧边栏打开时,使用固定定位。
  &.mobile.openSidebar {
    position: fixed;
    top: 0;
  }
}

// 移动端遮罩层样式,黑色半透明背景,覆盖全屏,层级为999。
.drawer-bg {
  background: #000;
  opacity: 0.3;
  width: 100%;
  top: 0;
  height: 100%;
  position: absolute;
  z-index: 999;
}

// 固定头部样式,使用固定定位,宽度为100%减去侧边栏宽度(200px),宽度变化有0.28秒过渡动画
.fixed-header {
  position: fixed;
  top: 0;
  right: 0;
  z-index: 9;
  width: calc(100% - #{vars.$base-sidebar-width});
  transition: width 0.28s;
}

// 当侧边栏隐藏时,固定头部宽度为100%减去54px(折叠后的侧边栏宽度)
.hideSidebar .fixed-header {
  width: calc(100% - 54px);
}

.sidebarHide .fixed-header {
  width: 100%;
}

// 当侧边栏完全隐藏时,固定头部占满全屏宽度。
.mobile .fixed-header {
  width: 100%;
}
</style>
  • 移动端打开侧边栏时使用固定定位
  • 不同状态下(隐藏侧边栏、移动端等)的样式适配

二、导航栏

导航栏在web页面上方,左侧是一个隐藏或展开侧边栏的按钮,紧挨着的是面包屑导航组件,右边是工具栏。 在这里插入图片描述

2.1 Navbar组件

src→layout→components→Navbar.vue Navbar组件是系统的顶部导航栏,包含以下主要功能:

  • 侧边栏控制:通过汉堡菜单控制侧边栏展开/收起
  • 导航显示:根据设置显示面包屑或顶部菜单
  • 快捷功能:搜索、全屏、主题切换、布局大小调整
  • 用户信息:显示用户头像和昵称,提供个人中心和退出登录功能
  • 项目链接:提供源码和文档的快速访问
  • 响应式设计:移动端隐藏部分功能

这个组件整合了系统顶部的大部分常用功能,为用户提供了便捷的操作入口。

2.1.1 Template模板部分

<template>
  // 创建navbar容器,作为导航栏的根元素。
  <div class="navbar">
    // 汉堡菜单组件,用于控制侧边栏的展开/收起状态,点击时触发toggleSideBar方法
    <hamburger id="hamburger-container" :is-active="appStore.sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />
    // 面包屑导航组件,当不使用顶部导航时显示,显示当前页面路径。
    <breadcrumb v-if="!settingsStore.topNav" id="breadcrumb-container" class="breadcrumb-container" />
    // 顶部导航菜单组件,当启用顶部导航时显示。
    <top-nav v-if="settingsStore.topNav" id="topmenu-container" class="topmenu-container" />

    // 右侧菜单容器,包含各种功能按钮和用户信息 
    <div class="right-menu">
      // 非移动设备上显示以下内容
      <template v-if="appStore.device !== 'mobile'">
        // 头部搜索组件,用于快速搜索菜单项
        <header-search id="header-search" class="right-menu-item" />
        // 显示项目源码链接的组件,使用Element Plus的tooltip提示"源码地址"。
        <el-tooltip content="源码地址" effect="dark" placement="bottom">
          <ruo-yi-git id="ruoyi-git" class="right-menu-item hover-effect" />
        </el-tooltip>
        // 显示项目文档链接的组件,使用Element Plus的tooltip提示"文档地址"。
        <el-tooltip content="文档地址" effect="dark" placement="bottom">
          <ruo-yi-doc id="ruoyi-doc" class="right-menu-item hover-effect" />
        </el-tooltip>
        // 全屏切换组件,用于切换页面全屏状态
        <screenfull id="screenfull" class="right-menu-item hover-effect" />
        // 主题切换组件
        <el-tooltip content="主题模式" effect="dark" placement="bottom">
          <div class="right-menu-item hover-effect theme-switch-wrapper" @click="toggleTheme">
            <svg-icon v-if="settingsStore.isDark" icon-class="sunny" />
            <svg-icon v-if="!settingsStore.isDark" icon-class="moon" />
          </div>
        </el-tooltip>
        // 布局大小选择组件
        <el-tooltip content="布局大小" effect="dark" placement="bottom">
          <size-select id="size-select" class="right-menu-item hover-effect" />
        </el-tooltip>
      // 结束非移动端条件渲染
      </template>

      // 用户信息下拉菜单,显示用户头像和昵称,提供个人中心链接和退出登录功能
      <el-dropdown @command="handleCommand" class="avatar-container right-menu-item hover-effect" trigger="hover">
        <div class="avatar-wrapper">
          <img :src="userStore.avatar" class="user-avatar" />
          <span class="user-nickname"> {{ userStore.nickName }} </span>
        </div>
        <template #dropdown>
          <el-dropdown-menu>
            <router-link to="/user/profile">
              <el-dropdown-item>个人中心</el-dropdown-item>
            </router-link>
            <el-dropdown-item divided command="logout">
              <span>退出登录</span>
            </el-dropdown-item>
          </el-dropdown-menu>
        </template>
      </el-dropdown>
      // 设置按钮,当启用设置功能时显示,点击打开设置面板
      <div class="right-menu-item hover-effect setting" @click="setLayout" v-if="settingsStore.showSettings">
        <svg-icon icon-class="more-up" />
      </div>
    </div>
  </div>
// 结束navbar容器
</template>

2.1.2 Script脚本部分

<script setup>
// 导入Element Plus的消息框组件,用于确认对话框
import { ElMessageBox } from 'element-plus'
// 导入所需子组件
import Breadcrumb from '@/components/Breadcrumb'
import TopNav from '@/components/TopNav'
import Hamburger from '@/components/Hamburger'
import Screenfull from '@/components/Screenfull'
import SizeSelect from '@/components/SizeSelect'
import HeaderSearch from '@/components/HeaderSearch'
import RuoYiGit from '@/components/RuoYi/Git'
import RuoYiDoc from '@/components/RuoYi/Doc'
// 导入状态管理模块
import useAppStore from '@/store/modules/app'
import useUserStore from '@/store/modules/user'
import useSettingsStore from '@/store/modules/settings'

// 初始化各状态管理实例
const appStore = useAppStore()
const userStore = useUserStore()
const settingsStore = useSettingsStore()

// 切换侧边栏展开/收起状态的方法。
function toggleSideBar() {
  appStore.toggleSideBar()
}

// 处理下拉菜单命令的通用方法,根据命令类型调用相应函数
function handleCommand(command) {
  switch (command) {
    case "setLayout":
      setLayout()
      break
    case "logout":
      logout()
      break
    default:
      break
  }
}
// 退出登录方法
function logout() {
  ElMessageBox.confirm('确定注销并退出系统吗?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
      // 确认后调用用户登出方法并跳转到首页
    userStore.logOut().then(() => {
      location.href = '/index'
    })
  }).catch(() => { })
}

// 定义并实现setLayout事件发射方法,用于通知父组件打开设置面板
const emits = defineEmits(['setLayout'])
function setLayout() {
  emits('setLayout')
}

// 切换主题模式方法,调用设置存储中的切换主题功能
function toggleTheme() {
  settingsStore.toggleTheme()
}
</script>

2.1.3 Style样式部分

<style lang='scss' scoped>
// 导航栏基础样式
.navbar {
  height: 50px;
  overflow: hidden;
  position: relative;
  background: var(--navbar-bg);
  box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);

  // 汉堡菜单容器样式
  .hamburger-container {
    line-height: 46px;
    height: 100%;
    float: left;
    cursor: pointer;
    transition: background 0.3s;
    -webkit-tap-highlight-color: transparent;

    &:hover {
      background: rgba(0, 0, 0, 0.025);
    }
  }

  // 面包屑容器左浮动 
  .breadcrumb-container {
    float: left;
  }

  // 顶部菜单容器绝对定位
  .topmenu-container {
    position: absolute;
    left: 50px;
  }

  // 错误日志容器样式
  .errLog-container {
    display: inline-block;
    vertical-align: top;
  }

  // 右侧菜单样式容器
  .right-menu {
    float: right;
    height: 100%;
    line-height: 50px;
    display: flex;

    &:focus {
      outline: none;
    }

    // 右侧菜单项通用样式
    .right-menu-item {
      display: inline-block;
      padding: 0 8px;
      height: 100%;
      font-size: 18px;
      color: #5a5e66;
      vertical-align: text-bottom;

      &.hover-effect {
        cursor: pointer;
        transition: background 0.3s;

        &:hover {
          background: rgba(0, 0, 0, 0.025);
        }
      }
      // 主题切换按钮样式
      &.theme-switch-wrapper {
        display: flex;
        align-items: center;

        svg {
          transition: transform 0.3s;
          
          &:hover {
            transform: scale(1.15);
          }
        }
      }
    }
    
    // 用户头像容器样式
    .avatar-container {
      margin-right: 0px;
      padding-right: 0px;

      .avatar-wrapper {
        margin-top: 10px;
        right: 5px;
        position: relative;

        .user-avatar {
          cursor: pointer;
          width: 30px;
          height: 30px;
          border-radius: 50%;
        }

        .user-nickname{
          position: relative;
          left: 5px;
          bottom: 10px;
          font-size: 14px;
          font-weight: bold;
        }

        i {
          cursor: pointer;
          position: absolute;
          right: -20px;
          top: 25px;
          font-size: 12px;
        }
      }
    }
  }
}
</style>

三、侧边栏

web页面左侧的侧边栏

image.png

3.1 Sidebar组件

src→layout→Sidebar→index.vue Sidebar组件实现了完整的侧边栏导航功能,具有以下特点:

  • 可配置性:支持显示/隐藏Logo、主题切换、折叠/展开等配置
  • 动态菜单:根据权限动态生成菜单项
  • 主题支持:支持多种主题和暗黑模式
  • 状态管理:与应用状态管理集成,保持状态同步
  • 自适应:根据屏幕大小和设置自动调整布局,颜色和样式根据主题动态调整 通过组合Logo、SidebarItem等子组件构建完整的侧边栏界面

3.1.1 Template模板部分

<template>
  // 创建侧边栏容器,根据showLogo的值决定是否添加'has-logo'类
  <div :class="{ 'has-logo': showLogo }" class="sidebar-container">
    // 条件渲染Logo组件,当showLogo为true时显示,传递collapse属性控制Logo的折叠状态
    <logo v-if="showLogo" :collapse="isCollapse" />
    // 使用Element Plus的滚动条组件包装菜单内容,设置包装类名为scrollbar-wrapper
    <el-scrollbar wrap-class="scrollbar-wrapper">
      // 创建Element Plus菜单组件,并设置以下属性
      <el-menu
        // 当前激活的菜单项
        :default-active="activeMenu"
        // 控制菜单是否折叠
        :collapse="isCollapse"
        // 菜单背景色
        :background-color="getMenuBackground"
        // 菜单文字颜色
        :text-color="getMenuTextColor"
        // 只保持一个子菜单展开
        :unique-opened="true"
        // 激活菜单项的文字颜色
        :active-text-color="theme"
        // 是否使用折叠动画
        :collapse-transition="false"
        // 菜单模式为垂直
        mode="vertical"
        // 菜单主题类
        :class="sideTheme"
      >
        // 遍历sidebarRouters,为每个路由创建SidebarItem组件,传递路由信息和基础路径
        <sidebar-item
          v-for="(route, index) in sidebarRouters"
          :key="route.path + index"
          :item="route"
          :base-path="route.path"
        />
      // 结束菜单和滚动条组件
      </el-menu>
    </el-scrollbar>
  </div>
</template>

3.1.2 Script脚本部分

<script setup>
// 导入所需组件和模块
import Logo from './Logo'
import SidebarItem from './SidebarItem'
import variables from '@/assets/styles/variables.module.scss'
import useAppStore from '@/store/modules/app'
import useSettingsStore from '@/store/modules/settings'
import usePermissionStore from '@/store/modules/permission'

// 获取路由和各状态管理实例
const route = useRoute()
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const permissionStore = usePermissionStore()

// 计算属性,获取权限存储中的侧边栏路由
const sidebarRouters = computed(() => permissionStore.sidebarRouters)
// 根据设置决定是否显示logo
const showLogo = computed(() => settingsStore.sidebarLogo)
// 获取侧边栏主题设置
const sideTheme = computed(() => settingsStore.sideTheme)
// 获取当前主题颜色
const theme = computed(() => settingsStore.theme)
// 根据侧边栏是否打开决定菜单是否折叠
const isCollapse = computed(() => !appStore.sidebar.opened)

// 根据暗黑模式和主题设置获取菜单背景色
const getMenuBackground = computed(() => {
  if (settingsStore.isDark) {
    return 'var(--sidebar-bg)'
  }
  return sideTheme.value === 'theme-dark' ? variables.menuBg : variables.menuLightBg
})

// 根据暗黑模式和主题设置获取菜单文字颜色
const getMenuTextColor = computed(() => {
  if (settingsStore.isDark) {
    return 'var(--sidebar-text)'
  }
  return sideTheme.value === 'theme-dark' ? variables.menuText : variables.menuLightText
})

// 根据路由元信息决定当前激活的菜单项
const activeMenu = computed(() => {
  const { meta, path } = route
  if (meta.activeMenu) {
    return meta.activeMenu
  }
  return path
})
</script>

3.1.3 Style样式部分

// 设置侧边栏容器背景色,使用v-bind绑定计算属性
<style lang="scss" scoped>
.sidebar-container {
  background-color: v-bind(getMenuBackground);
  // 设置滚动条包装器背景色
  .scrollbar-wrapper {
    background-color: v-bind(getMenuBackground);
  }

  // 设置菜单样式
  .el-menu {
    border: none;
    height: 100%;
    width: 100% !important;
    
    // 设置菜单项和子菜单标题在悬停时的背景色
    .el-menu-item, .el-sub-menu__title {
      &:hover {
        background-color: var(--menu-hover, rgba(0, 0, 0, 0.06)) !important;
      }
    }

    // 设置菜单项文字颜色,并为激活状态的菜单项设置特殊颜色和背景
    .el-menu-item {
      color: v-bind(getMenuTextColor);
      
      &.is-active {
        color: var(--menu-active-text, #409eff);
        background-color: var(--menu-hover, rgba(0, 0, 0, 0.06)) !important;
      }
    }

    // 设置子菜单标题文字颜色
    .el-sub-menu__title {
      color: v-bind(getMenuTextColor);
    }
  }
}
</style>

四、顶部栏

页面上方的菜单栏,开启后会在页面上方显示一级菜单 关闭状态: 在这里插入图片描述

打开状态: 在这里插入图片描述

4.1 TopNav组件

src→components→TopNav→index.vue TopNav组件是一个基于Element Plus的顶部水平导航菜单,主要功能包括:

  • 动态菜单显示:根据路由配置动态生成顶部菜单项
  • 菜单折叠:当菜单项过多时,自动将超出部分折叠到"更多菜单"下拉中
  • 自适应:根据屏幕宽度动态调整可见菜单项数量
  • 路由联动:与侧边栏菜单联动,点击顶部菜单项可切换侧边栏内容
  • 多类型链接支持:支持内部路由跳转和外部链接新窗口打开
  • 主题定制:支持主题颜色定制

该组件充分利用了Vue 3的响应式特性和组合式API,结合Element Plus组件库实现顶部导航菜单。

4.1.1 Template模板部分

<template>
  <el-menu
    // 绑定当前激活的菜单项
    :default-active="activeMenu"
    // 设置为水平模式
    mode="horizontal"
    // 菜单选项被选择时的处理函数
    @select="handleSelect"
    // 禁用菜单项的省略显示
    :ellipsis="false"
  >
    // 遍历显示顶部菜单项
    <template v-for="(item, index) in topMenus">
      <el-menu-item :style="{'--theme': theme}" :index="item.path" :key="index" v-if="index < visibleNumber">
        // 如果菜单项有图标则显示SVG图标
        <svg-icon
        v-if="item.meta && item.meta.icon && item.meta.icon !== '#'"
        :icon-class="item.meta.icon"/>
        // 显示菜单项标题
        {{ item.meta.title }}
      </el-menu-item>
    </template>

    <!-- 顶部菜单超出数量折叠 -->
    <el-sub-menu :style="{'--theme': theme}" index="more" v-if="topMenus.length > visibleNumber">
      // 折叠菜单的标题
      <template #title>更多菜单</template>
      // 显示被折叠的菜单项
      <template v-for="(item, index) in topMenus">
        <el-menu-item
          :index="item.path"
          :key="index"
          v-if="index >= visibleNumber">
        <svg-icon
          v-if="item.meta && item.meta.icon && item.meta.icon !== '#'"
          :icon-class="item.meta.icon"/>
        {{ item.meta.title }}
        </el-menu-item>
      </template>
    </el-sub-menu>
  </el-menu>
</template>

4.1.2 Script脚本部分

<script setup>
// 导入模块
import { constantRoutes } from "@/router"
import { isHttp } from '@/utils/validate'
import useAppStore from '@/store/modules/app'
import useSettingsStore from '@/store/modules/settings'
import usePermissionStore from '@/store/modules/permission'

// 顶部栏初始化
const visibleNumber = ref(null)
// 当前激活菜单的 index
const currentIndex = ref(null)
// 隐藏侧边栏路由
const hideList = ['/index', '/user/profile']

const appStore = useAppStore()
const settingsStore = useSettingsStore()
const permissionStore = usePermissionStore()
const route = useRoute()
const router = useRouter()

// 主题颜色
const theme = computed(() => settingsStore.theme)
// 所有的路由信息
const routers = computed(() => permissionStore.topbarRouters)

// 顶部显示菜单
const topMenus = computed(() => {
  let topMenus = []
  routers.value.map((menu) => {
    // 过滤隐藏菜单
    if (menu.hidden !== true) {
      // 兼容顶部栏一级菜单内部跳转
      if (menu.path === '/' && menu.children) {
          // 如果是根路径且有子菜单,使用第一个子菜单
          topMenus.push(menu.children[0])
      } else {
          // 否则直接使用该菜单
          topMenus.push(menu)
      }
    }
  })
  return topMenus
})

// 设置子路由
const childrenMenus = computed(() => {
  let childrenMenus = []
  routers.value.map((router) => {
    for (let item in router.children) {
      // 处理子路由路径
      if (router.children[item].parentPath === undefined) {
        if(router.path === "/") {
          router.children[item].path = "/" + router.children[item].path
        } else {
          if(!isHttp(router.children[item].path)) {
            router.children[item].path = router.path + "/" + router.children[item].path
          }
        }
        router.children[item].parentPath = router.path
      }
      childrenMenus.push(router.children[item])
    }
  })
  // 合并常量路由和处理后的子路由
  return constantRoutes.concat(childrenMenus)
})

// 默认激活的菜单
const activeMenu = computed(() => {
  const path = route.path
  let activePath = path
  // 根据当前路由路径确定激活的菜单项
  if (path !== undefined && path.lastIndexOf("/") > 0 && hideList.indexOf(path) === -1) {
    const tmpPath = path.substring(1, path.length)
    if (!route.meta.link) {
      activePath = "/" + tmpPath.substring(0, tmpPath.indexOf("/"))
      // 显示侧边栏
      appStore.toggleSideBarHide(false)
    }
  } else if(!route.children) {
    activePath = path
    // 隐藏侧边栏
    appStore.toggleSideBarHide(true)
  }
  activeRoutes(activePath)
  return activePath
})

// 设置可见菜单项数量
function setVisibleNumber() {
  const width = document.body.getBoundingClientRect().width / 3
  // 根据屏幕宽度计算可显示菜单项数量
  visibleNumber.value = parseInt(width / 85)
}

// 处理菜单项选择
function handleSelect(key, keyPath) {
  currentIndex.value = key
  const route = routers.value.find(item => item.path === key)
  if (isHttp(key)) {
    // http(s):// 路径新窗口打开
    window.open(key, "_blank")
  } else if (!route || !route.children) {
    // 没有子路由路径内部打开
    const routeMenu = childrenMenus.value.find(item => item.path === key)
    if (routeMenu && routeMenu.query) {
      let query = JSON.parse(routeMenu.query)
      router.push({ path: key, query: query })
    } else {
      router.push({ path: key })
    }
    appStore.toggleSideBarHide(true)
  } else {
    // 显示左侧联动菜单
    activeRoutes(key)
    appStore.toggleSideBarHide(false)
  }
}

// 激活路由
function activeRoutes(key) {
  let routes = []
  if (childrenMenus.value && childrenMenus.value.length > 0) {
    childrenMenus.value.map((item) => {
      if (key == item.parentPath || (key == "index" && "" == item.path)) {
        routes.push(item)
      }
    })
  }
  if(routes.length > 0) {
    // 设置侧边栏路由
    permissionStore.setSidebarRouters(routes)
  } else {
    appStore.toggleSideBarHide(true)
  }
  return routes
}

// 声明周期钩子
onMounted(() => {
  // 监听窗口大小变化
  window.addEventListener('resize', setVisibleNumber)
})

onBeforeUnmount(() => {
  // 移除事件监听
  window.removeEventListener('resize', setVisibleNumber)
})

onMounted(() => {
  // 初始化可见菜单项数量
  setVisibleNumber()
})
</script>

4.1.3 Style样式部分

<style lang="scss">
// 菜单项基本样式
.topmenu-container.el-menu--horizontal > .el-menu-item {
  float: left;
  height: 50px !important;
  line-height: 50px !important;
  color: #999093 !important;
  padding: 0 5px !important;
  margin: 0 10px !important;
}

// 激活菜单项样式
.topmenu-container.el-menu--horizontal > .el-menu-item.is-active, .el-menu--horizontal > .el-sub-menu.is-active .el-submenu__title {
  border-bottom: 2px solid #{'var(--theme)'} !important;
  color: #303133;
}

// 子菜单项样式
/* sub-menu item */
.topmenu-container.el-menu--horizontal > .el-sub-menu .el-sub-menu__title {
  float: left;
  height: 50px !important;
  line-height: 50px !important;
  color: #999093 !important;
  padding: 0 5px !important;
  margin: 0 10px !important;
}

/* 背景色隐藏 */
.topmenu-container.el-menu--horizontal>.el-menu-item:not(.is-disabled):focus, .topmenu-container.el-menu--horizontal>.el-menu-item:not(.is-disabled):hover, .topmenu-container.el-menu--horizontal>.el-submenu .el-submenu__title:hover {
  background-color: #ffffff;
}

/* 图标右间距 */
.topmenu-container .svg-icon {
  margin-right: 4px;
}

// 菜单箭头样式
/* topmenu more arrow */
.topmenu-container .el-sub-menu .el-sub-menu__icon-arrow {
  position: static;
  vertical-align: middle;
  margin-left: 8px;
  margin-top: 0px;
}


</style>

文件太大怎么上传?【分组分片上传大文件】-实战记录

作者 码间舞
2025年9月18日 21:41

前言

很久以前就想尝试大文件上传了,一直没有什么机会,主要是业务上没有契合的场景。前段时间偶然的一个契机尝试了一下,今天准备用一个实际案例来记录一下分组分片上传大文件

场景和背景

我用的vue3+element-plus,是的还是熟悉的配方,还是熟悉的味道,上传组件使用el-upload。需求是上传安装包,其实不算大,但是也会有几百兆。所以这里考虑使用分组分片上传。

分组上传

我这边直接使用http-request来自定义上传行为:

<el-upload
  v-model:file-list="form.fileExeList"
  accept=".exe"
  :limit="1"
  :disabled="isEdit"
  :on-preview="handlePreview"
  :on-remove="handleRemove"
  :before-remove="beforeRemove"
  :on-exceed="handleExceed"
  :http-request="httpRequest"
>
  <el-button type="primary" :disabled="isEdit">
    {{ t("versionManage.publish.dialog.uploadTips") }}
    {{ t("versionManage.publish.dialog.exe") }}
  </el-button>
</el-upload>

JS代码,我们先分析逻辑:

  1. 定义好chunk大小: chunkSize,拿到原始文件后进行分片,得到最终会有多少chunk: totalChunks。还需一个变量finishedChunks 来表示已经完成了几个chunk,用于展示进度条
  2. 由于被分割成了多个chunk,如果我们串行一个一个上传,那么分割的意义就没有了。为了节省时间,所以我们使用Promise.all来并行发出所有chunk,所以需要组装每个chunk的请求方法
  3. 等待所有chunk上传完毕后需要调用一个完成上传的方法告诉后端已经上传完毕
  4. 处理上传过程中的错误,尤其注意并行的时候发生错误弹窗不能多次弹出

好了,直接上源码:

const httpRequest = async (options) => {
  const { file, onSuccess, onError, onProgress } = options;
  const initRes = await initMultipartUpload({
    fileName: file.name,
    isPublic: true
  });
  if ((initRes.data as any)?.code) {
    ElMessage.error(t("buttons.upLoadFail"));
    onError && onError((initRes.data as any)?.msg);
    return;
  }
  const chunkSize = 5 * 1024 * 1024; // 5MB
  const rawFile = file.raw || file;
  const totalChunks = Math.ceil(rawFile.size / chunkSize);
  let finishedChunks = 0;
  let hasError = false;

  resendFormRef.value?.clearValidate();

  // 并行上传所有分片
  const uploadPromises = Array.from({ length: totalChunks }).map(
    async (_, currentChunk) => {
      const start = currentChunk * chunkSize;
      const end = Math.min(start + chunkSize, rawFile.size);
      const chunk = rawFile.slice(start, end);
      const fD = new FormData();
      fD.append("partData", chunk);
      try {
        const res = await uploadMultipartPart(
          {
            uploadId: initRes.data?.uploadId,
            partNumber: currentChunk + 1
          },
          fD
        );
        // throw new Error("error");
        if ((res.data as any)?.code) {
          hasError = true;
          onError && onError((res.data as any)?.msg);
          return Promise.reject((res.data as any)?.msg);
        }
        finishedChunks++;
        if (onProgress) {
          onProgress({
            percent: Math.round((finishedChunks / totalChunks) * 100)
          });
        }
      } catch (err) {
        !hasError && ElMessage.error(t("buttons.upLoadFail")); // 避免多次弹窗
        hasError = true;
        onError && onError(err);
      }
    }
  );

  await Promise.all(uploadPromises);

  if (!hasError) {
    const res = await completeMultipartUpload({
      uploadId: initRes.data?.uploadId
    });
    if ((res.data as any)?.code) {
      ElMessage.error(t("buttons.upLoadFail"));
      onError && onError((res.data as any)?.msg);
      return;
    }
    onSuccess({ response: { fileUrl: res.data?.key }, status: "success" });
    ElMessage.success(t("buttons.upLoadSuccess"));
  }
};

代码解析:

  • 首先我们需要线条用一个初始化的接口,这个接口告诉服务器我要开始上传一个大文件了,并提交大文件的一些基本信息,服务器返回一个uploadId,后续上传使用,这个uploadId相当于一个任务id,开起了一个上传任务。
  • 将文件分好片,然后调用分段上传即可。我们使用Promise.all来做并行这个操作,为此需要封装uploadPromise这个方法,创建一个数组,然后往里面塞我们的请求方法将文件按照chunkSize一点点进行上传,并告诉服务器当前chunk的顺序partNumber(服务器需要按照顺序拼接回去)
  • 分片上传了,就更容易展示进度条了。Promise.all里面的的请求每成功一次finishedChunks就加1,以此来展示当前上传的进度,这可不是假进度条了,货真价实的进度。
  • 所有分片上传完毕后,再调用一个completeMultipartUpload的方法告诉服务器当前大文件已上传完毕,服务器合并文件后就可以提供link了

问题拓展

代码写完了,上传了一个几十MB的文件测试没有问题,满心欢喜提测。

然鹅,测试甩了一个截图,所有part分片上传失败了,即uploadMultipartPart的上传失败了。原因是测试用另一个几百MB的.exe文件上传,我们的分片有好几十个,但是服务器来不及处理完所有就超时了,http请求超时自动cancel了。

怎么办呢?

1. 延长http请求的超时时间

治标不治本,超大文件、低网速环境下依然没有用

2. 将分片大小减少

一样治不了根,原因同上

3. 分组分片上传

将分好的分片放入一个组,比如五个为一组,然后一组一组的请求,每一组请求完毕后继续下一组,直到最后一组请求完毕可以解决这个问题。

4. 请求管道

思想同第三点类似,也是将分片的请求方法放入一个组中,只是这里是一个管道。比如这个管道能装5个请求,然后发起请求,管道里每请求完毕一个就加入一个在管道里,直到请求完毕

解决方案

我综合了一下,决定还是用分组分片来实现。没用管道,主要不想太麻烦。

思路:

  • 设定每组并发上传的分片数为 5(groupSize = 5)。
  • 外层循环以每组 5 个分片为单位遍历所有分片。
  • 每组内用一个数组 groupPromises 收集 5 个分片的上传 Promise。
  • 内层循环遍历当前组的每个分片,切割文件,放入 FormData,调用 uploadMultipartPart 上传。
  • 每个分片上传成功后,finishedChunks++,并通过 onProgress 回调实时更新进度百分比。
  • 如果某个分片上传失败,设置 hasError = true,并通过 onError 回调通知外部。
  • await Promise.all(groupPromises) 等待当前组的所有分片上传完成后再进入下一组。
  • 如果有分片上传失败(hasError),中断后续上传。

根据以上思路我们重写代码为:

const httpRequest = async (options) => {
  const { file, onSuccess, onError, onProgress } = options;
  const initRes = await initMultipartUpload({
    fileName: file.name,
    isPublic: true
  });
  if ((initRes.data as any)?.code) {
    ElMessage.error(t("buttons.upLoadFail"));
    onError && onError((initRes.data as any)?.msg);
    return;
  }
  const chunkSize = 5 * 1024 * 1024; // 5MB
  const rawFile = file.raw || file;
  const totalChunks = Math.ceil(rawFile.size / chunkSize);
  let finishedChunks = 0;
  let hasError = false;

  resendFormRef.value?.clearValidate();

  // 分组并发上传,每组5个分片,组间串行
  const groupSize = 5;
  for (let groupStart = 0; groupStart < totalChunks; groupStart += groupSize) {
    const groupEnd = Math.min(groupStart + groupSize, totalChunks);
    const groupPromises = [];
    for (
      let currentChunk = groupStart;
      currentChunk < groupEnd;
      currentChunk++
    ) {
      const start = currentChunk * chunkSize;
      const end = Math.min(start + chunkSize, rawFile.size);
      const chunk = rawFile.slice(start, end);
      const fD = new FormData();
      fD.append("partData", chunk);
      const promise = (async () => {
        try {
          const res = await uploadMultipartPart(
            {
              uploadId: initRes.data?.uploadId,
              partNumber: currentChunk + 1
            },
            fD
          );
          if ((res.data as any)?.code) {
            hasError = true;
            onError && onError((res.data as any)?.msg);
            return Promise.reject((res.data as any)?.msg);
          }
          finishedChunks++;
          if (onProgress) {
            onProgress({
              percent: Math.round((finishedChunks / totalChunks) * 100)
            });
          }
        } catch (err) {
          !hasError && ElMessage.error(t("buttons.upLoadFail"));
          hasError = true;
          onError && onError(err);
        }
      })();
      groupPromises.push(promise);
    }
    await Promise.all(groupPromises);
    if (hasError) break;
  }

  if (!hasError) {
    const res = await completeMultipartUpload({
      uploadId: initRes.data?.uploadId
    });
    if ((res.data as any)?.code) {
      ElMessage.error(t("buttons.upLoadFail"));
      onError && onError((res.data as any)?.msg);
      return;
    }
    onSuccess({ response: { fileUrl: res.data?.key }, status: "success" });
    ElMessage.success(t("buttons.upLoadSuccess"));
  }
};

总结

实际上,我还是把http的超时时间扩展了,主要是测试环境速度太拉跨了,分组本来也想用10个分片一组的,也是超时报错。最后受不了了,三管齐下,把之前分析的手段都用上了。既延长请求超时时间,也降低分组数量,还可以降低分片大小,慢慢传直到上传成功。

JS 原型链深度解读:从混乱到通透,掌握 90% 前端面试核心

2025年9月18日 21:32

JS 原型链深度解读:从混乱到通透,掌握 90% 前端面试核心

前言:你是否也被这些原型链问题折磨过?

" 为什么obj.toString()能调用却不在自身属性里?"

"prototype__proto__到底有什么区别?"

" 用class定义的类和原型链是什么关系?"

"修改原型对象为什么会影响所有实例?"

作为 JavaScript 的核心机制,原型链是理解继承、对象关系和内置方法的基础,却因其概念抽象、术语混淆和动态特性成为开发者的 "噩梦"。本文将从数据结构本质出发,通过 "概念拆解 + 代码实证 + 场景对比" 的方式,帮你彻底搞懂原型链,从此告别 "死记硬背式学习"。

一、原型链基础:从数据结构看透本质

在开始复杂的概念之前,我们先抓住原型链的本质 —— 它本质上是一种单向链表结构,用于实现对象间的属性委托访问。这种结构决定了它的行为特性,也带来了独特的优势和陷阱。

1.1 原型链的 "三角关系"

理解原型链的核心是搞懂三个基本概念的关系:构造函数 (Constructor)实例 (Instance)原型对象 (Prototype Object)

// 构造函数(本质是函数对象)

function Person(name) {

     this.name = name;

}

// 原型对象(构造函数的prototype属性指向它)

Person.prototype.sayHello = function() {

     console.log(\`Hello, \${this.name}\`);

};

// 实例对象(通过new创建)

const person = new Person("Alice");

这三者构成了原型链的基础三角关系:

  • 实例的__proto__属性指向原型对象

  • 原型对象的constructor属性指向构造函数

  • 构造函数的prototype属性指向原型对象

用代码验证这个关系:

console.log(person.\_\_proto\_\_ === Person.prototype); // true

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

console.log(person.constructor === Person); // true(通过原型链查找)

关键结论new操作符的本质是创建一个实例对象,并让实例的__proto__指向构造函数的prototype

1.2 原型链的链表结构与查找规则

原型链之所以被称为 "链",是因为每个原型对象本身也是对象,它也有自己的__proto__属性,形成链式结构:

// 原型链查找路径

person.sayHello(); // 自身未找到 → 查person.\_\_proto\_\_(Person.prototype)→ 找到

person.toString(); // 自身未找到 → 查Person.prototype → 未找到 → 查Person.prototype.\_\_proto\_\_(Object.prototype)→ 找到

person.foo(); // 遍历完整条链直到null → 未找到 → 返回undefined

原型链查找规则

  1. 访问对象属性时,先在对象自身查找

  2. 若未找到,则通过__proto__访问原型对象继续查找

  3. 以此类推,直到找到属性或到达链的终点null

  4. 整个过程是单向的,不能反向查找

用链表结构类比:

person → Person.prototypeObject.prototypenull

     ↑           ↑                ↑

实例        构造函数原型      顶层原型对象

性能提示:原型链查找是O(n)复杂度的线性搜索,链越长查找效率越低,应避免过深的原型链设计。

二、核心概念辨析:扫清术语迷雾

原型链的 confusion 很大程度来自于相似术语的混淆,我们需要精准区分每个概念的内涵和应用场景。

2.1 prototype vs proto:最易混淆的两个概念

这两个概念的区别可以用一句话概括:prototype是函数独有的属性,__proto__是对象实例的属性

特性 prototype proto
所有者 仅函数对象 所有对象(包括函数)
作用 定义实例共享的属性和方法 建立原型链,指向构造函数的 prototype
规范状态 标准特性 已弃用(推荐用 Object.getPrototypeOf)
典型用途 定义构造函数的共享方法 查看或修改原型链(不推荐)
// 函数才有prototype

function Foo() {}

console.log(Foo.prototype); // { constructor: Foo, \_\_proto\_\_: Object.prototype }

// 所有对象都有\_\_proto\_\_

const obj = {};

console.log(obj.\_\_proto\_\_ === Object.prototype); // true

console.log(Foo.\_\_proto\_\_ === Function.prototype); // true(函数也是对象)

最佳实践:避免使用__proto__操作原型链,应使用标准方法Object.getPrototypeOf()Object.setPrototypeOf()

2.2 构造函数与原型对象的协作

构造函数和原型对象分工明确:构造函数负责初始化实例属性,原型对象负责定义共享方法

function Person(name) {

     // 实例独有属性(每个实例都有独立副本)

     this.name = name;

     this.id = Date.now(); // 每次创建实例都生成新值

}

// 共享方法(所有实例共享同一个函数对象)

Person.prototype.sayHello = function() {

     console.log(\`Hello, \${this.name}\`);

};

const p1 = new Person("Alice");

const p2 = new Person("Bob");

console.log(p1.name === p2.name); // false(实例属性独立)

console.log(p1.sayHello === p2.sayHello); // true(原型方法共享)

这种设计的优势是内存高效:共享方法只在原型对象中存储一份,而非每个实例都复制一份。

2.3 继承 vs 委托:JavaScript 的独特实现

很多开发者误以为 JavaScript 的原型链是 "继承",但更准确的描述是委托(delegation):

  • 继承:传统面向对象中是属性和方法的复制

  • 委托:JavaScript 中是属性和方法的引用查找

// 这不是复制(继承),而是委托

Person.prototype.sayHello = function() {};

// 修改原型会影响所有实例(因为是共享引用)

Person.prototype.sayHello = function() {

     console.log(\`Hi, \${this.name}\`); // 所有实例都会使用新方法

};

这种动态委托特性使得 JavaScript 可以在运行时修改对象的行为,但也带来了维护挑战。

三、ES6 class 与原型链:语法糖下的本质

ES6 引入的class语法让代码更接近传统面向对象风格,但本质上仍是原型链的封装。理解class与原型链的关系,能帮你避免 "语法糖陷阱"。

3.1 class 语法的原型链本质

// ES6 class写法

class Animal {

     constructor(name) {

       this.name = name;

     }

        

     speak() {

       console.log(\`\${this.name} makes a noise\`);

     }

}

// 等价的ES5原型写法

function Animal(name) {

     this.name = name;

}

Animal.prototype.speak = function() {

     console.log(this.name + " makes a noise");

};

Babel 等转译工具会将class代码转换为原型链代码,证明class只是语法糖。

3.2 extends 实现的双重原型链

extends关键字创建的继承关系实际上建立了两条原型链:

  1. 子类实例的原型链(继承实例方法)

  2. 子类本身的原型链(继承静态方法)

class Dog extends Animal {

     constructor(name) {

       super(name); // 必须调用super()

     }

        

     bark() {

       console.log(\`\${this.name} barks\`);

     }

        

     static info() {

       return "Dogs are mammals";

     }

}

等价的原型链操作:

// 实例方法继承链

Object.setPrototypeOf(Dog.prototype, Animal.prototype);

// 静态方法继承链

Object.setPrototypeOf(Dog, Animal);

验证这两条链:

// 实例方法链:Dog实例 → Dog.prototype → Animal.prototype

const dog = new Dog("Buddy");

console.log(dog.bark); // Dog.prototype(自身)

console.log(dog.speak); // Animal.prototype(继承)

// 静态方法链:Dog → Animal

console.log(Dog.info()); // Dog自身

console.log(Dog.prototype.constructor === Dog); // true

注意点:ES6 class 内部默认使用严格模式,且类方法不可枚举,这与 ES5 原型方法不同。

四、原型链实战:从基础到高级应用

掌握原型链的最佳方式是通过实际场景练习,以下是开发中最常用的原型链技巧和模式。

4.1 实现继承的三种方式对比

1. 原型链继承(基础版)
// 父类

function Parent() {

     this.name = "Parent";

}

Parent.prototype.getName = function() {

     return this.name;

};

// 子类

function Child() {}

// 核心:子类原型指向父类实例

Child.prototype = new Parent();

// 修复constructor指向

Child.prototype.constructor = Child;

const child = new Child();

console.log(child.getName()); // "Parent"(继承成功)

缺点:父类实例属性会被所有子类实例共享,容易导致意外修改。

2. 组合继承(推荐)
function Parent(name) {

     this.name = name;

}

Parent.prototype.getName = function() {

     return this.name;

};

function Child(name, age) {

     // 继承实例属性

     Parent.call(this, name);    

     this.age = age;

}

// 继承原型方法

Child.prototype = Object.create(Parent.prototype);

Child.prototype.constructor = Child;

const child = new Child("Alice", 18);

console.log(child.getName()); // "Alice"(正确继承)

优势:组合继承解决了原型链继承的共享问题,是 ES5 中最完善的继承方式。

3. 寄生组合继承(优化版)
function inheritPrototype(child, parent) {

     // 创建纯净的原型对象

     const prototype = Object.create(parent.prototype);

     prototype.constructor = child;

     child.prototype = prototype;

}

function Child(name, age) {

     Parent.call(this, name);

     this.age = age;

}

// 优化点:避免创建父类实例

inheritPrototype(Child, Parent);

优势:比组合继承更高效,避免了调用父类构造函数创建不必要的属性。

4.2 原型链在实际开发中的应用

场景 1:扩展原生对象功能(谨慎使用)
// 为数组添加求和方法

Array.prototype.sum = function() {

     return this.reduce((acc, cur) => acc + cur, 0);

};

\[1, 2, 3].sum(); // 6

警告:修改原生对象原型可能导致命名冲突和兼容性问题,大型项目中应避免。

场景 2:创建无原型的纯净对象
// 创建没有原型链的对象

const pureObj = Object.create(null);

console.log(pureObj.\_\_proto\_\_); // undefined

console.log(Object.getPrototypeOf(pureObj)); // null

// 用途:作为安全的哈希表

const map = Object.create(null);

map\["\_\_proto\_\_"] = "value"; // 不会污染原型链

优势:纯净对象避免了原型链污染攻击,适合作为数据容器。

场景 3:实现对象的类型判断
// 更可靠的类型判断函数

function getType(obj) {

     const type = Object.prototype.toString.call(obj);

     return type.slice(8, -1).toLowerCase();

}

getType(\[]); // "array"

getType(null); // "null"

getType(new Date()); // "date"

原理:利用Object.prototype.toString能准确返回对象类型的特性,这是原型链的典型应用。

五、原型链避坑指南:解决 90% 常见错误

原型链的动态特性和隐式行为容易导致难以调试的问题,这些常见陷阱你一定要避免。

5.1 误区 1:混淆__proto__和 prototype

// 错误示例

function Foo() {}

Foo.\_\_proto\_\_.bar = function() {}; // 错误地修改了Function.prototype

// 正确做法

Foo.prototype.bar = function() {}; // 给实例添加方法

记住prototype是函数用来定义实例方法的,__proto__是实例用来查找方法的。

5.2 误区 2:直接修改实例的__proto__

// 不推荐的做法

const obj = {};

obj.\_\_proto\_\_ = Array.prototype; // 修改原型链

// 推荐做法

const betterObj = Object.create(Array.prototype);

原因:修改现有对象的原型链是非常缓慢的操作,会破坏 JavaScript 引擎的优化。

5.3 误区 3:忘记修复 constructor 属性

// 错误示例

function Child() {}

Child.prototype = Object.create(Parent.prototype);

// 此时Child.prototype.constructor === Parent(错误)

const child = new Child();

console.log(child.constructor === Child); // false(不符合预期)

// 正确做法

Child.prototype.constructor = Child; // 修复constructor指向

影响:错误的constructor可能导致类型判断出错,尤其是在序列化和反序列化场景。

5.4 误区 4:原型链循环引用

// 危险操作:创建循环引用

const a = {};

const b = {};

a.\_\_proto\_\_ = b;

b.\_\_proto\_\_ = a; // 形成循环

// 访问属性会导致无限循环

a.foo; // 引擎会报错或崩溃

原理:原型链本质是单向链表,循环引用违反了这一结构,会导致属性查找进入死循环。

5.5 误区 5:在原型上定义引用类型属性

// 错误示例

function User() {}

User.prototype.tags = \[]; // 引用类型属性

const u1 = new User();

const u2 = new User();

u1.tags.push("js");

console.log(u2.tags); // \["js"](意外共享修改)

// 正确做法

function User() {

     this.tags = \[]; // 实例属性

}

原因:原型上的引用类型属性会被所有实例共享,应在构造函数中定义实例独有的引用类型属性。

六、原型链速查表:核心知识点汇总

6.1 关键属性与方法

概念 作用 最佳实践
prototype 函数属性,定义实例共享方法 用于添加实例方法
__proto__ 对象属性,指向原型对象 避免使用,改用Object.getPrototypeOf
constructor 原型对象指向构造函数的指针 继承后需手动修复指向
Object.getPrototypeOf() 获取对象原型 标准方法,推荐使用
Object.setPrototypeOf() 设置对象原型 谨慎使用,影响性能
Object.create() 创建指定原型的对象 推荐用于原型继承

6.2 原型链关系图

// 函数对象的原型链

FunctionFunction.prototypeObject.prototypenull

// 普通对象的原型链

{} → Object.prototypenull

// 数组的原型链

\[] → Array.prototypeObject.prototypenull

// 实例的原型链

new Foo() → Foo.prototypeObject.prototypenull

6.3 ES5 vs ES6 继承实现对比

特性 ES5 原型继承 ES6 class 继承
语法 手动设置 prototype extends 关键字
构造函数调用 Parent.call(this) super()
静态方法继承 手动设置 自动继承
代码可读性 较低 较高
底层机制 原型链 相同的原型链

结语:原型链的哲学与价值

JavaScript 的原型链机制体现了它的设计哲学 ——简单而灵活。不同于传统面向对象的类继承,原型链通过委托机制实现了更动态的对象关系。

掌握原型链不仅能帮你写出更优雅的代码,更能让你理解:

  • 为什么[]能调用Array.prototype的方法

  • 为什么async/await本质是原型链上的语法糖

  • 为什么框架能通过原型链实现强大的扩展能力

原型链的学习没有捷径,建议你:

  1. 画原型链关系图理解对象间的联系

  2. Object.getPrototypeOf()实际验证链结构

  3. 分析内置对象(如 Array、Promise)的原型链设计

当你能自如地运用原型链解决实际问题,你对 JavaScript 的理解就进入了新的层次。记住,最好的学习方式是在实践中不断验证和深化理解,原型链尤其如此。总而言之,一键点赞、评论、喜欢收藏吧!这对我很重要!

❌
❌