阅读视图

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

每日一题-重复 K 次的最长子序列🔴

给你一个长度为 n 的字符串 s ,和一个整数 k 。请你找出字符串 s重复 k 次的 最长子序列

子序列 是由其他字符串删除某些(或不删除)字符派生而来的一个字符串。

如果 seq * ks 的一个子序列,其中 seq * k 表示一个由 seq 串联 k 次构造的字符串,那么就称 seq 是字符串 s 中一个 重复 k 的子序列。

  • 举个例子,"bba" 是字符串 "bababcba" 中的一个重复 2 次的子序列,因为字符串 "bbabba" 是由 "bba" 串联 2 次构造的,而 "bbabba" 是字符串 "bababcba" 的一个子序列。

返回字符串 s重复 k 次的最长子序列  。如果存在多个满足的子序列,则返回 字典序最大 的那个。如果不存在这样的子序列,返回一个 字符串。

 

示例 1:

example 1

输入:s = "letsleetcode", k = 2
输出:"let"
解释:存在两个最长子序列重复 2 次:let" 和 "ete" 。
"let" 是其中字典序最大的一个。

示例 2:

输入:s = "bb", k = 2
输出:"b"
解释:重复 2 次的最长子序列是 "b" 。

示例 3:

输入:s = "ab", k = 2
输出:""
解释:不存在重复 2 次的最长子序列。返回空字符串。

 

提示:

  • n == s.length
  • 2 <= k <= 2000
  • 2 <= n < k * 8
  • s 由小写英文字母组成

最简洁易懂的方法:利用字母频率

如果一个字母频率为freq,那么其可能参与组成的子串最多为freq//k个,因此我们只需要统计s中各个字母出现的频率,进行倒序排列便于后续能够直接筛选出首字母最大的子串,然后频率满足要求的字母组合起来成为新的串hot

接着我们求出hot全部子串的全排列,然后依次判断是否属于s,第一个满足要求的即为所求

class Solution:
    def longestSubsequenceRepeatedK(self, s: str, k: int) -> str:
        num = Counter(s)
        hot = ''.join(ele * (num[ele] // k) for ele in sorted(num, reverse=True))
        for i in range(len(hot), 0, -1):
            for item in permutations(hot, i):
                word = ''.join(item)
                ss = iter(s)
                if all(c in ss for c in word * k):
                    return word
        return ''

注意在判断是否属于s时,利用iter()函数生成迭代器是个非常巧妙的选择,比直接for循环判断要更加简洁高效

枚举排列 + 判断子序列(Python/Java/C++/Go)

分析

如果 $\textit{seq} * k$ 是 $s$ 的子序列,必须满足:

  • $\textit{seq}$ 中的每个字母在 $s$ 中至少出现 $k$ 次。

示例 1 的 $s=\texttt{letsleetcode}$,$k=2$,其中 $\texttt{s},\texttt{c},\texttt{o},\texttt{d}$ 这些只出现一次的字母,一定不在 $\textit{seq}$ 中。只有 $\texttt{l},\texttt{e},\texttt{t}$ 这些至少出现 $k$ 次的字母,才可能在 $\textit{seq}$ 中。

分析到这里,题目的这个奇怪的数据范围 $n < 8k$ 就有用了。

设有 $x$ 个字母至少出现 $k$ 次,那么

$$
kx\le n
$$

$$
x \le \dfrac{n}{k} < 8
$$

所以至多有 $7$ 个字母至少出现 $k$ 次。

枚举排列

枚举从 $7$ 个字母中选出 $i=1,2,3,4,5,6,7$ 个字母的排列,枚举次数为

$$
A_7^1 + A_7^2 + A_7^3 + A_7^4 + A_7^5 + A_7^6 + A_7^7 = 13699
$$

可以接受。

在示例 1 中,至少出现 $k=2$ 次的字母有 $\texttt{l},\texttt{e},\texttt{t}$。其中字母 $\texttt{e}$ 出现了 $4$ 次,这意味着 $\textit{seq}$ 中可以有 $\left\lfloor\dfrac{4}{k}\right\rfloor=2$ 个 $\texttt{e}$。所以我们枚举的是 $a=[\texttt{l},\texttt{e},\texttt{e},\texttt{t}]$ 的 47. 全排列 II我的题解。注意排列中有重复元素。

优化:题目要求子序列尽量长,所以优先枚举更长的排列。此外,把 $a$ 从大到小排序,这样长度相同时,字典序大的先枚举到。所以我们枚举到的第一个符合要求的排列就是答案。

判断子序列

设当前枚举的排列为 $\textit{seq}$,我们需要判断 $\textit{seq} * k$ 是否为 $s$ 的子序列。

做法见 392. 判断子序列我的题解

优化前

class Solution:
    # 392. 判断子序列
    # 返回 seq 是否为 s 的子序列
    def isSubsequence(self, seq: str, s: str) -> bool:
        it = iter(s)
        return all(c in it for c in seq)  # in 会消耗迭代器

    def longestSubsequenceRepeatedK(self, s: str, k: int) -> str:
        cnt = Counter(s)
        a = [ch for ch, freq in cnt.items() for _ in range(freq // k)]
        a.sort(reverse=True)

        for i in range(len(a), 0, -1):
            for perm in permutations(a, i):  # a 的长为 i 的排列
                seq = ''.join(perm)
                if self.isSubsequence(seq * k, s):  # seq*k 是 s 的子序列
                    return seq
        return ''
class Solution {
    private char[] ans;
    private int ansLen = 0;

    public String longestSubsequenceRepeatedK(String S, int k) {
        char[] s = S.toCharArray();

        int[] cnt = new int[26];
        for (char c : s) {
            cnt[c - 'a']++;
        }

        StringBuilder tmp = new StringBuilder();
        // 倒序,这样我们可以优先枚举字典序大的排列
        for (int i = 25; i >= 0; i--) {
            String c = String.valueOf((char) ('a' + i));
            tmp.append(c.repeat(cnt[i] / k));
        }
        char[] a = tmp.toString().toCharArray();

        ans = new char[a.length];
        permute(a, k, s);

        return new String(ans, 0, ansLen);
    }

    // 47. 全排列 II
    // 枚举从 nums 中选任意个数的所有排列,处理枚举的排列
    private void permute(char[] nums, int k, char[] s) {
        int n = nums.length;
        char[] path = new char[n];
        boolean[] onPath = new boolean[n]; // onPath[j] 表示 nums[j] 是否已经填入排列
        dfs(0, nums, path, onPath, k, s);
    }

    private void dfs(int i, char[] nums, char[] path, boolean[] onPath, int k, char[] s) {
        // 处理当前排列 path
        process(path, i, k, s);

        if (i == nums.length) {
            return;
        }

        // 枚举 nums[j] 填入 path[pathLen]
        for (int j = 0; j < nums.length; j++) {
            // 如果 nums[j] 已填入排列,continue
            // 如果 nums[j] 和前一个数 nums[j-1] 相等,且 nums[j-1] 没填入排列,continue
            if (onPath[j] || j > 0 && nums[j] == nums[j - 1] && !onPath[j - 1]) {
                continue;
            }
            path[i] = nums[j]; // 填入排列
            onPath[j] = true; // nums[j] 已填入排列(注意标记的是下标,不是值)
            dfs(i + 1, nums, path, onPath, k, s); // 填排列的下一个数
            onPath[j] = false; // 恢复现场
            // 注意 path 无需恢复现场,直接覆盖 path[i] 就行
        }
    }

    private void process(char[] seq, int seqLen, int k, char[] s) {
        // 先比大小(时间复杂度低),再判断是否为子序列(时间复杂度高)
        if (seqLen > ansLen || seqLen == ansLen && compare(seq, ans, ansLen) > 0) {
            if (isSubsequence(seq, seqLen, k, s)) {
                System.arraycopy(seq, 0, ans, 0, seqLen);
                ansLen = seqLen;
            }
        }
    }

    // 比较 a 和 b 的字典序大小
    private int compare(char[] a, char[] b, int n) {
        for (int i = 0; i < n; i++) {
            if (a[i] != b[i]) {
                return a[i] - b[i];
            }
        }
        return 0;
    }

    // 392. 判断子序列
    // 返回 seq*k 是否为 s 的子序列
    private boolean isSubsequence(char[] seq, int n, int k, char[] s) {
        int i = 0;
        for (char c : s) {
            if (seq[i % n] == c) {
                i++;
                if (i == n * k) { // seq*k 的所有字符匹配完毕
                    return true; // seq*k 是 s 的子序列
                }
            }
        }
        return false;
    }
}
class Solution {
    // 47. 全排列 II
    // 枚举从 nums 中选任意个数的所有排列,用 f 处理枚举的排列
    void permuteFunc(const string& nums, auto&& f) {
        int n = nums.size();
        string path;
        vector<int8_t> on_path(n); // on_path[j] 表示 nums[j] 是否已经填入排列
        auto dfs = [&](this auto&& dfs) -> void {
            f(path);
            if (path.size() == n) {
                return;
            }
            // 枚举 nums[j] 填入 path[i]
            for (int j = 0; j < n; j++) {
                // 如果 nums[j] 已填入排列,continue
                // 如果 nums[j] 和前一个数 nums[j-1] 相等,且 nums[j-1] 没填入排列,continue
                if (on_path[j] || j > 0 && nums[j] == nums[j - 1] && !on_path[j - 1]) {
                    continue;
                }
                path += nums[j]; // 填入排列
                on_path[j] = true; // nums[j] 已填入排列(注意标记的是下标,不是值)
                dfs(); // 填排列的下一个数
                on_path[j] = false; // 恢复现场
                path.pop_back(); // 恢复现场
            }
        };
        dfs();
    };

    // 392. 判断子序列
    // 返回 seq*k 是否为 s 的子序列
    bool isSubsequence(const string& seq, int k, const string& s) {
        int n = seq.size();
        int i = 0;
        for (char c : s) {
            if (seq[i % n] == c) {
                i++;
                if (i == n * k) { // seq*k 的所有字符匹配完毕
                    return true; // seq*k 是 s 的子序列
                }
            }
        }
        return false;
    }

public:
    string longestSubsequenceRepeatedK(string s, int k) {
        int cnt[26]{};
        for (char c : s) {
            cnt[c - 'a']++;
        }

        string a;
        for (int i = 25; i >= 0; i--) { // 倒序,这样我们可以优先枚举字典序大的排列
            a.insert(a.end(), cnt[i] / k, 'a' + i);
        }

        string ans;
        permuteFunc(a, [&](const string& seq) {
            // 先比大小(时间复杂度低),再判断是否为子序列(时间复杂度高)
            if (seq.size() > ans.size() || seq.size() == ans.size() && seq > ans) {
                if (isSubsequence(seq, k, s)) {
                    ans = seq;
                }
            }
        });
        return ans;
    }
};
// 47. 全排列 II
// 枚举从 nums 中选任意个数的所有排列,用 f 处理枚举的排列
func permuteFunc[T comparable](nums []T, f func([]T)) {
    n := len(nums)
    path := []T{}
    onPath := make([]bool, n) // onPath[j] 表示 nums[j] 是否已经填入排列
    var dfs func()
    dfs = func() {
        f(path)
        if len(path) == n {
            return
        }
        // 枚举 nums[j] 填入 path[i]
        for j, on := range onPath {
            // 如果 nums[j] 已填入排列,continue
            // 如果 nums[j] 和前一个数 nums[j-1] 相等,且 nums[j-1] 没填入排列,continue
            if on || j > 0 && nums[j] == nums[j-1] && !onPath[j-1] {
                continue
            }
            path = append(path, nums[j])
            onPath[j] = true // nums[j] 已填入排列(注意标记的是下标,不是值)
            dfs() // 填排列的下一个数
            onPath[j] = false // 恢复现场
            path = path[:len(path)-1] // 恢复现场
        }
    }
    dfs()
}

// 392. 判断子序列
// 返回 seq*k 是否为 s 的子序列
func isSubsequence(seq []byte, k int, s string) bool {
    n := len(seq)
    i := 0
    for _, c := range s {
        if seq[i%n] == byte(c) {
            i++
            if i == n*k { // seq*k 的所有字符匹配完毕
                return true // seq*k 是 s 的子序列
            }
        }
    }
    return false
}

func longestSubsequenceRepeatedK(s string, k int) string {
    cnt := [26]int{}
    for _, c := range s {
        cnt[c-'a']++
    }
    a := []byte{}
    for i := 25; i >= 0; i-- { // 倒序,这样我们可以优先枚举字典序大的排列
        bs := []byte{'a' + byte(i)}
        a = append(a, bytes.Repeat(bs, cnt[i]/k)...)
    }

    ans := []byte{}
    permuteFunc(a, func(seq []byte) {
        // 先比大小(时间复杂度低),再判断是否为子序列(时间复杂度高)
        if len(seq) > len(ans) || len(seq) == len(ans) && bytes.Compare(seq, ans) > 0 {
            if isSubsequence(seq, k, s) {
                ans = slices.Clone(seq)
            }
        }
    })
    return string(ans)
}

优化:子序列自动机

用 392 题的进阶做法(子序列自动机),原理见 我的题解

class Solution:
    def longestSubsequenceRepeatedK(self, s: str, k: int) -> str:
        s = [ord(c) - ord('a') for c in s]  # 转成 0 到 25,这样下面无需频繁调用 ord

        # 392. 判断子序列(进阶做法)
        n = len(s)
        nxt = [[n] * 26 for _ in range(n + 1)]
        for i in range(n - 1, -1, -1):
            nxt[i][:] = nxt[i + 1]
            nxt[i][s[i]] = i

        def isSubsequence(seq: Tuple[int, ...]) -> bool:
            i = -1
            for _ in range(k):
                for c in seq:
                    i = nxt[i + 1][c]
                    if i == n:  # c 不在 s 中,说明 seq*k 不是 s 的子序列
                        return False
            return True  # seq*k 是 s 的子序列

        cnt = Counter(s)
        a = [ch for ch, freq in cnt.items() for _ in range(freq // k)]
        a.sort(reverse=True)  # 排序后,下面会按照字典序从大到小枚举排列

        for i in range(len(a), 0, -1):  # 长度优先
            for seq in permutations(a, i):  # 枚举 a 的长为 i 的排列
                if isSubsequence(seq):
                    return ''.join(ascii_lowercase[c] for c in seq)
        return ''
class Solution {
    private char[] ans;
    private int ansLen = 0;

    public String longestSubsequenceRepeatedK(String S, int k) {
        char[] s = S.toCharArray();

        // 392. 判断子序列(进阶做法)
        int n = s.length;
        int[] cnt = new int[26];
        int[][] nxt = new int[n + 1][];
        nxt[n] = new int[26];
        Arrays.fill(nxt[n], n);
        for (int i = n - 1; i >= 0; i--) {
            int c = s[i] - 'a';
            nxt[i] = nxt[i + 1].clone();
            nxt[i][c] = i;
            cnt[c]++;
        }

        StringBuilder tmp = new StringBuilder();
        // 倒序,这样我们可以优先枚举字典序大的排列
        for (int i = 25; i >= 0; i--) {
            String c = String.valueOf((char) ('a' + i));
            tmp.append(c.repeat(cnt[i] / k));
        }
        char[] a = tmp.toString().toCharArray();

        ans = new char[a.length];
        permute(a, k, nxt);

        return new String(ans, 0, ansLen);
    }

    // 47. 全排列 II
    // 枚举从 nums 中选任意个数的所有排列,处理枚举的排列
    private void permute(char[] nums, int k, int[][] nxt) {
        int n = nums.length;
        char[] path = new char[n];
        boolean[] onPath = new boolean[n]; // onPath[j] 表示 nums[j] 是否已经填入排列
        dfs(0, nums, path, onPath, k, nxt);
    }

    private void dfs(int i, char[] nums, char[] path, boolean[] onPath, int k, int[][] nxt) {
        // 处理当前排列 path
        process(path, i, k, nxt);

        if (i == nums.length) {
            return;
        }

        // 枚举 nums[j] 填入 path[pathLen]
        for (int j = 0; j < nums.length; j++) {
            // 如果 nums[j] 已填入排列,continue
            // 如果 nums[j] 和前一个数 nums[j-1] 相等,且 nums[j-1] 没填入排列,continue
            if (onPath[j] || j > 0 && nums[j] == nums[j - 1] && !onPath[j - 1]) {
                continue;
            }
            path[i] = nums[j]; // 填入排列
            onPath[j] = true; // nums[j] 已填入排列(注意标记的是下标,不是值)
            dfs(i + 1, nums, path, onPath, k, nxt); // 填排列的下一个数
            onPath[j] = false; // 恢复现场
            // 注意 path 无需恢复现场,直接覆盖 path[i] 就行
        }
    }

    private void process(char[] seq, int seqLen, int k, int[][] nxt) {
        // 先比大小(时间复杂度低),再判断是否为子序列(时间复杂度高)
        if (seqLen > ansLen || seqLen == ansLen && compare(seq, ans, ansLen) > 0) {
            if (isSubsequence(seq, seqLen, k, nxt)) {
                System.arraycopy(seq, 0, ans, 0, seqLen);
                ansLen = seqLen;
            }
        }
    }

    // 比较 a 和 b 的字典序大小
    private int compare(char[] a, char[] b, int n) {
        for (int i = 0; i < n; i++) {
            if (a[i] != b[i]) {
                return a[i] - b[i];
            }
        }
        return 0;
    }

    // 392. 判断子序列
    // 返回 seq*k 是否为 s 的子序列
    private boolean isSubsequence(char[] seq, int n, int k, int[][] nxt) {
        int i = -1;
        while (k-- > 0) {
            for (int j = 0; j < n; j++) {
                char c = seq[j];
                i = nxt[i + 1][c - 'a'];
                if (i + 1 == nxt.length) { // c 不在 s 中,说明 seq*k 不是 s 的子序列
                    return false;
                }
            }
        }
        return true;
    }
}
class Solution {
    // 47. 全排列 II
    // 枚举从 nums 中选任意个数的所有排列,用 f 处理枚举的排列
    void permuteFunc(const string& nums, auto&& f) {
        int n = nums.size();
        string path;
        vector<int8_t> on_path(n); // on_path[j] 表示 nums[j] 是否已经填入排列
        auto dfs = [&](this auto&& dfs) -> void {
            f(path);
            if (path.size() == n) {
                return;
            }
            // 枚举 nums[j] 填入 path[i]
            for (int j = 0; j < n; j++) {
                // 如果 nums[j] 已填入排列,continue
                // 如果 nums[j] 和前一个数 nums[j-1] 相等,且 nums[j-1] 没填入排列,continue
                if (on_path[j] || j > 0 && nums[j] == nums[j - 1] && !on_path[j - 1]) {
                    continue;
                }
                path += nums[j]; // 填入排列
                on_path[j] = true; // nums[j] 已填入排列(注意标记的是下标,不是值)
                dfs(); // 填排列的下一个数
                on_path[j] = false; // 恢复现场
                path.pop_back(); // 恢复现场
            }
        };
        dfs();
    };

public:
    string longestSubsequenceRepeatedK(string s, int k) {
        // 392. 判断子序列(进阶做法)
        int n = s.size();
        vector<array<int, 26>> nxt(n + 1);
        ranges::fill(nxt[n], n);
        for (int i = n - 1; i >= 0; i--) {
            nxt[i] = nxt[i + 1];
            nxt[i][s[i] - 'a'] = i;
        }

        auto isSubsequence = [&](const string& seq, int k) -> bool {
            int i = -1;
            while (k--) {
                for (char c : seq) {
                    i = nxt[i + 1][c - 'a'];
                    if (i == n) { // c 不在 s 中,说明 seq*k 不是 s 的子序列
                        return false;
                    }
                }
            }
            return true;
        };

        int cnt[26]{};
        for (char c : s) {
            cnt[c - 'a']++;
        }

        string a;
        for (int i = 25; i >= 0; i--) { // 倒序,这样我们可以优先枚举字典序大的排列
            a.insert(a.end(), cnt[i] / k, 'a' + i);
        }

        string ans;
        permuteFunc(a, [&](const string& seq) {
            // 先比大小(时间复杂度低),再判断是否为子序列(时间复杂度高)
            if (seq.size() > ans.size() || seq.size() == ans.size() && seq > ans) {
                if (isSubsequence(seq, k)) {
                    ans = seq;
                }
            }
        });
        return ans;
    }
};
// 47. 全排列 II
// 枚举从 nums 中选任意个数的所有排列,用 f 处理枚举的排列
func permuteFunc[T comparable](nums []T, f func([]T)) {
    n := len(nums)
    path := []T{}
    onPath := make([]bool, n) // onPath[j] 表示 nums[j] 是否已经填入排列
    var dfs func()
    dfs = func() {
        f(path)
        if len(path) == n {
            return
        }
        // 枚举 nums[j] 填入 path[i]
        for j, on := range onPath {
            // 如果 nums[j] 已填入排列,continue
            // 如果 nums[j] 和前一个数 nums[j-1] 相等,且 nums[j-1] 没填入排列,continue
            if on || j > 0 && nums[j] == nums[j-1] && !onPath[j-1] {
                continue
            }
            path = append(path, nums[j])
            onPath[j] = true  // nums[j] 已填入排列(注意标记的是下标,不是值)
            dfs()             // 填排列的下一个数
            onPath[j] = false // 恢复现场
            path = path[:len(path)-1]
        }
    }
    dfs()
}

func longestSubsequenceRepeatedK(s string, k int) string {
    // 392. 判断子序列(进阶做法)
    n := len(s)
    nxt := make([][26]int, n+1)
    for j := range nxt[n] {
        nxt[n][j] = n
    }
    for i := n - 1; i >= 0; i-- {
        nxt[i] = nxt[i+1]
        nxt[i][s[i]-'a'] = i
    }
    isSubsequence := func(seq []byte) bool {
        i := -1
        for range k {
            for _, c := range seq {
                i = nxt[i+1][c-'a']
                if i == n { // c 不在 s 中,说明 seq*k 不是 s 的子序列
                    return false
                }
            }
        }
        return true
    }

    cnt := [26]int{}
    for _, c := range s {
        cnt[c-'a']++
    }
    a := []byte{}
    for i := 25; i >= 0; i-- { // 倒序,这样我们可以优先枚举字典序大的排列
        bs := []byte{'a' + byte(i)}
        a = append(a, bytes.Repeat(bs, cnt[i]/k)...)
    }

    ans := []byte{}
    permuteFunc(a, func(seq []byte) {
        // 先比大小(时间复杂度低),再判断是否为子序列(时间复杂度高)
        if len(seq) > len(ans) || len(seq) == len(ans) && bytes.Compare(seq, ans) > 0 {
            if isSubsequence(seq) {
                ans = slices.Clone(seq)
            }
        }
    })
    return string(ans)
}

复杂度分析

  • 时间复杂度:$\mathcal{O}((n/k)!\cdot n)$,其中 $n$ 是 $s$ 的长度。注意 $\sum_{i=0}^m A_m^i$ 就是全排列搜索树的节点个数,我在 排列型回溯【基础算法精讲 16】中精确地算出了全排列搜索树的节点个数为 $\left\lfloor e\cdot m!\right\rfloor$,其中 $e=2.718\cdots$ 为自然常数。比如 $m=7$ 时,$\sum_{i=0}^7 A_7^i = 13700 = \left\lfloor e\cdot 7!\right\rfloor$。当 $m=n/k$ 时,遍历这棵搜索树需要 $\mathcal{O}((n/k)!)$ 的时间,每个节点判断子序列需要 $\mathcal{O}(n)$ 的时间。
  • 空间复杂度:$\mathcal{O}(n|\Sigma|)$。其中 $|\Sigma|=26$ 是字符集合的大小。

相似题目

  1. 回溯题单的「§4.5 排列型回溯」。
  2. 双指针题单的「§4.2 判断子序列」。
  3. 字符串题单的「九、子序列自动机」。

分类题单

如何科学刷题?

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

一个猎奇一点的随机算法

令参数 $\ell=\frac{n}{k}$,注意到题中的 $\ell$ 非常小,可以视为常数。不过根据个人习惯,下文分析复杂度的时候还是会将 $\ell$ 作为参数代入进行计算。
$O(\ell!\cdot n)$ 的搜索我猜肯定有人会写题解,用到的性质是最多只有 $\ell$ 个出现频率超过 $\frac{1}{\ell}$ 的候选字符,这里就不赘述了。下面介绍一个猎奇一点的随机算法:
考虑最优解中每个重复的子序列 $seq$ 的出现区间,平均来说长度不超过 $\ell$。根据 Markov's inequality,至少有 $\frac{k}{\ell}$ 个区间的长度不超过 $\ell+1$。那么如果我们从原数组中 $n-\ell$ 个可能的长度为 $\ell+1$ 的子区间中随机挑选一个,它完整包含子序列 $seq$ 的概率至少为 $\dfrac{k/\ell}{n-\ell}\geq \dfrac{1}{\ell^2}$。如果我们猜对了区间,就只需枚举该区间中所有 $2^{\ell+1}$ 个子序列进行贪心验证即可,每次验证需要 $O(n)$ 的时间。重复随机 $O(\ell^2\log \frac{1}{\epsilon})$ 次即可使错误率足够小。
这个算法的复杂度是 $O(2^\ell\cdot \mathrm{poly}(\ell)\cdot n\cdot \log \frac{1}{\epsilon})$,在 $\ell$ 较大时是比搜索的复杂度渐近更优的。(注意到根据斯特林公式有 $l!=O^*\left((\dfrac{\ell}{e})^\ell\right)$.)


以下代码没怎么进行剪枝优化,所以比较慢。比赛的时候随便狂调了几次重复次数就过了。仔细优化一下的话应该是稳过的。
无标题.png

const int L=8;
int n; char *a;
class Solution {
public:
bool test(string t,int k){
int m=t.size(),cnt=0,j=0;
char *b=&t[0];
for (int i=0;i<n;++i)
if (a[i]==b[j]){
if (j==m-1)j=0,++cnt;
else ++j;
}
return cnt>=k;
}
string longestSubsequenceRepeatedK(string s, int k) {
a=&s[0]; string ans;
n=s.size(); int l=min(n,L);
for (int T=1;T<=20;++T){
int p=rand()%(n-l+1);
for (int i=1;i<(1<<l);++i)
if (__builtin_popcount(i)>=ans.size()){
string b;
for (int j=0;j<l;++j)
if (i&(1<<j))b+=a[p+j];
if ((b.size()>ans.size()||b>ans)&&test(b,k))ans=b;
}
}
return ans;
}
};

用这个性质进行 dfs 剪枝,再调一下参,可以刷到第一。
2014.png{:width=400}

const int N=2005;
int c[255],_next[N][26],(*nxt)[26]=&_next[2],n,m,k;
char b[N];
string a,ans;
inline bool check(const string &t){
int m=t.size(),cur;
if (!m)return 1;
if (k>40){
bool flag=0;
for (int i=0;i<10;++i){
int p=rand()%n; cur=p-1;
for (auto &ch:t)cur=nxt[cur][ch-'a'];
if (cur>=0&&cur-p<=7){flag=1; break;}
}
if (!flag)return 0;
}
cur=-1;
for (int i=0;i<k;++i){
for (auto &ch:t)cur=nxt[cur][ch-'a'];
if (cur<0)return 0;
}
return 1;
}
void dfs(string &t){
if (!check(t))return;
if (t.size()>ans.size()||t.size()==ans.size()&&t>ans)ans=t;
for (int i=0;i<m;++i)if (!i||b[i]!=b[i-1]){
char ch=b[i]; --m;
for (int j=i;j<m;++j)b[j]=b[j+1];
t.push_back(ch);
dfs(t);
t.pop_back();
for (int j=m;j>i;--j)b[j]=b[j-1];
++m; b[i]=ch;
}
}
class Solution {
public:
string longestSubsequenceRepeatedK(string s, int k) {
m=0; ::k=k; a=ans="";
for (int i='a';i<='z';++i)c[i]=0;
for (auto &ch:s)++c[ch];
for (auto &ch:s)if (c[ch]>=k)a.push_back(ch);
n=a.size();
for (int i=0;i<26;++i)nxt[-2][i]=nxt[n-1][i]=-2;
for (int i=n-2;i>=-1;--i){
memcpy(nxt[i],nxt[i+1],sizeof(int)*26);
nxt[i][a[i+1]-'a']=i+1;
}
for (int i='a';i<='z';++i)
for (int j=0;j<c[i]/k;++j)b[m++]=i;
string _s; dfs(_s);
return ans;
}
};

科技爱好者周刊(第 354 期):8000mAh 手机电池,说明了什么?

这里记录每周值得分享的科技内容,周五发布。

本杂志开源,欢迎投稿。另有《谁在招人》服务,发布程序员招聘信息。合作请邮件联系(yifeng.ruan@gmail.com)。

封面图

成都推出机器人交警。(via

8000mAh 手机电池,说明了什么?

大家发现了吗,手机的电池正在越变越大。

你可以看一下你的手机,电池容量是多少。

仅仅三四年前,手机电池一般都是 4000mAh(毫安时),最多就到 5000mAh。

但是在去年(2024年),电池容量增加到了 6000mAh。今年(2025年)更是出现好几部 8000mAh 的手机

更让人惊奇的是,这些手机并没有因为更大的电池,而变得更重更厚。

以某品牌的 8000mAh 手机为例,重量209克,厚度7.98毫米,跟一般的大屏手机差不多。

为什么手机塞进了更多的电池,却没有变重?

原因很简单,电池技术在这几年出现了突破

大家应该听说过"固态电池"。它不同于现在的锂电池,最大特点是更高的能量密度,也就是同样的重量可以储存更多的能量。

但是,固态电池还在测试中,量产时间最快也要等到2027年。目前,真正进入市场的是"半固态电池"。

半固态电池介入传统锂电池与固态电池之间,电解液是固态和液态的混合物。

2023年4月份,宁德时代宣布将要生产凝聚态电池,也就是半固态电池。

根据厂家公布的数据,这种电池的能量密度是 500 Wh/kg,也就是每公斤可以储存0.5度电,传统锂电池的能量密度是 250 Wh/kg。

所以,手机从锂电池换成半固态电池,重量不变,电量翻一倍,正好从 4000mAh 增加到 8000mAh。从时间上看,半固态电池是2023年发布,2024年投产,2025年进入消费电子产品,时间也刚好。

可以预期,随着越来越多手机换成半固态电池和将来的固态电池,续航时间不再成为问题,充电焦虑将彻底消失。

以今年发布的 8000mAh 手机为例,续航时间就非常惊人。根据评测,它可以连续播放25小时的视频。也就是说,中度或轻度使用时,可以两天一充,甚至三天一充。

半固态电池只有中国厂商量产了,目前只用于中国品牌的手机。三星旗舰手机 S25 Ultra 的电池容量,还停留在几年前的 5000mAh,苹果就更差劲了,iPhone 16 Pro 是 3582mAh,iPhone 16 Pro Max 是 4685mAh。所以,中国品牌手机在电池上是世界领先。

固态电池的应用,不限于手机。有报道说,比亚迪正在测试固态电池的汽车,续航里程居然可以达到1875公里。

这意味着,一次充满电,可以从上海开到成都(直线距离1600公里),太不可思议了。

固态电池还使得电动飞机成为可能。飞机需要大量能源,同时又不能有太大的起飞重量,固态电池正好满足。中国的电动飞行器,很可能会像电动汽车一样,成为下一个在全球竞争中脱颖而出的产业。

科技动态

1、世界最长的航线

本周,中国东航宣布将开通中国到阿根廷的航线,这将是两国之间的唯一直航航线,也是世界最长航线。

在地球仪上,从中国传过地心就是阿根廷,两国之间的距离,相当于赤道的一半。因此,地球任意两个城市之间,几乎不可能有更长航线了。

赤道的长度是4万公里,这条航线是19,680公里。没有任何民航客机,可以一次性飞2万公里,所以这条航线中途会在新西兰落地休息。

整个飞行时间大约24小时~25小时,十分辛苦,上海到新西兰要11个小时,新西兰到阿根廷又要十几个小时。

2、一家以色列的 AI 编程公司,上周以8000万美元被收购

这家公司刚刚成立半年,31岁的创始人一开始是兼职的,现在全公司也只有8个人。

它年初才成立,五月份首次实现盈利18.9万美元,六月份就以8000万美元被收购。

这到底反映了我们正处在 AI 的泡沫,还是验证了 Sam Altman 的预言:"AI 会创造一个人的独角兽(估值10亿美元的创业公司)"。

3、本周,比尔·盖茨与托瓦兹见面了。

上面照片中,左一是微软 Azure 云服务的首席技术官 Mark Russinovich,他组织了这次饭局。

左二是 Windows 创始人比尔·盖茨,右二是 Linux 创始人托瓦兹(Linus Torvalds),右一是 Windows NT 的首席架构师 Dave Cutler。

比尔·盖茨与托瓦兹从未见过,这是两人第一次见面。多年前,Windows 和 Linux 互相将对方视为敌人,现在创始人都老了,终于一笑泯恩仇。

4、问答网站 Stack Overflow,快要被 AI 消灭了。

五月份,整个网站上的新发布问题只有20000个,跟刚上线的2008年下半年相仿。

6月份更惨,截止到6月25日,新发布问题只有12015个。

最高峰的2020年,每月的新问题超过30万个。它的访问量曾经排名全球前50名,就这样被 AI 淘汰了。

5、一项研究确认,AI 影响了网站的访问量。

研究发现,谷歌搜索的 AI 总结,让其他网站的访问量下降了30%。

可以想像,随着 AI 大量使用,网站的访问人数还会大大下降。

文章

1、智能插头当作网站开关(英文)

作者想了一个很聪明的方法,将智能插头当作网站的浏览开关。

如果本机通过 Wifi 检测到插头,就立刻修改/etc/hosts文件,使得某些社交网站无法访问。反之,拔出插头,则计算机将该文件再改回原样。

2、网页压缩算法比较(英文)

服务器发送给浏览器的网页,一般都是压缩的,主要有四种算法:gzip、deflate、brotli、zstd。

作者用 Go 语言测试,哪种压缩算法对服务器开销比较小。

3、巧解 Docker 镜像拉取失败(中文)

本文介绍一种拉取 Docker 镜像的变通方法:通过 GitHub workflow 拉取,然后存储到阿里云个人镜像站,并给出脚本。(@you8023 投稿)

4、CSS 的部分关键帧(英文)

本文是 CSS 中级教程,介绍 CSS 动画如果只写一个关键帧(起始/结束),也有很多应用场景。

5、让 Claude Code 使用其他模型(中文)

Claude Code 只能使用自家模型,本文介绍使用 Claude Bridge,让它可以使用任意第三方模型,从而极大降低使用成本。(@jerrylususu 投稿)

6、git notes 命令(英文)

git 有一个鲜为人知的 notes 命令,可以往日志添加自定义数据,很适合为每次提交加入元数据。

7、如何减少 OpenAI 的音频/视频费用(英文)

作者让 OpenAI 概括一个视频的内容,意外发现,如果让文件的播放速度加快到2倍或3倍,OpenAI 的处理费用可以减少30%以上。

原因可能是,加速会让一些短音节变得不明显,从而减少输入 token 的数量。

工具

1、postmarketOS

一个专门适配移动设备的 Linux 发行版,适合将过时的手机变成 Linux 设备。

2、to-userscript

一个命令行工具,可以将浏览器插件转成 userscript,方便移植。

3、Reeden

纯本地的电子书阅读软件,支持多个平台,免费版没有数据同步和 AI 功能。(@unclezs 投稿)

4、AdaCpp

一个基于浏览器的在线 C++ 学习环境,可以编辑/编译代码,并有 AI 的代码解释。(@xueywn 投稿)

5、Moocup

一个为图片加上背景渐变色的在线工具。

6、浸入式学语言助手

开源的浏览器翻译插件,根据设定的外语水平,帮助在日常网页浏览中自然地学习外语。(@xiao-zaiyi 投稿)

7、EasyDisplay

通过局域网展示数位看板的解决方案。(@yyfd2013zy 投稿)

8、QueryBox

跨平台的桌面端 GraphSQL 调试工具。(@zhnd 投稿)

9、RingLink

国产的远程设备互通组网的工具,类似于 Tailscale。(@Aplusink 投稿)

10、LogTape

JS 日志库,号称性能好,功能强,参见介绍文章

11、Project Indigo

Adobe 推出的一款免费的 iPhone 相机,比原生相机更简单易用,融入了 AI 的自动调整,参见介绍文章

AI 相关

1、Gemini CLI

谷歌推出的基于终端的 AI 客户端,可以完成各种 AI 操作,包括调用谷歌的视频模型 Veo 和图像模型 Imagen。

此前,其他 AI 公司已经发布了类似的命令行产品,比如 Claude CodeOpenAI Codex (CLI)

2、Twocast

真人 AI 播客生成器,一键生成 3~5 分钟播客,支持多语言、多音色,免费开源。(@panyanyany 投稿)

3、Duck.ai

DuckDuckGo 推出的免费 AI 聊天服务,强调保护用户隐私。

资源

1、My Ringtone

免费无需注册的铃声搜索下载网站,提供 MP3 格式铃声。(@twjiem 投稿)

2、维基电台 Wiki Radio

这个网站随机播放,维基百科里面的音频文件。

3、ICONIC

一个开源的图标库,专门提供各种软件技术的图标。

4、Linux/Windows 开发 iOS 应用教程(英文)

一个图文教程,使用 xtool 工具在 Linux/Windows 上开发 iOS 应用。

图片

1、印度裔掌管的美国科技公司

印度人在美国科技界有着庞大的势力,下图是印度裔掌管的美国科技公司的不完全列表。

微软、谷歌、IBM 都是印度裔掌管的。

2、迪士尼绿

迪士尼乐园使用绿色,对很多基础设施进行油漆。

这样做的目的是,尽量减少游客对基础设施的关注。

这种绿色就被称为"迪士尼绿"。

文摘

1、离职面谈是不必要的

当你即将离职,HR 可能想找你进行一次"离职面谈",询问你"为什么要离职?",以及"跟同事一起工作感觉如何"。

别上当。你的最佳选择是,推掉这些离职面谈,如果不行,那也不要对任何人或任何事进行批评。

你可以回答,你遇到了一个不想放过的机会,然后很荣幸能跟曾经的同事一起工作,对于这家公司曾经给予的工作机会,充满感激。就这样,离职面谈就可以结束了。

这有几个原因。

(1)离职面谈不会给你带来任何好处,反而会带来很多负面后果。

你的建议和反馈,不会得到采纳和改进。反而,你会被别人认为是一个爱抱怨的人,并可能因此树敌。

没人想树敌。你或许以为自己再也不用和那些领导和同事打交道了,但这个世界真的很小。

(2)一旦你递交了辞呈,在你离开公司之前,你的目标就是让人们永远记得你,对你留下好印象。

你要优雅地离开,不要破坏任何人际关系。无论你心里认为,老板有多愚蠢,部门有多糟糕,都不要说出来。说出来不会有好结果,只会伤害你自己。

(3)同理,不要给同事们发一封冗长的告别电子邮件,告诉他们你为什么离开,这毫无意义且有害。

人们对这种事的记忆力很强。发一封邮件抱怨公司有多糟糕,你就会以这种方式被人们记住,很有可能还会传开,而你所做的一切好事都会被人们忘记。

(4)如果你真的对公司运作有什么建议,最好没辞职的时候就说出来。如果那样没有效果,那么你在离职面谈中给出忠告,更不会有效果了。

(5)离职后,原来的公司变好或变坏,都跟你无关了。你也不应该再关心那些问题了。

总之,最好的离职就是不惹恼别人,悄悄地离开,全力以赴你接下来的路。

言论

1、

AI 使得我的90%技能,价值变为0,但使得剩下的10%技能,价值增长了1000倍。

每个人在 AI 面前,都需要重新调整自己的技能。

-- Kent Beck,极限编程的创始人

2、

Anthropic 公司为了训练模型,聘请了谷歌图书扫描项目前主管汤姆·特维(Tom Turvey)。

他的任务是获取"世界上所有的书籍",花费数百万美元购买了数百万本纸质书籍,新的和二手的都有。然后,把这些书都拆了,进行扫描,完成后就扔掉。

-- 美国法院判决书,出版公司控告 Anthropic 未经许可使用版权书籍训练模型,法院一审判 Anthropic 胜诉

3、

西方国家的博士学位,基本上是移民计划,而大学很乐意配合。

-- Hacker News 读者

4、

企业将来不会区分"Python 程序员"或"React 程序员",招聘的时候,不会在意你会什么语言。企业只会招聘能够解决问题的程序员,不管他们的技术栈。因为有了大模型,编程语言障碍已经完全消失了。

我们已经到了这个地步:学习哪种编程语言无关紧要。现在真正的技能是系统设计、架构、DevOps、云计算----那些在 AI 之上快速构建系统的技能。

-- Reddit 读者

5、

社会的危机,不是人变得孤独,而是人变得隐形、没有用处、可有可无。

-- 《隐形的人》

往年回顾

不要看重 Product Hunt(#307)

黄仁勋的 Nvidia 故事(#257)

汽车行业的顶峰可能过去了(#207)

KK 给年轻人的建议(#157)

(完)

文档信息

  • 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证
  • 发表日期: 2025年6月27日

HelloGitHub 第 111 期

本期共有 41 个项目,包含 C 项目 (1),C# 项目 (2),C++ 项目 (3),Go 项目 (4),Java 项目 (2),JavaScript 项目 (5),Kotlin 项目 (2),Objective-C 项目 (1),Python 项目 (5),Rust 项目 (3),Swift 项目 (2),人工智能 (5),其它 (6)

React 简洁代码 - 使用绝对路径导入

译文原文链接 React Clean Code with Absolute Imports - 作者 Tasawar Hussain

React 是一个很受欢迎的 JavaScript 的库,用于构建用户界面。当我们使用 React 工作的时候,会发现我们需要从不同的文件中引入不同的模块。其中一个管理我们引入的方式是使用绝对路径引入。在本文中,我们将探讨在 React 中(无论你是用 Typescript 还是 JavaScript 组织代码)使用绝对路径导入的好处。

什么是绝对路径导入

JavaScript 中,当我们导入一个模块,我们需要指定需要导入的模块的文件路径。这个路径可以是相对路径或者绝对路径。相对路径是以一个点或者多个点开始,后面带个前斜线。

嗯,我们假设有下面一个 TODO 的应用,有下面的结构:

src/
  components/
    TodoList.tsx
    TodoItem.tsx
  screens/
    TodoScreen.tsx

为了在 TodoScreen 组件中引入 TodoList 组件,我们可以如下引入:

import TodoList from "../../components/TodoList";

上面的声明使用了相对的路径从 components 文件夹中导入了 TodoList 模块。使用相对路径很容易出错,特别是当我们编写一个大型的项目,该项目有很多层级的目录。

为了避免这个问题,我们可以使用绝对路径来导入模块。绝对路径是从我们项目根目录开始,并指定我们想要导入的模块路径。以下是个案例👇

import TodoList from "components/TodoList";

该声明使用绝对路径从 src/components 目录中导入 TodoList 模块。使用绝对可以让我们避免错误和使得代码更容易维护。

TypeScript 中怎么使用绝对路径导入

如果我们在 React 中使用的是 TypeScript,我们可以在文件 tsconfig.json 中使用 baseUrlpaths 选项来配置绝对路径导入。下面是个例子👇

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "src/*": ["src/*"]
    }
  }
}

JavaScript 中怎么使用绝对路径导入

如果我们在 React 项目中使用 JavaScript,我们可以在根目录下的文件 jsconfig.json 中添加下面的内容:

{
  "compilerOptions": {
    "baseUrl": "src"
  },
  "include": ["src"]
}

上面的配置设定 baseUrl 是你项目的根目录并且定义了一个 src/* 的路径映射。这意味着我们可以从 src 目录中使用绝对路径。

React 中使用绝对路径导入的好处

1. 简化导入

React 中使用绝对路径导入的最主要的好处就是简化我们的引入。相比于使用复杂的相对路径导入模块,我们可以使用简短的绝对路径导入。这让我们的代码更加容易读和维护。

比如,下面的相对路径:

import MyComponent from '../../../components/MyComponent';

上面使用了比较长和复杂的路径来引入 MyComponent 组件。相反的,使用绝对路径改写如下👇

import MyComponent from 'src/components/MyComponent';

上面这个声明使用了简洁的绝对路径导入 MyComponent 组件。

2. 避免文件路径错误

当我们使用相对路径时候,很容易错误导入文件。绝对路径通过指定我们想要导入的文件的精准位置来避免这个问题。这意味着我们可以避免写常见的错误,比如导入错误的文件或者文件丢失。

3. 重构更容易

使用绝对路径让我们重构代码更加容易。当我们在项目中移动文件或者目录,我们可以很简单地更新导入的路径。这为我们节约了不少时间并降低了引入错误的风险。

守护你的代码:JavaScript Obfuscator 实际操作指南

引言

在上篇文章中,我们深入探讨了 JavaScript 混淆的原理和意义。今天,我们将聚焦于一款在混淆领域备受推崇的利器——javascript-obfuscator!它以其强大的功能和丰富的配置选项,成为了许多开发者保护代码的得力助手。

本文将带你走进 javascript-obfuscator 的实际操作世界,从零开始,一步步学会如何使用它来提升你的 JavaScript 代码安全性,并给出一些实用的建议。准备好了吗?让我们开始吧!

准备工作:安装 javascript-obfuscator

首先,你需要将 javascript-obfuscator 安装到你的项目中。我们通常将其作为开发依赖安装。

使用 npm:

npm install javascript-obfuscator --save-dev

安装完成后,你就可以在你的项目中调用它了。

基础操作:命令行使用

javascript-obfuscator 最直接的使用方式是通过命令行。这对于构建脚本、自动化部署流程非常方便。

假设你有一个名为 my-script.js 的文件,内容如下:

// my-script.js
function sayHello(name) {
  const message = `Hello, ${name}! Welcome to my awesome app.`;
  console.log(message);
}

const userName = "Developer";
sayHello(userName);

// Some important logic here!
// var sensitiveData = 'this should be protected';

1. 基本混淆:生成混淆后的代码文件

最简单的用法就是将你的 .js 文件进行混淆,并输出到一个新的文件。

命令格式:

npx javascript-obfuscator <input_file> --output <output_file>

实操示例:

npx javascript-obfuscator my-script.js --output my-script.obfuscated.js

执行后,你会在当前目录下看到一个 my-script.obfuscated.js 文件。打开它,你会发现代码变得非常难以阅读,变量名被替换,字符串可能被加密,整体结构被重组。

输出示例:

const a0_0x4074ce = a0_0x4bc6; (function (_0x54b00d, _0x3b0184) { const _0x33237b = a0_0x4bc6, _0x3f3139 = _0x54b00d(); while (!![]) { try { const _0x103ed2 = parseInt(_0x33237b(0x1e1)) / 0x1 + parseInt(_0x33237b(0x1e4)) / 0x2 * (-parseInt(_0x33237b(0x1e8)) / 0x3) + -parseInt(_0x33237b(0x1ea)) / 0x4 + -parseInt(_0x33237b(0x1e3)) / 0x5 * (parseInt(_0x33237b(0x1eb)) / 0x6) + -parseInt(_0x33237b(0x1e7)) / 0x7 * (parseInt(_0x33237b(0x1e0)) / 0x8) + -parseInt(_0x33237b(0x1e9)) / 0x9 + parseInt(_0x33237b(0x1e5)) / 0xa * (parseInt(_0x33237b(0x1e2)) / 0xb); if (_0x103ed2 === _0x3b0184) break; else _0x3f3139['push'](_0x3f3139['shift']()); } catch (_0xc2b768) { _0x3f3139['push'](_0x3f3139['shift']()); } } }(a0_0x5c84, 0xe2898)); function a0_0x4bc6(_0x4b158a, _0x21504c) { const _0x5c84fa = a0_0x5c84(); return a0_0x4bc6 = function (_0x4bc667, _0xeef566) { _0x4bc667 = _0x4bc667 - 0x1e0; let _0x43b085 = _0x5c84fa[_0x4bc667]; return _0x43b085; }, a0_0x4bc6(_0x4b158a, _0x21504c); } function sayHello(_0xe54705) { const _0x202d82 = 'Hello,\x20' + _0xe54705 + '!\x20Welcome\x20to\x20my\x20awesome\x20app.'; console['log'](_0x202d82); } function a0_0x5c84() { const _0x3f2783 = ['14ExAAPK', '4920423yomcmx', '6376545hhdlhg', '1226148OmYpLz', '282558ycKIJX', '4454768rKIPel', '500372ZUSqtf', '8125084ygdeic', '25DvXNbT', '2enaKSl', '60hbcWcJ', 'Developer']; a0_0x5c84 = function () { return _0x3f2783; }; return a0_0x5c84(); } const userName = a0_0x4074ce(0x1e6); sayHello(userName);

关键点:

  • 默认情况下,javascript-obfuscator 会启用多种混淆技术,包括字符串数组、控制流平坦化、变量名混淆等,以提供良好的保护。

2. 控制混淆选项:定制化你的保护策略

javascript-obfuscator 提供了海量的配置选项,允许你精细控制混淆过程。你可以通过命令行参数来传递这些选项。

常用选项介绍与实操:

  • --compact: 压缩输出,移除空格和换行符。

    npx javascript-obfuscator my-script.js --output my-script.compact.js --compact true
    

    这会让输出代码更加紧凑,占用更少空间,并且也更难阅读。

  • --string-array: 将代码中的字符串收集到一个数组中,运行时再通过加密或编码的方式获取。这是非常有效的字符串保护技术。

    npx javascript-obfuscator my-script.js --output my-script.string-array.js --string-array true
    

    你会发现代码中多了一个类似 _0xXXXX 的数组,并且原始字符串不见了。

  • --string-array-encoding: 指定字符串数组的编码方式,如 base64, rc4, utf16le。RC4 通常提供更好的加密效果。

    npx javascript-obfuscator my-script.js --output my-script.rc4.js --string-array true --string-array-encoding rc4
    
  • --control-flow-flattening: 启用控制流平坦化,将代码逻辑转化为复杂的 switch 语句,极大增加理解难度。

    npx javascript-obfuscator my-script.js --output my-script.cf.js --control-flow-flattening true
    
  • --dead-code-injection: 注入无用的代码,增加代码量和阅读难度。

    npx javascript-obfuscator my-script.js --output my-script.deadcode.js --dead-code-injection true
    
  • --rename-globals: (谨慎使用!) 重命名全局变量。如果你的代码依赖于全局变量(例如被其他脚本调用),开启此选项可能导致错误。

    npx javascript-obfuscator my-script.js --output my-script.mangle-globals.js --rename-globals true
    
  • --reserved-strings: 指定一些不应被混淆的字符串。例如,把Developer列为不需要混淆的字符串。

    npx javascript-obfuscator my-script.js --output my-script.reserved.js --string-array true --reserved-strings Developer
    

3. 使用配置文件

当配置选项变得很多时,使用命令行参数会显得冗长。javascript-obfuscator 支持从配置文件读取配置,通常是一个 .json 文件。

首先,创建一个配置文件,例如 obfuscator-config.json:

{
  "compact": true,
  "controlFlowFlattening": true,
  "controlFlowFlatteningFactor": 0.75,
  "deadCodeInjection": true,
  "deadCodeInjectionPattern": "!![]",
  "stringArray": true,
  "stringArrayEncoding": ["rc4"],
  "stringArrayThreshold": 0.75,
  "renameGlobals": false,
  "splitStrings": true,
  "splitStringsChunkSize": 10,
  "unicodeEscapeSequence": true
}

然后,在命令行中使用 --config 参数:

npx javascript-obfuscator my-script.js --output my-script.config.js --config obfuscator-config.json

这样会使你的构建流程更加清晰和易于管理。

4. 使用 Node.js API

你也可以直接在你的 Node.js 脚本中调用 javascript-obfuscator 的 API。

const JavaScriptObfuscator = require('javascript-obfuscator');

const obfuscationResult = JavaScriptObfuscator.obfuscate(`
  function add(a, b) {
    return a + b;
  }

  console.log(add(5, 3));
`);

console.log(obfuscationResult.getObfuscatedCode());

结语

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

在 Flutter 中避免过度使用 StatefulWidget:常见错误及更好的替代方案

Flutter 是一个用于构建跨平台移动、Web 和桌面应用程序的出色工具包。其响应式框架和精美的 UI 组件使其成为全球开发者的首选。但就像任何强大的工具一样,如果我们不小心,很容易误用它。

Flutter 开发者(尤其是初学者)最常犯的错误之一,就是过度使用或误用StatefulWidget

让我们深入探讨为什么会出现这种情况,它会导致什么问题,以及如何通过更好的替代方案来避免它。

Flutter 中的 StatefulWidget 是什么?

在深入探讨陷阱之前,我们先快速回顾一下StatefulWidget的定义。

在 Flutter 中,Widget 是构建 UI 的基本单元。Widget 主要分为两种类型:

  • StatelessWidget:不可变,不维护自身状态。
  • StatefulWidget:维护可变状态,且状态可在 Widget 生命周期中变化。

StatefulWidget 的常见应用场景包括管理计数器、开关按钮,或响应用户交互的动态 UI 变化。

class MyCounter extends

很简单,对吧?但问题恰恰从这里开始……


❌ 常见错误:随处使用 StatefulWidget

在 Flutter 应用中,我们最常看到的反模式之一是将过多组件包裹在StatefulWidget中 —— 而这往往是不必要的。

为什么这样做不好?

现在让我们来深入分析。

1. 组件臃肿与紧密耦合

在 StatefulWidget 中塞入过多逻辑,会导致组件树臃肿,使 UI 与业务逻辑紧密耦合。这会让代码更难:

  • 维护:调试和重构变得困难。

  • 测试:紧耦合的代码难以进行单元测试。

  • 扩展:添加新功能时容易陷入混乱。

2. 不必要的重绘

调用setState()时,整个组件会被重绘。如果StatefulWidget包含复杂 UI 元素或嵌套组件,会导致性能低效,常见问题包括:

  • 动画卡顿
  • UI 响应缓慢
  • 移动设备耗电加剧

3.违背单一职责原则

优秀的软件架构强调职责分离。理想情况下,组件应仅负责渲染 UI,而非同时管理状态、网络请求和业务逻辑。
如果你的组件看起来像一个 “迷你后端 API 客户端”,是时候重构了!


💡 替代 “随处使用 StatefulWidget” 的更佳方案

现在我们来看看如何避免过度使用 StatefulWidget,并编写更简洁、易维护且可测试的 Flutter 代码。

✅ 1. 将 StatelessWidget 与状态管理结合使用

现代 Flutter 开发的最佳实践是将业务逻辑从组件中剥离,迁移至专门的状态管理方案中。
常用方案包括:

  • Provider
  • Riverpod
  • Bloc / Cubit
  • GetX
  • MobX

示例: 使用 Provider 代替 StatefulWidget

class Counter with ChangeNotifier {
  int _count = 0;

int get count => _count;
  void increment() {
    _count++;
    notifyListeners();
  }
}

将你的app裹在ChangeNotifierProvider中,你的组件会变得更加简洁:

class CounterText extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final counter = Provider.of<Counter>(context);
    return Text('Count: ${counter.count}');
  }
}

现在,你已经将用户界面和逻辑分离。你还能获得以下好处:

  • 可重用性
  • 更简洁的重建
  • 更轻松的测试

✅ 2. 利用Hooks(flutter_hooks)

flutter_hooks将React风格的钩子引入Flutter,让你能够在StatelessWidget中使用局部状态!


class HookCounter extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final count = useState(0);

    return Column(
      children: [
        Text('Count: ${count.value}'),
        ElevatedButton(
          onPressed: () => count.value++,
          child: Text('Increment'),
        ),
      ],
    );
  }
}

更简洁、更具函数式特性,且无需编写样板代码!

✅ 3. 为父级管理的状态使用回调参数

有时,你只需要让父级组件管理状态并将其作为属性传递下去。

class CustomSwitch extends StatelessWidget {
  final bool value;
  final ValueChanged<bool> onChanged;

  CustomSwitch({required this.value, required this.onChanged});
  @override
  Widget build(BuildContext context) {
    return Switch(value: value, onChanged: onChanged);
  }
}

现在状态在更高级别被控制,这提高了灵活性和可测试性。

✅ 4. 使用ValueNotifierValueListenableBuilder 对于轻量级的响应式更新,ValueNotifier是一个很棒的选择。

final counter = ValueNotifier<int>(0);

ValueListenableBuilder<int>(
  valueListenable: counter,
  builder: (context, value, child) {
    return Text('Count: $value');
  },
)

它效率很高,并且避免了StatefulWidget的样板代码。

🧠 你究竟何时该使用StatefulWidget?

需要明确的是,StatefulWidget并非“洪水猛兽”,它自有其适用场景。 当出现以下情况时可以使用它:

  • 你需要管理临时的局部状态(如动画、焦点或标签控制器)。
  • 该状态无需共享或持久化。
  • 你正在构建快速原型或进行实验。

但对于生产级应用,应避免将其作为状态管理策略。

👀 实际场景:重构一个混乱的StatefulWidget

假设你正在构建一个商品Card组件,而你最初的实现使用了StatefulWidget来管理:

  • 商品是否被点赞
  • 点赞/取消点赞的网络请求
  • 界面渲染
class ProductCard extends StatefulWidget {
  final Product product;
  ProductCard({required this.product});

  @override
  _ProductCardState createState() => _ProductCardState();
}
class _ProductCardState extends State<ProductCard> {
  bool isLiked = false;
  void toggleLike() {
    setState(() {
      isLiked = !isLiked;
      // Simulate API call
    });
  }
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text(widget.product.name),
        IconButton(
          icon: Icon(isLiked ? Icons.favorite : Icons.favorite_border),
          onPressed: toggleLike,
        ),
      ],
    );
  }
}

这种情况可以使用Riverpod等状态管理库来更好地处理:

final likeProvider = StateProvider.family<bool, String>((ref, productId) => false);

现在,你的ProductCard就只是一个展示型组件了,简洁得多。 怎么样学会了吗?最后欢迎大家关注我的公众号OpenFlutter

🚀🚀⚡️ Rspack 1.4 发布:性能再突破,生态更完善 ⚡️ 🚀🚀

前言

Rspack 1.4 正式发布!作为前端开发者,这是一个令人振奋的消息。Rspack 以其基于 Rust 的高性能和与 Webpack 的高度兼容性,成为现代 Web 开发中的重要工具。本次更新带来了显著的性能提升、生态系统扩展和新功能支持,让我们一起来探索这些激动人心的变化!

往期精彩推荐

正文

2025年6月26日,Rspack 团队发布了 1.4 版本,进一步提升了这一高性能 JavaScript 打包器的能力。以下是本次更新的核心亮点:

1. WebAssembly 支持:在线开发更便捷

Rspack 1.4 引入了对浏览器环境的 WebAssembly(Wasm)目标支持,特别适用于在线开发平台。

开发者现在可以直接在浏览器中构建和运行 Rspack 项目,极大地方便了在线开发和预览!

浏览器中构建和运行 Rspack

使用指南: rspack.dev/zh/guide/st…

2. 性能优化:更快、更小

Rspack 1.4 在性能方面取得了显著突破:

  • SWC 性能提升:与 SWC 团队合作,JavaScript 解析器速度提升了 30%~35%,压缩器速度提升了 10%。相比 Rspack 1.3 使用的 SWC 16,性能提升明显!

性能提升

  • 更小的构建产物:通过优化的死代码消除(DCE)和 tree shaking 技术,Rspack 生成的构建产物更精简。以 react-router 为例,Rspack(通过 Rsbuild)生成的压缩后大小为 36.35 kB(Gzip 后 13.26 kB),优于 Webpack(36.96 kB,13.37 kB)、Vite(42.67 kB,15.67 kB)等其他工具

更小的构建产物

3. 增量构建与 HMR 优化

Rspack 1.4 默认启用增量构建,通过 experiments.incremental: 'safe' 配置,仅重新构建发生变化的部分,显著减少构建时间。此外,热模块替换(HMR)性能提升了 30%~40%,让开发过程中的模块更新更加流畅。

 HMR 优化

4. CSS 代码分割

新引入的 CssChunkingPlugin 插件,支持 CSS 代码分割,优化了 CSS 资源的加载性能,特别适合大型项目。

import { rspack } from '@rspack/core';

export default {
  plugins: [
    new rspack.experiments.CssChunkingPlugin({
      // ...options
    }),
  ],
};

5. 懒编译与自定义文件系统

  • 懒编译:在 MultiCompiler 中支持懒编译,可针对每个编译器单独配置,优化大型项目的构建性能。
  • 自定义文件系统:通过 experiments.useInputFileSystem 支持自定义文件系统,例如 VirtualModulesPlugin,为开发者提供了更大的灵活性。

6. 性能追踪

Rspack 1.4 支持使用 Perfetto进行性能追踪。开发者可以通过设置环境变量 RSPACK_PROFILE=OVERVIEW 启用此功能,并在 Perfetto 平台上可视化性能数据!

性能追踪

7. 依赖升级

  • 升级 Zod 到 v4。
  • Biome v2 作为 create-rspack 的可选依赖,提升代码格式化和分析能力。

8. 生态系统扩展

Rspack 1.4 进一步扩展了其生态系统,与主流框架和工具的集成更加完善:

  • Rsbuild 1.4:支持 Chrome DevTools 集成,新增 .js?raw 查询以导入原始内容,并通过 SWC 支持 monorepo 编译范围,确保浏览器兼容性。

Chrome DevTools

  • Rslib 0.10:优化 ESM,支持 Vue 组件库 rsbuild-plugin-unplugin-vue。
  • Rspress 2.0 beta:引入 Shiki 代码高亮和新主题样式!
  • Rsdoctor MCP:通过 @rsdoctor/mcp-server 提供 AI 辅助的构建分析。
  • Rstest v0.0.3:Jest 兼容的测试框架,适用于 Node.js 和 UI 开发
  • next-rspack:测试覆盖率达 99.4%(生产环境)和 98.4%(开发环境)。
  • Kmi:结合 Umi 和 Rspack 的框架,提供性能提升!

9. 升级注意事项

  • SWC Wasm 插件:如 @swc/plugin-emotion 需要升级到 swc_core@29
  • 懒编译中间件:自动读取 lazyCompilation 配置,无需手动设置。

10. 未来计划

Rspack 1.4 的成功离不开社区的支持。自开源以来,项目已吸引 170 位贡献者,提交了超过 5000 个 Pull Request 和 2000 多个 Issue,npm 周下载量突破 10 万次。未来,Rspack 计划继续优化性能,支持更多现代 Web 标准,并完善与 Webpack 生态的兼容性。

如何开始?

最后

Rspack 1.4 通过性能优化、生态扩展和新功能支持,进一步巩固了其作为高性能 Web 打包器的地位。无论是更快的构建速度、更小的输出产物,还是与 Next.js、Vue 等框架的无缝集成,Rspack 都为开发者提供了更高效的工具链

更多详细更新看这里:rspack.dev/zh/blog/ann…

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

往期精彩推荐

Flutter,如何实现轮播图

说到这个 Flutter 项目配置,我发现 flutter_swiper_view 这个库用起来还挺顺手的。最近在做一个首页轮播图的功能,记录一下实现过程。

对了,先说说项目的基本配置吧。在 pubspec.yaml 里除了基础依赖,主要加了三个关键库:

  • flutter_swiper_view 负责轮播图
  • oktoast 用于提示消息
  • flutter_screenutil 做屏幕适配

关于 flutter_screenutil 的详细说明,flutter_screenutil 这个库真的太重要了!它帮我们解决了不同设备屏幕适配的大问题。说到屏幕适配,Android 和 iOS 设备那么多不同尺寸,要是手动适配简直要疯掉。

这个库主要提供了几个超好用的功能:

  1. .w - 宽度适配单位
  2. .h - 高度适配单位
  3. .r - 圆角/边距适配单位
  4. .sp - 字体大小适配单位

初始化的时候需要设置 designSize,这个要根据 UI 设计稿的尺寸来定。比如设计稿是 375x812(iPhone X 尺寸),那就这样设置:

主应用入口设置

主应用入口的代码挺有意思的。我注意到这里用 OKToast 包裹了整个应用,这样在任何地方都可以调用 toast 提示了。ScreenUtilInit 则是用来初始化屏幕适配的,designSize 需要提前定义好设计稿尺寸。

return OKToast(
  child: ScreenUtilInit(
    designSize: designSize,
    builder: (context, child) {
      return MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
          useMaterial3: true
        ),
        home: HomePage()
      );
    },
  )
);
void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ScreenUtilInit(
      designSize: Size(375, 812),
      minTextAdapt: true,
      splitScreenMode: true,
      builder: (context, child) {
        return MaterialApp(
          title: 'Flutter Demo',
          theme: ThemeData(),
          home: child,
        );
      },
      child: HomePage(),
    );
  }
}

使用起来特别简单,比如:

Container(
  width: 100.w,  // 相当于设计稿上的100px
  height: 200.h, // 相当于设计稿上的200px
  margin: EdgeInsets.all(10.r), // 圆角适配
  child: Text(
    'Hello', 
    style: TextStyle(fontSize: 16.sp) // 字体大小适配
  ),
)

首页轮播图实现

说到轮播图,在 HomePage 里我用 Swiper 组件实现了一个简单的版本。这里有几个关键点:

  1. 容器高度设为了 150.h - 这个 .hScreenUtil 提供的适配单位
  2. 宽度设为 double.infinity 让它撑满父容器
  3. 加了 15.r 的边距 - .r 也是适配单位
Container(
  height: 150.h,
  width: double.infinity,
  child: Swiper(
    indicatorLayout: PageIndicatorLayout.COLOR,
    autoplay: true,
    control: const SwiperControl(),
    itemCount: 3,
    itemBuilder: (context, index){
      return Container(
        width: double.infinity,
        margin: EdgeInsets.all(15.r),
        height: 150.h,
        color: Colors.lightBlue
      );
    }
  )
)

对了,Swiper 的配置项也挺丰富的:

  • autoplay: true 开启自动轮播
  • control 显示左右箭头控制器
  • indicatorLayout 设置指示器样式

遇到的坑

还有一个有意思的事,刚开始我没用 SafeArea,结果在 iPhone 上轮播图被刘海挡住了。后来加上了 SafeArea 才解决这个问题。所以现在代码是这样的:

Scaffold(
  body: SafeArea(child: Column(children: [
    // 轮播图和其他内容
  ]))
)

说到这个,Flutter 的布局系统有时候确实需要点时间适应,特别是从原生开发转过来的同学。不过一旦熟悉了,写起来还是挺爽的!

效果

1.gif

总结

总的来说,实现一个基础轮播图并不复杂,但要注意:

  • 屏幕适配问题
  • 不同设备的显示安全区域
  • 轮播图的各种配置选项

下次我打算给轮播图加上真实的图片和点击事件,到时候再分享更多心得!

【React专栏】一、React基础(一)

React基础

1.1 React项目创建

npm install -g create-react-app

1.2 JSX语法

  • 使用成对的标签构成一个树状结构的数据

  • 当使用DOM类型标签时,标签的首字母必须小写

  • 当使用React组件类型标签时,标签的首字母必须大写

    • 因为React正是通过标签的首字母大小写,来判断当前是一个DOM类型标签,还是React组件类型标签。
  • 可以使用JavaScript表达式,因为JSX本质上仍是JavaScript

    • 在JSX中使用JavaScript表达式,需要用大括号“{}”包起来
    • 使用场景
      • 1️⃣通过表达式给标签属性赋值
      • 2️⃣通过表达式定义子组件
  • JSX中只能使用JavaScript表达式,不能使用多行JavaScript语句

  • 可以使用三元运算符或逻辑与(&&)运算符 代替 if 语句 的作用

  • JSX是DOM类型标签时,对应DOM标签支持的属性JSX也支持。

    • 但部分属性名称会改变,主要变化有:
      • class --> className(因为class时JavaScript的关键字)
      • onclick --> onClick(React对DOM标签支持的事件做了封装,封装时采用更常用的驼峰式命名法命名事件)
  • JSX标签是React组件类型时,可以任意自定义标签的属性名

  • JSX中的注释,需要用大括号“{}”将/* 注释 */包裹起来

const element = {
    <div>
        {/* 这是注释 */}
        <span>React</span>
   </div>
}

1.2.1 JSX 不是必需的

  • JSX语法只是 React.createElement(component, props, ...children)的语法糖,所有的JSX语法最终都会被转换成对这个方法的调用。
// JSX 语法
const element = <div className='foo'>Hello,React</div>

// 转换后
const element = React.createElement('div',{className: 'foo'},'Hello,React')

1.3 组件

React应用程序正是由一个个组件搭建而成。

1.3.1 组件定义

组件定义有两种方式: 1️⃣ES6 class(类组件) 2️⃣使用函数(函数组件)

1.3.1.1 类组件
  • 使用class定义组件,需要满足两个条件
    • class继承自React.Component
    • class内部必须定义render方法,render方法返回代表该组件UI的React元素
  • 创建好的React组件,需要使用ES6 export导出,方便其他文件引入
  • 渲染至页面,还需要使用到 react-dom 库中的 render函数(这个库会完成组件所代表的虚拟DOM节点到浏览器的DOM节点的转换)
import ReactaDOM from "react-dom";
import PostList from "./PostList";

ReactDOM.render(<PostList />, document.getElementById("root"));
1.3.1.2 函数组件

方便用于定义 无状态组件(即无需定义 state,仅接收props)

function Welcome(props) {
    return <h1>Hello,{props.name}</h1>
}

1.3.2 组件的 props

组件的props用于把 父组件中的数据或方法 传递给子组件,供子组件使用。

1.3.3 组件的 state

  • 组件的 state 是组件内部的状态,state 的变化最终将反应到组件的UI变化上。
  • 通过组件的构造方法 constructor 中,通过 this.state 定义组件的初始化。
  • 通过调用 this.setState 方法改变组件的状态(唯一改变组件状态的方式),组件UI会重新渲染

在组件的构造方法 constructor 内,首先要调用 super(props),这一步实际上是调用 React.Component 这个 class 的 constructor 方法,用来完成React组件的初始化工作。

小结: React 组件正是由 props 和 state 两种类型的数据,驱动渲染组件。props 是组件对外的接口,通过 props 接收外部传入的数据和方法; state 是组件内部的接口,组件内部的状态变化通过 state 反映。props 是只读的,不允许更改; state 是可变得,组件状态变化可通过改变 state 来实现。

1.3.4 有状态组件和无状态组件

React 应用组件设计的一般思路是,通过定义少数的有状态组件,管理整个应用的状态变化,并且将状态通过 props 传递给其余的无状态组件,由无状态组件完成页面的大部分 UI的渲染工作

  • 应尽可能多的使用无状态组件,无状态组件无需关心状态的变化,只聚焦UI的展示,更容易被复用。
  • 有状态组件主要处理状态变化的业务逻辑,无状态组件主要关注UI的渲染。

1.3.5 属性校验和默认属性

  • 属性校验
    • 基本的属性校验类型

      • String --> PropTypes.string
      • Number --> PropTypes.number
      • Boolean --> PropTypes.bool
      • Function --> PropTypes.func
      • Object --> PropTypes.object
      • Array --> PropTypes.array
      • Symbol --> PropTypes.symbol
      • Element --> PropTypes.element
      • Node --> PropTypes.node (可被渲染的节点:数字、字符串、React 元素或由这些类型的数据组成的数组)
    • 当知道要校验的属性是一个对象或数组时,通过使用PropTypes.shape(校验对象结构,校验内部属性类型)或PropTypes.arrayOf(校验数组内部元素类型)

style: PropTypes.shape({
    color: PropTypes.string;
    fontSize: PropTypes.number;
}),
sequence: PropTypes.arrayOf(PropTypes.number);

// 表示 style 是一个对象,对象内有 color 和 fontSize 两个属性, 
// color 是字符串类型,fontSize 是数字类型;sequence 是一个数组,数组的元素是数字类型。
  • 必填校验
    • 需要在PropTypes 的类型属性上,调用isRequired。
PostItem.propTypes = {
    post: PropTypes.shape({
        id: PropTypes.number
    }).isRequired,
    onVote: PropTypes.func.isRequired
}
  • 属性默认值
    • 通过组件的defaultProps 实现。当组件属性未被赋值时,组件会使用 defaultProps 定义的默认属性。
function Welcome(props) {
    return <h1 className='foo'>Hello, {props.name}</h1>
}

Welcome.defaultProps = {
    name: 'Stranger'
}

// 当props.name 未被赋值时,使用下方定义的默认属性值

【TypeScript专栏】二、TypeScript基础(一)

2.1 JavaScript 基础知识补充

  • 数据类型

    • 7种原始类型

      • Boolean 布尔
      • Null 空
      • Undefined 未定义
      • Number 数字
      • BigInt 任意精度的整数

        它解决了传统 Number 类型因使用 64 位浮点数格式而无法安全表示超大整数(超过 ±(2^53 - 1))的问题。

      • String 字符串
      • Symbol 唯一
    • Object

      除 Object 以外的所有类型都是不可变的(值本身无法被改变)。称这些类型的值为“原始值”。

  • 类数组

    • 是普通对象,具有 length 属性和数字索引访问能力,但不继承 Array.prototype,因此无法直接调用数组方法。

    • 区别

      • 数组:是 JavaScript 的内置对象,用于存储有序的元素集合,继承自 Array.prototype,拥有丰富的数组方法(如 pushmapforEach 等)。
      • image.png
    • 转换为数组的方法

      // Array.from() (推荐)
      const argsArray = Array.from(arguments);
      
      // 扩展运算符 ...
      const nodesArray = [...doucment.querySelectorAll('div')];
      
      // Array.prototype.slice.call() (旧版本兼容)
      const argsArray = Array.prototype.slice.call(arguments);
      

2.2 原始数据类型

// 布尔类型
let isDone: boolean = false;

// 数字类型
let age: number = 12;

// 字符串类型
let firstName: string = 'zhangsan';
let message: string = `Hello,${firstName}`

// null 和 undifined
// 这两种类型是所有类型的子类型
// 也就是说这两种类型值可以赋值给其他类型
let u: undefined = undefined;
let n: null = null;

2.3 Any类型

  • 在无法判断所写数据的类型时,或临时完善ts校验时,可用。
  • 允许赋值给任意类型。
  • 一般情况下,要避免使用any类型,因为这个类型可以任意调用方法和属性,就会更容易出现错误,丧失类型检查的作用。
let notSure: any = 4;
notSure = 'maybe a string';
notSure = true;

notSure.myName;
notSure.getName(); 
// 调用属性和方法均不会报错,因为默认为any 任意类型

2.4 数组

// 声明 数字类型的数组
// 数组的元素,不允许出现 数字以外的类型
let arrOfNumber: number[] = [1,2,3];

// 在指定好类型后,在调用这个数组后
// 可以自动获得数组上所有的方法
// (输入 数组名.  后,存在的方法会自动弹出提示框)
arrOfNumber.push(5);

image.png

2.5 类数组

function test() {
    console.log(arguments);
    // arguments 就是类数组
    // 而类数组有已经内置的类型 IArguments 类型
    // 还有诸多已经存在的内置类型
}

2.6 元组

  • 合并不同类型的对象 将内部的数据类型进行更精细的确定
  • 起源于函数式编程
// 存储的数组元素,顺序和类型必须对应
// 因为仍旧是数组,所以可以使用数组的方法
// 如 push,但只可添加设定好的两种类型的值
let user: [string, number] = ['zhangsan', 20];

MCP+JS实现动态路线展示+视角跟随

MCP+JS实现动态路线展示+视角跟随

项目简介

本项目基于 React + @baidumap/mapv-three + Three.js 实现了《长安的荔枝》中广州至从化的荔枝运输路线3D可视化展示。项目支持在百度矢量地图和Bing卫星地图之间切换,并通过3D人物模型动态展示运输过程。

exp2.png

主要功能

  • 支持百度矢量地图和Bing卫星地图的切换显示
  • 使用3D人物模型沿路线动态移动
  • 智能相机跟随系统,提供最佳观察视角
  • 路线采用动态发光效果展示
  • Web Mercator (EPSG:3857) 投影支持,确保多地图源兼容性

依赖安装

npm install --save @baidumap/mapv-three three react react-dom
npm install --save-dev webpack webpack-cli copy-webpack-plugin html-webpack-plugin @babel/core @babel/preset-env @babel/preset-react babel-loader

构建与运行

npx webpack
# 生成 dist/ 目录,浏览器打开 dist/index.html 预览

路线说明

路线概述

  • 起点:广州市越秀区
  • 终点:从化区
  • 主要途经:广州北环高速 → 机场高速 → 大广高速
  • 路线特点:全程按照实际道路规划,主要通过高速公路网络连接

数据结构

项目主要数据存储在 data/lychee.geojson 文件中,包含:

  • 完整的路线坐标点序列
  • 详细的路段描述
  • 路线说明和数据来源信息

3D可视化效果

  • 地图默认以广州为中心
  • 采用 30° 倾斜角和 60° 俯仰角
  • 初始观察距离为 2km
  • 可视化元素:
    • 基础路线:浅蓝色发光效果
    • 动态飞线:红色动画效果
    • 3D人物模型:动态奔跑动画

目录结构

lychee/
├── data/
│   └── lychee.geojson     # 路线地理数据
├── public/
│   └── models/
│       └── running_man.glb # 3D人物模型
├── src/
│   ├── Demo.jsx           # 主要展示组件
│   └── index.js           # 入口文件
├── webpack.config.js      # 构建配置
├── package.json          # 项目依赖
└── README.md             # 项目说明

技术实现细节

地图系统

  • 支持在百度矢量地图和Bing卫星地图间无缝切换
  • 使用 EPSG:3857 (Web Mercator) 投影确保地图兼容性
  • 通过 projectArrayCoordinate 进行坐标投影转换

3D模型集成

  • 使用 GLTFLoader 加载 .glb 格式的3D人物模型
  • 实现模型动画混合器处理走路动画
  • 智能调整模型朝向,确保始终面向运动方向

相机系统

  • 实现智能相机跟随系统
  • 保持适当的观察距离和高度
  • 平滑的相机运动效果
  • 自动调整视角以获得最佳观察效果

配置说明

项目运行需要配置以下密钥:

  • 百度地图开发者密钥 (ak)
  • Cesium ion 访问令牌 (accessToken)

请在 Demo.jsx 中替换为你自己的密钥:

mapvthree.BaiduMapConfig.ak = '你的百度地图密钥';
mapvthree.CesiumConfig.accessToken = '你的Cesium ion密钥';

参考资料

复盘——微信小程序自定义tabbar结构设计优化导致的问题

要拿捏局部优化和系统优化

文章简单记录下,小程序自定义tabbar结构设计优化,导致的多页面代码变更的问题,和解决思维的变化

背景

全局layout组件有个error-page样式会覆盖住自定义tabbar,需要解决

image.png

自定义tabbar组件目前是在各个页面引用的,而不是统一在公共的layout组件使用,这个属于历史原因了,所以就有这个问题了

这里有个小点:微信小程序的app.jsontabbar可支持放入5个以上的页面,这些页面将会拥有switchTab的切换效果,但底部tabbar只能展示5个

原先代码case

<!-- index页面 --!>
<layout>
    <view>
        <!-- content --!>
    </view>
    <tabbar />
    <view style="height:50px"></view>
</layout>

<!-- layout组件代码 --!>
<view>
    <view class="content-page">
        <slot />
    </view>
    <view class="error-page" wx:if="{{ showErrorPage }}">
        接口报错啦
    </view>
</view>

<style>
    .content-page {
        z-index: 1;
        position:relative;
    }
    .error-page {
        z-index: 2;
        position: fixed;
        height: 100vh;
        background-color: #fff;
    }
</style>

其实主要本质上是因为tabbar位置放错了,应该得把tabbar组件放到layout组件,而不是放到页面里,这样就能和error-page保持同级,从而不会被盖住,如下:

<!-- layout组件代码 --!>
<view>
    <view class="content-page">
        <slot />
    </view>
    <view class="error-page" wx:if="{{ showErrorPage }}">
        接口报错啦
    </view>
    <tabbar />
    <view style="height:50px"></view>
</view>

为什么要说他呢,因为一开始我其实不敢把这个自定义tabbar放在layout里,我的想法是能简单就简单,能少改动就少改动,能不影响之前的就不影响之前的

也就是一开始想“局部修补”,但发现在复杂结构中,局部修补只会带来更多局部修补。

所以一开始我的解决的切入点就是error-pagecontent-page这俩部分,比方说

  • error-page判断有没有自定义tabbar,动态变更高度:calc(100vh - 50px)50px自定义tabbar高度,因为error-pagefixed+100vh,所以他就盖住了自定义tabbar

    • 这里有个点,虽然自定义tabbar层级可能很高,但error-page最终的层级比较还是content-page
  • 考虑到content-page设置了z-index,所以可以把content-pagez-index``设置为unset,这样error-page就能和自定义tabbar进行比较了,从而自定义tabbar不会被盖住

以上2点,就是我一开始解决时想到的点

这里说明下三个方案的优缺点,加上抽取自定义tabbar放到layout的方案

方案 改动成本 可维护性 健壮性 长期收益
❌ 高度调整 error-page ✅ 低 ❌ 差 ❌ 差 ❌ 差
❌ unset z-index content-page ✅ 低 ⚠️ 一般 ⚠️ 一般 ❌ 差
✅ 移动 tabbar 到 layout ❌ 高 ✅ 好 ✅ 好 ✅ 高

这里的主要问题就是:对原结构进行妥协,没有要做到系统性优化

只是把当前问题解决了,他带来的隐藏问题还需要一个一个页面进行查看,那这就和重新优化tabbar引入位置带来的问题一样,也需要一个个页面查看,既然投入的成本类似,那为什么不尝试用更好的办法呢,优化系统结构

不过呢,局部优化和整体优化有时候是有一定的取舍的,也就是不是完全反对局部优化,需要看情况,总结就是

  • 结构有误、问题普遍、未来可期 ⇒ 优化结构
    • 影响到了全部页面,每个页面都可能有类似问题等,后期也有类似问题等
  • 局部异常、代价可控、生命周期短 ⇒ 优化局部
    • 比方说,只有一个页面有问题,或者需要紧急上线

问题&设计

以上是背景,接下来就是一些问题&设计

iphone安全距离

项目 constant() env()
状态 已废弃 ✅ 推荐
兼容性 老版 iOS Safari(12 及以前) iOS 11.2+,现代浏览器支持
用法 constant(safe-area-inset-bottom) env(safe-area-inset-bottom)

其他可用安全距离

变量名 含义
safe-area-inset-top 顶部安全距离(刘海/状态栏)
safe-area-inset-bottom 底部安全距离(Home Indicator)
safe-area-inset-left 左边安全距离(圆角屏)
safe-area-inset-right 右边安全距离(圆角屏)
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);

bottom: constant(safe-area-inset-bottom);
bottom: env(safe-area-inset-bottom);

自定义tabbar引发的一系列高度问题

原先页面中都有一段这样的代码,height: 50px起一个占位作用,因为tabbarfixed布局

<tabbar />
<view style="height:50px"></view>

现在放到了layout组件里

<!-- layout组件代码 --!>
<view>
    <view class="content-page">
        <slot />
    </view>
    <view class="error-page" wx:if="{{ showErrorPage }}">
        接口报错啦
    </view>
    <tabbar />
    <view style="height:50px"></view>
</view>

为什么要说这个呢,因为页面里有些样式会这么写

.my-content-page {
    height: 100vh
}

此时把自定义tabbar组件移到layout组件里,和content-page保持同级,就会导致height: 100vh多了50px,会影响滚动或者其他的样式,所以原来的页面需要减掉50px

layout怎么判断是否展示自定义tabbar

这里推荐使用每个页面传递参数的方法进行标识

<layout is-tab-bar-page="{{ true }}"></layout>

这样layout可以更快的判别是否渲染自定义tabbar,核心原因是微信小程序的组件的生命周期原因

微信小程序的组件的生命周期:created -> attached -> ready

简单理解可以看成vue的生命周期,比如 beforeCreate -> created -> mounted

我在created环节就能拿到props数据了,从而更快的判别是否渲染自定义tabbar

还有个方法可以判别:

可以在mounted(ready)环节,获取页面实例,获取页面名、页面路径,判断是否在tabbar,从而判别是否渲染自定义tabbar

但他是在mounted环节判断的,速度要比beforeCreate(created)慢,所以推荐向子组件传参

我这里的欠缺点,没有考虑到生命周期的影响,我一开始采用的是后者,进行代码review后使用了前者,所以记录下

按钮点击没有反应

这算是个历史bug,刚好讲讲,个人的解决思路,顺便记录下,代码case

api.submit().then(res => { wx.navigate({ url: 'pages/index/index' }) })

手机上看到时,发现点击没反应,没有报错提示,也没有跳转,那么至少可以简单的分析出来,接口可能发生了点问题,从而没有走进跳转逻辑

至于报错提示没有,需要了解处理请求返回结果的逻辑怎么做报错提示的

我这里的代码是只对个别错误码进行了toast提示,个别没有,那接下来该怎么办,复现又不好复现

此时需要让后端人员,查一下接口日志,看看有没有发起请求、请求报错等

我这里得到的结果就是————没有登录。

考虑到,我页面初始化就会判断是否登录,没有登录就会发起登录,所以这里需要分析下,为什么会没有登录,token问题还是什么问题,这就需要让后端人员看了

我这里的欠缺点:KiBanba不熟悉、对没有登录不敏感(因为页面初始化就会发起登录,所以就得确认,是不是token过期了,或者后端逻辑问题等)

面对问题的解决思路

刚好在整理一下问题的解决思路

广泛来说:个人解决 -> AI解决 -> google、社区、issue解决

问题现象有很多:接口报错、没反应、页面加载速度慢、控制台报错、页面异常等

这里主要说明个人解决思路,以我的error-page覆盖问题来说明

  1. 问题是什么,描述问题现象
  2. 寻找触发问题的代码
  3. 判断这是前端问题还是后端问题
  4. 分析造成问题的核心原因,局部方面,系统方面,要带着全局观来看
  5. 提出、评估解决问题的手段,有时候还需要大胆猜测,小心求证
  6. 实践

如果是诡异的问题,可以多多比较,二分法等,比较可以比机型、环境、参数、版本等

如果有解决不了的问题,需要带着代码执行流程的进行模拟一遍,然后分析

总结

多多复盘,查漏补缺,沉着冷静,带着代码流程去分析,保持怀疑

如何在TypeScript里使用类封装枚举来实现Java的枚举形参倒置

一、前言

首先,枚举形参倒置 的意思是通过为枚举形参添加一些方法,来减少调用时候传入的形参个数。

🌰举个栗子

long timestamp = System.currentTimeMillis();

// before
DateTimeUtil.format(timestamp, DateTimeFormatter.FULL_DATE);

// after
DateTimeFormatter.FULL_DATE.format(timestamp);

如上示例代码,我们可以在调用时候减少传入一个枚举形参的传入,写出来的代码会更加简洁好看。

还有其他好处吗?

好像没有了...

当然,我们不讨论这两种写法的好处和坏处 (评论区可以讨论) ,我们只聊实现。

二、Java 的实现

众所周知,在 Java 中,枚举本身也是类的特殊封装,而枚举项可以认为是 Java 类的静态实例常量,所以,我们很轻易的就能实现:

2.1 封装枚举类

@Getter
@AllArgsConstructor
public enum DateTimeFormatter {
    /**
     * 年月日
     */
    FULL_DATE("yyyy-MM-dd"),

    /**
     * 时分秒
     */
    FULL_TIME("HH:mm:ss"),

    /**
     * 年月日时分秒
     */
    FULL_DATETIME("yyyy-MM-dd HH:mm:ss"),
    ;

    private final String value;
}

如上,我们声明这个枚举类之后,就可以为这个枚举添加一些我们需要的方法了:

2.2 添加方法

@Getter
@AllArgsConstructor
public enum DateTimeFormatter {
    // 省略定义的枚举项目
    ;

    // 枚举项封装的值
    private final String value;

    /**
     * 使用这个模板格式化毫秒时间戳
     *
     * @param milliSecond 毫秒时间戳
     * @return 格式化后的字符串
     */
    public final @NotNull String format(long milliSecond) {
        return DateTimeUtil.format(milliSecond, this);
    }

    /**
     * 使用这个模板格式化当前时间
     *
     * @return 格式化后的字符串
     */
    public final @NotNull String formatCurrent() {
        return format(System.currentTimeMillis());
    }
}

2.3 调用示例

所以封装完毕之后,我们就可以用这两个方法来实现本文提到的 枚举形参倒置 了:

QQ_1744356483132.png

三、TypeScript 的实现

可是在 TypeScript 中,枚举 并没有像 Java 那样,有 枚举封装 的特性,所以,我们只能通过 来实现这个功能了。可以参考我们这篇文章:TypeScript使用枚举封装和装饰器优雅的定义字典。今天我们就不赘述之前的设计了,可以先阅读之后继续下面的内容。

3.1 封装枚举类

export class DateTimeFormatter extends AirEnum {
  static readonly FULL_DATETIME = new DateTimeFormatter(
    'yyyy-MM-dd HH:mm:ss', '年-月-日 时:分:秒'
  )
  static readonly FULL_DATE = new DateTimeFormatter(
    'yyyy-MM-dd', '年-月-日'
  )
  static readonly FULL_TIME = new DateTimeFormatter(
    'HH:mm:ss', '时-分-秒'
  )
}

我们的枚举就声明好了,当然这里的 例子🌰 其实是多余的,因为 DateTimeFormatter 这种几乎好像大概应该也许是没有什么符合字典业务场景的。

当然,为了举例,你就假设它有。

3.2 添加方法

接下来,我们也同样实现这两个方法:

export class DateTimeFormatter extends AirEnum<string> {
  // 一些静态枚举项目

  /**
   * 格式化毫秒时间戳
   * @param timestamp 毫秒时间戳
   */
  format(timestamp: number) {
    return AirDateTime.formatFromMilliSecond(timestamp)
  }

  /**
   * 格式化当前时间
   */
  formatCurrent() {
    return this.format(Date.now().valueOf())
  }
}

如上,我们实现了和 Java 一样的方法,然后就可以在调用的时候使用这个方法了:

3.3 调用示例

QQ_1744357214249.png

四、总结

本文通过类的封装,来实现了枚举功能以及 枚举形参倒置 的功能,虽然大概可能没什么用。

但我还是建议你先读 TypeScript使用枚举封装和装饰器优雅的定义字典 这个文章。

今天周五,祝大家双休。

Bye。

前端多维数组扁平化常见的几种方法

在 JavaScript 中,数组扁平化是指将一个嵌套多层的数组转换为一个一维数组。
以下是几种实现数组扁平化的方法:
一:使用flat()方法:

const array = arr.flat([depth]);

参数 depth 参数可选,默认为1,代表要展开数组的深度级别.

flat()方法是ES2019中的方法

const arr = [1, 2, [3, 4], 5, 6];
const newArr = arr.flat();
console.log(newArr) // [1, 2, 3, 4, 5, 6]

const arr = [1, 2, [3, 4], [5, [6, 7], 8], 9];
const newArr = arr.flat();
console.log(newArr) // [1, 2, 3, 4, 5, [6, 7], 8, 9]

const newArr = arr.flat(2);
console.log(newArr) // [1, 2, 3, 4, 5, 6, 7, 8, 9]

// 指定深度为 Infinity 可以完全扁平化
const arr = [1, 2, [3, 4], [5, [6, 7], 8], 9];
const newArr = arr.flat(Infinity);
console.log(newArr) // [1, 2, 3, 4, 5, 6, 7, 8, 9]

当前方法会深度的递归展开数组,最终返回一个新的一维数组,但是原数组不会改变。

二:使用递归reduce

通过遍历每个元素,将数组元素展开并合并成一个新的的数组。

function flatten(arr){
    return arr.reduce((acc, val)=>{
        return acc.concat(Array.isArray(val) ? flatten(val): val);
    }, []);
};

// 使用:
const arr = [1, 2, [3, 4], [5, [6, 7], 8], 9];
const newArr = flatten(arr);
console.log(newArr) // [1, 2, 3, 4, 5, 6, 7, 8, 9]
三:flatMap()方法

flatMap()方法适合一层嵌套的数组。

const arr = [1, 2, [3, 4], 5, 6];
const newArr = arr.flatMap(item=>item);
console.log(newArr) // [1, 2, 3, 4, 5, 6]

如果使用flatMap()方法处理多层数组,只会结构最外层的单层一层数组。

const arr = [1, 2, [3, 4], [5, [6, 7], 8], 9];
const newArr = arr.flatMap(item=>item);
console.log(newArr) // [1, 2, 3, 4, 5, [6, 7], 8, 9]
四:使用 toString() 和 split()

当前方法只适用于纯数字的数组,这种方法先将数组转换为字符串,然后再转成数组。

const arr = [1, 2, [3, 4], [5, [6, 7], 8], 9];
const newArr = arr.toString().split(',').map(Number);
console.log(newArr) // [1, 2, 3, 4, 5, 6, 7, 8, 9]

arr.toString() 会将数组转换成字符串 1,2,3,4,5,6,7,8,9, 再通过split(',')后转化为['1', '2', '3', '4', '5', '6', '7', '8', '9'],然后遍历数组将string转换为number。

五:使用(非递归方法)
function flatten(arr){
    const result = [];
    const stack = [...arr];
    while(stack?.length > 0){
        // 获取数组的最后一个元素,生成新数组。
        const current = stack.pop();
        if(Array.isArray(current)){
            stack.push(...current);
        }else{
           result.unshift(current); 
        }
    };
    return result;
};

const arr = [1, 2, [3, 4], [5, [6, 7], 8], 9];
console.log(newArr) // [1, 2, 3, 4, 5, 6, 7, 8, 9]
总结:
  • flat()简洁且性能好,但需要 ES2019+ 支持。
  • 递归 + reduce() :灵活且兼容性好,适合处理任意深度的嵌套。
  • flatMap():适合一层嵌套的数组。
  • toString() :仅适用于纯数字数组,简单但有局限性。
  • 栈方法:避免了递归的潜在堆栈溢出问题,适合处理极深的嵌套数组。

Rspack 1.4 发布:支持在浏览器中运行

Rspack 1.4

Rspack 1.4 已经正式发布!

值得关注的变更如下:

  • 新功能
    • 在浏览器中运行
    • 更快的 SWC
    • 更小的构建产物
    • 默认启用增量构建
    • 新增 CssChunkingPlugin
    • 增强 lazy compilation
    • 自定义文件系统
    • 性能分析工具
  • Rstack 进展
    • Rsbuild 1.4
    • Rslib 0.10
    • Rspress 2.0 beta
    • Rsdoctor MCP
    • Rstest 发布
  • 生态系统
    • next-rspack
    • Kmi
  • 升级指南

新功能

在浏览器中运行

从 Rspack 1.4 开始,我们正式引入了 Wasm target 支持,这意味着 Rspack 现在可以在浏览器环境中运行,包括 StackBlitzWebContainers)等在线平台。这使得开发者无需配置本地环境,即可快速创建原型、分享代码示例。

你可以直接体验我们提供的 在线示例,也可以在 这篇文档 中了解 StackBlitz 的使用指南。

ezgif-86ca2fa2b8caa5.gif

在后续版本中,我们将继续优化 Wasm 版本的使用流程和包体积。

同时我们也在开发 @rspack/browser 包,它是专为浏览器环境设计的版本,允许你直接在任何现代浏览器中使用 Rspack,而无需依赖 WebContainers 或是特定平台。

更快的 SWC

在过去几个月中,我们与 SWC 团队持续合作,共同优化 JavaScript 工具链的性能和可靠性。经过一段时间的优化,我们很高兴地看到,SWC 的性能取得了显著提升,使 Rspack 用户和所有基于 SWC 的工具都从中受益:

  • JavaScript parser(解析器)的性能提升了 30%~35%
  • JavaScript minifier(压缩器)的性能提升了 10%
SWC benchmark

以上数据来自:CodSpeed - SWC,对比的基准为 Rspack 1.3 所使用的 SWC 16。

更小的构建产物

在当前版本中,SWC 加强了死代码消除(DCE)能力,结合 Rspack 强大的 tree shaking 功能,使 Rspack 1.4 能够生成体积更小的构建产物。

我们以 react-router 为例进行测试:在源代码中仅引入它的一部分导出,然后对比不同打包工具的构建结果,可以看到 Rspack 生成的包体积最小。

import { BrowserRouter, Routes, Route } from 'react-router';

console.log(BrowserRouter, Routes, Route);

各个打包工具输出的包体积如下:

打包工具 压缩后体积 Gzipped 后体积
Rspack (Rsbuild) 36.35 kB 13.26 kB
webpack 36.96 kB 13.37 kB
Vite 42.67 kB 15.67 kB
Rolldown 42.74 kB 15.17 kB
Rolldown Vite 43.42 kB 15.46 kB
Farm 43.42 kB 15.63 kB
Parcel 44.62 kB 16.07 kB
esbuild 46.12 kB 16.63 kB
Bun 57.73 kB 20.8 kB

以上数据来自:react-router-tree-shaking-compare

默认启用增量构建

通过不断的优化迭代,Rspack 的增量构建功能已趋于稳定,在 Rspack 1.4 中,我们将所有阶段的增量优化设为默认开启,这能够显著加快重新构建的速度,HMR 性能通常可提升 30%-40%,具体提升幅度因项目而异。

下面是一位用户开启增量构建后的性能对比:

incremental benchmark

如果你需要降级到之前的行为,可以设置 experiments.incremental'safe' ,但我们推荐大部分项目直接使用新的默认配置,以获得最佳性能。

export default {
  experiments: {
    // 降级到之前的行为
    incremental: 'safe',
  },
};

新增 CssChunkingPlugin

Rspack 1.4 新增了实验性的 CssChunkingPlugin 插件,专门用于处理 CSS 代码分割。该插件能够确保样式的加载顺序与源代码中的导入顺序保持一致,避免因 CSS 加载顺序错误而导致的 UI 问题。

import { rspack } from '@rspack/core';

export default {
  plugins: [
    new rspack.experiments.CssChunkingPlugin({
      // ...options
    }),
  ],
};

启用 CssChunkingPlugin 后,CSS 模块的代码分割将完全由该插件处理,optimization.splitChunks 配置将不再对 CSS 模块生效,你可以查看 使用文档 了解更多。

该插件由 Next.js 的 CSS Chunking 功能启发而来,感谢 Next.js 团队在这一领域的创新。

增强 lazy compilation

Rspack 现已支持在 MultiCompiler 中启用 lazy compilation,这意味着当你在单次构建中使用多份 Rspack 配置时,可以为不同的 compiler 实例独立设置各自的 lazyCompilation 选项

export default [
  {
    target: 'web',
    experiments: {
      // enable lazy compilation for client
      lazyCompilation: true,
    },
  },
  {
    target: 'node',
    experiments: {
      // disable lazy compilation for server
      lazyCompilation: false,
    },
  },
];

自定义文件读取系统

Rspack 现在允许你自定义 compiler.inputFileSystem(编译器的文件读取系统),该功能可以通过配置 experiments.useInputFileSystem 开启,典型的使用场景包括:

import VirtualModulesPlugin from 'webpack-virtual-modules';

export default {
  entry: './virtualEntry.js',
  plugins: [
    new VirtualModulesPlugin({
      'virtualEntry.js': `console.log('virtual entry')`,
    }),
  ],
  experiments: {
    useInputFileSystem: [/virtualEntry\.js$/],
  },
};

由于自定义的 inputFileSystem 是通过 JavaScript 实现的,可能导致性能下降。为了缓解这个问题,useInputFileSystem 允许你传入一个正则表达式数组,过滤哪些文件需要从自定义的 inputFileSystem 读取,避免因替换原生文件系统而导致的性能开销。

未来我们还计划在 Rspack 中内置虚拟模块支持,从而提供更好的性能和使用体验。

详细用法请参考 文档

性能分析工具

Rspack 1.4 引入了更精确的 tracing 能力,它可以基于 perfetto 进行性能分析,用于快速定位构建性能的瓶颈。

你可以通过 RSPACK_PROFILE 环境变量开启 tracing:

RSPACK_PROFILE=OVERVIEW rspack build

生成的 rspack.pftrace 文件可在 ui.perfetto.dev 中进行可视化分析:

tracing

详细的用法请参考 Tracing 文档

依赖升级

在 Rspack 1.4 中,我们升级了一些主要依赖的版本,包括:

  • Rspack 现在使用 Zod v4 来校验配置的正确性。
  • create-rspack 现在提供 Biome v2 作为可选的代码校验和格式化的工具。

Rstack 进展

Rstack 是一个围绕 Rspack 打造的 JavaScript 统一工具链,具有优秀的性能和一致的架构。

Rsbuild 1.4

Rsbuild 1.4 已与 Rspack 1.4 同步发布,值得关注的特性有:

Chrome DevTools 集成

我们引入了全新的 rsbuild-plugin-devtools-json 插件,通过该插件,你可以无缝集成 Chrome DevTools 的 自动工作区文件夹 (Automatic Workspace Folders) 新特性。这意味着你可以在 DevTools 中直接修改和调试源代码,并将改动保存到本地文件系统。

rsbuild plugin devtools json

改进查询参数

Rsbuild 现在内置支持 .js?raw 查询参数,允许你将 JavaScript、TypeScript 和 JSX 文件的原始内容作为文本导入。这在需要将代码作为字符串进行处理的场景下非常有用(例如展示代码示例)。

import rawJs from './script1.js?raw';
import rawTs from './script2.ts?raw';
import rawJsx from './script3.jsx?raw';

console.log(rawJs); // JS 文件的原始内容
console.log(rawTs); // TS 文件的原始内容
console.log(rawJsx); // JSX 文件的原始内容

改进浏览器兼容性

当你在 monorepo 中引用其他包的 JS 文件时,Rsbuild 现在默认会使用 SWC 编译它们,这有助于避免外部依赖引入的浏览器兼容性问题。

以下图为例,假设 app 的构建目标为 ES2016,utils 的构建目标为 ES2021,当 app/src/index.js 引用 utils/dist/index.js 时,SWC 现在会将它降级到 ES2016。

rsbuild monorepo compile scope

Rslib 0.10

Rslib 0.10 版本已发布,主要新增了以下功能:

ESM 产物优化

Rslib 现在默认生成更简洁清晰、体积更小的 ESM 产物。

rslib esm

构建 Vue 组件库

通过引入 rsbuild-plugin-unplugin-vue 插件,你可以使用 Rslib 生成 Vue 组件库的 bundleless 产物。

import { defineConfig } from '@rslib/core';
import { pluginUnpluginVue } from 'rsbuild-plugin-unplugin-vue';

export default defineConfig({
  plugins: [pluginUnpluginVue()],
  lib: [
    {
      format: 'esm',
      bundle: false,
      output: {
        target: 'web',
      },
    },
  ],
});

输出 IIFE 格式

Rslib 现在可以生成 IIFE 格式 的产物,将代码包裹在函数表达式中。

rslib iife

阅读 博客 进一步了解 Rslib。

Rspress 2.0 beta

我们正在积极开发 Rspress 2.0,并发布了多个 beta 版本。目前,我们已完成大部分代码重构工作,并在 Rspress 2.0 中默认集成 Shiki 来提供更强大的代码高亮功能。

同时,我们正在开发全新的主题,预览效果如下:

rspress theme preview

Rsdoctor MCP

Rsdoctor 推出了 @rsdoctor/mcp-server,结合大模型来帮助你更好地分析构建数据。它能调用 Rsdoctor 的本地构建分析数据,提供智能的分析和优化建议。

Rsdoctor MCP 提供产物分析、依赖分析、产物优化建议和构建优化建议,能够分析产物的体积构成、依赖关系、重复依赖,并针对产物体积优化、代码分割以及构建性能提供相应的优化建议。

Rstest 发布

Rstest 是一个全新的基于 Rspack 的测试框架,它为 Rspack 生态提供了全面、一流的支持,能够轻松集成到现有的 Rspack 项目中,提供与 Jest 兼容的 API。

在这个月,我们发布了 Rstest 的 v0.0.3 版本,初步支持了 Node.js 和 UI 组件的测试,并在我们的 Rsbuild 等多个仓库中接入使用。

rstest

Rstest 目前仍处于早期阶段,我们建议你再关注一段时间,以确保它能够提供更完整的测试能力。

生态系统

next-rspack

自从 Rspack 加入 Next.js 生态 以来,我们的首要目标是提升 next-rspack 的稳定性和测试覆盖率。

在最新版本中,next-rspack 的功能已基本完善,测试覆盖率达到:

  • 生产构建 99.4%
  • 开发构建 98.4%

接下来,我们计划继续推进测试覆盖率至 100%,并进一步优化 next-rspack 的性能表现。

next-rspack

Kmi

Kmi 是一个基于 Umi 和 Rspack 的框架,通过集成 Rspack 作为打包工具,Kmi 带来了数倍于 webpack 版本的性能提升。

对于正在使用 Umi 框架的开发者而言,Kmi 提供了一种渐进式的迁移路径,让他们能够在保持项目稳定性的同时,享受 Rspack 带来的性能优势。

更多信息请参考 Kmi 仓库

升级指南

升级 SWC 插件

如果你的项目中使用了 SWC Wasm 插件(如 @swc/plugin-emotion 等),需要将插件升级至兼容 swc_core@29 的版本,否则可能因版本不兼容导致构建报错。

详情请查阅:常见问题 - SWC 插件版本不匹配

Lazy compilation 中间件

Lazy compilation 中间件的接入方式有所变化,该中间件现在可以从 compiler 实例中自动读取 lazyCompilation 选项,因此你不再需要手动传入 lazyCompilation 选项。

import { experiments, rspack } from '@rspack/core';
import { RspackDevServer } from '@rspack/dev-server';

const compiler = rspack([
  // ...multiple configs
]);

// no longer need to pass options to the middleware
const middleware = experiments.lazyCompilationMiddleware(compiler);

const server = new RspackDevServer(
  {
    setupMiddlewares: other => [middleware, ...other],
  },
  compiler,
);

鸿蒙手势识别:流畅的拖拽识别区域设置

1. 目标

我们要实现的是一个屏幕识别区域设置功能,用户可以通过拖拽手势动态调整识别区域的位置和大小。这种交互方式在答题类应用、OCR识别工具、截图标注等场景中都有广泛应用。

核心需求

  • 用户可以通过触摸拖拽调整识别区域
  • 实时显示调整过程中的区域变化
  • 提供视觉反馈增强用户体验
  • 智能边界控制防止区域超出有效范围

2. PanGesture手势识别核心实现

1. 手势基础配置

首先,我们来看手势的基础配置:

  • 主要代码
// 主要识别区域
Column() {
  // 上部分蒙层
  Row()
    .width('100%')
    .height(this.cropAreaY)

  // 识别区域
  Stack() {
    // 识别区域背景
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor(this.isDragging ? '#fffc8d56' : '#FD7D3F')

    Image($r("app.media.ic_area"))
      .height(this.cropAreaHeight < 50 ? '100%' : 50)
      .objectFit(ImageFit.Contain)
  }
  .width('100%')
  .opacity(this.selectedImageUri ? 0.5 : 1)
  .height(this.cropAreaHeight)

  // 下部分蒙层
  Row()
    .width('100%')
    .layoutWeight(1)
}
.layoutWeight(1)
.gesture(
  PanGesture({ fingers: 1, distance: 1 })
    .onActionStart((event: GestureEvent) => {
      this.isDragging = true;
      this.startPoint = {
        x: event.fingerList[0].globalX,
        y: event.fingerList[0].globalY
      };
      console.log('开始拖拽识别');
    })
    .onActionUpdate((event: GestureEvent) => {
      this.handlePanGesture(event);
    })
    .onActionEnd((event: GestureEvent) => {
      this.isDragging = false;
      console.log('结束拖拽识别');
    })
)
  • 识别手势代码
.gesture(
  PanGesture({ fingers: 1, distance: 1 })
    .onActionStart((event: GestureEvent) => { ... })
    .onActionUpdate((event: GestureEvent) => { ... })
    .onActionEnd((event: GestureEvent) => { ... })
)

配置参数解析:

  • fingers: 1 - 指定需要1根手指触发手势,确保是单指操作
  • distance: 1 - 设置最小触发距离为1像素,让手势响应更加敏感

原理:通过设置顶部的高度来确定识别区域 cropAreaY为首次触碰的纵坐标

2. 手势生命周期详解

onActionStart - 手势开始阶段

.onActionStart((event: GestureEvent) => {
  this.isDragging = true;
  this.startPoint = {
    x: event.fingerList[0].globalX,
    y: event.fingerList[0].globalY
  };
  console.log('开始拖拽识别');
})

关键功能:

  • 状态标记:设置isDragging = true,标识进入拖拽状态
  • 起始点记录:保存手指触摸的全局坐标globalXglobalY
  • 状态初始化:为后续的区域计算提供基准点

技术要点:

  • 使用event.fingerList[0]获取第一个手指的信息
  • globalX/globalY提供相对于整个屏幕的绝对坐标
  • 状态管理确保只有在拖拽时才进行区域更新

onActionUpdate - 手势更新阶段

.onActionUpdate((event: GestureEvent) => {
  this.handlePanGesture(event);
})

这里调用了核心的手势处理函数handlePanGesture,我们来详细分析:

private handlePanGesture = (event: GestureEvent) => {
  if (!this.isDragging) {
    return; // 防护性检查
  }

  const currentY = event.fingerList[0].globalY;

  // 计算识别区域参数
  const minY = Math.min(this.startPoint.y, currentY);
  const maxY = Math.max(this.startPoint.y, currentY);

  // 定义底部操作区域高度
  const bottomOperationAreaHeight = 100;
  // 计算可用的最大高度
  const maxAvailableHeight = this.pageHeight - bottomOperationAreaHeight;

  // 应用边界限制
  this.cropAreaY = Math.max(0, minY);

  // 限制识别区域高度
  const calculatedHeight = maxY - minY;
  const maxAllowedHeight = maxAvailableHeight - this.cropAreaY;
  this.cropAreaHeight = Math.min(calculatedHeight, maxAllowedHeight);

  // 确保识别区域高度不为负数
  if (this.cropAreaHeight < 0) {
    this.cropAreaHeight = 0;
  }
}

实现亮点:

  1. 智能区域计算
    • 使用Math.min/Math.max自动确定矩形区域的上下边界
    • 支持从上往下拖拽和从下往上拖拽两种操作方式
  1. 动态边界控制
    • 上边界限制:Math.max(0, minY)确保不超出屏幕顶部
    • 下边界保护:预留100像素操作区域,防止覆盖按钮
    • 高度约束:动态计算最大允许高度
  1. 实时状态更新
    • 直接更新@State变量,触发UI自动重渲染
    • 无需手动调用刷新方法,响应速度快

onActionEnd - 手势结束阶段

.onActionEnd((event: GestureEvent) => {
  this.isDragging = false;
  console.log('结束拖拽识别');
})

清理工作:

  • 重置拖拽状态标记
  • 结束拖拽模式,恢复正常显示状态

3. 视觉反馈机制

在拖拽过程中,系统提供了丰富的视觉反馈:

// 识别区域背景色动态变化
.backgroundColor(this.isDragging ? '#fffc8d56' : '#FD7D3F')
  • 正常状态:橙色背景 #FD7D3F
  • 拖拽状态:半透明黄色 #fffc8d56
  • 实时响应:基于isDragging状态自动切换

3. 坐标系统与数据持久化

坐标转换机制

// 保存时:vp -> px -> 归一化
let region: image.Region = {
  x: 0,
  y: vp2px(this.cropAreaY) / screenHeight,
  size: {
    width: 0,
    height: vp2px(this.cropAreaHeight) / screenHeight
  }
};

// 加载时:归一化 -> px -> vp
this.cropAreaY = px2vp(region.y * screenHeight);
this.cropAreaHeight = px2vp(region.size.height * screenHeight);

设计优势:

  • 设备适配:使用归一化坐标适配不同屏幕尺寸
  • 精度保持:通过标准坐标转换保证显示一致性
  • 数据压缩:相对坐标占用更少存储空间

4. 实际应用效果

用户操作流程

  1. 触摸开始 → 记录起始坐标,进入拖拽模式
  2. 拖拽移动 → 实时计算区域范围,更新UI显示
  3. 松开手指 → 确定最终区域,退出拖拽模式
  4. 保存设置 → 归一化坐标并持久化存储

5. 完整代码

import photoAccessHelper from '@ohos.file.photoAccessHelper';
import { BusinessError } from '@ohos.base';
import image from '@ohos.multimedia.image';
import { promptAction } from "@kit.ArkUI";
import display from '@ohos.display';
import { PreferencesConstants, preferencesUtils } from 'utils';

const TAG = 'ScreenReadSetting';

@Component
export struct ScreenReadSetting {
  @Consume('pathStack') pathStack: NavPathStack;
  @State title: string = '读屏设置';
  @State selectedImageUri: string = '';
  // 识别相关状态
  @State startPoint: Position = { x: 0, y: 0 }; // 起始点
  @State cropAreaY: number = 60; // 识别区域Y坐标,默认60
  @State cropAreaHeight: number = 200; // 识别区域高度,默认200
  @State isDragging: boolean = false;
  @State pageHeight: number = 0; // 页面高度

  // 组件即将出现时的回调
  aboutToAppear() {
    this.loadSettings();
  }

  // 加载之前保存的设置
  private async loadSettings() {
    try {
      const savedSettings = preferencesUtils.getString(PreferencesConstants.SCREEN_READ_SETTING, '');
      let region: image.Region
      if (savedSettings) {
        region = JSON.parse(savedSettings);
      } else {
        region =
          { x: 0, y: 0.0721, size: { width: 0, height: 0.493 } }
        console.log(TAG, '没有找到保存的设置,使用默认值');
      }

      console.log(TAG, '加载保存的设置:', JSON.stringify(region));

      // 获取屏幕高度
      const displayInfo = display.getDefaultDisplaySync();
      const screenHeight = displayInfo.height;

      // 将归一化的坐标转换回像素值,再转换为vp
      this.cropAreaY = px2vp(region.y * screenHeight);
      this.cropAreaHeight = px2vp(region.size.height * screenHeight);

      console.log(TAG, '恢复的识别区域 - Y:', this.cropAreaY, 'Height:', this.cropAreaHeight);
    } catch (error) {
      console.error(TAG, '加载设置失败:', error);
      // 如果加载失败,保持默认值
    }
  }

  // 从相册选取
  private async selectFromGallery() {
    console.log('从相册选取');

    try {
      const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
      photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
      photoSelectOptions.maxSelectNumber = 1;

      const photoPicker = new photoAccessHelper.PhotoViewPicker();
      let photoSelectResult = await photoPicker.select(photoSelectOptions);

      if (photoSelectResult && photoSelectResult.photoUris && photoSelectResult.photoUris.length > 0) {
        this.selectedImageUri = photoSelectResult.photoUris[0];
        console.log('选择的图片URI:', this.selectedImageUri);
      } else {
        console.log('用户取消了图片选择');
      }
    } catch (error) {
      const err = error as BusinessError;
      console.error('选择图片失败:', err.code, err.message);
    }
  }

  // 保存设置
  private saveSettings() {
    console.log('保存设置');

    // 获取屏幕宽度
    const displayInfo = display.getDefaultDisplaySync();
    const screenHeight = displayInfo.height;

    console.log(TAG, 'screenHeight =' + JSON.stringify(screenHeight))


    // 输出符合image.Region格式的区域信息
    let region: image.Region = {
      x: 0,
      y: vp2px(this.cropAreaY) / screenHeight, // 区域左上角纵坐标
      size: {
        width: 0,
        height: vp2px(this.cropAreaHeight) / screenHeight
      }
    };

    // 存入首选项
    preferencesUtils.putString(PreferencesConstants.SCREEN_READ_SETTING, JSON.stringify(region))
    console.log(TAG, 'region =' + JSON.stringify(region))

    promptAction.showToast({
      message: '保存成功',
    })
    // 返回上一页
    this.pathStack.pop();
  }

  // 处理拖拽手势 - 自由拖拽调整识别区域
  private handlePanGesture = (event: GestureEvent) => {
    if (!this.isDragging) {
      return;
    }

    const currentY = event.fingerList[0].globalY;

    // 计算识别区域参数
    const minY = Math.min(this.startPoint.y, currentY);
    const maxY = Math.max(this.startPoint.y, currentY);

    // 定义底部操作区域高度
    const bottomOperationAreaHeight = 100;
    // 计算可用的最大高度(页面高度减去底部操作区域高度)
    const maxAvailableHeight = this.pageHeight - bottomOperationAreaHeight;

    // 应用边界限制
    this.cropAreaY = Math.max(0, minY);

    // 限制识别区域高度,确保不超过底部操作区域
    const calculatedHeight = maxY - minY;
    const maxAllowedHeight = maxAvailableHeight - this.cropAreaY;
    this.cropAreaHeight = Math.min(calculatedHeight, maxAllowedHeight);

    // 确保识别区域高度不为负数
    if (this.cropAreaHeight < 0) {
      this.cropAreaHeight = 0;
    }
    console.log(TAG, JSON.stringify(this.cropAreaY))
    console.log(TAG, JSON.stringify(this.cropAreaY))
  }

  build() {
    NavDestination() {
      Stack({ alignContent: Alignment.Top }) {
        Column() {
          // 主要识别区域
          Column() {
            // 上部分蒙层
            Row()
              .width('100%')
              .height(this.cropAreaY)

            // 识别区域
            Stack() {
              // 识别区域背景
              Column()
                .width('100%')
                .height('100%')
                .backgroundColor(this.isDragging ? '#fffc8d56' : '#FD7D3F')

              Image($r("app.media.ic_area"))
                .height(this.cropAreaHeight < 50 ? '100%' : 50)
                .objectFit(ImageFit.Contain)
            }
            .width('100%')
            .opacity(this.selectedImageUri ? 0.5 : 1)
            .height(this.cropAreaHeight)

            // 下部分蒙层
            Row()
              .width('100%')
              .layoutWeight(1)
          }
          .layoutWeight(1)
          .gesture(
            PanGesture({ fingers: 1, distance: 1 })
              .onActionStart((event: GestureEvent) => {
                this.isDragging = true;
                this.startPoint = {
                  x: event.fingerList[0].globalX,
                  y: event.fingerList[0].globalY
                };
                console.log('开始拖拽识别');
              })
              .onActionUpdate((event: GestureEvent) => {
                this.handlePanGesture(event);
              })
              .onActionEnd((event: GestureEvent) => {
                this.isDragging = false;
                console.log('结束拖拽识别');
              })
          )

          // 底部操作区域
          Column() {
            Row() {
              // 选取按钮
              Column() {
                Image($r('app.media.ic_photo'))
                  .width(22)
                  .height(22)
                  .fillColor('#333333')
                Text('选取')
                  .fontSize(14)
                  .fontColor('#333333')
                  .margin({ top: 4 })
              }
              .padding({ left: 20 })
              .onClick(() => {
                this.selectFromGallery();
              })

              Blank()

              // 取消按钮
              Button('取消')
                .backgroundColor(Color.White)
                .fontColor('#FF7F50')
                .fontSize(18)
                .borderWidth(2)
                .borderColor('#FF7F50')
                .borderRadius(25)
                .width(152)
                .height(50)
                .onClick(() => {
                  this.pathStack.pop();
                })

              // 保存按钮
              Button('保存')
                .backgroundColor('#FF7F50')
                .fontColor(Color.White)
                .fontSize(18)
                .borderRadius(25)
                .width(152)
                .height(50)
                .margin({ left: 12 })
                .onClick(() => {
                  this.saveSettings();
                })
            }
            .width('100%')
            .height(54)

            Text('提示:先在答题界面截图,选取图片设置题目区域更精确。')
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#333333')
              .height(20)
              .textAlign(TextAlign.Center)
              .margin({ top: 8 })
          }
          .width('100%')
          .height(100)
          .backgroundColor(Color.White)
        }
        .width('100%')
        .height('100%')
      }
      .onAreaChange((oldValue: Area, newValue: Area) => {
        // 获取页面尺寸
        this.pageHeight = Number(newValue.height);

        console.log(TAG, '页面尺寸:', this.pageHeight)
      })
    }
    .hideTitleBar(true)
    .backgroundColor(this.selectedImageUri ? Color.Transparent : '')
    .backgroundImage(this.selectedImageUri)
    .backgroundImageSize(ImageSize.Cover)
  }
}

// 位置接口定义
interface Position {
  x: number;
  y: number;
}
❌