普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月28日技术

每日一题-获取单值网格的最小操作数🟡

2026年4月28日 00:00

给你一个大小为 m x n 的二维整数网格 grid 和一个整数 x 。每一次操作,你可以对 grid 中的任一元素 x x

单值网格 是全部元素都相等的网格。

返回使网格化为单值网格所需的 最小 操作数。如果不能,返回 -1

 

示例 1:

输入:grid = [[2,4],[6,8]], x = 2
输出:4
解释:可以执行下述操作使所有元素都等于 4 : 
- 2 加 x 一次。
- 6 减 x 一次。
- 8 减 x 两次。
共计 4 次操作。

示例 2:

输入:grid = [[1,5],[2,3]], x = 1
输出:5
解释:可以使所有元素都等于 3 。

示例 3:

输入:grid = [[1,2],[3,4]], x = 2
输出:-1
解释:无法使所有元素相等。

 

提示:

  • m == grid.length
  • n == grid[i].length
  • 1 <= m, n <= 105
  • 1 <= m * n <= 105
  • 1 <= x, grid[i][j] <= 104

【鄙人拙见:为什么要取中位数】JavaScript

作者 lzxjack
2021年10月11日 21:40

image.png
应该没多少人提交吧,所以我才这么快🤣🤣🤣

解题思路

为什么是中位数,我的理解:
image.png
数轴上的三个点$a$、$b$、$c$,间隔为$l$。

所有点到$a$点的距离之和:$3l$
所有点到$b$点的距离之和:$2l$
所有点到$c$点的距离之和:$3l$
显然,取中间的$b$点,距离之和是最小的。

可以这么想:不管点取哪个,$a$点到其的距离加上$c$点到其的距离,是个定值$2l$。所以区别就在于$b$点到其的距离了,那么很显然,取$b$点本身,$b$点到其的距离是最小的,为$0$。所以才取的中位数,可以推广到一般情况。

若有不妥之处,欢迎指出。

代码

###javascript

const minOperations = (grid, x) => {
    // 行、列
    const [m, n] = [grid.length, grid[0].length];
    // 如果只有一个元素,返回0
    if (m === 1 && n === 1) return 0;
    // 将网格扁平化
    const nums = [];
    for (let i = 0; i < m; i++) {
        nums.push(...grid[i]);
    }
    // 升序排序
    nums.sort((a, b) => a - b);
    const numsLen = nums.length;
    // 中位数
    const num = nums[numsLen >> 1];
    let res = 0;
    for (let i = 0; i < numsLen; i++) {
        // 当前数和中位数的差值
        const gap = nums[i] - num;
        // 某个差值不是x的倍数,则不能完成操作
        if (gap % x) return -1;
        // 累加上步骤次数
        res += (gap > 0 ? gap : -gap) / x;
    }
    return res;
};

[java]贪心 取中位数(垃圾解释)

作者 fei-xiao-r
2021年10月10日 12:54

思路:其实没啥思路,就是简单的获得中位数,在进行加减x等于中位数的操作
bd68d7f4e59501f83d7cb4643d620e7.jpg

class Solution {
    public int minOperations(int[][] grid, int x) {
        int n = grid.length;
        int m = grid[0].length;
        int[] arr = new int[m*n];
        int i = 0;
        for(int[] a : grid)
            for(int a_ : a){
                arr[i++] = a_;
            }
        Arrays.sort(arr);
        int j = arr[(n*m)/2];
        int sum = 0;
        for(int a : arr){
            int l = Math.abs(j-a);
            if(l%x != 0) return -1;
            sum += l/x;
        }
        return sum;
    }
}

中位数贪心(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2021年10月10日 12:13

先判断是否无解。

例如 $x=2$,我们可以把 $2,4,6,8$ 都变成同一个数,但能把 $2,5$ 变成同一个数吗?不能,在 $x=2$ 的情况下,偶数只能变成偶数,奇数只能变成奇数,无法把偶数和奇数变成同一个数。在 $x=2$ 的情况下,每个数的奇偶性(模 $2$ 的结果)必须都一样,才能变成同一个数。

一般地,对于整数 $k$,我们有 $(\textit{grid}[i][j] + kx)\bmod x = \textit{grid}[i][j]\bmod x$。所以操作后,$\textit{grid}[i][j] \bmod x$ 是不变的。每个数模 $x$ 的结果必须都一样,才能变成同一个数。否则无解,输出 $-1$。

想象操场上有一些人,把这些人聚在一起,怎么移动最迅速?

往「中间」移动是最迅速的。

根据 中位数贪心及其证明,把所有数变成 $\textit{grid}$ 的中位数是最优的。

设 $\textit{grid}$ 的中位数为 $\textit{median}$,总操作次数为

$$
\sum_{v\in \textit{grid}}\dfrac{|v - \textit{median}|}{x} = \dfrac{\sum\limits_{v\in \textit{grid}}|v - \textit{median}|}{x}
$$

class Solution:
    def minOperations(self, grid: List[List[int]], x: int) -> int:
        a = []
        target = grid[0][0] % x

        # 1. 判断是否无解
        for row in grid:
            for v in row:
                if v % x != target:  # 每个数模 x 都必须相等
                    return -1
            a += row

        # 2. 计算 grid 的中位数 median
        a.sort()
        median = a[len(a) // 2]

        # 3. 计算操作次数
        return sum(abs(v - median) for v in a) // x
class Solution {
    public int minOperations(int[][] grid, int x) {
        int k = grid.length * grid[0].length;
        int[] a = new int[k];
        int idx = 0;
        int target = grid[0][0] % x;

        // 1. 判断是否无解
        for (int[] row : grid) {
            for (int v : row) {
                if (v % x != target) { // 每个数模 x 都必须相等
                    return -1;
                }
                a[idx++] = v;
            }
        }

        // 2. 计算 grid 的中位数 median
        Arrays.sort(a);
        int median = a[k / 2];

        // 3. 计算操作次数
        int ans = 0;
        for (int v : a) {
            ans += Math.abs(v - median);
        }
        return ans / x;
    }
}
class Solution {
public:
    int minOperations(vector<vector<int>>& grid, int x) {
        int k = grid.size() * grid[0].size();
        vector<int> a;
        a.reserve(k); // 预分配空间
        int target = grid[0][0] % x;
    
        // 1. 判断是否无解
        for (auto& row : grid) {
            for (int v : row) {
                if (v % x != target) { // 每个数模 x 都必须相等
                    return -1;
                }
                a.push_back(v);
            }
        }

        // 2. 计算 grid 的中位数 median
        // ranges::sort(a);
        ranges::nth_element(a, a.begin() + k / 2); // 用快速选择代替排序
        int median = a[k / 2];

        // 3. 计算操作次数
        int ans = 0;
        for (int v : a) {
            ans += abs(v - median);
        }
        return ans / x;
    }
};
int cmp(const void* a, const void* b) {
    return *(int*)a - *(int*)b;
}

int minOperations(int** grid, int gridSize, int* gridColSize, int x) {
    int m = gridSize, n = gridColSize[0];
    int* a = malloc(m * n * sizeof(int));
    int k = 0;
    int target = grid[0][0] % x;

    // 1. 判断是否无解
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            if (grid[i][j] % x != target) { // 每个数模 x 都必须相等
                return -1;
            }
            a[k++] = grid[i][j];
        }
    }

    // 2. 计算 grid 的中位数 median
    qsort(a, k, sizeof(int), cmp);
    int median = a[k / 2];

    // 3. 计算操作次数
    int ans = 0;
    for (int i = 0; i < k; i++) {
        ans += abs(a[i] - median);
    }
    free(a);
    return ans / x;
}
func minOperations(grid [][]int, x int) int {
k := len(grid) * len(grid[0])
a := make([]int, 0, k) // 预分配空间
target := grid[0][0] % x

// 1. 判断是否无解
for _, row := range grid {
for _, v := range row {
if v%x != target { // 每个数模 x 都必须相等
return -1
}
}
a = append(a, row...)
}

// 2. 计算 grid 的中位数 median
slices.Sort(a)
median := a[k/2]

// 3. 计算操作次数
ans := 0
for _, v := range a {
ans += abs(v - median)
}
return ans / x
}

func abs(x int) int {
if x < 0 {
return -x
}
return x
}
var minOperations = function(grid, x) {
    const a = [];
    const target = grid[0][0] % x;

    // 1. 判断是否无解
    for (const row of grid) {
        for (const v of row) {
            if (v % x !== target) { // 每个数模 x 都必须相等
                return -1;
            }
            a.push(v);
        }
    }

    // 2. 计算 grid 的中位数 median
    a.sort((a, b) => a - b);
    const median = a[Math.floor(a.length / 2)];

    // 3. 计算操作次数
    let ans = 0;
    for (const v of a) {
        ans += Math.abs(v - median);
    }
    return ans / x;
};
impl Solution {
    pub fn min_operations(grid: Vec<Vec<i32>>, x: i32) -> i32 {
        let k = grid.len() * grid[0].len();
        let mut a = Vec::with_capacity(k); // 预分配空间
        let target = grid[0][0] % x;

        // 1. 判断是否无解
        for row in grid {
            for v in row {
                if v % x != target { // 每个数模 x 都必须相等
                    return -1;
                }
                a.push(v);
            }
        }

        // 2. 计算 grid 的中位数 median
        let median = *a.select_nth_unstable(k / 2).1;

        // 3. 计算操作次数
        a.into_iter().map(|v| (v - median).abs()).sum::<i32>() / x
    }
}

注:如果 $\textit{grid}$ 有负数,模 $x$ 的结果有正有负,不方便判断,此时可以把判断条件改成 (grid[i][j] - grid[0][0]) % x != 0

复杂度分析

  • 时间复杂度:$\mathcal{O}(mn\log(mn))$ 或 $\mathcal{O}(mn)$,其中 $m$ 和 $n$ 分别是 $\textit{grid}$ 的行数和列数。瓶颈在排序上。用快速选择算法代替排序,可以做到 $\mathcal{O}(mn)$,见 C++ 或 Rust 代码。如果你想手写快速选择算法,可以看 215. 数组中的第K个最大元素我的题解
  • 空间复杂度:$\mathcal{O}(mn)$。

专题训练

见下面贪心题单的「§4.5 中位数贪心」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

昨天 — 2026年4月27日技术

二十五.Electron 初体验与进阶

2026年4月27日 18:58

前言

又是将近快一年没有写作了,主要是回武汉后每天忙到不停,而且涉及到的技能点之前了解比较少,现在终于有时间抽出空来总结下,最近做的比较多的是Electorn桌面端应用,在Electron中,你可以实现更多浏览器网页端无法实现的操作,经过这段时间的学习和使用,也算是对这块有个大概的了解,这里记录下学习使用过程中出现的一些问题和实现思路,希望能对你有帮助。

Electron基础介绍

Electron是一个使用 JavaScript、HTML 和 CSS 构建桌面应用程序的框架。 嵌入 Chromium 和 Node.js 到 二进制的 Electron 允许您保持一个 JavaScript 代码代码库并创建 在Windows上运行的跨平台应用 macOS和Linux——不需要本地开发 经验。

个人理解Electron就是将node环境以及chromium浏览器打包进应用里面,然后浏览器打开对应页面,然后通过nsis等工具构建对应平台需要的安装包。

优点是一套代码能同时打包MacLinuxWindows,开发成本低,社区轮子多,稍微学习下就能上手

缺点是打包体积大,性能相对没那么好,加载相对比较慢,运行占用内存比较大

从模块上,electorn分为 主进程(main)、渲染进程(renderer)、预加载脚本(preload),其中主进程和渲染进程是独立的,本身做了进程隔离。

  • 主进程: 创建应用程序的主入口,可以做:创建窗口、自动更新升级、系统级调用/获取,node操作等;
  • 渲染进程:也就是我们正常的页面,跟常规项目差不多,正常实现即可;
  • 预加载脚本:由于主进程和渲染进程是隔离开的,为了进程之间通信,所以需要预加载在中间充当桥梁;

所以一个electron项目,基础的项目结构应该是这样的:

├── build                            ---安装进程文件
├── src
│  ├── main                           ---主进程
│  ├── preload                        ---预加载进程
│  ├── render                         ---渲染进程
├──  package.json

推荐使用electron-vite去构建项目,内置基础自动更新和热更新等,开发更加方便。

主进程基础介绍

主进程是整个程序的入口,下面将提供一个基础示例:

具体窗口参数可以看:BrowserWindow | Electron

const { app, BrowserWindow } = require('electron')  
  
const createWindow = () => {  
    const win = new BrowserWindow({  
        width: 800,  
        height: 600  
    })  
  
    win.loadFile('index.html')  //加载渲染进程文件,可选loadFile、loadUrl,等价于在浏览器打开html文件
}  
  
app.whenReady().then(() => {  
    createWindow()  
})

一般主进程内需要实现:

  • 创建窗口
  • 日志记录
  • 托盘
  • 快捷键注册
  • 自动更新升级
  • 和渲染进程通信
  • ...其他node或系统级操作,例如文件保存、读取等

生命周期

electron也有对应的生命周期,常见的如下:

  1. ready: 当electron初始化完成触发,一般在这里开始创建窗口并挂载渲染进程文件,等价于app.whenReady
app.on('ready', ()=>{

})
 // 等价于=
app.whenReady().then(() => {

})
  1. activate: 仅Mac上使用,各种操作都可以触发此事件, 例如首次启动应用程序、尝试在应用程序已运行时或单击应用程序的坞站或任务栏图标时重新激活它,一般在这里判断是否已创建窗口,如果已创建就显示,未创建则创建。
  app.on("activate", function () {
    if (BrowserWindow.getAllWindows().length === 0) {
      windowManager.createWindow();
    } else {
      const mainWindow = windowManager.getMainWindow();
      if (mainWindow) {
        mainWindow.show();
        mainWindow.focus();
      }
    }
  });
});
  1. window-all-closed: 所有窗口都关闭时触发
app.on("window-all-closed", () => {
  if (process.platform !== "darwin") {
    app.quit();
  }
});

4.before-quit:应用退出开始关闭窗口之前触发,可通过 event.preventDefault()进行阻止。

app.on("before-quit", (event) => {
    event.preventDefault();
    app.quit();
});

5.will-quit:应用退出关闭窗口后,退出应用之前触发,可通过 event.preventDefault()进行阻止。

app.on("will-quit", (event) => {
    event.preventDefault();
    app.quit();
});

6.quit:应用窗口关闭后,应用退出时触发,不可阻止。

app.on("quit", (event) => {
   console.log("应用已退出")
});

常用方法

electron中有一些常用方法,这里单独说明和记录下:

app级方法:

  1. app.quit():退出应用,会先触发before-quit -> will-quit -> quit
  2. app.exit(): 立即退出应用,不会触发before-quit
  3. app.relaunch(): 重启应用
  4. app.whenReady(): 应用初始化完成,等价于app.on('ready')
  5. app.hide(): 应用隐藏
  6. app.show(): 应用显示
  7. app.getAppPath(): 应用所在目录

窗口级方法:

  1. loadUrl: 加载html url地址
  2. loadFile: 加载html 路径
  3. show/hide:显示隐藏窗口
  4. maximize/minimize/restore: 窗口最大化 / 最小化 / 还原

日志记录

一个完整的项目肯定是需要日志记录,我们可以通过 electron-log 这个插件进行日志记录,这里我封装了一个基础的日志记录工具,可以贴出来给大家参考下,主要是存储在userData用户数据目录,按小时进行轮转。

import * as path from "path";
import { app } from "electron";
import log from "electron-log";
import { ensureDir } from "./util";
import moment from "moment";

class DailyLogger {
  private logDirectory: string;
  private currentHour: string;

  constructor(options: { logDirectory?: string; maxSize?: number } = {}) {
    this.logDirectory = options.logDirectory || path.join(app.getPath("userData"), "logs");
    this.currentHour = this.getFormattedHour();

    // 禁用默认轮转行为
    log.transports.file.maxSize = 0;

    // 初始化日志配置
    this.setupLog();
  }

  // 时间格式改为 YYYYMMDDHH
  private getFormattedHour(): string {
    return moment().format("YYYYMMDDHH");
  }

  // 设置日志文件路径和配置
  private setupLog(): void {
    ensureDir(this.logDirectory);

    // this.checkLogFile();
    log.transports.file.resolvePathFn = () => this.getLogFilePath();
    log.transports.file.format = "[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}\n";
  }

  // 获取当前日志文件路径
  private getLogFilePath(): string {
    return path.join(this.logDirectory, `${this.currentHour}.log`);
  }

  // 写入日志
  public info(message: string): void {
    this.ensureCorrectHour();

    log.info(message);
  }

  public warn(message: string): void {
    this.ensureCorrectHour();
    log.warn(message);
  }

  public error(message: string): void {
    this.ensureCorrectHour();
    log.error(message);
  }

  private ensureCorrectHour(): void {
    const nowHour = this.getFormattedHour();
    if (nowHour !== this.currentHour) {
      this.currentHour = nowHour;
      this.setupLog(); // 小时变化时立即创建新文件
    }
  }
}

const logger = new DailyLogger();

export default logger;

托盘

electron项目一般都需要托盘,当应用右上角关闭后,都会最小化到托盘,这里我们可以直接用electron自带的Tray进行托盘构建。

import { BrowserWindow, Tray, Menu, nativeImage } from "electron";

function createTray() {
    const trayIconImage = nativeImage
      .createFromPath(this.iconPath)
      .resize({ width: 16, height: 16 });

    this.tray = new Tray(trayIconImage);

    // 设置托盘提示文字
    this.tray.setToolTip("应用");

    // 创建托盘右键菜单
     const contextMenu = Menu.buildFromTemplate([
      {
        label: "退出应用",
        click: () => this._quitApp(),
      },
    ]);

    this.tray.setContextMenu(contextMenu);

    // 托盘图标单击事件 - 切换窗口显示/隐藏
    this.tray.on("click", () => {
      this._toggleWindow();
    });

    // 托盘图标双击事件 - 总是显示窗口
    this.tray.on("double-click", () => {
      this._showWindow();
    });
  }

快捷键注册

我们在应用中,可能会需要用到一些快捷键,例如ctrl+s报错等等,我们可以用electron提供的globalShortcut进行快捷键注册,但是这个快捷键仅支持单个实例窗口,例如我们打开多开应用的时候,这个方法就不行了。

import { globalShortcut } from "electron";

if (!globalShortcut.isRegistered("alt+d")) {
    globalShortcut.register("alt+d", () => {
      console.log("触发")
    });
}

如果没有多开需要,可以直接使用electron自带的快捷键注册,如果有需要多开,可以使用社区提供的插件electron-localshortcut,使用方法也很简单:

import electronLocalshortcut from "electron-localshortcut";
function registerOpenDevTools(win) {
  electronLocalshortcut.register("Alt+D", () => {
     console.log("触发")
  });
}

自动更新升级

一个正常的应用,肯定是需要进行自动更新升级的,而不是需要用户自己每次去下载最新版,这里我们使用electron-updater进行自动更新升级,自动更新分为全量更新增量更新两种方式,我们先用全量更新进行实现,如果有需要增量更新,后续可以单独写一篇来写这个,这里就不过多赘述了。

更新包准备: 我们先要提前准备好增量更新包,以window为例,将我们打开好的exe文件和latest.yml文件放在同一文件夹内放置在更新服务器上。

electron-updater基础介绍:我们主要使用 electron-updater里面的autoUpdater模块,它包含如下方法:

  • update-available: 监听有可用的更新
  • update-not-available: 监听没有可用的更新
  • download-progress: 监听下载进度
  • update-downloaded: 监听下完完成
  • error: 监听失败

所以我们一个基础的代码如下:

import { autoUpdater } from "electron-updater";

//设置请求头
autoUpdater.requestHeaders = {
    Accept: "application/octet-stream",
    "X-Custom-Header": "Custom-Value",
};
//自动开始下载
autoUpdater.autoDownload = true;

autoUpdater.setFeedURL({
  provider: "generic",  //自定义服务器
  url: updateUrl,  //更新包latest.yml地址,通过yml去找更新包地址
});

// 开始检测,他会自动比对yml的版本,如果高度当前版本,就会触发update-available
autoUpdater.checkForUpdates();

autoUpdater.on("error", function (_: any, err: any) {
    console.error(`更新失败:${JSON.stringify(err)}`);
});

//有可用的更新
autoUpdater.on("update-available", function (message: any) {
    console.log("update-available", message);
});

//没有可用的更新
autoUpdater.on("update-not-available", function (message: any) {
    console.log(`【没有可用的更新】${JSON.stringify(message)}`);
});

// 更新下载进度事件
autoUpdater.on("download-progress", function (progressObj: any) {
    console.log(`【更新包下载进度】${JSON.stringify(progressObj)}`);
});

//更新包下载完成
autoUpdater.on("update-downloaded", function (res) {
    //退出并安装
    autoUpdater.quitAndInstall();
});

文件本地化存储

electron应用和浏览器应用的区别是,我们可以充分利用本地存储的优势,将一些变化不是特别大的数据,存储在本地,减少服务器调用,这里我们使用:node-localstorage进行数据本地化存储,使用方法和浏览器的localStorage类似,这里就直接贴示例了。

import { LocalStorage } from "node-localstorage";

const storage = new LocalStorage(storagePath);  //文件本地存储位置,一般放userData

// 设置值
function setItem(key: string, value: any) {
  storage.setItem(key, JSON.stringify(value));
}

// 获取值
function getItem(key: string): T | null {
    const val = storage.getItem(key);
    return val ? JSON.parse(val) : null;
}

//删除单个值
removeItem(key: string): void {
    storage.removeItem(key);
}

//清除所有值
clear(): void {
    storage.clear();
}

当然这里只是一些简单数据存储,例如key等不是很长,且数据不怎么会变化的值,如果需要大量数据存储,比如聊天记录等,可以使用IndexexDb或真正的数据库sqlite,推荐使用nedbbetter-sqlite3,前者是一个Nosql嵌入型数据库,在渲染进程可以直接使用,但是文件最大大小不超过512Mb,后者是sql数据库,必须要在主进程使用,需要建库建表设置字段类型,用法和真正写sql差不多,而且还提供了事务等操作。

进程通信

之前有提到过,主进程和渲染进程之间进程通信,需要通过preload预加载作为桥梁,这里提供下示例。

主进程

import {ipcMain,app} from "electron";

// 监听窗口最小化
ipcMain.on("window-min", function () {
    win && win.minimize();
});

ipcMain.handle("get-version", () => {
  return app.getVersion();
});

preload预加载

import { contextBridge, ipcRenderer } from "electron";
import { electronAPI } from "@electron-toolkit/preload";

// 用于渲染进程的自定义 API
const api = {
   setWindowMin(){
     ipcRenderer.send("window-min");
   },
   getVersion(){
     reutrn ipcRenderer.invoke("get-version")
   }
};

if (process.contextIsolated) {
  try {
    contextBridge.exposeInMainWorld("electron", electronAPI);
    contextBridge.exposeInMainWorld("api", api);
  } catch (error) {
    console.error(error);
  }
} else {
  window.electron = electronAPI;
  window.api = api;
}

渲染进程

// 通过window.api获取
function getVersion(){
   const version=window.api.getVersion();
   console.log(version)
}

function setWindowMin(){
   window.api.setWindowMin();
}

最后

到这里electron初体验已经完成了,其实和常规的项目区别没太大,可能就需要一部分系统的配置,其实还是挺有意思的,如果这篇文章对你有所帮助,可以点个赞和评论区沟通交流~

其他文章

UI组件库elementplus

作者 RONIN
2026年4月27日 18:28

官网:www.he-fan.cn/zh-CN/

1. 安装

npm install element-plus --save安装elementplus

npm install -d unplugin-vue-components unplugin-auto-import安装两个插件自动按需引入

2. vite.config.js集成插件

import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
        resolvers: [ElementPlusResolver()],
    }),
    Components({
        resolvers: [ElementPlusResolver()],
    }),
  ],
  resolve: {
    alias: {
      '@'fileURLToPath(new URL('./src', import.meta.url))
    }
  },

// 代理服务器
    // server: {
    //  proxy: {
    //      '/api': {
    //          target: 'http://43.136.34.132:8088', // 目标
    //          changeOrigin: true,
    //          // rewrite: path => path.replace(/^\/api/, ''),
    //      },
    //  },
    // },
})

3.main.js引入elementplus样式,否则可能出现使用组件没效果。


import { createApp } from "vue";
import router from "./router";
import store from "./store";
import 'element-plus/dist/index.css';//引入elementplus样式
import App from "./App.vue";
import './permission.js' //登录认证

import ElementPlus from 'element-plus';//完整引入elementplus,文件大小会很大。可以考虑按需引入

const app = createApp(App);
app.use(router);
app.use(store);
 
app.use(ElementPlus);//完整引入elementplus,文件大小会很大。可以考虑按需引入
app.mount("#app");

4.项目中使用组件示例

  • Layout网格布局组件;
  • form表单组件、button按钮组件、icon图标组件、ElMessage消息提示组件;
  • container布局容器、image图片组件、面包屑导航组件、dropdown下拉菜单、menu菜单组件;
  • table表格组件、dialog弹框组件、upload上传组件、popconfirm气泡确认框、pagination分页组件。

Layout网格布局组件:(默认每行分成24个栅栏)

1行1列

<el-row>
<el-col :span=”24”></el-col>
</el-row>

1行2列,row组件的gutter属性指定列之间的间距,默认0

<el-row :gutter=”20”>
  <el-col :span=”12”></el-col>
  <el-col :span=”12”></el-col>
</el-row>

1行2列,col组件的offset属性指定列偏移

<el-row>
  <el-col :span=”6”></el-col>
  <el-col :span=”12” :offset=”6”></el-col>
</el-row>

1行3列,默认flex布局,

使用justify属性定义对齐方式start/end/center/speace-between/speace-around/space-evenly

<el-row :justisy=”center”>
  <el-col :span=”6”></el-col>
  <el-col :span=”6”></el-col>
  <el-col :span=”6”></el-col>
</el-row>

form表单、button按钮组件、icon图标、ElMessage消息提示

  • <el-from>rules定义验证规则,:rules绑定验证规则,:model绑定数据
  • <el-from-item>绑定具体规则(prop属性设为需验证的特殊键值)
  • 输入框v-model双向数据绑定
  • <el-button type=’success’>success</el-button>
  • <el-icon>

npm install@element-plus/icons-vue下载图标包管理器

import { User, Lock } from '@element-plus/icons-vue'引入图标

components: {

        User,

        Lock,

 },注册后使用

  • import { ElMessage } from 'element-plus'

ElMessage({

       message: '成功了!',

       type: 'success',

})

Login.vue

<template>
    <div class="g-container">
        <div class="g-wrapper">
            <h2>xx系统</h2>
            <el-form
                class="g-login"
                :rules="rules"
                :model="user"
                ref="loginFormRef"
            >
                <el-form-item prop="name">
                    <el-input placeholder="请输入用户名" v-model="user.name">
                        <template #prefix>
                            <el-icon><User /></el-icon>
                        </template>
                    </el-input>
                </el-form-item>
                <el-form-item prop="password">
                    <el-input
                        placeholder="请输入密码"
                        v-model="user.password"
                        show-password
                    >
                        <template #prefix>
                            <el-icon><Lock /></el-icon>
                        </template>
                    </el-input>
                </el-form-item>
                <el-button type="primary" @click="bindLogin">登录</el-button>
            </el-form>
        </div>
    </div>
</template>

<script>
import { User, Lockfrom '@element-plus/icons-vue'
import { RequestLoginfrom '@/api/index.js'
import { ElMessagefrom 'element-plus'

export default {
    components: {
        User,
        Lock,
    },

    data() {
        return {
            user: {
                name'root',
                password'root',
            },

            // 定义校验规则,在data选项中,
            // 在form-item中使用prop绑定规则
            // 双向数据绑定 v-model
            // form单表中使用 :model="user"
            rules: {
                name: [
                    {
                        requiredtrue,
                        message'请输入用户名',
                        trigger'blur',
                    },
                ],
                password: [
                    { requiredtrue, message'请输入密码', trigger'blur' },
                ],
            },
        }
    },

    methods: {
        bindLogin() {
            const { name, password } = this.user
            const formRef = this.$refs.loginFormRef
            formRef.validate(async valid => {
                // 1. 表单校验
                if (valid) {
                    // 2. 调用登录接口,验证账户
                    const data = await RequestLogin(name, password)
                    const { resultCode, resultInfo } = data
                    if (resultCode === 1) {
                        // 3. 保存用户昵称和头像,用于主界面显示
                        const userInfo = {
                            nick: resultInfo.nick,
                            headerimg: resultInfo.headerimg,
                        }
                        this.$store.dispatch('member/saveUser', userInfo)
                        // 4. 保存token
                        // eslint-disable-next-line no-undef
                        localStorage.setItem('TOKEN', token)
                        // 5. 跳转到主界
                        this.$router.push({ path: '/home' })
                    } else {
                        ElMessage({
                            message'账号出错!',
                            type'error',
                        })
                    }
                }
            })
        },
    },
}
</script>

<style lang="scss" scoped>
.g-container {
    width: 100%;
    height: 100vh;
    background-color: #2b3c4d;
    position: relative;
    .g-wrapper {
        position: absolute;
        left: 50%;
        top: 50%;
        transform: translate(-50%, -50%);
        width: 400px;
        h2 {
            text-align: center;
            color: white;
            margin-bottom: 20px;
        }
        .g-login {
            background-color: white;
            border-radius: 5px;
            padding: 40px 30px;
            .el-button {
                width: 100%;
            }
        }
    }
}
</style>

story→index.js(vuex持久化存储插件,解决刷新用户信息丢失问题)

/* eslint-disable prettier/prettier */
import { createStore } from "vuex";
import member from "./modules/member.js";
import createPersistedState from "vuex-persistedstate";

const store = createStore({
  modules: {
    member,
  },

  // 集成插件
  plugins: [
    createPersistedState({
      storage: sessionStorage,
      key"storekey",
    }),
  ],
});
export default store;

story→modules→member.js


/* eslint-disable prettier/prettier */

const member = {
  namespacedtrue,
  // 内存中
  state: {
    // user:{name:'',password:''},
    usernull,
  },

  mutations: {
    SAVE_USER(state, _user) {
      state.user = _user; // 保存内存
      // localStorage.setItem('USER', JSON.stringify(_user)) // 持久化存储
    },
  },

  actions: {
    saveUser({ commit }, _user) {
      commit("SAVE_USER", _user);
    },
  },

  getters: {
    user(state) => state.user,
  },
};
export default member;

permission.js统一登录认证封装

import router from './router'
/**

 * 全局前置导航守卫
 *   路由router对象的一个方法 beforeEach
 */
// eslint-disable-next-line no-unused-vars

router.beforeEach((to, from) => {
    // 1. 加入白名单: 有些路由是不需要登录身份认证 path: /login ,  /

    // if (to.path === '/login') {
    //     return true //放行
    // }

    const whiteList = ['/login', '/']

    if (whiteList.includes(to.path)) {
        return true //放行
    }

    // 2. 登录认证,检查token

    let token = localStorage.getItem('TOKEN')

    if (token) {
        return true // 放行
    } else {
        // 如果不存在, 重定向到登录界面
        router.replace({ path'/login' })
        return false
    }
})

container布局容器(上中下布局、左右布局等)、image图片、面包屑导航、dropdown下拉菜单

  • <el-container>外层容器,子元素中含<el-header><el-footer>时全部子元素垂直上下排列,否则水平排列

  • <el-header>顶栏容器

  • <el-main>主要区域容器

  • <el-aside>侧边栏容器

  • <el-footer>底栏容器

  • <el-image :src="url"></el-image>

  • <el-breadcrumb separator-class="el-icon-arrow-right">

  • <el-breadcrumb-item :to="{ path: '' ,name:’’}">  

  • 收缩菜单功能:(图标改变、宽度改变)

<component :is="componentName"></component>动态组件切换图标

<el-menu :collapse="!isCollapse" >  通过 :collapse控制收缩(默认false打开,true收起)

 

  • <el-dropdown trigger=”click”>  trigger触发方式,默认移上去触发,也可设置为点击触发

<el-dropdown-menu>

<el-dropdown-item>

  • 可选链运算 ? (先判断前面的对象是否为真,如果为真,执行后面的点语法)

<p>欢迎您:{{ userInfo?.nick }}</p>

Home.vue

<template>

    <el-container>

        <!-- 左侧区域 -->

        <el-aside :width="asideWidth">

            <div class="g-title">

                <el-image :src="url"></el-image>

                <h3 v-show="isCollapse">xx管理</h3>

            </div>

            <p>首页</p>

            <Menu :isCollapse="isCollapse"></Menu>

        </el-aside>

        <!-- 右侧区域 -->

        <el-container>

            <!-- 头部区域 -->

            <el-header>

                <el-icon size="25" @click="bindCollapse">

                    <!-- <Fold v-if="isCollapse" @click="bindCollapse" />

                  <Expand v-else @click="bindCollapse"/>  -->

                    <component :is="componentName"></component>

                </el-icon>

 

                <div>

                    <el-dropdown>

                        <div class="g-header-r">

                           <!-- 可选链运算 ?. 先判断前面的对象是否为真,如果为真执行点语法-->

                            <p>欢迎您:{{ userInfo?.nick }}</p>

                            <el-image :src="userInfo?.headerimg"></el-image>

                            <!-- <p>欢迎您:{{ userInfo?userInfo.nick:'' }}</p>

                            <el-image :src="userInfo?userInfo.headerimg:''"></el-image> -->

                        </div>

                        <template #dropdown>

                            <el-dropdown-menu>

                                <el-dropdown-item>个人中心</el-dropdown-item>

                                <el-dropdown-item>切换用户</el-dropdown-item>

                                <el-dropdown-item @click="bindExit"

                                    >退出登录</el-dropdown-item

                                >

                            </el-dropdown-menu>

                        </template>

                    </el-dropdown>

                </div>

            </el-header>

            <!-- 内容区域 -->

            <el-main>

                <!-- 面包屑导航 -->

               <BreadCrumb></BreadCrumb>

                <!-- 子路由输出 -->

                <router-view></router-view>

            </el-main>

        </el-container>

    </el-container>

</template>

<script>

import Menu from '@/components/Menu.vue'

import BreadCrumb from '@/components/BreadCrumb.vue'

import { Fold, Expandfrom '@element-plus/icons-vue'

export default {

    components: {

        Menu,

        Fold,

        Expand,

        BreadCrumb,

    },

    data() {

        return {

            url'https://gw.alipayobjects.com/zos/rmsportal/KDpgvguMpGfqaHPjicRK.svg',

            isCollapsetrue,

            asideWidth'200px',

            componentNameFold,

        }

    },

    computed: {

        userInfo() {

            return this.$store.getters['member/user']

        },

    },

    methods: {

        bindCollapse() {

            this.isCollapse = !this.isCollapse

            this.asideWidththis.isCollapse'200px' : '70px'

            this.componentNamethis.isCollapseFoldExpand

        },

        bindExit() {

            localStorage.remove('TOKEN')//退出登录清除token

            this.$router.replace({ path'/login' })

        },

    },

}

</script>

<!-- eslint-disable prettier/prettier -->

<style lang="scss" scoped>

.el-container {

    width: 100%;

    height: 100vh;

 

    .el-aside {

        background-color: #2d3436;

        color: white;

        transition: 0.5s;

        .g-title {

            display: flex;

            margin: 20px;

        }

        .el-image {

            width: 25px;

            height: 25px;

        }

        p {

            margin-left: 20px;

        }

        .el-menu {

            border-right: 0;

        }

    }

    .el-container {

        .el-header {

            display: flex;

            justify-content: space-between;

            padding: 10px 20px;

            background-color: #7f8fa6;

            height: 60px;

            .g-header-r {

                display: flex;

                p {

                    color: white;

                    // padding-top: 10px;

                }

                .el-image {

                    width: 30px;

                    height: 30px;

                }

            }

        }

        .el-breadcrumb {

            margin: 10px 0;

        }

    }

}

</style>

BreadCrumb.vue面包屑导航组件化

<!-- eslint-disable prettier/prettier -->
<template>
    <el-breadcrumb separator="/">
        <el-breadcrumb-item :to="{ path: '/index' }">首页</el-breadcrumb-item>
        <el-breadcrumb-item v-for="(item,index) in navigateList" :key="index" :to="{ path: item.path }">{{item.title}}</el-breadcrumb-item>
    </el-breadcrumb>
</template>

<!-- eslint-disable prettier/prettier -->
<script>
export default{
    data(){
        return{
            navigateList:[]
        }
    },

    watch:{
        // 路由变化,更新面包屑导航
        //侦听$route
        //获取matched数组中元素,更新面包屑导航
        $route(value){
            if(value.path === '/index'){
                this.navigateList = []
                return
            }
            // console.log(this.matched);
            // [{path:'/home1',title:'产品管理'},{path:'/product/list',title:'产品列表'},]
            const navigateList = value.matched.map(item =>{
                return{path:item.path,title:item.meta.title}
            })
            console.log(navigateList);
            this.navigateList = navigateList
        }
    }
}
</script>
<!-- eslint-disable prettier/prettier -->
<style lang="scss" scoped></style>

menu菜单组件:

<el-menu>菜单

<el-sub-menu index="1">二级菜单

<el-menu-item index="1-1">菜单项

Menu.vue

<template>

    <el-menu

        active-text-color="#ffd04b"

        background-color="#2d3436"

        text-color="#fff"

        :collapse="!isCollapse"

    >

        <el-sub-menu index="1">

            <template #title>

                <el-icon><Location /></el-icon> <span>产品管理</span>

            </template>

            <el-menu-item index="1-1">

                <el-icon><Handbag /></el-icon>

                <router-link to="/product/list">产品列表</router-link>

            </el-menu-item>

            <el-menu-item index="1-2">

                <el-icon><ReadingLamp /></el-icon>

                <router-link to="/product/category">产品分类</router-link>

            </el-menu-item>

            <el-menu-item index="1-3">

                <el-icon><ReadingLamp /></el-icon>

                <router-link to="/product/map">产品地图</router-link>

            </el-menu-item>

        </el-sub-menu>

 

        <el-sub-menu index="2">

            <template #title>

                <el-icon><OfficeBuilding /></el-icon><span>账户管理</span>

            </template>

            <el-menu-item index="2-1"

                ><el-icon><Mic /></el-icon

                ><router-link to="/account/list"

                    >账户列表</router-link

                ></el-menu-item

            >

            <el-menu-item index="2-2"

                ><el-icon><Camera /></el-icon

                ><router-link to="/account/add"

                    >账户添加</router-link

                ></el-menu-item

            >

        </el-sub-menu>

        <el-sub-menu index="3">

            <template #title>

                <el-icon><OfficeBuilding /></el-icon><span>OA管理</span>

            </template>

            <el-menu-item index="3-1"

                ><el-icon><Mic /></el-icon

                ><router-link to="/log/list"

                    >日志列表</router-link

                ></el-menu-item

            >

            <el-menu-item index="3-2"

                ><el-icon><Camera /></el-icon

                ><router-link to="/log/reply"

                    >日志回复</router-link

                ></el-menu-item

            >

            <el-menu-item index="3-3"

                ><el-icon><Camera /></el-icon

                ><router-link to="/log/add"

                    >日志添加</router-link

                ></el-menu-item

            >

        </el-sub-menu>

        <!-- <el-sub-menu index="1">

          <template #title>

              <el-icon><Location /></el-icon> <span>产品管理</span>

          </template>

          <el-menu-item

              :index="index"

              v-for="(menu, index) in menuList"

              :key="index"

              ><el-icon><Handbag /></el-icon

              ><router-link :to="menu.path">{{

                  menu.meta.title

              }}</router-link></el-menu-item

          >

      </el-sub-menu> -->

    </el-menu>

</template>

<script>

import {

    Location,

    Handbag,

    ReadingLamp,

    Camera,

    Mic,

    OfficeBuilding,

} from '@element-plus/icons-vue'

export default {

    props: {

        isCollapseBoolean,

    },

    components: { Location, Handbag, ReadingLamp, Camera, Mic, OfficeBuilding },

    computed: {

        menuList() {

            const list = this.$router.options.routes

            const menu = list[2].children

            const list1 = menu.filter(item => item.meta.hidden)

            return list1

        },

    },

}

</script>

<style lang="scss" scoped></style>

router→index.js

import { createRouter, createWebHistory } from 'vue-router'

import Home from '../views/Home.vue'

import Login from '../views/Login.vue'

import Index from '../views/Index.vue'

 

const router = createRouter({

    history: createWebHistory(import.meta.env.BASE_URL),

    routes: [

        {

            path: '/',

            redirect: '/login',

        },

        {

            path: '/login',

            component: Login,

        },

        {

            path: '/home',

            component: Home,

            redirect: '/index',

            children: [

                {

                    path: '/index',

                    component: Index,

                    meta: { hidden: false },

                },

            ],

        },

         // 产品管理模块

        {

            path: '/home1',

            meta: { title: '产品管理' },

            component: Home,

            children: [

                {

                    path: '/product/list',

                    component: () =>

                        import('../views/plateform/product/list.vue'),

                    meta: { hidden: true, title: '产品列表' },

                },

                {

                    path: '/product/category',

                    component: () =>

                        import('../views/plateform/product/category.vue'),

                    meta: { hidden: true, title: '产品分类' },

                },

                {

                    path: '/product/map',

                    component: () =>

                        import('../views/plateform/product/map.vue'),

                    meta: { hidden: true, title: '产品地图' },

                },

            ],

        },

        // 系统设置

        {

            path: '/home2',

            meta: { title: '账户管理' },

            component: Home,

            children: [

                {

                    path: '/account/list',

                    component: () =>

                        import('../views/plateform/account/list.vue'),

                    meta: { hidden: true, title: '账户列表' },

                },

                {

                    path: '/account/add',

                    component: () =>

                        import('../views/plateform/account/add.vue'),

                    meta: { hidden: true, title: '添加账户' },

                },

            ],

        },

        // oa管理

        {

            path: '/home3',

            meta: { title: 'OA管理' },

            component: Home,

            children: [

                {

                    path: '/log/list',

                    component: () => import('../views/plateform/log/list.vue'),

                    meta: { hidden: true, title: '日志管理' },

                },

                {

                    path: '/log/reply',

                    component: () => import('../views/plateform/log/reply.vue'),

                    meta: { hidden: true, title: '日志回复' },

                },

                {

                    path: '/log/add',

                    component: () => import('../views/plateform/log/add.vue'),

                    meta: { hidden: true, title: '添加日志' },

                },

            ],

        },

        // 404路由不存在匹配,放在路由最下面

        {

            path: '/:pathMatch(.*)*',

            component: () => import('@/views/NotPage.vue'),

        },

    ],

})

export default router

table表格组件、dialog弹框组件、upload上传组件、popconfirm气泡确认框组件、pagination分页组件

  • <el-table :data="goodsList" @selection-chanege=”onchange”>@selection-change拿到复选框选中的数据(批量删除)
  • <el-table-column prop=”date” label=”Date” type=”selection”>表格列,prop对应键名填入数据,label表格列名,type表格复选框(批量删除)
  • <el-dialog v-model=”” ttitle=””>设v-mode接收boolean,true是显示弹框
  • <el-upload>图片上传
  • <el-popconfirm>气泡确认框
  • <el-pagination>分页

增删改查功能

List.vue

<!-- eslint-disable prettier/prettier -->
<template>

    <div> 

         <!-- 搜索 -->

        <el-row :gutter="20" style="margin-bottom: 10px">

            <el-col :span="4" :offset="0">

                <el-input v-model="product.name" placeholder="名称搜索" clearable></el-input>

            </el-col>

            <el-col :span="4" :offset="0">

                <el-input v-model="product.shop" placeholder="店铺名称搜索" clearable></el-input>

            </el-col>

            <el-col :span="4" :offset="0">

                <el-input v-model="product.startPrice" placeholder="开始价格搜索" clearable></el-input>

            </el-col>

            <el-col :span="4" :offset="0">

                <el-input v-model="product.endPrice" placeholder="结束价格搜索" clearable></el-input>

            </el-col>

            <el-col :span="4" :offset="0">

                <el-button type="primary" @click="bindSearch">搜索产品</el-button>

            </el-col>

 

        </el-row>

        <el-button-group>

            <el-button type="success" size="small" @click="bindAddGood">添加</el-button>

            <el-button type="success" size="small" @click="bindRefresh">刷新</el-button>

            <el-button type="success" size="small" @click="bindBatchDelete">批量删除</el-button>

            <el-button type="success" size="small" @click="bindExcelExport">导出excel</el-button>

        </el-button-group>

        <!-- 表格 -->

        <el-table :data="goodsList" style="width: 100%" @selection-change="handleSelectionChange">

            <el-table-column type="selection" ></el-table-column>

            <el-table-column label="序列号" prop="id" width="100"></el-table-column>

            <el-table-column label="名称" prop="product"></el-table-column>

            <el-table-column label="店铺名称" prop="shop"></el-table-column>

            <el-table-column label="图片">

                <template #default="scope">

                    <!-- <el-image

                        :src="scope.row.picture?.indexOf('http') === -1 ? 'http://10.7.163.142:8089/' + scope.row.picture : scope.row.picture"

                        style="width: 100px; height: 100px"></el-image> -->

                    <el-image :src="filterUrl(scope.row.picture)" style="width: 100px; height: 100px"></el-image>

                </template>

            </el-table-column>

            <el-table-column label="价格" prop="price"></el-table-column>

            <el-table-column label="类型" prop="categoryname"></el-table-column>

            <el-table-column label="操作">

                <template #default="scope">

                    <el-button type="success" size="small" @click="bindEdit(scope.row)">编辑</el-button>

                    <!-- <el-button type="primary" size="small" @click="bindDelete(scope.row.id)">删除</el-button> -->

                    <el-popconfirm title="确认要删除此记录吗?" @confirm="bindDelete(scope.row.id)" confirm-button-text="Yes"

                        cancel-button-text="No">

                        <template #reference>

                            <el-button type="primary" size="small">删除</el-button>

                        </template>

                    </el-popconfirm>

                </template>

            </el-table-column>

        </el-table>

        <!-- 分页 -->

        <el-pagination background layout="total, sizes, prev, pager, next,jumper" :total="total"

            :page-sizes="[5, 10, 20]" @size-change="handleSizeChange" @current-change="handleCurrentChange" />

        <!-- total-总记录条数

        sizes-选择每页几条

        :page-sizes="[5,10,20]"

        jumper-跳转

        @size-change="handleSizeChange"//page-size 改变时触发

         -->

        <!-- 弹出对话框 -->

        <el-dialog :title="type === 'ADD' ? '添加产品' : '编辑产品'" v-model="dialogGoodsFormVisible" width="40%">

            <GoodsDialog v-if="dialogGoodsFormVisible" @close="bindClose" :goods="goods" :type="type"></GoodsDialog>

        </el-dialog>

    </div>

</template>

<!-- eslint-disable prettier/prettier -->

<script>

import { RequestShopList, RequestDeleteGoods, RequestBatchDeletefrom '@/api/index.js'

import GoodsDialog from '@/components/GoodsDialog.vue'

import { ElMessagefrom 'element-plus'

import { excelExport2 } from '@/utils/xlsxutil.js'

export default {

    components: {

        GoodsDialog,

    },

    data() {

        return {

            // tableData: [

            //     { id:1,name: '回锅肉', price: 30, category: '荤', url: 'https://image5.suning.cn/uimg/b2c/newcatentries/0000000000-000000000834870991_1_800x800.jpg' },

            //     { id:2,name: '土豆丝', price: 20, category: '素', url: 'https://image5.suning.cn/uimg/b2c/newcatentries/0000000000-000000000834870991_1_800x800.jpg' },

            // ]

            goodsList: [],

            dialogGoodsFormVisiblefalse,

            total'',//总记录条数

            pageSize5,//每页记录条数

            pageNo1,//当前页号

            type'ADD',//EDIT 编辑 ADD 添加

            goodsnull,

            product: {

                name'',//搜索产品名称

                shop'',//搜索店铺名称

                price'',//搜索价格

                startPrice'',

                endPrice'',

            },

            ids'',//删除商品id集合

        }

    },

    created() {

        this.getShopList()

    },

    methods: {

        filterUrl(url) {

            return url?.indexOf('http') === -1 ? 'http://10.7.163.142:8089/' + url : url

        },

        /**

         * 产品列表

         */

        async getShopList() {

            const data = await RequestShopList(this.pageSize, this.pageNo, this.product.name, this.product.shop, this.product.startPrice, this.product.endPrice)

            const { resultCode, resultInfo } = data

            if (resultCode === 1) {

                this.goodsList = resultInfo.list

                this.total = resultInfo.total// 总记录条数

            }

        },

        /**

         * 编辑产品

         */

        async bindEdit(row) {

            // console.log('row ', row)

            this.goods = row

            this.type'EDIT'

            this.dialogGoodsFormVisibletrue

        },

        /**

         * 删除商品

         */

        async bindDelete(id) {

            const data = await RequestDeleteGoods(id)

            const { resultCode } = data

            if (resultCode === 1) {

                ElMessage({

                    message'删除成功',

                    type'success',

                })

                this.getShopList()

            }

        },

        /**

         * 添加-弹出添加表单对话框

         */

        bindAddGood() {

            this.type'ADD'

            this.dialogGoodsFormVisibletrue

        },

        /**

        * 页大小改变事件

        */

        handleSizeChange(value) {

            this.pageSize = value

            this.getShopList()

        },

        /**

         * 页号改变事件

         */

        handleCurrentChange(value) {

            this.pageNo = value

            this.getShopList()

        },

        /**

         * 刷新

         */

        bindRefresh() {

            this.product = {}//重置搜索数据

            this.getShopList()

        },

        bindClose() {

            this.dialogGoodsFormVisiblefalse

            this.getShopList()

        },

        /**

         * 搜索产品

         */

        bindSearch() {

            this.getShopList()

        },

        /*

        多选

         */

        handleSelectionChange(value) {

            // console.log('value ', value) // [{id:10,name:''}] => [10,12,34] => '10,12,34'

            const list = value.map(item => item.id)

            const ids = list.join(',')

            this.ids = ids

        },

        /*

        批量删除

         */

        async bindBatchDelete() {

            if (this.ids?.split(',').length <= 0) {

                ElMessage({

                    message'请选择删除产品',

                    type'info',

                })

                return

            }

            const data = await RequestBatchDelete(this.ids)

            const { resultCode } = data

            if (resultCode === 1) {

                ElMessage({

                    message'批量删除成功',

                    type'success',

                })

                this.getShopList()

            }

        },

        /**

         * 导出excel

         */

         bindExcelExport() {

            excelExport2(

                this.goodsList,

                {

                    id'序列号',

                    product'产品名称',

                    shop'店铺名称',

                    picture'图片',

                    price'价格',

                    oldprice'原价',

                    categoryname'类型名称',

                },

                '产品列表'

            )

        },

    }

}

</script>

<!-- eslint-disable prettier/prettier -->

<style lang="scss" scoped>

</style>

GoodsDialog.vue

<!-- eslint-disable prettier/prettier -->

<template>

    <el-form :model="goodForm" ref="goodFormRef" :rules="rules" label-width="80px">

        <el-form-item label="店铺名称">

            <el-input v-model="goodForm.shop"></el-input>

        </el-form-item>

        <el-form-item label="产品名称" prop="product">

            <el-input v-model="goodForm.product"></el-input>

        </el-form-item>

        <el-form-item label="产品分类">

            <el-select v-model="goodForm.categoryId" clearable placeholder="选择产品分类">

                <el-option v-for="item in category" :key="item.id" :label="item.name" :value="item.id" />

            </el-select>

        </el-form-item>

        <el-form-item label="现价">

            <el-input v-model="goodForm.price"></el-input>

        </el-form-item>

        <el-form-item label="原价">

            <el-input v-model="goodForm.oldprice"></el-input>

        </el-form-item>

        <el-form-item label="图片">

            <!--

                list-type: picture-card 卡片样式

                action: 图片上传url地址

                auto-upload: 是否自动上传,true自动上传,选中图片直接上传到action指定url

                show-file-list: false单文件上传,true支持多文件上传

                before-upload: 上传前回调, 当前auto-upload为true时执行,回调函数中返回false终止上传

             -->

            <!-- <el-input v-model="goodForm.picture"></el-input> -->

            <el-upload list-type="picture-card" action="#" :auto-upload="true" :show-file-list="false"

                :before-upload="beforeAvatarUpload">

                <img v-if="imageUrl" :src="imageUrl" class="avatar" />

                <el-icon v-else>

                    <Plus />

                </el-icon>

            </el-upload>

        </el-form-item>

        <el-form-item label="上下架">

            <el-radio-group v-model="goodForm.putaway">

                <el-radio :label="1">上架</el-radio>

                <el-radio :label="0">下架</el-radio>

            </el-radio-group>

        </el-form-item>

        <el-form-item>

            <el-button type="primary" @click="onSubmit">确定</el-button>

            <el-button type="primary" @click="onClose">关闭</el-button>

        </el-form-item>

    </el-form>

</template>

<!-- eslint-disable prettier/prettier -->

<script>

import {

    RequestCategoryList,

    RequestAddGoods,

    RequestUpdateGoods,

} from '@/api/index.js'

import { ElMessagefrom 'element-plus'

import { Plusfrom '@element-plus/icons-vue'

export default {

    components: {

        Plus,

    },

    props: ['goods', 'type'],

    emits: ['close'],

    data() {

        return {

            goodForm: {

                shop'',

                product'',

                price'',

                oldprice'',

                picture'',

                putaway1,

                categoryId'',

            },

            category: [

                // { label: '海鲜', value: 1 },

                // { label: '川湘菜', value: 2 },

                // { label: '日韩料理', value: 3 },

            ],

            rules: {

                product: [

                    {

                        requiredtrue,

                        message'请输入产品名称',

                        trigger'blur',

                    },

                ],

            },

            imageUrl'', //图片预览地址

            imageFilenull, //上传图片文件

        }

    },

    created() {

        if (this.type === 'EDIT') {

            this.goodForm = { ...this.goods }

            this.imageUrlthis.filterUrl(this.goodForm.picture)

        }

        this.getCategoryList()

    },

    methods: {

        filterUrl(url) {

            return url?.indexOf('http') === -1

                ? 'http://10.7.163.142:8089/' + url

                : url

        },

        /**

         * 分类列表

         */

        async getCategoryList() {

            const data = await RequestCategoryList()

            const { resultCode, resultInfo } = data

            if (resultCode === 1) {

                this.category = resultInfo.list

            }

        },

        /**

         * 保存产品

         *  图片上传http注意事项

         *     1. post请求

         *     2. 请求头header 上传内容类型 content-type:multipart/form-data

         *         headers: { 'Content-Type': 'multipart/form-data' },

         *     3. FormData

         *         const formData = new FormData()

         *         formData.append('product',product)

         *         formData.append('picture',file)

         */

        async onSubmit() {

            // if (this.type === 'ADD') {

            //     // console.log('this.goodForm ', this.goodForm)

            //     const formData = new FormData()

            //     formData.append('categoryId', this.goodForm.categoryId)

            //     formData.append('product', this.goodForm.product)

            //     formData.append('picture', this.imageFile)

            //     formData.append('shop', this.goodForm.shop)

            //     formData.append('price', this.goodForm.price)

            //     formData.append('oldprice', this.goodForm.oldprice)

            //     formData.append('putaway', this.goodForm.putaway)

            //     const data = await RequestAddGoods(formData)

            //     const { resultCode } = data

            //     if (resultCode === 1) {

            //         ElMessage({

            //             message: '添加产品成功!',

            //             type: 'success',

            //         })

            //     }

            //     this.$emit('close')

            // } else {

            //     const formData = new FormData()

            //     formData.append('id', this.goodForm.id)

            //     formData.append('categoryId', this.goodForm.categoryId)

            //     formData.append('product', this.goodForm.product)

            //     formData.append('picture', this.imageFile)

            //     formData.append('shop', this.goodForm.shop)

            //     formData.append('price', this.goodForm.price)

            //     formData.append('oldprice', this.goodForm.oldprice)

            //     formData.append('putaway', this.goodForm.putaway)

            //     const data = await RequestUpdateGoods(formData)

            //     const { resultCode } = data

            //     if (resultCode === 1) {

            //         ElMessage({

            //             message: '编辑产品成功!',

            //             type: 'success',

            //         })

            //     }

            //     this.$emit('close')

            // }

            // console.log('this.goodForm ', this.goodForm)

            const formData = new FormData()

            formData.append('categoryId', this.goodForm.categoryId)

            formData.append('product', this.goodForm.product)

            formData.append('picture', this.imageFile)

            formData.append('shop', this.goodForm.shop)

            formData.append('price', this.goodForm.price)

            formData.append('oldprice', this.goodForm.oldprice)

            formData.append('putaway', this.goodForm.putaway)

            // 添加  编辑

            let data

            if (this.type === 'EDIT') {

                formData.append('id', this.goodForm.id)

                data = await RequestUpdateGoods(formData)

            } else {

                data = await RequestAddGoods(formData)

            }

            const { resultCode } = data

            if (resultCode === 1) {

                ElMessage({

                    messagethis.type === 'EDIT' ? '编辑产品成功!' : '添加产品成功!',

                    type'success',

                })

            }

            this.onClose()

        },

        onClose() {

            this.$emit('close')

        },

        /**

         * 文件上传之前执行

         */

        beforeAvatarUpload(rawFile) {

            const arr = ['image/jpeg', 'image/png', 'image/jpg']

            // 图片格式验证

            if (!arr.includes(rawFile.type)) {

                ElMessage({

                    message'上传图片格式不正确!!',

                    type'error',

                })

                return false

            }

            // 图片大小验证

            if (rawFile.size1024 / 1024 > 2) {

                ElMessage({

                    message'上传图片大小不能超过2M!',

                    type'error',

                })

                return false

            }

            // 图片预览

            //1. 选中的本地图片转成Base64编码 赋值给 imageUrl

            //2. FileReader 读文件

            this.imageUrlURL.createObjectURL(rawFile)

            // 上传图片

            this.imageFile = rawFile

            return false // 不向下执行

        },

    },

}

</script>

<!-- eslint-disable prettier/prettier -->
<style lang="scss" scoped>

.avatar {
    width: 170px;
    height: 170px;
    display: block;
}

</style>

util→request.js

import axios from 'axios'

import { ElMessage } from 'element-plus'

 

const instance = axios.create({

    baseURL'http://10.7.163.142:8089', //服务器根地址

    // baseURL: 'http://127.0.0.1:5173', //服务器根地址

    timeout3000, //超时时间

})

/**

 * 请求拦截器

 */

instance.interceptors.request.use(

    config => {

        // 请求拦截处理

        // console.log('请求拦截处理 >>> ', config)

        const token = localStorage.getItem('TOKEN')

        if(token){

            config.headers['Authorization'] = token

        }

        // token && config.headers['Authorization'] = 'token'

        return config

    },

    error => {

        // 请求出错处理

        return Promise.reject(error)

    }

)

/**

 * 响应拦截器

 */

instance.interceptors.response.use(

    response => {

        // 响应拦截处理

        // console.log('响应拦截器处理 >>> ', response)

        // return response

        return response.data

    },

    error => {

        const { response } = error

        if (response) {

            const status = response.status

            switch (status) {

                case 404:

                    // console.log('资源不存在 404')

                    ElMessage({

                        message'资源不存在 404',

                        type'error',

                    })

                    break

                case 401:

                    // console.log('Unauthorized 身份验证凭证缺失!')

                    ElMessage({

                        message'Unauthorized 身份验证凭证缺失!',

                        type'error',

                    })

                    break

                case 403:

                    // console.log('403 Forbidden - 拒绝访问!')

                    ElMessage({

                        message'403 Forbidden - 拒绝访问!',

                        type'error',

                    })

                    break

                case 500:

                    // console.log('服务器出错!')

                    ElMessage({

                        message'服务器出错!',

                        type'error',

                    })

                    break

                default:

                    // console.log('出现异想不到的错误!')

                    ElMessage({

                        message'出现异想不到的错误!',

                        type'error',

                    })

                    break

            }

        } else {

            // 说明服务器连结果都没有返回,可能的原因有两种:

            /**

             * 1. 服务器崩掉了

             * 2. 前端客户端断网状态

             */

            if (!window.navigator.onLine) {

                // 判断为断网,可以跳转到断网页面

                // console.log('网络不可用,请检查您的网络连接!')

                ElMessage({

                    message'网络不可用,请检查您的网络连接!',

                    type'error',

                })

                return

            } else {

                // console.log('连接服务端出错!' + error?.message)

                ElMessage({

                    message'连接服务端出错!' + error?.message,

                    type'error',

                })

                return Promise.reject(error)

            }

        }

        return Promise.reject(error)

    }

)

export default instance

api→index.js

/* eslint-disable prettier/prettier */

import instance from "@/utils/request.js";

/**

 * 登录接口

 * @param {*} username

 * @param {*} password

 * @returns

 */

export const RequestLogin = (username, password) => {

  return instance({

    method"post",

    url"/api/login",

    //post请求参数使用data选项, get参数 params选项

    data: {

      username,

      password,

    },

  });

};

 

/**

 * 商品列表接口

 * @returns

 */

export const RequestShopList = (pageSize,pageNo,productKey,shopKey,startPrice,endPrice) => {

  return instance({

    method"get",

    url"/api/shop",

    //post请求参数使用data选项, get参数 params选项

    params:{

      pageSize,

      pageNo,

      productKey,//商品名

      shopKey,//产品名

      startPrice,

      endPrice

    }

  });

};

/**

 * 商品分类接口

 * @returns

 */

export const RequestCategoryList = () => {

  return instance({

    method"get",

    url"/api/category",

  });

};

/**

 * 添加产品

 */

export const RequestAddGoods = (formData)=>{

  // const token = localStorage.getItem('TOKEN')

  return instance({

      method:'post',

      url:'/api/shop/insert',

      headers: { 'Content-Type''multipart/form-data' },

      data:formData

  })

}

/**

 * 删除产品

 */

export const RequestDeleteGoods = (id)=>{

  // const token = localStorage.getItem('TOKEN')

  return instance({

      method:'get',

      url:'/api/shop/delete',

      params:{

        id

      }

  })

}

/**

 * 编辑产品

 */

export const RequestUpdateGoods = (formData)=>{

  // const token = localStorage.getItem('TOKEN')

  return instance({

      method:'post',

      url:'/api/shop/edit',

      headers: { 'Content-Type''multipart/form-data' },

      data:formData

  })

}

/**

 * 批量删除产品

*  ids: 商品id '1,2,3,4'

 */

export const RequestBatchDelete = (ids)=>{

  // const token = localStorage.getItem('TOKEN')

  return instance({

      method:'get',

      url:'/api/shop/batchdelete',

      params:{

        ids

      }

  })

}

一天通关HTML80%核心细节(新手友好版)

2026年4月27日 18:20

一、基础认知

1.HTML 是超文本标记语言(HyperText Markup Language) ,只有标签,没有逻辑、变量、循环,本质是给浏览器看的 "说明书",告诉浏览器页面结构是什么样的。

2.HTML5 不是新语言,HTML5 是 HTML 的第 5 个大版本,我们现在写的所有 HTML 代码,本质都是 HTML5。

3.即使你写错标签、少写闭合、语法错误,浏览器也会尽量渲染页面,不会直接报错。这是 HTML 最友好也最坑人的地方 —— 代码错了但看起来正常,留下隐形 bug。

二、文档结构: 新手都会忽略的细节

image.png

  • <!DOCTYPE html> 是 HTML5 独有的极简声明,老式 HTML 的声明长达 3 行,已经完全淘汰

  • lang="zh-CN" 不要写成 lang="zh",前者更标准

  • meta charset 必须写在 head 的最前面,否则可能出现乱码

  • viewport 标签是移动端适配的基础,没有它手机上页面会缩小

三、最容易踩坑的基础属性

  1. id vs name:90% 新手分不清
属性 作用
id 唯一标识标签
作用 给谁用
前端(label 绑定、JS 查找、CSS 选择器) 全局唯一,一个页面只能有一个相同 id
name 表单提交传数据
后端服务器 可以重复(比如单选框、复选框)

超级大坑

  • 表单提交时,只有带 name 属性的元素才会被发送到后端

  • id 不会被提交到后端,和后端完全无关

  • 写成一样只是为了好记,不是必须一样

2. class vs id

  • id:唯一标识,一个标签只能有一个 id,一个页面只能有一个相同 id
  • class:通用类名,一个标签可以有多个 class,一个页面可以有多个相同 class
  • 优先级:id > class > 标签选择器

3. 路径问题

  • 绝对路径:https://xxx.com/image.jpg
  • 相对路径:./image.jpg(当前目录)、../image.jpg(上级目录)
  • 根路径:/image.jpg(网站根目录)

坑点:本地打开 HTML 文件时,根路径会指向电脑磁盘根目录,而不是项目文件夹,导致资源加载失败。

四、核心标签深度细节

  • <h1> 一个页面只能有一个,SEO 权重最高

  • <p> 段落标签,会自动在上下添加边距

  • <br> 强制换行,单标签,不要写成 </br>

  • <hr> 水平线,单标签

  • <strong> 语义化加粗,表示重要内容;<b> 只是视觉加粗,无语义

  • <em> 语义化斜体,表示强调内容;<i> 只是视觉斜体,无语义

标签 作用 替代的旧写法
<header> 网页头部、板块头部 <div class="header">
<nav> 导航栏 <div class="nav">
<section> 独立内容板块 <div class="section">
<article> 独立文章、帖子、评论 <div class="article">
<aside> 侧边栏 <div class="aside">
<footer> 网页底部、板块底部 <div class="footer">

最佳实践

  • 大块独立内容用 section,纯布局用 div
  • article 是特殊的 section,代表可以独立存在的内容
  • 不要滥用语义化标签,纯排版用 div 就好

3. 列表标签

  • 无序列表 <ul>:只能包含 <li> 子元素
  • 有序列表 <ol>:只能包含 <li> 子元素
  • 自定义列表 <dl>:包含 <dt>(标题)和 <dd>(描述)

4. 超链接 <a>

image.png

  • target="_blank":在新标签页打开

  • rel="noopener noreferrer":安全属性,防止新页面获取原页面信息,必须加

  • href="#":空链接,点击会跳转到页面顶部

  • href="javascript:;":空链接,点击无反应

5. 图片 <img>

image.png

  • alt 属性:图片加载失败时显示的文字,SEO 和无障碍必备

  • 不要用 width 和 height 属性设置图片大小,应该用 CSS

  • 图片是行内块元素,默认底部会有 3px 的空白间隙

五、表单全解:HTML 最核心的交互部分

1. 表单基础结构

image.png

  • action:表单提交的后端 API 地址

  • method:提交方式,常用 GET 和 POST

    • GET:数据拼在 URL 后面,长度有限,不安全
    • POST:数据放在请求体中,长度无限制,安全
  • required:HTML5 自带验证,必填项

2. 常用 input 类型

type 值 作用 HTML5 专属
text 单行文本输入框
password 密码框,输入内容掩码
radio 单选框,相同 name 为一组
checkbox 复选框,相同 name 为一组
submit 提交按钮,点击自动提交表单
button 普通按钮,无默认行为
email 邮箱输入框,自带格式验证
number 数字输入框
date 日期选择器
time 时间选择器
tel 电话号码输入框
search 搜索框

超级重要

  • 单选框必须设置相同的 name 才能实现单选效果
  • 复选框的 name 应该写成数组形式:name="hobby[]",后端才能接收多个值
  • <input type="submit"> 自带提交功能,不需要写任何 JS

3. 其他表单元素

  • <select>:下拉选择框,包含 <option> 子元素
  • <textarea>:多行文本域,不能用 value 属性设置默认值,默认值写在标签中间
  • <button>:按钮标签,比 input 按钮更灵活,可以包含图片、文字等内容

六、HTML5 专属核心特性

1. 多媒体标签

image.png

  • controls:显示播放控件

  • poster:视频封面图

  • 标签中间的文字是浏览器不支持时的备用内容

  1. Canvas 画布

image.png

  • Canvas 是用 JS 绘图的画布,适合画复杂图形、动画、游戏

  • getContext("2d") 是固定写法,开启 2D 绘图模式

  • 3D 绘图用 getContext("webgl")

  1. SVG 矢量图

image.png

  • SVG 是用标签画的矢量图,放大不会失真

  • 适合画图标、简单图形

  • 可以直接嵌入 HTML,也可以作为外部文件引入

4. 本地存储

  • localStorage:永久存储,关闭浏览器不会消失
  • sessionStorage:会话存储,关闭浏览器就消失

七、容易混淆的标签对比

标签 A 标签 B 核心区别
<div> <section> div 纯布局,section 代表独立内容板块
<section> <article> article 是特殊的 section,代表可以独立存在的内容
<b> <strong> b 只是视觉加粗,strong 语义化加粗,表示重要
<i> <em> i 只是视觉斜体,em 语义化斜体,表示强调
<input type="button"> <button> input 按钮只能显示文字,button 按钮可以包含任意内容
<img> <canvas> img 显示图片,canvas 用 JS 绘图
<canvas> <svg> canvas 是位图,放大失真;svg 是矢量图,放大不失真

八、冷门但实用的小标签

  • <details><summary>:折叠面板
  • <mark>:高亮文字
  • <sup><sub>:上标和下标
  • <blockquote>:长引用
  • <code>:行内代码
  • <pre>:预格式化文本,保留空格和换行

九、企业级最佳实践

  • 语义化优先:能用语义化标签就不用 div,提升 SEO 和无障碍

  • 结构和样式分离:不要用行内样式,所有样式都写在 CSS 文件中

  • 标签闭合:所有标签都要正确闭合,单标签不要写闭合标签

  • 属性小写:所有标签和属性都用小写字母

  • alt 属性必写:所有图片都要加 alt 属性

  • label 必绑 input:所有输入框都要有对应的 label 标签

  • 不要用废弃标签<font><center><strike>等已经被 HTML5 淘汰,全部用 CSS 代替

  • 移动端适配:必须加 viewport 标签

  • 安全:外链加 rel="noopener noreferrer"

像使用 Redis 一样操作 LocalStorage

2026年4月27日 18:10

参考 Redis 缓存接口封装 LocalStorage,在全栈项目中保持一致的缓存操作体验,降低前端同学转向全栈开发的心智负担

1. 为什么需要 Redis 风格的 LocalStorage 封装

在全栈开发中,后端通常使用 Redis 进行缓存管理,而前端则使用 LocalStorage 存储本地数据。两者的 API 接口差异较大,这给从前端转向全栈的开发者带来了额外的学习成本。

通过封装一个 Redis 风格的 LocalStorage 工具类,我们可以:

  • 保持前后端缓存操作接口一致
  • 提供更丰富的缓存管理功能(如过期时间、键匹配等)
  • 增强代码的可维护性和可读性
  • 为未来可能的后端迁移做好准备

2. 核心功能实现与代码解析

完整工具类实现

export abstract class CacheUtil {
  /**
   * 设置缓存
   * @param key 缓存键
   * @param value 缓存值
   * @param ttl 过期时间(单位:秒),-1 表示永不过期
   */
  static set(key: string, value: any, ttl: number = -1) {
    const data = { value, ttl: ttl === -1 ? ttl : Date.now() + ttl * 1000 }
    localStorage.setItem(key, JSON.stringify(data))
  }

  /**
   * 获取缓存
   * @param key 缓存键
   * @param defaultValue 缓存不存在或过期时的默认值
   * @returns 缓存值或默认值
   */
  static get<T = any>(key: string, defaultValue: T | null = null): T | null {
    try {
      const jsonStr = localStorage.getItem(key)
      if (!jsonStr) return defaultValue
      const data = JSON.parse(jsonStr)
      if (data.ttl === -1 || Date.now() <= data.ttl) return data.value
      localStorage.removeItem(key)
      return defaultValue
    } catch (error: unknown) {
      localStorage.removeItem(key)
      return defaultValue
    }
  }

  /**
   * 获取缓存剩余过期时间(秒)
   * -1 = 永久有效
   * -2 = 已过期/不存在
   */
  static ttl(key: string): number {
    try {
      const item = localStorage.getItem(key)
      if (!item) return -2
      const data = JSON.parse(item)
      if (data.ttl === -1) return -1
      const remaining = data.ttl - Date.now()
      return remaining > 0 ? Math.floor(remaining / 1000) : -2
    } catch {
      return -2 // 解析失败,视为无效缓存
    }
  }

  /**
   * 动态设置缓存过期时间
   * @param key 缓存键
   * @param ttl 过期时间(秒)
   * @returns 是否设置成功
   */
  static expire(key: string, ttl: number): boolean {
    const value = this.get(key)
    if (value === null) return false
    this.set(key, value, ttl)
    return true
  }

  /**
   * 删除缓存
   * @param key 缓存键
   */
  static del(key: string) {
    localStorage.removeItem(key)
  }

  /**
   * 清空所有缓存
   */
  static flushall() {
    localStorage.clear()
  }

  /**
   * 查找缓存键(支持通配符 *,和 Redis 用法一致)
   * @param pattern 匹配规则,例如 user*、*info、*token*,默认 *
   * @returns 匹配的键数组
   */
  static keys(pattern: string = '*'): string[] {
    const allKeys = Object.keys(localStorage)
    const regex = new RegExp(pattern.replace(/\*/g, '.*'))
    return allKeys.filter((key) => regex.test(key))
  }

  /**
   * 检查缓存是否存在且未过期
   * @param key 缓存键
   * @returns 是否存在有效缓存
   */
  static exists(key: string): boolean {
    return this.get(key) !== null
  }
}

核心设计要点

  1. 数据结构设计:使用 { value, ttl } 结构存储缓存数据,其中 ttl 为过期时间戳或 -1(永不过期)

  2. 过期时间处理

    • 设置时计算绝对过期时间戳
    • 获取时检查是否过期,过期则自动清理
    • 提供 ttl 方法查看剩余过期时间
  3. 错误处理:通过 try-catch 捕获 JSON 解析异常,确保缓存操作的稳定性

  4. Redis 风格 API:实现了与 Redis 相似的 setgetdelexpirekeysexists 等方法

  5. 通配符支持keys 方法支持 * 通配符匹配,与 Redis 用法一致

3. 完整 API 接口说明

方法 功能描述 参数说明 返回值
set(key, value, ttl) 设置缓存 key: 缓存键
value: 缓存值
ttl: 过期时间(秒),默认 -1
get(key, defaultValue) 获取缓存 key: 缓存键
defaultValue: 默认值,默认 null
缓存值或默认值
ttl(key) 获取剩余过期时间 key: 缓存键 -1: 永久有效
-2: 已过期/不存在
正数: 剩余秒数
expire(key, ttl) 设置过期时间 key: 缓存键
ttl: 过期时间(秒)
是否设置成功
del(key) 删除缓存 key: 缓存键
flushall() 清空所有缓存
keys(pattern) 查找匹配的键 pattern: 匹配规则,默认 * 匹配的键数组
exists(key) 检查缓存是否存在 key: 缓存键 是否存在有效缓存

4. 实战使用示例

基础操作

// 设置缓存,1小时过期
CacheUtil.set('USER', { id: 1, name: 'John' }, 3600)

// 获取缓存
const user = CacheUtil.get('USER')
console.log(user) // { id: 1, name: 'John' }

过期时间管理

// 续期缓存,设置为2小时过期
CacheUtil.expire('USER', 7200)

// 查看剩余过期时间
const remainingTime = CacheUtil.ttl('USER')
console.log(`剩余过期时间:${remainingTime}秒`)

键管理

// 通配符查找键
const userKeys = CacheUtil.keys('USER*')
const infoKeys = CacheUtil.keys('*INFO')
console.log('用户相关键:', userKeys)
console.log('信息相关键:', infoKeys)

// 检查缓存是否存在
const exists = CacheUtil.exists('USER')
console.log('USER 缓存存在:', exists)

删除操作

// 删除指定缓存
CacheUtil.del('USER')

// 清空所有缓存
CacheUtil.flushall()

5. 性能考量与最佳实践

性能考量

  1. 存储限制:LocalStorage 通常有 5MB 左右的存储限制,避免存储过大的数据

  2. 读取性能:频繁读取大对象会影响性能,建议将数据合理拆分

  3. 过期检查:每次 get 操作都会检查过期时间,对性能影响较小但需注意

  4. JSON 序列化:复杂对象的序列化/反序列化会有性能开销,建议存储结构尽量简单

最佳实践

  1. 命名规范:使用统一的命名前缀(如 APP_)避免键名冲突

  2. 数据类型:只存储必要的数据,避免存储整个应用状态

  3. 过期策略:为临时数据设置合理的过期时间,避免占用存储空间

  4. 错误处理:虽然工具类已做了错误处理,但调用时仍需考虑异常情况

  5. 安全注意:不要存储敏感信息(如密码、Token)到 LocalStorage


你在项目中是如何管理本地缓存的?有哪些好用的缓存策略或工具推荐?欢迎在评论区分享你的经验和想法!

基于 Cursor 实现智能代码审查skill

作者 review44543
2026年4月27日 18:08

方法一:用 Cursor Rules (.cursorrules)快速体验效果

文件位置:在项目根目录创建 .cursorrules 文件。

定义规则:在其中明确你的审查标准和偏好。

## 项目概述
- 这是一个基于 React + JavaScript 的前端项目。
- 优先使用函数式组件和 Hooks。

## 目录边界
; - 只允许修改:src/、components/、utils/ 下的文件。
; - 禁止修改:node_modules/、.next/、dist/ 等构建产物目录。

## 代码规范
- 使用 `const` 或 `let`,禁止 `var`。
- 统一为项目其它文件的“有分号”风格
- 统一引号为双引号

## 安全红线
- 禁止使用 `dangerouslySetInnerHTML`,除非配合 DOMPurify 使用。
- 禁止将 API 密钥、Token 等硬编码在代码中。

## 输出要求
- 在进行代码审查时,先输出审查计划。
- 按 "严重程度(高/中/低)" 列出问题,并提供修复建议。

测试

要确保 .cursorrules 生效,可以分三步来验证:

1、验证加载状态:在 Cursor 的设置里找到 Rules 选项卡,如果能看到你的 .cursorrules 文件在列表中,就说明它已经被成功加载了。

2、使用明确的测试指令:不要空泛地提问,要用自然但具体的语言,直接要求 AI 在代码中执行你设定的规则。例如:

如果规则要求“不能使用 var”,你可以指示 AI:“根据项目规范,将所有 var 声明替换为 const 或 let。”

如果规则定义了“组件 Props 必须有类型”,可以要求:“给 UserProfile 组件添加缺失的 Props 类型定义。”

3、通过实战测试验证:可以故意在一个小文件里写一段“违规”代码,然后用 Cmd/Ctrl + K 或 Cursor Chat,指示 AI 帮你修复。如果 AI 按规则要求修改了代码,就说明规则生效了。

结果如下: image.png

方法二:用 cursor 内置 “AI审查” (Agent Review)快速体验效果

在 AI 生成代码的 Diff 视图中:通常会有一个 Agent Review 的入口,如下图:

image.png

点击自动 review 代码。

我用AI做了一个微信小程序,全程没写一行代码

作者 牛奶
2026年4月27日 18:01

从想法到上线,只用了2周,0成本,0代码基础也能看懂


原文地址

墨渊书肆/我用AI做了一个微信小程序,全程没写一行代码


起因:家族群里,每天都是"震惊体"养生文

去年回家,发现家族群里每天刷屏的都是:

"震惊!吃这个能活到99岁!" "央视曝光!这三种食物千万不能一起吃!" "转给家人看!太有用了!"

我妈手机里装了七八个养生类APP,每天花两三个小时看各种文章和视频。

"这个说吃这个好,那个说吃那个好,我都搞不清听谁的。"

更让我头疼的是,她经常把一些明显是谣言的文章转发给我:

"快看看这个,说吃绿豆能治高血压!" "这个视频你一定要看,太可怕了!"

我一条条辟谣,她一句"宁可信其有"把我堵回来。

一个60岁的人,只是想获得靠谱的养生知识,怎么就这么难?

市面上的养生内容要么太专业看不懂,要么太夸张不靠谱,要么就是各种弹窗广告,要么就是需要登录才能看。

于是我想:能不能做一个真正适合长辈用的小程序?

  • 不用登录,打开就看
  • 字要大,按钮要大
  • 内容要靠谱,来源要清晰
  • 功能要简单,一个页面只做一件事
  • 低密度的广告干扰(至少初期没有广告)

技术选型:为什么选微信小程序+云开发

我评估了几个方案:

方案 开发成本 运维成本 长辈使用门槛
原生APP 高(服务器+域名) 高(下载安装)
H5网页 中(服务器) 中(浏览器打开)
微信小程序 低(云开发免费额度) 低(微信内打开)

最终选了微信小程序+云开发,原因很简单:

  1. 长辈不用下载,微信里搜一下就能用
  2. 云开发免费额度够用,前期0成本
  3. 不用管服务器,不用备案域名

AI全程辅助:从0到1的开发过程

第一步:让AI帮我设计产品

我把需求告诉AI:

"我想做一个面向中老年人的养生小程序,不用登录,打开就能看每日养生知识和实用工具。帮我设计信息架构。"

AI给了我一个清晰的页面结构:

3个Tab:养生(首页)/ 工具(12个工具)/ 设置
首页:每日一签 + 养生知识列表
工具:健康自测、菜谱、食物搭配、急救、节气、黄历...

关键决策:AI建议我把功能拆成12个独立工具页,每个页面只做一件事。这个建议非常对,长辈不需要复杂的多功能页面。

第二步:用Trae让AI直接开发

我用的工具是 Trae(字节跳动出的AI编程IDE),开发方式很简单:

  1. 用自然语言描述需求 → 告诉Trae我要什么功能
  2. Trae直接改代码 → 自动在编辑器里生成/修改文件
  3. 我预览效果 → 在微信开发者工具里看效果
  4. 报错反馈 → 把错误信息告诉Trae,它自动修复

举个实际例子:

"帮我写一个菜谱列表页,从云函数获取数据,支持分类筛选和搜索,适老化设计,字号要大。"

Trae直接输出了完整的页面代码,包括:

  • 搜索栏
  • 分类标签
  • 菜谱卡片列表
  • 加载状态
  • 空状态

我只需要看效果,不满意就继续提要求,它会自动调整。

整个过程就像跟一个懂技术的助手对话,我说需求,它写代码。

第三步:云函数也让AI写

云开发的核心是云函数,之前我完全没写过云函数,但AI帮我搞定了:

"写一个云函数,定时从正规来源抓取养生知识,过滤掉广告,存到数据库。"

AI输出了完整的云函数代码,包括:

  • HTML解析逻辑
  • 内容过滤规则
  • 数据库写入
  • 错误处理

我只需要在云开发控制台点"上传",就部署好了。

第四步:适老化设计,AI比我更懂

我原本以为适老化就是"字大一点",但AI给了我一整套设计规范:

  • 字号:正文≥36rpx,标题≥42rpx
  • 色彩:暖米白背景#FFF8F3,不用冷色调
  • 交互:只用单击,禁用长按/滑动
  • 反馈:错误提示用口语化中文+emoji("网络休息中 😴")

这些细节,如果不是AI提醒,我根本想不到。

踩过的坑

坑1:定时触发器不会自动创建

我以为上传了云函数的config.json,定时器就会自动生效。结果等了两天,数据一条没抓取。

真相:定时触发器需要在云开发控制台手动创建,config.json只是配置模板。

坑2:小程序审核不通过

第一次提交审核,被拒了:

"你的小程序服务内容涉及信息资讯,信息资讯属于个人主体未开放服务类目。"

原因:首页做得太像资讯平台了,有列表、有分类、有"每日推荐"标题。

解决

  • 把页面标题从"每日推荐"改成"每日养生"
  • 卡片去掉时间和来源,突出养生分类标签
  • 整体视觉更像"知识卡片"而不是"资讯列表"
  • 审核备注写明"养生健康知识分享工具,非资讯平台"

第二次就过了。

坑3:分享卡片打开是空白页

用户从分享卡片点进来,先显示"加载中...",体验很差。

解决:分享时把标题和摘要通过URL参数传递,详情页一进来就能渲染内容,后台再异步加载完整数据。

最终成果

12个工具

工具 数据来源
健康自测 本地静态数据
家常菜谱 聚合数据API
历史今天 聚合数据API
食物搭配 本地静态数据
急救知识 本地静态数据
节气养生 lunar-javascript库
今日黄历 lunar-javascript库
生日转换 lunar-javascript库
吉日查询 lunar-javascript库
道教日历 lunar-javascript库
佛教日历 lunar-javascript库
八字排盘 lunar-javascript库

成本

项目 费用
微信云开发 0元(免费额度)
聚合数据API 0元(免费额度)
合计 0元/月

代码量

  • 前端页面:18个
  • 云函数:7个
  • 总代码行数:约3000行
  • 我自己写的代码:0行(全是AI生成,我只负责复制粘贴和调试)

一些感悟

1. AI不是替代开发者,而是放大开发者的能力

我不会Node.js,但AI帮我写出了云函数;我不懂适老化设计,但AI给了我一整套规范。

AI的价值不在于写代码,而在于把想法变成现实的速度。

2. 适老化不是"字大一点"那么简单

真正的适老化是:

  • 零学习成本(不用教就会用)
  • 容错友好(点错了能回来)
  • 情感化设计(错误提示不冰冷)

这些细节,决定了长辈愿不愿意继续用。

3. 一个人也能做出完整的产品

以前我觉得做产品需要:产品经理+UI设计+前端+后端+测试。

现在我发现:一个人+AI = 一个团队

体验方式

微信搜索「乐龄闲读」,或者扫码:

lelin.jpg

如果你家里也有长辈,欢迎让他们试试。字大、简单、不用登录,打开就能用。

也欢迎开发者朋友交流AI辅助开发的经验。

原型链核心原理:prototype 与 proto 的闭环与查找规则

2026年4月27日 17:46

深入理解 JavaScript 原型链

1. 为什么需要原型链?

在 JavaScript 中,我们需要一种机制让对象之间共享方法或属性。如果每个实例都复制一份方法,内存会被大量浪费。原型链机制让对象可以“借用”其构造函数的 prototype 上的属性与方法,实现内存共享与行为继承。

简单来说:原型链就是 JavaScript 对象之间“自己找不到就找长辈”的查找规则。

2. 核心三角:prototype、proto、constructor

每一个构造函数(用 function 定义或 class 声明的函数)都拥有一个 prototype 属性,指向一个对象,这个对象就是该构造函数所创建实例的原型。原型对象内会有一个 constructor 属性指回构造函数。而每一个对象(实例)都拥有一个 __proto__ 属性指向其构造函数的 prototype。这三者构成了一个闭环。

属性 归属 指向 作用
prototype 函数(构造函数) 原型对象 存放共享属性和方法
__proto__ 所有对象(包括函数) 该对象的原型 构成查找链
constructor 原型对象 关联的构造函数 标识对象的构造来源

速记函数有 prototype,对象有 proto,原型有 constructor

function Person(name) {
  this.name = name;
}
Person.prototype.sayHi = function() {
  console.log('Hi, I am ' + this.name);
};

const p = new Person('Alice');

// 验证闭环
p.__proto__ === Person.prototype;       // true
Person.prototype.constructor === Person; // true

如图:

image.png

2.1 谁有 prototype?——不是所有函数都有

很多人以为“函数都有 prototype”,其实只有可以作为构造函数调用的函数,JavaScript 引擎才会为它自动创建 prototype 对象

有 prototype 的函数

// 函数声明
function foo() {}
console.log(foo.hasOwnProperty('prototype')); // true

// 匿名函数表达式
const bar = function() {};
console.log(bar.hasOwnProperty('prototype')); // true

// class(本质是函数)
class Person {}
console.log(Person.hasOwnProperty('prototype')); // true

// 内置构造函数
console.log(Object.hasOwnProperty('prototype')); // true
console.log(Array.hasOwnProperty('prototype'));  // true

这些函数的 prototype 都是引擎自动创建的普通对象,内部结构如下:

{
  constructor: 该函数,
  [[Prototype]]: Object.prototype
}

没有 prototype 的函数

函数类型 示例 为何没有
箭头函数 () => {} 不能作为构造函数,无 [[Construct]] 内部方法
方法简写 { method() {} } 同上,纯粹的执行逻辑
bind 返回的函数 fn.bind(null) 本质仍是原函数,不具备独立构造能力

验证:

const arrow = () => {};
new arrow(); // TypeError: arrow is not a constructor

记忆口诀new 就有 prototype,不能 new 就没有。

2.2 prototype 与 proto 的方向相反

站在不同主体的视角看,prototype__proto__ 恰好是反方向的:

主体 属性 指向 比喻
构造函数 prototype 它的原型对象 向下(父亲为孩子准备的模板)
实例 / 对象 __proto__ 它的原型 向上(孩子去查父亲的模板)

一句话总结prototype 是构造函数“向下分发”的入口,__proto__ 是实例“向上查找”的路径。前者是模板的提供方,后者是模板的使用方。原型链正是靠这一下一上的配合,才实现了共享与继承。

3. 原型链查找机制

当访问对象的属性或方法时:

  1. 先在自身属性中找。
  2. 找不到,则沿着 __proto__ 向上一级原型对象中找。
  3. 若仍未找到,继续沿 __proto__ 向上,直到 Object.prototype
  4. Object.prototype.__proto__null,查找结束,返回 undefined

这就是原型链的实质:一条由 __proto__ 串联起来的对象查找路径。

p.toString(); // p → Person.prototype → Object.prototype 找到
p.abc;        // p → Person.prototype → Object.prototype → null 返回 undefined

4. 内置构造函数与原型链全景

JavaScript 中,所有对象最终都继承自 Object,函数也继承自 Function,它们共同编织起完整的原型网络。

构造类型 实例举例 原型链路径
自定义构造函数 new Person() 实例 → Person.prototypeObject.prototypenull
数组 [1, 2, 3] 实例 → Array.prototypeObject.prototypenull
函数 function foo(){} 实例 → Function.prototypeObject.prototypenull
普通对象 {a:1} 实例 → Object.prototypenull

全景图(含实例):

4.1 特例:Function 的自引用——既是鸡又是蛋

在 JavaScript 中,Function 构造函数是所有函数的“母亲”,但 Function 自己也是一个函数。这就产生了一个奇特的闭环:

typeof Function;                            // "function"
Function.__proto__ === Function.prototype;   // true —— 它就是自己的实例!

这意味着 Function.prototype 既是所有函数的原型,也是 Function 自身的原型。在整个原型链图谱中,这是一个自引用环

这个环为什么成立?

  • 向下(prototype)Function 作为构造函数,为所有函数实例提供共享方法(callapplybind 等),所以它有一个 prototype
  • 向上(protoFunction 自身也是函数,需要沿着原型链去获取自己的方法,所以它的 __proto__ 只能指向 Function.prototype
普通构造函数(如 Person Function 构造函数
Person.__proto__ === Function.prototype(因为它是一个函数) Function.__proto__ === Function.prototype(它也是函数,且只能指回自己)
Person.prototype 是一个普通对象,与 Person 不是同一个引用 Function.prototype 也是 Function 的原型,两者形成闭环

速记:普通函数是 Function 的实例;Function 自己是自己的实例。

理解了这个自引用,你就能明白为什么所有函数都能用 callapply,因为它们最终都通过 __proto__ 找到了 Function.prototype,包括 Function 自己。

5. 原型链与继承(ES5 方案演进)

JavaScript 早期没有 class,只能通过原型链实现继承。不同方案不断弥补前一代的缺陷。

继承方式 核心思路 典型缺陷
原型链继承 Child.prototype = new Parent() 父类引用值被所有子实例共享
构造函数继承 在 Child 内执行 Parent.call(this) 父类原型上的方法无法继承
组合继承 结合上面两种方式 父类构造函数被执行两次
寄生组合继承 Child.prototype = Object.create(Parent.prototype) 写法略复杂(但最优)

寄生组合继承(最优 ES5 方案)示例

function Parent(name) { this.name = name; }
Parent.prototype.getName = function() { return this.name; };

function Child(name, age) {
  Parent.call(this, name);
  this.age = age;
}
Child.prototype = Object.create(Parent.prototype); // 取用父原型副本
Child.prototype.constructor = Child;                // 修正 constructor

6. ES6 class 与原型链

class 本质上仍然是原型链的语法糖。定义在 class 内部的方法会被挂载到 类.prototype 上,实例通过 __proto__ 调用。

class Person {
  constructor(name) { this.name = name; }
  sayHi() {} // 等价于 Person.prototype.sayHi
}
class Student extends Person {
  constructor(name, grade) {
    super(name);
    this.grade = grade;
  }
}

const s = new Student('Bob', 3);
s.__proto__ === Student.prototype;              // true
Student.prototype.__proto__ === Person.prototype; // true(extends 设置)

extends 背后就做了两件事:

  • Student.prototype.__proto__ 指向 Person.prototype(原型继承)。
  • Student.__proto__ 指向 Person(构造函数的静态继承)。

整套机制与寄生组合继承完全一致,只是语法更直观。

7. 常见误区与速记

误区 真相
“所有对象都有 prototype” 普通函数才有 prototype,对象没有(但都有 __proto__
__proto__ 可以随意使用” __proto__ 是非标准访问器,应优先使用 Object.getPrototypeOf()
instanceof 检查自身构造” instanceof 是沿着原型链查找,可能误判(如跨 iframe)
Object.create(null) 和普通对象一样” 用 null 做原型的对象没有 __proto__,无任何内置方法

8. 总结

原型链是 JavaScript 对象之间共享数据与行为的根本机制。它通过 __proto__ 指针建立从实例、构造函数的 prototype、再到 Object.prototype 的层层查找路径。早年的继承方案为弥补原型链的缺陷不断演进,最终沉淀为 ES6 class 的底层实现。理解原型链,你就掌握了对象关系、继承原理,以及 class 真正在做的是什么。

Vue2 结合 vue-video-player 播放 FLV 直播流

作者 恋爱脑
2026年4月27日 17:43

Vue2 结合 vue-video-player 播放 FLV 直播流

一. 依赖包版本

  • vue-video-player@5.0.2
  • video.js@6.13.0
  • videojs-flvjs@0.3.1
  • flv.js@1.6.2

二. main.js 的配置及加载顺序:

import videojs from 'video.js';
import flvjs from 'flv.js';
window.videojs = videojs;
window.flvjs = flvjs;
import 'videojs-flvjs';
import VideoPlayer from 'vue-video-player';
Vue.use(VideoPlayer);

如果使用 video.js@7.x,可能会出现 No compatible source was found for this media. 的兼容问题。

三. FlvVideoPlayer 组件代码

<template>
    <div class="flv-player-box">
        <video-player
            ref="videoPlayer"
            class="vjs-big-play-centered"
            :options="playerOptions"
            @playerReady="onPlayerReady"
        />
    </div>
</template>

<script>
export default {
    name: 'FlvVideoPlayer',
    props: {
        flvUrl: {
            type: String,
            required: true,
        },
        autoplay: {
            type: Boolean,
            default: true,
        },
        muted: {
            type: Boolean,
            default: true,
        },
        controls: {
            type: Boolean,
            default: true,
        },
        fluid: {
            type: Boolean,
            default: true,
        },
        preload: {
            type: String,
            default: 'auto',
        },
        poster: {
            type: String,
            default: '',
        },
        playsinline: {
            type: Boolean,
            default: true,
        },
        controlBar: {
            type: Object,
            default: () => ({
                volumePanel: { inline: false },
                progressControl: false,
            }),
        },
        retryOnError: {
            type: Boolean,
            default: true,
        },
        retryDelay: {
            type: Number,
            default: 3000,
        },
        maxRetry: {
            type: Number,
            default: 3,
        },
        techOrder: {
            type: Array,
            default: () => ['flvjs', 'html5'],
        },
    },
    data() {
        return {
            player: null,
            retryCount: 0,
            retryTimer: null,
        };
    },
    computed: {
        playerOptions() {
            return {
                autoplay: this.autoplay,
                muted: this.muted,
                controls: this.controls,
                fluid: this.fluid,
                preload: this.preload,
                poster: this.poster,
                playsinline: this.playsinline,
                techOrder: this.techOrder,
                sources: [
                    {
                        src: this.flvUrl,
                        type: 'video/x-flv',
                    },
                ],
                controlBar: this.controlBar,
            };
        },
    },
    watch: {
        flvUrl(newUrl, oldUrl) {
            if (newUrl && newUrl !== oldUrl) {
                this.retryCount = 0;
                this.updateSource(newUrl);
            }
        },
    },
    beforeDestroy() {
        this.destroyPlayer();
    },
    methods: {
        onPlayerReady(player) {
            this.player = player;
            this.player.on('error', this.handleError);
            this.player.on('play', () => this.$emit('play'));
            this.player.on('pause', () => this.$emit('pause'));
            this.player.on('loadedmetadata', () => this.$emit('loadedmetadata'));
            this.$emit('ready', player);
            this.updateSource(this.flvUrl);
            if (this.autoplay) {
                this.player.play().catch(() => {});
            }
        },
        updateSource(url) {
            if (!this.player) {
                return;
            }
            this.clearRetryTimer();
            this.player.src({
                src: url,
                type: 'video/x-flv',
            });
            this.player.load();
            if (this.autoplay) {
                this.player.play().catch(() => {});
            }
        },
        handleError() {
            this.$emit('error', this.player.error());
            if (!this.retryOnError) {
                return;
            }
            if (this.maxRetry >= 0 && this.retryCount >= this.maxRetry) {
                this.$emit('retry-failed', this.retryCount);
                return;
            }
            this.retryCount += 1;
            this.retryTimer = setTimeout(() => {
                if (this.player) {
                    this.updateSource(this.flvUrl);
                    this.$emit('retry', this.retryCount);
                }
            }, this.retryDelay);
        },
        clearRetryTimer() {
            if (this.retryTimer) {
                clearTimeout(this.retryTimer);
                this.retryTimer = null;
            }
        },
        destroyPlayer() {
            this.clearRetryTimer();
            if (this.player) {
                this.player.off('error', this.handleError);
                this.player.dispose();
                this.player = null;
            }
        },
        reload() {
            this.retryCount = 0;
            this.updateSource(this.flvUrl);
        },
    },
};
</script>

<style scoped>
.flv-player-box {
    width: 100%;
    background: #000;
}
</style>

四. 页面使用

<FlvVideoPlayer
  flvUrl="https://example.com/live.flv"
  :autoplay="true"
  :muted="true"
  :retryOnError="true"
  :retryDelay="3000"
  :maxRetry="3"
/>

五. 拓展: m3u8 流播放组件

<template>
    <div class="hls-player-box">
        <video-player
            ref="videoPlayer"
            class="vjs-big-play-centered"
            :options="playerOptions"
            @playerReady="onPlayerReady"
        />
    </div>
</template>

<script>
export default {
    name: 'HlsLivePlayer',
    props: {
        m3u8Url: {
            type: String,
            required: true,
        },
        autoplay: {
            type: Boolean,
            default: true,
        },
        muted: {
            type: Boolean,
            default: true,
        },
        controls: {
            type: Boolean,
            default: true,
        },
        fluid: {
            type: Boolean,
            default: true,
        },
        preload: {
            type: String,
            default: 'auto',
        },
        poster: {
            type: String,
            default: '',
        },
        playsinline: {
            type: Boolean,
            default: true,
        },
        isLive: {
            type: Boolean,
            default: true,
        },
        retryOnError: {
            type: Boolean,
            default: true,
        },
        retryDelay: {
            type: Number,
            default: 3000,
        },
        maxRetry: {
            type: Number,
            default: 3,
        },
        techOrder: {
            type: Array,
            default: () => ['html5'],
        },
        controlBar: {
            type: Object,
            default: () => ({
                volumePanel: { inline: false },
                progressControl: false,
            }),
        },
    },
    data() {
        return {
            player: null,
            retryCount: 0,
            retryTimer: null,
        };
    },
    computed: {
        playerOptions() {
            return {
                autoplay: this.autoplay,
                muted: this.muted,
                controls: this.controls,
                fluid: this.fluid,
                preload: this.preload,
                poster: this.poster,
                playsinline: this.playsinline,
                techOrder: this.techOrder,
                isLive: this.isLive,
                sources: [
                    {
                        src: this.m3u8Url,
                        type: 'application/x-mpegURL',
                    },
                ],
                controlBar: this.controlBar,
            };
        },
    },
    watch: {
        m3u8Url(newUrl, oldUrl) {
            if (newUrl && newUrl !== oldUrl) {
                this.retryCount = 0;
                this.updateSource(newUrl);
            }
        },
    },
    beforeDestroy() {
        this.destroyPlayer();
    },
    methods: {
        onPlayerReady(player) {
            this.player = player;
            this.player.on('error', this.handleError);
            this.player.on('play', () => this.$emit('play'));
            this.player.on('pause', () => this.$emit('pause'));
            this.player.on('loadedmetadata', () => this.$emit('loadedmetadata'));
            this.$emit('ready', player);
            if (this.autoplay) {
                this.player.play().catch(() => {});
            }
        },
        updateSource(url) {
            if (!this.player) {
                return;
            }
            this.clearRetryTimer();
            this.player.src({
                src: url,
                type: 'application/x-mpegURL',
            });
            this.player.load();
            if (this.autoplay) {
                this.player.play().catch(() => {});
            }
        },
        handleError() {
            this.$emit('error', this.player.error());
            if (!this.retryOnError) {
                return;
            }
            if (this.maxRetry >= 0 && this.retryCount >= this.maxRetry) {
                this.$emit('retry-failed', this.retryCount);
                return;
            }
            this.retryCount += 1;
            this.retryTimer = setTimeout(() => {
                if (this.player) {
                    this.$emit('retry', this.retryCount);
                    this.updateSource(this.m3u8Url);
                }
            }, this.retryDelay);
        },
        clearRetryTimer() {
            if (this.retryTimer) {
                clearTimeout(this.retryTimer);
                this.retryTimer = null;
            }
        },
        destroyPlayer() {
            this.clearRetryTimer();
            if (this.player) {
                this.player.off('error', this.handleError);
                this.player.dispose();
                this.player = null;
            }
        },
        reload() {
            this.retryCount = 0;
            this.updateSource(this.m3u8Url);
        },
    },
};
</script>

<style scoped>
.hls-player-box {
    width: 100%;
    max-width: 1200px;
    margin: 0 auto;
    background: #000;
}
</style>

前端优化实战指南(工程化/首屏/懒加载/Next.js等)

作者 北冥有鱼
2026年4月27日 17:20

构建优化是前端优化的基础,也是性价比最高的优化方向——无需大量修改业务代码,就能显著降低打包体积、提升构建速度,适配不同环境的部署需求,同时适配 React、Vue2、Vue3 等主流框架。


一、工程化 & 构建优化(Webpack / Vite)

1.1 Webpack 优化(主流项目实战,适配 React/Vue2/Vue3)

1.1.1 减小打包体积(核心:tree-shaking + 代码分割 + 依赖优化)

  • 开启 tree-shaking:仅打包被使用的代码,剔除死代码。

    • 配置:mode: "production"(默认开启),配合 package.json"sideEffects": false(需确认第三方依赖无副作用,若有则单独配置,如 ["*.css", "*.less", "*.scss"])。
    • 注意:仅对 ES6 模块(import/export)有效,CommonJS 模块(require)无法触发 tree-shaking,需避免混用模块规范;Vue2 项目需确保使用 vue-loader@15+ 版本,React 项目需避免使用 require 引入组件/工具。
  • 代码分割(Code Splitting):将代码拆分为多个 chunk,避免单文件过大,实现按需加载。

    • 路由分割

      • React 项目:使用 React.lazy + Suspense(函数组件),配合 react-router-dom 实现路由拆分,Suspense 需配置 fallback(加载占位),避免页面空白;类组件可使用 loadable-components 替代。

        // React 路由分割示例
        import { Suspense, lazy } from 'react';
        import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
        
        const Home = lazy(() => import('./pages/Home'));
        const About = lazy(() => import('./pages/About'));
        
        function App() {
          return (
            <Router>
              <Suspense fallback={<div>加载中...</div>}>
                <Routes>
                  <Route path="/" element={<Home />} />
                  <Route path="/about" element={<About />} />
                </Routes>
              </Suspense>
            </Router>
          );
        }
        
      • Vue2 项目:使用 vue-routercomponent: () => import('xxx'),配合 webpackChunkName 自定义 chunk 名称,便于调试。

        // Vue2 路由分割示例(vue-router@3.x)
        const router = new VueRouter({
          routes: [
            {
              path: '/',
              name: 'Home',
              component: () => import(/* webpackChunkName: "home" */ './views/Home.vue')
            },
            {
              path: '/about',
              name: 'About',
              component: () => import(/* webpackChunkName: "about" */ './views/About.vue')
            }
          ]
        });
        
      • Vue3 项目:与 Vue2 用法一致,适配 vue-router@4.x,可结合 setup 语法使用,无额外配置差异。

    • 公共依赖分割splitChunks 配置,将第三方依赖(如 react、react-dom、vue、vue-router、axios)与业务代码分离,单独打包为 vendor chunk,利用浏览器缓存复用。

      // webpack.config.js
      optimization: {
        splitChunks: {
          chunks: 'all',
          cacheGroups: {
            vendor: {
              test: /[\\/]node_modules[\\/]/,
              name: 'vendors',
              priority: 10,
              reuseExistingChunk: true
            },
            common: {
              minSize: 30000,
              minChunks: 2,
              priority: 5,
              reuseExistingChunk: true
            }
          }
        }
      }
      
  • 依赖优化:剔除无用依赖 + 替换轻量依赖

    • 使用 webpack-bundle-analyzer 分析打包体积,找到体积过大的依赖。

    • 替换方案:moment.jsday.js(体积缩小 80%+)、lodashlodash-es(支持 tree-shaking)、jquery → 原生 DOM / 轻量库。

    • 按需引入:

      • React 生态:antd、Material-UI 等 UI 库,使用 babel-plugin-import 实现组件和样式的按需加载。
      • Vue2 生态:element-ui 使用 babel-plugin-import 按需引入。
      • Vue3 生态:element-plusant-design-vue@4+ 支持 babel-plugin-import 按需引入,也可通过 setup 语法自动按需引入组件。
      // .babelrc(React + antd 按需引入)
      {
        "plugins": [
          ["import", {
            "libraryName": "antd",
            "libraryDirectory": "es",
            "style": "css"
          }]
        ]
      }
      
      // .babelrc(Vue2 + element-ui 按需引入)
      {
        "plugins": [
          ["import", {
            "libraryName": "element-ui",
            "libraryDirectory": "lib",
            "style": true
          }]
        ]
      }
      
  • 资源压缩

    • JS 压缩:production 模式默认使用 TerserPlugin,可配置 parallel: true 开启多线程压缩。

    • CSS 压缩:使用 mini-css-extract-plugin 提取 CSS 为单独文件,配合 css-minimizer-webpack-plugin 压缩 CSS。

    • 图片压缩:使用 image-webpack-loader 压缩图片,配置 limit 限制小图片转为 base64(减少 HTTP 请求)。

      // module.rules 中配置
      {
        test: /\.(png|jpe?g|gif|svg)$/i,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8192,
              name: 'static/img/[name].[hash:8].[ext]',
              esModule: false
            }
          },
          'image-webpack-loader'
        ]
      }
      

1.1.2 提升构建速度

  • 多线程构建:使用 thread-loader 将耗时的 loader(如 babel-loaderts-loader)放入单独线程。

    // React 项目配置
    {
      test: /\.(js|jsx|ts|tsx)$/,
      exclude: /node_modules/,
      use: [
        'thread-loader',
        {
          loader: 'babel-loader',
          options: {
            cacheDirectory: true
          }
        }
      ]
    }
    
    // Vue 项目配置(thread-loader 放在 vue-loader 之前)
    {
      test: /\.vue$/,
      exclude: /node_modules/,
      use: [
        'thread-loader',
        'vue-loader'
      ]
    }
    
  • 缓存优化:开启 loader 缓存(cacheDirectory)和 webpack 持久化缓存(cache: { type: 'filesystem' }),避免每次构建都重新编译。

  • 缩小构建范围exclude 排除 node_modulesdist 等无需编译的目录;include 明确指定需要编译的目录(如 src)。

  • 替换构建工具:若项目体积较大,可考虑将 Webpack 替换为 Vite(基于 ES Module,冷启动速度提升 10 倍+)。Vue3 项目优先使用 Vite,React 项目可使用 @vitejs/plugin-react 适配。

1.2 Vite 优化(新兴项目首选,适配 React/Vue2/Vue3)

Vite 本身已做了大量优化,核心优化方向是"减少不必要的编译":

  • 依赖预构建:Vite 自动预构建第三方依赖,生成 ESM 格式产物,可通过 optimizeDeps 自定义预构建范围。

  • 静态资源优化:内置图片、CSS 压缩,小图片自动转 base64,可通过 assetsInclude 配置。

  • 生产环境优化build 时默认开启 minify: 'terser',配置 rollupOptions 实现代码分割。

    // vite.config.js(Vue3 项目)
    import { defineConfig } from 'vite';
    import vue from '@vitejs/plugin-vue';
    
    export default defineConfig({
      plugins: [vue()],
      build: {
        minify: 'terser',
        rollupOptions: {
          output: {
            chunkFileNames: 'static/js/[name].[hash].js',
            entryFileNames: 'static/js/[name].[hash].js',
            assetFileNames: 'static/[ext]/[name].[hash].[ext]',
            manualChunks: {
              vendor: ['vue', 'vue-router', 'axios']
            }
          }
        }
      }
    });
    
    // vite.config.js(React 项目)
    import { defineConfig } from 'vite';
    import react from '@vitejs/plugin-react';
    
    export default defineConfig({
      plugins: [react()],
      build: {
        minify: 'terser',
        rollupOptions: {
          output: {
            chunkFileNames: 'static/js/[name].[hash].js',
            entryFileNames: 'static/js/[name].[hash].js',
            assetFileNames: 'static/[ext]/[name].[hash].[ext]',
            manualChunks: {
              vendor: ['react', 'react-dom', 'react-router-dom']
            }
          }
        }
      }
    });
    
  • 框架适配:Vue2 需安装 @vitejs/plugin-vue2;React 需安装 @vitejs/plugin-react(支持 Fast Refresh);Vue3 原生支持。


二、首屏加载优化(核心:减少加载时间,提升用户感知)

首屏加载速度直接影响用户留存,核心思路是"减少首屏资源体积、减少 HTTP 请求、优化资源加载顺序"。

2.1 资源层面优化

  • HTML 优化

    • 精简 HTML 结构,将首屏关键 CSS 内联到 <head>,JS 脚本放在 <body> 底部或使用 defer/async 属性。
    • React 项目:使用 react-dom/server 或 Next.js 实现服务端渲染,减少白屏时间。
    • Vue 项目:使用 vue-server-renderer(Vue2)、@vue/server-renderer(Vue3)或 Nuxt.js 实现 SSR。
  • CSS 优化

    • 提取首屏关键 CSS(Critical CSS)内联到 HTML,非关键 CSS 异步加载。
    • 使用 CSS Sprites 合并小图标,避免使用 @import 引入 CSS(会阻塞渲染)。
    • React 项目:使用 CSS Modules 或 Styled Components 避免样式冲突。
    • Vue 项目:使用 scoped 样式或 CSS Modules 减少样式冗余。
  • JS 优化

    • 减少首屏 JS 体积,非必要脚本(统计、广告)异步加载。
    • React 项目:使用 React.lazy + Suspense 拆分首屏组件,减少 useEffect 的不必要执行。
    • Vue2 项目:使用路由懒加载,用 v-if 替代 v-show(首屏不显示的组件不渲染)。
    • Vue3 项目:使用 setup 语法提升响应式效率,配合 Teleport 将非首屏组件挂载到主渲染树外。

2.2 框架专属首屏优化

2.2.1 React 项目

  • SSR/SSG:使用 Next.js,通过 getStaticProps(SSG)或 getServerSideProps(SSR)提前获取数据,首屏由服务端返回完整 HTML。
  • 预加载:使用 Next.js Link 组件的 prefetch 属性预加载路由;使用 dynamic import 动态加载非首屏组件。
  • 状态管理:首屏无需的状态延迟初始化,使用 useMemouseCallback 缓存计算结果和函数。

2.2.2 Vue2 项目

  • SSR:使用 Nuxt.js@2,通过 asyncDatafetch 提前获取首屏数据。
  • Vue 实例优化:避免在 createdmounted 中执行耗时操作,可延迟到 $nextTicksetTimeout
  • 组件优化:首屏组件精简,非首屏组件使用路由懒加载;避免使用 Vue.filter(全局过滤器会增加初始化时间)。

2.2.3 Vue3 项目

  • SSR/SSG:使用 Nuxt.js@3 或 VitePress,通过 useAsyncDatauseFetch 提前获取数据。
  • Composition API 优化setup 语法减少组件初始化时间;避免在 setup 中执行耗时操作,使用 onMounted 延迟执行。
  • 按需引入:Vue3 核心库可按需引入;Pinia 替代 Vuex(体积更小、性能更优)。

三、懒加载优化(通用+框架适配,减少首屏压力)

懒加载核心是"按需加载",仅当资源进入视口或即将进入视口时才加载。

3.1 图片懒加载

  • 原生懒加载:使用 <img loading="lazy"> 属性,浏览器原生支持,无需额外插件。不支持 IE,可做降级处理。

  • 插件懒加载(适配框架)

    • React 项目:使用 react-lazyload 或自定义 Hook(IntersectionObserver API)。

      // React 自定义懒加载 Hook
      import { useEffect, useRef, useState } from 'react';
      
      function useLazyLoad() {
        const ref = useRef(null);
        const [isVisible, setIsVisible] = useState(false);
      
        useEffect(() => {
          const observer = new IntersectionObserver(
            ([entry]) => setIsVisible(entry.isIntersecting),
            { threshold: 0.1 }
          );
          if (ref.current) observer.observe(ref.current);
          return () => {
            if (ref.current) observer.unobserve(ref.current);
          };
        }, []);
      
        return { ref, isVisible };
      }
      
      // 使用示例
      function LazyImage({ src, alt }) {
        const { ref, isVisible } = useLazyLoad();
        return (
          <div ref={ref}>
            {isVisible ? (
              <img src={src} alt={alt} />
            ) : (
              <div className="placeholder" />
            )}
          </div>
        );
      }
      
    • Vue2 项目:使用 vue-lazyload 插件。

      // Vue2 配置 vue-lazyload
      import Vue from 'vue';
      import VueLazyload from 'vue-lazyload';
      
      Vue.use(VueLazyload, {
        preLoad: 1.3,
        error: 'error.png',
        loading: 'loading.gif',
        attempt: 1
      });
      
      <!-- 组件中使用 -->
      <img v-lazy="imageUrl" />
      
    • Vue3 项目:使用 vue3-lazyload 插件。

      // Vue3 配置 vue3-lazyload
      import { createApp } from 'vue';
      import App from './App.vue';
      import VueLazyload from 'vue3-lazyload';
      
      const app = createApp(App);
      app.use(VueLazyload, {
        preLoad: 1.3,
        error: 'error.png',
        loading: 'loading.gif'
      });
      app.mount('#app');
      
      <!-- 组件中使用 -->
      <img v-lazy="imageUrl" />
      
  • 注意事项:懒加载图片需设置宽高避免布局抖动;优先使用 WebP 格式(体积更小)并做降级处理;首屏可见的图片不要使用懒加载。

3.2 组件/路由懒加载

3.2.1 React 组件/路由懒加载

  • 路由懒加载:使用 React.lazy + Suspense(参见 1.1.1)。

  • 组件懒加载:非首屏组件使用 dynamic import 动态加载。

    // React 组件懒加载示例
    import { Suspense, lazy, useState } from 'react';
    
    const ModalComponent = lazy(() => import('./components/ModalComponent'));
    
    function Home() {
      const [showModal, setShowModal] = useState(false);
      return (
        <div>
          <button onClick={() => setShowModal(true)}>打开弹窗</button>
          {showModal && (
            <Suspense fallback={<div>加载中...</div>}>
              <ModalComponent onClose={() => setShowModal(false)} />
            </Suspense>
          )}
        </div>
      );
    }
    

3.2.2 Vue2/Vue3 组件/路由懒加载

  • 路由懒加载:使用 component: () => import('xxx')(参见 1.1.1)。

  • 组件懒加载:

    // Vue2 组件懒加载
    export default {
      components: {
        LazyComponent: () => import(
          /* webpackChunkName: "lazy-component" */
          './LazyComponent.vue'
        )
      }
    };
    
    // Vue3 组件懒加载(setup 语法)
    import { defineAsyncComponent } from 'vue';
    
    const LazyComponent = defineAsyncComponent(
      () => import('./LazyComponent.vue')
    );
    
    export default {
      components: { LazyComponent }
    };
    

3.3 第三方资源懒加载

  • 第三方脚本(统计、广告、地图)异步加载,避免阻塞首屏渲染。

    // 动态加载第三方脚本
    function loadScript(url, callback) {
      var script = document.createElement('script');
      script.src = url;
      script.async = true;
      script.onload = callback;
      document.body.appendChild(script);
    }
    
    // 页面加载完成后再加载统计脚本
    window.addEventListener('load', function () {
      loadScript('https://analytics.example.com/sdk.js', function () {
        console.log('统计脚本加载完成');
      });
    });
    
  • React/Vue 项目:第三方组件(如 echarts)使用懒加载引入,避免首屏加载冗余资源。


四、Next.js 优化(React 框架专属)

Next.js 内置了大量优化特性,在此基础上补充实战优化方案。

4.1 渲染模式优化

  • SSG(静态站点生成):适用于静态页面(官网、文档),构建时生成 HTML,可部署到 CDN,首屏最快。

  • SSR(服务端渲染):适用于动态页面(用户中心、数据看板),每次请求由服务端渲染,SEO 友好。

  • ISR(增量静态再生):结合 SSG 和 SSR,构建时生成静态页面,定期重新生成。

    // Next.js ISR 示例
    export async function getStaticProps() {
      const res = await fetch('https://api.example.com/news');
      const data = await res.json();
      return {
        props: { data },
        revalidate: 60  // 每 60 秒重新生成页面
      };
    }
    

4.2 路由优化

  • 路由预加载Link 组件默认预加载视口内的路由(prefetch: true),可手动预加载。

    import Link from 'next/link';
    import { useRouter } from 'next/router';
    
    function Home() {
      const router = useRouter();
    
      const preloadAbout = () => {
        router.prefetch('/about');
      };
    
      return (
        <div>
          <Link href="/about" prefetch={true}>关于我们</Link>
          <button onClick={preloadAbout}>预加载关于我们</button>
        </div>
      );
    }
    
  • 动态路由优化:使用 getStaticPaths 配置预渲染路径;大量动态路径可设置 fallback: true,未预渲染的路径由服务端实时渲染。

4.3 资源优化

  • 图片优化:使用 Next.js 内置 Image 组件,自动压缩、格式转换、懒加载。

    import Image from 'next/image';
    
    function Home() {
      return (
        <Image
          src="/images/hero.jpg"
          alt="首页封面"
          width={1200}
          height={600}
          loading="lazy"
          quality={80}
        />
      );
    }
    
  • 字体优化:使用 Next.js 内置 Font 组件,预加载字体,避免 FOIT(字体闪烁)。

  • 脚本优化:使用 Script 组件,支持 beforeInteractiveafterInteractivelazyOnload 等加载策略。

4.4 运行时优化

  • 数据缓存:使用 SWR 或 React Query 缓存数据,减少重复请求。
  • 组件优化:使用 React.memouseMemouseCallback 避免不必要的重渲染。
  • 打包优化:通过 next.config.js 配置 optimization,开启代码分割和依赖优化。

五、运行时优化(React/Vue2/Vue3 通用)

运行时优化核心是"减少重渲染、提升交互响应速度"。

5.1 React 运行时优化

  • 减少重渲染

    import { memo, useMemo, useCallback, useState } from 'react';
    
    // 子组件:使用 memo 包裹,避免无意义重渲染
    const Child = memo(({ name, onClick }) => {
      console.log('子组件渲染');
      return <button onClick={onClick}>{name}</button>;
    });
    
    // 父组件
    function Parent() {
      const [count, setCount] = useState(0);
      const name = useMemo(() => `用户${count}`, [count]);
      const handleClick = useCallback(() => {
        console.log('点击事件');
      }, []);
    
      return (
        <div>
          <button onClick={() => setCount(count + 1)}>计数:{count}</button>
          <Child name={name} onClick={handleClick} />
        </div>
      );
    }
    
  • 事件优化:避免在 render 中创建内联函数,使用 useCallback 缓存事件处理函数;大量列表使用事件委托。

  • 数据处理优化:大量数据使用虚拟列表(react-windowreact-virtualized);耗时计算使用 Web Worker。

5.2 Vue2 运行时优化

  • 减少重渲染

    • 使用 v-once 只渲染一次不再更新的内容。
    • 使用 v-if 替代 v-show(不常显示的组件不创建 DOM)。
    • 减少 watch 监听范围,使用 computed 缓存计算属性。
  • 组件优化:拆分大型组件;使用 keep-alive 缓存路由组件。

    <!-- Vue2 keep-alive 示例 -->
    <keep-alive>
      <router-view v-if="$route.meta.keepAlive" />
    </keep-alive>
    <router-view v-if="!$route.meta.keepAlive" />
    
    // 路由配置中设置 keepAlive
    const routes = [
      {
        path: '/home',
        component: Home,
        meta: { keepAlive: true }
      },
      {
        path: '/about',
        component: About,
        meta: { keepAlive: false }
      }
    ];
    
  • 数据优化:大量数据使用虚拟列表(vue-virtual-scroller);避免在 created/mounted 中执行耗时操作。

5.3 Vue3 运行时优化

  • 减少重渲染

    • 使用 refreactive 替代 Vue2 的 data,响应式效率更高。
    • 使用 computed 缓存计算属性。
    • 使用 watchEffect 替代 watch,自动追踪依赖。
    • 使用 definePropsdefineEmits 明确组件接口。
  • 组件优化keep-alive 缓存组件;Teleport 将弹窗等组件挂载到指定节点;拆分大型组件。

  • 数据优化:虚拟列表使用 vue-virtual-scroller@next;使用 toReftoRefs 避免解构导致响应式丢失;耗时计算使用 Web Worker。


六、网络 & 缓存优化(通用)

6.1 网络优化

  • HTTP 协议:使用 HTTPS;升级到 HTTP/2(多路复用、头部压缩)。
  • CDN 加速:静态资源部署到 CDN,用户就近获取。
  • 接口优化
    • 合并接口请求,避免重复请求。
    • 分页加载,避免一次性获取大量数据。
    • 使用接口缓存(localStorage/sessionStorage)缓存不常变化的数据。
    • React 可使用 SWR/React Query,Vue 可使用 vue-query。

6.2 缓存优化

  • 浏览器缓存

    • 强缓存:Cache-Control: public, max-age=86400,浏览器直接使用本地缓存。
    • 协商缓存:ETag/Last-Modified,强缓存过期后服务器判断资源是否更新,未更新返回 304。
  • 前端缓存

    • localStorage:持久化存储不常变化的数据。
    • sessionStorage:会话级临时数据。
    • Service Worker:缓存静态资源,实现离线访问(PWA),可通过 workbox 快速配置。
  • 缓存更新策略:静态资源使用哈希命名(app.[hash].js),资源更新时哈希变化触发重新请求;HTML 文件不缓存或短时间缓存,确保能获取最新资源引用。


七、总结

前端优化是系统性工作,需结合项目场景(React/Vue2/Vue3/Next.js)和业务需求,从工程化构建、首屏加载、懒加载、运行时、网络缓存等多个层面入手。

优先级建议:构建优化(tree-shaking、代码分割)> 路由懒加载 > 图片优化 > 首屏 SSR/SSG > 运行时优化 > 网络缓存优化。

验证工具:Lighthouse、Chrome DevTools Performance 面板、webpack-bundle-analyzer,持续检测优化效果。

我为什么不想再来回 `checkout` 分支了:用 `git worktree` 同时开发多个分支,干净很多

2026年4月27日 17:02

如果你平时只维护一个分支,git checkout 看起来没什么问题;但只要开始同时处理 dev、功能分支、临时修复分支,切分支这件事很快就会变得烦人。

最典型的几个问题几乎每个人都遇到过:

  • 当前目录还有未提交改动,分支切不过去。
  • 刚在 feature 上改了一半,又被拉去修 dev 上的问题。
  • 为了避免冲突先 stash 一次,修完再 stash pop,结果改动又缠到一起。
  • 本地要同时跑两个分支,只能复制一份目录,最后哪个目录对应哪个分支自己都记不清。

我以前也是这么干的。直到项目开始同时并行多个需求后,我才真正体会到:很多混乱并不是 Git 太难,而是“一个目录承载多个分支”的工作方式本身就容易出错。

后来我把本地开发方式换成了 git worktree,最大的变化不是命令更高级,而是一个很朴素的原则终于成立了:一个目录只负责一个分支。

这篇文章就想把这件事讲清楚。

1. git worktree 到底解决了什么问题

git worktree 是 Git 自带功能,不需要单独安装。它允许同一个仓库在多个目录里同时检出不同分支。

核心理解只有一句话:

不是在一个目录里反复切分支,而是给不同分支各自分配一个目录。

这和“手工复制多个仓库目录”看起来有点像,但底层完全不是一回事。worktree 共享同一个 Git 仓库历史,不需要你真的复制一份完整仓库出来,所以它更适合长期并行开发。

当你开始把这个模型代入日常工作,会发现很多原来很别扭的动作都顺了:

  • dev 可以长期常驻在一个目录里。
  • 每个需求分支都能有自己的独立工作区。
  • 不再需要频繁 stashcheckoutpop
  • 同时开两个编辑器窗口、两个终端、甚至两个本地服务都更自然。

2. 我最常见的实际场景

拿一个真实的本地目录举例:

D:\www\smart-park-app-patrol-car

这时候如果我执行:

git worktree list

可能会看到类似结果:

D:/www/smart-park-app-patrol-car      67c10b6 [feature/voice-intercom]
D:/www/smart-park-app-patrol-car-dev  6b89ca7 [dev]

这个结果表达的信息其实很明确:

  • D:\www\smart-park-app-patrol-car 对应 feature/voice-intercom
  • D:\www\smart-park-app-patrol-car-dev 对应 dev

从这里开始,开发习惯就会发生变化。

以前的思路是:进入同一个目录,然后不停切分支。
现在的思路是:要改哪个分支,就直接进入那个分支对应的目录。

这看起来只是路径变化,但它带来的好处非常直接:分支边界终于变成了物理边界。

3. 最值得先记住的几个命令

3.1 查看当前有哪些 worktree

git worktree list

这是最常用的命令之一。它能让你快速确认当前仓库已经有哪些工作目录、每个目录对应哪个分支。

如果你本地并行维护多个需求,这个命令基本等于“先看地图再行动”。

3.2 基于已有分支创建一个新目录

比如我已经有 dev 分支,想单独给它一个目录:

git worktree add ..\smart-park-app-patrol-car-dev dev

这条命令的意思是:

  • 在当前仓库的同级目录创建 smart-park-app-patrol-car-dev
  • 这个目录直接检出 dev 分支

从此以后,dev 就可以固定在这个目录里长期存在。

3.3 创建新分支并同时建立 worktree

如果我想从 dev 拉一个新需求分支:

git worktree add ..\smart-park-app-patrol-car-feature-camera -b feature/camera dev

这条命令会同时做两件事:

  1. 基于 dev 创建 feature/camera
  2. 在新目录中直接检出这个新分支

这一点非常实用,因为它把“开新分支”和“准备开发目录”合并成了一次动作。

3.4 不切目录直接看另一个 worktree 状态

有时候你并不想切到那个目录,只想看一下状态:

git -C D:\www\smart-park-app-patrol-car-dev status

这个用法在 Windows PowerShell 下很顺手,适合快速确认另一个工作区有没有未提交改动。

3.5 删除不用的 worktree

git worktree remove ..\smart-park-app-patrol-car-feature-camera

这个动作会删除对应工作目录,并解除它和主仓库的关联。

但这里一定要注意,删除前先确认目录里没有未提交改动,否则很容易把自己还没处理的变更一起带走。

4. 为什么它比反复 checkout 更适合日常开发

很多人第一次接触 worktree 时,容易把它理解成“高级一点的分支切换工具”。但我实际用下来,觉得它更像是一种工作方式的切换。

直接 checkout 的问题,不在于命令本身,而在于它要求一个目录在不同时间承担多个分支的职责。

这会带来几个常见副作用:

  • 一个目录里的未提交改动会阻塞另一个分支的切换。
  • 你很难一眼看出当前终端到底在哪个分支上。
  • 两个分支共用一个工作目录时,构建缓存、依赖状态和本地临时文件容易互相污染。
  • 当你一边开发需求、一边处理线上修复时,思路很容易被切碎。

worktree 的优势,恰恰是把这些问题转移掉了。

因为一个目录固定一个分支,所以:

  • 当前目录改动只属于当前分支。
  • 本地服务、构建产物和依赖状态天然分开。
  • 哪个窗口对应哪个分支,一眼就知道。
  • 你不需要再通过 stash 给自己制造额外状态。

5. 一个很实用的最小工作流

如果你是个人开发或者小团队协作,我很建议直接用下面这个最小工作流:

  1. 保留一个长期存在的 dev worktree。
  2. 每个需求从 dev 再开一个 feature worktree。
  3. 每个目录只处理自己的分支,不在同一个目录里来回切。
  4. 需求完成后,合并回 dev
  5. 删除已经完成的 feature worktree。
  6. 定期清理失效记录。

例如从 dev 再开一个需求:

cd D:\www\smart-park-app-patrol-car-dev
git pull
git worktree add ..\smart-park-app-patrol-car-feature-camera -b feature/camera dev

这个流程的好处是,dev 永远保留在一个稳定目录里,而不是每次都被你切来切去。

6. 和 mergerebase 的关系是什么

worktree 解决的是“多个分支如何并行开发”的问题,不是“分支怎么合并”的问题。

也就是说,它不会替代 mergerebase,但会让你在执行这些操作时更从容。

比如你想把 dev 的最新代码同步到功能分支,可以先更新 dev

cd D:\www\smart-park-app-patrol-car-dev
git pull

再去功能分支目录执行:

cd D:\www\smart-park-app-patrol-car-feature-camera
git merge dev

或者:

git rebase dev

这一步和传统 Git 工作流并不冲突,只是因为目录分开了,整个过程会更清晰。

7. 本地目录怎么命名最不容易乱

我建议一开始就定一个统一命名规则。比如:

D:\www\
  smart-park-app-patrol-car
  smart-park-app-patrol-car-dev
  smart-park-app-patrol-car-feature-camera
  smart-park-app-patrol-car-feature-voice

这个规则最大的好处不是“好看”,而是低认知成本。

看到目录名,你就能立刻知道:

  • 这是哪个项目
  • 这是 dev 还是某个 feature
  • 这个终端该提交到哪里

对并行开发来说,这种明确性很重要。

8. 它会不会更占空间

会,但通常没有很多人想象中那么夸张。

git worktree 会给每个分支准备一份自己的工作目录文件,但 Git 历史对象本身是共享的,所以它通常比“完整复制多个仓库目录”更省。

真正更占空间的,往往不是 Git,而是这些东西:

  • node_modules
  • dist
  • 构建缓存
  • IDE 生成文件

所以如果你做的是前端、移动端或者多模块项目,空间成本主要还是来自依赖和产物,而不是 worktree 本身。

9. 我遇到最多的几个报错

9.1 某个分支已经被别的 worktree 占用

如果 Git 提示某个分支已经被检出,通常说明这个分支已经在另一个 worktree 目录里存在了。

先执行:

git worktree list

找到对应目录后,要么直接继续使用那个目录,要么先删除旧 worktree,再重新创建。

9.2 删除 worktree 失败

最常见原因有两个:

  • 目录里还有未提交改动
  • 目录正被终端、编辑器或者运行中的进程占用

这种时候不要急着强删,先检查:

git status

确认没有遗留改动后,再关闭相关终端和程序,再执行 git worktree remove

9.3 目录被手工删掉了,但记录还在

这时候不用慌,执行:

git worktree prune

它会清理已经失效的 worktree 记录。

10. 最后一句结论

我现在对 git worktree 的理解很简单:它不是帮你“更快地切分支”,而是帮你尽量少切分支。

一旦项目进入并行开发阶段,这种变化会非常明显。你不再需要让一个目录承担所有分支的上下文,也不需要频繁用 stash 去救场。对我来说,它最大的价值不是命令多高级,而是让本地开发环境终于变得更稳定、更可预测。

如果你现在还在一个目录里来回 checkoutstashpop,而且手上经常同时挂着两个以上分支,那 git worktree 很值得尽早用起来。

参考资料

记录 CSS 常见渐变

作者 moneyinto
2026年4月27日 16:17

CSS 常见渐变主要有 6 种:

类型 作用
linear-gradient 线性渐变,按钮/背景最常用
radial-gradient 径向渐变,适合光晕、聚光灯
conic-gradient 锥形渐变,适合饼图、扫光边框
repeating-linear-gradient 重复线性渐变,适合条纹
repeating-radial-gradient 重复径向渐变,适合波纹/圆环
repeating-conic-gradient 重复锥形渐变,适合刻度盘/放射纹

核心区别:

/* 1. 线性渐变:沿一条线 */
background: linear-gradient(135deg, red, blue);

/* 2. 径向渐变:从中心向外 */
background: radial-gradient(circle at center, red, blue);

/* 3. 锥形渐变:围绕中心旋转 */
background: conic-gradient(from 0deg, red, yellow, green, blue, red);

radial-gradientconic-gradient 存在中心点(默认中心点)

background: radial-gradient(circle at 30% 40%, red, blue);

background: conic-gradient(
  from 0deg at 30% 40%,
  red,
  yellow,
  blue
);

进阶想法

  • 手电筒光效果(Spotlight)

<div class="flashlight"></div>
.flashlight {
  position: fixed;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(
    circle at 50% 50%,
    rgba(255,255,255,0.3) 0px,
    rgba(255,255,255,0.1) 80px,
    rgba(0,0,0,0.6) 200px,
    rgba(0,0,0,0.9) 400px
  );
}
document.addEventListener('mousemove', e => {
  const x = e.clientX;
  const y = e.clientY;

  document.querySelector('.flashlight').style.background =
    `radial-gradient(circle at ${x}px ${y}px,
      rgba(255,255,255,0.3) 0px,
      rgba(255,255,255,0.1) 80px,
      rgba(0,0,0,0.6) 200px,
      rgba(0,0,0,0.95) 400px
    )`;
});
  • 瞄准镜效果(Scope)

<div class="scope">
  <div class="crosshair"></div>
</div>
.scope {
  position: fixed;
  inset: 0;

  background: radial-gradient(
    circle at center,
    transparent 120px,
    rgba(0,0,0,0.8) 140px
  );
}

.crosshair::before,
.crosshair::after {
  content: "";
  position: absolute;
  background: rgba(255,255,255,0.8);
}

.crosshair::before {
  width: 2px;
  height: 100%;
  left: 50%;
  transform: translateX(-50%);
}

.crosshair::after {
  height: 2px;
  width: 100%;
  top: 50%;
  transform: translateY(-50%);
}
  • 多光源

background:
  radial-gradient(circle at 30% 40%, rgba(255,255,255,0.3), transparent),
  radial-gradient(circle at 70% 60%, rgba(0,200,255,0.2), transparent);
  • 呼吸光

@keyframes pulse {
  0% { background-size: 200px 200px; }
  100% { background-size: 260px 260px; }
}

轮播图实战:用Web API玩转元素属性操作

作者 心连欣
2026年4月27日 16:05

哈喽大家好,我是心连欣。轮播图是前端开发中最常见的组件之一,无论是电商网站的商品展示,还是新闻门户的头条轮播,都离不开它的身影。今天,我们就用Web API的核心能力——操作元素属性,来实现一个基础但功能完整的轮播图。

核心思路:属性操作是轮播图的“命脉”

轮播图的本质,是通过动态修改元素的属性来实现图片的切换和指示器的同步。具体来说,我们需要操作两类属性:

  • 图片的src属性:决定显示哪张图片。
  • 指示器的类名属性:决定哪个指示器处于“激活”状态。

第一步:HTML结构搭建

首先,我们需要一个包含图片和指示器的基本结构:

<div class="carousel"> <!-- 图片容器 --> 
  <img id="carouselImage" src="./images/1.jpg" alt="轮播图"> <!-- 指示器容器 --> 
  <div class="indicators"> 
    <span class="active" data-index="0"></span>
    <span data-index="1"></span> 
    <span data-index="2"></span>
  </div> 
</div>

这里,我们给图片一个id,方便后续通过document.getElementById获取;给每个指示器一个data-index自定义属性,用于标记对应的图片索引。

第二步:CSS样式准备

为了让轮播图看起来更美观,我们需要一些基础样式:

.carousel {
    position: relative;
    width: 600px; 
    height: 400px;
    overflow: hidden;
  }
#carouselImage { 
    width: 100%;
    height: 100%;
  } 
.indicators { 
    position: absolute;
    bottom: 20px; 
    left: 50%;
    transform: translateX(-50%);
  } 
.indicators span { 
    display: inline-block;
    width: 10px; 
    height: 10px;
    background-color: #fff;
    border-radius: 50%; 
    margin: 0 5px; 
    cursor: pointer; 
    opacity: 0.5;
  }
.indicators .active {
    opacity: 1;
    background-color: #f00;
  }

这里的关键是.active类,它会让当前指示器的透明度变为1,背景色变为红色,从而突出显示。

第三步:JavaScript实现轮播逻辑

接下来,就是最核心的JavaScript部分。我们需要做三件事:

  1. 获取元素:拿到图片和所有指示器。
  2. 定义轮播函数:修改图片的src和指示器的class
  3. 定时切换:用setInterval实现自动轮播。
获取元素
const image = document.getElementById('carouselImage');
const indicators = document.querySelectorAll('.indicators span');

这里,我们用getElementById获取图片元素,用querySelectorAll获取所有指示器,返回的是一个NodeList。

定义轮播函数
  let currentIndex = 0;
// 当前显示的图片索引
  const images = ['./images/1.jpg', './images/2.jpg', './images/3.jpg']; 
// 图片路径数组 
  function changeImage() { 
// 1. 修改图片的src属性 image.src = images[currentIndex];
// 2. 修改指示器的class属性 // 先移除所有指示器的active类
  indicators.forEach(indicator => { indicator.classList.remove('active'); });
// 再给当前索引的指示器添加active类 
  indicators[currentIndex].classList.add('active');
// 3. 更新索引,循环到下一张 
  currentIndex = (currentIndex + 1) % images.length; }

这里用到了classList属性,它是操作元素类名的现代方法,比直接操作className更灵活。remove方法用于移除类,add方法用于添加类。

定时切换

javascript

编辑

// 每3秒调用一次changeImage函数
setInterval(changeImage, 3000);

这样,轮播图就会每3秒自动切换一张图片,同时指示器也会同步更新。

进阶:点击指示器切换图片

为了让用户能手动控制轮播图,我们可以给每个指示器添加点击事件:

javascript

编辑

indicators.forEach((indicator, index) => {
  indicator.addEventListener('click', () => {
    // 点击时,将当前索引设置为点击的指示器索引
    currentIndex = index;
    // 立即调用changeImage函数,切换图片
    changeImage();
  });
});

这里用到了addEventListener方法,它是Web API中用于绑定事件的核心方法。当用户点击某个指示器时,我们会将currentIndex设置为该指示器的索引,然后调用changeImage函数,实现手动切换。

总结

通过这个轮播图的实现,我们深入理解了Web API中操作元素属性的核心方法:

  • getElementByIdquerySelectorAll:用于获取DOM元素。
  • src属性:用于修改图片的路径。
  • classList属性:用于操作元素的类名,实现样式的动态切换。
  • addEventListener:用于绑定事件,实现用户交互。

这些看似简单的API,却是构建复杂前端组件的基石。掌握了它们,你就能轻松实现各种动态交互效果,让网页“活”起来。

Git 提示nothing to commit, working tree clean

作者 张可爱
2026年4月27日 15:58

最近在更新服务器 SSL 证书时,遇到了一个比较常见的问题: 新的证书文件已经放到了项目目录:

nginx/cert/***********.key
nginx/cert/***********.pem

但是执行提交时,Git 却提示:

nothing to commit, working tree clean

也就是说,Git 认为当前项目没有任何变化。 这通常是因为:现在解压出来的这两个文件,内容和 Git 仓库里现有的文件是一模一样的。

检查了目前的证书文件,它的有效期到 2026年7月19日,现在解压出来的可能还是那套证书,所以 Git 检测不到内容变化。

如何强制更新并触发 GitLab 流水线?

如果确定现在的证书是更新的(或者想通过重新运行流水线来确保生产环境同步了最新的文件),可以尝试以下方法:

方法 A:强制提交(推荐)

如果文件内容没变,但仍想触发流水线,可以做一个“空提交”:

在项目根目录下运行

git commit --allow-empty -m "chore: force update SSL certificates and trigger deploy"
git push origin main
方法 B:检查是否真的替换成功
  1. 手动删除 nginx/cert/ 下的 .key 和 .pem
  2. 重新从 .zip 包里解压。
  3. 运行 git status。如果还是显示 clean,说明内容确实没变。

我是怎么用 GPT-5.5 分析一个陌生项目源码的

作者 知了清语
2026年4月27日 15:43

拿到一个完全陌生的项目,怎么用 GPT-5.5 快速看懂它。

这次我分析的是一个叫 autofit.js 的项目。我是随机选取的一个 github上开源的项目。

  1. 分析源码结构
  2. 梳理核心模块关系
  3. 画出架构图
  4. 最后把整个学习过程沉淀成笔记

这一套流程下来,我对 GPT-5.5 的感受很直接:

它最有价值的地方,不是替你“看代码”,而是替你把理解过程结构化。

一、为什么我要用 GPT-5.5 分析源码

平时拿到一个新项目,最常见的问题不是“看不懂某一行”,而是:

  • 不知道入口在哪
  • 不知道哪些模块是核心
  • 不知道主流程怎么走
  • 看完源码之后,很难快速整理成输出材料

尤其是当你不是去维护它,而是去学习它、理解它、吸收它的时候,这种“从陌生到建立结构认知”的过程特别重要。

所以这次我没有直接自己从头硬啃,而是让 GPT-5.5 参与整个过程。

我的思路不是让它替我完成学习,而是让它成为一个:

源码阅读助手 + 结构整理助手 + 文档输出助手 + 图示生成助手

二、这次实操的真实流程

1. 先给出目标,不让 AI 只停留在“解释代码”

我一开始给它的任务,不是简单一句“帮我看懂这个项目”,而是带着明确结果去提问:

  • 分析 磁盘目录\autofit.js 里面的源码
  • 然后绘制架构图
  • 源码分析输出到这个目录 root 中

这个目标很关键。

因为如果你只说“分析一下源码”,AI 很容易停在泛泛解释层面;但你一旦要求它产出文件、产出架构图、落到目录里,它就会开始从“聊天回答”切换到“交付结果”的模式。

2. 它先读外围信息,而不是直接扎进源码细节

项目结构确认之后,它没有马上逐行解释 src/index.ts,而是先去读:

  • package.json
  • readme.md
  • readme.en.md
  • rolldown.config.ts
  • rolldown.config.dev.ts

这个顺序非常对。

因为理解一个陌生项目,最有效的方法通常不是一上来盯着实现细节,而是先看外围:

  • 这个项目是干什么的
  • 它的入口文件是什么
  • 它怎么构建
  • 它暴露哪些能力
  • 它是给谁用的

这一层上下文一旦建立起来,后面再看 src 目录,理解速度会快很多。

3. 再进入核心源码,抓主流程而不是死抠所有细节

接下来它重点分析了 src 目录下的几个核心文件:

  • index.ts
  • strategy.ts
  • rectification.ts
  • state.ts
  • types.ts

然后把整个项目的骨架梳理出来了:

  • index.ts 是对外 API 门面
  • init() 是初始化入口
  • off() 负责清理副作用
  • keepFit() 是核心缩放策略
  • state.ts 保存共享运行时状态
  • elRectification() 处理局部元素修正

这一点很像一个有经验的工程师在带你读代码:
它不是先解释每一行,而是先告诉你:

这几个模块分别是什么角色,它们之间怎么协作。

对于学习一个陌生项目来说,这种方式比“从上到下翻译代码”有效太多了。

4. 不只是分析,还输出成 Markdown 文档

学习过程不是“看完就结束”,而是被沉淀成了可以复用的材料。

这很重要。因为很多时候我们自己读完源码,脑子里好像懂了,但过两天就忘了。
而一份结构化文档,能把你的“当下理解”固化下来,之后回看成本会低很多。

三、从“看懂源码”到“画出架构图”

如果说源码分析解决的是“理解”,那架构图解决的就是“表达”。

这次我直接指定:

使用 architecture-diagram skill 再绘制一个架构图,名称叫 autofit.js-架构图

四、实际用到的 skill

1. architecture-diagram

这个是这次画架构图的关键 skill。它的特点比较明确:

  • 深色主题
  • 内联 SVG
  • 适合画技术架构图
  • 有固定的组件配色和图例风格

用它来画源码模块关系图,比让模型自由发挥要稳定得多。

image.png

五、 Prompt

分析 目录\autofit.js 目录 里面的源码。然后绘制架构图,源码分析输出到这个目录的 root 中。使用 architecture-diagram skill 再绘制一个架构图,名称叫 autofit.js-架构图

六、我从这次过程里总结出的几个经验

1. 不要只让 AI “解释”,要让它“交付” 产物

如果只是问“这段代码是什么意思”,你得到的通常还是聊天式回答。
但如果你要求它:

  • 生成文档
  • 画图
  • 输出到目录
  • 修改已有文件

它就更像一个真正参与工作的助手。

2. 分析陌生项目时,先看外围再看核心

这次 GPT-5.5 的处理顺序我很认可:

先 package.json、README、构建配置,
再 src 目录核心文件,
最后再整理模块关系。

七、最后

GPT-5.5 在源码分析场景里,最强的能力不是替你读代码,而是帮你建立理解框架。

它能帮你:

  • 快速定位项目入口
  • 提炼核心模块
  • 梳理调用链和状态流转
  • 把源码理解沉淀成文档
  • 再进一步变成架构图和学习笔记

对我来说,这已经不只是“AI 辅助编码”了,
而是“AI 辅助学习和知识建模”。

vue3+lodash+ts+tailwin 实现多行文本的展开收起代码(支持渲染html)

作者 脱缰胖虎
2026年4月27日 15:41

07a7ae24fef487dbb588a967f3803000.jpg

546cc5d2086a8d3c425bd07710220ae9.jpg

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { debounce } from 'lodash-es'

interface Props {
  text: string
  maxLines?: number
  expandText?: string
  collapseText?: string
  expandClass?: string
  collapseClass?: string
}

const props = withDefaults(defineProps<Props>(), {
  maxLines: 3,
  expandText: '展开',
  collapseText: '收起',
  expandClass: 'text-blue-500',
  collapseClass: 'text-blue-500',
})

const containerRef = ref<HTMLElement>()
const expanded = ref(false)
const isTruncated = ref(false)
const truncatedHtml = ref(props.text)

// ─── HTML 工具 ───────────────────────────────────────────────

/** 块级标签集合:仅这些标签会被认为产生新行,用于"在最后一行末尾追加"判断 */
const BLOCK_TAGS = new Set([
  'DIV', 'P', 'SECTION', 'ARTICLE', 'BLOCKQUOTE',
  'LI', 'UL', 'OL', 'PRE',
  'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
])

/**
 * 把 suffixHtml 注入到 html 的「最深一个块级容器」内部末尾,
 * 保证它与最后一行可见文字处于同一内联流。
 * 碰到 <strong> 等行内元素会停住,避免继承粗体等样式。
 */
function appendInsideLastBlock(html: string, suffixHtml: string): string {
  const wrapper = document.createElement('div')
  wrapper.innerHTML = html
  let target: Element = wrapper
  while (target.lastElementChild && BLOCK_TAGS.has(target.lastElementChild.tagName)) {
    target = target.lastElementChild
  }
  target.insertAdjacentHTML('beforeend', suffixHtml)
  return wrapper.innerHTML
}

/** 把 HTML 字符串转成纯文本(保留换行语义) */
function htmlToPlainText(html: string): string {
  const div = document.createElement('div')
  div.innerHTML = html
  // <br> / <p> / <div> 换成换行,方便行高量测一致
  div.querySelectorAll('br').forEach(br => br.replaceWith('\n'))
  div.querySelectorAll('p, div').forEach(el => {
    el.prepend('\n')
  })
  return div.innerText ?? div.textContent ?? ''
}

/**
 * 将"纯文本截断到第 visibleLen 个字符"映射回原始 HTML,
 * 返回一段合法闭合的 HTML 片段。
 *
 * 思路:遍历原始 HTML 字符,跳过标签字符,只计可见字符数;
 * 找到第 visibleLen 个可见字符在原始字符串中的位置后截断,
 * 再用 DOMParser 补全未闭合标签。
 */
function sliceHtmlByVisibleLen(html: string, visibleLen: number): string {
  let visible = 0
  let i = 0
  let inTag = false

  while (i < html.length && visible < visibleLen) {
    const ch = html[i]
    if (ch === '<') {
      inTag = true
    } else if (ch === '>') {
      inTag = false
    } else if (!inTag) {
      visible++
    }
    i++
  }

  // i 现在指向截断位置(继续把当前标签走完,避免截断在标签内部)
  if (inTag) {
    const closeIdx = html.indexOf('>', i)
    i = closeIdx === -1 ? html.length : closeIdx + 1
  }

  const raw = html.slice(0, i)

  // 用 DOMParser 补全未闭合标签
  const doc = new DOMParser().parseFromString(raw, 'text/html')
  return doc.body.innerHTML
}

// ─── 样式量测 ────────────────────────────────────────────────

function getLineHeight(el: HTMLElement): number {
  const lh = parseFloat(getComputedStyle(el).lineHeight)
  return isNaN(lh) ? parseFloat(getComputedStyle(el).fontSize) * 1.5 : lh
}

function createMeasureEl(el: HTMLElement, width: number): HTMLDivElement {
  const cs = getComputedStyle(el)
  const div = document.createElement('div')
  div.style.cssText = `
    position: absolute;
    visibility: hidden;
    pointer-events: none;
    width: ${width}px;
    font-size: ${cs.fontSize};
    font-family: ${cs.fontFamily};
    font-weight: ${cs.fontWeight};
    line-height: ${cs.lineHeight};
    letter-spacing: ${cs.letterSpacing};
    word-break: ${cs.wordBreak};
    white-space: ${cs.whiteSpace};
  `
  document.body.appendChild(div)
  return div
}

// ─── 截断计算 ────────────────────────────────────────────────

function calcTruncation() {
  const el = containerRef.value
  if (!el || expanded.value) return

  const cs = getComputedStyle(el)
  const width = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)
  if (width <= 0) return

  const lineHeight = getLineHeight(el)
  const maxHeight = lineHeight * props.maxLines
  const measureEl = createMeasureEl(el, width)

  // 用 innerHTML 量高,与实际渲染一致
  measureEl.innerHTML = props.text
  const fullHeight = measureEl.scrollHeight

  if (fullHeight <= maxHeight+1) {
    document.body.removeChild(measureEl)
    isTruncated.value = false
    truncatedHtml.value = props.text
    return
  }

  isTruncated.value = true

  // 二分搜索操作纯文本字符数
  const plain = htmlToPlainText(props.text)
  const suffix = `...${props.expandText}x` // 占位 x 抵消 ml-0.5 偏差

  let lo = 0
  let hi = plain.length

  while (lo < hi) {
    const mid = Math.floor((lo + hi + 1) / 2)
    const slicedHtml = sliceHtmlByVisibleLen(props.text, mid)
    // 把 suffix 注入到最后一个块级容器内部,量测才会跟实际渲染一致
    measureEl.innerHTML = appendInsideLastBlock(slicedHtml, suffix)
    if (measureEl.scrollHeight <= maxHeight+1) {
      lo = mid
    } else {
      hi = mid - 1
    }
  }

  document.body.removeChild(measureEl)
  truncatedHtml.value = sliceHtmlByVisibleLen(props.text, lo)
}

// ─── 生命周期 & 侦听 ─────────────────────────────────────────

const debouncedCalc = debounce(calcTruncation, 100)
let resizeObserver: ResizeObserver | null = null

onMounted(() => {
  nextTick(() => {
    calcTruncation()
    if (containerRef.value) {
      resizeObserver = new ResizeObserver(debouncedCalc)
      resizeObserver.observe(containerRef.value)
    }
  })
})

onUnmounted(() => {
  resizeObserver?.disconnect()
  debouncedCalc.cancel()
})

watch(
  () => [props.text, props.maxLines],
  () => {
    expanded.value = false
    nextTick(calcTruncation)
  },
)

// ─── 展开 / 收起 ─────────────────────────────────────────────

function expand() {
  expanded.value = true
}

function collapse() {
  expanded.value = false
  nextTick(calcTruncation)
}

// ─── 最终渲染 HTML ───────────────────────────────────────────

/**
 * 把按钮 HTML 注入到内容末尾。
 * 展开态:全文 + 收起按钮
 * 收起态:截断 HTML + ...展开按钮
 */
const btnClass = computed(() =>
  `inline ml-0.5 cursor-pointer bg-transparent border-none p-0 [font-family:inherit] [font-size:inherit] [line-height:inherit]`,
)

const renderedHtml = computed(() => {
  if (expanded.value) {
    const collapseBtn =
      `<button class="${btnClass.value} ${props.collapseClass}"
               onclick="this.dispatchEvent(new CustomEvent('collapse', { bubbles: true }))"
       >${props.collapseText}</button>`
    return appendInsideLastBlock(props.text, collapseBtn)
  }
  if (isTruncated.value) {
    const expandBtn =
      `...<button class="${btnClass.value} ${props.expandClass}"
                  onclick="this.dispatchEvent(new CustomEvent('expand', { bubbles: true }))"
       >${props.expandText}</button>`
    return appendInsideLastBlock(truncatedHtml.value, expandBtn)
  }
  return props.text
})
</script>

<template>
  <div
    ref="containerRef"
    v-html="renderedHtml"
    @expand="expand"
    @collapse="collapse"
  />
</template>
❌
❌