普通视图

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

每日一题-最多可以参加的会议数目 II🔴

2025年7月8日 00:00

给你一个 events 数组,其中 events[i] = [startDayi, endDayi, valuei] ,表示第 i 个会议在 startDayi 天开始,第 endDayi 天结束,如果你参加这个会议,你能得到价值 valuei 。同时给你一个整数 k 表示你能参加的最多会议数目。

你同一时间只能参加一个会议。如果你选择参加某个会议,那么你必须 完整 地参加完这个会议。会议结束日期是包含在会议内的,也就是说你不能同时参加一个开始日期与另一个结束日期相同的两个会议。

请你返回能得到的会议价值 最大和 。

 

示例 1:

输入:events = [[1,2,4],[3,4,3],[2,3,1]], k = 2
输出:7
解释:选择绿色的活动会议 0 和 1,得到总价值和为 4 + 3 = 7 。

示例 2:

输入:events = [[1,2,4],[3,4,3],[2,3,10]], k = 2
输出:10
解释:参加会议 2 ,得到价值和为 10 。
你没法再参加别的会议了,因为跟会议 2 有重叠。你  需要参加满 k 个会议。

示例 3:

输入:events = [[1,1,1],[2,2,2],[3,3,3],[4,4,4]], k = 3
输出:9
解释:尽管会议互不重叠,你只能参加 3 个会议,所以选择价值最大的 3 个会议。

 

提示:

  • 1 <= k <= events.length
  • 1 <= k * events.length <= 106
  • 1 <= startDayi <= endDayi <= 109
  • 1 <= valuei <= 106

动态规划 + 二分查找优化(Python/Java/C++/Go/JS/Rust)

作者 endlesscheng
2022年10月22日 07:35

请先完成本题的简单版本:1235. 规划兼职工作我的题解

本题在 1235 的基础上,约束参加的会议个数至多为 $k$。相应地,DP 要增加一个参数 $j$,表示至多参加 $j$ 个会议。

和 1235 一样,按照结束时间排序。定义 $f[i+1][j]$ 表示参加 $\textit{events}[0]$ 到 $\textit{events}[i]$ 中的至多 $j$ 个会议,能得到的最大价值和。其中 $+1$ 是为了方便用 $f[0]$ 表示初始值,即没有会议的情况。

分类讨论:

  • 不参加 $\textit{events}[i]$,问题变成参加 $\textit{events}[0]$ 到 $\textit{events}[i-1]$ 中的至多 $j$ 个会议,能得到的最大价值和,即 $f[i][j]$;
  • 参加 $\textit{events}[i]$,设 $p$ 是最大的满足 $\textit{endDay}_p<\textit{startDay}_i$ 的 $p$(若不存在则 $p=-1$),问题变成参加 $\textit{events}[0]$ 到 $\textit{events}[p]$ 中的至多 $j-1$ 个会议,能得到的会议价值的最大和,即 $f[p+1][j-1] + \textit{value}_i$。

两种情况取最大值,得到状态转移方程

$$
f[i+1][j] = \max(f[i][j], f[p+1][j-1] + \textit{value}_i)
$$

由于结束时间是有序的(我们排过序了),$p$ 可以用二分查找快速计算,原理见 二分查找 红蓝染色法【基础算法精讲 04】

初始值:$f[0][j] = 0$。没有会议,价值和为 $0$。

答案:$f[n][k]$。

# 手写 max 更快
max = lambda a, b: b if b > a else a

class Solution:
    def maxValue(self, events: List[List[int]], k: int) -> int:
        events.sort(key=lambda e: e[1])  # 按照结束时间排序
        n = len(events)
        f = [[0] * (k + 1) for _ in range(n + 1)]
        for i, (start_day, _, value) in enumerate(events):
            p = bisect_left(events, start_day, hi=i, key=lambda e: e[1])  # hi=i 表示二分上界为 i(默认为 n)
            for j in range(1, k + 1):
                # 为什么是 p 不是 p+1:上面算的是 >= start_day,-1 后得到 < start_day,但由于还要 +1,抵消了
                f[i + 1][j] = max(f[i][j], f[p][j - 1] + value)
        return f[n][k]
class Solution {
    public int maxValue(int[][] events, int k) {
        Arrays.sort(events, (a, b) -> a[1] - b[1]); // 按照结束时间排序
        int n = events.length;
        int[][] f = new int[n + 1][k + 1];
        for (int i = 0; i < n; i++) {
            int p = search(events, i, events[i][0]);
            for (int j = 1; j <= k; j++) {
                f[i + 1][j] = Math.max(f[i][j], f[p + 1][j - 1] + events[i][2]);
            }
        }
        return f[n][k];
    }

    // 返回 endDay[i] < upper 的最大 i
    private int search(int[][] events, int right, int upper) {
        int left = -1;
        while (left + 1 < right) {
            int mid = left + (right - left) / 2;
            if (events[mid][1] < upper) {
                left = mid;
            } else {
                right = mid;
            }
        }
        return left;
    }
}
class Solution {
public:
    int maxValue(vector<vector<int>>& events, int k) {
        ranges::sort(events, {}, [](auto& e) { return e[1]; }); // 按照结束时间排序
        int n = events.size();
        vector f(n + 1, vector<int>(k + 1));
        for (int i = 0; i < n; i++) {
            int p = lower_bound(events.begin(), events.begin() + i, events[i][0],
                                [](auto& e, int lower) { return e[1] < lower; }) - events.begin();
            for (int j = 1; j <= k; j++) {
                // 为什么是 p 不是 p+1:上面算的是 >= startDay,-1 后得到 < startDay,但由于还要 +1,抵消了
                f[i + 1][j] = max(f[i][j], f[p][j - 1] + events[i][2]);
            }
        }
        return f[n][k];
    }
};
func maxValue(events [][]int, k int) int {
    slices.SortFunc(events, func(a, b []int) int { return a[1] - b[1] })
    n := len(events)
    f := make([][]int, n+1)
    for i := range f {
        f[i] = make([]int, k+1)
    }
    for i, e := range events {
        startDay, value := e[0], e[2]
        p := sort.Search(i, func(j int) bool { return events[j][1] >= startDay })
        for j := 1; j <= k; j++ {
            // 为什么是 p 不是 p+1:上面算的是 >= startDay,-1 后得到 < startDay,但由于还要 +1,抵消了
            f[i+1][j] = max(f[i][j], f[p][j-1]+value)
        }
    }
    return f[n][k]
}
var maxValue = function(events, k) {
    events.sort((a, b) => a[1] - b[1]);
    const n = events.length;
    const f = Array.from({ length: n + 1 }, () => Array(k + 1).fill(0));
    for (let i = 0; i < n; i++) {
        const p = search(events, i, events[i][0]);
        for (let j = 1; j <= k; j++) {
            f[i + 1][j] = Math.max(f[i][j], f[p + 1][j - 1] + events[i][2]);
        }
    }
    return f[n][k];
};

// 返回 endDay[i] < upper 的最大 i
var search = function(events, right, upper) {
    let left = -1;
    while (left + 1 < right) {
        const mid = (left + right) >>> 1;
        if (events[mid][1] < upper) {
            left = mid;
        } else {
            right = mid;
        }
    }
    return left;
};
impl Solution {
    pub fn max_value(mut events: Vec<Vec<i32>>, k: i32) -> i32 {
        events.sort_unstable_by_key(|a| a[1]);
        let n = events.len();
        let k = k as usize;
        let mut f = vec![vec![0; k + 1]; n + 1];
        for (i, e) in events.iter().enumerate() {
            let p = events[..i].partition_point(|a| a[1] < e[0]);
            for j in 1..=k {
                // 为什么是 p 不是 p+1:上面算的是 >= startDay,-1 后得到 < startDay,但由于还要 +1,抵消了
                f[i + 1][j] = f[i][j].max(f[p][j - 1] + e[2]);
            }
        }
        f[n][k]
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(nk+n\log n)$,其中 $n$ 为 $\textit{events}$ 的长度。
  • 空间复杂度:$\mathcal{O}(nk)$。

专题训练

见下面动态规划题单的「§7.2 不相交区间」。

分类题单

如何科学刷题?

  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站@灵茶山艾府

【宫水三叶】一题双解 :「朴素 DP」&「二分优化 DP」

作者 AC_OIer
2021年2月7日 12:26

基本思路

定义 $f[i][j]$ 为考虑前 $i$ 个事件,选择不超过 $j$ 的最大价值

对于每个事件,都有选择与不选两种选择:

  • 不选: $f[i][j] = f[i - 1][j]$
  • 选:找到第 $i$ 件事件之前,与第 $i$ 件事件不冲突的事件,记为 last,则有 $f[i][j] = f[last][j - 1] + value_i$

两者取 $max$,则是 $f[i][j]$ 的值。

分析到这里,因为我们要找 last,我们需要先对 events 的结束时间排序,然后找从右往左找,找到第一个满足 结束时间 小于 当前事件的开始时间 的事件,就是 last

而找 last 的过程,可以直接循环找,也可以通过二分来找,都能过。


动态规划

不通过「二分」来找 last 的 DP 解法。

代码:

###Java

class Solution {
    public int maxValue(int[][] es, int k) {
        int n = es.length;
        Arrays.sort(es, (a, b)->a[1]-b[1]);
        // f[i][j] 代表考虑前 i 个事件,选择不超过 j 的最大价值
        int[][] f = new int[n + 1][k + 1];
        for (int i = 1; i <= n; i++) {
            int[] ev = es[i - 1];
            int s = ev[0], e = ev[1], v = ev[2];
            
            // 找到第 i 件事件之前,与第 i 件事件不冲突的事件
            // 对于当前事件而言,冲突与否,与 j 无关
            int last = 0;
            for (int p = i - 1; p >= 1; p--) {
                int[] prev = es[p - 1];
                if (prev[1] < s) {
                    last = p;
                    break;
                }
            }
            
            for (int j = 1; j <= k; j++) {
                f[i][j] = Math.max(f[i - 1][j], f[last][j - 1] + v);    
            }
        }
        return f[n][k];
    }
}
  • 时间复杂度:排序复杂度为 $O(n\log{n})$,循环 n 个事件,每次循环需要往回找一个事件,复杂度为 $O(n)$,并更新 k 个状态,复杂度为 $O(k)$,因此转移的复杂度为 $O(n * (n + k))$;总的复杂度为 $O(n * (n + k + \log{n}))$
  • 空间复杂度:$O(n * k)$

二分 + 动态规划

通过「二分」来找 last 的 DP 解法。

代码:

###Java

class Solution {
    public int maxValue(int[][] es, int k) {
        int n = es.length;
        Arrays.sort(es, (a, b)->a[1]-b[1]);
        // f[i][j] 代表考虑前 i 个事件,选择不超过 j 的最大价值
        int[][] f = new int[n + 1][k + 1];
        for (int i = 1; i <= n; i++) {
            int[] ev = es[i - 1];
            int s = ev[0], e = ev[1], v = ev[2];
            
            // 通过「二分」,找到第 i 件事件之前,与第 i 件事件不冲突的事件
            // 对于当前事件而言,冲突与否,与 j 无关
            int l = 1, r = i - 1;
            while (l < r) {
                int mid = l + r + 1 >> 1;
                int[] prev = es[mid - 1];
                if (prev[1] < s) l = mid;    
                else r = mid - 1;
            }
            int last = (r > 0 && es[r - 1][1] < s) ? r : 0;
            
            for (int j = 1; j <= k; j++) {
                f[i][j] = Math.max(f[i - 1][j], f[last][j - 1] + v);    
            }
        }
        return f[n][k];
    }
}
  • 时间复杂度:排序复杂度为 $O(n\log{n})$,循环 n 个事件,每次循环需要往回找一个事件,复杂度为 $O(\log{n})$,并更新 k 个状态,复杂度为 $O(k)$,因此转移的复杂度为 $O(n * (\log{n} + k))$;总的复杂度为 $O(n * (k + \log{n}))$
  • 空间复杂度:$O(n * k)$

最后

如果有帮助到你,请给题解点个赞和收藏,让更多的人看到 ~ ("▔□▔)/

也欢迎你 关注我 和 加入我们的「组队打卡」小群 ,提供写「证明」&「思路」的高质量题解。

所有题解已经加入 刷题指南,欢迎 star 哦 ~

【动态规划 + 二分搜索】最多可以参加的会议数目

作者 Arsenal-591
2021年2月7日 00:09

首先将每个会议按照结束时间排序。

设 $\textit{dp}[i][k]$ 为:参加前 $i$ 个会议中的恰好 $k$ 个时,所能获得的最大会议价值。

对于 $\textit{dp}[i][k]$ 而言,若要参加前 $i$ 个会议中的 $k$ 个:

  • 要么不参加第 $i$ 个会议。此时需要在前 $i-1$ 个会议中参加 $k$ 个,能够获得的最大价值为 $\textit{dp}[i-1][k]$;
  • 要么参加第 $i$ 个会议。此时任何 $\textit{endDay}$ 不小于 第 $i$ 个会议的开始时间那些会议,都会因为时间重合而无法参加。

对于第二种情况而言,由于会议已经事先按照结束时间排序,故不妨设 $l$ 为满足「结束时间小于第 $i$ 个会议的开始时间」的最后一个会议。此时,我们需要在前 $l$ 个会议中选择 $k-1$ 个,对应的价值为 $\textit{dp}[l][k-1] + \textit{value}[i]$。在排序好的数组中,通过二分搜索,就能够快速地找到 $l$ 的值。

另外,还要额外考虑不存在 $l$ 的情形,即「若参加第 $i$ 个会议,则此前所有的会议都无法参加」。

class Solution {
public:
    static bool cmp(const vector<int>& x, const vector<int>& y) {
        return x[1] < y[1];
    }
    int maxValue(vector<vector<int>>& events, int k) {
        int n = events.size();
        sort(events.begin(), events.end(), cmp);
        
        vector<vector<int>> dp(n, vector<int>(k + 1, INT_MIN));
        dp[0][0] = 0;
        dp[0][1] = events[0][2];
        
        for (int i = 1; i < n; i++) {
            // 参加会议 i,此时需要二分查找
            int l = 0, r = i;
            while (r - l > 1) {
                int mid = (l + r) / 2;
                if (events[mid][1] >= events[i][0]) {
                    r = mid;
                } else {
                    l = mid;
                }
            }
            if (events[l][1] < events[i][0]) {
                for (int j = 1; j <= k; j++) {
                    dp[i][j] = max(dp[i][j], dp[l][j-1] + events[i][2]);
                }
            } else {
                dp[i][1] = max(dp[i][1], events[i][2]);
            }
            
            // 不参加会议 i
            for (int j = 0; j <= k; j++) {
                dp[i][j] = max(dp[i][j], dp[i-1][j]);
            }
        }
        
        int ret = 0;
        for (int i = 0; i <= k; i++) {
            ret = max(ret, dp[n-1][i]);
        }
        return ret;
    }
};

复杂度分析

  • 时间复杂度:$O(n\log n + nk)$。排序部分需要 $O(n\log n)$ 的复杂度。对于单个 $i$ 而言,二分查找的时间为 $O(\log n)$,更新 $\textit{dp}$ 数组的时间为 $O(k)$。因此,总的复杂度为 $O(n\log n) + O(n \cdot (\log n + k)) = O(n\log n + nk)$。
  • 空间复杂度:$O(nk)$。

简单入门Python装饰器

作者 烛阴
2025年7月7日 22:19

引言

在Python中,装饰器(Decorator)是一种强大的工具,它使用简单的@符号语法,却能实现令人惊叹的代码增强功能。

装饰器初体验

1.1 最简单的装饰器示例

def simple_decorator(func):
    def wrapper():
        print("函数执行前...")
        func()
        print("函数执行后...")
    return wrapper

@simple_decorator
def say_hello():
    print("Hello!")

say_hello()
"""
输出:
函数执行前...
Hello!
函数执行后...
"""

1.2 装饰器的本质

装饰器本质上是一个高阶函数,它:

  1. 接受一个函数作为参数
  2. 返回一个新函数
  3. 通常在不修改原函数代码的情况下增强其功能

@decorator 只是语法糖,等价于:

def say_hello(): ...
say_hello = decorator(say_hello)

为什么需要装饰器?

2.1 代码复用:DRY原则

避免重复代码(Don't Repeat Yourself):

import time

# 没有装饰器的重复代码
def funco1():
    start = time.time()
    # 函数逻辑...
    time.sleep(2)
    end = time.time()
    print(f"函数 {funco1.__name__} 耗时: {end-start}秒")

def funco2():
    start = time.time()
    # 函数逻辑...
    time.sleep(3)
    end = time.time()
    print(f"函数 {funco2.__name__} 耗时: {end-start}秒")

def timing(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"函数 {func.__name__} 耗时: {end-start}秒")
        return result
    return wrapper

@timing
def func1():
    # 函数逻辑...
    time.sleep(2)
    print('func1 ....')

@timing
def func2():
    time.sleep(3)
    # 函数逻辑...
    print('func2 ....')

if __name__ == '__main__':
    funco1()
    funco2()
    func1()
    func2()

2.2 分离关注点

将业务逻辑与横切关注点(如日志、权限检查)分离:

@login_required
@log_execution
def delete_user(user_id):
    # 纯业务逻辑
    ...

装饰器进阶用法

3.1 带参数的装饰器

def repeat(num_times):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(num_times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(num_times=3)
def greet(name):
    print(f"Hello {name}")

greet("Alice")
"""
Hello Alice
Hello Alice
Hello Alice
"""

3.2 保留原函数元信息

使用functools.wraps保持原函数的__name__等属性:

from functools import wraps

def logged(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"调用 {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

3.3 类装饰器

class CountCalls:
    def __init__(self, func):
        self.func = func
        self.num_calls = 0
    
    def __call__(self, *args, **kwargs):
        self.num_calls += 1
        print(f"调用次数: {self.num_calls}")
        return self.func(*args, **kwargs)

@CountCalls
def say_hello():
    print("Hello!")

say_hello()  # 输出调用次数和Hello!
say_hello()  # 输出调用次数和Hello!

"""
调用次数: 1
Hello!
调用次数: 2
Hello!
"""

结语

点个赞,关注我获取更多实用 Python 技术干货!如果觉得有用,记得收藏本文!

数据库设计神器DrawDB:cpolar内网穿透实验室第595个成功挑战

NO.595 DrawDB-01.png

DrawDB是专为「手残党」和「时间焦虑患者」打造的数据库设计神器!无论是刚学数据库的学生、加班到秃头的程序员,还是想优雅地远程协作的团队,都能在这里找到救赎。

软件名称:DrawDB

操作系统支持:Windows/macOS/Linux

NO.595 DrawDB-02.png软件介绍

DrawDB是「懒人数据库设计师」的终极武器:

  • 拖拽建模:鼠标比键盘帅多了,直接画关系图;
  • 代码自动生成:想用MySQL?还是PostgreSQL?点一下导出SQL,比复制粘贴快3倍;
  • 团队协作:多人同时编辑+版本回滚,再也不怕队友手滑删了整个库。
  • 可视化魔法:把复杂的表结构变成「连线游戏」;
  • 一键生成SQL:告别打字错误,代码直接能跑;
  • 云端协作:团队成员异地同步,进度条看得比老板还清楚。

NO.595 DrawDB-03.png

实用场景:从学生党到CEO都在抢这个神器!

  1. 远程办公的救星 团队在咖啡厅、家里、地铁上同时修改数据库设计?DrawDB+cpolar穿透内网,实时同步让你像坐在同一张办公桌前。
  2. 小白逆袭导师/老板 学生用DrawDB生成带注释的SQL脚本直接交作业;程序员甩给产品经理一个链接:“看!这就是你说的‘超级复杂业务逻辑’的数据库结构!”
  3. 低成本创业团队神器 没钱请专业架构师?DrawDB+免费版cpolar,搞定从设计到部署全流程。

NO.595 DrawDB-04.png

cpolar内网穿透:把你的数据库设计变成「全球可达」的云服务!

  • 远程办公不再卡:用DrawDB画图时突然出差?cpolar帮你把本地服务器暴露到公网,咖啡厅也能继续改表结构;
  • 演示神器:给客户展示架构方案?一键穿透生成分享链接,对方点开就是实时协作界面,比PPT酷10086倍。

总结:DrawDB + cpolar = 数据库设计的「时空穿梭机」!

  • 效率翻倍:从画图到部署全程自动化;
  • 成本归零:免费版就够用,比请一杯奶茶还便宜;
  • 协作无界:不管你在南极科考站还是北极圈露营,只要能联网就能和队友一起设计数据库。

NO.595 DrawDB-05.png

∵ 利器+利器=神器

∴ DrawDB+cpolar=神器

∴这样神器组合的教程你一定要看呦!

1. Windows本地部署DrawDB

演示环境:Windows10专业版

打开命令行,从github下载项目到本地,执行下面的命令

git clone https://github.com/drawdb-io/drawdb

(如果没有安装git的话,进入git官网进行下载windows版本 git-scm.com/downloads)

image-20240506142150939

创建目录

cd drawdb

在项目的根目录下,执行下面的命令,下载依赖。

npm install

(没有安装Node.js的话,点击官网下载链接nodejs.org/en/download…

d726d1df5d6dbfe54ec36dab384d442

在项目的根目录下,运行Drawdb,可以看到运行成功,出现 http://localhost:5173/

npm run dev

image-20240506131358905

可以看到我们已经在本地部署了Drawdb,测试一下是否部署成功,接下来我们打开浏览器输入 localhost:5173

88ed52e218929d6b29c0b6f5e8d6ac8

点击 Try it for yourself ,无需注册登录就可以直接进入到界面中。

左侧的导航中,会给出5种类型的对象,table,relationship,subject area,note 和type

5362539995a90858905db57d0f25128

快捷键

DrawDB虽然是一个纯web的系统,但是也配备了全面的快捷键。在帮助里可以查看到全部支持的快捷键。

image-20240506133457833

导入/导出

导入的话支持diagram和source两种方式。

d64ad63878e6407787cb19ef2c76af1

导出的话,支持导出到5种数据库:MySQL、PostgreSQL、SQLite、MariaDB、SQL Server。

另外还支持导出为图片、JSON、PDF和自己的格式等。

导入和导出方面基本符合了正常使用的需要。

image-20240506134026821

我们成功的在本地部署了DrawDB数据库设计工具,但是如果异地办公,或者团队成员不在同一局域网中该如何实现异地公网办公呢?

我们可以结合cpolar内网穿透工具,使团队成员可以同时在同一个数据库模型上工作,轻松共享想法、提供建议,并确保数据库设计的一致性和准确性。这对于团队合作设计复杂数据库结构的场景尤为重要。

2. 安装Cpolar内网穿透

下面是安装cpolar步骤:

Cpolar官网地址: www.cpolar.com

点击进入cpolar官网,点击免费使用注册一个账号,并下载最新版本的Cpolar

登录成功后,点击下载Cpolar到本地并安装(一路默认安装即可)本教程选择下载Windows版本。

image-20240319175308664

Cpolar安装成功后,在浏览器上访问http://localhost:9200,使用cpolar账号登录,登录后即可看到Cpolar web 配置界面,结下来在web 管理界面配置即可。

接下来配置一下 DrawDB 的公网地址,

登录后,点击左侧仪表盘的隧道管理——创建隧道,

创建一个 DrawDB 的公网http地址隧道

  • 隧道名称:可自定义命名,注意不要与已有的隧道名称重复
  • 协议:选择http
  • 本地地址:5173 (本地访问的地址)
  • 域名类型:免费选择随机域名
  • 地区:选择China Top

9605b2f88354c75ad63d0838c58ff63

隧道创建成功后,点击左侧的状态——在线隧道列表,查看所生成的公网访问地址,有两种访问方式,一种是http 和https

1a6dbdb5366422dbf8f463e3451b333

使用上面的Cpolar https公网地址,在手机或任意设备的浏览器进行登录访问,即可成功看到 DrawDB 界面,这样一个公网地址且可以远程访问就创建好了,使用了Cpolar的公网域名,无需自己购买云服务器,即可到公网访问 DrawDB 了!

3. 实现公网访问DrawDB

我们用刚才cpolar生成的公网地址,打开一个新的浏览器复制粘贴,可以看到进入到了DrawDB项目管理界面。

86187e6033ed007c86b2f51f5eb63e1

点击 Try it for yourself 进入到数据库设计界面。

image-20240506141439359

小结

如果我们需要长期异地远程访问DrawDB,由于刚才创建的是随机的地址,24小时会发生变化。另外它的网址是由随机字符生成,不容易记忆。如果想把域名变成固定的二级子域名,并且不想每次都重新创建隧道来访问DrawDB,我们可以选择创建一个固定的http地址来解决这个问题。

4. 固定DrawDB公网地址

我们接下来为其配置固定的HTTP端口地址,该地址不会变化,方便分享给别人长期查看你的博客,而无需每天重复修改服务器地址。

配置固定http端口地址需要将cpolar升级到专业版套餐或以上。

登录cpolar官网,点击左侧的预留,选择保留二级子域名,设置一个二级子域名名称,点击保留,保留成功后复制保留的二级子域名名称

ecc1d4d6fa6bcb1f3da56f27e769716

保留成功后复制保留成功的二级子域名的名称

f6b4172996f9929b1064f2e49e70d96

返回登录Cpolar web UI管理界面,点击左侧仪表盘的隧道管理——隧道列表,找到所要配置的隧道,点击右侧的编辑

48a4ebcd47d6b749eb0ded744207e05

修改隧道信息,将保留成功的二级子域名配置到隧道中

  • 域名类型:选择二级子域名
  • Sub Domain:填写保留成功的二级子域名

点击更新(注意,点击一次更新即可,不需要重复提交)

82cf5f7d36193b8310e5db83618181c

更新完成后,打开在线隧道列表,此时可以看到公网地址已经发生变化,地址名称也变成了固定的二级子域名名称的域名

50c136cb630ef6409823750e2fc7bba

最后,我们使用固定的公网https地址在任何浏览器打开访问,可以看到访问成功,这样一个固定且永久不变的公网地址就设置好了,可以随时随地进行异地公网访问DrawDB了,方便团队协作,大大提高了工作效率!

13e4ca78d63393e78a060bb4d7470e2

今天介绍下最新更新的Vite7

作者 鱼樱前端
2025年7月7日 20:06

大家好,我是鱼樱!!!

关注公众号【鱼樱AI实验室】持续分享更多前端和AI辅助前端编码新知识~~

写点笔记写点生活~写点经验。

在当前环境下,纯前端开发者可以通过技术深化、横向扩展、切入新兴领域以及产品化思维找到突破口。

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

前端最卷的开发语言一点不为过,三天一小更,五天一大更。。。一年一个框架升级~=嗯,要的就是这样感觉!与时俱进~

Vite(法语意为 "快速的",发音 /vit/,发音同 "veet")是一种新型前端构建工具,能够显著提升前端开发体验。它主要由两部分组成:

Vite 是一种具有明确建议的工具,具备合理的默认设置。您可以在 功能指南 中了解 Vite 的各种可能性。通过 插件,Vite 支持与其他框架或工具的集成。如有需要,您可以通过 配置部分 自定义适应你的项目。

在开发过程中,Vite 假设使用的是现代浏览器。这意味着该浏览器支持大多数最新的 JavaScript 和 CSS 功能。因此,Vite 将 esnext 设置为转换目标。这可以防止语法降低,使 Vite 能够尽可能接近原始源代码提供模块。Vite 会注入一些运行时代码以使开发服务器正常工作。这些代码使用了 Baseline 中包含的功能,该功能在每个主要版本发布时(此主要版本为 2025-05-01)新增。

对于生产环境构建,Vite 默认以 Baseline 广泛可用的浏览器为目标平台。这些浏览器至少发布于两年半之前。您可以通过配置降低目标浏览器版本。此外,可以通过官方 @vitejs/plugin-legacy 支持旧版浏览器。更多详情,请参阅 构建生产环境 部分。

Vite 需要 Node.js 版本 20.19+, 22.12+。然而,有些模板需要依赖更高的 Node 版本才能正常运行,当你的包管理器发出警告时,请注意升级你的 Node 版本。

image.png

Vite7 更改了什么

1. Node.js支持变更

  • 要求使用Node.js 20.19+或22.12+
  • 不再支持Node.js 18(已于2025年4月底达到EOL)
  • 新的Node.js版本要求使Vite能够以纯ESM格式发布,同时保持对CJS模块通过require调用的兼容性

2. 浏览器兼容性目标调整

  • 默认浏览器目标从'modules'更改为'baseline-widely-available'

  • 支持的最低浏览器版本更新:

    • Chrome: 87 → 107
    • Edge: 88 → 107
    • Firefox: 78 → 104
    • Safari: 14.0 → 16.0
  • 这一变化使浏览器兼容性更具可预测性

3. Environment API增强

  • 保留了Vite 6中引入的实验性Environment API
  • 新增buildApp钩子,使插件能够协调环境的构建过程
  • 为框架提供了更强大的环境API
export default {
  builder: {
    buildApp: async (builder) => {
      const environments = Object.values(builder.environments)
      return Promise.all(
        environments.map((environment) => builder.build(environment)),
      )
    },
  },
}

4. Rolldown集成

  • 引入基于Rust的下一代打包工具Rolldown
  • 通过rolldown-vite包可替代默认的vite包
  • 未来Rolldown将成为Vite的默认打包工具
  • 能显著减少构建时间,尤其对大型项目

5. Vite DevTools增强

  • 通过VoidZero与NuxtLabs合作开发
  • 为所有基于Vite的项目和框架提供更深入的调试与分析功能

6. 废弃功能移除

  • 移除了Sass的旧版API支持
  • 移除了splitVendorChunkPlugin

7. Vitest支持

  • Vitest 3.2开始支持Vite 7.0

在 Vite 7 中,我们新增了一个 buildApp 钩子,使插件能够协调环境的构建过程。详情请参阅面向框架的 Environment API 指南

本次 rolldown 并没有内置在 vite 7 中,如果你想体验由 rolldown 驱动的 vite 体验,请使用 rolldown-vite 包替换 vite 即可。

点击查看 rolldown-vite 使用说明

image.png 所有变更的完整列表请见 Vite 7 更新日志

01-自然壁纸实战教程-免费开放啦

作者 万少
2025年7月7日 19:17

01-自然壁纸实战教程-免费开放啦

项目背景

自然壁纸是一款运行在 HarmonyOS 5.0 设备上的元服务,目前是已经上架和运行了,项目功能如名字所示,提供了基本的图片壁纸、视频浏览和下载功能,免费使用。

image-20250622193356953


image-20250622191905198

运营基本数据

image-20250622192020982

没有什么额外推广,纯粹是自然流量。虽然是个简单的壁纸功能,但是我们也没有投入太多的成本,关键是参与开发的小伙伴得到了团队开发的锻炼和真实上架的流程。

开发团队

这个是我们这个项目的开发小团队,开发、上线、接入商用广告,包括代码重构离不开小伙伴的付出贡献,希望我们可以在技术道路上一起越走越远。

image-20250622192242904

开源资料

因为这个不是纯粹的商业项目,并且每一个学习技术的小伙伴都会有个开源的梦想吧,不是什么大的项目,但是也是团队小伙伴走出了自己的第一步,所以围绕这个项目,我们免费公开的资料有:

  1. 鸿蒙端代码 移除 appid、git 日志、真实接口地址、证书
  2. 设计稿,提供 html 版本的设计稿,让想要练习布局的小伙伴也可以玩一玩
  3. 后端接口,这里针对学习者,我们免费搭了一个后端接口,专门给学习者进行调用使用,不是后端代码
  4. 文字教程,从 0 开始搭建项目和实现关键功能的文字教程,到时候会分发到对应的官网和社区平台
  5. 视频教程,由于这个制作需要更多的时间,会尽量产出
  6. 多种框架的自然壁纸,包括 RN 版本的、uniapp 版本和 flutter 版本的,这个到时候会放在对应的分支上。

如果小伙伴也想要自己部署接口的话,可以在pixabay网站找到并且使用。

以上资料,我们持续更新迭代,如果朋友发现了一些不足,可以及时反馈给我们项目团队,我们尽量去完善。

敬请关注我们开源代码仓库 自然壁纸, 点个star。 后续资料的更新都将会在这里进行同步。

如何获取资料

获取资料的途径,可以关注我们 官网的公众号 青蓝逐码 ,输入 项目名称 《自然壁纸》 即可获得以上资料。

微信公众号二维码

为什么需要关注公众号

如果我们的资源,网友连关注公众号的欲望都没有,说明我们的这个资料和资源也没有什么太大价值,那么不要也罢,可以让用户付出一些成本的,才是能证明有真正价值的东西。

关于我们

关于青蓝逐码组织

如果你兴趣想要了解更多的鸿蒙应用开发细节和最新资讯甚至你想要做出一款属于自己的应用!欢迎在评论区留言或者私信或者看我个人信息,可以加入技术交流群。

image-20250622200325374

事件机制与委托:从冒泡捕获到高效编程的奇妙之旅

2025年7月7日 18:52

引入

今天在探索JavaScript事件机制时,我仿佛闯入了DOM世界的魔法学院。这里的事件就像调皮的魔法精灵,它们的行为规律让我大开眼界!先来看看这个神奇的现象:

<!-- 魔法盒子实验 -->
<div id="parent" style="background:blue;width:200px;height:200px;">
  <div id="child" style="background:red;width:100px;height:100px"></div>
</div>

<script>
  parent.addEventListener('click', () => console.log('父元素被点啦!'), false)
  child.addEventListener('click', () => console.log('子元素被戳啦!'), false)
</script>

当我点击红色子盒子时,控制台居然依次打印:

image.png

咦?明明只点了子元素,为什么父元素也"感觉"到了?原来这就是事件冒泡的魔法效果!事件就像水中的气泡,从触发点(子元素)慢慢浮到顶层(父元素)。不过别急,这只是故事的一半...

事件的三段奇妙旅程

DOM世界的事件传播就像一场精心编排的舞台剧,分为三个精彩幕次:

  1. 捕获阶段:事件从window逐级向下"潜行"到目标元素
  2. 目标阶段:事件到达目标元素
  3. 冒泡阶段:事件从目标元素向上"浮出"到window

image.png

当我们使用addEventListener时,第三个参数就是控制魔法时机的开关: 我们可以看MDN的官方文档怎么介绍addEventListener

  • true:在捕获阶段触发(魔法自上而下)
  • false(默认):在冒泡阶段触发(魔法自下而上)

通俗来说,我们当用true会先触发父元素事件,再到子元素事件,当是false则相反,当然这是建立在父元素和子元素绑定了同一个事件,且在子元素身上触发了

结论

  1. addEventListener 第3个参数决定了事件是在捕获阶段触发还是在冒泡阶段触发
  2. addEventListener 第3个参数为 true 表示捕获阶段触发,false 表示冒泡阶段触发,默认值为 false
  3. 事件流只会在父子元素具有相同事件类型时才会产生影响
  4. 绝大部分场景都采用默认的冒泡模式(其中一个原因是早期 IE 不支持捕获)

阻止冒泡e.stopPropagation()

事件对象.stopPropagation()

阻止冒泡是指阻断事件的流动,保证事件只在当前元素被执行,而不再去影响到其对应的祖先元素。

此方法可以阻断事件流动传播,不光在冒泡阶段有效,捕获阶段也有效

<body>
  <div class="father">
    <div class="son"></div>
  </div>
  <script>
    const father = document.querySelector('.father')
    const son = document.querySelector('.son')

    document.addEventListener('click', function () {
      alert('我是爷爷')
    })

    father.addEventListener('click', function () {
      alert('我是爸爸')
    })
    son.addEventListener('click', function (e) {
      alert('我是儿子')
      e.stopPropagation()   //阻止冒泡
    })

  </script>
</body>

我们来看效果:

B1E595BDE5B620183155_converted.gif 可以看到当我们点击子元素,是没有触发祖先事件的,这就是e.stopPropagation()的作用

结论:

事件对象中的 ev.stopPropagation 方法,专门用来阻止事件冒泡。

鼠标经过事件:

mouseover 和 mouseout 会有冒泡效果

mouseenter 和 mouseleave 没有冒泡效果 (推荐)

DOM0 vs DOM2:魔法咒语的进化史

在魔法学院的历史上,曾有两种施法方式:

<!-- DOM0 古早咒语(已过时) -->
<a onclick="doSomething()">点我试试</a>

<!-- DOM2 现代魔法 -->
element.addEventListener('click', doSomething)

为什么现代巫师都用DOM2呢?因为DOM0有三个致命缺陷:

  1. 只能绑定一个处理函数(最后覆盖前面的)
  2. HTML和JS耦合严重(违反职责分离原则)
  3. 无法控制事件传播阶段

而DOM2的addEventListener就像多功能魔法杖:

  • 可绑定多个处理函数
  • 可精确控制捕获/冒泡阶段
  • 代码可维护性更高

DOM2级事件实现了模块化分离,我们就应该html,css,js 分离来写,这样的代码可读性是十分优雅的

事件委托:以一敌百的智慧

当我遇到这个需求时:

"给列表中所有li添加点击事件新手巫师可能会这样:

const lis = document.querySelectorAll('li');
lis.forEach(li => {
  li.addEventListener('click', handleClick);
});

我们的浏览器是存在一个event loop,我们这样操作就是注册很多事件,每一个事件都存在监听器

当列表有1000项时,相当于创建了1000个事件监听器!这就像雇佣1000个卫兵每人看守一颗石子——效率低下到让国王破产。

智慧的老巫师微微一笑,祭出了事件委托大法:

<ul id="myList">
  <li>item1</li>
  <li>item2</li>
  <!-- 更多li... -->
</ul>

<script>
  document.getElementById('myList').addEventListener('click', e => {
    if (e.target.tagName === 'LI') {
      console.log(`你点击了:${e.target.textContent}`);
    }
  });
</script>

这里的神奇之处在于:

  1. 只在父元素ul上设置一个监听器
  2. 通过e.target识别实际点击的元素
  3. 自动处理动态添加的子元素(无需重新绑定)

这就像在城堡门口设一个智能安检门,自动识别并处理不同人员,而不是给每个房间都派卫兵!

事件委托是利用事件流的特征解决一些现实开发需求的知识技巧,主要的作用是提升程序效率。

利用事件流的特征,可以对上述的代码进行优化,事件的的冒泡模式总是会将事件流向其父元素的,如果父元素监听了相同的事件类型,那么父元素的事件就会被触发并执行,正是利用这一特征对上述代码进行优化,

我们来看效果,一样可以满足我们的需求

B1E595BDE5B620184451_converted.gif

target vs currentTarget:魔法镜的奥秘

在事件委托中,理解这两个属性至关重要:

  • e.target:实际触发事件的元素(事件起源)
  • e.currentTarget:当前处理事件的元素(绑定监听器的元素)
myList.addEventListener('click', function(e) {
  console.log(e.target);      // 被点击的<li>
  console.log(e.currentTarget); // 永远是<ul>
});

这就像快递配送:

  • target是原始发货人(工厂)
  • currentTarget是当前中转站(配送中心)

React中的魔法优化

在React王国里,事件委托被用到了极致。所有的React事件都委托到 #root容器

// React内部相当于这样做了:
document.getElementById('root').addEventListener('click', e => {
  // 通过虚拟DOM找到对应组件处理
});

这种设计带来三大优势:

  1. 内存高效:整个应用共用少量监听器
  2. 动态无忧:组件动态增减不影响事件
  3. 一致行为:统一处理事件冒泡逻辑

结语:事件机制的哲学

从蓝色父盒子与红色子盒子的互动,到高效处理动态列表,事件机制教会我们编程的重要哲学:

优秀的代码不是控制每个个体,而是建立优雅的响应系统

就像DOM世界的事件流,我们的人生也充满各种"事件"。理解它们的传播机制,学会用"委托"智慧处理问题,才能在代码与生活的复杂性中游刃有余。

JavaScript 事件机制:捕获、冒泡与事件委托详解

作者 XXUZZWZ
2025年7月7日 18:43

JavaScript 事件机制:捕获、冒泡与事件委托详解

事件机制的核心概念

在 Web 开发中,事件处理是创建交互式应用的基础。理解 JavaScript 事件机制对于构建高效、可维护的应用程序至关重要。

DOM 事件流

当一个事件发生时,它会经历三个明确的阶段:

  1. 捕获阶段(Capture Phase):事件从文档根节点(window 对象)向下传递到目标元素
  2. 目标阶段(Target Phase):事件到达实际触发元素
  3. 冒泡阶段(Bubble Phase):事件从目标元素向上传递回文档根节点
// addEventListener 方法签名
element.addEventListener(type, listener, useCapture);
  • type:事件类型(如'click'、'mouseover')
  • listener:事件处理函数
  • useCapture:布尔值,决定事件在捕获阶段(true)还是冒泡阶段(false)处理

事件传播示例

考虑以下 HTML 结构:

<div id="parent">
  <div id="child"></div>
</div>

当为父元素和子元素添加事件监听器时:

const parent = document.getElementById("parent");
const child = document.getElementById("child");

// 捕获阶段事件处理
parent.addEventListener("click", () => console.log("捕获阶段 - 父元素"), true);
child.addEventListener("click", () => console.log("捕获阶段 - 子元素"), true);

// 冒泡阶段事件处理
child.addEventListener("click", () => console.log("冒泡阶段 - 子元素"), false);
parent.addEventListener("click", () => console.log("冒泡阶段 - 父元素"), false);

点击子元素时,控制台输出顺序为:

捕获阶段 - 父元素
捕获阶段 - 子元素
冒泡阶段 - 子元素
冒泡阶段 - 父元素

事件委托:高效的事件处理模式

事件委托利用事件冒泡机制,将事件处理委托给父元素,而不是直接绑定到每个子元素上。

传统事件绑定的问题

// 为每个列表项添加事件监听器
const listItems = document.querySelectorAll("#list li");

listItems.forEach((item) => {
  item.addEventListener("click", function (e) {
    console.log(e.target.innerText);
  });
});

这种方法存在几个问题:

  • 当列表项数量庞大时,会创建大量事件监听器
  • 新增的动态元素需要手动绑定事件
  • 内存占用较高,可能导致性能问题

事件委托解决方案

// 将事件委托给父元素
document.getElementById("list").addEventListener("click", function (e) {
  if (e.target.tagName === "LI") {
    console.log(e.target.innerText);
  }
});

事件委托的优势

  • 性能优化:只需一个事件监听器处理所有子元素事件
  • 动态元素支持:新增元素自动获得事件处理能力
  • 内存效率:显著减少内存占用
  • 代码简洁:减少重复代码,更易维护

React 中的事件机制

React 实现了自己的合成事件系统,优化了原生 DOM 事件处理:

合成事件(SyntheticEvent)

React 将所有事件委托到根元素(v17 之前是 document,v17+是 React 根容器):

  • 提供跨浏览器一致的事件接口
  • 自动处理事件绑定和解绑
  • 实现事件池机制提升性能
function Button() {
  const handleClick = (e) => {
    // e 是合成事件对象
    console.log("Clicked!", e.nativeEvent);
  };

  return <button onClick={handleClick}>Click me</button>;
}

React 事件委托原理

React 不是将事件处理器直接附加到 DOM 元素,而是在根容器上设置事件监听器:

  1. 所有事件在根容器被捕获
  2. React 通过事件路径确定触发组件
  3. 调用相应的事件处理函数

优势

  • 减少内存占用
  • 统一事件处理逻辑
  • 简化事件回收机制
  • 更好的性能表现

事件处理最佳实践

  1. 合理使用事件传播阶段

    • 需要提前拦截事件时使用捕获阶段
    • 大多数情况下使用冒泡阶段
  2. 事件委托适用场景

    • 列表或表格等包含大量相似元素的场景
    • 动态内容区域
    • 需要优化性能的复杂界面
  3. React 事件处理技巧

    • 避免在渲染方法中创建事件处理函数
    • 使用事件委托处理动态列表
    • 了解合成事件与原生事件的差异
  4. 性能注意事项

    • 避免在顶层元素上监听频繁触发的事件(如 scroll、mousemove)
    • 及时移除不需要的事件监听器
    • 使用事件委托减少监听器数量

总结

理解 JavaScript 事件机制是现代 Web 开发的基石:

  • 事件传播三阶段(捕获、目标、冒泡)是事件处理的核心模型
  • 事件委托是利用冒泡机制的高效事件处理模式
  • React 的合成事件提供了跨浏览器一致性和性能优化

掌握这些概念不仅能帮助开发者编写更高效的代码,还能解决复杂应用中的事件处理问题。无论是原生 JavaScript 还是 React 等现代框架,事件处理原理都是相通的,深入理解这些原理将大大提高开发能力。

JavaScript reduce()函数详解

作者 汤姆Tom
2025年7月7日 18:39

摘要: reduce() 是 JavaScript 数组方法中最强大但也最常被误解的方法之一。它能够将数组元素"缩减"为单个值,这个值可以是数字、字符串、对象甚至另一个数组。本文将深入探讨 r...

reduce() 是 JavaScript 数组方法中最强大但也最常被误解的方法之一。它能够将数组元素"缩减"为单个值,这个值可以是数字、字符串、对象甚至另一个数组。本文将深入探讨 reduce() 的工作原理、使用场景和最佳实践。

1. reduce() 基础语法

reduce() 方法的基本语法如下:

array.reduce(callback(accumulator, currentValue[, index[, array]])[, initialValue])

参数说明:

callback:执行数组中每个值的函数,包含四个参数:

accumulator:累计器,累积回调的返回值

currentValue:数组中正在处理的当前元素

index(可选):当前元素的索引

array(可选):调用reduce的数组

initialValue(可选):作为第一次调用callback时的第一个参数的值

2. 工作原理

reduce() 方法对数组中的每个元素按顺序执行一个"reducer"函数,每一次调用都将上一次调用的结果作为下一次调用的第一个参数传入,最终汇总为单个返回值。

执行过程示例

const numbers = [1234];
const sum = numbers.reduce((acc, curr) => acc + curr, 0);

// 执行步骤:
// 第一次调用: acc = 0, curr = 1 => 返回 1
// 第二次调用: acc = 1, curr = 2 => 返回 3
// 第三次调用: acc = 3, curr = 3 => 返回 6
// 第四次调用: acc = 6, curr = 4 => 返回 10
// 最终结果: 10

3. 常见使用场景

3.1 数组求和

const numbers = [1234];
const sum = numbers.reduce((acc, num) => acc + num, 0);
console.log(sum); // 10

3.2 计算平均值

const numbers = [1234];
const average = numbers.reduce((acc, num, index, arr) => {
  acc += num;
  if (index === arr.length - 1) {
    return acc / arr.length;
  }
  return acc;
}, 0);
console.log(average); // 2.5

3.3 数组元素计数

const fruits = ['apple''banana''apple''orange''banana''apple'];
const count = fruits.reduce((acc, fruit) => {
  acc[fruit] = (acc[fruit] || 0) + 1;
  return acc;
}, {});
console.log(count); // { apple: 3, banana: 2, orange: 1 }

3.4 数组扁平化

const nestedArrays = [[12], [34], [56]];
const flattened = nestedArrays.reduce((acc, arr) => acc.concat(arr), []);
console.log(flattened); // [1, 2, 3, 4, 5, 6]

3.5 对象属性求和

const products = [
  { name'Laptop'price1000 },
  { name'Phone'price500 },
  { name'Tablet'price300 }
];
const totalPrice = products.reduce((acc, product) => acc + product.price0);
console.log(totalPrice); // 1800

4. 高级用法

4.1 管道函数组合

const pipe = (...functions) => input => 
  functions.reduce((acc, fn) => fn(acc), input);

const add5 = x => x + 5;
const multiply2 = x => x * 2;
const subtract3 = x => x - 3;

const transform = pipe(add5, multiply2, subtract3);
console.log(transform(10)); // ((10 + 5) * 2) - 3 = 27

4.2 实现数组的map和filter

// 实现map
const map = (arr, fn) => arr.reduce((acc, val) => [...acc, fn(val)], []);

// 实现filter
const filter = (arr, fn) => arr.reduce(
  (acc, val) => fn(val) ? [...acc, val] : acc, 
  []
);

4.3 按属性分组

const people = [
  { name'Alice'age21 },
  { name'Bob'age21 },
  { name'Charlie'age22 }
];

const groupedByAge = people.reduce((acc, person) => {
  const age = person.age;
  if (!acc[age]) {
    acc[age] = [];
  }
  acc[age].push(person);
  return acc;
}, {});

console.log(groupedByAge);
/*
{
  21: [{ name: 'Alice', age: 21 }, { name: 'Bob', age: 21 }],
  22: [{ name: 'Charlie', age: 22 }]
}
*/

5. 注意事项 

1.初始值的重要性:如果不提供初始值,reduce会使用数组的第一个元素作为初始值并从第二个元素开始迭代。这在空数组上调用时会出错误。

[].reduce((acc, val) => acc + val); // TypeError
[].reduce((acc, val) => acc + val, 0); // 0

2.性能考虑:在大型数组上,频繁使用展开运算符(...)或concat创建新数组可能会影响性能。在这种情况下,考虑使用push修改现有数组。 

3.可读性:复杂的reduce操作可能会降低代码可读性。如果逻辑变得复杂,考虑使用传统的循环或其他数组方法。

AI辅助网页设计:从图片到代码的实践探索

作者 yuki_uix
2025年7月7日 18:37

记得今年初,我正在Pinterest上闲逛寻找设计灵感,突然,一张设计图吸引了我的目光——简洁的布局、恰到好处的留白、模块化的组件排列,这不正是我最近在思考的"设计系统化"的完美体现吗!

I offer Wix services | Site web design, Plans de conception web, Portfolio webdesign

website-layout.jpg

作为一名有两年经验的前端开发者,在项目初期,我的大部分工作内容就是将设计图转化为前端代码。但这次,我想尝试不同的方式——让AI来帮忙完成这个转换过程。

Step1:V0.dev——惊喜与失望并存

我之前项目上的前端TL为我们推荐了V0.dev,操作十分简单——直接把那张设计图拖进输入框,点击生成,然后...很快,第一个AI生成的页面就出现在我面前:

第一眼看上去,确实像模像样!标题、描述文字、按钮,基本元素都在该在的位置。但当我仔细对比原设计图时,问题开始浮现:

  • 卡片边缘的圆角半径明显不对
  • 一些 flex 比例控制的布局不符合预期
  • 还有一些复杂样式例如渐变色等都变成了纯色硬过度

image 2.png

虽然这部操作非常方便快捷,但这次尝试证明了一点:AI确实能理解设计图的基本结构,只是在细节还原和关联性理解上还有很大提升空间。

Step2:Cursor——分而治之

我决定换种策略。这次我选择了Cursor——相较于网页版的一键生成,AI编程助手可以更加有针对性的调整代码(而且V0.dev的每日使用次数有限,用于调试有些浪费)。我的新计划是:把设计图切成小块,让AI逐个击破。

image 3.png

首先,我在Figma里给原设计图加上了清晰的辅助线,明确划分出:

  • 顶部的导航区
  • 中部的核心内容区
  • 底部的页脚信息

⠀我截取了顶部区域,连同我的详细说明一起发给Cursor:

请基于这个Header设计生成代码,要求:

  1. 使用Flex布局
  2. logo居左,导航菜单居中
  3. 右侧是登录/注册按钮
  4. 整体高度80px,背景半透明

这次的结果明显好多了!AI准确地还原了布局结构,虽然有些间距还是需要微调,但至少不再是完全跑偏的状态。

但当我进展到"Latest Stories"这个复杂板块时,又遇到了新挑战。这个部分包含:

image 5.png

  • 一个主标题
  • 三张图文混排的卡片
  • 每张卡片都有独特的悬停效果
  • 卡片之间还有微妙的视觉关联

⠀我尝试了各种提示词组合:

  • "生成一个三栏布局,每栏包含图片和文字..."
  • "创建三个新闻卡片,要有悬停效果..."
  • "使用Grid实现图文混排..."

AI每次都能生成看似合理的代码,但总有些细节不对劲。经过多次尝试后,我决定放弃100%AI生成,接受了90%的AI生成代码,剩下10%手动调整。

Step3:Figma助攻——突破性的进展

"既然AI对平面图的理解有限,为什么不先用Figma把设计图的结构明确出来呢?"

说干就干,我把原设计图导入Figma,开始按照开发习惯拆解“图层”:

  1. 最底层:背景色和装饰元素
  2. 中间层:内容容器和留白
  3. 上层:文本和图片内容

group-in-figma-v3-01.jpg

这个过程有点类似于顺应我往常的开发习惯,拆解设计图,逐渐揭示出隐藏的结构。我在Figma中建立了清晰的层级关系,给每个组件都起了有意义的名称,而不是默认的"rectangle"或"container"。 当我把这个结构化后的Figma文件通过插件转换成HTML,再导入Cursor进行调整时,AI现在能够准确识别出哪些样式应该放在父容器,哪些应该放在子元素——这正是之前手动调整最费时的部分。

Step4:组件化——有效提升效率

随着实践的深入,我摸索出了一套相对高效的工作流程。现在的我不再机械地把设计图扔给AI然后祈祷好运,而是现在脑海中盘算每一步操作,将我这套规则交给AI:

  1. 设计解构阶段 :从前端开发角度结构图面层次
  2. 组件规划阶段 :识别可复用的模式,规划组件结构
  3. AI生成阶段 :分步骤指导AI生成基础代码
  4. 人工优化阶段 :调整那些AI尚未理解,或调试难度较大,不如手动调整

比如在处理卡片组件时,可以定义接口:

interface CardProps {
  imageUrl: string;
  title: string;
  description: string;
  hoverEffect?: 'zoom' | 'fade' | 'lift';
  theme?: 'light' | 'dark';
}

group-in-figma-v4.jpg

然后让AI基于这个规范生成代码,而不是自由发挥。这种方式生成的组件不仅符合设计需求,还能在整个项目中保持一致性。

Step5:工具探索——开拓视野

在基本流程跑通后,我开始好奇,现在有哪些design-to-code的产品?于是开启了一段工具探索之旅:

Webflow 体验

Snipaste_2025-03-11_16-27-07.jpg

  • 优点:可视化编辑强大,响应式设计工具完善
  • 缺点:学习曲线陡峭,代码导出需要付费
  • 适合场景:不需要深度定制的营销页面

Framer 尝试

Snipaste_2025-03-11_16-33-28.jpg

  • 惊喜:基于React的理念,组件化工作流很顺手
  • 不足:复杂交互实现起来还是有限制
  • 亮点:布局逻辑特别清晰,适合前端思维

Same.new 评测

image 6.png

  1. 自动分析设计图结构
  2. 生成清晰的组件层次
  3. 产出可维护的React代码
  4. 甚至生成了完整的文档!

虽然还有些小bug,但感受到简单的 design-to-code 应该在不远的将来,就能一键实现了。

Step6:尝试理解AI的思维方式

为了更好地与AI协作,我开始研究这些工具背后的工作原理。通过阅读论文和开发者博客,我拼凑出了AI布局生成的大致流程:

  1. 视觉特征提取 :识别设计图中的色彩、形状、文字等基本元素
  2. 层级关系推断 :分析哪些元素属于同一组,如何嵌套
  3. 代码模式匹配 :在训练数据中寻找相似的设计模式
  4. 语法规则应用 :确保生成的代码符合语言规范

经过一次次的实践,我大致总结了如下经验:

  1. 分而治之 :把大设计图拆解成小模块,AI处理起来更精准
  2. 明确规范 :提前定义好设计系统,避免AI自由发挥
  3. 混合工作流 :AI生成+人工调整,效率最高

通过这套方法,原本需要1天的开发调试工作,现在可以用几句prompt,在半个小时内完成,而且页面效果符合预期。尤其是当我在项目上,与非前端技术栈的小伙伴pair完成开发工作时,他对AI转化的效果感到震惊,期待我能做出更多分享~

而我现在回望这段探索历程,我意识到这不仅仅是一次工具使用的尝试,更是对前端开发工作方式的重新思考。未来的前端开发者可能需要具备这些新能力:

  1. 设计翻译能力 :将视觉设计准确转化为AI能理解的规范
  2. 提示工程技能 :有效指导AI生成所需代码
  3. 质量把控意识 :识别AI输出的潜在问题
  4. 系统思维 :构建可维护的组件体系

⠀"技术会变,但解决问题的思维不会。"

你以为 React 的事件很简单?错了,它暗藏玄机!

作者 小飞悟
2025年7月7日 18:34

🧠 一、什么是 JavaScript 的事件机制?

JavaScript 是一种 单线程语言,也就是说它一次只能做一件事。但网页上有很多事情是用户随时会做的(比如点击按钮、输入文字等),JavaScript 就需要一种方式来处理这些“突发事件”,这就是 事件机制

你可以把事件机制想象成一个“快递员”系统:

  • 用户操作(比如点击)就像发了一个快递请求。
  • JS 把这个请求放到一个队列里排队。
  • 当主线程空了,JS 再一个个取出来处理。

🔁 异步执行

举个例子:

console.log("开始");

document.getElementById("btn").addEventListener("click", function() {
  console.log("你点了按钮");
});

console.log("结束");

输出顺序是这样的:

开始
结束
(当你点按钮时才会输出)你点了按钮

解释:

  • console.log("开始")console.log("结束") 是同步代码,立刻执行。
  • 点击事件是异步的 —— 只有当用户真的去点击按钮时才会执行。

✅ 所以说:事件是异步的,不会马上执行,而是等用户操作后才触发。


二、JavaScript 中的两种事件模型:DOM0 级 & DOM2 级事件


🌟 为什么需要了解这些?

JavaScript 的事件处理方式经历了几个阶段,不同的写法适用于不同年代的浏览器。现在虽然主要用 DOM2 级事件,但理解这些有助于你更好地理解 React 和现代前端框架是怎么处理事件的。


1️⃣ DOM0 级事件 —— 最早的方式

✅ 特点:

  • 直接在 HTML 标签中或 JS 中给元素添加 on事件名 属性。
  • 只能绑定一个事件处理函数。
  • 如果重复绑定,后面的会覆盖前面的。

示例:

<!-- 方式一:HTML 内联绑定 -->
<button onclick="alert('Hello')">点我</button>

<!-- 方式二:JS 中绑定 -->
<script>
  const btn = document.getElementById("btn");
  btn.onclick = function() {
    alert("你点了按钮!");
  };
</script>

缺点:

  • 不够灵活,不能多次绑定同一个事件。
  • 代码和结构混在一起,不便于维护。

2️⃣ DOM1 级事件? —— 并不存在!

❓那 DOM1 是什么?

  • DOM1(Document Object Model Level 1)是 W3C 发布的一个标准,主要是定义了文档的基本结构和操作方法。
  • 并没有涉及事件处理机制
  • 所以说:DOM1 级事件这个说法其实是不存在的

3️⃣ DOM2 级事件 —— 推荐使用的方式

✅ 特点:

  • 使用 addEventListener() 方法。
  • 支持多个监听器绑定到同一个事件上。
  • 可以指定是在 捕获阶段 还是 冒泡阶段 触发。
  • 更加灵活、强大。

示例:

const btn = document.getElementById("btn");

btn.addEventListener("click", function() {
  alert("第一次点击");
});

btn.addEventListener("click", function() {
  alert("第二次点击");
});

两个提示都会弹出,不会互相覆盖!


捕获 vs 冒泡:

你可以把事件想象成一颗石头掉进水里:

  • 捕获阶段(Capture Phase):石头往水里沉下去(从最外层向目标元素传播)。
  • 冒泡阶段(Bubble Phase):水花溅上来(从目标元素向外传播)。

React 的事件命名也体现了这一点:

  • onClick → 冒泡阶段触发
  • onClickCapture → 捕获阶段触发

🧩 实际应用场景
1. 事件委托

使用冒泡阶段可以非常方便地实现事件委托。例如,你可以在一个列表项很多的情况下,只给整个列表添加一个事件监听器,而不是为每一个列表项都添加监听器。

document.getElementById('list').addEventListener('click', function(event) {
    if (event.target.tagName === 'LI') {
        console.log('你点击了一个列表项');
    }
});

这样不仅减少了内存占用,还简化了代码逻辑。


2. 阻止事件传播

有时候你需要阻止某个事件继续传播,比如在一个模态框(Modal)内部点击时,不希望触发背后的页面点击事件。你可以通过在捕获阶段阻止事件传播来达到目的。

document.getElementById('modal').addEventListener('click', function(event) {
    event.stopPropagation(); // 阻止事件继续冒泡
}, true); // 使用捕获阶段

🤔 为什么不只用一个阶段?

如果只保留一个阶段(无论是捕获还是冒泡),都会失去一些重要的灵活性:

  • 仅捕获阶段:无法轻松实现常见的用户交互,因为大多数情况下我们希望事件能够“冒泡”上来。
  • 仅冒泡阶段:则失去了在事件到达目标之前进行处理的机会,这对于某些高级功能(如全局拦截、权限控制)是必要的。

📋 总结一句话:

捕获阶段和冒泡阶段各有其独特的用途和优势,它们共同提供了强大的灵活性,使得我们可以根据不同的需求选择合适的时机来处理事件。


🔁 对比总结表

特性 DOM0 级事件 DOM2 级事件
绑定方式 onclick 属性或赋值 addEventListener()
是否支持多个监听器 否(会被覆盖)
是否支持捕获/冒泡控制
是否推荐使用 ❌ 不推荐 ✅ 推荐
是否容易维护 ❌ 差(逻辑与结构混合) ✅ 好

🧠 小贴士:React 用了哪种方式?

React 的合成事件系统底层其实用的是 DOM2 级事件addEventListener),只是封装了一层,让你不用手动管理事件绑定和解绑,更加安全高效。


✅ 现代网页开发都使用 addEventListener() 来监听事件,它更灵活、功能更强,是主流做法。


三、🧩 addEventListener() 是什么?

它是 JavaScript 中用来监听事件的标准方法,比如点击、输入、滚动等。

基本写法:

element.addEventListener(type, listener, options);

或者:

element.addEventListener(type, listener, useCapture);

1. 参数详解

1) type:要监听的事件类型(字符串)

就是你想监听哪种操作,比如:

  • "click" 点击
  • "input" 输入框内容变化
  • "scroll" 滚动页面
  • "keydown" 键盘按键按下

✅ 注意:这个值是大小写敏感的,一般都用小写。

示例:
document.getElementById("btn").addEventListener("click", function() {
  alert("按钮被点击了");
});

2) listener:事件发生时执行的函数

它是一个函数或实现了 EventListener 接口的对象。

✅ 最常见的是一个函数:
function handleClick(event) {
  console.log("你点我了");
}

button.addEventListener("click", handleClick);

⚠️ 注意:不要加括号 (),因为我们要传的是函数本身,不是调用结果。


3) optionsuseCapture:可选参数,控制监听行为

这是高级功能,我们可以根据需求选择是否使用。


2. useCapture 参数(布尔值)

决定是在 捕获阶段 还是 冒泡阶段 触发事件。

  • true:在捕获阶段触发(从外向内)
  • false:在冒泡阶段触发(从内向外)—— 默认值
示例:
<div id="outer">
  <div id="inner">点我</div>
</div>
const outer = document.getElementById("outer");
const inner = document.getElementById("inner");

// 捕获阶段监听
outer.addEventListener("click", () => {
  console.log("外层:捕获阶段");
}, true);

// 冒泡阶段监听
outer.addEventListener("click", () => {
  console.log("外层:冒泡阶段");
}, false);

inner.addEventListener("click", () => {
  console.log("内层被点击");
});
输出顺序(点击“内层”):
外层:捕获阶段
内层被点击
外层:冒泡阶段

3. options 参数对象(React 和现代项目常用)

options 是一个对象,可以包含以下选项:

属性 类型 描述
capture Boolean useCapture,是否在捕获阶段触发
once Boolean 只触发一次,之后自动移除监听器
passive Boolean 表示不会调用 preventDefault(),用于优化性能(如滚动)
signal AbortSignal 配合 AbortController 使用,可以手动取消监听

✅ 1) once: 只触发一次

button.addEventListener("click", () => {
  console.log("只执行一次!");
}, { once: true });

点击第一次会输出,第二次就不会了。


✅ 2) passive: 不阻止默认行为,提高滚动性能

适合移动端滚动优化:

window.addEventListener("wheel", () => {
  console.log("滚动了");
}, { passive: true });

如果用了 { passive: true },就不能再调用 event.preventDefault(),否则浏览器会警告。


✅ 3) signal: 动态取消监听

配合 AbortController 使用:

const controller = new AbortController();
const signal = controller.signal;

button.addEventListener("click", () => {
  console.log("点击了一次");
}, { signal });

controller.abort(); // 主动取消监听

4. 总结对比表

参数名 类型 作用 示例
type 字符串 事件类型,如 "click" "scroll"
listener 函数或对象 事件触发时执行的函数 handleClick
useCapture 布尔值 是否在捕获阶段触发 true / false
options.capture 布尔值 同上 { capture: true }
options.once 布尔值 只触发一次后自动移除 { once: true }
options.passive 布尔值 不调用 preventDefault(),提升性能 { passive: true }
options.signal AbortSignal 手动控制取消监听 { signal }

四、🧠 什么是事件委托?

事件委托,就是把子元素的事件监听任务交给父元素来做。

听起来有点抽象?没关系,我们用小白也能懂的方式解释。


🏠 类比理解

想象你是一个小区的保安:

  • 小区里有 100 户人家。
  • 如果你给每家每户门口都装一个摄像头、安排一个人看守,成本太高了。
  • 所以你选择在小区大门装一个摄像头,谁进来了你都知道 —— 这就是“事件委托”。

✅ 在网页中:

  • 子元素很多的时候,每个都加监听器效率低。
  • 把监听器放在它们的共同父元素上,通过冒泡机制统一处理。

✅ 举个例子

HTML 结构:

<ul id="menu">
  <li>首页</li>
  <li>关于我们</li>
  <li>联系我们</li>
</ul>

你想点击每个 <li> 的时候弹出对应的菜单名。

不推荐的做法(每个都加监听器):

document.querySelectorAll("#menu li").forEach(item => {
  item.addEventListener("click", function() {
    alert(this.textContent);
  });
});

推荐做法(使用事件委托):

document.getElementById("menu").addEventListener("click", function(event) {
  if (event.target.tagName === "LI") {
    alert(event.target.textContent);
  }
});

🔍 原理讲解

因为事件会冒泡,所以:

  • 点击的是 <li>,但事件会一直冒泡到它的父元素 <ul>
  • 我们在 <ul> 上监听点击事件,然后判断 event.target 是哪个元素触发的
  • 如果是 <li>,就执行对应的操作

💡 event.target 和 event.currentTarget 的区别

属性 含义
event.target 实际被点击的元素(比如某个 <li>
event.currentTarget 当前正在处理事件的元素(也就是绑定监听器的那个父元素)
document.getElementById("menu").addEventListener("click", function(event) {
  console.log("event.target:", event.target); // 被点击的 <li>
  console.log("event.currentTarget:", event.currentTarget); // 绑定事件的 <ul>
});

🚀 事件委托的优点(为什么大家都喜欢用它)

优点 说明
性能更好 不需要为每个子元素单独绑定事件,减少内存消耗
动态添加元素也有效 新增的 <li> 也会自动被委托处理,不需要重新绑定
代码更简洁 只需写一个监听函数,就可以处理多个子元素

📌 使用场景举例

  • 表格、列表项很多时(如聊天消息、商品列表)
  • 动态加载内容(比如 Ajax 加载新数据)
  • 导航栏、选项卡等交互组件
  • React 中的事件系统其实也是基于事件委托实现的!

❗ 注意事项

  • 只能用于支持事件冒泡的事件类型(如 click、input),不适用于 focus、blur 等
  • 要注意判断 event.target,避免误操作其他嵌套元素(比如 <li> 里面有 <span>

事件委托是一种利用事件冒泡机制,将多个子元素的事件监听集中到父元素上的优化技术,既节省资源又便于维护。

基于 Three.js 开发三维引擎-02动态圆柱墙体实现

2025年7月7日 18:12

三维引擎Wall2类封装:动态圆柱墙体实现

在三维可视化场景中,墙体是常见的几何元素之一。本文将基于Three.js库,将一段创建动态圆柱墙体的代码封装为Wall2类,实现可复用的圆柱墙体组件,支持动态效果和自定义样式。

image.png

类设计概述

Wall2类主要实现以下功能:

  • 创建圆柱几何体
  • 应用自定义着色器实现动态效果
  • 支持高度、颜色等属性配置
  • 提供资源管理和更新接口

该类参考了已有的Wall类结构,采用面向对象设计,便于在场景中集成和管理。

完整代码实现

/**
 *
 * @file 动态圆柱墙体
 *
 * @author
 */
import {
  Object3D,
  CylinderGeometry,
  ShaderMaterial,
  Mesh,
  DoubleSide
} from 'three';

/**
 * 动态圆柱墙体类 - 用于创建带波浪动态效果的圆柱墙体
 * @class Wall2
 * @extends Object3D
 * @param {Object} [opts={}] 初始化参数
 * @param {number} [opts.radius=30] 圆柱半径
 * @param {number} [opts.height=20] 圆柱高度
 * @param {number} [opts.radialSegments=4] 径向分段数
 * @param {number} [opts.heightSegments=64] 高度分段数
 * @param {boolean} [opts.openEnded=true] 是否开放端面
 * @param {Object|string} [opts.color] 墙体颜色
 * @param {number} [opts.time=0] 时间参数,用于动态效果
 * @param {number} [opts.positionY=10] Y轴位置偏移
 */
class Wall2 {
  constructor(opts = {}) {
    super();
    // 基础属性初始化
    this.radius = opts.radius || 30;
    this.height = opts.height || 20;
    this.radialSegments = opts.radialSegments || 4;
    this.heightSegments = opts.heightSegments || 64;
    this.openEnded = opts.openEnded !== undefined ? opts.openEnded : true;
    this.color = opts.color || { r: 0, g: 1, b: 0.5 };
    this.time = opts.time || 0;
    this.positionY = opts.positionY || 10;
    
    // 渲染相关属性
    this.geometry = null;
    this.material = null;
    this.mesh = null;
    
    // 初始化墙体
    this.init();
  }

  /**
   * 初始化圆柱墙体
   * @private
   */
  init() {
    // 创建圆柱几何体
    this.geometry = new CylinderGeometry(
      this.radius,
      this.radius,
      this.height,
      this.radialSegments,
      this.heightSegments,
      this.openEnded
    );
    
    // 创建着色器材质
    this.material = new ShaderMaterial({
      side: DoubleSide,
      transparent: true,
      depthWrite: false,
      uniforms: {
        uTime: { value: this.time },
        uColor: { value: this.color }
      },
      vertexShader: `
        varying vec2 vUv; 
        void main() {
          vUv = uv;
          gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }
      `,
      fragmentShader: `
        uniform float uTime;
        uniform vec3 uColor;
        varying vec2 vUv;
        #define PI 3.14159265

        void main() {
          vec4 baseColor = vec4(uColor, 1.0);
          vec4 finalColor;
          
          float amplitude = 1.0;
          float frequency = 10.0;
          
          float x = vUv.x;
          float y = sin(x * frequency);
          float t = 0.01 * (-uTime * 130.0);
          y += sin(x * frequency * 2.1 + t) * 4.5;
          y += sin(x * frequency * 1.72 + t * 1.121) * 4.0;
          y += sin(x * frequency * 2.221 + t * 0.437) * 5.0;
          y += sin(x * frequency * 3.1122 + t * 4.269) * 2.5;
          y *= amplitude * 0.06;
          y /= 3.0;
          y += 0.55;

          float r = step(0.5, fract(vUv.y - uTime));
          baseColor.a = step(vUv.y, y) * (y - vUv.y) / y;
          
          gl_FragColor = baseColor;
        }
      `
    });
    
    // 创建网格对象
    this.mesh = new Mesh(this.geometry, this.material);
    this.mesh.position.y = this.positionY;
  }

 



}

export { Wall2 };

核心功能解析

构造函数与属性初始化

constructor(opts = {}) {
  super();
  // 基础属性初始化
  this.radius = opts.radius || 30;
  this.height = opts.height || 20;
  this.radialSegments = opts.radialSegments || 4;
  this.heightSegments = opts.heightSegments || 64;
  this.openEnded = opts.openEnded !== undefined ? opts.openEnded : true;
  this.color = opts.color || { r: 0, g: 1, b: 0.5 };
  this.time = opts.time || 0;
  this.positionY = opts.positionY || 10;
  
  // 渲染相关属性
  this.geometry = null;
  this.material = null;
  this.mesh = null;
  
  // 初始化墙体
  this.init();
}

构造函数中定义了墙体的基本属性:

  • 几何属性:半径、高度、分段数等
  • 视觉属性:颜色、位置偏移
  • 渲染相关:几何体、材质、网格对象引用

初始化方法

init() {
  // 创建圆柱几何体
  this.geometry = new CylinderGeometry(
    this.radius,
    this.radius,
    this.height,
    this.radialSegments,
    this.heightSegments,
    this.openEnded
  );
  
  // 创建着色器材质
  this.material = new ShaderMaterial({
    side: DoubleSide,
    transparent: true,
    depthWrite: false,
    uniforms: {
      uTime: { value: this.time },
      uColor: { value: this.color }
    },
    // 顶点着色器和片段着色器代码(略)
  });
  
  // 创建网格对象并添加到场景
  this.mesh = new Mesh(this.geometry, this.material);
  this.mesh.position.y = this.positionY;
  this.add(this.mesh);
}

初始化方法完成以下核心工作:

  1. 使用CylinderGeometry创建圆柱几何体
  2. 配置ShaderMaterial实现自定义渲染效果
  3. 将几何体与材质组合成Mesh对象
  4. 设置网格位置并添加到当前对象

着色器实现动态效果

着色器代码是实现动态波浪效果的关键:

// 顶点着色器
varying vec2 vUv; 
void main() {
  vUv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

// 片段着色器
uniform float uTime;
uniform vec3 uColor;
varying vec2 vUv;
#define PI 3.14159265

void main() {
  vec4 baseColor = vec4(uColor, 1.0);
  
  float amplitude = 1.0;
  float frequency = 10.0;
  
  float x = vUv.x;
  float y = sin(x * frequency);
  float t = 0.01 * (-uTime * 130.0);
  y += sin(x * frequency * 2.1 + t) * 4.5;
  y += sin(x * frequency * 1.72 + t * 1.121) * 4.0;
  y += sin(x * frequency * 2.221 + t * 0.437) * 5.0;
  y += sin(x * frequency * 3.1122 + t * 4.269) * 2.5;
  y *= amplitude * 0.06;
  y /= 3.0;
  y += 0.55;

  float r = step(0.5, fract(vUv.y - uTime));
  baseColor.a = step(vUv.y, y) * (y - vUv.y) / y;
  
  gl_FragColor = baseColor;
}

着色器实现了以下效果:

  • 通过多个正弦函数叠加生成复杂波浪曲线
  • 使用uTimeuniform参数驱动动画效果
  • 根据UV坐标计算透明度,实现波浪上升效果
  • 支持自定义颜色参数

更新与资源管理

/**
 * 更新墙体状态
 * @param {number} deltaTime 时间增量
 */
update(deltaTime) {
  if (this.material) {
    this.time += deltaTime;
    this.material.uniforms.uTime.value = this.time;
  }
}

/**
 * 释放资源
 */
dispose() {
  if (this.mesh) {
    if (this.mesh.geometry) {
      this.mesh.geometry.dispose();
    }
    if (this.mesh.material) {
      this.mesh.material.dispose();
    }
    this.remove(this.mesh);
    this.mesh = null;
  }
  this.geometry = null;
  this.material = null;
}

更新方法用于驱动动态效果,通过更新uTime参数使波浪动画持续运行。

使用示例

下面是一个完整的使用示例,展示如何创建Wall2实例并集成到Three.js场景中:

import { Wall2 } from "./Wall2";
import * as THREE from "three";

// 初始化场景、相机和渲染器
function initScene() {
  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
  );
  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(window.innerWidth, window.innerHeight);
  document.body.appendChild(renderer.domElement);
  
  // 添加光源
  const ambientLight = new THREE.AmbientLight(0x404040, 2);
  scene.add(ambientLight);
  const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
  directionalLight.position.set(1, 1, 1);
  scene.add(directionalLight);
  
  return { scene, camera, renderer };
}

// 创建墙体
function createWall(scene) {
  const wall = new Wall2({
    radius: 40,
    height: 30,
    radialSegments: 8,
    heightSegments: 128,
    color: 0x00aaff,
    positionY: 15
  });
  scene.add(wall);
  return wall;
}

// 动画循环
function animate(scene, camera, renderer, wall) {
  requestAnimationFrame(() => animate(scene, camera, renderer, wall));
  
  // 更新墙体动画
  const deltaTime = 0.01;
  wall.update(deltaTime);
  
  // 旋转相机
  camera.position.x = Math.sin(Date.now() * 0.001) * 100;
  camera.position.z = Math.cos(Date.now() * 0.001) * 100;
  camera.lookAt(new THREE.Vector3(0, 15, 0));
  
  renderer.render(scene, camera);
}

// 初始化并启动
const { scene, camera, renderer } = initScene();
const wall = createWall(scene);
animate(scene, camera, renderer, wall);

扩展与优化方向

功能扩展

  1. 端面处理:当前实现为开放端面,可添加封闭端面选项

  2. 纹理支持:扩展材质以支持纹理贴图,丰富墙体外观

  3. 动态参数配置:增加更多可配置的动态效果参数,如波浪幅度、频率等

  4. 事件系统:添加交互事件支持,如鼠标悬停、点击等

性能优化

  1. 几何体优化:对于大型场景,可根据相机距离动态调整分段数

  2. 批量渲染:使用InstancedMesh实现多个墙体的批量渲染

  3. LOD支持:实现层次细节(Level of Detail),远距离使用简化模型

高级效果

  1. 光影效果:修改着色器以支持光照,使墙体更真实

  2. 透明渐变:实现墙体透明度随高度变化的效果

  3. 复杂波形:扩展波形函数,实现更复杂的动态效果

  4. 材质混合:支持多种材质混合,创建更丰富的视觉效果

总结

通过封装Wall2类,我们实现了一个功能完整的动态圆柱墙体组件,具有以下特点:

  • 支持圆柱几何参数自定义
  • 通过着色器实现动态波浪效果
  • 提供颜色、位置等视觉属性配置
  • 实现资源管理和状态更新接口

为什么推荐前端学习油猴脚本开发?

作者 石小石Orz
2025年7月7日 18:07

大家好,我是石小石!


相信不少前端同学对 Chrome 扩展插件并不陌生:比如能屏蔽广告的 Adblock、自定义标签页的 Infinity 新标签页、格式化 JSON 的开发小工具、截图工具,甚至还有 Vue.js devtools、Redux DevTools 这样的调试利器。

很早以前,我也被这些插件的强大功能吸引,暗自立下目标,一定要掌握扩展插件开发。但实际动手后发现,Chrome 插件的开发门槛并不低,权限机制、构建流程、清单配置……这些问题让我一度搁置。

直到后来,我接触到了油猴(Tampermonkey) 脚本开发。它让我重新燃起了对网页增强脚本的兴趣:同样可以实现丰富的浏览器功能,但上手却要简单许多。借助 HTML、CSS 和 JavaScript,便能快速开发出媲美插件的实用功能,而且部署和调试都非常灵活。

正因如此,我花了很多时间深入研究油猴脚本的各种能力,从简单的界面增强,到跨域数据请求、页面劫持,再到摄像头识别、画中画等进阶玩法,这些都可以仅靠一个脚本实现。也正是在这个过程中,我写下了《油猴脚本实战指南》这本小册,分享我一路走来的经验一些技术分享。

这本小册上线已近一个月,期间我也遇到了很多志同道合的开发者,他们和我一样,在原本有限的时间里,通过脚本开发打开了新的成长路径。

如果你也是一名前端开发者,渴望提升能力却苦于时间有限,不妨试试油猴脚本开发。它不仅能帮你解决日常工作中的小痛点,还能训练你的综合编程能力。对我来说,这是性价比极高的一种前端进阶方式。希望你也能从中收获惊喜。

什么是油猴脚本

油猴(Tampermonkey)是一款浏览器插件,允许用户在网页加载时注入自定义的 JavaScript 脚本,来增强、修改或自动化网页行为

通俗地说,借助油猴,你可以将自己的 JavaScript 代码“植入”任意网页,实现自动登录、抢单、签到、数据爬取、广告屏蔽等各种“开挂级”功能,彻底掌控页面行为。

分享一些我实现的有趣网页脚本

  • 手势识别实现网页控制

**
**

  • 人脸识别实现”人脸版黄金旷工“小游戏

  • 接口拦截工具:修改CSDN博客数据接口返回值

  • Vue路由一键切换:开发效率起飞

  • 任意元素双击实现画中画:摸鱼超级助手

  • 掘金后台自动签到助手

  • 解除文本复制、网页复制、一键下载为MD

  • 主题切换助手

它能给你带来什么

  • 提高互联网体验

通过用户脚本,我们可以为网页添加各种实用功能,显著提升浏览效率和使用体验。比如在阅读时,我们可以实现一键翻译、自动展开全文、解除复制限制、去除广告干扰;在观看视频时,可以开启 VIP 视频解析、倍速播放,甚至自动跳过片头片尾;对于学习和工作场景,还能自动刷题、辅助答题、自动播放课程;而在购物或资源获取方面,脚本也能帮你自动抢购、快速下载网页中的音视频内容。所有这些功能,只需我们动动手写几行JavaScript,就能一劳永逸!

  • 提升工作效率

在日常工作中,我们经常会遇到一些重复、耗时又低效的操作,比如:每天手动输入账号密码登录某个网页、反复填写相似的表单内容、开发中费尽周折从网页中提取数据、频繁刷新 token 以保持页面正常访问,在多路由项目中不断手动切换页面路径……这些看似“习以为常但不可避免”的操作,实则完全可以通过脚本解决。

  • 提高工作竞争力

在公司工作中,很多重复、低效的流程其实完全可以通过脚本来优化。如果你能主动用脚本解决这些问题,不仅能提升团队效率,更能显著增强你在公司的核心竞争力。

以我自己的经历为例:我们公司的前端项目采用的是 Qiankun 微前端架构。在本地开发子应用时,由于缺乏主应用的数据支持,调试过程经常变得非常麻烦。为了解决这个问题,我编写了一个脚本工具,能够将主应用的数据无缝注入本地环境,同时也支持将本地子应用嵌入到线上主应用中进行联调。这极大地提升了开发调试的效率。

后来,这个脚本在公司内部广泛推广,并被评为“最佳提效工具”,也为我在职级晋升中加分不少。所以,如果你能通过脚本解决公司一些业务痛点,很容易提升你的核心竞争力。

  • 增加面试亮点

果你在简历中写到:“通过脚本解决了开发过程中的 XXX 问题,优化了业务流程中的 XXX 环节,节省了 XX 小时的人力成本”——那你的简历一定会脱颖而出,更容易通过面试。毕竟,几乎所有面试官和管理者都欣赏那种善于发现问题、主动用技术解决问题,并能为团队带来实实在在价值的人。

  • 变现

利用脚本变现有多种途径,效果因人而异。你可以在 GreasyFork 等平台发布实用脚本,获得用户打赏;也可以承接定制开发项目,实现接单变现;此外,脚本还能帮助你降低其他互联网变现方式的运营成本,从而实现效益的最大化。

学习难度

用户脚本本质上是通过 JavaScript 增强网页功能或操作页面 DOM,所以对于前端同学而言,你只需掌握油猴的开发规则和几个关键 API,就能快速上手。借助本小册基础篇的内容,你甚至可以在 2 到 4 小时内完成脚本的入门学习,轻松实现脚本编写与快速发布。

但是,如果不懂CSS+HTML+JavaScript ,你需要提前学习这些前置知识。

此外,你甚至能零成本将一个油猴脚本打包成一个原生谷歌浏览器插件,YYDS!


如果你也对脚本开发感兴趣,可以加我shc139874527进油猴脚本开发交流群,一起感受脚本开发的魅力~

发布一个monaco-editor 汉化包

2025年7月7日 17:59

monaco-editor 配置中文语言并制作npm包

在vite6.0+vue3的项目中使用monaco-editor开发一个在线编辑器,如何配置中文语言,并且结合Ai开发一个vite插件发布到npm

依赖版本及运行环境

{
  "monaco-editor": "^0.52.2",
  "vite": "^6.3.5",
  "vue": "^3.5.17",
  "node": "22.14.0",
}

如何配置中文

1、下载中文语言文件包

首先,去VS Code的语言包仓库下载对应的语言包:vscode-loc/i18n/vscode-language-pack-zh-hans,里面的translations/main.i18n.json就是中文的语言包。

2、应用语言包

参考vite-plugin-monaco-editor-nls实现语言转换,它的原理是将汉化文本注入到monaco源码的nls.js文件中。

import fs from 'node:fs';
import path from 'node:path';
import type { Plugin } from 'vite';
import MagicString from 'magic-string';

export enum Languages {
  bg = 'bg',
  cs = 'cs',
  de = 'de',
  en_gb = 'en-gb',
  es = 'es',
  fr = 'fr',
  hu = 'hu',
  id = 'id',
  it = 'it',
  ja = 'ja',
  ko = 'ko',
  nl = 'nl',
  pl = 'pl',
  ps = 'ps',
  pt_br = 'pt-br',
  ru = 'ru',
  tr = 'tr',
  uk = 'uk',
  zh_hans = 'zh-hans',
  zh_hant = 'zh-hant',
}

export interface Options {
  locale: Languages;
  localeData?: Record<string, any>;
}

/**
 * 在vite中dev模式下会使用esbuild对node_modules进行预编译,导致找不到映射表中的filepath,
 * 需要在预编译之前进行替换
 * @param options 替换语言包
 * @returns
 */
export function esbuildPluginMonacoEditorNls(options: Options) {
  options = Object.assign({ locale: Languages.en_gb }, options);
  const CURRENT_LOCALE_DATA = getLocalizeMapping(options.locale, options.localeData);

  return {
    name: 'esbuild-plugin-monaco-editor-nls',
    setup(build) {
      build.onLoad({ filter: /esm[/\\]vs[/\\]nls\.js/ }, async () => {
        return {
          contents: getLocalizeCode(CURRENT_LOCALE_DATA),
          loader: 'js',
        };
      });

      build.onLoad({ filter: /monaco-editor[/\\]esm[/\\]vs.+\.js/ }, async (args) => {
        return {
          contents: transformLocalizeFuncCode(args.path, CURRENT_LOCALE_DATA),
          loader: 'js',
        };
      });
    },
  };
}

/**
 * 使用了monaco-editor-nls的语言映射包,把原始localize(data, message)的方法,替换成了localize(path, data, defaultMessage)
 * vite build 模式下,使用rollup处理
 * @param options 替换语言包
 * @returns
 */
export default function (options: Options): Plugin {
  options = Object.assign({ locale: Languages.en_gb }, options);
  const CURRENT_LOCALE_DATA = getLocalizeMapping(options.locale, options.localeData);

  return {
    name: 'rollup-plugin-monaco-editor-nls',

    enforce: 'pre',

    load(filepath) {
      if (/esm[/\\]vs[/\\]nls\.js/.test(filepath)) {
        return getLocalizeCode(CURRENT_LOCALE_DATA);
      }
    },
    transform(code, filepath) {
      if (
        /monaco-editor[/\\]esm[/\\]vs.+\.js/.test(filepath) &&
        !/esm[/\\]vs[/\\].*nls\.js/.test(filepath)
      ) {
        const re = /monaco-editor[/\\]esm[/\\](.+)(?=\.js)/;
        if (re.exec(filepath) && code.includes('localize(')) {
          let path = RegExp.$1;
          path = path.replace(/\\/g, '/');
          code = code.replace(/localize\(/g, `localize('${path}', `);

          return {
            code: code,

            /** 使用magic-string 生成 source map */
            map: new MagicString(code).generateMap({
              includeContent: true,
              hires: true,
              source: filepath,
            }),
          };
        }
      }
    },
  };
}

/**
 * 替换调用方法接口参数,替换成相应语言包语言
 * @param filepath 路径
 * @param CURRENT_LOCALE_DATA 替换规则
 * @returns
 */
function transformLocalizeFuncCode(filepath: string, _CURRENT_LOCALE_DATA: string) {
  let code = fs.readFileSync(filepath, 'utf8');
  const re = /monaco-editor[/\\]esm[/\\](.+)(?=\.js)/;
  if (re.exec(filepath)) {
    let path = RegExp.$1;
    path = path.replace(/\\/g, '/');

    // if (filepath.includes('contextmenu')) {
    //     console.log(filepath);
    //     console.log(JSON.parse(CURRENT_LOCALE_DATA)[path]);
    // }

    // console.log(path, JSON.parse(CURRENT_LOCALE_DATA)[path])
    code = code.replace(/localize\(/g, `localize('${path}', `);
  }

  return code;
}

/**
 * 获取语言包
 * @param locale 语言
 * @param localeData
 * @returns
 */
function getLocalizeMapping(
  locale: Languages,
  localeData: Record<string, any> | undefined = undefined
) {
  if (localeData) return JSON.stringify(localeData);
  const locale_data_path = path.join(__dirname, `./locale/${locale}.json`);

  return fs.readFileSync(locale_data_path) as unknown as string;
}

/**
 * 替换代码
 * @param CURRENT_LOCALE_DATA 语言包
 * @returns
 */
function getLocalizeCode(CURRENT_LOCALE_DATA: string) {
  return `
    /*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/
// eslint-disable-next-line local/code-import-patterns
import { getNLSLanguage, getNLSMessages } from './nls.messages.js';
// eslint-disable-next-line local/code-import-patterns
export { getNLSLanguage, getNLSMessages } from './nls.messages.js';
const isPseudo = getNLSLanguage() === 'pseudo' || (typeof document !== 'undefined' && document.location && document.location.hash.indexOf('pseudo=true') >= 0);
function _format(message, args) {
    let result;
    if (args.length === 0) {
        result = message;
    }
    else {
        result = message.replace(/\\{(\\d+)\\}/g, (match, rest) => {
            const index = rest[0];
            const arg = args[index];
            let result = match;
            if (typeof arg === 'string') {
                result = arg;
            }
            else if (typeof arg === 'number' || typeof arg === 'boolean' || arg === void 0 || arg === null) {
                result = String(arg);
            }
            return result;
        });
    }
    if (isPseudo) {
        // FF3B and FF3D is the Unicode zenkaku representation for [ and ]
        result = '\uFF3B' + result.replace(/[aouei]/g, '$&$&') + '\uFF3D';
    }
    return result;
}
/**
 * @skipMangle
 */
// export function localize(data /* | number when built */, message /* | null when built */, ...args) {
//     if (typeof data === 'number') {
//         return _format(lookupMessage(data, message), args);
//     }
//     return _format(message, args);
// }
// ------------------------invoke----------------------------------------
    export function localize(path, data, defaultMessage, ...args) {
        if (typeof data === 'number') {
            return _format(lookupMessage(data, message), args);
        }
        var key = typeof data === 'object' ? data.key : data;
        var data = ${CURRENT_LOCALE_DATA} || {};
        var message = (data[path] || data?.contents?.[path] || {})[key];
        if (!message) {
            message = defaultMessage;
        }
        return _format(message, args);
    }
// ------------------------invoke----------------------------------------
/**
 * Only used when built: Looks up the message in the global NLS table.
 * This table is being made available as a global through bootstrapping
 * depending on the target context.
 */
function lookupMessage(index, fallback) {
    const message = getNLSMessages()?.[index];
    if (typeof message !== 'string') {
        if (typeof fallback === 'string') {
            return fallback;
        }
        throw new Error(\`!!! NLS MISSING: \${index} !!!\`);
    }
    return message;
}
/**
 * @skipMangle
 */
export function localize2(data /* | number when built */, originalMessage, ...args) {
    let message;
    if (typeof data === 'number') {
        message = lookupMessage(data, originalMessage);
    }
    else {
        message = originalMessage;
    }
    const value = _format(message, args);
    return {
        value,
        original: originalMessage === message ? value : _format(originalMessage, args)
    };
}
  
    `;
}

3、配置vite.config.ts

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import UnoCSS from 'unocss/vite';
import nlsPlugin, { Languages, esbuildPluginMonacoEditorNls } from './nls/index.ts';
import zh_hans from './nls/zh-hans.json';

// 注意只在生产环境下添加rollup插件,开发模式下会报错
const dev_plugins = [];
if (process.env.NODE_ENV !== 'development') {
  dev_plugins.push(
    nlsPlugin({
      locale: Languages.zh_hans,
      localeData: zh_hans,
    })
  );
}

// https://vite.dev/config/
export default defineConfig({
  optimizeDeps: {
    esbuildOptions: {
      plugins: [
        // 开发环境下通过esbuild插件进行汉化
        esbuildPluginMonacoEditorNls({
          locale: Languages.zh_hans,
          localeData: zh_hans,
        }),
      ],
    },
  },
  plugins: [vue(), UnoCSS(), ...dev_plugins],
  resolve: {
    alias: {
      '@': '/src',
    },
  },
});

如何开发vite插件并发布npm包

到现在已经实现monaco-editor汉化功能,但是如何将其作为vite插件发布呢? 在不具备开发插件的技能的情况下,借助通义灵码实现这个工程。

1、明确插件功能

当前的插件是用于 Monaco Editor 的国际化支持,通过 Vite 插件机制在开发和构建阶段替换 Monaco Editor 的 nls.js 文件并注入本地化语言包。

主要功能包括:

在 vite dev 模式下使用 esbuild 替换语言资源 在 vite build 模式下使用 rollup 注入语言逻辑 支持多种语言(如 zh-hans, en-gb 等)

2、项目结构

vite-plugin-monaco-editor-i18n/
├── src/
│   ├── index.ts      ← 主逻辑
│   └── locales/      ← 所有语言包
│       └── zh-hans.json
├── dist/             ← 构建输出目录
│   ├── index.js
│   └── index.d.ts    ← 类型定义文件
│   └── locales/      ← 所有语言包
│       └── zh-hans.json
├── package.json
├── tsconfig.json
└── README.md

3、配置 TypeScript 编译

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "lib": ["DOM", "ESNext"],
    "declaration": true,
    "declarationDir": "./dist",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

4、添加package.json

{
  "name": "vite-plugin-monaco-editor-i18n",
  "version": "1.0.3",
  "description": "为 Monaco Editor 提供多语言支持的 Vite 插件",
  "main": "dist/index.js",
  "module": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": [
    "dist/**/*"
  ],
  "scripts": {
    "build": "tsc --build && copyfiles -u 1 src/locales/**/* dist"
  },
  "keywords": [
    "monaco",
    "monaco-editor",
    "i18n",
    "vite-plugin",
    "language",
    "localization"
  ],
  "author": "important489",
  "license": "MIT",
  "peerDependencies": {
    "monaco-editor": "*",
    "vite": "^6.3.5"
  },
  "devDependencies": {
    "@types/node": "^24.0.10",
    "copyfiles": "^2.4.1",
    "magic-string": "^0.30.17",
    "typescript": "^5.0.0"
  }
}

5、添加入口文件 index.ts

写入上文的内容,需要根据需要解决部分报错。

6、添加语言包

下载语言包,并保存在 src/locales 目录下。

7、发布npm

  1. 创建npm账号
  2. 登录npm:npm login
  3. 发布npm:npm publish --access public

注意:登录npm时,需要切换回官方 npm registry,npm config set registry https://registry.npmjs.org/

8、确认结果并使用插件

pnpm add vite-plugin-monaco-editor-i18n

根据*.md文档内容配置vite.config.ts,并使用插件。

思考总结

  1. "files": ["dist/**/*"]通过这个配置可以只发布dist目录下的文件,
  2. 如何实现动态语言支持?

参考资料: 用Tauri开发一个EPUB编辑器(二) Monaco Editor的汉化、代码高亮、设置主题、代码补全

Three.js-硬要自学系列38之专项学习缓冲几何体

2025年7月7日 17:54

什么是缓冲几何体

假设我们要在电脑屏幕上画一个 3D 的立方体。这个立方体由很多个小的三角形面片组成(这是 WebGL 绘制的基础)。每个三角形的角叫做顶点。每个顶点至少需要知道:

  1. 位置信息:它在 3D 空间中的坐标 (x, y, z)。

  2. 朝向信息 :这个点所面对的方向(法向量),用于计算光照。

  3. 贴图坐标 :告诉电脑这个点对应到图片(纹理)上的哪个位置,以便给立方体贴上图案。

在three.js中使用BufferGeometry来构建缓冲几何体

它是高效描述 3D 形状的数据结构

它把顶点数据(位置、法线、UV等)按类型分离,各自打包成连续的大数组(缓冲区)。

我们需要通过一系列的练习来熟悉掌握缓冲几何体相关概念

案例 - 顶点构建三角形

通过设置一组顶点坐标,来构建一个三角形

image.png

const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
    -1, 0, 0,
    1, 0, 0,
    1, 1, 0
])
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({color: 'deepskyblue', side: THREE.DoubleSide}));
scene.add(mesh);

案例 - 设置法线

1.gif

const data_normal = [
    0, -0, 1,
    0, -0, 1,
    0, -0, 1
];
geometry.setAttribute('normal', new THREE.BufferAttribute( new Float32Array(data_normal), 3 ));

// 显示法线
const helper = new VertexNormalsHelper( mesh1, 0.5, 'yellow' );
scene.add( helper );

案例 - UV贴图

这个案例在之前的章节里有写过,这里就当是复习一下,下面是其核心代码

1.gif

const width = 4,
height = 4;
const size = width * height;
const data = new Uint8Array( 4 * size );
for (let i = 0; i < size; i++) {
    const stride = i * 4;
    const v = Math.floor(THREE.MathUtils.seededRandom() * 255);
    data[stride] = v-15;
    data[stride + 1] = v-40;
    data[stride + 2] = v-170;
    data[stride + 3] = 155;
}

const texture = new THREE.DataTexture(data, width, height);
texture.needsUpdate = true;
const mesh1 = new THREE.Mesh(
    geometry,
    new THREE.MeshStandardMaterial({
        map: texture,
        side: THREE.FrontSide
}));

案例 - groups组合

先看效果

2.gif

这个案例用到了缓冲几何体的分组功能,它可以让我们将一个几何体划分为多个部分,每个部分使用不同的材质渲染

const materials = [
    new THREE.MeshBasicMaterial({color: 'deepskyblue', side: THREE.DoubleSide}),
    new THREE.MeshBasicMaterial({color: 'deeppink', side: THREE.DoubleSide}),
]

const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
    0, 0, 0, // triangle 1
    1, 0, 0,
    1, 1, 0,
    0, 0, 0, // triangle 2
    0, 1, 0,
    1, 1, 0
])

geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
geometry.addGroup(0, 3, 1);
geometry.addGroup(3, 3, 0);
scene.add(new THREE.Mesh(geometry, materials));

代码中materials数组中定义了两种不同颜色的基础材质,用于后续分组渲染,vertices定义了6个顶点数据,需要注意的是分组规则中的addGroup,其参数分别指顶点数据开始索引,顶点数,以及材质索引

案例 - Index索引

这个案例主要是练习如何通过索引渲染,减少顶点数据的重复存储

3.gif

通过索引渲染,我们只需要存储4个顶点,6个索引值便可实现该效果

const geometry = new THREE.BufferGeometry();
const pos = new THREE.BufferAttribute(
    new Float32Array([
        0,-3, 0,  // 0
        0, 3, 0,  // 1
        -5, 0, 0,  // 2
        0, 0,-5   // 3 
    ]), 3
);
geometry.setAttribute('position', pos);
geometry.computeVertexNormals();  // 计算顶点法线
geometry.setIndex([0, 1, 2, 0, 1, 3]);  // 定义索引

const mesh = new THREE.Mesh(
    geometry, 
    new THREE.MeshNormalMaterial({ side: THREE.DoubleSide})
);
scene.add(mesh);

案例 - triangles三角

先看效果

4.gif

从图中可以看到,我们创建了两个相连的三角形,形成了一个具有立体感的几何体

const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
    0.00,  0.00,  0.00,
    1.00,  0.00,  0.00,
    0.50,  1.00, -0.50,
    1.00,  0.00,  0.00,
    0.75,  0.00, -1.00,
    0.50,  1.00, -0.50
])
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
geometry.computeVertexNormals();  // 计算顶点法线

const mesh = new THREE.Mesh(geometry, materials);
scene.add(mesh);

案例 - unIndexed贴图索引

这是一个对比案例,左侧蓝色三角形设置uv属性以及进行了法线贴图使得平面看起来更具立体感,右侧红色三角形被转换为非索引几何体与蓝色三角形产生区别

1.gif

const texture_normal = new THREE.DataTexture( new Uint8Array( data_normalmap ), 4,  4 );
texture_normal.needsUpdate = true;
const material_nm = new THREE.MeshPhongMaterial({
    color: 'deepskyblue',
    normalMap: texture_normal,
    side: THREE.FrontSide
});
const material = new THREE.MeshPhongMaterial({
    color: 'deeppink',
    side: THREE.FrontSide
});

const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
    0.00,  0.00,  0.00,
    2.00,  0.00,  0.00,
    0.00,  2.00,  0.00,
    0.00,  0.00, -3.50
]);
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
geometry.setIndex([0,1,2,1,3,2]);
geometry.computeVertexNormals();
const data_uv = new Float32Array([
    0.00,  1.00,
    0.00,  0.00,
    1.00,  0.00,
    1.00,  1.00,
]);
geometry.setAttribute('uv', new THREE.BufferAttribute(data_uv, 2));
geometry.computeTangents();  // 计算切线向量,用于光照计算。

const geometry_ni = geometry.toNonIndexed(); // 转换为非索引几何体,以便后续操作。
geometry_ni.computeVertexNormals();  // 计算顶点法线,用于光照计算。

案例 - rotation旋转

先看效果

2.gif

如图所示,左侧椎体是以几何体级别进行旋转,而右侧椎体则是以对象级别进行旋转的,两者的旋转方式如下

mesh1.geometry.copy( new THREE.ConeGeometry(0.25, 2, 32, 32) );
mesh1.geometry.rotateX( rx );
mesh1.geometry.rotateZ( rz );

mesh2.rotation.set(rx ,0, rz)

案例 - json转换

这个案例演示了Three.js中3D几何体的序列化和反序列化流程,通过创建THREE.BufferGeometryLoader实例,调佣parse方法将JSON对象解析为BufferGeometry实例, 这个案例对于需要保存、传输或动态生成3D模型数据的开发工作特别有用,感兴趣的童鞋可以尝试载入个模型转换看看

image.png

const geo = new THREE.SphereGeometry(1,8,8);

const buffObj = geo.toNonIndexed().toJSON(); // 转为非索引几何体
console.log(buffObj);

const text = JSON.stringify(buffObj);

const loader = new THREE.BufferGeometryLoader();
const obj = JSON.parse(text); // 解析json

const geo2 = loader.parse(obj);
const mesh = new THREE.Mesh(geo2)
scene.add(mesh);

案例 - 四元数

这里通过四元数来对几何体进行旋转,并以点云形式展现该几何体的顶点,效果如下

3.gif

const geometry = new THREE.CylinderGeometry(0, 2, 6, 32, 32);
const q = new THREE.Quaternion();  // 创建一个四元数
q.setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI / 4);  // 将四元数设置为绕Z轴旋转45度的四元数
geometry.applyQuaternion(q);  // 应用四元数到几何体上

const material = new THREE.PointsMaterial({ color: 'deepskyblue', size: 0.1 });  // 点的材质
const points = new THREE.Points(geometry, material);  // 创建点云
scene.add(points);  // 将点云添加到场景中

案例 - 中心点

这个案例主要使用到 center方法,该方法有以下几个功能

  1. 计算几何体的边界盒(bounding box)
  2. 计算几何体的中心点坐标
  3. 将几何体平移,使其中心点位于原点(0,0,0)

image.png

const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
    0,0,0,
    2,0,0,
    0,2,0,
    2,2,0
])
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
geometry.setIndex([0, 1, 2, 1, 3, 2 ]);  // 定义索引
geometry.computeVertexNormals();  // 计算顶点法线
geometry.center();  // 计算几何体的中心

const mesh = new THREE.Mesh(
    geometry,
    new THREE.MeshNormalMaterial({ side: THREE.DoubleSide})
);
scene.add(mesh);

案例 - 平移

练习一下几何体的平移 translate很简单,效果如下

5.gif

[ [0,1,0], [1,0,-1], [0,1,-4], ].forEach( (pos, i, arr) => {
    const geometry = geometry_source.clone().translate( pos[0], pos[1], pos[2]);
    const mesh = new THREE.Mesh(
        geometry,
        new THREE.MeshBasicMaterial({
            color: 'deepskyblue',
            side: THREE.FrontSide,
            transparent: true,
            opacity: 0.5
        })
    );
    mesh.renderOrder = arr.length - i;  // 控制渲染顺序,后添加的在前显示
    scene.add(mesh);
});

案例 - setfrompoints

这个案例主要是练习使用setfrompoints自动设置geometryposition属性

image.png

const points_array = [
    new THREE.Vector3( -1, -1,  1),
    new THREE.Vector3( -1, -1, -1),
    new THREE.Vector3(  1, -1,  1),
    new THREE.Vector3(  1, -1, -1),
    new THREE.Vector3( -1,  1,  1),
    new THREE.Vector3( -1,  1, -1),
    new THREE.Vector3(  1,  1,  1),
    new THREE.Vector3(  1,  1, -1),
];

const geometry = new THREE.BufferGeometry();
geometry.setFromPoints(points_array);
const material = new THREE.PointsMaterial( { color: 'deepskyblue', size: 0.15 } );
scene.add( new THREE.Points( geometry, material ) );

案例 - 克隆

练习克隆几何体,clone方法会创建当前几何体的完整副本,对于需要基于同一几何体创建多个不同变体的场景非常有用

6.gif

const geo_source = new THREE.ConeGeometry( 0.5, 1, 24, 24 );  // 圆锥体
const geo1 = geo_source.clone().rotateX(Math.PI / 180 * 45).translate(-2,0,0);  // 旋转45度,然后平移到(-2,0,0)
const geo2 = geo_source.clone();

const material = new THREE.MeshNormalMaterial();
const mesh1 = new THREE.Mesh(geo1, material);
const mesh2 = new THREE.Mesh(geo2, material);
scene.add(mesh1, mesh2);

案例 - builtinBox

这个案例在之前的章节里有介绍过了,实现方式也很简单,这里就贴代码看看就好了

image.png

const w=2, h=4,d=2;
const ws=8, hs=20, ds=32;
const geometry = new THREE.BoxGeometry(w, h, d, ws, hs, ds);  // 立方体

const material = new THREE.PointsMaterial({ size: 0.05, color: 'deepskyblue' });  // 点材质
const points = new THREE.Points(geometry, material);  // 点
scene.add(points);

案例 - 胶囊

该案例演示了如何创建一个胶囊几何体

image.png

const radius = 1;
const length = 2;
const capsubs = 8;
const radsegs = 16;
const geometry = new THREE.CapsuleGeometry(radius, length, capsubs, radsegs);  // 胶囊

const material = new THREE.MeshNormalMaterial({wireframe: true});   
const mesh = new THREE.Mesh(geometry, material);  // 网格
scene.add(mesh);

案例 - builtinEdges

这个案例使用到EdgesGeometry,它接收一个已有的几何体作为输入,提取该几何体的边缘线,根据角度阈值过滤边缘(只保留两个相邻面之间夹角大于指定阈值的边缘),最后生成一个质保函这些边缘线的新几何体

image.png

const geo_source = new THREE.BoxGeometry();
const threshold_angle = 1;
const geometry = new THREE.EdgesGeometry(geo_source, threshold_angle);  // 边

const material = new THREE.LineBasicMaterial({color: 'deeppink', linewidth: 5});  // 线
const line = new THREE.LineSegments(geometry, material);  // 线段
scene.add(line);

案例 - 挤出

先看效果

7.gif

该案例中将使用ExtrudeGeometry对2D形状进行挤出,从而实现如图效果, 该方法的配置参数说明如下

  • depth: 挤出的深度(默认为1)
  • bevelEnabled: 是否启用斜面(默认为true)
  • bevelThickness: 斜面厚度
  • bevelSize: 斜面大小
  • bevelSegments: 斜面的分段数
  • curveSegments: 曲线的分段数
  • steps: 沿挤出深度的分段数
const shape = new THREE.Shape();  // 定义形状
shape.moveTo( 2,-1 );
shape.bezierCurveTo( 0.45,-0.25,    0.25,0,    1,0 );
shape.lineTo(  1,1 );
shape.lineTo( -1,2 );
shape.bezierCurveTo(-2,0,   -2,-1,   0,-1 ); 

const geometry = new THREE.ExtrudeGeometry( shape );

const material = new THREE.MeshNormalMaterial();
const mesh = new THREE.Mesh( geometry, material );
scene.add( mesh );

案例 - 旋转成型

这个案例中用到了LatheGeometry,它一系列二维点(Vector2数组)作为输入,将这些点围绕Y轴旋转一周(或指定角度),生成一个旋转体(车削体)几何形状

8.gif

const v1 = new THREE.Vector2( 0, 0 );
const v2 = new THREE.Vector2( 0.5, 0 );
const v3 = new THREE.Vector2( 0.5, 0.5);
const v4 = new THREE.Vector2( 0.4, 0.5);
const v5 = new THREE.Vector2( 0.2, 0.1);
const v6 = new THREE.Vector2( 0, 0.1);
const vc1 = v2.clone().lerp(v3, 0.5).add( new THREE.Vector2(0.25,-0.1) );
const vc2 = v4.clone().lerp(v5, 0.5).add( new THREE.Vector2(0.25, 0) );
const curve = new THREE.CurvePath();
curve.add( new THREE.LineCurve( v1, v2 ) );
curve.add( new THREE.QuadraticBezierCurve(v2, vc1, v3) );
curve.add( new THREE.LineCurve( v3, v4 ) );
curve.add( new THREE.QuadraticBezierCurve( v4, vc2, v5 ) );
curve.add( new THREE.LineCurve( v5, v6 ) );
const v2array = curve.getPoints(20);

const segments_lathe = 80;
const phi_start = 0;
const phi_length = 2*Math.PI;
const geometry = new THREE.LatheGeometry( v2array, segments_lathe, phi_start, phi_length );

案例 - ring环

这个案例利用了three.js 内置的RingGeometry几何体创建一个圆环

9.gif

const radius1 = 1.5;
const radius2 = 1;
const segments = 80;
const geometry = new THREE.RingGeometry( radius1, radius2, segments );

const material = new THREE.LineBasicMaterial({ linewidth: 3, color: 'deepskyblue'});
const line = new THREE.LineSegments( geometry, material );
scene.add( line );

案例 - shape形状

这个案例使用了three.js内置的ShapeGeometry几何体实现

10.gif

const heartShape = new THREE.Shape();
heartShape.moveTo( 2.5, 2.5 );
heartShape.bezierCurveTo( 2.5, 2.5, 2.0, 0, 0, 0 );
heartShape.bezierCurveTo( - 3.0, 0, - 3.0, 3.5, - 3.0, 3.5 );
heartShape.bezierCurveTo( - 3.0, 5.5, - 1.0, 7.7, 2.5, 9.5 );
heartShape.bezierCurveTo( 6.0, 7.7, 8.0, 5.5, 8.0, 3.5 );
heartShape.bezierCurveTo( 8.0, 3.5, 8.0, 0, 5.0, 0 );
heartShape.bezierCurveTo( 3.5, 0, 2.5, 2.5, 2.5, 2.5 );

const geometry = new THREE.ShapeGeometry( heartShape );
geometry.rotateX(Math.PI);
geometry.rotateY(Math.PI);
geometry.scale(0.3, 0.3, 0.3);
geometry.center();

const material = new THREE.MeshNormalMaterial({ side: THREE.DoubleSide});
const mesh = new THREE.Mesh( geometry, material );
scene.add( mesh );

案例 - 环形体

与上面那个案例不同,这里用到了内置的TorusGeometry创建了一个环形体,而不是环面,效果中是以点云形式呈现

11.gif

const geometry = new THREE.TorusGeometry( 1, 0.3, 26, 80 );
const material = new THREE.PointsMaterial( { color: 'cyan', size: 0.05 } );
const points = new THREE.Points( geometry, material );
scene.add( points );

案例 - loadPromise

先看效果

12.gif

这个案例演示了如何处理多个模型加载的方案,首先我们创建一个能够加载多个BufferGeometry JSON

const loadBufferGeometryJSON = (urls = [], w = 2, scale = 5, material = new THREE.MeshNormalMaterial()) => {
    const onBuffLoad = (geometry, i) => {
        const x = i % w;
        const z = Math.floor(i / w);
        const mesh = new THREE.Mesh(geometry, material);
        mesh.name = `mesh_${i}`;
        mesh.position.set(x,0, z).multiplyScalar(scale);
        scene.add(mesh);
    }
    const onBuffProgress = (geometry) => {};
    return new Promise((resolve, reject) => {
        const manager = new THREE.LoadingManager();
        manager.onLoad = () => {
            resolve(scene);
        };
        const onBuffError = (error) => {
            reject(error);
        }
        const loader = new THREE.BufferGeometryLoader(manager);
        urls.forEach((url,index)=> {
            loader.load(url,(geometry)=> {
                onBuffLoad(geometry, index);
            }, onBuffProgress, onBuffError);
        })
    });
}

加载多个json格式的geometry

const URLS = [
    '/json/vertcolor-trees/6tri/0.json',
    '/json/vertcolor-trees/6tri/1.json',
    '/json/vertcolor-trees/6tri/2.json',
    '/json/vertcolor-trees/6tri/3.json',
    '/json/vertcolor-trees/6tri/4.json',
    '/json/vertcolor-trees/6tri/5.json'
];
const material = new THREE.MeshBasicMaterial({vertexColors: true, side: THREE.DoubleSide });
loadBufferGeometryJSON(URLS, 2, 4, material)
.then( (scene_source) => {
    console.log('JSON files are loaded!');
    scene.add( scene_source );
    renderer.render(scene, camera);
})
.catch( (e) => {
    console.warn('No Good.');
    console.warn(e);
});  

案例 - morphattributes

先看效果

13.gif

这里用到了morphTargetInfluences,它用于在不同的几何形状之间实现平滑过渡,常用于角色动画、面部表情或物体变形等效果

const geo = new THREE.BoxGeometry(2, 2, 2, 32, 32, 32);
geo.morphAttributes.position = []; 
const pos = geo.attributes.position;
const data_pos = [];
for ( let i = 0; i < pos.count; i ++ ) {
    const x = pos.getX( i );
    const y = pos.getY( i );
    const z = pos.getZ( i );
    data_pos.push(
        x * Math.sqrt( 1 - ( y * y / 2 ) - ( z * z / 2 ) + ( y * y * z * z / 3 ) ),
        y * Math.sqrt( 1 - ( z * z / 2 ) - ( x * x / 2 ) + ( z * z * x * x / 3 ) ),
        z * Math.sqrt( 1 - ( x * x / 2 ) - ( y * y / 2 ) + ( x * x * y * y / 3 ) )
    );
}
geo.morphAttributes.position[ 0 ] = new THREE.Float32BufferAttribute( data_pos, 3 );

const material = new THREE.MeshNormalMaterial({ side: THREE.DoubleSide });
const mesh = new THREE.Mesh(geo, material);
scene.add(mesh);

orbitControls.update();

let count = 0.01;
function animation() {
    // 渲染场景和相机
    renderer.render( scene, camera );
    requestAnimationFrame( animation );
    count += 0.01;
    if ( count > 10 ) count = 0;
    mesh.morphTargetInfluences[ 0 ] = ( Math.cos( count * Math.PI ) + 1 ) / 0.5;
}
animation();

大文件分片上传和断点续传

2025年7月7日 17:53

一、功能拆解

(一)前端(web-admin)

  • 产品添加界面支持选择大图片文件,实现分片上传断点续传
  • 实时展示上传进度,上传完成后图片可正常回显

(二)后端(web-server)

  • 提供分片上传接口,支持接收分片合并分片校验已上传分片
  • 实现断点续传(前端可查询已上传分片,避免重复上传) 。
  • 图片合并后,支持访问与回显

二、详细实现步骤

(一)前端实现

  1. 切片处理:选择图片后,按固定大小(如 2MB)对文件进行切片拆分。
  2. 断点续传校验:上传前请求后端,获取已上传分片列表,跳过重复分片。
  3. 分片上传:逐个上传分片,支持失败重传(捕获上传异常,重试未成功分片 )。
  4. 合并通知:所有分片上传完成后,调用后端接口触发分片合并
  5. 回显处理:合并成功后,获取图片 URL 并渲染回显。

(二)后端实现

  1. 分片上传接口(POST /upload/chunk:接收分片文件,按规则(hash_index)保存到临时目录(如 public/tmpuploads/ )。
  2. 已上传分片查询接口(GET /upload/chunk?hash=xxx:根据文件 hash,返回已存在的分片 index 列表
  3. 分片合并接口(POST /upload/merge:校验所有分片完整性,按序合并为完整图片,保存到最终目录(如 public/productuploads/ ),返回可访问的图片 URL 。
  4. 静态资源访问:通过 GET /upload/file/:filename 提供合并后图片的访问能力。

三、接口设计建议

接口类型 路径 功能说明 关键参数/返回值
POST /upload/chunk 上传单个分片 参数:hash(文件标识)、index(分片序号)、file(分片内容);返回上传状态
GET /upload/chunk?hash=xxx 查询已上传分片 参数:hash(文件标识);返回已上传 index 列表
POST /upload/merge 合并分片生成完整图片 参数:hash(文件标识)、totalChunks(总分片数);返回图片访问 URL
GET /upload/file/:filename 访问合并后的图片 参数:filename(图片文件名);返回图片文件内容

四、前端分片上传核心流程

  1. 文件标识计算:使用 spark-md5 等库计算文件 hash,作为文件唯一标识。
  2. 切片与上传准备:按固定大小切片,记录每个分片的 index
  3. 断点续传校验:调用 GET /upload/chunk?hash=xxx,获取已上传分片 index,跳过重复分片。
  4. 分片上传循环:遍历未上传分片,调用 POST /upload/chunk 上传,实时更新进度。
  5. 合并触发:所有分片上传完成后,调用 POST /upload/merge 合并分片。
  6. 回显处理:拿到合并后的图片 URL,渲染到页面完成回显。

五、后端分片处理核心流程

  1. 分片存储:收到分片后,按 hash_index 规则(如 public/tmpuploads/${hash}_${index} )保存到临时目录。
  2. 查询逻辑:根据 hash 遍历临时目录,收集已存在的 index 并返回。
  3. 合并逻辑
    • 校验所有分片是否完整(totalChunks 与实际分片数匹配 )。
    • index 顺序读取分片内容,写入最终文件(如 public/productuploads/${hash}.png )。
    • 返回合并后文件的访问 URL(如 /upload/file/${hash}.png )。
  4. 清理逻辑(可选):合并完成后,删除临时分片文件,释放存储。

通过以上方案,可实现大图片的高效分片上传、断点续传及回显,适配前后端协作流程,保障上传稳定性与用户体验。

JavaScript 中你不知道的按位运算

2025年7月7日 17:47

JavaScript 中你不知道的按位运算

个人主页:康师傅前端面馆


位运算直接操作二进制位,适用于整数(JS 中数字会隐式转换为 32 位有符号整数)。以下是核心运算符详解:

一、基础运算符

  1. 按位与 &

    • 规则:同位置均为 1 时结果为 1
    • 示例:权限校验
      const READ = 0b001; // 1
      const WRITE = 0b010; // 2
      const EXECUTE = 0b100; // 4
      
      let userPermissions = READ | WRITE; // 0b011 (3)
      console.log(userPermissions & READ); // 1 (true)
      console.log(userPermissions & EXECUTE); // 0 (false)
      
  2. 按位或 |

    • 规则:同位置任一为 1 则结果为 1
    • 示例:组合权限
      let permissions = READ | EXECUTE; // 0b101 (5)
      
  3. 按位异或 ^

    • 规则:同位置不同则为 1
    • 示例:交换变量(无临时变量)
      let a = 5; // 0b101
      let b = 3; // 0b011
      a = a ^ b; // 0b110 (6)
      b = a ^ b; // 0b101 (5)
      a = a ^ b; // 0b011 (3)
      
  4. 按位非 ~

    • 规则:所有位取反(相当于 -x - 1
    • 示例:快速取整
      console.log(~~3.7); // 3 (等价于 Math.floor)
      

二、位移运算符

  1. 左移 <<

    • 规则:向左移动指定位数,低位补 0
    • 示例:快速计算 2n2^n
      const power = 1 << 3; // 8 (2^3)
      
  2. 有符号右移 >>

    • 规则:向右移动,保留符号位
    • 示例:负数处理
      console.log(-8 >> 2); // -2 (符号位不变)
      
  3. 无符号右移 >>>

    • 规则:向右移动,高位补 0(负数会变正)
    • 示例:提取 RGB 颜色值
      const color = 0xFFA07A; // LightSalmon
      const red = (color >>> 16) & 0xFF; // 255
      const green = (color >>> 8) & 0xFF; // 160
      

三、应用场景

1、权限控制系统

传统方案

const permissions = {
  read: true,
  write: false,
  execute: true
};
if (permissions.read) { ... } // 对象属性查找

位运算方案

const READ = 1, WRITE = 2, EXECUTE = 4;
const userPerm = READ | EXECUTE;
if (userPerm & READ) { ... } // 位运算检查

优势对比

维度 传统方案 位运算方案
内存 每个权限占独立内存 单整数存储所有权限
性能 属性查找(O(n)) 位操作(O(1))
序列化 需完整对象结构 单数字即可传输/存储
扩展性 新增权限需修改结构 新增权限只需定义新位

2、多状态标志存储

传统方案(数组/对象):

const flags = [true, false, true, false]; // 存储4个状态
if (flags[0]) { ... } // 数组索引访问

位运算方案

const FLAG_A = 1, FLAG_B = 2, FLAG_C = 4;
let state = FLAG_A | FLAG_C; // 存储多个状态
if (state & FLAG_A) { ... } // 位检查

优势对比

维度 传统方案 位运算方案
内存 每个状态占4-8字节 所有状态共享4字节
操作 需循环遍历 单次位操作完成
GC压力 产生多个对象/数组 无额外内存分配

3、高性能数值计算

传统数学运算

// 浮点数运算(有精度损失风险)
const half = value / 2; 
const doubled = value * 2;

位运算优化

// 整数运算(无精度问题)
const half = value >> 1;   // 右移1位 = ÷2
const doubled = value << 1; // 左移1位 = ×2

性能测试对比(Chrome V8 100万次操作):

// 乘法:2.8ms 
for(let i=0; i<1e6; i++) a = i * 2;  

// 位运算:0.9ms (快3倍)
for(let i=0; i<1e6; i++) a = i << 1;  

4、颜色值操作

传统方案(字符串操作):

const color = "#FFA07A";
const red = parseInt(color.substring(1,3), 16); // 字符串截取+转换

位运算方案

const color = 0xFFA07A;
const red = (color >>> 16) & 0xFF; // 无符号右移+掩码

优势对比

维度 传统方案 位运算方案
性能 字符串解析开销大 直接内存操作
可读性 直观但冗长 简洁但需理解位操作
类型安全 需处理非法字符串 天然数值类型保证

四、注意事项

  • 整数范围:运算结果在 [-2^31, 2^31-1] 之间,超出会截断。
  • 可读性:复杂位运算需添加注释说明逻辑。
  • 隐式转换:非整数(如浮点数)会先转成 32 位整数再运算。

示例完整代码:GitHub Gist 链接

❌
❌