普通视图

发现新文章,点击刷新页面。
昨天 — 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! 愿你的日期永远准确,时区永远正确! 🎈

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

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

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 的运行机制有了更深入的理解。

(完)

Node.js v24.6.0 新功能速览 🚀🚀🚀

2025年8月16日 21:23

前言

Node.js v24.6.0 发布了,让我们一起来看看这些朴实却强大的变化!

往期精彩推荐

正文

以下是 v24.6.0 的核心更新和功能的详细介绍。

1. CLI:系统 CA 支持

Node.js v24.6.0 新增 NODE_USE_SYSTEM_CA=1 环境变量,支持使用系统 CA 证书。这简化了企业环境下的证书配置,提升兼容性。

示例

NODE_USE_SYSTEM_CA=1 node app.js

这对需要严格安全合规的场景尤其实用。

2. Crypto:支持 ML-DSA 算法

Crypto 模块新增了对 ML-DSA(Module Lattice-based Digital Signature Algorithm)的支持,包括 KeyObject 生成、签名和验证。这是后量子密码学算法,为未来安全奠定基础。

示例

const crypto = require('crypto');

// 生成 ML-DSA 密钥对
const { publicKey, privateKey } = crypto.generateKeyPairSync('ml-dsa');

// 签名
const signature = crypto.sign(null, Buffer.from('data'), privateKey);

// 验证
const isValid = crypto.verify(null, Buffer.from('data'), publicKey, signature);
console.log(isValid); // true

这为高安全需求的加密应用提供了新选择。

3. Zlib:zstdCompress 和 zstdDecompress 字典支持

Zlib 模块的 zstdCompresszstdDecompress 函数新增字典支持,通过预定义常见模式提升压缩效率。

示例

const zlib = require('zlib');
const dict = Buffer.from('common patterns'); // 自定义字典

const compressed = zlib.zstdCompressSync('data to compress', { dictionary: dict });
const decompressed = zlib.zstdDecompressSync(compressed, { dictionary: dict });
console.log(decompressed.toString()); // 'data to compress'

这优化了数据传输和存储场景。

4. HTTP:新增 keepAliveTimeoutBuffer 选项

HTTP 服务器新增 keepAliveTimeoutBuffer 选项,用于缓冲 keep-alive 超时,提升连接管理效率。

示例

const http = require('http');

const server = http.createServer((req, res) => res.end('Hello'));
server.keepAliveTimeoutBuffer = 1000; // 缓冲 1 秒
server.listen(3000);

这有助于减少网络抖动,提高服务器稳定性。

5. Lib:文档废弃 http*

内部 HTTP 模块的 _http_* 函数已被文档废弃,鼓励使用标准 API,提升代码规范性。

6. FS:移植 SonicBoom 作为 Utf8Stream

FS 模块引入了 Utf8Stream,通过移植 SonicBoom 提升文件流处理性能,适合高吞吐量场景。

7. 其他改进

  • 基准测试:优化基准脚本,提升测试效率。
  • 依赖更新:升级 ada 到 3.2.7、OpenSSL 到 3.5.2,确保安全性。
  • 文档优化:修复 Pbkdf2Params 和 x509.keyUsage 的文档问题。

最后

Node.js 新版本从 Crypto 的后量子算法到 HTTP 的连接优化,这些功能让你的项目更健壮、更高效。快来升级到 v24.6.0,体验这些实用的新特性吧!

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

往期精彩推荐

昨天以前首页

移动端浏览器中设置 100vh 却出现滚动条?

作者 小小愿望
2025年8月16日 17:58

🎉 写在前面 你是否遇到过这样的诡异场景:明明设置了 height: 100vh,却在移动端意外触发了滚动条?本文将从底层原理到实战方案为你彻底剖析这一经典陷阱,并提供多种可靠解决方案。


以下是对问题的详细解答:

一、现象原因分析

  1. 浏览器UI元素的动态特性:移动浏览器(如Chrome、Safari)的地址栏、工具栏等界面组件会根据用户操作(如滚动页面)自动显示或隐藏。这种动态行为会导致视口(viewport)的可用高度发生变化,但 100vh 的值始终基于初始隐藏状态下的视口高度计算,而非实时变化的可见区域高度。

  2. 视口高度计算偏差:当地址栏从隐藏变为可见时,实际可用视口高度会减小,但 100vh 仍保持原值,导致内容超出可视区域,触发滚动条。

  3. 浏览器厂商差异:不同浏览器对视口高度的计算逻辑存在差异,例如 iOS Safari 更倾向于将 100vh 视为未包含地址栏的高度。


二、解决方案

✅方案1:动态计算视口高度 + CSS 变量(推荐)

  1. 核心思路:通过 JavaScript 实时获取 window.innerHeight(当前可视区域高度),将其转换为 CSS 变量,并在样式中使用该变量替代 100vh

  2. 实现步骤

    • JavaScript 部分:监听窗口大小变化事件,动态更新 CSS 变量。
      function setViewportHeight() {
        const innerHeight = window.innerHeight ; 
        document.documentElement.style.setProperty('--innerHeight', `${innerHeight}px`);
      }
      window.addEventListener('resize', setViewportHeight);
      setViewportHeight(); // 初始化
      
    • CSS 部分:使用自定义变量控制元素高度。
      .fullscreen {
        height: var(--innerHeight);
        background: pink;
        overflow: hidden; /* 避免子内容溢出 */
      }
      

    优势:
    ✔️ 完美适配各种设备状态变化
    ✔️ 兼容所有支持 CSS 变量的现代浏览器
    ✔️ 无需修改现有布局结构


✅方案2:绝对定位 + 全屏覆盖

  1. 适用场景:简单布局且需完全覆盖屏幕的元素。

  2. 实现代码

    .fullscreen {
      position: absolute;
      width: 100%;
      height: 100%;
      top: 0;
      left: 0;
      background: lightblue;
    }
    

    注意:
    ⚠️ 如果父元素不是 body,需确保其父级链上的所有元素都有 height: 100%
    ⚠️ 此方法会脱离文档流,可能影响其他元素布局

    适用场景

    • 模态对话框/加载动画等临时全屏组件
    • 视频播放器等需要强制全屏的场景

✅方案3:使用动态视口单位(dvh)

  1. 实验性方案:部分现代浏览器支持 dvh(Dynamic Viewport Units),可直接响应视口变化。
    .fullscreen {
      height: 100dvh; /* 根据最新标准动态计算 */
    }
    
    现状: 📱 仅部分现代浏览器支持(Chrome 88+、Edge 88+) 🚫 iOS Safari 暂未支持 👉 适合作为渐进增强方案,需配合 fallback 使用 在这里插入图片描述

💡 经验之谈:无论采用哪种方案都能解决大多数问题,如果不行可以叠加其他方案试试,只用不断地尝试,不断优化适配策略。

TypeScript 接口入门:定义代码的契约与形态

作者 烛阴
2025年8月16日 17:07

一、什么是接口?

用于描述一个对象的结构。

// 定义一个名为 User 的接口
interface User {
    id: number;
    name: string;
    email: string;
}

function printUserInfo(user: User) {
    console.log(`ID: ${user.id}, Name: ${user.name}, Email: ${user.email}`);
}

const myUser: User = {

    id: 1,
    name: 'Alice',
    email: 'alice@example.com',
};

printUserInfo(myUser); // OK

const invalidUser: User = {
    id: 2,
    username: 'Bob', // 属性名不匹配 编译时错误
    // 缺少 name,email 属性
};


二、接口的丰富特性

1. 可选属性(Optional Properties)

有时,对象的某些属性不是必需的。我们可以使用 ? 来标记它们。

interface UserProfile {
    id: number;
    username: string;
    bio?: string; // bio 是可选的
}

const user1: UserProfile = { id: 1, username: 'Alice' }; // OK
const user2: UserProfile = { id: 2, username: 'Bob', bio: 'Developer' }; // OK

2. 只读属性(Readonly Properties)

我们可以使用 readonly 关键字来防止对象属性在创建后被修改,这对于创建不可变数据非常有用。

interface Point {
    readonly x: number;
    readonly y: number;
}

const p1: Point = { x: 10, y: 20 };
p1.x = 5; // Error: 无法为“x”赋值,因为它是只读属性。

3. 函数类型

接口也能用来定义函数的签名(参数类型和返回值类型)。

interface SearchFunc {
    (source: string, subString: string): boolean;
}

let mySearch: SearchFunc = function (src: string, sub: string) {
    let result = src.search(sub);
    return result > -1;
};

console.log(mySearch('hello', 'll'));

4. 可索引类型(Indexable Types)

接口可以描述那些可以通过索引得到的类型,比如数组和对象。

interface StringArray {
    [index: number]: string; // 索引是数字,值是字符串
}

let myArray: StringArray;
myArray = ['Bob', 'Fred'];
let myStr: string = myArray[0]; // OK
console.log(myStr);


interface Dictionary {
    [key: string]: any; // 索引是字符串,值是任意类型
}

let user: Dictionary = {
    name: '张三',
    age: 18,
    sex: '男',
}

console.log(user.name);

5. 类实现(Class Implementations)

接口可以被类(Class)implements(实现),强制一个类必须遵循接口定义的契约。

interface ClockInterface {
    currentTime: Date;
    setTime(d: Date): void;
}

class Clock implements ClockInterface {
    currentTime: Date = new Date();
    setTime(d: Date) {
        this.currentTime = d;
    }
    constructor(h: number, m: number) {
        this.currentTime.setHours(h);
        this.currentTime.setMinutes(m);
    }

    printTime() {
        console.log(this.currentTime.toLocaleTimeString());
    }
}


let clock = new Clock(12, 30);
clock.printTime(); //12:30:43
clock.setTime(new Date('2024-5-6 09:30:43'));
clock.printTime(); //09:30:43

三、接口的扩展与合并

1. 继承(Extends)

一个接口可以像类一样继承另一个接口,从而复用和扩展类型定义。

interface Shape {
    color: string;
}

interface PenStroke {
    penWidth: number;
}

// Square 继承了 Shape 和 PenStroke
interface Square extends Shape, PenStroke {
    sideLength: number;
}

let square: Square = {
    color: 'blue',
    penWidth: 5.0,
    sideLength: 10,
};

2. 声明合并(Declaration Merging)

这是一个接口独有的、非常强大的特性。如果你在同一个作用域内定义了两个同名的接口,它们会自动合并成一个单一的接口。

interface Box {
    height: number;
    width: number;
}

interface Box {
    scale: number;
}

// 合并后,Box 接口同时拥有 height, width, 和 scale 属性
const box: Box = { height: 5, width: 6, scale: 10 };

常用的用法 扩展第三方库的类型定义。例如,如果你想为 window 对象添加一个自定义属性,你可以这样做,而不会覆盖原有的定义:

// 在你的 .d.ts 文件中
declare global {
    interface Window {
        myAppConfig: object;
    }
}

// 现在你可以在代码中安全地访问它
window.myAppConfig = { version: '1.0' };

总结

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

❌
❌