普通视图

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

uniq Command in Linux: Remove and Count Duplicate Lines

uniq is a command-line utility that filters adjacent duplicate lines from sorted input and writes the result to standard output. It is most commonly used together with sort to remove or count duplicates across an entire file.

This guide explains how to use the uniq command with practical examples.

uniq Command Syntax

The syntax for the uniq command is as follows:

txt
uniq [OPTIONS] [INPUT [OUTPUT]]

If no input file is specified, uniq reads from standard input. The optional OUTPUT argument redirects the result to a file instead of standard output.

Removing Duplicate Lines

By default, uniq removes adjacent duplicate lines, keeping one copy of each. Because it only compares consecutive lines, the input must be sorted first — otherwise only back-to-back duplicates are removed.

Given a file with repeated entries:

fruits.txttxt
apple
apple
banana
cherry
banana
cherry
cherry

Running uniq on the unsorted file removes only the back-to-back duplicates (apple apple and cherry cherry), but leaves the second banana and the second cherry intact:

Terminal
uniq fruits.txt
output
apple
banana
cherry
banana
cherry

To remove all duplicates regardless of position, sort the file first:

Terminal
sort fruits.txt | uniq
output
apple
banana
cherry

Counting Occurrences

To prefix each output line with the number of times it appears, use the -c (--count) option:

Terminal
sort fruits.txt | uniq -c
output
 2 apple
2 banana
3 cherry

The count and the line are separated by whitespace. This is especially useful for finding the most frequent lines in a file. To rank them from most to least common, pipe the output back to sort:

Terminal
sort fruits.txt | uniq -c | sort -rn
output
 3 cherry
2 banana
2 apple

Showing Only Duplicate Lines

To print only lines that appear more than once (one copy per group), use the -d (--repeated) option:

Terminal
sort fruits.txt | uniq -d
output
apple
banana
cherry

To print every instance of a repeated line rather than just one, use -D:

Terminal
sort fruits.txt | uniq -D
output
apple
apple
banana
banana
cherry
cherry
cherry

Showing Only Unique Lines

To print only lines that appear exactly once (no duplicates), use the -u (--unique) option:

Terminal
sort fruits.txt | uniq -u

If every line appears more than once, the output is empty. In the example above, all three fruits are duplicated, so nothing is printed.

Ignoring Case

By default, uniq comparisons are case-sensitive, so Apple and apple are treated as different lines. To compare lines case-insensitively, use the -i (--ignore-case) option:

Terminal
sort -f file.txt | uniq -i

Skipping Fields and Characters

uniq can be told to skip leading fields or characters before comparing lines.

To skip the first N whitespace-separated fields, use -f N (--skip-fields=N). This is useful when lines share a common prefix (such as a timestamp) that should not be part of the comparison:

log.txttxt
2026-01-01 ERROR disk full
2026-01-02 ERROR disk full
2026-01-03 WARNING low memory
Terminal
uniq -f 1 log.txt
output
2026-01-01 ERROR disk full
2026-01-03 WARNING low memory

The first field (the date) is skipped, so the two ERROR disk full lines are treated as duplicates.

To skip the first N characters instead of fields, use -s N (--skip-chars=N). To limit the comparison to the first N characters per line, use -w N (--check-chars=N).

Combining uniq with Other Commands

uniq works well in pipelines with grep , cut , sort , and wc .

To count the number of unique lines in a file:

Terminal
sort file.txt | uniq | wc -l

To find the top 10 most common words in a text file:

Terminal
grep -Eo '[[:alnum:]]+' file.txt | sort | uniq -c | sort -rn | head -10

To list unique IP addresses from an access log:

Terminal
awk '{print $1}' /var/log/nginx/access.log | sort | uniq

To find lines that appear in one file but not another (using -u against merged sorted files):

Terminal
sort file1.txt file2.txt | uniq -u

Quick Reference

Command Description
sort file.txt | uniq Remove all duplicate lines
sort file.txt | uniq -c Count occurrences of each line
sort file.txt | uniq -c | sort -rn Rank lines by frequency
sort file.txt | uniq -d Show only duplicate lines (one per group)
sort file.txt | uniq -D Show all instances of duplicate lines
sort file.txt | uniq -u Show only lines that appear exactly once
uniq -i file.txt Compare lines case-insensitively
uniq -f 2 file.txt Skip first 2 fields when comparing
uniq -s 5 file.txt Skip first 5 characters when comparing
uniq -w 10 file.txt Compare only first 10 characters

Troubleshooting

Duplicates are not removed uniq only removes adjacent duplicate lines. If the file is not sorted, non-consecutive duplicates are not detected. Always sort the input first: sort file.txt | uniq.

-c output has inconsistent spacing The count is right-aligned and padded with spaces. If you need to process the output further, use awk to normalize spacing while preserving the full line text: sort file.txt | uniq -c | awk '{count=$1; $1=\"\"; sub(/^ +/, \"\"); print count, $0}'.

Case variants are treated as different lines Use the -i option to compare case-insensitively. Also sort with -f so that case-insensitive duplicates are adjacent before uniq processes them: sort -f file.txt | uniq -i.

FAQ

What is the difference between sort -u and sort | uniq? Both produce the same output for simple deduplication. sort -u is slightly more efficient. sort | uniq is more flexible because uniq supports options like -c (count occurrences) and -d (show only duplicates) that sort -u does not.

Does uniq modify the input file? No. uniq reads from a file or standard input and writes to standard output. The original file is never modified. To save the result, use redirection: sort file.txt | uniq > deduped.txt.

How do I count unique lines in a file? Pipe through sort, uniq, and wc : sort file.txt | uniq | wc -l.

How do I find lines that only appear in one of two files? Merge both files and use uniq -u: sort file1.txt file2.txt | uniq -u. Lines shared by both files become duplicates in the merged sorted stream and are suppressed. Lines that exist in only one file remain unique and are printed.

Can uniq work on columns instead of full lines? Yes. Use -f N to skip the first N fields, -s N to skip the first N characters, or -w N to limit comparison to the first N characters. This lets you deduplicate based on a portion of each line.

Conclusion

The uniq command is a focused tool for filtering and counting duplicate lines. It is most effective when used after sort in a pipeline and pairs naturally with wc , grep , and head for text analysis tasks.

If you have any questions, feel free to leave a comment below.

pkill Cheatsheet

Basic Syntax

Core pkill command forms.

Command Description
pkill process_name Send SIGTERM to matching process names
pkill -f "pattern" Match against full command line
pkill -u username process_name Match only processes owned by a user
pkill -x process_name Match exact process name only
pkill -l process_name Kill matching processes and print their name and PID

Common Signals

Frequently used signals with pkill.

Command Description
pkill -15 process_name Graceful stop (SIGTERM, default)
pkill -9 process_name Force kill (SIGKILL)
pkill -HUP process_name Reload/reopen config for daemons
pkill -INT process_name Interrupt process (SIGINT)
pkill -USR1 process_name Send user-defined signal 1

Match Controls

Limit matches to avoid terminating the wrong process.

Command Description
pkill -x nginx Kill only exact nginx process name
pkill -f "python3 app.py" Match a specific command string
pkill -u deploy -x node Match exact node only for user deploy
pkill -t pts/2 Match processes attached to terminal pts/2
pkill -P 1234 Match child processes of PID 1234

Safer Workflow

Preview targets before signaling processes.

Command Description
pgrep -a nginx Preview matching processes and command lines
pgrep -afu deploy node Preview user-scoped full-command matches
pgrep -f "python3 app.py" Confirm full-pattern matches first
pkill -x nginx Execute only after preview validation
echo $? Check exit code (0 match found, 1 none found)

Service and App Examples

Practical process control patterns.

Command Description
pkill -HUP nginx Ask Nginx master process to reload
pkill -u www-data -x php-fpm Stop php-fpm workers for one user
pkill -f "gunicorn: worker" Signal Gunicorn worker processes
pkill -f "node server.js" Stop a specific Node.js app instance
pkill -x firefox Close all Firefox processes for current user

Troubleshooting

Quick checks for common pkill issues.

Issue Check
Nothing happened Verify matches with pgrep -a using the same pattern
Wrong process was terminated Use -x or stricter -f pattern matching
Operation not permitted Use sudo or run as the process owner
Process did not stop Try SIGTERM first, then escalate to -9 only if needed
Script fails when nothing matches Handle exit code 1 as a non-match condition

Related Guides

Use these guides for deeper signal and process-management workflows.

Guide Description
pkill Command in Linux Full pkill guide with examples
kill Command in Linux Send signals by PID
pgrep Command in Linux Search processes by name and pattern
ps Command in Linux Inspect current process list
top Command in Linux Monitor processes in real time

echarts实例:进度条加描述

2026年3月4日 16:24

记录下工作中使用echarts做出的特殊组件

image.png

import { defineComponent } from 'vue'
import { FONT_SIZE, COLOR_LIGHT_ORG, COLOR_YELLOW, COLOR_LIGHT_GREEN } from './createChart'


let props = {
  propData: {
    type: Array,
    default: () => [
      { name: '名称1', value: 353, color: COLOR_YELLOW, desc: '{x}条描述' },
      { name: '名称2', value: 85, color: COLOR_LIGHT_GREEN, desc: '{2}条描述' },
      { name: '名称3', value: 30, color: '#FF6B6B', desc: '包括{名称3} XX{2}条描述,XX{4}条描述,XX{2}条描述' },
      { name: '名称4', value: 8, color: '#00BAAE', desc: '' },
    ],
  },
  barWidth: {
    default: 25,
    type: Number,
  },
}
const colors = [COLOR_LIGHT_ORG, COLOR_YELLOW, COLOR_LIGHT_GREEN, '#7B68EE', '#FF6B6B', '#4ECDC4'] // 添加更多备用颜色


export default defineComponent({
  props,
  data() {
    return {
      fontSize: FONT_SIZE + 4,
    }
  },
  created() {},
  mounted() {
    this.init()
    this.$watch(
      () => this.$props, // 监听整个 props 对象
      () => {
        this.init()
      },
      { deep: true, immediate: false },
    )
  },
  beforeDestroy() {
    this.chart?.dispose?.()
  },
  methods: {
    init() {
      const option = this.getOption() // 间隙
      const dom = this.$refs.chart
      if (!this.chart) {
        this.chart = echarts.init(dom, null, {
          renderer: 'canvas',
        })
      }
      this.chart.setOption(option, true)
    },
    getOption() {
      if (!this.propData.length) return {}
      const seriesdata = this.getData()
      const series = this.getSeries(seriesdata)
      const maxValue = this.getMaxValue(this.propData)
      const seriesBg = this.getBackgroundBarSeries(maxValue)
      return {
        legend: {
          show: false,
        },
        tooltip: {
          show: false,
        },
        grid: {
          left: '6%',
          right: '5%',
          bottom: '0%',
          top: '1%',
          containLabel: true,
        },
        xAxis: [
          {
            type: 'value',
            axisLine: {
              show: false,
            },
            splitLine: {
              show: false,
            },
            axisLabel: {
              show: false,
            },
            axisTick: {
              show: false,
            },
          },
        ],
        yAxis: [
          {
            type: 'category',
            data: _.map(this.propData, 'name'),
            axisLine: {
              show: false,
            },
            splitLine: {
              show: false,
            },
            axisLabel: {
              show: true,
              fontSize: this.fontSize,
              textStyle: {
                color: '#fff',
              },
              align: 'left', // 左对齐
              padding: [0, 0, 0, -150], // 去掉内边距
              interval: 0,
            },
            axisTick: {
              show: false,
            },
          },
          {
            type: 'category',
            data: this.propData,
            offSet: -10,
            axisLine: {
              show: false,
            },
            splitLine: {
              show: false,
            },
            axisLabel: {
              show: false,
            },
            axisTick: {
              show: false,
            },
          },
          {
            type: 'category',
            data: this.propData,
            axisLine: {
              show: false,
            },
            splitLine: {
              show: false,
            },
            axisLabel: {
              show: false,
            },
            axisTick: {
              show: false,
            },
          },
        ],
        series: [...seriesBg, ...series],
      }
    },

    // 获取最大值
    getMaxValue(data) {
      const values = _.map(data, 'value')
      return Math.max(...values)
    },
    getData() {
      return this.propData.map((item, index) => {
        const color = item.color || colors[index] || '#ffffff'
        return {
          ...item,
          label: {
            show: true,
            position: [0, 0],
            fontSize: this.fontSize - 5,
            offset: [20, -70],
            color: '#fff',
            formatter: (params, index) => {
              const text = this.propData[params.dataIndex]?.desc || ''
              // 将文本拆分成数组,然后组合成富文本字符串
              const parts = text.split(/(\{[^}]+\})/)
              let richText = ''
              parts.forEach((part) => {
                if (part.startsWith('{') && part.endsWith('}')) {
                  // 移除花括号
                  const value = part.substring(1, part.length - 1)
                  richText += `{num|${value}}`
                } else if (part) {
                  richText += part
                }
              })
              return richText
            },
            rich: {
              num: {
                color, // 动态颜色
                fontSize: this.fontSize ,
                padding: [0, 5, 0, 5],
              },
            },
          },
          itemStyle: {
            color: {
              type: 'linear',
              x: 0,
              y: 0, // 上
              x2: 1,
              y2: 0, // 下
              colorStops: [
                {
                  offset: 0,
                  color: echarts.color.modifyAlpha(color, 0.1),
                },
                {
                  offset: 0.3,
                  color: echarts.color.modifyAlpha(color, 0.6),
                },
                {
                  offset: 1,
                  color: echarts.color.modifyAlpha(color, 1),
                },
              ],
            },
            shadowColor: 'rgba(255,255,255,0.2)',
            shadowBlur: 5,
            shadowOffsetX: 0,
            shadowOffsetY: 0,
          },
        }
      })
    },
    getSeries(data) {
      return [
        {
          name: '主系列',
          type: 'bar',
          stack: 'total',
          barWidth: this.barWidth,
          yAxisIndex: 0,
          z: 2,
          data,
        },
      ]
    },
    // 最大值为背景底部进度条
    getBackgroundBarSeries(maxValue) {
      const config = {
        data: this.propData.map(() => {
          return maxValue
        }),
        tooltip: { show: false },
        showInLegend: false,
      }
      const series = [
        {
          ...config,
          type: 'bar',
          name: '背景bar',
          barWidth: this.barWidth,
          itemStyle: {
            color: 'rgba(255, 255, 255,0.13)',
            borderWidth: 0,
          },

          yAxisIndex: 1,
        },
        {
          ...config,
          type: 'bar',
          name: '背景框',
          data: this.propData.map((i, index) => {
            return {
              value: maxValue + 5,
              label: {
                color: this.propData[index]?.color || '#fff',
              },
            }
          }),
          barWidth: this.barWidth + 20,
          itemStyle: {
            color: 'rgba(255, 255, 255,0)',
            borderColor: '#fff',
            borderWidth: 2,
            borderRadius: 0,
          },
          label: {
            show: true,
            position: 'right',
            fontSize: this.fontSize+10,
            fontFamily: 'TRENDS',
            offset: [50, 0],
            formatter: (params) => {
              return this.propData[params.dataIndex]?.value || 0
            },
          },
          yAxisIndex: 2,
        },
      ]
      return series
    },
  },
})

并发 401 下的 Token 刷新竞态:一个被低估的 Bug

作者 ZengLiangYi
2026年3月4日 16:22

当多个请求同时遇到 401 时,朴素实现会触发多次 token 刷新,导致 race condition。用一个 isRefreshing 标志 + 订阅者队列可以彻底解决——但大多数实现里存在一个隐藏的 Promise 泄漏问题。

本文假设你熟悉 async/await、HTTP 拦截器(axios/fetch)和 JWT 认证基础。


问题:并发 401 不止一个

实现过 token 刷新的人,第一版代码大概长这样:

// ❌ 朴素实现
axios.interceptors.response.use(null, async (error) => {
  if (error.response?.status === 401) {
    const newToken = await refreshToken();
    error.config.headers.Authorization = `Bearer ${newToken}`;
    return axios(error.config);
  }
  return Promise.reject(error);
});

单个请求失效时,这完全够用。但在真实应用里,你的页面同时发出 5 个请求是常态——Dashboard 加载时并行请求用户信息、通知数量、最新数据……

当 token 在这 5 个请求飞行途中过期:

Request A401refreshToken() ─┐
Request B401refreshToken()  │← 同时触发 5 次刷新
Request C → 401refreshToken()  │
Request D → 401refreshToken() ─┘
Request E → 401refreshToken()

每次刷新都会使上一次发出的 refresh_token 失效(轮换机制)。结果是:第一个刷新成功,其余四个用过期的 refresh_token 刷新——全部失败,用户被踢回登录页。


心理模型:收银台排队

把并发请求想象成超市收银台:

  • 朴素实现:每个顾客(请求)都跑去叫店长(刷新 token)。店长同时被 5 个人拉着,什么都做不了。
  • 正确实现:第一个顾客去叫店长,其他人在收银台前排队等候。店长回来后,所有人一起结账(用新 token 重试)。

实现这个逻辑只需要两个变量:

let isRefreshing = false;          // 店长是否在处理中
let subscribers: Subscriber[] = []; // 排队等待的顾客

实现:带队列的刷新机制

完整实现分四个部分:

1. 订阅者类型

// newToken 为字符串时表示刷新成功,为 null 时表示刷新失败
type Subscriber = (newToken: string | null) => void;

let isRefreshing = false;
let subscribers: Subscriber[] = [];

注意 string | null 的设计——这是避免 Promise 泄漏的关键,后面详述。

2. 队列管理

function addSubscriber(callback: Subscriber) {
  subscribers.push(callback);
}

function notifySubscribers(newToken: string | null) {
  subscribers.forEach((cb) => cb(newToken));
  subscribers = [];
}

3. 核心调度逻辑

export async function handleUnauthorized<T>(
  doRefresh: () => Promise<string | null>,
  doRetry: (newToken: string) => Promise<T>,
  onFailure: () => void,
): Promise<T> {
  // 已有刷新进行中 → 排队等待
  if (isRefreshing) {
    return new Promise<T>((resolve, reject) => {
      addSubscriber((newToken) => {
        if (newToken) {
          doRetry(newToken).then(resolve).catch(reject);
        } else {
          reject(new Error('Token refresh failed'));
        }
      });
    });
  }

  // 发起刷新
  isRefreshing = true;
  const newToken = await doRefresh();

  if (newToken) {
    notifySubscribers(newToken); // 通知队列重试
    isRefreshing = false;
    return doRetry(newToken);
  }

  // 刷新失败:通知队列(传 null),然后执行失败处理
  notifySubscribers(null);
  isRefreshing = false;
  onFailure();
  return Promise.reject(new Error('Token refresh failed'));
}

4. 接入 Axios 拦截器

axios.interceptors.response.use(null, (error) => {
  const { response, config } = error;

  // 只处理 401,跳过登录和刷新接口本身
  if (response?.status !== 401) return Promise.reject(error);
  if (config?.url?.includes('/auth/login')) return Promise.reject(error);
  if (config?.url?.includes('/auth/refresh')) {
    clearStorage();
    window.location.href = '/login';
    return Promise.reject(error);
  }

  return handleUnauthorized(
    () => fetchNewToken(),
    (newToken) => {
      config.headers.Authorization = `Bearer ${newToken}`;
      return axios(config);
    },
    () => {
      clearStorage();
      window.location.href = '/login';
    },
  );
});

现在同样的并发场景:

Request A → 401 → isRefreshing=false → 发起刷新 → isRefreshing=true
Request B → 401 → isRefreshing=true  → 加入队列
Request C → 401 → isRefreshing=true  → 加入队列
Request D → 401 → isRefreshing=true  → 加入队列

刷新成功 → notifySubscribers(newToken) → B、C、D 用新 token 重试 ✅

隐藏的 Bug:Promise 泄漏

这是大多数网上教程里存在的问题,包括一些知名库的早期版本。

当刷新失败时,朴素实现通常这样写:

// ❌ 有 Bug 的版本
isRefreshing = false;
subscribers = []; // ← 直接清空!
onFailure();

问题在于:subscribers 数组里存的是 Promise 的 resolve/reject 回调。直接清空等于把这些 Promise 永远挂起——它们既不 resolve 也不 reject,永远 pending

JavaScript 引擎不会回收仍在等待的 Promise(因为理论上它们还能被 resolve)。在 SPA 里,这意味着用户每次遇到刷新失败,都会积累一批无法被 GC 的 Promise 和闭包。

修复方式:通知订阅者失败,让它们主动 reject:

// ✅ 正确版本
notifySubscribers(null); // 传 null → 订阅者收到后调用 reject()
isRefreshing = false;
onFailure();

这就是为什么 Subscriber 的类型是 (newToken: string | null) => void 而不是 (newToken: string) => void


需要注意的边界情况

并发刷新之间的时序

isRefreshing 是模块级变量,在整个应用生命周期内共享。如果两个页面同时初始化(如 iframe 或多标签页共享 localStorage),队列不会跨页面同步——这是该模式的设计边界。多标签页场景需要用 BroadcastChannelSharedWorker

刷新接口本身的 401

必须跳过对刷新接口的重试,否则会死循环:

refreshToken() → 401handleUnauthorized() → refreshToken() → ...

代码里的这一判断不能省:

if (config?.url?.includes('/auth/refresh')) {
  clearStorage();
  window.location.href = '/login';
  return Promise.reject(error);
}

状态重置时机

isRefreshing = false 必须在 notifySubscribers() 之后设置,不能之前。否则队列通知过程中如果又进来新的 401,会再次触发刷新。


取舍与局限

优点 缺点
无额外依赖,纯逻辑 模块级状态,无法跨 iframe/标签页
O(1) 判断,O(n) 通知,性能无影响 刷新超时无内建处理(需自行包装)
与具体 HTTP 客户端解耦 队列顺序不保证(取决于 Promise 执行顺序)

如果你的应用有严格的刷新超时需求,可以在 doRefresh 里用 Promise.race 包一层 timeout:

const doRefresh = () => Promise.race([
  fetchNewToken(),
  new Promise<null>((resolve) => setTimeout(() => resolve(null), 10_000)),
]);

完整代码

token-refresh-queue.ts


延伸阅读

Vite项目中的SVG雪碧图

作者 NB_R
2026年3月4日 16:19

在当前前端开发过程中,图标管理痛点问题解决办法:

1、字体图标(如iconfont)虽然方便,但在高清屏幕下容易模糊,且颜色单一;

2、而直接使用<img>加载多个SVG文件又会带来大量HTTP请求。

3、SVG雪碧图结合了两者的优点:既能保持矢量清晰度,又能合并请求,还可以通过CSS灵活控制颜色。

4、以下介绍如何在Vite项目中使用vite-plugin-svg-icons插件,优雅地实现SVG雪碧图。

什么是SVG雪碧图?

SVG雪碧图的核心思想是将多个SVG图标合并到一个文件中,每个图标用一个<symbol>元素定义,并赋予唯一的id。使用时,通过<use xlink:href="#icon-id">来引用对应的图标。这样做不仅减少了网络请求,而且所有图标都以矢量形式存在,无论放大多少倍都清晰锐利。

一个典型的雪碧图文件结构如下:

xml

<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
  <symbol id="icon-home" viewBox="0 0 24 24">
    <path d="..."/>
  </symbol>
  <symbol id="icon-user" viewBox="0 0 24 24">
    <path d="..."/>
  </symbol>
</svg>

然后在页面中引用:

html

<svg><use xlink:href="#icon-home"/></svg>

为什么选择 vite-plugin-svg-icons?

手动维护雪碧图不仅繁琐,而且容易出错。vite-plugin-svg-icons是专为Vite设计的插件,它可以:

  • 自动扫描指定文件夹中的所有SVG文件,并生成雪碧图;
  • 开发环境实时更新,新增或修改图标无需重启服务;
  • 支持SVGO优化,压缩图标体积;
  • 可自定义symbolId格式,方便在代码中引用;
  • 与Vite完美集成,无需额外配置loader。

原理简述

插件的核心逻辑是在构建时(或开发服务器启动时)读取iconDirs目录下的所有SVG文件,将它们转换为<symbol>片段,并组合成一个大的<svg>元素。在开发模式下,这个<svg>会通过一个虚拟模块动态注入到DOM中;生产构建时,则会将雪碧图打包到最终的assets目录,并通过入口文件中的注册代码自动注入。

快速上手

1. 安装插件

在Vite项目根目录执行:

bash

npm install -D vite-plugin-svg-icons
# 或
yarn add -D vite-plugin-svg-icons

2. 配置 vite.config.js

打开vite.config.js,引入插件并配置:

javascript

import { defineConfig } from 'vite'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import path from 'path'

export default defineConfig({
  plugins: [
    // ... 其他插件
    createSvgIconsPlugin({
      // 指定要缓存的图标文件夹,即存放SVG文件的目录
      iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
      // 指定symbolId格式,这里设置为 'icon-文件名',后面使用 <use href="#icon-xxx"/>
      symbolId: 'icon-[name]',
      // 可选:SVGO压缩配置
      svgoOptions: {
        plugins: [
          // 例如,移除所有图标的fill属性,以便通过CSS控制颜色
          { name: 'removeAttrs', params: { attrs: 'fill' } }
        ]
      }
    })
  ]
})

注意iconDirs路径必须正确指向你的图标文件夹。建议将所有SVG图标存放在src/assets/icons下。

3. 在入口文件中注册插件

在项目的入口文件(如src/main.jssrc/main.ts)中添加一行特殊的导入语句,这行代码会触发插件的运行时逻辑,将雪碧图注入到页面中。

javascript

import { createApp } from 'vue'
import App from './App.vue'
import 'virtual:svg-icons-register'  // 关键!注入雪碧图

createApp(App).mount('#app')

virtual:svg-icons-register是一个由插件提供的虚拟模块,它会在DOM中创建一个隐藏的<svg>元素,包含所有图标的<symbol>定义。

4. 封装一个通用的 SvgIcon 组件

为了使用方便,我们通常封装一个可复用的图标组件。以Vue 3为例,创建一个SvgIcon.vue文件:

vue

<!-- src/components/SvgIcon.vue -->
<template>
  <svg
    aria-hidden="true"
    :class="['svg-icon', $attrs.class]"
    :style="customStyle"
  >
    <use :xlink:href="iconName" />
  </svg>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  name: {
    type: String,
    required: true
  },
  color: {
    type: String,
    default: 'currentColor'
  },
  size: {
    type: [Number, String],
    default: '1em'
  }
})

const iconName = computed(() => `#icon-${props.name}`)
const customStyle = computed(() => ({
  width: typeof props.size === 'number' ? `${props.size}px` : props.size,
  height: typeof props.size === 'number' ? `${props.size}px` : props.size,
  color: props.color
}))
</script>

<style scoped>
.svg-icon {
  vertical-align: -0.15em;  /* 与文字对齐 */
  fill: currentColor;        /* 继承颜色 */
  overflow: hidden;
}
</style>

5. 全局注册组件(可选)

为了让组件在项目中随处可用,可以在入口文件中全局注册:

javascript

// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import 'virtual:svg-icons-register'
import SvgIcon from '@/components/SvgIcon.vue'

const app = createApp(App)
app.component('SvgIcon', SvgIcon)
app.mount('#app')

6. 在页面中使用图标

现在,只需将SVG文件放入src/assets/icons目录,例如home.svguser.svg,然后在任意Vue组件中:

vue

<template>
  <div>
    <!-- 基础用法,使用 home.svg 图标,大小1em,颜色默认 -->
    <SvgIcon name="home" />

    <!-- 自定义颜色和大小 -->
    <SvgIcon name="user" color="#ff6b6b" size="32" />

    <!-- 动态绑定 -->
    <SvgIcon :name="isActive ? 'star-filled' : 'star'" />
  </div>
</template>

最终渲染时,插件会自动生成类似下面的DOM结构:

html

<svg id="__svg__icons__dom__" style="display: none;">
  <symbol id="icon-home" viewBox="...">...</symbol>
  <symbol id="icon-user" viewBox="...">...</symbol>
</svg>

然后<SvgIcon name="home">会生成<use href="#icon-home">,从而显示对应图标。

进阶配置

自定义 symbolId 格式

symbolId选项支持多种占位符:

  • [name]:文件名(不含扩展名)
  • [dir]:相对于iconDirs的目录路径
  • [path]:完整相对路径

例如:symbolId: 'icon-[dir]-[name]',如果你的文件结构是src/assets/icons/common/logo.svg,生成的id就是icon-common-logo

SVG 压缩与优化

通过svgoOptions可以自定义SVGO配置。例如,移除fill属性以允许通过CSS控制颜色:

javascript

svgoOptions: {
  plugins: [
    { name: 'removeAttrs', params: { attrs: 'fill' } }
  ]
}

你也可以完全自定义SVGO配置,详情参考SVGO文档

常见问题与解决

1. 图标不显示?

  • 检查iconDirs路径是否正确,确保SVG文件确实存放在该目录下。
  • 确认在入口文件中已导入'virtual:svg-icons-register'
  • 检查生成的symbolId是否与<use>中的href一致。可以在浏览器开发者工具中查看隐藏的<svg>元素,确认是否有对应的<symbol>存在。

2. 图标颜色无法改变?

SVG文件本身可能自带了fillstroke属性。可以通过SVGO的removeAttrs插件移除这些属性,或者在封装组件时通过CSS覆盖:

css

.svg-icon {
  fill: currentColor;
}

同时在vite.config.js中配置removeAttrs插件(如上所示),以确保图标不包含固定颜色。

参考资料:

vue写一些进度条样式1

2026年3月4日 16:18

不太好用echarts来实现的进度条样式,决定记录下组件

image.png

import { defineComponent } from 'vue'
import { setVeConfig } from './veConfigUtils'
import CommonProgressBar from './CommonProgressBar'
import GeneralTitleCard from './GeneralTitleCard'
import { COLOR_YELLOW, COLOR_LIGHT_GREEN } from './createChart'

const props = {
  propData: {
    type: Array,
    default: () => [
      { name: '家堡4290', value: 22 },
      { name: '家堡4290', value: 20 },
      { name: '家堡4290', value: 18 },
      { name: '家堡4290', value: 15 },
      { name: '家堡4290', value: 10 },
    ],
  },
 
  puValue: {
    default: 10,
    type: Number,
  },
}

export const VeConfig = setVeConfig('DisplayProgressBar', props)

export default defineComponent({
  components: {},
  props,
  data() {
    return {}
  },
  computed: {
    showPropData() {
      return _.orderBy(this.propData, 'value', 'desc')
    },
  },
  created() {},
  mounted() {},
  beforeDestroy() {},
  methods: {
    getBarWidth(value) {
      // 计算百分比,最大不超过100%
      const maxValue = this.puValue + (this.showPropData[0]?.value || 0)
      const percentage = Math.min((value / maxValue) * 100, 100)
      return `${percentage}%`
    },
  },
})

<div class="display-progress-bar ">
  <div class="display-progress-bar__top">
    <span>实际值</span>
    <span class="el-icon-minus"></span>
    <span>标准值</span>
  </div>
  <div class="display-progress-bar__bottom">
    <div v-for="(item, index) in showPropData" :key="item.name + index" class="display-progress-bar__item">
      <section>No.{{ index + 1 }}</section>
      <section>{{ item.name }}</section>
      <section class="bar-boxs">
        <div class="bar-item" :style="{ width: getBarWidth(item.value) }"></div>
        <div class="pu-icon pu-icon__red" :style="{ left: getBarWidth(item.value) }"></div>
        <div class="pu-icon pu-icon__green" :style="{ left: getBarWidth(puValue) }"></div>
      </section>
      <section class="value1">{{ item.value }}km</section>
      <section class="line-icon">
        <span class="el-icon-minus"></span>
      </section>
      <section class="value2">{{ puValue }}km</section>
    </div>
  </div>
</div>


.display-progress-bar {
  // display: flex;
  // flex-direction: column;
  // justify-content: space-between;
  letter-spacing: 5px;
  font-size: 40px;
  padding: 2rem 0 0 0;
  position: relative;

  &__top {
    position: absolute;
    right: 0;
    display: grid;
    grid-template-columns: 150px 5px 150px;
    gap: 2rem;
    top: 0;
    color: #AEAEAE;
    font-size: 32px;

    >span {
      text-align: center;
    }

    .el-icon-minus {
      opacity: 0;
    }
  }

  &__bottom {
    height: 100%;
    overflow-y: auto;
    overflow-x: hidden;
    scroll-behavior: smooth;
    display: flex;
    flex-direction: column;
    justify-content: space-between;

    .display-progress-bar__item {
      display: grid;
      grid-template-columns: 170px 250px 1fr 150px 5px 150px;
      gap: 2rem;
      grid-template-rows: 110px;
      align-items: center;
    }

    section {
      text-align: center;

      &:nth-of-type(1) {
        border: 2px solid #fff;
        padding: 0 5px;
      }

      &:nth-of-type(2) {
        text-align: left;
      }
    }

    .value1 {
      color: #ff432A;
    }

    .value2 {
      color: #18EE8E;
    }

    .line-icon {
      display: flex;
      align-items: center;
      justify-content: center;
    }

    .el-icon-minus {
      transform: rotate(90deg) translate(5px, 0px);
    }

    .bar-boxs {
      width: 100%;
      background: rgba(255, 255, 255, 0.13);
      position: relative;
      height: 25px;

      &::before {
        content: '';
        position: absolute;
        width: calc(100% + 10px);
        height: calc(100% + 10px);
        left: -5px;
        top: -5px;
        border: 1px solid #fff;
      }

      .bar-item {
        height: 100%;
        background: linear-gradient(270deg, #ff432A 0%, rgba(255, 67, 42, 0.42) 100%);
      }

      .pu-icon {
        position: absolute;
        width: 22px;
        height: 88px;
        top: -120%;
        background: url(scn/file/upload/assets/图例icon/红色标幺值icon.png) 100% no-repeat;
      }

      .pu-icon__green {
        background: url(scn/file/upload/assets/图例icon/绿色标幺值icon.png) 100% no-repeat;
      }
    }

    &::-webkit-scrollbar {
      width: 8px; // 滚动条宽度
    }

    &::-webkit-scrollbar-track {
      background: rgba(255, 255, 255, 0.10); // 滚动条轨道背景
      border-radius: 4px;
    }

    &::-webkit-scrollbar-thumb {
      background: rgba(2, 227, 244, 0.5); // 蓝色滚动条滑块
      border-radius: 4px;

      &:hover {
        background: rgba(255, 255, 255, 0.10); // 鼠标悬停时更深一点的蓝色
      }
    }

    &::-webkit-scrollbar-corner {
      background: rgba(255, 255, 255, 0.10); // 滚动条角落背景
    }
  }
}

echarts实例:可堆叠的立体柱形图+特殊symbol的折线图

2026年3月4日 16:10

记录下工作中使用echarts做出的特殊组件

image.png

注意点:柱子颜色不能有透明不然在三角形堆叠的地方会透出来,效果就不好。我这里用了echarts.color.lift给了一个相近的亮色。可以改成自定义的两个颜色做左侧和右侧的立体效果更好。

import { COLOR_GREEN, FONT_SIZE } from './createChart'

let props = {
  // labelDistance: {
  //   type: Array,
  //   default: () => [FONT_SIZE / 2, 0],
  // },
  areaColorArray: {
    type: Array,
    default: () => ['#248BD2', COLOR_GREEN, '#FFE300', '#248BD2', COLOR_GREEN, '#FFE300'],
  },
  barWidth: {
    type: Number,
    default: 50,
  },
  lineWidth: {
    type: Number,
    default: 5,
  },
  propData: {
    type: Array,
    default: () => [
      { name: '2021', value1: 451.844, value2: 337.342, value3: 184.088, value4: 61, value5: 26, value6: 8 },
      { name: '2022', value1: 451.844, value2: 337.342, value3: 184.088, value4: 61, value5: 26, value6: 8 },
      { name: '2023', value1: 465.456, value2: 350.0438, value3: 184.088, value4: 61, value5: 26, value6: 8 },
      { name: '2024', value1: 476.9401, value2: 336.1348, value3: 184.088, value4: 63, value5: 26, value6: 8 },
      { name: '2025', value1: 484.2852, value2: 402.1726, value3: 184.088, value4: 64, value5: 26, value6: 8 },
    ],
  },
  legendData: {
    type: Array,
    default: () => [
      { name: '35kV长度', type: 'bar', unit: 'KM', key: 'value1' },
      { name: '110kV长度', type: 'bar', unit: 'KM', key: 'value2' },
      { name: '220kV长度', type: 'bar', unit: 'KM', key: 'value3' },
      { name: '35kV条数', type: 'line', unit: '条', key: 'value4' },
      { name: '110kV条数', type: 'line', unit: '条', key: 'value5' },
      { name: '220kV条数', type: 'line', unit: '条', key: 'value6' },
    ],
  },

}



export default defineComponent({
  props,
  data() {
    return {}
  },
  created() {},
  mounted() {
    this.init()
    this.$watch(
      () => this.$props, // 监听整个 props 对象
      () => {
        this.init()
      },
      { deep: true, immediate: false },
    )
  },
  beforeDestroy() {
    this.chart?.dispose?.()
  },
  methods: {
    init() {
      const textStyle = {
        fontSize: FONT_SIZE,
        color: '#fff',
        // formatter: ({ value }) => value.toFixed(2),
      }
      const lineStyle = {
        color: '#fff',
        width: 1,
      }
      const legendData = this.legendData

      const option = {
        legend: [
          {
            // 第一个图例 - 用于柱状图
            show: true,
            top: '0%',

            left: '3%',
            itemWidth: 40,
            itemHeight: 40,
            itemGap: 25,
            data: legendData
              .filter((item) => item.type === 'bar')
              .map((item, index) => ({
                name: item.name,
                itemStyle: {
                  color: this.areaColorArray[index] || '#FFFFFF', // 设置图例图标颜色
                },
              })),
            formatter: (name) => {
              return `{text|${name}}`
            },
            textStyle: {
              ...textStyle,
              padding: [0, 10],
              lineHeight: 1, // 增加行高
              rich: {
                text: {
                  fontSize: 36,
                  color: '#fff',
                  padding: [5, 0, 0, 0], // 只移动文字
                },
              },
            },
          },
          {
            // 第二个图例 - 用于折线图
            show: true,
            top: '0%',
            right: '3%',
            itemWidth: 40,
            itemHeight: 10,
            icon: 'rect',
            itemGap: 25,
            data: legendData
              .filter((item) => item.type === 'bar')
              .map((item, index) => ({
                name: item.name,
                itemStyle: {
                  color: this.areaColorArray[index + 3] || '#FFFFFF', // 设置图例图标颜色
                },
              })), // 过滤出折线图系列的图例
            textStyle: {
              ...textStyle,
              padding: [0, 10],
            },
            itemStyle: {
              borderWidth: 0,
              borderColor: 'transparent',
            },
          },
        ],
        tooltip: {
          show: true,
          trigger: 'axis',
          axisPointer: {
            type: 'none',
          },
          appendToBody: true,
          symbolSize: 100,
          formatter: (params) => {
            if (!(params || params[0])) return
            let result = '<div class="chart-tooltip">' + params[0].axisValue + '<br>'
            params.forEach((item, index) => {
              result += `
              <div class="${item.componentSubType}">
              <span class="icon" style="background:${this.areaColorArray[index]}"></span>
              <span class="name">${item.seriesName}:</span>
              <span class="value" style="color:${this.areaColorArray[index]}">${item.value}
              <span class="unit" style="color:#fff">${item.componentSubType === 'bar' ? 'kM' : '条'}</sapn>
              </span>
              </div>`
            })
            result += '</div >'
            return result
          },
          textStyle: {
            fontSize: 50,
            color: '#fff',
          },
          backgroundColor: '#0C3E5F',
          borderColor: '#0CB6FF',
          borderWidth: 4,
        },
        grid: {
          left: '8%',
          right: '6%',
          bottom: '6%',
          top: '15%',
          containLabel: false,
        },
        xAxis: [
          {
            type: 'category',
            axisTick: {
              show: false,
              alignWithLabel: true,
              lineStyle,
            },
            axisLabel: {
              show: true,
              ...textStyle,
              margin: 25,
            },

            axisLine: {
              lineStyle,
            },
            splitLine: {
              show: false,
            },
            data: this.propData.map((item) => item.name),
          },
          {
            type: 'category',
            axisTick: {
              show: false,
            },
            axisLabel: {
              show: false,
            },
            axisLine: {
              lineStyle,
            },
            splitLine: {
              show: false,
            },
            data: this.propData.map((item) => item.name),
          },
        ],
        yAxis: [
          {
            type: 'value',
            name: 'KM',
            nameGap: 0,
            nameLocation: 'end',
            position: 'left',
            nameTextStyle: {
              ...textStyle,
              padding: [0, 50, 50, 0], // 上、右、下、左
            },
            axisLine: {
              show: true,
              lineStyle,
            },
            axisLabel: {
              ...textStyle,
              margin: 25,
            },
            axisTick: {
              show: false,
            },
            splitLine: {
              show: false,
            },
          },
          {
            type: 'value',
            name: '条数',
            nameGap: 0,
            nameLocation: 'end',
            position: 'right',
            nameTextStyle: {
              ...textStyle,
              padding: [0, 0, 50, 0], // 上、右、下、左
            },
            axisLine: {
              show: true,
              lineStyle,
            },
            axisLabel: {
              ...textStyle,
              margin: 25,
            },
            axisTick: {
              show: false,
            },
            splitLine: {
              show: false,
            },
          },
        ],
        series: this.getSeries(),
      }
      this.draw(option)
    },
    draw(option) {
      const dom = this.$refs.chart
      if (!this.chart) {
        this.chart = echarts.init(dom, null, {
          renderer: 'canvas',
        })
      }
      this.chart.setOption(option, true)
    },
    getSeries() {
      let series = []
      series = this.legendData.map((item, index) => {
        let seriesItem = {
          name: item.name,
          type: item.type,
          yAxisIndex: item.type === 'bar' ? 0 : 1,
          data: this.propData.map((dataItem) => dataItem[item.key]),
        }
        if (item.type === 'line') {
          seriesItem = {
            ...seriesItem,
            symbol: 'circle',
            symbolSize: 18,
            color: this.areaColorArray[index],
            lineStyle: {
              color: this.areaColorArray[index], // 单独控制线条颜色
              width: this.lineWidth,
            },
            z: 60,
            itemStyle: {
              color: '#fff', // 单独控制线条颜色
              borderColor: this.areaColorArray[index], // 边框颜色
              borderWidth: 5, // 边框宽度
              shadowColor: 'rgba(0, 0, 0, 0.5)', // 阴影颜色(黑色半透明)
              shadowBlur: 5, // 阴影模糊大小
              shadowOffsetX: 0, // 水平偏移
              shadowOffsetY: 0,
            },
          }
        }

        if (item.type === 'bar') {
          seriesItem = {
            ...seriesItem,
            stack: 'total',
            barWidth: this.barWidth,
            color: this.areaColorArray[index],
            itemStyle: {
              color: {
                x: 0,
                y: 0,
                x2: 1,
                y2: 0,
                type: 'linear',
                global: false,
                colorStops: this.getColorStops(this.areaColorArray[index]),
              },
            },
            label: {
              show: true,
              fontSize: 36,
              color: '#fff',
              fontFamily: 'TRENDS',
              position: 'right',
              distance: 15,
              borderWidth: 1, // 边框宽度
              borderRadius: 3, // 圆角边框
              padding: [15, 15, 0, 15], // 内边距
              backgroundColor: 'rgba(0, 0, 0, 0.24)', // 背景色
              borderColor: this.areaColorArray[index], // 边框颜色
            },
          }
        }
        return seriesItem
      })
      series = [...this.getSeriesLine(series), ...series, ...this.getSeriesPictorialBar(series)]

      return series
    },
    getSeriesLine(series) {
      const seriesLine = []
      series.forEach((item) => {
        if (item.type === 'line') {
          seriesLine.push({
            ...item,
            symbol: 'circle',
            tooltip: { show: false },
            symbolSize: 40,
            lineStyle: {
              color: 'transparent', // 单独控制线条颜色
              width: 0,
            },
            itemStyle: {
              color: echarts.color.modifyAlpha(item.color, 0.5), // 单独控制线条颜色
              borderColor: '#fff', // 边框颜色
              borderWidth: 2, // 边框宽度
            },
          })
        }
      })
      return seriesLine
    },
    // 为了3D效果加的立体面
    getSeriesPictorialBar(series) {
      const pictorialBarSeries = []
      const bar_series = series.filter((item) => {
        return item.type === 'bar'
      })
      const maxData = this.propData.map((item) => {
        return item.value1 + item.value2 + item.value3
      })
      const common_config = {
        name: 'top',
        type: 'pictorialBar',
        symbol: 'diamond',
        symbolPosition: 'end',
        tooltip: { show: false },
        symbolSize: [this.barWidth, this.barWidth / 2],
        symbolOffset: [-0, -this.barWidth / 2 + 11],
        z: 40,
        label: {},
        data: maxData,
        itemStyle: {
          borderColor: echarts.color.modifyAlpha('#666666', 0.9),
          borderWidth: 3, // 边框宽度
          borderType: 'solid', // 边框样式,可选:solid, dashed, dotted
        },
      }

      const colors = _.map(bar_series, 'color')
      bar_series.forEach((item, index) => {
        if (index === bar_series.length - 1) {
          pictorialBarSeries.push({
            ...common_config,
            name: 'top',
            color: item.color,
          })
        } else {
          const data = this.getPictorialBarData(bar_series, index)

          const color = colors.reverse()[index] || '#fff'
          pictorialBarSeries.push({
            ...common_config,
            symbolOffset: [-0, -this.barWidth / 2 + 13],
            z: 20,
            color,
            itemStyle: {
              color: {
                x: 0,
                y: 0,
                x2: 1,
                y2: 0,
                type: 'linear',
                global: false,
                colorStops: this.getColorStops(color),
              },
            },
            data,
          })
        }
      })
      // 中间一根竖线
      pictorialBarSeries.push({
        data: maxData,
        type: 'bar',
        barWidth: 3,
        barGap: '-50%',
        tooltip: { show: false },
        itemStyle: {
          color: echarts.color.modifyAlpha('#666666', 0.2),
        },
        z: 30,
        xAxisIndex: 1,
      })
      return pictorialBarSeries
    },

    getPictorialBarData(seriesData, index) {
      return this.getCumulativeData(_.map(seriesData, 'data').reverse(), index)
    },
    getCumulativeData(data, index) {
      // 获取从 index+1 开始的所有数组
      const remainingArrays = data.slice(index + 1)

      if (remainingArrays.length === 0) {
        return []
      }
      const result2 = remainingArrays[0].map((_, index) => remainingArrays.reduce((sum, row) => sum + row[index], 0))
      return result2
    },
    getColorStops(color) {
      return [
        {
          offset: 0,
          color: echarts.color.lift(color, 0.15),
        },
        {
          offset: 0.5,
          color: echarts.color.lift(color, 0.15),
        },
        {
          offset: 0.5,
          color: echarts.color.modifyAlpha(color, 1),
        },
        {
          offset: 1,
          color: echarts.color.modifyAlpha(color, 1),
        },
      ]
    },
  },
})

可视化大屏适配方案之- px-To-viewport

作者 web_bee
2026年3月4日 16:07

px-To-viewport 方案

1、原理:

不缩放整个页面,而是让页面内的所有元素(字体、间距、宽高)根据屏幕宽度动态变化。

将所有元素(字体、间距、宽高)的单位使用 vw/vh,它理论上是一个比例值;

我们在开发过程中还是参考设计高,使用px单位进行开发,通过插件自动将 px 转化为 vw/vh

核心原理

  1. 基准设定:假设设计稿宽为 1920px

  2. 单位转换

    • 将设计稿中的所有 px 单位,在构建时自动转换为 vw (视口宽度的百分比)
    • 公式1vw = 设计稿宽度 / 100。例如 1920px 设计稿中,100px = 100/1920 * 100vw5.208vw

2、使用:

2.1 安装依赖

我们需要一个 PostCSS 插件,在打包编译时将 CSS 中的 px 自动转换为 vw

npm install postcss postcss-px-to-viewport-8-plugin @tailwindcss/postcss -D

(注:推荐使用 postcss-px-to-viewport-8-plugin,它是原 postcss-px-to-viewport 的维护版,兼容性更好)

这里也顺便测时一下 tailwindcss,所以安装了包:@tailwindcss/postcss

2.2 配置 PostCSS

在项目根目录创建或修改 postcss.config.js 。如果是 Vite 项目 或在 vue.config.js (Webpack) 中配置。

postcss.config.js

module.exports = {
  plugins: {
    '@tailwindcss/postcss': {},
    'postcss-px-to-viewport-8-plugin': {
      viewportWidth: 1920,        // 【核心】设计稿宽度,根据实际设计稿修改 (如 1920, 3840)
      viewportHeight: 1080,       // 设计稿高度 (通常用于 vh 转换,若只转 width 可忽略)
      unitPrecision: 5,           // 转换后的精度,保留几位小数
      propList: ['*', 'font-size'],            // 需要转换的属性列表,'*' 表示所有属性
      selectorBlackList: [],      // 不需要转换的选择器黑名单,例如 ['.no-convert']
      minPixelValue: 1,           // 小于等于 1px 的不转换 (防止 border: 1px 变成 0.00xxx)
      mediaQuery: true,          // 是否在媒体查询 (@media) 中也转换 px
      exclude: [/node_modules/i], // 排除 node_modules 中的文件
      // include: [/src/],        // 如果 exclude 太宽泛,可以用 include 指定只转换 src
      viewportUnit: 'vw',         // 转换成的单位,通常是 'vw'
      fontViewportUnit: 'vw',     // 字体专用的单位,通常也是 'vw'
      
      // 【进阶】针对大屏优化的特殊配置
      // 如果你希望某些特定文件不转换,可以在这里添加逻辑,或者使用 selectorBlackList
    },
  }
};

2.3 结果展示及问题排查

以上就完成了相关配置,然后我们运行项目看看效果;

largeScreen.png

到此时发现了新问题:

使用 px 的元素都正确被转化为了 vw

width: 100px; // 成功转换

当使用tailwindcss 设置的类时,没有被转化为 vw

<div class="w-[100px]"></div> // 未转换

经过反复排查,最终发现问题所在是:

上面tailwindcss的配置方式是基于 tailwindcss V3 版本的;但是项目本地下载的tailwindcss包是 V4版本的,造成postcss解析的过程可能有问题;

现象:tailwindcss 在浏览器上能正常使用,但是不能被 postcss-px-to-viewport-8-plugin 转换;

解决方案:

  1. tailwindcss 版本降到 V3

  2. tailwindcss 的配置方式改为 V4的配置方式:

    npm install tailwindcss @tailwindcss/vite
    

    Vite.config.js

    import { defineConfig } from 'vite'
    import tailwindcss from '@tailwindcss/vite'
    export default defineConfig({
      plugins: [
        tailwindcss(),
      ],
    })
    

3、总结:

优点

  • 真正的自适应:没有黑边,内容永远填满屏幕,无论屏幕是 16:9, 21:9 还是 32:9。
  • 无需 JS 监听 resize:纯 CSS 解决方案 (vw 是 CSS 单位),浏览器原生支持,性能极高,无重排重绘开销。
  • 开发直观:设计师给多少 px,你就写多少 px,构建工具自动转换。

缺点

  1. 字体失控风险

    • 在超宽屏(如 3840px 宽)上,font-size: 16px 会变成 32px 甚至更大,可能导致排版崩坏。
    • 在窄屏笔记本上,字体可能变得极小。
    • 解决:使用 CSS clamp() 函数限制字体范围(需手动写,插件很难自动处理)。
    /* 手动覆盖插件转换,限制字体在 16px 到 32px 之间 */
    .title {
      font-size: clamp(16px, 2.5vw, 32px); 
    }
    
  2. 高度适配问题

    • vw 是基于宽度的。如果屏幕特别扁(宽很大,高很小),按宽度换算的高度 (height: 50vw) 可能会超出屏幕高度,导致出现纵向滚动条。

    • 解决

      • 高度尽量使用 vh (视口高度) 或 %
      • 或者配置插件 viewportUnit: 'vw' 但手动将高度的 px 改为 vh
      • 最稳妥:布局容器用 height: 100vh,内部元素用 flexgrid 分配空间,少用固定 px 高度。
  3. 边框过细

    • 1px 的边框在 4K 屏上转换成 0.5px 左右的 vw,在某些浏览器渲染下可能会消失或模糊。
    • 解决:对于 border,可以在 selectorBlackList 中排除,或者手动写 min-width 逻辑(较难),通常建议设计稿中关键边框适当加粗(如 2px)。

echarts实例:最高最低标识-并列立体柱形图

2026年3月4日 16:05

记录下工作中使用echarts做出的特殊组件

image.png

import { defineComponent, ref, watchPostEffect, onBeforeUnmount } from 'vue'
import { FONT_SIZE, getImagePath, getMax } from './createChart'

const propData = [
  ['年份', '日整点最低负荷', '日整点最高负荷', '年供电量', '年售电量'],
  [2021, 125, 455, 20.62, 19.52],
  [2022, 116, 580, 20.62, 19.52],
  [2023, 131, 510, 20.62, 19.52],
  [2024, 137, 577, 20.62, 19.52],
]
let props = {
  propData: {
    type: Array,
    default: () => propData,
  },
  labelPosition: {
    type: Array,
    default: () => [-30, 10],
  },
  chartExtraOption: {
    type: Object,
    default() {
      return {}
    },
  },
  areaColorArray: {
    type: Array,
    default: () => ['#FFFB00', '#00D8FF', '#28C3FF', '#54D26A'],
  },
  imageVeArray: {
    type: Array,
    default: () =>
      ['黄色圆圈icon', '蓝色圆圈icon'].map((name) => {
        return {
          imageUrl: `scn/file/xxx/assets/图例icon/${name}.png`,
        }
      }),
  },
}


export default defineComponent({
  props,
  setup(props) {
    const chartRef = ref(null)

    let chart
    const drawChart = () => {
      const data = props.propData
    // 下面几行 需要调用初始化绘画图形的方法 类似:
      // const dom = this.$refs.chart
      // if (!this.chart) {
      //   this.chart = echarts.init(dom, null, {
      //     renderer: 'canvas',
      //   })
      // }
      // this.chart.setOption(option, true)
    }
    watchVeProps(props, drawChart)

    watchPostEffect(() => {
      if (!chartRef.value) return
      drawChart()
    })

    onBeforeUnmount(() => {
      if (chart?.dispose) chart.dispose()
    })

    return { chartRef }
  },
})

function getBarChartOption(data, props) {
  const textStyle = {
    fontSize: FONT_SIZE,
    color: '#fff',
    // formatter: ({ value }) => value.toFixed(2),
  }
  const lineStyle = {
    color: '#fff',
    width: 1,
  }

  // const data.slice(1) = data.slice(1)
  const { areaColorArray, imageVeArray, labelPosition } = props
  const legends = (data[0] || []).slice(1)

  const option = _.merge(
    {},
    {
      grid: {
        left: '3%',
        right: '5%',
        bottom: '3%',
        top: '15%',
        containLabel: true,
      },
      tooltip: {
        show: true,
        trigger: 'axis',
        axisPointer: {
          type: 'none',
        },
        appendToBody: true,
        symbolSize: 100,
        formatter: (params) => {
          if (!(params || params[0])) return
          let result = '<div class="chart-tooltip">' + params[0].axisValue + '<br>'
          params.forEach((item, index) => {
            let icon = `<span class="icon" style = "background:${areaColorArray[index]}" > </span>`
            if (item.componentSubType === 'pictorialBar') {
              icon = `<img class="icon" src="${imageVeArray[index].imageUrl}"/>`
            }
            result += `
              <div class="${item.componentSubType}">
              ${icon}
              <span class="name">${item.seriesName}:</span>
              <span class="value" style="color:${areaColorArray[index]}">${item.value}
              <span class="unit" style="color:#fff; font-size: 36px;">${item.componentSubType === 'custom' ? '亿度' : '万千瓦'}</sapn>
              </span>
              </div>`
          })
          result += '</div >'
          return result
        },
        textStyle: {
          fontSize: 50,
          color: '#fff',
        },
        backgroundColor: '#0C3E5F',
        borderColor: '#0CB6FF',
        borderWidth: 4,
      },
      legend: [
        {
          ...getCommonLegend([legends[0]], areaColorArray[0]),
          icon: getImagePath(imageVeArray[1].imageUrl, '', true),
          left: '15%',
        },
        {
          ...getCommonLegend([legends[1]], areaColorArray[1]),
          icon: getImagePath(imageVeArray[0].imageUrl, '', true),
          left: '32%',
        },

        {
          ...getCommonLegend(legends.slice(2, 4), areaColorArray.slice(2, 4)),
          right: '25%',
          itemWidth: 50,
          itemHeight: 30,
          itemGap: 80,
          icon: 'rect',
          itemStyle: {
            borderWidth: 0,
            borderColor: 'transparent',
          },
        },
      ],
      xAxis: [
        {
          data: data.slice(1).map(([month]) => +month + '年'),
          axisLabel: {
            fontSize: FONT_SIZE,
            color: '#fff',
            margin: 10,
          },
          axisLine: {
            show: true,
            lineStyle,
          },
          axisTick: {
            show: false,
          },
          splitLine: {
            show: false,
          },
        },
        {
          data: data.slice(1).map(([month]) => +month + '年'),
          axisLabel: {
            show: false,
          },
          axisLine: {
            show: true,
            lineStyle,
          },
          axisTick: {
            show: false,
          },
          splitLine: {
            show: false,
          },
        },
      ],
      yAxis: [
        {
          type: 'value',
          name: '亿度',
          nameGap: 0,
          nameLocation: 'end',
          position: 'left',
          nameTextStyle: {
            ...textStyle,
            padding: [0, 50, 40, 0], // 上、右、下、左
          },
          axisLine: {
            show: true,
            lineStyle,
          },
          axisLabel: {
            ...textStyle,
            margin: 25,
          },
          axisTick: {
            show: false,
          },
          splitLine: {
            show: false,
          },
        },
        {
          type: 'value',
          name: '万千瓦',
          nameGap: 0,
          nameLocation: 'end',
          position: 'right',
          max: (value) => getMax(value.max, 1.1),
          min: 0,
          nameTextStyle: {
            ...textStyle,
            padding: [0, 0, 40, 70], // 上、右、下、左
          },
          axisLine: {
            show: true,
            lineStyle,
          },
          axisLabel: {
            ...textStyle,
            margin: 25,
          },
          axisTick: {
            show: false,
          },
          splitLine: {
            show: false,
          },
        },
      ],
      series: [
        getSeriesOptionTop(
          legends[1],
          data.slice(1).map(([, , max]) => {
            return (+max).toFixed(0)
          }),
          areaColorArray,
          imageVeArray,
        ),
        getSeriesOptionTop(
          legends[0],
          data.slice(1).map(([, min]) => {
            return (+min).toFixed(0)
          }),
          areaColorArray,
          imageVeArray,
        ),
        getSeriesOption(
          legends[0],
          data.slice(1).map(([, min]) => {
            return (+min).toFixed(0)
          }),
          areaColorArray,
        ),
        getSeriesOption(
          legends[1],
          data.slice(1).map(([, min, max]) => {
            return (max - min).toFixed(0)
          }),
          areaColorArray,
        ),

        // // 虚线折线图
        // getSeriesDottedLine(
        //   legends[2],
        //   data.slice(1).map((item) => {
        //     return item[3]
        //   }),
        //   areaColorArray,
        // ),
        getSeries3DBar(
          legends[2],
          data.slice(1).map((item) => {
            return item[3]
          }),
          areaColorArray[2],
          -50,
        ),
        getSeries3DBar(
          legends[3],
          data.slice(1).map((item) => {
            return item[4]
          }),
          areaColorArray[3],
          80,
        ),
        getSeries3DBarLabel(
          data.slice(1).map((item) => {
            return item[3]
          }),
          areaColorArray[2],
          labelPosition[0],
        ),
        getSeries3DBarLabel(
          data.slice(1).map((item) => {
            return item[4]
          }),
          areaColorArray[3],
          labelPosition[1],
        ),
      ],
    },
  )

  return option
}
function getSeriesOption(name, data, areaColorArray) {
  return {
    name,
    type: 'bar',
    stack: '负荷',
    yAxisIndex: 1,
    tooltip: { show: false },
    color: name.includes('最高')
      ? new echarts.graphic.LinearGradient(0, 0, 0, 1, [
          { offset: 0, color: areaColorArray[0] },

          {
            offset: 1,
            color: areaColorArray[1],
          },
        ])
      : 'transparent',
    label: {
      show: false,
    },
    symbol: 'circle',
    symbolSize: FONT_SIZE / 5,
    itemStyle: {
      borderRadius: 10,
    },
    barWidth: FONT_SIZE / 4,
    data,
  }
}

function getCommonLegend(legends, color) {
  return {
    show: true,
    top: '0%',
    itemWidth: 30,
    itemHeight: 30,
    itemGap: 25,
    textStyle: {
      color: '#fff',
      fontSize: FONT_SIZE - 5,
      padding: 10,
    },
    data: legends.map((item, index) => ({
      name: item,
      itemStyle: {
        color: Array.isArray(color) ? color[index] : color || '#FFFFFF', // 设置图例图标颜色
      },
    })),
  }
}

function getSeriesOptionTop(name, data, areaColorArray, imageColorArray) {
  return {
    name,
    type: 'pictorialBar',
    yAxisIndex: 1,
    color: name.includes('最高') ? areaColorArray[0] : areaColorArray[1],
    symbol: getImagePath(name.includes('最高') ? imageColorArray[0].imageUrl : imageColorArray[1].imageUrl, '', true),
    label: {
      show: true,
      color: '#fff',
      fontSize: FONT_SIZE - 5,
      fontWeight: 'bold',
      position: 'top',
      offset: name.includes('最高') ? [0, 0] : [0, FONT_SIZE + 50],
    },
    symbolSize: FONT_SIZE - 5,
    symbolPosition: 'end',
    symbolOffset: name.includes('最高') ? [0, -FONT_SIZE / 3] : [0, -FONT_SIZE / 2],
    data,
  }
}

function getSeriesDottedLine(name, data, areaColorArray) {
  return {
    name,
    type: 'line',
    yAxisIndex: 1,
    color: areaColorArray[2] || '#FFF',
    label: {
      show: false,
    },
    // symbol: 'circle',
    symbolSize: 25,
    itemStyle: {},
    lineStyle: {
      width: 3, // 增加线条粗细
      type: 'dashed', // 设置为虚线
    },
    data,
  }
}

function getSeries3DBar(name, data, color, gap = 0) {
  const barWidth = 15 // 定义柱形宽度

  return {
    name,
    type: 'custom',
    yAxisIndex: 0,
    renderItem: (params, api) => {
      const location = api.coord([api.value(0), api.value(1)])
      const xlocation = api.coord([api.value(0), 0])

      // 根据gap参数调整位置
      const xOffset = gap // 使用传入的gap值
      const barCenterX = location[0] + xOffset
      const barLeftX = barCenterX - barWidth / 2

      return {
        type: 'group',
        children: [
          {
            type: 'CubeLeft',
            shape: {
              api,
              xValue: api.value(0),
              yValue: api.value(1),
              x: barLeftX, // 使用计算后的位置
              y: location[1],
              xAxisPoint: xlocation,
              width: barWidth, // 添加宽度控制
              offset: xOffset, // 传递偏移量
            },
            style: {
              fill: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                {
                  offset: 0,
                  color: echarts.color.modifyAlpha(color, 0.9),
                },
                {
                  offset: 1,
                  color: echarts.color.modifyAlpha(color, 0.12),
                },
              ]),
            },
          },
          {
            type: 'CubeRight',
            shape: {
              api,
              xValue: api.value(0),
              yValue: api.value(1),
              x: barLeftX, // 使用计算后的位置
              y: location[1],
              xAxisPoint: xlocation,
              width: barWidth, // 添加宽度控制
              offset: xOffset, // 传递偏移量
            },
            style: {
              fill: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                {
                  offset: 0,
                  color: echarts.color.modifyAlpha(color, 0.4),
                },
                {
                  offset: 1,
                  color: echarts.color.modifyAlpha(color, 0.01),
                },
              ]),
            },
          },
          {
            type: 'CubeTop',
            shape: {
              api,
              xValue: api.value(0),
              yValue: api.value(1),
              x: barLeftX, // 使用计算后的位置
              y: location[1],
              xAxisPoint: xlocation,
              width: barWidth, // 添加宽度控制
              offset: xOffset, // 传递偏移量
            },
            style: {
              fill: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                {
                  offset: 0,
                  color: echarts.color.modifyAlpha(color, 0.2),
                },
                {
                  offset: 1,
                  color,
                },
              ]),
            },
          },
        ],
      }
    },
    color,
    data,
  }
}

function getSeries3DBarLabel(data, color, offsetX) {
  return {
    type: 'bar',
    color,
    tooltip: { show: false },
    yAxisIndex: 0,
    xAxisIndex: 1,
    data,
    label: {
      show: true,
      position: 'top',
      fontSize: FONT_SIZE,
      color,
      offset: [offsetX, -10],
    },
    itemStyle: {
      color: 'transparent',
    },
  }
}

echarts实例:圆环内衬图

2026年3月4日 15:59

记录下工作中使用echarts做出的特殊组件

image.png

import { defineComponent } from 'vue'

import { COLOR_LIGHT_ORG,} from './createChart'


let props = {
  propData: {
    type: Array,
    default: () => [100, 80, 50],
  },
  legendData: {
    type: Array,
    default: () => [
      { name: '0%-60%', desc: '20天22小时25分钟' },
      { name: '60%-80%', desc: '10天15小时35分钟' },
      { name: '80%以上', desc: '5天15小时35分钟' },
    ],
  },
  barWidth: {
    type: Number,
    default: 20,
  },
  barColorArray: {
    type: Array,
    default: () => ['#FDC13C', '#32FFE1', '#E365FF', COLOR_LIGHT_ORG],
  },
  chartExtraOption: {
    type: Object,
    default() {
      return {}
    },
  },
}


export default defineComponent({
  props,
  data() {
    return {}
  },
  created() {},
  mounted() {
    this.init()
    this.$watch(
      () => this.$props, // 监听整个 props 对象
      () => {
        this.init()
      },
      { deep: true, immediate: false },
    )
  },
  beforeDestroy() {
    this.chart?.dispose?.()
  },
  methods: {
    init() {
      const option = this.getChartOption()
      const dom = this.$refs.chart
      if (!this.chart) {
        this.chart = echarts.init(dom, null, {
          renderer: 'canvas',
        })
      }
      this.chart.setOption(option, true)
    },
    getChartOption() {
      const filtered = this.legendData.map((item, index) => {
        return [item.name, this.propData[index]]
      })
      const maxValue = Math.max(...this.propData)
      return {
        color: this.barColorArray,
        angleAxis: {
          clockwise: false,
          axisLine: {
            show: false,
          },
          splitLine: {
            show: false,
          },
          axisLabel: {
            show: false,
          },
          axisTick: {
            show: false,
          },
          min: 0,
          max: maxValue * 1.4,
        },
        radiusAxis: {
          type: 'category',
          data: _.map(this.legendData, 'name'),
          z: 10,
          inverse: true,
          axisLine: {
            show: false,
          },
          axisLabel: {
            show: false,
          },
          axisTick: {
            show: false,
          },
        },
        polar: {
          radius: '100%',
        },
        series: [
          {
            type: 'bar',
            barWidth: this.barWidth,
            stack: 'base',
            data: filtered,
            coordinateSystem: 'polar',
            name: '1',
            colorBy: 'data',
          },
          {
            type: 'bar',
            stack: 'base',
            barWidth: this.barWidth,
            data: this.legendData.map((row) => [row.name, maxValue * 1.25]),
            coordinateSystem: 'polar',
            name: '半透衬底',
            itemStyle: {
              color: '#ffffff08',
            },
          },
        ],
      }
    },
  },
})

echarts实例:雷达图做出时钟效果

2026年3月4日 15:56

记录下工作中使用echarts做出的特殊组件

image.png

import { defineComponent } from 'vue'
import { FONT_SIZE, COLOR_LIGHT_ORG } from './createChart'


const props = {
  propData: {
    type: Array,
    default: () => [
      { name: 'A相电压相角', value: -110 },
      { name: 'B相电压相角', value: -130 },
      { name: 'C相电压相角', value: 15 },
    ],
  },
  showLegend: {
    type: Boolean,
    default: true,
  },
  lineColorArray: {
    type: Array,
    default: () => ['#00C7FA', '#F7C948', COLOR_LIGHT_ORG],
  },

}



export default defineComponent({
  props,
  data() {
    return {}
  },
  created() {},
  mounted() {
    this.init()
    this.$watch(
      () => this.$props, // 监听整个 props 对象
      () => {
        this.init()
      },
      { deep: true, immediate: false },
    )
  },
  beforeDestroy() {
    this.chart?.dispose?.()
  },
  methods: {
    init() {
      const indicator = this.generateMainIndicators()
      const option = {
        legend: [
          {
            show: this.showLegend,
            left: 'center',
            itemWidth: 40,
            itemHeight: 10,
            icon: 'rect',
            itemGap: 50,
            data: this.propData.map((item, index) => ({
              name: item.name,
              itemStyle: {
                color: this.lineColorArray[index] || '#FFf',
              },
            })),
            itemStyle: {
              borderWidth: 0,
              borderColor: 'transparent',
            },
            textStyle: {
              fontSize: FONT_SIZE,
              color: '#fff',
              padding: [0, 10],
            },
          },
        ],
        tooltip: {
          show: true,
          trigger: 'item',
          axisPointer: {
            type: 'none',
          },
          appendToBody: true,
          formatter: () => {
            let content = ''
            this.propData.map((item, index) => {
              content += `${item.name}: <span style="color:${this.lineColorArray[index] || '#FFf'}">${item.value}</span><br/>`
            })
            return content
          },
          textStyle: {
            fontSize: 50,
            color: '#fff',
            padding: [20, 100, 20, 100],
          },
          backgroundColor: '#0C3E5F',
          borderColor: '#0CB6FF',
          borderWidth: 4,
        },
        radar: [
          {
            // 第一个雷达:显示内圈
            radius: '60%',
            splitNumber: 3,
            startAngle: 90, // 添加开始角度配置
            center: ['50%', '50%'],
            axisLine: {
              show: true,
              lineStyle: {
                color: '#387892',
                type: 'dashed',
                width: 2,
              },
            },
            splitLine: {
              lineStyle: {
                color: '#387892',
                type: 'dashed',
                width: 2,
              },
            },
            splitArea: {
              show: true,
              areaStyle: {
                // 单色背景
                color: 'rgba(29, 65, 90, 0.5)',
                opacity: 1,
              },
            },
            axisName: {
              show: true, // 确保显示名称
              color: '#ffffff', // 名称颜色
              fontSize: 40,
            },
            indicator,
          },
          {
            // 第二个雷达:只显示最外圈
            radius: '60%',
            splitNumber: 1, // 只显示最外圈
            startAngle: 90, // 添加开始角度配置
            center: ['50%', '50%'],
            axisLine: {
              show: false,
            },
            axisName: {
              show: false, // 确保显示名称
            },

            splitLine: {
              lineStyle: {
                color: '#387892',
                type: 'solid', // 实线
                width: 5, // 粗线
              },
            },
            splitArea: {
              show: false,
            },
            axisLabel: {
              show: false,
            },
            indicator,
          },
          {
            // 第二个雷达:只显示最外圈
            radius: '60%',
            splitNumber: 1, // 只显示最外圈
            startAngle: 90, // 添加开始角度配置
            center: ['50%', '50%'],
            axisLine: {
              show: false,
            },
            axisName: {
              show: false, // 确保显示名称
            },

            splitLine: {
              show: false,
            },
            splitArea: {
              show: false,
            },
            axisLabel: {
              show: false,
            },

            indicator: this.generateContinuousIndicators(),
          },
        ],

        series: this.getSeries(),
      }
      this.draw(option)

    },
    draw(option) {
      const dom = this.$refs.chart
      if (!this.chart) {
        this.chart = echarts.init(dom, null, {
          renderer: 'canvas',
        })
      }
      this.chart.setOption(option, true)
    },
    getSeries() {
      return this.propData.map((item, index) => {
        return {
          name: item.name,
          type: 'radar',
          radarIndex: 2,
          symbolSize: 0,

          data: [
            {
              value: this.createDirectionData(item.value),
              name: item.name,
              lineStyle: {
                color: this.lineColorArray[index] || '#fff',
                width: 10,
              },
            },
          ],
        }
      })
    },
    generateContinuousIndicators() {
      const indicators = []
      for (let angle = 0; angle < 180; angle++) {
        indicators.push({
          name: angle.toString(),
          max: 100,
        })
      }
      for (let angle = -179; angle < 0; angle++) {
        indicators.push({
          name: angle.toString(),
          max: 100,
        })
      }
      return indicators
    },
    createDirectionData(angle) {
      const indicators = this.generateContinuousIndicators()
      const index = indicators.findIndex((item) => +item.name === +angle)
      return indicators.map((_, findex) => {
        return index === findex ? 95 : 0
      })
    },
    generateMainIndicators() {
      const targetAngles = [0, 30, 60, 90, 120, 150, 180, -150, -120, -90, -60, -30]
      const mainIndicators = []
      for (const angle of targetAngles) {
        mainIndicators.push({
          name: angle.toString(),
          max: 100,
        })
      }
      return mainIndicators
    },
  },
})

Promise由浅入深

作者 willow
2026年3月4日 15:52

是什么

  1. 是异步编程的一种解决方法,它是一个构造函数,由它的实例对象来封装一个异步操作,并且可以获取其成功或失败的结果。构造函数Promise内部自动执行一个执行器函数executor,执行器函数接收两个参数resolve、reject两个回调函数,executor执行器函数是同步回调函数。
  2. 有三个状态:pending、fulfilled、rejected。实例的状态只能由pending转为fulfilled或rejected状态,并且状态一经改变,就没办法再被改变了。
  3. 状态如何改变的呢?通过resolve()将状态改为fulfilled;reject()函数将状态改为rejected;
  4. 回调地狱:回调函数嵌套使用,不利于阅读以及异常处理,Promise的链式调用解决了回调地狱。它实例对象具有then和catch方法,而它们的返回结果依然是一个Promise对象,因此可以使用.符号进行链式调用

Promise构造函数 Promise(excutor){}

  1. excutor函数:同步执行(resolve, reject)=>{}, excutor会在Promise内部立即同步回调
  2. resolve函数:内部定义成功时我们调用的函数 value =>{}
  3. reject函数:内部定义失败时我们调用的函数 reason=>{}

Promise原型上的方法

  1. Promise.prototype.then((onResolved, onRejected)=> {})
    a. onResolved函数:成功的回调函数(value)=>{}
    b. onRejected函数:失败的回调函数(reason)=>{}
    c. 成功或失败的回调函数都会返回一个新的Promise,也就是promise能链式书写的原因
  2. Promise.prototype.catch((onRejected)=> {}):
    onRejected函数:失败的回调函数(reason)=>{}。是then()的语法糖,相当于:then(undefined,onRejected)
  3. Promise.prototype.finally:finally()方法用于指定不管 Promise 对象最后状态如何,都会执行的操作。
// 1 Promise构造函数 Promise(excutor){}
const p1 = new Promise((resolve, reject)=>{ 
  // ...异步操作
  resolve(1)
})
// 2 Promise.resolve方法 Promise.reject方法
const p2 = Promise.resolve(2) 
const p3 = Promise.reject(3)  
// 3 原型上的方法 Promise.prototype.then((onResolved, onRejected)=> {})  
// 4 原型上的方法 Promise.prototype.catch((onRejected)=> {})
// onResolved函数:成功的回调函数(value)=>{}; onRejected函数:失败的回调函数(reason)=>{}
p1.then(value=>{console.log(value)})// 结果:1
p2.then(value=>{console.log(value)})// 结果:2
p3.catch(reason=>{console.log('catch', reason)}) // 结果:catch 3

Promise身上的方法

  1. Promise.resolve方法 (value)=>{}:返回一个成功/失败的promise对象,value参数是成功的数据或promise对象
  2. Promise.reject方法 (reason)=>{}:返回一个失败的promise对象,reason:失败的原因
  3. Promise.all方法 (promises)=>{}: 返回一个新的promise,参数是promise数组。只有所有的promise都成功才成功,一个失败了就直接失败了(全部成功,任一失败立即拒绝
  4. Promise.race方法 (promises)=>{}: 返回一个新的promise,第一个完成的Promise的状态就是最终的结果状态(第一个响应的是啥就是啥)
  5. Promise.allSettled方法 (promises)=>{}:返回一个新的promise,状态总是已完成,用来确定一组异步操作是否都结束了(全部返回,不管成功或失败)
  6. Promise.any方法 (promises)=>{}: 返回一个新的promise,状态总是已完成,只要参数实例有一个变成fulfilled状态,包装实例就会变成 fulfilled状态(任一成功,全部失败才拒绝);如果所有参数实例都变成rejected状态,包装实例就会变成rejected状态。
// 1 Promise构造函数 Promise(excutor){}
const p1 = new Promise((resolve, reject)=>{ 
  // ...异步操作
  resolve(1)
})
// 2 Promise身上的几个方法使用
const p2 = Promise.resolve(2)  //Promise.resolve方法
const p3 = Promise.reject(3)  //Promise.reject方法
const p4 = Promise.all([p1, p2, p3]) //Promise.all方法
const p5 = Promise.race([p1, p2, p3]) //Promise.race方法
const p6 = Promise.allSettled([p1, p2, p3]) //Promise.allSettled方法
const p7 = Promise.any([p1, p2, p3]) //Promise.any方法
// 对应all race allSettled any的结果
p4.catch(reason=>{console.log('catch', reason)})// all结果:catch 3,全部成功,任一失败立即拒绝
p5.then(value=>{console.log(value)})// race结果:1,第一个响应的是啥就是啥
p6.then(value=>{console.log(value)})
// allSettled结果:[{"status": "fulfilled","value": 1},{"status": "fulfilled","value": 2}
//,{"status": "rejected","reason": 3}],全部返回,不管成功或失败
p7.then(value=>{console.log(value)})// any结果:1 //任一成功,全部失败才拒绝

promise.all与promise.race的区别

  1. 相同点用来完成并行任务, 它接收一个数组,数组的每一项都是一个promise对象
  2. 不同点:Promise.all 关注全部完成,Promise.race 关注最先响应
  3. Promise.all:只有所有的promise都成功才成功,一个失败了就直接失败了。
    a.当数组中所有的promise的状态都达到resolved的时候,all方法的状态就会变成resolved;
    b.有一个状态变成了rejected,那么all方法的状态就会变成rejected。
  4. Promise.race: 第一个完成的Promise的状态就是最终的结果状态。
    a.如果第一个promise对象状态变成resolved状态就是resolved;第一个promise变成rejected,状态就是rejected。

Promise几个关键问题

  1. new Promise(executor) 中的执行器以及 .then、.catch 都是同步的,但是执行器中的异步操作是异步的,.then 和 .catch 中的回调函数也是异步的。
  2. promise.then与promise.catch的区别:catch()是then()的语法糖,相当于:then(undefined,onRejected)
  3. promise怎么串联多个操作任务?通过then的链式调用串连多个同步/异步任务,因为promise的 then() 返回一个新的 promise
  4. promise.then()返回的新的promise状态是谁改变?由then里面回调执行的结果决定。

① 如果抛出异常,新promise变为rejected;
② 如果返回的是非promise,那么状态为已完成,value为返回值;
③ 如果返回的是promise,那么状态由promise的状态决定

  1. 异常穿透:在Promise的链式调用链中,所有的then都没有指定错误的回调,则前面出现的异常会在最后失败的回调中处理。
  2. 值传透:链式调用的参数不是函数时,会发生值穿透,就传入的非函数值忽略,传入的是之前的函数参数。
  3. 如何中断promise链?在回调函数中返回一个pendding状态的promise对象:return new Promise(()=>{})

Promise问题

  1. 无法取消Promise,若没有状态变更,也无法停止 promise 的等待
  2. 不设定 then 或 catch 方法,构造函数(excutor函数)错误,无法捕获
  3. 未完成状态时,无法得知是刚开始,还是即将完成

手写一个简易promise

  1. 定义Promise构造函数:① 定义excutor执行器函数是同步执行;② 声明pending、fulfilled、rejected三个全局状态;③ 定义resolve()修改状态以及异步执行所有成功的回调函数;④ 定义reject()修改状态以及异步执行所有失败的回调函数
  2. 定义Promise原型对象then(),参数是onResolved, onRejected两个回调;① 返回一个新的Promise对象,返回promise的结果由onResolved/onRejected执行结果决定。② 根据全局状态,如果是fulfilled、rejected则异步执行onResolved/onRejected,改变return的promise的状态;③ 如果是pending则push进数组等待被执行。
  3. Promise原型对象catch(),返回一个新的Promise对象
// 1 Promise 构造函数 excutor执行器函数 同步执行
const PENDING = 'pending'
const RESOLVED = 'fulfilled'
const REJECTED = 'rejected'
function Promise(excutor){
   try{// excutor同步执行
      excutor(resolve, reject) 
  }catch(error){// 若执行器异常,promise 对象变为 rejected 状态
      reject(error)
  }
  this.data = null
  this.status = PENDING //状态默认是pending
  this.callbacks = [] //保存的回调数组,结构是[{onResolved(){},onRejected(){}}]
  // 修改状态以及异步执行所有成功的回调函数
  function resolve(value){
      // 状态只能修改一次
      if(this.status !== PENDING) return
      // 修改值与状态
      this.status = RESOLVED;
      this.data = value;
      // 回调数组有值则立马取出执行
      if(this.callbacks.length > 0){
          // 模拟异步执行所有成功的回调函数
          setTimeout(()=>{
              this.callbacks.forEach(callbacksObj => {
                  callbacksObj.onResolved(value)
              });
          })
      }
  }
  // reject同理 修改状态以及异步执行所有失败的回调函数
}
// 2 Promise原型对象then方法 返回一个新的Promise对象
Promise.prototype.then = function(onResolved, onRejected){
  // 返回一个新的Promise对象 返回promise的结果由onResolved/onRejected执行结果决定
   return new Promise((resolve, reject)=>{
     function handle(callback){
      // a. 如果抛出异常,新 promise 变为 rejected, reason为抛出的异常
      // b. 如果返回的是非 promise 的任意值,新promise变为 resolved,value 为返回的值
      // c. 如果返回的是另一个新 promise,此 promise 的结果就会成为新 promise 的结果
        try{
            let result = callback(self.data)
            //3 返回的是另一个新 promise,此 promise 的结果就会成为新 promise 的结果
            if(result instanceof Promise){
                result.then(
                    value => resolve(value), // 当result成功,返回的promise也成功
                    reason => reject(reason) // 当result失败,返回的promise也失败
                )
            }else{
                resolve(result)//2 如果返回的是非 promise 的任意值,promise变为 resolved
            }
        }catch(error){ reject(error) }//1 如果抛出异常 promise 变为 rejected
    }
     // resolved/rejected状态,则异步执行onResolved/onRejected,改变return的promise的状态
     if(self.status === RESOLVED){
        setTimeout(()=>{
          handle(onResolved)
        },0)
     }else if(self.status === REJECTED){ 
       setTimeout(()=>{
          handle(onRejected)
        },0
     }else if(self.status === PENDING){//push进数组等待被执行
       self.callbacks.push({
         onResolved(){ handle(onResolved) }, 
         onRejected(){ handle(onRejected) }
       })
     }    
   })
}
// Promise原型对象 catch 方法 返回一个新的Promise对象
Promise.prototype.catch = function(onRejected){
  return this.then(undefined, onRejected);
}
// Promise函数对象resolve方法,返回一个新的Promise对象
// 返回新promise得结果由value决定,如果value是一个非promise,那么返回成功的promise,value就是这个传入的参数;value是一个promise,那么返回的promise,执行结果取决于传入的promise;
Promise.resolve = function(value){
  return new Promise((resolve, reject)=>{
    if(value instanceof Promise) value.then(resolve,reject)
    else resolve(value);
  })
}
// Promise函数对象reject方法,返回reason
Promise.reject = function(value){ 
  return new Promise((resolve, reject)=>{  reject(value) })
})

其他

  1. 手写Promise函数对象all方法,参数是promise类型的数组。返回一个新的Promise对象,所有promise成功则成功,只有一个失败就失败;
  2. 手写Promise函数对象race方法,参数是promise类型的数组。返回一个新的Promise对象,第一个响应的是啥就是啥。
// 1 all方法参数是promise类型的数组,返回一个新的Promise对象 所有promise成功则成功,只有一个失败就失败
// 循环数组的每个promise实例的响应结果,有一个失败状态就直接reject(reason);
// 没有的话比较数组个数与成功状态的个数,相等了才resolve(values)
Promise.all = function(promises){
    let values = new Array(promises.length)
    let count = 0
    return new Promise((resolve, reject)=>{
        promises.forEach((p, index) => {
            p.then(value=>{
                count++
                values[index] = value;
                if(promises.length === count){// 全部成功,返回所有成功的值的数组
                    resolve(values)
                }
            },reason=>{ // 出现失败就返回失败
                reject(reason)
            })
        })
    })
}
// 2 Promise函数对象race方法,参数是promise类型的数组
// 返回一个新的Promise对象,第一个响应的是啥就是啥
Promise.race = function(value){
    return new Promise((resolve, reject)=>{
        promises.forEach((p, index) => {
            p.then(value=>{
                resolve(value)
            },reason=>{
                reject(reason)
            })
        })
    })
}

链接

尚硅谷Promise教程(promise前端进阶)

echarts实例:双轴水平条形图(菱形和三角形的symbol)

2026年3月4日 15:49

记录下工作中使用echarts做出的特殊组件

image.png

import { defineComponent } from 'vue'
import { FONT_SIZE } from './createChart'
const props = {
  propData: {
    type: Array,
    default: () => [
      ['年份', '左侧(个)', '右侧(件)'],
      [2044, 59.4, 55.1],
      [2045, 54.2, 54.1],
      [2046, 68.4, 63.2],
      [2047, 64.7, 69.1],
      [2048, 76.1, 74.3],
    ],
  },
  ValueMaxMin: {
    type: Array,
    default: () => [
      [0, 100],
      [0, 100],
    ],
  },

  barWidth: {
    type: Number,
    default: 50,
  },
  barLabelDistance: {
    type: Number,
    default: 50,
  },
  YAxisNameRate: {
    type: Number,
    default: 20,
  },
  YAxisNamePosition: {
    type: Number,
    default: 50,
  },

  leftTriangleColorArray: {
    type: Array,
    default: () => ['rgba(26, 132, 211, 0.1)', 'rgba(15, 221, 138, 1)'],
  },
  barColorArray: {
    type: Array,
    default: () => ['rgba(15, 221, 138, 1)', 'rgba(251, 190, 0, 1)'],
  },
  rightDiamondColorArray: {
    type: Array,
    default: () => ['rgba(211, 140, 26, 0)', 'rgba(251, 190, 0, 1)'],
  },
}
function getModifyAlpha(color, alpha) {
  return echarts.color.modifyAlpha(color, alpha)
}

export default defineComponent({
  props,
  data() {
    return {}
  },
  created() {},
  mounted() {
    this.init()
    this.$watch(
      () => this.$props, // 监听整个 props 对象
      () => {
        this.init()
      },
      { deep: true, immediate: false },
    )
  },
  beforeDestroy() {
    this.chart?.dispose?.()
  },
  methods: {
    init() {
      const label = {
        show: true,
        fontSize: FONT_SIZE,
        color: '#fff',
      }
      const axisLine = {
        show: true,
        lineStyle: {
          color: 'rgba(211, 211, 211, 0.6)', // 轴线颜色
          width: 3, // 轴线宽度
          type: 'solid', // 线型:solid(实线)、dashed(虚线)、dotted(点线)
        },
      }
      const grid共同样式 = {
        show: false,
        top: '5%',
        bottom: '10%',
      }

      const props = this._props
      const barWidth = props.barWidth
      const legendColor = props.barColorArray
      const 系列名称names = props.propData[0].slice(1)
      const Y轴名称names = props.propData.slice(1).map((item) => item[0])
      const data1 = props.propData.slice(1).map((item) => item[1])
      const data2 = props.propData.slice(1).map((item) => item[2])
      const maxValue1 = Math.max(...data1)
      const maxValue2 = Math.max(...data2)
      const xAxisMax = Math.ceil(Math.max(maxValue1, maxValue2) / 100) * 100
      const 中间y轴名称占比Proportion = props.YAxisNameRate
      const 中间y轴名称离左距离 = props.YAxisNamePosition

      const 左右柱子占比Proportion = (100 - 中间y轴名称占比Proportion) / 2 - 3
      const 左右柱子标签偏移量offset = props.barLabelDistance
      const 左侧三角形的渐变色 = props.leftTriangleColorArray.length
        ? props.leftTriangleColorArray
        : [getModifyAlpha(legendColor[0], 0.8), getModifyAlpha(legendColor[0], 1)]
      const 右侧菱形的渐变色 = props.rightDiamondColorArray.length
        ? props.rightDiamondColorArray
        : [getModifyAlpha(legendColor[1], 0.8), getModifyAlpha(legendColor[1], 1)]

      const 左侧series通用配置 = {
        data: data1,
        xAxisIndex: 0,
        yAxisIndex: 0,
        gridIndex: 0,
        name: 系列名称names[0],
      }

      const 右侧series通用配置 = {
        data: data2,
        xAxisIndex: 2,
        yAxisIndex: 2,
        gridIndex: 2,
        name: 系列名称names[1],
      }

      const series装饰用途通用配置 = {
        tooltip: { show: false },
        label: { show: false },
        symbolPosition: 'end',
      }

      const 通用模糊效果 = {
        shadowBlur: 10, // 发光模糊度
        shadowOffsetX: 2,
        shadowOffsetY: 4,
        shadowColor: 'rgba(255, 255, 255, 0.5)',
      }

      const option = {
        color: legendColor,
        legend: {
          show: true,
          data: 系列名称names,
          lineStyle: {
            width: 10,
          },
          top: '0%',
          left: '38%',
          textStyle: {
            ...label,
            padding: [0, 0, 0, 30],
          },
          itemHeight: 30,
          itemWidth: 30,
          itemGap: 50,
        },
        tooltip: {
          show: Y轴名称names.length ? true : false,
          trigger: 'axis',
          axisPointer: {
            type: 'none',
          },
          textStyle: {
            ...label,
          },
          appendToBody: true,
          backgroundColor: '#0C3E5F',
          borderColor: '#0CB6FF',
          borderWidth: 4,
          formatter: (params) => {
            let result = ''
            params.forEach((param) => {
              const value = param.value || 0
              const seriesName = param.seriesName
              // 提取出括号中的单位
              const match = param.seriesName.match(/\((.*?)\)/)
              const unit = match ? match[1] : ''
              // 提取出颜色
              let color = param.color.colorStops[0]?.color || '#ffffff'
              color = getModifyAlpha(color, 1)

              const marker = `<span style="${this.getSerieSymbolStyle(color)}"></span>`
              result += `${marker} ${seriesName} :${value}   ${unit}<br/>`
            })
            return result
          },
        },
        grid: [
          {
            ...grid共同样式,
            left: '8%',
            width: `${左右柱子占比Proportion}%`,
          },
          {
            ...grid共同样式,
            left: `${中间y轴名称离左距离}%`,
            width: `${中间y轴名称占比Proportion}%`,
          },
          {
            ...grid共同样式,
            right: '8%',
            width: `${左右柱子占比Proportion}%`,
          },
        ],
        xAxis: [
          {
            gridIndex: 0,
            type: 'value',
            inverse: true,
            max: this.ValueMaxMin[0][1],
            min: this.ValueMaxMin[0][0],

            // 轴线
            axisLine: {
              ...axisLine,
            },
            axisTick: {
              show: false,
            },
            position: 'bottom',
            axisLabel: {
              ...label,
              textStyle: {
                color: '#fff',
              },
            },
            splitLine: {
              show: false,
            },
          },
          {
            gridIndex: 1,
            show: false,
          },
          {
            gridIndex: 2,
            type: 'value',
            inverse: false,
            max: this.ValueMaxMin[1][1],
            min: this.ValueMaxMin[1][0],

            axisLine: {
              ...axisLine,
            },
            axisTick: {
              show: false,
            },
            position: 'bottom',
            axisLabel: {
              ...label,
              textStyle: {
                color: '#fff',
              },
            },
            splitLine: {
              show: false,
            },
          },
        ],
        yAxis: [
          {
            gridIndex: 0,
            show: true,
            type: 'category',
            inverse: true,
            data: Y轴名称names,
            position: 'right',
            // 轴线
            axisLine: {
              ...axisLine,
            },
            axisTick: {
              show: false,
            },
            axisLabel: {
              show: false,
            },
            splitLine: {
              show: false,
            },
          },
          {
            gridIndex: 1,
            show: true,
            type: 'category',
            inverse: true,
            position: 'center',
            data: Y轴名称names,
            axisLine: {
              show: false,
            },
            axisTick: {
              show: false,
            },
            axisLabel: {
              ...label,
              align: 'center', // 或 'center'
              verticalAlign: 'middle',
              textStyle: {
                color: '#fff',
                padding: [0, 0, 0, 0],
              },
              formatter: (params) => {
                // 这里可以自定义y轴标签样式
                return params
              },
            },
            splitLine: {
              show: false,
            },
          },
          {
            gridIndex: 2,
            show: true,
            type: 'category',
            inverse: true,
            data: Y轴名称names,
            // 轴线
            axisLine: {
              ...axisLine,
            },
            axisTick: {
              show: false,
            },
            axisLabel: {
              show: false,
            },
            splitLine: {
              show: false,
            },
          },
        ],
        series: [
          // 左侧外三角形
          {
            ...左侧series通用配置,
            ...series装饰用途通用配置,
            type: 'pictorialBar',
            symbol: 'triangle',
            symbolSize: [barWidth, barWidth * 0.7],
            symbolRotate: -90,
            symbolOffset: [-25, 0],
            itemStyle: {
              color: {
                type: 'linear',
                x: 0,
                y: 1,
                x2: 0,
                y2: 0,
                colorStops: [
                  {
                    offset: 0,
                    color: 左侧三角形的渐变色[0],
                  },
                  {
                    offset: 1,
                    color: 左侧三角形的渐变色[1],
                  },
                ],
              },
              ...通用模糊效果,
              shadowColor: 左侧三角形的渐变色[0],
            },
          },

          // 左侧内三角形
          {
            ...左侧series通用配置,
            ...series装饰用途通用配置,
            type: 'pictorialBar',
            symbol: 'triangle',
            symbolSize: [barWidth - 10, barWidth * 0.7 - 10],
            symbolRotate: -90,
            symbolOffset: [-11, 0],
            itemStyle: {
              color: 'transparent',
              borderColor: {
                type: 'linear',
                x: 0,
                y: 1,
                x2: 0,
                y2: 0,
                colorStops: [
                  {
                    offset: 0,
                    color: getModifyAlpha(legendColor[0], 0.1),
                  },
                  {
                    offset: 0.8,
                    color: 'rgba(255, 255, 255, 0.5)',
                  },
                  {
                    offset: 1,
                    color: 'rgba(255, 255, 255, 1)',
                  },
                ],
              },
              ...通用模糊效果,
              borderWidth: 30, // 边框宽度
              borderType: 'solid',
            },
          },

          // 左侧实际柱形
          {
            ...左侧series通用配置,
            type: 'bar',
            barWidth,
            label: {
              ...label,
              offset: [-左右柱子标签偏移量offset, 0],
              position: 'left',
            },
            itemStyle: {
              color: (params) => {
                return new echarts.graphic.LinearGradient(1, 0, 0, 0, [
                  {
                    offset: 0,
                    color: getModifyAlpha(legendColor[0], 0.9),
                  },
                  {
                    offset: 1,
                    color: getModifyAlpha(legendColor[0], 1),
                  },
                ])
              },
              borderColor: getModifyAlpha(legendColor[0], 0.1),
              ...通用模糊效果,
            },
          },
          // 右侧前景条
          {
            ...右侧series通用配置,
            ...series装饰用途通用配置,
            type: 'pictorialBar',
            symbol: 'diamond',
            symbolSize: [barWidth, barWidth * 0.7],
            symbolRotate: 90,
            symbolOffset: [35, 0],
            itemStyle: {
              color: {
                type: 'linear',
                x: 0,
                y: 1,
                x2: 0,
                y2: 0,
                colorStops: [
                  {
                    offset: 0,
                    color: 右侧菱形的渐变色[0],
                  },
                  {
                    offset: 1,
                    color: 右侧菱形的渐变色[1],
                  },
                ],
              },
            },
          },
          {
            ...右侧series通用配置,
            ...series装饰用途通用配置,
            type: 'pictorialBar',
            symbol: 'diamond',
            symbolSize: [barWidth - 6, barWidth * 0.7 - 6],
            symbolRotate: 90,
            symbolOffset: [60, 0],
            itemStyle: {
              color: 'transparent',
              borderColor: {
                type: 'linear',
                x: 0,
                y: 1,
                x2: 0,
                y2: 0,
                colorStops: [
                  {
                    offset: 0,
                    color: getModifyAlpha(legendColor[1], 0.1),
                  },
                  {
                    offset: 0.8,
                    color: 'rgba(255, 255, 255, 0.5)',
                  },
                  {
                    offset: 1,
                    color: 'rgba(255, 255, 255, 1)',
                  },
                ],
              },
              ...通用模糊效果,
              borderWidth: 30, // 边框宽度
              borderType: 'solid',
            },
          },

          // 右侧实际柱形
          {
            ...右侧series通用配置,
            type: 'bar',
            barWidth,
            label: {
              ...label,
              offset: [左右柱子标签偏移量offset, 0],
              position: 'right',
            },
            itemStyle: {
              color: (params) => {
                return new echarts.graphic.LinearGradient(1, 0, 0, 0, [
                  {
                    offset: 1,
                    color: getModifyAlpha(legendColor[1], 0.9),
                  },
                  {
                    offset: 0,
                    color: getModifyAlpha(legendColor[1], 1),
                  },
                ])
              },
              borderColor: getModifyAlpha(legendColor[1], 0.1),
              ...通用模糊效果,
            },
          },
        ],
      }
      this.draw(option)
    },
    draw(option) {
      const dom = this.$refs.chart
      if (!this.chart) {
        this.chart = echarts.init(dom, null, {
          renderer: 'canvas',
        })
      }
      this.chart.setOption(option, true)
    },
    getSerieSymbolStyle(color) {
      const style = {
        display: 'inline-block',
        width: '30px',
        height: '30px',
        'border-radius': '3px',
        'margin-right': '12px',
        'vertical-align': 'middle',
        background: color || '#fff',
      }

      return Object.entries(style)
        .map(([key, value]) => `${key}:${value}`)
        .join(';')
    },
  },
})

【LangChain.js学习】 RAG(检索增强生成)完整实现解析

2026年3月4日 15:44

一、核心说明

你提供的代码是 LangChain.js 实现 RAG(Retrieval-Augmented Generation,检索增强生成) 的标准范例,核心是将「向量数据库的相似性检索」与「大模型生成」结合,确保回答严格基于知识库文本(避免模型幻觉)。整个流程实现了「用户提问→检索相关文本→拼接上下文→大模型回答」的端到端知识库问答能力。

二、代码核心逻辑拆解

整体执行流程

flowchart TD
    A[加载文档并分割] --> B[文本块存入内存向量库]
    C[用户提问] --> D[向量库检索Top1相关文本]
    D --> E[格式化检索结果为上下文]
    E --> F[拼接上下文+问题生成提示词]
    F --> G[打印最终提示词]
    G --> H[大模型基于上下文生成回答]
    H --> I[输出回答结果]

完整带注释代码(可直接运行)

import { TextLoader } from "@langchain/classic/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "@langchain/classic/text_splitter";
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
import { OpenAIEmbeddings } from "@langchain/openai";
import { ChatOpenAI } from "@langchain/openai";
import type { Document } from "@langchain/core/documents";
import type { ChatPromptValueInterface } from "@langchain/core/prompt_values";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { RunnableLambda, RunnablePassthrough, RunnableSequence } from "@langchain/core/runnables";

// ===================== 1. 定义提示词模板(约束回答范围) =====================
// 核心:强制大模型只能基于{context}(检索到的知识库文本)回答{input}(用户问题)
const promptTemplate = ChatPromptTemplate.fromMessages([
    ["system", "你是一个专业的问答机器人,你的回答必须基于上下文,不能编造信息"],
    ["human", "知识:{context},问题:{input}"],
]);

// ===================== 2. 初始化大模型(通义千问) =====================
// 通义千问旗舰版模型
const chatModel = new ChatOpenAI({
    model: "qwen-max",
    // 设为0减少随机性,保证回答精准
    temperature: 0,
    configuration: {
        // 阿里百炼OpenAI兼容接口
        baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
        // 替换为个人有效API Key
        apiKey: "[你的阿里百炼API Key]",
    },
});

// ===================== 3. 初始化嵌入模型+内存向量库 =====================
// 嵌入模型:将文本转为数值向量,用于相似性检索
const embeddingsModel = new OpenAIEmbeddings({
    // 通义千问文本嵌入模型(768维向量)
    model: "text-embedding-v2",
    configuration: {
        // 阿里百炼OpenAI兼容接口
        baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
        // 替换为个人有效API Key
        apiKey: "[你的阿里百炼API Key]",
    },
});

// 内存向量库:存储文本向量,支持相似性检索(程序重启后数据丢失)
const vectorStore = new MemoryVectorStore(embeddingsModel);

// ===================== 4. 加载并处理文档(存入向量库) =====================
// 加载TXT格式的知识库文档
const loader = new TextLoader("./data/data.txt");
const rawDocuments = await loader.load();

// 文本分割器:将长文本拆分为小文本块(适配嵌入模型上下文,提升检索精度)
const splitter = new RecursiveCharacterTextSplitter({
    // 每个文本块最大字符数
    chunkSize: 25,
    // 块间重叠字符(保证语义连贯)
    chunkOverlap: 5,
    // 优先按中文标点分割,避免语义断裂
    separators: [",", "。"],
});

// 分割文档并将文本块存入向量库(自动生成向量)
const splitDocuments = await splitter.splitDocuments(rawDocuments);
await vectorStore.addDocuments(splitDocuments);

// ===================== 5. 自定义Runnable组件(格式化/调试) =====================
// 5.1 格式化检索结果:将Document数组转为纯文本字符串(方便拼接提示词)
const formatDocuments = RunnableLambda.from((documents: Document[]): string => {
    return documents.map(doc => doc.pageContent).join("\n"); // 多个文本块换行分隔
});

// 5.2 打印提示词:调试用,输出最终传给大模型的完整提示词
const printPrompt = RunnableLambda.from((input: ChatPromptValueInterface): ChatPromptValueInterface => {
    console.log("【最终传给大模型的提示词】:\n", input.toString(), "\n");
    return input; // 透传提示词,不修改内容
});

// ===================== 6. 构建检索器(向量库检索入口) =====================
// asRetriever(1):检索Top1最相似的文本块(可调整数字扩大检索范围)
const retriever = vectorStore.asRetriever(1);

// ===================== 7. 构建RAG链式调用 =====================
// 定义用户问题
const question: string = "李娟的出生于哪里?";

// 链式调用流程:
// 1. 并行处理:context=检索结果格式化,input=用户问题透传
// 2. 拼接提示词模板
// 3. 打印提示词(调试)
// 4. 大模型生成回答
const chain = RunnableSequence.from([
    {
        // 检索→格式化
        context: retriever.pipe(formatDocuments),
        // 透传用户问题
        input: new RunnablePassthrough(),
    },
    // 拼接提示词
    promptTemplate,
    // 打印提示词
    printPrompt,
    // 大模型生成回答
    chatModel,
]);

// ===================== 8. 执行链式调用并输出结果 =====================
const result = await chain.invoke(question);
console.log("【最终回答】:", result.content);

三、核心组件详解

1. 关键Runnable组件

组件 作用 核心说明
RunnablePassthrough 透传数据 不修改输入,直接传递到下一个环节(此处用于保留用户问题)
RunnableLambda 自定义逻辑 封装格式化、打印等自定义函数,融入链式调用
RunnableSequence 链式执行 按顺序执行多个Runnable,前一个输出作为后一个输入
retriever.pipe(formatDocuments) 管道组合 检索结果直接传入格式化函数,简化嵌套调用

2. 核心代码段解析

(1)检索器构建

// asRetriever(k):k表示返回最相似的k个文本块,此处设为1表示只取最相关的1条
// 检索器本质是封装了similaritySearch方法,更适配LangChain的Runnable体系
const retriever = vectorStore.asRetriever(1);

(2)链式调用数据流转

{
    // 执行时:用户问题→检索器→Top1文本块→格式化字符串
    context: retriever.pipe(formatDocuments),
    // 执行时:直接等于用户问题(李娟的出生于哪里?)
    input: new RunnablePassthrough(),
}
  • 执行时会并行生成两个字段,作为参数传入promptTemplate,替换{context}{input}占位符。

(3)提示词最终效果

假设检索到的文本块是 李娟,1979年7月出生于新疆生产建设兵团,则最终提示词为:

System: 你是一个专业的问答机器人,你的回答必须基于上下文,不能编造信息
Human: 知识:李娟,1979年7月出生于新疆生产建设兵团,问题:李娟的出生于哪里?

四、运行效果示例

输入输出示例

【最终传给大模型的提示词】:
 System: 你是一个专业的问答机器人,你的回答必须基于上下文,不能编造信息
Human: 知识:李娟,1979年7月出生于新疆生产建设兵团,问题:李娟的出生于哪里? 

【最终回答】: 李娟于1979年7月出生于新疆生产建设兵团。

五、关键优化与注意事项

1. RAG核心优化点

  • 检索参数调整asRetriever(1)k 值可根据需求调整(如设为3,取Top3相关文本),平衡检索全面性和回答精准度;
  • 提示词模板优化:强化「仅基于给定上下文回答」的约束,例如添加「如果上下文没有相关信息,直接回复‘未查询到相关内容’,不要编造」;
  • 文本分割优化:根据知识库文档类型调整 chunkSize(如长文档设为50-100字符),保证检索到的文本块语义完整;

2. RAG常见问题排查

  • 检索不到结果:检查文本分割是否过细、嵌入模型与向量库是否兼容、用户问题表述是否与文档语义匹配;
  • 模型回答偏离上下文:优化提示词模板的约束性,或增大检索k值补充更多相关上下文;
  • 提示词拼接异常:确保格式化函数正确处理空检索结果(如返回「无相关知识」),避免提示词缺失context字段。

六、总结

  1. 该代码是 LangChain.js 实现 RAG 的标准范式,核心是「检索+生成」结合,解决大模型幻觉问题;
  2. RunnableSequence 是链式调用的核心,通过 pipe/passthrough 实现灵活的数据流控制;
  3. 文本分割的「中文标点分隔」和「块重叠」是保证中文语义完整性,提升RAG检索精度的关键;
  4. 检索器的 k 值、提示词模板的约束性,直接决定RAG最终回答的精准度和可靠性。

一文搞懂Nextjs中的Proxy

作者 helloweilei
2026年3月4日 15:19

在 Next.js 中,“Proxy” 这个概念实际上指向两个不同层面但同样重要的功能:一个是 Next.js 15 之后内置的、运行在边缘网络上的请求拦截器(原 Middleware),另一个则是通过配置文件实现的、用于转发 API 请求的 Rewrites 代理

简单来说,前者像是应用的门卫,后者则像是应用的传话员

为了帮你更清晰地理解,我把它们的核心区别整理成了一个表格:

特性 内置 proxy (原 Middleware) 配置 rewrites (反向代理)
核心定位 在请求到达页面之前,运行自定义代码进行拦截和处理。 将请求从一个路径“映射”到另一个路径(可以是内部或外部URL),对客户端透明。
主要用途 鉴权、A/B测试、国际化、日志记录、修改请求/响应头。 解决跨域问题、隐藏真实API地址、将旧站点路由平滑过渡到新应用、集成第三方服务。
运行时机 redirects 之后,beforeFiles rewrites 之前。 有三种时机:beforeFilesafterFilesfallback,可以在文件系统(页面/静态文件)检查前后执行。
代码位置 项目根目录下的 proxy.tsproxy.js 文件。 next.config.js 文件中的 async rewrites() 函数。
能力范围 可以编程式地返回 NextResponse,实现 redirectrewrite, 或直接响应。 声明式地配置 sourcedestination 映射关系,支持复杂的路径匹配规则。

深入理解:内置 proxy (原 Middleware)

从 Next.js 15 开始,原来的 middleware 文件被重命名为 proxy,其功能保持不变。你可以把它想象成一个在服务器上运行、在用户请求到达页面之前的“检查站”。

  • 关键特性与能力proxy 函数中,你可以访问 NextRequest 对象,并通过返回 NextResponse 来实现多种操作:

    1. 重写(Rewrite):在服务端将请求映射到另一个 URL,但客户端浏览器地址栏的 URL 不会改变。这非常适合做 A/B 测试或在同一个域名下托管不同版本的应用。
      // proxy.ts
      import { NextResponse } from 'next/server'
      import type { NextRequest } from 'next/server'
      
      export function proxy(request: NextRequest) {
        if (request.nextUrl.pathname.startsWith('/about')) {
          // 访问 /about 的用户,实际看到的是 /about-2 页面的内容
          return NextResponse.rewrite(new URL('/about-2', request.url))
        }
      }
      
    2. 重定向(Redirect):将请求引导至另一个 URL,浏览器地址栏会更新。适用于用户登录校验或永久移动的页面。
      // proxy.ts
      import { NextResponse } from 'next/server'
      import type { NextRequest } from 'next/server'
      
      export function proxy(request: NextRequest) {
        if (!isLoggedIn(request)) {
          // 未登录用户访问 dashboard 时,跳转到登录页
          return NextResponse.redirect(new URL('/login', request.url))
        }
      }
      
    3. 操作头信息与 Cookie:可以读取、设置或删除请求头和响应头,以及 Cookie,用于实现功能开关、用户追踪等。
    4. 直接响应:甚至可以直接在 proxy 层返回一个响应体,而不需要经过页面渲染,比如返回一个简单的 robots.txt 或维护页面。
  • 配置与执行 proxy 默认会作用于所有路由,因此强烈建议通过导出的 config 对象中的 matcher 选项来精确控制其生效路径,以优化性能。

    // proxy.ts
    export const config = {
      matcher: [
        /*
         * 匹配所有除了以这些开头的路径:
         * - api (API routes)
         * - _next/static (静态文件)
         * - _next/image (图片优化文件)
         * - favicon.ico, sitemap.xml (静态资源文件)
         */
        '/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml).*)',
      ],
    }
    

深入理解:配置 rewrites (反向代理)

rewrites 是定义在 next.config.js 中的一个异步函数,它更像是一种“声明式”的代理规则。它告诉 Next.js 服务器:“当用户请求路径 A 时,你悄悄地替我去路径 B 拿到内容,然后返回给用户。”

  • 核心价值与场景

    1. 解决跨域(CORS):这是开发中最常见的用途。通过将前端对 /api 的请求,在 Next.js 服务端转发到真实的后端 API 地址(如 https://api.example.com),从而绕过了浏览器的同源策略。
      // next.config.js
      module.exports = {
        async rewrites() {
          return [
            {
              source: '/api/:path*', // 前端请求的路径
              destination: 'https://api.example.com/:path*', // 实际转发的后端地址
            },
          ]
        },
      }
      
    2. 集成第三方服务:可以隐藏第三方服务的真实地址,将其“包装”成自己域名下的接口,避免被广告屏蔽器拦截,同时也更安全。
    3. 平滑迁移:当你将旧站点逐步迁移到 Next.js 时,可以用 fallback 类型的 rewrites,让所有不存在的页面都 fallback 到旧站点,实现渐进式重构。
  • 三种执行时机 rewrites 函数可以返回一个对象,包含三种数组,它们在不同的时机执行,提供了极大的灵活性:

    • beforeFiles:在检查 public 目录下的静态文件和页面路由之前执行。可以用来覆盖某些特定页面。
    • afterFiles:在检查完静态文件和页面路由之后,但在动态路由(如 pages/[slug].js)之前执行。
    • fallback:在所有路由(包括动态路由)都没有匹配到之后,返回 404 页面之前执行。非常适合作为“最后一道防线”代理到旧系统。
  • 高级匹配规则 rewritessource 路径支持参数化和正则表达式,并能根据 hasmissing 条件(如特定的 header、cookie 或 query 参数)来决定是否应用。

    // next.config.js
    module.exports = {
      async rewrites() {
        return [
          {
            source: '/blog/:slug',
            destination: '/news/:slug', // 将 /blog/hello 转发到 /news/hello
          },
          {
            source: '/old/:path*',
            destination: 'https://legacy-site.com/:path*', // 代理到外部 URL
          },
          {
            source: '/admin/:path*',
            has: [{ type: 'cookie', key: 'authorized', value: 'true' }],
            destination: '/dashboard/:path*', // 只有携带特定 cookie 时才转发
          },
        ]
      },
    }
    

总结与选择指南

简单来说,如何选择取决于你的业务逻辑:

  • 如果你的逻辑是编程式的、条件复杂的、需要读取或修改请求/响应头,例如“检查用户是否登录,未登录就跳转”,那么请使用 proxy
  • 如果你的逻辑是声明式的、纯粹的路径映射,例如“将所有 /api 请求转发到另一个服务器来解决跨域问题”,那么请使用 rewrites

在开发环境中,rewrites 通常是解决 API 跨域问题的最简单、最标准的方法。而在生产环境中,虽然 rewrites 也可以作为反向代理,但更常见的做法是在 Next.js 应用前面再加一层专业的反向代理服务器(如 Nginx),来处理负载均衡、SSL 终端和安全防护等更底层的网络问题。

Pinia状态管理原理:从响应式核心到源码实现

作者 wuhen_n
2026年3月4日 15:01

在前面的文章中,我们学习了 Vue Router 与响应式系统的集成。今天,我们将探索 Pinia,这是 Vue 官方推荐的状态管理库。Pinia 充分利用 Vue3 的响应式系统,提供了简单、类型安全的状态管理方案。理解它的实现原理,将帮助我们更好地组织应用状态,写出更可维护的代码。

前言:状态管理的演进

随着应用规模增长,组件间共享状态变得越来越复杂: 组件通信复杂性 在 Vue2 开发中,使用 Vuex 状态管理来解决组件共享状态问题;而 Vue3 则采用了 Pinia 的方式,为什么会有这层变化呢?

传统 Vuex 的问题

  • 繁琐的 mutations:必须通过 mutations 修改状态
  • 类型支持差:TypeScript 体验不佳
  • 模块化复杂:namespaced 概念增加心智负担
  • 体积较大:包含大量模板代码

Pinia的优势:

  • 直接修改状态:无需 mutations
  • 完美的类型推导:原生 TypeScript 支持
  • 扁平化结构:没有嵌套模块
  • 轻量高效:核心逻辑精简

Pinia 的设计理念与架构

Pinia 的本质

Pinia 本质上是一个 基于 Vue 3 响应式系统 + effectScope 的全局可控副作用容器 。它的核心目标是以最简洁的方式管理全局状态,同时保持类型安全和开发体验。

整体架构分层

Pinia 的源码架构可以清晰地分为三层: Pinia三层架构 这种分层设计使得 Pinia 既保持了上层 API 的简洁性,又能够充分利用 Vue 3 底层响应式系统的能力。

Pinia 如何利用 Vue 3 响应式系统

响应式核心:reactive 与 ref

Pinia 的状态管理完全建立在 Vue 3 的响应式 API 之上。当我们在 Pinia 中定义状态时,实际上是在创建 Vue 的响应式对象 :

// Pinia 内部的核心实现
import { reactive, ref } from 'vue'

// 选项式 Store 的 state 会被转换为 reactive
const state = reactive({
  count: 0,
  user: null
})

// 组合式 Store 直接使用 ref/reactive
const count = ref(0)
const user = ref(null)

Pinia 并不会重新发明一套响应式系统,而是直接复用 Vue 的响应式能力,这意味着:

  • 状态变化自动触发视图更新:当 state 变化时,所有依赖它的组件会自动重新渲染
  • 依赖自动收集:getters 中访问 state 时,Vue 会自动收集依赖关系

effectScope:全局副作用管理

Pinia 的一个重要创新是使用 Vue 3 的 effectScope API 来管理所有 store 的副作用 :

// createPinia 源码简化
export function createPinia() {
  // 创建全局 effectScope
  const scope = effectScope(true)
  
  // 全局 state 容器
  const state = scope.run(() => ref({}))!

  const pinia = markRaw({
    _e: scope,        // 全局 scope
    _s: new Map(),    // store 注册表
    state,            // 全局 state
    install(app) {
      app.provide(piniaSymbol, pinia)
    }
  })

  return pinia
}

这种设计有以下优势:

  • 统一管理:所有 storecomputedwatcheffect 都挂载在全局 scope
  • 一键清理:调用 pinia._e.stop() 即可销毁所有 store 的副作用
  • 每个 store 独立 scope:每个 store 还有自己的 scope,支持独立销毁(store.$dispose()

Store 的创建与类型推导

defineStore 的核心逻辑

defineStore 是用户定义 store 的入口,它返回一个 useStore 函数 :

// defineStore 源码简化
export function defineStore(id, setupOrOptions) {
  return function useStore() {
    // 获取当前活跃的 pinia 实例
    const pinia = getActivePinia()
    
    // 单例模式:同一 id 的 store 只创建一次
    if (!pinia._s.has(id)) {
      createStore(id, setupOrOptions, pinia)
    }
    
    return pinia._s.get(id)
  }
}

两种 Store 定义方式的实现

Pinia 支持两种定义 store 的方式:选项式 Store组合式 Store,它们的底层实现略有不同 :

选项式 Store(Options Store)

// 用户定义
export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0, name: 'Pinia' }),
  getters: {
    doubleCount: (state) => state.count * 2
  },
  actions: {
    increment() {
      this.count++
    }
  }
})

// 内部处理逻辑
function createOptionsStore(id, options, pinia) {
  const { state, getters, actions } = options
  
  // 1. 初始化 state
  pinia.state.value[id] = state ? state() : {}
  
  // 2. 创建 store 实例
  const store = reactive({})
  
  // 3. 将 state 转换为 refs 挂载到 store
  for (const key in pinia.state.value[id]) {
    store[key] = toRef(pinia.state.value[id], key)
  }
  
  // 4. 处理 getters -> 转换为 computed
  for (const key in getters) {
    store[key] = computed(() => {
      setActivePinia(pinia)
      return getters[key].call(store, store)
    })
  }
  
  // 5. 处理 actions -> 绑定 this
  for (const key in actions) {
    store[key] = wrapAction(key, actions[key])
  }
  
  return store
}

组合式 Store(Setup Store)

// 用户定义
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const name = ref('Pinia')
  
  const doubleCount = computed(() => count.value * 2)
  
  function increment() {
    count.value++
  }
  
  return { count, name, doubleCount, increment }
})

// 内部处理逻辑
function createSetupStore(id, setup, pinia) {
  const scope = effectScope()
  
  // 运行 setup 函数,创建响应式状态
  const setupResult = scope.run(() => setup())
  
  // 创建 store 实例(reactive 包裹整个 store)
  const store = reactive({})
  
  // 将 setup 返回的属性挂载到 store
  for (const key in setupResult) {
    const prop = setupResult[key]
    store[key] = prop
  }
  
  pinia._s.set(id, store)
  return store
}

类型推导的实现

Pinia 的类型推导之所以强大,是因为它充分利用了 TypeScript 的 类型推断条件类型

// 简化的类型定义
export function defineStore<Id, S, G, A>(
  id: Id,
  options: Omit<DefineStoreOptions<Id, S, G, A>, 'id'>
): StoreDefinition<Id, S, G, A>

// 使用时的类型推导
const store = useCounterStore()
// TypeScript 自动推导出:
// store.count: number
// store.doubleCount: number
// store.increment: () => void

Actions 的实现原理

Action 的本质

Pinia 中的 actions 就是普通的函数,但它们的 this 被自动绑定到了 store 实例上 :

// 源码中的 action 包装
function wrapAction(name, action) {
  return function(this: any) {
    // 绑定 this 为当前 store
    return action.apply(this, arguments)
  }
}

同步与异步 Action

Piniaactions 天然支持同步和异步操作,无需任何特殊处理 :

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    loading: false
  }),
  
  actions: {
    // 同步 action
    setUser(user) {
      this.user = user
    },
    
    // 异步 action
    async fetchUser(id) {
      this.loading = true
      try {
        const response = await fetch(`/api/users/${id}`)
        this.user = await response.json()
      } finally {
        this.loading = false
      }
    }
  }
})

Actions 的订阅机制

Pinia 提供了 $onAction 方法来订阅 actions 的执行 :

// 源码简化
store.$onAction(({ name, store, args, after, onError }) => {
  console.log(`Action ${name} 开始执行`)
  
  after((result) => {
    console.log(`Action ${name} 执行完成`, result)
  })
  
  onError((error) => {
    console.error(`Action ${name} 执行失败`, error)
  })
})

Getters 的实现原理

Getter 的本质是 computed

Piniagetters 底层就是 Vue 的 computed 属性:

// 源码中的 getter 处理
for (const key in getters) {
  store[key] = computed(() => {
    // 确保当前 pinia 实例活跃
    setActivePinia(pinia)
    // 调用 getter 函数,绑定 this 为 store
    return getters[key].call(store, store)
  })
}

这意味着 getters 具备 computed 的所有特性:

  • 缓存性:只有依赖变化时才重新计算
  • 懒计算:只有在被访问时才执行
  • 响应式依赖收集:自动追踪依赖的 state

Getter 的互相调用

getters 之间可以相互调用,就像计算属性可以组合一样 :

getters: {
  doubleCount: (state) => state.count * 2,
  
  // 通过 this 访问其他 getter
  quadrupleCount(): number {
    return this.doubleCount * 2
  }
}

Pinia vs Vuex:核心差异对比

设计理念对比

维度 Pinia Vuex
API 设计 简洁直观,无 mutations 严格区分 state/getters/mutations/actions
TypeScript 支持 原生支持 需要手动声明类型,支持有限
模块化 store 自然拆分 单一 store + 模块嵌套
响应式系统 直接使用 reactive/computed 内部实现响应式
代码体积 轻量(约1KB) 相对较大

核心差异详解

Mutations 的废除

Pinia 最大的改变是移除了 mutations。在 Vuex 中,修改状态必须通过 mutations(同步)和 actions(异步): Vuex 方式:

mutations: {
  add(state) {
    state.count++
  }
},
actions: {
  increment({ commit }) {
    commit('add')
  }
}

而在 Pinia 中,actions 可以直接修改状态:

actions: {
  increment() {
    this.count++  // 直接修改
  }
}

模块化设计

  • Vuex:单一 store,通过 modules 拆分,需要处理命名空间
  • Pinia:每个 store 独立,按需引入,天然支持代码分割

TypeScript 支持

Pinia 在设计之初就充分考虑 TypeScript,几乎所有 API 都支持类型推导。

源码简析:Pinia 的核心逻辑

createPinia:全局容器创建

// 源码简化自 pinia/src/createPinia.ts
export function createPinia() {
  const scope = effectScope(true)
  
  // 全局状态容器
  const state = scope.run(() => ref({}))
  
  const pinia = markRaw({
    // 唯一标识
    __pinia: true,
    
    // 全局 effectScope
    _e: scope,
    
    // store 注册表
    _s: new Map(),
    
    // 全局状态
    state,
    
    // 插件数组
    _p: [],
    
    // Vue 插件安装方法
    install(app) {
      // 设置为当前活跃 pinia
      setActivePinia(pinia)
      
      // 通过 provide 注入
      app.provide(piniaSymbol, pinia)
      
      // 挂载 $pinia 到全局属性
      app.config.globalProperties.$pinia = pinia
      
      // 使用效果域来管理响应式
      pinia._e.run(() => {
        app.runWithContext(() => {
          // 初始化
        })
      })
    }
  })
  
  return pinia
}

响应式 store 的创建过程

// 源码简化自 pinia/src/store.ts
function createStore(id, options, pinia) {
  // 创建 store 的作用域
  const scope = effectScope()
  
  // 创建 store 实例(整个 store 是 reactive 的)
  const store = reactive({})
  
  // 初始化 state
  pinia.state.value[id] = options.state ? options.state() : {}
  
  // 将 state 转换为 ref 并挂载
  for (const key in pinia.state.value[id]) {
    store[key] = toRef(pinia.state.value[id], key)
  }
  
  // 处理 getters(转换为 computed)
  if (options.getters) {
    for (const key in options.getters) {
      store[key] = computed(() => {
        setActivePinia(pinia)
        return options.getters[key].call(store, store)
      })
    }
  }
  
  // 处理 actions(绑定 this)
  if (options.actions) {
    for (const key in options.actions) {
      store[key] = function(...args) {
        return options.actions[key].apply(store, args)
      }
    }
  }
  
  // 缓存 store 实例
  pinia._s.set(id, store)
  
  return store
}

storeToRefs 的实现原理

为什么直接从 store 解构会失去响应式?因为 store 本身是一个 reactive 对象,解构会得到原始值 。storeToRefs 的源码揭示了解决方案:

// 源码简化自 pinia/src/storeToRefs.ts 
export function storeToRefs(store) {
  // 将 store 转换为原始对象,避免重复代理
  store = toRaw(store)
  
  const refs = {}
  
  for (const key in store) {
    const value = store[key]
    
    // 只转换响应式数据(state 和 getters)
    if (isRef(value) || isReactive(value)) {
      // 使用 toRef 保持响应式连接
      refs[key] = toRef(store, key)
    }
  }
  
  return refs
}

这个实现的核心在于:

  • toRaw(store):脱掉 storeProxy 外壳,获取原始对象
  • 只转换响应式数据:过滤掉 actions 等非响应式属性
  • toRef 包装:创建 ref 引用,保持与原始数据的响应式连接

结语

Pinia 的成功告诉我们,优秀的状态管理库不一定要复杂,而是要在保持简洁的同时,充分利用框架底层的能力。理解 Pinia 的响应式原理,不仅有助于我们更好地使用它,也为我们在实际项目中设计和封装自己的组合式函数提供了思路和借鉴。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

Star-Office-UI-Node 实战:从 0 到 1 接入 OpenClaw 的多 Agent 看板

作者 兔子零1024
2026年3月4日 15:01

项目地址先放这里,避免读到最后还找链接:

项目预览图(可直接放掘金正文):
Star Office UI 预览


多 Agent 协作里,最难排障的一件事不是模型能力,而是状态不可见。你知道任务在跑,但不知道谁卡住、谁离线、谁正在执行关键步骤。Star-Office-UI-Node 的目标很明确:把这个“黑盒协作”变成“实时可观察”。

这个项目是 Star-Office-UI 的 Node.js 复刻版,后端由 Flask 改为 Express,接口行为尽量兼容原版,方便 OpenClaw/龙虾/自研 Agent 低成本接入。
核心能力不是 UI,而是这条生命周期协议:

join-agent -> agent-push -> leave-agent

1. 本地启动

cd /Users/hfy/wm-code/Star-Office-UI-Node
pnpm install
pnpm start

默认地址:http://127.0.0.1:18791
端口占用时:PORT=18792 pnpm start

健康检查:

curl -s http://127.0.0.1:18791/health

2. API 速览(接入必须知道)

  • GET /health:服务健康
  • GET /status:主 Agent 状态
  • POST /set_state:设置主 Agent 状态
  • GET /agents:访客 Agent 列表
  • POST /join-agent:访客加入
  • POST /agent-push:状态心跳
  • POST /leave-agent:访客离开

状态枚举:idle / writing / researching / executing / syncing / error
兼容映射:busy -> writingrun/exec -> executingsearch -> researching

3. OpenClaw 对接步骤(可直接照抄)

Step 1:启动后 join,一次拿到 agentId

curl -s -X POST http://127.0.0.1:18791/join-agent \
  -H "Content-Type: application/json" \
  -d '{
    "name": "openclaw-agent-01",
    "joinKey": "ocj_starteam02",
    "state": "idle",
    "detail": "just joined"
  }'

返回里的 agentId 必须缓存(内存或本地文件)。

Step 2:每 10~30 秒推送状态

curl -s -X POST http://127.0.0.1:18791/agent-push \
  -H "Content-Type: application/json" \
  -d '{
    "agentId": "agent_xxx",
    "joinKey": "ocj_starteam02",
    "name": "openclaw-agent-01",
    "state": "executing",
    "detail": "running task #A-129"
  }'

Step 3:进程退出时 leave

curl -s -X POST http://127.0.0.1:18791/leave-agent \
  -H "Content-Type: application/json" \
  -d '{"agentId":"agent_xxx"}'

4. 我给的生产建议(别跳过)

  1. agent-push 增加 timeout + retry + exponential backoff。
  2. 收到 403/404 说明会话失效:停止 push,重新 join。
  3. detail 字段标准化:任务ID | 阶段 | 动作,后续诊断会轻松很多。
  4. SIGINT/SIGTERM 强制调用 leave,避免“幽灵在线”。

5. 结论

Star-Office-UI-Node 的价值不在“像素风界面”,而在它把多 Agent 状态协议沉淀成了一个可快速部署、可直接接入、可持续扩展的协作层。
如果你已经在并行跑 Agent,这类状态看板建议尽早上,不然团队越大,状态黑盒带来的沟通和排障成本越高。

Docker安装(非sudo用户可用)

作者 阿虎儿
2026年3月4日 14:58

Docker 安装说明 (Ubuntu/Debian)

本文档提供在 Ubuntu/Debian 系统上安装 Docker 并配置非 sudo 运行的完整步骤。

目录


系统要求

  • 操作系统:Ubuntu 20.04/22.04/24.04 或 Debian 10/11/12
  • 架构:x86_64 (amd64) 或 arm64
  • 权限:需要 sudo 权限进行安装

安装前检查

1. 检查系统信息

# 查看系统版本
lsb_release -a

# 查看系统架构
dpkg --print-architecture

2. 检查是否已安装 Docker

# 检查 Docker 版本
docker --version

# 检查 Docker 服务状态
systemctl status docker

# 检查是否能运行容器
docker ps

如果显示 command not found,说明 Docker 未安装。

3. 卸载旧版本(如存在)

# 卸载旧版本
sudo apt-get remove -y docker docker-engine docker.io containerd runc

安装 Docker

方法一:使用官方脚本安装(推荐,简单快捷)

# 下载并运行官方安装脚本
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

# 清理安装脚本
rm get-docker.sh

方法二:手动安装(推荐,更可控)

步骤 1:更新软件包索引
sudo apt-get update
步骤 2:安装必要的依赖包
sudo apt-get install -y \
    ca-certificates \
    curl \
    gnupg \
    lsb-release
步骤 3:添加 Docker 官方 GPG 密钥
# 创建 keyrings 目录
sudo install -m 0755 -d /etc/apt/keyrings

# 下载并添加 GPG 密钥
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

# 设置权限
sudo chmod a+r /etc/apt/keyrings/docker.gpg

注意:如果是 Debian 系统,将 URL 中的 ubuntu 替换为 debian

步骤 4:添加 Docker 软件源
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

注意:如果是 Debian 系统,将 URL 中的 ubuntu 替换为 debian

步骤 5:安装 Docker Engine
# 更新软件包索引
sudo apt-get update

# 安装 Docker 组件
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

配置非 sudo 运行

默认情况下,Docker 守护进程绑定到 Unix socket 而非 TCP 端口,该 socket 由 root 用户拥有,因此需要 sudo 权限。

步骤 1:创建 docker 用户组

# 创建 docker 组(通常安装时已自动创建)
sudo groupadd docker

步骤 2:将当前用户添加到 docker 组

# 将当前用户添加到 docker 组
sudo usermod -aG docker $USER

# 查看当前用户所属的组
groups $USER

步骤 3:使组权限生效

有两种方法使组权限生效:

方法 1:重新登录(推荐)

# 注销并重新登录系统
# 或者重启系统
sudo reboot

方法 2:使用 newgrp 临时生效

# 在当前终端临时生效
newgrp docker

步骤 4:启动 Docker 服务

# 启动 Docker 服务
sudo systemctl start docker

# 设置开机自启动
sudo systemctl enable docker

# 检查服务状态
sudo systemctl status docker

验证安装

1. 检查 Docker 版本

docker --version

预期输出类似:

Docker version 24.0.7, build afdd53b

2. 检查 Docker 服务状态

sudo systemctl status docker

3. 运行测试容器(验证非 sudo)

# 运行 hello-world 测试镜像
docker run hello-world

预期输出:

Hello from Docker!
This message shows that your installation appears to be working correctly.
...

4. 检查 Docker 信息

docker info

5. 验证 Docker Compose

docker compose version

安装 Docker Compose

Docker Compose 现已作为 Docker 的插件集成,通过 docker compose 命令使用。

如果需要独立版本的 docker-compose 命令:

# 下载最新版本(替换版本号)
sudo curl -L "https://github.com/docker/compose/releases/download/v2.24.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose

# 添加执行权限
sudo chmod +x /usr/local/bin/docker-compose

# 验证安装
docker-compose --version

常见问题排查

问题 1:权限被拒绝 (Permission denied)

错误信息

permission denied while trying to connect to the Docker daemon socket

解决方案

# 方法 1:重新加载组权限
newgrp docker

# 方法 2:修改 socket 权限(临时)
sudo chmod 666 /var/run/docker.sock

# 方法 3:重新登录系统使组权限生效

问题 2:Docker 服务未启动

错误信息

Cannot connect to the Docker daemon. Is the docker daemon running on this host?

解决方案

# 启动 Docker 服务
sudo systemctl start docker

# 检查服务状态
sudo systemctl status docker

# 查看日志
sudo journalctl -u docker

问题 3:网络问题导致下载失败

解决方案

# 使用国内镜像源(阿里云)
# 编辑 Docker 配置
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json <<-'EOF'
{
  "registry-mirrors": [
    "https://mirror.ccs.tencentyun.com",
    "https://docker.mirrors.ustc.edu.cn"
  ]
}
EOF

# 重启 Docker 服务
sudo systemctl daemon-reload
sudo systemctl restart docker

问题 4:GPG 密钥问题

解决方案

# 删除旧密钥
sudo rm /etc/apt/keyrings/docker.gpg

# 重新添加密钥
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg

问题 5:用户组未生效

解决方案

# 检查用户是否在 docker 组
groups $USER

# 如果不在,重新添加
sudo usermod -aG docker $USER

# 完全注销并重新登录
logout
# 或重启系统
sudo reboot

卸载 Docker

如需卸载 Docker:

# 卸载 Docker 包
sudo apt-get purge -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin docker-ce-rootless-extras

# 删除所有镜像、容器和数据卷
sudo rm -rf /var/lib/docker
sudo rm -rf /var/lib/containerd

# 删除 Docker 源
sudo rm /etc/apt/sources.list.d/docker.list

# 删除 GPG 密钥
sudo rm /etc/apt/keyrings/docker.gpg

参考链接

Generator与Iterator

作者 willow
2026年3月4日 14:52

Iterator

是什么
  1. 遍历器(Iterator),它是一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要具备Symbol.iterator属性,就可以被遍历。比如for...of循环遍历某种数据结构时,该循环会自动去寻找Iterator接口。
  2. 作用:1)是为各种数据结构,提供一个统一的、简便的访问接口;2)使得数据结构的成员能够按某种次序排列;3)Iterator接口主要供for...of消费。
调用Iterator接口的场合
  • 解构赋值,对数组和Set结构进行解构赋值时,会默认调用Symbol.iterator方法。let [first, ...rest] = set
  • 还有扩展运算符[...arr]for...ofArray.from()Map()Set()
// 当使用扩展运算符(...)或者对数组和 Set 结构进行解构赋值时,
// 或者使用for of去遍历某一个数据结构的时候会默认调用Symbol.iterator方法。
// 原生具备iterator接口的数据(可用for of遍历),比如数组 字符串  Set,对象不行
let arr = [1, 2, 3, 4];
let str = "kaixin";
let set = new Set(["q","w","e","r","t","y"])
for(let i of str){
    console.log(i); // k a i x i n
}
console.log(...arr) // 1 2 3 4
console.log(...str) // k a i x i n
console.log(...set) //q w e r t y
工作原理
  1. 创建一个指针对象,指向数据结构的起始位置。
  2. 第一次调用next方法,指针自动指向数据结构的第一个成员
  3. 接下来不断调用next方法,指针会一直往后移动,直到指向最后一个成员
  4. 每调用next方法返回的是一个包含value(当前成员的值)和done(尔)的对象,value表示当前成员的值,done对应的布尔值表示当前的数据的结构是否遍历结束。
  5. 当遍历结束的时候返回的value值是undefined,done值为true
//模拟`next`方法返回值的例子
//一个遍历器生成函数,作用就是返回一个遍历器对象
    function makeIterator(arr){
        var index = 0;
        return {
            next: function() {
               if(index<arr.length){
                   return {value:arr[index++],done:false}
               }else{
                   return {value: undefined,done:true}
               }
            }
        }
    }
    var it = makeIterator(['a', 'b']);
    //指针对象的next方法,用来移动指针。
    //开始时,指针指向数组的开始位置。
    //然后,每次调用next方法,指针就会指向数组的下一个成员。
    //第一次调用,指向a;第二次调用,指向b。
    console.log(it.next())//Iterator.html:21 {value: "a", done: false}
    console.log(it.next())//{value: "b", done: false}
    console.log(it.next())//{value: undefined, done: true}
  1. 让对象支持for…of的办法就是手动给对象添加迭代器
// 1 当使用for of去遍历某一个数据结构的时候,首先去找Symbol.iterator,
   //找到了就去遍历,没有找到的话不能遍历。对象使用for of会报错,因为本身没有迭代器。
let obj = { a: 2, b: 3 }
for(let i of obj){
    console.log(i); // Iterator.html:38 Uncaught TypeError: obj is not iterable
}
// 2 让对象支持for…of的办法就是手动给对象添加迭代器
  // a. 实现:返回一个对象有next方法,调用其next函数返回{done, value}
  // b. 思路:index指针记录位置,在返回的函数next里面比较指针与集合的长度,得出done状态,
        //大于则done为true,value为undefined        
obj[Symbol.iterator] = function(){
    const keys = Object.keys(this)
    let index = 0
    return {
        next(){
            let done = index <= len ? false : true;
            let value = !done ? arr[index++] : undefined;
            return { done, value }
        }
    }
}
// 对象可以使用for of了。
for(let i of obj){
    console.log(i); // 2,3
}

Generator

1. 是什么?
  1. Generator 函数一种异步编程解决方案。它有两个特征。一是,function关键字与函数名之间有一个星号;二是,函数体内部使用yield语句,定义不同的内部状态。yield可暂停,next方法可启动。每次返回的是yield后的表达式结果
  2. Generator 函数特点

a. 调用Generator函数后该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的遍历器对象。
b. 每次调用next方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个yield语句(或return语句)为止。
c. Generator函数是分段执行的,yield语句是暂停执行的标记,而next方法可以恢复执行。

  1. Generator执行分析:yield相当于看视频的时候有事暂停按钮,调用next()就相当于回来后点击一下继续观看。

a. 遇到yield语句,就暂停执行后面的操作。
b. 下一次调用next方法时,再继续往下执行,直到遇到下一个yield语句。
c. 如果没有再遇到新的yield语句,就一直运行到函数结束,直到return语句为止,并将return语句后面的表达式的值,作为返回的对象的value属性值。
d. 如果该函数没有return语句,则返回的对象的value属性值为undefined

// yield相当于看视频的时候有事暂停按钮,调用next()就相当于回来后点击一下继续观看。
function* testGenerator(){
    console.log("开始执行")
    yield 'hello';
    console.log("中间")
    yield 'generator';
    yield '!!';
}
let gen = testGenerator();
console.log(gen.next()); 
console.log(gen.next());
console.log(gen.next());
console.log(gen.next());
// 开始执行
// {value: 'hello', done: false}
// 中间
// {value: "hello", done: false}
// {value: "generator", done: false}
// {value: "!!", done: false}
// {value: undefined, done: true}
与 Iterator 接口的关系

由于 Generator 函数就是遍历器生成函数,因此可以把 Generator 赋值给对象的Symbol.iterator属性,从而使得该对象具有 Iterator 接口。

// 对象没有iterator接口,用for...of遍历时便会报错。
let obj = { username: 'kobe', age: 39 }
for (let i of obj) {
    console.log(i) //  Uncaught TypeError: obj is not iterable
}
//由于 Generator 函数就是遍历器生成函数,因此可以把 Generator 赋值给对象的Symbol.iterator属性,从而使得该对象具有 Iterator 接口。
let obj = { username: '王', age: 12, sex: '男'}
obj[Symbol.iterator] = function* myTest() {
    yield '王';
    yield 12;
    yield 男;
};
for (let i of obj) {
    console.log(i) // 王 // 12  //男
}
❌
❌