普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月15日技术

flex 0 flex 1 flex none flex auto 应该在什么场景下使用

作者 喵爱吃鱼
2025年12月15日 13:49

1. flex: 0 (等价于 flex: 0 1 0%)

.item {
  flex-grow: 0;      /* 不扩展 */
  flex-shrink: 1;    /* 可以收缩 */
  flex-basis: 0%;    /* 基础尺寸为 0 */
}

2. flex: 1 (等价于 flex: 1 1 0%)

.item {
  flex-grow: 1;      /* 扩展填满剩余空间 */
  flex-shrink: 1;    /* 可以收缩 */
  flex-basis: 0%;    /* 基础尺寸为 0,完全由 grow 决定 */
}

3. flex: none (等价于 flex: 0 0 auto)

.item {
  flex-grow: 0;      /* 不扩展 */
  flex-shrink: 0;    /* 不收缩 */
  flex-basis: auto;  /* 基于内容的固有尺寸 */
}

4. flex: auto (等价于 flex: 1 1 auto)

.item {
  flex-grow: 1;      /* 扩展填满剩余空间 */
  flex-shrink: 1;    /* 可以收缩 */
  flex-basis: auto;  /* 基于内容的固有尺寸 */
}

选择指南

属性 何时使用 典型场景
flex: 0 元素有固定尺寸,不占额外空间 按钮、图标、固定宽度组件
flex: 1 需要占满剩余空间 主内容区、等分布局
flex: none 保持原始尺寸,不被压缩 Logo、重要按钮、图片
flex: auto 内容驱动的弹性布局 标签页、表单控件、响应式组件

记忆技巧:

  • flex: 0 - "我不要额外空间"
  • flex: 1 - "给我所有剩余空间"
  • flex: none - "保持我的原样"
  • flex: auto - "我很灵活,看情况调整"

flex: 0 vs flex: none 的核心区别

关键差异:flex-shrink(收缩能力)

/* flex: 0 */
flex-grow: 0;      /* 不扩展 ✓ 相同 */
flex-shrink: 1;    /* 可以收缩 ⚠️ 关键区别 */
flex-basis: 0%;    /* 基础尺寸为 0 */

/* flex: none */
flex-grow: 0;      /* 不扩展 ✓ 相同 */
flex-shrink: 0;    /* 不能收缩 ⚠️ 关键区别 */
flex-basis: auto;  /* 基于内容尺寸 */

记忆技巧

决策流程图

需要固定尺寸的元素?
    ↓ 是
容器空间不足时,这个元素可以被压缩吗?
    ↓ 可以                    ↓ 不可以
  flex: 0                  flex: none
(适应性固定)              (刚性固定)

简单记忆法

  • flex: 0 = "我是固定的,但可以妥协"(可收缩)
  • flex: none = "我是固定的,绝不妥协"(不收缩)

实际应用

当你不确定用哪个时,问自己:

  1. 如果容器空间不够,这个元素可以被压缩吗?
  2. 这个元素的尺寸是否绝对不能改变?

如果答案是"可以压缩" → 用 flex: 0

如果答案是"绝不能变" → 用 flex: none

flex: 1 vs flex: auto 的核心区别

关键差异:flex-basis(初始尺寸计算)

/* flex: 1 */
flex-grow: 1;      /* 扩展 ✓ 相同 */
flex-shrink: 1;    /* 收缩 ✓ 相同 */
flex-basis: 0%;    /* 忽略内容尺寸 ⚠️ 关键区别 */

/* flex: auto */
flex-grow: 1;      /* 扩展 ✓ 相同 */
flex-shrink: 1;    /* 收缩 ✓ 相同 */
flex-basis: auto;  /* 基于内容尺寸 ⚠️ 关键区别 */

实际效果对比

场景1:不同长度的内容

// 容器宽度:600px
<Flex style={{width: '600px', border: '1px solid red'}}>
  <div style={{flex: 1, background: 'lightblue', textAlign: 'center'}}>
    短
  </div>
  <div style={{flex: 1, background: 'lightgreen', textAlign: 'center'}}>
    这是一段比较长的文本内容
  </div>
  <div style={{flex: 1, background: 'lightyellow', textAlign: 'center'}}>
    中等长度
  </div>
</Flex>

flex: 1 的结果:

  • 每个元素都是 200px 宽(600px ÷ 3)
  • 完全忽略内容长度,强制等分
<Flex style={{width: '600px', border: '1px solid red'}}>
  <div style={{flex: 'auto', background: 'lightblue', textAlign: 'center'}}>
    短
  </div>
  <div style={{flex: 'auto', background: 'lightgreen', textAlign: 'center'}}>
    这是一段比较长的文本内容
  </div>
  <div style={{flex: 'auto', background: 'lightyellow', textAlign: 'center'}}>
    中等长度
  </div>
</Flex>

flex: auto 的结果:

  • 长文本元素:约 280px(基础尺寸更大)
  • 短文本元素:约 120px(基础尺寸更小)
  • 中等文本元素:约 200px
  • 先考虑内容尺寸,再分配剩余空间

场景2:混合内容类型

<Flex style={{width: '500px'}} gap={16}>
  {/* 按钮 */}
  <Button style={{flex: 1}}>确定</Button>
  <Button style={{flex: 1}}>取消</Button>
  <Button style={{flex: 1}}>重置数据</Button>
</Flex>

flex: 1 的效果:

  • 三个按钮完全等宽
  • "重置数据"按钮内的文字可能显得很松散
<Flex style={{width: '500px'}} gap={16}>
  {/* 按钮 */}
  <Button style={{flex: 'auto'}}>确定</Button>
  <Button style={{flex: 'auto'}}>取消</Button>
  <Button style={{flex: 'auto'}}>重置数据</Button>
</Flex>

flex: auto 的效果:

  • "重置数据"按钮会稍微宽一些(因为文字更长)
  • 每个按钮的宽度更符合内容需求

记忆技巧

核心原则:需要视觉统一用 flex: 1,需要内容自然用 flex: auto

决策流程

需要元素填满剩余空间?
    ↓ 是
希望所有元素完全等分吗?
    ↓ 是                    ↓ 否
  flex: 1                 flex: auto
(强制等分)              (内容驱动)

简单记忆法

  • flex: 1 = "我要平均分配,不管内容多少"(忽略内容)
  • flex: auto = "我要分配空间,但要考虑我的内容"(考虑内容)

视觉比喻

想象分蛋糕:

  • flex: 1:不管谁的胃口大小,每人分到完全相同的一块
  • flex: auto:先看每人的基本需求,然后把剩余的平分

在实际开发中,90% 的"占满剩余空间"需求都用 flex: 1的原因

  • 避免了内容的干扰

  • 在不同屏幕尺寸下表现一致

  • flex: 1 计算更高效:

    • 跳过内容尺寸计算步骤
    • 减少重排(reflow)的可能性
    • 在复杂布局中性能更好
/* flex: 1 的计算 */
flex-basis: 0%;  /* 跳过内容尺寸计算 */
/* 直接计算:剩余空间 ÷ flex-grow 总和 */

/* flex: auto 的计算 */
flex-basis: auto;  /* 需要先计算内容尺寸 */
/* 然后计算:(剩余空间 - 内容尺寸) ÷ flex-grow 总和 + 内容尺寸 */

前端字符串排序搜索可以更加细化了

作者 CC码码
2025年12月15日 13:42

大家好,我是CC,在这里欢迎大家的到来~

开场

书接上文,Intl 下的 Segmenter 对象可以实现对文本的分割,除此之外,还有对字符串比较、数字格式化、日期格式化等其他功能。

这篇文章先来看看字符串比较,现在来理论加实践一下。

字符串比较

Intl.Collator用于语言敏感的字符串比较。

比较

基于 Collator 对象的排序规则进行比较。第一个字符串出现在第二个字符串之前则为负值,否则为正则,相等时则返回 0。

console.log(new Intl.Collator().compare("a", "c")); // -1
console.log(new Intl.Collator().compare("c", "a")); // 1
console.log(new Intl.Collator().compare("a", "a")); // 0

基于语言比较

// 德语中,ä 使用 a 的排序
console.log(new Intl.Collator("de").compare("ä", "z"));
// -1

// 在瑞典语中,ä 排在 z 之后
console.log(new Intl.Collator("sv").compare("ä", "z"));
// 1

配置项

  • localeMatcher
    • 使用的区域匹配算法,可选的值包括:
    • 默认值为best fit-使用浏览器最佳匹配算法,还有lookup-使用 BCP 47 规范的标准查找
const testStrings = ['苹果', '香蕉', '橙子'];

// lookup:使用 BCP 47 规范的标准查找
const lookupCollator = new Intl.Collator('zh', {
  localeMatcher: 'lookup'
});

// best fit:使用浏览器的最佳匹配算法(默认)
const bestFitCollator = new Intl.Collator('zh', {
  localeMatcher: 'best fit'
});

console.log(testStrings.sort(lookupCollator.compare));
console.log(testStrings.sort(bestFitCollator.compare));

// ["橙子","苹果","香蕉"]
// ["橙子","苹果","香蕉"]
  • usage
    • 是用于排序还是用于搜索匹配的字符串,可选的值包括:
    • 默认值是 sort,还有 search
const words = ['数据', '数据库', '数学', '数字', '数值'];

// 用于排序的 Collator
const sortCollator = new Intl.Collator('zh-CN', {
  usage: 'sort',
  sensitivity: 'variant'
});

// 用于搜索的 Collator
const searchCollator = new Intl.Collator('zh-CN', {
  usage: 'search',
  sensitivity: 'base'  // 搜索时更宽松
});

console.log('排序结果:', words.sort(sortCollator.compare));
// ["数据", "数据库", "数值", "数字", "数学"]

const searchTerm = '数';
const searchResults = words.filter(word => 
  searchCollator.compare(word.slice(0, searchTerm.length), searchTerm) === 0
);
console.log(`搜索"${searchTerm}"的结果:`, searchResults);
// ["数据", "数据库", "数学", "数字", "数值"]
  • sensitivity
    • 字符串中哪些差异应导致结果值为非零,可能的值包括:
    • base: 只有字母不同的字符串比较时不相等,像 a ≠ b、a = á、a = A。
    • accent: 只有不同的基本字母或重音符号和其他变音符号的字符串比较时不相等,例如:a ≠ b、a ≠ á、a = A。
    • case: 只有不同的基本字母或大小写的字符串比较时不相等,例如:a ≠ b、a = á、a ≠ A。
    • variant: 字符串的字母、重音和其他变音富豪,或不同大小写比较不相等,例如:a ≠ b、a ≠ á、a ≠ A。
    • usage 是 sort 时默认值是 variant,search 时默认值取决于区域。
const pinyinExamples = [
  ['mā', 'ma'],     // 声调差异
  ['lǜ', 'lu'],     // 特殊字符差异
  ['zhōng', 'zhong'] // 音调符号差异
];

const pinyinAccentCollator = new Intl.Collator('zh-CN', {
  sensitivity: 'accent',  // 忽略声调差异
  usage: 'search'
});

pinyinExamples.forEach(([a, b]) => {
  const result = pinyinAccentCollator.compare(a, b);
  console.log(`"${a}" vs "${b}": ${result === 0 ? '匹配' : '不匹配'}`);
});
// "mā" vs "ma": 不匹配
// "lǜ" vs "lu": 不匹配
// "zhōng" vs "zhong": 不匹配

const pinyinBaseCollator = new Intl.Collator('zh-CN', {
  sensitivity: 'base',  // 忽略声调差异
  usage: 'search'
});
pinyinExamples.forEach(([a, b]) => {
  const result = pinyinBaseCollator.compare(a, b);
  console.log(`"${a}" vs "${b}": ${result === 0 ? '匹配' : '不匹配'}`);
});
// "mā" vs "ma": 匹配
// "lǜ" vs "lu": 匹配
// "zhōng" vs "zhong": 匹配

const pinyinCaseCollator = new Intl.Collator('zh-CN', {
  sensitivity: 'case',  // 忽略声调差异
  usage: 'search'
});
pinyinExamples.forEach(([a, b]) => {
  const result = pinyinCaseCollator.compare(a, b);
  console.log(`"${a}" vs "${b}": ${result === 0 ? '匹配' : '不匹配'}`);
});
// "mā" vs "ma": 匹配
// "lǜ" vs "lu": 匹配
// "zhōng" vs "zhong": 匹配

const pinyinVariantCollator = new Intl.Collator('zh-CN', {
  sensitivity: 'variant',  // 忽略声调差异
  usage: 'search'
});
pinyinExamples.forEach(([a, b]) => {
  const result = pinyinVariantCollator.compare(a, b);
  console.log(`"${a}" vs "${b}": ${result === 0 ? '匹配' : '不匹配'}`);
});
// "mā" vs "ma": 不匹配
// "lǜ" vs "lu": 不匹配
// "zhōng" vs "zhong": 不匹配
  • ignorePunctuation
    • 是否忽略标点
    • 默认是 false
const texts = [
  '你好,世界!',
  '你好世界',
  '你好-世界',
  '你好。世界',
  '你好——世界'
];

const withPunctuation = new Intl.Collator('zh-CN', {
  ignorePunctuation: false  // 不忽略标点(默认)
});
const withoutPunctuation = new Intl.Collator('zh-CN', {
  ignorePunctuation: true   // 忽略标点
});

console.log(texts.sort(withPunctuation.compare));
console.log(texts.sort(withoutPunctuation.compare));
// [
//     "你好-世界",
//     "你好——世界",
//     "你好,世界!",
//     "你好。世界",
//     "你好世界"
// ]
// [
//     "你好,世界!",
//     "你好世界",
//     "你好-世界",
//     "你好。世界",
//     "你好——世界"
// ]
  • numeric
    • 是否使用数字对照,使得“1”<“2”<“10”
    • 默认是 false
    • 同 locales 的 Unicode 扩展键 kn 设置,但优先级高于他
const items = [
  '第1章 引言',
  '第10章 总结',
  '第2章 正文',
  '第11章 附录',
  '第20章 参考文献'
];
// 普通排序(字符串方式)
const regularCollator = new Intl.Collator('zh-CN', {
  numeric: false  // 默认
});

// 数字感知排序
const numericCollator = new Intl.Collator('zh-CN', {
  numeric: true
});

console.log(items.slice().sort(regularCollator.compare));
console.log(items.slice().sort(numericCollator.compare));
// [
//     "第10章 总结",
//     "第11章 附录",
//     "第1章 引言",
//     "第20章 参考文献",
//     "第2章 正文"
// ]
// [
//     "第1章 引言",
//     "第2章 正文",
//     "第10章 总结",
//     "第11章 附录",
//     "第20章 参考文献"
// ]
  • caseFirst
    • 是否首先根据大小写排序,可选的值包括:
    • upper
    • lower
    • false
    • 同 locales 的 Unicode 扩展键 kf 设置,但优先级高于他
const mixedList = [
  'Apple',
  'apple',
  'Banana',
  'banana',
  '中文',
  'China',
  'china',
  '苹果'
];
// 大写优先
const upperFirst = new Intl.Collator('zh-CN', {
  caseFirst: 'upper',
  sensitivity: 'case'  // 需要区分大小写
});

console.log(mixedList.slice().sort(upperFirst.compare));
// ["Apple", "Banana", "China", "apple", "banana", "china", "中文", "苹果"]
// [
//     "苹果",
//     "中文",
//     "Apple",
//     "apple",
//     "Banana",
//     "banana",
//     "China",
//     "china"
// ]

// 小写优先
const lowerFirst = new Intl.Collator('zh-CN', {
  caseFirst: 'lower',
  sensitivity: 'case'
});

console.log(mixedList.slice().sort(lowerFirst.compare));
// ["apple", "banana", "china", "Apple", "Banana", "China", "中文", "苹果"]
// [
//     "苹果",
//     "中文",
//     "apple",
//     "Apple",
//     "banana",
//     "Banana",
//     "china",
//     "China"
// ]

// 不优先(默认)
const noCaseFirst = new Intl.Collator('zh-CN', {
  caseFirst: false,
  sensitivity: 'case'
});

console.log(mixedList.slice().sort(noCaseFirst.compare));
// [
//     "苹果",
//     "中文",
//     "apple",
//     "Apple",
//     "banana",
//     "Banana",
//     "china",
//     "China"
// ]
  • collation
    • 区域的变体
    • 同 locales 的 Unicode 扩展键 co 设置,但优先级高于他
const words = ['张三', '李四', '王五', '赵六', '孙七'];

// 默认拼音排序
const defaultCollator = new Intl.Collator('zh-CN');
console.log(words.slice().sort(defaultCollator.compare));

// 尝试不同的 collation(如果支持)
try {
  // 笔画排序(如果支持)
  const strokeCollator = new Intl.Collator('zh-CN-u-co-stroke');
  console.log(words.slice().sort(strokeCollator.compare));
} catch (e) {
  console.log('\n笔画排序不支持:', e.message);
}

// 通过 Unicode 扩展键设置 collation
const localeWithCollation = 'zh-CN-u-co-pinyin'; // 拼音排序(默认)
const collator1 = new Intl.Collator(localeWithCollation);
console.log('\n通过 Unicode 扩展键设置 (拼音):', collator1.resolvedOptions().collation);

// 通过 options 参数覆盖
const collator2 = new Intl.Collator('zh-CN-u-co-stroke', {
  collation: 'pinyin'  // options 优先级更高
});
console.log('Options 覆盖 Unicode 扩展键:', collator2.resolvedOptions().collation);

获取配置项

const options = collator.resolvedOptions();

usedOptions.locale; // "de"
usedOptions.usage; // "sort"
usedOptions.sensitivity; // "base"
usedOptions.ignorePunctuation; // false
usedOptions.collation; // "default"
usedOptions.numeric; // false

判断返回支持的 locale

在给定的 locales 数组中判断出 Collator支持的 locales。但是可能每个浏览器支持的不大一样。

const locales = ["ban", "id-u-co-pinyin", "de-ID"];
const options = { localeMatcher: "lookup" };
console.log(Intl.Collator.supportedLocalesOf(locales, options));
// ["id-u-co-pinyin", "de-ID"]

总结

Intl.Collator可以根据当前环境或者手动设置的 Locale 以及字符串的大小写、音标、声调、标点符号和数字敏感度来实现字符串的排序和搜索;在构建国际化应用中,特别是在处理用户生成内容、搜索排序、数据展示等场景中,能确保应用遵循目标语言的正确规则。

kuma-ui中Flex vs FlexMin的关键区别

作者 喵爱吃鱼
2025年12月15日 13:12

意外发现 FlexMin 在处理滚动时效果很好,而直接使用 Flex 情况下,当子元素内容过长时,无法正确计算滚动区域,研究了一下原因,如下:

Flex组件

// Flex/index.js
export default forwardRef(function (props, ref) {
  return React.createElement(Flex, {
    display: "flex",
    ...props,
    className: classnames(props.className),
    ref: ref
  }, props.children);
});

FlexMin组件

// FlexMin/index.js  
export default forwardRef(function (props, ref) {
  return React.createElement(Flex, {
    ...props,
    className: classnames(props.className, styles.flex),
    ref: ref
  }, props.children);
});

特点:

  • 基于 Flex 组件的封装
  • 关键差异:添加了 styles.flex 这个 CSS 类

核心差异:CSS样式

// index.module.less
:where(.flex) {
  > * {
    min-width: 0;
    min-height: 0;
    flex-shrink: 0;
  }
}

实际效果差异

FlexMin的优势

  1. 防止子元素溢出min-width: 0 和 min-height: 0 确保子元素不会因为内容过长而撑破容器
  2. 更好的滚动控制:当内容超出容器时,能正确显示滚动条
  3. 防止 flex 收缩问题flex-shrink: 0 防止子元素被意外压缩

那么新的问题来了,为什么min-width: 0min-height: 0 可以防止子元素溢出呢

CSS Flexbox 的默认行为问题

问题根源:Flexbox 的隐式最小尺寸

在 CSS Flexbox 中,flex 子元素有一个隐式的最小尺寸

/* 浏览器默认行为 */
.flex-item {
  min-width: auto;  /* 不是 0! */
  min-height: auto; /* 不是 0! */
}

min-width: auto 的含义:

  • 元素的最小宽度 = 内容的固有宽度
  • 即使 flex 容器空间不足,元素也不会收缩到小于内容宽度
  • 这会导致内容"撑破"容器'

具体场景演示

场景1:长文本溢出

<div class="flex-container" style="width: 300px;">
  <div class="flex-item">
    这是一段很长很长很长很长很长很长的文本内容
  </div>
</div>

不设置 min-width: 0 的结果:

  • 文本不会换行
  • flex-item 宽度 = 文本的完整宽度(比如 500px)
  • 容器被撑破,超出 300px 限制
  • 无法出现滚动条

设置 min-width: 0 的结果:

  • flex-item 可以收缩到 0 宽度
  • 文本会换行或被截断
  • 容器保持 300px 宽度
  • 可以正常显示滚动条

场景2:图片溢出

<div class="flex-container" style="width: 200px;">
  <img src="large-image.jpg" style="width: 400px;" />
</div>

不设置 min-width: 0:

  • 图片保持 400px 宽度
  • 容器被撑破到 400px

设置 min-width: 0:

  • 图片可以被压缩
  • 容器保持 200px 宽度

技术原理深入

1. CSS 规范定义

根据 CSS Flexbox 规范:

min-width: auto = min(max-content, specified size)
  • max-content:内容的最大固有宽度
  • specified size:显式指定的宽度

2. 计算优先级

/* 浏览器计算顺序 */
实际宽度 = max(min-width, min(max-width, flex-basis + flex-grow - flex-shrink))

min-width: auto 时:

  • 即使 flex-shrink: 1,元素也不会收缩到小于内容宽度
  • 这导致容器被撑破

min-width: 0 时:

  • 元素可以收缩到任意小的尺寸
  • 容器尺寸得到保护

FlexMin 的完整解决方案

:where(.flex) {
  > * {
    min-width: 0;    /* 允许水平收缩 */
    min-height: 0;   /* 允许垂直收缩 */
    flex-shrink: 0;  /* 但默认不收缩 */
  }
}

这个组合的巧妙之处:

  1. min-width: 0 - 移除内容宽度限制
  2. min-height: 0 - 移除内容高度限制
  3. flex-shrink: 0 - 默认不收缩,保持布局稳定

实际效果对比

普通 Flex(有问题):

<div style="display: flex; width: 300px; overflow: auto;">
  <div>很长很长很长很长很长的文本</div>
</div>
  • 文本撑破容器
  • 滚动条无法正常工作

FlexMin(修复后):

<div style="display: flex; width: 300px; overflow: auto;">
  <div style="min-width: 0;">很长很长很长很长很长的文本</div>
</div>
  • 容器保持 300px
  • 滚动条正常显示
  • 内容可以正常滚动

总结

min-width: 0min-height: 0 的核心作用是打破 CSS Flexbox 的默认内容尺寸约束,让容器能够真正控制子元素的尺寸,从而实现正确的滚动行为。这就是 FlexMin 能够完美处理浏览器滚动条的根本原理!

从40亿设备漏洞到AI浏览器:藏在浏览器底层的3个“隐形”原理

作者 zzpper
2025年12月15日 13:05

前阵子WebXR漏洞引爆技术圈——这个潜伏7个月的漏洞,让全球40亿台Chromium内核浏览器面临数据泄露风险,本质竟是浏览器3D转换时的内存管理漏洞,导致相邻64字节堆内存被意外读取。

我们每天和浏览器打交道,却很少深究:为什么一个小小的3D转换会触发安全危机?AI浏览器Atlas的智能预加载,又依赖哪些底层支撑?今天呢就从热点漏洞切入,扒一扒浏览器底层那些重要却易忽略的干货,帮你真正吃透原理,而非死记面试题。

一、热点漏洞背后:被低估的“进程隔离”护城河

这次WebXR漏洞能影响如此多设备,核心原因是Chromium内核的进程沙盒机制被绕过。很多前端开发者只知道“浏览器多进程”,却不清楚这层隔离的底层逻辑和漏洞风险点。

先纠正一个常见误区:不是所有浏览器都采用“一标签一进程”。Chrome的架构设计里,进程分配更像“智能分舱”:

  • 浏览器进程:管地址栏、书签、网络请求这些“全局事务”,相当于航母指挥中心;
  • 渲染进程:每个标签页一个(内存紧张时合并同源标签),负责页面渲染和JS执行,是最容易出问题的“作战单元”;
  • GPU进程:单独处理图形渲染,避免占用主线程资源;
  • 插件进程:隔离Flash等插件,防止插件崩溃拖垮整个浏览器。

而WebXR漏洞的突破口,就是渲染进程与GPU进程的通信边界。当处理3D转换时,渲染进程需要将数据传给GPU,却没做好内存边界校验,导致GPU读取到相邻内存的指针数据——这就像两个部门传递文件时,不小心夹带了机密档案。

前端启示:开发VR/AR相关页面时,除了兼容WebXR API,还要注意控制3D模型的顶点数据量。过多冗余数据不仅会增加内存压力,还可能放大这类边界校验漏洞的风险。

二、渲染引擎的“隐形加班”:增量绘制与dirty区域

聊浏览器渲染,多数文章只讲“HTML→DOM→CSSOM→渲染树→布局→绘制→合成”的流程,但真正影响页面性能的,是流程中容易被忽略的“增量绘制”机制——这也是WebXR漏洞中“脏区域”概念的延伸。

很多人以为页面更新时会全量重绘,其实浏览器很“聪明”:只有当部分元素发生更改(比如修改颜色、位移),且不影响整个渲染树时,会把这部分区域标记为dirty 区域(脏区域),只对这块区域进行重绘。就像给墙面补漆,只补掉漆的部分,而非整面墙重刷。

但这里有个易踩坑的点:JS操作可能意外扩大脏区域。比如频繁修改元素的offsetTop(触发重排),会导致浏览器不断重新计算布局,原本的增量绘制变成全量重绘,性能直接拉胯。

举个实际场景:做下拉加载列表时,若每次添加列表项都直接操作DOM并读取其位置信息,会触发连续的重排-重绘。正确做法是用DocumentFragment批量处理,或使用CSS transform代替top/left位移——因为transform属于合成层操作,不会触发布局和绘制,只需要合成线程处理,效率提升10倍以上。

三、AI浏览器的底层支撑:预加载与跨进程通信的“暗战”

2025年另一大热点是AI浏览器扎堆出现:OpenAI的ChatGPT Atlas、Chrome集成Gemini、Edge的Copilot模式。这些AI功能的流畅体验,背后依赖浏览器预加载机制和高效的跨进程通信(IPC) ——这两个点很少被前端深入关注。

先说说预加载:你在地址栏输入网址时,浏览器进程的界面线程会提前判断你可能访问的网站,在后台启动渲染进程并预加载资源。这种“预判式加载”让打开速度提升30%以上,但很多开发者不知道它的触发条件:

  1. 匹配浏览器历史记录中高频访问的域名;
  2. 书签中的收藏网址;
  3. 地址栏输入时的自动补全建议。

而AI浏览器的预加载更智能——比如ChatGPT Atlas能根据你的浏览上下文,预加载相关的文档和数据。这背后需要渲染进程、浏览器进程、AI服务进程之间的高效通信,也就是IPC。

这里有个关键知识点:浏览器的跨进程通信不是“直接对话”,而是通过“消息队列”中转。因为进程间内存隔离,不能直接共享数据,所有通信都要序列化后通过IPC通道传递。这就是为什么有些AI功能在低配设备上会卡顿——本质是IPC消息堆积,导致进程间响应延迟。

前端优化技巧:开发需要调用AI服务的页面时,尽量采用异步通信并设置超时时间。避免在主线程等待IPC消息返回,否则会阻塞页面渲染,影响用户体验。

四、最后:从底层原理看前端避坑指南

浏览器底层原理不是“面试八股文”,而是解决实际问题的钥匙。结合这次WebXR漏洞和AI浏览器的热点,总结3个前端必记的避坑点:

  • 安全层面:涉及WebXR、Canvas等图形相关API时,严格校验输入数据,避免传入异常值触发内存读取漏洞;
  • 性能层面:减少重排重绘,优先使用合成层属性(transform、opacity);批量操作DOM时用DocumentFragment;
  • 兼容层面:AI浏览器的预加载机制可能导致资源重复加载,需给静态资源设置合理的缓存策略(Cache-Control),避免浪费带宽。

浏览器就像一个精密的“技术生态系统”,我们平时看到的页面、用到的功能,都是底层无数机制协同工作的结果。理解这些“隐形”的原理,才能从根源上写出更安全、更高效的代码。

最后提醒一句:这次WebXR漏洞虽已修复,但浏览器的安全和性能优化永远在路上。不妨现在打开你的浏览器设置,检查是否开启自动更新——这是最基础也最有效的防护措施。

前端权限系统的“断舍离”:从安全防线到体验向导的架构演进

2025年12月15日 12:48

摘要:在企业级中后台应用中,前端权限控制往往容易陷入“过度设计”的误区。本文复盘了我们如何将一个原本计划投入 30 人天的“全栈级前端鉴权方案”,通过架构思维的转变,重构为仅需 5 人天的“体验导向型方案”。我们放弃了在浏览器端构建虚假的“马其诺防线”,转而利用验证中心(Verification Center)模式和 TypeScript 类型系统,打造了极致的用户体验。


一、 背景:一场关于“安全感”的博弈

在最近的 IBS Web 内测迭代中,我们面临一个经典的安全审计问题:“用户可以通过直接修改 URL 访问无权限的页面。”

面对这个问题,技术团队的第一反应是构建一套严密的“前端防线”:

  1. 路由层:在 beforeEach 中拦截所有未授权访问。
  2. 视图层:封装 v-permission 指令移除 DOM 元素。
  3. 数据层:在 Store 中维护一份庞大的权限映射表,甚至试图在前端过滤列表数据。

然而,在深入评估后,我们发现这种“重前端、轻后端”的策略存在巨大的 ROI(投入产出比)陷阱

1.1 误区分析

  • 重复建设:后端 API 已经实现了完善的数据级权限控制(Data Scope),前端再做一遍数据过滤是纯粹的冗余。
  • 维护噩梦:前后端权限逻辑必须时刻保持 1:1 同步,一旦后端调整粒度(如新增一个“导出”权限),前端必须发版,否则就会出现“后端允许但前端拦截”的 False Positive
  • 伪安全:前端的所有代码对用户都是透明的。熟练的攻击者可以直接通过 Postman 绕过前端路由调用 API。前端永远不是安全防线,后端才是。

二、 架构重构:Verification Center 模式

基于“前端负责体验,后端负责安全”的原则,我们重新设计了权限架构。核心组件是 验证中心(Verification Center)

2.1 架构设计图

graph TD
    User[用户行为] --> Router[路由导航]
    Router --> VC[验证中心 (Verification Center)]
    
    subgraph Frontend Logic
        VC -- 触发检查 --> Rules[验证规则链]
        Rules --> R1[登录态校验]
        Rules --> R2[用户类型校验]
        Rules --> R3[企业认证校验]
        Rules --> R4[密码过期校验]
        
        R1 & R2 & R3 & R4 -- 校验通过 --> Next[放行 / 渲染页面]
        R1 & R2 & R3 & R4 -- 校验失败 --> Actions[引导行为]
        Actions --> A1[跳转登录]
        Actions --> A2[显示 403 提示]
        Actions --> A3[弹出强制认证弹窗]
    end
    
    subgraph Backend Security
        API[后端 API] -- 数据请求 --> AuthGuard[后端鉴权层]
        AuthGuard -- 有权限 --> Data[返回业务数据]
        AuthGuard -- 无权限 --> Error[返回 403/空数据]
    end
    
    Next --> API

2.2 核心代码实现:可插拔的验证规则

为了解决“不同场景需要触发不同验证”的问题(例如:F5 刷新时需要重新校验,但路由跳转时可以复用缓存),我们设计了 VerificationRule 接口,并引入了 noCache 机制。

// src/services/verification/index.ts (精简版)

export type When = 'login' | 'appReady' | 'routeChange' | 'manual'

export interface VerificationRule {
  id: string
  when: When[]
  // 核心特性:控制是否跳过会话级缓存
  // F5 刷新或强制重校验时,此标志决定是否再次弹出认证窗口
  noCache?: boolean 
  shouldRun: (ctx: VerificationContext, when?: When) => boolean | Promise<boolean>
  run: (ctx: VerificationContext, when?: When) => void | Promise<void>
}

// 验证执行引擎
async function run(when: When) {
  const list = rules.filter(r => r.when.includes(when))
  for (const rule of list) {
    // 智能缓存策略:除非规则明确要求 noCache,否则同一会话仅执行一次
    if (!rule.noCache && sessionSeen[rule.id])
      continue
      
    if (await rule.shouldRun(ctx, when)) {
      if (!rule.noCache) sessionSeen[rule.id] = true
      await rule.run(ctx, when)
    }
  }
}

设计亮点

  • 解耦:路由守卫不再关心具体的业务逻辑(如“密码是否过期”),只负责触发 VerificationCenter.run('routeChange')
  • 性能:通过 sessionSeen 缓存机制,避免了每次路由切换都重复执行昂贵的校验逻辑。
  • 灵活:针对关键操作(如“用户类型变更”),通过配置 noCache: true 即可强制每次刷新页面时重新校验,完美解决了“F5 刷新后弹窗不复现”的顽疾。

三、 TypeScript 与 Pinia 的类型体操

在重构 Permission Store 时,我们遇到了 Pinia 在复杂类型推断下的一个经典问题:ts(2742)

3.1 问题复现

当我们尝试在 setup 语法中使用复杂的嵌套类型(如递归的菜单树)并隐式推断返回类型时,TypeScript 编译器抛出了错误:

The inferred type of 'usePermissionStore' cannot be named without a reference to '.pnpm/.../node_modules/@intlify/core-base'. This is likely not portable. A type annotation is necessary.

这是因为推断出的类型包含了一些不仅不可见、而且路径极深的第三方内部类型。

3.2 解决方案:显式接口定义

为了解决这个问题,并遵循“高内聚”的设计原则,我们放弃了隐式推断,转而定义明确的 Store 接口。

// src/store/core/permission.ts

// 1. 明确定义路由类型(解决递归类型推断问题)
export type AppRouteRecordRaw = RouteRecordRaw & {
  hidden?: boolean
  children?: AppRouteRecordRaw[]
}

// 2. 定义 Store 公开接口(Contract)
export interface PermissionStoreAPI {
  routes: Ref<AppRouteRecordRaw[]>
  generateRoutesFromMenu: (menuList: MenuItem[]) => MenuItem[]
  restoreRoutes: () => boolean
}

// 3. 在 defineStore 中显式应用接口
export const usePermissionStore = defineStore('permission', (): PermissionStoreAPI => {
  const routes = ref<AppRouteRecordRaw[]>([])
  
  function generateRoutesFromMenu(menuList: MenuItem[]) {
    // ... 具体的业务逻辑
    return []
  }

  function restoreRoutes() {
    // ... 恢复逻辑
    return true
  }

  return {
    routes,
    generateRoutesFromMenu,
    restoreRoutes,
  }
})

这种做法虽然多写了几行代码,但带来了显著的收益:

  • 类型稳定:切断了对第三方私有类型的依赖。
  • 文档化PermissionStoreAPI 接口本身就是最好的文档,开发者一眼就能看出这个 Store 提供了哪些能力。

四、 路由层的“软拦截”策略

在路由层面,我们放弃了传统的“硬拦截”(即检测到无权限直接 next(false) 或重定向),转而采用“软拦截”策略。

4.1 为什么要软拦截?

在内测阶段,如果用户通过 URL 访问了一个尚未在菜单配置的页面,硬拦截会直接导致 404 或死循环。而软拦截允许页面加载,但通过后端 API 的 403 响应来驱动 UI 展示。

4.2 实现方式

// src/router/index.ts

router.beforeEach(async (to, from, next) => {
  // 1. 启动进度条,提升感知
  nprogressManager.start()

  // 2. 核心:不在此处做复杂的权限比对
  // 我们信任后端数据安全,这里只做基础的登录态检查
  // 如果用户已登录但无权限,让他进入页面,看到“无数据”或“无权限”的空状态组件
  
  // 3. 触发验证中心(异步,不阻塞路由跳转)
  VerificationCenter.run('routeChange')

  next()
})

这种策略将“权限不足”的处理权交还给了页面组件(配合 <el-empty description="无权访问" />),既保证了系统的鲁棒性,又提升了用户体验。


五、 总结与思考

这次重构不仅仅是代码层面的修改,更是技术价值观的校准。

  1. 分层治理:后端守住安全底线,前端负责交互上限。
  2. 体验优先:权限控制的目的是“引导用户”,而不是“防御用户”。
  3. 极简主义:用 20% 的代码解决 80% 的核心体验问题,剩下的 20% 极端场景交给后端兜底。

通过这套架构,我们将原本需要 30 人天的庞大工程,精简为 5 人天的高效迭代,同时彻底解决了 F5 刷新、类型推断错误等技术债。这或许才是架构设计的魅力所在:在约束中寻找最优解。

openFuyao 总体定位和解决方案

作者 几何心凉
2025年12月15日 12:28

一、openFuyao 愿景使命与核心主张

1.1 社区愿景

openFuyao社区致力于构建面向多样化算力集群的开放软件生态,专注于推动云原生与AI原生技术的高效协同,促进有效算力的极致释放。通过"轻量核心+生态赋能"模式,助力企业快速构建高效、弹性、智能的算力基础设施,降低异构环境下的运维复杂度,为数字化转型提供敏捷支撑。

在当前算力多元化、应用场景复杂化的时代背景下,企业面临着来自多个维度的挑战。

首先是硬件多样化的困境。随着云计算技术的发展,企业的数据中心中不再仅仅配置CPU,而是需要部署CPU、GPU、NPU等多种异构硬件来满足不同的计算需求。然而,这些硬件架构差异大、管理接口各异,企业需要投入大量的人力和物力来进行统一管理,这成为了一个重大的技术难题。

其次是应用复杂化的挑战。AI训练、推理、大数据分析等多种应用场景对算力的需求差异巨大。AI训练需要高度的并行计算能力,推理则对时延和吞吐量有严格要求,而大数据分析则需要大内存和高I/O性能。这些场景的需求差异导致企业难以用统一的资源配置和调度策略来满足所有业务需求。

第三是运维成本高的问题。在异构环境下,集群管理、资源调度、监控告警等运维工作变得异常复杂繁琐。企业需要掌握多种硬件的驱动程序、固件更新、性能调优等知识,这大大增加了运维团队的工作负担和技术要求。

最后是资源利用率低的现象。传统的调度策略往往是基于静态的资源配置,难以充分发挥多样化硬件的性能潜力。在离线业务混部、NUMA亲和性调度、众核优化等高级调度技术的缺失,导致企业的硬件投资回报率不理想。

openFuyao正是在这样的背景下应运而生。我们致力于通过开源社区的力量,汇聚来自硬件厂商、软件企业、科研机构和开发者的智慧,构建一个开放、高效、易用的算力管理生态。通过提供轻量化的核心平台和丰富的生态组件,帮助企业快速解决异构算力管理的难题,加速数字化转型的进程。

编辑

1.2 核心主张

1.2.1 极简轻量 开箱即用

openFuyao发行版以模块化、轻量化、安全可靠为核心设计理念,基于开源的Kubernetes平台深度优化,提供开箱即用的容器化集群管理能力。这一设计理念源于我们对企业用户实际需求的深刻理解——企业需要的不是功能堆砌,而是能够快速部署、易于维护、灵活扩展的解决方案。

模块化架构是openFuyao的核心设计特色。我们采用"核心平台+可插拔组件"的架构模式,将系统分解为独立的功能模块。用户可以根据自身的实际需求,灵活地选择所需的功能模块进行部署,而不必承载不必要的功能负担。这种设计使得openFuyao既能满足简单场景的需求,也能支持复杂的企业级应用。

轻量化部署是openFuyao的另一大特色。我们在设计过程中充分考虑了资源效率,最小化了系统的资源占用。openFuyao支持从单节点的开发环境到数千节点的大规模集群的灵活部署,用户可以根据业务规模的增长逐步扩展系统,无需进行大规模的重构。

开箱即用是openFuyao的核心承诺。我们内置了完整的工具链、监控体系和应用市场,用户无需进行复杂的配置就可以快速上线。这大大降低了用户的学习曲线和部署成本,使得即使是没有深厚Kubernetes经验的团队也能快速上手。

openFuyao的核心平台涵盖了资源编排、弹性伸缩、多维度监控等基础功能,完全满足企业级生产环境的运维需求。同时,通过内置的应用市场,我们提供了丰富的产业级高价值组件,包括智算/通算混合调度、异构资源统一管理、动态智能调度、端到端可观测性增强等关键能力,帮助企业快速构建高效的算力管理体系。

1.2.2 异构融合 算力释放

openFuyao的核心竞争力在于其强大的异构融合能力。我们深刻认识到,在当今的计算生态中,没有任何单一的硬件架构能够满足所有的计算需求。因此,我们设计了一套完整的多元算力池化与统一接口抽象方案,致力于打破硬件架构之间的壁垒,释放异构硬件的全部潜力。

在异构资源统一管理方面,openFuyao支持鲲鹏KAE、昇腾NPU、GPU等多样化硬件资源的自动化管理。我们通过统一的资源接口屏蔽了底层硬件的差异,使得用户可以用统一的方式来管理和使用不同的硬件资源。这不仅降低了用户的学习成本,也大大简化了运维工作。

在智能调度优化方面,openFuyao结合了AI驱动的动态资源分配策略。与传统的静态调度策略不同,我们的调度引擎能够根据实时的工作负载情况,动态地调整资源分配决策,从而最大化资源的利用效率。实践证明,这种智能调度策略能够将资源利用率提升30%-50%,为企业带来显著的成本节省。

在场景深度适配方面,openFuyao针对AI训推、大数据分析等不同的应用场景提供了专业的优化套件。我们深入研究了各个场景的特点,设计了针对性的优化方案。例如,在AI推理场景中,我们通过智能路由、KV Cache管理等技术,实现了推理吞吐量提升55%、时延降低40%的显著性能提升。

1.2.3 开放生态 持续演进

openFuyao秉承生态开放的理念,我们坚信开源的力量来自于社区的共同贡献。因此,我们致力于打造一个繁荣的云原生+AI原生开源生态,而不是一个封闭的商业产品。

在组件化设计方面,openFuyao采用了高度模块化的架构,支持灵活的扩展。开发者可以基于我们提供的标准接口开发自定义组件,这些组件可以无缝地集成到openFuyao平台中。这种设计使得openFuyao生态具有强大的可扩展性,能够适应不断变化的业务需求。

在兼容主流工具链方面,openFuyao与Kubernetes、Helm、Prometheus等主流云原生工具无缝集成。我们不是要替代这些工具,而是在它们的基础上进行增强和优化。这种兼容性确保了用户可以继续使用他们熟悉的工具和工作流程,降低了迁移成本。

在技术栈演进方面,我们持续跟进CNCF技术栈的最新发展,确保openFuyao的技术方向始终保持前瞻性。我们不仅关注当前的技术热点,更重要的是预见未来的技术趋势,为用户提供面向未来的解决方案。

在社区共建方面,我们建立了完善的SIG(特别兴趣小组)机制、贡献流程和激励体系。我们欢迎来自全球的开发者、企业用户和硬件厂商的参与和贡献,共同推动openFuyao的发展。

二、openFuyao 技术定位

2.1 技术核心定位

openFuyao定位为新一代云原生算力管理平台,聚焦于以下核心维度:

定位维度 核心能力
平台定位 面向多样化算力的企业级容器管理平台
技术方向 云原生 + AI原生深度融合
应用场景 智算/通算混合部署、AI训推一体化、大数据分析
核心优势 轻量化、可插拔、高性能、易运维

2.2 技术特色亮点

2.2.1 异构融合能力

openFuyao的异构融合能力是其最核心的技术竞争力。我们深入理解了当代企业数据中心的多样化硬件环境,设计了一套完整的异构硬件管理和优化体系。

多元算力池化是我们的基础能力。openFuyao支持CPU、GPU、NPU、KAE等多种算力形态的统一管理。不同于传统的单一硬件管理方案,我们的设计允许企业在同一个平台上管理和调度多种硬件资源,充分利用每种硬件的优势,为不同的应用场景选择最合适的计算资源。

自动识别与管理是我们的创新特性。openFuyao能够自动发现和识别鲲鹏KAE、昇腾NPU等异构硬件,并自动化部署相应的驱动程序。这意味着企业无需手动配置复杂的硬件驱动,只需将硬件接入集群,openFuyao就能自动识别并配置,大大降低了部署难度。

统一接口抽象是我们的设计哲学。我们通过统一的资源接口屏蔽了底层硬件的差异,使得应用开发者可以用统一的方式来请求和使用不同的硬件资源。这种抽象不仅简化了应用开发,也为硬件的升级和替换提供了灵活性。

打破硬件壁垒是我们的最终目标。通过上述技术的组合,openFuyao实现了真正的硬件无关性,支持应用在不同硬件平台之间的迁移,为企业提供了最大的灵活性和可选择性。

2.2.2 智能调度引擎

openFuyao的智能调度引擎是其性能优化的核心。与传统的静态调度策略不同,我们采用了AI驱动的动态调度方案,能够根据实时的工作负载情况进行智能决策。

AI驱动的动态资源分配是我们的创新方向。我们基于机器学习算法,分析历史的工作负载模式和资源使用情况,动态优化资源分配决策。这种方案能够自动学习应用的特性,预测未来的资源需求,从而提前进行资源调整,避免资源浪费和性能瓶颈。

跨集群资源利用率优化扩展了我们的调度能力。openFuyao支持跨多个集群的全局资源调度,使得企业可以在多个数据中心或云环境中统一管理资源,实现全局的负载均衡和资源优化。

在离线混部技术是我们的核心优化技术。通过在同一个集群中混合部署在线业务(如Web服务)和离线业务(如数据分析),我们能够充分利用硬件资源。我们的QoS保障机制确保在线业务的性能不受离线业务的影响,性能抖动控制在5%以内,同时资源利用率提升30%-50%。

NUMA亲和调度针对现代多核处理器的特性进行了优化。我们在集群级和节点级都进行了硬件NUMA拓扑感知,确保应用的内存访问尽可能在本地NUMA节点上进行,减少跨节点的内存访问延迟。这种优化使得应用性能平均提升30%。

众核调度针对256核+的超大规模处理器进行了特殊优化。我们基于业务类型的反亲和调度策略和多维资源评分机制,能够在众核环境下实现更好的性能隔离和资源利用,容器部署密度提升10%,同时性能下降控制在5%以内。

2.2.3 场景化适配

openFuyao不是一个通用的平台,而是针对不同的应用场景进行了深度优化。我们理解不同的业务场景有不同的需求,因此提供了针对性的优化方案。

AI训推加速是我们的重点优化方向。大模型推理已经成为当代AI应用的核心,但推理的性能往往成为瓶颈。openFuyao通过智能路由、KV Cache管理、PD分离等技术,实现了推理吞吐量提升55%、时延降低40%的显著性能提升。这些优化使得企业可以用更少的硬件资源支撑更多的推理请求,大大降低了AI应用的部署成本。

大数据分析优化针对Spark、Flink等大数据框架进行了专业的性能优化。我们深入研究了这些框架的特性,优化了资源调度、内存管理、网络通信等关键环节,使得大数据分析任务能够更高效地运行。

Ray分布式计算是我们为云原生场景设计的高易用解决方案。Ray是一个强大的分布式计算框架,但在云原生环境中的部署和运维往往比较复杂。openFuyao提供了开箱即用的Ray集成,用户可以轻松部署和管理Ray集群,快速构建分布式计算应用。

降低业务落地门槛是我们的最终目标。通过预集成的优化套件,企业可以快速将各种业务场景迁移到openFuyao平台上,无需进行复杂的性能调优,加速业务上线的时间。

2.2.4 极致可观测性

openFuyao认为可观测性是现代云原生平台的必备能力。我们提供了完整的监控、日志、告警体系,帮助用户快速定位和解决问题。

开箱即用的监控系统是我们的基础能力。我们基于Prometheus构建了多层级的监控体系,从集群级、节点级、工作负载级到容器级,提供了全方位的性能指标。用户无需进行复杂的配置就可以获得完整的监控数据。

自定义监控看板提供了灵活的数据可视化能力。用户可以根据自己的业务需求定制监控指标和可视化看板,快速了解系统的运行状态和性能情况。

完整的日志系统汇集了多类型的日志数据。openFuyao支持应用日志、系统日志、审计日志等多种日志类型的汇集、查看和下载,为故障诊断提供了完整的信息。

实时告警系统支持多告警源和灵活的告警规则配置。用户可以根据自己的需求配置告警规则,当系统出现异常时,能够及时收到告警通知,快速响应问题。

实时性能分析工具支持性能剖析、链路追踪等诊断工具,帮助用户深入理解应用的性能特性,快速定位性能瓶颈。


三、openFuyao 社区技术框架

3.1 整体架构设计

openFuyao采用分层解耦、模块化可插拔的架构设计理念:

┌─────────────────────────────────────────────────────────────┐ │ 用户交互层 │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ Web控制台 │ │ CLI工具 │ │ API接口 │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ └─────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────┐ │ 扩展组件层(可插拔) │ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌──────────┐ │ │ │ 算力优化 │ │ 可观测性 │ │ 硬件管理 │ │ AI推理 │ │ │ │ 中心 │ │ 中心 │ │ Operator │ │ 加速 │ │ │ └────────────┘ └────────────┘ └────────────┘ └──────────┘ │ └─────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────┐ │ 核心平台层 │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ 应用管理 │ │ 多集群管理 │ │ 资源管理 │ │ │ │ 仓库管理 │ │ 用户管理 │ │ RBAC权限 │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ └─────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────┐ │ 容器编排核心层 │ │ openFuyao Kubernetes (K8s 1.33) │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ 高密部署 | 启动加速 | 日志增强 | 证书管理 | PVC扩容 │ │ │ └──────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────┐ │ 基础设施层 │ │ CPU | GPU | NPU | KAE | 存储 | 网络 | 内存 │ └─────────────────────────────────────────────────────────────┘

3.2 核心技术框架详解

3.2.1 基础平台功能模块

  1. 安装部署

openFuyao的安装部署工具对接标准的Cluster-API,提供了一套完整的集群部署解决方案。我们支持一键式业务集群部署,用户可以通过简单的配置快速启动一个完整的openFuyao集群。我们支持多种部署场景,包括单节点的开发环境、多节点的生产环境、在线安装和离线安装、以及高可用部署等。此外,openFuyao还提供了集群扩缩容和Kubernetes原地升级的能力,使得集群的运维变得简单高效。

  1. 容器编排核心(openFuyao Kubernetes)

openFuyao Kubernetes是基于Kubernetes 1.33深度优化的容器编排核心。我们在多个方面进行了创新优化:

高密部署是我们的核心优化之一。openFuyao支持每节点1000+ Pod的部署,这在传统Kubernetes中是难以实现的。这种高密部署能力使得企业可以用更少的硬件资源支撑更多的容器,大大提高了硬件的利用效率。

启动加速通过kubelet支持CPU垂直扩容来实现。这意味着容器可以在启动时获得更多的CPU资源,加快启动速度,然后在运行时逐步降低CPU使用,这种动态的资源分配策略大大加快了容器的启动速度。

日志增强提供了更可靠的日志管理。openFuyao支持日志轮转和可靠性增强,确保重要的日志信息不会丢失。

证书管理支持K8s证书的热加载。这意味着集群可以在不中断服务的情况下更新证书,大大提高了集群的可用性。

PVC扩容使得StatefulSet的PVC模板支持扩容,这为有状态应用的扩展提供了更多的灵活性。

  1. 管理控制面

openFuyao提供了开箱即用的Web控制台,这是一个功能完整、易于使用的管理界面。通过这个控制台,用户可以进行应用的全生命周期管理,包括部署、更新、回退等操作。用户还可以管理扩展组件、进行资源可视化管理、配置监控告警,以及通过Web Terminal进行命令行交互。这个控制面的设计充分考虑了用户的易用性,使得即使是没有深厚Kubernetes经验的用户也能快速上手。

  1. 认证鉴权系统

openFuyao内置了OAuth2-Server,支持OAuth2.0协议,提供了统一的认证和鉴权接入方案。同时,我们还提供了灵活的密码策略和安全管理功能,确保系统的安全性。

  1. 用户与权限管理

openFuyao支持跨集群的多用户管理,用户可以在平台级和集群级进行角色绑定。我们提供了管理者、操作者、观察者三种角色,满足不同用户的权限需求。

  1. 多集群管理

openFuyao支持集群联邦纳管,用户可以通过统一的入口访问多个集群。我们支持跨集群的资源调度,使得企业可以在多个集群之间进行全局的资源优化。同时,我们还提供了完整的集群生命周期管理能力,用户可以轻松创建、更新、删除集群。

3.2.2 组件安装管理框架

  1. 应用市场

openFuyao的应用市场是一个丰富的组件库,汇集了各种优化套件和扩展组件。应用市场基于Helm进行应用分发,提供了算力加速套件库,用户可以通过一键式部署快速安装所需的组件。我们还提供了完整的版本管理和升级能力,用户可以轻松管理应用的版本,进行升级和回退操作。

  1. 应用管理

openFuyao集成了Helm v3应用包管理器,提供了完整的应用生命周期管理能力。用户可以通过openFuyao进行应用的部署、升级、回退、卸载等操作,无需直接操作Helm命令。同时,openFuyao还提供了完整的监控与日志查看功能,用户可以快速了解应用的运行状态。此外,我们还提供了YAML配置的可视化编辑,使得用户可以通过图形界面进行配置,无需手动编写复杂的YAML文件。

  1. 仓库管理

openFuyao内置了Harbor仓库,提供了完整的Helm Chart包管理能力。用户可以在openFuyao中管理多个仓库,支持远程仓库同步和私有仓库支持。这使得企业可以构建自己的应用仓库,快速分发内部开发的应用和组件。

  1. 扩展组件管理(可插拔架构)

openFuyao采用了基于ConsolePlugin CRD的动态可插拔框架,这是一个创新的设计。第三方开发者可以开发自己的扩展组件,这些组件可以无缝集成到openFuyao的前端界面中。组件可以即插即用,无需修改openFuyao的核心代码。所有的扩展组件都继承了openFuyao的统一认证鉴权机制,确保了系统的安全性。这种可插拔架构使得openFuyao具有强大的可扩展性,能够快速适应不同的业务需求。

3.2.3 算力调度优化框架

  1. 在离线混部

在离线混部是openFuyao的核心优化技术之一。传统的集群部署往往是在线业务和离线业务分开部署,这导致硬件资源的利用率很低。openFuyao支持在同一个集群中混合部署在线业务(如Web服务、API服务)和离线业务(如数据分析、模型训练)。通过智能的资源隔离和QoS保障机制,我们确保在线业务的性能不受离线业务的影响,性能抖动控制在5%以内,同时资源利用率提升30%-50%。

openFuyao集成了Rubik组件,提供了多项高级能力,包括弹性限流和内存分级回收。弹性限流能够根据系统的负载情况动态调整离线业务的资源使用,确保在线业务始终有足够的资源。内存分级回收能够根据内存的使用情况,自动回收不必要的内存,提高内存的利用效率。

  1. NUMA亲和调度

现代处理器往往采用NUMA(Non-Uniform Memory Access)架构,这意味着不同的CPU核心访问不同的内存区域的延迟不同。openFuyao在集群级和节点级都进行了硬件NUMA拓扑感知,能够理解集群中每个节点的NUMA结构。基于这种理解,openFuyao的调度器能够进行NUMA亲和性调度优化,确保应用的内存访问尽可能在本地NUMA节点上进行,减少跨节点的内存访问延迟。这种优化使得应用性能平均提升30%。

  1. 众核调度

随着处理器技术的发展,256核+的超大规模处理器已经成为常见的硬件配置。然而,传统的调度策略往往难以充分利用这些众核处理器的性能。openFuyao针对256核+架构进行了特殊优化。我们基于业务类型的反亲和调度策略,确保不同类型的业务不会相互干扰。我们还采用了多维资源评分机制,综合考虑CPU、内存、网络等多个维度的资源情况,进行更智能的调度决策。这种优化使得容器部署密度提升10%,同时性能下降控制在5%以内。

  1. openFuyao Ray

Ray是一个强大的分布式计算框架,但在云原生环境中的部署和运维往往比较复杂。openFuyao提供了云原生场景下Ray的高易用解决方案。我们提供了Ray集群及作业的全生命周期管理,用户可以通过openFuyao轻松创建和管理Ray集群。我们支持RayCluster、RayJob、RayService等多种Ray资源类型。同时,我们还提供了全局的资源监控与可观测性,用户可以实时了解Ray集群的运行状态和性能情况。

3.2.4 硬件自动化管理框架

  1. KAE-Operator(鲲鹏加速引擎)

KAE-Operator是openFuyao为鲲鹏硬件设计的自动化管理工具。它能够自动发现集群中的KAE设备节点,无需人工干预。一旦发现KAE设备,KAE-Operator会自动进行驱动的部署与配置,使得KAE硬件快速进入可用状态。整个过程可以在5分钟内完成,大大降低了硬件的使用门槛。这意味着企业可以快速部署鲲鹏硬件,无需投入大量的人力进行复杂的配置工作。

  1. NPU-Operator(昇腾NPU)

NPU-Operator是openFuyao为昇腾NPU硬件设计的自动化管理工具。它能够自动识别昇腾节点及其设备型号(如910B、310P等),并自动进行MindCluster组件的管理。整个NPU部署到可用的过程可以在十分钟内完成。

NPU-Operator提供了完整的训练推理全栈支持,包括以下组件:

**· ** ** ***昇腾驱动和固件 *****:确保NPU硬件能够正常工作

**· ** ** ***Ascend Device Plugin *****:为Kubernetes提供NPU设备的资源管理

**· ** ** ***Ascend Operator *****:自动化管理NPU相关的Kubernetes资源

**· ** ** ***Ascend Docker Runtime *****:支持容器直接访问NPU设备

**· ** ** ***NPU Exporter *****:导出NPU的性能指标,用于监控

**· ** ** ***Resilience Controller *****:提供NPU故障恢复能力

**· ** ** ***ClusterD ****** ********** ******NodeD ********** ****** ********** ****MindIO *:MindCluster的核心组件,提供集群管理和IO优化

**· ** ** ***Volcano ****调度器 *:为NPU工作负载提供高效的调度

这个完整的组件栈使得企业可以快速构建基于昇腾NPU的AI计算平台,无需担心复杂的硬件配置和驱动问题。

3.2.5 可观测性框架

openFuyao的可观测性框架是其运维能力的核心。我们认为,没有完整的可观测性,就无法有效地管理和优化系统。因此,我们提供了一套完整的监控、日志、告警体系。

  1. 监控系统

openFuyao的监控系统基于Prometheus构建,提供了多层级的监控能力。我们从集群级、节点级、工作负载级到容器级,提供了全方位的性能指标。这意味着用户可以从不同的角度了解系统的运行状态。

我们提供了开箱即用的监控看板,用户无需进行复杂的配置就可以获得完整的监控数据。同时,我们还支持自定义PromQL查询,用户可以根据自己的需求进行灵活的数据查询和分析。

openFuyao还提供了实时性能分析工具,用户可以深入了解应用的性能特性,快速定位性能瓶颈。

  1. 自定义监控看板

openFuyao的监控看板设计充分考虑了用户的灵活性需求。用户可以根据自己的业务需求定制监控指标和可视化看板。我们提供了丰富的可视化组件,用户可以通过拖拽和配置快速构建自己的监控看板,无需编写代码。这种灵活的数据可视化能力使得用户可以精准地观测和分析系统的运行状态。

  1. 日志系统

openFuyao的日志系统支持多类型日志的汇集。我们支持应用日志、系统日志、审计日志等多种日志类型,所有这些日志都可以集中管理和查看。用户可以快速查看、下载和分析日志,为故障诊断提供了完整的信息。

  1. 告警系统

openFuyao的告警系统支持多告警源,包括Prometheus和Loki等。用户可以根据自己的需求灵活配置告警规则,当系统出现异常时,能够及时收到告警通知。我们支持多级别的告警(严重、警告、提示),用户可以根据告警级别采取不同的响应措施,快速响应问题。

3.2.6 AI推理加速框架

  1. AI推理优化

openFuyao的AI推理加速框架是针对当代大模型推理场景设计的端到端解决方案。我们深入理解了大模型推理的性能瓶颈,设计了一套完整的优化技术栈。

智能路由模块是我们的核心创新之一。在多个模型副本的场景中,智能路由能够根据请求的特性和系统的负载情况,动态地将请求路由到最合适的模型副本。这种智能的负载均衡能够显著提高系统的吞吐量。

全局KV Cache管理是我们针对Transformer模型推理的优化。在推理过程中,KV Cache往往占用大量的内存。我们的全局KV Cache管理能够跨多个推理实例共享KV Cache,减少内存占用,提高内存利用效率。

PD分离模块(Prefill和Decode分离)是我们的另一项创新。Prefill和Decode是推理过程中的两个不同阶段,它们的计算特性和资源需求不同。通过将这两个阶段分离,我们可以为每个阶段采用最优的资源配置和调度策略,显著提高推理的效率。

通过这些优化技术的组合,openFuyao实现了推理吞吐量提升55%、时延降低40%的显著性能提升。

  1. AI推理软件套件

openFuyao提供了完整的AI推理软件套件,这是一个开箱即用、可扩展的解决方案。我们提供了AI一体机集成解决方案,用户可以快速部署完整的AI推理系统。

我们提供了基础的LLM推理全栈,包括模型加载、推理引擎、结果处理等完整的流程。我们还提供了DeepSeek等主流大模型的集成支持,用户可以快速部署这些模型进行推理。

这个软件套件支持NPU与GPU等多种硬件,用户可以根据自己的硬件配置灵活选择,无需担心硬件兼容性问题。


四、openFuyao 演进策略

4.1 技术演进路线

4.1.1 短期演进(当前-未来6个月)

openFuyao的短期演进目标是进一步完善核心能力,为用户提供更稳定、更高效的平台。

  1. 核心平台增强

我们将持续跟进Kubernetes的最新版本,确保openFuyao始终采用最新的Kubernetes特性和安全补丁。我们的高密部署能力目前支持每节点1000+ Pod,在短期内我们的目标是进一步优化到单节点2000+ Pod,这将使得企业可以用更少的硬件资源支撑更多的容器。

我们还将增强证书管理的自动化程度,使得集群的证书更新变得完全自动化,无需人工干预。同时,我们将优化存储与网络的性能,使得数据密集型应用能够获得更好的性能。

  1. AI能力深化

AI推理是当前最热门的应用场景,我们将持续优化AI推理的性能。我们计划集成更多的LLM框架,包括LLaMA、Qwen等主流框架,使得用户可以灵活选择自己喜欢的框架。

我们还将继续优化推理加速算法,探索更多的优化技术,进一步提高推理的吞吐量和降低时延。同时,我们将实现模型部署的自动化,使得用户可以一键部署模型,无需进行复杂的配置。

  1. 可观测性提升

我们将提供更丰富的监控指标,覆盖系统的各个方面。我们还将实现智能告警与根因分析,当系统出现异常时,不仅能够告警,还能够自动分析根本原因,帮助用户快速定位问题。

我们将增强日志分析与检索能力,使得用户可以更快速地找到所需的日志信息。同时,我们将集成更多的性能剖析工具,帮助用户深入理解应用的性能特性。

4.1.2 中期演进(6-12个月)

中期演进的目标是扩展openFuyao的应用范围,支持更复杂的企业场景。

  1. 多云原生支持

随着企业数字化的深入,多云部署已经成为常见的架构模式。openFuyao将支持多云集群的统一管理,用户可以通过单一的控制面管理部署在不同云平台上的集群。

我们将实现跨云资源调度优化,使得企业可以在多个云平台之间进行全局的资源优化和负载均衡。我们还将深度适配混合云场景,支持企业在私有云和公有云之间的灵活部署。

随着边缘计算的发展,我们还将支持云边协同能力,使得企业可以在云端和边缘节点之间进行协同计算。

  1. 智能化运维

我们将引入AIOps(AI for IT Operations)的理念,实现智能化的运维能力。我们将实现故障自愈机制,当系统出现故障时,能够自动进行修复,无需人工干预。

我们还将实现资源的自动优化,系统能够根据历史的运行数据和当前的负载情况,自动调整资源配置,确保系统始终处于最优状态。

我们将实现智能容量规划,系统能够根据业务增长的趋势,预测未来的资源需求,提前进行容量规划。

  1. 生态扩展

我们将支持更多的硬件加速器,包括ASIC等新型加速器,使得openFuyao能够适应不断演进的硬件生态。

我们将丰富应用市场的组件库,提供更多的垂直行业解决方案,包括金融、制造、医疗等行业的专业解决方案。

我们还将完善开发者工具链,提供更好的开发体验,吸引更多的开发者参与openFuyao的生态建设。

4.1.3 长期演进(12个月+)

长期演进的目标是推动算力生态的创新发展。

  1. 算力网络

我们将构建算力感知网络,使得网络能够理解和优化算力的流动。我们将实现跨域算力调度,使得算力可以跨越地域限制进行调度。

我们将探索算力交易与共享的机制,使得企业可以灵活地购买和出租算力资源。我们还将支持边缘算力的接入,使得边缘节点的算力也能够被统一管理和调度。

  1. AI原生平台

我们将实现AI训推一体化的深度融合,使得AI模型的训练和推理能够在同一个平台上进行,共享资源和优化。

我们将优化大模型的训练性能,支持分布式训练、混合精度训练等高级特性。

我们将支持联邦学习,使得多个企业可以在不共享原始数据的情况下进行协同学习。

我们还将集成AutoML平台,使得用户可以自动进行模型选择和超参数优化。

  1. 安全与合规

我们将实现零信任安全架构,确保系统中的每个请求都经过严格的身份验证和授权。

我们将提供数据加密与隐私保护能力,确保用户的数据安全。

我们将提供合规性审计工具,帮助企业满足各种合规要求。

我们还将提供安全沙箱隔离,确保恶意应用无法影响其他应用。

4.2 版本演进策略

4.2.1 版本发布节奏

openFuyao采用了科学的版本发布策略,平衡了创新速度和稳定性。

大版本每年发布1-2次,包含架构升级和重大特性。大版本的发布往往标志着平台的重大演进,可能包括新的架构设计、新的核心能力等。

功能版本每季度发布1次,包含新功能和性能优化。功能版本是openFuyao持续演进的主要方式,用户可以定期获得新的功能和性能改进。

修复版本按需发布,包含Bug修复和安全补丁。当发现重要的Bug或安全漏洞时,我们会立即发布修复版本,确保用户系统的稳定性和安全性。

4.2.2 版本兼容策略

openFuyao对版本兼容性有严格的要求,确保用户的投资得到保护。

向下兼容是我们的核心承诺。新版本保证对旧版本配置和数据的兼容,用户无需修改现有的配置就可以升级到新版本。

平滑升级是我们的设计目标。我们提供了原地升级能力,用户可以在不中断业务的情况下进行升级,最小化业务中断。

长期支持是我们对关键版本的承诺。关键版本提供12个月以上的技术支持,用户可以放心地使用这些版本。

升级工具是我们提供的便利。我们提供了自动化的升级检查和迁移工具,帮助用户快速、安全地进行升级。

4.3 社区共建策略

openFuyao是一个开源项目,社区的力量是我们的核心竞争力。我们致力于打造一个开放、包容、充满活力的社区。

4.3.1 开源共建机制

  1. SIG(特别兴趣小组)机制

我们按技术领域组建了多个SIG,包括核心平台SIG、AI推理SIG、硬件适配SIG等。每个SIG都有明确的技术方向和工作目标。我们采用开放的技术决策流程,所有的重大技术决策都通过SIG讨论进行,确保社区的声音能够被听到。

我们定期举办技术交流与分享活动,邀请社区成员分享他们的经验和见解。这些活动不仅能够促进知识的传播,也能够增进社区成员之间的了解和合作。

我们还建立了完善的社区贡献者激励体系,鼓励社区成员积极参与openFuyao的建设。

  1. 贡献方式

openFuyao欢迎各种形式的贡献。代码贡献是最直接的方式,社区成员可以提交代码来修复Bug、实现新功能或进行性能优化。

文档完善也是重要的贡献方式。好的文档能够帮助用户快速上手,降低学习曲线。我们欢迎社区成员改进和完善openFuyao的文档。

问题反馈与解答也是重要的贡献。用户在使用openFuyao时遇到的问题往往能够反映系统的不足之处,我们欢迎用户反馈问题。同时,有经验的社区成员可以帮助其他用户解答问题。

测试与验证是保证质量的重要环节。我们欢迎社区成员进行测试,发现Bug并提交测试用例。

推广与布道也是重要的贡献。社区成员可以撰写博客、发表演讲、组织线下活动等方式来推广openFuyao。

  1. 协作平台

我们使用GitCode作为代码托管平台,所有的代码都在这里进行管理。我们使用邮件列表进行讨论,社区成员可以在邮件列表上讨论技术问题和提出建议。

我们使用Issue跟踪管理系统来管理Bug、功能请求等。我们还使用技术文档协作工具来管理文档,社区成员可以直接编辑和改进文档。

4.3.2 生态合作策略

  1. 硬件厂商合作

openFuyao与鲲鹏、昇腾等硬件厂商进行了深度合作,确保这些硬件能够得到最优的支持。我们也与GPU等加速器的厂商合作,支持这些加速器。

我们还与存储、网络设备的厂商合作,确保openFuyao能够充分利用这些设备的性能。

  1. 软件伙伴合作

openFuyao与主流的云原生工具进行了集成,包括Kubernetes、Helm、Prometheus等。我们也与AI框架和平台进行了对接,包括TensorFlow、PyTorch等。

我们与数据库、中间件的厂商合作,确保这些组件能够在openFuyao上高效运行。我们也与安全和监控工具的厂商合作,构建完整的生态。

  1. 行业解决方案

我们与各个行业的企业合作,开发行业特定的解决方案。我们已经开发了金融行业方案、制造业方案、互联网行业方案等。我们还与科研机构和教育机构合作,开发科研与教育方案。

4.4 技术创新策略

openFuyao的技术创新策略包括两个方面:一是跟踪前沿技术,确保我们始终走在技术的前沿;二是进行自主创新,开发具有自主知识产权的核心技术。

4.4.1 前沿技术跟踪

云原生技术是我们持续跟踪的重点。我们密切关注CNCF技术栈的发展,及时将新的技术引入openFuyao。我们参与CNCF的各个项目,与全球的开发者合作,推动云原生技术的发展。

AI技术是另一个重要的跟踪方向。我们关注大模型、联邦学习等前沿技术的发展,积极探索这些技术在openFuyao中的应用。我们与AI研究机构合作,将最新的AI研究成果应用到openFuyao中。

算力技术是我们的核心关注点。我们跟踪新型算力架构的发展,包括新的CPU架构、GPU架构等。我们也关注新的调度算法的发展,将这些算法应用到openFuyao的调度器中。

边缘计算是我们未来的重要方向。我们关注云边端协同技术的发展,探索如何在openFuyao中支持边缘计算。

4.4.2 自主创新方向

异构算力调度是我们的核心自主创新方向。我们自研了智能调度算法,能够根据应用的特性和硬件的特性进行智能的调度决策。这种算法在业界处于领先地位。

高密部署优化是我们的另一项自主创新。我们通过深入优化Kubernetes的各个环节,实现了单节点1000+ Pod的部署,这在业界是领先的。我们的目标是进一步突破单节点容器密度的极限。

AI推理加速是我们的重点创新方向。我们自研了多项推理加速技术,包括智能路由、KV Cache管理、PD分离等,这些技术使得openFuyao的推理性能处于业界领先水平。

可观测性是我们的创新重点。我们开发了智能化的监控与诊断系统,能够自动发现系统的问题并提供诊断建议。


五、openFuyao 价值主张

openFuyao为不同的利益相关者提供了不同的价值。我们相信,只有为所有的利益相关者创造价值,才能构建一个可持续发展的生态。

5.1 对企业用户的价值

降本增效是openFuyao为企业带来的最直接的价值。通过我们的智能调度和在离线混部技术,企业可以将资源利用率提升30%-50%,这意味着企业可以用更少的硬件投资支撑相同的业务规模,或者用相同的硬件投资支撑更多的业务。这种成本节省对于大规模的数据中心来说是非常可观的。

简化运维是openFuyao的另一项重要价值。openFuyao提供了开箱即用的解决方案,企业无需进行复杂的配置就可以快速部署。我们的自动化管理能力使得运维复杂度降低60%以上,这意味着企业可以用更少的运维人员来管理更大规模的集群。

性能提升是openFuyao在AI推理场景中的核心价值。通过我们的推理加速技术,企业可以获得推理吞吐量提升55%、时延降低40%的显著性能提升。这对于AI应用的商业化部署至关重要。

快速交付是openFuyao为企业带来的时间价值。openFuyao支持分钟级的集群部署,企业可以快速启动新的集群来支持新的业务。这种快速交付能力使得企业可以更快地响应市场变化。

风险可控是openFuyao的重要价值主张。openFuyao对硬件进行了深度适配,支持鲲鹏、昇腾等芯片,帮助企业实现自主可控。同时,openFuyao提供了完整的安全和合规能力,帮助企业满足各种合规要求。

5.2 对开发者的价值

易用性是openFuyao为开发者提供的首要价值。openFuyao提供了直观的Web界面,开发者无需深入学习Kubernetes就可以快速上手。这大大降低了学习曲线,使得更多的开发者可以参与到云原生应用的开发中。

灵活性是openFuyao的架构特性。openFuyao采用了可插拔的架构,开发者可以根据自己的需求选择所需的组件。这种灵活性使得openFuyao能够适应各种不同的应用场景。

可扩展性是openFuyao为开发者提供的创新空间。openFuyao提供了开放的扩展接口,开发者可以基于这些接口开发自己的组件和插件。这种可扩展性使得openFuyao能够不断演进,满足新的需求。

标准化是openFuyao的重要特性。openFuyao兼容Kubernetes生态,开发者可以使用标准的Kubernetes工具和API。这种标准化确保了开发者不会被锁定在openFuyao中,可以灵活地迁移到其他平台。

5.3 对社区的价值

技术创新平台是openFuyao为社区提供的最重要的价值。openFuyao汇聚了来自全球的开发者、企业和研究机构的智慧,共同推动云原生和AI原生技术的创新。社区成员可以在这个平台上进行技术创新,开发新的功能和优化。

知识共享空间是openFuyao社区的另一项重要价值。我们提供了丰富的文档、案例和最佳实践分享。社区成员可以从这些资源中学习,也可以贡献自己的知识。

职业发展机会是openFuyao为社区成员提供的价值。参与开源贡献可以帮助开发者提升技术能力,积累行业经验。许多知名的技术人才都是通过开源贡献成长起来的。

生态共建是openFuyao的最终目标。我们致力于打造一个繁荣的云原生+AI原生生态,这个生态中的每个参与者都能够获得价值。通过共建,我们可以创造出单个企业无法创造的价值。


六、总结

openFuyao作为新一代云原生算力管理平台,以"轻量核心+生态赋能"为核心理念,通过异构融合、智能调度、场景适配、生态开放四大技术亮点,为企业提供高效、弹性、智能的算力基础设施解决方案。

核心竞争力

openFuyao的核心竞争力体现在以下几个方面:

  1. 轻量化设计:openFuyao采用了模块化、可插拔的架构设计,用户可以根据自己的需求灵活选择所需的功能模块。这种设计使得openFuyao既能支持简单的场景,也能支持复杂的企业级应用,同时保持系统的轻量性。
  2. 异构融合:openFuyao统一管理多样化的算力资源,包括CPU、GPU、NPU等。通过统一的资源接口和智能的调度策略,openFuyao打破了硬件之间的壁垒,使得企业可以充分利用每种硬件的优势。
  3. 性能卓越:openFuyao在多个方面实现了性能的突破。在离线混部技术使得资源利用率提升30%-50%,NUMA调度使得应用性能提升30%,AI推理加速使得推理吞吐量提升55%、时延降低40%。这些性能优势使得openFuyao在业界处于领先地位。
  4. 开箱即用:openFuyao提供了完整的工具链、监控体系和应用市场。用户无需进行复杂的配置就可以快速部署和使用openFuyao,大大降低了使用门槛。
  5. 生态开放:openFuyao兼容云原生的主流技术栈,包括Kubernetes、Helm、Prometheus等。同时,openFuyao支持社区共建,欢迎开发者贡献代码、文档和最佳实践。

未来展望

openFuyao的未来发展方向是多维度的:

更强的算力调度能力:我们将支持更多样化的硬件,包括新型的加速器和处理器。我们还将开发更智能的调度策略,能够根据应用的特性和硬件的特性进行最优的调度决策。

更深的AI融合:我们将构建从训练到推理的全栈AI原生平台。我们将优化大模型的训练性能,支持分布式训练和混合精度训练。我们还将支持联邦学习,使得多个企业可以进行协同学习。

更广的生态覆盖:我们将丰富应用市场的组件库,提供更多的垂直行业解决方案。我们还将与更多的硬件厂商和软件伙伴合作,构建更加繁荣的生态。

更优的用户体验:我们将实现智能化的运维和自动化的管理。系统将能够自动发现问题、自动修复故障、自动优化资源配置。这将大大降低用户的运维成本。

邀请与展望

openFuyao社区欢迎广大开发者、企业用户、硬件厂商、软件伙伴的参与和贡献。无论你是想要使用openFuyao来构建自己的算力平台,还是想要参与openFuyao的开发,或者想要基于openFuyao开发自己的产品和服务,openFuyao社区都为你敞开大门。

我们相信,通过社区的共同努力,我们可以构建一个繁荣的云原生+AI原生开源生态,为全球的企业和开发者提供最先进的算力管理解决方案。让我们一起,为云原生和AI原生的未来而努力!

冒泡排序 bubble sort

作者 Crystal328
2025年12月15日 12:12

1. 冒泡排序过程

  • 假设,我们现在要将这个无序数组[1,5,3,2,6]从小到大来排列image.png

按冒泡排序的思想:

要把相邻的元素两两比较,当一个元素大于右侧相邻元素时,交换它们的位置;当一个元素小于或等于右侧相邻元素时,位置不变

第一轮:

image.png

第二轮:

image.png

第三轮:

image.png

第四轮

image.png

轮数 对比次数 确认元素个数 有序区个数
第1轮 4 1 1
第2轮 3 1 2
第3轮 2 1 3
第4轮 1 1 5
结论:
  • 元素交换轮数=数组长度-1
  • 每一轮交换次数=数组长度-当前交换轮

代码实现思路

  • 我们可以用 for 循环嵌套来实现,外部循环控制交换轮数
  • 内部循环用来实现每一轮的交换处理。先进行元素比较,如果元素大于右侧相邻相元素,则两元素位交换,如果不大于,则啥也不做
// 排序数组
var arr = [1, 5, 3, 2, 6];
// 数组长度
var len = arr.length;
// 外层for控制交换轮数
for (var i = 0; i < len - 1; i++) {
  // 内层for控制每一轮,元素交换次数处理
  for (var j = 0; j < len - i - 1; j++) {
    if (arr[j] > arr[j + 1]) {
      // 交换两元素位置
      var tmp; // 用来交换两个变量的中间变量
      tmp = arr[j];
      arr[j] = arr[j + 1];
      arr[j + 1] = tmp;
    }
  }
}
console.log(arr); // [1, 2, 3, 5, 6]

2. 冒泡排序优化1 --减少没必要的“轮次”

假设法:

  • 如果当前数组以及该是有序,它压根就不会进到交换
  • 假设数组一开始就是有序的,如果从未进入
     // 交换两元素位置
     var tmp; // 用来交换两个变量的中间变量
     tmp = arr[j];
     arr[j] = arr[j + 1];
     arr[j + 1] = tmp;

代表他是有序的

  • 每一轮循环时假设他是有序的 如果确实没进入交换代表是有序的,后面就不要再交换了

在每一轮开始时,默认打上 isSorted='有序' 标记,如果在这一轮交换中,数据一旦发生交换,就把 isSorted='无序',如果整轮交换中,都没有发生交换,那就表示数组是有序的了。我们就可以退出整个 for 循环的执行。

// 排序数组
var arr = [1, 5, 3, 2, 6];
// 数组长度
var len = arr.length;
var isSorted;
// 外层for控制交换轮数
for (var i = 0; i < len - 1; i++) {
   isSorted = true; // 假设当前数组是有序的
  // 内层for控制每一轮,元素交换次数处理
  for (var j = 0; j < len - i - 1; j++) {
    if (arr[j] > arr[j + 1]) {
      // 交换两元素位置
      var tmp; // 用来交换两个变量的中间变量
      tmp = arr[j];
      arr[j] = arr[j + 1];
      arr[j + 1] = tmp;
   //如果当前数组以及该是有序,它压根就不会进到交换
       isSorted = false;
    }
  }
  if(isSorted) {
      break;
  }
}
console.log(arr); // [1, 2, 3, 5, 6]

image.png

优化的价值

  • 最好情况(原本有序):
    时间复杂度从 O(n²) → O(n)
  • 实际项目中,对“几乎有序”的数据非常友好

3. 冒泡排序优化 2 -- 减少没必要的“比较”

  • 动态缩小“无序区”的右边界
  • 记录每一轮最后一次交换元素的位置,该位置为无序列表的边界,再往右就是有序区了
  • 每一轮比较,比较到上一轮元素最后一次交换的位置(即无序列表的边界)就不再比较了。
变量 解决的问题
isSorted 还要不要继续排序
sortBorder 内层循环跑到哪里为止
index 下一轮新的无序边界
// 排序数组
var arr = [98, 2, 3, 45, 4, 1, 5, 78, 6, 7, 8, 20];
// 数组长度
var len = arr.length;
// 当前是否是有序的
var isSorted;
// 有序的边界
var sortBorder = len - 1; //初始值
// 记录每一轮最后一次交换的值,确定下一次循有序边界
var index;

// 外层for控制交换轮数
for (var i = 0; i < len - 1; i++) {
    // 内层for控制每一轮,元素交换次数处理
    isSorted = true; // 有序标记,每轮开始默认为有序,如果一旦发生交换,就会变成flag=false,无序
    for (var j = 0; j < sortBorder; j++) {
        if (arr[j] > arr[j + 1]) {
            // 交换两元素位置
            var tmp; // 用来交换两个变量的中间变量
            tmp = arr[j];
            arr[j] = arr[j + 1];
            arr[j + 1] = tmp;
            isSorted = false;
  // 把无序列表的边界,更新为最后一次交换元素的位置
            index = j;
        }
    }
    // 如果无序,记录上一次最后一次交换的元素下标
    if (!isSorted) {
        sortBorder = index;
    }
    // 这一轮多次交换下来,flag没有变为false,说明没有发生元素交换,此时数组已是有序的
    if (isSorted) {
        break; // 退出最外层for循环
    }
}
console.log(arr);

image.png

  • 尾部有序的数据越多,收益越大
  • 对“部分有序、局部乱序”的数组特别友好
  • 冒泡排序从“教学算法”,进化成了一个还算能用的基础排序

【节点】[Adjustment-Saturation节点]原理解析与实际应用

作者 SmalBox
2025年12月15日 11:34

【Unity Shader Graph 使用与特效实现】专栏-直达

饱和度节点是Unity通用渲染管线(URP)中Shader Graph的重要组成部分,专门用于调节颜色的鲜艳程度。该节点通过数学运算实现色彩空间的转换,能够在维持色调与明度不变的前提下,精确控制颜色的纯度。在游戏开发中,饱和度节点广泛应用于材质动态调节、后处理效果以及视觉反馈系统,为场景渲染提供丰富的色彩表现力。

饱和度调整基于HSL色彩空间理论,通过分离亮度分量来实现。当饱和度值为0时,颜色完全转为灰度;值为1时保持原色;大于1时则增强色彩鲜艳度。这种非线性调节方式符合人类视觉感知特性,有效避免了传统线性插值可能导致的色彩失真问题。

节点结构与端口配置

饱和度节点采用标准的三端口设计,支持灵活的连接方式:

  • In端口:输入类型为Vector 3,接收RGB颜色值作为处理对象。该端口可连接纹理采样节点、颜色混合节点,或直接输入常量值。
  • Saturation端口:输入类型为Float,用于控制饱和度调整的强度。参数范围通常为[0, 2],其中0表示完全去饱和,1为原始状态,2为双倍饱和度。
  • Out端口:输出类型为Vector 3,返回处理后的颜色值。该端口可连接至材质主色、光照模型或后续处理节点。

节点内部采用基于亮度的饱和度算法,通过以下步骤完成颜色转换:

  1. 计算输入颜色的亮度分量(luma)
  2. 根据饱和度参数在原始颜色与亮度分量之间进行插值
  3. 输出处理后的颜色向量

核心算法原理

饱和度节点的实现基于感知亮度计算与线性插值技术,其核心算法包括以下关键步骤:

亮度分量计算

采用标准感知亮度系数计算输入颜色的亮度值:

float luma = dot(In, float3(0.2126729, 0.7151522, 0.0721750));

该系数源自CIE 1931色彩空间标准,反映了人眼对不同颜色通道的敏感度差异。其中绿色通道权重最高(0.7151522),红色通道次之(0.2126729),蓝色通道最低(0.0721750)。

饱和度调整

通过线性插值实现饱和度控制:

Out = luma.xxx + Saturation * (In - luma.xxx);

该公式可理解为在原始颜色与对应灰度值之间进行插值。当Saturation为0时,结果完全为luma分量;为1时保持原色;大于1时则增强色彩鲜艳度。

数值稳定性处理

在实际实现中,通常会对中间结果进行范围限制:

Out = saturate(luma + Saturation * (In - luma));

saturate函数确保输出值位于[0,1]范围内,避免因计算误差导致颜色溢出。

实际应用场景

饱和度节点在游戏开发与实时渲染中具有广泛的应用价值:

材质动态调整

  • 环境适应系统:根据游戏时间或天气条件动态调整场景饱和度
  • 角色状态反馈:通过饱和度变化表现角色生命值、能量状态等
  • 季节变换效果:模拟不同季节的色彩特征

后处理效果

  • 电影风格渲染:创建低饱和度或高饱和度的特殊视觉效果
  • 视觉焦点引导:通过局部饱和度调整引导玩家注意力
  • 风格化处理:实现卡通渲染、水墨画等艺术风格

视觉反馈系统

  • 界面状态指示:利用饱和度变化表示UI元素的状态
  • 环境交互反馈:表现角色与环境的互动效果
  • 特殊事件提示:通过颜色变化强调重要游戏事件

节点连接与参数配置

基础连接方式

  1. 将纹理采样节点的RGB输出连接到In端口
  2. 使用浮点常量或材质参数控制Saturation值
  3. 将Out端口连接至材质主色或光照模型

高级配置技巧

  • 动态控制:结合Time节点实现饱和度动画效果
  • 条件分支:使用Branch节点根据游戏状态选择不同饱和度
  • 区域控制:通过Voronoi节点实现局部饱和度调整

参数范围建议

  • Saturation值:推荐范围[0, 2],超出范围可能导致颜色失真
  • 输入颜色:确保在[0,1]范围内,避免因输入值异常导致计算错误
  • 性能优化:对静态物体使用预计算参数,减少实时计算量

性能优化与最佳实践

计算优化策略

  • 简化亮度计算:在移动平台使用近似系数(如0.33, 0.33, 0.33)
  • 预计算参数:对静态物体在编辑阶段预计算饱和度值
  • LOD控制:根据距离简化饱和度计算精度

内存优化建议

  • 避免临时变量:优化中间计算结果存储
  • 重用计算结果:在多个节点间共享亮度分量
  • 控制精度:根据平台特性选择合适的数据类型

调试技巧

  • 可视化中间结果:通过自定义节点显示luma分量
  • 参数范围检查:添加saturate函数确保数值稳定性
  • 性能分析:使用Shader Profiler评估节点开销

常见问题与解决方案

颜色失真问题

  • 现象:调整后出现色彩偏移或异常
  • 原因:输入颜色值超出[0,1]范围或Saturation值过大
  • 解决方案:添加saturate函数限制输入输出范围

性能瓶颈

  • 现象:使用后帧率下降明显
  • 原因:过度复杂的饱和度计算或频繁调用
  • 解决方案:简化计算流程,减少实时计算量

视觉效果不一致

  • 现象:在不同光照条件下效果差异大
  • 原因:未考虑光照对颜色的影响
  • 解决方案:在光照计算后应用饱和度调整

高级应用技巧

结合其他节点

  • 与噪声节点配合:创建自然饱和度波动效果
  • 使用梯度噪声:实现区域化饱和度控制
  • 结合对比度节点:构建完整的颜色分级管线

自定义实现

  • 扩展节点功能:通过HLSL代码实现特殊饱和度算法
  • 创建子图:封装复杂饱和度控制逻辑
  • 开发自定义函数:添加新的饱和度调整模式

展望

随着实时渲染技术的持续发展,饱和度节点将在虚拟现实、电影化渲染以及跨平台开发中发挥更为重要的作用。

未来,饱和度节点可能朝以下方向发展:

  • 智能饱和度调整:基于场景内容自动优化参数
  • 物理精确的饱和度模型:更符合真实世界的色彩表现
  • AI驱动的风格化处理:通过机器学习实现艺术风格转换

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

Cursor 的 Debug模式的核心理念和使用流程

作者 王小酱
2025年12月15日 11:28

传统的AI代码修复往往基于代码静态分析就直接给出修复方案,但常常因为缺乏运行时信息而失败。Debug模式强制要求先获取运行时证据,再进行修复,避免盲目猜测。

使用流程

Debug模式遵循一个系统化的工作流程:

  1. 生成假设阶段

    • 针对bug生成3-5个精确的假设
    • 每个假设都要详细说明可能的根因
  2. 插桩收集证据

    • 在代码的关键位置插入日志(instrumentation)

    • 日志会自动发送到:http://127.0.0.1:7243/ingest/0fc3ea5d-6e07-48ff-823d-2a041d2001cc

    • 并写入到:/Users/oker/meili/qiang.wang1_dacs_at_okg.com/108/Documents/WorkSpace/devops/.cursor/debug.log

    • 典型的日志插入点包括

      • 函数入口和出口
      • 关键操作前后的值
      • 分支执行路径
      • 可疑的边界情况
      • 状态变化
  3. 重现问题

    • 要求用户按照给定步骤重现bug
    • UI会提供"Proceed"按钮,无需手动回复
  4. 分析日志

    • 读取日志文件中的NDJSON格式数据
    • 逐一评估每个假设:CONFIRMED(确认)/ REJECTED(拒绝)/ INCONCLUSIVE(不确定)
    • 基于日志证据找到根因
  5. 实施修复

    • 仅在有100%把握时修复
    • 保留日志插桩(不移除)
  6. 验证修复

    • 要求用户再次运行
    • 对比修复前后的日志
    • 必须有日志证据证明修复成功
  7. 清理

    • 只有在验证成功且用户确认后,才移除日志插桩
    • 提供问题总结和修复说明

适用场景

Debug模式特别适合以下场景:

  1. ✅ 运行时行为问题
    • 程序执行流程不符合预期
    • 状态变化追踪
    • 异步操作时序问题
  2. ✅ 难以复现的Bug
    • 间歇性出现的问题
    • 特定条件下才触发的bug
    • 多组件交互导致的问题
  3. ✅ 复杂逻辑问题
    • 多层条件判断
    • 复杂的数据处理流程
    • 状态机问题
  4. ✅ 性能问题
    • 追踪函数调用次数
    • 监控数据变化频率
    • 定位性能瓶颈
  5. ✅ 数据流追踪
    • Props传递问题
    • 状态更新不生效
    • 数据转换错误

能解决什么问题

  1. 🎯 避免盲目修复
    • 传统方式:看到报错 → 猜测原因 → 直接改代码 → 可能引入新bug
    • Debug模式:插桩 → 收集证据 → 分析根因 → 精准修复
  2. 🎯 提供可追溯的证据链
    • 每个修复决策都有日志证据支撑
    • 可以回溯到具体的执行路径
    • 避免"看起来应该能修好"的主观判断
  3. 🎯 确保修复有效性
    • 修复后的验证基于实际运行数据
    • 对比前后日志确认问题已解决
    • 避免假阳性(看似修好实则未修好)
  4. 🎯 系统化定位问题
    • 通过多个假设并行测试
    • 即使第一轮假设全部被拒绝,也能根据日志生成新假设
    • 迭代式逼近真正的根因

典型的日志示例

JavaScript/TypeScript中的插桩示例:

// #region agent log
fetch('http://127.0.0.1:7243/ingest/0fc3ea5d-6e07-48ff-823d-2a041d2001cc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'PageLayout.tsx:176',message:'headerConfig value',data:{headerConfig},timestamp:Date.now(),sessionId:'debug-session',hypothesisId:'H1'})}).catch(()=>{});
// #endregion

日志会以NDJSON格式记录:

{"id":"log_123","timestamp":1733456789000,"location":"PageLayout.tsx:176","message":"headerConfig value","data":{"headerConfig":{...}},"sessionId":"debug-session","hypothesisId":"H1"}

与传统调试的区别

  • 传统调试
    • 手动打断点
    • 需要开发环境
    • 单次运行
    • 依赖经验猜测
    • 修复后难以验证
  • Debug模式
    • 自动插桩收集日志
    • 可在生产环境收集数据
    • 可多次对比分析
    • 基于证据系统分析
    • 强制验证修复有效性

总结

Debug模式的核心价值在于:用运行时证据代替静态猜测,用系统化流程代替经验主义,确保每一次修复都有可靠的数据支撑。 这种方法虽然可能需要更多轮次的迭代,但能显著提高修复的准确性和可靠性。

国际化语言包与Excel自动化双向转换方案

2025年12月15日 11:23

一、方案背景

在国际化项目开发过程中,多语言资源(locales语言包)的管理通常要经历下面的流程:

  1. 开发人员通过机翻初步整理好一份语言包
  2. 把语言包转成Excel交付给翻译团队人工翻译
  3. 把人工翻译好的Excel重新转换为语言包且需保证其正确性

这种手动转换的方式存在效率低下、容易出错的问题。因此需要设计一套自动转换方案,实现语言包和Excel的自动化双向转换。

二、方案目标

  1. 实现语言包和Excel的自动化双向转换。
  2. Excel需包含"功能模块"列,方便定位语言包中字段对应页面的具体位置。
  3. 若存在中文语言包,转换成Excel时把中文列放在其它语言列前面,方便翻译对照。
  4. 基于TypeScript的项目,需提供完整的类型支持。

三、技术选型与目录结构设计

3.1 技术选型

  • 核心框架:Vue3 + Vite + TypeScript
  • 国际化方案:vue-i18n
  • 语言包格式:YAML(支持注释功能)
  • 表格处理:xlsx 库
  • 自动化工具:Node.js脚本,脚本可以考虑大模型实现

3.2 目录结构

├── package.json                  # 项目依赖与脚本配置
├── vite.config.ts                # Vite配置文件
├── build/
│   └── vite-plugin-i18n-types.ts # 自动生成类型声明的Vite插件
├── scripts/
│   └── i18n/
│       ├── yaml2xlsx.js          # YAML转Excel脚本
│       ├── xlsx2yaml.js          # Excel转YAML脚本
│       └── output/
│           ├── translations.xlsx # 生成的Excel文件
│           └── locales/          # 从Excel生成的语言包
├── types/
│   └── i18n.d.ts                 # 自动生成的类型声明文件
└── src/
    ├── i18n/
    │   ├── index.ts              # i18n配置入口
    │   └── locales/
    │       ├── zh-CN.yaml        # 中文语言包
    │       ├── en-US.yaml        # 英文语言包
    │       └── ...               # 其他语言包
    └── ...                       # 其他业务代码

四、核心实现方案

4.1 语言包设计

采用yaml文件管理语言包。

为什么不用更常用的json格式? 脚本在把语言包转换为Excel文件时,需要根据一些标记(如注释)来生成【功能模块】列,而json格式不支持注释。 js或ts也支持注释,但属于特定开发语言的格式,而非数据序列化格式,不够通用。 yaml是支持注释、开发语言无关的数据序列化格式,但对于TypeScript类型不友好,需要额外支持,不过这个问题解决了,后面会提到。所以最终选择了yaml格式。

zh-CN.yaml示例:

# 首页
home:
  # 导航栏
  navbar:
    title: '首页'
# 登录页
login:
  # 表单
  form:
    username: '用户名'
    password: '密码'

en-US.yaml示例:

# 首页
home:
  # 导航栏
  navbar:
    title: 'Home'
# 登录页
login:
  # 表单
  form:
    username: 'Username'
    password: 'Password'

4.2 YAML转Excel实现(yaml2xlsx.js)

import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import yaml from 'yaml'
import XLSX from 'xlsx'
import yargs from 'yargs'
import { hideBin } from 'yargs/helpers'

// 获取当前文件的目录路径
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

/**
 * 反转义YAML单引号字符串中的转义序列
 * @param {string} str - 要反转义的字符串
 * @returns {string} 反转义后的字符串
 */
function unescapeSingleQuotes(str) {
  // 将两个连续的单引号转义为一个单引号
  return str.replace(/''/g, "'")
}

/**
 * 解析YAML文件并提取注释路径
 * @param {string} filePath - YAML文件路径
 * @returns {Object} 解析后的数据对象,包含键值对和模块路径
 */
function parseYamlWithComments(filePath) {
  // 读取YAML文件内容
  const content = fs.readFileSync(filePath, 'utf8')
  // 按行分割内容
  const lines = content.split('\n')
  const result = {}
  // 注释栈,用于存储当前层级的注释
  const commentStack = []

  // 当前路径栈和注释路径栈
  let currentPath = []
  let currentCommentPath = []

  // 遍历每一行
  lines.forEach((line, index) => {
    const trimmedLine = line.trim()

    // 如果是注释行
    if (trimmedLine.startsWith('#')) {
      // 提取注释内容(去掉#号并去除前后空格)
      const comment = trimmedLine.substring(1).trim()
      if (comment) {
        // 将注释压入栈中
        commentStack.push(comment)
      }
      return // 注释行不进行后续处理
    }

    // 如果是键值对行(包含冒号且不是注释)
    if (trimmedLine.includes(':') && !trimmedLine.startsWith('#')) {
      // 提取键名
      const key = trimmedLine.split(':')[0].trim()
      // 计算缩进级别(通过行首空格数量)
      const indent = line.match(/^\s*/)[0].length

      // 计算当前层级(假设使用2空格缩进)
      const level = indent / 2

      // 根据层级更新路径栈(移除超出当前层级的部分)
      currentPath = currentPath.slice(0, level)
      currentCommentPath = currentCommentPath.slice(0, level)

      // 将当前键添加到路径栈
      currentPath.push(key)

      // 处理注释路径
      if (commentStack.length > 0) {
        // 如果有注释,将其添加到注释路径栈
        currentCommentPath.push(commentStack.pop())
      } else if (currentCommentPath.length < level && currentCommentPath.length > 0) {
        // 如果没有注释但需要继承父级注释,使用父级注释
        currentCommentPath.push(currentCommentPath[currentCommentPath.length - 1])
      }

      // 检查是否是叶子节点(包含值的节点)
      const valueMatch = trimmedLine.match(/:\s*(.+)$/)
      if (valueMatch && valueMatch[1].trim()) {
        // 提取值并去除前后空格
        let value = valueMatch[1].trim()

        // 去除可能存在的单引号(YAML字符串有时会用引号包裹)
        if (value.startsWith("'") && value.endsWith("'")) {
          value = value.substring(1, value.length - 1)
          // 反转义单引号(将两个单引号转义为一个单引号)
          value = unescapeSingleQuotes(value)
        } else if (value.startsWith('"') && value.endsWith('"')) {
          value = value.substring(1, value.length - 1)
          // 如果需要,也可以处理双引号的转义
        }

        // 构建完整的键路径(用点号连接)
        const fullKey = currentPath.join('.')
        // 构建模块路径(用斜杠连接注释)
        const modulePath = currentCommentPath.join('/')

        // 将结果存储到对象中
        result[fullKey] = {
          value: value,
          module: modulePath,
        }
      }
    }

    // 重置注释栈(注释只对下一行有效)
    if (!trimmedLine.startsWith('#') && trimmedLine !== '') {
      commentStack.length = 0
    }
  })

  return result
}

/**
 * 设置工作表的列宽
 * @param {Object} worksheet - XLSX工作表对象
 */
function setColumnWidths(worksheet) {
  // 定义列宽配置
  const colWidths = [
    { wch: 30 }, // 功能模块列宽:30字符
    { wch: 40 }, // key列宽:40字符
  ]

  // 获取工作表的范围
  const range = XLSX.utils.decode_range(worksheet['!ref'])
  const numCols = range.e.c - range.s.c + 1

  // 为语言列设置固定宽度(从第三列开始)
  for (let i = 2; i < numCols; i++) {
    colWidths.push({ wch: 20 }) // 语言列宽:20字符
  }

  // 将列宽配置应用到工作表
  worksheet['!cols'] = colWidths
}

/**
 * 设置行高
 * @param {Object} worksheet - XLSX工作表对象
 */
function setRowHeights(worksheet) {
  // 获取工作表的范围
  const range = XLSX.utils.decode_range(worksheet['!ref'])

  // 遍历每一行设置行高
  for (let row = range.s.r; row <= range.e.r; row++) {
    // 确保行配置数组存在
    if (!worksheet['!rows']) worksheet['!rows'] = []
    // 设置行高,标题行(第0行)稍高一些
    worksheet['!rows'][row] = { hpt: row === 0 ? 25 : 20 }
  }
}

/**
 * 对语言进行排序,确保zh-CN在最前面
 * @param {Array} languages - 语言代码数组
 * @returns {Array} 排序后的语言数组
 */
function sortLanguages(languages) {
  return [...languages].sort((a, b) => {
    // zh-CN始终排在最前面
    if (a === 'zh-CN') return -1
    if (b === 'zh-CN') return 1
    // 其他语言按字母顺序排序
    return a.localeCompare(b)
  })
}

/**
 * 将目录下的所有YAML文件转换为XLSX格式
 * @param {string} inputDir - 输入目录路径(包含YAML文件)
 * @param {string} outputFile - 输出文件路径(XLSX文件)
 */
function convertYamlDirToXlsx(inputDir, outputFile) {
  try {
    // 确保输入目录存在
    if (!fs.existsSync(inputDir)) {
      throw new Error(`输入目录不存在: ${inputDir}`)
    }

    // 读取目录中的所有文件
    const files = fs.readdirSync(inputDir)
    // 过滤出YAML文件(支持.yaml和.yml扩展名)
    const yamlFiles = files.filter((file) => file.endsWith('.yaml') || file.endsWith('.yml'))

    // 如果没有找到YAML文件,提示并退出
    if (yamlFiles.length === 0) {
      console.log('未找到YAML文件')
      return
    }

    // 收集所有语言的数据
    const allData = {}
    const languages = []

    // 处理每个YAML文件
    yamlFiles.forEach((file) => {
      // 构建完整的文件路径
      const filePath = path.join(inputDir, file)
      // 从文件名提取语言代码(去掉扩展名)
      const language = path.basename(file, path.extname(file))
      // 将语言代码添加到语言数组
      languages.push(language)

      // 解析YAML文件
      const parsedData = parseYamlWithComments(filePath)

      // 将解析后的数据按key组织
      Object.entries(parsedData).forEach(([key, data]) => {
        // 如果该key尚未存在,初始化数据结构
        if (!allData[key]) {
          allData[key] = {
            module: data.module,
            translations: {},
          }
        }
        // 存储该语言的翻译值
        allData[key].translations[language] = data.value
      })
    })

    // 对语言进行排序,确保zh-CN在最前面
    const sortedLanguages = sortLanguages([...new Set(languages)])

    // 准备XLSX数据
    const worksheetData = []

    // 添加表头行
    const headers = ['功能模块', 'key', ...sortedLanguages]
    worksheetData.push(headers)

    // 添加数据行
    Object.entries(allData).forEach(([key, data]) => {
      // 创建新行:功能模块和key
      const row = [data.module, key]

      // 按排序后的语言顺序添加翻译值
      sortedLanguages.forEach((language) => {
        // 如果该语言有翻译值则添加,否则为空字符串
        row.push(data.translations[language] || '')
      })

      // 将行添加到工作表数据
      worksheetData.push(row)
    })

    // 创建新的workbook和工作表
    const workbook = XLSX.utils.book_new()
    const worksheet = XLSX.utils.aoa_to_sheet(worksheetData)

    // 设置列宽和行高
    setColumnWidths(worksheet)
    setRowHeights(worksheet)

    // 添加样式:标题行加粗并居中对齐
    const headerRange = XLSX.utils.decode_range(worksheet['!ref'])
    for (let col = headerRange.s.c; col <= headerRange.e.c; col++) {
      const cellAddress = XLSX.utils.encode_cell({ r: 0, c: col })
      // 如果单元格存在,设置样式
      if (worksheet[cellAddress]) {
        worksheet[cellAddress].s = {
          font: { bold: true },
          alignment: { vertical: 'center', horizontal: 'center' },
        }
      }
    }

    // 添加worksheet到workbook
    XLSX.utils.book_append_sheet(workbook, worksheet, '翻译数据')

    // 确保输出目录存在
    const outputDir = path.dirname(outputFile)
    if (!fs.existsSync(outputDir)) {
      // 递归创建目录
      fs.mkdirSync(outputDir, { recursive: true })
    }

    // 写入文件
    XLSX.writeFile(workbook, outputFile)

    // 输出成功信息
    console.log(`✅ 转换完成!`)
    console.log(`📁 输入目录: ${inputDir}`)
    console.log(`📄 输出文件: ${outputFile}`)
    console.log(`🌐 处理了 ${yamlFiles.length} 个语言文件: ${sortedLanguages.join(', ')}`)
    console.log(`📊 列顺序: 功能模块, key, ${sortedLanguages.join(', ')}`)
  } catch (error) {
    // 错误处理
    console.error('❌ 转换过程中发生错误:', error.message)
    process.exit(1)
  }
}

// 配置命令行参数
const argv = yargs(hideBin(process.argv))
  .option('input', {
    alias: 'i',
    type: 'string',
    description: 'YAML文件所在目录路径',
    default: './locales',
  })
  .option('output', {
    alias: 'o',
    type: 'string',
    description: '输出的XLSX文件路径',
    default: './output/translations.xlsx',
  })
  .option('config', {
    alias: 'c',
    type: 'string',
    description: '配置文件路径',
  })
  .help() // 添加帮助信息
  .alias('help', 'h') // 设置帮助命令别名
  .version() // 添加版本信息
  .alias('version', 'v').argv // 设置版本命令别名 // 解析命令行参数

/**
 * 主函数
 */
function main() {
  // 如果有配置文件,读取配置文件
  if (argv.config) {
    try {
      // 解析配置文件路径
      const configPath = path.resolve(process.cwd(), argv.config)
      // 读取并解析配置文件
      const config = JSON.parse(fs.readFileSync(configPath, 'utf8'))

      // 合并配置参数(命令行参数优先于配置文件)
      const finalInput = argv.input !== './locales' ? argv.input : config.input || './locales'
      const finalOutput =
        argv.output !== './output/translations.xlsx'
          ? argv.output
          : config.output || './output/translations.xlsx'

      // 执行转换
      convertYamlDirToXlsx(finalInput, finalOutput)
    } catch (error) {
      console.error('❌ 读取配置文件失败:', error.message)
      process.exit(1)
    }
  } else {
    // 直接使用命令行参数
    convertYamlDirToXlsx(argv.input, argv.output)
  }
}

main()

4.3 Excel转YAML实现(xlsx2yaml.js)

import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import XLSX from 'xlsx'
import yargs from 'yargs'
import { hideBin } from 'yargs/helpers'

// 获取当前文件的目录路径
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

/**
 * 读取XLSX文件并解析为结构化数据
 * @param {string} filePath - XLSX文件路径
 * @returns {Object} 包含语言数据和注释映射的对象
 */
function parseXlsxFile(filePath) {
  // 读取XLSX文件
  const workbook = XLSX.readFile(filePath)
  // 获取第一个工作表
  const worksheet = workbook.Sheets[workbook.SheetNames[0]]
  // 将工作表数据转换为JSON
  const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 })

  if (jsonData.length < 2) {
    throw new Error('XLSX文件数据不足,至少需要表头和数据行')
  }

  // 提取表头(第一行)
  const headers = jsonData[0]
  // 验证表头结构
  if (headers[0] !== '功能模块' || headers[1] !== 'key') {
    throw new Error('XLSX文件格式不正确,前两列应为"功能模块"和"key"')
  }

  // 提取语言列(从第三列开始)
  const languages = headers.slice(2)

  // 初始化数据结构
  const result = {
    languages: languages,
    data: {},
    comments: {}, // 存储注释映射关系
  }

  // 处理每一行数据(跳过表头)
  for (let i = 1; i < jsonData.length; i++) {
    const row = jsonData[i]
    if (!row || row.length < 3) continue

    const modulePath = row[0] // 功能模块路径
    const key = row[1] // 键路径

    // 为每种语言存储键值对
    languages.forEach((lang, index) => {
      const value = row[2 + index] || ''
      if (!result.data[lang]) {
        result.data[lang] = {}
      }

      // 使用点号分隔的键路径设置嵌套对象
      setNestedValue(result.data[lang], key, value)
    })

    // 处理注释映射
    if (modulePath) {
      // 将功能模块路径与键路径关联
      result.comments[key] = modulePath
    }
  }

  return result
}

/**
 * 根据点号分隔的路径设置嵌套对象的值
 * @param {Object} obj - 目标对象
 * @param {string} path - 点号分隔的路径
 * @param {*} value - 要设置的值
 */
function setNestedValue(obj, path, value) {
  const keys = path.split('.')
  let current = obj

  // 遍历路径,创建嵌套对象
  for (let i = 0; i < keys.length - 1; i++) {
    const key = keys[i]
    if (!current[key] || typeof current[key] !== 'object') {
      current[key] = {}
    }
    current = current[key]
  }

  // 设置最终的值
  current[keys[keys.length - 1]] = value
}

/**
 * 构建注释映射,将注释分配到正确的YAML层级
 * @param {Object} commentsMap - 原始的注释映射
 * @returns {Object} 按YAML层级组织的注释映射
 */
function buildHierarchicalComments(commentsMap) {
  const hierarchicalComments = {}

  // 遍历所有键和对应的注释路径
  Object.entries(commentsMap).forEach(([key, commentPath]) => {
    const keyParts = key.split('.')
    const commentParts = commentPath.split('/')

    // 为每个层级构建注释
    let currentPath = ''
    for (let i = 0; i < keyParts.length; i++) {
      // 构建当前路径
      currentPath = currentPath ? `${currentPath}.${keyParts[i]}` : keyParts[i]

      // 如果当前层级有对应的注释部分,则分配注释
      if (i < commentParts.length) {
        // 确保每个路径只分配一次注释(取第一个出现的)
        if (!hierarchicalComments[currentPath]) {
          hierarchicalComments[currentPath] = commentParts[i]
        }
      }
    }
  })

  return hierarchicalComments
}

/**
 * 转义字符串中的单引号,用于YAML单引号字符串
 * @param {string} str - 要转义的字符串
 * @returns {string} 转义后的字符串
 */
function escapeSingleQuotes(str) {
  // 将单引号转义为两个单引号
  return str.replace(/'/g, "''")
}

/**
 * 格式化YAML值,确保字符串使用单引号包裹并正确处理转义
 * @param {*} value - 要格式化的值
 * @returns {string} 格式化后的YAML值表示
 */
function formatYamlValue(value) {
  if (value === null || value === undefined) {
    return "''" // 空值用空字符串表示
  }

  if (typeof value === 'boolean') {
    return value.toString() // 布尔值直接输出
  }

  if (typeof value === 'number') {
    return value.toString() // 数字直接输出
  }

  if (typeof value === 'string') {
    // 空字符串用空字符串表示
    if (value === '') {
      return "''"
    }

    // 转义字符串中的单引号
    const escapedValue = escapeSingleQuotes(value)

    // 检查是否需要引号(包含特殊字符)
    const needsQuotes =
      /[:{}\[\],&*#?|<>=!%@`]/.test(value) ||
      value.trim() !== value ||
      value.includes('\n') ||
      value.includes('\t')

    if (needsQuotes) {
      return `'${escapedValue}'` // 需要引号的情况
    } else {
      // 简单字符串可以不加引号,但为了统一风格,我们仍然使用单引号
      return `'${escapedValue}'`
    }
  }

  // 其他类型(如对象、数组)不应该出现在这里
  return `'${escapeSingleQuotes(String(value))}'`
}

/**
 * 将对象转换为YAML格式字符串,并添加注释
 * @param {Object} data - 要转换的数据对象
 * @param {Object} commentsMap - 注释映射关系
 * @returns {string} YAML格式的字符串
 */
function objectToYamlWithComments(data, commentsMap) {
  // 构建层级化的注释映射
  const hierarchicalComments = buildHierarchicalComments(commentsMap)

  let yamlContent = ''
  let indentLevel = 0

  /**
   * 递归处理对象,生成带注释的YAML
   * @param {Object} obj - 当前处理的对象
   * @param {string} currentPath - 当前路径
   */
  function processObject(obj, currentPath = '') {
    const keys = Object.keys(obj)

    keys.forEach((key) => {
      const value = obj[key]
      const newPath = currentPath ? `${currentPath}.${key}` : key
      const indent = '  '.repeat(indentLevel)

      // 检查当前路径是否有注释
      if (hierarchicalComments[newPath]) {
        // 添加注释(缩进与当前层级相同)
        yamlContent += `${indent}# ${hierarchicalComments[newPath]}\n`
      }

      if (typeof value === 'object' && value !== null) {
        // 如果是嵌套对象,添加键并递归处理
        yamlContent += `${indent}${key}:\n`
        indentLevel++
        processObject(value, newPath)
        indentLevel--
      } else {
        // 如果是叶子节点,格式化值并添加键值对
        const formattedValue = formatYamlValue(value)
        yamlContent += `${indent}${key}: ${formattedValue}\n`
      }
    })
  }

  // 开始处理根对象
  processObject(data)
  return yamlContent
}

/**
 * 从XLSX文件生成YAML语言包
 * @param {string} inputFile - 输入的XLSX文件路径
 * @param {string} outputDir - 输出的YAML文件目录
 */
function convertXlsxToYaml(inputFile, outputDir) {
  try {
    // 确保输入文件存在
    if (!fs.existsSync(inputFile)) {
      throw new Error(`输入文件不存在: ${inputFile}`)
    }

    // 解析XLSX文件
    const parsedData = parseXlsxFile(inputFile)
    const { languages, data, comments } = parsedData

    console.log(`📊 检测到 ${languages.length} 种语言: ${languages.join(', ')}`)
    console.log(`📝 处理了 ${Object.keys(data[languages[0]] || {}).length} 个键值对`)

    // 确保输出目录存在
    if (!fs.existsSync(outputDir)) {
      fs.mkdirSync(outputDir, { recursive: true })
    }

    // 为每种语言生成YAML文件
    languages.forEach((lang) => {
      if (!data[lang]) {
        console.warn(`⚠️  语言 ${lang} 没有数据,跳过生成`)
        return
      }

      // 生成带注释的YAML内容
      const yamlContent = objectToYamlWithComments(data[lang], comments)

      // 构建输出文件路径
      const outputFile = path.join(outputDir, `${lang}.yaml`)

      // 写入文件
      fs.writeFileSync(outputFile, yamlContent, 'utf8')

      console.log(`✅ 生成 ${lang}.yaml`)
    })

    console.log(`🎉 转换完成!`)
    console.log(`📁 输出目录: ${outputDir}`)
  } catch (error) {
    console.error('❌ 转换过程中发生错误:', error.message)
    process.exit(1)
  }
}

// 配置命令行参数
const argv = yargs(hideBin(process.argv))
  .option('input', {
    alias: 'i',
    type: 'string',
    description: '输入的XLSX文件路径',
    default: './translations.xlsx',
  })
  .option('output', {
    alias: 'o',
    type: 'string',
    description: '输出的YAML文件目录',
    default: './locales',
  })
  .option('config', {
    alias: 'c',
    type: 'string',
    description: '配置文件路径',
  })
  .help()
  .alias('help', 'h')
  .version()
  .alias('version', 'v').argv

/**
 * 主函数
 */
function main() {
  // 如果有配置文件,读取配置文件
  if (argv.config) {
    try {
      const configPath = path.resolve(process.cwd(), argv.config)
      const config = JSON.parse(fs.readFileSync(configPath, 'utf8'))

      // 合并配置参数
      const finalInput =
        argv.input !== './translations.xlsx' ? argv.input : config.input || './translations.xlsx'
      const finalOutput = argv.output !== './locales' ? argv.output : config.output || './locales'

      convertXlsxToYaml(finalInput, finalOutput)
    } catch (error) {
      console.error('❌ 读取配置文件失败:', error.message)
      process.exit(1)
    }
  } else {
    // 直接使用命令行参数
    convertXlsxToYaml(argv.input, argv.output)
  }
}

main()

4.4 脚本命令配置(package.json)

脚本支持通过-i-o参数自定义输入输出路径。

{
  "scripts": {
    "i18n:yaml2xlsx": "node ./scripts/i18n/yaml2xlsx.js -i src/i18n/locales -o scripts/i18n/output/translation.xlsx",
    "i18n:xlsx2yaml": "node ./scripts/i18n/xlsx2yaml.js -i scripts/i18n/output/translation.xlsx -o scripts/i18n/output/locales"
  }
}

4.5 类型自动生成方案

ts默认是不支持导入yaml文件的,需要添加模块声明:

declare module '*.yaml' {
  const content: Record<string, any>
  export default content
}

这样就支持导入yaml文件了,但导入的所有ts类型都是Record<string, any>,使用vue-i18n时也没有类型提示:

// 无类型提示:login.?
t('login.form.username')

期望的目标是:导入的yaml语言包是类型安全的,使用时可以享受类型提示。手动根据语言包yaml文件生成ts类型声明文件是重复低效的,yaml是一个结构化的文件,根据yaml文件自动生成ts类型声明文件是可行的。方案可以参考unplugin-vue-components,编写一个vite插件,根据语言包yaml文件自动生成ts类型声明文件。

vite-plugin-i18n-types.ts

import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
import { resolve, dirname } from 'path'
import { parse } from 'yaml'
import type { Plugin } from 'vite'

export interface I18nTypePluginOptions {
  input: string | string[]
  output: string
  typeName?: string
  watch?: boolean
  build?: boolean
  // 模块声明的路径,如 '@/i18n/locales'
  modulePath?: string
}

function objectToTypeString(obj: any, indent = 2, level = 1): string {
  const spaces = ' '.repeat(indent * level)
  const entries = Object.entries(obj)

  if (entries.length === 0) return 'Record<string, any>'

  const result: string[] = []

  for (const [key, value] of entries) {
    if (typeof value === 'object' && value !== null) {
      const nestedType = objectToTypeString(value, indent, level + 1)
      result.push(`${spaces}${key}: ${nestedType}`)
    } else {
      result.push(`${spaces}${key}: string`)
    }
  }

  return `{
${result.join('\n')}
${' '.repeat(indent * (level - 1))}}`
}

export default function i18nTypePlugin(options: I18nTypePluginOptions): Plugin {
  const {
    input = 'src/i18n/locales/zh-CN.yaml',
    output = 'types/i18n.d.ts',
    typeName = 'I18nMessageSchema',
    watch = true,
    build = true,
    modulePath = '@/i18n/locales',
  } = options

  const inputPaths = Array.isArray(input) ? input : [input]
  const resolvedInputPaths = inputPaths.map((path) => resolve(process.cwd(), path))
  const resolvedOutputPath = resolve(process.cwd(), output)

  function generateTypes() {
    try {
      // 读取第一个输入文件(假设所有语言文件有相同的结构)
      const inputPath = resolvedInputPaths[0]
      if (!existsSync(inputPath)) {
        console.warn(`⚠️  File not found: ${inputPath}`)
        return
      }

      const yamlContent = readFileSync(inputPath, 'utf-8')
      const data = parse(yamlContent)
      const typeDefinition = objectToTypeString(data, 2)

      // 构建完整的类型声明内容
      let content = `declare type ${typeName} = ${typeDefinition}\n`

      // 添加模块声明
      if (modulePath) {
        content += `
declare module '${modulePath}/*.yaml' {
  const content: ${typeName}
  export default content
}

declare module '${modulePath}/*.yml' {
  const content: ${typeName}
  export default content
}
`
      }

      // 确保输出目录存在
      const outputDir = dirname(resolvedOutputPath)
      if (!existsSync(outputDir)) {
        mkdirSync(outputDir, { recursive: true })
      }

      writeFileSync(resolvedOutputPath, content, 'utf-8')
      console.log(`✅ Generated i18n types: ${resolvedOutputPath}`)
    } catch (error) {
      console.error('❌ Failed to generate i18n types:', error)
    }
  }

  return {
    name: 'vite-plugin-i18n-types',

    buildStart() {
      if (build) {
        generateTypes()
      }
    },

    configureServer(server) {
      if (watch) {
        // 首次生成
        setTimeout(() => generateTypes(), 100)

        // 监听所有输入文件
        resolvedInputPaths.forEach((inputPath) => {
          if (existsSync(inputPath)) {
            server.watcher.add(inputPath)
            console.log(`👀 Watching: ${inputPath}`)
          }
        })

        // 监听文件变化
        server.watcher.on('change', (changedPath) => {
          if (resolvedInputPaths.some((path) => resolve(path) === resolve(changedPath))) {
            console.log(`📄 i18n file changed: ${changedPath}`)
            generateTypes()

            // 发送 HMR 更新
            server.ws.send({
              type: 'full-reload',
              path: '*',
            })
          }
        })
      }
    },

    buildEnd() {
      if (build && !watch) {
        generateTypes()
      }
    },
  }
}

4.6 Vite配置集成

vite.config.ts

import { defineConfig } from 'vite'
import i18nTypePlugin from './build/vite-plugin-i18n-types'

export default defineConfig({
  plugins: [
    i18nTypePlugin({
      // 使用哪个语言包为基准用来生成.d.ts文件
      input: 'src/i18n/locales/zh-CN.yaml',
      // 用于生成.d.ts文件中的declare module '@/i18n/locales/*.yaml'语句
      modulePath: '@/i18n/locales',
      // .d.ts文件生成位置
      output: 'types/i18n.d.ts',
      // .d.ts文件中的MessageSchema类型的名称
      typeName: 'I18nMessageSchema',
      watch: true,
      build: true,
    }),
  ],
})

自动生成types/i18n.d.ts文件

declare type I18nMessageSchema {
  home: {
    navbar: {
      title: string
    }
  }
  login: {
    form: {
      username: string
      password: string
    }
  }
}

declare module '@/i18n/locales/*.yaml' {
  const content: I18nMessageSchema
  export default content
}

declare module '@/i18n/locales/*.yml' {
  const content: I18nMessageSchema
  export default content
}

上面的declare module语句不仅保证了引入的语言包yaml文件的类型安全,而且只限定了@/i18n/locales目录下的文件,不影响其它目录下的文件引入。

需要配合tsconfig.json:

{
  "include": ["types/**/*"],
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

4.7 i18n配置实现

src/i18n/index.ts

import { createI18n, useI18n } from 'vue-i18n'
// zhCN和enUS都是I18nMessageSchema类型
import zhCN from '@/i18n/locales/zh-CN.yaml'
import enUS from '@/i18n/locales/en-US.yaml'
export type LangCodes = 'zh-CN' | 'en-US'
const messages = {
  'zh-CN': zhCN,
  'en-US': enUS,
}
const i18n = createI18n<[I18nMessageSchema], LangCodes, false>({
  legacy: false,
  locale: 'zh-CN', // 默认中文
  fallbackLocale: 'zh-CN',
  messages,
})
export default i18n
export const useGlobalI18n = () =>
  useI18n<{ message: I18nMessageSchema }>({
    useScope: 'global',
  })

在其它文件中使用:

const { t } = useGlobalI18n()
// 享受完整的类型提示
t('home.navbar.title')

五、使用流程说明

  1. YAML 转 Excel:执行pnpm i18n:yaml2xlsx命令,这会扫描src/i18n/locales目录下的所有yaml文件,把它们转换成excel文件输出到scripts/i18n/output/translation.xlsx,语言包转换后的结果示例:
功能模块 key zh-CN en-US
首页/导航栏 home.navbar.title 首页 Home
登录页/表单 login.form.username 用户名 Username
登录页/表单 login.form.password 密码 Password
  1. Excel 转 YAML: 执行pnpm i18n:xlsx2yaml命令,这会把scripts/i18n/output/translation.xlsx文件转换成语言包文件输出到scripts/i18n/output/locales目录下,而不是直接覆盖原语言包文件,这样方便开发者校对生成结果后再覆盖源码,防止可能发生的错误。

为什么微应用不需要配置 try_files?

作者 Syron
2025年12月15日 10:54

📚 核心概念:SPA 路由模式

1. 传统 SPA 应用的路由流程

css
用户访问 → Nginx → HTML → 前端路由

示例:独立运行的 React 应用

nginx
# 主应用(独立 SPA)
location / {
    root /data/apps/frontend/main;
    try_files $uri $uri/ /index.html;  # ✅ 需要 try_files
}

为什么需要 try_files

bash
# 用户直接访问深层路由
https://example.com/users/profile

# Nginx 处理流程:
1. 查找文件:/data/apps/frontend/main/users/profile → ❌ 不存在
2. 查找目录:/data/apps/frontend/main/users/profile/ → ❌ 不存在
3. Fallback:返回 /index.html → ✅ 让前端路由接管

# 浏览器接收到 index.html
# React Router 解析 URL:/users/profile
# 渲染对应组件

没有 try_files 会发生什么?

bash
# 用户访问:https://example.com/users/profile
# Nginx:文件不存在 → 404 错误 ❌
# 前端路由永远不会执行

🎯 Qiankun 微前端架构的不同之处

2. Qiankun 的路由职责划分

scss
主应用              微应用
  ↓                  ↓
前端路由           静态资源
控制页面          (JS/CSS/图片)

关键区别:

  • ✅ 主应用:负责所有路由控制(包括微应用路由)
  • ✅ 微应用:只提供静态资源(JS Bundle)
  • ❌ 微应用:不直接处理 URL 路由

3. 实际请求流程对比

场景 A:主应用路由(需要 try_files)

bash
# 1️⃣ 用户直接访问主应用路由
https://example.com/dashboard

# 2️⃣ Nginx 处理
location / {
    root /data/apps/frontend/main;
    try_files $uri $uri/ /index.html;  # ✅ 必须
}

# 3️⃣ 流程
用户访问 /dashboard
    ↓
Nginx: /dashboard 文件不存在
    ↓
try_files fallback → 返回 /index.html
    ↓
主应用 React Router 解析 /dashboard
    ↓
渲染 Dashboard 页面

场景 B:微应用路由(不需要 try_files)

bash
# 1️⃣ 用户访问微应用路由
https://example.com/keyboard-management

# 2️⃣ 主应用处理(不是 Nginx!)
主应用 index.html 加载
    ↓
主应用 React Router 解析 /keyboard-management
    ↓
Qiankun 决定加载微应用
    ↓
请求微应用资源:
  - /keyboard/keyboard-management.js  ← ✅ 直接请求 JS 文件
  - /keyboard/index.html               ← ❌ 不会请求
    ↓
微应用 JS 执行并挂载

关键点:

  • 🔴 用户永远不会直接访问微应用的 HTML 路由
  • 🟢 所有路由都由主应用控制
  • 🟢 微应用只被当作 JS 库加载

4. 具体代码示例

主应用路由配置(React Router)

javascript
// 主应用 App.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';

function App() {
  return (
    <BrowserRouter>
      <Routes>
        {/* ✅ 主应用路由 */}
        <Route path="/" element={<Home />} />
        <Route path="/dashboard" element={<Dashboard />} />
        
        {/* ✅ 微应用路由(由主应用控制) */}
        <Route path="/keyboard-management/*" element={
          <MicroAppContainer name="keyboard-management" />
        } />
        <Route path="/mouse-management/*" element={
          <MicroAppContainer name="mouse-management" />
        } />
      </Routes>
    </BrowserRouter>
  );
}

// ✅ 微应用容器组件
function MicroAppContainer({ name }) {
  useEffect(() => {
    // Qiankun 加载微应用
    loadMicroApp({
      name,
      entry: `/keyboard/`,  // ← 只请求静态资源
      container: '#micro-app-container',
    });
  }, []);
  
  return <div id="micro-app-container" />;
}

用户访问流程

bash
# 用户输入 URL:https://example.com/keyboard-management
# ↓

# 1️⃣ 浏览器请求
GET https://example.com/keyboard-management
    ↓
# 2️⃣ Nginx 处理(主应用配置)
location / {
    root /data/apps/frontend/main;
    try_files $uri $uri/ /index.html;  # ✅ fallback 到主应用
}
# 返回:/data/apps/frontend/main/index.html# 3️⃣ 主应用加载
<script src="/main.js"></script>  # 主应用 JS# 4️⃣ React Router 解析
URL: /keyboard-management
Route 匹配: <Route path="/keyboard-management/*" ... />
    ↓
# 5️⃣ Qiankun 加载微应用
loadMicroApp({ entry: '/keyboard/' })
    ↓
# 6️⃣ 请求微应用资源(AJAX 请求)
GET /keyboard/index.html        # Qiankun 解析入口
GET /keyboard/keyboard-management.js  # ← 真正的 JS 文件
GET /keyboard/xxx.css
    ↓
# 7️⃣ Nginx 处理微应用资源请求
location /keyboard/ {
    alias /data/apps/frontend/keyboard/;
    # ❌ 不需要 try_files
    # 因为这些都是真实的文件请求,不是路由
}
# 返回:实际的 JS/CSS 文件内容

🔍 详细对比:主应用 vs 微应用

主应用请求模式

表格

请求类型 URL 示例 Nginx 处理 结果
首页 / 文件存在 → 返回 index.html
路由 /dashboard 文件不存在 → try_files → index.html
路由 /users/123 文件不存在 → try_files → index.html
静态资源 /main.js 文件存在 → 返回 JS

没有 try_files:

bash
用户访问 /dashboard
    ↓
Nginx: 404 Not Found  ❌
前端路由永远不会执行

微应用请求模式

表格

请求类型 URL 示例 请求发起者 Nginx 处理
入口 HTML /keyboard/ Qiankun 返回 index.html ✅
JS Bundle /keyboard/keyboard-management.js Qiankun 返回 JS 文件 ✅
CSS /keyboard/style.css Qiankun 返回 CSS 文件 ✅
图片 /keyboard/logo.png 微应用 返回图片 ✅
❌ 路由 /keyboard/page1 不会发生 -

关键:

  • 🔴 用户不会直接访问 /keyboard/page1
  • 🟢 只有主应用控制路由,然后加载微应用资源
  • 🟢 所有请求都是实际的文件路径

💡 实际验证

情况 1:如果微应用配置了 try_files

nginx
# ❌ 错误配置
location /keyboard/ {
    alias /data/apps/frontend/keyboard/;
    try_files $uri $uri/ /keyboard/index.html;  # 会出问题
}

会发生什么?

bash
# Qiankun 请求:/keyboard/keyboard-management.js
# ↓
# Nginx 处理:
1. 尝试文件:/data/apps/frontend/keyboard/keyboard-management.js
   # 如果文件存在 → ✅ 返回 JS(正常)
   # 如果文件不存在(路径错误) → ❌ 继续

2. 尝试目录:/data/apps/frontend/keyboard/keyboard-management.js/
   # 不存在 → 继续

3. Fallback:返回 /keyboard/index.html  # ❌ 问题在这里!
   # 浏览器期望收到 JS,却收到 HTML

# 结果:
Uncaught SyntaxError: Unexpected token '<'
# 因为浏览器把 HTML 当 JavaScript 解析了

情况 2:不配置 try_files(正确)

nginx
# ✅ 正确配置
location /keyboard/ {
    alias /data/apps/frontend/keyboard/;
    # 不配置 try_files
}

会发生什么?

bash
# Qiankun 请求:/keyboard/keyboard-management.js
# ↓
# Nginx 处理:
1. 直接查找文件:/data/apps/frontend/keyboard/keyboard-management.js
   # 如果存在 → ✅ 返回 JS
   # 如果不存在 → ✅ 返回 404

# 结果:
# 文件存在 → 微应用正常加载 ✅
# 文件不存在 → 明确的 404 错误,易于排查 ✅

🎯 特殊情况:微应用内部路由

问题:微应用内部有子路由怎么办?

javascript
// 微应用内部路由
<BrowserRouter basename="/keyboard-management">
  <Routes>
    <Route path="/" element={<List />} />
    <Route path="/create" element={<Create />} />  // 子路由
    <Route path="/edit/:id" element={<Edit />} />  // 子路由
  </Routes>
</BrowserRouter>

答案:依然不需要 Nginx 的 try_files!

原因:

bash
# 1️⃣ 用户访问:https://example.com/keyboard-management/create
# ↓

# 2️⃣ 主应用处理
主应用路由匹配: /keyboard-management/*
    ↓
Qiankun 加载微应用
    ↓
# 3️⃣ 微应用 JS 执行
微应用 React Router 解析: /keyboard-management/create
    ↓
渲染 Create 组件 ✅

# 🔴 注意:整个过程中,浏览器只请求了一次 HTML(主应用的)
# 微应用的子路由完全在前端内存中处理,不涉及 Nginx

📊 总结表格

表格

对比项 主应用 微应用
路由控制 自己控制 主应用控制
用户访问方式 直接访问 URL 不会直接访问
Nginx 职责 返回 HTML + 处理路由 fallback 只返回静态资源
需要 try_files ✅ 是 ❌ 否
请求类型 HTML 页面 + 静态资源 只有静态资源
404 情况 Fallback 到 index.html 真实的 404 错误

✅ 最佳实践总结

1. 主应用 Nginx 配置

nginx
# ✅ 需要 try_files
location / {
    root /data/apps/frontend/main;
    try_files $uri $uri/ /index.html;  # 处理 SPA 路由
}

原因:

  • 用户会直接访问任意路由
  • 需要 fallback 到 index.html 让前端路由接管

2. 微应用 Nginx 配置

nginx
# ✅ 不需要 try_files
location /keyboard/ {
    alias /data/apps/frontend/keyboard/;
    # 直接返回文件,不做 fallback
}

原因:

  • 只提供静态资源服务
  • 所有请求都是真实文件路径
  • 路由由主应用和微应用前端代码控制

3. 记忆口诀

主应用:用户入口,需要路由 fallback → try_files ✅
微应用:资源仓库,直接文件访问 → 不需要 try_files ❌

🔧 调试技巧

如何验证是否需要 try_files?

bash
# 问自己 3 个问题:

1. 用户会直接在浏览器输入这个路径吗?
   - 主应用:会(/dashboard, /users/123)→ 需要
   - 微应用:不会(由 Qiankun 加载)→ 不需要

2. 这个路径是真实文件还是前端路由?
   - 真实文件(/app.js, /logo.png)→ 不需要
   - 前端路由(/dashboard)→ 需要

3. 谁在控制这个路由?
   - 用户(直接访问)→ 需要 Nginx fallback
   - 前端代码(AJAX 加载)→ 不需要 Nginx fallback

希望这个解释清楚了!核心就是:

  • 🎯 微应用在 Qiankun 架构中只是"静态资源包",不是独立的 SPA 应用
  • 🎯 所有路由控制权都在主应用,微应用只负责提供 JS/CSS/图片等文件
  • 🎯 Nginx 对微应用只需要做"文件服务器",不需要处理路由 fallback

前端小记:Vue3引入mockjs开发

2025年12月15日 10:11

介绍

Mockjs 是一个用于前后端分离开发的工具,可以拦截 Ajax 请求,返回模拟的响应数据。换言之,前后端分离模式下的前端开发者可以独立于后端进行开发,无需后端先定义返回的数据结构再开始开发,可以完善前端工程开发链路,可无缝对接后端接口。

Mockjs网站点这

引入

  1. 创建Vue3项目,参考Vue.js创建教程
  2. 添加mockj.js依赖,最终package.json的devDependencies属性会添加'"mockjs": "^1.1.0"'。
# npm
npm i mockjs -D

# yarn
yarn add mockjs --dev

3. 引入mockjs。在main.js文件中,添加以下代码行。

// 如果需要按照启动环境则放开注释
// if (process.env.NODE_ENV === 'development') {
//   import('./mock').then(({ default: mock }) => { 
//     console.log('mock启动成功');
//   })
// }

// 直接引入
import('./mock')
import { createApp } from 'vue'
// 假如需要再App.vue生命周期时拦截Ajax,mockjs需先启动,否则无法拦截
// App.vue的请求可设置50ms的延迟
import App from './App.vue'
import router from './router'

在src下创建mock文件夹,编辑index.js

import Mock from 'mockjs'
// 设置响应延迟,可注释
Mock.setup({
  timeout: 500
})

// Mock.mock('路由', 'http请求方法', '返回内容')

console.log('已注册 Mock 路由:', Mock._mocked)

至此,引入mockjs已经完成,使用npm run dev看见控制台输出证明成功。

业务场景

使用前,你需要有Ajax发起http请求,按照你的业务逻辑访问一个后端接口路径,最终被mockjs拦截返回需要的模拟数据。

例如:访问'/home/info'路由,使用'GET'方法,返回数据结构如下:

 {
    code:  200,
    msg: 'success',
    data: {
      userId: '1',
      name: 'admin',
      avatarUrl: '/static/avatar.jpg',
      roles: ['admin']
    }
  }

以此处为例,提前封装axios,步骤省略。
在views下新建home目录,并在route中定义home页面的访问路由,省略。

在home目录下新建api/index.js,按照正常业务逻辑编写API内容

import { get, post, put, del } from '@/utils/request'
 export const homeApi = {
  getInfo(){
    return get('/home/info')
  }
}

编写home/index.vue文件内容

<template>
 <div class="body">
  {{ userInfo.name }}
 </div>
</template>

<script setup>
import { ref, reactive, onMounted, defineProps, defineEmits, watch, computed  } from 'vue'
import { homeApi } from './api'
// Data
const userInfo = ref({})

// Lifecycle hooks
onMounted(() => {
  queryInfo()
})

// Methods
const queryInfo = () => {
  homeApi.getInfo().then(res => {
    console.log(res);
    if(res.code === 200){
      userInfo.value = res.data
    }
  })
}
</script>

到目前为止,我们的开发流程还是根据正常的业务逻辑进行开发的,没有在代码中使用硬编码或FakeData获取数据,正常发起接口请求,并期望获取后端返回数据。下一步,我们需要使用mock.js将后端业务返回的数据进行定义,并正确拦截请求进行返回。

编写上文已新建的mock/index.js文件


// 添加
// 正则匹配
Mock.mock(/user\/info/, 'get',() => {
    return {
    code:  200,
    msg: 'success',
    data: {
      userId: '1',
      name: 'admin',
      avatarUrl: '/static/avatar.jpg',
      roles: ['admin']
    }
  }
})

启动项目,访问home路由,查看控制台或页面输出。

image.png

使用小记

前文已经描述了如何使用mockjs,在此记录下相关已经踩坑的内容,或使用变种。

  1. 返回多个数据或动态内容,更多内置方法参考点此
// 在返回的json中支持多个数据生成,或内置方法支持动态数据
{
    code: 200,
    message: '成功',
    // 返回3~5个元素的列表
    'data|3-5': [
      {
        'id|+1': 1,
        'cId': '@id', // 生成id
        'name': '@cName', // 生成中文名称
        'createTime': '@datetime', // 时间
        'updateTime': '@datetime', 
        // 1-5个数据内容
        'children|1-5': [
          {
            'id|+1': 1,
            'parentId': '@id',
            'index|+1': 1,
            'name': '@cName 
          }
        ]
      }
    ]
  }
  1. 自定义拓展方法
// 在mock/index.js编写
Mock.Random.extend({
    userName(){
      const names = ['张三', '李四', '王五', '赵六', '孙七', '周八', '吴九', '郑十']
      return names[Math.floor(Math.random() * names.length)]
    }
  })
// 上述方法已经添加了一个userName的拓展方法,在返回的数据属性中使用'@userName'即可
  1. 在App.vue中使用
// App.vue在项目启动时生命周期触发时mockjs还没有加载完成,因此可能需要设置延迟访问才会被mockjs拦截
onMounted(() => {
   setTimeout(() => {
    query()
  }, 200)
})
  1. 模板化返回内容
// 上文编写的Mock.mock方法中返回内容是直接编写在回调方法中的
// 编写多个时不直观、不美观
// 可以将返回的对象封装成常量引入mock/index.js中
import Mock from 'mockjs' 
import { homeTemplate } from './template/homeTemplate' 
Mock.mock(/user\/info/, 'get', homeTemplate.queryInfo)
export const homeTemplate = {
  queryInfo: {
    code:  200,
    msg: 'success',
    data: {
      userId: '1',
      name: 'admin',
      avatarUrl: '/static/avatar.jpg',
      roles: ['admin']
    }
  }
}
  1. 匹配路径方式
// 请求路径http://localhost:8080/home/info/{userId}
Mock.mock(/home\/info\/\d+/, 'get', homeTemplate.queryInfo)

// 请求路径http://localhost:8080/home/info 
Mock.mock(/home\/info\/\d+/, 'get', homeTemplate.queryInfo)
Mock.mock('http://localhost:8080/home/info', 'get', homeTemplate.queryInfo)

SCSS 实战指南:从基础到进阶,让 CSS 编写效率翻倍​

作者 渔_
2025年12月15日 09:56

写 CSS 时总被 “重复劳动” 困扰?改个主题色要全局搜半天,嵌套层级一深就乱成一团,通用样式只能复制粘贴…… 其实掌握 SCSS,这些问题都能轻松解决。

作为前端最常用的样式工具之一,SCSS 既有原生 CSS 的熟悉感,又多了变量、混合、继承等实用特性。今天就从基础用法进阶技巧,带你手把手玩转 SCSS,从此写样式又快又省心~

一、先搞懂:SCSS 和 CSS 有什么不一样?

SCSS 是 CSS 的 “增强版”,写法上和 CSS 高度相似(保留{};),但多了很多能提升效率的功能:

  • 支持变量,统一管理常用值(比如主题色、字体大小)
  • 可以嵌套选择器,跟着 HTML 结构写样式,逻辑更清晰
  • 能定义 “混合” 代码块,复用通用样式(比如 flex 居中、圆角按钮)
  • 支持模块化拆分,把样式拆成多个文件,维护更方便

最重要的是,SCSS 最终会编译成浏览器能识别的原生 CSS,不用担心里兼容性问题~

二、SCSS 基础:3 个核心功能,上手即能用

1. 变量:一次定义,全局复用

写样式时,主题色、字体大小这类常用值,用变量统一管理,改的时候只改一处就行,再也不用全局搜索!语法:用$开头定义变量,直接在样式中引用。

// 定义变量(建议按用途分类,方便查找)
$color-primary: #0081ff; // 主色
$color-secondary: #f5f5f5; // 辅助色
$font-size-base: 14px; // 基础字体大小
$border-radius: 4px; // 圆角大小

// 引用变量
.btn-primary {
  background-color: $color-primary;
  color: #fff;
  font-size: $font-size-base;
  border-radius: $border-radius;
  padding: 8px 16px;
}

.card {
  background-color: #fff;
  border: 1px solid $color-secondary;
  border-radius: $border-radius;
}

2. 嵌套:跟着 HTML 结构写,告别冗长选择器

原生 CSS 写嵌套结构时,选择器会越来越长(比如.header .nav .list .item),SCSS 的嵌套语法能完美解决这个问题,直接跟着 HTML 层级写就行。注意:嵌套别超过 3 层,否则编译后的 CSS 选择器过长,影响性能。

// HTML结构:.header > .nav > .list > .item
.header {
  width: 100%;
  padding: 20px 0;

  // 嵌套子选择器
  .nav {
    display: flex;
    justify-content: space-between;

    // 嵌套孙子选择器
    .list {
      display: flex;
      gap: 20px;

      // 伪类嵌套(用&指代父选择器)
      .item {
        color: #333;
        cursor: pointer;

        &:hover {
          color: $color-primary; // 引用变量
        }

        &.active {
          font-weight: bold;
          border-bottom: 2px solid $color-primary;
        }
      }
    }
  }
}

编译后的原生 CSS:

.header {
  width: 100%;
  padding: 20px 0;
}
.header .nav {
  display: flex;
  justify-content: space-between;
}
.header .nav .list {
  display: flex;
  gap: 20px;
}
.header .nav .list .item {
  color: #333;
  cursor: pointer;
}
.header .nav .list .item:hover {
  color: #0081ff;
}
.header .nav .list .item.active {
  font-weight: bold;
  border-bottom: 2px solid #0081ff;
}

3. 混合(Mixin):复用代码块,减少重复

遇到 flex 居中、清除浮动、兼容前缀这类重复样式,用@mixin定义一个 “代码块”,需要时用@include调用,不用再复制粘贴了。还能给混合传参数,实现 “个性化复用”~

// 1. 定义无参数混合(通用flex居中)
@mixin flex-center {
  display: flex;
  justify-content: center;
  align-items: center;
}

// 2. 定义带参数混合(自定义flex方向)
@mixin flex-layout($direction: row, $justify: center) {
  display: flex;
  flex-direction: $direction;
  justify-content: $justify;
  align-items: center;
}

// 3. 调用混合
.footer {
  @include flex-center; // 调用无参数混合
  height: 80px;
  background-color: #f5f5f5;
}

.form {
  @include flex-layout(column, flex-start); // 调用带参数混合
  gap: 16px;
  padding: 20px;
}

三、SCSS 进阶:3 个技巧,让样式更灵活

1. 继承(Extend):共享样式,减少冗余

如果多个选择器有相同的基础样式(比如不同类型的按钮),用@extend继承基础样式,比混合更简洁,编译后的 CSS 也更精简。

// 基础按钮样式
.base-btn {
  padding: 8px 16px;
  border-radius: $border-radius;
  cursor: pointer;
  border: none;
  font-size: $font-size-base;
}

// 继承基础样式,再添加个性化样式
.btn-primary {
  @extend .base-btn;
  background-color: $color-primary;
  color: #fff;
}

.btn-secondary {
  @extend .base-btn;
  background-color: $color-secondary;
  color: #333;
}

编译后的 CSS(会合并相同样式):

.base-btn, .btn-primary, .btn-secondary {
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
  border: none;
  font-size: 14px;
}
.btn-primary {
  background-color: #0081ff;
  color: #fff;
}
.btn-secondary {
  background-color: #f5f5f5;
  color: #333;
    }

2. 模块化:拆分样式文件,多人协作不冲突

大型项目中,把样式按功能拆分成多个 SCSS 文件(比如变量、混合、组件样式),用@import导入主文件,结构更清晰,多人协作时也不会互相干扰。注意:拆分的文件建议用_开头(比如_variables.scss),表示 “局部文件”,编译时不会单独生成 CSS 文件。

// 1. 拆分文件(示例结构)
- styles/
  - _variables.scss // 变量文件
  - _mixins.scss    // 混合文件
  - _header.scss    // 头部样式
  - _footer.scss    // 底部样式
  - main.scss       // 主文件(导入其他文件)

// 2. 主文件 main.scss 中导入其他文件
@import "./variables"; // 不用写下划线和后缀
@import "./mixins";
@import "./header";
@import "./footer";

// 3. 在项目中引入 main.scss 即可

3. 条件判断与循环:动态生成样式

SCSS 支持@if/@for等语法,能动态生成样式,比如生成栅格系统的类名(.col-1.col-12),不用手动写 12 遍。

// 1. 条件判断(根据屏幕尺寸切换样式)
@mixin responsive($size) {
  @if $size == "small" {
    @media (max-width: 768px) {
      font-size: 12px;
      padding: 4px 8px;
    }
  } @else if $size == "large" {
    @media (min-width: 1200px) {
      font-size: 16px;
      padding: 12px 24px;
    }
  }
}

// 调用条件混合
.btn {
  @include responsive("small");
  @include responsive("large");
}

// 2. 循环(生成栅格类名)
@for $i from 1 through 12 {
  .col-#{$i} { // #{} 是插值语法,把变量插入类名
    width: (100% / 12) * $i;
    float: left;
    padding: 0 8px;
    box-sizing: border-box;
  }
}

四、实战避坑:新手容易踩的 3 个问题

  1. 版本选择:优先用dart-sass(官方推荐),别用node-sass(已废弃,兼容 Node.js 版本麻烦),安装时直接执行npm install sass即可。
  2. 嵌套深度:别嵌套超过 3 层,否则编译后的 CSS 选择器过长(比如.a .b .c .d),影响渲染性能,也难维护。
  3. 变量命名:建议统一前缀,比如颜色用$color-xxx,尺寸用$size-xxx,字体用$font-xxx,项目大了也能快速找到需要的变量。

五、最后:SCSS 怎么融入项目?

无论是 Vue、React 还是原生项目,集成 SCSS 都很简单:

  • Vue 项目:直接在<style>标签中加lang="scss",比如 <style lang="scss" scoped>
  • React 项目:安装sass后,把样式文件后缀改成.scss,直接导入即可。
  • 原生项目:用dart-sass编译 SCSS 文件为 CSS,再引入到 HTML 中。

其实 SCSS 不难,核心就是用 “变量、嵌套、混合” 解决原生 CSS 的痛点。刚开始可以从基础功能用起,熟悉后再尝试进阶技巧,慢慢就能感受到它带来的效率提升~

你平时用 SCSS 时有没有遇到过什么问题?或者有什么实用技巧?欢迎在评论区交流~

跟“白屏”说拜拜:用 Next.js 把 React 搬到服务器上,Google 爬虫都要喊一声“真香”

2025年12月15日 09:42

前言:除了你,没人愿意对着空白屏幕发呆

接上回。咱们用虚拟滚动把 10 万条数据治得服服帖帖。现在你的应用在渲染出来之后,性能确实很顶。

但是,有一个更尴尬的问题摆在面前:首屏加载(First Paint)

咱们之前写的 React(CSR - 客户端渲染),工作流程是这样的:

  1. 浏览器请求页面。
  2. 服务器扔回来一个几乎是空的 HTML:<div id="root"></div>
  3. 浏览器加载那个 5MB 的 JS 包。
  4. JS 执行,去调 API,拿到数据。
  5. JS 把 DOM 算出来,塞进 root 里。

这一套下来,用户在前几秒钟看到的都是大白屏。 更惨的是 SEO(搜索引擎优化)。Google 的爬虫虽然聪明点了,但百度的爬虫那是相当“直男”。它过来一看:“哟,这网页只有一个 div?内容是空的?垃圾!” 然后转身就走,你的网页在搜索结果里永远排在第 100 页开外。

今天,我们要来聊聊 Next.js服务端渲染 (SSR)。我们要把渲染这脏活累活从用户的浏览器挪到服务器上,让用户打开网页的一瞬间,内容就是满的。

[Image of CSR vs SSR architecture diagram]


观念升级:不仅是框架,是“元框架”

很多兄弟对 Next.js 有误解,觉得它就是个“带路由的 React”。 错!在 2025 年的今天,Next.js 其实是 React 的完全体

React 官方团队现在都明说了:“你要写 React,推荐直接用 Next.js。” 为什么?因为 React 只是个 UI 库(View 层),而 Next.js 帮你搞定了路由、打包、SSR、API 路由、图片优化……它是全家桶。

我们重点要说的,是它的核心大招:React Server Components (RSC)


实战演练:从 useEffect 到 async/await

在传统的 CSR 项目里,我们要获取数据,通常得写个 useEffect,还得处理 loading 状态。

❌ 传统的 CSR 写法(慢、且 SEO 为 0):

// 客户端组件
import { useState, useEffect } from 'react';

export default function UserProfile({ id }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    // 浏览器加载完 JS 后才开始发请求
    fetch(`/api/users/${id}`).then(res => res.json()).then(setUser);
  }, [id]);

  if (!user) return <div>Loading...</div>; // 用户先看 1 秒钟这个

  return <h1>{user.name}</h1>;
}

✅ Next.js (App Router) 写法(秒开、SEO 满分):

在 Next.js 的 App Router 模式下,组件默认就是服务端组件 (Server Component) 。这意味着你可以直接在组件里写 async/await,直接连数据库!

// 这代码是在服务器上跑的!浏览器连一行 JS 都收不到,只收到 HTML。
import db from '@/lib/db';

async function getUser(id) {
  // 直接查库,或者调内网 API,快得飞起
  return await db.user.findUnique({ where: { id } });
}

export default async function UserPage({ params }) {
  const user = await getUser(params.id);

  // 服务器直接把渲染好的 HTML 扔给浏览器
  // 爬虫看到的就是:<h1>Jack</h1>
  return (
    <main>
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
    </main>
  );
}

发生了什么?

  1. 用户请求 /user/1
  2. Next.js 服务器接收请求,执行 getUser,拿到数据。
  3. Next.js 生成完整的 HTML:<main><h1>Jack</h1>...</main>
  4. 浏览器收到 HTML,直接显示内容

没有 Loading 转圈,没有白屏。爬虫一看:“哇,内容好丰富!” 排名蹭蹭往上涨。


进阶技巧:静态生成 (SSG) —— 也就是“作弊”

对于“用户个人主页”这种数据会变的页面,我们用 SSR(每次请求都跑一遍服务器)。 但对于“关于我们”、“博客文章”这种一万年不改一次的页面,如果每次有人访问都去查数据库,那就太冤大头了。

Next.js 有个无敌的功能叫 SSG (Static Site Generation)

你只需要告诉 Next.js:“这页面是静态的。” 它就会在构建打包 (Build Time) 的时候,把这个页面生成为一个 .html 文件。

当用户访问时,Nginx/Vercel 直接把这个 HTML 扔出去。这比 SSR 还要快,因为连数据库都不用查,甚至不需要服务器计算,这就是纯静态资源分发。


// 告诉 Next.js 提前把哪些文章生成好 HTML
export async function generateStaticParams() {
  const posts = await getposts();
  return posts.map((post) => ({
    slug: post.slug,
  }));
}

export default async function BlogPost({ params }) {
  // ... 渲染逻辑
}

避坑指南:Hydration(注水)之痛

虽然 SSR 出来的 HTML 能立马看到,但它一开始是“死”的(没有交互)。 浏览器展示 HTML 后,会加载 JS,把 React 的事件监听器(onClick 等)挂载上去,这个过程叫 Hydration(注水)

如果你在服务端渲染的内容,和客户端注水时的内容不一致,React 就会报错: Text content does not match server-rendered HTML.

常见翻车现场:

  // ❌ 错误!
  // 服务器时间是 UTC,客户端时间是 GMT+8
  // HTML 里是 10:00,JS 算出来是 18:00,直接报错
  return <div>Current time: {new Date().toLocaleTimeString()}</div>;
}

解决办法: 凡是涉及到浏览器特有属性(windowlocalStorage、时区)的,必须放到 useEffect 里,或者用 dynamic import 强制转为客户端组件。

import { useState, useEffect } from 'react';

export default function TimeDisplay() {
  const [time, setTime] = useState('');

  useEffect(() => {
    setTime(new Date().toLocaleTimeString());
  }, []);

  return <div>Current time: {time}</div>;
}

总结:该不该上 Next.js?

如果你的项目是:

  • 后台管理系统(Dashboard) :无所谓,CSR 就够了,没人靠 SEO 搜后台。
  • 企业官网 / 博客 / 电商 / 资讯站必须上 Next.js。不上就是跟钱过不去,跟流量过不去。

SSR 解决了首屏速度,SSG 解决了高并发压力,Server Components 让前端能直接操作后端逻辑。 这就叫降维打击。

好了,我要去把那个还是 create-react-app 的老官网迁移到 Next.js 了,为了那该死的 Google 排名。

lg_90841_1619336946_60851ef204362.png


下期预告:既然我们都已经能在 Server Components 里直接读数据库了,那是不是连 API 接口都不用写了? 没错!写 axios.post 已经是过去式了。 下一篇,我们来聊聊 “Server Actions:全栈 React 的最后一公里” 。教你如何在按钮的 onClick 里直接调用服务器函数,把“前后端分离”重新“合二为一”。

别再写 API 路由了:Server Actions 才是全栈 React 的终极形态

2025年12月15日 09:41

前言:一种名为“胶水代码”的疲惫

接上回。咱们用 Next.js App RouterServer Components 实现了服务端直接读数据库。页面加载速度快得离谱,Google 爬虫也爱死我们了。

但是,只要涉及到操作(比如提交表单、点赞、收藏),你的痛苦回忆又回来了。

tangsengxiaotu.jpg

按照老规矩(Next.js Pages Router 时代),你要做一个“添加待办事项”的功能,你得这么折腾:

  1. pages/api/todos.ts 里写一个 API Handler,解析 req.body,连数据库写入,返个 JSON。
  2. 在前端组件里,引入 axios
  3. 写个 handleSubmit,调用 axios.post('/api/todos', data)
  4. 处理 loading 状态,处理 error。
  5. 请求成功后,为了让列表更新,还得手动去更新本地 state 或者触发 SWR/React Query 的 mutate

累不累? 我不就为了存一行字吗?为什么要跨越千山万水,写这么多“胶水代码”来连接前后端?

今天,我们要把这层胶水撕掉。欢迎来到 Server Actions 的世界。在这里,前端按钮可以直接调用后端函数


核心魔法:远程过程调用 (RPC) 的文艺复兴

什么是 Server Action? 简单说,就是你在服务器文件里写一个函数,标上 'use server',然后你可以直接把这个函数 import 到前端组件里,绑在 onClick 上。

Next.js 在背后会自动帮你把这个函数调用变成一个 HTTP POST 请求。你看着像是在调用本地函数,其实是在搞 RPC(远程过程调用)

❌ 以前的写法(前后端分离,心也分离):

1. 后端 (pages/api/createTodo.ts):

export default async function handler(req, res) {
  if (req.method === 'POST') {
    const todo = await db.todo.create({ data: req.body });
    res.status(200).json(todo);
  }
}

2. 前端 (components/AddTodo.tsx):

  const [text, setText] = useState('');
  const add = async () => {
    await axios.post('/api/createTodo', { text }); // 还要记 URL
    // 手动刷新列表...
  };
  return Add;
}

✅ Server Actions 写法(合二为一):

我们新建一个 actions.ts 文件,这里面的代码只会在服务器运行

  1. 定义 Action (actions.ts):
import db from '@/lib/db';
import { revalidatePath } from 'next/cache';

export async function createTodo(formData: FormData) {
  const text = formData.get('text');
  
  // 直接连库,无需 API 路由
  await db.todo.create({ data: { text } });

  // ✨ 魔法中的魔法:告诉 Next.js &#34;/todos&#34; 路径下的数据过期了
  // 页面会自动重新获取最新数据,界面自动刷新!
  revalidatePath('/todos');
}
  1. 使用 Action (components/AddTodo.tsx):

const AddTodo = () => {
  // 直接把 Server Action 绑在 form 的 action 上
  // 甚至关了 JS 这表单都能提交(渐进增强)
  return (
    
      
      Add
    
  );
}

发现了吗?axios 不见了,useEffect 不见了,手动刷新数据的逻辑也不见了。 你写代码的感觉就像回到了 PHP 时代(褒义),直接跟数据库对话,但享受着 React 的组件化体验。

进阶玩法:如果不只是 Form 提交怎么办?

“将军,我不是提交表单,我就想点个赞,或者点击按钮执行个逻辑,怎么办?”

这时候我们不能用 `` 了,我们需要在 Event Handler 里调用。 但是,直接调用 Server Action 是拿不到 loading 状态的。

这时候,React 的 useTransition 也就是为此而生的。

import { useTransition } from 'react';
import { likePost } from '@/actions'; // 引入服务器函数

const LikeButton = ({ postId }) => {
  let [isPending, startTransition] = useTransition();

  const handleClick = () => {
    // startTransition 会把状态更新标记为“非紧急”
    // 并自动追踪 async 函数的执行状态到 isPending 里
    startTransition(async () => {
      await likePost(postId);
    });
  };

  return (
    
      {isPending ? '点赞中...' : '👍 点赞'}
    
  );
};

这体验简直绝了。 你不需要自己定义 const [loading, setLoading] = useState(false)useTransition 帮你搞定了一切 loading 态管理。

避坑指南:别太天真,这还是 HTTP

虽然写起来像本地函数,但你心里要有数:这依然是一次网络请求

1. 安全,安全,还是TMD安全!

千万别以为这是一个普通函数,就觉得能在里面随便传东西。 Server Action 本质上就是一个公开的 API 接口。 任何人都可以通过抓包,模拟发送 POST 请求给这个 Action。

所以,必须在 Action 内部做身份验证和权限校验!

export async function deletePost(id: string) {
  // 必须检查用户是否有权限!
  const session = await getSession();
  if (!session || session.user.role !== 'admin') {
    throw new Error('你没有权限干这事儿');
  }

  // 必须校验输入参数!(Zod 再次登场)
  const safeId = z.string().parse(id);

  await db.post.delete({ where: { id: safeId } });
}

2. 不能传递复杂对象

因为 Action 调用要跨越网络(序列化),所以参数和返回值必须是可序列化的。 你不能把一个 onClick 回调函数或者一个 Class 实例传给 Server Action。 传 JSON、String、FormData 都是最稳的。

3. 不要把敏感数据传回客户端

Server Action 的返回值会发给浏览器。 别手滑把 user.password_hash 或者整个数据库连接对象给 return 出来了。


总结:全栈的最后一公里

Server Actions 的出现,标志着 React 正式从一个 UI 库,进化成了一个全栈框架。

它打破了“前端”和“后端”之间那道厚厚的墙。

  • 对于简单的 CRUD,你再也不用去写繁琐的 REST API。
  • 你的业务逻辑高度内聚:数据库操作代码和触发它的按钮代码,物理距离只有几行 import。

当然,这并不意味着后端工程师失业了。对于复杂的微服务编排、高并发处理,还是需要专业的后端架构。 但对于我们这种做应用、做产品的开发者来说,Server Actions 让我们能用原来 1/3 的代码量,干完 100% 的活

这就叫生产力。

lg_90841_1619336946_60851ef204362.png


下期预告:代码写完了,测试跑通了。最后一步是什么?部署! 你还在手动 SSH 连服务器、装 Nginx、传文件吗?太 Low 了。 下一篇(完结篇),我们来聊聊 “CI/CD 与 Vercel 部署” 。教你如何把 GitHub 代码提交的一瞬间,自动触发构建、部署,并让你的应用跑在全世界的边缘节点(Edge)上。

React学习:组件化思想

作者 南山安
2025年12月15日 08:53

前言

Vue 用得顺手,模板语法清晰,三部分(模板、脚本、样式)分离得特别清楚,上手快,生态也成熟。但工作中总会遇到一些团队或项目在用 React,甚至很多大厂的前端岗位都更偏好 React。React 的理念更“激进”——它把一切都交给 JavaScript,强调“All in JS”。学了之后发现,这种方式虽然入门门槛高一点,但一旦上手,写复杂交互和大型应用时反而更灵活。

React 和 Vue 都是现代前端框架,它们有很多共同点:

  • 都支持响应式(数据变了,界面自动更新)
  • 都支持数据绑定
  • 都推崇组件化开发

但实现方式和哲学完全不同。Vue 更像“渐进式”,你可以用得很简单,也可以用得很复杂;React 则从一开始就逼着你接受它的整套玩法。

一、JSX:React 最亮眼也最让人迷惑的地方

React 最出名的就是 JSX——在 JavaScript 里直接写类似 HTML 的代码。

return (
  <div>
    <h1>Hello React!</h1>
  </div>
);

第一次看到这种写法,我的第一反应是:“这不是把 HTML 塞到 JS 里吗?多乱啊!” Vue 是把 HTML 模板单独写在 template 里,逻辑在

但用着用着就发现,JSX 其实是一种“语法糖”,它的本质是调用 React.createElement 函数。比如上面那段 JSX,底层其实是:

return React.createElement("div", null, 
  React.createElement("h1", null, "Hello React<p align=left>!")</p>
);

React 官方提供了 JSX 这种更可读的写法,让我们少写一大堆 createElement。

关键点来了:JSX 不是字符串,也不是 HTML,它是一种 JavaScript 的语法扩展,最终会被 Babel 编译成普通的 JS 函数调用。这就是为什么 React 敢说“Learn once, write anywhere”——一切都是 JS。

和 Vue 对比:

  • Vue:模板是声明式的,指令(v-if、v-for)很直观
  • React:没有指令,所有逻辑都在 JS 里,用 JS 的方式控制渲染(三元运算符、map 等)

这也是很多人觉得 React 难上手的原因:你得习惯用 JS 的思维写界面。

二、组件:React 开发的基本单位

React 一上来就告诉你:整个应用是由组件组成的。

一个最简单的组件就是一个函数,返回 JSX:

function JuejinHeader() {
  return (
    <div>
      <header>
        <h1>掘金的首页</h1>
      </header>
    </div>
  );
}

然后在另一个组件里像用 HTML 标签一样使用它:

function App() {
  return (
    <div>
      <JuejinHeader />
      <main>
        <Articles />
        <aside>
          <Checkin />
          <TopArticles />
        </aside>
      </main>
    </div>
  );
}

看到没?组件就是可以复用、组合的积木块。我们不再像传统开发那样直接操作 DOM 树,而是构建一棵组件树。

这点和 Vue 很像,Vue 也是组件化,但 Vue 的单文件组件(.vue)把模板、脚本、样式明确分开,React 则更倾向于“一个文件就是一个组件”,HTML(JSX)、JS 逻辑、CSS 都可以写在一起(当然也可以分开导入)。

React 的组件必须首字母大写,这是和普通函数的区别,也是为了让 JSX 区分原生 HTML 标签和自定义组件。

最外层必须只有一个根元素,或者用碎片 <></> 包裹,这是因为 React 的 return 只能返回一个元素。

三、useState:React 的响应式核心

Vue 的响应式很“魔法”——你定义一个 ref 或 reactive,改值界面就自动更新,几乎感觉不到背后在做什么。

React 则更“显式”。它通过 Hook 来管理状态,最常用的是 useState:

import { useState } from "react";

function App() {
  const [name, setName] = useState("vue");
  const [isLoggedIn, setIsLoggedIn] = useState(false);

  const toggleLogin = () => {
    setIsLoggedIn(!isLoggedIn);
  };

  // 3秒后自动改成 react
  setTimeout(() => {
    setName("react");
  }, 3000);

  return (
    <>
      <h1>
        Hello <span className="title">{name}</span>
      </h1>
      {isLoggedIn ? <div>已登录</div> : <div>未登录</div>}
      <button onClick={toggleLogin}>
        {isLoggedIn ? "退出登录" : "登录"}
      </button>
    </>
  );
}

注意几点:

  1. useState 返回一个数组:[当前状态值, 更新函数],我们用解构赋值取出来。
  2. 更新状态必须调用 setXxx,不能直接改 name = 'xxx',因为 React 要靠这个知道要重新渲染。
  3. 类名用 className,因为 class 是 JS 关键字。
  4. 事件用驼峰命名,比如 onClick,不是 onclick。

和 Vue 对比:

  • Vue:ref 修改 .value,或者 reactive 直接改属性,Vue 内部通过 Proxy 劫持
  • React:必须通过 setState 函数更新,更新是“显式”的

React 的这种方式更可预测,你一眼就能看出哪里会触发重新渲染。

四、条件渲染和列表渲染:用原生 JS 的方式

Vue 有 v-if 和 v-for,很直观。

React 完全用 JavaScript 表达式:

{todos.length > 0 ? (
  <ul>
    {todos.map((todo) => (
      <li key={todo.id}>{todo.title}</li>
    ))}
  </ul>
) : (
  <div>暂无待办事项</div>
)}
  • 条件渲染:三元运算符或 &&
  • 列表渲染:array.map()
  • 一定要加 key,和 Vue 的 :key 作用一样,帮助 React 高效更新 DOM

这也是 React “All in JS”的体现:没有新的模板语法,全部用你已经会的 JS。

五、组件化思想:从“写页面”到“搭积木”

传统开发:我们关心的是页面长什么样,直接写一堆 HTML + CSS + JS 操作 DOM。

React 开发:我们关心的是页面由哪些组件组成。

就像盖房子:

  • 传统方式:自己一块砖一块砖砌墙
  • React 方式:先设计好“门组件”“窗组件”“墙组件”,然后像搭乐高一样组合

Facebook 就是用这种方式管理极其复杂的界面。每个小功能都是独立组件,改一个不会影响其他。

组件的好处:

  • 可复用:同一个按钮组件可以在多个页面用
  • 可维护:逻辑集中在组件内部,改起来不怕牵一发而动全身
  • 可组合:复杂页面由简单组件嵌套而成
  • 团队协作:不同的人负责不同的组件

六、React 和 Vue 的核心区别总结

方面 Vue React
模板语法 独立的模板 + 指令(v-if、v-for) JSX(JS 中写 HTML)
响应式机制 隐式(Proxy 自动追踪) 显式(通过 setState 触发)
组件写法 单文件组件(.vue),模板/脚本/样式分离 函数组件或类组件,通常一个文件一个组件
学习曲线 较平缓,上手快 较陡峭,需要适应“一切都是 JS”的思维
灵活性 渐进式,可简单可复杂 从一开始就拥抱函数式和 Hook,适合大型应用
状态管理 Vuex / Pinia Redux / Context / Zustand 等多种选择
生态 官方维护路由、状态管理等 社区驱动,生态更碎片化但选择多

写在最后:我的真实感受

刚开始学 React 时,确实很不适应。没有指令、没有模板,一切都要用 JS 写,感觉像退回到了原生开发。但当你写完第一个有状态的组件,看到数据一改界面就自动更新,那种“原来可以这样”的感觉特别爽。

React 逼着你用更纯粹的 JavaScript 思考问题,这其实是在提升你的 JS 基本功。组件化的思想也让我重新审视以前写的流水面条式的代码

React 不是要取代 Vue,它们只是不同的工具。选哪个不重要,重要的是理解背后的组件化、响应式、数据驱动这些现代前端的核心思想。

上手了,我越来越觉得:不管用什么框架,能写出清晰、可维护的代码,才是最重要的。

别再被 CSS 定位搞晕了!5 种 position 一图打尽 + 实战代码全公开

作者 xhxxx
2025年12月15日 08:04

CSS 定位机制详解:从文档流到五种定位方式实战

在网页开发中,如何精准控制元素的位置是每个前端开发者必须掌握的核心技能。而这一切的基础,就是 CSS 的定位(Positioning)机制

本文将结合我自己的学习笔记和实践代码,带你一步步理解:

  • 什么是文档流
  • 五种 position 值的区别与使用场景;
  • 每种定位方式的实际效果演示。

所有示例代码均来自我的本地实验文件(1.html ~ 5.html),力求真实、可复现。


一、什么是“文档流”?

在深入定位之前,我们必须先理解一个底层概念:文档流(Document Flow)

文档流是 HTML 元素默认的布局方式

  • 块级元素(如 <div>)垂直排列,独占一行;
  • 行内元素(如 <span>)水平排列,从左到右;
  • 整体遵循 从上到下、从左到右 的自然顺序。

当一个元素处于文档流中时,它会“老老实实”地排队,后面的元素会根据它的存在来安排自己的位置。

一旦我们使用某些 CSS 属性(如 position: absolutedisplay: none),元素就可能脱离文档流——它不再占据空间,其他元素会“假装它不存在”。


二、五种 CSS 定位方式详解

CSS 通过 position 属性控制元素的定位行为,共有五种取值:

1. static —— 静态定位(默认)

position: static;
  • 这是所有元素的默认定位方式
  • 元素完全遵循文档流。
  • topleftz-index 等属性无效
  • 通常用于重置已定位元素的行为。

用途:当你想取消某个元素的定位效果时,可以显式设置 position: static

📌 效果展示区(static)

image.png

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>CSS Static 定位 + 块级与行内元素演示</title>
  //样式省略了
</head>
<body>

  <div class="container">
    <h2>1. 块级元素(Block Elements)</h2>
    <div class="block-demo">块元素 A</div>
    <div class="block-demo">块元素 B</div>
    <p class="note">每个块级元素独占一行,垂直堆叠。即使设置了 top/left,static 定位下也不会偏移。</p>

    <h2>2. 行内元素(Inline Elements)</h2>
    <p>
      这是一段文字,包含多个 
      <span class="inline-demo">行内元素 1</span><span class="inline-demo">行内元素 2</span>
      ,它们会
      <em class="inline-demo">在同一行内排列</em>
      ,不会换行。
    </p>
    <p class="note">行内元素不会独占一行,宽度由内容决定。同样,top/left 对 static 元素无效。</p>

    <h2>3. 关键说明</h2>
    <ul>
      <li><code>position: static</code> 是所有元素的默认定位方式。</li>
      <li>在 static 定位下,<code>top</code><code>bottom</code><code>left</code><code>right</code><code>z-index</code><strong>不生效</strong></li>
      <li>块级元素垂直排列,行内元素水平排列——这是正常文档流的表现。</li>
    </ul>
  </div>

</body>
</html>

2. relative —— 相对定位

position: relative;
  • 元素仍在文档流中,原始位置保留;
  • 通过 top/left 等属性,相对于自己原来的位置偏移
  • 后续元素仍按原位置布局,不会“让位”

💡 我的理解:就像你在排队,但是突然有事需要离开,但是你提前通知了所有人说,这个位置是我的,请帮我留着,就不会有人占据这个位置

典型用途

  • 微调元素位置;
  • 作为 absolute 子元素的定位上下文容器

📌 效果展示区(relative)

为了加深你的理解,我加上了这个绿色的框(实际上不存在),这就是parent原本占据的地方,你可以认为显示出来的是它的灵魂,但肉体依然留在原地 image.png

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Relative 相对定位</title>
    <style>
        *{
            margin: 0;
            padding: 0;
        }
        .parent{
            width: 500px;
            height: 500px;
            background-color: red;
            position: relative;
            left: 100px;
            top: 100px;

        }
        .child{
            width: 300px;
            height: 200px;
            background-color: blue;
           
        }
        .box{
            width: 100px;
            height: 100px;
            background-color: yellow;
           
        }
    </style>
<body>
    <div class="parent">
        <div class="child">

        </div>
    </div>
    <div class="box">

        </div>
</body>
</html>


3. absolute —— 绝对定位

position: absolute;
right: 100px;
top: 50px;
  • 元素完全脱离文档流,不占空间;
  • 定位参考点是最近的非 static 定位祖先元素
  • 如果没有,则以 <body> 为参考。

⚠️ 注意:若父容器未设置 position: relative/absolute/fixed,子元素会“穿透”到更上层!

用途:弹窗、角标、悬浮按钮等需要精确定位的组件。

📌 效果展示区(absolute)

解析:可以看到和relative不同的是,使用absolute进行定位的元素不会像无赖一样占据着原来的地方,而是自己脱离了文档流,而剩余的元素继续按文档流排列,所以图中的123和456才能够排列在原本被占据的部分

image.png

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
  * {
    margin: 0;
    padding: 0;
  }
  body {
    background-color: azure;
  }
  .parent {
    /* 透明度 */
    opacity: 0.9;
    /* display:none; */
    width: 550px;
    height: 500px;
    background-color: pink;
    position: relative;
  }
  .child {
    width: 300px;
    height: 200px;
    background-color:skyblue;
    position: absolute;
    right: 100px;
  }
  .box {
    width: 100px;
    height: 100px;
    background-color: green;
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%,-50%);
  }
  </style>
</head>
<body>
  <div class="parent">
    <!-- 离开了文档流 -->
    <div class="child"></div>
    <div>123</div>
  </div>
  <div class="box">Hello World</div>
  <div>456</div>
</body>
</html>

这样 .child 会相对于粉色父容器右对齐,且不影响 123456 的布局。


4. fixed —— 固定定位

position: fixed;
  • 元素脱离文档流
  • 始终相对于浏览器视口(viewport) 定位;
  • 页面滚动时,元素保持不动

用途:固定导航栏、返回顶部按钮、悬浮客服图标。

📌 效果展示区(fixed)

解析:可以看到的是,不论怎么滚动鼠标,蓝色区域始终的相对浏览器的视窗进行定位,它永远在浏览器的固定显示区域进行定位

屏幕录制 2025-12-14 220650.gif 例如:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
  * {
    margin: 0;
    padding: 0;
  }
  body {
    height: 2000px;
  }
  .parent {
    width: 500px;
    height: 500px;
    background-color: pink;
  }
  .box {
    width: 100px;
    height: 100px;
    background-color: green;
  }
  .child {
    width: 300px;
    height: 200px;
    background-color: blue;
    position: fixed;
    right: 100px;
    bottom: 100px;
  }
  </style>
</head>
<body>
  <div class="parent">
     <div class="child"></div>
  </div>
   
  
  <div class="box">Hello World</div>
</body>
</html>

5. sticky —— 粘性定位

position: sticky;
top: 100px;
  • relativefixed 的混合体;
  • 在滚动到阈值前表现为 relative,之后变为 fixed
  • 必须指定 top/bottom 等偏移值才生效

用途:表格表头固定、侧边目录吸附。

📌 效果展示区(sticky)

解析:可以看到,绿色区域距离顶部的距离大于阈值(这里设置的100px)时,就会变为fixed定位,无论怎么滚动鼠标都不会改变相对于视口的定位,而当这个距离小于阈值时,他又像relative一样变成了正常的文档流,整个过程好像绿色区域具有粘性一样,所以叫做粘性定位。 屏幕录制 2025-12-14 221536.gif

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
  * {
    margin: 0;
    padding: 0;
  }
  body {
    height: 2000px;
  }
  .parent {
    width: 500px;
    height: 500px;
    background-color: pink;
    
  }
  .box {
    width: 100px;
    height: 100px;
    background-color: green;
    position: sticky;
    top: 100px;
  }
  .child {
    width: 300px;
    height: 200px;
    background-color: blue;
  }
  </style>
</head>
<body>
  <div class="parent">
  <div class="child"></div>

  </div>
  
  <div class="box">Hello World</div>
</body>
</html>

三、脱离文档流的其他方式

除了 position,还有两种常见方式会让元素脱离文档流:

方式 是否脱离文档流 是否占空间
display: none ✅ 是 ❌ 不占
visibility: hidden ❌ 否 ✅ 占(只是看不见)

📝 正如我的笔记所写:
display: none 隐藏元素,不占用空间,不会影响其他元素布局。


四、总结对比表

定位类型 是否脱离文档流 定位参考点 是否影响兄弟元素
static
relative 自身原位置 否(占位)
absolute ✅ 是 最近非 static 祖先 ✅ 是(不占位)
fixed ✅ 是 视口(viewport) ✅ 是
sticky 部分(滚动时) 视口 否(初始占位)

五、结语

CSS 定位看似简单,但背后涉及文档流、包含块、层叠上下文等多个核心概念。通过亲手编写 这些小实验,我逐渐理清了它们之间的关系。

记住

  • 让元素留在文档流中,布局更健壮;
  • 只在必要时使用 absolute/fixed
  • relative 父容器为绝对定位子元素提供“安全区”。

希望这篇笔记也能帮到正在学习 CSS 定位的你!


面试官:你能说下订阅发布模式么,怎么在VUE项目中实现一个类似eventBus的事件总线呢

作者 jun_不见
2025年12月15日 05:52

简介

订阅发布模式是一种消息传递范式,用于解耦发送消息的组件(发布者,Publisher)和接收消息的组件(订阅者,Subscriber),它还有一个核心枢纽 (消息代理,Message Broker),三大核心角色组成了订阅发布模式。


核心概念

订阅发布模式中有三个主要角色:

1. 发布者 (Publisher) 📢

  • 职责: 负责创建和发送消息。
  • 特点: 发布者不知道哪些或有多少订阅者会接收这些消息,它只管将消息发送给一个中间层——消息代理/消息总线(Message Broker/Bus)

2. 订阅者 (Subscriber) 👂

  • 职责: 负责接收和处理消息的角色。
  • 特点: 订阅者不知道谁发布了消息。它们通过向消息代理 订阅(Subscribe) 一个或多个特定的 主题(Topic)频道(Channel) 来接收相关消息。

3. 消息代理 (Message Broker) 📮

  • 职责: 这是一个核心中间件,负责接收发布者发送的消息,并根据消息的主题,将消息分发给所有已订阅该主题的订阅者。
  • 特点: 它就是一个核心枢纽,发布者和订阅者之间的桥梁,彼此可以不知道对方的存在,实现解耦

工作流程

  1. 订阅: 订阅者通知消息代理,它对某个或某些主题感兴趣。
  2. 发布: 发布者将包含数据的消息以及一个特定的主题发送给消息代理。
  3. 分发: 消息代理接收到消息后,根据消息携带的主题,查找所有订阅了该主题的订阅者。
  4. 接收: 消息代理将消息发送给相应的订阅者。

主要优点 🏆

  • 解耦性: 这是最大的优势。发布者和订阅者彼此独立,不需要知道对方的身份或存在。这使得系统组件可以独立地开发、部署和扩展。
  • 可扩展性: 可以轻松地增加新的订阅者来处理相同的消息,而无需修改发布者。
  • 灵活性: 消息可以以异步方式处理。发布者无需等待订阅者处理完消息,提高了系统的响应速度。

简单用一个比喻描述订阅发布模式

有一家专门对接岗位与求职者的工会服务中心(消息代理),这里每天都挤满了怀揣求职梦的年轻人(订阅者)。

要是求职者们都守在服务中心里干等机会,不仅会白白耗费时间,还可能错过其他要紧事。于是工会贴心地推出了 “岗位预约提醒” 服务:求职者只需留下自己的求职意向 —— 有人只填了 “前端开发”,有人同时勾选了 “后端开发” 和 “软件测试”(多主题订阅),再登记好联系方式,就可以先去忙自己的事,不用再原地苦等(异步机制的体现)。

没过多久,热闹的招聘场景就来了:先是 A 科技公司的招聘负责人(发布者 1)来到工会,带来了 2 个前端工程师的岗位需求;紧接着 B 互联网企业(发布者 2)也上门,发布了 3 个前端岗位和 1 个后端岗位的招聘信息;半小时后,C 外包公司(发布者 3)也送来 2 个软件测试的岗位需求(多发布者不同主题发消息)。

工会工作人员立刻对照着登记册,开启了精准分发:

  • 所有只登记了 “前端开发” 的求职者,都同时收到了 A 公司和 B 公司的前端岗位信息;
  • 那些同时勾选 “后端 + 测试” 的求职者,既拿到了 B 公司的后端岗位通知,也收到了 C 公司的测试岗位邀约;
  • 仅意向 “后端” 的求职者,则只收到了 B 公司的后端岗位信息。

整个过程里,多家招聘方不用挨个对接求职者,不同需求的求职者也能精准获取匹配的岗位信息,甚至有人能同时收到多类岗位通知。

在Vue项目中实现一个简单的eventBus事件总线

项目有现成的不一定需要自己写事件总线,不过可以通过例子加深对订阅发布的了解,例子里面四个主要文件,这几个文件结合起来的效果是这样的,初始状态是右图,点击按钮后会发布订阅,接收者数据会变更。

首先创建一个eventBus.JS文件,实现事件总线中心,代码如下:

/**
 * 核心原理:
 * 1. 中间载体:维护一个事件对象,作为事件的「中转站」
 * 2. 事件存储:使用对象存储事件名和回调函数的映射关系 { 'eventName': [cb1, cb2, ...] }
 * 3. 订阅(on):将回调函数推入对应事件名的数组中
 * 4. 发布(emit):触发事件名对应的所有回调函数,并传递参数
 * 5. 取消(off):从事件数组中移除指定回调(避免内存泄漏)
 * 6. 一次性订阅(once): 订阅事件,但只执行一次,执行后自动取消订阅
 */

class EventBus {
  constructor() {
    /**
     * 事件存储对象
     * 结构:{ 'eventName': [callback1, callback2, ...] }
     * key: 事件名称(字符串)
     * value: 该事件对应的回调函数数组
     */
    this.events = {};
  }

  isString(eventName) {
    if (!eventName || typeof eventName !== 'string') {
      console.warn('[EventBus] 事件名必须是字符串');
      return false;
    }
    return true;
  }

  /**
   * 订阅事件(on)
   * 将回调函数注册到指定事件名对应的回调数组中
   * 
   * @param {string} eventName - 事件名称
   * @param {Function} callback - 回调函数
   */
  on(eventName, callback) {
    // 参数验证
    if (!this.isString(eventName)) return () => {};
    
    if (typeof callback !== 'function') {
      console.warn('[EventBus] 回调函数必须是函数类型');
      return () => {};
    }

    // 如果该事件名不存在,初始化为空数组
    if (!this.events[eventName]) {
      this.events[eventName] = [];
    }

    // 将回调函数推入数组中
    this.events[eventName].push(callback);

    // 返回取消订阅的函数,方便使用
    return () => {
      this.off(eventName, callback);
    };
  }

  /**
   * 发布事件(emit)
   * 触发指定事件名的所有回调函数,并传递参数
   * 
   * @param {string} eventName - 事件名称
   * @param {...any} args - 传递给回调函数的参数
   */
  emit(eventName, ...args) {
    // 参数验证
    if (!this.isString(eventName)) return

    // 获取该事件对应的所有回调函数
    const callbacks = this.events[eventName];

    // 如果该事件没有订阅者,直接返回
    if (!callbacks || callbacks.length === 0) {
      console.warn(`[EventBus] 事件 "${eventName}" 没有订阅者`);
      return;
    }

    // 遍历执行所有回调函数
    callbacks.forEach((callback) => {
      try {
        // 使用 try-catch 包裹,避免某个回调出错影响其他回调
        callback.apply(null, args);
      } catch (error) {
        console.error(`[EventBus] 执行事件 "${eventName}" 的回调时出错:`, error);
      }
    });
  }

  /**
   * 取消订阅(off)
   * 从指定事件名的回调数组中移除回调函数
   * 
   * @param {string} eventName - 事件名称
   * @param {Function} callback - 要移除的回调函数(可选,不传则移除该事件的所有回调)
   */
  off(eventName, callback) {
    // 参数验证
    if (this.isString(eventName)) return

    // 如果该事件不存在,直接返回
    if (!this.events[eventName]) {
      return;
    }

    // 如果没有传入 callback,则移除该事件的所有回调
    if (!callback) {
      delete this.events[eventName];
      return;
    }

    // 从数组中移除指定的回调函数
    const callbacks = this.events[eventName];
    const index = callbacks.indexOf(callback);
    
    if (index > -1) {
      callbacks.splice(index, 1);
      
      // 如果数组为空,删除该事件
      if (callbacks.length === 0) {
        delete this.events[eventName];
      }
    }
  }

  /**
   * 一次性订阅(once)
   * 订阅事件,但只执行一次,执行后自动取消订阅
   * 
   * @param {string} eventName - 事件名称
   * @param {Function} callback - 回调函数
   */
  once(eventName, callback) {
    // 创建一个包装函数,执行一次后自动取消
    const wrapper = (...args) => {
      callback.apply(null, args);
      this.off(eventName, wrapper);
    };

    // 订阅包装后的函数
    this.on(eventName, wrapper);
  }

  /**
   * 清除所有事件
   * 清空所有事件和回调函数(通常在应用卸载时调用,避免内存泄漏)
   */
  clear() {
    this.events = {};
  }

}

// 创建并导出一个单例实例
// 这样整个应用使用同一个事件总线实例
const eventBus = new EventBus();

export default eventBus;

// 也可以导出类,方便需要多个独立事件总线的场景
export { EventBus };


在创建一个订阅者组件Subscriber.vue 文件,代码如下:

<template>
  <div class="receiver">
    <h3>接收者组件(订阅事件)</h3>
    
    <div class="messages">
      <h4>收到的消息:</h4>
      <div v-if="messages.length === 0" class="empty">暂无消息</div>
      <div v-else class="message-list">
        <div v-for="(msg, index) in messages" :key="index" class="message-item">
          {{ msg }}
        </div>
      </div>
    </div>

    <div class="user-info" v-if="userInfo">
      <h4>用户信息:</h4>
      <div class="info-card">
        <p><strong>ID:</strong> {{ userInfo.id + new Date().toLocaleTimeString() }}</p>
        <p><strong>姓名:</strong> {{ userInfo.name + new Date().toLocaleTimeString() }}</p>
        <p><strong>邮箱:</strong> {{ userInfo.email }}</p>
      </div>
    </div>

    <div class="counter" v-if="counter !== null">
      <h4>计数器值:</h4>
      <div class="counter-value">{{ counter }}</div>
    </div>

    <!-- once 示例区域 -->
    <div class="once-example">
      <div v-if="!onceMessageReceived" class="once-pending">
        <p>等待接收一次性消息...</p>
        <p class="tip">点击"发送一次性消息"按钮,这个消息只会被接收一次</p>
      </div>
      <div v-else class="once-received">
        <p class="success">✓ 已收到一次性消息:</p>
        <div class="once-message">{{ onceMessage }}</div>
        <p class="tip">即使再次发送,也不会再接收(因为使用了 once)</p>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import eventBus from '../utils/eventBus';

/**
 * Receiver 组件
 * 功能:通过 EventBus 订阅事件,接收其他组件发布的消息
 */

const messages = ref([]);
const userInfo = ref(null);
const counter = ref(null);

// once 示例相关的状态
const onceMessageReceived = ref(false);
const onceMessage = ref('');

/**
 * 处理收到的消息
 */
const handleMessage = (data) => {
  messages.value.unshift(data.text);
  // 只保留最近 5 条消息
  if (messages.value.length > 5) {
    messages.value = messages.value.slice(0, 5);
  }
};

/**
 * 处理收到的用户信息
 */
const handleUserInfo = (data) => {
  userInfo.value = data;
};

/**
 * 处理收到的计数器值
 */
const handleCounter = (count) => {
  counter.value = count;
};

/**
 * 处理一次性消息(once 示例)
 * 
 * 这个回调只会执行一次,执行后自动取消订阅
 */
const handleOnceMessage = (data) => {
  onceMessageReceived.value = true;
  onceMessage.value = data.text;
};

/**
 * 组件挂载时订阅事件
 * 
 * 订阅三个事件:
 * 1. 'message' - 消息事件(使用 on,可多次接收)
 * 2. 'userInfo' - 用户信息事件(使用 on,可多次接收)
 * 3. 'counter' - 计数器事件(使用 on,可多次接收)
 * 4. 'onceMessage' - 一次性消息事件(使用 once,只接收一次)
 */
onMounted(() => {
  // 使用 on 订阅事件(可多次接收)
  eventBus.on('message', handleMessage);
  eventBus.on('userInfo', handleUserInfo);
  eventBus.on('counter', handleCounter);
  
  // 使用 once 订阅事件(只接收一次,执行后自动取消订阅)
  // 这是 once 方法的调用示例
  eventBus.once('onceMessage', handleOnceMessage)
});

/**
 * 组件卸载时取消订阅
 * 
 * 重要:必须取消订阅,避免内存泄漏
 * 否则回调函数会一直保留在内存中
 */
onUnmounted(() => {
  // 取消所有订阅
  eventBus.off('message', handleMessage);
  eventBus.off('userInfo', handleUserInfo);
  eventBus.off('counter', handleCounter);
});
</script>

<style scoped>
.receiver {
  padding: 20px;
  border: 2px solid #3498db;
  border-radius: 8px;
  background-color: #f0f8ff;
}

h3 {
  margin-top: 0;
  color: #3498db;
}

h4 {
  color: #2980b9;
  margin-top: 20px;
  margin-bottom: 10px;
}

.messages {
  margin-bottom: 20px;
}

.empty {
  padding: 10px;
  color: #999;
  font-style: italic;
}

.message-list {
  max-height: 150px;
  overflow-y: auto;
}

.message-item {
  padding: 8px 12px;
  margin: 5px 0;
  background-color: white;
  border-left: 3px solid #3498db;
  border-radius: 4px;
  font-size: 14px;
}

.user-info .info-card {
  padding: 15px;
  background-color: white;
  border-radius: 4px;
  border: 1px solid #ddd;
}

.info-card p {
  margin: 8px 0;
  color: #333;
}

.counter-value {
  font-size: 32px;
  font-weight: bold;
  color: #e74c3c;
  text-align: center;
  padding: 20px;
  background-color: white;
  border-radius: 4px;
  border: 2px solid #e74c3c;
}

.once-example {
  margin-top: 20px;
  padding: 15px;
  background-color: #fff3cd;
  border-radius: 4px;
  border: 2px solid #ff9800;
}

.once-pending {
  color: #856404;
}

.once-pending .tip {
  font-size: 12px;
  margin-top: 10px;
  color: #856404;
  font-style: italic;
}

.once-received {
  color: #155724;
}

.once-received .success {
  font-weight: bold;
  color: #155724;
  margin-bottom: 10px;
}

.once-message {
  padding: 10px;
  background-color: white;
  border-radius: 4px;
  border-left: 3px solid #ff9800;
  margin: 10px 0;
  font-weight: bold;
}

.once-received .tip {
  font-size: 12px;
  margin-top: 10px;
  color: #856404;
  font-style: italic;
}
</style>

再创建一个发布者组件 Publisher.vue ,代码如下:

<template>
  <div class="sender">
    <h3>发送者组件(发布事件)</h3>
    <div class="buttons">
      <button @click="sendMessage">发送消息</button>
      <button @click="sendUserInfo">发送用户信息</button>
      <button @click="sendCounter">发送计数器</button>
      <button @click="sendOnceMessage" class="btn-once">
        发送一次性消息
      </button>
    </div>
    <div class="info">
      <p>已发送 {{ messageCount }} 条消息</p>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import eventBus from '../utils/eventBus';

/**
 * 
 * 功能:通过 EventBus 发布事件,通知其他组件
 */

const messageCount = ref(0);

/**
 * 发送消息事件
 */
const sendMessage = () => {
  messageCount.value++;
  const message = `这是第 ${messageCount.value} 条消息 - ${new Date().toLocaleTimeString()}`;
  
  // 发布 'message' 事件,传递消息内容
  eventBus.emit('message', {
    text: message,
    timestamp: new Date().toISOString()
  });
};

/**
 * 发送用户信息事件
 */
const sendUserInfo = () => {
  const userInfo = {
    id: 1,
    name: '张三',
    email: `zhangsan@${Math.floor(Math.random() * 1000)}.com`
  };
  
  // 发布 'userInfo' 事件
  eventBus.emit('userInfo', userInfo);
};

/**
 * 发送计数器事件
 */
const sendCounter = () => {
  const count = Math.floor(Math.random() * 100);
  
  // 发布 'counter' 事件
  eventBus.emit('counter', count);
};

/**
 * 发送一次性消息事件(用于演示 once 方法)
 * 
 * 这个事件只会被 once 订阅者接收一次
 * 即使多次点击,只有第一次会触发回调
 */
const sendOnceMessage = () => {
  const message = `这是一次性消息 - ${new Date().toLocaleTimeString()}`;
  
  // 发布 'onceMessage' 事件
  eventBus.emit('onceMessage', {
    text: message,
    timestamp: new Date().toISOString()
  });
};
</script>

<style scoped>
.sender {
  padding: 20px;
  border: 2px solid #42b983;
  border-radius: 8px;
  background-color: #f0f9ff;
}

h3 {
  margin-top: 0;
  color: #42b983;
}

.buttons {
  display: flex;
  gap: 10px;
  margin: 15px 0;
  display: flex;
  flex-direction: column;
}

button {
  padding: 10px 20px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: background-color 0.3s;
}

button:hover {
  background-color: #35a372;
}

button.btn-once {
  background-color: #ff9800;
}

button.btn-once:hover {
  background-color: #f57c00;
}

.info {
  margin-top: 15px;
  padding: 10px;
  background-color: #e8f5e9;
  border-radius: 4px;
}

.info p {
  margin: 0;
  color: #2e7d32;
}
</style>

根组件代码:

<template>
  <div id="app">
    <div class="container">
      <header>
        <h1>EventBus 订阅发布模</h1>
        <p class="subtitle">实现非父子组件间的跨层级通信</p>
      </header>

      <div class="components">
        <!-- 发送者组件 -->
        <Sender />      
        <!-- 接收者组件 -->
        <Receiver />
        
      </div>
    </div>
  </div>
</template>

<script setup>
import Sender from './components/Sender.vue';
import Receiver from './components/Receiver.vue';
</script>

<style>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  min-height: 100vh;
  padding: 20px;
}

#app {
  max-width: 1200px;
  margin: 0 auto;
}

.container {
  background-color: white;
  border-radius: 12px;
  padding: 30px;
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
}

header {
  text-align: center;
  margin-bottom: 30px;
  padding-bottom: 20px;
  border-bottom: 2px solid #eee;
}

header h1 {
  color: #333;
  font-size: 32px;
  margin-bottom: 10px;
}

.subtitle {
  color: #666;
  font-size: 16px;
}

.components {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 20px;
  margin-bottom: 30px;
}
</style>

总结

发布订阅模式的核心价值是解耦:发布者无需知道谁订阅了事件,订阅者无需知道事件由谁发布;前端中最常用的场景是「跨组件通信」和「事件驱动逻辑」,最常用的工具是 Vue 生态的 mitt/EventBus、原生 EventEmitter、Socket.IO 等。使用时需注意取消订阅,避免内存泄漏。

从前端送花说起:HTML敲击乐与JavaScript代理模式的浪漫邂逅

作者 栀秋666
2025年12月15日 00:48

💥 引言:当“小明”决定用代码表白时,世界安静了0.01秒

那是一个风和日丽的下午,小明站在电脑前,手握键盘,眼神坚定。
他不想再靠微信发“在吗?”来撩妹了。
他要用——HTML + CSS + JS 三剑客,写一段能敲出音符、还能自动帮他追女孩的程序。

于是,一个叫《敲击乐》的项目诞生了。
而这个项目的背后,藏着前端开发最核心的哲学:
👉 结构归HTML,颜值归CSS,行为归JS,爱情……归代理模式


🎹 第一幕:敲击乐上线!按个键都能奏响爱的旋律

我们先来看一段能让浏览器变成钢琴的代码:

<!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8" />
  <title>小明的告白钢琴</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>

  <div class="key" data-key="65">A</div>
  <div class="key" data-key="83">S</div>
  <div class="key" data-key="68">D</div>
  <div class="key" data-key="70">F</div>

  <audio data-key="65" src="sounds/clap.wav"></audio>
  <audio data-key="83" src="sounds/hihat.wav"></audio>
  <audio data-key="68" src="sounds/kick.wav"></audio>
  <audio data-key="70" src="sounds/snare.wav"></audio>

  <script>
    document.addEventListener('DOMContentLoaded', () => {
      function playSound(e) {
        const keyCode = e.keyCode;
        const key = document.querySelector(`.key[data-key="${keyCode}"]`);
        const audio = document.querySelector(`audio[data-key="${keyCode}"]`);

        if (!key) return; // 按了个寂寞?

        key.classList.add('playing');
        audio.currentTime = 0; // 重复触发不卡顿
        audio.play();
      }

      function removeTransition(e) {
        if (e.propertyName !== 'transform') return;
        this.classList.remove('playing');
      }

      const keys = document.querySelectorAll('.key');
      keys.forEach(key => key.addEventListener('transitionend', removeTransition));
      window.addEventListener('keydown', playSound);
    });
  </script>
</body>
</html>
/* style.css */
.key {
  border: 1px solid #ccc;
  border-radius: 5px;
  margin: 1rem;
  padding: 2rem;
  font-size: 2rem;
  text-align: center;
  transition: all 0.1s ease;
}

.playing {
  transform: scale(1.1);
  background-color: #ff4d4f;
  color: white;
  box-shadow: 0 0 10px red;
}

✨ 效果是什么?
你按下 A S D F,页面上的按钮会“跳起来”,同时播放鼓点音效!

但重点不是这个——
重点是:为什么 JS 放在 <body> 最下面?


🧠 原理揭秘:浏览器的“渐进式恋爱法则”

浏览器加载网页,就像一场相亲:

  1. 第一眼看脸(DOM树)
    • 浏览器从上往下读 HTML,构建 DOM 结构。
  2. 第二眼看妆容(CSSOM树)
    • 遇到 <link> 就去下载 CSS,解析样式规则。
  3. 第三眼才考虑性格(JS执行)
    • JS 在最后加载,避免阻塞页面渲染。

📌 所以规范建议:

<!-- ✅ 正确姿势 -->
<head>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <!-- 页面内容 -->
  <script src="app.js"></script> <!-- 放底部! -->
</body>

如果把 JS 写在 <head>,就会出现“毛坯房闪屏”——用户先看到一堆没样式的文字,然后“啪”一下变美了,体验极差。

📢 用户说:“我不要过程,我要结果!”
浏览器说:“好的,先给你静态页,JS我慢慢加。”


👯‍♀️ 第二幕:小明不会直接送花?因为他用了「代理模式」

让我们进入本剧高潮——情感代理系统上线!

let zhang = {
  name: '小明',
  hometown: '江西抚州',
  age: 18,
  isSingle: false,
  sendFlower(targetProxy) {
    console.log(`${this.name} 准备送花...`);
    targetProxy.receiveFlower(this); // 不直接给小美!走代理!
  }
};

let xiaomei = {
  name: '小美',
  xq: 30, // 心情值,低于80不约
  receiveFlower(sender) {
    console.log(`小美收到了${sender.name}的花🌸`);
    if (this.xq < 80) {
      console.log('不约,不合适。');
    } else {
      console.log('走,去硕果吃甜品!');
    }
  }
};

// 💡 代理登场 —— 小红成了“情感中介”
let xiaohong = {
  name: '小红',
  receiveFlower(sender) {
    console.log(`小红代收花束,启动情绪调理程序...`);
    
    // 秘密操作:延迟3秒提升心情值
    setTimeout(() => {
      xiaomei.xq = 90;
      console.log('【系统通知】小美心情值已提升至90!');
      xiaomei.receiveFlower(sender);
    }, 3000);
  }
};

🎯 使用方式:

zhang.sendFlower(xiaohong); 
// 输出:
// 小明 准备送花...
// 小红代收花束,启动情绪调理程序...
// 【系统通知】小美心情值已提升至90!
// 小美收到了小明的花🌸
// 走,去硕果吃甜品!

🔍 什么是代理模式?

角色 说明
真实主题 小美(目标对象)
代理对象 小红(中间人)
客户端 小明(调用者)

优点

  • 客户端无需知道真实逻辑
  • 可以添加额外行为(如延迟、权限控制、日志记录)
  • 实现解耦,增强扩展性

🧠 类比现实:

就像你想追班花,不敢直接说话,于是找闺蜜传话:“帮我递瓶奶茶,顺便夸她今天好看。”
闺蜜就是代理,她可以帮你润色语言、观察反应、甚至制造机会。


🧩 数据类型:JavaScript 的“恋爱人格测试”

在 JS 的世界里,每个变量都有自己的“性格”。来测一测你是哪种类型?

1️⃣ 字符串 string —— 戏精本精

let bio = `我是${zhang.name},来自${zhang.hometown}`;
console.log(bio); // 我是小明,来自江西抚州 v
  • 特点:天生爱表现,支持模板字符串
  • 缺点:不可变!改一次就得重生

2️⃣ 数值 number —— 理科直男

0.1 + 0.2 === 0.3 // ❌ false!结果是 0.30000000000000004
  • 精度问题堪比渣男承诺:“我会改的……下次一定。”

3️⃣ 布尔值 boolean —— 非黑即白

!!"love"     // true
!!""         // false
!!null       // false
!!undefined  // false
  • 判断标准简单粗暴:有内容就是真,没内容就是假

4️⃣ 对象 object —— 多面体人格

let user = { name: "小美", hobby: ["奶茶", "拍照"] };
let copy = user;
copy.name = "小红";
console.log(user.name); // 小红 😱

⚠️ 注意:对象是引用传递!改副本等于改本人!

5️⃣ null vs undefined —— “失联”双子星

维度 undefined null
含义 自然未定义(我没想好) 主动清空(我不想活了)
typeof "undefined" "object"(历史Bug)
Number转换 NaN 0
使用场景 未赋值变量 主动释放内存

💬 小美问:“你还爱我吗?”
undefined:我不知道…
null:不爱了,删好友吧。


🏁 总结:前端开发的本质,是一场精心设计的表演

层级 职责 类比
HTML 内容结构 相亲简历
CSS 视觉表现 化妆穿搭
JS 行为交互 情商话术
设计模式 架构思想 恋爱战术

💡 开发启示:

  1. 模块化分工:别让CSS写逻辑,也别让JS管排版;
  2. 加载顺序优化:让用户先看到“人”,再了解“性格”;
  3. 善用设计模式:复杂逻辑交给代理、工厂、观察者处理;
  4. 理解数据本质:知道什么时候该深拷贝,什么时候该转类型。

❌
❌