普通视图

发现新文章,点击刷新页面。
今天 — 2025年9月18日掘金 前端

Node.js 子进程:child_process

作者 gnip
2025年9月18日 01:24

概述

Node.js 基于单线程事件循环模型,——当遇到 CPU 密集型任务(如大规模数学计算、图像/视频处理、文件压缩等)时,单个线程会被阻塞,导致整个应用停滞不前,无法处理其他请求。

child_process 模块正是 Node.js 为我们提供的,用于跳出单线程限制、真正利用多核 CPU 性能的强大武器。它允许你创建并管理子进程,从而执行系统命令、运行其他脚本或可执行文件,实现并行处理。

简单来说,child_process 能够在 Node.js 程序中“开小号”,让多个任务同时进行,极大地提升了程序的性能。

核心 API

child_process 模块提供了四种主要方法来创建子进,适用于不同的场景。

1. exec:执行 Shell 命令

exec 方法用于执行一个 Shell 命令。它会启动一个 Shell(如 /bin/sh on Unix,cmd.exe on Windows)来解析命令,这意味着你可以使用管道 |、重定向 > 等 Shell 特性。

它的特点是缓冲输出,即会等待命令完全执行完毕,然后将所有输出一次性返回给你。

const { exec } = require('child_process');

// 执行 git status 命令
exec('git status', (error, stdout, stderr) => {
  if (error) {
    console.error(`执行出错: ${error.message}`);
    return;
  }
  if (stderr) {
    console.error(`标准错误: ${stderr}`);
    return;
  }
  console.log(`标准输出: ${stdout}`);
});

适用场景

  • 执行简单的、输出量不大的 Shell 命令,并获取最终结果。例如,获取项目 git 状态、检查磁盘空间 (df -h) 等。

2. spawn:流式处理

spawn 它不启动一个 Shell,而是直接衍生一个新的进程来执行指定的命令。它通过流(Stream)  的方式返回子进程的 stdout 和 stderr

这意味着你可以实时地接收到输出数据块,而不是等到命令结束。这对于处理大量数据(如处理大文件、长日志)至关重要,因为内存占用小,效率高。

const { spawn } = require('child_process');

// 使用 spawn 执行 find 命令,查找所有 .js 文件
const child = spawn('find', ['.', '-name', '*.js']);

// 实时接收数据
child.stdout.on('data', (data) => {
  console.log(`找到文件: ${data}`);
});

child.stderr.on('data', (data) => {
  console.error(`错误: ${data}`);
});

// 监听进程结束
child.on('close', (code) => {
  console.log(`子进程退出,退出码: ${code}`);
});

适用场景

  • 处理大量输出:如 npm installdocker build 等需要实时查看进度的命令。

3. execFile:二进制执行

execFile 与 exec 类似,但它不启动 Shell。它直接执行一个可执行文件。这使得它比 exec 效率稍高,而且更安全(因为它避免了潜在的 Shell 注入攻击)。但它不能使用 Shell 的原语,如通配符 * 或管道 |

const { execFile } = require('child_process');

// 直接执行 node 可执行文件,并传递参数
execFile('node', ['--version'], (error, stdout, stderr) => {
  if (error) {
    throw error;
  }
  console.log(stdout); // v18.x.x
});

适用场景:运行已知的、不需要 Shell 特性的二进制文件或脚本,追求更高的安全性和效率。

总结

方法 核心特点 输出处理 典型使用场景
exec 使用 Shell 缓冲 简单的 Shell 命令,输出量小
spawn 不使用 Shell,高效 流式 (Stream) 需要实时输出、处理大量数据
execFile 不使用 Shell,安全 缓冲 执行已知的二进制可执行文件

上面的几个方法在项目工程化开发插件、提供开发工具、规范、文件输出、脚手架改造的时候比较常用。

GSAP ScrollTrigger 详解

作者 mCell
2025年9月18日 01:11

同步更新至个人站点:GSAP ScrollTrigger 详解 - CellStack

image.png

在上一篇文章 GSAP 入门指南 里,我们学习了 GSAP 的两个核心:

  • Tween:补间动画。
  • Timeline:时间线。

有了它们,我们能让元素动起来。 但是,动画什么时候触发?靠谁来控制?

答案是:滚动(Scroll)

最常见的场景:

  • 元素滚动到视窗才开始播放。
  • 滚动条走到哪里,动画精确跟到哪里。
  • 内容卡住一会儿,再接着滚动,就像苹果官网。

要实现这些,我们需要今天的主角:ScrollTrigger。 它是 GSAP 官方提供的滚动插件。 一句话:把滚动条变成动画的遥控器

注册插件

我们还是用 CDN 的方式引入:

<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>

然后在 JS 里注册:

gsap.registerPlugin(ScrollTrigger)

不注册的话,GSAP 根本不知道有个滚动插件存在。

核心概念

ScrollTrigger 配置很多,但核心问题就两个:

  1. 什么时候开始?
  2. 怎么播放?

trigger

谁来触发?

gsap.to(".box", {
  x: 500,
  duration: 2,
  scrollTrigger: {
    trigger: ".box",
  },
})

这里 .box 就是触发点。 当 .box 出现在视口,动画才会执行。

start 和 end

动画何时开始?何时结束?

格式:"<元素位置> <视窗位置>"

  • start: "top center" → 元素顶部到达视窗中心时开始。
  • end: "+=300" → 从开始再滚动 300px,结束。

可以想象一根“滚动尺子”,startend 就是区间范围。

markers

调试神器。

scrollTrigger: {
  trigger: ".box",
  start: "top center",
  markers: true
}

页面会出现彩色的 start / end 标记线。 建议开发时一直开着,肉眼确认动画区间。

image.png

scrub

ScrollTrigger 的灵魂。

  • 默认:动画触发后,按 duration 播放完。
  • scrub: true:动画进度和滚动条绑定。
scrub: true

滚动到一半,动画停在一半。 就像动画挂在滚动条上。

scrub: 1 → 多加 1 秒缓冲,让体验更丝滑。

pin

pin 可以让元素在滚动区间内固定。

pin: true

这就是网页常见的“卡住”效果。 和 CSS 的 sticky 不同,它能和动画区间深度绑定,常见的横行滚动,就是这个效果。

toggleActions

如果不用 scrub,就要靠 toggleActions

它控制四种状态:

  • onEnter
  • onLeave
  • onEnterBack
  • onLeaveBack

默认是 "play none none none"

  • 元素进入时播放一次
  • 其他情况不处理

如果你想“返回时反播”,可以设置:

toggleActions: "play none none reverse"

实战演练

下面给出三个完整示例,复制到本地就能跑。 每个示例后我都会点出关键解释。

示例一:元素进入视窗

方块从左侧淡入。 进入视窗时播放,返回时反向。

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <title>ScrollTrigger 示例1</title>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
    <style>
      body {
        height: 300vh;
      }
      .spacer {
        height: 100vh;
      }
      .box {
        width: 150px;
        height: 150px;
        background: #28a92b;
        margin-left: 50px;
      }
    </style>
  </head>
  <body>
    <div class="spacer"></div>
    <div class="box"></div>
    <div class="spacer"></div>

    <script>
      gsap.registerPlugin(ScrollTrigger)
      gsap.to(".box", {
        x: 600,
        y: 100,
        rotation: 360,
        duration: 2,
        scrollTrigger: {
          trigger: ".box",
          start: "top 80%", // 元素进入视口下方 80% 时触发
          end: "bottom 20%", // 元素离开视口上方 20% 时结束
          toggleActions: "play none none reverse",
          markers: true,
        },
      })
    </script>
  </body>
</html>

关键点

  • toggleActions: "play ... reverse" → 往下滚时播放,往上滚时反播。
  • markers: true → 方便调试触发区间。

image.png

示例二:视差滚动

背景比前景慢,制造 3D 深度感。

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <title>ScrollTrigger 示例2</title>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
    <style>
      section {
        height: 100vh;
        display: flex;
        justify-content: center;
        align-items: center;
        font-size: 4rem;
      }
      .section-two {
        background: url("https://picsum.photos/1200/1200?random=1") no-repeat
          center/cover;
        height: 100vh;
      }
    </style>
  </head>
  <body>
    <section class="section-one"><h1>第一页</h1></section>
    <section class="section-two"></section>
    <section class="section-three"><h1>结束页</h1></section>

    <script>
      gsap.registerPlugin(ScrollTrigger)
      gsap.to(".section-two", {
        backgroundPosition: "50% 100%",
        scrollTrigger: {
          trigger: ".section-two",
          start: "top bottom", // 元素顶部到达视窗底部时开始
          end: "bottom top", // 元素底部到达视窗顶部时结束
          scrub: 1, // 滚动进度与动画绑定,+1秒缓冲
          markers: true,
        },
      })
    </script>
  </body>
</html>

关键点

  • scrub: 1 → 背景跟着滚动,有点延迟,更真实。
  • startend → 定义滚动区间,覆盖整个视差段落。

image.png

示例三:横向滚动

纵向滚动转为水平切换。

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <title>ScrollTrigger 示例3</title>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
    <style>
      .horizontal-container {
        height: 100vh;
        overflow: hidden;
      }
      .panel-wrapper {
        height: 100%;
        display: flex;
      }
      .panel {
        flex: 0 0 100%;
        display: flex;
        align-items: center;
        justify-content: center;
        font-size: 3rem;
      }
      .panel:nth-child(1) {
        background: url("https://picsum.photos/1200/1200?random=1") center/cover;
      }
      .panel:nth-child(2) {
        background: url("https://picsum.photos/1200/1200?random=2") center/cover;
      }
      .panel:nth-child(3) {
        background: url("https://picsum.photos/1200/1200?random=3") center/cover;
      }
      .spacer {
        height: 100vh;
      }
    </style>
  </head>
  <body>
    <div class="spacer"></div>
    <section class="horizontal-container">
      <div class="panel-wrapper">
        <div class="panel">第一页</div>
        <div class="panel">第二页</div>
        <div class="panel">第三页</div>
      </div>
    </section>
    <div class="spacer"></div>

    <script>
      gsap.registerPlugin(ScrollTrigger)
      const wrapper = document.querySelector(".panel-wrapper")
      gsap.to(wrapper, {
        x: () => -(wrapper.scrollWidth - window.innerWidth),
        ease: "none",
        scrollTrigger: {
          trigger: ".horizontal-container",
          pin: true, // 固定容器
          scrub: 1, // 滚动驱动动画
          end: () => "+=" + (wrapper.scrollWidth - window.innerWidth),
          invalidateOnRefresh: true,
          markers: true,
        },
      })
    </script>
  </body>
</html>

关键点

  • pin: true → 容器在滚动期间固定住。
  • x: () => -(wrapper.scrollWidth - window.innerWidth) → 根据内容宽度计算移动距离。
  • invalidateOnRefresh: true → 窗口大小变化时,重新计算。

image.png

总结

ScrollTrigger 的本质是: 让滚动条变成动画的时间轴

要点:

  • 引入并注册插件。
  • triggerstartend 控制触发区间。
  • 开启 markers 调试。
  • scrubpin 是进阶玩法的核心。

有了它,你能轻松实现滚动叙事: 从淡入淡出,到视差,再到横向切换。

(完)

昨天 — 2025年9月17日掘金 前端

为什么在 Three.js 中平面能产生“起伏效果”?

作者 excel
2025年9月17日 22:28

在 Three.js 里,我们经常用顶点着色器给平面加上“波浪起伏”的效果。
但很多初学者会问:
👉 明明片元着色器没变,为什么光靠顶点着色器,就能让平面像水面一样起伏?


1. GPU 的基本绘制单位:三角形

在 GPU 渲染里,所有几何体最终都会被拆成三角形。

  • 一个平面并不是一整块,而是由很多小三角形拼接而成的。
  • 每个三角形由 3 个顶点决定。
  • 只要顶点动了,三角形的形状也会随之改变。

👉 三角形不是固定的刚体,而是会随顶点移动而“变形”的柔性单元。


2. 顶点着色器:移动顶点的位置

顶点着色器的工作就是决定每个顶点在屏幕上的位置。
如果我们在顶点着色器里加上一个函数(比如 sin 波动),就能让顶点在 z 方向上下浮动:

uniform float uTime;
void main() {
  vec3 newPos = position;
  newPos.z += sin(position.x * 2.0 + uTime) * 0.5;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}
  • 每个顶点位置变了 → 三角形的形状也随之改变。
  • 当一大堆三角形被一起拉伸、压缩,就像一张布被掀起,形成了起伏。

3. 为什么视觉上是连续的?

关键就在于 三角形的可变形性 + 光栅化插值

  1. 三角形可变形

    • 如果三个顶点高度不同,三角形的平面就会“倾斜”或“扭曲”。
    • 这意味着顶点之间的空间会自然填充,而不是断裂。
  2. 光栅化插值

    • GPU 在把三角形映射到屏幕像素时,会对顶点属性(位置、颜色等)做插值。
    • 顶点之间的像素不会突兀,而是平滑过渡。

因此,哪怕只改变顶点,三角形本身的变形和插值机制,就能保证你看到的不是零散的点,而是连续的波浪效果


4. 顶点数决定效果细腻度

  • 少量三角形:如果平面只有两个三角形(4 个顶点),位移之后表面会显得“折角明显”,像折纸一样。
  • 大量三角形:如果平面有 100×100 个小格子(2 万个三角形),每个顶点都能单独上下运动 → 整体效果就会非常光滑自然。

👉 三角形越多,网格越密,视觉效果越连续。


5. 总结

  • GPU 世界的基本单位是 三角形
  • 三角形不是刚性的,它会随着顶点移动而变形。
  • 顶点着色器改变顶点高度 → 三角形随之变形 → 光栅化插值让像素平滑过渡 → 你看到的就是连续的起伏效果。

一句话概括:

平面几何就像由无数会弯曲的“小三角布块”拼成的布。
当顶点着色器拉高或压低顶点时,这些三角布块就会跟着变形,拼合起来的整张布就产生了自然的波浪起伏。

Node.js 断言与测试框架示例对比

作者 excel
2025年9月17日 21:15

一、概念

  • Node.js assert:轻量级断言库,直接内置于 Node.js。
  • Mocha + Chai:经典组合,Mocha 提供测试运行器,Chai 提供断言语法。
  • Jest:开箱即用的全能测试框架,内置断言、Mock、覆盖率。
  • Vitest:新一代测试框架,兼容 Jest API,运行速度快。

二、原理

  • assert:基于函数调用检查值,失败就抛 AssertionError
  • Chai:提供多样化的链式断言语法(BDD 风格)。
  • Jest/Vitest:提供类似的 expect API,并扩展 Mock / Snapshot / 异步测试。

三、对比

  • assert:轻便,但功能少。
  • Mocha+Chai:可定制,生态大。
  • Jest:集成度最高,报错提示最好。
  • Vitest:现代化、快、兼容 Jest。

四、实践示例

假设我们有一个函数 sum

function sum(a, b) {
  return a + b;
}

module.exports = sum;

1. 使用 Node.js assert

// test-assert.js
const assert = require('node:assert/strict');
const sum = require('./sum');

// 基本断言
assert.strictEqual(sum(2, 3), 5);   // ✅
// 错误示例
assert.strictEqual(sum(2, 3), '5'); // ❌ AssertionError

运行:

node test-assert.js

2. 使用 Mocha + Chai

安装:

npm install mocha chai --save-dev

测试文件:

// test/sum.test.js
const { expect } = require('chai');
const sum = require('../sum');

describe('sum 函数', () => {
  it('两个数字相加', () => {
    expect(sum(2, 3)).to.equal(5);
  });

  it('返回值类型是 number', () => {
    expect(sum(2, 3)).to.be.a('number');
  });
});

运行:

npx mocha

3. 使用 Jest

安装:

npm install jest --save-dev

测试文件:

// sum.test.js
const sum = require('./sum');

test('两个数字相加', () => {
  expect(sum(2, 3)).toBe(5);
});

test('返回值类型是 number', () => {
  expect(typeof sum(2, 3)).toBe('number');
});

运行:

npx jest

4. 使用 Vitest

安装:

npm install vitest --save-dev

测试文件:

// sum.test.js
import { describe, it, expect } from 'vitest';
import sum from './sum.js';

describe('sum 函数', () => {
  it('两个数字相加', () => {
    expect(sum(2, 3)).toBe(5);
  });

  it('返回值类型是 number', () => {
    expect(typeof sum(2, 3)).toBe('number');
  });
});

运行:

npx vitest

五、拓展

  1. 异步测试

    • Jest / Vitest 内置异步支持:
    test('异步加法', async () => {
      const asyncSum = (a, b) => Promise.resolve(a + b);
      await expect(asyncSum(2, 3)).resolves.toBe(5);
    });
    
    • Mocha 通过 doneasync/await 支持:
    it('异步加法', async () => {
      const asyncSum = (a, b) => Promise.resolve(a + b);
      const result = await asyncSum(2, 3);
      expect(result).to.equal(5);
    });
    
  2. Mock 函数

    • Jest / Vitest:
    test('调用回调函数', () => {
      const callback = jest.fn();
      sum(1, 2);
      callback();
      expect(callback).toHaveBeenCalled();
    });
    
    • Mocha/Chai 需要配合 Sinon。

六、潜在问题

  1. assert:适合小脚本,不适合复杂测试。
  2. Mocha:配置多,需要额外库。
  3. Jest:强大但稍显笨重,适合大型项目。
  4. Vitest:快,但生态不如 Jest 完善。

总结

  • 快速检查 → assert
  • 灵活可定制 → Mocha + Chai
  • 主流全能 → Jest
  • 现代高效 → Vitest

使用orval自动拉取swagger文档并生成ts接口

2025年9月17日 19:45

orval 介绍

orval 是一个开源的基于 OpenAPI 的接口测试工具,它可以自动从 Swagger/OpenAPI 文档中拉取接口信息并生成生成前端接口代码。

官方文档:orval.dev/

安装

pnpm add orval

配置

这里以 swagger 文档为例:

先创建一个 api.gen.config.js 文件,用来配置 swagger 信息:

const path = require('path')

module.exports = [
  {
    name: 'task', // 接口生成的文件夹名称
    url: 'https://orion-gateway.sit.sf-express.com/task/v2/api-docs', // swagger 文档地址
    filters: { tags: [/^(?!gexAoi$)/] }, // 过滤某些接口
    outDir: path.resolve(__dirname, './src/api/task/index.ts'), // 输出文件路径
  },
]

创建一个 orval 的配置文件 orval.config.ts:

import type { Options } from 'orval'

import config from '../../api.gen.config.js'

function initConfig() {
  const result: { [key: string]: Options } = {}

  if (!Array.isArray(config)) {
    console.error('config must be an array')
    return
  }

  config.forEach(item => {
    const { name, filters, outDir } = item

    const options: Options = {
      input: {
        // target: url,
        target: `../swagger/${name}.json`,
        filters,
        override: {
          transformer: './transformer.cjs',
        },
      },
      output: {
        target: outDir,
        mode: 'tags',
        override: {
          mutator: {
            path: './mutator.ts',
            name: 'request',
          },
        },
      },
      hooks: {
        afterAllFilesWrite: ['npx prettier --write'],
      },
    }
    result[name] = options
  })

  return result
}

export default initConfig()

如果 swagger 文档可以直接通过请求获取,不需要鉴权,那么 input.target 字段可以直接填写 swagger 文档的地址。

如果需要鉴权,那么可以先把 swagger 文档下载到本地,然后配置 input.target 字段为本地文件路径。

获取 swagger 文档

// scripts/fetch-swagger.ts
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

import config from '../../api.gen.config.js'

async function fetchSwagger() {
  config.forEach(async item => {
    const { name, url } = item
    const outputPath = path.resolve(__dirname, `../swagger/${name}.json`)
    const auth = 'Basic ' + Buffer.from('admin:Uac@2024').toString('base64')
    const res = await fetch(url, {
      headers: {
        Authorization: auth,
      },
    })

    const data = await res.json()

    // ✅ 创建目录
    fs.mkdirSync(path.dirname(outputPath), { recursive: true })

    // ✅ 写入文件
    fs.writeFileSync(outputPath, JSON.stringify(data, null, 2))
    console.log(`✅ Swagger downloaded to ./swagger/${name}.json`)
  })
}

fetchSwagger().catch(console.error)

生成接口

如果 香自定义请求方法,可以新建 mutator.ts, 并在 orval.config.ts 中配置到 output.override 字段。

import axios from 'axios'
import type { AxiosRequestConfig } from 'axios'

export const request = <T>(config: AxiosRequestConfig, options?: AxiosRequestConfig): Promise<T> => {
  const promise = axios.request({
    ...config,
    ...options,
  })

  return promise
}

如果想修改 swagger 中的内容,比如可以通过修改 tags 的名字来修改生成的文件名。

可以新建 transformer.cjs,并在 orval.config.ts 中配置到 input.override 字段:

// 名字是否包含中文
function includeChinese(str) {
  return /.*[\u4e00-\u9fa5]+.*$/.test(str)
}

// 去除所有空格然后首字母小写
function formatVariable(str) {
  return str.replace(/\s/g, '').replace(/^[A-Z]/, s => s.toLowerCase())
}

// 是否是合法的js变量名
function isValidVariableName(str) {
  return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(str)
}

// 从路径中获取文件名
function getModuleNameFromPath(path) {
  const parts = path.split('/').filter(Boolean) // 去掉空
  // 假设格式固定为 /task/moduleName/apiName
  return parts.length >= 2 ? formatVariable(parts[1]) : null
}

function formatTagName(name, description) {
  if (includeChinese(name) || !isValidVariableName(name)) {
    if (includeChinese(description) || !isValidVariableName(description) || !description) {
      return null
    } else {
      return formatVariable(description)
    }
  }
  return formatVariable(name)
}

module.exports = inputSchema => {
  const tags = inputSchema.tags.reduce((acc, { name, description }) => {
    if (includeChinese(name) || !isValidVariableName(name)) {
      // 默认先尝试 description
      let formatName = formatTagName(name, description) ? formatTagName(name, description) : null
      // 如果 description 没有,就从 paths 里找 moduleName
      if (!formatName) {
        const pathEntry = Object.entries(inputSchema.paths).find(([_, pathItem]) =>
          Object.values(pathItem).some(operation => operation.tags.includes(name))
        )

        if (pathEntry) {
          const [path] = pathEntry
          formatName = getModuleNameFromPath(path) ? getModuleNameFromPath(path) : formatVariable(name)
        }
      }

      console.log(name, '---->', formatName)

      return { ...acc, [name]: formatName }
    } else {
      return acc
    }
  }, {})

  return {
    ...inputSchema,
    tags: inputSchema.tags.map(el => (tags[el.name] ? { ...el, name: tags[el.name] } : el)),
    paths: Object.entries(inputSchema.paths).reduce(
      (acc, [path, pathItem]) => ({
        ...acc,
        [path]: Object.entries(pathItem).reduce(
          (pathItemAcc, [verb, operation]) => ({
            ...pathItemAcc,
            [verb]: {
              ...operation,
              tags: operation.tags.map(tag => (tags[tag] ? tags[tag] : tag)),
            },
          }),
          {}
        ),
      }),
      {}
    ),
  }
}

最后新建入口文件 index.js

import { fileURLToPath, URL } from 'node:url'

import orval from 'orval'

const configPath = fileURLToPath(new URL('./orval.config.ts', import.meta.url))

orval.generate(configPath)

最后在 package.json 中添加脚本:

{
  "scripts": {
    "api:gen": "node ./api-gen/src/fetch-swagger.js && node ./api-gen/src/index.js"
  }
}

执行 pnpm run api:gen 即可生成接口文件。

前端开发者的组件设计之痛:为什么我的组件总是难以维护?

作者 前端大鱼
2025年9月17日 19:40

组件化不是银弹,用不好的组件比面条代码更可怕

为什么我精心设计的组件,总是会逐渐变得难以维护?

组件化的美好幻想与现实打击

刚开始学习React/Vue时,我觉得组件化就是前端开发的终极解决方案。"拆分组件、复用代码、提高维护性",这些话听起来多么美好。但现实很快给了我一巴掌:

// 最初的按钮组件 - 简洁美好
const Button = ({ children, onClick }) => {
  return <button onClick={onClick}>{children}</button>;
};

// 半年后的按钮组件 - 灾难现场
const Button = ({
  children,
  onClick,
  type = 'primary',
  size = 'medium',
  loading = false,
  disabled = false,
  icon,
  iconPosition = 'left',
  href,
  target,
  htmlType = 'button',
  shape = 'rectangle',
  block = false,
  ghost = false,
  danger = false,
  // 还有15个props...
}) => {
  // 200行逻辑代码
};

我们陷入了"组件 Props 泛滥"和"组件职责混乱"的陷阱。

组件设计的常见陷阱

在多个项目重构后,我总结出了组件设计的七大致命陷阱:

1. Props 泛滥症

// 反面教材:过多的props
const Modal = ({
  visible,
  title,
  content,
  footer,
  onOk,
  onCancel,
  okText,
  cancelText,
  width,
  height,
  mask,
  maskClosable,
  closable,
  closeIcon,
  zIndex,
  className,
  style,
  // 还有20个props...
}) => {
  // 组件实现
};

2. 过度抽象

// 过度抽象的"万能组件"
const UniversalComponent = ({
  componentType,
  data,
  renderItem,
  onAction,
  config,
  // ... 
}) => {
  // 试图用一套逻辑处理所有情况
  if (componentType === 'list') {
    return data.map(renderItem);
  } else if (componentType === 'form') {
    // 表单逻辑
  } else if (componentType === 'table') {
    // 表格逻辑
  }
  // 10个else if之后...
};

3. 嵌套地狱

// 嵌套地狱
<Form>
  <Form.Item>
    <Input>
      <Icon />
      <Tooltip>
        <Popconfirm>
          <Button>
            <span>确认</span>
          </Button>
        </Popconfirm>
      </Tooltip>
    </Input>
  </Form.Item>
</Form>

组件设计原则:从混乱到清晰

经过无数次的反思和重构,我总结出了组件设计的核心原则:

1. 单一职责原则

一个组件只做一件事,做好一件事:

// 拆分前的复杂组件
const UserProfileCard = ({ user, onEdit, onDelete, onFollow, showActions }) => {
  return (
    <div className="card">
      <img src={user.avatar} alt={user.name} />
      <h3>{user.name}</h3>
      <p>{user.bio}</p>
      {showActions && (
        <div>
          <button onClick={onEdit}>编辑</button>
          <button onClick={onDelete}>删除</button>
          <button onClick={onFollow}>关注</button>
        </div>
      )}
    </div>
  );
};

// 拆分后的专注组件
const UserAvatar = ({ src, alt }) => (
  <img src={src} alt={alt} className="avatar" />
);

const UserInfo = ({ name, bio }) => (
  <div className="info">
    <h3>{name}</h3>
    <p>{bio}</p>
  </div>
);

const UserActions = ({ onEdit, onDelete, onFollow }) => (
  <div className="actions">
    <Button onClick={onEdit}>编辑</Button>
    <Button onClick={onDelete}>删除</Button>
    <Button onClick={onFollow}>关注</Button>
  </div>
);

// 组合使用
const UserProfileCard = ({ user, showActions }) => (
  <div className="card">
    <UserAvatar src={user.avatar} alt={user.name} />
    <UserInfo name={user.name} bio={user.bio} />
    {showActions && (
      <UserActions
        onEdit={onEdit}
        onDelete={onDelete}
        onFollow={onFollow}
      />
    )}
  </div>
);

2. 受控与非受控组件

// 支持受控和非受控模式
const Input = ({ value: controlledValue, defaultValue, onChange }) => {
  const [internalValue, setInternalValue] = useState(defaultValue || '');
  
  const value = controlledValue !== undefined ? controlledValue : internalValue;
  
  const handleChange = (newValue) => {
    if (controlledValue === undefined) {
      setInternalValue(newValue);
    }
    onChange?.(newValue);
  };
  
  return <input value={value} onChange={handleChange} />;
};

// 使用示例
// 受控模式
<Input value={value} onChange={setValue} />

// 非受控模式  
<Input defaultValue="初始值" onChange={console.log} />

3. 复合组件模式

// 使用复合组件避免props drilling
const Form = ({ children, onSubmit }) => {
  const [values, setValues] = useState({});
  
  return (
    <form onSubmit={() => onSubmit(values)}>
      {Children.map(children, child =>
        cloneElement(child, {
          value: values[child.props.name],
          onChange: (value) => setValues(prev => ({
            ...prev,
            [child.props.name]: value
          }))
        })
      )}
    </form>
  );
};

const FormInput = ({ name, value, onChange, ...props }) => (
  <input
    name={name}
    value={value || ''}
    onChange={(e) => onChange(e.target.value)}
    {...props}
  />
);

// 使用
<Form onSubmit={console.log}>
  <FormInput name="username" placeholder="用户名" />
  <FormInput name="password" type="password" placeholder="密码" />
</Form>

实战:重构复杂组件

让我分享一个真实的重构案例——一个电商的商品卡片组件:

重构前:

const ProductCard = ({
  product,
  showImage = true,
  showPrice = true,
  showDescription = true,
  showRating = true,
  showActions = true,
  onAddToCart,
  onAddToWishlist,
  onQuickView,
  imageSize = 'medium',
  layout = 'vertical',
  // 20多个props...
}) => {
  // 200多行逻辑代码
};

重构过程:

  1. 按功能拆分组件
// 基础展示组件
const ProductImage = ({ src, alt, size }) => (
  <img src={src} alt={alt} className={`image-${size}`} />
);

const ProductPrice = ({ price, originalPrice, currency }) => (
  <div className="price">
    <span className="current">{currency}{price}</span>
    {originalPrice && (
      <span className="original">{currency}{originalPrice}</span>
    )}
  </div>
);

const ProductRating = ({ rating, reviewCount }) => (
  <div className="rating">
    <Stars rating={rating} />
    <span>({reviewCount})</span>
  </div>
);
  1. 使用复合组件模式
const ProductCard = ({ children }) => (
  <div className="product-card">{children}</div>
);

ProductCard.Image = ProductImage;
ProductCard.Price = ProductPrice;
ProductCard.Rating = ProductRating;
ProductCard.Actions = ProductActions;

// 使用
<ProductCard>
  <ProductCard.Image src={product.image} alt={product.name} />
  <h3>{product.name}</h3>
  <ProductCard.Price
    price={product.price}
    originalPrice={product.originalPrice}
    currency="¥"
  />
  <ProductCard.Rating
    rating={product.rating}
    reviewCount={product.reviewCount}
  />
  <ProductCard.Actions
    onAddToCart={addToCart}
    onAddToWishlist={addToWishlist}
  />
</ProductCard>
  1. 自定义Hook处理逻辑
const useProductCard = (product) => {
  const [isInCart, setIsInCart] = useState(false);
  const [isInWishlist, setIsInWishlist] = useState(false);

  const addToCart = useCallback(() => {
    setIsInCart(true);
    // API调用...
  }, []);

  const addToWishlist = useCallback(() => {
    setIsInWishlist(true);
    // API调用...
  }, []);

  return {
    isInCart,
    isInWishlist,
    addToCart,
    addToWishlist
  };
};

// 在组件中使用
const ProductCard = ({ product }) => {
  const { isInCart, isInWishlist, addToCart, addToWishlist } = useProductCard(product);
  
  return (
    // JSX...
  );
};

组件测试策略

1. 单元测试

// 组件单元测试
describe('Button', () => {
  it('应该渲染正确的内容', () => {
    const { getByText } = render(<Button>点击我</Button>);
    expect(getByText('点击我')).toBeInTheDocument();
  });

  it('应该触发点击事件', () => {
    const handleClick = jest.fn();
    const { getByRole } = render(<Button onClick={handleClick}>按钮</Button>);
    
    fireEvent.click(getByRole('button'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });
});

2. 交互测试

// 交互测试
describe('Form', () => {
  it('应该提交表单数据', async () => {
    const handleSubmit = jest.fn();
    const { getByLabelText, getByRole } = render(
      <Form onSubmit={handleSubmit}>
        <FormInput name="username" label="用户名" />
        <FormInput name="password" type="password" label="密码" />
      </Form>
    );

    await userEvent.type(getByLabelText('用户名'), 'testuser');
    await userEvent.type(getByLabelText('密码'), 'password123');
    await userEvent.click(getByRole('button', { name: '提交' }));

    expect(handleSubmit).toHaveBeenCalledWith({
      username: 'testuser',
      password: 'password123'
    });
  });
});

组件文档化

1. 使用Storybook

// Button.stories.jsx
export default {
  title: 'Components/Button',
  component: Button,
};

const Template = (args) => <Button {...args} />;

export const Primary = Template.bind({});
Primary.args = {
  children: '主要按钮',
  type: 'primary'
};

export const Disabled = Template.bind({});
Disabled.args = {
  children: '禁用按钮',
  disabled: true
};

2. 自动生成文档

// 使用JSDoc注释
/**
 * 通用按钮组件
 * 
 * @param {Object} props - 组件属性
 * @param {ReactNode} props.children - 按钮内容
 * @param {string} [props.type='default'] - 按钮类型
 * @param {boolean} [props.disabled=false] - 是否禁用
 * @param {function} [props.onClick] - 点击回调函数
 * @example
 * <Button type="primary" onClick={() => console.log('clicked')}>
 *   点击我
 * </Button>
 */
const Button = ({ children, type = 'default', disabled = false, onClick }) => {
  // 组件实现
};

结语:组件设计的艺术

组件设计不是一门科学,而是一门艺术。它需要在复用性和灵活性简单性和完整性之间找到平衡点。

现在,当我面对复杂的组件需求时,不再试图一次性解决所有问题,而是遵循"简单开始,逐步演进"的原则。每个组件都应该有进化的空间,而不是一开始就追求完美。


你在组件设计中遇到过哪些挑战?有什么独到的组件设计心得?欢迎在评论区分享你的故事,让我们一起提升组件设计的艺术。

微前端学习记录(qiankun、wujie、micro-app)

作者 白水清风
2025年9月17日 18:34

一定要使用微前端吗?

不一定,任何技术都是取决于你的需求,过度设计反而导致程序更加糟糕,因为微前端并不是万能的,各个微前端框架都存在一些问题,甚至无法解决你的问题,如果你不知道自己是否需要微前端,那么大概率是不需要。

微前端的核心目标我认为有两个

  1. 将"巨石应用"拆解成若干可以自治的松耦合微应用
  2. 多个团队独立开发、部署、管理,共同构建现代化 web 应用

微前端架构要解决什么问题

  1. 独立-每个微应用可独立开发、运行、部署,具备完全自主权
  2. 隔离-每个微应用之间状态隔离,样式隔离,js隔离,运行不冲突
  3. 共享-应用间上下文可以共享,系统间可以通讯,数据同步

我认为最核心的就是这三点、至于其他比如性能、简单易用等问题,这是每种架构设计都应该考虑和解决的范围,这里就不赘述。

iframe(内联框架)

在HTML的中,大家都应该认识这个标签,<iframe>,用于在网页中嵌入另外一个独立的HTML文档,同时它还具有浏览器原生支持的沙盒环境,天然具备安全隔离的功能,可以让每个iframe标签内的子应用实现独立和隔离,这简直就是为微前端量身设计的,但显然iframe存在一些问题不能很好的解决上面所提到的问题,不然也不会有那么多的微前端框架出现(多个微前端架构的出现是不是也意味着每个架构间都有自己无法解决的问题呢?哈哈)

那为什么不选iframe呢?上面提到了三点(独立-隔离-共享),有点像不可能三角,没办法做到同时满足这三点要求,iframe在共享这一块也因为它的强隔离,变得复杂困难,跨域通讯困难,状态同步问题,URL管理问题,另外还有性能开销、加载保活、样式交互、用户体验等问题

这里也有标准答案 为什么不是iframe

接下来的所有记录,都只会与Vue技术栈相关,因为我主要使用Vue相关技术栈开发。

qiankun(阿里)

qiankun是一个基于single-spa的微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统, single-spa是通过监听 url change 事件,在路由变化时匹配到渲染的子应用并进行渲染,这个思路也是目前实现微前端的主流方式

        ┌──────────────────────┐
        │      qiankun         │ ← 阿里开源,企业级解决方案
        │(HTML Entry、沙箱、预加载、通信)│
        └─────────┬────────────┘
                  │ 依赖/封装
        ┌─────────▼────────────┐
        │    single-spa        │ ← 社区开源,微前端核心框架
        │(生命周期、路由匹配、应用注册)│
        └──────────────────────┘

特性

  • 📦 基于 single-spa 封装,提供了更加开箱即用的 API。
  • 📱 技术栈无关,任意技术栈的应用均可 使用/接入,不论是 React/Vue/Angular/JQuery 还是其他等框架。
  • 💪 HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。
  • 🛡 样式隔离,确保微应用之间样式互相不干扰。
  • 🧳 JS 沙箱,确保微应用之间 全局变量/事件 不冲突。
  • ⚡️ 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。
  • 🔌 umi 插件,提供了 @umijs/plugin-qiankun 供 umi 应用一键切换成微前端架构系统。

上手

主应用

主应用不限技术栈,只要提供一个容器DOM,然后注册微应用并start即可。

$ pnpm add qiankun # 或者 npm i qiankun -S
基于路由配置自动加载微应用
import { registerMicroApps, start, setDefaultMountApp, runAfterFirstMounted } from 'qiankun';


//registerMicroApps(apps, lifeCycles?)
// apps: 必选,微应用的一些注册信息
// lifeCycles: 可选,全局的微应用生命周期钩子
registerMicroApps([
  {
    name: 'vueApp1', 
    // string,必选,微应用的名称,微应用之间必须确保唯一
    entry: '//localhost:9501', 
    // string | { scripts?: string[]; styles?: string[]; html?: string },必选,微应用的入口
    //为字符串是表示微应用访问地址;
    //为对象是,html的值是微应用的html内容字符串;微应用的publicPath将会被设置成'/'
    container: '#container', // string | HTMLElement,必选,微应用的容器节点的选择器或者 Element 实例
    activeRule: '/app-vue2', // string | (location: Location) => boolean | Array<string | (location: Location) => boolean> 必选,微应用的激活规则
    // 支持直接配置字符串或字符串数组;支持配置一个 active function 函数或一组 active function   
    props: {
    appName:'vueApp1'
    }
    // `object` - 可选,主应用需要传递给微应用的数据
  },
{
    name: 'vueApp2',
    entry: '//localhost:9502', 
    container: '#container',
    activeRule: '/app-vue3',
    props: {
    appName:'vueApp2'
    }
  },
],
{
beforeLoad: (app) => console.log('before load', app.name),
beforeMount: [(app) => console.log('before mount', app.name)],
afterMount: [(app) => console.log('after mount', app.name)],
beforeUnmount: [(app) => console.log('before ummount', app.name)],
afterUnmount: [(app) => console.log('after ummount', app.name)],
// Lifecycle | Array<Lifecycle> - 可选
}
);

// 启动 qiankun
// start(opts?)
start({
prefetch:true,
// boolean | 'all' | string[] | (( apps: RegistrableApp[] ) => { criticalAppNames: string[]; minorAppsName: string[] }) 可选,是否开启预加载,默认为 `true`
// 配置为 `true` 则会在第一个微应用 mount 完成后开始预加载其他微应用的静态资源
// 配置为 `'all'` 则主应用 `start` 后即开始预加载所有微应用静态资源
// 配置为 `string[]` 则会在第一个微应用 mounted 后开始加载数组内的微应用资源
// 配置为 `function` 则可完全自定义应用的资源加载时机 (首屏应用及次屏应用)
sandbox:true,
// boolean | { strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean } - 可选,是否开启沙箱,默认为 `true`
// 默认情况下沙箱可以确保单实例场景子应用之间的样式隔离,但是无法确保主应用跟子应用、或者多实例场景的子应用样式隔离
// 当配置为 { strictStyleIsolation: true } 时表示开启严格的样式隔离模式,这种模式下qiankun会为每个微应用容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局影响
// 当 { experimentalStyleIsolation: true } 时,qiankun 会改写子应用所添加的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围;
// 注意: @keyframes, @font-face, @import, @page 将不会被改写
singular:true,
// boolean | ((app: RegistrableApp<any>) => Promise<boolean>),可选,是否为单实例场景,单实例指的是同一时间只会渲染一个微应用。默认为 true
// fetch - `Function` - 可选,自定义的 fetch 方法。
// getPublicPath - `(entry: Entry) => string` - 可选,参数是微应用的 entry 值。
// getTemplate - `(tpl: string) => string` - 可选。
// excludeAssetFilter - `(assetUrl: string) => boolean` - 可选,指定部分特殊的动态加载的微应用资源(css/js) 不被 qiankun 劫持处理。
})



// 设置主应用启动后默认进入
// setDefaultMountApp(appLink),- appLink - `string` - 必选
setDefaultMountApp('/vueApp2')

// 第一个微应用 mount 后需要调用的方法,比如开启一些监控或者埋点脚本
// runAfterFirstMounted(effect), - effect - `() => void` - 必选
runAfterFirstMounted(() => startMonitor());
手动加载/预加载微应用
import { loadMicroApp, prefetchApps } from 'qiankun'

// loadMicroApp(app, configuration?)
// app - 必选,微应用的基础信息
// configuration - 可选,微应用的配置信息
// 返回微应用实例,实例方法有 
// mount(): Promise<null>;
// unmount(): Promise<null>;
// update(customProps: object): Promise<any>;
// getStatus(): | "NOT_LOADED" | "LOADING_SOURCE_CODE" | "NOT_BOOTSTRAPPED" | "BOOTSTRAPPING" | "NOT_MOUNTED" | "MOUNTING" | "MOUNTED" | "UPDATING" | "UNMOUNTING" | "UNLOADING" | "SKIP_BECAUSE_BROKEN" | "LOAD_ERROR";
// loadPromise: Promise<null>;
// bootstrapPromise: Promise<null>;
// mountPromise: Promise<null>;
// unmountPromise: Promise<null>;

// 如果需要能支持主应用手动 update 微应用,需要微应用 entry 再多导出一个 update 钩子:
// 增加 update 钩子以便主应用手动更新微应用
// export async function update(props) {
  //...
//}

loadMicroApp({
name: 'vueApp2',
    entry: '//localhost:9502', 
    container: '#container',
    activeRule: '/app-vue3',
    props: {
    appName:'vueApp2'
    }
},
{
 // sandbox
 // singular
 // fetch
 // getPublicPath
 // getTemplate
 // excludeAssetFilter
})



// prefetchApps(apps, importEntryOpts?)
// apps - 必选 - 预加载的应用列表
// importEntryOpts - 可选 - 加载配置
prefetchApps([
  { name: 'vueApp1', entry: '//localhost:9501' },
  { name: 'vueApp2', entry: '//localhost:9502' },
]);
添加/移除全局的异常处理器
import { addErrorHandler, removeErrorHandler } from 'qiankun';

const handler = (error: AppError) => void
// addErrorHandler(handler) - handler - `(error: AppError) => void` - 必选
addGlobalUncaughtErrorHandler(handler);
// removeErrorHandler(handler) - handler - `(error: AppError) => void` - 必选
removeGlobalUncaughtErrorHandler(handler);
添加/移除全局的未捕获异常处理器
import { addGlobalUncaughtErrorHandler, removeGlobalUncaughtErrorHandler } from 'qiankun';

const handler = (event) => console.log(event)
// addGlobalUncaughtErrorHandler(handler) - handler - `(...args: any[]) => void` - 必选
addGlobalUncaughtErrorHandler(handler);
// removeGlobalUncaughtErrorHandler(handler) - handler - `(...args: any[]) => void` - 必选
removeGlobalUncaughtErrorHandler(handler);

定义全局状态
import { initGlobalState, MicroAppStateActions } from 'qiankun';
// 初始化 state
// initGlobalState(state) 定义全局状态,并返回通信方法,建议在主应用使用,微应用通过 props 获取通信方法
const actions: MicroAppStateActions = initGlobalState(state);

actions.onGlobalStateChange((state, prev) => {
  // state: 变更后的状态; prev 变更前的状态
  console.log(state, prev);
});
// (callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) => void, 在当前应用监听全局状态,有变更触发 callback,fireImmediately = true 立即触发 callback

actions.setGlobalState(state);
// setGlobalState: (state: Record<string, any>) => boolean, 按一级属性设置全局状态,微应用中只能修改已存在的一级属性
actions.offGlobalStateChange();
// offGlobalStateChange: `() => boolean`,移除当前应用的状态监听,微应用 umount 时会默认调用

微应用(Vue)

Webpack 构建
  1. 新增 public-path.js 文件,用于修改运行时的 publicPath什么是运行时的 publicPath ?。 在 src 目录新增 public-path.js
if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
注意:运行时的 publicPath 和构建时的 publicPath 是不同的,两者不能等价替代

2. 微应用建议使用 history 模式的路由,需要设置路由 base,值和它的 activeRule 是一样的 3. 在入口文件最顶部引入 public-path.js,修改并导出三个生命周期函数

import './public-path';
import Vue from 'vue';
import VueRouter from 'vue-router';
import App from './App.vue';
import routes from './router';
import store from './store';

Vue.config.productionTip = false;

let router = null;
let instance = null;

function render(props = {}) {
  const { container } = props;
  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? '/app-vue2/' : '/',
    mode: 'history',
    routes,
  });

  instance = new Vue({
    router,
    store,
    render: (h) => h(App),
  }).$mount(container ? container.querySelector('#app') : '#app');

}

// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap() {
  console.log('[vue] vue app bootstraped');
}

export async function mount(props) {
  console.log('[vue] props from main framework', props);
  render(props);
}

export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = '';
  instance = null;
  router = null;
}
  1. 修改 webpack 打包,允许开发环境跨域和 umd 打包
const { name } = require('./package');
module.exports = {
  devServer: {
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
  configureWebpack: {
    output: {
      library: `${name}-[name]`,
      libraryTarget: 'umd', // 把微应用打包成 umd 库格式
      jsonpFunction: `webpackJsonp_${name}`, // webpack 5 需要把 jsonpFunction 替换成 chunkLoadingGlobal
    },
  },
};
Vite构建

由于Vite是基于原生ES Module的按需加载和输出格式问题,所以需要通过插件适配不同框架和需求,同时保持开发速度的优势

  1. 需要安装帮助应用快速接入乾坤的vite插件,vite-plugin-qiankun
$ pnpm add vite-plugin-qiankun # 或者 npm i vite-plugin-qiankun -S
  1. 微应用建议使用 history 模式的路由,需要设置路由 base,值和它的 activeRule 是一样的
  2. vite.config.ts中配置插件
// vite.config.ts
import qiankun from 'vite-plugin-qiankun';

export default {
  // 这里的 'vueApp2' 是子应用名,主应用注册时AppName需保持一致
  plugins: [
  qiankun('vueApp2',{
useDevMode: true
  })
  ],
  // 生产环境需要指定运行域名作为base
  base: 'http://xxx.com/'
}
  1. 使用插件导出的方法加载微应用,配置生命周期函数
// main.ts
import { createApp } from 'vue'
import type { App } from 'vue'
import AppComponent from './App.vue'
import store from './store'
import routes from './router'
import { createRouter, createWebHistory, Router } from 'vue-router'
import { renderWithQiankun, qiankunWindow } from 'vite-plugin-qiankun/dist/helper'  

let app: App | null = null
let router: Router | null = null  

const renderApp = (props?: any) => {
const { container } = props
app = createApp(AppComponent)
router = createRouter({
history: createWebHistory(qiankunWindow.__POWERED_BY_QIANKUN__ ? '/app-vue3/' : '/'),
routes
})
app.use(router)
app.use(store)
app.mount(container ? container.querySelector('#app') : '#app')
}
  

const initQianKun = () => {
renderWithQiankun({
bootstrap() {},
mount(props) {
renderApp(props)
},
update(props) {},
unmount(props) {}
})
}

if (qiankunWindow.__POWERED_BY_QIANKUN__) {
initQianKun()
} else {
renderApp()
}

wujie(腾讯)

wujie是基于WebComponent容器 + iframe沙箱的微前端框架

特性

  1. 原生隔离;
    • css 样式通过 Web Components 可以做到严格的原生隔离
    • js 运行在 iframe 中做到严格的原生隔离
  2. 多种模式
    • 单例模式
    • 保活模式
    • 重建模式
  3. 去中心化通信
  4. 支持插件系统

上手

主应用(Vue)

# vue2 框架 
# pnpm add wujie-vue2    # npm i wujie-vue2 -S 
# vue3 框架 
pnpm add wujie-vue3  # npm i wujie-vue3 -S
// vue2 
// import WujieVue from "wujie-vue2"; 

// vue3 
import WujieVue from "wujie-vue3"; 

const { bus, setupApp, preloadApp, destroyApp } = WujieVue;

Vue.use(WujieVue);

bus(事件管理)
  • $on - 监听事件并提供回调
  • $onAll - 监听所有事件并提供回调,回调函数的第一个参数是事件名
  • $once - 一次性的监听事件
  • $off - 取消事件监听
  • $offAll - 取消监听所有事件
  • $emit - 触发事件
  • $clear - 清空EventBus实例下所有监听事件
    • 子应用在被销毁或重新渲染(非保活模式)时,框架会自动调用清空上次渲染所有的订阅事件
    • 子应用内部组件的渲染可能导致反复订阅(比如在mounted生命周期调用了$wujie.bus.$on),需要用户在unmount生命周期中手动调用$wujie.bus.off来取消订阅
setupApp(注册应用)

setupApp设置子应用默认属性,非必须。startApppreloadApp 会从这里获取子应用默认属性,如果有相同的属性则会直接覆盖

type lifecycle = (appWindow: Window) => any;
type loadErrorHandler = (url: string, e: Error) => any;

type baseOptions = {
  /** 唯一性用户必须保证 */
  name: string;
  /** 需要渲染的url */
  url: string;
  /** 需要渲染的html, 如果用户已有则无需从url请求 */
  html?: string;
  /** 代码替换钩子 */
  replace?: (code: string) => string;
  /** 自定义fetch */
  fetch?: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
  /** 注入给子应用的属性 */
  props?: { [key: string]: any };
  /** 自定义运行iframe的属性 */
  attrs?: { [key: string]: any };
  /** 自定义降级渲染iframe的属性 */
  degradeAttrs?: { [key: string]: any };
  /** 子应用采用fiber模式执行 */
  fiber?: boolean;
  /** 子应用保活,state不会丢失 */
  alive?: boolean;
  /** 子应用采用降级iframe方案 */
  degrade?: boolean;
  /** 子应用插件 */
  plugins?: Array<plugin>;
  /** 子应用window监听事件 */
  iframeAddEventListeners?: Array<string>;
  /** 子应用iframe on事件 */
  iframeOnEvents?: Array<string>;
  /** 子应用生命周期 */
  beforeLoad?: lifecycle;
  beforeMount?: lifecycle;
  afterMount?: lifecycle;
  beforeUnmount?: lifecycle;
  afterUnmount?: lifecycle;
  activated?: lifecycle;
  deactivated?: lifecycle;
  loadError?: loadErrorHandler;
};

type preOptions = baseOptions & {
  /** 预执行 */
  exec?: boolean;
};

type startOptions = baseOptions & {
  /** 渲染的容器 */
  el: HTMLElement | string;
  /**
   * 路由同步开关
   * 如果false,子应用跳转主应用路由无变化,但是主应用的history还是会增加
   * https://html.spec.whatwg.org/multipage/history.html#the-history-interface
   */
  sync?: boolean;
  /** 子应用短路径替换,路由同步时生效 */
  prefix?: { [key: string]: string };
  /** 子应用加载时loading元素 */
  loading?: HTMLElement;
};

type optionProperty = "url" | "el";

/**
 * 合并 preOptions 和 startOptions,并且将 url 和 el 变成可选
 */
type cacheOptions = Omit<preOptions & startOptions, optionProperty> & Partial<Pick<startOptions, optionProperty>>;

startApp(启动应用)

startApp启动子应用,异步返回 destroy函数,可以销毁子应用,一般不建议用户调用,除非清楚的理解其作用

  • 一般情况下不需要主动调用destroy函数去销毁子应用,除非主应用再也不会打开这个子应用了,子应用被主动销毁会导致下次打开该子应用有白屏时间
  • namereplacefetchalivedegrade这五个参数在preloadAppstartApp中须保持严格一致,否则子应用的渲染可能出现异常
type lifecycle = (appWindow: Window) => any;
type loadErrorHandler = (url: string, e: Error) => any;

type startOption  {
  /** 唯一性用户必须保证,如果主应用上有多个菜单栏用到了子应用的不同页面,在每个页面启动该子应用的时候建议将 name 设置为同一个,这样可以共享一个实例 */
  name: string;
  /** 需要渲染的url 
  如果子应用为单例模式,改变url则可以让子应用跳转到对应子路由
  如果子应用为保活模式,改变url则无效,需要采取通信的方式通知子应用路由进行跳转
  如果子应用为重建模式,改变url子应用的路由会跳转对应路由,但在路由同步场景并且子应用的路由同步参数已经同步到主应用url上时则无法生效,因为改变url后会导致子应用销毁重新渲染,此时如果有同步参数则同步参数优先级最高
  */
  url: string;
  /** 需要渲染的html, 如果用户已有则无需从url请求 */
  html?: string;
  /** 渲染的容器,最好设置好宽高防止渲染问题,在`webcomponent`元素上无界还设置了`wujie_iframe`的`class`方便用户自定义样式 */
  el: HTMLElement | string;
  /** 子应用加载时loading元素,如果不想出现默认加载,可以赋值一个空元素:`document.createElement('span')` */
  loading?: HTMLElement;
  /** 路由同步开关,false刷新无效,但是前进后退依然有效;true, wujie会把子应用name作为一个url查询参数,实时同步子应用的路径作为这个查询参数的值,这样分享URL或者刷新浏览器子应用路由都不会丢失,这个同步是单向的,只有打开 URL 或者刷新浏览器的时候,子应用才会从 URL 中读回路由 */
  sync?: boolean;
  /** 子应用短路径替换,路由同步时生效,如果子应用链接过长,可以采用短路径替换的方式缩短同步的链接 */
  prefix?: { [key: string]: string };
  /** 子应用保活模式,state不会丢失,切换子应用只是对`webcomponent`的热插拔
  如果子应用不想做生命周期的改造,子应用切换又不想有白屏时间,可以采用保活模式
  如果主应用有多个菜单栏跳转到子应用不同页面,此时不建议采用保活模式。因为子应用在保活模式下 startApp 无法更改子应用路由,不同菜单无法跳转到指定子应用路由,推荐单例模式 
  预执行模式结合保活模式可以实现类似`ssr`的效果,包括页面数据的请求和渲染全部提前完成,用户可以瞬间打开子应用*/
  alive?: boolean;
  /** 注入给子应用的数据 */
  props?: { [key: string]: any };
  /** js采用fiber模式执行,间断执行js,防止阻塞主应用渲染进程;如果打开主应用就要加载子应用可以设置为false */
  fiber?: boolean;
  /** 子应用采用降级iframe方案,一旦降级,弹窗由于在iframe内部无法覆盖整个应用 */
  degrade?: boolean;
  /** 子应用运行在iframe内,可以自定义运行iframe的属性 */
  attrs?: { [key: string]: any };
  /** 自定义降级渲染iframe的属性 */
  degradeAttrs?: { [key: string]: any };
  /** 代码替换钩子,`replace`函数可以在运行时处理子应用的代码,如果子应用不方便修改代码,可以在这里进行代码替换,子应用的`html`、`js`、`css`代码均会做替换 */
  replace?: (codeText: string) => string;
  /** 自定义fetch,资源和接口 */
  fetch?: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
  /** 子应用window监听事件 */
  iframeAddEventListeners?: Array<string>;
  /** 子应用iframe on事件 */
  iframeOnEvents?: Array<string>;
  /** 子应插件 */
  plugins: Array<plugin>;
  /** 子应用生命周期 */
  beforeLoad?: lifecycle;
  /** 没有做生命周期改造的子应用不会调用 */
  beforeMount?: lifecycle;
  afterMount?: lifecycle;
  beforeUnmount?: lifecycle;
  afterUnmount?: lifecycle;
  /** 非保活应用不会调用 */
  activated?: lifecycle;
  deactivated?: lifecycle;
  /** 子应用资源加载失败后调用 */
  loadError?: loadErrorHandler
};

preloadApp(预加载应用)

预加载可以极大的提升子应用首次打开速度

  • 资源的预加载会占用主应用的网络线程池
  • 资源的预执行会阻塞主应用的渲染线程
  • namereplacefetchalivedegrade这五个参数在preloadAppstartApp中须保持严格一致,否则子应用的渲染可能出现异常
type lifecycle = (appWindow: Window) => any;
type loadErrorHandler = (url: string, e: Error) => any;

type preOptions  {
  /** 唯一性用户必须保证 */
  name: string;
  /** 需要渲染的url */
  url: string;
  /** 需要渲染的html, 如果用户已有则无需从url请求 */
  html?: string;
  /** 注入给子应用的数据 */
  props?: { [key: string]: any };
  /** 自定义运行iframe的属性 */
  attrs?: { [key: string]: any };
  /** 自定义降级渲染iframe的属性 */
  degradeAttrs?: { [key: string]: any };
  /** 代码替换钩子 */
  replace?: (code: string) => string;
  /** 自定义fetch,资源和接口 */
  fetch?: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
  /** 子应用保活模式,state不会丢失 */
  alive?: boolean;
  /** 预执行模式, 预执行模式,为`false`时只会预加载子应用的资源,为`true`时会预执行子应用代码,极大的加快子应用打开速度 */
  exec?: boolean;
  /** js采用fiber模式执行 */
  fiber?: boolean;
  /** 子应用采用降级iframe方案 */
  degrade?: boolean;
  /** 子应用window监听事件 */
  iframeAddEventListeners?: Array<string>;
  /** 子应用iframe on事件 */
  iframeOnEvents?: Array<string>;
  /** 子应插件 */
  plugins: Array<plugin>;
  /** 子应用生命周期 */
  beforeLoad?: lifecycle;
  /** 没有做生命周期改造的子应用不会调用 */
  beforeMount?: lifecycle;
  afterMount?: lifecycle;
  beforeUnmount?: lifecycle;
  afterUnmount?: lifecycle;
  /** 非保活应用不会调用 */
  activated?: lifecycle;
  deactivated?: lifecycle;
  /** 子应用资源加载失败后调用 */
  loadError?: loadErrorHandler
};

destroyApp(销毁应用)

主动销毁子应用,承载子应用的iframeshadowRoot都会被销毁,无界实例也会被销毁,相当于所有的缓存都被清空,除非后续不会再使用子应用,否则都不应该主动销毁。

destroyApp(name)

微应用

$wujie

$wujie.bus
$wujie.shadowRoot -
$wujie.props
$wujie.location
  • 由于子应用的location.host拿到的是主应用的host,无界提供了一个正确的location挂载到挂载到$wujie
  • 当采用vite编译框架时,由于script的标签typemodule,所以无法采用闭包的方式将 location 劫持代理,子应用所有采用window.location.host的代码需要统一修改成$wujie.location.host
  • 当子应用发生降级时,由于proxy无法正常工作导致location无法代理,子应用所有采用window.location.host的代码需要统一修改成$wujie.location.host
  • 当采用非vite编译框架时,proxy代理了window.location,子应用代码无需做任何更改

micro-app(京东)

micro-app是借鉴 WebComponent,通过CustomElement结合自定义的ShadowDom,将微前端封装成一个类WebComponent的组件,从而实现微前端的组件化渲染。

特性

  1. 组件化渲染
    1. 类WebComponent的组件
    2. 依赖于CustomElements和Proxy两个较新的API
  2. 虚拟路由系统
    1. MicroApp通过拦截浏览器路由事件以及自定义的location、history,实现了一套虚拟路由系统,子应用运行在这套虚拟路由系统中,和主应用的路由进行隔离,避免相互影响。
  3. 多种沙箱
    1. with 沙箱 (默认)
    2. iframe 沙箱
  4. 支持插件系统

上手

主应用(Vue)

pnpm add @micro-zoe/micro-app --save
# npm i @micro-zoe/micro-app --save
import microApp from '@micro-zoe/micro-app'
microApp.start()
<template>
  <div>
    <h1>子应用👇</h1>
    <!-- name:应用名称, url:应用地址 -->
    <micro-app name='my-app' url='http://localhost:3000/'></micro-app>
  </div>
</template>

配置项

import microApp from '@micro-zoe/micro-app'
// 全局配置
microApp.start({
  iframe: true, // 全局开启iframe沙箱,默认为false
  inline: true, // 全局开启内联script模式运行js,默认为false
  destroy: true, // 全局开启destroy模式,卸载时强制删除缓存资源,默认为false
  ssr: true, // 全局开启ssr模式,默认为false
  'disable-scopecss': true, // 全局禁用样式隔离,默认为false
  'disable-sandbox': true, // 全局禁用沙箱,默认为false
  'keep-alive': true, // 全局开启保活模式,默认为false
  'disable-memory-router': true, // 全局关闭虚拟路由系统,默认值false
  'keep-router-state': true, // 子应用在卸载时保留路由状态,默认值false
  'disable-patch-request': true, // 关闭子应用请求的自动补全功能,默认值false
  iframeSrc: location.origin, // 设置iframe沙箱中iframe的src地址,默认为子应用所在页面地址
})
<!-- 单独配置 -->
<micro-app 
  name='xx' 
  url='xx' 
  iframe='false'
  inline='false'
  destroy='false'
  ssr='false'
  disable-scopecss='false'
  disable-sandbox='false'
  keep-alive='false'
  disable-memory-router='false'
  keep-router-state='false'
  disable-patch-request='false'
></micro-app>

虚拟路由

MicroApp通过拦截浏览器路由事件以及自定义的location、history,实现了一套虚拟路由系统,子应用运行在这套虚拟路由系统中,和主应用的路由进行隔离,避免相互影响。

keep-alive

开启keep-alive后,应用卸载时不会销毁,而是推入后台运行。 micro-app的keep-alive是应用级别的,它只会保留当前正在活动的页面状态,如果想要缓存具体的页面或组件,需要使用子应用框架的能力,如:vue的keep-alive。

数据通信

micro-app提供了一套灵活的数据通信机制,方便主应用和子应用之间的数据传输。

主应用和子应用之间的通信是绑定的,主应用只能向指定的子应用发送数据,子应用只能向主应用发送数据,这种方式可以有效的避免数据污染,防止多个子应用之间相互影响。

同时也提供了全局通信,方便跨应用之间的数据通信。

Js沙箱

使用Proxy拦截了用户全局操作的行为,防止对window的访问和修改,避免全局变量污染。micro-app中的每个子应用都运行在沙箱环境,以获取相对纯净的运行空间

子应用(Vue+Vite)

设置跨域

必须,vite默认开启跨域支持,不需要额外配置。

注册卸载函数
const app = createApp(App)
app.mount('#app')

// 卸载应用
window.unmount = () => {
  app.unmount()
}

总结

维度 qiankun wujie(无界) micro-app
实现原理 路由代理+沙箱(基于single-spa封装) Web Components + iframe Shadow DOM + JS 沙箱(Proxy)
沙箱隔离强度 高(Proxy沙箱) 极高(iframe原生隔离) 高(JS代理沙箱+CSS隔离)
Vite支持 需插件(vite-plugin-qiankun) 原生支持(无需额外配置) 原生支持(1.0版本)
接入成本 中等(需适配生命周期钩子) 极低(直接URL嵌入) 低(无需修改子应用代码)
调试难度 中(需沙箱调试) 高(iframe上下文) 低(可视化工具支持)
定制灵活性 高(插件机制完善) 低(框架固化) 中(API丰富)
性能损耗 动态代理(高) iframe通信(低) 资源拦截(中等)
通信机制 props + globalState 数据通信 + 事件总线 props + window通信
路由处理 路由级调度(支持路由冲突) 主子应用路由同步 虚拟路由(解决刷新问题)
子应用保活 需额外实现 内置保活机制 应用级别保活(需配合Vue/React)
多应用激活 支持(需配置) 支持(多实例激活) 支持(虚拟路由)
社区活跃度
技术栈兼容性 任意框架(React/Vue/Angular等) 任意框架(支持Vite) 任意框架(兼容Vue/React等)
浏览器兼容性 IE11+(需polyfill) IE11+(iframe fallback) IE11+(Web Components polyfill)
安全特性 沙箱隔离(非绝对) iframe物理隔离(最高) Shadow DOM隔离(高)
构建工具集成 Webpack 5+(需配置) Webpack/Vite(原生支持) Webpack/Vite(原生支持)
代码复杂度 中等
插件生态 丰富(qiankun-plugin-*) 有限(原生功能完备) 有限(社区插件较少)
热更新体验 需手动配置 原生支持 原生支持
资源加载方式 动态加载(JS/CSS) iframe加载 资源劫持加载
首屏加载速度 中等(依赖主应用) 快(预加载优化) 中等(虚拟路由优化)
错误隔离 部分隔离(沙箱限制) 完全隔离(iframe) 部分隔离(沙箱)

后面我需要实践一个多页签的微前端项目,目标是 把原来“巨石前端”拆成多个 Vue3 + Vite5 + TypeScript 子应用,全部应用使用一样的基础依赖,会使用pnpm-workspace monorepo的形式管理依赖,基座应用负责管理菜单,向子应用提供菜单、状态等其他共享信息;每个子应用内部都有多个路由菜单,可以使用基座应用的菜单/tab页签切换,需要实现切换时页面保活。

Flutter 简仿Excel表格组件介绍

作者 BG
2025年9月17日 18:24

前言

哈喽,各位 Flutter 开发者们!今天我要给大家介绍一个非常实用的 Flutter 表格组件库 - excel_table_plus!✨

作为一个天天和数据打交道的开发者,你是否也曾经为 Flutter 原生组件无法满足复杂表格需求而苦恼?是否也希望能有一个像 Excel 一样强大的表格组件?那么今天介绍的这个库,绝对能让你眼前一亮!😍

什么?你说你不需要处理复杂表格?那你也应该看看,说不定哪天就用上了呢~

🌟 为什么选择 excel_table_plus?

在 Flutter 生态中,虽然有一些表格组件,但功能都比较基础或者很复杂,满足不了或者定制不了我们特殊的需求。而 excel_table_plus 则是一个功能增强的 Excel 风格表格组件,它提供了更多基础的交互以及可自定义的单元格样式,可以满足各种复杂的业务需求,该库是在flutter_excel_table的参考基础下,经过了大量改动和完善,毕竟取自于开源,当然也要回馈于开源。其实大部分也都是AI辅助完成的,我只是起到一个需求引导作用。不得不说现在AI确实强大,其实连这篇文章主体都是让AI写完,我再做补充的,哈哈哈哈哈哈。

让我来给你展示一下它的核心功能:

  • ✅ 创建自定义行列的表格
  • ✅ 支持单元格合并
  • ✅ 自定义单元格样式(颜色、字体、对齐方式等)
  • ✅ 只读单元格支持
  • ✅ 可配置的单元格尺寸(宽度和高度)
  • ✅ 可调整行列大小
  • ✅ 单元格选择管理
  • ✅ 可滚动的表格视图
  • ✅ 自定义单元格构建器,完全控制
  • ✅ 行列标题(序号)
  • ✅ 可自定义边框和圆角
  • ✅ 支持自定义单元格模型类和 JSON 序列化
  • ✅ 支持导出和导入表格数据为 JSON 格式

是不是感觉功能很全面?别急,还有更高级的功能等着你去发掘呢!

🎯 高级功能一览

基于这些核心功能,开发者可以轻松实现以下高级功能:

  • 🎯 多单元格选择(支持拖拽)
  • 🎯 插入/删除行列
  • 🎯 单元格格式化(文本、数字、日期、下拉框)
  • 🎯 缩放功能
  • 🎯 撤销/重做操作
  • 🎯 复杂单元格类型(下拉框、日期选择器等)

看到这些功能,是不是已经心动了?这不就是你梦寐以求的表格组件吗?

🚀 快速上手

使用 excel_table_plus 非常简单,只需几个步骤即可集成到你的项目中:

1. 添加依赖

在你的 pubspec.yaml 文件中添加以下依赖:

dependencies:
  flutter:
    sdk: flutter
  excel_table_plus: ^0.0.1

2. 安装依赖

flutter pub get

3. 导入库

import 'package:excel_table_plus/excel_table_plus.dart';

4. 基本使用

// 创建控制器
ExcelController controller = ExcelController(
  excel: ExcelModel(
    x: 10, // 10列
    y: 20, // 20行
    showSn: true, // 显示序号
    backgroundColor: Colors.white,
    rowColor: Colors.blue.withOpacity(.25),
    resizable: true, // 可调整大小
    borderRadius: 8.0, // 圆角
  ),
  items: [
    // 标题行
    ExcelItemModel(
      position: ExcelPosition(0, 0),
      value: '姓名',
      color: Colors.grey.shade200,
      isReadOnly: true,
    ),
    ExcelItemModel(
      position: ExcelPosition(1, 0),
      value: '年龄',
      color: Colors.grey.shade200,
      isReadOnly: true,
    ),
    // 数据单元格
    ExcelItemModel(
      position: ExcelPosition(0, 1),
      value: '张三',
    ),
    ExcelItemModel(
      position: ExcelPosition(1, 1),
      value: '30',
    ),
    // 合并单元格示例
    ExcelItemModel(
      position: ExcelPosition(0, 5),
      value: '合并单元格示例',
      color: Colors.green,
      isReadOnly: true,
      isMergeCell: true,
      positions: [
        ExcelPosition(0, 5),
        ExcelPosition(1, 5),
        ExcelPosition(2, 5),
        ExcelPosition(0, 6),
        ExcelPosition(1, 6),
        ExcelPosition(2, 6),
      ],
    ),
  ],
);

// 使用组件
FlutterExcelWidget(
  controller: controller,
  itemBuilder: (x, y, item) {
    // 自定义单元格部件构建器
    if (item != null) {
      return TextField(
        controller: TextEditingController()..text = item.value?.toString() ?? '',
        readOnly: item.isReadOnly,
        decoration: const InputDecoration(
          border: InputBorder.none,
          contentPadding: EdgeInsets.all(8),
        ),
        onChanged: (value) {
          item.value = value;
        },
      );
    }
    return const SizedBox();
  },
)

是不是很简单?几行代码就能创建一个功能强大的表格组件!

🎥 效果预览

说了这么多,不如直接看效果!下面是几个示例的运行效果:

简单示例

preview_video1.gif

高级示例

preview_video2.gif

JSON 导入/导出示例

preview_video3.gif

看到这些效果,是不是已经开始跃跃欲试了?😎

🧠 自定义单元格模型

excel_table_plus 还支持带有 JSON 序列化的自定义单元格模型类。为了正确地将单元格数据导出和导入为 JSON,自定义单元格值类必须实现 toJson()fromJson() 方法:

// 带 JSON 支持的自定义单元格值类
class CustomCellValue {
  String? value;
  final String cellType = 'custom_text';
  final TextEditingController controller;
  final TextAlign textAlign;
  final TextStyle? style;
  
  CustomCellValue({
    this.value,
    TextEditingController? controller,
    this.textAlign = TextAlign.start,
    this.style,
  }) : controller = controller ?? TextEditingController();
  
  // JSON 序列化
  Map<String, dynamic> toJson() {
    final Map<String, dynamic> json = <String, dynamic>{};
    json['value'] = value;
    json['cellType'] = cellType;
    json['textAlign'] = textAlign.toString();
    // 如需要,序列化样式
    if (style != null) {
      json['style'] = {
        'fontSize': style!.fontSize,
        'color': style!.color?.value,
        // 根据需要添加其他样式属性
      };
    }
    return json;
  }
  
  // JSON 反序列化
  factory CustomCellValue.fromJson(Map<String, dynamic> json) {
    // 实现反序列化逻辑
    // ...
  }
}

📦 数据导入/导出

库支持将整个表格数据导出为 JSON 格式并重新导入:

// 将表格数据导出为 JSON
Map<String, dynamic> exportTableData() {
  return {
    'excel': controller.excel.toJson(),
    'items': controller.items.map((item) => item.toJson()).toList(),
  };
}

// 从 JSON 导入表格数据
void importTableData(Map<String, dynamic> json) {
  ExcelModel excel = ExcelModel.fromJson(json['excel'] as Map<String, dynamic>);
  List<ExcelItemModel> items = (json['items'] as List<dynamic>)
      .map((itemJson) => ExcelItemModel.fromJson(
            itemJson as Map<String, dynamic>,
            customValueFactory: (cellType, valueJson) {
              // 根据类型创建自定义单元格值
              switch (cellType) {
                case 'custom_text':
                  return CustomCellValue.fromJson(valueJson);
                // 为其他自定义单元格类型添加更多情况
                default:
                  return valueJson;
              }
            },
          ))
      .toList();
  
  controller = ExcelController(
    excel: excel,
    items: items,
  );
}

📚 总结

excel_table_plus 是一个功能强大且灵活的 Flutter 表格组件库,它提供了丰富的功能和良好的扩展性,能够满足各种复杂的表格需求。无论你是需要一个简单的数据展示表格,还是一个功能复杂的 Excel 风格编辑器,它都能胜任。

哎呀,这么好的库,你还在等什么?赶紧去试试吧!(这句也是AI写的,其实我想说的是,我能力有限,希望大佬们多多指导完善)✨

项目信息

安装方式

flutter pub add excel_table_plus

P.S. 如果你觉得这个库还不错,别忘了去 GitHub 上给个 Star 哦!你的支持是我继续创作的动力!💪(这句也是AI写的,star不star都无所谓的,主打一个分享的喜悦,或许能帮到跟我一样遇到相同需求并且为之所困的有缘人。)

[译] Composition in CSS

作者 石金龙
2025年9月17日 18:19

原作信息


Tailwind 和一些工具库,一直推动「组合」理念,但依我看,它们的「组合」未免太过天真。

像这种一条一条添加 class……

<div class="p-4 border-2 border-blue-500"> ... </div>

和这种直接写 CSS 的有什么区别?

/* 这不也是组合嘛 */
.card {
  padding: 1rem; 
  border: 2px solid var(—color-blue-500)
}

不过有一说一,自从用了 Tailwind 对组合概念也想得多了。我整理了一些思考笔记。

自古有之

CSS 天然能组合,这一特性内置在了层叠机制。比方,决定用这些属性,设置按钮样式。

.button {
  display: inline-flex;
  padding: 0.75em 1.5em; 
  /* other styles... */
}

随时能添加别的 class 改变按钮的外观:

<button class="button primary"> ... </button>
<button class="button secondary"> ... </button>
.primary { background: orange; }
.secondary { background: pink; }

甚至能添加 .button class 将其他元素改为按钮外观:

<a href="#" class="button"> ... </a>

这两种情况都在发生组合:

  1. .button 加给 a 元素上
  2. .red 组合到 .button

因此,CSS 组合机制自古有之。因为是语言天然的特性,所以我们习以为常。

对「组合」的理解片面

有的人提到 CSS 的组合,只能想到 HTML 中添加 class。

<div class="one two"> ... </div>

Sass 混合器高级 Tailwind 工具的时候,又几乎没人探讨过 CSS 文件中的组合问题。

在这些时候,其实也在组合样式……没在 HTML 里操作而已。

@mixin button () {
  display: inline-flex;
  padding: 0.75em 1.5em; 
  /* other styles ... */
}

.button {
  @include button; 
}

Composition 组合,可能源于两个词根:

  • Compose 组成:  将多个部分或元素放在一起形成整体;拼接或拼凑不同的部分以创建完整的物体或概念
  • Composite 合成:  由不同部分或元素构成

这两个词都源自同一个拉丁词根 "componere",其含义为「排列」或「指挥」。

也就是说……所有工作都以某种方式被组织起来,因此所有工作都是被组合而成的。于是我在想,为何「组合」概念在编程语境中被运用得如此局限?🤔

译者注

词根这段儿可能理解有误,还请指正。

接着看……

组合不是压缩

组合 class 只在用工具类的情况下才能 CSS 瘦身。然而,组合工具类可能导致 HTML 膨胀。

<div class="utility composition">...</div>
<div class="one utility at a time">...</div>
<div class="may create html bloat">...</div>

另一方面,组合选择器的 class 可能不会减轻 CSS,但确实能减少 HTML 膨胀。

<div class="class composition">...</div>
<div class="card primary">...</div>
<div class="may override properties">...</div>
<div class="less html bloat"> ... </div>

孰好孰坏?¯_(ツ)_/¯

HTML 和 CSS 膨胀可能是最不用担心的问题

众所周知:

  • HTML 能包含大量内容且对性能影响甚微。
  • CSS:俺也一样。
  • 500 行的 CSS 大小约 12kb ~ 15kb (根据 Claude 数据)。
  • 一张图片大小通常 150kb 起步。

想要项目变苗条,与其纠结 CSS 的写法,还不如优化图片的用法。

想重构 CSS 提升性能,加载时间也就缩短 2 毫秒;要是为了提升可读性那必须支持。

我认为:

  • HTML 和 CSS 的冗余部分其实不重要。
  • 重点要放在架构设置、代码结构和清晰度上。

高级组合

站在高处,就会发现,所有内容都可归为四大类:

  1. 布局:关乎物品在页面摆放
  2. 文字:字体相关
  3. 主题:颜色相关
  4. 特效:渐变、阴影等好看的

这四类样式互不影响。比方:

  • font-weight 只属于文字一类
  • colour 只属于主题一类

按类创建可组合的 class 才合理的——将这些 class 组合搭配,生成最终成品。

假设你对这四个类进行了 class 组合,HTML 可能是这样:

<!-- 假装写些 class,发挥你的想象力吧! -->
<div class="layout-1 layout-2 effects-1">
  <h2 class="typography-1 theming-1"> ... </div>
  <div class="typography-2"> ... </div>
</div>

这就是典型的例子,用 Splendid Styles 和 Splendid Layouts 的 class:

<div class="card vertical elevation-3">
  <h2 class="inter-title"> ... </h2>
  <div class="prose"> ... </div>
</div>

我将进一步阐述这四个类别体系,以及我在最新作品 Unorthodox Tailwind 中是如何创建可组合类别的。感兴趣的话,来瞧一瞧!

总结

概括:

  1. CSS 天然能组合。
  2. 有些开发人员对于 CSS 的「组合」理解片面。
  3. 能在 HTML 或 CSS 里组合操作。
  4. 样式分四类:布局、文字、主题和特效。

最后:Splendid Styles 包含了能够辅助这四个类别中的每一种进行设计的 class。Splendid Layouts 则负责处理布局部分。而且我在课程中 Unorthodox Tailwind 还会详细介绍我是如何创建可组合类的。

Promise 的使用

2025年9月17日 18:10

为什么需要 Promise

异步操作的解决方案

在函数内有一个异步操作

function request() {
    // 模拟异步操作
    setTimeout(() => {
      // 异步执行代码
      ...
    }, 3000);
}

// 调用函数
request()

通常我们会有以下需求:

  • 获取到异步操作的结果;

  • 异步操作结束后执行其他的操作;

这时候我们的解决思路是,定义一个回调函数,在异步操作执行结束后调用

function request(..., callback) {
    // 模拟异步操作
    setTimeout(() => {
      // 异步执行代码
      
      // 回调函数   
      callback()
    }, 3000);
}

在调用函数时传递回调函数

request(() => {
    console.log('异步执行结束了');
})

如何使用 Promise

Promise 的基本使用

1. 实例化一个 Promise 对象

const promise = new Promise((reslove, reject) {
    // 成功执行
    reslove()
    // 失败执行
    reject()
})

2. then 方法

// then 方法传入两个回调函数
// 第一个回调函数会在 Promise 执行 resolve 函数时被回调
// 第二个回调函数会在 Promise 执行 reject 函数时被回调
promise.then(() => {}, () => {})

Promise 的状态

Promise 对象的状态值有三种

  • pending
  • fulfilled
  • rejected
new Promise((reslove, reject) => {
    ...
    // pending 待定

    reslove()
    // fulfilled 已完成
    
    reject()
    // rejected 已拒绝
}).then(() => { 

}, () => { 

}) 
  • 默认是 pending 状态,执行 reslove 方法之后就变成 fulfilled 状态,执行 reject 方法之后就变成 rejected 状态。

  • Promise 的状态一旦确定下来就不能更改了,所以 reslove 方法跟 reject 方法只会触发一个。

  • 只有当 Promise 的状态变成 fulfilled 时才会触发 then 方法的第一个回调函数, rejected 状态同理。

reslove 方法传参

  1. 参数是常用数据类型

pending —> fulfilled

  1. 参数是一个 Promise 对象

这时状态值不会直接变成 fulfilled,而是由传入的 Promise 决定,相当于状态进行了移交。

  1. 参数是一个对象,且对象有 then 方法

会直接执行对象

new、原型和原型链浅析

2025年9月17日 18:05

new、原型和原型链

继承是编程里的一个常见思想。通过对父对象属性、方法的继承子对象可以不用编写相同的代码但能调用对应的属性、方法。

js通过原型来实现继承特性。

原型

在js中,在js中每一个对象都有一个自己的“父对象”,这个“父对象”就叫做原型。

了解原型我们需要知道三个属性以及他们对应的含义

  • __ proto__ :对象的一个属性,指向这个对象的原型。
  • prototype :函数的独有属性,指向这个函数的原型。
  • constructor:对象的一个函数,指向这个对象的构造函数。

这里需要特别提到的有两点。

1、__ proto__在ES标准定义里的属性名应该是[[prototype]],具体实现是由浏览器代理自己实现。不同的浏览器可能在实现[[prototype]]上命名不同,功能是一致的。本文采用谷歌浏览器的实现命名方式__ proto__。

2、prototype是函数特有的属性,对象变量并没有prototype属性指向原型。但是由于函数也是一种对象,所以函数既有prototype,又有__ proto__,还有指向构造函数的constructor属性。

let func = function(value){
    this.value = value
}

func.prototype.getValue = function() {
  return this.value;
}

let objA = new func(4);

这段代码是一个简单的通过new关键字将func函数作为构造函数来实例化一个对象objA

func()是一个构造函数,它有着特殊的属性prototype,在js中函数也是对象,所以它也有__ proto__constructorprototype__ proto__都指向原型func.prototype。 constructor指向func()的构造函数,这个在之后的原型链部分再讲。

func.prototype是一个原型对象,它也有__ proto__constructor。在js中一个构造函数的它的constructor指向构造函数

objA作为对象他有两个元素,__ proto__constructorconstructor指向构造函数func(), __ proto__指向原型。由于new关键字在实例化对象时是将构造函数的原型赋给实例化对象,所以__ proto__指向的是func.prototype。

func的prototype和objA的__ proto__都指向func.prototype,objAfunc.prototype的构造函数(constructor)都是func

原型示意图.png

new

通过new关键字调用一个函数时,将其视为一个构造函数,创建一个该构造函数的实例对象。这个对象拥有这个函数的共享属性、共享方法。具体实现在这里先不展示,需要知道的是: 在实例化这个对象的过程中,new将构造函数的prototype赋给了实例对象。

所以实例对象和构造函数实际上是共享一个原型。

原型链

对象有原型和构造函数,原型对象和构造函数也不例外。它们都有自己的原型和构造函数。 调用一个对象没有的方法时,该对象就会查找它的原型[[prototype]]查看是否有该方法,如果它的原型也没有该方法就会查找它原型的原型,直到查到Object.prototype。因为Object.prototype的原型是null。

这个通过[[prototype]]将各个原型穿起来链接就叫原型链。

原型链.png

如图所示从上到下分层来进行对这个原型链的解析。

第一层: ObjA是func()实例化对象,所以func()是ObjA的构造函数。在js内部,每个函数都是由Function()实例化而来所以Function()是fun()的构造函数。这里需要注意一点,其实ObjA自身是没有constructor属性的,在查询constructor属性时是遍历到自身的原型然后调用的原型的constructor。

所以实例对象的constructor == 实例对象的原型的constructor

Function()函数比较特殊,它自己就是自己的构造函数。

第二层:objA在实例化时func()将自身的原型赋给了它,所以objA和func()是同一原型。 由于ObjA是对象。所以只有__ proto__属性指向func.prototype。

每个构造函数在创建时,它的原型的constructor会自动指向它本身。 所以func.prototype.constructor == func。

同样Function.prototype.constructor指向Function()

第三层:由于fun.prototype是一个对象且它不是由其它构造函数构造得来,所以它的原型是Object.prototype,func.prototype.__ proto__ == Object.prototype。

Function.prototype作为一个Function()的原型对象,自然它的原型也指向Object.prototype,Function.prototype.__ proto__ == Object.prototype。

Object()是Object的构造函数,所以Object().prototype.constructor == Object()

第四层:Object()作为最上层的对象,所有的对象都是从Object.prototype直接或间接继承而来。所以Object()再往上没有原型。 Object.prototype == null

补充一下:

[[prototype]]可以通过Object.setPrototypeOf修改构造函数的原型,但是不建议这么做,因为构造函数在修改原型时不会一同修改


let func = function(value){
   this.value = value
}

func.prototype.getValue = function() {
 return this.value;
}

let objA = new func(4);
console.log(objA.value)

let o = function(value){
   this.value = value + 1
}

Object.setPrototypeOf(func.prototype, o.prototype)

let objB = new func(2);
console.log(objB.value)

coze源码解读: space develop 页面

作者 前端阿星
2025年9月17日 18:03

layout 组件解释完了之后,我们顺着路由配置一个个往下看。前面几个 Redirect 都不用看,调整过,直接来到 space 页面。

space 下面就一个大的子路由:SpaceIdLayout,SpaceIdLayout大概有 8 个页面。我们先来看Develop(Space 和 SpaceIdLayout 都没什么特别的),核心代码在:/entry-adapter/src/pages/develop/index.tsx

启动服务之后打开,可以看到页面大概长这样:

develop-page-ui.jpg

页面比较简单,一个标题,两个 select,以及具体创建的项目列表(app 或者 agent)。

接下来我们来看看具体的实现。

先看逻辑部分。

页面状态读取

useSpaceStore,useCachedQueryParams,isFilterHighlight,通过三个方法去获取当前状态,主要是用来控制渲染。

const isPersonal = useSpaceStore(
  state => state.space.space_type === SpaceType.Personal,
);

// Keyword Search & Filtering
const [filterParams, setFilterParams, debouncedSetSearchValue] =
  useCachedQueryParams();

const {
  isIntelligenceTypeFilterHighlight,
  isOwnerFilterHighlight,
  isPublishAndOpenFilterHighlight,
} = isFilterHighlight(filterParams);

useSpaceStore逻辑比较简单,从 store 中获取 space 类型。 useCachedQueryParams主要是从 localstorage 中获取当前 select 的查询状态,以便在页面刷新的时候可以保留(该说不说,感觉这功能没啥用)。该 hooks 会返回两个方法:setFilterParams,debouncedSetSearchValue。分别用来设置 select 和搜索框的状态。

isFilterHighlight是一个工具方法,负责解析 filterParams,控制页面展示。

列表展示控制

这里主要有两个 hooks:useIntelligenceList,useGlobalEventListeners

useIntelligenceList底层封装了useInfiniteScroll,负责列表数据的获取和更新,对外暴露一些控制方法,例如reloadmutate等。

useGlobalEventListeners监听了在refreshFavList等事件派发的时候主动调用reload方法刷新列表。主要是在列表删除、负责或者创建的时候会执行这些动作。事件是基于 mitt 封装了一层。

接下来有两个useEffect,一个是在 spaceId 变化的时候更新 select,一个应该是监控相关的一些逻辑。都不太重要,一起放在这里讲了。

应用的创建和复制

接下来还有三个 hooks,分别是控制应用的创建、复制、删除等逻辑。

useProjectCopyPolling({
  listData: data?.list,
  spaceId,
  mutate,
});

const { contextHolder: cardActionsContextHolder, actions: cardActions } =
  useCardActions({
    isPersonalSpace: isPersonal,
    mutate,
  });

/**
 * Create project
 */
const { contextHolder, actions } = useIntelligenceActions({
  spaceId,
  mutateList: mutate,
  reloadList: reload,
});

useProjectCopyPolling这个 hooks 主要是监听了onCopyTaskUpdate事件,事件完成的时候更新列表状态。应该是任务复制是一个比较耗时的操作,所有用了 polling 的方式去做状态监听(这块逻辑感觉还是不错的)。

接下来是两个 actions:useCardActions,useIntelligenceActions,用来控制弹框的渲染和执行,以及删除、复制等逻辑。。

就是这两个:

develop-popup-a.jpg

develop-popup-b.jpg 不展开了,没什么好说的(这种逻辑和视图耦合在一起的做法,不知道是怎么被设计出来的,可能是我孤陋寡闻了)。

ui

逻辑部分讲完了,接下来讲讲 ui 部分。

ui 部分比较长,但是核心就是:BotCard。但是其实也没什么好讲的,核心逻辑都在上面提到的两个 action 里面。

develop 部分就差不多到这里吧。

Vite + Vue3项目版本更新检查与页面自动刷新方案

作者 NBtab
2025年9月17日 18:01

使用 Vite 对 Vue 项目进行打包,对 js 和 css 文件使用了 chunkhash 进行了文件缓存控制,但是项目的 index.html 文件在版本频繁迭代更新时,会存在被浏览器缓存的情况。

在发版后,如果用户不强制刷新页面,浏览器会使用旧的 index.html 文件,在跳转页面时会向服务器端请求了上个版本 chunkhash 的 js 和 css 文件,但此时的文件已经在版本更新时已替换删除了,最终表现为页面卡顿,控制台报错 404。

解决思路

在每次打包生产代码时,在 public 目录下生成一个 version.json 版本信息文件,页面跳转时请求服务器端的 version.json 中的版本号和浏览器本地缓存的版本号进行对比,从而监控版本迭代更新,实现页面自动更新,获取新的 index.html 文件(前提是服务器端对 index.htmlversion.json 不缓存)。

第一步:配置服务器,禁止关键文件缓存

要实现版本对比,需确保 index.html 和后续生成的版本文件 version.json 不被浏览器缓存 —— 每次请求均从服务器获取最新内容。以 Nginx 为例,添加如下配置:

location ~ .*\.(htm|html|json)?$ {
    expires -1;
}

第二步:开发 Vite 插件,自动生成版本信息

通过自定义 Vite 插件,在打包时自动在 public 目录下生成 version.json 文件,记录当前版本标识(建议用时间戳,确保每次打包版本号唯一)。 TypeScript 版本插件(src/plugins/versionUpdatePlugin.ts)

// versionUpdatePlugin.ts
import fs from "node:fs"
import path from "node:path"

import type { ResolvedConfig } from "vite"

function writeVersion(versionFile: string, content: string) {
  // 写入文件
  fs.writeFile(versionFile, content, (err) => {
    if (err) throw err
  })
}

export default (version: string | number) => {
  let config: ResolvedConfig
  return {
    name: "version-update",
    configResolved(resolvedConfig: ResolvedConfig) {
      // 存储最终解析的配置
      config = resolvedConfig
    },
    buildStart() {
      // 生成版本信息文件路径
      const file = config.publicDir + path.sep + "version.json"
      // 这里使用编译时间作为版本信息
      const content = JSON.stringify({ version })
      if (fs.existsSync(config.publicDir)) {
        writeVersion(file, content)
      } else {
        fs.mkdir(config.publicDir, (err) => {
          if (err) throw err
          writeVersion(file, content)
        })
      }
    },
  }
}

JavaScript 版本插件(src/plugins/versionUpdatePlugin.js)

// versionUpdatePlugin.js
const fs = require("fs")
const path = require("path")

const writeVersion = (versionFile, content) => {
  // 写入文件
  fs.writeFile(versionFile, content, (err) => {
    if (err) throw err
  })
}

export default (options) => {
  let config

  return {
    name: "version-update",
    configResolved(resolvedConfig) {
      // 存储最终解析的配置
      config = resolvedConfig
    },
    buildStart() {
      // 生成版本信息文件路径
      const file = config.publicDir + path.sep + "version.json"
      // 这里使用编译时间作为版本信息
      const content = JSON.stringify({ version: options.version })
      if (fs.existsSync(config.publicDir)) {
        writeVersion(file, content)
      } else {
        fs.mkdir(config.publicDir, (err) => {
          if (err) throw err
          writeVersion(file, content)
        })
      }
    },
  }
}

第三步:配置 Vite,注入全局版本变量

在 vite.config.js/ts 中引入上述插件,同时定义全局版本变量 __APP_VERSION__(供前端对比使用),建议用当前时间戳作为版本号,确保每次打包版本唯一。

类型声明(TS 项目必备)

若使用 TypeScript,需在 vite-env.d.ts 或 env.d.ts 中添加全局变量类型声明,避免类型报错:

// vite-env.d.ts
declare const __APP_VERSION__: string

——————————

// vite.config.js or vite.config.ts
export default defineConfig((config) => {
  const now = new Date().getTime()
  return {
    // ...
    define: {
      // 定义全局变量
      __APP_VERSION__: now,
    },
    plugins: [
      // ...
      versionUpdatePlugin({
        version: now,
      }),
    ],
    // ...
  }
})

第四步:前端实现版本检测,触发自动刷新

利用 Vue Router 的全局前置守卫,在每次页面跳转前检查版本 —— 对比浏览器端全局变量 __APP_VERSION__ 与服务器端 version.json 中的版本号,若不一致则提示用户并自动刷新页面(选择前置守卫是因为跳转失败不会触发后置守卫,可在报错前完成检测)。

const router = useRouter()
// 这里在路由全局前置守卫中检查版本
router.beforeEach(async () => {
  await versionCheck()
})

// 版本监控
const versionCheck = async () => {
  if (import.meta.env.MODE === "development") return
  const response = await axios.get("version.json")
  if (__APP_VERSION__ !== response.data.version) {
    ElMessage({
      message: "发现新内容,自动更新中...",
      type: "success",
      showClose: true,
      duration: 1500,
      onClose: () => {
        window.location.reload()
      },
    })
  }
}

如果大家有任何建议或疑问,都可以在下方评论或私信我,真的非常感谢!

✅ 觉得某部分设计太繁琐,有更简洁 / 高级的封装方式

✅ 发现代码能用新 API 或语法优化

✅ 对内容有不理解的地方

✅ 能解答我文中没说透的疑问

✅ 发现交互 / 功能 bug,或有优化建议

也欢迎大家实用我刚刚用Vue开发的标签页项目:NBtab新标签页

最后感谢大家的耐心阅读,既然都看到这了,点个👍赞再走吧!

来全面地review一下Flex布局(面试可用)

作者 天天扭码
2025年9月17日 17:53

面试时怎么给面试官讲Flex?

这个问题很宽泛,其实最核心的问题不过是如何介绍Flex、如何背一些可能被问到的固定题、如何使用Flex实现一些简单的布局,接下来就按照上面的逻辑来写这篇文章吧

如何介绍Flex

我面试害怕两种题型:

1.我一定都不会的题

2.会一点但是面试官问的很宽泛,想看我的深度的题

现在我们解决的是后者

面试官:"你对Flex布局了解多少?讲一下吧"

嗯..很宽泛的问题,那么从何讲起呢?

首先我们要介绍Flex布局的基本概念,虽然很枯燥,但是用来开头确实很不错

首先我们要讲Flex的定义,什么是Flex

"弹性布局(Flex 布局)是 CSS3 引入的一种一维布局模型,通过为父元素设置 display: flex 使其成为弹性容器,其直接子元素自动变为弹性项;核心是让容器能灵活控制内部元素的排列方向、对齐方式、尺寸伸缩及空间分配,无需依赖浮动或定位即可快速实现居中、均匀分布等复杂布局,兼顾简洁性与适应性,是现代前端布局的核心方案。"

接下来我们讲Flex的优点,我相信面试官很想听这个,可以体现出我们有自己的思考,并不是看别人用这个我才用这个,最后我们举个例子去说明Flex的优点

“Flex布局,代码简洁高效,无需依赖浮动、定位等复杂方案,就能轻松实现元素在水平 / 垂直方向的精准对齐,同时支持元素根据容器空间自动伸缩,适配不同屏幕尺寸,大幅降低响应式布局的开发成本

比如要实现一个导航栏,让 4 个导航按钮在容器内水平居中且均匀分布,传统布局可能需要计算按钮间距、处理浮动清除等问题,而用 Flex 只需两步:给导航栏父容器添加 display: flex 使其成为弹性容器,再补充 justify-content: space-between(让按钮沿主轴均匀分布,两端贴边)和 align-items: center(让按钮在垂直方向居中),无需额外计算尺寸,且当屏幕缩小时,按钮会自动调整间距以适应容器宽度,完美体现其简洁性与自适应优势。(诸如此类的例子都可以)”

接下来就是具体的Flex的具体属性的内容了,现在让我们更加深入一些,切入点是两个概念 容器和轴

“容器分为两个,一个是父容器,一个是子容器,顾名思义,父容器里面的就是子容器

轴分为两个轴,一个是主轴,另一个是和主轴垂直的轴,我们叫做交差轴”

接下来简单总结二者的关系

“弹性布局由父容器和子容器组成,通过主轴和交叉轴去控制子元素的排列方式”

接下来继续深入,我们开始切入Flex的具体属性

接下来我根据父容器和子容器分开来讲Flex的具体属性

在父容器中,主要是下面的六种属性

  1. flex-direction:设置主轴方向,取值为 row(默认,水平从左到右)、row-reverse(水平从右到左)、column(垂直从上到下)、column-reverse(垂直从下到上)。
  2. flex-wrap:控制弹性项是否换行,取值为 nowrap(默认,不换行,可能溢出)、wrap(换行,第一行在上)、wrap-reverse(换行,第一行在下)。
  3. flex-flowflex-direction 和 flex-wrap 的简写,默认值为 row nowrap
  4. justify-content:设置弹性项在主轴上的对齐方式,常用值有 flex-start(默认,靠左 / 上)、flex-end(靠右 / 下)、center(居中)、space-between(两端对齐,中间等距)、space-around(元素两侧间距相等)。
  5. align-items:设置弹性项在交叉轴(与主轴垂直)上的对齐方式,常用值有 stretch(默认,拉伸填满容器)、flex-start(靠交叉轴起点)、flex-end(靠交叉轴终点)、center(交叉轴居中)、baseline(基线对齐)。
  6. align-content:当弹性项换行后(多行),控制多行在交叉轴上的对齐方式,取值与 justify-content 类似,默认 stretch(拉伸填满交叉轴)。

上面用来给面试官讲足够了,但是我们有必要去深入学习一下上面的属性,万一面试官揪住某个属性继续追问呢?接下来,不是面试时间,是学习时间

一、flex-direction

flex-direction 是 Flex 父容器的核心属性,用于定义主轴方向(子元素沿主轴排列),共 4 个常用取值row(默认,水平从左到右)、row-reverse(水平从右到左)、column(垂直从上到下)、column-reverse(垂直从下到上),接下来我们用直观的例子去体会这个属性的妙用

案例 1:默认值 flex-direction: row(水平从左到右)

<style>
    /* 父容器:启用 Flex 布局,设置主轴为水平从左到右 */
    .flex-container {
        display: flex;
        flex-direction: row; /* 默认值,可省略,但显式写更清晰 */
        width: 400px;
        height: 150px;
        background-color: #eee; /* 灰色背景,方便观察容器范围 */
        gap: 10px; /* 子元素之间的间距 */
        padding: 10px; /* 容器内边距,避免子元素贴边 */
    }
    /* 子元素:彩色方块,带文字标识 */
    .flex-item {
        width: 80px;
        height: 80px;
        color: white;
        font-size: 20px;
        text-align: center;
        line-height: 80px; /* 文字垂直居中 */
    }
    /* 给不同子元素设不同颜色,区分顺序 */
    .item1 { background-color: #ff4444; }
    .item2 { background-color: #00c851; }
    .item3 { background-color: #33b5e5; }
</style>
<body>
    <h3>flex-direction: row(水平从左到右,默认)</h3>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
    </div>
</body>

image.png

运行效果:3 个彩色方块(红 1→绿 2→蓝 3)在灰色容器内水平排列,从左到右依次分布。

案例 2:flex-direction: row-reverse(水平从右到左)


    <style>
        .flex-container {
            display: flex;
            flex-direction: row-reverse; /* 主轴为水平从右到左 */
            width: 400px;
            height: 150px;
            background-color: #eee;
            gap: 10px;
            padding: 10px;
        }
        .flex-item {
            width: 80px;
            height: 80px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
    </style>
</head>
<body>
    <h3>flex-direction: row-reverse(水平从右到左)</h3>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
    </div>
</body>

image.png

运行效果:3 个彩色方块(蓝 3→绿 2→红 1)在灰色容器内水平排列,从右到左依次分布(顺序反转)。

案例 3:flex-direction: column(垂直从上到下)


    <style>
        .flex-container {
            display: flex;
            flex-direction: column; /* 主轴为垂直从上到下 */
            width: 400px;
            height: 250px; /* 增高容器,容纳垂直排列的子元素 */
            background-color: #eee;
            gap: 10px;
            padding: 10px;
        }
        .flex-item {
            width: 80px;
            height: 80px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
    </style>
</head>
<body>
    <h3>flex-direction: column(垂直从上到下)</h3>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
    </div>
</body>

image.png

运行效果:3 个彩色方块(红 1→绿 2→蓝 3)在灰色容器内垂直排列,从上到下依次分布。

案例 4:flex-direction: column-reverse(垂直从下到上)

    <style>
        .flex-container {
            display: flex;
            flex-direction: column-reverse; /* 主轴为垂直从下到上 */
            width: 400px;
            height: 250px;
            background-color: #eee;
            gap: 10px;
            padding: 10px;
        }
        .flex-item {
            width: 80px;
            height: 80px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
    </style>
</head>
<body>
    <h3>flex-direction: column-reverse(垂直从下到上)</h3>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
    </div>
</body>

image.png

运行效果:3 个彩色方块(蓝 3→绿 2→红 1)在灰色容器内垂直排列,从下到上依次分布(顺序反转)。

二、flex-wrap

flex-wrap 用于控制 Flex 容器内的子元素在总尺寸超过容器时,是否自动换行以及换行方向。它有 3 个常用取值,以下案例均为完整可运行的 HTML 文件,直观展示不同取值的效果差异。

  • nowrap(默认):强制不换行,子元素可能被压缩变形;
  • wrap:自动换行,多余元素从容器顶部向底部排列(正常阅读顺序);
  • wrap-reverse:自动换行,但多余元素从容器底部向顶部排列(反转顺序);

案例 1:默认值 flex-wrap: nowrap(不换行,子元素可能被压缩)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>flex-wrap: nowrap</title>
    <style>
        .flex-container {
            display: flex;
            flex-wrap: nowrap; /* 默认值:不换行 */
            width: 300px; /* 容器宽度固定为300px */
            height: 120px;
            background-color: #eee;
            gap: 10px;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 100px; /* 单个子元素宽度100px,4个总宽400px > 容器300px */
            height: 80px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
        .item4 { background-color: #aa66cc; }
    </style>
</head>
<body>
    <h3>flex-wrap: nowrap(不换行,子元素会被压缩)</h3>
    <p>容器宽300px,4个子元素各宽100px(总宽400px),不换行时会被压缩</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
        <div class="flex-item item4">4</div>
    </div>
</body>
</html>

image.png

运行效果:4 个子元素会强行挤在一行,每个元素的实际宽度会被压缩(小于设置的 100px),以适应容器宽度,不会换行。

案例 2:flex-wrap: wrap(自动换行,第一行在上)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>flex-wrap: wrap</title>
    <style>
        .flex-container {
            display: flex;
            flex-wrap: wrap; /* 自动换行,超出部分排到下一行 */
            width: 300px;
            height: 200px; /* 增高容器,容纳两行子元素 */
            background-color: #eee;
            gap: 10px;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 100px; /* 与案例1相同的子元素尺寸 */
            height: 80px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
        .item4 { background-color: #aa66cc; }
    </style>
</head>
<body>
    <h3>flex-wrap: wrap(自动换行,第一行在上)</h3>
    <p>容器宽300px,4个子元素各宽100px,自动分成两行(第一行1、2,第二行3、4)</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
        <div class="flex-item item4">4</div>
    </div>
</body>
</html>

image.png

运行效果:子元素总宽超过容器时,自动换到下一行排列。第一行显示 1、2(宽度足够容纳 2 个),第二行显示 3、4,保持设置的 100px 宽度不变。

案例 3:flex-wrap: wrap-reverse(自动换行,第一行在下)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>flex-wrap: wrap-reverse</title>
    <style>
        .flex-container {
            display: flex;
            flex-wrap: wrap-reverse; /* 自动换行,但第一行在下方 */
            width: 300px;
            height: 200px;
            background-color: #eee;
            gap: 10px;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 100px; /* 与前两个案例相同的子元素尺寸 */
            height: 80px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
        .item4 { background-color: #aa66cc; }
    </style>
</head>
<body>
    <h3>flex-wrap: wrap-reverse(自动换行,第一行在下)</h3>
    <p>容器宽300px,4个子元素换行后,第一行(1、2)在下方,第二行(3、4)在上方</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
        <div class="flex-item item4">4</div>
    </div>
</body>
</html>

image.png

运行效果:同样会自动换行,但换行方向反转。原本应在第一行的 1、2 会显示在下方,原本应在第二行的 3、4 会显示在上方。

三、flex-flow

flex-direction 和 flex-wrap 的简写,默认值为 row nowrap

简单举两个例子

案例 1:flex-flow: row wrap(水平方向 + 自动换行)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>flex-flow: row wrap</title>
    <style>
        .flex-container {
            display: flex;
            /* 等价于 flex-direction: row; flex-wrap: wrap */
            flex-flow: row wrap; 
            width: 300px;
            height: 200px;
            background-color: #eee;
            gap: 10px;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 100px;
            height: 80px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
        .item4 { background-color: #aa66cc; }
    </style>
</head>
<body>
    <h3>flex-flow: row wrap(水平方向 + 自动换行)</h3>
    <p>等价于 flex-direction: row; flex-wrap: wrap,子元素水平排列且自动换行</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
        <div class="flex-item item4">4</div>
    </div>
</body>
</html>

image.png

运行效果:子元素沿水平方向排列(左→右),总宽超容器时自动换行,第一行显示 1、2,第二行显示 3、4。

案例 2:flex-flow: column wrap(垂直方向 + 自动换行)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>flex-flow: column wrap</title>
    <style>
        .flex-container {
            display: flex;
            /* 等价于 flex-direction: column; flex-wrap: wrap */
            flex-flow: column wrap; 
            width: 300px;
            height: 180px; /* 限制高度,触发垂直换行 */
            background-color: #eee;
            gap: 10px;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 80px;
            height: 80px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
        .item4 { background-color: #aa66cc; }
    </style>
</head>
<body>
    <h3>flex-flow: column wrap(垂直方向 + 自动换行)</h3>
    <p>等价于 flex-direction: column; flex-wrap: wrap,子元素垂直排列且自动换行</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
        <div class="flex-item item4">4</div>
    </div>
</body>
</html>

image.png

运行效果:子元素沿垂直方向排列(上→下),总高超容器时自动换列,第一列显示 1、2,第二列显示 3、4。

四、justify-content

justify-content 用于控制 Flex 容器内的子元素在主轴方向上的对齐方式和剩余空间分配规则。它的效果取决于 flex-direction 定义的主轴方向(水平或垂直),以下案例均以水平主轴(flex-direction: row)为例,方便直观理解。

6 个取值的核心差异在于剩余空间的分配方式

  • flex-start/flex-end/center:控制整体对齐,剩余空间集中在某一侧或两侧;
  • space-between:强调 “两端贴边,中间等距”;
  • space-around:强调 “元素两侧间距相等”(边缘间距是中间的一半);
  • space-evenly:强调 “所有间距(包括边缘)完全相同”;

案例 1:默认值 justify-content: flex-start(靠主轴起点对齐)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>justify-content: flex-start</title>
    <style>
        .flex-container {
            display: flex;
            justify-content: flex-start; /* 默认值:靠主轴起点对齐 */
            width: 400px;
            height: 120px;
            background-color: #eee;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 80px;
            height: 80px;
            margin: 0 5px; /* 小间距区分元素 */
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
    </style>
</head>
<body>
    <h3>justify-content: flex-start(靠主轴起点对齐)</h3>
    <p>水平主轴下,子元素靠容器左侧(起点)排列,剩余空间在右侧</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
    </div>
</body>
</html>

image.png

运行效果:3 个子元素从容器左侧(主轴起点)开始排列,右侧留下空白剩余空间。

案例 2:justify-content: flex-end(靠主轴终点对齐)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>justify-content: flex-end</title>
    <style>
        .flex-container {
            display: flex;
            justify-content: flex-end; /* 靠主轴终点对齐 */
            width: 400px;
            height: 120px;
            background-color: #eee;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 80px;
            height: 80px;
            margin: 0 5px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
    </style>
</head>
<body>
    <h3>justify-content: flex-end(靠主轴终点对齐)</h3>
    <p>水平主轴下,子元素靠容器右侧(终点)排列,剩余空间在左侧</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
    </div>
</body>
</html>

image.png

运行效果:3 个子元素从容器右侧(主轴终点)开始排列,左侧留下空白剩余空间。

案例 3:justify-content: center(主轴居中对齐)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>justify-content: center</title>
    <style>
        .flex-container {
            display: flex;
            justify-content: center; /* 主轴居中对齐 */
            width: 400px;
            height: 120px;
            background-color: #eee;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 80px;
            height: 80px;
            margin: 0 5px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
    </style>
</head>
<body>
    <h3>justify-content: center(主轴居中对齐)</h3>
    <p>水平主轴下,子元素在容器中间排列,剩余空间平均分布在左右两侧</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
    </div>
</body>
</html>

image.png

运行效果:3 个子元素整体在容器水平方向居中,左右两侧剩余空间相等。

案例 4:justify-content: space-between(两端对齐,中间等距)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>justify-content: space-between</title>
    <style>
        .flex-container {
            display: flex;
            justify-content: space-between; /* 两端对齐,中间间距相等 */
            width: 400px;
            height: 120px;
            background-color: #eee;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 80px;
            height: 80px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
    </style>
</head>
<body>
    <h3>justify-content: space-between(两端对齐,中间等距)</h3>
    <p>第一个子元素靠左侧,最后一个靠右侧,中间元素间距相等</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
    </div>
</body>
</html>

image.png

运行效果:第一个子元素贴左,最后一个子元素贴右,中间子元素之间的间距相等(无额外边距时)。

案例 5:justify-content: space-around(元素两侧间距相等)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>justify-content: space-around</title>
    <style>
        .flex-container {
            display: flex;
            justify-content: space-around; /* 每个元素两侧间距相等 */
            width: 400px;
            height: 120px;
            background-color: #eee;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 80px;
            height: 80px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
    </style>
</head>
<body>
    <h3>justify-content: space-around(元素两侧间距相等)</h3>
    <p>每个子元素左右两侧的间距相等,相邻元素间距是边缘间距的2倍</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
    </div>
</body>
</html>

image.png

运行效果:每个子元素左右两侧的间距相同,因此相邻元素之间的间距是最左侧 / 最右侧间距的 2 倍。

案例 6:justify-content: space-evenly(所有间距完全相等)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>justify-content: space-evenly</title>
    <style>
        .flex-container {
            display: flex;
            justify-content: space-evenly; /* 所有间距(包括边缘)完全相等 */
            width: 400px;
            height: 120px;
            background-color: #eee;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 80px;
            height: 80px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
    </style>
</head>
<body>
    <h3>justify-content: space-evenly(所有间距完全相等)</h3>
    <p>容器左侧到第一个元素、元素之间、最后一个元素到容器右侧的间距完全相同</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
    </div>
</body>
</html>

image.png

运行效果:容器左侧边缘到第一个子元素、子元素之间、最后一个子元素到容器右侧边缘的所有间距完全相等。

最后,容易混淆的就是下面的三个属性

  • space-between:强调 “两端贴边,中间等距”;
  • space-around:强调 “元素两侧间距相等”(边缘间距是中间的一半);
  • space-evenly:强调 “所有间距(包括边缘)完全相同”;

这里可以再理解一下

五、align-items

align-items 用于控制 Flex 容器内的子元素在交叉轴方向(与主轴垂直的轴)上的对齐方式。当主轴为水平方向(flex-direction: row)时,交叉轴是垂直方向;当主轴为垂直方向时,交叉轴是水平方向。以下案例以水平主轴为例,聚焦垂直方向的对齐效果。

  • stretch:默认拉伸填充(子元素无固定尺寸时);
  • flex-start/flex-end:靠交叉轴的起点 / 终点对齐;
  • center:交叉轴居中(最常用的垂直居中方案);
  • baseline:文字基线对齐(适合文本类布局);

案例 1:默认值 align-items: stretch(拉伸填满交叉轴)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>align-items: stretch</title>
    <style>
        .flex-container {
            display: flex;
            align-items: stretch; /* 默认值:拉伸填满交叉轴 */
            width: 400px;
            height: 180px; /* 容器高度大于子元素默认高度,方便观察拉伸效果 */
            background-color: #eee;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 80px;
            /* 不设置高度,让其自动拉伸 */
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px; /* 文字垂直居中参考线 */
            margin: 0 5px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
    </style>
</head>
<body>
    <h3>align-items: stretch(拉伸填满交叉轴)</h3>
    <p>子元素未设置高度时,会自动拉伸至与容器交叉轴高度一致(垂直方向填满)</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
    </div>
</body>
</html>

image.png

运行效果:子元素未设置高度时,会自动拉伸至与容器高度(交叉轴长度)一致,垂直方向填满容器。

案例 2:align-items: flex-start(靠交叉轴起点对齐)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>align-items: flex-start</title>
    <style>
        .flex-container {
            display: flex;
            align-items: flex-start; /* 靠交叉轴起点对齐 */
            width: 400px;
            height: 180px;
            background-color: #eee;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 80px;
            height: 80px; /* 固定高度,方便观察对齐效果 */
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
            margin: 0 5px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
    </style>
</head>
<body>
    <h3>align-items: flex-start(靠交叉轴起点对齐)</h3>
    <p>水平主轴下,交叉轴起点为容器顶部,子元素靠顶部对齐</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
    </div>
</body>
</html>

image.png

运行效果:子元素沿容器顶部(交叉轴起点)对齐,底部留下空白空间。

案例 3:align-items: flex-end(靠交叉轴终点对齐)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>align-items: flex-end</title>
    <style>
        .flex-container {
            display: flex;
            align-items: flex-end; /* 靠交叉轴终点对齐 */
            width: 400px;
            height: 180px;
            background-color: #eee;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 80px;
            height: 80px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
            margin: 0 5px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
    </style>
</head>
<body>
    <h3>align-items: flex-end(靠交叉轴终点对齐)</h3>
    <p>水平主轴下,交叉轴终点为容器底部,子元素靠底部对齐</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
    </div>
</body>
</html>

image.png

运行效果:子元素沿容器底部(交叉轴终点)对齐,顶部留下空白空间。

案例 4:align-items: center(交叉轴居中对齐)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>align-items: center</title>
    <style>
        .flex-container {
            display: flex;
            align-items: center; /* 交叉轴居中对齐 */
            width: 400px;
            height: 180px;
            background-color: #eee;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 80px;
            height: 80px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
            margin: 0 5px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
    </style>
</head>
<body>
    <h3>align-items: center(交叉轴居中对齐)</h3>
    <p>水平主轴下,子元素在容器垂直方向居中对齐,上下空白空间相等</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
    </div>
</body>
</html>

image.png

运行效果:子元素在容器垂直方向(交叉轴)居中,上下两侧的空白空间相等,是实现垂直居中的常用方案。

案例 5:align-items: baseline(基线对齐)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>align-items: baseline</title>
    <style>
        .flex-container {
            display: flex;
            align-items: baseline; /* 基线对齐 */
            width: 400px;
            height: 180px;
            background-color: #eee;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 80px;
            height: 80px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
            margin: 0 5px;
        }
        .item1 { background-color: #ff4444; font-size: 24px; } /* 不同字体大小,更易观察基线 */
        .item2 { background-color: #00c851; font-size: 16px; }
        .item3 { background-color: #33b5e5; font-size: 32px; }
    </style>
</head>
<body>
    <h3>align-items: baseline(基线对齐)</h3>
    <p>子元素的文字基线对齐(而非元素本身对齐),常用于文本类元素</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
    </div>
</body>
</html>

image.png运行效果:即使子元素字体大小不同,它们的文字基线(字母底部对齐的参考线)仍保持在同一水平线上,而非元素本身的边缘对齐。

这时可能有人要问了——煮波煮波,为什么主轴对齐方式有justify-content: space-between,交叉轴对齐方式却没有align-items:space-between呢?

兄弟,我们来看一下一个细节,这个属性是align-items,操作的主要是“行内”这个维度的元素对齐方式,既然是“行内”肯定是单行,再交差轴上只有一个元素,那么这一元素怎么可以“分裂”开呢?所以就不会出现align-items:space-between这种东西

但是当有多行的时候,我们就可以发现,还是需要一个space-between属性来控制整体的行对齐方式,这个维度就是“行”,而不是“行内”了,这个属性就是align-content

六、align-content

align-content 用于控制 Flex 容器内多行弹性项在交叉轴方向上的对齐方式和空间分配。它仅在以下两个条件同时满足时生效:

  1. 子元素发生换行(需设置 flex-wrap: wrap 或 wrap-reverse);
  2. 容器在交叉轴方向有剩余空间(如高度大于所有行总高度)。

以下案例均以水平主轴(flex-direction: row)为例,展示垂直方向(交叉轴)上多行元素的对齐效果。

  • align-content 与 justify-content 取值相似,但作用对象不同

    • justify-content 作用于单行子元素在主轴的对齐;
    • align-content 作用于多行子元素在交叉轴的对齐(必须配合 flex-wrap: wrap);
  • 与 align-items 的区别:

    • align-items 控制单个子元素在交叉轴的对齐;
    • align-content 控制多行整体在交叉轴的空间分配;

案例 1:默认值 align-content: stretch(拉伸填满交叉轴)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>align-content: stretch</title>
    <style>
        .flex-container {
            display: flex;
            flex-wrap: wrap; /* 允许换行,否则 align-content 无效 */
            align-content: stretch; /* 默认值:拉伸填满交叉轴 */
            width: 400px;
            height: 300px; /* 容器高度远大于子元素总高度,产生剩余空间 */
            background-color: #eee;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 100px;
            height: 80px;
            margin: 5px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
        .item4 { background-color: #aa66cc; }
        .item5 { background-color: #ffc107; }
    </style>
</head>
<body>
    <h3>align-content: stretch(拉伸填满交叉轴)</h3>
    <p>多行子元素会拉伸高度,平均分配容器剩余空间,填满整个交叉轴</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
        <div class="flex-item item4">4</div>
        <div class="flex-item item5">5</div>
    </div>
</body>
</html>

运行效果:子元素自动换行后形成两行,两行会拉伸高度以填满容器垂直方向的空间(原本 80px 高的子元素会变高)。

案例 2:align-content: flex-start(靠交叉轴起点对齐)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>align-content: flex-start</title>
    <style>
        .flex-container {
            display: flex;
            flex-wrap: wrap;
            align-content: flex-start; /* 靠交叉轴起点对齐 */
            width: 400px;
            height: 300px;
            background-color: #eee;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 100px;
            height: 80px;
            margin: 5px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
        .item4 { background-color: #aa66cc; }
        .item5 { background-color: #ffc107; }
    </style>
</head>
<body>
    <h3>align-content: flex-start(靠交叉轴起点对齐)</h3>
    <p>多行子元素整体靠容器顶部(交叉轴起点)排列,剩余空间在底部</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
        <div class="flex-item item4">4</div>
        <div class="flex-item item5">5</div>
    </div>
</body>
</html>

运行效果:两行子元素紧贴容器顶部排列,底部留下大片空白剩余空间。

案例 3:align-content: flex-end(靠交叉轴终点对齐)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>align-content: flex-end</title>
    <style>
        .flex-container {
            display: flex;
            flex-wrap: wrap;
            align-content: flex-end; /* 靠交叉轴终点对齐 */
            width: 400px;
            height: 300px;
            background-color: #eee;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 100px;
            height: 80px;
            margin: 5px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
        .item4 { background-color: #aa66cc; }
        .item5 { background-color: #ffc107; }
    </style>
</head>
<body>
    <h3>align-content: flex-end(靠交叉轴终点对齐)</h3>
    <p>多行子元素整体靠容器底部(交叉轴终点)排列,剩余空间在顶部</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
        <div class="flex-item item4">4</div>
        <div class="flex-item item5">5</div>
    </div>
</body>
</html>

运行效果:两行子元素紧贴容器底部排列,顶部留下大片空白剩余空间。

案例 4:align-content: center(交叉轴居中对齐)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>align-content: center</title>
    <style>
        .flex-container {
            display: flex;
            flex-wrap: wrap;
            align-content: center; /* 交叉轴居中对齐 */
            width: 400px;
            height: 300px;
            background-color: #eee;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 100px;
            height: 80px;
            margin: 5px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
        .item4 { background-color: #aa66cc; }
        .item5 { background-color: #ffc107; }
    </style>
</head>
<body>
    <h3>align-content: center(交叉轴居中对齐)</h3>
    <p>多行子元素整体在容器垂直方向居中,上下剩余空间相等</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
        <div class="flex-item item4">4</div>
        <div class="flex-item item5">5</div>
    </div>
</body>
</html>

运行效果:两行子元素整体在容器垂直方向居中,上下两侧的空白空间相等。

案例 5:align-content: space-between(两端对齐,中间等距)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>align-content: space-between</title>
    <style>
        .flex-container {
            display: flex;
            flex-wrap: wrap;
            align-content: space-between; /* 两端对齐,中间等距 */
            width: 400px;
            height: 300px;
            background-color: #eee;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 100px;
            height: 80px;
            margin: 5px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
        .item4 { background-color: #aa66cc; }
        .item5 { background-color: #ffc107; }
    </style>
</head>
<body>
    <h3>align-content: space-between(两端对齐,中间等距)</h3>
    <p>第一行靠顶部,最后一行靠底部,中间行间距相等</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
        <div class="flex-item item4">4</div>
        <div class="flex-item item5">5</div>
    </div>
</body>
</html>

运行效果:第一行贴容器顶部,最后一行贴容器底部,两行之间的间距自动分配剩余空间(间距相等)。

案例 6:align-content: space-around(行两侧间距相等)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>align-content: space-around</title>
    <style>
        .flex-container {
            display: flex;
            flex-wrap: wrap;
            align-content: space-around; /* 行两侧间距相等 */
            width: 400px;
            height: 300px;
            background-color: #eee;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .flex-item {
            width: 100px;
            height: 80px;
            margin: 5px;
            color: white;
            font-size: 20px;
            text-align: center;
            line-height: 80px;
        }
        .item1 { background-color: #ff4444; }
        .item2 { background-color: #00c851; }
        .item3 { background-color: #33b5e5; }
        .item4 { background-color: #aa66cc; }
        .item5 { background-color: #ffc107; }
    </style>
</head>
<body>
    <h3>align-content: space-around(行两侧间距相等)</h3>
    <p>每行上下两侧的间距相等,相邻行间距是边缘间距的2倍</p>
    <div class="flex-container">
        <div class="flex-item item1">1</div>
        <div class="flex-item item2">2</div>
        <div class="flex-item item3">3</div>
        <div class="flex-item item4">4</div>
        <div class="flex-item item5">5</div>
    </div>
</body>
</html>

运行效果:每行上下两侧的间距相同,因此相邻两行之间的间距是第一行顶部和最后一行底部间距的 2 倍。

下面我们来详细分析一下align-items和align-content

align-items和align-content是冲突的吗

我们可以发现,align-items和align-content都是控制元素在交叉轴排列,那么二者的属性是否冲突呢?

不冲突,这里有一个区别

align-items是控制的”行内“维度的,并不在”父容器“的维度上,而align-content是在父容器的维度上的。

为什么有的时候align-items效果很明显,有的时候很不明显?

当在交叉轴上只有一行元素的时候,align-items的”行内“,就成了”父容器“,这时的”align-items:start“和”align-items:end“就是一个在父容器的头,另一个在父容器的尾部,但是如果有多行,align-items:start“和”align-items:end“就变为了在”行内“的上下,效果就会不那么明显

和讲完了父容器上的属性,接下来我们要讲子容器上的属性了

我相信,对于父容器上的属性我们都耳熟能详,但是子容器上的属性就略显神秘了,所以这里,正是我们和其他面试者拉开差距的关键

在 Flex 布局中,子容器(弹性项,即 flex-item)相关的属性主要用于控制单个子元素在 Flex 容器内的自身尺寸、对齐方式等。我们分为三类来讲——尺寸控制、对齐、辅助属性

那么,开始吧

一、尺寸控制相关属性

1. flex-grow(扩展因子)

当 Flex 容器在主轴方向有剩余空间时,子元素按照 flex-grow 定义的比例分配剩余空间,实现拉伸效果。

数字(默认值为 0,表示不参与剩余空间分配;若为 1,则等比例分配剩余空间)。

这里给一个可视化的例子来看一下

image.png

2. flex-shrink(收缩因子)

当 Flex 容器在主轴方向空间不足时,子元素按照 flex-shrink 定义的比例收缩,避免溢出。

数字(默认值为 1,表示空间不足时会收缩;若为 0,则不收缩)。

image.png

3. flex-basis(基准尺寸)

定义子元素在主轴方向上的初始尺寸,优先级高于 width(或 height,取决于主轴方向)。

auto(默认,由内容或 width/height 决定)、具体长度(如 100px)等。

image.png

image.png

4. flex(复合属性)

是 flex-growflex-shrinkflex-basis 的简写,推荐使用以简化代码。

flex: <flex-grow> <flex-shrink> <flex-basis>,常见简写值有:

-   `flex: auto`:等价于 `flex: 1 1 auto`,会灵活伸缩。
-   `flex: none`:等价于 `flex: 0 0 auto`,既不扩展也不收缩。
-   `flex: 1`:等价于 `flex: 1 1 0%`,等比例分配剩余空间或收缩。

GIF.gif

二、对齐相关属性

1. align-self

覆盖 Flex 容器的 align-items 属性,单独设置单个子元素在交叉轴方向的对齐方式。

与 align-items 相同,如 flex-start(交叉轴起点对齐)、center(交叉轴居中)、flex-end(交叉轴终点对齐)、stretch(拉伸填满交叉轴,子元素不能设置固定交叉轴尺寸)等。

image.png

这里需要注意align-self 会覆盖 align-items 的设置

这种机制允许我们先通过 align-items 为大多数元素设置统一的对齐方式,再通过 align-self 为个别需要特殊处理的元素单独设置对齐方式。

三、其他辅助属性

1. order

控制子元素在 Flex 容器内的排列顺序,数值越小,排列越靠前。

整数(默认值为 0)。

这里我们可以给面试官讲三栏布局如何让中间的列先加载之类的,这里是加分项

这个其实很简单,就不做展示了

那么flex基础到这里就可以了,下面是两个常考的八股

解释一下flex:1,除了1还有什么其他的取值

这是一个考察几率很大的题,神奇的flex:1

在 CSS Flexbox 布局中,flex 是一个复合属性,用于设置 flex 项目(flex item)的伸缩能力,它是 flex-growflex-shrink 和 flex-basis 三个属性的简写形式。

flex: 1 等价于 flex: 1 1 0%,这里要介绍一下各部分含义

  • flex-grow: 1:项目在容器有剩余空间时,会按比例(与其他项目的 flex-grow 值相比)分配剩余空间,实现 “扩张”。
  • flex-shrink: 1:项目在容器空间不足时,会按比例(与其他项目的 flex-shrink 值相比)缩小,实现 “收缩”。
  • flex-basis: 0%:项目在分配空间前的初始尺寸基准为 0%(相对于父容器),实际尺寸主要由伸缩决定。

单值语法双值语法三值语法,这里可以快速过,可以和面试官讲下面的两个关键字

flex: none:等价于 flex: 0 0 auto(不伸缩,尺寸由内容或 width/height 决定)。

flex: auto:等价于 flex: 1 1 auto(优先按内容确定初始尺寸,再根据空间伸缩)。

这些取值的核心是通过调整 “扩张权重”“收缩权重” 和 “初始基准尺寸”,控制项目在 flex 容器中的空间分配方式。

实现中间先加载的三栏布局

如果碰到了这个题,什么双飞翼布局,圣杯布局,不要记了,flex直接暴力完成

/* 基础样式重置 */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    min-height: 100vh;
}

/* 容器使用flex布局 */
.container {
    display: flex;
    min-height: 100vh;
}

/* 中间内容区域 - HTML中优先定义 */
.main {
    flex: 1; /* 占据剩余所有空间 */
    order: 2; /* 视觉上显示在中间 */
    background-color: #f0f0f0;
    padding: 20px;
}

/* 左侧边栏 */
.left {
    width: 200px;
    order: 1; /* 视觉上显示在左侧 */
    background-color: #e0e0e0;
    padding: 20px;
}

/* 右侧边栏 */
.right {
    width: 200px;
    order: 3; /* 视觉上显示在右侧 */
    background-color: #e0e0e0;
    padding: 20px;
}
<div class="container">
    <!-- 中间内容区域 - 在HTML中先加载 -->
    <div class="main">
        <h2>主内容区域</h2>
        <p>此区域在HTML中优先定义,先加载</p>
    </div>


    <!-- 左侧边栏 - 后加载 -->
    <div class="left">
        <h3>左侧边栏</h3>
    </div>

    <!-- 右侧边栏 - 后加载 -->
    <div class="right">
        <h3>右侧边栏</h3>
    </div>
</div>

结语

这篇也是写的略显随意了,但是我觉得对复习和面试来说还是不错的

多语言采集京东商品评论,京东API(json数据返回)

2025年9月17日 17:52

多语言采集京东商品评论的API解决方案

一、京东官方API接口

京东开放平台提供商品评论API接口 jingdong.comments.product.query,支持获取商品评论详情、评分统计及用户信息,返回JSON格式数据。核心参数如下:

  • 必填参数app_key(应用ID)、secret_key(密钥)、sku_id(商品SKU)、page(页码)、page_size(每页数量,最大100)
  • 可选参数score(评分筛选,1-5)、sort_type(排序方式,1=时间倒序,2=点赞数降序)、lang(语言,支持中/英/西/阿等12种)
  • 返回字段:评论ID、内容、时间、评分、用户昵称、省份、会员等级,以及评论总数、好评率、差评率等统计指标。

二、多语言支持方案

  1. 语言参数配置
    在请求中添加 lang 参数,例如:

    python
    params = {
        "app_key": "YOUR_APP_KEY",
        "method": "jingdong.comments.product.query",
        "sku_id": "123456789",
        "page": 1,
        "page_size": 20,
        "lang": "en"  # 支持en/zh/es/ar等
    }
    

    返回数据将自动转换为指定语言版本,如英文商品标题、用户评论等。

  2. 混合翻译模式
    对未直接支持的小语种,可结合第三方翻译API(如阿里云翻译)对英文数据进行二次翻译,实现全语言覆盖。

三、Python代码示例

python
import requests
import hashlib
import time
 
# 配置参数
APP_KEY = "YOUR_APP_KEY"
SECRET_KEY = "YOUR_SECRET_KEY"
SKU_ID = "123456789"
LANG = "en"  # 多语言配置
 
# 生成签名
def generate_sign(params, secret_key):
    sorted_params = sorted(params.items())
    sign_str = f"{secret_key}{''.join([f'{k}{v}' for k, v in sorted_params])}{secret_key}"
    return hashlib.md5(sign_str.encode()).hexdigest().upper()
 
# 构造请求
url = "https://api.jd.com/routerjson"
params = {
    "app_key": APP_KEY,
    "method": "jingdong.comments.product.query",
    "sku_id": SKU_ID,
    "page": 1,
    "page_size": 10,
    "lang": LANG,
    "timestamp": int(time.time() * 1000)
}
params["sign"] = generate_sign(params, SECRET_KEY)
 
# 发送请求
response = requests.get(url, params=params)
data = response.json()
 
# 解析评论数据
if data.get("code") == 0:
    comments = data["comments"]
    for comment in comments:
        print({
            "id": comment["guid"],
            "content": comment["content"],
            "rating": comment["score"],
            "date": comment["creation_time"],
            "user": comment["user_nickname"]
        })
else:
    print(f"Error: {data.get('msg')}")

四、权限与认证

  1. 申请流程

    • 注册京东开放平台账号,创建应用并申请“商品评论数据权限”。
    • 审核通过后获取 app_key 和 secret_key,用于API调用和签名验证。
  2. 签名规则
    所有请求需生成MD5签名,确保数据安全性。签名规则为:MD5(secret_key + 参数拼接字符串 + secret_key)

五、数据清洗与存储

  • 缺失值处理:使用 pandas 或 SimpleImputer 填充评分、评论内容等字段。
  • 格式标准化:将时间戳转换为可读格式,统一评分类型为浮点数。
  • 存储方案:建议使用MySQL数据库存储,表结构包含商品ID、评论ID、内容、评分、语言、时间等字段。

六、合规与反爬策略

  • 频率限制:单日调用上限10万次,QPS默认50次/秒,需合理设置请求间隔。
  • 法律合规:遵守《个人信息保护法》和GDPR,仅采集公开评论数据,避免用户隐私泄露。
  • 反爬对抗:使用住宅代理IP池,随机化请求间隔(10-30秒),避免高频访问触发风控。

通过上述方案,可实现京东商品评论的多语言采集,输出结构化JSON数据,适用于竞品分析、用户反馈研究、本地化适配等场景。

函数封装实现Echarts多表渲染/叠加渲染

作者 Ticnix
2025年9月17日 17:45

前言

继上一篇文章通过拖拽实现Echart可视化渲染后,在多个表的情况下必定要渲染多个表,而且表的数据和横纵坐标指标不一样,那难道要每一个表都要写一段配置吗,然后我就开始找不同表的共同点,然后发现只要把Echarts官网上给的图表配置中的option封装成函数,这个函数用来返回option的值就好了,然后只需改变不同表的data,把这个data传给这个函数就能返回这个表的所有配置,就不用重复写配置啦

实现的效果

20250917_170023[00h00m00s-00h00m14s].gif

实现逻辑

省流版:就是把Echarts官网的代码复制下来把里面的option配置封装成函数,并传入自定义的data

图表渲染整体函数renderChart

  1. 根据传入的zoneId找到要渲染图表的 DOM 容器,如果没有DOM容器直接返回,下面的所有逻辑判断都不用参与

  2. 判断该容器中是否有实例,先销毁该容器中已有的 ECharts 实例(避免重复渲染)

  3. 根据Id(组件类型)的不同进行匹配:

    • 准备对应的数据
    • 调用相应的配置函数(柱状图 / 饼图 / 折线图)
  4. 初始化新的 ECharts 实例并绘制图表

核心就是:先清旧图 → 按类型准备数据和配置 → 画新图,实现了不同图表的动态切换渲染。

  const renderChart = (zoneId,Id) => {
    const componentId=Id//拿到要渲染的组件id用于下面的switch操作
    var chartDom = document.getElementById(`chart-${zoneId}`)//获取要渲染的区域
    
    var CheckChart = echarts.getInstanceByDom(chartDom);获取dom实例
    if (CheckChart) {
          CheckChart.dispose();
          
    }//检查是否已经有渲染的实例,如果有就销毁
    if (!chartDom) {
      console.error(`Canvas element with id chart-${zoneId} not found`)//看是否有区域能渲染
      return
    }else{
      var myChart = echarts.init(chartDom);//初始化,创建图表实例
      //注意这里一定要先销毁前面的已存在的实例再初始化
      //如果初始化再销毁会渲染不出来,因为刚建的实例已经被销毁了,不要互换顺序
      var option//定义图表配置
      
      //开始对组件进行匹配,匹配到哪一个就传哪一个的data
      switch(componentId) {
        case 1:
          var data1 = [120, 135, 150, 145, 160, 175, 180, 190, 205, 210, 220, 230];
          var xdata1 = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
          option = getChartOption(data1,xdata1);//向柱状图函数传值
          break;
        case 2:
          var data2 = [
            { value: 80, name: '人事部' },
            { value: 60, name: '财务部' },
            { value: 50, name: '行政部' },
            { value: 120, name: '市场部' },
            { value: 150, name: '销售部' },
            { value: 90, name: '产品部' },
            { value: 200, name: '研发部' },
            { value: 30, name: '法务部' }
          ]
          option = getPieOption(data2)/向扇形图函数传值
          break;
         case 3:
          var data5 =  [8, 12, 10, 15, 9, 11, 14, 7, 13, 10, 9, 12];
          var xdata5 =  ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
          option = getLineOption(data5,xdata5);/向折线图函数传值
          break;
      }
      myChart.resize();//调整图表尺寸
      option && myChart.setOption(option);画表
    }

  }
    }

图表类型函数

这里的函数其实就是传一个option,完整的代码看Echarts官网echarts.apache.org/examples/zh… 然后导入相关的包和用到的条件,然后中间就看到有option[],没错把这段复制出来放到你的函数return{}中间,如下,然后把对应的data属性,作为传入的参数,在上面case匹配完定义对应数值调用函数并传值,就会返回一个完整的option[],这样就能直接把对应想要的图渲染出来了

function getChartOption(data,xdata) {//柱状图
  return {
    xAxis: {
      type: 'category',
      data: xdata,//传入的数值这里只横坐标
    },
    yAxis: {
      type: 'value',
    },
    series: [
      {
        data: data,  // 使用传入的data
        type: 'bar',
        label: {
          show: true,
          position: 'top',
        }
      }
    ]
  };
}

其它的图也是这个逻辑。。。

//饼图
function getPieOption(data){
  return{
    tooltip: {
    trigger: 'item'
  },
  legend: {
    orient: 'vertical',
    left: 'left'
  },
  series: [
    {
      name: 'Access From',
      type: 'pie',
      radius: '50%',
      data: data,
      emphasis: {
        itemStyle: {
          shadowBlur: 10,
          shadowOffsetX: 0,
          shadowColor: 'rgba(0, 0, 0, 0.5)'
        }
      }
    }
  ]
  }
}
//折线图
function getLineOption(data,xdata){
  return{
      xAxis: {
    type: 'category',
    data: xdata
  },
  yAxis: {
    type: 'value'
  },
  series: [
    {
      data: data,
      type: 'line'
    }
  ]
  }
}

CSS特异性:如何精准控制样式而不失控?

2025年9月17日 17:23

CSS特异性(Specificity)是前端开发中一个关键但常被忽视的概念。理解它不仅能解决样式冲突问题,还能写出更优雅、更易维护的代码。让我们一起来探索这个看似简单实则精妙的概念。

什么是CSS特异性?

想象一下这样的场景:你给一个元素设置了颜色为蓝色,但页面上显示却是红色。检查后发现另一条CSS规则覆盖了你的样式。这就是特异性在起作用——它决定了浏览器在冲突时应用哪条CSS规则。

CSS特异性是一套计算规则,用于确定当多个CSS规则同时指向同一个元素时,哪条规则将最终生效。

特异性计算规则

特异性权重体系

特异性由四个级别组成,从左到右权重依次降低:

  1. 内联样式(权重值:1000)
  2. ID选择器(权重值:100)
  3. 类选择器、属性选择器和伪类(权重值:10)
  4. 元素选择器和伪元素(权重值:1)

值得注意的是,通用选择器(*)、组合器(如+, >, ~)和否定伪类(:not())不影响特异性值,但:not()内部的选择器会计入特异性。

计算示例

让我们通过一些例子来理解特异性计算:

/* 特异性:0,0,1,0 - 总分10 */
.button { color: blue; }

/* 特异性:0,1,0,0 - 总分100 */
#submit-btn { color: red; }

/* 特异性:0,0,2,1 - 总分21 */
form .button:hover { color: green; }

/* 特异性:0,1,1,1 - 总分111 */
div#header .logo { color: orange; }

当这些规则应用于同一个元素时,浏览器会计算每条规则的特异性总分,然后应用得分最高的规则。

可视化特异性计算

为了更直观地理解,我们可以将特异性表示为四个数字的组合:(a,b,c,d)

  • a:内联样式的数量
  • b:ID选择器的数量
  • c:类、属性和伪类选择器的数量
  • d:元素和伪元素选择器的数量

例如:

  • div#main .content a:hover → (0,1,2,2)
  • #navbar li.item.active → (0,1,2,1)

特异性冲突解决

当两条规则具有相同的特异性时,后定义的规则优先(就近原则)。但请注意:特异性优先于顺序——高特异性的规则即使定义在前也会覆盖低特异性的规则。

<style>
  /* 特异性:0,0,1,0 */
  .text { color: blue; }
  
  /* 特异性:0,0,0,1 */
  p { color: red; } /* 这个规则后定义,但.text仍然优先 */
</style>

<p class="text">这个段落将显示蓝色</p>

优化策略:编写适度具体的选择器

1. 优先使用低特异性选择器

高特异性选择器会导致后续难以覆盖,形成"特异性战争"。尽量保持选择器简洁且特异性低。

/* 不推荐 - 特异性过高 */
div#main-content .article-list li.item a { ... }

/* 推荐 - 特异性适中 */
.article-list .item-link { ... }

2. 遵循"最少权力原则"

选择器应该只具有足够应用所需样式的最小权力(特异性)。这样更容易覆盖和维护。

3. 避免使用ID选择器

ID选择器具有高特异性(100),难以覆盖,应尽量避免在CSS中使用。

/* 不推荐 */
#header { ... }
#sidebar { ... }

/* 推荐 */
.header { ... }
.sidebar { ... }

4. 谨慎使用!important

!important会打破正常的特异性规则,应该极其谨慎地使用。它通常只是掩盖了更深层次的问题(特异性过高)。

/* 尽量避免 */
.error-message {
  color: red !important;
}

/* 更好的解决方案 */
.error-message {
  color: red;
}

如果必须使用!important,可以考虑添加注释说明原因:

/* !required to override third-party library styles */
.button {
  border: 2px solid blue !important;
}

5. 使用CSS自定义属性(变量)

CSS变量可以帮助减少对高特异性选择器的需求:

:root {
  --primary-color: #3498db;
  --secondary-color: #2ecc71;
}

.button {
  background-color: var(--primary-color);
}

6. 采用BEM等命名方法论

BEM(Block Element Modifier)等命名约定可以帮助保持低特异性:

/* 传统方式 */
.article .header .title { ... }

/* BEM方式 */
.article__header--title { ... }

BEM通过类名传达结构关系,避免了多层嵌套选择器的高特异性问题。

特异性管理实践

1. 审查和重构高特异性代码

定期检查代码中的高特异性选择器:

/* 重构前 - 特异性:0,2,1,2 */
div#main div#content .article p { ... }

/* 重构后 - 特异性:0,0,1,0 */
.article-content { ... }

2. 利用CSS预处理器的嵌套功能

使用Sass或Less时,谨慎使用嵌套功能,避免生成高特异性选择器:

// 不推荐 - 生成高特异性选择器
#main {
  .content {
    .article {
      // ...
    }
  }
}

// 推荐 - 保持低特异性
.article {
  &-content {
    // ...
  }
}

调试特异性问题

当遇到样式不按预期工作时,可以:

  1. 使用浏览器开发者工具检查应用的样式
  2. 查看哪些规则被覆盖以及原因
  3. 注意特异性计算值

现代浏览器开发者工具通常会明确显示为什么某条规则被覆盖,包括特异性比较。

个人见解

在我多年的前端开发经验中,CSS特异性管理是大型项目中最常被低估的挑战之一。许多开发者倾向于通过增加特异性(或使用!important)快速解决问题,但这只会导致长期维护困难。

特异性不是要击败的敌人,而是要驾驭的工具。合理利用特异性可以创建出既灵活又稳定的CSS架构。

我的建议是:

  • 建立团队特异性规范
  • 定期进行CSS代码审查
  • 使用工具(如CSS统计工具)监控特异性增长
  • 优先考虑可维护性而非编写速度

这次来点狠的:用 Vue 3 把 AI 的“碎片 Markdown”渲染得又快又稳(Monaco 实时更新 + Mermaid 渐进绘图)

作者 Simon_He
2025年9月17日 17:06

在做 AI 应用或协同编辑时,你八成遇到过这些痛点:

  • 模型在“打字”,Markdown 半截一坨,渲染器直接崩溃
  • 代码块动不动几千行,实时更新卡得怀疑人生
  • Mermaid 图不合法就整块失效,用户看到一片空白
  • 内容一边流一边更新,DOM 抖动、滚动乱跳

我做了一个专门为“流式 Markdown”场景优化的组件库——vue-renderer-markdown,它能把这些“坏情况”处理得优雅、顺滑、还很快。

为什么它不一样

  • 为流式而生:兼容“半截 Markdown/半截代码块/半截 Mermaid”的中间态,边流边稳
  • 高性能 Monaco:大块代码的增量更新,不卡 UI,编辑/预览都丝滑
  • 渐进式 Mermaid:一旦语法“刚好可解析”,先画出来;后续流入再迭代完善,避免整块空白
  • 完整 Markdown 能力:表格、公式、emoji、复选框、代码块……该有的都有
  • 实时更新友好:尽量少的重排重绘,几乎没有 DOM 抖动
  • TypeScript First:完备类型定义 + 智能提示
  • 零配置引入:开箱即用,Vue 3 项目即插即用
  • 数学公式极速渲染(KaTeX):支持行内/块级公式,流式输入也能稳健增量更新;半截公式不“炸屏”,后续补齐自动完善显示

一分钟上手

安装

可任选你的包管理工具(以下是可选命令,仅供参考):

# 推荐
pnpm add vue-renderer-markdown

# 同时安装常见 peer 依赖(按需取舍)
pnpm add vue @iconify/vue @vueuse/core katex mermaid vue-use-monaco
# npm
npm i vue-renderer-markdown vue @iconify/vue @vueuse/core katex mermaid vue-use-monaco
# yarn
yarn add vue-renderer-markdown vue @iconify/vue @vueuse/core katex mermaid vue-use-monaco

基础用法:就是一个普通的 Vue 组件

<script setup lang="ts">
import MarkdownRender from 'vue-renderer-markdown'

const markdownContent = `
# Hello Vue Markdown

这是一段**Markdown**,支持:
- 列表
- [x] 复选框
- :tada: Emoji
`
</script>

<template>
  <MarkdownRender :content="markdownContent" />
</template>

流式渲染(AI 输出、实时更新场景)

<script setup lang="ts">
import { ref } from 'vue'
import MarkdownRender from 'vue-renderer-markdown'

const content = ref('')
const full = `# 流式内容\n\n这段文字会“逐字”出现…`

let i = 0
const timer = setInterval(() => {
  if (i < full.length) {
    content.value += full[i++]
  } else {
    clearInterval(timer)
  }
}, 40)
</script>

<template>
  <MarkdownRender :content="content" />
</template>

实战 1:Mermaid 渐进式绘图(边流边画)

Mermaid 的常见问题是“语法半截 -> 图形全挂”。这里我们做了渐进式解析:一旦内容达到“可画”的下限,就先画出来;后续流入再增量更新。

<script setup lang="ts">
import { ref } from 'vue'
import MarkdownRender from 'vue-renderer-markdown'

const content = ref('')
const steps = [
  '```mermaid\n',
  'graph TD\n',
  'A[Start]-->B{Valid?}\n',
  'B -- Yes --> C[Render]\n',
  'B -- No  --> D[Wait]\n',
  '```\n',
]

let i = 0
const id = setInterval(() => {
  content.value += steps[i] || ''
  i++
  if (i >= steps.length) clearInterval(id)
}, 120)
</script>

<template>
  <MarkdownRender :content="content" />
</template>

实战 2:Monaco 大代码块的“丝滑流更”

大代码块每次整体重渲染是灾难。vue-renderer-markdown 针对这类场景做了“增量更新 + 合理切分”,在内容不断流入时保持 UI 流畅。

Vite 项目推荐配合 vite-plugin-monaco-editor-esm,打包 Worker 更稳(Windows 尤其):

// vite.config.ts
import path from 'node:path'
import monacoEditorPlugin from 'vite-plugin-monaco-editor-esm'

export default {
  plugins: [
    monacoEditorPlugin({
      languageWorkers: [
        'editorWorkerService',
        'typescript',
        'css',
        'html',
        'json',
      ],
      customDistPath(root, outDir, base) {
        return path.resolve(outDir, 'monacoeditorwork')
      },
    }),
  ],
}

然后像上面一样用 <MarkdownRender :content="..."> 渲染带代码块的 Markdown;当内容流式追加时,你会发现 Monaco 的高亮和结构更新不会阻塞主线程。

实战 3:数学公式极速渲染(KaTeX)

KaTeX 渲染快、稳定,非常适合 AI 场景的“边流边出”。本库内置对数学节点的处理,配好 peer 依赖即可用。

  • 依赖提示:请在你的项目中安装 katex(本库会处理 KaTeX CSS 的加载;如需自定义主题样式,可自行覆盖)。

基础用法(行内 + 块级)

<script setup lang="ts">
import MarkdownRender from 'vue-renderer-markdown'

const markdownContent = `
这是行内公式:$E = mc^2$

这是块级公式:
$$
\\int_{-\\infty}^{\\infty} e^{-x^2} \\, dx = \\sqrt{\\pi}
$$
`
</script>

<template>
  <MarkdownRender :content="markdownContent" />
</template>

流式渲染(逐步补齐的公式)

当 AI 一段段吐词时,公式也可以渐进显示:一旦达到可解析的下限就先展示,后续补齐再增量更新,不会整块空白或抖动。

<script setup lang="ts">
import { ref } from 'vue'
import MarkdownRender from 'vue-renderer-markdown'

const content = ref('')
const steps = [
  '$$\\n',
  '\\\\sum_{i=1}^n i = ',
  '\\\\frac{n(n+1)}{2}',
  '\\n$$\\n',
]

let i = 0
const id = setInterval(() => {
  content.value += steps[i] || ''
  i++
  if (i >= steps.length) clearInterval(id)
}, 120)
</script>

<template>
  <MarkdownRender :content="content" />
</template>

它为什么快?

  • 增量解析:不是“全量重跑解析器”,而是只处理变动的片段
  • DOM 最小化更新:节点级别的精准更新,避免重排/重绘风暴
  • 动画帧调度:把昂贵任务放进 requestAnimationFrame,滚动/输入更顺滑
  • 内存优化:长时间流式渲染也不“越跑越重”,自动清理废弃状态
  • 容错/降级:半截语法、异常 token、临时不合法内容都能稳稳接住
  • 数学节点增量更新:内容未完整时先稳态呈现,补齐后迅速完善,不阻塞其他内容渲染

适合哪些场景?

  • AI Chat / Copilot 类应用:模型边说边写、Markdown 边渲染
  • 文档/博客编辑器的实时预览:对大代码块/长文档友好
  • 数据/日志可视化:Mermaid/表格/高亮混合流式输出
  • 协同编辑:多人同时修改,内容频繁变动

API 简要

  • 组件:MarkdownRender

  • 关键 Props

    • content: string(渲染 Markdown 字符串)
    • nodes: BaseNode[](也可传 AST 节点)
    • customComponents: Record<string, any>(在 Markdown 中渲染自定义组件)
  • 模板中使用时注意驼峰转短横线,例如 customComponents => custom-components

生态与依赖

  • 同时安装必要的 peer 依赖(根据你启用的功能按需选择),例如:vue、@iconify/vue、@vueuse/core、katex、mermaid、vue-use-monaco
  • Monaco 仅在需要编辑器/大代码预览时安装
  • Mermaid 仅在需要绘图时安装

在线体验 & 下一步

如果你正在做 AI 应用、云 IDE、或任何需要“边流边渲染”的工具,这个库会让你少掉很多不必要的性能坑。欢迎 Star、试用、提 Issue/PR,一起把“流式 Markdown 渲染”这件小事做到极致。

Vue Router 路由懒加载引发的生产页面白屏问题

作者 FarmerLiusun
2025年9月17日 17:05

使用Vue Router 路由懒加载引发的生产页面白屏问题

1.项目技术栈

项目使用vue3 + Vue Router + ant-design-vue,构建工具使用 vite.

  • 框架: vue3
  • 组件库: andt-design-vue
  • 路由: Vue Router
  • 构建工具: vite

2.问题现象

  • 1.本地开发阶段,一切正常没有任何问题。
  • 2.通过nginx部署到开发环境后,访问该前端,其他所有功能均正常无任何问题。
  • 3.当访问/deviceMgmt/deviceLogQuery页面时,页面白屏,控制台无任何报错。

3.问题排查

刚看到这个问题还是挺懵的,自己本地开发环境没有问题,一部署到开发环境就出问题,而且还是没有任何报错、警告、提示。根据上面的现象,初步想到以下几个可能造成该问题的原因:

3.1 路径问题

  • 前端访问的路径写错了
  • 路由配置的路径跟组件的路径写错了

但是通过仔细对比发现,路由配置路径跟组件路径都没有任何问题,故排除了路径问题

3.1 路由配置问题

查看页面元素发现,访问/deviceMgmt/deviceLogQuery路径时,app容器内的组件为空。 并且之后发现之前部署的上一个前端版本是没有问题的,但是只要换成现在重新编译的版本就有这个问题。

通过git版本对比发现,原来是路由懒加载的配置写错了造成的

   // router>index.js
    const routes = [
        {
          name: 'deviceLogQuery',
          path: '/deviceMgmt/deviceLogQuery',
          //✅ 正确写法
          // component: () => import('../views/deviceLogQuery/test.vue'),
          // ❌ 错误写法
          component: import('../views/deviceLogQuery/index.vue'),
        }
    ]

路由配置中如果使用路由懒加载,呢组件的导入配置,component 是通过() => import(./MyPage.vue) 的方式导入。 这里少了 () => ,直接导入了😥。

4.问题解决

  • 通过git版本对比发现,原来是路由懒加载的配置写错了 image.png

  • 并且仔细查看控制台发现原来Vue Router 已经报警提示了:但是只在第一次加载才会报警告😰(自己没仔细看)

    image.png

  • 修改配置,重新打包部署,问题解决。

5.总结

  • Vue Router 路由懒加载引发的生产页面白屏问题: 是由于路由懒加载的配置写错造成的
  • 写代码的过程: 需要更加细致,原本是一个很小的问题,就是不够仔细才发生
  • git的重要性: 一定要多提交代码,出现问题时才能更好的溯源
  • 给Vue Router的建议: 建议直接将警告改成报错,并且增加提示
❌
❌