阅读视图

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

从零构建 Vue 弹窗组件

整体学习路线:简易弹窗 → 完善基础功能 → 组件内部状态管理 → 父→子传值 → 子→父传值 → 跨组件传值(最终目标)


步骤 1:搭建最基础的弹窗(静态结构,无交互)

目标:实现一个固定显示在页面中的弹窗,包含标题、内容、关闭按钮,掌握 Vue 组件的基本结构。

组件文件:BasicPopup.vue

<template>
  <!-- 弹窗外层容器(遮罩层) -->
  <div class="popup-mask">
    <!-- 弹窗主体 -->
    <div class="popup-content">
      <h3>简易弹窗</h3>
      <p>这是最基础的弹窗内容</p>
      <button>关闭</button>
    </div>
  </div>
</template>

<script setup>
// 现阶段无逻辑,仅搭建结构
</script>

<style scoped>
/* 遮罩层:占满整个屏幕,半透明背景 */
.popup-mask {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
}

/* 弹窗主体:白色背景,固定宽高,圆角 */
.popup-content {
  width: 400px;
  padding: 20px;
  background-color: #fff;
  border-radius: 8px;
  text-align: center;
}

/* 按钮样式 */
button {
  margin-top: 20px;
  padding: 8px 16px;
  background-color: #1890ff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

使用组件(App.vue

<template>
  <h1>弹窗学习演示</h1>
  <BasicPopup />
</template>

<script setup>
import BasicPopup from './components/BasicPopup.vue';
</script>

步骤 2:添加基础交互(控制弹窗显示/隐藏)

目标:通过「响应式状态」控制弹窗的显示与隐藏,给关闭按钮添加点击事件,掌握 ref 和事件绑定。

改造 BasicPopup.vue(新增响应式状态和点击事件)

<template>
  <!-- 用 v-if 控制弹窗是否显示 -->
  <div class="popup-mask" v-if="isShow">
    <div class="popup-content">
      <h3>简易弹窗</h3>
      <p>这是最基础的弹窗内容</p>
      <!-- 绑定关闭按钮点击事件 -->
      <button @click="closePopup">关闭</button>
    </div>
  </div>
</template>

<script setup>
// 1. 导入 ref 用于创建响应式状态
import { ref } from 'vue';

// 2. 定义响应式变量,控制弹窗显示/隐藏
const isShow = ref(true); // 初始值为 true,默认显示弹窗

// 3. 定义关闭弹窗的方法
const closePopup = () => {
  isShow.value = false; // 响应式变量修改需要通过 .value
};
</script>

<style scoped>
/* 样式同步骤 1,不变 */
.popup-mask {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
}

.popup-content {
  width: 400px;
  padding: 20px;
  background-color: #fff;
  border-radius: 8px;
  text-align: center;
}

button {
  margin-top: 20px;
  padding: 8px 16px;
  background-color: #1890ff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

补充:在 App.vue 添加「打开弹窗」按钮

我们知道,Vue 遵循「单向数据流」和「组件封装隔离」,子组件内部的方法 / 私有状态默认是对外隐藏的,外部父组件无法直接访问。

ref 就是打破这种 “隔离” 的合法方式(非侵入式),让父组件能够:

  1. 调用子组件通过 defineExpose 暴露的方法(如 openPopup 方法,用于打开弹窗)。
  2. 访问子组件通过 defineExpose 暴露的响应式状态(如弹窗内部的 isShow 状态)。
  3. 实现「父组件主动控制子组件」的交互场景(如主动打开 / 关闭弹窗、主动刷新子组件数据)。
<template>
  <h1>弹窗学习演示</h1>
  <!-- 新增打开弹窗按钮 -->
  <button @click="handleOpenPopup">打开弹窗</button>
  <BasicPopup ref="popupRef" />
</template>

<script setup>
import { ref } from 'vue';
import BasicPopup from './components/BasicPopup.vue';

// 获取弹窗组件实例
const popupRef = ref(null);

// 打开弹窗的方法(调用子组件的方法,后续步骤会完善)
const handleOpenPopup = () => {
  if (popupRef.value) {
    popupRef.value.openPopup();
  }
};
</script>

<style>
button {
  margin: 10px;
  padding: 8px 16px;
  background-color: #1890ff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

BasicPopup.vue 补充「打开弹窗」方法(暴露给父组件)

<template>
  <div class="popup-mask" v-if="isShow">
    <div class="popup-content">
      <h3>简易弹窗</h3>
      <p>这是最基础的弹窗内容</p>
      <button @click="closePopup">关闭</button>
    </div>
  </div>
</template>

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

const isShow = ref(false); // 初始值改为 false,默认隐藏

const closePopup = () => {
  isShow.value = false;
};

// 新增:打开弹窗的方法
const openPopup = () => {
  isShow.value = true;
};

// 暴露组件方法,让父组件可以调用(关键)
defineExpose({
  openPopup
});
</script>

<style scoped>
/* 样式同前 */
</style>

核心知识点

  1. ref 创建响应式状态,修改时需要 .value
  2. @click 事件绑定,触发自定义方法
  3. defineExpose 暴露组件内部方法/状态,供父组件调用
  4. v-if 控制元素的渲染与销毁(实现弹窗显示/隐藏)

步骤 3:父组件 → 子组件传值(Props 传递)

目标:父组件向弹窗组件传递「弹窗标题」和「弹窗内容」,掌握 defineProps 的使用,实现弹窗内容的动态化。

改造 BasicPopup.vue(接收父组件传递的参数)

<template>
  <div class="popup-mask" v-if="isShow">
    <div class="popup-content">
      <!-- 渲染父组件传递的标题 -->
      <h3>{{ popupTitle }}</h3>
      <!-- 渲染父组件传递的内容 -->
      <p>{{ popupContent }}</p>
      <button @click="closePopup">关闭</button>
    </div>
  </div>
</template>

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

// 1. 定义 Props,接收父组件传递的值(指定类型和默认值)
const props = defineProps({
  // 弹窗标题
  popupTitle: {
    type: String,
    default: '默认弹窗标题' // 默认值,防止父组件未传递
  },
  // 弹窗内容
  popupContent: {
    type: String,
    default: '默认弹窗内容'
  }
});

const isShow = ref(false);

const closePopup = () => {
  isShow.value = false;
};

const openPopup = () => {
  isShow.value = true;
};

defineExpose({
  openPopup
});
</script>

<style scoped>
/* 样式同前 */
</style>

改造 App.vue(向子组件传递 Props 数据)

<template>
  <h1>弹窗学习演示</h1>
  <button @click="openPopup">打开弹窗</button>
  <!-- 向子组件传递 props 数据(静态传递 + 动态传递均可) -->
  <BasicPopup
    ref="popupRef"
    popupTitle="父组件传递的标题"
    :popupContent="dynamicContent"
  />
</template>

<script setup>
import { ref } from 'vue';
import BasicPopup from './components/BasicPopup.vue';

const popupRef = ref(null);
// 动态定义弹窗内容(也可以是静态字符串)
const dynamicContent = ref('这是父组件通过 Props 传递的动态弹窗内容~');

const openPopup = () => {
  if (popupRef.value) {
    popupRef.value.openPopup();
  }
};
</script>

<style>
button {
  margin: 10px;
  padding: 8px 16px;
  background-color: #1890ff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

核心知识点

  1. defineProps 定义组件接收的参数,支持类型校验和默认值
  2. Props 传递规则:父组件通过「属性绑定」向子组件传值,子组件只读(不能修改 Props,遵循单向数据流)
  3. 静态传值(直接写字符串)直接把等号后面的内容作为纯字符串传递给子组件的 Props,Vue 不会对其做任何解析、计算,原样传递。动态传值(:xxx="变量") Vue 会先解析求值,再把结果传递给子组件的 Props。

步骤 4:子组件 → 父组件传值(Emits 事件派发)

目标:弹窗关闭时,向父组件传递「弹窗关闭的状态」和「自定义数据」,掌握 defineEmits 的使用,实现子向父的通信。

改造 BasicPopup.vue(派发事件给父组件)

<template>
  <div class="popup-mask" v-if="isShow">
    <div class="popup-content">
      <h3>{{ popupTitle }}</h3>
      <p>{{ popupContent }}</p>
      <!-- 关闭按钮点击时,触发事件派发 -->
      <button @click="handleClose">关闭</button>
    </div>
  </div>
</template>

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

// 1. 定义 Props
const props = defineProps({
  popupTitle: {
    type: String,
    default: '默认弹窗标题'
  },
  popupContent: {
    type: String,
    default: '默认弹窗内容'
  }
});

// 2. 定义 Emits,声明要派发的事件(支持数组/对象格式,对象格式可校验)
const emit = defineEmits([
  'popup-close', // 弹窗关闭事件
  'send-data'    // 向父组件传递数据的事件
]);

const isShow = ref(false);

// 3. 改造关闭方法,派发事件给父组件
const handleClose = () => {
  isShow.value = false;
  
  // 派发「popup-close」事件,可携带参数(可选)
  emit('popup-close', {
    closeTime: new Date().toLocaleString(),
    message: '弹窗已正常关闭'
  });
  
  // 派发「send-data」事件,传递自定义数据
  emit('send-data', '这是子组件向父组件传递的额外数据');
};

const openPopup = () => {
  isShow.value = true;
};

defineExpose({
  openPopup
});
</script>

<style scoped>
/* 样式同前 */
</style>

改造 App.vue(监听子组件派发的事件)

<template>
  <h1>弹窗学习演示</h1>
  <button @click="openPopup">打开弹窗</button>
  <!-- 监听子组件派发的事件,绑定处理方法 -->
  <BasicPopup
    ref="popupRef"
    popupTitle="父组件传递的标题"
    :popupContent="dynamicContent"
    @popup-close="handlePopupClose"
    @send-data="handleReceiveData"
  />
</template>

<script setup>
import { ref } from 'vue';
import BasicPopup from './components/BasicPopup.vue';

const popupRef = ref(null);
const dynamicContent = ref('这是父组件通过 Props 传递的动态弹窗内容~');

const openPopup = () => {
  if (popupRef.value) {
    popupRef.value.openPopup();
  }
};

// 1. 处理子组件派发的「popup-close」事件
const handlePopupClose = (closeInfo) => {
  console.log('接收弹窗关闭信息:', closeInfo);
  alert(`弹窗已关闭,关闭时间:${closeInfo.closeTime}`);
};

// 2. 处理子组件派发的「send-data」事件
const handleReceiveData = (data) => {
  console.log('接收子组件传递的数据:', data);
  alert(`收到子组件数据:${data}`);
};
</script>

<style>
button {
  margin: 10px;
  padding: 8px 16px;
  background-color: #1890ff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

核心知识点

  1. defineEmits 声明组件要派发的事件,遵循「事件名小写+短横线分隔」规范
  2. emit 方法用于派发事件,第一个参数是事件名,后续参数是要传递的数据
  3. 父组件通过 @事件名 监听子组件事件,处理方法的参数就是子组件传递的数据
  4. 单向数据流补充:子组件不能直接修改 Props,如需修改,可通过「子组件派发事件 → 父组件修改数据 → Props 重新传递」实现

步骤 5:跨组件传值(非父子组件,使用 Provide / Inject)

目标:实现「非父子组件」(如:Grandpa.vueParent.vuePopup.vue,爷爷组件向弹窗组件传值)的通信,掌握 provide / inject 的使用,这是 Vue 中跨组件传值的核心方案之一。

步骤 5.1:创建层级组件结构

├── App.vue(入口)
├── components/
│   ├── Grandpa.vue(爷爷组件,提供数据)
│   ├── Parent.vue(父组件,中间层级,无数据处理)
│   └── Popup.vue(弹窗组件,注入并使用数据)

步骤 5.2:爷爷组件 Grandpa.vue(提供数据,provide

<template>
  <div class="grandpa">
    <h2>爷爷组件</h2>
    <button @click="updateGlobalData">修改跨组件传递的数据</button>
    <!-- 引入父组件(中间层级) -->
    <Parent />
  </div>
</template>

<script setup>
import { ref, provide } from 'vue';
import Parent from './Parent.vue';

// 1. 定义要跨组件传递的响应式数据
const globalPopupConfig = ref({
  author: 'yyt',
  version: '1.0.0',
  theme: 'light',
  maxWidth: '500px'
});

// 2. 提供(provide)数据,供后代组件注入使用
// 第一个参数:注入标识(字符串/Symbol),第二个参数:要传递的数据
provide('popupGlobalConfig', globalPopupConfig);

// 3. 修改响应式数据(后代组件会同步更新)
const updateGlobalData = () => {
  globalPopupConfig.value = {
    ...globalPopupConfig.value,
    version: '2.0.0',
    theme: 'dark',
    updateTime: new Date().toLocaleString()
  };
};
</script>

<style scoped>
.grandpa {
  padding: 20px;
  border: 2px solid #666;
  border-radius: 8px;
  margin: 10px;
}

button {
  padding: 8px 16px;
  background-color: #1890ff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

步骤 5.3:父组件 Parent.vue(中间层级,仅做组件嵌套)

<template>
  <div class="parent">
    <h3>父组件(中间层级)</h3>
    <button @click="openPopup">打开跨组件传值的弹窗</button>
    <!-- 引入弹窗组件 -->
    <Popup ref="popupRef" />
  </div>
</template>

<script setup>
import { ref } from 'vue';
import Popup from './Popup.vue';

const popupRef = ref(null);

// 打开弹窗
const openPopup = () => {
  if (popupRef.value) {
    popupRef.value.openPopup();
  }
};
</script>

<style scoped>
.parent {
  padding: 20px;
  border: 2px solid #999;
  border-radius: 8px;
  margin: 10px;
  margin-top: 20px;
}

button {
  padding: 8px 16px;
  background-color: #1890ff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

步骤 5.4:弹窗组件 Popup.vue(注入数据,inject

<template>
  <div class="popup-mask" v-if="isShow" :style="{ backgroundColor: themeBg }">
    <div class="popup-content" :style="{ maxWidth: globalConfig.maxWidth, background: globalConfig.theme === 'dark' ? '#333' : '#fff', color: globalConfig.theme === 'dark' ? '#fff' : '#333' }">
      <h3>{{ popupTitle }}</h3>
      <p>{{ popupContent }}</p>
      <!-- 渲染跨组件传递的数据 -->
      <div class="global-info" style="margin: 15px 0; padding: 10px; border: 1px solid #eee; border-radius: 4px;">
        <p>跨组件传递的配置:</p>
        <p>作者:{{ globalConfig.author }}</p>
        <p>版本:{{ globalConfig.version }}</p>
        <p>主题:{{ globalConfig.theme }}</p>
        <p v-if="globalConfig.updateTime">更新时间:{{ globalConfig.updateTime }}</p>
      </div>
      <button @click="handleClose">关闭</button>
    </div>
  </div>
</template>

<script setup>
import { ref, inject, computed } from 'vue';

// 1. 定义 Props
const props = defineProps({
  popupTitle: {
    type: String,
    default: '默认弹窗标题'
  },
  popupContent: {
    type: String,
    default: '默认弹窗内容'
  }
});

// 2. 注入(inject)爷爷组件提供的数据
// 第一个参数:注入标识(与 provide 一致),第二个参数:默认值(可选)
const globalConfig = inject('popupGlobalConfig', ref({ author: '默认作者', version: '0.0.1' }));

// 3. 基于注入的数据创建计算属性(可选,优化使用体验)
const themeBg = computed(() => {
  return globalConfig.value.theme === 'dark' ? 'rgba(0, 0, 0, 0.8)' : 'rgba(0, 0, 0, 0.5)';
});

// 4. 定义 Emits
const emit = defineEmits(['popup-close']);

const isShow = ref(false);

// 5. 关闭方法
const handleClose = () => {
  isShow.value = false;
  emit('popup-close', { message: '弹窗已关闭' });
};

// 6. 打开弹窗方法
const openPopup = () => {
  isShow.value = true;
};

// 7. 暴露方法
defineExpose({
  openPopup
});
</script>

<style scoped>
.popup-mask {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  justify-content: center;
  align-items: center;
}

.popup-content {
  padding: 20px;
  border-radius: 8px;
  text-align: center;
}

button {
  margin-top: 20px;
  padding: 8px 16px;
  background-color: #1890ff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

步骤 5.5:入口 App.vue(引入爷爷组件)

<template>
  <h1>跨组件传值演示(Provide / Inject)</h1>
  <Grandpa />
</template>

<script setup>
import Grandpa from './components/Grandpa.vue';
</script>

核心知识点

  1. provide / inject 用于跨层级组件通信(无论层级多深),爷爷组件提供数据,后代组件注入使用
  2. 传递响应式数据:provide 时传递 ref/reactive 包装的数据,后代组件可感知数据变化(同步更新)
  3. 注入标识:建议使用 Symbol 避免命名冲突(生产环境推荐),本步骤为简化使用字符串标识
  4. 注入默认值:inject 第二个参数为默认值,防止祖先组件未提供数据时出现报错
  5. 与 Props/Emits 的区别:Props 适用于父子组件,provide/inject 适用于跨层级组件

👋 手搓 gzip 实现的文件分块压缩上传

👋 手搓 GZIP 实现的文件分块压缩上传

1 前言

已经半年多的时间没有闲下来写文章了。一方面是重新迷上了玩游戏,另一方面是 AI 时代的到来,让我对普通技术类文章的阅读频率减少了很多,相应的,自己动笔的动力也减缓了不少。

但经过这段时间的摸索,有一点是可以确定的:具有一定技术深度、带有强烈个人风格或独特创意的文章,在 AI 时代仍具有不可替代的价值。

所以,本篇来了。

在上一篇文章中,我们实现了在浏览器中记录结构化日志,现在,我们需要将这部分日志上传到云端,方便工程师调试。

我们面临的首要问题就是,文件太大了,必须分片上传。

我们将从零构建一套大文件上传系统。和普通的大文件上传系统(如阿里 OSS、七牛云常见的方案)相似,我们具备分片上传、断点续传的基础能力。但不同的是,我们为此引入了两个高阶特性:

  1. AWS S3 预签名直传(Presigned URL) :降低服务端带宽压力。
  2. 独立分片 Gzip 压缩:在客户端对分片进行独立压缩,但最终在服务端合并成一个合法的 Gzip 文件。

阅读本篇,你将收获:

  • Gzip (RFC 1952) 与 Deflate (RFC 1951) 协议的底层实现原理。
  • 基于 AWS S3 实现大文件分片直传的完整架构。
  • 一个生产级前端上传 SDK 的设计思路。

2 基础方案设计

在正式开始设计之前,我们需要先了解以下知识:AWS 提供服务端的大文件上传或下载能力,但不直接提供直传场景(presign url)的大文件分片上传能力。

基于 AWS 实现的常规流程的大文件上传 flow 为:

  • 后端先启用 CreateMultipartUpload,得到 uploadId,返回前端

    • 在启用时,需遵循以下规则:

      • ✅ 分段上传的最大文件大小为 5TB
      • ⚠️ 最大分段数为 10000
      • ⚠️ 分段大小单次限制为 5MB-5GB,最后一段无限制
    • 需提前定义 x-amz-acl

    • 需提前定义使用的校验和算法 x-amz-checksum-algorithm

    • 需提前定义校验和类型 x-amz-checksum-type

  • 在上传时,可以通过 presign url 上传

    • 每一段都必须在 header 中包含 uploadId
    • 每一段都建议计算校验和,并携带到 header 中(声明时如定义了 **x-amz-checksum-algorithm 则必传)**
    • 每一段上传时,都必须携带分段的序号 partNumber
    • 上传后,返回每一段的 ETag 和 PartNumber,如果使用了校验和算法,则也返回;该返回数据需要记录下来
  • 上传完成后,调用 CompleteMultipartUpload

    • 必须包含参数 part,使用类似于:
    • ⚠️ 除了最后一段外,单次最小 5MB,否则 complete 阶段会报错

好在这并不意味着我们要在「直传」和「分片上传」中间二选一。

来看到我们的架构图,我们在 BFF 总共只需要三个接口,分别负责「创建上传任务」「获取分片上传 URL」「完成分片上传」的任务,而实际上传时,调用预授权的 AWS URL。

Mermaid Chart - Create complex, visual diagrams with text. A smarter way of creating diagrams.-2025-09-05-092658.png

更细节的部分,可以参考这份时序图。

Mermaid Chart - Create complex, visual diagrams with text. A smarter way of creating diagrams.-2025-09-05-092540.png

2.1 关键接口

📤 创建上传任务

  • 接口地址POST /createSliceUpload

  • 功能

    • 检查文件是否已存在
    • 检查是否存在未完成的上传任务
    • 创建新的分片上传任务
  • 返回示例

    • ✅ 文件已存在:

      {
        "id": "xxx",
        "fileName": "example.txt",
        "url": "https://..."
      }
      
    • 🔄 任务进行中:

      {
        "id": "xxx",
        "fileName": "example.txt",
        "uploadId": "abc123",
        "uploadedParts": [1, 2, 3]
      }
      
    • 🆕 新建任务:

      {
        "id": "xxx",
        "fileName": "example.txt",
        "uploadId": "abc123",
        "uploadedParts": []
      }
      

🔗 获取分片上传 URL

  • 接口地址POST /getSlicePresignedUrl

  • 功能:获取指定分片的预签名上传 URL

  • 请求参数

    {
      "id": "xxx",
      "fileName": "example.txt",
      "partNumber": 1,
      "uploadId": "abc123"
    }
    
  • 返回示例

    {
      "uploadUrl": "https://..."
    }
    

/getSlicePresignedUrl 接口中,我们通过 AWS SDK 可以预签一个直传 URL

import { UploadPartCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

  const uploadUrl = await getSignedUrl(
    s3client,
    new UploadPartCommand({
      Bucket: AWS_BUCKET,
      Key: fileKey,
      PartNumber: partNumber,
      UploadId: uploadId,
    }),
    { expiresIn: 3600 },
  );

✅ 完成分片上传

  • 接口地址POST /completeSliceUpload

  • 功能:合并所有已上传的分片

  • 请求参数

    {
      "id": "xxx",
      "fileName": "example.txt",
      "uploadId": "abc123",
      "parts": [
        { "ETag": "etag1", "PartNumber": 1 },
        { "ETag": "etag2", "PartNumber": 2 }
      ]
    }
    
  • 返回示例

    {
      "id": "xxx",
      "location": "https://..."
    }
    

2.2 前端设计

为了方便使用,我们尝试构建一套方便使用的 SDK,设计的 Options 如下

interface UploadSliceOptions {
  fileName: string;
  id: string;
  getContent: (
    uploadSlice: (params: { content: ArrayBufferLike; partNumber: number; isLast?: boolean }) => Promise<void>,
  ) => Promise<void>;
  acl?: 'public-read' | 'authenticated-read';
  contentType?: string;
  contentEncoding?: 'gzip';
}

这些参数的设计意图是:

  • fileName: 分片最终合并时呈现的名字

  • id :同名文件可能实际并不同,可以使用 hash 值来区分

  • 核心上传逻辑的抽象(getContent 函数):

    • 职责:负责异步地生成或获取每一个文件分片(比如从本地文件中读取一块数据)

    • 不直接接收文件内容,而是接收一个回调函数 uploadSlice 作为参数。

      • uploadSlice 的职责是:负责异步地将这一个分片的数据(content)和它的序号(partNumber)发送到服务器。
  • 可选的文件属性(HTTP 头部相关):

  • contentType?: string: 可选。指定文件的 MIME 类型(例如 'image/jpeg''application/pdf')。这在云存储中很重要,它会影响文件被访问时的 Content-Type 响应头。

  • contentEncoding?: 'gzip': 可选。指明文件内容是否(或如何)被压缩的。在这里,它明确只支持 'gzip',意味着如果提供了这个选项,上传的内容会被进行独立分片压缩

2.2.1 核心功能实现

📤 单个分片上传

uploadSlice 函数实现逻辑如下:

  1. 通过 FileClient 获取预签名 URL
  2. 使用 fetch API 将分片内容上传到该 URL
  3. 获取 ETag,并返回上传结果
export const uploadSlice = async ({ id, fileName, partNumber, content, uploadId }: UploadSliceParams) => {
  const { uploadUrl: presignedUrl } = await FileClient.getSlicePresignedUrl({
    id,
    fileName,
    partNumber,
    uploadId,
  });

  const uploadRes = await fetch(presignedUrl, {
    method: 'PUT',
    body: content,
  });
  const etag = uploadRes.headers.get('etag');
  if (!etag) throw new Error('Upload failed');
  return {
    ETag: etag,
    PartNumber: partNumber,
  };
};

🔁 分片上传流程控制

uploadSliceFile 实现完整上传逻辑:

  1. 创建上传任务,获取 uploadId
  2. 若返回完整 URL(如小文件无需分片),则直接返回
  3. 调用 getContent 回调,获取各分片内容并上传
  4. 对失败的分片进行重试
  5. 所有分片上传完成后,调用接口合并分片
  const uploadTask = await FileClient.createSliceUpload({
    fileName,
    id,
    acl,
    contentEncoding,
    contentType,
  });
  
  if (uploadTask.url) {
    return uploadTask.url; // 代表这个 id 的文件实际上已经上传过了
  }
  
  const { uploadedParts = [] } = uploadTask;
  const uploadId = uploadTask.uploadId as string;

  const parts: { PartNumber: number; ETag: string }[] = [...(uploadedParts as { PartNumber: number; ETag: string }[])];
  
  await getContent(async ({content,isLast})=>{
     ...
     const part = await uploadSlice({
         content: new Blob([content]),
         partNumber: currentPartNumber,
         uploadId,
         id,
         fileName,
     });
     parts.push(part);
  })
  
  
  return FileClient.completeSliceUpload(...)

❗ 错误处理与重试机制

  • 最大重试次数:MAX_RETRY_TIMES = 3
  • 重试延迟时间:RETRY_DELAY = 1000ms
  • 若分片上传失败,则按策略重试
  • 合并上传前需校验所有分片是否上传成功

🔄 分片去重处理

合并前对已上传分片进行去重:

  1. 按分片序号排序
  2. 使用 Set 记录已处理的分片编号
  3. 构建唯一的分片列表

2.2.2 使用示例

虽然咋一看有些奇怪,但这种方式对于流式上传支持度更好,且在普通场景也同样适用。如下边这份代码是普通文件的上传 demo

// 示例:上传一个大文件
const fileId = 'unique-file-id';
const fileName = 'large-file.mp4';
const file = /* 获取文件对象 */;
const chunkSize = 5 * 1024 * 1024; // 每片5MB
const chunks = Math.ceil(file.size / chunkSize);

const fileUrl = await uploadSliceFile({
  fileName,
  id: fileId,
  getContent: async (uploadSlice) => {
    for (let i = 0; i < chunks; i++) {
      const start = i * chunkSize;
      const end = Math.min(file.size, start + chunkSize);
      const chunk = file.slice(start, end);

      await uploadSlice({
        content: chunk,
        partNumber: i + 1, // 分片编号从1开始
      });
    }
  },
});

console.log('文件上传成功,访问地址:', fileUrl);

3 进阶:分块 GZIP 压缩

我们的日志,其以字符串的形式保存,上传时,最终也是要上传成一份文本型文件。

所以,我们可以考虑在上传前进行压缩,以进一步减少上传时的体积——这个过程中,我们可以考虑使用 gzip、brotli、zstd 等算法。

从兼容性考虑 💭,现在 Web 浏览器支持率最高的算法是 gzip 和 brotli 算法。但 brotli 的原理决定了我们可能很难完整发挥出 brotli 算法的效果。

原因有几个。

第一个致命的原因是,brotli(RFC 7932) 是一种 raw stream 格式,它的数据流由一个或多个“元块”(Meta-Block) 组成。流中的最后一个元块会包含一个特殊的 ISLAST 标志位,它相当于一个「文件结束符」

当我们单独压缩每一个分散的文本片段时:

  • 文本A -> 片段A.br (最后一个元块包含 ISLAST=true)
  • 文本B -> 片段B.br (最后一个元块包含 ISLAST=true)
  • 文本C -> 片段C.br (最后一个元块包含 ISLAST=true)
  • ...

当我们把它们合并在一起时(例如通过 cat A.br B.br C.br > final.br),我们得到的文件结构是: [A的数据... ISLAST=true] [B的数据... ISLAST=true] [C的数据... ISLAST=true]

当一个标准的 Brotli 解码器(比如浏览器)读取这个 final.br 文件时:

  1. 解码器开始读取 [A的数据...]
  2. 解码器读取到 A 的最后一个元块,看到了 ISLAST=true 标志。
  3. 解码器立即停止解码,因为它认为流已经结束了。
  4. [B的数据...][C的数据...] 会被完全忽略,当成文件末尾的“垃圾数据”。

最终结果 我们只能成功解压出 文本A,所有后续的文本内容都会丢失。

——即便我们手动将 IS_LAST 修改正确,但「独立压缩」会导致另一个严重问题——压缩率的极大损失。

因为 br 的压缩过程中,需要先建立一个滑动窗口字典。而如果我们对每一个分片都进行压缩,br 实际上需要为每一个分片建立一个字典。

这意味着这个过程中,最核心的字典不断被重置,br 压缩器丢失了用于判断内部重复的关键工具, 进而会导致压缩率极大的下降。

而对于 gzip 来讲,虽然 gzip body 采用的 deflate 算法同样需要字段,但其窗口大小只有 32KB(br 则是 4-16MB),而我们单个分片单最小大小即是 %MB,所以对于 gzip 来说,分成 5MB 再压缩还是 500MB 直接压缩区别并不大。

所以,我们选择 gzip 来做分块压缩。

Gzip 协议是一种文件格式,它充当一个“容器”。这个容器包裹了使用 DEFLATE (RFC 1951) 算法压缩的数据块,并为其添加了元信息和校验和,以确保文件的完整性和可识别性。

一个 gzip 文件由三个核心部分组成:

  1. Header (头部) :识别文件并提供元信息。
  2. Body (主体) :包含 DEFLATE 压缩的数据流。
  3. Footer (尾部) :提供数据完整性校验。

这意味着,我们进行分块压缩时,可以通过手动创建 header + body + footer 的方式进行分块压缩。

3.1 HEADER & FOOTER

头部至少有 10 个字节。

偏移量 (字节) 长度 (字节) 字段名 固定值 / 描述
0 1 ID1 0x1f (或 31)。这是识别 gzip 文件的“魔术数字”第一部分。
1 1 ID2 0x8b (或 139)。“魔术数字”第二部分。
2 1 CM 0x08 (或 8)。表示压缩方法 (Compression Method) 为 DEFLATE
3 1 FLG 标志位 (Flags)。这是一个极其重要的字节,它的每一位都代表一个布尔值,用于控制是否存在“可选头部”。
4 4 MTIME 文件的最后修改时间 (Modification Time),以 4 字节的 Unix 时间戳格式存储。
8 1 XFL 额外标志 (Extra Flags)。通常用于指示 DEFLATE 压缩器使用的压缩级别(例如 0x02 = 最高压缩率,0x04 = 最快压缩率)。
9 1 OS 操作系统 (Operating System)。0x03 = Unix, 0x00 = Windows/FAT, 0xFF = 未知。

其中的核心部分是 FLG,即标志位。这是头部第 4 个字节 (偏移量 3),我们需要按位 (bit) 来解析它:

Bit (位) 掩码 (Hex) 字段名 描述
0 (最低位) 0x01 FTEXT 如果置 1,表示文件可能是 ASCII 文本文件(这只是一个提示)。
1 0x02 FHCRC 如果置 1,表示头部包含一个 2 字节的头部校验和 (CRC-16)
2 0x04 FEXTRA 如果置 1,表示头部包含一个扩展字段 (extra field)
3 0x08 FNAME 如果置 1,表示头部包含原始文件名
4 0x10 FCOMMENT 如果置 1,表示头部包含注释
5 0x20 RESERVED 保留位,必须为 0。
6 0x40 RESERVED 保留位,必须为 0。
7 0x80 RESERVED 保留位,必须为 0。

然后,根据 FLG 标志位的设置,紧跟在 10 字节固定头部后面的,可能会按顺序出现以下字段:

  • FEXTRA (如果 FLG & 0x04 为真):

    • XLEN (2 字节): 扩展字段的总长度 N。
    • EXTRA (N 字节): N 字节的扩展数据。
  • FNAME (如果 FLG & 0x08 为真):

    • 原始文件名,以 NULL ( 0x00 ) 字节结尾的 C 风格字符串。
  • FCOMMENT (如果 FLG & 0x10 为真):

    • 注释,以 NULL ( 0x00 ) 字节结尾的 C 风格字符串。
  • FHCRC (如果 FLG & 0x02 为真):

    • 一个 2 字节的 CRC-16 校验和,用于校验整个头部(包括所有可选部分)的完整性。

我们的话,我们需要写入 filename,所以转换成代码,就是如下的实现:

/**
 * 生成标准 GZIP Header(10 字节)
 * 符合 RFC 1952 规范。
 * 可用于拼接 deflate raw 数据生成完整 .gz 文件。
 */
/**
 * 生成包含文件名的标准 GZIP Header
 * @param {string} filename - 要嵌入头部的原始文件名
 */
export function createGzipHeader(filename: string): Uint8Array {
  // 1. 创建基础的10字节头部,并将Flags位设置为8 (FNAME)
  const header = new Uint8Array([
    0x1f,
    0x8b, // ID1 + ID2: magic number
    0x08, // Compression method: deflate (8)
    0x08, // Flags: 设置FNAME位 (bit 3)
    0x00,
    0x00,
    0x00,
    0x00, // MTIME: 0
    0x00, // Extra flags: 0
    0x03, // OS: 3 (Unix)
  ]);

  // 动态设置 MTIME
  const mtime = Math.floor(Date.now() / 1000);
  header[4] = mtime & 0xff;
  header[5] = (mtime >> 8) & 0xff;
  header[6] = (mtime >> 16) & 0xff;
  header[7] = (mtime >> 24) & 0xff;

  // 2. 将文件名字符串编码为字节
  const encoder = new TextEncoder(); // 默认使用 UTF-8
  const filenameBytes = encoder.encode(filename);

  // 3. 拼接最终的头部
  // 最终头部 = 10字节基础头 + 文件名字节 + 1字节的null结束符
  const finalHeader = new Uint8Array(10 + filenameBytes.length + 1);

  finalHeader.set(header, 0);
  finalHeader.set(filenameBytes, 10);
  // 最后一个字节默认为0,作为null结束符

  return finalHeader;
}

footer 则相对简单一些,尾部是固定 8 字节的块,由 CRC32 和 ISIZE 组成:

偏移量 长度 (字节) 字段名 描述
0 4 CRC-32 原始未压缩数据的 CRC-32 校验和。
4 4 ISIZE 原始未压缩数据的大小 (字节数)。由于它只有 4 字节,gzip 文件无法正确表示大于 4GB 的文件(解压后的大小)。

这两个值是 gzip 压缩过程中需要从整个文件角度计算的信息,由于两者均可以增量计算,问题不大。(crc32 本身计算量不大,推荐直接使用 sheetjs 库就行)

这样的话,我们就得到了这样的代码:

export function createGzipFooter(crc32: number, size: number): Uint8Array {
  const footer = new Uint8Array(8);
  const view = new DataView(footer.buffer);
  view.setUint32(0, crc32, true);
  view.setUint32(4, size % 0x100000000, true);
  return footer;
}

3.2 BODY

对我们来说,中间的 raw 流是最麻烦的。

gzip body 中的 DEFLATE 流 (RFC 1951) 并不是一个单一的、连续的东西,它本身就有一套非常重要的“特殊规则”。

DEFLATE 流的真正结构是由一个或多个数据“块” (Block) 拼接而成的。

gzip压缩器在工作时,会根据数据的情况,智能地将原始数据分割成不同类型的“块”来处理。它可能会先用一种块,然后再换另一种,以达到最佳的压缩效果。

DEFLATE 流中的每一个“块”,都必须以一个 3-bit (比特) 的头部开始。这个 3-bit 的头部定义了这个块的所有规则。

这 3 个 bit (比特) 分为两部分:

  1. BFINAL (1-bit): “最后一块”标记

    • 1: 这是整个 DEFLATE 流的最后一个块。解压器在处理完这个块后,就应该停止,并去寻找 gzip 的 Footer (CRC-32 和 ISIZE)。
    • 0: 后面还有更多的块,请继续。
  2. BTYPE (2-bits): “块类型”

    • 这 2 个 bit 决定了紧随其后的整个块的数据要如何被解析。

BTYPE 字段有三种可能的值,每一种都代表一套完全不同的压缩规则:

****规则 1:BTYPE = 00 (无压缩块) 压缩器在分析数据时,如果发现数据是完全随机的(比如已经压缩过的图片、或加密数据),它会发现压缩后的体积反而变大了。

  • 此时,它会切换到 00 模式,意思是:“我放弃压缩,直接原文存储。”

  • 结构:

    1. (BFINAL, 00) 这 3-bit 头部。
    2. 跳到下一个字节边界 (Byte-alignment)。
    3. LEN (2 字节): 声明这个块里有多少字节的未压缩数据(长度 N)。
    4. NLEN (2 字节): LEN 的“反码”(NOT LEN),用于校验 LEN 是否正确。
    5. N 字节的原始数据(原文照搬)。

规则 2:BTYPE = 01 (静态霍夫曼压缩)

  • 这是“标准”规则。 压缩器使用一套固定的、在 RFC-1951 规范中预先定义好的霍夫曼树(Huffman Tree)来进行压缩。

  • 这套“静态树”是基于对大量英语文本统计分析后得出的最佳通用编码表(例如,'e'、'a'、' ' 的编码非常短)。

  • 优点: 压缩器不需要在数据流中包含霍夫曼树本身,解压器直接使用它内置的这套标准树即可。这节省了头部空间。

  • 缺点: 如果你的数据不是英语文本(比如是中文或代码),这套树的效率可能不高。

  • 结构:

    1. (BFINAL, 01) 这 3-bit 头部。
    2. 紧接着就是使用“静态树”编码的 LZ77 + 霍夫曼编码 的数据流。
    3. 数据流以一个特殊的“块结束”(End-of-Block, EOB) 符号(静态树中的 256 号符号)结尾。

规则 3:BTYPE = 10 (动态霍夫曼压缩)

  • 这是“定制”规则,也是压缩率最高的规则。

  • 压缩器会先分析这个块的数据,统计出所有字符的准确频率,然后为这个块“量身定做”一套最优的霍夫曼树。

  • 优点: 压缩率最高,因为它完美贴合了当前数据块的特征(比如在压缩 JS 时,{ } ( ) . 的编码会变得极短)。

  • 缺点: 压缩器必须把这套“定制树”本身也压缩后,放到这个块的开头,以便解压器知道该如何解码。这会占用一些头部空间。

  • 结构:

    1. (BFINAL, 10) 这 3-bit 头部。
    2. 一个“定制霍夫曼树”的描述信息(这部分本身也是被压缩的)。
    3. 紧接着是使用这套“定制树”编码的 LZ77 + 霍夫曼编码 的数据流。
    4. 数据流以一个特殊的“块结束”(End-of-Block, EOB) 符号(定制树中的 256 号符号)结尾。

——不过,于我们而言,我们先通过静态霍夫曼压缩即可。

这个过程中,我们需要借助三方库,目前浏览器虽然支持 CompressionStream API,但并不支持我们进行精确流控制。

import pako from 'pako';

export async function compressBufferRaw(buf: ArrayBufferLike, isLast?: boolean): Promise<ArrayBufferLike> {
  const originalData = new Uint8Array(buf);

  const deflater = new pako.Deflate({ raw: true });
  deflater.push(originalData, isLast ? pako.constants.Z_FINISH : pako.constants.Z_SYNC_FLUSH);
  if (!isLast) {
    deflater.onEnd(pako.constants.Z_OK);
  }
  const compressedData = deflater.result;
  return compressedData.buffer;
}

我们用一个示例来表示一个完整 gzip 文件的话,方便理解。假设我们压缩一个叫 test.txt 的文件,它的 Gzip 文件 test.txt.gz 在十六进制编辑器中可能如下所示:

Offset  Data
------  -------------------------------------------------------------
0000    1F 8B         (ID1, ID2: Gzip 魔术数字)
0002    08            (CM: DEFLATE)
0003    08            (FLG: 0x08 = FNAME 标志位置 1)
0004    XX XX XX XX   (MTIME: 4 字节时间戳)
0008    04            (XFL: 最快压缩)
0009    03            (OS: Unix)

(可选头部开始)
000A    74 65 73 74   (t e s t)
000E    2E 74 78 74   (. t x t)
0012    00            (FNAME: NULL 终结符)

(Body 开始)
0013    ED C0 ...     (DEFLATE 压缩流开始...)
...
...     ...           (...此块数据流的末尾包含一个 EOB 符号...)
                      (... DEFLATE 压缩流结束)

(Footer 开始)
XXXX    YY YY YY YY   (CRC-32: 原始 test.txt 文件的校验和)
XXXX+4  ZZ ZZ ZZ ZZ   (ISIZE: 原始 test.txt 文件的大小)

至此,我们完成了一套社区前列的分片上传方案。S3 将所有上传的部分按序合并后,在S3上形成的文件结构是:[Gzip Header][Deflate_Chunk_1][Deflate_Chunk_2]...[Deflate_Last_Chunk][Gzip Footer] 这个拼接起来的文件是一个完全合法、可流式解压的 .gz 文件。

4 性能 & 对比

为了验证该方案(Smart S3 Gzip)的实际效果,我们构建了一个基准测试环境,将本文方案与「普通直传」及「传统前端压缩上传」进行全方位对比。

4.1 测试环境

  • 测试文件:1GB Nginx Access Log (纯文本)
  • 网络环境:模拟家用宽带上行 50Mbps (约 6.25MB/s)
  • 测试设备:MacBook Pro (M1 Pro), 32GB RAM
  • 浏览器:Chrome 143

4.2 核心指标对比

核心指标 方案 A:普通直传 方案 B:前端整体压缩 方案 C:本文方案 (分片 Gzip 流)
上传总耗时 ~165 秒 ~45 秒 (但等待压缩很久) ~38 秒 (边压边传)
首字节发送时间 0 秒 (立即开始) 30 秒+ (需等待压缩完成) 0.5 秒 (首个分片压缩完即发)
峰值内存占用(计算值) 50MB (流式) 2GB+ (需读入全量文件) 100MB (仅缓存并发分片)
网络流量消耗 1GB ~120MB ~121MB (略多出的 Header 开销可忽略)
客户端 CPU 负载 极低 (<5%) 单核 100% (持续一段时间,可能 OOM) 多核均衡 (并发压缩,利用率高)

4.3 深度解析

🚀 1. 速度提升的秘密:流水线效应

在方案 B(整体压缩)中,用户必须等待整个 1GB 文件在本地压缩完成,才能开始上传第 1 个字节。这是一种「串行阻断」模型。 而本文方案 C 采用了「流水线(Pipeline)」模型:压缩第 N 个分片的同时,正在上传第 N-1 个分片。 对于高压缩率的文本文件(通常压缩比 5:1 到 10:1),网络传输往往比本地 CPU 压缩要慢。这意味着 CPU 的压缩几乎是“免费”的,因为它掩盖在了网络传输的时间里。

💰 2. 成本分析:不仅是快,还省钱

AWS S3 的计费主要包含存储费和流量费。

  • 存储成本:1GB 的日志存入 S3,如果未压缩,每月存储费是压缩后的 5-10 倍。虽然 S3 本身很便宜,但对于 PB 级日志归档,这笔费用惊人。
  • 传输加速成本:如果使用了 S3 Transfer Acceleration,费用是按流量计算的。压缩后上传意味着流量费用直接打一折。

🛡️ 3. 内存安全性

方案 B 是前端的大忌。试图将 1GB 文件读入 ArrayBuffer 进行整体 gzip 压缩,极其容易导致浏览器 Tab 崩溃(OOM)。本文方案将内存控制在 分片大小 * 并发数 (例如 5MB * 5 = 25MB) 的安全范围内,即使上传 100GB 文件也不会爆内存。

4.4 适用场景与局限性

✅ 强烈推荐场景:

  • 日志归档 / 数据备份:CSV, JSON, SQL Dump, Log 文件。压缩率极高,收益巨大。
  • 弱网环境:上传带宽受限时,压缩能显著减少等待时间。

❌ 不推荐场景:

  • 已经压缩的文件:MP4, JPG, ZIP, PNG。再次 Gzip 几乎无压缩效果,反而浪费 CPU。
  • 超低端设备:如果用户的设备是性能极差的老旧手机,CPU 压缩速度可能低于网络上传速度,反而成为瓶颈。建议在 SDK 增加 navigator.hardwareConcurrency 检测,自动降级。

5 结语

通过深入理解 HTTP、AWS S3 协议以及 Gzip 的二进制结构,我们打破了“压缩”与“分片”不可兼得的魔咒。这套系统目前已在我们内部的日志回放平台稳定运行,有效减少文件上传时长。

有时候,技术的突破口往往就藏在那些看似枯燥的 RFC 文档里。希望这篇“硬核”的实战总结,能给你带来一些启发。

丧心病狂!在浏览器全天候记录用户行为排障

1 前言

QA:“bug, 你把这个 bug 处理一下。”

我:“这个 bug 复现不了,你先复现一下。”

QA:“我也复现不了。”

(PS: 面面相觑脸 x 2)

众所周知,每个公司每个项目都可能存在偶现的缺陷,毋庸置疑,这为问题的定位和修复带来了严重的阻碍。

要解决这个问题,社区方案中常常依赖 datadog、sentry 等问题记录工具,但这些工具存在采样率限制或依赖错误做信息收集,很难做到 100% 的日志记录。

emoji_002.png

偶然间,我看到了 pagespy,它符合需求,但又不完全符合,好在调研下来,我们只要魔改一番,保留其基础的日志能力,修改其存储方式,就能得到一个能做全天候日志采集的工具。

那么,目标明确:

  • 实现全时段用户行为录制与回放
  • 最小化对用户体验的影响
  • 确保数据安全与隐私保护
  • 与现有系统(如 intercom )无缝集成

2 SDK 设计

目前 pagespy 设计目标和我们预期并不一致,并不能开箱即用。pagespy 的方案不满足我们需求的点在于:

  1. 没有持久化能力,内存存储,单次录制不对数据做导出则数据清空。
  2. pagespy 的设计理念中。数据是需要显式由用户手动导出的,但我们是需要持续存储数据。

经过对 pagespy 的源码解析以及文档阅读,整理出来其中分支的 OSpy(离线版 pagespy 的数据走向如下):

image.png

我们可以通过 inject 的形式,把这两个能力代理到我们的逻辑中。

image.png

样式上,则通过插入一段 style 强制将 dom 样式隐藏。

  document.head.insertAdjacentHTML(
    'beforeend',
    `<style>
    #o-spy {
      display: none;
    }
    </style>`,
  );

至此,我们已经基本脱离了 pagespy 的数据 in & out 逻辑,所有数据都由我们来处理,包括数据存储也需要我们重新设计。

2.1 日志存储方案

✅ 确定日志存储方案。需要注意避免大量日志将用户的电脑卡死。

✅ pagespy 的设计理念中。数据是需要显式由用户手动导出的,但我们是需要持续存储数据。

✅ pagespy 为了防止爆内存引入了时间上限等因素,会时不时清除数据(rrweb 存在非常重要的首屏帧,缺少该帧后续都无法渲染成功),这会导致以单个浏览器标签作为切片的设计逻辑被迫中断,会对我们的逻辑带来负面影响。

为了实现全时段存储的目标,经评估除了 indexDB 之外没有其他很好的存储方案可以满足我们的大容量需求。在此,决定引入 dexie 进行数据库管理。

import type { EntityTable } from 'dexie';
import Dexie from 'dexie';

const DB_NAME = 'SpyDataHarborDB';

export class DataHarborClient {
  db: DBType;
  constructor() {
    this.db = new Dexie(DB_NAME) as DBType;
    this.db.version(1).stores({
      logs: '++id,[tabId+timestamp],tabId, timestamp',
      metas: '++id,tabId,startTime,endTime',
    });
  }
}

export const { db } = new DataHarborClient();

我们将日志以浏览器标签页为维度进行拆分,引入了 tabId 的概念。并设计了两个表,一个用于存储日志,一个用于存在 tab 的基本信息。

type DBType = Dexie & {
  logs: EntityTable<{
    id?: number;
    tabId: string;
    timestamp: number;
    data: string;
  }>;
  metas: EntityTable<{
    id?: number;
    tabId: string;
    size: number;
    startTime: number;
    endTime: number;
  }>;
};

这意味着,从 pagespy 得到的数据只需要直接入库,我们在每次入库后做一次日志清理,即可实现一个基本的存储系统。

  async addLog(data: CacheMessageItem) {
    const now = new Date();

    const dataStr = JSON.stringify(data);

    await db.logs.add({
      tabId: this.tabId,
      timestamp: now.getTime(),
      data: dataStr,
    });

    await db.transaction('rw', ['metas'], async (tx) => {
      const meta = await tx.metas.get({
        tabId: this.tabId,
      });
      if (meta) {
        meta.size += dataStr.length;
        meta.endTime = now.getTime();
        await db.metas.put(meta);
        return meta;
      } else {
        await db.metas.add({
          tabId: this.tabId,
          size: dataStr.length,
          startTime: now.getTime(),
          endTime: now.getTime(),
        });
      }
    });
  }

在我们完成日志入库之后,额外需要考虑的是持续直接入库的性能损耗。 经测试,通过 worker 进行操作与直接在主线程进行操作,对主线程的耗时影响对比表格如下(基于 performance.now()):

操作方式 峰值 最低值 中位数 平均值
worker + insert 5.3 ms 0ms 0.1ms 0.31ms
直接 insert 149.5 ms 0.4ms 3.6ms 55.29ms

所以最终决策将数据库操作转移到 worker 中实现——但这又反应了一点问题,目前 pagespy 的入库数据是序列化后的字符串,并不能很好地享受主线程和 worker 线程之间通过 transfer 传输的性能优势。

2.2 安全和合规问题

目前可知,我们的方案先天就存在较严重的合规问题 🙋,这体现在:

  1. pagespy 会保存一些隐秘的 storage、cookie 数据到 indexedDB 中,有一定安全风险。
  2. pagespy 基于 rrweb ⏺️ 录制页面,用户在电脑上的行为和信息可能被记录。(如 PII 数据)

第一个问题,我们可以考虑直接基于 Pagespy 来记录,其实际上提供了 API 允许我们自行决定要抛弃哪些信息。

使用时,类似于:

    network: (data) => {
      if (['fetch', 'xhr'].includes(data.requestType)) {
        data.responseHeader?.forEach((item) => {
          if (item[0] === 'set-cookie') {
            item[1] = obfuscate(item[1]);
          }
        });
        return true;
      }
      return true;
    },

image.png 第二个问题,我们应考虑基于 rrweb 的默认隐私策略来做处理,rrweb 在 sentry、posthog 中都有使用,都是基于默认屏蔽规则来允许,所以我们使用默认屏蔽规则,其他库的隐私合规也相当于一起做了。

所以,我们需遵循以下规则(rrweb 默认屏蔽规则)修改 Web 端,而不是 SDK:

  • 具有该类名的元素.rr-block不会被记录。它将被替换为具有相同尺寸的占位符。
  • 具有该类名的元素.rr-ignore将不会记录其输入事件。
  • 具有类名的元素.rr-mask及其子元素的所有文本都将被屏蔽。和 block 的区别是,只会屏蔽文本,不会直接替换 dom 结构(也就是背景颜色之类的会保留)
  • input[type="password"]将被默认屏蔽。

根据元素是否包含“用户输入能力”,分为 3 种处理方式:

  • 1️⃣ 包含输入能力(如 input, textarea,canvas 可编辑区域)

    • 目的:既屏蔽用户的输入行为,也屏蔽输入内容
    • 处理方式:添加 rr-ignorerr-block 两个类
    • 效果:

image.png

  • 2️⃣ 不包含输入能力(如纯展示类的文本)

    • 目的:保留结构,隐藏文本内容,避免泄露隐私
    • 处理方式:添加 rr-mask 类,将文本进行混淆显示
    • 效果:

image.png

  • 3️⃣ 图片、只读 canvas 包含隐私信息(如签名)

    • 目的:隐藏内容
    • 处理方式:添加 rr-block

2.3 日志获取和处理

在上述流程中,我们设计了基于浏览器标签页的存储系统,但由于 rrweb 和 ospy 的设计,我们仍有两个问题待解决:

  1. ospy 中的 meta 帧只在 download 时获取,并需要是 logs 的最后一帧。
  2. rrweb 存在特殊限制,即必须存在首 2 帧,否则提取出来的日志无法显示页面。

这两个问题我们需要特殊处理,针对 meta 帧的情况,首先要知道,meta 帧包含了客户端信息等数据:

image.png

image.png

这部分信息虽然相比之下不是那么重要,但在特定场景中非常有用,nice to have。在此前提下,由于 ospy 未提供对外函数,我们需要自行添加该帧。目前,meta 帧会在 spy 初始化时自动插入,然后在读取时排序到尾部。

// 这个其实是 spy 的源码
export const minifyData = (d: any) => {
  return strFromU8(zlibSync(strToU8(JSON.stringify(d)), { level: 9 }), true);
};

export const getMetaLog = () => {
  return minifyData({
    ua: navigator.userAgent,
    title: document.title,
    url: window.location.href,
    startTime: 0,
    endTime: 0,
    remark: '',
  });
};

第二个问题相比之下更加致命,但解决起来又异常简单。rrweb 的机制决定了我们在导出的时候必定要查询出第一二帧,我们在获取日志时需要特殊处理:

  1. 获取用户指定日期范围内的日志的 tabId。
  2. 基于 tabId 筛查出所有日志,筛查出 < endTime 的所有日志。
async getTabLogs({ tabId, end }: { tabId: string; end: number }) {
    // 日志获取逻辑
}

(如你所见,获取日志阶段 start 直接 gank 没了)

此外,由于持续存储特性,读取日志时会面临数据量过大的问题。例如,8 分钟连续操作导出的日志约 17MB,一小时约 120MB。按照平均每小时录制数据量估算,静态浏览约 2 - 5MB,普通交互约 50MB,高频交互约 100MB。以单个用户每日使用 8 小时计算,平均用户约 400MB / 天,重度用户约 800MB / 天。基于 14 天保留策略,单用户最大存储空间约为 12GB。

这意味着如果用户选择的时间范围较大,传统读取流程可能读取 10GB+ 日志到内存,这显然会导致浏览器内存溢出。

为避免读取大量日志导致浏览器内存溢出,我们采用分片式读取。核心思想是将指定 tab 的日志数据按需 “分片提取”,通过回调逐步传输给调用方,确保高效、稳定地处理大体积日志的读取与传输:

  1. 读取元信息 (meta):

    • 通过 tabIddb.metas 获取对应日志的元信息(如日志总大小)。
  2. 判断是否需要分片:

    • 如果日志总大小小于阈值 MIN_SLICE_CHUNK_SIZE一次性读取所有日志,拼接成完整 JSON,再调用 callback 发送。
  3. 大文件分片处理逻辑:

    • 根据日志总大小计算合适的 chunkSize,从而决定分片数量 chunkCount
    • 每次读取一部分日志数据(受限于计算出的 limit),拼接为 JSON 片段,通过 callback 逐步传出。
    • 每片都使用 Comlink.transfer() 进行内存零拷贝传输,提高性能。
  4. 合并与补充 meta 信息:

    • 如果日志数据中有 meta 类型数据(携带一些压缩信息),在最后一片中进行处理与拼接,保持语义完整。
  5. 进度追踪与标记:

    • 每一片传输都附带 progresspartNumber,便于前端追踪处理进度。
  async getTabLogs(
    {
      tabId,
      end,
    }: {
      tabId: string;
      end: number;
    },
    callback: (log: { content: Uint8Array; progress: number; partNumber: number }) => void | Promise<void>,
  ) {
  
    ...

    const totalSize = meta.size + BUFFER_SIZE;
    // 根据 totalSize、MAX_SLICE_CHUNK、MIN_SLICE_CHUNK_SIZE 计算出最佳分片大小
    const chunkSize = Math.max(Math.min(totalSize / MAX_SLICE_CHUNK, MIN_SLICE_CHUNK_SIZE), MIN_SLICE_CHUNK_SIZE);

    const chunkCount = Math.ceil(totalSize / chunkSize);

    let offset = 0;
    const count = await db.logs
      .where('tabId')
      .equals(tabId)
      .and((log) => log.timestamp <= end)
      .count();

    const limit = Math.max(1, Math.ceil(count / chunkCount / 3));

    let metaData: string | null = null;

    let startTime = 0;
    let endTime = 0;

    let preLogStr = '';
    let progressContentSize = 0;
    let partNumber = 1;
    while (offset <= count) {
      try {
        const logs = await db.logs
          .where('tabId')
          .equals(tabId)
          .and((log) => log.timestamp <= end)
          .offset(offset)
          .limit(limit)
          .toArray();

        let baseStr = preLogStr;
        if (offset > 0) {
          baseStr += ',';
        } else if (offset === 0) {
          baseStr += '[';
        }

        endTime = logs?.[logs.length - 1]?.timestamp ?? endTime;
        if (offset === 0) {
          startTime = logs?.[0].timestamp ?? 0;
        }

        offset += logs.length;

        const logData = logs.map((log) => log.data).filter((log) => log !== '"PERIOD_DIVIDE_IDENTIFIER"');
        ...

        const logsStr = logData.join(',');
        baseStr += logsStr;

        if (offset === count) {
          if (!metaData) {
            await callback({
              content: transfer(baseStr + ']'),
              progress: 1,
              partNumber,
            });
          } else {
            const metaJson = JSON.parse(metaData);
            const parseMetaData = parseMinifiedData(metaJson.data);
            const metaMinifyData = minifyData({
              ...parseMetaData,
              startTime,
              endTime,
            });
            const metaStr = JSON.stringify({
              type: 'meta',
              timestamp: endTime,
              data: metaMinifyData,
            });
            await callback({
              content: transfer(baseStr + ',' + metaStr + ']'),
              progress: 1,
              partNumber,
            });
          }
          break;
        }

        progressContentSize += baseStr.length;
        const progress = Math.min(0.99, progressContentSize / totalSize);

        // 如果 size < minSize,那么就继续获取
        if (baseStr.length < MIN_SLICE_CHUNK_SIZE) {
          preLogStr = baseStr;
          continue;
        }

        preLogStr = '';
        await callback({
          content: transfer(baseStr),
          progress,
          partNumber,
        });
        partNumber++;
      } catch (error) {
        console.log(error);
        break;
      }
    }
  }

3 工作流设计

3.1 👼 基础工作流

我们公司采用 intercom 和外部客户沟通,用户可以在网页右下角的 intercom iframe 中和客服沟通。

image.png

所以,如果有办法将整个日志流程合并到目前的 intercom 流程中,不仅贴合目前的业务情况,而且不改变用户习惯。

通过调研,可以确定以下方案:

  1. CS 侧配置默认时间范围,需要 POST /configure-card 进行表单填写,填写后表单会在下一步被携带到 payload 中。
  2. CS 侧在发送时,会 POST /initialize接口(由自有后端提供),接口需返回 canvas json 数据。如:
{
  canvas: {
    content: {
      components: [
        {
          type: "text",
          text: "*Log Submission*",
          style: "header",
        },
        {
          type: "button",
          label: "Select logs",
          style: "primary",
          id: "submit_button",
          action: {
            type: "sheet",
            url: "xxxxxx",
          },
        },
      ],
    },
  },
}
  1. 发送后,用户点击 sheet 按钮可以跳转到前端,但需注意,该请求为 POST 请求。
  2. 用户填写完表单,提交时可以直接请求后端接口,也可以由 intercom 服务端向后端发起 POST 请求。
  3. 如期望在提交后修改消息状态,则必须在上一步执行【由 intercom 服务端向后端发起 POST 请求】(推荐,最完整的 flow),此时后端需返回 canvas json,后端同步触发逻辑,添加 note 到 intercom 页面,方便 CS 创建 jira 单时携带复现链接

Editor _ Mermaid Chart-2025-05-09-062511.png

3.2 ⚠️ 增强工作流

在我们上述 flow 中,需要获取用户授权,由用户操作触发下载和上传日志的过程,但实际上有比较刑的方案。

具体 flow 如图:

image.png

该方案的整体优势是:

  1. 无需 CS 介入,无需修改 CS 流程。
  2. 用户对日志上传感知力度小

换句话说,隐私合规风险较大。

4 工作流技术要点

4.1 😈 iframe 实现

Iframe 指的是 【日志上传 iframe】,对应这一步骤:

image.png

由于 intercom 将基于 POST 请求去调用服务希望得到 html 的限制,这里存在两个问题:

  1. Intercom 使用 POST 请求,则我们的服务需要支持 POST 请求返回 html,目前是不支持的,所以需要解决方案。
  2. 由于我们的 iframe 网页要读取日志,那么 iframe 地址必须和 Web 端同源,但生产的 API 地址和 Web 端不同源。

基本方向上,我们可以通过反向代理的方式实现:

image.png

iframe 的同源限制比预想的还要麻烦一些,由于 intercom 的接入方式是 iframe 嵌套,类似于:A(<https://samesite.com/>)->B(<https://xxxx.com/>)->A(<https://samesite.com>)

这个过程会导致两个跨域限制:

  1. Cookie 的跨域限制,具体表现为用于登录态的 Cookie 由于未显式设置 Samesite: None ,无法被携带进内层网页,进而丢失登录态。
  2. indexedDB 的跨域限制,由于中间多了一层外域,浏览器限制了最里边的网页读取 indexedDB,具体表现为读取到的数据为🈳。

Cookie 的跨域限制通过显式设置 Samesite 可以解决,但进一步地,为了确保安全性,我们需要给网页其他路径添加X-Frame-Options SAMEORIGIN; 防止外域嵌套我们的其他网页。

后者卡了一阵子,最后的解决思路是通过 postMessage 通信的方式变相读取——反正能读取到就行。

  window.top.postMessage(
    {
      type: 'uploadLogs',
      id: topUUID,
      params: {
        start,
        end,
      },
    },
    '*',
  );

(有趣的是,排查过程中发现了 chrome devtools 的缺陷,devtools 里的 document 都指不到最外层,但是实际上 window.top 和 window.parent.parent.parent 都是最外层,具体不细说了)

4.2 🥹 日志安全与上传

日志的格式是 JSON 格式,将其拖拽到 ospy 中即可复原用户浏览器操作记录,一旦泄漏会有极高的安全风险。在此,提出加密方案用于解决该问题。

思路其实很简单:在文件上传前对文件内容进行 AES 加密,对 AES 密钥做 RSA 非对称加密,通过公钥加密,然后将加密后的密钥附加到文件尾。

image.png

其实还可以进一步,我们在写入日志的时候就加密,但这样读取的时候压力会比较大,因为日志是一段一段的,或许我们还需要定制分隔符。

5 总结

好,那么理所当然的,我们应该不会遇到其他卡点卡,方案落地应该是没问题了。但——

Leader: “有个问题,我们没有分片上传”

我: ”Woc? 又要自己写?”

欲知后事如何,且听下回分解。

GDAL 实现投影转换

^ 关注我,带你一起学GIS ^

前言

在GIS开发中,对于数据源首先要确定的第一件事就是坐标系统,这就涉及到坐标转换处理问题。而经常遇到的便是Shapefile数据的投影转换,如何高效、准确的将源数据坐标系转换到目标坐标系是我们需要研究解决的问题。

在之前的文章中讲了如何使用GDAL或者ogr2ogr工具将txt以及csv文本数据转换为Shp格式,本篇教程在之前一系列文章的基础上讲解如何使用GDAL 实现投影转换

如果你还没有看过,建议从以上内容开始。

1. 开发环境

本文使用如下开发环境,以供参考。

时间:2025年

系统:Windows 11

Python:3.11.7

GDAL:3.11.1

2. 数据准备

如下是本文选取的全国省级行政区Shp数据(数据坐标系为4326):

部分景点数据:

3. 投影形式

参考网站:https://epsg.io/4490

坐标定义信息具有多种格式,可以选择符合通用标准的OGC格式,下面以EPSG编码4490,即CGCS2000坐标系为例进行展示。

  • OGC WKT
GEOGCS["China Geodetic Coordinate System 2000",    DATUM["China_2000",        SPHEROID["CGCS2000",6378137,298.257222101,            AUTHORITY["EPSG","1024"]],
        AUTHORITY["EPSG","1043"]],
    PRIMEM["Greenwich",0,        AUTHORITY["EPSG","8901"]],
    UNIT["degree",0.0174532925199433,        AUTHORITY["EPSG","9122"]],
    AUTHORITY["EPSG","4490"]]
  • ESRI WKT
GEOGCS["GCS_China_Geodetic_Coordinate_System_2000",    DATUM["D_China_2000",        SPHEROID["CGCS2000",6378137.0,298.257222101]],
    PRIMEM["Greenwich",0.0],
    UNIT["Degree",0.0174532925199433]]
  • PROJ.4
+proj=longlat +ellps=GRS80 +no_defs +type=crs
  • Proj4js
proj4.defs("EPSG:4490","+proj=longlat +ellps=GRS80 +no_defs +type=crs");
  • GeoServer
4490=GEOGCS["China Geodetic Coordinate System 2000",DATUM["China_2000",SPHEROID["CGCS2000",6378137,298.257222101,AUTHORITY["EPSG","1024"]],AUTHORITY["EPSG","1043"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4490"]]

4. 获取图层坐标系统

使用GetSpatialRef方法可以获取图层坐标参考,若图层缺少投影信息,则返回None

# 获取图层及坐标系统
sourceLayer = shpDs.GetLayer(0)
geomType = sourceLayer.GetGeomType()
sourceSrs = sourceLayer.GetSpatialRef()

也可以使用Geometry对象方法GetSpatialReference获取。

# 获取坐标系统
geom = feature.GetGeometryRef()
sourceSrs = geom.GetSpatialReference()

5. 图层投影转换

5.1. 导入依赖

Shp作为一种矢量数据格式,可以使用矢量库OGR进行处理,用于打开数据源和获取图层。还需要引入osr模块用于坐标定义以及os模块用于判断文件数据路径。

from osgeo import ogr,osr
import os

5.2. 获取数据

定义一个方法LayerProject用于图层投影转换,该方法接收两个参数,一个源数据文件路径sourcePath,一个投影转换文件数据路径projectPath

"""
说明:GDAL 投影转换
参数:
    -sourcePath:源文件Shp数据路径
    -projectPath:投影转换图层数据路径
"""
def LayerProject(sourcePath,projectPath):

按照老规矩添加数据驱动,使用checkFilePath方法检查文件路径是否存在,使用checkDriver判断数据驱动是否正常,之后获取图层及坐标系统。

# 检查文件是否存在
checkFilePath(sourcePath)
checkFilePath(projectPath)

# 获取数据驱动
shpDriver = ogr.GetDriverByName("ESRI Shapefile")

# 检查数据驱动是否正常
checkDriver(shpDriver)

# 获取数据源
shpDs = shpDriver.Open(sourcePath)

# 获取源图层及坐标信息
sourceLayer = shpDs.GetLayer(0)
geomType = sourceLayer.GetGeomType()
sourceSrs = sourceLayer.GetSpatialRef()

# 获取源数据结构
featureDefn = sourceLayer.GetLayerDefn()
fieldCount = featureDefn.GetFieldCount()

文件和数据驱动检查方法定义如下。

"""
说明:检查文件路径是否正常
参数:
    -filePath:文件数据路径
"""
def checkFilePath(filePath):
    if os.path.exists(filePath):
        print(f"{filePath} 文件数据路径存在")
    else:
        print(f"{filePath} 文件数据路径不存在,请检查!")

"""
说明:检查数据驱动是否正常
"""
def checkDriver(driver):
    if driver is None:
        print("数据驱动不可用")
        return False

5.3. 创建投影

使用osr.SpatialReference()创建空间参考,然后将投影信息导入到坐标系统。可以使用如下多种方法:

- ImportFromEPSG(SpatialReference self, int arg)
- ImportFromESRI(SpatialReference self, char ** ppszInput)
- ImportFromProj4(SpatialReference self, char * ppszInput)
- ImportFromUSGS(SpatialReference self, long proj_code, long zone=0, double [15] argin=0, long datum_code=0)
- ImportFromWkt(SpatialReference self, char ** ppszInput)

其中个人觉得最简单方便的还是ImportFromEPSG方法。

5.4. 创建投影图层

使用CreateDataSource方法创建投影数据源和图层,并根据源数据复制要素。创建图层方法CreateLayer第二个参数用于指定数据坐标系,坐标系统可以使用ImportFromEPSG方法定义,只需传入一个EPSG编码。

"""
投影转换操作
"""
# 创建投影数据源
prjDs = shpDriver.CreateDataSource(projectPath)

# 投影坐标对象
srs = osr.SpatialReference()
srs.ImportFromEPSG(4522)

# 创建投影图层
prjLayer = prjDs.CreateLayer("prj_layer",srs,geomType)

# 添加属性结构
for i in range(fieldCount):
    fieldDefn = featureDefn.GetFieldDefn(i)
    prjLayer.CreateField(fieldDefn)

# 写入要素
for feature in sourceLayer:    
    prjLayer.CreateFeature(feature)    

5.5. 导出投影

可以使用以下方法导出投影信息。

- ExportToPrettyWkt(SpatialReference self, int simplify=0)
- ExportToProj4(SpatialReference self)
- ExportToUSGS(SpatialReference self)
- ExportToWkt(SpatialReference self, char ** options=None)
- ExportToXML(SpatialReference self, char const * dialect="")

5.6. 几何投影

使用CoordinateTransformation方法定义转换信息,第一个参数为源数据坐标系,第二个参数为目标投影坐标系,然后调用Geometry对象方法Transform进行坐标转换。

# 源数据坐标参考
sourceSrs = osr.SpatialReference()
sourceSrs.ImportFromEPSG(4326)
print("源数据坐标系名称:",sourceSrs.GetName())

# 添加几何对象
geom = feature.GetGeometryRef()
print("之前的坐标:",geom.GetX(),geom.GetY())

# 几何投影
targetSrs = osr.SpatialReference()
targetSrs.ImportFromEPSG(4522)
print("目标数据坐标系名称:",targetSrs.GetName())

# 坐标转换
coordsTransform = osr.CoordinateTransformation(sourceSrs,targetSrs)
geom.Transform(coordsTransform)

print("之后的坐标:",geom.GetX(),geom.GetY())

坐标系输出信息显示如下。

5.7. 创建投影文件

对于缺少.prj文件的数据可以使用空间参考方法MorphToESRI()修改坐标信息,使用ExportToWkt()方法导出坐标参考后将其写入投影文件中。

创建一个方法CreatePrjFile用于创建投影文件。

"""
说明:创建.prj文件
参数:
    -shpPath: Shp文件路径
"""
def CreatePrjFile(shpPath):
    prjSrs = osr.SpatialReference()
    prjSrs.ImportFromEPSG(4522)

    prjSrs.MorphToESRI()

    fileName = os.path.splitext(shpPath)[0]
    prjFile = fileName + ".prj"

    with open(prjFile,"w") as f:
        f.write(prjSrs.ExportToWkt())
        print(f"成功创建投影文件: {prjFile}")

6. 注意事项

windows开发环境中同时安装GDALPostGIS,其中投影库PROJ的环境变量指向PostGIS的安装路径,在运行GDAL程序时,涉及到要素、几何与投影操作时会导致异常。具体意思为GDAL不支持PostGIS插件中的投影库版本,需要更换投影库或者升级版本。

RuntimeError: PROJ: proj_identify: D:Program FilesPostgreSQL13sharecontribpostgis-3.5projproj.db contains DATABASE.LAYOUT.VERSION.MINOR = 2 whereas a number >= 5 is expected. It comes from another PROJ installation.

解决办法为修改PROJ的环境变量到GDAL支持的版本或者在GDAL程序开头添加以下代码:

os.environ['PROJ_LIB'] = r'D:\Programs\Python\Python311\Libsite-packages\osgeo\data\proj'

图片效果


OpenLayers示例数据下载,请在公众号后台回复:ol数据

全国信息化工程师-GIS 应用水平考试资料,请在公众号后台回复:GIS考试

GIS之路 公众号已经接入了智能 助手,可以在对话框进行提问,也可以直接搜索历史文章进行查看。

都看到这了,不要忘记点赞、收藏 + 关注

本号不定时更新有关 GIS开发 相关内容,欢迎关注 


    

GeoTools 开发合集(全)

OpenLayers 开发合集

GDAL 实现矢量合并

GDAL 图层合并操作

国产版的Google Earth,吉林一号卫星App“共生地球”来了

2026年全国自然资源工作会议召开

日本欲打造“本土版”星链系统

GDAL 实现矢量裁剪

GDAL 实现空间分析

GDAL 空间关系解析

GDAL 实现数据空间查询

GDAL 实现数据属性查询

吉林一号国内首张高分辨率彩色夜光卫星影像发布

GDAL 实现创建几何对象

GDAL 数据类型大全

从“无”到“有”:手动实现一个 3D 渲染循环全过程

一、 Three.js 的基本构成

  1. Scene :场景。
  2. Camera :摄像机。
  3. Renderer :渲染器,。

二、具体实现

1. 初始化场景 (The Scene)

场景是一切物体的容器。

const scene = new THREE.Scene();
// 💡 INTP 视角:把它想象成一个坐标系原点为 (0,0,0) 的无限空腔。

2. 配置相机 (The Camera)

最常用的是 透视相机(PerspectiveCamera) ,它模拟了人眼的“近大远小”效果。

const camera = new THREE.PerspectiveCamera(
    75, // 视角 (Field of View)
    window.innerWidth / window.innerHeight, // 宽高比
    0.1, // 近剪裁面
    1000 // 远剪裁面
);
camera.position.z = 5; // 将相机后退 5 个单位,否则会在物体中心

3. 加入材质 (The Mesh)

一个物体由两部分组成:

  • 几何体(Geometry)
  • 材质(Material)
const geometry = new THREE.BoxGeometry(1, 1, 1); // 形状:立方体
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 }); // 材质:基础绿色
const cube = new THREE.Mesh(geometry, material); // 组合成网格
scene.add(cube); // 必须添加到场景中

4. 渲染与循环 (The Render Loop)

这是最关键的一步。我们需要每一秒刷新 60 次屏幕,才能看到动画。

const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

function animate() {
    requestAnimationFrame(animate); // 核心:请求下一帧
    
    // 让物体动起来,增加一点生命力
    cube.rotation.x += 0.01;
    cube.rotation.y += 0.01;

    renderer.render(scene, camera); // 真正绘制的一行
}
animate();

📂 核心代码与完整示例:   my-three-app

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多Three.js开发干货

JavaScript 中的 sort 排序问题

在 JavaScript 中,以下两种写法是等价的:

写法一:

let fruits = ["banana", "apple", "cherry", "Apple"]  
fruits.sort()  
console.log(fruits) // ["Apple", "apple", "banana", "cherry"]  

写法二:

let fruits = ["banana", "apple", "cherry", "Apple"]
fruits.sort((a, b) => {
  return a > b ? 1 : -1
})
console.log(fruits)

sort 排序基本原理

因为 sort 函数默认是字符的 ASCII 码升序排列的。

比如:

'A'.charCode() // 65
'a'.charCode() // 97
'b'.charCode() // 98

因此如果是10和2排序的话,其实是'10'和'2'排序,'1'.charCode() 为 49,'2'.charCode() 为 50,导致出现 2 比 10 大,出现在 10 后面。

比如下面的代码:

let nums = [3, 10, 2]
nums.sort()
console.log('nums') // [10, 2, 3]

基础

那么问题来了,如果我想实现以下数组按照 appName 字典顺序降序排列怎么办?

let apps = [
  ['chrome', { cpu: 30, memory: 50 }],
  ['edge', { cpu: 30, memory: 20 }],
  ['firefox', { cpu: 80, memory: 90 }],
  ['safari', { cpu: 10, memory: 50 }],
]

注:chrome、edge 这些是 appName

欢迎在评论区解答。

进阶

再扩展一下,给定一个数组 sortRules,这个数组只能取 cpu 和 memory 两个值,可能是 0、1、2 个。

比如 sortRules 可能是:[]['cpu']['memory', 'cpu']['cpu', 'memory'] 等。

请实现先按照给定的 sortRules 的值依次升序排序,再按照 appName 降序排序。

比如 sortRules 是 ['cpu'],则排序结果是:

let apps = [
  ['safari', { cpu: 10, memory: 50 }],
  ['chrome', { cpu: 30, memory: 50 }],
  ['edge', { cpu: 30, memory: 20 }],
  ['firefox', { cpu: 80, memory: 90 }],
]

比如 sortRules 是 ['cpu', 'memory'],则排序结果是:

let apps = [
  ['safari', { cpu: 10, memory: 50 }],
  ['edge', { cpu: 30, memory: 20 }],
  ['chrome', { cpu: 30, memory: 50 }],
  ['firefox', { cpu: 80, memory: 90 }],
]

欢迎在评论区回复~

Service Worker 缓存请求:前端性能优化的进阶利器

Service Worker 缓存请求:前端性能优化的进阶利器

在前端性能优化的赛道上,缓存始终是绕不开的核心话题。从基础的 HTTP 缓存到 localStorage 本地存储,每一种缓存方案都在特定场景下发挥着价值。而今天要重点聊的Service Worker 缓存,则是突破传统缓存局限、实现进阶性能优化的关键手段——它不仅能加速资源加载,更能解锁离线访问等高级能力,让前端应用的体验实现质的飞跃。

一、为什么需要 Service Worker 缓存?传统缓存的痛点

在聊 Service Worker 之前,我们先回顾下前端最常用的 HTTP 原生缓存(强缓存 + 协商缓存)。它的优势很明显:无需前端开发成本,由浏览器自动遵循 HTTP 响应头(Cache-Control、Expires、Etag 等)执行,能有效减少静态资源的重复请求。但在实际开发中,它的局限性也愈发突出:

  • 控制权缺失:缓存规则完全由后端通过响应头控制,前端无法主动决定缓存哪些资源、何时更新或删除缓存;
  • 缓存范围受限:默认不缓存 POST 请求、带鉴权头(如 Authorization)的请求、跨域请求,而这些恰恰是接口请求的常见场景;
  • 策略单一:只有「强缓存 → 协商缓存」这一种固定逻辑,无法适配复杂的业务场景(如“先显示缓存再后台更新”);
  • 无离线能力:一旦断网,未被缓存的资源(尤其是接口数据)会直接加载失败,导致页面白屏或功能失效;
  • 缓存稳定性差:缓存存储在浏览器的内存/磁盘中,可能被浏览器在内存不足时自动清理,开发者无法干预。

而 Service Worker 缓存的出现,正是为了解决这些痛点——它让前端开发者完全掌控网络请求的缓存逻辑,实现更灵活、更强大的性能优化方案。

二、Service Worker 缓存核心认知:它是什么?怎么工作?

在深入缓存实现前,我们先理清 Service Worker 的核心特性,这是理解其缓存能力的基础:

1. 什么是 Service Worker?

Service Worker(简称 SW)是浏览器在后台独立运行的「无界面 JS 线程」,独立于当前页面,具备以下关键特性:

  • 基于 HTTPS 环境(本地开发 localhost 例外),保障安全性;
  • 能拦截当前域名下的所有网络请求(fetch/ajax、静态资源、接口等);
  • 拥有专属的持久化缓存仓库 Cache Storage,不受页面生命周期影响;
  • 页面关闭后仍可运行,支持离线推送、后台同步等高级能力。

2. Service Worker 缓存的核心工作流

SW 缓存的本质是「拦截请求 + 自定义处理」,核心流程如下:

  1. 页面加载时,注册并激活 Service Worker;
  2. 当页面发起网络请求时,请求被 SW 拦截;
  3. 开发者通过代码定义缓存策略(如“先查缓存再走网络”“先走网络再补缓存”等);
  4. SW 执行策略:从 Cache Storage 读取缓存,或发起真实网络请求;
  5. 将结果(缓存数据/网络数据)返回给页面,并根据策略更新缓存。

整个过程完全由前端代码控制,这也是 SW 缓存相较于 HTTP 缓存的核心优势。

三、Service Worker 缓存的核心价值:性能优化的关键场景

SW 缓存的价值不仅是“加速加载”,更在于解决传统缓存无法覆盖的优化场景,具体可分为以下 4 类:

1. 突破缓存限制:缓存传统方案搞不定的请求

这是 SW 缓存最直观的优势。对于 HTTP 缓存默认不支持的请求类型,SW 都能轻松搞定:

  • POST 接口缓存:HTTP 缓存默认不缓存 POST 请求(认为其是“数据提交”操作),但 SW 可拦截 POST 请求,将「请求体 + 响应数据」一起存入 Cache Storage
  • 带鉴权的请求缓存:含 Authorization、Token 等请求头的接口,HTTP 缓存会直接跳过,SW 可正常缓存;
  • 跨域请求缓存:HTTP 缓存对跨域资源的缓存支持有限,SW 可通过 CORS 正常拦截并缓存跨域接口/资源;
  • 动态参数请求缓存:如 /api/list?_t=1699999999 这类带随机参数的请求,HTTP 缓存会认为是不同请求而重复加载,SW 可自定义规则忽略无效参数,合并缓存。

2. 灵活缓存策略:适配不同业务场景的性能优化

HTTP 缓存只有“强缓存 → 协商缓存”一种固定逻辑,而 SW 支持多种经典缓存策略,可根据资源类型精准适配:

策略 1:Cache First(缓存优先)—— 适用于不常更新的静态资源

核心逻辑:优先从缓存读取资源,无缓存时才走网络,拿到网络数据后更新缓存。 适用场景:字体文件、图标库、第三方 SDK、不常更新的图片等。 优势:加载速度最快,减少网络请求次数。

// 缓存优先策略示例
self.addEventListener('fetch', (event) => {
  // 对静态资源应用缓存优先
  if (event.request.url.match(/.(png|jpg|font|js)$/)) {
    event.respondWith(
      caches.match(event.request)
        .then(cacheRes => {
          // 有缓存直接返回,无缓存则发起网络请求
          return cacheRes || fetch(event.request).then(networkRes => {
            // 更新缓存
            caches.open('static-cache-v1').then(cache => {
              cache.put(event.request, networkRes.clone());
            });
            return networkRes;
          });
        })
    );
  }
});
策略 2:Network First(网络优先)—— 适用于动态接口数据

核心逻辑:优先发起网络请求,拿到最新数据后更新缓存;若网络失败(断网/超时),则返回缓存数据兜底。 适用场景:列表接口、详情接口等需要实时更新的数据。 优势:保证数据新鲜度,同时实现断网降级,避免页面白屏。

策略 3:Stale-While-Revalidate(缓存兜底 + 后台更新)—— 性能与新鲜度兼顾

这是前端性能优化的「黄金策略」,核心逻辑: 1. 页面请求时,立即返回缓存数据(用户无感知等待); 2. 同时在后台发起网络请求,获取最新数据; 3. 用最新数据更新缓存,供下次请求使用。 适用场景:首页核心数据、个人中心信息等对加载速度和新鲜度都有要求的场景。 优势:完美平衡“加载速度”和“数据时效性”,用户体验拉满。

// 缓存兜底更新策略示例
self.addEventListener('fetch', (event) => {
  // 对核心接口应用 stale-while-revalidate
  if (event.request.url.includes('/api/core/')) {
    event.respondWith(
      caches.match(event.request).then(cacheRes => {
        // 并行发起网络请求
        const networkPromise = fetch(event.request).then(networkRes => {
          // 更新缓存
          caches.open('api-cache-v1').then(cache => {
            cache.put(event.request, networkRes.clone());
          });
          return networkRes;
        });
        // 有缓存先返回缓存,无缓存则等网络请求
        return cacheRes || networkPromise;
      })
    );
  }
});
策略 4:Cache Only(仅缓存)—— 适用于离线资源

核心逻辑:只从缓存读取资源,不发起任何网络请求。 适用场景:离线页面的静态资源(如离线提示图、离线文案)。 优势:确保断网时页面仍能正常展示基础内容。

3. 离线可用:从“加速”到“可用”的体验升级

这是 SW 缓存最具标志性的能力。HTTP 缓存只能“加速加载”,而 SW 缓存能让应用在断网时依然可用:

  • 缓存首页骨架屏、核心样式、基础 JS,断网时用户打开页面仍能看到完整的基础结构;
  • 缓存历史接口数据,断网时用户可查看之前加载过的列表、详情等内容;
  • 配合 Workbox 等工具,可快速实现 PWA(渐进式 Web 应用)的离线访问能力。

4. 精细化缓存管理:避免缓存污染与冗余

HTTP 缓存的最大痛点之一是“无法手动管理”,而 SW 可通过 Cache API 实现对缓存的完全掌控:

  • 缓存版本控制:给缓存命名时添加版本号(如 static-cache-v1),页面迭代时,通过代码删除旧版本缓存,避免缓存污染;
  • 精准清理缓存:可根据资源路径、请求类型手动删除指定缓存(如删除某个过期的接口缓存);
  • 缓存容量控制:定期清理长期未使用的缓存,避免占用过多浏览器空间。
// 清理旧版本缓存示例
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          // 删除非当前版本的缓存
          if (cacheName !== 'static-cache-v1' && cacheName !== 'api-cache-v1') {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

四、最佳实践:Service Worker 缓存与 HTTP 缓存的协同优化

很多开发者会误以为“用了 SW 就不用 HTTP 缓存了”,但实际上,二者是「分层缓存」的关系,最佳实践是「HTTP 缓存兜底 + SW 缓存增强」,原因如下:

SW 是在 HTTP 缓存之后拦截请求的:如果一个请求命中了 HTTP 强缓存,请求根本不会走到 SW,直接从浏览器内存/磁盘返回,效率最高;只有当 HTTP 缓存失效(强缓存过期、协商缓存命中 304)时,请求才会被 SW 拦截,再执行 SW 的缓存策略。

具体协同方案

  1. HTTP 缓存负责静态资源兜底

    1. 不常更新的资源(字体、第三方库):设置 Cache-Control: max-age=31536000(永久强缓存);
    2. 常更新的资源(业务 JS/CSS):设置 Cache-Control: max-age=0, must-revalidate(协商缓存),配合 Etag/Last-Modified 验证资源是否更新。
  2. SW 缓存负责进阶优化

    1. 缓存 HTTP 缓存搞不定的请求(POST 接口、带鉴权接口等);
    2. 对核心静态资源(如首页 JS/CSS)叠加 SW 缓存,实现“双重保险”;
    3. 用 Stale-While-Revalidate 策略优化核心接口,兼顾速度与新鲜度;
    4. 缓存离线所需的基础资源,实现离线访问。

五、工具推荐:降低 Service Worker 开发成本

手动编写 Service Worker 代码需要处理注册、激活、缓存策略、版本管理等诸多细节,推荐使用成熟工具简化开发:

  • Workbox:Google 官方推出的 SW 开发工具库,内置了多种缓存策略(如 CacheFirstNetworkFirst),支持自动缓存打包后的静态资源,还能处理缓存更新、过期清理等问题,开箱即用;
  • Create React App/Vite:主流构建工具内置了 SW 支持,可通过简单配置启用(如 CRA 的 serviceWorker: true),自动生成基础的 SW 缓存逻辑;
  • Lighthouse:Google 性能检测工具,可检测 SW 的配置是否合理、离线能力是否达标,提供优化建议。

六、注意事项与避坑指南

  1. HTTPS 环境要求:除 localhost 外,SW 仅在 HTTPS 环境下生效(保障请求拦截的安全性),生产环境需部署 HTTPS;
  2. 缓存更新问题:SW 激活后会持续运行,若修改了 SW 代码,需通过“版本号更新”触发重新注册(如修改缓存名称的版本号);
  3. 避免过度缓存:不要缓存所有请求(如登录接口、实时支付接口),需根据业务场景精准筛选缓存范围;
  4. 兼容性处理:部分老旧浏览器(如 IE 全系列)不支持 Service Worker,需做降级处理(检测 SW 支持性,不支持则走传统缓存);
  5. 调试技巧:在 Chrome 开发者工具的「Application → Service Workers」面板,可查看 SW 状态、手动触发更新、清除缓存,方便调试。

七、总结

Service Worker 缓存并非对传统缓存的替代,而是前端性能优化的「进阶补充」。它的核心价值在于「前端完全掌控缓存逻辑」,既能突破 HTTP 缓存的局限,缓存传统方案搞不定的请求,又能通过灵活的策略适配不同业务场景,甚至实现离线访问能力。

在实际开发中,只要合理搭配「HTTP 缓存兜底 + SW 缓存增强」的分层方案,就能在保证性能的同时,最大化提升用户体验。对于追求极致性能的前端应用(如移动端 H5、PWA、电商首页),Service Worker 缓存绝对是值得投入的优化手段。

最后,附上一句实践心得:缓存的本质是「用空间换时间」,而 Service Worker 让我们能更聪明地“换”——精准缓存需要的资源,灵活控制缓存生命周期,让每一份缓存都能发挥最大的价值。

深入理解Vue数据流:单向与双向的哲学博弈

前言:数据流为何如此重要?

在Vue的世界里,数据流就像城市的交通系统——合理的流向设计能让应用运行如行云流水,而混乱的数据流向则可能导致"交通拥堵"甚至"系统崩溃"。今天,我们就来深入探讨Vue中两种核心数据流模式:单向数据流双向数据流的博弈与融合。

一、数据流的本质:理解两种模式

1.1 什么是数据流?

在Vue中,数据流指的是数据在应用各层级组件间的传递方向和方式。想象一下水流,有的河流只能单向流淌(单向数据流),而有的则像潮汐可以来回流动(双向数据流)。

graph TB
    A[数据流概念] --> B[单向数据流]
    A --> C[双向数据流]
    
    B --> D[数据源 -> 视图]
    D --> E[Props向下传递]
    E --> F[事件向上通知]
    
    C --> G[数据源 <-> 视图]
    G --> H[自动双向同步]
    H --> I[简化表单处理]
    
    subgraph J [核心区别]
        B
        C
    end

1.2 单向数据流:Vue的默认哲学

Vue默认采用单向数据流作为其核心设计理念。这意味着数据只能从一个方向传递:从父组件流向子组件。

// ParentComponent.vue
<template>
  <div>
    <!-- 单向数据流:父传子 -->
    <ChildComponent :message="parentMessage" @update="handleUpdate" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      parentMessage: 'Hello from Parent'
    }
  },
  methods: {
    handleUpdate(newMessage) {
      // 子组件通过事件通知父组件更新
      this.parentMessage = newMessage
    }
  }
}
</script>

// ChildComponent.vue
<template>
  <div>
    <p>接收到的消息: {{ message }}</p>
    <button @click="updateMessage">更新消息</button>
  </div>
</template>

<script>
export default {
  props: {
    message: String  // 只读属性,不能直接修改
  },
  methods: {
    updateMessage() {
      // 错误做法:直接修改prop ❌
      // this.message = 'New Message'
      
      // 正确做法:通过事件通知父组件 ✅
      this.$emit('update', 'New Message from Child')
    }
  }
}
</script>

1.3 双向数据流:Vue的特殊礼物

虽然Vue默认是单向数据流,但它提供了v-model指令来实现特定场景下的双向数据绑定。

// 双向绑定示例
<template>
  <div>
    <!-- 语法糖:v-model = :value + @input -->
    <CustomInput v-model="userInput" />
    
    <!-- 等价于 -->
    <CustomInput 
      :value="userInput" 
      @input="userInput = $event" 
    />
  </div>
</template>

<script>
export default {
  data() {
    return {
      userInput: ''
    }
  }
}
</script>

二、单向数据流:为什么它是默认选择?

2.1 单向数据流的优势

flowchart TD
    A[单向数据流优势] --> B[数据流向可预测]
    A --> C[调试追踪简单]
    A --> D[组件独立性高]
    A --> E[状态管理清晰]
    
    B --> F[更容易理解应用状态]
    C --> G[通过事件追溯数据变更]
    D --> H[组件可复用性强]
    E --> I[单一数据源原则]
    
    F --> J[降低维护成本]
    G --> J
    H --> J
    I --> J

2.2 实际项目中的单向数据流应用

// 大型项目中的单向数据流架构示例
// store.js - Vuex状态管理(单向数据流典范)
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    user: null,
    products: []
  },
  mutations: {
    // 唯一修改state的方式(单向)
    SET_USER(state, user) {
      state.user = user
    },
    ADD_PRODUCT(state, product) {
      state.products.push(product)
    }
  },
  actions: {
    // 异步操作,提交mutation
    async login({ commit }, credentials) {
      const user = await api.login(credentials)
      commit('SET_USER', user)  // 单向数据流:action -> mutation -> state
    }
  },
  getters: {
    // 计算属性,只读
    isAuthenticated: state => !!state.user
  }
})

// UserProfile.vue - 使用单向数据流
<template>
  <div>
    <!-- 单向数据流:store -> 组件 -->
    <h2>{{ userName }}</h2>
    <UserForm @submit="updateUser" />
  </div>
</template>

<script>
import { mapState, mapActions } from 'vuex'

export default {
  computed: {
    // 单向:从store读取数据
    ...mapState({
      userName: state => state.user?.name
    })
  },
  methods: {
    // 单向:通过action修改数据
    ...mapActions(['updateUserInfo']),
    
    async updateUser(userData) {
      // 事件驱动:表单提交触发action
      await this.updateUserInfo(userData)
      // 数据流:组件 -> action -> mutation -> state -> 组件
    }
  }
}
</script>

2.3 单向数据流的最佳实践

// 1. 严格的Prop验证
export default {
  props: {
    // 类型检查
    title: {
      type: String,
      required: true,
      validator: value => value.length > 0
    },
    // 默认值
    count: {
      type: Number,
      default: 0
    },
    // 复杂对象
    config: {
      type: Object,
      default: () => ({})  // 工厂函数避免引用共享
    }
  }
}

// 2. 自定义事件规范
export default {
  methods: {
    handleInput(value) {
      // 事件名使用kebab-case
      this.$emit('user-input', value)
      
      // 提供详细的事件对象
      this.$emit('input-change', {
        value,
        timestamp: Date.now(),
        component: this.$options.name
      })
    }
  }
}

// 3. 使用.sync修饰符(Vue 2.x)
// 父组件
<template>
  <ChildComponent :title.sync="pageTitle" />
</template>

// 子组件
export default {
  props: ['title'],
  methods: {
    updateTitle() {
      // 自动更新父组件数据
      this.$emit('update:title', 'New Title')
    }
  }
}

三、双向数据流:v-model的魔法

3.1 v-model的工作原理

// v-model的内部实现原理
<template>
  <div>
    <!-- v-model的本质 -->
    <input 
      :value="message" 
      @input="message = $event.target.value"
    />
    
    <!-- 自定义组件的v-model -->
    <CustomInput v-model="message" />
    
    <!-- Vue 2.x:等价于 -->
    <CustomInput 
      :value="message" 
      @input="message = $event" 
    />
    
    <!-- Vue 3.x:等价于 -->
    <CustomInput 
      :modelValue="message" 
      @update:modelValue="message = $event" 
    />
  </div>
</template>

3.2 实现自定义组件的v-model

// CustomInput.vue - Vue 2.x实现
<template>
  <div class="custom-input">
    <input 
      :value="value" 
      @input="$emit('input', $event.target.value)"
      @blur="$emit('blur')"
    />
    <span v-if="error" class="error">{{ error }}</span>
  </div>
</template>

<script>
export default {
  // 接收value,触发input事件
  props: ['value', 'error'],
  model: {
    prop: 'value',
    event: 'input'
  }
}
</script>

// CustomInput.vue - Vue 3.x实现
<template>
  <div class="custom-input">
    <input 
      :value="modelValue" 
      @input="$emit('update:modelValue', $event.target.value)"
    />
  </div>
</template>

<script>
export default {
  // Vue 3默认使用modelValue和update:modelValue
  props: ['modelValue'],
  emits: ['update:modelValue']
}
</script>

3.3 多v-model绑定(Vue 3特性)

// ParentComponent.vue
<template>
  <UserForm
    v-model:name="user.name"
    v-model:email="user.email"
    v-model:age="user.age"
  />
</template>

<script>
export default {
  data() {
    return {
      user: {
        name: '',
        email: '',
        age: 18
      }
    }
  }
}
</script>

// UserForm.vue
<template>
  <form>
    <input :value="name" @input="$emit('update:name', $event.target.value)">
    <input :value="email" @input="$emit('update:email', $event.target.value)">
    <input 
      type="number" 
      :value="age" 
      @input="$emit('update:age', parseInt($event.target.value))"
    >
  </form>
</template>

<script>
export default {
  props: ['name', 'email', 'age'],
  emits: ['update:name', 'update:email', 'update:age']
}
</script>

四、两种数据流的对比与选择

4.1 详细对比表

特性 单向数据流 双向数据流
数据流向 单向:父 → 子 双向:父 ↔ 子
修改方式 Props只读,事件通知 自动同步修改
代码量 较多(需要显式事件) 较少(v-model简化)
可预测性 高,易于追踪 较低,隐式更新
调试难度 容易,通过事件追溯 较难,更新可能隐式发生
适用场景 大多数组件通信 表单输入组件
性能影响 最小,精确控制更新 可能更多重新渲染
测试难度 容易,输入输出明确 需要模拟双向绑定

4.2 何时使用哪种模式?

flowchart TD
    A[选择数据流模式] --> B{组件类型}
    
    B --> C[展示型组件]
    B --> D[表单型组件]
    B --> E[复杂业务组件]
    
    C --> F[使用单向数据流]
    D --> G[使用双向数据流]
    E --> H[混合使用]
    
    F --> I[Props + Events<br>保证数据纯净性]
    G --> J[v-model<br>简化表单处理]
    H --> K[单向为主<br>双向为辅]
    
    I --> L[示例<br>ProductList, UserCard]
    J --> M[示例<br>CustomInput, DatePicker]
    K --> N[示例<br>复杂表单, 编辑器组件]

4.3 混合使用实践

// 混合使用示例:智能表单组件
<template>
  <div class="smart-form">
    <!-- 单向数据流:显示验证状态 -->
    <ValidationStatus :errors="errors" />
    
    <!-- 双向数据流:表单输入 -->
    <SmartInput 
      v-model="formData.username"
      :rules="usernameRules"
      @validate="updateValidation"
    />
    
    <!-- 单向数据流:提交控制 -->
    <SubmitButton 
      :disabled="!isValid" 
      @submit="handleSubmit"
    />
  </div>
</template>

<script>
export default {
  data() {
    return {
      formData: {
        username: '',
        email: ''
      },
      errors: {},
      isValid: false
    }
  },
  methods: {
    updateValidation(field, isValid) {
      // 单向:更新验证状态
      if (isValid) {
        delete this.errors[field]
      } else {
        this.errors[field] = `${field}验证失败`
      }
      this.isValid = Object.keys(this.errors).length === 0
    },
    
    handleSubmit() {
      // 单向:提交数据
      this.$emit('form-submit', {
        data: this.formData,
        isValid: this.isValid
      })
    }
  }
}
</script>

五、Vue 3中的新变化

5.1 Composition API与数据流

// 使用Composition API处理数据流
<script setup>
// Vue 3的<script setup>语法
import { ref, computed, defineProps, defineEmits } from 'vue'

// 定义props(单向数据流入口)
const props = defineProps({
  initialValue: {
    type: String,
    default: ''
  }
})

// 定义emits(单向数据流出口)
const emit = defineEmits(['update:value', 'change'])

// 响应式数据
const internalValue = ref(props.initialValue)

// 计算属性(单向数据流处理)
const formattedValue = computed(() => {
  return internalValue.value.toUpperCase()
})

// 双向绑定处理
function handleInput(event) {
  internalValue.value = event.target.value
  // 单向:通知父组件
  emit('update:value', internalValue.value)
  emit('change', {
    value: internalValue.value,
    formatted: formattedValue.value
  })
}
</script>

<template>
  <div>
    <input 
      :value="internalValue" 
      @input="handleInput"
    />
    <p>格式化值: {{ formattedValue }}</p>
  </div>
</template>

5.2 Teleport和状态提升

// 使用Teleport和状态提升管理数据流
<template>
  <!-- 状态提升到最外层 -->
  <div>
    <!-- 模态框内容传送到body,但数据流仍可控 -->
    <teleport to="body">
      <Modal 
        :is-open="modalOpen"
        :content="modalContent"
        @close="modalOpen = false"
      />
    </teleport>
    
    <button @click="openModal('user')">打开用户模态框</button>
    <button @click="openModal('settings')">打开设置模态框</button>
  </div>
</template>

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

// 状态提升:在共同祖先中管理状态
const modalOpen = ref(false)
const modalContent = ref('')

function openModal(type) {
  // 单向数据流:通过方法更新状态
  modalContent.value = type === 'user' ? '用户信息' : '设置选项'
  modalOpen.value = true
}
</script>

六、最佳实践与常见陷阱

6.1 必须避免的陷阱

// 陷阱1:直接修改Prop(反模式)
export default {
  props: ['list'],
  methods: {
    removeItem(index) {
      // ❌ 错误:直接修改prop
      this.list.splice(index, 1)
      
      // ✅ 正确:通过事件通知父组件
      this.$emit('remove-item', index)
    }
  }
}

// 陷阱2:过度使用双向绑定
export default {
  data() {
    return {
      // ❌ 错误:所有数据都用v-model
      // user: {},
      // products: [],
      // settings: {}
      
      // ✅ 正确:区分状态类型
      user: {},           // 适合v-model
      products: [],       // 适合单向数据流
      settings: {         // 混合使用
        theme: 'dark',    // 适合v-model
        permissions: []   // 适合单向数据流
      }
    }
  }
}

// 陷阱3:忽略数据流的可追溯性
export default {
  methods: {
    // ❌ 错误:隐式更新,难以追踪
    updateData() {
      this.$parent.$data.someValue = 'new'
    },
    
    // ✅ 正确:显式事件,易于调试
    updateData() {
      this.$emit('data-updated', {
        value: 'new',
        source: 'ChildComponent',
        timestamp: Date.now()
      })
    }
  }
}

6.2 性能优化建议

// 1. 合理使用v-once(单向数据流优化)
<template>
  <div>
    <!-- 静态内容使用v-once -->
    <h1 v-once>{{ appTitle }}</h1>
    
    <!-- 动态内容不使用v-once -->
    <p>{{ dynamicContent }}</p>
  </div>
</template>

// 2. 避免不必要的响应式(双向数据流优化)
export default {
  data() {
    return {
      // 不需要响应式的数据
      constants: Object.freeze({
        PI: 3.14159,
        MAX_ITEMS: 100
      }),
      
      // 大数组考虑使用Object.freeze
      largeList: Object.freeze([
        // ...大量数据
      ])
    }
  }
}

// 3. 使用computed缓存(单向数据流优化)
export default {
  props: ['items', 'filter'],
  computed: {
    // 缓存过滤结果,避免重复计算
    filteredItems() {
      return this.items.filter(item => 
        item.name.includes(this.filter)
      )
    },
    
    // 计算属性依赖变化时才重新计算
    itemCount() {
      return this.filteredItems.length
    }
  }
}

6.3 测试策略

// 单向数据流组件测试
import { mount } from '@vue/test-utils'
import UserCard from './UserCard.vue'

describe('UserCard - 单向数据流', () => {
  it('应该正确接收props', () => {
    const wrapper = mount(UserCard, {
      propsData: {
        user: { name: '张三', age: 30 }
      }
    })
    
    expect(wrapper.text()).toContain('张三')
    expect(wrapper.text()).toContain('30')
  })
  
  it('应该正确触发事件', async () => {
    const wrapper = mount(UserCard)
    
    await wrapper.find('button').trigger('click')
    
    // 验证是否正确触发事件
    expect(wrapper.emitted()['user-click']).toBeTruthy()
    expect(wrapper.emitted()['user-click'][0]).toEqual(['clicked'])
  })
})

// 双向数据流组件测试
import CustomInput from './CustomInput.vue'

describe('CustomInput - 双向数据流', () => {
  it('v-model应该正常工作', async () => {
    const wrapper = mount(CustomInput, {
      propsData: {
        value: 'initial'
      }
    })
    
    // 模拟输入
    const input = wrapper.find('input')
    await input.setValue('new value')
    
    // 验证是否触发input事件
    expect(wrapper.emitted().input).toBeTruthy()
    expect(wrapper.emitted().input[0]).toEqual(['new value'])
  })
  
  it('应该响应外部value变化', async () => {
    const wrapper = mount(CustomInput, {
      propsData: { value: 'old' }
    })
    
    // 更新prop
    await wrapper.setProps({ value: 'new' })
    
    // 验证输入框值已更新
    expect(wrapper.find('input').element.value).toBe('new')
  })
})

七、实战案例:构建一个任务管理应用

// 完整示例:Todo应用的数据流设计
// App.vue - 根组件
<template>
  <div id="app">
    <!-- 单向:传递过滤条件 -->
    <TodoFilter 
      :filter="currentFilter"
      @filter-change="updateFilter"
    />
    
    <!-- 双向:添加新任务 -->
    <TodoInput v-model="newTodo" @add="addTodo" />
    
    <!-- 单向:任务列表 -->
    <TodoList 
      :todos="filteredTodos"
      @toggle="toggleTodo"
      @delete="deleteTodo"
    />
    
    <!-- 单向:统计数据 -->
    <TodoStats :stats="todoStats" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      todos: [],
      newTodo: '',
      currentFilter: 'all'
    }
  },
  computed: {
    // 单向数据流:计算过滤后的任务
    filteredTodos() {
      switch(this.currentFilter) {
        case 'active':
          return this.todos.filter(todo => !todo.completed)
        case 'completed':
          return this.todos.filter(todo => todo.completed)
        default:
          return this.todos
      }
    },
    
    // 单向数据流:计算统计信息
    todoStats() {
      const total = this.todos.length
      const completed = this.todos.filter(t => t.completed).length
      const active = total - completed
      
      return { total, completed, active }
    }
  },
  methods: {
    // 单向:添加任务
    addTodo() {
      if (this.newTodo.trim()) {
        this.todos.push({
          id: Date.now(),
          text: this.newTodo.trim(),
          completed: false,
          createdAt: new Date()
        })
        this.newTodo = ''
      }
    },
    
    // 单向:切换任务状态
    toggleTodo(id) {
      const todo = this.todos.find(t => t.id === id)
      if (todo) {
        todo.completed = !todo.completed
      }
    },
    
    // 单向:删除任务
    deleteTodo(id) {
      this.todos = this.todos.filter(t => t.id !== id)
    },
    
    // 单向:更新过滤条件
    updateFilter(filter) {
      this.currentFilter = filter
    }
  }
}
</script>

// TodoInput.vue - 双向数据流组件
<template>
  <div class="todo-input">
    <input 
      v-model="localValue"
      @keyup.enter="handleAdd"
      placeholder="添加新任务..."
    />
    <button @click="handleAdd">添加</button>
  </div>
</template>

<script>
export default {
  props: {
    value: String
  },
  data() {
    return {
      localValue: this.value
    }
  },
  watch: {
    value(newVal) {
      // 单向:响应外部value变化
      this.localValue = newVal
    }
  },
  methods: {
    handleAdd() {
      // 双向:更新v-model绑定的值
      this.$emit('input', '')
      // 单向:触发添加事件
      this.$emit('add')
    }
  }
}
</script>

// TodoList.vue - 单向数据流组件
<template>
  <ul class="todo-list">
    <TodoItem 
      v-for="todo in todos"
      :key="todo.id"
      :todo="todo"
      @toggle="$emit('toggle', todo.id)"
      @delete="$emit('delete', todo.id)"
    />
  </ul>
</template>

<script>
export default {
  props: {
    todos: Array  // 只读,不能修改
  },
  components: {
    TodoItem
  }
}
</script>

八、总结与展望

8.1 核心要点回顾

  1. 单向数据流是Vue的默认设计,它通过props向下传递,事件向上通知,保证了数据流的可预测性和可维护性。

  2. 双向数据流通过v-model实现,主要适用于表单场景,它本质上是:value + @input的语法糖。

  3. 选择合适的数据流模式

    • 大多数情况:使用单向数据流
    • 表单输入:使用双向数据流(v-model)
    • 复杂场景:混合使用,但以单向为主
  4. Vue 3的增强

    • 多v-model支持
    • Composition API提供更灵活的数据流管理
    • 更好的TypeScript支持

8.2 未来发展趋势

随着Vue生态的发展,数据流管理也在不断进化:

  1. Pinia的兴起:作为新一代状态管理库,Pinia提供了更简洁的API和更好的TypeScript支持。

  2. Composition API的普及:使得逻辑复用和数据流管理更加灵活。

  3. 响应式系统优化:Vue 3的响应式系统性能更好,为复杂数据流提供了更好的基础。

8.3 最后的建议

记住一个简单的原则:当你不确定该用哪种数据流时,选择单向数据流。它可能代码量稍多,但带来的可维护性和可调试性是值得的。

双向数据流就像是甜点——适量使用能提升体验,但过度依赖可能导致"代码肥胖症"。而单向数据流则是主食,构成了健康应用的基础。

【AI 编程实战】第 7 篇:登录流程设计 - 多场景、多步骤的优雅实现

登录是用户进入应用的第一道门,但设计一个体验好、可维护、多场景适用的登录流程并不简单。这篇文章以心动恋聊小程序为例,展示如何和 AI 对话,设计一套完整的登录系统——从微信授权到手机号绑定,从弹窗组件到全局状态管理。

系列专栏【AI 编程实战:TRAE SOLO 全栈开发指南】

本篇主题:登录流程设计 - 多场景、多步骤的优雅实现

实战项目:心动恋聊 - AI 恋爱聊天助手

一、开篇:登录流程的复杂性

1.1 看似简单,实则复杂

很多人觉得登录就是"点击按钮 → 调用接口 → 跳转首页",但实际业务中的登录流程要复杂得多:

用户点击登录
    ↓
微信静默登录(获取 openid)
    ↓
检查是否已注册?
    ├── 是 → 直接登录成功
    └── 否 → 需要手机号授权
              ↓
         用户授权手机号
              ↓
         是否新用户?
              ├── 是 → 完善资料(性别、年龄)
              └── 否 → 登录成功

还有更多场景

  • 任意页面触发登录(不只是登录页)
  • 登录成功后执行回调(如继续之前的操作)
  • 登录弹窗而非登录页(不打断用户浏览)
  • 登录状态全局同步

1.2 我需要什么

我需要一套完整的登录系统

  • 微信一键登录 + 手机号授权
  • 新用户引导完善资料
  • 全局登录弹窗(任意页面可触发)
  • 登录成功回调机制
  • 状态全局同步

二、整体架构设计

2.1 我的需求

我:需要设计一套登录系统,要求:
    1. 微信小程序环境
    2. 支持微信登录 + 手机号授权
    3. 新用户要引导填性别和年龄
    4. 任意页面都能触发登录弹窗
    5. 登录成功后能执行回调

2.2 AI 分析并给出架构

AI:这是一个比较复杂的登录系统,我来设计整体架构。

📐 架构设计:

┌─────────────────────────────────────────────────┐
│                    业务页面                       │
│   (首页、我的、详情页...任意需要登录的地方)         │
└───────────────────────┬─────────────────────────┘
                        │ 调用
                        ▼
┌─────────────────────────────────────────────────┐
│              loginModalStore                     │
│   (全局状态:控制弹窗显示、管理回调队列)            │
└───────────────────────┬─────────────────────────┘
                        │ 驱动
                        ▼
┌─────────────────────────────────────────────────┐
│              LoginModalRoot                      │
│   (全局组件:挂载在 App 层,监听 Store 状态)       │
└───────────────────────┬─────────────────────────┘
                        │ 包含
                        ▼
┌─────────────────────────────────────────────────┐
│              LoginModal                          │
│   (登录弹窗:多步骤流程 login→gender→age)         │
└───────────────────────┬─────────────────────────┘
                        │ 调用
                        ▼
┌─────────────────────────────────────────────────┐
│               userStore                          │
│   (用户状态:登录方法、用户信息、持久化)            │
└─────────────────────────────────────────────────┘

🗂️ 文件结构:

src/
├── store/
│   ├── user.ts           # 用户状态 + 登录方法
│   └── loginModal.ts     # 登录弹窗状态
├── components/
│   └── auth/
│       └── LoginModalRoot.vue  # 全局登录弹窗容器
└── pages/
    └── my/
        └── components/
            └── LoginModal.vue  # 登录弹窗主体

这样设计的好处:
1. 任意页面调用 loginModalStore.open() 即可触发登录
2. 登录逻辑集中在 LoginModal,不分散到各页面
3. 支持回调机制,登录成功后执行特定操作
我:这个架构看起来不错,开始实现吧

三、登录弹窗状态管理

3.1 设计 loginModalStore

AI:先实现登录弹窗的状态管理。

核心功能:
1. 控制弹窗显示/隐藏
2. 管理登录成功回调队列
3. 触发登录流程或资料完善流程
// src/store/loginModal.ts
import { defineStore } from 'pinia';
import type { UserInfo } from 'shared-types';

type LoginSuccessHandler = (userInfo?: UserInfo) => void | Promise<void>;

interface LoginModalState {
  visible: boolean;
  shouldStart: boolean; // 是否触发登录流程
  shouldStartProfileCompletion: boolean; // 是否触发资料完善流程
  queue: LoginSuccessHandler[]; // 回调队列
}

export const useLoginModalStore = defineStore('loginModal', {
  state: (): LoginModalState => ({
    visible: false,
    shouldStart: false,
    shouldStartProfileCompletion: false,
    queue: [],
  }),

  actions: {
    /**
     * 打开登录弹窗
     * @param options.onSuccess - 登录成功后的回调
     * @param options.startProfileCompletion - 是否直接进入资料完善
     */
    open(options?: { onSuccess?: LoginSuccessHandler; startProfileCompletion?: boolean }) {
      // 将回调加入队列
      if (options?.onSuccess) {
        this.queue.push(options.onSuccess);
      }

      this.visible = true;

      if (options?.startProfileCompletion) {
        this.shouldStartProfileCompletion = true;
      } else {
        this.shouldStart = true;
      }
    },

    /**
     * 登录流程已启动(由 LoginModalRoot 调用)
     */
    markStarted() {
      this.shouldStart = false;
    },

    /**
     * 资料完善流程已启动
     */
    markProfileCompletionStarted() {
      this.shouldStartProfileCompletion = false;
    },

    /**
     * 登录成功,执行所有回调
     */
    async resolveSuccess(userInfo?: UserInfo) {
      const callbacks = [...this.queue];
      this.queue = [];

      // 依次执行回调
      for (const callback of callbacks) {
        await callback(userInfo);
      }

      this.visible = false;
      this.shouldStart = false;
      this.shouldStartProfileCompletion = false;
    },

    /**
     * 关闭弹窗
     */
    close() {
      this.visible = false;
      this.shouldStart = false;
      this.shouldStartProfileCompletion = false;
      this.queue = [];
    },
  },
});
AI:使用方式:

// 任意页面触发登录
import { useLoginModalStore } from '@/store/loginModal'

const loginModalStore = useLoginModalStore()

// 场景1:简单触发登录
loginModalStore.open()

// 场景2:登录成功后执行操作
loginModalStore.open({
  onSuccess: (userInfo) => {
    console.log('登录成功:', userInfo)
    // 继续之前的操作,如发送消息、购买会员等
  }
})

// 场景3:直接进入资料完善(用户已登录但未填资料)
loginModalStore.open({ startProfileCompletion: true })

3.2 我追问回调队列的作用

我:为什么要用队列?直接存一个回调不行吗?
AI:队列是为了处理"连续触发"的场景。

🎯 场景示例:
用户在页面 A 点击"发送消息"(需要登录)
→ 弹出登录框
→ 用户没登录,又点击了"收藏"(也需要登录)
→ 这时有两个回调等待执行

如果只存一个回调,第二个会覆盖第一个。
用队列就能保证两个操作都能在登录后执行。

当然,如果你的业务不需要这种场景,用单个回调也可以。

四、全局登录弹窗容器

4.1 LoginModalRoot 设计

我:loginModalStore 有了,但弹窗组件在哪里渲染?
    每个页面都引入一遍吗?
AI:不用。创建一个全局容器组件,挂载在 App 层。

设计思路:
1. LoginModalRoot 放在 App.vue 或 Layout 中
2. 它监听 loginModalStore 的状态
3. 状态变化时,调用 LoginModal 的方法
<!-- src/components/auth/LoginModalRoot.vue -->
<template>
  <LoginModal ref="loginModalRef" v-model="modalVisible" @login-success="handleLoginSuccess" />
</template>

<script lang="ts" setup>
import { computed, nextTick, ref, watch } from 'vue';
import type { UserInfo } from 'shared-types';
import LoginModal from '@/pages/my/components/LoginModal.vue';
import { useLoginModalStore } from '@/store/loginModal';

const loginModalStore = useLoginModalStore();
const loginModalRef = ref<InstanceType<typeof LoginModal> | null>(null);

// 双向绑定弹窗显示状态
const modalVisible = computed({
  get: () => loginModalStore.visible,
  set: (value: boolean) => {
    loginModalStore.setVisible(value);
  },
});

// 登录成功处理
const handleLoginSuccess = async (userInfo?: UserInfo) => {
  await loginModalStore.resolveSuccess(userInfo);
};

// 监听"开始登录"信号
watch(
  () => loginModalStore.shouldStart,
  async (shouldStart) => {
    if (!shouldStart) return;
    await nextTick();
    loginModalRef.value?.startLogin();
    loginModalStore.markStarted();
  },
);

// 监听"开始资料完善"信号
watch(
  () => loginModalStore.shouldStartProfileCompletion,
  async (shouldStart) => {
    if (!shouldStart) return;
    await nextTick();
    loginModalRef.value?.startProfileCompletion();
    loginModalStore.markProfileCompletionStarted();
  },
);
</script>

4.2 挂载到全局

AI:把 LoginModalRoot 挂载到 Layout 或 App 层:
<!-- src/layouts/default.vue -->
<template>
  <slot />
  <!-- 全局登录弹窗 -->
  <LoginModalRoot />
</template>

<script setup lang="ts">
import LoginModalRoot from '@/components/auth/LoginModalRoot.vue';
</script>
AI:这样所有使用 default 布局的页面都能触发登录弹窗,
不需要每个页面单独引入。

五、多步骤登录弹窗

5.1 登录流程设计

我:现在要实现 LoginModal 本身了。
    流程是:登录 → 性别选择 → 年龄选择 → 完成
AI:我来设计多步骤的状态机:

📋 步骤状态:
- login: 登录界面(微信授权 + 协议勾选)
- gender: 性别选择(新用户)
- age: 年龄选择(新用户)

📋 流程逻辑:
1. 用户点击登录 → 调用微信登录
2. 检查返回结果:
   - 有 token + user_id → 老用户,直接成功
   - 无 token → 需要手机号授权
   - needsRegistration=true → 新用户,进入资料完善
<!-- src/pages/my/components/LoginModal.vue -->
<template>
  <u-popup
    v-model="isVisible"
    mode="bottom"
    border-radius="24"
    :safe-area-inset-bottom="true"
    :closeable="true"
    @close="handleClose"
  >
    <view class="login-modal-content">
      <!-- 步骤1:登录界面 -->
      <view v-if="currentStep === 'login'" class="login-step">
        <view class="pt-4.5 pb-10">
          <text class="block text-center text-lg font-bold">欢迎登录</text>
        </view>

        <!-- 登录按钮 -->
        <view class="px-9 pb-4">
          <XButton
            text="手机号快捷登录"
            :loading="isLoading"
            :open-type="needPhoneAuth ? 'getPhoneNumber' : undefined"
            @getphonenumber="handlePhoneNumber"
            @click="handleLoginClick"
          />
        </view>

        <!-- 协议勾选 -->
        <view class="px-9 pb-20">
          <view class="flex items-center justify-center" @click="toggleAgreement">
            <view
              class="w-5 h-5 rounded-full border flex items-center justify-center"
              :class="isAgreed ? 'bg-primary border-primary' : 'border-gray-400'"
            >
              <u-icon v-if="isAgreed" name="checkmark" size="20" color="#fff" />
            </view>
            <text class="ml-2 text-sm">
              勾选同意
              <text class="text-primary" @click.stop="openAgreement('user')">《用户协议》</text>
              和
              <text class="text-primary" @click.stop="openAgreement('privacy')">《隐私政策》</text>
            </text>
          </view>
        </view>
      </view>

      <!-- 步骤2:性别选择 -->
      <view v-else-if="currentStep === 'gender'" class="gender-step">
        <view class="pt-4 pb-10">
          <text class="block text-center text-lg font-bold">选择你的性别</text>
          <text class="block text-center text-sm text-gray-500 mt-2">更精准匹配回复话术</text>
        </view>

        <view class="flex justify-center gap-8 pb-20">
          <view
            v-for="gender in genderOptions"
            :key="gender.value"
            class="flex flex-col items-center"
            @click="selectGender(gender.value)"
          >
            <image :src="gender.icon" class="w-32 h-32" />
            <text class="mt-2">{{ gender.label }}</text>
            <view
              v-if="selectedGender === gender.value"
              class="w-5 h-5 rounded-full bg-primary mt-2"
            />
          </view>
        </view>
      </view>

      <!-- 步骤3:年龄选择 -->
      <view v-else class="age-step">
        <view class="pt-4 pb-10">
          <text class="block text-center text-lg font-bold">选择你的年龄段</text>
        </view>

        <view class="flex flex-wrap justify-center gap-4 pb-20">
          <view
            v-for="age in ageOptions"
            :key="age"
            class="px-6 py-3 rounded-full"
            :class="selectedAge === age ? 'bg-primary text-white' : 'bg-gray-100'"
            @click="selectAge(age)"
          >
            {{ age }}
          </view>
        </view>
      </view>
    </view>
  </u-popup>
</template>

5.2 登录逻辑实现

// LoginModal.vue <script setup>
import { ref, computed } from 'vue';
import { useUserStore } from '@/store/user';
import { toast } from '@/utils/toast';
import { GenderEnum, AgeGroupEnum } from 'shared-types';
import { requestWechatLoginCode } from '@/utils/wechat';

const userStore = useUserStore();

// 当前步骤
const currentStep = ref<'login' | 'gender' | 'age'>('login');

// 状态
const isAgreed = ref(false);
const isLoading = ref(false);
const needPhoneAuth = ref(false);
const selectedGender = ref('');
const selectedAge = ref('');

// 性别和年龄选项
const genderOptions = [
  { value: 'male', label: '男', icon: '/static/images/male.png' },
  { value: 'female', label: '女', icon: '/static/images/female.png' },
];
const ageOptions = ['00后', '05后', '90后', '80后', '70后'];

/**
 * 处理登录按钮点击
 */
const handleLoginClick = async () => {
  if (!isAgreed.value) {
    toast.warning('请勾选同意用户协议');
    return;
  }

  // 如果需要手机号授权,由 open-type 处理
  if (needPhoneAuth.value) return;

  await performWechatLogin();
};

/**
 * 执行微信登录
 */
const performWechatLogin = async () => {
  isLoading.value = true;
  try {
    // 1. 获取微信 code
    const loginCode = await requestWechatLoginCode();

    // 2. 调用 Store 登录方法
    const result = await userStore.wechatLogin({ code: loginCode });

    // 3. 判断结果
    if (result.token && result.user_id) {
      // 已有账号
      if (result.needsRegistration) {
        // 新用户,需要完善资料
        currentStep.value = 'gender';
      } else {
        // 老用户,直接成功
        completeLogin();
      }
    } else {
      // 需要手机号授权
      needPhoneAuth.value = true;
    }
  } catch (error) {
    console.error('微信登录失败:', error);
    toast.error('登录失败,请重试');
  } finally {
    isLoading.value = false;
  }
};

/**
 * 处理手机号授权
 */
const handlePhoneNumber = async (event: any) => {
  const { code, errMsg } = event.detail || {};

  if (!code) {
    if (errMsg?.includes('user deny')) {
      toast.info('已取消手机号授权');
    }
    return;
  }

  isLoading.value = true;
  try {
    const loginCode = await requestWechatLoginCode();
    const result = await userStore.phoneLogin({
      code,
      login_code: loginCode,
    });

    if (result.needsRegistration) {
      currentStep.value = 'gender';
    } else {
      completeLogin();
    }
  } catch (error) {
    toast.error('验证手机号失败');
  } finally {
    isLoading.value = false;
  }
};

/**
 * 选择性别
 */
const selectGender = (gender: string) => {
  selectedGender.value = gender;
  // 延迟跳转,让用户看到选择效果
  setTimeout(() => {
    currentStep.value = 'age';
  }, 500);
};

/**
 * 选择年龄
 */
const selectAge = async (age: string) => {
  selectedAge.value = age;

  // 提交资料
  setTimeout(async () => {
    await submitProfile();
  }, 300);
};

/**
 * 提交用户资料
 */
const submitProfile = async () => {
  const genderValue = selectedGender.value === 'male' ? GenderEnum.MALE : GenderEnum.FEMALE;

  const ageMapping: Record<string, number> = {
    '00后': AgeGroupEnum.POST_00,
    '05后': AgeGroupEnum.POST_05,
    '90后': AgeGroupEnum.POST_90,
    '80后': AgeGroupEnum.POST_80,
    '70后': AgeGroupEnum.POST_70,
  };

  await userStore.updateUserInfo(
    {
      gender: genderValue,
      age_group: ageMapping[selectedAge.value],
    },
    '登录成功',
  );

  completeLogin();
};

/**
 * 完成登录
 */
const completeLogin = () => {
  emit('login-success', userStore.userInfo);
  handleClose();
};

/**
 * 关闭弹窗
 */
const handleClose = () => {
  isVisible.value = false;
  // 重置状态
  setTimeout(() => {
    currentStep.value = 'login';
    isAgreed.value = false;
    selectedGender.value = '';
    selectedAge.value = '';
    needPhoneAuth.value = false;
  }, 300);
};

// 暴露方法给父组件
defineExpose({
  startLogin: () => {
    resetState();
    isVisible.value = true;
  },
  startProfileCompletion: () => {
    currentStep.value = 'gender';
    isVisible.value = true;
  },
});

六、业务页面集成

6.1 触发登录的场景

我:登录弹窗做好了,业务页面怎么用?
AI:业务页面只需要调用 loginModalStore.open():
// 场景1:需要登录才能操作的按钮
const handleSendMessage = () => {
  if (!userStore.isLoggedIn) {
    loginModalStore.open({
      onSuccess: () => {
        // 登录成功后,继续发送消息
        doSendMessage();
      },
    });
    return;
  }
  doSendMessage();
};

// 场景2:封装成通用的登录检查函数
const requireLogin = (callback: () => void) => {
  if (userStore.isLoggedIn) {
    callback();
    return;
  }

  loginModalStore.open({
    onSuccess: () => callback(),
  });
};

// 使用
const handleBuyVip = () => {
  requireLogin(() => {
    uni.navigateTo({ url: '/pages/vip/index' });
  });
};

const handleCollect = () => {
  requireLogin(() => {
    doCollect();
  });
};

6.2 我追问:能不能更简洁

我:每次都要写 if (!isLoggedIn) 很繁琐,能不能更简洁?
AI:可以封装成 Hook:
// src/hooks/useRequireLogin.ts
import { useUserStore } from '@/store/user';
import { useLoginModalStore } from '@/store/loginModal';

export function useRequireLogin() {
  const userStore = useUserStore();
  const loginModalStore = useLoginModalStore();

  /**
   * 需要登录才能执行的操作
   */
  const requireLogin = (callback: () => void | Promise<void>) => {
    if (userStore.isLoggedIn) {
      callback();
      return;
    }

    loginModalStore.open({
      onSuccess: async () => {
        await callback();
      },
    });
  };

  /**
   * 创建需要登录的处理函数
   */
  const withLogin = <T extends (...args: any[]) => any>(fn: T): T => {
    return ((...args: Parameters<T>) => {
      requireLogin(() => fn(...args));
    }) as T;
  };

  return {
    requireLogin,
    withLogin,
    isLoggedIn: computed(() => userStore.isLoggedIn),
  };
}
// 页面中使用
const { requireLogin, withLogin, isLoggedIn } = useRequireLogin();

// 方式1:包装函数
const handleSendMessage = () => {
  requireLogin(() => {
    doSendMessage();
  });
};

// 方式2:直接创建带登录检查的函数
const handleBuyVip = withLogin(() => {
  uni.navigateTo({ url: '/pages/vip/index' });
});

const handleCollect = withLogin(() => {
  doCollect();
});

七、登录状态判断优化

7.1 预检微信登录状态

我:每次打开登录弹窗都要等用户点击,
    能不能提前知道需不需要手机号授权?
AI:可以在打开弹窗时"预检"微信登录状态:
// LoginModal.vue
const determineLoginStrategy = async () => {
  if (!isMpWeixin) {
    needPhoneAuth.value = false;
    return;
  }

  try {
    // 静默获取 code,尝试登录
    const loginCode = await requestWechatLoginCode();
    const response = await loginByWechat({
      code: loginCode,
      source: clientSource,
      channel: clientChannel,
    });

    // 如果能直接登录,不需要手机号
    if (response.code === 200 && response.data?.token) {
      needPhoneAuth.value = false;
    } else {
      needPhoneAuth.value = true;
    }
  } catch (error) {
    needPhoneAuth.value = true;
  }
};

// 打开弹窗时调用
const startLogin = () => {
  resetState();
  isVisible.value = true;
  // 预检登录状态
  void determineLoginStrategy();
};
AI:这样的好处:
1. 老用户:按钮显示"微信登录",点击直接成功
2. 新用户:按钮显示"手机号快捷登录",需要授权

用户体验更顺畅,不用点两次。

八、核心经验:登录系统设计要点

8.1 架构设计原则

原则 说明
状态集中 loginModalStore 统一管理弹窗状态和回调
组件分离 LoginModalRoot 负责桥接,LoginModal 负责 UI 逻辑
全局可用 挂载在 Layout 层,任意页面可触发
回调机制 支持登录成功后执行特定操作

8.2 流程设计要点

// ✅ 推荐:多步骤用状态机
const currentStep = ref<'login' | 'gender' | 'age'>('login');

// ❌ 不推荐:多个 boolean 控制
const showLogin = ref(true);
const showGender = ref(false);
const showAge = ref(false);
// ✅ 推荐:预检登录状态
const startLogin = () => {
  void determineLoginStrategy(); // 提前判断需要哪种登录
};

// ❌ 不推荐:用户点击才判断
const handleClick = () => {
  // 点击后才知道需要手机号,体验差
};

8.3 错误处理

// 区分不同的错误场景
const showWechatLoginError = (error: any) => {
  if (error?.code === -8) {
    toast.error('未安装微信客户端');
    return;
  }
  toast.error('登录失败,请重试');
};

// 手机号授权取消 vs 失败
const handlePhoneNumber = (event: any) => {
  const { code, errMsg } = event.detail;
  if (!code) {
    if (errMsg?.includes('user deny')) {
      toast.info('已取消授权'); // 用户主动取消,不是错误
    } else {
      toast.error('获取手机号失败'); // 真正的错误
    }
    return;
  }
};

九、总结:登录系统的完整实现

9.1 文件清单

文件 职责
store/loginModal.ts 弹窗状态 + 回调队列
store/user.ts 用户状态 + 登录方法
components/auth/LoginModalRoot.vue 全局弹窗容器
pages/my/components/LoginModal.vue 登录弹窗 UI + 逻辑
hooks/useRequireLogin.ts 登录检查 Hook

9.2 关键收获

  1. 架构先行:先设计整体架构,再实现细节
  2. 状态集中:用 Store 管理弹窗状态和回调
  3. 多步骤流程:用状态机管理,避免多个 boolean
  4. 体验优化:预检登录状态,减少用户等待
  5. 错误区分:用户取消 vs 系统错误,提示不同

9.3 下一篇预告

《【AI 编程实战】第 8 篇:组件封装的艺术 - 从业务代码到可复用组件》

下一篇展示如何设计通用组件:

  • 从业务代码中提取组件
  • Props 和 Events 设计
  • 组件的扩展性和灵活性

登录系统不只是"调用接口",而是用户体验、状态管理、错误处理的综合考验。 通过和 AI 对话,逐步理清每个环节,最终形成完整的解决方案。

如果这篇文章对你有帮助,请点赞、收藏、转发!

Fork 主题如何更新?基于 Ink 构建主题更新 CLI 工具

本文地址:blog.cosine.ren/post/intera…

本文图表、伪代码等由 AI 辅助编写

背景

当你 fork 了一个开源项目作为自己的博客主题,如何优雅地从上游仓库同步更新?手动敲一串 Git 命令既繁琐又容易出错;但直接点 Fork 的 Sync 按钮,又可能覆盖你的自定义配置和内容。

很多人因此在「保持更新」和「保留修改」之间左右为难:要么干脆二开后不再同步,要么每次更新都提心吊胆。

这也是为什么不少项目会像 @fumadocs/cli 一样,提供专门的 CLI 来完成更新等相关操作。

本文将介绍如何简单地构建一个交互式 CLI 工具,把 fork 同步的流程自动化起来。

这个工具的核心目标是:

  • 安全:更新前检查工作区状态,必要时可备份
  • 透明:预览所有变更,让用户决定是否更新
  • 友好:出现冲突时给出明确指引

具体的代码可以看这个 PR:

github.com/cosZone/ast…

不过这个 PR 只是最初的版本,后面又缝缝补补了不少东西,整体流程是我研究一个周末后摸索出的,如有不足,那一定是我考虑不周,欢迎指出~

在这个 PR 里,我基于 Ink 构建了一个交互式 TUI 工具,提供了博客内容备份/还原、主题更新、内容生成、备份管理等功能:

pnpm koharu # 交互式主菜单
pnpm koharu backup # 备份博客内容 (--full 完整备份)
pnpm koharu restore # 还原备份 (--latest, --dry-run, --force)
pnpm koharu update # 从上游同步更新 (--check, --skip-backup, --force)
pnpm koharu generate # 生成内容资产 (LQIP, 相似度, AI 摘要)
pnpm koharu clean # 清理旧备份 (--keep N)
pnpm koharu list # 查看所有备份

其中备份功能可以:

  • 基础备份:博客文章、配置、头像、.env
  • 完整备份:包含所有图片和生成的资产文件
  • 自动生成 manifest.json 记录主题版本与备份元信息(时间等)

还原功能可以:

  • 交互式选择备份文件
  • 支持 --dry-run 预览模式
  • 显示备份类型、版本、时间等元信息

主题更新功能可以:

  • 自动配置 upstream remote 指向原始仓库
  • 预览待合并的提交列表(显示 hash、message、时间)
  • 更新前可选备份,支持冲突检测与处理
  • 合并成功后自动安装依赖
  • 支持 --check 仅检查更新、--force 跳过工作区检查

整体架构

infographic sequence-snake-steps-underline-text
data
  title Git Update 命令流程
  desc 从 upstream 同步更新的完整工作流
  items
    - label 检查状态
      desc 验证当前分支和工作区状态
      icon mdi/source-branch-check
    - label 配置远程
      desc 确保 upstream remote 已配置
      icon mdi/source-repository
    - label 获取更新
      desc 从 upstream 拉取最新提交
      icon mdi/cloud-download
    - label 预览变更
      desc 显示待合并的提交列表
      icon mdi/file-find
    - label 确认备份
      desc 可选:备份当前内容
      icon mdi/backup-restore
    - label 执行合并
      desc 合并 upstream 分支到本地
      icon mdi/merge
    - label 处理结果
      desc 成功则安装依赖,冲突则提示解决
      icon mdi/check-circle

更新相关 Git 命令详解

1. 检查当前分支

git rev-parse --abbrev-ref HEAD

作用:获取当前所在分支的名称。

参数解析

  • rev-parse:解析 Git 引用
  • --abbrev-ref:输出简短的引用名称(如 main),而不是完整的 SHA

使用场景:确保用户在正确的分支(如 main)上执行更新,避免在 feature 分支上意外合并上游代码。

const currentBranch = execSync("git rev-parse --abbrev-ref HEAD")
  .toString()
  .trim();
if (currentBranch !== "main") {
  throw new Error(`仅支持在 main 分支执行更新,当前分支: ${currentBranch}`);
}

2. 检查工作区状态

git status --porcelain

作用:以机器可读的格式输出工作区状态。

参数解析

  • --porcelain:输出稳定、易于解析的格式,不受 Git 版本和语言设置影响

输出格式

M  modified-file.ts      # 已暂存的修改
 M unstaged-file.ts      # 未暂存的修改
?? untracked-file.ts     # 未跟踪的文件
A  new-file.ts           # 新添加的文件
D  deleted-file.ts       # 删除的文件

前两个字符分别表示暂存区和工作区的状态。

const statusOutput = execSync("git status --porcelain").toString();
const uncommittedFiles = statusOutput.split("\n").filter((line) => line.trim());
const isClean = uncommittedFiles.length === 0;

3. 管理远程仓库

检查 remote 是否存在

git remote get-url upstream

作用:获取指定 remote 的 URL,如果不存在会报错。

添加 upstream remote

# 将 URL 替换为你的上游仓库地址
git remote add upstream https://github.com/original/repo.git

作用:添加一个名为 upstream 的远程仓库,指向原始项目。

为什么需要 upstream?

当你 fork 一个项目后,你的 origin 指向你自己的 fork,而 upstream 指向原始项目。这样可以:

  • upstream 拉取原项目的更新
  • origin 推送你的修改
// UPSTREAM_URL 需替换为你的上游仓库地址
const UPSTREAM_URL = "https://github.com/original/repo.git";

function ensureUpstreamRemote(): string {
  try {
    return execSync("git remote get-url upstream").toString().trim();
  } catch {
    execSync(`git remote add upstream ${UPSTREAM_URL}`);
    return UPSTREAM_URL;
  }
}

4. 获取远程更新

git fetch upstream

作用:从 upstream 远程仓库下载所有分支的最新提交,但不会自动合并到本地分支。

git pull 的区别

  • fetch 只下载数据,不修改本地代码
  • pull = fetch + merge,会自动合并

使用 fetch 可以让我们先预览变更,再决定是否合并。

5. 计算提交差异

git rev-list --left-right --count HEAD...upstream/main

作用:计算本地分支与 upstream/main 之间的提交差异。

参数解析

  • rev-list:列出提交记录
  • --left-right:区分左侧(本地)和右侧(远程)的提交
  • --count:只输出计数,不列出具体提交
  • HEAD...upstream/main:三个点表示对称差集

输出示例

2    5

表示本地有 2 个提交不在 upstream 上(ahead),upstream 有 5 个提交不在本地(behind)。

const revList = execSync(
  "git rev-list --left-right --count HEAD...upstream/main"
)
  .toString()
  .trim();
const [aheadStr, behindStr] = revList.split("\t");
const aheadCount = parseInt(aheadStr, 10);
const behindCount = parseInt(behindStr, 10);

console.log(`本地领先 ${aheadCount} 个提交,落后 ${behindCount} 个提交`);

6. 查看待合并的提交

git log HEAD..upstream/main --pretty=format:"%h|%s|%ar|%an" --no-merges

作用:列出 upstream/main 上有但本地没有的提交。

参数解析

  • HEAD..upstream/main:两个点表示 A 到 B 的差集(B 有而 A 没有的)
  • --pretty=format:"...":自定义输出格式
    • %h:短 hash
    • %s:提交信息
    • %ar:相对时间(如 "2 days ago")
    • %an:作者名
  • --no-merges:排除 merge commit

输出示例

a1b2c3d|feat: add dark mode|2 days ago|Author Name
e4f5g6h|fix: typo in readme|3 days ago|Author Name
const commitFormat = "%h|%s|%ar|%an";
const output = execSync(
  `git log HEAD..upstream/main --pretty=format:"${commitFormat}" --no-merges`
).toString();

const commits = output
  .split("\n")
  .filter(Boolean)
  .map((line) => {
    const [hash, message, date, author] = line.split("|");
    return { hash, message, date, author };
  });

7. 查看远程文件内容

git show upstream/main:package.json

作用:直接查看远程分支上某个文件的内容,无需切换分支或合并。

使用场景:获取上游仓库的版本号,用于显示"将更新到 x.x.x 版本"。

const packageJson = execSync("git show upstream/main:package.json").toString();
const { version } = JSON.parse(packageJson);
console.log(`最新版本: ${version}`);

8. 执行合并

git merge upstream/main --no-edit

作用:将 upstream/main 分支合并到当前分支。

参数解析

  • --no-edit:使用自动生成的合并提交信息,不打开编辑器

合并策略:Git 会自动选择合适的合并策略:

  • Fast-forward:如果本地没有新提交,直接移动指针
  • Three-way merge:如果有分叉,创建一个合并提交

注意:本工具采用 merge 同步上游,保留本地历史。如果你的需求是"强制与上游一致"(丢弃本地修改),需要使用 rebase 或 reset 方案,不在本文讨论范围。

9. 检测合并冲突

git diff --name-only --diff-filter=U

作用:列出所有未解决冲突的文件。

参数解析

  • --name-only:只输出文件名
  • --diff-filter=U:只显示 Unmerged(未合并/冲突)的文件

另一种方式是解析 git status --porcelain 的输出,查找冲突标记:

const statusOutput = execSync("git status --porcelain").toString();
const conflictFiles = statusOutput
  .split("\n")
  .filter((line) => {
    const status = line.slice(0, 2);
    // U = Unmerged, AA = both added, DD = both deleted
    return status.includes("U") || status === "AA" || status === "DD";
  })
  // 注:为简化展示,这里直接截取路径
  // 若需完整兼容重命名/特殊路径,应使用更严格的 porcelain 解析
  .map((line) => line.slice(3).trim());

10. 中止合并

git merge --abort

作用:中止当前的合并操作,恢复到合并前的状态。

使用场景:当用户遇到冲突但不想手动解决时,可以选择中止合并。

function abortMerge(): boolean {
  try {
    execSync("git merge --abort");
    return true;
  } catch {
    return false;
  }
}

状态机设计

如果是简单粗暴的使用 useEffect 的话,会出现很多 useEffect 那自然很不好。

整个更新流程使用简单的 useReducer + Effect Map 模式管理,将状态转换逻辑和副作用处理分离,确保流程清晰可控。

为什么不用 Redux?

在设计 CLI 状态管理时,很自然会想到 Redux,毕竟它是 React 生态中最成熟的状态管理方案,而且还是用着 Ink 来进行开发的。但对于 CLI 工具,useReducer 是更合适的选择,理由如下:

  1. 状态作用域单一:CLI 工具通常是单组件树结构,不存在跨页面、跨路由的状态共享需求,
  2. 无需 Middleware 生态:Redux 的强大之处在于中间件生态(redux-thunk、redux-saga、redux-observable),用于处理复杂的异步流程。但我们的场景不需要那么复杂。
  3. 依赖最小化:CLI 工具应该快速启动、轻量运行useReducer 内置于 React,不会引入额外依赖(当然 React 本身也是依赖,不过我的项目里本来就需要它)

总之,对这个场景来说 Redux 有点"过度设计"。

那咋整?

  • Reducer:集中管理所有状态转换逻辑,纯函数易于测试
  • Effect Map:状态到副作用的映射,统一处理异步操作
  • 单一 Effect:一个 useEffect 驱动整个流程

下面是完整的状态转换流程图,展示了所有可能的状态转换路径和条件分支:

注意:Mermaid stateDiagram 中状态名不能包含连字符 -,这里使用 camelCase 命名。

stateDiagram-v2
    [*] --> checking: 开始更新

    checking --> error: 不在 main 分支
    checking --> dirtyWarning: 工作区不干净 && !force
    checking --> fetching: 工作区干净 || force

    dirtyWarning --> [*]: 用户取消
    dirtyWarning --> fetching: 用户继续

    fetching --> upToDate: behindCount = 0
    fetching --> backupConfirm: behindCount > 0 && !skipBackup
    fetching --> preview: behindCount > 0 && skipBackup

    backupConfirm --> backingUp: 用户确认备份
    backupConfirm --> preview: 用户跳过备份

    backingUp --> preview: 备份完成
    backingUp --> error: 备份失败

    preview --> [*]: checkOnly 模式
    preview --> merging: 用户确认更新
    preview --> [*]: 用户取消

    merging --> conflict: 合并冲突
    merging --> installing: 合并成功

    conflict --> [*]: 用户处理冲突

    installing --> done: 依赖安装成功
    installing --> error: 依赖安装失败

    done --> [*]
    error --> [*]
    upToDate --> [*]

类型定义

// 12 种状态覆盖完整流程
type UpdateStatus =
  | "checking" // 检查 Git 状态
  | "dirty-warning" // 工作区有未提交更改
  | "backup-confirm" // 确认备份
  | "backing-up" // 正在备份
  | "fetching" // 获取更新
  | "preview" // 显示更新预览
  | "merging" // 合并中
  | "installing" // 安装依赖
  | "done" // 完成
  | "conflict" // 有冲突
  | "up-to-date" // 已是最新
  | "error"; // 错误

// Action 驱动状态转换
type UpdateAction =
  | { type: "GIT_CHECKED"; payload: GitStatusInfo }
  | { type: "FETCHED"; payload: UpdateInfo }
  | { type: "BACKUP_CONFIRM" | "BACKUP_SKIP" | "UPDATE_CONFIRM" | "INSTALLED" }
  | { type: "BACKUP_DONE"; backupFile: string }
  | { type: "MERGED"; payload: MergeResult }
  | { type: "ERROR"; error: string };

Reducer 集中状态转换

所有状态转换逻辑集中在 reducer 中,每个 case 只处理当前状态下合法的 action:

function updateReducer(state: UpdateState, action: UpdateAction): UpdateState {
  const { status, options } = state;

  // 通用错误处理:任何状态都可以转到 error
  if (action.type === "ERROR") {
    return { ...state, status: "error", error: action.error };
  }

  switch (status) {
    case "checking": {
      if (action.type !== "GIT_CHECKED") return state;
      const { payload: gitStatus } = action;

      if (gitStatus.currentBranch !== "main") {
        return {
          ...state,
          status: "error",
          error: "仅支持在 main 分支执行更新",
        };
      }
      if (!gitStatus.isClean && !options.force) {
        return { ...state, status: "dirty-warning", gitStatus };
      }
      return { ...state, status: "fetching", gitStatus };
    }

    case "fetching": {
      if (action.type !== "FETCHED") return state;
      const { payload: updateInfo } = action;

      if (updateInfo.behindCount === 0) {
        return { ...state, status: "up-to-date", updateInfo };
      }
      const nextStatus = options.skipBackup ? "preview" : "backup-confirm";
      return { ...state, status: nextStatus, updateInfo };
    }

    // ... 其他状态处理
  }
}

Effect Map:统一副作用处理

每个需要执行副作用的状态对应一个 effect 函数,可返回 cleanup 函数:

type EffectFn = (
  state: UpdateState,
  dispatch: Dispatch<UpdateAction>
) => (() => void) | undefined;

const statusEffects: Partial<Record<UpdateStatus, EffectFn>> = {
  checking: (_state, dispatch) => {
    const gitStatus = checkGitStatus();
    ensureUpstreamRemote();
    dispatch({ type: "GIT_CHECKED", payload: gitStatus });
    return undefined;
  },

  fetching: (_state, dispatch) => {
    fetchUpstream();
    const info = getUpdateInfo();
    dispatch({ type: "FETCHED", payload: info });
    return undefined;
  },

  installing: (_state, dispatch) => {
    let cancelled = false;
    installDeps().then((result) => {
      if (cancelled) return;
      dispatch(
        result.success
          ? { type: "INSTALLED" }
          : { type: "ERROR", error: result.error }
      );
    });
    return () => {
      cancelled = true;
    }; // cleanup
  },
};

组件使用

组件中只需一个核心 useEffect 来驱动整个状态机:

function UpdateApp({ checkOnly, skipBackup, force }) {
  const [state, dispatch] = useReducer(
    updateReducer,
    { checkOnly, skipBackup, force },
    createInitialState
  );

  // 核心:单一 effect 处理所有副作用
  useEffect(() => {
    const effect = statusEffects[state.status];
    if (!effect) return;
    return effect(state, dispatch);
  }, [state.status, state]);

  // UI 渲染基于 state.status
  return <Box>...</Box>;
}

这种模式的优势:

  • 可测试性:Reducer 是纯函数,可以独立测试状态转换
  • 可维护性:状态逻辑集中,不会分散在多个 useEffect
  • 可扩展性:添加新状态只需在 reducer 和 effect map 各加一个 case

用户交互设计

使用 React Ink 构建终端 UI,提供友好的交互体验:

预览更新

发现 5 个新提交:
  a1b2c3d feat: add dark mode (2 days ago)
  e4f5g6h fix: responsive layout (3 days ago)
  i7j8k9l docs: update readme (1 week ago)
  ... 还有 2 个提交

注意: 本地有 1 个未推送的提交

确认更新到最新版本? (Y/n)

处理冲突

发现合并冲突
冲突文件:
  - src/config.ts
  - src/components/Header.tsx

你可以:
  1. 手动解决冲突后运行: git add . && git commit
  2. 中止合并恢复到更新前状态

备份文件: backup-2026-01-10-full.tar.gz

是否中止合并? (Y/n)

完整代码实现

Git 操作封装

import { execSync } from "node:child_process";

function git(args: string): string {
  return execSync(`git ${args}`, {
    encoding: "utf-8",
    stdio: ["pipe", "pipe", "pipe"],
  }).trim();
}

function gitSafe(args: string): string | null {
  try {
    return git(args);
  } catch {
    return null;
  }
}

export function checkGitStatus(): GitStatusInfo {
  const currentBranch = git("rev-parse --abbrev-ref HEAD");
  const statusOutput = gitSafe("status --porcelain") || "";
  const uncommittedFiles = statusOutput
    .split("\n")
    .filter((line) => line.trim());

  return {
    currentBranch,
    isClean: uncommittedFiles.length === 0,
    // 注:简化处理,完整兼容需更严格的 porcelain 解析
    uncommittedFiles: uncommittedFiles.map((line) => line.slice(3).trim()),
  };
}

export function getUpdateInfo(): UpdateInfo {
  const revList =
    gitSafe("rev-list --left-right --count HEAD...upstream/main") || "0\t0";
  const [aheadStr, behindStr] = revList.split("\t");

  const commitFormat = "%h|%s|%ar|%an";
  const commitsOutput =
    gitSafe(
      `log HEAD..upstream/main --pretty=format:"${commitFormat}" --no-merges`
    ) || "";

  const commits = commitsOutput
    .split("\n")
    .filter(Boolean)
    .map((line) => {
      const [hash, message, date, author] = line.split("|");
      return { hash, message, date, author };
    });

  return {
    behindCount: parseInt(behindStr, 10),
    aheadCount: parseInt(aheadStr, 10),
    commits,
  };
}

export function mergeUpstream(): MergeResult {
  try {
    git("merge upstream/main --no-edit");
    return { success: true, hasConflict: false, conflictFiles: [] };
  } catch {
    const conflictFiles = getConflictFiles();
    return {
      success: false,
      hasConflict: conflictFiles.length > 0,
      conflictFiles,
    };
  }
}

function getConflictFiles(): string[] {
  const output = gitSafe("diff --name-only --diff-filter=U") || "";
  return output.split("\n").filter(Boolean);
}

Git 命令速查表

命令 作用 场景
git rev-parse --abbrev-ref HEAD 获取当前分支名 验证分支
git status --porcelain 机器可读的状态输出 检查工作区
git remote get-url <name> 获取 remote URL 检查 remote
git remote add <name> <url> 添加 remote 配置 upstream
git fetch <remote> 下载远程更新 获取更新
git rev-list --left-right --count A...B 统计差异提交数 计算 ahead/behind
git log A..B --pretty=format:"..." 列出差异提交 预览更新
git show <ref>:<path> 查看远程文件 获取版本号
git merge <branch> --no-edit 自动合并 执行更新
git diff --name-only --diff-filter=U 列出冲突文件 检测冲突
git merge --abort 中止合并 回滚操作

Git 命令功能分类

为了更好地理解这些命令的用途,下面按功能将它们分类展示:

infographic hierarchy-structure
data
  title Git 命令功能分类
  desc 按操作类型组织的命令清单
  items
    - label 状态检查
      icon mdi/information
      children
        - label git rev-parse
          desc 获取当前分支名
        - label git status --porcelain
          desc 检查工作区状态
    - label 远程管理
      icon mdi/server-network
      children
        - label git remote get-url
          desc 检查 remote 是否存在
        - label git remote add
          desc 添加 upstream remote
        - label git fetch
          desc 下载远程更新
    - label 提交分析
      icon mdi/source-commit
      children
        - label git rev-list
          desc 统计提交差异
        - label git log
          desc 查看提交历史
        - label git show
          desc 查看远程文件内容
    - label 合并操作
      icon mdi/source-merge
      children
        - label git merge
          desc 执行分支合并
        - label git merge --abort
          desc 中止合并恢复状态
    - label 冲突检测
      icon mdi/alert-octagon
      children
        - label git diff --diff-filter=U
          desc 列出未解决冲突文件

备份还原功能实现

除了主题更新,CLI 还提供了完整的备份还原功能,确保用户数据安全。

备份和还原是两个互补的操作,下图展示了它们的完整工作流:

infographic compare-hierarchy-row-letter-card-compact-card
data
  title 备份与还原流程对比
  desc 两个互补操作的完整工作流
  items
    - label 备份流程
      icon mdi/backup-restore
      children
        - label 检查配置
          desc 确定备份类型和范围
        - label 创建临时目录
          desc 准备暂存空间
        - label 复制文件
          desc 按配置复制所需文件
        - label 生成 manifest
          desc 记录版本和元信息
        - label 压缩打包
          desc tar.gz 压缩存档
        - label 清理临时目录
          desc 删除暂存目录
    - label 还原流程
      icon mdi/restore
      children
        - label 选择备份
          desc 读取 manifest 显示备份信息
        - label 解压到临时目录
          desc 提取归档内容(包含 manifest)
        - label 读取 manifest.files
          desc 获取实际备份成功的文件列表
        - label 按映射复制文件
          desc 使用自动生成的 RESTORE_MAP
        - label 清理临时目录
          desc 删除解压的暂存文件

备份项配置

备份系统采用配置驱动的方式,定义需要备份的文件和目录:

export interface BackupItem {
  src: string; // 源路径(相对于项目根目录)
  dest: string; // 备份内目标路径
  label: string; // 显示标签
  required: boolean; // 是否为必需项(basic 模式包含)
}

export const BACKUP_ITEMS: BackupItem[] = [
  // 基础备份项(required: true)
  {
    src: "src/content/blog",
    dest: "content/blog",
    label: "博客文章",
    required: true,
  },
  {
    src: "config/site.yaml",
    dest: "config/site.yaml",
    label: "网站配置",
    required: true,
  },
  {
    src: "src/pages/about.md",
    dest: "pages/about.md",
    label: "关于页面",
    required: true,
  },
  {
    src: "public/img/avatar.webp",
    dest: "img/avatar.webp",
    label: "用户头像",
    required: true,
  },
  { src: ".env", dest: "env", label: "环境变量", required: true },
  // 完整备份额外项目(required: false)
  { src: "public/img", dest: "img", label: "所有图片", required: false },
  {
    src: "src/assets/lqips.json",
    dest: "assets/lqips.json",
    label: "LQIP 数据",
    required: false,
  },
  {
    src: "src/assets/similarities.json",
    dest: "assets/similarities.json",
    label: "相似度数据",
    required: false,
  },
  {
    src: "src/assets/summaries.json",
    dest: "assets/summaries.json",
    label: "AI 摘要数据",
    required: false,
  },
];

备份流程

备份操作使用 tar.gz 格式压缩,并生成 manifest.json 记录元信息:

export function runBackup(
  isFullBackup: boolean,
  onProgress?: (results: BackupResult[]) => void
): BackupOutput {
  // 1. 创建备份目录和临时目录
  fs.mkdirSync(BACKUP_DIR, { recursive: true });
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
  const tempDir = path.join(BACKUP_DIR, `.tmp-backup-${timestamp}`);

  // 2. 过滤备份项目(基础备份只包含 required: true 的项目)
  const itemsToBackup = BACKUP_ITEMS.filter(
    (item) => item.required || isFullBackup
  );

  // 3. 复制文件到临时目录
  const results: BackupResult[] = [];
  for (const item of itemsToBackup) {
    const srcPath = path.join(PROJECT_ROOT, item.src);
    const destPath = path.join(tempDir, item.dest);

    if (fs.existsSync(srcPath)) {
      fs.cpSync(srcPath, destPath, { recursive: true });
      results.push({ item, success: true, skipped: false });
    } else {
      results.push({ item, success: false, skipped: true });
    }
    onProgress?.([...results]); // 进度回调
  }

  // 4. 生成 manifest.json
  const manifest = {
    name: "astro-koharu-backup",
    version: getVersion(),
    type: isFullBackup ? "full" : "basic",
    timestamp,
    created_at: new Date().toISOString(),
    files: Object.fromEntries(results.map((r) => [r.item.dest, r.success])),
  };
  fs.writeFileSync(
    path.join(tempDir, "manifest.json"),
    JSON.stringify(manifest, null, 2)
  );

  // 5. 压缩并清理
  tarCreate(backupFilePath, tempDir);
  fs.rmSync(tempDir, { recursive: true, force: true });

  return { results, backupFile: backupFilePath, fileSize, timestamp };
}

tar 操作封装

使用系统 tar 命令进行压缩和解压,并添加路径遍历安全检查:

// 安全验证:防止路径遍历攻击
function validateTarEntries(entries: string[], archivePath: string): void {
  for (const entry of entries) {
    if (entry.includes("\0")) {
      throw new Error(`tar entry contains null byte`);
    }
    const normalized = path.posix.normalize(entry);
    if (path.posix.isAbsolute(normalized)) {
      throw new Error(`tar entry is absolute path: ${entry}`);
    }
    if (normalized.split("/").includes("..")) {
      throw new Error(`tar entry contains parent traversal: ${entry}`);
    }
  }
}

// 创建压缩包
export function tarCreate(archivePath: string, sourceDir: string): void {
  spawnSync("tar", ["-czf", archivePath, "-C", sourceDir, "."]);
}

// 解压到指定目录
export function tarExtract(archivePath: string, destDir: string): void {
  listTarEntries(archivePath); // 先验证条目安全性
  spawnSync("tar", ["-xzf", archivePath, "-C", destDir]);
}

// 读取 manifest(不解压整个文件)
export function tarExtractManifest(archivePath: string): string | null {
  const result = spawnSync("tar", ["-xzf", archivePath, "-O", "manifest.json"]);
  return result.status === 0 ? result.stdout : null;
}

还原流程

还原操作基于 manifest 驱动,确保只还原实际备份成功的文件:

// 路径映射:从备份项配置自动生成,确保一致性
export const RESTORE_MAP: Record<string, string> = Object.fromEntries(
  BACKUP_ITEMS.map((item) => [item.dest, item.src])
);

export function restoreBackup(backupPath: string): RestoreResult {
  // 1. 创建临时目录并解压
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "restore-"));
  tarExtract(backupPath, tempDir);

  // 2. 读取 manifest 获取实际备份的文件列表
  const manifestPath = path.join(tempDir, "manifest.json");
  const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));

  const restored: string[] = [];
  const skipped: string[] = [];

  // 3. 基于 manifest.files 还原(只还原成功备份的文件)
  for (const [backupPath, success] of Object.entries(manifest.files)) {
    // 跳过备份失败的文件
    if (!success) {
      skipped.push(backupPath);
      continue;
    }

    const projectPath = RESTORE_MAP[backupPath];
    if (!projectPath) {
      console.warn(`未知的备份路径: ${backupPath},跳过`);
      skipped.push(backupPath);
      continue;
    }

    const srcPath = path.join(tempDir, backupPath);
    const destPath = path.join(PROJECT_ROOT, projectPath);

    if (fs.existsSync(srcPath)) {
      fs.mkdirSync(path.dirname(destPath), { recursive: true });
      fs.cpSync(srcPath, destPath, { recursive: true });
      restored.push(projectPath);
    } else {
      skipped.push(backupPath);
    }
  }

  // 4. 清理临时目录
  fs.rmSync(tempDir, { recursive: true, force: true });

  return {
    restored,
    skipped,
    backupType: manifest.type,
    version: manifest.version,
  };
}

Dry-Run 模式详解

Dry-run(预演模式)是 CLI 工具中常见的安全特性,允许用户在实际执行前预览操作结果。本实现采用函数分离 + 条件渲染的模式。

下图展示了预览模式和实际执行模式的核心区别:

infographic compare-binary-horizontal-badge-card-arrow
data
  title Dry-Run 模式与实际执行对比
  desc 预览模式和实际还原的关键区别
  items
    - label 预览模式
      desc 安全的只读预览
      icon mdi/eye
      children
        - label 提取 manifest.json
          desc 调用 tarExtractManifest 不解压整个归档
        - label 读取 manifest.files
          desc 获取实际备份的文件列表
        - label 统计文件数量
          desc 调用 tarList 计算每个路径的文件数
        - label 不修改任何文件
          desc 零副作用,可安全执行
    - label 实际执行
      desc 基于 manifest 的还原
      icon mdi/content-save
      children
        - label 解压整个归档
          desc 调用 tarExtract 提取所有文件
        - label 读取 manifest.files
          desc 获取实际备份成功的文件列表
        - label 按 manifest 复制文件
          desc 只还原 success: true 的文件
        - label 显示跳过的文件
          desc 报告 success: false 的文件

预览函数和执行函数

关键在于提供两个功能相似但副作用不同的函数:

// 预览函数:只读取 manifest,不解压不修改文件
export function getRestorePreview(backupPath: string): RestorePreviewItem[] {
  // 只提取 manifest.json,不解压整个归档
  const manifestContent = tarExtractManifest(backupPath);
  if (!manifestContent) {
    throw new Error("无法读取备份 manifest");
  }

  const manifest = JSON.parse(manifestContent);
  const previewItems: RestorePreviewItem[] = [];

  // 基于 manifest.files 生成预览
  for (const [backupPath, success] of Object.entries(manifest.files)) {
    if (!success) continue; // 跳过备份失败的文件

    const projectPath = RESTORE_MAP[backupPath];
    if (!projectPath) continue;

    // 从归档中统计文件数量(不解压)
    const files = tarList(backupPath);
    const matchingFiles = files.filter(
      (f) => f === backupPath || f.startsWith(`${backupPath}/`)
    );
    const fileCount = matchingFiles.length;

    previewItems.push({
      path: projectPath,
      fileCount: fileCount || 1,
      backupPath,
    });
  }

  return previewItems;
}

// 执行函数:实际解压并复制文件
export function restoreBackup(backupPath: string): RestoreResult {
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "restore-"));
  tarExtract(backupPath, tempDir); // 实际解压

  // 读取 manifest 驱动还原
  const manifest = JSON.parse(
    fs.readFileSync(path.join(tempDir, "manifest.json"), "utf-8")
  );

  const restored: string[] = [];
  for (const [backupPath, success] of Object.entries(manifest.files)) {
    if (!success) continue;
    const projectPath = RESTORE_MAP[backupPath];
    // ... 实际复制文件
    fs.cpSync(srcPath, destPath, { recursive: true });
    restored.push(projectPath);
  }

  return { restored, skipped: [], backupType: manifest.type };
}

两个函数的核心区别:

  • 预览:调用 tarExtractManifest() 只提取 manifest,再用 tarList() 统计文件数量
  • 执行:调用 tarExtract() 解压整个归档,基于 manifest.files 复制文件

组件层:条件分发

在 React 组件中,根据 dryRun 参数决定调用哪个函数:

interface RestoreAppProps {
  dryRun?: boolean; // 是否为预览模式
  force?: boolean; // 是否跳过确认
}

export function RestoreApp({ dryRun = false, force = false }: RestoreAppProps) {
  const [result, setResult] = useState<{
    items: RestorePreviewItem[] | string[];
    backupType?: string;
    skipped?: string[];
  }>();

  // 预览模式:只读取 manifest
  const runDryRun = useCallback(() => {
    const previewItems = getRestorePreview(selectedBackup);
    setResult({ items: previewItems });
    setStatus("done");
  }, [selectedBackup]);

  // 实际还原:基于 manifest 执行还原
  const runRestore = useCallback(() => {
    setStatus("restoring");
    const { restored, skipped, backupType } = restoreBackup(selectedBackup);
    setResult({ items: restored, backupType, skipped });
    setStatus("done");
  }, [selectedBackup]);

  // 确认时根据模式分发
  function handleConfirm() {
    if (dryRun) {
      runDryRun();
    } else {
      runRestore();
    }
  }
}

关键设计:

  • 统一数据结构result 可以容纳预览和执行两种结果
  • 类型区分:预览返回 RestorePreviewItem[](含 fileCount),执行返回 string[]
  • 额外信息:执行模式返回 backupTypeskipped,用于显示完整信息

UI 层:差异化展示

预览模式和实际执行模式在 UI 上有明确区分:

{
  /* 确认提示:显示备份类型和文件数量 */
}
<Text color="yellow">
  {dryRun ? "[预览模式] " : ""}
  确认还原 {result?.backupType} 备份? 此操作将覆盖现有文件
</Text>;

{
  /* 完成状态:根据模式显示不同标题 */
}
<Text bold color="green">
  {dryRun ? "预览模式" : "还原完成"}
</Text>;

{
  /* 结果展示:预览模式显示文件数量统计 */
}
{
  result?.items.map((item) => {
    const isPreviewItem = typeof item !== "string";
    const filePath = isPreviewItem ? item.path : item;
    const fileCount = isPreviewItem ? item.fileCount : 0;
    return (
      <Text key={filePath}>
        <Text color="green">{"  "}+ </Text>
        <Text>{filePath}</Text>
        {/* 预览模式额外显示文件数量 */}
        {isPreviewItem && fileCount > 1 && (
          <Text dimColor> ({fileCount} 文件)</Text>
        )}
      </Text>
    );
  });
}

{
  /* 统计文案:使用 "将" vs "已" 区分 */
}
<Text>
  {dryRun ? "将" : "已"}还原: <Text color="green">{result?.items.length}</Text>{" "}
  项
</Text>;

{
  /* 显示跳过的文件(仅实际执行模式) */
}
{
  !dryRun && result?.skipped && result.skipped.length > 0 && (
    <Box flexDirection="column" marginTop={1}>
      <Text color="yellow">跳过的文件:</Text>
      {result.skipped.map((file) => (
        <Text key={file} dimColor>
          {"  "}- {file}
        </Text>
      ))}
    </Box>
  );
}

{
  /* 预览模式特有提示 */
}
{
  dryRun && <Text color="yellow">这是预览模式,没有文件被修改</Text>;
}

{
  /* 实际执行模式:显示后续步骤 */
}
{
  !dryRun && (
    <Box flexDirection="column" marginTop={1}>
      <Text dimColor>后续步骤:</Text>
      <Text dimColor>{"  "}1. pnpm install # 安装依赖</Text>
      <Text dimColor>{"  "}2. pnpm build # 构建项目</Text>
    </Box>
  );
}

命令行使用

# 预览模式:查看将要还原的内容
pnpm koharu restore --dry-run

# 实际执行
pnpm koharu restore

# 跳过确认直接执行
pnpm koharu restore --force

# 还原最新备份(预览)
pnpm koharu restore --latest --dry-run

输出对比

预览模式输出(Full 备份)

备份文件: backup-2026-01-10-12-30-00-full.tar.gz
备份类型: full
主题版本: 1.2.0
备份时间: 2026-01-10 12:30:00

[预览模式] 确认还原 full 备份? 此操作将覆盖现有文件 (Y/n)

预览模式
  + src/content/blog (42 文件)
  + config/site.yaml
  + src/pages/about.md
  + .env
  + public/img (128 文件)
  + src/assets/lqips.json
  + src/assets/similarities.json
  + src/assets/summaries.json

将还原: 8 项
这是预览模式,没有文件被修改

预览模式输出(Basic 备份)

备份文件: backup-2026-01-10-12-30-00-basic.tar.gz
备份类型: basic
主题版本: 1.2.0
备份时间: 2026-01-10 12:30:00

[预览模式] 确认还原 basic 备份? 此操作将覆盖现有文件 (Y/n)

预览模式
  + src/content/blog (42 文件)
  + config/site.yaml
  + src/pages/about.md
  + .env
  + public/img/avatar.webp

将还原: 5 项
这是预览模式,没有文件被修改

实际执行输出(含跳过的文件)

还原完成
  + src/content/blog
  + config/site.yaml
  + src/pages/about.md
  + .env
  + public/img

跳过的文件:
  - src/assets/lqips.json (备份时不存在)

已还原: 5 项
后续步骤:
  1. pnpm install # 安装依赖
  2. pnpm build # 构建项目
  3. pnpm dev # 启动开发服务器

写在最后

能看到这里,那很厉害了,觉得还挺喜欢的话,欢迎给我一个 star 呢~

github.com/cosZone/ast…

自认为这次实现的这个 CLI 对于我自己的需求来说,相当好用,只恨没有早一些实践,如果你看到这篇文章,可以放心大胆的去构建。

相关链接如下

React Ink

Git 同步 Fork

状态机与 useReducer

Next.js第二十一章(环境变量)

环境变量

环境变量一般是指程序在运行时,所需要的一些配置信息,例如数据库连接字符串,API密钥,端口号等。其次就是环境变量跟我们的操作系统有关,例如Linux,Windows,Mac等。

基本用法

我先带大家熟悉各种操作系统(Linux,Windows /cmd/powershell,Mac)的临时环境变量的命令:

查询环境变量
  1. Linux / MacOS / wsl (通用命令):

提示:wsl是Windows Subsystem for Linux的缩写,是Windows 10/11操作系统的一个子系统,允许用户在Windows上运行Linux命令行工具。

echo $PATH #查询PATH环境变量
  1. Windows /cmd:
echo %PATH% #查询PATH环境变量
echo %USERNAME% #查询用户名环境变量
  1. Windows /powershell:
dir Env:* #查询所有环境变量

输出展示:

RlsSvcPort                     22112
SESSIONNAME                    Console
SystemDrive                    C:
SystemRoot                     C:\WINDOWS
TEMP                           C:\Users\11955\AppData\Local\Temp
TERM_PROGRAM                   vscode
TERM_PROGRAM_VERSION           2.1.25
TMP                            C:\Users\11955\AppData\Local\Temp
USERDOMAIN                     XIAOMAN
USERDOMAIN_ROAMINGPROFILE      XIAOMAN
USERNAME                       11955
USERPROFILE                    C:\Users\11955
设置临时环境变量
  1. Linux / MacOS / wsl (通用命令) 提示:这个必学,后期进行部署的时候也很常用。:
export XM=123 #设置XM环境变量为123
echo $XM #查询XM环境变量
  1. Windows /cmd:
set XM=123 #设置XM环境变量为123
echo %XM% #查询XM环境变量
  1. Windows /powershell:
$env:XM='123' #设置XM环境变量为123
echo $env:XM #查询XM环境变量

script-shell

那么我们学会临时环境变量之后有什么用呢? 我们可以应用到项目中,例如本地环境连接数据库localhost,root,12346,生产环境就会变成8.8.8.8,xiaoman,^&TG*H#**P,等等诸如此类。

打开package.json文件,找到scripts配置项,添加一个脚本:

set DB_HOST=localhost(本地环境 自定义环境变量)

set DB_HOST=8.8.8.8(生产环境 自定义环境变量)

这样我们就可以根据不同的环境变量,连接不同的数据库

"scripts": {
    "dev": "set DB_HOST=localhost && next dev",
    "build": "set DB_HOST=8.8.8.8 && next build",
    "start": "next start"
},

为什么我在powershell终端,编写cmd的命令,而且是可以运行的!!!

image.png

原理解释:script-shell, 是因为npm运行script脚本的时候会单独开一个线程,这个线程在Linux/MacOs下是/bin/sh,在Windows下是cmd,所以我们在script脚本中要编写cmd的命令。

cross-env

我们可以观察上一小节的问题,我们在script脚本中编写了cmd的命令,但是如果他在powershell终端/MacOs终端/Linux终端,他就无法运行,也就是跨平台问题。

所以我们可以使用cross-env来解决这个问题,cross-env是一个跨平台的环境变量设置工具,他可以让我们在不同的操作系统下,使用相同的命令来设置环境变量。

npm install cross-env -D #安装cross-env

然后我们就可以在package.json文件中使用cross-env来设置环境变量:

"scripts": {
    "dev": "cross-env DB_HOST=localhost next dev",
    "build": "cross-env DB_HOST=8.8.8.8 next build",
    "start": "next start"
},

这样我们就可以在不同的操作系统下,使用相同的命令来设置环境变量了。

最佳实践

因为上述方式依旧麻烦,如果有很多的环境变量,我们的命令就会变得非常长,所以我们可以使用.env文件来存储环境变量。

Next.js 环境变量查找规则(官方规定),如果在其中一个链路中找到了环境变量,那么就不会继续往下找了。

  1. process.env
  2. .env.$(NODE_ENV).local
  3. .env.local(未检查的情况NODE_ENV。test)
  4. .env.$(NODE_ENV)
  5. .env

提示:NODE_ENV是Next.js自动注入的环境变量,开发模式他会注入development,生产模式他会注入production

所以我们就可以创建两个不同的env文件,一个是开发环境,一个是生产环境。

创建 .env.development.local文件(表示开发环境),并添加环境变量:

DB_HOST=localhost
DB_USER=root
DB_PASSWORD=123456

创建 .env.production.local文件(表示生产环境),并添加环境变量:

DB_HOST=8.8.8.8
DB_USER=xiaoman
DB_PASSWORD=^&TG*H#**P

创建/src/app/home/page.tsx文件,并添加环境变量:

//服务器组件 不要增加`use client`
export default function Home() {
  return <div>
    <h1>Home</h1>
    <p>DB_HOST: {process.env.DB_HOST}</p>
    <p>DB_USER: {process.env.DB_USER}</p>
    <p>DB_PASSWORD: {process.env.DB_PASSWORD}</p>
  </div>;
}

当我们执行npm run dev时,他会自动读取.env.development.local文件中的环境变量,当我们执行npm run build时,他会自动读取.env.production.local文件中的环境变量。

image.png

解决Vue打包后静态资源图片失效的终极指南

前言:恼人的图片失效问题

作为一名Vue开发者,你是否经历过这样的场景:本地开发时图片显示正常,但打包部署后却变成了令人头疼的404?这种问题在Vue项目中相当常见,今天我们就来深入剖析这个问题,并提供一整套解决方案。

问题根源分析

为什么图片会失效?

在深入了解解决方案前,我们先看看问题产生的根本原因:

Vue项目图片引用引用方式相对路径引用绝对路径引用动态绑定路径开发环境正常可能路径错误打包后路径解析问题打包后路径变化部署环境路径不匹配模块系统处理差异图片404

从上图可以看出,问题的核心在于开发环境与生产环境的路径差异以及构建工具的路径处理方式

解决方案大全

方案一:正确的静态资源引用方式

1. 放置在public目录(推荐)

将图片放在public目录下,使用绝对路径引用:

<!-- 在public目录下创建images文件夹,放入图片 -->
<img src="/images/logo.png" alt="Logo">

<!-- 或者使用BASE_URL -->
<img :src="`${publicPath}images/logo.png`" alt="Logo">
// 在Vue组件中
export default {
  data() {
    return {
      publicPath: process.env.BASE_URL
    }
  }
}

2. 使用require动态引入

对于在src/assets目录下的图片:

<template>
  <div>
    <!-- 直接使用require -->
    <img :src="require('@/assets/images/logo.png')" alt="Logo">
    
    <!-- 或者在data中定义 -->
    <img :src="logoUrl" alt="Logo">
  </div>
</template>

<script>
export default {
  data() {
    return {
      // 使用require确保Webpack正确处理
      logoUrlrequire('@/assets/images/logo.png'),
      
      // 动态图片名称
      dynamicImagenull
    }
  },
  methods: {
    loadImage(imageName) {
      this.dynamicImage = require(`@/assets/images/${imageName}.png`)
    }
  }
}
</script>

方案二:配置Vue CLI

1. 修改vue.config.js文件

// vue.config.js
const { defineConfig } = require('@vue/cli-service')

module.exports = defineConfig({
  // 部署应用时的基本URL
  publicPath: process.env.NODE_ENV === 'production' 
    ? '/your-project-name/' 
    '/',
  
  // 生产环境构建文件的目录
  outputDir'dist',
  
  // 放置生成的静态资源目录
  assetsDir'static',
  
  // 静态资源文件名添加hash
  filenameHashingtrue,
  
  chainWebpack: config => {
    // 处理图片规则
    config.module
      .rule('images')
      .test(/.(png|jpe?g|gif|webp|svg)(?.*)?$/)
      .use('url-loader')
      .loader('url-loader')
      .options({
        limit4096, // 小于4kb的图片转为base64
        fallback: {
          loader'file-loader',
          options: {
            name'static/img/[name].[hash:8].[ext]'
          }
        }
      })
  },
  
  // 开发服务器配置
  devServer: {
    // 启用静态资源服务
    contentBase: './public'
  }
})

2. 环境变量配置

// .env.production
VUE_APP_BASE_URL = '/production-sub-path/'

// .env.development  
VUE_APP_BASE_URL = '/'

// 在组件中使用
const baseUrl = process.env.VUE_APP_BASE_URL

方案三:CSS中的图片处理

CSS中背景图片的路径问题也需要特别注意:

/* 错误方式 - 打包后可能失效 */
.banner {
  background-imageurl('./assets/images/banner.jpg');
}

/* 正确方式1 - 使用相对public目录的路径 */
.banner {
  background-imageurl('/images/banner.jpg');
}

/* 正确方式2 - 在Vue单文件组件中使用 */
<style scoped>
/* Webpack会正确处理相对路径 */
.banner {
  background-imageurl('@/assets/images/banner.jpg');
}
</style>

/* 正确方式3 - 使用JS变量 */
<template>
  <div :style="bannerStyle"></div>
</template>

<script>
export default {
  data() {
    return {
      bannerStyle: {
        backgroundImage`url(${require('@/assets/images/banner.jpg')})`
      }
    }
  }
}
</script>

方案四:动态图片路径处理

对于从API获取的图片路径或需要动态计算的图片:

// utils/imagePath.js
export default {
  // 处理动态图片路径
  getImagePath(path) {
    if (!path) return ''
    
    // 如果是网络图片
    if (path.startsWith('http') || path.startsWith('//')) {
      return path
    }
    
    // 如果是相对路径且不在public目录
    if (path.startsWith('@/') || path.startsWith('./')) {
      try {
        return require(`@/assets/${path.replace('@/''')}`)
      } catch (e) {
        console.warn(`图片加载失败: ${path}`)
        return ''
      }
    }
    
    // public目录下的图片
    return `${process.env.BASE_URL}${path}`
  },
  
  // 批量处理图片
  batchProcessImages(images) {
    return images.map(img => this.getImagePath(img))
  }
}

方案五:部署配置调整

1. Nginx配置示例

server {
    listen 80;
    server_name your-domain.com;
    
    # Vue项目部署目录
    root /var/www/your-project/dist;
    index index.html;
    
    # 处理history模式路由
    location / {
        try_files $uri $uri/ /index.html;
    }
    
    # 静态资源缓存配置
    location ~* .(jpg|jpeg|png|gif|ico|css|js)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        
        # 确保正确找到资源
        try_files $uri $uri/ =404;
    }
    
    # 处理静态资源目录
    location /static/ {
        alias /var/www/your-project/dist/static/;
    }
}

2. Apache配置示例

<VirtualHost *:80>
    ServerName your-domain.com
    DocumentRoot /var/www/your-project/dist
    
    <Directory /var/www/your-project/dist>
        Options Indexes FollowSymLinks
        AllowOverride All
        Require all granted
        
        RewriteEngine On
        RewriteBase /
        RewriteRule ^index.html- [L]
        RewriteCond %{REQUEST_FILENAME} !-f
        RewriteCond %{REQUEST_FILENAME} !-d
        RewriteRule . /index.html [L]
    </Directory>
    
    # 静态资源缓存
    <FilesMatch ".(jpg|jpeg|png|gif|js|css)$">
        Header set Cache-Control "max-age=31536000, public"
    </FilesMatch>
</VirtualHost>

调试技巧和工具

1. 构建分析工具

// 安装分析插件
// npm install webpack-bundle-analyzer -D

// vue.config.js中配置
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin

module.exports = {
  chainWebpack: config => {
    // 只在分析时启用
    if (process.env.ANALYZE) {
      config.plugin('webpack-bundle-analyzer')
        .use(BundleAnalyzerPlugin)
    }
  }
}

// package.json中添加脚本
"scripts": {
  "analyze""ANALYZE=true vue-cli-service build"
}

2. 路径调试方法

// debugPaths.js - 调试路径问题
export function debugResourcePaths() {
  console.log('当前环境:', process.env.NODE_ENV)
  console.log('BASE_URL:', process.env.BASE_URL)
  console.log('publicPath配置:', process.env.VUE_APP_PUBLIC_PATH)
  
  // 测试图片路径
  const testPaths = [
    '@/assets/logo.png',
    '/images/logo.png',
    './assets/logo.png'
  ]
  
  testPaths.forEach(path => {
    try {
      const resolved = require(path)
      console.log(`✓ ${path} => ${resolved}`)
    } catch (e) {
      console.log(`✗ ${path} 无法解析`)
    }
  })
}

最佳实践总结

项目结构建议

project/
├── public/
│   ├── index.html
│   └── images/          # 不常更改的图片,直接引用
│       ├── logo.png
│       └── banners/
├── src/
│   ├── assets/
│   │   └── images/      # 组件相关的图片
│   │       ├── icons/
│   │       └── components/
│   ├── components/
│   └── views/
├── vue.config.js        # 构建配置
└── package.json

引用策略决策图

开始图片引用图片类型公共/不常更改的图片组件专用图片动态/用户上传图片放入public目录使用绝对路径引用
/images/xxx.png放入src/assets目录使用require或
@/assets/路径API返回完整URL
或单独处理构建和部署部署后检查图片正常显示图片404检查构建配置检查服务器配置路径调试

实用代码片段集合

// 1. 图片懒加载指令
Vue.directive('lazy', {
  insertedfunction (el, binding) {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          el.src = binding.value
          observer.unobserve(el)
        }
      })
    })
    observer.observe(el)
  }
})

// 使用方式
// <img v-lazy="imageUrl" alt="">

// 2. 图片加载失败处理
Vue.directive('img-fallback', {
  bindfunction (el, binding) {
    el.addEventListener('error'() => {
      el.src = binding.value || '/images/default.jpg'
    })
  }
})

// 3. 自动处理图片路径的混合
export const imageMixin = {
  methods: {
    $img(path) {
      if (!path) return ''
      
      // 处理各种路径格式
      if (path.startsWith('http')) return path
      if (path.startsWith('data:')) return path
      if (path.startsWith('/')) return `${this.$baseUrl}${path}`
      
      try {
        return require(`@/assets/${path}`)
      } catch {
        return path
      }
    }
  },
  computed: {
    $baseUrl() {
      return process.env.BASE_URL
    }
  }
}

常见问题Q&A

Q1: 为什么有的图片转成了base64,有的没有?
A: 这是由Webpack的url-loader配置决定的。默认小于4KB的图片会转为base64,减少HTTP请求。

Q2: 如何强制所有图片都不转base64?

// vue.config.js
chainWebpack: config => {
  config.module
    .rule('images')
    .use('url-loader')
    .loader('url-loader')
    .options({
      limit: 1 // 设置为1字节,几乎所有图片都不会转base64
    })
}

Q3: 多环境部署路径不同怎么办?

// 使用环境变量
const envPublicPath = {
  development: '/',
  test: '/test/',
  production: 'https://cdn.yourdomain.com/project/'
}

module.exports = {
  publicPath: envPublicPath[process.env.VUE_APP_ENV]
}

结语

Vue项目图片打包问题看似简单,实则涉及Webpack配置、部署环境、引用方式等多个方面。通过本文的详细讲解,相信你已经掌握了解决这一问题的全套方案。记住关键点:理解Webpack的构建过程,合理规划项目结构,正确配置部署环境

实践是检验真理的唯一标准,赶紧把这些方案应用到你的项目中吧!如果你有更好的解决方案,欢迎在评论区分享讨论。

❌