阅读视图

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

从零到一打造 Vue3 响应式系统 Day 18 - Reactive:深入 Proxy 的设计思路

ZuB1M1H.png

在之前的文章中,我们已经完成了 ref 的实现,它能将原始值包装成响应式对象。现在,我们要接着完成响应式系统核心的另一部分:reactive 函数。我们的目标是接收一个完整的对象,并返回一个代理对象,使其所有属性都具备响应性。

目标设定

我们的目标很明确:完成一个 reactive 函数,让其行为和 Vue 的官方示例一样。

环境搭建

// import { reactive, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
import { reactive, effect } from '../dist/reactivity.esm.js'

const state = reactive({
  a: 0
})
effect(() => {
  console.log(state.a)
})

setTimeout(() => {
  state.a = 1
}, 1000)

我们期待初始化页面时输出 0,一秒钟后输出 1。

使用被注释掉的官方示例,我们可以很明显地看到正确的输出值。

day18-01.png 我们先在 src 目录下新建一个 reactive.ts

export function reactive(target){
}

并且在 index.ts 中引入。

export * from './ref'
export * from './effect'
export * from './reactive'

另外,我们在存放工具函数的 shared/src/index.ts 中编写一个对象判断函数。

export function isObject(value) {
  return typeof value === 'object' && value !== null
}

核心思路

我们再另外编写一个函数 createReactiveObject,我们实际的逻辑并不直接放在 reactive 函数中。

主要是因为 createReactiveObject 之后在其他地方也会用到,像是 shallowReactive 之类的 API。

export function reactive(target){
  return createReactiveObject(target)
}

接下来思考 createReactiveObject 本身的限制,以及我们的需求:

  1. 它只能接收对象类型,所以我们要去判断它的类型。

  2. reactive 的核心是使用一个 Proxy 对象来处理。

  3. Proxy 对象中会需要 getset 处理器来收集依赖、触发更新。

    • 收集依赖target 的每个属性都是一个依赖,因此我们需在收集依赖时,把 target 的属性跟 effect (也就是 sub) 建立关联关系。
    • 触发更新:通知之前为该属性收集的依赖,让它们重新执行。

为什么 Vue 3 的 reactive() 特别适合使用 Proxy?

主要是因为 Proxy 有几个关键特性:

  • Proxy 可以拦截并自定义对象的各种操作,不只是属性的读取和设置。
  • 与 Vue 2 使用 Object.defineProperty() 相比,Proxy 的最大优势是可以侦测到新增的属性
  • Proxy 可以直接拦截数组的索引操作和 length 变更。
  • Proxy 可以处理 MapSetWeakMapWeakSet 等集合类型。

看来针对对象类型的 reactiveProxy 对象确实是一个更好的解决方案,那我们开始实现吧!

初步实现 - 借鉴 Ref 的实现

import { isObject } from '@vue/shared'

function createReactiveObject(target){
  // reactive 只处理对象
  if (!isObject(target)) return target

  // 创建 target 的代理对象
  const proxy = new Proxy(target, {
    get(target, key, receiver){
      // 收集依赖:绑定 target 的属性与 effect 的关系
      console.log('get:', target, key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, newValue, receiver){
      // 触发更新:通知之前收集的依赖,重新执行 effect
      console.log('set:', target, key, newValue)
      return Reflect.set(target, key, newValue, receiver)
    }
  })

  return proxy
}

我们来看一下,实际的输出值:

看来好像挺接近的,但依照我们编写 ref 的经验,我们还需要处理链表相关的逻辑。

先回顾一下我们的 ref 之前是怎么写的:

export function trackRef(dep) {
  if (activeSub) {
    link(dep, activeSub)
  }
}

export function triggerRef(dep) {
  if (dep.subs) {
    propagate(dep.subs)
  }
}
  • get 中有一个 trackRef 函数,trackRef 函数判断是否存在 effect (activeSub),如果存在,就将依赖 (dep) 以及 effect (activeSub) 传入 link 函数建立链表关联关系。
  • set 中有一个 triggerRef 函数,triggerRef 函数判断该依赖是否收集过 effect,如果存在,就传入 propagate 进行触发更新。

看来这个依赖 (dep) 很重要,那什么是依赖呢?

class RefImpl {
  _value;
  [ReactiveFlags.IS_REF] = true

  subs: Link
  subsTail: Link
  // ...
  get value() {
    if (activeSub) {
      trackRef(this) // 这里的 this (RefImpl 实例) 就是 dep
    }
    return this._value
  }

  set value(newValue) {
    this._value = newValue
    triggerRef(this) // 这里的 this (RefImpl 实例) 就是 dep
  }
}

我们可以看到传入 trackReftriggerRefdep 必须包含 subssubsTail 属性。

那我们可以创建一个 Dep 类,其他逻辑可以照搬 reftrackReftriggerRef 并进行修改。

import { activeSub } from './effect'
import { link, propagate, Link } from './system'

function createReactiveObject(target){
  // reactive 只处理对象
  if(!isObject(target)) return target

  // 创建 target 的代理对象
  const proxy = new Proxy(target, {
    get(target, key, receiver){
      track(target, key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, newValue, receiver){
      const res = Reflect.set(target, key, newValue, receiver)
      trigger(target, key)
      return res
    }
  })

  return proxy
}

class Dep {
  subs: Link
  subsTail: Link
  constructor(){}
}

function track(target, key){
  if (!activeSub) return
  // link(dep, activeSub) // dep 从哪里来?
}

function trigger(target, key){
  // if (dep.subs) {
  //   propagate(dep.subs) // dep 从哪里来?
  // }
}

注:在 set 处理器中,我们应该先完成赋值操作,再触发更新通知。

感觉创建一个 Dep 类的实例,传入 track 就可以了。不过用户传入的 target 对象跟我们新建的 Dep 似乎没有直接关系。

看起来我们遇到了一些问题:

  • 我们不能再用一个 Dep 实例来管理所有属性的依赖,必须为对象的每个属性都维护一个独立的 Dep
  • 如何建立 target.aDep for a 的对应关系?
  • 如何在不污染原始 target 对象的情况下,存储 targetkeyDep 之间的关联?

为了解决这个问题,我们需要引入一个更复杂的数据结构来存储这些关系,明天我们再接着探讨。


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

深入探索浏览器缓存键:一次HTTP强缓存失效引发的思考

一次 HTTP 强缓存失效引发的浏览器缓存键深度探索

浏览器缓存键机制

缓存键的概念

缓存键(Cache Key)是浏览器为每个缓存条目生成的唯一标识符,用来决定是否存在匹配的缓存。

缓存键的组成要素

URL 的细微差别

// 每一个细微的URL差别都会产生不同的缓存键
const urls = [
  "https://example.com/api/data",
  "https://example.com/api/data/", // 末尾斜杠
  "https://example.com/api/data?", // 空查询参数
  "https://example.com/api/data#section", // fragment通常被忽略
  "https://example.com/api/data?a=1&b=2",
  "https://example.com/api/data?b=2&a=1", // 参数顺序不同
];

协议和端口

// 不同协议或端口会产生不同的缓存键
http://example.com/image.jpg
https://example.com/image.jpg      // 不同缓存键
https://example.com:8080/image.jpg // 不同缓存键

Vary 响应头的影响

// 服务器设置
app.get("/api/data", (req, res) => {
  res.set("Vary", "Accept-Language, Accept-Encoding");
  res.set("Cache-Control", "max-age=3600"); // ...
});

// 客户端 - 不同的头部值会产生不同的缓存键
fetch("/api/data", {
  headers: {
    "Accept-Language": "zh-CN",
    "Accept-Encoding": "gzip",
  },
});

fetch("/api/data", {
  headers: {
    "Accept-Language": "en-US", // 不同的语言
    "Accept-Encoding": "gzip",
  },
});

请求模式和凭据

// 不同的 fetch 配置可能产生不同的缓存键
fetch("/api/data", { mode: "cors" });
fetch("/api/data", { mode: "no-cors" });
fetch("/api/data", { credentials: "include" });
fetch("/api/data", { credentials: "omit" });
异常现象

在开发一个商品图片处理功能时,我采用了常见的优化策略:提前预加载图片,然后在需要时绘制到 Canvas 上进行处理。代码大致如下:

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

// 在 Canvas 中使用图片
function drawImageToCanvas(imageUrl) {
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d");
  const img = new Image();
  img.crossOrigin = "anonymous"; // 为了避免 Canvas污染
  img.src = imageUrl;
  img.onload = function () {
    ctx.drawImage(img, 0, 0); // 进行图片处理...
    const processedDataURL = canvas.toDataURL();
    return processedDataURL;
  };
}

// 服务器需要返回适当的 CORS 头部:
// Access-Control-Allow-Origin: *
// Access-Control-Allow-Methods: GET, OPTIONS

通过 Chrome DevTools 的 Network 面板,我发现了一个奇怪的现象:

  1. 第一次预加载图片时,浏览器正常请求并缓存了图片
  2. 后续在 Canvas 绘制时,浏览器竟然又发起了一次相同 URL 的请求
  3. 第二次请求返回了 200 OK 而不是期望的 200 (from cache)

解决方案与最佳实践

统一 crossOrigin 设置

为了避免缓存键不一致的问题,我们应该在整个应用中保持一致的 crossOrigin 设置:

// 封装图片加载函数
function loadImage(src, needsCORS = false) {
  const img = new Image();
  if (needsCORS) {
    img.crossOrigin = "anonymous";
  }
  img.src = src;
  return img;
}

// 在预加载时就设置crossOrigin
function preloadImagesForCanvas(urls) {
  return Promise.all(
    urls.map(
      (url) =>
        new Promise((resolve) => {
          const img = loadImage(url, true); // 统一设置crossOrigin
          img.onload = () => resolve(img);
        })
    )
  );
}

缓存键标准化

对于 API 请求,我们可以标准化参数来确保缓存键的一致性:

// 标准化查询参数
function normalizeParams(params) {
  return Object.keys(params)
    .sort()
    .reduce((result, key) => {
      if (params[key] !== undefined && params[key] !== "") {
        result[key] = params[key];
      }
      return result;
    }, {});
}

// 使用标准化参数
const fetchData = (params) => {
  const normalized = normalizeParams(params);
  const queryString = new URLSearchParams(normalized).toString();
  return fetch(`/api/data?${queryString}`);
};

服务器端优化

合理设置 Vary 头部,避免过度细分缓存:

app.get("/api/images/*", (req, res) => {
  // 只对真正影响响应内容的头部设置Vary
  res.set("Vary", "Accept"); // ✅ 合理 // res.set('Vary', 'User-Agent'); // ❌ 过度细分
  res.set("Cache-Control", "public, max-age=31536000");
  res.set("Access-Control-Allow-Origin", "*"); // 支持CORS // 返回图片...
});

缓存策略设计

根据资源类型设计不同的缓存策略:

// 静态资源:长期缓存 + 文件名hash
const staticAssets = {
  "app.js": "app.abc123.js",
  "style.css": "style.def456.css",
};

// API数据:短期缓存或协商缓存
fetch("/api/user-info", {
  headers: {
    "Cache-Control": "max-age=300", // 5分钟缓存
  },
});

// 图片资源:考虑Canvas使用场景
const loadImageForCanvas = (url) => {
  const img = new Image();
  img.crossOrigin = "anonymous"; // 预设CORS
  img.src = url;
  return img;
};

调试与监控

开发者工具使用技巧

// 在控制台中检测缓存行为
const testCache = async (url1, url2) => {
    console.time('Request 1');
    await fetch(url1);
    console.timeEnd'Request 1');
    
    console.time('Request 2');
    await fetch(url2);
    console.timeEnd('Request 2');
};

// 检测是否命中缓存
testCache('/api/data?v=1', '/api/data?v=1');

缓存键可视化

// 简化的缓存键生成逻辑(用于理解)
function generateCacheKey(url, options = {}) {
  const { method = "GET", headers = {}, cors = false } = options;
  let key = `${method}:${url}`;
  if (cors) {
    key += ":CORS";
  } // 添加影响缓存的头部
  const varyHeaders = ["Accept-Language", "Accept-Encoding"];
  const headerParts = varyHeaders
    .filter((header) => headers[header])
    .map((header) => `${header}:${headers[header]}`);
  if (headerParts.length > 0) {
    key += `|${headerParts.join("|")}`;
  }
  return key;
}

// 使用示例
console.log(generateCacheKey("https://example.com/image.jpg"));
// 输出: GET:https://example.com/image.jpg

console.log(generateCacheKey("https://example.com/image.jpg", { cors: true }));
// 输出: GET:https://example.com/image.jpg:CORS

性能影响分析

通过这次问题,我意识到缓存键不一致的性能影响:

// 测量缓存失效的影响
const measureCacheImpact = async () => {
  const imageUrl = "https://example.com/large-image.jpg"; // 第一次加载(预加载)
  console.time("Preload");
  const img1 = new Image();
  img1.src = imageUrl;
  await new Promise((resolve) => (img1.onload = resolve));
  console.timeEnd("Preload"); // 可能输出: Preload: 500ms // 第二次加载(Canvas使用,设置了crossOrigin)
  console.time("Canvas Load");
  const img2 = new Image();
  img2.crossOrigin = "anonymous";
  img2.src = imageUrl;
  await new Promise((resolve) => (img2.onload = resolve));
  console.timeEnd("Canvas Load"); // 可能输出: Canvas Load: 480ms(没有命中缓存!)
};

对于大图片或网络较慢的情况,这种重复请求的影响会更加明显。

总结与反思

这次由 crossOrigin 属性引发的缓存问题,让我对浏览器缓存机制有了更深入的理解:

关键收获

  • 缓存键的复杂性:浏览器的缓存键不仅仅是 URL,还包括请求方法、特定头部、CORS 属性等多个维度
  • Canvas 污染的必要性:虽然 crossOrigin 会影响缓存,但它是 Web 安全的重要保障,不能简单地去掉
  • 一致性的重要性:在整个应用中保持一致的请求配置,可以最大化缓存的效果
  • 性能与安全的平衡:需要在缓存性能和安全性之间找到平衡点

最佳实践总结

  • 提前规划:在设计阶段就考虑哪些资源需要 Canvas 处理,统一设置 crossOrigin
  • 参数标准化:对 URL 参数进行排序和过滤,确保缓存键的一致性
  • 服务器配置:合理设置 CORS 和 Vary 头部,支持前端的缓存策略
  • 监控调试:使用开发者工具监控缓存命中情况,及时发现问题

未来思考

随着 Web 技术的发展,浏览器缓存机制也在不断演进。Service Worker、HTTP/3 等新技术为缓存控制提供了更多可能性。作为前端开发者,我们需要持续学习和适应这些变化,在保证功能正确的前提下,不断优化应用的性能表现。

这次问题的排查过程提醒我:看似简单的缓存问题背后,往往隐藏着复杂的机制。只有深入理解这些机制,我们才能写出更高效、更可靠的代码。

记 HarmonyOS 开发中的一个小事件 怒提华为工单

前言

HarmonyOS 应用开发中,想要体验完整的能力,靠预览器和模拟器是满足不了的,

这个时候就需要我们使用真机来开发和运行。

开发状态下,如果不是需要特别的功能体验,如地图定位,华为登录等,

在使用真机开发的情况下,我们使用自动签名即可。

image-20250926231013860

当自动签名成功的时候,对应的AGC平台上也会有一条真机记录,

image-20250926231118269

那如果一些小伙伴疯狂新建项目,疯狂使用自动签名,默认的设备数量100条,很快就会被占满。

image-20250926231158141

你没有看错,默认的是100条,已经占满,这个时候可以选择 选中和删除某些设备。

image-20250926231247835

但是,删除的时候主要不要删除了正在使用的设备就行,

但是真去删除的时候,却发现删除不了

image-20250926231346319

这个提醒可能是当初在生成证书的时候,勾选了全部的设备,但是现在的确是无法通过删除腾出设备的空间,

那就无法添加新设备了,后面提了工单,也没有解决方案。

image-20250926231511506

反馈是要到十月中旬,人可以等,事情不能等,真到这会黄花菜都凉了。

最后接着提工单沟通,后面hwAGC平台那边就给提升了设备的额度 100 变成了 500。

image-20250926231636024

总结

多提工单!!

多提工单!!

多提工单!!

🌩️ 云边协同架构下的 WebAI 动态资源调度技术

在数字世界的剧场里,数据是演员,算力是舞台,AI 模型就是导演。观众越来越多,但剧场的位置有限,总不能把所有人都塞到剧院(云端)里看戏。怎么办?答案是 —— 把街边也开辟成小剧场!(边缘计算)

于是,云边协同架构登场了,它让“中央大剧院”和“街边小剧场”默契配合,既能演宏大的 AI 大戏,也能灵活地为附近观众表演小场景。

但问题来了:观众(用户请求)随时会涌进来,演员(AI 任务)该安排在哪个舞台演出?怎么在不同舞台之间灵活调度?这,就是今天的主题:动态资源调度


🏗️ 背景知识铺垫

先用一句极简的方式解释:

  • :集中式超大算力,适合跑“大、慢、全”的 AI 模型。例如海量训练、批处理推理。
  • :分布式轻量算力,离用户更近,适合“快、细、近”的任务,例如实时推理、视频分析。

🌍 底层原理小切片:
在计算机系统中,资源调度本质是把有限资源(CPU 核心、GPU 显存、网络带宽等)映射到无限可能的请求队列。早期操作系统通过调度算法(比如“时间片轮转”),来确保多个进程公平使用 CPU。而在云边协同中,我们把这种调度扩展到了 地理位置 + 异构算力 的维度上。

换句话说,我们不仅要决定“谁用 CPU”,还要决定“在哪片土地上用 CPU”。


📦 动态调度的三大核心

  1. 资源发现
    边缘节点和云节点像是一个个旅馆房间。资源发现就是要知道哪间旅馆有空床,有的甚至还带“海景房”(GPU)。

  2. 任务匹配
    有的 AI 模型需要大 GPU(如 GPT 推理);有的只是做点小操作(如人脸检测)。调度器必须像“贴心的订房小秘书”,把客人安排到适合的旅馆。

    示例逻辑:

    • 小任务 → 最近的边缘节点
    • 大任务 → 云中心,配大 GPU
    • 时间敏感任务 → 优先边缘
    • 成本敏感任务 → 优先云端批处理
  3. 弹性迁移
    当边缘节点过载时,就得把部分任务“迁回云端”;反过来,在高峰期,为了不给云堵车,也可能“下放”到边缘。

这就是动态调度的精髓:边缘负责及时行乐,云端负责兜底与积蓄力量


🧮 关于调度算法的一点诗意解释

调度算法是整个系统的“大脑”。它的思想,大概可以类比成一个“剧场演出排期”问题:

  • 我们有一张“任务优先度表”。
  • 每个任务都打上了标签:时间紧不紧?算力要求大不大?能不能忍一忍?
  • 系统根据这些标签,像编导排节目一样,决定谁先上场。

数值层面上,可以理解为:
任务紧急度 × 延迟容忍度因子 ÷ 可用算力权重
结果越大,说明越适合优先调度到某个节点。

没错,这就是“数值江湖”的朴素真相:一切决策,最终都能归结为某种带权重的评分比较。


⚙️ JavaScript 实现示例

下面我写一个简化版的 动态调度器,用来表现“云边协同”的分配逻辑。

class Node {
  constructor(name, type, capacity, latency) {
    this.name = name;       // 节点名称
    this.type = type;       // cloud 或 edge
    this.capacity = capacity; // 可用算力 (GPU 单位)
    this.latency = latency; // 网络延迟,edge 小,cloud 大
    this.load = 0;          // 当前负载
  }

  canHandle(task) {
    return (this.capacity - this.load) >= task.requirement;
  }

  assign(task) {
    this.load += task.requirement;
    console.log(`任务 ${task.id} 调度到 ${this.type} 节点 ${this.name}`);
  }
}

class Scheduler {
  constructor(nodes) {
    this.nodes = nodes;
  }

  schedule(task) {
    // 根据延迟和可用算力算一个调度评分
    const candidates = this.nodes
      .filter(node => node.canHandle(task))
      .map(node => {
        let score = (task.priority / (node.latency + 1)) * (node.capacity - node.load);
        return { node, score };
      });

    if (candidates.length === 0) {
      console.log(`❌ 任务 ${task.id} 无法调度`);
      return;
    }

    // 选择最高分的节点
    const best = candidates.sort((a, b) => b.score - a.score)[0];
    best.node.assign(task);
  }
}

// 示例:创建云节点和边缘节点
const edge1 = new Node("Edge-Shanghai", "edge", 8, 10);
const edge2 = new Node("Edge-Beijing", "edge", 6, 8);
const cloud = new Node("Cloud-HQ", "cloud", 64, 60);

const scheduler = new Scheduler([edge1, edge2, cloud]);

// 模拟调度任务
const tasks = [
  { id: 1, requirement: 2, priority: 9 },  // 时间敏感任务
  { id: 2, requirement: 10, priority: 5 }, // 大计算任务
  { id: 3, requirement: 1, priority: 7 },  // 小任务
];

tasks.forEach(task => scheduler.schedule(task));

输出可能是:

任务 1 调度到 edge 节点 Edge-Beijing
任务 2 调度到 cloud 节点 Cloud-HQ
任务 3 调度到 edge 节点 Edge-Shanghai

🌟 从结果可以看出:
边缘节点负责处理小而急的任务,云端负责处理大而重的任务。


📚 总结:科学与诗意的结合

  • 从操作系统到分布式调度:本质都是“如何把有限的资源合理分配给无限的需求”。
  • 云边协同的魅力:让延迟和算力之间不再是“二选一”,而是“协奏曲”。
  • 动态调度的使命:既做系统的算力管家,又是舞台剧的导演。

最后,用点幽默的比喻收尾:
如果说 云端像大学教授,知识渊博但总是慢半拍;
那么 边缘就是机灵的实习生,虽然经验有限,却总能第一时间把事情干好。
动态调度就是聪明的助理,既能哄教授,又能调教实习生,让整支团队既高效,又闪耀。 ✨

🚀 Cesium-Kit:10 秒为你的 Cesium 项目添加动态光效标记

在三维地图可视化中,标记(Marker)是最常见、也是最能承载数据与交互的一种元素。
但是 Cesium 默认的点标记过于单调,无法很好地展示数据的 炫酷感交互性

为了快速解决这个痛点,我们在 cesium-kit 中提供了一个全新的动态标记 —— RippleMarker
只需 10 秒钟,你就能在地图上添加一个带光效波纹的 3D 标点,让你的场景立刻“动”起来 ✨。


📌 RippleMarker 简介

RippleMarker 是一个专为 Cesium 场景定制的动态 3D 波纹标记,核心特性包括:

  • 🔵 动态波纹动画:酷炫的扩散光圈效果
  • 🔺 3D 倒立三棱锥:立体标记更直观
  • 🏷️ 标签显示:支持文字标注与美化
  • 🖱️ 点击事件:轻松绑定交互逻辑
  • 📦 数据绑定:传递业务数据,点击时直接拿到结果
  • 🛠️ 批量管理:支持一键隐藏、显示或移除
  • 💡 性能优化:基于 Cesium.CallbackProperty 实现高效动画

效果预览:


⏱️ 快速上手:10 秒集成

安装仓库包:

npm i cesium-kit

仅需几行代码,即可添加一个动态光效 Marker:

import { Viewer } from "cesium";
import { RippleMarker } from "cesium-kit";

const viewer = new Viewer("container");

RippleMarker(viewer, {
  lon: 116.3913,
  lat: 39.9075,
  color: "rgba(0,150,255,0.8)",
  maxRadius: 8000,
  duration: 1500,
  pyramidHeight: 1000,
});

运行后,一个带扩散光圈、会浮动的三棱锥标记就会出现在北京的位置 🎉。


🎨 进阶玩法

带标签和交互事件

const marker = RippleMarker(viewer, {
  lon: 116.3913,
  lat: 39.9075,
  id: "beijing-marker",
  data: { name: "北京", population: 21540000 },
  label: {
    text: "北京市",
    font: "18px sans-serif",
    fillColor: "#ffffff",
  },
  onClick: (data, position) => {
    console.log("点击了:", data.name, "坐标:", position);
  },
});

批量创建和统一管理

const cities = [  { name: "北京", lon: 116.3913, lat: 39.9075 },  { name: "上海", lon: 121.4737, lat: 31.2304 },];

const markers = cities.map((city) =>
  RippleMarker(viewer, {
    lon: city.lon,
    lat: city.lat,
    label: { text: city.name, fillColor: "#00ff88" },
    onClick: (data) => alert(`欢迎来到${data.name}!`),
  })
);

// 一键隐藏
markers.forEach((m) => m.hide());

🛡️ 为什么选择 RippleMarker?

  • 快速集成:无需重复造轮子,直接开箱即用
  • 高性能动画:比自定义 Shader 或 Entity 设置更省心
  • 业务友好:事件绑定 + 数据挂载,贴合业务场景
  • 类型安全:完整的 TypeScript 类型,IDE 智能提示

如果你正在做 智慧城市地理大数据可视化三维展示平台
RippleMarker 是一个提升项目观感的绝佳工具。


📖 更多文档与源码

仓库地址:👉 github.com/leongaooo/c…

立即尝试,让你的 Cesium 项目“亮”起来 🚦。

Webpack 与 Vite 构建速度对比:冷启动、HMR、打包性能实测分析

一、前言

在现代前端开发中,构建工具直接影响开发体验和交付效率。

长期以来,Webpack 是前端构建的“标准答案”。但随着项目规模增长,其冷启动慢、HMR(热更新)延迟等问题日益突出。

近年来,Vite 凭借“基于原生 ES Module 的开发服务器”理念,以极速启动闪电般 HMR 震惊业界。

本文将通过真实项目实测,对比 Webpack 与 Vite 在以下维度的表现:

  • ✅ 开发服务器冷启动时间
  • ✅ 热更新(HMR)响应速度
  • ✅ 生产环境打包性能
  • ✅ 内存占用与资源消耗

数据说话,帮你选择最适合的构建工具。


二、测试环境与项目配置

1. 测试环境

  • CPU:Intel i7-11800H
  • 内存:32GB
  • 系统:macOS 14
  • Node.js:v18.17.0
  • 项目依赖:Vue 3 + Vue Router + Pinia + 50+ 组件

2. 项目规模

  • JavaScript/TS 文件:120+
  • 总代码行数:~15,000
  • 初始 bundle 大小:约 2.3MB(未压缩)

三、维度一:开发服务器冷启动速度

冷启动是指首次运行 npm run dev 后,服务器准备好并打开浏览器的时间。

测试方法:

  • 清空 Webpack 缓存(--no-cache
  • 分别启动 Webpack Dev Server 和 Vite
  • 记录从命令执行到控制台输出“Ready in Xms”的时间

📊 测试结果:

构建工具 冷启动时间 说明
Webpack 5 8.2s 需解析所有模块、构建依赖图
Vite 0.8s 基于浏览器 ES Module,仅启动服务器

Vite 冷启动快 10 倍以上,开发体验显著提升。


四、维度二:热更新(HMR)速度

HMR(Hot Module Replacement)是开发中最频繁的操作。

测试方法:

  • 修改一个 Vue 组件的模板文本
  • 记录从保存文件到浏览器更新完成的时间

📊 测试结果:

构建工具 HMR 响应时间 特点
Webpack 800ms - 1.2s 需重新打包 chunk,全量分析
Vite < 100ms 仅编译修改文件,通过 ESM 动态加载

✅ Vite 的 HMR 几乎“即时生效”,极大提升开发流畅度。


五、维度三:生产环境打包性能

生产打包关注的是构建时间输出质量

测试命令:

  • Webpack: webpack --mode production
  • Vite: vite build

📊 打包结果对比:

指标 Webpack 5 Vite (Rollup)
打包时间 18.5s 6.3s
JS 总体积 480KB (gzip) 465KB (gzip)
CSS 体积 120KB (gzip) 115KB (gzip)
代码分割 支持 支持(更精细)
Tree Shaking 良好 优秀(基于 Rollup)

✅ Vite 打包速度更快,输出体积更小。


六、维度四:内存与资源占用

使用 htop 监控开发模式下的内存占用:

构建工具 内存占用(峰值) CPU 占用
Webpack 1.2 GB 高(持续编译)
Vite 480 MB 低(按需编译)

✅ Vite 资源消耗更低,适合中低配开发机。


七、核心差异:为什么 Vite 更快?

特性 Webpack Vite
开发模式 编译整个 bundle 基于浏览器 ES Module,按需编译
HMR 实现 重建模块依赖 精准更新修改模块
预构建 使用 esbuild 预构建依赖(极快)
底层引擎 自研打包器 开发:原生 ESM;生产:Rollup

🔍 关键点:

  • Vite 不在开发时打包,而是让浏览器通过 <script type="module"> 加载模块。
  • 第三方依赖使用 esbuild(Go 编写)预构建,比 Webpack 快 10-100 倍。
  • HMR 仅更新修改文件,无需重建整个依赖图。

八、适用场景建议

场景 推荐工具 理由
新项目 ✅ Vite 快速启动、优秀 HMR、现代生态
大型旧项目(Webpack) ⚠️ 暂不迁移 迁移成本高,插件生态可能不兼容
需要高度定制化构建 ✅ Webpack 插件系统成熟,灵活性极高
SSR / SSG 项目 ✅ Vite(+ Nuxt/VuePress) 原生支持,性能优势明显
微前端(多个构建系统) 视情况 Vite 更适合独立子应用

九、如何选择?

你关心什么? 推荐
开发体验、启动速度 ➡️ Vite
生产打包速度与体积 ➡️ Vite
插件生态与定制能力 ➡️ Webpack
项目稳定性与长期维护 ➡️ 两者皆可(Vite 更现代)

💡 趋势:Vite 正在成为 Vue、React 新项目的默认选择(如 Vue CLI 已推荐 Vite)。


十、总结

维度 胜出者
冷启动 🏆 Vite
HMR 速度 🏆 Vite
打包性能 🏆 Vite
资源占用 🏆 Vite
插件生态 🏆 Webpack
定制能力 🏆 Webpack

Vite 代表了构建工具的未来方向——利用现代浏览器能力,摆脱“打包”的束缚。

对于新项目,强烈推荐使用 Vite;对于老项目,可逐步尝试迁移或共存。

Vue接入AI聊天助手实战

前言

在AI技术快速发展的今天,为我们的应用添加智能对话功能已经成为提升用户体验的重要手段。最近在开发一个在线教育平台时,我遇到了一个需求:为学员提供实时的AI学习助手,帮助他们解答问题、提供学习建议。

经过一番调研,我发现了一个非常优秀的Vue组件:ai-suspended-ball-chat。这个组件不仅功能丰富,而且集成简单,完美解决了我的需求。今天就来分享一下我的实战经验。

Snipaste_2025-09-09_21-40-54.png

为什么选择这个组件?

1. 功能全面,开箱即用

这个组件提供了丰富的功能特性:

  • 🤖 AI对话:支持与AI进行自然语言对话
  • 📡 双模式请求:支持普通请求和流式响应两种模式
  • 🖼️ 图片上传:支持图片上传和AI图像识别
  • 🎤 语音输入:支持语音转文字输入
  • 🔊 语音播报:支持AI回复内容的语音播报
  • 💾 历史记录:本地存储对话历史
  • 🎨 主题切换:支持白天/夜间模式切换

2. 设计优雅,用户体验佳

组件采用了悬浮球的设计,不会干扰用户的主要操作流程,同时提供了完整的聊天面板。界面设计现代简洁,支持自定义主题。

3. 技术先进,性能优秀

  • 支持Vue 3 Composition API
  • 完整的TypeScript类型定义
  • 支持流式响应,实时显示AI回复
  • 响应式设计,适配各种屏幕尺寸

快速开始

安装组件

npm install ai-suspended-ball-chat
# 或
yarn add ai-suspended-ball-chat
# 或
pnpm add ai-suspended-ball-chat

基础使用

<template>
  <div id="app">
    <SuspendedBallChat
      :url="apiUrl"
      :app-name="appName"
      :domain-name="domainName"
      :enable-streaming="true"
      :enable-context="true"
      :enable-local-storage="true"
      :enable-voice-input="true"
      :callbacks="callbacks"
    />
  </div>
</template>

<script>
import { SuspendedBallChat } from 'ai-suspended-ball-chat'

export default {
  name: 'App',
  components: {
    SuspendedBallChat
  },
  data() {
    return {
      apiUrl: 'https://your-api-endpoint.com/chat',
      appName: 'my-app',
      domainName: 'user123',
      callbacks: {
        onUserMessage: (message) => {
          console.log('用户发送消息:', message)
        },
        onAssistantMessage: (message) => {
          console.log('AI回复:', message)
        },
        onError: (error) => {
          console.error('发生错误:', error)
        }
      }
    }
  }
}
</script>

就这么简单!一个功能完整的AI聊天助手就集成到你的应用中了。

实际业务场景应用

场景一:在线教育平台

在我的在线教育平台项目中,我这样配置了AI助手:

<template>
  <div class="education-platform">
    <!-- 其他页面内容 -->
    <SuspendedBallChat
      :url="'https://luckycola.com.cn/aiTools/openAiAssistant'"
      :app-name="'education-platform'"
      :domain-name="currentUser.id"
      :enable-streaming="true"
      :enable-context="true"
      :enable-local-storage="true"
      :enable-voice-input="true"
      :enable-image-upload="true"
      :assistant-config="assistantConfig"
      :preset-tasks="presetTasks"
      :callbacks="callbacks"
      :custom-request-config="customRequestConfig"
    />
  </div>
</template>

<script>
export default {
  data() {
    return {
      currentUser: {
        id: 'student_001'
      },
      assistantConfig: {
        avatar: '/images/ai-teacher-avatar.png',
        name: '学习助手小智',
        description: '您的专属学习助手,随时为您答疑解惑'
      },
      presetTasks: [
        {
          icon: '📚',
          title: '课程答疑',
          description: '解答课程相关问题'
        },
        {
          icon: '💡',
          title: '学习建议',
          description: '获取个性化学习建议'
        },
        {
          icon: '📝',
          title: '作业辅导',
          description: '协助完成课后作业'
        }
      ],
      customRequestConfig: {
        customParams: {
          systemPrompt: '你是一位经验丰富的在线教育导师,擅长解答各种学科问题,能够根据学生的水平提供个性化的学习建议。请用温和、耐心的语气与学生交流。',
          appKey: 'your-app-key-here'
        }
      },
      callbacks: {
        onUserMessage: (message) => {
          // 记录用户问题,用于后续分析
          this.analytics.track('ai_chat_user_question', {
            userId: this.currentUser.id,
            question: message
          })
        },
        onAssistantMessage: (message) => {
          // 记录AI回复,用于效果评估
          this.analytics.track('ai_chat_assistant_reply', {
            userId: this.currentUser.id,
            reply: message
          })
        }
      }
    }
  }
}
</script>

效果:

  • 学员可以随时点击悬浮球获得学习帮助
  • AI助手能够记住对话上下文,提供连贯的辅导
  • 支持语音输入,方便学员快速提问
  • 预设任务让学员快速找到需要的帮助类型

场景二:企业客服系统

<template>
  <div class="customer-service">
    <SuspendedBallChat
      :url="customerServiceApiUrl"
      :app-name="'customer-service'"
      :domain-name="customerInfo.id"
      :enable-streaming="true"
      :enable-context="true"
      :enable-local-storage="false"
      :enable-voice-input="true"
      :enable-image-upload="true"
      :assistant-config="{
        avatar: '/images/customer-service-avatar.png',
        name: '智能客服小助手',
        description: '7x24小时为您服务'
      }"
      :preset-tasks="[
        {
          icon: '🛒',
          title: '订单查询',
          description: '查询订单状态和物流信息'
        },
        {
          icon: '💳',
          title: '支付问题',
          description: '解决支付相关问题'
        },
        {
          icon: '🔄',
          title: '退换货',
          description: '处理退换货申请'
        }
      ]"
      :callbacks="serviceCallbacks"
    />
  </div>
</template>

场景三:代码编辑器集成

<template>
  <div class="code-editor">
    <textarea 
      v-model="code" 
      placeholder="在这里编写代码..."
      ref="codeEditor"
    ></textarea>
    
    <SuspendedBallChat
      :url="codeAssistantApiUrl"
      :app-name="'code-assistant'"
      :domain-name="developer.id"
      :enable-streaming="true"
      :enable-context="true"
      :enable-local-storage="true"
      :assistant-config="{
        avatar: '/images/code-assistant-avatar.png',
        name: '代码助手',
        description: '您的编程伙伴,随时提供代码建议'
      }"
      :callbacks="{
        clickAssistantMsgCallback: (message, index, messageObj) => {
          // 将AI建议的代码插入到编辑器中
          this.insertCodeToEditor(message)
        }
      }"
    />
  </div>
</template>

<script>
export default {
  methods: {
    insertCodeToEditor(code) {
      // 将AI建议的代码插入到光标位置
      const editor = this.$refs.codeEditor
      const start = editor.selectionStart
      const end = editor.selectionEnd
      const text = editor.value
      
      this.code = text.substring(0, start) + code + text.substring(end)
      
      // 设置光标位置
      this.$nextTick(() => {
        editor.focus()
        editor.setSelectionRange(start + code.length, start + code.length)
      })
    }
  }
}
</script>

高级配置技巧

1. 自定义主题

:root {
  /* 自定义主色调 */
  --ai-chat-primary-color: #007bff;
  --ai-chat-secondary-color: #6c757d;
  
  /* 自定义背景色 */
  --ai-chat-bg-color: #ffffff;
  --ai-chat-panel-bg: #f8f9fa;
  
  /* 自定义文字颜色 */
  --ai-chat-text-color: #262626;
  --ai-chat-text-muted: #595959;
}

/* 自定义悬浮球样式 */
.chat-bubble {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
  box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4) !important;
}

2. 动态配置

<script>
export default {
  data() {
    return {
      chatConfig: {
        enableStreaming: true,
        enableVoiceInput: true,
        enableImageUpload: false
      }
    }
  },
  computed: {
    dynamicConfig() {
      return {
        ...this.chatConfig,
        // 根据用户权限动态调整功能
        enableImageUpload: this.userPermissions.includes('upload_image'),
        enableVoiceInput: this.userPermissions.includes('voice_input')
      }
    }
  }
}
</script>

3. 错误处理和重试机制

const callbacks = {
  onError: (error) => {
    console.error('AI助手错误:', error)
    
    // 根据错误类型进行不同处理
    if (error.code === 3) {
      // 服务限流,显示提示并延迟重试
      this.showToast('服务繁忙,请稍后重试')
      setTimeout(() => {
        this.retryLastMessage()
      }, 5000)
    } else if (error.code === 2) {
      // 认证失败,重新登录
      this.handleAuthError()
    }
  },
  
  onStreamStart: () => {
    this.isLoading = true
  },
  
  onStreamEnd: (message) => {
    this.isLoading = false
    // 流式响应结束后的处理
    this.analytics.track('ai_response_complete', {
      messageLength: message.length,
      responseTime: Date.now() - this.requestStartTime
    })
  }
}

性能优化建议

1. 按需加载

// 动态导入组件,减少初始包体积
const loadChatComponent = async () => {
  const { SuspendedBallChat } = await import('ai-suspended-ball-chat')
  return SuspendedBallChat
}

// 在需要时才加载
export default {
  data() {
    return {
      showChat: false,
      ChatComponent: null
    }
  },
  methods: {
    async openChat() {
      if (!this.ChatComponent) {
        this.ChatComponent = await loadChatComponent()
      }
      this.showChat = true
    }
  }
}

2. 条件渲染

<template>
  <div>
    <button @click="showChat = true">打开AI助手</button>
    <SuspendedBallChat
      v-if="showChat"
      :url="apiUrl"
      :app-name="appName"
      :domain-name="domainName"
    />
  </div>
</template>

常见问题解决

1. 样式不生效

从v0.1.33版本开始,样式已经内联到JS中,不需要单独导入CSS文件:

import { SuspendedBallChat } from 'ai-suspended-ball-chat'
// 不需要再导入 CSS 文件

2. 语音输入不工作

确保以下几点:

  • 浏览器支持Web Speech API
  • 已授权麦克风权限
  • 网站使用HTTPS协议(本地开发可使用localhost)

3. 流式响应中断

const callbacks = {
  onStreamProgress: (partialMessage) => {
    // 实时更新UI
    this.updateStreamingMessage(partialMessage)
  },
  
  onStreamEnd: (fullMessage) => {
    // 流式响应结束,保存完整消息
    this.saveCompleteMessage(fullMessage)
  }
}

总结

ai-suspended-ball-chat 是一个功能强大、易于集成的Vue AI聊天组件。通过简单的配置,就能为你的应用添加智能对话功能。无论是教育平台、客服系统还是代码编辑器,都能找到合适的应用场景。

主要优势:

  • 🚀 快速集成:几行代码即可完成集成
  • 🎨 高度可定制:支持主题、图标、功能的自定义
  • 📱 响应式设计:完美适配各种设备
  • 🔧 丰富的API:提供完整的回调函数和配置选项
  • 📦 TypeScript支持:完整的类型定义,开发体验佳

如果你正在寻找一个优秀的AI聊天组件,我强烈推荐试试这个组件。它不仅功能全面,而且文档详细,社区活跃,是一个值得信赖的选择。


相关链接:

本文基于实际项目经验编写,如有问题欢迎交流讨论。

下一版本 MCP 协议将于2025年11月25日发布

本文来自公众号 猩猩程序员 欢迎关注

关于下一版本 Model Context Protocol 规范的时间表和优先级更新
2025年9月26日 · 5分钟阅读 · David Soria Parra

发布时间表

下一版本的 Model Context Protocol 规范将于2025年11月25日发布,2025年11月11日将提供发布候选版本(RC)。

我们为发布候选版本安排了14天的验证窗口,以便客户端实现者和SDK维护者能够彻底测试协议更改。此方法为我们提供了专注的时间来交付关键的改进,同时将我们的新治理模型应用于整个过程。

夏季进展

我们的上一个版本规范于2025年6月18日发布,重点关注了结构化工具输出、基于OAuth的授权、服务器发起的用户交互引导以及改进的安全最佳实践。

自那时以来,我们一直致力于为MCP生态系统建立更多基础:

正式的治理结构

我们为MCP建立了一个正式的治理模型,包括定义的角色和决策机制。我们还开发了“规范增强提案”(SEP)流程,为贡献规范变更提供了明确的指导。

我们的目标是实现透明性——使决策过程对每个人都清晰可见。就像任何服务于快速发展的社区的新系统一样,我们的治理模型仍在逐步完善。随着协议和社区的不断发展,我们正在积极改进它。

工作小组

我们已经启动了工作小组和兴趣小组,以促进社区合作。这些小组有多重目的:

  • 为新贡献者提供明确的入门途径
  • 赋予社区成员在各自专业领域领导项目的能力
  • 在整个生态系统中分散所有权,而不是集中在核心维护者手中

我们正在开发治理结构,将赋予这些小组更大的自治权,能够在决策和实施过程中发挥更大作用。这种分布式的方法确保协议能够在保持不同领域质量和一致性的同时,满足社区需求并实现增长。

注册表开发

在2025年9月,我们推出了MCP注册表的预览版——一个开放的目录和API,用于索引和发现MCP服务器。该注册表作为MCP服务器的唯一真实来源,支持公共和私有子注册表,组织可以根据自己的需求进行定制。

构建MCP注册表是一个真正的社区努力。任何MCP客户端都可以通过本地API或第三方注册表聚合器访问注册表内容,使用户更容易发现并将MCP服务器集成到他们的AI工作流中。

下一版发布的优先领域

在治理和基础设施基础到位之后,我们将重点关注工作小组确定的五项关键协议改进。

异步操作

目前,MCP主要围绕同步操作构建——当你调用一个工具时,系统会暂停并等待其完成。对于快速任务来说这种方式非常有效,但对于需要几分钟或几小时的操作怎么办?

代理工作小组正在添加异步支持,允许服务器启动长时间运行的任务,而客户端可以稍后查询结果。你可以在SEP-1391中关注进展。

无状态性和可扩展性

随着组织在企业级规模上部署MCP服务器,我们看到了一些新的需求。当前的实现通常需要在请求之间记住一些状态,这使得跨多个服务器实例的水平扩展变得困难。

虽然流式HTTP提供了一些无状态支持,但在服务器启动和会话处理上仍然存在痛点。传输工作小组正在解决这些难点,使得在生产环境中运行MCP服务器变得更加容易,同时为希望获得更复杂状态特性的团队保留简单的升级路径。

服务器身份

目前,如果你想知道一个MCP服务器能做什么,你必须先连接到它。这使得客户端很难浏览可用的服务器,或者像我们的注册表这样的系统也无法自动 catalog 能力。

我们通过让服务器通过 .well-known URL 广告自己来解决这个问题——这是一种提供元数据的已建立标准。你可以把它看作是服务器的名片,任何人都可以阅读,而不需要先进行连接。这将使得每个MCP消费者的发现过程变得更加直观。

官方扩展

随着MCP的增长,我们注意到某些行业和用例出现了模式——这些有价值的实现不一定属于核心协议规范的范围。

为了避免让每个人都重新发明轮子,我们将正式认可和记录最受欢迎的协议扩展。这些经过筛选的证明有效的模式将为开发者在医疗、金融或教育等专业领域构建应用时提供一个坚实的起点,而不需要从零开始构建每一个定制集成。

SDK支持标准化

今天选择MCP SDK可能会很有挑战性——你很难判断你将获得什么样的支持或规范合规性。一些SDK快速更新,而其他SDK可能在功能上滞后。

我们将引入一个清晰的SDK分层系统。在你决定使用某个SDK之前,你会清楚地知道你签署了什么协议,基于规范合规性速度、维护响应性和功能完整性等因素。

寻找贡献者

MCP的力量来源于其背后的社区。无论你是一个热衷于构建SDK的开发者,还是一家希望投资生态系统的公司,我们都需要你在以下几个关键领域提供帮助。

SDK维护

  • TypeScript SDK - 需要更多的维护者来开发新功能和修复bug
  • Swift SDK - 需要关注Apple生态系统的支持
  • 其他语言的SDK欢迎持续贡献

工具

  • Inspector - 开发和维护MCP服务器开发者的调试工具
  • Registry - 后端API和CLI开发;Go语言专家尤其欢迎

来自客户端开发者的意见

我们谈论MCP服务器很多,但客户端同样重要——它们是连接用户与整个MCP生态系统的桥梁。如果你正在构建MCP客户端,你将从独特的角度来看待协议,而我们需要这种视角融入协议设计中。

你在实施中的真实体验、性能瓶颈和用户需求,直接决定了协议接下来应该如何发展。无论是对现有功能的反馈,还是简化开发者体验的想法,我们都希望听到你的声音。

加入我们的MCP Discord中的#client-implementors工作小组频道。

展望未来

随着治理结构和工作小组的到位,我们现在能更高效地处理重要的协议改进,同时确保每个人在过程中都有发言权。今年夏天我们所做的基础性工作为我们的未来奠定了坚实的基础。

11月即将发布的改进——异步操作、改进的可扩展性、服务器发现和标准化扩展——将帮助MCP成为生产级AI集成的更强大支撑。但我们无法单打独斗。

MCP的优势一直在于它是一个由社区为社区构建的开放协议。我们很高兴能继续共同建设它。

感谢你们的持续支持,我们期待着不久后与大家分享更多内容。

本文来自公众号 猩猩程序员 欢迎关注

Node.js 全局变量完整总结

Node.js 全局变量完整总结

1. 真正的全局变量(在任何模块都可访问)

__dirname

  • 含义:当前模块的目录名
  • 示例/Users/project/src
console.log(__dirname); // 输出当前文件所在目录的绝对路径

__filename

  • 含义:当前模块的文件名(绝对路径)
  • 示例/Users/project/src/app.js
console.log(__filename); // 输出当前文件的绝对路径

exports

  • 含义:模块导出对象的引用
  • 注意:在模块中 exports 是 module.exports 的简写
exports.hello = function() { return 'Hello'; };

module

  • 含义:当前模块的引用
  • 用途:定义模块导出
module.exports = MyClass;

require()

  • 含义:用于导入模块的函数
  • 用法require(id)
const fs = require('fs');
const myModule = require('./my-module');

process

  • 含义:当前 Node.js 进程的信息和控制
  • 重要属性/方法
// 环境变量
console.log(process.env);

// 命令行参数
console.log(process.argv);

// 当前工作目录
console.log(process.cwd());

// 平台信息
console.log(process.platform);

// 内存使用情况
console.log(process.memoryUsage());

Buffer

  • 含义:用于处理二进制数据的类
  • 示例
const buf = Buffer.from('hello', 'utf8');
console.log(buf.toString()); // hello

console

  • 含义:用于标准输出的控制台对象
  • 方法
console.log('信息');
console.error('错误');
console.warn('警告');
console.table({a: 1, b: 2});

setTimeout / setInterval / setImmediate

  • 含义:定时器函数
setTimeout(() => console.log('延时执行'), 1000);
setInterval(() => console.log('间隔执行'), 2000);
setImmediate(() => console.log('立即执行'));

clearTimeout / clearInterval / clearImmediate

  • 含义:清除定时器
const timer = setTimeout(() => {}, 1000);
clearTimeout(timer);

2. 全局对象下的属性(global.xxx

global

  • 含义:全局命名空间对象
  • 注意:在 Node.js 中,顶层作用域不是全局作用域
// 在浏览器中:var x = 1; // 全局变量
// 在 Node.js 中:var x = 1; // 只是当前模块的变量
global.x = 1; // 这才是真正的全局变量

global.process

  • 含义:同 process

global.console

  • 含义:同 console

global.Buffer

  • 含义:同 Buffer

global.setTimeout 等定时器

  • 含义:同相应的定时器函数

3. ES 模块相关的全局变量

TextEncoder / TextDecoder

  • 含义:用于 UTF-8 编码转换
const encoder = new TextEncoder();
const decoder = new TextDecoder();

URL / URLSearchParams

  • 含义:用于 URL 处理
const url = new URL('https://example.com/path');
const params = new URLSearchParams('a=1&b=2');

fetch (Node.js 18+)

  • 含义:用于 HTTP 请求
const response = await fetch('https://api.example.com');
const data = await response.json();

4. 常用但非真正全局的变量

module.exports 与 exports 的关系

// 以下两种方式等价:
module.exports = { a: 1 };

// 或者
exports.a = 1;

// 但这样是错误的:
exports = { a: 1 }; // 不会生效

5. 环境相关的全局变量

process.env

  • 含义:包含用户环境的对象
  • 常用:用于配置管理
// 读取环境变量
const dbUrl = process.env.DATABASE_URL;
const port = process.env.PORT || 3000;

// 设置环境变量(仅在当前进程有效)
process.env.NODE_ENV = 'production';

6. 实用示例

webpack.config.js 中的典型使用

const path = require('path');

module.exports = {
  entry: path.resolve(__dirname, 'src/index.js'),
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.[hash].js'
  },
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV)
    })
  ]
};

进程信息监控

// 监控内存使用
setInterval(() => {
  const usage = process.memoryUsage();
  console.log(`内存使用: ${Math.round(usage.heapUsed / 1024 / 1024)}MB`);
}, 5000);

// 处理进程退出
process.on('exit', (code) => {
  console.log(`进程退出,代码: ${code}`);
});

// 处理未捕获异常
process.on('uncaughtException', (err) => {
  console.error('未捕获异常:', err);
  process.exit(1);
});

7. 注意事项

  1. ES 模块中的 __dirname
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
  1. 全局变量污染:避免在 global 对象上添加属性
  2. 环境差异:某些全局变量在浏览器中不存在(如 __dirname

这个总结涵盖了 Node.js 中所有重要的全局变量,在实际开发中会经常用到这些变量。

两种方法:枚举 / 凸包+旋转卡壳(Python/Java/C++/Go)

三角形面积公式

对于平面上的三个点 $P_1,P_2,P_3$,定义 $\mathbf{a} = \overrightarrow{P_1P_2}$,$\mathbf{b} = \overrightarrow{P_1P_3}$。

根据向量叉积的定义,$|| \mathbf{a} \times \mathbf{b} ||$ 是由 $\mathbf{a}$ 和 $\mathbf{b}$ 张成的平行四边形的面积。除以 $2$ 就得到了 $\triangle P_1P_2P_3$ 的面积。

严格来说,叉积是三维概念。这里把向量 $\mathbf{a}$ 和 $\mathbf{b}$ 视作 $z$ 方向为 $0$ 的三维向量。

设 $\mathbf{a} = (x_1,y_1)$,$\mathbf{b} = (x_2,y_2)$,根据叉积的计算公式,三角形面积为

$$
\dfrac{1}{2}|x_1y_2 - y_1x_2|
$$

上式中的 $(x_1,y_1)$ 来自 $P_1,P_2$ 的横坐标之差,纵坐标之差。$(x_2,y_2)$ 来自 $P_1,P_3$ 的横坐标之差,纵坐标之差。

方法一:暴力枚举

class Solution:
    def largestTriangleArea(self, points: List[List[int]]) -> float:
        ans = 0
        for p1, p2, p3 in combinations(points, 3):
            x1, y1 = p2[0] - p1[0], p2[1] - p1[1]
            x2, y2 = p3[0] - p1[0], p3[1] - p1[1]
            ans = max(ans, abs(x1 * y2 - y1 * x2))  # 注意这里没有除以 2
        return ans / 2
class Solution {
    public double largestTriangleArea(int[][] points) {
        int n = points.length;
        int ans = 0;
        for (int i = 0; i < n - 2; i++) {
            for (int j = i + 1; j < n - 1; j++) {
                for (int k = j + 1; k < n; k++) {
                    int[] p1 = points[i], p2 = points[j], p3 = points[k];
                    int x1 = p2[0] - p1[0], y1 = p2[1] - p1[1];
                    int x2 = p3[0] - p1[0], y2 = p3[1] - p1[1];
                    ans = Math.max(ans, Math.abs(x1 * y2 - y1 * x2)); // 注意这里没有除以 2
                }
            }
        }
        return ans / 2.0;
    }
}
class Solution {
public:
    double largestTriangleArea(vector<vector<int>>& points) {
        int n = points.size();
        int ans = 0;
        for (int i = 0; i < n - 2; i++) {
            auto& p1 = points[i];
            for (int j = i + 1; j < n - 1; j++) {
                auto& p2 = points[j];
                for (int k = j + 1; k < n; k++) {
                    auto& p3 = points[k];
                    int x1 = p2[0] - p1[0], y1 = p2[1] - p1[1];
                    int x2 = p3[0] - p1[0], y2 = p3[1] - p1[1];
                    ans = max(ans, abs(x1 * y2 - y1 * x2)); // 注意这里没有除以 2
                }
            }
        }
        return ans / 2.0;
    }
};
func largestTriangleArea(points [][]int) float64 {
n := len(points)
ans := 0
for i := range n - 2 {
for j := i + 1; j < n-1; j++ {
for k := j + 1; k < n; k++ {
p1, p2, p3 := points[i], points[j], points[k]
x1, y1 := p2[0]-p1[0], p2[1]-p1[1]
x2, y2 := p3[0]-p1[0], p3[1]-p1[1]
ans = max(ans, abs(x1*y2-y1*x2)) // 注意这里没有除以 2
}
}
}
return float64(ans) / 2
}

func abs(x int) int { if x < 0 { return -x }; return x }

复杂度分析

  • 时间复杂度:$\mathcal{O}(n^3)$,其中 $n$ 是 $\textit{points}$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。

方法二:凸包 + 旋转卡壳

前置题目587. 安装栅栏

若固定三角形的两个顶点,那么第三个顶点在哪?

三角形的高越长越好,所以第三个顶点相比在凸包内部,在凸包上更好(更远)。所以面积最大的三角形,三个顶点都在凸包上。

求出凸包后:

  1. 枚举凸包的顶点 $i$ 作为三角形的其中一个顶点。对于另外两个顶点,我们用旋转卡壳(同向双指针)计算。
  2. 初始化 $j=i+1$,$k=i+2$。
  3. 对于 $\triangle P_iP_jP_k$ 和 $\triangle P_iP_jP_{k+1}$ 的面积,如果后者更大,那么把 $k$ 加一。重复该过程,直到 $k+1$ 越界或者面积没有变大,跳出循环。
  4. 跳出循环后,$P_k$ 就移动到了一个在 $\overrightarrow{P_iP_j}$ 左侧且距离 $P_iP_j$ 最远的位置。计算 $\triangle P_iP_jP_k$ 的面积,更新答案的最大值。然后把 $j$ 加一,执行第三步。读者可以在纸上画画,随着 $j$ 的增大,在 $\overrightarrow{P_iP_j}$ 左侧且距离 $P_iP_j$ 最远的 $P_k$ 的下标 $k$ 也在增大,所以可以用同向双指针。

下面代码保证计算 $\mathbf{a} \times \mathbf{b}$ 时,$\mathbf{b}$ 在 $\mathbf{a}$ 的左侧,此时算出来的面积一定大于 $0$,无需取绝对值。

def det(x1: int, y1: int, x2: int, y2: int) -> int:
    return x1 * y2 - y1 * x2

def convex_hull(points: List[List[int]]) -> List[List[int]]:
    points.sort()

    # 计算下凸包(从左到右)
    q = []
    for p in points:
        while len(q) > 1 and det(q[-1][0] - q[-2][0], q[-1][1] - q[-2][1], p[0] - q[-1][0], p[1] - q[-1][1]) <= 0:
            q.pop()
        q.append(p)

    # 计算上凸包(从右到左)
    down_size = len(q)
    # 注意下凸包的最后一个点,是上凸包的右边第一个点,所以从 n-2 开始遍历
    for i in range(len(points) - 2, -1, -1):
        p = points[i]
        while len(q) > down_size and det(q[-1][0] - q[-2][0], q[-1][1] - q[-2][1], p[0] - q[-1][0], p[1] - q[-1][1]) <= 0:
            q.pop()
        q.append(p)

    # 此时首尾是同一个点 points[0],需要去掉
    q.pop()
    return q

class Solution:
    def largestTriangleArea(self, points: List[List[int]]) -> float:
        ch = convex_hull(points)

        def area(i: int, j: int, k: int) -> int:
            return det(ch[j][0] - ch[i][0], ch[j][1] - ch[i][1], ch[k][0] - ch[i][0], ch[k][1] - ch[i][1])

        m = len(ch)
        ans = 0
        # 固定三角形的其中一个顶点 ch[i]
        for i in range(m):
            # 同向双指针
            k = i + 2
            for j in range(i + 1, m - 1):
                while k + 1 < m and area(i, j, k) < area(i, j, k + 1):
                    k += 1
                # 循环结束后,ch[k] 距离 ch[i]ch[j] 最远
                ans = max(ans, area(i, j, k))  # 注意这里没有除以 2
        return ans / 2
class Solution {
    public double largestTriangleArea(int[][] points) {
        List<int[]> ch = convexHull(points);
        int m = ch.size();
        int ans = 0;
        // 固定三角形的其中一个顶点 ch[i]
        for (int i = 0; i < m; i++) {
            // 同向双指针
            int k = i + 2;
            for (int j = i + 1; j < m - 1; j++) {
                while (k + 1 < m && area(ch, i, j, k) < area(ch, i, j, k + 1)) {
                    k++;
                }
                // 循环结束后,ch[k] 距离 ch[i]ch[j] 最远
                ans = Math.max(ans, area(ch, i, j, k)); // 注意这里没有除以 2
            }
        }
        return ans / 2.0;
    }

    private List<int[]> convexHull(int[][] points) {
        Arrays.sort(points, (a, b) -> a[0] != b[0] ? a[0] - b[0] : a[1] - b[1]);

        // 计算下凸包(从左到右)
        List<int[]> q = new ArrayList<>();
        for (int[] p : points) {
            while (q.size() > 1) {
                int[] p1 = q.get(q.size() - 2);
                int[] p2 = q.getLast();
                if (det(p2[0] - p1[0], p2[1] - p1[1], p[0] - p2[0], p[1] - p2[1]) > 0) {
                    break;
                }
                q.removeLast();
            }
            q.add(p);
        }

        // 计算上凸包(从右到左)
        int downSize = q.size();
        // 注意下凸包的最后一个点,是上凸包的右边第一个点,所以从 n-2 开始遍历
        for (int i = points.length - 2; i >= 0; i--) {
            int[] p = points[i];
            while (q.size() > downSize) {
                int[] p1 = q.get(q.size() - 2);
                int[] p2 = q.getLast();
                if (det(p2[0] - p1[0], p2[1] - p1[1], p[0] - p2[0], p[1] - p2[1]) > 0) {
                    break;
                }
                q.removeLast();
            }
            q.add(p);
        }

        // 此时首尾是同一个点 points[0],需要去掉
        q.removeLast();
        return q;
    }

    private int det(int x1, int y1, int x2, int y2) {
        return x1 * y2 - y1 * x2;
    }

    private int area(List<int[]> ch, int i, int j, int k) {
        return det(ch.get(j)[0] - ch.get(i)[0], ch.get(j)[1] - ch.get(i)[1],
                ch.get(k)[0] - ch.get(i)[0], ch.get(k)[1] - ch.get(i)[1]);
    }
}
struct Vec {
    int x, y;

    Vec sub(const Vec& b) const {
        return {x - b.x, y - b.y};
    }

    int det(const Vec& b) const {
        return x * b.y - y * b.x;
    }
};

class Solution {
    vector<Vec> convexHull(vector<Vec>& points) {
        ranges::sort(points, {}, [](auto& p) { return pair(p.x, p.y); });

        vector<Vec> q;
        // 计算下凸包(从左到右)
        for (auto& p : points) {
            while (q.size() > 1 && q[q.size() - 1].sub(q[q.size() - 2]).det(p.sub(q[q.size() - 1])) <= 0) {
                q.pop_back();
            }
            q.push_back(p);
        }

        // 计算上凸包(从右到左)
        int down_size = q.size();
        // 注意下凸包的最后一个点,是上凸包的右边第一个点,所以从 n-2 开始遍历
        for (int i = (int) points.size() - 2; i >= 0; i--) {
            auto& p = points[i];
            while (q.size() > down_size && q[q.size() - 1].sub(q[q.size() - 2]).det(p.sub(q[q.size() - 1])) <= 0) {
                q.pop_back();
            }
            q.push_back(p);
        }

        // 此时首尾是同一个点 points[0],需要去掉
        q.pop_back();
        return q;
    }

public:
    double largestTriangleArea(vector<vector<int>>& points) {
        vector<Vec> a(points.size());
        for (int i = 0; i < points.size(); i++) {
            a[i] = {points[i][0], points[i][1]};
        }

        vector<Vec> ch = convexHull(a);

        auto area = [&](int i, int j, int k) -> int {
            return ch[j].sub(ch[i]).det(ch[k].sub(ch[i]));
        };

        int m = ch.size();
        int ans = 0;
        // 固定三角形的其中一个顶点 ch[i]
        for (int i = 0; i < m; i++) {
            // 同向双指针
            int k = i + 2;
            for (int j = i + 1; j < m - 1; j++) {
                while (k + 1 < m && area(i, j, k) < area(i, j, k + 1)) {
                    k++;
                }
                // 循环结束后,ch[k] 距离 ch[i]ch[j] 最远
                ans = max(ans, area(i, j, k)); // 注意这里没有除以 2
            }
        }
        return ans / 2.0;
    }
};
type vec struct{ x, y int }

func (a vec) sub(b vec) vec { return vec{a.x - b.x, a.y - b.y} }
func (a vec) det(b vec) int { return a.x*b.y - a.y*b.x }

func convexHull(points []vec) (q []vec) {
slices.SortFunc(points, func(a, b vec) int { return cmp.Or(a.x-b.x, a.y-b.y) })

// 计算下凸包(从左到右)
for _, p := range points {
for len(q) > 1 && q[len(q)-1].sub(q[len(q)-2]).det(p.sub(q[len(q)-1])) <= 0 {
q = q[:len(q)-1]
}
q = append(q, p)
}

// 计算上凸包(从右到左)
downSize := len(q)
// 注意下凸包的最后一个点,是上凸包的右边第一个点,所以从 n-2 开始遍历
for i := len(points) - 2; i >= 0; i-- {
p := points[i]
for len(q) > downSize && q[len(q)-1].sub(q[len(q)-2]).det(p.sub(q[len(q)-1])) <= 0 {
q = q[:len(q)-1]
}
q = append(q, p)
}

// 此时首尾是同一个点 points[0],需要去掉
q = q[:len(q)-1]
return
}

func largestTriangleArea(points [][]int) float64 {
a := make([]vec, len(points))
for i, p := range points {
a[i] = vec{p[0], p[1]}
}

ch := convexHull(a)
area := func(i, j, k int) int {
return ch[j].sub(ch[i]).det(ch[k].sub(ch[i]))
}

m := len(ch)
ans := 0
// 固定三角形的其中一个顶点 ch[i]
for i := range ch {
// 同向双指针
k := i + 2
for j := i + 1; j < m-1; j++ {
for k+1 < m && area(i, j, k) < area(i, j, k+1) {
k++
}
// 循环结束后,ch[k] 距离 ch[i]ch[j] 最远
ans = max(ans, area(i, j, k)) // 注意这里没有除以 2
}
}
return float64(ans) / 2
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n^2)$,其中 $n$ 是 $\textit{points}$ 的长度。枚举 $i$ 是 $\mathcal{O}(n)$,枚举 $j$ 和 $k$ 的同向双指针也是 $\mathcal{O}(n)$。
  • 空间复杂度:$\mathcal{O}(n)$。

:本题有 $\mathcal{O}(n)$ 做法,见论文 Maximal Area Triangles in a Convex Polygon

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

使用Tabs选项卡组件快速搭建鸿蒙APP框架

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

ArkUI提供了很多布局组件,其中Tabs选项卡组件可以用于快速搭建鸿蒙APP框架,本文通过案例研究Tabs构建鸿蒙原生应用框架的方法和步骤。

一、效果展示

1、效果展示

1.png

整个APP外层Tabs包含4个选项卡:首页、发现、消息、我的。在首页中,上滑列表会出现吸顶效果,分类可以左右滑动,当滑到最后一个分类时,与外层Tabs联动,滑到“发现”页面。首页中的分类标签可以用户自定义选择显示。

2、技术分析

主要使用Tabs选项卡搭建整个APP的框架,通过设置Tabs相关的属性和方法实现布局、滚动、吸顶、内外层嵌套联动等功能。

Tabs组件的页面组成包含两个部分,分别是TabContent和TabBar。TabContent是内容页,TabBar是导航页签栏,,根据不同的导航类型,布局会有区别,可以分为底部导航、顶部导航、侧边导航,其导航栏分别位于底部、顶部和侧边。

本例中通过嵌套Tabs实现,外层Tabs为底部导航、内层Tabs为顶部导航。

二、功能实现

1、准备工作

1.1 数据准备

在商业项目中,界面显示的数据是通过网络请求后端接口获得,本例重点放在Tabs组件的用法研究上,因此简化数据获取过程,直接将数据写入到json文件中。

将准备好的界面数据文件(tab标签和数据列表)拷贝到resources/rawfile目录下包含4个文件:default_all_tabs.json、default_all_tabs_en.json、default_content_items.json、default_content_items_en.json。

1.2 本地化

将界面文字

zh_CN/element:integer.json、string.json

en_US/element:integer.json、string.json

base/element:integer.json、string.json、color.json

1.3 素材

base/media:图片素材

1.4 通用类

ets目录新建common目录,新建constat目录用于存放常量,新建utils目录用于存放工具类。

constant目录下新建Constants.ets文件,记录用到的常量。

export class Constants {
  /**
   * Full screen width.
   */
  static readonly FULL_WIDTH: string = '100%';
  /**
   * Full screen height.
   */
  static readonly FULL_HEIGHT: string = '100%';
}

utils目录下新建StringUtil.ets文件,用于处理从文件中读取的数据。

import { util } from "@kit.ArkTS";
import { BusinessError } from "@kit.BasicServicesKit";
import { hilog } from "@kit.PerformanceAnalysisKit";

export default class StringUtil {
  static async getStringFromRawFile(ctx: Context, source: string) {
    try {
      let getJson = await ctx.resourceManager.getRawFileContent(source);
      let textDecoder = util.TextDecoder.create('utf-8', { ignoreBOM: true });
      let result = textDecoder.decodeToString(getJson);
      return Promise.resolve(result);
    } catch (error) {
      let code = (error as BusinessError).code;
      let message = (error as BusinessError).message;
      hilog.error(0x0000, 'StringUtil', 'getStringSync failed,error code: %{code}s,message: %{message}s.', code,
        message);
      return Promise.reject(error);
    }
  }
}

2、整体框架

整体布局分为2部分,顶部搜索栏和其下的嵌套Tabs页面。为了提升可维护性,采用组件化编程思想。

2.1 搜索组件

在ets目录下新建view目录用于存放组件,新建搜索组件SearchBarComponent.ets

import { Constants } from "../common/constant/Constants";

@Component
export default struct SearchBarComponent {
  @State changeValue: string = '';

  build() {
    Row() {
      // 1、传统方法
      // Stack() {
      //   TextInput({ placeholder: $r('app.string.search_placeholder') })
      //     .height(40)
      //     .width(Constants.FULL_WIDTH)
      //     .fontSize(16)
      //     .placeholderColor(Color.Grey)
      //     .placeholderFont({ size: 16, weight: FontWeight.Normal })
      //     .borderStyle(BorderStyle.Solid)
      //     .backgroundColor($r('app.color.search_bar_input_color'))
      //     .padding({ left: 35, right: 66 })
      //     .onChange((currentContent) => {
      //       this.changeValue = currentContent;
      //     })
      //   Row() {
      //     Image($r('app.media.ic_search')).width(20).height(20)
      //     Button($r('app.string.search'))
      //       .padding({ left: 20, right: 20 })
      //       .height(36)
      //       .fontColor($r('app.color.search_bar_button_color'))
      //       .fontSize(16)
      //       .backgroundColor($r('app.color.search_bar_input_color'))
      //
      //   }.width(Constants.FULL_WIDTH)
      //   .hitTestBehavior(HitTestMode.None)
      //   .justifyContent(FlexAlign.SpaceBetween)
      //   .padding({ left: 10, right: 2 })
      // }.alignContent(Alignment.Start)
      // .width(Constants.FULL_WIDTH)

      // 2、搜索组件
      Search({placeholder:$r('app.string.search_placeholder')})
        .searchButton('搜索')

    }
    .justifyContent(FlexAlign.SpaceBetween)
    .padding(10)
    .width(Constants.FULL_WIDTH)
    .backgroundColor($r('app.color.out_tab_bar_background_color'))
    .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP])

  }
}

在主界面引入,即可查看效果。修改Index.ets

import { Constants } from '../common/constant/Constants';
import SearchBarComponent from '../view/SearchBarComponent';

@Entry
@Component
struct Index {

  build() {
    Column() {
      // 搜索栏
      SearchBarComponent()
    }
    .height(Constants.FULL_HEIGHT)
    .width(Constants.FULL_WIDTH)
    .expandSafeArea([SafeAreaType.SYSTEM])
  }
}

2.2 外层Tabs

通过界面分析,外层Tabs的每一个TabContent内容不同,可以抽取为组件。第一个TabContent抽取为组件InTabsComponent,后边的几个抽取为OtherTabContentComponent。

在view目录下新建组件:InTabsComponent.ets

@Component
export default struct InTabsComponent {
  build() {
    Text('内层Tabs')
  }
}

在InTabsComponent中,先简单写点提示信息,待整体框架完成后,后续再继续完成内层的内容。

在view目录下新建组件:OtherTabComponent.ets

import { Constants } from "../common/constant/Constants";

@Component
export default struct OtherTabContentComponent {
  @State bgColor: ResourceColor = $r('app.color.other_tab_content_default_color');

  build() {
    Column()
      .width(Constants.FULL_WIDTH)
      .height(Constants.FULL_HEIGHT)
      .backgroundColor(this.bgColor)
  }
}

在OtherTabComponent中,通过接收父组件传递的颜色参数来设置背景颜色,用以区分不同的Tab。

在view目录下,新建外层组件OutTabsComponent.ets

import { Constants } from "../common/constant/Constants";
import InTabsComponent from "./InTabsComponent";
import OtherTabContentComponent from "./OtherTabComponent";

@Component
export default struct OutTabsComponent {
  @State currentIndex: number = 0;
  private tabsController: TabsController = new TabsController();

  @Builder
  tabBuilder(index: number, name: string | Resource, icon: Resource) {
    Column() {
      SymbolGlyph(icon).fontColor([this.currentIndex === index
        ? $r('app.color.out_tab_bar_font_active_color')
        : $r('app.color.out_tab_bar_font_inactive_color')])
        .fontSize(25)

      Text(name)
        .margin({ top: 4 })
        .fontSize(10)
        .fontColor(this.currentIndex === index
          ? $r('app.color.out_tab_bar_font_active_color')
          : $r('app.color.out_tab_bar_font_inactive_color'))
    }
    .justifyContent(FlexAlign.Center)
    .height(Constants.FULL_HEIGHT)
    .width(Constants.FULL_WIDTH)
    .padding({ bottom: 60 })
  }
  build() {
    Tabs({
      barPosition: BarPosition.End,
      index: this.currentIndex,
      controller: this.tabsController,
    }) {
      TabContent() {
        InTabsComponent()
      }.tabBar(this.tabBuilder(0, $r('app.string.out_bar_text_home'), $r('sys.symbol.house')))
      TabContent() {
        OtherTabContentComponent({ bgColor: Color.Blue })
      }
      .tabBar(this.tabBuilder(1, $r('app.string.out_bar_text_discover'), $r('sys.symbol.map_badge_local')))

      TabContent() {
        OtherTabContentComponent({ bgColor: Color.Yellow })
      }
      .tabBar(this.tabBuilder(2, $r('app.string.out_bar_text_messages'), $r('sys.symbol.ellipsis_message')))

      TabContent() {
        OtherTabContentComponent({ bgColor: Color.Orange })
      }
      .tabBar(this.tabBuilder(3, $r('app.string.out_bar_text_profile'), $r('sys.symbol.person')))
    }
    .vertical(false)
    .barMode(BarMode.Fixed)
    .scrollable(true) // false to disable scroll to switch
    // .edgeEffect(EdgeEffect.None) // disables edge springback
    .onChange((index: number) => {
      this.currentIndex = index;
    })
    .height(Constants.FULL_HEIGHT)
    .width(Constants.FULL_WIDTH)
    .backgroundColor($r('app.color.out_tab_bar_background_color'))
    .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
    .barHeight(120)
    .barBackgroundBlurStyle(BlurStyle.COMPONENT_THICK)
    .barOverlap(true)
  }
}

在主界面中引入外层Tabs组件OutTabsComponent,修改主界面Index.ets

import OutTabsComponent from '../view/OutTabsComponent';

...
    // 外层tabs
    OutTabsComponent()

这样就实现了整体布局。

3、内层组件

分析内层组件布局结构,顶部是一张Banner图片,下边是一个Tabs组件。整个内层组件可以上下滚动,并且上滑要产生吸顶效果,因此外层组件应该使用Scroll滚动组件作为顶层父容器,里边滚动的内容使用List组件即可,List里边的内容也需要封装成组件。

3.1 Banner组件

接下来先封装顶部的Banner图片组件,在view目录下新建BannerComponent组件,BannerComponent.ets

import { Constants } from "../common/constant/Constants";

@Component
export default struct BannerComponent {
  build() {
    Column() {
      Image($r('app.media.pic5'))
        .width(Constants.FULL_WIDTH)
        .height(186)
        .borderRadius(16)
    }
    .margin({
      left: 5,
      right: 5,
      top: 10,
      bottom: 2
    })
  }
}

3.2 列表项组件

接下来封装列表项组件ContentItemComponent,

封装数据类ContentItemModel,在ets目录下新建model目录,新建ContentItemModel.ets

export default class ContentItemModel {
  username: string | Resource = '';
  publishTime: string | Resource = '';
  rawTitle: string | Resource = '';
  title: string | Resource = '';
  imgUrl1: string | Resource = '';
  imgUrl2: string | Resource = '';
  imgUrl3: string | Resource = '';
  imgUrl4: string | Resource = '';
}

封装数据类ContentItemViewModel,在ets目录下新建viewmodel目录,新建ContentItemViewModel.ets文件

import ContentItemModel from "../model/ContentItemModel";

@Observed
export default class ContentItemViewModel {
  username: string | Resource = '';
  publishTime: string | Resource = '';
  rawTitle: string | Resource = '';
  title: string | Resource = '';
  imgUrl1: string | Resource = '';
  imgUrl2: string | Resource = '';
  imgUrl3: string | Resource = '';
  imgUrl4: string | Resource = '';

  updateContentItem(contentItemModel: ContentItemModel) {
    this.username = contentItemModel.username;
    this.publishTime = contentItemModel.publishTime;
    this.rawTitle = contentItemModel.rawTitle;
    this.title = contentItemModel.title;
    this.imgUrl1 = contentItemModel.imgUrl1;
    this.imgUrl2 = contentItemModel.imgUrl2;
    this.imgUrl3 = contentItemModel.imgUrl3;
    this.imgUrl4 = contentItemModel.imgUrl4;
  }
}

在view目录新建ContentItemComponent.ets

import { Constants } from "../common/constant/Constants";
import ContentItemViewModel from "../viewmodel/ContentItemViewModel";


@Component
export default struct ContentItemComponent {
  @Prop contentItemViewModel: ContentItemViewModel;

  build() {
    Column() {
      Row() {
        Image(this.contentItemViewModel.imgUrl1)
          .width(30)
          .height(30)
          .borderRadius(15)
        Column() {
          Text(this.contentItemViewModel.username)
            .fontSize(15)
          Text(this.contentItemViewModel.publishTime)
            .fontSize(12)
            .fontColor($r('app.color.content_item_text_color'))
        }
        .margin({ left: 10 })
        .justifyContent(FlexAlign.Start)
        .alignItems(HorizontalAlign.Start)
      }

      Column() {
        Text(this.contentItemViewModel.title)
          .fontSize(16)
          .id('title')
          .textAlign(TextAlign.Start)

      }
      .margin({top:10, bottom: 10})

      Row() {
        Image(this.contentItemViewModel.imgUrl2)
          .width(115)
          .height(115)
        Image(this.contentItemViewModel.imgUrl3)
          .width(115)
          .height(115)
        Image(this.contentItemViewModel.imgUrl4)
          .width(115)
          .height(115)
      }
      .width(Constants.FULL_WIDTH)
      .justifyContent(FlexAlign.SpaceBetween)
    }
    .width(Constants.FULL_WIDTH)
    .alignItems(HorizontalAlign.Start)

  }
}

3.3 列表数据封装

在制作列表项组件时封装了每一项数据对应的类ContentItemModel,还需要封装一个类用于表示整个Tabs界面的数据。

在model目录下新建InTabsModel.ets

import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import ContentItemModel from './ContentItemModel';
import StringUtil from '../common/utils/StringUtil';

export default class InTabsModel {
  contentItems: ContentItemModel[] = [];

  async loadContentItems(ctx: Context) {
    let filename = '';
    try {
      filename = await ctx.resourceManager.getStringValue($r('app.string.default_content_items_file').id);
    } catch (error) {
      let err = error as BusinessError;
      hilog.error(0x0000, 'InTabsModel', `getStringValue failed, error code=${err.code}, message=${err.message}`);
    }

    let res = await StringUtil.getStringFromRawFile(ctx, filename);

    this.contentItems = JSON.parse(res).map((item: ContentItemModel) => {

      let img1 = item.imgUrl1 as string;
      if (img1.indexOf('app.media') === 0) {
        item.imgUrl1 = $r(img1);
      }

      let img2 = item.imgUrl2 as string;
      if (img2.indexOf('app.media') === 0) {
        item.imgUrl2 = $r(img2);
      }

      let img3 = item.imgUrl3 as string;
      if (img3.indexOf('app.media') === 0) {
        item.imgUrl3 = $r(img3);
      }

      let img4 = item.imgUrl4 as string;
      if (img4.indexOf('app.media') === 0) {
        item.imgUrl4 = $r(img4);
      }

      return item;
    });
  }
}

该类主要实现从本地文件中读取列表数据。

在viewmodel目录下新建文件InTabsViewModel.ets

import ContentItemViewModel from "./ContentItemViewModel";
import InTabsModel from "../model/InTabsModel";

@Observed
class ContentItemArray extends Array<ContentItemViewModel> {
}

@Observed
export default class InTabsViewModel {
  private inTabsModel: InTabsModel = new InTabsModel();
  contentItems: ContentItemArray = new ContentItemArray();

  async loadContentData(ctx: Context) {
    await this.inTabsModel.loadContentItems(ctx);

    let tempItems: ContentItemArray = [];
    for (let item of this.inTabsModel.contentItems) {
      let contentItemViewModel = new ContentItemViewModel();
      contentItemViewModel.updateContentItem(item);
      tempItems.push(contentItemViewModel);
    }
    this.contentItems = tempItems;
  }
}

3.4 Tab类封装

将每一个Tab抽象为TabItemModel类,以便于记录当前选中的选项卡。

在model目录下新建TabItemModel.ets

export default class TabItemModel {
  id: number = 0;
  name: string | Resource = '';
  isChecked: boolean = true;
}

在viewmodel目录下新建TabItemViewModel.ets

import TabItemModel from "../model/TabItemModel";

@Observed
export default class TabItemViewModel {
  id: number = 0;
  name: string | Resource = '';
  isChecked: boolean = true;

  updateTab(tabItemModel: TabItemModel) {
    this.id = tabItemModel.id;
    this.name = tabItemModel.name;
    this.isChecked = tabItemModel.isChecked;
  }
}

3.5 标签分类封装

内层Tabs的标签TarBar也是直接从文件读取,内层标签初始加载时直接读取文件内容进行显示,后续还需要添加分类的选择和取消功能,实现自定义显示分类。

本小节先封装相关类,在model目录下新建SelectTabsModel类,用于存取文件中的标签分类,SelectTabsModel.ets

import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import TabItemModel from './TabItemModel';
import StringUtil from '../common/utils/StringUtil';

export default class SelectTabsModel {
  allTabs: TabItemModel[] = [];

  async loadAllTabs(ctx: Context) {
    let filename = '';
    try {
      filename = await ctx.resourceManager.getStringValue($r('app.string.default_all_tabs_file').id);
    } catch (error) {
      let err = error as BusinessError;
      hilog.error(0x0000, 'SelectTabsModel', `getStringValue failed, error code=${err.code}, message=${err.message}`);
    }
    let result = await StringUtil.getStringFromRawFile(ctx, filename);
    this.allTabs = JSON.parse(result);
  }
}

在viewmodel目录下新建SelectTabsViewModel.ets

import TabItemViewModel from "./TabItemViewModel";
import SelectTabsModel from "../model/SelectTabsModel";

@Observed
class TabItemArray extends Array<TabItemViewModel> {
}

@Observed
export default class SelectTabsViewModel {
  allTabs: TabItemArray = new TabItemArray();
  selectedTabs: TabItemArray = new TabItemArray();
  private selectTabsModel: SelectTabsModel = new SelectTabsModel();

  async loadTabs(ctx: Context) {
    await this.selectTabsModel.loadAllTabs(ctx);

    let tempTabs: TabItemViewModel[] = [];
    for (let tab of this.selectTabsModel.allTabs) {
      let tabItemViewModel = new TabItemViewModel();
      tabItemViewModel.updateTab(tab);
      tempTabs.push(tabItemViewModel);
    }
    this.allTabs = tempTabs;

    this.updateSelectedTabs();
  }

  updateSelectedTabs() {
    let tempTabs: TabItemViewModel[] = [];
    for (let tab of this.allTabs) {
      if (tab.isChecked) {
        tempTabs.push(tab);
      }
    }
    this.selectedTabs = tempTabs;
  }
}

3.6 内层组件

修改InTabsComponent.ets

import { Constants } from "../common/constant/Constants";
import BannerComponent from "./BannerComponent";
import { CommonModifier } from "@kit.ArkUI";
import ContentItemComponent from "./ContentItemComponent";
import ContentItemViewModel from "../viewmodel/ContentItemViewModel";
import TabItemViewModel from "../viewmodel/TabItemViewModel";
import InTabsViewModel from "../viewmodel/InTabsViewModel";
import { EnvironmentCallback, Configuration, AbilityConstant } from "@kit.AbilityKit";
import SelectTabsViewModel from "../viewmodel/SelectTabsViewModel";

@Component
export default struct InTabsComponent {
  @State selectTabsViewModel: SelectTabsViewModel = new SelectTabsViewModel();
  @State inTabsViewModel: InTabsViewModel = new InTabsViewModel();
  @State tabBarModifier: CommonModifier = new CommonModifier();
  @State focusIndex: number = 0;

  @State showSelectTabsComponent: boolean = false;
  @State selectTabsComponentZIndex: number = -1;
  private ctx: Context = this.getUIContext().getHostContext() as Context;
  private subsController: TabsController = new TabsController();
  private tabBarItemScroller: Scroller = new Scroller();

  subscribeSystemLanguageUpdate() {
    let systemLanguage: string | undefined;
    let inTabsViewModel = this.inTabsViewModel;
    let selectTabsViewModel = this.selectTabsViewModel;

    let applicationContext = this.ctx.getApplicationContext();

    let environmentCallback: EnvironmentCallback = {
      async onConfigurationUpdated(newConfig: Configuration) {
        if (systemLanguage !== newConfig.language) {
          await inTabsViewModel.loadContentData(applicationContext);

          await selectTabsViewModel.loadTabs(applicationContext);

          systemLanguage = newConfig.language;
        }
      },
      onMemoryLevel: (level: AbilityConstant.MemoryLevel): void => {
        // do nothing
      }
    };
    applicationContext.on('environment', environmentCallback);
  }

  async aboutToAppear() {
    await this.inTabsViewModel.loadContentData(this.ctx);
    await this.selectTabsViewModel.loadTabs(this.ctx);
    this.tabBarModifier.margin({ right: 56 }).align(Alignment.Start);
    this.subscribeSystemLanguageUpdate();
  }

  @Builder
  tabBuilder(index: number, tab: TabItemViewModel) {
    Row() {
      Text(tab.name)
        .fontSize(14)
        .fontWeight(this.focusIndex === index ? FontWeight.Medium : FontWeight.Regular)
        .fontColor(this.focusIndex === index ? Color.White : $r('app.color.in_tab_bar_text_normal_color'))
    }
    .justifyContent(FlexAlign.Center)
    .backgroundColor(this.focusIndex === index
      ? $r('app.color.in_tab_bar_background_active_color')
      : $r('app.color.in_tab_bar_background_inactive_color'))
    .borderRadius(20)
    .height(40)
    .margin({ left: 4, right: 4 })
    .padding({ left: 18, right: 18 })
    .onClick(() => {
      this.focusIndex = index;
      this.subsController.changeIndex(index);
      this.tabBarItemScroller.scrollToIndex(index, true, ScrollAlign.CENTER);
    })
  }

  build() {
    Scroll() {
      Column() {
        BannerComponent()

        Stack({ alignContent: Alignment.TopEnd }) {
          Row() {
            Image($r('app.media.more'))
              .width(20)
              .height(20)
              .margin({ left: 10 })
              .onClick(() => {
                // todo:弹层选择分类
              })
          }
          .margin({ top: 8, bottom: 8, right: 5 })
          .backgroundColor($r('app.color.in_tab_bar_background_inactive_color'))
          .width(40)
          .height(40)
          .borderRadius(20)
          .zIndex(1)

          Column() {
            Tabs({
              barPosition: BarPosition.Start,
              controller: this.subsController,
              barModifier: this.tabBarModifier
            }) {
              ForEach(this.selectTabsViewModel.selectedTabs, (tab: TabItemViewModel, index: number) => {
                TabContent() {
                  List({ space: 10 }) {
                    ForEach(this.inTabsViewModel.contentItems, (item: ContentItemViewModel, index: number) => {
                      ContentItemComponent({
                        contentItemViewModel: item,
                      })
                    }, (item: ContentItemViewModel, index: number) => index + '_' + JSON.stringify(item))
                  }
                  .padding({ left: 5, right: 5, bottom: 120 })
                  .width(Constants.FULL_WIDTH)
                  .height(Constants.FULL_HEIGHT)
                  .scrollBar(BarState.Off)
                }
                .tabBar(this.tabBuilder(index, tab))
              }, (tab: TabItemViewModel, index: number) => index + '_' + JSON.stringify(tab))
            }
            .barMode(BarMode.Scrollable)
            .width(Constants.FULL_WIDTH)
            .height(Constants.FULL_HEIGHT)
            .barBackgroundColor($r('app.color.out_tab_bar_background_color'))
            .scrollable(true)
            .onChange((index: number) => {
              this.focusIndex = index;
              this.tabBarItemScroller.scrollToIndex(index, true, ScrollAlign.CENTER);
              let preloadItems: number[] = [];
              if (index - 1 >= 0) {
                preloadItems.push(index - 1);
              }
              if (index + 1 < this.selectTabsViewModel.selectedTabs.length) {
                preloadItems.push(index + 1);
              }
              this.subsController.preloadItems(preloadItems);
            })
          }
          .width(Constants.FULL_WIDTH)
          .height(Constants.FULL_HEIGHT)
          .backgroundColor($r('app.color.out_tab_bar_background_color'))
        }

      }
    }
    .scrollBar(BarState.Off)
    .width(Constants.FULL_WIDTH)
    .height(Constants.FULL_HEIGHT)
    .backgroundColor($r('app.color.out_tab_bar_background_color'))
    .padding({ left: 5, right: 5 })
  }
}

这样基本效果就实现了。

3.7 吸顶效果

Tabs父组件外及Tabs的TabContent组件内嵌套可滑动组件。在TabContent内可滑动组件上设置滑动行为属性nestedScroll,使其往上滑动时,父组件先动,往下滑动时自己先动。

修改InTabsComponent,为List组件添加nestedScroll属性

...
List(){
    ...
}
.nestedScroll({
    scrollForward: NestedScrollMode.PARENT_FIRST,
    scrollBackward: NestedScrollMode.SELF_FIRST
 })
...

3.8 内外联动

当滑动内层Tabs最后一个时,需要联动外层滚动。

实现思路:外层Tabs和内层Tabs均可滑动切换页签,内层滑到尽头触发外层滑动;在内层Tabs最后一个TabContent上监听滑动手势,通过@Link传递变量到父组件的外层Tabs,然后通过外层Tabs的TabController控制其滑动。

在InTabsComponent组件中,通过ForEach遍历生成TabContent时,需要给最后一项绑定 滚动手势,设置当前是最后一项的标识。InTabsComponent.ets

@Link switchNext: boolean; //是否内层Tab最后一项
...
Tabs(){
   ForEach(this.selectTabsViewModel.selectedTabs, (tab: TabItemViewModel, index: number) => {
     if (index === this.selectTabsViewModel.selectedTabs.length - 1) {
          TabContent() {
                List({ space: 10 }) {
                    ForEach(this.inTabsViewModel.contentItems, (item: ContentItemViewModel, index: number) => {
                        ContentItemComponent({
                          contentItemViewModel: item,
                        })
                      }, (item: ContentItemViewModel, index: number) => index + '_' + JSON.stringify(item))
                    }
                    .padding({ left: 5, right: 5, bottom: 120 })
                    .width(Constants.FULL_WIDTH)
                    .height(Constants.FULL_HEIGHT)
                    .scrollBar(BarState.Off)
                    .nestedScroll({
                      scrollForward: NestedScrollMode.PARENT_FIRST,
                      scrollBackward: NestedScrollMode.SELF_FIRST
                    })
                  }
                  .tabBar(this.tabBuilder(index, tab))
                  .gesture(PanGesture(new PanGestureOptions({ direction: PanDirection.Left })).onActionStart(() => {
                    this.switchNext = true;
                  }))
        }else {
                  TabContent() {
                    List({ space: 10 }) {
                      ForEach(this.inTabsViewModel.contentItems, (item: ContentItemViewModel, index: number) => {
                        ContentItemComponent({
                          contentItemViewModel: item,
                        })
                      }, (item: ContentItemViewModel, index: number) => index + '_' + JSON.stringify(item))
                    }
                    .padding({ left: 5, right: 5, bottom: 120 })
                    .width(Constants.FULL_WIDTH)
                    .height(Constants.FULL_HEIGHT)
                    .scrollBar(BarState.Off)
                    .nestedScroll({
                      scrollForward: NestedScrollMode.PARENT_FIRST,
                      scrollBackward: NestedScrollMode.SELF_FIRST
                    })
                  }
                  .tabBar(this.tabBuilder(index, tab))
                }

              }, (tab: TabItemViewModel, index: number) => index + '_' + JSON.stringify(tab))
            } 
}

外层组件OutTabsComponent传递参数,并监听该参数,一旦子组件回传的参数改变,则调用外层Tabs的控制器来改变外层Tab选择项,选中下一页。

 @State @Watch('onchangeSwitchNext') switchNext: boolean = false;

  onchangeSwitchNext() {
    if (this.switchNext) {
      this.switchNext = false;
      this.tabsController.changeIndex(1);
    }
  }

TabContent() {
     InTabsComponent({ switchNext: this.switchNext })
}

这样就实现了内层组件与外层组件联动。

3.9 分类选择

在首页中,分类可以由用户自定义选择,点击图片弹出组件InTabsModel。

制作选择分类组件SelectTabsComponent,在view目录下新建SelectTabsComponent.ets

import { Constants } from "../common/constant/Constants";
import SelectTabsViewModel from "../viewmodel/SelectTabsViewModel"
import TabItemViewModel from "../viewmodel/TabItemViewModel";

@Component
export default struct SelectTabsComponent {
  @State checkedChange: boolean = false;
  @Link selectTabsViewModel: SelectTabsViewModel;
  build() {
    Grid() {
      ForEach(this.selectTabsViewModel.allTabs, (tab: TabItemViewModel) => {
        GridItem() {
          Row() {
            Toggle({ type: ToggleType.Button, isOn: tab.isChecked }) {
              if (this.checkedChange) {
                Text(tab.name)
                  .fontColor(tab.isChecked ? Color.White : $r('app.color.in_tab_bar_text_normal_color'))
                  .fontSize(14)
              } else {
                Text(tab.name)
                  .fontColor(tab.isChecked ? Color.White : $r('app.color.in_tab_bar_text_normal_color'))
                  .fontSize(14)
              }
            }
            .width($r('app.integer.in_tab_bar_width'))
            .borderRadius(20)
            .height(40)
            .margin({
              left: 4,
              right: 4,
              top: 10,
              bottom: 10
            })
            .padding({ left: 12, right: 12 })
            .selectedColor($r('app.color.in_tab_bar_background_active_color'))
            .onChange((isOn: boolean) => {
              tab.isChecked = isOn;
              this.checkedChange = !this.checkedChange;
            })
          }
        }
      }, (tab: TabItemViewModel, index: number) => index + '_' + JSON.stringify(tab))
    }
    .columnsTemplate(('1fr 1fr 1fr 1fr') as string)
    .height(Constants.FULL_HEIGHT)
  }
}

在InTabsComponent组件中,绑定弹出框事件,点击时弹出选择分类组件。修改InTabsComponent.ets

import SelectTabsComponent from "./SelectTabsComponent";

@Builder
sheetBuilder() {
    SelectTabsComponent({ selectTabsViewModel: this.selectTabsViewModel })
}
  
...
Row() {
   Image($r('app.media.more'))
   .onClick(() => {
                this.showSelectTabsComponent = !this.showSelectTabsComponent;
   })
}
.bindSheet($$this.showSelectTabsComponent, this.sheetBuilder(), {
            detents: [SheetSize.MEDIUM, SheetSize.MEDIUM, 500],
            preferType: SheetType.BOTTOM,
            title: { title: $r('app.string.bind_sheet_title') },
            onWillDismiss: (dismissSheetAction: DismissSheetAction) => {
              // update tab when closing modal box
              this.selectTabsViewModel.updateSelectedTabs();
              if (this.selectTabsViewModel.selectedTabs.length > 0) {
                this.subsController.changeIndex(0);
              }
              dismissSheetAction.dismiss();
            }
 })

点击图标,在弹出的页签中选择分类后关闭,内层Tabs的标签就自动显示选择的分类标签。

3.10 多语言测试

多语言的开发,开发者只需要准备不同语言的资源文件即可,匹配由系统自动实现。前面已经准备了中文和英文资源文件,即可实现多语言功能。

至于系统匹配的过程,只需要简单了解即可。系统匹配不同语言资源文件的过程和规则:程序运行时会获取系统语言与资源文件进行比对,如果系统语言是中文就匹配中文资源(zh_CN/element)。如果未匹配到,则获取用户首选项设置的语言进行比对,如果匹配到就显示对应的资源文件,否则就使用默认的资源配置文件(base/element/)。

这个匹配过程由系统自动完成,为了方面测试效果,可以使用18n手动设置语言首选项来改变语言环境。在entryability/EntryAbility.ets文件的onWindowStageCreate设置改变语言,观察效果。

onWindowStageCreate(windowStage: window.WindowStage): void {
    i18n.System.setAppPreferredLanguage("en");  //英文
    // i18n.System.setAppPreferredLanguage("zh");  //中文
    ...
 }

程序运行后,改变首选项语言,可以看到中文和英文的界面。

至此,功能开发完成。

三、总结

  • 实现双层嵌套Tabs

    • 外层Tabs和内层Tabs均可滑动切换页签,内层滑到尽头触发外层滑动
    • 在内层Tabs最后一个TabContent上监听滑动手势,通过@Link传递变量到父组件的外层Tabs,然后通过外层Tabs的TabController控制其滑动
  • 实现Tabs滑动吸顶

    • Tabs父组件外及Tabs的TabContent组件内嵌套可滑动组件
    • 在TabContent内可滑动组件上设置滑动行为属性nestedScroll,使其往上滑动时,父组件先动,往下滑动时自己先动
  • 实现底部自定义变化页签

    • @Builder装饰器修饰的自定义builder函数,传递给TabBar,实现自定义样式
    • 设置currentIndex属性,记录当前选择的页签,并且@Builder修饰的TabBar构建函数中利用其值来区分当前页签是否被选中,以呈现不同的样式
  • 实现顶部可滑动标签

    • 设置Tabs组件属性barMode(BarMode.Scrollable),页签显示不下的时候就可滑动
  • 实现增删现实页签项

    • 利用@Link双向绑定selectTabsViewModel到InTabsComponent和SelectTabsComponent
    • SelectTabsComponent选中需要显示的页签项,在退出模态框时调用selectTabsViewModel.updateSelectedTabs,更新可显示页签
    • 更新后通过@Link的机制传递到InTabsComponent,触发UI刷新,显示新选择的页签
  • 实现Tabs切换动效

    • 在Tabs上注册动画方法customContentTransition(this.customContentTransition)
    • 在动画方法中修改TabContent的尺寸属性和透明属性,并通过@State修饰后传递给TabContent,来实现动画

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

关注后,评论区领取本案例项目代码!

多页面应用登录状态共享:基于弹出窗口的通用解决方案

今天分享一个常见的场景:多页面应用怎么复用登录状态?比如微前端架构下,多个子应用都需要登录,但每个都搞个登录页太麻烦,用户体验也不连贯。那我们能不能做一个统一的登录页,登录一次,所有页面都同步登录状态呢?当然可以!下面就来分享我们的方案。

注意:这个方案不太适用移动端,但使用Cookie来实现登录状态同步的思路是一致的

思路很简单

我们打算这样干:

  1. 在任何需要登录的页面,点击登录按钮,弹出一个新窗口打开统一的登录页。
  2. 用户在登录页完成登录,后端设置Cookie(确保域名相同,路径为根路径)。
  3. 登录成功后,登录页通过window.opener通知父窗口,然后关闭自己。
  4. 父窗口检测到登录完成,执行回调函数,更新本地登录状态。

这样,同一个域名下的所有页面都能共享Cookie中的登录状态,实现一次登录,处处通行。

代码实现

父页面逻辑

我们封装一个useLogin钩子,主要做两件事:打开登录窗口和监听登录结果。

import { isFunction } from "@/utils/common"

export function useLogin() {
    let loginPage = null
    
    const login = (options = {}, cb = null) => {
        const url = 'pages/login/index'
        // 新窗口打开登录页,设置好窗口参数
        loginPage = window.open(url, '_blank', 'width=375,height=640,status=0,titlebar=0,toolbar=0,resizable=0,menubar=0,location=0,directories=0,left=100,top=100')
        loginPage.focus()
        // 避免有些浏览器焦点没抢到,再试一次
        setTimeout(() => loginPage.focus(), 10)
        
        // 监听焦点事件,判断登录窗口是否关闭
        window.onfocus = async function(activeCall) {
            setTimeout(() => {
                // 如果登录窗口关闭或者登录页主动通知(activeCall为"1")
                if (loginPage?.closed || activeCall == "1") {
                    loginPage = null
                    window.onfocus = null
                    // 执行回调
                    if (cb) {
                        if (isFunction(cb)) {
                            cb(activeCall)
                        } else {
                            console.error('回调函数不是Function类型')
                        }
                    }
                }
            }, 100)
        }
    }
    
    return {
        login
    }
}

登录页逻辑

登录页在登录接口调用成功后,通知父窗口,然后关闭自己。

// 登录接口调用成功后
try {
    // 通知父窗口,传递参数"1"表示登录成功
    await window.opener.onfocus("1")
} catch(e) {
    // 如果通知失败,比如父窗口已经关闭,我们打印错误
    console.log(e)
}
// 关闭登录窗口
window.close()

关键点说明

1. 窗口通信

我们用了window.opener来获取打开当前窗口的父窗口,然后调用父窗口的onfocus方法。这种方法简单直接,但要注意跨域限制,所以我们的登录页和父页面必须是同域名的。

2. 回调函数处理

父窗口通过回调函数来响应登录结果,例如更新对应的用户数据。我们做了类型检查,确保回调是函数才执行。

3. Cookie设置

登录成功后,后端设置Cookie时,要设置path=/domain为主域名,这样所有子页面都能读到。

遇到的问题和解决方案

浏览器拦截弹窗?

有的浏览器可能会拦截window.open,所以我们最好在用户点击事件中触发登录窗口打开,避免异步打开。

移动端怎么办?

移动端不建议用新窗口,可以直接跳转到登录页,登录成功后跳转回来。我们可以根据设备类型选择不同的策略。

总结

这个方案已经在我们项目中稳定运行,用户体验良好。代码量不多,但实现了多页面登录状态共享。当然,还有优化空间,比如加入登录状态过期自动跳转等。

MCP-学习(1)

MCP学习的前置知识

这一章主要对MCP的前置知识进行学习:stdio(通信方式)、JSON-RPC(通信格式)

stdio

stdio:standard input and output (标准输入输出)

进程之间是相互独立的,而进程需要与外界进行通信,通信的方式有很多,其中一种就是stdio。它能够让一个进程 通过标准输出接口(stdout) 向外界发送信息、外界也可以 通过它的标准输入接口(stdin) 向进程中写入一些信息。

举个栗子🌰

标准输出接口

用node.js来写个简单案例:

//创建一个server.js
//process进程  stdout标准输出
process.stdout.write('Hello World!!\n')

然后在终端中运行它node server.js (意思:启动一个node进程,参数是'server.js'),就会发现终端中打印了Hello World!!。没错看起来效果很像console.log(),因为在node环境console.log()的内部确实是调用了process.stdout.write()加上一些额外处理。

那我们刚刚说了,stdio是通信方式呀,那这里是谁跟谁通信了嘞?这里其实是node进程与终端进行通信了:终端本身是一个进程,它在 启动子进程(这里是node进程) 的时候会去 监听子进程的标准输出接口,终端已经内置了这些流程,当它监听到数据后就会将其显示在终端。

标准输入接口
//server.js
//监听父进程(终端)输入
process.stdin.on('data',(data)=>{
    const resp = `回复 ${data}\n`
    process.stdout.write(resp)
})

运行后,会发现终端中就会一直运行着这个node进程,可以在终端输入数据然后回车来发送,就可以被node子进程接收到。那么我们就会发现,在这个场景下,终端类似于客户端,因此才会给这个js文件起名为server.js

当然node.js自己本身也可以作为一个父进程来创建更多子进程:

import {spawn} from 'child_process'

// 创建子进程
const serverProcess = spawn('node',['server.js']) //启动node应用程序,参数是'server.js'

stdio的特点:简洁、高效,但仅适用于本地进程间通信

JSON-RPC (通信格式约束)

request(2.0的版本,如果用到不同版本需要分析)
{
    'jsonrpc': '2.0',  //版本
    'method': 'sum',   //方法
    'params': {        //参数
        "a": 5,
        "b": 6
    },
    'id': 1            //标记
}
response
{
    'jsonrpc': '2.0',  //版本
    'result': 11,
    'id': 1
}

这边从server.js这边写一个测试:

import utils from './utils.js'
process.stdin.on('data',(data)=>{
    const req = JSON.parse(data)
    const funcName = req.method
    const params = req.params
    const result = utils[funcName](params)
    const res = {
        'jsonrpc': '2.0',
        'result': result,
        'id': req.id
    }
})

小结

这些通信,主要是补充了某一些进程功能上的不足

Vue 3 + Supabase 认证与授权时序最佳实践指南

本文档旨在为使用 Vue 3、Pinia、Vue Router 和 Supabase 构建的现代化 Web 应用,提供一套清晰、健壮且可扩展的认证、授权与数据初始化流程。

一、核心设计理念

传统的认证流程常常会在应用启动时阻塞,直到获取到用户所有信息(包括权限)后才渲染页面和侧边栏,这会导致较长的白屏时间。我们的核心理念是 渐进式初始化 (Progressive Initialization) ,它遵循以下原则:

  1. 快速启动,优先交互:应用应尽快完成首次渲染。认证状态的初步检查(例如,是否存在有效的 access_token)应非常迅速,不阻塞 UI。
  2. 状态分离,异步加载:将“基础认证状态”(是否登录)与“详细用户权限/信息”(角色、权限列表、用户资料)分离开。前者用于快速决定页面布局(如显示登录页还是主界面),后者在后台异步加载,加载完成后再更新 UI。
  3. 中心化状态管理:使用 Pinia store(例如 authStore)作为唯一可信源 (Single Source of Truth) 来管理用户的认证状态、信息和权限。所有组件和路由守卫都从这里读取状态。
  4. 声明式权限控制:通过 Vue Router 的 meta 字段和自定义指令,以声明式的方式控制页面访问和组件元素的显示,使权限逻辑清晰易懂。
  5. 事件驱动的通信:解耦认证核心逻辑与 UI。认证模块在完成关键步骤(如会话就绪、权限加载完毕、登出)后,通过全局事件通知应用的其他部分,而不是直接调用 UI 相关代码。

二、完整的生命周期流程

graph TD
    subgraph "应用启动 (瞬间完成)"
        A(用户打开应用) --> B("main.ts: createApp.mount('#app')");
        B --> C("App.vue onMounted: 显示加载动画");
        C --> D("App.vue onMounted: 设置 onAuthStateChange 监听器");
    end

    subgraph "路由导航守卫 (快速路径)"
        D --> E("触发路由导航 beforeEach");
        E --> F{"authStore.isInitialized?"};
        F -- 否 --> G("调用 authStore.initAuth");
        G --> H("supabase.getSession");
        F -- 是 --> I(继续);
        H --> I;
        I --> J{"路由需要认证? (meta.requiresAuth)"};
        J -- 否 --> K("next(): 允许访问");
        J -- 是 --> L{"用户已认证? (authStore.isAuthenticated)"};
        L -- 是 --> K;
        L -- 否 --> M("next('/login'): 重定向到登录页");
    end

    subgraph "权限加载 (异步慢路径)"
        H -- "发现有效 Session" --> N("onAuthStateChange 监听器被触发");
        N --> O("从数据库查询用户角色和权限");
        O --> P("authStore.updateUserPermissions");
        P --> Q("更新 Pinia: 填充权限, isLoading = false");
        Q --> R("UI 响应: 隐藏加载动画, 显示主布局");
    end

    C -.-> R;

让我们从用户打开应用的那一刻起,一步步追踪整个流程。

阶段 1: 应用启动与初步渲染
  1. 应用挂载 (入口文件 main.ts) :

    • createApp(App).mount('#app') 被立即执行。应用不会等待任何认证或数据加载,用户会立刻看到根组件 (App.vue) 渲染的内容。这是实现快速启动的关键。
    • 此时,根组件中一个类似 shouldShowLoading 的计算属性会因为认证状态尚未初始化 (isInitializedfalse) 而返回 true,从而显示一个全屏的加载动画。
  2. 认证系统初始化 (根组件 App.vue) :

    • 在根组件的 onMounted 钩子中,调用一个核心的初始化函数(例如 initializeAuth)。
    • 这个函数会设置 Supabase 的 onAuthStateChange 监听器。此监听器是整个认证体系的脉搏,它会在登录、登出、令牌刷新等任何认证状态变化时自动触发。
    • 同时,可以设置一个回调函数,当权限加载完成后,这个回调会被调用,用于将最终的权限信息同步到 Pinia Store 中。
阶段 2: 快速认证与路由决策
  1. 路由导航触发 (router/index.ts) :

    • 用户访问网站,触发 Vue Router 的全局前置守卫 beforeEach
    • 守卫首先检查 authStore 中的 isInitialized 状态。由于此时还是 false,它会调用一个快速检查函数,如 authStore.initAuth()
  2. 执行快速认证检查 (stores/auth.ts) :

    • initAuth() 函数的职责非常单一和快速:它调用 supabase.auth.getSession()
    • 情况 A:用户有有效会话getSession() 从本地存储中快速读取到 session。authStore 会立刻更新 sessionuser 的基本信息,并将 isInitialized 设为 true注意:此时权限列表仍然是空的。
    • 情况 B:用户没有有效会话getSession() 返回 nullauthStore 同样会将 isInitialized 设为 true
    • 此过程不涉及任何数据库查询,因此执行得非常快。
  3. 路由守卫做出决策:

    • 现在 isInitializedtrue,守卫可以根据 authStore.isAuthenticated 和路由的 meta 信息来决定下一步操作:

      • 访问需授权页面但未登录:重定向到登录页。
      • 已登录状态下访问登录页:重定向到主页。
      • 其他情况:允许导航。
阶段 3: 异步权限加载与 UI 更新
  1. 权限加载触发:

    • 在阶段 2 中,如果 initAuth 发现了有效会话,onAuthStateChange 监听器会以 SIGNED_ININITIAL_SESSION 事件被触发。
    • 监听器的回调函数开始执行“慢”操作:从数据库查询用户的角色和权限列表。这个过程通常被封装在一个专门的权限管理模块 (RBAC Manager) 中。
  2. 更新 Pinia Store:

    • 当权限管理模块成功获取到所有信息后,它会调用之前设置的回调函数,即 authStore.updateUserPermissions()
    • 此方法会将用户的详细信息、角色和权限列表填充到 authStore 的 state 中,并将 isLoading 设为 false
  3. UI 最终响应 (根组件 App.vue) :

    • isLoading 变为 false 导致根组件的计算属性变化,全屏加载动画消失,主应用布局(如侧边栏、头部导航)被渲染出来。
    • 至此,整个应用完全加载并对用户可用。

[认证流程图]

三、权限控制的最佳实践

一个完整的权限体系应包含三个层级的控制:

1. 路由级权限 (Page Access Control)
  • 实现方式: 在路由定义中添加 meta 字段。

    {
      path: '/admin/settings',
      name: 'admin-settings',
      component: () => import('../views/AdminSettings.vue'),
      meta: {
        requiresAuth: true,         // 必须登录
        permission: 'settings.view', // 需要特定权限
      },
    }
    
  • 工作原理: router.beforeEach 守卫检查 to.meta.permission,并调用 authStore 中的方法进行验证。若无权限,则中断导航。

2. 视图/组件级权限 (UI Element Control)
  • 实现方式: 使用自定义指令,例如 v-permission

    // 在 main.ts 中注册
    import { setupPermissionDirectives } from './directives/permission'
    setupPermissionDirectives(app)
    
    // 在组件模板中使用
    <button v-permission="'posts.create'">创建文章</button>
    
  • 工作原理: 自定义指令内部访问 authStore,如果权限不足,则直接将 DOM 元素移除或禁用,非常优雅。

3. 逻辑级权限 (Action Control)
  • 实现方式: 在业务逻辑中直接调用 authStore 的权限检查方法。

    import { useAuthStore } from '@/stores/auth'
    const authStore = useAuthStore()
    
    const handleSubmitPost = () => {
      if (!authStore.hasPermission('posts.create')) {
        // 建议使用 UI 通知组件,避免使用 alert
        showNotification('您没有发布文章的权限!')
        return
      }
      // ...执行发布的逻辑
    }
    
  • 工作原理: 用于保护核心业务操作,确保即使用户通过某种方式绕过了 UI 限制,也无法执行未授权的操作。

四、其他关键实践

  • 登出流程: 一个健壮的登出流程应遵循“先清理本地,再请求远端”的原则:

    1. 立即清除本地状态:将 Pinia Store 中的 user, session, permissions 等设为 null,确保 UI 立即响应。
    2. 清理缓存:清理所有与用户相关的缓存数据。
    3. 调用 Supabase 登出:最后执行 supabase.auth.signOut()
  • 基础数据初始化: 对于非认证相关的全局数据(如下拉菜单的选项),可使用 setTimeout 在应用启动后延迟加载,避免阻塞核心渲染流程。

总结

通过采用渐进式初始化中心化状态管理多层级权限控制的策略,可以构建一个启动快速、体验流畅且安全可靠的 Vue 应用。这份文档总结了实现这一目标的核心思想和关键步骤,可作为项目开发中的通用参考指南。

一文通关JavaScript:从基本语法到TypeScript

基本语法

大小写敏感 以分号作为语句分隔符,单句一行可以省略 //单行注释;/* */多行注释

  1. 一些字面量:Infinity无穷大;NaN非数值(类型还是数值);true/false布尔型;' '/" "字符串;${ }格式化字符串,${}内是变量,输出时替代为对应变量的值;null空;undefined未定义
  2. 标识符:必须以字母、_或$开头,可由字母、数字、_和$组成
  3. 变量声明:var声明全局变量,let声明局部变量,const声明常量 变量在赋值时确定类型(数值,字符串,布尔型,对象(数组,函数,对象,类)),let a=[]定义数组;let a={}定义对象;let a=function(){}定义函数;class a{}定义类
  4. 解构赋值:用[ , ]=[ , ]的形式对其中元素进行同时赋值
  5. 算数操作符:+加 -减 *乘 /除 %模 **幂 ++自加 --自减,在右侧先赋值再运算,在左侧先运算后赋值 += -= *= /= %= 迭代运算
  6. 关系操作符:==等于;===值等于且类型等于;!=值不等于;!==值不等于或类型不等于;>大于;>=大于等于;<小于;<=小于等于
  7. 逻辑操作符:&&与 ||或 !非 返回布尔型

输入输出

输入

prompt:let 变量=prompt('提示信息', '默认值'),默认值可选,返回输入的字符串 confirm:let 变量=confirm('提示信息'),显示一个带有“确定”和“取消”按钮的对话框,返回布尔型 readline:读取输入的提供接口

import * as readline from "readline" //导入readline模块
let rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
})
rl.question('提示信息', (形参)=>{
  函数体
  rl.close();
}) //输入内容作为实参传给回调函数

多行输入

line事件:rl.on('line', (input)=>{}),在触发line事件(如回车、返回等)执行回调函数,一般要设置一个结束条件执行 rl.close() close事件:rl.on('close', ()=>{}),在触发close事件(如执行rl.close()等)执行回调函数

输出

console.log('输出信息')

选择结构

1.if语句

if(条件){
  执行代码
}else if(条件){
  执行代码
}else{
  执行代码
}

2.switch语句

跳转到对应值的位置向下开始执行

switch(变量){
  case1:
    代码;
    break;
  case2:
    代码;
    break;
}

循环结构

1.while循环

while(条件){
  执行代码
}

2.do while循环

do{
  执行代码
}while(条件)

3.for循环

for(初值设置; 循环执行; 终止条件){
  执行代码
}

4.for of循环

用于遍历可迭代对象(如数组,字符串)中的元素

for(let i of 可迭代对象){
  执行代码
}

5.for in循环

用于遍历可迭代对象的角标,对象[i] 才指代对应元素

for(let i in 可迭代对象){
  执行代码
}

break:退出当前循环体,只退出最里层的循环 带标签的 break:退出标签下的所有循环体,即

lab:
循环1{
  循环2{
  break lab;
}}
continue:结束当前循环,进入下一次循环
带标签的continue:结束当前循环,进入标签下最外层的下一次循环
lab:
循环1{
  循环2{
  continue lab;
}}

函数定义

1.使用function定义函数

function 函数名(形参){
  函数体;
  return 返回数据;
}
函数名(实参)

2.将匿名函数赋值给变量,通过变量调用

let 变量=function(形参){
  函数体;
  return 返回数据;
}
变量名(实参)

3.箭头函数省略function

let 变量=(形参)=>{
  函数体;
  return 返回数据;
}

只有一个形参时可以省略小括号 只有一句代码时可以省略大括号和return 返回对象时不能省略大括号和return,但用小括号代替大括号时可以省略return

嵌套函数

在函数内定义的函数,作用域仅限外函数内 闭包:函数内部定义变量,外部利用返回值获取内部变量的值,内部变量被外部引用不会因计算而改变 闭包函数:将嵌套函数名作为返回值,即可在外部使用,保护函数的参数在执行完不被销毁,会记忆改变。可用于多级参数传入,多次调用同一函数并记忆改变,即

function outside(){
  function inside(){
  嵌套函数体
  }
  return inside
}
let result=inside
result()
result=null

回调:一个函数作为参数传入另一个函数,可用于装饰函数,附加功能,即

function a(){
  函数体
}
function b(func){
  func()
}
b(a)

也可在调用时传入匿名函数作为参数

function a(func){
  函数体
}
a(function(){
  函数体
})

函数参数

默认参数:在形参设定时进行默认赋值,当实参未对应到时使用默认值 可变参数:不设置形参,则实参会存放在arguments类数组对象中,在函数体中用索引arguments[i]调用对应参数 剩余参数:在形参设定时最后一个形参使用...arg形式,把对应后剩余的实参存在arg数组中

数组

数组中元素可以不同 let a=[] 创建数组,用索引调用元素 a[i] 返回数组中第i个元素(从0开始) a[i]=b 修改数组的第i个元素,i大于数组元素数则将元素添加在末尾 a.length 返回数组长度 a.toString() 返回以逗号分隔的连接所有数组元素的字符串 a.join(' ') 返回以指定字符分隔的连接所有数组元素的字符串 a.push(b) 在数组末尾添加一个元素 a.pop() 删除并返回数组最后一个元素 a.shift() 删除数组的第一个元素 a.unshift(b) 在数组首添加一个元素 a.indexOf(b) 返回元素的索引 a.slice(i, j) 返回从i到j的切片数组,左闭右开 a1.concat(a2) 返回a1接a2的数组 forEach方法:a.forEach(func),遍历数组,以元素、索引、数组的顺序传给回调函数,即a.forEach(function(value, index, array){}) filter方法:定义一个以元素、索引的顺序作为形参,返回布尔型的规则函数,a.filter(func)进行过滤留下布尔型为true的元素 every方法:同上定义一个规则函数,a.every(func)验证全部元素是否满足规则,返回布尔型(全部满足返回true,否则返回false) some方法:同上定义一个规则函数,a.some(func)验证全部元素是否满足规则,返回布尔型(全不满足返回false,否则返回true) find方法:同上定义一个规则函数,a.find(func)返回第一个满足规则的元素值 findindex方法:同上定义一个规则函数,a.findindex(func)返回第一个满足规则的元素索引,全不满足返回-1 map方法:定义一个以元素、索引的顺序作为形参,返回新元素值的规则函数,a.map(func)从左往右对每一个元素执行函数,返回一个新的数组,不会改变原数组 reduce方法:定义一个以累加器、元素、索引、数组的顺序作为形参,返回计算规则的规则函数,a.reduce(func, 累加器初值)从左往右对每一个元素执行函数,返回值加给累加器;a.reduce(func)未设定初值默认是0,且从索引为1的元素开始;累加器初值设定可以是数值、字符串、数组、对象等 reduceRight方法:基本同上,唯一不同点是从右往左执行 splice方法:a.splice(索引, 0, 元素值)为0则将元素从索引处插入数组,a.splice(索引, 1)为1则将索引处的元素从数组中删除并返回 sort方法:a.sort()默认将数组升序重排,定义比较函数a.sort((a, b)=>a-b),a-b为升序,b-a为降序;元素为对象时,可用a.键 和b.键 确定排序参考的值 reverse方法:将数组逆序重排

多维数组:数组的元素还是数组 数组展开c=[...a, ...b]数组中在是数组的元素前加...将数组内的元素展开降维

对象

使用大括号创建,由属性(键值对key: value)组成;属性可以是数值、字符串、数组、函数、对象等 对象.键 返回相应的值,可以进行修改和增加 '键' in 对象 判断属性是否存在,返回布尔型 delete 键 删除对应属性 对象构造器:创建类型相同的多个对象

function 构造器(形参){
  this.键=值
  在构造器中用this.键代指值进行操作
}
let 对象=new 构造器(实参)
创造对象后用 对象.键 代指值进行增删改和调用操作

对象原型:用构造器.prototype.键=值为构造器增加属性,已创建的对象也会同步增加属性 对象解构赋值let {变量 ,变量}=对象,按顺序将属性解构赋值

由构造器和方法组成

class 类名{
  constructor(参数){
    this.键=值
  }
  函数(形参){
    函数体
  }
}
let 实例=new 类(参数)

可用类.prototype.键=值为类添加属性或方法

私有成员:无法在类外部访问;以#开头,在构造器外先声明,在构造器中通过 this.#私有成员 调用,可以是变量也可以是函数 静态成员:通过 static 静态成员 进行声明,类.静态成员 进行调用修改

外部访问私有成员

Getter 方法和 Setter 方法:

class a{
  #b;
  constructor(b){
    this.#b=b;
  }
  get b(){
    return this.#b;
  } //通过Getter方法输出私有成员
  set b(b){
    this.#b=b;
  } //通过Setter方法修改私有成员
}
let c=new a(0);
a.b=1; //调用的是Setter方法,将#b赋值为1
d=a.b; //调用的是Getter方法,返回#b的值

继承

子类可以使用父类的全部属性和方法 用 class 子类 extends 父类{} 继承,在子类中用 this.属性 可以直接调用父类属性(父类私有成员不可被访问),super(参数)调用父类的构造器,super.函数()调用父类的函数,构造同名函数可以覆盖

模块

一个js文件就是一个模块,用export导出,然后可以用import导入 导出可以用 export let 导出量 导出单个量,也可定义后 export {导出量, 导出量} 一次导出多个量,函数、对象、类只需导出对应的名 导入时可用 import {导入量 as 重命名, 导入量 as 重命名} from '路径' 导入并重命名 导入整个模块用 import * as 模块 from '路径',则用 模块.变量 进行调用,对核心模块可以将'路径'替换为模块名 默认导出:一个模块只能有一个默认导出,在导入时自动重命名,通常是匿名导出(匿名函数export default function(){}匿名对象export default {}、匿名类export default class{}) require方法:用 let 重命名=require('模块名') 导入核心模块,let 重命名=require('路径') 导入文件模块

文件

同步操作:按顺序执行 异步操作:同时执行不按顺序,执行时间不确定,在同时输入输出的情景下可能由于执行时间错位出错,同时过多的回调函数造成程序不易读

先导入fs模块 let fs=require('fs') 获取文件信息:文件大小,创建时间,修改时间等 同步获得文件信息:let stats=fs.statSync('路径') 异步获得文件信息:fs.stat('路径', (err, stats)=>{}) 将报错信息和文件信息作为实参传给回调函数

读写文件:read只读,write覆盖,append追加 同步:

let data=fs.readFileSync('路径'); //将文件内容读取到data变量中
fs.writeFileSync('路径', data); //用data中的内容覆盖文件,文件不存在则创建新文件
fs.appendFileSync('路径', data); //在文件末尾追加data中的内容,不存在则创建

异步:

fs.readFile('路径', (err, data)=>{}) //读取文件内容并作为实参传给回调函数
fs.writeFile('路径', data, (err)=>{}) //用data中的内容覆写文件,出现报错则将报错内容传给回调函数
fs.appendFile('路径', data, (err)=>{}) //在文件末尾追加data中的内容,出现报错则将报错内容传给回调函数

分段读写 方式: r读取;r+读写;rs同步读取;rs+同步读写;文件不存在则报错 w写入;w+读写;a追加;a+读写追加;文件不存在则创建 wx写入;wx+读写;ax追加;ax+读写追加;文件已存在则写入失败 同步读写:

let fs = require("fs");
let buf=new Buffer.alloc(10); //开辟指定大小的缓冲区,数据将先写入缓冲区
let file=fs.openSync('路径', '方式'); //以所需方式打开文件
let len=fs.readSync(file, buf, 0, buf.length, null); //读取文件,接收文件变量、缓冲区、指针偏移量、一次读取长度、开始读取位置(null则不动)
fs.writeSync(file2, buf, 0, len, null); //写入文件,接收文件变量、缓冲区、指针偏移量、一次写入长度、开始写入位置(null则不动)
fs.closeSync(file); //需要手动关闭文件

异步读写:

let fs = require("fs");
let buf=new Buffer.alloc(10); //开辟指定大小的缓冲区,数据将先写入缓冲区
let file=fs.open('路径', '方式', (err, fd)=>{}); //以所需方式打开文件,将报错和文件句柄传入回调函数
let len=fs.read(file, buf, 0, buf.length, null, (err, br, buf)=>{}); //读取文件,接收文件变量、缓冲区、指针偏移量、一次读取长度、开始读取位置(null则不动)、回调函数(报错,读取字节数,缓冲区)
fs.write(file2, buf, 0, len, null, (err, bw, buf)=>{}); //写入文件,接收文件变量、缓冲区、指针偏移量、一次写入长度、开始写入位置(null则不动)、回调函数(报错,写入字节数,缓冲区)
fs.close(file); //需要手动关闭文件

异步操作

Promise

由异步函数返回的指示异步操作所处状态的对象,有三种状态:pending未决断;fulfilld已决断;rejected已拒绝 适用于解决回调函数过多的情况 创建let promise=new Promise((reslove, reject)=>{}) 接收一个以reslove, reject为参数的回调函数,函数内调用reslove(参数),状态更新为fulfilled;调用reject(参数),状态更新为rejected;也可直接 let promise=Promise.resolve(参数) 返回给定状态的Promise对象 使用promise.then((参数)=>{}).catch((参数)=>{}).finally((参数)=>{}),根据状态执行,then方法的回调参数由reslove的参数提供,catch方法的回调参数由reject的参数提供,无论何种状态均会执行finally方法;一般用then方法作为前置异步函数执行完毕后的执行内容 all方法:接收Promise对象数组为参数,返回一个新的Promise对象;每个对象状态都是fulfilled,则新的Promise对象状态也为fulfilled,返回对象数组的参数结合的数组;有一个对象状态是rejected,则新的Promise对象状态也为rejected,返回第一个rejected对象的参数

let p1=Promise.resolve(c1);
let p2=Promise.resolve(c2);
let p3=Promise.resolve(c3);
Promise.all([p1, p2, p3]).then(res=>{}).catch(err=>{});

race方法:接收Promise对象数组为参数,返回一个新的Promise对象;新的Promise对象状态与数组中第一个改变状态的对象改变后的状态一致,返回该对象的参数

async和await:将Promise同步化

async定义函数表示函数内有异步操作,await只能在async内使用,后面跟一个返回Promise对象的表达式/函数;await将阻塞函数直到其后Promise对象调用reslove方法,返回resolve方法的参数

function func2(){
  函数体
  return new Promise((resolve)=>{reslove(参数)})
}
async function func1(){
  await func2()
}

TS

  1. 将js扩展出类型,运行前需要tsc .ts编译
  2. 原始数据类型:number,boolean,string,null,undefined,void,any
  3. 定义变量:let 变量: 类型=数据
  4. 强制类型转换:<类型>数据
  5. null、undefined只有一种取值(即该类型)
  6. void常作为函数返回值类型,定义void类型变量可用undefined赋值
  7. any为任意类型,可以赋值和被赋值任意类型的数据
  8. 联合类型:具有多种类型的变量,取值可以在多种类型中 let 变量=类型|类型|类型

函数

函数的参数和返回值也要明确类型 function 函数(参数: 类型): 返回值类型{},函数体内不能return具体的值 箭头函数 let 函数=(参数: 类型): 返回值类型=>{} 可选参数:只能在参数列表右侧,用 可选参数?: 类型 声明 默认参数:同样只能在参数列表右侧,用 参数: 类型=默认值 声明 剩余参数:只能是参数右侧最后一个,用 ...剩余参数 声明,接收所有剩余的实参形成数组

数组

定义时要明确类型,数组内元素须与数组类型一致 let 数组: 类型[]=[ , ] 多维数组let 数组: 类型[][]=[[ , ],[ , ]],多维数组可以不规则(内部数组的元素数无需保持一致)

接口

用接口定义对象类型

interface 对象{
  属性键: 类型
  可选属性键?: 类型
  readonly 只读属性键: 类型 //创建后无法修改
  函数: (参数: 类型): 返回值类型
}
let 变量: 对象={
  键: 值
  函数: (参数: 类型): 返回值类型=>{函数体}
  }

可以用 interface 子接口 extends 父接口 继承接口

元组:可存放不同类型的元素,用中括号定义 let 元组: [类型, 类型, 类型]=[ , , ]

枚举enum 枚举{选项=返回值},用 枚举.选项 输出对应返回值

定义时需要明确可见性 抽象类abstract:不能被实例化,只能被继承;包含抽象方法只能定义不能实现(无函数体),继承的子类必须实现父类抽象方法(补上函数体);非抽象方法可以在抽象类中实现,子类可以使用或覆盖 可见性:public默认可见;protected只能在类或子类内部访问;private只能在类内部访问,子类也不行

abstract class 父类{
  protected 属性: 类型 //可在类及子类内部访问
  private 属性: 类型 // 仅可在类内部访问
  public constructor(参数: 类型){
    public 属性: 类型
  }
  public abstract func(): void{} //抽象方法在抽象类中不实现,在子类中必须有实现
  public func2(): void{
    函数体
  } 
}

class 子类 extends 父类{
  protected 属性: 类型
  private 属性: 类型
  public constructor(参数: 类型){
    super() //调用父类构造器
    this.属性=数据
  }
  public abstract func(): void{
    函数体
  } //实现父类的抽象函数
}

let 实例: 类=new 类(参数)

泛型

将类型作为参数,为不同类型的输入执行相同的代码,可在函数、类、接口等使用 泛型函数function 函数<T>(参数: T):T{}函数<类型>(参数) 调用 可以有多个泛型参数:function 函数<T, U>(参数: T, 参数: U):[T, U]{} 泛型接口interface 接口<T>{属性键: T}let 对象: 接口<类型>={键: 值} 泛型类class 类<T>{属性: T}class 子类 extends 父类<类型>{} 继承 泛型约束:通过继承接口让输入的泛型必须是具有某一属性的类型

interface str{
  length: number
}
function a<T extends str>(s: T){
  console.log(str.length)
} //当类型不具有length属性时会报错

每日一题-最大三角形面积🟢

给你一个由 X-Y 平面上的点组成的数组 points ,其中 points[i] = [xi, yi] 。从其中取任意三个不同的点组成三角形,返回能组成的最大三角形的面积。与真实值误差在 10-5 内的答案将会视为正确答案

 

示例 1:

输入:points = [[0,0],[0,1],[1,0],[0,2],[2,0]]
输出:2.00000
解释:输入中的 5 个点如上图所示,红色的三角形面积最大。

示例 2:

输入:points = [[1,0],[0,0],[0,1]]
输出:0.50000

 

提示:

  • 3 <= points.length <= 50
  • -50 <= xi, yi <= 50
  • 给出的所有点 互不相同

为什么Bun.js能在3秒内启动一个完整的Web应用?

bun.js是什么?

Bun.js 是一个新兴的、高性能的 JavaScript 运行时(runtime),类似于 Node.js,但设计目标是从底层开始优化开发体验与性能。它由 Jarred Sumner 创建,使用 Zig 语言编写,内置了打包器、测试运行器、转译器、包管理器等功能,目标是“all-in-one”工具链。

快速安装

npm install bun -g

快速启动项目

my-app/
├── package.json
├── tsconfig.json
├── index.ts
└── test.test.ts

index.ts

const server = Bun.serve({
  port: 3000,
  fetch(req) {
    return new Response("Hello from Bun!");
  },
});

console.log(`Listening on http://localhost:${server.port}`);

🚀 bun.js 能做哪些事情呢?

1. JavaScript/TypeScript 执行

详细说明:Bun 直接执行 JS/TS 文件,无需额外编译

实际例子

// example.js
function calculateFibonacci(n) {
  if (n <= 1) return n;
  return calculateFibonacci(n - 1) + calculateFibonacci(n - 2);
}

console.log("斐波那契数列第10项:", calculateFibonacci(10));

// 异步操作示例
async function fetchData() {
  const response = await fetch("https://jsonplaceholder.typicode.com/users/1");
  const user = await response.json();
  console.log("用户信息:", user.name);
}

fetchData();
// example.ts
interface User {
  id: number;
  name: string;
  email: string;
}

class UserService {
  private users: User[] = [
    { id: 1, name: "Alice", email: "alice@example.com" },
    { id: 2, name: "Bob", email: "bob@example.com" }
  ];

  getUserById(id: number): User | undefined {
    return this.users.find(user => user.id === id);
  }

  getAllUsers(): User[] {
    return this.users;
  }
}

const service = new UserService();
console.log(service.getUserById(1));

2. 包管理器

详细说明:Bun 的包管理器比 npm/yarn 更快

实际例子

# package.json
{
  "name": "bun-project",
  "version": "1.0.0",
  "dependencies": {
    "zod": "^3.21.0"
  }
}
// 使用安装的包
import { z } from "zod";

const userSchema = z.object({
  name: z.string().min(2),
  age: z.number().min(0),
  email: z.string().email()
});

const userData = {
  name: "John",
  age: 30,
  email: "john@example.com"
};

try {
  const validUser = userSchema.parse(userData);
  console.log("验证成功:", validUser);
} catch (error) {
  console.log("验证失败:", error.errors);
}

3. 捆绑器 (Bundler)

详细说明:将多个文件打包成单个文件

实际例子

// src/math.js
export function add(a, b) {
  return a + b;
}

export function multiply(a, b) {
  return a * b;
}

// src/calculator.js
import { add, multiply } from './math.js';

export class Calculator {
  constructor() {
    this.history = [];
  }

  calculate(operation, a, b) {
    let result;
    switch(operation) {
      case 'add':
        result = add(a, b);
        break;
      case 'multiply':
        result = multiply(a, b);
        break;
      default:
        throw new Error('Unknown operation');
    }
    this.history.push({ operation, a, b, result });
    return result;
  }

  getHistory() {
    return this.history;
  }
}

// src/index.js
import { Calculator } from './calculator.js';

const calc = new Calculator();
console.log(calc.calculate('add', 5, 3)); // 8
console.log(calc.calculate('multiply', 4, 6)); // 24

// 构建命令: bun build src/index.js --outfile=dist/bundle.js

快速启动服务

4. HTTP 服务器

详细说明:内置高性能 HTTP 服务器

实际例子 - REST API

// server.js
import { serve } from "bun";

// 模拟数据库
let todos = [
  { id: 1, title: "学习 Bun.js", completed: false },
  { id: 2, title: "构建应用", completed: true }
];

let nextId = 3;

serve({
  port: 3000,
  async fetch(request) {
    const url = new URL(request.url);
    const path = url.pathname;
    const method = request.method;

    // GET /api/todos - 获取所有待办事项
    if (path === "/api/todos" && method === "GET") {
      return Response.json(todos);
    }

    // POST /api/todos - 创建待办事项
    if (path === "/api/todos" && method === "POST") {
      const newTodo = await request.json();
      const todo = {
        id: nextId++,
        ...newTodo,
        completed: false
      };
      todos.push(todo);
      return Response.json(todo, { status: 201 });
    }

    // PUT /api/todos/:id - 更新待办事项
    if (path.startsWith("/api/todos/") && method === "PUT") {
      const id = parseInt(path.split("/")[3]);
      const todoIndex = todos.findIndex(t => t.id === id);
    
      if (todoIndex === -1) {
        return new Response("Todo not found", { status: 404 });
      }

      const updates = await request.json();
      todos[todoIndex] = { ...todos[todoIndex], ...updates };
      return Response.json(todos[todoIndex]);
    }

    // DELETE /api/todos/:id - 删除待办事项
    if (path.startsWith("/api/todos/") && method === "DELETE") {
      const id = parseInt(path.split("/")[3]);
      const todoIndex = todos.findIndex(t => t.id === id);
    
      if (todoIndex === -1) {
        return new Response("Todo not found", { status: 404 });
      }

      todos.splice(todoIndex, 1);
      return new Response("Todo deleted", { status: 200 });
    }

    // 静态文件服务
    if (path === "/" || path === "/index.html") {
      return new Response(`
        <!DOCTYPE html>
        <html>
        <head><title>Todo App</title></head>
        <body>
          <h1>Todo List</h1>
          <div id="app"></div>
          <script>
            fetch('/api/todos')
              .then(r => r.json())
              .then(todos => {
                document.getElementById('app').innerHTML = 
                  '<ul>' + todos.map(t => '<li>' + t.title + '</li>').join('') + '</ul>';
              });
          </script>
        </body>
        </html>
      `, {
        headers: { "Content-Type": "text/html" }
      });
    }

    return new Response("Not Found", { status: 404 });
  }
});

console.log("服务器运行在 http://localhost:3000");

5. WebSocket 支持

详细说明:实现实时双向通信

实际例子 - 聊天应用

// chat-server.js
import { serve } from "bun";

const clients = new Set();
const chatHistory = [];

const server = serve({
  port: 3000,
  fetch(req, server) {
    // HTTP 请求:返回 HTML 页面
    if (req.headers.get("upgrade") !== "websocket") {
      return new Response(`
        <!DOCTYPE html>
        <html>
        <head>
          <title>实时聊天室</title>
          <style>
            #messages { height: 300px; overflow-y: scroll; border: 1px solid #ccc; }
            #messageInput { width: 70%; }
          </style>
        </head>
        <body>
          <h1>实时聊天室</h1>
          <div id="messages"></div>
          <input type="text" id="messageInput" placeholder="输入消息...">
          <button onclick="sendMessage()">发送</button>

          <script>
            const ws = new WebSocket('ws://localhost:3000');
            const messages = document.getElementById('messages');
            const messageInput = document.getElementById('messageInput');

            ws.onopen = function() {
              messages.innerHTML += '<div>连接已建立</div>';
            };

            ws.onmessage = function(event) {
              const data = JSON.parse(event.data);
              messages.innerHTML += '<div><strong>' + data.user + ':</strong> ' + data.message + '</div>';
              messages.scrollTop = messages.scrollHeight;
            };

            function sendMessage() {
              const message = messageInput.value.trim();
              if (message) {
                ws.send(JSON.stringify({
                  user: 'User' + Date.now(),
                  message: message
                }));
                messageInput.value = '';
              }
            }

            messageInput.addEventListener('keypress', function(e) {
              if (e.key === 'Enter') {
                sendMessage();
              }
            });
          </script>
        </body>
        </html>
      `, {
        headers: { "Content-Type": "text/html" }
      });
    }

    // WebSocket 连接
    const websocket = server.upgrade(req);
    if (websocket) {
      return websocket;
    }

    return new Response("Expected WebSocket upgrade", { status: 426 });
  },

  websocket: {
    open(ws) {
      console.log("客户端连接:", ws.data);
      clients.add(ws);
    
      // 发送历史消息
      for (const message of chatHistory) {
        ws.send(JSON.stringify(message));
      }
    },

    message(ws, message) {
      const data = JSON.parse(message);
      chatHistory.push(data);
    
      // 广播消息给所有客户端
      for (const client of clients) {
        if (client !== ws) {
          client.send(JSON.stringify(data));
        }
      }
    },

    close(ws) {
      console.log("客户端断开连接");
      clients.delete(ws);
    }
  }
});

console.log("聊天服务器运行在 http://localhost:3000");

📁 文件系统操作详解

6. 文件读写

详细说明:高效读写文件操作

实际例子 - 配置管理器

// config-manager.js
class ConfigManager {
  constructor(configFile = "./config.json") {
    this.configFile = configFile;
  }

  async readConfig() {
    try {
      const file = Bun.file(this.configFile);
      if (await file.exists()) {
        const content = await file.json();
        return content;
      }
      return {};
    } catch (error) {
      console.log("配置文件不存在,创建默认配置");
      return this.createDefaultConfig();
    }
  }

  async writeConfig(config) {
    await Bun.write(this.configFile, JSON.stringify(config, null, 2));
  }

  async updateConfig(key, value) {
    const config = await this.readConfig();
    config[key] = value;
    await this.writeConfig(config);
  }

  async createDefaultConfig() {
    const defaultConfig = {
      server: {
        port: 3000,
        host: "localhost"
      },
      database: {
        url: "sqlite://db.sqlite",
        maxConnections: 10
      },
      logging: {
        level: "info",
        file: "app.log"
      }
    };
  
    await this.writeConfig(defaultConfig);
    return defaultConfig;
  }
}

// 使用示例
const configManager = new ConfigManager();

async function main() {
  const config = await configManager.readConfig();
  console.log("当前配置:", config);

  // 更新配置
  await configManager.updateConfig("server.port", 8080);
  console.log("端口已更新为 8080");

  // 读取日志文件
  const logFile = Bun.file("./app.log");
  if (await logFile.exists()) {
    const logContent = await logFile.text();
    console.log("日志内容:", logContent.substring(0, 100) + "...");
  } else {
    console.log("日志文件不存在");
  }
}

main();

7. 文件监听

详细说明:监控文件变化

实际例子 - 代码热重载

// file-watcher.js
class HotReloader {
  constructor(watchPaths = ["./src"], onChangeCallback) {
    this.watchPaths = watchPaths;
    this.onChangeCallback = onChangeCallback;
    this.lastChanges = [];
  }

  async start() {
    const watcher = Bun.watch({
      paths: this.watchPaths,
      ignore: ["node_modules", "dist", ".git"],
      onChange: async (files) => {
        console.log("检测到文件变化:", files);
      
        for (const file of files) {
          const stat = await Bun.file(file).stat();
          const changeInfo = {
            file,
            timestamp: new Date(stat.mtime),
            type: this.getFileType(file)
          };
        
          this.lastChanges.push(changeInfo);
          console.log(`文件类型: ${changeInfo.type}, 修改时间: ${changeInfo.timestamp}`);
        }

        if (this.onChangeCallback) {
          await this.onChangeCallback(files);
        }
      }
    });

    console.log("开始监听文件变化...");
  }

  getFileType(filename) {
    if (filename.endsWith('.js') || filename.endsWith('.ts')) return 'script';
    if (filename.endsWith('.css')) return 'stylesheet';
    if (filename.endsWith('.html')) return 'html';
    if (filename.endsWith('.json')) return 'config';
    return 'other';
  }

  getRecentChanges(hours = 1) {
    const cutoffTime = new Date(Date.now() - hours * 60 * 60 * 1000);
    return this.lastChanges.filter(change => change.timestamp > cutoffTime);
  }
}

// 使用示例
const reloader = new HotReloader(
  ["./src"],
  async (changedFiles) => {
    console.log("文件变化,执行构建...");
  
    // 模拟构建过程
    for (const file of changedFiles) {
      if (file.endsWith('.js')) {
        console.log(`重新编译 JavaScript 文件: ${file}`);
        // 这里可以调用构建命令
      }
    }
  
    console.log("构建完成,刷新浏览器...");
  }
);

reloader.start();

🔧 系统工具详解

8. 子进程执行

详细说明:执行系统命令

实际例子 - 自动化部署脚本

// deploy-script.js
class Deployer {
  constructor(projectDir = "./") {
    this.projectDir = projectDir;
  }

  async runCommand(command, args) {
    console.log(`执行命令: ${command} ${args.join(' ')}`);
  
    const process = Bun.spawn([command, ...args], {
      cwd: this.projectDir,
      stdout: "pipe",
      stderr: "pipe"
    });

    const stdout = await new Response(process.stdout).text();
    const stderr = await new Response(process.stderr).text();
    const exitCode = await process.exited;

    return { stdout, stderr, exitCode };
  }

  async gitStatus() {
    const result = await this.runCommand("git", ["status", "--porcelain"]);
    return result.stdout.trim();
  }

  async gitCommit(message) {
    await this.runCommand("git", ["add", "."]);
    const result = await this.runCommand("git", ["commit", "-m", message]);
    return result;
  }

  async buildProject() {
    console.log("开始构建项目...");
    const result = await this.runCommand("bun", ["run", "build"]);
  
    if (result.exitCode !== 0) {
      throw new Error(`构建失败: ${result.stderr}`);
    }
  
    console.log("构建成功!");
    return result.stdout;
  }

  async deploy() {
    try {
      // 检查是否有未提交的更改
      const status = await this.gitStatus();
      if (status) {
        console.log("检测到未提交的更改,自动提交...");
        await this.gitCommit(`Auto deploy ${new Date().toISOString()}`);
      }

      // 构建项目
      await this.buildProject();

      // 推送到远程仓库
      const pushResult = await this.runCommand("git", ["push", "origin", "main"]);
      if (pushResult.exitCode !== 0) {
        throw new Error(`推送失败: ${pushResult.stderr}`);
      }

      console.log("部署成功!");
    } catch (error) {
      console.error("部署失败:", error.message);
      throw error;
    }
  }
}

// 使用示例
const deployer = new Deployer();
deployer.deploy().catch(console.error);

9. 测试框架

详细说明:内置测试框架

实际例子 - 完整测试套件

// tests/user.test.js
import { expect, test, describe, beforeAll, afterAll } from "bun:test";

// 被测试的类
class UserService {
  constructor() {
    this.users = [
      { id: 1, name: "Alice", email: "alice@example.com", age: 25 },
      { id: 2, name: "Bob", email: "bob@example.com", age: 30 }
    ];
  }

  getUserById(id) {
    return this.users.find(user => user.id === id);
  }

  getUserByEmail(email) {
    return this.users.find(user => user.email === email);
  }

  createUser(userData) {
    const newUser = {
      id: this.users.length + 1,
      ...userData
    };
    this.users.push(newUser);
    return newUser;
  }

  validateUser(user) {
    if (!user.name || user.name.length < 2) {
      throw new Error("Name must be at least 2 characters");
    }
    if (!user.email || !user.email.includes("@")) {
      throw new Error("Invalid email");
    }
    return true;
  }
}

// 测试套件
describe("UserService", () => {
  let service;

  beforeAll(() => {
    service = new UserService();
  });

  test("should get user by id", () => {
    const user = service.getUserById(1);
    expect(user).toBeDefined();
    expect(user.name).toBe("Alice");
    expect(user.email).toBe("alice@example.com");
  });

  test("should return undefined for non-existent user", () => {
    const user = service.getUserById(999);
    expect(user).toBeUndefined();
  });

  test("should get user by email", () => {
    const user = service.getUserByEmail("bob@example.com");
    expect(user).toBeDefined();
    expect(user.name).toBe("Bob");
  });

  test("should create new user", () => {
    const newUser = service.createUser({
      name: "Charlie",
      email: "charlie@example.com",
      age: 28
    });

    expect(newUser.id).toBe(3);
    expect(newUser.name).toBe("Charlie");
    expect(service.getUserById(3)).toBeDefined();
  });

  test("should validate user with valid data", () => {
    const validUser = { name: "Valid", email: "valid@test.com" };
    expect(() => service.validateUser(validUser)).not.toThrow();
  });

  test("should throw error for invalid user name", () => {
    const invalidUser = { name: "A", email: "test@example.com" };
    expect(() => service.validateUser(invalidUser))
      .toThrow("Name must be at least 2 characters");
  });

  test("should throw error for invalid email", () => {
    const invalidUser = { name: "Valid", email: "invalid-email" };
    expect(() => service.validateUser(invalidUser))
      .toThrow("Invalid email");
  });

  test("should handle async operations", async () => {
    // 模拟异步操作
    const result = await new Promise(resolve => {
      setTimeout(() => resolve("async result"), 100);
    });
    expect(result).toBe("async result");
  });
});

// 性能测试
test("performance test", async () => {
  const start = performance.now();

  // 执行一些操作
  for (let i = 0; i < 1000; i++) {
    const user = new UserService();
    user.getUserById(1);
  }

  const end = performance.now();
  const duration = end - start;

  console.log(`1000次操作耗时: ${duration}ms`);
  expect(duration).toBeLessThan(100); // 应该在100ms内完成
});

// 运行测试: bun test

📦 生态系统集成详解

10. Node.js 兼容

详细说明:与 Node.js 生态系统兼容

实际例子 - 文件处理工具

// file-processor.js
import fs from "fs";
import path from "path";
import { createHash } from "crypto";

class FileProcessor {
  constructor() {
    this.stats = {
      processed: 0,
      errors: 0,
      totalSize: 0
    };
  }

  async processDirectory(dirPath) {
    const files = await fs.promises.readdir(dirPath);
  
    for (const file of files) {
      const filePath = path.join(dirPath, file);
      const stat = await fs.promises.stat(filePath);
    
      if (stat.isDirectory()) {
        await this.processDirectory(filePath);
      } else if (this.isTextFile(file)) {
        await this.processFile(filePath, stat.size);
      }
    }
  
    return this.stats;
  }

  isTextFile(filename) {
    const extensions = ['.js', '.ts', '.json', '.txt', '.md', '.html', '.css'];
    return extensions.some(ext => filename.endsWith(ext));
  }

  async processFile(filePath, size) {
    try {
      const content = await fs.promises.readFile(filePath, 'utf8');
      const hash = createHash('md5').update(content).digest('hex');
    
      this.stats.processed++;
      this.stats.totalSize += size;
    
      console.log(`处理文件: ${filePath}`);
      console.log(`大小: ${size} bytes`);
      console.log(`MD5: ${hash.substring(0, 8)}...`);
      console.log('---');
    
    } catch (error) {
      this.stats.errors++;
      console.error(`处理文件错误 ${filePath}:`, error.message);
    }
  }

  generateReport() {
    return `
      文件处理报告:
      - 处理文件数: ${this.stats.processed}
      - 错误数: ${this.stats.errors}
      - 总大小: ${this.formatBytes(this.stats.totalSize)}
    `;
  }

  formatBytes(bytes) {
    if (bytes === 0) return '0 Bytes';
    const k = 1024;
    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  }
}

// 使用示例
async function main() {
  const processor = new FileProcessor();
  const stats = await processor.processDirectory('./src');
  console.log(processor.generateReport());
}

main().catch(console.error);

11. 前端开发

详细说明:完整的前端开发工具链

实际例子 - 博客构建系统

// blog-builder.js
import { glob } from "bun";

class BlogBuilder {
  constructor() {
    this.postsDir = "./posts";
    this.outputDir = "./dist";
  }

  async build() {
    console.log("开始构建博客...");
  
    // 清理输出目录
    await this.cleanOutputDir();
  
    // 读取所有 Markdown 文件
    const markdownFiles = await glob("**/*.md", this.postsDir);
    const posts = [];
  
    for (const file of markdownFiles) {
      const content = await Bun.file(file).text();
      const post = this.parseMarkdown(content);
      posts.push(post);
    }
  
    // 生成文章页面
    for (const post of posts) {
      await this.generatePostPage(post);
    }
  
    // 生成首页
    await this.generateIndex(posts);
  
    // 生成 RSS 订阅
    await this.generateRSS(posts);
  
    console.log(`构建完成! 生成了 ${posts.length} 篇文章`);
  }

  parseMarkdown(content) {
    const lines = content.split('\n');
    let frontmatter = {};
    let body = '';
    let inFrontmatter = false;
  
    for (let i = 0; i < lines.length; i++) {
      if (lines[i].trim() === '---') {
        if (!inFrontmatter) {
          inFrontmatter = true;
        } else {
          body = lines.slice(i + 1).join('\n');
          break;
        }
      } else if (inFrontmatter) {
        const [key, value] = lines[i].split(':');
        if (key && value) {
          frontmatter[key.trim()] = value.trim().replace(/"/g, '');
        }
      }
    }
  
    return {
      ...frontmatter,
      content: this.convertMarkdownToHTML(body),
      slug: this.generateSlug(frontmatter.title)
    };
  }

  convertMarkdownToHTML(markdown) {
    // 简单的 Markdown 转 HTML
    return markdown
      .replace(/^### (.*$)/gim, '<h3>$1</h3>')
      .replace(/^## (.*$)/gim, '<h2>$1</h2>')
      .replace(/^# (.*$)/gim, '<h1>$1</h1>')
      .replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
      .replace(/\*(.*)\*/gim, '<em>$1</em>')
      .replace(/`(.*?)`/gim, '<code>$1</code>')
      .replace(/\n\n/gim, '<br><br>');
  }

  generateSlug(title) {
    return title
      .toLowerCase()
      .replace(/[^\w\s-]/g, '')
      .replace(/[\s_-]+/g, '-')
      .replace(/^-+|-+$/g, '');
  }

  async generatePostPage(post) {
    const html = `
      <!DOCTYPE html>
      <html>
      <head>
        <title>${post.title}</title>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <style>
          body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
          h1 { color: #333; }
          .meta { color: #666; font-size: 0.9em; }
        </style>
      </head>
      <body>
        <h1>${post.title}</h1>
        <div class="meta">发布于 ${post.date} | 作者: ${post.author}</div>
        <div class="content">${post.content}</div>
        <a href="/">← 返回首页</a>
      </body>
      </html>
    `;
  
    const outputPath = `${this.outputDir}/posts/${post.slug}.html`;
    await Bun.write(outputPath, html);
  }

  async generateIndex(posts) {
    const postsList = posts
      .sort((a, b) => new Date(b.date) - new Date(a.date))
      .map(post => `
        <div style="margin-bottom: 30px;">
          <h2><a href="posts/${post.slug}.html">${post.title}</a></h2>
          <div style="color: #666; font-size: 0.9em;">发布于 ${post.date} | 作者: ${post.author}</div>
          <p>${post.excerpt || post.content.substring(0, 100) + '...'}</p>
        </div>
      `)
      .join('');
  
    const html = `
      <!DOCTYPE html>
      <html>
      <head>
        <title>我的博客</title>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <style>
          body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
          h1 { color: #333; }
          a { color: #007acc; text-decoration: none; }
          a:hover { text-decoration: underline; }
        </style>
      </head>
      <body>
        <h1>我的博客</h1>
        <div>${postsList}</div>
      </body>
      </html>
    `;
  
    await Bun.write(`${this.outputDir}/index.html`, html);
  }

  async generateRSS(posts) {
    const rss = `<?xml version="1.0" encoding="UTF-8"?>
    <rss version="2.0">
      <channel>
        <title>我的博客</title>
        <link>http://localhost:3000</link>
        <description>技术博客</description>
        ${posts.map(post => `
          <item>
            <title>${post.title}</title>
            <link>http://localhost:3000/posts/${post.slug}.html</link>
            <description>${post.excerpt || post.content.substring(0, 100)}</description>
            <pubDate>${new Date(post.date).toUTCString()}</pubDate>
          </item>
        `).join('')}
      </channel>
    </rss>`;
  
    await Bun.write(`${this.outputDir}/rss.xml`, rss);
  }

  async cleanOutputDir() {
    try {
      const outputDir = Bun.file(this.outputDir);
      if (await outputDir.exists()) {
        await Bun.spawn(["rm", "-rf", this.outputDir]);
      }
      await Bun.mkdir(this.outputDir);
      await Bun.mkdir(`${this.outputDir}/posts`);
    } catch (error) {
      console.log("清理输出目录:", error.message);
    }
  }
}

// 使用示例
const builder = new BlogBuilder();
builder.build().catch(console.error);

这些例子展示了 Bun.js 在实际项目中的完整应用,从简单的脚本到复杂的 Web 应用都有涉及。

从头看 vite 源码 - 调试

前言

准备开一个系列,记录学习 vite 源码的过程。

首先准备的是 vite 源码调试环境。

如何调试

vite 对调试的支持非常好。这里看的 vite 源码版本为:7.1.7,打包工具为 rolldown

首先 clone 项目。

git clone https://github.com/vitejs/vite.git

vite 采用 monorepo 架构,使用 pnpm 作为包管理工具,这个版本使用 rolldown 作为打包工具。

在根目录下,有两个文件夹:

  • packages
  • playground

packages 中存放着 vite 相关的包,包括源代码。

playground 中则是存放项目示例。

先说答案,如何调试。

  1. 进入 packages/vite
  2. 执行 npm run dev
  3. 进入 playground 文件夹,找到一个感兴趣的项目
  4. 进入并执行 pnpm run debug
  5. 打开谷歌浏览器
  6. 进入 chrome://inspect/#devices
  7. 找到 localhost:9229 (v22.19.0) 并点击 inspect 按钮
  8. 弹出调试窗口

说明

2. 执行 npm run dev

这一步主要是执行 npm run dev 命令。这个命令其实是 premove dist && pnpm build-bundle -w -s

可以看到这个命令由两部分组成,一部分删除 dist 文件夹。后面一部分则是使用 watch 模式打包。

4. 进入并执行 pnpm run debug

这个命令其实是"node --inspect-brk ../../packages/vite/bin/vite",主要作用是进入调试模式。

node --inspect-brkNode.js 的调试参数。

  • --inspect
    启动 Node inspector(调试协议),监听默认端口 9229,允许你用 Chrome DevTools / VS Code attach 调试。

  • --inspect-brk
    --inspect 的区别是:在程序的第一行就暂停,等你连接上调试器后再继续执行。

同时在 vite 脚手架代码中也对调试命令做了处理。

const inspector = await import('node:inspector').then((r) => r.default)
  const session = (global.__vite_profile_session = new inspector.Session())
  session.connect()
  session.post('Profiler.enable', () => {
    session.post('Profiler.start', start)
  })

5. 打开谷歌浏览器

截屏2025-09-26 22.24.37.png

8. 弹出调试窗口

截屏2025-09-26 22.26.59.png

ES2025:10个让你眼前一亮的JavaScript新特性

ES2025 新特性详解

ECMAScript 2025(ES2025)是JavaScript语言的最新标准版本,带来了多项重要的新特性和改进。以下是主要的新特性概述。

1. 原始类型扩展

BigInt 原生支持


// ES2025 中 BigInt 的增强支持

const bigNum = 123n;

console.log(typeof bigNum); // "bigint"

Symbol.prototype.description 属性


// 现在可以获取 Symbol 的描述信息

const sym = Symbol('description');

console.log(sym.description); // "description"

2. 数组方法增强

Array.prototype.at()


const arr = [12345];

console.log(arr.at(0));    // 1 (传统方式: arr[0])

console.log(arr.at(-1));   // 5 (传统方式: arr[arr.length - 1])

console.log(arr.at(-2));   // 4

Array.prototype.toReversed()


const arr = [123];

const reversed = arr.toReversed();

console.log(reversed); // [3, 2, 1]

console.log(arr);      // [1, 2, 3] (原数组不变)

Array.prototype.toSorted()


const arr = [31415];

const sorted = arr.toSorted((a, b) => a - b);

console.log(sorted); // [1, 1, 3, 4, 5]

console.log(arr);    // [3, 1, 4, 1, 5] (原数组不变)

3. 字符串方法增强

String.prototype.at()


const str = "Hello World";

console.log(str.at(0));   // "H"

console.log(str.at(-1));  // "d"

String.prototype.toReversed()


const str = "hello";

const reversed = str.toReversed();

console.log(reversed); // "olleh"

4. 对象方法增强

Object.hasOwn()


const obj = { name'John'age30 };

  
  


// 更安全的属性检查

console.log(Object.hasOwn(obj, 'name'));  // true

console.log(Object.hasOwn(obj, 'toString')); // false (继承属性)

Object.fromEntries() 改进


const entries = [['a'1], ['b'2]];

const obj = Object.fromEntries(entries);

console.log(obj); // { a: 1, b: 2 }

5. 函数增强

函数参数默认值优化


function greet(name = 'World', greeting = 'Hello') {

    return `${greeting}${name}!`;

}

  
  


console.log(greet());              // "Hello, World!"

console.log(greet('Alice'));       // "Hello, Alice!"

console.log(greet('Bob''Hi'));   // "Hi, Bob!"

6. Promise 增强

Promise.allSettled() 的使用


const promises = [

    Promise.resolve(1),

    Promise.reject(new Error('error')),

    Promise.resolve(3)

];

  
  


Promise.allSettled(promises).then(results => {

    results.forEach((result, index) => {

        if (result.status === 'fulfilled') {

            console.log(`Promise ${index} resolved with:`, result.value);

        } else {

            console.log(`Promise ${index} rejected with:`, result.reason);

        }

    });

});

7. 正则表达式增强

Unicode 属性转义


// 匹配所有 Unicode 字母

const letterPattern = /\p{Letter}/u;

console.log(letterPattern.test('A')); // true

console.log(letterPattern.test('中')); // true

  
  


// 匹配数字

const digitPattern = /\p{Number}/u;

console.log(digitPattern.test('5')); // true

8. 模块系统增强

动态导入增强


// 更灵活的动态导入

async function loadModule(modulePath) {

    const module = await import(modulePath);

    return module.default || module;

}

  
  


// 使用示例

loadModule('./utils.js').then(utils => {

    utils.doSomething();

});

9. 类和继承增强

私有字段和方法


class MyClass {

    #privateField = 'private';

    

    #privateMethod() {

        return 'private method called';

    }

    

    publicMethod() {

        return this.#privateField + ' ' + this.#privateMethod();

    }

}

  
  


const instance = new MyClass();

console.log(instance.publicMethod()); // "private private method called"

10. 实用工具增强

严格模式下的改进


'use strict';

  
  


// 更严格的错误处理

function strictFunction() {

    // 在严格模式下,某些操作会抛出错误

    // 如:删除变量、重复参数等

}

11. 性能优化

更好的内存管理


// JavaScript 引擎现在有更好的垃圾回收机制

// 例如:更快的 WeakMap 和 WeakSet 处理

const weakMap = new WeakMap();

const obj = {};

weakMap.set(obj, 'value');

// 当 obj 被垃圾回收时,weakMap 中的条目也会自动清除

12. 实际应用示例

综合使用多个新特性


// 使用现代 JavaScript 特性创建一个实用工具类

class DataProcessor {

    static processData(data) {

        // 使用 Array.prototype.at() 和 toReversed()

        const lastItem = data.at(-1);

        const reversed = data.toReversed();

        

        // 使用 Object.hasOwn() 安全检查属性

        const hasName = Object.hasOwn(lastItem, 'name');

        

        // 使用字符串方法

        const processed = reversed.map(item => 

            typeof item === 'string' ? item.toReversed() : item

        );

        

        return {

            original: data,

            processed,

            lastItem,

            hasName

        };

    }

}

  
  


// 使用示例

const data = [

    { name'Alice'age25 },

    { name'Bob'age30 },

    'hello'

];

  
  


const result = DataProcessor.processData(data);

console.log(result);

兼容性考虑

浏览器支持情况


// 检查浏览器支持情况

if (typeof Array.prototype.at === 'function') {

    console.log('Array.at() is supported');

} else {

    console.log('Array.at() is not supported');

}

  
  


// polyfill 示例

if (!Array.prototype.at) {

    Array.prototype.at = function(index) {

        const arr = this;

        const len = arr.length;

        const actualIndex = index < 0 ? len + index : index;

        return arr[actualIndex];

    };

}

小结

ES2025 主要增强了:

  1.  数组和字符串方法:添加了更多实用的不可变方法

  2.  对象操作:提供了更安全的属性检查方法

  3.  正则表达式:增强了 Unicode 支持

  4.  模块系统:改善了动态导入体验

  5.  性能优化:提升了引擎性能和内存管理

这些新特性使 JavaScript 开发更加现代化、安全和高效。建议在项目中逐步采用这些新特性以提升代码质量。

❌