阅读视图

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

你一般用哪些状态管理库?别担心,Zustand和Redux就能说个10分钟


Zustand 与 Redux:现代前端状态管理的两种路径

在构建复杂的 React 应用时,组件之间的状态共享是一个绕不开的问题。随着页面层级加深、交互逻辑增多,单纯依赖 props 传递或 useState 管理状态很快就会变得难以维护。这时,开发者便需要一个更高效的解决方案——状态管理库

状态管理库的核心作用,是将分散在多个组件中的共享状态集中管理,实现跨组件通信、状态同步和可预测更新。目前,ReduxZustand 是 React 生态中最主流的两个选择。它们目标一致,但设计哲学截然不同。


Redux:可预测的“状态中心”

Redux 是最早流行起来的状态管理方案,其核心理念是“单一数据源”和“不可变更新”。它通过 Action → Reducer → Store 的模式,确保每一次状态变化都清晰可追溯。

为了降低使用门槛,官方推出了 Redux Toolkit(RTK),极大简化了传统 Redux 的样板代码。我们来看一个购物车功能的实现:

// store/cartSlice.js
import { createSlice } from '@reduxjs/toolkit';

const cartSlice = createSlice({
  name: 'cart',
  initialState: { items: [] },
  reducers: {
    addItem: (state, action) => {
      state.items.push(action.payload);
    },
    removeItem: (state, action) => {
      state.items = state.items.filter(item => item.id !== action.payload);
    }
  }
});

export const { addItem, removeItem } = cartSlice.actions;
export default cartSlice.reducer;
// store/store.js
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './cartSlice';

export const store = configureStore({
  reducer: {
    cart: cartReducer
  }
});

在组件中使用:

import { useSelector, useDispatch } from 'react-redux';
import { addItem } from './store/cartSlice';

function Product() {
  const cartItems = useSelector(state => state.cart.items);
  const dispatch = useDispatch();

  return (
    <div>
      <button onClick={() => dispatch(addItem({ id: 1, name: 'iPhone' }))}>
        加入购物车
      </button>
      <p>购物车数量: {cartItems.length}</p>
    </div>
  );
}

Redux 的优势在于其结构清晰、调试能力强(配合 Redux DevTools 可追踪每一次 action),非常适合大型项目和团队协作。但其缺点也很明显:即使使用 RTK,仍需定义 slice、action、reducer,代码量偏多,学习成本较高。


Zustand:极简主义的现代方案

与 Redux 的“仪式感”不同,Zustand 走向了轻量与简洁。它没有 action、没有 dispatch、不需要 Provider 包裹(Zustand 5+),只需一个 create 函数即可定义全局状态。

同样的购物车功能,用 Zustand 实现如下:

// store/useCartStore.js
import { create } from 'zustand';

const useCartStore = create((set) => ({
  items: [],
  addItem: (product) => set((state) => ({ items: [...state.items, product] })),
  removeItem: (id) => set((state) => ({
    items: state.items.filter(item => item.id !== id)
  })),
  clearCart: () => set({ items: [] })
}));

export default useCartStore;

组件中使用更加直接:

import useCartStore from './store/useCartStore';

function Product() {
  const { items, addItem } = useCartStore();

  return (
    <div>
      <button onClick={() => addItem({ id: 1, name: 'iPhone' })}>
        加入购物车
      </button>
      <p>购物车数量: {items.length}</p>
    </div>
  );
}

Zustand 的 API 极其简洁,几乎没有学习成本,适合中小型项目或快速开发。它天然支持异步操作,性能优化也做得很好(支持 selector 避免不必要渲染)。唯一的短板是调试体验不如 Redux 强大,且在大型项目中若缺乏规范,状态可能变得分散。


状态管理库的存在意义:从“能用”到“好用”

我们不禁要问:如果没有状态管理库,就不能开发复杂应用了吗?技术上讲,可以。通过 useContext + useReducer,我们也能实现全局状态共享。但问题在于可维护性

当应用规模扩大,状态依赖复杂,手动维护 props 传递和回调函数将成为一场灾难。状态管理库的价值,正在于它提供了一套标准化的解决方案,让状态的定义、更新、监听变得统一和可预测。

它不仅仅是“让状态共享更方便”,更是工程化思维的体现——将状态视为一种资源,集中管理、统一调度、可追溯、可测试。这种模式降低了团队协作的认知成本,提升了代码的长期可维护性。


如何选择?根据项目需求做判断

  • 选择 Redux(RTK):如果你的项目规模大、团队成员多、业务逻辑复杂,且需要强大的调试能力(如回放用户操作),Redux 依然是最稳妥的选择。
  • 选择 Zustand:如果你追求开发效率、希望减少样板代码、项目规模中等或以下,Zustand 是更轻快、现代的方案。

甚至,在一些项目中,两者可以共存:用 Redux 管理核心业务状态(如用户、权限),用 Zustand 管理 UI 状态(如弹窗、表单)。


结语

Zustand 与 Redux 代表了状态管理的两种路径:一种是严谨规范的“中心化治理”,一种是灵活自由的“去中心化协作”。它们没有绝对的优劣,只有适配与否。

技术的演进,不是为了制造复杂,而是为了在复杂中寻找秩序。状态管理库的存在,正是为了让开发者从琐碎的状态同步中解放出来,专注于业务本身——这才是其真正的价值所在。

Webpack 背后做了什么?

在现代前端开发中,我们通常会将代码拆分成多个模块,按功能组织文件,使用 ES6+的import/export语法进行依赖管理。同时,项目中还可能包含 TypeScript、JSX、CSS 模块等浏览器无法直接识别的资源。然而,浏览器并不原生支持这种模块化开发方式,尤其是在旧版本浏览器中,模块加载和高级语法都无法直接运行。

因此,我们需要一个工具,能够将这些分散的、现代化的代码预先处理、转换并合并成少数几个浏览器可以理解的 JavaScript 文件。这个过程就是“打包”(bundling)。

本文将带你一步步分析并实现一个简易 JavaScript 模块打包器(Bundler),通过使用 @babel/parser@babel/traverse@babel/core 等工具,完成以下流程:

  1. 读取入口文件内容
  2. 解析代码生成 AST(抽象语法树)
  3. 遍历 AST 分析模块依赖关系
  4. 将 ES6+ 代码转换为低版本 JavaScript(兼容性处理)
  5. 递归收集所有依赖模块信息,构建依赖图谱
  6. 生成可执行的 IIFE(立即执行函数)打包代码
  7. 输出最终打包结果

一、项目背景与目标

我们要做什么?

我们希望实现一个函数 bundle('./src/index.js'),它能:

  • index.js 为入口文件;
  • 分析其中所有的 import 语句;
  • 递归查找所有依赖模块;
  • 将每个模块的代码转换为 ES5 兼容语法;
  • 最终生成一段可以在浏览器中直接运行的 JavaScript 代码;
  • 并将这段代码输出到项目根目录。

这个过程,正是 Webpack 等打包工具的核心逻辑。


二、准备工作:依赖安装

为了实现上述功能,我们需要引入几个关键的 Babel 工具包:

npm install @babel/parser      # 将 JavaScript 代码解析成 AST
npm install @babel/traverse    # 遍历和修改 AST
npm install @babel/core       # 核心编译器,用于代码转换
npm install @babel/preset-env # 将 ES6+ 转换为低版本 JS

此外,我们还会用到 Node.js 内置模块:

  • fs:读取文件内容
  • path:处理路径

三、核心函数详解

1. getModuleInfo(filename):获取单个模块的信息

这是整个打包流程的第一步。该函数接收一个文件路径,返回该模块的详细信息:文件路径、依赖列表、转换后的代码。

const getModuleInfo = (filename) => {
  const content = fs.readFileSync(filename, 'utf-8');
  const ast = parser.parse(content, {
    sourceType: 'module'  // 表示这是一个 ES Module
  });
  
  const deps = {}; // 存储该模块的依赖关系

  traverse(ast, {
    ImportDeclaration({ node }) {
      const modulePath = path.resolve(path.dirname(filename), node.source.value);
      deps[node.source.value] = modulePath;
    }
  });

  const { code } = babel.transformFromAst(ast, null, {
    presets: ['@babel/preset-env']
  });

  return {
    file: filename,
    deps: deps,
    code: code
  };
};

详细解析:

  1. fs.readFileSync(filename, 'utf-8')
    同步读取指定文件的源码内容,作为字符串返回。

  2. parser.parse(content, { sourceType: 'module' })
    使用 @babel/parser 将源码字符串解析成 AST(抽象语法树)。
    AST 是代码的树状结构表示,便于程序分析和操作。例如:

    import msg from './msg.js';
    

    会被解析成包含 type: "ImportDeclaration" 的节点。

  3. traverse(ast, { ImportDeclaration(...) })
    使用 @babel/traverse 遍历 AST,寻找所有 import 声明。

    • node.source.value 是导入的模块路径(如 './msg.js');
    • 使用 path.resolve() 将相对路径转为绝对路径,避免后续查找出错;
    • 将映射关系存入 deps对象:{ './msg.js': '/project/src/msg.js' }
  4. babel.transformFromAst(ast, null, { presets: ['@babel/preset-env'] })
    使用 Babel 将 AST 转换为低版本 JavaScript代码(如 ES5),确保兼容老浏览器。
    输出的是标准 JS 字符串,不再包含 import/export 语法。

  5. 返回模块信息对象
    包含文件路径、依赖映射、转译后代码,供后续打包使用。


2. parseModules(file):构建完整的依赖图谱

这一步是打包器的核心——递归分析所有依赖,构建依赖图(Dependency Graph)。

const parseModules = (file) => {
  const entry = getModuleInfo(file);
  const temp = [entry];
  
  // 广度优先遍历所有依赖
  for (let i = 0; i < temp.length; i++) {
    const deps = temp[i].deps;
    for (const key in deps) {
      if (deps.hasOwnProperty(key)) {
        temp.push(getModuleInfo(deps[key]));
      }
    }
  }

  // 构建成图结构
  const depsGraph = {};
  temp.forEach(moduleInfo => {
    depsGraph[moduleInfo.file] = {
      deps: moduleInfo.deps,
      code: moduleInfo.code
    };
  });

  return depsGraph;
};

详细解析:

  1. 从入口文件开始
    调用 getModuleInfo(file) 获取主模块信息,并放入 temp 数组。

  2. 广度优先遍历(BFS)所有依赖
    使用 for 循环遍历 temp,每遇到一个模块,就检查它的 deps,把每个依赖也调用 getModuleInfo 加入 temp
    这样就能递归地把整个依赖树“展开”成一个扁平数组。

  3. 构建依赖图 depsGraph
    将所有模块信息组织成一个对象,以文件路径为键,值为 {deps, code}
    示例结构如下:

    {
      "/project/src/index.js": {
        deps: { "./msg.js": "/project/src/msg.js" },
        code: "var msg = ...;"
      },
      "/project/src/msg.js": {
        deps: {},
        code: "var msg = 'Hello'; exports.default = msg;"
      }
    }
    

这个 depsGraph 就是 Webpack 所说的“依赖图谱”,它是后续打包执行的基础。


3. bundle(file):生成最终可执行的打包代码

最后一步,我们要把所有模块和它们之间的依赖关系组合成一个可以直接在浏览器中运行的文件。

思路是:把所有模块的代码都塞进一个大的函数里,通过一个 require 函数来控制每个模块的加载和执行顺序,确保依赖被正确引用。我们用一个立即执行函数(IIFE)来包裹整个代码,避免污染全局环境。

最终生成的代码结构大致如下:

const bundle = (file) => {
  const depsGraph = JSON.stringify(parseModules(file));
  return `(function(graph){
    function require(file){
      function absRequire(relPath){
        return require(graph[file].deps[relPath]);
      }
      var exports = {};
      (function(absRequire, exports, code){
        eval(code);
      })(absRequire, exports, graph[file].code);
      return exports;
    }
    require(${JSON.stringify(file)});
  })(${depsGraph});`;
}

详细解析:

  1. JSON.stringify(parseModules(file))
    将依赖图对象序列化为字符串,以便插入到生成的代码中。

  2. 返回一个 IIFE(立即执行函数)

    (function(graph){ ... })(depsGraph)
    
    • 定义一个函数,接收 graph(即依赖图);
    • 立即传入 depsGraph 执行;
    • 实现了作用域隔离,避免污染全局。
  3. require(file) 函数:模拟模块加载机制

    • absRequire(relPath):根据相对路径查找绝对路径并递归加载;
    • exports:模拟 CommonJS 的 exports 对象,用于收集模块导出;
    • eval(code):动态执行模块代码(此时代码已转为 ES5,无 import/export);
    • 返回 exports,模拟 module.exports
  4. 启动入口模块

    require(${JSON.stringify(file)})
    

    使用 JSON.stringify 安全地插入入口文件路径,开始执行整个应用。

  5. 整体结构就是一个自包含的 JS 包
    输出的是一段完整的 JavaScript 字符串,可以直接写入文件或在浏览器中运行。


四、完整流程图解

[入口文件 index.js]
        ↓
   readFileSync → 读取源码
        ↓
   @babel/parser → 生成 AST
        ↓
   @babel/traverse → 提取 import 依赖
        ↓
   @babel/core → 转换为 ES5 代码
        ↓
   getModuleInfo() → 得到单个模块信息
        ↓
   parseModules() → 递归分析所有依赖,构建 depsGraph
        ↓
   bundle() → 生成 IIFE 打包代码
        ↓
   输出到文件或执行

五、运行示例

假设项目结构如下:

/project
  ├── src/
  │   ├── index.js
  │   └── msg.js
  └── bundle.js

src/index.js

import msg from './msg.js';
console.log(msg);

src/msg.js

export default 'Hello from msg!';

调用:

const bundledCode = bundle('./src/index.js');
fs.writeFileSync('./output.js', bundledCode);

生成的 output.js 内容大致如下:

(function(graph){
  function require(file){
    function absRequire(relPath){
      return require(graph[file].deps[relPath]);
    }
    var exports = {};
    (function(absRequire, exports, code){
      eval(code);
    })(absRequire, exports, graph[file].code);
    return exports;
  }
  require("/project/src/index.js");
})({
  "/project/src/index.js": {
    deps: { "./msg.js": "/project/src/msg.js" },
    code: "var msg = absRequire('./msg.js'); console.log(msg);"
  },
  "/project/src/msg.js": {
    deps: {},
    code: "var msg = 'Hello from msg!'; exports.default = msg;"
  }
});

这段代码可以在浏览器中直接运行,输出 Hello from msg!

JavaScript高级程序设计(第5版):代码整洁之道

每个系列一本前端好书,帮你轻松学重点。

本系列来自曾供职于Google的知名前端技术专家马特·弗里斯比编写的 《JavaScript高级程序设计》(第5版)

前文提到几种编程范式,其一是“面向过程”,它的特点:将复杂事务分解为简单事务

听起来富有哲理,但世界上不存在万能范式,每个单一范式在放任自流的情况下都会暴露其弊端。

“面向过程”的弊端就是太零散,看不清代码之间的关系,同时逻辑难以复用,你只知道整个文件在干什么,无法轻易理解各部分是如何协同工作的。

这就涉及到“角色”与“分工”,只有角色清晰、分工明确,你才不怕去“阅读”和“修改”。

别心存侥幸,每个项目都注定从简单走向复杂,且速度惊人,要提高可读性和维护性,就需要控制代码的颗粒度,以及为它们找到合适的归属。

数据封装

数据封装,是为变量和行为找到归属。

看一段代码:

// 定义变量
let studentName = "张三";
let studentAge = 20;
let studentGrade = "大二";
let studentScores = [859078];

// 操作变量的函数
function printStudentInfo() {
  console.log(`姓名: ${studentName}, 年龄: ${studentAge}, 年级: ${studentGrade}`);
}

function calculateAverageScore() {
  let sum = studentScores.reduce((a, b) => a + b, 0);
  return sum / studentScores.length;
}

// 使用变量和函数
printStudentInfo();
console.log(`平均分: ${calculateAverageScore()}`);

这段代码有什么问题吗?没有问题,甚至“新手友好”,但就像是最简单的日记形式—流水账。

它有几宗罪:

1、变量和方法散落在全局作用域中,缺乏组织

2、每个变量名都需要添加student前缀,以避免产生冲突

3、数据和操作数据的方法完全独立,没有关联

4、只能处理单个student,无法处理多个

总结:可用,但不够好。

做个调整:

// 使用对象封装学生信息及相关操作
const student = {
  name"张三",
  age20,
  grade"大二",
  scores: [859078],
  
  printInfo() {
    console.log(`姓名: ${this.name}, 年龄: ${this.age}, 年级: ${this.grade}`);
  },
  
  calculateAverageScore() {
    const sum = this.scores.reduce((a, b) => a + b, 0);
    return sum / this.scores.length;
  }
};

// 使用对象的方法
student.printInfo();
console.log(`平均分: ${student.calculateAverageScore()}`);

感觉好多了?所有的变量和方法都归属于student对象,有组织,有关联,要再创建一个也更简单。

但似乎还不完美,当需要创建多个student的时候,要一个个写,依然不方便。

再来调整:

// 将student定义为一个类
class Student {
  constructor(name, age, grade, scores) {
    this.name = name;
    this.age = age;
    this.grade = grade;
    this.scores = scores;
  }
  
  printInfo() {
    console.log(`姓名: ${this.name}, 年龄: ${this.age}, 年级: ${this.grade}`);
  }
  
  calculateAverageScore() {
    const sum = this.scores.reduce((a, b) => a + b, 0);
    return sum / this.scores.length;
  }
}

// 创建学生实例
const student1 = new Student("张三"20"大二", [859078]);
const student2 = new Student("李四"21"大三", [928895]);

student1.printInfo();
console.log(`平均分: ${student1.calculateAverageScore()}`);

这段代码,将原先的对象字面量改为一个名为Student的class。

class称为类,在很多语言中都有,JavaScript中直到ES6才正式引入,实际上它不是什么新魔法,背后使用的仍是原型和构造函数。

从封装的层面,它和对象类似,但从动态性和灵活性,它更强大,其中的属性都变量化,放到了类的构造函数中,需要创建一个新的student时,只要 new Student 即可,不论是创建1个,还是100个,都显得便捷又优雅。

这,就是“面向对象”编程的魅力。

除此之外,类还有哪些特性?

最出色的就是原生支持继承。

class GoodStudent extends student {}

使用extends关键字实现继承,得来的类称为“派生类”,可以通过原型链访问到类和原型上定义的方法。

const student3 = new GoodStudent("王五"21"大三", [928895]);
console.log(`平均分: ${student3.calculateAverageScore()}`);

GoodStudent中并未定义calculateAverageScore,但仍可调用,因为原型上有此方法。

“封装”和“继承”已经看了,“多态”是什么呢?

简单理解,就是多种形态,从父类继承共性,同时可以具备自有的个性

主要体现在对父类方法的重写和自有方法的定义。

class GoodStudent extends student {
 constructor(name, age, grade, scores) {
     this.name = name;
     this.age = age;
     this.grade = grade;
     this.scores = scores;
   }
   
   // 父类方法重写
   printInfo() {
     console.log(`我是一名好学生,我的信息如下,姓名: ${this.name}, 年龄: ${this.age}, 年级: ${this.grade}`);
   }
 
   // 自有方法定义
   getGift(){
     console.log('好学生可以获得礼物哦!')
   }
}

如此,在创建GoodStudent实例的时候,它不仅具备一般student的属性和方法,还有自己的专有方法和输出内容。

现在,是不是觉得第一种写法有点“憨”?

行为封装

上面我们聊数据封装,其实已经涉及行为封装,就是类中的“方法”。

什么是“行为”,就是要做的事。

为什么“封装”?行为有如下特点:

1、需要处理数据

2、有相关的局部变量

3、有若干行代码

4、返回结果,或者对外部数据产生影响

多数时候,行为都不是一行代码能完成的(即便起初这样认为),所以行为的封装通常会用到“函数”。

calculateAverageScore() {
    const sum = this.scores.reduce((a, b) => a + b, 0);
    return sum / this.scores.length;
}

函数被定义后,有诸多好处,如:随时可以为其增加拓展性,或调整处理过程。

跟函数正式见个面:

function sum(){
 return 1 + 2;
}

这就完成了一个简单函数的定义,它的行为是,计算 1 + 2,结果是3。

显然,能力很有限,干不了别的,所以,函数可以传参。

function sum(num1,num2){
 return num1 + num2;
}

接收了参数,就代表无限可能,可以应对外部的动态变化。

但貌似还不够强大,只能传两个参数,如果参数个数不确定呢?当然可以,扩展操作符(...)来帮你。

let values = [12345];
function getSum() {
  let sum = 0;
  for (let i = 0; i < arguments.length; i++) {
    sum += arguments[i];
  }
  return sum;
}
let sum = getSum(...values); // 15

现在,你想传多少就传多少。

那么如果,有时候想传参,有时候不想传,没问题,可以设置默认参数:

function getSum(num1 = 1, num2 = 2) {
  return num1 + num2;
}

不传参的时候,就是 1 + 2,传了按照实参处理。

这个特性很实用,常用来设置“类型”或者“真假”,有需求的时候传参,没有就按默认的来。

闭包

顺便介绍一个跟函数紧密相关的概念—“闭包”,它有用途,也有“坑”,是面试中的常见问题。

什么是“闭包”?指的是那些引用了另一个函数作用域中变量的函数,通常在嵌套函数中出现。

function createComparisonFunction(propertyName) {
  return function (object1, object2) {
    let value1 = object1[propertyName];
    let value2 = object2[propertyName];
    if (value1 < value2) {
      return -1;
    } else if (value1 > value2) {
      return 1;
    } else {
      return 0;
    }
  };
}

说重点:

  • 函数内部又返回一个匿名函数
  • 匿名函数引用了一个外部变量propertyName
  • 当这个函数在其他地方被使用,它仍然引用着这个变量

这会导致一个现象,有时候你觉得值应该被更新,实际没有更新。

这里的关键就是“作用域链”。

函数执行时,每个执行上下文中都会有一个包含其变量的对象。全局上下文中的叫变量对象,它在代码执行期间始终存在,函数局部上下文中的叫活动对象,只在函数执行期间存在。

一般来说,函数执行完毕后,局部活动对象会被销毁,内存中只剩下全局作用域。

闭包的差异就在这,当createComparisonFunction方法返回匿名函数时,它的作用域链包含活动对象和全局变量对象,并且在执行完毕后仍保留对它的引用,不会销毁,这就是“闭包”现象。

我们似乎很少主动创建闭包,那么什么时候会无意中产生闭包?

  • 函数作为参数传递
  • 在循环中创建函数
  • 事件处理函数
  • 模块导出函数
  • 回调函数
  • 函数柯里化

如果你刚好用到它的特性,会带来便利,但如果确实无意间触发,它可能造成多余的性能开销或者内存泄漏,需谨慎。

个体封装

当我们把数据和行为做了封装,编码已经从原始状态往前进了一大步,可以高枕无忧了吗?

如果代码在200行内,确实是这样,但实际上,一个文件的代码达到2000行,甚至上万行,都是常见现象。

这个时候,量变产生质变,前述所有优点几乎荡然无存,维护代码只剩下负担。

所以,代码在被封装之后,还需要在恰当的时机继续拆分,而拆分最常见的形式就是“模块化”。

当下,JavaScript中主流的模块化包括:ESMCommonJS。

CommonJS

// 导出
module.exports = { ... };
// 或
exports.foo = bar;

// 导入
const module = require('./module');

ESM

// 导出
export const foo = 'bar';
export default function() { ... };

// 导入
import { foo } from './module.js';
import defaultFunc from './module.js';

你应该很眼熟,这就是日常项目中代码的样子,可能一些刚入行的朋友不知道,这么便利的书写方式其实来之不易,是技术专家们多年努力的结果。

由此衍生出的,现代工程的目录结构也就明晰了:

components:组件文件

utils:工具方法文件

api:接口请求文件

store:状态管理文件

config:配置文件

每类文件都做着不同的事,但当你深入查看,它们无一例外都做了模块化拆分。

那么两种模块化机制有什么区别呢?这也是高频面试考点。

CommonJS:

  • 文件名 .cjs 或 .js
  • 主要用在服务器端,不能在浏览器直接运行
  • 动态加载(运行时解析)
  • 同步加载
  • 导出值的拷贝

ESM:

  • 文件名 .mjs 或在 package.json 中设置 "type": "module"
  • 浏览器和Nodejs环境都支持
  • 静态加载(编译时解析)
  • 异步加载
  • 导出值的引用

可以看出,ESM的应用面更广,其中一些特性为现代化、高要求的开发提供了土壤,如可以进行Tree shaking、异步加载等。

小结

代码整洁,是一项产品无关、业务无关,甚至技术无关的事,极易被忽视。

但如果你有一定的编程经验,它又是很需要、难做好的事,当我们掌握了模块化的方法和技巧,就应该在编码时对自己提高要求,为整个项目的质量提升出一份力。

本篇文章,是本系列的倒数第二篇文章,下一篇,会带来什么内容作为收尾呢?

更多好文第一时间接收,可关注公众号:“前端说书匠”

Base64编码详解

Base64是一种将二进制数据编码为ASCII字符的编码方式。

它使用64个可打印字符(A-Z、a-z、0-9、+、/)来表示二进制数据,使数据可以在只支持文本的传输协议中安全传输。

base64 可以将二进制变成ASCII字符组成的字符串

  • 使数据可以在只支持文本的传输协议中安全传输。
  • 可以将二进制资源嵌入HTML、CSS、JSON中,减少 额外的网络请求

但是 base64 后字符串的体积会比原本二进制时体积要大 大约 33%

Base64 编码的过程可以分为以下几个步骤:

  1. 分组 : 将原始二进制数据每3字节(24位)为一组进行处理。

  2. 转换 : 每组24位被分成4个6位块。

  3. 映射 : 每个6位块转为十进制数,并映射到Base64字符表,得到对应字符。

  4. 填充: 如果数据长度不是3的倍数,最后一组会不足3字节:

  • 只剩1字节(8位) :补16个0,分4组6位,只用前2个Base64字符,后2位用=填充,输出格式为“XX==”。
  • 只剩2字节(16位) :补8个0,分4组6位,只用前3个Base64字符,最后1位用=填充,输出格式为“XXX=”。
  • 正好3字节:无需填充,直接输出4个Base64字符。

这样处理后,原始的二进制数据就被转化为只包含可打印字符的 Base64 字符串,适合在文本环境中安全传输和存储。

转换函数

  • atobbase64 => 字符串 ⇒ 反序列化
  • btoa字符串 => base64 ⇒ 序列化
// 编码字符串为Base64
const originalString = "Hello, World!";
const encoded = btoa(originalString);
console.log(encoded);
// 输出: "SGVsbG8sIFdvcmxkIQ=="

// 解码Base64为字符串
const decoded = atob(encoded);
console.log(decoded);
// 输出: "Hello, World!"

⚠️ btoa 和 atob 这两个函数不能直接处理中文等非 ASCII 字符

所以如果存在中文,需要先使用百分比编码等方式作为其中间编码过程

// 处理包含中文的字符串
function encodeUnicode(str) {
  return btoa(encodeURIComponent(str));
}

function decodeUnicode(str) {
  return decodeURIComponent(atob(str));
}

const chineseString = "你好,世界!";
const encoded = encodeUnicode(chineseString);
console.log(encoded);
// 输出: "5L2g5aW977yM5LiW55WM77yB"

const decoded = decodeUnicode(encoded);
console.log(decoded);
// 输出: "你好,世界!"

鸿蒙ArkTS 与 Native 交互场景分类总结与关键实现

Native侧跨HAR/HSP模块接口调用-程序包结构-应用框架 - 华为HarmonyOS开发者

ArkTS 与 Native 交互场景分类总结与关键实现

一、场景分类总结

场景类型 调用方向 适用场景 技术特点
ArkTS → Native HAP ArkTS → 同模块 Native 高性能计算、硬件操作 使用 Node-API 直接调用
Native → Native HAP Native → HAR/HSP Native 跨模块复用底层能力 头文件导出 + SO 链接
Native → ArkTS HAR/HSP Native → 同模块 ArkTS Native 回调业务逻辑 napi_load_module 动态加载

二、关键代码流程详解

场景1:ArkTS 调用同模块 Native

调用链HAP ArkTS → HAP Native

关键流程

// ArkTS 侧 (index.ets)
import napi from 'libentry.so'

Button('调用Native方法').onClick(() => {
  const result = napi.add(2, 3) // 调用Native方法
})
// Native 侧 (napi_init.cpp)
static napi_value Add(napi_env env, napi_callback_info info) {
  // 1. 解析参数
  size_t argc = 2;
  napi_value args[2];
  napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
  
  // 2. 类型转换
  double value0, value1;
  napi_get_value_double(env, args[0], &value0);
  napi_get_value_double(env, args[1], &value1);
  
  // 3. 执行计算
  double sum = value0 + value1;
  
  // 4. 返回结果
  napi_value result;
  napi_create_double(env, sum, &result);
  return result;
}

场景2:跨模块 Native 调用

调用链HAP Native → HAR/HSP Native

关键流程

// 被调用方 (HAR/HSP模块)
// napi_har.h
#pragma once
double harNativeAdd(double a, double b);

// napi_har.cpp
double harNativeAdd(double a, double b) {
  return a + b;
}
// 调用方 (HAP模块)
#include "napi_har.h" // 包含被调用方头文件

static napi_value invokeHarNative(napi_env env, napi_callback_info info) {
  // 直接调用跨模块函数
  double result = harNativeAdd(2, 3);
  
  napi_value sum;
  napi_create_double(env, result, &sum);
  return sum;
}

场景3:Native 调用 ArkTS

调用链HAR/HSP Native → 同模块 ArkTS

关键流程

// ArkTS 侧 (Util.ets)
export function add(a: number, b: number): number {
  return a + b;
}
// Native 侧 (napi_har.cpp)
napi_value harArkTSAdd(double a, double b) {
  // 1. 加载ArkTS模块
  napi_value module;
  napi_load_module_with_info(env, 
    "static_module/src/main/ets/utils/Util", // 模块路径
    "com.example.app/entry",                // 包名/模块名
    &module);
  
  // 2. 获取函数引用
  napi_value addFunc;
  napi_get_named_property(env, module, "add", &addFunc);
  
  // 3. 准备参数
  napi_value argv[2];
  napi_create_double(env, a, &argv[0]);
  napi_create_double(env, b, &argv[1]);
  
  // 4. 调用函数
  napi_value result;
  napi_call_function(env, module, addFunc, 2, argv, &result);
  return result;
}

三、CMake 配置精要

1. 被调用方 (HAR/HSP) 配置

# CMakeLists.txt (HAR/HSP模块)
add_library(native_har SHARED
    napi_har.cpp
    napi_init.cpp)

# 设置头文件搜索路径
target_include_directories(native_har PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR})
// build-profile.json5 (关键导出配置)
{
  "buildOption": {
    "nativeLib": {
      "headerPath": "./src/main/cpp" // 暴露头文件
    }
  }
}

2. 调用方 (HAP) 配置

# CMakeLists.txt (HAP模块)
add_library(entry SHARED napi_init.cpp)

# 关键:链接被调用方库
target_link_libraries(entry PUBLIC
    native_har)  # 被调用方模块名::库名

# 包含被调用方头文件
target_include_directories(entry PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR}
    path/to/har_module/headers)
// oh-package.json5 (模块依赖声明)
{
  "dependencies": {
    "native_har": "file:../native_har_module"
  }
}

四、特殊场景处理技巧

  1. 环境传递(Native 调用 ArkTS 前置条件)
// HAP Native 初始化时传递环境
EXTERN_C_START
static napi_value Init(napi_env env, napi_value exports) {
  setHarEnv(env); // 将env传递给HAR模块
  return exports;
}
EXTERN_C_END
  1. HSP/HAR 路径差异处理
// HSP模块使用自身模块名
napi_load_module_with_info(env,
    "hsp_module/src/main/ets/MainPage",  // 实际模块名
    "com.example.app/hsp_entry",         // 包名/模块名
    &module);
  1. 解决模块加载失败
// build-profile.json5 添加运行时包声明
"buildOption": {
  "arkOptions": {
    "runtimeOnly": {
      "packages": ["native_har"] // 声明依赖包
    }
  }
}

五、最佳实践建议

  1. 头文件管理

    • 使用 #pragma once防止重复包含
    • 为跨模块接口创建专用头文件目录
  2. 符号导出控制

    • 使用 __attribute__((visibility("default")))显式导出函数
    __attribute__((visibility("default"))) 
    double harNativeAdd(double a, double b);
    
  3. 错误处理增强

napi_status status = napi_load_module_with_info(...);
if (status != napi_ok) {
  napi_throw_error(env, "MODULE_LOAD_FAIL", "Failed to load ArkTS module");
  return nullptr;
}
  1. 性能优化

    • 缓存频繁调用的 ArkTS 函数引用
    • 避免在循环中频繁加载模块

关键点总结:跨模块通信的核心在于头文件导出SO链接配置环境传递。对于Native调用ArkTS,需特别注意模块路径的正确性和运行环境的传递。

Vue3 响应式大对比:ref vs reactive,到底该怎么选?

reactive 响应式丢失的排查

最近,在使用vue3开发中遇到一个响应式丢失的场景:对一个用reactive声明的对象通过工具函数处理过后,页面没有同步更新数据,排查了很久,才发现是对对象进行解构和重新赋值后,导致了响应式的丢失,例举如下demo来复现一下上述问题:

<script setup lang="ts">
import { reactive, ref } from "vue";

let stateReactive = reactive({
  count: 0,
});

const anotherState = reactive({
  message: "Hello Vue 3!",
});

const res = { ...stateReactive, ...anotherState };

function changeRes() {
  stateReactive.count++;
  anotherState.message = "Hello Vue 3 - Updated!";
}

function changeState() {
  stateReactive = {
    count: 3,
  };
}
</script>

如上述图片所示,无论如何点击按钮,页面中的数据都没有发生改变,可见这两组数据响应式出现了丢失的情况。

reactive响应式丢失场景汇总

一举反三,接下来我来汇总一下reactive会丢失响应式的一些场景,帮助大家避免踩坑。

1. 解构赋值导致丢失

const state = reactive({ count: 0, user: { name: 'Tom' } })

// ❌ 直接解构会丢失响应式
const { count } = state 
console.log(count) // 0,但不是响应式了

// ✅ 解决:使用 toRefs / toRef
const { count } = toRefs(state)

2. 返回新对象覆盖

const state = reactive({ list: [1, 2, 3] })

// ❌ 重新赋值整个对象,响应式丢失
state = { list: [4, 5, 6] }

// ✅ 正确做法:修改属性而不是替换对象
state.list = [4, 5, 6]

3. reactive 包裹的对象被 JSON.parse / JSON.stringify 处理

const state = reactive({ user: { name: 'Tom' } })

// ❌ 转换后失去响应式
const newState = JSON.parse(JSON.stringify(state))

// ✅ 如果需要深拷贝,保留响应式,可以用结构化 clone + reactive
const newState = reactive(structuredClone(state))

4. 数组或对象直接解构/赋值

const state = reactive({ arr: [1, 2, 3] })

// ❌ 解构数组丢失响应式
const arr = state.arr 
arr.push(4) // 不会触发视图更新

// ✅ 使用 toRef 或者始终通过 state.arr 修改
const arr = toRef(state, 'arr')

5. 使用浅拷贝

<script setup>
const state = reactive({ user: { name: 'Tom' } })
const userCopy = { ...state.user } // ❌ userCopy 不是响应式
</script>

✅ 正确做法:直接用 state.user,或者 toRefs(state.user)

6. reactive 不能嵌套使用

const state = reactive({ user: reactive({ name: 'Tom' }) })

// ❌ 内层 reactive 会被 unwrap 掉,丢失预期响应式行为

正确做法:只用一次 reactive,嵌套对象内部会自动递归代理。

ref是否也会丢失响应式

是的,ref也会丢失响应式的,ref 包裹对象 和 reactive 包裹对象的知识表现不同,对ref包裹的对象进行解构依然会出现响应式丢失的情况。

<script setup lang="ts">
import { reactive, ref } from "vue";

const stateRef = ref({
  count: 0,
});
  
function changeState() {
  stateReactive = {
    count: 3,
  };
}

const resRef = { ...stateRef.value };

function changeResRef() {
  resRef.count++;
}
</script>

<template>
  <div class="card">
    <button type="button" @click="changeResRef">{{ resRef }}</button>
  </div>
</template>

这里的表现与上述图片一样,点击按钮,按钮中的数据不会发生变化。

ref和reactive丢失响应式场景对比

场景 reactive ref(对象) 具体例子
解构属性 ❌ 丢失 ❌ 丢失 const state = reactive({ count: 0 })const { count } = state // ❌ 非响应式const obj = ref({ count: 0 })const { count } = obj.value // ❌ 非响应式\n
替换整个对象 ❌ 丢失响应式 ✅ 推荐做法 const state = reactive({ a: 1 })state = { a: 2 } // ❌ 丢失响应式const obj = ref({ a: 1 })obj.value = { a: 2 } // ✅ 保持响应式\n
基本类型 ❌ 不支持 ✅ 支持 const num = reactive(0) // ❌ 无效const num = ref(0) // ✅ 正常\n
JSON.parse / 拷贝 ❌ 丢失 ❌ 丢失 const state = reactive({ a: 1 })const copy1 = JSON.parse(JSON.stringify(state))copy1.a = 2 // ❌ 不触发更新const obj = ref({ a: 1 })const copy2 = JSON.parse(JSON.stringify(obj.value))copy2.a = 2
数组解构 ❌ 丢失 ❌ 丢失 const state = reactive({ list: [1,2,3] })const list1 = state.listlist1.push(4)const obj = ref({ list: [1,2,3] })const list2 = obj.value.listlist2.push(4) // ❌ 不触发更新\n
props 传递 可能丢失 可能丢失 // 父组件\n<Child :data="state" />// 子组件const { data } = defineProps<{ data: any }>()// ❌ 解构 data 后丢失响应式\n

避免响应式丢失的方法

常见的解决方案有:

  • 使用 toRef / toRefs 保持解构后的响应式
const state = reactive({ count: 0 });
const { count } = toRefs(state);

setInterval(() => {
  state.count++;
  console.log("响应式 count:", count.value); // ✅ 会更新
}, 1000);
  • reactive声明的对象,需要指定修改对象属性,而不是整体覆盖(除非用 ref 包裹)
  • 避免 JSON.stringify / parse 破坏响应式
  • 在组件中不要直接解构 props,配合 toRefs 使用(重要)
<!-- Parent.vue -->
<Child :user="user" />

<script setup>
import { reactive } from "vue";
import Child from "./Child.vue";

const user = reactive({ name: "张三" });
</script>

<!-- Child.vue -->
<script setup>
import { toRefs } from "vue";

const props = defineProps({ user: Object });
const { name } = toRefs(props.user);

// ✅ 响应式 name
</script>

ref 与 reactive 的实践建议

使用建议

  • 小数据 / 基本类型 → ref
  • 复杂对象(状态树、多属性) → reactive
  • 需要整体替换的对象 → ref
  • 解构属性时 → 配合 toRefs

参考文献

ref和reactive你必须要知道的使用场景和差异

Vue.js官方文档

🔒 前后端 AES 加密解密实战(Vue3 + Node.js)

各位掘友好,我是庚云。

在前后端交互中,如果账号密码明文传输,很容易被窃取 ⚠️。虽然 HTTPS 已经可以保证传输安全,但很多业务场景下,我们还会在此基础上 额外增加一层 AES 加密,提升安全性。

今天分享一个完整的 AES 双向加解密 Demo

  • 前端(Vue3 + Vite):加密用户输入的密码,再传给后端
  • 后端(Node.js + Express):解密密码,校验登录
  • 后端返回一个加密的 token,前端再解密出来

👉 实现一个 前端加密 → 后端解密 → 后端加密 → 前端解密 的完整闭环。


📂 项目结构

aes-demo/
├── backend/                  # Node.js + Express 后端
│   ├── index.js              # 主入口,API 服务
│   ├── package.json
│   └── node_modules/
│
├── frontend/                 # Vue3 前端
│   ├── src/
│   │   ├── api/
│   │   │   └── login.ts      # 登录 API
│   │   ├── utils/
│   │   │   └── aes.ts        # AES 工具方法(加密/解密)
│   │   ├── views/
│   │   │   └── Login.vue     # 登录页面
│   │   ├── main.ts           # Vue 应用入口
│   │   └── index.html
│   ├── package.json
│   └── node_modules/

🖥️ 一、后端实现(Node.js 解密)

后端负责:

  1. 解密前端传过来的密码;
  2. 验证账号密码;
  3. 登录成功后,返回一个 加密后的 token

📁 backend/index.js

import express from 'express'
import bodyParser from 'body-parser'
import crypto from 'crypto'
import cors from 'cors'

const app = express()
app.use(cors())
app.use(bodyParser.json())

// AES 配置(和前端保持一致)
const KEY = Buffer.from('1234567812345678', 'utf8')  // 16 字节
const IV = Buffer.from('8765432187654321', 'utf8')   // 16 字节

// 解密
function decryptAES(cipherText) {
  const decipher = crypto.createDecipheriv('aes-128-cbc', KEY, IV)
  let decrypted = decipher.update(cipherText, 'base64', 'utf8')
  decrypted += decipher.final('utf8')
  return decrypted
}

// 加密
function encryptAES(text) {
  const cipher = crypto.createCipheriv('aes-128-cbc', KEY, IV)
  let encrypted = cipher.update(text, 'utf8', 'base64')
  encrypted += cipher.final('base64')
  return encrypted
}

app.post('/api/login', (req, res) => {
  const { username, password } = req.body
  const decryptedPwd = decryptAES(password)

  if (username === 'admin' && decryptedPwd === '123456') {
    // 登录成功,返回加密后的 token
    const token = encryptAES('this-is-a-secret-token')
    res.json({ code: 0, msg: '登录成功', token })
  } else {
    res.status(401).json({ code: 1, msg: '账号或密码错误' })
  }
})

app.listen(3001, () => {
  console.log('后端服务已启动:http://localhost:3001')
})

🌐 二、前端实现(Vue3 加密)

前端负责:

  1. 输入密码 → AES 加密 → 传给后端;
  2. 拿到后端返回的加密 token → AES 解密 → 得到明文。

📁 frontend/utils/aes.ts

import CryptoJS from 'crypto-js'

// 和后端约定的 KEY 和 IV
const KEY = CryptoJS.enc.Utf8.parse('1234567812345678')
const IV = CryptoJS.enc.Utf8.parse('8765432187654321')

// 加密
export function encryptAES(text: string): string {
  const encrypted = CryptoJS.AES.encrypt(text, KEY, {
    iv: IV,
    mode: CryptoJS.mode.CBC,
    padding: CryptoJS.pad.Pkcs7,
  })
  return encrypted.toString()
}

// 解密
export function decryptAES(cipherText: string): string {
  const decrypted = CryptoJS.AES.decrypt(cipherText, KEY, {
    iv: IV,
    mode: CryptoJS.mode.CBC,
    padding: CryptoJS.pad.Pkcs7,
  })
  return decrypted.toString(CryptoJS.enc.Utf8)
}

📁 frontend/api/login.ts

import axios from 'axios'
import { encryptAES } from '@/utils/aes'

export function login(username: string, password: string) {
  const encryptedPwd = encryptAES(password)

  return axios.post('http://localhost:3001/api/login', {
    username,
    password: encryptedPwd,
  })
}

📁 frontend/views/Login.vue

<template>
  <div>
    <input v-model="username" placeholder="用户名" />
    <input v-model="password" type="password" placeholder="密码" />
    <button @click="handleLogin">登录</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { login } from '@/api/login'
import { decryptAES } from '@/utils/aes'

const username = ref('admin')
const password = ref('123456')

const handleLogin = async () => {
  try {
    const res = await login(username.value, password.value)
    if (res.data.code === 0) {
      const token = decryptAES(res.data.token)
      alert(`登录成功 ✅ 解密后的 token:${token}`)
    } else {
      alert(res.data.msg)
    }
  } catch (e) {
    alert('登录失败')
  }
}
</script>

📁 frontend/main.ts

import { createApp } from 'vue'
import Login from './views/Login.vue'

createApp(Login).mount('#app')

📁 frontend/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Login AES Demo</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

📦 前端依赖安装

npm create vite@latest frontend
# 选择 vue-ts 模板

cd frontend
npm install axios crypto-js
npm run dev

🚀 启动流程

# 启动后端
cd backend
npm install express body-parser cors
node index.js

# 启动前端
cd frontend
npm install
npm run dev

访问 👉 http://localhost:5173
输入用户名:admin,密码:123456
点击登录 → 会弹出 解密后的 token


📝 小结 & 注意事项

到这里,我们实现了一个完整的 AES 双向加解密 Demo

  1. 前端输入密码 → AES 加密 → 发给后端
  2. 后端解密 → 校验账号密码
  3. 登录成功 → 后端返回加密的 token
  4. 前端解密 token → 拿到明文

这样一来,即使有人截获了请求数据,也只会拿到密文,进一步提升安全性。

⚠️ 但在真实项目中,还需要注意:

  • AES key 长度:必须是 16/24/32 字节,分别对应 AES-128/192/256;
  • IV 向量:最好不要写死,而是每次随机生成,再跟密文一起传给对方;
  • HTTPS 仍然必须:AES 只是额外的加密层,不能替代 HTTPS;
  • 不要重复使用固定 key:可以考虑结合动态密钥、RSA 非对称加密来提升安全性;
  • token 更推荐用 JWT:这个 demo 只是展示 AES 使用场景,实际项目中还要结合业务。

第5章 高级UI与动画

5.1 自定义组件

在鸿蒙 ArkTS 中,你可以把 UI 和逻辑封装成可复用的组件。

📌 示例:封装一个卡片组件

@Component
struct InfoCard {
  title: string
  desc: string

  build() {
    Column({ space: 5 }) {
      Text(this.title)
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
      Text(this.desc)
        .fontSize(16)
        .fontColor(Color.Gray)
    }
    .padding(15)
    .backgroundColor("#FFFFFF")
    .borderRadius(12)
    .shadow({ radius: 8, color: '#22000000' })
    .margin(10)
  }
}

调用方式:

InfoCard({ title: "鸿蒙教程", desc: "从入门到精通" })
InfoCard({ title: "UI开发", desc: "动画与自定义组件" })

5.2 动画效果

鸿蒙支持 属性动画(Property Animation)过渡动画(Transition Animation)

1. 属性动画

适用于 透明度、缩放、旋转、位置 的动态效果。

📌 示例:点击按钮让图片放大

@Entry
@Component
struct ScaleImage {
  @State scale: number = 1

  build() {
    Column({ alignItems: HorizontalAlign.Center, space: 20 }) {
      Image($r('app.media.logo'))
        .width(100 * this.scale)
        .height(100 * this.scale)
        .animation({
          duration: 500,
          curve: Curve.EaseInOut
        })

      Button("放大图片")
        .onClick(() => {
          this.scale = this.scale === 1 ? 1.5 : 1
        })
    }
  }
}

2. 过渡动画

适用于 组件进入/退出/切换 时的效果。

📌 示例:点击按钮切换显示文本(淡入淡出)

@Entry
@Component
struct FadeText {
  @State show: boolean = true

  build() {
    Column({ space: 20, alignItems: HorizontalAlign.Center }) {
      if (this.show) {
        Text("Hello HarmonyOS")
          .fontSize(26)
          .transition(TransitionEffect.opacity(500))
      }

      Button("切换文字")
        .onClick(() => {
          this.show = !this.show
        })
    }
  }
}

3. 动画曲线

鸿蒙提供多种动画曲线(Curve):

  • Linear(匀速)
  • EaseIn(慢 → 快)
  • EaseOut(快 → 慢)
  • EaseInOut(慢 → 快 → 慢)
  • Spring(弹簧效果)

📌 示例:弹簧动画

Text("弹簧动画")
  .translate({ x: 100 })
  .animation({
    duration: 800,
    curve: Curve.Spring
  })

5.3 UI优化与多终端适配

鸿蒙支持一次开发,多端适配。

常用技巧:

  • 使用 percentage(百分比)控制宽高,避免固定像素。
  • 使用 FlexColumnRow 进行自适应布局。
  • 根据设备类型调整样式:
if (getContext().deviceInfo.deviceType === 'tablet') {
  // 平板样式
} else {
  // 手机样式
}

5.4 实操:实现一个首页轮播图

功能需求

  • 首页展示多张图片
  • 图片自动切换
  • 用户也可以手动滑动

代码:index.ets

@Entry
@Component
struct CarouselDemo {
  private images: Resource[] = [
    $r('app.media.banner1'),
    $r('app.media.banner2'),
    $r('app.media.banner3')
  ]
  @State currentIndex: number = 0

  aboutToAppear() {
    setInterval(() => {
      this.currentIndex = (this.currentIndex + 1) % this.images.length
    }, 3000) // 3秒切换一次
  }

  build() {
    Column({ alignItems: HorizontalAlign.Center }) {
      // 图片轮播
      Swiper({ index: this.currentIndex, autoPlay: true, interval: 3000 }) {
        ForEach(this.images, (item: Resource, index: number) => {
          Image(item)
            .width('90%')
            .height(200)
            .borderRadius(12)
        })
      }

      // 底部小圆点
      Row({ space: 8, justifyContent: FlexAlign.Center }) {
        ForEach(this.images, (item: Resource, index: number) => {
          Circle({ radius: 6 })
            .fill(this.currentIndex === index ? Color.Blue : Color.Gray)
        })
      }
      .margin({ top: 10 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

运行效果

  • 首页显示 3 张图片(需放到 resources/base/media/ 下:banner1.pngbanner2.pngbanner3.png)。
  • 自动每 3 秒切换,也支持手动左右滑动。
  • 底部小圆点实时显示当前页。

✅ 到这里,你已经掌握了:

  • 自定义组件封装
  • 属性动画 & 过渡动画
  • 动画曲线的使用
  • UI 适配技巧
  • 实战:一个带轮播图的首页

CSS 盒模型:Margin vs Padding 的区别

基本概念

Padding(内边距)

  • 定义:元素内容与边框之间的空间
  • 作用区域:元素内部
  • 背景色:会继承元素的背景色
  • 盒模型位置:在边框内部
  • 对盒子大小的影响:会把盒子撑大

Margin(外边距)

  • 定义:元素边框与其他元素之间的空间
  • 作用区域:元素外部
  • 背景色:透明,不继承背景色
  • 盒模型位置:在边框外部
  • 对盒子大小的影响:不会把盒子撑大

盒模型:Margin → Border → Padding → Content

对盒子大小的影响

Padding 会把盒子撑大

  • 原因:Padding 是元素内容与边框之间的空间,属于元素内部
  • 效果:增加 padding 会让元素的实际显示区域变大
  • 举例:如果元素原本是 100px × 100px,加上 20px 的 padding,实际显示大小就变成 140px × 140px

Margin 不会把盒子撑大

  • 原因:Margin 是元素边框与其他元素之间的空间,属于元素外部
  • 效果:增加 margin 只会改变元素与其他元素的距离,不会改变元素本身的大小
  • 举例:如果元素原本是 100px × 100px,加上 20px 的 margin,元素本身还是 100px × 100px,只是与其他元素的距离增加了

在不同元素类型上的表现

块级元素(如 div、p、h1-h6) ✅ 所有方向都有效

  • 可以设置上下左右的 margin 和 padding
  • 垂直方向的 margin 和 padding 都会影响布局

行内元素(如 span、a、 em ⚠️ 只有水平方向有效

  • 水平方向的 margin 和 padding 有效
  • 垂直方向的 margin 和 padding 无效(不会影响布局)

行内块元素(display: inline-block) ✅ 所有方向都有效

  • 结合了行内和块级元素的优点
  • 可以设置宽高,所有方向的 margin 和 padding 都有效

实际应用场景

Padding 适用场景:

  • 按钮内边距
  • 卡片内容与边框的距离
  • 文本与容器边框的间距
  • 需要背景色填充的区域
  • 想要按钮更大时

Margin 适用场景:

  • 元素之间的间距
  • 页面布局
  • 居中对齐
  • 负边距实现特殊效果
  • 想要元素间距时

重要特性

Margin 合并(Margin Collapse)

.box1 { margin-bottom: 20px; }
.box2 { margin-top: 30px; }
/* 实际间距是 30px,不是 50px */

负值使用

/* Margin 可以为负值 */
.negative-margin { margin: -10px; }
/* Padding 不能为负值 */
.negative-padding { padding: -10px; } 
/* 无效 */

记忆口诀

"外距透明可合并,内距有色不合并"

  • 外距(margin)透明且可合并
  • 内距(padding)有色且不合并

"内距撑大外距不"

  • 内距(padding)会把盒子撑大
  • 外距(margin)不会把盒子撑大

总结

  • Margin:元素外部间距,透明背景,可以合并,不会撑大盒子
  • Padding:元素内部间距,继承背景色,不会合并,会把盒子撑大
  • 选择原则:影响外部布局用 margin,影响内部间距用 padding
  • 大小影响:想要改变元素本身大小用 padding,想要改变元素间距用 margin

写给自己的 LangChain 开发教程(二):格式化数据 & 提取 & 分类

这个教程是一边学习一边写的,中间可能会出现一些疏漏或者错误,如果您看到该篇文章并且发现了问题,还请多多指教!

1. 格式化数据

在实际应用中,我们的应用的前端和后端之间的数据交换是一个确切参数的 json 数据,我们可能会期望大模型将不可控的用户输入,转换成我们定义好的某个规整结构进行输出,这是一个很常见的需求,我们可以通过 LangChain 结合 Zod 或者 JSON Scheme 来实现我们的需求。

a. Zod

Zod 是一个 转为 Typescript 应用设计的校验库

如果我们希望格式化数据,可能的流程是这样

  1. 定义一个工具函数 format_tool
  2. 根据用户输入的内容来决定是否需要调用 tool 转换
  3. 解析模型输出的内容转换为最终输出的 json

LangChain js-sdk 对 Zod 进行了深度的集成,提供了内置的 withStructuredOutput api 来让我们可以方便的格式化输入内容至我们定义好的数据。

import { ChatOllama } from '@langchain/ollama'
import { z } from 'zod'

const llm = new ChatOllama({
  model: 'qwen3:32b'
})

const personScheme = z.object({
  name: z.string().describe('The name of the person'),
  age: z.number().describe('The age of the person')
})

const structuredLLM = llm.withStructuredOutput(personScheme, {
  // 一定要传这个参数,告诉模型以纯json的格式输出,不然可能会报错 OUTPUT_PARSING_FAILURE
  method: 'json_mode'
})

const result  = await structuredLLM.invoke('他的姓名是tocka,今年26岁了')
console.log (result)
// 输出 { age: 26, name: 'tocka' }

b. JSON Scheme

JSON Scheme 是在前后端需要使用同一套校验逻辑的时候经常使用的结构,不需要借助外部库的情况下我们也能很轻松的看明白它所表达的内容。在 LangChain 中使用 JSON Scheme 的方式来格式化数据会稍微麻烦一点。

import { ChatOllama } from '@langchain/ollama'
import { JsonOutputParser } from '@langchain/core/output_parsers'
import { ChatPromptTemplate } from '@langchain/core/prompts'

const llm = new ChatOllama({
  model: 'qwen3:32b'
})

const personScheme = {
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "description": "The name of the person.",
      "minLength": 1
    },
    "age": {
      "type": "number",
      "description": "The age of the person.",
      "minimum": 0
    }
  },
  "required": ["name", "age"]
}
const parser = new JsonOutputParser()

const promptTemplate = ChatPromptTemplate.fromTemplate(`
Extract the person information from the following text and return it as JSON according to this schema:

Schema: {schema}

Text: {text}

Please return only valid JSON that matches the schema.
`)

const prompt = await promptTemplate.invoke({
  schema: personScheme,
  text: '他的姓名是tocka,今年26岁了'
})

const output = await llm.invoke(prompt)
const result = await parser.invoke(output)

console.log (result)
// 输出 { age: 26, name: 'tocka' }

LangChain 为每个可运行的组件实现了 .pipe() 方法,用来构建一个流程链,当我们调用链的时候它的组件会依次调用每个组件的 invoke 方法,将返回值传给链上的下一个组件,所以我们的代码可以简化一下

const chain = promptTemplate.pipe(llm).pipe(parser)
const result = await chain.invoke({
  schema: personScheme,
  text: '他的姓名是tocka,今年26岁了'
})

console.log (result)
// 输出 { age: 26, name: 'tocka' }

2. 提取 & 分类

在格式化数据的应用场景中,提取内容和进行分类也是一种很常见的需求,想象我们正在管理一个商品,这个商品有很多条评价,而评价有好评,中评,差评,这三个纬度可能是从评分直接展示,也有可能是需要根据用户的语义来分析获取的,现在你需要根据评价的内容,将评价转换为一个 { 评价总体倾向,优点,缺点 } 的 json 数据,假设我们的商品是一款电子手表。

import { ChatOllama } from '@langchain/ollama'
import { JsonOutputParser } from '@langchain/core/output_parsers'
import { ChatPromptTemplate } from '@langchain/core/prompts'
import { z } from 'zod'

const llm = new ChatOllama({
  model: 'qwen3:32b'
})

// 使用 nullish 让模型可以将字段置为 null 而不是乱编
const schema = z.object({
  sentiment: z.enum(['positive','neutral','negative']).describe('评论对商品的总体评价倾向'),
  advantage: z.nullish(z.string()).describe('评论提到的具体的优点'),
  disadvantage: z.nullish(z.string()).describe('评论提到的具体的缺点'),
})

const data = [
  "这款手表的电池续航太棒了,充一次电能用好几天。",
  "手表外观很时尚,佩戴起来也很舒适,但心率监测功能似乎不太准。",
  "考虑到价格,这块表还不错,不过打开应用时有点慢。",
  "我喜欢它的设计,屏幕也很清晰。运动追踪功能非常详细。",
  "软件有很多bug,经常卡顿闪退。我对它的性能很失望。"
]

const structuredLLM = llm.withStructuredOutput(schema, {
  method: 'json_mode'
})

const prompt = ChatPromptTemplate.fromTemplate(`
    本商品是一款电子手表,以下输入是用户对它的评价,请从中提取商品的优点和缺点,并判断评价的整体倾向
    input: {commend}
  `)

const chain = prompt.pipe(structuredLLM)

const result = data.map((commend) => chain.invoke({ commend }))

console.log(await Promise.all(result))
// 输出:
[
  {
    advantage: '电池续航太棒了,充一次电能用好几天',
    disadvantage: null,
    sentiment: 'positive'
  },
  {
    advantage: '手表外观时尚,佩戴舒适',
    disadvantage: '心率监测功能不太准',
    sentiment: 'neutral'
  },
  {
    advantage: '考虑到价格,这块表还不错',
    disadvantage: '打开应用时有点慢',
    sentiment: 'neutral'
  },
  {
    advantage: '设计、屏幕清晰、运动追踪功能详细',
    disadvantage: null,
    sentiment: 'positive'
  },
  { disadvantage: '软件有很多bug,经常卡顿闪退', sentiment: 'negative' }
]

3. 小结

通过本篇我们学习了如何通过 ZodJSON Scheme 格式化数据,这部份内容虽然比较基础,但是在我们编排流程时会经常用到,例如根据用户的输入来构建查询参数等等。

CSS Visibility(可见性)

Visibility 属性指定元素是可见还是隐藏。

指定元素的可见性

您可以使用该visibility属性来指定元素是否可见。此属性可以采用下表中列出的以下值之一:

描述
visible 默认值。该框及其内容是可见的。
hidden 该框及其内容是不可见的,但仍会影响页面的布局。
collapse 该值导致整个行或列从显示中删除。此值用于行,行组,列和列组元素。
inherit 指定可见性属性的值应从父元素继承,即采用与其父元素相同的可见性值。

visibility: collapse;但是,样式规则会删除内部表格元素,但不会以任何其他方式影响表格的布局。表元素通常占用的空间将由随后的同级填充。

注意: 如果visibility: collapse;为其他元素(而不是表元素)指定了样式规则,则其行为与相同hidden。

CSS Visibility vs Display

CSS display 与 visibility属性看起来似乎是一回事,但实际上它们是完全不同的,并且常常使Web开发的新特性感到困惑。

  • visibility: hidden;隐藏元素,但是它仍然占用布局中的空间。如果隐藏框的子元素的可见性设置为“可见”,则它们将是可见的。
  • display: none;关闭显示并从文档中完全删除该元素。即使它的HTML仍在源代码中,它也不占用任何空间。即使所有子元素的显示属性均设置为none,也将关闭其显示。

Visibility 属性的用法

Visibility 属性共有四个可用的值(visible、hidden、collapse 和 inherit),但常用的值是 visible 和 hidden。

visibility: visible
/* 元素可见,默认值 */
visibility: hidden
/* 元素不可见,但仍然为其保留相应的空间 */
visibility: collapse
/* 只对 table 对象起作用,能移除行或列但不会影响表格的布局。如果这个值用在 table 以外的对象上则表现为 hidden 。 */
visibility: inherit
/* 继承上级元素的 visibility 值。 */

Display 属性的用法

Display 属性的可用值有很多,但在这里我们只关注其中的几个值:block、none 和 inline

display: none
/* 元素不可见,并且不为其保留相应的位置 */
display: block
/* 表现为一个块级元素(一般情况下独占一行) */
display: inline
/* 表现为一个行级元素(一般情况下不独占一行) */

以上可以看出,虽然 Visibility 和 Display 属性都可以隐藏一个元素,但它们之间的不同点在于visibility: hidden 在隐藏一个元素的同时仍然在页面上为该元素保留所需的空间,而 display: none 则表现得像把元素从页面里删除了,在页面上看不出该元素还存在着。
另外,display: block 和 display: inline 的区别在于 block 元素会在页面中独占一行,而 inline 元素不会,有的对象默认为 block 元素,而有的对象则默认为 inline 元素,大家在使用时需要注意防止相同属性的重复定义。

何时使用 Visibility 或 Display 属性

Visibility 和 Display 属性虽然都可以达到隐藏页面元素的目的,但它们的区别在于如何回应正常文档流。
如果你想隐藏某元素,但在页面上保留该元素的空间的话,你应该使用 visibility: hidden 。如果你想在隐藏某元素的同时让其它内容填充空白的话应该使用 display: none 。

引用

菜鸟教程

BFC原理剖析:前端布局救星

BFC(Block Formatting Context,块级格式化上下文)是 CSS 中的一个核心布局概念,用于创建一个独立的渲染区域,其内部的元素布局与外部隔离,遵循特定规则。以下是其核心要点:


一、BFC 的定义与特性

BFC 是页面中的一块独立容器,内部元素布局不受外部影响,反之亦然。其核心特性包括:

  1. 垂直排列:内部块级元素在垂直方向依次排列。
  2. 外边距折叠:同一 BFC 内相邻元素的垂直外边距会发生重叠(取较大值)。
  3. 包含浮动元素:计算 BFC 高度时,浮动元素的高度会被纳入。
  4. 隔离浮动:BFC 区域不与外部浮动元素重叠。
  5. 边界对齐:元素的左外边距紧贴包含块的左边框(从左到右布局)。

二、触发 BFC 的条件

满足以下任一条件即可创建 BFC:

  • 根元素<html>
  • 浮动float: left/right
  • 绝对定位position: absolute/fixed
  • 特定 display 值inline-blocktable-cellflexgridflow-root(推荐,无副作用)。
  • 溢出处理overflow: hidden/auto/scroll(非 visible)。

💡 现代开发中优先使用 display: flow-root,避免 overflow: hidden裁剪内容的问题。


三、BFC 的核心作用与场景

1. 解决高度塌陷

问题:父元素包含浮动子元素时,高度塌陷(高度为 0)。

方案:触发父元素的 BFC,使其包含浮动子元素的高度。

.parent { overflow: hidden; } /* 或 display: flow-root; */

2. 阻止外边距折叠(Margin Collapse)

问题:同一 BFC 内相邻元素的垂直外边距重叠。

方案:将元素放入不同 BFC 隔离。

<div class="box1">Box 1</div>
<div class="bfc-container"> <!-- 触发 BFC -->
    <div class="box2">Box 2</div>
</div>

3. 实现自适应两栏布局

问题:浮动元素覆盖后续内容。

方案:为自适应侧触发 BFC,避免与浮动元素重叠。

.left { float: left; width: 200px; }
.right { overflow: hidden; } /* 触发 BFC 自适应宽度 */

4. 隔离浮动影响

问题:浮动元素导致文本环绕。

方案:为文本容器触发 BFC,阻止环绕。

.text { overflow: hidden; } /* 文本不再环绕浮动元素 */

四、BFC 的设计意义

BFC 通过创建独立的布局环境,解决了 CSS 中常见的定位冲突问题(如浮动、边距、重叠),使布局更可控。现代布局技术(如 Flexbox/Grid)虽简化了部分场景,但 BFC 仍是处理以下问题的基石:

  • 兼容性:支持旧浏览器布局问题修复。
  • 精准控制:需严格隔离元素时(如组件化开发)。

总结

BFC 是 CSS 布局的底层机制,通过触发独立渲染区域解决高度塌陷、边距重叠、浮动覆盖等问题。掌握其触发条件(如 overflow: hiddendisplay: flow-root)和应用场景,可显著提升布局代码的健壮性。在现代开发中,可结合 Flex/Grid 布局与 BFC 实现更灵活的页面结构。

Vue状态管理工具pinia的使用以及Vue组件通讯

         状态管理工具redux以及resso和zustand都用过了,这里pinia和resso是一样的,存储的状态数据直接写,方法也是直接写就可以了,会自动进行类型推断。

1.pinia的使用

        首先入口文件引入pinia之后创建pinia对象用use安装pinia和路由一样

import { createApp } from "vue";
import App from './App.vue'
//引入pinia
import { createPinia } from "pinia";
const app =createApp(App)
//创建pinia
const pinia=createPinia()
//安装pinia
app.use(pinia)
app.mount('#app')

        之后创建store文件夹,不同的数据以及数据方法单独创建文件。

编辑

        然后用defineStore初始化store容器。

        编辑

        actions写方法,state写数据。

        之后就可以在组件里面引入useCountStore函数调用生成countStore对象就可以引用里面的方法和数据了。

<template>
    <div class="count">
        <h2>当前求和{{ sum }}</h2>
        <select name="number" id="" v-model="n">
            <option value="1">1</option>
            <option value="2">2</option>
        </select>
        <button @click="add"></button>
        <button @click="increase"></button>
    </div>
</template>
<script setup lang="ts" name="Count">
    import { ref } from 'vue';
    import {useCountStore} from '@/store/count'
    import { storeToRefs } from 'pinia';
    const countStore=useCountStore()
    console.log(countStore.sum);
    let {sum} = storeToRefs(countStore)
    let n =ref(1)
    const add=()=>{
        //第一种修改
        //countStore.sum=countStore.sum+Number(n.value)
        //第二种修改 可以一次修改多种数据
        // countStore.$patch({
        //     sum:+Number(n.value)
        // })
        //第三种修改方式actions
        countStore.add(n.value)
    }
      const increase=()=>{
       countStore.sum=countStore.sum-Number(n.value) 
    }
    //store的监听事件
    //subscribe回调函数接收两个参数 mutate修改的数据 state就是store存储的数据
    countStore.$subscribe((mutate,state)=>{
        console.log('state改变了',mutate,state);
    })
</script>
<style scoped>
    .count{
        background-color: aqua;
    }

    select{
        width: 50px;
    }
    button{
        height: 100px;
        width: 100px;
        background-color: aquamarine;
        color: #000;
    }
</style>

        还有一个$subscribe可以监听store种的数据变化,mutate第一个参数是变化的数据,state就是存储的所以数据对象。

2.Vue组件通讯的方式、

        包括之前的vue博客,并不是零基础的,是在写过完整的前端项目之后去学的,所以原理大致都是通的,只是方法用法不同。

        这里通讯方式其实和React是相通的,这里只是总结。

        编辑

        这里和react一致。父子和子夫通过props操作。

编辑

编辑

        分享数据的组件on绑定事件,然后把数据放到回调函数中,然后触发事件的时候把参数传递之后,绑定事件的回调函数就会接收参数。

        记得组件卸载的时候解绑。和pubsub一致

编辑

        v-model双向绑定,无非就是把数据绑定到input的value上,然后@input事件发生的时候把value绑定到数据上,和react受控组件一个道理。

        编辑

        父子传递props子没有接收就会放到attrs属性中,可以借此用props传递给孙组件。

        provide提供数据子组件inject拿。子获取父的时候需要父暴露数据,父获取子的实例对象也需要子暴露数据,不然数据是隐藏的。

        

如何更好的封装一个接口轮询?

相信大家做pc端开发的时候都会遇到一个需求,比如扫码登录的时候,通过手机端扫码之后一般都需要通过轮询查询登录状态,其实这是个很常见的需求,但是没做过的小伙伴可能就会不经意间写出一个bug。就是比如1秒钟轮询一次的时候,如果上次的接口还没成功响应那么下一次的接口就又发送出去了。正确做法应该是等待上个响应后再发出下一次请求。

其实解决bug的关键就是如何等待上次请求响应之后才发出下一次的请求,等待具体应该怎么实现呢?可以参考下我的思路。

具体如下:
type PollingEvent = 'success' | 'error' | 'stop';

interface PollingOptions {
 interval?: number; // 轮询间隔时间
 retries?: number; // 接口失败后重试次数
 immediate?: boolean; // 是否立即调用一次接口
}

type EventCallback<T> = (data?: T | Error) => void;

class PollingService<T = any> {
 private requestFn: () => Promise<T>;
 private interval: number;
 private retries: number;
 private immediate: boolean;

 private timer: ReturnType<typeof setTimeout> | null = null;
 private isPolling: boolean = false;
 private currentRetry: number = 0;
 private listeners = new Map<PollingEvent, Set<EventCallback<T>>>();

 constructor(requestFn: () => Promise<T>, options: PollingOptions = {}) {
   this.requestFn = requestFn;
   this.interval = options.interval || 1500;
   this.retries = options.retries || 0;
   this.immediate = options.immediate ?? true;
 }

 on(event: PollingEvent, callback: EventCallback<T>): () => void {
   if (!this.listeners.has(event)) {
     this.listeners.set(event, new Set());
   }
   const listeners = this.listeners.get(event)!;
   listeners.add(callback);

   return () => listeners.delete(callback);
 }

 start(): void {
   if (this.isPolling) return;
   this.isPolling = true;
   this.currentRetry = 0;

   if (this.immediate) {
     this.execute();
   } else {
     this.scheduleNext();
   }
 }

 stop(): void {
   this.isPolling = false;
   this.clearTimer();
   this.emit('stop');
 }

 private async execute(): Promise<void> {
   if (!this.isPolling) return;
   
   // 其实关键就在这里了,我们借助 async/await来实现
   try {
     const data = await this.requestFn();
     this.currentRetry = 0;
     this.emit('success', data);
   } catch (error) {
     this.handleError(error as Error);
     return;
   }

   this.scheduleNext();
 }

 private handleError(error: Error): void {
   this.emit('error', error);

   if (this.currentRetry < this.retries) {
     this.currentRetry++;
     this.execute(); // 立即重试
     return;
   }

   this.stop();
 }

 private scheduleNext(): void {
   if (!this.isPolling) return;

   this.clearTimer();
   this.timer = setTimeout(() => {
     this.execute();
   }, this.interval);
 }

 private clearTimer(): void {
   if (this.timer) {
     clearTimeout(this.timer);
     this.timer = null;
   }
 }

 private emit(event: PollingEvent, data?: T | Error): void {
   const listeners = this.listeners.get(event);
   if (listeners) {
     listeners.forEach((callback) => callback(data));
   }
 }
}

export { PollingService };

完成上述代码之后,在页面中直接引入使用即可。

使用如下:
import React, { useEffect, useRef } from 'react';
import { PollingService } from 'xxx/PollingService';

const Demo = () => {
    const pollingRef = useRef<any>(null);
    
    // 轮询的方法
    const requesFun = async () => {
        const res = await axios.post('xxx')
        // todo sth
    }
    
    useEffect(() => {
        pollingRef.current = new PollingService()
        // 启动轮询
        pollingRef.current.start()
        
        // !!!组件卸载一定要关闭轮询
        return () => {
            pollingRef?.current?.stop()
        }
    }, [)
    <div>测试页面</div>
}
export default Demo;

震惊!多核性能反降11%?node接口压力测试出乎意料!

背景

最近在优化一个Node.js后端服务时,我决定对多进程模式进行一次真实压力测试。大家都知道Node.js是单线程模型,通常我们会用PM2的Cluster模式来启动多进程,试图榨干多核CPU的性能。理论上,这应该能让服务性能起飞,但事实真的如此吗?

测试设计

但我决定不做假设,而是用真实数据说话。我设计了一个压力测试对比:

  • 单进程模式:一个 Node.js 实例运行在主核上
  • 多进程模式:两个进程分别运行在两个核心上(通过 PM2 启动)

测试环境为:

  • 机器:2核2G服务器
  • Node.js 版本:24+
  • 测试工具:autocannon
  • 测试条件:10秒内发送100个并发请求

测试结果

单核心接口压力测试开始,10秒钟请求100次接口

autocannon -c 100 -d 10 http://your-server-ip:3000/api/endpoint

image.png

结果让人惊喜:平均延迟354ms哈哈哈,QPS达到278.11,数据传输量543 kB/s。表现....

多进程压测:搓手期待性能起飞,接下来配置PM2多进程模式,创建ecosystem.config.js:

module.exports = { 
apps: [
{ name: "my-node-api", 
script: "/var/www/my-node-api/index.js",
instances: 2, // 明确指定2个实例(或者max) 
exec_mode: "cluster", //cluster-多线程模式 fork-单线程模式
env: {
      NODE_ENV: "production", PORT: 3000
    },
error_file: "/var/www/my-node-api/logs/err.log",
out_file: "/var/www/my-node-api/logs/out.log", 
time: true, 
max_memory_restart: "400M"
  }]
}

开启多核心后继续压力测试,结果意想不到...

image.png

满怀期待启动服务,结果...让人大跌眼镜!

性能不升反降?数据说话

对比测试数据后,我发现了一个反直觉的结果:

1. 延迟性能下降 ⚠️

指标 单进程 多进程 变化
平均延迟 354ms 394ms +11.4%
中位数延迟 348ms 390ms +12.1%
最大延迟 496ms 517ms +4.2%

2. 吞吐量下降 ⚠️

指标 单进程 多进程 变化
请求/秒 278.11 249.9 -10.2%
数据传输量 543 kB/s 487 kB/s -10.3%

3. 稳定性略有改善 ✅

  • 延迟波动(标准差):从 43.15ms 降至 41.49ms,改善 3.8%
  • 吞吐波动(标准差):大幅减少,改善 25.4%

多进程模式为什么反而更差了?

没有想到在开启多核心的之后的压力测试并没有得到提升,反而接口延迟又变高了,请求数据量也从单核的5.48M变成了双核4.87M,我反复对比了下。 理论上多进程应该更好,但实际测试结果却相反。我分析主要有以下几个原因:

  1. 进程间需要通信:多个进程之间需要协调工作,这部分额外工作单进程不需要做
  2. 切换成本高:系统在不同进程间切换比在单个进程内切换开销更大
  3. 内存无法共享:每个进程都有自己的内存空间,无法共享数据
  4. 负载均衡本身也需要资源:分配请求的工作也需要消耗计算资源

那为什么稳定性反而提升了?

虽然性能下降了,但稳定性的确略有提升。这是因为:

  • 压力分散到多个进程,单个进程负担减轻
  • 一个进程出问题不会影响整个服务
  • 错误被控制在单个进程内

结论与建议

多进程模式不是万能的!特别是在:

  • 访问量不大时
  • 业务逻辑不复杂时
  • 主要是处理I/O操作时(如常见的API服务)

单进程模式可能效果更好。

什么时候应该用多进程?

  • 访问量非常大时
  • 需要处理大量计算任务时(如图片处理、数据加密等)
  • 对稳定性要求特别高时

这次测试说明:

没有最好的方案,只有最适合的方案~

JavaScript - 观察者模式的实现与应用场景

1. 什么是观察者模式

观察者模式(Observer Pattern)是一种行为型设计模式,它定义了一种一对多的依赖关系。当主题带有的状态被改变时,所有观察者都会收到通知并自动更新。

主题(Subject):Subject也可以称为被观察者,它维护一个Observer列表,实现新增、删除、通知Observer更新的方法。

观察者(Observer):Observer是接收主题通知的对象,需要实现一个更新方法,当收到Subject的通知时,调用该方法进行更新。

具体主题(Concrete Subject):ConcreteSubject继承了Subject,是Subject的具体实现类,我们在其中定义主题状态,当状态发生某些变化时通知Observer更新。

具体观察者(Concrete Observer):ConcreteObserver是Observer的具体实现类。它定义了在收到通知时需要执行的具体操作。

需要注意的是,在观察者模式的依赖关系中,并不是观察者主动拉取主题消息,而是被动的接收主题通知并产生相应变化

2. 观察者模式举例

以拍卖会上拍卖者与竞价者的一对多的依赖关系举例:当价格更新时,所有的竞价者都会收到通知。

3. 例子的代码实现

定义Subject类与Observer类

/**
 * 主题(Subject)中维护一个观察者列表,实现新增、删除观察者、与通知观察者更新的方法
 */
class Subject {
  constructor() {
   this.observerList = [];
  }
  
  /**
   * 新增观察者方法
   * @param observer
   */
  addObserver(observer) {
   this.observerList.push(observer);
  }
  
  /**
   * 移除观察者方法
   * @param observer
   */
  removeObserver(observer) {
   const findBidderIndex = this.observerList.findIndex(observerItem => {
    return observerItem.id === observer.id;
   })
   if(findBidderIndex > -1) {
    this.observerList.splice(findBidderIndex, 1);
   }
  }
  
  /**
   * 通知观察者进行更新
   * @param data
   */
  notify(data) {
   this.observerList.forEach(observerItem => {
    observerItem.update(data);
   })
  }
}

/**
 * 观察者(Observer)中应定义一个更新方法
 */
class Observer {
  
  /**
   * 更新方法
   * @param data
   */
  update(data) {
  }
}

定义Subject与Observer的具体类

/**
 * 定义一个拍卖师类,作为具体主题(ConcreteSubject)
 */
class Auctioneer extends Subject {
  constructor() {
   super();
   this.state = '30';
  }
  
  setState(state){
   this.state = state;
   this.notify(this.state);
  }
  
  getState(){
   return this.state;
  }
}

/**
 * 定义一个竞拍者类,作为具体观察者(ConcreteObserver),实现update方法
 */
class Bidder extends Observer {
  constructor(id) {
   super();
   this.id = id;
  }
  
  update(data) {
   console.log('竞拍者 ' + this.id + ' 收到通知:' + data);
  }
}

创建实例、修改状态

//实例化具体主题
const auctioneer = new Auctioneer();

//实例化具体观察者
const bidder1 = new Bidder(1);
const binder2 = new Bidder(2);
const binder3 = new Bidder(3);

//将具体观察者推入具体观察者列表
auctioneer.addObserver(bidder1);
auctioneer.addObserver(binder2);
auctioneer.addObserver(binder3);
auctioneer.removeObserver(binder2);

//修改状态
auctioneer.setState(20);

打印结果

竞拍者 1 收到通知:20
竞拍者 3 收到通知:20

4.观察者模式的适用场景

当目标对象与其它对象产生一对多关系时,当目标对象改变,希望其它对象也发生相应变化

比如在订单页面使用v-for生成出来几个Tab组件,通过滑动切换。这些Tab页标识着该用户所有订单的状态。

当用户操作的某一笔订单的状态发生改变时,我们希望每个与该笔订单相关的所有Tab组件都发生变化,都重新走一遍查询接口。

这时就可以定义这一组一对多的依赖,将相关标签页的key推入Store中定义的列表作为状态。使用观察者模式使那些存于列表中的Tab刷新。

「Versakit攻略」🔥Pnpm+Monorepo+Changesets搭建通用组件库项目和发包流程

文档作者: Jannik

image.png

什么是Monorepo

Monorepo 即单体版本库,是一种将多个项目的代码存储在一个版本控制系统(如 Git)仓库中的软件开发策略,以下是关于它的详细介绍:

基本概念

  • 定义:Monorepo 把所有相关的项目代码都放在一个仓库中进行管理,这些项目可以是不同的应用程序、库、工具等,但它们在某种程度上相互关联或属于同一个产品体系。
  • 与传统多仓库的对比:传统的软件开发通常采用多仓库模式,即每个项目都有自己独立的版本库。而 Monorepo 则将所有项目整合到一个仓库中,方便统一管理和代码共享。

优势

  • 代码共享与复用:在 Monorepo 中,不同项目之间可以方便地共享代码。如果多个项目都需要使用某个公共的功能或库,只需要在 Monorepo 中维护一份代码,各个项目都可以直接引用,避免了代码的重复编写和维护。
  • 统一的工作流程和规范:所有项目在一个仓库中,便于制定统一的代码规范、提交规范、构建流程等。开发人员可以遵循相同的标准进行开发,提高代码质量和可维护性。
  • 原子提交:由于所有项目在一个仓库中,一次提交可以涵盖多个项目的相关更改,保证了提交的原子性。这使得版本历史更加清晰,便于追踪整个系统的变更过程。
  • 依赖管理简化:Monorepo 中各个项目之间的依赖关系更加明确和易于管理。可以通过统一的依赖管理工具,方便地管理项目之间的依赖版本,避免了多仓库模式下可能出现的依赖冲突问题。

适用场景

  • 大型项目或项目群:适用于大型的企业级项目或由多个相关子项目组成的项目群,如大型电商平台、综合性金融系统等,这些项目通常需要多个团队协同开发,代码共享和复用的需求较高。
  • 开源项目集合:一些开源组织会将多个相关的开源项目放在一个 Monorepo 中进行管理,方便开发者对整个项目生态系统进行贡献和维护。

Monorepo(单体仓库)与 MultiRepo(多仓库)

  1. Monorepo 和 MultiRepo 简介

在软件开发中,代码仓库的管理方式对项目的效率和协作有着重要影响。常见的代码仓库管理方式主要有两种:Monorepo(单体仓库)和 MultiRepo(多仓库)。

  • Monorepo(单体仓库) :是指将多个项目存储在同一个代码仓库中。这种方式允许不同项目共享代码和依赖,并在同一个版本控制系统中进行管理。
  • MultiRepo(多仓库) :是指将每个项目存储在独立的代码仓库中,每个仓库独立管理代码和依赖。不同项目之间的代码和依赖需要通过包管理工具或其他方式进行共享。

实践指南

全局安装 pnpm

npm install pnpm -g

在项目下面初始化

pnpm init

得到初始的 package.json

{
  "name": "versakit-monorepo",
  "version": "0.2.2",
  "private": "true",
  "type": "module",
  "scripts": {
    "cli:build": "pnpm --filter cli build",
    "docs:build": "pnpm --filter docs docs:build",
    "docs:dev": "pnpm --filter docs docs:dev",
    "docs:preview": "pnpm --filter docs docs:preview",
    "ui:build": "pnpm --filter ui build",
    "test": "vitest",
    "test:ui": "vitest --ui",
    "preinstall": "node ./scripts/preinstall.js",
    "lint": "eslint packages",
    "lint:fix": "eslint packages --fix",
    "format": "prettier --write "./**/*.{html,vue,ts,js,json,md}"",
    "lint:eslint": "eslint packages/**/*.{ts,vue} --cache --fix",
    "prepare": "husky install",
    "lint-staged": "lint-staged",
    "commitlint": "commitlint --config commitlint.config.cjs -e -V",
    "changeset": "changeset",
    "packages-version": "changeset version",
    "publish": "changeset publish --access=public --registry=https://versakit.npmjs.com/"
  },
  "lint-staged": {
    "**/*.{js,jsx,ts,tsx,vue}": [
      "eslint --fix",
      "prettier --write",
      "git add"
    ],
    "*.{scss,less,styl,html}": [
      "prettier --write"
    ]
  },
  "dependencies": {
    "@types/vue": "^2.0.0",
    "@vueuse/core": "^12.0.0",
    "eslint": "8",
    "sass": "^1.83.0",
    "versakit-monorepo": "file:",
    "vue": "^3.5.12"
  },
  "devDependencies": {
    "@babel/eslint-parser": "^7.25.9",
    "@changesets/cli": "^2.27.11",
    "@commitlint/cli": "^19.6.0",
    "@commitlint/config-conventional": "^19.6.0",
    "@tsconfig/node22": "^22.0.0",
    "@types/node": "^22.9.0",
    "@types/postcss-pxtorem": "^6.1.0",
    "@typescript-eslint/eslint-plugin": "^8.17.0",
    "@vitejs/plugin-vue": "^5.1.4",
    "@vitest/ui": "^2.1.5",
    "@vue/test-utils": "^2.4.6",
    "@vue/tsconfig": "^0.5.1",
    "autoprefixer": "^10.4.20",
    "cssnano": "^7.0.6",
    "eslint-config-prettier": "^9.1.0",
    "eslint-plugin-import": "^2.31.0",
    "eslint-plugin-node": "^11.1.0",
    "eslint-plugin-prettier": "^5.2.1",
    "eslint-plugin-vue": "^9.32.0",
    "globals": "^15.12.0",
    "husky": "^8.0.0",
    "jsdom": "^25.0.1",
    "lint-staged": "^15.2.10",
    "npm-run-all2": "^7.0.1",
    "postcss": "^8.4.49",
    "postcss-pxtorem": "^6.1.0",
    "prettier": "^3.4.2",
    "typescript": "~5.6.3",
    "vite": "^5.4.10",
    "vite-plugin-dts": "^4.3.0",
    "vite-plugin-svg-icons": "^2.0.1",
    "vitest": "^2.1.5",
    "vue-tsc": "^2.1.10"
  }
}

当然上面的是经过我整合的好的了

配置Pnpm工作区

  1. 新建pnpm-workspace.yaml文件,并且写入
packages:
 # 主包,存放所有项目的目录
  - "packages/*"

在根目录创建项目

在每个包下面创建package.json文件做单一包管理

以下是项目工程目录

Changesets

什么是Changesets

Changesets 是一种在软件开发和版本控制系统中用于管理代码变更的概念,以下是关于它的详细介绍:

基本定义

  • 在版本控制系统如 Git、Mercurial 等中,Changesets 指的是一系列相关的文件修改或变更的集合。它记录了从一个版本到另一个版本之间所发生的所有更改,包括文件的添加、删除、修改等操作。

主要特点

  • 原子性:一个 Changeset 通常被视为一个原子操作单元,即要么所有的更改都被应用到版本库中,要么都不应用。这确保了版本库的一致性和完整性。
  • 关联性:Changesets 之间存在着一定的关联关系,它们可以形成一个线性的历史记录,也可以通过分支和合并等操作形成复杂的版本树结构。通过这种关联性,可以方便地查看代码的演变过程。
  • 可追溯性:每个 Changeset 都有唯一的标识,如 Git 中的哈希值,通过这个标识可以方便地追溯到该 Changeset 所包含的具体更改内容,以及是谁在什么时间进行的修改等信息。

实践指南

1、安装依赖

pnpm install @changesets/cli -wD

2、初始化

pnpm changeset init

执行完初始化命令后,会在工程的根目录下生成 .changeset 目录

3、在根目录 package.json 中配置对应的命令

"scripts": {    
    "changeset": "changeset",  
    "packages-version": "changeset version",    
    "publish": "changeset publish --registry=https://registry.npmjs.com/"
 }

执行 pnpm run changeset

1、选择要发布的包

2、发布 minor,选择对应的包

现在是 1.0.0 更新为 1.1.0,这里选择 minor

3、填写 changelog

4、Is this your desired changeset 选择true

执行 pnpm run packages-version

继续发包

不同点在于选择发布 major,剩余的流程和上面的都一样

Npm私域问题

因为@在Npm上是私人的包

解决方式

在package.json上写入 --access=public

最终结果

前端布局避坑指南:Grid、Flex 与传统 CSS2 布局的优缺点全解析

概念

  • Grid(二维布局) :原生支持“行 × 列”,擅长“规则网格”“一排 N 个”“最后一行自然靠左且间距统一”。
  • Flex(一维布局) :擅长在单行或单列内分配空间;多行时每一行各自独立,容易出现“最后一行两端拉扯”或对齐不齐。
  • CSS2 传统方法(float / inline-block / position) :历史包袱大、需要各种 hack;能实现,但维护成本高、弹性差。

原理

  • Grid 轨道(track)grid-template-columns 定义列轨道;repeat(auto-fit, minmax(X, 1fr)) 会尽可能多塞列,每列至少 X 宽,最多分到 1fr 的剩余空间。

    • auto-fit:把空轨道折叠掉,视觉上会“贴左对齐”。
    • auto-fill:保留空轨道(不可见,但占位),某些布局会更易控。
  • Flex 伸缩:主轴上对子项分配剩余空间;flex-wrap: wrap 换行后,“每一行”是各自独立的 flex 行,导致多行对齐难。

  • CSS2

    • float 脱离常规流、需要“清除浮动”;
    • inline-block 会产生字间空隙;
    • 绝对定位失去自适应,通常只做局部装饰用。

对比(要点速览)

维度 Grid Flex CSS2(float/inline-block)
布局维度 二维(行+列) 一维(行或列) 近似一维/靠技巧
多行对齐 天然规整 行与行独立,易“最后一行不齐” 需要 hack/细算宽度
间距 gap 原生、行列一致 gap 原生(现代浏览器 OK) 多靠 margin,会有合并/溢出
响应式 minmax + auto-fit/fill 很优雅 常要 calc() + 媒体查询 百分比 + 负外边距等旧技巧
复杂度 语义清晰、可读性好 中等 维护成本高

你的痛点映射

  • “一排三个 & 自动对齐 & 最后一行不要被拉扯” → Grid 最优
  • “Flex 两端对齐导致最后一行被拉开,竖向不齐” → 换用 flex-start + gap 或改用 Grid。
  • “padding/margin 造成边距问题” → 统一用 gap(Grid/Flex 都支持),尽量少用水平外边距叠加。

实践(含逐行注释)

A. Grid 方案(固定 3 列,简洁稳妥)

<div class="g3">
  <div class="card">1</div>
  <div class="card">2</div>
  <div class="card">3</div>
  <div class="card">4</div>
  <div class="card">5</div>
</div>
.g3 {
  margin-top: 42px;        /* 你的需求里的外边距 */
  display: grid;           /* 启用 Grid */
  grid-template-columns: repeat(3, minmax(0, 1fr));
  /* ↑ 固定 3 列;minmax(0,1fr) 防止内容过长撑破列宽 */
  gap: 20px;               /* 统一行/列间距,不再用 margin 叠加 */
}
.card {
  background: #e8f3ff;     /* 示例背景 */
  padding: 16px;           /* 卡片内边距 */
  border-radius: 8px;
  /* 默认 align-items: stretch,保证一行内等高(以该行最高为准) */
}

特点:始终 3 列,最后一行自然靠左且间距一致;无需计算 calc();适合“大屏固定列数”的列表页。


B. Grid 方案(自适应列数:最小卡片宽度 280px)

<div class="g-auto">
  <!-- 放任意数量的卡片 -->
  <div class="card">1</div><div class="card">2</div><div class="card">3</div>
  <div class="card">4</div><div class="card">5</div><div class="card">6</div>
</div>
.g-auto {
  display: grid; 
  gap: 20px; 
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  /* ↑ 每列至少 280px,空间够就多塞一列;不够就自动掉列 */
  /* auto-fit 会折叠空列,最后一行会自然左对齐(视觉更舒服) */
}

如果你想“更像卡片墙”的体验,通常用 固定最小宽度(像 240/280/320px)比用百分比(30%)更稳,因为百分比还要把 gap 算进去,容易出现刚好不满 3 列的临界抖动。


C. Flex 方案(演示常见坑 & 修复)

❌ 常见问题写法(最后一行被拉开)

.flex-bad {
  display: flex;
  flex-wrap: wrap;               /* 允许换行 */
  justify-content: space-between;/* ❌ 两端对齐:最后一行会被撑开难看 */
  gap: 20px;                     /* 现代浏览器支持 flex-gap,但配合 space-between 仍有最后一行问题 */
}
.flex-bad > .item {
  flex: 0 1 30%;                 /* 看似 3 列,但没考虑 gap 与临界宽 */
}

✅ 修复 1:改为靠左 + 用 gap 控间距

.flex-good {
  display: flex;
  flex-wrap: wrap;               /* 换行 */
  justify-content: flex-start;   /* ✅ 靠左排列,最后一行不再被拉开 */
  gap: 20px;                     /* ✅ 统一间距 */
}
.flex-good > .item {
  /* 三列公式:((100% - 2*gap) / 3),2*gap 是一行内两个间距 */
  flex: 0 1 calc((100% - 2*20px) / 3);  /* 需要把 20px 写进 calc */
  /* 或者使用 CSS 变量:--g:20px; calc((100% - 2*var(--g))/3) */
  box-sizing: border-box;        /* 防止 padding 参与宽度导致换行抖动 */
}

✅ 修复 2:响应式(最小宽度方案)

.flex-auto {
  --min: 280px;                  /* 最小卡片宽度 */
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-start;   /* 保持靠左 */
  gap: 20px;
}
.flex-auto > .item {
  /* 当容器足够宽时,会挤出更多列;不够时掉列 */
  flex: 1 1 var(--min);          /* “至少占 --min,剩余平均分” 的近似效果 */
  max-width: calc((100% - 2*20px) / 3); /* 避免无限拉伸,限制到 3 列上限(可选) */
}

结论:Flex 可以做,但需要一堆小心思;Grid 更“开箱即用”。


D. CSS2 方案(备用与迁移)

1) float(含清除浮动)

<div class="float-3 clearfix">
  <div class="col">1</div><div class="col">2</div><div class="col">3</div>
  <div class="col">4</div><div class="col">5</div>
</div>
.float-3 { 
  margin-top: 42px;
  /* 用负外边距抵消子项 margin 的“行末溢出”,是老派写法之一 */
  margin-left: -10px;            /* = 子项的左 margin */
}
.float-3 .col {
  float: left;                    /* 关键:让列并排 */
  width: calc((100% - 2*20px) / 3); /* 三列宽度 + 手动扣 gap(复杂且易抖动) */
  margin-left: 10px;             /* 横向间距的一半(左右合起来 20px) */
  margin-bottom: 20px;           /* 纵向间距 */
  box-sizing: border-box;
  background: #f6f6f6; padding: 16px; border-radius: 8px;
}
/* 清除浮动 */
.clearfix::after { content: ""; display: table; clear: both; }

缺点

  • 需要清除浮动;
  • 间距要靠 margin 手算,容器要配负外边距抵消;
  • 断点/换行/等高都不优雅。

2) inline-block(解决空白缝隙)

<div class="ib-3">
  <!-- 去掉换行/空格,或用注释/字体归零来消除间隙 -->
  <div class="cell">1</div><div class="cell">2</div><div class="cell">3</div>
  <div class="cell">4</div><div class="cell">5</div>
</div>
.ib-3 {
  font-size: 0;                   /* 关键:清除 inline-block 之间的空白字符间隙 */
}
.ib-3 .cell {
  display: inline-block;
  vertical-align: top;            /* 避免基线错位 */
  width: calc((100% - 2*20px)/3); /* 三列 + 手算 gap(同样麻烦) */
  margin-right: 20px;             /* 间距 */
  margin-bottom: 20px;
  font-size: 14px;                /* 恢复文字大小 */
  background: #fff7e6; padding: 16px; border-radius: 8px; box-sizing: border-box;
}
.ib-3 .cell:nth-child(3n) { margin-right: 0; } /* 每行最后一个去掉右间距 */

缺点:靠技巧实现,易碎、难维护;不推荐新项目使用。


拓展

  1. auto-fit vs auto-fill
  • auto-fit 折叠空轨道,最后一行看起来“贴左”;
  • auto-fill 保留空轨道(不可见),某些网格对齐需求更好算。

你的场景通常选 auto-fit,视觉更自然。

  1. gap 取代 margin 做栅格间距
  • gap 不会引起外边距合并;语义更清晰;Grid/Flex 均支持(现代浏览器)。
  1. 防止内容把列撑爆
  • 列宽写 minmax(0, 1fr)
  • 文本加 overflow-wrap:anywhere;word-break:break-word;
  1. 统一卡片高度
  • Grid/Flex 默认同一行会以最高项为基准拉伸;
  • 若需卡片内部“头-体-脚”布局:给 .carddisplay:flex; flex-direction:column;,中间内容 flex:1,按钮区自然贴底。
  1. 响应式更优雅
  • 常见写法:grid-template-columns: repeat(auto-fit, minmax(clamp(240px, 25vw, 320px), 1fr));

    • clamp(最小, 理想, 最大) 让卡片宽度随视口柔性变化。
  1. 容器查询(Container Query) (现代浏览器)
  • 组件而非视口决定断点:
.wrapper { container-type: inline-size; }
@container (min-width: 900px) {
  .list { grid-template-columns: repeat(3, 1fr); }
}

JavaScript 并发编程实战:用 Atomics 与 SharedArrayBuffer 玩转多线程与视频渲染

1)Atomics 与 SharedArrayBuffer

概念

  • SharedArrayBuffer(SAB) :可被多个执行体(主线程/Worker)同时访问的内存缓冲区。它不复制数据,而是多方共享同一块内存。
  • Atomics:在共享内存上提供一组原子读写、算术、位运算、交换、以及“futex 风格”等待/唤醒的 API,确保并发下的内存可见性与操作不可分割性。

原理

  • JS 的普通操作并不保证对共享内存的原子性(不可分割)与顺序可见性(别的线程何时能看到你的写入)。
  • Atomics.*SharedArrayBuffer 上的 Int32Array/BigInt64Array 视图上执行,提供内存序保障(类似 CPU 原子指令 + 内存栅栏),避免“读旧值”“丢写”“乱序可见”等并发问题。

对比

  • ArrayBuffer:只能单线程访问或拷贝给别人(会复制或转移所有权),无并行共享。
  • SharedArrayBuffer真正并发共享,因此需要 Atomics 来做同步互斥
  • 高层抽象(比如消息通道 postMessage)适合低频大块传输;SAB+Atomics 更适合高频、低延迟、细粒度的共享与同步(例如音视频帧环形缓冲区、计数器、锁/条件变量等)。

实践(小结)

  • 没有 Atomics 的 SAB 常导致资源争用:多线程同时读/改/写同一位置,会出现竞态
  • 用 Atomics 把关键步骤“锁住”或“原子化”,再配合 wait/notify阻塞/唤醒式同步,就能写出稳定的并发代码。

拓展

  • 可用 Atomics 自行实现 spinlockticket lock信号量环形队列有界缓冲读写锁 等原语。
  • WebCodecsOffscreenCanvasWebAssembly 配合,能搭建高性能音视频管线、图像处理流水线。

潜在问题

  • 只有 Worker 可以调用阻塞式 Atomics.wait(主线程禁止阻塞)。
  • 浏览器端使用 SAB 需要 crossOriginIsolated(COOP/COEP 头或等效策略)。
  • 原子操作频繁也有成本:要平衡锁粒度争用

2)SharedArrayBuffer:四个 Worker 访问同一 SAB 的“资源争用”演示

2.1 无原子操作:会丢写(错误示范)

<!-- demo-race.html:演示丢写,需在 crossOriginIsolated 环境中运行 -->
<script>
  // 1) 准备共享内存:仅 4 字节(一个 32 位整数)
  const sab = new SharedArrayBuffer(4);
  const view = new Int32Array(sab);
  view[0] = 0; // 共享计数器

  // 2) Worker 脚本(用 Blob URL 内联,便于单文件运行)
  const workerCode = `
    self.onmessage = async (e) => {
      const { sab, rounds } = e.data;
      const view = new Int32Array(sab);
      // 不使用 Atomics:典型的“读-改-写”竞态
      for (let i = 0; i < rounds; i++) {
        const v = view[0];    // A: 读
        // 模拟计算耗时,增大竞态概率
        for (let j = 0; j < 50; j++) {} 
        view[0] = v + 1;      // B: 写(可能覆盖他人写入)
      }
      postMessage('done');
    };
  `;
  const workerUrl = URL.createObjectURL(new Blob([workerCode], {type: 'text/javascript'}));

  // 3) 启动 4 个 worker,每个自增 N 次
  const N = 10000;
  const workers = Array.from({length: 4}, () => new Worker(workerUrl));
  let done = 0;
  workers.forEach(w => {
    w.onmessage = () => {
      if (++done === workers.length) {
        console.log('期望值 =', 4*N, '实际值 =', view[0]); // 实际值通常 < 期望值
      }
    };
    w.postMessage({ sab, rounds: N });
  });
</script>

说明:多个 Worker 在 view[0] 上做“读-改-写”,没有互斥或原子化,发生丢写(比如两个线程都读取了旧值 5,然后分别写回 6 → 6,等价于只+1一次)。

2.2 使用 Atomics.add 修复丢写(正确示范)

<!-- demo-atomic-add.html:用 Atomics 消除丢写 -->
<script>
  const sab = new SharedArrayBuffer(4);
  const view = new Int32Array(sab);
  view[0] = 0;

  const workerCode = `
    self.onmessage = async (e) => {
      const { sab, rounds } = e.data;
      const view = new Int32Array(sab);
      for (let i = 0; i < rounds; i++) {
        // 原子加:硬件级不可分割 + 内存序保障
        Atomics.add(view, 0, 1);
      }
      postMessage('done');
    };
  `;
  const workerUrl = URL.createObjectURL(new Blob([workerCode], {type: 'text/javascript'}));

  const N = 10000;
  const workers = Array.from({length: 4}, () => new Worker(workerUrl));
  let done = 0;
  workers.forEach(w => {
    w.onmessage = () => {
      if (++done === workers.length) {
        console.log('期望值 =', 4*N, '实际值 =', view[0]); // === 期望值
      }
    };
    w.postMessage({ sab, rounds: N });
  });
</script>

3)原子操作基础

3.1 算术与位运算:add/sub/or/and/xor

使用场景

  • 计数/配额add / sub
  • 多标志位or 设置位,and 清位,xor 翻转位
  • 资源争用:用位标志表示“资源是否被占用/脏页/就绪”等

示例:用位运算管理 4 个“资源占用”位,并原子自增计数

<script>
  // 布局:index 0 = 计数器;index 1 = 标志位(低 4 位对应 4 个资源占用)
  const sab = new SharedArrayBuffer(8);
  const i32 = new Int32Array(sab);
  const COUNTER = 0, FLAGS = 1;

  // 设置第 k 位(占用资源 k)
  function occupy(k) {
    const mask = 1 << k;
    Atomics.or(i32, FLAGS, mask); // 原子置位
  }

  // 清除第 k 位(释放资源 k)
  function release(k) {
    const mask = ~(1 << k);
    Atomics.and(i32, FLAGS, mask); // 原子清位
  }

  // 查询第 k 位
  function isOccupied(k) {
    return (Atomics.load(i32, FLAGS) & (1 << k)) !== 0;
  }

  // 原子自增计数(比如处理任务数)
  function inc() {
    return Atomics.add(i32, COUNTER, 1) + 1; // 返回自增后的值
  }

  // —— 演示 —— 
  occupy(0); // 占用资源0
  console.log('flags after occupy(0)=', i32[FLAGS].toString(2));
  console.log('isOccupied(0)=', isOccupied(0));
  release(0);
  console.log('flags after release(0)=', i32[FLAGS].toString(2));
  console.log('inc() ->', inc()); // 1
</script>

3.2 原子的读/写:load / store

  • Atomics.load(view, idx):从共享内存原子读取,带必要内存序(保证之前的写入对当前可见)。
  • Atomics.store(view, idx, value)原子写入并带发布语义(之后的读取者按顺序可见)。

示例:标志位 + 数据写入的“先写数据、再置就绪标志”

<script>
  // index 0: READY 标志;index 1: 数据
  const sab = new SharedArrayBuffer(8);
  const i32 = new Int32Array(sab);
  const READY = 0, DATA = 1;

  // 生产者(Worker 内常见)
  function producerWrite(v) {
    Atomics.store(i32, DATA, v);     // ① 先写数据
    Atomics.store(i32, READY, 1);    // ② 再置就绪(发布)
    // 消费者用 load/或 wait 看到 READY==1 时,保证能看到数据 v
  }

  // 消费者
  function consumerRead() {
    if (Atomics.load(i32, READY) === 1) {
      const v = Atomics.load(i32, DATA);
      console.log('read =', v);
    }
  }

  producerWrite(42);
  consumerRead(); // 输出 42
</script>

3.3 原子交换:exchange / compareExchange

  • exchange:把索引处的值替换为新值,并返回旧值(常用于交换标志获取旧状态)。
  • compareExchange(CAS):若当前值等于期望值,则写入新值并返回旧值;否则不写,返回实际旧值。用来实现无锁算法自旋锁once 初始化等。

示例 A:exchange 实现“获取并清空”通知标志

<script>
  const sab = new SharedArrayBuffer(4);
  const i32 = new Int32Array(sab);

  function notifyOnce() {
    Atomics.store(i32, 0, 1); // 置 1:有通知
  }
  function consumeNotify() {
    const prev = Atomics.exchange(i32, 0, 0); // 写回0,返回旧值
    if (prev === 1) console.log('收到一次通知并已清空');
  }

  notifyOnce();
  consumeNotify(); // 收到一次通知并已清空
</script>

示例 B:compareExchange 实现“最大值更新”(无锁)

<script>
  const sab = new SharedArrayBuffer(4);
  const i32 = new Int32Array(sab);
  i32[0] = 10; // 初始最大值

  function updateMax(candidate) {
    while (true) {
      const cur = Atomics.load(i32, 0);
      if (candidate <= cur) return cur; // 不需要更新
      // 仅当 cur 仍是当前值时,写入 candidate
      const old = Atomics.compareExchange(i32, 0, cur, candidate);
      if (old === cur) return candidate; // 成功
      // 否则被别人抢先更新,循环重试
    }
  }

  console.log(updateMax(15)); // 15
  console.log(updateMax(12)); // 15(不会倒退)
</script>

3.4 原子的 “futex” 操作与加锁:wait / notify

  • Atomics.wait(view, idx, expected[, timeout])阻塞当前 Worker,直到 view[idx] 不再等于 expected 或超时被唤醒(仅 Worker 可用!)。
  • Atomics.notify(view, idx[, count]):唤醒在该地址上等待的 count 个线程(默认唤醒全部)。

示例:4 个 Worker 依次取得锁并按顺序执行

思路:用一个共享变量 turn 指示当前轮到的线程 ID(0..3)。每个 Worker:
1)在 turn 不等于自己 ID 时调用 wait 挂起;
2)轮到自己时执行任务;3)将 turn 改为下一个 ID,并 notify 唤醒其他等待者。

<!-- demo-turn.html:依次执行(0 → 1 → 2 → 3 → 循环) -->
<script>
  const sab = new SharedArrayBuffer(4);
  const i32 = new Int32Array(sab);
  const TURN = 0;
  Atomics.store(i32, TURN, 0); // 从 0 号开始

  const workerCode = `
    self.onmessage = async (e) => {
      const { sab, id, rounds } = e.data;
      const i32 = new Int32Array(sab);
      const TURN = 0;

      for (let r = 0; r < rounds; r++) {
        // 1) 等待轮到自己
        while (Atomics.load(i32, TURN) !== id) {
          // 等待值在当前值上发生变化
          Atomics.wait(i32, TURN, Atomics.load(i32, TURN), 100); // 可设超时避免永久睡眠
        }
        // 2) 执行临界区(此处仅打印模拟)
        // (在真实任务中,这里可访问共享数据结构)
        postMessage(`Worker ${id}: run #${r}`);

        // 3) 交接“令牌”给下一个
        const next = (id + 1) % 4;
        Atomics.store(i32, TURN, next); // 更新 turn
        Atomics.notify(i32, TURN, 1);   // 唤醒一个等待者
      }
      postMessage(`Worker ${id}: done`);
    };
  `;

  const url = URL.createObjectURL(new Blob([workerCode], {type:'text/javascript'}));
  const workers = Array.from({length:4}, (_,id)=>new Worker(url));
  workers.forEach((w,id)=>{
    w.onmessage = (e)=> console.log(e.data);
    w.postMessage({ sab, id, rounds: 3 });
  });
</script>

4)“视频播放”示例(OffscreenCanvas + SAB 环形缓冲 + Atomics.wait/notify

目标:搭一个“解码/生产者渲染/消费者”的双 Worker 管线:

  • 解码 Worker 把帧像素(这里用程序生成假帧代替真实解码)写入 SAB 环形缓冲
  • 渲染 Worker 拿到 OffscreenCanvas,在 Worker 内 Atomics.wait 阻塞等待有帧可用,唤醒后从 SAB 读帧并绘制。
  • 主线程只负责创建 canvas、传参、显示 UI,不做阻塞等待。

✅ 重点:演示 有界缓冲区(避免无限增长)、等待/唤醒(低 CPU 占用)、无复制共享(SAB)。

⚠️ 要求:在 crossOriginIsolated 环境运行(见文末部署说明)。

<!-- video-sab.html:双 Worker 视频模拟播放(单文件版) -->
<canvas id="screen" width="320" height="180" style="border:1px solid #ccc"></canvas>
<button id="start">Start</button>
<button id="stop">Stop</button>
<script>
(() => {
  // ========================
  // 1) 配置环形缓冲区参数
  // ========================
  const WIDTH = 320, HEIGHT = 180, BYTES_PER_PIXEL = 4;
  const FRAME_BYTES = WIDTH * HEIGHT * BYTES_PER_PIXEL;
  const RING_CAP = 8; // 帧缓冲容量(越大越抗抖;越小越省内存)

  // 控制区布局(Int32)
  // [0] writeIndex  生产者写入位置(0..RING_CAP-1)
  // [1] readIndex   消费者读取位置
  // [2] available   当前可用帧数量(0..RING_CAP)
  // [3] running     1=运行,0=停止(供协程优雅退出)
  const CTRL_INTS = 4;
  const controlSAB = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * CTRL_INTS);
  const ctrl = new Int32Array(controlSAB);
  ctrl[0] = 0; ctrl[1] = 0; ctrl[2] = 0; ctrl[3] = 0;

  // 帧区(共享像素),总大小 = CAP * FRAME_BYTES
  const framesSAB = new SharedArrayBuffer(RING_CAP * FRAME_BYTES);

  // ========================
  // 2) Worker 代码(内联 Blob)
  // ========================
  const decoderCode = `
    // 生产者:把“合成帧”写入环形缓冲,并 Atomics.notify 唤醒渲染线程
    self.onmessage = (e) => {
      const { controlSAB, framesSAB, width, height, cap } = e.data;
      const ctrl = new Int32Array(controlSAB);
      const bytes = new Uint8ClampedArray(framesSAB);
      const FRAME_BYTES = width * height * 4;

      function produceOne(t) {
        // 1) 如果环形缓冲已满,等待消费者消耗
        while (Atomics.load(ctrl, 2) === cap) {
          // 在 available 索引(2)上等待值变化
          Atomics.wait(ctrl, 2, cap, 50); // 50ms 超时避免死等
          if (Atomics.load(ctrl, 3) === 0) return false; // 被要求停止
        }

        // 2) 写入一帧到 writeIndex
        const wi = Atomics.load(ctrl, 0);
        const base = wi * FRAME_BYTES;

        // —— 生成一个简单动态图案(渐变+移动方块)——
        for (let y = 0; y < height; y++) {
          for (let x = 0; x < width; x++) {
            const i = base + (y * width + x) * 4;
            // 背景:横向渐变
            bytes[i+0] = (x + t) % 256;     // R
            bytes[i+1] = (y*2) % 256;       // G
            bytes[i+2] = (255 - x) % 256;   // B
            bytes[i+3] = 255;               // A

            // 叠加一个随时间移动的白色小方块
            const s = 32;
            const cx = (t*2) % (width - s);
            const cy = (t) % (height - s);
            if (x >= cx && x < cx+s && y >= cy && y < cy+s) {
              bytes[i+0] = 255; bytes[i+1] = 255; bytes[i+2] = 255;
            }
          }
        }

        // 3) 提交一帧:writeIndex 向前,available++
        Atomics.store(ctrl, 0, (wi + 1) % cap);
        Atomics.add(ctrl, 2, 1);
        // 唤醒在 available 上等待的消费者
        Atomics.notify(ctrl, 2, 1);
        return true;
      }

      // 主循环:~60 FPS 生产
      let t = 0;
      const produceLoop = () => {
        if (Atomics.load(ctrl, 3) === 0) return; // 停止
        produceOne(t++);
        setTimeout(produceLoop, 16);
      };
      produceLoop();
    };
  `;

  const rendererCode = `
    // 消费者:在 OffscreenCanvas 中阻塞等待有帧可用,再绘制
    self.onmessage = (e) => {
      const { canvas, controlSAB, framesSAB, width, height, cap } = e.data;
      const off = canvas;
      const ctx = off.getContext('2d');
      const ctrl = new Int32Array(controlSAB);
      const bytes = new Uint8ClampedArray(framesSAB);
      const FRAME_BYTES = width * height * 4;

      function drawOne() {
        // 1) 若无可用帧,则阻塞等待 available 变化
        while (Atomics.load(ctrl, 2) === 0) {
          Atomics.wait(ctrl, 2, 0, 100); // 100ms 超时防止永久睡眠
          if (Atomics.load(ctrl, 3) === 0) return false; // 停止
        }

        // 2) 取出一帧
        const ri = Atomics.load(ctrl, 1);
        const base = ri * FRAME_BYTES;

        // 为了兼容性,复制到非共享缓冲再创建 ImageData(一些浏览器对 SAB-backed ImageData 有限制)
        const local = new Uint8ClampedArray(FRAME_BYTES);
        local.set(bytes.subarray(base, base + FRAME_BYTES));

        const img = new ImageData(local, width, height);
        ctx.putImageData(img, 0, 0);

        // 3) 消费完成:readIndex 前移,available--
        Atomics.store(ctrl, 1, (ri + 1) % cap);
        Atomics.sub(ctrl, 2, 1);
        // 唤醒可能在等待“缓冲不满”的生产者
        Atomics.notify(ctrl, 2, 1);
        return true;
      }

      // 渲染循环:尽量快地消费(可加节流/时间戳对齐)
      const loop = () => {
        if (Atomics.load(ctrl, 3) === 0) return;
        drawOne();
        // 这里选择 requestAnimationFrame 风格节奏:~60fps
        setTimeout(loop, 16);
      };
      loop();
    };
  `;

  const decoderURL  = URL.createObjectURL(new Blob([decoderCode],  {type:'text/javascript'}));
  const rendererURL = URL.createObjectURL(new Blob([rendererCode], {type:'text/javascript'}));

  // ========================
  // 3) 主线程:创建 Worker 与 OffscreenCanvas
  // ========================
  const canvas = document.getElementById('screen');
  const startBtn = document.getElementById('start');
  const stopBtn  = document.getElementById('stop');
  let decoder, renderer;

  startBtn.onclick = () => {
    if (Atomics.load(ctrl, 3) === 1) return; // 已运行
    Atomics.store(ctrl, 3, 1);               // running = 1

    decoder  = new Worker(decoderURL);
    renderer = new Worker(rendererURL);

    // 把 canvas 交给渲染 Worker
    const offscreen = canvas.transferControlToOffscreen();

    // 传入共享内存与参数(注意把 OffscreenCanvas 置于 transferable 中)
    renderer.postMessage({
      canvas: offscreen, controlSAB, framesSAB,
      width: WIDTH, height: HEIGHT, cap: RING_CAP
    }, [offscreen]);

    decoder.postMessage({
      controlSAB, framesSAB, width: WIDTH, height: HEIGHT, cap: RING_CAP
    });
  };

  stopBtn.onclick = () => {
    if (Atomics.load(ctrl, 3) === 0) return;
    Atomics.store(ctrl, 3, 0);      // 请求停止
    Atomics.notify(ctrl, 2, 10);    // 唤醒阻塞的 wait
    decoder && decoder.terminate();
    renderer && renderer.terminate();
    decoder = renderer = null;
    // 重置索引
    Atomics.store(ctrl, 0, 0);
    Atomics.store(ctrl, 1, 0);
    Atomics.store(ctrl, 2, 0);
  };
})();
</script>

你会看到:点击 Start 后,canvas 中出现平滑变化的“视频”画面(程序合成帧)。这证明了:

  • 不复制像素(共享 SAB)→ 低延迟;
  • Atomics.wait/notify 在 Worker 内阻塞/唤醒 → 低 CPU 占用;
  • 环形缓冲(availablereadIndexwriteIndex)→ 平衡生产/消费速率。

5)拓展与实践建议

  • 接入真实视频:用 WebCodecs 在解码 Worker 中解码 EncodedVideoChunk,把 VideoFrame 映射/拷到 SAB(或转成 RGBA),其余流程相同。
  • 音视频同步:在控制区携带 时间戳时钟偏移,渲染端按 wall-clock(performance.now())或 AudioContext 时钟对齐。
  • 多生产者/多消费者:把 available 拆为空槽信号量已填充信号量,或用 ticket lock 控制写入/读取原子性。
  • 帧丢弃策略:渲染端若落后,可原子地把 readIndex 前跳至最新,丢弃陈旧帧,保持实时性。

6)潜在问题与排错清单

  1. Atomics.wait 只能在 Worker 调用:主线程会抛异常。主线程宜用 postMessage/事件/RAF 协调。

  2. crossOriginIsolated 要求(浏览器中使用 SAB 必备):

    • 响应头需包含:

      • Cross-Origin-Opener-Policy: same-origin
      • Cross-Origin-Embedder-Policy: require-corp
    • 或其它等效策略确保 window.crossOriginIsolated === true

    • 你可在本地用任意静态服务器加这两行头部(如 Nginx/Express/Cloudflare Pages 等)再访问 video-sab.html

  3. 忙等 vs 阻塞:若发现 CPU 飙高,检查是否错误地用 while 自旋;应使用 Atomics.wait/notify

  4. 数据对齐Atomics 只能作用于 Int32Array / BigInt64Array 视图的元素(4/8 字节对齐)。

  5. 内存越界:环形缓冲的 base + FRAME_BYTES 计算要严谨,最好封装成函数并加断言。

  6. 兼容性OffscreenCanvasWebCodecs 在部分环境可能需要开关或降级方案(可退化为主线程绘制 + 非阻塞轮询)。

❌