普通视图

发现新文章,点击刷新页面。
今天 — 2025年9月5日首页

每日一题-得到整数零需要执行的最少操作数🟡

2025年9月5日 00:00

给你两个整数:num1num2

在一步操作中,你需要从范围 [0, 60] 中选出一个整数 i ,并从 num1 减去 2i + num2

请你计算,要想使 num1 等于 0 需要执行的最少操作数,并以整数形式返回。

如果无法使 num1 等于 0 ,返回 -1

 

示例 1:

输入:num1 = 3, num2 = -2
输出:3
解释:可以执行下述步骤使 3 等于 0 :
- 选择 i = 2 ,并从 3 减去 22 + (-2) ,num1 = 3 - (4 + (-2)) = 1 。
- 选择 i = 2 ,并从 1 减去 22 + (-2) ,num1 = 1 - (4 + (-2)) = -1 。
- 选择 i = 0 ,并从 -1 减去 20 + (-2) ,num1 = (-1) - (1 + (-2)) = 0 。
可以证明 3 是需要执行的最少操作数。

示例 2:

输入:num1 = 5, num2 = 7
输出:-1
解释:可以证明,执行操作无法使 5 等于 0 。

 

提示:

  • 1 <= num1 <= 109
  • -109 <= num2 <= 109

C++双百模拟

作者 raven_z
2023年6月25日 13:05

解题思路

这题其实挺简单,就是统计num1-num2之后的数的二进制位为1的个数是否少于cnt。
举例:

cnt=1:3-(-2)=5->101(2>cnt)

cnt=2:5-(-2)=7->111(3>cnt)

cnt=3:7-(-2)=9->1001(2<cnt)

结束

原理很简单,因为根据二进制特性,num1-num2余下的必然为2的幂之和,只要该和的加数个数少于等于cnt,无论这个和是什么,我都可以用恰好cnt个数相加得到num1-num2。

注意:根据二进制位数,cnt不会超过32.

代码

###cpp

class Solution {
public:
    int makeTheIntegerZero(int num1, int num2) {
        int cnt=0;
        long n1=num1,n2=num2;
        while(1){
            cnt++;
            n1-=n2;
            if(cnt<=n1&&__builtin_popcountll(n1)<=cnt)return cnt;
            else if(n2>=-1&&n1<0)return -1;
        }
        return 0;
    }
};

枚举 & 复杂度分析

作者 tsreaper
2023年6月25日 12:13

解法:枚举

假设每次操作只会减去 $2^i$,大家都知道答案是 num1 的二进制表示中 $1$ 的数量。加入了 num2 之后不太好处理,所以我们尝试枚举操作次数,把 num2 的影响一次性处理掉。

假设我们要恰好执行 $k$ 次操作,令 x = num1 - k * num2,我们需要检查能否用恰好 $k$ 个 $2^i$ 凑成 $x$。

容易看出,至少需要 popcount(x) 个 $2^i$ 才能凑成 $x$(popcount(x) 就是 $x$ 的二进制表示中 $1$ 的数量);同时,至多只能用 $x$ 个 $2^0 = 1$ 凑出 $x$。也就是说,只要 $k$ 满足 popcount(x) <= k <= x 就是一个合法的 $k$。

那么为什么 popcount(x) 和 $x$ 之间的所有值都能取到呢?这是因为,每个 $2^i$ 都能拆成两个 $2^{i - 1}$,数量增加 $1$,因此所有值都能取到。

因此,我们从 $1$ 开始枚举 $k$,发现合法的 $k$ 即可返回答案。

接下来分析这个做法的复杂度:

  • num2 == 0 时,popcount(x)x 的值是固定的,只要枚举到 k == popcount(x) 即可返回答案。复杂度 $\mathcal{O}(\log x)$。
  • num2 < 0 时,x 每次至少增加 $1$,而 popcount(x) 是 $\mathcal{O}(\log x)$ 的。因为 $k$ 从 $0$ 开始,每次只增加 $1$,因此它永远不会超过 $x$。那么 $k$ 只要超过 popcount(x) 就够了。复杂度 $\mathcal{O}(\log x)$。
  • num2 > 0 时,x 会越来越小,当 $k > x$ 时即可返回无解。除此之外,当 $k$ 超过 popcount(x) 时即可返回答案,复杂度仍然为 $\mathcal{O}(\log x)$。

因此整体复杂度 $\mathcal{O}(\log x)$。

参考代码(c++)

###c++

class Solution {
public:
    int makeTheIntegerZero(int num1, int num2) {
        for (int k = 1; ; k++) {
            long long x = num1 - 1LL * k * num2;
            if (k > x) return -1;
            if (__builtin_popcountll(x) <= k && k <= x) return k;
        }
    }
};

上下界分析 + 枚举答案(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2023年6月25日 12:07

转化

假设我们操作了 $k$ 次,此时 $\textit{num}_1$ 变成 $\textit{num}_1 - \textit{num}_2\cdot k$ 再减去 $k$ 个 $2^i$。

能否把 $\textit{num}_1$ 变成 $0$,等价于:

  • 能否把 $\textit{num}_1 - \textit{num}_2\cdot k$ 拆分成恰好 $k$ 个 $2$ 的幂之和?

在示例 1 中,$k=3$ 时 $\textit{num}_1 - \textit{num}_2\cdot k = 9$,我们可以把 $9$ 拆分成 $4+4+1$,这三个数都是 $2$ 的幂。

上下界分析

设 $x=\textit{num}_1 - \textit{num}_2\cdot k$。

为了判断能否把 $x$ 拆分成恰好 $k$ 个 $2$ 的幂之和,我们可以先做上下界分析:

  • 上界:求出 $x$ 最多可以拆分出 $\textit{high}$ 个 $2$ 个幂。
  • 下界:求出 $x$ 最少可以拆分出 $\textit{low}$ 个 $2$ 个幂。

由于一个 $2^i$ 可以分解成两个 $2^{i-1}$,而 $2^{i-1}$ 又可以继续分解为 $2^{i-2}$,所以分解出的 $2$ 的幂的个数可以是 $[\textit{low},\textit{high}]$ 中的任意整数。$k$ 只要在这个范围中,那么分解方案就是存在的。

  • 上界:由于 $2$ 的幂最小是 $1$,所以 $x$ 最多可以拆分出 $x$ 个 $2$ 个幂($x$ 个 $1$)。
  • 下界:$x$ 的二进制中的 $1$ 的个数。比如 $x$ 的二进制为 $10110$,至少要拆分成 $3$ 个 $2$ 的幂,即 $10000+100+10$。

枚举 k

暴力的想法是,从小到大枚举 $k=1,2,3,\ldots$ 计算 $x=\textit{num}_1 - \textit{num}_2\cdot k$,判断 $k$ 是否满足上下界(在区间中)。这样做是否会超时?$k$ 最大枚举到多少呢?

对于上界,即 $k\le x = \textit{num}_1 - \textit{num}_2\cdot k$,变形得 $k\cdot (\textit{num}_2+1)\le \textit{num}_1$。

  • 如果 $\textit{num}_2 + 1\le 0$,由于题目保证 $\textit{num}_1\ge 1$,上式恒成立。
  • 如果 $\textit{num}_2 + 1> 0$,那么 $k\le \dfrac{\textit{num}_1}{\textit{num}_2+1}$。

对于下界,定义 $\text{popcount}(x)$ 为 $x$ 的二进制中的 $1$ 的个数,我们要满足 $k\ge \text{popcount}(x)$。粗略估计一下,当 $k=60$ 时,在本题数据范围下,当 $\textit{num}_1=10^9$,$\textit{num}_2 = -10^9$ 时 $x$ 最大,为 $61\times 10^9$,二进制长度只有 $36$。由于 $\text{popcount}(x)$ 不会超过 $x$ 的二进制长度,所以此时 $k$ 一定满足下界。所以本题的枚举次数其实很小,暴力枚举不会超时。

综上所述,在枚举 $k=1,2,3,\ldots$ 的过程中:

  • 如果 $k > \textit{num}_1 - \textit{num}_2\cdot k$,不满足上界。那么对于更大的 $k$,同样不满足上界,此时可以退出循环,返回 $-1$。
  • 否则,如果 $k$ 满足下界,返回 $k$。
  • 否则,继续枚举 $k$。

视频讲解(第二题)

class Solution:
    def makeTheIntegerZero(self, num1: int, num2: int) -> int:
        for k in count(1):  # 枚举 k=1,2,3,...
            x = num1 - num2 * k
            if k > x:
                return -1
            if k >= x.bit_count():
                return k
class Solution {
    public int makeTheIntegerZero(int num1, int num2) {
        for (long k = 1; k <= num1 - num2 * k; k++) {
            if (k >= Long.bitCount(num1 - num2 * k)) {
                return (int) k;
            }
        }
        return -1;
    }
}
class Solution {
public:
    int makeTheIntegerZero(int num1, int num2) {
        for (long long k = 1; k <= num1 - num2 * k; k++) {
            if (k >= popcount((uint64_t) num1 - num2 * k)) {
                return k;
            }
        }
        return -1;
    }
};
int makeTheIntegerZero(int num1, int num2) {
    for (long long k = 1; k <= num1 - num2 * k; k++) {
        if (k >= __builtin_popcountll(num1 - num2 * k)) {
            return k;
        }
    }
    return -1;
}
func makeTheIntegerZero(num1, num2 int) int {
for k := 1; k <= num1-num2*k; k++ {
if k >= bits.OnesCount(uint(num1-num2*k)) {
return k
}
}
return -1
}
var makeTheIntegerZero = function(num1, num2) {
    for (let k = 1; k <= num1 - num2 * k; k++) {
        if (k >= bitCount64(num1 - num2 * k)) {
            return k;
        }
    }
    return -1;
};

function bitCount64(i) {
    return bitCount32(Math.floor(i / 0x100000000)) + bitCount32(i >>> 0);
}

function bitCount32(i) {
    i = i - ((i >>> 1) & 0x55555555);
    i = (i & 0x33333333) + ((i >>> 2) & 0x33333333);
    i = (i + (i >>> 4)) & 0x0f0f0f0f;
    i = i + (i >>> 8);
    i = i + (i >>> 16);
    return i & 0x3f;
}
impl Solution {
    pub fn make_the_integer_zero(num1: i32, num2: i32) -> i32 {
        for k in 1.. {
            let x = num1 as i64 - num2 as i64 * k;
            if k > x {
                return -1;
            }
            if k as u32 >= x.count_ones() {
                return k as _;
            }
        }
        unreachable!()
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(f^{-1}(\textit{num}_1+|\textit{num}_2|))$,其中 $f^{-1}(x)$ 是 $f(x)=\dfrac{2^x}{x}$ 的反函数,略大于 $\log_2 x$。在本题的数据范围下,$k\le 36$。
  • 空间复杂度:$\mathcal{O}(1)$。

:关于这个反函数的研究,见朗伯 W 函数(Lambert W function)。

分类题单

如何科学刷题?

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

文件操作:showDirectoryPicker

作者 gnip
2025年9月4日 23:59

概述

在传统的前端开发中,处理用户文件一直是个棘手的问题。虽然 <input type="file"> 提供了基本的文件选择功能,但当需要处理整个目录结构时,开发者往往需要借助复杂的后端支持或Electron等桌面框架。showDirectoryPicker() 的出现彻底改变了这一局面,它为Web应用提供了原生的目录访问能力,文件操作体验。

showDirectoryPicker

showDirectoryPicker() 是 File System Access API 的核心方法之一,它允许Web应用通过用户授权的方式访问整个目录结构,而不仅仅是单个文件。这意味着开发者现在可以在浏览器中实现以前只能在桌面应用中才能完成的文件操作。

核心特性

  • 完整的目录访问:读取、遍历、修改目录内容
  • 权限持久化:用户授权后可保存访问权限
  • 安全沙箱:在严格的用户控制下运行
  • 现代化API:基于Promise的异步设计

浏览器支持情况

只有部分浏览器支持,并且版本比较新,希望以后能够更好支持 image.png

基础使用与语法

 基本调用方式

async function selectDirectory() {
  try {
    const directoryHandle = await window.showDirectoryPicker();
    console.log('选择的目录:', directoryHandle.name);
    return directoryHandle;
  } catch (err) {
    if (err.name === 'AbortError') {
      console.log('用户取消了选择');
    } else {
      console.error('发生错误:', err);
    }
  }
}

配置选项

const options = {
  id: 'projectFolder', // 标识符,用于记住用户选择
  mode: 'readwrite',   // 权限模式:read 或 readwrite
  startIn: 'documents' // 起始目录:desktop, documents, downloads等
};

const directoryHandle = await showDirectoryPicker(options);

权限模式

  • read :仅读取权限,可以列出文件和读取内容
  • readwrite :读写权限,可以创建、修改、删除文件

实际应用场景

遍历文件内容

async function* walkDirectory(directoryHandle, path = '') {
  for await (const entry of directoryHandle.values()) {
    const entryPath = `${path}/${entry.name}`;
    
    if (entry.kind === 'directory') {
      yield* await walkDirectory(entry, entryPath);
    } else {
      yield {
        handle: entry,
        path: entryPath,
        kind: 'file'
      };
    }
  }
}

// 使用示例
async function printDirectoryTree() {
  const directoryHandle = await showDirectoryPicker();
  
  for await (const entry of walkDirectory(directoryHandle)) {
    console.log(entry.path);
  }
}

printDirectoryTree()

解析本地文件夹可视化到线上

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>

    <button>打开文件</button>


    <script>

        const btn = document.querySelector('button');

        btn.addEventListener('click', async () => {

            async function readDirectoryRecursive(directoryHandle, path = '') {
                const results = {
                    path: path || directoryHandle.name,
                    files: [],
                    directories: [],
                    complete: false,
                    totalFiles: 0
                };

                try {
                    for await (const entry of directoryHandle.values()) {
                        const entryPath = `${path}/${entry.name}`;

                        if (entry.kind === 'file') {
                            results.files.push({
                                name: entry.name,
                                path: entryPath,
                                handle: entry
                            });
                            results.totalFiles++;

                        } else if (entry.kind === 'directory') {
                            // 递归读取子目录
                            const subDirResult = await readDirectoryRecursive(entry, entryPath);
                            results.directories.push(subDirResult);
                            results.totalFiles += subDirResult.totalFiles;
                        }
                    }

                    results.complete = true;
                    return results;

                } catch (error) {
                    results.error = error;
                    results.complete = false;
                    throw error;
                }
            }

            // 使用示例
            async function main() {
                const directoryHandle = await showDirectoryPicker();
                const result = await readDirectoryRecursive(directoryHandle);

                if (result.complete) {
                    console.log(`递归读取完成,共找到 ${result.totalFiles} 个文件`);
                    console.log('目录结构:', result);

                    processFile(result);
                    async function processFile(result) {
                        if (result.files && result.files.length) {
                            for (let i = 0; i < result.files.length; i++) {
                                const fileHandle = result.files[i].handle;
                                const file = await fileHandle.getFile();
                                console.log("file----", file);
                                const fileReader = new FileReader();

                                fileReader.onload = (e) => {
                                    console.log("fileReader.result---", e.target.result);
                                }
                                fileReader.readAsText(file);
                            }
                        }

                        if (result.directories && result.directories.length) {
                            for (let i = 0; i < result.directories.length; i++) {
                                const dirRes = result.directories[i];
                                processFile(dirRes);
                            }
                        }

                    }
                }
            }
            main()




        });
    </script>
</body>

</html>

做一个 3D 图片画廊

作者 繁依Fanyi
2025年9月4日 23:08

有时候我会在深夜翻看自己硬盘里堆积的照片,从旅行的风景到日常的瞬间,它们零散地躺在文件夹里,显得有些杂乱。每次打开都要一张一张点开,不仅体验差,还很难有那种“沉浸感”。于是我萌生了一个想法:为什么不做一个 3D 图片画廊呢?让相册的浏览方式更有仪式感,不再是普通的平铺,而是像旋转的立体展示柜一样,既美观又有趣。这个想法听起来挺酷,但真正要实现却没那么简单。我决定从零开始搭建一个属于自己的 3D 图片画廊。

最初我脑海中想象的是一个可以用鼠标或触控去旋转的立方体,每一面都展示不同的照片,用户可以通过交互来切换视角。我也考虑过环形画廊的形式,类似一个旋转的圆盘,照片沿着圆周分布,随着操作而流动切换。最终我确定要先做一个环形画廊版本,后续再扩展成立方体或其他形状,这样循序渐进会更容易。

在这里插入图片描述

有了这样的整体逻辑,我心里就更清晰了。接下来要考虑的就是技术栈的选择。我的第一直觉是用 CSS3 的 transform 来实现,因为 CSS3 已经支持 rotateYtranslateZ 等属性,可以比较轻松地做出立体效果。如果后续想扩展更复杂的场景,再考虑用 Three.js 这样的专业 3D 库。于是我的第一版决定完全依赖 HTML + CSS + JavaScript 来完成。

我先搭了一个基本的 HTML 结构:一个容器 div 作为画廊主体,里面放若干张图片。每张图片都用一个 figure 元素包裹,这样更语义化一些。代码大致是这样的:

<div class="gallery">
  <figure><img src="img1.jpg" alt="pic1"></figure>
  <figure><img src="img2.jpg" alt="pic2"></figure>
  <figure><img src="img3.jpg" alt="pic3"></figure>
  <figure><img src="img4.jpg" alt="pic4"></figure>
  <figure><img src="img5.jpg" alt="pic5"></figure>
</div>

接下来重点就是 CSS。我给 .gallery 添加了 transform-style: preserve-3d;perspective,让其子元素能够在三维空间里呈现。我希望这些图片分布在一个圆环上,于是用了一个小技巧:假设有 N 张图片,那么每张图沿着圆周平均分布,每个的角度就是 360 / N。例如 5 张图片,角度间隔就是 72°。于是我给每个 figure 设置类似 rotateY(72deg * i) translateZ(300px) 的 transform,就能让它们围绕 Y 轴排成一个圆。

代码如下:

.gallery {
  width: 600px;
  height: 400px;
  margin: 100px auto;
  position: relative;
  transform-style: preserve-3d;
  perspective: 1200px;
}
.gallery figure {
  position: absolute;
  width: 260px;
  height: 180px;
  left: 170px;
  top: 110px;
  transform-origin: 50% 50% 300px;
  transition: transform 1s;
}
.gallery figure img {
  width: 100%;
  height: 100%;
  border-radius: 12px;
  box-shadow: 0 10px 25px rgba(0,0,0,0.3);
}

不过单靠 CSS 还不够,我需要用 JavaScript 来动态控制旋转。于是我加了一段逻辑:当用户点击左右按钮时,整个 gallery 容器绕 Y 轴旋转一个角度,从而展示下一个或上一个图片。角度的增量和数量相关,比如 angle = 360 / N

const gallery = document.querySelector('.gallery');
const figures = document.querySelectorAll('.gallery figure');
let angle = 360 / figures.length;
let curr = 0;

document.getElementById('next').onclick = () => {
  curr++;
  gallery.style.transform = `rotateY(${-angle * curr}deg)`;
}
document.getElementById('prev').onclick = () => {
  curr--;
  gallery.style.transform = `rotateY(${-angle * curr}deg)`;
}

运行之后,画廊真的能转起来了!当我点击“下一张”,整个环形顺时针旋转 72°,新的图片就正对用户了。那一刻的成就感特别强烈。不过问题也随之出现:图片之间的切换显得有点生硬,没有过渡动画。我仔细检查,发现我把 transition 写在了 figure 元素上,但其实旋转的是 gallery 容器,所以动画没有生效。把 transition 移到 .gallery 的 transform 上,就自然顺滑了。

再进一步,我希望这个画廊不仅能用按钮控制,还能用拖拽来旋转。于是我监听了鼠标按下、移动和松开的事件,根据拖动的距离来计算旋转角度。这样用户就能用手势滑动的方式去浏览,体验更自然。我在实现过程中遇到一个小坑:如果直接用 mousemove 的距离来控制旋转,很容易出现旋转过快或过慢的问题。最后我通过一个比例系数来调整,类似于 rotateY(totalX / 5),才让手感合适。

随着功能一点点完善,我还给画廊加上了缩放与景深效果。比如当某张图片正对用户时,可以稍微放大一点,并且加重阴影,让它成为视觉焦点。这是通过在旋转结束时计算当前角度来实现的。我写了一个小函数,根据 curr 的值判断哪张图片处于正中间,然后给它添加一个 active 类名,CSS 再去控制样式。

.gallery figure.active img {
  transform: scale(1.2);
  box-shadow: 0 20px 40px rgba(0,0,0,0.5);
  transition: all 0.6s;
}

那一刻整个画廊终于有了“灵魂”,不再只是单纯的旋转,而是真的有层次感和焦点。我甚至忍不住放上了几张旅行的照片,配合着 3D 的效果,就像重新走进了当时的场景。

当然,过程中还有很多小问题,比如不同分辨率下如何保持居中,图片大小不一致时如何裁剪适配,如何在移动端保持流畅度等等。我慢慢一点点解决,比如用 object-fit: cover 来保证图片比例不变形,或者用媒体查询去适配不同的屏幕。

写到这里,我发现这篇文章已经写了不少内容,不过这还只是开始。我还想讲讲我如何给画廊加上自动旋转、背景音乐、灯光特效,甚至用 Three.js 做进阶版的立体空间效果。


基本的旋转功能做出来之后,我就开始思考如何让这个画廊更加生动。最直接的一个想法就是增加自动旋转。毕竟,如果用户不操作,整个画廊停在那里未免显得有些死板。于是我在 JavaScript 里写了一个 setInterval 定时器,每隔三秒自动执行一次 curr++ 并触发旋转。这样画廊就会缓慢自转,照片一张张轮流呈现在正中央。

不过实际运行后,我发现自动旋转和手动拖拽之间存在冲突。当我在拖动时,如果定时器也在运行,就会出现画廊突然“抢走”控制权的情况。我想了一个办法:当检测到用户在按下鼠标或手指触摸时,就 clearInterval 停止自动旋转,等松开之后再重新启动定时器。这样逻辑就顺畅多了。

let autoRotate = setInterval(() => {
  curr++;
  gallery.style.transform = `rotateY(${-angle * curr}deg)`;
}, 3000);

gallery.addEventListener('mousedown', () => clearInterval(autoRotate));
gallery.addEventListener('mouseup', () => {
  autoRotate = setInterval(() => {
    curr++;
    gallery.style.transform = `rotateY(${-angle * curr}deg)`;
  }, 3000);
});

当我第一次看到画廊自己慢慢转动的时候,那种观感真的很舒服,就像商场里旋转展示的橱窗一样,静静地展示着每张照片。

不过在优化体验时,我也注意到一个问题:旋转的速度不能太快,否则眼睛会跟不上;也不能太慢,否则就没什么意思。我测试了几次,发现三秒一张是比较理想的节奏。同时我在 CSS 动画里调了 transition 的时间,让旋转的过渡在 1 秒左右完成,这样既不会显得突兀,也不会太拖沓。


我还想给画廊增加一点“舞台感”。光有旋转还不够,我希望背景也能有层次,于是我给画廊容器外层加了一个渐变背景,类似舞台灯光的感觉。

body {
  background: radial-gradient(circle at center, #222, #000);
  overflow: hidden;
}

效果一出来,画廊就像悬浮在舞台中央,被一束聚光灯照亮。我还给每张图片加了一点反射效果,用 ::after 做了一个渐变透明的镜像,这让照片看起来像摆在玻璃台面上。

.gallery figure img::after {
  content: '';
  position: absolute;
  top: 100%;
  left: 0;
  width: 100%;
  height: 100%;
  background: linear-gradient(to bottom, rgba(255,255,255,0.3), transparent);
  transform: scaleY(-1);
}

虽然只是一个小小的装饰,但视觉效果立刻丰富了很多。


在交互上,我想让用户能够用键盘操作。比如按左右方向键可以旋转,按空格键暂停或恢复自动播放。实现起来很简单,只需要监听 keydown 事件:

document.addEventListener('keydown', (e) => {
  if(e.key === 'ArrowRight') {
    curr++;
    gallery.style.transform = `rotateY(${-angle * curr}deg)`;
  }
  if(e.key === 'ArrowLeft') {
    curr--;
    gallery.style.transform = `rotateY(${-angle * curr}deg)`;
  }
  if(e.key === ' ') {
    if(autoRotate) {
      clearInterval(autoRotate);
      autoRotate = null;
    } else {
      autoRotate = setInterval(() => {
        curr++;
        gallery.style.transform = `rotateY(${-angle * curr}deg)`;
      }, 3000);
    }
  }
});

这样一来,用户可以完全不用鼠标,只靠键盘就能操控整个画廊。


到这里,功能算是比较完整了,但我还不满意。我想要更“沉浸”的感觉。于是我尝试给画廊加上背景音乐。进入页面时,音乐自动播放,伴随着图片旋转,整个人的心境会进入一种“回忆”的氛围。

我用 HTML5 <audio> 标签加载一首轻音乐:

<audio id="bgm" autoplay loop>
  <source src="music.mp3" type="audio/mpeg">
</audio>

不过自动播放在很多浏览器上默认是禁止的,除非用户有过交互。我就加了一个“播放音乐”的按钮,用户点一下就能开启背景音乐。顺便我还给音乐播放加了一个小小的旋转唱片动画,摆在画廊右下角,既是装饰,也是控制按钮。

在这里插入图片描述


这次的 3D 画廊不仅让我学到了很多 CSS3 和 JavaScript 的知识,更重要的是让我体会到了从无到有构建一个作品的乐趣。

Nuxt 3 微前端:模块导入导出与路由跳转实战

作者 excel
2025年9月4日 22:59

在 Nuxt 3 中使用微前端(Micro Frontend, MFE)可以实现模块化、按需加载和跨应用路由跳转。相比 Nuxt 2,Nuxt 3 提供了原生 Composition API、Vite 支持以及更灵活的模块系统,使得微前端集成更方便。


1. 概念

  • 主应用(Host) :负责加载和渲染子应用,管理全局路由与状态。
  • 子应用(Remote) :独立部署的模块化 Nuxt 3 应用,可通过 Module Federation 暴露组件或页面。
  • Module Federation:Webpack 5 的微前端实现方案,允许子应用暴露模块,主应用按需加载。
  • 路由跳转:微前端下,需处理主应用和子应用之间的路由通信。

2. 原理

2.1 子应用导出模块

Nuxt 3 配合 Webpack Module Federation,通过 exposes 导出组件:

// remoteApp/nuxt.config.ts
import { defineNuxtConfig } from 'nuxt/config'
import { ModuleFederationPlugin } from 'webpack'

export default defineNuxtConfig({
  build: {
    extend(config) {
      config.plugins?.push(
        new ModuleFederationPlugin({
          name: 'remoteApp',
          filename: 'remoteEntry.js',
          exposes: {
            './Widget': './components/Widget.vue'
          },
          shared: ['vue', 'vue-router']
        })
      )
    }
  }
})
  • name:子应用名称。
  • filename:远程入口文件。
  • exposes:导出的组件列表。
  • shared:共享依赖,避免重复打包。

2.2 主应用导入模块

主应用通过 remotes 引入子应用组件:

// hostApp/nuxt.config.ts
import { defineNuxtConfig } from 'nuxt/config'
import { ModuleFederationPlugin } from 'webpack'

export default defineNuxtConfig({
  build: {
    extend(config) {
      config.plugins?.push(
        new ModuleFederationPlugin({
          remotes: {
            remoteApp: 'remoteApp@http://localhost:3001/remoteEntry.js'
          },
          shared: ['vue', 'vue-router']
        })
      )
    }
  }
})

主应用可以直接使用子应用暴露的组件:

<script setup lang="ts">
import Widget from 'remoteApp/Widget'
</script>

<template>
  <div>
    <h1>Host App</h1>
    <Widget />
  </div>
</template>

2.3 路由跳转

Nuxt 3 使用 Composition API 的 useRouteruseRoute 进行路由操作。微前端中:

  1. 主应用内部跳转
const router = useRouter()
router.push('/dashboard')
  1. 子应用跳转到主应用或其他子应用
// 子应用中
function navigateToHost(path: string) {
  window.dispatchEvent(new CustomEvent('navigate', { detail: path }))
}

// 主应用中
window.addEventListener('navigate', (e: any) => {
  const router = useRouter()
  router.push(e.detail)
})

3. 对比

功能 Nuxt 3 单体 Nuxt 3 微前端
模块拆分 不支持 支持按子应用拆分
部署 单包 子应用独立部署
路由 全局 需协调主子应用路由
组件复用 受限 可跨应用导入

4. 实践示例

4.1 创建子应用(Remote)

npx nuxi init remoteApp
cd remoteApp
npm install
npm install -D webpack webpack-cli
  • 配置 nuxt.config.ts 添加 Module Federation。
  • 暴露组件 Widget.vue
  • 启动服务:npm run dev,生成 remoteEntry.js

4.2 创建主应用(Host)

npx nuxi init hostApp
cd hostApp
npm install
npm install -D webpack webpack-cli
  • 配置 nuxt.config.ts 添加 remotes
  • 在页面中引入 <Widget />
  • 添加全局事件监听处理子应用路由跳转。

5. 拓展功能

  1. 状态共享:通过 Pinia 或 Vue 3 的 provide/inject 实现主子应用状态共享。
  2. 权限控制:主应用统一管理路由权限,子应用仅渲染组件。
  3. 懒加载:使用动态 import 按需加载子应用,减少主应用首屏压力。
  4. 多子应用组合:支持多个微前端模块组合成复杂系统。

6. 潜在问题

  • CSS 冲突:子应用样式可能污染主应用,建议使用 Scoped 或 CSS Module。
  • 路由冲突:子应用路由与主应用冲突时,需要命名空间或前缀处理。
  • 依赖版本冲突:Vue/Nuxt 版本需保持兼容。
  • 性能开销:过多子应用增加网络请求和运行时开销。

7. 思路图示

+-------------------+      +-------------------+
|    Host App       |      |    Remote App     |
|                   |      |                   |
| +---------------+ |      | +---------------+ |
| | Nuxt Router   |<-----> | | Widget.vue    | |
| +---------------+ |      | +---------------+ |
|                   |      |                   |
| <Widget />        |      | remoteEntry.js    |
+-------------------+      +-------------------+

用 Electron 做一个屏幕取色器

作者 繁依Fanyi
2025年9月4日 22:55

作为一名开发者,我经常会闲的没事想做些什么。于是我决定自己动手,用Electron构建一个功能完善、界面现代的屏幕取色器应用。

项目架构设计与技术选型

在开始编码之前,我花了不少时间思考整个应用的架构设计。Electron应用本质上是一个多进程架构,主进程负责应用生命周期管理和系统级操作,渲染进程负责UI展示和用户交互。对于取色器这样的应用,我需要特别考虑以下几个关键点:

在这里插入图片描述

整个应用的核心流程可以分为几个主要阶段:应用启动与初始化、取色器窗口创建、屏幕捕获与颜色提取、颜色数据管理与存储。每个阶段都有其独特的技术挑战和解决方案。

在技术选型上,我选择了Electron 28.0.0作为基础框架,这个版本在安全性和性能方面都有显著提升。为了数据持久化,我使用了electron-store库来管理用户的颜色历史和偏好设置。在UI设计方面,我采用了Microsoft的Fluent Design设计语言,通过CSS3实现了玻璃态效果和流畅的动画过渡。

主进程架构与窗口管理

主进程是整个应用的控制中心,负责管理应用的生命周期、创建和控制窗口、处理系统级操作。在我的实现中,主进程需要管理两个不同类型的窗口:主应用窗口和全屏取色器窗口。

function createMainWindow() {
  const windowConfig = store.get('windowBounds') || { width: 400, height: 600 };
  
  mainWindow = new BrowserWindow({
    width: windowConfig.width,
    height: windowConfig.height,
    minWidth: 320,
    minHeight: 450,
    frame: false,
    transparent: true,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      preload: path.join(__dirname, 'preload.js'),
      webSecurity: true
    },
    icon: path.join(__dirname, 'assets/icons/icon.png')
  });
}

主窗口的设计考虑了现代应用的用户体验需求。我禁用了默认的窗口框架(frame: false),这样可以实现自定义的标题栏设计,让应用看起来更加现代和统一。透明窗口(transparent: true)的设置让我能够实现玻璃态效果和圆角边框。在安全配置方面,我严格遵循了Electron的最佳实践:禁用Node.js集成、启用上下文隔离、使用预加载脚本进行安全的API暴露。

取色器窗口的创建更加复杂,因为它需要覆盖整个屏幕并捕获屏幕内容:

function createPickerWindow() {
    const { width, height } = screen.getPrimaryDisplay().workArea;
    
    pickerWindow = new BrowserWindow({
        width,
        height,
        x: 0,
        y: 0,
        frame: false,
        transparent: true,
        fullscreen: true,
        alwaysOnTop: true,
        skipTaskbar: true,
        resizable: false,
        movable: false,
        webPreferences: {
            nodeIntegration: false,
            contextIsolation: true,
            preload: path.join(__dirname, 'preload.js'),
            webSecurity: true
        }
    });
}

这个窗口需要始终保持在最顶层(alwaysOnTop: true),不出现在任务栏中(skipTaskbar: true),并且不能被用户调整大小或移动。这些设置确保了取色器能够正确地覆盖整个屏幕,为用户提供无干扰的取色体验。

安全的IPC通信机制

在Electron应用中,主进程和渲染进程之间的通信是通过IPC(Inter-Process Communication)机制实现的。为了确保应用的安全性,我在预加载脚本中创建了一个安全的API桥接层:

contextBridge.exposeInMainWorld('electronAPI', {
    // 颜色选择相关
    colorPicked: (colorData) => ipcRenderer.send('color-picked', colorData),
    cancelPicking: () => ipcRenderer.send('cancel-picking'),
    closePicker: () => ipcRenderer.send('close-picker-window'),
    
    // 屏幕捕获
    getScreenSources: () => ipcRenderer.invoke('get-screen-sources'),
    
    // 窗口控制
    minimizeWindow: () => ipcRenderer.send('minimize-window'),
    maximizeWindow: () => ipcRenderer.send('toggle-maximize-window'),
    closeWindow: () => ipcRenderer.send('close-window'),
    
    // 应用功能
    startPicking: () => ipcRenderer.send('start-picking'),
    saveColors: (colors) => ipcRenderer.send('save-colors', colors),
    exportColors: (colors) => ipcRenderer.send('export-colors', colors)
});

这种设计模式的优势在于,渲染进程只能访问我明确暴露的API,而不能直接访问Node.js的原生模块或Electron的主进程API。这大大降低了安全风险,特别是在处理用户输入或外部数据时。

在主进程中,我使用ipcMain来处理来自渲染进程的消息。对于一些需要返回值的操作,我使用了handle/invoke模式而不是传统的send/on模式,这样可以更好地处理异步操作和错误情况:

ipcMain.handle('get-screen-sources', async () => {
    try {
        const sources = await desktopCapturer.getSources({
            types: ['screen'],
            thumbnailSize: { width: 1, height: 1 }
        });
        
        if (!sources || sources.length === 0) {
            throw new Error('未找到屏幕源');
        }
        
        return sources[0].id;
    } catch (error) {
        console.error('获取屏幕源失败:', error);
        throw error;
    }
});

屏幕捕获与颜色提取技术

屏幕取色的核心技术是屏幕捕获和颜色提取。在Electron中,我使用了desktopCapturer API来获取屏幕内容,然后通过getUserMedia API将其转换为视频流。这个过程涉及到几个关键的技术细节:

在这里插入图片描述

首先,我需要获取屏幕的访问权限并创建视频流:

async function setupVideoStream(sourceId) {
    try {
        const stream = await navigator.mediaDevices.getUserMedia({
            audio: false,
            video: {
                mandatory: {
                    chromeMediaSource: 'desktop',
                    chromeMediaSourceId: sourceId
                }
            }
        });

        videoElement = document.createElement('video');
        videoElement.srcObject = stream;
        videoElement.style.display = 'none';
        document.body.appendChild(videoElement);
        
        return new Promise((resolve) => {
            videoElement.onloadedmetadata = async () => {
                await videoElement.play();
                isVideoReady = true;
                resolve();
            };
        });
    } catch (error) {
        console.error('设置视频流失败:', error);
        throw error;
    }
}

这里有一个重要的技术细节:我将video元素设置为不可见(display: none),因为它只是作为数据源使用,用户不需要看到实际的视频内容。真正的显示是通过Canvas来实现的。

放大镜功能是整个取色器的核心用户体验。我使用Canvas API来实现实时的屏幕内容放大显示:

function updateMagnifier(x, y) {
    if (!isVideoReady || !videoElement) return;
    
    try {
        const centerX = magnifierSize / 2;
        const centerY = magnifierSize / 2;
        
        // 计算缩放比例
        const scaleX = videoElement.videoWidth / window.innerWidth;
        const scaleY = videoElement.videoHeight / window.innerHeight;
        
        // 优化取样区域的计算
        const sourceX = Math.max(0, Math.min(x * scaleX - centerX / zoomFactor, 
            videoElement.videoWidth - magnifierSize / zoomFactor));
        const sourceY = Math.max(0, Math.min(y * scaleY - centerY / zoomFactor, 
            videoElement.videoHeight - magnifierSize / zoomFactor));
        
        magnifierCtx.clearRect(0, 0, magnifierSize, magnifierSize);
        
        // 使用 imageSmoothingEnabled 提高放大质量
        magnifierCtx.imageSmoothingEnabled = false;
        
        magnifierCtx.drawImage(
            videoElement,
            sourceX,
            sourceY,
            magnifierSize / zoomFactor,
            magnifierSize / zoomFactor,
            0,
            0,
            magnifierSize,
            magnifierSize
        );
    } catch (error) {
        console.error('更新放大镜错误:', error);
    }
}

这段代码中有几个关键的优化点。首先是坐标系的转换:屏幕坐标需要转换为视频坐标,因为视频的分辨率可能与屏幕显示分辨率不同。其次是边界检查:确保取样区域不会超出视频的边界。最重要的是设置imageSmoothingEnabled为false,这样可以保持像素的锐利度,避免在放大时出现模糊效果。

颜色提取是通过Canvas的getImageData API实现的:

function handleClick(e) {
    if (!isVideoReady) return;
    
    const centerX = magnifierSize / 2;
    const centerY = magnifierSize / 2;
    
    try {
        const pixelData = magnifierCtx.getImageData(centerX, centerY, 1, 1).data;
        const [r, g, b] = pixelData;
        const hexColor = rgbToHex(r, g, b);
        
        window.electronAPI.colorPicked({
            hex: hexColor,
            rgb: `rgb(${r}, ${g}, ${b})`,
            hsl: rgbToHsl(r, g, b),
            timestamp: Date.now()
        });
    } catch (error) {
        console.error('取色错误:', error);
    }
}

高性能渲染优化

在开发过程中,我发现放大镜的实时更新会带来性能问题,特别是在高分辨率屏幕上。鼠标移动事件的频率非常高,如果每次都立即更新Canvas,会导致CPU使用率过高和界面卡顿。为了解决这个问题,我采用了几种优化策略。

首先是使用requestAnimationFrame来控制更新频率:

function handleMouseMove(e) {
    if (!isVideoReady) return;
    
    // 取消上一帧的请求
    if (lastRaf) {
        cancelAnimationFrame(lastRaf);
    }
    
    // 使用 requestAnimationFrame 优化性能
    lastRaf = requestAnimationFrame(() => {
        const x = e.clientX;
        const y = e.clientY;
        
        // 使用 transform 代替 left/top 提高性能
        magnifier.style.transform = `translate(${x}px, ${y}px)`;
        updateMagnifier(x, y);
    });
}

这种方法确保了更新频率不会超过浏览器的刷新率(通常是60fps),同时通过取消上一帧的请求来避免积压。另外,我使用CSS的transform属性而不是left/top来移动放大镜,因为transform会触发GPU加速,性能更好。

在CSS方面,我也做了相应的优化:

#magnifier {
    will-change: transform;  /* 优化性能 */
    transition: transform 0.05s cubic-bezier(0.23, 1, 0.32, 1);
}

will-change属性告诉浏览器这个元素的transform属性会频繁变化,浏览器会为其创建合成层,从而提高渲染性能。

现代化UI设计与用户体验

在UI设计方面,我采用了Microsoft的Fluent Design设计语言,这是一种强调光线、深度、运动和材质的现代设计风格。整个应用的视觉设计围绕着几个核心原则:简洁性、一致性、可访问性和美观性。

:root {
  /* Fluent Design 风格的配色方案 */
  --primary: #0078d4;
  --primary-light: #2b88d8;
  --primary-dark: #106ebe;
  
  /* 中性色 */
  --bg: #fafafa;
  --surface: rgba(255, 255, 255, 0.98);
  --text: #323130;
  --text-secondary: #605e5c;
  
  /* Fluent 设计阴影 */
  --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.08);
  --shadow: 0 4px 8px rgba(0, 0, 0, 0.12);
  --shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.14);
  
  /* 流畅动画 */
  --transition: 180ms cubic-bezier(0.16, 1, 0.3, 1);
}

玻璃态效果是现代UI设计的一个重要元素,我通过CSS的backdrop-filter属性来实现:

.glass-effect {
  background: var(--surface);
  backdrop-filter: blur(20px) saturate(180%);
  border: 1px solid rgba(0, 0, 0, 0.06);
  box-shadow: var(--shadow-sm);
}

这种效果创造了一种半透明的玻璃质感,让界面看起来更加现代和精致。backdrop-filter属性会对元素后面的内容应用模糊和饱和度调整,创造出真实的玻璃效果。

在动画设计方面,我使用了精心调校的缓动函数来创造流畅自然的过渡效果:

.primary-button {
  transition: var(--transition);
}

.primary-button:hover {
  background: var(--primary-light);
  transform: translateY(-1px);
}

.primary-button:active {
  background: var(--primary-dark);
  transform: translateY(0);
}

这种微妙的垂直移动效果模拟了按钮被按下的物理感觉,增强了用户的交互反馈。

颜色数据管理与持久化

颜色数据的管理是这个应用的核心功能之一。用户需要能够查看历史记录、收藏常用颜色、导出颜色数据等。我设计了一个完整的颜色数据管理系统来处理这些需求。

在这里插入图片描述

数据持久化是通过localStorage和electron-store两种方式实现的。localStorage用于快速的本地存储,而electron-store用于更可靠的跨会话数据保存:

saveColorsToStorage() {
    try {
        const colorsData = {
            history: this.colorHistoryData,
            favorites: this.colorFavoritesData
        };
        
        localStorage.setItem('savedColors', JSON.stringify(colorsData));
        window.electronAPI.saveColors(colorsData);
    } catch (error) {
        console.error('保存颜色数据失败:', error);
        this.showNotification('保存失败', 'error');
    }
}

在颜色历史管理方面,我实现了智能的去重和排序机制。当用户选择一个已存在的颜色时,系统会将其移动到历史记录的顶部,而不是创建重复条目:

addColor(colorData) {
    // 检查是否已存在
    const existingIndex = this.colorHistoryData.findIndex(c => c.hex === colorData.hex);
    if (existingIndex !== -1) {
        // 移动到顶部
        this.colorHistoryData.splice(existingIndex, 1);
    }
    
    // 添加到历史记录顶部
    this.colorHistoryData.unshift(colorData);
    
    // 限制历史记录数量
    if (this.colorHistoryData.length > 100) {
        this.colorHistoryData = this.colorHistoryData.slice(0, 100);
    }
    
    this.saveColorsToStorage();
    this.updateUI();
}

多格式颜色导出功能

颜色导出功能是为了满足不同用户的需求而设计的。设计师可能需要JSON格式的颜色数据,前端开发者可能更喜欢CSS变量格式,而使用Sass的开发者则需要SCSS变量格式。我实现了一个灵活的导出系统来支持这些不同的格式:

generateExportContent(format) {
    const allColors = [...this.colorFavoritesData, ...this.colorHistoryData];
    const uniqueColors = allColors.filter((color, index, self) => 
        index === self.findIndex(c => c.hex === color.hex)
    );
    
    switch (format) {
        case 'json':
            return JSON.stringify(uniqueColors, null, 2);
        
        case 'css':
            let cssContent = ':root {\n';
            uniqueColors.forEach((color, index) => {
                const name = `--color-${index + 1}`;
                cssContent += `  ${name}: ${color.hex};\n`;
            });
            cssContent += '}\n';
            return cssContent;
        
        case 'scss':
            let scssContent = '';
            uniqueColors.forEach((color, index) => {
                const name = `$color-${index + 1}`;
                scssContent += `${name}: ${color.hex};\n`;
            });
            return scssContent;
        
        default:
            return '';
    }
}

导出功能还包括实时预览,用户可以在导出前看到生成的内容格式,这大大提高了用户体验。

系统集成与全局快捷键

为了让应用更加便于使用,我实现了系统托盘集成和全局快捷键功能。用户可以通过系统托盘快速访问应用功能,也可以使用键盘快捷键在任何时候启动取色功能。

function createTray() {
  try {
    tray = new Tray(path.join(__dirname, 'assets/icons/tray-icon.png'));
    
    const contextMenu = Menu.buildFromTemplate([
      { label: '显示', click: () => mainWindow.show() },
      { label: '取色', click: startColorPicking },
      { type: 'separator' },
      { label: '退出', click: () => app.quit() }
    ]);
    
    tray.setToolTip('屏幕取色器');
    tray.setContextMenu(contextMenu);
    
    tray.on('click', () => {
      if (mainWindow) {
        if (mainWindow.isVisible()) {
          mainWindow.hide();
        } else {
          mainWindow.show();
        }
      }
    });
  } catch (error) {
    console.error('创建系统托盘失败:', error);
  }
}

全局快捷键的实现让用户可以在任何应用中快速启动取色功能:

// 注册全局快捷键
globalShortcut.register('CommandOrControl+Shift+C', startColorPicking);

这个快捷键组合(Ctrl+Shift+C)是经过仔细考虑的,它不太可能与其他应用的快捷键冲突,同时也容易记忆(C代表Color)。

在这里插入图片描述


这个屏幕取色器不仅仅是一个工具,更是我对现代应用开发理念的实践。从用户体验设计到技术架构选择,从性能优化到安全考虑,每一个细节都体现了对品质的追求。虽然开发过程中遇到了不少挑战,但正是这些挑战让我学到了更多宝贵的经验。

如何错误手写 ES2025 新增的 Promise.try() 静态方法

2025年9月4日 22:48

全文速览

欢迎关注 前端情报社。大家好,我是社长林语冰。

Promise 从 ES2015 成为 JavaScript 的一部分。10 年后,ES2025 是第 16 版 JavaScript 语言规范,它新增了 9 种颠覆性功能,Promise.try() 就是其中之一。

Promise.try() 提案并非原创,ES2025 之前,bluebird 和 p-try 等流行库就提供了等价的功能。但我发现 GitHub 上一些遗留源码,为了不使用第三方库,自己会尝试手写模拟实现 Promise.try() 的功能,但部分实现采用了下列错误方案:

  • Promise.resolve() 会导致异常逃逸
  • Promise.prototype.then() 会产生多余的微任务

本文我们会探讨 ES2025 最新 Promise.try() 静态方法的基本用法,以及如何正确手写 Promise.try()

使用场景

Promise.try() 适用于将回调封装为 Promise 风格,然后安全开启链式调用的场景:

import { readFile } from 'node:fs/promises'

function readLocalFile(path) {
  if (!path) {
    throw new Error('path 不能为空')
  }

  path = new URL(path, import.meta.url)

  return readFile(path, { encoding: 'utf8' })
}

Promise.try(readLocalFile).catch(console.log) // ❌ path 不能为空
Promise.try(readLocalFile, './package.json').then(console.log) // ✅

这里,Promise.try() 会接受一个回调,并将返回值转化为 promise,以便后续开启链式调用。此外,回调内部的同步/异步异常,都会被捕获并转化为失败的 promise 实例。

只有正确掌握 Promise.try() 的行为机制,我们才能正确手写模拟 Promise.try()

异常逃逸

GitHub 上一些遗留代码采用了 ES6 的 Promise.resolve() 来模拟 Promise.try() 的行为,其实是一种错误的方案:

Promise.try = function promiseTry(fn, ...args) {
  return Promise.resolve(fn(...args))
}

Promise.try(readLocalFile, './package.json').then(console.log) // ✅
Promise.try(readLocalFile).catch(console.log) // ❌ 报错,异常逃逸

这里,Promise.resolve() 虽然能将回调的返回值封装为 promise 实例,但它无法捕获回调内部的同步异常。所以,同步异常会逃逸,最终导致程序执行终端并报错。

同理,采用 ES2024 新增的 Promise.withResolvers() 方法,也会导致异常逃逸:

Promise.try = function promiseTry(fn, ...args) {
  let { promise, resolve } = Promise.withResolvers()
  resolve(fn(...args))
  return promise
}

Promise.try(readLocalFile, './package.json').then(console.log) // ✅
Promise.try(readLocalFile).catch(console.log) // ❌ 报错,异常逃逸

如果你非要用上述两种 API 来模拟实现 Promise.try(),那只能手动 try/catch 处理同步异常,并转化为失败的 promise。

以 ES2024 的 Promise.withResolvers() 为例:

Promise.try = function promiseTry(fn, ...args) {
  let { promise, resolve, reject } = Promise.withResolvers()
  try {
    resolve(fn(...args))
  } catch (e) {
    reject(e)
  }
  return promise
}

这种方案允许我们捕获同步异常,并转化为失败的 promise,但混用了同/异步的异常处理方式,比超人内裤外穿还碍眼。

丢失同步行为

为了利用 Promise 自动捕获同步异常的机制,有人采用了 then() 方法来包裹:

Promise.try = function promiseTry(fn, ...args) {
  return Promise.resolve().then(() => fn(...args))
}

Promise.try(readLocalFile).catch(console.log) // ❌ path 不能为空
Promise.try(readLocalFile, './package.json').then(console.log) // ✅

这里,我们利用了 then() 方法的底层机制,其内部会自动捕获异常,并转化为失败的 promise,不需要我们手动 try/catch

可以看到,在这种场景下,我们得到了和原生 Promise.try() 一致的结果。bug 在于,fn() 函数不要求一定是异步函数,它可能是一个同步执行的回调,但我们将其放在 then() 方法中,它被强制转化为一个永远只能异步执行的微任务。

热补丁

不同于 then()new Promise()executor() 是同步调用的 阻塞型回调

console.log('sync:', 1)

function maybeSync() {
  console.log('maybeSync:', 2)
  throw new Error('同步异常')
}

new Promise(function executor(resolve) {
  resolve(maybeSync())
})
  .then(() => {
    console.log('async:', 3)
  })
  .catch((e) => {
    console.log(`catch ${e}:`, 4)
  })

console.log('sync:', 5)
/**
 * sync: 1
 * maybeSync: 2
 * sync: 5
 * catch 同步异常: 4
 */

因此,ES2025 之前,采用 new Promise() 模拟 Promise.try() 是一种可行的 热补丁

Promise.try = function promiseTry(fn, ...args) {
  return new Promise((resolve, reject) => {
    try {
      resolve(fn(...args))
    } catch (e) {
      reject(e)
    }
  })
}

这里,new Promise() 内部调用回调,同时将返回值封装为一个 promise 实例。

由于 ES6 标准的 Promise 构造函数内部会自动捕获异常,并转化为失败的 promise 实例,所以上述代码可以优化为:

Promise.try = function (f, ...args) {
  return new Promise((resolve) => {
    resolve(f(...args))
  })
}

两种实现方式的功能是等价的,只是后者更加精简。

此外,async function 也能模拟 Promise.try()

Promise.try = async function (f, ...args) {
  return f(...args)
}

那为何还需要 Promise.try()

async function 初学者会误解其内代码都异步执行,其实没有 awaitasync function 会同步执行。同理,它们会误解 async function 始终返回成功的 promise,除非函数体中存在 try/catch

Promise.try() 更直观,能减少初学者的认知负荷。TC39 委员如是说,“Promise API 和 async/await 语法应互补实现等价功能,Promise.try() 是缺失的拼图。async/await 语法无法取代 Promise API,让它们并行不悖至关重要。”

高潮总结

根据《ecmascript 语言规范》,ES2025 新增 promise.try() 静态方法,用于调用可能返回 promise 的回调,最终返回 promise。

实际开发中,部分用户为了不安装第三方模块,会手动模拟实现 Promise.try() 方法,在不兼容的平台中使用这种现代 API。然而,部分实现采用了 Promise.resolve()then() 方法错误实现,会不小心引入 bug。

推荐采用原生 Promise.try(),或集成 polyfill 扩展来重构代码屎山,消除技术负债。如果要手写 Promise.try(),请使用 new Promise() 的方案。

做一个石头剪刀布小游戏

作者 繁依Fanyi
2025年9月4日 22:46

最近我无意间看到一个非常有趣的小游戏,玩法看起来简单却颇有意思。它不是传统意义上的石头剪刀布,而是把这种经典的博弈规则搬到屏幕上,变成了一场群体之间的“进化战争”。石头、剪刀和布不再只是单一的手势,而是成群结队的小兵,它们在屏幕上乱跑,彼此相遇时遵循石头剪刀布的规则来吞并对方,逐渐演化出谁能统治整个画布的局面。第一次看到的时候,我就被这种群体对抗的视觉效果吸引住了,心里也萌生了一个念头:我是不是可以自己实现一个这样的游戏,并且在这个过程中做一些个性化改造?

最初我想的很简单,就是在屏幕上生成一些石头、剪刀和布,用 emoji 表情代替素材,毕竟这样省去了加载图片的麻烦,而且在现代浏览器里表情符号的兼容性已经相当不错。但我很快就发现,有些表情符号在部分浏览器环境下可能并不能正常显示,于是我也准备了退路,如果遇到显示问题,我会考虑换成其他符号或者干脆画一些简化的几何图形。不过幸运的是,在我的环境下石头(✊)、剪刀(✌️)和布(✋)都能正常展示,这让我可以毫无心理负担地继续推进。

真正让我觉得要动手做的一个原因是,我不满足于原本那个版本的“单个控制”。很多实现只是让你选中某一个棋子去移动,而这种设计的问题很明显:一旦你控制的那个棋子被对方吃掉,游戏体验就会戛然而止。这显然和我想要的群体策略对抗完全不一样。于是我决定让玩家控制的是一个“阵营”,比如你选择剪刀,那么整个剪刀军团都会响应你的操作,整体朝某个方向缓慢移动,而不是操控某个单体单位。这样一来,游戏体验就完全不同了,你不是某个兵卒,而是整个军团的将军。

在开始编程之前,我习惯性地先把这个游戏的逻辑梳理了一遍,用一个简单的流程图来帮助我理清思路。整个游戏的循环大致是这样的:首先生成随机分布的石头、剪刀和布单位;然后不断进行更新,每个单位都会根据速度移动;玩家可以通过输入来调整自己阵营的整体趋势;当两个不同阵营的单位接触时,按照石头剪刀布的规则来决定胜负,败者被转化为胜者阵营;不断循环下去,直到某个阵营统治全场。

在这里插入图片描述

在脑子里理顺了逻辑之后,我就开始着手写代码。我选择用 HTML + JavaScript + Canvas 来实现,原因很简单:这种组合足够轻量,不需要复杂的构建工具,写出来的东西直接放到浏览器里就能运行,调试和修改都很方便。而且 Canvas 天生就适合绘制大量的小元素并不断刷新,刚好契合这个项目的需求。

我首先搭建了一个最基本的框架:在页面里放一个全屏的 <canvas>,然后通过 JavaScript 获取上下文对象。接着我写了一个实体类 Entity 来代表游戏中的每个小兵,它包含 type(石头、剪刀或布)、坐标 xy、速度 vxvy,还有一个绘制方法 draw,用来把对应的 emoji 渲染到画布上。这个阶段的目标只是让屏幕上出现一些随机分布的表情符号,能动起来就算成功。

写完之后我运行了一下,果然屏幕上出现了一堆 ✊✋✌️,它们各自朝着随机方向移动,看起来像一群小精灵在乱舞。不过很快问题就来了,这种随机直线运动实在是太生硬了,每个单位看起来像一颗子弹,直来直去,完全没有那种“漂移”的丝滑感。于是我意识到,需要在移动逻辑上做改造。具体来说,我不能让它们每一帧都沿着固定方向移动固定距离,而应该引入一种“目标趋势”的概念。比如说玩家输入了向右移动的指令,那么我给整个阵营设定一个目标速度向量,而当前速度会逐渐逼近这个目标速度,这样就会产生一种缓动效果,画面看上去更自然。

我把这个逻辑写进了更新函数里,大致的公式是这样的: vx = vx * 0.9 + targetVx * 0.1 vy = vy * 0.9 + targetVy * 0.1 这样一来,当前速度会在每一帧都逐渐贴近目标速度,而不会立即跳过去,从而实现类似漂移的感觉。等我重新跑一遍,果然画面舒服了很多,每个单位的运动不再是死板的直线,而是带有惯性,移动过程有一种柔和的流畅感。

当基本的移动效果做出来之后,我开始考虑玩家控制逻辑。我决定采用“阵营整体控制”的方式,也就是当玩家选择了某个阵营,比如剪刀,那么在键盘上按方向键或者在手机上滑动,就能让整个剪刀军团一起调整方向。这部分实现的关键在于给每个属于该阵营的单位施加一个额外的速度偏移,让它们整体向某个方向漂移。至于其他阵营,它们则保持原有的随机运动,不受玩家影响。这样一来,玩家就能感受到带领军团作战的快感,而不是局限于一个小兵的命运。

在这里插入图片描述

代码里我写了一个全局变量 playerFaction 来记录玩家阵营,值可以是 "rock", "scissors", "paper"。在初始化的时候,玩家可以选择控制哪一方。然后我在键盘事件监听函数里检测方向键,如果按下了右方向键,就把该阵营的 targetVx 设为正数;如果是上方向键,就把 targetVy 设为负数。移动端我也准备了一个方案,可以通过监听触摸事件,根据手指滑动方向来设定偏移。这样就实现了 PC 和移动端的双重适配。

接下来最核心的部分就是碰撞检测与规则判定了。逻辑很清晰:如果一个石头和一个剪刀碰撞,那么剪刀会变成石头;如果一个剪刀和一张布碰撞,那么布会变成剪刀;如果一张布和一个石头碰撞,那么石头会变成布。为了让这一过程更直观,我又画了一个规则图:

在这里插入图片描述

我在代码里写了一个函数 beats(a, b),用来判断 a 是否能战胜 b,返回布尔值。然后在每一帧更新时,我遍历所有单位,检测它们是否彼此接触,如果是,就调用规则函数来决定结果,最后把败者的 type 改成胜者的类型。这种“转化”效果看起来非常有趣,画面里会出现不断扩张和收缩的阵营,像是生物之间的捕食与进化。


当我把石头剪刀布的碰撞规则写完以后,屏幕上的局面终于开始“活”了起来。每一帧更新时,我看到不同阵营的小兵在不断地吞并和被吞并,颜色和符号在画布上动态变化。第一次看到石头群体一口气吞掉一片剪刀的时候,我甚至有点小激动,因为这种变化是完全由简单的规则驱动出来的复杂动态,而我只是提供了最基本的框架。

不过很快我就发现了一些问题。第一个问题是性能瓶颈。因为在最初的实现里,我用了一个双重循环去检测所有单位之间的碰撞,这意味着如果我有 200 个单位,就需要进行接近 200 × 200 次检测,而这是一个平方级的复杂度。虽然在低数量下没什么问题,但当我尝试把单位数量提升到 500 以上的时候,帧率明显下降,画面开始变得卡顿。我立刻意识到必须优化碰撞检测逻辑。

我想到的解决办法是“空间划分”。与其让所有单位都两两比较,不如把画布划分为一个个小格子,每个单位只需要和同格子或相邻格子的单位进行比较。这样就能大大减少不必要的检测次数,因为相隔很远的单位根本不可能发生碰撞。我在代码里实现了一个简单的网格系统:比如把整个画布分成 50×50 的小块,每个单位根据自己的位置放到对应的桶里,然后只检查桶里的元素。这样优化之后,即使单位数提升到 1000,帧率依然能保持流畅。

我用一张图把这个“空间划分”的思想画了出来,方便以后给别人解释:

在这里插入图片描述

第二个问题是边界处理。在没有做边界限制之前,小兵们会很快跑到画布之外,结果整个游戏的画面越来越空,直到什么也看不到。这显然不符合预期,于是我决定让所有单位在碰到边界时“反弹”。实现也很简单:如果 x 超过画布宽度或小于零,就把 vx 取负;如果 y 超过画布高度或小于零,就把 vy 取负。这样一来,所有小兵就被限制在了画布内,不会消失掉。不过我又觉得反弹的效果太过生硬,像是台球一样。我后来调整了一下,把速度乘上一个衰减系数,这样在撞墙时会略微减速,看起来更自然一些。

接着我考虑了游戏的“终局”问题。如果任由系统无限运行下去,很可能会形成一种僵持状态,但通常情况是某一个阵营逐渐吞并掉所有对手。我希望能在某一方统治全场的时候给出一个提示,宣布获胜。于是我写了一个统计函数,每隔一定帧数就数一遍画布上三种类型的单位数量。如果有一方数量达到总数的 95% 以上,我就宣布该阵营获胜,并弹出一个简单的提示框。这样玩家就能明确知道游戏什么时候结束,而不是永远盯着屏幕。

随着逻辑越来越完整,我开始想要给整个项目写一份更清晰的结构设计。我画了一个比较精致的系统架构图,概括了游戏循环和各个模块的关系:

在这里插入图片描述

写到这里,我突然想起之前在设计“漂移效果”的时候只给玩家阵营实现了缓动,但其他阵营依然是随机直线移动的。这样看久了就觉得有点别扭,因为玩家阵营在优雅地漂移,而其他小兵却还在傻乎乎地直来直去。我于是干脆把缓动逻辑推广到所有单位,让它们在每一帧都稍微朝着一个目标速度靠拢。区别在于玩家阵营的目标速度由输入决定,而其他阵营的目标速度则是每隔一段时间随机改变一次。这样一来,整个画面都呈现出流畅的漂移动作,看起来非常协调。

在这个过程中我踩了一个小坑,就是在移动端的适配上。键盘控制在 PC 上完全没问题,但在手机上显然没人会带着方向键。我原本考虑用屏幕上的虚拟摇杆,后来觉得实现起来稍微复杂一些,于是先用了一种简化方案:监听触摸事件,根据手指滑动的方向来决定目标速度。如果玩家从左往右滑,就给阵营一个向右的目标速度;如果从上往下滑,就让阵营整体下移。虽然不如虚拟摇杆精准,但对于休闲小游戏来说已经足够了。等我调试好以后,用手机点开网页,也能轻松玩起来,那种“随时随地开一局”的感觉让我挺满足的。

到这个阶段,核心玩法和主要功能已经全部实现,我决定开始对代码进行一些整理。最重要的是把不同部分的逻辑分开,避免写成一团乱麻。我给每个模块起了单独的函数:

  • updateEntities() 用来更新位置和速度;
  • handleCollisions() 专门负责碰撞检测与规则判定;
  • render() 负责绘制画布;
  • checkVictory() 负责胜负检测;
  • handleInput() 负责处理键盘和触摸输入。

这样一来,整个代码就变得结构清晰,任何时候想修改某个功能,都可以直接去找对应的函数,而不是在上千行的循环里苦苦翻找。

我还给 Entity 类加了一些辅助方法,比如 move()changeType(newType),前者封装了速度和位置的更新,后者则用来在碰撞时改变阵营。这样每个实体的行为就相对独立,而不是完全依赖外部函数去操控。

在整理的过程中,我也顺便加了一些小的美化,比如在画布左上角显示当前三种阵营的数量,让玩家随时能看到局势的变化。我甚至加了一个小动画,当某个阵营被吞并到只剩下个位数的时候,数字会闪烁,营造一种紧张感。这些都是锦上添花的功能,但对于游戏体验来说确实有加分。

写到这里,我已经有了一个完整可玩的“石头剪刀布群体战争”,无论是在 PC 还是手机上,都能打开网页直接玩。整个开发过程让我非常享受,因为它并不是一个庞大的工程,却能在很短的时间内给我带来即时的反馈和成就感。尤其是当我解决了漂移效果和群体控制的难题以后,画面一下子从生硬变得灵动,这种转变特别直观,就像给游戏注入了生命一样。

在这里插入图片描述

Promise 再次进化,ES2025 新增 Promise.try() 静态方法

2025年9月4日 22:46

全文速览

欢迎关注 前端情报社。大家好,我是社长林语冰。

Promise 从 ES2015 成为 JavaScript 的一部分。10 年后,ES2025 是第 16 版 JavaScript 语言规范,它新增了 9 种颠覆性功能,Promise.try() 就是其中之一。

顾名思义,Promise.try()Promise 类新增了一个静态方法,它接收一个 行为不可知的阻塞型回调

  • 它可能是异步函数;
  • 它可能返回 promise;
  • 它可能引发异常
  • .....

然后 立即调用 该回调,最终返回一个 promise。

本文我们会探讨 ES2025 最新 Promise.try() 静态方法的基本用法,高级用例,底层原理和编程技巧。

ES2025 Promise.try()

Promise.try() 提案并非原创,ES2025 之前,bluebird 和 p-try 等流行库就提供了等价的功能。

bluebird 官方文档提供了基本示例:

function getUserById(id) {
  return Promise.try(function () {
    if (typeof id !== 'number') {
      throw new Error('id 要求为数字!')
    }
    return db.getUserById(id)
  })
}

getUserById().catch(console.log)
// Error: id 要求为数字!

现实开发中的代码往往错综复杂,有的业务逻辑可能混用同步/异步操作。上述代码中,输入验证是同步错误,数据库操作可能是异步操作。Promise.try() 可以用于封装这些复杂业务,确保无论回调是否异步执行或报错,都能返回一个 promise,继续链式调用。

这就是 ES2025 Promise.try() 的用途,但我们不需要再安装 bluebird 等第三方库。

具体而言,Promise.try() 接受一个行为不可知的 阻塞型回调 并立即调用它,最终返回 promise:

// ES2025 之后的写法:
// ✅️ 1. 回调返回非 promise
Promise.try(() => '同步结果').then(console.log)

// ✅️ 2. 回调同步报错
Promise.try(() => {
  throw new Error('同步异常')
}).catch(console.log)

// ✅️ 3. 回调返回成功的 promise
Promise.try(() => Promise.resolve('fulfillment')).then(console.log)

// ✅️ 4. 回调返回失败的 promise
Promise.try(() => Promise.reject('rejection')).catch(console.log)

// ✅️ 5. 回调是正常执行的异步函数
Promise.try(async () => {
  let data = await Promise.resolve('异步结果')
  // 其他业务......
  return data
}).then(console.log)

// ✅️ 6. 回调是异步报错的异步函数
Promise.try(async () => {
  try {
    let result = await Promise.reject('异步异常')
  } catch (e) {
    throw e
  }
}).catch(console.log)

可以看到,Promise.try() 是一个更加强大和通用的现代原生 API,适用于各种复杂的回调场景。

另请参考,其 TypeScript 源码的函数签名如下:

interface PromiseConstructor {
  try<T, U extends unknown[]>(
    callbackFn: (...args: U) => T | PromiseLike<T>,
    ...args: U
  ): Promise<Awaited<T>>
}

底层原理

ES2025 之前,想要实现 Promise.try() 的等价功能,除了引入第三方模块,还可以使用 new Promise() 手动封装,只要你懂得基本的底层原理。

具体而言,new Promise() 模拟 Promise.try() 的底层原理如下:

Promise.try = function (f, ...args) {
  return new Promise((resolve) => {
    resolve(f(...args))
  })
}

这里,new Promise() 内部调用回调,同时将返回值封装为一个 promise 实例。

比起安装第三方模块或手动封装,ES2025 原生的 Promise.try() 显然更符合人体工程学。

不同于 new Promise(resolve => resolve(f())) 这种遗臭万年的代码屎山,Promise.try(f) 是一种更精简的“代码高尔夫”:你能用 更少的字符 重构等价的功能。

薛定谔的异步

现实开发中,某些 API 的回调可能同步/异步执行:

let map = new Map([[1, 'cache']])

function log(data) {
  console.log(`callback: ${data}`)
}

function zalgoAPI(id, cb) {
  if (map.has(id)) {
    // 若缓存命中,则回调同步执行
    cb(map.get(id))
  } else {
    // 若缓存未命中,则回调异步执行
    setTimeout(() => {
      map.set(id, 'update data')
      cb(map.get(id))
    }, 1_000)
  }
}

console.log('sync:', 1)
zalgoAPI(1, log)
zalgoAPI(2, log)
console.log('sync:', 2)
/**
 * sync: 1
 * callback: cache
 * sync: 2
 * callback: update data
 */

“npm 之父”将这种难以预测的设计屎山称为 Zalgo 问题(混沌问题)。

为了解决 Zalgo 问题,我们可以使用 Promise.try() 简单重构,确保回调始终异步执行:

import { setTimeout as setTimeoutPromise } from 'node:timers/promises'

let map = new Map([[1, 'async cache']])

function asyncAPI(id) {
  return Promise.try(() => {
    if (map.has(id)) {
      return map.get(id)
    } else {
      return setTimeoutPromise(1_000).then(() => {
        map.set(id, 'async data')
        return map.get(id)
      })
    }
  })
}

console.log('sync:', 1)
asyncAPI(1).then(log)
asyncAPI(2).then(log)
console.log('sync:', 2)
/**
 * sync: 1
 * sync: 2
 * callback: cache
 * callback: update data
 */

实用技巧

此外,类似 setTimeout()Promise.try() 支持 实参转发

setTimeout(function closure() {
  console.log('ES2025')
}, 1_000)

// 👇️ 实参转发
setTimeout(console.log, 1_000, 'ES2025')

// ********************************

Promise.try(function closure() {
  console.log('ES2025')
})

// 👇️ 实参转发
Promise.try(console.log, 'ES2025')

两种写法功能等价,但后者减少了冗余闭包,性能更棒。

浏览器兼容性

2025 年 1 月,Promise.try() 成为 Baseline 基准可用 新特性,所有最新主流浏览器都原生支持。

在尚不支持 Promise.try() 的旧平台中,可以按需引入 polyfill(功能补丁) 优雅降级

以 GitHub 人气最高的 core-js 为例,先用 npm / pnpm 安装 core-js 模块:

npm install core-js@latest
# 或者:
pnpm install core-js@latest

然后导入开箱即用的 polyfill,更多细节请参考 core-js 官方文档:

// 集成 polyfill
import 'core-js/es/promise/try.js'

// 基本用法
Promise.try(console.log, 'Hello ES2025')

高潮总结

根据《ECMAScript 语言规范》,es2025 新增 Promise.try() 静态方法,用于调用可能返回 promise 的回调,最终返回 promise。

作为一个更通用的现代 API 糖Promise.try() 无差别执行开发者提供的回调,高效且稳健地开启 Promise 链,更符合人体工程学。

有了 Promise.try(),库作者免于反复造轮子手写样板代码,也避免了错误模拟 Promise.try() 引入潜在 bug,更避免了集成 bluebird 等第三方库增加打包体积。

推荐采用原生 Promise.try(),或集成 polyfill 扩展来重构代码屎山,消除技术负债。

dify插件开发-Dify 插件如何顺利上架应用市场?流程 + 常见问题一次讲透

作者 wwwzhouhui
2025年9月4日 22:43

1.前言

Dify 插件是 Dify 平台中的一种模块化组件,用于增强 AI 应用的能力,支持开发者通过即插即用的方式扩展平台功能。Dify 插件系统允许开发者或用户通过安装、上传和分享插件来扩展 Dify 的功能,从而提升 AI 应用的灵活性和能力。

Dify 插件的类型包括模型(Models)、工具(Tools)、代理策略(Agent Strategies)、扩展(Extensions)和插件包(Bundles)等。这些插件类型支持开发者根据具体需求选择和使用,以满足不同场景下的功能需求。例如,模型插件可以集成多种 AI 模型,工具插件可以调用第三方服务,代理策略插件可以定义代理节点的推理逻辑,扩展插件则提供端点功能,适用于简单场景。

img

之前有给大家做过关于dify插件开发的文章。《dify案例分享-零代码搞定 DIFY 插件开发:小白也能上手的文生图插件实战

和《dify案例分享-零基础上手 Dify TTS 插件!从开发到部署免费文本转语音,测试 + 打包教程全有》,上周由于google nano_banana

非常火爆我又基于dify插件做了一个nano_banana 插件,然后基于nano_banana的插件做了一个dify的工作流。

dify案例分享-国内首发!手把手教你用Dify调用Nano BananaAI画图》当时为了图方便就使用本地安装,后面安装过程中会有点小麻烦,然后我在群里面协助大家解决。

由于之前对dify 插件的上传应用市场不熟悉,导致不能及时上架dify应用市场,走了一些弯路。今天就带大家手把手教大家如何把制作好的dify 插件上传到应用市场上。 我们看一下我已经上架的三个插件

img

感兴趣的小伙伴可以在应用市场来体验三个插件。话不多说下面带大家制作如何上传这个插件。

2.插件文档要求

在上传插件市场之前文档有相关要求。

需要有一个PRIVACY.md 文档

大概内容如下

# Privacy Policy

This plugin processes your text prompts to generate images using the OpenRouter API. Here's how your data is handled:

## Data Processing

- **Text Prompts**: Your text content is sent to the OpenRouter API service to generate corresponding images
- **API Communication**: The plugin communicates with OpenRouter servers (https://openrouter.ai) to process text-to-image requests
- **Generated Images**: Image files are temporarily downloaded and processed by the plugin, then returned to your workflow
- **Model Selection**: You can choose from multiple AI models including Gemini, DALL-E, and Claude for image generation

## Data Storage

- **No Local Storage**: The plugin does not permanently store your text prompts or generated images locally
- **Temporary Processing**: All data processing is temporary and happens only during the image generation process
- **API Key Security**: Your OpenRouter API key is stored securely within your environment and is not logged or transmitted elsewhere

## Third-Party Services

- **OpenRouter API**: Your text prompts are sent to the OpenRouter image generation service to create images
- **Network Communication**: The plugin requires internet connectivity to communicate with OpenRouter servers
- **Service Provider**: OpenRouter API service (https://openrouter.ai) processes your requests according to their privacy policy
- **AI Model Providers**: Depending on your model selection, requests may be processed by Google (Gemini), OpenAI (DALL-E), or Anthropic (Claude)

## Optional Features

- **Image-to-Image Generation**: If you provide an input image URL, it will be downloaded, processed, and sent to OpenRouter for transformation
- **Image Processing**: Input images are temporarily downloaded, resized if necessary, and converted to appropriate formats before transmission

## Data Retention

- The plugin does not retain any user data after task completion
- Generated images are temporarily processed and immediately returned to your workflow
- No persistent storage of prompts, images, or user information within the plugin

## Data Transmission

- Text prompts and input images are transmitted securely over HTTPS to OpenRouter API
- Generated images are returned securely over HTTPS
- No data is shared with any other third parties beyond the OpenRouter service

有的小伙伴会问这个文档怎么写的? 不会写可以问AI 我的思路是把之前别人写的好的复制到当前项目下面。输入下面的提示词

请认真阅读我这个项目nano_banana,基于这个项目的内容修改下PRIVACY.md 关于插件项目隐私相关说明。原来的PRIVACY.md是基于别的项目参考拿过来的,需要修改的修改使用的内容用英文

我使用的是claude code . 或则ccr 模型可以用国内模型,阿里的魔搭社区每天送2000次积分,这个国内模型都可以实现。 关于ccr 安装不会? 可以看我之前的文章《免费玩转 AI 编程!Claude Code Router + Qwen3-Code 实战教程

通过上面的操作模型理解了你的项目功能并结合Privacy Policy 生成相关文档。

需要编写英文版本的README.md

这个就很好理解,你需要有一个项目介绍。因为考虑到dify走海外市场路线,所以需要英文README.md文档,我之前的文档是中文的,我让大模型给我生成

请基于README_CN.md 编写一个README.md 文档,内容都是英文的。

img

3.插件上传

首选我们需要把插件代码写好,并打包成difypkg 文件

img

关于如何打包可以看我之前的教程。这里就不做相信展开了。

fork项目

首选我们需要去 dify-plugins 项目中fork 这个项目到本地

img

我已经fork过了,所以显示我fork 后的项目

img

我们点击这个项目进入到我们fork插件项目

img

将仓库clone到本地

这里我们使用git 来clone 我这个fork的项目到本地

git clone github.com/wwwzhouhui/…

img

这个时候我们在本地就可以看到这个项目。

img

新建一个文件夹

由于我们是第一次上传,所以需要新建一个我们自己项目的文件。比如我的是wwwzhouhui,然后就是具体插件名称nano_banana

文件夹里面就是我要上传的这个插件。nano_banana.difypkg

img

创建新分支,将修改后的本地仓库搬运到分支中

接下来我们使用git 命令把这个插件提交到分支仓库中。

cd dify-plugins/
git checkout -b  2025090405
git add .
git commit -m "插件nano_banana"
git push origin 2025090405

img

pull request

这个时候我们在插件提交需要提交一个PR 实行代码的合并操作。

image-20250904215010624

完成后,进入你fork的仓库,找到该分支

点击

image-20250904175708420

填写信息提交, Plugin Author 填写我的名字、Plugin Name 填写我们上面插件名字 如:nano_banana,Repository URL 填写我们开源项目地址例如:github.com/wwwzhouhui/…

1. Metadata
Plugin Author: wwwzhouhui
Plugin Name: nano_banana
Repository URL: https://github.com/wwwzhouhui/nano_banana
2. Submission Type
 New plugin submission

 Version update for existing plugin

3. Description
4. Checklist
[x] I have read and followed the Publish to Dify Marketplace guidelines
[x] I have read and comply with the Plugin Developer Agreement
[x] I confirm my plugin works properly on both Dify Community Edition and Cloud Version
[x] I confirm my plugin has been thoroughly tested for completeness and functionality
[x] My plugin brings new value to Dify
5. Documentation Checklist
Please confirm that your plugin README includes all necessary information:

 Step-by-step setup instructions
 Detailed usage instructions
 All required APIs and credentials are clearly listed
 Connection requirements and configuration details
 Link to the repository for the plugin source code
6. Privacy Protection Information
Based on Dify Plugin Privacy Protection Guidelines:

Data Collection
Privacy Policy
 I confirm that I have prepared and included a privacy policy in my plugin package based on the Plugin Privacy Protection Guidelines

这里需要注意的是这个插件仓库对提交文件有一定的要求。必须要只能上传一个文件,并且文件是.difypkg 文件。超过多个文件是不能触发自动检查的,这里会导致自动执行action失败。

img

下面的是错误的3个文件

image-20250904215803680

按照上面要求提交后,系统自动触发action

image-20250904215853854

上面出现后等待代码合并,合并完成后就可以了。

这里非常感谢彻夜之歌 🍌布拿拿 和非法操作 两位大佬的指导

img

后面插件代码合并后我们就可以在应用市场看到了

img

4.常见问题

插件签名验证问题

之前没有使用插件市场上下载插件,通过本地上传插件会出现下面这个错误

image-20250904220325233

这个问题主要是因为插件本地安装没有通过dify 安全验证 ,所以我们需要在.env文件中修改配置

FORCE VERIFYING SIGNATURE=false

img

默认的这个FORCE VERIFYING SIGNATURE=true,

如果从插件市场下载,这里就不需要设置.

安装nano_banana生成图片显示不了

image-20250904220716587

这个问题主要是因为生成的图片链接需要通过公网访问,我们需要把下面配置修改

1.env 文件中查找FILES_URL

默认的FILES_URL是空的,我们需要修改使用 http://:5001 或 http://api:5001,在此情况下,确保外部可以访问端口 5001

image-20250510003900660

2.docker-compose.yaml 对应的FILES_URL修改

image-20250510004029749

此外dify-api容器镜像端口开放出来(默认情况是不开放的),增加如下代码

  ports:

   - '5001:5001'

image-20250510004232125

我们也可以从docker容器看到端口开放情况(默认是不开启的)

image-20250510004416081

5.总结

今天主要带大家了解并实现了将 Dify 插件上传至应用市场的完整流程,从插件文档的规范准备(包括 PRIVACY.md 隐私政策的编写、英文 README.md 的生成),到通过 fork 仓库、本地克隆、创建分支、提交 PR 等步骤完成插件上传,再到常见问题的针对性解决,形成了一套覆盖插件上架全流程的实操指南。

通过这套实践方案,开发者能够更高效地将自己开发的 Dify 插件推向应用市场 —— 无需再为文档格式、上传步骤走弯路,借助规范的流程确保插件顺利通过审核,让更多用户能够便捷地获取和使用插件,极大降低了插件分享和推广的技术门槛。在实际操作中,该流程能够稳定支持各类 Dify 插件(如模型、工具、代理策略等)的上传需求,无论是新插件首发还是版本更新,都能通过清晰的步骤完成操作,有效解决了开发者对插件上架流程不熟悉、审核易受阻的问题。同时,该流程具备良好的扩展性 —— 开发者可以基于此持续迭代插件功能,通过多次 PR 提交实现版本更新,进一步丰富 Dify 应用市场的生态,为平台带来更多样化的功能扩展,满足不同场景下的用户需求。

感兴趣的小伙伴可以按照这份指南尝试将自己开发的 Dify 插件上传至应用市场,让优质插件发挥更大价值。今天的分享就到这里结束了,我们下一篇文章见。

从零到一,制作一个项目展示平台

作者 繁依Fanyi
2025年9月4日 22:35

作为一名开发者,我一直想要一个能够完美展示自己项目作品的平台。市面上虽然有GitHub、个人博客等展示方式,但总感觉缺少一些视觉冲击力和现代感。我希望能够创建一个既有科技感又实用的项目展示平台,不仅能够清晰地展示项目信息,还能给访问者带来震撼的视觉体验。经过几个月的构思和开发,SoftHub——一个融合了科幻美学与实用功能的现代化项目展示平台终于诞生了。

项目构思与技术选型

在开始编码之前,我花了不少时间思考这个项目展示平台的核心理念。我希望这个平台不仅仅是一个简单的项目列表,而是要具备强烈的视觉冲击力和沉浸式的用户体验。我想要创造一种科幻电影般的氛围,让访问者在浏览项目的同时,也能感受到技术的魅力和未来感。

经过反复权衡,我最终选择了React + TypeScript + Vite的技术栈。React的组件化开发模式非常适合构建这种视觉效果丰富的应用,TypeScript能够提供强类型支持,确保复杂动画逻辑的稳定性,而Vite则能带来极快的开发体验,让我能够快速迭代视觉效果。为了实现科幻风格的视觉效果,我引入了Framer Motion动画库来处理复杂的页面转场和交互动画,同时还自己实现了粒子系统和天体背景动画来营造太空科幻的氛围。

在这里插入图片描述

项目架构设计

在正式开发之前,我设计了整个应用的架构。SoftHub采用了模块化的单页应用(SPA)架构,主要包含几个核心模块:科幻风格首页模块、项目展示网格模块、项目详情模块、二维码生成模块和多种视觉背景系统。整个应用的数据流采用了React的状态管理模式,通过自定义Hook来管理视觉效果、时间感知UI和滚动动画等复杂交互状态。

在这里插入图片描述

核心功能实现

时间感知UI系统设计

项目最独特的功能之一是时间感知UI系统。我希望这个平台能够根据当前时间动态调整视觉效果,营造出更加沉浸式的体验。通过分析一天中不同时段的光线变化和色彩心理学,我设计了一套完整的时间感知配色系统。

interface TimeAwareConfig {
  primaryColor: string;
  secondaryColor: string;
  particleDensity: number;
  animationIntensity: number;
  glowIntensity: number;
}

const useTimeAwareUI = (): TimeAwareConfig => {
  const [timeConfig, setTimeConfig] = useState<TimeAwareConfig>({
    primaryColor: '#00A3FF',
    secondaryColor: '#0066CC',
    particleDensity: 50,
    animationIntensity: 1.0,
    glowIntensity: 15
  });

  useEffect(() => {
    const updateTimeAwareUI = () => {
      const now = new Date();
      const hour = now.getHours();
      const minute = now.getMinutes();
      
      let config = { ...timeConfig };
      
      // 根据时间段调整主题色彩
      if (hour >= 6 && hour < 12) {
        // 清晨:清新的蓝色调
        config.primaryColor = '#00A3FF';
        config.secondaryColor = '#0066CC';
        config.glowIntensity = 12;
      } else if (hour >= 12 && hour < 18) {
        // 下午:温暖的青色调
        config.primaryColor = '#00D4FF';
        config.secondaryColor = '#0099CC';
        config.glowIntensity = 18;
      } else if (hour >= 18 && hour < 22) {
        // 傍晚:神秘的紫色调
        config.primaryColor = '#6A5ACD';
        config.secondaryColor = '#483D8B';
        config.glowIntensity = 20;
      } else {
        // 深夜:深邃的蓝紫色调
        config.primaryColor = '#1E1E3F';
        config.secondaryColor = '#2D2D5F';
        config.glowIntensity = 25;
      }
      
      // 根据分钟微调颜色亮度
      const minuteFactor = minute / 60;
      config.primaryColor = adjustColorBrightness(config.primaryColor, minuteFactor * 0.1);
      
      setTimeConfig(config);
    };

    updateTimeAwareUI();
    const interval = setInterval(updateTimeAwareUI, 60000);
    
    return () => clearInterval(interval);
  }, []);

  return timeConfig;
};

这个系统不仅仅是简单的颜色切换,它还会影响粒子密度、动画强度和发光效果,让整个界面呈现出随时间变化的动态美感。当用户在不同时间访问网站时,会看到完全不同的视觉风格,这种细节上的用心让平台具有了独特的生命力。

在这里插入图片描述

科幻粒子背景系统

为了营造科幻太空的氛围,我开发了一个复杂的粒子背景系统。这个系统不仅仅是简单的粒子动画,而是一个完整的视觉生态系统,包含了粒子生成、运动轨迹计算、粒子间连接、性能优化等多个层面的技术实现。

interface Particle {
  x: number;
  y: number;
  speedX: number;
  speedY: number;
  size: number;
  color: string;
  opacity: number;
  life: number;
  maxLife: number;
}

const SciFiParticleBackground: React.FC = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const { primaryColor, secondaryColor, particleDensity, animationIntensity } = useTimeAwareUI();

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    
    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    // 动态调整画布尺寸
    const resizeCanvas = () => {
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
    };

    // 创建粒子系统 - 根据屏幕尺寸和性能动态调整粒子数量
    const particles: Particle[] = [];
    const particleCount = Math.floor(window.innerWidth * window.innerHeight / 20000 * particleDensity / 50);
    
    for (let i = 0; i < particleCount; i++) {
      particles.push({
        x: Math.random() * canvas.width,
        y: Math.random() * canvas.height,
        speedX: (Math.random() - 0.5) * 0.5 * animationIntensity,
        speedY: (Math.random() - 0.5) * 0.5 * animationIntensity,
        size: Math.random() * 2 + 0.5,
        color: Math.random() > 0.5 ? primaryColor : secondaryColor,
        opacity: Math.random() * 0.8 + 0.2,
        life: Math.random() * 1000 + 500,
        maxLife: Math.random() * 1000 + 500
      });
    }

    // 粒子更新逻辑
    const updateParticle = (particle: Particle) => {
      particle.x += particle.speedX;
      particle.y += particle.speedY;
      particle.life--;
      
      // 边界检测和循环
      if (particle.x < 0) particle.x = canvas.width;
      if (particle.x > canvas.width) particle.x = 0;
      if (particle.y < 0) particle.y = canvas.height;
      if (particle.y > canvas.height) particle.y = 0;
      
      // 生命周期管理
      if (particle.life <= 0) {
        particle.life = particle.maxLife;
        particle.opacity = Math.random() * 0.8 + 0.2;
      }
      
      // 动态透明度变化
      particle.opacity = Math.sin(particle.life / particle.maxLife * Math.PI) * 0.8 + 0.2;
    };

    // 粒子连接系统 - 创造网络效果
    const connectParticles = (particles: Particle[], context: CanvasRenderingContext2D) => {
      for (let a = 0; a < particles.length; a++) {
        for (let b = a + 1; b < Math.min(a + 10, particles.length); b++) {
          const dx = particles[a].x - particles[b].x;
          const dy = particles[a].y - particles[b].y;
          const distance = Math.sqrt(dx * dx + dy * dy);
          
          if (distance < 120) {
            const opacity = (120 - distance) / 120 * 0.3;
            context.strokeStyle = `rgba(0, 212, 255, ${opacity})`;
            context.lineWidth = 0.5;
            context.beginPath();
            context.moveTo(particles[a].x, particles[a].y);
            context.lineTo(particles[b].x, particles[b].y);
            context.stroke();
          }
        }
      }
    };

    // 高性能动画循环
    let lastTime = 0;
    const targetFPS = 30;
    const frameInterval = 1000 / targetFPS;
    let animationId: number;

    const animate = (currentTime: number) => {
      animationId = requestAnimationFrame(animate);
      
      const deltaTime = currentTime - lastTime;
      if (deltaTime < frameInterval) return;
      
      lastTime = currentTime;
      
      // 清空画布
      ctx.fillStyle = 'rgba(10, 10, 10, 0.1)';
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      
      // 更新和绘制粒子
      particles.forEach(particle => {
        updateParticle(particle);
        
        ctx.fillStyle = `rgba(${hexToRgb(particle.color)}, ${particle.opacity})`;
        ctx.beginPath();
        ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
        ctx.fill();
      });
      
      // 绘制粒子连接
      connectParticles(particles, ctx);
    };

    resizeCanvas();
    window.addEventListener('resize', resizeCanvas);
    animate(0);

    return () => {
      window.removeEventListener('resize', resizeCanvas);
      cancelAnimationFrame(animationId);
    };
  }, [primaryColor, secondaryColor, particleDensity, animationIntensity]);

  return (
    <canvas
      ref={canvasRef}
      className="fixed inset-0 pointer-events-none z-0"
      style={{ background: 'radial-gradient(ellipse at center, #1a1a2e 0%, #0a0a0a 100%)' }}
    />
  );
};

这个粒子系统的设计考虑了多个方面的优化:首先是性能优化,通过限制帧率和减少不必要的计算来确保流畅运行;其次是视觉效果的丰富性,粒子不仅有基本的运动,还有生命周期、透明度变化和相互连接等复杂效果;最后是与时间感知系统的深度集成,粒子的颜色、密度和动画强度都会根据时间动态调整。

项目展示与交互设计

项目展示是这个平台的核心功能。我设计了一个多层次的项目展示系统,包括首页的精选项目网格、完整的项目列表页面和详细的项目展示页面。每个层级都有不同的展示重点和交互方式,确保用户能够从不同角度了解项目信息。

在ProjectGrid组件中,我实现了一个响应式的项目卡片网格系统。每个项目卡片都包含了项目的核心信息,并且具有丰富的悬停效果和点击交互。

interface Project {
  id: number;
  title: string;
  description: string;
  image: string;
  tags: string[];
  category: string;
  status: 'completed' | 'in-progress' | 'planning';
  githubUrl?: string;
  liveUrl?: string;
  downloadUrl?: string;
}

const ProjectCard: React.FC<ProjectCardProps> = ({ project, index }) => {
  return (
    <motion.div
      className="project-card"
      initial={{ opacity: 0, y: 50 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ 
        duration: 0.6, 
        delay: index * 0.1,
        type: "spring",
        stiffness: 100 
      }}
      whileHover={{ 
        y: -10,
        boxShadow: "0 20px 40px rgba(0, 212, 255, 0.3)",
        transition: { duration: 0.3 }
      }}
    >
      <div className="project-image-container">
        <img 
          src={project.image} 
          alt={project.title}
          className="project-image"
        />
        <div className="project-overlay">
          <div className="project-actions">
            <Link 
              to={`/project/${project.id}`}
              className="action-button primary"
            >
              查看详情
            </Link>
            {project.liveUrl && (
              <a 
                href={project.liveUrl}
                target="_blank"
                rel="noopener noreferrer"
                className="action-button secondary"
              >
                在线预览
              </a>
            )}
          </div>
        </div>
      </div>
      
      <div className="project-content">
        <h3 className="project-title">{project.title}</h3>
        <p className="project-description">{project.description}</p>
        
        <div className="project-tags">
          {project.tags.map(tag => (
            <span key={tag} className="project-tag">
              {tag}
            </span>
          ))}
        </div>
        
        <div className="project-meta">
          <span className={`project-status ${project.status}`}>
            {project.status === 'completed' ? '已完成' : 
             project.status === 'in-progress' ? '开发中' : '计划中'}
          </span>
        </div>
      </div>
    </motion.div>
  );
};

为了让项目展示更加生动,我还实现了一个复杂的滚动动画系统。当用户滚动页面时,项目卡片会依次出现,每个卡片都有独特的入场动画,创造出一种电影般的视觉效果。

const useScrollAnimation = (threshold: number = 0.1) => {
  const ref = useRef<HTMLDivElement>(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
        }
      },
      { threshold }
    );

    if (ref.current) {
      observer.observe(ref.current);
    }

    return () => {
      if (ref.current) {
        observer.unobserve(ref.current);
      }
    };
  }, [threshold]);

  return { ref, isVisible };
};

在这里插入图片描述

科幻风格首页设计

首页是整个平台的门面,我希望它能够立即抓住访问者的注意力,传达出强烈的科技感和未来感。SciFiHomePage组件是我花费最多心思设计的部分,它不仅仅是一个静态的展示页面,而是一个充满动态效果的沉浸式体验空间。

const SciFiHomePage: React.FC = () => {
  const {
    primaryColor,
    secondaryColor,
    glowIntensity,
    animationIntensity
  } = useTimeAwareUI();

  // 使用useMemo优化配置对象,避免不必要的重渲染
  const heroStyle = React.useMemo(() => ({
    '--primary-glow': `0 0 20px ${glowIntensity}px ${primaryColor}`,
    '--secondary-glow': `0 0 15px ${glowIntensity * 0.8}px ${secondaryColor}`,
    '--primary-color': primaryColor,
    '--secondary-color': secondaryColor,
  }), [primaryColor, secondaryColor, glowIntensity]);

  const containerVariants = {
    hidden: { opacity: 0 },
    visible: {
      opacity: 1,
      transition: {
        delayChildren: 0.3,
        staggerChildren: 0.2
      }
    }
  };

  const itemVariants = {
    hidden: { y: 20, opacity: 0 },
    visible: {
      y: 0,
      opacity: 1,
      transition: {
        type: "spring" as const,
        stiffness: 100
      }
    }
  };

  return (
    <div className="scifi-hero" style={heroStyle}>
      <SciFiParticleBackground />
      
      <motion.div
        className="hero-content"
        variants={containerVariants}
        initial="hidden"
        animate="visible"
      >
        <motion.div className="hero-title-container" variants={itemVariants}>
          <h1 className="hero-title">
            <span className="title-main">SoftHub</span>
            <span className="title-sub">未来科技项目展示平台</span>
          </h1>
        </motion.div>

        <motion.div className="hero-description" variants={itemVariants}>
          <p>探索创新技术的无限可能,展示前沿项目的卓越成果</p>
        </motion.div>

        <motion.div className="hero-actions" variants={itemVariants}>
          <TouchButton
            variant="primary"
            size="large"
            className="hero-button"
            onClick={() => document.getElementById('projects')?.scrollIntoView({ behavior: 'smooth' })}
          >
            探索项目
          </TouchButton>
          
          <TouchButton
            variant="outline"
            size="large"
            className="hero-button"
            onClick={() => window.open('/qrcodes', '_blank')}
          >
            联系方式
          </TouchButton>
        </motion.div>

        <motion.div className="hero-stats" variants={itemVariants}>
          <div className="stat-item">
            <span className="stat-number">15+</span>
            <span className="stat-label">完成项目</span>
          </div>
          <div className="stat-item">
            <span className="stat-number">8</span>
            <span className="stat-label">技术栈</span>
          </div>
          <div className="stat-item">
            <span className="stat-number">3</span>
            <span className="stat-label">年经验</span>
          </div>
        </motion.div>
      </motion.div>

      <div className="hero-scroll-indicator">
        <motion.div
          className="scroll-arrow"
          animate={{ y: [0, 10, 0] }}
          transition={{ repeat: Infinity, duration: 2 }}
        ></motion.div>
      </div>
    </div>
  );
};

首页的设计采用了多层次的视觉结构:最底层是动态的粒子背景,中间层是渐变色彩和光效,最上层是文字内容和交互元素。通过精心调配的CSS变量系统,整个首页的色彩会随着时间感知系统动态变化,创造出一种活生生的视觉体验。

我特别注重了首页的性能优化,使用了React.useMemo来缓存样式对象,避免不必要的重渲染。同时,所有的动画都经过精心调校,确保在不同性能的设备上都能流畅运行。

应用详情与下载管理

当用户点击某个应用卡片时,会弹出一个详细的应用信息模态框。这个模态框包含了应用的完整信息,包括详细描述、功能特性、系统要求、版本历史等。我特别注重这个组件的信息架构设计,确保用户能够快速找到他们需要的信息。

在这里插入图片描述

在AppDetail组件中,我实现了多种下载方式。对于桌面用户,提供直接下载链接;对于移动设备用户,则生成二维码供扫描下载。这种设计考虑了不同使用场景的需求,让用户能够选择最适合的下载方式。

const AppDetail: React.FC<AppDetailProps> = ({ app, onClose, onShowQR }) => {
  const handleDownload = () => {
    // 检测设备类型
    const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
    
    if (isMobile) {
      onShowQR();
    } else {
      window.open(app.downloadUrl, '_blank');
    }
  };

  return (
    <motion.div
      className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0 }}
      onClick={onClose}
    >
      <motion.div
        className="bg-white rounded-lg max-w-4xl max-h-[90vh] overflow-y-auto m-4"
        initial={{ scale: 0.9, opacity: 0 }}
        animate={{ scale: 1, opacity: 1 }}
        exit={{ scale: 0.9, opacity: 0 }}
        onClick={(e) => e.stopPropagation()}
      >
        <div className="p-6">
          <div className="flex items-start justify-between mb-6">
            <div className="flex items-center">
              <img 
                src={app.icon} 
                alt={app.name}
                className="w-16 h-16 rounded-lg mr-4"
              />
              <div>
                <h2 className="text-2xl font-bold text-gray-900">{app.name}</h2>
                <p className="text-gray-600">{app.developer}</p>
                <div className="flex items-center mt-1">
                  <StarIcon className="w-5 h-5 text-yellow-400 mr-1" />
                  <span className="text-gray-600">{app.rating}</span>
                  <span className="text-gray-400 ml-2">({app.downloads} 下载)</span>
                </div>
              </div>
            </div>
            <button
              onClick={onClose}
              className="text-gray-400 hover:text-gray-600"
            >
              <XIcon className="w-6 h-6" />
            </button>
          </div>

          <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
            <div className="lg:col-span-2">
              <div className="mb-6">
                <h3 className="text-lg font-semibold mb-2">应用描述</h3>
                <p className="text-gray-600 leading-relaxed">{app.description}</p>
              </div>

              <div className="mb-6">
                <h3 className="text-lg font-semibold mb-2">主要功能</h3>
                <ul className="list-disc list-inside text-gray-600 space-y-1">
                  {app.features.map((feature, index) => (
                    <li key={index}>{feature}</li>
                  ))}
                </ul>
              </div>

              {app.screenshots.length > 0 && (
                <div className="mb-6">
                  <h3 className="text-lg font-semibold mb-2">应用截图</h3>
                  <div className="grid grid-cols-2 gap-2">
                    {app.screenshots.map((screenshot, index) => (
                      <img 
                        key={index}
                        src={screenshot} 
                        alt={`${app.name} 截图 ${index + 1}`}
                        className="rounded-lg border"
                      />
                    ))}
                  </div>
                </div>
              )}
            </div>

            <div>
              <div className="bg-gray-50 rounded-lg p-4 mb-4">
                <h3 className="font-semibold mb-3">应用信息</h3>
                <div className="space-y-2 text-sm">
                  <div className="flex justify-between">
                    <span className="text-gray-600">版本</span>
                    <span>{app.version}</span>
                  </div>
                  <div className="flex justify-between">
                    <span className="text-gray-600">大小</span>
                    <span>{app.size}</span>
                  </div>
                  <div className="flex justify-between">
                    <span className="text-gray-600">分类</span>
                    <span>{app.category}</span>
                  </div>
                </div>
              </div>

              <div className="bg-gray-50 rounded-lg p-4 mb-4">
                <h3 className="font-semibold mb-3">系统要求</h3>
                <div className="space-y-2 text-sm">
                  <div>
                    <span className="text-gray-600">操作系统:</span>
                    <span>{app.systemRequirements.os.join(', ')}</span>
                  </div>
                  <div>
                    <span className="text-gray-600">内存:</span>
                    <span>{app.systemRequirements.memory}</span>
                  </div>
                  <div>
                    <span className="text-gray-600">存储空间:</span>
                    <span>{app.systemRequirements.storage}</span>
                  </div>
                </div>
              </div>

              <button
                onClick={handleDownload}
                className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg hover:bg-blue-700 transition-colors font-medium"
              >
                立即下载
              </button>

              <button
                onClick={onShowQR}
                className="w-full mt-2 border border-gray-300 text-gray-700 py-3 px-4 rounded-lg hover:bg-gray-50 transition-colors"
              >
                生成二维码
              </button>
            </div>
          </div>
        </div>
      </motion.div>
    </motion.div>
  );
};

二维码功能实现

考虑到现代用户经常需要在移动设备上下载软件,我特别实现了二维码下载功能。使用qrcode.react库,我能够动态生成包含下载链接的二维码,用户只需要用手机扫描就能直接跳转到下载页面。

QRCodeModal组件不仅仅是简单地显示二维码,我还添加了一些贴心的功能,比如显示下载链接的文本版本,方便用户复制分享,以及提供不同尺寸的二维码选项来适应不同的使用场景。

const QRCodeModal: React.FC<QRCodeModalProps> = ({ app, onClose }) => {
  const [qrSize, setQrSize] = useState(200);

  return (
    <motion.div
      className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0 }}
      onClick={onClose}
    >
      <motion.div
        className="bg-white rounded-lg p-6 max-w-md w-full m-4"
        initial={{ scale: 0.9, opacity: 0 }}
        animate={{ scale: 1, opacity: 1 }}
        exit={{ scale: 0.9, opacity: 0 }}
        onClick={(e) => e.stopPropagation()}
      >
        <div className="text-center">
          <h3 className="text-lg font-semibold mb-4">扫码下载 {app.name}</h3>
          
          <div className="flex justify-center mb-4">
            <QRCode 
              value={app.downloadUrl}
              size={qrSize}
              level="M"
              includeMargin={true}
            />
          </div>

          <div className="mb-4">
            <label className="block text-sm text-gray-600 mb-2">二维码尺寸</label>
            <div className="flex justify-center space-x-2">
              {[150, 200, 250].map(size => (
                <button
                  key={size}
                  onClick={() => setQrSize(size)}
                  className={`px-3 py-1 rounded text-sm ${
                    qrSize === size 
                      ? 'bg-blue-600 text-white' 
                      : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
                  }`}
                >
                  {size}px
                </button>
              ))}
            </div>
          </div>

          <div className="mb-4 p-3 bg-gray-50 rounded text-sm">
            <p className="text-gray-600 mb-1">下载链接:</p>
            <p className="text-blue-600 break-all">{app.downloadUrl}</p>
          </div>

          <div className="flex space-x-3">
            <button
              onClick={() => navigator.clipboard.writeText(app.downloadUrl)}
              className="flex-1 bg-gray-600 text-white py-2 px-4 rounded hover:bg-gray-700 transition-colors"
            >
              复制链接
            </button>
            <button
              onClick={onClose}
              className="flex-1 border border-gray-300 text-gray-700 py-2 px-4 rounded hover:bg-gray-50 transition-colors"
            >
              关闭
            </button>
          </div>
        </div>
      </motion.div>
    </motion.div>
  );
};

回顾整个开发过程,我深深感受到了现代前端开发的魅力和挑战。技术在不断进步,用户的期望也在不断提高,作为开发者需要持续学习和改进!

昨天 — 2025年9月4日首页

leetcode-5-最长回文子串

作者 一支鱼
2025年9月4日 21:50

最长回文子串

1.题目描述

给你一个字符串 s,找到 s 中最长的 回文 子串。

示例 1:

输入: s = "babad"
输出: "bab"
解释: "aba" 同样是符合题意的答案。

示例 2:

输入: s = "cbbd"
输出: "bb"

提示:

  • 1 <= s.length <= 1000
  • s 仅由数字和英文字母组成

2.解决方案

1.暴力解法

  1. 思路
  • 枚举字符串 s 的所有子串,对于每个子串,检查它是否是回文串。
  • 从长度为 1 的子串开始,逐渐增加子串长度,记录下最长的回文子串。
  1. 代码实现
function longestPalindromeBruteForce(s: string): string {
    let maxLength = 0;
    let result = '';
    const n = s.length;
    for (let i = 0; i < n; i++) {
        for (let j = i; j < n; j++) {
            let isPalindrome = true;
            for (let k = 0; k < (j - i + 1) / 2; k++) {
                if (s[i + k]!== s[j - k]) {
                    isPalindrome = false;
                    break;
                }
            }
            if (isPalindrome && (j - i + 1) > maxLength) {
                maxLength = j - i + 1;
                result = s.slice(i, j + 1);
            }
        }
    }
    return result;
}
  1. 分析
  • 时间复杂度:(O(n^3))。外层循环遍历子串的起始位置 i,时间复杂度为 (O(n));中层循环遍历子串的结束位置 j,时间复杂度为 (O(n));内层循环检查子串是否为回文,时间复杂度为 (O(n))。所以总的时间复杂度为 (O(n \times n \times n))。
  • 空间复杂度:(O(1)),除了存储结果的字符串外,只使用了常数级别的额外空间。
  1. 缺点:时间复杂度极高,对于较长的字符串,运行效率极低,会导致超时。

2.中心扩展算法

  1. 思路
  • 回文串的特点是关于中心对称,所以可以以每个字符和相邻字符间隙为中心,向两边扩展,检查扩展出的子串是否为回文。
  • 对于长度为 n 的字符串,有 2n - 1 个可能的中心(n 个字符作为单字符中心,n - 1 个相邻字符间隙作为双字符中心)。
  1. 代码实现
function longestPalindrome(s: string): string {
    let start = 0;
    let maxLength = 0;
    const n = s.length;
    for (let i = 0; i < n; i++) {
        // 以单个字符为中心扩展
        let left1 = i;
        let right1 = i;
        while (left1 >= 0 && right1 < n && s[left1] === s[right1]) {
            if (right1 - left1 + 1 > maxLength) {
                maxLength = right1 - left1 + 1;
                start = left1;
            }
            left1--;
            right1++;
        }
        // 以两个相邻字符为中心扩展
        let left2 = i;
        let right2 = i + 1;
        while (left2 >= 0 && right2 < n && s[left2] === s[right2]) {
            if (right2 - left2 + 1 > maxLength) {
                maxLength = right2 - left2 + 1;
                start = left2;
            }
            left2--;
            right2++;
        }
    }
    return s.slice(start, start + maxLength);
}
  1. 分析
  • 时间复杂度:(O(n^2))。对于每个可能的中心,最多需要扩展 n 次,而总共有 2n - 1 个中心,所以时间复杂度为 (O(n \times n))。
  • 空间复杂度:(O(1)),只使用了常数级别的额外空间。
  1. 优点:相比暴力解法,时间复杂度有所降低,在实际应用中效率更高。

3.Manacher 算法

  1. 思路
  • Manacher 算法通过对字符串进行预处理,将奇数长度和偶数长度的回文串统一处理。
  • 它利用已经计算出的回文子串信息,避免了重复计算,从而将时间复杂度优化到 (O(n))。
  • 具体来说,算法使用一个数组 p 记录以每个字符为中心的回文半径,通过巧妙的计算和更新,快速找到最长回文子串。
  1. 代码实现
function longestPalindromeManacher(s: string): string {
    // 预处理字符串
    const newS = '#';
    for (const char of s) {
        newS += char + '#';
    }
    const n = newS.length;
    const p: number[] = new Array(n).fill(0);
    let center = 0;
    let right = 0;
    for (let i = 0; i < n; i++) {
        let iMirror = 2 * center - i;
        if (right > i) {
            p[i] = Math.min(right - i, p[iMirror]);
        } else {
            p[i] = 0;
        }
        // 尝试扩展
        while (i + (1 + p[i]) < n && i - (1 + p[i]) >= 0 && newS[i + (1 + p[i])] === newS[i - (1 + p[i])]) {
            p[i]++;
        }
        // 更新中心和右边界
        if (i + p[i] > right) {
            center = i;
            right = i + p[i];
        }
    }
    let maxLen = 0;
    let maxCenter = 0;
    for (let i = 0; i < n; i++) {
        if (p[i] > maxLen) {
            maxLen = p[i];
            maxCenter = i;
        }
    }
    const start = (maxCenter - maxLen) / 2;
    return s.slice(start, start + maxLen);
}
  1. 分析
  • 时间复杂度:(O(n))。虽然代码中有多层循环,但由于巧妙地利用了已有的回文信息,每个字符最多被访问常数次,所以时间复杂度为 (O(n))。
  • 空间复杂度:(O(n)),需要一个数组 p 来记录回文半径。
  1. 优点:时间复杂度最优,在处理非常长的字符串时,性能远远优于暴力解法和中心扩展算法。

4.最优解及原因

  1. 最优解:Manacher 算法是最优解。
  2. 原因:当字符串长度较大时,时间复杂度是衡量算法优劣的关键指标。Manacher 算法将时间复杂度优化到了线性的 (O(n)),相比暴力解法的 (O(n^3)) 和中心扩展算法的 (O(n^2)),在处理大规模数据时效率有显著提升。虽然它需要 (O(n)) 的额外空间,但对于追求高效的场景,这种以空间换时间的方式是值得的。

3.拓展和题目变形

拓展

  • 找到所有最长回文子串。

思路

  • 在 Manacher 算法的基础上,记录所有达到最大回文半径的中心位置,然后根据这些位置还原出所有最长回文子串。

代码实现

function findAllLongestPalindromes(s: string): string[] {
    const newS = '#';
    for (const char of s) {
        newS += char + '#';
    }
    const n = newS.length;
    const p: number[] = new Array(n).fill(0);
    let center = 0;
    let right = 0;
    for (let i = 0; i < n; i++) {
        let iMirror = 2 * center - i;
        if (right > i) {
            p[i] = Math.min(right - i, p[iMirror]);
        } else {
            p[i] = 0;
        }
        while (i + (1 + p[i]) < n && i - (1 + p[i]) >= 0 && newS[i + (1 + p[i])] === newS[i - (1 + p[i])]) {
            p[i]++;
        }
        if (i + p[i] > right) {
            center = i;
            right = i + p[i];
        }
    }
    let maxLen = 0;
    const maxCenters: number[] = [];
    for (let i = 0; i < n; i++) {
        if (p[i] > maxLen) {
            maxLen = p[i];
            maxCenters.length = 0;
            maxCenters.push(i);
        } else if (p[i] === maxLen) {
            maxCenters.push(i);
        }
    }
    const results: string[] = [];
    for (const maxCenter of maxCenters) {
        const start = (maxCenter - maxLen) / 2;
        results.push(s.slice(start, start + maxLen));
    }
    return results;
}

题目变形

  • 给定一个字符串 s 和一个整数 k,找到长度至少为 k 的最长回文子串。

思路

  • 可以在中心扩展算法或 Manacher 算法的基础上进行修改。在扩展或计算回文半径时,当找到的回文子串长度达到或超过 k 时,记录下来并继续寻找更长的满足条件的回文子串。

代码实现(基于中心扩展算法)

function longestPalindromeWithMinLength(s: string, k: number): string {
    let start = 0;
    let maxLength = 0;
    const n = s.length;
    for (let i = 0; i < n; i++) {
        let left1 = i;
        let right1 = i;
        while (left1 >= 0 && right1 < n && s[left1] === s[right1]) {
            if (right1 - left1 + 1 >= k && right1 - left1 + 1 > maxLength) {
                maxLength = right1 - left1 + 1;
                start = left1;
            }
            left1--;
            right1++;
        }
        let left2 = i;
        let right2 = i + 1;
        while (left2 >= 0 && right2 < n && s[left2] === s[right2]) {
            if (right2 - left2 + 1 >= k && right2 - left2 + 1 > maxLength) {
                maxLength = right2 - left2 + 1;
                start = left2;
            }
            left2--;
            right2++;
        }
    }
    return maxLength >= k? s.slice(start, start + maxLength) : '';
}

24999 元!华为推了一个「最大」的 Mate!

2025年9月4日 21:11

在中国三折叠屏手机市场,华为占有率是 100%。很显然这是事实,没有任何办法反驳。

2025 年 9 月 4 日,华为秋季新品发布会上,华为常务董事、终端 BG 董事长余承东曝光了去年发布的三折叠机型的进化产品华为 Mate XTs 非凡大师,10 几英寸的屏幕不变的情况下,这款产品在智能、影像等方面都进行了升级,华为团队甚至还为新款三折叠搭配了一支手写笔。

智慧屏终于配得上 Mate 标签|图片来源:华为

当你以为华为 Mate XTs 屏幕已经够大了的时候,华为又在现场曝光了一款最大 98 英寸的 Mate TV 产品,让「Mate」这个品牌,终于杀入了智能大屏市场,8999 元起的价格,在 Mate XTs 的衬托下,反而显得并没那么贵?

当天的发布会上,华为还曝光了华为 MatePad Mini 等一系列产品。可以说,在屏幕「大」和「小」上,华为这次是彻底玩明白了。

 

「比大更大」,电视当手机玩!

最近有消息,三星的三折叠手机或将在不久后曝光。

而在国内,华为三折叠屏手机 Mate XT 非凡大师已经在去年发布,成功抢下「三折叠」的头筹。9 月 4 日的发布会上,华为又带来了 Mate XTs 非凡大师,最新的三折叠手机。

余承东曝光华为 Mate XTs 非凡大师三折叠手机新品|图片来源:华为

外观和设计上,华为 Mate XTs 非凡大师并没有太多改动,毕竟上一代产品已经相对成熟。本次的新机型,主要在影像、智能和应用等方面进行了优化和提升。

例如,这次华为 Mate XTs 就搭配了一支手写笔,因为显然一块 10 英寸大小的屏幕,只用键盘输入不仅单调,而且并不十分方便。但是,一个顺手的手写笔,就会让这款机器瞄准的成功人士用户,有种「批奏折」的快感。当然,搭配新机型的折叠键盘/操控板,三折叠手机瞬间就变成了一个移动大屏。

用三折叠手机玩金铲铲是一种怎样的体验|图片来源:华为

事实上,华为对于华为 Mate XTs 的野心可能还更大一些,像 Wind 万德这样的 PC 类应用,现在也被移植到了鸿蒙系统中,让用户可以用大屏,实时「盯盘」。甚至,不久后,用户还可以用华为 Mate XTs 非凡大师玩流行的游戏——金铲铲。

当你认为华为 Mate XTs 非常大师已经足够大的时候,余承东又拿出了一块更大的屏——华为 Mate TV。

电视形式本身就是华为终端产品之一,只是这次的「智慧屏」被彻底纳入到「Mate」品牌之中。

Mate TV 想让你像刷手机一样刷电视|图片来源:华为

要晋级到 Mate,产品显然需要在配置上远超友商。为了让 Mate TV 能让用户拥有在大屏上畅玩手机的体验,华为团队给 Mate TV 配上了 12GB 运存和 256GB 内存,一劳永逸地解决智能电视卡顿的问题。

Mate TV 还搭配了一个灵犀悬浮触控板|图片来源:华为

根据官方数据,用华为 Mate TV 玩王者荣耀,帧数能保持在 60 帧。甚至,为了让你能玩得更爽,华为还为这块大屏搭配了一个手柄样式的「触控板」华为灵犀悬浮触控——没有十字和 AB 键,但是你可以在上面用触摸的方式玩游戏,同时用手机屏幕双指操控的形式,来控制电视上的内容。

当然,这么大的屏幕,同样配上了磁吸手写笔,让这块屏幕秒变白板。

华为智慧屏 Mate TV 尺寸从 65 英寸到 98 英寸,定价从 8999 元到 24999 元,丰俭由人。

 

玩「尺寸」的神

有大就有小,当天发布会上,已经提前被曝光的华为小平板——华为 MatePad Mini 也正式亮相。

华为终端 BG 首席执行官何刚曝光华为 MatePad Mini|图片来源:华为

这款 8.8 英寸的小平板产品,和友商主打游戏的游戏平板不同,华为 MatePad Mini 主打的还是迷你平板的市场,255 克的重量和 5.1 毫米的厚度,让这块平板更便携。事实上,你还可以将电话卡插到这个平板中,它直接就变成了一个大屏的智能手机——小平板,大手机。

和三折叠手机一样,华为 MatePad Mini 同样支持手写笔,让假装生产力的你,能用这块小平板记笔记、做书摘等等。当然,买前生产力买后爱奇艺的宿命,能不能被打破,还要看用户自己了。

华为 Mate XTs 非凡大师等产品将搭载鸿蒙 5.1 系统|图片来源:华为

从华为的三折叠 Mate XT 和「阔折叠」PuraX 开始,华为在屏幕尺寸上的魔术,玩得炉火纯青。上至 98 英寸的屏幕,下到 8.8 英寸的迷你平板,都能看到团队凭借硬件上的想象,在竞争激烈的红海市场,不断率先跳出单纯的内卷。

而随着鸿蒙用户已经超过 1400 万,同时鸿蒙 6.0 已经在前方招手,华为终端也在不断的围剿中,持续向前。

头图来源:华为

*ST天茂:向深圳证券交易所提出终止上市申请

2025年9月4日 20:54
36氪获悉,*ST天茂发布公告,公司拟以股东会决议方式主动撤回A股股票在深圳证券交易所的上市交易,并在股票终止上市后申请转入全国中小企业股份转让系统有限责任公司代为管理的退市板块转让。该事项已于2025年8月25日经公司2025年第一次临时股东会审议通过。公司已向深交所提交主动终止上市申请材料。
❌
❌