普通视图

发现新文章,点击刷新页面。
昨天以前白云苍狗

LLM Agent 常用范式详解

作者 白云苍狗
2026年3月17日 10:35

本文整理了 LLM Agent 中常用的几种范式,从简单到复杂,详细介绍每种范式的概念、工作原理、特点、适用场景、流程示意以及优势与局限。

单步回答型(Single-step / Direct Response) ​

单步回答型是最基础的 LLM Agent 范式,特点是收到问题或任务后直接生成答案,不保留历史状态,也不调用外部工具。
简单理解:“你问我答,不做额外思考”

工作原理 ​

  • 接收输入:用户发送问题或任务描述给模型。
  • 生成输出:模型根据训练中学到的语言模式和知识生成答案。
  • 返回结果:输出文本返回给用户。

注意:整个过程没有循环、计划或外部验证,完全依赖模型自身知识。

特点 ​

  • 快速:无需多轮推理或外部查询。
  • 实现简单:只需调用生成接口即可。
  • 依赖训练数据:输出结果依赖模型已有知识。

适用场景 ​

  • 事实类问答:如“世界上最高的山是哪座?”
  • 知识查询:如“谁写了《哈利·波特》?”
  • 简单计算或翻译:如“把‘Hello’翻译成中文”。

流程示意 ​

优势与局限 ​

  • 优势:快速、易实现,对简单任务足够使用。
  • 局限:无法处理多步骤问题或实时信息,缺乏可解释性。

链式思维型(Chain-of-Thought, CoT) ​

链式思维型让 LLM 在生成答案之前先写出思考步骤,增加可解释性。
简单理解:“先写下我的思路,再给你答案”

工作原理 ​

  1. 接收输入:用户提供问题或任务。
  2. 生成思考步骤:模型拆解问题、分析子问题或列出公式。
  3. 生成答案:结合思考步骤输出最终结果。
  4. 返回结果:输出既包含思考步骤,也包含答案。

注意:链式思维型通常一次生成,不会根据结果再循环修正,除非与 ReAct 或反思型结合。

特点 ​

  • 显式推理,增强可解释性。
  • 支持多步骤问题处理。
  • 依赖模型内部知识,无外部工具调用能力。

适用场景 ​

  • 数学题、逻辑题
  • 多步骤推理问题
  • 需要分析过程的问答

流程示意 ​

优势与局限 ​

  • 优势:提高复杂问题正确率,增强信任度。
  • 局限:无法调用外部数据,输出长度较长,处理速度略慢。

ReAct(Reasoning + Acting) ​

ReAct 结合 推理和行动,让模型在思考的同时可以执行操作,并根据结果不断调整策略。
简单理解:“先想,再做,边做边想”

工作原理 ​

  1. 接收输入:用户提供任务。
  2. 推理(Reasoning):分析问题,生成解决思路。
  3. 行动(Acting):执行操作,如调用工具、查询数据、计算等。
  4. 反馈:行动结果返回模型,供下一轮推理。
  5. 循环迭代:模型根据反馈不断调整,直到完成任务。
  6. 输出答案:最终返回结果。

特点 ​

  • 动态交互,可根据环境或工具结果调整策略。
  • 支持多步骤任务处理。
  • 可以调用外部工具或 API。
  • 推理链条透明,便于理解。

适用场景 ​

  • 查询实时数据(天气、股市、新闻)
  • 多步骤问答或推理任务
  • 自动化办公任务(数据处理、报告生成)

流程示意 ​

优势与局限 ​

  • 优势:适合复杂任务,支持工具调用,循环迭代可提高准确性。
  • 局限:实现复杂,循环可能增加响应时间。

反思型(Reflexive Agent) ​

反思型让 LLM 在输出前自我检查和修正,类似人类写完作业后回头自查。
简单理解:“先写,再检查,再修正”

工作原理 ​

  1. 接收输入
  2. 初步生成答案
  3. 自我反思:检查语法、逻辑或数据合理性
  4. 修正输出
  5. 最终输出

特点 ​

  • 内部自查循环,提升输出质量。
  • 无需外部工具。
  • 适合长文本或多轮任务。

适用场景 ​

  • 自动写作(文章、报告、邮件)
  • 长对话系统保持逻辑一致
  • 编程辅助:生成代码并检查逻辑/语法

流程示意 ​

任务规划型(Planner + Executor) ​

任务规划型将复杂任务拆分为子任务并逐步执行,核心是“规划 + 执行”。
简单理解:“先规划,再执行,每步有条理”

工作原理 ​

  1. 接收输入
  2. 规划阶段:拆解任务、排列子任务顺序
  3. 执行阶段:逐步完成子任务,可调用工具
  4. 整合结果:汇总各子任务输出
  5. 最终输出

特点 ​

  • 适合多步骤任务
  • 结构清晰
  • 可结合工具
  • 支持长任务处理

流程示意 ​

记忆增强型(Memory-Augmented Agent) ​

记忆增强型具有长期记忆能力,在多轮对话或长期任务中持续利用历史信息。
简单理解:“记住过去,做出更智能的决策”

工作原理 ​

  1. 接收输入
  2. 访问记忆:检索历史信息或上下文
  3. 生成答案:结合当前输入和记忆
  4. 更新记忆:保存新信息
  5. 返回输出

特点 ​

  • 长期记忆与个性化
  • 上下文连续,多轮任务一致性
  • 可动态更新

流程示意 ​

多 Agent 协作型(Multi-Agent / Team Agents) ​

多 Agent 协作型由多个 Agent 分工协作完成任务
简单理解:“分工合作,每个成员做擅长的事”

工作原理 ​

  1. 接收输入
  2. 任务拆分与分配:将任务分配给不同 Agent
  3. 各 Agent 执行任务
  4. 信息汇总:整合输出
  5. 最终输出

特点 ​

  • 分工明确
  • 可扩展性强
  • 协作与信息整合
  • 每个 Agent 可调用工具

流程示意 ​

总结 ​

从简单到复杂的 LLM Agent 范式:

单步回答 → 链式思维 → ReAct → 反思型 → 任务规划 → 记忆增强 → 多 Agent 协作

  • 越往右,模型能力越强,处理复杂任务能力越高。
  • 越往右,实现成本越高,但灵活性和适用性也越强。
  • 在实际应用中,常会混合使用多种范式,发挥各自优势,提升准确性和任务处理能力。

利用 AI 编程提效体验

作者 白云苍狗
2025年3月25日 15:46

从今年开始,公司开始全员推广AI编程,并提供Cursor账号给大家使用。经过三个多月的深度使用,在此分享我的一些真实体验与感受。

AI编程辅助工具的发展历程 ​

从ChatGPT到目前各种AI编程助手和智能编辑器,AI辅助编程工具的发展可以大致分为三个阶段:

生成式阶段 ​

最初的AI编程辅助仅限于对话式生成,且生成质量一般。使用这类工具辅助编程需要在AI聊天窗口和代码编辑器之间不断切换,频繁复制粘贴,体验非常繁琐。虽然后来编辑器中出现了大量封装AI对话的插件,但交互依然不够顺畅,仍需手动在不同界面间复制代码。

在这个阶段,我的使用主要集中在早期尝鲜阶段,新鲜感过后便将其降级为高级搜索引擎使用。

交互式阶段 ​

Cursor在早期虽然其他功能表现平平,但它之所以能迅速走红,很大程度上源于其创新的交互设计。它能基于本地代码建立索引,根据项目上下文提供更智能的代码提示和修改建议,并通过Tab键快速应用这些修改(Tab键功能的便捷性无需多言,用过的人都懂😂)。

执行式阶段 ​

随着Cursor等工具的交互模式获得认可,AI编程工具的能力边界进一步扩展。现代AI编程助手不仅能生成代码,还能直接执行并根据运行结果进行优化。例如,当你需要处理文件夹中的文件时,之前的AI只会生成代码而不会执行;如今的AI则能运行生成的代码,根据执行结果自动修复错误或进行下一步操作。

这种进化显著提升了开发效率,特别是对非专业程序员更为友好。

个人使用体验 ​

在这三个月的Cursor深度使用过程中,我主要在以下几个方面获益良多:

项目代码分析 ​

作为公司新人,面对多样化的项目架构和技术栈,快速理解代码成为首要挑战。AI工具在此环节表现出色,帮助我迅速梳理项目结构和代码逻辑。有时候连我自己都需要花时间理解的代码片段,AI已经能够逐行分析并提供清晰解释。

对于代码中的疑点,直接向AI提问往往比翻阅文档更加高效。

AI代码分析示例

复杂技术规范解析 ​

办公软件在线化是个技术挑战,尤其是涉及到OOXML等规范时。这类规范文档通常分为多个部分,每部分都是数千页的英文PDF,对不熟悉的开发者极不友好。

在需要解析PPT文件并进行二次编辑时,我直接向AI咨询相关技术细节,它不仅能解释概念,还能根据项目实际需求提供针对性解决方案。甚至可以直接将XML片段交给AI,让它提取关键信息并转换为网页可用的CSS值。

PPT规范解析示例

代码重构与优化 ​

为求速度,我有时会将功能实现集中在单个文件中。作为曾接手过"屎山"代码的开发者,我深知这种做法的后患。在无法立即进行组件拆分的情况下,我通常选择先完成业务功能,再交由AI进行代码拆分和封装。

向AI明确提出重构需求,再根据反馈进行调整,这种方式既保证了开发速度,又维持了代码质量。更值得称赞的是,AI重构后的代码通常能直接运行,免去了大量调试时间。

环境搭建与小需求快速解决 ​

AI在简化工作流方面表现突出。比如需要批量处理文件时,AI能根据需求生成脚本,指导执行过程,并根据执行结果进行修复。使用者只需按提示操作即可。

例如,尽管我后端知识已经淡忘,Python也几乎没接触过,但在AI的辅助下,我依然能够编写简单的Python后端代码。环境配置过程中,AI会提供详细指引,甚至在遇到国外源不可用时,主动帮我切换到国内镜像,体现了超出预期的智能水平。

另一个实例是,我需要将大量视频文件按特定格式上传至云存储,本地文件名虽有规律但信息不完整。本想手动处理,却尝试让AI协助,结果几分钟就完成了可能需要我花费数小时的整理工作。

代码智能提示 ​

Cursor的Tab补全功能堪称神器,它能基于本地代码和项目结构智能预测下一步操作。"一直Tab一直爽"并非夸张之词。

实际使用中,当修改组件CSS字体大小时,AI常能预测到其他关联部分也需要调整,准确率相当高。如今的代码提示已经进化到我正在输入代码的同时,AI已经预生成了后续内容的地步。

跨语言代码转换与生成 ​

在参考其他语言实现的功能时,AI能够快速将代码转换为目标语言,省去了人工"翻译"的麻烦。大部分转换后的代码无需大幅调整即可运行。

向AI描述需求时,我发现一些技巧:尽量详细描述功能,避免过于宏大的任务,最好将大任务分解为小步骤逐一实现。若直接提出复杂需求,AI容易"卡壳"甚至开始胡乱生成代码。

使用中的不足之处 ​

尽管AI编程工具带来诸多便利,使用过程中也存在一些值得注意的问题:

内容不准确 ​

虽然现代AI比早期版本强大许多,但仍存在"幻觉"问题,有时会生成不准确内容。更令人困扰的是,即使指出错误,AI可能口头认同却不实质性修正问题。这种情况部分源于提示不当,部分则是AI能力限制。

意图理解偏差 ​

有时我只需AI提供思路,它却直接修改代码;有时需要具体实现,它却只给出概念性指导。这种意图理解的不一致让人难以把握,需要通过不同提示词来引导。早期Cursor将对话和生成分开设计,现在功能整合后反而产生了一些混淆。

弱化思考能力 ​

过度依赖AI实现功能会导致一种"失控感",因为代码逻辑并非源自自己思考,而是AI直接生成。当AI编码风格与个人习惯不符时,甚至需要"反向学习"AI的实现方式。这时,要求AI详细解释实现逻辑就显得尤为重要。

总结 ​

总体而言,AI编程工具确实显著提升了开发效率。在回归传统编辑器后,没有智能提示的编码体验甚至让人有些不适应。

项目验收需要做哪些东西?

作者 白云苍狗
2024年10月10日 10:16

最近需要做一些项目验收相关的事,结合网上的别人的经验和自己实操,整理一些需要清单和要做的事情。

项目验收 ​

存档文档 ​

  • 项目架构设计文档
  • 数据库设计说明书
  • 接口设计说明书
  • 代码结构文件说明书
  • 编码规范和数据库设计规范
  • 原型或PRD
  • 系统操作手册
  • 质量分析报告
  • 服务器软硬件环境配置说明书

验收准备 ​

  • 提供演示站点
  • 将要交付的软件安装于指定服务器,并完成调试和上线。
  • 文档齐全,文档内容需要描述完整并没有歧义,对主要功能和关键操作尽量提供应用实例。

界面验收 ​

  • PC 端需要适配现代浏览器 谷歌、edge、火狐、safari...等
  • 移动端需要适配主流设备 iphone、华为、小米、vivo、oppo... 等

功能验收 ​

  • 功能验收范围覆盖(接口、数据库存取、页面功能)
  • 提供单元测试用例、集成测试用例和系统测试用例

性能验收 ​

  • 提供BUG管理跟踪记录表
  • 提供质量分析报告
  • 提供性能测试报告

安全验收 ​

  • 软件中的敏感数据需以密文方式存储
  • 软件需有留痕功能,即保存用户的操作日志、系统异常日志、接口调用数据日志等
  • 软件中各种用户的权限分配合理

用户验收 ​

  • 外包团队需提供稳定的用户验收环境
  • 业务场景功能测试,不得出现严重 BUG
  • 所有提交的问题都已得到修复

源码交接 ​

源码交接前提 ​

  • 涉及交接的软件,原则上建议接受交接软件所有功能,不建议交接软件部分功能模块
  • 线上稳定运行既线上可用率,需满足:最近3至6个月内,线上没有出现影响20人以上或数据错误的严重bug,且每月线上bug数不超过3个

源码验收规范 ​

  • 代码应只保留跟本项目相关的代码,无效代码应一律去除
  • 数据库应只保留跟本项目相关的表、视图、存储过程、函数、触发器、定时job等,无效内容应一律去除
  • 特别注意合理做好数据表结构设计,适当冗余提升性能
  • 代码结构清晰无冗余,注释完整有效,避免硬编码
  • 但凡不符合源码验收规范的,外包团队需修复完毕

其他 ​

  • 售后服务
  • 培训计划

vitepress 自定义主题教程

作者 白云苍狗
2024年9月23日 13:15

这篇文章是什么呢? 就是如题所述告诉你如何自定义极简的博客主题。

需求 ​

vitepress 是什么 ​

VitePress 是一个静态站点生成器 (SSG),专为构建快速、以内容为中心的站点而设计。简而言之,VitePress 获取用 Markdown 编写的内容,对其应用主题,并生成可以轻松部署到任何地方的静态 HTML 页面。

功能需求 ​

首先确定我们开发一个主题需要哪些页面和功能,一个极简的博客可以只需要下面三个页面。加载文章数据生成首页,以及显示文章的详情页

  • 首页
  • 文章详情页
  • 404 页

实现 ​

需要注意的是 vitepress 自定义主题需要区分哪些是运行在 node 环境的,哪些是运行在 浏览器 上的。

只运行在 node 中的

  • 配置文件 config.ts
  • 构建是加载数据 .data.js.data.ts 结尾文件

node 和 浏览器都会运行的

  • SFC 文件,因为使用的 SSR 渲染,所有需要注意使用范围

原则上只在 Vue 组件的 beforeMount 或 mounted 钩子中访问浏览器或 DOM API。

自定义主题 ​

vitepress 想要自定义主题,只需要在主题启动入口文件里申明使用自定义主题即可 ( .vitepress/theme/index.js.vitepress/theme/index.ts 为主题入口文件)

.
│─ .vitepress
│  ├─ theme
│  │  └─ index.ts   # 主题入口
│  └─ config.ts     # 配置文件
│─ index.md
└─ package.json

在入口文件中导出根布局组件,Layout.vue 会替代 vitepress 默认主题进行页面的渲染,让我们从 Layout.vue 开始完善一个完整的主题吧。

ts
// .vitepress/theme/index.ts

// 可以直接在主题入口导入 Vue 文件
import Layout from "./Layout.vue";
export default {
  Layout, // 每个页面的根布局组件
};

在开始之前,我们确定我们项目页面结构组成,暂定为如下目录结构,将文章集中放在 _posts 目录下

.
│─ .vitepress
│  ├─ theme
│  │  └─ index.ts   # 主题入口
│  └─ config.ts     # 配置文件
│─ _posts # 文章存放目录
│  ├─ xxxx.md
│  └─ xxxx.md
│─ index.md # 首页
└─ package.json

加载 _posts 文章 ​

首页里的内容除了通用的布局样式之外 (导航、侧栏、横幅、....) 主要的就是文章列表了,我们先来实现对 _posts 目录下文件加载和分页,以及文章置顶排序等实现。

在 vitepress 提供了构建是加载数据的钩子,我们可以自定义加载数,并且在页面中使用它。使用方式也很简单只需要文件 .data.js.data.ts 结尾即可。

我们添加 posts.data.ts 文件,大致流程就是 读取文件 => 处理 frontmatter 参数、摘要、内容 => 对文章排序处理

需要注意的是: .data.ts 是运行在 node 环境下的,避免使用浏览器的 api

ts
export default {
  watch: ["_posts/**/*.md"],
  // files 是一个所匹配文件的绝对路径的数组。
  load(files) {
    // 使用 vitepress 内置 渲染插件渲染 md 文件里提取摘要信息
    md = md || (await createMarkdownRenderer(config.srcDir, config.markdown, config.site.base, config.logger));
    const raw = [];
  },
};

通过 fs 读取文件内容和文件信息,以及文件信息(创建时间 和 修改时间),可以将生成的文章信息缓存起来,通过判断 mtimeMs 是否变更来判单是否需要重新生成。

ts
const { mtimeMs: timestamp, birthtimeMs } = fs.statSync(file);
const cached = cache.get(file);

if (cached && timestamp === cached.timestamp) {
  raw.push(cached.data);
  continue;
}

使用 gray-matter 库来提取 md 文件里的 YAML frontmatter 信息,在 gray-matterexcerpt 添加摘要生成函数。

ts
const fileContent = fs.readFileSync(file, "utf-8");
let excerpt = "";
const { data: meta } = matter<string, any>(fileContent, {
  excerpt: ({ content }: matter.GrayMatterFile<string>) => {
    const reg = /<!--\s*more\s*-->/gs; //  将 <!-- more --> 前面内容视为摘要格式
    const rpt = reg.exec(content);
    excerpt = rpt ? content.substring(0, rpt.index) : "";
  },
});

获取文章创建时间,取值规则如下 md => git => file,先去 frontmatter 里声明时间,然后获取 git 提交时间,如果前面两个都没获取到则获取文件的时间。

frontmatter 和 文件创建时间 上面都以及获取到了接下来主要是获取 git 提交时间 ,主要是通过 git log 命令获取文件信息。

node 实现如下

ts
import { spawnSync } from "node:child_process";

function getFileBirthTime(url: string) {
  try {
    // ct
    const infoStr = spawnSync("git", ["log", '--pretty="%ci"', url]).stdout?.toString().replace(/["']/g, "").trim();
    const timeList = infoStr.split("\n").filter((item) => Boolean(item.trim()));
    if (timeList.length > 0) {
      return new Date(timeList.pop()!).getTime();
    }
  } catch (error) {
    return undefined;
  }
}

渲染摘要信息 和 处理文件路径

ts
let url = normalizePath(path.relative(config.srcDir, file));
url = config.rewrites.map[url] ?? url;
url = "/" + url.replace(/(^|\/)index\.md$/, "$1").replace(/\.md$/, config.cleanUrls ? "" : ".html");

const renderedExcerpt = excerpt ? md.render(excerpt) : void 0;
const data = {
  excerpt: renderedExcerpt,
  ...meta,
  url: withBase(config.site.base, url),
  filePath: slash(path.relative(config.srcDir, file)),
};
cache.set(file, { data, timestamp });
raw.push(data);

对生成数据进行排序并返回

ts
return sortBy(raw, "-date");

页面布局 &ZeroWidthSpace;

对文章的数据加载已经实现 ,在布局组件里直接导入 post.data 文件, layout 为 home 视为首页, 其他则为文章详情页面 。

vue
<script setup lang="ts">
import { useData } from "vitepress";
import { data as allPosts } from "./post.data";
import { formatDate } from "./util/client";
const { page } = useData();
</script>

<template>
  <div v-if="page.isNotFound">404</div>
  <template v-else>
    <div v-if="page.frontmatter.layout === 'home'" class="post-list">
      <div v-for="item in allPosts" class="post-list-item">
        <a :href="item.url">
          <span>{{ item.title }}</span>
          <span>{{ formatDate(item.date, "YYYY-MM-dd") }}</span>
        </a>
      </div>
    </div>
    <template v-else>
      <div class="post-title">
        <div>{{ page.title }}</div>
        <a href="/">返回</a>
      </div>
      <div class="post-info">
        <Content />
      </div>
    </template>
  </template>
</template>

把所有页面逻辑都写在一个页面会难以维护 ,我们拆分一下

vue
<template>
  <NotFoundPage v-if="page.isNotFound" />
  <template v-else>
    <HomePage v-if="page.frontmatter.layout === 'home'" />
    <PostPage v-else />
  </template>
</template>

实现分页 &ZeroWidthSpace;

在 HomePage 对 allPost 实现前端分页,根据当前 currentPage 和 pageSize 将文章数组进行拆分

ts
const currentPage = ref(1);
const pageSize = 3;

// 当前页显示 文章
const curPageList = computed(() => {
  const startIdx = (currentPage.value - 1) * pageSize;
  const endIdx = startIdx + pageSize;
  return allPosts.slice(startIdx, endIdx);
});

根据当前页码信息,生成一个简易分页组件数据

ts
// 页码生成
const pageList = computed(() => {
  const count = Math.ceil(allPosts.length / pageSize);
  if (count > 5) {
    const list = [1, currentPage.value - 1, currentPage.value, currentPage.value + 1, count].filter((i) => i > 0 && i <= count);
    return [...new Set(list)];
  } else {
    return new Array(count).fill(null).map((_v, i) => i + 1);
  }
});

将模板中遍历 allPosts 替换成 curPageList ,并渲染出页码信息,切换页码时候设置 currentPage 就可以了

自定义配置 &ZeroWidthSpace;

在上面都是将一些信息写死在代码中的,vitepress 提供了注册配置方式的,我们可以将主题中的一些参数写入到配置中。

.vitepress/config.ts 是 vitepress 加载配置的文件, 我们要自定义主题所以不能在使用 vitepress 提供的 defineConfig 方法。

如果时仅仅自己使用,可以直接导出我们配置就可以了

ts
import { UserConfig } from "vitepress";
const themeConfig = {};
export type ThemeConfig = typeof themeConfig;
export default {
  title: "vitepress-demo",
  description: "viteoress 自定义主题示例",
  themeConfig, // 这里将配置我们主题的配置
} as UserConfig<ThemeConfig>;

比如我们上面存在文章的目录、页码 等等都可以写在配置里, 以方便细粒度配置。

ts
const themeConfig = {
  pageSize: 3,
  sort: "-date",
  postDir: "_posts",
};

在主题中可以通过 vitepress 提供 useData 使用

ts
const { theme } = useData<ThemeConfig>();

如果我们需要将主题发布到 npm 供其他人使用,当主题更为复杂的时候,会有更多的配置,所有我们需要处理主题默认的配置 和 用户配置合并,提供的 defineConfig 方便用户的使用

ts
import { mergeConfig as mergeViteConfig } from "vite";
import type { UserConfig } from "vitepress";

export const defineConfig = (config: UserConfig<ThemeConfig>) => {
  config = mergeConfig(defaultConfig, config);

  // 可以在这里处理一些 配置合并问题

  return config;
};

总结 &ZeroWidthSpace;

本文中使用到的代码示例可以在 stackblitz 上查看

一个复杂的主题当然还会有更多的共功能,页面中更良好的布局,那些都是在此基础上进行的扩展。


安利一下我写的 hexo 和 vitepress 主题

从 hexo 迁移到 Vitepress

作者 白云苍狗
2024年8月24日 13:13

最近抽空把博客从 hexo 转移到 vitepress 了,也对 vitepress-theme-async 进行了一波爆更,整体迁移还算比较顺利。大部分的功能都已经处理完了,部分原来需要三方插件支持功能还没支持。

有需要从 hexo-theme-async 迁移到 vitepress-theme-async 的可以 参考文档这里

vitepress-theme-async 主题发布

作者 白云苍狗
2024年4月16日 13:15

之前文档一直使用的是 vitepress 搭建的,体验感觉也挺好的,所以萌生了想移植到 vitepress 上去。为什么不是选择 vuepress,是因为 vitepress 更加轻量更快。

早在几个月前就完成了 hexo-theme-async 大部分功能的移植,但是因为在使用 vitepress 自定义加载数据出现了一些问题(参考这里),没有进行下去,想着等 vieptepres 从 rc 到正式发布会不会处理这个问题,最终 1.0 发版时这个问题还是存在,只好修改主题实现方式避免这个问题。

快速开始 &ZeroWidthSpace;

创建一个新的博客 &ZeroWidthSpace;

bash
$ npm create async-theme@latest my-first-blog

运行查看效果 &ZeroWidthSpace;

bash
$ vitepress dev ./

地址 &ZeroWidthSpace;

支持功能 &ZeroWidthSpace;

基本上 UI 上的功能,已经基本完善实现了。一些插件类功能本身 vitepress 也支持,所以没有移植处理。

  • ✅ 语言切换
  • ✅ 主题模式
  • ✅ 搜索模块
  • ✅ 顶部导航模块
  • ✅ 侧栏模块
  • ✅ 横幅模块
  • ✅ 页脚模块
  • ✅ 固定按钮区域模块
  • ✅ 分页模块
  • ✅ 打赏模块
  • ✅ 版权信息模块
  • ✅ 上下篇模块
  • ✅ 文章封面模块
  • ✅ 过期提醒模块
  • ✅ 友接页
  • ✅ 关于页
  • ✅ 归档页
  • ✅ 标签页
  • ✅ 分类页
  • ✅ 全局组件
  • ✅ 自定义插槽
  • ✅ 自定义图标
  • ✅ 自定义样式
  • ✅ 自定义组件
  • ✅ RSS 插件

自建免费 Moe-Counter 计数器

作者 白云苍狗
2024年3月7日 16:39

因为 Moe-Counter 经常会出现无法访问的情况,于是基于 vercel + mongodb 自建了计数器。

示例 &ZeroWidthSpace;

使用方式也稍有修改,只需要在后面拼接自己唯一标识即可。

https://counter.imalun.com/唯一标识?theme=对应主题

asoul &ZeroWidthSpace;

asoul

moebooru &ZeroWidthSpace;

moebooru

rule34 &ZeroWidthSpace;

Rule34

gelbooru &ZeroWidthSpace;

Gelbooru

部署 &ZeroWidthSpace;

如果有想自建的可以按照如下步骤搭建:

  1. 申请 MongoDB 账号

  2. 创建免费 MongoDB 数据库,区域推荐选择 AWS / N. Virginia (us-east-1)

  3. 在 Database Access 页面点击 Add New Database User 创建数据库用户,Authentication Method 选 Password,在 Password Authentication 下设置数据库用户名和密码,用户名和密码可包含数字和大小写字母,请勿包含特殊符号。点击 Database User Privileges 下方的 Add Built In Role,Select Role 选择 Atlas Admin,最后点击 Add User

  4. 在 Network Access 页面点击 Add IP Address,Access List Entry 输入 0.0.0.0/0(允许所有 IP 地址的连接),点击 Confirm

  5. 在 Database 页面点击 Connect,连接方式选择 Drivers,并记录数据库连接字符串,请将连接字符串中的 <username>:<password> 修改为刚刚创建的数据库 用户名:密码

  6. 申请 Vercel 账号

  7. 点击以下按钮将 Moe-Counter-Vercel 一键部署到 Vercel

Deploy

  1. 进入 Settings - Environment Variables,添加环境变量 MONGODB_PATH,值为前面记录的数据库连接字符串

  2. 进入 Deployments , 然后在任意一项后面点击更多(三个点) , 然后点击 Redeploy , 最后点击下面的 Redeploy

  3. 进入 Overview,点击 Domains 下方的链接,如果环境配置正确,可以看到 默认计数器样式

Hexo 主题开发之自定义模板

作者 白云苍狗
2023年12月13日 15:29

关于 Hexo 如何开发主题包的教程已经是大把的存在了,这里就不再赘述了。这篇文章主要讲的是作为一个主题的开发者,如何让你的主题具有更好的扩展性,在用户自定义修改主题后,能够更加平易升级主题。

问题所在 &ZeroWidthSpace;

Hexo 提供两种方式安装主题包:

  • 直接在 themes 目录下直接存放主题包文件,这种方式用户可以很方便的魔改主题,但是魔改后升级主题会变得比较困难
  • 通过 npm 安装主题包,这种方式更加方便用户升级主题,但是不易扩展现有的主题。

当用户想要自定义修改主题时,基本上只能通过第一种方式安装,且只能通过修改 源代码 形式去修改主题。这样带来的问题就是,当主题修复一些 bug 或者主题迭代 N 个版本后,用户想升级主题时就会变的比较麻烦。

有没有能让用户方便升级,又能提供一定个性化的能力的东西呢?答案是有的,那就是通过 npm 方式分发主题包,我们通过一些魔法,让其有一定的扩展能力,这篇文章就来讲解如何实现它。

模板 &ZeroWidthSpace;

在 Hexo 中,主题的模板决定的网站页面程序的方式,当你不同页面结构很相似时候,可以通过布局(Layout)去复用相同的结构,而相似的部分可以抽离成通用局部模板,通过使用 Partial 去加载,以达到模板复用的效果。

这就是 Hexo 在开发主题处理模板复用的方式,可把一个个局部模板理解为一个个独立的组件,哪里需要是就在哪里加载它。如果说用户想替换某一个局部模板,我们可以让用户提供一个新的模板,然后去加载用户提供的模板,那是不是达到在用户不修改源代码情况下对主题进行个性话的扩展呢。

Partial &ZeroWidthSpace;

要想知道 Hexo 是如果加载局部模板的,翻看下 Hexo 源码里 Partial 的实现(/plugins/helper/partial.js),可以看到是通过调用 ctx.theme 获取到对应的 view,接着调用 render 渲染的。

js
const { dirname, join } = require("path");

module.exports = (ctx) =>
    function partial(name, locals, options = {}) {
        const viewDir = this.view_dir;
        const currentView = this.filename.substring(viewDir.length);
        const path = join(dirname(currentView), name); // 根据当前路径找到,局部模板路径
        const view = ctx.theme.getView(path) || ctx.theme.getView(name); // 根据路径去匹配 view
        const viewLocals = { layout: false };
        // Partial don't need layout
        viewLocals.layout = false;
        return view.renderSync(viewLocals);
    };

Hexo 对文件处理分为两种,一种是 source 目录文件处理,一种是对主题包里文件处理。在辅助函数注册里可以看 ctx 其实就是 hexo 运行时的实例,上面的 ctx.theme 就是主题文件处理的 Box。通过 Hexo 提供 api 可以看到,它不仅提供了 getView,还提供了 setViewremoveView 方法。

然后翻看 setView 代码,可以看到当你重新设置一个新的 view 时,它会覆盖掉已有的 view。也就是说我们可以直接覆盖主题里的 局部模板

js

  setView(path, data) {
    const ext = extname(path);
    const name = path.substring(0, path.length - ext.length);
    this.views[name] = this.views[name] || {};
    const views = this.views[name];

    views[ext] = new this.View(path, data);
  }

修改示例 &ZeroWidthSpace;

以覆盖 hexo-theme-async 为示例,在生成前钩子 generateBefore 里,覆盖掉主题里默认的侧栏模板。

js
hexo.on("generateBefore", () => {
    hexo.theme.setView("_partial/sidebar/index.ejs", "<div>111</div>");
});

运行起来会发现侧栏模板已经替换成我们写的 111 了。

示例

主题实现 &ZeroWidthSpace;

通过上面方式确实可以达到覆盖主题默认模板能力,但是让用户直接修改会很不友好,需要自己去看主题中局部模板的路径信息,并且还需要自己编写加载文件内容,覆盖主题默认模板逻辑。

可以将这部分操作内置进入主题内处理,只需要让用户编写自己的模板,以及告诉我们需要替换对应模板即可。大致流程如下:

demo

还可以提供默认配置,简化通过路径覆盖

demo

通过在配置中配置好主题中使用的局部模板,类似这样,将主题中使用的局部模板以配置形式展示。

yaml
layout:
    path: layout
    # layout
    main: _partial/main
    header: _partial/header
    banner: _partial/banner
    sidebar: _partial/sidebar/index
    footer: _partial/footer

接着在加载局部模板时,直接读取配置的信息,当用户覆盖掉了 layout.header 时候,主题就会自动使用新的模板了。

html
<%- partial(theme.layout.header) %>

模板加载实现 &ZeroWidthSpace;

根据上面配置,约定 layout.path 配置指向目录为用存在模板目录,以便可以自定义存放路径。

yaml
layout:
    path: layout

首先就是根据配置获取模板存在的绝对路径,可以根据 hexo 实例,获取到根目录,拼接出完整路径位置。

js
const { resolve } = require("path");
const layoutDir = resolve(hexo.base_dir, hexo.theme.config.layout.path);

然后是对文件目录的监听,这个可以直接使用 hexo-fs ,避免安装额外的依赖包,提供了新增、删除、修改、文件夹变动的监听,可以针对不同事件做出不同操作。

js
const { watch } = require("hexo-fs");

watch(layoutDir, {
    persistent: true,
    awaitWriteFinish: {
        stabilityThreshold: 200,
    },
}).then((watcher) => {
    watcher.on("add", (path) => /** 设置模板 */);
    watcher.on("change", (path) => /** 设置模板 */);
    watcher.on("unlink", (path) => /** 移除模板 */);
    watcher.on("addDir", (path) => /** 添加文件夹,递归遍历设置模板 */);
});

因为上面是通过配置去加载模板的,所有为了避免用户自定义的模板名称会与主题的模板名称冲突,导致覆盖了主题的模板,可以在使用时增加一个约定的前缀,避免重名,需要对设置模板进行简单封装。

js
const setView = (fullpath) => {
    const path = "async" + fullpath.replace(layoutDir, ""); // 约定固定前缀为 async
    hexo.theme.setView(path, readFileSync(fullpath, { encoding: "utf8" }));
};

上面处理方式,用户自定义模板,可以正常加载使用的,但是当自定义的模板又引入了其他模板时会存在一个问题,在有的模板引擎中会出现路径不正常。通过查看 view 实例信息,可以看到其指向目录是在 node_modules,而实际上是存在根目录的。

view

翻看 view 源码可以看到 source 是获取的 this._theme.base ,而 this._theme.base 往上找就 theme_dir,也就是主题存放的目录,最后又通过 renderer.compile 设置模板渲染到,导致传入 path 不正确。

view-code

知道了原因我对上面代码进行修正,设置后重新获取到 view,然后手动根据路径信息。

js
const setView = (fullpath) => {
    const path = "async" + fullpath.replace(layoutDir, ""); // 约定固定前缀为 async
    hexo.theme.setView(path, readFileSync(fullpath, { encoding: "utf8" }));

    const view = hexo.theme.getView(path);
    view.source = fullpath; // 修正原文件路径
    view._precompile(); // 重新调用渲染器的初始化
};

将上面操作,放置在在 Hexo 的 generateBefore 中:

js
const { resolve } = require("path");
const { watch, readdirSync, statSync } = require("hexo-fs");

hexo.on("generateBefore", () => {
    const layoutDir = resolve(hexo.base_dir, hexo.theme.config.layout.path);

    const setView = (fullpath) => {
        const path = "async" + fullpath.replace(layoutDir, ""); // 约定固定前缀为 async
        hexo.theme.setView(path, readFileSync(fullpath, { encoding: "utf8" }));
        const view = hexo.theme.getView(path);
        view.source = fullpath; // 修正原文件路径
        view._precompile(); // 重新调用渲染器的初始化
    };

    watch(layoutDir, {
        persistent: true,
        awaitWriteFinish: {
            stabilityThreshold: 200,
        },
    }).then((watcher) => {
        watcher.on("add", (path) => setView(path));
        watcher.on("change", (path) => setView(path));
        watcher.on("unlink", (path) => {
            const path = "async" + path.replace(layoutDir, "");
            hexo.theme.removeView(path);
        });
        watcher.on("addDir", (path) => loadDir(path));
    });

    const loadDir = (base) => {
        let dirs = readdirSync(base);
        dirs.forEach((path) => {
            const fullpath = resolve(base, path);
            const stats = statSync(fullpath);
            if (stats.isDirectory()) {
                loadDir(fullpath);
            } else if (stats.isFile()) {
                setView(fullpath);
            }
        });
    };

    loadDir(layoutDir);
});

到此主要功能以及实现了,其他待优化项这里就不描述了,可以看看完整实现源码。

使用示例 &ZeroWidthSpace;

以为 hexo-theme-async 为例,在根目录新建 layout 目录,再目录下添加 sidebar.ejs 文件,结构如下:

txt
┌── blog
│   └── layout
│          └── sidebar.ejs
│   └── scaffolds
│   └── source
│   └── themes

sidebar.ejs 添加一点内容

html
<div>111</div>

在 _config.async.yml 中修改 layout 配置,替换掉默认 sidebar 模板。

yml
layout:
    sidebar: async/sidebar

运行起来后,可以看到效果和 修改示例 中的效果一样,但是简化了用户使用。

结语 &ZeroWidthSpace;

通过上面方式,可以在使用 npm 安装主题时,也支持自定义替换部分区域,来个性化的目的,当主题版本迭代升级后,也更方便用户更新升级。

完整实现源码可以参考 hexo-theme-async 中源码。

手撸 Grid 拖拽布局

作者 白云苍狗
2023年11月30日 20:07

最近有个需求需要实现自定义首页布局,需要将屏幕按照 6 列 4 行进行等分成多个格子,然后将组件拖拽对应格子进行渲染展示。

示例

对比一些已有的插件,发现想要实现产品的交互效果,没有现成可用的。本身功能并不是太过复杂,于是决定自己基于 vue 手撸一个简易的 Grid 拖拽布局。

完整源码在此,在线体验

概况 &ZeroWidthSpace;

需要实现 Grid 拖拽布局,主要了解这两个东西就行

  • 拖放 API,关于拖放 API 介绍文章有很多 ,可以直接看 MDN 里拖放 API介绍,可以说很详细了。
  • Grid 布局, Grid 布局与 Flex 布局很相似,但是 Grid 像是二维布局,Flex 则为一维布局,Grid 布局远比 Flex 布局强大。MDN 关于网格布局介绍

需要实现主要包含:

  • 组件物料栏拖拽到布局容器
  • 布局容器 Grid 布局
  • 放置时是否重叠判断
  • 拖拽时样式
  • 放置后样式
  • 容器内二次拖拽

拖放操作实现 &ZeroWidthSpace;

拖拽中主要使用到的事件如下

  • 被拖拽元素事件:
事件 触发时刻
dragstart 当用户开始拖拽一个元素或选中的文本时触发。
drag 当拖拽元素或选中的文本时触发。
dragend 当拖拽操作结束时触发
  • 放置容器事件:
事件 触发时刻
dragenter 当拖拽元素或选中的文本到一个可释放目标时触发。
dragleave 当拖拽元素或选中的文本离开一个可释放目标时触发。
dragover 当元素或选中的文本被拖到一个可释放目标上时触发。
drop 当元素或选中的文本在可释放目标上被释放时触发。

可拖拽元素 &ZeroWidthSpace;

让一个元素能够拖拽只需要给元素设置 draggable="true" 即可拖拽,拖拽事件 API 提供了 DataTransfer 对象,可以用于设置拖拽数据信息,但是仅仅只能 drop 事件中获取到。因为我们需要在拖拽中就需要获取到拖拽信息,用来显示拖拽时样式,所以需要自己处理这些信息存储起来,以便读取。

需要处理主要是,在拖拽时将 将当前元素信息设置到 dragStore 中,结束时清空当前信息。

html
<script setup lang="ts">
  import { dragStore } from "./drag";

  const props = defineProps<{
    data: DragItem;
    groupName?: string;
  }>();

  const onDragstart = (e) => dragStore.set(props.groupName, { ...props.data });
  const onDragend = () => dragStore.remove(props.groupName);
</script>
<template>
  <div class="drag-item__el" draggable="true" @dragstart="onDragstart" @dragend="onDragend"></div>
</template>

封装一个存储方法,通过配置相同 key ,可以在同时存在多个放置区域时候,区分开来。

ts
class DragStore<T extends DragItemData> {
  moveItem = new Map<string, DragItemData>();

  set(key: string, data: T) {
    this.moveItem.set(key, data);
  }

  remove(key: string) {
    this.moveItem.delete(key);
  }

  get(key: string): undefined | DragItemData {
    return this.moveItem.get(key);
  }
}

可放置区域 &ZeroWidthSpace;

首先是需要告诉浏览器当前区域是可以放置的,只需要在元素监听 dragenterdragleavedragover 事件即可,然后通过 preventDefault 来阻止浏览器默认行为。可以在这三个事件中处理判断当前位置是否可以放置等等。

示例:

html
<script setup lang="ts">
  // 进入放置目标
  const onDragenter = (e) => {
    e.preventDefault();
  };

  // 在目标中移动
  const onDragover = (e) => {
    e.preventDefault();
  };

  // 离开目标
  const onDragleave = (e) => {
    e.preventDefault();
  };
</script>
<template>
  <div @dragenter="onDragenter($event)" @dragover="onDragover($event)" @dragleave="onDragleave($event)" @drop="onDrop($event)"></div>
</template>

上面的代码已经可以让,元素可以拖拽,当元素拖到可防止区域时候,可以看到鼠标样式会变为可放置样式了。

Grid 布局 &ZeroWidthSpace;

我们是需要进行 Grid 拖拽布局,所以先对上面放置容器进行改造,首先就是需要将容器进行格子划分区域显示。

计算 Grid 格子大小 &ZeroWidthSpace;

我这里直接使用了 @vueuse/coreuseElementSize 的 hooks 去获取容器元素大小变动,也可以自己通过 ResizeObserver 去监听元素变动。接着根据设置列数、行数、间隔去计算单个格子大小。

ts
import { useElementSize } from "@vueuse/core";

/**
 * 容器等分尺寸
 * @param {*} target 容器 HTML
 * @param {*} column 列数
 * @param {*} row 行数
 * @param {*} gap 间隔
 * @returns
 */
export const useBoxSize = (target: Ref<HTMLElement | undefined>, column: number, row: number, gap: number) => {
  const { width, height } = useElementSize(target);
  return computed(() => ({
    width: (width.value - (column - 1) * gap) / column,
    height: (height.value - (row - 1) * gap) / row,
  }));
};

设置 Grid 样式 &ZeroWidthSpace;

根据列数和行数循环生成格子数,rowCountcolumnCount为行数和列数。

html
<div class="drop-content__drop-container" @dragenter="onDragenter($event)" @dragover="onDragover($event)" @dragleave="onDragleave($event)" @drop="onDrop($event)">
  <template v-for="x in rowCount">
    <div class="bg-column" v-for="y in columnCount" :key="`${x}-${y}`"></div>
  </template>
</div>

设置 Grid 样式,下面变量中 gap 为格子间隔,repeat 是 Grid 用来重复设置相同值的,grid-template-columns: repeat(2,100px) 等效于 grid-template-columns: 100px 100px。因为我们只需在容器里监听拖拽放置事件,所以我们还需要将 所有的 bg-column 事件去掉,设置 pointer-events: none 即可。

scss
.drop-content__drop-container {
  display: grid;
  row-gap: v-bind("gap+'px'");
  column-gap: v-bind("gap+'px'");
  grid-template-columns: repeat(v-bind("columnCount"), v-bind("boxSize.width+'px'"));
  grid-template-rows: repeat(v-bind("rowCount"), v-bind("boxSize.height+'px'"));
  .bg-column {
    background-color: #fff;
    border-radius: 6px;
    pointer-events: none;
  }
}

效果如下: Grid 容器样式

放置元素 &ZeroWidthSpace;

放置元素时我们需要先计算出元素在 Grid 位置信息等,这样才知道元素应该放置那哪个地方。

拖拽位置计算 &ZeroWidthSpace;

当元素拖拽进容器中时,我们可以通过 offsetXoffsetY 两个数据获取当前鼠标距离容器左上角位置距离,我们可以根据这两个值计算出对应的在 Grid 中做坐标。

计算方式:

ts
// 计算 x 坐标
const getX = (num) => parseInt(num / (boxSizeWidth + gap));
// 计算 y 坐标
const getY = (num) => parseInt(num / (boxSizeHeight + gap));

需要注意的是上面计算坐标是 0,0 开始的,而 Grid 是 1,1 开始的。

获取拖拽信息 &ZeroWidthSpace;

我们在进入容器时,通过上面封装 dragData 来获取当前拖拽元素信息,获取它尺寸信息等等。

ts
// 拖拽中的元素
const current = reactive({
  show: <boolean>false,
  id: <undefined | number>undefined,
  column: <number>0, // 宽
  row: <number>0, // 高
  x: <number>0, // 列
  y: <number>0, // 行
});

// 进入放置目标
const onDragenter = (e) => {
  e.preventDefault();
  const dragData = dragStore.get(props.groupName);
  if (dragData) {
    current.column = dragData.column;
    current.row = dragData.row;
    current.x = getX(e.offsetX);
    current.y = getY(e.offsetY);
    current.show = true;
  }
};

// 在目标中移动
const onDragover = (e) => {
  e.preventDefault();
  const dragData = dragStore.get(props.groupName);
  if (dragData) {
    current.x = getX(e.offsetX);
    current.y = getY(e.offsetY);
  }
};

const onDragleave = (e) => {
  e.preventDefault();
  current.show = false;
  current.id = undefined;
};

在 drop 事件中,我们将当前拖拽元素存放起来,list 会存放每一次拖拽进来元素信息。

ts
const list = ref([]);

// 放置在目标上
const onDrop = async (e) => {
  e.preventDefault();
  current.show = false;
  const item = dragStore.get(props.groupName);

  list.value.push({
    ...item,
    x: current.x,
    y: current.y,
    id: new Date().getTime(),
  });
};

计算碰撞 &ZeroWidthSpace;

在上面还需要计算当前拖拽的位置是否可以放置,需要处理是否包含在容器内,是否与其他已放置元素存在重叠等等。

计算是否在容器内 &ZeroWidthSpace;

这个是比较好计算的,只需要当前拖拽位置左上角坐标 >= 容器左上角的坐标,然后右下角的坐标 <= 容器的右下角的坐标,就是在容器内的。

代码实现:

ts
/**
 * 判断是否在当前四边形内
 * @param {*} p1 父容器
 * @param {*} p2
 *  对应是 左上角坐标 和 右下角坐标
 *  [0,0,1,1]  => 左上角坐标 0,0  右下角 1,1
 */
export const booleanWithin = (p1: [number, number, number, number], p2: [number, number, number, number]) => {
  return p1[0] <= p2[0] && p1[1] <= p2[1] && p1[2] >= p2[2] && p1[3] >= p2[3];
};

计算是否与现有的相交 &ZeroWidthSpace;

两个矩形相交情况有很多种,计算比较麻烦,但是我们可以计算他们不相交,然后在取反方式判断是否相交。

不相交情况只有四种,假设有 p1、p2 连个矩形,它们不相交的情况只有四种:

  • p1 在 p2 左边
  • p1 在 p2 右边
  • p1 在 p2 上边
  • p1 在 p2 下边

代码实现:

ts
/**
 * 判断是两四边形是否相交
 * @param {*} p1 父容器
 * @param {*} p2
 *  对应是 左上角坐标 和 右下角坐标
 *  [0,0,1,1]  => 左上角坐标 0,0  右下角 1,1
 */
export const booleanIntersects = (p1: [number, number, number, number], p2: [number, number, number, number]) => {
  return !(p1[2] <= p2[0] || p2[2] <= p1[0] || p1[3] <= p2[1] || p2[3] <= p1[1]);
};

在放置前判断 &ZeroWidthSpace;

可以通过计算属性去计算,在后面拖拽中处理样式也可以用到。修改 drop 中方法,然后在 drop 中根据 isPutDown 是否有效。

ts
// 是否可以放置
const isPutDown = computed(() => {
  const currentXy = [current.x, current.y, current.x + current.column, current.y + current.row];
  return (
    booleanWithin([0, 0, columnCount.value, rowCount.value], currentXy) && //
    list.value.every((item) => item.id === current.id || !booleanIntersects([item.x, item.y, item.x + item.column, item.y + item.row], currentXy))
  );
});

拖拽时样式 &ZeroWidthSpace;

上处理了基本拖放数据处理逻辑,为了更好的交互,我们可以在拖拽中显示元素预占位信息,更加直观的显示元素占位大小,类似这样:

可放置示例

我们可以根据上面 current 中信息去计算大小信息,还可以根据 isPutDown 去判断当前位置是否可以放置,用来显示不同交互效果。

不可放置示例

可以直接通过 Grid 的 grid-area 属性,快速计算出放置位置信息,应为我们上面计算的 x 、y 是从 0 开始的,所以这里需要 +1。

css
grid-area: `${y + 1} / ${x + 1} / ${y + row + 1}/ ${ x + column + 1 }`

预览容器 &ZeroWidthSpace;

在元素放置后,我们还需要根据 list 中数据,生成元素占位样式处理,我们可以拖拽容器上层在放置一个容器,专门用来显示放置后的样式,也是可以直接使用 Grid 布局去处理。

预览样式 &ZeroWidthSpace;

样式基本上和 drop-container 样式抱持一致即可,需要注意的时需要为预览容器设置 pointer-events: none,避免遮挡了 drop-container 事件监听。

scss
.drop-content__preview,
.drop-content__drop-container {
  // ...
}

每个元素位置信息计算方式,基本和拖拽时样式计算方式一致,直接通过 grid-area 去布局就可以了。

css
grid-area: `${y + 1} / ${x + 1} / ${y + row + 1}/ ${ x + column + 1 }`

示例

二次拖拽 &ZeroWidthSpace;

当元素拖拽进来后,我们还需要对放置的元素支持继续拖拽。因为上面我们将预览事件通过 pointer-events 去除了,所以我们需要给每个子元素都加上去。然后给子元素添加 draggable=true,然后处理拖拽事件,基本上和上面处理方式一样,在 dragstartdragend 处理拖拽元素信息。

然后我们还需在 onDrop 进行一番修改,如果是二次拖拽时只需要修改坐标信息,修改原 onDrop 处理方式:

ts
if (item.id) {
  item.x = current.x;
  item.y = current.y;
} else {
  list.value.push({
    ...item,
    x: current.x,
    y: current.y,
    id: new Date().getTime(),
  });
}

位置偏移优化 &ZeroWidthSpace;

当你对元素二次拖拽时,会发现元素会存在偏移问。比如你放置了一个 1x2 元素后,当你从下面拖拽,你会发现拖拽中的占位样式和你拖拽元素位置存在偏差。

效果如下图

示例

出现这情况应为上面我们时根据鼠标位置为左上角进行计算的,所以会存在这种偏差问题,我们可在拖拽前计算出偏移量来校正位置。

我们可以在二次拖拽时,获取到鼠标在当前元素内位置信息

ts
const onDragstart = (e) => {
  const data = props.data;
  data.offsetX = e.offsetX;
  data.offsetY = e.offsetY;
  dragStore.set(props.groupName, data);
};

drop-container 内计算 x、y 值时候减去偏移量,对 onDragenteronDragover 进行如下调整修改

ts
current.x = getX(e.offsetX) - getX(dragData?.offsetX ?? 0);
current.y = getY(e.offsetY) - getY(dragData?.offsetY ?? 0);

拖拽元素优化 &ZeroWidthSpace;

因为上面我们将预览元素添加了 pointer-events: all,所以在我们拖拽到现有元素上时,会挡住 drop-container 事件的触发,在二次拖拽时,比如将一个 2x2 元素我们需要往下移动一格时,会发现也会被自己挡住。

  • 预览元素遮挡问题,可以在拖拽时将其他元素都设置为 none,二次拖拽时要做自己设置为 all 否则会无法拖拽
html
:style="{ pointerEvents: current.show && item.id !== current.id ? 'none' : 'all' }"`
  • 二次拖拽时自己位置遮挡问题 我们可以在拖拽时增加标识,将自己通过 transform 移除到多拽容器外去
ts
moveing.value
  ? {
      opacity: 0,
      transform: `translate(-999999999px, -9999999999px)`,
    }
  : {};

拖拽调整大小 &ZeroWidthSpace;

调整大小和调整位置计算类似,只不过一个是计算坐标一个计算行列。

首先是能让元素可以进行拖拽,很多拖拽调整大小的都是在元素里添加几个拖拽节点元素,然后在监听拖拽节点鼠标事件去计算大小位置等。但是我这里需求比较简单,就不需要做的那么复杂,直接通过 css 让元素可以支持调整大小。

css
.preview-item {
  overflow: auto;
  resize: both;
}

添加样式后,可以看到元素可调整样式

resize

监听元素大小调整方式有两种

  • ResizeObserver API 监听,但是这个 API 还会监听到其他因数引起变动,比如窗口大小变动,导致元素变动等等。
  • 使用 mousedownmousemovemouseup 组合使用,监听鼠标事件,但是这个会存在与拖放事件同时触发问题。

两种方式都可以实现,但是都有需要解决问题,我这里选择了第二种方式实现。

大概实现就是在 PreviewItem 中监听 mousedown 事件,在 mousemove 中获取元素宽度大小实时计算宽度大小就可以。需要注意的是在 mouseup 中要重置 size 信息避免改变原有元素大小。

ts
const onMousedown = (e) => {
  dragStore.set(props.groupName, props.data);
  emits("resize-start");
  resizeing.value = true;

  e.target.onmousemove = function (event) {
    emits("resizeing", {
      width: event.target.offsetWidth,
      height: event.target.offsetHeight,
    });
  };

  e.target.onmouseup = function (event) {
    unset(event.target);
    emits("resize-end");
    event.target.style.width = "100%";
    event.target.style.height = "100%";
    dragStore.remove(props.groupName);
  };
};

const unset = (target) => {
  resizeing.value = false;
  target.onmousemove = null;
  target.onmouseup = null;
};

在 DropContent 通过上面抛出信息,计算大小改变,然后设置拖拽时样式动态查看当前占位大小。

ts
// 调整大小开始
const onResizeStart = () => {
  const dragData = dragStore.get(props.groupName);
  if (dragData) {
    current.column = dragData.column;
    current.row = dragData.row;
    current.x = dragData.x;
    current.y = dragData.y;
    current.id = dragData.id;
    current.show = true;
  }
};

// 调正大小时
const onResizeing = (e) => {
  const dragData = dragStore.get(props.groupName);
  current.column = getColumn(e.width);
  current.row = getRow(e.height);
};

// 调整大小结束
const onResizeEnd = async () => {
  current.show = false;
  const dragData = dragStore.get(props.groupName);
  if (
    isPutDown.value &&
    (await props.beforeDrop(
      {
        ...dragData,
        column: current.column,
        row: current.row,
      },
      list.value
    ))
  ) {
    dragData.column = current.column;
    dragData.row = current.row;
  }
};

实现效果:

resize_demo

结语 &ZeroWidthSpace;

到目前为止基本上的 Grid 拖拽布局大致实现了,已经满足基本业务需求了,当然有需要朋友还可以在上面增加碰撞后自动调整位置等等。

完整源码在此,在线体验

前端基建之工具篇

作者 白云苍狗
2023年9月19日 20:35

随着前端发展的越来越工程化,越来越繁琐复杂,前端能做的事情越来越多,最近几年 前端基建 也是越来越火热。但是实际上很多公司并不注重前端,更别谈能会有人来做基建。对于没有基建的前端,我们能做些什么呢?

我个人推荐可以从工具、CLI 入手,因为这些往往是独立,不会像推规范、搞数据埋点、日志上报、整 BFF 那样对团队或者现有代码有入侵性。即便在自己空闲时间也能弄一弄,弄得好可以在团队推广使用,弄得不好也无所谓,可以当作练手提升自己能力。

什么是前端基建? &ZeroWidthSpace;

前端基建 指的是业务团队内的前端工程师执行的一些基础建设,包括了 前端规范文档、前端脚手架、前端模板、前端组件库、前端工具库、前端 BFF、前端 CI/CD 的构建部署、前端数据埋点 等等;

前端基建的好处

  • 业务复用;
  • 提升研发效率;
  • 规范研发流程;
  • 团队技术提升;
  • 开源建设;

善用工具解放双手 &ZeroWidthSpace;

举例:最近接到一个需求,一张简单报表包含增删改查,相信每个公司都有自己封装好的增删改查组件或者 hooks,等接口定义好,直接写 api,配好表单,表格配置完事。但后来接口发生变动,或者查询变动,这个时候就需要手动去调整原来代码。又或者做完这个后又来了个十几个报表需求,又得需要将上面来个十几遍。有些公司甚至连封装都没有,项目里全是 CTRL V + C,重复工作量更是陡增。

这些重复性操作完全可以通过工具自动生成,提交代码准确性,也能解放自己双手(摸点 🐟 不香吗),提高团队效率。

工具能做事情,做的东西有很多,不仅仅只是代码生成这些,一切你觉得重复性工作,都可以尝试通过工具来提高自己效率。

  • 代码生成
  • 代码格式检测
  • 智能提示
  • 测试自动化
  • 文档集成
  • 代码调试
  • ...

工具实现方式有很多种,这取决在团队需求

  • cli
  • web (比如通过拖拉拽生成代码)
  • 编辑器插件 (比如 vscode 插件)
  • ...

重复代码生成 &ZeroWidthSpace;

就以上面例子为例,就可以使用工具去生成 接口的定义、表格配置、表单配置以及组件使用代码,不同需求表单、表格那些都是高度相似的,可以通过代码生成方式来提升自己编码效率。

从接口文档到可用代码,生成的方式大概流程如下:

接口文档 =》 JSON Schema =》 UI 交互(可省略)=》 根据模板生成 || 根据 AST 生成 =》 插入到代码位置

生成 JSON Schema &ZeroWidthSpace;

为了方便后续代码生成,我们需要首先接口文档转换成一个标准通用格式,这样不管后端提供什么样格式 api 文档或者不管后端使用什么框架去生成 api 文档时,只需要将对应格式转换成我们 JSON Schema 格式即可。

UI 交互 &ZeroWidthSpace;

UI 交互并不是必须的,比如生成 接口定义、类型定义 并不需要交互,直接生成就可以了。但是像表格生成、表单生成这些是需要的,因为接口返回字段并不一定全是需要生成的,可以通过添加适当交互完成这一步骤。

代码片段生成 &ZeroWidthSpace;

代码生成方式分两种 模板AST 去生成。使用 模板 比较简单,也会比较直观,但是缺乏灵活性,使用 AST 会更为灵活,但也会复杂的多。

模板 &ZeroWidthSpace;

对于一些固定代码片段格式,可以选择通过模板去生成,而不必使用 AST 增加代码生成复杂度。

比如你项目中请求方法格式如下,可以直接使用模板去生成,类似这样 const {0} = (par) => http("{1}", par, "{2}");

js
const apiFn = (par) => http("/api/demo", par, "post");
AST &ZeroWidthSpace;

AST 是抽象语法树 (Abstract Syntax Tree) 的简称,它是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。

当你生成代码需要根据不同条件生成不同节点,修改不固定时候,可以选择使用 AST 去生成。

AST 生成原理,简单来说说就是将代码生成一个树状结构,其中树中每个节点描述的应就是你实际代码,我们只需要根据需求去操作对应节点,然后再将 AST 转为代码。

不同工具生成 AST 是不一致的,可根据实际需求选择。在线查看 AST 结构工具,可以说很直观方便 ast explorer ts ast

插入代码位置 &ZeroWidthSpace;

将生成代码插入到现有代码中,实现方式也有多种不同方式,通过对于简单的结构可以通过正则匹配去替换,对于复杂的结构可以通过 AST 方式去修改插入,如果你是基于编辑器插件去做的,还可以通过获取编辑器光标位置、选中等等操作将代码插入到对于位置。

更方便的文档查阅方式 &ZeroWidthSpace;

很多公司使用的都是像 yapi、swagger 等那些开源文档管理平台,很多都是 web 在线的,当接口文档字段很多时,需要平凡的来回切换查阅,这样也是比较浪费时间的。可以通过编辑器插件形式集成到我们插件中,这样可以更方便自己开发,也有很多时以及有现成插件可以使用的,但是功能上并不丰富,有些仅仅只能查看,连常见代码都无法生成。

将接口文档集成到编辑器里能做的事情有很多,以 vscode 来说,可以通过文档快速生成接口定义,还可以通过注册语言服务,来识别检测接口定义正确性,也可以做的接口文档发生变更,做到智能提示然后快速自动修改,也可以通过接口文档快速起个 mock 服务,而不需要手动在项目中添加 mock 定义等等。

其他 &ZeroWidthSpace;

很多繁琐的事情,其实可以尝试通过工具简化这些操作,没有合适的可以尝试自己造个,既可以提升自己能力,又能给团队做一点贡献。

uni-app 使用体验

作者 白云苍狗
2023年7月21日 21:46

一句话总结就是,坑是真的多,填完一个坑等着你的就是下个坑。

缘起 &ZeroWidthSpace;

因为所在项目组属于生活服务类相关的,需要做电影、蛋糕、打车、加油、车票、酒店等一系列的板块,需要嵌入在其他项目的 app 、小程序、h5 中。我们需要提供 H5 和 小程序双端服务,由于每个项目周期又短,为了节省开发周期时间,所以选了 uni-app 来开发。

初识 &ZeroWidthSpace;

uni-app 使用上,基本上和普通 vue 开发差不多,只要避免直接使用 web api 就可以,捋一遍文档基本可以上手了。踩得的第一个坑来了,由于我入职时第一个板块已经开始做了,项目使用 HBuilderX 创建的。HBuilderX 创建的项目只能使用 HBuilderX 启动,HBuilderX 这东西也不好用,只能再开个 vscode 开发了,开发阶段也没觉得啥,无非多开发编辑器多占点内存。

等测试阶段傻眼了,HBuilderX 创建项目只能再 HBuilderX 构建发布,没法集成到现有 CI/CD 上去,再 HBuilderX 打包就打包吧,这玩意还强制登陆,一个编辑器不登陆还不让打包。搜索了一下,还是有方法将 HBuilderX 创建项目转换成 cli 形式的,不过由于项目模块导入方式混用,无法无痛切换过去,项目也快上线,于是就没有去折腾了,在下一个项目去切换到 cli 方式。

生态 &ZeroWidthSpace;

uni-app 有自己插件市场,有很多现成的插件可以用。但是呢当你去下载插件是骚操作来了,首先是强制需要你登陆,然后登陆后还需要你强制看广告才能下载,这波骚操作真的是骚。有些作者希望整点插件赚点外快,可以收费下载可以理解,但是别人免费开源的插件,uni-app 还强制你扫码看广告是真的恶心了。

uni-app 一直都在加新功能,对于 bug 处理速度真的是慢,官方论坛一大堆没人回复的问题,一个劲的堆新功能,bug 没见修复多少,bug 全留着当传家宝了。HBuilderX 也是有点离谱,第一次在开发者工具里边看到招聘广告的,只能说 newb 了。

踩坑 &ZeroWidthSpace;

定位 &ZeroWidthSpace;

在 H5 中 uni.getLocation,内部抛出异常,没做处理,导致无法捕获到,也不会进入 fail 回调,导致无法得知定位失败。比如 地图 key ip 定位超上限, navigator.geolocation 定位失败等。 &ZeroWidthSpace;

解决方式:

h5 端自己对 navigator.geolocation 封装,其他端使用 uni.getLocation。或者对 uni.getLocation 进行封装增加超时设置,超过几秒未返回,返回默认定位点。自己封装 navigator.geolocation 需要做好坐标转换,应该浏览器默认返回是 wgs84 ,需要自己转换为 gcj02 或其他的坐标系。

定时失败时候,只要你配置了地图 key,就会使用对应地图服务商 ip 定位。因为正常情况下前端的地图的 key 只需有地图绘制相关功能使用,不需要包含地图服务商 WebService API 功能的, 为了避免盗刷也不会将 WebService API 的 key 存放在前端,这就导致你使用 uni-app 的 map 组件,就会使用 ip 定位,但你的 key 并不支持。 &ZeroWidthSpace;

解决方式:

和上面处理方式一样,最直接就是自己封装 h5 定位,将 ip 定位地址,替换成自己服务器地址作为代理。

uni.openLocation 在 vue3 + ts 中,H5 端是以组件形式存在的,不想其他 vue2 是以路由形式存在,看内部实现并没有监听路由变化卸载组件,会导致通过浏览时返回上一页时,组件还是覆盖在页面上。 &ZeroWidthSpace;

解决方式:

自定义查看位置组件,在 H5 端替换掉 uni-app 的。

map 渲染问题 &ZeroWidthSpace;

uni-app 的 map 封装了一些覆盖物绘制属性。比如绘制多边形区域绘制,如果在地图绘制前就已经给 map 的 polygons 属性设置了数据,第一次打开页面不会生效。避免这种情况出现,需要在地图渲染后在设置这些数据,但是在 updated 事件触发后立即设置也会出现问题,这种情况在第一打开页面时生效,第二次进入时候会出现不生效情况,未找到出现这种问题原因。 &ZeroWidthSpace;

解决方式: updated 事件后,再加入定时器延迟执行绘制操作。

嵌套渲染 &ZeroWidthSpace;

有多个 v-for 嵌套渲染时候, 内层的 v-for 上的点击事件在小程序中无法触发。 &ZeroWidthSpace;

解决方式: 将部分 v-for 拆分成组件形式,避免页面上 v-for 嵌套过多。

其他 &ZeroWidthSpace;

组件差异性 &ZeroWidthSpace;

有些组件会在不同平台出现不同差异效果,需要你慢慢去踩坑填坑,一个个的趟过去。比如 popup 组件,小程序会出现滚动透传问题,而在 h5 上做了兼容处理,但是如果你打开后,未关闭直接跳转到新的页面,会导致新的页面无法滚动,需要在路由变化时关闭当前页面里的 popup 组件,这种问题真的是只能自己遇到一个填一个了。

文档混乱 &ZeroWidthSpace;

uni-app 做了很多平台封装保证 api 的一致性,但是还是会经常写着发现平台不支持,只能自己另辟蹊径。

结语 &ZeroWidthSpace;

由于没有 app 需求,不知道在 app 上坑多不多,据以前同事使用体验来看据说坑更多🤣。不过对于一些简单应用可以尝试使用,毕竟能一套代码构建多端还是能提升不少效率的。但是你的应用复杂起来,什么多端开发,提高效率,这玩意效率真的不一定能高到哪里去,还不如分开效率高.

hexo 在线编辑器

作者 白云苍狗
2023年4月6日 21:48

开源了一款 Hexo 在线编辑器,提供在线编写 Hexo 方式。目前已实现对本地 Hexo 编辑维护

Img 示例

话不多说放链接:在线地址Github 地址

实现大致原理 &ZeroWidthSpace;

实现不算复杂,利用 FileSystemHandle 获取文件读写权限,就可以随心所欲操作 Hexo 文件了,就可对文件增删改了。

至于预览就更简单了,将 hexo-renderer 操作替换 markdown 渲染操作,实时渲染输出 HTML 就可以了,这些都有很多现成的案例。

需要更加完整支持 Hexo 在线编辑预览,还是有些细节需要调整的,比如需要支持配置预览样式,最好能和自己博客样式一致,还需要支持 Hexo Tag 等这些需要完善。

功能 &ZeroWidthSpace;

以实现功能

  • [x] 文章增删改和预览
  • [x] 发布草稿、下架发布
  • [x] Markdown 编辑、预览、格式化
  • [x] front-matters 编辑
  • [x] 图片粘贴、Markdown 语法提示、解析 HTML
  • [x] 主题切换
  • [x] 静态资源管理
  • [x] 命令面板

计划实现

  • [ ] 替换 markdown 渲染器(因为 Hexo 默认用 marked,结果 marked 渲染标记到具体行,比较麻烦,导致同步滚动还没实现)
  • [ ] 搜索文章内容
  • [ ] 支持图床
  • [ ] Markdown 同步滚动
  • [ ] 连接 Github 仓库,在线维护 Hexo 博客

2023年,还在手动发布 npm 包?

作者 白云苍狗
2023年2月10日 10:50

还在手动 npm publish 发布 npm ? 还在手动更新版本创建发布 Github Release ?还在手动添加 Changelog?是时候利用 CI/CD 解放双手啦。常见的 CI/CD 有很多,比如 Jenkins、GitLab CI、CircleCI 、Github Actions 等。本文主要是通过 Github Actions 来自动化处理 npm 包管理。

Github Actions &ZeroWidthSpace;

GitHub Actions 距离推出已经好几年了,相信小伙伴们没用过,也听说过。GitHub Actions 不仅仅是能自动执行生成、测试和部署操作,还允许您在存储库中发生其他事件时运行工作流程。 例如,您可以运行工作流程,以便在有人在您的存储库中创建新问题时自动添加相应的标签。而且 GitHub 提供 Linux、Windows 和 macOS 多种虚拟机来运行工作流程,可以更具项目选择合适的虚拟机环境。

GitHub Actions 其实主要步骤只有两个,event (事件触发时机)、jobs(工作流程)。如果还不熟悉 GitHub Actions,可以参考文档对其大致使用有个了解。

发布 Npm &ZeroWidthSpace;

在本地发布 npm 包都是本地打包成组件后,登录 npm 账号运行 npm publish 发布。使用 GitHub Actions 来发布 npm 可以使用 Npm Access Tokens,可以更据需要分配 Tokens 权限,不需要使用账号密码登录。

Github Actions 发布 Npm &ZeroWidthSpace;

首先需要确定你工作流触发时机,这个需要根据你个人习惯决定。

举例:

yaml
on:
  push:
    tags:
      - "v*"
yaml
on:
  push:
    branches: [master]
yaml
# 仅在 master 推送时,且 docs 下文件修改时触发,更多可以[参考文档](https://docs.github.com/zh/actions/using-workflows/events-that-trigger-workflows)
on:
  push:
    branches: [master]
    paths:
      - 'docs/**'

本文就以为推送 Tag 触发为例:

在你项目下添加 .github\workflows\publish.yml 文件,内容如下

yaml
name: NPM Publish
on:
  push:
    tags:
      - "v*"

代码拉取和Node配置 &ZeroWidthSpace;

jobs 添加一个名为 build 的 job,配置 build 的环境和拉取代码。runs-on: ubuntu-latest 配置虚拟机为 ubuntu,使用官方提取代码拉取和 配置 Node 的 Actions,更多 官方提供 Actions

yaml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2   # 拉取代码
      - uses: actions/setup-node@v3 # 设置 Node 版本
        with:
          node-version: 16
          registry-url: 'https://registry.npmjs.org'
          cache: yarn

配置环境密钥 &ZeroWidthSpace;

添加环境变量,在你项目 => Settings => Secrets and variables => Actions 中 添加你的密钥,名称随意取,密钥值为你上面生成的 Npm Access Tokens

例如: Name => NPM_TOKEN Secret => 你的 Npm Access Tokens

发布 Npm &ZeroWidthSpace;

build 的 job 中,添加发布 Npm 操作,利用 NPM_TOKEN 发布 npm 包

yaml
    steps:
      - name: Npm Publish
        run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

到此时,当你推送一个以 v开头 tag 到仓库时,就会执行这个 publish.yml,这个 Action 会将当前仓库发布到 Npm 了。

当然你的包可能还需要执行一些构建操作等等,你可以在 run 里执行多条命令

yaml
run: |
    npm run build
    #...更多操作
    npm publish

区分 Beta 和 latest &ZeroWidthSpace;

默认情况下 npm publish 发布时正式包,如果需要测试包需要执行 npm publish --tag beta。利用 git tag 我们可以将 v1.0.2 格式发为正式包,将 v1.0.2-beta.1 格式发布测试包。

修改 publish 流程,Github Action 支持 if 操作,并不支持 else,只能通过如下模拟 if else 操作。

yaml
    steps:
      - name: Beta Publish
        if: ${{ contains(github.ref,'beta') }}
        run: npm publish --tag beta
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

      - name: Publish
        if: ${{ !contains(github.ref,'beta') }}
        run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

根据 Tag 更新版本号 &ZeroWidthSpace;

为例避免每次修改 package.json 中版本,我们可以根据 tag 来修改 package.json 中版本,因为上面配置 node 环境,可以直接执行 node 代码,我们直接添加一个 publish.js 文件,去修改 package.json 中 version 。

js
const fs = require('fs')
const path = require('path')

if (process.env.RELEASE_VERSION) {
    const version = process.env.RELEASE_VERSION.split('/').reverse()[0]
    console.log('当前版本:', version);
    const pkg = require('./package.json')
    pkg.version = version.replace('v', '')
    fs.writeFileSync(path.resolve(__dirname, './package.json'), JSON.stringify(pkg, null, 4), 'utf-8')
}

修改 publish 流程,在发布前修改版本,并将 github.ref 添加环境变量 RELEASE_VERSION

yaml
    steps:
      - name: Build
        run: node ./publish
        env:
          RELEASE_VERSION: ${{ github.ref }}

创建发布 Github Release &ZeroWidthSpace;

官方 actions/create-release 不在维护,推荐如下 Actions,可以结合需要选择,你也可以去前往 marketplace 选择更多 Actions。

很多创建 ReleaseActions,并不会将你的提交自动生成发版说明,所以我编写了一个发布 ReleaseActions,具体实现参考 MaLuns/add-release 以满足我的需求。Github 为我们提供 actions/toolkit 帮助我们简化了很多操作。

简单描述如何实现:

从当前位置开始分页获取提交记录获取所有 tags循环读取记录是否是 tag 处此页记录是否循环完成处理两个 tag 间提交记录存储这条记录循环当页提交记录是否还有下页根据规则,将记录生成 MD

编写完自定义 Actions 后,使用自定义 Actions。

yaml
    steps:
      - name: Release
        uses: MaLuns/add-release@指定版本
        with:
          files: Demo.zip
          generate_by_commit: true

效果图:

效果图

到此,一个根据 Git tag 自动发布 Npm 和 Release 的工作流编写完成了。你还可以在结合 Github Pages、Vercel 等同时自动化部署你的 Npm 包的文档和示例站点等。

小程序 swiper 性能优化

作者 白云苍狗
2022年12月28日 13:22

事情是这样的,我做了一个在线壁纸小程序,壁纸预览部分为了更好时使用体验,增加了类似于那些短视频上滑下滑快速切换壁纸预览的功能,为了避免重复造轮子直接使用了 swiper 来时实现滑动快速切换壁纸。

问题所在 &ZeroWidthSpace;

当壁纸量比较多的时候,全部渲染成 swiper-item,会出现明显性能问题。还有个原因应该预览壁纸时加载的都是高清原图,直接全部加载出来,对性能也是很大一个浪费。图片的加可以通过设置 lazy-load 懒加载图片。

解决方案 &ZeroWidthSpace;

解决方案也挺简单,直接使用分页去处理,比如有 200 项,但是我们一次只渲染 3 个 swiper-item,比如当前下标是 1(对应原列表下标1),当你下滑时新的下标就是 2(对应原列表下标2)了,这个时候就需要我们更新下标 0(对应原列表下标3),为当前项,对应原始列表中的下一个,上滑也是这样的逻辑,这样就保证每次滑洞都按照原始列表顺序显示的,但是实际页面只渲染 3 个 swiper-item,和虚拟列表原理类似。

官方提供了一个 video-swiper 组件,原理时类似的,只不过是给视频列表使用的,我们参考他的实现进行修改。

实现 &ZeroWidthSpace;

假设我们的数据的为 list,实际渲染的数据为 swiperList,我们现在给他就固定 3 个 swiper-item,前后滑动的时候去替换数据,正向滑动的时候去替换滑动后的下一个数据,反向滑动的时候去替换滑动后的上一个数据,然后将 swiper 设置为可衔接滑动,这样保证一直可以循环滑动,然后更具滑动方向替换数据。

监听 swiper 的 bindchange 事件,可以获取到滑动后的 current,然后在 bindchange 事件里更新数据

滑动的方向判断

js
// swiper 长度
const LEN = 3
const stateNum = current - this.data.swiperIndex
const state = [-1, LEN - 1].includes(stateNum) ? "Last" : "Next"

获取 swiperList 需要更新的下标

js
let updateIndex;
if (state === "Next") {
    updateIndex = current === (LEN - 1) ? 0 : current + 1
} else {
    updateIndex = current === 0 ? (LEN - 1) : current - 1
}

除了需要 swiperList 更新下标,还得记录对应 list 的下标

js
// 获取当前项对应 list 下标
const previewIndex = state === "Last" ? this.data.previewIndex - 1 : this.data.previewIndex + 1

然后据可以根据滑块方向和 previewIndex,就可以确定更新 swiperList 数据了

js
 this.setData({
    // 更新实际 list 下标
    previewIndex, 
    [`swiperList[${updateIndex}]`]: state === "Last" ? list[previewIndex - 1] : list[previewIndex + 1] 
})
// 记录 swiper 下标
this.data.swiperIndex = current

根据上面思路基本上可以解决 swiper 数据量大性能问题了,但是还存在一些问题。

  • 滑块边界问题,list 数据也是有限的,也会滑到边界,需要对边界做一个判断处理

这个问题可以根据 previewIndex 下标去判断是否到达了边界,如果不需要无限加载数据,可以在达到边界时直接回弹静止滑块继续滚动,因为设置 swiper 为衔接滑动,达到边界时滑动时上一个或者下一个还是会显示有其他 swiper-item,所有需要我们添加一个空的 swiper-item 用来占位。如果时需要无限加载数据,可以在快到达边界时提前拉取数据,等拿到数据时继续下滑,否则反弹回去保持在最后一个。

  • 当 previewIndex 不是通过滑块更新时候,比如直接从 0 跳到 5 会有默认滚动动画,导致体验很差

这个解决方式是在非滑动更新时候,先将动画时间 duration 设置为 0 ,去掉动画,然后在更新下标和恢复动画。

js
this.setData({ duration: 0 }, () => {
    this.setData({
        previewIndex: previewIndex,
        swiperIndex: swiperIndex,
        swiperList: swiperList,
        duration: 500
    })
})

代码实现 &ZeroWidthSpace;

相对完整的代码实现

html
<swiper vertical="{{true}}" duration="{{duration}}" current="{{swiperIndex}}" circular="{{circular}}" bindchange="handleChangeBigImage">
    <swiper-item wx:for="{{swiperList}}" wx:key="index">
        <block wx:if="{{item.type==='placeholder'}}">
            <view class="placeholder-view"></view>
        </block>
        <block wx:else>
            <image show-menu-by-longpress lazy-load mode="aspectFill" src="{{item.path}}"></image>
        </block>
    </swiper-item>
</swiper>
ts
// swiper 长度
const LEN = 3
const SwiperPlaceholder = { type: "placeholder" }
type State = "Next" | "Last"

Component({
  properties: {
    list: {
      type: Array,
      value: [] as Array<ImageItem>
    },
    index: {
      type: Number,
      value: 0
    }
  },
  data: {
    // 当前元素下标
    previewIndex: 0,
    // swiper 切换
    circular: true,
    duration: 300,
    swiperIndex: 1, // 当前 swiper 下标
    swiperList: <Array<ImageItem>>[],
  },
  observers: {
    'index,list': function (index, list) {
      if (list.length && list[index]) {
        this._initSwiper(index, list)
      }
    },
  },
  methods: {
    // 切换 swiper
    handleChangeBigImage(e: WechatMiniprogram.SwiperChange) {
      const { current, source } = e.detail
      if (source !== "touch") return;

      const state = this._getSlideState(current, this.data.swiperIndex)
      const previewIndex = state === "Last" ? this.data.previewIndex - 1 : this.data.previewIndex + 1
      const currentItem = this.data.swiperList[current]

      // 到达了边界时,反弹回去
      if (currentItem.type === "placeholder") {
        this.setData({
          swiperIndex: this.data.swiperIndex
        })
        return
      }

      this.setData({
        previewIndex,
        [`swiperList[${this._updateUpdateIndex(current, state)}]`]: this._getUpdateSwiperItem(previewIndex, state),
      })

      this.data.swiperIndex = current
    },
    // 初始化 Swiper 模式
    _initSwiper(index: number, list: Array<ImageItem>, cb?: Function) {
      this.setData({ duration: 0 }, () => {
        let swiperIndex = 1
        let swiperList: Array<ImageItem> = []

        swiperList.push(list[index - 1] || SwiperPlaceholder)
        swiperList.push(list[index] || SwiperPlaceholder)
        swiperList.push(list[index + 1] || SwiperPlaceholder)

        this.setData({
          previewIndex: index,
          swiperIndex,
          swiperList,
          duration: 500,
          isHide: false
        }, () => {
          if (cb) cb()
        })
      })
    },
    // 获取滚动状态
    _getSlideState(current: number, lastCurrent: number): State {
      const state = current - lastCurrent
      return [-1, LEN - 1].includes(state) ? "Last" : "Next"
    },
    // 获取需要更新下标
    _updateUpdateIndex(current: number, type: State) {
      if (type === "Next") {
        return current === (LEN - 1) ? 0 : current + 1
      } else {
        return current === 0 ? (LEN - 1) : current - 1
      }
    },
    // 获取需要更新数据
    _getUpdateSwiperItem(index: number, type: State) {
      const list = this.data.list
      let item
      if (type === "Last") {
        item = list[index - 1]
      } else {
        item = list[index + 1]
      }
      // 到达边界时 返回填充元素
      if (!item) {
        item = SwiperPlaceholder
      }
      return item
    }
  }
})

瀑布流使用虚拟列表性能优化

作者 白云苍狗
2022年11月14日 12:05

瀑布流算是比较常见的布局了,一个般常见纵向瀑布流的交互,当我们滚动到底的时候加载下一页的数据追加到上去。因为一次加载的数据量不是很多,页面操作是也不会有太大的性能消耗。但是如果当你一直往下滚动加载,加载几十页的时候,就会开始感觉不那么流畅的,这是因为虽然每次操作的很少,但是页面的 DOM 越来越多,内存占用也会增大,而且发生重排重绘时候浏览器计算量耗时也会增大,就导致了慢慢不能那么流畅了。这个时候可以选择结合虚拟列表方式使用,虚拟列表本身就是用来解决超长列表时的处理方案。

瀑布流 &ZeroWidthSpace;

瀑布流的实现方式有很多种,大体分为:

  • CSS: CSS 实现的有 multi-column、grid ,CSS 实现存在一定局限性,例如无法调整顺序,当元素高度差异较大时候不是很好处理各列间隔差等。
  • JavaScript:JavaScript 实现的有 JavaScript + flex、JavaScript + position,JavaScript 实现兼容性较好,可控制性高。

因为我的瀑布流是可提前计算元素宽高,列数是动态的,所以采用了 JavaScript + position 来配合 虚拟列表 进行优化。

js + flex 实现 &ZeroWidthSpace;

如果你的瀑布流 列是固定,列宽不固定 的,使用 flex 是个很好选择,当你的容器宽度变话时候,每一列宽度会自适应,大致实现方式

将你的数据分为对应列数

js
let data1 = [], //第一列
    data2 = [], //第二列
    data3 = [], //第三列
    i = 0;

while (i < data.length) {
    data1.push(data[i++]);
    if (i < data.length) {
        data2.push(data[i++]);
    }
    if (i < data.length) {
        data3.push(data[i++]);
    }
}

然后将你的每列数据插入进去就可以了,设置 list 为 flex 容器,并设置主轴方向为 row

html
<div class="list">
    <!-- 第一列 -->
    <div class="column">
        <div class="item"></div>
        <!-- more items-->
    </div>
    <!-- 第二列 -->
    <div class="column">
        <div class="item"></div>
        <!-- more items-->
    </div>
    <!-- 第三列 -->
    <div class="column">
        <div class="item"></div>
        <!-- more items-->
    </div>
</div>

js + position 实现 &ZeroWidthSpace;

这种方式比较适合 列定宽,列数量不固定情况,而且最好能计算出每个元素的大小。

大致 HTML 结构如下:

html
<ul class="list">
    <li class="list-item"></li>
    <!-- more items-->
</ui>
<style>
    .list {
        position: relative;
    }

    .list-item {
        position: absolute;
        top: 0;
        left: 0;
    }
</style>

JavaScript 部分,首先需要获取 list 宽度,根据 list.width/列宽 计算出列的数量,然后根据列数量去分组数据和计算位置

js
// 以列宽为300 间隔为20 为例

let catchColumn = (Math.max(parseInt((dom.clientWidth + 20) / (300 + 20)), 1))

const toTwoDimensionalArray = (count) => {
    let list = []
    for (let index = 0; index < count; index++) {
        list.push([])
    }
    return list;
}

const minValIndex = (arr = []) => {
    let val = Math.min(...arr);
    return arr.findIndex(i => i === val)
}

// 缓存累计高度
let sumHeight = toTwoDimensionalArray(catchColumn)

data.forEach(item => {
    // 获取累计高度最小那列
    const minIndex = minValIndex(sumHeight)

    let width = 0 // 这里宽高更具需求计算出来
    let height = 0

	item._top = minIndex * (300 + 20) // 缓存位置信息,后面会用到
    item.style = {
        width: width + 'px',
        height: height + 'px',
        // 计算偏移位置
        transform: `translate(${minIndex * (300 + 20)}px, ${sumHeight[minIndex]}px)`
    }

    sumHeight[minIndex] = sumHeight[minIndex] + height + 20 
})

动态列数 &ZeroWidthSpace;

可以使用 ResizeObserver(现代浏览器兼容比较好了) 监听容器元素大小变化,当宽度变化时重新计算列数量,当列数量发生变化时重新计算每项的位置信息。

js
const observer = debounce((e) => {
    const column = updateVisibleContainerInfo(visibleContainer)
    if (column !== catchColumn) {
        catchColumn = column
        // 重新计算
        this.resetLayout()
    }
}, 300)

const resizeObserver = new ResizeObserver(e => observer(e));

// 开始监听
resizeObserver.observe(dom);

过渡动画 &ZeroWidthSpace;

当列数量发生变化时候,元素项的位置很多都会发生变化,如下图,第 4 项的位置从第 3 列变到了第 4 项,如果不做处理会显得比较僵硬。

1 2 3 4 4

好在我们使用了 transform(也是为什么不使用 top、left 原因,transform 动画性能更高) 进行位置偏移,可以直接使用 transition 过渡。

css
.list-item {
    position: absolute;
    top: 0;
    left: 0;
    transition: transform .5s ease-in-out;
}

使用虚拟列表 &ZeroWidthSpace;

瀑布流存在的问题 &ZeroWidthSpace;

很多虚拟列表的都是使用的单列定高使用方式,但是瀑布流使用虚拟列表方式有点不同,瀑布流存在多列且时是错位的。所以常规 length*height 为列表总高度,根据 scrollTop/height 来确定下标方式就行不通了,这个时候高度需要根据瀑布流高度动态决定了,可显示元素也不能通过 starindex-endindex 去截取显示了。

如下图:蓝色框的元素是不应该显示的,只有与可视区域存在交叉的元素才应该显示

1 2 3 4 5 6 8 9 10 7 12 11 13 15 14

可视元素判定 &ZeroWidthSpace;

先来看下面图,当元素完全不在可视区域时候就视为当前元素不需要显示,只有与可视区域存在交叉或被包含时候视为需要显示。

1 2 3 4 5 6 8 9 10 7 12 11 13 14

因为上面瀑布流的实现采用的是 position 定位的,所以我们完全能知道所有元素距离顶部的距离,很容易计算出与可视区域交叉位置。

元素偏移位置 < 滚动高度+可视区域高度 && 元素偏移位置 + 元素高度 > 滚动高度

如果只渲染可视区域范围,滚动时候会存在白屏再出现,可视适当的扩大渲染区域,例如把上一屏和下一屏都算进来,进行预先渲染。

js
const top = scrollTop - clientHeight
const bottom = scrollTop + clientHeight * 2
const visibleList = data.filter(item => item._top + item.height > top && item._top < bottom)

然后通过监听滚动事件,根据滚动位置去处理筛选数。这里会存在一个隐藏性能问题,当滚动加载数据比较多的时候,滚动事件触发也是比较快的,每一次都进行一次遍历,也是比较消耗性能的。可以适当控制一下事件触发频率,当然这也只是治标不治本,归根倒是查询显示元素方法问题。

标记下标 应为列表数据的 _top 值是从小到大正序的,所以我们可以标记在可视区元素的下标,当发生滚动的时候,我们直接从标记下标开始查找,根据滚动分几种情况来判断。 1> 如果滚动后,标记下标元素还在可视范围内,可以直接从标记下标二分查找,往上往下找直到不符合条件就停止。 2> 如果滚动后,标记下标元素不在可视范围内,根据滚动方向往上或者往下去查找。这个时候存在两种情况,一种是滚动幅度比较小,直接根据当前下标往上或者往下找。当用户拖动滚动条滚动幅度特别大的时候,可以将下标往上或者往下偏移,偏移量根据 滚动高度/预估平均高度*列数 去估算一个,然后在根据这个预估下标进行查找。找到后然后缓存一个新的下标。

抖动问题 &ZeroWidthSpace;

我们 absolute 定位会撑开容器高度,但是滚动时候还是会存在抖动问题,我们可以自定义一个元素高度去撑开,这个元素高度也就是我们之前计算的每一列累计高度 sumHeight 中最大的那个了。

过渡动画问题 &ZeroWidthSpace;

当列宽发生变化时,元素位置发生了变化,在可视区域的元素也发生了变化,有些元素可能之前并没有渲染,所以使用上面 CSS 会存在新出现元素不会产生过渡动画。好在我们能够很清楚的知道元素原位置信息和新的位置信息,我们可以利用 FLIP 来处理这动画,很容易控制元素过渡变化,如果有些元素之前不存在,就没有原位置信息,我们可以在可视范围内给他随机生成一个位置进行过渡,保证每一个元素都有个过渡效果避免僵硬。

总结 &ZeroWidthSpace;

上面情况仅仅是针对动态列数量,又能计算出高度情况下优化,可能业务中也是可能存在每项高度是动态的,这个时候可以采用预估元素高度在渲染后缓存大小位置等信息,或者离屏渲染等方案解决做出进一步的优化处理。

Pjax 下动态加载插件方案

作者 白云苍狗
2022年9月28日 16:35

在纯静态网站里,有时候会动态更新某个区域往会选择 Pjax(swup、barba.js)去处理,他们都是使用 ajax 和 pushState 通过真正的永久链接,页面标题和后退按钮提供快速浏览体验。

但是实际使用中可能会遇到不同页面可能会需要加载不同插件处理,有些人可能会全量选择加载,这样会导致加载很多无用的脚本,有可能在用户关闭页面时都不一定会访问到,会很浪费资源。

解决思路 &ZeroWidthSpace;

首先想到的肯定是在请求到新的页面后,我们手动去比较当前 DOM 和 新 DOM 之间 script 标签的差异,手动给他插入到 body 里。

处理 Script &ZeroWidthSpace;

一般来说 JavaScript 脚本都是放在 body 后,避免阻塞页面渲染,假设我们页面脚本也都是在 body 后,并在 script 添加 [data-reload-script] 表明哪些是需要动态加载的。

首先我们直接获取到带有 [data-reload-script] 属性的 script 标签:

js
// NewHTML 为 新页面 HTML
const pageContent = NewHTML.replace('<body', '<div id="DynamicPluginBody"').replace('</body>', '</div>');
let element = document.createElement('div');
element.innerHTML = pageContent;
const children = element.querySelector('#DynamicPluginBody').querySelectorAll('script[data-reload-script]');

然后通过创建 script 标签插入到 body

js
children.forEach(item => {
    const element = document.createElement('script');
    for (const { name, value } of arrayify(item.attributes)) {
        element.setAttribute(name, value);
    }
    element.textContent = item.textContent;
    element.setAttribute('async', 'false');
    document.body.insertBefore(element)
})

如果你的插件都是通过 script 引入,且不需要执行额外的 JavaScript 代码,只需要在 Pjax 钩子函数这样处理就可以了。

执行代码块 &ZeroWidthSpace;

实际很多插件不仅仅需要你引入,还需要你手动去初始化做一些操作的。我们可以通过 src 去判断是引入的脚本,还是代码块。

js
let scripts = Array.from(document.scripts)
let scriptCDN = []
let scriptBlock = []

children.forEach(item => {
    if (item.src)
        scripts.findIndex(s => s.src === item.src) < 0 && scriptCDN.push(item);
    else
        scriptBlock.push(item.innerText)
})

scriptCDN 继续通过上面方式插入到 body 里,然后通过 eval 或者 new Function 去执行 scriptBlock 。因为 scriptBlock 里的代码可能是会依赖 scriptCDN 里的插件的,所以需要在 scriptCDN 加载完成后在执行 scriptBlock 。

js
const loadScript = (item) => {
    return new Promise((resolve, reject) => {
        const element = document.createElement('script');
        for (const { name, value } of arrayify(item.attributes)) {
            element.setAttribute(name, value);
        }
        element.textContent = item.textContent;
        element.setAttribute('async', 'false');
        element.onload = resolve
        element.onerror = reject
        document.body.insertBefore(element)
    })
}

const runScriptBlock = (code) => {
    try {
        const func = new Function(code);
        func()
    } catch (error) {
        try {
            window.eval(code)
        } catch (error) {
        }
    }
}

Promise.all(scriptCDN.map(item => loadScript(item))).then(_ => {
    scriptBlock.forEach(code => {
        runScriptBlock(code)
    })
})

卸载插件 &ZeroWidthSpace;

按照上面思去处理之后,会存在一个问题。 比如:我们添加了一个 全局的 'resize' 事件的监听,在跳转其他页面时候我们需要移除这个监听事件。

这个时候我们需要对代码块的格式进行一个约束,比如像下面这样,在初次加载时执行 mount 里代码,页面卸载时执行 unmount 里代码。

js
<script data-reload-script>
    DynamicPlugin.add({
        // 页面加载时执行
        mount() {
            this.timer = setInterval(() => {
                document.getElementById('time').innerText = new Date().toString()
            }, 1000)
        },
        // 页面卸载时执行
        unmount() {
            window.clearInterval(this.timer)
            this.timer = null
        }
    })
</script>

DynamicPlugin 大致结构:

js
let cacheMount = []
let cacheUnMount = []
let context = {}

class DynamicPlugin {
    add(options) {
        if (isFunction(options))
            cacheMount.push(options)

        if (isPlainObject(options)) {
            let { mount, unmount } = options
            if (isFunction(mount))
                cacheMount.push(mount)
            if (isFunction(unmount))
                cacheUnMount.push(unmount)
        }

        // 执行当前页面加载钩子
        this.runMount()
    }

    runMount() {
        while (cacheMount.length) {
            let item = cacheMount.shift();
            item.call(context);
        }
    }

    runUnMount() {
        while (cacheUnMount.length) {
            let item = cacheUnMount.shift();
            item.call(context);
        }
    }
}

页面卸载时调用 DynamicPlugin.runUnMount()。

处理 Head &ZeroWidthSpace;

Head 部分处理来说相对比较简单,可以通过拿到新旧两个 Head,然后循环对比每个标签的 outerHTML,用来判断哪些比是需要新增的哪些是需要删除的。

结尾 &ZeroWidthSpace;

本文示例代码完整版本可以 参考这里

hexo-theme-async 使用指北

作者 白云苍狗
2022年9月7日 15:29

demo预览图

前提 &ZeroWidthSpace;

本主题为 Hexo 主题,请确保您对 Hexo 已有基本了解 。详请参见 Hexo 官网

开始前,请确保您的 Hexo 初始化工作已经准备完成,请参考 Hexo 安装。本主题依赖于 Node 14.x 以上版本,请注意您本地 Node 环境。

主题安装 &ZeroWidthSpace;

进入您的 Hexo 博客根目录,执行如下命令,安装主题:

bash
npm i hexo-theme-async@latest
bash
yarn add hexo-theme-async@latest

如果您没有 ejsless 的渲染器,请先安装:hexo-renderer-ejshexo-renderer-less

bash
npm install --save hexo-renderer-less hexo-renderer-ejs
bash
yarn add -D hexo-renderer-less hexo-renderer-ejs

启用主题 &ZeroWidthSpace;

修改 Hexo 博客配置文件 _config.yml,到此运行起来就 Hexo-Theme-Async 主题就生效啦。

yaml
# 将主题设置为 hexo-theme-async
theme: async

关于主题配置修改,可以参考 hexo-theme-async 文档

演示视频 &ZeroWidthSpace;

安装示例视频,更多视频前往这里

云开发又又又涨价了

作者 白云苍狗
2022年7月13日 16:44

之前一波搞没了免费额度,这次直接要回收以前开通免费版的资源了。搞成基础套餐+按量计费模式,直接最低39.9块一个月。用不起了,腾讯是真TM的狗。

公告 超出基础后,价格比原来也翻了好多倍。 价格 准备关了评论插件,换图床了,之前图床用的云存储,还不让整个文件下载,简直无语子了。

❌
❌