普通视图

发现新文章,点击刷新页面。
昨天 — 2025年11月15日掘金 前端

解决 uniapp H5 与原生应用通信的坑:一个经过实战验证的解决方案

2025年11月15日 10:56

解决 uniapp H5 与原生应用通信的坑:一个经过实战验证的解决方案

📖 前言

在开发 uniapp 项目时,你是否遇到过这样的问题:将 uniapp 打包成 H5 后,需要向原生应用发送消息,但发现常用的 uni.postMessagewindow.parent.postMessage 方法都不起作用?

这个问题困扰了我很久,经过大量的测试和验证,我终于找到了唯一有效的解决方案,并基于此封装了自定义的 webUni 通信工具。今天就来分享这个经过实战验证的解决方案。

🔍 问题背景

在混合开发场景中,我们经常需要将 uniapp 打包成 H5,然后嵌入到不同的容器中:

  1. 原生 App 的 WebView:H5 页面需要与原生应用进行通信
  2. 微信小程序的 web-view:H5 页面需要与小程序进行通信

常见的通信需求包括:

  • 关闭 WebView
  • 传递登录信息
  • 同步页面状态
  • 触发原生功能

❌ 常见的失败尝试

在寻找解决方案的过程中,我尝试了多种方法,但都失败了:

方法一:使用 uni.postMessage

// ❌ 在 H5 环境下无法正常工作
uni.postMessage({
  data: {
    action: 'closeWebView'
  }
})

方法二:使用 window.parent.postMessage

// ❌ 在 H5 环境下无法正常工作
window.parent.postMessage({
  type: 'message',
  data: { action: 'closeWebView' }
}, '*')

方法三:使用 plus.webview.postMessage

// ❌ 在 H5 环境下 plus 对象不存在
plus.webview.postMessage({
  data: { action: 'closeWebView' }
})

✅ 唯一有效的解决方案

经过大量测试验证,我找到了不同环境下的有效通信方式:

  1. App 环境:使用 webUni.postMessage 方式
  2. 微信小程序 web-view 环境:使用 wx.miniProgram.postMessage 方式

核心原理

我基于不同环境的通信机制封装了一个统一的工具对象,核心特性包括:

  1. 自动环境检测:自动判断运行环境(App、微信小程序、普通 H5)
  2. 手动指定环境:支持通过参数显式指定目标环境(推荐使用,避免检测问题)
  3. 多端兼容:支持多种原生容器(DCloud、5+ Runtime、微信小程序等)
  4. 统一 API:提供统一的接口,无需关心底层实现
  5. 消息封装:将消息封装成标准格式传递给对应容器

微信小程序特殊处理

重要发现:在微信小程序的 web-view 中,如果只调用 wx.miniProgram.postMessage,消息会在页面销毁后才被小程序接收到。这是微信官方的已知问题。

解决方案:在发送消息后,立即调用 wx.miniProgram.navigateBack(),这样消息会立即被小程序接收。插件提供了 autoNavigateBack 选项来自动处理这个问题。

实现代码(自定义封装摘录)

// ✅ 支持手动指定环境或自动检测环境的完整实现
const webviewPostMessage = {
    // 静态方法:主动检测环境
    detectEnv: function() {
        const env = {};
        // 检测App环境
        if (window.plus) {
            env.plus = true;
            env.app = true;
        }
        // 检测微信小程序环境
        else if (window.wx && window.wx.miniProgram) {
            env.wechat = true;
            env.miniprogram = true;
        }
        // 其他为普通H5环境
        else {
            env.h5 = true;
        }
        return env;
    },
    
    // 发送消息方法(支持手动指定环境)
    postMessage: function(options) {
        try {
            if (!options || typeof options !== 'object') {
                console.warn('[webview-postmessage] postMessage 需要传入一个对象参数');
                return;
            }
            if (!options.data) {
                console.warn('[webview-postmessage] postMessage 需要传入 data 属性');
                return;
            }
            
            // 优先使用手动指定的环境
            const manualEnv = options.env;
            
            // 根据手动指定的环境或自动检测来决定发送方式
            if (manualEnv === 'mp-weixin') {
                console.log('[webview-postmessage] 手动指定微信小程序环境');
                // 强制使用微信小程序方式发送消息
                try {
                    wx.miniProgram.postMessage({ data: options.data });
                    
                    // 如果设置了autoNavigateBack为true,则自动调用navigateBack
                    if (options.autoNavigateBack === true) {
                        setTimeout(() => {
                            wx.miniProgram.postMessage({ data: options.data });
                        }, 100);
                    }
                } catch (wechatError) {
                    console.error('[webview-postmessage] 微信小程序环境发送消息失败:', wechatError);
                    // 失败后尝试回退到原始方式
                    webUni.postMessage(options);
                }
            } else if (manualEnv === 'app') {
                console.log('[webview-postmessage] 手动指定App环境');
                // 强制使用App方式发送消息
                webUni.postMessage(options);
            } else {
                // 自动检测环境(向后兼容)
                if (window.wx && window.wx.miniProgram) {
                    console.log('[webview-postmessage] 自动检测到微信小程序环境');
                    wx.miniProgram.postMessage({ data: options.data });
                    
                    if (options.autoNavigateBack === true) {
                        setTimeout(() => {
                            wx.miniProgram.postMessage({ data: options.data });
                        }, 100);
                    }
                } else {
                    console.log('[webview-postmessage] 自动检测到App或普通H5环境');
                    webUni.postMessage(options);
                }
            }
        } catch (error) {
            console.error('[webview-postmessage] 发送消息失败:', error);
        }
    }
};

// 使用方式1:手动指定环境(推荐)
webviewPostMessage.postMessage({
    data: {
        action: 'closeWebView'
    },
    env: 'app'  // 手动指定环境为App
})

// 使用方式2:手动指定微信小程序环境
webviewPostMessage.postMessage({
    data: {
        action: 'closeWebView'
    },
    env: 'mp-weixin',  // 手动指定环境为微信小程序
    autoNavigateBack: true  // 自动调用navigateBack
})

// 使用方式3:自动检测环境(兼容性模式)
webviewPostMessage.postMessage({
    data: {
        action: 'closeWebView'
    }
})

🚀 自研通信插件(uni_modules 版本)

目前我将这套封装以 uni_modules/webview-postmessage 的形式维护在项目里,尚未发布到 npm 或插件市场。想要使用时,只需把整个目录复制到自己的项目中即可。

获取方式

  1. 在代码仓库中找到 uni_modules/webview-postmessage
  2. 将该目录复制到你项目的 uni_modules
  3. 若需要分享给团队,可直接 zip 打包该目录发送

基础使用

强烈推荐手动指定宿主类型!

import webviewPostMessage from '@/uni_modules/webview-postmessage/index.js'

// 推荐:手动指定App环境
webviewPostMessage.postMessage({ 
  data: { 
    action: 'closeWebView' 
  },
  env: 'app'  // 手动指定环境为App
})

// 推荐:手动指定微信小程序环境(并自动返回)
webviewPostMessage.postMessage({ 
  data: { 
    action: 'closeWebView' 
  },
  env: 'mp-weixin',  // 手动指定环境为微信小程序
  autoNavigateBack: true  // 自动调用 navigateBack,确保消息立即被接收
})

// 不推荐:自动检测环境(可能存在兼容性问题)
/*
webviewPostMessage.postMessage({ 
  data: { 
    action: 'closeWebView' 
  } 
})
*/

Vue3 Composition API 示例

<template>
  <view>
    <button @click="sendMessage">发送消息</button>
    <button @click="sendMessageAndBack">发送消息并返回</button>
  </view>
</template>

<script setup>
import webviewPostMessage from '@/uni_modules/webview-postmessage/index.js'

// 普通发送消息 - 推荐手动指定环境类型
const sendMessage = () => {
  webviewPostMessage.postMessage({
    data: {
      action: 'closeWebView',
      // 可以传递其他数据
      token: 'xxx',
      userId: 123
    },
    env: 'app'  // 手动指定环境为App
  })
}

// 微信小程序环境:发送后自动返回(推荐手动指定环境类型)
const sendMessageAndBack = () => {
  webviewPostMessage.postMessage({
    data: {
      action: 'closeWebView',
      token: 'xxx',
      userId: 123
    },
    env: 'mp-weixin',  // 手动指定环境为微信小程序
    autoNavigateBack: true  // 自动返回,确保消息立即被接收
  })
}
</script>

Vue2 Options API 示例

<template>
  <view>
    <button @click="handleClose">关闭页面</button>
  </view>
</template>

<script>
import webviewPostMessage from '@/uni_modules/webview-postmessage/index.js'

export default {
  methods: {
    handleClose() {
      webviewPostMessage.postMessage({
        data: {
          action: 'closeWebView'
        },
        env: 'app'  // 推荐:手动指定环境为App
      })
    }
  }
}
</script>

💡 实际应用场景

场景一:关闭 WebView

// 用户点击返回按钮时关闭 WebView - 推荐手动指定环境
const handleBack = () => {
  webviewPostMessage.postMessage({
    data: {
      action: 'closeWebView'
    },
    env: 'app'  // 手动指定环境为App
  })
}

场景二:传递登录信息

// 登录成功后,将 token 传递给原生应用 - 推荐手动指定环境
const handleLogin = async (username, password) => {
  const result = await loginAPI(username, password)
  
  webviewPostMessage.postMessage({
    data: {
      action: 'login',
      token: result.token,
      userId: result.userId,
      userInfo: result.userInfo
    },
    env: 'app'  // 手动指定环境为App
  })
}

场景三:同步页面状态

// 页面加载完成后通知原生应用 - 推荐手动指定环境
onMounted(() => {
  webviewPostMessage.postMessage({
    data: {
      action: 'pageLoaded',
      pageId: 'home',
      timestamp: Date.now()
    },
    env: 'app'  // 手动指定环境为App
  })
})

场景四:触发原生功能

// 需要调用原生分享功能 - 推荐手动指定环境
const handleShare = () => {
  webviewPostMessage.postMessage({
    data: {
      action: 'share',
      title: '分享标题',
      content: '分享内容',
      url: 'https://example.com'
    },
    env: 'app'  // 手动指定环境为App
  })
}

📋 API 说明

postMessage(options)

向原生应用或微信小程序发送消息(推荐手动指定环境类型)

参数:

参数 类型 必填 说明
options Object 消息选项
options.data Object 要发送的数据对象
options.env String 推荐 手动指定环境类型:'mp-weixin'(微信小程序)、'app'(App环境) - 推荐使用,避免自动检测问题
options.autoNavigateBack Boolean 是否在微信小程序环境中自动调用 navigateBack(解决消息延迟问题,默认 false)

示例:

// App 环境 - 推荐手动指定环境
webviewPostMessage.postMessage({
  data: {
    action: 'customAction',
    type: 'userAction',
    value: 'someValue',
    // 可以传递任意 JSON 可序列化的数据
    nested: {
      key: 'value'
    }
  },
  env: 'app'  // 手动指定环境为App
})

// 微信小程序环境,发送后自动返回 - 推荐手动指定环境
webviewPostMessage.postMessage({
  data: {
    action: 'closeWebView',
    token: 'xxx'
  },
  env: 'mp-weixin',  // 手动指定环境为微信小程序
  autoNavigateBack: true  // 自动调用 navigateBack,确保消息立即被接收
})

⚠️ 注意事项

  1. 适用于 H5 环境:此插件主要用于 uniapp 打包成 H5 后的消息通信

    • App 环境:使用 webUni.postMessage
    • 微信小程序 web-view 环境:使用 wx.miniProgram.postMessage
    • 需要引入 jweixin.js 才能支持微信小程序环境
  2. 必须传递 data 属性:postMessage 方法必须传入包含 data 属性的对象

  3. 数据格式:data 可以是任意 JSON 可序列化的对象

  4. 强烈推荐手动指定宿主类型:为了避免自动环境检测可能出现的问题(如环境特征不明显导致检测失败),强烈推荐通过 env 参数手动指定当前环境类型为 'app''mp-weixin'

  5. 环境选择指南:在 App 中嵌套运行时指定为 'app',在微信小程序 web-view 中运行时指定为 'mp-weixin'

  6. 微信小程序消息延迟问题:在微信小程序的 web-view 中,如果只发送消息不调用 navigateBack,消息会在页面销毁后才被接收。建议使用 autoNavigateBack: true 选项自动处理

  7. 原生应用/小程序需要监听消息:需要在对应端实现消息监听逻辑来接收消息

🧪 测试验证

此插件经过以下环境测试:

  • ✅ uniapp 打包成 H5
  • ✅ App 环境(Android/iOS)
  • ✅ 微信小程序 web-view 环境
  • ✅ 微信浏览器
  • ✅ Chrome 浏览器
  • ✅ Safari 浏览器
  • ✅ Android 浏览器
  • ✅ iOS Safari
  • ✅ Vue2 和 Vue3

❓ 常见问题

Q: 为什么不能使用 uni.postMessage

A: 经过测试,uni.postMessage 在 H5 环境下无法正常工作,只有 webUni.postMessage 方式有效。这是因为 uniapp 在 H5 环境下的消息通信机制与 App 端不同。

Q: 支持哪些平台?

A: 插件支持以下平台:

  • App 环境:使用 webUni.postMessage(DCloud、5+ Runtime)
  • 微信小程序 web-view 环境:使用 wx.miniProgram.postMessage(需要引入 jweixin.js)
  • 普通 H5 环境:使用 webUni.postMessagewindow.parent.postMessage

注意:虽然插件支持自动检测环境,但强烈推荐通过 env 参数手动指定环境类型,以避免检测问题。

Q: 微信小程序中消息为什么在页面销毁后才收到?

A: 这是微信官方的已知问题。在 web-view 中只调用 postMessage 时,消息会被缓存,直到页面销毁才被小程序接收。解决方案是在发送消息后立即调用 wx.miniProgram.navigateBack(),插件提供了 autoNavigateBack: true 选项来自动处理这个问题。

Q: 如何在微信小程序环境中使用?

A: 需要确保在 H5 页面中引入了 jweixin.js(微信 JS-SDK)。可以通过以下方式引入:

<!-- 在 template.h5.html 中引入(Vue2) -->
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>

<!-- 或者在 Vue3 项目中,在 main.js 中动态引入 -->

然后在代码中使用:

import webviewPostMessage from '@/uni_modules/webview-postmessage/index.js'

// 发送消息并自动返回(解决延迟问题)
webviewPostMessage.postMessage({
  data: { action: 'closeWebView' },
  autoNavigateBack: true
})

Q: 可以传递什么类型的数据?

A: 可以传递任何 JSON 可序列化的数据,包括对象、数组、字符串、数字等。

Q: 原生应用如何接收消息?

A: 原生应用需要在 WebView 中实现消息监听。以 Android 为例:

webView.addJavascriptInterface(new Object() {
    @JavascriptInterface
    public void postMessage(String message) {
        // 处理接收到的消息
        JSONObject data = new JSONObject(message);
        String action = data.optString("action");
        // ... 处理逻辑
    }
}, "webUni");

Q: 微信小程序如何接收消息?

A: 在小程序页面的 web-view 组件上绑定 @message 事件:

<template>
  <web-view :src="url" @message="onMessage"></web-view>
</template>

<script>
export default {
  methods: {
    onMessage(e) {
      // e.detail.data 是一个数组,包含所有发送的消息
      const messages = e.detail.data || [];
      messages.forEach(msg => {
        if (msg.action === 'closeWebView') {
          uni.navigateBack();
        }
        // 处理其他消息...
      });
    }
  }
}
</script>

📦 项目状态

  • 存放位置uni_modules/webview-postmessage
  • 使用方式:直接复制到业务项目的 uni_modules 目录
  • 插件市场:已发布到 uni-app 插件市场
  • 维护方式:通过 changelog.md 记录改动,手动同步到各个项目
  • 最新版本:v1.4.0
  • 最新版本特性:支持手动指定环境类型,优化了错误处理和文档说明

🎯 总结

通过本文的分享,我们了解到:

  1. 问题根源

    • uniapp 在 H5 环境下的消息通信机制与 App 端不同
    • 微信小程序 web-view 环境有特殊的通信方式和延迟问题
  2. 解决方案

    • App 环境:使用 webUni.postMessage
    • 微信小程序环境:使用 wx.miniProgram.postMessage + navigateBack(解决延迟)
    • 支持手动指定环境类型,避免自动检测可能出现的问题
  3. 最佳实践

    • 强烈推荐手动指定环境类型,通过 env 参数明确指定 'app''mp-weixin'
    • 封装统一的 API,自动检测环境作为备用方案
    • 提供 autoNavigateBack 选项解决微信小程序消息延迟问题
    • 以插件形式提供,方便使用和维护

希望这个解决方案能够帮助到遇到同样问题的开发者。如果你觉得有用,欢迎 Star 和分享!

🔗 相关链接


如果这篇文章对你有帮助,欢迎点赞、收藏、评论! 🎉

JavaScript 原型/原型链

2025年11月15日 10:11

热身

先来看一道题:

请你编写一个函数,检查给定的值是否是给定类或超类的实例。

可以传递给函数的数据类型没有限制。例如,值或类可能是 undefined 。

Leetcode:2618 检查是否类的对象实例

你可能会很轻松的用 instanceof 写出来:obj instanceof class 这种形式的判断。

class 是 es6 引入的新的一个关键字,他可以让我们在 JavaScript 中更加容易使用面向对象的思维去编写代码,让对象,原型这些东西更加清晰。

同样的,引入 instanceof 则是方便我们去理解,不必写一套复杂的代码来判断实例和类的关系。

那么,class 这个关键字帮我们做了什么?而 instanceof 又帮我们简化了什么?

原型

首先依旧是来讲那些前人已经说烂了的东西,原型。

原型设计模式

原型首先是面向对象编程中的一个概念,是一种设计模式,他的核心思想是,通过共享属性和方法来实现对象之间的代码重用。说实话,这个听着令人感觉像是在讲类继承,目前的编程语言多是通过类继承的方式支持面向对象的,而我们所使用的 JavaScript 则是通过原型链来支持的面向对象的[1],且按下不表。

我们不妨先给原型下一个定义:原型就是一个用于创建对象的模板,他定义了一系列的属性和方法,基于原型创建的对象之间共享一些属性和方法。

根据定义,我们写出的 JavaScript 的实现代码应该是这样的:

const boxPrototype = {
  value: 1,
  getValue() {
    return this.value;
  }
}

const box1 = {};
const box2 = {};
const box3 = {};
Object.assign(box1, boxPrototype);
Object.assign(box2, boxPrototype);
box2.value = 2;
Object.assign(box3, boxPrototype);
box3.value = 3;

box1.getValue === boxPrototype.getValue; // true
box2.getValue === boxPrototype.getValue; // true
box3.getValue === boxPrototype.getValue; // true
  1. 我们定义了一个原型 boxPrototype
  2. 基于原型 boxPrototype 创建(即拷贝、复制)了三个对象
  3. 三个对象之间各自有自己的 value 值,但是引用都是的 boxPrototypegetValue 函数,即共享了方法。

原型设计模式最主要的优点是减少了对象创建的时间和成本,通过拷贝原型对象来创建新的对象,避免重复使用构造函数初始化的操作,提高了创建对象的效率。

在不同的编程语言中,原型设计模式的实现均有差异(深拷贝、浅拷贝以及语言特性),在 JavaScript 自然也是可以有不一样的实现。

JavaScript 中的原型

先给出答案:原型实际就是一个对象

我们在上文的原型设计模式中接触到了原型对象克隆对象,并给出了 JavaScript 中的一种实现,但是我们不难发现,在这样的实现下,原型对象和克隆对象是比较独立的两个对象(除了函数能够共享,其他属性互相之间是无法访问到的)。

想要被访问到也不是不可以,只要我们保存原型对象的链接即可,同时为了避免混乱,我们为其制定了标准:

遵循 ECMAScript 标准,符号 someObject.[[Prototype]] 用于标识 someObject 的原型。内部插槽 [[Prototype]] 可以通过 Object.getPrototypeOf()Object.setPrototypeOf() 函数来访问。这个等同于 JavaScript 的非标准但被许多 JavaScript 引擎实现的属性 __proto__ 访问器。为在保持简洁的同时避免混淆,在我们的符号中会避免使用 obj.__proto__,而是使用 obj.[[Prototype]] 作为代替。其对应于 Object.getPrototypeOf(obj) [1]。

规范很好理解,规范中声明的 [[Prototype]] 可以认为是一个对象的私有属性,想要对其操作,那么就需要实现对应的 get/set 方法。__proto__ 则是以前没有一套行业执行规范时,各个厂家自己实现的一套逻辑。

值得注意的是,JS 函数中的 prototype 属性和 [[Prototype]] 还有一定的差别,不可以混淆。

原型链

在使用原型创建对象的时候,每个对象都有一个指向原型的链接,这个一条链就被称为原型链。

有了这条链接,我们便可以使通过原型创建出来的对象可以访问到原型上的方法和属性,也就是说,当我们访问一个对象的属性时,如果这个对象本身不存在该属性,那么就会去原型上查找,其访问顺序应当是:当前对象 -> 原型 -> 原型的原型 -> …… -> 最初的原型。null 标志着原型链的终结,当某个对象的原型指向 null 时,便不再继续向上搜索。

继承

如果你已经有了一定的编程语言的基础,那么你可能首先会联想到的是类的继承,A extends B 那么 A 便可以访问 A 对象中的所有属性和方法,以及从 B 中继承下来的 public 和 protected 属性和方法,而 B extends C则可以类推。

可以通过这个去理解原型链,但是要知道他们是不同的。

硬要说的话,可以说他是一个 继承链 ,同样也是做到了方法属性的复用,但不一样的是,他们无法“共享”。

举个例子:

// 继承
class Animal {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  changeName(name: string) {
    this.name = name;
  }
}

class Dog extends Animal {
}

class Cat extends Animal {
  changeName(name: string) {
    this.name = 'cat' + name;
  }
}

const animal = new Animal("1");
console.log(animal.name); // 1
animal.changeName("111");
console.log(animal.name); // 111
const dog = new Dog("2");
dog.changeName("222");
console.log(dog.name, animal.name); // 222 111
const cat = new Cat("3");
console.log(cat.name); // 3
cat.changeName("333");
console.log(cat.name, dog.name, animal.name); // cat333 222 111

上面这段代码是比较典型的继承,Dog 和 Cat 类都继承自 Animal 类,自然的,这里 Animal 类中定义的属性 name 和方法 changeName 都被继承下来了,但不同的是,Cat 类里面我们覆写了 changeName 方法,所以我们在实例化这三个类后,分别调用 changeName 方法,并打印方法调用前后 name 属性变化的情况,不难发现,每个对象仅改变了自己的属性,并没有对其他对象造成影响(也就是说他们互相之间无法访问属性),而且即使 Dog 类中没有写任何内容,但是他继承了父类的所有内容,所以他依旧可以访问到对应的属性和方法。

原型链

原型是“共享”属性和方法,以做到代码复用:

const anObj = {};
const anObjPrototype = { df: 1 };

Object.setPrototypeOf(anObj, anObjPrototype);

console.log(anObj.df); // 1,访问到了 anObjPrototype 的 df 属性值
anObj.df = 2;
console.log(anObj.df, anObjPrototype.df); // 2, 1,修改了 anObj 的 df 属性值,但对于其原型 anObjPrototype 没有影响,等于在 anObj 对象中创建了一个 df 属性,并赋值为 1

Object.getPrototypeOf(anObj) === anObjPrototype; // true,说明 anObj 的原型保存的是 anObjPrototype 的对象地址

这段代码非常简单,我们让 anObj 对象保存了原型对象 anObjPrototype 的地址,按照我们之前对原型链的定义,由于 anObj 中没有属性 df ,所以我们去他的原型上搜索,获取到值。

而我们在后面给 anObjdf 属性赋值为 2,由于其本身是没有这个属性的,所以我们这里的操作实际可以看做两步:

  1. anObj 对象中创建属性 df
  2. 为该属性赋值

而后再访问属性 df 时,由于已经在 anObj 对象中找到了对应的属性,所以就不再继续向上搜索了,即使其原型对象上存在一个相同的属性,这个就是所谓的属性遮蔽

如果在 anObjPrototype 中也没找到的话,那就返回 undefined。

对比上面两种形式,不难看出我们之前所说的继承实际上各个实例对象之间是没有关联的,而在原型链上,对象及其原型对象是通过一个链接(或者说一个指针指向的关系)关联上的,对象可以访问到其原型上的一些属性。

一些特别的情况

查漏补缺。

我们通过字面量创建的对象,会隐式的设置其 prototype:

// 对象字面量(没有 `__proto__` 键)自动将 `Object.prototype` 作为它们的 `[[Prototype]]`
const object = { a: 1 };
Object.getPrototypeOf(object) === Object.prototype; // true

// 数组字面量自动将 `Array.prototype` 作为它们的 `[[Prototype]]`
const array = [1, 2, 3];
Object.getPrototypeOf(array) === Array.prototype; // true

// 正则表达式字面量自动将 `RegExp.prototype` 作为它们的 `[[Prototype]]`
const regexp = /abc/;
Object.getPrototypeOf(regexp) === RegExp.prototype; // true

本段代码来源于 MDN。

func.prototype 指向的是构造函数,通过 new func() 方式创建的对象,会自动的将 func.prototype 作为自己的原型。

function Box(name: string) {
  this.name = name;
}

Box.prototype; // {constructor: f}

const box = new Box('hen');
box.prototype; // 

定义构造函数是使用 new 调用的函数,如 new Box();

基于原型链的继承

图穷匕见,JavaScript 的继承模式是 原型继承 ,尽管现在语言已经支持了 class 关键字,即类的说法,但实际并没有改变他的继承模式。

那么什么是原型继承呢?

我们在原型链一节中讲到,JavaScript 通过原型链来实现代码的复用,同时阅读对应的示例代码可以发现,我们通过构造了一个原型链,使得对象能够访问到其原型的属性,这就是继承了属性

继承“方法” ,本质和继承属性一样,这时候的 属性遮蔽 我们可以则类比为 “方法重写”。但是这里有一个 JavaScript 的特别之处,也是一个难点,即 this ,当继承的函数被调用的时候,this 会指向当前对象,而不是拥有该函数的原型对象。

const anObjPrototype = {
  a: 1,
  getValue() {
    return this.a;
  }
}

const anObj = {};
Object.setPrototypeOf(anObj, anObjPrototype);

// 修改 anObj 中属性 a 的值
anObj.a = 2;
anObj.getValue(); // 2,得到的是 anObj 自己的属性值。

运行上述代码,你会发现虽然 getValue 是属于原型对象 anObjPrototype 的,但是最终 anObj 调用该方法的时候得到的是对象 anObj 的属性值,而非原型对象的值。

最后

回到我们开篇的问题,class 关键字帮我们做了什么?instanceof 关键字又帮我们简化了什么?

这两个问题的答案已经十分清晰了,但也不妨在这里做个总结:

class 本质是一个语法糖,他帮我们处理绑定每个对象的原型,实现属性的共享。

// 无 class 的写法
function Box(name: string) {
  this.name = name;
}

Box.prototype.getName = function () {
  return this.name;
}

const box1 = new Box(1);
const box2 = new Box(2);
box1.getValue === box2.getValue; // true

// 对应的 class 写法
class Box {
  constructor(name: string) {
    this.name = name;
  }

  getName() {
    return this.name;
  }
}

显而易见的,class 的写法更符合我们面向对象的编程习惯,function 的写法则相对不那么直观。

instanceof 则是帮我们检查实例对象是否是某个类的对象,实际上,我们在 JavaScript 中的描述应该是,用于检测构造函数的 prototype 是否出现在了实例对象的原型链上。

function Box() {
}
const box = new Box(); // 前文定义了使用 new 调用的函数就是构造函数

box instanceof Box; // true
Object.getPrototypeOf(box) === Box.prototype; // true

由此我们可以推导出,如果想在 JavaScript 中实现继承,我们可以构造一个很长的原型链:

function Animal(name: string) {
  this.name = name;
}

function Cat() {}
Object.setPrototypeOf(Cat.prototype, Animal.prototype); // Cat 继承自 Animal
function Dog() {}
Object.setPrototypeOf(Dog.prototype, Animal.prototype); // Dog 继承自 Animal

代码中我们在修改了原型之后,Cat 和 Dog 就变成了 Animal 的一个子类,Animal 则作为基类存在,在 function 这种写法下,他并不是那么直观,但是,我们将其转换成 class 的写法后,一切会更加清晰易读。

不妨躬身一试。

一些思考

说 js 中类的性能比较差,但这并不是说我们一定要用以前这种比较拗口的形式去面向对象编程,这是一种取舍,让我们在代码的可读性和可维护性和性能之间权衡,我们未必需要保证我们编写的代码去追求极致的性能,但是我们需要保证我们的代码符合一定的规范,让别人维护时不至于会高兴的蹦起来去指责前人的代码。

时代总是在进步的,编译器、引擎也是在更新换代,在努力解决这些问题,有时候极致的性能就意味着厚重的技术债务,后人对你当前引以为豪的代码无从下手。

当然,以上是从业务角度出发的思考,如果你是写高性能库的,看看就好。

参考文章

[1]: 继承与原型链 - JavaScript | MDN

[2]: 对象原型 - 学习 Web 开发 | MDN

[3]: 基于原型编程 - MDN Web 文档术语表:Web 相关术语的定义 | MDN

ArkTs单元测试 UnitTest 指南

作者 littleplayer
2025年11月15日 09:40

ArkTS 提供了完善的单元测试框架,支持对 HarmonyOS 应用进行单元测试。以下是完整的 ArkTS 单元测试指南:

1. 测试环境配置

1.1 项目结构

project/
├── src/
│   └── main/
│       └── ets/
│           └── ...
├── ohosTest/
│   └── src/
│       └── test/
│           └── ets/
│               └── test/
│                   └── Example.test.ets
│               └── TestAbility.ts
│           └── resources/
│       └── module.json5

1.2 module.json5 配置

// ohosTest/src/module.json5
{
  "module": {
    "name": "test",
    "type": "feature",
    "srcEntrance": "./ets/TestAbility.ts",
    "description": "$string:TestAbility_desc",
    "mainElement": "TestAbility",
    "deviceTypes": [
      "phone",
      "tablet"
    ],
    "deliveryWithInstall": true,
    "installationFree": false,
    "pages": "$profile:test_pages",
    "abilities": [
      {
        "name": "TestAbility",
        "srcEntrance": "./ets/TestAbility.ts",
        "description": "$string:TestAbility_desc",
        "icon": "$media:icon",
        "label": "$string:TestAbility_label",
        "startWindowIcon": "$media:icon",
        "startWindowBackground": "$color:start_window_background",
        "visible": true,
        "skills": [
          {
            "actions": [
              "action.system.home"
            ],
            "entities": [
              "entity.system.home"
            ]
          }
        ]
      }
    ]
  }
}

2. 基础单元测试

2.1 工具类测试

// src/main/ets/utils/MathUtil.ets
export class MathUtil {
  static add(a: number, b: number): number {
    return a + b;
  }

  static subtract(a: number, b: number): number {
    return a - b;
  }

  static multiply(a: number, b: number): number {
    return a * b;
  }

  static divide(a: number, b: number): number {
    if (b === 0) {
      throw new Error('Division by zero');
    }
    return a / b;
  }

  static isEven(num: number): boolean {
    return num % 2 === 0;
  }

  static factorial(n: number): number {
    if (n < 0) throw new Error('Negative number');
    if (n === 0 || n === 1) return 1;
    return n * this.factorial(n - 1);
  }
}
// ohosTest/src/test/ets/test/MathUtil.test.ets
import { describe, it, expect } from '@ohos/hypium';
import { MathUtil } from '../../../src/main/ets/utils/MathUtil';

export default function mathUtilTest() {
  describe('MathUtil Tests', () => {
    it('should_add_two_numbers', 0, () => {
      const result = MathUtil.add(2, 3);
      expect(result).assertEqual(5);
    });

    it('should_subtract_two_numbers', 0, () => {
      const result = MathUtil.subtract(5, 3);
      expect(result).assertEqual(2);
    });

    it('should_multiply_two_numbers', 0, () => {
      const result = MathUtil.multiply(4, 3);
      expect(result).assertEqual(12);
    });

    it('should_divide_two_numbers', 0, () => {
      const result = MathUtil.divide(10, 2);
      expect(result).assertEqual(5);
    });

    it('should_throw_error_when_dividing_by_zero', 0, () => {
      try {
        MathUtil.divide(10, 0);
        expect(true).assertFalse(); // 不应该执行到这里
      } catch (error) {
        expect(error.message).assertEqual('Division by zero');
      }
    });

    it('should_detect_even_numbers', 0, () => {
      expect(MathUtil.isEven(4)).assertTrue();
      expect(MathUtil.isEven(7)).assertFalse();
    });

    it('should_calculate_factorial', 0, () => {
      expect(MathUtil.factorial(5)).assertEqual(120);
      expect(MathUtil.factorial(0)).assertEqual(1);
    });

    it('should_throw_error_for_negative_factorial', 0, () => {
      try {
        MathUtil.factorial(-1);
        expect(true).assertFalse();
      } catch (error) {
        expect(error.message).assertEqual('Negative number');
      }
    });
  });
}

2.2 业务逻辑测试

// src/main/ets/services/UserService.ets
export class UserService {
  private users: Map<string, User> = new Map();

  addUser(user: User): boolean {
    if (this.users.has(user.id)) {
      return false;
    }
    this.users.set(user.id, user);
    return true;
  }

  getUser(id: string): User | undefined {
    return this.users.get(id);
  }

  deleteUser(id: string): boolean {
    return this.users.delete(id);
  }

  getAllUsers(): User[] {
    return Array.from(this.users.values());
  }

  updateUser(user: User): boolean {
    if (!this.users.has(user.id)) {
      return false;
    }
    this.users.set(user.id, user);
    return true;
  }
}

export class User {
  constructor(
    public id: string,
    public name: string,
    public email: string,
    public age: number
  ) {}
}
// ohosTest/src/test/ets/test/UserService.test.ets
import { describe, it, expect, beforeEach } from '@ohos/hypium';
import { UserService, User } from '../../../src/main/ets/services/UserService';

export default function userServiceTest() {
  describe('UserService Tests', () => {
    let userService: UserService;

    beforeEach(() => {
      userService = new UserService();
    });

    it('should_add_user_successfully', 0, () => {
      const user = new User('1', 'John Doe', 'john@example.com', 30);
      const result = userService.addUser(user);
      
      expect(result).assertTrue();
      expect(userService.getUser('1')).assertEqual(user);
    });

    it('should_not_add_duplicate_user', 0, () => {
      const user = new User('1', 'John Doe', 'john@example.com', 30);
      userService.addUser(user);
      const result = userService.addUser(user);
      
      expect(result).assertFalse();
    });

    it('should_get_user_by_id', 0, () => {
      const user = new User('1', 'John Doe', 'john@example.com', 30);
      userService.addUser(user);
      
      const retrievedUser = userService.getUser('1');
      expect(retrievedUser).assertEqual(user);
    });

    it('should_return_undefined_for_nonexistent_user', 0, () => {
      const retrievedUser = userService.getUser('nonexistent');
      expect(retrievedUser).assertUndefined();
    });

    it('should_delete_user', 0, () => {
      const user = new User('1', 'John Doe', 'john@example.com', 30);
      userService.addUser(user);
      
      const deleteResult = userService.deleteUser('1');
      expect(deleteResult).assertTrue();
      expect(userService.getUser('1')).assertUndefined();
    });

    it('should_return_false_when_deleting_nonexistent_user', 0, () => {
      const deleteResult = userService.deleteUser('nonexistent');
      expect(deleteResult).assertFalse();
    });

    it('should_get_all_users', 0, () => {
      const user1 = new User('1', 'John Doe', 'john@example.com', 30);
      const user2 = new User('2', 'Jane Smith', 'jane@example.com', 25);
      
      userService.addUser(user1);
      userService.addUser(user2);
      
      const allUsers = userService.getAllUsers();
      expect(allUsers.length).assertEqual(2);
      expect(allUsers).assertContain(user1);
      expect(allUsers).assertContain(user2);
    });

    it('should_update_user', 0, () => {
      const user = new User('1', 'John Doe', 'john@example.com', 30);
      userService.addUser(user);
      
      const updatedUser = new User('1', 'John Updated', 'updated@example.com', 31);
      const updateResult = userService.updateUser(updatedUser);
      
      expect(updateResult).assertTrue();
      expect(userService.getUser('1')).assertEqual(updatedUser);
    });

    it('should_return_false_when_updating_nonexistent_user', 0, () => {
      const user = new User('1', 'John Doe', 'john@example.com', 30);
      const updateResult = userService.updateUser(user);
      
      expect(updateResult).assertFalse();
    });
  });
}

3. 异步测试

3.1 异步服务测试

// src/main/ets/services/ApiService.ets
export class ApiService {
  async fetchData(url: string): Promise<string> {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        if (url.startsWith('https://')) {
          resolve(`Data from ${url}`);
        } else {
          reject(new Error('Invalid URL'));
        }
      }, 100);
    });
  }

  async processUserData(userId: string): Promise<UserData> {
    // 模拟 API 调用
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve({
          id: userId,
          name: `User ${userId}`,
          score: Math.random() * 100
        });
      }, 50);
    });
  }
}

export interface UserData {
  id: string;
  name: string;
  score: number;
}
// ohosTest/src/test/ets/test/ApiService.test.ets
import { describe, it, expect } from '@ohos/hypium';
import { ApiService, UserData } from '../../../src/main/ets/services/ApiService';

export default function apiServiceTest() {
  describe('ApiService Tests', () => {
    const apiService = new ApiService();

    it('should_fetch_data_successfully', 0, async () => {
      const result = await apiService.fetchData('https://api.example.com/data');
      expect(result).assertEqual('Data from https://api.example.com/data');
    });

    it('should_reject_invalid_url', 0, async () => {
      try {
        await apiService.fetchData('invalid-url');
        expect(true).assertFalse(); // 不应该执行到这里
      } catch (error) {
        expect(error.message).assertEqual('Invalid URL');
      }
    });

    it('should_process_user_data', 0, async () => {
      const userData: UserData = await apiService.processUserData('123');
      
      expect(userData.id).assertEqual('123');
      expect(userData.name).assertEqual('User 123');
      expect(userData.score).assertLarger(0);
      expect(userData.score).assertLess(100);
    });

    it('should_process_multiple_users', 0, async () => {
      const promises = [
        apiService.processUserData('1'),
        apiService.processUserData('2'),
        apiService.processUserData('3')
      ];
      
      const results = await Promise.all(promises);
      
      expect(results.length).assertEqual(3);
      results.forEach((userData, index) => {
        expect(userData.id).assertEqual((index + 1).toString());
        expect(userData.name).assertEqual(`User ${index + 1}`);
      });
    });
  });
}

4. 组件测试

4.1 自定义组件测试

// src/main/ets/components/CounterComponent.ets
@Component
export struct CounterComponent {
  @State count: number = 0;
  private maxCount: number = 10;

  build() {
    Column() {
      Text(`Count: ${this.count}`)
        .fontSize(20)
        .fontColor(this.count >= this.maxCount ? Color.Red : Color.Black)
      
      Button('Increment')
        .onClick(() => {
          if (this.count < this.maxCount) {
            this.count++;
          }
        })
        .enabled(this.count < this.maxCount)
      
      Button('Reset')
        .onClick(() => {
          this.count = 0;
        })
    }
  }

  // 公共方法用于测试
  increment(): void {
    if (this.count < this.maxCount) {
      this.count++;
    }
  }

  reset(): void {
    this.count = 0;
  }

  getCount(): number {
    return this.count;
  }

  isMaxReached(): boolean {
    return this.count >= this.maxCount;
  }
}
// ohosTest/src/test/ets/test/CounterComponent.test.ets
import { describe, it, expect, beforeEach } from '@ohos/hypium';
import { CounterComponent } from '../../../src/main/ets/components/CounterComponent';

export default function counterComponentTest() {
  describe('CounterComponent Tests', () => {
    let counter: CounterComponent;

    beforeEach(() => {
      counter = new CounterComponent();
    });

    it('should_initialize_with_zero', 0, () => {
      expect(counter.getCount()).assertEqual(0);
      expect(counter.isMaxReached()).assertFalse();
    });

    it('should_increment_count', 0, () => {
      counter.increment();
      expect(counter.getCount()).assertEqual(1);
    });

    it('should_not_exceed_max_count', 0, () => {
      for (let i = 0; i < 15; i++) {
        counter.increment();
      }
      expect(counter.getCount()).assertEqual(10);
      expect(counter.isMaxReached()).assertTrue();
    });

    it('should_reset_count', 0, () => {
      counter.increment();
      counter.increment();
      expect(counter.getCount()).assertEqual(2);
      
      counter.reset();
      expect(counter.getCount()).assertEqual(0);
      expect(counter.isMaxReached()).assertFalse();
    });

    it('should_handle_multiple_operations', 0, () => {
      // 增加 5 次
      for (let i = 0; i < 5; i++) {
        counter.increment();
      }
      expect(counter.getCount()).assertEqual(5);
      
      // 重置
      counter.reset();
      expect(counter.getCount()).assertEqual(0);
      
      // 再次增加
      counter.increment();
      expect(counter.getCount()).assertEqual(1);
    });
  });
}

5. Mock 和 Stub 测试

5.1 依赖注入测试

// src/main/ets/services/WeatherService.ets
export class WeatherService {
  private apiKey: string;

  constructor(apiKey: string) {
    this.apiKey = apiKey;
  }

  async getWeather(city: string): Promise<WeatherData> {
    // 实际应用中这里会调用真实的 API
    throw new Error('Not implemented in tests');
  }
}

export interface WeatherData {
  city: string;
  temperature: number;
  description: string;
}

// 模拟实现用于测试
export class MockWeatherService extends WeatherService {
  constructor() {
    super('test-key');
  }

  override async getWeather(city: string): Promise<WeatherData> {
    return Promise.resolve({
      city: city,
      temperature: 25,
      description: 'Sunny'
    });
  }
}
// ohosTest/src/test/ets/test/WeatherService.test.ets
import { describe, it, expect } from '@ohos/hypium';
import { WeatherService, MockWeatherService, WeatherData } from '../../../src/main/ets/services/WeatherService';

export default function weatherServiceTest() {
  describe('WeatherService Tests', () => {
    it('should_create_weather_service_with_api_key', 0, () => {
      const weatherService = new WeatherService('real-api-key');
      // 可以验证构造函数逻辑
      expect(weatherService).not().assertUndefined();
    });

    it('should_get_weather_data_using_mock', 0, async () => {
      const mockService = new MockWeatherService();
      const weatherData: WeatherData = await mockService.getWeather('Beijing');
      
      expect(weatherData.city).assertEqual('Beijing');
      expect(weatherData.temperature).assertEqual(25);
      expect(weatherData.description).assertEqual('Sunny');
    });

    it('should_handle_multiple_cities_with_mock', 0, async () => {
      const mockService = new MockWeatherService();
      
      const cities = ['Beijing', 'Shanghai', 'Guangzhou'];
      const promises = cities.map(city => mockService.getWeather(city));
      const results = await Promise.all(promises);
      
      expect(results.length).assertEqual(3);
      results.forEach((weatherData, index) => {
        expect(weatherData.city).assertEqual(cities[index]);
        expect(weatherData.temperature).assertEqual(25);
      });
    });
  });
}

6. 测试运行配置

6.1 测试列表文件

// ohosTest/src/test/ets/test/TestList.test.ets
import mathUtilTest from './MathUtil.test.ets';
import userServiceTest from './UserService.test.ets';
import apiServiceTest from './ApiService.test.ets';
import counterComponentTest from './CounterComponent.test.ets';
import weatherServiceTest from './WeatherService.test.ets';

export default function testList() {
  mathUtilTest();
  userServiceTest();
  apiServiceTest();
  counterComponentTest();
  weatherServiceTest();
}

6.2 运行测试

# 在项目根目录运行
./gradlew hmosTest
# 或者
npm test

7. 测试最佳实践

7.1 测试命名规范

  • 测试方法名应该描述性很强
  • 使用 should_ 前缀描述预期行为
  • 测试用例应该独立,不依赖其他测试

7.2 测试组织结构

  • 每个被测试类对应一个测试文件
  • 使用 describe 块组织相关测试
  • 使用 beforeEach 进行测试准备

7.3 断言使用

  • 使用明确的断言方法
  • 一个测试用例一个断言(理想情况)
  • 测试边界条件和异常情况

这样完整的单元测试框架可以确保 ArkTS 代码的质量和可靠性,支持 TDD(测试驱动开发)实践。

React 闭包陷阱详解

2025年11月15日 01:09

React 闭包陷阱详解

什么是闭包陷阱?

React 闭包陷阱是指函数组件中的回调函数(特别是事件处理函数或副作用函数)捕获了过时的状态值,而不是最新的状态值。这是因为这些函数在创建时形成了一个闭包,捕获了当时的变量值。

什么是闭包?

1. 存在外部函数和内部函数

  • 必须有一个外部函数(enclosing function)
  • 外部函数内部定义了内部函数(inner function)

2. 内部函数引用了外部函数的变量

  • 内部函数访问了外部函数作用域中的变量(自由变量)

3. 内部函数在外部函数作用域外被调用

  • 内部函数被返回或在外部函数外部被执行

触发条件

闭包陷阱通常在以下情况下发生:

  1. 在 useEffect 中使用状态但依赖项数组为空
  2. 在异步操作中使用状态
  3. 在事件处理函数中使用状态,但函数在组件挂载时创建

实际案例

案例 1:useEffect 中的闭包陷阱

import React, { useState, useEffect } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // 问题:这里的 count 始终是初始值 0
    const timer = setInterval(() => {
      console.log(count); // 总是输出 0
    }, 1000);
    
    return () => clearInterval(timer);
  }, []); // 空依赖数组

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

解决方案:

useEffect(() => {
  const timer = setInterval(() => {
    console.log(count); // 现在会输出最新的 count 值
  }, 1000);
  
  return () => clearInterval(timer);
}, [count]); // 添加 count 到依赖数组

案例 2:异步操作中的闭包陷阱

function AsyncCounter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
    
    // 问题:这里的 count 是点击时的值,不是最新的值
    setTimeout(() => {
      console.log('Current count:', count); // 可能不是最新的值
    }, 3000);
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment with delay</button>
    </div>
  );
}

解决方案:

function AsyncCounter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    // 使用函数式更新确保获取最新状态
    setCount(prevCount => {
      const newCount = prevCount + 1;
      
      setTimeout(() => {
        console.log('Current count:', newCount); // 确保是最新值
      }, 3000);
      
      return newCount;
    });
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment with delay</button>
    </div>
  );
}

案例 3:事件监听器中的闭包陷阱

function EventListenerExample() {
  const [value, setValue] = useState('');

  useEffect(() => {
    const handleKeyPress = (event) => {
      // 问题:这里的 value 始终是空字符串
      console.log('Current value:', value); // 总是空字符串
    };

    document.addEventListener('keypress', handleKeyPress);
    
    return () => {
      document.removeEventListener('keypress', handleKeyPress);
    };
  }, []); // 空依赖数组

  return (
    <div>
      <input 
        value={value} 
        onChange={(e) => setValue(e.target.value)} 
        placeholder="Type something"
      />
    </div>
  );
}

解决方案:

function EventListenerExample() {
  const [value, setValue] = useState('');
  const valueRef = useRef(value);

  // 保持 ref 与状态同步
  useEffect(() => {
    valueRef.current = value;
  }, [value]);

  useEffect(() => {
    const handleKeyPress = (event) => {
      // 通过 ref 获取最新值
      console.log('Current value:', valueRef.current);
    };

    document.addEventListener('keypress', handleKeyPress);
    
    return () => {
      document.removeEventListener('keypress', handleKeyPress);
    };
  }, []); // 依赖数组可以为空,因为我们使用 ref

  return (
    <div>
      <input 
        value={value} 
        onChange={(e) => setValue(e.target.value)} 
        placeholder="Type something"
    </div>
  );
}

更复杂的案例:多个状态交互

function ComplexExample() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  const sendData = useCallback(async () => {
    // 问题:这里的 count 和 text 可能是过时的值
    const response = await fetch('/api/data', {
      method: 'POST',
      body: JSON.stringify({ count, text })
    });
    // 处理响应...
  }, []); // 空依赖数组,函数不会更新

  useEffect(() => {
    // 假设我们需要在特定条件下发送数据
    if (count > 5) {
      sendData();
    }
  }, [count, sendData]);

  return (
    <div>
      <p>Count: {count}</p>
      <input 
        value={text} 
        onChange={(e) => setText(e.target.value)} 
        placeholder="Enter text"
      />
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

解决方案:

function ComplexExample() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  // 使用 ref 来存储最新状态
  const stateRef = useRef({ count, text });

  // 保持 ref 与状态同步
  useEffect(() => {
    stateRef.current = { count, text };
  }, [count, text]);

  const sendData = useCallback(async () => {
    // 通过 ref 获取最新状态
    const { count, text } = stateRef.current;
    
    const response = await fetch('/api/data', {
      method: 'POST',
      body: JSON.stringify({ count, text })
    });
    // 处理响应...
  }, []); // 依赖数组可以为空

  useEffect(() => {
    if (count > 5) {
      sendData();
    }
  }, [count, sendData]);

  return (
    <div>
      <p>Count: {count}</p>
      <input 
        value={text} 
        onChange={(e) => setText(e.target.value)} 
        placeholder="Enter text"
      />
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

最佳实践和解决方案总结

  1. 正确使用依赖数组:确保 useEffect、useCallback、useMemo 的依赖数组包含所有使用的外部变量

  2. 使用函数式更新:对于基于前一个状态的计算,使用函数式更新

    setCount(prevCount => prevCount + 1);
    
  3. 使用 useRef 存储可变值:对于需要在回调中访问但不想触发重新渲染的值,使用 useRef

  4. 使用 useCallback 的正确依赖:确保 useCallback 的依赖数组包含所有在回调中使用的变量

  5. 使用自定义 Hook 封装逻辑:将复杂的状态逻辑封装到自定义 Hook 中

// 自定义 Hook 处理闭包问题
function useLatestRef(value) {
  const ref = useRef(value);
  useEffect(() => {
    ref.current = value;
  });
  return ref;
}

// 使用示例
function MyComponent() {
  const [count, setCount] = useState(0);
  const countRef = useLatestRef(count);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log('Latest count:', countRef.current);
    }, 1000);
    
    return () => clearInterval(timer);
  }, []);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}

理解 React 闭包陷阱的关键是认识到函数组件每次渲染都会创建新的作用域,而闭包会捕获这些作用域中的变量值。通过正确的依赖管理和使用适当的 React API,可以有效地避免闭包陷阱。

useEffectEvent(实验性 API)

React 团队正在开发一个名为 useEffectEvent的实验性 API,专门用于解决闭包陷阱问题。它允许你在 effect 中读取最新的 props 和 state,而无需将它们声明为依赖项。

import React, { useState, useEffect } from 'react';
import { useEffectEvent } from 'react'; // React 19+ 官方API

function Counter() {
  const [count, setCount] = useState(0);

  // 使用 useEffectEvent 创建一个能访问最新状态的事件处理函数
  const handleTick = useEffectEvent(() => {
    console.log('最新计数:', count); // 这里总是能访问到最新的 count
  });

  useEffect(() => {
    const timer = setInterval(() => {
      handleTick(); // 调用 Effect Event
    }, 1000);
    
    return () => clearInterval(timer);
  }, []); // 依赖数组为空,Effect 只执行一次

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

React 5 个 “隐形坑”:上线前没注意,debug 到凌晨 3 点

作者 zzpper
2025年11月14日 23:32

React 5 个 “隐形坑”:上线前没注意,debug 到凌晨 3 点

用了这么久的React,我发现一个扎心规律:80% 的线上 bug 和性能卡顿,都来自那些 “看起来没问题” 的细节

比如明明用了 React 18 的自动批处理,却还是触发多次渲染;Context 只改一个字段,整个组件树都跟着重渲染;异步回调里拿不到最新状态 —— 这些问题藏在日常编码的角落,开发时很难察觉,上线后却能让你连夜 debug。

今天就把这些 “重要但易忽略” 的 React 陷阱,拆成 5 个实战案例,每个都附 “反面案例 + 问题根源 + 解决方案”,帮你避开 90% 的隐形坑,代码写得又稳又快~

一、React 18 自动批处理 “失效”?这些场景不生效!

React 18 的自动批处理(Automatic Batching)是个性能神器,能把多次状态更新合并成一次渲染,减少不必要的计算开销。但很多人不知道,它并不是 “万能的”,有些场景下会悄悄失效。

反面案例:以为会合并,结果触发两次渲染

javascript

import { useState, createRoot } from 'react';

function App() {
  const [a, setA] = useState(0);
  const [b, setB] = useState(0);

  // 点击按钮,预期一次渲染,实际两次
  const handleClick = () => {
    // 浏览器原生事件,自动批处理不生效
    window.addEventListener('resize', () => {
      setA(prev => prev + 1);
      setB(prev => prev + 1);
    });
  };

  console.log('组件渲染'); // 会打印两次
  return <button onClick={handleClick}>点击</button>;
}

createRoot(document.getElementById('root')).render(<App />);

问题根源

React 18 的自动批处理仅覆盖 “React 能控制的场景”,比如合成事件、Promise、setTimeout 等,但浏览器原生事件(resize、scroll)、SyntheticEvent 之外的场景,React 无法拦截调度,批处理会失效。

解决方案

  1. unstable_batchedUpdates手动包裹:

javascript

import { unstable_batchedUpdates } from 'react-dom';

const handleClick = () => {
  window.addEventListener('resize', () => {
    // 手动合并更新,仅触发一次渲染
    unstable_batchedUpdates(() => {
      setA(prev => prev + 1);
      setB(prev => prev + 1);
    });
  });
};
  1. 优先使用 React 合成事件,避免直接操作原生事件。

关键提醒

别滥用flushSync!它会强制同步更新,直接打断批处理,非必要场景(如需要立即获取更新后 DOM)不要用。

二、Context 的 “性能刺客”:改一个字段,全组件树重渲染

Context 是 React 全局状态管理的常用工具,但很多人把所有状态都塞进一个 Context,结果变成 “牵一发而动全身” 的性能陷阱 —— 改用户昵称,连主题组件都跟着重渲染。

反面案例:大而全的 Context 导致无效重渲染

javascript

// 错误:所有状态都放一个Context
const AppContext = createContext();

function AppProvider({ children }) {
  const [user, setUser] = useState({ name: '张三', age: 25 });
  const [theme, setTheme] = useState('light');

  // 每次渲染生成新对象,即使状态没变化也触发重渲染
  return (
    <AppContext.Provider value={{ user, setUser, theme, setTheme }}>
      {children}
    </AppContext.Provider>
  );
}

// 只用到theme的组件,也会因user变化重渲染
function ThemeButton() {
  const { theme, setTheme } = useContext(AppContext);
  console.log('主题按钮重渲染'); //  user变化时也会打印
  return <button onClick={() => setTheme('dark')}>切换主题</button>;
}

问题根源

  1. Context 未拆分,所有状态耦合在一起,一个字段变化会通知所有消费者。
  2. Provider 的value是动态创建的对象,每次渲染都会生成新引用,导致子组件误判 “状态变化”。

解决方案

1. 拆分 Context,按功能模块化

javascript

// 拆分后:用户Context和主题Context独立
const UserContext = createContext();
const ThemeContext = createContext();

// 用户Provider
function UserProvider({ children }) {
  const [user, setUser] = useState({ name: '张三', age: 25 });
  return <UserContext.Provider value={{ user, setUser }}>{children}</UserContext.Provider>;
}

// 主题Provider
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  // 用useMemo缓存value,确保引用稳定
  const value = useMemo(() => ({ theme, setTheme }), [theme]);
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

// 外层组合,避免嵌套地狱
function AppProviders({ children }) {
  return (
    <UserProvider>
      <ThemeProvider>{children}</ThemeProvider>
    </UserProvider>
  );
}
2. 用useContextSelector精准订阅(React 18+)

安装use-context-selector库,让组件只订阅需要的字段:

javascript

import { useContextSelector } from 'use-context-selector';

function ThemeButton() {
  // 仅订阅theme字段,user变化不影响
  const theme = useContextSelector(ThemeContext, (state) => state.theme);
  const setTheme = useContextSelector(ThemeContext, (state) => state.setTheme);
  return <button onClick={() => setTheme('dark')}>切换主题</button>;
}

image

(示意图:左为未拆分 Context 的重渲染组件数,右为拆分后,标注 “重渲染组件数减少 79%”)

三、闭包陷阱:异步回调里的状态永远是 “旧值”

这是 React Hooks 最容易踩的坑之一:明明状态已经更新,异步回调(如 setTimeout、接口回调)里却拿不到最新值,排查半天都找不到原因。

反面案例:定时器里的状态 “停滞不前”

javascript

function Counter() {
  const [count, setCount] = useState(0);

  const showCount = () => {
    // 3秒后弹出的count是调用时的旧值
    setTimeout(() => {
      alert(`当前计数:${count}`); // 点击时count=3,弹出却可能是0
    }, 3000);
  };

  return (
    <div>
      <p>count: {count}</p>
      <button onClick={() => setCount(prev => prev + 1)}>加1</button>
      <button onClick={showCount}>3秒后显示</button>
    </div>
  );
}

问题根源

每次组件渲染都是独立的函数调用,异步回调会捕获 “创建时” 的状态快照(闭包特性)。即使后续状态更新,回调里引用的还是旧的状态值。

解决方案

1. 用 useRef 存储最新状态

javascript

function Counter() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  // 每次状态更新,同步到ref.current
  useEffect(() => {
    countRef.current = count;
  }, [count]);

  const showCount = () => {
    setTimeout(() => {
      alert(`当前计数:${countRef.current}`); // 拿到最新值
    }, 3000);
  };

  // 其余代码不变
}
2. 状态更新依赖前值?用函数式更新

javascript

// 依赖前一个状态时,避免直接引用count
setCount(prev => prev + 1); // prev始终是最新状态
3. 复杂场景用 useReducer

dispatch引用在组件生命周期内稳定,reducer 中能获取最新状态:

javascript

function reducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    default:
      return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0 });
  // 无需依赖state,dispatch始终能触发最新状态更新
}

四、useMemo/useCallback 滥用:优化变 “添乱”

很多开发者把 useMemo 和 useCallback 当 “万能优化药”,不管什么场景都用上,结果不仅没提升性能,还增加了 React 的缓存开销 —— 这俩 Hook 是 “优化工具”,不是 “装饰器”。

反面案例:没必要的优化

javascript

// 错误1:简单计算用useMemo,纯属多余
const sum = useMemo(() => a + b, [a, b]);

// 错误2:不传递给子组件的函数用useCallback
const handleInputChange = useCallback((e) => {
  setValue(e.target.value);
}, []);

// 错误3:依赖项不全,导致缓存过时
const filteredList = useMemo(() => {
  return list.filter(item => item.status === status);
}, [list]); // 漏加status依赖,status变化后列表不更新

问题根源

  1. 简单计算的声明成本远低于缓存开销,优化反而拖慢性能。
  2. 函数仅在组件内部使用时,是否重新创建对性能无影响。
  3. 依赖项不全导致缓存 “过期”,引发逻辑 bug。

正确使用场景 & 技巧

1. useMemo:缓存复杂计算结果

javascript

// 正确:大数据排序/过滤,计算成本高
const sortedProducts = useMemo(() => {
  // 1000+条数据排序,值得缓存
  return products.sort((a, b) => b.sales - a.sales);
}, [products]); // 仅当products变化时重新计算
2. useCallback:缓存传递给子组件的函数

javascript

// 子组件用React.memo包裹(纯组件)
const ProductItem = memo(({ product, onAddToCart }) => {
  console.log(`渲染商品:${product.name}`);
  return <button onClick={() => onAddToCart(product.id)}>加入购物车</button>;
});

// 父组件:用useCallback缓存函数,避免子组件无效重渲染
function ProductList({ products }) {
  const [cartCount, setCartCount] = useState(0);
  const onAddToCart = useCallback((id) => {
    setCartCount(prev => prev + 1);
  }, [cartCount]); // 依赖变化时才重新创建函数

  return products.map(product => (
    <ProductItem key={product.id} product={product} onAddToCart={onAddToCart} />
  ));
}
3. 核心原则
  • 先定位性能问题:用 React DevTools 的 Profiler 工具找到频繁重渲染 / 耗时计算的组件,再优化。
  • 依赖项要 “全且准”:用到的所有状态 / 变量都要加入依赖数组。
  • 不做 “预防性优化”:简单组件无需过早优化,优先保证代码可读性。

五、组件卸载后异步未取消:内存泄漏的 “隐形杀手”

切换路由或关闭弹窗时,组件已经卸载,但之前发起的接口请求、定时器还在运行,完成后尝试更新状态,就会触发警告:Can't perform a React state update on an unmounted component,还可能导致内存泄漏。

反面案例:卸载后异步任务仍在执行

javascript

function UserProfile() {
  const [userInfo, setUserInfo] = useState(null);

  useEffect(() => {
    // 发起接口请求
    fetch('/api/user')
      .then(res => res.json())
      .then(data => {
        // 组件卸载后,这里仍会执行
        setUserInfo(data);
      });

    // 定时器未清理
    const timer = setInterval(() => {
      console.log('定时器还在运行');
    }, 1000);

    // 没有清理逻辑
  }, []);

  return <div>{userInfo?.name}</div>;
}

解决方案:useEffect 清理函数

1. 取消接口请求(AbortController)

javascript

useEffect(() => {
  const controller = new AbortController();
  const signal = controller.signal;

  fetch('/api/user', { signal })
    .then(res => res.json())
    .then(data => {
      setUserInfo(data);
    })
    .catch(err => {
      if (err.name === 'AbortError') return; // 忽略取消请求的错误
    });

  // 组件卸载时取消请求
  return () => controller.abort();
}, []);
2. 清理定时器 / 事件监听

javascript

useEffect(() => {
  const timer = setInterval(() => {
    console.log('定时器运行中');
  }, 1000);

  // 清理定时器
  return () => clearInterval(timer);
}, []);
3. 标记组件挂载状态

javascript

useEffect(() => {
  let isMounted = true; // 标记组件是否挂载

  fetch('/api/user')
    .then(res => res.json())
    .then(data => {
      if (isMounted) { // 仅当组件挂载时更新状态
        setUserInfo(data);
      }
    });

  return () => {
    isMounted = false; // 组件卸载时标记为false
  };
}, []);

📌 最后:React 开发的 “避坑心法”

其实这些容易被忽略的问题,核心都围绕一个原则:理解 React 的底层逻辑,而不是死记 API 用法

  • 状态更新要懂 “批处理”,知道哪些场景会失效;
  • 性能优化要抓 “关键点”,不做无用功;
  • 异步操作要守 “生命周期”,及时清理副作用。

开发时多问自己一句:“这个写法的底层逻辑是什么?有没有可能触发异常?” 很多隐形坑自然就避开了。

最后想问:你在 React 开发中还踩过哪些 “不起眼” 的坑?评论区聊聊,点赞最高的送一份《React 避坑手册》(含本文所有案例代码 + 排查工具清单)~

《大厂面试:从手写 Ajax 到封装 getJSON,再到理解 Promise 与 sleep》

作者 xhxxx
2025年11月14日 23:03

大厂面试必考:从手写 Ajax 到封装 getJSON,再到理解 Promise 与 sleep

在前端工程师的求职过程中,尤其是冲击一线大厂(如阿里、腾讯、字节等)时,手写代码题几乎是绕不开的一环。这些题目看似基础,实则考察候选人对 JavaScript 核心机制的理解深度——包括异步编程、事件循环、内存模型以及浏览器原生 API 的掌握程度。

本文将焦三个经典手写题:

  1. 手写原生 Ajax
  2. 封装支持 Promise 的 getJSON 函数
  3. 手写 sleep 函数

我们将逐层深入,不仅写出代码,更要讲清楚“为什么这么写”,帮助你在面试中不仅能写出来,还能讲明白。


一、手写 Ajax:回调地狱的起点

虽然现代开发中我们早已习惯使用 fetchaxios,但 Ajax 是所有网络请求的基石。面试官让你手写 Ajax,不是为了让你重复造轮子,而是检验你是否真正理解 HTTP 请求在浏览器中的实现方式。 ajax 基于回调函数实现,代码复杂,这正是其痛点所在。

手写一个基础版 Ajax

function ajax(url, callback) {
  const xhr = new XMLHttpRequest();
  
  xhr.open('GET', url, true); // 异步请求
  xhr.onreadystatechange = function () {
    if (xhr.readyState === 4) { // 请求完成
      if (xhr.status >= 200 && xhr.status < 300) {
        // 成功:调用回调并传入响应数据
        callback(null, JSON.parse(xhr.responseText));
      } else {
        // 失败:传入错误
        callback(new Error(`HTTP ${xhr.status}`), null);
      }
    }
  };
  xhr.send();
}

问题在哪?

  • 强依赖回调函数:调用方必须传入 callback,无法链式操作;
  • 错误处理分散:成功和失败逻辑耦合在同一个函数里;
  • 无法组合多个异步操作:比如“先请求 A,再根据 A 的结果请求 B”,代码会迅速变得嵌套混乱——即所谓的“回调地狱”。

这正是为什么我们需要 Promise


二、封装 getJSON:用 Promise 改造 Ajax

“如何封装一个 getJSON 函数。使用 ajax,支持 Promise,get 请求方法,返回是 JSON”

这其实是一个典型的“将传统回调式 API 转为 Promise 化”的过程。

封装思路

  • 创建一个返回 Promise 的函数;
  • Promise 构造函数内部执行 Ajax;
  • 成功时调用 resolve(data),失败时调用 reject(error)
  • 自动解析 JSON 响应体。

实现代码

function getJSON(url) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url, true);
    xhr.setRequestHeader('Accept', 'application/json');

    xhr.onload = function () {
      if (xhr.status >= 200 && xhr.status < 300) {
        try {
          const data = JSON.parse(xhr.responseText);
          resolve(data);
        } catch (e) {
          reject(new Error('Invalid JSON response'));
        }
      } else {
        reject(new Error(`Request failed with status ${xhr.status}`));
      }
    };

    xhr.onerror = function () {
      reject(new Error('Network error'));
    };

    xhr.send();
  });
}

使用方式(对比 fetch)

// 使用我们封装的 getJSON
getJSON('/api/user')
  .then(user => console.log(user))
  .catch(err => console.error('Failed:', err));

// 等价于 fetch 写法(但 fetch 不自动抛出 HTTP 错误)
fetch('/api/user')
  .then(res => {
    if (!res.ok) throw new Error('HTTP error');
    return res.json();
  })
  .then(user => console.log(user))
  .catch(err => console.error(err));

为什么 Promise 更好?

“fetch 简单易用,基于 Promise 实现,(then)无需回调函数”

Promise 的核心优势在于:

  • 状态机模型:初始为 pending,只能变为 fulfilled(通过 resolve)或 rejected(通过 reject),且状态不可逆;
  • 链式调用.then().catch() 形成清晰的流程控制;
  • 统一错误处理:任意环节出错,都会被最近的 .catch 捕获。

这使得异步代码更接近同步逻辑的阅读体验。


三、深入 Promise:不只是语法糖

“promise 类 ,为异步变同步而(流程控制)实例化,事实标准。接收一个函数,函数有两个参数,resolve reject,他们也是函数。”

  • new Promise(executor) 中的 executor 是一个立即执行的函数;
  • 它接收两个参数:resolvereject,都是由 Promise 内部提供的函数;
  • 调用 resolve(value) 会将 Promise 状态转为 fulfilled,并将 value 传递给下一个 .then
  • 调用 reject(reason) 则转为 rejected,触发 .catch

例如:

const p = new Promise((resolve, reject) => {
  setTimeout(() => {
    Math.random() > 0.5 ? resolve('ok') : reject('fail');
  }, 1000);
});

p.then(console.log).catch(console.error);

这种设计让开发者能主动控制异步结果的“成功”或“失败”路径,是构建可靠异步系统的基础。


四、手写 sleep:Promise

实现

 function sleep(n){
            let p;
                 p = new Promise ((resolve,reject)=>{
                
                setTimeout(()=>{
                // pending 等待
                console.log(p);
                //resolve();
                reject();
                // fulfilled  成功
                console.log(p);
                }
                    ,n);
            })
            return p;
        }
        sleep(3000)
        .then(()=>{
            console.log('3s后执行');

        })
        .catch(()=>{
            console.log('3s后执行失败');
        })
        // promise 状态改变 就会执行
        .finally(()=>{
            console.log('finally');
        })

手写题的本质是理解机制

大厂面试之所以反复考察这些“老掉牙”的手写题,是因为它们像一面镜子,照出你对 JavaScript 运行机制的理解深度:

  • Ajax → 浏览器网络 API + 回调模型;
  • Promise 封装 → 异步流程控制范式升级;
  • sleep → Promise 与定时器的创造性结合;

当你不仅能写出这些代码,还能清晰解释其背后的原理时,你就已经超越了大多数候选人。

记住:面试不是考你会不会用库,而是考你知不知道库为什么存在。

Promise:让 JavaScript 异步任务“同步化”的利器

作者 AAA阿giao
2025年11月14日 22:26

引言

在前端开发中,异步编程是绕不开的话题。JavaScript 作为一门单线程语言,无法像多线程语言那样并行处理多个任务。为了不让耗时操作(如网络请求、文件读取、定时器等)阻塞主线程,JS 引入了**事件循环(Event Loop)**机制,将这些操作放入任务队列中,等待主线程空闲后再执行。

但随之而来的问题是:代码的执行顺序不再与书写顺序一致,这使得逻辑变得难以追踪,尤其在复杂的业务场景中,很容易陷入“回调地狱”。

为了解决这一问题,ES6 引入了 Promise —— 一种用于更优雅地处理异步操作的工具。它不仅让异步代码看起来像“同步”执行,还极大地提升了代码的可读性与可维护性。


一、为什么需要 Promise?

1. JavaScript 的单线程特性

JavaScript 是单线程语言,意味着同一时间只能执行一个任务。如果遇到耗时操作(比如 setTimeoutfs.readFile),若采用同步方式等待,页面就会卡死,用户无法进行任何交互。

因此,JS 将这些操作交给浏览器或 Node.js 环境去异步处理,主线程继续执行后续代码。例如:

console.log(1);
setTimeout(() => console.log(2), 0);
console.log(3);
// 输出:1 → 3 → 2

虽然 setTimeout 延迟为 0,但它仍是异步任务,会被放入任务队列,等主线程执行完所有同步代码后才执行。

2. 回调函数的局限性

早期我们通过回调函数处理异步结果:

fs.readFile('a.txt', (err, data) => {
  if (err) throw err;
  console.log(data.toString());
});

但当多个异步操作需要按顺序执行时,回调嵌套会迅速失控:

readFile('1.txt', () => {
  readFile('2.txt', () => {
    readFile('3.txt', () => {
      // 回调地狱!
    });
  });
});

这种代码不仅难以阅读,调试和错误处理也极其困难。


二、Promise:异步变“同步”的桥梁

Promise 是 ES6 提供的一种对象,用于表示一个异步操作的最终完成(或失败)及其结果值。它的核心思想是:将异步操作的结果“封装”起来,通过 .then().catch() 来统一处理成功与失败的情况

基本用法示例

// 1. 立刻执行,输出 1(同步代码)
console.log(1);

// 2. 创建一个 Promise,立刻开始执行里面的代码(但里面的异步操作不会阻塞后续代码)
const p = new Promise((resolve) => {
  // 3. 设置一个 1 秒后执行的定时器(异步任务,不会马上运行)
  setTimeout(() => {
    // 5. 1 秒后,先输出 2
    console.log(2);
    // 6. 调用 resolve(),告诉 Promise:“任务完成了!”
    resolve();
  }, 1000);
});

// 4. 注册一个“当 Promise 成功完成时”要运行的函数(但此时 Promise 还没完成,所以先记下来)
p.then(() => {
  // 7. Promise 完成后,立即执行这里,输出 3
  console.log(3);
});

// 最终输出顺序:1 → 2 → 3
// 虽然 2 和 3 是异步的,但通过 Promise,我们确保了 3 一定在 2 之后执行。

虽然 setTimeout 仍是异步的,但通过 Promise,我们确保了“3”一定在“2”之后输出,实现了逻辑上的“同步化”


三、深入理解 Promise 机制

1. Promise 的三种状态

  • pending(进行中) :初始状态,既不是成功也不是失败。

  • fulfilled(已成功) :异步操作成功完成,调用 resolve()

  • rejected(已失败) :异步操作失败,调用 reject()

一旦状态改变,就不可逆。

2. 构造函数同步执行

new Promise() 中的执行器函数(executor)是立即同步执行的:

// 1. 立刻执行,输出 'start'(同步任务)
console.log('start');

// 2. 创建一个 Promise,会**立即同步执行**传入的函数(称为 executor)
new Promise((resolve) => {
  // 3. 这行是同步执行的!所以马上输出 'in executor'
  console.log('in executor');

  // 4. 设置一个 1 秒后调用 resolve() 的定时器(异步任务,不会阻塞代码)
  //    此时 Promise 状态还是 pending,但主线程不会等它
  setTimeout(resolve, 1000);
});

// 5. 主线程继续往下走,立刻执行这行,输出 'end'
console.log('end');

// 最终输出顺序:start → in executor → end
// 注意:setTimeout 里的 resolve 要 1 秒后才运行,但 console.log('end') 不会等它,
// 因为 Promise 的 executor 是同步执行的,而 setTimeout 是异步的。

异步任务(如 setTimeout)在 executor 内部启动,但 executor 本身是同步运行的。

3. 链式调用与错误处理

.then() 返回一个新的 Promise,支持链式调用:

// 1. 发起一个网络请求,获取 lemoncode 组织的成员列表(异步操作)
//    fetch() 立即返回一个 Promise,不会阻塞后续代码
fetch('https://api.github.com/orgs/lemoncode/members')

  // 2. 当请求成功返回响应(Response 对象)后,进入第一个 .then()
  //    调用 res.json() 将响应体解析为 JSON 格式(它也返回一个 Promise)
  .then(res => res.json())

  // 3. 当 JSON 解析完成后,进入第二个 .then()
  .then(members => {
    // 4. 找到页面中 id 为 'members' 的元素
    //    把每个成员的 login 名称转成 <li> 标签,并拼接成字符串
    document.getElementById('members').innerHTML = 
      members.map(item => `<li>${item.login}</li>`).join('');
  })

  // 5. 如果上面任意一步出错(网络失败、JSON 解析失败等),
  //    就会跳到这里,捕获错误并打印出来
  .catch(err => {
    console.error('请求失败:', err);
  });

这种方式避免了回调嵌套,逻辑清晰,错误也能集中处理。


四、实际应用场景

1. 文件读取(Node.js)

// 1. 引入 Node.js 的文件系统模块(用于读写文件)
import fs from 'fs';

// 2. 立刻执行,输出 1(同步任务)
console.log(1);

// 3. 创建一个 Promise 实例 p
//    注意:new Promise() 中的函数会**立即同步执行**
const p = new Promise((resolve, reject) => {
  // 4. 这行是同步执行的!所以马上输出 3
  console.log(3);

  // 5. 调用 fs.readFile 读取 './b.txt' 文件(这是异步 I/O 操作)
  //    主线程不会等待,而是继续往下执行,读取结果稍后通过回调返回
  fs.readFile('./b.txt', (err, data) => {
    // 8. 【1秒或更久后】文件读取完成,进入这个回调(异步执行)

    // 打印错误信息(如果有的话),用于调试
    console.log(err, '//////');

    // 如果读取出错(比如文件不存在)
    if (err) {
      reject(err);  // 让 Promise 变成失败状态
      return;
    }

    // 读取成功,把 Buffer 转成字符串并 resolve
    resolve(data.toString());
  });
});

// 6. 注册成功和失败的处理函数
p.then((data) => {
  // 9. 如果文件读取成功,这里会执行,输出文件内容
  console.log(data, '//////');
}).catch((err) => {
  // 9. 如果文件读取失败,这里会执行,输出错误
  console.log(err, '读取文件失败');
});

// 7. 主线程继续执行,不等文件读取完成,立刻输出 2(同步任务)
console.log(2);

最终输出顺序(假设文件存在):

1
3
2
null //////        ← err 为 null 表示无错误
<文件内容> //////  

如果文件 不存在,则输出:

1
3
2
[Error: ENOENT...] //////  
[Error: ENOENT...] 读取文件失败

关键总结:

  • console.log(1)console.log(3)console.log(2) 都是同步代码,按顺序立即执行 → 输出 1 → 3 → 2
  • fs.readFile 是异步操作,它的回调(包括 resolve/reject)会在 I/O 完成后才执行,因此 .then() 或 .catch() 的内容一定在 2 之后输出
  • Promise 的 executor(传给 new Promise 的函数)是同步执行的,但其中的异步操作(如 readFile)不会阻塞主线程。

2. 网络请求(浏览器)

// 1. 发起一个网络请求,获取 lemoncode 组织的成员列表
//    fetch() 立刻返回一个 Promise,不会卡住页面(异步操作)
fetch('https://api.github.com/orgs/lemoncode/members')

  // 2. 当服务器返回响应(比如状态码 200)后,进入第一个 .then()
  //    response.json() 会把响应体(通常是 JSON 字符串)解析成 JavaScript 对象
  //    它也返回一个 Promise,所以可以继续链式调用
  .then(response => response.json())

  // 3. 当 JSON 解析完成,进入第二个 .then()
  //    此时 members 是一个数组,每个元素是一个成员对象,例如 { login: "antonio06", ... }
  .then(members => {
    // 4. 把每个成员的用户名(m.login)转成 <li> 标签
    //    例如:[{login:"alice"}] → ["<li>alice</li>"] → "<li>alice</li>"
    const list = members.map(m => `<li>${m.login}</li>`).join('');

    // 5. 找到 HTML 中 id="members" 的元素,把生成的列表插入进去
    document.getElementById('members').innerHTML = list;
  })

  // 6. 如果上面任何一步出错(比如网络断了、URL 写错、JSON 格式不对等),
  //    就会跳过所有 .then(),直接进入 .catch() 处理错误
  .catch(error => {
    console.error('获取成员失败:', error);
  });

关键总结:

  • 整个过程是异步的,但通过 .then() 链,让逻辑像“一步一步顺序执行”一样清晰。
  • .catch() 能捕获整个链中的任何错误,避免程序崩溃。

五、Promise vs 回调函数:谁更胜一筹?

特性 回调函数 Promise
可读性 嵌套深,难维护 链式调用,结构清晰
错误处理 每层需单独处理 统一 .catch() 捕获
组合多个异步 困难 支持 Promise.all()Promise.race()
返回值传递 手动传参 自动通过 resolve(value) 传递

显然,Promise 在现代 JS 开发中已成为异步处理的标准方案。


结语

Promise 并没有真正让异步变成同步——底层依然是异步执行。但它通过状态管理链式调用,让我们能以接近同步的方式编写和理解异步代码,极大提升了开发体验。

随着 async/await 的普及(其底层正是基于 Promise),异步编程变得更加简洁直观。但理解 Promise 的原理,仍是掌握现代 JavaScript 异步编程的基石。

正如那句老话: “Promise 不是魔法,但它让异步世界变得更有序。”

Vite & Webpack & Rollup 入口与产出配置与示例

2025年11月14日 21:04

对Vite v5.x、Webpack v5.x、Rollup v4.x三个主流前端构建工具,分别记录入口与产出的配置,并且给出与配置文件相对应的项目结构和构建后的示例文件内容。

备注:输出产物文件夹统一采用“构建工具名称+dist”命名,静态资源路径统一采用“构建工具名称+asset”命名。

一、Vite v5.x 配置与输出示例

1.1 核心配置文件(vite.config.js)

import { defineConfig } from 'vite'
export default defineConfig({
  // 项目根目录配置(自定义为src目录)
  root: './src', // 自定义项目根目录为src,入口路径需基于此目录编写
  // 基础路径配置(自定义为子路径部署场景)
  base: '/vite-app/', // 自定义为子路径/vite-app/,适配生产环境部署到子目录的场景
  // 静态资源公共目录配置(publicDir)
  publicDir: '../vite-public', // 配置public目录为项目根目录下的vite-public,存放无需处理的静态资源
  // 打包配置
  build: {
    // 产出配置
    outDir: '../vite-dist', // 因root为src,需向上一级输出,保持工具名+dist规则
    assetsDir: 'vite-asset', // 静态资源路径:构建工具名称+asset
    assetsInlineLimit: 4096, // 小于4kb的静态资源内联,超过则输出到assetsDir
    rollupOptions: {
      // 明确入口文件配置(基于root:./src,路径简化)
      input: {
        main: './main.js', // 主入口,基于root:./src,实际路径为src/main.js
        subPage: './subPage.js' // 子入口,实际路径为src/subPage.js
      },
      // 多入口对应输出配置(可选,默认按入口名生成对应文件)
      output: {
        entryFileNames: 'vite-asset/js/[name]-[hash].js', // 入口JS输出路径及命名
        chunkFileNames: 'vite-asset/js/[name]-[hash].js', // 代码分割chunk输出路径及命名
        assetFileNames: 'vite-asset/[ext]/[name]-[hash].[ext]' // 静态资源(css、图片等)输出路径及命名
      }
    },
    sourcemap: false, // 是否生成sourcemap,生产环境建议关闭
    minify: 'esbuild' // 压缩工具,Vite默认使用esbuild,可替换为terser
  },
  ...
})

1.2 入口与产出配置说明

配置类型 具体配置项 配置值 说明
入口配置 主入口 ./main.js 基于root:./src,实际路径为src/main.js,负责初始化应用
子入口 ./subPage.js 基于root:./src,实际路径为src/subPage.js,多入口场景可独立打包
产出及基础配置 项目根目录(root) ./src 自定义为src目录,入口路径需基于此编写,简化入口路径写法
基础路径(base) /vite-app/ 自定义为子路径,适配生产环境部署到域名下/vite-app/子目录的场景,影响资源引用路径
静态资源公共目录(publicDir) ../vite-public 基于root:./src,实际路径为项目根目录下vite-public,存放无需处理的静态资源,打包时直接复制到产物根目录
产物根目录 ../vite-dist 因root为src,需向上一级输出,保持“工具名+dist”规则,实际位于项目根目录下
静态资源目录 vite-asset 存放JS、CSS、图片等经处理的静态资源,遵循“构建工具名称+asset”规则
入口JS输出路径 vite-asset/js/[name]-[hash].js [name]为入口名,[hash]为文件哈希值,用于缓存控制
chunk输出路径 vite-asset/js/[name]-[hash].js 代码分割后生成的公共模块或异步模块文件路径
其他静态资源路径 vite-asset/[ext]/[name]-[hash].[ext] [ext]为文件扩展名,按扩展名分类存放经处理的图片、CSS等资源

1.3 打包后文件内容示例

1.3.1 文件结构

// 项目整体目录结构(体现root、publicDir与outDir关系)
project-root/          // 实际项目根目录
├── src/               // root配置指定的项目根目录
│   ├── main.js        // 主入口文件
│   ├── subPage.js     // 子入口文件
│   └── vite.config.js // Vite配置文件(因root为src,配置文件可放src内)
├── vite-public/       // publicDir配置指定的静态资源公共目录
│   ├── favicon.ico    // 网站图标(无需处理,打包直接复制)
│   └── static/        // 公共静态资源子目录
│       └── global.css // 全局静态CSS(无需构建工具处理)
└── vite-dist/         // 产物根目录,由outDir:../vite-dist生成
    ├── index.html     // 主入口对应的HTML文件
    ├── subPage.html   // 子入口对应的HTML文件
    ├── favicon.ico    // 从vite-public复制过来的网站图标
    ├── static/        // 从vite-public复制过来的静态资源子目录
    │   └── global.css // 从vite-public复制过来的全局CSS
    └── vite-asset/    // 经构建工具处理后的静态资源目录
        ├── js/        // JS文件目录
        │   ├── main-8f7d2b.js      // 主入口JS文件(哈希值示例)
        │   ├── subPage-3e5a1c.js   // 子入口JS文件(哈希值示例)
        │   └── vendor-7b9c4d.js    // 公共依赖chunk(如Vue框架)
        ├── css/       // CSS文件目录
        │   ├── main-5d8f3e.css     // 主入口对应的CSS文件
        │   └── subPage-2a4c6b.css  // 子入口对应的CSS文件
        └── img/       // 图片资源目录
            └── logo-9a3b5d.png     // 图片资源文件(哈希值示例,经处理后输出)

1.3.2 关键文件内容示例

1. vite-dist/index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vite 主入口页面</title>
  <link rel="icon" href="/vite-app/favicon.ico"> 
  <link rel="stylesheet" href="/vite-app/static/global.css"> 
  <!-- 资源引用路径前缀为base配置的/vite-app/,经处理的CSS在vite-asset下 -->
  <link rel="stylesheet" href="/vite-app/vite-asset/css/main-5d8f3e.css">
</head>
<body>
  <div id="app"></div>
  <!-- 脚本引用路径同样添加base前缀,经处理的JS在vite-asset下 -->
  <script type="module" src="/vite-app/vite-asset/js/vendor-7b9c4d.js"></script>
  <script type="module" src="/vite-app/vite-asset/js/main-8f7d2b.js"></script>
</body>
</html>

2. vite-dist/vite-asset/js/main-8f7d2b.js(简化版)

import { createApp } from './vendor-7b9c4d.js';
import App from './App.vue';
createApp(App).mount('#app');
// 业务逻辑代码(已压缩,此处为简化展示)
console.log('Vite 主入口应用启动');

二、Webpack v5.x 配置与输出示例

2.1 核心配置文件(webpack.config.js)

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin'); // 生成HTML文件的插件
const MiniCssExtractPlugin = require('mini-css-extract-plugin'); // 提取CSS为单独文件的插件

module.exports = {
  // 项目根目录配置
  context: path.resolve(__dirname, 'src'), // 自定义根目录为src,入口路径基于此编写
  // 入口配置(基于context:src,路径简化)
  entry: {
    main: './main.js', // 主入口,基于context:src,实际路径为src/main.js
    subPage: './subPage.js' // 子入口,实际路径为src/subPage.js
  },
  // 产出配置
  output: {
    path: path.resolve(__dirname, 'webpack-dist'), // 输出产物文件夹:构建工具名称+dist
    filename: 'webpack-asset/js/[name]-[contenthash].js', // 入口JS输出路径及命名
    chunkFilename: 'webpack-asset/js/[name]-[contenthash].chunk.js', // 代码分割chunk输出路径
    assetModuleFilename: 'webpack-asset/[ext]/[name]-[contenthash].[ext]', // 静态资源输出路径
    publicPath: '/webpack-app/', // 基础路径,适配子路径部署
    clean: true // 打包前清理输出目录
  },
  // 模块解析规则
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: [MiniCssExtractPlugin.loader, 'css-loader'], // 提取CSS为单独文件
      },
      {
        test: /\.(png|svg|jpg|jpeg|gif)$/i,
        type: 'asset', // 自动判断是否内联或输出为文件
        parser: {
          dataUrlCondition: {
            maxSize: 4 * 1024 // 4kb以下内联,超过输出到静态资源目录
          }
        }
      },
      {
        test: /\.m?js$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: 'babel-loader', // 转译ES6+语法
          options: {
            presets: ['@babel/preset-env']
          }
        }
      }
    ]
  },
  // 插件配置
  plugins: [
    // 为每个入口生成对应的HTML文件
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: path.resolve(__dirname, 'public/index.html'), // 模板路径需补全根目录
      chunks: ['main', 'vendor'] // 引入主入口JS和公共依赖chunk
    }),
    new HtmlWebpackPlugin({
      filename: 'subPage.html',
      template: path.resolve(__dirname, 'public/subPage.html'), // 模板路径需补全根目录
      chunks: ['subPage', 'vendor'] // 引入子入口JS和公共依赖chunk
    }),
    // 配置CSS输出路径
    new MiniCssExtractPlugin({
      filename: 'webpack-asset/css/[name]-[contenthash].css',
      chunkFilename: 'webpack-asset/css/[name]-[contenthash].chunk.css'
    })
  ],
  // 代码分割配置
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor', // 公共依赖chunk名称
          priority: -10 // 优先级,多个缓存组规则冲突时,按优先级决定模块归属
        }
      }
    },
    runtimeChunk: 'single' // 提取运行时代码,优化缓存
  },
  mode: 'production' // 生产环境模式(自动开启压缩等优化)
};

2.2 入口与产出配置说明

配置类型 具体配置项 配置值 说明
入口配置 主入口 ./main.js 基于context:src,实际路径为src/main.js,负责初始化应用
子入口 ./subPage.js 基于context:src,实际路径为src/subPage.js,多入口场景可独立打包
产出及基础配置 项目根目录(context) path.resolve(__dirname, 'src') 类似Vite的root,自定义根目录为src,入口路径基于此编写
基础路径(publicPath) /webpack-app/ 类似Vite的base,适配子路径部署,自动为资源添加路径前缀
产物根目录 webpack-dist 打包后所有文件的根文件夹,遵循“构建工具名称+dist”规则
静态资源根目录 webpack-asset 存放JS、CSS、图片等静态资源,遵循“构建工具名称+asset”规则
入口JS输出路径 webpack-asset/js/[name]-[contenthash].js [name]为入口名,[contenthash]为内容哈希值,用于长效缓存
chunk输出路径 webpack-asset/js/[name]-[contenthash].chunk.js 代码分割后生成的公共模块(如vendor)或异步模块文件路径
图片等资源输出路径 webpack-asset/[ext]/[name]-[contenthash].[ext] [ext]为文件扩展名,按扩展名分类存放静态资源

2.3 打包后文件内容示例

2.3.1 文件结构

// 项目整体目录结构(体现context与output.path关系)
project-root/          // 实际项目根目录
├── src/               // context配置指定的项目根目录
│   ├── main.js        // 主入口文件(基于context简化路径)
│   ├── subPage.js     // 子入口文件
│   └── webpack.config.js // Webpack配置文件(可放项目根目录,需补全路径)
├── public/            // 静态模板目录
│   ├── index.html     // 主入口HTML模板
│   └── subPage.html   // 子入口HTML模板
└── webpack-dist/      // 产物根目录,由output.path生成
    ├── index.html     // 主入口对应的HTML文件
    ├── subPage.html   // 子入口对应的HTML文件
    ├── webpack-asset/ // 静态资源目录
    │   ├── js/        // JS文件目录
    │   │   ├── main-9f2d7b.contenthash.js       // 主入口JS文件
    │   │   ├── subPage-4e6a3c.contenthash.js    // 子入口JS文件
    │   │   ├── vendor-8b7c5d.contenthash.js     // 公共依赖chunk(如第三方库)
    │   │   └── runtime-main.contenthash.js      // 运行时代码chunk
    │   ├── css/       // CSS文件目录
    │   │   ├── main-6d9f4e.contenthash.css      // 主入口对应的CSS文件
    │   │   └── subPage-3a5c7b.contenthash.css   // 子入口对应的CSS文件
    │   └── png/       // 图片资源目录(按扩展名分类)
    │       └── logo-7a4b6d.contenthash.png      // 图片资源文件
    └── favicon.ico    // 从public目录复制的静态文件(若有)

2.3.2 关键文件内容示例

1. webpack-dist/index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Webpack 主入口页面</title>
  <!-- 资源路径自动添加publicPath前缀/webpack-app/ -->
  <link href="/webpack-app/webpack-asset/css/main-6d9f4e.contenthash.css" rel="stylesheet">
</head>
<body>
  <div id="app"></div>
  <script src="/webpack-app/webpack-asset/js/runtime-main.contenthash.js"></script>
  <script src="/webpack-app/webpack-asset/js/vendor-8b7c5d.contenthash.js"></script>
  <script src="/webpack-app/webpack-asset/js/main-9f2d7b.contenthash.js"></script>
</body>
</html>

2. webpack-dist/webpack-asset/js/main-9f2d7b.contenthash.js(简化版)

"use strict";
(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
// 引入依赖和业务代码
const App = require('./App.js');
App.init('#app');
console.log('Webpack 主入口应用启动');
},{'./App.js':2}]},{},[1]);

三、Rollup v4.x 配置与输出示例

3.1 核心配置文件(rollup.config.js)

import { defineConfig } from 'rollup';
import babel from '@rollup/plugin-babel'; // 转译ES6+语法
import resolve from '@rollup/plugin-node-resolve'; // 解析第三方模块
import commonjs from '@rollup/plugin-commonjs'; // 转换CommonJS模块为ES模块
import css from 'rollup-plugin-css-only'; // 处理CSS文件
import html from '@rollup/plugin-html'; // 生成HTML文件
import image from '@rollup/plugin-image'; // 处理图片资源
import { terser } from 'rollup-plugin-terser'; // 压缩JS代码
import hash from 'rollup-plugin-hash'; // 生成文件哈希值
import path from 'path';

export default defineConfig({
  // 项目根目录配置,rollup需要通过插件配置
  plugins: [
    // 配置根目录为src,解析模块时优先从src查找
    resolve({
      rootDir: path.resolve(__dirname, 'src'),
      moduleDirectories: [path.resolve(__dirname, 'src')]
    }),
    // 转换CommonJS模块为ES模块
    commonjs(),
    // 转译ES6+语法
    babel({
      exclude: 'node_modules/**',
      presets: ['@babel/preset-env']
    }),
    // 处理CSS文件,提取为单独文件
    css({
      output: 'rollup-asset/css/[name]-[hash].css'
    }),
    // 处理图片资源,小图片内联为base64,大图片输出为文件
    image({
      limit: 4 * 1024 // 4kb以下内联
    }),
    // 生成HTML文件,自动引入打包后的资源(配置base路径)
    html({
      fileName: ({ name }) => `${name}.html`, // 为每个入口生成对应HTML
      publicPath: '/rollup-app/', // 基础路径(类似Vite的base),适配子路径部署
      template: ({ attributes, bundle, files, publicPath, title }) => {
        const jsFiles = files.js.filter(file => file.name === title.toLowerCase());
        const cssFiles = files.css.filter(file => file.name === title.toLowerCase());
        return `
          <!DOCTYPE html>
          <html>
          <head>
            <meta charset="utf-8">
            <title>${title}</title>
            ${cssFiles.map(file => `<link rel="stylesheet" href="${publicPath}${file.fileName}">`).join('')}
          </head>
          <body>
            <div id="app"></div>
            ${jsFiles.map(file => `<script type="module" src="${publicPath}${file.fileName}"></script>`).join('')}
          </body>
          </html>
        `;
      },
      title: ({ name }) => `Rollup ${name.charAt(0).toUpperCase() + name.slice(1)} Page`
    }),
    // 生成文件哈希值,用于缓存控制
    hash({
      dest: 'rollup-dist',
      hashLength: 8 // 哈希值长度
    }),
    // 生产环境压缩JS代码
    terser()
  ],
  // 入口配置(基于rootDir:src,路径简化)
  input: {
    main: './main.js', // 主入口,基于rootDir:src,实际路径为src/main.js
    subPage: './subPage.js' // 子入口,实际路径为src/subPage.js
  },
  // 产出配置(多入口时可配置多个输出)
  output: [
    {
      dir: '../rollup-dist', // 因rootDir为src,需向上一级输出,保持工具名+dist规则
      format: 'esm', // 输出为ES模块格式
      entryFileNames: 'rollup-asset/js/[name]-[hash].js', // 入口JS输出路径
      chunkFileNames: 'rollup-asset/js/[name]-[hash].chunk.js', // 代码分割chunk路径
      assetFileNames: 'rollup-asset/[extname]/[name]-[hash][extname]', // 静态资源路径
      sourcemap: false
    }
  ],
  // 代码分割配置
  preserveModules: false, // 关闭保留模块结构,合并相关模块
});

3.2 入口与产出配置说明

配置类型 具体配置项 配置值 说明
入口配置 主入口 ./main.js 基于rootDir:src,实际路径为src/main.js,负责初始化应用
子入口 ./subPage.js 基于rootDir:src,实际路径为src/subPage.js,独立生成对应文件
产出及基础配置 项目根目录(rootDir) path.resolve(__dirname, 'src') 类似Vite的root,通过resolve插件配置,入口路径基于此简化
基础路径(publicPath) /rollup-app/ 类似Vite的base,在html插件中配置,为资源引用添加路径前缀
产物根目录 ../rollup-dist 因rootDir为src,向上一级输出,实际位于项目根目录,遵循工具名+dist规则
静态资源根目录 rollup-asset 存放JS、CSS、图片等静态资源,遵循“构建工具名称+asset”规则
入口JS输出路径 rollup-asset/js/[name]-[hash].js [name]为入口名,[hash]为文件哈希值,输出为ES模块格式
CSS输出路径 rollup-asset/css/[name]-[hash].css 通过rollup-plugin-css-only提取的单独CSS文件路径

3.3 打包后文件内容示例

3.3.1 文件结构

// 项目整体目录结构(体现rootDir与output.dir关系)
project-root/          // 实际项目根目录
├── src/               // rootDir配置指定的项目根目录
│   ├── main.js        // 主入口文件(基于rootDir简化路径)
│   ├── subPage.js     // 子入口文件
│   └── rollup.config.js // Rollup配置文件(放src内,需适配根目录)
└── rollup-dist/       // 产物根目录,由output.dir:../rollup-dist生成
    ├── main.html      // 主入口对应的HTML文件
    ├── subPage.html   // 子入口对应的HTML文件
    ├── manifest.json  // 哈希值映射表(记录原文件名与哈希文件名的对应关系)
    └── rollup-asset/  // 静态资源目录
        ├── js/        // JS文件目录
        │   ├── main-7d2f9b.js      // 主入口JS文件(哈希值示例)
        │   ├── subPage-3c5a1e.js   // 子入口JS文件(哈希值示例)
        │   └── common-5b7c4d.js    // 公共模块chunk(代码分割生成)
        ├── css/       // CSS文件目录
        │   ├── main-4f8d3e.css     // 主入口对应的CSS文件
        │   └── subPage-2b6c5a.css  // 子入口对应的CSS文件
        └── png/       // 图片资源目录(按扩展名分类)
            └── logo-6d3b5a.png     // 图片资源文件(哈希值示例,超过4kb时输出)

3.3.2 关键文件内容示例

1. rollup-dist/main.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Rollup Main Page</title>
  <!-- 资源引用路径添加publicPath前缀/rollup-app/ -->
  <link rel="stylesheet" href="/rollup-app/rollup-asset/css/main-4f8d3e.css">
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/rollup-app/rollup-asset/js/common-5b7c4d.js"></script>
  <script type="module" src="/rollup-app/rollup-asset/js/main-7d2f9b.js"></script>
</body>
</html>

2. rollup-dist/rollup-asset/js/main-7d2f9b.js(简化版)

import { commonFunc } from './common-5b7c4d.js';

class App {
  init(el) {
    this.el = document.querySelector(el);
    commonFunc();
    this.render();
  }
  render() {
    this.el.innerHTML = '<h1>Rollup 应用</h1>';
  }
}

new App().init('#app');
console.log('Rollup 主入口应用启动');

四、构建工具共性配置

4.1 共性配置(功能对标)

三大工具虽配置名称或实现方式不同,但核心功能存在明确对标关系,均围绕“入口管理、资源处理、产出控制、环境适配”四大核心场景设计,具体对标如下:

功能场景 Vite v5.x Webpack v5.x Rollup v4.x 功能共性说明
项目根目录 root context resolve.rootDir(插件配置) 定义项目基准目录,简化入口及模块引用路径,统一资源查找根节点
部署基础路径 base output.publicPath html.publicPath(插件配置) 适配子路径部署场景,为所有资源引用添加统一路径前缀,避免404错误
静态资源公共目录 publicDir(原生) copy-webpack-plugin(插件) rollup-plugin-copy(插件) 存放无需构建处理的静态资源(如favicon、全局静态CSS),打包时直接复制到产物根目录
入口配置 build.rollupOptions.input entry input 支持单入口/多入口配置,指定应用启动的核心文件路径,多入口时可独立生成产物
产物输出目录 build.outDir output.path output.dir 指定打包后产物的存放目录,均支持自定义路径
静态资源输出路径 build.assetsDir + 输出规则 output.assetModuleFilename output.assetFileNames 指定JS、CSS、图片等经处理资源的输出路径及命名规则,支持哈希值优化缓存
代码分割 build.rollupOptions.output.chunkFileNames optimization.splitChunks output.chunkFileNames 拆分公共依赖(如第三方库)或异步模块,减少重复代码,提升首屏加载速度
压缩优化 build.minify(esbuild/terser) mode: production(自动压缩) rollup-plugin-terser(插件) 生产环境压缩JS/CSS代码,移除空格、注释,降低产物体积

总结

  1. Vite:顶层配置简洁直观,原生集成开发/预览服务器,开发启动速度快,适合前端应用快速开发;
  2. Webpack:配置灵活全面,loader+插件生态丰富,支持复杂模块解析和场景适配,适合大型复杂项目;
  3. Rollup:专注ES模块打包,支持多格式输出,产物轻量无冗余,适合类库或轻量应用开发。

实际项目中可根据项目类型(应用/类库)、复杂度及团队熟悉度选择:前端应用优先选Vite,大型复杂项目选Webpack,类库开发选Rollup。

❌
❌