阅读视图

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

uni-app中v-if使用”异常”

前情

最近又接手一个全新多端项目,包括抖音/快手/微信/支付宝/安卓/ios,所选用的技术栈是uni-app,它是一个基于vue的多平台跨端框架,重要的是对前端开发友好,自带的IDE让开发体验也挺棒的

最近在开发设置页面,是一个列表页,产品初始想实现的效果如下图:

110ced3b-34f0-49dc-abeb-52a497b77d10.png

点击不同列表项可以跳转到不同页面查看内容,但是对于个别项上,就像下面的绑定的手机号,产品的意思是只是展示手机号即可,后面如果有需求了再考虑做修改手机号功能

image.png

我考虑到这里既然这里不可跳转,我觉得就没必要展示箭头了,不然容易误导用户点击,就向产品提了对于这种没操作的项去掉箭头,最终想实现的效果如下:

b7447db6-fedd-4d60-994e-e7b753a7cb5c.png

坑位

对于这种列表渲染,一般习惯使用v-for来渲染这种重复的列表结构,关键代码如下:

<template>
    ...
    <view v-for="item in list" :key="item.label" class="flex flex-row items-center justify-between py-[56rpx] border-b-[1rpx] border-[#E2E1E7]" @click="item.onClick">
    <view class="text-[28rpx] leading-[1em] tracking-[4rpx]">
      {{ item.label }}
    </view>

    <view class="flex flex-row items-center gap-[34rpx]">
      <text class="text-[#AEAAB3] text-[24rpx]" v-if="item.desc">{{ item.desc }}</text>
      <image class="w-[34rpx] h-[34rpx]" src="/static/qianzijinru.png" v-if="item.onClick" />
    </view>
  </view>
  ...
</template>

<script setup>
    ...
    const list = [
      {
        label: '用户协议',
        onClick: () => {
          const url = `${AGREEMENT_BASE_URL}/documents/user-agreement`
          uni.navigateTo({
            url: `/other/webview/webview?link=${encodeURIComponent(url)}`,
          })
        },
      },
      {
        label: '隐私协议',
        onClick: () => {
          const url = `${AGREEMENT_BASE_URL}/documents/user-privacy-agreement`
          uni.navigateTo({
            url: `/other/webview/webview?link=${encodeURIComponent(url)}`,
          })
        },
      },
      {
        label: '绑定手机号',
        desc: globalInstance?.userInfo?.account ? desensitizePhone(globalInstance?.userInfo?.account) :  '未绑定',
        isLogin: true,
        onClick: null,
      },
      {
        label: '退出登录',
        isLogin: true,
        onClick: () => {
          console.log('---- 退出登录 ----:', globalInstance?.userInfo);
          logoutShow.value = true
        },
      },
    ].filter((m) => {
      return m.isLogin ? globalInstance?.userInfo : true
    })
    ...
</script>

上面代码是我一开始做法的关键代码,我的想法,通过判断当前数据项是否有方法来判断是否有交互,没有交互那就不显示右箭头,我于是特意把那没有交互的项的方法置为null,但是事情并像期望那样,这样一改所有列表项的箭头都没了,当时开始怀疑是不是vue框架有这个限制,同时也问了AI,豆包说有,文言一心说没有,此时就让我有点蒙了,于是通过Vue Playground写了小测试,结果验证是Vue框架是没有这个问题的

image 1.png

Why?

逛了uni-app的文挡没有找到相关的说明,逛了论坛找到一个19年的贴子,贴子里说uniapp的template里是不允许用函数的,贴子链接如下:uni-app v-if不能使用函数返回值作为判断条件 - DCloud问答,个人猜测应该是uni-app并不支付所有Vue的语法,存在部分的差异,看上面贴子说有文挡有介绍到,如果有知道文挡在哪里的,希望不吝分享,谢谢

解决方案

直接用函数做条件判断行不通,那我们直接判断它是否不等于null和undefined是否可行了,测试后是OK的,关键代码如下:

<template>
    ...
    <view v-for="item in list" :key="item.label" class="flex flex-row items-center justify-between py-[56rpx] border-b-[1rpx] border-[#E2E1E7]" @click="item.onClick">
    <view class="text-[28rpx] leading-[1em] tracking-[4rpx]">
      {{ item.label }}
    </view>

    <view class="flex flex-row items-center gap-[34rpx]">
      <text class="text-[#AEAAB3] text-[24rpx]" v-if="item.desc">{{ item.desc }}</text>
      <image class="w-[34rpx] h-[34rpx]" src="/static/qianzijinru.png" v-if="![undefined, null].includes(item.onClick)" />
    </view>
  </view>
  ...
</template>

其实最稳的做法是新增一个标志变量用来判断是否要展示箭头,但是我觉得能少一些配置就少一些配置,所以就想直接通过onClick来判断了,如果是你,你会选择哪一种?

总结

uni-app虽然是基于Vue来的,绝大部分用法是可以直接照秒Vue用法来用的,部分用法uni-app可能有一些限制,这时就得多试多测找到另一种可行方案,虽然找到解决方案,但是并不理解为什么这样就可以,如果有知道的大佬,希望不吝留言分享,感谢

对于做技术的我们每天都是在踩坑的路上,但我真心希望所有人都一路生花,少踩坑,也坚信坑坑难爬,坑坑过

极简三分钟ES6 - ES9中for await of

快递站取快递

想象你同时网购了3件商品(对应3个异步任务)

  • 普通for...of:必须按顺序取快递(等第一个到了才能取第二个)
  • for await...of:快递站到货通知你(哪个先到先取哪个)
// 模拟三个快递请求  
const fetchPackages = [  
  fetch('//快递1').then(r => r.json()),   
  fetch('//快递2').then(r => r.json()),   
  fetch('//快递3').then(r => r.json())   
];  
 
// 使用for await...of取快递  
async function getDeliveries() {  
  for await (const pkg of fetchPackages) {  
    console.log(` 收到包裹:${pkg.name}`);   
  }  
}  

特性详解

能遍历异步迭代器(Async Iterable)

  • 普通数组/Set/Map → 直接遍历
  • 异步数据源(如网络流/数据库查询)→ 等待每个值完成再继续
// 创建异步迭代器(每秒生成一个随机数)  
const asyncRandom = {  
  [Symbol.asyncIterator]: async function*() {  
    while(true) {  
      await new Promise(resolve => setTimeout(resolve, 1000));  
      yield Math.random();   
    }  
  }  
};  
 
// 遍历异步迭代器  
(async () => {  
  for await (const num of asyncRandom) {  
    if(num > 0.9) break;  
    console.log(` 当前数值:${num}`);  
  }  
})();  

与同步迭代器的区别

特性 for...of for await...of
迭代器类型 同步迭代器 异步迭代器
等待机制 同步执行 每个值都等待完成
适用数据源 Array/Set/Map等 流/WebSocket/数据库

错误处理:try/catch包裹

async function readStream() {  
  try {  
    for await (const chunk of networkStream) {  
      process(chunk);  
    }  
  } catch (err) {  
    console.error(" 数据流中断:", err);  
  }  
}  

一些常见的使用场景

分页API的连续请求

async function getAllPages() {  
  const pageRequests = [  
    fetch('//api?page=1'),  
    fetch('//api?page=2'),  
    fetch('//api?page=3')  
  ];  
    
  // 哪个接口先响应先处理哪个  
  for await (const response of pageRequests) {  
    const data = await response.json();   
    render(data);  
  }  
}  

读取大文件流

import { createReadStream } from 'fs';  
 
async function readBigFile() {  
  const stream = createReadStream('data.log');   
  for await (const chunk of stream) {  
    console.log(` 收到${chunk.length} 字节数据`);  
  }  
} 

WebSocket消息处理

const socket = new WebSocket('wss://实时数据');  
 
async function handleMessages() {  
  for await (const msg of socket) { // 需实现异步迭代器  
    if (msg.type  === 'ALERT') notifyUser(msg);  
  }  
}  

数据库批量查询

async function exportUserData() {  
  const query = db.query('SELECT  * FROM users');  
  for await (const user of query) {  
    writeToCSV(user); // 逐条写入CSV  
  }  
}  

并发任务状态监控

const tasks = [  
  backupDatabase(), // 返回Promise  
  uploadLogs(),  
  sendEmails()  
];  
 
async function monitorTasks() {  
  for await (const result of tasks) {  
    updateProgressBar(result.taskId);   
  }  
}  

一些特殊场景

同步与异步混合遍历

const mixedSources = [  
  Promise.resolve(' 异步苹果'),  
  '同步香蕉',  
  Promise.resolve(' 异步橙子')  
];  
 
for await (const item of mixedSources) {  
  console.log(item);  // 正常输出所有值  
}  

提前终止迭代

// 通过break/return/throw终止  
for await (const data of sensorStream) {  
  if (data.temperature  > 100) {  
    shutdownSystem();  
    break; // 停止监听  
  }  
}

与生成器结合

async function* genAsyncData() {  
  yield await fetch('//data1');  
  yield await fetch('//data2');  
}  
 
for await (const data of genAsyncData()) {  
  // 处理数据  
}  

与传统方案对比

需求 Promise.all() 方案 for await...of方案 优势
按序处理异步任务 ❌(必须全部完成) ✅(顺序处理) 节省内存,即时处理
处理无限数据流 不可能实现 唯一解决方案
错误处理 单个失败导致全部失败 ✅(可单独捕获) 容错性强
动态添加任务 ✅(可配合数组追加) 灵活扩展

牢记

for await...of = 异步世界的for...of,用同步语法遍历异步集合,数据随到随处理,不积压内存,随时中断/恢复迭代

i18n国际语言化配置

国际化(i18n)是多语言项目的核心需求,本文将带你从零开始配置 Vue 3 项目的国际化功能,包括基础翻译、语言切换、第三方组件库(如 VXETable)适配,以及解决常见的插件报错问题。

1. 技术栈

  • Vue 3(Composition API)
  • TypeScript
  • vue-i18n(v9+,Vue 3 专用版本)
  • Vite(构建工具,本文示例基于 Vite)
  • 可选:i18n-ally(VS Code 插件,辅助翻译开发)

首先安装 vue-i18n

npm install vue-i18n@9 --save
# 或
yarn add vue-i18n@9

涉及配置的文件

src/
└── i18n
|   └──locales/
|      └──  zh-CN/       # 中文语言包
|      │   └── test.json     # 业务模块翻译
|      │   └── index.json    # 暴露全部的json
|      └── en/               # 英文语言包
|      |   └── test.json
|      |   └── index.json    # 暴露全部的json
|      └──index.ts          # 国际化配置
|      └──use-locale.ts          # 监听引入组件(按需求引入)
└──main.ts  # 入口文件配置   

使用

/* src/i18n/locales/zh-CN/index.ts */
// 暴露出当前文件所有的中文配置
const zhLocale = Object.fromEntries(
    Object.entries(import.meta.glob('./*.json', { eager: true })
    ).map(([key, value]) => {
    const moduleName = key.replace(/^\.\/(.*)\.json$/, '$1')
    return [moduleName, (value as any).default]
    })
)
export default zhLocale
/* src/i18n/locales/zh-CN/test.json */
{
    "haha": "哈哈"
}
/* src/i18n/locales/en/index.ts */
const enLocale = Object.fromEntries(
    Object.entries(import.meta.glob('./*.json', { eager: true })
    ).map(([key, value]) => {
    const moduleName = key.replace(/^\.\/(.*)\.json$/, '$1')
    return [moduleName, (value as any).default]
    })
)

export default enLocale
/* src/i18n/locales/en/test.json */
{
    "haha": "haha"
}
/* src/i18n/locales/index.ts */
// 国际化配置
import { createI18n } from 'vue-i18n'
import zhLocale from './locales/zh-CN'
import enLocale from './locales/en'

/* 这里根据情况导入,用到什么引入什么 */
// VXETable国际化  
import zhCN from 'vxe-pc-ui/lib/language/zh-CN' 
import en from 'vxe-table/lib/locale/lang/en-US' 

const messages = {
    zh_CN: { ...zhLocale, ...zhCN },
    en: { ...enLocale, ...en }
}
console.log('messages',messages)

// 本地获取储存的默认的语言(如果没有就使用中文)
const defaultLocale = localStorage.getItem('localelanguage') || 'zh_CN'

// 语言配置
const localesLanguage = createI18n({
    messages,
    locale: defaultLocale,
    fallbackLocale: 'zh_CN', //翻译缺失的时候使用
    legacy: false,//使用 composition API 模式
    globalInjection: true, //全局注册$t函数
})

export default localesLanguage
/* src/i18n/locales/use-locale.ts */
/**
 * AntDesignVue、Dayjs 国际化配置
 */
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Locale } from 'ant-design-vue/es/locale-provider'
// AntDesignVue
import zh_CN from 'ant-design-vue/es/locale/zh_CN'
import en from 'ant-design-vue/es/locale/en_US'
// Dayjs
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import 'dayjs/locale/zh-tw'
const antLocales = { zh_CN, en }
const eleLocales = { zh_CN: eleZh_CN, en: eleEn }

export function useLocale() {
  const { locale } = useI18n()
  const antLocale = ref<Locale>()

  watch(
    locale,
    () => {
      antLocale.value = antLocales[locale.value]
      dayjs.locale(locale.value.toLowerCase().replace(/_/g, '-'))
    },
    { immediate: true }
  )
  return { antLocale }
}

//任意vue文件测试
<template>
  <div>
    <div>{{ t("test.haha") }}</div>
    <el-button
      type="primary"
      @click="changeLanguege('zh_CN')"
      v-if="language.includes('en')"
      >English</el-button
    >
    <el-button
      type="primary"
      @click="changeLanguege('en')"
      v-if="language.includes('zh_CN')"
      >简体中文</el-button
    >
  </div>
</template>

<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();

const { locale } = useI18n();
const language = computed(() => [locale.value]);

const changeLanguege = (lang: string) => {
  locale.value = lang;
  console.log("Current language:", locale.value);
};
</script>

Promise 的场景和最佳实践

Promise 是 JavaScript 中用于处理异步操作的一个对象,它表示一个异步操作的最终结果,可能是成功或失败,Promise 通常用于处理异步操作,比如从服务器获取数据读取文件定时任务用户操作

基本的使用

const myPromise = new Promise((resolve, reject) => {
  const success = true;
  
  if(success) {
    resolve("操作成功!");
  } else {
    reject("操作失败。");
  }
});

myPromise
  .then(result => console.log(result))  // 如果 Promise 成功
  .catch(error => console.log(error));  // 如果 Promise 失败

常见的用法和示例

  1. 链式调用 (then)

Promise 支持链式调用,可以在一个 then 后接着另一个 then,处理不同的异步操作。

new Promise((resolve, reject) => {
  setTimeout(() => resolve("第一个操作成功!"), 1000);
})
.then(result => {
  console.log(result);  // 输出: 第一个操作成功!
  return "第二个操作成功!";
})
.then(result => {
  console.log(result);  // 输出: 第二个操作成功!
});

  1. 多个 Promise 的并行执行 (Promise.all)

如果你有多个独立的异步操作,并且你希望它们并行执行,可以使用 Promise.all,它会等到所有的 Promise 都完成后再继续执行。

const promise1 = new Promise(resolve => setTimeout(() => resolve("操作1完成"), 1000));
const promise2 = new Promise(resolve => setTimeout(() => resolve("操作2完成"), 2000));

Promise.all([promise1, promise2])
  .then(results => {
    console.log(results);  // 输出: ["操作1完成", "操作2完成"]
  });

  1. 处理单个 Promise 的异常 (catch)

catch 方法是用来捕捉 Promise 中的异常的,它可以让你在异步操作失败时处理错误。

new Promise((resolve, reject) => {
  setTimeout(() => reject("操作失败!"), 1000);
})
.then(result => {
  console.log(result);
})
.catch(error => {
  console.log("捕获错误:", error);  // 输出: 捕获错误: 操作失败!
});

  1. Promise 的竞态条件 (Promise.race) Promise.race 会返回第一个完成的 Promise,不管它是成功还是失败。
const promise1 = new Promise(resolve => setTimeout(() => resolve("操作1完成"), 2000));
const promise2 = new Promise(resolve => setTimeout(() => resolve("操作2完成"), 1000));

Promise.race([promise1, promise2])
  .then(result => {
    console.log(result);  // 输出: 操作2完成
  });

  1. 串联多个异步操作 (async/await)

async/awaitPromise 的语法糖,使得异步操作看起来像是同步的。

async function run() {
  try {
    const result1 = await new Promise(resolve => setTimeout(() => resolve("操作1完成"), 1000));
    console.log(result1);
    const result2 = await new Promise(resolve => setTimeout(() => resolve("操作2完成"), 1000));
    console.log(result2);
  } catch (error) {
    console.log("捕获错误:", error);
  }
}

run();

async 函数中,你可以使用 await 等待 Promise 的结果,这使得异步代码更简洁易读。

在实际项目中使用

  1. API 请求

在开发前端应用时,向后端请求数据是最常见的异步操作。通常会使用 Promise 来处理 API 请求, 通常会使用 async/await 来简化这类代码。

async function fetchData() {
  try {
    const response = await fetch('https://api.example.com/data');
    const data = await response.json();
    console.log("数据:", data);
  } catch (error) {
    console.error("获取数据失败:", error);
  }
}
  1. 多个异步操作并行执行 (Promise.all)

这种方式比一一等待每个请求更加高效,尤其是在多个请求之间没有依赖关系时

例如,加载多个 API 数据:

const fetchData1 = fetch('https://api.example.com/data1').then(res => res.json());
const fetchData2 = fetch('https://api.example.com/data2').then(res => res.json());

Promise.all([fetchData1, fetchData2])
  .then(([data1, data2]) => {
    console.log("数据1:", data1);
    console.log("数据2:", data2);
  })
  .catch(error => {
    console.error("加载数据失败:", error);
  });

  1. 串联多个异步操作 (then)

一个异步操作完成后再执行下一个操作,这时可以使用 then 链式调用来处理。

例如,先提交表单数据,再获取结果:

function submitForm(data) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // 模拟异步表单提交
      if (data) resolve("表单提交成功");
      else reject("表单提交失败");
    }, 1000);
  });
}

submitForm({ name: 'John', age: 30 })
  .then(result => {
    console.log(result);  // 输出: 表单提交成功
    return fetch('https://api.example.com/result');  // 提交成功后获取结果
  })
  .then(response => response.json())
  .then(data => console.log("结果数据:", data))
  .catch(error => console.error("错误:", error));

  1. 处理复杂的异步控制流async/await

async/awaitPromise 的语法糖,使得异步代码看起来像同步代码,简化了逻辑结构,尤其是处理多层嵌套的异步调用时非常有用。

例如,用户登录后执行后续操作:

async function loginUser(credentials) {
  try {
    const loginResponse = await fetch('https://api.example.com/login', {
      method: 'POST',
      body: JSON.stringify(credentials),
    });
    const loginData = await loginResponse.json();
    
    if (loginData.success) {
      const userProfile = await fetch('https://api.example.com/user/profile');
      const profileData = await userProfile.json();
      console.log("用户资料:", profileData);
    } else {
      console.error("登录失败");
    }
  } catch (error) {
    console.error("发生错误:", error);
  }
}

  1. 错误处理和重试机制

在项目中,经常会遇到某些操作可能会失败的情况,比如网络请求失败。你可以使用 catch 来捕获错误,甚至设置重试机制。

例如,添加重试机制:

function fetchWithRetry(url, retries = 3) {
  return new Promise((resolve, reject) => {
    function attempt() {
      fetch(url)
        .then(response => {
          if (!response.ok) throw new Error("请求失败");
          return response.json();
        })
        .then(resolve)
        .catch(error => {
          if (retries > 0) {
            console.log(`重试剩余次数: ${retries}`);
            retries--;
            attempt();  // 重试
          } else {
            reject(error);  // 最终失败
          }
        });
    }
    attempt();
  });
}

fetchWithRetry('https://api.example.com/data')
  .then(data => console.log("数据:", data))
  .catch(error => console.error("最终错误:", error));

  1. 动画和延时操作 有时你需要在执行一系列操作时,插入一些动画或延时操作。你可以利用 Promise 来实现这一需求。

例如,延时执行:

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

delay(1000)  // 延迟1秒
  .then(() => console.log("1秒后执行"))
  .then(() => delay(2000))  // 再延迟2秒
  .then(() => console.log("再过2秒执行"));

前端大文件分片上传 —— 基于 React 的工程化实现

你是否曾苦苦等待一个几G的大文件上传,却在进度条到99%时因为网络波动而崩溃重来?在现代Web应用中,传统的单次上传方式早已力不从心。本文介绍作者使用的一个 生产级 的解决方案,实现支持 分片、取消、重试 的大文件上传。

一、为什么要做分片上传?

1. 解决大文件上传的稳定性问题(核心意义)

这是分片上传最首要的目的。

  • 传统单次上传的弊端:如果一个2GB的文件一次性上传,网络波动导致在传输到90%时中断,整个上传就失败了,用户必须从头开始重传,体验极差。
  • 分片上传的优势:如果将2GB文件切成100个20MB的分片。即使传输到第99个分片时中断,也只需要重新上传这失败的1个分片(第99片),而不是整个文件。这极大地增强了上传的容错能力,节省了时间和带宽

2. 提升上传速度和用户体验

  • 并发上传:浏览器可以同时发起多个HTTP请求,分别上传不同的文件分片。这充分利用了用户的网络带宽,将串行上传变为并行上传,显著提高了大文件的上传速度
  • 进度条更精确:可以实时计算已成功上传的分片数量和总大小的比例,向用户展示一个非常精确和流畅的上传进度条,提升了用户体验。

3. 绕过各种大小限制

  • 客户端限制:某些浏览器、服务器或中间件(如Nginx)对单个请求的Body大小有默认限制。分片上传确保了每个请求的体积都很小,可以轻松绕过这些限制。
  • 服务端处理:一次性将超大文件加载到服务器内存中进行处理是不现实且危险的,容易导致内存溢出。分片上传后,服务器每次只处理一个小分片,最后再按需合并,对服务器更友好。

4. 便于做额外的校验和控制

  • MD5校验:不仅可以对整个文件做校验,还可以对每一个分片单独做MD5校验。服务端收到分片后立即验证其MD5,如果校验失败,可以立即要求客户端重传该分片,保证了每个分片数据的准确性。
  • 暂停和重试:因为上传状态(每个分片是否成功)被记录在服务端,用户可以随时暂停上传,并在之后继续上传。

二、如何实现分片上传?

核心流程:化整为零,再化零为整

分片上传的核心思想是“分而治之”,其流程如下图所示:

image.png

核心实现细节

以下基于 React + antd 实现,我们将聚焦几个关键的技术细节。整体设计思路可参考这篇优秀的文章:前端大文件上传深入研究和实现

🔍 MD5计算:全量 vs 抽样

计算整个文件的MD5以保证唯一性准确,但大文件全量计算非常耗时。我们采用了一种均衡策略

  • 小文件:直接全量计算。
  • 大文件:采用抽样计算,在精度和速度间取得尽量的平衡。
  • 如何定义大小文件:根据实际情况,给分片数设定一个阈值,低于该阈值采用全量计算,高于该阈值采用抽样计算

下面我们来介绍一下抽样计算方案:

image.png

  1. 切片:按固定大小(如2MB)将文件切分成多个切片。

  2. 采样

    • 第一个最后一个切片的全部内容。
    • 对于中间的所有切片,每片取首、中、尾各x个字节
  3. 组合:将这些采样的字节数据合并成一个新的 Blob 对象。

  4. 计算:使用 spark-md5 库计算这个采样 Blob 的hash值,作为文件的唯一标识。

这种方法极大地减少了计算量,虽有小概率的hash碰撞,但在实际业务中完全可以接受

🧵 并发控制:线程池

为了避免浏览器同时发起过多HTTP请求,实现了一个简单的线程池来控制并发数。它采用 队列 + 工作者  模型。

class ThreadPool {
    maxThread: number;        // 最大并发数
    queue: Array<Worker>;     // 当前执行队列
    workers: Array<Worker>;   // 待执行的工作者队列  
    errorList: Array<Error>;  // 错误收集列表
    
    constructor(maxThread: number, fns: Array<() => Promise<void>>) {
        this.maxThread = maxThread;
        this.queue = [];
        // 将所有任务函数包装成 worker
        this.workers = fns.map((item, ind) => {
            return this.createWorker({ fn: item, ind });
        });
        this.errorList = [];
    }
    
    // Worker 包装机制
    createWorker(worker: { fn: () => Promise<void>; ind: number }) {
        // 1. 补足队列 - 如果当前队列未满,从待执行队列中取任务
        this.fullQueue();
        // 2. 执行当前任务
        await worker.fn().catch(() => {
            // 错误收集
            ...
        })
        this.queue = this.queue.filter((item) => item.ind !== worker.ind);
        
        // 4. 递归执行下一个任务 - 这里有个关键的递归调用
        await this.workers?.[0]?.fn();
    }
    // 队列补充机制
    fullQueue(){
        // 1. 从待执行队列取出
        // 2. 加入执行队列
        while...
    }
    async start() {
        // 初始填充队列
        this.fullQueue();
        // 并发执行队列中的所有任务
        return await Promise.allSettled(this.queue.map(async (item) => await item.fn()));
    }
}

执行过程解析(假设 maxThread=3)

时间轴:  0    1    2    3    4    5    6
任务队列: [T1, T2, T3, T4, T5, T6, T7, T8] (假设 maxThread=3)

执行过程:
T0: queue=[T1,T2,T3], workers=[T4,T5,T6,T7,T8]
     ↓ 并发执行T1,T2,T3

T1: T1完成 → queue=[T2,T3], workers=[T4,T5,T6,T7,T8]
     → fullQueue() → queue=[T2,T3,T4], workers=[T5,T6,T7,T8]
     → 启动T4

T2: T2完成 → queue=[T3,T4], workers=[T5,T6,T7,T8]  
     → fullQueue() → queue=[T3,T4,T5], workers=[T6,T7,T8]
     → 启动T5

... 依此类推,直到所有任务完成

重试与暂停

分片上传阶段

利用线程池和错误收集实现:

  • 失败重试:每个分片任务失败时,将错误信息(分片索引等)存入 errorList。所有分片首次尝试后,只需重试 errorList 中的任务即可。
  • 暂停:给所有上传请求设置统一的cancelToken,使用cancelTokenSource.cancel终止所有上传的请求
  • 关于暂停机制的补充说明:需要特别注意的是,在上传阶段执行暂停操作时,前端实际所做的是中止(cancel)  发出的 HTTP 请求——即前端不再等待和接收后端的响应。然而,此时请求可能已经到达服务器,后端可能仍在处理并持续写入对应的分片直到完成。对于这些已生成的完整分片,通常有两种处理方式:在后续重试上传同一分片时直接覆盖,或者统一清理
    const fn = async(index:number)=>{
        try{
            // 上传逻辑
            ...
        }catch(error){
            // 收集上传失败信息
            status.onUploadErr.push({
                lifeCircle: 'chunkEnd',
                error,
            })
        }
    }
    
  -----------
  
    this.cancelTokenSource = axios.CancelToken.source();
    // 设置统一的cancelToken
    const res = await axios.post(url,data,{
        cancelToken:  this.cancelTokenSource.token
    })
    // 取消所有上传
    cancel() {
      this.cancelTokenSource.cancel('请求取消');
    }
    
分片合并阶段

合并请求是串行的,上一个合并完成才能执行下一个,失败则终止。我们通过 for循环 + async/await 实现串行,并用一个标志位 isContinueMerge 控制暂停。

for (index = p.startIndex; index < this.mergeParams.total; index++) {
    try {
        await mergeFn(index);
    } catch (error) {
        // ...错误处理
        
        // 跳出循环,后续的 merge 不会执行
        break;
    }
}

async mergeFn(){
    if (!this.isContinueMerge) {
        // 当前批次的请求,应该暂停了
        throw new Error('合并暂停');
    }
}

注意:合并请求的暂停是“软暂停”,即取消下一个合并请求,而非中止当前正在进行的请求。合并过程中不支持暂停,所有使用标志位控制

上传速度与进度计算

实时计算速度与进度,给用户良好的反馈。

const beforeUpload = Date.now()
await requestFn()
const afterUpload = Date.now();

 // 计算当前分片上传耗时
const duration = (afterUpload - beforeUpload) / 1000; // 秒

if (duration > 0 && chunkSize > 0) {
    speed = chunkSize / duration; // 分片上传速度 byte/s
    this.totalBytes += chunkSize; // 已上传的文件大小
}

用户体验与交互设计

  1. 模态框管理:上传过程通常放在模态框中:

    • 上传过程中,禁用遮罩层关闭,右上角关闭按钮点击会出现需取消的提示。
    • 提供清晰的取消、重试按钮
  2. 资源清理:弹窗关闭后,调用服务端清理接口,删除暂存的无效分片,避免存储资源浪费。

展望与优化空间

  1. Web Worker 解放主线程:计算MD5是CPU密集型任务,会阻塞UI渲染导致页面卡顿。将其放入 Web Worker 中运行,可解决部分性能问题,保证页面流畅。
  2. 更健壮的重试机制:为每个分片上传增加重试策略,避免网络抖动造成的瞬时失败。
  3. 上传速度平滑与预估:计算平均速度而非仅当前分片速度,并基于此预估剩余时间,体验更佳。
  4. 上传进度的实时感知:目前分片上传依靠前端存储的状态来感知上传状态,如果用户刷新浏览器重置前端状态,没有相应的接口机制去实时获取上传状态(如上传信息、上传阶段等等)从而造成上传状态丢失

`mockito` 的核心“打桩”规则

掌握 mockito 中不同的“打桩 (Stubbing)”技巧,是写出高质量单元测试的关键。

when(...).thenAnswer(...) 只是其中一种方式,实际上 mockito 提供了一套非常灵活的 API 来规定 Mock 对象在被调用时的行为。

总结一下最常用、最重要的几种规则:


mockito 的核心“打桩”规则

假设我们有一个 mockRepository,它有一个方法 String? findUser(int id)

1. thenReturn - 返回一个具体的值

这是最常用、最简单的规则。它规定当方法被调用时,直接返回一个预设好的值。

使用场景:当方法的返回值是一个可以直接创建的、简单的对象时(如 String, int, bool,或者一个你自己创建的模型实例)。

代码示例

// 规定:当 findUser 被以参数 1 调用时,返回字符串 '张三'
when(mockRepository.findUser(1)).thenReturn('张三');

// 规定:当 findUser 被以任何整数参数调用时,返回字符串 '默认用户'
// an_y 是 mockito 提供的匹配器,表示“任何值”
when(mockRepository.findUser(any)).thenReturn('默认用户');

// 规定:当一个返回 Future 的方法被调用时,返回一个已完成的 Future
when(mockRepository.syncFromRemote()).thenReturn(Future.value()); 

2. thenThrow - 抛出一个异常

用于测试代码中的错误处理逻辑。

使用场景:模拟网络请求失败、数据库读取错误、文件未找到等异常情况。

代码示例

// 规定:当 findUser 被以参数 -1 调用时,抛出一个 Exception
when(mockRepository.findUser(-1)).thenThrow(Exception('用户ID无效'));

// 测试代码就可以这样写:
expect(
  () => userManager.getUserName(-1), 
  throwsException
);

3. thenAnswer - 执行一个函数并返回其结果

这是最强大、最灵活的规则。它允许你提供一个函数,当 Mock 方法被调用时,mockito 会执行你提供的这个函数,并将函数的返回值作为 Mock 方法的返回值。

使用场景

  • 当返回值是一个 FutureStream 时(这是最常见的用法)。
  • 当返回值需要根据传入的参数动态计算时。
  • 当您想在方法被调用时执行一些额外的逻辑(比如打印日志)。

代码示例

// 规定:当 syncFromRemote 被调用时,执行一个异步函数,并返回一个空的 Future
// 这是您问题中的例子,非常适合异步方法
when(mockRepository.syncFromRemote()).thenAnswer((_) async => {});

// 规定:当 findUser 被调用时,根据传入的 id 动态返回一个名字
when(mockRepository.findUser(any)).thenAnswer((invocation) {
  // invocation 对象包含了调用的所有信息,包括参数
  final int id = invocation.positionalArguments.first;
  return '用户 $id';
});

// 使用
print(mockRepository.findUser(101)); // 会输出: '用户 101'

4. thenCallRealMethod - 调用真实的原始方法

使用场景:这在使用 spy 时非常有用。spy 是一种特殊的 Mock 对象,它会“包装”一个真实的对象。默认情况下,调用 spy 的方法会执行真实的方法,但您可以用 when 来覆盖其中某几个方法的行为。thenCallRealMethod 可以让被覆盖的方法恢复其原始的真实行为。

代码示例

// 1. 创建一个真实的对象和一个 spy
final realRepo = RealRepository();
final spyRepo = spy(realRepo);

// 2. 默认情况下,spy 会调用真实方法
spyRepo.someMethod(); // 执行 RealRepository.someMethod()

// 3. 我们可以覆盖某个方法的行为
when(spyRepo.someMethod()).thenReturn('假的返回值');
spyRepo.someMethod(); // 返回 '假的返回值'

// 4. 现在,我们可以让它恢复真实的行为
when(spyRepo.someMethod()).thenCallRealMethod();
spyRepo.someMethod(); // 再次执行 RealRepository.someMethod()

总结

规则 作用 最常用场景
thenReturn(value) 直接返回一个预设的值 同步方法,返回简单对象
thenThrow(error) 直接抛出一个预设的异常 测试错误处理和异常流程
thenAnswer(function) 执行一个函数并返回其结果 异步方法 (Future),或需要根据参数动态返回值的场景
thenCallRealMethod() 调用被包装的真实方法 spy 配合使用,用于临时恢复真实行为

掌握这四种核心的“打桩”规则,您就能够非常自如地为几乎所有场景编写出简洁、健壮的单元测试了。

12个TypeScript奇淫技巧你需要掌握😏😏😏

哈喽大家好,我是Lotzinfly,一位前端小猎人。欢迎大家来到前端丛林,在这里你将会遇到各种各样的前端猎物,我希望可以把这些前端猎物统统拿下,嚼碎了服用,并成为自己身上的骨肉。当我们学习前端进阶的时候,TypeScript是重中之重。学会TypeScript将大大提高我们的开发效率,所以一定要掌握好TypeScript。

这篇文章让我们手写12个TypeScript奇淫技巧,并逐行分析,最近秋招就要来了,学会手写TypeScript例子就可以大胆和面试官畅所欲言和自信谈薪啦!😏😏😏 击败TypeScript并嚼碎了服用将会给我们带来大量经验,你们准备好了吗?话不多说,现在开启我们今天的前端丛林冒险之旅吧!


1. Partial 深度递归版

场景 更新一个嵌套对象,但只想传“任意深度”的片段
易错 原生 Partial 只擦一层,{ address: { city: undefined } } 会把外层 address 整个覆盖
进化 手写 DeepPartial,同时保留“数组”结构
// 工具
type DeepPartial<T> = T extends object
  ? T extends readonly any[]
    ? ReadonlyArray<DeepPartial<T[number]>>
    : { [K in keyof T]?: DeepPartial<T[K]> }
  : T;

//  domain
interface User {
  id: number;
  profile: {
    name: string;
    avatar: { url: string; size: [number, number] };
  };
  tags: string[];
}

//  使用:只改头像宽度
const patch: DeepPartial<User> = {
  profile: { avatar: { size: [undefined, 128] } },
};

//  单元测试
function testDeepPartial() {
  const original: User = {
    id: 1,
    profile: { name: 'Tom', avatar: { url: 'a.png', size: [64, 64] } },
    tags: ['vip'],
  };
  const merged = merge(original, patch); // 手写 merge 或 lodash.merge
  console.assert(merged.profile.avatar.size[0] === 64); // 未被覆盖
  console.assert(merged.profile.avatar.size[1] === 128);
}

2. Required 带“运行时检查”

场景 后端返沪配置字段全部可选,但前端初始化时必须补齐
进化 Required 丢给 zod,自动生成“运行时校验 + 类型”
import { z } from 'zod';

const ConfigSchema = z.object({
  apiUrl: z.string().url(),
  timeout: z.number().int().positive(),
  retry: z.boolean(),
});

//  推导后全部是必填
type Config = z.infer<typeof ConfigSchema>; // Required<{apiUrl:string; timeout:number; retry:boolean}>

//  使用:本地 config.json 可能缺字段
function loadConfig(): Config {
  const raw = JSON.parse(readFileSync('./config.json', 'utf-8'));
  return ConfigSchema.parse(raw); // 缺字段会直接抛错
}

3. Readonly 冻结“React 状态”

场景 把 Redux Toolkit 的 state 彻底锁死,防止误赋值
进化 结合 immerDraft 类型,做到“写时复制、读时只读”
import type { Draft } from 'immer';

interface State {
  user: { name: string };
  list: number[];
}

const initialState: Readonly<State> = {
  user: { name: 'Alice' },
  list: [1, 2, 3],
};

//  在 reducer 里只能改 Draft,外面拿到的是 Readonly
function reducer(state: Readonly<State>, action: any) {
  return produce(state, (draft: Draft<State>) => {
    draft.list.push(4); // ✅ 允许
  });
}

//  组件里
const state: Readonly<State> = useSelector(s => s);
state.list.push(5); // ❌ TS 报错:Property 'push' does not exist on readonly number[]

4. Pick<T,K> 造“最小列”数据库查询

场景 ORM 只查需要用到的列,减少网络传输
进化 Pick 做成“通用返回类型”,再配合 sql tag
//  通用工具:指定列查询
type PickCol<T, K extends keyof T> = Pick<T, K>;

interface UserTable {
  id: number;
  name: string;
  password: string;
  createdAt: Date;
}

//  只查两列
async function getUserNames(): Promise<PickCol<UserTable, 'id' | 'name'>[]> {
  return sql<{ id: number; name: string }[]>`SELECT id, name FROM user`;
}

//  调用方永远拿不到 password
const list = await getUserNames();
list[0].password; // ❌ TS 报错

5. Omit<T,K> 造“公有类型”同时保持单一代码源

场景 同一个 User 实体,内部服务需要 password,开放给前端的 DTO 不需要
进化 Omit 生成 PublicUser,但不手写第二份接口,保证“字段新增/删除”时两边同步
interface User {
  id: number;
  name: string;
  password: string;
  internalRemark: string;
}

//  所有对外接口都用 PublicUser
export type PublicUser = Omit<User, 'password' | 'internalRemark'>;

//  新增字段 email 时,只改 User,PublicUser 自动同步

6. Record<K,T> 做“字典”时给 value 加“默认值函数”

场景 权限字典初始化时,希望缺失 key 自动兜底
进化 封装 getWithDefault 高阶函数,保持 Record 类型安全
type Role = 'admin' | 'user' | 'guest';
const defaultPerms = {
  admin: { read: true, write: true },
  user: { read: true, write: false },
  guest: { read: false, write: false },
} satisfies Record<Role, { read: boolean; write: boolean }>;

function getPerm<R extends Role>(r: R): Record<Role, typeof defaultPerms[R]> {
  return defaultPerms[r] ?? defaultPerms.guest;
}

//  使用
const p = getPerm('user');
p.write; // false

7. Exclude & Extract 玩“可分配路线图”

场景 一个组件只接受部分颜色,需要把系统级颜色过滤出来
type SystemColor = 'red' | 'green' | 'blue' | 'yellow';
type AllowedColor = Extract<SystemColor, 'red' | 'green'>; // 'red' | 'green'
type DisallowedColor = Exclude<SystemColor, AllowedColor>; // 'blue' | 'yellow'

//  再进一步做运行时 map 检查
const COLOR_MAP: Record<AllowedColor, string> = {
  red: '#ff0000',
  green: '#00ff00',
};

function setColor(c: AllowedColor) {
  document.body.style.color = COLOR_MAP[c];
}

8. NonNullable 与“空值断言”组合拳

场景 数组查找后立刻使用,不想写一堆 if
进化 封装“查找 or 抛”函数,返回 NonNullable<T>
function findStrict<T, K extends keyof T>(
  arr: T[],
  key: K,
  value: T[K]
): NonNullable<T> {
  const item = arr.find(v => v[key] === value);
  if (!item) throw new Error(`Not found`);
  return item!; // ! 断言后类型擦除 null|undefined
}

//  使用
const users: ({ id: number; name: string } | null)[] = [
  { id: 1, name: 'A' },
  null,
];
const u = findStrict(users, 'id', 1); // 返回类型 {id:number; name:string},null 被排除

9. ReturnType + Parameters 做“函数装饰器”不改类型

场景 给任意 async 函数加“自动重试”包裹,但保持原类型
function withRetry<F extends (...args: any[]) => Promise<any>>(fn: F) {
  return async (...args: Parameters<F>): Promise<ReturnType<F>> => {
    let lastErr: unknown;
    for (let i = 0; i < 3; i++) {
      try {
        return (await fn(...args)) as ReturnType<F>;
      } catch (e) {
        lastErr = e;
        await new Promise(r => setTimeout(r, 100 * 2 ** i));
      }
    }
    throw lastErr;
  };
}

//  使用:类型完全不变
const fetchUser = async (id: number) => ({ id, name: 'A' });
const safeFetch = withRetry(fetchUser);
//  safeFetch 签名仍是 (id:number)=>Promise<{id:number; name:string}>

10. ConstructorParameters + InstanceType 造“通用工厂”

场景 写 DI 容器,需要根据“类”自动产生工厂函数
class Logger {
  constructor(public prefix: string) {}
  log(msg: string) {
    console.log(`[${this.prefix}] ${msg}`);
  }
}

//  通用工厂
function createFactory<T extends abstract new (...args: any) => any>(Cls: T) {
  return (...args: ConstructorParameters<T>): InstanceType<T> =>
    new Cls(...args);
}

//  使用
const makeLogger = createFactory(Logger);
const log = makeLogger('App'); // 类型自动推导为 Logger
log.log('started');

11. ThisParameterType 给“方法链”锁上下文

场景 jQuery 风格链式调用,确保 this 指向不被丢失
interface Query {
  where<K extends keyof User>(
    field: K,
    val: User[K]
  ): ThisParameterType<typeof where>;
}
function where(this: Query[], field: any, val: any) {
  return this.filter(u => u[field] === val);
}

const chain: Query & ThisParameterType<typeof where> = [] as any;
chain.where('name', 'Tom').where('age', 18); // 全程 this 被约束

12. 组合技:API 返回 → 隐藏敏感 → 局部更新 → 深度合并

把上面所有知识串一个完整案例:

//  1. 原始实体
interface User {
  id: number;
  name: string;
  password: string;
  profile: { avatar: { url: string }; bio: string };
}

//  2. 返回给前端的安全 DTO
type UserDto = Omit<User, 'password'>;

//  3. 更新时允许深度局部
type UserPatch = DeepPartial<UserDto>;

//  4. 实现:查 → 删敏感 → 合并 → 返
async function updateUser(id: number, patch: UserPatch): Promise<UserDto> {
  const raw = await db.user.findUnique({ where: { id } });
  if (!raw) throw new Error('404');
  const sanitized: UserDto = exclude(raw, ['password']); // 手写工具
  const merged = deepMerge(sanitized, patch);
  await db.user.update({ where: { id }, data: merged });
  return merged;
}

一键复制清单

技巧 一句话记忆
DeepPartial 更新嵌套对象,不丢兄弟字段
Required + zod 类型 & 运行时一次搞定
Readonly + immer 状态不可变,写时复制
Pick 列 查多少返多少,带宽省 70%
Omit 敏感 同一份实体,对外自动脱敏
Record 字典 枚举 key 全铺平,再配默认值函数
Exclude/Extract 联合类型做“白名单/黑名单”
NonNullable 去掉 null,后面代码不再 ?.
ReturnType + Parameters 装饰器、包裹器必备,类型零丢失
ConstructorParameters + InstanceType 工厂函数一行代码泛型化
ThisParameterType 方法链、库作者专用,防 this 丢失

把上面 12 组代码全部跑通,你就在项目中把 TypeScript 内置工具类型“吃干抹净”了。
需要哪一条再单独抽出来封装为 npm utils,就能让团队永远告别“手写重复类型”的悲剧。祝你编码愉快!

JavaScript 从零开始(四):基础语法详解——从变量声明到数据类型的完全指南

引言:JavaScript入门指南

JavaScript作为Web开发的三大核心技术之一,是一种高级、解释型编程语言,赋予网页交互性和动态功能。从简单的表单验证到复杂的前端框架,JavaScript无处不在。掌握其基础语法,特别是变量声明和数据类型,是每位开发者的必经之路,这直接关系到代码质量、性能和可维护性。

在开始学习之前,你只需要基本的计算机操作知识。虽然了解HTML和CSS会有帮助,但并非必需。准备一个现代浏览器(如Chrome、Firefox)和任意代码编辑器(如VS Code)即可开始实践。浏览器内置的开发者控制台是学习和测试JavaScript的绝佳工具,无需复杂配置。

本文将系统讲解JavaScript的核心基础:首先介绍三种变量声明方式(var、let、const)及其区别;然后深入探讨JavaScript的基本数据类型(字符串、数字、布尔值、null、undefined、Symbol和BigInt);接着解析复杂数据类型(对象和数组);最后讲解类型转换、检查以及最佳实践。通过掌握这些基础,你将能够编写更高效、更可靠的JavaScript代码,为学习高级概念打下坚实基础。

变量声明:var、let和const的全面解析

在JavaScript中,变量声明是编程的基础。JavaScript提供了三种变量声明方式:varletconst,每种方式都有其特定的使用场景和规则。

var声明变量

var是JavaScript中最传统的变量声明方式,具有函数作用域和变量提升的特性。

// 函数作用域示例:var变量只在函数内有效
function exampleVar() {
  var message = "Hello from var";
  console.log(message); // 输出: Hello from var
}
// console.log(message); // 抛出ReferenceError: message is not defined

// 变量提升示例:var声明会被提升到作用域顶部
console.log(name); // 输出: undefined
var name = "JavaScript";
console.log(name); // 输出: JavaScript
// 上述代码等同于:
var name;
console.log(name); // undefined
name = "JavaScript";
console.log(name); // JavaScript

let声明变量

let是ES6引入的变量声明方式,具有块级作用域,解决了var的一些问题。

// 块级作用域示例:let变量只在代码块内有效
{
  let message = "Hello from let";
  console.log(message); // 输出: Hello from let
}
// console.log(message); // 抛出ReferenceError: message is not defined

// 暂时性死区示例:let声明之前不能访问
// console.log(name); // 抛出ReferenceError: Cannot access 'name' before initialization
let name = "JavaScript";
console.log(name); // 输出: JavaScript

const声明常量

const用于声明常量,一旦赋值就不能更改,同样具有块级作用域。

// 常量声明示例
const PI = 3.14159;
console.log(PI); // 输出: 3.14159

// 尝试修改常量会抛出错误
// PI = 3.14; // 抛出TypeError: Assignment to constant variable.

// 注意:const声明的是变量引用的不可性,而不是变量内容的不可变性
const user = { name: "Alice", age: 30 };
user.age = 31; // 这是允许的,因为修改的是对象属性,不是变量引用
console.log(user.age); // 输出: 31

变量声明的最佳实践

  1. 优先使用const:当不需要重新赋值时,使用const可以防止意外的变量修改。
  2. 需要重新赋值时使用let:只在确实需要修改变量值时使用let。
  3. 避免使用var:由于var的函数作用域和变量提升特性,容易导致意外的行为,建议在新代码中避免使用。

作用域与提升机制

JavaScript中的作用域分为全局作用域、函数作用域和块级作用域(ES6引入)。变量提升是JavaScript的一个特性,变量声明会被移动到其所在作用域的顶部。

// 全局作用域示例
var globalVar = "I'm global";

function checkScope() {
  // 函数作用域示例
  var functionVar = "I'm in function";
  
  if (true) {
    // 块级作用域示例(ES6)
    let blockVar = "I'm in block";
    const blockConst = "I'm also in block";
    
    console.log(globalVar); // 可以访问全局变量
    console.log(functionVar); // 可以访问函数变量
    console.log(blockVar); // 可以访问块级变量
    console.log(blockConst); // 可以访问块级常量
  }
  
  console.log(globalVar); // 可以访问
  console.log(functionVar); // 可以访问
  // console.log(blockVar); // 抛出错误,无法访问块级变量
  // console.log(blockConst); // 抛出错误,无法访问块级常量
}

checkScope();

总结来说,理解JavaScript中的变量声明机制对于编写高质量代码至关重要。现代JavaScript开发中,推荐使用let和const,避免使用var,以减少潜在的错误并提高代码的可读性。

JavaScript数据类型详解:从原始值到对象

在JavaScript中,数据类型是理解语言行为的基础。JavaScript中的数据类型分为两大类:原始数据类型和复杂数据类型。

原始数据类型:Number、String、Boolean详解

Number类型用于表示数值,包括整数和浮点数。

// 整数
let integer = 42;
// 浮点数
let float = 3.14;

// 特殊数值
let infinity = Infinity;  // 正无穷大
let notANumber = NaN;  // 非数字
console.log(1 / "hello"); // 输出: NaN

String类型用于表示文本数据,由零个或多个Unicode字符组成。

// 字符串字面量
let str1 = "使用双引号";
let str2 = '使用单引号';
let str3 = `使用模板字符串可以插入${variable}`;

// 字符串常用方法
let message = "Hello World";
console.log(message.length);  // 输出: 11
console.log(message.toUpperCase());  // 输出: "HELLO WORLD"

Boolean类型只有两个值:truefalse,用于逻辑判断。

let isTrue = true;
let isFalse = false;

// 在条件语句中
if (isTrue) {
    console.log("这是真的");
}

原始数据类型:Undefined、Null、Symbol的应用场景

Undefined表示已声明但未赋值的变量。

let declaredButNotAssigned;
console.log(declaredButNotAssigned);  // 输出: undefined

Null表示故意设置的空值,表示"没有值"。

let emptyValue = null;
console.log(emptyValue);  // 输出: null

Symbol是ES6引入的新类型,表示唯一的值,常用于对象属性键。

// 创建Symbol
let id = Symbol("id");

// 作为对象属性
let user = {
    [id]: 123,
    name: "张三"
};
console.log(user[id]);  // 输出: 123

复杂数据类型:Object及其子类型(Array、Date等)

Object是JavaScript中最复杂的数据类型,由键值对集合组成。

// 对象字面量
let person = {
    name: "李四",
    age: 30,
    sayHello: function() {
        console.log(`你好,我是${this.name}`);
    }
};

// 访问对象属性
console.log(person.name);  // 输出: "李四"
person.sayHello();  // 输出: "你好,我是李四"

Array是对象的特殊形式,用于存储有序的值集合。

// 数组字面量
let fruits = ["苹果", "香蕉", "橙子"];

// 数组操作
console.log(fruits[0]);  // 输出: "苹果"
fruits.push("葡萄");  // 添加元素
console.log(fruits.length);  // 输出: 4

Date用于处理日期和时间。

let now = new Date();
console.log(now.getFullYear());  // 输出当前年份
console.log(now.toLocaleDateString());  // 输出本地格式的日期

类型判断:typeof操作符详解与局限性

typeof操作符用于检测变量的数据类型。

console.log(typeof 42);        // 输出: "number"
console.log(typeof "hello");   // 输出: "string"
console.log(typeof true);      // 输出: "boolean"
console.log(typeof undefined); // 输出: "undefined"
console.log(typeof null);      // 输出: "object" (这是typeof的一个已知bug)
console.log(typeof Symbol());  // 输出: "symbol"
console.log(typeof {});        // 输出: "object"
console.log(typeof []);        // 输出: "object" (无法区分普通对象和数组)

注意typeof null返回"object"是一个历史遗留bug,需要特别注意。

检测数据类型的其他方法:instanceof与Object.prototype.toString

instanceof操作符用于检测对象是否属于特定类。

console.log([] instanceof Array);    // 输出: true
console.log({} instanceof Object);  // 输出: true
console.log("hello" instanceof String);  // 输出: false (字符串字面量不是String对象实例)

Object.prototype.toString是更可靠的类型检测方法。

// 创建一个通用的类型检测函数
function getType(value) {
    return Object.prototype.toString.call(value).slice(8, -1);
}

console.log(getType(42));          // 输出: "Number"
console.log(getType("hello"));     // 输出: "String"
console.log(getType(null));       // 输出: "Null"
console.log(getType(undefined));   // 输出: "Undefined"
console.log(getType([]));         // 输出: "Array"
console.log(getType({}));         // 输出: "Object"

理解JavaScript的数据类型及其检测方法是编写健壮代码的基础,掌握这些知识可以帮助开发者更好地处理数据和避免常见的类型相关错误。

数据类型转换与检查:掌握JavaScript的类型系统

在JavaScript中,数据类型转换是开发者必须掌握的核心概念,它分为隐式(强制)类型转换和显式类型转换两种方式。

隐式类型转换

JavaScript是一种动态类型语言,当操作不同类型的数据时,会自动进行类型转换,这可能导致意外结果。

// 数字与字符串的隐式转换
console.log(5 + "10");  // 输出: "510" (数字被转换为字符串)
console.log("5" - "2"); // 输出: 3 (字符串被转换为数字)

// 布尔值的隐式转换
console.log("5" == true); // 输出: false (非直观比较)
console.log(!!"non-empty"); // 输出: true (转换为布尔值)

显式类型转换

使用全局函数进行明确的类型转换是更可控的方式:

// 显式转换为数字
console.log(Number("123"));     // 输出: 123
console.log(parseInt("123px")); // 输出: 123
console.log(parseFloat("3.14")); // 输出: 3.14

// 显式转换为字符串
console.log(String(123));       // 输出: "123"
console.log((123).toString());  // 输出: "123"

// 显式转换为布尔值
console.log(Boolean(0));        // 输出: false
console.log(!!"hello");        // 输出: true

数据类型检查

JavaScript提供了多种检查数据类型的方法:

// 使用typeof操作符
console.log(typeof "hello");   // 输出: "string"
console.log(typeof 123);      // 输出: "number"
console.log(typeof null);     // 输出: "object" (这是JavaScript的一个已知缺陷)

// 使用Object.prototype.toString.call()
function getType(value) {
  return Object.prototype.toString.call(value).slice(8, -1);
}
console.log(getType(null));    // 输出: "Null"
console.log(getType(undefined)); // 输出: "Undefined"

常见陷阱与解决方案

JavaScript的类型转换存在一些令人困惑的陷阱:

// 陷阱1: 相等比较的复杂性
console.log(0 == "0");    // 输出: true (隐式转换导致)
console.log(0 === "0");   // 输出: false (严格相等比较)

// 陷阱2: 对象与原始值的比较
console.log([1] == 1);    // 输出: true (数组被转换为字符串再转为数字)

// 解决方案: 始终使用严格相等(===)进行类型和值的比较

避免不必要的数据类型转换

编写清晰的代码可以减少类型转换的需要:

// 好的做法: 明确类型
function calculateTotal(items) {
  // 确保items是数组
  if (!Array.isArray(items)) {
    throw new Error("Items must be an array");
  }
  
  // 使用reduce处理数字计算,避免隐式转换
  return items.reduce((sum, item) => {
    // 确保每个item的价格是数字
    const price = Number(item.price);
    return sum + price;
  }, 0);
}

// 更好的做法: 使用类型系统(如TypeScript)在编译时捕获类型错误

掌握JavaScript的类型转换规则,不仅能帮助你编写更可靠的代码,还能避免许多难以调试的bug。

常见错误与最佳实践:编写健壮的JavaScript代码

变量命名规范与最佳实践

良好的变量命名就像给房子贴上清晰的门牌号,让代码一目了然。遵循驼峰命名法(camelCase),避免使用特殊字符和JavaScript保留字:

// ✅ 推荐的命名方式
let userName = "Alice";
let shoppingCartItems = [];
const MAXIMUM_ITEMS = 10;

// ❌ 避免的命名方式
let x = "Alice"; // 无描述性
let shopping_cart_items = []; // 非驼峰命名
const maximum_items = 10; // 常量应使用全大写

避免未声明变量与严格模式

未声明变量会在全局作用域中创建新变量,可能导致意外行为:

function processData(data) {
  // ❌ 错误:未声明result变量
  result = processData(data);
  return result;
}

// ✅ 正确:使用严格模式避免意外全局变量
"use strict";
function processDataStrict(data) {
  let result = processData(data); // 必须声明变量
  return result;
}

选择合适的变量声明方式

JavaScript提供三种变量声明方式,各有用途:

// var - 函数作用域,可重复声明,存在变量提升
var counter = 0; // 在函数外部可访问
function updateCounter() {
  var counter = 10; // 创建新变量,不影响外部counter
  if (true) {
    var counter = 20; // 同一个变量
  }
  return counter; // 返回20
}

// let - 块级作用域,不可重复声明
let count = 0;
if (true) {
  let count = 10; // 不同的变量
  console.log(count); // 10
}
console.log(count); // 0

// const - 块级作用域,不可重新赋值
const API_KEY = "abc123"; // 不能重新赋值
const user = { name: "Alice" };
user.name = "Bob"; // ✅ 可以修改对象属性

处理null和undefined的技巧

// 检查null或undefined
function getUserName(user) {
  if (user == null) { // 同时检查null和undefined
    return "Guest";
  }
  return user.name;
}

// 使用可选链操作符(ES2020)
function getAddress(user) {
  return user?.address?.street ?? "未知地址";
}

// 使用空值合并运算符(ES2020)
const displayName = user.name ?? "匿名用户";

调试数据类型问题

// 使用typeof检查数据类型
let value = "hello";
console.log(typeof value); // "string"

// 使用console.table查看复杂数据结构
const users = [
  { id: 1, name: "Alice", active: true },
  { id: 2, name: "Bob", active: false }
];
console.table(users);

// 使用断点调试
function processData(data) {
  debugger; // 浏览器会在此处暂停
  return data.map(item => item * 2);
}

记住,好的代码不仅功能正确,还要易于理解和维护。遵循这些最佳实践可以避免常见错误,编写出更加健壮的JavaScript代码。

总结与进阶学习路径:从基础到应用

掌握JavaScript变量声明和数据类型是构建扎实编程基础的关键。通过本文,我们学习了var、let和const三种声明方式的区别,理解了原始类型与引用类型的特性,掌握了类型转换和检查方法。这些知识不仅帮助你编写更健壮的代码,也为理解JavaScript高级概念如闭包和原型链奠定了基础。

常见问题包括何时使用let而非const,以及如何避免类型转换中的隐式陷阱。推荐《JavaScript高级程序设计》和《ES6标准入门》作为进阶读物,MDN文档则是随时查阅的优质资源。

下一步应深入理解作用域链、事件循环和异步编程,同时尝试构建小型项目,如待办事项应用或简单计算器。实践是巩固知识的最佳途径,建议每天编写代码并参与开源项目。记住,精通JavaScript基础语法是成为优秀开发者的第一步,持续学习和实践将让你在这个动态语言的世界中游刃有余。

JS的对象属性存储器

在 JavaScript 中,对象的属性可以通过 "存储器属性"(Accessor Properties)来定义,也就是我们常说的 getter 和 setter 方法。它们允许我们在读取或设置属性时执行自定义逻辑,而不仅仅是简单地获取或赋值。

存储器属性的基本结构

存储器属性由两个函数组成:

  • get:读取属性时调用的函数,返回属性值
  • set:设置属性时调用的函数,接收新值作为参数

定义方式

  1. 对象字面量中定义

const obj = {
  _value: 0, // 通常用下划线表示"私有"变量(约定俗成)
  
  // getter:读取 value 时触发
  get value() {
    console.log('读取 value 属性');
    return this._value;
  },
  
  // setter:设置 value 时触发
  set value(newVal) {
    console.log('设置 value 属性为', newVal);
    if (newVal >= 0) { // 可以添加验证逻辑
      this._value = newVal;
    } else {
      console.log('值不能为负数');
    }
  }
};

// 使用
obj.value = 10; // 调用 setter,输出 "设置 value 属性为 10"
console.log(obj.value); // 调用 getter,输出 "读取 value 属性" 和 10
obj.value = -5; // 调用 setter,输出 "设置 value 属性为 -5" 和 "值不能为负数"
  1. 使用 Object.defineProperty() 定义

const obj = {
  _value: 0
};

Object.defineProperty(obj, 'value', {
  get() {
    return this._value;
  },
  set(newVal) {
    if (newVal >= 0) {
      this._value = newVal;
    }
  },
  enumerable: true, // 是否可枚举
  configurable: true // 是否可修改或删除
});

特点与用途

  1. 控制访问:可以对属性的读写进行控制,例如添加验证、转换数据格式

  2. 计算属性:根据其他属性动态计算值

    const person = {
      firstName: 'John',
      lastName: 'Doe',
      get fullName() {
        return `${this.firstName} ${this.lastName}`;
      },
      set fullName(name) {
        [this.firstName, this.lastName] = name.split(' ');
      }
    };
    
    console.log(person.fullName); // "John Doe"
    person.fullName = "Alice Smith";
    console.log(person.firstName); // "Alice"
    
  3. 数据劫持:可以监听属性的变化,常用于框架中的响应式实现

  4. 隐藏内部实现:通过 getter/setter 暴露接口,隐藏实际存储数据的变量

注意事项

  • getter 不能有参数,setter 只能有一个参数

  • 存储器属性和数据属性(直接存储值的属性)不能同时定义在同一个属性上

  • 通常使用一个单独的变量(如 _value)来存储实际值,避免在 getter/setter 中递归调用

存储器属性为对象属性提供了更灵活的访问控制,是 JavaScript 中实现封装和数据验证的重要机制。

setTimeout延迟超过2^31立即执行?揭秘JavaScript定时器的隐藏边界

看似简单的setTimeout背后,隐藏着一个24.8天的神秘限制

现象:当setTimeout遇到巨大延迟值

作为一名前端开发者,相信你经常使用setTimeout函数。但你是否尝试过给它一个非常大的延迟值?比如:

// 这会立即执行!
setTimeout(() => {
    console.log('执行了setTimeout的回调');
}, Math.pow(2, 31)); // 2147483648

当延迟值等于或大于2的31次方(2147483648毫秒)时,setTimeout的回调函数会立即执行,而不是等待约24.8天。这是JavaScript引擎的bug,还是有意为之的设计?

深入剖析:32位有符号整数的限制

要理解这个现象,我们需要了解JavaScript底层如何处理定时器延迟值。

32位有符号整数范围

JavaScript引擎使用32位有符号整数来存储setTimeout和setInterval的延迟参数。这意味着:

  • 最小値:-2147483648 (-2^31)
  • 最大値:2147483647 (2^31 - 1)

整数溢出现象

当我们传入2147483648(2^31)时:

// 十进制:2147483648
// 二进制:10000000000000000000000000000000

// 作为32位有符号整数解释时:
// 最高位是1,表示负数
// 实际值:-2147483648

这就是整数溢出——当值超出数据类型所能表示的范围时发生的情况。

浏览器如何处理负延迟

浏览器规范规定,如果setTimeout的延迟参数为负数,它会被clamp到0。这意味着:

// 这些写法效果相同
setTimeout(callback, -1000);
setTimeout(callback, 0);
setTimeout(callback); // 省略参数时默认也是0

因此,当我们传入大于等于2^31的值时,实际发生的是:

  1. 值被转换为32位有符号整数
  2. 发生整数溢出,正值变成负值
  3. 浏览器将负延迟解释为"立即执行"
  4. 回调被放入任务队列,等待当前执行栈清空后立即执行

实际影响与边界情况

最大可用延迟值

// 这是setTimeout能正常工作的最大延迟值
const MAX_DELAY = 2147483647; // 2^31 - 1

// 约等于24.8天
const days = MAX_DELAY / 1000 / 60 / 60 / 24;
console.log(days); // ≈24.85天

边界测试

// 正常执行(等待1秒)
setTimeout(() => console.log('正常延迟'), 1000);

// 正常执行(等待24.8天)
setTimeout(() => console.log('最大延迟'), 2147483647);

// 立即执行(整数溢出)
setTimeout(() => console.log('溢出延迟'), 2147483648);

// 立即执行(整数溢出)
setTimeout(() => console.log('更大延迟'), 9999999999);

解决方案:如何实现长延迟

如果需要超过24.8天的延迟,我们不能直接依赖setTimeout。以下是几种解决方案:

方案一:递归setTimeout检查

function longTimeout(callback, delayMs) {
    const startTime = Date.now();
    const maxDelay = 2147483647; // 最大安全值
    
    if (delayMs <= maxDelay) {
        // 在安全范围内,直接使用setTimeout
        return setTimeout(callback, delayMs);
    }
    
    // 超过安全范围,使用递归检查
    function check() {
        const elapsed = Date.now() - startTime;
        const remaining = delayMs - elapsed;
        
        if (remaining <= 0) {
            callback();
        } else if (remaining > maxDelay) {
            // 仍需等待较长时间
            setTimeout(check, maxDelay);
        } else {
            // 剩余时间在安全范围内
            setTimeout(callback, remaining);
        }
    }
    
    // 启动第一次检查
    setTimeout(check, maxDelay);
}

// 使用示例
longTimeout(() => {
    console.log('这段代码将在30天后执行');
}, 30 * 24 * 60 * 60 * 1000);

方案二:基于时间戳的循环检查

class LongTimeout {
    constructor(callback, delayMs) {
        this.callback = callback;
        this.targetTime = Date.now() + delayMs;
        this.timeoutId = null;
        this.start();
    }
    
    start() {
        const now = Date.now();
        const remaining = this.targetTime - now;
        
        if (remaining <= 0) {
            this.callback();
            return;
        }
        
        // 使用安全范围内的最大延迟
        const delay = Math.min(remaining, 2147483647);
        this.timeoutId = setTimeout(() => this.start(), delay);
    }
    
    clear() {
        if (this.timeoutId) {
            clearTimeout(this.timeoutId);
            this.timeoutId = null;
        }
    }
}

// 使用示例
const timeout = new LongTimeout(() => {
    console.log('这段代码将在30天后执行');
}, 30 * 24 * 60 * 60 * 1000);

// 如需取消
// timeout.clear();

方案三:使用第三方库

一些现有的库已经解决了这个问题,如:

实际应用场景

虽然日常开发中很少需要设置超过24.8天的定时器,但了解这个限制很重要,特别是在以下场景:

  1. 长期计划任务:如定时数据备份、月度报告生成等
  2. 浏览器标签页休眠恢复:长时间未活动的标签页恢复时
  3. 错误预防:避免因计算错误导致意外立即执行

总结与最佳实践

  1. 了解限制:setTimeout/setInterval的最大延迟是2147483647毫秒(约24.8天)
  2. 边界检查:在代码中添加对延迟参数的验证
  3. 错误处理:处理可能出现的整数溢出情况
  4. 选择方案:根据需求选择合适的超时实现方式
// 最佳实践:添加参数验证
function safeSetTimeout(callback, delayMs) {
    const MAX_DELAY = 2147483647;
    
    if (delayMs > MAX_DELAY) {
        console.warn(`延迟时间超过最大允许值(${MAX_DELAY}ms),将使用递归实现`);
        // 使用上述长延迟实现方案
        return implementLongTimeout(callback, delayMs);
    }
    
    if (delayMs < 0) {
        console.warn('延迟时间不能为负数,已自动调整为0');
        delayMs = 0;
    }
    
    return setTimeout(callback, delayMs);
}

JavaScript的这个"特性"提醒我们,即使是最基础的API,也隐藏着许多值得深入了解的细节。理解这些底层原理,不仅能避免潜在的bug,还能让我们成为更出色的开发者。

思考题:你在工作中还遇到过哪些看似简单却隐藏着复杂逻辑的JavaScript特性?欢迎在评论区分享你的经历!

如何在 vue3+vite 中使用 Element-plus 实现 自定义主题 多主题切换

Element Plus 多主题切换:源码级新玩法

在做 Vue 3 + Element Plus 项目时,自定义主题几乎是必修课。
大家都知道的几种方案:

  • 覆盖 CSS 变量
  • 修改 SCSS 变量重新编译
  • 官方提供的暗黑模式

这些方式足够应付单主题或暗黑/明亮切换,但要实现 多主题自由切换(品牌 A、品牌 B、高对比度…) 时,体验就差强人意了。

翻源码的过程中,我发现了一个关键点 —— Element Plus 内部生成颜色变量的函数 set-css-color-type。默认它写死了 "base" 作为主色入口。

前置条件

  • 使用了sass 因此我们需要安装插件 sass-embedded
pnpm add sass-embedded -D

原版源码(节选)

源码地址:https://github.com/element-plus/element-plus/blob/dev/packages/theme-chalk/src/mixins/_var.scss

// https://github.com/element-plus/element-plus/blob/dev/packages/theme-chalk/src/mixins/_var.scss
@mixin set-css-color-type($colors, $type) {
  @include set-css-var-value(('color', $type), map.get($colors, $type, 'base'));

  @each $i in (3, 5, 7, 8, 9) {
    @include set-css-var-value(
      ('color', $type, 'light', $i),
      map.get($colors, $type, 'light-#{$i}')
    );
  }

  @include set-css-var-value(
    ('color', $type, 'dark-2'),
    map.get($colors, $type, 'dark-2')
  );
}

可以看到,map.get(..., 'base') 被写死了。
这意味着所有主题都只能以 base 为入口。


改造方案

我们把 base 抽象成一个 $theme 参数,这样就能为每个主题(如 default / simple / grand / noble)指定不同的基色。

@mixin set-css-color-type($colors, $type, $theme: "base") {
  @include set-css-var-value(('color', $type), map.get($colors, $type, $theme));

  @each $i in (3, 5, 7, 8, 9) {
    @include set-css-var-value(
      ('color', $type, 'light', $i),
      map.get($colors, $type, 'light-#{$i}')
    );
  }

  @include set-css-var-value(
    ('color', $type, 'dark-2'),
    map.get($colors, $type, 'dark-2')
  );
}

然后批量生成主题 class:

@each $class, $color in ('base', 'default', 'simple', 'grand', 'noble') {
  .#{$class} {
    @each $type in (primary, success, warning, danger, info) {
      @include set-css-color-type($colors, $type, $class);
    }
  }
}

定义多主题颜色

$colors: (
  'primary': (
    default: #0069d9,
    simple: #4a90e2,
    grand: #2c6b97,
    noble: #003b61
  ),
  'success': (
    default: #28a745,
    simple: #5cb85c,
    grand: #2d8c4f,
    noble: #1f7b35
  ),
  'info': (
    default: #17a2b8,
    simple: #32c8d5,
    grand: #2292b7,
    noble: #166f80
  ),
  'warning': (
    default: #ffc107,
    simple: #f39c12,
    grand: #e67e22,
    noble: #e04e01
  ),
  'danger': (
    default: #dc3545,
    simple: #e57373,
    grand: #c0392b,
    noble: #9b2d20
  )
);

Vue 中动态切换

// useTheme.ts
import { ref } from 'vue'

const theme = ref('default')

export function useTheme() {
  const setTheme = (name: string) => {
    theme.value = name
    document.documentElement.className = name
  }
  return { theme, setTheme }
}

只要调用 setTheme('grand'),页面就会切换成深蓝主题,Element Plus 的暗色模式与亮色层级依然会自动生效。不过推荐使用vueuse的 useColorMode,好不好用我就不用多说了

最后 完整代码

目录结构:

src/
└─ index.scss
├─ main.ts
├─ app.vue
vite.ts

// src/index.scss
@use 'sass:map';
@use 'sass:color';

$primary-themes: (
  default: #0069d9,
  simple: #4a90e2,
  grand: #2c6b97,
  noble: #003b61
);

$success-themes: (
  default: #28a745,
  simple: #5cb85c,
  grand: #2d8c4f,
  noble: #1f7b35
);

$info-themes: (
  default: #17a2b8,
  simple: #32c8d5,
  grand: #2292b7,
  noble: #166f80
);

$warning-themes: (
  default: #ffc107,
  simple: #f39c12,
  grand: #e67e22,
  noble: #e04e01
);

$danger-themes: (
  default: #dc3545,
  simple: #e57373,
  grand: #c0392b,
  noble: #9b2d20
);

@forward 'element-plus/theme-chalk/src/common/var.scss' with (
  $colors: (
    'primary': $primary-themes,
    'success': $success-themes,
    'info': $info-themes,
    'warning': $warning-themes,
    'danger': $danger-themes
  )
);

@use 'element-plus/theme-chalk/src/common/var.scss' as *;
@use 'element-plus/theme-chalk/src/mixins/_var.scss' as *;




@mixin set-css-color-type($colors, $type, $theme: "base") {
  @include set-css-var-value(('color', $type), map.get($colors, $type, $theme));
  @each $i in (3, 5, 7, 8, 9) {
    @include set-css-var-value(
      ('color', $type, 'light', $i),
      map.get($colors, $type, 'light-#{$i}')
    );
  }

  @include set-css-var-value(
    ('color', $type, 'dark-2'),
    map.get($colors, $type, 'dark-2')
  );
}


@each $class, $color in $primary-themes {
  .#{$class} {
    @each $type in (primary, success, warning, danger,  info) {
        @include set-css-color-type($colors, $type,$class)
    }
  }
}

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import ElementPlus from 'element-plus'
// import 'element-plus/dist/index.css'
import "./index.scss"
createApp(App).use(ElementPlus).mount('#app')

// vite.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vite.dev/config/
export default defineConfig({
  plugins: [vue()],
})

// src/app.vue
<script setup lang="ts">

</script>

<template>
  <div>
    element-plus-custom-themes
    <el-button>默认按钮</el-button>
    <el-button type="primary">主要按钮</el-button>
  </div>
</template>

<style scoped>

</style>

按需引入

如果我们使用按需引入,只需要改掉vite.ts 和main.ts

  • 前置条件 安装 按需引入需要的插件
pnpm add unplugin-vue-components unplugin-auto-import -D
  • 修改后的文件
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
// import ElementPlus from 'element-plus'
// import 'element-plus/dist/index.css'
// import "./assets/styles/index.scss"
// createApp(App).use(ElementPlus).mount('#app')
createApp(App).mount('#app')

// vite.ts
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      include: [/\.[jt]sx?$/, /\.vue$/, /\.vue\?vue/, /\.md$/],
      imports: [
        'vue',
      ],
      resolvers: [ElementPlusResolver()],
      vueTemplate: true,
    }),
    Components({
      extensions: ['vue', 'md'],
      include: [/\.vue$/, /\.vue\?vue/, /\.md$/],
      resolvers: [ElementPlusResolver({
        importStyle: 'sass',
      })],
    }),
  ],
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@use "@/assets/styles/themes/index.scss" as *;`
      },
    },
  },
})


总结

  • Element Plus 默认 set-css-color-type 写死了 "base",这是多主题受限的根源。
  • 我们改造后,可以让 default / simple / grand / noble 等多套主题并存。
  • 内置的暗黑模式、颜色层级(light/dark)逻辑依旧能正常工作,不需要重复维护。
  • 按需导入,减少构建体积。

这样,你就可以在实际项目里优雅地支持 品牌主题、活动主题、暗黑主题 等多套风格啦 🎉

前端发送语音方式总结

核心流程概述

无论采用哪种方式,其核心流程通常都包含以下几个步骤:

  1. 采集 (Capture) :通过浏览器的 MediaDevices.getUserMedia() API 获取麦克风的音频流(MediaStream)。
  2. 处理 (Process) :(可选)对音频流进行加工,如降噪、增益、编码格式转换等。
  3. 编码与封装 (Encode & Package) :将音频数据转换为特定的格式(如 WAV, MP3, WebM, OPUS 等)并可能封装成文件或数据块。
  4. 传输 (Transmit) :通过网络协议(如 HTTP, WebSocket)将数据发送到后端。
  5. 后端处理:后端接收数据,进行解码、转码、存储或分析(如语音识别)。

常用方式详解

1. 录制为文件后上传 (HTTP Multipart/form-data)

这是最传统、兼容性最好的方式。用户录制一段语音,结束后生成一个音频文件(如 MP3),然后通过普通的表单提交上传到后端。

  • 实现步骤

    1. 使用 MediaRecorder API 录制 MediaStream
    2. 录制结束后,生成一个音频文件(通常是 Blob 对象)。
    3. 创建一个 FormData 对象,将 Blob 文件添加到其中。
    4. 通过 fetch 或 XMLHttpRequest 以 POST 请求发送这个 FormData
  • 代码示例

    let mediaRecorder;
    let audioChunks = [];
    
    // 1. 请求麦克风权限并开始录制
    navigator.mediaDevices.getUserMedia({ audio: true })
      .then(stream => {
        mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' }); // 或 'audio/mp3'
    
        mediaRecorder.ondataavailable = event => {
          audioChunks.push(event.data);
        };
    
        mediaRecorder.onstop = () => {
          // 2. 录制结束,生成Blob
          const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
          // 3. 创建FormData并附加文件
          const formData = new FormData();
          formData.append('audio', audioBlob, 'recording.webm');
          // 可以附加其他信息,如用户ID
          formData.append('userId', '123');
    
          // 4. 上传到服务器
          fetch('/api/upload-voice', {
            method: 'POST',
            body: formData
          }).then(response => {
            console.log('Upload successful!');
          });
        };
      });
    
    // 开始录制
    mediaRecorder.start();
    // ... 用户点击停止后
    // mediaRecorder.stop();
    
  • 优点

    • 实现简单,后端处理也简单(像处理普通文件上传一样)。
    • 兼容绝大多数浏览器。
  • 缺点

    • 延迟高:必须等整个录音过程结束才能上传,无法实时传输。
    • 不适用于实时场景:如语音识别、直播连麦等。
  • 适用场景:发送语音消息、录制后上传的语音笔记等非实时应用。

2. 使用 WebSocket 进行流式传输 (Streaming)

这是实现实时语音传输的首选方案。音频数据被分成小块,一旦采集到就立即通过 WebSocket 连接发送到后端,实现极低的延迟。

  • 实现步骤

    1. 建立 WebSocket 连接到后端。
    2. 获取麦克风音频流。
    3. 使用 MediaRecorder API,但设置一个很短的 timeslice 参数(例如 100ms),这会定期触发 ondataavailable 事件,输出小块的音频数据(数据块,通常是 WebM 或 OPUS 格式)。
    4. 在 ondataavailable 事件中,将收到的数据块通过 WebSocket 直接发送。
    5. 后端 WebSocket 服务持续接收这些数据块,并进行实时处理(如拼装、解码、转码或送入语音识别引擎)。
  • 代码示例

    const socket = new WebSocket('wss://your-backend.com/ws-voice');
    let mediaRecorder;
    
    socket.onopen = () => {
      navigator.mediaDevices.getUserMedia({ audio: true })
        .then(stream => {
          // 关键:设置 timeslice 为 100ms,每100ms产生一个数据块
          mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm; codecs=opus' });
          mediaRecorder.ondataavailable = (event) => {
            // 确保 WebSocket 连接是打开的
            if (event.data.size > 0 && socket.readyState === WebSocket.OPEN) {
              // 将数据块直接发送给服务器
              socket.send(event.data);
            }
          };
          mediaRecorder.start(100); // 每100ms触发一次 ondataavailable
        });
    };
    
    // 结束时关闭连接和录制
    // mediaRecorder.stop();
    // socket.close();
    
  • 优点

    • 实时性极好,延迟非常低。
    • 双向通信,后端可以随时发回响应(如识别结果)。
  • 缺点

    • 后端需要实现 WebSocket 服务,处理流式数据,复杂度较高。
    • 需要处理网络不稳定和重连逻辑。
  • 适用场景:实时语音识别、语音聊天、在线会议、语音助手交互。

3. 使用 HTTP 分块传输编码 (Chunked Transfer Encoding)

类似于 WebSocket 方式,但使用 HTTP。前端将音频数据流通过一个持续的 HTTP 请求发送,后端像接收流一样读取数据。这通常需要后端框架支持流式请求体。

  • 实现步骤

    1. 与 WebSocket 方式类似,使用 MediaRecorder 和 timeslice 切割音频数据。
    2. 使用 fetch 发起一个 POST 请求,但请求体是一个 ReadableStream
    3. 将产生的音频数据块(Blob)不断写入这个流。
    4. 后端(如 Node.js with Express, Python with Flask)以流的形式读取请求体。
  • 代码示例(概念性)

    // 这是一个高级用法,浏览器支持和使用复杂度较高
    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
    const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm; codecs=opus' });
    
    const { readable, writable } = new TransformStream();
    const writer = writable.getWriter();
    
    mediaRecorder.ondataavailable = async (event) => {
      if (event.data.size > 0) {
        await writer.write(event.data);
      }
    };
    mediaRecorder.start(100);
    
    // 发起流式Fetch请求
    fetch('/api/stream-voice', {
      method: 'POST',
      headers: { 'Content-Type': 'application/octet-stream' },
      body: readable // 请求体是一个流
    });
    
  • 优点

    • 基于 HTTP,基础设施简单(不需要单独的 WS 服务)。
  • 缺点

    • 实现相对复杂,浏览器和後端的支持不如前两种方式通用。
    • 连接管理不如 WebSocket 灵活(例如,服务器难以主动推送消息)。
  • 适用场景:需要流式传输但又想避免 WebSocket 复杂性的场景,但通常 WebSocket 是更优选择。


总结与选择建议

方式 协议 实时性 复杂度 适用场景
文件上传 HTTP (录制完成后上传) 语音消息、邮件附件、非实时录制
WebSocket 流 WebSocket (实时流) 中高 实时语音识别、语音聊天、在线会议
HTTP 分块 HTTP 中高(准实时流) 流式传输,但更推荐 WebSocket

如何选择?

  • 如果你的应用是“对讲机”或“微信语音消息”模式:用户说一段话,松手后发送。选择方式1(文件上传)  最简单高效。
  • 如果你的应用是“语音实时转文字”或“Siri/Google Assistant”模式:用户说话的同时,屏幕上就在出文字。选择方式2(WebSocket 流)
  • 如果你的应用是“视频会议”或“语音聊天”模式:需要双向、持续的音频流交互。选择方式2(WebSocket 流) ,并可能需要使用 WebRTC( peer-to-peer 协议,比 WebSocket 更专门用于实时音视频,但后端处理逻辑也不同)。

额外重要考虑因素:

  • 音频格式(Codec)

    • audio/webm; codecs=opus强烈推荐。OPUS 格式音质好、压缩率高、延迟低,是 WebRTC 和现代浏览器的标准。后端需要支持解码 OPUS(例如使用 ffmpeg)。
    • audio/mp3:兼容性好,文件小,但编码延迟高,不适合实时流
    • audio/wav:音质无损,文件巨大,主要用于需要高质量音频且不需要考虑带宽的场景。
  • 后端处理:前端选择何种方式很大程度上取决于后端的接收和处理能力。在决定前端方案前,务必与后端工程师沟通好他们期望接收的数据格式和传输协议。

# vue3 实现甘特图

vue3 实现甘特图

有一个特别好用的插件,专门用来做甘特图的,分享一下。

相关资料

文档:vxeui.com/#/start/use…

安装

安装的话特别简单,使用下面命令就可以了:

npm install vxe-pc-ui@next vxe-table@next vxe-gantt@next

安装完成之后需要单独配置一下,在 main.js 文件中:

import VxeUIAll from 'vxe-pc-ui'
import 'vxe-pc-ui/es/style.css'
import VxeUITable from 'vxe-table'
import 'vxe-table/es/style.css'
import VxeUIGantt from 'vxe-gantt'
import 'vxe-gantt/lib/style.css'

createApp(App).use(VxeUIAll).use(VxeUITable).use(VxeUIGantt).mount('#app')

配置完成就可以直接使用了。

使用

在需要使用的页面,先嵌入一个div用来展示甘特图。

<template>
  <div class="ed-page">
    <vxe-gantt v-bind="ganttOptions"></vxe-gantt>
  </div>
</template>

然后就是通过配置他的配置项进行渲染效果了。

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

const ganttOptions = ref({
  border: true,
  height: "100%",
  rowConfig: {
    isHover: true
  },
  checkboxConfig: {
    labelField: 'title',
    highlight: true
  },
  taskBarConfig: {
    showProgress: true,
    showContent: true,
    barStyle: {
      round: true,
      bgColor: '#fca60b',
      completedBgColor: '#65c16f'
    }
  },
  taskViewConfig: {
    tableStyle: {
      width: 480
    }
  },
  columns: [
    { type: 'checkbox', title: '任务名称' },
    { field: 'start', title: '开始时间', width: 100 },
    { field: 'end', title: '结束时间', width: 100 }
  ],
  data: [
    { id: 10001, title: 'A项目', start: '2024-03-01', end: '2024-03-04', progress: 3 },
    { id: 10002, title: '城市道路修理进度', start: '2024-03-03', end: '2024-03-08', progress: 10 },
    { id: 10003, title: 'B大工程', start: '2024-03-03', end: '2024-03-11', progress: 90 },
    { id: 10004, title: '超级大工程', start: '2024-03-05', end: '2024-03-11', progress: 15 },
    { id: 10005, title: '地球净化项目', start: '2024-03-08', end: '2024-03-15', progress: 100 },
    { id: 10006, title: '一个小目标项目', start: '2024-03-10', end: '2024-03-21', progress: 5 },
    { id: 10007, title: '某某计划', start: '2024-03-15', end: '2024-03-24', progress: 70 },
    { id: 10008, title: '某某科技项目', start: '2024-03-20', end: '2024-03-29', progress: 50 },
    { id: 10009, title: '地铁建设工程', start: '2024-03-19', end: '2024-03-20', progress: 5 },
    { id: 10010, title: '铁路修建计划', start: '2024-03-12', end: '2024-03-20', progress: 50 }
  ]
})

</script>

最后出现的效果就像下面的截图一样啦:

在这里插入图片描述

其他案例

当然还有各种各样的效果,可以参考上面的网站进行配置,下面也放几个其他效果的代码。

案例一

<template>
  <div class="ed-page">
    <vxe-gantt v-bind="ganttOptions"></vxe-gantt>
  </div>
</template>
<script setup>
// 安装
// npm install vxe-pc-ui@next vxe-table@next vxe-gantt@next

import { reactive, ref } from 'vue'

const ganttOptions = ref({
  border: true,
  columnConfig: {
    resizable: true
  },
  taskBarConfig: {
    showProgress: true
  },
  columns: [
    { field: 'title', title: '任务名称' },
    { field: 'start', title: '开始时间', width: 100 },
    { field: 'end', title: '结束时间', width: 100 }
  ],
  data: [
    { id: 10001, title: 'A项目', start: '2024-03-01', end: '2024-03-04', progress: 3 },
    { id: 10002, title: '城市道路修理进度', start: '2024-03-03', end: '2024-03-08', progress: 10 },
    { id: 10003, title: 'B大工程', start: '2024-03-03', end: '2024-03-11', progress: 90 },
    { id: 10004, title: '超级大工程', start: '2024-03-05', end: '2024-03-11', progress: 15 },
    { id: 10005, title: '地球净化项目', start: '2024-03-08', end: '2024-03-15', progress: 100 },
    { id: 10006, title: '一个小目标项目', start: '2024-03-10', end: '2024-03-21', progress: 5 },
    { id: 10007, title: '某某计划', start: '2024-03-15', end: '2024-03-24', progress: 70 },
    { id: 10008, title: '某某科技项目', start: '2024-03-20', end: '2024-03-29', progress: 50 },
    { id: 10009, title: '地铁建设工程', start: '2024-03-19', end: '2024-03-20', progress: 5 },
    { id: 10010, title: '铁路修建计划', start: '2024-03-12', end: '2024-03-20', progress: 10 },
    { id: 10011, title: '蓝天计划', start: '2024-03-02', end: '2024-03-42', progress: 0 },
    { id: 10012, title: 'C计划', start: '2024-03-05', end: '2024-03-14', progress: 90 }
  ]
})

</script>
<style scoped lang="scss"></style>

在这里插入图片描述

案例二

<template>
  <div class="ed-page">
    <vxe-gantt v-bind="ganttOptions"></vxe-gantt>
  </div>
</template>
<script setup>
// 安装
// npm install vxe-pc-ui@next vxe-table@next vxe-gantt@next

import { reactive, ref } from 'vue'

const ganttOptions = ref({
  showOverflow: true,
  border: 'outer',
  stripe: true,
  rowConfig: {
    useKey: true
  },
  columnConfig: {
    resizable: true
  },
  checkboxConfig: {
    labelField: 'name'
  },
  treeConfig: {
    transform: true,
    showLine: true
  },
  taskBarConfig: {
    showProgress: true,
    showContent: true
  },
  taskViewConfig: {
    tableStyle: {
      width: 480
    }
  },
  columns: [
    { field: 'title', title: '任务名称', treeNode: true },
    { field: 'start', title: '开始时间', width: 100 },
    { field: 'end', title: '结束时间', width: 100 },
    { field: 'progress', title: '进度(%)', width: 80 }
  ],
  data: [
    { id: 10001, parentId: null, title: 'A项目', start: '2024-03-01', end: '2024-03-04', progress: 3 },
    { id: 10002, parentId: 10001, title: '城市道路修理进度', start: '2024-03-03', end: '2024-03-08', progress: 10 },
    { id: 10003, parentId: null, title: 'B大工程', start: '2024-03-03', end: '2024-03-11', progress: 90 },
    { id: 10004, parentId: 10003, title: '超级大工程', start: '2024-03-05', end: '2024-03-11', progress: 15 },
    { id: 10005, parentId: 10003, title: '地球净化项目', start: '2024-03-08', end: '2024-03-15', progress: 100 },
    { id: 10006, parentId: 10003, title: '一个小目标项目', start: '2024-03-10', end: '2024-03-21', progress: 0 },
    { id: 10007, parentId: 10005, title: '某某计划', start: '2024-03-15', end: '2024-03-24', progress: 70 },
    { id: 10008, parentId: null, title: '某某科技项目', start: '2024-03-20', end: '2024-03-29', progress: 50 },
    { id: 10009, parentId: 10008, title: '地铁建设工程', start: '2024-03-19', end: '2024-03-20', progress: 5 },
    { id: 10010, parentId: 10008, title: '公寓装修计划2', start: '2024-03-12', end: '2024-03-20', progress: 30 },
    { id: 10011, parentId: 10008, title: '两个小目标工程', start: '2024-03-01', end: '2024-03-04', progress: 20 },
    { id: 10012, parentId: null, title: '蓝天计划', start: '2024-03-02', end: '2024-03-08', progress: 50 },
    { id: 10013, parentId: 10010, title: 'C大项目', start: '2024-03-08', end: '2024-03-11', progress: 10 },
    { id: 10014, parentId: 10010, title: 'H计划', start: '2024-03-12', end: '2024-03-16', progress: 100 },
    { id: 10015, parentId: 10011, title: '铁路修建计划', start: '2024-03-05', end: '2024-03-06', progress: 0 },
    { id: 10016, parentId: 10011, title: 'D项目', start: '2024-03-06', end: '2024-03-11', progress: 10 },
    { id: 10017, parentId: 10011, title: '海外改造工程', start: '2024-03-08', end: '2024-03-09', progress: 0 },
    { id: 10018, parentId: null, title: 'Z计划', start: '2024-03-24', end: '2024-03-26', progress: 80 },
    { id: 10019, parentId: 10018, title: 'F工程', start: '2024-03-20', end: '2024-03-28', progress: 10 },
    { id: 10020, parentId: 10018, title: '投资大项目', start: '2024-03-23', end: '2024-03-28', progress: 60 },
    { id: 10021, parentId: 10018, title: 'X计划', start: '2024-03-16', end: '2024-03-25', progress: 10 },
    { id: 10022, parentId: null, title: '上天计划', start: '2024-03-05', end: '2024-03-24', progress: 0 },
    { id: 10023, parentId: null, title: 'G项目', start: '2024-03-08', end: '2024-03-28', progress: 5 },
    { id: 10024, parentId: 10023, title: '下地计划', start: '2024-03-09', end: '2024-03-16', progress: 50 }
  ]
})

</script>
<style scoped lang="scss"></style>

在这里插入图片描述

案例三

<template>
  <div class="ed-page">
    <vxe-gantt v-bind="ganttOptions"></vxe-gantt>
  </div>
</template>
<script setup>
// 安装
// npm install vxe-pc-ui@next vxe-table@next vxe-gantt@next

import { reactive, ref } from 'vue'

const ganttOptions = ref({
  border: true,
  height: 500,
  rowConfig: {
    isHover: true
  },
  checkboxConfig: {
    labelField: 'title',
    highlight: true
  },
  taskBarConfig: {
    showProgress: true,
    showContent: true,
    barStyle: {
      round: true,
      bgColor: '#fca60b',
      completedBgColor: '#65c16f'
    }
  },
  taskViewConfig: {
    tableStyle: {
      width: 480
    }
  },
  columns: [
    { type: 'checkbox', title: '任务名称' },
    { field: 'start', title: '开始时间', width: 100 },
    { field: 'end', title: '结束时间', width: 100 }
  ],
  data: [
    { id: 10001, title: 'A项目', start: '2024-03-01', end: '2024-03-04', progress: 3 },
    { id: 10002, title: '城市道路修理进度', start: '2024-03-03', end: '2024-03-08', progress: 10 },
    { id: 10003, title: 'B大工程', start: '2024-03-03', end: '2024-03-11', progress: 90 },
    { id: 10004, title: '超级大工程', start: '2024-03-05', end: '2024-03-11', progress: 15 },
    { id: 10005, title: '地球净化项目', start: '2024-03-08', end: '2024-03-15', progress: 100 },
    { id: 10006, title: '一个小目标项目', start: '2024-03-10', end: '2024-03-21', progress: 5 },
    { id: 10007, title: '某某计划', start: '2024-03-15', end: '2024-03-24', progress: 70 },
    { id: 10008, title: '某某科技项目', start: '2024-03-20', end: '2024-03-29', progress: 50 },
    { id: 10009, title: '地铁建设工程', start: '2024-03-19', end: '2024-03-20', progress: 5 },
    { id: 10010, title: '铁路修建计划', start: '2024-03-12', end: '2024-03-20', progress: 10 }
  ]
})

</script>
<style scoped lang="scss"></style>

在这里插入图片描述

好了,上面的网站还有很多的效果,看需要实现吧!

React中,useState和useReducer有什么区别

在 React 中,useState 和 useReducer 都是用于管理组件状态的 Hook,但它们的设计理念、适用场景和使用方式有显著区别。理解这些区别可以帮助我们在不同场景下选择更合适的状态管理方案。

Taimili 艾米莉 ( 一款免费开源的 taimili.com )

艾米莉 是一款优雅便捷的 GitHub Star 管理和加星工具,基于 PHP & javascript 构建, 能对github 得 star fork follow watch 管理和提升,最适合github 的深度用户

image.png

核心区别对比

维度 useState useReducer
设计理念 轻量、简洁的状态管理,适合简单场景 基于「Reducer 模式」,通过 action 驱动状态变更,适合复杂逻辑
状态复杂度 适合管理单一值或简单结构(如布尔、数字、简单对象) 适合管理多关联状态、复杂状态逻辑(如多字段表单、状态变更依赖多个条件)
更新逻辑 直接通过 setState 函数更新状态 通过 dispatch(action) 触发 reducer 函数计算新状态
逻辑复用 复杂逻辑复用较困难 reducer 函数可独立提取,便于复用和测试
调试友好度 状态变更分散,难以追踪 通过 action 类型可清晰追踪状态变更原因

1. 基本用法与设计思路

useState:简单状态的直接管理

useState 是最基础的状态管理 Hook,设计目标是简洁直观。它接收一个初始状态,返回一个「状态变量」和「更新函数」,通过直接调用更新函数修改状态。

jsx

// 示例:用 useState 管理计数器
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>加 1</button>
      <button onClick={() => setCount(count - 1)}>减 1</button>
    </div>
  );
}

核心特点

  • 状态更新逻辑直接写在事件处理中(如 count + 1);
  • 适合状态变化简单、逻辑单一的场景。

useReducer:复杂状态的结构化管理

useReducer 借鉴了 Redux 的思想,通过「Reducer 函数」和「Action」来管理状态。它接收两个参数:

  • reducer:一个纯函数,接收当前状态和 action,返回新状态;

  • initialState:初始状态。

返回值为「当前状态」和「dispatch 函数」,通过 dispatch(action) 触发状态更新。

jsx

// 示例:用 useReducer 管理计数器
// 1. 定义 reducer 函数(纯函数,处理状态逻辑)
function countReducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;
    case 'DECREMENT':
      return state - 1;
    case 'RESET':
      return 0;
    default:
      return state;
  }
}

// 2. 在组件中使用
function Counter() {
  const [count, dispatch] = useReducer(countReducer, 0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>加 1</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>减 1</button>
      <button onClick={() => dispatch({ type: 'RESET' })}>重置</button>
    </div>
  );
}

核心特点

  • 状态更新逻辑集中在 reducer 中,通过 action.type 区分不同操作;
  • 状态变化的意图更清晰(如 INCREMENT 明确表示「增加」)。

2. 适用场景差异

优先用 useState 的场景:

  • 状态结构简单(如单个布尔值、数字、字符串);

  • 状态更新逻辑单一(如直接赋值、简单计算);

  • 组件规模小,状态变更少。

例如:开关状态(isOpen)、输入框内容(inputValue)、加载状态(isLoading)等。

优先用 useReducer 的场景:

  • 状态结构复杂(如包含多个子字段的对象,{ name, age, address });

  • 状态更新依赖多个条件或前一个状态(如「先判断再更新」的逻辑);

  • 存在多种状态变更方式(如一个表单有「提交」「重置」「修改单个字段」等操作);

  • 需要复用状态逻辑(reducer 可提取到组件外,供多个组件使用);

  • 调试需求高(通过 action 可追踪状态变更历史)。

例如:购物车(添加、删除、修改数量)、多步骤表单、复杂游戏状态等。

3. 其他关键区别

状态更新的依赖处理

useState 若要基于前一个状态更新,需使用函数式更新(避免闭包陷阱):

jsx

// 正确:基于前一个状态更新
setCount(prevCount => prevCount + 1);

useReducer 天然基于前一个状态计算新状态(reducer 的第一个参数就是当前状态),无需额外处理闭包问题:

jsx

// reducer 中直接使用当前状态
function reducer(state, action) {
  return { ...state, count: state.count + 1 }; // 安全依赖当前状态
}

逻辑复用能力

useReducer 的 reducer 是纯函数,可独立于组件存在,便于复用和测试:

jsx

// 提取到单独文件(如 reducers.js)
export function formReducer(state, action) { /* ... */ }

// 在多个组件中导入使用
import { formReducer } from './reducers';
function FormA() { const [state, dispatch] = useReducer(formReducer, initialA); }
function FormB() { const [state, dispatch] = useReducer(formReducer, initialB); }

而 useState 的更新逻辑若复杂,往往分散在组件的事件处理中,复用难度大。

总结

  • useState 是「简单状态的快捷方式」,适合逻辑简单、状态独立的场景,写法更简洁;

  • useReducer 是「复杂状态的结构化方案」,适合逻辑复杂、状态关联的场景,更利于维护和调试。

实际开发中,两者并非互斥关系:一个组件中可以同时使用 useState(管理简单状态)和 useReducer(管理复杂状态),根据具体需求灵活选择即可。

Babel 配置完全指南:从基础安装到业务项目与 Library 开发的最佳实践

npm install --save-dev @babel/core @babel/cli @babel/preset-env
touch babel.config.json
touch .browserslistrc
// babel.config.json
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "entry",
        "corejs": "3.22"
      }
    ]
  ]
}
// .browserslistrc
> 0.25%
not dead

@babel/core

Babel 的核心功能

@babel/cli

命令行使用的工具

@babel/preset-env

  • 转换 JavaScript 最新的 Syntax
  • 转换 JavaScript 最新的 API
  • stage 3 以上

参数

useBuiltIns

如何处理 polyfills

usage

根据环境每个文件都会引入 polyfills

corejs

  • 建议指定次要版本
  • 如果想使用 proposals
    • useBuiltIns: usage
      • shippedProposals :true 浏览器中发布了一段时间的提案
      • { version: "3.8", proposals: true } core-js@3.8 支持的提案

@babel/plugin-transform-runtime

npm install --save-dev @babel/plugin-transform-runtime
npm install --save @babel/runtime // 需要根据 core-js 选项安装
  • 重用 babel helper
  • 解决 polyfill 全局污染
  • 不得设置 @babel/preset-env 中的 useBuiltIns 选项

如果需要提案,使用 {proposals: true}

corejs option Install command
false npm install --save @babel/runtime
2 npm install --save @babel/runtime-corejs2
3 npm install --save @babel/runtime-corejs3
  • 如果是业务项目开发者@babel/plugin-transform-runtime ,建议关闭 corejs,polyfill 的引入由 @babel/preset-env 完成,即开启 useBuiltIns(如需其他配置,自行根据诉求配置)。
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "usage",
        "corejs": {
          "version": 3,
          "proposals": true
        }
      }
    ]
  ],
  "plugins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "corejs": false
      }
    ]
  ]
}
  • 如果是 Library 开发者@babel/plugin-transform-runtime ,建议开启 corejs,polyfill 由 @babel/plugin-transform-runtime 引入。 @babel/preset-env 关闭 useBuiltIns
{
  "presets": [
    [
      "@babel/preset-env",
    ]
  ],
  "plugins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "corejs": {
          "version": 3,
          "proposals": true
        }
      }
    ]
  ]
}

bun install:安装过程的幕后揭秘

原文:nolanlawson.com/2025/08/31/…

翻译:安东尼

前端周刊进群:flowus.cn/48d73381-69…

运行 bun install 很快,是真的很快。平均而言,它比 npm 快约 7 倍,比 pnpm 快约 4 倍,比 yarn 快约 17 倍。在大型代码库里差异尤其明显——原本要花几分钟的事,现在只要(毫)秒。

这并不是挑选有利数据的“cherry pick”。Bun 之所以快,是因为它把“安装依赖”当成系统编程问题,而不是 JavaScript 问题。
这篇文章我们就来展开说说:从尽量减少系统调用、把清单缓存成二进制、优化 tar 包解压、利用操作系统原生的文件复制能力,到把工作扩展到多核 CPU。
但在理解“为什么这很重要”之前,我们先小小回到过去。

现在是 2009 年。你从一个 .zip 文件安装 jQuery,你的 iPhone 3GS 只有 256MB 内存。GitHub 诞生才一年,256GB 的 SSD 要价 700 美元。你的笔记本配置着 5400RPM 机械硬盘,极限 100MB/s,“宽带”意味着 10Mbps(还是运气不错时)。


更重要的是: **Node.js 刚刚发布!**Ryan Dahl 登台解释为什么服务器的大部分时间都耗在“等待”上。

在 2009 年,一次典型的磁盘寻道需要 10ms,一次数据库查询 50–200ms,向外部 API 发起一个 HTTP 请求要 300ms+。在这些事务期间,传统服务器会……一直等着。你的服务器开始读一个文件,然后就卡住了 10ms。

现在把这个过程乘以“成千上万的并发连接”,每个连接都在做多次 I/O。服务器大约 95% 的时间都在等 I/O。

Node.js 发现 JavaScript 的事件循环(最初为浏览器事件而设计)非常适合服务器 I/O。当代码发起一个异步请求时,I/O 会在后台进行,而主线程立刻继续做下一件事。完成后,对应的回调被排入队列执行。

下面是 Node.js 处理 fs.readFile 的事件循环 + 线程池的简化示意。为简洁起见省略了其他异步来源与实现细节。

JavaScript 的事件循环在那个“等待数据才是主要瓶颈”的世界里是一剂良方。
随后 15 年里,Node 的架构塑造了我们的工具构建方式。包管理器继承了 Node 的线程池、事件循环、异步范式——在磁盘寻道 10ms 的时代非常合理的优化。

但硬件变了。现在不是 2009 年了,而是 16 年后(难以置信吧)。我正在用的这台 M4 Max MacBook,在 2009 年能排进全球最快超级计算机的前 50。如今的 NVMe 能跑到 7000MB/s,比 Node 设计时的世界快 70×!慢吞吞的机械硬盘离场,网络可以流畅看 4K,连入门级手机的内存都比 2009 年的高端服务器更多。
然而今天的包管理器仍在优化“上个十年”的问题。在 2025 年,真正的瓶颈不再是 I/O,而是系统调用。

系统调用的问题(The Problem with System Calls)

每当你的程序需要操作系统做点事(读文件、开网络连接、分配内存),它就要发起一个系统调用。每次系统调用,CPU 都得做一次“模式切换”。

CPU 有两种运行模式:
用户态(user mode):你的应用代码在这里运行,不能直接访问硬件和物理地址。这种隔离防止程序互相干扰或把系统搞崩。
内核态(kernel mode):操作系统内核在这里运行,负责调度、内存、磁盘/网络等硬件。只有内核和驱动能在内核态运行。

当你在程序里想打开一个文件(比如 fs.readFile())时,CPU 在用户态下不能直接读磁盘。它得先切到内核态。
在这次切换中,CPU 停下执行你的程序 → 保存状态 → 切换到内核态 → 执行操作 → 再切回用户态。

但是,这个模式切换很贵! 光是切换本身就要消耗 1000–1500 个 CPU 周期的纯开销,真正的工作还没开始呢。

CPU 以 GHz 计时。3GHz 的处理器每秒完成 30 亿个周期;每个周期能执行加法、数据搬移、比较等指令。一个周期是 0.33ns。
在 3GHz 下,1000–1500 个周期约等于 500ns。听起来微不足道,但现代 SSD 每秒能处理上百万次操作。如果每次都要系统调用,你光模式切换就烧掉每秒 15 亿个周期!

安装依赖会触发成千上万次系统调用。装个 React 加依赖可能就有 5 万+ 次系统调用:光模式切换就烧掉数秒的 CPU 时间!不是在读文件、也不是在装包,就是在用户态和内核态之间来回切。

这就是为什么 Bun 把安装依赖当系统编程问题来做。它通过减少系统调用并充分利用操作系统的优化来获得速度。

当我们跟踪各包管器的实际系统调用时,差异一目了然:

Benchmark 1: strace -c -f npm install
    Time (mean ± σ):  37.245 s ±  2.134 s [User: 8.432 s, System: 4.821 s]
    Range (min … max):   34.891 s … 41.203 s    10 runs

    System calls: 996,978 total (108,775 errors)
    Top syscalls: futex (663,158),  write (109,412), epoll_pwait (54,496)

Benchmark 2: strace -c -f bun install
    Time (mean ± σ):      5.612 s ±  0.287 s [User: 2.134 s, System: 1.892 s]
    Range (min … max):    5.238 s …  6.102 s    10 runs

    System calls: 165,743 total (3,131 errors)
    Top syscalls: openat(45,348), futex (762), epoll_pwait2 (298)

Benchmark 3: strace -c -f yarn install
    Time (mean ± σ):     94.156 s ±  3.821 s    [User: 12.734 s, System: 7.234 s]
    Range (min … max):   89.432 s … 98.912 s    10 runs

    System calls: 4,046,507 total (420,131 errors)
    Top syscalls: futex (2,499,660), epoll_pwait (326,351), write (287,543)

Benchmark 4: strace -c -f pnpm install
    Time (mean ± σ):     24.521 s ±  1.287 s    [User: 5.821 s, System: 3.912 s]
    Range (min … max):   22.834 s … 26.743 s    10 runs

    System calls: 456,930 total (32,351 errors)
    Top syscalls: futex (116,577), openat(89,234), epoll_pwait (12,705)

Summary
    'strace -c -f bun install' ran
      4.37 ± 0.28 times faster than 'strace -c -f pnpm install'
      6.64 ± 0.51 times faster than 'strace -c -f npm install'
     16.78 ± 1.12 times faster than 'strace -c -f yarn install'

System Call Efficiency:
    - bun:  165,743 syscalls (29.5k syscalls/s)
    - pnpm: 456,930 syscalls (18.6k syscalls/s)
    - npm:  996,978 syscalls (26.8k syscalls/s)
    - yarn: 4,046,507 syscalls (43.0k syscalls/s)

可以看到,Bun 不仅装得更快,系统调用也更少。一次普通安装里,yarn 超 400 万次,npm 近 100 万,pnpm 接近 50 万,Bun 只有 16.5 万。
以每次 1000–1500 周期计,yarn 的 400 万次调用意味着它光模式切换就消耗了数十亿 CPU 周期——在 3GHz 处理器上就是几秒纯开销!

不仅如此,看看那些 futex 调用!Bun 只有 762 次(总调用的 0.46%),npm 有 663,158 次(66.51%),yarn 2,499,660 次(61.76%),pnpm 116,577 次(25.51%)。
futex(fast userspace mutex)是 Linux 的线程同步系统调用。线程大多在用户态用原子操作协调,不需要切内核态,很高效。但如果抢到的是“已被占用”的锁,就要 futex 进内核让线程睡眠,直到锁可用。大量 futex 通常意味着线程彼此等待严重,造成延迟。

Bun 到底做了什么不一样?

消灭 JavaScript 运行时开销(Eliminating JavaScript overhead)

npm、pnpm、yarn 都是用 Node.js 写的。在 Node 里,系统调用不是直达的:你在 JS 里调用 fs.readFile(),到真正触达 OS 之前要经过好几层。

Node 使用 C 库 libuv 抽象平台差异,并通过线程池管理异步 I/O。
结果就是,哪怕只读一个文件,也要走这套复杂流水线。以 fs.readFile('package.json', ...) 为例:

  1. JS 先校验参数并把 UTF-16 的字符串转成 libuv C API 需要的 UTF-8(在 I/O 开始前就会短暂阻塞主线程)。
  2. libuv 把请求排到 4 个工作线程的队列里;线程都忙的话就排队等。
  3. 工作线程取到任务,打开文件描述符,执行真正的 read() 系统调用。
  4. 内核切到内核态、从磁盘取数据、把数据返回给工作线程。
  5. 工作线程通过事件循环把数据推回主线程,最终调度并执行你的回调。

每一次 fs.readFile() 都要这么走。安装依赖要读成千上万份 package.json,还要扫目录、处理元数据……每次线程协调(比如访问任务队列或回传事件)都可能涉及 futex 上锁/等待。
当系统调用成千上万次时,这些“开销”本身就可能比真实的数据搬运更费时。

Bun 走的是另一条路。Bun 用 Zig 写成,编译为原生代码,可以直接发起系统调用

// 直接系统调用,没有 JS 开销
var file = bun.sys.File.from(try bun.sys.openatA(
    bun.FD.cwd(),
    abs,
    bun.O.RDONLY,
    0,
).unwrap());

当 Bun 读文件:

  1. Zig 代码直接调用(如 openat()
  2. 内核立刻执行系统调用并返回数据

就这样。没有 JS 引擎、没有线程池、没有事件循环、没有跨层编解码。原生代码直接对内核说话。

性能差异不言自明:

Runtime   Version     Files/Second      Performance
Bun       v1.2.20     146,057
Node.js   v24.5.0      66,576           2.2× slower
Node.js   v22.18.0     64,631           2.3× slower

这个基准里,Bun 每秒处理 146,057 个 package.json,Node v24.5.0 是 66,576,v22.18.0 是 64,631——超过 2×
Bun 的 0.019ms/文件 代表“直接系统调用下的真实 I/O 成本”,Node 同样操作要 0.065ms。用 Node 写的包管理器“被迫”接受 Node 的抽象:有没有必要都要走线程池,每一次文件操作都要付这笔税。
Bun 的包管理器更像一个“懂 JS 包格式的原生应用”,而不是一个“用 JS 做系统编程的应用”。
即便 Bun 不是用 Node 写的,你仍然可以在任何 Node 项目里用 bun install,无需更换运行时。Bun 会尊重你现有的 Node 生态和工具链,你只会得到更快的安装速度!

不过到这里,我们还没真正“装包”。接下来看看 Bun 在“安装阶段”做了哪些优化。

当你敲下 bun install,Bun 先解析你的意图:读取传入的 flags,找到 package.json 并解析依赖。

异步 DNS 解析(Async DNS Resolution)

⚠️ 仅在 macOS 上的优化

处理依赖就意味着要发网络请求,而网络请求要先 DNS 将 registry.npmjs.org 这样的域名解析为 IP。
当 Bun 解析 package.json 的同时,就预取 DNS 解析。也就是说,在依赖解析还没结束之前,网络解析已经在路上了。

用 Node 写包管理器时,一个办法是 dns.lookup()。从 JS 看像异步,但底层是阻塞式的 getaddrinfo(),只是放到了 libuv 线程池里,不占主线程而已。
Bun 在 macOS 上用的是苹果的“隐藏”异步 DNS API(getaddrinfo_async_start()),不属于 POSIX 标准,但能通过 mach port(苹果的进程间通信系统)把 DNS 做成“系统级的真正异步”。
DNS 在后台解析时,Bun 可以继续做文件 I/O、网络、依赖分析等工作。等它需要下载 React 时,DNS 很可能已经好了。
这是一个小优化(未单独跑基准),但能看出 Bun 的理念:每一层都要抠细节

二进制清单缓存(Binary Manifest Caching)

建立和 npm Registry 的连接后,接下来要拿包清单(manifest)。
清单是 JSON,包含每个包的所有版本、依赖、元数据。热门包(如 React)经常有 100+ 个版本,清单动辄数 MB

典型清单如下(节选):

{
  "name": "lodash",
  "versions": {
    "4.17.20": {
      "name": "lodash",
      "version": "4.17.20",
      "description": "Lodash modular utilities.",
      "license": "MIT",
      "repository": { "type": "git", "url": "git+https://github.com/lodash/lodash.git" },
      "homepage": "https://lodash.com/"
    },
    "4.17.21": {
      "name": "lodash",
      "version": "4.17.21",
      "description": "Lodash modular utilities.",
      "license": "MIT",
      "repository": { "type": "git", "url": "git+https://github.com/lodash/lodash.git" },
      "homepage": "https://lodash.com/"
    }
    // ... 100+ 几乎相同的版本
  }
}

大多数包管器把清单作为 JSON 缓存。下次 npm install 会从缓存读,但每次仍要解析 JSON:语法校验、建对象树、GC……这都是开销。
还不止解析:看 lodash,“Lodash modular utilities.” 在每个版本里都重复;“MIT” 重复 100+ 次;仓库 URL、主页 URL 也重复……字符串海量重复
在内存里,JS 为每个字符串创建独立对象,既浪费内存,又让比较变慢。比如检查两个包是否用相同 postcss 版本时,你在比较两个不同的字符串对象,而不是复用同一份驻留字符串。

Bun 把清单存成二进制格式。****下载到包信息后,它只解析一次 JSON,然后存为二进制文件(~/.bun/install/cache/*.npm)。这些二进制文件把包的版本、依赖、校验和等数据都放在****固定的字节偏移上。
当 Bun 访问 lodashname 时,就是指针算术:string_buffer + offset没有分配、没有解析、没有对象遍历,只是按位读。

伪代码示意:

// 所有字符串只存一份
string_buffer = "lodash\0MIT\0Lodash modular utilities.\0git+https://github.com/lodash/lodash.git\0https://lodash.com/\04.17.20\04.17.21\0..."
                 ^0     ^7   ^11                        ^37                                      ^79                   ^99      ^107

// 固定大小的版本条目
versions = [
  { name_offset: 0, name_len: 6, version_offset: 99,  version_len: 7, desc_offset: 11, desc_len: 26, license_offset: 7,  license_len: 3, ... },  // 4.17.20
  { name_offset: 0, name_len: 6, version_offset: 107, version_len: 7, desc_offset: 11, desc_len: 26, license_offset: 7,  license_len: 3, ... },  // 4.17.21
  // ... 100+ 个版本
]

为了检查是否有更新,Bun 还存了 ETag,并用 If-None-Match 请求头;npm 返回 304 就知道缓存仍新鲜,连一个字节都不用解析

基准如下:

Benchmark 1: bun install # fresh
  Time (mean ± σ):     230.2 ms ± 685.5 ms    [User: 145.1 ms, System: 161.9 ms]
  Range (min … max):     9.0 ms … 2181.0 ms    10 runs

Benchmark 2: bun install # cached
  Time (mean ± σ):       9.1 ms ±   0.3 ms    [User: 8.5 ms, System: 5.9 ms]
  Range (min … max):     8.7 ms …  11.5 ms    10 runs

Benchmark 3: npm install # fresh
  Time (mean ± σ):      1.786 s ±  4.407 s    [User: 0.975 s, System: 0.484 s]
  Range (min … max):    0.348 s … 14.328 s    10 runs

Benchmark 4: npm install # cached
  Time (mean ± σ):     363.1 ms ±  21.6 ms    [User: 276.3 ms, System: 63.0 ms]
  Range (min … max):   344.7 ms … 412.0 ms    10 runs

Summary
  bun install # cached ran
    25.30 ± 75.33 times faster than bun install # fresh
    39.90 ± 2.37 times faster than npm install # cached
    196.26 ± 484.29 times faster than npm install # fresh

你会发现,npm 的“缓存安装”居然比 Bun 的“新鲜安装”还慢。这就是解析缓存 JSON(及其他因素)带来的额外负担。

优化 tar 包解压(Optimized Tarball Extraction)

拿到清单后,就要去 Registry 下载并解压 tarball(压缩归档,包含源码与文件)。

大多数包管器以流式方式边收边解压。面对“未知大小”的流,典型写法是这样的:

let buffer = Buffer.alloc(64 * 1024); // 64KB 起步
let offset = 0;

function onData(chunk) {
  while (moreDataToCome) {
    if (offset + chunk.length > buffer.length) {
      // 不够就扩容
      const newBuffer = Buffer.alloc(buffer.length * 2);
      buffer.copy(newBuffer, 0, 0, offset); // 复制旧数据
      buffer = newBuffer;
    }
    chunk.copy(buffer, offset);
    offset += chunk.length;
  }
  // ... 从 buffer 解压 ...
}

看似合理,但每次扩容都要复制一遍已有数据,对性能是个坑。
比如 1MB 包,从 64KB 开始:64→128→256→512→1MB,每步都要拷贝,最终你多拷了 960KB——每个包都这样

Bun 选择先把整个 tarball 缓存到内存,再解压。
你可能会想:“内存不浪费吗?”——对于像 TypeScript 这种 50MB 压缩包,大概是;但绝大多数 npm 包都很小(<1MB)。在常见场景里,这样能彻底避免重复拷贝。对于大包,现代机器上这点瞬时内存峰值也可接受,且少 5–6 次拷贝往往值得。

把 tarball 全部读入内存后,Bun 会去读 gzip 格式的最后 4 字节——里面存的正是解压后的准确大小。它就可以预分配刚刚好的内存,彻底避免“边长边拷”的增量扩容:

// gzip 最后 4 字节就是未压缩大小
if (tgz_bytes.len > 16) {
  const last_4_bytes: u32 = @bitCast(tgz_bytes[tgz_bytes.len - 4 ..][0..4].*);
  if (last_4_bytes > 16 and last_4_bytes < 64 * 1024 * 1024) {
    esimated_output_size = last_4_bytes;
    if (zlib_pool.data.list.capacity == 0) {
      zlib_pool.data.list.ensureTotalCapacityPrecise(zlib_pool.data.allocator, last_4_bytes) catch {};
    } else {
      zlib_pool.data.ensureUnusedCapacity(last_4_bytes) catch {};
    }
  }
}

解压本身,Bun 用 libdeflate,比大多数包管器用的 zlib 更快,针对现代 CPU 的 SIMD 做了优化。


在 Node 里要实现这种优化很麻烦:你得为流另起读流 → seek 到末尾 → 读 4 字节 → 解析 → 关流 → 再重新开始解压。Node 的 API 天性就不适合这种模式。
Zig 就很直接:seek 到尾部,读 4 个字节,搞定。

接下来还有一个挑战:高效存放并访问成千上万个(且相互依赖的)包。

友好的缓存布局(Cache-Friendly Data Layout)

安装过程中,包管器要遍历依赖图:检查版本、解决冲突、决定装谁,还要做 hoist(把依赖“抬”到上层,让多个包共享)。
依赖图如何布局会显著影响性能。传统包管器大致像这样存:

const packages = {
  next: {
    name: "next",
    version: "15.5.0",
    dependencies: {
      "@swc/helpers": "0.5.15",
      "postcss": "8.4.31",
      "styled-jsx": "5.1.6",
    },
  },
  postcss: {
    name: "postcss",
    version: "8.4.31",
    dependencies: {
      nanoid: "^3.3.6",
      picocolors: "^1.0.0",
    },
  },
};

这对写 JS 很友好,但对现代 CPU 来说并不好。
JS 对象分配在堆上,packages["next"] 存的是个指针,指到 Next 的数据,再从里面指到它的 dependencies,再指到哈希表里的字符串,层层指针“跳转”。


更糟的是,对象的分配地址几乎随机(什么时候创建就占用哪块空闲内存):

packages["react"]  = {/*...*/}  // 0x1000
packages["next"]   = {/*...*/}  // 0x2000
packages["postcss"]= {/*...*/}  // 0x8000
// ... 还会有几百个

CPU 为了弥补“计算比取数快太多”的差距,有多级缓存(L1/L2/L3),而且以 64 字节 cache line 为单位加载。你的数据如果不连续,一次载入的 64 字节多数都浪费了。访问 0x2000 后下一次要 0x8000,就是另一个 cache line;L1 很快被无关数据挤爆,命中率雪崩,每次访问都从 RAM 拉,一次 300 个周期


而且“指针追逐(pointer chasing)”会让 CPU 无法预取:它必须等上一跳加载完成,才能知道下一跳去哪。

Bun 则采用 SoA(Structure of Arrays,数组的结构)。不是每个包都维护自己的依赖数组,而是把“所有包的某个字段”集中到一块连续内存里:

// ❌ 传统 AoS:指针横飞
packages = {
  next: { dependencies: { "@swc/helpers": "0.5.15", "postcss": "8.4.31" } },
};

// ✅ Bun 的 SoA:缓存友好
packages = [
  {
    name: { off: 0, len: 4 },
    version: { off: 5, len: 6 },
    deps: { off: 0, len: 2 },
  }, // next
];

dependencies = [
  { name: { off: 12, len: 13 }, version: { off: 26, len: 7 } }, // @swc/helpers@0.5.15
  { name: { off: 34, len: 7 },  version: { off: 42, len: 6 } }, // postcss@8.4.31
];

string_buffer = "next\015.5.0\0@swc/helpers\00.5.15\0postcss\08.4.31\0";

核心思想是:
packages 里只存轻量的“偏移 + 长度”结构体
dependencies 集中存所有依赖关系
string_buffer 把所有文本顺序拼接在一个大字符串里
versions 把语义化版本压成紧凑结构

访问 Next 的依赖就是简单的“下标 + 偏移”算术。更妙的是,访问 packages[0] 时,CPU 会把一个 64B 的 cache line一口气把 packages[0..7] 都带进来,于是你顺序访问时的局部性极好。
最终效果是:无论你有多少包,只需要 ~6 次大块分配,而不是成百上千个小对象的随机分配,缓存命中率直线上升。

优化的锁文件格式(Optimized Lockfile Format)

Bun 把 SoA 的思路也用在 bun.lock 上。
bun install 需要解析锁文件看看哪些已装、哪些要更新。大多数包管器把锁写成嵌套 JSON(npm)或 YAML(pnpm/yarn)。例如 npm 的 package-lock.json

{
  "dependencies": {
    "next": {
      "version": "15.5.0",
      "requires": {
        "@swc/helpers": "0.5.15",
        "postcss": "8.4.31"
      }
    },
    "postcss": {
      "version": "8.4.31",
      "requires": {
        "nanoid": "^3.3.6",
        "picocolors": "^1.0.0"
      }
    }
  }
}

每个包都是独立对象 + 嵌套对象树。解析 JSON 要分配大量对象,再次引入“指针追逐”。
Bun 的 bun.lock 用 SoA 思路写成人类可读的格式:

{
  "lockfileVersion": 0,
  "packages": {
    "next": [
      "next@npm:15.5.0",
      { "@swc/helpers": "0.5.15", "postcss": "8.4.31" },
      "hash123"
    ],
    "postcss": [
      "postcss@npm:8.4.31",
      { "nanoid": "^3.3.6", "picocolors": "^1.0.0" },
      "hash456"
    ]
  }
}

这样既能去重字符串,又能按依赖顺序存放,方便顺序读取,避免在对象树之间乱跳。Bun 还会根据锁文件大小预分配内存,就像解压时那样,避免一遍遍“扩容 + 拷贝”。
顺便提一句:Bun 早期用过二进制锁文件(bun.lockb)来彻底避免 JSON 解析,但二进制难以在 PR 里审阅,也不利于冲突合并。

文件复制(File copying)

把包装进缓存 ~/.bun/install/cache/ 后,接下来要把文件放进 node_modules。这一步对 Bun 的总体性能影响最大

传统复制会遍历目录,一个个文件地复制:

  1. 打开源文件(open()
  2. 创建/打开目标文件(open()
  3. 循环 read()/write() 直到写完
  4. 关闭两个文件(close()

每一个步骤都是系统调用,要模式切换。对一个普通 React 应用的大量文件来说,就是几十万到上百万个系统调用——这正是本文最开始说的“系统编程问题”。

Bun 会根据操作系统/文件系统使用不同策略,尽可能利用本地最强的路径:

在 macOS 上

Bun 使用苹果的 clonefile()写时复制)系统调用。
clonefile 能在一个系统调用里克隆整个目录树:它不会写入新数据,而是让新文件的元数据指向同一组物理磁盘块。

// 传统:数以百万计的系统调用
for (each file) {
  copy_file_traditionally(src, dst);  // 每个文件 50+ 次系统调用
}

// Bun:一次系统调用
clonefile("/cache/react", "/node_modules/react", 0);

写时复制(CoW)意味着只有在修改时才会真正复制数据。安装后 node_modules 基本只读,很少被改动,所以这是接近 O(1) 的操作。

基准:

bun install --backend=copyfile
  2.955 s ± 0.101 s
bun install --backend=clonefile
  1.274 s ± 0.052 s

=> clonefile 约快 2.32×

若文件系统不支持,Bun 退化到逐目录的 clonefile_each_dir;再不行才用传统 copyfile

在 Linux 上

Linux 没有 clonefile(),但有更古老也更强的硬链接(hardlink)。Bun 的回退链如下(从最优到最差):

  1. 硬链接link("/cache/react/index.js", "/node_modules/react/index.js")。创建的是“另一个名字”,指向同一个 inode(同一份数据)。无需数据搬运,一个系统调用,微秒级完成,且节省磁盘空间。限制是不能跨文件系统、某些 FS/权限不支持等。
  2. ioctl_ficlone:在 Btrfs/XFS 开启写时复制,类似 macOS 的效果,但生成的是独立文件,数据块共享,修改时才分裂。
  3. copy_file_range:若没有 CoW,至少让复制留在内核态。传统复制要先读到内核缓冲区 → 拷到用户态 → 再写回内核缓冲区 → 落盘;而 copy_file_range 直接在内核里源→目的,少两次拷贝与上下文切换。
  4. sendfile:更老、更广泛支持的 API,原为网络设计,但也可用于磁盘到磁盘复制,同样不进用户态。
  5. copyfile:最终兜底,传统读写循环,系统调用最多,效率最低,但兼容性最好。

基准:

copyfile   : 325.0 ms ± 7.7 ms
hardlink   : 109.4 ms ± 5.1 ms

=> hardlink 约快 2.97×

这些优化直击主要瓶颈:系统调用开销。Bun 不用“一刀切”,而是因地制宜选择最优的复制后端。

多核并行(Multi-Core Parallelism)

上面所有优化主要是在单核视角下减少工作量。但现代笔电有 8、16、甚至 24 核!
Node 虽然有线程池,但真正的决策工作(解析依赖图、版本约束求解、决定装谁)仍在一个线程上完成。npm 跑在你的 M3 Max 上时,常见的情况是一个核心忙得要死,其他 15 个闲着。

Bun 走的是**无锁 + “工作窃取”**线程池架构。
工作窃取(work-stealing):空闲线程会去“偷”繁忙线程队列里的任务。线程先看自己本地队列,再看全局队列,最后去其他线程偷,尽量不让任何线程闲着。

传统多线程经常被锁拖慢(上文 npm 大量 futex 就是线程频繁等待)。每次改共享队列都要加锁,其他线程就阻塞:

// 传统:有锁
mutex.lock();
queue.push(task);
mutex.unlock();
// 其他线程在等锁

Bun 使用无锁数据结构:依赖 CPU 的原子操作保证安全修改共享数据,无需锁:

pub fn push(self: *Queue, batch: Batch) void {
  // 原子 CAS,瞬时完成
  _ = @cmpxchgStrong(usize, &self.state, state, new_state, .seq_cst, .seq_cst);
}

还记得前面那个“每秒处理 146,057 份 package.json vs Node 的 66,576”吗?这就是把所有核心都拉上的效果。
Bun 的网络也不同:传统包管器常会“边等边下”,CPU 在等网络时空转。Bun 维护 64 路并发 HTTP 连接BUN_CONFIG_MAX_HTTP_REQUESTS 可调),由独立网络线程跑事件循环,负责下载;CPU 线程负责解压与处理,互不等待。
此外,Bun 给每个线程分配独立的内存池,避免所有线程争用同一个分配器造成的内存分配竞争

结语(Conclusion)

这些被我们拿来做基准的包管器并没有“写错”,它们都是在当时的约束下做出的优秀解法:
npm 奠定了地基;yarn 让 workspace 更顺手;pnpm 用硬链接巧妙地省空间、提速度。它们都认真解决了当时开发者真正在遇到的问题。
但那个世界已经变了:SSD 快了 70×,CPU 有几十核,内存很便宜。真正的瓶颈从硬件速度,挪到了软件抽象。

Bun 的方法不是什么“革命”,而是直面 2025 年的真实瓶颈
当 SSD 每秒能做百万次操作,为什么还要接受线程池的额外成本?
当你读了第一百次的包清单,为什么还要再解析一次 JSON?
当文件系统支持写时复制,为什么还要真的复制上 GB 的数据?

决定下一个十年开发者生产力的工具,现在正在被那些理解“存储变快、内存变廉价后瓶颈如何迁移”的团队重写。他们不是在“微调旧物”,而是在重想可能性
把安装速度做到 25× 并不是“魔法”——这只是针对当下硬件认真做工程后的自然结果。


Vue项目i18n国际化多语言切换方案实践

1. 前言

Vue项目国际化能让应用适应不同地区用户需求。本文将围绕Vue项目国际化,借助官方提供的Vue I18n插件,介绍,讲解安装配置、多语言切换、复杂场景应用及优化等等。

2. i18n插件概述

Vue I18n是Vue官方推荐用于实现国际化的插件,它通过加载不同语言的翻译文件,结合Vue的响应式特性,实现页面内容的动态切换。Vue I18n支持多种语言格式,如JSON、YAML等,同时具备以下优势:

  • 灵活配置:开发者可以根据项目需求,自由定义语言包结构和加载方式。
  • 与Vue深度集成:无缝融入Vue的生态体系,支持组件内使用指令、过滤器等方式展示翻译内容。
  • 动态切换:能够在运行时动态切换语言,满足用户随时切换语言环境的需求。

3. 安装与基础配置

3.1. 安装Vue I18n

在Vue项目中,可以使用npm或yarn安装Vue I18n插件:

# 使用npm安装
npm install vue-i18n@next

# 使用yarn安装
yarn add vue-i18n@next

3.2. 初始化I18n实例

main.jsmain.ts文件中,引入并配置Vue I18n。首先创建语言包,假设项目需要支持中文(简体)和英文,在src/locales目录下创建zh-CN.jsonen-US.json文件: zh-CN.json

{
  "message": {
    "hello": "你好",
    "welcome": "欢迎使用本应用"
  }
}

en-US.json

{
  "message": {
    "hello": "Hello",
    "welcome": "Welcome to use this application"
  }
}

然后在main.js中初始化I18n实例:

import { createApp } from 'vue';
import { createI18n } from 'vue-i18n';
import App from './App.vue';

// 引入语言包
import zhCN from './locales/zh-CN.json';
import enUS from './locales/en-US.json';

const i18n = createI18n({
  legacy: false, // 使用 Composition API 模式
  locale: 'zh-CN', // 设置默认语言
  fallbackLocale: 'en-US', // 设置备用语言
  messages: {
    'zh-CN': zhCN,
    'en-US': enUS
  }
});

const app = createApp(App);
app.use(i18n);
app.mount('#app');

3.3. 在组件中使用翻译内容

在Vue组件中,可以通过$t方法来获取翻译后的文本。例如:

<template>
  <div>
    <h1>{{ $t('message.hello') }}</h1>
    <p>{{ $t('message.welcome') }}</p>
  </div>
</template>

上述代码中,$t方法会根据当前设置的语言,从对应的语言包中获取相应的文本内容。

4. 动态切换语言

为了让用户能够在应用中自由切换语言,可以在组件中添加语言切换按钮,并编写切换逻辑。

<template>
  <div>
    <button @click="switchLanguage('zh-CN')">中文</button>
    <button @click="switchLanguage('en-US')">英文</button>
    <h1>{{ $t('message.hello') }}</h1>
    <p>{{ $t('message.welcome') }}</p>
  </div>
</template>

<script>
import { useI18n } from 'vue-i18n';

export default {
  setup() {
    const { locale } = useI18n();

    const switchLanguage = (lang) => {
      locale.value = lang;
    };

    return {
      switchLanguage
    };
  }
};
</script>

在上述代码中,通过useI18n函数获取locale响应式变量,修改其值即可实现语言的动态切换,页面内容也会随之更新。

5. 复杂场景应用

5.1. 动态文本插值

在实际应用中,经常会遇到需要在翻译文本中插入动态数据的情况。Vue I18n支持使用占位符来实现动态文本插值。例如: zh-CN.json

{
  "message": {
    "greet": "你好,{name}!"
  }
}

en-US.json

{
  "message": {
    "greet": "Hello, {name}!"
  }
}

在组件中使用:

<template>
  <div>
    <p>{{ $t('message.greet', { name: 'Alice' }) }}</p>
  </div>
</template>

上述代码会根据当前语言,将{name}替换为实际传入的值,生成相应的问候语。

5.2. 复数处理

不同语言对复数的表达规则各不相同,Vue I18n提供了复数处理功能。以英文和中文为例: zh-CN.json

{
  "message": {
    "apple_count": "{count, plural, =0 {没有苹果} =1 {有1个苹果} other {有{count}个苹果}}"
  }
}

en-US.json

{
  "message": {
    "apple_count": "{count, plural, =0 {There are no apples} =1 {There is 1 apple} other {There are {count} apples}}"
  }
}

在组件中使用:

<template>
  <div>
    <p v-for="count in [0, 1, 5]" :key="count">{{ $t('message.apple_count', { count }) }}</p>
  </div>
</template>

通过这种方式,Vue I18n会根据count的值和对应语言的复数规则,生成正确的文本。

6. 优化与扩展

6.1. 代码分割与按需加载

当项目支持的语言较多时,全部语言包一次性加载会影响应用的初始加载速度。可以使用Webpack或Vite的代码分割功能,实现语言包的按需加载。例如,在Vite项目中,可以修改vite.config.js配置:

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vueI18n from '@intlify/unplugin-vue-i18n/vite';

export default defineConfig({
  plugins: [
    vue(),
    vueI18n({
      include: './src/locales/**',
      runtimeOnly: false
    })
  ]
});

上述配置会将语言包进行单独打包,在需要时动态加载,提升应用性能。

6.2 结合浏览器语言自动设置

为了提供更好的用户体验,可以根据用户浏览器的默认语言,自动设置应用的语言。在main.js中添加以下代码:

import { createApp } from 'vue';
import { createI18n } from 'vue-i18n';
import App from './App.vue';

// 引入语言包
import zhCN from './locales/zh-CN.json';
import enUS from './locales/en-US.json';

const browserLang = navigator.language.slice(0, 2);
const i18n = createI18n({
  legacy: false,
  locale: ['zh', 'zh-CN'].includes(browserLang)? 'zh-CN' : 'en-US',
  fallbackLocale: 'en-US',
  messages: {
    'zh-CN': zhCN,
    'en-US': enUS
  }
});

const app = createApp(App);
app.use(i18n);
app.mount('#app');

通过上述代码,应用会优先使用用户浏览器的语言,若不支持则使用默认语言。


本次分享就到这儿啦,我是鹏多多,如果看了觉得有帮助的,欢迎 点赞 关注 评论,在此谢过道友;

往期文章

LangChain.js 完全开发手册(八)Agent 智能代理系统开发

第8章:Agent 智能代理系统开发

前言

大家好,我是鲫小鱼。是一名不写前端代码的前端工程师,热衷于分享非前端的知识,带领切图仔逃离切图圈子,欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!!

🎯 本章学习目标

  • 掌握 Agent 核心概念:工具调用、计划分解、多轮推理、状态管理
  • 构建具备检索、计算、API 调用能力的多功能 Agent
  • 用 LangGraph 实现 Multi-Agent 协作、冲突解决与任务编排
  • 集成代码执行环境(沙箱),支持数据分析与脚本生成
  • 在 Next.js 中构建 Agent 对话界面,支持工具选择与执行过程可视化
  • 实战项目:智能客服 Agent(多轮对话 + 知识检索 + 工单创建)
  • 工程化:Agent 安全、性能优化、可观测性与错误恢复

🤖 Agent 基础概念与架构

8.1 什么是 Agent

Agent 是具备以下能力的 AI 系统:

  • 工具调用:能使用外部工具(搜索、计算、API)
  • 计划分解:将复杂任务拆解为可执行的子步骤
  • 多轮推理:基于中间结果调整策略,而非一次性生成
  • 状态管理:维护对话历史、工具调用记录、任务进度

8.2 Agent 架构模式

用户输入 → 思考(Reasoning)→ 工具选择(Tool Selection)→ 工具执行(Tool Execution)
  → 结果分析(Result Analysis)→ 下一步决策(Next Action)→ 输出或继续循环

8.3 Agent 类型

  • ReAct Agent:思考-行动-观察循环,适合复杂推理
  • Tool-using Agent:直接工具调用,适合结构化任务
  • Multi-Agent:多个 Agent 协作,适合复杂场景
  • Code Agent:代码生成与执行,适合编程任务

🛠️ 工具系统:定义、注册与调用

8.4 工具定义与类型

// 文件:src/ch08/tools.ts
import { Tool } from "@langchain/core/tools";
import { z } from "zod";

// 基础工具接口
export interface BaseTool {
  name: string;
  description: string;
  schema: z.ZodSchema;
  func: (...args: any[]) => Promise<any>;
}

// 搜索工具
export class SearchTool extends Tool {
  name = "search";
  description = "搜索网络信息,输入搜索关键词";
  schema = z.object({ query: z.string().describe("搜索关键词") });

  async _call(input: { query: string }) {
    // 模拟搜索,实际可接入 Google/Bing API
    return `搜索结果:${input.query} 的相关信息...`;
  }
}

// 计算工具
export class CalculatorTool extends Tool {
  name = "calculator";
  description = "执行数学计算,支持加减乘除和基本函数";
  schema = z.object({ expression: z.string().describe("数学表达式,如 2+3*4") });

  async _call(input: { expression: string }) {
    try {
      // 安全计算,避免 eval
      const result = this.safeEval(input.expression);
      return `计算结果:${result}`;
    } catch (error) {
      return `计算错误:${error}`;
    }
  }

  private safeEval(expr: string): number {
    // 简化的安全计算实现
    const allowed = /^[0-9+\-*/().\s]+$/;
    if (!allowed.test(expr)) throw new Error("包含非法字符");
    return Function(`"use strict"; return (${expr})`)();
  }
}

// 时间工具
export class TimeTool extends Tool {
  name = "get_time";
  description = "获取当前时间信息";
  schema = z.object({});

  async _call() {
    const now = new Date();
    return `当前时间:${now.toLocaleString('zh-CN')}`;
  }
}

8.5 工具注册与管理

// 文件:src/ch08/tool-manager.ts
import { SearchTool, CalculatorTool, TimeTool } from "./tools";

export class ToolManager {
  private tools = new Map<string, any>();

  constructor() {
    this.register(new SearchTool());
    this.register(new CalculatorTool());
    this.register(new TimeTool());
  }

  register(tool: any) {
    this.tools.set(tool.name, tool);
  }

  getTool(name: string) {
    return this.tools.get(name);
  }

  getAllTools() {
    return Array.from(this.tools.values());
  }

  async execute(name: string, args: any) {
    const tool = this.getTool(name);
    if (!tool) throw new Error(`工具 ${name} 不存在`);
    return await tool._call(args);
  }
}

🧠 ReAct Agent:思考-行动-观察循环

8.6 ReAct 模式实现

// 文件:src/ch08/react-agent.ts
import { ChatOpenAI } from "@langchain/openai";
import { PromptTemplate } from "@langchain/core/prompts";
import { ToolManager } from "./tool-manager";

export type AgentState = {
  question: string;
  thought: string;
  action: string;
  actionInput: any;
  observation: string;
  answer: string;
  step: number;
};

export class ReActAgent {
  private llm: ChatOpenAI;
  private tools: ToolManager;
  private maxSteps = 5;

  constructor() {
    this.llm = new ChatOpenAI({ temperature: 0 });
    this.tools = new ToolManager();
  }

  private getPrompt() {
    return PromptTemplate.fromTemplate(`
你是一个智能助手,可以调用工具来完成任务。

可用工具:
{tools}

思考过程:
1. 分析问题,确定需要使用的工具
2. 调用工具获取信息
3. 基于结果继续思考或给出最终答案

格式:
思考:<你的推理过程>
行动:<工具名称>
行动输入:<工具参数>
观察:<工具返回结果>
... (可以重复思考-行动-观察)
思考:<最终推理>
最终答案:<给用户的答案>

问题:{question}
思考:`);
  }

  async run(question: string): Promise<AgentState[]> {
    const steps: AgentState[] = [];
    let currentThought = "";
    let step = 0;

    while (step < this.maxSteps) {
      const prompt = this.getPrompt();
      const toolsDesc = this.tools.getAllTools()
        .map(t => `${t.name}: ${t.description}`)
        .join("\n");

      const response = await prompt.pipe(this.llm).invoke({
        question,
        tools: toolsDesc,
        thought: currentThought
      });

      const content = response.content as string;

      // 解析响应(简化版,实际需要更复杂的解析)
      const thoughtMatch = content.match(/思考:(.*?)(?=\n|$)/);
      const actionMatch = content.match(/行动:(.*?)(?=\n|$)/);
      const inputMatch = content.match(/行动输入:(.*?)(?=\n|$)/);
      const answerMatch = content.match(/最终答案:(.*?)(?=\n|$)/);

      if (answerMatch) {
        steps.push({
          question,
          thought: thoughtMatch?.[1] || "",
          action: "",
          actionInput: {},
          observation: "",
          answer: answerMatch[1],
          step
        });
        break;
      }

      if (actionMatch && inputMatch) {
        const action = actionMatch[1].trim();
        const actionInput = JSON.parse(inputMatch[1]);

        // 执行工具
        const observation = await this.tools.execute(action, actionInput);

        steps.push({
          question,
          thought: thoughtMatch?.[1] || "",
          action,
          actionInput,
          observation,
          answer: "",
          step
        });

        currentThought += `\n思考:${thoughtMatch?.[1]}\n行动:${action}\n观察:${observation}\n`;
        step++;
      } else {
        break;
      }
    }

    return steps;
  }
}

8.7 改进的响应解析

// 文件:src/ch08/parser.ts
export function parseReActResponse(content: string) {
  const lines = content.split('\n');
  const result: any = {};

  for (const line of lines) {
    if (line.startsWith('思考:')) {
      result.thought = line.slice(3).trim();
    } else if (line.startsWith('行动:')) {
      result.action = line.slice(3).trim();
    } else if (line.startsWith('行动输入:')) {
      try {
        result.actionInput = JSON.parse(line.slice(5).trim());
      } catch {
        result.actionInput = line.slice(5).trim();
      }
    } else if (line.startsWith('观察:')) {
      result.observation = line.slice(3).trim();
    } else if (line.startsWith('最终答案:')) {
      result.answer = line.slice(5).trim();
    }
  }

  return result;
}

🔧 代码执行 Agent:沙箱环境

8.8 代码执行工具

// 文件:src/ch08/code-tools.ts
import { Tool } from "@langchain/core/tools";
import { z } from "zod";

export class CodeExecutionTool extends Tool {
  name = "execute_code";
  description = "执行 Python 代码,支持数据分析、图表生成等";
  schema = z.object({
    code: z.string().describe("要执行的 Python 代码"),
    language: z.enum(["python"]).default("python").describe("编程语言")
  });

  private sandbox = new Map<string, any>();

  async _call(input: { code: string; language: string }) {
    if (input.language !== "python") {
      return "目前只支持 Python 代码执行";
    }

    try {
      // 简化的 Python 代码执行(实际应使用安全的沙箱)
      const result = await this.executePython(input.code);
      return `执行结果:\n${result}`;
    } catch (error) {
      return `执行错误:${error}`;
    }
  }

  private async executePython(code: string): Promise<string> {
    // 安全检查
    const dangerous = ['import os', 'import sys', 'eval(', 'exec(', '__import__'];
    for (const d of dangerous) {
      if (code.includes(d)) {
        throw new Error(`禁止使用:${d}`);
      }
    }

    // 模拟执行(实际应使用安全的 Python 运行时)
    if (code.includes('print(')) {
      return "模拟输出:代码执行成功";
    } else if (code.includes('import pandas')) {
      return "pandas 已导入,可以进行数据分析";
    } else if (code.includes('import matplotlib')) {
      return "matplotlib 已导入,可以生成图表";
    }

    return "代码执行完成";
  }
}

// 数据分析工具
export class DataAnalysisTool extends Tool {
  name = "analyze_data";
  description = "分析数据,生成统计信息和可视化";
  schema = z.object({
    data: z.string().describe("CSV 格式的数据或数据描述"),
    analysis_type: z.enum(["summary", "visualization", "correlation"]).describe("分析类型")
  });

  async _call(input: { data: string; analysis_type: string }) {
    const code = this.generateAnalysisCode(input.data, input.analysis_type);
    const execTool = new CodeExecutionTool();
    return await execTool._call({ code, language: "python" });
  }

  private generateAnalysisCode(data: string, type: string): string {
    switch (type) {
      case "summary":
        return `
import pandas as pd
import numpy as np
# 假设数据已加载
print("数据概览:")
print(f"行数:{len(data)}")
print(f"列数:{len(data.columns)}")
print("\\n统计摘要:")
print(data.describe())
`;
      case "visualization":
        return `
import matplotlib.pyplot as plt
import seaborn as sns
# 生成示例图表
plt.figure(figsize=(10, 6))
plt.title("数据可视化")
plt.show()
`;
      default:
        return "print('分析完成')";
    }
  }
}

🤝 Multi-Agent 协作系统

8.9 Agent 角色定义

// 文件:src/ch08/multi-agent.ts
import { ChatOpenAI } from "@langchain/openai";
import { PromptTemplate } from "@langchain/core/prompts";

export interface AgentRole {
  name: string;
  description: string;
  expertise: string[];
  tools: string[];
}

export const AGENT_ROLES: AgentRole[] = [
  {
    name: "研究员",
    description: "负责信息收集和事实核查",
    expertise: ["搜索", "验证", "总结"],
    tools: ["search", "get_time"]
  },
  {
    name: "分析师",
    description: "负责数据分析和洞察",
    expertise: ["计算", "统计", "可视化"],
    tools: ["calculator", "analyze_data"]
  },
  {
    name: "协调员",
    description: "负责任务分配和结果整合",
    expertise: ["规划", "协调", "总结"],
    tools: ["get_time"]
  }
];

export class MultiAgentSystem {
  private agents: Map<string, any> = new Map();
  private llm: ChatOpenAI;

  constructor() {
    this.llm = new ChatOpenAI({ temperature: 0.7 });
    this.initializeAgents();
  }

  private initializeAgents() {
    for (const role of AGENT_ROLES) {
      this.agents.set(role.name, {
        role,
        llm: new ChatOpenAI({ temperature: 0.5 }),
        prompt: this.createRolePrompt(role)
      });
    }
  }

  private createRolePrompt(role: AgentRole) {
    return PromptTemplate.fromTemplate(`
你是 ${role.name}${role.description}

你的专长:${role.expertise.join(", ")}
可用工具:${role.tools.join(", ")}

任务:{task}
当前状态:{state}
其他 Agent 的意见:{other_opinions}

请基于你的专长提供建议或执行任务:
`);
  }

  async collaborate(task: string): Promise<any> {
    const results: any = {};
    const opinions: string[] = [];

    // 第一轮:各 Agent 独立分析
    for (const [name, agent] of this.agents) {
      const response = await agent.prompt.pipe(agent.llm).invoke({
        task,
        state: "初始分析阶段",
        other_opinions: "暂无"
      });

      results[name] = response.content;
      opinions.push(`${name}: ${response.content}`);
    }

    // 第二轮:基于其他 Agent 意见调整
    for (const [name, agent] of this.agents) {
      const otherOpinions = opinions.filter(o => !o.startsWith(name));
      const response = await agent.prompt.pipe(agent.llm).invoke({
        task,
        state: "协作调整阶段",
        other_opinions: otherOpinions.join("\n")
      });

      results[`${name}_final`] = response.content;
    }

    // 最终整合
    const coordinator = this.agents.get("协调员");
    const finalResponse = await coordinator.prompt.pipe(coordinator.llm).invoke({
      task,
      state: "最终整合",
      other_opinions: Object.entries(results).map(([k, v]) => `${k}: ${v}`).join("\n")
    });

    return {
      individual_results: results,
      final_answer: finalResponse.content
    };
  }
}

🌐 Next.js 集成:Agent 对话界面

8.10 Agent API 接口

// 文件:src/app/api/agent/route.ts
import { NextRequest } from "next/server";
import { ReActAgent } from "@/src/ch08/react-agent";
import { MultiAgentSystem } from "@/src/ch08/multi-agent";

export const runtime = "edge";

export async function POST(req: NextRequest) {
  const { message, agentType = "react" } = await req.json();

  try {
    let result;

    if (agentType === "react") {
      const agent = new ReActAgent();
      result = await agent.run(message);
    } else if (agentType === "multi") {
      const system = new MultiAgentSystem();
      result = await system.collaborate(message);
    } else {
      return Response.json({ error: "不支持的 Agent 类型" }, { status: 400 });
    }

    return Response.json({ success: true, data: result });
  } catch (error: any) {
    return Response.json({ error: error.message }, { status: 500 });
  }
}

8.11 前端对话界面

// 文件:src/app/agent/page.tsx
"use client";
import { useState, useRef, useEffect } from "react";

interface Message {
  id: string;
  type: "user" | "agent";
  content: string;
  steps?: any[];
  timestamp: Date;
}

export default function AgentPage() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState("");
  const [agentType, setAgentType] = useState("react");
  const [loading, setLoading] = useState(false);
  const messagesEndRef = useRef<HTMLDivElement>(null);

  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  };

  useEffect(() => {
    scrollToBottom();
  }, [messages]);

  const sendMessage = async () => {
    if (!input.trim() || loading) return;

    const userMessage: Message = {
      id: Date.now().toString(),
      type: "user",
      content: input,
      timestamp: new Date()
    };

    setMessages(prev => [...prev, userMessage]);
    setInput("");
    setLoading(true);

    try {
      const response = await fetch("/api/agent", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message: input, agentType })
      });

      const result = await response.json();

      if (result.success) {
        const agentMessage: Message = {
          id: (Date.now() + 1).toString(),
          type: "agent",
          content: agentType === "react"
            ? result.data[result.data.length - 1]?.answer || "处理完成"
            : result.data.final_answer,
          steps: agentType === "react" ? result.data : undefined,
          timestamp: new Date()
        };
        setMessages(prev => [...prev, agentMessage]);
      } else {
        throw new Error(result.error);
      }
    } catch (error: any) {
      const errorMessage: Message = {
        id: (Date.now() + 1).toString(),
        type: "agent",
        content: `错误:${error.message}`,
        timestamp: new Date()
      };
      setMessages(prev => [...prev, errorMessage]);
    } finally {
      setLoading(false);
    }
  };

  const renderStep = (step: any, index: number) => (
    <div key={index} className="ml-4 mb-2 p-2 bg-gray-50 rounded">
      <div className="text-sm text-gray-600">步骤 {step.step + 1}</div>
      {step.thought && <div className="text-sm">思考:{step.thought}</div>}
      {step.action && <div className="text-sm">行动:{step.action}</div>}
      {step.observation && <div className="text-sm">观察:{step.observation}</div>}
    </div>
  );

  return (
    <div className="max-w-4xl mx-auto h-screen flex flex-col">
      <div className="p-4 border-b">
        <h1 className="text-2xl font-bold mb-4">智能 Agent 对话</h1>
        <div className="flex gap-4">
          <select
            value={agentType}
            onChange={(e) => setAgentType(e.target.value)}
            className="border rounded px-3 py-1"
          >
            <option value="react">ReAct Agent</option>
            <option value="multi">Multi-Agent</option>
          </select>
        </div>
      </div>

      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.map((message) => (
          <div key={message.id} className={`flex ${message.type === "user" ? "justify-end" : "justify-start"}`}>
            <div className={`max-w-3xl p-3 rounded-lg ${
              message.type === "user"
                ? "bg-blue-500 text-white"
                : "bg-gray-100"
            }`}>
              <div className="whitespace-pre-wrap">{message.content}</div>
              {message.steps && (
                <div className="mt-2">
                  <div className="text-sm font-semibold mb-2">执行步骤:</div>
                  {message.steps.map(renderStep)}
                </div>
              )}
              <div className="text-xs opacity-70 mt-1">
                {message.timestamp.toLocaleTimeString()}
              </div>
            </div>
          </div>
        ))}
        {loading && (
          <div className="flex justify-start">
            <div className="bg-gray-100 p-3 rounded-lg">
              <div className="flex items-center gap-2">
                <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900"></div>
                Agent 正在思考...
              </div>
            </div>
          </div>
        )}
        <div ref={messagesEndRef} />
      </div>

      <div className="p-4 border-t">
        <div className="flex gap-2">
          <input
            type="text"
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyPress={(e) => e.key === "Enter" && sendMessage()}
            placeholder="输入你的问题..."
            className="flex-1 border rounded px-3 py-2"
            disabled={loading}
          />
          <button
            onClick={sendMessage}
            disabled={loading || !input.trim()}
            className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
          >
            发送
          </button>
        </div>
      </div>
    </div>
  );
}

🚀 实战项目:智能客服 Agent

8.12 项目需求

  • 多轮对话:理解上下文,支持追问和澄清
  • 知识检索:集成 RAG 系统,提供准确答案
  • 工单创建:复杂问题自动创建工单
  • 情感分析:识别用户情绪,调整回复策略
  • 意图识别:分类用户意图(咨询、投诉、建议等)

8.13 客服 Agent 实现

// 文件:src/ch08/customer-service-agent.ts
import { ChatOpenAI } from "@langchain/openai";
import { PromptTemplate } from "@langchain/core/prompts";
import { ToolManager } from "./tool-manager";

export interface CustomerIntent {
  type: "query" | "complaint" | "suggestion" | "escalation";
  confidence: number;
  entities: Record<string, any>;
}

export class CustomerServiceAgent {
  private llm: ChatOpenAI;
  private tools: ToolManager;
  private memory: any[] = [];

  constructor() {
    this.llm = new ChatOpenAI({ temperature: 0.3 });
    this.tools = new ToolManager();
  }

  private async analyzeIntent(message: string): Promise<CustomerIntent> {
    const prompt = PromptTemplate.fromTemplate(`
分析用户意图,输出 JSON:
{
  "type": "query|complaint|suggestion|escalation",
  "confidence": 0.0-1.0,
  "entities": {
    "product": "产品名称",
    "issue": "问题描述",
    "urgency": "紧急程度"
  }
}

用户消息:{message}
历史对话:{history}
`);

    const response = await prompt.pipe(this.llm).invoke({
      message,
      history: this.memory.slice(-3).map(m => `${m.role}: ${m.content}`).join("\n")
    });

    return JSON.parse(response.content as string);
  }

  private async generateResponse(
    message: string,
    intent: CustomerIntent,
    context: any
  ): Promise<string> {
    const prompt = PromptTemplate.fromTemplate(`
你是专业的客服代表,基于用户意图和上下文生成回复。

用户意图:{intent}
上下文信息:{context}
历史对话:{history}

要求:
1. 语气友好专业
2. 针对意图类型调整回复策略
3. 提供具体解决方案
4. 必要时引导用户提供更多信息

用户消息:{message}
回复:`);

    const response = await prompt.pipe(this.llm).invoke({
      message,
      intent: JSON.stringify(intent),
      context: JSON.stringify(context),
      history: this.memory.slice(-3).map(m => `${m.role}: ${m.content}`).join("\n")
    });

    return response.content as string;
  }

  async processMessage(message: string): Promise<{
    response: string;
    intent: CustomerIntent;
    actions: string[];
    shouldEscalate: boolean;
  }> {
    // 1. 意图分析
    const intent = await this.analyzeIntent(message);

    // 2. 根据意图执行相应工具
    const actions: string[] = [];
    let context: any = {};

    if (intent.type === "query") {
      // 知识检索
      const searchResult = await this.tools.execute("search", {
        query: message
      });
      context.searchResult = searchResult;
      actions.push("知识检索");
    }

    if (intent.confidence < 0.7) {
      // 低置信度,需要澄清
      context.needsClarification = true;
      actions.push("请求澄清");
    }

    // 3. 生成回复
    const response = await this.generateResponse(message, intent, context);

    // 4. 更新记忆
    this.memory.push({ role: "user", content: message });
    this.memory.push({ role: "assistant", content: response });

    // 5. 判断是否需要升级
    const shouldEscalate = intent.type === "escalation" ||
                          (intent.type === "complaint" && intent.confidence > 0.8);

    return {
      response,
      intent,
      actions,
      shouldEscalate
    };
  }

  async createTicket(userInfo: any, issue: string): Promise<string> {
    // 模拟工单创建
    const ticketId = `TKT-${Date.now()}`;
    console.log(`创建工单 ${ticketId}:`, { userInfo, issue });
    return ticketId;
  }
}

8.14 客服 API 接口

// 文件:src/app/api/customer-service/route.ts
import { NextRequest } from "next/server";
import { CustomerServiceAgent } from "@/src/ch08/customer-service-agent";

export const runtime = "edge";

export async function POST(req: NextRequest) {
  const { message, sessionId } = await req.json();

  try {
    const agent = new CustomerServiceAgent();
    const result = await agent.processMessage(message);

    // 如果需要升级,创建工单
    let ticketId = null;
    if (result.shouldEscalate) {
      ticketId = await agent.createTicket(
        { sessionId },
        `用户问题:${message}\n意图:${JSON.stringify(result.intent)}`
      );
    }

    return Response.json({
      success: true,
      data: {
        ...result,
        ticketId
      }
    });
  } catch (error: any) {
    return Response.json({ error: error.message }, { status: 500 });
  }
}

⚙️ 工程化与优化

8.15 Agent 安全与防护

// 文件:src/ch08/security.ts
export class AgentSecurity {
  private blacklist = [
    "rm -rf", "format", "delete", "drop", "exec", "eval",
    "password", "token", "key", "secret"
  ];

  private rateLimit = new Map<string, number[]>();

  validateInput(input: string): { valid: boolean; reason?: string } {
    // 敏感词检查
    for (const word of this.blacklist) {
      if (input.toLowerCase().includes(word)) {
        return { valid: false, reason: `包含敏感词:${word}` };
      }
    }

    // 长度限制
    if (input.length > 1000) {
      return { valid: false, reason: "输入过长" };
    }

    return { valid: true };
  }

  checkRateLimit(userId: string, limit = 10, window = 60000): boolean {
    const now = Date.now();
    const userRequests = this.rateLimit.get(userId) || [];

    // 清理过期请求
    const validRequests = userRequests.filter(time => now - time < window);

    if (validRequests.length >= limit) {
      return false;
    }

    validRequests.push(now);
    this.rateLimit.set(userId, validRequests);
    return true;
  }
}

8.16 性能优化

// 文件:src/ch08/optimization.ts
export class AgentOptimizer {
  private cache = new Map<string, any>();
  private toolCache = new Map<string, any>();

  async cachedToolCall(toolName: string, args: any, ttl = 300000): Promise<any> {
    const key = `${toolName}:${JSON.stringify(args)}`;
    const cached = this.toolCache.get(key);

    if (cached && Date.now() - cached.timestamp < ttl) {
      return cached.result;
    }

    // 实际调用工具
    const result = await this.executeTool(toolName, args);

    this.toolCache.set(key, {
      result,
      timestamp: Date.now()
    });

    return result;
  }

  private async executeTool(toolName: string, args: any): Promise<any> {
    // 工具执行逻辑
    return `Tool ${toolName} executed with ${JSON.stringify(args)}`;
  }

  // 并行工具调用
  async parallelToolCalls(calls: Array<{ tool: string; args: any }>): Promise<any[]> {
    const promises = calls.map(call => this.cachedToolCall(call.tool, call.args));
    return Promise.all(promises);
  }
}

8.17 可观测性与监控

// 文件:src/ch08/monitoring.ts
export interface AgentMetrics {
  requestId: string;
  userId: string;
  agentType: string;
  startTime: number;
  endTime: number;
  toolCalls: Array<{ tool: string; duration: number; success: boolean }>;
  totalTokens: number;
  error?: string;
}

export class AgentMonitor {
  private metrics: AgentMetrics[] = [];

  startRequest(requestId: string, userId: string, agentType: string): AgentMetrics {
    const metric: AgentMetrics = {
      requestId,
      userId,
      agentType,
      startTime: Date.now(),
      endTime: 0,
      toolCalls: [],
      totalTokens: 0
    };

    this.metrics.push(metric);
    return metric;
  }

  recordToolCall(requestId: string, tool: string, duration: number, success: boolean) {
    const metric = this.metrics.find(m => m.requestId === requestId);
    if (metric) {
      metric.toolCalls.push({ tool, duration, success });
    }
  }

  endRequest(requestId: string, totalTokens: number, error?: string) {
    const metric = this.metrics.find(m => m.requestId === requestId);
    if (metric) {
      metric.endTime = Date.now();
      metric.totalTokens = totalTokens;
      metric.error = error;
    }
  }

  getMetrics(): AgentMetrics[] {
    return this.metrics;
  }

  getAverageResponseTime(): number {
    const completed = this.metrics.filter(m => m.endTime > 0);
    if (completed.length === 0) return 0;

    const total = completed.reduce((sum, m) => sum + (m.endTime - m.startTime), 0);
    return total / completed.length;
  }
}

🧪 测试与评估

8.18 Agent 测试框架

// 文件:src/ch08/testing.ts
export interface TestCase {
  id: string;
  input: string;
  expectedIntent?: string;
  expectedTools?: string[];
  expectedResponse?: string;
  maxSteps?: number;
}

export class AgentTester {
  constructor(private agent: any) {}

  async runTest(testCase: TestCase): Promise<{
    passed: boolean;
    actual: any;
    expected: any;
    duration: number;
  }> {
    const startTime = Date.now();

    try {
      const result = await this.agent.processMessage(testCase.input);
      const duration = Date.now() - startTime;

      const passed = this.evaluateResult(result, testCase);

      return {
        passed,
        actual: result,
        expected: testCase,
        duration
      };
    } catch (error) {
      return {
        passed: false,
        actual: { error: error.message },
        expected: testCase,
        duration: Date.now() - startTime
      };
    }
  }

  private evaluateResult(actual: any, expected: TestCase): boolean {
    if (expected.expectedIntent && actual.intent?.type !== expected.expectedIntent) {
      return false;
    }

    if (expected.expectedTools) {
      const actualTools = actual.actions || [];
      for (const tool of expected.expectedTools) {
        if (!actualTools.includes(tool)) {
          return false;
        }
      }
    }

    if (expected.expectedResponse && !actual.response.includes(expected.expectedResponse)) {
      return false;
    }

    return true;
  }

  async runTestSuite(testCases: TestCase[]): Promise<{
    total: number;
    passed: number;
    failed: number;
    results: any[];
  }> {
    const results = await Promise.all(
      testCases.map(testCase => this.runTest(testCase))
    );

    const passed = results.filter(r => r.passed).length;
    const failed = results.length - passed;

    return {
      total: results.length,
      passed,
      failed,
      results
    };
  }
}

📚 延伸链接

  • LangChain.js Agent 文档:https://js.langchain.com/docs/use_cases/autonomous_agents/
  • LangGraph 状态图:https://langchain-ai.github.io/langgraph/
  • ReAct 论文:https://arxiv.org/abs/2210.03629
  • Agent 安全最佳实践:https://github.com/langchain-ai/langchain/tree/master/libs/langchain/langchain/agents

✅ 本章小结

  • 构建了完整的 Agent 系统:工具定义、ReAct 循环、多 Agent 协作
  • 实现了代码执行环境与数据分析能力
  • 在 Next.js 中提供了完整的 Agent 对话界面
  • 实战了智能客服 Agent,具备意图识别、知识检索、工单创建能力
  • 建立了安全、性能、可观测性的工程化体系

🎯 下章预告

下一章《LangGraph 状态图与工作流编排》中,我们将:

  • 深入 LangGraph 状态图设计模式
  • 构建复杂的工作流编排系统
  • 实现条件分支、循环、并行处理
  • 集成外部 API 与数据库操作

最后感谢阅读!欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!!!

2025 年还有前端不会 Nodejs ?

大家好,我是双越。前百度 滴滴 资深前端工程师,慕课网金牌讲师,PMP。我的代表作有:

  • wangEditor 开源 web 富文本编辑器,GitHub 18k star,npm 周下载量 20k
  • 划水AI Node 全栈 AIGC 知识库,包括 AI 写作、多人协同编辑。复杂业务,真实上线。
  • 前端面试派 系统专业的面试导航,刷题,写简历,看面试技巧,内推工作。开源免费。

近期我准备开发一个 AI Agent 智能体项目 智语zhitalk AI 智能面试官,可以优化简历、模拟面试。很多同学想加入项目学习,但是自己是前端不会 Nodejs ,于是我就先写一篇 Nodejs 的入门资料,帮他们初步了解 Nodejs 以便能快速跟上项目进度。如有问题可留言或私信。

开始

Nodejs 于 2009 年发布,现在 2025 年 8 月已经更新到 v24 版本,现在几乎所有的开放平台、云服务、Serverless 等都支持 Nodejs 。它早已经成为最热门的开发语言之一,而且随着国外对于 SSR 全栈的推广,还在继续扩展它的影响范围。

还有,如果你作为前端人员想接触 AI 开发,最便捷的方式就是使用 Nodejs 作为开发语言,学习成本最低。

接下来我试图用一篇文章,用最简单的方式,帮助各位前端开发人员入门 Nodejs ,其他它很简单。你先不要看 Nodejs 文档,你先按照我的学习方式来。

学习要求:

  • 熟悉 JS 语法,熟悉 npm 使用
  • 熟悉 HTTP 协议,做过 ajax 请求

JS runtime 运行时

一段 JS 代码或其他编程代码,本质上就是一段字符串,就跟我们随便写一句话、一段话一样,都是字符串。

console.log('hello world')

一段字符串能干嘛?能看,能读,能写,能复制,能粘贴。如果是富文本能设置一下颜色字体大小等...

它能被执行吗?

如果我们拷贝一段 JS 代码,穿越到 JS 语言发明之前的 90 年代(那会儿 PC 技术已经很发达了,盖茨早就世界首富了),你如何执行它?不能。

最开始 JS 代码(字符串)是在浏览器中执行的。浏览器中内置了 JS 解释器,然后你在 html 代码中通过特定的语法 <script> 标签内嵌 JS 代码,浏览器识别以后执行这段 JS 代码。

一个语言的执行环境,我们就称为 runtime “运行时”,没有 runtime 代码就无法被执行,就是一堆字符串,任何语言都需要 runtime 。

Nodejs 就是继浏览器以后第二个 JS 代码的 runtime 运行时。从此以后,JS 代码不只能运行在浏览器了,它还可以运行在 Nodejs 环境。而 Nodejs 可以被安装在任何操作系统中(windows mac linux 等),也就是说 JS 代码可以在任何电脑上运行,即便没有浏览器也可以运行。

现在的 JS 运行时除了浏览器、Nodejs 之外还有 Deno Bun ,这俩用途都和 Nodejs 类似,而且目前尚未全面流行,你可以先保持观察,不用深入。

安装 Nodejs 并写 demo

现代前端开发都是用 npm 管理插件,用 nodejs 支持打包工具,所以你应该是安装了 nodejs 的。

如果没有安装,就下载安装一下,安装完以后打开控制台输入一下命令,可以看到 Nodejs 的版本。

node --version
npm --version

然后在控制台输入 node 即可进入 nodejs 环境,可输入任何 JS 代码,按 ctrl+c 退出

image.png

你可以创建一个 .js 文件,然后使用 node 命令来执行它。例如我在当前的 src 目录下创建了 test.js 文件

// src/test.js
function fn() {
  console.log('this is a test file')
}

fn()

然后使用 node 命令执行它,即可打印预期的结果

image.png

不能使用浏览器 API

Nodejs 运行时可以执行 JS 代码,但你不能使用浏览器 API ,因为这里根本没有浏览器

例如我在 Nodejs 环境执行 alert('hello') 就报错了 alert is not definedalert 没有定义,Nodejs 中没有 alert ,只有浏览器中才有 alert

image.png

所以,JS 语法和浏览器 API 要分开来看,他俩也压根不是一个东西,只是初学者一块学习就容易混在一块。

常见的浏览器 API 还有:

  • DOM 操作,如 document getElementById createElement appendChild
  • DOM 时间,如 addEventListener
  • BOM 操作,如 window navigation sreen
  • XMLHTTPRequest ,但 fetch 是可以在 Nodejs 中使用的

Nodejs 内置的 API

Nodejs 不能使用浏览器 API ,这个好理解,因为不是浏览器环境。

但如果 Nodejs 只能执行 JS 语法,没有其他 API 这也不行啊。因为光有 JS 语法,写个函数、做个计算、打印个字符串等,解决不了具体的问题呀。

所以,Nodejs 也需要提供(内置)其他 API 让我们能开发具体的功能。

Nodejs 是作为一个软件安装在操作系统上的,所以 Nodejs 提供了关于操作系统的 API

新建一个文件 os.js 写入如下代码

// src/os.js

const os = require('os') // 和 import 语法类似

function getSystemInfo() {
  return {
    platform: os.platform(),
    type: os.type(),
    architecture: os.arch(),
    cpuCount: os.cpus().length,
    cpuModel: os.cpus()[0].model,
    totalMemoryGB: Math.round(os.totalmem() / 1024 / 1024 / 1024),
    hostname: os.hostname(),
  }
}

const systemInfo = getSystemInfo()
console.log(systemInfo)

然后在控制台使用 node 命令执行这个文件,即可打印我当前的系统信息

image.png

至此就能开始体现 Nodejs 和浏览器 JS 的区别了。你一定要注意去观察和思考这种区别,以及两者不同的用途,这个很重要。

像以上这些 OS 的信息,浏览器能获取吗?当然不能。浏览器是客户端,如果能轻松获取 OS 信息,那就是安全漏洞。而 Nodejs 是服务端或者本机,我自己获取我自己的 OS 信息,这肯定是没问题的。

获取 OS 信息这个功能不常用,这也只是一个简单的例子,接下来我们才正式开始写一些实际的功能。

文件 API

Nodejs 是作为一个软件安装在操作系统上的,文件是操作系统的数据组织方式,所以 Nodejs 作为服务端语言要能操作文件,如创建文件、读取文件、修改文件、删除文件、复制文件等操作。

新建一个 js 文件 src/file.js 写入如下代码

首先,引入 fs path 两个模块。其中 fs 就是 file system 文件系统,用于操作文件,path 用于操作文件路径。

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

然后定义文件路径,我们计划在 file 目录下创建 data.txt 文件

// 定义文件路径
const fileDir = path.join(__dirname, '../file')
const filePath = path.join(fileDir, 'data.txt')

然后创建文件

// 功能1: 在 /file 目录下创建 data.txt 文件并写入内容
function createAndWriteFile() {
  // 确保文件目录存在,否则先目录和文件
  if (!fs.existsSync(fileDir)) {
    fs.mkdirSync(fileDir, { recursive: true }) // 创建目录和文件
    console.log('创建目录:', fileDir)
  }

  // 写入文件内容
  fs.writeFileSync(filePath, content, 'utf8') // 写入内容
  console.log('文件创建成功:', filePath)
  console.log('写入内容:', content)
}

createAndWriteFile()

在控制台执行 node src/file.js 可以看到 data.txt 文件被创建出来了

image.png

然后继续写代码,读取文件内容

// 功能2: 读取文件内容
function readFile() {
  try {
    const data = fs.readFileSync(filePath, 'utf8') // 读取文件内容
    console.log('读取的文件内容:', data)
    return data
  } catch (error) {
    console.error('读取文件失败:', error.message)
    return null
  }
}
readFile()

在控制台执行 node src/file.js 可以看到读取了正确的文件内容

image.png

Nodejs 还有更多文件操作的 API ,你可以咨询 AI 或让 AI 给你写出 demo

HTTP 服务

Nodejs 对前端人员最大的赋能就是:可以开发服务端,可以前后端都做,可以做全栈开发。

前端主要开发 HTML CSS JS ,通过 ajax 访问服务端 API 接口获取 JSON 数据,然后渲染到页面上。

服务端开发,最主要的就是 API 接口,接收前端 ajax 请求,获取数据,最后返回数据给前端。

新建一个 js 文件 src/http.js 写入如下内容

  • 引入 Nodejs 内置的 http 模块,用于启动 HTTP 服务
  • 通过 createServer 创建一个 HTTP 服务,函数内的两个参数分别代表 RequestResponse ,这两个是 HTTP 协议的基础知识 —— 你写前端 ajax 也用得着,默认你已经熟知
  • 函数内,通过 res.setHeader 设置 HTTP header(前端 ajax 也需要设置 header),返回格式为 JSON
  • 函数内,通过 res.end 返回本次请求的内容,要返回字符串格式
  • 通过 server.listen 监听 3000 端口
const http = require('http')

// 创建 HTTP 服务器
const server = http.createServer((req, res) => {
  // 设置响应头为 JSON 格式
  res.setHeader('Content-Type', 'application/json')

  // 创建要返回的 JSON 数据
  const response = {
    message: 'Hello World!',
    time: new Date().toLocaleString(),
    status: 'success',
  }

  // 返回 JSON 响应
  res.end(JSON.stringify(response, null, 2))
})

// 监听 300 端口
server.listen(3000, () => {
  console.log('HTTP 服务器已启动,监听端口: 3000')
  console.log('访问地址: http://localhost:3000')
})

在控制台运行 node src/http.js 可启动本地的 HTTP 服务,并监听 3000 端口

image.png

使用浏览器访问 localhost:3000 可以看到服务端返回的 JSON 内容,说明 API 接口成功了

image.png

以上最简单的 get 请求,而且没有判断路由,下面再来一个稍微复杂一点的例子。

新建一个 JS 文件 src/http-post.js 代码如下,模拟一个创建用户的 API 接口

  • 使用 req.method 可以获得前端 ajax 请求的 method ,这里规定必须是 POST
  • 使用 req.url 可以获得前端 ajax 请求的路由,这里规定必须是 /api/user
  • 使用 req.on('data', (chunk) => { }) 可以接收到客户端传递过来的 request body 数据
const http = require('http')

// 创建 HTTP 服务器
const server = http.createServer((req, res) => {
  // 设置响应头为 JSON 格式
  res.setHeader('Content-Type', 'application/json')

  // 检查是否为 POST 请求且路由为 /api/user
  if (req.method === 'POST' && req.url === '/api/user') {
    let body = ''

    // 接收数据
    req.on('data', (chunk) => {
      body += chunk.toString()
    })

    // 数据接收完成
    req.on('end', () => {
      try {
        // 解析 JSON 数据
        const userData = JSON.parse(body)

        // 打印接收到的数据
        console.log('接收到的用户数据:', userData)
        console.log('userId:', userData.userId)
        console.log('name:', userData.name)
        console.log('email:', userData.email)
        
        // TODO...

        // 返回成功响应
        const response = {
          status: 'success',
        }

        res.statusCode = 200
        res.end(JSON.stringify(response))
      } catch (error) {
        // 如果 JSON 解析失败,返回错误
        const response = {
          status: 'error',
          message: '无效的 JSON 数据',
        }

        res.statusCode = 400
        res.end(JSON.stringify(response))
      }
    })
  } else {
    // 其他请求返回简单提示
    const response = {
      message: '请发送 POST 请求到 /api/user',
    }

    res.end(JSON.stringify(response))
  }
})

// 监听 3000 端口
server.listen(3000, () => {
  console.log('HTTP 服务器已启动,监听端口: 3000')
  console.log('POST 请求地址: http://localhost:3000/api/user')
})

控制台运行 node src/http-post.js 启动服务并监听 3000 端口。

但 POST 请求我们不能直接使用浏览器访问测试,因为浏览器访问是 GET 请求。

测试 POST 请求一般有两种方式,如果你控制台支持 curl 命令,可以使用它来测试,在控制台运行

curl -X POST http://localhost:3000/api/user \
  -H "Content-Type: application/json" \
  -d '{"userId": 123, "name": "张三", "email": "zhang@example.com"}'

或者,你安装 Postman 然后选择 POST 输入 url 和 body 信息,点击 Send 按钮,即可得到返回结果。

image.png

至此 HTTP 服务端的基础知识其实都已经包含了

  • 启动 HTTP 服务,监听端口
  • Request Reponse
  • method
  • url
  • 获取 Request body
  • 返回数据

通过这些知识搞一个基础的 HTTP 服务端是完全没有问题的,都在这个范围之内。例如你想要获取 JWT token 或者设置 cookie 都可以通过操作 HTTP header 来搞定,具体可以咨询 AI ,不看文档方便。

另,以上代码中的 // TODO... 位置,可以把 user 数据插入到数据库,这一点后面再说。

Koa 框架

在 Nodejs 刚开始流行的时候,早就有人总结了 Nodejs 作为服务端的通用能力,并且开发了框架,可以让我们更加便捷的开发 Nodejs 服务端,其中最出名的就是 expresskoa

两者设计思路和使用方式都一样,而且很多年都没变,早已稳定了,我们以后者 koa 为例。

新建一个目录,执行 npm init -y 创建一个 package.json 文件,和做前端开发一样。

然后安装 koa 和 nodemon ,后者用于启动服务,和代码逻辑没关系,可暂时不用管

npm i koa
npm i nodemon -D

然后创建一个目录 src 再在里面创建一个文件 index.js 写入如下代码

// src/index.js

const Koa = require('koa')
const app = new Koa()

app.use(async (ctx) => {
  ctx.body = 'Hello World'
})

app.listen(3000)

然后在 package.json 文件加入一个命令 "dev": "nodemon src/index.js"

image.png

再执行 npm run dev 就可以启动 nodejs 服务,用浏览器访问 localhost:3000 就可以看到访问结果

image.png

如果你想返回一段 JSON 怎么办?直接给 ctx.body 赋值一段 JSON 即可,不用考虑转换为字符串格式

image.png

如果想要支持 POST PUT 的 HTTP 请求方法,可如下修改代码

  • 先安装 npm i koa-body 它可用于获取 request body 数据
  • 使用 ctx.method 判断 Method ,其他就很简单了
const Koa = require('koa')
const { koaBody } = require('koa-body')
const app = new Koa()

app.use(koaBody())
app.use(async (ctx) => {
  if (ctx.method === 'POST') {
    const { user, email } = ctx.request.body || {}
    console.log('user and email ', user, email)

    // 其他处理,如 await insertUserToDatabase(user, email)

    ctx.body = {
      status: 'success',
      user,
      email,
    }
  }
})

app.listen(3000)

启动 nodejs 服务端以后,我们使用 Postman 测试如下,而且服务端也打印了正确的结果

image.png

如果想使用路由,那就再安装 koa router

npm install @koa/router

代码如下

  • 通过 router.post 即可定义method 和路由 path
  • 还可以继续扩展其他路由
const Koa = require('koa')
const koaBody = require('koa-body')
const Router = require('@koa/router')

const app = new Koa()
const router = new Router()

app.use(koaBody())

router.post('/api/user', (ctx) => {
  const { user, email } = ctx.request.body || {}
  console.log('user and email ', user, email)

  // 其他处理,如 await insertUserToDatabase(user, email)

  ctx.body = {
    status: 'success',
    user,
    email,
  }
})

// 继续扩展其他路由...

app.use(router.routes())
app.use(router.allowedMethods())

app.listen(3000)

至此,你已经看到 koa 是如何启动 nodejs 服务并且处理基础的请求,其他更多的 API 你可以参考文档,也可以直接咨询 AI 更方便。

只要你熟悉前端 ajax 请求和 HTTP 协议,那这些知识点基本都难不倒你。

操作数据库

数据库最常见的操作就是增删改查 CRUD ,你可能之前听说过操作数据库需要专门的 SQL 语言,挺麻烦的。

其实现在做常见的 web 应用开发,基本不会使用 SQL 语句了,最常见的就是 ORM 工具,例如 Prisma

你一开始看它的文档肯定是看不懂的,而且也不需要你立刻看懂所有的,你只需要知道它是如何增删改查的就可以入门了。

下面这段代码能看懂吗?不要管它如何执行,你能看懂语意就可以,仅语意。

  • 数据库里有一个 user 表(就像一个 excel 表),这一点代码中没写
  • user 表中创建一行数据,两列 nameemail
  • 查询这个表中的所有数据,并打印
async function main() {
  await prisma.user.create({
    data: {
      name: 'Alice',
      email: 'alice@prisma.io',
    },
  })

  const allUsers = await prisma.user.findMany()
  console.dir(allUsers, { depth: null })
}

我想大部分人应该都能看懂这几行代码的语意,其实现代 ORM 工具操作数据库就是使用 create fined update delete 等这些函数进行增删改查的操作,并不是 SQL 语句。

再来个复杂一点的例子,这个不要求看懂,当然能看懂最好

  • 数据库中有三个表 user profile posts
  • user 表中创建一行,两列 name email ,同时:
    • posts 表中插入一行,一列 title
    • profile 表中插入一行,一列 bio
  • 查询所有 user 表中的数据,同时查询出 postsprofile 两个表的相关数据
async function main() {
  await prisma.user.create({
    data: {
      name: 'Alice',
      email: 'alice@prisma.io',
      posts: {
        create: { title: 'Hello World' },
      },
      profile: {
        create: { bio: 'I like turtles' },
      },
    },
  })

  const allUsers = await prisma.user.findMany({
    include: {
      posts: true,
      profile: true,
    },
  })
  console.dir(allUsers, { depth: null })
}

把数据库的增删改查结合到上面 nodejs 服务端代码中,就可以开发一个基础的数据服务 API 。

调用第三方服务

第三方服务一般都是 API 的形式,一般有两种调用方式,以 deepseek API 为例

第一种方式,直接发送 http 请求调用,deepseek API 文档是使用 curl 工具举例的

curl https://api.deepseek.com/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <DeepSeek API Key>" \
  -d '{
        "model": "deepseek-chat",
        "messages": [
          {"role": "system", "content": "You are a helpful assistant."},
          {"role": "user", "content": "Hello!"}
        ],
        "stream": false
      }'

其实我们可以转换为 nodejs 内置的 fetch 方法去请求,这是我用 AI 生成的代码

    await fetch('https://api.deepseek.com/chat/completions', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer <DeepSeek API Key>' // 请替换为你的实际 API Key
      },
      body: JSON.stringify({
        model: "deepseek-chat",
        messages: [
          { role: "system", content: "You are a helpful assistant." },
          { role: "user", content: "Hello!" }
        ],
        stream: false
      })
    });

第二种方式,有的第三方开放平台会开发一个 npm 插件,让你直接安装并调用它的方法。例如调用 deepseek API 可以安装 openai

npm i openai

然后直接引入并调用它的方法即可。其实它背后也是发送 HTTP 请求,不过它封装了。

// Please install OpenAI SDK first: `npm install openai`

import OpenAI from "openai";

const openai = new OpenAI({
        baseURL: 'https://api.deepseek.com',
        apiKey: '<DeepSeek API Key>'
});

async function main() {
  const completion = await openai.chat.completions.create({
    messages: [{ role: "system", content: "You are a helpful assistant." }],
    model: "deepseek-chat",
  });

  console.log(completion.choices[0].message.content);
}

main();

把调用 deepseek API 结合到上面 nodejs 服务端代码中,就可以开发一个自己的 AI 接口服务。

最后

如果你能看懂大部分内容,说明你可以快速入门 Nodejs 并进行 AI 开发。有兴趣的可以关注我的 智语zhitalk AI Agent 项目。

❌