阅读视图

发现新文章,点击刷新页面。

[Python3/Java/C++/Go/TypeScript] 一题一解:贪心(清晰题解)

方法一:贪心

我们不妨假设子字符串 "ab" 的得分总是不低于子字符串 "ba" 的得分,如果不是,我们可以交换 "a" 和 "b",同时交换 $x$ 和 $y$。

接下来,我们只需要考虑字符串中只包含 "a" 和 "b" 的情况。如果字符串中包含其他字符,我们可以将其视为一个分割点,将字符串分割成若干个只包含 "a" 和 "b" 的子字符串,然后分别计算每个子字符串的得分。

我们观察发现,对于一个只包含 "a" 和 "b" 的子字符串,无论采取什么样的操作,最后一定只剩下一种字符,或者空串。由于每次操作都会同时删除一个 "a" 和一个 "b",因此总的操作次数一定是固定的。我们可以贪心地先删除 "ab",再删除 "ba",这样可以保证得分最大。

因此,我们可以使用两个变量 $\textit{cnt1}$ 和 $\textit{cnt2}$ 分别记录 "a" 和 "b" 的数量,然后遍历字符串,根据当前字符的不同情况更新 $\textit{cnt1}$ 和 $\textit{cnt2}$,并计算得分。

对于当前遍历到的字符 $c$:

  • 如果 $c$ 是 "a",由于要先删除 "ab",因此此时我们不消除该字符,只增加 $\textit{cnt1}$;
  • 如果 $c$ 是 "b",如果此时 $\textit{cnt1} > 0$,我们可以消除一个 "ab",并增加 $x$ 分,否则我们只能增加 $\textit{cnt2}$;
  • 如果 $c$ 是其他字符,那么对于该子字符串,我们剩下了一个 $\textit{cnt2}$ 个 "b" 和 $\textit{cnt1}$ 个 "a",我们可以消除 $\min(\textit{cnt1}, \textit{cnt2})$ 个 "ab",并增加 $y$ 分。

遍历结束后,我们还需要额外处理一下剩余的 "ab",增加若干个 $y$ 分。

###python

class Solution:
    def maximumGain(self, s: str, x: int, y: int) -> int:
        a, b = "a", "b"
        if x < y:
            x, y = y, x
            a, b = b, a
        ans = cnt1 = cnt2 = 0
        for c in s:
            if c == a:
                cnt1 += 1
            elif c == b:
                if cnt1:
                    ans += x
                    cnt1 -= 1
                else:
                    cnt2 += 1
            else:
                ans += min(cnt1, cnt2) * y
                cnt1 = cnt2 = 0
        ans += min(cnt1, cnt2) * y
        return ans

###java

class Solution {
    public int maximumGain(String s, int x, int y) {
        char a = 'a', b = 'b';
        if (x < y) {
            int t = x;
            x = y;
            y = t;
            char c = a;
            a = b;
            b = c;
        }
        int ans = 0, cnt1 = 0, cnt2 = 0;
        int n = s.length();
        for (int i = 0; i < n; ++i) {
            char c = s.charAt(i);
            if (c == a) {
                cnt1++;
            } else if (c == b) {
                if (cnt1 > 0) {
                    ans += x;
                    cnt1--;
                } else {
                    cnt2++;
                }
            } else {
                ans += Math.min(cnt1, cnt2) * y;
                cnt1 = 0;
                cnt2 = 0;
            }
        }
        ans += Math.min(cnt1, cnt2) * y;
        return ans;
    }
}

###cpp

class Solution {
public:
    int maximumGain(string s, int x, int y) {
        char a = 'a', b = 'b';
        if (x < y) {
            swap(x, y);
            swap(a, b);
        }

        int ans = 0, cnt1 = 0, cnt2 = 0;
        for (char c : s) {
            if (c == a) {
                cnt1++;
            } else if (c == b) {
                if (cnt1) {
                    ans += x;
                    cnt1--;
                } else {
                    cnt2++;
                }
            } else {
                ans += min(cnt1, cnt2) * y;
                cnt1 = 0;
                cnt2 = 0;
            }
        }
        ans += min(cnt1, cnt2) * y;
        return ans;
    }
};

###go

func maximumGain(s string, x int, y int) (ans int) {
a, b := 'a', 'b'
if x < y {
x, y = y, x
a, b = b, a
}

var cnt1, cnt2 int
for _, c := range s {
if c == a {
cnt1++
} else if c == b {
if cnt1 > 0 {
ans += x
cnt1--
} else {
cnt2++
}
} else {
ans += min(cnt1, cnt2) * y
cnt1, cnt2 = 0, 0
}
}
ans += min(cnt1, cnt2) * y
return
}

###ts

function maximumGain(s: string, x: number, y: number): number {
    let [a, b] = ['a', 'b'];
    if (x < y) {
        [x, y] = [y, x];
        [a, b] = [b, a];
    }

    let [ans, cnt1, cnt2] = [0, 0, 0];
    for (let c of s) {
        if (c === a) {
            cnt1++;
        } else if (c === b) {
            if (cnt1) {
                ans += x;
                cnt1--;
            } else {
                cnt2++;
            }
        } else {
            ans += Math.min(cnt1, cnt2) * y;
            cnt1 = 0;
            cnt2 = 0;
        }
    }
    ans += Math.min(cnt1, cnt2) * y;
    return ans;
}

###rust

impl Solution {
    pub fn maximum_gain(s: String, mut x: i32, mut y: i32) -> i32 {
        let (mut a, mut b) = ('a', 'b');
        if x < y {
            std::mem::swap(&mut x, &mut y);
            std::mem::swap(&mut a, &mut b);
        }

        let mut ans = 0;
        let mut cnt1 = 0;
        let mut cnt2 = 0;

        for c in s.chars() {
            if c == a {
                cnt1 += 1;
            } else if c == b {
                if cnt1 > 0 {
                    ans += x;
                    cnt1 -= 1;
                } else {
                    cnt2 += 1;
                }
            } else {
                ans += cnt1.min(cnt2) * y;
                cnt1 = 0;
                cnt2 = 0;
            }
        }

        ans += cnt1.min(cnt2) * y;
        ans
    }
}

###js

function maximumGain(s, x, y) {
    let [a, b] = ['a', 'b'];
    if (x < y) {
        [x, y] = [y, x];
        [a, b] = [b, a];
    }

    let [ans, cnt1, cnt2] = [0, 0, 0];
    for (let c of s) {
        if (c === a) {
            cnt1++;
        } else if (c === b) {
            if (cnt1) {
                ans += x;
                cnt1--;
            } else {
                cnt2++;
            }
        } else {
            ans += Math.min(cnt1, cnt2) * y;
            cnt1 = 0;
            cnt2 = 0;
        }
    }
    ans += Math.min(cnt1, cnt2) * y;
    return ans;
}

###cs

public class Solution {
    public int MaximumGain(string s, int x, int y) {
        char a = 'a', b = 'b';
        if (x < y) {
            (x, y) = (y, x);
            (a, b) = (b, a);
        }

        int ans = 0, cnt1 = 0, cnt2 = 0;
        foreach (char c in s) {
            if (c == a) {
                cnt1++;
            } else if (c == b) {
                if (cnt1 > 0) {
                    ans += x;
                    cnt1--;
                } else {
                    cnt2++;
                }
            } else {
                ans += Math.Min(cnt1, cnt2) * y;
                cnt1 = 0;
                cnt2 = 0;
            }
        }

        ans += Math.Min(cnt1, cnt2) * y;
        return ans;
    }
}

时间复杂度 $O(n)$,其中 $n$ 为字符串 $s$ 的长度。空间复杂度 $O(1)$。


有任何问题,欢迎评论区交流,欢迎评论区提供其它解题思路(代码),也可以点个赞支持一下作者哈😄~

每日一题-删除子字符串的最大得分🟡

给你一个字符串 s 和两个整数 x 和 y 。你可以执行下面两种操作任意次。

  • 删除子字符串 "ab" 并得到 x 分。
    • 比方说,从 "cabxbae" 删除 ab ,得到 "cxbae" 。
  • 删除子字符串"ba" 并得到 y 分。
    • 比方说,从 "cabxbae" 删除 ba ,得到 "cabxe" 。

请返回对 s 字符串执行上面操作若干次能得到的最大得分。

 

示例 1:

输入:s = "cdbcbbaaabab", x = 4, y = 5
输出:19
解释:
- 删除 "cdbcbbaaabab" 中加粗的 "ba" ,得到 s = "cdbcbbaaab" ,加 5 分。
- 删除 "cdbcbbaaab" 中加粗的 "ab" ,得到 s = "cdbcbbaa" ,加 4 分。
- 删除 "cdbcbbaa" 中加粗的 "ba" ,得到 s = "cdbcba" ,加 5 分。
- 删除 "cdbcba" 中加粗的 "ba" ,得到 s = "cdbc" ,加 5 分。
总得分为 5 + 4 + 5 + 5 = 19 。

示例 2:

输入:s = "aabbaaxybbaabb", x = 5, y = 4
输出:20

 

提示:

  • 1 <= s.length <= 105
  • 1 <= x, y <= 104
  • s 只包含小写英文字母。

【无需额外空间】贪心算法

方法一:贪心

首先,不妨假设 $"ab"$ 的得分总是不低于 $"ba"$;否则,我们将字符串中的字符 $a$ 换成 $b$,$b$ 换成 $a$,再交换 $x$ 和 $y$ 即可。

随后,我们也只需考虑字符串中只包含 $a,b$ 的情形。如果字符串中含有其他的字符,就以该字符为分隔,分别考虑左右两个字符串即可。

注意到:对于一个只包含 $a,b$ 的字符串而言,无论采取怎样的方案进行消除,最后一定只剩下一种字符(或者为空字符串);而由于每次消除操作都同等地将 $a,b$ 的出现次数减 $1$,因此总的消除操作数量也是固定的。既然消除操作的数量是固定值,那么最优的策略一定是:尽可能地多消除 $ab$。

因此,我们维护两个计数器 $c_a, c_b$,分别代表着 $a,b$ 两种字符剩余的数目

  • 如果当前字符为 $a$,由于贪心策略要求多消除 $ab$,因此此时不消除该字符 $a$,而是将 $c_a$ 递增
  • 如果当前字符为 $b$,
    • 如果 $c_a > 0$,说明此前有剩余的字符 $a$,因此我们利用这个 $a$ 消除当前的 $b$,于是将 $c_a$ 递减,并记录一次得分 $x$
    • 如果 $c_a = 0$,说明没有剩余的字符 $a$ 了,此时我们无法将这个 $b$ 消除掉,于是将 $c_b$ 递增。

最后,我们留下了 $c_a$ 个字符 $a$,$c_b$ 个字符 $b$。此时我们终于可以消除 $ba$ 了,消除的次数为 $\min{c_a, c_b}$,故记录得分 $y\cdot min{c_a, c_b}$.

class Solution {
public:
    int maximumGain(string s, int x, int y) {
        int n = s.length();
        if (x < y) {
            swap(x, y);
            for (int i = 0; i < n; i++) {
                if (s[i] == 'a') s[i] = 'b';
                else if (s[i] == 'b') s[i] = 'a';
            }
        }

        int ret = 0;
        int i = 0;
        while (i < n) {
            while (i < n && s[i] != 'a' && s[i] != 'b') i++;
            
            int ca = 0, cb = 0;
            while (i < n && (s[i] == 'a' || s[i] == 'b')) {
                if (s[i] == 'a') {
                    ca++;
                } else {
                    if (ca > 0) {
                        ca--;
                        ret += x;
                    } else {
                        cb++;
                    }
                }
                i++;
            }
            
            ret += min(ca, cb) * y;
        }

        return ret;
    }
};

C++ 贪心 超多细节,手把手讲明白噢!

题目2:5634. 删除子字符串的最大得分

思路:贪心

  • 贪心算法的正确性

    我们不妨假设 $x <= y$,由于本题只涉及到 $a, b$ 两个字符,且不论是删除 $ab$ 还是 $ba$,两个字符都是同等数量的减少,不会凭空产生,所以一定不会出现优先删除了一个 $ba$,导致无法删除多个 $ab$ ,从而失去了获得更高分数的可能性。故利用贪心算法优先删除得分较高的子字符串是正确的。

  • 问题处理的优化

    对于如下的两个字符串互为逆序的示例,示例 $1$ 将先删除 $ab$ ,再删除 $ba$ 得到 $15$ 分,而示例 $2$ 将先删除 $ba$ ,再删除 $ab$ 得到 $15$ 分,我们发现,字符串逆序后,将对应的得分也进行互换,最终的最大得分是相同的,所以,我们可以将 $x > y$ 的情况经过预处理转化为 $x <= y$ 的情况。

###cpp

示例1:s = "abcdba", x = 10, y = 5
示例2:s = "abdcba", x = 5, y = 10
  • 具体实现的细节

    在用栈结构优先处理完所有 $ba$ 子字符串后,要第二次处理栈中剩余的 $ab$ 子字符串。由于栈具有“后入先出”的特点,该栈中 $ab$ 子字符串弹出元素的顺序实际上为 $ba$,所以在代码实现中,利用了第二个栈 $t$ 作为载体,基本复制第一次处理的代码即可。

代码:

###c++

class Solution {
public:
    int maximumGain(string a, int x, int y) {
        stack<char> s, t;
        int ret = 0;
        // 处理优化
        if(x > y) {
            swap(x, y);
            reverse(a.begin(), a.end());
        } 
        // 先处理 ba
        for(char c : a) {
            if(c != 'a') s.push(c);
            else {
                // 形成 ba 子字符串
                if(!s.empty() && s.top() == 'b') {
                    s.pop();
                    ret += y;
                } else {
                    s.push(c);
                }
            }
        }
        // 再处理 ab
        while(!s.empty()) {
            char c = s.top();
            s.pop();
            if(c != 'a') t.push(c);
            else {
                if(!t.empty() && t.top() == 'b') {
                    t.pop();
                    ret += x;
                } else {
                    t.push(c);
                }
            }
        }
        return ret;
    }
};

复杂度分析:

  • 时间复杂度为 $O(n)$,相当于遍历了两次字符串。
  • 空间复杂度为 $O(n)$,利用了两个辅助栈。

关注GTAlgorithm,专注周赛、面经题解分享,陪大家一起攻克算法难关~

层序遍历?套模板就够了

LeetCode 第 222 场周赛 题解

p5.js 圆弧的用法

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

在 p5.js 中,arc() 函数用于绘制圆弧,它是创建各种圆形图形和动画的基础。圆弧本质上是椭圆的一部分,由中心点、宽度、高度、起始角度和结束角度等参数定义。通过灵活运用 arc() 函数可以轻松创建饼图、仪表盘、时钟等常见 UI 组件,以及各种创意图形效果。

arc() 的基础语法

基础语法

arc() 函数的完整语法如下:

arc(x, y, w, h, start, stop, [mode], [detail])

核心参数解释:

  • x, y:圆弧所在椭圆的中心点坐标

  • w, h:椭圆的宽度和高度,如果两者相等,则绘制的是圆形的一部分

  • start, stop:圆弧的起始角度和结束角度,默认以弧度(radians)为单位

可选参数:

  • mode:定义圆弧的填充样式,可选值为OPEN(开放式半圆)、CHORD(封闭式半圆)或PIE(闭合饼图)

  • detail:仅在 WebGL 模式下使用,指定组成圆弧周长的顶点数量,默认值为 25

角度单位与转换

在 p5.js 中,角度可以使用弧度或角度两种单位表示:

  • 默认单位是弧度:0 弧度指向正右方(3 点钟方向),正角度按顺时针方向增加

  • 使用角度单位:可以通过 angleMode(DEGREES) 函数将角度单位设置为角度

两种单位之间的转换关系:

  • 360 度 = 2π 弧度

  • 180 度 = π 弧度

  • 90 度 = π/2 弧度

p5.js 提供了两个辅助函数用于单位转换:

  • radians(degrees):将角度转换为弧度

  • degrees(radians):将弧度转换为角度

举个例子(基础示例)

举个例子讲解一下如何使用 arc() 函数绘制不同角度的圆弧。

01.png

function setup() {
  createCanvas(400, 400);
  angleMode(DEGREES); // 使用角度单位
}

function draw() {
  background(220);
  
  // 绘制不同角度的圆弧
  arc(100, 100, 100, 100, 0, 90); // 90度圆弧
  arc(250, 100, 100, 100, 0, 180); // 180度圆弧
  arc(100, 250, 100, 100, 0, 270); // 270度圆弧
  arc(250, 250, 100, 100, 0, 360); // 360度圆弧(整圆)
}

这段代码会在画布上绘制四个不同角度的圆弧,从 90 度到 360 度不等。注意,当角度为 360 度时,实际上绘制的是一个完整的圆形。

三种圆弧模式:OPEN、CHORD 与 PIE

arc() 函数的第七个参数mode决定了圆弧的填充方式,有三种可选值:

  • OPEN(默认值):仅绘制圆弧本身,不填充任何区域

  • CHORD:绘制圆弧并连接两端点形成闭合的半圆形区域

  • PIE:绘制圆弧并连接两端点与中心点形成闭合的扇形区域

这三种模式不需要手动定义,p5.js 已经在全局范围内定义好了这些常量。

举个例子:

02.png

function setup() {
  createCanvas(400, 200);
  angleMode(DEGREES);
}

function draw() {
  background(220);
  
  // 绘制不同模式的圆弧
  arc(100, 100, 100, 100, 0, 270, OPEN);
  arc(220, 100, 100, 100, 0, 270, CHORD);
  arc(340, 100, 100, 100, 0, 270, PIE);
}

这段代码会在画布上绘制三个 270 度的圆弧,分别展示 OPENCHORDPIE 三种模式的效果。可以明显看到,OPEN 模式只绘制弧线,CHORD 模式连接两端点形成闭合区域,而 PIE 模式则从两端点连接到中心点形成扇形。

如何选择合适的模式

选择圆弧模式时,应考虑以下因素:

  • 视觉效果需求:需要纯弧线效果时选择 OPEN,需要闭合区域时选择 CHORDPIE

  • 应用场景:饼图通常使用 PIE 模式,仪表盘可能使用 CHORD 模式,而简单装饰线条可能使用 OPEN 模式

  • 填充与描边需求:不同模式对填充和描边的处理方式不同,需要根据设计需求选择

值得注意的是,arc() 函数绘制的默认是填充的扇形区域。如果想要获取纯圆弧(没有填充区域),可以使用 noFill() 函数拒绝 arc() 函数的填充。

做几个小demo玩玩

简易数字时钟

在这个示例中,我将使用 arc() 函数创建一个简单的数字时钟,显示当前的小时、分钟和秒数。

03.png

let hours, minutes, seconds;

function setup() {
  createCanvas(400, 400);
  angleMode(DEGREES); // 使用角度单位
}

function draw() {
  background(220);
  
  // 获取当前时间
  let now = new Date();
  hours = now.getHours();
  minutes = now.getMinutes();
  seconds = now.getSeconds();

  // 绘制时钟边框
  stroke(0);
  strokeWeight(2);
  noFill();
  arc(width/2, height/2, 300, 300, 0, 360);

  // 绘制小时刻度
  strokeWeight(2);
  for (let i = 0; i < 12; i++) {
    let angle = 90 - i * 30;
    let x1 = width/2 + 140 * cos(radians(angle));
    let y1 = height/2 + 140 * sin(radians(angle));
    let x2 = width/2 + 160 * cos(radians(angle));
    let y2 = height/2 + 160 * sin(radians(angle));
    line(x1, y1, x2, y2);
  }

  // 绘制分钟刻度
  strokeWeight(1);
  for (let i = 0; i < 60; i++) {
    let angle = 90 - i * 6;
    let x1 = width/2 + 150 * cos(radians(angle));
    let y1 = height/2 + 150 * sin(radians(angle));
    let x2 = width/2 + 160 * cos(radians(angle));
    let y2 = height/2 + 160 * sin(radians(angle));
    line(x1, y1, x2, y2);
  }

  // 绘制小时指针
  let hourAngle = 90 - (hours % 12) * 30 - minutes * 0.5;
  let hourLength = 80;
  let hx = width/2 + hourLength * cos(radians(hourAngle));
  let hy = height/2 + hourLength * sin(radians(hourAngle));
  line(width/2, height/2, hx, hy);

  // 绘制分钟指针
  let minuteAngle = 90 - minutes * 6;
  let minuteLength = 120;
  let mx = width/2 + minuteLength * cos(radians(minuteAngle));
  let my = height/2 + minuteLength * sin(radians(minuteAngle));
  line(width/2, height/2, mx, my);

  // 绘制秒针
  stroke(255, 0, 0);
  let secondAngle = 90 - seconds * 6;
  let secondLength = 140;
  let sx = width/2 + secondLength * cos(radians(secondAngle));
  let sy = height/2 + secondLength * sin(radians(secondAngle));
  line(width/2, height/2, sx, sy);
  
  // 显示当前时间文本
  noStroke();
  fill(0);
  textSize(24);
  text(hours + ":" + nf(minutes, 2, 0) + ":" + nf(seconds, 2, 0), 50, 50);
}

关键点解析:

  1. 获取当前时间:使用Date()对象获取当前的小时、分钟和秒数

  2. 角度计算:根据时间值计算指针的旋转角度,注意将角度转换为 p5.js 使用的坐标系(0 度指向正上方)

  3. 刻度绘制:使用循环绘制小时和分钟刻度,每个小时刻度间隔 30 度,每个分钟刻度间隔 6 度

  4. 指针绘制:根据计算的角度和长度绘制小时、分钟和秒针,注意秒针使用红色以区分

  5. 时间文本显示:使用text()函数在画布左上角显示当前时间

饼图

在这个示例中,我将创建一个简单的饼图,展示不同类别数据的比例。

04.png

let data = [30, 10, 45, 35, 60, 38, 75, 67]; // 示例数据
let total = 0;
let lastAngle = 0;

function setup() {
  createCanvas(720, 400);
  angleMode(DEGREES); // 使用角度单位
  noStroke(); // 不绘制边框
  total = data.reduce((a, b) => a + b, 0); // 计算数据总和
}

function draw() {
  background(100);
  pieChart(300, data); // 调用饼图绘制函数
}

function pieChart(diameter, data) {
  lastAngle = 0; // 重置起始角度
  for (let i = 0; i < data.length; i++) {
    // 设置圆弧的灰度值,map函数将数据映射到0-255的灰度范围
    let gray = map(i, 0, data.length, 0, 255);
    fill(gray);
    
    // 计算当前数据点的角度范围
    let startAngle = lastAngle;
    let endAngle = lastAngle + (data[i] / total) * 360;
    
    // 绘制圆弧
    arc(
      width / 2,
      height / 2,
      diameter,
      diameter,
      startAngle,
      endAngle,
      PIE // 使用PIE模式创建扇形
    );
    
    lastAngle = endAngle; // 更新起始角度为下一个数据点做准备
  }
}

关键点解析:

  1. 数据准备:定义示例数据数组data,并计算数据总和total

  2. 颜色设置:使用map()函数将数据索引映射到 0-255 的灰度范围,实现渐变效果

  3. 角度计算:根据每个数据点的值与总和的比例计算对应的角度范围

  4. 圆弧绘制:使用PIE模式绘制每个数据点对应的扇形,形成完整的饼图

这个饼图示例可以通过添加标签、交互效果或动态数据更新来进一步增强功能。

描边效果

在 p5.js 中,我们可以通过以下函数定制圆弧的描边效果:

  • stroke(color):设置描边颜色

  • strokeWeight(weight):设置描边宽度

  • strokeCap(cap):设置描边端点样式(可选值:BUTT, ROUND, SQUARE)

  • strokeJoin(join):设置描边转角样式(可选值:MITER, ROUND, BEVEL)

以下示例展示了如何定制圆弧的描边效果:

05.png

function setup() {
  createCanvas(400, 200);
  angleMode(DEGREES);
}

function draw() {
  background(220);
  
  // 示例1:粗红色描边
  stroke(255, 0, 0);
  strokeWeight(10);
  arc(100, 100, 100, 100, 0, 270);
  
  // 示例2:带圆角端点的描边
  stroke(0, 255, 0);
  strokeWeight(10);
  strokeCap(ROUND);
  arc(220, 100, 100, 100, 0, 270);
  
  // 示例3:带阴影效果的描边
  stroke(0, 0, 255);
  strokeWeight(15);
  strokeCap(SQUARE);
  arc(340, 100, 100, 100, 0, 270);
  
  // 恢复默认设置
  noStroke();
}

关键点解析:

  1. 颜色设置:使用stroke()函数设置不同颜色的描边

  2. 宽度设置:使用strokeWeight()函数调整描边粗细

  3. 端点样式:使用strokeCap()函数设置描边端点的样式(圆角效果特别适合圆弧)

  4. 阴影效果:通过增加描边宽度并偏移绘制位置可以创建简单的阴影效果

填充效果

在 p5.js 中,我们可以通过以下函数定制圆弧的填充效果:

  • fill(color):设置填充颜色

  • noFill():禁用填充效果

  • colorMode(mode):设置颜色模式(RGB、HSB 等)

  • alpha():设置颜色透明度

以下示例展示了如何定制圆弧的填充效果:

06.png

function setup() {
  createCanvas(400, 200);
  angleMode(DEGREES);
  colorMode(HSB, 360, 100, 100); // 使用HSB颜色模式
}

function draw() {
  background(220);
  
  // 示例1:单色填充
  fill(120, 100, 100); // 绿色
  arc(100, 100, 100, 100, 0, 270);
  
  // 示例2:渐变填充
  noFill();
  stroke(0, 0, 100);
  strokeWeight(10);
  for (let i = 0; i < 360; i += 10) {
    fill(i, 100, 100);
    arc(220, 100, 100, 100, i, i+10);
  }
  
  // 示例3:透明填充
  fill(240, 100, 100, 50); // 半透明蓝色
  arc(340, 100, 100, 100, 0, 270);
  
  // 恢复默认设置
  noFill();
  stroke();
}

关键点解析:

  1. 颜色模式:使用colorMode()函数切换到 HSB 模式,方便创建渐变效果

  2. 单色填充:直接使用fill()函数设置单一填充颜色

  3. 渐变填充:通过循环绘制多个小角度的圆弧,每个使用不同的色相值实现渐变效果

  4. 透明度设置:在fill()函数中添加第四个参数(0-100)设置透明度

旋转圆弧

在 p5.js 中创建圆弧动画非常简单,主要通过以下方法实现:

  • **draw()**函数:每秒自动执行约 60 次,用于更新动画帧

  • 变量控制:使用变量控制圆弧的参数(如位置、大小、角度等)

  • frameRate(fps):设置动画帧率(可选)

  • millis():获取当前时间(毫秒),用于精确控制动画时间

圆弧动画效果示例:

07.gif

let angle = 0;

function setup() {
  createCanvas(400, 400);
  angleMode(DEGREES);
}

function draw() {
  background(220);
  
  // 绘制旋转的红色圆弧
  stroke(255, 0, 0);
  strokeWeight(10);
  arc(width/2, height/2, 300, 300, angle, angle + 90);
  
  // 更新角度值,实现旋转效果
  angle += 2; // 调整这个值可以改变旋转速度
  
  // 恢复默认设置
  noStroke();
}

关键点解析:

  1. 角度变量:使用 angle 变量控制圆弧的起始角度

  2. 角度更新:在每次 draw() 调用时增加angle值,实现旋转效果

  3. 速度控制:通过调整每次增加的角度值(这里是 2 度)控制旋转速度

弧度与角度的转换技巧

在 p5.js 中,arc()函数默认使用弧度作为角度单位,但我们通常更习惯使用角度。以下是一些转换技巧:

  • 角度转弧度:使用 radians(degrees) 函数将角度转换为弧度

  • 弧度转角度:使用 degrees(radians) 函数将弧度转换为角度

  • 设置角度单位:使用 angleMode(DEGREES) 函数将全局角度单位设置为角度,这样 arc() 函数就可以直接使用角度值

  • 常见角度值:记住一些常用角度的弧度值,如 90 度 = PI/2,180 度 = PI,270 度 = 3PI/2,360 度 = 2PI

圆弧绘制的常见问题与解决方案

在使用 arc() 函数时,可能会遇到以下问题:

  1. arc () 函数中的 bug:当 start_angle == end_angle 时,可能会出现意外绘制效果。例如,当 start_angle == end_angle == -PI/2 时会绘制一个半圆,这不符合预期。解决方案是避免 start_angleend_angle 相等。

  2. 起始角度的位置:在 p5.js 中,0 弧度(或 0 度,如果使用 angleMode(DEGREES))指向正右方(3 点钟方向),而不是数学上的正上方。这可能导致方向与预期不符。

  3. 描边宽度的影响:较宽的描边会使圆弧看起来比实际大。这是因为描边会向路径的两侧扩展。如果需要精确控制大小,可以考虑将arc()的尺寸适当减小,或者使用 shapeMode() 函数调整坐标系。

  4. 浮点精度问题:在进行角度计算时,尤其是涉及到除法和循环时,可能会遇到浮点精度问题。建议使用 nf() 函数(如 nf(value, 2, 0) )来格式化显示的数值,避免显示过多的小数位。


以上就是本文的全部内容啦,如果想了解更多 p5.js 的玩法可以关注 《P5.js中文教程》

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

JavaScript垃圾回收:你不知道的内存管理秘密

大家好,我是前端大鱼。作为前端开发者,我们每天都在与JavaScript打交道,但很少有人真正了解JavaScript是如何管理内存的。今天,我们就来揭开JavaScript垃圾回收机制的神秘面纱,让你对内存管理有更深入的理解。

为什么需要垃圾回收?

在编程中,内存管理一直是个重要话题。C/C++等语言需要手动管理内存,而JavaScript则采用了自动内存管理机制。这是因为:

  1. 防止内存泄漏(应用程序不再需要的内存没有被释放)
  2. 避免野指针(访问已释放的内存)
  3. 减轻开发者负担,让开发者更专注于业务逻辑
// 伪代码示例:手动内存管理 vs 自动内存管理
// C语言风格(手动)
let ptr = malloc(1024); // 分配内存
// 使用内存...
free(ptr); // 必须手动释放

// JavaScript风格(自动)
let obj = { data: "value" }; // 自动分配
obj = null; // 不再需要时,垃圾回收器会自动回收

JavaScript的内存生命周期

JavaScript中的内存生命周期可以分为三个阶段:

  1. 分配阶段:当声明变量、函数或创建对象时,JavaScript会自动分配内存
  2. 使用阶段:读写分配的内存
  3. 释放阶段:当内存不再需要时自动释放

垃圾回收的基本策略

现代JavaScript引擎主要采用两种垃圾回收策略:

1. 标记-清除算法(Mark-and-Sweep)

这是目前主流JavaScript引擎(V8、SpiderMonkey等)采用的算法。其工作原理如下:

// 标记-清除算法伪代码
function garbageCollect() {
    // 标记阶段:从根对象出发,标记所有可达对象
    markFromRoots();
    
    // 清除阶段:遍历堆内存,回收未被标记的对象
    sweep();
}

function markFromRoots() {
    let worklist = [...roots]; // roots包括全局对象、当前调用栈等
    
    while (worklist.length > 0) {
        let obj = worklist.pop();
        if (!obj.marked) {
            obj.marked = true;
            worklist.push(...obj.references); // 递归标记引用对象
        }
    }
}

function sweep() {
    for (let obj in heap) {
        if (obj.marked) {
            obj.marked = false; // 为下次GC准备
        } else {
            free(obj); // 释放内存
        }
    }
}

2. 引用计数(Reference Counting)

这是一种较简单的策略,但现在已很少单独使用:

// 引用计数伪代码
let obj = { count: 0 }; // 新对象引用计数为0

// 当有引用指向该对象时
function addReference(obj) {
    obj.count++;
}

// 当引用移除时
function removeReference(obj) {
    obj.count--;
    if (obj.count === 0) {
        free(obj); // 释放内存
    }
}

引用计数的主要问题是无法处理循环引用:

// 循环引用示例
function createCycle() {
    let a = {};
    let b = {};
    a.ref = b; // a引用b
    b.ref = a; // b引用a
    // 即使函数执行完毕,a和b的引用计数仍为1,无法回收
}

V8引擎的垃圾回收优化

现代JavaScript引擎如V8对基本标记-清除算法做了许多优化:

1. 分代收集(Generational Collection)

V8将堆内存分为新生代(Young Generation)和老生代(Old Generation):

  • 新生代:存放生命周期短的对象,使用Scavenge算法(一种复制算法)频繁回收
  • 老生代:存放存活时间长的对象,使用标记-清除或标记-整理算法较少回收
// 分代收集伪代码
function generationalGC() {
    if (youngGenerationIsFull()) {
        scavengeYoungGeneration();
        if (promotionConditionMet()) {
            promoteToOldGeneration();
        }
    }
    
    if (oldGenerationIsFull()) {
        markSweepOrCompactOldGeneration();
    }
}

2. 增量标记(Incremental Marking)

为了避免长时间停顿,V8将标记过程分成多个小步骤,与JavaScript执行交替进行。

3. 空闲时间收集(Idle-time Collection)

利用浏览器空闲时段进行垃圾回收,减少对主线程的影响。

内存泄漏的常见模式

即使有垃圾回收机制,不当的代码仍可能导致内存泄漏:

  1. 意外的全局变量
function leak() {
    leakedVar = '这是一个全局变量'; // 意外创建全局变量
}
  1. 遗忘的定时器或回调
let data = getHugeData();
setInterval(() => {
    // 即使data不再需要,定时器仍保持引用
    process(data);
}, 1000);
  1. DOM引用
let elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image')
};

// 即使从DOM移除,JavaScript引用仍然存在
document.body.removeChild(document.getElementById('image'));
  1. 闭包
function outer() {
    let largeData = new Array(1000000).fill('*');
    
    return function inner() {
        // inner函数保持对largeData的引用
        return 'Hello';
    };
}

最佳实践

  1. 使用弱引用:对于不需要强引用的数据,可以使用WeakMap或WeakSet
let weakMap = new WeakMap();
let key = { id: 1 };
weakMap.set(key, 'some data');
// 当key不再被引用时,条目会自动从WeakMap中移除
  1. 及时清理:不再需要的引用显式设为null
let data = getLargeData();
process(data);
data = null; // 不再需要时清除引用
  1. 避免内存密集操作:特别是在循环或频繁调用的函数中

  2. 使用开发者工具监控内存:Chrome DevTools的Memory面板是强大的内存分析工具

🌟总结

JavaScript的垃圾回收机制是语言设计的一大优势,它让开发者从繁琐的内存管理中解放出来。理解其工作原理不仅能帮助我们编写更高效的代码,还能有效避免内存泄漏问题。

希望这篇文章能帮助你更深入地理解JavaScript的内存管理机制,写出更健壮、高效的代码!

做副业,稳住心态,不靠鸡汤!我的实操经验之路

做副业有段时间了,最近几乎每天都有小伙伴问我:“匿星,你心态怎么那么稳?你不焦虑吗?”

坦白说——就在两个月前,我还陷在极度焦虑里,几乎每天都在怀疑自己,觉得自己可能真的做不好副业。

今天这篇文章,聊聊我是如何缓解焦虑、逐步找回内心稳定的。

1. 方向明确,比什么都重要

转折点,来自一次深度沟通。

那天和我的私教教练聊了很久,他们没有一上来就给我方法论,而是帮我梳理了方向——左手项目,右手IP,同时进行。

你有没有发现,很多焦虑其实是“不确定”带来的?

比如:到底先做流量,还是先做产品?到底选哪个赛道?

越反复横跳,越动弹不得,焦虑就在内耗里无限放大。

我的改变,是从定下“主副线同步推进”开始的。

大方向定了,哪怕小步慢一点都没关系——又不是今年做完就不再继续的事,没必要太着急,欲速则不达

2. 让自己忙起来,是最好的解药

以前我总以为,得先解决焦虑,才能静下心做事。

现在回头看,其实只有把自己沉浸在具体行动里,焦虑才会被消耗掉

那段时间,正好赶上新项目开始,群里各种素材、文案、答疑,根本没空胡思乱想。

我还给自己定了一个小目标:每天一条朋友圈,雷打不动

哪怕硬着头皮,也要写完。刚开始写,真的很难受,感觉没有表达欲,每天花半小时甚至更久,基本都拖到凌晨。

但你会发现,坚持三天是折磨,坚持三十天就变成习惯。

忙起来,焦虑就没那么大位置了。

3. 沉浸式学习,让成长有迹可循

今年我第三年参加微信读书的365天打卡挑战。每天读书、做笔记,素材就顺手成了朋友圈的内容。

一本好书,就是最好的充电宝。

你在书里看到的、学到的,哪怕只吸收一点点,都能转化成内在底气。

而且,学习的过程很容易让人进入心流状态,这种专注感本身就是焦虑的天敌。

4. 和自己比,知足常乐

这一点,是最近才真正想通的。

我们总喜欢和别人比:别人粉丝暴涨,别人项目爆火,别人变现几万几十万……

但那些大佬的成绩,是无数个日夜的沉淀和积累,才一步步做起来的。而我自己才做副业多长时间,怎么能奢望这样的成绩?

于是就问自己:你是不是已经比之前的自己更好了?是不是已经找到了前进的路?是不是已经遇到一帮靠谱的小伙伴?

把这些想通,发现自己已经很知足了。

image.png

记得《遥远的救世主》里有一句话:

如果我的能力只能让我穷困潦倒,那穷困潦倒就是我的价值。只有我自己觉到、悟到,我才有可能做到。能做到的,才是我的。

与其和别人较劲,不如和自己赛跑。你只需要比昨天的自己更进一步就够了。

5. 回归本质:执行+修炼内功

焦虑的本质,是怕“错过机会、抓不住结果”。

但你有没有想过——

如果你整天只焦虑“没流量、没赚钱”,却从不认真执行项目、不修炼内功,机会来了你也接不住。

与其在情绪里徘徊,不如脚踏实地,哪怕慢一点,也要持续前进。

当你认真做事,提升能力,机会真的来了,你自然能接得住!

焦虑不是病,而是成长路上的必经之路。

认清方向,保持行动,专注成长,和自己比,每天进步一点点——你会发现,稳住心态真的没那么难。

愿我们都能在忙碌和成长中,逐渐消解焦虑,成为更稳、更强的自己!

我是匿星,今天的分享就到这里!

ps:  我有一个副业社群,平时分享一些赚钱项目、案例,以及个人成长类思维认知,感兴趣的朋友可以进群一起交流学习。

长按下方加我微信,备注  “掘金”,  送你一份送你一份神秘礼物。

image.png

前端开发中的 Mock 实践与接口联调技巧

引言

在现代前端开发中,前后端分离架构已经成为主流。然而,这种架构模式也带来了一个挑战:前端开发往往需要等待后端提供接口才能进行联调。为了解决这个问题,Mock 技术应运而生。通过使用Mock,前端可以在没有真实接口的情况下进行开发和测试,从而提升开发效率并减少等待时间。
本文将深入探讨如何在 React 项目中使用 vite-plugin-mock 实现 Mock 接口,并结合 Apifox 工具进行接口测试,最终实现与真实接口的无缝对接。


一、传统前后端分离开发模式

在传统的前后端分离架构中,前端通常使用 React、Vue 等框架进行开发,而后端则采用 Java、Node.js、Go 等语言构建服务。前后端通过 RESTful API 或 GraphQL 进行通信。

前端开发痛点:

  • 后端接口尚未完成时,前端无法进行联调。
  • 接口返回格式不统一,导致前端处理逻辑复杂。
  • 联调过程中容易受到后端服务不稳定的影响。

解决方案:

  • 使用 Mock 技术模拟接口数据。
  • 前端自定义接口响应格式,提升开发效率。
  • 使用 Apifox、Postman 等工具进行接口调试。

二、mock是什么?

Mock 是指在开发过程中,使用虚拟数据模拟后端接口的行为。它可以帮助前端在没有真实接口的情况下进行开发和测试。

1.安装依赖

// 在开发依赖当中下载mock工具
pnpm i vite-plugin-mock -D

2.配置环境

当下载完之后在工具包当中将viteMockServe解构出来,并且在vite当中配置该工具包,使其具备自行模拟数据请求的能力。

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path' 
// 服务器端 mock 模拟下
// vite 前端模拟服务器 准备好了插件
// 前后端分离 不能等后端接口写好了,前端先起来
import {
  viteMockServe
} from 'vite-plugin-mock'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(),
    viteMockServe({
      mockPath: 'mock',
      localEnabled: true,
    })
  ],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
    },
  },  
})

3.为何需要mock?

服务器端mock模拟状态下,前端伪接口,axios/api 请求
不能等后端接口写好了再调用接口,前端先写起来,并且在写前端的过程当中可以自行模拟数据请求,

4.使用场景

  • 在前后端分离的项目中,后端接口尚未完成时,前端可以使用 viteMockServe 模拟接口数据进行开发。
  • 在单元测试中,可以使用模拟数据来验证前端逻辑是否正确。
  • 在调试过程中,可以通过修改模拟数据快速验证不同场景下的应用行为。

三、实战源码

1.mock接口

在主文件当中创建mock文件夹,并且在其中创建test.js文件开始编写伪数据,下面的代码就是咱们已经写好的test接口请求。接下来将咱们的文件源码跑起来,并向这个地址发送请求,观察使用的虚拟模拟api当中是否会拿到相应的源码内容(需要用到Apifox)

export default [
  {
    url: '/api/todos',
    method: 'get',
    response:() => {
      const todos = [
        {
          id: 1,
          title: 'todo1',
          completed: false, // completed是一个布尔类型的属性
        },
        {
          id: 2,
          title: 'todo2',
          completed: true, // 表示这个 todo 已经完成
        }
      ]
      return {
        code: 0, // 没有错误
        data: todos,
        message: 'success'
      }
    }
  }
]

2.Apifox测试

让我们启动该软件,并且在软件当中发送快捷请求,当在发送请求当中输入咱们本地创建的服务器,可以看到轻松拿到接口请求的结果了,这也意味着咱们请求成功啦!

image.png

3.小结

  • 前端在后端接口未完成时可使用 Mock 数据进行开发。
  • 使用 vite-plugin-mock 可快速搭建 Mock 服务。
  • 接口文件格式固定,需包含 urlmethod 和 response

image.png


四、实战案例(请求到github仓库)

1.简单测试

当我们将接口请求换成repos,并且修改自己代码中的test可以看到页面当中的效果。这时候咱们就会有两个接口,这时候咱们向自己新定义的接口发送请求,可以看到Apifox当中的数据测试内容。

export default [
    {
        url: '/api/todos',
        method: 'get',
        response: () => {
            const todos = [
                {
                    id: 1,
                    title: 'todo1',
                    completed: false,
                },
                {
                    id: 2,
                    title: 'todo2',
                    completed: true,
                }
            ]
            return {
                code: 0, // 没有错误
                message: 'success',
                data: todos,
            }
        }
    },
    {
        url: '/api/repos',
        method: 'get',
        response: () => {
            const repos = [
                {
                    id: 695370163,
                    title: 'ai_lesson',
                    description: "AI全栈工程师课程",
                },
                {
                    id: 152578450,
                    title: 'AlloyFinger',
                    description: "super tiny size multi-touch gestures library for the web.    You can touch this",
                }
            ]
            return {
                code: 0, // 没有错误
                message: 'success',
                data: repos,
            }
        }
    },
]

image.png

2.config文件

// 标准的http请求库, vue/react 都用它
import axios from 'axios'
axios.defaults.baseURL = 'http://localhost:5173'
export default axios;
  • 统一管理:将axios的引入和配置集中到一个文件中,便于全局管理和维护。
  • 简化使用:其他模块只需导入该配置文件即可使用axios,而不需要重复引入和配置。
  • 可扩展性:如果后续需要对axios进行全局配置(如设置默认请求头、请求拦截、响应拦截等),可以在该文件中进行统一处理,而不影响其他代码。

3.index文件

import axios from './config'
// todos接口
export const getTodos = () => {
  return axios.get('/api/todos')
}

export const getRepos = () => {
  return axios.get('/api/repos')
}

getTodos函数的作用是向后端发起GET请求,获取待办事项列表。它封装了具体的API请求逻辑,前端组件或业务代码只需调用该函数即可。除了getTodos,代码中还定义了getRepos函数,用于获取仓库数据。这表明该文件是统一管理前端API请求的地方,便于维护和修改。

4.效果展示通过 Apifox 测试接口返回数据,并在页面上成功渲染 GitHub 仓库信息。

咱们需要根据接口联调请求回来的数据在App当中请求查看其中的文档内容,当我们去主页面查看打印结果的时候可以发现代码当中存在需要的数据内容。

import {
  useState,
  useEffect
} from 'react'
import './App.css'
import {
  getTodos,
  getRepos
} from '@/api'


function App() {
  const [todos, setTodos] = useState([])
  useEffect(() => {
    const fetchData = async () => {
      const todosResult = await getTodos()
      console.log(todosResult);
      setTodos(todosResult.data.data)
    }
    fetchData()
  }, [])

  return (
    <>

    </>
  )
}

export default App

image.png 接下来咱们只需要根据主页当中拿到的数据内容进一步进行页面渲染,选择适合自己的页面数据,将需要的数据渲染在页面当中。可以看到需要的数据在data.data当中,同样的根据代码模版内容,Repos的代码是与Todos的代码格式是相同的。

  return (
    <>
      {
        todos.map(todo => (
        // key 不能是索引
          <div key={todo.id}>
            {todo.title}
          </div>
        ))
      }
    </>
  )
}

image.png

image.png 当我们将在线链接换成咱们自己的github仓库就可以实现发送真正的数据请求。

// 标准的http请求库, vue/react 都用它
import axios from 'axios'
// axios.defaults.baseURL = 'http://localhost:5173'
// 线上地址有了
axios.defaults.baseURL = 'https://api.github.com/users/shunwuyu'
export default axios;
import axios from './config'
// todos接口
//export const getTodos = () => {
//  return axios.get('/api/todos')
//}

export const getRepos = () => {
  return axios.get('/repos')
}
import {
  useState,
  useEffect
} from 'react'
import './App.css'
import {
  getTodos,
  getRepos
} from '@/api'


function App() {
  const [todos, setTodos] = useState([])
  const [repos, setRepos] = useState([])
  useEffect(() => {
    const fetchData = async () => {
      // const todosResult = await getTodos()
      // console.log(todosResult);
      // setTodos(todosResult.data.data)
      const reposResult = await getRepos()
      console.log(reposResult);
      setRepos(reposResult.data)
    }
    fetchData()
  }, [])

  return (
    <>
      {
        repos.map(repo => (
          <div key={repo.id}>
            {repo.title}
            {repo.description}
          </div>
        ))
      }
    </>
  )
}

export default App

image.png 当我们能够熟练运用上述三段代码使用的技巧,接口联调的内容基本上就已经掌握。


五、总结

在前后端分离开发中,Mock技术是前端开发的重要工具。通过 vite-plugin-mock,我们可以轻松实现接口模拟,提升开发效率。结合Apifox进行接口测试,可以确保前端逻辑的正确性。最终,通过统一的API封装和Axios配置,实现与真实接口的无缝对接。

前端国际化技术实践

1、国际化是什么?

在构建面向全球用户的应用系统时,“国际化(i18n)” 是基础能力之一。系统层面的国际化不仅包含前端国际化,也包括后端国际化

前端国际化

  • 多语言文本渲染
  • 货币、时间、数字等本地格式展示
  • 实时语言切换能力

后端国际化

  • 接口多语言返回(如:标题、产品详情等)
  • 日志记录语言、服务端渲染内容的语言切换
  • 数据库中支持多语言字段
  • 后端根据 Accept-Language / Token 判断用户语言

本文聚焦于 前端国际化技术实践,尤其是如何选型、实现与维护 UI 层的多语言能力。

2、为什么需要前端国际化?

  • 让用户使用自己熟悉的语言,是用户友好设计的重要一环。
  • 不同市场需要以本地化方式推广产品,支持国际化是出海的前提条件。
  • 多个国家地区要求系统必须提供本地语言版本的协议或功能。

3、前端国际化方案演进与对比

早期方案:基于 jQuery 插件

  • 使用 jquery.i18n.properties,兼容 Java .properties 格式;
  • 手动替换 DOM 文本。

优点:简单、快速接入,适合老系统或小型项目,这个方案很难见到了。
缺点:缺乏插值、懒加载、框架集成能力。


现代框架方案:Vue / React

Vue (vue-i18n)
  • 使用 $t('key') 替代原文;
  • 支持变量插值、复数形式、懒加载语言包。
React (react-i18next)
  • 使用 useTranslation() Hook;
  • 支持异步加载、上下文管理、嵌套 key、复数、格式化。

优点:与组件深度集成、功能强大。
缺点:接入配置稍复杂,需统一 key 命名规范。


自动化方案:AI 翻译 + 自动提取

典型工具:

自动扫描中文文本,提取成语言 key,并调用 AI(如腾讯翻译、Google Translate)生成目标语言翻译。

优点:显著提升效率,降低遗漏风险,适合多人协作项目。
缺点:AI 翻译需人工校对,不适用于正式文案场景。


方案对比

特性 jQuery 插件 Vue/React 集成 自动提取工具链
适配框架 中(结合构建工具)
插值支持 基础 完善 支持
懒加载支持
易用性 简单 自动化高,但需配置
适合场景 传统后台、小项目 中大型项目、前后端分离 多人协作、自动化构建系统

前端国际化核心流程

🧩 Step 1:抽取语言 key

将所有页面文案提取为 key,例如:

$t('login.welcome') // ✅
$t('欢迎登录')       // ❌
🧩 Step 2:维护语言包

语言包建议结构如下:

/locales/  
├─ en-US/  
│ ├─ login.json  
│ ├─ dashboard.json  
└─ zh-CN/  
  ├─ login.json  
  ├─ dashboard.json
🧩 Step 3:检测用户语言
const lang = localStorage.getItem('lang') || navigator.language || 'en-US';
🧩 Step 4:加载语言资源

使用 Vue/React 的 i18n 插件异步加载语言 JSON 文件。

🧩 Step 5:支持语言切换
i18n.global.locale = 'en'; // Vue 3
i18n.changeLanguage('zh'); // React

4、示例

4.1 Vue + vue-i18n 示例

npm install vue-i18n@next
import { createI18n } from 'vue-i18n';

const i18n = createI18n({
  locale: 'zh-CN',
  messages: {
    'en-US': { welcome: 'Welcome!' },
    'zh-CN': { welcome: '欢迎!' }
  }
});
<template>
  <div>{{ $t('welcome') }}</div>
</template>

4.2 React + react-i18next 示例

npm install react-i18next i18next
import { useTranslation } from 'react-i18next';

function App() {
  const { t, i18n } = useTranslation();
  return (
    <div>
      <h1>{t('welcome')}</h1>
      <button onClick={() => i18n.changeLanguage('en-US')}>EN</button>
    </div>
  );
}

参考链接

🚀 TSX动态编译的黑科技,快如闪电!

今天想和大家分享一个我开发的小工具dctc,它能让TSX文件的编译和执行变得更加简单高效。虽然不是什么革命性的工具,但在某些场景下可能会帮到你。

🔍 这玩意儿是啥?

dctc是一个轻量级的命令行工具,专注于将TypeScript和JSX文件快速编译为可执行代码。它基于esbuild实现高效编译(虽然vite版本目前被注释),希望能为开发者提供一些便利。

🛠️ 它能干啥?

  1. 高效编译:利用esbuild实现快速的TSX到JS转换
  2. 即时执行:编译后可直接执行,简化开发流程
  3. React支持:内置React和React-DOM,方便组件开发
  4. 简洁CLI:通过简单命令即可完成所有操作

🎯 适用场景

  • 快速预览React组件
  • 生成HTML邮件模板
  • 临时执行TSX脚本
  • 教学演示React组件

⚙️ 技术实现

  1. 编译模块 (complie_es.js)

    • 基于esbuild实现高效编译
    • 完整支持TypeScript和JSX语法
    • 输出CommonJS格式代码
  2. 执行模块 (execute.js)

    • 使用Node.js的vm模块创建安全沙箱
    • 提供完整的Node.js环境上下文
    • 完善的错误处理和日志输出机制
  3. CLI接口 (bin/index.js)

    • 参数解析和验证功能
    • 版本和帮助信息展示
    • 文件路径检查功能

🚴 快速体验

安装

npm install -g dctc

写个TSX文件

// hello.tsx
import React from 'react';

const App = () => {
  return <h1>Hello, dctc!</h1>;
};

export default App;

执行

dctc hello.tsx

🎨 高级用法

项目还支持更复杂的场景,比如生成HTML模板:

// email-template.tsx
import { renderToString } from 'react-dom/server';
import React from 'react';

const EmailTemplate = () => (
  <div style={{ fontFamily: 'Arial' }}>
    <h1>欢迎订阅我们的服务</h1>
    <p>感谢您选择我们!</p>
  </div>
);

const html = renderToString(<EmailTemplate />);
console.log(html);

🤔 为什么选择dctc?

  1. 速度快:相比传统TypeScript编译器,esbuild的编译速度提升10倍以上
  2. 轻量级:没有复杂的配置,开箱即用
  3. 灵活:既可以在开发时使用,也可以集成到构建流程中

🏁 结语

dctc就像是你工具箱里的瑞士军刀,简单却强大。无论是快速原型开发,还是临时脚本执行,它都能完美胜任。项目代码简洁优雅!

GitHub地址:github.com/SteamedBrea…

CSS 管理方案CSS Modules、CSS-in-JS`和 Tailwind CSS

一、CSS Modules

📌 是什么?

CSS Modules 是一种 CSS 文件模块化方案,它通过 局部作用域(local scope) 的方式来避免 CSS 类名冲突。

✅ 特点:

  • 每个 CSS 文件只作用于引入它的组件(局部作用域)。
  • 写法仍然是标准的 CSS。
  • 与 React、Vue 等框架集成良好。
  • 支持使用 CSS 预处理器(如 Sass、Less)。

📁 示例:

jsx
// Button.module.css
.button {
  background: blue;
  color: white;
  padding: 10px;
}

// Button.jsx
import styles from './Button.module.css';

function Button() {
  return <button className={styles.button}>Click me</button>;
}

⚙️ 优点:

  • 保持 CSS 原生写法,学习成本低。
  • 避免全局样式污染。
  • 构建工具(如 Webpack、Vite)原生支持。

❌ 缺点:

  • 不支持动态样式(除非配合 JS 逻辑)。
  • 类名需要手动绑定,维护略显繁琐。

二、CSS-in-JS

📌 是什么?

CSS-in-JS 是一种将 CSS 样式写在 JavaScript 中的方案,通常通过库(如 styled-componentsemotion)实现。

✅ 特点:

  • 样式定义在 JS 中,可以动态生成。
  • 支持组件化、作用域隔离、主题等高级功能。
  • 与 React 生态高度融合。

📁 示例(以 styled-components 为例):

jsx
import styled from 'styled-components';

const Button = styled.button`
  background: ${props => props.primary ? 'blue' : 'gray'};
  color: white;
  padding: 10px;
`;

function App() {
  return <Button primary>Primary Button</Button>;
}

⚙️ 优点:

  • 样式可动态生成,高度灵活。
  • 组件化强,与 React 高度契合。
  • 组件隔离,自动处理 CSS 作用域和冲突。
  • 无需.css文件,纯JS环境。

❌ 缺点:

  • 学习曲线略高(需要了解库 API)。
  • 依赖 JavaScript 运行时,可能影响性能和 SEO。
  • css 无法被浏览器缓存
  • 类名不直观,调试稍复杂。

三、Tailwind CSS

📌 是什么?

Tailwind CSS 是一个 原子化(atom)+功能化(utility first) 实用优先的 CSS 框架,它不提供预设的组件样式,而是 提供大量细粒度的类名 供开发者组合使用。

✅ 特点:

  • 所有样式通过类名控制,无需写额外 CSS。
  • 可高度定制,支持响应式、暗黑模式等。
  • 构建时自动移除未使用的 CSS(通过 PostCSS + PurgeCSS)。

📁 示例:

jsx
<button className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
  Click me
</button>

⚙️ 优点:

  • 开发效率高,无需写 CSS 文件。
  • 一致性好,适合设计系统。
  • 支持按需加载,最终 CSS 体积小。

❌ 缺点:

  • 类名长,HTML 结构复杂。
  • 初学者需要适应“utility-first”的写法。
  • 语义性差(类名本身不表达语义)。

四、对比总结表

功能/特性 CSS Modules CSS-in-JS Tailwind CSS
是否模块化 ✅ 是(文件作用域) ✅ 是(组件级) ❌ 否(全局类名)
是否支持动态样式 ❌ 否(需 JS 配合) ✅ 是(原生支持) ✅ 是(需 JS 控制)
是否需要写 CSS 文件 ✅ 是 ❌ 否 ✅ 是(配置文件)
是否支持主题系统 ❌ 否 ✅ 是 ✅ 是
是否影响构建性能 ⚠️ 一般 ⚠️ 略高 ✅ 构建优化好
是否影响 SEO / SSR ✅ 是 ⚠️ 依赖库实现 ✅ 是
学习成本 ✅ 低 ✅ 中等 ⚠️ 中等偏高
适合场景 中大型项目、传统 CSS 管理 动态 UI、组件库 快速 UI 开发、设计系统

🌟 React Router Dom 终极指南:二级路由与 Outlet 的魔法之旅

蛋黄酥带你探索前端路由的奥秘,解锁 SPA 应用的核心技能!只需一杯咖啡的时间,彻底掌握 React Router Dom 的嵌套路由技巧。

🚀 前言:为什么路由如此重要?

在前端开发的魔法世界里,路由就像空间传送门!它决定了:

  • 用户访问 /home 时看到什么
  • 点击链接时如何无刷新切换内容
  • 如何保持页面状态的同时改变 URL

今天,就让蛋黄酥带你用 react-router-dom 打造属于你的传送门系统!


🔧 第一步:安装与基础搭建

npm install react-router-dom@6

基础路由框架搭建

// App.js
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Home from './pages/Home';
import About from './pages/About';

function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </Router>
  );
}

🧩 二级路由:页面中的小世界

当我们需要在同一个页面展示不同内容区块时(如控制台侧边栏+主内容区),二级路由就是最佳选择!

魔法道具:<Outlet />

// Dashboard.js
import { Outlet } from 'react-router-dom';

const Dashboard = () => {
  return (
    <div className="dashboard">
      <aside>导航菜单</aside>
      <main>
        {/* 这里是二级路由的渲染位置! */}
        <Outlet />
      </main>
    </div>
  );
};

🌀 二级路由配置实战

1. 定义嵌套路由结构

// App.js
<Routes>
  <Route path="/dashboard" element={<Dashboard />}>
    {/* 二级路由作为子元素 */}
    <Route index element={<DashboardHome />} />
    <Route path="settings" element={<Settings />} />
    <Route path="analytics" element={<Analytics />} />
  </Route>
</Routes>

2. 路由匹配效果

访问路径 渲染组件
/dashboard Dashboard + DashboardHome
/dashboard/settings Dashboard + Settings
/dashboard/analytics Dashboard + Analytics

🎯 动态路由参数进阶

二级路由同样支持动态参数,打造高度灵活的页面结构:

// 电商产品详情页案例
<Route path="/products" element={<ProductLayout />}>
  <Route index element={<ProductList />} />
  <Route path=":productId" element={<ProductDetail />} />
  <Route path="new" element={<NewProductForm />} />
</Route>
// ProductDetail.js
import { useParams } from 'react-router-dom';

export default function ProductDetail() {
  const { productId } = useParams();
  
  return (
    <div>
      <h2>产品详情:{productId}</h2>
      {/* 产品详情内容 */}
    </div>
  );
}

💡 黄金技巧:Outlet 的三种高阶用法

1. 嵌套布局

// 三级路由结构
<Route path="/admin" element={<AdminLayout />}>
  <Route index element={<Dashboard />} />
  <Route path="users" element={<UserManagementLayout />}>
    <Route index element={<UserList />} />
    <Route path=":userId" element={<UserProfile />} />
  </Route>
</Route>

2. 条件渲染

// 根据路由状态显示不同UI
const ProductLayout = () => {
  return (
    <>
      <Header />
      {location.pathname.includes('new') ? (
        <NewProductBanner />
      ) : null}
      <Outlet />
    </>
  );
};

3. 路由过渡动画

import { motion, AnimatePresence } from 'framer-motion';

const AnimatedOutlet = () => (
  <AnimatePresence mode="wait">
    <motion.div
      key={location.key}
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0 }}
    >
      <Outlet />
    </motion.div>
  </AnimatePresence>
);

🚫 常见避坑指南

错误1:忘记在父组件放置 Outlet

// ❌ 错误:二级内容不会显示
const Dashboard = () => {
  return (
    <div>
      <h1>控制台</h1>
      {/* 缺少 <Outlet /> */}
    </div>
  );
};

// ✅ 正确:添加 Outlet 容器
const Dashboard = () => {
  return (
    <div>
      <h1>控制台</h1>
      <Outlet /> {/* 魔法发生在这里 */}
    </div>
  );
};

错误2:错误的路由嵌套结构

// ❌ 错误:平级路由无法形成嵌套
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/dashboard/settings" element={<Settings />} />

// ✅ 正确:使用子路由结构
<Route path="/dashboard" element={<Dashboard />}>
  <Route path="settings" element={<Settings />} />
</Route>

🌈 真实案例:掘金风格个人中心

// 路由配置
<Route path="/user/:username" element={<UserProfileLayout />}>
  <Route index element={<UserPosts />} />
  <Route path="likes" element={<UserLikes />} />
  <Route path="collections" element={<UserCollections />} />
  <Route path="following" element={<UserFollowing />} />
</Route>
// UserProfileLayout.js
import { Outlet, useParams } from 'react-router-dom';

export default function UserProfileLayout() {
  const { username } = useParams();
  
  return (
    <div className="user-profile">
      <header>
        <h1>{username}的主页</h1>
        <nav>
          <Link to=".">文章</Link>
          <Link to="likes">赞过</Link>
          <Link to="collections">收藏</Link>
        </nav>
      </header>
      
      {/* 动态内容区域 */}
      <div className="content">
        <Outlet />
      </div>
    </div>
  );
}

🔮 未来展望:React Router 7.7 新特性

  1. 路由预加载

    <Route 
      path="/dashboard"
      element={<Dashboard />}
      loader={loadDashboardData} // 预加载数据
    />
    
  2. 路由级错误边界

    <Route
      path="/admin"
      element={<AdminPanel />}
      errorElement={<AdminErrorPage />}
    />
    

🎉 结语:路由的艺术

通过本文,你已经掌握了:

  • ✅ 二级路由的配置技巧
  • <Outlet /> 的核心作用
  • ✅ 动态路由的高级用法
  • ✅ 常见错误的避坑方法

记住蛋黄酥的黄金法则:

"路由是前端的骨架,Outlet 是嵌套的灵魂"

现在就去创建你的第一个嵌套路由项目吧!遇到问题欢迎在评论区留言,蛋黄酥会第一时间为你解答~

three.js学习

核心概念

image.png

1、创建3D场景

1.1 三维场景Scene

可以把三维场景Scene对象理解为虚拟的3D场景,用来表示模拟生活中的真实三维场景,或者说三维世界。

// 创建3D场景对象Scene
const scene = new THREE.Scene()

// 给三维场景添加物体
// 定义一个几何体 定义长宽高
const geometry = new THREE.BoxGeometry(100, 100, 100)

// 创建一个材质对象
const meterial = new THREE.MeshBasicMaterial({ color: 0xff0000 })

// 创建一个网格模型对象:表示生活中的物体
const mesh = new THREE.Mesh(geometry, meterial)
mesh.position.set(0, 10, 0)

// 将网格模型添加到场景中
scene.add(mesh)

几何体

image.png

材质

image.png

1.2 虚拟相机 Camera

image.png

image.png

image.pngimage.png

image.png

image.png

  const width = 800
  const height = 800
 // 创建一个透视投影相机对象
  const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 3000)
  // 设置相机位置
  camera.position.set(300, 300, 300)
  // 设置相机的视线
  camera.lookAt(0, 0, 0)
  // camera.lookAt(mesh.position)

1.3 渲染器

image.png

 // 创建一个WebGL渲染器
  const renderer = new THREE.WebGLRenderer()
  // 设置渲染区域尺寸
  renderer.setSize(width, height) 
  // 执行渲染操作
  renderer.render(scene, camera)
  // 把渲染结果展示在canvas画布上
  canvasContainer.value.appendChild(renderer.domElement);

2、Three.js三维坐标系

2.1 辅助观察坐标系

image.png

 // 辅助坐标系  参数150表示坐标系大小,可以根据场景大小去设置
  const axesHelper = new THREE.AxesHelper(150)
  // 将坐标系添加到场景中
  scene.add(axesHelper)

image.png

const meterial = new THREE.MeshBasicMaterial({ 
    color: 0x0000ff,
    transparent: true,
    opacity: 0.5
})

image.png 图黄色部分才是可视范围

image.png

3、光源对物体表面影响

image.png 基础网格材质MeshBasicMaterial不会受到光照影响。

// MeshBasicMaterial不会受到光照影响
const material = new THREE.MeshBasicMaterial():

漫反射网格材质MeshLambertMaterial会受到光照影响,该材质也可以称为Lambert网格材质,音译为兰伯特网格材质。

// MeshLambertMaterial会受到光照影响
const meterial = new THREE.MeshLambertMaterial()

3.1 光源介绍

image.png

image.png

点光源

点光源沿着某个点向四周发射

// 创建一个点光源 
// 第一个参数表示光源颜色,第二个参数表示光源强度
const light = new THREE.PointLight(0xffffff, 1)
// 设置光源衰减率 衰减率为0表示光源不衰减
light.decay = 0
// 设置光源位置
light.position.set(400, 200, 300)
// 将光源添加到场景中
scene.add(light)

效果图

image.png

环境光

环境光AmbientLight没有特定方向,只是整体改变场景的光照明暗

// 环境光
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4)
scene.add(ambientLight)

效果图

image.png

平行光
// 平行光
const directionalLight = new THREE.DirectionalLight(0xffffff, 1)
// 设置光源位置
directionalLight.position.set(200, 200,200)
// 将光源添加到场景中
scene.add(directionalLight)

效果图

image.png

点光源辅助观察

image.png

// 点光源辅助对象
const pointLightHelper = new THREE.PointLightHelper(light, 10)
scene.add(pointLightHelper)

效果图

image.png

4、相机控件轨道控制器OrbitControls

npm安装控件库

npm install three @types/three

引入vue中

import { OrbitControls } from "three/addons/controls/OrbitControls.js";

代码中使用

// 创建相机控件对象
const controls = new OrbitControls(camera, renderer.domElement);
// 启用控件阻尼(惯性效果)
controls.enableDamping = true; 
// 设置阻尼系数
controls.dampingFactor = 0.05;

 // 动画循环
const animate = () => {
// 调用渲染器的render方法,执行渲染操作
requestAnimationFrame(animate);
// 每次调用requestAnimationFrame方法时,都会更新相机的位置
// 必须调用,否则阻尼无效
controls.update(); 
renderer.render(scene, camera);
};
animate();

5、动画渲染循环

threejs可以借助HTML5的API请求动画帧window.requestAnimationFrame 实现动画渲染。

// 周期性执行 默认每秒执行60次 可实现动画效果
const render = ()=>{
    mesh.rotateY(0.01)
    // 结合OrbitControls使用
    controls.update();
    // 周期性执行渲染操作 更新canvas到画布上的内容
    renderer.render(scene, camera);
    requestAnimationFrame(render)
}
render()

6、canvas画布宽高动态变化

  const width = window.innerWidth
  const height = window.innerHeight
  
 // 窗口大小调整
  window.addEventListener("resize", onWindowResize);
  
 // 窗口大小调整时更新相机和渲染器
function onWindowResize() {
  // 更新相机的宽高比
  camera.aspect = window.innerWidth / window.innerHeight;
  // 更新相机的投影矩阵
  camera.updateProjectionMatrix();
  // 更新渲染器的尺寸
  renderer.setSize(window.innerWidth, window.innerHeight);
}

7、WebGL渲染器基础设置(锯齿模糊、背景颜色)

渲染器锯齿属性

renderer = new THREE.WebGLRenderer({
    // 边界更平滑
    antialias: true,
})

设备像素比

image.png

// 通用设置
  // 告诉three.js你的屏幕设备像素比window.devicePixelRatio
  renderer.setPixelRatio(window.devicePixelRatio)

设置背景色

renderer.setClearColor(0x444444, 1)

8、gui.js库(可视化改变三维场景)

vue3中安装gui库

npm install lil-gui

导入并且使用

image.png

import GUI from "lil-gui"; // 导入 lil-gui

let gui = null

// 定义可调试的参数对象
const params = {
  x: 30,
  meshColor: '#00ffff',
  meshRotationSpeed: 0.01,
  lightIntensity: 1,
  resetScene: () => {
    mesh.rotation.set(0, 0, 0)
    mesh.material.color.set(params.meshColor)
  },
  materialType: 'MeshStandardMaterial',
}

// 10. 初始化 GUI
  initGUI();
  
const initGUI = () => {
  gui = new GUI({ title: "调试面板", width: 300 });
  
  // 添加x轴位置
  gui.add(mesh.position, "x", 0, 100)
    .name("x轴")
    .onChange((value) => {
      mesh.position.x = value;
    })

  // 添加颜色控制器
  gui.addColor(params, "meshColor")
    .name("立方体颜色")
    .onChange((value) => {
      mesh.material.color.set(value);
    });

  // 添加旋转速度滑块
  gui.add(params, "meshRotationSpeed", 0, 0.1, 0.001)
    .name("旋转速度");

  // 添加光照强度滑块
  gui.add(params, "lightIntensity", 0, 2, 0.1)
    .name("光照强度")
    .onChange((value) => {
      scene.children.forEach((child) => {
        if (child instanceof THREE.Light) {
          child.intensity = value;
        }
      });
    });

  // 添加重置按钮
  gui.add(params, "resetScene").name("重置场景");
};

效果图

image.png

gui.js库(下拉菜单、单选框)

下拉菜单

// 下拉菜单

const params = {
  materialType: "MeshStandardMaterial", // 默认选项
  // ...其他参数
};
  
gui
.add(params, 'materialType', [
  'MeshBasicMaterial',
  'MeshStandardMaterial',
  'MeshPhongMaterial',
])
.name('材质类型')
.onChange((value) => {
  // 根据选项切换材质
  switch (value) {
    case 'MeshBasicMaterial':
      mesh.material = new THREE.MeshBasicMaterial({ color: 0x00ff00 })
      break
    case 'MeshStandardMaterial':
      mesh.material = new THREE.MeshStandardMaterial({ color: 0x00ff00 })
      break
    case 'MeshPhongMaterial':
      mesh.material = new THREE.MeshPhongMaterial({ color: 0x00ff00 })
      break
  }
})

单选框

const params = {
  bool:true
  // ...其他参数
}

const render = () => {
    if(params.bool)mesh.rotateY(0.01)
    // ...其他内容
 }
 
gui.add(params,'bool').name('是否旋转')
效果图

image.png

gui分组

image.png

const gui = new GUI({ title: '调试面板', width: 300 })

const matForder = gui.addFolder('材质')

const lightForder = gui.addFolder('光照')

const otherForder = gui.addFolder('其他')

image.png

image.png

9、查询案例和文档(辅助开发)

image.png

不改后端、不开 Node,纯前端方案搞定 Canvas 跨域下载 —— wsrv.nl 野路子实战指南

适用场景:只能在浏览器 / no‑code 平台里写前端脚本,后端不给改、也不允许自建转发服务,但你又得把跨域图片画进 Canvas 并成功导出 / 下载——本文带你一步到位。

1. 跨域 Canvas 的“痛点复盘”

  1. 浏览器同源策略:<img> 的源域名 ≠ 页面域名 ⇒ 用它绘制的 Canvas 会被标记为 tainted

  2. tainted Canvas 上任何读操作(toDataURL() / getImageData() 等)都会抛 SecurityError,导出完全没戏。

  3. 常规解法:

    • 让图片服务器返回 Access-Control-Allow-Origin: *
    • 或者把图片转成同源资源(Base64、本地缓存、后端代理)。

    但——我们既改不了后端,也搭不起代理,怎么办?

2. 为什么是 wsrv.nl?

能力 说明
公共图片 CDN + 处理网关 你把原图 URL 丢给它,它帮你拉取 → 裁剪 / 转码 → 用 Cloudflare 全球缓存后返图。
自动加 CORS 头 响应里自带 Access-Control-Allow-Origin: *,Canvas 再也不会 tainted 🔍Shodan
免费额度友好 ≤ 2500 张未缓存请求 / 10 分钟 / 单 IP,足够大多数前端 / no‑code 场景 [🔍FAQ](images.weserv.nl/faq/index.h… "FAQ wsrv.nl")。
顺带图片优化 一行参数即可缩放、转 AVIF / PNG / WebP,甚至直接返 Base64 DataURL [🔍Format](images.weserv.nl/docs/format… "Format wsrv.nl")。

一句话:它既是“白嫖代理”,又是“前端图像处理器 + CDN 缓存”。

3. 三步拼出“可跨域可导出”的终极 URL

原图演示地址(带查询串):

https://avatars.githubusercontent.com/u/583231?s=640&v=4
步骤 操作 关键点
① URL‑encode encodeURIComponent(原地址)https%3A%2F%2Favatars.githubusercontent.com%2Fu%2F583231%3Fs%3D640%26v%3D4 ?/& 必须完整编码,否则 wsrv.nl 解析异常。
② 拼接 wsrv.nl https://wsrv.nl/?url=<编码后的地址> 到这一步,浏览器就能无跨域地渲染图片。
③ 追加处理参数(可选) 例如 &w=160&h=160&output=png 缩成 160×160 且转 PNG,更利于 Canvas 导出。所有参数可叠加 [🔍Quick‑ref](wsrv.nl/docs/quick-… "Quick reference wsrv.nl")。

最终示例

https://wsrv.nl/?url=https%3A%2F%2Favatars.githubusercontent.com%2Fu%2F583231%3Fs%3D640%26v%3D4&w=160&h=160&output=png

结语

不能改后端、也不开 Node 的刚性约束下,wsrv.nl 给了我们一条“代理 + CDN + CORS + 图像处理”一站式白嫖通路:

  • 仅改前端 URL,上线成本 ≈ 0;
  • Canvas 不再 tainted,导出任你折腾;
  • 顺带搞定缩放 / 转码 / Base64,一键多得。

按以上流程接入,你就能在任何 no‑code / 纯前端项目里,稳稳搞定跨域 Canvas 图片下载。祝撸码顺利!🚀

电商数据分析实战:利用 API 构建商品价格监控系统

在电商运营中,商品价格是影响转化率和竞争力的核心因素。手动跟踪竞品价格效率低下,而基于 API 构建的自动化价格监控系统能实时捕捉价格波动,为定价策略提供数据支撑。本文将从零开始,带您实现一套覆盖京东、淘宝双平台的商品价格监控系统,包含数据采集、存储、分析、告警全流程。
一、系统架构设计
价格监控系统需实现 “实时采集 - 时序存储 - 波动分析 - 智能告警” 的闭环,整体架构如下:

image.pngimage.png

核心模块说明:

  • 数据采集层:调用京东 / 淘宝 API,定时获取目标商品价格;
  • 数据清洗层:处理空值、格式转换、去重,确保数据一致性;
  • 时序存储层:存储价格历史数据(重点保留时间戳 + 价格字段);
  • 分析引擎:计算价格波动率、同比 / 环比,识别异常波动;
  • 告警系统:基于阈值(如降价 5%)触发通知(邮件 / 钉钉);
  • 可视化看板:展示价格趋势、竞品对比图表。

二、前置准备:API 接入与环境配置

1. 平台 API 权限申请

平台 所需凭证 核心接口 申请要点
京东 AppKey + AppSecret jingdong.ware.price.get 个人开发者可申请 “商品价格查询” 权限
淘宝 AppKey + AppSecret + Token taobao.item.price.get 需通过淘宝联盟备案,获取session

2. 开发环境配置

  • 编程语言:Python 3.8+(推荐)

  • 核心库

    • requests/aiohttp:API 请求(异步推荐aiohttp);
    • pymongo:MongoDB 数据库交互;
    • schedule:定时任务调度;
    • matplotlib:价格趋势可视化;
    • dingtalkchatbot:钉钉告警。
  • 环境安装

    bash

    pip install requests pymongo schedule matplotlib dingtalkchatbot
    

三、核心功能实现:从 API 调用到数据存储

1. 多平台 API 价格采集模块

京东 API 价格采集(含签名生成)

python

运行

import requests
import hashlib
import time

class JDPriceCollector:
    def __init__(self, app_key, app_secret):
        self.app_key = app_key
        self.app_secret = app_secret
        self.base_url = "https://api.jd.com/routerjson"
    
    def generate_sign(self, params):
        """生成京东API签名"""
        sorted_params = sorted(params.items(), key=lambda x: x[0])
        sign_str = self.app_secret
        for k, v in sorted_params:
            sign_str += f"{k}{v}"
        sign_str += self.app_secret
        return hashlib.md5(sign_str.encode()).hexdigest().upper()
    
    def get_price(self, sku_id):
        """获取单个商品价格"""
        params = {
            "app_key": self.app_key,
            "method": "jingdong.ware.price.get",
            "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
            "format": "json",
            "v": "2.0",
            "skuId": sku_id,
            "sign_method": "md5"
        }
        # 生成签名
        params["sign"] = self.generate_sign(params)
        
        try:
            response = requests.post(self.base_url, data=params, timeout=10)
            result = response.json()
            # 解析价格(京东API返回结构需按实际调整)
            price = result.get("jingdong_ware_price_get_response", {}) \
                        .get("price", {}) \
                        .get("p", 0)  # "p"为实际售价字段
            return {
                "platform": "jd",
                "sku_id": sku_id,
                "price": float(price),
                "crawl_time": time.time()
            }
        except Exception as e:
            print(f"京东API调用失败:{e}")
            return None

淘宝 API 价格采集(简化版)

python

运行

class TaobaoPriceCollector:
    def __init__(self, app_key, app_secret, session):
        self.app_key = app_key
        self.app_secret = app_secret
        self.session = session  # 用户授权session
        self.base_url = "https://eco.taobao.com/router/rest"
    
    def generate_sign(self, params):
        """淘宝签名生成(逻辑类似京东,需注意编码)"""
        sorted_params = sorted(params.items(), key=lambda x: x[0])
        sign_str = self.app_secret
        for k, v in sorted_params:
            sign_str += f"{k}{v}"
        sign_str += self.app_secret
        return hashlib.md5(sign_str.encode()).hexdigest().upper()
    
    def get_price(self, num_iid):
        """获取淘宝商品价格"""
        params = {
            "app_key": self.app_key,
            "method": "taobao.item.price.get",
            "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
            "format": "json",
            "v": "2.0",
            "num_iid": num_iid,
            "session": self.session,
            "sign_method": "md5"
        }
        params["sign"] = self.generate_sign(params)
        
        try:
            response = requests.post(self.base_url, data=params, timeout=10)
            result = response.json()
            price = result.get("item_price_get_response", {}) \
                        .get("price", {}) \
                        .get("price", 0)  # 淘宝售价字段
            return {
                "platform": "taobao",
                "sku_id": num_iid,
                "price": float(price),
                "crawl_time": time.time()
            }
        except Exception as e:
            print(f"淘宝API调用失败:{e}")
            return None

2. 数据存储设计:时序数据库选型与实现

价格数据是典型的时序数据(按时间戳有序生成),需支持高写入、时序查询(如 “过去 7 天价格”)。推荐使用MongoDB(灵活)或InfluxDB(专为时序优化)。

MongoDB 存储实现

python

运行

from pymongo import MongoClient
from datetime import datetime

class PriceStorage:
    def __init__(self, db_name="price_monitor"):
        self.client = MongoClient("mongodb://localhost:27017/")
        self.db = self.client[db_name]
        # 创建价格历史集合(按平台+SKU索引,加速查询)
        self.price_collection = self.db["price_history"]
        self.price_collection.create_index([("platform", 1), ("sku_id", 1), ("crawl_time", -1)])
    
    def save_price(self, price_data):
        """存储单条价格数据"""
        # 补充格式化时间(便于可视化)
        price_data["crawl_datetime"] = datetime.fromtimestamp(price_data["crawl_time"])
        self.price_collection.insert_one(price_data)
        print(f"已存储 {price_data['platform']} {price_data['sku_id']} 价格:{price_data['price']}")
    
    def get_price_history(self, platform, sku_id, days=7):
        """获取最近N天价格历史"""
        start_time = time.time() - days * 24 * 3600
        return list(self.price_collection.find({
            "platform": platform,
            "sku_id": sku_id,
            "crawl_time": {"$gte": start_time}
        }).sort("crawl_time", 1))  # 按时间升序

三、核心逻辑:价格监控与波动分析

1. 定时采集任务

使用schedule库实现定时采集,支持多平台、多商品并行监控:

python

运行

import schedule
import time
from concurrent.futures import ThreadPoolExecutor

class PriceMonitor:
    def __init__(self, jd_collector, taobao_collector, storage, interval=30):
        self.jd_collector = jd_collector
        self.taobao_collector = taobao_collector
        self.storage = storage
        self.interval = interval  # 采集间隔(分钟)
        self.monitor_list = {  # 监控商品列表:{平台: [SKU列表]}
            "jd": ["100012345678", "100012345679"],  # 京东SKU
            "taobao": ["598765432101"]  # 淘宝商品ID
        }
    
    def collect_single_sku(self, platform, sku_id):
        """采集单个商品价格"""
        try:
            if platform == "jd":
                price_data = self.jd_collector.get_price(sku_id)
            elif platform == "taobao":
                price_data = self.taobao_collector.get_price(sku_id)
            else:
                return
            
            if price_data:
                self.storage.save_price(price_data)
                # 采集后立即分析价格波动
                self.analyze_price_fluctuation(platform, sku_id)
        except Exception as e:
            print(f"采集 {platform} {sku_id} 失败:{e}")
    
    def batch_collect(self):
        """批量采集所有监控商品"""
        print(f"开始批量采集({time.ctime()})")
        with ThreadPoolExecutor(max_workers=5) as executor:  # 并发采集
            for platform, sku_list in self.monitor_list.items():
                for sku_id in sku_list:
                    executor.submit(self.collect_single_sku, platform, sku_id)
        print(f"批量采集完成({time.ctime()})")
    
    def start_monitoring(self):
        """启动监控任务"""
        # 立即执行一次
        self.batch_collect()
        # 定时执行
        schedule.every(self.interval).minutes.do(self.batch_collect)
        while True:
            schedule.run_pending()
            time.sleep(60)

2. 价格波动分析:识别异常与趋势

分析模块需计算价格变化率历史最低 / 最高价,并通过阈值判断是否触发告警: python

运行

class PriceAnalyzer:
    def __init__(self, storage, threshold=0.05):  # 价格波动阈值(5%)
        self.storage = storage
        self.threshold = threshold  # 降价5%以上触发告警
    
    def get_price_change_rate(self, platform, sku_id):
        """计算最近一次价格与上一次的变化率"""
        # 获取最近两条价格记录
        history = self.storage.get_price_history(platform, sku_id, days=1)  # 1天内数据
        if len(history) < 2:
            return 0.0  # 数据不足,不计算
        
        # 按时间排序(旧→新)
        history_sorted = sorted(history, key=lambda x: x["crawl_time"])
        prev_price = history_sorted[-2]["price"]
        current_price = history_sorted[-1]["price"]
        
        # 计算变化率((当前-上次)/上次)
        if prev_price == 0:
            return 0.0
        return (current_price - prev_price) / prev_price
    
    def analyze_price_fluctuation(self, platform, sku_id):
        """分析价格波动,触发告警"""
        change_rate = self.get_price_change_rate(platform, sku_id)
        if abs(change_rate) >= self.threshold:
            # 获取当前价格
            current_price = self.storage.get_price_history(platform, sku_id, days=1)[-1]["price"]
            # 构建告警信息
            trend = "降价" if change_rate < 0 else "涨价"
            msg = (f"【价格异动告警】\n"
                   f"平台:{platform}\n"
                   f"商品ID:{sku_id}\n"
                   f"当前价格:{current_price}元\n"
                   f"波动幅度:{change_rate*100:.2f}%\n"
                   f"时间:{time.ctime()}")
            # 发送告警
            self.send_alert(msg)
            print(msg)
    
    def send_alert(self, msg):
        """发送告警(支持钉钉/邮件)"""
        # 钉钉告警示例(需提前创建机器人)
        from dingtalkchatbot.chatbot import DingtalkChatbot
        webhook = "https://oapi.dingtalk.com/robot/send?access_token=你的token"
        bot = DingtalkChatbot(webhook)
        bot.send_text(msg=msg, is_at_all=False)  # 不@所有人

四、系统部署与扩展

1. 部署步骤

  1. 配置 API 凭证:将京东 / 淘宝的app_keyapp_secret等写入环境变量(避免硬编码);

  2. 初始化数据库:启动 MongoDB,创建price_monitor数据库;

  3. 启动监控

    python

    运行

    if __name__ == "__main__":
        # 初始化组件
        jd_collector = JDPriceCollector(app_key="你的京东app_key", app_secret="你的京东app_secret")
        taobao_collector = TaobaoPriceCollector(
            app_key="你的淘宝app_key", 
            app_secret="你的淘宝app_secret",
            session="你的淘宝session"
        )
        storage = PriceStorage()
        analyzer = PriceAnalyzer(storage)
        
        # 启动监控
        monitor = PriceMonitor(jd_collector, taobao_collector, storage, interval=30)
        monitor.start_monitoring()
    

2. 扩展方向

  • 多平台支持:接入拼多多、亚马逊 API,实现跨平台价格对比;
  • 可视化看板:用 Flask+ECharts 构建实时价格趋势图;
  • 智能预测:基于 LSTM 模型预测未来价格走势;
  • 竞品自动关联:通过商品标题分词,自动匹配同款竞品。

五、实战避坑指南

问题场景 解决方案
API 调用频繁被限流 1. 分散采集时间(如不同商品错开 1 分钟) 2. 申请更高权限 3. 使用代理 IP 池
价格数据缺失 / 重复 1. 采集时添加重试机制 2. 存储前去重(按 SKU + 时间戳) 3. 标记异常值(如价格 = 0)
告警风暴(频繁触发) 1. 增加 “冷静期”(1 小时内同一商品不重复告警) 2. 分层阈值(如 5%→10%→20% 阶梯告警)
签名错误排查困难 1. 日志记录完整签名参数拼接过程 2. 用官方签名工具验证本地生成的签名

总结

通过本文的实战方案,您可以构建一套稳定的商品价格监控系统,实现从 API 数据采集到智能告警的全自动化。核心价值在于:

  1. 实时性:分钟级更新,捕捉转瞬即逝的价格波动;
  2. 可扩展性:支持多平台、多商品,随业务需求灵活扩展;
  3. 决策支撑:基于数据而非经验制定定价策略,提升竞争力。

后续可重点优化 “竞品自动发现” 和 “价格预测” 功能,让系统从 “被动监控” 升级为 “主动决策辅助

React中的forwardRef:打破父子组件间的"隔墙"

大家好,我是你们的老朋友FogLetter,今天我们来聊聊React中一个非常实用但容易被忽视的API——forwardRef。这个API就像是在父子组件之间架起了一座桥梁,让我们能够"穿透"组件边界直接访问子组件中的DOM元素或组件实例。

为什么需要forwardRef?

在React的世界里,props是父子组件通信的主要方式,但有时候我们需要更"直接"的访问。想象一下这个场景:

function Parent() {
  const inputRef = useRef(null);
  
  useEffect(() => {
    inputRef.current.focus(); // 想在组件挂载后自动聚焦输入框
  }, []);
  
  return <Child ref={inputRef} />;
}

function Child() {
  return <input type="text" />;
}

这段代码看起来合理,但实际上会报错!为什么呢?因为ref并不是一个真正的prop,它不会被自动传递给子组件。这就是React设计中的一个特殊之处——ref默认情况下是不会向下传递的。

forwardRef的基本用法

这时候,forwardRef就派上用场了。它就像是给组件装了一个"透明窗口",让ref可以穿透组件直接到达内部的DOM节点或组件。

让我们改造上面的例子:

const Child = forwardRef(function Child(props, ref) {
  return <input type="text" ref={ref} />;
});

function Parent() {
  const inputRef = useRef(null);
  
  useEffect(() => {
    inputRef.current.focus();
  }, []);
  
  return <Child ref={inputRef} />;
}

现在,代码可以正常工作啦!forwardRef接收一个渲染函数,这个函数会接收props和ref两个参数,我们需要手动将这个ref传递给内部的DOM元素。

从生活场景理解forwardRef

想象你是一个餐厅经理(父组件),你的服务员(子组件)负责接待顾客。通常,你会通过服务员与顾客交流(props传递)。但有时候,你需要直接与某位VIP顾客(DOM节点)对话。forwardRef就像是你给服务员的一个特殊对讲机,让你可以直接与VIP顾客沟通,而不需要经过服务员转达。

forwardRef的进阶用法

1. 与高阶组件结合

forwardRef在高阶组件(HOC)中特别有用。假设我们有一个withLogging的高阶组件:

function withLogging(WrappedComponent) {
  return forwardRef(function WithLogging(props, ref) {
    useEffect(() => {
      console.log('Component mounted');
      return () => console.log('Component unmounted');
    }, []);
    
    return <WrappedComponent {...props} ref={ref} />;
  });
}

const LoggedInput = withLogging(Input);

这样,即使经过高阶组件包装,ref也能正确传递到底层组件。

2. 转发多个ref

有时候我们需要转发多个ref,可以通过将ref作为prop传递:

function FancyInput(props) {
  const inputRef = useRef();
  const divRef = useRef();
  
  // 将refs暴露给父组件
  useImperativeHandle(props.forwardedRef, () => ({
    input: inputRef.current,
    div: divRef.current
  }));
  
  return (
    <div ref={divRef}>
      <input ref={inputRef} />
    </div>
  );
}

const ForwardedFancyInput = forwardRef((props, ref) => (
  <FancyInput {...props} forwardedRef={ref} />
));

这种方式既保持了封装性,又提供了必要的控制能力。

实际应用场景

1. 表单自动聚焦

const AutoFocusInput = forwardRef(function AutoFocusInput(props, ref) {
  const inputRef = useRef();
  
  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current.focus()
  }));
  
  useEffect(() => {
    inputRef.current.focus();
  }, []);
  
  return <input {...props} ref={inputRef} />;
});

function LoginForm() {
  const usernameRef = useRef();
  const passwordRef = useRef();
  
  useEffect(() => {
    usernameRef.current.focus();
  }, []);
  
  return (
    <form>
      <AutoFocusInput ref={usernameRef} placeholder="用户名" />
      <AutoFocusInput ref={passwordRef} placeholder="密码" />
    </form>
  );
}

2. 第三方组件集成

当你需要集成第三方组件库,但又需要访问其内部DOM元素时:

const FancyThirdPartyInput = forwardRef(function(props, ref) {
  return <ThirdPartyInput {...props} innerRef={ref} />;
});

3. 动画控制

const AnimatedBox = forwardRef(function(props, ref) {
  const boxRef = useRef();
  
  useImperativeHandle(ref, () => ({
    animate: () => {
      boxRef.current.animate(...);
    }
  }));
  
  return <div ref={boxRef} className="box" />;
});

function App() {
  const boxRef = useRef();
  
  return (
    <div>
      <AnimatedBox ref={boxRef} />
      <button onClick={() => boxRef.current.animate()}>开始动画</button>
    </div>
  );
}

注意事项

  1. 不要滥用forwardRef:大多数情况下,props已经足够满足组件通信需求。只有在确实需要直接访问DOM节点或组件实例时才使用forwardRef。

  2. 性能考虑:forwardRef创建的组件会有一个额外的渲染层,虽然影响很小,但在性能敏感的场景需要考虑。

  3. 测试影响:使用forwardRef后,测试策略可能需要调整,因为你现在可以直接访问子组件的内部实现。

与Context结合使用

forwardRef也可以与Context API结合使用,实现更灵活的组件设计:

const ThemeContext = createContext('light');

const ThemedButton = forwardRef(function(props, ref) {
  const theme = useContext(ThemeContext);
  
  return (
    <button
      ref={ref}
      style={{ background: theme === 'dark' ? '#333' : '#eee' }}
      {...props}
    />
  );
});

function App() {
  const buttonRef = useRef();
  
  return (
    <ThemeContext.Provider value="dark">
      <ThemedButton ref={buttonRef}>Click me</ThemedButton>
    </ThemeContext.Provider>
  );
}

总结

forwardRef是React中一个强大的工具,它打破了组件之间的"隔墙",让我们能够在需要时直接访问子组件的DOM节点或实例。但正如蜘蛛侠的叔叔所说:"能力越大,责任越大",我们应该谨慎使用这个功能,避免破坏组件的封装性。

记住以下几个要点:

  1. 默认情况下,ref不会自动传递
  2. forwardRef可以让你显式地将ref传递给子组件
  3. 结合useImperativeHandle可以控制暴露的内容
  4. 在第三方组件集成、表单控制、动画管理等场景特别有用

希望这篇文章能帮助你更好地理解和使用forwardRef。如果你觉得有用,别忘了点赞收藏,我们下期再见!

深度SEO优化实战:从电商案例看技术层面如何提升300%搜索流量

一、问题场景:电商列表页的SEO困局

项目背景
某家具电商网站商品列表页(/category/sectionals)存在三大问题:

  1. 移动端加载速度8.3s(Google测速评分28/100)
  2. 搜索引擎仅收录页面框架,动态内容不被索引
  3. 用户在搜索“布艺沙发 北欧风”时,页面未进入前10结果

技术痛点分析

graph TD
    A[JS渲染内容] --> B[爬虫无法解析]
    C[未压缩图片] --> D[加载缓慢]
    E[缺乏结构化数据] --> F[搜索摘要不完整]
    G[未适配Core Web Vitals] --> H[排名降权]

二、解决方案:四维技术优化实践

1. 渲染方式优化:SSR替换CSR

旧方案问题:React纯客户端渲染导致爬虫获取空HTML
改造方案

// next.config.js 开启SSR+增量静态生成
module.exports = {
  experimental: {
    reactMode: 'concurrent',
  },
  async rewrites() {
    return [
      {
        source: '/category/:slug',
        destination: '/category/[slug]?isSSR=true', // 🔍 SSR开关
      }
    ]
  }
}

// 列表页组件
export async function getStaticProps({ params }) {
  const products = await fetch(API_ENDPOINT + params.slug).then(res => res.json());
  return {
    props: { products },
    revalidate: 3600 // 🔍 每小时增量更新
  };
}

关键技术解析

  • getStaticProps 在构建时预取数据,生成静态HTML
  • revalidate 实现增量静态再生(ISR),平衡实时性与SEO
  • 通过URL参数控制SSR开关,灰度发布更安全

效果对比

指标 CSR方案 SSR方案
TTFB(首字节) 2.3s 380ms
爬虫索引率 12% 100%

2. 性能优化:关键指标突破

Core Web Vitals优化前后对比

graph LR
    A[LCP 8.3s] -->|图片懒加载| B[LCP 1.2s]
    C[FID 320ms] -->|代码分割| D[FID 45ms]
    E[CLS 0.45] -->|尺寸占位| F[CLS 0.02]

图片加载优化代码

<img 
  src="placeholder.webp" 
  data-src="real-product.webp"
  alt="北欧风布艺沙发 - 环保棉麻材质"
  width="600" 
  height="400"
  class="lazyload"
  loading="lazy" >
  
<script>
// 🔍 可视区域加载
document.addEventListener("DOMContentLoaded", () => {
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        observer.unobserve(img);
      }
    });
  }, { threshold: 0.01 });
  
  document.querySelectorAll('.lazyload').forEach(img => {
    observer.observe(img);
  });
});
</script>

核心优化点

  • loading="lazy" 原生懒加载降服务器压力
  • IntersectionObserver API实现精确可视区域加载
  • 尺寸占位属性避免布局偏移(CLS问题)

3. 内容可访问性增强

问题根源:爬虫无法解析JSON-LD导致商品信息缺失
结构化数据注入

// 商品列表页头注入
export default function CategoryPage({ products }) {
  const structuredData = {
    "@context": "https://schema.org",
    "@type": "ItemList",
    "itemListElement": products.map((product, index) => ({
      "@type": "ListItem",
      "position": index + 1,
      "item": {
        "@id": `https://example.com/products/${product.id}`,
        "name": product.title,
        "image": product.thumbnails[0],
        "offers": {
          "@type": "Offer",
          "priceCurrency": "CNY",
          "price": product.price
        }
      }
    }))
  };

  return (
    <>
      <script type="application/ld+json">
        {JSON.stringify(structuredData)}
      </script>
      {/* 页面内容 */}
    </>
  );
}

验证效果

+ 搜索结果富媒体展示:
+ 商品价格 | 图片预览 | 直接购买按钮
- 原普通文本摘要

4. 爬虫协作优化

robots.txt策略调整

User-agent: *  
Allow: /category/*
Disallow: /api/
Disallow: /cart

# 🔍 动态渲染区分
User-agent: Googlebot
Allow: /dynamic-render/

Sitemap: https://example.com/sitemap-2025.xml

预渲染检测中间件

// 识别爬虫的User-Agent
const botUserAgents = [
  'Googlebot', 
  'Bingbot',
  'YandexBot'
];

app.use((req, res, next) => {
  const userAgent = req.headers['user-agent'] || '';
  
  if (botUserAgents.some(bot => userAgent.includes(bot))) {
    req.isBot = true; 
    req.botName = userAgent.match(/(Googlebot|Bingbot|YandexBot)/)?.[0];
  }
  next();
});

// 路由处理中
app.get('/category/:slug', (req, res) => {
  if (req.isBot) {
    return renderSSRForBot(req, res); // 返回完整HTML
  }
  return serveCSRBundle(req, res); // 返回客户端渲染包
});

三、底层原理深度剖析

现代爬虫工作原理

sequenceDiagram
    participant S as 搜索引擎爬虫
    participant C as CDN
    participant A as 应用服务器
    
    S->>C: 请求URL
    alt 静态资源
        C->>S: 返回预缓存HTML
    else 动态页面
        C->>A: 转发请求(带User-Agent)
        A->>A: 识别Googlebot
        A->>A: 启用SSR渲染
        A->>S: 返回完整HTML
    end
    S->>S: 解析结构化数据
    S->>索引库: 存储语义化内容

技术方案对比

维度 客户端渲染(CSR) 服务端渲染(SSR) 混合渲染(ISR)
SEO支持 ❌ 几乎不可用 ✅ 完整支持 ✅ 支持(需配置)
TTFB 200-500ms 300-800ms 50-200ms(缓存命中)
开发成本
适用场景 后台管理系统 内容型网站 电商/资讯平台

四、多环境配置方案

全栈SEO配置片段

// seo.config.js (全栈通用)
module.exports = {
  minifyHTML: true, // HTML压缩
  imageOptim: {
    format: 'webp', // 优先WebP格式
    quality: 80,    // 质量比
    sizes: [480, 768, 1200] // 响应式断点
  },
  structuredData: {
    enable: true,
    maxProductCount: 50 // 单页最多注入商品数
  },
  botDetection: {
    enable: true,
    agents: ['Googlebot', 'Bingbot', 'Bytespider']
  }
};

// 环境适配说明:
// 开发环境:disable imageOptim
// 生产环境:开启所有优化+CDN缓存

五、举一反三:变体场景方案

1. 媒体站视频内容优化

// 视频结构化数据
{
  "@type": "VideoObject",
  "name": "北欧风装修教程",
  "description": "如何用沙发改变客厅风格...",
  "thumbnailUrl": "/thumbnails/video1.jpg",
  "uploadDate": "2025-07-22",
  "duration": "PT5M33S" // 🔍 ISO 8601时长格式
}

2. 新闻站实时索引优化

// next.config.js 动态路径再生
export async function getStaticPaths() {
  const newsIds = await fetchLatestNewsIds(); // 获取最新ID
  return {
    paths: newsIds.map(id => ({ params: { id } })),
    fallback: 'blocking' // 🔍 首次访问时生成
  };
}

3. SaaS平台用户生成内容SEO

// 防作弊签名方案
function generateSEOSafeHTML(userContent) {
  const allowedTags = ['p', 'h2', 'h3', 'ul', 'li'];
  const cleanContent = sanitizeHTML(userContent, {
    allowedTags,
    allowedAttributes: {}
  });
  
  // 🔍 签名确保内容完整性
  const sign = crypto.createHash('md5').update(cleanContent).digest('hex');
  return `<div data-seo-sign="${sign}">${cleanContent}</div>`;
}

六、工程师的SEO思维转变

2025年技术SEO新认知

  1. 体验即排名:Core Web Vitals已成硬性指标,Google明确表示LCP每提升100ms转化率下降7%
  2. AI友好内容:结构化数据是为LLM提供语义理解的关键跳板
  3. 边缘计算赋能:Cloudflare Workers等边缘SSR方案使TTFB突破100ms瓶颈

📌 行动准则:每次提交代码前自查

  • 是否影响LCP/FID/CLS?
  • 爬虫看到的HTML是否包含关键内容?
  • 结构化数据是否符合schema.org规范?

微搭低代码教程四:云函数、云数据库

一、云函数

  • 云函数即在云端(服务器端)运行的函数:

    • 在物理设计上,一个云函数可由多个文件组成,占用一定量的CPU 内存等计算资源;
    • 各云函数完全独立,可分别部署在不同的地区;
    • 开发者无需购买、搭建服务器,只需编写函数代码并部署到云端即可在小程序端调用;
    • 同时云函数之间也可互相调用
  • 云函数的编写方式:

    • 一个云函数的写法与一个在本地定义的 JavaScript 方法无异,代码运行在云端 Node.js 中;
    • 当云函数被小程序端调用时,定义的代码会被放在Node.js 运行环境中执行;
    • 我们可以如在 Node.js 环境中使用 JavaScript 一样在云函数中进行网络请求等操作,而且我们还可以通过云函数后端 SDK搭配使用多种服务,比如使用云函数 SDK 中提供的数据库和存储 API进行数据库和存储的操作
  • 云开发的云函数的独特优势在于与微信登录鉴权的无缝整合

    • 当小程序端调用云函数时 云函数的传入参数中会被注入小程序端用户的 openid,开发者无需校验 openid 的正确性因为微 信已经完成了这部分鉴权,开发者可以直接使用该 openid

云函数 在传统开发模式中充当的角色就是接口。API 调用云函数,云函数去操作数据库后把返回值响应给页面。

13.png


二、创建云函数

您可以在 云开发开放平台 单击云函数 > 新建云函数
  • 1 新建云函数
  • 2 在线开发

14.png

15.png

在线开发云函数

16.png

17.png

本例云函数为“executorHandler”,在该目录中:

  • node_modules 为该云函数的依赖,需要通过 npm install xx 去安装相关依赖
  • index.js 云函数主文件(在该文件中编写业务代码增、删、改、查)
  • package.json 该云函数配置

二、编写云函数

  • 新增 add()
  • 删除 remove()
  • 更新 aupdate()
  • 查询 get()

2.1 引入及初始化

const cloud = require("wx-server-sdk");
cloud.init({
      env: "dev-1234567890", // 使用当前云环境
});

const db = cloud.database();
const _ = db.command;

2.2 新增

场景:往数据库表“user”添加一个用户

const cloud = require("wx-server-sdk");
cloud.init({
      env: "dev-1234567890", // 使用当前云环境
});

const db = cloud.database();
const _ = db.command;

exports.main = async (event, context) => {
      // event 为 APIs 传入的参数;context 为环境变量,包含了openId、appId等
      const { api } = event;

      switch (api) {
            // 新增
            case "onAdd":
                  return await onAddHandler(event, context);
            default:
                  return {
                        code: -1,
                        msg: "api not found",
                  };
      }
};

// 新增
async function onAddHandler(event, context) {
      const transaction = await db.startTransaction();  // 开始事务
      let result;

      try {
            result = await transaction.collection("user").add({
                  data: [{
                        openid: context.OPENID,
                        name: event.userName,
                        status: event.status,
                        createTime: Date.now()
                  }]
            });
            transaction.commit(); // 提交事务
      } catch (error) {
            transaction.rollback();
            throw new Error("unknwon error");
      }
      return result;
}

2.3 删除

场景:在数据库“user”表中删除某一个用户

const cloud = require("wx-server-sdk");
cloud.init({
      env: "dev-1234567890", // 使用当前云环境
});

const db = cloud.database();
const _ = db.command;

exports.main = async (event, context) => {
      // event 为 APIs 传入的参数;context 为环境变量,包含了openId、appId等
      const { api } = event;

      switch (api) {
            // 删除
            case "onDelete":
                  return await onDeleteHandler(event, context);
            default:
                  return {
                        code: -1,
                        msg: "api not found",
                  };
      }
};

// 删除
async function onDeleteHandler(event, context) {
      const transaction = await db.startTransaction();  // 开始事务
      let result;
      try {
            result = await transaction.collection("user")
                  .where({ 
                        _id: event._id 
                  })
                  .remove();
            transaction.commit(); // 提交事务
            return {
                  code: 0,
                  message: '删除成功',
                  data: result
            };
      } catch (error) {
            transaction.rollback();
            throw new Error("unknwon error");
      }
      return result;
}

2.4 更新

场景:在数据库“user”表中更新某一个用户的状态

const cloud = require("wx-server-sdk");
cloud.init({
      env: "dev-1234567890", // 使用当前云环境
});

const db = cloud.database();
const _ = db.command;

exports.main = async (event, context) => {
      // event 为 APIs 传入的参数;context 为环境变量,包含了openId、appId等
      const { api } = event;
      
      switch (api) {
            // 更新
            case "onUpdate":
                  return await onUpdateHandler(event, context);
            default:
                  return {
                        code: -1,
                        msg: "api not found",
                  };
      }
};

// 更新
async function onUpdateHandler(event, context) {
      const transaction = await db.startTransaction();  // 开始事务
      let result;
      try {
            result = await transaction.collection("user")
                  .where({ 
                        _id: event._id 
                  })
                  .update({
                        data: {
                              STATUS: "1",
                              UPDATE_TIME: Date.now()
                        }
                  });
            transaction.commit(); // 提交事务
            return {
                  code: 0,
                  message: '更新成功',
                  data: result
            };
      } catch (error) {
            transaction.rollback();
            throw new Error("unknwon error");
      }
      return result;
}

2.5 查询

场景:查询数据库表“user”中所有状态为“1”的数据

const cloud = require("wx-server-sdk");
cloud.init({
      env: "dev-1234567890", // 使用当前云环境
});

const db = cloud.database();
const _ = db.command;

exports.main = async (event, context) => {
      // event 为 APIs 传入的参数;context 为环境变量,包含了openId、appId等
      const { api } = event;

      switch (api) {
            // 查询
            case "onGet":
                  return await onGetListHandler(event, context);
            default:
                  return {
                        code: -1,
                        msg: "api not found",
                  };
      }
};

// 查询
async function onGetListHandler(event, context) {
      let result = await transaction.collection("user")
            .where({ 
                  status: "1"
            })
            .get();
      return result;
}

2.6 调试 & 部署

右键点击云函数,可以对云函数进行调试和部署。测试云函数是否通过,点击“调试”;通过 APIs 方式联调前,需对云函数进行部署。

18.png


三、云数据库

在这里,数据库我们选择“文档型”。

19.png

集合名称为表名,权限设置可按实际需求选择,点击“确定”即创建“user”表成功。(表名不可重复)

20.png

babel\corejs\postcss配置

一、背景

在低版本浏览器中,会出现页面打不开或者样式不如意的情况,这种就是本文需要处理的兼容性问题,一般分为两类

二、js问题

image.png 上面就是典型的使用了新的api在旧浏览器中不支持造成的;

解决方式

(1)配置项目中支持的浏览器版本,后面的postcss也会默认按这个文件来兼容

// 新建.browserslistrc
chrome >= 72
edge >= 79
firefox >= 70
safari >= 12
ios_saf >= 12

(2)配置babel

// 新建babel.config.js
module.exports = {
  presets: [
    [
      "next/babel",
      {
        "preset-env": {
          targets: {
            chrome: "72"
          },
          useBuiltIns: "usage", // 按需引入
          corejs: "3" // 使用corejs3版本
        }
      }
    ]
  ],
  plugins: [
    "@babel/plugin-proposal-optional-chaining", // 支持可选链操作符 ?.
    "@babel/plugin-proposal-nullish-coalescing-operator", // 支持空值合并操作符 ??
    // 其他需要的插件
  ]
}

(3)单独引入corejs的polyfill

// 在项目入口处配置
//提供了JavaScript标准库的polyfill(如Promise、Symbol、Array.prototype.includes等)
import 'core-js/stable';
// 支持async/await语法和生成器函数(generator functions)的运行时
import 'regenerator-runtime/runtime';

三、css问题

有时候引入新的css方法,旧浏览器可能不支持,比如@layer css变量等

// 新建postcss.config.cjs
module.exports = {
  plugins: [
    'tailwindcss',
    ['postcss-preset-env', {
      stage: 3, // 启用稳定的CSS特性
      features: {
        'custom-properties': true, // 关键:启用CSS变量polyfill
        'nesting-rules': true, // 嵌套属性支持
      },
      browsers: 'chrome >= 72' // 明确指定目标浏览器
    }],
    'autoprefixer',
    'postcss-flexbugs-fixes' // 修复Flexbox相关bug
  ]
}

四、查看浏览器中某些属性兼容性网站

查看某些属性能否使用: caniuse.com/

收费(免费1h试用)可以实际选择真实的浏览器版本:accounts.saucelabs.com/am/XUI/#log…

五、问题思考: 为什么有些polyfill不配置在babel中,需要在全局引入

全局引入的优势:

  1. 确保全局可用性

    • 某些第三方库可能依赖这些polyfill,全局引入可以确保它们在任何地方都可用
    • 避免因Babel配置不同导致的部分文件缺少polyfill
  2. 避免重复引入

    • 在Babel中配置useBuiltIns: 'usage'可能导致不同文件重复引入相同的polyfill
    • 全局引入一次可以优化打包体积
  3. 更可控

    • 明确知道项目中使用了哪些polyfill
    • 避免Babel自动按需引入时可能遗漏某些polyfill

与Babel配置的差异:

  1. Babel配置方式

    • useBuiltIns: 'entry' + 在入口文件导入core-js → 类似于手动全局引入
    • useBuiltIns: 'usage' → Babel会分析代码并按需自动引入polyfill
  2. 体积差异

    • 全局引入通常会包含更多polyfill(特别是使用core-js/stable时)
    • Babel按需引入(usage)通常体积更小,但可能不够全面
  3. 维护性

    • 手动引入更直观,但需要手动更新
    • Babel配置更自动化,但配置复杂度较高

推荐做法

现代前端项目通常推荐:

  1. 对于应用项目:全局引入核心polyfill,确保稳定性
  2. 对于库开发:使用Babel按需转换,避免污染全局环境*
❌