普通视图

发现新文章,点击刷新页面。
今天 — 2025年11月16日技术

每日一题-仅含 1 的子串数🟡

2025年11月16日 00:00

给你一个二进制字符串 s(仅由 '0' 和 '1' 组成的字符串)。

返回所有字符都为 1 的子字符串的数目。

由于答案可能很大,请你将它对 10^9 + 7 取模后返回。

 

示例 1:

输入:s = "0110111"
输出:9
解释:共有 9 个子字符串仅由 '1' 组成
"1" -> 5 次
"11" -> 3 次
"111" -> 1 次

示例 2:

输入:s = "101"
输出:2
解释:子字符串 "1" 在 s 中共出现 2 次

示例 3:

输入:s = "111111"
输出:21
解释:每个子字符串都仅由 '1' 组成

示例 4:

输入:s = "000"
输出:0

 

提示:

  • s[i] == '0's[i] == '1'
  • 1 <= s.length <= 10^5

枚举子串右端点,简洁写法(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2025年10月31日 20:01

遍历 $s$ 的过程中,记录上一个 $0$ 的位置 $\textit{last}_0$。

如果 $s[i]=1$,那么对于右端点为 $i$ 的全 $1$ 子串,左端点可以是

$$
\textit{last}_0+1,\textit{last}_0+2,\ldots,i-1,i
$$

一共有 $i - \textit{last}_0$ 个,加到答案中。

比如 $\textit{last}_0=2$,现在 $s[5]=1$,那么子串 $[3,5],[4,5],[5,5]$ 都只包含 $1$,有 $5-2=3$ 个以 $5$ 为右端点的全 $1$ 子串。

可能一开始没有 $0$,为了让公式兼容这种情况,初始化 $\textit{last}_0=-1$。

注意答案不超过 $64$ 位整数的最大值,可以在最后返回时再取模。

###py

class Solution:
    def numSub(self, s: str) -> int:
        MOD = 1_000_000_007
        ans = 0
        last0 = -1
        for i, ch in enumerate(s):
            if ch == '0':
                last0 = i  # 记录上个 0 的位置
            else:
                ans += i - last0  # 右端点为 i 的全 1 子串个数
        return ans % MOD

###java

class Solution {
    public int numSub(String s) {
        final int MOD = 1_000_000_007;
        long ans = 0;
        int last0 = -1;
        for (int i = 0; i < s.length(); i++) {
            char ch = s.charAt(i);
            if (ch == '0') {
                last0 = i; // 记录上个 0 的位置
            } else {
                ans += i - last0; // 右端点为 i 的全 1 子串个数
            }
        }
        return (int) (ans % MOD);
    }
}

###cpp

class Solution {
public:
    int numSub(string s) {
        constexpr int MOD = 1'000'000'007;
        long long ans = 0;
        int last0 = -1;
        for (int i = 0; i < s.size(); i++) {
            if (s[i] == '0') {
                last0 = i; // 记录上个 0 的位置
            } else {
                ans += i - last0; // 右端点为 i 的全 1 子串个数
            }
        }
        return ans % MOD;
    }
};

###c

#define MOD 1000000007

int numSub(char* s) {
    long long ans = 0;
    int last0 = -1;
    for (int i = 0; s[i]; i++) {
        if (s[i] == '0') {
            last0 = i; // 记录上个 0 的位置
        } else {
            ans += i - last0; // 右端点为 i 的全 1 子串个数
        }
    }
    return ans % MOD;
}

###go

func numSub(s string) (ans int) {
const mod = 1_000_000_007
last0 := -1
for i, ch := range s {
if ch == '0' {
last0 = i // 记录上个 0 的位置
} else {
ans += i - last0 // 右端点为 i 的全 1 子串个数
}
}
return ans % mod
}

###js

var numSub = function(s) {
    const MOD = 1_000_000_007;
    let ans = 0, last0 = -1;
    for (let i = 0; i < s.length; i++) {
        if (s[i] === '0') {
            last0 = i; // 记录上个 0 的位置
        } else {
            ans += i - last0; // 右端点为 i 的全 1 子串个数
        }
    }
    return ans % MOD;
};

###rust

impl Solution {
    pub fn num_sub(s: String) -> i32 {
        const MOD: i64 = 1_000_000_007;
        let mut ans = 0;
        let mut last0 = -1;
        for (i, ch) in s.bytes().enumerate() {
            if ch == b'0' {
                last0 = i as i32; // 记录上个 0 的位置
            } else {
                ans += (i as i32 - last0) as i64; // 右端点为 i 的全 1 子串个数
            }
        }
        (ans % MOD) as _
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $s$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。

相似题目

2348. 全 0 子数组的数目

专题训练

见下面双指针题单的「六、分组循环」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

仅含 1 的子串数

2020年7月18日 22:02

方法一:遍历字符串寻找最长子串

如果一个所有字符都为 $1$ 的字符串的长度为 $k$,则该字符串包含的所有字符都为 $1$ 的子字符串(包括该字符串本身)的数量是 $\dfrac{k * (k + 1)}{2}$。

首先寻找到所有的只包含字符 $1$ 的最长子字符串。这里的「只包含字符 $1$ 的最长子字符串」的意思是,假设该子字符串的下标范围是 $[i, j]$(包含下标 $i$ 和下标 $j$),其中 $i \le j$,该子字符串中的所有字符都是 $1$,且下标 $i$ 满足 $i$ 位于字符串 $s$ 的最左侧或者下标 $i - 1$ 位置的字符是 $0$,以及下标 $j$ 满足 $j$ 位于字符串 $s$ 的最右侧或者下标 $j + 1$ 位置的字符是 $0$。

寻找到所有的只包含字符 $1$ 的最长子字符串之后,就可以计算所有字符都为 $1$ 的子字符串的数量。

具体做法是,从左到右遍历字符串,如果遇到字符 $1$ 则计算连续字符 $1$ 的数量,如果遇到字符 $0$ 则说明上一个只包含字符 $1$ 的最长子字符串遍历结束,根据最长子字符串的长度计算子字符串的数量,然后将连续字符 $1$ 的数量清零。遍历结束后,如果连续字符 $1$ 的数量大于零,则还有最后一个只包含字符 $1$ 的最长子字符串,因此还需要计算其对应的子字符串的数量。

###Java

class Solution {
    public int numSub(String s) {
        final int MODULO = 1000000007;
        long total = 0;
        int length = s.length();
        long consecutive = 0;
        for (int i = 0; i < length; i++) {
            char c = s.charAt(i);
            if (c == '0') {
                total += consecutive * (consecutive + 1) / 2;
                total %= MODULO;
                consecutive = 0;
            } else {
                consecutive++;
            }
        }
        total += consecutive * (consecutive + 1) / 2;
        total %= MODULO;
        return (int) total;
    }
}

###C++

class Solution {
public:
    static constexpr int P = int(1E9) + 7;
    
    int numSub(string s) {
        int p = 0;
        long long ans = 0;
        while (p < s.size()) {
            if (s[p] == '0') {
                ++p;
                continue;
            }
            int cnt = 0;
            while (p < s.size() && s[p] == '1') {
                ++cnt;
                ++p;
            }
            ans = ans + (1LL + (long long)cnt) * cnt / 2;
            ans = ans % P;
        }
        return ans;
    }
};

###Python

class Solution:
    def numSub(self, s: str) -> int:
        total, consecutive = 0, 0
        length = len(s)
        for i in range(length):
            if s[i] == '0':
                total += consecutive * (consecutive + 1) // 2
                consecutive = 0
            else:
                consecutive += 1
        
        total += consecutive * (consecutive + 1) // 2
        total %= (10**9 + 7)
        return total

###C#

public class Solution {
    public int NumSub(string s) {
        const int MODULO = 1000000007;
        long total = 0;
        long consecutive = 0;
        foreach (char c in s) {
            if (c == '0') {
                total += consecutive * (consecutive + 1) / 2;
                total %= MODULO;
                consecutive = 0;
            } else {
                consecutive++;
            }
        }
        total += consecutive * (consecutive + 1) / 2;
        total %= MODULO;
        return (int)total;
    }
}

###Go

func numSub(s string) int {
    const MODULO = 1000000007
    total := 0
    consecutive := 0
    for _, c := range s {
        if c == '0' {
            total += consecutive * (consecutive + 1) / 2
            total %= MODULO
            consecutive = 0
        } else {
            consecutive++
        }
    }
    total += consecutive * (consecutive + 1) / 2
    total %= MODULO
    return total
}

###C

int numSub(char * s) {
    const int MODULO = 1000000007;
    long total = 0;
    long consecutive = 0;
    for (int i = 0; s[i]; i++) {
        if (s[i] == '0') {
            total += consecutive * (consecutive + 1) / 2;
            total %= MODULO;
            consecutive = 0;
        } else {
            consecutive++;
        }
    }
    total += consecutive * (consecutive + 1) / 2;
    total %= MODULO;
    return (int)total;
}

###JavaScript

var numSub = function(s) {
    const MODULO = 1000000007;
    let total = 0;
    let consecutive = 0;
    for (const c of s) {
        if (c === '0') {
            total += consecutive * (consecutive + 1) / 2;
            total %= MODULO;
            consecutive = 0;
        } else {
            consecutive++;
        }
    }
    total += consecutive * (consecutive + 1) / 2;
    total %= MODULO;
    return total;
};

###TypeScript

function numSub(s: string): number {
    const MODULO = 1000000007;
    let total = 0;
    let consecutive = 0;
    for (const c of s) {
        if (c === '0') {
            total += consecutive * (consecutive + 1) / 2;
            total %= MODULO;
            consecutive = 0;
        } else {
            consecutive++;
        }
    }
    total += consecutive * (consecutive + 1) / 2;
    total %= MODULO;
    return total;
}

###Rust

impl Solution {
    pub fn num_sub(s: String) -> i32 {
        const MODULO: i64 = 1000000007;
        let mut total: i64 = 0;
        let mut consecutive: i64 = 0;
        for c in s.chars() {
            if c == '0' {
                total += consecutive * (consecutive + 1) / 2;
                total %= MODULO;
                consecutive = 0;
            } else {
                consecutive += 1;
            }
        }
        total += consecutive * (consecutive + 1) / 2;
        total %= MODULO;
        total as i32
    }
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 是字符串的长度。需要遍历字符串一次。

  • 空间复杂度:$O(1)$。只需要维护有限的变量,空间复杂度是常数。

昨天 — 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>
  );
}
❌
❌