普通视图

发现新文章,点击刷新页面。
昨天 — 2025年8月17日首页

vite和webpack打包结构控制

作者 gnip
2025年8月17日 22:45

概述

在工程化项目中,Vite 和 Webpack 作为当前最流行的两大构建工具,它们在打包输出目录结构的配置上各有特点,webpack和vite默认打包构建的输出目录结构可能不满足我们的需求,因此需要根据实际情况进行控制。

默认输出结构对比

webpack

默认情况下,基本上都不会根据情况进行分块,所有资源都是默认被打包到了一个文件中。

dist/
  ├── main.js
  ├── index.html
  |—— .....

vite

dist/
  ├── assets/
  │   ├── index.[hash].js
  │   ├── vendor.[hash].js
  │   └── style.[hash].css
  └── index.html

Vite目录结构精细控制

文件指纹策略

Webpack 提供了多种 hash 类型:

  • [hash]: 项目级hash
  • [chunkhash]: chunk级hash
  • [contenthash]: 内容级hash

基础配置方案

// vite.config.js
export default {
  build: {
    outDir: 'dist',
    assetsDir: 'static',
    emptyOutDir: true
  }
}

Rollup 输出配置

由于vite内部打包使用rollup,因此打包输出相关配置需参考rollup的配置

export default {
  build: {
    rollupOptions: {
      output: {
          //资源块输出目录配置
        chunkFileNames: 'static/js/[name]-[hash].js',
        //入口文件输出目录配置
        entryFileNames: 'static/js/[name]-[hash].js',
        //静态输出目录配置(图片、音频、字体)
        assetFileNames: ({ name }) => {
          const ext = name.split('.').pop()
          //函数形式动态返回文件输出名及其位置
          return `static/${ext}/[name]-[hash].[ext]`
        }
      }
    }
  }
}

webpack 目录结构精细控制

基础输出配置

// webpack.config.js
module.exports = {
  output: {
    path: path.resolve(__dirname, 'build'), // 修改输出目录
    filename: 'js/[name].[contenthash:8].js', // JS文件输出路径
    chunkFilename: 'js/[name].[contenthash:8].chunk.js', // 异步chunk
    assetModuleFilename: 'media/[name].[hash:8][ext]', // 静态资源
    clean: true // 构建前清空目录
  }
}

高级资源管理

使用 mini-css-extract-plugin 控制 CSS 输出:

  module: {
    rules: [
      { test: /\.css$/, use: [MiniCssExtractPlugin.loader, "css-loader"] },
     
    ],
  },

Webpack 5 引入了资源模块类型,取代了传统的 file-loader/url-loader,用来处理之前繁琐的配置

 module: {
   rules: [
{
       test: /.(png|jpe?g|gif|svg)$/i,
       type: 'asset/resource' // 替换 file-loader
     },
     {
       test: /.(mp4|webm|ogg)$/i,
       type: 'asset/resource'
     }
   ],
 },

四种资源模块类型

类型 作用 等价 loader
asset/resource 导出单独文件并生成 URL file-loader
asset/inline 导出资源的 Data URI url-loader
asset/source 导出资源的源代码 raw-loader
asset 自动选择 resource 或 inline url-loader + 限制

总结

上面列举的部分配置,更多的详细配置,可以查阅官网解析。

前端必会:如何创建一个可随时取消的定时器

作者 烛阴
2025年8月17日 20:58

一、原生的取消方式

JavaScript 原生就提供了取消定时器的方法。setTimeoutsetInterval 在调用时都会返回一个数字类型的 ID,我们可以将这个 ID 传递给 clearTimeoutclearInterval 来取消它。

// 1. 设置一个定时器
const timerId: number = setTimeout(() => {
  console.log("这个消息可能永远不会被打印");
}, 2000);

// 2. 在它触发前取消它
clearTimeout(timerId);

常见痛点:

  • timerId 变量需要被保留在组件或模块的作用域中,状态分散。
  • 启动、暂停、取消的逻辑是割裂的,代码可读性和可维护性差。

二、封装一个可取消的定时器类

我们可以简单的封装一个 CancellableTimer 类,将定时器的状态和行为内聚在一起。后续可以扩展,把项目中的所有定时器进行统一管理。

// 定义定时器ID类型
type TimeoutId = ReturnType<typeof setTimeout>;

class CancellableTimer {
    private timerId: TimeoutId | null = null;

    constructor(private callback: () => void, private delay: number) {}

    public start(): void {
        // 防止重复启动
        if (this.timerId !== null) {
            this.cancel();
        }

        this.timerId = setTimeout(() => {
            this.callback();
            // 执行完毕后重置 timerId
            this.timerId = null;
        }, this.delay);
    }

    public cancel(): void {
        if (this.timerId !== null) {
            clearTimeout(this.timerId);
            this.timerId = null;
        }
    }
}

// 使用示例
console.log('定时器将在3秒后触发...');
const myTimer = new CancellableTimer(() => {
    console.log('定时器任务执行!');
}, 3000);

myTimer.start();

// 模拟在1秒后取消
setTimeout(() => {
    console.log('用户取消了定时器。');
    myTimer.cancel();
}, 1000);

三、实现可暂停和恢复的定时器

在很多场景下,我们需要的不仅仅是取消,还有暂停恢复

要实现这个功能,我们需要在暂停时记录剩余时间

type TimeoutId = ReturnType<typeof setTimeout>;

class AdvancedTimer {
    private timerId: TimeoutId | null = null;
    private startTime: number = 0;
    private remainingTime: number;
    private callback: () => void;
    private delay: number;


    constructor(callback: () => void, delay: number) {
        this.remainingTime = delay;
        this.callback = callback;
        this.delay = delay;
    }

    public resume(): void {
        if (this.timerId) {
            return; // 已经在运行
        }

        this.startTime = Date.now();
        this.timerId = setTimeout(() => {
            this.callback();
            // 任务完成,重置
            this.remainingTime = this.delay;
            this.timerId = null;
        }, this.remainingTime);
    }

    public pause(): void {
        if (!this.timerId) {
            return;
        }

        clearTimeout(this.timerId);
        this.timerId = null;
        // 计算并更新剩余时间
        const timePassed = Date.now() - this.startTime;
        this.remainingTime -= timePassed;
    }

    public cancel(): void {
        if (this.timerId) {
            clearTimeout(this.timerId);
        }
        this.timerId = null;
        this.remainingTime = this.delay; // 重置
    }
}

// 使用示例
console.log('定时器启动,5秒后执行...');
const advancedTimer = new AdvancedTimer(() => console.log('Done!'), 5000);
advancedTimer.resume();

setTimeout(() => {
    console.log('2秒后暂停定时器');
    advancedTimer.pause();
}, 2000);

setTimeout(() => {
    console.log('4秒后恢复定时器 , 应该还剩3秒');
    advancedTimer.resume();
}, 4000);

总结

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

Oxc 最新 Transformer Alpha 功能速览! 🚀🚀🚀

2025年8月17日 20:16

前言

刚刚看到尤雨溪推特转发了 OXC 团队的最新成果,并介绍了该成果背后的一些故事!

尤雨溪推特

今天介绍下这些详细成果!

往期精彩推荐

正文

Oxc Transformer Alpha 内置 React Refresh,以及无需 TypeScript 编译器的独立 .d.ts 文件生成。相较于 SWC 和 Babel,Oxc 在性能、内存占用和包体积上表现出色,堪称前端构建的实用利器。

以下是其核心特性的详细解析。

1. TypeScript 和 JSX 到 ESNext 转换

Oxc 支持将 TypeScript 和 React JSX 代码转换为 ESNext,性能显著优于传统工具:

  • 3-5 倍于 SWC:处理 100 到 10,000 行代码,Oxc 耗时仅 0.14ms 至 14.9ms,而 SWC 为 0.7ms 至 35.9ms。
  • 20-50 倍于 Babel:Babel 处理同样代码耗时 11.5ms 至 492ms,Oxc 效率遥遥领先。

2. 内置 React Refresh

Oxc 集成了 React Refresh,支持开发中的热重载,速度比 SWC 快 5 倍,比 Babel 快 50 倍。这让 React 开发更流畅,减少等待时间。

3. TypeScript 独立声明生成

Oxc 提供无需 TypeScript 编译器的 .d.ts 文件生成,性能惊人:

  • 40 倍于 TSC:处理 100 行代码仅需 0.1ms(TSC 为 23.1ms)。
  • 20 倍于大文件:10,000 行代码耗时 3.5ms(TSC 为 115.2ms)。

示例

import { transform } from 'oxc-transform';
const transformed = transform('file.ts', sourceCode, {
  typescript: {
    onlyRemoveTypeImports: true,
    declaration: { stripInternal: true },
  },
});
await fs.writeFile('out.js', transformed.code);
await fs.writeFile('out.d.ts', transformed.declaration);

4. 轻量级与低内存占用

Oxc 仅需 2 个 npm 包(总计 2MB),对比 SWC 的 37.5MB 和 Babel 的 21MB(170 个包)。内存占用上,Oxc 处理 10,777 行代码仅用 51MB 内存,SWC 用 67MB,Babel 高达 172MB。

5. 实际应用案例

  • Vue.js:实验性使用 oxc-transform 优化构建流程。
  • vue-macros:通过 unplugin-isolated-decl.d.ts 生成时间从 76s 降至 16s。
  • Airtable:在 Bazel 构建中集成 Oxc 的 .d.ts 生成。
  • Rolldown:直接使用 Rust oxc_transformer crate。

最后

Oxc Transformer Alpha 以 Rust 的高性能和轻量级设计,为 JavaScript 编译带来新可能。无论是加速 TypeScript 转换还是优化 React 开发体验,它都展现了朴实无华的实用力量!

今天的分享就这些了,感谢大家的阅读!如果文章中存在错误的地方欢迎指正!

往期精彩推荐

拯救你的app/小程序审核!一套完美避开审核封禁的URL黑名单机制

2025年8月17日 18:25

app/微信小程序审核又双叒叕被拒了?因为一个历史遗留页面导致整个小程序被封禁搜索?别让精心开发的app/小程序毁在几个不起眼的URL上!本文将揭秘我们在多次惨痛教训后总结出的终极解决方案。

前言:每个小程序开发者都经历过的噩梦

凌晨两点,微信审核通知:"您的小程序因存在违规页面,搜索功能已被限制"。看着辛苦运营的用户量断崖式下跌,排查三天才发现是因为一个早已下架但还能访问的历史页面。这不是假设,而是真实发生的灾难场景

在经历多次微信审核失败后,我们意识到:必须有一套灵活、实时的URL黑名单机制,能够在app/微信审核发现问题前,快速屏蔽任何违规页面。这套系统需要:

  1. 分钟级响应:新发现的违规URL,1分钟内全局生效

  2. 精准打击:既能拦截整个页面,也能封禁特定参数组合

  3. 零误杀:确保正常页面不受影响

  4. 优雅降级:被拦截用户跳转到友好提示页,且可一对一设置兜底页。

下面是我们用血泪教训换来的完整解决方案,已成功帮助我们通过n多次app审核。

app/微信小程序审核的致命陷阱:你未必意识到的风险点

真实审核失败案例

  • 案例1:三年前的活动页仍可通过直接URL访问(违反现行规则)

  • 案例2:用户生成内容包含敏感关键词(UGC页面)

  • 案例3:第三方合作伙伴的H5页面突然变更内容

  • 最致命案例:历史页面被微信爬虫索引,导致整个小程序搜索功能被封禁

核心需求清单(微信审核视角)

  1. 实时封堵能力:无需发版即可封禁任意URL

  2. 精准匹配:支持完整URL和带参数的URL匹配

  3. 全类型覆盖:原生页面 + H5页面统一处理

  4. 优雅降级:被封禁用户看到友好提示而非404

  5. 安全兜底:系统异常时自动放行,不影响正常业务

系统架构设计:三重防护盾

核心流程

  1. 所有跳转请求经过黑名单检查

  2. 命中规则则跳转到兜底页

  3. 系统异常时降级放行

  4. 后台配置秒级生效

核心技术实现

参数级精准打击 - 只封禁违规内容

// 黑名单配置
["pages/user/content?type=sensitive"]

// 结果:
"pages/user/content?type=normal" => 放行 ✅
"pages/user/content?type=sensitive" => 拦截 ⛔

微信审核场景:当只有特定参数组合违规时,最小化业务影响

匹配规则详解:如何应对app审核

场景1:紧急封禁整个页面(后台配置示例)

{
  "YourBlackList": [
    {
      "nowUrl": "https://baidu.com",
      "ToUrl": "www.juejin.cn"
    }
  ]
}

只要命中 baidu.com 无论实际跳转页面后面参数是什么,都命中了黑名单,直接跳转到自己的兜底页](url)

场景2:精准封禁违规内容

// 配置黑名单
{
  "YourBlackList": [
    {
      "nowUrl": "pages/news/detail?id=12345",
      "ToUrl": "www.baidu.com"
    }
  ]
}
// 效果:
仅拦截id=12345的新闻,如果命中,则跳转到百度(你设置的兜底页)。其他正常展示

场景3:批量处理历史内容

// 配置黑名单
{
  "YourBlackList": [
    {
      "nowUrl": "pages/history/?year=2020",
      "ToUrl": "www.baidu.com"
    }
  ]
}

// 效果:
拦截2020年的所有历史页面,其他年份正常

实际应用:拯救审核的最后一公里

在路由跳转处拦截

async function myNavigateTo(url) {
  const { isBlocked, ToUrl } = checkUrlBlacklist(url);
  if (isBlocked) {
    console.warn('审核风险页面被拦截:', url);
    // 跳转到安全页
    return wx.navigateTo({ url: ToUrl });
  }
  
  // 正常跳转逻辑...
}

性能与安全:双保险设计

二重保障机制

  1. 性能优化:黑名单为空时短路返回
if (!blackUrlList.length) return { isBlocked: false };
  1. 频率控制:避免相同URL重复解析

更新时机

app/小程序初始化时,如果想更精细一些,可以监听app/小程序后台切到前台onShow时

  // 获取阿波罗接口配置
      const resp = await request({
        url: 'https://你的后台配置接口',
      });
      // 这里blackUrlInfoList需要保存在全局,可以放在本地存储下
      blackUrlInfoList = res.blackUrlInfoList || []

校验时机:每次跳转时。

具体判断逻辑在此不做阐述,

总结:从此告别审核噩梦

通过实施这套URL黑名单系统,我们实现了:

  • 审核通过率从63% → 98%

  • 问题响应时间从2天 → 5分钟

  • 搜索封禁事故0发生

关键收获

  1. 提前拦截比事后补救更重要

  2. 参数级控制最大化保留正常功能

  3. 实时配置能力是应对审核的关键

现在点击右上角收藏本文,当app审核再次亮红灯时,你会感谢今天的自己!


分享你的审核故事:你在微信/app审核中踩过哪些坑?欢迎在评论区分享你的经历和解决方案!

nextjs项目build导出静态文件

作者 1024小神
2025年8月17日 17:16

只需要在next.config.ts中配置output为export即可,可以配置三种模式。

undefined:默认的构建输出,.next目录,适用于生产模式下的next start或Vercel等托管提供商

“standalone”:一个独立的构建输出,.next/stalone目录,仅包含必要的文件/依赖项。适用于Docker容器中的自托管。

'export':导出的构建输出,输出目录,仅包含静态HTML/CSS/JS。适用于没有Node.js服务器的自托管

配置完成后,再build:

JavaScript 日期的奇妙冒险:当 UTC 遇上 el-date-picker

作者 parade岁月
2025年8月17日 17:06

前情提要:程序员小明在使用 Element Plus 的日期选择器时,发现同一个时间用不同方式设置默认值,居然显示出了三个不同的日期。这到底是闹哪样?

🌍 UTC:时间界的"标准答案"

首先,我们来认识一下 UTC(协调世界时),它就像是时间界的"标准答案"。

想象一下,如果全世界的手表都不统一,那该多乱:

  • 北京的程序员说:"我们下午 3 点开会"
  • 纽约的程序员回复:"哪个下午 3 点?"
  • 伦敦的程序员插话:"我这里已经晚上了..."

为了避免这种尴尬,聪明的人类发明了 UTC:

  • UTC = 全世界公认的"标准时间"
  • 可以理解为"0 时区"的时间
  • 所有其他时区都是 UTC ± 几小时
// UTC 就像是时间界的"普通话"
const utcTime = new Date().toISOString(); // "2023-12-25T08:30:45.123Z"
// 末尾的 Z 就是 UTC 的标志,意思是"Zulu Time"(军事术语)

🎭 三种日期格式的"变脸"表演

第一幕:toISOString().split('T')[0] - "我是个好孩子"

const format1 = new Date().toISOString().split('T')[0];
console.log(format1); // "2023-12-25"

这家伙的内心独白

"我看起来很无辜,就是个普通的日期字符串。但是!我其实是基于 UTC 时间的。如果你在东八区,而现在是北京时间凌晨 2 点,我可能会给你显示昨天的日期。惊不惊喜?意不意外?"

问题所在:看起来人畜无害的 "2023-12-25",但 el-date-picker 会按本地时区解释,可能出现日期偏移。

第二幕:toUTCString() - "我很正式但很难懂"

const format2 = new Date().toUTCString();
console.log(format2); // "Mon, 25 Dec 2023 08:30:45 GMT"

这家伙的内心独白

"我是正宗的 UTC 格式,很正式很标准。但是 el-date-picker 看到我就懵了:'这是什么鬼格式?Mon?Dec?GMT?我该怎么解析你?'"

问题所在:格式太"学术",el-date-picker 可能无法正确解析或解析结果不符合预期。

第三幕:dayjs().format("DD MMM YYYY HH:mm:ss") - "我很个性"

const format3 = dayjs(new Date()).format("DD MMM YYYY HH:mm:ss");
console.log(format3); // "25 Dec 2023 16:30:45"

这家伙的内心独白

"我很有个性,用的是自定义格式。我基于本地时间,没有时区信息。el-date-picker 看到我:'你到底是哪个时区的?算了,我随便猜一个吧。'"

问题所在:自定义格式 + 没有时区信息 = 解析结果不可预测。

🎪 时区的"魔术表演"

让我们看看时区是如何"变魔术"的:

// 假设现在是北京时间 2023-12-25 16:30:45 (UTC+8)
const now = new Date();

console.log('北京时间:', now.toString());
// "Mon Dec 25 2023 16:30:45 GMT+0800 (中国标准时间)"

console.log('UTC时间:', now.toUTCString()); 
// "Mon, 25 Dec 2023 08:30:45 GMT"  <- 注意:比北京时间早8小时!

console.log('ISO格式:', now.toISOString());
// "2023-12-25T08:30:45.123Z"  <- Z表示UTC时间

时区换算公式

  • 北京时间 = UTC + 8 小时
  • 纽约时间 = UTC - 5 小时(冬令时)
  • 伦敦时间 = UTC + 0 小时

🚑 救命稻草:el-date-picker 的正确喂食方法

经过一番折腾,我们总结出了 el-date-picker 的正确"喂食"方法:

🥇 金牌方案:直接喂 Date 对象

// 最简单粗暴,不会出错
const dateValue = ref(new Date());

为什么好用

  • Date 对象自带时区信息
  • el-date-picker 天生就认识它
  • 不需要任何格式转换

🥈 银牌方案:完整的 ISO 字符串

// 保留完整的时区信息
const dateValue = ref(new Date().toISOString());

为什么靠谱

  • ISO 格式是国际标准
  • 包含完整的时区信息(那个 Z)
  • 大部分组件都认识

🥉 铜牌方案:dayjs 标准格式

// 使用标准的日期格式
const dateValue = ref(dayjs().format('YYYY-MM-DD'));

适用场景

  • 只需要日期,不需要时间
  • 想要更多控制权

🏆 特别奖:精确控制方案

// 我要的就是今天的开始时间!
const dateValue = ref(dayjs().startOf('day').toDate());

// 注意:dayjs().toDate() 本质上也是 Date 对象,和 new Date() 一样安全
console.log(dayjs().toDate() instanceof Date); // true

🎯 实战代码

<template>
  <div>
    <h3>正确的日期选择器使用姿势</h3>
    
    <!-- 推荐方式 -->
    <el-date-picker
      v-model="dateValue"
      type="date"
      placeholder="选择日期"
    />
  </div>
</template>

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

// 🎉 最推荐:简单直接,不会出错
const dateValue = ref(new Date())

// 其他可行方案:
// const dateValue = ref(dayjs().startOf('day').toDate())  // 精确控制
// const dateValue = ref(new Date().toISOString())         // 完整 ISO
</script>

🤔 思考题

看完这篇文章,你能回答这个问题吗:

如果现在是北京时间 2023-12-25 凌晨 02:00,new Date().toISOString().split('T')[0] 会返回什么?

答案是:"2023-12-24"

为什么?因为:

  1. 北京时间 2023-12-25 02:00 - 8小时 = UTC 时间 2023-12-24 18:00
  2. toISOString() 返回 "2023-12-24T18:00:00.000Z"
  3. split('T')[0] 取日期部分:"2023-12-24"

所以虽然你的本地时间已经是 25 号了,但这个方法返回的还是 24 号!

🎊 总结

记住这个简单的口诀:

日期选择器要喂饱,
Date 对象是最好。
时区问题很头疼,
UTC 标准要记牢!

最后,如果你还在纠结用哪种格式,就直接用 new Date() 吧。简单粗暴,永不出错!

Happy Coding! 愿你的日期永远准确,时区永远正确! 🎈

也是用上webworker了

作者 小遁哥
2025年8月17日 14:08

React 16.8,我自己写的的足球应用,问题是模态框没有立即弹出,反而是等了一会才弹出。

  useImperativeHandle(ref, () => ({
    showModal: (id: string) => {
      const newState = {
        ...state,
        id,
        teamCount: teamOddList.length,
        tableLoading: true,
        open: true,
      };
      setState(newState);
      setAddedItems(new Map());

      // 初始化已存在的奖金项目
      initializeAddedItems(id);

      getOddResultList(newState, teamOddList);
    },
  }));

我开始以为是 React 响应式设计导致的,因为此时 setState 的是异步的,虽然 showModal 中设置了 open 为 true,后续处理不当,还是会导致 open 隐式设置为 false

仔细检查一番发现没有,并且我已经把 newSate 传递过去了,通过注释代码发现,是getOddResultList导致的,其实在之前的写法中我是加了setTimeout的,只不过时间太久了忘记了为什么加。

如今再写一来觉得setTimeout这种解决方式并不好。而且延迟 20ms 是没用的,看起来像是在等计算完成后再显示模态框。

于是我问了下 Trace,他说是因为getOddResultList有大量的同步计算,建议我用 webworker 来处理。

  • 多重嵌套循环 :对每个球队的赔率信息进行多维度组合计算(胜平负、让球、比分、进球数、半场等)
  • 指数级复杂度 :通过递归函数 getTeamCombinationList 和 againForEach 生成所有可能的投注组合
  • 大量数据处理 :每个组合都需要计算赔率乘积,并进行排序
  • JavaScript 是单线程的,同步计算会完全阻塞主线程
  • 模态框的 open 状态虽然已设置为 true ,但 React 无法进行重新渲染
  • 用户界面会出现"卡顿",模态框无法立即显示

这个函数确实会消耗大量的时间,因为有 4 只球队会产生 9 百万种结果。

使用 webworker 的话,需要将 js 文件放到 public 目录下,通过onmessage来接收消息,通过postMessage来发送消息。

self.onmessage = function (e) {
  const { teamOddList } = e.data;
  try {
    const result = calculateOddResultList(teamOddList);
    self.postMessage({ success: true, data: result });
  } catch (error) {
    self.postMessage({ success: false, error: error.message });
  }
};

在 jsx 文件中使用 webworker

useEffect(() => {
    // 创建 Web Worker

    workerRef.current = new Worker('/oddResultWorker.js');
    workerRef.current.onmessageerror = (e) => {
      console.error('Worker message error:', e);
    };

    // 监听 Worker 消息
    workerRef.current.onmessage = (e) => {
      const { success, data, error } = e.data;
      if (success) {
        allOddResultListRef.current = data;
        setState((preState) => ({
          ...preState,
          total: data.length,
          tableLoading: false,
          oddResultList: data.slice(
            (preState.currentPage - 1) * pageSize,
            preState.currentPage * pageSize
          ),
        }));
      } else {
        console.error('Worker error:', error);
      }
    };

    return () => {
      if (workerRef.current) {
        workerRef.current.terminate();
      }
    };
  }, []);

调用时

    showModal: (id: string, teamOddList: Array<NFootball.ITeamRecordOdds>) => {
      ...
      setAddedItems(new Map());
      initializeAddedItems(id);

      workerRef.current.postMessage({ teamOddList });
    },

React 牵手 Ollama:本地 AI 服务对接实战指南

作者 LeonGao
2025年8月17日 14:03

在这个 AI 大模型如雨后春笋般涌现的时代,让前端应用与本地大模型来一场 “亲密接触”,就像给你的 React 应用装上一个 “本地智囊团”。今天,我们就来实现一个看似高深实则简单的需求:用 React 对接本地 Ollama 服务。这就好比教两个素未谋面的朋友打招呼,Ollama 是守在本地的 “AI 达人”,React 则是活泼的 “前端信使”,我们要做的就是搭建它们之间的沟通桥梁。

底层原理:通信的奥秘

在开始编码前,我们得先搞明白这两个 “朋友” 是如何交流的。Ollama 作为本地运行的大模型服务,会在你的电脑上开启一个 “通信窗口”—— 也就是 HTTP 服务器,默认情况下这个窗口的地址是 http://localhost:11434。而 React 应用要做的,就是通过 HTTP 协议向这个窗口发送 “消息”(请求),并等待 “回复”(响应)。

这就像你去餐厅吃饭,Ollama 是后厨的厨师,React 是前厅的服务员,http://localhost:11434 就是厨房的传菜口。服务员把顾客的订单(请求)通过传菜口递给厨师,厨师做好菜后再通过传菜口把菜(响应)送回给服务员。

准备工作:工具就位

在正式开始前,我们需要准备好 “食材” 和 “厨具”:

  1. 安装 Ollama:去 Ollama 官网下载并安装,这一步就像把厨师请到厨房里。安装完成后,打开命令行,输入 ollama run llama3 来启动一个基础模型,这里我们用 llama3 作为示例,你也可以换成其他喜欢的模型。
  1. 创建 React 应用:如果你还没有 React 项目,可以用 Create React App 快速创建一个,命令是 npx create-react-app ollama-demo,这就像搭建好前厅的场地。

代码实现:搭建沟通桥梁

一切准备就绪,现在我们来编写核心代码,实现 React 与 Ollama 的通信。

首先,我们需要一个发送请求的函数。在 React 组件中,我们可以用 fetch API 来发送 HTTP 请求到 Ollama 的 API 端点。Ollama 的聊天接口是 http://localhost:11434/api/chat,我们需要向这个接口发送包含模型名称和消息内容的 JSON 数据。

import { useState } from 'react';
function OllamaChat() {
  const [message, setMessage] = useState('');
  const [response, setResponse] = useState('');
  const sendMessage = async () => {
    try {
      // 构建请求体,指定模型和消息
      const requestBody = {
        model: 'llama3',
        messages: [{ role: 'user', content: message }],
        stream: false // 不使用流式响应,等待完整回复
      };
      // 发送 POST 请求到 Ollama 的聊天接口
      const response = await fetch('http://localhost:11434/api/chat', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(requestBody),
      });
      // 解析响应数据
      const data = await response.json();
      
      // 提取并显示 AI 的回复
      if (data.message && data.message.content) {
        setResponse(data.message.content);
      }
    } catch (error) {
      console.error('与 Ollama 通信出错:', error);
      setResponse('抱歉,无法连接到 AI 服务,请检查 Ollama 是否正在运行。');
    }
  };
  return (
    <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
      <h2>React × Ollama 聊天 Demo</h2>
      <div style={{ marginBottom: '20px' }}>
        <input
          type="text"
          value={message}
          onChange={(e) => setMessage(e.target.value)}
          placeholder="输入你的问题..."
          style={{ width: '70%', padding: '8px', marginRight: '10px' }}
        />
        <button onClick={sendMessage} style={{ padding: '8px 16px' }}>
          发送
        </button>
      </div>
      <div style={{ border: '1px solid #ccc', padding: '10px', borderRadius: '4px' }}>
        <h3>AI 回复:</h3>
        <p>{response}</p>
      </div>
    </div>
  );
}
export default OllamaChat;

代码解析:庖丁解牛

让我们来仔细看看这段代码的工作原理,就像拆解一台精密的机器。

  1. 状态管理:我们用 useState 钩子创建了两个状态变量,message 用来存储用户输入的消息,response 用来存储 AI 的回复。这就像两个储物盒,分别存放要发送的消息和收到的回复。
  1. 发送消息函数:sendMessage 是核心函数,它通过 fetch 发送请求到 Ollama。请求体中指定了要使用的模型(llama3)和用户的消息。这里的 stream: false 表示我们希望一次性收到完整的回复,而不是逐字接收。
  1. 处理响应:当 Ollama 处理完请求后,会返回一个 JSON 格式的响应。我们从中提取出 AI 的回复内容,并更新 response 状态,这样页面上就会显示出 AI 的回答了。
  1. 错误处理:如果通信过程中出现错误(比如 Ollama 没有运行),我们会捕获错误并显示友好的提示信息。

运行测试:见证奇迹的时刻

现在,让我们来测试一下这个 Demo 是否能正常工作。

  1. 确保 Ollama 正在运行:打开命令行,输入 ollama run llama3,等待模型加载完成。
  1. 启动 React 应用:在项目目录下运行 npm start,打开浏览器访问 http://localhost:3000
  1. 发送消息:在输入框中输入一个问题,比如 “你好,Ollama!”,然后点击 “发送” 按钮。稍等片刻,你应该就能看到 AI 的回复了。

如果一切顺利,你会看到 React 应用和 Ollama 成功 “牵手”,完成了一次愉快的对话。如果遇到问题,先检查 Ollama 是否正在正常运行,模型名称是否正确,网络连接是否通畅。

进阶思考:拓展可能性

这个简单的 Demo 只是一个开始,就像我们只是搭建了一座简陋的小桥。你可以基于这个基础进行很多拓展:

  1. 实现流式响应:将 stream 设置为 true,然后处理流式响应,让 AI 的回复像打字一样逐字显示,提升用户体验。
  1. 增加聊天历史:用状态管理存储聊天记录,让对话可以上下文连贯。
  1. 切换不同模型:在界面上增加模型选择功能,让用户可以根据需要切换不同的 Ollama 模型。
  1. 优化错误处理:增加更详细的错误提示,帮助用户排查问题。

总结:本地 AI 的魅力

通过这个 Demo,我们展示了 React 对接本地 Ollama 服务的全过程。相比于调用云端的 AI 服务,本地部署的 Ollama 具有隐私性好、响应速度快、无需网络连接等优点,就像把 AI 助手请到了自己家里,随时可以交流。

希望这篇文章能帮助你理解 React 与本地 AI 服务对接的原理和方法。现在,你可以基于这个基础,开发出更强大、更有趣的本地 AI 应用了。让我们一起探索前端与 AI 结合的无限可能吧!

Next.js 全栈开发基础:在 pages/api/*.ts 中创建接口的艺术

作者 LeonGao
2025年8月17日 13:57

在 Web 开发的世界里,前端与后端就像一对需要默契配合的舞者。前端负责优雅地展示数据,后端则默默在幕后准备数据,而接口就是它们之间传递信号的乐谱。在 Next.js 的舞台上,pages/api/*.ts就是谱写这份乐谱的最佳创作室。今天,我们就来揭开在 Next.js 中创建接口的神秘面纱,用 TypeScript 为你的全栈应用搭建起高效的数据桥梁。

接口的本质:数据交换的高速公路

在深入技术细节之前,让我们先理解接口的本质。想象你在餐厅点餐,你(前端)告诉服务员(接口)想要什么,服务员把需求传达给厨房(数据库 / 业务逻辑),然后把做好的食物(数据)端给你。这个过程中,服务员就是接口,负责规范请求格式、处理业务逻辑并返回结果。

在计算机科学中,接口本质上是客户端与服务器之间约定的数据交换格式和规则。Next.js 的 API 路由之所以强大,是因为它允许我们在同一个项目中同时编写前端页面和后端接口,就像在同一个屋檐下同时拥有餐厅大堂和厨房,大大提高了开发效率。

初探 pages/api:Next.js 的接口魔法

Next.js 的 API 路由基于一个简单而强大的约定:在pages/api目录下创建的文件会自动成为 API 接口。这个机制背后其实是 Next.js 的文件系统路由在起作用,当服务器启动时,它会扫描pages/api目录下的所有文件,为每个文件创建对应的路由端点。

比如我们创建pages/api/hello.ts文件,访问http://localhost:3000/api/hello就能调用这个接口。这种设计就像给每个接口分配了独立的办公室,它们互不干扰又能协同工作。

第一个接口:Hello World 的进阶版

让我们从经典的 Hello World 开始,创建一个能返回个性化问候的接口。在pages/api目录下新建greet.ts文件,输入以下代码:

export default function handler(req, res) {
  // 从请求中获取查询参数name
  const { name = "World" } = req.query;
  
  // 设置响应状态码为200(成功)
  res.status(200).json({ 
    message: `Hello, ${name}!`,
    timestamp: new Date().toISOString()
  });
}

这个接口做了三件事:

  1. 从请求的查询参数中获取 name,如果没有提供则默认使用 "World"
  1. 设置 HTTP 响应状态码为 200,表示请求成功
  1. 返回一个 JSON 对象,包含问候消息和当前时间戳

运行你的 Next.js 应用,访问http://localhost:3000/api/greet?name=Next.js,你会看到类似这样的响应:

{
  "message": "Hello, Next.js!",
  "timestamp": "2025-08-17T12:34:56.789Z"
}

处理不同的 HTTP 方法:接口的多面手

一个健壮的接口应该能处理不同的 HTTP 方法,就像一个多才多艺的演员能胜任不同的角色。常见的 HTTP 方法有 GET(获取数据)、POST(创建数据)、PUT(更新数据)和 DELETE(删除数据)。

让我们创建一个简单的任务管理接口,支持 GET 和 POST 方法:

// pages/api/tasks.ts
let tasks = [
  { id: 1, title: "学习Next.js", completed: false },
  { id: 2, title: "创建API接口", completed: true }
];
export default function handler(req, res) {
  // 获取请求方法
  const { method } = req;
  switch (method) {
    case 'GET':
      // 处理GET请求:返回所有任务
      res.status(200).json(tasks);
      break;
    case 'POST':
      // 处理POST请求:创建新任务
      const { title } = req.body;
      
      // 验证请求数据
      if (!title) {
        return res.status(400).json({ error: "任务标题不能为空" });
      }
      
      // 创建新任务
      const newTask = {
        id: tasks.length + 1,
        title,
        completed: false
      };
      
      // 添加到任务列表
      tasks.push(newTask);
      
      // 返回创建的任务,状态码201表示资源创建成功
      res.status(201).json(newTask);
      break;
    default:
      // 处理不支持的方法
      res.setHeader('Allow', ['GET', 'POST']);
      res.status(405).end(`方法 ${method} 不被允许`);
  }
}

这个接口展示了如何根据不同的 HTTP 方法执行不同的操作:

  • 当使用 GET 方法访问时,它返回所有任务列表
  • 当使用 POST 方法并发送包含 title 的 JSON 数据时,它创建一个新任务
  • 当使用不支持的方法(如 PUT 或 DELETE)时,它返回 405 错误

你可以使用工具如 Postman 或 curl 来测试这个接口:

# 测试GET请求
curl http://localhost:3000/api/tasks
# 测试POST请求
curl -X POST -H "Content-Type: application/json" -d '{"title":"新任务"}' http://localhost:3000/api/tasks

接口参数处理:精准获取请求数据

在实际开发中,我们经常需要从不同位置获取请求数据。Next.js 的 API 路由提供了多种方式来获取这些数据,就像有多个入口可以进入一个建筑:

  1. 查询参数(Query Parameters) :位于 URL 中?后面的键值对,通过req.query获取
  1. 路径参数(Path Parameters) :URL 路径中的动态部分,通过文件名中的[param]定义
  1. 请求体(Request Body) :POST、PUT 等方法发送的数据,通过req.body获取

让我们创建一个支持路径参数的接口,用于获取单个任务:

// pages/api/tasks/[id].ts
// 假设tasks数组与前面的例子相同
let tasks = [
  { id: 1, title: "学习Next.js", completed: false },
  { id: 2, title: "创建API接口", completed: true }
];
export default function handler(req, res) {
  const { id } = req.query;
  // 将id转换为数字
  const taskId = parseInt(id, 10);
  
  // 验证id是否有效
  if (isNaN(taskId)) {
    return res.status(400).json({ error: "无效的任务ID" });
  }
  
  // 查找任务
  const task = tasks.find(t => t.id === taskId);
  
  if (task) {
    res.status(200).json(task);
  } else {
    res.status(404).json({ error: "任务不存在" });
  }
}

现在,访问http://localhost:3000/api/tasks/1会返回 ID 为 1 的任务,而访问http://localhost:3000/api/tasks/99会返回 404 错误。

错误处理:接口的安全网

就像现实生活中需要应急预案一样,接口也需要完善的错误处理机制。一个好的错误处理策略应该:

  • 返回适当的 HTTP 状态码
  • 提供清晰的错误信息
  • 避免暴露敏感信息

让我们改进前面的任务接口,添加更完善的错误处理:

// pages/api/tasks/[id].ts(改进版)
let tasks = [
  { id: 1, title: "学习Next.js", completed: false },
  { id: 2, title: "创建API接口", completed: true }
];
export default function handler(req, res) {
  try {
    const { id } = req.query;
    const taskId = parseInt(id, 10);
    
    if (isNaN(taskId)) {
      // 400 Bad Request:请求参数无效
      return res.status(400).json({ 
        error: "无效的任务ID",
        details: "ID必须是数字"
      });
    }
    
    const task = tasks.find(t => t.id === taskId);
    
    if (task) {
      // 200 OK:请求成功
      res.status(200).json(task);
    } else {
      // 404 Not Found:资源不存在
      res.status(404).json({ 
        error: "任务不存在",
        details: `没有ID为${taskId}的任务`
      });
    }
  } catch (error) {
    // 500 Internal Server Error:服务器内部错误
    console.error("处理请求时出错:", error);
    res.status(500).json({ 
      error: "服务器内部错误",
      details: "请稍后再试"
    });
  }
}

这个改进版接口使用 try-catch 块捕获可能的错误,并为不同类型的错误返回相应的状态码和详细信息,同时避免将内部错误直接暴露给客户端。

接口的性能考量:让数据流动更快

随着应用规模的增长,接口的性能变得越来越重要。以下是一些提高 API 路由性能的小贴士:

  1. 数据缓存:对于不经常变化的数据,可以使用缓存减少重复计算
  1. 请求验证:尽早验证请求数据,避免不必要的处理
  1. 分页处理:对于大量数据,使用分页减少数据传输量
  1. 异步处理:对于耗时操作,考虑使用异步处理避免阻塞

让我们实现一个带分页功能的任务列表接口:

// pages/api/tasks/paginated.ts
let tasks = [
  // 假设这里有很多任务...
  { id: 1, title: "任务1", completed: false },
  { id: 2, title: "任务2", completed: true },
  // ...更多任务
];
export default function handler(req, res) {
  try {
    // 获取分页参数,默认页码为1,每页10条
    const { page = 1, limit = 10 } = req.query;
    const pageNum = parseInt(page, 10);
    const limitNum = parseInt(limit, 10);
    
    // 验证分页参数
    if (isNaN(pageNum) || isNaN(limitNum) || pageNum < 1 || limitNum < 1) {
      return res.status(400).json({ 
        error: "无效的分页参数",
        details: "页码和每页数量必须是正整数"
      });
    }
    
    // 计算总页数
    const totalPages = Math.ceil(tasks.length / limitNum);
    
    // 计算起始索引
    const startIndex = (pageNum - 1) * limitNum;
    
    // 获取当前页的任务
    const paginatedTasks = tasks.slice(startIndex, startIndex + limitNum);
    
    res.status(200).json({
      data: paginatedTasks,
      pagination: {
        total: tasks.length,
        page: pageNum,
        limit: limitNum,
        totalPages
      }
    });
  } catch (error) {
    console.error("分页查询出错:", error);
    res.status(500).json({ error: "服务器内部错误" });
  }
}

这个接口支持通过page和limit参数控制返回的数据量,减轻了服务器和网络的负担。

部署与注意事项:让接口飞向生产环境

当你的接口准备好部署到生产环境时,有几个重要的注意事项:

  1. 环境变量:敏感信息如数据库连接字符串应该使用环境变量,而不是硬编码在代码中
  1. CORS 设置:如果你的前端和后端不在同一个域名下,需要配置跨域资源共享(CORS)
  1. 速率限制:为了防止滥用,考虑添加速率限制功能
  1. 日志记录:添加适当的日志记录以便调试和监控

在 Next.js 中配置 CORS 非常简单,你可以使用cors中间件:

// pages/api/with-cors.ts
import cors from 'cors';
// 初始化cors中间件
const corsMiddleware = cors({
  origin: process.env.NEXT_PUBLIC_FRONTEND_URL || '*',
  methods: ['GET', 'POST', 'PUT', 'DELETE']
});
// 辅助函数:将中间件转换为Promise
function runMiddleware(req, res, fn) {
  return new Promise((resolve, reject) => {
    fn(req, res, (result) => {
      if (result instanceof Error) {
        return reject(result);
      }
      return resolve(result);
    });
  });
}
export default async function handler(req, res) {
  // 应用CORS中间件
  await runMiddleware(req, res, corsMiddleware);
  
  // 处理请求
  res.status(200).json({ message: "这个接口支持跨域请求!" });
}

总结:接口开发的艺术与科学

在 Next.js 中创建 API 接口就像在构建一座连接前端和后端的桥梁,它需要扎实的技术基础,也需要对用户需求的深刻理解。通过pages/api/*.ts文件,我们可以快速创建功能完善的接口,处理各种 HTTP 方法,获取不同来源的请求数据,并返回结构化的响应。

记住,一个好的接口应该是清晰、健壮、高效且安全的。它不仅要能正确处理正常情况,还要能优雅地应对错误;不仅要能满足当前需求,还要为未来的扩展留有余地。

随着你对 Next.js API 路由的深入了解,你可以尝试更高级的功能,如数据库集成、身份验证、文件上传等。全栈开发的世界充满了可能性,而接口就是打开这个世界的钥匙。现在,拿起这把钥匙,开始构建你的全栈应用吧!

p5.js 3D 形状 "预制工厂"——buildGeometry ()

2025年8月17日 13:08

点赞 + 关注 + 收藏 = 学会了

如果你已经会用box()sphere()画简单 3D 形状,想组合它们做出复杂模型,又担心画面卡顿,那么buildGeometry()就是你的 "性能救星"。这个函数能把多个简单形状 "焊接" 成一个自定义 3D 模型,让绘制效率飙升。

什么是 buildGeometry ()?

buildGeometry()是 p5.js 中用于组装复杂 3D 模型的工具函数。它的核心作用就像 "预制构件厂":

  • 把多个简单 3D 形状(比如box()sphere())组合成一个完整的p5.Geometry对象(可以理解为 "自定义 3D 零件");
  • 这个 "零件" 只需要在程序启动时制作一次,之后每次绘制直接调用即可,大幅减少重复计算;
  • 必须在WebGL 模式下使用(和所有 3D 函数一样)。

buildGeometry()就是来解决这个问题的:它能把多个简单 3D 形状 “打包” 成一个p5.Geometry对象,只需创建一次,之后反复绘制都不会卡顿。就像快递打包,把多个小包裹捆成一个大包裹,搬运起来更高效~

基础用法

buildGeometry打包一个球体,然后绘制它。

01.png

let myShape; // 存储打包好的3D对象

function setup() {
  // 开启WebGL模式(3D绘图必备)
  createCanvas(400, 400, WEBGL);
  // 用buildGeometry创建3D对象,回调函数是makeShape
  myShape = buildGeometry(makeShape);
}

function draw() {
  background(200); // 灰色背景
  orbitControl(); // 允许鼠标拖动旋转视角
  lights(); // 添加光照(3D物体需要光照才看得见)
  model(myShape); // 绘制打包好的3D对象
}

// 回调函数:定义要打包的形状
function makeShape() {
  sphere(50); // 画一个半径50的球体
}

旋转的几何花朵

buildGeometry组合多个锥体,形成一朵 “花”,然后让它随时间旋转并变色,展示高性能复杂 3D 动画的实现。

02.gif

let flower;
let hueValue = 0; // 色相值(用于颜色变化)

function setup() {
  createCanvas(600, 600, WEBGL);
  // 创建几何花朵
  flower = buildGeometry(makeFlower);
}

function draw() {
  background(0); // 黑色背景
  orbitControl(); // 允许鼠标旋转视角
  lights(); // 光照
  
  // 颜色随时间变化(HSB模式:色相、饱和度、亮度)
  colorMode(HSB);
  fill(hueValue % 360, 80, 90);
  hueValue += 0.5;
  
  // 整体旋转(X和Y轴同时转,更有动感)
  // rotateX(frameCount * 0.005);
  rotateY(frameCount * 0.008);
  
  model(flower); // 绘制花朵
}

// 构建花朵形状的回调函数
function makeFlower() {
  // 中心球体
  sphere(15);
  
  // 周围的“花瓣”:12个锥体
  for (let i = 0; i < 12; i++) {
    push();
    // 绕Y轴均匀分布(360度/12=30度一个)
    rotateY(i * PI / 6);
    // 沿Z轴向外移动
    translate(0, 0, 40);
    // 锥体:底面半径10,高30,朝上
    cone(10, 30);
    pop();
  }
  
}

以上就是本文的全部内容啦,想了解更多 P5.js 用法欢迎关注 《P5.js中文教程》

也可以➕我 green bubble 吹吹水咯

qrcode.jpeg

点赞 + 关注 + 收藏 = 学会了

158.gif

手把手教你入门 MCP:模型上下文协议与 Trae IDE 中的实践

作者 前端嘿起
2025年8月17日 12:42

✨点击上方关注☝️,追踪不迷路!

一、什么是 MCP?

MCP(Model Context Protocol)是一种开放协议,它标准化了应用程序如何向大型语言模型(LLMs)提供上下文。可以将 MCP 想象成 AI 应用程序的 USB-C 接口——正如 USB-C 提供了标准化的方式来连接设备与各种外设和配件一样,MCP 提供了标准化的方式来连接 AI 模型与不同的数据源和工具。

MCP 使你能够在 LLM 之上构建代理和复杂工作流,并将你的模型与外部世界连接起来。它提供了一套标准规则,让 AI 模型能够理解如何获取上下文信息、如何与外部工具交互,以及如何处理和利用返回的结果。

二、为什么要学习 MCP?

MCP 为 AI 生态系统带来了许多重要价值:

  1. 丰富的预构建集成:MCP 提供了一系列预构建的集成,你的 LLM 可以直接接入使用
  2. 标准化的自定义集成方式:为 AI 应用程序提供了标准化的方式来构建自定义集成
  3. 开放的协议:每个人都可以自由实现和使用 MCP 协议
  4. 灵活性:允许在不同应用之间切换并携带你的上下文

对于开发者和 AI 用户来说,学习 MCP 意味着你可以充分利用 AI 模型与外部工具的协同能力,构建更加强大和实用的 AI 应用。

三、MCP 的基本工作原理

MCP 协议的核心工作原理是建立 AI 模型与外部数据源、工具之间的标准化通信桥梁。以下是 MCP 的基本工作流程:

image.png

  1. 上下文获取:MCP 服务从各种数据源收集上下文信息
  2. 标准化处理:将上下文信息转换为标准格式
  3. 模型交互:将标准化的上下文传递给 AI 模型
  4. 工具调用:根据模型需求,通过 MCP 协议调用外部工具
  5. 结果返回:将工具执行结果格式化后返回给模型

在 Trae IDE 这样的开发环境中,AI 助手正是通过 MCP 协议来调用代码搜索、文件编辑、命令执行等各种功能,从而更好地理解代码库上下文,为开发者提供更精准的帮助。

四、如何在 Trae IDE 中配置 Gitee MCP 服务

Gitee 提供了 MCP 服务,允许 Trae IDE 通过 MCP 协议与 Gitee 平台进行交互。下面是在 Trae 中配置 Gitee MCP 服务的步骤:

1. 获取 Gitee 访问令牌

首先,你需要在 Gitee 上创建一个访问令牌,用于 Trae IDE 与 Gitee MCP 服务之间的身份验证:

  1. 登录你的 Gitee 账号
  2. 进入「设置」→「安全设置」→「私人令牌」
  3. 点击「生成新令牌」,选择需要的权限(如 repouser_info 等)
  4. 保存生成的令牌,这将用于后续配置

2. 在 Trae IDE 中配置 Gitee MCP 服务

在 Trae IDE 中,你可以通过以下 JSON 配置来连接 Gitee MCP 服务:

{
  "mcpServers": {
    "gitee": {
      "url": "https://api.gitee.com/mcp",
      "headers": {
        "Authorization": "Bearer <私人令牌>"
      }
    }
  }
}

其中,<私人令牌> 是你在第一步中获取的 Gitee 访问令牌。你可以将此配置添加到 Trae IDE 的 MCP 服务配置文件中。

3. 测试 Gitee MCP 服务连接

在 Trae IDE 中测试 Gitee MCP 服务连接的实际操作步骤如下:

  1. 打开 Trae IDE 的 MCP 服务配置面板
  2. 选择你刚刚配置的 Gitee MCP 服务
  3. 点击服务配置旁边的「开启连接」按钮
  4. Trae IDE 会自动向 Gitee MCP 服务发送一个简单的请求来验证连接是否正常
  5. 观察测试结果:如果连接成功,你会看到服务会有一个绿色勾,并可能显示一些基本的用户信息摘要;如果连接失败,会有红色感叹号,点击会显示具体的错误信息

通过这种方式,你可以确认 Gitee MCP 服务配置是否正确,以及能否正常与 Gitee 平台通信。

4. 开始使用

在trae对话中输入@切换到对应的builder with MCP 智能体,并输入“帮我查询我的 gitee 有多少个仓库?” 如下图

iShot_2025-08-17_12.28.41.png

五、在 Trae IDE 中配置 MCP 服务

在 Trae IDE 中,你可以通过两种方式添加和配置 MCP 服务:从市场添加或手动添加。下面将详细介绍这两种配置方法。

5.1 从市场添加 MCP 服务

Trae IDE 提供了 MCP 市场,其中包含了社区中热门的 MCP 服务,你可以直接从中选择并添加:

操作步骤:

  1. 在 AI 侧边对话框的右上角,点击「设置」图标
  2. 选择「MCP」页签
  3. 点击「+ 添加 MCP Servers」按钮;或点击右上角的「+ 添加」按钮,然后在菜单中选择「从市场添加」
  4. 在打开的 MCP 市场中,浏览并找到你需要的 MCP 服务
  5. 点击该服务右侧的「+」按钮
  6. 在弹出的配置窗口中,填写必要的配置信息

注意事项:

  • 对于标记为「Local」的 MCP 服务,需要在本地安装 NPX 或 UVX 后才能使用
  • 配置内容中的环境变量信息(如 API Key、Token、Access Key 等字段)须替换为你的真实信息
  • 填写完成后,点击「确认」按钮保存配置

5.2 手动添加 MCP 服务

如果你需要添加的 MCP 服务不在市场中,或者你想使用自己开发的 MCP 服务,可以通过手动添加的方式:

操作步骤:

  1. 在 AI 侧边对话框的右上角,点击「设置」图标,然后在菜单中选择「MCP」
  2. 进入 MCP 配置界面后,点击右上角的「+ 添加」按钮,然后在菜单中选择「手动添加」
  3. 在弹出的「手动配置」窗口中,填写 MCP 服务的详细配置

配置建议:

  • 优先使用 NPX 或 UVX 配置方式
  • 如果你希望添加一个全新的 MCP 服务,可以参考 MCP 官方文档中的协议规范
  • 配置完成后,点击「确认」按钮保存设置

5.3 配置系统环境

为确保 MCP 服务能够正常启动和运行,你可能需要安装以下工具:

5.3.1 安装 Node.js

NPX 工具依赖于 Node.js 环境(版本需大于等于 18):

  1. 前往 Node.js 官网,下载并安装 Node.js 18 或更高版本
  2. 安装完成后,在终端中运行以下命令确认安装成功:
    node -v
    npx -v
    
  3. 若安装成功,终端会输出相应的版本号
  4. 重启 Trae IDE,使 Node.js 环境生效

5.3.2 安装 uvx(Python 工具)

uvx 是基于 Python 的快速执行工具,用于运行 Python 脚本的 MCP 服务:

  1. 前往 Python 官网,下载并安装 Python 3.8 或更高版本
  2. 安装完成后,在终端中运行以下命令确认安装成功:
    python --version
    
  3. 执行以下命令安装 uv(包含 uvx):
    • macOS / Linux 系统:
      curl -LsSf https://astral.sh/uv/install.sh | sh
      
    • Windows 系统(PowerShell):
      powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
      
  4. 安装完成后,运行以下命令验证:
    uvx --version
    

5.3.3 安装 Docker(可选)

如果你需要使用 GitHub MCP Server,则需要安装 Docker:

macOS 系统:

  • 手动安装:前往 Docker 官方网站,下载适用于 macOS 的 Docker Desktop 安装包(.dmg 文件)
  • 使用 Homebrew 安装:brew install --cask docker

Windows 系统:

  • 前往 Docker 官方网站,下载并安装 Docker Desktop,确保勾选「Use the WSL 2 based engine」选项

验证安装:

 docker --version
docker info

5.4 MCP 服务传输类型

在 Trae IDE 中,MCP Server 支持三种传输类型:

  1. stdio 传输:通过标准输入/输出进行通信,适用于本地运行的 MCP 服务
  2. SSE 传输:Server-Sent Events 传输,支持服务端向客户端推送事件
  3. Streamable HTTP 传输:支持流式 HTTP 通信的传输方式

根据你选择的 MCP 服务类型,Trae IDE 会自动选择合适的传输方式。

六、MCP 开发资源

如果你想深入了解 MCP 协议或开发自己的 MCP 服务,可以参考以下资源:

  1. 官方 SDK:MCP 提供了多种语言的官方 SDK,包括 Python、JavaScript、Java 等,这些 SDK 处理了协议细节,让你可以专注于构建功能
  2. 协议规范文档:详细说明了 MCP 协议的请求和响应格式、数据类型等技术细节
  3. 示例代码库:包含了各种 MCP 服务和客户端的示例实现
  4. 社区论坛:可以与其他开发者交流 MCP 开发经验和问题

七、总结

MCP(模型上下文协议)是连接 AI 模型与外部世界的重要桥梁,它通过标准化的方式,让 AI 模型能够获取上下文信息并调用各种外部工具。在 Trae IDE 中,AI 助手正是通过 MCP 协议来提供代码搜索、文件编辑、命令执行等强大功能。

通过本文的介绍,你应该已经了解了 MCP 的基本概念、工作原理以及在 Trae IDE 中配置和使用 Gitee MCP 服务的方法。随着 AI 技术的不断发展,MCP 协议的重要性将会越来越凸显,它将为 AI 应用提供更广阔的可能性。

如果你想进一步学习 MCP,可以访问官方文档或加入 MCP 社区,与其他开发者一起探索 MCP 的更多可能性。


延伸阅读:

最后,创作不易请允许我插播一则自己开发的小程序广告,感兴趣可以访问体验:

【「合图图」产品介绍】

  • 主要功能为:本地添加相册图片进行无限长图高清拼接,各种布局拼接等

  • 安全:无后台服务无需登录,全程设备本地运行,隐私100%安全;

  • 高效:自由布局+实时预览,效果所见即所得;

  • 高清:秒生高清拼图,一键保存相册。

  • 立即体验 →合图图 或微信小程序搜索「合图图」

如果觉得本文有用,欢迎点个赞👍和收藏⭐支持我吧!

借助CSS实现自适应屏幕边缘的tooltip

作者 XboxYan
2025年8月17日 13:23

欢迎关注我的公众号:前端侦探

tooltip是一个非常常见的交互,一般用于补充文案说明。比如下面这种(蓝色边框表示屏幕边缘)

image-20250523223912070

通常tooltip都会有一个固定的方向,比如top表示垂直居中向上。

但是,如果提示文案比较多,提示区域右比较靠近屏幕边缘,就可能出现这种情况

image-20250523224309231

直接超出屏幕!这很显然是不能接受的。

你可能会想到改变一下对齐方向,比如top-right,但是这里的文案可能是不固定的,也就是会出现这样

image-20250523224706671

嗯...感觉无论怎么对齐都会有局限。那么如何解决呢?一起看看吧

一、理想中的自适应对齐

我们先想想,最完美的对齐是什么样的。

其实没那么复杂,就分两种情况,一个居左,一个居右

1.居左

正常情况下,就是垂直居中朝上

image-20250523225707868

如果提示文本比较多,那就靠左贴近文本容器对齐

image-20250523225826347

如果提示文本继续增加,那就整行换行,并且不超过文本容器

image-20250523230041333

2. 居右

正常情况下,也是垂直居中朝上

image-20250523230849249

如果提示文本比较多,那就靠右贴近文本容器对齐

image-20250523230936187

如果提示文本继续增加,也是整行换行,并且不超过文本容器

image-20250523231035167

那么如何实现这样的对齐方式呢?

二、左自适应对齐的思路

我们先看第一种情况,看似好像有3种对齐方式,而且还要监测是否到了边界,好像挺复杂。其实换个角度,其实是这样一种规则

  1. 当内容较少时,居中对齐
  2. 当内容较多时,居左对齐
  3. 当内容多到换行时,有一个最大宽度

既然涉及到了对齐,那就有对齐的容器和被对齐的对象。

我们可以想象一个虚拟容器,以对齐中心(下图问号图标)向两边扩展,一直到边界处,如下所示(淡蓝色区域)

image-20250523233837924

假设HTML如下

<span class="tooltip" title="提示"></span>

当气泡文本比较少时,可以通过文本对齐实现居中,气泡可以直接通过伪元素实现

.tooltip{
  width: 50px; /*虚拟容器宽度,暂时先固定 */
  text-align:center;
}
.tooltip::before{
  content: attr(title);
  display: inline-block;
  color: #fff;
  background-color: #000;
  padding: .5em 1em;
  border-radius: 8px;
  box-sizing: border-box;
}
/*居中箭头*/
.tooltip::after{
  content: '';
  position: absolute;
  width: 1em;
  height: .6em;
  background: #000;
  clip-path: polygon(0 0, 100% 0, 50% 100%);
  top: 0;
  left:0;
  right:0;
  margin: 0 auto;
  transform: translateY(-150%)
}

使用文本居中,也就是text-align: center有个好处,当文本不超过容器时,居中展示,就如同上图展示一样。

当文本比较多时,默认会换行,效果如下

image-20250524112135696

这样应该很好理解吧。

我们需要气泡里的文本在多行时居左,可以直接给气泡设置居左对齐

.tooltip::before{
  /*...*/
  text-align: left;
}

效果如下

image-20250524112333960

这样就实现了单行居中,多行居左的效果了。

现在还有一个问题,如何在气泡文本较多时,不被对齐容器束缚呢?

首先可以想到的是禁止换行,也就是

.tooltip::before{
  /*...*/
  white-space: nowrap
}

这样在文本不超过一行时确实可以

image-20250524112641734

看,已经突破了容器束缚。但是文本继续增加时,也会出现无法换行的问题

image-20250524112800984

我们可以想一想,还有什么方式可以控制换行呢?

这里,我们需要设置宽度为最大内容宽度,相当于文本有多少,文本容器就有多宽

.tooltip::before{
  /*...*/
  width: max-content
}

看似好像和不换行一样

image-20250524112800984

实则不然,我们并没用禁止换行。只要给一个最大宽度,立马就换行了

.tooltip::before{
  /*...*/
  width: max-content;
  max-width: 300px;
}

效果如下

image-20250524113318010

是不是几乎实现了我们想要的效果了?

不过,这里涉及了两个需要动态计算的宽度,一个是虚拟容器宽度,还有一个是外层最大宽度,

image-20250524152008282

下面看如何实现

三、借助JS计算所需宽度

现如今,外层的最大宽度倒是可以通过容器查询获得,但内部的虚拟容器宽度还无法直接获取,只能借助JS了。

不过我们这里可以先只计算左侧偏移,也就是一半的宽度

image-20250524155257344

具体实现如下

//问号中心到左侧距离
const x = this.offsetLeft - 8
// 问号的宽度
const w = this.clientWidth
// 外层整行文本容器宽度
const W = this.offsetParent.clientWidth - 32
// 左侧偏移
this.style.setProperty('--x', x + 'px')
// 外层文本容器宽度(气泡最大宽度)
this.style.setProperty('--w', W + 'px')

然后给前面待定的宽度绑定这些变量就行了

.tooltip{
  /*...*/
  width: calc(var(--x) * 2);
}
.tooltip::before{
  /*...*/
  max-width: var(--w);
}

这样左侧就完全实现自适应了,无需实时计算,仅需初始化一次就好了

Kapture 2025-05-24 at 15.56.13转存失败,建议直接上传图片文件

四、完全自适应对齐

前面是左侧,那右侧如何判断呢?我们可以比较左侧距离的占比,如果超过一半,就表示现在是居右了

这里用一个属性表示

this.tooltip.dataset.left = x/W < 0.5 //是否居左

然后就右侧虚拟容器的宽度了,和左侧还有有点不一样

image-20250524160146516

前面我们已经算出了左侧距离,由于超过了一半,所以需要先减然后再乘以二

.tooltip[data-left="false"]::before{
  /*...*/
  width: calc( (var(--w) - var(--x)) * 2);
  max-width: var(--w);
}

其实这里还是有个小问题的,当气泡文字比较长时,仍然是朝右突破了边界,如下所示

image-20250524160531721

这是因为默认的语言流向造成的(从左往右),解决这个问题也非常简单,仅需改变语言方向就可以了,要用到direction:rtl,如下

.tooltip[data-left="false"]::before{
  /*...*/
  width: calc( (var(--w) - var(--x)) * 2);
  max-width: var(--w);
  direction: rtl;
}

这样就完美了

image-20250524160856055

现在来看一下所有边界情况的演示

Kapture 2025-05-24 at 16.10.06

你也可以访问在线demo真实体验:codepen.io/xboxyan/pen…

如果你是 vue3 项目,可以直接用这段封装好的组件(其实没几行代码,大部分自适应都是CSS完成的)

<!-- 极度自适应的tooltips -->
<script setup lang="ts">
const props = defineProps({
  text: String,
  gap: {
    type: Number,
    default: 12,
  },
})

const show = ref(false)
const pos = reactive({
  x: 0,
  w: 0,
  top: 0,
  gap: 0,
  isLeft: true,
})
const click = (ev: MouseEvent) => {
  // console.log()
  // if (ev.target) {
  //   ev.stopPropagation()
  // }
  const target = ev.target as Element | null
  console.log('xxxxxxxxxxx', target)
  if (target) {
    const { x, y, width } = target.getBoundingClientRect()
    pos.top = y + window.scrollY
    pos.gap = props.gap
    pos.x = x + width / 2 - props.gap
    pos.w = window.innerWidth - props.gap * 2
    show.value = true
  }
}

const wrap = ref<HTMLElement>()

document.body.addEventListener('touchstart', (ev) => {
  // 没有点击当前触发对象就隐藏tooltips
  if (!(wrap.value && ev.target && wrap.value.contains(ev.target as Node))) {
    show.value = false
  }
})
</script>

<template>
  <span class="wrap" ref="wrap" @click="click">
    <slot></slot>
  </span>
  <Teleport to="body">
    <div
      class="tooltip"
      v-show="show"
      :data-title="text"
      :data-left="pos.x / pos.w < 0.5"
      :style="{
        '--x': pos.x + 'px',
        '--top': pos.top + 'px',
        '--gap': pos.gap + 'px',
        '--w': pos.w + 'px',
      }"
    ></div>
  </Teleport>
</template>
<style>
.wrap {
  display: contents;
}
.tooltip {
  position: absolute;
  top: var(--top);
  text-align: center;
  pointer-events: none;
}
.tooltip[data-left='true'] {
  width: calc(var(--x) * 2);
  left: var(--gap);
}
.tooltip[data-left='false'] {
  width: calc((var(--w) - var(--x)) * 2);
  right: var(--gap);
  direction: rtl;
}

.tooltip::before {
  content: attr(data-title);
  display: inline-block;
  color: #fff;
  background-color: #191919;
  padding: 0.5em 0.8em;
  border-radius: 8px;
  transform: translateY(calc(-100% - 0.5em));
  width: max-content;
  max-width: var(--w);
  box-sizing: border-box;
  text-align: left;
}
.tooltip::after {
  content: '';
  position: absolute;
  width: 1.2em;
  height: 0.6em;
  background: #000;
  clip-path: polygon(0 0, 100% 0, 50% 100%);
  top: 0;
  left: 0;
  right: 0;
  margin: 0 auto;
  transform: translateY(calc(-100% - 0.2em));
}
</style>

五、推荐一个开源库

其实市面上有一个库可以完成类似的交互,叫做 float-ui

image-20250817104551464转存失败,建议直接上传图片文件

这个是专门做popover这类交互的,其中有一个shift属性,可以做这种跟随效果

image-20250817104816034

不过对于大部分情况,引入一个单独的库还是成本偏大,建议还是纯原生实现。

这样一个极度自适应的气泡组件,你学会了吗,赶紧在项目中用起来吧~最后,如果觉得还不错,对你有帮助的话,欢迎点赞、收藏、转发 ❤❤❤

事件委托的深层逻辑:当冒泡不够时⁉️

2025年8月17日 13:15

前言

在项目不断扩大之时,管理用户交互变的越来越重要,为每个交互元素附加一个事件监听器是一种糟糕的做法,因为它会导致代码混乱、内存消耗增加以及性能瓶颈。这时,事件委托就派上用场了。

认识dom事件传播

三个阶段

当事件在 DOM 元素上触发时,它不会简单地到达目标并停止。相反,它会经历以下阶段:

  1. 捕获阶段旅程从window级别开始,沿着 DOM 树向下移动,经过每个祖先元素,直到到达目标的父级。带有(中的第三个参数)的事件监听器在此触发useCapture = true``addEventListener
  2. 目标阶段在此阶段,事件到达预期的目标元素。所有直接附加到此元素的监听器都会被触发
  3. 冒泡阶段命中目标后,事件会沿着 DOM 向上“冒泡”,从目标的父元素到祖父元素,依此类推,直到到达目标window。默认情况下,大多数事件监听器都在此阶段运行

事件在dom树中流动过程

< div id = "grandparent" >
< div id = "parent" >
< button id = "child" >点击我</ button > 
</ div > 
</ div > 

如果您单击,则事件流程如下:<button id="child"> click

  1. 捕获 -window -> document -> <html> -> <body> -> <div id="grandparent"> -> <div id="parent">
  2. 目标<button id="child">
  3. 冒泡 - <button id="child"> -> <div id="parent"> -> <div id="grandparent"> -> <body> -> <html> ->document -> window

什么是事件委托

事件委托是一种将事件监听器添加到多个子元素的父元素上,而不是分别添加到每个子元素上的方法。当子元素上发生事件时,它会触发父元素上的监听器,父元素会检查是哪个子元素触发了该事件。

假设一个<ul>包含<li>以下项目的简单列表:

< ul id = "myList" > 
< li >项目 1 </ li > 
< li >项目 2 </ li >
< li >项目 3 </ li >
< li >项目 4 </ li > 
</ ul > 
  

而不是为每个添加一个点击监听器<li>

const listItems = document . querySelectorAll ( '#myList li' ); 
listItems . forEach ( item => { 
  item . addEventListener ( 'click' , ( event ) => { console . log ( `点击于: $ { event . target . textContent } ` );
  });
 });           

通过事件委托,你可以将一个监听器附加到<ul>父级:

onst myList =文档. getElementById ( 'myList' ); 

myList . addEventListener ( 'click' , ( event ) => { 
// 检查点击的元素是否为 <li>
if ( event . target . tagName === 'LI' ) {
console . log ( ` Clicked on : $ { event . target . textContent } ` );
} 
});   
  

在此示例中,当<li>点击任意一个时,click事件都会冒泡到。然后,myList上的单个事件监听器会检查是否是触发了该事件,并采取相应的措施:myList``event.target.tagName``<li>

为什么事件委托如此重要

  • 无需添加数百或数千个监听器,只需几个父容器就足够了,从而大大减少内存占用
  • 更少的监听器可以提高浏览器整体系统内存的使用率,并减少 JavaScript 引擎在事件管理和调度方面的工作量
  • 它支持动态创建元素,这非常实用。假设在页面加载后(例如,在 API 调用后)<li>添加了新元素,监听器仍然有效。无需重新连接监听器。#myList``#myList

事件委托中常见的误区

event.target vs event.currentTarget
  • event.target 是触发事件的特定元素。
  • event.currentTarget 是事件监听器实际附加到的元素。
stopPropagation ()和stopImmediatePropagation () 
  • event.stopPropagation() – 此方法仅允许事件停止沿 DOM 树向上或向下冒泡或捕获。如果在子元素的事件处理程序中执行此方法,则其祖先元素上的任何委托监听器都将无法访问该事件
  • event.stopImmediatePropagation() 这不stopPropagation()的复制粘贴。它的相似之处仅限于添加了这个效果:它阻止进一步的事件传播,并阻止绑定到同一元素的任何其他监听器被执行。

在某些情况下,它们会破坏委托处理程序,例如:子元素的事件处理程序调用stopPropagation将导致位于 DOM 层次结构中更高层级的任何委托监听器的功能失效。委托监听器将无法接收事件。这对于分析、集中式 UI 逻辑或可访问的自定义控件功能尤其麻烦。

非冒泡事件

最突出的非冒泡事件包括:

  • focus– 当元素获得焦点时触发
  • blur– 当元素失去焦点时触发
  • mouseenter– 当指针进入元素时触发
  • mouseleave– 当指针离开元素时触发

为什么它们不起泡

由于浏览器的工作方式以及过去的兼容性问题,通常无法触发此类事件。focusblur旨在在获得或失去焦点的特定元素上触发,因此不存在冒泡。mouseentermouseleavemouseover 和 mouseout配对(它们会产生冒泡);但是与mouseover 和 mouseout不同,mouseenter 和 mouseleave仅在指针位于元素上(而不是其子元素上)时触发。

对于非冒泡事件只能通过自定义冒泡事件来替代

总结

事件委托通过将单个监听器附加到父元素来简化事件处理。当子元素触发事件时,它会向上冒泡到父元素,从而减少内存占用并简化代码。

这种技术在管理大量相似元素(例如列表项或按钮)时非常有效,尤其是在它们动态生成的情况下。父级监听器无需额外配置即可处理新添加元素的事件。

并非所有事件都会冒泡 focusblurmouseleave 等是例外。对于这些事件,可以用 focusinfocusout 或自定义冒泡事件等替代方法。

全面解析this-理解this指向的原理

作者 pepedd864
2025年8月17日 12:03

参考资料

  • 《你不知道的JavaScript》- this全面解析

this 是什么

  • this 是一个代词,代指一个对象
  • this 提供了一种更优雅的方式来隐式的传递一个对象引用,可以让代码更加简洁易于复用

调用位置

调用位置就是函数在代码中被调用的位置(而不是声明的位置)。调用位置决定了 this 的绑定

比如下面代码:

function baz() {
// 当前调用栈是:baz
// 因此,当前调用位置是全局作用域
console.log("baz");
bar(); // <-- bar 的调用位置
}
function bar() {
// 当前调用栈是 baz -> bar
// 因此,当前调用位置在 baz 中
console.log("bar");
foo(); // <-- foo 的调用位置
}
function foo() {
// 当前调用栈是 baz -> bar -> foo // 因此,当前调用位置在 bar 中
console.log("foo");
}
baz(); // <-- baz 的调用位置

可以使用浏览器的开发工具查看调用栈




this的绑定规则

this的指向有以下四条特性/规则

  • 默认绑定
  • 隐式绑定
  • 显式绑定
  • new 绑定

默认绑定

当函数被独立调用时,函数的 this 指向 window

独立调用就是像这样:foo() 的调用

比如

function foo() {
console.log(this.a);
}
var a = 2;
foo(); // 2

如果使用严格模式(strict mode),那么全局对象将无法使用默认绑定,因此 this 会绑定到 undefined

隐式绑定

当函数引用有上下文对象 且被该对象调用时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象

function foo() {
console.log(this.a);
}
var obj = {
  a: 2,
  foo: foo
};
obj.foo(); // 2
// 这里就是隐式绑定,foo函数的this绑定到了obj上

需要注意的是,对象属性引用链中只有最顶层或者说最后一层会影响调用位置

比如

function foo() {
console.log(this.a);
}
var obj2 = {
a: 42,
foo: foo,
};
var obj1 = {
a: 2,
obj2: obj2,
};
obj1.obj2.foo(); // 42
// 相当于 obj2.foo(),因为只有最后一层会影响调用位置

隐式丢失

隐式绑定的函数可能会丢失绑定对象,也就是说它会应用默认绑定

隐式丢失的几种情况:

  1. 函数别名
function foo() {
console.log(this.a);
}
var obj = {
a: 2,
foo: foo,
};

// 注意这里的函数别名,会导致隐式绑定丢失,导致foo函数的this指向全局
var bar = obj.foo; // 函数别名!
var a = "oops, global"; // a 是全局对象的属性
bar(); // "oops, global"
  1. 函数作为参数传入,并调用时
function foo() {
console.log(this.a);
}
function doFoo(fn) {
// fn 其实引用的是 foo
fn(); // <-- 调用位置!
}
var obj = {
a: 2,
foo: foo,
};
var a = "oops, global"; // a 是全局对象的属性
doFoo(obj.foo); // "oops, global"

参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一个例子一样

显式绑定

  • fn.call(obj, x, x, ...) 将fn函数的this指向obj,并调用,call的剩余参数是fn需要的参数
function foo(aram) {
console.log("foo", param.a); // foo 1

function bar(x, y) {
console.log("bar", this.a, x, y); // bar 1 2 3
}
bar.call(param, 2, 3);
}

foo({ a: 1 });
  • fn.apply(obj, [x, x, ...]) 将fn函数的this指向obj,并调用,apply的第二个参数是一个数组
function foo(param) {
console.log("foo", param.a); // foo 1

function bar(x, y) {
console.log("bar", this.a, x, y); // bar 1 2 3
}
bar.apply(param, [2, 3]);
}

foo({ a: 1 });
  • fn.bind(obj, x, x, ...)(x, x, ...) 将fn函数的this指向obj,bind会返回一个新的函数,新的函数也可以传递参数
function foo(param) {
console.log("foo", param.a); // foo 1

function bar(x, y) {
console.log("bar", this.a, x, y); // bar 1 2 3
}
return bar.bind(param, 2);
}

const bar = foo({ a: 1 });
bar(3);

new 绑定

new 绑定 - this会绑定到新创建的对象上

function Person(name, age) {
  // this 指向新创建的对象
  this.name = name;
  this.age = age;

  // 通过new调用构造函数时,this指向新创建的对象
// 直接调用构造函数时,应用默认绑定规则,this指向全局或undefined
  console.log(this);
}

// 使用 new 关键字调用构造函数
const person1 = new Person("张三", 25);
console.log(person1.name); // "张三"
console.log(person1.age);  // 25

// 不使用 new 调用,this 会指向全局对象(非严格模式)或 undefined(严格模式)
const person2 = Person("李四", 30); // this 不会指向新对象
console.log(person2); // undefined (因为没有显式返回)

箭头函数

  1. 箭头函数没有自己的this 指向,它需要继承外层函数的this指向
  2. 箭头函数即使是new也无法改变this指向,因此箭头函数不能用于编写构造函数
var a = 1;
function foo() {
var obj1 = {
a: 2,
bar: function () {
console.log("bar", this.a);
var obj2 = {
a: 3,
baz: () => {
console.log("baz", this.a);
},
};
// 箭头函数不会创建自己的 this,它会捕获外层函数的 this
obj2.baz(); // baz 2
},
};
console.log("foo", this.a);
obj1.bar(); // bar 2
}
foo(); // foo 1

神奇魔法类:使用 createMagicClass 增强你的 JavaScript/Typescript 类

2025年8月17日 11:40

什么是神奇魔法类?

神奇魔法类(Magic Class)是一种特殊的类包装器,它通过createMagicClass函数创建,能够赋予普通 JavaScript/TypeScript 类超能力。这个工具让你的类既可以作为构造函数使用,又可以作为函数调用来配置选项,大大增强了类的灵活性和可用性。

神奇特性

1. 双重身份

魔法类具有双重身份,可以:

  • 作为普通类使用用于继承:class MyClass extends MagicClass
  • 作为函数调用来传入选项用于构建类:class MyClass extends MagicClass({<options>})

2. 生命周期钩子

提供完整的生命周期钩子,让你能够精确控制实例的创建过程:

  • onBeforeInstance: 实例创建前触发,可以阻止实例创建或修改类
  • onAfterInstance: 实例创建后触发,可以对实例进行后处理
  • onErrorInstance: 实例创建出错时触发,可以进行错误处理

使用示例

基本用法

import { createMagicClass } from "flex-tools/classs";
type UserCreateOptions = {
  prefix?: string;
  x?: number;
};
// 定义一个普通类
class User {
  name: string;
  prefix: string = "";
  constructor(name: string) {
    this.name = name;
    this.prefix = getMagicClassOptions<UserCreateOptions>(this)?.prefix!;
  }
  get title() {
    return `${this.prefix}${this.name}`;
  }
  toString() {
    return `${this.constructor.name}<${this.name}>`;
  }
}

// 创建魔术类
const MagicUser = createMagicClass<typeof User, UserCreateOptions>(User, {
  prefix: "Hi,", // 默认配置
  x: 1,
  onBeforeInstance: (cls, args, _options) => {},
  onAfterInstance: (inst, _options) => {},
});
//  直接作为类使用
class Admin extends MagicUser {}
class Guest extends MagicUser({ x: 2, prefix: "欢迎," }) {}
class Customer extends MagicUser({ prefix: "尊贵的" }) {}

const user = new User("用户");
const admin = new Admin("管理员");
const guest = new Guest("访客");
const customer = new Customer("客户");

高级用法:拦截实例创建

const ValidatedPerson = createMagicClass(Person, {
  onBeforeInstance: (cls, args, options) => {
    const name = args[0];

    // 验证名称
    if (!name || name.length < 2) {
      throw new Error("Name must be at least 2 characters long");
    }

    // 可以修改参数
    args[0] = name.charAt(0).toUpperCase() + name.slice(1);

    // 返回false会阻止实例创建
    // 返回一个对象会使用该对象作为实例
    // 返回一个类会使用该类创建实例
  },
  onErrorInstance: (error, cls, options) => {
    console.error("Failed to create person:", error.message);
  },
});

class SitePerson extends ValidatedPerson {}

// 这将抛出错误,因为名称太短
try {
  const invalid = new SitePerson("A");
} catch (e) {
  console.log(e.message); // "Name must be at least 2 characters long"
}

// 这将成功,并且名称首字母会被自动大写
const valid = new SitePerson("bob"); // 实际名称将是 "Bob"

获取实例配置

你可以使用getMagicClassOptions函数获取实例的配置选项:

import { createMagicClass, getMagicClassOptions } from "flex-tools/classs";

const MagicPerson = createMagicClass(Person, { version: "1.0" });
const ConfiguredPerson = MagicPerson({ theme: "dark" });
const person = new ConfiguredPerson("Alice");

const options = getMagicClassOptions(person);
console.log(options); // { version: '1.0', theme: 'dark' }

总结

神奇魔法类通过createMagicClass函数提供了一种优雅而强大的方式来增强 JavaScript/TypeScript 类的能力。它不仅保留了原始类的所有功能,还添加了配置选项、生命周期钩子和灵活的实例化方式,使你的代码更加灵活、可配置且易于维护。

无论你是构建复杂的 UI 组件、可配置的工具类,还是需要精细控制实例创建过程的系统,神奇魔法类都能为你提供强大而灵活的解决方案。

详见flex-tools

一个前端开发者的救赎之路-JS基础回顾(三)-Function函数

作者 雲墨款哥
2025年8月17日 11:19

函数的声明

  1. 赋值式:var func = function() {}
  2. 声明式:function func() {}
  3. 箭头函数:()=>{}
  4. 三者之间的区别
    a. 声明式可以在函数声明前去调用,赋值式不可以
    b. 箭头函数,他们从定义自己的环境继承上下文,而不是像以其他方式定义的函数那样定义自己的调用上下文
    c. 箭头函数没有prototype属性,所以它不能作为新类的构造函数

函数的调用

  • 作为函数:
    • 注意这个说法,函数是通过调用表达式被作为函数或方法调用的。
    • 调用表达式包括求值为函数对象的函数表达式,后跟一对圆括号,圆括号中是逗号分隔的零或多个参数表达式列表
    • this指向:
      • 非严格模式:全局对象
      • 严格模式:undefined
      • 注意 箭头函数又有不同:它们总是继承自身定义所在环境的this值(这里即使是严格模式,this也是window
      • 下面的代码可以用来判断是不是处于严格模式
      // 定义并调用函数,以确定当前是不是严格模式
      const strict = (function() { return !this }())
      
      • 我有一篇文章,这里面有一个自己遇到的有趣的this指向和作用域的问题
    • 这里注意条件式调用:
      • 在Es2020中,可以在函数表达式的后面、圆括号的前面插入?.,从而只在函数不是null或undefined的情况下调用。
      • 在没有副作用的前提下:f?.()<=>(f !== null && f !== undefined ? f() : undefined)
  • 作为方法:
    • this关键字不具有变量那样的作用域机制,除了箭头函数,嵌套函数不会继承包含函数的this值。如果嵌套函数被当做方法来用,那它的this就是调用它的对象。如果嵌套函数(不是箭头函数)被当做函数来调用,则它的this值要么是全局对象(非严格模式),要么是undefined(严格模式)

      let o = {
          a: 1,
          b: 2,
          m: function() {
              let self = this;
              console.log(this.a); // 1
              f();
      
              function f() {
                  console.log(this.b); // undefined
              }
              
              const g = () => {
                  console.log(this.b); // 2
              }
              
              g();
          }
      }
      o.m();
      
  • 作为构造函数
    • 作为构造函数调用,上下文是这个新对象
  • 通过call()或apply()方法调用
    • 这两个方法允许我们指定调用时的this,这意味着可以将任意函数作为任意对象的方法来调用
    • call()方法使用自己的参数列表作为函数的参数,而apply()方法则期待数组值作为参数。
  • 通过JavaScript语言隐式调用
    • 这里在开发中排查bug,很关键,就是我们引用了别人的三方库或者代码都有可能出现这个隐式调用

函数的参数

  • 函数调用时所传的实参,可以少于形参也可以多于形参,
  • 函数定义式可以用一个剩余参数来接收多余的参数,剩余参数必须作为最后一个参数,并且是...args这种形式,剩余参数永远是一个数组,不会是undefined,即使没有传对应的实参,因此不需要给剩余参数默认值

JS预解析

  • 对于函数的预解析和普通变量不一样,函数预解析是直接把整个函数提到顶部作用域,在预解析时会提前定义,只是不会立即执行。
  • 普通变量只是把声明提升到所在作用域顶部,而不进行初始化。
  • 因此,上面函数定义的时候,如果使用字面量的方式会把这个变量提升到顶部作用域并赋值undefined,所以在定义前调用的时候会报错。
  • let 和 const 会提升(hoisting),但由于 TDZ(暂时性死区) 机制,在声明前访问会报错

作用域

  • 作用域在定义的时候,一个函数就会形成一个内部作用域,后来引入了let/const又有了块级作用域
  • 作用域在访问的时候,是从下往上找,一直找到顶级作用域,找不到就报is not defined
  • 在赋值的时候不是,如果一直到最顶级都未声明,那他就是直接在全局定义域声明并且赋值这个变量。所以即使在vue中的某一个函数直接使用xxx=来进行操作,也是会在全局作用域添加一个全局变量xxx(内存泄漏),千万不要这样干

函数的方法和属性

  1. length属性:返回声明时声明的形参的个数(不包含剩余参数),只读
  2. name属性:表示定义函数时使用的名字(如果是用名字定义的),如果是未命名的函数,表示在第一次创建这个函数时赋给该函数的变量名或属性名。这个属性主要用于记录调试或排错信息。只读
  3. prototype属性:箭头函数没有
  4. call()和apply()方法:箭头函数的this不会被修改
  5. bind()方法:bind()方法主要目的是把函数绑定到对象,箭头函数不起作用
  6. toString()方法:
    • ECMAScript规定返回一个符合函数声明语句的字符串
    • 实际上,多数(不是全部),都是返回函数完整源代码
    • 内置函数返回的字符串中通常包含“[native code]”,表示函数体
  7. Function()构造函数:
    • Function()构造函数可以接收任意多个字符串参数,其中最后一个参数函数体的文本。这个函数体 文本中可以包含任意JavaScript语句,相互以分号分隔。传给构造函数的其他字符串参数,将作为这个新函数的的形参。
    • 注意:Function()构造函数不接受任何指定新函数名字的参数。与函数字面量一样,Function()构造函数创建的也是匿名函数

深入理解 TypeScript 的 /// <reference /> 注释及其用途

2025年8月17日 10:34

在 Angular 项目中,出现的 index.d.ts 文件中包含了以下代码:

/// <reference path="./lib.app.d.ts" />
/// <reference path="./lib.page.d.ts" />
/// <reference path="./lib.component.d.ts" />
/// <reference path="./lib.mixin.d.ts" />
/// <reference path="./lib.shared.d.ts" />
/// <reference path="./lib.global.d.ts" />

这些代码的作用及其语法含义,涉及到 TypeScript 的编译器如何解析类型声明文件,以及如何通过 /// <reference /> 注释建立模块或类型之间的依赖关系。

以下将逐步分析这些代码的每个部分,详细解释其功能和语法规则,并提供相关的运行示例来演示其应用。

什么是 /// <reference />

/// <reference /> 是 TypeScript 中一种特殊的三斜杠注释(Triple-Slash Directive)。这类注释提供了一种方式,允许在文件之间显式声明依赖关系,指导编译器加载特定的类型定义文件。它们通常用于 .d.ts 类型声明文件。

三斜杠注释的语法如下:

/// <reference path="relative-or-absolute-path" />

这里的 path 表示要引用的文件的路径,可以是相对路径或绝对路径。路径指向一个 TypeScript 声明文件(以 .d.ts 为扩展名)。

逐个拆解代码片段的含义

///

这部分表示三斜杠注释的起始标志。三斜杠注释是一种特殊的注释类型,只能出现在文件的顶部或注释之前没有其他语句。

<reference />

这是三斜杠注释的核心指令部分,表明这是一个引用指令。指令用于引入外部文件中的类型信息。

path="./lib.app.d.ts"

path 是一个属性,指定要引用的文件路径。在这个示例中,路径为 ./lib.app.d.ts,表示当前目录下的 lib.app.d.ts 文件。

文件路径支持以下形式:

  1. 相对路径:以 ./../ 开头,指向相对于当前文件的路径。
  2. 绝对路径:在某些项目中可以使用项目根目录的绝对路径,但这通常需要与 tsconfig.json 配合。

为什么需要 /// <reference />

在现代 TypeScript 中,/// <reference /> 的使用场景较为有限,因为大多数项目依赖模块系统(如 ES Modules 或 CommonJS)自动处理文件之间的依赖关系。然而,在以下情况下仍然需要使用这种语法:

  1. 全局类型声明文件:如果一个类型定义文件中定义了全局变量、类型或接口,其他文件需要显式引用它以确保类型安全。
  2. 非模块化项目:当项目没有采用模块系统时,可以通过三斜杠注释建立文件间的依赖关系。
  3. 特定工具链或框架:某些工具或框架可能要求使用这种语法来声明类型依赖。

提供一个完整的运行示例

以下示例演示如何使用 /// <reference /> 在项目中引入全局类型声明。

文件结构

project/
  |-- tsconfig.json
  |-- index.ts
  |-- types/
        |-- lib.app.d.ts
        |-- lib.page.d.ts

tsconfig.json

配置文件用于告诉 TypeScript 编译器如何处理项目。

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "CommonJS",
    "outDir": "dist",
    "rootDir": "./",
    "typeRoots": ["./types"],
    "strict": true
  }
}

types/lib.app.d.ts

declare namespace App {
  interface Config {
    appName: string;
    version: string;
  }
}

types/lib.page.d.ts

declare namespace Page {
  interface Metadata {
    title: string;
    description: string;
  }
}

index.ts

在主文件中使用三斜杠注释引用这些全局声明文件:

/// <reference path="./types/lib.app.d.ts" />
/// <reference path="./types/lib.page.d.ts" />

const appConfig: App.Config = {
  appName: `MyApp`,
  version: `1.0.0`
};

const pageMetadata: Page.Metadata = {
  title: `Home Page`,
  description: `Welcome to the home page of MyApp.`
};

console.log(appConfig, pageMetadata);

编译与运行

执行以下命令进行编译和运行:

tsc
node dist/index.js

输出结果为:

{ appName: 'MyApp', version: '1.0.0' } { title: 'Home Page', description: 'Welcome to the home page of MyApp.' }

注意事项

  1. 路径有效性:确保引用的路径正确,并且文件存在。
  2. 模块系统的替代方案:对于模块化项目,优先使用 importexport,而非三斜杠注释。
  3. tsconfig.json 配合:在配置文件中设置 typeRootsinclude,可以减少手动引用的需求。

总结

三斜杠注释是 TypeScript 的一种显式依赖声明机制,用于特定场景下的类型声明管理。虽然在现代项目中应用范围有限,但它在处理全局声明和非模块化项目时依然具有重要作用。通过合理使用 /// <reference />,可以有效组织和管理大型项目的类型定义。

面试官的 JS 继承陷阱,你能全身而退吗?🕳️

2025年8月17日 10:29

继承,是 JS 面试绕不开的灵魂拷问。本文将带你一网打尽 JS 继承的所有姿势,配合代码实例和细致讲解,助你面试不再慌张!

一、什么是继承?

继承,就是让子类可以访问到父类的属性和方法。JS 继承的实现方式多如牛毛,面试官最爱考察各种细节和坑点。

二、原型链继承

原理

子类的原型指向父类的实例。所有子类实例共享同一个父类实例。

代码演示

function Parent() {
    this.name = 'parent'
    this.like = ['a', 'b', 'c']
}
Child.prototype = new Parent()
function Child() {
    this.age = 18
}
let c = new Child()
let d = new Child()
c.like.push('d')
console.log(c.like) // ['a', 'b', 'c', 'd']
console.log(d.like) // ['a', 'b', 'c', 'd']

优缺点

  • 优点:实现简单,能访问父类属性和方法。
  • 缺点:引用类型属性会被所有实例共享,互相影响,容易踩坑。

面试官小贴士

"你能说说原型链继承的缺陷吗?为什么 like 属性会被所有实例共享?"

三、构造函数继承

原理

在子类构造函数中调用父类构造函数,this 指向子类实例。

代码演示

Parent.prototype.say = function () {
    console.log('hello')
}
function Parent() {
    this.name = 'parent'
    this.like = ['a', 'b', 'c']
}
function Child() {
    this.age = 18
    Parent.call(this)
}
let c = new Child()
console.log(c.say) // undefined

优缺点

  • 优点:每个实例独立拥有父类属性,引用类型不再共享。
  • 缺点:无法继承父类原型上的方法(如 say),只能继承构造函数里的属性。

面试官小贴士

"为什么 c.say 是 undefined?如何让子类也能继承父类原型上的方法?"

四、组合继承

原理

原型链继承 + 构造函数继承,双管齐下。

代码演示

Parent.prototype.say = function () {
    console.log('hello')
}
function Parent() {
    this.name = 'parent'
    this.like = ['a', 'b', 'c']
}
Child.prototype = new Parent()
Child.prototype.constructor = Child
function Child() {
    this.age = 18
    Parent.call(this)
}
let c = new Child()
let d = new Child()
d.like.push('d')
console.log(d.like); // ['a', 'b', 'c', 'd']
console.log(c.like); // ['a', 'b', 'c']
console.log(c.say); // function
console.log(c.constructor); // Child

优缺点

  • 优点:既能继承父类属性,又能继承父类原型方法,引用类型不共享。
  • 缺点:父类构造函数会执行两次(一次给原型,一次给实例),有点浪费性能。

面试官小贴士

"组合继承为什么会调用两次父类构造函数?有没有更优的方案?"

五、原型式继承

原理

用 Object.create 或类似方式,以某对象为原型创建新对象。

代码演示

let parent = {
    name: 'parent',
    like: ['a', 'b', 'c']
}
let child1 = Object.create(parent)
let child2 = Object.create(parent)
child1.like.push('d')
console.log(child1.like); // ['a', 'b', 'c', 'd']
console.log(child2.like); // ['a', 'b', 'c', 'd']

优缺点

  • 优点:实现简单,适合对象克隆。
  • 缺点:引用类型属性依然共享。

面试官小贴士

"Object.create(parent) 和 new Object(parent) 有什么区别?"

六、寄生式继承

原理

在原型式继承基础上,增强返回的新对象。

代码演示

let parent = {
    name: 'parent',
    like: ['a', 'b', 'c']
}
function clone(origin) {
    let cloneObj = Object.create(origin)
    cloneObj.getLike = function() {
        return this.like
    }
    return cloneObj
}
let child1 = clone(parent)
let child2 = clone(parent)
child1.like.push('d')
console.log(child1.like); // ['a', 'b', 'c', 'd']
console.log(child2.like); // ['a', 'b', 'c', 'd']
console.log(child1.getLike()); // ['a', 'b', 'c', 'd']
console.log(child2.getLike()); // ['a', 'b', 'c', 'd']

优缺点

  • 优点:可以扩展新对象。
  • 缺点:引用类型属性依然共享。

面试官小贴士

"寄生式继承和原型式继承的本质区别是什么?"

七、寄生组合式继承(最优解)

原理

只继承父类原型,不调用父类构造函数,避免性能浪费。

代码演示

Parent.prototype.getName = function() {
    return this.Name
}
function Parent() {
    this.Name = 'parent'
    this.like = ['a', 'b', 'c']
}
Child.prototype = Object.create(Parent.prototype)
Child.prototype.constructor = Child
function Child() {
    this.age = 18
    Parent.call(this)
}
let c1 = new Child()
console.log(c1.getName()); // 'parent'
console.log(c1.constructor); // Child

优缺点

  • 优点:只调用一次父类构造函数,性能最佳,继承属性和方法都不落下。
  • 缺点:实现稍复杂,但值得!

面试官小贴士

"为什么寄生组合式继承被称为 JS 继承的终极方案?"

八、ES6 类继承

原理

用 class 和 extends 语法糖,优雅实现继承。

代码演示

class Parent {
    constructor() {
        this.Name = 'parent'
        this.like = ['a', 'b', 'c']
    }
    getName() {
        return this.Name
    }
    static say() {
        console.log('hello');
    }
}
class Child extends Parent {
    constructor() {
        super()
        this.age = 18
    }
}
let p = new Parent()
console.log(p.getName()); // 'parent'
let c = new Child()
console.log(c.getName()); // 'parent'

优缺点

  • 优点:语法简洁,继承关系清晰,原型链自动处理。
  • 缺点:底层依然是原型链,只是语法糖。

面试官小贴士

"class 继承和传统原型链继承的本质区别是什么?"

九、知识点总结与面试答题模板

继承方式对比表

方式 是否共享引用类型 是否继承原型方法 构造函数调用次数 优缺点
原型链继承 1 引用类型共享
构造函数继承 1 不能继承原型方法
组合继承 2 性能浪费
原型式继承 0 引用类型共享
寄生式继承 0 引用类型共享
寄生组合式继承 1 性能最佳
ES6 类继承 1 语法糖

面试高频问题

  • 说说 JS 继承的实现方式及优缺点?
  • 为什么原型链继承会导致引用类型属性共享?
  • 如何实现一个既能继承属性又能继承方法的子类?
  • ES6 的 class 继承和传统继承有什么区别?

十、幽默收尾

JS 继承就像家庭聚会,谁家锅碗瓢盆都能借来用,但有时候大家都用同一个锅,炒出来的菜味道就不一样了!面试官最爱问的那些继承细节,你现在都能用段子和代码轻松拿下!


祝大家面试不再慌张,继承全家桶一把梭!🎉

JavaScript 运行机制详解:再谈 Event Loop

作者 mCell
2025年8月17日 02:28

同步更新至个人站点: JavaScript 运行机制详解:再谈 Event Loop

005.avif

本文从经典的 Promise 与 setTimeout 执行顺序问题入手,深入浅出地剖析了 JavaScript 的单线程模型、事件循环(Event Loop)机制。通过辨析宏任务与微任务的区别与优先级,帮助你彻底理解 JS 异步执行的底层原理,看懂页面卡顿的真相。

我常常在各种场合被问到类似下面代码的输出顺序。

console.log("start");

setTimeout(function () {
  console.log("setTimeout");
}, 0);

Promise.resolve().then(function () {
  console.log("promise");
});

console.log("end");

如果你能毫不犹豫地答出 start, end, promise, setTimeout,并解释其原因,那么你对 JS 的异步机制已经有了不错的理解。如果你还有一丝困惑,希望本文能帮助你彻底梳理清楚。

这个问题的背后,是整个 JavaScript 的运行模型(runtime model),也就是我们常说的“事件循环”(Event Loop)。理解它,是前端工程师进阶的必经之路。

为什么 JavaScript 是单线程?

首先,我们必须记住一个基本事实:JavaScript 语言是一门单线程语言。

这意味着,在任何一个时刻,JS 引擎只能执行一段代码。为什么这么设计?这与它的初衷有关。JavaScript 最初是为浏览器设计的,用于处理用户的交互,比如鼠标点击、键盘输入,以及操作 DOM。

试想一下,如果 JavaScript 是多线程的,会发生什么?一个线程要在一个 DOM 节点上增加内容,另一个线程要删除这个节点。那么浏览器应该听谁的?这会带来极其复杂的同步问题。为了避免这种复杂性,JavaScript 从诞生起就选择了单线程。

这既是它的优点,也是它的缺点。优点是简单,没有多线程的竞态、死锁等问题。缺点是,如果一个任务耗时很长,整个程序就会被“卡住”,无法响应其他操作。

浏览器:一个多进程的“操作系统”

“JS 是单线程的”这个说法其实不完全准确。准确来说,执行 JavaScript 代码的那个主线程是单线程的

现代浏览器(以 Chrome 为例)本身是一个非常复杂的程序,它采用了多进程架构来保证稳定性和安全性。你可以打开 Chrome 的任务管理器(“更多工具” > “任务管理器”)看看,通常会看到好几个进程:

  • 浏览器进程(Browser Process):负责浏览器界面的“外壳”,比如地址栏、书签、前进后退按钮,以及协调其他进程。
  • 渲染进程(Renderer Process):核心部分,负责将 HTML、CSS 和 JavaScript 转换成用户可以看到的网页。我们写的 JS 代码,主要就在这个进程的主线程(Main Thread)上运行。每个标签页通常会有一个独立的渲染进程。
  • 网络进程(Network Process):负责处理网络请求,比如 fetch
  • GPU 进程(GPU Process):负责处理 GPU 相关的任务,加速 3D 绘图和页面渲染。

这种设计的好处是隔离。一个标签页(渲染进程)崩溃了,不会影响到整个浏览器。

任务队列(Task Queue)和事件循环(Event Loop)

我们回到渲染进程的主线程。这个线程非常繁忙,它要做的事情包括:

  • 执行 JavaScript 代码
  • 渲染页面布局(Layout)
  • 绘制页面(Paint)
  • 响应用户交互(Click, Scroll)

如果所有任务都排队等着,一个耗时长的 JS 计算就会阻塞页面渲染和用户响应,这就是“假死”现象。

// 一个会让页面卡住的例子
document.getElementById("myButton").addEventListener("click", function () {
  // 假装这是一个非常耗时的计算
  const start = Date.now();
  while (Date.now() - start < 5000) {
    // 这5秒内,页面完全无法响应
  }
  console.log("计算完成!");
});

为了解决这个问题,浏览器引入了异步(asynchronous)执行模型。当遇到一些耗时操作(比如网络请求、定时器)时,主线程不会傻等,而是把这些任务“外包”给浏览器的其他线程(比如网络线程、定时器线程)。

这些“外包”任务完成后,会把一个“回调函数”(callback)放进一个叫做**任务队列(Task Queue)**的地方。主线程则继续执行自己手头的同步代码。

等到主线程的同步代码全部执行完毕,它就会去任务队列里看看,有没有需要执行的回调函数。如果有,就取出一个来执行。这个“主线程不断从任务队列里读取并执行任务”的过程,就叫做事件循环(Event Loop)

这个模型可以用一张经典的图来表示:

019.jpg

微任务(Microtask)和宏任务(Macrotask)

事情还没完。任务队列其实不止一个。根据 WHATWG 规范,任务被分为两种类型:

  1. 宏任务(Macrotask,规范中称为 Task)

    • setTimeout, setInterval
    • script(整体代码块)
    • I/O 操作, UI 渲染
    • 用户交互事件(如 click, scroll
  2. 微任务(Microtask)

    • Promise.then(), Promise.catch(), Promise.finally()
    • queueMicrotask()
    • MutationObserver

事件循环的规则是,优先级更高的是微任务。主线程在执行完一个宏任务后,并不是立刻去执行下一个宏任务,而是会检查微任务队列。

完整的事件循环流程如下:

  1. 从宏任务队列中取出一个任务(通常是 script 脚本本身)并执行。
  2. 执行完毕后,检查微任务队列。
  3. 循环执行微任务队列中的所有任务,直到队列清空。
  4. 执行浏览器 UI 渲染(这一步不一定每次都会发生)。
  5. 回到第一步,从宏任务队列中取出下一个任务。

这个“执行一个宏任务 -> 清空所有微任务 -> 再取下一个宏任务”的循环,是理解所有异步执行顺序的关键。

回到最初的问题

现在,我们用这个模型来分析开头的代码:

console.log("start"); // 1

setTimeout(function () {
  // 4
  console.log("setTimeout");
}, 0);

Promise.resolve().then(function () {
  // 3
  console.log("promise");
});

console.log("end"); // 2
  1. 第一轮宏任务(script 脚本)开始执行。

    • 遇到 console.log('start'),直接执行。输出 start
    • 遇到 setTimeout,它是一个宏任务。浏览器定时器线程接管,0ms 后将其回调函数推入宏任务队列
    • 遇到 Promise.resolve().then().then() 的回调是一个微任务。它被推入微任务队列
    • 遇到 console.log('end'),直接执行。输出 end
  2. 第一个宏任务(script)执行完毕。

    • 现在,事件循环会检查微任务队列。发现里面有一个任务(打印 promise)。
    • 取出并执行该微任务。输出 promise
    • 微任务队列现在空了。
  3. 开始下一轮宏任务。

    • 事件循环检查宏任务队列,发现 setTimeout 的回调函数在那里。
    • 取出并执行该宏任务。输出 setTimeout

至此,所有代码执行完毕。最终输出 start, end, promise, setTimeout

应用与思考

理解了事件循环,很多问题就迎刃而解了。

  • setTimeout(fn, 0) 为什么不是立即执行? 因为它只是把 fn 尽快地推入宏任务队列,但必须等到当前主线程的同步代码和所有微任务都执行完之后,才有机会被执行。

  • 页面为什么会卡顿? 通常是因为一个宏任务(比如一段 JS 计算或一个事件回调)执行时间过长,导致主线程无法脱身去处理其他宏任务(如 UI 渲染、用户点击)。

  • 如何处理耗时计算? 对于真正 CPU 密集的计算,应该使用 Web Worker。它允许你在一个完全独立的后台线程中运行脚本,不会阻塞主线程。

参考链接

希望读完本文,你对 JavaScript 的运行机制有了更深入的理解。

(完)

❌
❌