阅读视图

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

微信小程序一键登录可行性方案

背景说明

微信小程序的用户信息获取接口经过多次迭代后,目前已无法直接通过 API 获取用户的真实头像、昵称等信息。微信官方推荐的最佳实践是引导用户在个人中心或设置页面主动完善资料,但许多产品希望支持用户点击授权按钮即可完成登录。为此,我们可以利用微信公众号网页接口限制较宽松的特点,设计如下实现流程。

方案难点

  1. 微信服务号 / 公众号需配置服务器,用于接收并处理微信回调到网页的请求;
  2. 需要实现 webview 内网页与小程序之间的通信。

具体实现流程

1. 微信小程序端处理

小程序需包含一个专门用于加载网页的 webview 路由,核心是拼接微信授权网页的 URL 并跳转至该 webview。代码示例如下:

// 微信公众号/服务号的 appid,可登录微信公众平台 -> 设置与开发 -> 开发接口管理
const appId = 'wx1234'

/*
 * 这里需要填写公众号/服务号配置的域名(此域名也需要配置到小程序的安全域名里面)
 * PS. 推荐使用 https , 因为小程序安全域名只支持 https
 */
const redirectUri = 'https://xxxx.site/wechat/callback'
const encodedRedirectUri = encodeURIComponent(redirectUri)
const scope = 'snsapi_userinfo' // 获取用户信息

// 这里的 crypto 由 crypto-js 提供,高版本使用此代码会报错,建议固定版本为:3.3.0
const state = crypto.enc.Hex.stringify(crypto.lib.WordArray.random(16))

const url = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}&redirect_uri=${encodedRedirectUri}&response_type=code&scope=${scope}&state=${state}#wechat_redirect`

uni.redirectTo({
  // 这里的 url 用小程序配置的 web-view 路由
  url: `/pages/webview/index?url=${encodeURIComponent(url)}`,
})

公众号/服务号配置

用户点击授权后,微信会将请求跳转至redirectUri对应的地址,因此需提前在公众号后台配置该地址。以公众号为例,配置路径为:设置与开发 → 开发接口管理 → 服务器配置

image.png

配置时需在服务器上部署验证接口,用于完成微信的服务器验证。代码示例(基于 Express)如下:

const express = require("express");
const crypto = require("crypto-js");

const app = express();
const port = 3000;

app.get("/", (req, res) => {
  res.send("Hello World!");
});

// 将在 服务号/公众号 后台配置的 Token 值填入这里
const WECHAT_TOKEN = 'wechat'

/**
 * 专门用于微信服务号/公众号注册服务器时验证 Token 时启动
 */
app.get('/wechat/register', (req, res) => {
    try {
      // 从请求参数中获取微信服务器发送的验证信息
      const { signature, timestamp, nonce, echostr } = req.query;

      // 验证参数是否完整
      if (!signature || !timestamp || !nonce || !echostr) {
        return res.status(400).send("缺少必要参数");
      }

      // 将token、timestamp、nonce按字典序排序
      const sorted = [WECHAT_TOKEN, timestamp, nonce].sort();

      // 拼接并进行SHA1加密
      const hashcode = crypto.SHA1(sorted.join("")).toString(crypto.enc.Hex);

      // 打印调试信息
      console.log(
        `验证信息 - 计算的hash: ${hashcode}, 接收的signature: ${signature}`
      );

      // 验证通过则返回echostr,否则返回空
      if (hashcode === signature) {
        res.status(200).send(echostr);
      } else {
        res.send("");
      }
    } catch (error) {
        console.error('验证出错', error.message)
        res.send('')
    }
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});

配置完成后,在公众号后台点击 “提交” 即可生效(注意:服务器地址必须使用 HTTPS 协议,因小程序安全域名仅支持 HTTPS)。

3. 编写微信授权回调接口与页面

用户确认授权后,微信会跳转至redirectUri并携带code参数。服务器需通过该code获取用户信息,具体流程如下:

  1. 获取访问令牌:使用code、公众号appidsecret调用微信接口换取access_token
  2. 获取用户信息:使用access_tokenopenid调用微信接口获取用户详情
  3. 传递信息至小程序:将用户信息注入 HTML 页面,通过微信 JS-SDK 跳转回小程序并携带信息。

后端接口代码示例:

// 处理微信授权回调
async function handleWechatCallback(code) {
    try {
        const appId = process.env.WECHAT_APP_ID;
        const appSecret = process.env.WECHAT_APP_SECRET;
        
        console.log('开始处理微信授权回调');
        console.log('参数信息:', {
            appId,
            code,
            redirectUri: process.env.WECHAT_REDIRECT_URI
        });
        
        // 获取访问令牌
        console.log('正在请求访问令牌...');
        const tokenResponse = await axios.get(
            `https://api.weixin.qq.com/sns/oauth2/access_token?appid=${appId}&secret=${appSecret}&code=${code}&grant_type=authorization_code`
        );
        
        console.log('访问令牌响应:', {
            status: tokenResponse.status,
            statusText: tokenResponse.statusText,
            data: tokenResponse.data
        });
        
        const { access_token, openid } = tokenResponse.data;
        
        // 获取用户信息
        console.log('正在获取用户信息...');
        const userInfoResponse = await axios.get(
            `https://api.weixin.qq.com/sns/userinfo?access_token=${access_token}&openid=${openid}&lang=zh_CN`
        );
        
        console.log('用户信息响应:', {
            status: userInfoResponse.status,
            statusText: userInfoResponse.statusText,
            data: userInfoResponse.data
        });
        
        return userInfoResponse.data;
    } catch (error) {
        console.error('微信授权回调处理失败:', {
            message: error.message,
            response: error.response ? {
                status: error.response.status,
                statusText: error.response.statusText,
                data: error.response.data
            } : 'No response',
            stack: error.stack
        });
        throw error;
    }
}

app.get('/wechat/callback', async (req, res) => {
    console.log('收到微信回调请求:', {
        query: req.query,
        headers: req.headers,
        ip: req.ip
    });

    try {
        const { code } = req.query;
        if (!code) {
            console.error('缺少授权码');
            return res.status(400).json({
                success: false,
                error: 'Missing authorization code'
            });
        }
        
        console.log('开始处理授权码:', code);
        const userInfo = await handleWechatCallback(code);
        
        console.log('成功获取用户信息,准备返回响应');
    
        const templatePath = path.join(__dirname, 'public', 'callback.html');
    
        fs.readFile(templatePath, 'utf8', (err, data) => {
            if (err) {
                console.error('Error reading template:', err);
                return res.status(500).send('Error loading page');
            }
            
            console.log('userInfo', userInfo);
            // 替换所有出现的 {{userInfo}}
            const html = data.replace(/\{\{userInfo\}\}/g, JSON.stringify(userInfo));
            res.send(html);
        });
    } catch (error) {
        console.error('处理微信回调失败:', {
            error: error.message,
            stack: error.stack
        });
        
        res.status(500).json({
            success: false,
            error: error.message
        });
    }
});

用于传递信息的 HTML 页面代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>登陆</title>
    <!-- 注意:这里必须是 https 地址,因为这个这个网页的地址是 https 不能引用 http 资源 -->
    <script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
</head>
<body>
    <script>

        function objectToQueryString(obj, prefix = '') {
            if (obj === null || typeof obj !== 'object') {
                return encodeURIComponent(prefix) + '=' + encodeURIComponent(obj);
            }
            
            const queryParts = [];
            
            for (const key in obj) {
                if (obj.hasOwnProperty(key)) {
                    const value = obj[key];
                    const fullKey = prefix ? `${prefix}[${key}]` : key;
                    
                    if (value !== null && typeof value === 'object') {
                        if (Array.isArray(value)) {
                            // 处理数组
                            value.forEach((item, index) => {
                                const arrayKey = `${fullKey}[${index}]`;
                                if (typeof item === 'object' && item !== null) {
                                    queryParts.push(objectToQueryString(item, arrayKey));
                                } else {
                                    queryParts.push(encodeURIComponent(arrayKey) + '=' + encodeURIComponent(item));
                                }
                            });
                        } else {
                            // 处理嵌套对象
                            queryParts.push(objectToQueryString(value, fullKey));
                        }
                    } else {
                        // 处理基本类型值
                        queryParts.push(encodeURIComponent(fullKey) + '=' + encodeURIComponent(value));
                    }
                }
            }
            return queryParts.filter(part => part.length > 0).join('&');
        }

        if(wx.miniProgram){

          const userInfo = {{userInfo}}

          wx.miniProgram.redirectTo({url:"/pages/callback/index?" + objectToQueryString(userInfo)})
          wx.miniProgram.postMessage({ data: userInfo });
        }
    </script>
</body>
</html>

4. 小程序回调页处理

在小程序中创建/pages/callback/index页面,在onLoad生命周期中接收 URL 参数中的用户信息,即可完成登录逻辑处理:

<script setup>
import { onLoad } from '@dcloudio/uni-app'
onLoad((params) => {
    if (!params) {
        return toast('获取用户信息失败', 'error')
    }

    handleLogin(
        decodeURIComponent(params.nickname),
        decodeURIComponent(params.headimgurl),
    )
})
  
  handleLogin(nickname, headimgurl) {
    // 登录逻辑实现
    console.log('用户信息', nickname, headimgurl);
    // ...
  }
</script>

总结

该方案通过借助微信公众号网页接口的特性,间接实现了小程序一键授权登录的需求,核心在于公众号服务器配置与 webview 和小程序的跨端通信。需注意的是,这一方案属于基于当前平台规则的灵活实现,可能会受微信生态政策调整的影响。关键技术点包括公众号服务器验证、OAuth2.0 授权流程及 webview 与小程序的通信机制。

React Native DApp 开发全栈实战·从 0 到 1 系列(流动性挖矿-前端部分)

前言

继上一篇《React Native DApp 开发全栈实战·从 0 到 1 系列(流动性挖矿-合约部分)》,本文进入“前端交互”环节:把 Hardhat 测试脚本里那套「mint → approve → deposit → evm_increaseTime → harvest」的自动化流程,
原封不动地搬到浏览器/移动端,并通过 MetaMask 实现「真钱包、真签名、真 Gas」的交互体验。
同时,我们还会解决 Web3Provider 无法时间快进、授权额度不足、奖励误差等 3 个常见踩坑点,让你真正做到「开发时秒级验证,上线后零改动」。

前置准备

  • hardhat启动网络节点npx hardhat node
  • hardhat启动网络节点npx hardhat node
  • 合约编译npx hardhat compile 生成对应的xxx.json用获取abi等相关信息
  • 合约部署npx hardhat deploy --tags token,token1,LiquidityMiningVault 获取合约地址(奖励代币、质押代币和流动性挖矿的合约地址)
  • 节点的私钥导入钱包用来与合约交互时支付对应的gas费

核心代码

注意事项

  • 解决测试时模拟时间快进使用new ethers.providers.Web3Provider(window.ethereum)没有快进的属性
const url = 'http://localhost:8545';   // 以 Hardhat 启动日志为准
const raw = new ethers.providers.JsonRpcProvider(url);
const id = await raw.send('eth_chainId', []);
console.log('chainId', parseInt(id, 16));
await raw.send('evm_increaseTime', [10]);
await raw.send('evm_mine');
console.log('✅ 直连成功');
  • 获取不到预取结果区分质押代币和奖励代币合约的使用场景
  • 领取奖励后会出现预期误差主要是模拟时间快进原因

代码

说明把测试操作集中在一个函数中

  • 用 MetaMask 做签名器,实现了「预充 → mint → 授权 → 质押 → 调速度 → 时间快进 → 领取」
  • 详细步骤
      1. 双签名器:owner 管 mint / 设速,alice 管授权、质押、领取,符合真实权限模型。
      1. 授权必校验:approve 完立刻读 allowance,杜绝「额度不足」导致的 deposit 失败。
      1. 时间快进:必须另起 JsonRpcProvider 直连接口,调用 evm_increaseTime + evm_mineWeb3Provider 无此 API。
      1. 触发更新:链上时间只会在新块里生效,快进后必须再发一笔交易(这里用 deposit(0))让合约重新计算 earned
      1. 代币别混淆:StakeToken 用于质押,RewardToken 用于收益;地址一旦反了,就会出现「查不到余额」或「领不到钱」。
      1. 日志全打满:每一步都 console.log.wait() 回执,开发阶段一眼定位失败点;上线前把 try/catch 细化到业务层即可直接复用。
import { abi as LiquidityMiningVaultABI } from "@/abi/LiquidityMiningVault.json";
import { abi as StakeTokenABI } from "@/abi/MyToken.json";
import { abi as REWARDTokenABI } from "@/abi/MyToken1.json"; //代币
import * as ethers from 'ethers';
const withdrawToken = async () => {
  try {
    const provider = new ethers.providers.Web3Provider(window.ethereum);

    /* 0. 连接 MetaMask 并确保 Alice 在账户列表里 */
    await provider.send('eth_requestAccounts', []);
    const ALICE_ADDR = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8';
    const VAULT_ADDR = '0x1613beB3B2C4f22Ee086B2b38C1476A3cE7f78E8';
    const STAKE_ADDR = '0x5FbDB2315678afecb367f032d93F642f64180aa3';
    const REWARD_ADDR = '0xa513E6E4b8f2a923D98304ec87F64353C4D5C853';//奖励币

    let accounts = await provider.listAccounts();
    if (!accounts.map(a => a.toLowerCase()).includes(ALICE_ADDR.toLowerCase())) {
      await window.ethereum.request({
        method: 'wallet_requestPermissions',
        params: [{ eth_accounts: {} }]
      });
      accounts = await provider.send('eth_requestAccounts', []);
    }

    const aliceIndex = accounts.findIndex(
      a => a.toLowerCase() === ALICE_ADDR.toLowerCase()
    );
    if (aliceIndex === -1) throw new Error('MetaMask 中未找到 Alice 地址');
    const aliceSigner = provider.getSigner(aliceIndex);
    const ownerSigner = provider.getSigner(0);

    const DEPOSIT = ethers.utils.parseEther('1000');
    const SPEED   = ethers.utils.parseEther('10');

    const stakeToken = new ethers.Contract(STAKE_ADDR, StakeTokenABI, ownerSigner);
    const rewardToken = new ethers.Contract(REWARD_ADDR, REWARDTokenABI, ownerSigner);
    const vault      = new ethers.Contract(VAULT_ADDR, LiquidityMiningVaultABI, ownerSigner);

    /* 1. vault 预充奖励 */
    console.log('1. vault 预充奖励');
    try{
    await (await rewardToken.mint(VAULT_ADDR, ethers.utils.parseEther('2000'))).wait();
    }catch(err){
        console.error('❌ 预充奖励失败', err);
    }

    /* 2. 给 alice 发币 */
    console.log('2. alice mint');
    await (await stakeToken.mint(ALICE_ADDR, DEPOSIT)).wait();

    /* 3. alice 授权 —— 用 alice 自己的签名器 */
    console.log('3. alice 授权');
    const stakeForAlice = stakeToken.connect(aliceSigner);
    const approveTx = await stakeForAlice.approve(VAULT_ADDR, DEPOSIT);
    const approveRcpt = await approveTx.wait();
    console.log('approve receipt status:', approveRcpt.status);

    /* 4. 再读一次授权,确认额度足够 */
    const allowance = await stakeToken.allowance(ALICE_ADDR, VAULT_ADDR);
    console.log('allowance (枚)', ethers.utils.formatEther(allowance));
    if (!allowance.gte(DEPOSIT)) throw new Error('授权额仍不足');

    /* 5. alice 质押 */
    console.log('4. alice deposit');
    try{
    await (await vault.connect(aliceSigner).deposit(DEPOSIT, ALICE_ADDR)).wait();
    }catch(err){
        console.error('❌ 质押失败', err);
    }
    /* 6. owner 设奖励速度 */
    console.log('5. 设奖励速度');
    await (await vault.connect(ownerSigner).setRewardPerSecond(SPEED)).wait();

    /* 7. 时间快进 */
    console.log('6. evm+100s');
    try{
        const url = 'http://localhost:8545';   // 以 Hardhat 启动日志为准
        const raw = new ethers.providers.JsonRpcProvider(url);
        const id = await raw.send('eth_chainId', []);
        console.log('chainId', parseInt(id, 16));

        await raw.send('evm_increaseTime', [10]);
        await raw.send('evm_mine');
        console.log('✅ 直连成功');
    }catch(err){
        console.error('❌ 时间快进失败', err);
    }
    /* 8. 触发更新 */
    console.log('7. 再存 0 触发更新');
    await (await vault.connect(aliceSigner).deposit(0, ALICE_ADDR)).wait();

    /* 9. 查询收益 */
    const earned = await vault.earned(ALICE_ADDR);
    console.log('earned(枚)', ethers.utils.formatEther(earned));
    // 10. 提取奖励
    console.log('8. 提取奖励');
    try{
    await (await vault.connect(aliceSigner).harvest()).wait();
    }catch(err){
        console.error('❌ 提取奖励失败', err);
    }
    console.log('9. 提取奖励后查询余额');
    const balance = await rewardToken.balanceOf(ALICE_ADDR);
    console.log('balance(枚)', ethers.utils.formatEther(balance));
  } catch (err) {
    console.error('❌ 流程中断', err.message ?? err);
  }
};

效果图

图1转存失败,建议直接上传图片文件图1转存失败,建议直接上传图片文件图1转存失败,建议直接上传图片文件图1转存失败,建议直接上传图片文件图1转存失败,建议直接上传图片文件

总结

  1. 环境打通:一条 npx hardhat node 本地链 + 合约地址 + 节点私钥导入钱包,即可完成「开发 ⇄ 钱包」的双向通信。
  2. 时间快进:浏览器端无法 time.increase(),但可以通过直连 JsonRpcProvider 调用 evm_increaseTimeevm_mine,在 UI 上实现「一键跳 100 秒」的测试快感。
  3. 权限拆分:mint/预充用 owner 签名器,approve/deposit/harvest 用 Alice 签名器,既符合真实业务,也避免「一个私钥走天下」的安全误区。
  4. 代币辨析:StakeToken 只负责「质押额度」,RewardToken 只负责「收益发放」,两者地址一旦混淆,就会出现「查询不到余额」或「领取失败」的假象。
  5. 误差可控:时间快进后立即调用 deposit(0) 触发 updateReward,能把区块时间误差压到 1 s 以内,肉眼可见「earned ≈ 1000 枚」。
  6. 无缝迁移:整套代码基于 ethers.js v5,与 React/React Native 100% 兼容;只要把 window.ethereum 换成 @walletconnect/web3walletWalletConnectModal,即可原地切换主网、测试网或移动端钱包,真正做到「开发即生产」。

至此,「合约开发 → 单元测试 → 前端交互」完整链路已跑通。

TypeScript 队列实战:从零实现简单、循环、双端、优先队列,附完整测试代码

队列是一种遵循先进先出(FIFO,First-In-First-Out) 原则的元素集合。这意味着最早添加的元素会最先被移除,就像超市排队结账时,顾客按到达顺序依次被服务一样。

Queue(队列)
  尾部(Back) ← 队首(Front)
    入队(enqueue)   出队(dequeue)

在这篇实操教程中,你将学习如何使用链表在 TypeScript 中实现队列。以下是我们将要覆盖的内容:

  • 前置条件
  • 入门指南
  • 什么是队列?
  • 什么是链表?
  • 什么是简单队列?
  • 什么是循环队列?
  • 什么是双端队列?
  • 什么是优先队列?
  • 何时使用(以及避免使用)队列
  • 总结

一、前置条件

要学习本教程,你需要具备以下基础:

  1. TypeScript 基础:了解 TypeScript 的核心概念,如接口、类型和类。
  2. 算法基础:掌握数据结构与算法的基本概念,例如能使用大 O 表示法分析时间和空间复杂度。
  3. 链表数据结构 :熟悉链表的工作原理。如果你需要补充这部分知识,可以参考于此文章配套的示例代码工程

二、入门指南

本教程提供了一个实操项目,帮助你逐步实现队列并跟进学习。请按以下步骤开始:

  1. 克隆项目:从 GitHub 仓库克隆项目代码,边学边写。
  2. 项目结构:项目文件组织如下:
.
├── index.ts                  // 入口文件
├── examples                  // 示例目录(存放各队列的最终实现)
│   ├── 01-linked-list.ts     // 链表示例
│   ├── 02-simple-queue.ts    // 简单队列示例
│   ├── 03-circular-queue.ts  // 循环队列示例
│   ├── 04-double-ended-queue.ts  // 双端队列示例
│   └── 05-priority-queue.ts  // 优先队列示例
└── playground                // 实操目录(用于自己实现代码)
    ├── 01-linked-list.ts     // 链表实操文件
    ├── 02-simple-queue.ts    // 简单队列实操文件
    ├── 03-circular-queue.ts  // 循环队列实操文件
    ├── 04-double-ended-queue.ts  // 双端队列实操文件
    └── 05-priority-queue.ts  // 优先队列实操文件
  • playground 目录:用于编写和测试你的代码,是核心实操区域。
  • examples 目录:存放每个功能的最终实现代码。如果遇到问题卡壳,可以参考这里的解决方案(建议先自己尝试再看答案)。

三、什么是队列?

队列是一种以先进先出(FIFO) 顺序管理元素的数据结构------最早进入队列的元素会最早被取出。

生活中的队列示例

打印机处理任务时,如果你发送 3 个文档打印,打印机将按接收顺序依次处理:第一个文档先打印,然后是第二个,最后是第三个。

编程中的队列应用

队列常用于需要按顺序处理任务的场景,例如:

  • Web 服务器将 incoming 请求排队,逐个处理;
  • 聊天应用将消息排队,按输入顺序发送;
  • 导航应用将位置点排队,用于广度优先搜索(BFS) 逐层探索地图。

4 种常见队列类型

  1. 简单队列(Simple Queue):仅允许从尾部添加元素、从头部移除元素,严格遵循 FIFO。
  2. 循环队列(Circular Queue):与简单队列类似,但尾部元素会"连接"到头部,形成循环,可复用空间。
  3. 双端队列(Double-Ended Queue,简称 Deque):允许从头部和尾部同时添加或移除元素,类似公交站排队时,人可以从两端进出。
  4. 优先队列(Priority Queue):不按到达顺序处理元素,而是按"优先级"处理------优先级高的元素先被处理(如外卖 App 中,VIP 订单优先于普通订单)。

队列的核心操作

所有队列都包含一组基础操作,本教程将重点实现以下常用操作:

  • enqueue(入队):将元素添加到队列尾部(如顾客排到队伍末尾);
  • dequeue(出队):移除并返回队列头部的元素;
  • getFront(获取队首):查看队首元素但不移除(如查看队伍最前面是谁);
  • getRear(获取队尾):查看队尾元素但不移除(如查看队伍最后是谁);
  • isEmpty(判空):检查队列是否为空;
  • isFull(判满):检查队列是否达到最大容量;
  • peek(预览):与 getFront 功能相同,快速查看队首元素;
  • size(获取大小):返回队列中的元素数量(如统计队伍人数)。

为什么用链表实现队列?

实现队列的方式有多种,但本教程将使用基于链表的队列------因为链表在"尾部插入"和"头部删除"这两个队列核心操作上效率极高(时间复杂度为O(1)),无需像数组那样移动元素。

接下来,我们先简要了解链表的基础知识,再开始实现队列。

image.png

四、什么是链表?

链表是一种存储元素的方式:每个元素(称为"节点")包含两部分------实际数据指向后一个节点的引用(或指针)

与数组不同(数组元素在内存中连续存储),链表通过引用将节点连接成链状结构。

为什么用循环双向链表?

本教程将使用循环双向链表(Circular Doubly Linked List) 实现队列,它的特点是:

  • 每个节点同时指向"下一个节点"和"上一个节点";
  • 最后一个节点的"下一个"指向第一个节点,第一个节点的"上一个"指向最后一个节点,形成闭环。

这种结构的优势在于:

  1. 支持双向遍历,无需处理"首尾为 null"的特殊情况;
  2. 简化队列的首尾操作(如双端队列的头尾插入/删除);
  3. 保持操作效率为O(1)。

已提供的循环双向链表实现

教程已在src/playground/01-linked-list.ts中提供了循环双向链表的代码,你可以直接使用。以下是核心代码及说明:

// 📁 src/playground/01-linked-list.ts

/**
 * 双向链表的节点类
 */
export class NodeItem<T> {
  value: T;
  next: NodeItem<T> | null = null; // 指向后一个节点
  prev: NodeItem<T> | null = null; // 指向前一个节点

  constructor(value: T) {
    this.value = value;
  }
}

/**
 * 循环双向链表类
 */
export class LinkedList<T> {
  private head: NodeItem<T> | null = null; // 头节点
  private tail: NodeItem<T> | null = null; // 尾节点
  private currentSize: number = 0; // 当前节点数量

  /**
   * 向链表头部添加节点
   * @param value 要添加的值
   */
  prepend(value: T): void { /* 实现略 */ }

  /**
   * 向链表尾部添加节点
   * @param value 要添加的值
   */
  append(value: T): void { /* 实现略 */ }

  /**
   * 移除并返回头部节点的值
   * @returns 头节点的值,为空时返回undefined
   */
  deleteHead(): T | undefined { /* 实现略 */ }

  /**
   * 移除并返回尾部节点的值
   * @returns 尾节点的值,为空时返回undefined
   */
  deleteTail(): T | undefined { /* 实现略 */ }

  /**
   * 查看头部节点的值(不移除)
   * @returns 头节点的值,为空时返回undefined
   */
  getHead(): T | undefined { /* 实现略 */ }

  /**
   * 查看尾部节点的值(不移除)
   * @returns 尾节点的值,为空时返回undefined
   */
  getTail(): T | undefined { /* 实现略 */ }

  /**
   * 检查链表是否为空
   * @returns 为空返回true,否则返回false
   */
  isEmpty(): boolean { /* 实现略 */ }

  /**
   * 获取链表当前大小
   * @returns 节点数量
   */
  size(): number { /* 实现略 */ }
}

该链表提供了 8 个核心方法,恰好满足队列实现的需求。接下来,我们开始实现第一个队列------简单队列。

五、什么是简单队列?

简单队列是最基础的队列类型,严格遵循 FIFO 原则:只能从尾部添加元素,从头部移除元素,就像火车站售票窗口前的队伍。

简单队列的实现

打开src/playground/02-simple-queue.ts,按以下代码实现简单队列(核心是复用上面的循环双向链表):

// 📁 src/playground/02-simple-queue.ts
import { LinkedList } from "./01-linked-list";

/**
 * 基于循环双向链表实现的简单队列
 */
export class SimpleQueue<T> {
  private list: LinkedList<T>; // 用链表存储队列元素
  private maxSize?: number; // 可选的最大容量(不设置则无上限)

  /**
   * 构造函数
   * @param maxSize 可选,队列的最大容量
   */
  constructor(maxSize?: number) {
    this.list = new LinkedList<T>();
    this.maxSize = maxSize;
  }

  /**
   * 入队:将元素添加到队列尾部
   * @param item 要添加的元素
   */
  enqueue(item: T): void {
    if (this.isFull()) {
      throw new Error("队列已满");
    }
    this.list.append(item); // 复用链表的append方法(添加到尾部)
  }

  /**
   * 出队:移除并返回队列头部元素
   * @returns 队首元素,为空时返回undefined
   */
  dequeue(): T | undefined {
    return this.list.deleteHead(); // 复用链表的deleteHead方法(删除头部)
  }

  /**
   * 获取队首元素(不移除)
   * @returns 队首元素,为空时返回undefined
   */
  getFront(): T | undefined {
    return this.list.getHead();
  }

  /**
   * 获取队尾元素(不移除)
   * @returns 队尾元素,为空时返回undefined
   */
  getRear(): T | undefined {
    return this.list.getTail();
  }

  /**
   * 检查队列是否为空
   * @returns 为空返回true,否则返回false
   */
  isEmpty(): boolean {
    return this.list.isEmpty();
  }

  /**
   * 检查队列是否已满
   * @returns 已满返回true,否则返回false
   */
  isFull(): boolean {
    return this.maxSize !== undefined && this.list.size() >= this.maxSize;
  }

  /**
   * 预览队首元素(与getFront功能相同)
   * @returns 队首元素,为空时返回undefined
   */
  peek(): T | undefined {
    return this.getFront();
  }

  /**
   * 获取队列当前大小
   * @returns 元素数量
   */
  size(): number {
    return this.list.size();
  }
}

简单队列的工作原理

由于复用了链表的方法,简单队列的实现非常简洁,核心逻辑如下:

  1. enqueue(入队):先检查队列是否已满,若未满则调用链表的append方法将元素添加到尾部;
  2. dequeue(出队):直接调用链表的deleteHead方法删除并返回头部元素;
  3. 判空/判满:通过链表的isEmpty和自定义的isFull(结合maxSize)实现;
  4. 获取首尾元素:复用链表的getHead和getTail方法。

测试简单队列

实现完成后,在项目根目录运行以下命令测试代码是否正确:

npm run test:file 02

如果测试失败,可以参考src/examples/02-simple-queue.ts中的最终代码调试。

六、什么是循环队列?

循环队列是一种固定容量的队列,尾部元素与头部元素"相连"形成闭环------当头部元素被移除后,其空间可以被新元素复用,就像自助餐台上循环补充的餐盘。

循环队列与简单队列的区别

循环队列的核心特点是必须指定最大容量(简单队列的最大容量是可选的),因此更适合需要控制内存或资源的场景(如实时系统、缓存缓冲区)。

循环队列的实现

打开src/playground/03-circular-queue.ts,实现代码如下:

// 📁 src/playground/03-circular-queue.ts
import { LinkedList } from "./01-linked-list";

/**
 * 基于循环双向链表实现的循环队列
 */
export class CircularQueue<T> {
  private list: LinkedList<T>;
  private maxSize: number; // 循环队列必须有固定最大容量

  /**
   * 构造函数(必须传入最大容量)
   * @param maxSize 队列的最大容量
   */
  constructor(maxSize: number) {
    this.list = new LinkedList<T>();
    this.maxSize = maxSize;
  }

  /**
   * 入队:添加元素到尾部
   * @param item 要添加的元素
   */
  enqueue(item: T): void {
    if (this.isFull()) {
      throw new Error("循环队列已满");
    }
    this.list.append(item);
  }

  /**
   * 出队:移除并返回头部元素
   * @returns 队首元素,为空时返回undefined
   */
  dequeue(): T | undefined {
    return this.list.deleteHead();
  }

  // 以下方法与SimpleQueue一致,略去注释
  getFront(): T | undefined { return this.list.getHead(); }
  getRear(): T | undefined { return this.list.getTail(); }
  isEmpty(): boolean { return this.list.isEmpty(); }
  isFull(): boolean { return this.list.size() >= this.maxSize; }
  peek(): T | undefined { return this.getFront(); }
  size(): number { return this.list.size(); }
}

循环队列的核心差异

与简单队列相比,循环队列只有 2 个关键区别:

  1. 构造函数:maxSize是必填参数,强制队列有固定容量;
  2. 设计意图:循环队列针对"固定缓冲区"场景设计,例如网络数据传输中的数据包缓存------当队列满时必须等待前序元素被处理,才能添加新元素。

测试循环队列

运行以下命令测试:

npm run test:file 03

七、什么是双端队列?

双端队列(Deque,全称 Double-Ended Queue)是一种灵活的队列:允许从头部和尾部同时添加或移除元素,就像公交站的"双向排队"------人可以从队首或队尾上车/下车。

双端队列的实现

打开src/playground/04-double-ended-queue.ts,实现代码如下:

// 📁 src/playground/04-double-ended-queue.ts
import { LinkedList } from "./01-linked-list";

/**
 * 基于循环双向链表实现的双端队列
 */
export class Deque<T> {
  private list: LinkedList<T>;
  private maxSize?: number; // 可选最大容量

  /**
   * 构造函数
   * @param maxSize 可选,队列的最大容量
   */
  constructor(maxSize?: number) {
    this.list = new LinkedList<T>();
    this.maxSize = maxSize;
  }

  /**
   * 从头部入队
   * @param item 要添加的元素
   */
  enqueueFront(item: T): void {
    if (this.isFull()) {
      throw new Error("双端队列已满");
    }
    this.list.prepend(item); // 复用链表的prepend方法(添加到头部)
  }

  /**
   * 从尾部入队
   * @param item 要添加的元素
   */
  enqueueRear(item: T): void {
    if (this.isFull()) {
      throw new Error("双端队列已满");
    }
    this.list.append(item); // 复用链表的append方法(添加到尾部)
  }

  /**
   * 从头部出队
   * @returns 队首元素,为空时返回undefined
   */
  dequeueFront(): T | undefined {
    return this.list.deleteHead();
  }

  /**
   * 从尾部出队
   * @returns 队尾元素,为空时返回undefined
   */
  dequeueRear(): T | undefined {
    return this.list.deleteTail(); // 复用链表的deleteTail方法(删除尾部)
  }

  // 以下方法与SimpleQueue一致,略去注释
  getFront(): T | undefined { return this.list.getHead(); }
  getRear(): T | undefined { return this.list.getTail(); }
  isEmpty(): boolean { return this.list.isEmpty(); }
  isFull(): boolean { return this.maxSize !== undefined && this.list.size() >= this.maxSize; }
  peek(): T | undefined { return this.getFront(); }
  size(): number { return this.list.size(); }
}

双端队列的核心特点

双端队列的关键在于双向操作能力,这使其同时具备"队列"和"栈"的特性:

  • 若只使用enqueueRear和dequeueFront:表现为普通队列(FIFO);
  • 若只使用enqueueFront和dequeueFront:表现为栈(LIFO,后进先出)。

适用场景包括: undo/redo 功能(前一步操作可从队尾撤销)、滑动窗口算法(两端添加/移除窗口元素)。

测试双端队列

运行以下命令测试:

npm run test:file 04

八、什么是优先队列?

优先队列不遵循 FIFO 原则,而是按元素的优先级处理:优先级高的元素先出队,就像医院急诊室------重症患者优先于轻症患者被救治。

优先队列的实现

打开src/playground/05-priority-queue.ts,实现代码如下(核心是"插入时排序"):

// 📁 src/playground/05-priority-queue.ts
import { LinkedList, NodeItem } from "./01-linked-list";

/**
 * 带优先级的元素接口
 */
interface PriorityItem<T> {
  value: T;       // 元素值
  priority: number; // 优先级(数字越大,优先级越高)
}

/**
 * 基于循环双向链表实现的优先队列
 */
export class PriorityQueue<T> {
  private list: LinkedList<PriorityItem<T>>; // 存储带优先级的元素
  private maxSize?: number;

  constructor(maxSize?: number) {
    this.list = new LinkedList<PriorityItem<T>>();
    this.maxSize = maxSize;
  }

  /**
   * 入队:按优先级插入元素(优先级高的靠前)
   * @param value 元素值
   * @param priority 优先级(数字越大优先级越高)
   */
  enqueue(value: T, priority: number): void {
    if (this.isFull()) {
      throw new Error("优先队列已满");
    }

    const newItem: PriorityItem<T> = { value, priority };

    // 若队列为空,直接插入头部
    if (this.isEmpty()) {
      this.list.prepend(newItem);
      return;
    }

    // 遍历链表,找到插入位置(确保链表按优先级降序排列)
    let current = this.list["head"]; // 访问链表的私有head属性
    let count = 0;
    while (current && current.value.priority >= priority && count < this.size()) {
      current = current.next;
      count++;
    }

    // 若遍历到队尾,直接添加到尾部
    if (count === this.size()) {
      this.list.append(newItem);
    } else {
      // 否则在当前位置插入新节点(手动维护链表的循环结构)
      const newNode = new NodeItem(newItem);
      newNode.next = current;
      newNode.prev = current!.prev;

      if (current!.prev) {
        current!.prev.next = newNode;
      } else {
        this.list["head"] = newNode; // 若插入到头部,更新head
      }

      current!.prev = newNode;
      // 维护循环:尾节点的next指向新head,新head的prev指向尾节点
      this.list["tail"]!.next = this.list["head"];
      this.list["head"]!.prev = this.list["tail"];
      this.list["currentSize"]++; // 更新链表大小
    }
  }

  /**
   * 出队:移除并返回优先级最高的元素(队首)
   * @returns 优先级最高的元素值,为空时返回undefined
   */
  dequeue(): T | undefined {
    // 队首元素就是优先级最高的,直接删除并返回其value
    return this.list.deleteHead()?.value;
  }

  /**
   * 获取优先级最高的元素(不移除)
   * @returns 优先级最高的元素值
   */
  getFront(): T | undefined {
    return this.list.getHead()?.value;
  }

  /**
   * 获取优先级最低的元素(不移除)
   * @returns 优先级最低的元素值
   */
  getRear(): T | undefined {
    return this.list.getTail()?.value;
  }

  // 以下方法与其他队列一致,略去注释
  isEmpty(): boolean { return this.list.isEmpty(); }
  isFull(): boolean { return this.maxSize !== undefined && this.list.size() >= this.maxSize; }
  peek(): T | undefined { return this.getFront(); }
  size(): number { return this.list.size(); }
}

优先队列的核心逻辑

优先队列的关键在于入队时的排序

  1. 每个元素都关联一个优先级(数字越大优先级越高);
  2. 入队时遍历链表,将元素插入到"第一个优先级低于它"的元素前面,确保链表始终按优先级降序排列;
  3. 出队时直接删除队首元素(即优先级最高的元素),效率为O(1)。

适用场景包括:任务调度(高优先级任务先执行)、Dijkstra 最短路径算法(优先选择距离最近的节点)。

测试优先队列

运行以下命令测试:

npm run test:file 05

九、何时使用队列(以及何时避免使用)

队列是处理"顺序任务"和"异步流程"的利器,但并非万能。正确判断适用场景是关键。

适合使用队列的场景

  1. 顺序处理任务:需要按 arrival 顺序处理的场景,如打印机任务队列、API 请求限流。
  2. 异步通信:消息队列(如 RabbitMQ、Kafka)用于解耦微服务------生产者发送消息到队列,消费者异步处理,避免服务直接依赖。
  3. 缓冲数据流:实时系统(如视频流、传感器数据)中,用队列缓冲突发数据,避免下游处理不及时导致数据丢失。
  4. 算法实现:广度优先搜索(BFS)、拓扑排序等算法必须依赖队列。

避免使用队列的场景

  1. 需要随机访问元素:队列只支持首尾操作,若需访问中间元素(如"查找第 5 个元素"),效率极低(O(n)),此时应使用数组或链表。
  2. 复杂搜索/排序:队列不适合"按条件搜索元素"或"动态排序",应使用哈希表、二叉搜索树等数据结构。
  3. 过度解耦系统:在微服务中盲目使用队列会增加调试难度(如消息丢失、延迟排查),且可能导致"队列积压"(backpressure),反而降低系统稳定性。

十、总结

队列是一种基础但强大的数据结构,核心价值在于按顺序管理元素支持异步处理。本教程通过循环双向链表实现了 4 种常见队列:

  • 简单队列:基础 FIFO,适用于简单顺序任务;
  • 循环队列:固定容量,适用于缓存缓冲区;
  • 双端队列:双向操作,兼具队列与栈的特性;
  • 优先队列:按优先级处理,适用于任务调度。

掌握队列的关键在于理解适用场景------既不要低估它在解耦和顺序处理中的作用,也不要在需要随机访问的场景中强行使用。

现在,你可以尝试在自己的项目中使用这些队列实现,解决实际开发中的顺序任务或异步问题了!祝你编码愉快!

看到了很多次WebRTC,但是你真的需要它吗?

我最开始接触WebRTC应该是在24年的时候,那时候公司安防摄像头并不支持WebRTC,在这之前公司也没有web端查看摄像头画面的功能,因为公司新要一个新的平台,用于安防和考勤使用才开始转向WebRTC。

什么是WebRTC?

你可以理解为它是一个开源的实时通信技术,它允许网页、移动端或应用之间进行音视频通话、文件传输和数据共享,不用安装额外的插件或者第三方软件。主流浏览器都是支持WebRTC的,JS API直接调 navigator.mediaDevices.getUserMedia() 就能拿到摄像头,这个是很方便的。像我们公司的智能摄像头,就是把实时视频通过WebRTC推流到浏览器。

WebRTC优缺点

它的优点就是实时性好、延迟低对于安放摄像头来说这点还是很重要的,缺点就是P2P 建立连接受限NAT、防火墙这些,需要STUN/TURN 服务器辅助,我们在第一个项目的时候并没有搭建自己的STUN/TURN 服务器,而是使用的亚马逊的Amazon Kinesis Video Streams with WebRTC 服务,自己搭建运维成本高还需要考虑各种其他情况。

什么时候适合使用WebRTC呢?

安防摄像头的实时查看,现在我们用的很多的视频会议,甚至是远程操作无人机、机器人这些都可以使用WebRTC,因为它延迟低(<500ms)且浏览器直接支持。但是在大规模分发、存储场景下RTSP/RTMP/HLS仍然还是首选,因为WebRTC 在大规模转发、带宽利用上成本是很高的。现在海康威视、萤石、TP-Link、Home Assistant 插件都开始支持WebRTC,如果你刚好做的就是这些有关的工作,也可以先开始了解WebRTC了。

如何使用WebRTC?

其实WebRTC的使用核心就是信令交换,我这演示一个简单的方案。

获取本地媒体流(摄像头、麦克风)

const localStream = await navigator.mediaDevices.getUserMedia({
  video: true,
  audio: true
});
document.getElementById("localVideo").srcObject = localStream;

建立 PeerConnection(点对点连接)

创建 RTCPeerConnection 对象,配置 ICE 服务器(STUN/TURN),但是有时候P2P是不成功的,这个时候就会走TURN 中继转发了。TURN 中继是需要TURN 服务器的,不过我们没有TURN服务器也不影响P2P使用的。如果感兴趣的也可以自建 STUN/TURN,在国内的云服务器(阿里云、腾讯云、华为云等)上自己部署 coturn

const pc = new RTCPeerConnection({
  iceServers: [
    { urls: "stun:stun.l.google.com:19302" } // Google 免费 STUN
    /** 
    无法访问谷歌的可以尝试下面的,但这些都是公共服务,稳定性和可控性不高,不适合生产。
    stun:stun.xten.com
stun:stun.voipbuster.com
stun:stun.counterpath.net
stun:stun.sipnet.net
    */
  ]
});

// 将本地流添加到连接中
localStream.getTracks().forEach(track => pc.addTrack(track, localStream));

// 远端视频接收
pc.ontrack = (event) => {
  document.getElementById("remoteVideo").srcObject = event.streams[0];
};

信令交换(核心!需要服务器中转,下面会给出服务端简单的案例)

示例:创建 offer

const offer = await pc.createOffer();
await pc.setLocalDescription(offer);

// 连接你的信令服务器
const signalingServer = new WebSocket("ws://localhost:8080");

// 收到远端消息
signalingServer.onmessage = async (msg) => {
  const data = JSON.parse(msg.data);
  if (data.type === "answer") {
    await pc.setRemoteDescription(new RTCSessionDescription(data.sdp));
  } else if (data.type === "candidate") {
    await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
  }
};


// 把 offer 发送给远端(通过 WebSocket/HTTP)
signalingServer.send(JSON.stringify({ type: "offer", sdp: offer }));

ICE 候选收集 & 发送

pc.onicecandidate = (event) => {
  if (event.candidate) {
    signalingServer.send(JSON.stringify({ type: "candidate", candidate: event.candidate }));
  }
};

// 接收远端的 candidate
signalingServer.onmessage = async (msg) => {
  const data = JSON.parse(msg.data);
  if (data.type === "candidate") {
    await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
  }
};

后端信令服务器示例(Node.js + WebSocket)

// server.js
const WebSocket = require("ws");
const wss = new WebSocket.Server({ port: 8080 });

wss.on("connection", ws => {
  ws.on("message", msg => {
    // 简单广播给其他客户端
    wss.clients.forEach(client => {
      if (client !== ws && client.readyState === WebSocket.OPEN) {
        client.send(msg);
      }
    });
  });
});

它用来发送/接收 SDP、ICE Candidate,让两个浏览器知道对方怎么连,P2P 建立成功后,音视频流不经过信令服务器

Web Workers:前端多线程解决方案

Web Workers 是 HTML5 提供的一个 JavaScript 多线程解决方案,允许在后台线程中运行脚本,而不会影响主线程(通常是UI线程)的性能。这意味着你可以执行一些耗时较长的任务(如大量计算、数据处理等)而不阻塞用户界面。

为什么需要 Web Workers?

在传统的 JavaScript 中,由于是单线程的,所有任务都在主线程上执行。如果执行一个耗时的任务(比如复杂的计算、大数据的处理、长时间的网络请求等),就会导致页面卡顿,用户无法进行其他操作,直到任务完成。Web Workers 就是为了解决这个问题,它允许将一些任务放到后台线程中去执行,从而释放主线程。


核心价值

  1. 避免 UI 阻塞:耗时任务(如复杂计算、大数据处理)在 Worker 中执行,保持页面响应

  2. 多线程并行:利用多核 CPU 提升性能

  3. 隔离环境:Worker 线程无法直接操作 DOM,保证线程安全


Web Workers 的特点

  1. 独立线程:Web Worker 运行在另一个全局上下文中,与主线程是分离的。因此,它不能直接访问 DOM,也不能使用一些默认的方法和属性(如window对象、document对象等)。

  2. 通信机制:主线程和 Worker 线程之间通过消息传递进行通信。使用postMessage()发送消息,通过onmessage事件处理函数来接收消息。数据是通过复制而不是共享来传递的(除了ArrayBuffer等可以通过转移所有权的方式传递)。

  3. 脚本限制:Worker 线程中只能运行部分 JavaScript 特性,不能操作 DOM,不能使用alertconfirm等,但可以使用XMLHttpRequestfetch进行网络请求,也可以使用setTimeoutsetInterval等。

  4. 同源限制:Worker 脚本必须与主脚本同源(协议、域名、端口相同)。

  5. 关闭 Worker:主线程可以随时终止 Worker,Worker 也可以自己关闭。

技术架构

image.png


使用场景

  1. CPU 密集型任务

    ∙ 大数据排序/过滤

    ∙ 图像/视频处理(如 WebAssembly + Canvas)

    ∙ 物理引擎计算

  2. 实时数据处理

    ∙ WebSocket 消息处理

    ∙ 日志分析

  3. 预加载资源

    ∙ 提前加载和解析数据


代码示例

1. 基础使用

步骤1:创建 Worker 脚本

首先,你需要创建一个单独的 JavaScript 文件作为 Worker 的脚本。例如,我们创建一个worker.js

// worker.js
// 监听主线程发来的消息
self.onmessage = function(e) {
  console.log('Worker: Message received from main script');
  const data = e.data;
  // 执行一些耗时操作
  const result = heavyTask(data);
  // 将结果发送回主线程
  self.postMessage(result);
};

function heavyTask(data) {
  // 这里模拟一个耗时操作,比如复杂计算
  let result = 0;
  for (let i = 0; i < data; i++) {
    result += i;
  }
  return result;
}

步骤2:在主线程中创建 Worker 并通信

在主线程的 JavaScript 文件中:

// main.js
// 创建一个新的 Worker,传入脚本的URL
const worker = new Worker('worker.js');

// 向 Worker 发送数据
worker.postMessage(1000000000); // 发送一个很大的数,模拟耗时计算

// 接收来自 Worker 的消息
worker.onmessage = function(e) {
  console.log('Main: Message received from worker', e.data);
};

// 错误处理
worker.onerror = function(error) {
  console.error('Worker error:', error);
};

终止 Worker

当不再需要 Worker 时,应该终止它以释放资源。

// 主线程中终止 Worker
worker.terminate();

// 或者在 Worker 内部自己关闭
self.close();

注意事项

  1. 数据传输:通过postMessage传递的数据是深拷贝的,所以如果传递的数据量很大,可能会影响性能。对于大数据,可以使用Transferable对象(如ArrayBuffer)来转移数据的所有权,这样数据不会被复制,而是直接转移,原上下文将无法访问该数据。

    // 在 Worker 中
    const buffer = new ArrayBuffer(32);
    self.postMessage(buffer, [buffer]); // 第二个参数表示要转移的对象数组
    

    这样,主线程收到后,原 Worker 中的 buffer 将不可用。

  2. 作用域:在 Worker 内部,全局对象是self(也可以使用this),而不是window

  3. 引入其他脚本:在 Worker 中可以使用importScripts()来同步导入其他脚本:

    importScripts('script1.js', 'script2.js');
    

2. 图像处理示例

假设我们有一个图像处理的任务,比如将图像转换为灰度图。这个操作可能很耗时,特别是对于大图像。

worker.js(图像处理Worker)

self.onmessage = function(e) {
  const imageData = e.data;
  const data = imageData.data;
  // 灰度化处理:每个像素的RGB值取平均值
  for (let i = 0; i < data.length; i += 4) {
    const avg = (data[i] + data[i+1] + data[i+2]) / 3;
    data[i] = avg;   // R
    data[i+1] = avg; // G
    data[i+2] = avg; // B
  }
  self.postMessage(imageData);
};

主线程代码

const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
// 假设我们有一个图像
const img = new Image();
img.onload = function() {
  ctx.drawImage(img, 0, 0);
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  
  // 创建Worker
  const worker = new Worker('worker.js');
  worker.postMessage(imageData); // 注意:imageData包含一个Uint8ClampedArray,它是可转移的
  worker.onmessage = function(e) {
    const processedImageData = e.data;
    ctx.putImageData(processedImageData, 0, 0);
    worker.terminate(); // 处理完成,终止Worker
  };
};
img.src = 'example.jpg';

高级技巧

  1. Transferable Objects

    // 零拷贝传输大数据
    worker.postMessage(largeBuffer, [largeBuffer]);
    
  2. Worker 池管理

    class WorkerPool {
      constructor(size, script) {
        this.workers = Array(size).fill().map(() => new Worker(script));
      }
      // ...任务队列管理
    }
    
  3. 动态加载 Worker

    const worker = new Worker(URL.createObjectURL(
      new Blob([`(${workerFunction.toString()})()`])
    ));
    

注意事项

  1. 通信成本:频繁的小消息传递可能抵消性能收益

  2. 功能限制

    ∙ 无法访问:DOM、window、document

    ∙ 受限访问:navigator、location(只读)

  3. 调试技巧

    ∙ Chrome DevTools → Sources → Threads

    console.log在 Worker 中可用

  4. 终止机制

    // 主线程中
    worker.terminate();
    
    // Worker 内部
    self.close();
    

性能对比

操作类型 主线程耗时 Worker 耗时 优势
10万次浮点运算 120ms 30ms ⚡️ 4倍
5MB 图像处理 阻塞UI 800ms 无阻塞 300ms 🚀 零卡顿
实时数据流处理 丢帧率 35% 丢帧率 3% 💯 流畅体验

总结

Web Workers 为前端开发提供了多线程能力,可以显著提高复杂应用的性能和用户体验。但需要注意,Worker 线程不能操作 DOM,与主线程的通信是异步的,并且需要谨慎处理数据传输的性能问题。在需要执行耗时任务时,合理使用 Web Workers 可以避免阻塞主线程,保持页面的流畅响应。

Flex 与 Grid 的 order 参数:布局界的 "插队神器"

想象一下,你精心布置了一排座位,却突然临时需要调整几个人的位置 —— 在 CSS 布局里,order参数就是这个 "无需重构座位的插队许可"。它能让元素在视觉上换位置,而 DOM 结构保持不动,简直是前端开发者的 "乾坤大挪移"。

一、概念:order 是什么?

order是 Flex 和 Grid 布局中控制元素排列顺序的 CSS 属性,就像给每个元素贴了个 "序号贴",浏览器会按序号从小到大排列元素。

  • 默认值:0(所有元素默认都是 0)
  • 取值:整数(正整数、负整数、0 都可以)
  • 作用范围:仅在 Flex 容器或 Grid 容器的直接子元素上有效

二、Flex 中的 order:灵活的队列调整

在 Flex 布局中,order决定了项目在主轴上的排列顺序。

.flex-container {
  display: flex;
}

/* 让第三个元素排第一,第一个元素排第二 */
.item-3 { order: -1; }  /* 负数比0小,会排前面 */
.item-1 { order: 1; }
.item-2 { order: 2; }   /* 默认0,这里手动设为2 */

效果:视觉上的顺序是 item-3 → item-2 → item-1(因为 - 1 < 0 < 1)

三、Grid 中的 order:网格里的位置魔法

Grid 布局中,order同样影响元素顺序,但要注意它只改变视觉顺序,不影响网格线的对应关系。


    .grid-container {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
    }

    /* 让最后一个格子跳去第一个位置 */
    .grid-item:last-child {
      order: -1;
    }

特点:Grid 中的order优先级高于网格线定位,哪怕你用grid-column指定了位置,order仍会影响整体排列顺序。

四、代码实践:order 的妙用场景

直接上代码,效果请看下图:

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <title>Order参数大比拼</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet" />

    <script>
      tailwind.config = {
        theme: {
          extend: {
            colors: {
              primary: '#3B82F6',
              secondary: '#10B981',
              accent: '#F59E0B',
            },
          },
        },
      };
    </script>

    <style type="text/tailwindcss">
      @layer utilities {
        .flex-demo {
          @apply flex gap-2 p-4 bg-gray-100 rounded-lg my-4;
        }
        .grid-demo {
          @apply grid grid-cols-3 gap-2 p-4 bg-gray-100 rounded-lg my-4;
        }
        .item {
          @apply p-3 rounded-md text-white text-center font-bold;
        }
      }
    </style>
  </head>
  <body class="p-6 max-w-4xl mx-auto">
    <h1 class="text-3xl font-bold mb-6 text-center text-gray-800">
      <i class="fa fa-sort-amount-asc text-primary"></i> Flex vs Grid: order参数大比拼
    </h1>

    <!-- Flex布局演示 -->
    <div class="mb-8">
      <h2 class="text-2xl font-semibold mb-4 text-primary"><i class="fa fa-align-justify"></i> Flex布局中的order</h2>

      <p class="mb-3 text-gray-700">默认顺序(不设置order):</p>
      <div class="flex-demo">
        <div class="item bg-primary">1号</div>
        <div class="item bg-secondary">2号</div>
        <div class="item bg-accent">3号</div>
      </div>

      <p class="mb-3 text-gray-700">设置order后的顺序:</p>
      <div class="flex-demo">
        <div class="item bg-primary" style="order: 2">1号 (order=2)</div>
        <div class="item bg-secondary" style="order: 3">2号 (order=3)</div>
        <div class="item bg-accent" style="order: 1">3号 (order=1)</div>
      </div>
    </div>

    <!-- Grid布局演示 -->
    <div>
      <h2 class="text-2xl font-semibold mb-4 text-secondary"><i class="fa fa-th"></i> Grid布局中的order</h2>

      <p class="mb-3 text-gray-700">默认顺序(不设置order):</p>
      <div class="grid-demo">
        <div class="item bg-primary">1号</div>
        <div class="item bg-secondary">2号</div>
        <div class="item bg-accent">3号</div>
        <div class="item bg-purple-600">4号</div>
        <div class="item bg-red-500">5号</div>
        <div class="item bg-indigo-500">6号</div>
      </div>

      <p class="mb-3 text-gray-700">设置order后的顺序:</p>
      <div class="grid-demo">
        <div class="item bg-primary">1号</div>
        <div class="item bg-secondary">2号</div>
        <div class="item bg-accent" style="order: -1">3号 (order=-1)</div>
        <div class="item bg-purple-600">4号</div>
        <div class="item bg-red-500" style="order: 1">5号 (order=1)</div>
        <div class="item bg-indigo-500">6号</div>
      </div>
    </div>

    <div class="mt-8 p-4 bg-blue-50 rounded-lg">
      <h3 class="font-bold text-gray-800 mb-2">💡 小提示</h3>
      <ul class="list-disc pl-5 text-gray-700">
        <li>order值越小,元素越靠前</li>
        <li>负数比0小,会排在所有默认元素前面</li>
        <li>只影响视觉顺序,不改变DOM结构(对无障碍访问有影响)</li>
        <li>常用于响应式布局,在不同屏幕尺寸下调整元素顺序</li>
      </ul>
    </div>
  </body>
</html>

运行图片:

image.png

五、使用小贴士

  1. 响应式神器:在移动设备上把重要按钮放前面,桌面端保持原有顺序

    
        @media (max-width: 640px) {
          .cta-button { order: -1; } /* 移动端让按钮置顶 */
        }
    
  2. 注意无障碍:屏幕阅读器会按 DOM 顺序朗读,而非order顺序,重要内容别光靠order调整

  3. 别过度使用:滥用order会让 DOM 结构和视觉表现严重脱节,给维护者挖坑

总结

总结一下,order就像布局中的 "VIP 通道",能让特定元素享受优先展示权,但使用时要记得 "权力越大,责任越大" 哦!

JS 打造丝滑手风琴

手风琴菜单是后台与官网的常客,但 90% 的实现依赖第三方库或 CSS Transition。今天原生 JS 手写一条「高度动画 + 状态机」的完整链路,打造丝滑手风琴效果。

效果预览

JS 打造丝滑手风琴.gif

一、核心思路

  • 高度动画:把 height: 0 ↔ 实际高度 交给逐帧函数 createAnimation
  • 状态机:用自定义属性 status="closed|opened|playing" 避免并发点击
  • 复用:任意html代码结构插上即用,零配置

二、代码速览

1.动画引擎(animate.js)

function createAnimation({ from, to, totalMS = 300, onmove, onend }) {
  const dis = (to - from) / (totalMS / 15);
  let cur = 0;
  const timer = setInterval(() => {
    from += dis;
    if (++cur >= totalMS / 15) {
      from = to;
      clearInterval(timer);
      onend && onend();
    }
    onmove(from);
  }, 15);
}

实现从一个初始值到目标值的平滑过渡效果,每帧更新一次视图。

函数参数(配置项)

  • from:动画起始值(如初始位置、初始透明度等)
  • to:动画目标值(最终要达到的数值)
  • totalMS:动画总时长(默认 300 毫秒,即 0.3 秒)
  • onmove:每帧更新时的回调函数(接收当前动画值,用于实时更新视图,比如 DOM 位置、样式等)
  • onend:动画结束时的回调函数(可选,动画完成后执行)

2.交互逻辑(index.js)

const titles = document.querySelectorAll('.menu h2');
const itemHeight = 30;

titles.forEach(title =>
  title.addEventListener('click', () => {
    const submenu = title.nextElementSibling;
    const before = document.querySelector('.submenu[status="opened"]');
    before && before !== submenu && closeSubmenu(before);
    toggleSubmenu(submenu);
  })
);

function openSubmenu(el) {
  if (el.getAttribute('status') !== 'closed') return;
  el.setAttribute('status', 'playing');
  createAnimation({
    from: 0,
    to: el.children.length * itemHeight,
    onmove: h => (el.style.height = h + 'px'),
    onend: () => el.setAttribute('status', 'opened'),
  });
}

function closeSubmenu(el) {
  if (el.getAttribute('status') !== 'opened') return;
  el.setAttribute('status', 'playing');
  createAnimation({
    from: el.children.length * itemHeight,
    to: 0,
    onmove: h => (el.style.height = h + 'px'),
    onend: () => el.setAttribute('status', 'closed'),
  });
}

function toggleSubmenu(el) {
  const status = el.getAttribute('status');
  status === 'opened' ? closeSubmenu(el) : openSubmenu(el);
}

关键设计思路

  • 用 status 属性(closed/opened/playing)管理子菜单状态,避免动画过程中重复触发点击事件
  • 子菜单高度通过 “选项数量 × 单个高度” 动态计算,适配不同数量的子菜单
  • 点击新菜单时自动关闭已打开的菜单,保证同一时间只有一个子菜单处于展开状态

3.样式骨架

<ul class="menu-container">
  <li class="menu">
    <h2>菜单1</h2>
    <ul class="submenu">
      <li>菜单1</li>
      <li>菜单2</li>
      <li>菜单3</li>
      <li>菜单4</li>
    </ul>
  </li>
  <li class="menu">
    <h2>菜单2</h2>
    <ul class="submenu">
      <li>菜单1</li>
      <li>菜单2</li>
      <li>菜单3</li>
      <li>菜单4</li>
    </ul>
  </li>
  <li class="menu">
    <h2>菜单3</h2>
    <ul class="submenu">
      <li>菜单1</li>
      <li>菜单2</li>
      <li>菜单3</li>
      <li>菜单4</li>
    </ul>
  </li>
  <li class="menu">
    <h2>菜单4</h2>
    <ul class="submenu">
      <li>菜单1</li>
      <li>菜单2</li>
      <li>菜单3</li>
      <li>菜单4</li>
    </ul>
  </li>
</ul>

三、状态机流程图

click ──► status=playing ──► height 0→n ──► status=opened
               │                        ▲
               └─► 再次 click ──► height n→0 ──► status=closed

总结

高度动画 + 状态机 + 事件委托,让手风琴在任何项目里「开箱即合」。

多模态 AIGC 在 Web 内容创作中的技术融合实践:把“创作引擎”装进浏览器

如果文字是乐谱,图像是色卡,音频是节拍,视频是镜头,那么多模态 AIGC 就是把它们塞进同一个工作室的总导演。今天我们不谈玄学,直面代码与底层机制,一起把“会写、会画、会听、会看”的 AI 装进 Web 创作流程里。

🧠 + 🎨 + 🎧 + 🎬 = 🌐 创作飞轮


目录

  • 为什么是多模态 AIGC?
  • 多模态在浏览器的“落地难题”与解决思路
  • 架构全景图:前端、边缘、后端的协作(含数据流)
  • 关键能力拆解:文本、图像、音频、视频与跨模态对齐
  • 前端工程化与流水线:从 Prompt 到发布
  • 实战代码片段(JS):管线编排、推理调度、可观测性
  • 性能与成本优化:推理粒度、缓存、量化与并行
  • 合规与安全:版权、水印、隐私与审计
  • 常见坑与排障清单
  • 行动清单

为什么是多模态 AIGC?

  • 用户注意力的“争夺战”早已不是单线作战:纯文本难以打动“短视频级别”注意力。
  • 创作团队希望“一个指令,多轨产出”:同一主题自动生成文案、配图、配音、分镜和封面。
  • Web 是天然的发布与互动平台:无需安装,随时协作,浏览器即工作站。

一句话:多模态 AIGC 把创作链路从“手工串联”升级为“自动流水线”,人类创意负责方向,机器负责体力活。


落地难题与解决思路

  • 推理重且多样:图像生成、视频合成对显存/计算要求高,浏览器端难以独立完成。

    • 思路:前端负责轻量预处理、可视化与编排;重推理在边缘/后端;中间引入缓存与分片。
  • 实时交互 vs. 成本:用户希望“秒回”,但大模型贵。

    • 思路:分级模型与草稿-精修策略:先小模型出草图,后大模型精修;用流式响应提升主观速度。
  • 跨模态一致性:文案和图像风格不一致,视频与旁白节奏错位。

    • 思路:统一“语义锚点”:主题标签、风格 token、颜色/镜头字典;将其在各轨道共享。

架构全景:浏览器—边缘—后端

数据流(从左到右):
用户输入 → 前端 Prompt 编排与模版化 → 边缘路由(AB、配额、缓存) → 模态服务(文本/图像/音频/视频) → 资产存储与 CDN → 前端预览/编辑 → 一键发布

  • 前端(浏览器)

    • 角色:编排器、预览器、轻量推理器(如打标签、语义切片、TTS 拼接)。
    • 能力:Web Worker、OffscreenCanvas、WebAudio、WebGPU(可选)。
  • 边缘(Edge Functions/Workers)

    • 角色:近用户的“交通枢纽”,做鉴权、速率限制、请求切分、缓存命中。
  • 后端(GPU 集群/模型服务)

    • 角色:重推理:文生图、图生文、音频/视频生成与合成,矢量索引检索。

小图标地图:

  • 🧭 前端编排器
  • 🛰️ 边缘路由
  • 🧪 模型工厂
  • 📦 资产仓库
  • 🚀 发布管线

关键能力拆解与底层视角

  1. 文本生成(文案/脚本/SEO)
  • 模型:指令优化的语言模型,支持工具调用(结构化输出)。
  • 底层点:提示词结构→解码策略→约束采样(JSON 模式/模板对齐)。
  • 实践:生成统一“语义锚点包”(主题、风格、情绪板、关键词、色彩倾向)。
  1. 图像生成(封面/插图/海报)
  • 模型:扩散类或生成对抗类,支持风格控制与参考图。
  • 底层点:条件控制(文本编码器→交叉注意力)、低秩适配(LoRA)做风格迁移。
  • 实践:先低分辨率草图,用户微调后再高分辨率放大;关键元素用 ControlNet(姿态/边缘/深度)。
  1. 音频生成(配音/BGM)
  • 模型:TTS/声音克隆/音乐生成。
  • 底层点:文本到语音的对齐(音素化、韵律预测),分段流式输出减少等待。
  • 实践:把字幕与时间码绑定,导出 SRT/WEBVTT;音量侧链压缩让 BGM 不压住旁白。
  1. 视频生成与合成(分镜/片段/转场)
  • 模型:文本转视频或图像序列驱动,或传统剪辑流水线。
  • 底层点:时序一致性(关键帧锚点、潜空间跨帧共享)、编码器(H.264/H.265/AV1)参数选型。
  • 实践:多轨时间轴:图像轨 + 旁白轨 + BGM + 文案字幕;先粗合成预览(低码率),确认后再高码率渲染。
  1. 跨模态对齐
  • 统一 IDs 和时间轴:每个片段有统一“片段号”,字幕、镜头、配音都挂载它。
  • 统一语义空间:用多模态编码器把图文映射到共享嵌入,保证风格连贯。
  • 元数据驱动:颜色板、字体、Logo 安全区、品牌指南作为硬约束。

前端工程化与流水线

  • Prompt 模版化:Handlebars/自定义 DSL 生成模型指令,避免“Prompt 零散化”。
  • 任务队列与幂等:每次生成都有 jobId,支持重试、断点续传。
  • 流式 UI:SSE/WebSocket 展示“进度/草稿”,快速可见即价值。
  • 可编辑终局:所有生成结果都应可二次编辑(富文本/图层/音轨),AI 是助理不是裁判。

实战代码片段(JS)

以下示例聚焦“浏览器编排 + 边缘路由 + 后端推理”的最小可用框架。接口用占位符,你可以替换为自有服务。

  1. 前端:多模态任务编排与流式消费
// src/pipeline.js
// 核心思想:将一次创作拆成可并行/可重试的子任务,并在 UI 中流式展示

export async function createMultimodalProject({ topic, style, durationSec = 30 }) {
  const anchor = await fetchJSON('/edge/anchor', { topic, style });

  // 并行启动文案和视觉草图
  const [scriptJob, storyboardJob] = await Promise.all([
    postJSON('/edge/jobs/text', { anchor, length: Math.ceil(durationSec / 5) }),
    postJSON('/edge/jobs/image-storyboard', { anchor, frames: 6 })
  ]);

  // 流式订阅结果
  const scriptStream = streamEvents(`/edge/jobs/${scriptJob.id}/events`);
  const storyboardStream = streamEvents(`/edge/jobs/${storyboardJob.id}/events`);

  return { anchor, scriptStream, storyboardStream };
}

async function fetchJSON(url, body) {
  const res = await fetch(url, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

async function postJSON(url, payload) {
  return fetchJSON(url, payload);
}

function streamEvents(url, onEvent) {
  const es = new EventSource(url);
  const listeners = new Set();
  es.onmessage = (e) => {
    const data = JSON.parse(e.data);
    listeners.forEach(fn => fn(data));
  };
  es.onerror = () => { /* 可加重连 */ };
  return {
    subscribe: (fn) => (listeners.add(fn), () => listeners.delete(fn)),
    close: () => es.close()
  };
}
  1. 前端:时间轴合成预览(低码率草稿)
// src/timeline.js
// 将图像序列 + 文案字幕 + TTS 片段合成可预览的时间轴(非最终导出)
export function buildTimeline({ images, captions, ttsClips, bpm = 90 }) {
  const timeline = [];
  const beat = 60_000 / bpm;
  let t = 0;

  for (let i = 0; i < images.length; i++) {
    const img = images[i];
    const cap = captions[i] || '';
    const voice = ttsClips[i];

    timeline.push({
      type: 'frame',
      start: t,
      end: t + 4 * beat,
      image: img.url,
      caption: cap,
      voice: voice?.url
    });
    t += 4 * beat;
  }
  return timeline;
}
  1. 前端:Web Worker 做轻量渲染与字幕烧制(示意)
// public/preview-worker.js
self.onmessage = async (e) => {
  const { canvas, timeline, width, height } = e.data;
  const ctx = canvas.getContext('2d');
  const start = performance.now();

  let nextIndex = 0;
  const images = new Map();

  function load(src) {
    return new Promise((resolve) => {
      if (images.has(src)) return resolve(images.get(src));
      const img = new Image();
      img.onload = () => (images.set(src, img), resolve(img));
      img.src = src;
    });
  }

  function drawCaption(text) {
    ctx.font = '24px system-ui';
    ctx.fillStyle = 'rgba(0,0,0,0.5)';
    ctx.fillRect(0, height - 60, width, 60);
    ctx.fillStyle = '#fff';
    ctx.fillText(text, 24, height - 24);
  }

  const render = async () => {
    const now = performance.now() - start;
    const item = timeline[nextIndex];
    if (!item) return requestAnimationFrame(render);

    if (now >= item.start && now < item.end) {
      const img = await load(item.image);
      ctx.drawImage(img, 0, 0, width, height);
      if (item.caption) drawCaption(item.caption);
    } else if (now >= item.end) {
      nextIndex++;
    }
    requestAnimationFrame(render);
  };
  render();
};
  1. 前端:把 Worker 接到页面并启动预览
// src/preview.js
export function startPreview(timeline, canvas) {
  const worker = new Worker('/preview-worker.js', { type: 'module' });
  const offscreen = canvas.transferControlToOffscreen();
  worker.postMessage({ canvas: offscreen, timeline, width: canvas.width, height: canvas.height }, [offscreen]);
  return () => worker.terminate();
}
  1. 边缘路由:AB 分流、缓存与速率限制(伪代码)
// edge/route.js (示意:Cloudflare Workers / Vercel Edge Functions 风格)
export default async function handler(req) {
  const url = new URL(req.url);

  if (url.pathname === '/edge/anchor') {
    const { topic, style } = await req.json();
    const anchor = await buildAnchor(topic, style);
    return json(anchor);
  }

  if (url.pathname.startsWith('/edge/jobs/')) {
    // 事件流转发到后端任务系统
    return proxySSE(req, process.env.JOB_BUS_URL);
  }

  if (url.pathname === '/edge/jobs/text') {
    rateLimit(req, { key: userKey(req), rpm: 30 });
    const payload = await req.json();
    // 命中缓存直接返回
    const cacheKey = hash(payload);
    const cached = await EDGE_KV.get(cacheKey, 'json');
    if (cached) return json(cached);

    const job = await submitJob('text', payload);
    await EDGE_KV.put(cacheKey, JSON.stringify(job), { expirationTtl: 60 });
    return json(job);
  }

  // 其他路由...
  return new Response('Not Found', { status: 404 });
}

function json(data) { return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } }); }
  1. 后端任务处理:分级模型与草稿-精修
// server/jobs/text.js
// 先用小模型草稿,后用大模型精修,并保持结构化输出
import { eventBus } from './bus.js';

export async function runTextJob(job) {
  const { anchor, length } = job.payload;
  const draft = await smallLM(anchor, length, { temperature: 0.7 });
  eventBus.emit(job.id, { phase: 'draft', content: draft });

  const refined = await largeLM({ outline: draft.outline, style: anchor.style, constraints: anchor.constraints });
  const script = enforceSchema(refined, {
    type: 'object',
    properties: { segments: { type: 'array', items: { type: 'object', properties: { caption: { type: 'string' }, durationMs: { type: 'number' } }, required: ['caption', 'durationMs'] } } },
    required: ['segments']
  });

  eventBus.emit(job.id, { phase: 'final', content: script });
  return script;
}

性能与成本优化

  • 分层推理:草稿小模型、精修大模型;图像先低清后高清;视频先关键帧后插帧。
  • 缓存优先:文案模板 + 主题锚点可强缓存;相同 Prompt 走 KV/向量近似缓存。
  • 并行与流水:文本、图像、TTS 可并行;最终合成串行保证一致性。
  • 量化与蒸馏:服务端模型采用 8 位或更低精度,GPU 显存压力小;热门风格用 LoRA 微调替代全参。
  • 传输与预览:SSE/分段响应,先显后优;前端低码率草稿预览,点击再生成高清版。

合规与安全

  • 来源与版权:训练数据来源透明;生成素材记录来源标记与许可证。
  • 可识别度与水印:对合成媒体做可机器检测的隐形标记;导出时附带元数据。
  • 隐私:面对用户上传素材,按需加密、最小化存储;任务隔离与访问审计。
  • 风险过滤:指令前置过滤 + 输出后置审核;多模态检测(文本、图像帧、音频文本化)联动。

常见坑与排障清单

  • 所有模态都“等对方”:没有并行导致总时延爆炸。解决:早建锚点,分轨并行。
  • 图像风格飘:未共享风格 token 与色板。解决:把品牌样式做硬约束。
  • 旁白卡顿:TTS 一次性返回,用户以为卡。解决:流式分段合成与播放。
  • 预览卡帧:主线程被 React 渲染占满。解决:用 Worker 与 OffscreenCanvas。
  • 成本失控:热门话题重复生成。解决:KV 缓存 + 语义去重。
  • 发布后“花屏”:浏览器解码能力差异。解决:导出多档编码,HLS 自适应。

行动清单

  • 定义统一“语义锚点”:主题、风格、颜色、镜头词典,贯通全模态。
  • 建立前端编排器:SSE 流式体验 + 可编辑时间轴。
  • 架构上用边缘做路由与缓存,后端做重推理与任务编排。
  • 实施草稿-精修与多级缓存,降本增效。
  • 把合规内置到流水线:过滤器、水印、审计日志一个都不能少。

创作从来不是把灵感关在服务器机房,而是把灵感调度到用户眼前。
愿你的 Web 创作工作室像一台精密乐团:提示词是指挥棒,模型是乐手,时间轴是节拍器,内容在浏览器里,现场开演。🎼🎬🎨🧠

Next.js 的 Web Vitals 监测与 Lighthouse 分析:从底层到实战的快乐科学

目录

  • 为什么是 Web Vitals?
  • Web Vitals 指标长啥样(以及它们“真的在乎什么”)
  • 在 Next.js 中采集 Web Vitals(含自定义上报)
  • 用 Lighthouse 验证与对照
  • 指标对标与调优策略(含 SSR/ISR/Edge 等底层视角)
  • 常见坑与排障清单
  • 小结与行动清单

为什么是 Web Vitals?

  • 用户体验不是玄学,它有可测量的客观指标。
  • 谷歌推的 Web Vitals 已经成为“绩效考核标准”,搜索引擎、转化率、留存都与之强相关。
  • Next.js 自带对 Web Vitals 的采集能力,我们只需要接住它,把数据打到监控平台就能建立“可观测性闭环”。

小结:没有数据,优化都是“玄学叙事”;有了 Web Vitals,我们才有“实验-验证-迭代”的工程闭环。


Web Vitals 指标长啥样?

以下是核心指标(Core Web Vitals)与常见扩展指标的“人话版”:

  • LCP(Largest Contentful Paint)最大内容绘制
    关注页面主内容可见的时间。
    逻辑理解:浏览器判断哪一块是“最大”的内容(大图、大块文本、视频封面)并记录它首次出现的时间。
    目标:越快越好。通常 2.5 秒以内被视为优秀。
  • CLS(Cumulative Layout Shift)累计布局偏移
    页面元素跳来跳去的“烦躁指数”。
    逻辑理解:根据每次布局变化的“位移比例 × 视窗影响面积比例”累计叠加。
    目标:越小越好。一般 0.1 以下算优秀。
  • INP(Interaction to Next Paint)交互到下一次绘制
    用户交互(点击、输入)到页面下一帧渲染的延迟。
    逻辑理解:把各类交互事件的响应时间分布里“接近高位”的值拿出来衡量稳定体验。
    目标:200 毫秒以内优秀。
  • FID(First Input Delay)首个输入延迟(逐步被 INP 替代)
    首次交互到事件处理程序真正运行的延迟。
    目标:100 毫秒以内优秀。
  • TTFB(Time To First Byte)首字节时间
    服务端到客户端第一字节到达的时间。
    目标:<= 0.8 秒普遍可用;越低越好。
  • FCP(First Contentful Paint)首个内容绘制
    屏幕上出现第一个非白屏内容的时间。
    常被用于对比不同渲染路径的可见速度。

小图标助兴:

  • ⚡ 快:LCP、FCP
  • 🧩 稳:CLS
  • 🕹️ 灵:INP、FID
  • 🚚 供:TTFB(供给链:网络、后端、边缘)

在 Next.js 中采集 Web Vitals

Next.js 为我们提供了一个“官方入口”来接收浏览器端的 Web Vitals:reportWebVitals。你可以将数据打印到控制台、发送到你的 APM/日志平台、或者自建端点持久化。

下面给出两套写法:App Router(app/)与 Pages Router(pages/)。

1)App Router(Next.js 13+,app/)

app/ 目录下新建 vitals.ts,并在 app/layout.tsx 中导入以初始化。

// app/vitals.ts
export function onReportWebVitals(metric) {
  // metric 对象结构示例:
  // {
  //   id, name, startTime, value, label, delta, entries
  // }
  // name 可能为 'CLS', 'FCP', 'FID', 'INP', 'LCP', 'TTFB'
  try {
    // 示例:发送到你自己的后端收集端点
    const body = JSON.stringify({
      id: metric.id,
      name: metric.name,
      value: metric.value,
      delta: metric.delta,
      label: metric.label,
      startTime: metric.startTime,
      page: location.pathname,
      ua: navigator.userAgent,
      ts: Date.now()
    });

    // 使用 navigator.sendBeacon 优先,失败再 fetch
    const url = '/api/vitals';
    if (navigator.sendBeacon) {
      navigator.sendBeacon(url, body);
    } else {
      fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        keepalive: true,
        body
      });
    }
  } catch (e) {
    // 静默失败,避免影响用户体验
    console.warn('Vitals report failed', e);
  }
}
// app/layout.tsx
import './globals.css'
import { onReportWebVitals } from './vitals'

export const reportWebVitals = onReportWebVitals

export default function RootLayout({ children }) {
  return (
    <html lang="zh-CN">
      <body>{children}</body>
    </html>
  )
}

再创建一个 API 路由接收数据:

// app/api/vitals/route.js
export async function POST(req) {
  const data = await req.json();

  // 你可以在此把数据写入日志、存数据库、进消息队列等
  // 下面仅示例打印到服务器日志
  console.log('[web-vitals]', data.name, data.value, data.page, data.id);

  return new Response('ok', { status: 200 });
}

2)Pages Router(pages/)

// pages/_app.js
import '../styles/globals.css'

export function reportWebVitals(metric) {
  try {
    const body = JSON.stringify({
      id: metric.id,
      name: metric.name,
      value: metric.value,
      delta: metric.delta,
      label: metric.label,
      startTime: metric.startTime,
      page: location.pathname,
      ua: navigator.userAgent,
      ts: Date.now()
    });

    const url = '/api/vitals';
    if (navigator.sendBeacon) {
      navigator.sendBeacon(url, body);
    } else {
      fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        keepalive: true,
        body
      });
    }
  } catch (e) {
    console.warn('Vitals report failed', e);
  }
}

export default function MyApp({ Component, pageProps }) {
  return <Component {...pageProps} />
}
// pages/api/vitals.js
export default async function handler(req, res) {
  if (req.method === 'POST') {
    // 收集与落盘/转发
    console.log('[web-vitals]', req.body?.name, req.body?.value, req.body?.page, req.body?.id);
    return res.status(200).send('ok');
  }
  res.status(405).send('Method Not Allowed');
}

贴士:

  • 生产环境建议加上采样率,例如只上报 10%:if (Math.random() > 0.1) return;
  • 避免在首屏关键路径上做重量级计算或同步 IO,上报应使用 sendBeacon 或 keepalive fetch。

用 Lighthouse 验证与对照

Lighthouse 是一盏手电筒,帮你照亮“你以为”和“真实情况”的差距。你可以在 Chrome DevTools 的 Lighthouse 面板运行,或使用 lighthouse CLI 与 CI 集成。

  • 它会模拟冷启动加载,生成以下分数:Performance、Accessibility、Best Practices、SEO。

  • Performance 会给出 LCP、CLS、INP/FID、TTFB、FCP 等的“实验室数据”。

  • 对照要点:

    • Web Vitals 上报是“真实用户数据”(RUM);
    • Lighthouse 是“合成测试”。
    • 两者互相校准:Lighthouse 用来定位问题和回归测试;RUM 用来看实际用户分布与波动。

常见对照结论:

  • Lighthouse LCP 佳但线上 LCP 差:CDN 地域、真实图片体积、登录/个性化带来的差异。
  • Lighthouse INP 优但线上 INP 飙高:真实用户设备较弱、第三方脚本劫持事件循环、瀑布数据更多。

指标对标与调优策略(含底层视角)

从浏览器、网络、Node/边缘运行时多层入手。

1)LCP 优化

  • 图像优化:

    • 使用 <Image> 组件与自适应格式(AVIF/WEBP),开启 next/image 的优化服务或外部 loader。
    • 预加载 LCP 资源:在 Head 里添加 rel="preload";Next.js 通过 <link rel=prefetch/preload> 可控制关键资源获取顺序。
  • HTML 优先级:

    • 减少阻塞渲染的 CSS/JS。将非关键 CSS 延迟加载;拆分上行 JS,减少初始包。
  • 渲染路径:

    • SSR/ISR 让用户更快拿到可渲染 HTML;用 Edge Runtime 把 TTFB 拉低,间接提振 LCP。
    • 避免在渲染阶段做慢 I/O,可用并发与缓存(fetch 的内建缓存、revalidate)。

底层原理小剧场:
浏览器要呈现 LCP,必须先拿到 HTML、解析 DOM、下载/解析 CSS、布局、绘制。当“最大节点”(例如 Hero 图)首次绘制完成就计时。阻塞 CSS、延迟图片加载、主线程 JS 占用都会延后这个时刻。

2)CLS 优化

  • 预留尺寸:给图片、广告位、组件容器设置明确的宽高或 aspect-ratio。
  • 动态注入:避免在顶部插入 DOM;需要插入时占位或使用过渡动画。
  • 字体闪动:用可交换字体策略(font-display: swap/optional),并为自定义字体设置合适的 fallback。

底层原理小剧场:
CLS 是对“偏移比例 × 影响面积”的累计。哪怕一个元素轻微移动,但覆盖屏幕大面积,也会有显著分值。稳定布局就是消灭“晚知道的尺寸”。

3)INP/FID 优化

  • 主线程健康:

    • 分割长任务(超过 50ms 的脚本),使用 requestIdleCallback/setTimeout 切片。
    • 使用 React Server Components 降低客户端 JS;用于交互的组件才下发 JS。
  • 事件处理:

    • 避免在点击事件中做重计算和同步阻塞(如 JSON 大解析、加密、巨大 dataURL)。
    • 对输入框相关逻辑做防抖/节流。
  • 第三方脚本:

    • 打上 async/defer,或采用 Partytown 把第三方运行到 web worker。
    • 用资源提示 preconnect 提前握手第三方域名。

底层原理小剧场:
INP 度量交互到下次绘制的延迟。只要事件处理或渲染链路卡住主线程,下一帧就来不及。最小化主线程“独占时间”是王道。

4)TTFB 优化

  • 架构:

    • 使用 Edge Runtime(Vercel Edge Functions 或 Cloudflare Workers)把逻辑前移。
    • 为数据请求加缓存(HTTP 缓存头、fetch 缓存、revalidateTag 等)。
  • 数据源:

    • 合并往返次数,靠 BFF 接口聚合。
    • 用流式 SSR(React 服务器组件/Server Actions)尽早送字节。
  • 网络:

    • 启用 HTTP/2 或 HTTP/3,复用连接;预连接 preconnect 关键域名。

在 Next.js 里把 Lighthouse 和 Web Vitals 联动起来

  • 在 CI 中跑 Lighthouse(如 lighthouse-ci),设定最低阈值;对比构建前后。
  • 线上用 RUM 收集 Web Vitals,做 75 分位数统计(例如每日/每端/每地域)。
  • 若 CI 分数下降但 RUM 正常,可能是实验室环境变动;若 RUM 下降而 CI 正常,可能是真实流量变了(比如一次运营投放带来大量低端机流量)。

实战代码片段:资源提示与图像优化

// app/head.js (App Router)
export default function Head() {
  return (
    <>
      <link rel="preconnect" href="https://example-cdn.com" crossOrigin="" />
      <link rel="dns-prefetch" href="https://example-cdn.com" />
      {/* 关键 CSS 预加载示例(注意匹配实际构建产物) */}
      {/* <link rel="preload" as="style" href="/styles/critical.css" /> */}
      {/* LCP 图像预加载(如果确定该图像是首屏最大内容) */}
      {/* <link rel="preload" as="image" href="/hero.avif" imagesrcset="/hero.avif 1x, /hero@2x.avif 2x" /> */}
    </>
  )
}
// 使用 next/image 提升 LCP 与节流带宽
import Image from 'next/image'

export default function Hero() {
  return (
    <div style={{ position: 'relative', minHeight: 360 }}>
      <Image
        src="/hero.avif"
        alt="英雄横幅"
        fill
        priority
        sizes="100vw"
        style={{ objectFit: 'cover' }}
      />
      <h1 className="title">你好,快速世界 ⚡</h1>
      <style jsx>{`
        .title {
          position: absolute;
          bottom: 16px;
          left: 16px;
          margin: 0;
          color: white;
          text-shadow: 0 2px 8px rgba(0,0,0,0.5);
        }
      `}</style>
    </div>
  )
}

常见坑与排障清单

  • 只在开发环境测试 Lighthouse:误差大。请用无扩展的干净 Chrome、模拟 4G/中端机配置。
  • 忽略真实用户:RUM 才是决策依据;Lighthouse 只是“幻灯片拍照”。
  • 图片懒加载过度:LCP 图片被懒加载,或者未 priority,导致 LCP 被延后。
  • CSS 阻塞:单个大 CSS/JS 包可能阻塞初始渲染;使用代码拆分与关键 CSS。
  • 第三方脚本未隔离:热量全在主线程燃烧,INP 爆表。
  • 无占位导致 CLS:广告/推荐位首次渲染后挤开布局。
  • 服务器渲染慢:TTFB 高,后续指标也受拖累。考虑缓存与边缘计算。

一点“底层味道”的侦查技巧

  • Performance 面板火焰图:找到超过 50ms 的长任务;把它切片或延后。
  • Coverage 面板:看看首屏用到多少 JS/CSS;不需要的就别上车。
  • WebSocket/Server Actions:谨慎大对象序列化,避免在关键时刻开销过大。
  • React Profiler:识别重复渲染与无效 diff;使用 memo、useMemouseCallback 有的放矢。
  • HTTP 头与缓存:cache-control, etag, stale-while-revalidate 组合拳。

小结与行动清单

  • 建立数据闭环:Next.js 的 reportWebVitals + 你的日志/指标平台。
  • 将 Lighthouse 接入 CI,设阈值守门。
  • 聚焦三件事:LCP 快、CLS 稳、INP 灵。
  • 架构层面:用 SSR/ISR/Edge 优化 TTFB 与传输链;用 next/image 和资源提示搞定“首屏关键资源”。
  • 把慢任务切碎,把第三方脚本“关小黑屋”(worker/async/defer)。

把性能做好,不是让页面“瘦成干瘪”,而是让它“肌肉分明”。
愿你的页面像短跑冠军一样起跑迅猛(LCP),落地稳健(CLS),反应敏捷(INP)。🏃‍♂️💨🛡️🕹️

—— 祝你在 Lighthouse 的光照下,像素闪闪发光。

1688 item_get_app 接口深度分析及 Python 实现

1688 平台的 item_get_app 接口是获取商品原始详情数据的核心接口,专门针对移动端应用场景设计。与普通的 item_get 接口相比,它返回的数据结构更贴近 1688 APP 端展示的原始格式,包含更丰富的 B2B 场景特有字段,如批发价格、起订量、供应商信息等,对采购决策和供应链分析具有重要价值。

一、接口核心特性分析

  1. 接口功能与定位
  • 核心功能:获取 1688 商品的原始详情数据,包括商品基础信息、价格体系(含批发价)、规格参数、供应商信息、交易数据等

  • 数据特点

    • 与 1688 APP 端数据结构一致,保留移动端特有的展示字段
    • 包含 B2B 特有的批发价格、起订量、混批规则等信息
    • 提供供应商详细信息,如经营年限、交易量、认证资质等
    • 包含实时库存、发货地、物流模板等供应链关键数据
  • 应用场景

    • 采购决策支持系统
    • 供应商评估与筛选
    • 市场价格监测
    • 竞品分析与比较
    • 供应链优化与风险控制
  1. 认证机制 1688 开放平台采用 appkey + access_token 的认证方式:
  • 开发者在 1688 开放平台注册应用,获取 appkey 和 appsecret
  • 通过 appkey 和 appsecret 获取 access_token(通常有效期为 24 小时)
  • 每次接口调用需携带有效 access_token
  • item_get_app 属于高级接口,需要单独申请权限,企业账号权限更高
  1. 核心参数与响应结构

请求参数

参数名 类型 是否必填 说明
offer_id String 商品 ID(1688 中称为 offer_id)
access_token String 访问令牌
member_id String 采购商 ID,用于获取个性化价格
province String 省份名称,用于获取区域化价格和物流信息
fields String 需要返回的字段,默认返回全部字段

响应核心字段

  • 商品基础信息:商品 ID、名称、标题、详情描述、类目信息
  • 价格体系:批发价、零售价、阶梯价格、混批规则、折扣信息
  • 规格参数:SKU 规格、属性组合、起订量、库存
  • 供应商信息:供应商 ID、名称、经营年限、交易量、认证信息
  • 物流信息:发货地、运费模板、预计发货时间
  • 交易数据:30 天成交量、买家数、重复采购率
  • 多媒体信息:主图、详情图、视频、规格图

二、Python 脚本实现

以下是调用 1688 item_get_app 接口的完整 Python 实现,包含令牌获取、接口调用、数据解析及 B2B 场景特有分析功能: import requests import time import json import logging import re from typing import Dict, Optional, List from requests.exceptions import RequestException

配置日志 logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" )

class Alibaba1688ItemGetAppAPI: def init(self, appkey: str, appsecret: str): """ 初始化1688商品详情API客户端 :param appkey: 1688开放平台appkey :param appsecret: 1688开放平台appsecret """ self.appkey = appkey self.appsecret = appsecret self.base_url = "gw.open.1688.com/openapi" self.access_token = None self.token_expires_at = 0 # token过期时间戳 self.session = requests.Session() self.session.headers.update({ "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "AlibabaApp/9.1.0 (iPhone; iOS 14.4; Scale/2.00)" })

def _get_access_token(self) -> Optional[str]:
    """获取访问令牌"""
    # 检查token是否有效
    if self.access_token and self.token_expires_at > time.time() + 60:
        return self.access_token
        
    logging.info("获取新的access_token")
    params = {
        "method": "alibaba.oauth2.getToken",
        "client_id": self.appkey,
        "client_secret": self.appsecret,
        "grant_type": "client_credentials",
        "format": "json"
    }
    
    try:
        response = self.session.get(f"{self.base_url}/gateway.do", params=params, timeout=10)
        response.raise_for_status()
        result = response.json()
        
        if "error_response" in result:
            logging.error(f"获取access_token失败: {result['error_response']['msg']} (错误码: {result['error_response']['code']})")
            return None
            
        self.access_token = result["access_token"]
        self.token_expires_at = time.time() + result.get("expires_in", 86400)  # 默认为24小时
        return self.access_token
            
    except RequestException as e:
        logging.error(f"获取access_token请求异常: {str(e)}")
        return None

def get_item_raw_data(self, 
                     offer_id: str, 
                     member_id: Optional[str] = None,
                     province: Optional[str] = None,
                     fields: Optional[str] = None) -> Optional[Dict]:
    """
    获取商品原始详情数据
    :param offer_id: 商品ID(1688中称为offer_id)
    :param member_id: 采购商ID
    :param province: 省份名称
    :param fields: 需要返回的字段
    :return: 商品原始数据
    """
    # 获取有效的access_token
    if not self._get_access_token():
        return None
        
    params = {
        "method": "alibaba.item.get.app",
        "client_id": self.appkey,
        "access_token": self.access_token,
        "offer_id": offer_id,
        "format": "json",
        "v": "1.0",
        "timestamp": time.strftime("%Y-%m-%d %H:%M:%S")
    }
    
    # 可选参数
    if member_id:
        params["member_id"] = member_id
    if province:
        params["province"] = province
    if fields:
        params["fields"] = fields
        
    try:
        response = self.session.get(f"{self.base_url}/gateway.do", params=params, timeout=20)
        response.raise_for_status()
        result = response.json()
        
        if "error_response" in result:
            logging.error(f"获取商品数据失败: {result['error_response']['msg']} (错误码: {result['error_response']['code']})")
            return None
            
        item_response = result.get("alibaba_item_get_app_response", {})
        item_data = item_response.get("result", {})
        
        if not item_data:
            logging.warning("未获取到商品数据")
            return None
            
        # 格式化原始数据
        return self._process_raw_data(item_data)
        
    except RequestException as e:
        logging.error(f"获取商品数据请求异常: {str(e)}")
        return None
    except json.JSONDecodeError:
        logging.error(f"商品数据响应解析失败: {response.text[:200]}...")
        return None

def _process_raw_data(self, raw_data: Dict) -> Dict:
    """处理原始数据,提取关键信息并格式化"""
    # 基础信息提取
    base_info = {
        "offer_id": raw_data.get("offer_id"),
        "title": raw_data.get("title"),
        "sub_title": raw_data.get("sub_title"),
        "detail_url": raw_data.get("detail_url"),
        "category": {
            "cid": raw_data.get("category_id"),
            "name": raw_data.get("category_name"),
            "level1_cid": raw_data.get("level1_category_id"),
            "level1_name": raw_data.get("level1_category_name"),
            "level2_cid": raw_data.get("level2_category_id"),
            "level2_name": raw_data.get("level2_category_name")
        },
        "tags": raw_data.get("tags", "").split(","),
        "creation_time": raw_data.get("creation_time"),
        "modify_time": raw_data.get("modify_time")
    }
    
    # 价格信息提取(B2B特有的阶梯价格体系)
    price_info = {
        "retail_price": self._safe_float(raw_data.get("retail_price")),  # 零售价
        "wholesale_prices": self._format_wholesale_prices(raw_data.get("wholesale_price_list", [])),  # 批发价列表
        "mix_batch": {  # 混批规则
            "support": raw_data.get("support_mix_batch", False),
            "min_amount": self._safe_float(raw_data.get("mix_batch_min_amount")),
            "min_quantity": self._safe_int(raw_data.get("mix_batch_min_quantity"))
        },
        "currency": raw_data.get("currency", "CNY"),
        "price_unit": raw_data.get("price_unit", "个")
    }
    
    # 库存与规格信息提取
    sku_info = {
        "total_sku": self._safe_int(raw_data.get("total_sku")),
        "skus": self._format_skus(raw_data.get("skus", [])),
        "specs": self._format_specs(raw_data.get("specs", [])),
        "total_stock": self._safe_int(raw_data.get("total_stock")),
        "sales_count": self._safe_int(raw_data.get("sales_count_30d")),  # 30天销量
        "unit": raw_data.get("unit", "个")
    }
    
    # 供应商信息提取(B2B核心信息)
    supplier_info = {
        "supplier_id": raw_data.get("supplier_id"),
        "supplier_name": raw_data.get("supplier_name"),
        "company_name": raw_data.get("company_name"),
        "years": self._safe_int(raw_data.get("operating_years")),  # 经营年限
        "main_products": raw_data.get("main_products", "").split(";"),
        "location": raw_data.get("location"),
        "transaction": {
            "turnover": self._safe_float(raw_data.get("annual_turnover")),  # 年交易额
            "rating": self._safe_float(raw_data.get("supplier_rating")),  # 供应商评分
            "repeat_rate": self._safe_float(raw_data.get("repeat_purchase_rate")),  # 重复采购率
            "buyer_count": self._safe_int(raw_data.get("buyer_count_30d"))  # 30天买家数
        },
        "authentication": {
            "real_name": raw_data.get("real_name_authentication", False),
            "company": raw_data.get("company_authentication", False),
            "gold_supplier": raw_data.get("is_gold_supplier", False),  # 是否金牌供应商
            "assessed_supplier": raw_data.get("is_assessed_supplier", False)  # 是否实力商家
        },
        "contact": {
            "phone": raw_data.get("contact_phone"),
            "online_status": raw_data.get("online_status")
        }
    }
    
    # 物流信息提取
    logistics_info = {
        "delivery_location": raw_data.get("delivery_location"),  # 发货地
        "freight_template": raw_data.get("freight_template_name"),
        "delivery_time": raw_data.get("delivery_time"),  # 发货时间
        "transport_modes": raw_data.get("transport_modes", "").split(","),  # 运输方式
        "min_delivery_days": self._safe_int(raw_data.get("min_delivery_days")),
        "max_delivery_days": self._safe_int(raw_data.get("max_delivery_days"))
    }
    
    # 多媒体信息提取
    media_info = {
        "main_images": raw_data.get("main_image_list", []),
        "detail_images": raw_data.get("detail_image_list", []),
        "video_url": raw_data.get("video_url"),
        "video_cover": raw_data.get("video_cover")
    }
    
    # 详情描述提取
    description = {
        "detail": self._clean_html(raw_data.get("detail", "")),
        "short_description": raw_data.get("short_description")
    }
    
    return {
        "base_info": base_info,
        "price_info": price_info,
        "sku_info": sku_info,
        "supplier_info": supplier_info,
        "logistics_info": logistics_info,
        "media_info": media_info,
        "description": description,
        "raw_data": raw_data  # 保留原始数据
    }

def _safe_float(self, value) -> float:
    """安全转换为float"""
    try:
        return float(value) if value is not None else 0.0
    except (ValueError, TypeError):
        return 0.0

def _safe_int(self, value) -> int:
    """安全转换为int"""
    try:
        return int(value) if value is not None else 0
    except (ValueError, TypeError):
        return 0

def _format_wholesale_prices(self, prices: List[Dict]) -> List[Dict]:
    """格式化批发价格列表(阶梯价格)"""
    formatted = []
    for price in prices:
        formatted.append({
            "min_quantity": self._safe_int(price.get("min_quantity")),
            "max_quantity": self._safe_int(price.get("max_quantity")),
            "price": self._safe_float(price.get("price")),
            "discount": self._safe_float(price.get("discount"))  # 折扣
        })
    # 按起订量排序
    return sorted(formatted, key=lambda x: x["min_quantity"])

def _format_skus(self, skus: List[Dict]) -> List[Dict]:
    """格式化SKU列表"""
    formatted = []
    for sku in skus:
        # 处理SKU的阶梯价格
        sku_prices = []
        if sku.get("price_list"):
            for p in sku.get("price_list"):
                sku_prices.append({
                    "min_quantity": self._safe_int(p.get("min_quantity")),
                    "price": self._safe_float(p.get("price"))
                })
        
        formatted.append({
            "sku_id": sku.get("sku_id"),
            "specs": sku.get("specs", []),  # 规格组合
            "price": self._safe_float(sku.get("price")),
            "wholesale_prices": sku_prices,  # SKU级别的阶梯价格
            "stock": self._safe_int(sku.get("stock")),
            "sales_count": self._safe_int(sku.get("sales_count")),
            "image_url": sku.get("image_url")
        })
    return formatted

def _format_specs(self, specs: List[Dict]) -> List[Dict]:
    """格式化规格参数列表"""
    formatted = []
    for spec in specs:
        formatted.append({
            "name": spec.get("name"),
            "values": [
                {
                    "name": val.get("name"),
                    "image_url": val.get("image_url"),
                    "spec_id": val.get("spec_id")
                } for val in spec.get("values", [])
            ]
        })
    return formatted

def _clean_html(self, html: str) -> str:
    """清理HTML内容,提取纯文本"""
    if not html:
        return ""
    # 去除HTML标签
    clean = re.sub(r'<.*?>', ' ', html)
    # 去除多余空格和换行
    clean = re.sub(r'\s+', ' ', clean).strip()
    return clean

def analyze_b2b_value(self, item_data: Dict) -> Dict:
    """分析商品的B2B采购价值,生成评估报告"""
    if not item_data:
        return {}
        
    # 价格优势评分(0-10分)
    price_advantage = 0
    if item_data["price_info"]["wholesale_prices"]:
        # 基于批发价与零售价的差异计算
        retail_price = item_data["price_info"]["retail_price"]
        lowest_wholesale = item_data["price_info"]["wholesale_prices"][0]["price"]
        
        if retail_price > 0 and lowest_wholesale > 0:
            discount_rate = (retail_price - lowest_wholesale) / retail_price
            price_advantage = min(10, round(discount_rate * 10))
    
    # 供应商可靠性评分(0-10分)
    supplier_score = 0
    supplier = item_data["supplier_info"]
    if supplier["authentication"]["company"]:
        supplier_score += 3
    if supplier["authentication"]["gold_supplier"]:
        supplier_score += 2
    if supplier["authentication"]["assessed_supplier"]:
        supplier_score += 2
    if supplier["years"] >= 3:
        supplier_score += 2
    if supplier["transaction"]["repeat_rate"] > 30:
        supplier_score += 1
    
    # 采购灵活性评分(0-10分)
    flexibility_score = 0
    if item_data["price_info"]["mix_batch"]["support"]:
        flexibility_score += 5
    # 基于起订量评估
    min_order = item_data["price_info"]["mix_batch"]["min_quantity"] or float('inf')
    if min_order <= 5:
        flexibility_score += 5
    elif min_order <= 20:
        flexibility_score += 3
    elif min_order <= 100:
        flexibility_score += 1
    
    # 物流评分(0-10分)
    logistics_score = 0
    logistics = item_data["logistics_info"]
    if logistics["min_delivery_days"] <= 2:
        logistics_score += 5
    elif logistics["min_delivery_days"] <= 5:
        logistics_score += 3
    if len(logistics["transport_modes"]) >= 2:
        logistics_score += 3
    if logistics["freight_template"]:
        logistics_score += 2
    
    # 综合评分
    overall_score = round((price_advantage + supplier_score + flexibility_score + logistics_score) / 4, 1)
    
    return {
        "overall_score": overall_score,
        "price_advantage": {
            "score": price_advantage,
            "description": f"批发价最低为零售价的{round(item_data['price_info']['wholesale_prices'][0]['price']/item_data['price_info']['retail_price']*100, 1)}%" if item_data["price_info"]["retail_price"] > 0 else ""
        },
        "supplier_reliability": {
            "score": supplier_score,
            "description": f"{supplier['years']}年经营,重复采购率{supplier['transaction']['repeat_rate']}%"
        },
        "purchase_flexibility": {
            "score": flexibility_score,
            "description": f"{'支持' if item_data['price_info']['mix_batch']['support'] else '不支持'}混批,最低起订量{item_data['price_info']['mix_batch']['min_quantity'] or '未知'}"
        },
        "logistics_capability": {
            "score": logistics_score,
            "description": f"发货地{logistics['delivery_location']}{logistics['min_delivery_days']}-{logistics['max_delivery_days']}天送达"
        },
        "risk_assessment": {
            "high_risk": overall_score < 3,
            "medium_risk": 3 <= overall_score < 6,
            "low_risk": overall_score >= 6
        }
    }

示例调用 if name == "main": # 替换为实际的appkey和appsecret(从1688开放平台获取) APPKEY = "your_appkey" APPSECRET = "your_appsecret" # 替换为目标商品offer_id OFFER_ID = "61234567890"

# 初始化API客户端
api = Alibaba1688ItemGetAppAPI(APPKEY, APPSECRET)

# 获取商品原始数据
item_data = api.get_item_raw_data(
    offer_id=OFFER_ID,
    # member_id="your_member_id",  # 可选,采购商ID
    # province="浙江省",  # 可选,省份
    # fields="offer_id,title,price,stock"  # 可选,指定需要的字段
)

if item_data:
    print(f"=== 1688商品详情 (offer_id: {OFFER_ID}) ===")
    print(f"商品名称: {item_data['base_info']['title']}")
    print(f"类目: {item_data['base_info']['category']['level1_name']} > {item_data['base_info']['category']['level2_name']}")
    print(f"供应商: {item_data['supplier_info']['supplier_name']} ({item_data['supplier_info']['company_name']})")
    print(f"经营年限: {item_data['supplier_info']['years']}年")
    print(f"30天销量: {item_data['sku_info']['sales_count']}件")
    print(f"30天买家数: {item_data['supplier_info']['transaction']['buyer_count']}")
    print(f"重复采购率: {item_data['supplier_info']['transaction']['repeat_rate']}%")
    
    # 价格信息
    print("\n价格信息:")
    print(f"  零售价: {item_data['price_info']['retail_price']}元/{item_data['price_info']['price_unit']}")
    print("  批发价:")
    for i, wholesale in enumerate(item_data['price_info']['wholesale_prices'], 1):
        max_qty = f"-{wholesale['max_quantity']}" if wholesale['max_quantity'] else "+"
        print(f"    {wholesale['min_quantity']}{max_qty}件: {wholesale['price']}元/{item_data['price_info']['price_unit']}")
    
    # 混批信息
    print("\n采购规则:")
    mix_batch = item_data['price_info']['mix_batch']
    print(f"  混批: {'支持' if mix_batch['support'] else '不支持'}")
    if mix_batch['support']:
        print(f"    最低金额: {mix_batch['min_amount']}元")
        print(f"    最低数量: {mix_batch['min_quantity']}件")
    
    # 物流信息
    print("\n物流信息:")
    print(f"  发货地: {item_data['logistics_info']['delivery_location']}")
    print(f"  预计发货时间: {item_data['logistics_info']['delivery_time']}")
    print(f"  运输方式: {', '.join(item_data['logistics_info']['transport_modes'])}")
    print(f"  预计送达时间: {item_data['logistics_info']['min_delivery_days']}-{item_data['logistics_info']['max_delivery_days']}天")
    
    # 规格信息
    if item_data['sku_info']['skus']:
        print("\n规格信息:")
        for i, sku in enumerate(item_data['sku_info']['skus'][:3], 1):
            specs = ", ".join([f"{s['name']}:{s['value']}" for s in sku['specs']]) if sku['specs'] else "标准"
            print(f"  {i}. {specs}: {sku['price']}元, 库存{sku['stock']}件")
    
    # B2B价值分析
    b2b_analysis = api.analyze_b2b_value(item_data)
    print("\n=== B2B采购价值评估 ===")
    print(f"综合评分: {b2b_analysis['overall_score']}/10分")
    print(f"价格优势: {b2b_analysis['price_advantage']['score']}/10分 {b2b_analysis['price_advantage']['description']}")
    print(f"供应商可靠性: {b2b_analysis['supplier_reliability']['score']}/10分 {b2b_analysis['supplier_reliability']['description']}")
    print(f"采购灵活性: {b2b_analysis['purchase_flexibility']['score']}/10分 {b2b_analysis['purchase_flexibility']['description']}")
    print(f"物流能力: {b2b_analysis['logistics_capability']['score']}/10分 {b2b_analysis['logistics_capability']['description']}")
    print(f"风险评估: {'高风险' if b2b_analysis['risk_assessment']['high_risk'] else '中等风险' if b2b_analysis['risk_assessment']['medium_risk'] else '低风险'}")

三、接口调用注意事项

  1. 调用限制与规范
  • QPS 限制:1688 开放平台对商品详情接口的 QPS 限制通常为 5-10 次 / 秒
  • 权限差异:企业认证账号比个人账号能获取更详细的供应商数据和价格信息
  • 数据缓存:建议缓存获取的数据(缓存时间 30-60 分钟),减少重复调用
  • 地区差异:不同地区可能有不同的物流政策和价格,需正确设置 province 参数
  • 字段权限:部分敏感字段(如供应商联系方式)需要额外申请权限
  1. 常见错误及解决方案
错误码 说明 解决方案
401 未授权或 token 无效 重新获取 access_token,检查权限是否正确
403 权限不足 升级账号类型,申请商品详情接口的完整权限
404 商品不存在 确认 offer_id 是否正确,1688 商品 ID 通常为 10-12 位数字
429 调用频率超限 降低调用频率,实现请求限流
500 服务器内部错误 实现重试机制,最多 3 次,间隔指数退避
110 参数错误 检查 offer_id 格式是否正确
  1. 数据解析要点
  • 价格体系:1688 采用阶梯价格体系,需特别处理不同采购量对应的价格
  • 供应商评估:重点关注经营年限、重复采购率、认证资质等供应商指标
  • 库存状态:部分商品显示的是总库存,部分显示 SKU 级库存,需区分处理
  • 混批规则:混批支持情况和具体规则对采购决策至关重要
  • 物流信息:发货地和物流模板直接影响采购成本和到货时间

四、应用场景与扩展建议 典型应用场景

  • 智能采购系统:基于商品详情数据和供应商评估实现自动化采购决策
  • 供应商管理平台:整合商品数据和供应商信息,构建供应商评估体系
  • 价格监测工具:实时监控商品价格变化,把握最佳采购时机
  • 竞品分析系统:对比同类商品的价格、规格、供应商等信息
  • 供应链优化工具:基于物流信息和供应商分布优化采购布局 扩展建议
  • 实现价格趋势分析:定期获取价格数据,分析价格波动规律
  • 开发供应商匹配系统:基于采购需求自动匹配最合适的供应商
  • 构建采购风险评估模型:结合供应商信息和商品数据评估采购风险
  • 实现多地区价格对比:分析不同地区的价格和物流成本差异
  • 开发批量采购计算器:根据阶梯价格计算最优采购量和总成本
  • 构建商品相似度匹配:基于规格参数和描述实现同类商品自动匹配 通过合理使用 1688 item_get_app 接口,开发者可以构建针对 B2B 场景的商品分析和采购决策系统,充分利用 1688 平台的批发特性,为企业采购提供数据支持。使用时需特别关注 B2B 场景特有的阶梯价格、起订量、供应商评估等维度,以获取更有价值的分析结果

Vue 项目图标一把梭:Iconify 自用小记(含 TS/JS 双版本组件)

自留地第一锄,先把我每天 Cmd+C / Cmd+V 的图标方案刨出来,以后再也不用 ../assets/icon-xxx.svg 了。


1. 为什么选 Iconify

痛点 传统方案 Iconify 方案
图标源分散 阿里、Feather、Heroicons... 各装一个包 100+ 开源图标库 一个包全吃
编译体积 全量引入,打包 2 MB 按需 CDN,只下当前图标
使用成本 先下载 → 再放 assets → 再 import <iconify-icon icon="mdi:home" /> 一行搞定
可定制性 改颜色得开 Figma 直接 class="text-red-500" Tailwind 即玩

2. 一分钟上车

① 安装

# Vue3 官方组件
pnpm i @iconify/vue
# 如果还想离线,再装具体库(可选)
pnpm i @iconify-json/mdi

② 图标怎么找?

下面给出一份「从打开网页到把图标贴进代码」的完整动线,保证你 3 分钟就能搞定。


1️⃣ 打开官网

直达地址 → icon-sets.iconify.design/


2️⃣ 搜索图标

  1. 顶部搜索框输入关键词,例如 arrow
  2. 左侧可「按图标集过滤」:常用的是 mdiepcarbonri
  3. 看到中意的图标后点进去,会打开「详情抽屉」

3️⃣ 复制「图标名」

在抽屉里找到 Icon name 这一行,形如
mdi:chevron-right
点击右侧复制按钮即可——这就是你在代码里唯一需要填的字符串

ps:网站里面不好搜索的,不好找的图标 可以去:Yesicon - 精选全球高品质、开源、免费的矢量图标库 选中你想要的icon Vue 自动就会有 搭配 @iconify/vue 的哦,svg 、png 就更多啦~~

③ 最简裸用

<template>
  <Icon icon="mdi:home" class="text-2xl text-sky-600" />
</template>

<script setup>
import { Icon } from '@iconify/vue'
</script>

3. 自封装 <SvgIcon> 组件(双版本)

🔷 TS 版(已自用)

<script setup lang="ts">
import { computed, useAttrs } from 'vue'
import { Icon } from '@iconify/vue'

interface Props { icon?: string }
defineProps<Props>()

const attrs = useAttrs()
const bindAttrs = computed(() => ({
  class: (attrs.class as string) || '',
  style: (attrs.style as string) || '',
}))
</script>

<template>
  <Icon :icon="icon" v-bind="bindAttrs" />
</template>

🔶 JS 版(无类型,直接抄)

<script setup>
import { useAttrs, computed } from 'vue'
import { Icon } from '@iconify/vue'

defineProps({ icon: String })
const attrs = useAttrs()
const bindAttrs = computed(() => ({
  class: attrs.class || '',
  style: attrs.style || '',
}))
</script>

<template>
  <Icon :icon="icon" v-bind="bindAttrs" />
</template>

4. 实战调用

<template>
  <!-- 知识库入口 -->
  <SvgIcon icon="garden:knowledge-base-26" class="text-2xl flex-1" />

  <!-- 带旋转动画 -->
  <SvgIcon icon="mdi:loading" class="animate-spin text-xl" />

  <!-- 行内按钮 -->
  <button class="btn">
    <SvgIcon icon="mdi:plus" /> 新建
  </button>
</template>

5. 踩坑小抄

  1. 图标不显示?
    检查图标名大小写 & 冒号:mdi:homeMdi:Home

  2. 离线模式
    生产内网无法访问 api.iconify.design 时,把所需图标提前打包:

    pnpm i @iconify-json/mdi
    

    然后在 main.js 注册:

    import { addCollection } from '@iconify/vue'
    import mdi from '@iconify-json/mdi/icons.json'
    addCollection(mdi)
    
  3. 颜色/尺寸
    Iconify 默认 width: 1em; height: 1em; 随字体走,直接改 text-*text-2xl 即可。

  4. SSR 白屏
    Nuxt3 记得加 @iconify/vuebuild.transpile



6. 结语

现在新建 .vue 文件,我第一件事就是敲 <SvgIcon icon="" />,图标焦虑彻底治好了。
如果你也有私藏图标小技巧,评论区互相种草,下次见 👋

前端开发 8 个非常实用小技巧:高效解决日常开发痛点

前端开发 8 个实用小技巧:高效解决日常开发痛点

在前端开发过程中,我们经常会遇到一些看似简单却耗时的问题,比如处理表单数据、优化交互体验、解决兼容性 bug 等。很多时候,掌握一些冷门但实用的小技巧,能让复杂问题迎刃而解,还能让代码更简洁、性能更优。本文整理了 8 个覆盖日常开发高频场景的小技巧,从 JavaScript 语法到 CSS 样式,再到实际业务场景处理,每个技巧都附带可直接复用的代码示例。

一、JavaScript:简化代码,提升效率

1. 快速判断数组是否包含指定元素(不止 includes)

判断数组是否包含某个元素,除了常用的includes,还有someevery,适用于不同场景,尤其在处理对象数组时更灵活。

const arr = [1, 2, 3, { id: 4, name: "test" }];
// 1. 基础值判断:includes(推荐,简洁)
const hasNum2 = arr.includes(2); // true
const hasNum5 = arr.includes(5); // false
// 2. 对象属性判断:some(判断是否存在满足条件的元素)
const hasTargetObj = arr.some((item) => item.id === 4); // true
const hasNameTest = arr.some((item) => item.name === "test"); // true
// 3. 全量判断:every(判断所有元素是否都满足条件)
const allNumLessThan5 = arr.every(
  (item) => typeof item === "number" && item < 5
); // false(因存在对象元素)
const allItemValid = arr.every((item) => item !== undefined && item !== null); // true

2. 优雅处理对象默认值(替代层层判断)

当获取对象深层属性时,常因属性不存在导致Cannot read property 'xxx' of undefined错误。使用可选链操作符(?.)+空值合并运算符(??),可一行代码处理默认值,无需层层判断。

// 常规写法:层层判断,繁琐

const user = { name: "张三", address: { city: "北京" } };
const district =
  user.address && user.address.district ? user.address.district : "未知区域"; // 未知区域
// 优化写法:可选链+空值合并,简洁
const districtOpt = user.address?.district ?? "未知区域"; // 未知区域
const street = user.address?.street ?? "未填写街道"; // 未填写街道
const age = user.age ?? 18; // 18(若user.age为0或false,仍会返回实际值,区别于||)

3. 快速交换两个变量的值(无需临时变量)

交换两个变量的值,无需定义临时变量,利用数组解构或算术运算即可实现,代码更简洁。

// 1. 数组解构(推荐,支持任意数据类型)
let a = 10,
  b = 20;
[a, b] = [b, a];
console.log(a, b); // 20 10

// 2. 算术运算(仅适用于数字类型)
let x = 5,
  y = 8;
x = x + y; // x=13
y = x - y; // y=5(13-8)
x = x - y; // x=8(13-5)
console.log(x, y); // 8 5

二、CSS:优化样式,解决兼容问题

4. 实现元素 “垂直居中” 的 3 种高效方式

垂直居中是前端高频需求,不同场景适合不同方案,以下 3 种方式覆盖大多数情况,且兼容性良好。

/* 场景1:已知子元素高度(固定高度) */
.parent1 {
  position: relative;
  height: 300px;
  border: 1px solid #eee;
}

.child1 {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 200px;
  height: 100px;
  margin-top: -50px; /* 自身高度的一半 */
  margin-left: -100px; /* 自身宽度的一半 */
  background: #4285f4;
}

/* 场景2:未知子元素高度(通用方案) */
.parent2 {
  display: flex;
  justify-content: center; /* 水平居中 */
  align-items: center; /* 垂直居中 */
  height: 300px;
  border: 1px solid #eee;
}

.child2 {
  background: #ea4335;
  padding: 20px;
}

/* 场景3:文本垂直居中(单行/多行通用) */
.text-container {
  display: flex;
  align-items: center;
  height: 100px;
  border: 1px solid #eee;
}

.text-content {
  line-height: 1.5; /* 多行文本需重置line-height,避免行高过大 */
}

5. 用 “CSS 渐变” 替代简单背景图(减少请求)

简单的背景色过渡、条纹效果,无需切图,用 CSS 渐变即可实现,减少 HTTP 请求,提升页面加载速度。

/* 1. 线性渐变:从左到右的颜色过渡 */
.gradient-btn {
  background: linear-gradient(to right, #4285f4, #ea4335);
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
}

/* 2. 重复线性渐变:条纹背景 */
.striped-bg {
  background: repeating-linear-gradient(
    45deg,
    #f5f5f5,
    #f5f5f5 10px,
    #eee 10px,
    #eee 20px
  );
  height: 200px;
  width: 100%;
}

/* 3. 径向渐变:圆形渐变背景 */
.circle-gradient {
  background: radial-gradient(circle, #4285f4 0%, #3367d6 100%);
  width: 100px;
  height: 100px;
  border-radius: 50%;
}

6. 解决 “iOS 端输入框聚焦时页面上移” 问题

iOS Safari 浏览器中,输入框(input/textarea)聚焦时,页面常会莫名上移且失焦后不回落,影响用户体验。通过监听输入框焦点事件,可强制页面回正。

// 解决iOS输入框聚焦后页面上移问题
const inputs = document.querySelectorAll("input, textarea");
inputs.forEach((input) => {
  // 失焦时强制页面滚动到顶部(或指定位置)
  input.addEventListener("blur", () => {
    window.scrollTo({
      top: 0,
      left: 0,
      behavior: "smooth", // 平滑滚动,可选
    });
  });

  // 聚焦时也可微调位置(根据需求可选)
  input.addEventListener("focus", () => {
    setTimeout(() => {
      input.scrollIntoViewIfNeeded(false); // false表示不居中,避免页面偏移
    }, 100);
  });
});

三、业务场景:高效处理实际需求

7. 表单 “防抖提交”:避免重复点击提交

表单提交时,用户可能因网络延迟重复点击提交按钮,导致多次请求。用防抖函数可限制一定时间内只能提交一次,且不影响正常提交体验。

// 1. 防抖函数(可复用)
function debounce(func, delay = 1000) {
  let timer = null;
  return function (...args) {
    if (timer) clearTimeout(timer);
    // 延迟delay毫秒执行,期间重复点击会重置定时器
    timer = setTimeout(() => {
      func.apply(this, args);
      timer = null;
    }, delay);
  };
}

// 2. 表单提交函数
function submitForm() {
  const formData = {
    username: document.getElementById("username").value,
    password: document.getElementById("password").value,
  };

  // 模拟接口请求
  console.log("提交表单数据:", formData);
  // axios.post('/api/login', formData).then(res => { ... });
}

// 3. 给提交按钮绑定防抖后的提交事件
const submitBtn = document.getElementById("submit-btn");
submitBtn.addEventListener("click", debounce(submitForm, 1500)); // 1.5秒内只能提交一次

8. 快速实现 “点击空白处关闭弹窗” 功能

弹窗组件是前端常用组件,“点击空白处关闭弹窗” 是核心交互之一。通过判断点击事件的目标元素是否在弹窗内部,可轻松实现该功能。

<!-- 弹窗HTML结构 -->
<div class="modal" id="modal">
  <div class="modal-content">
    <h3>弹窗标题</h3>
    <p>弹窗内容...</p>
    <button class="close-btn" id="closeBtn">关闭</button>
  </div>
</div>
<button id="openBtn">打开弹窗</button>
/* 弹窗样式 */
.modal {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  display: none; /* 默认隐藏 */
  justify-content: center;
  align-items: center;
}

.modal.show {
  display: flex; /* 显示弹窗 */
}

.modal-content {
  width: 300px;
  background: white;
  padding: 20px;
  border-radius: 4px;
  position: relative;
}

.close-btn {
  margin-top: 10px;
  padding: 6px 12px;
  background: #ea4335;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
// 弹窗控制逻辑
const modal = document.getElementById("modal");
const openBtn = document.getElementById("openBtn");
const closeBtn = document.getElementById("closeBtn");
const modalContent = document.querySelector(".modal-content");

// 打开弹窗
openBtn.addEventListener("click", () => {
  modal.classList.add("show");
});

// 关闭弹窗(通用函数)
function closeModal() {
  modal.classList.remove("show");
}

// 点击关闭按钮关闭弹窗
closeBtn.addEventListener("click", closeModal);

// 点击空白处关闭弹窗
modal.addEventListener("click", (e) => {
  // 判断点击的是弹窗背景(modal),而非弹窗内容(modalContent)
  if (e.target === modal) {
    closeModal();
  }
});

总结:小技巧带来大提升

以上 8 个小技巧,覆盖了前端开发中 JavaScript 语法优化、CSS 样式实现、业务场景处理等多个维度,它们的共同特点是:

  • 简洁高效:用更少的代码解决问题,减少冗余逻辑;

  • 实用性强:聚焦日常开发高频痛点,学完即可应用;

  • 兼容性好:避免使用过于前沿的 API,确保在主流浏览器中正常运行。

前端开发的核心是 “高效解决问题”,这些小技巧看似简单,却能在实际开发中帮你节省大量时间,避免不必要的踩坑。建议将这些技巧融入日常开发习惯,同时也可以根据实际需求灵活调整,举一反三,解决更多复杂场景的问题。

前端img与background-image渲染图片对H5页面性能的影响

一、背景

最近在前端性能优化中,业务线H5总是在97.5%指标上下波动,查其原因是页面内大量使用了img元素作为装饰,通过相关系统分析,图片性能达标率波动影响最大。

二、问题分析

在固定的环境中(同一应用、相同的cnd图片地址、以vu访问量60w为基准)使用img出现单图访问错误率约为0.07%,因为对同一cdn的一批图片地址,出错率基本接近,开始猜测是cdn问题造成的,为了更准确的定位到问题,还是先从前端的角度做了分析,在出问题的img地址中没有发现background-image使用的,大胆猜测,是不是background-image引用的图片不会出错,于是在页面内拿几个指定图片,由img方式改为background-image方式渲染。 经过几天观察,确实指定的几个图片不在出现在错误列表中,出错率直接为0。

三、深入调研

于是对img和background-image做了深入的分析汇总:

1. 加载顺序与首屏渲染

  • img标签:在HTML解析时直接触发图片下载,优先加载内容相关图片,对Largest Contentful Paint(LCP) 指标更友好。例如,产品图、文章配图等语义化内容会随HTML同步加载,减少视觉闪烁。
  • background图片:通过CSS加载,需等待CSS解析完成后才下载图片。若背景图在外部CSS文件中,可能延迟首屏渲染(如全屏背景图可能导致LCP超时)。解决方案包括内联关键CSS、使用<link rel="preload">预加载背景图,或通过媒体查询按需加载。

2. 缓存与资源复用

  • img标签:每个图片独立请求,但浏览器缓存可复用相同URL的图片。若多图场景未优化(如未启用HTTP/2多路复用),可能增加请求数。
  • background图片:支持CSS雪碧图(Sprite)合并小图标,减少HTTP请求;通过background-sizemedia-query实现响应式适配,但重复使用大图可能增加内存占用。

3. 响应式与适应性

  • img标签:通过srcsetsizes属性实现设备适配(如高分辨率屏加载2x/3x图),配合<picture>元素实现艺术指导(Art Direction)。例如:

    html
    <picture>
    
      <source media="(min-width: 768px)" srcset="desktop.jpg 2x, mobile.jpg 1x">
    
      <img src="default.jpg" alt="示例">
    
    </picture>
    
  • background图片:需通过媒体查询或不同CSS类切换图片,代码量较大。例如:

    css
    .hero {
    
      background-image: url("mobile.jpg");
    
    }
    
    @media (min-width: 768px) {
    
      .hero {
    
        background-image: url("desktop.jpg");
    
      }
    
    }
    

4. 懒加载与性能优化

  • img标签:支持原生懒加载(loading="lazy"),浏览器自动延迟加载视口外图片,减少初始负载。
  • background图片:需通过JavaScript(如Intersection Observer)或内联样式实现懒加载,复杂度较高。工具如FlyingPress可自动检测并优化内联背景图的加载。

5. 渲染性能与动画

  • img标签:作为替换元素(Replace Element),浏览器直接渲染图片内容,动画性能更优。测试显示,大量img元素的360°旋转动画比background更流畅(因background涉及样式重计算)。
  • background图片:动画可能触发重排(Reflow)和重绘(Repaint),尤其在使用background-positionbackground-size时。CSS硬件加速(如transform: translateZ(0))可缓解此问题。

6. 可访问性与SEO

  • img标签:支持alt属性,对SEO和屏幕阅读器友好,适合内容相关图片(如产品图、信息图表)。
  • background图片:无语义化支持,不适合承载关键内容,但可用于装饰性元素(如渐变、纹理)。

最佳实践建议

  • 内容图片:优先使用img标签,结合srcsetsizes和懒加载优化性能,确保语义化和可访问性。
  • 装饰性图片:使用background属性,配合雪碧图、媒体查询和预加载提升效率。
  • 关键路径优化:将首屏背景图内联或预加载,避免阻塞LCP;非首屏图片使用懒加载。
  • 格式与压缩:统一采用现代格式(如WebP/AVIF),并通过工具(如Squoosh)压缩图片,减少文件体积。

四、解决问题

以此为准继续做第二次实验,将所有的装饰图片换为background-image; 经过几天观察,替换调用img作为装饰的图片,没有再出现访问出错,图片的正确率从原来的97-98% 提升稳定到到99%以上。公司要求的综合性能指标97.5%,此方面的优化提升,直接将综合性能指标由原来的97.5%上下波动到稳定到98%以上。

五、结语

个人是不习惯用img作为装饰元素来使用的,但是不同协同开发者,习惯不一样,在代码review中没有重视这种问题,也忽略了这种写法的累积对页面性能的影响。以此记录共大家参考,避免在同一个坑中崴脚。

欢迎大家共同探讨!

「周更第2期」实用JS库推荐:Rsbuild

引言

大家好,欢迎来到第2期的JavaScript库推荐!本期为大家介绍的是 Rsbuild,一个基于 Rspack 的高性能 Web 构建工具,提供开箱即用的构建配置和卓越的开发体验。

在现代前端开发中,我们经常遇到构建速度慢、配置复杂、开发体验差等痛点。传统的构建工具如 Webpack 虽然功能强大,但往往存在配置繁琐、构建速度慢、学习成本高等问题。Rsbuild 正是为了解决这些痛点而生的,它以其开箱即用的配置、5-10倍的构建性能提升、优秀的生态兼容性在构建工具中脱颖而出,成为了现代前端项目的首选构建方案。

本文将从 Rsbuild 的核心特性、实际应用、性能表现、最佳实践等多个维度进行深入分析,帮助你全面了解这个优秀的构建工具。

库介绍

基本信息

主要特性

  • 🚀 极致性能:基于 Rust 构建的 Rspack,提供 5-10 倍的构建速度提升
  • 💡 开箱即用:零配置启动项目,内置精心设计的默认构建配置
  • 🔧 生态兼容:兼容大多数 webpack 插件和所有 Rspack 插件
  • 📱 框架无关:支持 Vue、React、Svelte、Solid 等多种前端框架
  • 🛠️ 丰富功能:内置 TypeScript、JSX、CSS 预处理器、模块联邦等支持
  • 🔒 稳定可靠:开发和生产环境高度一致,自动语法降级和 polyfill 注入

兼容性

  • 浏览器支持:现代浏览器,可兼容至 IE11(通过配置)
  • Node.js支持:Node.js 18.12.0 或更高版本
  • 框架兼容:Vue 3、Vue 2、React、Svelte、Solid、Lit、Preact 等
  • TypeScript支持:原生支持 TypeScript,无需额外配置

安装使用

安装方式

# 创建新项目(推荐)
npm create rsbuild@latest

# 手动安装到现有项目
npm install @rsbuild/core -D

# 使用 yarn
yarn create rsbuild

# 使用 pnpm
pnpm create rsbuild@latest

基础使用

1. 创建项目

# 创建新项目
npm create rsbuild@latest my-app
cd my-app

# 安装依赖
npm install

# 启动开发服务器
npm run dev

2. 基础示例

// rsbuild.config.js - 基础配置文件
import { defineConfig } from '@rsbuild/core';

export default defineConfig({
  // 入口文件配置
  source: {
    entry: {
      index: './src/index.js',
    },
  },
  // 输出配置
  output: {
    distPath: {
      root: 'dist',
    },
  },
});

3. 配置选项

// 完整配置示例
import { defineConfig } from '@rsbuild/core';
import { pluginVue } from '@rsbuild/plugin-vue';

export default defineConfig({
  plugins: [pluginVue()], // 插件配置
  
  // 开发服务器配置
  dev: {
    hmr: true,        // 热模块替换
    liveReload: true, // 实时重载
  },
  
  // 路径解析配置
  resolve: {
    alias: {
      '@': './src',     // 路径别名
      '@components': './src/components',
    },
  },
  
  // 性能优化配置
  performance: {
    chunkSplit: {
      strategy: 'split-by-experience', // 代码分割策略
    },
  },
});

实际应用

应用场景1:Vue 3.0 项目构建

在 Vue 3.0 项目开发中,我们可以使用 Rsbuild 来获得极致的构建性能和开发体验。

// rsbuild.config.js - Vue 3.0 项目配置
import { defineConfig } from '@rsbuild/core';
import { pluginVue } from '@rsbuild/plugin-vue';
import { pluginSass } from '@rsbuild/plugin-sass';

export default defineConfig({
  plugins: [
    pluginVue({
      // Vue 特定配置
      vueJsxOptions: {
        mergeProps: false,
      },
    }),
    pluginSass(), // Sass 支持
  ],
  
  source: {
    entry: {
      index: './src/main.ts',
    },
  },
  
  html: {
    template: './public/index.html',
    title: 'My Vue App',
  },
  
  // 开发环境配置
  dev: {
    port: 3000,
    open: true, // 自动打开浏览器
  },
});

应用场景2:多页面应用构建

// 多页面应用配置
import { defineConfig } from '@rsbuild/core';

export default defineConfig({
  source: {
    entry: {
      // 多个入口点
      home: './src/pages/home/index.js',
      about: './src/pages/about/index.js',
      contact: './src/pages/contact/index.js',
    },
  },
  
  html: {
    template: './src/template.html',
  },
  
  output: {
    // 输出文件名配置
    filename: {
      js: '[name].[contenthash:8].js',
      css: '[name].[contenthash:8].css',
    },
  },
});

优缺点分析

优点 ✅

  • 极致性能:基于 Rust 的 Rspack 提供 5-10 倍构建速度提升,显著改善开发体验
  • 零配置启动:开箱即用的配置,新手友好,大幅降低学习成本
  • 生态兼容性强:兼容大多数 webpack 插件,迁移成本低
  • 框架无关设计:支持多种前端框架,不绑定特定技术栈
  • 稳定可靠:开发和生产环境一致性高,自动处理兼容性问题
  • 现代化工具链:集成 SWC、Lightning CSS 等高性能工具

缺点 ❌

  • 相对较新:作为新兴工具,社区生态还在发展中,部分插件可能不够成熟
  • 学习资源有限:相比 Webpack,教程和学习资源相对较少
  • 某些高级特性:部分 webpack 的高级特性可能还未完全支持

最佳实践

开发建议

1. 性能优化技巧

// 性能优化配置
export default defineConfig({
  performance: {
    // 代码分割优化
    chunkSplit: {
      strategy: 'split-by-experience',
      override: {
        chunks: {
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendor',
            chunks: 'all',
          },
        },
      },
    },
    
    // 预加载配置
    preload: {
      type: 'all-chunks',
    },
    
    // 预获取配置
    prefetch: {
      type: 'async-chunks',
    },
  },
  
  // 输出优化
  output: {
    polyfill: 'entry', // Polyfill 策略
    cleanDistPath: true, // 清理输出目录
  },
});

2. 错误处理策略

// 开发环境错误处理
export default defineConfig({
  dev: {
    // 错误覆盖层
    client: {
      overlay: {
        errors: true,
        warnings: false,
      },
    },
  },
  
  // 构建错误处理
  tools: {
    rspack: {
      stats: {
        errors: true,
        warnings: true,
        errorDetails: true,
      },
    },
  },
});

3. 内存管理

// 大型项目内存优化
export default defineConfig({
  tools: {
    rspack: {
      // 内存优化配置
      optimization: {
        splitChunks: {
          chunks: 'all',
          maxSize: 244 * 1024, // 244KB
        },
      },
    },
  },
});

常见陷阱

  • ⚠️ 插件兼容性:使用 webpack 插件时需要验证兼容性,建议优先使用 Rsbuild 官方插件
  • ⚠️ 路径配置:注意相对路径和绝对路径的使用,建议使用路径别名
  • ⚠️ 环境变量:确保正确配置环境变量,避免开发和生产环境不一致

进阶用法

高级特性

1. 模块联邦

// 模块联邦配置
import { defineConfig } from '@rsbuild/core';
import { pluginModuleFederation } from '@rsbuild/plugin-module-federation';

export default defineConfig({
  plugins: [
    pluginModuleFederation({
      name: 'host',
      remotes: {
        remote: 'remote@http://localhost:3001/remoteEntry.js',
      },
    }),
  ],
});

2. 自定义插件开发

// 自定义插件示例
const customPlugin = () => ({
  name: 'custom-plugin',
  setup(api) {
    api.onBeforeBuild(() => {
      console.log('构建开始前的自定义逻辑');
    });
    
    api.onAfterBuild(() => {
      console.log('构建完成后的自定义逻辑');
    });
  },
});

export default defineConfig({
  plugins: [customPlugin()],
});

自定义扩展

// 扩展 Rspack 配置
export default defineConfig({
  tools: {
    rspack: (config, { env }) => {
      if (env === 'development') {
        // 开发环境特定配置
        config.devtool = 'eval-cheap-module-source-map';
      }
      
      // 添加自定义 loader
      config.module.rules.push({
        test: /\.custom$/,
        use: 'custom-loader',
      });
      
      return config;
    },
  },
});

工具集成

  • 构建工具:与 Vite、Webpack 项目迁移指南
  • 测试框架:支持 Jest、Vitest 等测试框架
  • 开发工具:VS Code 插件、Chrome DevTools 支持

故障排除

常见问题

Q1: 构建速度没有明显提升

问题描述:从 Webpack 迁移后构建速度提升不明显

解决方案

// 检查配置优化
export default defineConfig({
  // 确保启用 SWC
  tools: {
    swc: {
      jsc: {
        parser: {
          syntax: 'typescript',
          tsx: true,
        },
      },
    },
  },
  
  // 优化代码分割
  performance: {
    chunkSplit: {
      strategy: 'split-by-experience',
    },
  },
});

Q2: 热更新不工作

问题描述:开发环境下代码修改后页面不自动刷新

解决方案

// 检查 HMR 配置
export default defineConfig({
  dev: {
    hmr: true,
    liveReload: true,
    client: {
      host: 'localhost',
      port: 3000,
    },
  },
});

调试技巧

// 开启调试模式
export default defineConfig({
  dev: {
    // 显示构建进度
    progressBar: true,
  },
  
  tools: {
    rspack: {
      // 详细的构建信息
      stats: 'verbose',
    },
  },
});

性能问题诊断

  • 检查点1:确认 Node.js 版本是否满足要求(18.12.0+)
  • 检查点2:检查是否正确配置了代码分割和缓存策略
  • 检查点3:分析 bundle 大小,移除不必要的依赖

总结

Rsbuild 是一个革命性的现代前端构建工具,特别适合追求高性能和优秀开发体验的项目。它的开箱即用配置、极致构建性能、强大的生态兼容性使其在构建工具领域中表现出色。

推荐指数:⭐⭐⭐⭐⭐ (5/5)

适合人群

  • ✅ 希望提升构建性能的前端开发者
  • ✅ 需要快速启动项目的团队
  • ✅ 从 Webpack 迁移的项目
  • ✅ 追求现代化工具链的开发者

不适合场景

  • ❌ 需要使用大量特定 webpack 插件的项目
  • ❌ 对构建工具稳定性要求极高的生产环境(建议等待更多版本迭代)

学习建议

  1. 入门阶段:从官方模板开始,体验零配置的开发体验
  2. 进阶阶段:学习插件系统和自定义配置,掌握性能优化技巧
  3. 实战应用:在实际项目中应用,对比构建性能提升效果

附一份完整的配置文件

import { defineConfig } from '@rsbuild/core';
import { pluginVue } from '@rsbuild/plugin-vue';
import { pluginTypeCheck } from '@rsbuild/plugin-type-check';
import { pluginLess } from '@rsbuild/plugin-less';
import { pluginEslint } from '@rsbuild/plugin-eslint';
import { pluginImageCompress } from '@rsbuild/plugin-image-compress';
import { pluginModuleFederation } from '@rsbuild/plugin-module-federation';
import path from 'path';

export default defineConfig({
  // ==================== 插件配置 ====================
  plugins: [
    // Vue 3.0 支持插件
    pluginVue({
      // Vue Loader 配置选项
      vueLoaderOptions: {
        compilerOptions: {
          // 保留空白字符设置
          preserveWhitespace: false,
          // 自定义元素处理
          isCustomElement: (tag) => tag.startsWith('custom-'),
        },
        // 实验性内联匹配资源
        experimentalInlineMatchResource: true,
      },
      // 代码分割配置
      splitChunks: {
        vue: true,
        router: true,
      },
    }),

    // TypeScript 类型检查插件
    pluginTypeCheck({
      // 启用 fork 模式,提升构建性能
      fork: true,
      // TypeScript 配置文件路径
      typescript: {
        configFile: './tsconfig.json',
        // 构建时进行类型检查
        build: true,
      },
    }),

    // Less 预处理器插件
    pluginLess({
      // Less 编译选项
      lessOptions: {
        // 启用内联 JavaScript
        javascriptEnabled: true,
        // 数学计算模式
        math: 'always',
        // 全局变量文件
        globalVars: {
          '@primary-color': '#1890ff',
          '@success-color': '#52c41a',
          '@warning-color': '#faad14',
          '@error-color': '#f5222d',
        },
      },
      // 自动注入全局样式文件
      additionalData: `@import "@/styles/variables.less";`,
    }),

    // ESLint 代码检查插件
    pluginEslint({
      // ESLint 配置文件路径
      eslintPath: './.eslintrc.js',
      // 包含的文件扩展名
      extensions: ['js', 'jsx', 'ts', 'tsx', 'vue'],
      // 排除的目录
      exclude: ['node_modules', 'dist'],
      // 开发环境启用
      enable: process.env.NODE_ENV === 'development',
    }),

    // 图片压缩插件
    pluginImageCompress({
      // 压缩配置
      compress: {
        jpg: {
          quality: 80,
        },
        png: {
          quality: 80,
        },
        webp: {
          quality: 80,
        },
      },
    }),

    // 模块联邦插件(微前端支持)
    pluginModuleFederation({
      name: 'vue_app',
      filename: 'remoteEntry.js',
      exposes: {
        './App': './src/App.vue',
        './components': './src/components/index.ts',
      },
      shared: {
        vue: {
          singleton: true,
          requiredVersion: '^3.3.0',
        },
        'vue-router': {
          singleton: true,
          requiredVersion: '^4.2.0',
        },
        pinia: {
          singleton: true,
          requiredVersion: '^2.1.0',
        },
      },
    }),
  ],

  // ==================== 源码配置 ====================
  source: {
    // 入口文件配置
    entry: {
      // 主应用入口
      index: './src/main.ts',
      // 管理后台入口(多页面应用示例)
      admin: './src/admin/main.ts',
    },

    // 路径别名配置
    alias: {
      '@': path.resolve(__dirname, './src'),
      '@components': path.resolve(__dirname, './src/components'),
      '@views': path.resolve(__dirname, './src/views'),
      '@utils': path.resolve(__dirname, './src/utils'),
      '@api': path.resolve(__dirname, './src/api'),
      '@store': path.resolve(__dirname, './src/store'),
      '@assets': path.resolve(__dirname, './src/assets'),
      '@styles': path.resolve(__dirname, './src/styles'),
    },

    // 包含的文件目录
    include: [
      path.resolve(__dirname, './src'),
      path.resolve(__dirname, './packages'),
    ],

    // 排除的文件目录
    exclude: [
      /node_modules/,
      /dist/,
      /coverage/,
      /\.temp/,
    ],

    // 预编译依赖
    transformImport: [
      {
        // Element Plus 按需导入
        libraryName: 'element-plus',
        libraryDirectory: 'es',
        style: 'css',
      },
    ],

    // 全局变量定义
    define: {
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
      'process.env.API_BASE_URL': JSON.stringify(
        process.env.NODE_ENV === 'production' 
          ? 'https://api.example.com'
          : 'http://localhost:8080'
      ),
      '__DEV__': process.env.NODE_ENV === 'development',
    },
  },

  // ==================== HTML 配置 ====================
  html: {
    // HTML 模板文件
    template: './public/index.html',
    // 页面标题
    title: 'Vue 3.0 + Rsbuild 项目',
    // 网站图标
    favicon: './public/favicon.ico',
    // 注入到 HTML 的标签
    tags: [
      {
        tag: 'meta',
        attrs: {
          name: 'description',
          content: '基于 Vue 3.0 和 Rsbuild 构建的现代前端应用',
        },
      },
      {
        tag: 'meta',
        attrs: {
          name: 'keywords',
          content: 'Vue3, Rsbuild, TypeScript, Vite, 前端开发',
        },
      },
    ],
    // 模板参数
    templateParameters: {
      NODE_ENV: process.env.NODE_ENV,
      BUILD_TIME: new Date().toISOString(),
    },
  },

  // ==================== 输出配置 ====================
  output: {
    // 输出目录配置
    distPath: {
      root: 'dist',
      js: 'static/js',
      css: 'static/css',
      svg: 'static/svg',
      font: 'static/font',
      image: 'static/image',
      media: 'static/media',
      html: '.',
    },

    // 文件名配置
    filename: {
      js: '[name].[contenthash:8].js',
      css: '[name].[contenthash:8].css',
      svg: '[name].[contenthash:8].svg',
      font: '[name].[contenthash:8][ext]',
      image: '[name].[contenthash:8][ext]',
    },

    // 资源内联配置
    dataUriLimit: {
      svg: 10000,
      font: 10000,
      image: 10000,
      media: 10000,
    },

    // 清理输出目录
    cleanDistPath: true,

    // 复制静态资源
    copy: [
      {
        from: './public',
        to: './',
        globOptions: {
          ignore: ['**/index.html'],
        },
      },
    ],

    // 外部化依赖(CDN 引入)
    externals: {
      // 生产环境使用 CDN
      ...(process.env.NODE_ENV === 'production' && {
        vue: 'Vue',
        'vue-router': 'VueRouter',
        axios: 'axios',
      }),
    },
  },

  // ==================== 开发服务器配置 ====================
  dev: {
    // 热模块替换
    hmr: true,
    // 实时重载
    liveReload: true,
    // 开发中间件配置
    setupMiddlewares: [
      (middlewares) => {
        // 自定义中间件示例
        middlewares.unshift((req, res, next) => {
          if (req.url === '/health') {
            res.writeHead(200, { 'Content-Type': 'application/json' });
            res.end(JSON.stringify({ status: 'ok', timestamp: Date.now() }));
            return;
          }
          next();
        });
        return middlewares;
      },
    ],
  },

  // ==================== 服务器配置 ====================
  server: {
    // 服务器端口
    port: 3000,
    // 自动打开浏览器
    open: true,
    // 启用 HTTPS
    https: false,
    // 主机地址
    host: 'localhost',
    // 代理配置
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        pathRewrite: {
          '^/api': '',
        },
      },
      '/upload': {
        target: 'http://localhost:8081',
        changeOrigin: true,
      },
    },
    // HTML 回退配置
    htmlFallback: 'index',
  },

  // ==================== 构建优化配置 ====================
  performance: {
    // 代码分割策略
    chunkSplit: {
      strategy: 'split-by-experience',
      override: {
        chunks: {
          // 第三方库单独打包
          vendor: {
            test: /[\\\\/]node_modules[\\\\/]/,
            name: 'vendor',
            chunks: 'all',
          },
          // Vue 相关库单独打包
          vue: {
            test: /[\\\\/]node_modules[\\\\/](vue|vue-router|pinia)/,
            name: 'vue-vendor',
            chunks: 'all',
          },
          // UI 组件库单独打包
          ui: {
            test: /[\\\\/]node_modules[\\\\/]element-plus/,
            name: 'ui-vendor',
            chunks: 'all',
          },
        },
      },
    },

    // 预加载配置
    preload: {
      type: 'all-chunks',
      include: ['initial'],
    },

    // 预获取配置
    prefetch: {
      type: 'all-chunks',
      include: ['async'],
    },

    // 移除控制台输出
    removeConsole: process.env.NODE_ENV === 'production' ? ['log', 'warn'] : false,

    // 打包分析
    bundleAnalyze: process.env.ANALYZE === 'true',
  },

  // ==================== 工具配置 ====================
  tools: {
    // Rspack 配置扩展
    rspack: (config, { env, isDev, isProd }) => {
      // 开发环境配置
      if (isDev) {
        config.devtool = 'eval-cheap-module-source-map';
      }

      // 生产环境配置
      if (isProd) {
        config.devtool = 'source-map';
        
        // 压缩配置
        config.optimization = {
          ...config.optimization,
          minimize: true,
          sideEffects: false,
        };
      }

      // 自定义 loader
      config.module.rules.push({
        test: /\.md$/,
        use: [
          {
            loader: 'html-loader',
          },
          {
            loader: 'markdown-loader',
          },
        ],
      });

      return config;
    },

    // PostCSS 配置
    postcss: {
      plugins: [
        require('autoprefixer')({
          overrideBrowserslist: [
            '> 1%',
            'last 2 versions',
            'not dead',
            'not ie <= 11',
          ],
        }),
        require('cssnano')({
          preset: 'default',
        }),
      ],
    },

    // Babel 配置
    babel: (config) => {
      config.plugins.push([
        'import',
        {
          libraryName: 'element-plus',
          libraryDirectory: 'es',
          style: 'css',
        },
        'element-plus',
      ]);
      return config;
    },
  },



  // ==================== 安全配置 ====================
  security: {
    // 内容安全策略随机数
    nonce: process.env.NODE_ENV === 'production',
    // 子资源完整性
    sri: {
      enable: process.env.NODE_ENV === 'production',
      algorithm: 'sha384',
    },
  },

  // ==================== 实验性功能 ====================
  experiments: {
    // 启用 CSS 实验性功能
    css: true,
    // 启用懒编译(仅开发环境)
    lazyCompilation: process.env.NODE_ENV === 'development' ? {
      entries: false,
      imports: true,
    } : false,
  },
});

相关资源


如果你觉得这篇文章对你有帮助,欢迎点赞、收藏和分享。如果你有其他想了解的JavaScript库,也欢迎在评论区留言告诉我!


本文是「掘金周更」系列的第2期,每周为大家推荐一个实用的JavaScript第三方库。关注我,不错过每一期精彩内容!

极简三分钟ES6 - 正则表达式的扩展

Unicode 支持:u 修饰符

作用:正确处理复杂的 Unicode 字符(如表情符号 𐐷)

传统问题

ES5 将 4 字节的 Unicode 字符(如 \uD83D\uDC2A)拆成两个字符

/^\uD83D/.test('\uD83D\uDC2A'); // true(错误识别)

u 的改进

添加 u 后,正则将其视为一个整体字符

/^\uD83D/u.test('\uD83D\uDC2A'); // false(正确识别)

其他增强

  • 点字符 . 可匹配 Unicode 字符:/^.$/u.test('🐱') // true
  • 正确计算 Unicode 字符串长度
function length(str) {{
  return str.match(/./gu)?.length  || 0; // 使用 u 修饰符
}}
length('𠮷𠮷') // 2(传统方式返回 4)

粘连匹配:y 修饰符

作用:强制从上一次匹配结束的位置开始匹配,类似“贪吃蛇”步步推进

对比 g 修饰符

  • g:全局匹配,跳过不匹配的位置。
  • y:必须连续匹配,否则失败。

应用场景

语法解析、模板引擎等需要严格连续匹配的场景

具名组匹配:给分组起名字

作用:用名字替代数字编号的分组,代码更易读

传统问题

分组只能通过 [1][2] 引用,顺序变动会导致错误

const date = /(\d{4})-(\d{2})-(\d{2})/.exec('2025-09-05'); console.log(date[1]); // "2025"(但开发者需记住 1 是年份)

ES6 解决方案

使用 (?<组名>) 命名分组,通过 groups 属性访问

const date = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u.exec('2025-09-05'); console.log(date.groups.year); // "2025"(清晰直观)

其他一些实用扩展

s 修饰符:点号匹配所有字符

传统点号 . 不匹配换行符 \ns 修饰符解决此问题

/hello.world/.test('hello\nworld'); // false 
/hello.world/s.test('hello\nworld'); // true

正则属性扩展

  • sticky 属性:检测是否启用 y 修饰符(reg.sticky )
  • flags 属性:返回正则的修饰符字符串(如 "gu"
const reg = /abc/gy;
console.log(reg.flags);  // "gy"

构造函数优化

支持直接复制正则表达式,并可覆盖修饰符

const reg1 = /abc/i;
const reg2 = new RegExp(reg1, 'g'); // 复用 reg1 但改为 g 修饰符 

ES6 正则 vs ES5

特性 ES6 方案 ES5 限制
Unicode 支持 u 修饰符 无法识别 4 字节字符
连续匹配 y 修饰符(粘连匹配) 仅 g(可跳过字符)
分组可读性 具名组 (?<name>) 数字索引(易混乱)
换行符匹配 s 修饰符 点号不匹配 \n
修饰符检测 reg.flags 无直接获取方式

牢记

u 治乱码,y 抓连续,具名分组不迷路,点号加 s 破换行。”

🎨 页面卡得像PPT?浏览器渲染原理告诉你性能瓶颈在哪

🎯 学习目标:深入理解浏览器渲染机制,掌握性能优化的核心技巧,让页面渲染丝滑如德芙

📊 难度等级:中级-高级
🏷️ 技术标签#浏览器渲染 #性能优化 #重排重绘 #渲染流水线
⏱️ 阅读时间:约8分钟


🌟 引言

在日常的前端开发中,你是否遇到过这样的困扰:

  • 页面滚动卡顿:滚动列表时感觉像在看幻灯片,用户体验极差
  • 动画掉帧严重:CSS动画或JS动画执行时页面明显卡顿
  • 首屏渲染缓慢:页面白屏时间过长,用户等得不耐烦
  • 交互响应迟钝:点击按钮后要等很久才有反应

这些问题的根源往往在于我们对浏览器渲染机制的理解不够深入。今天分享5个浏览器渲染原理的核心知识点,让你的页面性能优化有的放矢!


💡 核心技巧详解

1. 渲染流水线全解析:从HTML到像素的完整旅程

🔍 应用场景

理解渲染流水线是性能优化的基础,只有知道浏览器是如何工作的,才能找到性能瓶颈的根源。

❌ 常见问题

很多开发者只知道"重排重绘影响性能",但不知道具体的触发条件和优化方法。

// ❌ 频繁触发重排的写法
const updateElements = () => {
  for (let i = 0; i < 1000; i++) {
    const element = document.getElementById(`item-${i}`);
    element.style.left = `${i * 10}px`; // 每次都触发重排
    element.style.top = `${i * 5}px`;   // 每次都触发重排
  }
};

✅ 推荐方案

理解渲染流水线的5个关键步骤,针对性优化。

/**
 * 批量更新DOM样式,减少重排次数
 * @description 使用DocumentFragment和transform避免频繁重排
 * @param {Array} items - 需要更新的元素数据
 * @returns {void}
 */
const optimizedUpdateElements = (items) => {
  //  使用transform避免重排
  const fragment = document.createDocumentFragment();
  
  items.forEach((item, index) => {
    const element = document.getElementById(`item-${index}`);
    // 使用transform只触发合成,不触发重排
    element.style.transform = `translate3d(${item.x}px, ${item.y}px, 0)`;
    element.style.willChange = 'transform'; // 提升到合成层
  });
};

💡 核心要点

  • 解析阶段:HTML解析成DOM树,CSS解析成CSSOM树
  • 布局阶段:计算元素的几何信息(位置、大小)
  • 绘制阶段:将元素绘制成位图
  • 合成阶段:将多个图层合成最终图像
  • 显示阶段:将合成结果显示到屏幕上

🎯 实际应用

在实际项目中,我们可以通过Chrome DevTools的Performance面板观察渲染流水线。

// 实际项目中的渲染优化
const performanceOptimizedComponent = {
  /**
   * 使用requestAnimationFrame优化动画
   * @description 确保动画在浏览器重绘前执行
   */
  animateElement: () => {
    let start = null;
    
    const animate = (timestamp) => {
      if (!start) start = timestamp;
      const progress = timestamp - start;
      
      // 使用transform而不是改变left/top
      element.style.transform = `translateX(${progress * 0.1}px)`;
      
      if (progress < 2000) {
        requestAnimationFrame(animate);
      }
    };
    
    requestAnimationFrame(animate);
  }
};

2. 重排(Reflow)与重绘(Repaint):性能杀手的真面目

🔍 应用场景

当我们修改DOM元素的样式时,需要知道哪些操作会触发重排和重绘,从而选择性能更好的实现方式。

❌ 常见问题

不了解哪些CSS属性会触发重排,导致性能问题。

// ❌ 频繁触发重排的操作
const badPerformanceCode = () => {
  const element = document.querySelector('.target');
  
  // 这些操作都会触发重排
  element.style.width = '200px';
  element.style.height = '100px';
  element.style.padding = '10px';
  element.style.margin = '5px';
  
  // 读取布局信息也会强制触发重排
  console.log(element.offsetWidth);
  console.log(element.offsetHeight);
};

✅ 推荐方案

批量修改样式,使用只触发合成的CSS属性。

/**
 * 优化的样式更新方法
 * @description 批量更新样式,减少重排次数
 * @param {HTMLElement} element - 目标元素
 * @param {Object} styles - 样式对象
 * @returns {void}
 */
const optimizedStyleUpdate = (element, styles) => {
  //  批量更新样式
  const cssText = Object.entries(styles)
    .map(([key, value]) => `${key}: ${value}`)
    .join('; ');
  
  element.style.cssText = cssText;
};

/**
 * 使用只触发合成的属性
 * @description 优先使用transform和opacity
 */
const useCompositeOnlyProperties = () => {
  const element = document.querySelector('.animate-target');
  
  //  只触发合成,性能最佳
  element.style.transform = 'translateX(100px) scale(1.2)';
  element.style.opacity = '0.8';
  element.style.filter = 'blur(2px)';
};

💡 核心要点

  • 重排触发条件:改变元素几何属性(width、height、position等)
  • 重绘触发条件:改变元素外观属性(color、background等)
  • 合成触发条件:只改变transform、opacity、filter等
  • 强制同步布局:读取布局信息会立即触发重排

🎯 实际应用

在Vue组件中优化列表渲染性能。

<template>
  <div class="optimized-list">
    <div 
      v-for="item in items" 
      :key="item.id"
      class="list-item"
      :style="getItemStyle(item)"
    >
      {{ item.content }}
    </div>
  </div>
</template>

<script>
export default {
  methods: {
    /**
     * 获取列表项样式
     * @description 使用transform避免重排
     * @param {Object} item - 列表项数据
     * @returns {Object} 样式对象
     */
    getItemStyle: (item) => {
      return {
        // 使用transform而不是top/left
        transform: `translateY(${item.index * 50}px)`,
        // 提升到合成层
        willChange: 'transform'
      };
    }
  }
};
</script>

3. 合成层(Composite Layer):GPU加速的秘密武器

🔍 应用场景

当需要执行复杂动画或处理大量元素时,合成层可以利用GPU加速,大幅提升性能。

❌ 常见问题

滥用will-change或不当的层级提升导致内存占用过高。

/* ❌ 滥用will-change */
.every-element {
  will-change: transform; /* 不要给所有元素都加 */
}

.static-element {
  will-change: transform; /* 静态元素不需要 */
}

✅ 推荐方案

合理使用合成层,在需要时提升,用完及时清理。

/**
 * 智能的合成层管理
 * @description 动态管理will-change属性
 * @param {HTMLElement} element - 目标元素
 * @returns {Object} 管理对象
 */
const createCompositeLayerManager = (element) => {
  return {
    /**
     * 开始动画前提升到合成层
     */
    promote: () => {
      element.style.willChange = 'transform';
      element.style.transform = 'translateZ(0)'; // 强制创建层
    },
    
    /**
     * 动画结束后清理
     */
    demote: () => {
      element.style.willChange = 'auto';
      element.style.transform = '';
    },
    
    /**
     * 执行优化的动画
     * @param {Function} animationFn - 动画函数
     */
    animate: (animationFn) => {
      this.promote();
      
      return new Promise((resolve) => {
        animationFn(() => {
          this.demote();
          resolve();
        });
      });
    }
  };
};

💡 核心要点

  • 创建条件:3D变换、opacity动画、position:fixed等
  • GPU加速:合成层在GPU上处理,不占用主线程
  • 内存考虑:每个合成层都占用显存,需要合理管理
  • 层级爆炸:避免创建过多不必要的合成层

🎯 实际应用

在实际项目中实现高性能的无限滚动列表。

/**
 * 高性能无限滚动实现
 * @description 使用合成层优化滚动性能
 */
class OptimizedInfiniteScroll {
  constructor(container, itemHeight = 50) {
    this.container = container;
    this.itemHeight = itemHeight;
    this.visibleItems = new Map();
    this.init();
  }
  
  /**
   * 初始化滚动容器
   */
  init = () => {
    // 提升容器到合成层
    this.container.style.willChange = 'scroll-position';
    this.container.style.transform = 'translateZ(0)';
    
    this.container.addEventListener('scroll', this.handleScroll, {
      passive: true // 被动监听,不阻塞滚动
    });
  };
  
  /**
   * 处理滚动事件
   */
  handleScroll = () => {
    requestAnimationFrame(() => {
      this.updateVisibleItems();
    });
  };
  
  /**
   * 更新可见项目
   */
  updateVisibleItems = () => {
    const scrollTop = this.container.scrollTop;
    const containerHeight = this.container.clientHeight;
    
    const startIndex = Math.floor(scrollTop / this.itemHeight);
    const endIndex = Math.ceil((scrollTop + containerHeight) / this.itemHeight);
    
    // 使用transform定位,避免重排
    for (let i = startIndex; i <= endIndex; i++) {
      const item = this.getOrCreateItem(i);
      item.style.transform = `translateY(${i * this.itemHeight}px)`;
    }
  };
}

4. 关键渲染路径(Critical Rendering Path):首屏优化的核心

🔍 应用场景

优化页面首屏加载时间,提升用户体验,特别是在移动端网络环境较差的情况下。

❌ 常见问题

阻塞渲染的资源过多,导致首屏白屏时间过长。

<!-- ❌ 阻塞渲染的资源 -->
<head>
  <!-- 大量同步CSS -->
  <link rel="stylesheet" href="large-framework.css">
  <link rel="stylesheet" href="icons.css">
  <link rel="stylesheet" href="animations.css">
  
  <!-- 阻塞的JavaScript -->
  <script src="large-library.js"></script>
  <script src="analytics.js"></script>
</head>

✅ 推荐方案

优化资源加载顺序,内联关键CSS,延迟非关键资源。

<!--  优化的资源加载 -->
<head>
  <!-- 内联关键CSS -->
  <style>
    /* 首屏关键样式 */
    .header { height: 60px; background: #fff; }
    .main-content { min-height: 400px; }
  </style>
  
  <!-- 预加载关键资源 -->
  <link rel="preload" href="critical.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
  
  <!-- 异步加载非关键CSS -->
  <link rel="preload" href="non-critical.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
  <noscript><link rel="stylesheet" href="non-critical.css"></noscript>
</head>
/**
 * 关键资源加载优化
 * @description 智能加载策略,优先加载关键资源
 */
class CriticalResourceLoader {
  constructor() {
    this.criticalResources = [];
    this.nonCriticalResources = [];
  }
  
  /**
   * 添加关键资源
   * @param {string} url - 资源URL
   * @param {string} type - 资源类型
   */
  addCriticalResource = (url, type) => {
    this.criticalResources.push({ url, type });
  };
  
  /**
   * 加载关键资源
   * @returns {Promise} 加载完成的Promise
   */
  loadCriticalResources = async () => {
    const promises = this.criticalResources.map(resource => {
      return this.loadResource(resource.url, resource.type);
    });
    
    await Promise.all(promises);
    
    // 关键资源加载完成后,延迟加载非关键资源
    requestIdleCallback(() => {
      this.loadNonCriticalResources();
    });
  };
  
  /**
   * 加载单个资源
   * @param {string} url - 资源URL
   * @param {string} type - 资源类型
   * @returns {Promise} 加载Promise
   */
  loadResource = (url, type) => {
    return new Promise((resolve, reject) => {
      const element = type === 'css' 
        ? document.createElement('link')
        : document.createElement('script');
      
      element.onload = resolve;
      element.onerror = reject;
      
      if (type === 'css') {
        element.rel = 'stylesheet';
        element.href = url;
      } else {
        element.src = url;
        element.async = true;
      }
      
      document.head.appendChild(element);
    });
  };
}

💡 核心要点

  • 关键资源识别:首屏渲染必需的HTML、CSS、JavaScript
  • 资源优先级:关键资源优先加载,非关键资源延迟加载
  • 渲染阻塞:避免CSS和同步JavaScript阻塞渲染
  • 预加载策略:使用preload、prefetch等资源提示

🎯 实际应用

在Vue应用中实现首屏优化。

<template>
  <div class="app">
    <!-- 首屏关键内容 -->
    <header class="header">{{ title }}</header>
    
    <!-- 懒加载非关键组件 -->
    <component 
      :is="lazyComponent" 
      v-if="componentLoaded"
    />
  </div>
</template>

<script>
export default {
  data() {
    return {
      title: '首页',
      componentLoaded: false,
      lazyComponent: null
    };
  },
  
  async mounted() {
    // 首屏渲染完成后加载非关键组件
    await this.$nextTick();
    
    requestIdleCallback(async () => {
      const { default: LazyComponent } = await import('./LazyComponent.vue');
      this.lazyComponent = LazyComponent;
      this.componentLoaded = true;
    });
  }
};
</script>

5. 渲染性能监控:数据驱动的优化策略

🔍 应用场景

在生产环境中监控渲染性能,及时发现和解决性能问题,提供数据支撑的优化方案。

❌ 常见问题

缺乏性能监控,只能凭感觉优化,无法量化优化效果。

// ❌ 没有性能监控的代码
const updateUI = () => {
  // 不知道这个操作的性能影响
  document.querySelectorAll('.item').forEach(item => {
    item.style.transform = 'scale(1.1)';
  });
};

✅ 推荐方案

建立完整的性能监控体系,实时收集和分析性能数据。

/**
 * 渲染性能监控器
 * @description 监控关键渲染指标,提供性能分析数据
 */
class RenderPerformanceMonitor {
  constructor() {
    this.metrics = {
      fps: [],
      renderTime: [],
      layoutTime: [],
      paintTime: []
    };
    
    this.observer = null;
    this.init();
  }
  
  /**
   * 初始化性能监控
   */
  init = () => {
    // 监控FPS
    this.startFPSMonitoring();
    
    // 监控长任务
    this.startLongTaskMonitoring();
    
    // 监控渲染指标
    this.startRenderMetrics();
  };
  
  /**
   * 开始FPS监控
   */
  startFPSMonitoring = () => {
    let lastTime = performance.now();
    let frameCount = 0;
    
    const measureFPS = (currentTime) => {
      frameCount++;
      
      if (currentTime - lastTime >= 1000) {
        const fps = Math.round((frameCount * 1000) / (currentTime - lastTime));
        this.metrics.fps.push(fps);
        
        // 如果FPS低于30,记录性能问题
        if (fps < 30) {
          this.reportPerformanceIssue('low_fps', { fps, timestamp: currentTime });
        }
        
        frameCount = 0;
        lastTime = currentTime;
      }
      
      requestAnimationFrame(measureFPS);
    };
    
    requestAnimationFrame(measureFPS);
  };
  
  /**
   * 监控长任务
   */
  startLongTaskMonitoring = () => {
    if ('PerformanceObserver' in window) {
      this.observer = new PerformanceObserver((list) => {
        list.getEntries().forEach((entry) => {
          if (entry.duration > 50) { // 超过50ms的任务
            this.reportPerformanceIssue('long_task', {
              duration: entry.duration,
              startTime: entry.startTime
            });
          }
        });
      });
      
      this.observer.observe({ entryTypes: ['longtask'] });
    }
  };
  
  /**
   * 测量渲染操作性能
   * @param {string} operationName - 操作名称
   * @param {Function} operation - 操作函数
   * @returns {Promise} 操作结果
   */
  measureRenderOperation = async (operationName, operation) => {
    const startTime = performance.now();
    
    // 执行操作
    const result = await operation();
    
    // 等待渲染完成
    await new Promise(resolve => {
      requestAnimationFrame(() => {
        requestAnimationFrame(resolve);
      });
    });
    
    const endTime = performance.now();
    const duration = endTime - startTime;
    
    this.metrics.renderTime.push({
      operation: operationName,
      duration,
      timestamp: startTime
    });
    
    // 如果渲染时间超过16ms(60fps阈值),记录问题
    if (duration > 16) {
      this.reportPerformanceIssue('slow_render', {
        operation: operationName,
        duration
      });
    }
    
    return result;
  };
  
  /**
   * 报告性能问题
   * @param {string} type - 问题类型
   * @param {Object} data - 问题数据
   */
  reportPerformanceIssue = (type, data) => {
    console.warn(`Performance Issue [${type}]:`, data);
    
    // 发送到监控服务
    if (typeof window.analytics !== 'undefined') {
      window.analytics.track('performance_issue', {
        type,
        ...data,
        userAgent: navigator.userAgent,
        url: window.location.href
      });
    }
  };
  
  /**
   * 获取性能报告
   * @returns {Object} 性能报告
   */
  getPerformanceReport = () => {
    const avgFPS = this.metrics.fps.reduce((a, b) => a + b, 0) / this.metrics.fps.length;
    const avgRenderTime = this.metrics.renderTime.reduce((a, b) => a + b.duration, 0) / this.metrics.renderTime.length;
    
    return {
      averageFPS: Math.round(avgFPS),
      averageRenderTime: Math.round(avgRenderTime * 100) / 100,
      totalMeasurements: this.metrics.renderTime.length,
      performanceGrade: this.calculatePerformanceGrade(avgFPS, avgRenderTime)
    };
  };
  
  /**
   * 计算性能等级
   * @param {number} fps - 平均FPS
   * @param {number} renderTime - 平均渲染时间
   * @returns {string} 性能等级
   */
  calculatePerformanceGrade = (fps, renderTime) => {
    if (fps >= 55 && renderTime <= 10) return 'A';
    if (fps >= 45 && renderTime <= 16) return 'B';
    if (fps >= 30 && renderTime <= 25) return 'C';
    return 'D';
  };
}

💡 核心要点

  • 关键指标:FPS、首屏时间、交互响应时间、长任务
  • 实时监控:使用PerformanceObserver API监控性能
  • 数据收集:收集用户真实的性能数据
  • 问题定位:快速定位性能瓶颈和问题根源

🎯 实际应用

在Vue应用中集成性能监控。

// main.js
import { createApp } from 'vue';
import App from './App.vue';

const app = createApp(App);

// 初始化性能监控
const performanceMonitor = new RenderPerformanceMonitor();

// Vue性能监控插件
app.config.globalProperties.$performanceMonitor = performanceMonitor;

// 监控组件渲染性能
app.mixin({
  async beforeUpdate() {
    if (this.$options.name) {
      await this.$performanceMonitor.measureRenderOperation(
        `${this.$options.name}_update`,
        () => this.$nextTick()
      );
    }
  }
});

app.mount('#app');

📊 技巧对比总结

技巧 使用场景 优势 注意事项
渲染流水线优化 所有页面性能优化 从根本上理解性能瓶颈 需要深入理解浏览器机制
重排重绘优化 动画和交互优化 直接减少渲染开销 需要熟悉触发条件
合成层管理 复杂动画和滚动 GPU加速,性能提升明显 注意内存占用
关键渲染路径 首屏性能优化 显著提升加载速度 需要合理划分资源优先级
性能监控 生产环境优化 数据驱动,问题可追踪 需要建立完整监控体系

🎯 实战应用建议

最佳实践

  1. 渲染流水线优化:理解每个阶段的作用,针对性优化瓶颈环节
  2. 重排重绘控制:批量修改样式,优先使用只触发合成的属性
  3. 合成层管理:动态管理will-change,避免内存浪费
  4. 关键路径优化:内联关键CSS,延迟非关键资源
  5. 性能监控建设:建立完整的监控体系,持续优化

性能考虑

  • 内存管理:合理使用合成层,避免内存泄漏
  • 网络优化:优化资源加载顺序和大小
  • 用户体验:保持60fps的流畅体验
  • 兼容性:考虑不同设备和浏览器的性能差异

💡 总结

这5个浏览器渲染原理的核心知识点在日常开发中至关重要,掌握它们能让你的页面性能优化:

  1. 渲染流水线理解:从根本上认识性能瓶颈,优化有的放矢
  2. 重排重绘控制:减少不必要的渲染开销,提升交互流畅度
  3. 合成层利用:借助GPU加速,实现高性能动画和滚动
  4. 关键路径优化:提升首屏加载速度,改善用户体验
  5. 性能监控体系:数据驱动优化,持续改进性能表现

希望这些技巧能帮助你在前端开发中写出性能更优的代码,让用户享受丝滑的页面体验!


🔗 相关资源


💡 今日收获:掌握了5个浏览器渲染原理的核心知识点,这些知识在性能优化中非常实用。

如果这篇文章对你有帮助,欢迎点赞、收藏和分享!有任何问题也欢迎在评论区讨论。 🚀

组件生命周期

组件生命周期

使用 use_hook 初始化状态

use_hook 允许您为组件创建新状态。传递给 use_hook 的闭包将在组件首次渲染时被调用。每次组件重新渲染时,都会复用初始运行时创建的值。

fn UseHook() -> Element {
    // The closure that is passed to use_hook will be called once the first time the component is rendered
    let random_number = use_hook(|| {
        let new_random_number = random_number();

        log!("{new_random_number}");

        new_random_number
    });

    rsx! {
        div { "Random {random_number}" }
    }
}

重新渲染

当某个值发生变化时,您可以使用追踪值来重新渲染组件。

fn Rerenders() -> Element {
    let mut count = use_signal(|| 0);

    log!("Rerendering parent component with {}", *count.peek());

    rsx! {
        button { onclick: move |_| count += 1, "Increment" }
        // Since we read count here, the component will rerender when count changes
        Count { current_count: count() }
    }
}

// If the count prop changes, the component will rerender
#[component]
fn Count(current_count: i32) -> Element {
    log!("Rerendering child component with {current_count}");

    rsx! {
        div { "The count is {current_count}" }
    }
}

⚠️ 请勿在组件主体中修改状态

应避免在组件主体中更改状态。若在组件主体中读写状态,可能导致无限循环——组件因状态变更触发重新渲染,进而引发新一轮状态变更。

fn Bad() -> Element {
    let mut count = use_signal(|| 0);

    // ❌ 不要在组件主体中修改状态。这很容易导致无限循环!
    count += 1;

    rsx! { "{count}" }
}

相反,应使用 use_memouse_resource 或在 effect 中修改状态来推导状态。

使用 Effect

你可以使用effect在组件每次渲染时执行代码。

fn Effect() -> Element {
    // Effects 在组件渲染后执行
    // 可用于读取或修改渲染后的组件component
    use_effect(|| {
        log!("Effect ran");
        document::eval(&format!(
            "document.getElementById('effect-output').innerText = 'Effect ran'"
        ));
    });

    rsx! {
        div { id: "effect-output", "This will be changed by the effect" }
    }
}

使用 Drop 清理组件

组件被 drop 前,会释放其所有挂钩。可利用此 drop 行为清理组件使用的资源。若仅需 drop 效果,可使用 use_drop 挂钩。

fn TogglesChild() -> Element {
    let mut show = use_signal(|| true);

    rsx! {
        button { onclick: move |_| show.toggle(), "Toggle" }
        if show() {
            Child {}
        }
    }
}

fn Child() -> Element {
    // 你可以使用 use_drop 钩子来清理任何资源
    use_drop(|| {
        log!("Child dropped");
    });

    rsx! {
        div { "Child" }
    }
}

🚨 2025 最该淘汰的 10 个前端 API!

1. 为什么要“告别”?

  • 规范已打 🚩 Deprecated:浏览器随时下架,埋得越深爆得越惨
  • 性能/体积/安全:老 API 常阻塞线程、无权限模型、包体积爆炸
  • 面试必问:能讲清“为什么不用 + 怎么迁”是高分项

以下 10 组案例,95% 的代码库还能搜到,但官方文档早已不建议使用


2. 一图速览

# 过时 API / 库 典型场景 官方替代 浏览器支持
1 document.execCommand 富文本、复制 navigator.clipboard 96%
2 同步 XMLHttpRequest 导出 Excel fetch / axios 98%
3 escape/unescape URL 编码 encodeURIComponent 100%
4 String.substr 截后缀 slice/substring 100%
5 Event.keyCode 回车 13 Event.key 97%
6 window.event IE 全局事件 事件参数 e 100%
7 trimLeft/trimRight 去空白 trimStart/trimEnd 98%
8 showModalDialog 阻塞弹窗 <dialog> 已移除
9 Moment.js 日期格式化 date-fns / luxon / Temporal 95%
10 jQuery DOM & Ajax 原生 ES6+ / React / Vue 98%

3. 逐个详解 & 最小迁移代码

document.execCommand —— 富文本 & 剪贴板

问题:W3C 停止维护,行为差异大,有 XSS 风险
替代navigator.clipboard(基于 Promise + 权限 API)

// 老
document.execCommand('copy');

// 新
await navigator.clipboard.writeText('hello');

低端机兜底:降级回 execCommand


② 同步 XMLHttpRequest —— 冻结主线程

问题:官方强烈不建议;Chrome 已报 Deprecation 警告
替代fetch(异步 + Stream)

// 老
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api', false);   // 同步
xhr.send();

// 新
const res = await fetch('/api');
const data = await res.json();

escape/unescape —— 非标准百分号编码

问题:中文变 %u4E2D%u6587,+/@ 不编码
替代encodeURIComponent

escape('中文+abc');          // %u4E2D%u6587+abc
encodeURIComponent('中文+abc'); // %E4%B8%AD%E6%96%87%2Babc

String.substr(start, length) —— 语义混乱

问题:ECMA-262 标为 legacy
替代slice(支持负数索引)

const ext = fileName.slice(fileName.lastIndexOf('.'));

Event.keyCode —— 数值随布局变化

问题:UI Events spec 已废弃
替代Event.key(可读字符串)

document.addEventListener('keydown', e => {
  if (e.key === 'Enter') { /* 回车 */ }
  if (e.key === 'Escape') { /* ESC */ }
});

window.event(IE 全局事件)

问题:非标准,严格模式直接报错
替代:事件参数 e

btn.onclick = (e) => {
  console.log(e.target);   // 足够
};

trimLeft/trimRight —— 命名不统一

问题:ECMA 2020 起官方别名改为 trimStart/trimEnd
迁移

str.trimStart(); // 去左
str.trimEnd();   // 去右

showModalDialog —— 同步阻塞弹窗

现状Chrome 43 起已移除
替代:原生 <dialog>(非阻塞 + 可样式化)

<dialog id="d">
  <p>Hi</p>
  <button onclick="d.close()">关闭</button>
</dialog>
<script>d.showModal();</script>

⑨ Moment.js —— 66 KB 的“日期巨头”

问题:2020 起官方进入维护模式,不再加新功能;包体积大
替代

  • date-fns(tree-shaking,按需引)
  • luxon(时区友好)
  • Temporal(原生草案,2025 已 Chrome 95+ 实验旗)
// Moment
moment().format('YYYY-MM-DD');

// date-fns
import { format } from 'date-fns';
format(new Date(), 'yyyy-MM-dd');

⑩ jQuery —— “百万网站”历史债

现状:BuiltWith 统计全球 Top 1M 网站 78% 仍引用,但新立项使用率 < 6%(State of JS 2024)
替代

  • DOM:querySelector + addEventListener
  • Ajax:fetch
  • 动画:Web Animations API / CSS transition
  • 链式:可选链 ?. + 空值合并 ??
// jQuery
$('#btn').click(() => $.get('/api', console.log));

// 原生
document.querySelector('#btn')
        .addEventListener('click', () => fetch('/api').then(r => r.json()).then(console.log));

4. 老项目如何“无痛”批量扫描

  1. ESLint 官方插件
    npm i -D eslint-plugin-deprecation
    一键标出所有 @deprecated 调用
  2. jscodeshift 脚本
    substrslicekeyCodekey 全项目自动替换
  3. Can I use + Babel 插件
    确认替代 API 的最低浏览器版本,再决定要不要兜底

5. 结论 & 行动清单

动作 优先级
新代码直接不用左侧 API 🔴 必须
老代码lint 报错codemod回归测试 🟡 1 个 Sprint
剪贴板、网络、日期优先迁,ROI 最高 🟢 本周

把这篇文章丢进团队 Wiki,下次 Code Review 再看到 execCommandMoment.js,就能有理有据地拒绝了。


6. 参考资料

: 51CTO《2025 年该淘汰的五个 JavaScript 库》2024-12
: 稀土掘金《2025 年该淘汰的 5 个 JavaScript 库》2024-12
: CSDN《这些过时的前端技术请不要再继续学了!》2023-10
: 21CTO《2025 年你应该告别的 5 个 JavaScript 库》2024-12
: 知乎专栏《2024 总结下前端现状!》2025-02

如果对你有帮助,点个「赞」再走呗~

为什么 JDK 1.8 要给 HashMap 加红黑树?

原文来自于:zha-ge.cn/java/35

JDK 1.8 中 HashMap 引入红黑树的深层原因

问题的根源:链表的性能瓶颈

在 JDK 1.8 之前,HashMap 采用"数组 + 链表"的数据结构。当发生哈希冲突时,新元素会被添加到链表的头部或尾部。这种设计在正常情况下工作良好,但存在一个致命缺陷:当大量元素映射到同一个桶时,链表会变得很长,导致查询性能急剧下降

想象一下,如果一个桶中的链表有 1000 个元素,那么在最坏情况下,查找一个元素需要遍历整个链表,时间复杂度退化为 O(n),完全失去了哈希表应有的 O(1) 查询优势。

恶意攻击的威胁

更严重的是,这个特性可能被恶意利用。攻击者可以精心构造大量具有相同哈希值的字符串,强制它们映射到 HashMap 的同一个桶中,形成极长的链表。这种哈希碰撞攻击可以让服务器的 CPU 使用率飙升,造成拒绝服务攻击(DoS)。

红黑树:优雅的解决方案

JDK 1.8 引入红黑树正是为了解决这个问题。新的设计规则如下:

  • 阈值控制:当链表长度超过 8 时,自动转换为红黑树
  • 动态切换:当红黑树节点数量少于 6 时,重新退化为链表
  • 性能保障:红黑树保证了 O(log n) 的查询时间复杂度

这样的设计兼顾了两种数据结构的优势:

  • 链表在元素较少时具有更好的空间效率和简单性
  • 红黑树在元素较多时提供稳定的查询性能

为什么选择红黑树?

你可能会问,为什么不选择 AVL 树或其他平衡二叉树?答案在于权衡:

  1. 相对平衡:红黑树不要求严格平衡,但保证最长路径不超过最短路径的2倍
  2. 插入删除效率:相比 AVL 树,红黑树的插入和删除操作需要的旋转次数更少
  3. 实现复杂度:在性能和实现复杂度之间找到了最佳平衡点

总结

HashMap 引入红黑树是一个典型的工程优化案例。它不仅解决了链表在极端情况下的性能问题,还有效防范了哈希碰撞攻击。这个改进体现了 Java 团队对性能和安全性的持续关注,也展现了在数据结构设计中"没有银弹,只有权衡"的工程哲学。

通过这个优化,HashMap 在保持原有简单易用特性的同时,获得了更加稳定和可靠的性能表现,这正是一个成熟框架应有的品质。

❌