阅读视图

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

封装了一个vue版本 Pag组件

1. 组件介绍

PAGAnimation 是一个基于 libpag 实现的 Vue 3 动画组件,用于在网页中播放高性能的 PAG (Portable Animated Graphics) 格式动画。组件封装了 PAG 初始化、加载和播放逻辑,提供了简单易用的 API,能够轻松集成高质量的动画效果到 Vue 项目中。

2. 核心功能

2.1 基础动画播放

  • 支持单个 PAG 文件的加载和播放
  • 自动管理 PAG 实例和资源生命周期
  • 支持 Canvas 尺寸自定义

2.2 高级动画特性

  • 组合动画:支持多个 PAG 文件叠加组合播放
  • 动态替换图片:可在运行时替换 PAG 动画中的指定图片资源
  • 延迟播放:支持设置动画延迟开始时间
  • 响应式控制:通过 visible 属性控制动画的显示与隐藏

2.3 性能优化

  • 单例模式:全局共享 PAG 实例,减少资源占用
  • 缓存机制:对加载的 PAG 文件进行缓存,避免重复加载
  • 资源自动销毁:在组件卸载或隐藏时自动销毁资源,防止内存泄漏

3. 技术实现原理

3.1 核心工具函数 (utils/pag.ts)

PAGInit

  • 初始化 libpag 库,加载 WebAssembly 二进制文件
  • 采用单例模式,确保全局只有一个 PAG 实例
  • 支持 file:// 协议,方便开发调试

loadPagFile

  • 加载 PAG 文件并缓存
  • 自动检查缓存文件状态,销毁的文件会重新加载
  • 内部调用 loadFile 获取文件对象

loadFile

  • 加载 PAG 文件资源
  • 使用 XMLHttpRequest 兼容 file:// 协议
  • 缓存 File 对象,提高性能

3.2 组件工作流程

  1. 初始化阶段:组件挂载时,调用 PAGInit 初始化 PAG 实例
  2. 加载阶段:根据 pagNamecomposition 属性加载对应的 PAG 文件
  3. 渲染阶段:将 PAG 文件渲染到 Canvas 元素上
  4. 播放阶段:调用 PAGView 的 play() 方法开始播放动画
  5. 更新阶段:监听 props 变化,重新初始化或更新动画
  6. 销毁阶段:组件卸载或隐藏时,销毁 PAGView 实例,释放资源

4. 使用方法

4.1 安装依赖

npm install libpag

4.2 导入组件

<template>
  <PagAnimation pagName="example.pag" :width="300" :height="300" />
</template>

<script setup lang="ts">
import PagAnimation from '@/components/PagAnimation.vue';
</script>

4.3 基础用法

单个 PAG 文件播放

<PagAnimation pagName="example.pag" :width="300" :height="300" />

带延迟的 PAG 文件播放

<PagAnimation pagName="example.pag" :delay="1000" />

动态替换图片

<PagAnimation 
  pagName="example.pag" 
  :replaceIndex="0" 
  replaceImage="https://example.com/image.jpg" 
/>

组合动画

<PagAnimation 
  :composition="{
    pagNames: ['background.pag', 'foreground.pag'],
    width: 1080,
    height: 1920,
    layers: [
      { pagName: 'foreground.pag', replaceIndex: 0, replaceImage: 'https://example.com/image.jpg' }
    ]
  }" 
/>

4.4 Props 说明

参数名 类型 默认值 说明
pagName string - PAG 文件名
width number 199 画布宽度
height number 199 画布高度
replaceIndex number - 要替换的图片索引
replaceImage string - 替换图片的 URL
visible boolean true 控制动画显示/隐藏
delay number 0 延迟开始动画的时间(毫秒)
composition object - 组合动画配置

composition 对象结构

{
  pagNames: string[];        // 多个 PAG 文件名
  width?: number;            // 组合画布宽度
  height?: number;           // 组合画布高度
  layers?: Array<{
    pagName: string;         // 图层 PAG 文件名
    replaceIndex?: number;   // 替换图片索引
    replaceImage?: string;   // 替换图片 URL
  }>;                        // 图层配置
}

5. 组件优点

5.1 高性能

  • 基于 WebAssembly 实现,动画播放流畅
  • 单例模式和缓存机制减少资源占用
  • 自动销毁资源,避免内存泄漏

5.2 易用性

  • 封装了复杂的 PAG 初始化和播放逻辑
  • 提供简洁的 API,易于集成到项目中
  • 支持 Vue 3 的 Composition API

5.3 灵活性

  • 支持单个和组合动画
  • 支持动态替换图片
  • 支持延迟播放
  • 响应式设计,参数变化时自动更新

5.4 兼容性

  • 支持 file:// 协议,方便开发调试
  • 基于标准 Canvas API,浏览器兼容性好

5.5 可维护性

  • 代码结构清晰,易于扩展
  • 类型安全,使用 TypeScript 开发
  • 良好的错误处理机制

6. 注意事项

  1. PAG 文件路径:确保 PAG 文件存放在正确的位置(默认是 /pag/./pag/ 目录)
  2. 图片替换索引:替换图片时需要知道正确的索引,可通过 PAG 文件编辑工具查看
  3. 组合动画图层顺序:组合动画的图层顺序与 pagNames 数组顺序一致
  4. 大文件加载:大尺寸的 PAG 文件可能会影响加载速度,建议优化 PAG 文件大小
  5. 内存管理:虽然组件会自动销毁资源,但频繁切换动画时仍需注意性能
  6. 跨域问题:替换的图片需要支持 CORS,否则会加载失败

7. 最佳实践

7.1 预加载常用动画

<template>
  <!-- 预加载动画 -->
  <PagAnimation 
    v-if="false" 
    pagName="common.pag" 
  />
  <!-- 实际使用的动画 -->
  <PagAnimation 
    v-if="showAnimation" 
    pagName="common.pag" 
  />
</template>

7.2 动态控制动画播放

<template>
  <div>
    <button @click="toggleAnimation">
      {{ showAnimation ? '隐藏动画' : '显示动画' }}
    </button>
    <PagAnimation 
      v-model:visible="showAnimation" 
      pagName="example.pag" 
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import PagAnimation from '@/components/PagAnimation.vue';

const showAnimation = ref(false);

const toggleAnimation = () => {
  showAnimation.value = !showAnimation.value;
};
</script>

7.3 使用组合动画创建复杂效果

<template>
  <PagAnimation 
    :composition="composition" 
    :width="1080" 
    :height="1920" 
  />
</template>

<script setup lang="ts">
import { ref } from 'vue';
import PagAnimation from '@/components/PagAnimation.vue';

const composition = ref({
  pagNames: ['background.pag', 'character.pag', 'effects.pag'],
  width: 1080,
  height: 1920,
  layers: [
    {
      pagName: 'character.pag',
      replaceIndex: 0,
      replaceImage: 'https://example.com/character.jpg'
    }
  ]
});
</script>

组件源码

<template>
  <div class="pag-animation-container">
    <canvas
      v-if="internalVisible"
      ref="canvasRef"
      class="pag-canvas"
      :width="width"
      :height="height"
    />
  </div>
</template>

<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue';
import { PAGInit, loadPagFile } from '@/utils/pag';

interface Props {
  pagName: string;
  width?: number;
  height?: number;
  replaceIndex?: number;
  replaceImage?: string;
  visible?: boolean;
  delay?: number; // 延迟开始动画的时间(毫秒)
  // 组合功能配置
  composition?: {
    pagNames: string[]; // 多个 PAG 文件名
    width?: number; // 组合画布宽度
    height?: number; // 组合画布高度
    layers?: Array<{
      pagName: string;
      replaceIndex?: number;
      replaceImage?: string;
    }>; // 图层配置
  };
}

const props = withDefaults(defineProps<Props>(), {
  width: 199,
  height: 199,
  visible: true,
  delay: 0, // 默认不延迟
});

const canvasRef = ref<HTMLCanvasElement | null>(null);
let PAG: any = null;
let pagFile: any = null;
let pagView: any = null;

const internalVisible = ref(props.visible);

// 加载图片
const loadImage = async (src: string) => {
  const img = new Image();
  img.crossOrigin = 'anonymous';
  img.src = src;
  try {
    await img.decode();
    return img;
  } catch (err) {
    console.error('Load image failed:', src, err);
    return null;
  }
};

// 加载单个 PAG 文件并应用替换图片
const loadPagFileWithReplace = async (pagName: string, replaceIndex?: number, replaceImage?: string) => {
  if (!PAG) return null;
  
  const pagFile = await loadPagFile(pagName);
  
  if (replaceImage && replaceIndex !== undefined) {
    const img = await loadImage(replaceImage);
    if (img) {
      const pagImg = PAG.PAGImage.fromSource(img);
      pagFile.replaceImage(replaceIndex, pagImg);
    }
  }
  
  return pagFile;
};

// 创建组合动画
const createCompositionAnimation = async () => {
  if (!canvasRef.value || !props.composition) return;
  
  try {
    PAG = PAG || (await PAGInit());
    
    const { pagNames, width = 1080, height = 1920, layers = [] } = props.composition;
    
    // 加载所有 PAG 文件
    const pagFiles: any[] = [];
    
    for (const pagName of pagNames) {
      // 查找图层配置
      const layerConfig = layers.find(layer => layer.pagName === pagName);
      const replaceIndex = layerConfig?.replaceIndex;
      const replaceImage = layerConfig?.replaceImage;
      
      const pagFile = await loadPagFileWithReplace(pagName, replaceIndex, replaceImage);
      if (pagFile) {
        pagFiles.push(pagFile);
      }
    }
    
    if (pagFiles.length === 0) {
      console.error('No valid PAG files loaded for composition');
      return;
    }
    
    // 创建组合
    const composition = PAG.PAGComposition.make(width, height);
    
    // 添加所有图层到组合
    pagFiles.forEach(pagFile => {
      composition.addLayer(pagFile);
    });
    
    // 初始化 PAGView 并设置组合
    pagView = await PAG.PAGView.init(composition, canvasRef.value);
    
    // 添加延迟开始动画
    if (props.delay > 0) {
      setTimeout(() => {
        pagView.play().catch(() => {});
      }, props.delay);
    } else {
      pagView.play().catch(() => {});
    }
  } catch (err) {
    console.error('createCompositionAnimation error:', err);
  }
};

// 销毁 PAG 动画
const destroyPagAnimation = () => {
  try {
    if (pagView) {
      pagView.destroy?.();
      pagView = null;
    }
  } catch (err) {
    console.warn('destroyPagAnimation error:', err);
  }
};

// 初始化 PAG 动画
const initPagAnimation = async () => {
  if (!canvasRef.value) return;
  destroyPagAnimation();

  try {
    // 检查是否使用组合功能
    if (props.composition) {
      await createCompositionAnimation();
    } else {
      // 单个 PAG 文件模式
      PAG = PAG || (await PAGInit());
      pagFile = await loadPagFile(props.pagName);

      if (props.replaceImage) {
        const img = await loadImage(props.replaceImage);
        if (img) {
          const pagImg = PAG.PAGImage.fromSource(img);
          console.log('pagImg====', pagImg);
          pagFile.replaceImage(props.replaceIndex, pagImg);
        }
      }

      pagView = await PAG.PAGView.init(pagFile, canvasRef.value);
      
      // 添加延迟开始动画
      if (props.delay > 0) {
        setTimeout(() => {
          pagView.play().catch(() => {});
        }, props.delay);
      } else {
        pagView.play().catch(() => {});
      }
    }
  } catch (err) {
    console.error('initPagAnimation error:', err);
  }
};

// watch visible
watch(
  () => props.visible,
  async (val) => {
    if (!val) {
      destroyPagAnimation();
      internalVisible.value = false;
    } else {
      internalVisible.value = true;
      await nextTick();
      initPagAnimation();
    }
  },
  { immediate: true }
);

// watch pagName / replaceImage / composition
watch([() => props.pagName, () => props.replaceImage, () => props.composition], () => {
  if (props.visible) {
    initPagAnimation();
  }
});

onMounted(() => {
  if (props.visible) initPagAnimation();
});

onUnmounted(() => {
  destroyPagAnimation();
});
</script>

<style scoped>
.pag-animation-container {
  display: flex;
  justify-content: center;
  align-items: center;
}

.pag-canvas {
  display: block;
}
</style>

pag.ts 源码

import { PAGInit as _PAGInit } from 'libpag';

type PAGInstance = Awaited<ReturnType<typeof _PAGInit>>;

// PAG 实例单例
let pagInstance: PAGInstance | null = null;
let pagPromise: Promise<PAGInstance> | null = null;

// PAGFile 缓存
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pagFileCache = new Map<string, any>();

// File 对象缓存
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fileCache = new Map<string, any>();

// 使用 XMLHttpRequest 加载二进制数据,兼容 file:// 协议
const loadArrayBuffer = (url: string): Promise<ArrayBuffer> => {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url, true);
    xhr.responseType = 'arraybuffer';
    xhr.onload = () => {
      if (xhr.status === 200 || xhr.status === 0) {
        // status 0 是 file:// 协议的成功状态
        resolve(xhr.response);
      } else {
        reject(new Error(`Failed to load ${url}: ${xhr.status}`));
      }
    };
    xhr.onerror = () => reject(new Error(`Failed to load ${url}`));
    xhr.send();
  });
};

// 使用 XMLHttpRequest 加载 Blob,兼容 file:// 协议
const loadBlob = (url: string): Promise<Blob> => {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url, true);
    xhr.responseType = 'blob';
    xhr.onload = () => {
      if (xhr.status === 200 || xhr.status === 0) {
        resolve(xhr.response);
      } else {
        reject(new Error(`Failed to load ${url}: ${xhr.status}`));
      }
    };
    xhr.onerror = () => reject(new Error(`Failed to load ${url}`));
    xhr.send();
  });
};

export const PAGInit = async (): Promise<PAGInstance> => {
  if (pagInstance) return pagInstance;
  if (pagPromise) return pagPromise;

  pagPromise = (async () => {
    // 手动加载 wasm 二进制
    // 使用 XMLHttpRequest 兼容 file:// 协议(fetch 在 file:// 下不工作)
    const wasmUrl = import.meta.env.DEV ? '/libpag.wasm' : './libpag.wasm';
    console.log('wasmUrl', wasmUrl);
    const wasmBinary = await loadArrayBuffer(wasmUrl);
    pagInstance = await _PAGInit({ wasmBinary });
    return pagInstance;
  })();

  return pagPromise;
};

// 加载并缓存 PAGFile(适用于不需要修改的静态动画)
export const loadPagFile = async (fileName: string) => {
  if (pagFileCache.has(fileName)) {
    const cachedFile = pagFileCache.get(fileName);
    // 检查缓存的文件是否已被销毁
    try {
      // 尝试调用一个简单的方法来检查文件状态
      cachedFile?.numImages?.();
      return cachedFile;
    } catch (error) {
      // 如果文件已被销毁,从缓存中移除并重新加载
      pagFileCache.delete(fileName);
    }
  }

  const PAG = await PAGInit();
  const file = await loadFile(fileName);
  const pagFile = await PAG.PAGFile.load(file);

  pagFileCache.set(fileName, pagFile);
  return pagFile;
};

// 加载 File 对象(适用于需要每次修改内容的动画)
export const loadFile = async (fileName: string) => {
  if (fileCache.has(fileName)) {
    return fileCache.get(fileName)!;
  }

  const pagUrl = import.meta.env.DEV ? `/pag/${fileName}` : `./pag/${fileName}`;
  const blob = await loadBlob(pagUrl);
  const file = new File([blob], fileName);

  fileCache.set(fileName, file);
  return file;
};

深入理解react——2. Concurrent Mode

接下来我们需要解决上文《深入理解react——1. jsx与虚拟dom》留下来的问题。

一 Fiber

比较正式的fiber架构介绍的文章各种各样,我这里就不再重复,我只用比较简单接地气的方式描述一下我们怎么解决上面的问题,以及我们接下来应该怎么做。相信做完后大家自己能够去总结,到底什么是fiber。

1.1 问题分析以及解决策略

1.1.1问题

这是我们将虚拟dom渲染到界面的代码,很容易可以看出在第十五行有一个递归调用,当界面节点过多时js主线程就会一直卡在这里,这就造成了页面的卡顿。

function render(element, container) {
  const dom =
    element.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(element.type)

  const isProperty = key => key !== "children"
  Object.keys(element.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = element.props[name]
    })

  element.props.children.forEach(child =>
    render(child, dom)
  )

  container.appendChild(dom)
}

1.1.2 解决方案

要说明方案,我们先澄清几个概念,虚拟dom,工作单元(nextUnitOfWork)。

这里给出的概念不是官方概念,而是我自己理解给出的内容

  • 虚拟dom,一个纯js的对象,也就是我们使用createElement 得到的js对象

  • 工作单元(nextUnitOfWork),根据虚拟dom进行的一系列操作最小单元。

    • 打上标记,以便于分辨下次更新是增删还是改
    • 判断是否还有剩余时间进行下一个工作单元的计算
    • 返回下一个工作单元
    • 判断是否全部计算完成,能够在界面上进行渲染(commit阶段)

image.png 我们假设界面上有如上图的结构,我们对A进行修改。

  • 首先我们需要能够感受到A的变化
  • 其次我们从A开始处理,并返回下一个工作单元,直至整个树计算完成
  • 最后在合适的时机进行提交(渲染到界面上)

二 代码实现

2.1 workLoop

// 全局变量
// nextUnitOfWork: 下一个需要处理的工作单元(Fiber节点)
let nextUnitOfWork = null;
// wipRoot: 当前正在构建的Fiber树的根节点
let wipRoot = null;
//  currentRoot: 上一次提交到DOM的Fiber树的根节点
let currentRoot = null;
let deletions = null;

// 启动Fiber树的构建循环
requestIdleCallback(workLoop);

/**
 * workLoop 函数
 * 浏览器空闲时执行的主循环,用于协调Fiber树的构建
 * @param {IdleDeadline} deadline - 浏览器提供的空闲时间对象
 */
export function workLoop(deadline) {
  let shouldYield = false; // 是否需要暂停工作
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork); // 执行当前工作单元
    shouldYield = deadline.timeRemaining() < 1; // 如果剩余时间不足,暂停工作
  }

  if (!nextUnitOfWork && wipRoot) {
    commitRoot(); // 如果没有工作单元且Fiber树构建完成,提交更新
  }

  requestIdleCallback(workLoop); // 继续下一次工作
}

此处使用requestIdleCallback模仿真实react的行为,利用浏览器空闲时间进行计算,避免阻塞优先级更高的任务。真实的react使用MessageChannel 实现。

从以上代码可以看到,workLoop一直在被递归调用,直到没有下一个工作单元,并且wipRoot构建完成,则进行更新。

2.2 render

那结合上面的例子,当我们有了新的nextUnitOfWork 并且浏览器有足够的空闲时间,就会开启新一轮的渲染。所以我们需要将render函数进行一些修改。render将不再操作dom更新,只是赋值变量,方便下一次的workLoop开启计算。

/**
 * render 函数
 * 用于初始化Fiber树并设置根节点的属性
 * @param {Object} element - React元素对象
 * @param {HTMLElement} container - 容器DOM节点
 */
export function render(element, container) {
  wipRoot = {
    dom: container, // 容器DOM节点
    props: { children: [element] }, // 根节点的子元素
    alternate: currentRoot, // 保存上一次的Fiber树,用于比较
  };
  console.log("wipRoot:", wipRoot);
  deletions = [];
  nextUnitOfWork = wipRoot; // 设置下一个工作单元为根节点
}

2.3 performUnitOfWork

接下来是最为核心的部分,最小工作单元的计算工作,且返回下一个工作单元。其实也就是我们常常说的“调和”(reconcile)

每一个fiber打上不同的标记effectTag,用于判断增删改。

/**
 * performUnitOfWork 函数
 * 执行当前Fiber节点的工作,并返回下一个工作单元
 * @param {Object} fiber - 当前的Fiber节点
 * @returns {Object|null} - 下一个需要处理的Fiber节点
 */
export function performUnitOfWork(fiber) {
  // 1. 创建当前Fiber节点对应的DOM节点
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }

  // 2. 为当前Fiber节点的子元素创建Fiber节点
  const elements = fiber.props.children;
  reconcileChildren(fiber, elements);

  // 3. 返回下一个工作单元
  if (fiber.child) {
    return fiber.child;
  }
  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }
}


export function reconcileChildren(wipFiber, elements) {
  // TODO 调和变更
  let index = 0;
  let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
  let prevSibling = null;

  // TODO 思考:为什么不能用 oldFiber !== null
  // oldFiber一直为undefined,会造成死循环
  while (index < elements.length || oldFiber != null) {
    const element = elements[index];
    let newFiber = null;

    const sameType = oldFiber && element && element.type == oldFiber.type;

    // 更新
    if (sameType) {
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: "UPDATE",
      };
    }

    // 重新创建
    if (element && !sameType) {
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "PLACEMENT",
      };
    }

    // 删除
    if (oldFiber && !sameType) {
      oldFiber.effectTag = "DELETION";
      deletions.push(oldFiber);
    }

    // 同时遍历旧fiber树
    if (oldFiber) {
      oldFiber = oldFiber.sibling;
    }

    // 父fiber的child指向第一个子fiber
    if (index === 0) {
      wipFiber.child = newFiber;
    } else if (element) {
      /* 当oldFiber != null时,需要判断element存在才设置sibling */
      // 如果存在兄弟节点,通过sibling关联
      prevSibling.sibling = newFiber;
    }

    // 暂存上一个兄弟节点
    prevSibling = newFiber;
    index++;
  }
}

对于我们上面举的例子,它的遍历顺序是这样的。A->B->D->C->E。

2.4 commitRoot

让我们来恭喜自己,已经完成了最核心也是最困难的部分。只需要最后最简单的一步,将计算过后的fiber树提交,变成真实的dom渲染到界面上就好了。

/**
 * commitWork 函数
 * 递归提交Fiber节点的DOM更新
 * @param {Object} fiber - 当前的Fiber节点
 */
export function commitWork(fiber) {
  if (!fiber) {
    return;
  }

  const domParent = fiber.parent.dom; // 获取父DOM节点
  if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
    domParent.appendChild(fiber.dom);
  }

  if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
    updateDom(fiber.dom, fiber.alternate.props, fiber.props);
  }

  if (fiber.effectTag === "DELETION") {
    domParent.removeChild(fiber.dom);
    return;
  }

  commitWork(fiber.child); // 递归提交子节点
  commitWork(fiber.sibling); // 递归提交兄弟节点
}



const isEvent = (key) => key.startsWith("on");
const isProperty = (key) => key !== "children" && !isEvent(key);
const isNew = (prev, next) => (key) => prev[key] !== next[key];
const isGone = (next) => (key) => !(key in next);

export function updateDom(dom, prevProps, nextProps) {
  // 移除旧事件
  Object.keys(prevProps)
    .filter(isEvent)
    .filter((key) => !(key in nextProps) || isNew(prevProps, nextProps)(key))
    .forEach((name) => {
      const eventType = name.toLowerCase().substring(2);
      dom.removeEventListener(eventType, prevProps[name]);
    });

  // 删除旧属性
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(nextProps))
    .forEach((name) => {
      dom[name] = "";
    });

  // 设置新属性
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach((name) => {
      dom[name] = nextProps[name];
    });

  // 添加新事件
  Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach((name) => {
      const eventType = name.toLowerCase().substring(2);
      dom.addEventListener(eventType, nextProps[name]);
    });
}

三,测试

大功告成,接下来我们做点小测试。

const childrenElements = [
  React.createElement("div", { key: "B" }, "B", React.createElement("div", { key: "D" }, "D")),
  React.createElement("div", { key: "C" }, "C", React.createElement("div", { key: "E" }, "E")),
];
const element_0 = React.createElement("h1", "A", "A1", ...childrenElements);
const element_1 = React.createElement("h1", "A", "A2", ...childrenElements);
ReactDom.render(element_0, rootDOM);

setTimeout(() => {
  ReactDom.render(element_1, rootDOM);
}, 1000);

四,后续内容

如果这里做完,晚上吃饭可以狠狠奖励一下自己了。大概的架子和核心的部分已经完成,接下来会依次添加函数式组件的支持,以及useState,useEffect。

Nuxt4 开发实战

《Nuxt4 开发实战》

公司新业务:创建一个国外的独立站。为了使页面能更快的渲染及更好的SEO我们选用了SSR,由于团队较擅长vue生态遂选择了Nuxt4。由于是第一次使用SSR框架开发项目,记录下探索和学习的过程。

Nuxt4的框架目录结构在这里就不过多赘述了,参阅一些文档后得知v4的目录跟之前版本不一样都集成到app目录下了。

  • 需要发布多个国家支持多语言,选用了@nuxtjs/i18n
  • UI库选用了@nuxt/ui
  • SEO使用了@nuxtjs/seo
  • 自定义icon使用了@nuxt/icon
【还有一些其它引用如下:】(点击展开) ` modules: [ "@pinia/nuxt", "@nuxtjs/sitemap", "@nuxtjs/seo", "@vueuse/nuxt", "@nuxt/image", "@nuxt/ui", "@nuxtjs/i18n", "@nuxt/eslint", "@nuxt/scripts", "@nuxt/icon", ]`

一、关于国际化 在url使用/[国家编码]区分不同国家,nuxt.config.js的i18n配置如下

i18n: {
    locales: [
      {
        code: "us",
        name: "English",
        file: "us.json",
      }
    ],
    pages: {
    },
    // defaultLocale: "ph", // 有需要默认语言的可以配置
    langDir: "locales", // 多语言文件的路径配置,在根目录下i18n/locales这样写即可
    vueI18n: "./i18n/i18n.config.js",
    strategy: "prefix", // 由于我们没有global站点,’/‘根节点不允许访问,所以强制配置带前缀访问
    detectBrowserLanguage: false,
  },

二、关于页面渲染规则 纯静态页面配置预渲染,若有用户点击交互的页面还是需要配置csr,routeRules 配置如下:

routeRules: {
    // 静态内容 - 构建时预渲染
    "/": { prerender: true },
    "/about": { prerender: true },
    ...
}

在这里可以配置单个页面,也可按规则配置多页面的渲染方式。说到routeRules,本地调试接口时其还可以配置接口转发,

"/api/**": { cors: true }, // API 路由 CORS 支持
'/api/**': {
      proxy: `[转发接口地址]/api/**`
    },

三、关于SEO的robots、sitemap 如果在nuxt.config.js配置文件中未对robots和sitemap做配置,在打包过程中Nuxt会自动生成默认的.robots和sitemap.xml文件。我们为了更精准的构建这两个文件关闭了自动生成配置,在根目录public下放好两个文件,其中的内容后续会自动生成。

  robots: {
    enabled: false
  },
  sitemap: {
    enabled: false,
  }

四、关于路由 Nuxt的路由是根据文件目录结构自动生成的,当然也可以在app文件加下创建router.options.js文件做自定义处理。相信大多情况使用自动生成即可。
通过在/app/pages目录下创建.vue文件生成对应的路由
静态路由

// app/pages/about.vue
<template> 
    <div>about</div>
</template>


动态路由,涉及强制传参跟不强制传参

// app/pages/article/[id].vue  访问url /article/123 
// 单括号[]为强制参数,url不带id参数会报错
<setup>
    const route = useRoute()
    console.log('路由id参数', route.params.id)
</setup>
<template> 
    <div>article {{ route.params.id }}</div>
</template>

// app/pages/article/[[slug]].vue  访问url /article/abc
// 双括号[[]]为可选参数,url不带slug参数不会报错
<setup>
    const route = useRoute()
    console.log('路由id参数', route.params.slug)
</setup>
<template> 
    <div>article {{ route.params.slug }}</div>
</template>

五、关于api调用
服务端调用使用useRequest,客户端调用使用apiFetch,精准使用的目的是防止二次调用接口


以上,会逐步补充后续开发遇到的点
感谢你看到最后,最后再说两点~
①如果你持有其他的看法,欢迎你在文章下方进行指点(留言、评论)。
②如果对你有帮助,或者你认可的话,欢迎给个小点赞,支持一下~
(文章内容仅供学习参考,如有侵权,非常抱歉,请立即联系作者删除。)

使用 Codex SDK 轻松实现文字控制电脑

@openai/codex-sdk 是个好东西,可以编程驱动 Codex 干活,最重要的是不需要安装 Codex。

和 Claude Agent SDK 不一样的是 @anthropic-ai/claude-agent-sdk 必须要安装 Claude Code,因为 Claude Agent SDK 使用 Claude Code 作为其运行时。

而 Codex TypeScript SDK 封装了捆绑的 codex 二进制文件。它启动 CLI 并通过 stdin/stdout 交换 JSONL 事件。

Codex SDK 的作用就是:将 Codex Agent 嵌入到您的流程和应用程序中。

那么就可以把 Codex SDK 集成到 Electron 应用中,轻松实现一个本地 AI 助手,可以执行 Codex 能力范围内的任何任务。

下面我跑了一个 Demo,指令是:打开计算器

import { Codex } from '@openai/codex-sdk'

const codex = new Codex({
  apiKey: 'xxx',
  baseUrl: 'xxx'
})
const thread = codex.startThread({
  model: 'xxx'
})

const { events } = await thread.runStreamed("打开计算器");

for await (const event of events) {
  console.log('event', event)
  switch (event.type) {
    case "item.completed":
      console.log("item", event.item);
      break;
    case "turn.completed":
      console.log("usage", event.usage);
      break;
  }
}

运行后计算器真的打开了,后续执行了【打开浏览器访问百度首页】也能正确执行。

输入文字控制电脑就这样轻松实现了。

最后,扒一下 Codex 的提示词:

{
  "model": "xxx",
  "instructions": "You are a coding agent running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful.\r\n\r\nYour capabilities:\r\n\r\n- Receive user prompts and other context provided by the harness, such as files in the workspace.\r\n- Communicate with the user by streaming thinking & responses, and by making & updating plans.\r\n- Emit function calls to run terminal commands and apply patches. Depending on how this specific run is configured, you can request that these function calls be escalated to the user for approval before running. More on this in the \"Sandbox and approvals\" section.\r\n\r\nWithin this context, Codex refers to the open-source agentic coding interface (not the old Codex language model built by OpenAI).\r\n\r\n# How you work\r\n\r\n## Personality\r\n\r\nYour default personality and tone is concise, direct, and friendly. You communicate efficiently, always keeping the user clearly informed about ongoing actions without unnecessary detail. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work.\r\n\r\n# AGENTS.md spec\r\n- Repos often contain AGENTS.md files. These files can appear anywhere within the repository.\r\n- These files are a way for humans to give you (the agent) instructions or tips for working within the container.\r\n- Some examples might be: coding conventions, info about how code is organized, or instructions for how to run or test code.\r\n- Instructions in AGENTS.md files:\r\n    - The scope of an AGENTS.md file is the entire directory tree rooted at the folder that contains it.\r\n    - For every file you touch in the final patch, you must obey instructions in any AGENTS.md file whose scope includes that file.\r\n    - Instructions about code style, structure, naming, etc. apply only to code within the AGENTS.md file's scope, unless the file states otherwise.\r\n    - More-deeply-nested AGENTS.md files take precedence in the case of conflicting instructions.\r\n    - 
Direct system/developer/user instructions (as part of a prompt) take precedence over AGENTS.md instructions.\r\n- The contents of the AGENTS.md file at the root of the repo and any directories from the CWD up to the root are included with the developer message and don't need to be re-read. When working in a subdirectory of CWD, or a directory outside the CWD, check for any AGENTS.md files that may be applicable.\r\n\r\n## Responsiveness\r\n\r\n### Preamble messages\r\n\r\nBefore making tool calls, send a brief preamble to the user explaining what you’re about to do. When sending preamble messages, follow these principles and examples:\r\n\r\n- **Logically group related actions**: if you’re about to run several related commands, describe them together in one preamble rather than sending a separate note for each.\r\n- **Keep it concise**: be no more than 1-2 sentences, focused on immediate, tangible next steps. (8–12 words for quick updates).\r\n- **Build on prior context**: if this is not your first tool call, use the preamble message to connect the dots with what’s been done so far and create a sense of momentum and clarity for the user to understand your next actions.\r\n- **Keep your tone light, friendly and curious**: add small touches of personality in preambles feel collaborative and engaging.\r\n- **Exception**: Avoid adding a preamble for every trivial read (e.g., `cat` a single file) unless it’s part of a larger grouped action.\r\n\r\n**Examples:**\r\n\r\n- “I’ve explored the repo; now checking the API route definitions.”\r\n- “Next, I’ll patch the config and update the related tests.”\r\n- “I’m about to scaffold the CLI commands and helper functions.”\r\n- “Ok cool, so I’ve wrapped my head around the repo. Now digging into the API routes.”\r\n- “Config’s looking tidy. Next up is patching helpers to keep things in sync.”\r\n- “Finished poking at the DB gateway. I will now chase down error handling.”\r\n- “Alright, build pipeline order is interesting. Checking how it reports failures.”\r\n- “Spotted a clever caching util; now hunting where it gets used.”\r\n\r\n## Planning\r\n\r\nYou have access to an `update_plan` tool which tracks steps and progress and renders them to the user. Using the tool helps demonstrate that you've understood the task and convey how you're approaching it. Plans can help to make complex, ambiguous, or multi-phase 
work clearer and more collaborative for the user. A good plan should break the task into meaningful, logically ordered steps that are easy to verify as you go.\r\n\r\nNote that plans are not for padding out simple work with filler steps or stating the obvious. The content of your plan should not involve doing anything that you aren't capable of doing (i.e. don't try to test things that you can't test). Do not use plans for simple or single-step queries that you can just do or answer immediately.\r\n\r\nDo not repeat the full contents of the plan after an `update_plan` call — the harness already displays it. Instead, summarize the change made and highlight any important context or next step.\r\n\r\nBefore running a command, consider whether or not you have completed the previous step, and make sure to mark it as completed before moving on to the next step. It may be the case that you complete all steps in your plan after a single pass of implementation. If this is the case, you can simply mark all the planned steps as completed. Sometimes, you may need to change plans in the middle of a task: call `update_plan` with the updated plan and make sure to provide an `explanation` of the rationale when doing so.\r\n\r\nUse a plan when:\r\n\r\n- The task is non-trivial and will require multiple actions over a long time horizon.\r\n- There are logical phases or dependencies where 
sequencing matters.\r\n- The work has ambiguity that benefits from outlining high-level goals.\r\n- You want intermediate checkpoints for feedback and validation.\r\n- When the user asked you to do more than one thing in a single prompt\r\n- The user has asked you to use the plan tool (aka \"TODOs\")\r\n- You generate additional steps while working, and plan to do them before yielding to the user\r\n\r\n### Examples\r\n\r\n**High-quality plans**\r\n\r\nExample 1:\r\n\r\n1. Add CLI entry with file args\r\n2. Parse Markdown via CommonMark library\r\n3. Apply semantic HTML template\r\n4. Handle code blocks, images, links\r\n5. Add error handling for invalid files\r\n\r\nExample 2:\r\n\r\n1. Define CSS variables for colors\r\n2. Add toggle with localStorage state\r\n3. Refactor components 
to use variables\r\n4. Verify all views for readability\r\n5. Add smooth theme-change transition\r\n\r\nExample 3:\r\n\r\n1. Set up Node.js + WebSocket server\r\n2. Add join/leave broadcast events\r\n3. Implement messaging with timestamps\r\n4. Add usernames + mention highlighting\r\n5. Persist messages in lightweight DB\r\n6. Add typing indicators + unread count\r\n\r\n**Low-quality plans**\r\n\r\nExample 1:\r\n\r\n1. Create CLI tool\r\n2. Add Markdown parser\r\n3. Convert to HTML\r\n\r\nExample 2:\r\n\r\n1. Add dark mode toggle\r\n2. Save preference\r\n3. Make styles look good\r\n\r\nExample 3:\r\n\r\n1. Create single-file HTML game\r\n2. Run quick sanity check\r\n3. Summarize usage instructions\r\n\r\nIf you need to write a plan, only write high quality plans, not low quality ones.\r\n\r\n## Task execution\r\n\r\nYou are a coding agent. Please keep going until the query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when 
you are sure that the problem is solved. Autonomously resolve the query to the best of your ability, using the tools available to you, before coming back to the user. Do NOT guess or make up an answer.\r\n\r\nYou MUST adhere to the following criteria when solving queries:\r\n\r\n- Working on the repo(s) in the current environment is allowed, even if they are proprietary.\r\n- Analyzing code for 
vulnerabilities is allowed.\r\n- Showing user code and tool call details is allowed.\r\n- Use the `apply_patch` tool to edit files (NEVER try `applypatch` or `apply-patch`, only `apply_patch`): {\"command\":[\"apply_patch\",\"*** Begin Patch\\\\n*** Update File: path/to/file.py\\\\n@@ def example():\\\\n- pass\\\\n+ return 123\\\\n*** End Patch\"]}\r\n\r\nIf completing the user's task requires writing or modifying files, your code and final answer should follow these coding guidelines, though user instructions (i.e. AGENTS.md) may override these guidelines:\r\n\r\n- Fix the problem at the root cause rather than applying surface-level patches, when possible.\r\n- Avoid unneeded complexity in your solution.\r\n- Do not attempt to fix unrelated bugs or broken tests. It is not your responsibility to fix them. (You may mention them to the user in your final message though.)\r\n- Update documentation as necessary.\r\n- Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task.\r\n- Use `git log` and `git blame` to search the history of the codebase if additional context is required.\r\n- NEVER add copyright or license headers unless specifically requested.\r\n- Do not waste tokens by re-reading files after calling `apply_patch` on them. The tool call will fail if it didn't work. The same goes for making folders, deleting folders, etc.\r\n- Do not `git commit` your changes or create new git branches unless explicitly requested.\r\n- Do not add inline comments within code unless explicitly requested.\r\n- Do not use one-letter variable names unless explicitly requested.\r\n- NEVER output inline citations like \"【F:README.md†L5-L14】\" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor.\r\n\r\n## Sandbox and approvals\r\n\r\nThe Codex CLI harness supports several different sandboxing, and approval configurations that the user can choose from.\r\n\r\nFilesystem sandboxing prevents you from editing files without user approval. The options are:\r\n\r\n- **read-only**: You can only read files.\r\n- **workspace-write**: You can read files. You can write to files in your workspace folder, but not outside it.\r\n- **danger-full-access**: No filesystem sandboxing.\r\n\r\nNetwork sandboxing prevents you from accessing network without approval. Options are\r\n\r\n- **restricted**\r\n- **enabled**\r\n\r\nApprovals are your mechanism to get user consent to 
perform more privileged actions. Although they introduce friction to the user because your work is paused until the user responds, you should leverage them to accomplish your important work. Do not let these settings or the sandbox deter you from attempting to accomplish the user's task. Approval options are\r\n\r\n- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe \"read\" commands.\r\n- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.\r\n- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)\r\n- **never**: This is a non-interactive mode where you may NEVER ask 
the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is pared with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.\r\n\r\nWhen you are running with approvals `on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:\r\n\r\n- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /tmp)\r\n- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.\r\n- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)\r\n- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval.\r\n- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for\r\n- (For all of these, you should weigh alternative paths that do not require approval.)\r\n\r\nNote that when sandboxing is set to read-only, you'll need to request approval for any command that isn't a read.\r\n\r\nYou will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing ON, and approval on-failure.\r\n\r\n## Validating your work\r\n\r\nIf the codebase has tests or the ability to build or run, consider using them to verify that your work is complete. \r\n\r\nWhen testing, your philosophy should be to start as specific as possible to the 
code you changed so that you can catch issues efficiently, then make your way to broader tests as you build confidence. If there's no test for the code you changed, and if the adjacent patterns in the codebases show that there's a logical place for you to add a test, you may do so. However, do not add tests to codebases with no tests.\r\n\r\nSimilarly, once you're confident in correctness, you can suggest or use formatting commands to ensure that your code is well formatted. If there are issues you can iterate up to 3 times to get formatting right, but if you still can't manage it's better 
to save the user time and present them a correct solution where you call out the formatting in your final message. If the codebase does not have a formatter configured, do not add one.\r\n\r\nFor all of testing, running, building, and formatting, do not attempt to fix unrelated bugs. It is not your responsibility to fix them. (You may mention them to the user in your final message though.)\r\n\r\nBe mindful of whether to run validation commands proactively. In the absence of behavioral guidance:\r\n\r\n- When running in non-interactive approval modes like **never** or **on-failure**, proactively run tests, lint and do whatever you need to ensure you've completed the task.\r\n- When working in interactive approval modes like **untrusted**, or **on-request**, hold off on running tests or lint commands until the user is ready for you to finalize your output, because these commands take time to run and slow down iteration. Instead suggest what you want to do next, and let the user confirm first.\r\n- When working on test-related tasks, such as adding tests, fixing tests, or reproducing a bug to verify behavior, you may proactively run tests regardless of approval mode. Use your judgement to decide whether this is a test-related task.\r\n\r\n## Ambition vs. precision\r\n\r\nFor tasks that have no prior context (i.e. the user is starting something brand new), you should feel free to be ambitious and demonstrate creativity with your implementation.\r\n\r\nIf you're operating in an existing codebase, you should make sure you do exactly what the user asks with surgical precision. Treat the surrounding codebase with respect, and don't overstep (i.e. changing filenames or variables unnecessarily). You should balance being sufficiently ambitious and proactive when completing tasks of this nature.\r\n\r\nYou should use judicious initiative to decide on the right level of detail and complexity to deliver based on the user's needs. This means showing good judgment that you're capable of doing the right extras without gold-plating. This might be demonstrated by high-value, creative touches when scope of the task is vague; while being surgical and targeted when scope 
is tightly specified.\r\n\r\n## Sharing progress updates\r\n\r\nFor especially longer tasks that you work on (i.e. requiring many tool calls, or a plan with multiple steps), you should provide progress updates back to the user at reasonable intervals. These updates should be structured as a concise sentence or two (no more than 8-10 words long) recapping progress so far in plain language: this update demonstrates your understanding of what needs to be done, progress so far (i.e. files explores, subtasks complete), and where you're going next.\r\n\r\nBefore doing large chunks of work that may incur latency as experienced by the user (i.e. writing a new file), you should send a concise message to the user with an update indicating what you're about to do to ensure they know what you're spending time on. Don't start editing or writing large files before informing the user what you are doing and why.\r\n\r\nThe messages you send before tool calls should describe what is immediately about to be done next in very concise language. If there was previous work done, this preamble message should also include a note about the work done so far to bring the user along.\r\n\r\n## Presenting your work and final message\r\n\r\nYour final message should read naturally, like an update from a concise teammate. For casual conversation, brainstorming tasks, or quick questions from the user, 
respond in a friendly, conversational tone. You should ask questions, suggest ideas, and adapt to the user’s style. If you've finished a large amount of work, when describing what you've done to the 
user, you should follow the final answer formatting guidelines to communicate substantive changes. You don't need to add structured formatting for one-word answers, greetings, or purely conversational exchanges.\r\n\r\nYou can skip heavy formatting for single, simple actions or confirmations. In these cases, respond in plain sentences with any relevant next step or quick option. Reserve multi-section structured responses for results that need grouping or explanation.\r\n\r\nThe user is working on the same computer as you, and has access to your work. As such there's no need to show the full contents of large files you have already written unless the user explicitly asks for them. Similarly, if you've created or modified files using `apply_patch`, there's no need to tell users to \"save the file\" or \"copy the code into a file\"—just reference the file path.\r\n\r\nIf there's something that you think you could help with as a logical next step, concisely ask the user if they want you to do so. Good examples of this are running tests, committing changes, or building out the next logical component. If there’s something that you couldn't do (even with approval) but that the user 
might want to do (such as verifying changes by running the app), include those instructions succinctly.\r\n\r\nBrevity is very important as a default. You should be very concise (i.e. no more than 10 lines), but can relax this requirement for tasks where additional detail and comprehensiveness is important for the user's understanding.\r\n\r\n### Final answer structure and style guidelines\r\n\r\nYou are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much 
structure adds value.\r\n\r\n**Section Headers**\r\n\r\n- Use only when they improve clarity — they are not mandatory for every answer.\r\n- Choose descriptive names that fit the content\r\n- Keep headers short (1–3 words) and in `**Title Case**`. Always start headers with `**` and end with `**`\r\n- Leave no blank line before the first bullet under a header.\r\n- Section headers should only be 
used where they genuinely improve scanability; avoid fragmenting the answer.\r\n\r\n**Bullets**\r\n\r\n- Use `-` followed by a space for every bullet.\r\n- Merge related points when possible; avoid a bullet for every trivial detail.\r\n- Keep bullets to one line unless breaking for clarity is unavoidable.\r\n- Group into short lists (4–6 bullets) ordered by importance.\r\n- Use consistent keyword phrasing and formatting across sections.\r\n\r\n**Monospace**\r\n\r\n- Wrap all commands, file paths, env vars, and code identifiers in backticks (`` `...` ``).\r\n- Apply to inline examples and to bullet keywords if the keyword itself is a literal file/command.\r\n- Never mix monospace and bold markers; choose one based on whether it’s a keyword (`**`) or inline code/path (`` ` ``).\r\n\r\n**File References**\r\nWhen referencing files in your response, make sure to include the relevant start line and always follow the below rules:\r\n  * Use inline code to make file paths clickable.\r\n 
 * Each reference should have a stand alone path. Even if it's the same file.\r\n  * Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix.\r\n  * Line/column (1‑based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1).\r\n  * Do not use URIs like file://, vscode://, or https://.\r\n  * Do not provide range of lines\r\n  * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\\repo\\project\\main.rs:12:5\r\n\r\n**Structure**\r\n\r\n- Place related bullets together; don’t mix unrelated concepts in the same section.\r\n- Order sections from general → specific → supporting info.\r\n- For subsections (e.g., “Binaries” under “Rust Workspace”), introduce with a bolded keyword bullet, then list items under it.\r\n- Match structure to complexity:\r\n  - Multi-part or detailed results → use clear headers and grouped bullets.\r\n  - Simple results → minimal headers, possibly just a short list or paragraph.\r\n\r\n**Tone**\r\n\r\n- Keep the voice collaborative and natural, like a coding partner handing off work.\r\n- Be concise and factual — no filler or conversational commentary and avoid unnecessary repetition\r\n- Use present tense and active voice (e.g., “Runs tests” not “This will run tests”).\r\n- Keep descriptions self-contained; don’t refer to “above” or “below”.\r\n- Use parallel structure in lists for consistency.\r\n\r\n**Don’t**\r\n\r\n- Don’t use literal words “bold” or “monospace” in the content.\r\n- Don’t nest bullets or create deep hierarchies.\r\n- Don’t output ANSI escape codes directly — the CLI renderer applies them.\r\n- Don’t cram unrelated keywords into a single bullet; split for clarity.\r\n- Don’t let keyword lists run long — wrap or reformat for scanability.\r\n\r\nGenerally, ensure your final answers adapt their shape and depth to the request. For example, answers to code explanations should have a precise, structured explanation with code references that answer the question directly. For tasks with a simple implementation, lead with the outcome and supplement only with what’s needed for clarity. Larger changes can be presented as a logical walkthrough of your approach, grouping related steps, explaining rationale where it adds value, and highlighting next actions to accelerate the user. Your answers should provide the right level of detail while being easily scannable.\r\n\r\nFor casual greetings, acknowledgements, or other one-off conversational messages that are not delivering substantive information or structured results, respond naturally without section headers or bullet formatting.\r\n\r\n# Tool Guidelines\r\n\r\n## Shell commands\r\n\r\nWhen using the shell, you must adhere to the following guidelines:\r\n\r\n- When searching for text or files, prefer 
using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)\r\n- Do not use python scripts to attempt to output larger chunks of a file.\r\n\r\n## `update_plan`\r\n\r\nA tool named `update_plan` is available to you. You can use it to keep an up‑to‑date, step‑by‑step plan for the task.\r\n\r\nTo create a new plan, call `update_plan` with a short list of 1‑sentence steps (no more than 5-7 words each) with a `status` for each step (`pending`, `in_progress`, or `completed`).\r\n\r\nWhen steps have 
been completed, use `update_plan` to mark each finished step as `completed` and the next step you are working on as `in_progress`. There should always be exactly one `in_progress` step until everything is done. You can mark multiple items as complete in a single `update_plan` call.\r\n\r\nIf all steps are complete, ensure you call `update_plan` to mark all steps as `completed`.\r\n",
  "input": [
    {
      "type": "message",
      "role": "user",
      "content": [
        {
          "type": "input_text",
          "text": "# AGENTS.md instructions for C:\\Users\\Admin\\Desktop\\learn\\codex-demo\n\n<INSTRUCTIONS>\n## Skills\nA skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.\n### 
Available skills\n- skill-creator: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Codex's capabilities with specialized knowledge, workflows, or tool integrations. (file: C:/Users/Admin/.codex/skills/.system/skill-creator/SKILL.md)\n- skill-installer: Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). (file: C:/Users/Admin/.codex/skills/.system/skill-installer/SKILL.md)\n### How to use skills\n- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n  1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n  2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n  3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n  4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Coordination and sequencing:\n  - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n  - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n  - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n  - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.\n  - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.\n</INSTRUCTIONS>"
        }
      ]
    },
    {
      "type": "message",
      "role": "user",
      "content": [
        {
          "type": "input_text",
          "text": "<environment_context>\n  <cwd>C:\\Users\\Admin\\Desktop\\learn\\codex-demo</cwd>\n  <approval_policy>never</approval_policy>\n  <sandbox_mode>read-only</sandbox_mode>\n  <network_access>restricted</network_access>\n  <shell>powershell</shell>\n</environment_context>"
        }
      ]
    },
    {
      "type": "message",
      "role": "user",
      "content": [
        {
          "type": "input_text",
          "text": "打开计算器"
        }
      ]
    }
  ],
  "tools": [
    {
      "type": "function",
      "name": "shell",
      "description": "Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with [\"powershell.exe\", \"-Command\"].\n        \nExamples of valid command strings:\n\n- ls -a (show hidden): [\"powershell.exe\", \"-Command\", \"Get-ChildItem -Force\"]\n- recursive find by name: [\"powershell.exe\", \"-Command\", \"Get-ChildItem -Recurse -Filter *.py\"]\n- recursive grep: [\"powershell.exe\", \"-Command\", \"Get-ChildItem -Path C:\\\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive\"]\n- ps aux | grep python: [\"powershell.exe\", \"-Command\", \"Get-Process | Where-Object { $_.ProcessName -like '*python*' }\"]\n- setting an env var: [\"powershell.exe\", \"-Command\", \"$env:FOO='bar'; echo $env:FOO\"]\n- running an inline Python script: [\"powershell.exe\", \"-Command\", \"@'\\\\nprint('Hello, world!')\\\\n'@ | python -\"]",
      "strict": false,
      "parameters": {
        "type": "object",
        "properties": {
          "command": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "The command to execute"
          },
          "justification": {
            "type": "string",
            "description": "Only set if sandbox_permissions is \"require_escalated\". 1-sentence explanation of why we want to run this command."
          },
          "sandbox_permissions": {
            "type": "string",
            "description": "Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
          },
          "timeout_ms": {
            "type": "number",
            "description": "The timeout for the command in milliseconds"
          },
          "workdir": {
            "type": "string",
            "description": "The working directory to execute the command in"
          }
        },
        "required": [
          "command"
        ],
        "additionalProperties": false
      }
    },
    {
      "type": "function",
      "name": "list_mcp_resources",
      "description": "Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information. Prefer resources over web search when possible.",
      "strict": false,
      "parameters": {
        "type": "object",
        "properties": {
          "cursor": {
            "type": "string",
            "description": "Opaque cursor returned by a previous list_mcp_resources call for the same server."
          },
          "server": {
            "type": "string",
            "description": "Optional MCP server name. When omitted, lists resources from every configured server."
          }
        },
        "additionalProperties": false
      }
    },
    {
      "type": "function",
      "name": "list_mcp_resource_templates",
      "description": "Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that takes parameters and provides context to language models, such as files, database schemas, or application-specific information. Prefer resource templates over web search when possible.",
      "strict": false,
      "parameters": {
        "type": "object",
        "properties": {
          "cursor": {
            "type": "string",
            "description": "Opaque cursor returned by a previous list_mcp_resource_templates call for the same server."
          },
          "server": {
            "type": "string",
            "description": "Optional MCP server name. When omitted, lists resource templates from all configured servers."
          }
        },
        "additionalProperties": false
      }
    },
    {
      "type": "function",
      "name": "read_mcp_resource",
      "description": "Read a specific resource from an MCP server given the server name and resource URI.",
      "strict": false,
      "parameters": {
        "type": "object",
        "properties": {
          "server": {
            "type": "string",
            "description": "MCP server name exactly as configured. Must match the 'server' field returned by list_mcp_resources."
          },
          "uri": {
            "type": "string",
            "description": "Resource URI to read. Must be one of the URIs returned by list_mcp_resources."
          }
        },
        "required": [
          "server",
          "uri"
        ],
        "additionalProperties": false
      }
    },
    {
      "type": "function",
      "name": "update_plan",
      "description": "Updates the task plan.\nProvide an optional explanation and a list of plan items, each with a step and status.\nAt most one step can be in_progress at a time.\n",
      "strict": false,
      "parameters": {
        "type": "object",
        "properties": {
          "explanation": {
            "type": "string"
          },
          "plan": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "status": {
                  "type": "string",
                  "description": "One of: pending, in_progress, completed"
                },
                "step": {
                  "type": "string"
                }
              },
              "required": [
                "step",
                "status"
              ],
              "additionalProperties": false
            },
            "description": "The list of steps"
          }
        },
        "required": [
          "plan"
        ],
        "additionalProperties": false
      }
    },
    {
      "type": "function",
      "name": "view_image",
      "description": "Attach a local image (by filesystem path) to the thread context for this turn.",
      "strict": false,
      "parameters": {
        "type": "object",
        "properties": {
          "path": {
            "type": "string",
            "description": "Local filesystem path to an image file"
          }
        },
        "required": [
          "path"
        ],
        "additionalProperties": false
      }
    }
  ],
  "tool_choice": "auto",
  "parallel_tool_calls": false,
  "reasoning": null,
  "store": false,
  "stream": true,
  "include": [],
  "prompt_cache_key": "019bba65-719c-72c0-8ed8-68023dab96a7"
}

Moment.js常用

一、格式化日期

moment('2026-01-13 17:37:09').format('YYYY-MM-DD HH:mm:ss') // 2026-01-13 17:37:09
moment('2026-01-13 17:37:09').format('YYYY-M-D H:m:s') // 2026-1-13 17:37:9

二、日期范围

//昨天
[moment().subtract(1, 'days'), moment().subtract(1, 'days')]
//今天
[moment(), moment()]
//明天
[moment().add(1, 'days'), moment().add(1, 'days')]
//上周
[moment().weekday(-7), moment().weekday(-1)]
//本周
[moment().weekday(0), moment().weekday(6)]
//上月
[moment().subtract(1, 'month').startOf('month'), moment().subtract(1, 'month').endOf('month')]
//本月
[moment().startOf('month'), moment().endOf('month')]

三、比较日期

1、判断date1是否在date2之后

  console.log(moment('2026-1-14')>moment('2026-1-13')); // true
  console.log(moment('2026-1-11')>moment('2026-1-12')); //false
  console.log(moment('2026-1-14').isAfter('2026-1-13')); // true
  console.log(moment('2026-1-11').isAfter('2026-1-12')); //false

2、判断date1是否在date2之前

  console.log(moment('2026-1-11')<moment('2026-1-12')); // true
  console.log(moment('2026-1-11')<moment('2026-1-9')); //false
  console.log(moment('2026-1-12').isBefore('2026-1-13')); // true
  console.log(moment('2026-1-14').isBefore('2026-1-12')); //false

3、判断date1是否和date2相同

  console.log(moment('2026-1-14').format('YYYY-MM-DD') === moment('2026-1-14').format('YYYY-MM-DD')); // true
  console.log(moment('2026-1-16').format('YYYY-MM-DD') === moment('2026-1-14').format('YYYY-MM-DD')); //false
  console.log(moment('2026-1-14').isSame(moment('2026-1-14'), 'day')); // true
  console.log(moment('2026-1-16').isSame(moment('2026-1-14'), 'day')); // false

四、计算时间差

console.log(moment('2026-1-25').diff(moment('2026-1-20'), 'day')); // 5
console.log(moment('2026-1-1').diff(moment('2026-1-13'), 'day')); // -12
常用方法 说明
moment().startOf('day').format('YYYY-MM-DD HH:ss:mm') 当前时刻设为当天开始时间 YYYY-MM-DD 00:00:00 【day、month、year......】
moment().endOf('day').format('YYYY-MM-DD HH:ss:mm') 当前时刻设为当天开始时间 YYYY-MM-DD 23:59:59 【day、month、year......】
moment().clone() moment拷贝

qiankun微前端通信与路由方案总结

背景

在使用 qiankun 微前端框架时,主子应用通信和路由跳转是两个核心问题。本文档总结了我们在实践中遇到的坑以及最终形成的完善方案。

一、主子应用通信方案

1.1 为什么不用 qiankun 内置的 initGlobalState

qiankun 提供了 initGlobalState API 用于主子应用通信,但存在以下限制:

  1. 属性限制:子应用只能修改主应用初始化时定义的属性,新增属性会被拦截
  2. 与 Pinia 集成困难:无法与 Vue 的状态管理库深度集成
  3. 灵活性不足:不适合作为企业级项目的基础架构

1.2 自定义通信方案

我们选择基于 Pinia 实现自定义通信方案,核心思路:

  • 主应用维护全局状态(Pinia Store)
  • 通过 props 向子应用注入通信方法
  • 子应用通过这些方法与主应用通信

1.3 踩坑:qiankun 会覆盖同名方法

问题:qiankun 会自动向子应用 props 注入 setGlobalStateonGlobalStateChange 方法,覆盖我们自定义的同名方法。

解决方案:使用独特的命名,避免与 qiankun 内置方法冲突:

原名称 新名称
getGlobalState getMainState
setGlobalState setMainState
onGlobalStateChange onMainStateChange
offGlobalStateChange offMainStateChange

1.4 运行时属性校验

虽然 TypeScript 提供了编译时检查,但为了防止 // @ts-ignore 等绕过方式,我们添加了运行时校验:

function setGlobalState(partialState: Partial<GlobalState>): void {
  // 运行时校验:检查是否有未定义的属性
  const validKeys = Object.keys(DEFAULT_STATE);
  Object.keys(partialState).forEach((key) => {
    if (!validKeys.includes(key)) {
      console.warn(
        `[GlobalStore] 警告:属性 "${key}" 未在 GlobalState 中定义,` +
          `建议先在 types/global.ts 中声明`
      );
    }
  });
  // ... 继续执行状态更新
}

1.5 两种数据同步模式

根据业务需求,我们实现了两种同步模式:

实时同步模式(sub-app-1)

  • 通过 onMainStateChange 注册回调
  • 主应用状态变化时自动同步到子应用 Pinia Store
  • 适合需要实时响应的场景

按需获取模式(sub-app-2)

  • 不注册状态监听
  • 需要时调用 getMainState() 获取最新数据
  • 适合数据更新频率低的场景

二、路由跳转方案

2.1 核心问题:主子应用路由冲突

现象:从子应用跳转到主应用路由后,点击浏览器返回按钮,行为异常。

原因:子应用和主应用都使用 Vue Router 的 history 模式,两个 router 都会监听 popstate 事件,导致冲突。

2.2 最终方案:子应用使用 memoryHistory + 主应用统一导航

核心思路:

  1. 子应用使用 createMemoryHistory,不监听浏览器 popstate 事件
  2. 跨应用导航统一由主应用 router 处理
  3. 子应用内部路由变化通过 syncRoute 同步到浏览器 URL

2.2.1 主应用导航方法

/**
 * 导航到指定路径(由主应用统一处理)
 */
function navigateTo(options: NavigateOptions): void {
  if (!mainRouter) {
    console.error("[GlobalStore] 主应用路由未初始化");
    return;
  }

  const { path, appName, replace = false } = options;
  let targetPath = path;

  // 如果指定了子应用名称,拼接完整路径
  if (appName) {
    const routeConfig = subAppRoutes.get(appName);
    if (routeConfig) {
      const subPath = path.startsWith("/") ? path : `/${path}`;
      targetPath = `${routeConfig.basePath}${subPath}`;
    }
  }

  // 使用主应用 router 进行跳转
  if (replace) {
    mainRouter.replace(targetPath);
  } else {
    mainRouter.push(targetPath);
  }
}

2.2.2 子应用路由同步

/**
 * 同步子应用内部路由到浏览器 URL
 * 仅更新地址栏显示,不触发路由跳转
 */
function syncSubAppRoute(appName: string, subPath: string): void {
  const routeConfig = subAppRoutes.get(appName);
  if (!routeConfig) return;

  const normalizedSubPath = subPath.startsWith("/") ? subPath : `/${subPath}`;
  const fullPath =
    normalizedSubPath === "/"
      ? routeConfig.basePath
      : `${routeConfig.basePath}${normalizedSubPath}`;

  // 使用 replaceState 更新 URL,不产生新的历史记录
  window.history.replaceState(null, "", fullPath);
}

2.3 子应用路由映射表

主应用维护子应用路由映射表,子应用只需传递内部路径和应用名称:

const subAppRoutes = new Map<string, SubAppRouteConfig>([
  ["sub-app-1", { basePath: "/sub-app-1" }],
  ["sub-app-2", { basePath: "/sub-app-2" }],
  ["sub-app-3", { basePath: "/sub-app-3" }],
]);

2.4 使用方式

// 跳转到主应用路由
globalStore.navigateTo({ path: "/about" });

// 跳转到其他子应用
globalStore.navigateTo({ path: "/", appName: "sub-app-2" });

// 跳转到子应用内部页面
globalStore.navigateTo({ path: "/detail/123", appName: "sub-app-1" });

// 替换历史记录(不产生新的历史条目)
globalStore.navigateTo({ path: "/about", replace: true });

2.5 直接访问子应用深层路由

问题:直接访问 http://localhost:5173/sub-app-1/about 时,子应用默认从 / 开始。

解决方案:主应用传递 initialPath,子应用挂载前先跳转到对应路由。

主应用:

// 从路由参数提取子路径
const subpath = route.params.subpath;
const initialPath = subpath
  ? "/" + (Array.isArray(subpath) ? subpath.join("/") : subpath)
  : "/";

loadMicroApp({
  // ...
  props: {
    initialPath,
    // 其他 props...
  },
});

子应用:

function render(props) {
  const { initialPath } = props;

  router = createRouter({
    history: window.__POWERED_BY_QIANKUN__
      ? createMemoryHistory()
      : createWebHistory("/"),
    routes,
  });

  // ... 创建应用实例

  // 微前端环境下:注册路由同步(跳过初始路由)
  if (window.__POWERED_BY_QIANKUN__) {
    let isInitialNavigation = true;
    router.afterEach((to) => {
      if (isInitialNavigation) {
        isInitialNavigation = false;
        return;
      }
      globalStore.syncRoute(to.path);
    });
  }

  // 如果有初始路径,先跳转再挂载
  if (window.__POWERED_BY_QIANKUN__ && initialPath && initialPath !== "/") {
    router.replace(initialPath).then(() => {
      instance.mount(container ? container.querySelector("#app") : "#app");
    });
  } else {
    instance.mount(container ? container.querySelector("#app") : "#app");
  }
}

2.6 isInitialNavigation 标志位的位置选择

在实现「跳过初始路由同步」时,isInitialNavigation 标志位的放置位置有三种选择:

方案 位置 特点
模块顶层 let isInitialNavigation = true 在文件顶部 只在子应用首次加载时为 true,切换后再回来不会重置
bootstrap 生命周期 bootstrap() 中设置 与模块顶层行为一致,bootstrap 只执行一次
render 函数内部 render() 函数内定义 每次 mount 都会重新初始化为 true ✅

我们选择在 render 函数内部定义标志位,原因:

  1. 语义正确:「跳过初始路由」的语义是「每次挂载时,跳过第一次路由同步」,而不是「整个应用生命周期只跳过一次」
  2. 场景覆盖:用户从 sub-app-1 切换到 sub-app-2,再切回 sub-app-1 时,子应用会重新 mount,此时应该再次跳过初始路由
  3. 逻辑内聚initialPath 本身就是通过 props 在 mount 时传入的,标志位放在 render 内部与之呼应
// ✅ 正确:标志位在 render 函数内部,每次 mount 都会重置
function render(props) {
  // ...
  if (window.__POWERED_BY_QIANKUN__) {
    let isInitialNavigation = true; // 每次 render 都重新初始化
    router.afterEach((to) => {
      if (isInitialNavigation) {
        isInitialNavigation = false;
        return;
      }
      globalStore.syncRoute(to.path);
    });
  }
}

// ❌ 错误:标志位在模块顶层,子应用切换后再回来不会重置
let isInitialNavigation = true; // 只在模块加载时初始化一次
function render(props) {
  // ...
  router.afterEach((to) => {
    if (isInitialNavigation) {
      isInitialNavigation = false;
      return;
    }
    globalStore.syncRoute(to.path);
  });
}

三、仪表盘模式(多子应用并行)

3.1 场景说明

仪表盘页面需要同时加载多个子应用,这是 loadMicroApp 相比 registerMicroApps + start 的核心优势。

3.2 dashboardMode 标识

在仪表盘模式下,子应用需要禁用某些功能:

// 主应用加载子应用时传递 dashboardMode
loadMicroApp({
  name: "sub-app-1",
  container: "#dashboard-app-1",
  props: {
    dashboardMode: true, // 关键标识
    // 其他通信方法...
  },
});

3.3 子应用行为差异

功能 单实例模式 仪表盘模式
URL 同步 ✅ 启用 ❌ 禁用(避免多子应用互相覆盖)
跨应用导航 ✅ 启用 ❌ 禁用(避免离开仪表盘页面)
内部路由 ✅ 启用 ✅ 启用
状态通信 ✅ 启用 ✅ 启用

3.4 子应用适配代码

// 子应用根据 dashboardMode 控制行为
router.afterEach((to) => {
  // 仪表盘模式下不同步 URL
  if (globalStore.dashboardMode) return;
  globalStore.syncRoute(to.path);
});
<!-- 仪表盘模式下隐藏跨应用导航按钮 -->
<template>
  <div v-if="!globalStore.dashboardMode" class="cross-app-nav">
    <button @click="navigateToOtherApp">跳转到其他应用</button>
  </div>
</template>

四、完整架构图

┌─────────────────────────────────────────────────────────────┐
│                        主应用 (main-app)                      │
│  ┌─────────────────────────────────────────────────────┐   │
│  │                   GlobalStore (Pinia)                │   │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐  │   │
│  │  │    state    │  │ subscribers │  │  mainRouter │  │   │
│  │  └─────────────┘  └─────────────┘  └─────────────┘  │   │
│  │                                                      │   │
│  │  Methods:                                            │   │
│  │  - getMainState()      - setMainState()             │   │
│  │  - onMainStateChange() - offMainStateChange()       │   │
│  │  - navigateTo()        - syncRoute()                │   │
│  │  - createSubAppMethods()                            │   │
│  └─────────────────────────────────────────────────────┘   │
│                              │                              │
│                    props 注入通信方法                        │
│                              ▼                              │
│  ┌────────────────┐ ┌────────────────┐ ┌────────────────┐  │
│  │   sub-app-1    │ │   sub-app-2    │ │   sub-app-3    │  │
│  │  (实时同步模式) │ │  (按需获取模式) │ │  (实时同步模式) │  │
│  │ memoryHistory  │ │ memoryHistory  │ │ memoryHistory  │  │
│  └────────────────┘ └────────────────┘ └────────────────┘  │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │              仪表盘页面 (/dashboard)                  │   │
│  │  ┌─────────────────┐    ┌─────────────────┐         │   │
│  │  │   sub-app-1     │    │   sub-app-3     │         │   │
│  │  │ dashboardMode   │    │ dashboardMode   │         │   │
│  │  └─────────────────┘    └─────────────────┘         │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

五、关键文件清单

主应用

  • src/types/global.ts - 类型定义
  • src/stores/globalStore.ts - 全局状态管理
  • src/config/microApps.ts - 子应用配置
  • src/main.ts - 应用入口
  • src/views/subApp1/index.vue - 子应用 1 加载组件
  • src/views/subApp2/index.vue - 子应用 2 加载组件
  • src/views/subApp3/index.vue - 子应用 3 加载组件
  • src/views/dashboard/index.vue - 仪表盘页面(多子应用并行)

子应用

  • src/stores/global.js - 子应用状态管理
  • src/main.js - 应用入口,生命周期钩子

六、注意事项

  1. 命名规范:所有传递给子应用的方法都使用 Main 前缀,避免 qiankun 覆盖
  2. 跨应用跳转:子应用跳转到主应用或其他子应用时,必须使用 navigateTo 方法
  3. 子应用内部跳转:子应用内部页面跳转使用自己的 router,会自动同步 URL
  4. 状态类型:新增状态属性需要先在 types/global.ts 中声明
  5. 卸载清理:子应用 unmount 时需要取消状态监听,重置 store
  6. 返回按钮:子应用内的"返回"按钮在微前端环境下应使用 router.push("/") 而非 router.back()
  7. 仪表盘模式:多子应用并行时必须传递 dashboardMode: true,禁用 URL 同步和跨应用导航

七、总结

通过自定义通信方案和 memoryHistory + 主应用统一导航的路由方案,我们解决了 qiankun 微前端中的核心问题:

  1. 通信问题:基于 Pinia 的自定义通信,支持实时同步和按需获取两种模式
  2. 路由问题:子应用使用 memoryHistory 避免 popstate 冲突,跨应用导航由主应用统一处理
  3. URL 同步:子应用内部路由变化通过 syncRoute 同步到浏览器地址栏
  4. 深层路由:通过 initialPath 支持直接访问子应用深层路由
  5. 多子应用并行:通过 dashboardMode 支持仪表盘等多子应用同时加载场景

这套方案已在实践中验证可行,可作为企业级微前端项目的基础架构。

TinyPro v1.4 空降:Spring Boot 集成,后端兄弟也能愉快写前端!

本文由体验技术团队Kagol原创。

TinyPro 是一个基于 TinyVue 打造的前后端分离的后台管理系统,支持在线配置菜单、路由、国际化,支持页签模式、多级菜单,支持丰富的模板类型,支持多种构建工具,功能强大、开箱即用!

我们很高兴地宣布,2026年1月10日,TinyPro 正式发布 v1.4.0 版本,本次发布集中在扩展后端模板、增强移动端体验以及对 NestJS 后端功能的实用增强。

本次 v1.4.0 版本主要有以下重大变更:

  • 增加 Spring Boot 后端
  • 增强移动端适配
  • 增加卡片列表和高级表单页面
  • 支持多设备登录
  • 支持配置预览模式

你可以更新 <span leaf="">@opentiny/tiny-toolkit-pro@1.4.0</span> 进行体验!

tiny install @opentiny/tiny-toolkit-pro@1.4.0

详细的 Release Notes 请参考:github.com/opentiny/ti…

1 支持 Spring Boot 后端

之前只有 NestJS 后端,有不少开发者提出需要 Java 版本后端,大家的需求必须安排,所以本次版本新增对 Spring Boot 的支持,使得偏 Java / Spring 的团队可以更快速地用熟悉的后端框架搭建 TinyPro 全栈样板。

该支持包括 Docker 化示例、配置覆盖示例(application.yaml 覆写示例)以及针对 deploy 的说明,便于在容器化环境中直接部署或做二次开发。

如果你或团队偏向 Java 技术栈,这次更新显著降低了启动成本与集成难度。

详细使用指南请参考文档:Spring Boot 后端开发指南

2 移动端响应式与布局优化

本次引入移动端适配方案,包含布局调整、样式优化和若干移动交互逻辑改进。配套增加了端到端测试(E2E),保证常见移动场景(小屏导航、侧边栏收起、页签/页面切换)行为稳定。

适配覆盖了常见断点,页面在手机端的易用性和可读性有明显提升,适合需要同时兼顾桌面与移动管理后台的项目。

效果如下:

高级表单.png

详细介绍请参考文档:TinyPro 响应式适配指南

3 增加卡片列表页面

之前列表页仅提供单一的查询表格形式,功能相对有限,难以满足日益多样化、复杂化的业务需求。为了提升用户体验、增强系统的灵活性,我们在原有基础上新增了一个卡片列表页面,以更直观、灵活的方式展示数据,满足不同场景下的使用需求。

体验地址:opentiny.design/vue-pro/pag…

效果如下:

卡片列表.png

4 增加高级表单页面

表单页增加了高级表单,在普通表单基础上增加了表格整行输入功能。

体验地址:opentiny.design/vue-pro/pag…

效果如下:

移动端效果.png

5 支持多设备登录

之前只能同时一个设备登录,后面登录的用户会“挤”掉前面登录的用户,本次版本为账号登录引入设备限制(Device Limit)策略,可限制单账号并发活跃设备数,有助于减少滥用和提高安全性,适配企业安全合规需求。

可通过 <span leaf="">nestJs/.env</span> 中的 <span leaf="">DEVICE_LIMIT</span> 进行配置。

比如配置最多 2 人登录:

DEVICE_LIMIT=2

如果不想限制登录设备数,可以设置为 -1:

DEVICE_LIMIT=-1

6 演示模式

由于配置了 RejectRequestGuard,默认情况下,所有接口都只能读,不能写,本次版本增加了演示模式(PREVIEW_MODE),要修改 NestJS 后端代码才能改成可写的模式(<span leaf="">nestJs/src/app.module.ts</span>)。

本次版本增加了演示模式的配置,可通过 <span leaf="">nestJs/.env</span> 中的 <span leaf="">PREVIEW_MODE</span> 进行配置。

<span leaf="">PREVIEW_MODE</span> 默认为 true, 会拒绝所有的增加、修改、删除操作,设置为 false,则变成可写模式。

PREVIEW_MODE=false

7 Redis 引入应用安装锁(redis app install lock)

主要用于避免重复安装或初始化时的竞态问题。

默认情况下,第一次运行 NestJS 后端,会生成 Redis 锁,后续重新运行 NestJS 后端,不会再更新 MySQL 数据库的数据。

如果你修改了默认的菜单配置(<span leaf="">nestJs/src/menu/init/menuData.ts</span>)或者国际化词条(<span leaf="">nestJs/locales.json</span>),希望重新初始化数据库,可以在开发机器 Redis 中运行 <span leaf="">FLUSHDB</span> 进行解锁,这样重新运行 NestJS 后端时,会重新初始化 MySQL 数据库的数据。

更多更新,请参考 Release Notes:github.com/opentiny/ti…

8 社区贡献

感谢所有为 v1.4.0 做出贡献的开发者!你们的辛勤付出让 TinyPro 变得更好!

  • GaoNeng-wWw
  • zhaoxiaofeng876
  • WangWant7
  • zzl12222
  • discreted66

注:排名不分先后,按名字首字母排序。

如果你有任何建议或反馈,欢迎通过 GitHub Issues 与我们联系,也欢迎你一起参与 TinyPro 贡献。

往期推荐文章

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~

OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue 源码:github.com/opentiny/ti…
TinyEngine 源码: github.com/opentiny/ti…

欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~ 如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

当代码照进生活:一个程序员眼中的欲望陷阱

写代码写了这么多年,我一直觉得编程和生活是两个完全不相干的世界。直到最近读了《模仿欲望》这篇文章,我才恍然大悟:原来我的生活,早就被写进了别人的代码里。

vx公众号:TSing_addiction

01. 我们都在implements一个看不见的接口

做TypeScript开发的人都知道,interface是定义一个对象"应该长什么样"的蓝图。我们常常这样写:

interface IdealLife {
  fancyCar: boolean;
  luxuryWatch: boolean;
  weekendTravel: boolean;
  perfectSkin: boolean;
}

然后我们拼命让自己去"实现"这个接口,买各种东西,去各种地方打卡,仿佛不这样就不是个"合格的对象"。

上周我刷小红书,看到一个和我同龄的博主展示他的"程序员精致生活":苹果全家桶、最新款机械键盘、咖啡店远程工作照。我的第一反应不是"这很酷",而是"我怎么没有这些"。那一刻,我突然意识到:我正在试图implements一个从未明确定义、却无处不在的社会接口。

最可怕的是,这个接口不是写在代码里的,而是通过算法悄悄注入到我们脑海中的。就像TypeScript的类型推断,我们甚至没有意识到自己正在被定义。

02. 闭包里的无限循环

在编程中,闭包是指函数记住并访问它的词法作用域,即使这个函数在其作用域外执行。这听起来很技术,但其实我们每天都在经历:

那天晚上,我本来只想刷5分钟抖音,结果两小时后才放下手机。算法给我推荐了第一个露营视频,我点了赞;然后是第二个、第三个...每一个互动都被记住,用来决定下一个推荐什么。我就这样被困在一个闭包里,不断循环,无法跳出。

就像这段代码:

let timeSpent = 0;
function scrollFeed() {
  timeSpent += 15; // 每次刷15分钟
  if (timeSpent < 120) { // 两小时后才意识到
    scrollFeed();
  } else {
    console.log("天啊,已经凌晨1点了!");
  }
}

我们的注意力就这样被算法捕获,形成一个完美的闭包,而我们甚至不知道自己被困住了。

03. "精致穷"就像危险的类型断言

做前端开发的都知道,TypeScript中有一种操作叫类型断言——你告诉编译器:"相信我,这个变量就是这个类型"。有时候这很危险,特别是当你断言一个不可能的类型时。

// 明知工资不高,却断言自己能过上博主的生活
const myWallet = { balance: 5000 } as LuxuryLifestyleWallet;

在编译阶段(发朋友圈时),一切看起来完美无缺。但到了运行时(月底交房租时),就会抛出一个残酷的错误:Uncaught BalanceError: Insufficient funds for projected lifestyle

我有个朋友就是这样。他月薪1万,却买了3万的相机,理由是"博主都用这个"。结果是接下来三个月天天吃泡面。这不就是把BudgetReality强行断言为InfluencerBudget的后果吗?

04. 职场内卷:递归函数没有退出条件

在编程中,递归是很强大的工具,但必须有明确的退出条件,否则会导致栈溢出。现在想想,职场内卷不就是这样一个没有退出条件的递归函数吗?

function workHarder(colleagues) {
  // 看到同事加班
  const maxOvertime = colleagues.reduce((max, c) => 
    Math.max(max, c.overtimeHours), 0);
  
  // 于是你也加更长时间的班
  myOvertimeHours = maxOvertime + 1;
  
  // 但没有人问:为什么要加班?
  return workHarder(updatedColleaguesList); 
  // 没有退出条件,直到精神崩溃
}

我们团队就有这样的情况。一开始只有一两个人晚走,渐渐地,七点下班变成了常态,然后是八点、九点...没有人明确要求这样,但就像递归函数没有base case,我们陷入了无限循环。

最讽刺的是,项目进度并没有因此加快多少。就像算法复杂度,我们的努力是O(n²),但产出只是O(n)。这种内卷,本质上就是一段写得很烂的代码。

05. 重写人生代码

意识到这些问题后,我开始尝试"重构"自己的生活代码。这不是件容易事,就像重构一个老旧的系统,处处都是隐患。

首先是依赖注入的问题。以前我的消费决策严重依赖外部注入:小红书推荐、博主种草、同事攀比。现在我尝试这样写:

class MyLife {
  private coreValues = ['growth', 'health', 'authenticity'];
  
  decidePurchase(item: any): boolean {
    // 不再依赖外部注入
    return this.coreValues.includes(item.value) && 
           this.budget.allows(item.price);
  }
}

其次是打破那个闭包。我给自己装了个屏幕时间管理app,设置每天刷短视频不超过30分钟。第一次看到自己一天刷了4小时短视频时,我惊呆了。这就像在调试时突然看到一个函数被调用了几百次——你必须找出为什么循环停不下来。

还有很重要的一点:接受自己不是泛型。TypeScript中有泛型,可以适配各种类型。但生活不是代码,我们不必成为"通用模板"。我开始允许自己:

  • 用便宜的键盘写代码(只要它好用)
  • 周末在家休息而不是去网红地点打卡
  • 穿舒适的衣服而不是"程序员该穿"的潮牌

06. 从any到明确类型

TypeScript最强大的功能之一,就是将JavaScript的"any"世界变成明确类型的系统。我们的人生也需要这样的转变——从"别人怎么做我也怎么做"(any)到"这是我真正想要的"(明确类型)。

以前,我的消费决定就像这样:

// 以前的我
let wantToBuy: any = trendingOnXiaohongshu;

现在,我尝试这样:

// 现在的我
let wantToBuy: unknown = trendingOnXiaohongshu;

function isRealNeed(item: unknown): item is GenuineNeed {
  return (
    typeof item === 'object' && 
    'solvesMyActualProblem' in item && 
    item.solvesMyActualProblem
  );
}

if (isRealNeed(wantToBuy)) {
  buy(wantToBuy);
} else {
  ignore(wantToBuy);
}

这种转变并不容易。有时候看到同事换了新手机,我还是会心动;刷到精致生活的内容,依然会感到焦虑。但至少现在,我有了一个"类型守卫"来检查这些欲望是否真实。

最后

作为一个程序员,我习惯了相信代码是理性的、有逻辑的。但我没想到,我们的欲望和行为,也早已被写入了某种看不见的代码中。

这篇文章不是要批判所有消费或所有社交媒体的使用。就像代码本身没有好坏,关键是谁在控制它,以及它服务于什么目的。

当我开始用程序员的眼睛观察生活,我发现自己不再那么容易被算法操控。当我看到小红书推送"必买清单",我会想:"这是callback hell,我不能陷入这个异步循环。"当同事炫耀新买的奢侈品,我会提醒自己:"不要进行不安全的类型断言。"

或许,理解这些"代码"就是夺回控制权的第一步。就像我们重构一段混乱的代码,生活也可以被重构成更真实、更符合自己核心价值观的样子。

下次当你想买一个东西、做一个重要决定,或者感到莫名焦虑时,不妨问自己:这真的是我的需求,还是我在implements别人的接口?我的人生代码,到底是谁在编写?

写完这篇文章,我要去关掉手机通知,好好享受一个没有算法干扰的周末。毕竟,最好的代码,是能服务于人,而不是让人服务于它的代码。

看板必备的丝滑、高端技巧 — 数字滚动

但行好事,莫问前程

前言

最近需要开发看板功能,涉及到给用户展示一些 number 数据的场景。

作为看板页面,我们要 避免突兀的数值变化,让数字的变化更加自然、更有视觉吸引力,提升用户体验。

最后我采用了 数字滚动 动效,并封装为 hooks 方便复用。

效果如下,其中有不少有趣的设计思路值得复盘。

numberscroll.gif

预览:我的后台 -> Editor
源文件:github.com/XIwE1/react…

<NumberScroll value={numberValue} options={{ decimals: 0 }} className="justify-center self-center" />

录屏2025-08-29 16.49.33.gif

文中已附上源代码和思路,如果对你有所帮助,还望点赞、收藏、关注三连😽。

方案

整理一下思路,如果要实现 0 -> 100 或者 100 -> 0,会想到什么方法?

  • 最简单的是直接更新对应 state,但这样一闪而过十分突兀
  • 其次是步进,即value / time = step,例如step = 100 -> 10 -> 20 ... -> 100
    • 这样不错,但这种线性的变化还是略显生硬
  • 最后我们可以利用缓动函数来控制过程
    • 0 -> ... -> 50 -> 70 -> 80 -> 85 -> ... -> 98 -> 99 -> 100

实现

  1. 迅速完成大部分数值变化,视觉上快速地接近最终值 +
  2. 剩余小部分差值平滑变化,平稳、自然减速并抵达终点

变量分为:更新频率 和 更新步幅(数值)

原理是结合变量与场景,使用缓动函数(贝塞尔曲线数学公式)。

程序逻辑

例:
初始值: 0 → 目标值: 1000,
duration = 3500ms,1 / 3 = 1167ms 时间用于快速的线性变化,剩下时间用于平滑滚动
线性阈值 = 1000 * 2 / 3 = 666.67,滑动值 = 1000 / 3 = 333.33
│
├─ determine(): 在不同节点,判断需要变化的数值A和线性阈值B,设置新的数值变化目标C
│  ├─ 第一阶段:剩余变化量A 1000 > 阈值B 666.67(大数值线性变化),0 → 目标C 666.67(线性,3500 / 3 = 1167ms)
│  └─ 第二阶段:剩余变化量A 333.33 < 阈值B 666.67(小数值平滑滚动),666.67 → 目标C 1000(缓动,3500 - 1167 = 2333ms)
│
└─ count() 循环执行:循环改变计数值
   ├─ 第一阶段:
   │  ├─ 线性变化
   │  ├─ 目标数值 = 666.67
   │  ├─ 当前数值 = 0 -> 666.67
   │  ├─ 耗时 = 3500 / 3 = 1167ms
   │  └─ 动画结束,调用 determine() 重新计算
   │
   └─ 第二阶段:
   │  ├─ 平滑滚动
   │  ├─ ...类上
      └─ 动画结束,currentValue = 1000

还有很多场景要考虑,数值为负数,减操作,数字被减为0...

缓动函数

简单来说,是 “通过数学公式来 控制 变化的进度 或者 运动的轨迹”

它使数据和动画摆脱生硬、呆板的线性变化,它让事物的变化 更加符合视觉常识

详情可见 贝塞尔曲线:实现更好的动画效果和图形

动画2.gif

// utils/index.ts
// 生成对应三次贝塞尔曲线的js代码
export function createBezierFunction(p1x: number, p1y: number, p2x: number, p2y: number) {
  return function (t: number) {
    return 3 * Math.pow(1 - t, 2) * t * p1y + 3 * (1 - t) * Math.pow(t, 2) * p2y + Math.pow(t, 3);
  };
}

实践

我们封装成hooks来重复使用

/src/hooks/useNumberDuration.ts

import { useEffect, useRef, useState, useMemo, useCallback } from "react";

// 缓动函数
const easeOutExpo = (t: number) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t));
const linear = (t: number) => t;

export interface UseNumberDurationProps {
  value: number;
  duration?: number;
  decimals?: number;
  split?: string;
}

const useNumberScroll = ({
  value,
  duration = 5500,
  decimals = 2,
  split = ",",
}: UseNumberDurationProps) => {
  const rafRef = useRef<number>();
  const currentRef = useRef<number>(0);
  const durationRef = useRef<number>(duration);

  const startTime = useRef<number>();
  const startValue = useRef<number>(0);
  /** 线性变化的值 */
  const EasingThreshold = useMemo(() => {
    const diff = value - startValue.current;
    const threshold = (Math.abs(diff) * 2) / 3;
    return startValue.current + (diff > 0 ? threshold : -threshold);
  }, [value]);

  /** 滑动变化的值 */
  const EasingAmount = useMemo(() => {
    const amount = value - EasingThreshold;
    return amount;
  }, [value, EasingThreshold]);

  // 当前的实际值
  const [currentValue, setCurrentValue] = useState(0);
  // 格式化后用于展示的值
  const _current = useMemo(
    () =>
      currentValue.toFixed(decimals).replace(/\B(?=(\d{3})+(?!\d))/g, split),
    [currentValue, decimals, split]
  );

  // 当前目标的结束值
  const endValue = useRef<number>(value);
  // 最终值
  const finalValue = useRef<number | null>(null);
  const result = useMemo(
    () => value.toFixed(decimals).replace(/\B(?=(\d{3})+(?!\d))/g, split),
    [value, decimals, split]
  );

  // determine - 判断变化采用线性还是缓动,设置最终值final和当前目标结束值end
  const determine = useCallback(() => {
    const end =
      finalValue.current !== null ? finalValue.current : endValue.current;
    const animateAmount = Math.abs(end - startValue.current);

    if (animateAmount > Math.abs(EasingThreshold)) {
      finalValue.current = end;
      endValue.current = end - EasingAmount;
      // 拿出小部分时间用于线性变化
      durationRef.current = duration / 3;
    } else {
      finalValue.current = null;
      endValue.current = end;
      durationRef.current = duration; // 这样动画滑动更明显一点
      // durationRef.current = (duration * 2) / 3;
    }
  }, [duration, EasingThreshold, EasingAmount]);

  const count = useCallback(
    (timestamp: number) => {
      if (!startTime.current) startTime.current = timestamp;
      // 根据时间差计算当前进度
      const elapsed = timestamp - startTime.current;
      const progress = Math.min(elapsed / durationRef.current, 1);
      const eased =
        finalValue.current !== null ? linear(progress) : easeOutExpo(progress);

      const currentValue =
        startValue.current + (endValue.current - startValue.current) * eased;

      currentRef.current = currentValue;
      setCurrentValue(currentValue);

      if (progress < 1) {
        rafRef.current = requestAnimationFrame(count);
      } else {
        startValue.current = currentValue;
        // 最终值不为空 = 还未到最终值 = 剩下的值要平滑增加
        if (finalValue.current !== null) {
          cancelAnimationFrame(rafRef.current!);
          startTime.current = undefined;
          endValue.current = finalValue.current;
          finalValue.current = null;
          determine();
          rafRef.current = requestAnimationFrame(count);
        }
      }
    },
    [determine]
  );

  useEffect(() => {
    if (rafRef.current) cancelAnimationFrame(rafRef.current);

    // 变化时重置状态
    endValue.current = value;
    finalValue.current = null;
    durationRef.current = duration;
    startValue.current = currentRef.current;
    startTime.current = undefined;
    // 判断当前的变化方式,并开始动画循环
    determine();
    rafRef.current = requestAnimationFrame(count);

    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
      startTime.current = undefined;
    };
  }, [value, duration]);

  return { current: _current, result };
};

export default useNumberScroll;

数字滚动

上述的hooks已经实现数字增长的效果了,但还有样式上的问题需要解决,

  1. 数据变化的过程中,元素占宽也在不断变化
  2. 非等宽字体的数字占宽不同

以上因素会导致 数据抖动

我采用的解决方案:

  • if 可以使用等宽字体
    • 预先使用 result 渲染真实元素获取所占宽度 estimateWidth
  • else
    • result 内的数字替换为占宽最大数字来渲染预估值

最后我们封装一个ui组件进行复用

/src/ui/NumberScroll/index.tsx

import React, { useRef, useLayoutEffect, useState } from "react";
import useNumberScroll, { UseNumberDurationProps } from "../hooks/useNumberScroll";

interface NumberScrollProps {
  value: number;
  options?: Omit<UseNumberDurationProps, 'value'>;
  className?: string;
  suffix?: string;
  style?: React.CSSProperties;
}

const NumberScroll: React.FC<NumberScrollProps> = ({
  value,
  options,
  suffix = "",
  className,
  style,
}) => {
  const { current, result } = useNumberScroll({
    value,
    ...options
  });

  const measureRef = useRef<HTMLSpanElement>(null);
  const [fixedWidth, setFixedWidth] = useState<number | undefined>(undefined);

  useLayoutEffect(() => {
    if (measureRef.current) {
      const { width } = measureRef.current.getBoundingClientRect();
      setFixedWidth(width);
    }
  }, [result]);

  return (
    <>
      {/* estimate width */}
      <span
        className={className}
        ref={measureRef}
        style={{
          position: "absolute",
          visibility: "hidden",
          height: "auto",
          width: "auto",
          whiteSpace: "nowrap",
          ...style,
        }}
      >
        {result}
        {suffix}
      </span>

      <span
        className={className}
        style={{
          display: "inline-block",
          width: fixedWidth,
          ...style,
        }}
      >
        {current}
        {suffix}
      </span>
    </>
  );
};

export default NumberScroll;

总结

我们可以看出数字滚动动画的核心:

1. 分阶段策略

  • 大数值快速线性变化:使用 1/3 的时间快速完成 2/3 的数值变化
  • 小数值平滑缓动:使用 2/3 的时间平滑完成剩余的 1/3 变化
  • 关键参数:线性阈值(2/3)、滑动值(1/3)、时间分配(1/3 vs 2/3)

2. 缓动函数选择

  • 第一阶段:使用 linear 线性函数,快速接近目标
  • 第二阶段:使用 easeOutExpo 指数缓出函数,自然减速到达

3. 样式优化

  • 固定宽度:避免动画过程中的布局抖动
  • 等宽字体优先:如果可以使用等宽字体,直接测量最终宽度
  • 预估宽度:非等宽字体时,用最宽数字预估宽度

4. 封装复用

  • 逻辑层:使用 hooks 处理动画逻辑,返回当前值和最终值
  • UI 层:使用组件处理样式和布局,确保视觉稳定

结语

不要光看不实践哦,希望本文能对你有所帮助。

持续更新前端知识,脚踏实地不水文,真的不关注一下吗~

写作不易,如果有收获还望 点赞+收藏 🌹

才疏学浅,如有问题或建议还望指教~

这 5 个冷门 HTML 标签,让我直接删了100 行 JS 代码!

在写前端的时候,我们实现的比较多的一些基础交互,比如折叠面板、弹窗、输入提示、进度条或颜色选择等等,会不得不引入 JavaScript

但其实,HTML 自己也内置了不少功能强大的原生标签,它们开箱即用、语义清晰,还能大幅减少 JS 的代码量。

下面介绍 5 个冷门但实用的 HTML 标签。

1. <details><summary> - 可折叠内容

替代: 手风琴效果、折叠面板、FAQ部分

<details>
  <summary>点击查看详情</summary>
  <p>隐藏的内容,无需JS实现展开/收起</p>
</details>

实现效果:

使用场景

  • FAQ 折叠面板
  • 设置项分组展开
  • 移动端“查看更多”区域

注意事项

  • 默认是关闭状态;添加 open 属性可默认展开:<details open>
  • 可通过 CSS 的 details[open] 选择器定制展开样式
  • 支持键盘操作(Enter/Space 触发),无障碍友好

2. <dialog> - 原生对话框

替代:div模拟模态框 + 背景遮罩 + 关闭逻辑

<dialog id="modal">
  <p>这是原生弹窗</p>
  <button onclick="document.getElementById('modal').close()">关闭</button>
</dialog>
<button onclick="document.getElementById('modal').showModal()">打开弹窗</button>

实现效果:

使用场景

  • 确认提示框
  • 登录/注册弹窗
  • 临时信息展示

注意事项

  • .showModal() 会自动创建半透明遮罩(可通过 ::backdrop 自定义)
  • .show() 是非模态显示(不锁定背景)
  • 聚焦自动管理:打开时聚焦第一个可聚焦元素,关闭后焦点返回触发按钮
  • 兼容性:Chrome/Firefox/Edge 支持良好;Safari 15.4+ 支持;IE 不支持

3. <datalist> - 输入建议列表

替代:监听input事件 + 动态生成下拉列表

<input list="browsers" placeholder="选择或输入浏览器">
<datalist id="browsers">
  <option value="Chrome">
  <option value="Firefox">
  <option value="Safari">
</datalist>

实现效果:

使用场景

  • 搜索建议(非强制选项)
  • 表单字段预填(如城市、产品名)
  • 快速输入辅助

注意事项

  • 用户仍可输入不在列表中的值(与 <select> 不同)
  • 浏览器会自动根据输入过滤匹配项
  • 移动端会调出带建议的软键盘(部分浏览器支持)

4. <meter> & <progress> - 进度指示器

替代:div模拟进度条 + JS更新宽度

<!-- 已知范围内的标量值(如磁盘使用率) -->
<meter min="0" max="100" value="70">70%</meter>

<!-- 任务完成进度(如文件上传) -->
<progress value="50" max="100">50%</progress>

实现效果:

使用场景

  • 搜索建议(非强制选项)
  • 表单字段预填(如城市、产品名)
  • 快速输入辅助

注意事项

  • 用户仍可输入不在列表中的值(与 <select> 不同)
  • 浏览器会自动根据输入过滤匹配项
  • 移动端会调出带建议的软键盘(部分浏览器支持)

5. <input type="color"> - 颜色选择器

替代:自定义颜色选择器UI + 色值转换逻辑

<input type="color" value="#ff0000">

实现效果:

使用场景

  • 主题配色设置
  • 图表颜色配置
  • 设计工具中的拾色功能

注意事项

  • 返回值始终为 小写 7 位十六进制(如 #ff5733
  • 移动端会调出系统级颜色选择器
  • 无法自定义 UI,但可通过 ::-webkit-color-swatch 微调样式(有限)

总结

  • <details> / <summary>:实现折叠内容
  • <dialog>:原生弹窗,自带遮罩和焦点管理
  • <datalist>:输入建议选择
  • <meter> / <progress>:进度展示无需手动计算宽度
  • <input type="color">:系统级颜色选择器开箱即用

这些原生 HTML 标签虽然不太起眼,但用好它们,不仅能省去大量 JavaScript 逻辑,还能让页面更语义化、更友好。

本文首发于公众号:程序员大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

qiankun两种加载模式registerMicroApps和loadMicroApp对比分析

概述

qiankun 提供了两种加载子应用的方式:

  1. registerMicroApps + start 模式:声明式,由 qiankun 自动管理子应用生命周期
  2. loadMicroApp 模式:命令式,由开发者手动控制子应用加载/卸载

本文将深入分析两种模式的技术区别、适用场景、可能遇到的问题及解决方案。


一、技术原理对比

1.1 registerMicroApps + start 模式

import { registerMicroApps, start } from "qiankun";

// 注册子应用
registerMicroApps([
  {
    name: "sub-app-1",
    entry: "//localhost:3000",
    container: "#sub-app-container",
    activeRule: "/sub-app-1",
  },
]);

// 启动 qiankun
start();

工作原理:

  • start() 会启动 qiankun 的路由监听(基于 single-spa)
  • qiankun 监听 popstatehashchange 事件
  • 当 URL 匹配 activeRule 时,自动加载对应子应用
  • 当 URL 不再匹配时,自动卸载子应用

1.2 loadMicroApp 模式

import { loadMicroApp } from "qiankun";

// 在需要的时机手动加载
const microApp = loadMicroApp({
  name: "sub-app-1",
  entry: "//localhost:3000",
  container: "#sub-app-container",
});

// 在需要的时机手动卸载
microApp.unmount();

工作原理:

  • 不需要调用 start()
  • 开发者完全控制加载和卸载时机
  • 通常配合主应用的路由组件生命周期使用

二、核心区别对比表

特性 registerMicroApps + start loadMicroApp
路由控制 qiankun 自动控制 开发者手动控制
是否需要 start() ✅ 必须调用 ❌ 不需要
子应用容器 必须始终存在于 DOM 可动态创建/销毁
主应用路由定义 通过 activeRule 匹配 不依赖路由,可在任意场景使用
多子应用同时加载 ❌ 默认路由互斥,难以实现 ✅ 天然支持,核心优势
生命周期控制 自动 手动
适合场景 简单的路由切换 复杂的动态加载需求
仪表盘/工作台 ❌ 不适合 ✅ 最佳选择
弹窗/Tab 中加载 ❌ 不适合 ✅ 天然支持

三、registerMicroApps + start 模式详解

3.1 优点

  1. 配置简单:只需注册一次,qiankun 自动处理
  2. 开箱即用:不需要额外的路由配置
  3. 统一管理:所有子应用配置集中在一处

3.2 缺点与问题

问题 1:容器必须始终存在

<!-- ❌ 错误:容器随路由销毁 -->
<template>
  <router-view />
</template>

<!-- 子应用页面组件 -->
<template>
  <div id="sub-app-container"></div>
</template>

当路由切换时,容器被销毁,qiankun 会报错:

[qiankun]: Target container with #sub-app-container not existed

解决方案:

<!-- ✅ 正确:容器始终存在,用 v-show 控制显示 -->
<template>
  <div>
    <!-- 主应用内容 -->
    <router-view v-show="!isSubApp" />

    <!-- 子应用容器始终存在 -->
    <div id="sub-app-1-container" v-show="currentApp === 'sub-app-1'"></div>
    <div id="sub-app-2-container" v-show="currentApp === 'sub-app-2'"></div>
  </div>
</template>

<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";

const route = useRoute();
const isSubApp = computed(() => route.path.startsWith("/sub-app"));
const currentApp = computed(() => {
  if (route.path.startsWith("/sub-app-1")) return "sub-app-1";
  if (route.path.startsWith("/sub-app-2")) return "sub-app-2";
  return "";
});
</script>

问题 2:主子应用路由冲突

当主应用和子应用都使用 createWebHistory 时,两个路由实例都会监听 popstate 事件,导致:

  • 浏览器返回按钮行为异常
  • 历史记录跳转混乱

解决方案:

  • 子应用使用 createMemoryHistory(推荐)
  • 或者在子应用卸载时销毁路由监听

3.3 完整示例

// main.ts
import { createApp } from "vue";
import { registerMicroApps, start } from "qiankun";

const app = createApp(App);
app.mount("#app");

registerMicroApps([
  {
    name: "sub-app-1",
    entry: "//localhost:3000",
    container: "#sub-app-1-container",
    activeRule: "/sub-app-1",
    props: {
      /* 传递给子应用的数据 */
    },
  },
]);

start({
  sandbox: {
    experimentalStyleIsolation: true,
  },
});

四、loadMicroApp 模式详解

4.1 优点

  1. 灵活控制:完全掌控加载/卸载时机
  2. 动态容器:容器可以随组件动态创建销毁
  3. 多实例支持:可以同时加载多个子应用实例
  4. 与主应用路由解耦:不依赖 qiankun 的路由监听
  5. 多子应用并行加载:这是 loadMicroApp 的核心优势,可以在同一页面同时加载多个子应用

4.2 核心优势:多子应用并行加载

这是 loadMicroApp 相比 registerMicroApps + start 模式最重要的差异化能力。

为什么 registerMicroApps + start 难以实现多子应用并行?

registerMicroApps + start 模式基于 single-spa 的路由监听机制,其设计理念是:

  • 一个 URL 对应一个激活的子应用
  • 当 URL 变化时,自动卸载当前子应用,加载新子应用
  • 子应用之间是路由互斥的关系

虽然可以通过配置多个 activeRule 让多个子应用同时激活,但这需要:

  1. 复杂的 activeRule 配置
  2. 多个容器必须始终存在于 DOM
  3. 难以实现灵活的布局控制

loadMicroApp 如何实现多子应用并行?

// 在同一个页面组件中,同时加载多个子应用
import { loadMicroApp } from "qiankun";
import { onMounted, onUnmounted } from "vue";

let microApp1 = null;
let microApp3 = null;

onMounted(() => {
  // 加载第一个子应用
  microApp1 = loadMicroApp({
    name: "sub-app-1",
    entry: "//localhost:3000",
    container: "#dashboard-app-1",
    props: { dashboardMode: true },
  });

  // 加载第二个子应用
  microApp3 = loadMicroApp({
    name: "sub-app-3",
    entry: "//localhost:3002",
    container: "#dashboard-app-3",
    props: { dashboardMode: true },
  });
});

onUnmounted(() => {
  microApp1?.unmount();
  microApp3?.unmount();
});

实际业务场景

场景 描述 实现方式
运维仪表盘 同时展示多个监控子系统(日志、指标、告警) 多个子应用并排显示
工作台页面 同时加载邮件、日历、任务等多个微应用 网格布局展示多个子应用
对比分析页 同时展示不同数据源的分析结果 左右对比布局
多租户管理 同时管理多个租户的配置 Tab 或分栏布局

仪表盘模式的特殊处理

在仪表盘模式下,子应用需要运行在「小部件模式」:

// 主应用传递 dashboardMode 标识
loadMicroApp({
  name: "sub-app-1",
  entry: "//localhost:3000",
  container: "#dashboard-app-1",
  props: {
    dashboardMode: true, // 关键标识
    // 其他通信方法...
  },
});

子应用根据 dashboardMode 调整行为:

功能 单实例模式 仪表盘模式
URL 同步 ✅ 启用 ❌ 禁用
跨应用导航 ✅ 启用 ❌ 禁用
内部路由 ✅ 启用 ✅ 启用
状态通信 ✅ 启用 ✅ 启用
// 子应用中根据 dashboardMode 控制行为
router.afterEach((to) => {
  // 仪表盘模式下不同步 URL
  if (props.dashboardMode) return;
  props.syncRoute?.(to.path);
});

4.3 需要处理的问题

问题 1:需要手动管理生命周期

<script setup>
import { loadMicroApp } from "qiankun";
import { onMounted, onUnmounted } from "vue";

let microApp = null;

onMounted(() => {
  microApp = loadMicroApp({
    name: "sub-app-1",
    entry: "//localhost:3000",
    container: "#sub-app-container",
  });
});

onUnmounted(() => {
  if (microApp) {
    microApp.unmount();
    microApp = null;
  }
});
</script>

问题 2:子应用路由与浏览器 URL 同步

使用 createMemoryHistory 后,子应用路由不会反映在浏览器地址栏。需要:

  1. 主应用传递初始路径给子应用
  2. 子应用内部路由变化时同步到浏览器 URL

主应用传递初始路径:

// 从路由参数提取子路径
const subpath = route.params.subpath;
const initialPath = subpath
  ? `/${Array.isArray(subpath) ? subpath.join("/") : subpath}`
  : "/";

loadMicroApp({
  // ...
  props: {
    initialPath,
  },
});

子应用处理初始路径并同步路由:

function render(props) {
  const { initialPath } = props;

  router = createRouter({
    history: window.__POWERED_BY_QIANKUN__
      ? createMemoryHistory()
      : createWebHistory("/"),
    routes,
  });

  // 注册路由同步(跳过初始路由避免覆盖 URL)
  if (window.__POWERED_BY_QIANKUN__) {
    let isInitialNavigation = true;
    router.afterEach((to) => {
      if (isInitialNavigation) {
        isInitialNavigation = false;
        return;
      }
      globalStore.syncRoute(to.path);
    });
  }

  // 如果有初始路径,先跳转再挂载
  if (window.__POWERED_BY_QIANKUN__ && initialPath && initialPath !== "/") {
    router.replace(initialPath).then(() => {
      instance.mount(container ? container.querySelector("#app") : "#app");
    });
  } else {
    instance.mount(container ? container.querySelector("#app") : "#app");
  }
}

问题 3:主应用路由配置(可选)

如果希望子应用有独立的 URL 路径(如 /sub-app-1/about),可以配置路由:

// 主应用路由配置(可选,用于 URL 同步场景)
const routes = [
  {
    // 匹配子应用所有路径
    path: "/sub-app-1/:subpath(.*)*",
    component: () => import("@/views/SubApp1.vue"),
  },
];

注意:这不是 loadMicroApp 的必需配置。loadMicroApp 可以在任何场景使用,包括:

  • 弹窗中嵌入子应用
  • Tab 页签中加载子应用
  • 侧边栏小部件
  • 任意 DOM 容器中

4.4 完整示例

<!-- SubApp1.vue -->
<template>
  <div id="sub-app-1-container"></div>
</template>

<script setup>
import { loadMicroApp } from "qiankun";
import { onMounted, onUnmounted } from "vue";
import { useRoute } from "vue-router";
import { microAppConfigs } from "@/main";

const route = useRoute();
let microApp = null;

onMounted(() => {
  const subpath = route.params.subpath;
  const initialPath = subpath
    ? `/${Array.isArray(subpath) ? subpath.join("/") : subpath}`
    : "/";

  const config = microAppConfigs["sub-app-1"];
  microApp = loadMicroApp(
    {
      ...config,
      props: {
        ...config.props,
        initialPath,
      },
    },
    {
      sandbox: {
        experimentalStyleIsolation: true,
      },
    }
  );
});

onUnmounted(() => {
  microApp?.unmount();
  microApp = null;
});
</script>

五、如何选择?

5.1 选择 registerMicroApps + start 的场景

  • ✅ 子应用数量固定,不需要动态加载
  • ✅ 子应用之间互斥,同一时间只显示一个
  • ✅ 主应用布局简单,容器可以始终存在
  • ✅ 希望快速集成,减少代码量
  • ✅ 不需要精细控制子应用生命周期

5.2 选择 loadMicroApp 的场景

  • ✅ 子应用容器需要随路由动态创建/销毁
  • 需要同时加载多个子应用(仪表盘、工作台场景)
  • ✅ 需要精细控制加载时机(如懒加载、条件加载)
  • ✅ 主应用有复杂的布局结构
  • ✅ 需要在非路由场景加载子应用(如弹窗、Tab)
  • ✅ 子应用可能被多次加载/卸载
  • 需要子应用运行在「小部件模式」(禁用 URL 同步)

5.3 决策流程图

开始
  │
  ▼
需要同时显示多个子应用?(仪表盘/工作台场景)
  │
  ├─ 是 → 推荐 loadMicroApp(唯一合理选择)
  │
  └─ 否 → 子应用容器能否始终存在于 DOM?
            │
            ├─ 是 → 子应用之间是否互斥?
            │         │
            │         ├─ 是 → 推荐 registerMicroApps + start
            │         │
            │         └─ 否 → 推荐 loadMicroApp
            │
            └─ 否 → 推荐 loadMicroApp

六、两种模式能否共存?

答案:可以共存,但需要注意。

6.1 共存场景

  • 部分子应用使用 registerMicroApps 自动加载
  • 部分子应用使用 loadMicroApp 手动加载(如弹窗中的子应用)

6.2 共存示例

import { registerMicroApps, start, loadMicroApp } from "qiankun";

// 自动加载的子应用
registerMicroApps([
  {
    name: "main-sub-app",
    entry: "//localhost:3000",
    container: "#main-container",
    activeRule: "/main-sub-app",
  },
]);

start();

// 手动加载的子应用(如在弹窗中)
function openSubAppModal() {
  const microApp = loadMicroApp({
    name: "modal-sub-app",
    entry: "//localhost:3001",
    container: "#modal-container",
  });

  // 关闭弹窗时卸载
  onModalClose(() => microApp.unmount());
}

6.3 注意事项

  1. 避免同名子应用:两种方式加载的子应用 name 不能重复
  2. 容器隔离:确保容器 ID 不冲突
  3. 路由冲突registerMicroAppsactiveRule 不要与 loadMicroApp 的触发路由重叠

七、我们项目的选择

7.1 为什么选择 loadMicroApp?

  1. 布局需求:我们的子应用页面有独立的控制面板,容器随路由组件创建/销毁
  2. 路由控制:需要精确控制子应用的加载时机
  3. 历史记录:通过 createMemoryHistory 避免主子应用路由冲突
  4. 仪表盘场景:需要在同一页面同时加载多个子应用(sub-app-1 和 sub-app-3)

7.2 仪表盘页面实现

我们实现了一个仪表盘页面(/dashboard),同时加载 sub-app-1 和 sub-app-3 两个子应用:

<!-- DashboardView.vue -->
<template>
  <div class="dashboard">
    <h1>多子应用仪表盘</h1>

    <!-- 统一控制面板 -->
    <div class="control-panel">
      <button @click="broadcastToAll">向所有子应用发送数据</button>
    </div>

    <!-- 子应用并排显示 -->
    <div class="apps-container">
      <div class="app-wrapper">
        <h3>Sub-App-1</h3>
        <div id="dashboard-app-1"></div>
      </div>
      <div class="app-wrapper">
        <h3>Sub-App-3</h3>
        <div id="dashboard-app-3"></div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { loadMicroApp } from "qiankun";
import { onMounted, onUnmounted } from "vue";

let microApp1 = null;
let microApp3 = null;

onMounted(() => {
  // 同时加载两个子应用,传递 dashboardMode: true
  microApp1 = loadMicroApp({
    name: "sub-app-1",
    entry: "//localhost:3000",
    container: "#dashboard-app-1",
    props: {
      dashboardMode: true,
      // 其他通信方法...
    },
  });

  microApp3 = loadMicroApp({
    name: "sub-app-3",
    entry: "//localhost:3002",
    container: "#dashboard-app-3",
    props: {
      dashboardMode: true,
      // 其他通信方法...
    },
  });
});

onUnmounted(() => {
  microApp1?.unmount();
  microApp3?.unmount();
});
</script>

7.3 我们做的兼容处理

问题 解决方案
容器动态创建 在 Vue 组件 onMounted 中调用 loadMicroApp
路由冲突 子应用使用 createMemoryHistory
初始路径同步 主应用提取 subpath 参数传递给子应用
子应用内部路由同步 通过 syncRoute 使用 history.replaceState 更新浏览器 URL
跨应用导航 统一通过 navigateTo 由主应用 router 处理
子应用返回按钮 微前端环境下使用 router.push("/") 而非 router.back()
仪表盘模式 通过 dashboardMode: true 禁用 URL 同步和跨应用导航
多子应用状态同步 主应用 GlobalStore 统一管理,变化时通知所有子应用

八、总结

维度 registerMicroApps + start loadMicroApp
复杂度
灵活性
容器要求 必须始终存在 可动态创建
多实例 ❌ 默认路由互斥 ✅ 天然支持
仪表盘场景 ❌ 不适合 ✅ 最佳选择
适合场景 简单路由切换 复杂动态加载

核心原则:

  • 简单场景用 registerMicroApps + start
  • 复杂场景用 loadMicroApp
  • 需要多子应用并行加载时,必须使用 loadMicroApp
  • 可以根据不同子应用的需求混合使用

loadMicroApp 的核心优势:

  1. 多子应用并行加载:这是 registerMicroApps + start 难以实现的场景
  2. 灵活的运行模式:通过 dashboardMode 等参数控制子应用行为
  3. 完全的生命周期控制:主应用完全掌控子应用的加载和卸载时机

使用InterSection进行页面图片加载优化思路

讲讲业务场景

当页面上出现大量的图片/视频/音频 在页面的加载时进行同步请求会造成页面卡顿

页面卡顿原因

1.网络请求的拥堵与等待

浏览器从服务器获取资源的第一步,就容易出现“堵车”。

  • 资源过多,带宽竞争:浏览器对同一域名的并发请求数有上限(通常6-8个)。当页面上有几十甚至上百张图片时,它们需要排队等待加载。这会直接拖慢后续关键资源(如CSS、JavaScript文件)的下载,从而阻塞整个页面的渲染。使用HTTP/1.1时,队头阻塞(Head-of-Line blocking)问题会更明显,即一个响应慢的资源会阻塞后续所有资源。
  • 单个文件体积过大:一张未经压缩的高清图片或一段视频可能达到几MB甚至几十MB。在带宽有限的情况下,加载一个10MB的文件可能需要8秒以上,这会显著增加用户看到完整页面的时间。
2.浏览器解析、渲染与解码的压力

资源下载后,浏览器需要进行一系列处理才能将其呈现出来,这个阶段同样压力重重。

  • 渲染阻塞:虽然图片、视频、音频本身不阻塞DOM树的构建,但它们依赖的CSS和JavaScript可能会。如果页面脚本需要等待这些多媒体资源的尺寸信息,或者CSS文件过大,浏览器就可能延迟渲染,导致白屏时间变长。
  • 主线程占用与回流重绘:浏览器的渲染、布局、绘制以及JavaScript运行主要都在主线程上完成,这相当于浏览器的“大脑”。大量多媒体资源需要主线程进行解码和渲染,尤其是当图片或视频尺寸发生变化时,会触发昂贵的回流(重排)和重绘,进一步占用CPU资源,导致页面响应迟钝。
  • 昂贵的解码成本:图片和视频文件需要被解码成浏览器能够直接处理的位图格式,这个过程非常消耗CPU资源。同时渲染大量图片或播放高清视频时,解码压力会陡增,尤其在处理GIF或自动播放的视频时更为明显。
3.内存与存储的持续占用

资源被加载和解码后,并不会马上消失,而是会持续占用系统资源。

  • 内存占用激增:每张图片、每个视频帧解码后都会占用一定的内存。当用户快速滑动页面,大量图片涌入又来不及被垃圾回收机制回收时,内存占用会急剧上升。在内存有限的移动设备上,这可能导致浏览器崩溃或页面被强行重新加载。
  • 存储I/O压力:大体积的多媒体文件会增加服务器磁盘的I/O压力。服务器需要花更多时间从磁盘读取数据再返回给浏览器,这可能间接影响服务器响应其他请求的速度

代码实现(React+TS):

前情提要:SongList组件中封装了SongMenu组件,并传参给子组件item(包含imgUrl)每个SongMenu中展示自己的imageUrl

NO.1 在每个Song-Menu实现一个观察者 与SongMenu的组件生命周期相关联

import React, { memo, FC, useRef, useEffect, useState } from 'react';
import { SongsMenuWrapper } from './style';
import { formatCount,getImageSize } from '../../utils/formar';
import { useIntersectionObserver } from '../../hooks/useInterSectionObserver';
import { isVisible } from '@testing-library/user-event/dist/utils';


interface IProps{
    children?:React.ReactNode
    itemData?:any
    isVisible?:boolean
}

const SongsMenu: FC<IProps>= (props)=> {
    const { itemData, isVisible = false } = props;
    const [isLoaded, setIsLoaded] = useState(false);

    const [imgRef, isIntersecting] = useIntersectionObserver<HTMLImageElement>({
        once: true,
        rootMargin: '0px 0px 200px 0px',
        threshold: 0.1,
    });

    useEffect(() => {
        if(isIntersecting && itemData?.picUrl && imgRef?.current && !isLoaded){
            setIsLoaded(true);
            imgRef.current!.src = getImageSize(itemData.picUrl, 100, 100);
        }
    }, [itemData, isIntersecting])


    return (
        <SongsMenuWrapper>
            <div className="cover_top">
                {/* 初始时src为空,避免提前加载 */}
                <img 
                    ref={imgRef} 
                    alt={itemData?.name} 
                />
                <div className="cover sprite_cover">
                    <div className="info sprite_cover">
                        <span>
                            <i className='headset sprite_icon'>
                                { formatCount(itemData?.playCount || 0) }
                            </i>
                        </span>
                        <i className='play sprite_icon'></i>
                    </div>
                </div>
            </div>

            <div className="cover_bottom">
                {itemData?.name}
            </div>
        </SongsMenuWrapper>
    )
}

export default memo(SongsMenu);

IntserSectionObserver的实现原理

  • 异步检测与更新队列浏览器渲染引擎有一个专门的“更新相交观察步骤”(Update Intersection Observations Steps)。这个步骤被集成在渲染帧的“更新渲染”(Update the rendering)阶段,通常在执行完 requestAnimationFrame回调之后进行。这意味着,浏览器会利用自身的布局信息来高效计算相交状态,而不是响应高频率的滚动事件。检测到的变化会被放入一个队列,异步地通知给观察者。
  • 相交区域的计算算法当计算一个目标元素与根元素的相交情况时,浏览器会遵循一个特定的算法:
  • 获取目标元素矩形:首先,通过类似 getBoundingClientRect()的方法获取目标元素完整的边界矩形。
  • 遍历祖先元素应用裁剪:接着,从目标元素的直接父级开始,向上遍历直到根元素。如果路径上的祖先元素设置了非 visibleoverflow属性或者是类似 <iframe>的浏览上下文,则会根据这些元素的裁剪区域(clipping area)对第一步得到的矩形进行逐级裁剪
  • 映射与求交:最终,将裁剪后得到的矩形映射到根元素的坐标空间,并与根元素的边界(可被 rootMargin扩展或收缩)求交,得到最终的 intersectionRect(交叉区域)。相交比例则由 intersectionRect面积与目标元素完整矩形面积的比值得出。
  • 阈值(Threshold)的触发机制你设置的 threshold数组(如 [0, 0.25, 0.5, 1])决定了回调函数在哪些关键点触发。浏览器会跟踪当前的 intersectionRatio和之前的状态。只有当相交比例穿过你设置的阈值点时,才会将相应的条目加入回调的 entries数组。例如,从 0.2 滚动到 0.4,如果设置了 0.25 和 0.5 两个阈值,则只会在穿过 0.25 时触发一次回调
InterSectionObserver的底层原理
1.观察者实例与目标管理

在底层,每个 IntersectionObserver实例内部确实维护着关键数据:

  • 观察目标列表:每个观察者实例都持有一个它正在观察的DOM元素列表。当你调用 observe(element)时,这个元素就会被添加到内部列表中进行追踪 。
  • 配置信息:实例化时传入的 root, rootMargin, threshold等选项也被存储在实例内部,用于后续的交叉计算 。

浏览器内核(如Blink)会维护一个全局的注册表(Registry) ,所有活跃的 IntersectionObserver实例都会在此注册,这确保了只要观察者还在工作,它和其观察的目标就不会被垃圾回收机制错误回收 。

2.异步检测与线程协作

IntersectionObserver的高性能秘诀并非在于为每个观察者“新开一个独立的异步线程”,而是利用了浏览器现有的渲染引擎的工作机制

  1. 集成于渲染流水线:交叉检测并非在独立的线程中循环不断计算,而是巧妙地“挂载”在浏览器的渲染过程中。核心计算发生在渲染帧(Frame)的某个特定阶段,通常是在样式计算(Style)和布局(Layout)之后。此时,浏览器已经为了绘制页面而计算出了每个元素的精确几何信息(位置、大小),IntersectionObserver可以直接利用这些现成的布局数据,避免了重复计算带来的性能损耗 。
  2. 批量异步处理:正因为与渲染流程绑定,所有观察者的交叉状态计算是批量(Batched) 进行的。浏览器会在一个渲染帧内,集中处理所有需要检查的观察目标,计算它们的交叉状态。这个过程对JavaScript主线程是异步的。计算完成后,如果发现某个目标的交叉状态发生了变化(例如,穿过了你设置的阈值),才会将对应的回调函数放入JavaScript的消息队列,等待主线程空闲时执行 。这种机制避免了在密集滚动等场景下,频繁的同步计算阻塞主线程,从而解决了传统 scroll事件监听带来的性能问题 。规范中提到,其实现优先级较低,甚至会采用类似 requestIdleCallback的机制,在浏览器空闲时才执行回调,进一步减少对用户交互的影响

NO.2 封装通用的hooks,便于组件间通用

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

interface UseIntersectionObserverOptions {
  root?: Element | null;
  rootMargin?: string;
  threshold?: number | number[];
  once?: boolean;
  defaultVisible?: boolean;
}

export function useIntersectionObserver<T extends Element>(
  options: UseIntersectionObserverOptions = {}
): [RefObject<T> | null, boolean] {
  const { 
    root = null, 
    rootMargin = '0px', 
    threshold = 0, 
    once = false,
    defaultVisible = false
  } = options;

  const targetRef = useRef<T>(null) as RefObject<T>;
  const [isIntersecting, setIsIntersecting] = useState(defaultVisible);
  const observerRef = useRef<IntersectionObserver | null>(null);

  useEffect(() => {
    // 如果没有目标元素,不执行观察
    if (!targetRef.current) return;

    // 清除之前的观察器
    if (observerRef.current) {
      observerRef.current.disconnect();
    }

    // 创建新的观察器
    observerRef.current = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          setIsIntersecting(entry.isIntersecting);
          
          // 如果设置了once且元素可见,则停止观察
          if (once && entry.isIntersecting) {
            observerRef.current?.disconnect();
          }
        });
      },
      { root, rootMargin, threshold }
    );

    // 开始观察目标元素
    observerRef.current.observe(targetRef.current);

    // 清理函数
    return () => {
      if (observerRef.current) {
        observerRef.current.disconnect();
      }
    };
  }, [root, rootMargin, threshold, once]);

  return [targetRef, isIntersecting];
}

NO.3参考事件委托思想 在Song-list 实现一个观察者 观察n个SongMenu子组件(节省性能)

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

interface UseIntersectionObserverOptions {
  root?: Element | null;
  rootMargin?: string;
  threshold?: number | number[];
  once?: boolean;
  defaultVisible?: boolean;
}

interface BatchIntersectionResult<T extends Element> {
  ref: (el: T | null, index: number) => void;
  isIntersecting: (index: number) => boolean;
  visibleIndices: number[];
}

// 单个元素版本
export function useIntersectionObserver<T extends Element>(
  options: UseIntersectionObserverOptions = {}
): [RefObject<T>, boolean] {
  const { 
    root = null, 
    rootMargin = '0px', 
    threshold = 0, 
    once = false,
    defaultVisible = false
  } = options;

  // 使用类型断言解决初始值为null的类型问题
  const targetRef = useRef<T>(null) as RefObject<T>;
  const [isIntersecting, setIsIntersecting] = useState(defaultVisible);
  const observerRef = useRef<IntersectionObserver | null>(null);

  useEffect(() => {
    // 如果没有目标元素,不执行观察
    if (!targetRef.current) return;

    // 清除之前的观察器
    if (observerRef.current) {
      observerRef.current.disconnect();
    }

    // 创建新的观察器
    observerRef.current = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          setIsIntersecting(entry.isIntersecting);
          
          // 如果设置了once且元素可见,则停止观察
          if (once && entry.isIntersecting) {
            observerRef.current?.disconnect();
          }
        });
      },
      { root, rootMargin, threshold }
    );

    // 开始观察目标元素
    observerRef.current.observe(targetRef.current);

    // 清理函数
    return () => {
      if (observerRef.current) {
        observerRef.current.disconnect();
      }
    };
  }, [root, rootMargin, threshold, once]);

  return [targetRef, isIntersecting];
}

// 批量元素版本
export function useBatchIntersectionObserver<T extends Element>(
  count: number,
  options: UseIntersectionObserverOptions = {}
): BatchIntersectionResult<T> {
  const { 
    root = null, 
    rootMargin = '0px', 
    threshold = 0, 
    once = false,
    defaultVisible = false
  } = options;

  const targetsRef = useRef<(T | null)[]>(new Array(count).fill(null));
  const [isIntersectingMap, setIsIntersectingMap] = useState<Map<number, boolean>>(
    new Map(Array.from({ length: count }, (_, i) => [i, defaultVisible]))
  );
  const observerRef = useRef<IntersectionObserver | null>(null);

  // 获取可见的索引
  const visibleIndices = Array.from(isIntersectingMap.entries())
    .filter(([_, visible]) => visible)
    .map(([index]) => index);

  // 设置ref的回调函数
  const ref = (el: T | null, index: number) => {
    if (index >= 0 && index < count) {
      targetsRef.current[index] = el;
    }
  };

  // 检查指定索引的元素是否可见
  const isIntersecting = (index: number): boolean => {
    return isIntersectingMap.get(index) || false;
  };

  useEffect(() => {
    // 清除之前的观察器
    if (observerRef.current) {
      observerRef.current.disconnect();
    }

    // 创建新的观察器
    observerRef.current = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          // 找到当前元素的索引
          const index = targetsRef.current.findIndex(el => el === entry.target);
          if (index !== -1) {
            setIsIntersectingMap(prev => {
              const newMap = new Map(prev);
              newMap.set(index, entry.isIntersecting);
              return newMap;
            });
            
            // 如果设置了once且元素可见,则停止观察
            if (once && entry.isIntersecting) {
              observerRef.current?.unobserve(entry.target);
            }
          }
        });
      },
      { root, rootMargin, threshold }
    );

    // 开始观察所有目标元素
    targetsRef.current.forEach((el) => {
      if (el) {
        observerRef.current?.observe(el);
      }
    });

    // 清理函数
    return () => {
      if (observerRef.current) {
        observerRef.current.disconnect();
      }
    };
  }, [root, rootMargin, threshold, once, count]);

  return { ref, isIntersecting, visibleIndices };
}

Polyfill 的实现思路

在不支持该 API 的旧版浏览器中,Polyfill 会模拟这一功能,其实现方式恰恰反衬了原生 API 的优雅。Polyfill 通常需要:

  • 监听 scrollresize事件,并进行节流。
  • 使用 MutationObserver来监听 DOM 结构变化,因为这些变化可能影响元素位置。
  • 在事件处理程序中,循环遍历所有被观察的元素,使用 getBoundingClientRect()进行手动计算,这本身就会引发性能损耗。

这种模拟方式在性能和精度上都无法与原生实现相提并论,这也正是为什么应该尽可能使用原生 IntersectionObserver的原因

进阶指南:彻底理解 TypeScript 模块共享与隔离机制

一、 核心现象:模块是“天生共享”的

在 ES Modules(ESM)规范下,模块具有单例特性。无论一个模块被导入多少次,它内部的代码只会在第一次被加载时运行一次,结果会被缓存。

1. 代码实验

假设我们有一个状态管理模块:

// state.ts
export let count = 0;
export const dataObj = { name: "Initial" };

export function increment() { count++; }

当我们在不同文件中引用它:

  • 文件 A:调用 increment()
  • 文件 B:读取 count
  • 结果:文件 B 看到的 count1dataObj 的修改也是全局可见的。

2. 底层原理

  1. 加载与执行:引擎首次遇到 import './state',执行该文件并在内存创建作用域。
  2. 模块缓存(Module Registry):引擎会将导出结果存入缓存表。
  3. 引用传递(Live Bindings):后续所有的 import 都是从缓存中获取指向该内存地址的引用。

注意:导入的变量是只读的。你不能在外部直接写 count = 10,必须通过模块内部提供的函数(如 increment)来修改。


二、 为什么要防止变量共享?

虽然全局共享在做“配置管理”或“全局状态”时很方便,但在以下场景则是灾难:

  1. 单元测试隔离:测试用例 A 修改了状态,导致测试用例 B 运行失败,产生干扰。
  2. 多实例需求:例如页面上有三个独立的“计数器组件”,如果共用一个模块变量,它们会同步跳动。
  3. 服务端渲染(SSR):在 Node.js 中,如果模块变量存储了用户信息,不同用户的请求可能会互相污染,造成严重的隐私泄露。

三、 防止共享的四种高级方案

如果你的目标是让每个引入者拥有“独立的代码副本”,请尝试以下方法:

1. 导出类(Class)而非实例

这是最推荐的 OOP(面向对象)方案。每次调用者 new 一个实例,都会开辟独立的内存空间。

// Counter.ts
export class Counter {
  count = 0;
  increment() { this.count++; }
}

// 使用:const c1 = new Counter();

2. 使用工厂函数(Factory Function)

函数式编程的最佳实践。通过闭包产生私有作用域,每次执行函数都返回全新对象。

// state.ts
export const createStore = () => {
  let count = 0; // 闭包私有变量
  return {
    add: () => ++count,
    get: () => count
  };
};

// 使用:const storeA = createStore();

3. 依赖注入与传参

不要在模块顶层存储状态,而是让函数接受状态作为参数。将状态的“生命周期”交给调用者管理。

// logic.ts
export function processData(context: UserContext, data: any) {
  context.history.push(data); // 状态由外部传入的 context 决定
}

4. 框架层面的隔离(Context/Scoped)

在 React 或 Vue 中,利用 Context APIProvide/Inject。数据不再挂载在模块上,而是挂载在 UI 组件树的节点上,实现“局部单例”。


四、 总结与最佳实践

需求场景 推荐策略 核心优势
全局配置、常量 直接导出变量/对象 简单、高效、全应用统一
数据库连接、缓存 默认单例共享 节省资源,避免重复初始化
业务组件状态 导出 Class 或工厂函数 互不干扰,支持多实例
纯逻辑处理 传参/纯函数 极易进行单元测试,无副作用

💡 独家技巧:

如果你确实需要全局单例,但又想方便测试,记得导出一个 reset 函数:

let state = { ... };
export const resetStateForTest = () => { state = { ... }; };

结语: 理解 TypeScript 模块的共享机制是走向中高级开发的必经之路。记住:默认共享是为了效率,主动隔离是为了安全。 根据业务场景选择合适的导出方式,才能写出既健壮又易于维护的代码。

深入浅出 TypeScript 模块系统:从语法到构建原理

在现代前端开发中,模块化是组织大规模代码库的基石。TypeScript 不仅完全支持 ES6 模块标准,还在此基础上增加了类型安全的保障。本文将从语法使用、编译器原理、构建行为三个维度,深度拆解 TS 的模块系统。


一、 核心语法:Import 与 Export

在 TS 中,一个文件就是一个模块。它具有独立的作用域,外部无法访问其内部变量,除非显式导出。

1. 导出 (Export)

  • 命名导出:一个文件可导出多个,导入时需名称匹配。
    export const PI = 3.14;
    export function add(a: number, b: number) { return a + b; }
    
  • 默认导出:一个文件仅限一个,通常用于模块的核心功能。
    export default class Logger { ... }
    

2. 导入 (Import)

  • 常用导入import { PI } from './math'
  • 重命名import { PI as MathPI } from './math'
  • 全量导入import * as MathTools from './math'

3. TypeScript 特色:类型导入 (Type-Only Imports)

这是 TS 独有的语法,用于明确告诉编译器:我只想要类型,不想要任何运行时代码。

import type { UserInterface } from './types';
// 或者
import { add, type Point } from './math';
  • 优点:极致的构建优化,避免类型定义在 JS 中产生冗余,且能防止某些循环引用导致的运行时错误。

二、 编译器原理:当你写下 Import 时发生了什么?

当我们写下 import { user } from "../../../models/user" 时,TS 编译器(tsc)会经历以下过程:

  1. 路径解析 (Module Resolution)
    • 编译器根据 tsconfig.json 中的 moduleResolution 策略寻找文件。
    • 它会按顺序尝试 .ts -> .tsx -> .d.ts 后缀,甚至进入 node_modules 查找 package.json 中的类型声明。
  2. 符号链接 (Symbol Linking)
    • 编译器读取目标文件,确认其是否真的 exportuser
    • 建立链接,此时你在当前文件中对 user 的所有操作都将受到 user.ts 中定义的类型约束。
  3. 构建依赖图
    • 编译器建立起整个项目的树状引用关系,用于增量编译和错误追踪。

三、 编译 vs 打包:代码最后去哪了?

这是一个常见的误区:TS 编译并不等于打包。

1. 编译阶段 (tsc)

  • 不合并代码tsc 只是把 .ts 翻译成 .js
  • 转换语法:把 import 翻译成 require (CommonJS) 或保留 (ESM)。
  • 文件独立A.js 依然是 A.jsuser.js 依然是 user.js,代码没有合在一起。

2. 打包阶段 (Vite / Webpack)

  • 合并代码:打包工具会将所有依赖的文件“缝合”成一个或几个 bundle.js
  • Tree Shaking:如果 user.ts 导出了很多函数但你只用了一个,打包工具会把没用的代码删掉,减小体积。

四、 深度思考:多文件引入同一份数据会怎样?

如果文件 A 和文件 B 都 import { config } from "./data",打包后会产生多份 data 副本吗?

答案是:不会。

  1. 模块单例模式:在运行时,模块代码只会在第一次被引用时执行一次
  2. 缓存机制:执行结果会被缓存在内存中。之后所有引用该模块的地方,拿到的都是同一个引用(内存地址)
  3. 构建优化
    • 如果是单文件打包,data 代码只会出现一次。
    • 如果是多页面应用,打包工具会自动提取“公共依赖”为一个独立文件(如 vendor.js),实现浏览器端的跨页面缓存。

五、 最佳实践建议

  1. 优先使用命名导出:比默认导出更利于 IDE 自动补全和 Tree Shaking。
  2. 显式使用 import type:当你只需要接口或类型声明时,养成这个习惯可以提升编译性能。
  3. 配置路径别名:在 tsconfig.json 中配置 paths(如 @/*),告别 ../../../../ 的痛苦。
  4. 关注模块规范:在 Node.js 环境优先考虑 CommonJS,在浏览器/Vite 环境优先考虑 ESNext

总结:TypeScript 的模块系统是静态类型检查与现代 JS 模块标准的完美结合。理解它在“编译时”和“打包时”的不同表现,能帮助我们写出更健壮、性能更好的前端代码。

深度解析Vue3响应式原理:Proxy + Reflect + effect 三叉戟

响应式系统是Vue框架的核心基石,它实现了“数据驱动视图”的核心思想——当数据发生变化时,依赖该数据的视图会自动更新,无需手动操作DOM。Vue3相较于Vue2,彻底重构了响应式系统,放弃了Object.defineProperty,转而采用Proxy + Reflect + effect的组合方案,解决了Vue2响应式的诸多缺陷(如无法监听对象新增属性、数组索引变化等)。本文将从核心概念入手,层层拆解三者的协作机制,深入剖析Vue3响应式系统的实现原理与核心细节。

一、核心目标:什么是“响应式”?

在Vue中,“响应式”的核心目标可概括为:建立数据与依赖(如组件渲染函数、watch回调)之间的关联,当数据发生变化时,自动触发所有依赖的重新执行

举个直观的例子:

<script setup>
import { ref } from 'vue';
const count = ref(0); // 响应式数据

// 依赖count的逻辑(组件渲染函数)
const render = () => {
  document.body.innerHTML = `count: ${count.value}`;
};

// 初始执行渲染
render();

// 1秒后修改数据,视图自动更新
setTimeout(() => {
  count.value = 1;
}, 1000);
</script>

上述代码中,count是响应式数据,render函数是依赖count的“副作用”。当count.value修改时,render函数会自动重新执行,视图随之更新。Vue3响应式系统的核心任务,就是自动完成“依赖收集”(识别render依赖count)和“依赖触发”(count变化时触发render重新执行)。

二、核心三要素:Proxy + Reflect + effect 各司其职

Vue3响应式系统的实现依赖三个核心要素,它们分工明确、协同工作:

  • Proxy:作为响应式数据的“代理层”,拦截数据的读取(get)、修改(set)等操作,为依赖收集和依赖触发提供“钩子”。
  • Reflect:配合Proxy完成数据操作的“反射层”,确保在拦截操作时,能正确保留原对象的行为(如原型链、属性描述符等),同时简化拦截逻辑。
  • effect:封装“副作用”逻辑(如组件渲染函数、watch回调),负责触发依赖收集(记录数据与副作用的关联)和在数据变化时重新执行副作用。

三者的协作流程可简化为:

  1. effect执行副作用函数,触发数据的读取操作。
  2. Proxy拦截数据读取,通过Reflect完成原始读取操作,同时触发依赖收集(将当前effect与数据关联)。
  3. 当数据被修改时,Proxy拦截数据修改,通过Reflect完成原始修改操作,同时触发依赖触发(找到所有关联的effect并重新执行)。

三、逐个拆解:核心要素的作用与实现

3.1 Proxy:响应式数据的“拦截器”

Proxy是ES6新增的对象,用于创建一个对象的代理,从而实现对目标对象的属性读取、修改、删除等操作的拦截和自定义处理。Vue3正是利用Proxy的拦截能力,为响应式数据提供了“监听”机制。

3.1.1 Proxy的核心优势(对比Vue2的Object.defineProperty)

  • 支持监听对象新增属性:Object.defineProperty只能监听已存在的属性,无法监听新增属性;Proxy的set拦截可以捕获对象新增属性的操作。
  • 支持监听数组索引/长度变化:Object.defineProperty难以监听数组通过索引修改元素、修改length属性的操作;Proxy可以轻松拦截数组的这些变化。
  • 支持监听对象删除操作:Proxy的deleteProperty拦截可以捕获属性删除操作。
  • 非侵入式拦截:Proxy无需像Object.defineProperty那样遍历对象属性并重新定义,直接代理目标对象,更高效、更简洁。

3.1.2 Proxy在响应式中的核心拦截操作

在Vue3响应式系统中,主要拦截以下两个核心操作:

  1. get拦截:当读取响应式对象的属性时触发,核心作用是“依赖收集”——记录当前正在执行的effect与该属性的关联。
  2. set拦截:当修改响应式对象的属性时触发,核心作用是“依赖触发”——找到所有与该属性关联的effect,重新执行它们。

简单实现一个基础的响应式Proxy:

// 目标对象
const target = { count: 0 };

// 创建Proxy代理
const reactiveTarget = new Proxy(target, {
  // 拦截属性读取操作
  get(target, key, receiver) {
    console.log(`读取属性 ${key}${target[key]}`);
    // 此处会触发依赖收集逻辑(后续补充)
    return target[key];
  },
  // 拦截属性修改/新增操作
  set(target, key, value, receiver) {
    console.log(`修改属性 ${key}${value}`);
    target[key] = value;
    // 此处会触发依赖触发逻辑(后续补充)
    return true; // 表示修改成功
  }
});

// 测试拦截效果
reactiveTarget.count; // 输出:读取属性 count:0
reactiveTarget.count = 1; // 输出:修改属性 count:1
reactiveTarget.name = "Vue3"; // 输出:修改属性 name:Vue3(支持新增属性拦截)

3.2 Reflect:拦截操作的“反射器”

Reflect也是ES6新增的内置对象,它提供了一系列方法,用于执行对象的原始操作(如读取属性、修改属性、删除属性等),这些方法与Proxy的拦截方法一一对应。Vue3在Proxy的拦截器中,通过Reflect执行原始数据操作,而非直接操作目标对象。

3.2.1 为什么需要Reflect?

  • 确保原始操作的正确性:Reflect的方法会严格遵循ECMAScript规范,正确处理对象的原型链、属性描述符等细节。例如,当目标对象的属性不可写时,Reflect.set会返回false,而直接赋值会抛出错误。
  • 简化拦截逻辑:Reflect的方法会自动传递receiver(Proxy实例),确保在操作中正确绑定this。例如,当目标对象的属性是访问器属性(getter/setter)时,receiver可以确保this指向Proxy实例,而非目标对象。
  • 统一的返回值逻辑:Reflect的方法都会返回一个布尔值,表示操作是否成功,便于拦截器中判断操作结果。

3.2.2 Reflect在响应式中的应用

修改上述Proxy示例,使用Reflect执行原始操作:

const target = { count: 0 };

const reactiveTarget = new Proxy(target, {
  get(target, key, receiver) {
    console.log(`读取属性 ${key}`);
    // 使用Reflect.get执行原始读取操作,传递receiver
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver) {
    console.log(`修改属性 ${key}${value}`);
    // 使用Reflect.set执行原始修改操作,返回操作结果
    const success = Reflect.set(target, key, value, receiver);
    if (success) {
      // 操作成功后触发依赖
      console.log("依赖触发成功");
    }
    return success;
  }
});

reactiveTarget.count; // 输出:读取属性 count
reactiveTarget.count = 1; // 输出:修改属性 count:1 → 依赖触发成功

3.3 effect:副作用的“管理器”

effect是Vue3响应式系统中封装“副作用”的核心函数。所谓“副作用”,是指会依赖响应式数据、且当响应式数据变化时需要重新执行的逻辑(如组件渲染函数、watch回调函数、computed计算函数等)。

3.3.1 effect的核心作用

  • 触发依赖收集:当effect执行时,会将自身设为“当前活跃的effect”,然后执行副作用函数。副作用函数中读取响应式数据时,会触发Proxy的get拦截,此时将“当前活跃的effect”与该数据属性关联起来(依赖收集)。
  • 响应数据变化:当响应式数据变化时,会触发Proxy的set拦截,此时找到所有与该数据属性关联的effect,重新执行它们(依赖触发)。

3.3.2 effect的简单实现

要实现effect,需要解决两个核心问题:

  1. 如何记录“当前活跃的effect”?
  2. 如何存储“数据属性与effect的关联关系”?

解决方案:

  • 用一个全局变量(如activeEffect)存储当前正在执行的effect。
  • 用一个“依赖映射表”(如targetMap)存储关联关系,结构为:targetMap → target → key → effects(Set集合)。

具体实现代码:

// 1. 全局变量:存储当前活跃的effect
let activeEffect = null;

// 2. 依赖映射表:target → key → effects
const targetMap = new WeakMap();

// 3. 依赖收集函数:建立数据属性与effect的关联
function track(target, key) {
  // 若没有活跃的effect,无需收集依赖
  if (!activeEffect) return;

  // 从targetMap中获取当前target的依赖表(没有则创建)
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()));
  }

  // 从depsMap中获取当前key的effect集合(没有则创建)
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }

  // 将当前活跃的effect添加到集合中(Set自动去重)
  deps.add(activeEffect);
}

// 4. 依赖触发函数:数据变化时,执行关联的effect
function trigger(target, key) {
  // 从targetMap中获取当前target的依赖表
  const depsMap = targetMap.get(target);
  if (!depsMap) return;

  // 从depsMap中获取当前key的effect集合
  const deps = depsMap.get(key);
  if (deps) {
    // 执行所有关联的effect
    deps.forEach(effect => effect());
  }
}

// 5. effect核心函数:封装副作用
function effect(callback) {
  // 定义effect函数
  const effectFn = () => {
    // 执行副作用前,先清除当前effect的关联(避免重复收集)
    cleanup(effectFn);
    // 将当前effect设为活跃状态
    activeEffect = effectFn;
    // 执行副作用函数(会触发响应式数据的get拦截,进而触发track收集依赖)
    callback();
    // 副作用执行完毕,重置活跃effect
    activeEffect = null;
  };

  // 存储当前effect关联的依赖集合(用于cleanup清除)
  effectFn.deps = [];

  // 初始执行一次effect,触发依赖收集
  effectFn();
}

// 6. 清除依赖函数:避免effect重复执行
function cleanup(effectFn) {
  // 遍历effect关联的所有依赖集合,移除当前effect
  for (const deps of effectFn.deps) {
    deps.delete(effectFn);
  }
  // 清空deps数组
  effectFn.deps.length = 0;
}

// 7. 响应式函数:创建Proxy代理
function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      // 执行原始读取操作
      const result = Reflect.get(target, key, receiver);
      // 触发依赖收集
      track(target, key);
      return result;
    },
    set(target, key, value, receiver) {
      // 执行原始修改操作
      const success = Reflect.set(target, key, value, receiver);
      // 触发依赖触发
      trigger(target, key);
      return success;
    }
  });
}

3.3.3 effect的工作流程演示

结合上述实现,演示effect与响应式数据的协作流程:

// 1. 创建响应式数据
const state = reactive({ count: 0 });

// 2. 定义副作用(组件渲染逻辑模拟)
effect(() => {
  console.log(`count: ${state.count}`);
});
// 初始执行effect,输出:count: 0
// 执行过程中读取state.count,触发get拦截 → 调用track收集依赖(effect与state.count关联)

// 3. 修改响应式数据
state.count = 1;
// 触发set拦截 → 调用trigger → 执行关联的effect → 输出:count: 1

// 4. 新增属性(Proxy支持)
state.name = "Vue3";
// 触发set拦截 → 调用trigger(无关联effect,无输出)

// 5. 定义依赖name的副作用
effect(() => {
  console.log(`name: ${state.name}`);
});
// 初始执行effect,输出:name: Vue3
// 收集name与该effect的关联

// 6. 修改name
state.name = "Vue3 Reactivity";
// 触发set拦截 → 执行关联的effect → 输出:name: Vue3 Reactivity

四、核心协作流程:完整响应式链路拆解

结合上述实现,我们可以梳理出Vue3响应式系统的完整协作流程,分为“依赖收集阶段”和“依赖触发阶段”两个核心环节。

4.1 依赖收集阶段(数据与effect关联)

  1. 调用effect函数,传入副作用回调(如渲染函数)。
  2. effect函数内部创建effectFn,执行effectFn。
  3. effectFn中先执行cleanup清除旧依赖,再将自身设为activeEffect(当前活跃effect)。
  4. 执行副作用回调,回调中读取响应式数据的属性(如state.count)。
  5. 触发响应式数据的Proxy.get拦截。
  6. get拦截中调用Reflect.get执行原始读取操作。
  7. 调用track函数,在targetMap中建立“target(state)→ key(count)→ effectFn”的关联。
  8. 副作用回调执行完毕,重置activeEffect为null。

4.2 依赖触发阶段(数据变化触发effect重新执行)

  1. 修改响应式数据的属性(如state.count = 1)。
  2. 触发响应式数据的Proxy.set拦截。
  3. set拦截中调用Reflect.set执行原始修改操作。
  4. 调用trigger函数,从targetMap中查找“target(state)→ key(count)”关联的所有effectFn。
  5. 遍历执行所有关联的effectFn,副作用逻辑(如渲染函数)重新执行,视图更新。

五、进阶细节:Vue3响应式系统的优化与扩展

5.1 对Ref的支持:基本类型的响应式

Proxy只能代理对象类型,无法直接代理基本类型(string、number、boolean等)。Vue3通过Ref解决了基本类型的响应式问题:

  • Ref将基本类型包装成一个“具有value属性的对象”(如{ value: 0 })。
  • 对Ref对象的value属性进行Proxy代理,从而实现基本类型的响应式。
  • 在模板中使用Ref时,Vue3会自动解包(无需手动写.value),在组合式API的setup中则需要手动使用.value。

5.2 对computed的支持:缓存型副作用

computed本质是一个“缓存型effect”,它具有以下特性:

  • computed的回调函数是一个副作用,依赖响应式数据。
  • computed会缓存计算结果,只有当依赖的响应式数据变化时,才会重新计算。
  • computed内部通过effect的调度器(scheduler)实现缓存逻辑:当依赖变化时,不立即执行effect,而是标记为“脏数据”,等到下次读取computed值时再重新计算。

5.3 对watch的支持:监听数据变化的副作用

watch的核心是“监听指定响应式数据的变化,触发自定义副作用”,其实现基于effect:

  • watch内部创建一个effect,副作用函数中读取要监听的响应式数据(触发依赖收集)。
  • 当监听的数据变化时,触发effect重新执行,此时调用watch的回调函数,并传入新旧值。
  • watch支持“深度监听”(通过deep选项)和“立即执行”(通过immediate选项),本质是通过调整effect的执行时机和依赖收集范围实现。

5.4 调度器(scheduler):控制effect的执行时机

Vue3的effect支持传入调度器函数(scheduler),用于控制effect的执行时机和方式。调度器是实现computed缓存、watch延迟执行、批量更新的核心:

  • 当effect触发时,若存在调度器,会执行调度器而非直接执行effect。
  • 例如,Vue3的批量更新机制:将多个effect的执行延迟到下一个微任务中,避免多次DOM更新,提升性能。

六、实战避坑:响应式系统的常见问题

6.1 响应式数据的“丢失”问题

问题描述:将响应式对象的属性解构赋值给普通变量,普通变量会失去响应式。

import { reactive } from 'vue';

const state = reactive({ count: 0 });
const { count } = state; // 解构出普通变量count,失去响应式

count = 1; // 不会触发响应式更新

解决方案:

  • 避免直接解构响应式对象,若需解构,可使用toRefs将响应式对象的属性转为Ref。
  • 使用Ref包裹基本类型,避免解构导致的响应式丢失。
import { reactive, toRefs } from 'vue';

const state = reactive({ count: 0 });
const { count } = toRefs(state); // count是Ref对象,保留响应式

count.value = 1; // 触发响应式更新

6.2 数组响应式的特殊情况

问题描述:通过数组的某些方法(如push、pop)修改数组时,Vue3能正常监听,但直接修改数组索引或length时,需注意响应式触发。

import { reactive } from 'vue';

const arr = reactive([1, 2, 3]);

arr[0] = 10; // 能触发响应式更新
arr.length = 0; // 能触发响应式更新
arr.push(4); // 能触发响应式更新

注意:Vue3对数组的响应式支持已非常完善,大部分数组操作都能正常触发响应式,但仍建议优先使用数组的内置方法(push、splice等)修改数组,更符合直觉。

6.3 深层对象的响应式问题

问题描述:响应式对象的深层属性变化时,是否能正常触发响应式?

答案:能。因为Proxy的get拦截会递归触发深层属性的依赖收集。例如:

import { reactive } from 'vue';

const state = reactive({ a: { b: 1 } });

effect(() => {
  console.log(state.a.b); // 读取深层属性,收集依赖
});

state.a.b = 2; // 能触发响应式更新,输出2

注意:若深层对象是后来新增的,需确保新增的对象也是响应式的(Vue3的reactive会自动处理新增属性的响应式)。

七、总结:Vue3响应式系统的核心价值

Vue3响应式系统通过Proxy + Reflect + effect的组合,构建了一个高效、灵活、功能完善的响应式机制,其核心价值在于:

  • 彻底解决了Vue2响应式的缺陷:支持对象新增属性、数组索引/长度变化、属性删除等操作的监听。
  • 非侵入式设计:通过Proxy代理目标对象,无需修改原始对象的结构,更符合JavaScript的语言特性。
  • 灵活的扩展能力:通过effect的调度器、Ref、computed、watch等扩展,支持各种复杂的业务场景。
  • 高效的性能:通过批量更新、缓存机制(computed)等优化,减少不必要的副作用执行,提升应用性能。

理解Vue3响应式原理,不仅能帮助我们更好地使用Vue3的API(如reactive、ref、computed、watch),还能让我们在遇到响应式相关问题时快速定位并解决。Proxy + Reflect + effect的组合设计,也为我们编写高效的JavaScript代码提供了优秀的思路借鉴。

Git 提交AI神器:用大模型帮你写出规范的 Commit Message

Git 提交AI神器:用大模型帮你写出规范的 Commit Message

在软件开发中,规范的 Git 提交信息不仅是团队协作的基础,更是项目可维护性、可追溯性的关键。无论是用于生成清晰的 CHANGELOG,还是帮助 Leader 审核你的工作成果,甚至让新手也能像资深工程师一样提交高质量代码——一个好用的 Commit Message 工具都不可或缺。

今天,我们来介绍一款基于 本地开源大模型 + 全栈技术栈 打造的 Git 提交辅助神器:Git Commit AI Assistant。它能自动分析你的 git diff,并生成符合 Conventional Commits 规范的专业级提交信息。


🌟 项目亮点

  • 本地部署,数据安全:使用 Ollama 在本地运行 deepseek-r1:8b 开源大模型,无需联网,隐私无忧。
  • 前后端分离架构:前端 React + TailwindCSS,后端 Node.js + Express,结构清晰,易于扩展。
  • 开箱即用:只需复制粘贴 git diff 内容,AI 自动为你生成语义清晰、格式规范的 commit message。
  • 开发者友好:支持热重载(nodemon)、API 调试(Apifox)、跨域处理(CORS),开发体验丝滑。

🛠 技术栈一览

层级 技术
前端 React 18 + Vite + Tailwind CSS + Axios
后端 Node.js + Express
AI 引擎 Ollama + deepseek-r1:8b(8B 参数推理模型)
开发工具 nodemon(热更新)、Apifox(API 测试)

🧠 核心原理

  1. 用户在前端粘贴 git diff 输出内容。
  2. 前端通过 Axios 将 diff 文本发送到后端 /chat 接口。
  3. 后端调用本地 Ollama 服务(http://localhost:11434),使用 LangChain 构建提示词链。
  4. 大模型根据预设的系统角色(如“你是一个专业的 Git 提交信息生成助手”)生成规范的 commit message。
  5. 结果返回前端,用户一键复制即可使用。

🚀 快速启动指南

1. 启动 AI 模型(Ollama)

确保已安装 Ollama,然后拉取并运行模型:

ollama pull deepseek-r1:8b
ollama run deepseek-r1:8b  # 可选,验证是否正常

Ollama 默认提供兼容 OpenAI 的 API 接口,监听 http://localhost:11434


2. 启动后端服务(Express)

cd server
npm install
npm install express cors @langchain/ollama @langchain/core
npx nodemon index.js

服务将在 http://localhost:3000 启动,并提供以下接口:

  • GET /hello → 测试连通性
  • POST /chat → 接收用户输入,返回 AI 生成的 commit message

✅ 已内置 JSON 解析中间件和 CORS 跨域支持,前端可直接调用。


3. 启动前端(React + Vite)

cd frontend
npm install
npm run dev

前端运行于 http://localhost:5173,界面简洁,支持实时加载与错误提示。


💡 示例:AI 如何生成 Commit Message?

输入(git diff 片段)

+ export const formatDate = (date) => {
+   return new Date(date).toLocaleDateString();
+ };

AI 输出

feat(utils): add formatDate utility function

完全符合 Conventional Commits 规范!


📦 后端核心代码(Express + LangChain)

import express from 'express';
import cors from 'cors';
import { ChatOllama } from "@langchain/ollama";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from '@langchain/core/output_parsers';

const model = new ChatOllama({
  baseUrl: 'http://localhost:11434',
  model: 'deepseek-r1:8b',
  temperature: 0.1 // 降低随机性,提高一致性
});

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

app.post('/chat', async (req, res) => {
  const { message } = req.body;

  if (!message || typeof message !== 'string') {
    return res.status(400).json({
      error: "message 必填,必须是字符串"
    });
  }

  try {
    const prompt = ChatPromptTemplate.fromMessages([
      ['system', '你是一个专业的 Git 提交信息生成助手。请根据用户提供的 git diff 内容,生成一条符合 Conventional Commits 规范的 commit message。只输出 commit message,不要解释。'],
      ['human', '{input}']
    ]);

    const chain = prompt.pipe(model).pipe(new StringOutputParser());
    const result = await chain.invoke({ input: message });

    res.json({ reply: result.trim() });
  } catch (e) {
    console.error(e);
    res.status(500).json({ error: "调用大模型失败" });
  }
});

app.listen(3000, () => {
  console.log('🚀 Git Commit AI Server running on http://localhost:3000');
});

🧪 前端 Hook 封装(React)

// hooks/useGitDiff.js
import { useState, useEffect } from 'react';
import { chat } from '../api';

export const useGitDiff = (diffText) => {
  const [content, setContent] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!diffText) return;
    
    const generateCommit = async () => {
      setLoading(true);
      setError(null);
      try {
        const { data } = await chat(diffText);
        setContent(data.reply);
      } catch (err) {
        setError('生成失败,请检查后端服务');
      } finally {
        setLoading(false);
      }
    };

    generateCommit();
  }, [diffText]);

  return { loading, content, error };
};

🔒 为什么选择本地大模型?

  • 无网络依赖:公司内网、离线环境也能用。
  • 零成本:无需支付 OpenAI 或其他云 API 费用。
  • 高性能deepseek-r1:8b 在消费级 GPU 上推理流畅。
  • 可定制:可微调提示词或更换模型(如 codegemma, phi3 等)。

📌 结语

规范的 Git 提交不是负担,而是专业性的体现。借助 AI,我们可以把重复性工作交给机器,专注于更有价值的编码与设计。

这个 Git 提交AI神器 不仅是一个工具,更是一种工程文化的倡导者。现在就把它集成到你的开发流程中,让你的每一次 git commit 都闪闪发光 ✨!

GitHub 仓库即将开源,关注我们获取最新进展!
本地部署 · 隐私安全 · 极简体验

拒绝繁琐!Redux Toolkit (RTK) 极简拟人化入门指南

前言:还在为 Redux 繁琐的样板代码(Boilerplate)头秃吗?还在写无休止的 switch-caseaction types 吗?

大人,时代变了。官方现在强烈推荐使用 Redux Toolkit (RTK) 。它不仅是 Redux 的官方工具集,更是为了简化逻辑而生。今天我们不讲晦涩的源码,我们用一个**“现代化超级仓库”**的故事,带你十分钟上手 RTK。

一、 核心概念:拟人化图解 📦

想象你的 React 应用是一个巨大的 “超级物流园”

1. Store(仓库)

这是整个物流园的总基地。所有的数据(货物)都存放在这里,严禁外人随意进出拿取,必须按规矩办事。

2. Slice(片区/部门)

仓库太大,必须分区分片管理。比如“计数器区”、“用户信息区”。每个片区都有自己的**“管理员手册”“初始库存”**。

3. State(库存)

片区里存放的具体数据。比如计数器区的当前数字是 0,这就是库存。

4. Reducer(管理员/规则制定者)

这是片区里的执行规则

  • 拟人化:管理员手里拿着一本手册,上面写着:“如果收到‘加一’的指令,就把库存 +1”。
  • RTK的魔法:在 RTK 里,管理员可以直接“修改”库存(底层由 Immer 库处理不可变性),你感觉你在直接改数据,其实 RTK 帮你处理了复杂的脏活累活。

5. Dispatch(传令兵)

组件(页面)想修改数据,不能自己动手,必须派传令兵把指令(Action)送给管理员。

6. Selector(监控探头)

组件想看数据,不需要把整个仓库搬走,只需要通过监控探头看一眼自己关心的那个数据。

二、 代码实战:三步搭建“计数器部门” 🛠️

我们要实现的功能很简单:一个计数器,能加、能减、能重置。

第一步:建立片区与制定规则 (counterSlice.js)

我们需要创建一个“计数器部门”,并制定几条铁律。

import { createSlice } from '@reduxjs/toolkit';

// 1. 初始库存:最开始是 0
const initialState = {
  value: 0,
};

// 2. 创建片区 (createSlice)
export const counterSlice = createSlice({
  name: 'counter', // 片区名字:计数器部
  initialState,
  // 3. 管理员手册 (Reducers):制定规则
  reducers: {
    // 规则一:增量
    increment: (state) => {
      // 拟人化:管理员直接把库存改了 (RTK 允许这样写,不用写 return { ...state })
      state.value += 1; 
    },
    // 规则二:减量
    decrement: (state) => {
      state.value -= 1;
    },
    // 规则三:自定义数量 (接收一个指令包 action)
    incrementByAmount: (state, action) => {
      // action.payload 就是传令兵带来的具体数字
      state.value += action.payload;
    },
  },
});

// 4. 导出指令 (Actions)
// RTK 自动帮我们把 increment 等规则生成了对应的指令牌,组件直接拿去用
export const { increment, decrement, incrementByAmount } = counterSlice.actions;

// 5. 导出这个片区的管理员 (Reducer),总仓库要用
export default counterSlice.reducer;

第二步:组建总仓库 (store.js)

现在把“计数器部门”接入到“总基地”里。

import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';

// 创建总仓库
export const store = configureStore({
  reducer: {
    // 这里的 key ('counter') 就是在总仓库里的片区门牌号
    counter: counterReducer,
  },
});

第三步:让应用接入仓库 (index.jsmain.jsx)

在应用的入口,把仓库的大门打开,让所有组件都能连接进来。

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { store } from './store'; // 引入总仓库
import { Provider } from 'react-redux'; // 引入连接器

ReactDOM.createRoot(document.getElementById('root')).render(
  // 用 Provider 包裹 App,把 store 传进去
  <Provider store={store}>
    <App />
  </Provider>
);

三、 组件使用:派单与查看 📱

现在来到 React 组件内部,作为用户(User),我们如何与仓库交互?

  • useSelector: 查看库存(读数据)。
  • useDispatch: 雇佣传令兵(改数据)。
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
// 引入我们刚才生成的指令牌
import { increment, decrement, incrementByAmount } from './counterSlice';

export function Counter() {
  // 1. 查看监控:只关心 counter 片区的 value 值
  const count = useSelector((state) => state.counter.value);
  
  // 2. 雇佣传令兵
  const dispatch = useDispatch();

  return (
    <div>
      <h1>当前库存: {count}</h1>
      
      <div className="card">
        {/* 派发“增加”指令 */}
        <button onClick={() => dispatch(increment())}>
          进货 (+1)
        </button>

        {/* 派发“减少”指令 */}
        <button onClick={() => dispatch(decrement())}>
          出货 (-1)
        </button>

        {/* 派发“自定义”指令,带上参数 10 */}
        <button onClick={() => dispatch(incrementByAmount(10))}>
           大批量进货 (+10)
        </button>
      </div>
    </div>
  );
}

四、 总结:RTK 到底爽在哪? 🎉

回顾一下,使用 Redux Toolkit 相比老旧的 Redux,我们省去了什么:

  1. 不再需要 手动定义 ACTION_TYPES 常量字符串(比如 'INCREMENT')。
  2. 不再需要 写冗长的 switch...case 语句。
  3. 不再需要 手动做不可变更新(return { ...state, value: state.value + 1 }),直接 state.value += 1 即可,Immer 库在底层为你保驾护航。
  4. 不再需要 复杂的 combineReducers 配置,configureStore 一键搞定。

一句话总结: RTK 就是把复杂的仓库管理变成了**“定义片区 -> 制定简单规则 -> 组件直接调用”**的流水线模式。

快去你的项目里试试吧!

不懂鸿蒙权限?看这篇就够了(鸿蒙权限获取最佳实践,附完整代码)

鸿蒙权限管理不踩坑指南:做个“懂分寸”的合规好应用

在鸿蒙(HarmonyOS)的世界里,权限管理就像应用的“社交礼仪”——懂分寸、不越界,才能赢得用户好感和系统“青睐”;要是乱要权限、硬闯隐私,轻则被用户无情卸载,重则过不了应用市场审核。今天就用接地气的吐槽+干货,把鸿蒙权限使用的核心玩法说清楚,让你开发路上少踩雷!

一、鸿蒙权限使用基本原则:做个“不贪心、不霸道”的好应用

鸿蒙对权限的要求,本质就是让应用“守规矩”,这几个原则记牢,能少走99%的弯路:

  1. 最小权限原则:别当“伸手党”
    应用需要啥权限就拿啥,多余的一概不碰!比如一个看小说的APP,非要申请相机、麦克风权限,这不是“没事找事”吗?用户看到直接黑人问号脸,卸载按钮都要按出火星子。
  2. 必要性原则:按需“点菜”,别一次性“包场”
    权限要在用户用到对应功能时再申请,别一打开APP就弹窗“轰炸”:“要位置!要存储!要通讯录!” 这跟刚见面就问人要银行卡密码一样离谱,用户不拒绝你才怪。
  3. 透明化原则:坦诚相待,别“玩套路”
    申请权限前,得跟用户说清楚“要这玩意儿干啥”。比如“要存储权限是为了保存你下载的小说”,别只说“需要存储权限”,用户哪知道你是不是要偷偷存人家照片?坦诚才是必杀技!
  4. 尊重用户选择原则:拒绝就体面点,别死缠烂打
    用户拒绝权限后,别反复弹窗“骚扰”,更不能搞“不授权就用不了核心功能”的霸道操作。人家不想给相机权限,你还不让人看小说了?格局打开,给个替代方案不香吗?
  5. 合规性原则:别“走歪路”,系统爸爸会“打屁股”
    别想着绕过系统授权、隐藏权限用途这些“骚操作”,鸿蒙的审核机制可不是吃素的,违规的话,应用上架直接“原地凉凉”,前期开发全白费。

二、权限分类:鸿蒙的“权限等级表”,别认错“大佬”和“路人”

鸿蒙把权限分了三六九等,就像职场里的“普通员工”“核心骨干”“大老板”,待遇和申请难度完全不一样,千万别搞混了:

(一)按权限等级分类

  1. system_grant(系统授权):“路人甲”级别的小透明
    不涉及隐私、不影响系统安全,比如网络访问、查看蓝牙的配置这些基础操作。系统会自动“放行”,不用麻烦用户手动授权,只要在配置文件里打个“报告”就行,省心又省力。
  2. user_grant(用户授权):“高危操作选手”,需用户点头
    涉及用户隐私(位置、相册)或核心功能(相机、麦克风)的权限,都是“高危分子”。想使用这些权限,必须让用户明确“点头”同意,系统还会特意弹窗提醒,相当于给用户一个“反悔的机会”。
  3. manual_settings(手动设置授权):“大佬级”权限,申请门槛拉满
    比如拦截键盘输入、无需弹窗录制屏幕、无需弹窗访问用户公共路径这些“核心操作”,属于权限里的“天花板”。想拿到它们可不容易,应用没法直接申请,得引导用户手动去系统设置里开启,相当于要去“大佬办公室”亲自报备。

另外,在system_grant和user_grant类型权限中,还藏着一些特殊的 “受限开放权限”,比如悬浮窗、读取联系人等等。这些权限可不是声明就能用的,必须提前单独申请,否则应用上架时直接会被审核老师打回,连辩解的机会都没有。 受限开放权限申请步骤(直接抄官方流程不踩坑)

(二)按功能权限组分类:权限也爱“抱团取暖”

鸿蒙特别贴心地把功能相关的权限分成了 “小组”,既方便开发者管理,也方便用户理解。这里有个小知识点要划重点:应用请求权限时,同一权限组内的权限会在一个弹窗内统一请求用户授权,用户一旦同意,整个权限组内的权限就会被批量授予。不过有例外 —— 位置信息、通讯录、日历这三个权限组,不遵循这个 “抱团授权” 规则,得单独留意。

以位置信息权限组和相机权限组举个例子帮你快速理解,一看就懂:。

当应用只申请权限ohos.permission.APPROXIMATELY_LOCATION(属于位置信息权限组)时,用户将收到一个请求位置信息的弹窗,包含单个权限的申请。 当应用同时申请权限ohos.permission.APPROXIMATELY_LOCATION和ohos.permission.LOCATION(均属于位置信息权限组)时,用户将收到一个请求位置信息的弹窗,包含两个权限的申请。 当应用同时申请权限ohos.permission.APPROXIMATELY_LOCATION(属于位置信息权限组)和ohos.permission.CAMERA(属于相机权限组)时,用户将收到请求位置信息、请求使用相机的两个弹窗。 权限组完整使用说明(官方清单,按需查阅)

三、权限申请方法:三步搞定,不做“尴尬申请者”

鸿蒙权限申请讲究“先报备、再申请、看结果”,不同权限有不同玩法,一步步来准没错:

(一)静态声明:先给系统“打报告”,不报备可不行

所有权限都得先在项目的 module.json5 文件里“登记备案”,相当于告诉系统“我要用这些权限啦”,这是基础操作,少了这步直接翻车。

  1. 找到 module.json5 文件,在 requestPermissions 节点里添加权限信息;
  2. 正确示范(别瞎写,权限名称要跟官方一致):
"requestPermissions": [
  {
    "name": "ohos.permission.CAMERA", // 相机权限,官方名称不能改
    "reason": "$string:camera_permission_reason", // 用途说明,比如“拍照上传头像”
    "usedScene": {
      "abilities": ["MainAbility"], // 哪个功能要用
      "when": "inuse" // 只有用的时候才申请,别一直要
    }
  }
]
  1. 避坑提醒:用途说明别写“需要权限”这种废话,要写清楚“用权限干嘛”,不然审核老师会给你打回重写!

(二)动态申请:看准时机“表白”,别盲目冲锋

危险权限光报备不够,还得在用户用对应功能时“趁热打铁”申请,流程就像“表白”——先探探口风,再正式出击:

  1. 先查状态:别做“舔狗”
    PermissionManager 查一查权限有没有被授权,已经授权了就直接用,别反复申请,不然用户会烦:“都给你了还问!”
  2. 再发起申请:真诚最重要
    没授权就调用 requestPermissionsFromUser 申请,系统会弹出弹窗,把用途告诉用户,让用户心甘情愿点头。
  3. 处理结果:成了就用,不成别纠缠
    用户同意了就开开心心用功能;拒绝了就好好说:“要开启相机权限才能拍照哦,可去系统设置里打开~” 别逼用户,不然会被反感。
  4. 代码示例(核心思路,看懂不踩坑):
import { abilityAccessCtrl, common, Permissions } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

const permissions: Permissions[] = ['ohos.permission.LOCATION', 'ohos.permission.APPROXIMATELY_LOCATION'];

function reqPermissionsFromUser(permissions: Array<Permissions>, context: common.UIAbilityContext): void {
  let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
  // requestPermissionsFromUser会判断权限的授权状态来决定是否唤起弹窗
  atManager.requestPermissionsFromUser(context, permissions).then((data) => {
    let grantStatus: number[] = data.authResults;
    let length: number = grantStatus.length;
    for (let i = 0; i < length; i++) {
      if (grantStatus[i] === 0) {
        // 用户授权,可以继续访问目标操作
        console.info(`${permissions[i]} is granted by user.`);
      } else {
        // 用户拒绝授权,提示用户必须授权才能访问当前页面的功能,并引导用户到系统设置中打开相应的权限
        return;
      }
    }
    // 授权成功
  }).catch((err: BusinessError) => {
    console.error(`Failed to request permissions from user, code: ${err.code}, message: ${err.message}`);
  })
}

@Entry
@Component
struct Index {
  aboutToAppear() {
    const context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
    reqPermissionsFromUser(permissions, context);
  }

  build() {
    // ...
  }
}

官方申请权限开发步骤

(三)用户拒绝授权了怎么办?别慌,有备选方案

这里有个关键知识点:当应用通过requestPermissionsFromUser()拉起弹窗请求用户授权时,如果用户明确拒绝,后续应用就无法再通过这个方法拉起同款弹窗了,只能引导用户去系统设置里手动授权。

在“设置”应用中的路径如下:

路径一:设置 > 隐私与安全 > 权限类型(如位置信息) > 具体应用 路径二:设置 > 应用和元服务 > 某个应用

当然,你也可以更贴心一点,通过调用 requestPermissionOnSetting(),直接拉起权限设置弹窗,帮用户省去手动找路径的麻烦,好感度直接拉满:

import { abilityAccessCtrl, Context, common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

// ···
   let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
   let context: Context = this.getUIContext().getHostContext() as common.UIAbilityContext;
   atManager.requestPermissionOnSetting(context, ['ohos.permission.APPROXIMATELY_LOCATION']).then((data: Array<abilityAccessCtrl.GrantStatus>) => {
     console.info(`requestPermissionOnSetting success, result: ${data}`);
   }).catch((err: BusinessError) => {
     console.error(`requestPermissionOnSetting fail, code: ${err.code}, message: ${err.message}`);
   });

授权最佳实践(直接 ctrl c/ctrl v 就能用,抄作业就完了)

下面给大家整理了一套权限管理工具类,核心逻辑已经帮你梳理清晰,无需理解太深,直接复制到项目里就能用,堪称 “懒人福音”。 工具类核心逻辑说明

  1. 状态检查阶段:先筛选,再申请,不做无用功 ・通过应用tokenId获取真实身份标识,确保权限检查的准确性; ・采用异步方式批量校验每个权限的当前状态,效率更高; ・动态构建待申请权限列表,只处理未授权权限,避免重复弹窗打扰用户。
  2. 分级申请策略:首次、二次分开处理,更懂用户心理 ・首次申请:直接弹窗请求所有未授权权限,一步到位,不折腾用户; ・二次申请:当检测到dialogShownResults存在false值时(说明用户已拒绝过一次)→ 自动转入设置页引导模式,不反复弹窗惹人烦;→ 逐个发起权限申请(因系统限制,二次申请不同权限组无法批量操作),提高授权成功率。

具体调用方式(注意:权限需要先在module.json5中声明)

checkAndRequestPermissions(['ohos.permission.MICROPHONE', 'ohos.permission.CAMERA'], "hello world")

权限管理工具类(完整可复制)

import abilityAccessCtrl, { Permissions } from '@ohos.abilityAccessCtrl';
import bundleManager from '@ohos.bundle.bundleManager';
import common from '@ohos.app.ability.common';
import promptAction from '@ohos.promptAction';
import { BusinessError } from '@ohos.base';


/**
 * 单个权限授权状态检查
 * @param permission 待检查的单个权限
 * @returns 权限授权状态
 */
async function checkPermissionGrant(permission: Permissions): Promise<abilityAccessCtrl.GrantStatus> {
  let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
  let grantStatus: abilityAccessCtrl.GrantStatus = abilityAccessCtrl.GrantStatus.PERMISSION_DENIED;

  // 获取应用程序的accessTokenID
  let tokenId: number = 0;
  try {
    let bundleInfo: bundleManager.BundleInfo =
      await bundleManager.getBundleInfoForSelf(bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION);
    let appInfo: bundleManager.ApplicationInfo = bundleInfo.appInfo;
    tokenId = appInfo.accessTokenId;
  } catch (error) {
    const err: BusinessError = error as BusinessError;
    console.error(`获取应用Bundle信息失败,错误码:${err.code},错误信息:${err.message}`);
    return grantStatus;
  }

  // 校验应用是否被授予该权限
  try {
    grantStatus = await atManager.checkAccessToken(tokenId, permission);
  } catch (error) {
    const err: BusinessError = error as BusinessError;
    console.error(`检查权限授权状态失败,错误码:${err.code},错误信息:${err.message}`);
  }

  return grantStatus;
}

/**
 * 权限申请辅助方法(处理首次拒绝与二次引导设置)
 * @param permissions 待申请的权限数组
 * @param refuseStr 权限申请被拒绝后的提示语
 * @returns 最终是否授权成功
 */
async function requestPermissionHelper(permissions: Array<Permissions>, refuseStr: string): Promise<boolean> {
  // 判断首次申请是否全部授权成功
  let userGrant = true;
  try {
    let context = getContext() as common.UIAbilityContext;
    let isFirstTime: boolean = true;
    let atManager = abilityAccessCtrl.createAtManager();
    let grantStatus = await atManager.requestPermissionsFromUser(context, permissions);
    for (let element of grantStatus.authResults) {
      if (element !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
        userGrant = false;
        break;
      }
    }

    // 判断是否为非首次申请(弹窗未显示说明已被用户拒绝过一次)
    if (grantStatus.dialogShownResults) {
      for (let element of grantStatus.dialogShownResults) {
        if (!element) {
          isFirstTime = false;
          break;
        }
      }
    }

    // 非首次申请且授权失败:引导用户去设置页面开启
    if (!isFirstTime && !userGrant) {
      // 不同权限组不能同时申请,逐个申请并校验
      for (let permission of permissions) {
        const data: Array<abilityAccessCtrl.GrantStatus> =
          await atManager.requestPermissionOnSetting(context, [permission]);
        userGrant = true;
        for (let element of data) {
          if (element !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
            userGrant = false;
          }
        }
        if (!userGrant) {
          promptAction.showToast({
            message: refuseStr,
            duration: 5000
          });
        }
      }
    } else if (isFirstTime && !userGrant) {
      // 首次申请且授权失败:仅提示用户
      promptAction.showToast({
        message: refuseStr,
        duration: 5000
      });
    }
  }catch ( err){
    console.error(`Request permissions failed, code: ${(err as BusinessError).code}, message: ${(err as BusinessError).message}`);
    userGrant =  false;
  }

  return userGrant;
}

/**
 * 核心封装函数:先批量检查所有权限,仅对未授权权限发起申请
 * @param permissions 待校验/申请的权限数组
 * @param refuseStr 权限申请被拒绝后的提示语
 * @returns 所有权限是否最终授权成功(true:全部授权;false:存在未授权权限)
 */
export async function checkAndRequestPermissions(permissions: Array<Permissions>, refuseStr: string): Promise<boolean> {
  // 边界处理:空权限数组直接返回授权成功
  if (!permissions || permissions.length === 0) {
    console.warn("待申请权限数组为空,无需处理");
    return true;
  }

  // 第一步:批量检查所有权限的授权状态,筛选出未授权的权限
  const unGrantedPermissions: Array<Permissions> = [];
  for (const permission of permissions) {
    const grantStatus = await checkPermissionGrant(permission);
    if (grantStatus !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
      unGrantedPermissions.push(permission);
      console.log(`权限【${permission}】未授权,加入申请队列`);
    } else {
      console.log(`权限【${permission}】已授权,无需重复申请`);
    }
  }

  // 第二步:如果所有权限都已授权,直接返回true
  if (unGrantedPermissions.length === 0) {
    console.log("所有权限均已授权,无需发起申请");
    return true;
  }

  // 第三步:仅对未授权权限发起申请,返回最终申请结果
  console.log(`开始发起未授权权限申请,待申请权限:${unGrantedPermissions.join(', ')}`);
  const finalGrantResult = await requestPermissionHelper(unGrantedPermissions, refuseStr);

  return finalGrantResult;
}

一、核心调用流程

  1. 入口方法:checkAndRequestPermissions(permissions, refuseStr)

image.png 2. 权限申请辅助方法:requestPermissionHelper(unGrantedPermissions, refuseStr)

image.png

结语

其实鸿蒙权限管理并没有想象中复杂,核心就是 “懂分寸、不贪心、够坦诚”。遵循本文的原则、分类标准和申请方法,再直接抄用现成的权限管理工具类,你的应用既能顺利实现功能,又能让用户觉得 “这 APP 真懂事”,好感度拉满! 如果想了解更详细的权限列表、API 用法,可直接查阅华为开发者联盟官方文档,合规开发才是在鸿蒙生态里走得更远的关键哦!

langchainjs&langgraphjs入门(二)格式化输出

格式化输出

zod

zod是一个ts的类型校验库,langchain官方推荐使用zod来定义ai输出的schema,例如:

import {z} from 'zod'
// 期望ai返回对象,其中包含name,age,skills
const schema = z.object({
    name:z.string().describe('姓名'),
    age: z.number().init().describe('年龄'),
    skills: z.array(z.string()).describe('技能')
})

安装:

npm i zod

速查表:

API 用途 示例
z.string() 字符串 z.string()
z.number() 数字 z.number().int()
z.boolean() 布尔值 z.boolean()
z.object() 对象 z.object({ name: z.string() })
z.array() 数组 z.array(z.string())
z.enum() 枚举 z.enum(["A", "B"])
z.union() 联合类型 z.string().or(z.number())
z.optional() 可选字段 z.string().optional()
z.nullable() 允许 null z.string().nullable()
z.default() 默认值 z.number().default(0)
z.literal() 字面量 z.literal("on")
z.record() 键值映射 z.record(z.string())
z.tuple() 元组 z.tuple([z.string(), z.number()])
.refine() 自定义校验 .refine(s => s.length > 3)
.transform() 转换输出 .transform(s => s.toUpperCase())
.describe() 描述 z.string().describe('姓名')

withStructuredOutput

上一章节的例子可以看到模型的输出只是普通的字符串,并没有格式化.无法直接使用.要想让模型输出格式化的内容可以使用官方推荐的zod,使用他来定义数据结构并验证数据结构是否正确,从而帮助langchain实现输出的格式化和验证.

  1. 首先使用zod定义类型
  2. 然后通过langchain提供的.withStructuredOutput接口使用类型,调用这个方法传入zod定义的类型.模型将添加所需的所有模型参数和输出解析器

示例

import model from './1调用模型.mjs'
import { z } from 'zod'

const schema = z.object({ isCool: z.boolean() }) // 定义输出类型
const structuredLlm = model.withStructuredOutput(schema)

const res = await structuredLlm.invoke('我帅不帅')
console.log("res:",res); // res: { isCool: true }输出了结构化的内容,可以看到模型也知道我很帅

实际上withStructuredOutput在背后会根据schema自动生成严格的提示词,并自动解析验证模型输出,然后将结果返回给开发者

withStructuredOutputlangchain封装后的便捷api,如果想深入理解背后做了什么可以查看这里,后面我们也会详细讲解

StringOutputParser

StringOutoutParser可以从LLM回复的消息中直接提取文本内容,使得我们获取的不再是AIMessage对象而是纯文本

用法

实例化的方式进行创建

import { StringOutputParser } from "@langchain/core/output_parsers";

// 创建实例(无需传递任何参数)
const outputParser = new StringOutputParser();
// 使用
const res = await llm.invoke('你好')
const str = outputParser.invoke(res)

// 简便使用
const chain = llm.pipe(outputParser)
const res = chain.invoke('你好')

对于格式化输出,为便于记忆暂时先了解这么多.知道langchain提供了这么个功能,当上述能力不满足实际开发场景时,再去翻阅官方文档即可.

关注我,该专栏会持续更新!

❌