阅读视图

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

JavaScript 学习笔记:深入理解 map() 方法与面向对象特性

JavaScript 学习笔记:深入理解 map() 方法与面向对象特性

一、Array.prototype.map() 方法详解

map() 是 ES6 中引入的重要数组方法之一,属于高阶函数(Higher-order Function) 。它的核心作用是对原数组的每个元素执行一个回调函数,并返回一个由回调函数返回值组成的新数组,而不会修改原数组。

1.1 基本语法

const newArray = arr.map(callbackFn(element, index, array), thisArg);
  • callbackFn:为每个元素执行的函数,必须返回一个值。

    • element:当前元素
    • index:当前索引
    • array:原数组本身
  • thisArg(可选) :指定回调函数中 this 的值

1.2 使用示例

const numbers = [1, 4, 9];
const roots = numbers.map(num => Math.sqrt(num)); // [1, 2, 3]
const doubles = numbers.map(num => num * 2);      // [2, 8, 18]

注意:map() 不会改变原数组,而是返回一个全新数组。

1.3 常见陷阱:map(parseInt) 的误区

一个经典面试题:

console.log(["1", "2", "3"].map(parseInt)); // [1, NaN, NaN]

原因分析

map() 会传递三个参数给回调函数:(element, index, array)
parseInt(string, radix) 接收两个参数:字符串和进制基数。

因此实际调用过程如下:

parseInt("1", 0)  // → 1(radix=0 被忽略,默认十进制)
parseInt("2", 1)  // → NaN(1进制非法)
parseInt("3", 2)  // → NaN(2进制不含数字3)

正确写法

// 方式1:显式指定基数
["1", "2", "3"].map(str => parseInt(str, 10));

// 方式2:使用 Number 构造函数(更简洁)
["1", "2", "3"].map(Number); // [1, 2, 3]

⚠️ 注意:Number() 会解析浮点数和科学计数法,而 parseInt() 只取整数部分。


二、NaN(Not a Number)详解

2.1 什么是 NaN?

  • NaN 表示“不是一个数字”,但其 typeof 结果为 'number'
  • 它出现在无效的数学运算或类型转换中。

2.2 常见产生 NaN 的场景

console.log(0 / 0);           // NaN
console.log("abc" - 1);       // NaN
console.log(undefined + 1);   // NaN
console.log(parseInt("hello")); // NaN
console.log(Math.sqrt(-1));   // NaN

2.3 NaN 的特殊性质

  • NaN 不等于任何值,包括它自己

    console.log(NaN == NaN);     // false
    console.log(NaN === NaN);    // false
    
  • 正确判断 NaN 的方法

    if (Number.isNaN(value)) {
      console.log("这是一个 NaN");
    }
    

    ✅ 推荐使用 Number.isNaN(),而非全局 isNaN()(后者会进行类型转换,可能导致误判)。


三、JavaScript 的面向对象特性与包装类

3.1 JS 是完全面向对象的语言

尽管 JavaScript 有原始类型(如 string, number, boolean),但它通过包装类(Wrapper Classes) 实现了统一的对象调用风格。

例如:

"hello".length;        // 5
114.514.toFixed(2);    // "114.51"

这些看似“原始类型调用方法”的操作,在底层实际上是:

(new String("hello")).length;
(new Number(114.514)).toFixed(2);

JS 引擎会临时创建包装对象,调用方法后立即销毁,实现“傻瓜式”编程体验。

3.2 包装类的生命周期

let str = "hello";               // 原始字符串
let strObj = new String(str);    // 显式创建 String 对象
console.log(typeof str);         // "string"
console.log(typeof strObj);      // "object"
strObj = null;                   // 手动释放(通常不需要)

💡 日常开发中无需手动创建包装对象,JS 会自动处理。


四、字符串处理相关知识补充

4.1 字符串长度与编码

JavaScript 使用 UTF-16 编码,大多数字符占 1 个单位,但某些 Unicode 字符(如 emoji)占 2 个或更多:

console.log("a".length);     // 1
console.log("中".length);    // 1
console.log("𝄞".length);    // 2(音乐符号)
console.log("👋".length);    // 2(emoji)

4.2 常用字符串方法对比

方法 支持负索引 参数顺序处理 示例
slice(start, end) 保持顺序,start > end 返回空 "Hello".slice(-3, -1)"ll"
substring(start, end) ❌(负数转为 0) 自动交换使 start ≤ end "Hello".substring(3, 1)"el"

4.3 查找字符位置

const str = "Hello";
console.log(str.indexOf('l'));      // 2(首次出现)
console.log(str.lastIndexOf('l'));  // 3(最后一次出现)

五、总结与最佳实践

  1. 慎用 map(parseInt) :务必显式指定进制或改用 Number
  2. 正确判断 NaN:使用 Number.isNaN() 而非 == NaN
  3. 理解包装类机制:原始类型能调用方法是 JS 的语法糖,背后是临时对象。
  4. 优先使用 map 返回新数组:若不需要返回值,应使用 forEach
  5. 注意字符串编码问题:处理 emoji 或生僻字时,length 可能不符合直觉。

通过深入理解 map()、NaN、包装类等核心概念,我们不仅能写出更健壮的代码,还能避免常见的“坑”。JavaScript 虽灵活,但其设计哲学强调开发者友好性与一致性,掌握这些底层机制,方能真正驾驭这门语言。

📚 参考资料:MDN - Array.prototype.map()

数据字典:从"猜谜游戏"到"优雅编程"的奇幻之旅

👀 我们先来看一段没有使用数据字典的代码

javascript

// 看到这段代码,你什么感受?
if (user.type === 1) {
  // ... 
} else if (user.type === 2) {
  // ...
}

内心os:  "这1和2到底是什么?这我得去问多少人呀!"

javascript

//当我们需求要把'禁用'改成'冻结',把状态值从1改成2
if (user.status === 1) {
  return '禁用'; // 这里要改
}
<Option value={1}>禁用</Option> // 这里要改
const disabledUsers = users.filter(u => u.status === 1); // 这里要改

内心os:  "这到底是什么破代码!"

🎉 那用了数据字典后是什么样的呢?

javascript

if (user.type === USER_TYPE.VIP){
  // 奥奥,原来是vip用户!
}

const USER_STATUS = {
  NORMAL: { value: 0, label: '正常' },
  DISABLED: { value: 2, label: '冻结' } // 这里value从1改成2label从'禁用'改成'冻结'
};

内心os:  "天呐!好方便!"

所以,数据字典的真正作用是:让我们写的代码,既能被机器正确执行,也能被人轻松读懂。

🏠 那数据字典存储在哪里呢?

javascript

// 刚开始,我觉得把字典写死在前端简直太方便了
export const DEPARTMENT = {
  TECH: { value: 1, label: '技术部' },
  PRODUCT: { value: 2, label: '产品部' },
  DESIGN: { value: 3, label: '设计部' },
  OPERATION: { value: 4, label: '运营部' }
};

内心os:  "一次定义,到处使用,美滋滋!没有网络请求,性能杠杠的!代码清晰,类型安全,完美!"

javascript

export const USER_ROLE = {
  ADMIN: { value: 1, label: '管理员' },
  USER: { value: 2, label: '普通用户' },
  GUEST: { value: 3, label: '访客' }
};

💥 那么此时问题来了

问题1:  如果现在的需求变了,要把技术部分为前端部和后端部。哦!这简直是个灾难。因为所有的业务也跟着变化!那我要改到什么时候!!

问题2:  如果我现在想在USER_Role里面添加一个审核员的角色,那现在value值要写多少呢?4?会不会和后台冲突?

  • 后端也要同步改,前后端要一起上线!
  • 如果后端先上线,前端还没改,就显示不出来!
  • 如果前端先上线,后端还没改,就会报错!

🎯 所以什么适合写死在前端呢?

✅ 适合写死在前端的(不会变的)

javascript

// 1. 通用状态枚举(业务逻辑相关)
export const ORDER_STATUS = {
  PENDING: { value: 1, label: '待支付' },
  PAID: { value: 2, label: '已支付' },
  COMPLETED: { value: 3, label: '已完成' },
  CANCELLED: { value: 4, label: '已取消' }
};
// 理由:这些状态与业务逻辑强相关,基本不会改变

// 2. 界面状态
export const BUTTON_SIZE = {
  SMALL: { value: 'small', label: '小' },
  LARGE: { value: 'large', label: '大' }
};
// 理由:纯前端控制,与后端无关

// 3. 颜色、样式映射
export const STATUS_COLOR = {
  SUCCESS: { value: 'success', color: '#52c41a' },
  ERROR: { value: 'error', color: '#ff4d4f' },
  WARNING: { value: 'warning', color: '#faad14' }
};
// 理由:纯前端显示逻辑

❌ 不适合写死在前端的

javascript

// 1. 组织架构数据
export const DEPARTMENT = {
  TECH: { value: 1, label: '技术部' },
  PRODUCT: { value: 2, label: '产品部' }
};

// 应该从后端获取
const [departments, setDepartments] = useState([]);
useEffect(() => {
  api.getDepartments().then(setDepartments);
}, []);

// 2. 分类标签数据
export const ARTICLE_CATEGORY = {
  TECH: { value: 1, label: '技术文章' },
  NEWS: { value: 2, label: '公司新闻' }
};

const [categories, setCategories] = useState([]);
useEffect(() => {
  api.getArticleCategories().then(setCategories);
}, []);

// 3. 权限角色数据
export const USER_ROLE = {
  ADMIN: { value: 1, label: '管理员' },
  USER: { value: 2, label: '普通用户' }
};

// 应该由后端管理
const [roles, setRoles] = useState([]);
useEffect(() => {
  api.getRoles().then(setRoles);
}, []);

🤔 既然明白了字典的存储,那又有一个问题了。前端要怎么样去管理后端返回的数据字典呢?

在思考这个问题之前,我们先来思考一个问题:我们为什么要去管理后端返回的数据字典呢?

在实际开发中,一个常见的痛点是:同一个字典数据(如"用户状态"、"部门列表")可能在应用的多个组件或模块中被使用。如果每个使用的地方都独立发起请求,会导致对同一个接口的重复调用

这会带来三个问题:

  1. 增加服务端压力和网络开销
  2. 可能导致数据不一致(如果多次请求之间数据更新了)
  3. 影响用户体验,用户会反复看到Loading状态

因此,一个高效的策略是引入前端缓存机制。它的工作流程如下:

  1. 当需要某个字典数据时(例如 userStatus),前端首先检查缓存中是否存在
  2. 如果缓存中存在,则直接返回该数据
  3. 如果缓存中不存在,则向后台发起请求,获取数据后放入缓存,再返回

这套机制的核心价值在于:

  • 性能优化:避免了重复请求,减轻了前后端负担
  • 体验提升:缓存的读取是瞬时的,用户无需等待
  • 状态统一:确保了整个应用使用的字典数据是同一份,消除了不一致的风险

🏗️ 前端要怎么样去管理后端返回的数据字典

好的,直接给出核心答案。前端管理后端字典的核心方法是:建立一套中心化的缓存机制

1. 创建全局字典仓库

javascript

// 创建一个全局的字典仓库
const dictCache = new Map(); // 使用Map存储所有字典数据

// 或者使用状态管理库
const useDictStore = create((set, get) => ({
  dicts: {},
  fetchDict: async (dictKey) => {
    const state = get();
    // 1. 先查缓存,有则直接返回
    if (state.dicts[dictKey]) {
      return state.dicts[dictKey];
    }
    // 2. 没有则请求并缓存
    const data = await api.getDict(dictKey);
    set({ dicts: { ...state.dicts, [dictKey]: data } });
    return data;
  }
}));

2. 统一接入层

javascript

// 所有组件都通过这个Hook获取字典
const useDict = (dictKey) => {
  const { dicts, fetchDict } = useDictStore();
  
  useEffect(() => {
    if (!dicts[dictKey]) {
      fetchDict(dictKey);
    }
  }, [dictKey]);
  
  return dicts[dictKey] || [];
};

3. 业务组件使用

javascript

// 所有地方都这样使用
const UserForm = () => {
  const departments = useDict('departments'); // 同一份数据,多个组件共享
  const roles = useDict('user_roles');
  
  return (
    <select>
      {departments.map(item => 
        <option key={item.value} value={item.value}>{item.label}</option>
      )}
    </select>
  );
};

📚 三层管理架构

1. 数据层 - dictCache / useDictStore

角色:数据的唯一真相来源

职责:负责与后端通信,并在内存中持久化获取到的字典数据。它像一个全局仓库,所有字典数据都存储于此

2. 接入层 - useDict Hook

角色:连接组件与数据层的桥梁

职责:组件不直接接触底层缓存和API,而是通过这个Hook。它封装了复杂的逻辑:首先检查缓存,若存在则立即返回,若不存在则触发请求并更新缓存

3. 展示层 - 业务组件

角色:数据的使用者

职责:只需声明需要什么字典(如 useDict('departments')),无需关心数据从哪里来、是否已经加载过。它们总是能获得统一、一致的数据

MJML邮件如何随宽度变化动态切换有几列📮

需求:邮件中需要展示数组信息,每个模块宽高固定不变,在PC端(600px)三列展示在移动端(400px)两列展示,且该mjml格式邮件样式在GMail中可以正常显示。

MJML官方文档:MJML - The Responsive Email Framework

MJML在现示例查看:Email Editor

一、效果展示及完整代码

1.1. 效果展示

PC端(宽度600px)

移动端(宽度400px)

1.2. 完整代码

注:下列代码请在支持解析MJML文件的项目下运行查看


<mjml>
  <mj-head>
    <mj-style inline="inline">
      .card-content {
        width: 100%;
        text-align: left;
        font-size:0;
        background: red;
      }
      .fixed-item {
        display: inline-block !important;
        width: 180px !important;
        height: 100px !important;
        margin: 10px !important;
        color: #000;
        font-size: 14px;
        line-height: 100px;
        background: #f0f0f0 !important;
        text-align: center !important;
        vertical-align: top !important;
      }
      .item-image {
        float: left;
        width: 42%;
        height: 100%;
      }
      .item-image img {
        width: 100%;
      }
      .item-details {
        float: left;
        width: 58%;
        height: 100%;
        font-family: PingFang SC;
        text-align: left;
      }
      .item-details-text-title {
        margin: 15px 10px 5px 10px;
        height: 24px;
        line-height: 24px;
        font-size: 18px;
        font-weight: 600;
        color: #13171D;
      }
      .item-details-text-subtitle {
        margin: 0 10px;
        height: 24px;
        line-height: 24px;
        font-size: 14px;
        color: #6d6d6d;
      }
    </mj-style>
  </mj-head>
  <mj-body>
    <mj-section>
      <mj-column>
        <mj-raw>
          <div class="card-content">
            <!-- 固定宽高元素会自动换行 -->
            <div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字1</div>
                <div class="item-details-text-subtitle">这是一段描述文字「1」</div>
              </div>
            </div>
            <div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字2</div>
                <div class="item-details-text-subtitle">这是一段描述文字「2」</div>
              </div>
            </div><div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字3</div>
                <div class="item-details-text-subtitle">这是一段描述文字「3」</div>
              </div>
            </div><div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字4</div>
                <div class="item-details-text-subtitle">这是一段描述文字「4」</div>
              </div>
            </div><div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字5</div>
                <div class="item-details-text-subtitle">这是一段描述文字「5」</div>
              </div>
            </div><div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字6</div>
                <div class="item-details-text-subtitle">这是一段描述文字「6」</div>
              </div>
            </div><div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字7</div>
                <div class="item-details-text-subtitle">这是一段描述文字「7」</div>
              </div>
            </div><div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字8</div>
                <div class="item-details-text-subtitle">这是一段描述文字「8」</div>
              </div>
            </div>
          </div>
        </mj-raw>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>

二、实现方法及逻辑解析

需求整理:

  • PC端(宽度600px)下三列显示,移动端(400px)下两列展示
  • 数组的每个元素宽高固定不变,不会随着宽高变化而比例性 压缩/拉伸
  • GMail中样式内容正常显示
  • MJML中不支持javascrip逻辑,MJML智能单纯的显示同步显示的值

方法调研

方法一( ❌ 不可行)

基于以上需求调研发现GMail不支持CSS3样式语法,这样下来display:flexdisplay:girdposition等诸多样式均不可使用

方法二( ❌ 不可行)

MJML中不支持写入javascrip逻辑,所以试用javascrip 操控/监听 DOM的方法是行不通的

方法三( ❌ 不可行)

MJML标签中有一个<mj-fixed-column width="33.3%">可以设置一行有几列,最后将<mj-fixed-column width="33.3%">标签包裹在<mj-section padding="0">

    1. 但是因为不能使用javascrip语言来监听尺寸变化,所以不能动态切换<mj-fixed-column>标签中width何时为 50% 何时为 33.3%
    2. 所以通过网络上查询发现可以考虑使用@media screen and (max-width: 480px),来实现屏幕尺寸变化时,来通过class样式来改变元素宽度
    3. 但是配置后发现MJML不能识别 或 运行@media screen and (max-width: 480px)这种代码,类似于MJML不能运行javascrip一样
方法四( ✅ 可行)

故基于以上,思路需要调整为如何让数组元素在GMail支持的样式配置中,跟随宽度变化自动换行,这样使得宽度为600px时三列显示,在宽度为400px时两列显示

通过配置如下代码:


<mjml>
  <mj-head>
    <mj-style inline="inline">
      .fixed-item {
        display: inline-block !important;
        width: 100px !important;
        height: 100px !important;
        margin: 10px !important;
        background: #f0f0f0 !important;
        text-align: center !important;
        vertical-align: top !important;
      }
    </mj-style>
  </mj-head>
  <mj-body>
    <mj-section>
      <mj-column>
        <mj-raw>
          <div style="text-align: left; font-size: 0;">
            <!-- 固定宽高元素会自动换行 -->
            <div class="fixed-item">项目1</div>
            <div class="fixed-item">项目2</div>
            <div class="fixed-item">项目3</div>
            <div class="fixed-item">项目4</div>
            <div class="fixed-item">项目5</div>
            <div class="fixed-item">项目6</div>
            <div class="fixed-item">项目7</div>
            <div class="fixed-item">项目8</div>
          </div>
        </mj-raw>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>

深入剖析 JavaScript 中 map() 与 parseInt 的“经典组合陷阱”

为什么 ["1", "2", "3"].map(parseInt) 返回 [1, NaN, NaN]
这个看似简单的代码片段,却藏着 JavaScript 函数调用机制、参数传递规则和类型转换的多重细节。本文将带你彻底搞懂这个高频面试题,并掌握安全使用 mapparseInt 的最佳实践。


🧩 一、问题重现:一个让人困惑的输出

先看这段代码:

js
编辑
console.log([1, 2, 3].map(parseInt)); // [1, NaN, NaN]

我们期望的是 [1, 2, 3],但实际结果却是 [1, NaN, NaN]。这是怎么回事?

要理解这个问题,我们需要分别了解两个核心知识点:

  • Array.prototype.map() 的回调函数参数规则
  • parseInt() 的参数含义和行为

🔍 二、map() 的回调函数到底传了什么?

map() 方法会对数组中的每个元素调用一次提供的回调函数,并将以下三个参数传入:

js
编辑
arr.map((element, index, array) => { /* ... */ })
  • element:当前元素(如 "1"
  • index:当前索引(如 012
  • array:原数组本身(如 ["1", "2", "3"]

我们可以通过打印验证:

js
编辑
[1, 2, 3].map(function(item, index, arr) {
  console.log('item:', item, 'index:', index, 'arr:', arr);
  return item;
});
// 输出:
// item: 1 index: 0 arr: [1, 2, 3]
// item: 2 index: 1 arr: [1, 2, 3]
// item: 3 index: 2 arr: [1, 2, 3]

所以,当你写 [1, 2, 3].map(parseInt) 时,实际上等价于:

js
编辑
[  parseInt(1, 0, [1,2,3]),
  parseInt(2, 1, [1,2,3]),
  parseInt(3, 2, [1,2,3])
]

parseInt 只会使用前两个参数!


📚 三、parseInt() 的真实面目

parseInt(string, radix) 接收两个参数:

参数 说明
string 要解析的字符串(会被自动转为字符串)
radix 进制基数(2~36),可选,默认为 10

⚠️ 关键点:如果 radix0 或未提供,按十进制处理;但如果 radix 是非法值(如 1),则返回 NaN

让我们逐行分析:

js
编辑
console.log(parseInt(1, 0));   // 1 → radix=0 被忽略,按十进制解析 "1"
console.log(parseInt(2, 1));   // NaN → 1 进制不存在!
console.log(parseInt(3, 2));   // NaN → "3" 不是合法的二进制数字(只能是 0/1)

💡 补充:parseInt("10", 8) → 8(八进制);parseInt("ff", 16) → 255(十六进制)

因此,["1", "2", "3"].map(parseInt) 实际执行如下:

元素 调用 结果
"1" parseInt("1", 0) 1 ✅
"2" parseInt("2", 1) NaN ❌
"3" parseInt("3", 2) NaN ❌

🛠 四、正确写法:三种安全方案对比

方案 1:显式箭头函数(推荐)

js
编辑
const result = ["1", "2", "3"].map(str => parseInt(str, 10));
console.log(result); // [1, 2, 3]

优点:清晰、可控、明确指定十进制
适用场景:需要严格整数解析,忽略小数部分


方案 2:使用 Number() 构造器

js
编辑
const result = ["1", "2", "3"].map(Number);
console.log(result); // [1, 2, 3]

优点:代码极简
⚠️ 注意差异

js
编辑
["1.1", "2e2", "3e300"].map(Number);       // [1.1, 200, 3e+300]
["1.1", "2e2", "3e300"].map(str => parseInt(str, 10)); // [1, 2, 3]

Number() 会完整解析浮点数和科学计数法,而 parseInt 会在遇到非数字字符时停止。


方案 3:封装专用函数(适合复用)

js
编辑
const toInt = (str) => {
  const num = parseInt(str, 10);
  if (isNaN(num)) {
    throw new Error(`无法解析为整数: ${str}`);
  }
  return num;
};

["1", "2", "abc"].map(toInt); // 抛出错误,便于调试

优点:增强健壮性,便于错误处理


⚠️ 五、关于 NaN 的补充知识

NaN(Not-a-Number)是 JavaScript 中一个特殊的数值类型,不与任何值相同

js
编辑
console.log(typeof NaN); // "number" ← 是的,它属于 number 类型!
console.log(NaN === NaN); // false ← 最反直觉的特性之一

如何正确判断 NaN?

❌ 错误方式:

js
编辑
if (value === NaN) { ... } // 永远为 false!不与热表格值相同

✅ 正确方式:

js
编辑
if (Number.isNaN(value)) { ... } // ES6 推荐
// 或
if (isNaN(value) && typeof value === 'number') { ... } // 兼容旧环境
console.log(0 / 0,6 / 0,-6 / 0);
NaN 0/0(无意义) Infinity6/0(趋于无穷大)  -Infinity-6/0(趋于无穷小)
console.log(Math.sqrt(-1));
console.log("abc" - 10);
console.log(undefined + 10);
console.log(parseInt("hello"));
const a = 0/0;
这些都是无意义的计算所以都是NaN

📊 六、实测数据:不同方法的解析行为对比

输入字符串 parseInt(s, 10) Number(s) 说明
"123" 123 123 相同
"123.45" 123 123.45 parseInt 截断
" 42 " 42 42 都会忽略前后空格
"42abc" 42 NaN parseInt 遇到非数字停止
"abc42" NaN NaN 两者都失败
"0xFF" 0 255 parseInt("0xFF", 16) 才是 255
"1e3" 1 1000 Number 支持科学计数法

📌 结论:根据需求选择——要整数用 parseInt(str, 10),要完整数值用 Number(str)


✅ 七、总结与最佳实践

🎯 核心要点

  1. map(callback) 会传入三个参数,即使 callback 只声明一个参数。
  2. parseInt 第二个参数是进制,误传索引会导致非法进制(如 1 进制)。
  3. 永远显式指定 radix 为 10,避免隐式行为。
  4. 不要直接传递 parseInt 给 map,除非你知道后果。

🛡 安全编码建议

js
编辑
// ✅ 推荐写法
const numbers = strArray.map(s => parseInt(s.trim(), 10));

// ✅ 更健壮的写法(带验证)
const safeParseInt = (s) => {
  if (typeof s !== 'string') return NaN;
  const n = parseInt(s.trim(), 10);
  return isNaN(n) ? null : n; // 或抛出错误
};

🔄 替代方案选择指南

需求 推荐方法
字符串 → 整数 parseInt(str, 10)
字符串 → 数值(含小数) Number(str) 或 +str
严格验证数字格式 结合正则 + Number.isNaN
大量数据转换 考虑性能,避免 try/catch

📌 八、延伸思考

  • 为什么 JavaScript 设计 parseInt 支持 radix?
    历史原因:早期 Web 需要解析不同进制的字符串(如颜色值 #ff0000)。
  • 能否用 flatMap 或其他方法避免此问题?
    不能,问题根源在于函数签名不匹配,与方法无关。
  • TypeScript 能防止这类错误吗?
    可以!TS 会提示 parseInt 的参数类型不匹配,提前暴露问题。

📚 参考资料

作者结语:看似简单的 API 组合,背后却隐藏着语言设计的细节。理解这些“坑”,不仅能写出更健壮的代码,也能在面试中脱颖而出。
欢迎点赞、收藏、评论!你是否也曾在项目中踩过这个坑?来分享你的经历吧 👇

TypeScript核心类型系统完全指南

第一部分:TypeScript基础入门

TypeScript简介

1.什么是TypeScript
  • TSJS 的超集,简单来说就是为 js添加了类型限定。众所周知js的类型系统存在 先天的缺陷,程序中很多的问题都是因为错误的 类型导致的。

    ts属于静态类型编程语言,js属于动态编程语言

2. Ts的优势
  • ts是前端项目的首选语言,ts中存在类型推断机制 不需要在代码中的每个地方都显示标注

体验TS与配置

体验ts
  1. ts交于js会对数据类型进行检查

  2. 只需要第一次 定义变量的时候对 数据类型进行注解

     let age:number=18;
    //:number 是类型注解  表示age变量的类型是number
    
常见的TS类型注解
  • 原始类型

    • number
    • string
    • boolean
    • symbol
    • null
    • undefined
  • 对象类型

  • object(数组 对象 函数)

  • 联合类型

    • 自定义类型(类型别名)
    • 接口
    • 元组
    • 字面量类型
    • 枚举
    • void
    • any
配置tsconfig.json
  • 需要在根目录中被指ts的配置文件,这是ts开发必备的 操作之一

    • {
        "compilerOptions": {
          "target": "ES2020",
          "module": "CommonJS",
          "strict": true, 
          "esModuleInterop": true,
          "skipLibCheck": true,
          "forceConsistentCasingInFileNames": true
        }
      }
      

第二部分:TypeScript核心类型系统

基础类型

原始类型

原始类型就是常见的 字符串 数字 布尔值 未定义 等等类型 基本上js怎么使用那么ts就这么使用 只是需要添加一些类型注解而已

let age: number=18;
let name: string='张三';
let sex: boolean=true;
let symbol: symbol=Symbol('123');
let nullValue: null=null;
let undefinedValue: undefined=undefined;
//js 里面怎么用 ts就是怎么用
数组类型

数组类型属于对象类型,在对象类型中每个子类型都有自己的细分语法

数组类型的类型注解: number[](推荐使用) 或者 :Array<string> 如果数组中存在多种数据类型就使用联合类型 (number | string)[]

//推荐写法
let numbers: number[]=[1,2,3,4,5];
let objArr:Object[]=[{},{}]
//其他写法
let strings:Array<string>=['1','2','3'];
//数组中含有多种类型数据 --> 联合类型
let arr: (number | string)[]=[1,'2',3,'4',5];
元组类型

元组可以看作确定元素个数与类型的数组,在部分场景里面会 使用到确定元素个数的数组类型 这种类型就叫做元组类型(比如:地图的经纬度)

元组类型类型注解::[number,number]

let Position=[31.232,12.653];
//元组的类型也不一定必须要一样
let Position2:[number,string]=[31.232,'12.653'];

枚举类型

枚举类型可以作为字面量类型的平替方案 枚举类型类似于字面量类型+联合类型的组合形态

枚举的值称为命名常量

定义枚举:

通过enum关键词定义 使用{}包裹命名常量

enum Direction{up,down,right,left}

枚举类型使用:

当使用枚举类型的函数需要调用的时候 只能通过枚举命名常量的属性来作为函数的参数

// 方向枚举
enum Direction {
    Up,
    Down,
    Left,
    Right
}

// 状态枚举
enum Status {
    Pending = "PENDING",
    Approved = "APPROVED",
    Rejected = "REJECTED"
}

// 函数参数使用枚举
function move(direction: Direction) {
    switch (direction) {
        case Direction.Up:
            console.log("向上移动");
            break;
        case Direction.Down:
            console.log("向下移动");
            break;
        // ...其他情况
    }
}

// 调用函数时使用枚举
move(Direction.Up);
枚举的种类

1.数字枚举

数字枚举是最常见的枚举类型,默认情况下第一个成员的值为0,后续成员按顺序递增:

enum Direction {
    Up,     // 值为 0
    Down,   // 值为 1
    Left,   // 值为 2
    Right   // 值为 3
}

// 使用枚举
let dir: Direction = Direction.Up;  // 值为 0
console.log(dir);  // 输出: 0

可以手动设置枚举成员的值:

// 设置起始值
enum Direction {
    Up = 1,    // 值为 1
    Down,      // 值为 2
    Left,      // 值为 3
    Right      // 值为 4
}

// 为每个成员设置具体值
enum Direction {
    Up = 1,
    Down = 3,
    Left = 5,
    Right = 9
}

2.字符枚举:

字符串枚举的每个成员都必须显式地初始化为字符串字面量,它们没有自增长行为:

enum Direction {    Up = "UP",    Down = "DOWN",    Left = "LEFT",    Right = "RIGHT" }
// 使用字符串枚举 
let dir: Direction = Direction.Up;
console.log(dir); // 输出: "UP"
  1. 异构枚举

虽然不建议使用,但技术上是可以混合字符串和数字成员的:

typescript

enum BooleanLikeHeterogeneousEnum {    No = 0,    Yes = "YES", }

字面量类型

字面量类型是一种特殊的类型,它将变量的类型限制为特定的值。与普通的stringnumberboolean类型不同,字面量类型不仅指定了值的类型,还指定了值的具体内容。

字面量类型的特点:

  1. 精确性:字面量类型比普通类型更加精确,限定了变量只能是特定的值
  2. 常量推断:使用const声明的变量,TypeScript会自动推断为字面量类型
  3. 可组合性:通过联合类型(Union Types)可以组合多个字面量类型

字面量类型用法:

  • let str1='hello ts' //string类型
    const str2='hello ts' //hello ts 字面量类型
    
  • 字面量类型通常与联合类型结合使用,用于限制函数参数或配置对象的取值范围

    // 限制函数参数的取值
    function setPosition(direction: 'left' | 'right' | 'up' | 'down') {
        // 函数体
    }
    
    // 调用时只能传入指定的字符串字面量
    setPosition('left');  // 正确
    setPosition('forward');  // 错误
    
    // 配置对象的属性限制
    interface Config {
        theme: 'light' | 'dark';
        size: 'small' | 'medium' | 'large';
    }
    
    const config: Config = {
        theme: 'dark',   // 只能是'light'或'dark'
        size: 'medium'   // 只能是'small'、'medium'或'large'
    };
    

与枚举类型相比

通常字面量类型与枚举类型可以替换

// 使用字面量类型
type Direction = 'up' | 'down' | 'left' | 'right';

// 使用枚举类型
enum DirectionEnum {
    Up,
    Down,
    Left,
    Right
}

联合类型

联合类型(Union Types)是TypeScript中的一种高级类型特性,它允许一个变量或参数可以是多种类型中的一种。联合类型使用竖线(|)分隔每个类型,表示"或"的关系。 后续在高级类型特性里面会详细介绍联合类型的各种使用方法

基本语法:

// 基本语法:Type1 | Type2 | Type3
let value: number | string;
value = 123;     // 正确,number类型
value = "hello"; // 正确,string类型
value = true;    // 错误,boolean类型不在联合类型中

复杂类型

对象类型

对象类型就是在描述对象的结构与各个属性的类型与 方法类似

1.基本写法
let person:
{
    name:string;
    age:number;
    sayHi(name:string,age:number):void
} = {
    name:'张三',
    age:18,
    sayHi(name:string):void{
        console.log('hi',name)
    }
}
2.箭头函数写法 :
//箭头函数写法
let person2:{
    name:string;
    age:number;
    // 箭头的后面写返回值类型 
    sayHi:(name:string)=>void
} = {
    name:'张三',
    age:18,
    sayHi:(name:string):void=>{
        console.log('hi',name)
    }
}

3.对象类型的可选属性
//对象类型可选属性
let person3:{
    name:string;
    age:number;
    // 箭头的后面写返回值类型 
    sayHi:(name:string)=>void;
    sex?:string;
} = {
    name:'张三',
    age:18,
    sayHi:(name:string):void=>{
        console.log('hi',name)
    },
    //sex:'man' //可有可无
}
函数类型

函数类型就是在js的基础上单独为行数的 参数 与返回值类型进行类型标注.

单独标注参数类型与返回值类型

就是单对为函数的参数与返回值进行类型标注

function add(X:number,Y:number):number{
    return X+Y;
}
同时标注二者类型

这是第一种写法 可读性很差 前面两个类型定义是定义两个参数的 后面一个类型定义是定义返回值类型的

const add2:(num1:number,num2:number)=>number=(num1,num2)=>{
    return num1+num2;
}

下面是可读性更高的一中 写法

// 定义函数类型别名
type AddFunction = (num1:number, num2:number) => number;

// 使用类型别名
const add2:AddFunction = (num1, num2) => {
    return num1 + num2;
}

箭头函数常用的定义方法

const add1 = (X:number, Y:number):number => {
    return X + Y;
}
返回值为void
function add3(X:number,Y:number):void{
    console.log(X+Y);
}
可选参数类型

在参数后加一个? 就是可选参数 但是不建议使用可选参数在需要计算的函数中. 值得注意的是 必选参数一定要放在可选参数的前面

function add4(X:number,Y?:number):void{
    console.log(X+(Y??1));
}

//必选参数不能位于可选参数之后
function mySlice(start?:number,end?:number):void{
    console.log('开始',start,'结束',end);
}
mySlice()

特殊类型

any类型

any类型是我们极不推荐使用的类型 因为any类型不会对代码进行保护 和js基本没有两样了. 如果每个变量都说用any类型,那代码就和js基本一模一样了,失去了ts作为静态类型语言的作用了

let a:any = 123;
a = '123';
//any类型会忽略类型检查 还不如不用ts
void类型

void类型表示没有任何类型,通常用于函数没有返回值的情况。如果变量被注解为void类型一般只能赋值为null或者是undefined

基本用法
// 函数没有返回值时,返回类型标记为void
function sayHello(): void {
    console.log("Hello!");
    // 不需要return语句,或者可以return;
}

// 等同于
function sayHello2(): void {
    console.log("Hello!");
    return; // 可以显式返回undefined
}

// 变量声明为void类型(不常用)
let unusable: void = undefined; // void类型只能赋值为undefined或null
实际应用场景
// 事件处理函数通常没有返回值
function handleClick(event: Event): void {
    console.log("按钮被点击了");
}

// 日志记录函数
function logMessage(message: string): void {
    console.log(`[LOG]: ${message}`);
}
null和undefined

在TypeScript中,null和undefined都有各自的类型,分别是null和undefined类型。

基本用法
// null类型
let nullValue: null = null;

// undefined类型
let undefinedValue: undefined = undefined;

// 在严格模式下,null和undefined只能赋值给any类型和它们各自类型
let num: number = null; // 错误:在严格模式下不允许
let str: string = undefined; // 错误:在严格模式下不允许
与联合类型结合使用
// 变量可以是字符串或null
let userName: string | null = null;
userName = "张三"; // 正确

// 变量可以是数字或undefined
let userAge: number | undefined = undefined;
userAge = 25; // 正确

// 函数返回值可能是对象或null
function findUser(id: number): User | null {
    // 查找用户逻辑
    // 如果找到返回User对象,否则返回null
    return null;
}
在React中的应用
// React中常见的状态初始化为null
const [user, setUser] = useState<User | null>(null);

// 使用可选链操作符安全访问属性
console.log(user?.name); // 如果user为null,不会报错
never类型

never类型表示永远不会发生的值的类型。它是TypeScript类型系统中的底部类型

使用场景
// 1. 函数抛出异常,永远不会有返回值
function throwError(message: string): never {
    throw new Error(message);
}

// 2. 函数中有无限循环,永远不会结束
function infiniteLoop(): never {
    while (true) {
        // 无限循环
    }
}

// 3. 类型守卫中的never
function exhaustiveCheck(value: never): never {
    throw new Error(`Unexpected value: ${value}`);
}
类型特点
  1. 底部类型neverTypeScript类型系统中的底部类型,它是所有类型的子类型
  2. 不可赋值:除了never本身,没有其他值可以赋值给never类型
  3. 类型推断:在某些情况下,TypeScript会自动推断出never类型

第三部分:类型高级特性

类型别名(Type Alias)

类型别名 即为自定义类型 当统一类型被多次使用时,可以通过类型别名 简化该类型的使用

定义与使用

使用type关键字来创建类型别名

// 使用type关节子创建类型别名
type myType=(number|string)[]
let arr1:myType=[1,2,'3'];
console.log(arr1);

接口(Interface)

一般情况下如果一个对象类型被多次使用的时候,为了达到复用的目的,会使用接口来描述对象的类型

定义:
 interface Person{
    name:string;
     age:number;
    sayHi():void;
}
 let person1:Person={
     name:'张三',
     age:18,
     sayHi(){
         console.log('hi',this.name)
     }
 }
接口的继承

接口可以使用extends来继承另一个接口中的类型注解

//如果两个接口有公共属性 就可以通过继承的方式实现复用
interface People{
    name:string;
}

interface Teacther extends People{
    age:number;
    subject:string;
}

let t: Teacther={
    name:'张三',
    age:18,
    subject:'Math'
}
接口的合并
// 接口声明合并
interface Window {
    title: string;
}

interface Window {
    ts: TypeScriptAPI;
}

// 现在Window接口有title和ts两个属性
接口与类型别名:

接口和类型别名很相似,都可以为对象指定类型. 但是区别也是很明显的

  • 接口可以通过继承来拓展自身的类型注解
  • 接口可以通过合并拓展自身的类型注解
  • 类型别名可以为任何类型创建别名,但是接口只适用于对象
//结构与类型别名都可以为对象指定类型
type APerosn={
    name:string,
    age:number
}

interface BPerson{
    name:string,
    age:number
}

let a:APerosn={
    name:'张三',
    age:18
}

let b:BPerson={
    name:'张三',
    age:18
}

类(Class)

1.类的定义

TS中也引入了class的语法糖,写法基本和js中的class语法糖相似 不同的是 需要提前定义类中属性与方法的类型注解

//ts引入了class语法
class Person{
    name:string;
    age:number;
    //构造函数就是一种方法 所以参数必须规定类型
    constructor(name:string,age:number){
        this.name = name;
        this.age = age;
    }
    sayHi(){
        //因为没有return TS自动做了类型推断 所以这个方法可以不用写void返回类型
        console.log(`大家好,我叫${this.name},今年${this.age}岁`);
    }
    //类的实例方法和对象的方法是一样的 也需要指定类型
    changeName(name:string){
        this.name=name;
    }
}
// 如果类没有类型 会自动默认为any属性
let p1=new Person('张三',18)
2.类的继承

类的继承分为两种 一种是继承父类 另一种是继承接口中的类型定义

类的继承和其他语言面向对象类似,可以重写继承来的方法 也可以省略不写 使用父类继承来的方法. 而接口的继承和jva`是类似的.

//ts有两种继承方法 1.extends(继承父类) 2.implements(实现接口 ts特有)
class Animal{
    move(){
        console.log('move');
    }
}

class Dog extends Animal{
    bark(){
        console.log('bark');
    }
}

let dog=new Dog();
dog.bark()

//2.implements  继承interface接口
//和java一样 接口只能定义属性和抽象方法(没有实现的方法)
interface Person{
    name:string;
    age:number;
    move():void
}

class Student implements Person{
    name: string;
    age: number;
    constructor(name:string,age:number){
        this.name=name;
        this.age=age;
    }
    move(){
        console.log('move');
    }
}
3.类成员的可见性

类中的成员属性有四种访问修饰符 不仅仅是修饰属性的 也可以修饰方法

  • public - 公开的,任何人都可以访问(默认)
  • private - 私有的,只能在类内部访问
  • protected - 受保护的,只能在类和子类中访问
  • readonly - 只读的,只能在声明时或构造函数中初始化

演示代码

// 基类 - 演示所有四种访问修饰符
class Person {
    // 1. public - 公开的,任何人都可以访问(默认)
    public name: string;
    
    // 2. private - 私有的,只能在类内部访问
    private secret: string;
    
    // 3. protected - 受保护的,只能在类和子类中访问
    protected age: number;
    
    // 4. readonly - 只读的,只能在声明时或构造函数中初始化
    readonly id: number;
    
    constructor(name: string, secret: string, age: number, id: number) {
        this.name = name;
        this.secret = secret;
        this.age = age;
        this.id = id;
    }
    
    // 公共方法可以访问所有成员
    public introduce(): void {
        console.log(`我叫${this.name},年龄${this.age},ID: ${this.id}`);
        // console.log(this.secret); // 可以在类内部访问private成员
    }
    
    // 私有方法只能在类内部调用
    private tellSecret(): void {
        console.log(`我的秘密是: ${this.secret}`);
    }
}

// 子类 - 继承Person类
class Student extends Person {
    public grade: string;
    
    constructor(name: string, secret: string, age: number, id: number, grade: string) {
        super(name, secret, age, id);
        this.grade = grade;
    }
    
    public study(): void {
        console.log(`${this.name}正在学习`);
        // 可以访问protected成员
        console.log(`年龄: ${this.age}`);
        
        // 不能访问private成员 - 会报错
        // console.log(this.secret); // Error: Property 'secret' is private
        
        // 可以访问public成员
        console.log(`ID: ${this.id}`);
    }
}

// 测试代码
const person = new Person('张三', '我喜欢吃糖', 25, 1001);
const student = new Student('李四', '我害怕考试', 18, 1002, '高三');

// 1. public成员 - 可以任意访问
console.log(person.name);     // 输出: 张三
console.log(student.name);   // 输出: 李四

// 2. private成员 - 不能在类外部访问
 console.log(person.secret);  // Error: Property 'secret' is private
 console.log(student.secret); // Error: Property 'secret' is private

// 3. protected成员 - 不能在类外部访问
 console.log(person.age);     // Error: Property 'age' is protected
 console.log(student.age);   // Error: Property 'age' is protected

// 4. readonly成员 - 可以读取但不能修改
console.log(person.id);       // 输出: 1001
// person.id = 1003;          // Error: Cannot assign to 'id' because it is a read-only property

// 调用方法
person.introduce();           // 输出: 我叫张三,年龄25,ID: 1001
student.introduce();          // 输出: 我叫李四,年龄18,ID: 1002
student.study();              // 输出: 李四正在学习\n年龄: 18\nID: 1002

// 尝试修改readonly属性 - 编译时会报错
 student.id = 1005;         
// Error: Cannot assign to 'id' because it is a read-only property

泛型(Generics)

泛型基础概念

钻石运算符 <> 里面添加的是类型变量比如T 这个T是一个变量 往里填哪个类型 T就是什么类型 他是一个类型的容器 可以自动捕获用户提供的类型.

// 泛型是可以在保证安全的清况等下 让函数与多种类型一起工作 从而实现复用 常用于函数 接口 类中
// <>叫做钻石运算符 里面添加类型变量 比如T等等 
//T是一个特殊的变量 他的处理类型不是值 他是一个类型的容器 可以自动捕获用户提供的类型
function id<T>(value:T):T{
    return value;
}
const getId=<t>(Value:t):t=>{
    return Value;
}
//调用
const num=<string>getId('123')
const num1=<number>getId(123)

//简化调用 调用泛型函数的时候 可以把尖括号省了 ts会自动识别类型(类型参数推断)
const num2=getId(123);
//有时候推断的类型可能不准确 就需要手动去定义
泛型约束

默认情况下 泛型函数的类型数量type可以代表多个类型 这导致无法访问任何属性 比如id('a')调用函数时参数的长度

function id<T>(value:T):T{
    console.log(value.length);
    return value
}

使用上面的函数会报错 因为T可以代表任意类型 无法保证一定存在length属性 此时就需要为泛型添加约束来收缩类型

有两种为泛型添加约束的方法

  • 方法1: 为type指定更具体的类型

    function id<T>(value:T[]):T[]{
      console.log(value.length);
      return value
    }
    
  • 方法2: 定义接口为T添加约束

    interface LengthWise{
        length:number
    }
    function id<T extends LengthWise>(value:T):T{
        console.log(value.length);
        return value
    }
    
    • 方法二的解释:
      • 1.创建接口提供需要的属性 比如length
      • 2.通过extends关键字使用该接口 为泛型(控制变量)添加约束
      • 表述为 传入的类型必须具有length属性

多个泛型相互约束:

泛型的类型变量可以存在多个 而且类型变量之间也可以约束的 (比如 第二个类型变量受第一个变量的约束) 比如创建一个函数来获取兑现中属性的值

//泛型的类型变量可以存在多个 而且类型变量之间也可以约束的 (比如 第二个类型变量受第一个变量的约束) 比如创建一个函数来获取兑现中属性的值

function getProp<T,K extends keyof T>(Obj:T,key:K):T[K]{
    return Obj[key];
}
let person={name:'jack',age:18}
console.log(getProp(person,'name')); //jack
//keyof关键字会接受一个对象类型 生成其键名称(可能是字符串或是数字)的联合类型
//实例中keyof T实际上获取的是person对象所有键的联合类型 也就是'name'|'age'
//类型变量k受T的约束 可以理解为 k只能是t所有键的任意一个
泛型接口与泛型类
泛型接口:

接口也可以配合泛型使用.

// 接口也可以配合泛型来使用 已增加灵活性 增强复用性
interface IdFunc<T>{
    id:(Value:T)=>T
    ids:()=>T[]
}
let Obj:IdFunc<string>={
    id(Value){
        return Value
    },
    ids(){
        return []
    }
}
泛型类:

class 也可以搭配泛型来用. 比如: react的class组件的基类 Component就是泛型 不用的组件有不同的props和state

//创建泛型类
class GenericNumber<NumType>{
    defaultvalue: NumType;
    constructor(value: NumType) {
        this.defaultvalue = value;
    }
    add(x: NumType, y: NumType): NumType {
        return (x as any) + (y as any);
    }
}

//如果类存在构造函数并且构造函数正好使用到了类的泛型 就可以省略尖括号

联合类型与交叉类型

联合类型的使用和场景
联合类型与类型别名

为了简化复杂的联合类型,可以使用类型别名:

// 定义联合类型别名
type StringOrNumber = string | number;
type Status = "pending" | "approved" | "rejected";

let value: StringOrNumber;
value = 123;
value = "hello";

let status: Status;
status = "pending";   // 正确
status = "approved";  // 正确
status = "done";      // 错误,不在指定的字面量类型中
联合类型与类型守卫

当使用联合类型时,TypeScript只允许访问所有类型共有的属性和方法。要访问特定类型的属性,需要使用类型守卫:

function processValue(value: string | number) {
    // 错误:length属性只存在于string类型中
    // console.log(value.length);
    
    // 使用类型守卫
    if (typeof value === "string") {
        // 在这个代码块中,TypeScript知道value是string类型
        console.log(value.length); // 正确
        console.log(value.toUpperCase());
    } else {
        // 在这个代码块中,TypeScript知道value是number类型
        console.log(value.toFixed(2));
    }
}
联合类型与接口

联合类型也可以与接口结合使用:

interface Bird {
    type: "bird";
    flyingSpeed: number;
}

interface Horse {
    type: "horse";
    runningSpeed: number;
}

// 联合类型
type Animal = Bird | Horse;

function moveAnimal(animal: Animal) {
    switch (animal.type) {
        case "bird":
            console.log(`Bird flying at speed: ${animal.flyingSpeed}`);
            break;
        case "horse":
            console.log(`Horse running at speed: ${animal.runningSpeed}`);
            break;
    }
}
联合类型与null/undefined

联合类型常用于处理可能为null或undefined的值:

// 用户可能未定义
let user: User | null = null;

// 在使用前需要检查
if (user !== null) {
    console.log(user.name); // 安全访问
}

// 或者使用可选链操作符
console.log(user?.name);
联合类型与字面量类型

联合类型与字面量类型结合使用可以创建枚举式的类型:

// 方向只能是这四个字符串值之一
type Direction = "up" | "down" | "left" | "right";

function move(direction: Direction) {
    // ...
}

move("up");    // 正确
move("north"); // 错误,不在指定的字面量类型中
联合类型的注意事项
  1. 只能访问共有成员:使用联合类型时,只能访问所有类型共有的属性和方法
  2. 类型守卫:要访问特定类型的属性,需要使用类型守卫进行类型检查
  3. 可读性:对于复杂的联合类型,建议使用类型别名提高可读性
  4. 过度使用:避免过度使用联合类型,可能导致代码难以维护
交叉类型:
1.定义

使用符合& 对两个接口进行组合 成一个新的类型

交叉功能类似于接口的继承 用来组合多个类型为一个类型(一般用在对象类型中)

//交叉功能类似于接口继承 用于组合多个类型为一个类型(常用于对象类型)
interface Person{
    name:string
}
interface Contact{
    phone:number;
}
type PersonContact =Person & Contact;
let obj:PersonContact={
    name:'张三',
    phone:123456789 
}

2.接口交叉与继承
  • 相同点: 都可以实现对象类型的组合
  • 不同点:两种方式实现类型组合时 对于同名属性之间处理冲突的方式不同
//交叉类型和接口继承的对比
interface A {
    fn:(vlaie:number)=>string;
}

//接口继承 出现这种情况要么接口会报错 要么只保留一个属性
interface B extends A {
    fn(value:string):string
}
//交叉类型
interface A { 
    fn:(value:number)=>string;
}
interface B { 
    fn:(value:string)=>string;
}
type C = A & B;
//可以将组合后的c简单理解为 fn:(value:(number|string))=>string

处理方法: 对于接口继承要么类型会报错 要么只保留两个类型的其中之一 然后对于交叉合成来说 可以两个类型同时保留 类似于联合类型

第四部分:类型系统进阶

类型兼容性

结构化类型系统

ts使用的是结构化的类型系统 如果类的类型定义 是一样的 尽管类名是 不一样的 但是仍然可以当做一个类来看

class Point {
    x:number;
    y:number;
    constructor(x:number,y:number) {
        this.x = x;
        this.y = y;
    }
}

class Point2D {
    x:number;
    y:number;
    constructor(x:number,y:number) {
        this.x = x;
        this.y = y;
    }
}

let p1: Point =new Point2D(1,2)  //这种写法是允许的 因为Point2D兼容Point 所以Point和Point2D可以看作是一个类
对象类型兼容
函数类型兼容

类型推断与类型断言

类型推断机制
类型断言的使用场景

映射类型与工具类型

索引签名类型

绝大多数情况下 我们都在使用对象前就确定的对象的结构 但是并未对象添准确的类型 索性签名类型就是为接口中的 索引 都进行类型标注

使用场景:无法确定对象中有哪些类型信息 此时就用索引签名类型

interface AnyObject{
  [key:string]:number
}
let obj:AnyObject={
  a:1,
  b:2
}
//解释 使用[key:string] 用来约束接口中出现的属性名 表示只要是 string类型 的属性名称都可以出现在对象中
//:number约束了属性值的类型 表示只要是 number类型 的属性值都可以出现在对象中
//key只是一个占位符 有了[key:String]:number 就可以在对象中定义任意个属性 只要属性名是字符串 属性值是数字即可
//这里的key可以是任意名称
  • 使用[key:string] 用来约束接口中出现的属性名 表示只要是 string类型 的属性名称都可以出现在对象中
  • :number约束了属性值的类型 表示只要是 number类型 的属性值都可以出现在对象中
  • key只是一个占位符 有了[key:String]:number 就可以在对象中定义任意个属性 只要属性名是字符串 属性值是数字即可,这里的key可以是任意名称
映射类型

映射类型就是基于旧类型创建新类型(对象类型) 减少重复 提升开发效率

//例子
type Propkeys='x'|'y'|'z'
type Type1={
  x:number;
  y:number;
  z:number;
}
//这样写将x y z重复写了两遍 通常可以使用映射类型来进行简化
type Type2={
  [Key in Propkeys]:number
}

//实际开发还是使用Record类型工具
type Type3=Record<Propkeys,number>;

解释:

  • 映射类型是基于索引签名类型的 所以语法类似于索引签名类型 也是用[]
  • [Key in Propkeys] 表示遍历 Propkeys 中的每个元素 并将其赋值给 Key
  • 映射类型不能用于接口 只能用于类型别名
  • 实际开发还是使用Record泛型工具

对象类型的类型映射

type Props={
  a:number;
  b:string;
  c:boolean;
}
type Type={
  [key in keyof Props]:number
}
let obj:Type={
  a:1,
  b:2,
  c:3
}
泛型工具类型
Partial<Type>

用来构造一个类型 将type的所有属性设置为可选

  • interface Props{ //每个类型都是必选的属性 如果需要可选类型需要添加'?'
        id:string;
        children:number[]
    }
    type PartialProps=Partial<Props>
    // 创建的新类型结构和props一模一样 但是所有属性是可选的
    
     const obj0:Props={
         id:'1',
        // children:[1,2,3]
     }//缺少children属性 就会报错
     
    const obj:PartialProps={
        id:'1'
    }//可以只写一个属性
    
Readonly<type>
  • 创建一个只读的类型 不可更改 就不需要单独为属性添加readonly属性

  • type readonlyProps=Readonly<Props>
    const obj1:readonlyProps={
        id:'1',
        children:[1,2,3]
    }
     obj1.id='2'//不可以修改
    
Pick<type,keys>

type中选择一组属性来构造新类型

  • pick中有两个类型变量 如果值选择一个则值传入该属性名即可

  • 第二个变量传图的属性只能是第一个类型变量中存在的属性

  • type PickProps=Pick<Props,'id'>
    const obj2:PickProps={
        id:'1'
        //children:[1,2,3]//不可以添加 添加就会报错
    }
     type PickProps = {
         id: string;
     }
    
Record<key,type>

构造一个对象类型 属性键为key 属性类型为type

  • type RecordObj=Record<'a'|'b',string>
    const obj3:RecordObj={
        a:'1',
        b:'2'
    }
    //Record工具类型有两个类型变量 1.表示对象有哪些属性 2.表示对象属性对应的类型
    

第五部分:实用技巧与最佳实践

模块与声明文件

ts文件中 有两种声明文件的方法 一个是后缀为.ts 一个是后缀为.d.ts

  • .ts文件 既包含类型信息又包含可执行代码
  • .d.ts文件 只包含类型信息 不包含可执行代码 用途是为js提供类型信息

类型声明文件概述

在开发的时候会使用很多第三方库 我们不知道这些库是用js写的还是ts写的 所以我们需要类型声明文件为已经存在的js库提供类型信息,这样我们在使用这些库的时候就可以获得类型检查和智能提示

使用第三方库的类型声明

可以使用npm i @types/库名 --save来安装库的类型信息(第三方库)

React 第五十二节 Router中 useResolvedPath使用详解和注意事项示例

前言

useResolvedPathReact Router v6 提供的一个实用钩子,用于解析给定路径为完整路径对象。 它根据当前路由上下文解析相对路径,生成包含 pathname、search 和 hash 的完整路径对象。

一、useResolvedPath 核心用途

  1. 路径解析:将相对路径解析为绝对路径
  2. 链接构建:安全地构建导航链接
  3. 路径比较:比较当前路径与目标路径
  4. 动态路由处理:正确处理嵌套路由中的路径

二、useResolvedPath 解析结果对象

useResolvedPath 返回一个包含以下属性的对象: 比如原路径是:const resolved = useResolvedPath('../users?id=123#profile')

// 返回内容为
{ pathname: '/users', search: '?id=123', hash: '#profile' }
  1. pathname: 解析后的绝对路径
  2. search: 查询字符串(如果有)
  3. hash: 哈希值(如果有)

三、useResolvedPath 基本用法示例

import { useResolvedPath } from 'react-router-dom';

function PathInfo() {
  const resolved = useResolvedPath('../users?sort=name#section');
  
  return (
    <div>
      <h3>路径解析结果</h3>
      <p>原始路径: "../users?sort=name#section"</p>
      <p>解析后路径名: {resolved.pathname}</p>
      <p>查询参数: {resolved.search}</p>
      <p>哈希值: {resolved.hash}</p>
    </div>
  );
}

四、useResolvedPath 实际应用场景

4.1、在面包屑导航中解析路径

import { useResolvedPath, Link, useLocation, useMatches } from 'react-router-dom';

function Breadcrumbs() {
  const location = useLocation();
  const matches = useMatches();
  
  // 获取所有路由匹配项
  const crumbs = matches
    .filter(match => match.handle?.crumb)
    .map(match => {
      // 解析每个路由的路径
      const resolvedPath = useResolvedPath(match.pathname);
      return {
        pathname: resolvedPath.pathname,
        crumb: match.handle.crumb
      };
    });

  return (
    <nav className="breadcrumbs">
      {crumbs.map((crumb, index) => (
        <span key={index}>
          {index > 0 && ' > '}
          {index === crumbs.length - 1 ? (
            <span className="current">{crumb.crumb}</span>
          ) : (
            <Link to={crumb.pathname}>{crumb.crumb}</Link>
          )}
        </span>
      ))}
    </nav>
  );
}

// 在路由配置中使用
const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      {
        path: 'dashboard',
        handle: { crumb: '控制面板' },
        element: <Dashboard />,
        children: [
          {
            path: 'stats',
            handle: { crumb: '统计' },
            element: <StatsPage />
          }
        ]
      },
      {
        path: 'users',
        handle: { crumb: '用户管理' },
        element: <UsersPage />
      }
    ]
  }
]);

4.2、创建自定义导航链接组件

import { 
  useResolvedPath, 
  useMatch, 
  Link 
} from 'react-router-dom';

function CustomNavLink({ to, children, ...props }) {
  const resolved = useResolvedPath(to);
  const match = useMatch({ path: resolved.pathname, end: true });
  
  return (
    <div className={`nav-item ${match ? 'active' : ''}`}>
      <Link to={to} {...props}>
        {children}
      </Link>
    </div>
  );
}

// 在导航中使用
function Navigation() {
  return (
    <nav>
      <CustomNavLink to="/">首页</CustomNavLink>
      <CustomNavLink to="/about">关于</CustomNavLink>
      <CustomNavLink to="/products">产品</CustomNavLink>
      <CustomNavLink to="/contact">联系我们</CustomNavLink>
    </nav>
  );
}

4.3、在嵌套路由中正确处理相对路径

import { useResolvedPath, Link, Outlet } from 'react-router-dom';

function UserProfileLayout() {
  return (
    <div className="user-profile">
      <nav className="profile-nav">
        <ProfileNavLink to=".">概览</ProfileNavLink>
        <ProfileNavLink to="activity">活动</ProfileNavLink>
        <ProfileNavLink to="settings">设置</ProfileNavLink>
        <ProfileNavLink to="../friends">好友</ProfileNavLink>
      </nav>
      <div className="profile-content">
        <Outlet />
      </div>
    </div>
  );
}

function ProfileNavLink({ to, children }) {
  const resolved = useResolvedPath(to);
  const match = useMatch({ path: resolved.pathname, end: true });
  
  return (
    <Link 
      to={to} 
      className={match ? 'active' : ''}
    >
      {children}
    </Link>
  );
}

// 路由配置
const router = createBrowserRouter([
  {
    path: 'users',
    element: <UsersLayout />,
    children: [
      {
        path: ':userId',
        element: <UserProfileLayout />,
        children: [
          { index: true, element: <ProfileOverview /> },
          { path: 'activity', element: <ProfileActivity /> },
          { path: 'settings', element: <ProfileSettings /> }
        ]
      },
      {
        path: ':userId/friends',
        element: <UserFriends />
      }
    ]
  }
]);

4.4、动态生成侧边栏菜单

import { useResolvedPath, useMatch, Link } from 'react-router-dom';

function SidebarMenu({ items }) {
  return (
    <nav className="sidebar">
      <ul>
        {items.map((item) => (
          <MenuItem key={item.path} to={item.path} label={item.label} />
        ))}
      </ul>
    </nav>
  );
}

function MenuItem({ to, label }) {
  const resolved = useResolvedPath(to);
  const match = useMatch({ path: resolved.pathname, end: false });
  
  return (
    <li className={match ? 'active' : ''}>
      <Link to={to}>{label}</Link>
      
      {/* 显示子菜单(如果存在且匹配) */}
      {match && resolved.pathname === to && (
        <ul className="submenu">
          <li><Link to={`${to}/details`}>详细信息</Link></li>
          <li><Link to={`${to}/analytics`}>分析</Link></li>
        </ul>
      )}
    </li>
  );
}

// 使用示例
const menuItems = [
  { path: '/dashboard', label: '仪表盘' },
  { path: '/projects', label: '项目' },
  { path: '/reports', label: '报告' },
  { path: '/team', label: '团队' }
];

function AppLayout() {
  return (
    <div className="app-layout">
      <SidebarMenu items={menuItems} />
      <main className="content">
        {/* 页面内容 */}
      </main>
    </div>
  );
}

五、useResolvedPath 高级用法:路径比较工具

import { useResolvedPath, useLocation } from 'react-router-dom';

// 自定义钩子:比较当前路径是否匹配目标路径
function usePathMatch(to) {
  const resolvedTo = useResolvedPath(to);
  const location = useLocation();
  
  // 创建当前路径对象(去除可能的尾部斜杠)
  const currentPath = {
    pathname: location.pathname.replace(/\/$/, ''),
    search: location.search,
    hash: location.hash
  };
  
  // 创建目标路径对象
  const targetPath = {
    pathname: resolvedTo.pathname.replace(/\/$/, ''),
    search: resolvedTo.search,
    hash: resolvedTo.hash
  };
  
  // 比较路径是否匹配
  return (
    currentPath.pathname === targetPath.pathname &&
    currentPath.search === targetPath.search &&
    currentPath.hash === targetPath.hash
  );
}

// 在组件中使用
function NavigationItem({ to, children }) {
  const isActive = usePathMatch(to);
  
  return (
    <li className={isActive ? 'active' : ''}>
      <Link to={to}>{children}</Link>
    </li>
  );
}

六、 useResolvedPath 注意事项

6.1、相对路径解析

useResolvedPath 基于当前路由位置解析相对路径

6.2、查询参数和哈希

保留原始路径中的查询字符串和哈希值

6.3、动态路由参数

不会解析路径参数(如 :id),保持原样

6.4、性能考虑

解析操作轻量,但避免在循环中过度使用

6.5、路由上下文

必须在路由组件内部使用(在 <Router> 上下文中)

七、useResolvedPath 与相关钩子对比

在这里插入图片描述

总结

useResolvedPathReact Router v6 中处理路径的强大工具,主要用于:

  1. 在嵌套路由中正确处理相对路径
  2. 构建动态导航组件
  3. 创建面包屑导航等复杂导航结构
  4. 安全地比较路径和构建链接

通过合理使用 useResolvedPath,可以创建更健壮、可维护的路由结构,避免硬编码路径导致的错误,并简化嵌套路由中的路径处理逻辑。

vue3+qiankun主应用和微应用的路由跳转返回

继上一次vue3+vite+qiankun搭建微前端,这次处理下主应用和微应用的路由问题

主应用和微应用都是使用 History 路由模式

主要问题集中在各个模块的路由返回

  • 微应用返回微应用
  • 主应用返回微应用

相当于:/platform/test -> /main/home -> /test

微路由/platform/test跳转到主路由/main/home(或微路由),再返回微路由变成/test

这里都是用到了主应用的路由机制,返回时匹配不到微应用路由,页面就报404了

这边的解决方案是:完整路由替换微应用路由,浏览器才能识别到

  • 主应用的跳转会记录history的历史记录里面,返回是正常的;
  • 微应用是根据路由规则动态加载了一个容器组件,跳转到微应用的路由是没有记录到history的;

监听浏览器前进后退事件,将微应用路由替换成完整路由

```
// 监听浏览器前进后退事件
window.addEventListener('popstate', (event) => {
  if (window.history.state) {
    //current表示前进或后退的路由,意思是点击跳转或者返回上一页都是当前地址栏的路由
    console.log(window.history.state)
    const index=navList.value.findIndex(item=>item.path.includes(window.history.state.current));
    if(index>-1){
      //只处理qiankun的路由
      if(navList.value[index].isQianKun){
        console.log(navList.value[index].path,'qiankun监听');     
        //将完整路由替换当前的微应用路由
        window.history.replaceState(null, null, navList.value[index].path);
      }
    }
  }
});
```
const navList=ref([
  {
    path:'/platform/child-test',
    isQianKun:true,
    name:'微应用1',
  },
  {
    path:'/platform/platform-child/child-test2',
    isQianKun:true,
    name:'微应用2',
  },
  {
    path:'/hrm/hrmLeave',
    isQianKun:false,
    name:'请假申请',
  },
  {
    path:'/hrm/hrmLeaveStatistics',
    isQianKun:false,
    name:'请假统计',
  }
])

const nav=(url,item)=>{
  if(proxy.$route.path ===url) return;

  proxy.$router.push({path:url});
}
<template v-for="item in navList">
  <el-button @click="nav(item.path,item)">{{item.name}}</el-button>
</template>

Cesium 山洪流体模拟

基于 Cesium 的流体体渲染技术实现

e8bb21f05b0562b337ae56325ba3ee69.png

整体渲染流程

项目采用多通道渲染架构,通过 GPU 计算实现流体模拟:

地形捕获阶段:
正交相机 → 深度渲染 → ENU坐标转换 → 高度图纹理

流体计算阶段(循环执行):
BufferA (地形+水位计算)
    ↓
BufferB (流出量计算1)
    ↓
BufferC (水位更新)
    ↓
BufferD (流出量计算2)
    ↓
循环回 BufferA

最终渲染阶段:
体渲染Pass → Ray Marching → 深度混合 → 屏幕输出

核心类设计

1. FluidRenderer - 流体渲染器

流体渲染的主控类,负责整个渲染生命周期管理。

配置参数:

const config = {
    resolution: new Cesium.Cartesian2(1024, 1024),  // 计算纹理分辨率
    dimensions: new Cesium.Cartesian3(10000, 10000, 1000), // 流体体积尺寸(米)
    heightRange: { min: 0, max: 1000 },  // 高度归一化范围
    fluidParams: new Cesium.Cartesian4(0.995, 0.25, 0.0001, 0.1),
    customParams: new Cesium.Cartesian4(10, 20, 3, 0),
    lonLat: [120.2099, 30.1365],  // 流体中心经纬度
}

关键方法实现思路:

_generateHeightMapTexture() - 地形高度图生成

  • 创建正交投影相机俯视地形
  • 拦截 Cesium 地形渲染命令
  • 修改片段着色器输出局部坐标高度
  • 将深度信息转换为高度纹理

_createComputePasses() - 计算通道初始化

  • 创建 4 个浮点纹理作为双缓冲
  • 配置每个计算 Pass 的 Uniform 映射
  • 设置纹理依赖关系形成计算链
2. CustomPrimitive - 自定义渲染原语

封装 Cesium 的底层渲染命令,支持两种命令类型:

// 计算命令 - 用于流体模拟计算
new CustomPrimitive({
    commandType: 'Compute',
    fragmentShaderSource: shaderSource,
    uniformMap: uniforms,
    outputTexture: targetTexture
})

// 绘制命令 - 用于最终体渲染
new CustomPrimitive({
    commandType: 'Draw',
    geometry: boxGeometry,
    vertexShaderSource: vs,
    fragmentShaderSource: fs,
    uniformMap: uniforms,
    modelMatrix: transformMatrix
})

核心逻辑:

createCommand(context) {
    switch (this.commandType) {
        case 'Compute':
            return new Cesium.ComputeCommand({
                fragmentShaderSource: this.fragmentShaderSource,
                uniformMap: this.uniformMap,
                outputTexture: this.outputTexture
            });
        case 'Draw':
            return new Cesium.DrawCommand({
                vertexArray: VertexArray.fromGeometry(...),
                shaderProgram: ShaderProgram.fromCache(...),
                renderState: RenderState.fromCache(...)
            });
    }
}

流体模拟算法

物理模型

采用基于高度场的浅水方程 (Shallow Water Equations) 简化模型:

状态变量:

  • h: 地形高度
  • d: 水深
  • f: 流出量 (四方向: 右/上/左/下)

计算流程:

  1. 流出量计算 (BufferB/BufferD)
float computeOutFlowDir(vec2 centerHeight, ivec2 pos) {
    vec2 dirHeight = readHeight(pos);
    // 计算水位差 (地形高度 + 水深)
    return max(0.0, (centerHeight.x + centerHeight.y) - (dirHeight.x + dirHeight.y));
}

vec4 nOutFlow;
nOutFlow.x = computeOutFlowDir(height, p + ivec2( 1,  0));  // 向右
nOutFlow.y = computeOutFlowDir(height, p + ivec2( 0,  1));  // 向上
nOutFlow.z = computeOutFlowDir(height, p + ivec2(-1,  0));  // 向左
nOutFlow.w = computeOutFlowDir(height, p + ivec2( 0, -1));  // 向下

// 时间积分: 新流出量 = 衰减 * 旧流出量 + 强度 * 新计算值
nOutFlow = fluidParam.x * oOutFlow + fluidParam.y * nOutFlow;
  1. 水位更新 (BufferA/BufferC)
// 计算总流出量
float totalOutFlow = OutFlow.x + OutFlow.y + OutFlow.z + OutFlow.w;

// 计算总流入量 (读取邻居的流出量)
float totalInFlow = 0.0;
totalInFlow += readOutFlow(p + ivec2( 1,  0)).z;  // 右侧流向我
totalInFlow += readOutFlow(p + ivec2( 0,  1)).w;  // 上方流向我
totalInFlow += readOutFlow(p + ivec2(-1,  0)).x;  // 左侧流向我
totalInFlow += readOutFlow(p + ivec2( 0, -1)).y;  // 下方流向我

// 更新水深
waterDept = height.y - totalOutFlow + totalInFlow;

水源添加机制

通过点击地形添加水源,坐标转换流程:

addWaterSource(cartesian) {
    // 1. 世界坐标转局部 ENU 坐标
    const center = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
    const enuMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(center);
    const localMat4 = Cesium.Matrix4.inverse(enuMatrix, new Cesium.Matrix4());
    const localPos = Cesium.Matrix4.multiplyByPoint(localMat4, cartesian, ...);
    
    // 2. 局部坐标转纹理 UV (0-1)
    const u = (localPos.x + halfX) / dimensions.x;
    const v = 1.0 - ((localPos.y + halfY) / dimensions.y);
    
    // 3. UV 转像素坐标
    const pixelX = u * resolution.x;
    const pixelY = v * resolution.y;
}

在着色器中添加水源:

if (waterSourcePos.x >= 0.0 && waterSourceAmount > 0.0) {
    float dist = distance(gl_FragCoord.xy, waterSourcePos);
    if (dist < waterSourceRadius) {
        waterDept += waterSourceAmount;
    }
}

体渲染实现

Ray Marching 算法

采用 Ray Marching 技术在三维体积中追踪光线:

vec3 Render(in vec3 ro, in vec3 rd) {
    // 1. 射线与包围盒求交
    vec2 ret = boxIntersection(ro, rd, boxSize, n);
    if(ret.x <= 0.0) discard;
    
    vec3 pi = ro + rd * ret.x;  // 入射点
    
    // 2. 追踪地形表面
    float tt = ret.x;
    for (int i = 0; i < 80; i++) {
        vec3 p = ro + rd * tt;
        float h = p.y - getHeight(p).x;  // 当前高度 - 地形高度
        if (h < 0.0002 || tt > ret.y) break;
        tt += h * 0.1;  // 步进距离自适应
    }
    
    // 3. 追踪水面
    float wt = ret.x;
    for (int i = 0; i < 80; i++) {
        vec3 p = ro + rd * wt;
        float h = p.y - getHeight(p).y;  // 当前高度 - 水面高度
        if (h < 0.0002 || wt > min(tt, ret.y)) break;
        wt += h * 0.1;
    }
    
    return finalColor;
}

水深可视化

根据水深映射不同颜色:

float normalizedDepth = clamp(dist / 0.05, 0.0, 1.0);
vec3 depthColor;

if (normalizedDepth > 0.8) {
    // 最深: 红色
    depthColor = mix(vec3(1.0, 0.35, 0.0), vec3(1.0, 0.05, 0.0), ...);
} else if (normalizedDepth > 0.55) {
    // 中深: 黄色
    depthColor = mix(vec3(1.0, 1.0, 0.0), vec3(1.0, 0.3, 0.0), ...);
} else if (normalizedDepth > 0.25) {
    // 中浅: 蓝色
    depthColor = mix(vec3(0.0, 0.4, 1.0), vec3(1.0, 1.0, 0.0), ...);
} else {
    // 最浅: 绿色
    depthColor = mix(vec3(0.0, 1.0, 0.4), vec3(0.0, 0.4, 1.0), ...);
}

// 应用雾效果混合
tc = applyFog(tc, depthColor, dist * customParam.x);

地形高度图捕获

着色器拦截技术

通过修改 Cesium 地形渲染着色器来捕获高度信息:

_processHeightMapShaders() {
    const enuMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(
        Cesium.Cartesian3.fromDegrees(...lonLat, 0)
    );
    this._inverseEnuMatrix = Cesium.Matrix4.inverse(enuMatrix, ...);
    
    const commands = this._getDepthRenderCommands();
    commands.forEach(command => {
        command.uniformMap.u_inverseEnuMatrix = () => this._inverseEnuMatrix;
        command.shaderProgram = this._getDerivedShaderProgram(...);
    });
}

着色器修改:

// 原始主函数重命名
void czm_heightMap_main() {
    // ... 原始地形渲染逻辑
}

// 新主函数
uniform mat4 u_inverseEnuMatrix;
void main() {
    czm_heightMap_main();
    
    // 转换到局部 ENU 坐标
    vec3 posMC = (u_inverseEnuMatrix * vec4(v_positionMC, 1.0)).xyz;
    
    // 输出高度到纹理 R 通道
    out_FragColor = vec4(posMC.z, out_FragColor.gb, 1.0);
}

正交相机配置

创建俯视地形的正交投影相机:

_createOrthographicCamera() {
    const camera = new Cesium.Camera(scene);
    camera.frustum = new Cesium.OrthographicOffCenterFrustum();
    
    const frustum = camera.frustum;
    frustum.near = 0.01;
    frustum.far = dimensions.z * 2;
    frustum.left = -dimensions.x / 2;
    frustum.right = dimensions.x / 2;
    frustum.bottom = -dimensions.y / 2;
    frustum.top = dimensions.y / 2;
    
    // 相机位置: 中心点上方
    const offset = Cesium.Cartesian3.multiplyByScalar(dir, -frustum.far, ...);
    camera.position = Cesium.Cartesian3.add(center, offset, ...);
    camera.direction = dir;  // 向下
    
    return camera;
}

大气散射后处理

实现基于物理的大气散射效果:

Rayleigh 散射

float rayleigh_phase_func(float mu) {
    return 3. * (1. + mu*mu) / (16. * PI);
}

const vec3 betaR = vec3(5.5e-6, 13.0e-6, 22.4e-6);  // Rayleigh 散射系数
const float hR = 10e3;  // Rayleigh 尺度高度

Mie 散射

float henyey_greenstein_phase_func(float mu) {
    const float g = 0.76;  // 各向异性参数
    return (1. - g*g) / ((4. * PI) * pow(1. + g*g - 2.*g*mu, 1.5));
}

const vec3 betaM = vec3(21e-6);  // Mie 散射系数
const float hM = 3.8e3;  // Mie 尺度高度

Ray Marching 积分

vec4 get_incident_light(ray_t ray) {
    float march_step = (ray_length.y - ray_length.x) / float(num_samples);
    
    for (int i = 0; i < num_samples; i++) {
        vec3 s = ray.origin + ray.direction * march_pos;
        float height = length(s) - 6360e3;
        
        // 计算当前点的散射贡献
        float hr = exp(-height / hR) * march_step;
        float hm = exp(-height / hM) * march_step;
        
        // 累积光学深度
        optical_depthR += hr;
        optical_depthM += hm;
        
        // 计算光线到太阳的透射率
        bool overground = get_sun_light(light_ray, ...);
        if (overground) {
            vec3 attenuation = exp(-tau);
            sumR += hr * attenuation;
            sumM += hm * attenuation;
        }
    }
    
    return vec4(sumR * phaseR * betaR + sumM * phaseM * betaM, alpha);
}

性能优化策略

1. 纹理分辨率控制

// 降低计算纹理分辨率
const resolution = new Cesium.Cartesian2(512, 512);  // 从 1024 降到 512

2. Ray Marching 步数优化

// 根据距离自适应步进
for (int i = 0; i < 40; i++) {  // 从 80 降到 40
    tt += h * 0.2;  // 增大步进系数从 0.1 到 0.2
}

3. 计算频率控制

_startRenderLoop() {
    this.viewer.scene.postRender.addEventListener(() => {
        this._frameCount += this.config.timeStep;  // 控制计算速度
    });
}

调试技巧

可视化中间结果

// 查看高度图
out_FragColor = vec4(vec3(texture(heightMap, uv).r), 1.0);

// 查看水深
vec2 h = getHeight(p);
out_FragColor = vec4(0.0, 0.0, h.y * 10.0, 1.0);

// 查看流出量
vec4 flow = readOutFlow(p);
out_FragColor = vec4(flow.xy, 0.0, 1.0);

性能监控

viewer.scene.debugShowFramesPerSecond = true;  // 显示 FPS
viewer.resolutionScale = 1.0;  // 渲染分辨率缩放
viewer.scene.msaaSamples = 4;  // MSAA 抗锯齿

使用示例

初始化流体渲染器

const waterFluid = new FluidRenderer(viewer, {
    lonLat: [lon, lat],
    width: 1024,
    height: 1024,
    dimensions: new Cesium.Cartesian3(10000, 10000, 1000),
    minHeight: 0,
    maxHeight: 1000
});

添加交互控制

const viewModel = {
    param1: 10,   // 雾密度
    param2: 20,   // 高光混合
    param3: 3,    // 光强
};

function updateParam() {
    waterFluid.config.customParams.x = Number(viewModel.param1);
    waterFluid.config.customParams.y = Number(viewModel.param2);
    waterFluid.config.customParams.z = Number(viewModel.param3);
}

Cesium.knockout.track(viewModel);
Cesium.knockout.applyBindings(viewModel, toolbar);

点击添加水源

clickHandler.setInputAction((movement) => {
    let cartesian = viewer.scene.pickPosition(movement.position);
    if (!cartesian) return;
    
    waterFluid.addWaterSource(cartesian);
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);

技术要点总结

  1. 坐标系统转换: 世界坐标 → ENU局部坐标 → 纹理UV → 像素坐标
  2. 双缓冲技术: 使用 4 个纹理实现 Ping-Pong 缓冲
  3. 着色器拦截: 通过 ShaderCache 修改 Cesium 内部着色器
  4. 体渲染优化: 自适应步进距离提高 Ray Marching 效率
  5. 物理模拟: 基于高度场的流体力学简化模型

感觉m3d大佬提供的技术 站在巨人的肩膀

当循环遇上异步:如何避免 JavaScript 中最常见的性能陷阱?

引言:从一道面试题说起

"如果 JavaScript 是单线程的,为什么我们的网页在加载数据时不会卡死?在循环遍历过程中异步任务会按照预期执行么?" 这个看似简单的问题,却触及了 JavaScript 异步编程的核心。作为一名前端工程师,深入理解异步编程不仅是为了应对面试,更是为了构建高性能、用户体验良好的现代 Web 应用。

1. 异步编程:JavaScript 的"非阻塞"之道

1.1 什么是异步编程?

异步编程是一种让程序能够在等待某些操作完成的同时,继续执行其他任务的编程范式。其核心理念是:"发起操作,无需等待,完成后回调"

生活化比喻:

  • 同步:在单车道排队通行,前车不走,后车只能等待
  • 异步:在餐厅点餐,下单后无需在厨房门口等待,可以继续聊天,餐好后服务员会送来

JavaScript 中的典型异步场景:

// 网络请求
fetch('/api/data').then(response => response.json());

// 定时器
setTimeout(() => console.log('延时执行'), 1000);

// 用户交互
button.addEventListener('click', handleClick);

// 文件操作(Node.js)
fs.readFile('file.txt', 'utf8', (err, data) => {});

1.2 为什么需要异步编程?

根本原因:JavaScript 的单线程架构

想象一下:如果浏览器中 JavaScript 的每个操作都是同步的,那么一个 3 秒的网络请求就会让整个页面"冻结" 3 秒——用户无法点击、无法滚动、无法输入。这种体验在现代 Web 应用中是不可接受的。

异步编程解决了:

  • 主线程阻塞问题
  • 用户体验卡顿问题
  • 资源利用效率低下问题

2. 异步编程的演进:从回调地狱到优雅同步

2.1 回调函数:最初的解决方案

// 经典的回调地狱
getUser(userId, function(user) {
    getPosts(user.id, function(posts) {
        getComments(posts[0].id, function(comments) {
            renderPage(user, posts, comments);
        });
    });
});

存在的问题:

  • 嵌套层级过深,代码难以维护
  • 错误处理分散,容易遗漏
  • 控制流复杂,难以实现并行、竞速等场景

2.2 Promise:更优雅的链式调用

getUser(userId)
    .then(user => getPosts(user.id))
    .then(posts => getComments(posts[0].id))
    .then(comments => renderPage(user, posts, comments))
    .catch(error => console.error('处理失败', error));

改进点:

  • 链式调用,扁平化代码结构
  • 统一的错误处理机制
  • 支持 Promise.allPromise.race 等控制流

2.3 async/await:同步写法的异步实现

async function renderUserPage(userId) {
    try {
        const user = await getUser(userId);
        const posts = await getPosts(user.id);
        const comments = await getComments(posts[0].id);
        return renderPage(user, posts, comments);
    } catch (error) {
        console.error('页面渲染失败', error);
    }
}

核心优势:

  • 代码逻辑清晰,符合同步思维习惯
  • 错误处理使用熟悉的 try/catch
  • 调试体验大幅提升

3. 循环中的异步陷阱:常见误区与解决方案

3.1 不同遍历方法的异步行为差异

❌ 错误示范:forEach 的异步陷阱

async function processItems(items) {
    // 这里不会按预期工作!
    items.forEach(async (item) => {
        const result = await processItem(item);
        console.log(result);
    });
    console.log('所有项目处理完成?'); // 实际上这会立即执行
}

✅ 正确的循环异步处理

3.1.1 顺序执行:保证执行顺序

// 使用 for...of 实现顺序执行
async function processSequentially(items) {
    const results = [];
    for (const item of items) {
        // 每个项目等待前一个完成
        const result = await processItem(item);
        results.push(result);
    }
    return results;
}

// 使用 for 循环
async function processWithForLoop(items) {
    const results = [];
    for (let i = 0; i < items.length; i++) {
        const result = await processItem(items[i]);
        results.push(result);
    }
    return results;
}

3.1.2 并行执行:提升执行效率

// 使用 Promise.all 实现并行执行
async function processInParallel(items) {
    const promises = items.map(item => processItem(item));
    const results = await Promise.all(promises);
    return results;
}

// 使用 map + Promise.all 的简洁写法
async function processParallelConcise(items) {
    return Promise.all(items.map(processItem));
}

3.1.3 控制并发:平衡性能与资源

// 控制并发数量的执行
async function processWithConcurrency(items, concurrency = 3) {
    const results = [];
    
    for (let i = 0; i < items.length; i += concurrency) {
        const chunk = items.slice(i, i + concurrency);
        const chunkPromises = chunk.map(item => processItem(item));
        const chunkResults = await Promise.all(chunkPromises);
        results.push(...chunkResults);
    }
    
    return results;
}

3.2 实际业务场景示例

场景:批量上传图片

async function batchUploadImages(images, onProgress) {
    const results = {
        successful: [],
        failed: []
    };
    
    for (let i = 0; i < images.length; i++) {
        try {
            // 顺序上传,避免服务器压力过大
            const result = await uploadImage(images[i]);
            results.successful.push(result);
            
            // 更新进度
            onProgress && onProgress({
                completed: i + 1,
                total: images.length,
                current: images[i].name
            });
            
        } catch (error) {
            results.failed.push({
                image: images[i],
                error: error.message
            });
        }
    }
    
    return results;
}

场景:并行请求用户数据

async function fetchUserDashboard(userId) {
    // 并行发起多个独立请求
    const [user, orders, notifications, preferences] = await Promise.all([
        fetchUser(userId),
        fetchOrders(userId),
        fetchNotifications(userId),
        fetchPreferences(userId)
    ]);
    
    return {
        user,
        orders,
        notifications,
        preferences
    };
}

3.3 错误处理最佳实践

// 单个失败不影响其他请求
async function robustParallelProcessing(items) {
    const promises = items.map(item =>
        processItem(item)
            .then(result => ({ success: true, data: result }))
            .catch(error => ({ success: false, error, item }))
    );
    
    const results = await Promise.all(promises);
    
    return {
        successful: results.filter(r => r.success).map(r => r.data),
        failed: results.filter(r => !r.success)
    };
}

// 使用 Promise.allSettled
async function processWithAllSettled(items) {
    const results = await Promise.allSettled(
        items.map(item => processItem(item))
    );
    
    const successful = results
        .filter(result => result.status === 'fulfilled')
        .map(result => result.value);
        
    const failed = results
        .filter(result => result.status === 'rejected')
        .map(result => result.reason);
    
    return { successful, failed };
}

4. 性能优化与实战建议

4.1 选择正确的循环策略

场景 推荐方案 理由
有依赖关系的操作 for...of 顺序执行 保证执行顺序
独立并行操作 Promise.all + map 最大化并发性能
大量 I/O 操作 分块并发控制 平衡性能与资源
需要实时进度 for...of + 进度回调 便于进度跟踪

4.2 避免常见的性能陷阱

// ❌ 避免:在循环中创建不必要的异步函数
async function inefficientProcessing(items) {
    const results = [];
    for (const item of items) {
        // 每次循环都创建新的异步函数
        results.push(await someAsyncFunction(item));
    }
    return results;
}

// ✅ 推荐:预先处理或批量处理
async function efficientProcessing(items) {
    // 批量处理减少函数调用开销
    return Promise.all(items.map(someAsyncFunction));
}

5. 总结

理解 JavaScript 异步编程是现代前端开发的必备技能。从最初的回调函数到如今的 async/await,JavaScript 的异步编程能力在不断进化,让开发者能够编写出既高效又易于维护的代码。

关键要点:

  • 异步编程解决了 JavaScript 单线程的阻塞问题
  • async/await 让异步代码拥有同步代码的可读性
  • 在循环中处理异步操作时,要根据业务需求选择合适的执行策略
  • 错误处理和性能优化是异步编程中的重要考量

掌握这些概念和技巧,不仅能在面试中游刃有余,更能在实际工作中构建出性能卓越、用户体验良好的 Web 应用。异步编程虽然有一定学习曲线,但一旦掌握,将成为你工作中的利器。

Electron 集成第三方项目

如何将形态各异的开源项目(PC端App、包含前后端的Web应用)集成到统一的Electron应用商店中,并实现“开箱即用”的体验。

下面这两张架构图清晰地展示了方案的总体设计和技术选型: app-start-line.png


未命名.png flowchart TD A[用户操作] --> B(应用商店主窗口
Vue.js)

    B --> C{检测应用类型}
    C -->|Web应用| D[Web应用打包器<br>WebAppPackager]
    C -->|桌面应用| E[桌面应用启动器<br>DesktopLauncher]
    
    D --> F
    subgraph F [Web应用处理流程]
        F1{智能检测项目类型} --> F2[启动后端服务<br>(Node.js/Python/Java)]
        F2 --> F3[构建前端静态资源]
        F3 --> F4[创建Electron窗口<br>加载服务/静态资源]
    end

    E --> G[调用系统程序<br>启动原生应用]
    
    F --> H[统一运行环境]
    G --> H
    
    H --> I[用户开箱即用]
    
    D --> J[进程管理器]
    E --> J
    J --> K[应用注册表<br>(安装/卸载/元数据)]

核心设计理念

本方案的核心是设计一个应用打包器和运行时容器。它不是简单地将应用图标放在一起,而是通过一个统一的“启动器”(AppLauncher),智能地处理不同类型应用的完整启动生命周期,特别是解决Web应用前后端联动的难题。

方案详解与技术选型

1. 应用类型智能检测与处理策略

首先,我们需要一个“智能检测”模块(在架构图中体现为智能检测项目类型),它在应用安装时自动分析项目结构,决定后续如何处理。

检测目标(检查文件/配置) 识别为类型 后续处理策略
package.json(含expressnext等脚本) Node.js Web应用 启动Node服务,然后加载前端页面。
requirements.txtapp.pypom.xml Python/Java Web应用 调用相应解释器启动后端服务。
index.html+ 静态资源(无复杂后端) 静态Web应用 直接由Electron窗口加载HTML文件。
.exe.app.dmg等可执行文件 原生桌面应用 通过Node.js的child_process直接调用系统命令运行。

关键技术工具推荐: 使用 **electron-is**库,它可以帮助在运行时便捷地检测当前操作系统、运行环境(开发/生产)等,从而执行正确的平台特定命令。

2. Web应用的核心解决方案:一体化打包与启动

这是方案的精髓,对应架构图中的“Web应用处理流程”。

  • 启动后端服务:在Electron的主进程中,使用Node.js的 child_process模块,动态地运行启动命令(如 npm startpython app.py)。这个后台进程的生命周期将与您的Electron应用商店绑定。
  • 构建前端资源:同样在主进程中,自动执行构建命令(如 npm run build),生成静态文件(dist目录)。
  • 创建渲染窗口:服务启动后,创建一个Electron的BrowserWindow窗口,但其加载的URL不再是本地文件,而是指向刚刚启动的后端服务地址(例如 http://localhost:3000)。这样就完美地将前后端串联了起来。

3. 桌面应用的直接启动方案

对于原生应用,方案相对直接。通过Electron的主进程,使用Node.js的API(如Mac上的open命令,Windows上的execFile)直接启动用户下载的.app.exe文件。这对应于架构图中的“调用系统程序”。

4. 应用商店的管理与元数据

  • 应用注册表:需要一个本地的数据库或JSON文件(应用注册表),记录所有已安装应用的信息,如名称、ID、安装路径、类型、图标等。
  • 生命周期管理:提供安装、启动、卸载的API。特别是卸载时,要确保能正确停止由商店启动的后台服务进程。

关键决策与进阶考量

  1. 安全性(沙箱与权限) :现代Electron强烈建议启用上下文隔离(Context Isolation)和进程沙箱(Sandbox) 。这意味着渲染进程(即您用来显示应用商店界面的网页)不应拥有Node.js访问权限。所有对系统操作的请求(如启动应用),都应通过预加载脚本(Preload Script)定义的安全API,发送给主进程执行。

  2. 性能与资源管理

    • 懒加载:只有当用户点击打开某个AI应用时,才启动对应的后端服务。
    • 进程清理:应用关闭时,务必在Electron主进程中清理掉对应的后端服务子进程,防止资源泄漏。
  3. 不同应用的集成深度

    • 深度集成(推荐) :将Web应用完全封装在Electron窗口内,用户体验统一。
    • 浅度集成:将桌面应用作为独立进程启动,体验更原生,但管理难度稍大。

总结

这个方案的核心价值在于通过自动化和进程管理,将复杂的部署和启动流程黑盒化,为用户提供极简的“点击即用”体验。它要求您作为开发者,在Electron主进程中构建一个强大的“应用运行时引擎”,来智能处理不同类型应用的完整生命周期。

希望这个不带代码的技术方案和架构图能为您提供清晰的路径。如果您对某个具体环节(如安全性实现、应用注册表设计)有更深入的疑问,我们可以继续探讨。

你不知道的javascript:深入理解 JavaScript 的 `map` 方法与包装类机制(从基础到大厂面试题)

“小爷我是看文档学习的。” —— 这句话道出了真正成长的起点。本文将结合 MDN 官方文档、底层原理、常见陷阱与高频面试题,系统梳理 Array.prototype.map、原始类型包装类、特殊数值(NaN/Infinity)以及字符串操作细节,助你构建扎实的 JavaScript 核心知识体系。


一、map 方法:函数式编程的基石

1.1 基础定义(源自 ES5,ES6 广泛应用)

map() 是数组的高阶函数,用于对每个元素执行一次回调函数,并返回一个全新数组,原数组不变。

javascript
编辑
const doubled = [1, 2, 3].map(x => x * 2); // [2, 4, 6]
  • ✅ 不修改原数组(immutable)
  • ✅ 返回新数组,长度与原数组一致
  • ✅ 回调函数接收三个参数:(element, index, array)

1.2 经典陷阱:[1, 2, 3].map(parseInt) 为何输出 [1, NaN, NaN]

这是大厂高频面试题!

错误直觉:
javascript
编辑
["1", "2", "3"].map(parseInt) // 期望 [1, 2, 3]
实际执行逻辑:

map 传参为 (element, index, array),而 parseInt(string, radix) 第二个参数是进制

调用 等价于 结果
parseInt("1", 0) radix=0 → 默认十进制 1
parseInt("2", 1) radix=1(非法) NaN
parseInt("3", 2) 二进制中无 "3" NaN

正确写法

javascript
编辑
arr.map(x => parseInt(x, 10)); // 显式指定十进制
// 或更简洁:
arr.map(Number); // 适用于纯数字字符串

💡 面试加分点:指出 Number()parseInt() 区别——前者支持浮点和科学计数法,后者只取整。


二、JavaScript 的“面向对象”幻觉:原始类型包装类

2.1 为什么 "hello".length 能用?

在传统语言(如 Java)中,原始类型(primitive)不能调用方法。但 JS 为了简化开发者体验,引入了自动包装机制

当你写:

javascript
编辑
"hello".length

JS 引擎内部会临时执行:

javascript
编辑
(new String("hello")).length

然后立即销毁这个临时对象。

2.2 包装类有哪些?

原始类型 包装类
string String
number Number
boolean Boolean
symbol Symbol(不可构造)
bigint BigInt(不可构造)

⚠️ 注意:nullundefined 没有包装类

2.3 验证包装行为

javascript
编辑
let str = "hello";           // 原始类型
console.log(typeof str);     // "string"

let obj = new String("hello"); // 对象类型
console.log(typeof obj);     // "object"

// 但两者 .length 行为一致
console.log(str.length);     // 5
console.log(obj.length);     // 5

📌 面试提示:不要手动使用 new String(),它会产生不必要的对象开销,且 == 比较可能出错。


三、特殊数值:NaN vs Infinity

3.1 本质区别

特性 NaN Infinity
含义 Not a Number(无效计算) 无穷大(有效但超出范围)
类型 "number" "number"
自反性 NaN !== NaN Infinity === Infinity
产生场景 0/0Math.sqrt(-1)"abc" - 1 1/0Number.MAX_VALUE * 2

3.2 如何正确判断?

javascript
编辑
// 判断 NaN
Number.isNaN(NaN);        // true(推荐)
isNaN("abc");             // true(危险!会类型转换)

// 判断 Infinity
x === Infinity;           // true
isFinite(Infinity);       // false(NaN 和 Infinity 都返回 false)

3.3 大厂面试题模板

Q:typeof NaN 是什么?如何判断一个值是 NaN

A

  • typeof NaN 返回 "number",这是 JS 的历史设计。
  • 正确判断应使用 Number.isNaN(value),因为它不会进行类型转换,只对真正的 NaN 返回 true
  • 避免使用全局 isNaN(),因为它会先尝试将参数转为数字,导致 "abc" 也被误判为 NaN

四、字符串操作细节:易错 API 对比

4.1 slice(start, end) vs substring(start, end)

特性 slice() substring()
负数索引 ✅ 支持(-1 表示末尾) ❌ 转为 0
参数顺序 start > end → 返回空串 自动交换参数
推荐度 ✅ 高(行为可预测) ⚠️ 低
javascript
编辑
"hello".slice(-3, -1);      // "ll"
"hello".substring(-3, -1);  // ""(因为 -30, -10"hello".slice(3, 1);        // ""
"hello".substring(3, 1);    // "el"(自动变为 substring(1, 3))

4.2 str[i] vs str.charAt(i)

特性 str[i] str.charAt(i)
越界返回 undefined 空字符串 ""
兼容性 ES5+ 所有浏览器
性能 略快 略慢
javascript
编辑
"hi"[10];         // undefined
"hi".charAt(10);  // ""

💡 实践建议:现代开发优先用 str[i],除非需要兼容 IE8 以下。

4.3 字符串长度与 Unicode

JS 使用 UTF-16 编码,因此:

  • 英文、中文:1 个字符 = 1 个 code unit → .length = 1
  • Emoji、生僻字:1 个字符 = 2 个 code units → .length = 2
javascript
编辑
"a".length;     // 1
"中".length;    // 1
"😂".length;    // 2(代理对)

🔍 深层知识:若需准确计算“用户可见字符数”,应使用 Array.from(str).lengthIntl.Segmenter(新 API)。


五、其他数组 API 小知识

5.1 map vs forEach

方法 返回值 用途
map 新数组 数据转换(必须 return)
forEach undefined 执行副作用(如 console.log)

❌ 错误用法:不用 map 返回值 → 应改用 forEach

5.2 稀疏数组处理

map 不会遍历空槽(empty slots) ,结果数组仍保持稀疏:

javascript
编辑
[1, , 3].map(x => x * 2); // [2, empty, 6]

5.3 通用性(Generic)

map 可通过 call 用于类数组对象:

javascript
编辑
const arrayLike = { 0: 'a', 1: 'b', length: 2 };
Array.prototype.map.call(arrayLike, x => x.toUpperCase()); // ['A', 'B']

六、总结:构建你的知识网络

主题 核心要点
map 返回新数组、不改变原数组、注意回调参数陷阱
包装类 原始类型临时转对象、不要手动 new String
NaN/Infinity 都是 number 类型、判断用 Number.isNaNx === Infinity
字符串 API slice > substring[] > charAt、注意 Unicode 长度
面向对象 JS 是“基于对象”的语言,通过包装类实现原始类型方法调用

🌟 终极建议

  • 遇到问题先查 MDN 文档(到时候面试官问你,你就说你是看文档的选手,文档教的)
  • 多写测试代码验证猜想
  • 面试时不仅要答“是什么”,更要讲清“为什么”和“怎么用”

附:高频面试题清单

  1. [1, 2, 3].map(parseInt) 为什么不是 [1, 2, 3]
  2. typeof NaN 是什么?如何安全判断 NaN
  3. "hello".length 背后发生了什么?
  4. slice 和 substring 有什么区别?
  5. 为什么 NaN !== NaN?如何利用这一点检测 NaN

掌握这些,你离大厂 offer 又近了一步!🚀

《JavaScript的"魔法"揭秘:为什么基本类型也能调用方法?》

前言:从一段"不可思议"的代码说起

// 这看起来合理吗?
"hello".length           // 5 - 字符串有属性?
520.1314.toFixed(2)      // "520.13" - 数字有方法?
true.toString()          // "true" - 布尔值能转换?

// 更神奇的是:
const str = "hello";
str.customProperty = "test";
console.log(str.customProperty); // undefined - 属性去哪了?

如果你曾经对这些现象感到困惑,那么恭喜你,你即将揭开JavaScript最深层的设计秘密!

第一章:面向对象的"皇帝的新装"

1.1 什么是真正的面向对象?

在传统的面向对象语言中,比如Java或C#,一切都围绕"类"和"对象"展开:

// Java:严格的面向对象
String str = new String("hello");  // 必须创建对象
int length = str.length();         // 才能调用方法

// 基本类型没有方法
int num = 123;
// num.toFixed(2); // 编译错误!

但在JavaScript中,规则完全不同:

// JavaScript:看似"魔法"的操作
const str = "hello";      // 基本类型?
console.log(str.length);  // 5 - 却能调用方法!

const num = 123.456;      // 基本类型?
console.log(num.toFixed(2)); // "123.46" - 也有方法!

这就是JavaScript的设计哲学:让简单的事情简单,让复杂的事情可能。

1.2 包装类的诞生:为了"看起来"面向对象

JavaScript想要成为一门"全面面向对象"的语言,但又不愿放弃简单易用的特性。于是,包装类(Wrapper Objects) 这个巧妙的解决方案诞生了。

第二章:包装类的工作原理

2.1 背后的"魔术表演"

当你写下 "hello".length时,JavaScript在背后上演了一场精彩的魔术:

// 你写的代码:
const length = "hello".length;

// JavaScript在背后执行的代码:
// 步骤1:创建临时String对象
const tempStringObject = new String("hello");

// 步骤2:调用length属性
const result = tempStringObject.length;

// 步骤3:立即销毁临时对象
tempStringObject = null;

// 步骤4:返回结果
length = result;

这个过程如此之快,以至于你完全察觉不到临时对象的存在!

2.2 三种包装类:String、Number、Boolean

JavaScript为三种基本数据类型提供了对应的包装类:

// String包装类
const str = "hello";
// 背后:new String(str).toUpperCase()
console.log(str.toUpperCase()); // "HELLO"

// Number包装类  
const num = 123.456;
// 背后:new Number(num).toFixed(2)
console.log(num.toFixed(2)); // "123.46"

// Boolean包装类
const bool = true;
// 背后:new Boolean(bool).toString()
console.log(bool.toString()); // "true"

2.3 证明包装类的存在

虽然包装过程是隐式的,但我们可以通过一些技巧证明它的存在:

const str = "hello";

// 尝试添加属性(证明有对象行为)
str.customProperty = "test";

// 但属性立即丢失(证明对象被销毁)
console.log(str.customProperty); // undefined

// 查看原型链(证明与String对象共享原型)
console.log(str.__proto__ === String.prototype); // true

第三章:map方法:函数式编程的典范

3.1 什么是map方法?

ES6引入的map方法是函数式编程思想的完美体现:

const numbers = [1, 2, 3, 4, 5];

// 传统做法(命令式)
const squared1 = [];
for (let i = 0; i < numbers.length; i++) {
    squared1.push(numbers[i] * numbers[i]);
}

// map方法(声明式)
const squared2 = numbers.map(num => num * num);

console.log(squared2); // [1, 4, 9, 16, 25]

核心特点

  • 不改变原数组(纯函数特性)
  • 返回新数组(必须接收返回值)
  • 1对1映射(每个元素对应一个结果)

3.2 map与包装类的完美配合

map方法经常与包装类方法一起使用,创造出优雅的代码:

const prices = [100, 200, 300];

// 链式调用:包装类 + map
const formattedPrices = prices
    .map(price => price * 0.9)      // 打9折
    .map(discounted => discounted.toFixed(2))  // 格式化为字符串
    .map(str => `$${str}`);         // 添加货币符号

console.log(formattedPrices); // ["$90.00", "$180.00", "$270.00"]

第四章:NaN的奇幻之旅

4.1 最特殊的"数字"

NaN可能是JavaScript中最令人困惑的值:

console.log(typeof NaN); // "number" - 却是数字类型!
console.log(NaN === NaN); // false - 自己不等于自己!

4.2 NaN的产生场景

// 数学运算错误
console.log(0 / 0);          // NaN
console.log(Math.sqrt(-1));  // NaN

// 类型转换失败
console.log(Number("hello")); // NaN
console.log(parseInt("abc")); // NaN

// 无穷大运算
console.log(Infinity - Infinity); // NaN

4.3 正确检测NaN

由于NaN的特殊性,检测它需要特殊方法:

// ❌ 错误方式
console.log(NaN === NaN); // false

// ✅ 正确方式
console.log(Number.isNaN(NaN));     // true
console.log(isNaN("hello"));        // true(更宽松)
console.log(Number.isNaN("hello")); // false(更严格)

第五章:实际开发中的最佳实践

5.1 包装类的正确使用姿势

// ✅ 推荐:直接使用字面量
const name = "Alice";
const age = 25;
const active = true;

// ❌ 避免:手动创建包装对象
const nameObj = new String("Alice"); // 不必要的复杂性
const ageObj = new Number(25);
const activeObj = new Boolean(true);

5.2 map方法的高级技巧

// 1. 处理对象数组
const users = [
    { name: 'Alice', age: 25 },
    { name: 'Bob', age: 30 }
];

const names = users.map(user => user.name.toUpperCase());
console.log(names); // ["ALICE", "BOB"]

// 2. 使用索引参数
const items = ['a', 'b', 'c'];
const indexed = items.map((item, index) => `${index + 1}. ${item}`);
console.log(indexed); // ["1. a", "2. b", "3. c"]

// 3. 条件映射
const numbers = [1, 2, 3, 4, 5];
const processed = numbers.map(num => 
    num % 2 === 0 ? num * 2 : num / 2
);
console.log(processed); // [0.5, 4, 1.5, 8, 2.5]

5.3 避免常见的陷阱

// 陷阱1:忘记接收map的返回值
const numbers = [1, 2, 3];
numbers.map(x => x * 2); // ❌ 结果丢失!
console.log(numbers); // [1, 2, 3] - 原数组未变

const doubled = numbers.map(x => x * 2); // ✅
console.log(doubled); // [2, 4, 6]

// 陷阱2:在map中修改原数组
const data = [{ value: 1 }, { value: 2 }];
const badResult = data.map(item => {
    item.value *= 2; // ❌ 副作用!
    return item;
});
console.log(data); // [{value:2}, {value:4}] - 原数组被修改!

const goodResult = data.map(item => ({
    ...item,          // ✅ 创建新对象
    value: item.value * 2
}));

第六章:性能优化和底层原理

6.1 包装类的性能考虑

虽然包装类很方便,但在性能敏感的场景需要注意:

// 在循环中避免重复包装
const strings = ["a", "b", "c", "d", "e"];

// ❌ 不好:每次循环都创建临时对象
for (let i = 0; i < 10000; i++) {
    strings.map(str => str.toUpperCase());
}

// ✅ 更好:预先处理
const upperStrings = strings.map(str => str.toUpperCase());
for (let i = 0; i < 10000; i++) {
    // 使用预先处理的结果
}

6.2 mapvs for循环的性能对比

const largeArray = Array.from({length: 1000000}, (_, i) => i);

console.time('map');
const result1 = largeArray.map(x => x * 2);
console.timeEnd('map');

console.time('for loop');
const result2 = [];
for (let i = 0; i < largeArray.length; i++) {
    result2.push(largeArray[i] * 2);
}
console.timeEnd('for loop');

现代JavaScript引擎中map的性能已经非常接近for循环,而且代码更清晰。

第七章:从历史看JavaScript的设计哲学

7.1 为什么JavaScript要这样设计?

JavaScript诞生于1995年,当时的设计目标很明确:

  1. 让非程序员也能使用 - 语法要简单
  2. 在浏览器中运行 - 性能要轻量
  3. 与Java集成 - 要"看起来像"Java

包装类正是这种设计哲学的产物:让简单的事情简单,让复杂的事情可能

7.2 与其他语言的对比

// Java:严格但繁琐
String str = new String("hello");
int length = str.length();

// Python:实用但不一致
text = "hello"
length = len(text)  # 函数调用,不是方法
number = 123
# number.toFixed(2)  # 错误!

// JavaScript:简单统一
const str = "hello";
const length = str.length;     // 属性访问
const num = 123.45;
const fixed = num.toFixed(2);  // 方法调用

第八章:现代JavaScript的发展趋势

8.1 更函数式的编程风格

随着React、Vue等框架的流行,函数式编程越来越重要:

// 现代React组件大量使用map
function UserList({ users }) {
    return (
        <ul>
            {users.map(user => (
                <li key={user.id}>
                    {user.name.toUpperCase()} - {user.age}
                </li>
            ))}
        </ul>
    );
}

8.2 TypeScript的增强

TypeScript为这些特性提供了更好的类型支持:

// 更安全的map使用
const numbers: number[] = [1, 2, 3];
const doubled: number[] = numbers.map(x => x * 2);

// 包装类的类型推断
const str: string = "hello";
const length: number = str.length; // TypeScript知道这是number

结语:JavaScript的智慧

通过理解包装类和map方法,我们看到了JavaScript独特的设计智慧:

  1. 实用性优先 - 解决真实问题比理论纯洁性更重要
  2. 渐进式复杂 - 从简单开始,需要时提供高级功能
  3. 开发者友好 - 让代码写起来直观,读起来清晰

下次当你写下 "hello".lengthnumbers.map(...)时,记得欣赏背后精巧的设计。这些看似简单的语法糖,实则是JavaScript历经20多年演进的智慧结晶。

记住:好的语言设计不是让一切变得可能,而是让常见任务变得简单,让复杂任务变得可能。

这是一个很酷的金属球,点击它会产生涟漪……

1. 分享

最近看到一个很酷的金属球效果:

点击它的时候会产生涟漪,效果如下:

体验地址:gnufault.github.io/ripple-sphe…

移动端也可以,但因为部署在 Github,需要你科学上网。

2. 实现

该效果使用 three.js 实现,整体代码并不复杂,核心的 JS 代码也就 130 多行。

这是源码地址:github.com/GNUfault/ri…

import * as THREE from "https://unpkg.com/three@0.150.0/build/three.module.js";

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(devicePixelRatio);
document.body.appendChild(renderer.domElement);
document.body.style.margin = "0";
document.body.style.overflow = "hidden";
document.body.style.touchAction = "none";

const envMap = new THREE.CubeTextureLoader().load([
  "https://threejs.org/examples/textures/cube/Bridge2/posx.jpg",
  "https://threejs.org/examples/textures/cube/Bridge2/negx.jpg",
  "https://threejs.org/examples/textures/cube/Bridge2/posy.jpg",
  "https://threejs.org/examples/textures/cube/Bridge2/negy.jpg",
  "https://threejs.org/examples/textures/cube/Bridge2/posz.jpg",
  "https://threejs.org/examples/textures/cube/Bridge2/negz.jpg",
]);
scene.background = envMap;

const material = new THREE.MeshStandardMaterial({
  metalness: 1,
  roughness: 0,
  envMap: envMap,
});

const geometry = new THREE.SphereGeometry(1, 128, 128);
const sphere = new THREE.Mesh(geometry, material);
scene.add(sphere);

const light = new THREE.DirectionalLight(0xffffff, 2);
light.position.set(5, 5, 5);
scene.add(light);
scene.add(new THREE.AmbientLight(0xffffff, 0.5));

camera.position.z = 3;

const originalPositions = geometry.attributes.position.array.slice();

let ripples = [];
const maxRipples = 6;

function addRipple(point) {
  if (ripples.length >= maxRipples) ripples.shift();
  ripples.push({ point, start: performance.now() });
}

function updateRipples() {
  const positions = geometry.attributes.position.array;
  const now = performance.now();
  const vertex = new THREE.Vector3();
  const temp = new THREE.Vector3();

  for (let i = 0; i < positions.length; i += 3) {
    vertex.set(originalPositions[i], originalPositions[i + 1], originalPositions[i + 2]);

    let offset = 0;
    for (const ripple of ripples) {
      const age = (now - ripple.start) / 1000;
      if (age > 2.5) continue;
      const dist = vertex.distanceTo(ripple.point);

      const fadeIn = Math.min(age * 8.0, 1.0);
      const fadeOut = Math.exp(-age * 3.0);
      const wave = Math.sin(dist * 60 - age * 25) * Math.exp(-dist * 5);

      offset += wave * 0.01 * fadeIn * fadeOut;
    }

    temp.copy(vertex).normalize().multiplyScalar(offset);
    positions[i] = originalPositions[i] + temp.x;
    positions[i + 1] = originalPositions[i + 1] + temp.y;
    positions[i + 2] = originalPositions[i + 2] + temp.z;
  }

  geometry.attributes.position.needsUpdate = true;
  geometry.computeVertexNormals();
}

function animate() {
  requestAnimationFrame(animate);
  updateRipples();
  renderer.render(scene, camera);
}
animate();

const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();

function handleInteraction(clientX, clientY) {
  mouse.x = (clientX / innerWidth) * 2 - 1;
  mouse.y = -(clientY / innerHeight) * 2 + 1;

  raycaster.setFromCamera(mouse, camera);
  const intersects = raycaster.intersectObject(sphere);
  if (intersects.length > 0) {
    const worldPoint = intersects[0].point.clone();
    const localPoint = sphere.worldToLocal(worldPoint);
    const normalizedPoint = localPoint.normalize();
    addRipple(normalizedPoint);
  }
}

window.addEventListener("click", (event) => {
  handleInteraction(event.clientX, event.clientY);
});

window.addEventListener("touchstart", (event) => {
  event.preventDefault();
  for (let i = 0; i < event.touches.length; i++) {
    const touch = event.touches[i];
    handleInteraction(touch.clientX, touch.clientY);
  }
});

window.addEventListener("touchmove", (event) => {
  event.preventDefault();
});

window.addEventListener("resize", () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});

你可以:

  • fork 一份,添加其他效果,作为自己的作品集
  • 作为一个学习 three.js 的示例代码

【React Native+Appwrite】获取数据时的分页机制

一、为什么要有分页

在实战开发中,如果需要做Feed流且上拉加载更多内容,说明后端肯定有很多的数据,那么向后端获取数据的时候,总不能一次性加载所有的数据吧,这样会导致加载速度变慢,且后端数据库的压力很大。

那么我们在获取数据的时候,可以分次获取,每次获取一定数量的数据,用户上拉的时候再拉取下一页,再发起一次请求,这就是分页机制

在Appwrite中常用的分页方式有两种:

  • 基于页码Offset分页
  • 基于游标Cursor分页

二、Offset分页(页码式分页)

Offset分页是基于页码的,原理是记录当前的页码page,获取数据的时候从数据集中跳过前面的offset = (page - 1) * limit 条,获取limit条数据。

优点:

  • 逻辑直观好理解,实现简单
  • 适合数据量不大、不常变动的场景

缺点:

  • 数据频繁变动的时候(增加减少数据)可能会导致缺漏和重复,因为整体的数据会偏移
  • 数据量大的时候,需要跳过大量的数据,后端在处理的时候效率会降低

使用示例:

import { Query } from "appwrite";

async function fetchPage_offset(page = 1, limit = 15) {
  const offset = (page - 1) * limit;
  const res = await databases.listDocuments(DB_ID, COLLECTION_ID, [
    Query.orderDesc("$createdAt"),  //排序
    Query.limit(limit),    //限制数据数量
    Query.offset(offset),  //偏移量
  ]);
  return res; // 返回来的数据在res.documents, 数量是res.total
}

使用场景: 小型数据集、开发期快速验证、或者你需要精准的“页码跳转到第x页”体验。

三、Cursor分页

Cursor分页是基于游标的,原理是每次请求都按照某个顺序取一定数量的数据(一般是按照创建时间),取完以后拿出最后一条数据的ID作为游标,下一次请求的时候从这个游标之后继续取,这样定位准确,不会缺漏记录。

相当于看书,往看到的地方插个书签,下次再读就不会找错了。

优点

  • 性能更高,数据库不需要跳过大量记录
  • 稳定性强,在数据被频繁插入和删除时更可靠,以游标为基础,不易缺漏和重复
  • 适合无限滚动的场景

缺点

  • 需要按稳定字段排序(通常是创建时间$createdAt 或其它有索引的字段)
  • 实现时要处理排序方向(asc/desc)和 cursor 的关系
  • 无法直接跳转到某一页

使用示例

import { Query } from "appwrite";

//第一次请求 
async function fetchFirstPage_cursor(limit = 15) {  
  const res = await databases.listDocuments(DB_ID, COLLECTION_ID, [
    Query.orderDesc("$createdAt"),  //排序
    Query.limit(limit),   //取limit条数据
  ]);
  return res; // 数据在res.documents, 数量是res.total
}

// 获取到数据后,保存最后一条数据的ID作为游标
// const lastDocId = res.documents[res.documents.length - 1].$id;


//下一页请求
async function fetchNextPage_cursor(lastDocId, limit = 15) {
  const res = await databases.listDocuments(DB_ID, COLLECTION_ID, [
    Query.orderDesc("$createdAt"),
    Query.cursorAfter(lastDocId),  //从游标处后面找起
    Query.limit(limit),
  ]);
  return res;
}

注意cursorAfter/cursorBefore 这两个方法是基于游标,不是基于时间戳。所以在使用的时候需要和排序结合使用,先排序后按游标查找,效果更稳定。

四、总结

  • 方法选择:

    • Offset分页:数据量小,需要直接跳转到第x页的场景,实现简单
    • Cursor分页:数据量大的大部分场景,例如Feed流、评论区、聊天记录或大型数据集,效果更好,但稍微复杂
  • 分页大小(limit):为了保证首屏加载的效率,量不需要太大,可以在8~20之间

  • 游标选择:使用 Cursor 分页时,确保排序字段稳定且有索引(如 $id$createdAt,但Appwrite官方文档中使用的是id)。

  • 去重策略:不管使用什么方法分页,获取到新数据后,都要记得去重,以免出现重复的数据

  • 刷新(pull-to-refresh)策略:下拉刷新通常应该重新拉第一页,并替换列表数据而不是追加

为什么 `Promise.then` 总比 `setTimeout(..., 0)` 快?微任务的秘密

一、事件循环的两条队列

要理解微任务,我们必须先回到 JavaScript的事件循环(Event Loop)

它处理两种不同类型任务:

  1. 宏任务队列 (Macrotask):存放着独立的、较大的任务。
  2. 微任务队列 (Microtask):存放着需要被紧急执行的、较小的任务。

二、微任务 vs. 宏任务

特性 微任务 (Microtask) 宏任务 (Macrotask)
常见来源 Promise.then/catch/finallyqueueMicrotask()MutationObserverprocess.nextTick (Node.js) setTimeoutsetIntervalrequestAnimationFrame、I/O操作(文件、网络)、UI事件(点击、键盘)
执行时机 在当前任务执行结束后、下一个任务开始前立即执行。 在上一个任务结束后,由事件循环调度,中间可能穿插渲染和微任务。
执行数量 在一次循环中,会全部清空 在一次循环中,只会执行一个
优先级 。可以看作是当前任务的“收尾工作”。 。可以看作是全新的、独立的工作。
比喻 去银行办业务时,填完一张表后,你需要立即去另一个窗口签字确认。这个“签字”就是微任务,必须立刻完成,才能算当前业务(宏任务)告一段落。 你在银行办完了一项业务(宏任务),然后重新取号,等待叫到你的号再去办另一项完全独立的业务(下一个宏任务)。

三、事件循环示例

// 1. 同步代码入栈执行
console.log('Script Start');

// 2. 遇到 setTimeout,将其回调函数注册到 Web API,计时器结束后,回调被放入「宏任务队列」。
setTimeout(() => {
  console.log('Timeout');
}, 0);

// 3. 遇到 Promise.resolve(),其 .then() 的回调被立即放入「微任务队列」。
Promise.resolve().then(() => {
  console.log('Promise');
});

// 4. 同步代码入栈执行
console.log('Script End');

解析:

  1. 同步代码执行完毕:调用栈变空。输出 Script StartScript End
  2. 检查微任务队列:发现里面有一个 () => console.log('Promise')
  3. 清空微任务队列:执行该任务。输出 Promise
  4. 检查微任务队列:现在空了。
  5. (可能进行渲染)
  6. 取一个宏任务:从宏任务队列中取出 () => console.log('Timeout')
  7. 执行宏任务:执行该任务。输出 Timeout

流程结束。这完美解释了我们看到的输出顺序。

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript/TypeScript开发干货

基于 Vue3+TypeScript+Vant 的评论组件开发实践

在现代 Web 应用中,评论功能是提升用户互动性的核心模块之一。它不仅需要满足用户发表评论、回复互动的基础需求,还需要兼顾易用性、视觉体验和功能完整性。本文将结合完整代码,详细分享基于 Vue3+TypeScript+Vant 组件库开发的评论系统实现方案,从组件设计、代码实现到状态管理,层层拆解核心细节。

联想截图_20251107164213.jpg

一、整体架构设计

整个评论系统采用「组件化 + 状态管理」的架构模式,拆分为三个核心模块,各司其职且协同工作:

模块文件 核心职责 技术核心
CommentInput.vue 评论 / 回复输入弹窗,支持文本 + 表情输入 Vue3 组合式 API、Vant Popup/Field
CommentList.vue 评论列表展示,包含点赞、回复、删除等交互 条件渲染、事件监听、组件通信
comments.ts(Pinia) 全局评论状态管理,处理数据增删改查 Pinia 状态管理、TypeScript 接口定义

这种拆分遵循「单一职责原则」,让每个模块专注于自身功能,既提升了代码可维护性,也便于后续扩展。

二、核心模块代码详解

(一)评论输入组件:CommentInput.vue

负责接收用户输入(文本 + 表情),是交互入口。核心需求:支持多行输入、表情选择、内容同步、发送逻辑。

1. 模板结构核心代码

<van-popup v-model:show="show" position="bottom">
  <div class="comment-input">
    <!-- 文本输入框 -->
    <van-field
      type="textarea"
      rows="2"
      autosize
      v-model="inputValue"
      :placeholder="placeholder"
    />
    <!-- 操作栏:表情按钮+发送按钮 -->
    <div class="comment-actions">
      <van-icon name="smile-o" @click="onEmoji" />
      <van-button
        class="send-btn"
        round
        type="primary"
        :disabled="!inputValue"
        @click="handleSend"
      >发送</van-button>
    </div>
    <!-- 表情面板:折叠/展开切换 -->
    <div class="emoji-mart-wrapper" :class="{ expanded: showAllEmojis }">
      <div class="simple-emoji-list">
        <span
          v-for="(emoji, idx) in emojis"
          :key="idx"
          class="simple-emoji"
          @click="addEmojiFromPicker(emoji)"
        >{{ emoji }}</span>
      </div>
    </div>
  </div>
</van-popup>
  • 关键设计

    • van-popup实现底部弹窗,position="bottom"确保滑入效果;
    • 文本框用autosize自动适配高度,避免输入多行时滚动混乱;
    • 表情面板通过expanded类控制高度过渡(48px→240px),配合overflow-y:auto支持滚动。

2. 逻辑核心代码

import { ref, watch, defineProps, defineEmits } from 'vue'

// 定义props和emit,实现父子组件通信
const props = defineProps({
  show: Boolean,
  modelValue: String,
  placeholder: { type: String, default: '友善发言,理性交流' }
})
const emit = defineEmits(['update:show', 'update:modelValue', 'send'])

// 响应式变量
const show = ref(props.show) // 弹窗显示状态
const inputValue = ref(props.modelValue || '') // 输入内容
const showAllEmojis = ref(false) // 表情面板展开状态

// 表情库(包含表情、动物、食物等多分类)
const emojis = ['😀', '😁', '😂', ...] // 完整列表见原代码

// 监听props变化,同步到组件内部状态
watch(() => props.show, v => show.value = v)
watch(show, v => emit('update:show', v)) // 双向绑定弹窗状态
watch(() => props.modelValue, val => inputValue.value = val)
watch(inputValue, val => emit('update:modelValue', val)) // 同步输入内容

// 表情面板展开/收起切换
function onEmoji() {
  showAllEmojis.value = !showAllEmojis.value
}

// 选择表情:拼接至输入框
function addEmojiFromPicker(emoji: string) {
  inputValue.value += emoji
  emit('update:modelValue', inputValue.value)
}

// 发送评论
function handleSend() {
  if (!inputValue.value) return
  emit('send', inputValue.value) // 向父组件传递输入内容
  inputValue.value = '' // 清空输入框
  emit('update:modelValue', '')
  showAllEmojis.value = false // 收起表情面板
  show.value = false // 关闭弹窗
}
  • 关键逻辑

    • watch实现 props 与组件内部状态的双向同步,确保父子组件数据一致;
    • 表情选择直接拼接字符串,无需处理光标位置,简化逻辑;
    • 发送按钮通过!inputValue控制禁用状态,避免空内容提交。

3. 样式优化(SCSS)

.emoji-mart-wrapper {
  background: #fff;
  height: 48px;
  max-height: 48px;
  overflow-y: hidden;
  transition: max-height 0.3s, height 0.3s; // 平滑过渡
  &.expanded {
    height: 240px;
    max-height: 240px;
    overflow-y: auto;
  }
}
.simple-emoji {
  font-size: 24px;
  cursor: pointer;
  transition: transform 0.1s;
  &:hover {
    transform: scale(1.2); //  hover放大,提升交互反馈
  }
}

(二)评论列表组件:CommentList.vue

核心展示与交互模块,负责评论列表渲染、回复、点赞、删除、长按操作等。

1. 模板结构核心代码

<div class="comment-list">
  <!-- 评论列表 -->
  <div v-for="(comment, idx) in showComments" :key="comment.id" class="comment-item">
    <!-- 评论者头像 -->
    <img class="avatar" :src="comment.avatar" />
    <div class="comment-main">
      <div class="nickname">{{ comment.nickname }}</div>
      <!-- 评论内容:支持@高亮,绑定点击/长按事件 -->
      <div
        class="content"
        @click="openReply(idx, undefined, comment.userId)"
        @touchstart="onTouchStart(idx, undefined, comment.content)"
        @contextmenu.prevent="onContextMenu(idx, undefined, comment.content, $event)"
        v-html="comment.content"
      ></div>
      <!-- 操作栏:时间、回复、点赞 -->
      <div class="meta">
        <span class="time">{{ comment.time }}</span>
        <span class="reply" @click="openReply(idx, undefined, comment.userId)">回复</span>
        <span class="like" @click="likeComment(idx)" :class="{ 'liked-active': comment.liked }">
          <van-icon name="good-job-o" />
          {{ comment.likes }}
        </span>
      </div>
      <!-- 回复列表:支持折叠/展开 -->
      <div v-if="comment.replies && comment.replies.length" class="reply-list">
        <div
          v-for="(reply, ridx) in showAllReplies[idx] ? comment.replies : comment.replies.slice(0, 1)"
          :key="reply.id"
          class="comment-item reply-item"
        >
          <!-- 回复内容结构与评论一致,略 -->
        </div>
        <!-- 折叠/展开按钮 -->
        <div v-if="comment.replies.length > 1" class="expand-reply" @click="toggleReplies(idx)">
          {{ showAllReplies[idx] ? '收起' : `展开${comment.replies.length}条回复` }}
        </div>
      </div>
    </div>
  </div>

  <!-- 输入回复弹窗(复用CommentInput组件) -->
  <CommentInput
    v-model="replyContent"
    v-model:show="showReplyInput"
    :placeholder="replyTarget ? `回复 @${getNicknameByUserId(replyTarget.userId)}:` : '请输入回复内容~'"
    @send="sendReply"
  />

  <!-- 长按/右键操作菜单 -->
  <van-action-sheet
    v-model:show="showActionSheet"
    :actions="actionOptions"
    @select="onActionSelect"
    cancel-text="取消"
  />
</div>
  • 关键设计

    • 评论与回复共用一套结构,通过reply-item类区分样式,减少冗余;
    • 回复列表默认显示 1 条,超过 1 条显示「展开」按钮,优化视觉体验;
    • 复用CommentInput组件实现回复输入,提升代码复用率;
    • v-html渲染内容,支持回复中的 @用户高亮(蓝色文本)。

2. 核心逻辑代码

import { ref, watch, computed, PropType } from 'vue'
import CommentInput from '@/components/CommentInput.vue'
import { useCommentsStore, Comment, Reply } from '@/store/comments'
import { useUserStore } from '@/store/user'
import { showToast } from 'vant'

// Props定义:接收评论列表和是否显示全部
const props = defineProps({
  comments: { type: Array as PropType<Comment[]>, required: true },
  showAll: { type: Boolean, default: false }
})
const emit = defineEmits(['more'])

const commentsStore = useCommentsStore() // 评论状态管理
const userStore = useUserStore() // 用户状态(获取当前登录用户)

// 回复相关状态
const showReplyInput = ref(false) // 回复弹窗显示状态
const replyContent = ref('') // 回复内容
const replyTarget = ref<{ commentIdx: number; replyIdx?: number; userId: string } | null>(null) // 回复目标

// 控制回复列表折叠/展开
const showAllReplies = ref(props.comments.map(() => false))
watch(() => props.comments, val => {
  showAllReplies.value = val.map(() => false) // 评论列表变化时重置折叠状态
}, { immediate: true })

// 评论列表分页:默认显示2条,showAll为true时显示全部
const showComments = computed(() => {
  return props.showAll ? props.comments : props.comments.slice(0, 2)
})

// 当前登录用户ID(用于权限控制)
const currentUserId = computed(() => userStore.userInfo?.id?.toString() || 'anonymous')

// 1. 点赞评论
function likeComment(idx: number) {
  const comment = showComments.value[idx]
  commentsStore.likeComment(comment.id) // 调用Pinia Action修改状态
}

// 2. 回复评论/回复
function openReply(commentIdx: number, replyIdx?: number, userId?: string) {
  replyTarget.value = { commentIdx, replyIdx, userId: userId || '' }
  showReplyInput.value = true
  replyContent.value = '' // 清空输入框
}

// 3. 发送回复
function sendReply(val: string) {
  if (!val || !replyTarget.value) return
  const { commentIdx, replyIdx } = replyTarget.value
  const comment = showComments.value[commentIdx]
  let content = val
  // 回复某条回复时,添加@提及
  if (replyIdx !== undefined && comment.replies[replyIdx]) {
    content = `<span style='color:#409EFF'>@${comment.replies[replyIdx].nickname}</span> ${val}`
  }
  // 调用Pinia Action添加回复
  const userInfo = userStore.userInfo
  const reply: Reply = {
    id: Date.now(), // 用时间戳作为唯一ID
    avatar: userInfo?.avatar || getAssetUrl(userInfo?.gender === 'female' ? 'avatar_woman.svg' : 'avatar_man.svg'),
    nickname: userInfo?.nickname || '匿名用户',
    userId: userInfo?.id?.toString() || 'anonymous',
    content,
    time: new Date().toLocaleString(),
    likes: 0
  }
  commentsStore.addReply(comment.id, reply)
  showReplyInput.value = false
}

// 4. 长按/右键操作(复制/删除)
const showActionSheet = ref(false)
const actionOptions = ref([{ name: '复制' }, { name: '删除' }])
const actionTarget = ref<{ commentIdx: number; replyIdx?: number; content: string } | null>(null)
let touchTimer: any = null

// 设置操作菜单(只有自己的内容才显示删除)
function setActionOptions(commentIdx: number, replyIdx?: number) {
  let canDelete = false
  if (replyIdx !== undefined) {
    const comment = showComments.value[commentIdx]
    canDelete = comment.replies[replyIdx].userId === currentUserId.value
  } else {
    const comment = showComments.value[commentIdx]
    canDelete = comment.userId === currentUserId.value
  }
  actionOptions.value = canDelete ? [{ name: '复制' }, { name: '删除' }] : [{ name: '复制' }]
}

// 移动端长按触发
function onTouchStart(commentIdx: number, replyIdx: number | undefined, content: string) {
  setActionOptions(commentIdx, replyIdx)
  touchTimer = setTimeout(() => {
    actionTarget.value = { commentIdx, replyIdx, content }
    showActionSheet.value = true
  }, 500)
}

// 长按取消
function onTouchEnd() {
  if (touchTimer) clearTimeout(touchTimer)
}

// PC端右键菜单
function onContextMenu(commentIdx: number, replyIdx: number | undefined, content: string, e: Event) {
  e.preventDefault()
  setActionOptions(commentIdx, replyIdx)
  actionTarget.value = { commentIdx, replyIdx, content }
  showActionSheet.value = true
}

// 操作菜单选择(复制/删除)
async function onActionSelect(action: { name: string }) {
  if (!actionTarget.value) return
  const { commentIdx, replyIdx, content } = actionTarget.value
  if (action.name === '复制') {
    // 提取纯文本(过滤HTML标签)
    const tempDiv = document.createElement('div')
    tempDiv.innerHTML = content
    await navigator.clipboard.writeText(tempDiv.innerText)
    showToast('已复制')
  } else if (action.name === '删除') {
    if (replyIdx !== undefined) {
      commentsStore.deleteReply(showComments.value[commentIdx].id, showComments.value[commentIdx].replies[replyIdx].id)
    } else {
      commentsStore.deleteComment(showComments.value[commentIdx].id)
    }
    showToast('已删除')
  }
  showActionSheet.value = false
}
  • 关键逻辑

    • 权限控制:通过currentUserId与评论 / 回复的userId比对,仅显示自己内容的删除按钮;
    • 回复 @提及:回复特定用户时,自动拼接<span>标签实现蓝色高亮;
    • 兼容移动端 / PC 端:通过touchstart/touchend处理长按,contextmenu处理右键菜单;
    • 分页与折叠:评论列表默认显示 2 条,回复列表默认显示 1 条,优化长列表渲染性能。

(三)状态管理:comments.ts(Pinia)

负责管理评论全局状态,提供统一的数据操作 API,避免组件间数据传递混乱。

1. 数据模型定义(TypeScript 接口)

// 回复数据模型
export interface Reply {
  id: number
  avatar: string
  nickname: string
  userId: string
  content: string
  time: string
  likes: number
  liked?: boolean // 是否点赞
}

// 评论数据模型
export interface Comment {
  id: number
  avatar: string
  nickname: string
  userId: string
  content: string
  time: string
  likes: number
  liked?: boolean
  replies: Reply[] // 关联的回复列表
}
  • 用 TypeScript 接口定义数据结构,确保类型安全,减少开发时的类型错误。

2. Pinia Store 核心代码

import { defineStore } from 'pinia'
import { getAssetUrl } from '@/utils/index'
import { Comment, Reply } from './types'

export const useCommentsStore = defineStore('comments', {
  state: () => ({
    // 初始测试数据
    comments: [
      {
        id: 1,
        avatar: getAssetUrl('avatar_woman.svg'),
        nickname: '徐济锐',
        userId: 'xujirui',
        content: '内容详细丰富,详细的介绍了电信业务稽核系统技术规范,条理清晰。',
        time: '2025-06-09 17:08:17',
        likes: 4,
        replies: [
          {
            id: 11,
            avatar: getAssetUrl('avatar_man.svg'),
            nickname: '张亮',
            userId: 'zhangliang',
            content: '文本编辑调理清晰,很不错!',
            time: '2025-06-09 17:08:17',
            likes: 4
          }
        ]
      },
      // 更多测试数据...
    ] as Comment[]
  }),
  actions: {
    // 添加评论(插入到列表头部)
    addComment(comment: Comment) {
      this.comments.unshift(comment)
    },
    // 给指定评论添加回复
    addReply(commentId: number, reply: Reply) {
      const comment = this.comments.find(c => c.id === commentId)
      if (comment) comment.replies.push(reply)
    },
    // 点赞/取消点赞评论
    likeComment(id: number) {
      const comment = this.comments.find(c => c.id === id)
      if (comment) {
        comment.liked = !comment.liked
        comment.likes += comment.liked ? 1 : -1
      }
    },
    // 点赞/取消点赞回复
    likeReply(commentId: number, replyId: number) {
      const comment = this.comments.find(c => c.id === commentId)
      if (comment) {
        const reply = comment.replies.find(r => r.id === replyId)
        if (reply) {
          reply.liked = !reply.liked
          reply.likes += reply.liked ? 1 : -1
        }
      }
    },
    // 删除评论
    deleteComment(id: number) {
      this.comments = this.comments.filter(c => c.id !== id)
    },
    // 删除回复
    deleteReply(commentId: number, replyId: number) {
      const comment = this.comments.find(c => c.id === commentId)
      if (comment) {
        comment.replies = comment.replies.filter(r => r.id !== replyId)
      }
    }
  }
})
  • 关键设计

    • 所有数据操作都通过 Action 方法实现,组件无需直接修改 State,确保数据流向清晰;
    • 点赞逻辑通过liked状态切换,同步更新likes计数,避免重复点赞;
    • 初始测试数据模拟真实场景,便于开发调试。

三、核心技术亮点

  1. TypeScript 类型安全:从组件 Props 到 Pinia 状态,全程使用 TypeScript 接口约束,减少类型错误,提升开发体验;
  2. 组件复用CommentInput组件同时支持评论和回复输入,避免重复开发;
  3. 交互体验优化:表情面板平滑过渡、点赞状态切换反馈、长按防误触(500ms 延迟)、空状态提示;
  4. 性能优化:评论 / 回复列表分页渲染、折叠显示,减少 DOM 节点数量;;
  5. 权限控制:仅当前登录用户可删除自己的评论 / 回复,提升数据安全性。

基于 React Native/Expo 项目的持续集成(CI)最佳实践配置指南

目录


概述

本项目的 CI 配置包含以下核心功能:

🔍 检查项

检查项 触发时机 用途
TypeScript 类型检查 Pre-commit 确保类型安全
ESLint 代码规范 Pre-commit 代码风格和质量
Prettier 代码格式化 Pre-commit 统一代码格式
提交消息规范 Commit-msg 规范化 Git 提交
分支保护 Pre-commit 防止直接提交到主分支
依赖自动安装 Post-merge 保持依赖同步
单元测试 手动/CI 确保代码质量

📦 依赖包

{
  "husky": "^9.1.5",
  "lint-staged": "^15.2.9",
  "@commitlint/cli": "^19.2.2",
  "@commitlint/config-conventional": "^19.2.2",
  "eslint": "^9.28.0",
  "prettier": "^3.3.3",
  "typescript": "^5.8.3",
  "jest": "^29.7.0"
}

Git Hooks 配置

1. Husky 设置

安装和初始化

# 安装依赖
pnpm add -D husky

# 初始化 husky
pnpm exec husky init

目录结构

.husky/
├── _/                  # Husky 内部文件
├── common.sh          # 共享脚本
├── pre-commit         # 提交前检查
├── commit-msg         # 提交消息检查
└── post-merge         # 合并后处理

2. Pre-commit Hook

文件:.husky/pre-commit

. "$(dirname "$0")/common.sh"

echo "===\n>> Checking branch name..."

# 分支保护
if [[ -z $SKIP_BRANCH_PROTECTION ]]; then
    BRANCH=$(git rev-parse --abbrev-ref HEAD)
    PROTECTED_BRANCHES="^(main|master)"

    if [[ $BRANCH =~ $PROTECTED_BRANCHES ]]; then
        echo ">> Direct commits to the $BRANCH branch are not allowed."
        exit 1
    fi
fi

echo ">> Linting your files and fixing them if needed..."

# TypeScript 类型检查
pnpm type-check

# 代码规范检查和自动修复
pnpm lint-staged

功能:

  • ✅ 阻止直接提交到 main/master 分支
  • ✅ 运行 TypeScript 类型检查
  • ✅ 对暂存文件运行 ESLint 和 Prettier

3. Commit-msg Hook

文件:.husky/commit-msg

pnpm commitlint --edit $1

功能:

  • ✅ 验证提交消息格式符合 Conventional Commits 规范

4. Post-merge Hook

文件:.husky/post-merge

function changed {
    git diff --name-only HEAD@{1} HEAD | grep "^$1" >/dev/null 2>&1
}

echo 'Checking for changes in pnpm-lock.yaml...'

if changed 'pnpm-lock.yaml'; then
    echo "📦 pnpm-lock.yaml changed. Installing dependencies..."
    pnpm install
fi

echo 'You are up to date :)'

功能:

  • ✅ 检测依赖变化,自动运行 pnpm install

5. Common Shell 脚本

文件:.husky/common.sh

command_exists() {
    command -v "$1" >/dev/null 2>&1
}

# Windows Git Bash 兼容性
if command_exists winpty && test -t 1; then
    exec </dev/tty
fi

代码质量检查

1. Lint-staged 配置

文件:lint-staged.config.js

module.exports = {
  // TypeScript/JavaScript 文件
  '**/*.{js,jsx,ts,tsx}': (filenames) => [
    `npx eslint --fix ${filenames
      .map((filename) => `"${filename}"`)
      .join(' ')}`,
  ],

  // Markdown 和 JSON 文件
  '**/*.(md|json)': (filenames) =>
    `npx prettier --write ${filenames
      .map((filename) => `"${filename}"`)
      .join(' ')}`,

  // 翻译文件特殊处理
  'src/translations/*.(json)': (filenames) => [
    `npx eslint --fix ${filenames
      .map((filename) => `"${filename}"`)
      .join(' ')}`,
  ],
};

功能:

  • 只检查暂存的文件(提高性能)
  • 自动修复可修复的问题
  • 支持多种文件类型

2. Commitlint 配置

文件:commitlint.config.js

module.exports = {
  extends: ['@commitlint/config-conventional'],
};

提交消息格式:

<type>: <description>

[optional body]

[optional footer]

允许的 type 类型:

Type 说明 示例
feat 新功能 feat: 添加用户登录功能
fix Bug 修复 fix: 修复登录页面闪退问题
docs 文档更新 docs: 更新 API 文档
style 代码格式(不影响功能) style: 格式化代码缩进
refactor 重构 refactor: 重构用户服务模块
perf 性能优化 perf: 优化列表渲染性能
test 测试相关 test: 添加登录组件单元测试
chore 构建/工具相关 chore: 更新依赖版本
ci CI 配置 ci: 添加 GitHub Actions 配置
build 构建系统 build: 优化打包配置
revert 回滚提交 revert: 回滚登录功能

3. ESLint 配置

文件:eslint.config.mjs

核心规则:

export default defineConfig([
  // 全局忽略
  globalIgnores([
    'dist/*',
    'node_modules',
    'coverage',
    'android',
    'ios',
    '.expo',
  ]),

  // 核心规则
  {
    rules: {
      'max-params': ['error', 3], // 最多3个参数
      'max-lines-per-function': ['error', 300], // 函数最多300行
      'unicorn/filename-case': [
        'error',
        {
          case: 'kebabCase',
        },
      ], // kebab-case 命名
      'simple-import-sort/imports': 'error', // 自动排序 imports
      'unused-imports/no-unused-imports': 'error', // 禁止未使用的导入
      'import/no-cycle': ['error'], // 禁止循环依赖
      '@typescript-eslint/consistent-type-imports': [
        'warn',
        { prefer: 'type-imports' },
      ], // 强制使用 type imports
    },
  },

  // TypeScript 特定配置
  {
    files: ['**/*.ts', '**/*.tsx'],
    languageOptions: {
      parser: parser,
      parserOptions: {
        project: './tsconfig.json',
      },
    },
  },

  // 测试文件配置
  {
    files: ['**/__tests__/**/*', '**/*.test.*'],
    plugins: { 'testing-library': testingLibrary },
  },
]);

4. Prettier 配置

文件:.prettierrc.js

module.exports = {
  singleQuote: true, // 使用单引号
  endOfLine: 'auto', // 自动行尾符
  trailingComma: 'es5', // ES5 尾随逗号
};

5. TypeScript 配置

文件:tsconfig.json

{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true, // 严格模式
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"] // 路径别名
    },
    "esModuleInterop": true,
    "checkJs": true // 检查 JS 文件
  },
  "exclude": ["node_modules", "android", "ios"]
}

6. Jest 配置

文件:jest.config.js

module.exports = {
  preset: 'jest-expo',
  setupFilesAfterEnv: ['<rootDir>/jest-setup.ts'],
  testMatch: ['**/?(*.)+(spec|test).ts?(x)'],

  // 代码覆盖率收集
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!**/coverage/**',
    '!**/node_modules/**',
  ],

  // 报告器
  reporters: [
    'default',
    ['github-actions', { silent: false }],
    'summary',
    [
      'jest-junit',
      {
        outputDirectory: 'coverage',
        outputName: 'jest-junit.xml',
      },
    ],
  ],

  // 路径映射
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
};

配置文件详解

Package.json Scripts

{
  "scripts": {
    // 代码检查
    "lint": "eslint . --ext .js,.jsx,.ts,.tsx",
    "type-check": "tsc --noEmit",
    "lint:translations": "eslint ./src/translations/ --fix --ext .json",

    // 测试
    "test": "jest",
    "test:ci": "pnpm run test --coverage",
    "test:watch": "pnpm run test --watch",

    // 完整检查
    "check-all": "pnpm run lint && pnpm run type-check && pnpm run lint:translations && pnpm run test",

    // Git hooks
    "prepare": "husky"
  }
}

快速开始

在新项目中配置 CI

1. 安装依赖

pnpm add -D husky lint-staged @commitlint/cli @commitlint/config-conventional
pnpm add -D eslint prettier typescript jest
pnpm add -D @typescript-eslint/parser @typescript-eslint/eslint-plugin
pnpm add -D eslint-plugin-prettier eslint-config-prettier

2. 初始化 Husky

pnpm exec husky init

3. 创建配置文件

复制以下配置文件到项目根目录:

# 必需的配置文件
├── .husky/
│   ├── pre-commit
│   ├── commit-msg
│   ├── post-merge
│   └── common.sh
├── lint-staged.config.js
├── commitlint.config.js
├── eslint.config.mjs
├── .prettierrc.js
├── tsconfig.json
└── jest.config.js

4. 添加 NPM Scripts

package.json 中添加:

{
  "scripts": {
    "prepare": "husky",
    "lint": "eslint . --ext .js,.jsx,.ts,.tsx",
    "type-check": "tsc --noEmit",
    "test": "jest",
    "check-all": "pnpm run lint && pnpm run type-check && pnpm run test"
  }
}

5. 设置 Git Hooks 可执行权限

chmod +x .husky/pre-commit
chmod +x .husky/commit-msg
chmod +x .husky/post-merge

6. 测试配置

# 测试 lint
pnpm lint

# 测试类型检查
pnpm type-check

# 测试提交(会触发所有 hooks)
git add .
git commit -m "test: 测试 CI 配置"

提交代码流程

标准提交流程

# 1. 确保在功能分支上
git checkout -b feat/your-feature-name

# 2. 添加修改的文件
git add .

# 3. 提交(使用规范的提交消息)
git commit -m "feat: 添加新功能"

# 自动触发:
# ✓ 分支检查
# ✓ TypeScript 类型检查
# ✓ ESLint 自动修复
# ✓ Prettier 格式化
# ✓ 提交消息格式验证

绕过检查(仅紧急情况)

# 跳过分支保护
SKIP_BRANCH_PROTECTION=1 git commit -m "fix: 紧急修复"

# 跳过所有 pre-commit hooks
git commit --no-verify -m "fix: 紧急修复"

⚠️ 不推荐在正常开发中使用


常见问题

Q1: 提交时报错 "Direct commits to main branch are not allowed"

原因: 你在 mainmaster 分支上提交

解决方案:

# 创建新分支
git checkout -b feat/your-feature

# 或者跳过检查(不推荐)
SKIP_BRANCH_PROTECTION=1 git commit -m "your message"

Q2: TypeScript 类型检查失败

原因: 代码存在类型错误

解决方案:

# 查看具体错误
pnpm type-check

# 修复类型错误后重新提交

Q3: ESLint 错误无法自动修复

原因: 某些规则需要手动修复

解决方案:

# 查看详细错误
pnpm lint

# 尝试自动修复
pnpm lint --fix

# 手动修复剩余问题

Q4: 提交消息格式错误

错误示例:

git commit -m "add new feature"  # ❌ 缺少 type
git commit -m "Add new feature"  # ❌ 首字母大写

正确示例:

git commit -m "feat: add new feature"      # ✅
git commit -m "fix: resolve login issue"   # ✅

Q5: Husky hooks 不生效

解决方案:

# 重新安装 husky
rm -rf .husky
pnpm exec husky init

# 重新创建 hooks
# 复制配置文件到 .husky/

# 确保可执行权限
chmod +x .husky/*

Q6: 依赖没有自动安装

原因: post-merge hook 没有正确配置

解决方案:

# 手动安装
pnpm install

# 检查 .husky/post-merge 是否存在
ls -la .husky/post-merge

最佳实践

✅ 推荐做法

  1. 永远在功能分支上工作

    git checkout -b feat/feature-name
    
  2. 提交前本地测试

    pnpm check-all
    
  3. 编写清晰的提交消息

    feat: 用户认证功能
    
    - 添加登录页面
    - 实现 JWT token 验证
    - 添加用户状态管理
    
  4. 小步提交,频繁提交

    • 每个提交只包含一个逻辑变更
    • 便于代码审查和回滚
  5. 定期运行完整检查

    pnpm check-all
    

❌ 避免做法

  1. ❌ 频繁使用 --no-verify
  2. ❌ 在 main 分支直接提交
  3. ❌ 不遵循提交消息规范
  4. ❌ 提交未格式化的代码
  5. ❌ 跳过类型检查和测试

维护和更新

定期任务

# 每月更新依赖
pnpm update

# 检查过时的依赖
pnpm outdated

# 更新主要版本(谨慎)
pnpm update --latest

团队同步

# 拉取最新代码后
git pull
# post-merge hook 会自动安装依赖

# 如果配置文件更新
pnpm install

总结

通过这套 CI 配置,你的项目将获得:

  • 自动化代码质量检查
  • 统一的代码风格
  • 规范的提交历史
  • 早期发现问题
  • 更好的团队协作

这些配置可以直接应用于任何 React Native/Expo 项目,也可以根据项目需求进行调整。


参考资源

CSS实现高级流光按钮动画,这几行代码堪称神来之笔

大家好,我是大华!今天分享一个CSS流光按钮效果。这种效果在现代网站设计中非常流行,能够明细的提升用户体验和页面视觉吸引力。

先看一下最终效果

在这里插入图片描述

当鼠标悬停在按钮上时,按钮会上升并显示流动的彩色边框,同时内部会有高光扫过,实现了流光溢彩的视觉效果。

HTML结构

首先,我们来看HTML结构,它非常简洁明了:

<div class="container">
    <h1>这个标题也是有效果的哦~</h1>
    
    <p class="description">
        这是一个完全使用CSS创建的流光效果...
    </p>
    
    <div class="button-container">
        <a href="#" class="btn btn-primary"><span>开始体验</span></a>
        <a href="#" class="btn btn-secondary"><span>了解更多</span></a>
        <a href="#" class="btn btn-tertiary"><span>立即下载</span></a>
    </div>
</div>

CSS样式

1. 流光文字效果

h1 {
    color: transparent;
    background: linear-gradient(90deg, #ff0080, #00ffcc, #ff0080);
    background-size: 200% auto;
    background-clip: text;
    -webkit-background-clip: text;
    margin-bottom: 30px;
    font-size: 2.8rem;
    animation: textShine 5s linear infinite;
}

@keyframes textShine {
    0%, 100% {
        background-position: 0% center;
    }
    50% {
        background-position: 100% center;
    }
}

这里使用了几个关键技巧:

  • color: transparent让文字本身透明
  • 创建一个线性渐变背景,包含粉色和青蓝色
  • background-clip: text让背景只显示在文字区域
  • 通过动画改变背景位置,创造出流光效果

2. 按钮基础样式

.btn {
    position: relative;
    width: 240px;
    height: 70px;
    line-height: 70px;
    text-align: center;
    text-decoration: none;
    text-transform: uppercase;
    font-size: 18px;
    font-weight: 600;
    letter-spacing: 1px;
    color: #fff;
    background: rgba(20, 20, 40, 0.8);
    border-radius: 12px;
    z-index: 1;
    transition: all 0.4s ease;
    overflow: hidden;
    box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
}

按钮的基础样式设置了:

  • 相对定位,为后面的伪元素定位做准备
  • 固定的宽度和高度
  • 深色半透明背景
  • 圆角边框
  • 阴影增加立体感
  • 过渡效果,让状态变化更平滑

3. 流光边框效果(核心实现)

.btn::before {
    content: "";
    position: absolute;
    top: -2px;
    left: -2px;
    right: -2px;
    bottom: -2px;
    background: linear-gradient(45deg, #ff0080, #00ffcc, #0066ff, #ff0080);
    background-size: 400% 400%;
    border-radius: 14px;
    z-index: -1;
    opacity: 0;
    transition: opacity 0.4s ease;
    animation: borderGlow 6s ease infinite;
}

@keyframes borderGlow {
    0%, 100% {
        background-position: 0% 50%;
    }
    50% {
        background-position: 100% 50%;
    }
}

这是实现流光边框的关键:

  • 使用::before伪元素创建边框
  • 设置top/left/right/bottom为-2px,让它比按钮本身大一点
  • 创建多彩渐变背景,并设置较大的背景尺寸
  • 初始状态透明度为0,悬停时变为1
  • 通过动画不断改变背景位置,创造出流动效果

4. 内部高光效果

.btn::after {
    content: "";
    position: absolute;
    top: 0;
    left: -100%;
    width: 100%;
    height: 100%;
    background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
    transition: left 0.7s ease;
    z-index: 0;
}

这个效果:

  • 使用::after伪元素创建内部高光
  • 初始位置在按钮左侧外部(left: -100%)
  • 悬停时移动到右侧外部(left: 100%)
  • 创建一个透明-半透明白色-透明的渐变,模拟高光

5. 悬停效果

.btn:hover {
    transform: translateY(-5px);
    box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
}

.btn:hover::before {
    opacity: 1;
}

.btn:hover::after {
    left: 100%;
}

当鼠标悬停时:

  • 按钮向上移动5像素
  • 阴影变大,增强立体感
  • 显示流光边框
  • 触发内部高光动画

完整源码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>流光按钮效果</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        
        body {
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            background: radial-gradient(circle at center, #0f1b33, #000);
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            padding: 20px;
            overflow-x: hidden;
        }
        
        .container {
            text-align: center;
            max-width: 900px;
            width: 100%;
        }
        
        h1 {
            color: transparent;
            background: linear-gradient(90deg, #ff0080, #00ffcc, #ff0080);
            background-size: 200% auto;
            background-clip: text;
            -webkit-background-clip: text;
            margin-bottom: 30px;
            font-size: 2.8rem;
            animation: textShine 5s linear infinite;
        }
        
        .description {
            color: #a0aec0;
            margin-bottom: 50px;
            line-height: 1.6;
            font-size: 1.1rem;
            max-width: 700px;
            margin-left: auto;
            margin-right: auto;
        }
        
        .button-container {
            display: flex;
            flex-wrap: wrap;
            justify-content: center;
            gap: 30px;
            margin-top: 40px;
        }
        
        /* 按钮基础样式 */
        .btn {
            position: relative;
            width: 240px;
            height: 70px;
            line-height: 70px;
            text-align: center;
            text-decoration: none;
            text-transform: uppercase;
            font-size: 18px;
            font-weight: 600;
            letter-spacing: 1px;
            color: #fff;
            background: rgba(20, 20, 40, 0.8);
            border-radius: 12px;
            z-index: 1;
            transition: all 0.4s ease;
            overflow: hidden;
            box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
        }
        
        /* 流光边框效果 */
        .btn::before {
            content: "";
            position: absolute;
            top: -2px;
            left: -2px;
            right: -2px;
            bottom: -2px;
            background: linear-gradient(45deg, #ff0080, #00ffcc, #0066ff, #ff0080);
            background-size: 400% 400%;
            border-radius: 14px;
            z-index: -1;
            opacity: 0;
            transition: opacity 0.4s ease;
            animation: borderGlow 6s ease infinite;
        }
        
        /* 内部流光效果 */
        .btn::after {
            content: "";
            position: absolute;
            top: 0;
            left: -100%;
            width: 100%;
            height: 100%;
            background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
            transition: left 0.7s ease;
            z-index: 0;
        }
        
        .btn:hover {
            transform: translateY(-5px);
            box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
        }
        
        .btn:hover::before {
            opacity: 1;
        }
        
        .btn:hover::after {
            left: 100%;
        }
        
        .btn span {
            position: relative;
            z-index: 2;
        }
        
        /* 不同按钮的颜色变化 */
        .btn-primary::before {
            background: linear-gradient(45deg, #ff0080, #ff3399, #ff0080);
        }
        
        .btn-secondary::before {
            background: linear-gradient(45deg, #00ffcc, #33ffd6, #00ffcc);
        }
        
        .btn-tertiary::before {
            background: linear-gradient(45deg, #0066ff, #3399ff, #0066ff);
        }
        
        @keyframes borderGlow {
            0%, 100% {
                background-position: 0% 50%;
            }
            50% {
                background-position: 100% 50%;
            }
        }
        
        @keyframes textShine {
            0%, 100% {
                background-position: 0% center;
            }
            50% {
                background-position: 100% center;
            }
        }
        
    </style>
</head>
<body>
    <div class="container">
        <h1>这个标题也是有效果的哦~</h1>
        
        <p class="description">
            这是一个完全使用CSS创建的流光效果。按钮具有动态流光边框和内部高光动画,当鼠标悬停时,按钮会上升并显示流动的光效,带来沉浸式的视觉体验。
        </p>
        
        <div class="button-container">
            <a href="#" class="btn btn-primary"><span>开始体验</span></a>
            <a href="#" class="btn btn-secondary"><span>了解更多</span></a>
            <a href="#" class="btn btn-tertiary"><span>立即下载</span></a>
        </div>
        
    </div>
</body>
</html>

总结

1. 伪元素的使用::before::after伪元素让我们可以在不添加额外HTML元素的情况下创建复杂的视觉效果。

2. CSS渐变:线性渐变(linear-gradient)是创建流光效果的核心,通过设置多个颜色停止点创造出丰富的色彩过渡。

3. CSS动画:通过@keyframesanimation属性,我们可以创建平滑的动画效果,而不需要JavaScript。

4. 背景裁剪background-clip: text是一个很有用的属性,可以让背景只显示在文字区域。

5. Z-index层级管理:正确设置z-index确保各个元素按正确的顺序堆叠。

扩展思路

你可以尝试:

  • 改变渐变色创建不同的主题
  • 调整动画速度和方向
  • 添加点击效果

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《这20条SQL优化方案,让你的数据库查询速度提升10倍》

《MySQL 为什么不推荐用雪花ID 和 UUID 做主键?》

《用html写了个超好用的网页主题切换插件》

《SpringBoot3+Vue3实现的数据库文档工具,自动生成Markdown/HTML》

轻松打造个性化Leaflet地图标记

前期准备

在开始自定义标点(marker)功能开发前,我们已经完成了以下基础工作:

  1. 下载leaflet包并引入到项目中;
  2. 创建地图容器,地图实例使用mapRef指示;
<div id="mapRef" ></div>

这里准备工作就简短的介绍一下,前面有相关介绍,感兴趣的宝子可以看看,就不再过多赘述了(其实是我懒得再编辑了。。。)快速上手Leaflet:轻松创建你的第一个交互地图

加载marker数据

在官方文档中可以了解到marker的加载方式,这里我们可以看到其中的经纬度[50.5,30.5]数据是至关重要的,只有这个数据才决定了标记的位置。

L.marker(<LatLng> latlng, <Marker options> options?);
//示例
L.marker([50.5, 30.5]).addTo(mapRef);

可以看到官方给出的基本效果,但是在实际项目中,可能会遇到其他更多的需求,这样基本的标记就显得不够看了:

  • 不同类型的数据可能需要不同的标记样式
  • 选择了某个标记需要使其显得更加突出明显
  • 需要在地图上展示复杂的业务数据
  • 需要实现交互式的标记效果
  • 自定义icon图片 本章的重点就是如何进行自定义标记点样式

自定义icon图片

本章的重点就是如何进行自定义标记点样式 在官方文档中,可以注意到,在marker的经纬度数据之后,还有一个可选择的options,这里我们选择icon的参数进行设定。

var myIcon = L.icon({
    iconUrl: 'my-icon.png',
    iconSize: [38, 95],
    iconAnchor: [22, 94],
    popupAnchor: [-3, -76],
    shadowUrl: 'my-icon-shadow.png',
    shadowSize: [68, 95],
    shadowAnchor: [22, 94]
});
L.marker([50.505, 30.57], {icon: myIcon}).addTo(map);

这里是官方的icon设定,其中的图片可以从阿里巴巴矢量图标库中去任意选择 推荐资源:

完全自定义icon

上面讲解的是只更换图片的方式,有需要的同学可以自行进行测试。 接下来才是我要说的真正的重点, 因为涉及到需要展示的标记数据中可能包含其他需要展示的信息,又或者是图标库中没有自己想要的图片,想要自己用HTML + CSS 设计一个,那么就得使用接下来的方法了。

var myIcon = L.divIcon({className: 'my-div-icon'});
// 你可以在 .my-div-icon CSS 中设置样式

L.marker([50.505, 30.57], {icon: myIcon}).addTo(map);

官方文档中给出了另外一种设计方式,DivIcon代表一个轻量级的标记图标,使用一个简单的 <div> 元素而不是图片。继承自 icon ,但忽略了 iconUrl 和 shadow 选项。 自己设计其中的html参数就可以实现心中的自由!!!

//自定义icon的样式
let myIconHtml = '<div class="myIconCss"></div>'

//自定义icon
let myIcon = L.divIcon({
      className: "",
      html:myIconHtml,
      iconAnchor:[6, 12],//偏移量
})

//应用到标记中
L.marker([50.505, 30.57], {icon: myIcon}).addTo(mapRef);
/* 在Vue/React组件中需要使用深度选择器 */
:deep(.myIconCss) {
  width: 12px;
  height: 12px;
  transform: rotate(45deg);
  cursor: default;
  box-sizing: border-box;
  background: radial-gradient(circle, #ccc, #d3237a, #ccc);
  transition: all 0.1s;
  &:hover {
    transform: rotate(45deg) scale(1.2);
    box-shadow: 0 0 5px 2px #fff;
  }
}

注意!!!!

  1. 在Vue/React等框架中,需要使用:deep(.myIconCss)来深程度地设置样式,否则样式会无效
  2. iconAnchor参数是为了平衡地图不同层级对于标记的偏移,如果不设置这个参数,你自定义的标记会在地图缩放的过程中偏差位置
  3. 偏移量的计算规则:[宽度的一半,高度]。CSS中宽高分别为12像素,所以偏移量设定为[6,12]
  4. 建议同时设置popupAnchor控制弹出框位置

这样,一个完全自定义的标记就生成了。

扩展,带有数字显示的marker

另外,如果需要制作的是带有数字或者字母显示的marker,也是同理,这里我以字体图标库中的图片为底,再在其中显示marker的数字或者字母

实现步骤:

  1. 准备一个中空的标记图标(推荐使用PNG透明背景)
  2. 使用绝对定位将数字显示在图标中央

尽量选择中空的图片,方便突出其中数字的显示。

//自定义icon的样式,这里与之前的有些微的差别
let myIconHtml = '<div class="myIconNumCss" style="background-image: url(${getStaticFilePath("../../../assets/Ear_war_fore/marker_1-1.png")})">'

//自定义icon
let myIcon = L.divIcon({
      className: "",
      html:myIconHtml + '数字' + "</div>",
      iconAnchor: [25, 50],//偏移量
})

//应用到标记中
L.marker([50.505, 30.57], {icon: myIcon}).addTo(mapRef);
// 自定义地图icon
:deep(.myIconNumCss) {
  width: 50px;
  height: 50px;
  background-size: contain;
  background-repeat: no-repeat;
  cursor: default;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  padding-bottom: 14px;
  font-weight: bolder;
  font-size: 14px;
}

这里有一个方法getStaticFilePath,如果图片加载不出来可以使用这个方法

// 获取本地静态资源路径
export function getStaticFilePath(path) {
  const modules = import.meta.glob("/src/assets/**/*.{png,svg,jpg,jpeg,json}", {
    eager: true,
    import: "default",
  });
  path = path.replace(/.*?(/assets/)/, "/src$1");
  return modules[path];
}

最终效果:

这样的处理方式,可以使得在地图缩放过程对这个icon进行转换,来适应不同的业务需求

扩展,凸显标记,动态波纹效果

有时候业务需求中需要加载很多marker,但有时需要对某一个点进行特别显示,比如选中状态或重点标注。这时可以使用CSS动画实现水波纹效果。

  let  point_dynamic = null, //动态点图层实例

// 绘制动态marker(水波纹效果)
  draw_dynamic_point: (latlng) => {
    if (point_dynamic != null) {
      point_dynamic.setLatLng(latlng);
    } else {
      point_dynamic = L.marker(latlng, {
        icon: L.divIcon({
          className: "point_selected",
          html: '<div class="point_dynamic"></div>',
          iconAnchor: [6, 12],
        }),
      }).addTo(mapRef);
    }
  },
};
:deep(.point_dynamic) {
  width: 12px;
  height: 12px;
  border-radius: 50px;
  box-sizing: border-box;
  background: rgb(211, 35, 122);
  animation: dynamic 1s linear infinite;
}
@keyframes dynamic {
  0% {
    transform: scale(1);
  }

  25% {
    transform: scale(2);
    // border-color: rgba(255, 0, 0, 0.9);
    background: rgba(211, 35, 122, 0.9);
  }

  50% {
    transform: scale(3);
    // border-color: rgba(255, 0, 0, 0.75);
    background: rgba(211, 35, 122, 0.75);
  }

  75% {
    transform: scale(4);
    // border-color: rgba(255, 0, 0, 0.5);
    background: rgba(211, 35, 122, 0.5);
  }

  100% {
    transform: scale(5);
    // border-color: rgba(255, 0, 0, 0.1);
    background: rgba(211, 35, 122, 0.1);
  }
}

应用场景:

  1. 地图上标记用户当前位置
  2. 突出显示被选中的POI点
  3. 紧急事件的位置标记
  4. 需要特别关注的重要地点

最终效果:

总结

本文详细介绍了Leaflet中自定义标记的多种方法:

  1. 基础标记实现
  2. 自定义图片标记
  3. 完全自定义HTML+CSS标记
  4. 带数字/文字的标记实现
  5. 动态特效标记(水波纹效果) 每种方法都有其适用场景,开发者可以根据实际项目需求选择最合适的实现方式。

好的,以上是本次分享内容,再次感谢各位前辈的宝贵经验,后续将持续分享更多实践成果。

❌