阅读视图

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

从零构建 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的构建过程,合理规划项目结构,正确配置部署环境

实践是检验真理的唯一标准,赶紧把这些方案应用到你的项目中吧!如果你有更好的解决方案,欢迎在评论区分享讨论。

这两个网站,一个可以当时间胶囊,一个充满了赛博菩萨。

你好呀,我是歪歪。

前两天不是发了这篇《可怕,看到一个如此冷血的算法。》嘛。

文章中有这样的一个链接:

我当时放这个链接的目的是为了方便大家直达吃瓜现场。

但是,由于这个帖子最终被证实是假的,所以被官方给“夹”了:

幸好,原文本来就不长,所以我在我的文章中把原文全部给截下来了。

也算是以另外一种形式保留了吃瓜现场。

如果这个“爆料”的帖子再长一点,按照我的习惯,我可能就不会把整个帖子搬运过来了,只会留取我认为关键的部分。

但是这种“我认为关键的部分”是非常主观的,有的人就是想看原贴长什么样,但是原贴又被删除了,怎么办?

我教你一招,老好用了。

时间胶囊

在万能的互联网上,有这样一个仿佛是时间胶囊一般存在的神奇的网站:

archive.org/

这个网站是叫做"互联网档案馆"(Internet Archive),于 1996 年成立的非营利组织维护的网站。

自 1996 年以来,互联网档案库与世界各地的图书馆和合作伙伴合作,建立了一个人类在线历史的共享数字图书馆。

这个网站有一个非常宏大的愿景:

捕捉大小不一的网站,从突发新闻到被遗忘的个人页面,使它们能够为子孙后代保持可访问性。

所以里面收藏了的内容有免费书籍、电影、软件、音乐、网站等。

截至目前,该网站收集了这么多的数据:

其中网站的数量是最多的,有 1T,超过 1T 的时候,官方还发文庆祝了一下:

这个 1T 中的 T 指的是什么呢?

Trillion。

一个非常小众的词汇啊,歪师傅也不认识,所以我去查了一下:

这个图片上一眼望去全是 0。

1 Trillion 就是 1,000,000,000,000

反正是数不过来了。

感觉成都都没有这么多 0。

这个网站怎么用呢?

很简单。

拿前面 reddit 中被“夹”了的帖子举例。

我不是给了吃瓜现场的链接嘛。

你把链接往“时光机”的这个地方一粘:

你就会看到这个有一个时间轴的页面:

把鼠标浮到有颜色的日期上,就能看到各个时间点的页面快照了。

颜色越深代表那一天的快照越多:

比如,我们看一下这个网站收集到的第一个快照:

点进去,就是我们要找的吃瓜现场。

发帖后的两小时就被收集到了,速度还是挺快的。

从数据上看,这个时候已经有 3.7k 个点赞和 255 个评论,已经有要起飞的预兆了。

换个时间的快照,还可以看到点赞和评论的数据变化,比如发帖一天后:

点赞量已经是 71k,评论数来到了 3.8K,直接就是一个起飞的大动作。

这里只是用这个帖子举个例子。

再举一个例子。

也是我的真实使用场景。

有一次我在研究平滑加权轮询负载均衡策略算法为什么是平滑的。

和各类 AI 讨论了半天,它们也给出了各种参考文献。

我在其中一个参考文献中看到了这样一个链接:

tenfy.cn/2018/11/12/…

我知道这个链接的内容就是我要找的内容,但是这个链接跳转过去已经是 404 了:

于是,时间胶囊就派上用场了。

我直接把这个链接扔它:

找到了这个网页在 2019 年 12 月 10 日的快照:

通过这种方式就找到了原本已经被 404 的网页内容。

在看一些时间比较久远的文章的时候,参考链接打不开的情况,还是比较常见的。

所以这个方式是我最常用的一个场景。

此外,还有另外一个场景,就是偶尔去怀旧一下。

比如,中文互联网的一滴眼泪:天涯论坛。

这是 20 年前,2006 年 1 月的天涯论坛首页,一股浓烈的早期互联网风格:

在图片的右下角你还能看到“2006 天涯春晚”的字样。

另外,你不要觉得这只是一个静态页面。

里面的部分链接还是可以正常跳转的。

比如,这个链接:

点进去,你可以看到最最古早的一种直播形式:文字直播。

2006 年 1 月 2 日,《武林外传》开播。

天涯这个文字直播的时间是 2006 年 1 月 19 日,《武林外传》当时正在全国热播。

天涯网友在这个页面下提出自己关于《武林外传》的问题,作为天涯的知名写手,宁财神本人会选择部分问题进行回复。

我截取了几个我觉得有意思的回复:

这种行为这算不算是官方剧透了?

当年祝无双这个角色是真的不让人讨喜啊。幸好当时的网络还不发达,不然我觉得真有可能“网爆祝无双”。

DVD,一个多么具有年代感的词。

写文章的时候,我本来是想截几张图就走的,最多五分钟搞定。

结果我竟然一页页的翻完了这个帖子,看完之后才发现在这个帖子里面待了半个多小时。

时间过的还是很快的。

站在 2026 年,看 2006 的帖子,中间有 20 年的光阴。

但是就像是 2006 年佟掌柜对要给她干二十年工才能还清债务的小郭说的那样:不要怕,二十年快得很,弹指一挥间。

前几天小郭在微博上还回应了正式赎身这个梗。

去了六里桥、去了同福夹道、去了左家庄站、还去了祥蚨瑞,最后在人来人往的北京街头,一个猝不及防的回眸:

这是我的童年回头看了我一眼。

十几岁的不了解佟掌柜的这句话,三十出头了,一下就理解了:20 年,真的很快呀。

看到 2006 年的天涯的时候,我依稀想起了一些当年的往事。

那个时候我才 12 岁,看电视剧是真的在电视机上看,我还记得家里的电视机都是这样的“大屁股”电视机:

还记得《武林外传》每集开始,唱主题曲的时候,电视上面会显示一个电脑的桌面:

所以每次开头的时候,我就会叫表妹过来,对她说:你看,我等下把电视变成电脑。

那个时候表妹才 7 岁,我这个 12 岁的哥哥当然是把她唬的一愣一愣的。

那个时候电脑也还是一个稀奇的物品,虽然是乡下的学校,但是也还是有一个微机室,去微机室上课必须要带鞋套的那种。

所以 2006 年的天涯,我肯定是没有看过的,但是在 2026 年看到 2006 的天涯,我还是想起了很多童年往事。

对了,前几天才给表妹过完 27 岁的生日:

看着这张照片,再想起 7 岁时那个相信哥哥可以把电视变成电脑给她看《武林外传》的妹妹。

“二十年快得很,弹指一挥间”。

你说这不叫时间胶囊,叫什么?

再看一下 10 年前,2016 年 1 月 1 日的天涯,彼时的天涯可以说是如日中天,非常多的网友天天泡在论坛里面,谈古论今,激扬文字。

这是那天的天涯首页截图:

热帖榜第一的是一个关于纯电动汽车的帖子,我进去看了一下:

这个帖子的点击量是 10w,有 816 个回复。

可见这确实是当时的一个非常热门的话题。

按照作者的观点,纯电汽车代替燃油汽车,还很长的路要走。

站在 10 年后的今天,其实我们已经知道答案了。

但是,当我看到这个回复的时候,我还是佩服天涯网友的眼光:

除了天涯,还可以考古很多其他的网站。

比如,B 站:

从 2011 年开始有了网页快照,我随便点开一看,满满的历史感:

而这是 2016 年,10 年前的 B 站首页:

当时还有一个专门的鬼畜区:

而这里的一些视频甚至还是可以播放的。

比如这个“启蒙作品”:

现在在 B 站有 160w 的播放:

在这个视频的评论区,你能找到大量来“考古”的人:

二十年都弹指一挥间了,别说区区十年了。

从 B 站怀旧完成后,随便,我也去磨房、马蜂窝、穷游网看了一圈,随便选了 2012 年到 2016 年间的一些页面,感谢它们陪我度过了一整个美好的大学生活。

是我当时认识、感知、体验这个的广阔世界的一个重要窗口。

感谢磨房 4 年的陪伴:

感谢马蜂窝 4 年的陪伴:

感谢穷游网 4 年的陪伴:

如果你也有想要寻找的记忆,可以尝试在这个网站上去找一找。

存档

既然已经聊到“archive”了,那就顺便再分享一个“archive.today”。

archive.ph/

这个网站和前面的“互联网档案馆”最大的一个差异是“互联网档案馆”是它主动去做“网页快照”,什么时候做,什么页面做,并不一定。

而“archive.today”是一个你可以去主动存档的网站。

比如,还是说回 reddit 上的那个帖子。

帖子下面有这样的一个回复:

这个回复中的超链接就是回复者找到的关于这个“爆料”是 AI 生成的证据。

点过去是这样的:

他提供的是一个网页存档。

为什么他要这么做呢?

你想想,如果他提供一个原始链接,但是这个原始链接突然有一天找不到了,岂不是很尴尬?

但是先在“archive.today”上存档一下,然后把这个存档后的链接贴出来,就稳当多了。

以后你要保存证据的话,你就可以使用这个网站。

另外,这个网站还有一个骚操作。

反而是骚操作让这个网站的打开率更高一点。

国外的一些网站可能有些文章是要付费才能看到的。

比如纽约时报:

但是,如果你一不小心把付费文章的链接贴在这个网站上去搜索。

有一些“好事之人”已经帮你把文章在这个网站上做了快照了,这些人可以称之为“赛博菩萨”,因为这些“菩萨”,你就可能看到免费的原文了:

在这里叠个甲啊,偶尔看到一两篇的话可以这样操作一下,就当时是试看了。

如果经常要看的话,还是充点钱吧。

对了,多说一句,上面提到的神奇的网站既然叫做时光胶囊,还有一些赛博菩萨,这些魔法世界中才有的东西,那肯定需要你会对应的魔法咒语才能访问到。如果你不会魔法,强行访问,那你肯定要撞到墙上。

【Vue-2/Lesson62(2025-12-10)】模块化与 Node.js HTTP 服务器开发详解🧩

🧩在现代软件工程中,模块化不仅是提升代码可读性、可维护性和复用性的核心手段,更是构建大型应用系统的基石。而 Node.js 作为全栈 JavaScript 运行时环境,其原生支持的模块系统和 HTTP 服务能力,为开发者提供了从零搭建 Web 服务的强大工具。本文将围绕 模块化方案的意义Node.js HTTP 服务器的实现细节 展开全面、深入的解析,并结合实际代码示例,系统梳理从基础概念到工程实践的完整知识链。


🔌 为什么要有模块化方案?

模块化(Modularization)是将复杂程序分解为多个独立、职责单一、可组合单元的编程范式。它并非某一种具体技术,而是一种设计思想,贯穿于前后端开发的整个生命周期。

1. 📦 代码组织与管理

没有模块化的代码如同一锅大杂烩:所有逻辑挤在一个文件中,变量、函数、类混杂,难以理解、调试和扩展。模块化通过“分而治之”的策略,将功能拆解:

  • 前端项目:UI组件、工具函数(utils)、API请求封装、状态管理等各自成模块。
  • 后端项目:采用 MVC 架构,将路由(Router)、控制器(Controller)、模型(Model)、中间件(Middleware)分离。

例如,在 Express 应用中,routes/user.js 负责用户相关路由,controllers/userController.js 处理业务逻辑,models/User.js 定义数据结构——这种结构清晰、职责分明。

2. 🛡️ 避免全局变量污染

早期前端开发常将变量直接挂载到 window 对象上,极易引发命名冲突。模块化通过作用域隔离解决此问题:

  • CommonJS(Node.js 默认) :每个文件是一个模块,内部变量默认私有,通过 module.exports 导出,require() 导入。
  • ES6 Modules(ESM) :使用 import/export 语法,变量在模块作用域内,不会污染全局。
// math.js (ESM)
export const add = (a, b) => a + b;

// main.js
import { add } from './math.js';
console.log(add(2, 3)); // 5

3. ♻️ 提高代码复用性

模块是天然的“积木”。通用逻辑(如日期格式化、HTTP 请求封装)一旦封装为模块,即可在多个项目中复用:

  • 自建工具库:utils/string.jsutils/array.js
  • 第三方依赖:通过 npm 安装的 lodashaxios

4. 🔧 增强可维护性

单一职责原则(SRP)要求一个模块只负责一件事。这带来三大优势:

  • 封装性:隐藏内部实现,仅暴露必要接口(如只导出 getUser(id) 而非数据库连接细节)。
  • 低耦合:修改模块 A 不影响模块 B。
  • 易测试:可对单个模块进行单元测试。

5. 🧭 依赖管理

模块化显式声明依赖关系,避免“隐式引用”导致的混乱:

// userController.js 明确依赖 userService
const userService = require('./userService');

工具(如 Webpack、Rollup)能自动分析依赖图,按需打包或加载。

6. 👥 支持团队协作

多人并行开发时,模块化允许开发者专注特定功能模块,互不干扰:

  • 独立开发:前端 A 写登录组件,后端 B 写认证 API。
  • 独立测试:各模块可单独运行测试用例。
  • 代码审查:PR 聚焦于特定模块变更。

📜 模块化的发展历程

前端模块化演进

  • 无模块时代(<2015)<script> 标签堆砌,全局变量泛滥。
  • AMD/CMD(RequireJS/Sea.js) :异步加载,解决浏览器环境依赖问题。
  • UMD:兼容 AMD、CommonJS 和全局变量的“万能”格式。
  • ES6 Modules(2015+) :语言标准,静态分析,成为现代前端主流(Vue/React/Angular 均基于 ESM)。

后端模块化(Node.js)

  • CommonJS:Node.js 诞生之初采用,同步 require(),适合服务器端 I/O 阻塞场景。
  • ESM 支持:Node.js v13.2+ 原生支持 .mjs 文件或 package.json"type": "module"

尽管 ESM 是未来趋势,但大量 Node.js 生态库仍基于 CommonJS,二者共存是当前常态。


🖥️ Node.js HTTP 服务器实战解析

Node.js 内置 http 模块,无需额外依赖即可创建高性能 Web 服务器。以下通过逐步演进的代码示例,剖析其核心机制。

📁 基础版本(note1.md)

const http = require("http");
const url = require("url");

const users = [/* 用户数据 */];

const server = http.createServer((req, res) => {
  const parsedUrl = url.parse(req.url, true);
  console.log(parsedUrl);
  res.end('hello');
});

server.listen(1314, () => {
  console.log('Server is running on port 1314');
});

关键点:

  • 模块引入:使用 CommonJS 的 require() 加载内置模块。
  • URL 解析url.parse(req.url, true) 将 URL 字符串解析为对象(含 pathname, query 等属性)。
  • 问题:监听端口(1314)与注释(1214)不一致,属低级错误。

🚦 路由增强版(note2.md)

const server = http.createServer((req, res) => {
  const parsedUrl = url.parse(req.url, true);
  if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/users') {
    res.statusCode = 200;
    res.setHeader('Content-Type', 'text/html;charset=utf-8');
    const html = `<html><body><h1>Hello World</h1></body></html>`;
    res.end(html);
  } else {
    res.end('hello');
  }
});

改进:

  • 简单路由:根据 pathname 区分路径,返回不同内容。
  • 响应头设置Content-Type 指定为 HTML 并声明 UTF-8 编码,避免中文乱码。
  • 未利用数据users 数组定义但未使用,属资源浪费。

📊 数据驱动 HTML 版(note3.md / server.js)

function generationHtml(users) {
  const userRows = users.map(user => `
    <tr>
      <td>${user.id}</td>
      <td>${user.name}</td>
      <td>${user.email}</td>
    </tr>
  `).join('');
  
  return `
    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="UTF-8">
      <title>User List</title>
      <style>
        table { width: 100%; border-collapse: collapse; }
        th, td { border: 11px solid #ccc; padding: 8px; }
      </style>
    </head>
    <body>
      <h1>Users</h1>
      <table>
        <thead><tr><th>ID</th><th>Name</th><th>Email</th></tr></thead>
        <tbody>${userRows}</tbody>
      </table>
    </body>
    </html>
  `;
}

const server = http.createServer((req, res) => {
  const parsedUrl = url.parse(req.url, true);
  if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/users') {
    res.statusCode = 200;
    res.setHeader('Content-Type', 'text/html;charset=utf-8');
    const html = generationHtml(users); // 使用用户数据生成HTML
    res.end(html);
  } else {
    res.statusCode = 404;
    res.end('<h1>404 Not Found</h1>');
  }
});

核心升级:

  • 动态 HTML 生成generationHtml 函数接收 users 数组,通过模板字符串构建完整 HTML 表格。
  • 完整页面结构:包含 DOCTYPE、meta 标签、CSS 样式,确保页面美观。
  • 404 处理:对非法路径返回标准 404 响应。

⚠️ 注意:函数名 generationHtml 应为 generateHtml(动词形式),属命名规范问题。


🛠️ 代码优化与最佳实践

1. 修复命名与结构

function generateHtml(users) { /* ... */ } // 正确命名

2. 分离路由逻辑

随着路由增多,将处理函数抽离:

function handleRoot(req, res) { /* 返回首页 */ }
function handleUsers(req, res) { /* 返回用户列表 */ }

const server = http.createServer((req, res) => {
  const { pathname } = url.parse(req.url, true); // 解构赋值
  if (pathname === '/') handleRoot(req, res);
  else if (pathname === '/users') handleUsers(req, res);
  else res.statusCode = 404;
});

3. 支持 RESTful API

返回 JSON 而非 HTML,适配前后端分离架构:

if (pathname === '/api/users') {
  res.setHeader('Content-Type', 'application/json');
  res.end(JSON.stringify(users));
}

4. 添加 CORS 头

允许跨域请求(前端开发必备):

res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');

5. 端口常量化

const PORT = 1314;
server.listen(PORT, () => console.log(`Server running on port ${PORT}`));

🧪 测试与部署

运行服务器

node server.js
# 输出: Server is running on port 1314

浏览器访问

  • http://localhost:1314 → 显示用户表格
  • http://localhost:1314/users → 同上
  • http://localhost:1314/abc → 404 页面

日志监控

console.log(parsedUrl) 可输出每次请求的详细信息,便于调试。


📌 总结

从模块化思想到 Node.js HTTP 服务器的逐层实现,我们见证了如何将理论转化为可运行的代码。模块化不仅解决了代码组织、复用、维护等根本问题,更为现代工程化开发铺平道路。而 Node.js 凭借其简洁的 API 和事件驱动模型,让开发者能快速构建轻量级 Web 服务。

尽管本文示例仅为入门级别,但它涵盖了:

  • ✅ CommonJS 模块机制
  • ✅ HTTP 请求/响应处理
  • ✅ URL 路由解析
  • ✅ 动态 HTML 生成
  • ✅ 错误处理(404)
  • ✅ 代码结构优化方向

这些正是构建 Express、Koa 等框架的底层基石。掌握这些核心概念,方能在全栈开发之路上行稳致远。🚀

AI时代2025年下半年学的这些Web前端特性有没有用?

40岁老前端学习记录第6波,每半年一次。对前五次学习内容感兴趣的可以去我的掘金专栏“每周学习记录”进行了解。

不过这次前端记录有别于过往,会聊一聊学这些东西有没有价值。

先看学了什么。

第27周 2025.6.30-7.6

本周学习Select原生组件的样式自定义。

其中按钮部分若想完全自定义,使用下面的CSS代码:

select { appearance: base-select; }

若想下拉部分的样式可以自定义,则需要使用::picker()伪元素函数设置:

::picker(select) { appearance: base-select; }

此时,select元素和option元素就可以自如的自定义了。

其中,::picker-icon伪元素是设置下拉按钮后面那个三角的:

::checkmark伪元素伪元素指向的是下拉列表选中选项前面的勾勾:

根据我的实践,已经可以完全覆盖<select>元素的样式自定义了,唯一问题是目前仅Chrome浏览器支持。

更多更具体的细节参见我写的这篇文章:“好诶,select下拉框元素支持样式完全自定义啦!

第28周 2025.7.7-7.13

本周学习3个与HTML相关的东西。

1. 全新的search元素

Chrome 117和Safari 17新支持了一个HTML元素名为 <search>,用于定义搜索区域,通常作为表单的一部分。

兼容性如下图所示:

2. h1元素样式规则要变

如果页面有多个h1元素,且这些h1元素被<section>,<aside>,<nav>, 以及<article>等元素嵌套,那么h1元素会自动降级为h2, h3, h4这种。例如:

渲染效果则是这样的:

不过,浏览器可能会改变这种策略,即所有h1的字号保持一致。

然后这种变化是和CSS :heading伪类和:heading()函数一起出现的。

3. 声明式Shadow DOM

无需JavaScript即可定义Shadow DOM,支持服务器端渲染(SSR),提升组件隔离性。

示意代码:

也就是,DOM内容无需像过去一样,全部都通过JavaScript创建的。

但是,如果有交互行为,通常还是需要借助JS代码的,包括自定义组件的注册等。

由于HTML结构可以直接呈现,因此,后端输出就非常方便,SEO也会比过去更友好。

兼容性:

第29周 2025.7.14-7.20

本周学习的是JS新特性,还挺实用的。

RegExp.escape()是所有前端都要熟知的静态函数,用于安全地将字符串转换为正则表达式字面量,目前所有现代浏览器都已经支持。

详见此文:“JS正则新特性:安全过滤RegExp.escape方法

第30周 2025.7.21-7.27

本周学习CSS的if()函数。

CSS之前也支持类逻辑函数,例如数学函数中的min()max(),本质上也是有逻辑的,例如:max(300px, 100vw),表示:

  • 如果100vw的计算宽度大于300px,则最终尺寸300px

  • 否则是100vw的宽度。

现在,CSS支持 if() 函数了,这个是真正意义上的逻辑函数了。

目前,if() 支持三种不同类型的查询:

  • style():样式查询

  • media():媒体查询

  • supports():支持查询

其中,用得最多的当属 style() 样式查询,具体使用案例参见此文:“CSS倒反天罡居然支持if()函数了

第31周 2025.7.28-8.3

本周学习HTML popover属性新增的值hint

Popover可以说是弹出浮层最佳解决方案,自动显隐特性,外加顶层特性,非常香。

但是有一个问题,那就是一次最多只能有一个弹出层显示(如果不使用 showPopover() 方法手动设置的话),例如下面的Tips提示出现的时候会关闭前面的列表。

类似这种仅仅是用做信息展示的轻提示,其实无需关闭之前的弹出层,之前的popover属性能力就不够。

于是,从Chrome 133开始,popover属性新增了属性值hint,可以让轻提示显示的时候不会影响之前的弹出层。

目前仅Chrome浏览器支持:

第32周 2025.8.4-8.10

本周学习CSS索引和数量匹配函数sibling-index()sibling-count()

sibling-index()可以返回当前子元素是第几个,sibling-count()可以返回当前元素的父元素总共有多少个子元素。

这两个函数特别适合用在很多重复子元素需要动态匹配计算的场景。

例如下面的浮动等宽布局示意:

渲染效果如下(目前仅Chrome才有效果),宽度自动等比例分配:

更多细节可以参考我写的这篇文章:“CSS索引和数量匹配函数sibling-index sibling-count简介

第33周 2025.8.11-8.17

本周学习CSS @container scroll-state容器滚动查询。

此特性可以让我们,仅仅通过CSS,就可以检测是否滚动到容器边缘,Scroll Snap边缘,以及sticky元素是否触及定位的边缘。

 然后我们就可以实现更加精确友好的交互控制,参见下面的GIF示意。

还是挺实用的一个特性,例如很多文档需要阅读到底部才能按钮提交,就可以使用这里的CSS技术,无需JavaScript代码判断了。

详见此文:“CSS @container scroll-state容器滚动查询

第34周 2025.8.18-8.24

本周学习的是CSS社区呼声已久的一个特性,叫做corner-shape属性。

CSS的corner-shape属性是CSS背景与边框模块(Level 4)中引入的新特性,它通过扩展border-radius的功能,允许开发者自定义元素的角形状,而不仅限于传统的圆角效果。以下是其核心作用及特性:

扩展border-radius的几何形状

  • 传统圆角border-radius默认生成四分之一椭圆形的圆角(round)。

  • 新增形状corner-shape支持五种专业级角形状:

    • bevel(斜角):用直线替代圆弧,生成切角效果。

    • scoop(凹槽):创建内凹的弧形角。

    • notch(缺口):添加微小内凹的缺口。

    • squircle(超椭圆):介于圆和矩形之间的平滑形状。

    • 保留默认的round(圆角)。

该特性可以实现各种五花八门的图形效果。

特性是个好特性,可惜太新了点,目前仅Chrome 139以上版本才正式支持。

有兴趣的可以访问此文进行进一步了解:抢先学习大开眼界的CSS corner-shape属性

第35周 2025.8.25-8.31

本周学习的是HTML新特性,啥呢?

就是hidden属性支持了一个名为until-found的新值,默认不可见,但是如果用户搜索匹配,或者锚点定位了,就会自动显示,有助于特定场景下提高用户体验。

until-found隐藏的元素还支持一个名为beforematch的事件,在该元素从隐藏变成显示之前的一瞬间执行。

详见我撰写的这篇文章:“HTML之快速了解hidden=until-found的作用

第36周 2025.9.1-9.7

本周学习的也是个挺厉害的CSS特性,那就是@function规则,可以在CSS中函数编程了。

下面的代码是一个响应式布局的函数定义示意。

/* 在小于640px的屏幕上为侧边栏占用1fr的空间,在较大的屏幕上使用相对具体具体宽度值 */
@function --layout-sidebar(--sidebar-width: max(20ch, 20vw)) {
  result: 1fr;
  
  @media (width > 640px) {
    result: var(--sidebar-width) auto;
  }
}

.layout {
  display: grid;
  /*侧边栏宽度兜底20ch和20vw的较大计算值 */
  grid-template-columns: --layout-sidebar();
}

布局效果参见(需要Chrome 139+):

@function 规则可以定义可重用的计算逻辑,让 CSS 代码更灵活、模块化且易于维护。

更多案例与细节见我写的这篇文章:“@function自定义函数让CSS支持编程啦

第37周 2025.9.8-9.14

本周学习使用CSS linear()函数。

CSS 中的linear()函数用于创建自定义的线性缓动效果,它允许你通过定义多个关键点来精确控制动画在不同阶段的播放进度和速度变化。

无论是更加逼真的物理动画,还是特殊的阶梯式动画都不在话下。

目前所有浏览器都已经支持。

借助转换工具,可以将各类JS缓动动画变成CSS linear()函数,还是很厉害的一个特性。

详见我撰写的这篇文章:“使用CSS linear()函数实现更逼真的物理动画效果

第38周 2025.9.15-9.21

本周学习的新特性也是期盼已久的,几行CSS就能实现页面滚动的时候,菜单栏根据标题内容自动高亮。

使用的是CSS scroll-target-group属性以及:target-current伪类。

实现代码非常简单,HTML代码就下面这些:

<menu>
    <li><a href="#intro">前言</a></li>
    <li><a href="#ch1">第1章</a></li>
    <li><a href="#ch2">第2章</a></li>
</menu>

<article>
    <h1>欢迎来到我的博客</h1>
    <section id="intro">...</section>
    <section id="ch1">...</section>
    <section id="ch2">...</section>
</article>

CSS代码也非常简单,就这么点内容:

menu {
    position: fixed;
    scroll-target-group: auto;
}

a:target-current {
    color: red;
}

结束了,就结束了,对吧,给菜单容器设置scroll-target-group:auto,然后菜单里面的链接元素使用:target-current设置匹配样式就可以了。

此时,链接元素对应的href锚点元素进入区域的时候,链接元素就会高亮啦!

牛逼!

目前Chrome浏览器Only!

详见我撰写的这篇文章:“CSS scroll-target-group加:target-current滚动菜单自动高亮

第39周 2025.9.22-9.28

本周学习的是JS特性,Intl.Segmenter构造函数。

Intl.Segmenter不仅能精准返回Emoji字符的长度,还能实现中文语言的自动分词,分句效果,这个特性比预想的实用的多了。

目前所有现代浏览器都支持。

更多内容参见我的这篇文章:“使用Intl.Segmenter返回更准确的字符长度

第40周 2025.9.29-10.5

国庆假期,学什么学,起来high啦!

第41周 2025.10.6-10.12

本周学习CSS field-sizing属性,该属性可以让文本输入框根据输入内容的多少自动改变宽度和高度大小。

某些场景下,还是挺实用的。

例如<textarea> 元素内容输入的时候,高度自动变高。

以前都是需要使用div元素模拟,或者JS代码计算,现在就一行CSS代码的事情。

Chrome已经支持,Safari即将大范围支持,这个CSS的应用前景还可以。

更多内容,可见我撰写的这篇文章:“CSS field-sizing属性简介

第42周 2025.10.13-10.19

本周学习CSS reading-flowreading-order和两个新属性。

Web开发中,DOM文档的属性和视觉表现顺序不一致是很正常的。

在过去,我们会使用HTML tabindex属性进行交互处理。

但是HTML tabindex属性在页面复杂的时候,容易冲突。

在这种背景下,reading-flowreading-order属性应运而生。

其可以让可访问元素的Tab索引顺序和视觉表现顺序表现一致

目前仅Chrome浏览器支持:

注定沦为小众特性,有兴趣可以前往这里了解更多:“CSS reading-flow和reading-order属性简介

第43周 2025.10.20-10.26

本周学习Canvas新支持的锥形渐变绘制方法createConicGradient方法

CSS虽然也能实现锥形渐变,但是如果遇到需要前端截图的需求,那么CSS方案就会有问题,html2canvas并不支持锥形渐变。

此时,可以试试使用Canvas绘制图形。

浏览器全支持,下图就是使用createConicGradient()方法绘制的饼图。

完整代码和语法说明,参见我的这篇文章:“Canvas也支持锥形渐变了createConicGradient方法

第44周 2025.10.27-11.2

本周学习CSS :heading伪类和:heading()函数。

这两个CSS选择器是用来选择h1-h6元素的。

唉,说实话,这是我见过最让我失望的新特性。

完全看不到任何使用这个特性的理由。

是让人无语的垃圾特性。

后来经过同行提醒,这两个CSS伪类匹配的是语义上是h1~h6的元素,参考第28周学习的h1元素自动降级。

不过还是很烂的特性。

为什么说烂呢?有兴趣的可以访问这里进行了解:“垃圾特性之CSS :heading伪类和:heading()函数

第45周 2025.11.3-11.9

本周学习使用CSS锚点定位实现元素的鼠标跟随效果。

下图是其中一个案例的示意:

悬停跟着走,移开恢复到选中元素上。

具体实现参见这篇文章:“CSS锚点定位实战-鼠标跟随交互效果

第46周 2025.11.10-11.16

本周学习CSS :state()伪类函数。

此选择器函数可以匹配Web Components内部通过attachInternals添加的状态,从而实现穿透组件,实现更加精准的样式设置。

兼容性如下:

我觉得还算个不错的设计。

具体案例和细节,可以参见我写的这篇文章:“介绍下与CSS自定义组件相关的:state()函数

第47周 2025.11.17-11.23

本周学习了CSS ::details-content伪元素,可以用来匹配<details>元素的内容DOM,我们可以使用这个伪元素设置背景,边框等样式。

不过,实际开发,还是使用它实现动画效果更多一些。

目前所有现代浏览器都已经支持了这个特性。

详见这篇文章:巧用CSS ::details-content伪元素实现任意展开动画

第48周 2025.11.24-11.30

本周学习CookieStore对象。

在前端开发的长河中,Cookie始终扮演着重要角色。

从用户身份识别到状态维持,它是浏览器与服务器之间轻量通信的核心载体。

但长期以来,我们操作Cookie的方式始终停留在通过document.cookie拼接字符串的“原始阶段”,不仅代码冗余易出错,还无法应对异步场景下的复杂需求。

比方说,我们要设置一个Cookie,需要先获取,然后再手动进行键值对+属性的字符串拼接,例如:

document.cookie = "name=zhangxinxu; max-age=3600; path=/"

这就很麻烦,属性顺序、符号格式稍有偏差就会导致失效。

正是因为上面这些不足,CookieStore API才应运而生。

它将Cookie操作封装为标准化的异步方法,让Cookie管理变得简洁、可控。

详见我的这篇文章:“醒醒,该使用CookieStore新建和管理cookie了

第49周 2025.12.1-12.7

本周学习单IMG标签的图片内阴影效果实现

列举了三种方法:

  1. 如果图片背景纯色,可以通过添加padding,让box-shadow内阴影显示。

  2. 如果不考虑兼容性,可以使用attr()函数让图片地址作为背景图显示。

  3. 最后一种方法,任意图片,且兼容性极佳,就是使用SVG滤镜。

效果示意:

详见我的这篇文章:“单IMG标签的图片内阴影效果实现

第50周 2025.12.8-12.14

本周学习CSS progress()函数。

语法:

progress(<value>, <start>, <end>)

可以返回0-1之间的进度值,常用于进度条、动态过渡、动画关键帧等场景,是 CSS 原生实现进度关联样式的核心工具。

还挺实用的。

但是兼容性一般。

详见我的这篇文章:”CSS progress()函数简介

第51周 2025.12.15-12.21

本周学习CSS锚点定位锚定容器回退检测语法。

CSS锚点定位有边界位置自动改变位置的能力,但是,浏览器却无法检测,导致很多交互效果并不能完全使用CSS实现,着实可惜。

然后,最近,Chrome浏览器新支持了一个特性,那就是锚定容器位置查询检测语法。

.float-element { position-try-fallbacks: flip-block; container-type: anchored; }@container anchored(fallback: flip-block) {   .float-element { /* 如果垂直定位方向改变,如何如何…… */ }}

可以实现下图所示的交互效果,纯CSS实现的:

详见我的这篇文章:“补全不足,CSS锚点定位支持锚定容器回退检测了

第52周 2025.12.22-12.28

本周学习CSS style()样式查询及其range范围语法。

比方说下面的案例,识别CSS变量范围,显示不同的文字颜色。

<span class="score" style="--score: 95;">
  <data>95</data>
</span>
<span class="score" style="--score: 85;">
  <data>85</data>
</span>
<span class="score" style="--score: 65;">
  <data>65</data>
</span>
<span class="score" style="--score: 35;">
  <data>35</data>
</span>

此时,就可以在style()函数中,使用大于号,小于号进行匹配:

@container style(--score >= 90) {
  data {
    color: gold;
  }
}
@container style(--score >= 80) and style(--score < 90) {
  data {
    color: green;
  }
}
@container style(--score >= 60) and style(--score < 80) {
  data {
    color: orange;
  }
}
@container style(--score < 60) {
  data {
    color: red;
  }
}

通俗易懂,三岁小孩也能知道是什么意思。

效果图参考:

Chrome和Safari浏览器都支持,而且已经支持一段时间了。

另外,此查询和attr()if()等函数配合使用后,该特性的场景适应性更是直线攀升。

详见这篇文章:“今日学习CSS style()样式查询及其range范围语法

-------------

好,以上就是我这个40岁的老前端2025年下半年学习的内容。

接下来回答很多前端同行关心的一个问题,都什么年代了,还学这些细枝末节的东西,有什么用?

下面简单说说我的看法,注意,篇幅原因,真就简单说说,回头我专门写一篇聊聊这个事情。

前端还值得学习吗?

结论,还是要学习的,只是需要从以前的熟悉降为了解,然后把节约的精力去学习其他东西。

我本人是AI重度使用者,编程、写作、甚至在自制AI漫剧。

对目前AI编程能力的边界感受挺深的。

短期

以目前的AI能力,当我们实现一个需求的时候,它所实现的代码往往是基于历史代码训练的最稳健的实现,功能运行没问题,但是很多时候,并不是最佳实现。

我已经遇到很多类似的例子了。

例如一个划词评论功能,实现代码洋洋洒洒,实则啰嗦无比,非要提醒AI使用selectionchange事件,代码这才指数级腰斩。

又比如一个富文本输入框中的AT功能,也是一堆selection和range控制,天书一样,未能充分应用输入框自身拖拽、回撤等能力。

我再举本文中出现的一个例子,实现<textarea>多行文本输入框跟随内容高度自动的问题,我可以100%打包票,AI一定会使用JavaScript代码实现,他不会想到说使用CSS field-sizing实现,因为field-sizing有兼容性问题,如果开发者不主动让他优先使用field-sizing,AI绝对不会选择这个技术选型的。

但是,其实很多项目是不需要考虑兼容性的,只需要最新的Chrome浏览器支持。

下面问题来了,如果开发人员不学习,不知道有这么个东西,请问,这个可以大大简化代码量,降低复杂度的东西如何在项目中应用?

如果只奔着功能实现,公司为何还需要你,找个刚毕业的大学生不更香吗?

所以,就目前而言,学肯定是要学的,但是我们不需要去掌握语法和细节了,只需要知道有这么个东西,在什么场景下使用最合适就可以了。

作为优秀的指挥官,务必高屋建瓴,对吧,都有哪些手下、各自优缺点务必要了然于心,这样指挥作战的时候才能发挥出最大的威能。

长期

不过AI本身也是发展的,有可能:

1. AI自动分析项目运行环境,目标客户群体,或者适应技术人员的风格癖好,选择最适合的技术方案。

2. 不是人指挥AI,而是AI指挥人——老子会很多,你想要使用哪一个。

到时候,说不定这些广度知识也就没有必要去花时间去学习的了。

嗯……想了下,试试现在AI有没有这个水平吧。

使用Trae测试。

我让其新建一个空白HTML页面。

结果其自以为是连功能都实现了,果不其然是JavaScript实现的:

然后我对其进行使用其他技术的提示,结果还是一些传统实现:

我看了下其contenteditable的实现,很糟糕,没有关闭富文本的能力:

document.querySelectorAll('.editable-div').forEach(div => {    const placeholder = div.getAttribute('data-placeholder');        // 设置初始placeholder    if (placeholder && !div.textContent.trim()) {        div.innerHTML = `<span style="color: #aaa;">${placeholder}</span>`;    }        // 点击时清除placeholder    div.addEventListener('click', () => {        if (div.innerHTML.includes(placeholder)) {            div.innerHTML = '';        }    });        // 失去焦点时恢复placeholder    div.addEventListener('blur', () => {        if (!div.textContent.trim() && placeholder) {            div.innerHTML = `<span style="color: #aaa;">${placeholder}</span>`;        }    });});

应该设置contenteditable="plaintext-only"

算了,我直接当面指挥他使用filed-sizing属性吧。

我观察AI的思考过程,发现其确实捞到了filed-sizing属性的语法,就在我以为AI可以完美完成任务的时候,结果生成的页面一看,尼玛,完全就是一本正经的胡说八道。

嗯……短期来看,学习还是有必要的,尤其是一些前沿技术。

更长期

其实人生在世,无需那么多算计、功利,纠结于投入产出比,担心价值流失之类的。

想那么多干嘛,持续学习,总不会错的,只要是能够提高自己的,都要学,前端技术本身如此,如何使用AI也是如此。

毕竟,人在职场,在社会,看起来是人与AI的竞争,本质上你活得如何,还是人与人的竞争。

我比你懂很多的前端知识,我又比你更懂AI,你说,我会担心被淘汰吗?

所以,2026年我还将继续学习,继续保持对前端的好奇心,欢迎关注,转发,一起进步。

UniApp 隐私合规神器!一键搞定应用市场审核难题 - lf-auth 隐私合规助手

📌 前言

随着国内网络安全法规的日益严格,各大应用市场(华为、小米、OPPO、vivo 等)和 iOS App Store 对应用的隐私合规要求越来越高。作为 UniApp 开发者,你是否遇到过以下痛点:

  • 应用上架被拒:隐私协议不完整,漏掉关键 SDK 声明
  • 不知道用了哪些 SDK:项目配置复杂,容易遗漏高风险 SDK
  • 手写隐私协议太难:不知道该写什么、怎么写才符合规范
  • 启动就初始化 SDK:没有在用户同意前拦截 SDK,被审核打回
  • 权限申请被投诉:没有合理说明权限用途,用户不信任

如果你也有这些困扰,那么 lf-auth 隐私合规助手 就是为你量身定制的解决方案!

✨ 产品亮点

🔍 1. 智能静态扫描 - 一键发现所有隐私风险

lf-auth 内置专业的静态扫描引擎,能够自动分析你的项目配置文件,精准识别潜在的隐私合规风险:

  • SDK 扫描:自动识别 manifest.json 中配置的 50+常见 SDK(友盟、极光、高德等)
  • 权限扫描:分析 Android/iOS 权限申请,评估权限风险等级
  • 依赖扫描:检测 package.json 中的高风险 NPM 包
  • 代码扫描:扫描源代码中的敏感 API 调用(如获取设备信息、位置、通讯录等)
  • 风险评分:自动计算隐私合规风险分数(0-100 分),并给出改进建议

核心代码示例:

import { createScanner } from "@/uni_modules/lf-auth/js_sdk/index.js";

// 读取配置文件
const manifest = require("@/manifest.json");
const packageJson = require("@/package.json");

// 创建扫描器并执行扫描
const scanner = createScanner();
const result = scanner.scan({
  manifest,
  packageJson,
});

// 查看扫描结果
console.log("风险评分:", result.riskScore); // 如:65分(高风险)
console.log("检测到的SDK:", result.sdks); // 所有SDK列表
console.log("检测到的权限:", result.permissions); // 所有权限列表
console.log("改进建议:", result.suggestions); // 专业的改进建议

扫描结果示例:

{
  riskScore: 65,
  sdks: [
    {
      id: "Statistic",
      name: "uni统计",
      riskLevel: "high",
      dataCollected: ["设备信息", "位置信息", "应用使用情况"],
      suggestion: "必须在隐私协议中详细说明数据收集和使用目的"
    },
    {
      id: "Push",
      name: "UniPush",
      riskLevel: "medium",
      dataCollected: ["设备信息", "推送Token"],
      suggestion: "需说明推送服务的数据收集范围"
    }
  ],
  permissions: [
    {
      id: "READ_PHONE_STATE",
      name: "读取设备信息",
      riskLevel: "high",
      purpose: "获取设备唯一标识",
      description: "用于统计分析和设备识别"
    }
  ],
  suggestions: [
    {
      type: "important",
      message: "检测到 2 个高风险SDK,建议使用 lf-auth 拦截组件确保用户同意前不初始化",
      riskLevel: "high"
    }
  ]
}

📝 2. 自动生成隐私协议 - 10 秒搞定专业文案

基于扫描结果,lf-auth 能够自动生成符合法律规范的隐私协议 HTML 文档,包括:

  • 隐私政策:完整的隐私保护政策,符合《个人信息保护法》要求
  • 用户协议:标准用户服务协议模板
  • SDK 清单:第三方 SDK 清单,可直接提交到应用市场
  • 多主题支持:浅色/深色主题,适配不同 UI 风格

生成代码示例:

import { createGenerator } from "@/uni_modules/lf-auth/js_sdk/index.js";

// 创建生成器(填入你的公司信息)
const generator = createGenerator({
  companyName: "某某科技有限公司",
  appName: "我的应用",
  contactEmail: "support@myapp.com",
  contactPhone: "xxxxxxx",
  companyAddress: "xxx科技园",
  theme: "default", // 或 "dark"
  includeChildren: true, // 包含未成年人保护条款
});

// 生成隐私政策HTML(基于扫描结果自动填充SDK和权限说明)
const privacyHtml = generator.generate(result);

// 生成用户协议
const agreementHtml = generator.generateUserAgreement();

// 生成SDK清单纯文本(用于应用市场提交表单)
const sdkListText = generator.generateSDKListText(result.sdks);

console.log(sdkListText);
// 输出:
// 第三方SDK清单:
// 1. uni统计 - 用于应用数据统计分析
//    数据收集:设备信息、位置信息、应用使用情况
// 2. UniPush - 用于消息推送服务
//    数据收集:设备信息、推送Token

一键完成扫描+生成:

import { scanAndGenerate } from "@/uni_modules/lf-auth/js_sdk/index.js";

// 一行代码搞定所有!
const result = scanAndGenerate({
  manifest: require("@/manifest.json"),
  packageJson: require("@/package.json"),
  generatorOptions: {
    companyName: "深圳市某某科技有限公司",
    appName: "我的应用",
    contactEmail: "support@myapp.com",
  },
});

// 直接使用生成的结果
const { scanResult, privacyPolicy, userAgreement, sdkList } = result;

🛡️ 3. 启动拦截组件 - 确保合规不翻车

这是 lf-auth 最核心的功能!提供开箱即用的隐私弹窗组件,确保用户首次启动时必须同意隐私政策,在用户同意前绝不初始化任何 SDK

组件使用示例:

<template>
  <view>
    <!-- 隐私拦截弹窗 -->
    <lf-privacy-popup
      :privacy-url="privacyUrl"
      :agreement-url="agreementUrl"
      :show-sdk-list="true"
      :sdk-list="sdkList"
      @agreed="handlePrivacyAgreed"
      @disagree="handlePrivacyDisagree"
    />

    <!-- 你的应用内容 -->
    <view>应用主体内容</view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      privacyUrl: "https://yourapp.com/privacy.html",
      agreementUrl: "https://yourapp.com/agreement.html",
      sdkList: [
        { name: "uni统计", purpose: "数据统计分析" },
        { name: "UniPush", purpose: "消息推送服务" },
      ],
      sdkInitialized: false,
    };
  },
  methods: {
    handlePrivacyAgreed() {
      console.log("用户已同意隐私政策");

      // ⚠️ 重点:只有用户同意后才初始化SDK
      if (!this.sdkInitialized) {
        this.initAllSDK();
        this.sdkInitialized = true;
      }
    },
    handlePrivacyDisagree() {
      console.log("用户拒绝隐私政策");
      // 退出应用或给予用户提示
      uni.showModal({
        title: "提示",
        content: "需要您同意隐私政策才能使用本应用",
        showCancel: false,
      });
    },
    initAllSDK() {
      // 在这里初始化所有SDK
      // 初始化友盟统计
      // this.initUmeng();
      // 初始化极光推送
      // this.initJPush();
      // 初始化高德地图
      // this.initAmap();
      console.log("所有SDK初始化完成");
    },
  },
};
</script>

组件核心特性:

特性 说明
🎨 美观 UI 专业的弹窗设计,支持浅色/深色主题
🔒 强制拦截 首次启动必须同意,用户拒绝可退出应用
📜 协议链接 支持跳转到完整的隐私政策和用户协议
📋 SDK 清单展示 可选择展示第三方 SDK 列表,增强透明度
💾 持久化存储 用户同意后永久记录,不会重复弹出
🌐 全平台支持 支持 App、H5、小程序

🎯 完整使用流程

Step 1: 安装插件

访问 DCloud 插件市场 - lf-auth 隐私合规助手,点击"使用 HBuilderX 导入插件"。

或手动下载后复制到项目的 uni_modules 目录下。

Step 2: 扫描并生成隐私协议

在项目中创建一个扫描脚本(如 scripts/scan-privacy.js):

import { scanAndGenerate } from "@/uni_modules/lf-auth/js_sdk/index.js";
import fs from "fs";

const result = scanAndGenerate({
  manifest: require("../manifest.json"),
  packageJson: require("../package.json"),
  generatorOptions: {
    companyName: "你的公司名称",
    appName: "你的应用名称",
    contactEmail: "support@yourcompany.com",
    contactPhone: "xxxxxxx",
    companyAddress: "公司地址",
  },
});

// 保存隐私协议到文件
fs.writeFileSync("./static/privacy.html", result.privacyPolicy);
fs.writeFileSync("./static/agreement.html", result.userAgreement);
fs.writeFileSync("./static/sdk-list.txt", result.sdkList);

console.log("✅ 隐私协议生成成功!");
console.log("风险评分:", result.scanResult.riskScore);
console.log("改进建议:", result.scanResult.suggestions);

Step 3: 集成隐私拦截组件

App.vue 中添加隐私拦截组件:

<template>
  <view>
    <lf-privacy-popup
      privacy-url="/static/privacy.html"
      agreement-url="/static/agreement.html"
      @agreed="handlePrivacyAgreed"
    />
  </view>
</template>

<script>
export default {
  onLaunch: function () {
    console.log("App Launch");
    // ⚠️ 不要在这里初始化SDK,等待用户同意
  },
  methods: {
    handlePrivacyAgreed() {
      // 用户同意后才初始化SDK
      this.initSDK();
    },
    initSDK() {
      // 初始化各种SDK...
    },
  },
};
</script>

Step 4: 提交应用市场

  1. 将生成的 sdk-list.txt 内容复制到应用市场的"第三方 SDK 清单"表单
  2. 上传 privacy.html 到你的服务器并在应用信息中填写隐私协议链接
  3. 提交审核,坐等通过!✅

💡 核心优势

对比手动处理隐私合规:

维度 手动处理 使用 lf-auth
SDK 识别 需逐个查看配置,容易遗漏 ✅ 自动扫描 50+常见 SDK
隐私协议编写 需要法律专业知识,耗时数小时 ✅ 10 秒自动生成专业协议
启动拦截 需自己开发弹窗组件 ✅ 开箱即用的拦截组件
SDK 延迟初始化 需手动改造代码逻辑 ✅ 提供标准化接入方案
应用市场提交 需手动整理 SDK 清单 ✅ 一键生成提交材料
维护成本 SDK 变更需重新整理 ✅ 重新扫描即可更新

对比其他隐私合规方案:

  • 专为 UniApp 优化:完美适配 UniApp 项目结构
  • 免费开源:MIT 协议,无任何使用限制
  • 轻量级:核心代码不到 100KB,不影响应用性能
  • 无外部依赖:纯 JavaScript 实现,不依赖任何第三方库
  • 持续更新:SDK 数据库持续维护,紧跟政策变化

📊 技术实现原理

静态扫描引擎

lf-auth 的扫描器通过解析 manifest.jsonpackage.json,利用内置的 SDK 风险数据库进行匹配:

// 部分核心扫描逻辑
class Scanner {
  _scanModules(modules) {
    Object.keys(modules).forEach((moduleKey) => {
      const sdkInfo = SDK_DATABASE[moduleKey];
      if (sdkInfo) {
        this.result.sdks.push({
          id: moduleKey,
          ...sdkInfo,
          source: "modules",
        });
      }
    });
  }

  _scanAndroidPermissions(appPlus) {
    const permissions = appPlus.distribute?.android?.permissions || [];
    permissions.forEach((permStr) => {
      const permKey = this.parseAndroidPermission(permStr);
      if (permKey && PERMISSION_DESCRIPTIONS[permKey]) {
        const permInfo = PERMISSION_DESCRIPTIONS[permKey];
        this.result.permissions.push({
          id: permKey,
          ...permInfo,
          platform: "android",
        });
      }
    });
  }

  calculateRiskScore() {
    let score = 0;
    this.sdks.forEach((sdk) => {
      switch (sdk.riskLevel) {
        case "critical":
          score += 25;
          break;
        case "high":
          score += 15;
          break;
        case "medium":
          score += 8;
          break;
        case "low":
          score += 3;
          break;
      }
    });
    return Math.min(100, score);
  }
}

内置 SDK 数据库

插件内置了 50+常见 SDK 的风险评估数据:

const SDK_DATABASE = {
  Statistic: {
    name: "uni统计",
    riskLevel: "high",
    dataCollected: ["设备信息", "位置信息", "应用使用情况"],
    provider: "DCloud",
    suggestion: "必须在隐私协议中详细说明数据收集和使用目的",
  },
  Push: {
    name: "UniPush",
    riskLevel: "medium",
    dataCollected: ["设备信息", "推送Token"],
    provider: "DCloud/个推",
    suggestion: "需说明推送服务的数据收集范围",
  },
  // ... 更多SDK数据
};

⚠️ 注意事项与最佳实践

1. 法律合规性声明

⚠️ 重要提示:本插件生成的隐私协议仅供参考,不构成法律建议。正式上线前建议:

  • 咨询专业法律人士审核
  • 根据实际业务场景调整协议内容
  • 确保协议内容与应用实际行为完全一致

2. SDK 延迟初始化最佳实践

// ❌ 错误做法:App启动就初始化SDK
export default {
  onLaunch() {
    this.initUmeng(); // 违规!用户还没同意
    this.initJPush();
  }
}

// ✅ 正确做法:等用户同意后再初始化
export default {
  data() {
    return { sdkReady: false };
  },
  methods: {
    handlePrivacyAgreed() {
      if (!this.sdkReady) {
        this.initUmeng();
        this.initJPush();
        this.sdkReady = true;
      }
    }
  }
}

3. 定期更新隐私协议

建议每次发版前重新扫描:

# 在发版脚本中加入扫描
node scripts/scan-privacy.js

4. 权限申请时机

在用户实际使用相关功能时再申请权限,而不是启动时就申请所有权限:

// ❌ 不好的做法
onLaunch() {
  uni.authorize({ scope: 'scope.userLocation' });
  uni.authorize({ scope: 'scope.camera' });
}

// ✅ 推荐做法
onUseMap() {
  // 用户点击地图功能时再申请位置权限
  uni.authorize({ scope: 'scope.userLocation' });
}

🎉 总结

lf-auth 隐私合规助手 是每个 UniApp 开发者的必备工具!它能帮你:

10 分钟搞定隐私合规,节省数小时的手动整理时间
一次通过应用市场审核,避免反复修改提交
专业的法律协议文案,保护你和用户的合法权益
规范的 SDK 延迟初始化,符合所有应用市场要求

📥 立即获取

👉 插件地址DCloud 插件市场 - lf-auth 隐私合规助手


💬 常见问题 FAQ

Q1:插件是免费的吗?
A:完全免费!MIT 开源协议,可商用。

Q2:支持哪些平台?
A:支持 App(Android/iOS)、H5、微信/支付宝/百度/字节跳动/QQ 小程序。

Q3:生成的隐私协议能直接用吗?
A:建议作为基础模板使用,正式上线前请法律人士审核。

Q4:我已经上架的应用可以用吗?
A:可以!扫描现有配置,更新隐私协议并重新提交审核即可。

Q5:SDK 数据库会更新吗?
A:会持续维护更新,紧跟应用市场最新政策要求。


有任何问题欢迎在评论区留言交流!

浏览器唤起桌面端应用(进阶篇)

————基于自定义协议的桌面端版本控制方案

基础篇中,我们已经实现了:

  • 通过 Windows 自定义协议(XXXX://open)
  • 从浏览器直接唤起本地桌面程序

本篇作为 进阶版,核心目标不再只是「能唤起」,而是:

在多版本桌面程序共存的情况下,实现“可控、可扩展、可演进”的版本选择机制


为什么需要“桌面端版本控制”?

在工程软件企业内部工具中,常见问题包括:

  • 多个版本并行安装1.0、2.0、3.0、...
  • 不同项目、不同数据依赖特定版本
  • 浏览器 / Web 系统需要精确控制启动的版本

如果只是简单写死一个 exe 路径,会带来:

  • ❌ 无法指定版本
  • ❌ Web 侧与桌面侧强耦合
  • ❌ 后期升级成本高

因此我们需要一个 “启动代理层(Launcher)”, 接下来我们以NX为案例去实现一个对于不同版本控制的唤起。


整体架构设计思路

浏览器
  |
  | nx://open?version=12
  v
Windows 协议注册(nx)
  |
  v
NxLauncher(bat / ps1)
  |
  | 解析参数 + 版本选择
  v
具体 NX 版本 exe

核心思想

❗ 浏览器永远只关心“意图”,

❗ 桌面端 Launcher 决定“如何执行”。

NxLauncher:启动代理的职责划分

NxLauncher 需要做什么?

  1. 接收并解析 URL
  • nx://open
  • nx://open?version=11
  • nx://open?version=12
  1. 清洗参数
  • 去掉协议前缀
  • 去掉不可见字符 / 空格
  • 保证解析稳定性
  1. 版本 → 可执行文件映射
  • version = 11 → NX 11 路径
  • version = 12 → NX 12 路径
  1. 兜底策略
  • 未指定 version → 启动最新版本
  • 指定版本不存在 → fallback
  1. 真正启动程序

落地方案

1. 注册表实现

新建文本,复制如下文字

Windows Registry Editor Version 5.00

[HKEY_CLASSES_ROOT\nx]
@="URL:NX Protocol"
"URL Protocol"=""

[HKEY_CLASSES_ROOT\nx\shell\open\command]
;ps1 使用的代码
;@="powershell -NoProfile -ExecutionPolicy Bypass -File \"D:\\Desktop\\NxLauncher.ps1\" \"%1\""
;bat 使用代码
@="\"D:\\Desktop\\NxLauncher.bat\" \"%1\""

将文件后缀改为reg, 命名为nx.reg

然后根据使用的脚本做修改, 双击注册

⚠️ 注意;请根据你本机NxLauncher的实际位置修改 @= 后的路径。

nx_reg.png

注册表原则

❌ 不判断版本,只做转发,不做逻辑

❌ 不写死 exe

✅ 只负责把 URL 原样交给 Launcher

2. NxLauncher实现

2.1 BAT 方案(轻量、零依赖)

适合

  • 环境受限
  • 不方便修改 PowerShell 策略
  • 希望注册表直接调用

特点

  • 使用 PowerShell 作为辅助解析
  • 控制力略弱,但稳定可靠
@echo off
setlocal EnableDelayedExpansion

:: ==================================================
:: NxLauncher.bat
:: 功能:
:: 1. 解析 nx://open?version=xx
:: 2. 去掉空格和不可见字符
:: 3. 输出调试信息
:: 4. 根据 version 打开对应 NX
:: ==================================================

:: 获取浏览器传来的 URL,去掉两端引号
set RAW_URL=%~1

:: ---------------------------
:: 去掉协议前缀 nx://open 或 nx://open/
:: ---------------------------
set PARAMS=%RAW_URL:nx://open/=% 
set PARAMS=%PARAMS:nx://open?=% 

:: 去掉开头的 ?(如果有)
if defined PARAMS if "%PARAMS:~0,1%"=="?" set PARAMS=%PARAMS:~1%

:: ---------------------------
:: 提取 version
:: ---------------------------
set VERSION=[]

:: 调用 PowerShell 去解析并 trim
for /f %%V in ('powershell -NoProfile -Command ^
    "$p='%PARAMS%'; if ($p -match 'version=(.+)') { ($matches[1]).Trim() }"') do set VERSION=%%V

:: ---------------------------
:: 输出调试信息
:: ---------------------------
:: echo RAW_URL=%RAW_URL%
:: echo PARAMS=%PARAMS%
:: echo VERSION=[%VERSION%]
:: pause

:: ---------------------------
:: NX 安装路径
:: ---------------------------
set NX12=D:\Program Files\Siemens\NX 12.0\NXBIN\ugraf.exe
set NX11=D:\NX\NX 11.0\NXBIN\ugraf.exe

:: ---------------------------
:: 根据 version 选择 NX
:: ---------------------------
set NX_EXE=

if /i "%VERSION%"=="12" if exist "%NX12%" set NX_EXE="%NX12%"
if /i "%VERSION%"=="11" if exist "%NX11%" set NX_EXE="%NX11%"

:: 默认最新
if not defined NX_EXE (
    if exist "%NX12%" set NX_EXE="%NX12%"
    if not defined NX_EXE if exist "%NX11%" set NX_EXE="%NX11%"
)

:: ---------------------------
:: 找不到 NX
:: ---------------------------
if not defined NX_EXE (
    echo NX not found!
    pause
    exit /b 1
)

:: ---------------------------
:: 启动 NX
:: ---------------------------
start "" %NX_EXE%

exit /b 0

将文件后缀改为bat, 命名为NxLauncher.bat

然后根据使用的脚本做修改

⚠️ 注意;请根据你本机Nx的实际位置修改路径。

NxLauncher_bat.png

设计重点

  • 不在 bat 中硬解析 URL(太脆弱)
  • 利用 PowerShell 做字符串清洗
  • bat 只负责“调度”

2.2 PowerShell 方案(推荐 / 可扩展)

新建文本,粘贴以下文本

# NxLauncher.ps1
# ==================================================
# 功能:
# 1. 解析 nx://open?version=xx
# 2. 有 version → 打开指定 NX 版本
# 3. 无 version → 默认打开最新版本
# 4. 调试输出并暂停,方便查看解析过程
# ==================================================

param (
    [string]$RawUrl
)

# ---------------------------
# 1. 清理 URL
# ---------------------------
$RawUrl = $RawUrl.Trim('"')  # 去掉可能的引号
Write-Host "RawUrl: $RawUrl"

# 去掉协议前缀 nx://open 或 nx://open/
$paramString = $RawUrl -replace '^nx://open/?', ''
Write-Host "Param string: $paramString"

# ---------------------------
# 2. 解析 version
# ---------------------------
Add-Type -AssemblyName System.Web
$query = [System.Web.HttpUtility]::ParseQueryString($paramString)

$version = $query['version']
Write-Host "Parsed version: [$version]"

# ---------------------------
# 3. NX 安装路径配置
# ---------------------------
$nx12 = 'D:\Program Files\Siemens\NX 12.0\NXBIN\ugraf.exe'
$nx11 = 'D:\NX\NX 11.0\NXBIN\ugraf.exe'

$nxExe = $null

# ---------------------------
# 4. 根据 version 选择 NX
# ---------------------------
switch ($version) {
    '12' { if (Test-Path $nx12) { $nxExe = $nx12 } }
    '11' { if (Test-Path $nx11) { $nxExe = $nx11 } }
}

# 默认最新
if (-not $nxExe) {
    if (Test-Path $nx12) { $nxExe = $nx12 }
    elseif (Test-Path $nx11) { $nxExe = $nx11 }
}

# ---------------------------
# 5. 检查 NX 是否存在
# ---------------------------
if (-not $nxExe) {
    Write-Host "NX not found!"
    Read-Host "Press Enter to exit..."
    exit 1
}

Write-Host "Using NX: $nxExe"

# ---------------------------
# 6. 启动 NX
# ---------------------------
Start-Process -FilePath $nxExe

# ---------------------------
# 7. 暂停查看输出
# ---------------------------
# Read-Host "Press Enter to exit..."

将文件后缀改为ps1, 命名为NxLauncher.ps1

然后根据使用的脚本做修改

⚠️ 注意;请根据你本机Nx的实际位置修改路径。

NxLauncher_ps1.png

适合

  • 长期维护
  • 复杂参数
  • 后续扩展(license / workspace / project)

优势

  • 原生 QueryString 解析
  • 逻辑清晰
  • 可维护性极高

3. 浏览器实现

    <button id="openNX11">唤起NX11</button>
    <button id="openNX12">唤起NX12</button>
    <button id="openNXAuto">自动选择最新版本</button>
    <script>
        document.getElementById('openNX11').addEventListener('click', () => {
            openNX('11');
        })
        document.getElementById('openNX12').addEventListener('click', () => {
            openNX('12');
        })
        document.getElementById('openNXAuto').addEventListener('click', () => {
            openNX();
        })

        function openNX(version = '') {
            let url = 'nx://open';
            const params = [];
            if (version) params.push(`version=${version}`);

            if (params.length > 0) {
                url += '?' + params.join('&');
            }

            window.location.href = url;
        }
    </script>

好处

  • 前端逻辑极简

  • 参数可自由扩展:

    • nx://open?version=12&project=A
    • nx://open?workspace=xxx

成果展示

NX演示.gif

可继续演进的方向

📦 JSON 配置驱动版本映射

🧠 自动检测已安装版本

🧩 NxLauncher → 通用 Desktop Launcher

🌐 Web 平台统一调度本地能力

✅ 总结

基础篇解决“能不能唤起”

进阶篇解决“唤起谁、如何唤起、以后怎么扩展”

这套思路本质上是:

用 Web 参数驱动桌面能力,用 Launcher 隔离不确定性。

我被 border-image 坑了一天,总算解决了

你不会还不知道 border-image 吧,那我跟你普及一下:

在元素的边框位置画图像,而不是常见的 solid ,dashed 这些线条,线段等。具体使用请参考# border-image 详解

现在才明白, border-image 如果理解错了,可能就要多花费很久的时间,就跟我这次一样。

先说避坑指南:

  1. border-image-slice 用设计稿尺寸,应该使用图片中的像素;
  2. 没有认真分析图片直接开切,弄明白哪些需要拉伸,哪些不需要,然后再去得到尺寸;
  3. 如果你切的尺寸不同,需要弄明白 border-image-width 绘制宽度。

故事的开始是这样的。

设计图是这样的:

刚开始的思路有:

  • 内容部分和外面的 QA 圆圈分开,也就是里面内容写上边框,但是我发现右下角边框只是占了一点点,并不是全部,而 border 设置边框要不就是一边,所以这种方法行不通;
  • 全部使用绝对定位弄上去,因为外面我可以使用 svg 整体,但是这样存在一个问题,就是里面的内容并不是高度一致的,当高度变高或者变窄了就会出现拉伸,当然 svg 默认不拉伸而是居中显示,当然也是不符合我的要求,所以这种方法也行不通;
  • 最笨的方法就是分成几块绝对定位,也就是 Q边框A 和对应的的那个下边线 ,可以实现,但是不够优雅,所以这种方法暂不考虑;
  • 可以发现这个都在边框的位置,那么可以使用 border-image 来实现,顺便把中间的背景白色也弄成图中的一部分,这样里面的内容就不需要再设置 padding 了,理论可行于是我就开始实践。

避坑指南1: border-image-slice 用设计稿尺寸

border: 36px solid transparent;
border-image: url(./images/qa.png) 36;
/*
是以下的简写:
border-image-source: url(./images/qa.png);
border-image-slice: 36;
*/

于是就变成了下面这个样子:

这是啥,咋成这样了;难道是我 border-image-slice 不对嘛,可是设计图就是 36 呀;于是我再次检查了设计图,发现的确是这么多,那可能是我对这个属性的理解不对,先看看 border-image-slice 文档。

表示到图像边缘的偏移量,在位图中的单位为像素点,在矢量图中则是坐标。对于矢量图,<number> 值与元素大小相关,而非矢量图的原始大小。因此,使用矢量图时,使用百分比值(<percentage>)更可取。

原来是图片的偏移量,像素点,不是设计图的,于是我根据图片比例算了算得到了 36 / (352/1259) = 128.76136363636363

border: 36px solid transparent;
border-image: url(./images/qa.png) 128.76136363636363;
/*
相当于下面的写法:
border-image-source: url(./images/qa.png);
border-image-slice: 128.76136363636363;
*/

首先 Q 正常了,下边的 A 明显有变形,同时中间的白色并没有覆盖。

避坑指南2: 不分析图片直接开切

拿到图片要分析哪些部分需要拉伸,哪些部分不需要拉伸。

首先思考 A 为啥会变形,我们知道 slice 是将图片分割成 9 部分,拉伸除了四个角的其他部分,而我 slice 设置的是一个值,一个值代表四边都是这么多,很明显图片 A ,也就是右边包含 A 部分要大,所以右边部分还需要单独设置。

--slice: calc(36 / (352/1259));
border: 36px solid transparent;
border-image: url(./images/qa.png) var(--slice) calc(57.5 / (352/1259)) var(--slice) var(--slice);
/*
相当于下面的写法:
border-image-source: url(./images/qa.png);
border-image-slice: var(--slice) calc(57.5 / (352/1259)) var(--slice) var(--slice);
*/

可以看到右边仍然变形,只不过跟之前相比被挤压了,为啥??于是我把对应的 9 宫格画出来研究一下,结果不画不知道,一画就发现了问题:

避坑指南3: 当切的宽度不同时,需要考虑绘制宽度,不然就会问不是说好的 1/2/3/4 不拉伸嘛

根据上图看到由于边框大小都是 36 ,即便我把右边的 slice 改大了,但是仍然是在 36 这个大小内绘制,既然可绘制的宽度大小没变,那么要想画完整要么拉伸,要么缩小,而这里采用的就是拉伸,我猜为啥不采用缩小,是因为要保证图画的连续性,比较跟图片明显拼接相比,拉伸至少还知道是同一张图片。既然是右边的边框宽度不够导致的,那么我设置后边长度的宽度。

--slice: calc(36 / (352/1259));
border: 36px solid transparent;
border-right-width: 57.5px;
border-image: url(./images/qa.png) var(--slice) calc(57.5 / (352/1259)) var(--slice) var(--slice);
/*
相当于下面的写法:
border-image-source: url(./images/qa.png);
border-image-slice: var(--slice) calc(57.5 / (352/1259)) var(--slice) var(--slice);
*/

现在看起来就没问题了;但是看到右边的边框由于宽度太宽,导致当内容过多的时候会提前换行,并没有做到跟左边差不多,所以这样是不行的,于是我又去学习了一波 border-image-width ,这个属性是调整图片绘制宽度的,于是我改成了这样:

--slice: calc(36 / (352/1259));
border: 36px solid transparent;
border-image: url(./images/qa.png) var(--slice) calc(57.5 / (352/1259)) var(--slice) var(--slice) / 1 57.5px 1 1;
/*
相当于下面的写法:
border-image-source: url(./images/qa.png);
border-image-slice: var(--slice) calc(57.5 / (352/1259)) var(--slice) var(--slice);
border-image-width: 1 57.5px 1 1; 
*/

感觉跟上面完全一样,但实际上这个时候能变长就会正常了,我把内容增加就能看到了。

现在就剩下中间部分了,默认情况下 border-image 是不会绘制到除了 border 以外的地方的,如果需要铺满则需要 slice 中添加 fill 属性。

fill

保留图像的中心区域并将其作为背景图像显示出来,但其会堆叠在 background 之上。它的宽度和高度分别对应顶部和左侧图像切片的宽度和高度。

也就是这样设置:

--slice: calc(36 / (352/1259));
border: 36px solid transparent;
border-image: url(./images/qa.png) var(--slice) calc(57.5 / (352/1259)) var(--slice) var(--slice) fill / 1 57.5px 1 1;
/*
相当于下面的写法:
border-image-source: url(./images/qa.png);
border-image-slice: var(--slice) calc(57.5 / (352/1259)) var(--slice) var(--slice) fill;
border-image-width: 1 57.5px 1 1; 
*/

下面就是成果:

可算是解决了。其实右上角的圆角还存在一定的瑕疵,因为圆角的那个位置发生了拉伸,我只需要将 slice 上边调整大一些就解决了。

教训

通过我的惨痛教训,我们必须要记住,这样大家就不会再遇到,即便遇到了也可以通过我的避坑指南快速解决。

  • 必须要先分析图片,哪些应该拉伸,哪些不拉伸;
  • border-image-slice 的数值,永远基于图片原始尺寸,而不是设计稿;
  • 还有一点我没说到,也就是当使用百分比设置 border-image-slice 的时候,上下使用原图片的高度,左右使用图片的宽度。

从一行行雕琢到与代码共舞:我的古法开发到 Vibe Coding 跃迁之路

作为一名高级前端开发工程师,我深耕过 Vue/React 的 Web 生态,搭建过 H5 的响应式架构,开发过全端兼容的小程序,用 UniApp 实现过 Android 与 iOS 的统一交付,更基于 Electron-Vite 打造过支持双模式部署(桌面应用Windows/macOS + 浏览器服务Service)的企业级 RPA Workflow Platform 自动化平台。

在这条前端的成长之路上,我曾是「古法开发」的忠实拥趸,用严格的规范和分层设计,构建过稳定可靠的业务系统;也在快速迭代的需求浪潮中,觉醒于「Vibe Coding」的体感驱动,体会到了高效试错、灵活适配的创作快感。

这不是一次非此即彼的选择,而是一场从「术」到「道」的能力升级。在开发「RPA Workflow Platform 多品牌自动化平台」(覆盖 Electron 桌面端、Browser Service 浏览器端、多品牌定制化交付)的全流程中,我完整经历了从古法开发到 Vibe Coding 的跃迁,也终于明白:对于前端工程师而言,真正的高效开发,从来不是对某种范式的偏执,而是在合适的场景,用合适的方式,写出既符合工程标准,又能快速响应业务的代码 —— 尤其是在我们需要同时兼顾多品牌适配、跨模式部署、自动化引擎兼容的 RPA 前端领域。

一、 古法开发:我的前端筑基,也是我的「安全结界」

接触前端开发的前六年,我是一名坚定的「古法开发信徒」。

我信奉「无设计,不编码」。在开发每一个项目前,我都会花大量时间撰写详细的需求分析文档、组件分层设计、状态管理方案、跨模式兼容预案。对于前端的核心模块,我更是偏执到极致 ——Vue 组件必须遵循UI组件/业务组件/页面组件的三层划分,Electron 项目必须严格分离 主进程/渲染进程/预加载脚本,Playwright 自动化逻辑必须封装为独立模块,甚至连多品牌配置的命名都要严格遵循统一规范,注释的格式都要统一为/** 功能 + 参数 + 返回值 + 注意事项 */

这种习惯,在我开发RPA Workflow Platform 核心底层框架多品牌配置体系时,发挥了巨大的作用。

RPA 自动化平台的底层框架是整个系统的基础,它需要保证多品牌配置的隔离性、双模式部署的稳定性、Playwright 自动化引擎的兼容性、Electron 跨平台的一致性。任何一个微小的设计缺陷,都可能导致品牌定制失败、自动化任务异常,甚至整个平台的跨端兼容问题。

为了开发这个底层框架,我选择了 Electron + Vue3 + TypeScript 作为技术栈。在长达六个月的开发周期里,我严格遵循古法开发的流程:

  • 前期设计:我花了整整五周,绘制了详细的模块分层图、多品牌配置架构文档、Electron 主 / 渲染进程通信方案、Playwright 引擎封装方案。我为每一个核心模块(任务管理、浏览器管理、自动化调度)都定义了清晰的接口规范,为每一个多品牌配置项都设计了统一的 Schema,甚至为每一个自动化指令处理器都写好了详细的注释,说明它的功能、参数、返回值和跨模式适配注意事项。

  • 编码实现:在编码过程中,我严格遵守 Electron 安全最佳实践和前端工程化规范。对于 Electron 进程通信,我使用了 ContextBridge + IPC,保证渲染进程的安全隔离;对于多品牌配置,我使用了 配置驱动 的设计模式,保证品牌定制的灵活性;对于 Playwright 自动化逻辑,我使用了 池化管理,保证浏览器实例的高效复用。我还为核心模块编写了单元测试,使用 Vitest + Playwright Test 保证代码的质量,从根源上避免了跨模式、跨品牌的兼容问题。

  • 测试验证:编码完成后,我花了两个月的时间,在 Windows / macOS(Electron 模式)、Chrome(Browser Service 模式)、多品牌 下进行了兼容性测试。我模拟了各种极端场景(多浏览器并发、大流量任务调度、跨平台品牌打包),测试平台的性能和稳定性。最终,这个底层框架的代码覆盖率达到了 95% 以上,在后续的品牌定制和功能迭代中,没有出现任何严重的兼容性问题,成为了 RPA 平台的核心基础。

这段经历,让我深刻体会到了古法开发的魅力。它就像一位严谨的工匠,教会我如何 一行行雕琢代码,搭建起坚固的 RPA 前端工程堡垒。在古法开发的世界里,我感到无比的安心和踏实。我知道,只要我遵循规范,我的代码就会是可靠的、可维护的、可扩展的 —— 这对于需要同时兼顾多品牌、双模式的 RPA 项目而言,尤为重要。

但同时,我也渐渐发现了古法开发的局限性。

在开发 RPA Workflow Platform 多品牌定制版 时,我依然试图用古法开发的流程来约束自己。我花了一周的时间,撰写了详细的需求文档和品牌适配方案,然后开始编码。但在编码过程中,我发现,RPA 平台的 品牌定制需求、自动化场景适配、Electron 打包优化 的变化速度,远快于我的设计方案。我刚写完一套品牌图标配置逻辑,产品就要求新增品牌专属的自动化节点;我刚实现了 Windows 平台的打包脚本,就发现 macOS 下存在权限兼容问题;我刚完成了一套任务调度优化,就发现用户需要支持基于 cron 表达式 的自定义定时策略。

更让我沮丧的是,当我终于按照设计方案完成了代码,却发现它在 Electron 28 + 不同版本、不同系统的 Playwright 环境 下,出现了各种兼容性问题。这时我才意识到,对于 RPA 这种 高度依赖系统环境、快速响应业务定制、多模式多品牌兼容 的领域,古法开发的流程,反而成了一种束缚。它让我在前期投入了大量的时间和精力,却无法保证最终的结果是符合业务需求的。

我陷入了迷茫:难道我一直信奉的开发方式,已经不适合 RPA 前端的发展了吗?

二、 Vibe Coding:打破束缚,与 RPA 多模式代码共舞的觉醒

我的 Vibe Coding 觉醒,源于一次RPA Workflow Platform 紧急迭代需求

当时,产品要求我在 四天内,为 多品牌添加一个 基于 Playwright 的指纹浏览器自动化功能,并且需要同时兼容 Electron 桌面模式和 Browser Service 浏览器模式,还要保证多浏览器并发的性能稳定。按照古法开发的流程,我需要先写文档,再设计模块,再编码,再测试 —— 这至少需要十天的时间。但这次,我没有选择的余地。

于是,我决定打破束缚,尝试一种全新的开发方式。

我打开了 VS Code,启动了 Electron-Vite 的开发环境,然后开始了我的 Vibe Coding 之旅。

没有文档,没有详细的设计,甚至没有明确的模块分层。我只有一个核心目标:四天内,实现一个稳定的、跨模式的、支持指纹浏览器的 RPA 自动化功能。我凭借着自己对 Electron 跨进程通信的经验、Vue3 组合式 API 的体感、Playwright 自动化引擎的理解,开始一行行地写代码。

我用 Copilot Code 作为我的助手,它帮我生成了大量的重复代码 —— 比如指纹浏览器的配置项、多品牌的样式适配、Electron 主进程的权限管理;它帮我补全了复杂的逻辑 —— 比如 Playwright 实例池的管理、跨模式的 API 适配、多浏览器并发的资源控制;它甚至还为我提供了一些优化建议 —— 比如如何减少 Electron 的内存占用,如何优化 Playwright 的启动速度,如何规避不同系统的权限限制。

我用 Vite 的热更新 + Electron 热重载 作为我的调试工具,它让我可以实时修改代码,实时在桌面端和浏览器端验证效果,然后快速调整。我不再纠结于 模块的分层是否完美,函数的长度是否合适,配置文件的命名是否符合规范,而是专注于 代码是否能解决我的问题,是否能实现指纹浏览器的核心能力,是否能兼容两种部署模式

我还灵活地运用了我之前的技术积累:我直接复用了底层框架的 Playwright 基础封装逻辑,保证了自动化引擎的稳定性;我使用了 Electron 的 ipcMain 和 ipcRenderer,实现了桌面模式下的浏览器进程管理;我基于现有的多品牌配置体系,快速适配了 多品牌 的专属指纹策略;我还复用了 Browser Service 模式的 HTTP API 架构,保证了跨模式的功能一致性。

让我惊喜的是,这次开发的效率,远超我的预期。仅仅用了三天半的时间,我就完成了这个功能的开发。这个功能虽然代码不够优雅,模块分层不够清晰,甚至还有一些小的优化空间,但它却完美地实现了产品的需求:它稳定可靠,在 Electron 桌面模式和 Browser Service 模式下都能正常运行;它性能优异,支持 10 个以上浏览器实例并发执行;它适配性强,在 Windows 和 macOS 平台下的品牌中都能稳定工作,得到了测试和产品的一致认可。

在这次开发中,我第一次体会到了 与 RPA 多模式代码共舞的快感。我不再是代码的奴隶,而是代码的主人。我可以根据自己的技术体感,自由地调整代码的方向;可以根据业务的氛围,快速地选择最合适的技术方案;可以根据部署模式的特性,灵活地进行兼容处理。这种感觉,就像一位经验丰富的舞者,在不同的舞台上,自由地展现自己的舞姿。

这次成功的尝试,让我对 Vibe Coding 有了全新的认识。我意识到,Vibe Coding 不是「不规范的编程」,而是 「将 Electron 工程化规范、RPA 领域知识内化为体感后的自由」。它不是对古法开发的否定,而是对古法开发的补充和升级 —— 尤其是在 RPA 这种需要快速响应品牌定制、多模式兼容的领域,Vibe Coding 更是一种不可或缺的能力。

在那之后,我开始在我的开发工作中,大量使用 Vibe Coding 的方式。对于 RPA 新品牌的快速定制,我用多品牌配置体系快速适配,凭借着自己的品牌定制体感,快速完成品牌标识、功能集的调整;对于 自动化新节点的原型验证,我用 Playwright 快速实现核心逻辑,利用 Vite 热更新快速验证可行性;对于 Browser Service 模式的接口适配,我更是用 Vibe Coding 的方式,快速完成跨模式的功能对齐。

在这个过程中,我的开发效率得到了极大的提升。我可以在两周内,完成过去一个月才能完成的 多品牌 RPA 迭代需求;可以在一个月内,完成过去两个月才能完成的 自动化功能升级。更重要的是,我不再害怕 品牌定制的快速变化、部署模式的兼容问题、自动化引擎的版本迭代。我可以根据市场的反馈,快速调整品牌的功能方向;可以根据用户的需求,快速实现新的自动化节点;可以根据技术的发展,快速升级 Playwright 和 Electron 版本。

三、 范式融合:在规范与自由之间,找到前端开发的平衡

在经历了古法开发的筑基和 Vibe Coding 的觉醒后,我终于明白:对于前端工程师而言,真正的高效开发,不是对某种范式的偏执,而是在规范与自由之间,找到最佳的平衡

尤其是在我们需要同时兼顾 Electron 桌面端、Browser Service 浏览器端、多品牌、Windows/macOS/Linux 多平台 的 RPA 领域,单一的开发范式,已经无法满足复杂的业务需求。如果我一味地追求古法开发的规范,就会错失品牌定制的市场机会,无法实现产品的快速迭代;如果我一味地追求 Vibe Coding 的自由,就会导致代码的可维护性下降,多模式多品牌兼容的问题增多,无法支持产品的长期发展。

于是,我开始尝试 范式融合 的开发方式。我将 RPA Workflow Platform 分为 核心基础层品牌业务层,然后根据不同的层,选择不同的开发范式 —— 这恰好与我的技术栈完美匹配。

核心基础层:用古法开发,搭建多模式兼容的 RPA 工程堡垒

核心基础层是整个 RPA 平台的根基,它包括 Electron 主 / 渲染进程通信框架、Playwright 自动化引擎封装、多品牌配置核心、任务调度底层、Browser Service API 适配器。对于这一层,我坚持使用古法开发的方式。

我用 Electron + TypeScript 开发跨进程通信框架,严格遵循 Electron 安全指南、进程隔离原则,保证双模式部署的稳定性;我用 JavaScript 封装 Playwright 自动化引擎,严格遵循池化管理、资源回收的规范,保证自动化任务的可靠性;我用配置驱动的方式设计多品牌核心体系,严格定义配置 Schema 和加载逻辑,保证品牌定制的一致性;我用 Node.js 开发任务调度底层,严格遵循并发控制、异常重试的规范,保证任务执行的稳定性。

在开发过程中,我花大量的时间,撰写详细的文档和设计方案,编写大量的单元测试和跨模式测试用例,确保每一行代码都经得起时间的考验。这一层的开发,虽然速度较慢,但却为整个 RPA 平台的发展,打下了坚实的基础。它保证了多模式多品牌兼容的一致性,避免了后期的大量维护和重构成本 —— 这对于 RPA 项目而言,是至关重要的。

品牌业务层:用 Vibe Coding,实现多品牌 RPA 的快速迭代

品牌业务层是整个 RPA 平台的灵魂,它包括 多品牌的定制化功能、自动化节点的业务适配、Electron 打包的品牌专属配置、Browser Service 的品牌接口扩展。对于这一层,我大胆使用 Vibe Coding 的方式。

我用 Vue3 开发品牌专属的 UI 组件,凭借着自己的品牌定制体感,快速实现品牌标识、交互风格的适配;我用 Playwright 快速开发新的自动化节点原型,利用 Vite 热更新快速验证节点的可行性;我用 Vibe Coding 的方式,快速调整 Electron 打包配置,适配不同品牌的图标、名称和安装策略;我用快速迭代的方式,完成 Browser Service 模式下品牌专属 API 的开发。

在开发过程中,我不再纠结于规范和流程,而是专注于 品牌的用户体验和业务的快速迭代。我可以灵活地运用各种技术栈,快速实现品牌定制需求;我可以根据不同部署模式的特性,快速进行兼容处理;我可以根据用户的反馈,快速调整品牌的功能方向。

当然,我也为品牌业务层制定了严格的规则。我允许在原型阶段,承担一定的技术债务,但我会做好记录,并在原型验证通过后的 下一个迭代周期 内,将其重构为高质量的代码,融入核心基础层。这样,既保证了品牌创新的灵活性,又避免了技术债务的累积。

工具链整合:用智能化工具,实现 RPA 开发效率的最大化

为了实现范式融合的高效开发,我还整合了大量的智能化工具,这恰好与我的 RPA 技术栈完美匹配。

在核心基础层,我用 ESLint、Prettier、Vitest、Playwright Test 等工具,保证代码的质量和多模式兼容性;在品牌业务层,我用 Copilot Code、Vite 热更新、Electron 热重载、多品牌打包脚本 等工具,加速开发的速度。我还开发了一系列的自定义工具,如 多品牌配置校验工具、Electron 打包优化工具、Playwright 自动化测试工具,自动化重复的任务,提升开发的效率。

这种范式融合的开发方式,让我在开发多模式多品牌的 RPA 前端项目时,实现了 效率和质量的平衡,速度和稳定性的统一。我既可以快速实现品牌的定制迭代,抓住市场机会;又可以保证多模式兼容的一致性,支持产品的长期发展。这正是我们 RPA 前端工程师所追求的最高境界。

四、 跃迁感悟:RPA 前端开发的本质,是不断突破自我的边界

从古法开发到 Vibe Coding,再到范式融合,这不仅是一次开发方式的跃迁,更是一次 认知的升级,一次自我的突破

作为一名高级前端开发工程师,在这条前端的成长之路上,我深刻地体会到:

  • 古法开发是筑基,Vibe Coding 是升维。没有古法开发的筑基,就没有 Vibe Coding 的自由。只有扎牢了 Electron 工程化、Playwright 封装、多模式兼容的根基,才能在多品牌 RPA 的创新世界里,自由地翱翔。对于 RPA 前端工程师而言,这一点尤为重要 —— 我们的技术栈更新换代很快,但工程化的思想、自动化领域的核心逻辑是永恒的。
  • 前端开发的本质,是解决问题。无论是古法开发,还是 Vibe Coding,它们都是解决问题的工具。没有最好的工具,只有最合适的工具。在开发过程中,我们应该根据问题的特点,选择最合适的工具,而不是偏执于某种工具。尤其是在 RPA 领域,我们更应该灵活地运用 Electron、Playwright 等技术栈,解决复杂的多模式多品牌兼容问题。
  • 真正的高效,是平衡的艺术。开发的高效,不是单一维度的速度,而是多维度的平衡。它包括开发的速度、代码的质量、产品的稳定性、多模式多品牌的兼容性。只有实现了这些维度的平衡,才能实现真正的高效。
  • 前端工程师的成长,是不断突破自我的边界。作为一名前端工程师,我们不能停留在自己的舒适区,而是要不断地学习新的技术(如 Electron 新特性、Playwright 新 API),尝试新的开发方式,突破自我的边界。从 Browser Service 到 Electron,从单品牌到多品牌,从单浏览器自动化到多实例并发 —— 这正是我们 RPA 前端工程师的成长之路。只有这样,我们才能在快速发展的 RPA 领域,保持自己的竞争力。

如今,我依然在多模式多品牌的前端开发之路上前行。我依然会在核心基础层,用古法开发的方式,一行行雕琢代码;也依然会在品牌业务层,用 Vibe Coding 的方式,与 RPA 多模式代码共舞。

我知道,我的前端开发之路,还有很长的路要走。但我相信,只要我保持着对技术的热爱,对创新的追求,不断地突破自我的边界,我就一定能在多模式多品牌的 RPA 世界里,找到属于自己的节奏,写出既符合工程标准,又能快速响应品牌业务的代码 —— 这正是一名高级前端开发工程师的终极追求。

❌