阅读视图

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

markRaw 包裹引入的组件,是为了什么?

markRaw 的作用

在 Vue 中,markRaw 是一个函数,用于将一个对象标记为 “原始对象”,这意味着 Vue 将不会对该对象进行响应式转换。

使用场景

  1. 当你有一些对象,不希望 Vue 将其变成响应式对象时,可以使用 markRaw。例如,某些第三方库的实例或非常大的数据集,将其变成响应式对象会带来性能开销,此时可以使用 markRaw 来避免这种情况。
  2. 在某些情况下,你可能需要在 Vue 组件中存储一些非响应式的数据,这些数据不会影响视图的渲染,也不需要触发 Vue 的响应式更新机制,那么 markRaw 是一个很好的选择。

示例代码

<template>
  <div>
    <p>{{ rawObj.message }}</p>
    <button @click="updateObj">Update</button>
  </div>
</template>

<script>
import { markRaw, reactive } from 'vue';

export default {
  setup() {
    // 使用 markRaw 标记对象为原始对象
    const rawObj = markRaw({ message: 'Hello, World' });
    const reactiveObj = reactive({ message: 'Hello, Vue' });

    const updateObj = () => {
      // 修改原始对象的属性
      rawObj.message = 'Updated raw message';
      // 修改响应式对象的属性
      reactiveObj.message = 'Updated reactive message';
    };

    return {
      rawObj,
      reactiveObj,
      updateObj
    };
  }
};
</script>

代码解释

  • 首先,我们从 Vue 中导入 markRaw 和 reactive 函数。
  • 在 setup 函数中,使用 markRaw({ message: 'Hello, World' }) 创建了一个原始对象 rawObj
  • 同时,使用 reactive({ message: 'Hello, Vue' }) 创建了一个响应式对象 reactiveObj
  • updateObj 函数用于修改 rawObj 和 reactiveObj 的 message 属性。
  • 当点击按钮调用 updateObj 函数时,会发现修改 reactiveObj 的属性会触发 Vue 的响应式更新,从而更新视图中显示的 reactiveObj.message 的值;而修改 rawObj 的属性不会触发 Vue 的响应式更新,因为它被标记为原始对象,所以视图中显示的 rawObj.message 的值不会自动更新。

注意事项

  • 一旦一个对象被标记为 markRaw,它将始终是非响应式的,不能再通过 Vue 的响应式 API 使其变为响应式。
  • 对于大型数据集合或不需要响应式的对象,使用 markRaw 可以提高性能,但需要谨慎使用,确保不会在需要响应式的地方使用它,以免导致意外的行为。

markRaw 包裹引入的组件

在 Vue 中,将引入的组件用 markRaw 包裹通常是为了避免 Vue 将该组件实例转换为响应式对象。这主要基于以下几个方面的考虑:

性能优化

  • Vue 的响应式系统会对数据对象进行代理,以实现数据的响应式更新。对于组件实例来说,将其变成响应式对象可能会带来额外的性能开销,特别是对于一些复杂的组件。使用 markRaw 包裹组件实例可以避免 Vue 对其进行响应式处理,从而提高性能。

避免不必要的响应式更新

  • 有些组件在使用过程中并不需要响应式更新。例如,某些静态组件,它们的状态不会发生改变,或者它们的状态改变不会影响到父组件或其他相关组件的渲染逻辑。将这样的组件用 markRaw 包裹可以确保 Vue 不会对其内部的状态变化进行监听和响应式处理,避免不必要的渲染和性能消耗。

使用场景示例
假设你有一个自定义组件 MyComponent,你可能会在某些情况下这样使用 markRaw

<template>
  <div>
    <component :is="componentInstance" />
  </div>
</template>

<script>
import { markRaw, ref } from 'vue';
import MyComponent from './MyComponent.vue';

export default {
  setup() {
    // 将组件实例标记为原始对象,避免 Vue 对其进行响应式处理
    const componentInstance = markRaw(MyComponent);
    return {
      componentInstance
    };
  }
};
</script>

代码解释

  • 从 Vue 中导入 markRaw 和 ref 函数,并引入 MyComponent.vue 组件。
  • 在 setup 函数中,使用 markRaw(MyComponent) 将 MyComponent 组件实例标记为原始对象,得到 componentInstance
  • 在模板中使用动态组件 <component :is="componentInstance" /> 来渲染这个组件。
  • 因为 componentInstance 被标记为原始对象,Vue 不会对其进行响应式处理,所以在该组件实例的状态发生变化时,Vue 不会触发额外的响应式更新逻辑,这在一些场景下可以优化性能和避免不必要的更新。

注意事项

  • 要谨慎使用 markRaw 包裹组件,确保组件确实不需要 Vue 的响应式更新,否则可能会导致组件的状态改变无法正确反映在视图上。
  • 对于大多数常规组件,通常不需要使用 markRaw 包裹,因为 Vue 的响应式系统可以很好地处理它们的状态更新和渲染。只有在明确知道组件的状态更新不应该触发响应式更新时,才考虑使用 markRaw 包裹组件。

理解 TypeScript 条件类型与类型推断

在 TypeScript 中,条件类型与类型推断为开发者提供了强大的工具,用于灵活地定义复杂的类型。以下将逐步解析代码:

type TGetMixinMethods<T> = T extends { methods?: infer M } ? M : never;

并详细说明其含义,同时提供可运行的代码示例来帮助更好地理解。

type

在 TypeScript 中,type 关键字用于定义类型别名。类型别名是对一个复杂类型的命名,使其更具可读性和可重用性。

示例:

type StringAlias = string;
const name: StringAlias = `John Doe`;

在上述代码中,StringAliasstring 的别名。

TGetMixinMethods<T>

TGetMixinMethods<T> 是一个类型别名,其定义依赖于泛型 T。泛型允许类型别名在声明时并不具体指定类型,而是在使用时传递具体的类型。

示例:

type Identity<T> = T;
const value: Identity<number> = 42; // T 被推断为 number

T extends { methods?: infer M }

T extends { methods?: infer M } 是条件类型的一部分。以下逐步拆解:

  1. T extends { methods?: infer M }
    • extends:条件类型的判断部分,用于检查 T 是否符合 { methods?: infer M } 的结构。
    • { methods?: infer M }:表示 T 应该是一个包含可选属性 methods 的对象类型。methods 的值可以是任何类型,具体类型由 infer M 捕获。
    • infer M:TypeScript 的类型推断机制,表示如果 T 满足条件,则推断 methods 的类型为 M

示例:

type Example = { methods?: () => void };
type Check = Example extends { methods?: infer M } ? M : never; // 推断 M 为 () => void

在上述代码中,Check 的类型为 () => void,因为 Example 满足条件。

? M : never

? M : never 是条件类型的分支逻辑:

  • 如果 T 符合 { methods?: infer M },则结果类型为 M
  • 如果 T 不符合条件,则结果类型为 never

示例:

type Example1 = { methods?: () => void };
type Example2 = { name: string };
type Result1 = Example1 extends { methods?: infer M } ? M : never; // () => void
type Result2 = Example2 extends { methods?: infer M } ? M : never; // never

完整示例

为了更直观地理解 TGetMixinMethods,以下提供一个完整的例子:

type TGetMixinMethods<T> = T extends { methods?: infer M } ? M : never;

// 示例对象类型
type Component = {
  methods?: {
    sayHello: () => void;
    add: (a: number, b: number) => number;
  };
};

type Methods = TGetMixinMethods<Component>;
// Methods 的类型为:{
//   sayHello: () => void;
//   add: (a: number, b: number) => number;
// }

// 实际使用示例
const componentMethods: Methods = {
  sayHello: () => console.log(`Hello!`),
  add: (a, b) => a + b,
};

componentMethods.sayHello();
console.log(componentMethods.add(2, 3));

在该示例中:

  1. Component 是一个对象类型,包含一个可选的 methods 属性。
  2. TGetMixinMethods<Component> 提取了 methods 的类型,并将其赋值给 Methods
  3. Methods 被用作变量 componentMethods 的类型。

应用场景

TGetMixinMethods 通常用于需要从某些对象类型中提取特定子类型的场景。例如:

  1. 组件库设计:在一个组件库中,组件可能会通过 methods 提供特定的方法集。TGetMixinMethods 可以提取这些方法的类型,用于进一步的类型检查。
  2. 动态模块加载:在动态模块加载场景中,TGetMixinMethods 可以用来从模块定义中提取接口。

源码运行

为了验证代码的正确性,可以将以下代码粘贴到 TypeScript 编译器中运行:

type TGetMixinMethods<T> = T extends { methods?: infer M } ? M : never;

type Component = {
  methods?: {
    sayHello: () => void;
    add: (a: number, b: number) => number;
  };
};

type Methods = TGetMixinMethods<Component>;

const componentMethods: Methods = {
  sayHello: () => console.log(`Hello!`),
  add: (a, b) => a + b,
};

componentMethods.sayHello();
console.log(componentMethods.add(2, 3));

深入理解 TypeScript 中的类型提取语法与实际应用

在 TypeScript 中,类型系统提供了极大的灵活性和强大的表达能力。以下代码片段展示了一种使用高级类型工具实现的类型提取逻辑:

type TExtractValuesOfTuple<T extends any[]> = T[keyof T & number];

要理解这段代码,我们需要逐个分析其中的每个组成部分,并通过示例展示它的功能。


分析代码中的每个部分

type

TypeScript 中的 type 是用于定义类型别名的关键字。它允许我们为复杂类型取一个易于理解的名字,便于复用和维护。例如:

type MyNumber = number;

在当前代码中,TExtractValuesOfTuple 是一个类型别名,描述了如何从元组类型中提取所有可能的值。

TExtractValuesOfTuple

这个是我们定义的类型别名的名称。根据命名约定,这个名称暗示它的用途是从元组类型 T 中提取值。

<T extends any[]>

这里的 <T extends any[]> 是类型参数的声明部分,表示 T 必须是一个数组类型或元组类型。

  • T 是一个泛型参数,可以表示任何符合条件的类型。
  • extends any[] 表示 T 必须是 any[] 类型的子类型,也就是一个数组或元组。

例如:

// 合法的例子
type Example1 = TExtractValuesOfTuple<[number, string]>; // T 是元组类型
type Example2 = TExtractValuesOfTuple<string[]>; // T 是数组类型

// 非法的例子
type Example3 = TExtractValuesOfTuple<number>; // T 不是数组类型,报错
type Example4 = TExtractValuesOfTuple<{ a: string }>; // T 不是数组类型,报错

T[keyof T & number]

这个部分是类型提取的核心逻辑。它表示从类型 T 中取出所有 keyof T & number 对应的值。

keyof T

keyof T 表示获取类型 T 的所有键的联合类型。

  • 对于数组或元组类型,键通常是数字索引(如 0, 1, 2)以及一些额外的字符串键(如 length, push, pop 等)。
  • 例如:
type KeysOfTuple = keyof [string, number, boolean]; // "0" | "1" | "2" | "length" | "push" | "pop" | ...
& number

& 是交集操作符,用于取两个类型的交集。

在这里,keyof T & number 表示仅保留 keyof T 中属于数字的部分。这是因为元组或数组的值是通过数字索引访问的,其他字符串键(如 length, push)并不在我们的关注范围内。

例如:

type NumericKeys = keyof [string, number, boolean] & number; // 0 | 1 | 2
T[keyof T & number]

最终,这部分表示通过 keyof T & number 提取 T 中对应键的值。

  • 对于 [string, number, boolean] 类型,keyof T & number0 | 1 | 2,因此 T[keyof T & number] 的值是 string | number | boolean

示例代码

下面的示例代码展示了如何使用 TExtractValuesOfTuple

type TExtractValuesOfTuple<T extends any[]> = T[keyof T & number];

// 示例 1:简单元组
type Tuple1 = [string, number, boolean];
type Values1 = TExtractValuesOfTuple<Tuple1>; // string | number | boolean

// 示例 2:字符串数组
type StringArray = string[];
type Values2 = TExtractValuesOfTuple<StringArray>; // string

// 示例 3:空元组
type EmptyTuple = [];
type Values3 = TExtractValuesOfTuple<EmptyTuple>; // never

// 示例 4:混合数组
type MixedArray = [number, ...string[]];
type Values4 = TExtractValuesOfTuple<MixedArray>; // number | string

console.log("类型提取示例完成。");

关键点总结

  1. T[keyof T & number] 的作用是从数组或元组类型中提取所有可能的值。
  2. keyof T 包括所有键,但通过交集 & number 限制为数字索引。
  3. 这种类型工具对于操作复杂类型、定义通用逻辑十分有用。

通过对每个组成部分的逐步分析,以及示例的展示,相信你对这一类型提取工具的意义和用途有了更深刻的理解。

Node.js入门:Node.js 事件循环

大家好,我是前端理想哥!

今天是我们Node.js学习的第五节课,这节课我们来聊一聊Node.js 中一个非常重要的概念:事件循环。很多人听过它,但一提到具体原理就有点懵。别担心,今天我带你从代码角度,通俗易懂地理解事件循环的工作原理。

一、什么是事件循环?

事件循环(Event Loop)是 Node.js 能够执行非阻塞 I/O 操作的核心。尽管 Node.js 只有一个主线程,但通过将 I/O 操作交给操作系统的内核处理,Node.js 能够高效地进行并发操作。

举个例子:你可以把事件循环想象成一个老板,老板有很多任务要完成。每当有新的任务出现,老板就会安排合适的员工去做。当员工完成工作后,老板会拿到结果,再继续安排新任务。这样,老板始终能在最短的时间内处理最多的任务。

二、事件循环的6个阶段

因为Node.js底层使用的语言是libuv,一个c++语言,它用来操作底层的操作系统,然后封装了操作系统的接口,Node 的事件循环也是libuv写的,所以Node的生命周期和浏览器的还是有区别的。

因为Node是和操作系统打交道的,所以它的事件循环更加复杂。

Node.js的事件循环有6个主要阶段,执行原理也很简单,这6个阶段,每个阶段都维护了一个事件队列,当到达一个队列后,会检查队列内是否有任务(也就是看下是否有回调函数)需要执行。如果有,就依次执行,直到全部执行完毕、清空队列。

如果没有,那么就会进入到下一个队列继续检查,直到把6个阶段的队列都检查一遍,算是一个轮询。

接下来,让我们逐一了解这些阶段:

1. timers 计时器

这一阶段主要是用来存放计时器的,像 setTimeout、setInterval等的回调函数

举个例子:

setTimeout(() => {
  console.log("这是一个定时器回调");
}, 100);

定时器回调将在指定的时间之后执行, 但由于事件循环的机制,实际的执行时间可能会有所延迟。

2. pending callbacks 等待回调

这个阶段会处理那些在事件循环的其他阶段中未能及时执行的回调。这些回调通常是一些 I/O 操作的结果,比如网络请求失败、文件读取错误等。该阶段的回调并不依赖于定时器,也不属于其他阶段的任务,而是排队等待在此阶段执行。

举个例子:

const fs = require('fs');

// 异步读取文件
fs.readFile('example.txt', (err, data) => {
  if (err) {
    console.error('Error reading file');
  } else {
    console.log('File content:', data.toString());
  }
});

console.log('File reading initiated...');

解释:

  1. fs.readFile 是异步操作,当事件循环进入 poll 阶段时,文件的读取操作会被排入等待执行的队列中。

  2. 如果文件读取完成后,回调函数就会被推送到 pending callbacks 阶段,然后执行该回调输出文件内容。

  3. console.log('File reading initiated...') 是同步代码,优先执行。

执行顺序:

  1. 打印:File reading initiated...

  2. 在 pending callbacks 阶段执行 fs.readFile 的回调,并打印文件内容。

3. idle, prepare

这两个阶段是Node.js内部使用的阶段,在外部应用开发中我们很少接触到,主要是在事件循环的初期做一些准备工作,主要干两件事:

  • 空闲检测(Idle) :检查是否有任何可以执行的操作或者是否进入空闲状态,判断当前事件循环是否需要继续。
  • 内部准备(Prepare) :为后续阶段(尤其是轮询阶段 poll)的事件处理做准备。这个阶段可以看作是事件循环的一个内部“预处理”阶段,它对外部开发者没有直接的影响。

4. poll 轮询

在这个阶段,主要作用是处理 I/O 操作等待新的事件,并决定是否需要进入其他阶段(例如 timers 或 check 阶段)。

具体的流程有四步:

  1. 检索新的 I/O 事件:

poll 阶段会检查是否有新的 I/O 操作完成。例如,网络请求、文件操作、数据库查询等。如果有这些操作完成,它会把相关的回调放入事件队列,并在 poll 阶段执行它们。

  1. 执行已排队的 I/O 回调:

在 poll 阶段,Node.js 会执行所有排队的 I/O 回调,直到队列为空或者达到系统的最大回调数量限制。

  1. 阻塞等待:

如果没有新的 I/O 事件,poll 阶段会阻塞,等待新的事件发生。在此期间,Node.js 会挂起当前线程,直到有事件可以处理。这样避免了 CPU 空转,提高了资源利用效率。

  1. 决定是否跳到 check timers 阶段:

如果没有 I/O 事件可处理,poll 阶段会直接跳到 check 阶段去执行 setImmediate() 调度的回调,或者跳回到 timers 阶段去执行定时器回调。

举个例子:

假设我们有一个 Node.js 程序,监听一个文件变化,并在文件发生变化时触发回调。

const fs = require('fs');

// 使用 fs.watch 监听文件变化
fs.watch('somefile.txt', (eventType, filename) => {
  console.log(`File changed: ${filename}`);
});

console.log('Waiting for file change...');

我们从Node事件循环的角度来分析下这段代码的执行:

    1. Node.js 启动事件循环后,进入 poll 阶段。
    1. fs.watch() 是一个 I/O 操作,它会在底层操作系统等待文件变化的事件。
    1. 当文件发生变化时,操作系统会通知 Node.js 内核,Node.js 会把文件变化事件排入事件队列。
    1. 在 poll 阶段,Node.js 检测到文件变化事件,并执行回调 console.log()。

5. check

这个阶段的作用是执行通过 setImmediate() 调度的回调。这个阶段通常在 poll 阶段之后执行,主要负责执行那些被标记为“立即执行”的回调。

举个例子:

假设你使用 setImmediate() 调度了一个回调函数:

setImmediate(() => {
  console.log('这是setImmediate回调');
});

这个回调函数会在事件循环进入 check 阶段时执行,无论 poll 阶段是否还有事件未处理。setImmediate() 的回调总是会在轮询阶段结束后立即执行。

6. close callbacks

这个阶段用于处理与资源关闭相关的回调,主要是处理如网络连接、文件句柄或其他需要清理和关闭的资源。

举个例子:

const fs = require('fs');

// 打开一个文件流
const stream = fs.createWriteStream('example.txt');

// 写入数据
stream.write('Hello, world!');

// 监听 'close' 事件
stream.on('close', () => {
  console.log('文件已关闭');
});

// 关闭文件流
stream.end();  // 这时会触发 'close' 事件,并执行回调

在这个例子中,当调用 stream.end() 关闭文件流时,'close' 事件会被触发,然后回调函数会在 close callbacks 阶段执行,输出 '文件已关闭'。

三、关键概念:

在Node时间循环中,有几个关键概念:

  1. setImmediate()setTimeout()有什么不同?
  2. process.nextTick() 是什么?为什么要使用 process.nextTick()
  3. process.nextTick()setImmediate()有什么不同?

这些都是关键概念,因为篇幅有限,我们将放在下节视频,一个个给大家讲清楚。

四、总结

Node.js 的事件循环机制让它能够高效地处理 I/O 操作和异步任务。通过合理使用 process.nextTick()、setImmediate() 和 setTimeout(),我们可以精确控制回调的执行时机,避免阻塞主线程。

对于开发者来说,理解事件循环的工作原理非常重要。它不仅能帮助我们优化性能,还能避免一些常见的代码陷阱。

今天的分享就到这里,大家如果有任何问题,欢迎在评论区留言,我们一起讨论!别忘了点赞、收藏、关注哦!下次见!

如果你也对 Node.js 感兴趣,记得关注理想哥,我们一起深入探索。

简单了解vue3双端diff算法,以及为什么使用v-for时必须要给每个元素添加key

前言

众所周知,vue内部使用的是虚拟节点。当组件状态更改时,vue内部会对比老节点和新节点的差异(对比的是虚拟节点),最后根据差异再来操作真实dom节点。这样能尽量减少对真实dom节点的操作,提高性能。下面就来详细分析分析vue是如何对比新节点和老节点的差异的

vue双端对比算法

首先我们需要清楚vue虚拟节点的结构。vue虚拟节点的结构大致如下

{
    type: any,
    props?: any,
    children?: any
    shapeFlag?: any
    el?: any
    key?: any
}

type为虚拟节点类型。若为字符串,则代表真实dom元素的类型。若虚拟节点类型为对象,则type为组件对象,例

type="p"type="div"

type = {
    name: "App",
    setup() { },
    render() {
        return h("div", { tId: 1 }, [
            h("p", {}, "主页"),
        ])
    }
}

props为节点属性。例

props = {
    id=1
    class="root"
}

children为节点子节点,可以为节点数组,也可以为文本字符串。例:

children = [
    {
        "type": "p",
        "props": {},
        "children": "主页",
        "shapeFlag": 5,
        "el": null
    },
    {
        "type": {
            "name": "ArrayToArray"
        },
        "shapeFlag": 2,
        "el": null
    }
]

children = "hello,你好"

shapeFlag用来标识节点类型

export const enum ShapeFlags {
    //节点类型为真实dom元素类型,例:div,p
    ELEMENT = 1,  //0001
    //节点类型为组件
    STATEFUL_COMPONENT = 1 << 1,  //0010
    //孩子为文本字符串
    TEXT_CHILDREN = 1 << 2,  //0100
    //孩子为节点数组
    ARRAY_CHILDREN = 1 << 3,  //1000
    //孩子为插槽数组
    SLOT_CHILDREN = 1 << 4, // 10000
}

el为元素真实挂载的dom节点,通过el可以操作该虚拟节点对应的真实dom节点

双端对比算法主要就是根据虚拟节点的children属性进行操作

左侧遍历时,从左往右看新节点与老节点是否相同,若相同则继续比较下一个,直到遇到不相同的节点停止

右侧遍历时,从右往左看新节点与老节点是否相同,若相同则继续比较下一个,直到遇到不相同的节点停止

双端对比算法的本质是对children数组分别通过从左往右遍历和从右往左遍历,最终锁定中间需要变更的部分来进行更改。

如图所示:

image.png

这里先给出遍历代码

    /**
     * @param c1 老节点数组
     * @param c2 新节点数组
     */
    function patchKeyedChildren(c1: any[], c2: any[], container: any, parentComponent: any) {
        /**左侧指针 */
        let i = 0
        /**老节点数组右侧指针 */
        let e1 = c1.length - 1
        /**新节点数组右侧指针 */
        let e2 = c2.length - 1
        // 左侧遍历
        while (i <= e1 && i <= e2) {
            /**n1为旧节点数组第i个节点 */
            const n1 = c1[i]
            /**n2为新节点数组第i个节点 */
            const n2 = c2[i]
            if (isSomeVNodeType(n1, n2)) {
                /**n1,n2相同则继续递归遍历n1,n2子节点数组差异 */
                patch(n1, n2, container, parentComponent)
            } else {
                /**n1,n2不相同结束遍历 */
                break;
            }
            i++
        }

        //右侧遍历
        while (i <= e1 && i <= e2) {
            /**n1为旧节点数组第e1个节点 */
            const n1 = c1[e1]
            /**n2为新节点数组第e2个节点 */
            const n2 = c2[e2]

            if (isSomeVNodeType(n1, n2)) {
                /**n1,n2相同则继续递归遍历n1,n2子节点数组差异 */
                patch(n1, n2, container, parentComponent)
            } else {
                /**n1,n2不相同结束遍历 */
                break;
            }
            e1--
            e2--
        }
}

了解完遍历后,接下来就是如何处理中间乱序的部分

中间乱序的部分会有以下几种情况:

  1. 老节点序列中有,但是新节点序列中没有,则需要删除该节点。例如上面图片中老节点序列中有 C ,但是新节点序列中没有
  2. 老节点序列中没有,但是新节点序列中有,则需要创建新节点。例如上面图片中老节点序列中没有 K ,但是新节点序列中有
  3. 老节点序列中有,新节点序列中也有,但是节点所处位置不同,则需要移动节点。例如上面图片中老节点序列中的 H 和新节点序列中的 H 的位置不同

那么vue是通过什么来判断新老节点是否相同的呢就是通过key值。这就是为什么我们在使用v-for时必须给每个元素一个key值。

并且vue会给新节点和老节点建立一个映射关系,如下图:

image.png

建立完映射后,vue会先遍历旧节点乱序部分,把没有映射到新节点序列的节点删除(图中的C节点);

然后再遍历新节点乱序部分,把新节点序列中没有映射到老节点序列的节点进行创建(图中的K节点);

至于移动节点就比较麻烦,需要用到最大递增子序列这个概念,这么说可能听不懂,我们看着图里的例子来理解。我们可以发现,D,E节点都是“相对安稳”的,无论其它节点怎么移动,插入,都不会影响D,E节点的相对顺序,那么我们就可以把D,E看成一个“整体”,不需要动,然后移动其它节点即可。例如图中,我们只需要把H插入到D,E的后面即可。而D,E其实就是一个最大递增子序列。

当然,这里说起来轻松,其实代码实现起来很复杂。不过我们了解大概思路就行,至于代码实现我放在文末大家可以自行理解

另外还有一些特殊情况

1. 新节点比老节点多,需要创建

image.png

image.png

如图所示,都是需要创建节点 C

2. 新节点比老节点少,需要删除

image.png

image.png

如图所示,都需要删除节点 C

完整代码:

/**
     * @param c1 老节点数组
     * @param c2 新节点数组
     */
    function patchKeyedChildren(c1: any[], c2: any[], container: any, parentComponent: any) {
        /**左侧指针 */
        let i = 0
        /**老节点数组右侧指针 */
        let e1 = c1.length - 1
        /**新节点数组右侧指针 */
        let e2 = c2.length - 1
        // 左侧遍历
        while (i <= e1 && i <= e2) {
            /**n1为旧节点数组第i个节点 */
            const n1 = c1[i]
            /**n2为新节点数组第i个节点 */
            const n2 = c2[i]
            if (isSomeVNodeType(n1, n2)) {
                /**n1,n2相同则继续递归遍历n1,n2子节点数组差异 */
                patch(n1, n2, container, parentComponent)
            } else {
                /**n1,n2不相同结束遍历 */
                break;
            }
            i++
        }

        //右侧遍历
        while (i <= e1 && i <= e2) {
            /**n1为旧节点数组第e1个节点 */
            const n1 = c1[e1]
            /**n2为新节点数组第e2个节点 */
            const n2 = c2[e2]

            if (isSomeVNodeType(n1, n2)) {
                /**n1,n2相同则继续递归遍历n1,n2子节点数组差异 */
                patch(n1, n2, container, parentComponent)
            } else {
                /**n1,n2不相同结束遍历 */
                break;
            }
            e1--
            e2--
        }

        //新的比老的多需要创建
        if (i > e1 && i <= e2) {
            /**锚点,即新节点该在哪个节点前面插入,若为null则在尾部插入 */
            const anchor = e2 + 1 > c2.length - 1 ? null : c2[e2 + 1].el
            /**遍历创建所有新节点 */
            while (i <= e2) {
                /**新增并挂载新节点 */
                patch(null, c2[i], container, parentComponent, anchor)
                i++
            }
        } else if (i > e2) {
            //老的比新的多需要删除
            while (i <= e1) {
                // 删除节点
                hostRemove(c1[i].el)
                i++
            }
        } else {
            //中间对比

            /**经过左右遍历后,旧节点序列中间差异部分的第一个节点 */
            let s1 = i
            /**经过左右遍历后,新节点序列中间差异部分的第一个节点 */
            let s2 = i
            /**新节点序列中间差异部分节点数量 */
            const toBePatched = e2 - s2 + 1
            /**记录新节点序列中间差异部分已处理数量 */
            let patched = 0
            /**记录新节点序列中间差异部分的节点的key与位置(index)的映射*/
            const keyToNewIndexMap = new Map()
            /**新老节点映射 */
            const newIndexToOldIndexArr = new Array(toBePatched)
            //判断是否需要移动节点
            let moved = false
            //最后一个映射节点的位置
            let maxNewIndexSoFar = 0
            for (let index = 0; index < toBePatched; index++) {
                newIndexToOldIndexArr[index] = 0
            }
            //记录新节点序列中间差异部分的节点的key与位置(index)的映射
            for (let k = s2; k <= e2; k++) {
                /**新节点 */
                const nextChild = c2[k]
                keyToNewIndexMap.set(nextChild.key, k)
            }

            //遍历老节点序列乱序部分
            for (let j = s1; j <= e1; j++) {
                /**老节点 */
                const prevChild = c1[j]
                /**如果新节点差异部分已处理数量已大于等于新节点差异部分总节点数量,说明老节点比新节点多,直接删除老节点就好了 */
                if (patched >= toBePatched) {
                    //删除老节点
                    hostRemove(prevChild.el)
                    continue
                }
                //情况一:老节点序列有的,新节点序列没有的,需要删除节点
                let newIndex: null | number = null
                if (prevChild.key) {
                    newIndex = keyToNewIndexMap.get(prevChild.key) || null
                } else {
                    for (let h = s2; h <= e2; h++) {
                        if (isSomeVNodeType(prevChild, c2[h])) {
                            newIndex = h
                            break;
                        }
                    }
                }
                if (!newIndex) {
                    // 删除节点
                    hostRemove(prevChild.el)
                } else {
                    //当前映射节点位置大于maxNewIndexSoFar,则更新maxNewIndexSoFar,小于则说明需要移动
                    if (newIndex >= maxNewIndexSoFar) {
                        maxNewIndexSoFar = newIndex
                    } else {
                        moved = true
                    }
                    newIndexToOldIndexArr[newIndex - s2] = j + 1
                    //把老节点和新节点相同,能建立映射关系,继续递归遍历子节点数组差异
                    patch(prevChild, c2[newIndex], container, parentComponent)
                    patched++
                }
            }

            //求最长递增子序列。如果前面已经得出无须移动(moved = false),则直接赋空数组
            const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexArr) : []
            let j = increasingNewIndexSequence.length - 1

            //遍历新节点序列乱序部分
            for (let i = toBePatched - 1; i >= 0; i--) {
                const nextIndex = i + s2
                const nextChild = c2[nextIndex]
                const anchor = nextIndex + 1 > c2.length - 1 ? null : c2[nextIndex + 1].el
                if (newIndexToOldIndexArr[i] === 0) {
                    //没有映射的,创建新节点
                    patch(null, nextChild, container, parentComponent, anchor)
                }
                if (moved) {
                    if (j < 0 || i !== increasingNewIndexSequence[j]) {
                        console.log('移动位置');
                        insert(nextChild, container, anchor)
                    } else {
                        j--
                    }
                }


            }
        }
    }

不花一分钱,快速打造专属个性化个人主页

引言

个人博客的价值与重要性

在当今数字化信息爆炸的时代,个人博客不仅是一个表达自我、分享知识和见解的平台,更是建立个人品牌、提升在线影响力的重要工具。对于技术专家、内容创作者以及任何希望在网络空间中占据一席之地的人来说,拥有一个精心维护的个人博客可以显著增强其专业形象。通过持续发布高质量的内容,博主能够吸引志同道合的读者群体,进而形成社区效应,促进思想交流与合作机会。此外,个人博客也是SEO(搜索引擎优化)策略的关键组成部分,有助于提高网站在搜索结果中的可见度,从而增加流量和潜在业务机会。

技术选型:GitLab + Netlify + VitePress 的优势解析 

在构建个人博客的过程中,选择合适的技术栈至关重要。本篇文章将介绍如何利用 GitLab、Netlify 和 VitePress 这三个强大的工具来创建一个高效且成本效益高的静态网站。以下是选择这三者的主要原因:

  • GitLab:作为一款全面的DevOps生命周期管理平台,GitLab提供了从代码托管到CI/CD管道的一站式解决方案。它支持私有仓库,确保内容的安全性,并提供丰富的集成选项,使得开发者可以轻松地将版本控制、自动化测试和部署流程整合在一起。对于寻求高效协作环境和技术债务最小化的团队或个人而言,GitLab是一个理想的选择。 GitLab:作为一款全面的DevOps生命周期管理平台,GitLab提供了从代码托管到CI/CD管道的一站式解决方案。它支持私有仓库,确保内容的安全性,并提供丰富的集成选项,使得开发者可以轻松地将版本控制、自动化测试和部署流程整合在一起。对于寻求高效协作环境和技术债务最小化的团队或个人而言,GitLab是一个理想的选择。 
  • Netlify:专注于现代Web应用程序的快速部署与优化,Netlify以其简便易用的界面和卓越的性能著称。它不仅简化了前端应用的发布过程,还提供了内置的DNS管理、SSL证书自动配置以及全球分布式的CDN服务,确保用户能够享受到高速加载体验的同时降低延迟。此外,Netlify的形式化函数(Netlify Functions)允许开发者在无服务器环境中执行后端逻辑,进一步扩展了博客的功能性。 
  • VitePress:基于Vite构建的轻量级静态站点生成器,VitePress结合了Vue.js的强大组件系统和Vite的闪电般快速的开发服务器特性。它特别适合文档类网站的创建,具备热模块替换(HMR)、按需加载等先进功能,极大地提升了开发效率。VitePress还拥有简洁直观的主题定制能力,帮助用户快速搭建出美观大方且响应迅速的博客页面。 

综上所述,GitLab、Netlify 和 VitePress 的组合为个人博客的建设提供了一个强大而灵活的技术基础,既能满足开发者对性能和可扩展性的需求,又兼顾了用户体验和视觉设计的要求。接下来,我们将深入探讨具体的实现步骤。

第一部分:准备阶段 

一、使用到的工具

  • GitLab :GitLab 是一个集成的DevOps平台,提供从代码托管到CI/CD管道的一站式服务。它支持私有仓库和开源项目,并提供强大的自动化构建、测试和部署功能,使开发者能够高效协作并确保代码质量。
  • Netlify: Netlify 提供了一键部署静态网站的能力,并通过全球分布式的CDN网络优化了加载速度。它还自动配置SSL证书,确保安全的HTTPS连接,并支持无服务器函数(Serverless Functions)以扩展应用逻辑。
  • VitePress: VitePress 是一款基于Vite和Vue.js的静态站点生成器,专为文档类网站设计。它具有快速冷启动时间、热更新功能以及简单的配置方式,非常适合创建响应迅速且易于维护的博客。

二、准备工作 

1.注册并设置GitLab账号

访问 gitlab.com/ GitLab官网注册账户

image.png

2.创建Netlify账号

访问 netlify 使用 github 或者 gitlab 授权登录即可。

image.png

3.安装 VitePress

访问 vitepress 官网查看安装教程,这里不多做讲解。

第二部分:创建博客项目

1.初始化gitlab仓库

准备工作 时,我们已经注册了gitalb账户,现在登录进去后,创建一个新的仓库用于存放代码。

image.png

2.初始化vitepress

把刚刚创建的gitlab仓库 clone 到本地,然后cd进入目录后,运行

npx vitepress init

根据 官方文档 完成操作后,项目里机会出现了一些文件。进入到 vitepress的 config.mts。如果你们使用的是js版本则是 config.js

注意更改 baseoutDir 的路径,后续配置 netlify 的时候有影响。

image.png

运行 npm run docs:build 之后,根目录下就多了一个 home 目录,打包好的html文件就全在里面。

image.png

最后 git push 把这些更改推送到gitlab仓库里。

第三部分:部署至Netlify

链接GitLab仓库到Netlify

image.png

选择gitlab

image.png

选择 刚刚 创建的仓库

image.png

image.png

点击 deploy

image.png

等待部署完成就可以访问了。

image.png

完成部署,访问网站

注意,之前我们初始化 vitepress 的时候,更改了 config 中的 outDir 和 base。所以此时我们访问网站的时候,后面加一个 /home/

这是我生成的最终网站 wxfly.netlify.app/home/

什么是双键缓存?我们必须了解的浏览器缓存新规则!

Hello,大家好,我是 Sunday。

昨天有位 训练营的同学 问我:“Sunday 老师,为什么我的静态资源明明缓存了,但换个站点访问,又得重新下载?”

这个本质上就是因为 双键缓存(Double-keyed Caching) 导致的。

所以,咱们今天就来聊聊 双键缓存是什么,它是如何工作的,以及我们应该如何优化?

什么是双键缓存?

传统的浏览器缓存 中,资源的缓存通常是 基于 URL 进行存储的

比如,当我们访问 https://cdn.sunday.com/script.js 时,那么浏览器会缓存这个 script.js,当其他站点也引用这个 URL 时,浏览器直接复用缓存,不需要重新下载

这种传统的缓存方式,就是开始同学所说的:资源一旦缓存,任何站点都可以访问

但这样做有一个巨大的安全隐患——跨站点追踪(Cross-site Tracking)数据泄露风险

例如:

  • 某些网站可以通过检查公共 CDN 资源的缓存状态,来推测用户是否访问过某些网站(比如:广告追踪)。
  • 黑客可以利用缓存投毒(Cache Poisoning)攻击,让用户加载被污染的资源。

为了避免这些安全问题,很多浏览器(比如:Chrome、Firefox)引入了双键缓存机制。

双键缓存的核心规则是: 缓存资源时,不仅考虑 URL,还要考虑 资源是在哪个站点加载的(Origin),也就是 “站点 + URL” 作为缓存的唯一标识

换句话说:

  • 以前 A 站 缓存的资源,B 站 可以直接复用 ✅
  • 现在 A 站 缓存的资源,B 站 需要重新下载 ❌

双键缓存是如何工作的?

双键缓存 = 站点(Origin)+ 资源 URL

让我们用一个例子来理解:

假设你访问了 网页 A网页 A,它们都使用相同的 CDN 资源 https://cdn.sunday.com/script.js

  • 传统缓存(单键缓存)

    • 你在 网页 A 加载 script.js,浏览器缓存该文件。
    • 你访问 网页 B,浏览器发现它请求相同的 script.js,于是直接从缓存中加载 (减少了网络请求,提高了加载速度)
  • 双键缓存

    • 你在 网页 A 加载 script.js,浏览器缓存它,并标记为 “仅供 网页 A 使用”
    • 你访问 网页 B,即使请求相同的 script.js,浏览器也会认为它是 一个全新的资源,需要重新下载

不同的站点,即使请求相同的资源,仍然需要分别缓存!

这种方式提升了安全性,但是也会带来最初那位同学的疑惑,就是:资源无法跨站点共享,必须要重复下载了。

所以说:双键缓存虽然带来的“一定的”安全性,但是也带来了不少的问题,比如:

  • 缓存复用率降低:即使是相同的资源,不同站点仍然需要重新下载
  • 公共 CDN 失去部分优势:以往我们使用 CDN(如 jsDelivr、UNPKG)是为了让多个站点共用缓存,现在效果大大降低。
  • 首次访问成本上升:用户访问某个站点时,即使本地已经缓存了相同的资源,仍然需要重新下载,导致页面首次加载变慢。

如何优化双键缓存影响?

在上面,咱们已经大致了解了双键缓存的原理以及可能会带来的一些影响了,所以最后咱们就来看看如何尽可能的优化这些问题:

1. 利用 Service Worker

Service Worker 可以在客户端拦截请求,并利用 本地缓存 来减少对网络请求的依赖。

例如,我们可以使用 Cache API 将某些资源手动缓存下来,而不受双键缓存的限制:

self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      return response || fetch(event.request);
    })
  );
});

Service Worker 的缓存存储 不受双键缓存的影响,因此对于高频使用的静态资源,可以考虑让 Service Worker 进行管理,而不是完全依赖 HTTP 缓存。

2. 使用 HTTP/3,减少重复请求开销

由于双键缓存的影响,即使同一个用户访问不同网站,公共 CDN 资源也可能 多次下载

但是,如果通过 HTTP/3(QUIC)协议 通过 多路复用0-RTT 连接,可以优化对应的性能问题。

PS:如何检查你的 CDN 是否支持 HTTP/3?

可以在 Chrome DevToolsNetwork 面板中,查看 Protocol 列,如果显示 h3,说明该资源使用了 HTTP/3 进行传输。

3. 预加载关键资源

既然不能完全依赖浏览器缓存,我们可以主动 预加载关键资源

例如,使用 <link rel="preload"> 来预加载字体、脚本或 CSS:

<link rel="preload" href="https://你的 cdn 地址/fonts/Roboto.woff2" as="font" type="font/woff2" crossorigin="anonymous">

这样可以确保关键资源即使因为双键缓存机制需要重新下载,也能更快地完成加载。

前端训练营:1v1 私教,9 大服务,终身辅导,帮你拿到满意的 offer 已帮助数百位同学拿到了中大厂 offer

Mousetrap:打造高效键盘快捷键体验的JavaScript库

Mousetrap:打造高效键盘快捷键体验的JavaScript库

前言

在当今快节奏的数字世界中,用户对Web应用的交互效率提出了更高的要求。

键盘快捷键作为一种提升操作便捷性和速度的有效手段,被广泛应用于各种应用中。

然而,实现一套稳定且兼容性强的键盘快捷键系统并非易事,这涉及到复杂的按键事件监听、浏览器差异处理等诸多问题。

Mousetrap,这个轻量级的JavaScript库,以其简洁的API和强大的功能,为开发者提供了一种优雅的解决方案,让键盘快捷键的实现变得轻而易举。

介绍

Mousetrap是一个专注于键盘快捷键处理的JavaScript库。

它以极小的体积——压缩后仅4.5KB,且无任何外部依赖,为Web应用提供了丰富的快捷键绑定功能。

这意味着开发者可以迅速将其集成到项目中,而不必担心会增加额外的负担或引起依赖冲突。

Mousetrap支持包括IE8+、Firefox、Chrome、Safari在内的多种主流浏览器,并且在移动端也有良好的兼容性,确保了在不同设备上都能提供一致的用户体验。

它不仅支持单个按键的绑定,还能处理复杂的组合按键、按键序列,甚至允许开发者自定义按键事件的类型,如keypress、keydown和keyup。

官网:craig.is/killing/mic…

github:github.com/ccampbell/m…

使用案例

单个按键绑定

假设我们正在开发一个简单的文本编辑器,希望用户能够通过按下'b'键快速加粗选中的文本。

使用Mousetrap,我们可以轻松实现这一功能:

Mousetrap.bind('b', function() {
    console.log('文本加粗');
    // 这里可以添加加粗文本的逻辑
});

组合按键绑定

在许多应用中,组合按键用于执行更复杂的操作。

例如,在一个代码编辑器中,我们可能希望用户通过ctrl+shift+f来格式化代码:

Mousetrap.bind('ctrl+shift+f', function(e) {
    console.log('格式化代码');
    // 阻止浏览器默认行为,避免与浏览器快捷键冲突
    e.preventDefault();
    // 这里可以添加格式化代码的逻辑
});

跨平台组合键绑定

不同操作系统对快捷键的定义有所不同。

Mousetrap通过'mod'关键字,简化了跨平台快捷键的绑定。

例如,无论在Mac还是Windows系统上,用户都可以通过'mod+s'来执行保存操作:

Mousetrap.bind('mod+s', function(e) {
    console.log('保存文档');
    e.preventDefault();
    // 这里可以添加保存文档的逻辑
});

按键序列绑定

在一些特殊的应用场景中,按键序列的绑定可以提供更丰富的交互体验。

例如,在一个游戏应用中,我们可以通过特定的按键序列来触发特殊技能:

Mousetrap.bind('w a s d', function() {
    console.log('触发特殊技能');
    // 这里可以添加触发技能的逻辑
});

阻止默认行为

在某些情况下,我们可能需要阻止按键的默认行为,以避免与浏览器的默认操作冲突。

例如,当用户按下'esc'键时,我们希望执行自定义的退出全屏操作,而不是浏览器的默认行为:

Mousetrap.bind('esc', function(e) {
    console.log('退出全屏');
    e.preventDefault();
    // 这里可以添加退出全屏的逻辑
}, 'keydown');

总结

Mousetrap以其轻量级、易用性和强大的功能,成为了实现键盘快捷键功能的首选库之一。

它不仅支持多种类型的按键绑定,还提供了跨平台的解决方案,极大地简化了开发过程。

通过上述使用案例,我们可以看到Mousetrap在实际开发中的强大作用,无论是简单的文本编辑器还是复杂的游戏应用,都能轻松应对。

如果你的应用需要键盘快捷键功能,Mousetrap无疑是值得尝试的选择。

– 欢迎点赞、关注、转发、收藏【我码玄黄】,各大平台同名

Outlaw挖矿僵尸网络近期活动分析

1          概述

近期,安天CERT监测到多起Outlaw挖矿僵尸网络攻击事件,该挖矿僵尸网络最早于2018年被发现,主要针对云服务器从事挖矿活动,持续活跃。安天CERT在分析近期的攻击事件中发现,该挖矿僵尸网络样本在第三版本基础上有了重要更新,其功能更加多样、隐匿性更高、清除更加困难。主要传播途径和功能依旧是SSH暴力破解攻击目标系统,植入SS

如何优雅地处理第三方网站高清 Logo 显示?

今天正式发布了我的第一个新标签页插件:Next Idea NewTab

og.webp

这是一个为开发者、独立开发者量身打造的新标签页插件,主要功能分3个模块:

  • 精选的优质开源项目,分析项目潜在价值,帮助开发者找到Idea
  • 实时更新的 GitHub Trending,不错过当下热门项目
  • 开发者工具箱,高效便捷

有了这个插件,每次打开浏览器都能查找新 idea,一下子缩短了信息获取路径。

经常用掘金官方插件的朋友都觉得中间的 GitHub Trending 很眼熟。没错,掘金,你被我致敬了!感谢过去5年掘金一直在新标签页为我提供信息🫡

这篇博客的主要目的不是介绍插件,是要讲一个插件里出现的一个 bug。

bug描述

在插件的右侧,是一大批工具的展示,每个工具都需要显示 logo,问题恰恰出现在 logo 的展示上。

logo 最初的展示方案是,手动从第三方网站里找到高清 logo 地址,如果没有高清 logo,则使用第三方服务获取 logo。这个方案在我测试的时候没发现问题,上线后有用户反馈页面上出现 bug:

bug-2.webp

我第一反应就知道和网络稳定性有关。

解决这个 bug 的思路也简单:

  • 找到高清的 Logo
  • 做好回退方案

表演一下AI编程

启动 AI,发送 “我的产品里要展示不同网站的高清 logo,并且要做好回退方案,给我写一个 React 组件”。

AI 就开发分析如何找到高清 logo:

  • 加载 https://${domain}/apple-touch-icon.png
  • 利用 Google、DuckDuckGo 等第三方服务抓取

并且提供了一份组件代码:

import React, { useState } from 'react';

const WebsiteLogo = ({ domain, size = 32, className = "" }) => {
  const [imgSrc, setImgSrc] = useState(`https://${domain}/apple-touch-icon.png`);
  const [fallbackIndex, setFallbackIndex] = useState(0);
  
  const fallbackSources = [
    `https://${domain}/apple-touch-icon.png`,
    `https://${domain}/apple-touch-icon-precomposed.png`,
    `https://www.google.com/s2/favicons?domain=${domain}&sz=64`,
    `https://icons.duckduckgo.com/ip3/${domain}.ico`,
    `https://${domain}/favicon.ico`,
  ];

  const handleError = () => {
    const nextIndex = fallbackIndex + 1;
    if (nextIndex < fallbackSources.length) {
      setFallbackIndex(nextIndex);
      setImgSrc(fallbackSources[nextIndex]);
    }
  };

  return (
    <img 
      src={imgSrc}
      alt={`${domain} logo`}
      width={size}
      height={size}
      onError={handleError}
      className={`inline-block ${className}`}
      style={{ objectFit: 'contain' }}
    />
  );
};

export default WebsiteLogo;

这个组件已经实现了自动处理错误和降级方案,并且可以自定义大小和样式,看起来已经可行了。

但是咱们可是程序员,哪能被 AI 糊弄,继续鞭打,要求在加载状态添加占位符、logo 展示要有过渡效果、提供兜底的回退方案,并且每个回退方案只给1秒加载时间,超出即当作失败,去找下一个回退方案。

这样一份非常健壮的第三方网站 logo 展示的组件就实现好了,代码我放在开源项目 nextjs-15-starter 了,仓库的演示网站也能体验效果。

核心处理方法在这里:

  useEffect(() => {
    let timeoutId

    if (isLoading) {
      timeoutId = setTimeout(() => {
        handleError()
      }, timeout)
    }

    return () => {
      if (timeoutId) {
        clearTimeout(timeoutId)
      }
    }
  }, [imgSrc, isLoading])

  const handleError = () => {
    const nextIndex = fallbackIndex + 1
    if (nextIndex < fallbackSources.length) {
      setFallbackIndex(nextIndex)
      setImgSrc(fallbackSources[nextIndex])
      setIsLoading(true)
    } else {
      setHasError(true)
      setIsLoading(false)
    }
  }

现在组件就完成了如下任务:

  • 多重备选图标源,确保最大程度显示成功
  • 加载状态显示占位符
  • 超时处理机制
  • 优雅的降级显示(使用域名首字母)
  • 可自定义大小和样式

有了这个组件就能轻松解决不同网站的 favicon 格式不一、图标无法加载、加载超时等等痛点,希望同样有 logo 展示需求的朋友用起来!

关于我

🧑‍💻独立开发|⛵️出海|Next.js手艺人
🛠️今年致力于做独立产品和课程

欢迎在以下平台关注我:

Vue模板知识点

前言

Vue的两大特征是模板化、组件化。模板的优点是提高开发效率,按照规定的结果书写代码就能够快速完成页面开发。缺点也很明显,就是固定的模板结构牺牲了一定的灵活性,Vue提供了一系列的API来增加模板的灵活性。

一、动态数据

Mustache

 <template>
   <span>{{msg}}</span>
   <span>{{count > 99 ? 99: count}}</span>
 </template>

Mustache不仅支持数据、还支持method、computed、逻辑运算等。这是因为Vue在解析模板的时候会对Mustache里面的内容进行判断,包装成不同的解析函数,如果是数据直接返回,如果是逻辑运和函数则取运算结果。

computed和watch

思想上,computed注重运算结果,watch注重过程。watch直接进行拦截监听,数据变化时运行一些逻辑,computed则需要先进行依赖收集,对依赖进行监听,当依赖发变化的时候触发重新计算。computed是vue自发的进行依赖的收集监听。并在依赖变化时执行对应的渲染函数触发视图的更新,而watch则可以添加更多开发者自定义的逻辑。

特点:computed自发的,不可以控制,watch灵活可控,wathch有immediate、once、deep等。

<template>
 <div>选择了{{ choose }}件商品</div>
 <div>总价是:{{ totalPrice }}</div>
 <button @click="addChoose">add</button>
</template>

<script>
export default {
 name: "App",
 data() {
   return {
     choose: 0,
     price: 19.9,
   };
 },
 methods: {
   addChoose() {
     this.choose++;
   },
 },
 computed: {
   totalPrice() {
     return this.choose * this.price;
   },
 },
 watch: {
   choose(newVal, oldVal) {
     console.log(`choose从${oldVal}变成了${newVal}`);
   },
 },
};
</script>

watch中使用deep、immediate、once

  watch: {
   goodsInfo: {
     handler(newVal, oldVal) {},
     deep: true, // 深度监听,监听子属性
     immediate: true, // 初次赋值的时候也触发监听回调
     once: true, // 只监听一次
   },
 },

二、 动态结构(slot)

插槽: 父组件定义结构,子组件指定结构的位置。插槽分为三种:

  • 默认插槽
  • 具名插槽
  • 作用域插槽

2.1 默认插槽

// 父组件
<template>
 <HelloWorld>
   <span>这是传递给子组件的默认插槽</span>
 </HelloWorld>
</template>

<script>
import HelloWorld from "./HelloWorld";
export default {
 name: "App",
 components: {
   HelloWorld,
 },
};
</script>

// 子组件
<template>
 <div class="child-component">
   <slot></slot>
 </div>
</template>

<script>
export default {
 name: "HelloWorld",
};
</script>

2.2 具名插槽

默认插槽只能定义一个结构,如果想定义多个结构就要使用具名插槽。其中v-slot:header可以缩写为#header。

// 父组件
<template>
 <HelloWorld>
   <template v-slot:header> <span>header</span> </template>
   <template v-slot:content> <span>header</span> </template>
   <template #footer> <span>header</span> </template>
 </HelloWorld>
</template>

<script>
import HelloWorld from "./HelloWorld";
export default {
 name: "App",
 components: {
   HelloWorld,
 },
};
</script>

// 子组件
<template>
 <div>
   <span>HelloWorld</span>
   <slot name="header"></slot>
   <slot name="content"></slot>
   <slot name="footer"></slot>
 </div>
</template>

<script>
export default {
 name: "HelloWorld",
 data() {
   return {
     user: {
       name: "jack",
       age: 21,
     },
     date: Date.now(),
   };
 },
};
</script>

默认插槽只有一个所以没必要给它起名字,但实际上默认插槽也有自己的名字:default

   <slot></slot>

   // 等同于上面写法
   <slot name="default"></slot>

2.3 作用域插槽

作用域插槽也分为匿名作用域插槽和具名作用域插槽, 作用域插槽就是在默认插槽和具名插槽的基础上增加了“数据”的能力,即在父组件定义的插槽结构中可以使用子组件提供的数据。

  1. 默认作用域插槽
// 父组件
<template>
  <HelloWorld v-slot:default="slotProps">
    {{ slotProps.user.name }}
    {{ slotProps.user.age }}
    {{ slotProps.date }}
  </HelloWorld>
</template>

<script>
import HelloWorld from "./HelloWorld";
export default {
  name: "App",
  components: {
    HelloWorld,
  },
};
</script>

// 子组件
<template>
  <div>
    <slot :user="user" :date="date"></slot>
  </div>
</template>

<script>
export default {
  name: "HelloWorld",
  data() {
    return {
      user: {
        name: "jack",
        age: 21,
      },
      date: Date.now(),
    };
  },
};
</script>

针对默认具名插槽下面的三种书写是等效的:

  <HelloWorld v-slot:default="slotProps">
    {{ slotProps.user.name }}
    {{ slotProps.user.age }}
    {{ slotProps.date }}
  </HelloWorld>

   <HelloWorld v-slot="slotProps">
    {{ slotProps.user.name }}
    {{ slotProps.user.age }}
    {{ slotProps.date }}
  </HelloWorld>

    <HelloWorld #default="slotProps">
    {{ slotProps.user.name }}
    {{ slotProps.user.age }}
    {{ slotProps.date }}
  </HelloWorld>
  1. 具名作用域插槽
// 父组件
<template>
  <HelloWorld>
    <template #user="userProps">
      {{ userProps.user.name }}
      {{ userProps.user.age }}
    </template>
    <template #date="dateProps">
      {{ dateProps.date }}
    </template>
  </HelloWorld>
</template>

<script>
import HelloWorld from "./HelloWorld";
export default {
  name: "App",
  components: {
    HelloWorld,
  },
};
</script>

// 子组件
<template>
  <div>
    <slot name="user" :user="user"></slot>
    <span>content</span>
    <slot name="date" :date="date"></slot>
  </div>
</template>

<script>
export default {
  name: "HelloWorld",
  data() {
    return {
      user: {
        name: "jack",
        age: 21,
      },
      date: Date.now(),
    };
  },
};
</script>

三、模板的编译过程

Vue是如何将 .vue文件渲染成html的?

  1. 对tempalte进行编译,结合依赖收集和作用域插槽,生成渲染函数。
  2. 调用渲染函数,生成虚拟dom。
  3. 将虚拟dom渲染成真实dom。

虽然在vue中可以直接写render函数,但最好还是使用vue模板,因为这更符合vue的模板理念,更重要的是vue在对模板的编译过程中做了很多优化,比如dom diff算法,事件处理,渲染函数缓存等。

使用render函数的写法如下:

<script setup>
import { h } from 'vue';
</script>

<script>
export default {
  name: 'HelloWorld',
  render() {
    return h('div', 'Hello, World!');
  }
};
</script>

JavaScript 数组方法大盘点:从新手到大佬,快速掌握所有必备技能!🎉

前言

大家好!今天我们将继续深入探索 JavaScript 数组的奥秘!你可能以为 pushpop 就是数组操作的全部,但其实数组的世界远不止如此。除了这些基础方法,还有许多其他方法能够让你的数组操作如鱼得水,大幅提高开发效率。今天的目标是帮助你从新手晋升为 JavaScript 数组高手,不仅掌握常用方法,还会揭秘一些鲜为人知的“宝藏”方法。让我们继续这段魔法般的数组之旅吧!🚀


相关方法

1. push()pop()

  • push():将一个或多个元素添加到数组末尾,并返回数组的新长度。
  • pop():从数组末尾删除一个元素,返回被删除的元素。
const fruits = ['apple', 'banana'];
fruits.push('orange'); // 添加橙子
console.log(fruits); // ['apple', 'banana', 'orange']

const lastFruit = fruits.pop(); // 删除最后一个元素
console.log(lastFruit); // 'orange'
console.log(fruits); // ['apple', 'banana']

2. shift()unshift()

  • shift():删除数组中的第一个元素,返回被删除的元素。
  • unshift():将一个或多个元素添加到数组的开头,返回数组的新长度。
const numbers = [1, 2, 3, 4];
numbers.unshift(0); // 在数组开头添加 0
console.log(numbers); // [0, 1, 2, 3, 4]

const firstNumber = numbers.shift(); // 删除第一个元素
console.log(firstNumber); // 0
console.log(numbers); // [1, 2, 3, 4]

3. map()

  • map():创建一个新数组,数组中的每个元素是原数组元素调用指定函数处理后的结果。
let numbers = [1, 2, 3, 4];
let squared = numbers.map((num) => num ** 2); // 数组中每个数字平方
console.log(squared); // [1, 4, 9, 16]

4. filter()

  • filter():创建一个新数组,包含所有通过指定条件函数筛选出的元素,原数组不受影响。
let ages = [12, 18, 25, 30, 15];
let adults = ages.filter((age) => age >= 18); // 筛选出年龄大于等于 18 的人
console.log(adults); // [18, 25, 30]

5. reduce()

  • reduce():对数组中的每个元素执行指定的累加操作,最终返回单一结果(如求和、拼接等)。
let numbers = [1, 2, 3, 4];
let sum = numbers.reduce((acc, num) => acc + num, 0); // 数组求和
console.log(sum); // 10

6. forEach()

  • forEach():对数组的每个元素执行指定的回调函数,不返回结果,常用于遍历。
let colors = ['red', 'green', 'blue'];
colors.forEach(color => console.log(color));  // 打印每个颜色
// 输出:
// red
// green
// blue

7. find()findIndex()

  • find():返回第一个满足条件的元素,如果没有找到,则返回 undefined
  • findIndex():返回第一个满足条件的元素的索引,如果没有找到,则返回 -1。
let users = [{ name: 'Tom', age: 20 }, { name: 'Jerry', age: 25 }];
let user = users.find(user => user.name === 'Tom');  // 查找 Tom
console.log(user);  // { name: 'Tom', age: 20 }

let index = users.findIndex(user => user.name === 'Jerry');  // 查找 Jerry 的索引
console.log(index);  // 1

8. sort()

  • sort():对数组元素进行排序,默认按字符串字典序排列。如果要按数字大小排序,需要提供比较函数。
let nums = [4, 2, 8, 5];
nums.sort((a, b) => a - b);  // 数字升序排序
console.log(nums);  // [2, 4, 5, 8]

9. slice()splice()

  • slice():返回数组的一个新数组,包含指定起始和结束位置之间的元素,不会改变原数组。
  • splice():可以删除或插入数组中的元素,直接改变原数组。
let animals = ['dog', 'cat', 'rabbit', 'bird'];
let selectedAnimals = animals.slice(1, 3);  // 获取从索引 1 到 3 的元素
console.log(selectedAnimals);  // ['cat', 'rabbit']
console.log(animals);  // ['dog', 'cat', 'rabbit', 'bird']

animals.splice(2, 1, 'fish');  // 删除第 2 个元素,插入 'fish'
console.log(animals);  // ['dog', 'cat', 'fish', 'bird']

10. concat()

  • concat():合并两个或多个数组,返回一个新数组。
let array1 = [1, 2];
let array2 = [3, 4];
let combined = array1.concat(array2);  // 合并数组
console.log(combined);  // [1, 2, 3, 4]

11. join()

  • join():将数组中的所有元素连接成一个字符串,元素间可以指定分隔符。
let fruits = ['apple', 'banana', 'cherry'];
let fruitString = fruits.join(' & ');  // 用 '&' 连接数组元素
console.log(fruitString);  // 'apple & banana & cherry'

12. some()every()

  • some():只要有至少一个元素符合条件,返回 true,否则返回 false
  • every():只有所有元素都符合条件,返回 true,否则返回 false
let numbers = [10, 20, 30, 40];
let hasLargeNumber = numbers.some(num => num > 25);  // 判断是否有大于 25 的元素
console.log(hasLargeNumber);  // true

let allLargeNumbers = numbers.every(num => num > 5);  // 判断所有数字是否都大于 5
console.log(allLargeNumbers);  // true

13. from()

  • from():将类数组对象或可迭代对象转换为数组。
let str = 'hello';
let arr = Array.from(str);  // 将字符串转换为数组
console.log(arr);  // ['h', 'e', 'l', 'l', 'o']

14. fill()

  • fill():用指定的值填充数组的指定位置,填充的元素会改变原数组。
let numbers = [1, 2, 3, 4];
numbers.fill(0, 2, 4);  // 从索引 2 到 4 填充为 0
console.log(numbers);  // [1, 2, 0, 0]

15. includes()

  • includes():判断数组中是否包含某个特定的元素,返回布尔值。
let fruits = ['apple', 'banana', 'cherry'];
console.log(fruits.includes('banana'));  // true
console.log(fruits.includes('orange'));  // false

16. reverse()

  • reverse():将数组的元素反转,直接修改原数组。
let numbers = [1, 2, 3, 4];
numbers.reverse();  // 反转数组
console.log(numbers);  // [4, 3, 2, 1]

17. indexOf()lastIndexOf()

  • indexOf() :返回数组中首次出现指定元素的索引,若没有找到,返回 -1
  • lastIndexOf() :返回数组中最后一次出现指定元素的索引,若没有找到,返回 -1
let fruits = ['apple', 'banana', 'cherry', 'banana'];
console.log(fruits.indexOf('banana'));  // 1(返回第一个 banana 的索引)
console.log(fruits.lastIndexOf('banana'));  // 3(返回最后一个 banana 的索引)
console.log(fruits.indexOf('grape'));  // -1(未找到 grape)

18. Array.isArray()

  • Array.isArray():判断传入的值是否是一个数组,返回 truefalse
console.log(Array.isArray([1, 2, 3]));  // true
console.log(Array.isArray('hello'));  // false
console.log(Array.isArray({ name: 'Tom' }));  // false

总结

这些 JavaScript 数组方法就像是数组的超级英雄,掌握它们,你就能让数组操作事半功倍!从基础的增删查改到高级的 mapreduce,以及一些不常用但极具威力的方法如 fromfillincludes,都能帮助你在开发中大显身手。希望今天的分享能够让你在

数组的世界里游刃有余!如果你有其他的数组操作技巧,欢迎留言讨论!🔧

eslint配置文件的名字是eslintrc.cjs,但是有的名字是eslint.config.js

以下是关于 ESLint 配置文件名称不同的解释:

1. 传统的 ESLint 配置文件:.eslintrc.cjs

1.1 历史背景

  • 早期版本

    • 早期的 ESLint 配置文件通常使用 .eslintrc 加上扩展名的形式,如 .eslintrc.json.eslintrc.js 或 .eslintrc.yaml。其中 .eslintrc.cjs 是使用 CommonJS 模块格式的 JavaScript 文件。

    • 使用 .eslintrc.cjs 的原因是在某些项目中,特别是使用 Node.js 环境时,需要使用 CommonJS 模块系统(require 和 module.exports)来配置 ESLint。例如:

收起

javascript

//.eslintrc.cjs
module.exports = {
  "rules": {
    "semi": ["error", "always"],
    "indent": ["error", 2]
  }
};

1.2 特性

  • CommonJS 模块系统

    • 使用 .eslintrc.cjs 可以使用 require 来引入其他模块,适用于 Node.js 项目或需要使用 CommonJS 的情况。例如,如果你需要根据环境或项目的不同动态加载不同的 ESLint 规则,可以使用 require 函数。

2. 新的 ESLint 配置文件:eslint.config.js

2.1 新的配置方式

  • ESLint v8 引入

    • 在 ESLint v8 及以后,引入了 eslint.config.js 作为一种新的配置文件格式。这种配置文件使用 ES 模块(import 和 export)。例如:

收起

javascript

// eslint.config.js
export default [
  {
    "rules": {
      "semi": ["error", "always"],
      "indent": ["error", 2]
    }
  }
];

2.2 优势

  • 使用 ES 模块

    • 对于使用现代 JavaScript 开发,尤其是使用 ES 模块的项目,eslint.config.js 提供了更自然的配置方式,符合现代 JavaScript 的开发习惯。

3. 选择使用哪种配置文件

3.1 项目环境和需求

  • Node.js 项目或 CommonJS 环境

    • 如果你的项目使用 Node.js 或依赖 CommonJS 模块系统,使用 .eslintrc.cjs 可能更方便,因为你可以利用 Node.js 的模块加载机制,方便地引入其他模块和进行动态配置。
  • 现代 JavaScript 项目

    • 对于使用 ES 模块的现代 JavaScript 项目,使用 eslint.config.js 更合适,它与项目的模块系统相匹配,避免了在使用 ES 模块时可能出现的配置文件导入导出问题。

3.2 兼容性和工具支持

  • 工具支持

    • 大多数 ESLint 工具和编辑器插件都支持这两种配置文件,但有些旧的工具可能对 .eslintrc.cjs 支持更好,而有些新的工具可能更倾向于 eslint.config.js

4. 转换和迁移

4.1 从 .eslintrc.cjs 到 eslint.config.js

  • 转换示例

    • 如果你想从 .eslintrc.cjs 转换到 eslint.config.js,可以将配置从 module.exports 转换为使用 export default。例如:

收起

javascript

//.eslintrc.cjs
module.exports = {
  "rules": {
    "semi": ["error", "always"],
    "indent": ["error", 2]
  }
};
  • 可以转换为:

收起

javascript

// eslint.config.js
export default [
  {
    "rules": {
      "semi": ["error", "always"],
      "indent": ["error", 2]
    }
  }
];

4.2 注意事项

  • 配置结构的变化

    • eslint.config.js 的配置结构可能有些不同,它支持配置的扁平化和更多高级特性,在迁移时要注意这些细节。

5. 总结

  • .eslintrc.cjs 是传统的 ESLint 配置文件,使用 CommonJS 模块系统,适用于 Node.js 或 CommonJS 环境。
  • eslint.config.js 是 ESLint v8 引入的新配置文件,使用 ES 模块,适用于现代 JavaScript 开发。
  • 根据项目的模块系统和开发环境,选择合适的配置文件,并注意配置文件的迁移和转换。

在electron中实现一个桌面悬浮球

 概要

在electron + vue3 搭建的应用中实现了一个桌面悬浮球/mini窗口的功能,支持任意拖拽、丝滑的菜单折叠展开动画效果。在实现过程中需要关注的一些点:

1、管理悬浮球窗口创建以及配置:需要一个透明的窗口来承载视图。

2、解决electron拖拽和点击事件冲突(核心):因为使用 -webkit-app-region: drag 样式的方式会导致拖拽和点击事件冲突,所以需要通过渲染进程和主进程的通信来解决窗口位置的更新。

3、初始化组件位置,计算窗口拖动位置:这里需要一些拖拽状态的判断、还有更新位置信息。

4、折叠展开动画和事件处理

最终效果

代码细节实现

首先需要窗口electron窗口来承载vue页面,在窗口管理模块中配置需要的参数,主要是frame、transport、skipTaskbar,然后注入preload中的进程交互事件,来实现渲染进程和主进程的通信。

windowList.set(WINDOW_ROUTE_NAME.MINI_WINDOW, {
  options() {
    return {
      width: 190,
      height: 170,
      frame: false,
      show: true,
      skipTaskbar: true,
      transparent: true,
      resizable: false,
      alwaysOnTop: true,
      webPreferences: {
        preload,
        nodeIntegration: true,
        contextIsolation: true,
      }
    }
  },
  callback(window: any) {
    loadUrl(window, WINDOW_URLS.MINI_WINDOW)
    // 初始化悬浮球位置
    const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
    window.setPosition(screenWidth - window.getSize()[0] -100, screenHeight - window.getSize()[1] - 100)
  }
})

同时还要注册监听事件,来接受渲染进程的唤起动作

  ipcMainService.on("app:show:mini-window", (event, {
    name,
  }) => {
    const miniWindow = windowManager.createWindow(WINDOW_ROUTE_NAME.MINI_WINDOW)
    miniWindow.show()
  })

在页面中触发窗口唤起的动作,发送事件到主进程

const showMiniWindow = (value: boolean) => {
  ipcRenderService.send('app:show:mini-window', value)
}

在vue模板中添加基本的dom结构,注册事件handleMouseDown、handleMouseEnter、handleMouseLeave来实现位置计算、进程通信、折叠展开动画。

<template>
  <div class="mini-window"
       :class="{ 'expanded': isExpanded }"
       @mousedown="handleMouseDown"
       @mouseenter="handleMouseEnter"
       @mouseleave="handleMouseLeave">
    <!-- 折叠状态 -->
    <div class="mini-content">
      <span class="mini-bg"></span>
    </div>
    
    <!-- 展开状态 -->
    <div class="expanded-content" @click.stop>
      <div class="actions">
        <div class="action-item" @click="handleAction('restore')">
          <el-icon><FullScreen /></el-icon>
          <span>还原</span>
        </div>
        <div class="action-item" @click="handleAction('settings')">
          <el-icon><Setting /></el-icon>
          <span>设置</span>
        </div>
        <div class="action-item" @click="handleAction('dashboard')">
          <el-icon><House /></el-icon>
          <span>仪表盘</span>
        </div>
      </div>
    </div>
  </div>
</template>

下面是需要用到的样式

.mini-window {
  position: relative;
  margin-left: 125px;
  margin-top: 109px;
  width: 50px;
  height: 50px;
  border-radius: 25px;
  background: #fff;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
  transition: all 0.3s ease;
  overflow: hidden;
  user-select: none;
  
  &.expanded {
    width: 160px;
    height: 150px;
    border-radius: 12px;
    transform: translate(-110px, -100px);
    
    .mini-content {
      opacity: 0;
      pointer-events: none;
    }
    
    .expanded-content {
      opacity: 1;
      pointer-events: auto;
    }
  }
  
  .mini-content {
    position: absolute;
    bottom: 1px;
    right: 5px;
    opacity: 1;
    transition: opacity 0.3s;
    .mini-bg {
      cursor: pointer;
      display: inline-block;
      background: var(--app-color-gradient-blue);
      width: 40px;
      height: 40px;
      border-radius: 20px;
    }
  }
  
  .expanded-content {
    position: absolute;
    bottom: 0;
    right: 0;
    width: 160px;
    height: 150px;
    opacity: 0;
    padding: 9px 12px;
    pointer-events: none;
    transition: opacity 0.3s;
    
    .actions {
      display: flex;
      flex-direction: column-reverse;
      gap: 8px;
      
      .action-item {
        display: flex;
        align-items: center;
        gap: 12px;
        padding: 10px 12px;
        border-radius: 8px;
        cursor: pointer;
        transition: all 0.2s ease;
        color: var(--ep-color-primary);
        
        .el-icon {
          font-size: 18px;
        }
        
        span {
          font-size: 14px;
        }
        
        &:hover {
          background-color: var(--menu-active-bg-color);
          transform: scale(1.06);
          outline: 1px solid var(--ep-color-primary);
        }
      }
    }
  }
}

鼠标按下事件用来获取窗口初始位置,通过ipcRenderService.invoke和主进程通信获取位置信息,然后注册鼠标移动和鼠标抬起事件。

// 处理鼠标按下事件
const handleMouseDown = (e: MouseEvent) => {
  if (isExpanded.value) return // 展开状态不允许拖动
  
  isDragging = false
  initialMouseX = e.screenX // 使用screenX/screenY获取相对于屏幕的坐标
  initialMouseY = e.screenY
  mouseDownTime = Date.now()
  // 获取窗口初始位置
  ipcRenderService.invoke('app:window:get-position').then(([x, y]: [number, number]) => {
    windowInitialX = x
    windowInitialY = y
    
    document.addEventListener('mousemove', handleMouseMove)
    document.addEventListener('mouseup', handleMouseUp)
  })
}

 鼠标移动时,判断阈值并计算新的位置,然后和主进程通信设置当前的坐标位置。

// 处理鼠标移动事件
const handleMouseMove = (e: MouseEvent) => {
  const deltaX = e.screenX - initialMouseX
  const deltaY = e.screenY - initialMouseY
  
  // 判断是否达到拖动阈值
  if (!isDragging && (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5)) {
    isDragging = true
  }

  if (isDragging) {
    // 计算新位置
    const newX = windowInitialX + deltaX
    const newY = windowInitialY + deltaY
    
    // 发送新位置到主进程
    ipcRenderService.send('app:window:set-position', { x: newX, y: newY })
  }
}

鼠标抬起时需要移除前面注册的鼠标移动和鼠标抬起事件

const handleMouseUp = () => {
  document.removeEventListener('mousemove', handleMouseMove)
  document.removeEventListener('mouseup', handleMouseUp)
  
  // 如果不是拖拽且点击时间小于200ms,则触发展开/收起
  if (!isDragging && (Date.now() - mouseDownTime < 200)) {
    toggleExpand()
  }
}

这样就实现了整个交互的过程,详细讲解可以看这个视频。

electron 实现一个丝滑的桌面悬浮球/mini窗口_哔哩哔哩_bilibili

Node.js系列:事件驱动的核心机制事件循环

❤️ 事件循环:处理异步操作的机制

目的:使Nodejs能够在单线程环境下高效运行

一、事件循环运行机制:依赖libuv库

事件循环机制是基于libuv库(一个多平台的异步I/O的库)构建的;

libuv为Nodejs提供了高效的事件驱动的I/O操作能力,libuv负责在底层进行实际的操作调度,当这些操作完成时,通过事件循环将对应的回调函数在合适的阶段进行调用;事件循环依赖kibuv实现高效的异步操作

比如在定时器管理方面,libuv 提供了精准的定时器机制,让事件循环能够准确地在合适的时间执行定时器回调函数(像setTimeoutsetInterval相关的回调)。

在非阻塞 I/O 操作上,事件循环借助 libuv 可以在等待 I/O 完成的同时处理其他事务,避免了线程的大量阻塞,提高了程序的整体性能。

二、事件循环的6个阶段:每个阶段都对应一个任务队列

当事件循环进入某个阶段时, 将会在该阶段内执行回调,直到队列耗尽或者回调的最大数量已执行, 那么将进入下一个处理阶段

1. 定时器Timers阶段:执行setTimeout 和setInterval的回调函数

当设定的时间到达后,回调函数会被添加到 定时器阶段的任务队列中。(定时任务不一定按照设定的时间执行)

2.I/O回调阶段:主要用于处理各种I/O操作(如文件读取,网络请求等)完成后的回调函数

当一个I/O操作完成后, 其对应的回到函数就会被添加到这个任务队列中; 比如fs.readFile,文件读取完成后的回调函数就会在这个阶段会执行

3.闲置阶段:这是一个内部使用的过渡阶段

  • 主要用于一些内部操作和准备工作,一般开发者很少直接涉及这个阶段的具体操作

4.轮询(Poll)阶段:事件循环的关键,主要有两个功能

  • 等待新I/O事件到来
  • 处理定时器到期后的任务(如果定时器阶段没来得及处理)

如果没有新的I/O事件并且定时器也没有到期任务,这个阶段会阻塞等待

5.检查(check)阶段:主要用于执行setImmediate的回调函数

  • 在当前轮询阶段结束后立即执行

6.关闭事件回调阶段:TPC服务器对象关闭时,对应的关闭回调函数

  • 例如关闭一个服务器套接字段后,用于清理资源等的关闭回调函数会在这个阶段被调用;
    • 如:socket.on('close', ...)

三、任务队列和执行顺序

微任务:

  • process.nextTick: 会在当前操作完成后立即执行,在微任务之前执行
  • promise.then
  • queueMicrotask():是标准的微任务

宏任务:

  • setTimeout、setInterval
  • IO事件
  • 检查阶段的setImmediate
  • 关闭事件

执行顺序:

  • nextTick microtask queue
  • other microtask queue
  • timer queue
  • poll queue
  • check queue
  • close queue

微任务会在当前执行栈为空的时候立即执行,宏任务会根据事件循环的阶段顺序来执行

其他:

queueMicrotask 与 process.nextTick 的区别?

  • process.nextTick 会在当前操作完成后立即执行,甚至在事件循环的下一个阶段开始之前,而且在微任务之前执行。
  • queueMicrotask 是标准的微任务,会在当前事件循环的微任务队列中等待,在当前执行上下文的同步代码和 process.nextTick 之后,但在宏任务之前执行。

setTimeoutsetImmediate的输出顺序

  • 遇到setTimeout,虽然设置的是0毫秒触发,但实际上会被强制改成1ms,时间到了然后塞入times阶段;
  • 先进入times阶段,检查当前时间过去了1毫秒没有,如果过了1毫秒,满足setTimeout条件,执行回调,如果没过1毫秒,跳过
  • 跳过空的阶段,进入check阶段,执行setImmediate回调

这里的关键在于这1ms,如果同步代码执行时间较长,进入Event Loop的时候1毫秒已经过了,setTimeout先执行,如果1毫秒还没到,就先执行了setImmediate

【虚拟列表·终章】不定高度+动态图片加载,十万条数据流畅渲染全攻略!

大家好,我是 前端大卫

虚拟列表-示例.gif

在线 Demo 地址: codesandbox.io/p/devbox/ad…

今天是 虚拟列表 系列的终章,我将带大家深入探讨 不定高度列表项 的处理方式。如果你还没有看过前两篇内容,可以先点击下面的链接:

解决方案的优势

相较于市面上其他虚拟列表实现,我的这个方案具备以下优势:

  1. 高效索引查找
    根据滚动方向精准定位起始和结束索引,显著提升性能。
  2. 创新高度调整机制
    无需依赖传统二分查找算法,而是通过记录高度调整值,保证列表项的连续性。
  3. 动态监听高度变化
    利用 ResizeObserver 实时监听可视区域和整体列表高度的变化,确保数据准确。

如果你的项目不支持 ResizeObserver,欢迎在评论区留言,我会单独出一篇文章讲解如何用其他技术解决监听问题。

接下来,我会通过实例,从 简单到复杂 手把手讲解解决方案,并分享一些重要注意事项。

核心实现步骤

1. 初始化数据结构

假设以下场景:

  • 每项预估高度为 100
  • 可视区域高度为 450

初始数据结构如下:

[
  { "top": 0, "bottom": 100, "height": 100 },
  { "top": 100, "bottom": 200, "height": 100 },
  { "top": 200, "bottom": 300, "height": 100 },
  ...
]

预估高度.png

可以发现:

  • 每项的 top 值为前一项的 bottom 值。
  • 每项的 bottom 值为自身 top + height

2. 精确查找索引

根据滚动高度,确定起始和结束索引的公式为:

if (scrollTop >= item.top && scrollTop <= item.bottom) {
   // 找到对应索引
}

注意:
列表项之间必须保持连续,否则会出现无法匹配的情况。例如,如果滚动高度为 140,而列表项如下:

[
  { "top": 0, "bottom": 100 },
  { "top": 200, "bottom": 300 },
  { "top": 300, "bottom": 400 }
]

此时无法找到对应的索引,导致无法渲染虚拟列表。

3. 预估高度的作用

预估高度用于初始渲染,后续会根据实际高度进行调整。例如:
滚动高度为 0,起始索引为 0,结束索引为 4,渲染如下:

预估高度.png

4. 高度修正

渲染完成后,各列表项的实际高度可能不同。通过 ResizeObserver,我们可以动态监听每项高度并修正:

  • 如果列表项 高度较低:结束索引会增加,例如从 4 变为 7

高度较低修正.png

  • 如果列表项 高度较高:结束索引会减少,例如从 4 变为 3

高度较高修正.png

5. 确保列表项连续性

这是实现的核心难点。未渲染的列表项需要与已渲染项保持连续,以下分两种情况讨论:

情况 1:高度连续

如果下一项的 top 大于上一项的 bottom,简单赋值即可:

if (nextItem.top >= lastItem.bottom) {
  nextItem.top = lastItem.bottom;
}

高度连续.png

情况 2:高度不连续

如果下一项的 top 小于上一项的 bottom,需要记录调整值:

const heightAdjustment = lastItem.bottom - nextItem.top;

高度不连续.png

无需逐项更新所有列表项,只需在需要时应用调整值即可。

6. 滚动方向的优化查找

根据 newScrollTopprevScrollTop,判断滚动方向:

  • 向下滚动:新起始索引在旧索引下方。
  • 向上滚动:新起始索引在旧索引上方。

利用方向判断,比二分查找效率更高。

7. 动态调整后的处理

当某项被删除或高度变化时,确保页面流畅性:

  • 利用 uid 唯一标识复用旧列表项的数据。
  • 根据规则修正:
    • top = previous.bottom
    • bottom = top + height

结语

代码细节可以查看我的 GitHub 项目,希望大家点个 ⭐ 支持!

GitHub 源码地址:
github.com/feutopia/fe…

如果你对虚拟列表有其他问题或建议,欢迎留言讨论!

最后

点赞👍 + 关注➕ + 收藏❤️ = 学会了🎉。

更多优质内容关注公众号,@前端大卫。

前端性能优化中的技能CD和回城

🚀 前端性能优化必备技巧:深入理解防抖与节流

📚 前言

在前端开发中,性能优化是一个永恒的主题。当我们处理高频触发事件时,如果不进行适当处理,可能会导致以下问题:

  • 🔥 频繁触发事件导致性能下降
  • 💾 不必要的服务器请求
  • 🖥️ 页面卡顿
  • ⚡ 资源浪费

🎯 常见的高频触发场景

  1. 搜索框实时搜索 🔍

    • 用户输入时频繁发起请求
    • 每次按键都触发搜索
  2. 窗口调整事件 📱

    • resize 事件频繁触发
    • 需要重新计算布局
  3. 滚动事件处理 📜

    • scroll 事件持续触发
    • 可能影响页面性能
  4. 按钮提交事件 🖱️

    • 用户重复点击提交
    • 可能导致重复请求

🛡️ 防抖(Debounce)

🎮 生动的游戏类比

想象英雄联盟中的回城机制:

  • 按 B 开始回城,等待 8 秒
  • 受到伤害立即打断,重新计时
  • 必须完整等待才能回城成功

💻 实际应用案例

  1. 搜索建议功能
// 实现搜索框防抖
const searchInput = document.querySelector('#search');
const debouncedSearch = debounce(async (query) => {
  const results = await fetchSearchResults(query);
  updateSearchSuggestions(results);
}, 300);

searchInput.addEventListener('input', (e) => {
  debouncedSearch(e.target.value);
});
  1. 表单验证
// 实现实时表单验证
const emailInput = document.querySelector('#email');
const debouncedValidate = debounce(async (email) => {
  const isValid = await validateEmail(email);
  updateValidationUI(isValid);
}, 500);

emailInput.addEventListener('input', (e) => {
  debouncedValidate(e.target.value);
});

⚡ 节流(Throttle)

🎮 游戏类比

类似英雄联盟技能冷却:

  • 释放技能进入冷却时间
  • 冷却期间无法再次释放
  • 冷却结束才能再次使用

💻 实际应用案例

  1. 无限滚动加载
// 实现滚动加载
const container = document.querySelector('#infinite-list');
const throttledLoad = throttle(async () => {
  if (isNearBottom()) {
    const newItems = await fetchMoreItems();
    appendItems(newItems);
  }
}, 200);

window.addEventListener('scroll', throttledLoad);
  1. 数据统计上报
// 实现用户行为统计
const tracker = throttle((event) => {
  sendAnalytics({
    type: event.type,
    timestamp: Date.now(),
    data: event.data
  });
}, 1000);

document.addEventListener('mousemove', tracker);

🔄 如何选择防抖还是节流?

选择防抖的场景 🛡️

  • ✅ 搜索框输入查询
  • ✅ 表单实时验证
  • ✅ 调整窗口大小
  • ✅ 用户输入校验

选择节流的场景 ⚡

  • ✅ 页面滚动处理
  • ✅ 数据统计上报
  • ✅ 游戏中的按键处理
  • ✅ 射击类游戏的武器发射

💡 性能优化建议

  1. 延迟时间设置 ⏱️

    • 搜索框:300-500ms
    • 表单验证:400-600ms
    • 滚动处理:150-300ms
    • 统计上报:1000-2000ms
  2. 代码优化 📈

    • 使用闭包保存状态
    • 注意内存泄漏
    • 及时清除定时器
    • 考虑是否需要立即执行

📝 总结

选择合适的方案:

  • 防抖:关注最终结果
  • 节流:关注执行频率

🔗 完整实现代码

防抖实现:

// debounce.js
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <input style="width: 80%; height: 30px" type="text" id="id" />
    <script>
      let timer = null;
      function debounce(func, delay, immediate = false) {
        return (...args) => {
          //清除定时器  如果注释的话还是那么多频次,只是每次推迟了
          if (timer) {
            clearTimeout(timer);
          }
          if (!timer && immediate) {
            func(...args);
          }
          timer = setTimeout(() => {
            func(...args);
          }, delay);
        };
      }

      function pureFn(...params) {
        console.log('%c执行成功params:', 'color: red; font-size: 14px;', params);
      }

      const dom = document.getElementById('id');
      const debounceFn = debounce(pureFn, 500, true);
      dom.addEventListener('input', e => {
        const value = e.target.value;
        debounceFn(value, Date.now());
      });
    </script>
  </body>
</html>

节流实现:

// throttle.js
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <input style="width: 80%; height: 30px" type="text" id="id" />
    <script>
      const throttle = (func, delay) => {
        let last = 0;
        let deferTimer = null;
        return args => {
          // 干掉触发
          let now = Date.now();
          if (last && now < last + delay) {
            clearTimeout(deferTimer);
            deferTimer = setTimeout(() => {
              last = now;
              func(args);
            }, delay);
          } else {
            last = now; // 第一次时间
            func(args); // 先执行一次
          }
        };
      };

      function pureFn(...params) {
        console.log('%c执行成功params:', 'color: red; font-size: 14px;', params);
      }

      const dom = document.getElementById('id');
      const throttleFn = throttle(pureFn, 1000, false);
      dom.addEventListener('input', e => {
        const value = e.target.value;
        throttleFn(value, Date.now());
      });
    </script>
  </body>
</html>

🌟 如果这篇文章对你有帮助,欢迎点赞、收藏和评论!

📢 关注我,一起探讨前端技术!

❌