阅读视图

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

# Vue 中 provide/inject 与 props/emit 的对比与选择

一、核心设计理念差异

1. 数据流向的明确性

props/emit‌ 遵循严格的单向数据流:

// 父组件
<child-component :message="parentMsg" @update="handleUpdate" />

// 子组件
export default {
  props: ['message'],
  methods: {
    sendToParent() {
      this.$emit('update', newValue) // 明确的数据流向
    }
  }
}

provide/inject‌ 则是隐式的跨层级通信:

// 祖先组件
provide('sharedData', reactive({ value: null }))

// 任意后代组件
const data = inject('sharedData')
data.value = 123 // 来源不直观

二、必须使用 props/emit 的场景

1. 可复用组件开发

组件库中的按钮组件‌:

// 使用props定义明确接口
export default {
  props: {
    type: {
      type: String,
      default: 'default',
      validator: val => ['default', 'primary', 'danger'].includes(val)
    },
    disabled: Boolean
  },
  emits: ['click'], // 显式声明事件
  template: `
    <button 
      :class="['btn', `btn-${type}`]"
      :disabled="disabled"
      @click="$emit('click', $event)"
    >
      <slot></slot>
    </button>
  `
}

2. 父子组件明确契约

表单验证场景‌:

// 父组件
<validated-input
  :rules="[v => !!v || '必填项']"
  @valid="isFormValid = $event"
/>

// 子组件
export default {
  props: ['rules'],
  emits: ['valid'],
  watch: {
    inputValue() {
      const isValid = this.rules.every(rule => rule(this.inputValue))
      this.$emit('valid', isValid) // 明确的状态反馈
    }
  }
}

三、provide/inject 的适用边界

1. 适合使用 provide/inject 的场景

跨多层组件共享配置‌:

// 主题提供者组件
provide('theme', {
  colors: {
    primary: '#409EFF',
    danger: '#F56C6C'
  },
  darkMode: false
})

// 深层嵌套的按钮组件
const theme = inject('theme')
const buttonColor = computed(() => 
  theme.darkMode ? theme.colors.primary : '#333'
)

2. 不适合使用 provide/inject 的情况

列表项与父组件通信‌:

// 错误示范:使用inject修改父级状态
inject('parentMethods').updateItem(item) // 破坏组件独立性

// 正确做法:通过props/emit
props: ['item'],
emits: ['update'],
methods: {
  handleUpdate() {
    this.$emit('update', newItem) // 保持接口明确
  }
}

四、关键对比维度

维度 props/emit provide/inject
组件耦合度 低(明确接口) 高(隐式依赖)
可维护性 容易追踪数据流 调试困难
类型安全 支持完整类型定义 JavaScript中难以类型检查
适用层级 父子/直接关联组件 任意层级组件
测试便利性 可单独测试输入输出 需要构建完整上下文
代码可读性 接口清晰可见 需要查找provide源头

五、实际项目中的混合使用

1. 组合式API最佳实践

// 组件定义
export default {
  props: {
    // 必须的输入
    modelValue: { type: String }
  },
  emits: ['update:modelValue'],
  setup(props, { emit }) {
    // 注入应用级配置
    const appConfig = inject('appConfig')
    
    const handleInput = (e) => {
      // 本地事件处理
      emit('update:modelValue', e.target.value)
      
      // 同时使用注入的方法
      appConfig.trackInput?.(e.target.value)
    }
    
    return { handleInput }
  }
}

2. 设计模式选择指南

graph TD
    A[组件通信需求] --> B{通信方向}
    B -->|父→子| C[props]
    B -->|子→父| D[emit]
    B -->|兄弟组件| E[状态提升/全局状态]
    A --> F{层级深度}
    F -->|1-2层| G[优先props/emit]
    F -->|3+层| H[考虑provide]
    A --> I{复用性要求}
    I -->|高复用组件| J[必须用props/emit]
    I -->|内部实现细节| K[可用provide]

六、典型误用案例分析

1. 滥用 provide 导致的状态混乱

// 问题代码:多个组件通过inject修改同一状态
provide('globalState', reactive({ count: 0 }))

// 组件A
inject('globalState').count++

// 组件B
inject('globalState').count *= 2
// 无法追踪修改来源,调试困难

2. 应该使用 props 的场景

// 错误示范:用inject代替props
provide('userAvatar', avatarUrl)

// 正确做法:头像组件应该通过props接收数据
export default {
  props: {
    avatarUrl: String // 明确接口
  }
}

七、工程化考量

1. 项目可维护性影响

  • props/emit‌ 使组件成为"黑盒",通过接口文档即可理解功能
  • provide/inject‌ 需要查看组件实现才能理解依赖关系

2. 团队协作规范

// 良好的组件接口设计
export default {
  props: {
    // 带验证的props
    size: {
      type: String,
      default: 'medium',
      validator: s => ['small', 'medium', 'large'].includes(s)
    }
  },
  emits: {
    // 带验证的emit
    'size-change': payload => typeof payload === 'string'
  }
}

总结来说,props/emit 提供了组件间明确、可预测的通信方式,是构建可维护、可复用组件的基础;而 provide/inject 是特定场景下的补充方案,适用于真正需要穿透多层级的上下文共享场景。


Vue 中 provide/inject 与传统状态管理的深度对比

一、provide/inject 基础原理

1. 基本用法

// 祖先组件提供数据
export default {
  provide() {
    return {
      theme: 'dark',
      toggleTheme: this.toggleTheme
    }
  },
  methods: {
    toggleTheme() {
      this.theme = this.theme === 'dark' ? 'light' : 'dark'
    }
  }
}

// 后代组件注入使用
export default {
  inject: ['theme', 'toggleTheme'],
  template: `
    <button @click="toggleTheme">
      当前主题: {{ theme }}
    </button>
  `
}

2. 响应式数据传递

// 使用 Vue 3 的 reactive/ref
import { ref, provide } from 'vue'

export default {
  setup() {
    const count = ref(0)
    provide('count', count)
    
    return { count }
  }
}

// 后代组件
export default {
  setup() {
    const count = inject('count')
    return { count }
  }
}

二、provide/inject 的优势

1. 组件树穿透能力

场景‌:多层嵌套组件共享配置

// 根组件
provide('appConfig', {
  apiBaseUrl: 'https://api.example.com',
  features: {
    analytics: true,
    notifications: false
  }
})

// 第5层子组件直接使用
const config = inject('appConfig')
console.log(config.apiBaseUrl) // 直接访问

2. 减少 props 传递

传统方式‌:

// 每层组件都需要传递props
<Parent :config="config">
  <Child :config="config">
    <GrandChild :config="config" />
  </Child>
</Parent>

provide/inject 方式‌:

// 根组件
provide('config', config)

// 任意层级子组件
const config = inject('config')

3. 动态上下文共享

场景‌:表单组件与表单项通信

// Form 组件
provide('formContext', {
  registerField: (field) => { /* 注册字段 */ },
  validate: () => { /* 验证表单 */ }
})

// FormItem 组件
const { registerField } = inject('formContext')
onMounted(() => registerField(this))

三、provide/inject 的劣势

1. 调试困难

// 当多个祖先提供同名key时
const data = inject('settings') // 无法直观确认数据来源

// 解决方案:使用Symbol作为key
const SettingsKey = Symbol()
provide(SettingsKey, { theme: 'dark' })
const settings = inject(SettingsKey)

2. 缺乏状态管理

// 简单的计数器示例
provide('counter', {
  count: 0,
  increment() { this.count++ }
})

// 问题:
// 1. 状态变更无法追踪
// 2. 多个组件修改时可能产生冲突

3. 类型安全缺失(JavaScript中)

// 无法像TypeScript那样进行类型检查
const user = inject('user') // 不知道user的结构

四、与传统状态管理(Vuex)对比

1. Vuex 基本示例

// store.js
export default new Vuex.Store({
  state: {
    count: 0
  },
  mutations: {
    increment(state) {
      state.count++
    }
  },
  actions: {
    asyncIncrement({ commit }) {
      setTimeout(() => commit('increment'), 1000)
    }
  }
})

// 组件中使用
export default {
  computed: {
    count() {
      return this.$store.state.count
    }
  },
  methods: {
    increment() {
      this.$store.commit('increment')
    }
  }
}

2. 对比表格

特性 provide/inject Vuex
作用范围 组件树局部 全局
调试工具 不可见 完整的时间旅行调试
响应式 自动响应式 自动响应式
代码组织 分散在各组件 集中式管理
类型安全 需要额外处理 需要类型定义
服务端渲染 天然支持 需要额外配置
性能 按需注入,内存友好 全局存储,初始加载稍慢
适用场景 组件库/局部状态共享 大型应用全局状态管理

五、实际场景选择指南

1. 适合 provide/inject 的场景

场景1:UI组件库开发

// 下拉菜单组件
provide('dropdown', {
  registerItem: (item) => { /* 注册菜单项 */ },
  close: () => { /* 关闭菜单 */ }
})

// 菜单项组件
const { registerItem, close } = inject('dropdown')
onMounted(() => registerItem(this))

场景2:主题切换

// 主题提供者
provide('theme', {
  current: 'light',
  colors: {
    light: { primary: '#fff' },
    dark: { primary: '#000' }
  }
})

// 任意子组件
const { current, colors } = inject('theme')
const bgColor = computed(() => colors[current].primary)

2. 适合 Vuex/Pinia 的场景

场景1:用户全局状态

// store/user.js (Pinia示例)
export const useUserStore = defineStore('user', {
  state: () => ({
    name: '',
    token: ''
  }),
  actions: {
    async login(credentials) {
      const res = await api.login(credentials)
      this.name = res.name
      this.token = res.token
    }
  }
})

// 多个组件共享同一状态
const userStore = useUserStore()
userStore.login({...})

场景2:购物车管理

// store/cart.js (Vuex示例)
{
  state: {
    items: []
  },
  mutations: {
    ADD_ITEM(state, item) {
      state.items.push(item)
    }
  },
  getters: {
    totalPrice: (state) => {
      return state.items.reduce((sum, item) => sum + item.price, 0)
    }
  }
}

// 组件中使用
this.$store.commit('ADD_ITEM', product)
this.$store.getters.totalPrice

六、混合使用模式

1. 全局状态 + 局部增强

// 使用Pinia作为基础
const userStore = useUserStore()

// 在特定组件树中增强功能
provide('enhancedUser', {
  ...userStore,
  // 添加局部方法
  sendMessage() {
    console.log(`Message to ${userStore.name}`)
  }
})

2. 性能优化技巧

javascriptCopy Code
// 避免在provide中直接传递大对象
provide('heavyData', () => fetchHeavyData())

// 组件中按需获取
const getHeavyData = inject('heavyData')
const data = computed(() => getHeavyData())

七、决策流程图

graph TD
    A[需要共享状态?] -->|是| B{状态使用范围}
    B -->|全局多组件| C[Vuex/Pinia]
    B -->|特定组件树| D{状态复杂度}
    D -->|简单配置| E[provide/inject]
    D -->|复杂业务逻辑| C
    A -->|否| F[使用组件本地状态]

总结‌:

  • provide/inject 适合组件库开发和局部状态共享
  • Vuex/Pinia 适合大型应用全局状态管理
  • 在JavaScript项目中,注意通过命名规范和Symbol来避免注入冲突
  • 对于中型项目,可以考虑混合使用两种方案

O(n) 插入排序,简洁写法(Python/Java/C++/C/Go/JS/Rust)

不让用 $\texttt{sort}$ 吗?有意思……

技巧:O(1) 插入元素

假设现在有一个有序数组 $a=[0,0,1,1,2,2]$。在 $a$ 中插入一个 $0$,同时保证 $a$ 是有序的,你会怎么做?

最暴力的想法是,把 $0$ 插在数组的最左边,原来的元素全体右移一位,得到 $[0,0,0,1,1,2,2]$。这样做是 $\mathcal{O}(n)$ 的。

实际上,我们可以「狸猫换太子」:不是插入元素,而是修改元素!

对比一下插入前后:

  • 插入前 $[0,0,1,1,2,2]$。
  • 插入后 $[0,0,0,1,1,2,2]$。

竖着看,其实只有 $3$ 个位置变了:

  1. 原来的 $a[2]$ 变成 $0$。
  2. 原来的 $a[4]$ 变成 $1$。
  3. 末尾新增一个 $2$,相当于 $a[6]=2$。

怎么知道要修改的位置(下标)?

  1. 维护 $0$ 的个数,即为改成 $0$ 的位置,记作 $p_0$。上例中 $p_0=2$。把 $a[p_0]$ 改成 $0$。
  2. 维护 $0$ 和 $1$ 的个数,即为改成 $1$ 的位置,记作 $p_1$。上例中 $p_1=4$。把 $a[p_1]$ 改成 $1$。
  3. 末尾新增的位置记作 $i$,把 $a[i]$ 改成 $2$。

细节

如果 $a$ 中没有 $2$ 呢?上面第三步就错了。

比如现在 $a=[1]$,插入一个 $0$,变成 $[0,1]$。

如果按照上面三步走,最后把 $a[1]$ 改成 $2$,得到的是 $[0,2]$,这就错了。

要写很多 $\texttt{if-else}$,特判这些特殊情况吗?

不需要,我们可以倒过来算:先把 $a[1]$ 改成 $2$,再把 $a[1]$ 改成 $1$(覆盖),最后 $a[0]$ 改成 $0$,得到 $[0,1]$。这种「覆盖」等价于「没有 $2$ 的时候不改成 $2$」。

如果插入的是 $1$ 呢?

跳过「把 $a[p_0]$ 改成 $0$」这一步。

如果插入的是 $2$ 呢?

只需要把 $a[i]$ 改成 $2$ 即可。

本题思路

对 $\textit{nums}$ 执行插入排序,也就是对 $i=0,1,2,\ldots,n-1$ 依次执行如下过程:

  • 现在前缀 $\textit{nums}[0]$ 到 $\textit{nums}[i-1]$ 是有序的,我们把 $\textit{nums}[i]$ 插入到这个有序前缀中,从而把前缀 $\textit{nums}[0]$ 到 $\textit{nums}[i]$ 变成有序的。
  • 算法执行完后,$\textit{nums}$ 就是一个有序数组了。
class Solution:
    def sortColors(self, nums: List[int]) -> None:
        p0 = p1 = 0
        for i, x in enumerate(nums):
            nums[i] = 2
            if x <= 1:
                nums[p1] = 1
                p1 += 1
            if x == 0:
                nums[p0] = 0
                p0 += 1
class Solution {
    public void sortColors(int[] nums) {
        int p0 = 0;
        int p1 = 0;
        for (int i = 0; i < nums.length; i++) {
            int x = nums[i];
            nums[i] = 2;
            if (x <= 1) {
                nums[p1++] = 1;
            }
            if (x == 0) {
                nums[p0++] = 0;
            }
        }
    }
}
class Solution {
public:
    void sortColors(vector<int>& nums) {
        int p0 = 0, p1 = 0;
        for (int i = 0; i < nums.size(); i++) {
            int x = nums[i];
            nums[i] = 2;
            if (x <= 1) {
                nums[p1++] = 1;
            }
            if (x == 0) {
                nums[p0++] = 0;
            }
        }
    }
};
void sortColors(int* nums, int numsSize) {
    int p0 = 0, p1 = 0;
    for (int i = 0; i < numsSize; i++) {
        int x = nums[i];
        nums[i] = 2;
        if (x <= 1) {
            nums[p1++] = 1;
        }
        if (x == 0) {
            nums[p0++] = 0;
        }
    }
}
func sortColors(nums []int) {
    p0, p1 := 0, 0
    for i, x := range nums {
        nums[i] = 2
        if x <= 1 {
            nums[p1] = 1
            p1++
        }
        if x == 0 {
            nums[p0] = 0
            p0++
        }
    }
}
var sortColors = function(nums) {
    let p0 = 0, p1 = 0;
    for (let i = 0; i < nums.length; i++) {
        const x = nums[i];
        nums[i] = 2;
        if (x <= 1) {
            nums[p1++] = 1;
        }
        if (x === 0) {
            nums[p0++] = 0;
        }
    }
};
impl Solution {
    pub fn sort_colors(nums: &mut Vec<i32>) {
        let mut p0 = 0;
        let mut p1 = 0;
        for i in 0..nums.len() {
            let x = nums[i];
            nums[i] = 2;
            if x <= 1 {
                nums[p1] = 1;
                p1 += 1;
            }
            if x == 0 {
                nums[p0] = 0;
                p0 += 1;
            }
        }
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $\textit{nums}$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

大厂面试:与HTML相关的基础知识点解析

前言

在前端开发的面试中,HTML 是最基础也是最重要的知识点之一。

尤其是大厂的前端面试,往往会围绕 HTML 的核心概念和语义化标签进行深入考察。

本文将带你梳理一些常见的 HTML 面试问题,并结合实际应用场景进行解析。


Q1 :HTML5 是什么,为什么重要?

HTML5 是 HTML 的最新版本标准,它不仅增强了网页结构的表现能力,还引入了丰富的语义化标签和原生功能(如音视频支持、本地存储等),使开发者能够更高效地构建现代 Web 应用。

在 HTML5 中,我们通常会在文档开头使用:

<!DOCTYPE html>

这行代码的作用是告诉浏览器这是一个 HTML5 文档,确保浏览器以标准模式渲染页面。


Q2 : HTML 标签有哪些分类?


1.按布局需求,可分为行内元素标签和块级元素标签:

1.1 行内元素(Inline Elements)

  • 特点:默认情况下,行内元素不会独占一行,宽度由内容决定,不能设置宽高。
  • 常见标签
    • <a>:超链接
    • <span>:文本容器,常用于样式控制

✅ 小技巧:调试的时候可以给元素添加背景色,以便观察布局效果。

示例:

<span style="background-color: yellow;">这是一个 span</span>
<a href="#">这是一个链接</a>

1.2 块级元素(Block-level Elements)

  • 特点:每个块级元素独占一行,默认宽度为父容器的 100%,可以设置宽高。
  • 常见标签
    • <div>:通用容器
    • <ul> / <li>:无序列表
    • <p>:段落
    • <header> / <footer> / <nav>:语义化标签

示例:

<div style="background-color: lightblue;">这是一个 div</div>
<p>这是一个段落</p>

2. 按功能分,可分为语义化标签、表格标签、表单标签等...

除了布局相关的分类外,HTML 标签还可以根据其功能进行划分,尤其是一些新增的语义化标签,在大厂面试中经常被问到。

2.1 语义化标签(Semantic Tags)

HTML5 引入了许多具有明确含义的语义化标签,这些标签比传统的 <div> 更具可读性和可维护性,也有利于 SEO 和爬虫抓取。

标签 含义
<header> 页面或区块的头部
<main> 页面主要内容
<footer> 页面或区块的底部
<nav> 导航区域
<section> 内容区块
<article> 独立文章内容
<aside> 侧边栏或辅助信息

示例:

<header>
  <h1>网站标题</h1>
  <nav>
    <ul>
      <li><a href="#home">首页</a></li>
      <li><a href="#about">关于我们</a></li>
    </ul>
  </nav>
</header>
<main>
  <article>
    <h2>文章标题</h2>
    <p>这是文章内容。</p>
  </article>
</main>
<footer>
  <p>© 2025 版权所有</p>
</footer>

2.2 表格相关标签

表格用于展示结构化的数据,虽然现在不常用作布局工具,但在数据展示方面依然不可或缺。

  • <table>:定义表格
  • <tr>:表格行
  • <td>:单元格
  • <th>:表头单元格(加粗居中显示)

示例:

<table border="1">
  <tr>
    <th>姓名</th>
    <th>年龄</th>
  </tr>
  <tr>
    <td>张三</td>
    <td>28</td>
  </tr>
</table>

2.3 表单相关标签

表单是用户与网页交互的重要方式,常用于登录、注册等功能。

  • <form>:表单容器
  • <input>:输入框(文本、密码、复选框等)
  • <select> / <option>:下拉选择框
  • <textarea>:多行文本输入

示例:

<form action="/submit" method="post">
  <label for="username">用户名:</label>
  <input type="text" id="username" name="username"><br><br>

  <label for="color">喜欢的颜色:</label>
  <select id="color" name="color">
    <option value="red">红色</option>
    <option value="blue">蓝色</option>
  </select><br><br>

  <input type="submit" value="提交">
</form>

3. 结语

HTML 虽然是前端三大核心技术之一中最基础的部分,但其重要性不容忽视。掌握 HTML 的基本结构、标签分类以及语义化应用,不仅能帮助你写出更清晰、易于维护的代码,还能在大厂面试中脱颖而出。

在准备面试时,建议多动手实践,尝试用语义化标签重构传统 <div> 布局,理解不同标签的行为差异,并关注 HTML5 新增的功能特性,如 Canvas、SVG、本地存储等,这些都是进阶面试题的重要考点。


📌 小贴士:

  • 在调试页面时,可以给元素添加背景颜色,快速识别布局结构。
  • 使用语义化标签提升代码可读性,有助于团队协作和搜索引擎优化(SEO)。

希望这篇文章能帮助你在 HTML 面试中游刃有余,顺利拿下心仪的大厂 offer!

译:自由实验你的代码—Git worktree

编者注: Git worktree 是 Git 提供的一个强大功能,它允许你在同一个仓库中同时处理多个分支,而不会干扰当前的工作环境。1) 通过创建新的 worktree,你可以并行处理多个任务,比如修复紧急 bug 和开发新功能。2) worktree 与主工作区完全隔离,你可以独立提交、推送和归档更改。3) 使用 worktree 可以避免频繁切换分支带来的混乱,保持工作区的整洁。本文详细介绍了 worktree 的使用场景和操作方法,帮助你更高效地管理 Git 仓库。

什么是 Git worktree

Git worktree 是 Git 仓库的链接副本,允许你同时检出多个分支。worktree 与主工作副本的路径不同,但它可以处于不同的状态和不同的分支上。Git 中新的 worktree 的优势在于,你可以进行与当前任务无关的更改,提交更改,然后在稍后合并它,而不会干扰你当前的工作环境。

来自 git-worktree 手册页的典型示例是,当你正在为项目开发一个令人兴奋的新功能时,你的项目经理告诉你需要一个紧急修复。问题是,你的工作仓库(你的“worktree”)因为正在开发一个主要的新功能而处于混乱状态。你不想将修复“偷偷”插入当前的冲刺中,也不愿意暂存更改以创建一个新的分支来进行修复。相反,你决定创建一个新的 worktree,以便在那里进行修复:

$ git branch | tee 
* dev 
  trunk 
$ git worktree add -b hotfix ~/code/hotfix trunk 
Preparing ../hotfix (identifier hotfix) HEAD is now at 62a2daf commit

上述命令的功能是从 trunk 分支创建一个名为 hotfix 的新分支,并将其作为一个独立的 worktree 放置在 ~/code/hotfix 目录中。此时,你可以将这个 worktree 当作主要的工作空间。你能够切换到该目录,进行紧急修复,提交修改,最后删除这个 worktree:

$ cd ~/code/hotfix 
$ sed -i 's/teh/the/' hello.txt 
$ git commit --all --message '紧急修复'

一旦你完成了紧急工作,你可以回到之前的任务。你可以控制何时将你的 hotfix 集成到主项目中。例如,你可以直接从它的 worktree 将更改推送到项目的远程仓库:

$ git push origin HEAD 
$ cd ~/code/myproject

或者你可以将 worktree 归档为 TAR 或 ZIP 文件:

$ cd ~/code/myproject 
$ git archive --format tar --output hotfix.tar master

或者你可以从单独的 worktree 中获取更改:

$ git worktree list 
/home/seth/code/myproject 15fca84 [dev] 
/home/seth/code/hotfix 09e585d [master]

从那里,你可以使用最适合你和你的团队的策略来合并你的更改。

列出活跃的 worktree

你可以使用 git worktree list 命令获取 worktree 的列表,并查看每个 worktree 检出的分支:

$ git worktree list 
/home/seth/code/myproject 15fca84 [dev] 
/home/seth/code/hotfix 09e585d [master]

你可以在任何一个 worktree 中使用这个命令。worktree 总是链接的(除非你手动移动它们,破坏 Git 定位 worktree 的能力,从而切断链接)。

移动一个 worktree

Git 在你的项目的 .git 目录中跟踪 worktree 的位置和状态:

$ cat ~/code/myproject/.git/worktrees/hotfix/gitdir /home/seth/code/hotfix/.git

如果你需要重新定位一个 worktree,你必须使用 git worktree move;否则,当 Git 尝试更新 worktree 的状态时,它会失败:

$ mkdir ~/Temp 
$ git worktree move hotfix ~/Temp 
$ git worktree list 
/home/seth/code/myproject 15fca84 [dev] 
/home/seth/Temp/hotfix 09e585d [master]

删除一个 worktree

当你完成工作后,你可以使用 remove 子命令删除它:

$ git worktree remove hotfix 
$ git worktree list 
/home/seth/code/myproject 15fca84 [dev]

为了确保你的 .git 目录是干净的,在删除 worktree 后使用 prune 子命令:

$ git worktree prune

何时使用 worktree

使用标签、书签、自动备份等功能时,需自行跟踪数据,否则易管理混乱。Git worktree 也同理,别频繁创建,避免副本过多。建议按需创建,完成任务、提交工作后及时删除,保持简洁专注。

重要的是,worktree 为你管理 Git 仓库提供了更大的灵活性。在需要时使用它们,再也不用为了检查另一个分支上的内容而匆忙保存工作状态。

英文原文

ts极速封装axios,关注点分离,包会、包爽!

开篇

axios还用封装?


在这里插入图片描述


不着急,咱先聊聊为什么要封装axios,或者说我们的封装目标是什么。


1、三级拦截器:支持全局、实例、单次请求响应的拦截器设置

为什么要分级设置拦截器?


为了分离关注点,从而实现分层


拿错误处理来说,

当请求过程中发生错误时,错误拦截器执行顺序会是:全局拦截器、实例级拦截器、单次请求级拦截器。

那我们可以用实例级拦截器处理通用错误,用单次请求级拦截器处理特定业务错误。

业务错误在实例级错误拦截器里重新抛出就行了。

这样在错误处理时,实例级拦截器只需要处理通用错误,单次请求级拦截器只需要处理特定业务的错误。

岂不爽哉?


2、支持比axios更严格更清晰的类型要求

axios的get、post等方法泛型参数不传会自动给any。

而我们通过封装可以让 any 爬~

而且封装之后可以通过interface开放类型的拓展,还就那个开闭原则。


3、不锁死axios:方便渣~

哪天不想(能)用axios了,可以无痛切换。


所以说,有这些需求完全可以封装一下axios。


在这里插入图片描述


那怎么个封装法?


1、封装实例和请求配置

这里提一嘴,不建议在响应拦截器里偷懒返回res.data,因为axios内部拦截器是串行调用的,上一个拦截器的结果会作为下一个拦截器的输入,这会导致后续的其它拦截器拿到res.data,而不是res。


import type {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
InternalAxiosRequestConfig
} from 'axios';
import axios from 'axios';

//RD: Request Data, SD: Response Data
export interface RequestConfig<RD, SD> extends AxiosRequestConfig<RD> {
reqOKFn?: ((config: RequestConfig<RD, SD>) => Promise<RequestConfig<RD, SD>> | RequestConfig<RD, SD>)[];
//不支持偷懒返回res.data,这是一种反模式, 因为会导致后续的其它拦截器拿到res.data,而不是res
resOKFn?: ((res: AxiosResponse<SD, RD>) => Promise<AxiosResponse<SD, RD>> | AxiosResponse<SD, RD>)[];
reqFailFn?: (error: AxiosError<SD, RD>) => Promise<AxiosResponse<SD, RD>> | AxiosResponse<SD, RD>;
resFailFn?: (error: AxiosError<SD, RD>) => Promise<AxiosResponse<SD, RD>> | AxiosResponse<SD, RD>;
}


class Requester<TRD, TSD> {
instance: AxiosInstance;
constructor(config: RequestConfig<TRD, TSD>) {
this.instance = axios.create(config);
}
        
  async request<RD, SD>(config: RequestConfig<RD, SD>): Promise<AxiosResponse<SD, RD>> {
let res = await this.instance.request<SD, AxiosResponse<SD>, RD>(config);
return res;
}
}

这里要求泛型必须传入参数且以之指定返回值类型,就可以进行更为严格的类型要求。


2、三级拦截器功能

全局拦截器直接在constructor里设置,全局就是对每个实例都设置。


实例拦截器在传入config里设置、在constructor中应用。


值得一提的是,axios中请求拦截器的签名是这样的:

(config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig

而我们想要设置的是这样的:

(config:RequestConfig) => RequestConfig

看一眼axios源码看看InternalAxiosRequestConfig是个啥:

export interface InternalAxiosRequestConfig<D = any> extends AxiosRequestConfig<D> {
  headers: AxiosRequestHeaders;
}

那请求拦截器函数实际上拿到的是加了个 headers 属性的 RequestConfig ,同时也是属性只多不少的 InternalAxiosRequestConfig

所以我们可以通过类型断言来进行适配, 同时保持类型安全。


import type {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
InternalAxiosRequestConfig
} from 'axios';
import axios from 'axios';

//RD: Request Data, SD: Response Data
export interface RequestConfig<RD, SD> extends AxiosRequestConfig<RD> {
reqOKFn?: ((config: RequestConfig<RD, SD>) => Promise<RequestConfig<RD, SD>> | RequestConfig<RD, SD>)[];
//不支持偷懒返回res.data,这是一种反模式, 因为会导致后续的其它拦截器拿到res.data,而不是res
resOKFn?: ((res: AxiosResponse<SD, RD>) => Promise<AxiosResponse<SD, RD>> | AxiosResponse<SD, RD>)[];
reqFailFn?: (error: AxiosError<SD, RD>) => Promise<AxiosResponse<SD, RD>> | AxiosResponse<SD, RD>;
resFailFn?: (error: AxiosError<SD, RD>) => Promise<AxiosResponse<SD, RD>> | AxiosResponse<SD, RD>;
}

class Requester<TRD, TSD> {
instance: AxiosInstance;
//config:实例的默认配置
constructor(config: RequestConfig<TRD, TSD>) {
this.instance = axios.create(config);
// 这里 设置并应用 全局拦截器(会给每个实例都设置)
// this.instance.interceptors.request.use(onFulfilledCb,onRejectedCb);

// 这里 应用 实例的拦截器
this.instance.interceptors.request.use(async (config: InternalAxiosRequestConfig<TRD>) => {
/* 
实际上拿到的是加了 headers 属性的 RequestConfig ,
同时也是属性只多不少的 InternalAxiosRequestConfig
类型安全(鸭子类型)
*/
let configCustom = config as unknown as RequestConfig<TRD, TSD>;
for (const fn of configCustom.reqOKFn || []) {
configCustom = await fn(configCustom);
}
return configCustom as InternalAxiosRequestConfig<TRD>;
}, config.reqFailFn);

this.instance.interceptors.response.use(async (value: AxiosResponse<TSD, TRD>) => {
for (const fn of config.resOKFn || []) {
value = await fn(value);
}
return value;
}, config.resFailFn);
}

单次请求的拦截器在request方法里应用就行了。



async request<RD, SD>(config: RequestConfig<RD, SD>):: Promise<AxiosResponse<SD, RD>> {
// 这里 应用 单次请求和响应的拦截器
try {
for (const fn of config.reqOKFn || []) {
config = await fn(config);
}
let res = await this.instance.request<SD, AxiosResponse<SD>, RD>(config);
for (const fn of config.resOKFn || []) {
res = await fn(res);
}
return res;
} catch (error) {
/* 拦截器执行顺序:实例级拦截器、单次请求级拦截器
实例级拦截器处理通用错误,
单次请求级拦截器处理特定业务错误
*/
if (axios.isAxiosError(error)) {
if (error.response) {
//响应错误
if (config.resFailFn) {
                                        // 拦截器返回值会成为请求的最终结果(提供fallback)
return config.resFailFn(error); 
}
} else {
//请求或者请求创建错误
if (config.reqFailFn) {
return config.reqFailFn(error);
}
}
}

throw error;
}
}
}

这里注意要像这样手动设置request方法的返回值类型,不然会推导成Promise<any>


这样,当请求过程中发生错误时,拦截器执行顺序是:全局拦截器、实例级拦截器、单次请求级拦截器。


用实例级拦截器处理通用错误,用单次请求级拦截器处理特定业务错误。

业务错误在实例级错误拦截器里重新抛出就行了。


体会到拦截器分层的好处了吗?


其实不管前端后端,分层的架构思想都无处不在。


比如一个完整的前端应用会分为:

  1. UI组件层: 纯展示组件(按钮、表单等)
  2. 页面/容器层: 组合UI组件,连接数据与展示
  3. 状态管理层: 管理应用状态(Redux/Vuex等)
  4. 服务/API层: 处理与后端的通信(当前代码所处层次)
  5. 工具/辅助层: 提供通用功能

有些复杂的项目甚至还会使用BFF(Backend For Frontend)来加个一层。


在这里插入图片描述


3、结束

最后加上常用的getpost方法

async get<SD>(url: string, config?: RequestConfig<never, SD>) {
return await this.request<never, SD>({
url,
method: 'GET',
...config
});
}

async post<RD, SD>(url: string, data: RD, config?: RequestConfig<RD, SD>) {
return await this.request<RD, SD>({
url,
method: 'POST',
data,
...config
});
}

没了啊,就这。


使用示例:

interface ServerDataFormat<TData = unknown> {
code: string; 

message: string; 

data: TData; 
}

interface LoginResponse {
id: number;
username: string;
create_at: Date | null;
update_at: Date | null;
email: string;
token: string;
}

interface UserInfo {
username: string;

password: string;

email?: string;

captcha?: string;
}
import { type RequestConfig, Requester } from './requester';
import type { ServerDataFormat } from './types';

const config: RequestConfig<unknown, ServerDataFormat> = {
baseURL: 'http://localhost:3000',
timeout: 10000,
//这里 设置 实例拦截器
reqOKFn: [
async config => {
//添加token
const token = localStorage.getItem('token');
if (token && config.headers) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
}
],
resOKFn: [
async response => {
return response;
}
]
};

const instance = new Requester<unknown, ServerDataFormat>(config);

async function login(userInfo: UserInfo) {
return await instance.post<UserInfo, ServerDataFormat<LoginResponse>>('/user/login', userInfo);
}


结尾

后头咱来聊聊,如何从后端到前端,制定统一返回格式和错误处理,前端联合axios、react-query、redux,进行优雅的前后端通信、错误处理和状态管理。


在这里插入图片描述

Cesium基础(四):部署离线地图和地形资源

cesium离线部署

  引言:前面文章主要介绍了cesium的初始化,这篇文章主要介绍cesium怎么在断网条件下进行开发和调试,具体内容包括地图与地形的下载,切片和部署。最后会在断网条件下进行初始cesium。

cesium影像瓦片

  cesium的初始化成功后的地球,看似是一个完整的球体,但是仔细观察镜头在推进的时候能看到图层表面是有一些缝隙的,就像一张张图片拼接成的一个能观察的现实地球,这些图片就是瓦片图。AI搜索的cesium瓦片图解释是这样: Cesium的瓦片图是其实现大规模地理数据高效加载与渲染的核心技术,主要分为影像瓦片和地形瓦片两大类,同时支持三维模型瓦片(3D Tiles)。其实,这些影像瓦片是通过将地图按照层级、行号、列号切分为小图块,以四叉树结构组织存储。Cesium通过ImageryProvider加载这些瓦片。具体实现如下:

离线化部署
  1. 瓦片图下载:下载全能地图下载器进行破解,因为很多地图资源的下载都是收费的,所以你懂的,[网盘链接](pan.baidu.com/s/11f5a6tsM… 提取码: 25yn)。下载解压后,右键注册机以管理员身份运行得到注册码,双击imaps.exe,将注册码填写就可以愉快的下载瓦片图了。具体操作如下:

3.PNG   有其余需要下载的,由于篇幅有限,就不一一介绍了,请自己摸索。下载成功的资源目录如下:

image.png

image.png

  1. nginx部署: 在这里我就不介绍怎么在windows安装部署nginx了,请自行百度安装。安装后的目录如下: 捕获.PNG   打开conf文件夹,打开nginx.conf文件,新增一个server,配置代码如下。其中root对应的是文件名称,下面的是为例解决跨域访问的问题
 server {
        listen       81;
        server_name  127.0.0.1;
       
        location / {
            root   satellite;
            autoindex on;
            autoindex_exact_size off;
            autoindex_localtime on;
            add_header Access-Control-Allow-Origin *;
            add_header Access-Control-Allow-Credentials true;
            add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
            add_header Access-Control-Allow-Methods 'GET,POST,OPTIONS';  
        }

        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }

  之后双击nginx.exe,会出现cmd一闪而过,打开浏览器输入127.0.0.1:81出现如图所示即表示部署成功。 image.png

  1. 配置模板路径: 使用UrlTemplateImageryProvider,访问加载瓦片图,具体路径 如:http://127.0.0.1:81/{z}/{x}/{y}.jpg。具体实现如下:
<script setup>
import * as cesium from "cesium";
import { onMounted } from 'vue';
import { useCesium } from "@/hooks/useCesium";
let viewer = null
const map1 = new cesium.UrlTemplateImageryProvider({
    url: 'http://127.0.0.1:81/{z}/{x}/{y}.jpg'
})

onMounted(() => {
    const earth = document.querySelector("#earth");
    viewer = useCesium(earth);
    viewer.imageryLayers.addImageryProvider(map1)
})
</script>

<template>
    <div class="content">
        <div class="earth" id="earth"></div>
    </div>
</template>

<style lang="scss" scoped>
.content {
    width: 100%;
    height: 100%;
    position: relative;
    .earth {
        width: 100%;
        height: 100%;
    }
}
</style>

cesium地形瓦片

  1.下载: 地形瓦片的离线部署比较麻烦,因为需要自己切割,所以需要在地理空间数据云上下载数据。www.gscloud.cn/search 选择好需要下载的数据级,这里选择DEM数字高程数据中的GDEMV3 30M分辨率数字高程数据。

捕获.PNG   然后检索想要下载的地区,这里作者选择的是北京市昌平区。点击检索结果最右侧的下载按钮下载,实际下载的数据是绿色框框住的面积。

image.png   2.cesiumLab处理: 将解压后的ASTGTMV003_N40E115_dem.tif文件上传,具体操作如下:等待处理完成。 捕获.PNG   处理好的地形瓦片:

image.png   3. nginx部署: 具体代码如下:

server {
        listen       888;
        server_name  127.0.0.1;
       
        location / {
            root   terrain;
            autoindex on;
            autoindex_exact_size off;
            autoindex_localtime on;
            add_header Access-Control-Allow-Origin *;
            add_header Access-Control-Allow-Credentials true;
            add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
            add_header Access-Control-Allow-Methods 'GET,POST,OPTIONS'; 
        }
     
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }

捕获.PNG   4.配置模板路径: 使用静态方法cesium.CesiumTerrainProvider.fromUrl,访问http://127.0.0.1:888加载地形资源。实现与效果如下:

<script setup>
import * as cesium from "cesium";
import { onMounted } from "vue";
import { useCesium } from "@/hooks/useCesium";
let viewer = null
// 加载离线地图
const map1 = new cesium.UrlTemplateImageryProvider({
    url: 'http://127.0.0.1:81/{z}/{x}/{y}.jpg'
})
onMounted(async () => {
    const earth = document.querySelector("#earth");
    viewer = useCesium(earth);
    viewer.imageryLayers.addImageryProvider(map1)
    // 加载离线地形
   try{
    const terrainProvider = await cesium.CesiumTerrainProvider.fromUrl('http://127.0.0.1:888');
    viewer.terrainProvider = terrainProvider;
   }catch(e){
       console.log(e)
   }
});
</script>
<template>
    <div class="earth" id="earth"></div>
</template>

<style lang="scss" scoped>
.earth {
    width: 100%;
    height: 100%;
}
</style>

image.png

总结

  cesium影像瓦皮和地形瓦片的部署已经实现,回顾前几篇文章,发现几乎没有新的API用到,最麻烦的也就是nginx部署,实际应用中可以直接整合到后端代码中,如果前端再进行桌面化开发,那么就不用nginx也可以实现,作者已经使用tauri + rust实现了集成。具体实现等有时间再聊聊,今天就到此为止。下一篇聊cesium实体的创建,敬请期待!(觉得对你有帮助的话请点赞+收藏,有问题下方留言。)

npm link本地测试React组件库报错“Invalid hook call”?从多实例到pnpm依赖的完整排查指南

最近开发了一个React拖拽排序组件库,计划通过npm link到本地测试时,遇到了报错:Warning: Invalid hook call. Hooks can only be called inside of the body of a function component.经过多轮排查,最终发现到React多实例冲突pnpm对peerDependencies的隐式安装是核心原因,以下是完整解决流程,供同类问题参考

一、问题初判:React版本兼容性?

测试项目使用Next 15(App Router)报错,初步怀疑版本冲突:

  • Next 15 App Router默认依赖React 19,但组件库用了React 18.3.1
  • 尝试放宽版本限制:将组件库package.jsonreact的版本范围改为"^18.3.1 || ^19.0.0",但测试仍报错
  • 结论:版本兼容性非主因(组件库并没有使用与react18强相关的特性,后来使用了一个react18的项目也不行)

二、排查方向:组件库依赖配置问题

查阅React官方文档(Invalid Hook Call警告)和关键Issue(github.com/facebook/re… ,提示可能是React多实例Hooks未在函数组件内调用导致。因为组件代码没有问题所以排除后者,重点排查多实例:

1. 验证多实例的关键方法

  • 现象辅助判断:同时出现Invalid hook callCannot read properties of null (reading 'useState'),是典型多实例特征(不同React实例的Hooks上下文不共享)

  • 打包产物检查

    • 构建后搜索组件库代码,确认仅在入口有import React from 'react',其他位置无重复引入或者明显的function useState(){}定义等
    • 使用rollup-plugin-visualizer分析打包依赖,确认React未被打包进组件库

2. 修复尝试:配置peerDependencies与外部化

  • peerDependencies声明:在组件库package.json中,将reactreact-dom标记为peerDependencies(告知用户需自行安装)
  • Rollup外部化配置:在vite.config.ts中,通过build.rollupOptions.external将React相关模块排除在打包外:["react", "react-dom", "react/jsx-runtime"] 关键:包括react/jsx-runtime
  • 结果:仍报错

三、关键突破:React路径指向异常

尝试暴力验证:将组件库打包后的import React from 'react'改为测试项目中React的绝对路径(如import React from '/path/to/test-project/node_modules/react'),测试项目运行正常

  • 结论:实锤组件库与测试项目引用了不同路径的React实例

四、终极原因:pnpm对peerDependencies的隐式安装

进一步排查依赖管理工具pnpm的特性:

  • pnpm默认行为:pnpm 10默认开启autoInstallPeers官方文档)(pnpm9也是),会自动安装peerDependencies到当前项目的node_modules中(这篇文章stackoverflow.com/questions/7… 有误导性)
  • 问题触发场景:组件库依赖了motion,而motionpeerDependencies中声明了react,pnpm因autoInstallPeers=true,会隐式为组件库安装一个独立的React实例
  • 验证方法:执行pnpm why react(显示哪个包依赖了react导致的下载),输出显示React由motion的peer依赖触发安装

五、最终解决方案

通过配置pnpm禁用自动安装peer依赖,确保组件库与测试项目共享同一React实例:

  1. 在组件库根目录创建.npmrc文件,添加:auto-install-peers = false
  2. 删除lock文件,重新安装依赖(pnpm i),可以看到pnpm-lock.yaml中开头有一行autoInstallPeers: false
  3. 重新build组件库

总结与避坑指南

  • 核心原则:本地测试组件库时,确保组件库与测试项目共享同一React实例(路径、版本完全一致)

  • 关键配置

    • 组件库必须声明reactpeerDependencies,避免打包时包含React
    • Rollup/Vite需外部化reactreact-domreact/jsx-runtime(避免打包)
    • pnpm用户需检查auto-install-peers配置(默认开启,可能导致隐式安装独立实例)

tips:npm link和pnpm link的区别

  1. npm link:先npm link将组件库注册到全局,再在测试项目npm link 包名引用全局链接,即通过全局node_modules建立软链接
  2. pnpm link:直接将组件库路径硬链接到测试项目的node_modules(需显式指定路径pnpm link 组件库路径),无需经过全局node_modules

希望这篇记录能帮到遇到类似问题的开发者

Koa2 跨域实战:`withCredentials`场景下响应头配置全解析

第一章 跨域问题本质:同源策略与凭证携带限制

1.1 同源策略的核心规则

同源策略(Same-Origin Policy)是浏览器最核心的安全机制之一,其定义为:协议、域名、端口三者完全一致才视为同源。当浏览器发起跨域请求(如前端域名https://web.example.com访问后端https://api.example.com)时,会受到以下限制:

  • 默认禁止读取响应内容(如 JSON 数据)
  • 默认禁止携带凭证(如 Cookie、HTTP 认证头)
  • 需通过 CORS 响应头显式授权

1.2 withCredentials的特殊限制

当前端通过以下方式显式要求携带凭证时:

// Fetch API
fetch('https://api.example.com/data', { credentials: 'include' });

// XMLHttpRequest
xhr.withCredentials = true;

浏览器会对服务端响应头施加更严格的验证规则:

  1. Access-Control-Allow-Origin禁止使用*
    必须返回具体的源(如https://web.example.com),否则浏览器将拦截响应,报错:
    The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'

  2. 必须返回Access-Control-Allow-Credentials: true
    显式告知浏览器允许携带凭证,否则凭证不会被发送。

  3. 预检请求(OPTIONS)的特殊要求
    非简单请求(如 PUT/DELETE 方法、自定义请求头)需先发送 OPTIONS 请求验证权限,服务端必须正确响应以下头:

    Access-Control-Allow-Methods: POST, GET, OPTIONS
    Access-Control-Allow-Headers: Content-Type, Authorization
    

第二章 Koa2 核心解决方案:动态源与中间件配置

2.1 手动处理 CORS 响应头(无中间件)

通过 Koa 中间件手动解析请求源并动态设置响应头,适合轻量级项目或需要高度定制化的场景。

2.1.1 基础实现:白名单校验

const Koa = require('koa');
const app = new Koa();

// 允许的源白名单(生产环境建议从环境变量读取)
const ALLOWED_ORIGINS = new Set([
  'https://web.example.com',
  'https://admin.example.com:8080' // 包含端口的完整源
]);

app.use(async (ctx, next) => {
  const origin = ctx.get('Origin'); // 获取前端请求的源
  
  // 校验源是否合法
  if (ALLOWED_ORIGINS.has(origin)) {
    ctx.set('Access-Control-Allow-Origin', origin);
    ctx.set('Access-Control-Allow-Credentials', 'true'); // 必须设置为true
    ctx.set('Access-Control-Expose-Headers', 'Authorization, X-Total-Count'); // 允许前端读取的自定义头
  } else {
    // 不允许的源返回403或不设置Access-Control-Allow-Origin(浏览器自动拒绝)
    if (ctx.method === 'OPTIONS') {
      ctx.status = 403;
      return;
    }
  }

  // 处理预检请求
  if (ctx.method === 'OPTIONS') {
    ctx.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    ctx.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    ctx.set('Access-Control-Max-Age', '86400'); // 预检结果缓存24小时
    ctx.status = 204; // 成功响应预检请求
    return;
  }

  await next();
});

// 示例路由
app.use(async ctx => {
  if (ctx.path === '/api/data') {
    ctx.body = { data: 'Authenticated response' };
    ctx.set('Authorization', 'Bearer xxx'); // 配合Access-Control-Expose-Headers使用
  }
});

app.listen(3000);

2.1.2 进阶场景:通配符匹配(需谨慎)

允许同一域名下的所有子域名(如*.example.com),可通过正则表达式实现:

const ALLOWED_ORIGIN_REGEX = /^https?://(?:\w+.)?example.com$/;

app.use(async (ctx, next) => {
  const origin = ctx.get('Origin');
  if (origin && ALLOWED_ORIGIN_REGEX.test(origin)) {
    ctx.set('Access-Control-Allow-Origin', origin);
    ctx.set('Access-Control-Allow-Credentials', 'true');
  }
  // ...处理预检请求
});

警告:通配符匹配可能引入安全风险,仅建议在可控环境(如同一域名下的子系统)使用。

2.2 使用koa-cors中间件简化配置

koa-cors是 Koa 官方推荐的 CORS 中间件,支持动态源、凭证配置及预检请求处理,代码量可减少 50% 以上。

2.2.1 基础配置

const Koa = require('koa');
const cors = require('koa-cors');
const app = new Koa();

app.use(cors({
  origin: function(ctx) {
    // 动态返回允许的源,优先匹配白名单
    const allowed = ['https://web.example.com', 'https://admin.example.com'];
    const origin = ctx.get('Origin');
    if (allowed.includes(origin)) {
      return origin;
    }
    // 开发环境允许本地调试(需限制在开发阶段)
    if (process.env.NODE_ENV === 'development' && origin.includes('localhost')) {
      return origin;
    }
    return false; // 禁止的源返回false,浏览器会忽略该头
  },
  credentials: true, // 允许携带凭证
  maxAge: 86400, // 预检缓存时间
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  headers: ['Content-Type', 'Authorization', 'X-Custom-Header']
}));

app.listen(3000);

2.2.2 配置简写:环境变量驱动

app.use(cors({
  origin: ctx => ctx.get('Origin') || '*', // 生产环境需移除*,此处仅为示例
  credentials: true,
  // 从环境变量读取允许的方法和头
  methods: process.env.ALLOWED_METHODS.split(','),
  headers: process.env.ALLOWED_HEADERS.split(',')
}));

第三章 生产环境最佳实践:安全与性能优化

3.1 安全加固策略

3.1.1 严格限制允许的源

  • 禁止使用* :无论是否携带凭证,生产环境必须使用白名单

  • 白名单来源

    • 前端正式环境域名(如https://example.com
    • 移动端 API 域名(如api.example.com
    • 第三方合作平台域名(需提前审核)

3.1.2 防范 CSRF 攻击

  • 启用SameSite Cookie 属性:

    ctx.cookies.set('sessionId', 'xxx', {
      sameSite: 'strict', // 严格模式,禁止跨站发送Cookie
      secure: true, // 仅通过HTTPS传输
      httpOnly: true // 禁止JS读取Cookie
    });
    
  • 验证Referer头(配合 CORS 白名单):

    app.use(async (ctx, next) => {
      const referer = ctx.get('Referer');
      if (referer && !ALLOWED_ORIGINS.has(new URL(referer).origin)) {
        ctx.throw(403, 'Invalid referer');
      }
      await next();
    });
    

3.1.3 限制请求方法与头

  • 仅允许必要的 HTTP 方法(如GET/POST),禁止危险方法(如PUT/DELETE直接暴露在外网)

  • 严格控制允许的请求头,避免开放*

    // 错误示例(开放所有头,存在安全风险)
    ctx.set('Access-Control-Allow-Headers', '*');
    
    // 正确示例(仅允许必要头)
    ctx.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    

3.2 性能优化

3.2.1 预检请求缓存

通过Access-Control-Max-Age头设置预检结果缓存时间(单位:秒),避免重复 OPTIONS 请求:

ctx.set('Access-Control-Max-Age', '86400'); // 缓存24小时

3.2.2 静态资源与 API 分离

  • 静态资源(JS/CSS/ 图片)通过 CDN 分发,设置Access-Control-Allow-Origin: *(无需凭证)
  • API 接口单独部署,严格限制允许的源并启用凭证校验

第四章 典型场景解决方案

4.1 前后端分离开发(本地调试)

前端环境:

  • 开发服务器地址:http://localhost:8080
  • 后端 API 地址:http://localhost:3000

Koa2 配置:

const ALLOWED_ORIGINS = new Set([
  'http://localhost:8080', // 前端开发地址
  'http://127.0.0.1:8080' // 兼容不同本地IP访问
]);

// 开发环境允许动态添加源(仅用于调试)
if (process.env.NODE_ENV === 'development') {
  ALLOWED_ORIGINS.add(ctx.get('Origin')); // 临时信任首次请求的源
}

前端请求:

fetch('http://localhost:3000/api/data', {
  credentials: 'include', // 携带本地Cookie(如登录态)
});

4.2 多前端应用共享 API(如 Web+App + 小程序)

允许的源列表:

const ALLOWED_ORIGINS = new Set([
  'https://web.example.com', // Web端
  'https://app.example.com', // 移动端App
  'https://miniprogram.example.com' // 小程序
]);

差异化配置:

app.use(async (ctx, next) => {
  const origin = ctx.get('Origin');
  if (ALLOWED_ORIGINS.has(origin)) {
    ctx.set('Access-Control-Allow-Origin', origin);
    
    // 根据不同源返回不同响应头
    if (origin.includes('miniprogram')) {
      ctx.set('Access-Control-Allow-Headers', 'Content-Type'); // 小程序仅需基础头
    } else {
      ctx.set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); // Web端需要认证头
    }
  }
  await next();
});

4.3 第三方应用授权访问(需严格审核)

场景:

允许经过认证的第三方应用(如partner.example.com)访问 API,但需限制 IP 来源。

实现方案:

app.use(async (ctx, next) => {
  const origin = ctx.get('Origin');
  const clientIp = ctx.ip; // 获取客户端IP
  
  // 校验源与IP绑定关系
  if (origin === 'https://partner.example.com' && clientIp === '192.168.1.100') {
    ctx.set('Access-Control-Allow-Origin', origin);
    ctx.set('Access-Control-Allow-Credentials', 'true');
  }
  await next();
});

第五章 常见问题与排错指南

5.1 浏览器报错:Access-Control-Allow-Origin缺失

可能原因:

  1. 服务端未返回Access-Control-Allow-Origin
  2. 源校验失败,服务端未设置任何允许的源

解决方案:

  • 通过浏览器开发者工具(F12)查看响应头,确认是否存在该头
  • 检查白名单是否包含当前请求的源(包括端口)

5.2 凭证未携带(Cookie 未发送到服务端)

可能原因:

  1. Access-Control-Allow-Credentials未设置为true
  2. 服务端返回的Access-Control-Allow-Origin与请求的源不一致(如缺少端口)

解决方案:

// 确保同时设置两者
ctx.set('Access-Control-Allow-Origin', 'https://web.example.com:8080');
ctx.set('Access-Control-Allow-Credentials', 'true');

5.3 预检请求(OPTIONS)失败

可能原因:

  1. 未处理 OPTIONS 请求,返回 404 或其他错误状态码
  2. Access-Control-Allow-Methods未包含实际请求的方法(如 POST 请求但仅允许 GET)

解决方案:

// 手动处理OPTIONS请求
app.use(async (ctx, next) => {
  if (ctx.method === 'OPTIONS') {
    ctx.status = 204;
    ctx.set('Access-Control-Allow-Methods', 'GET, POST'); // 包含实际使用的方法
    return;
  }
  await next();
});

第六章 完整项目示例:Koa2 + Vue3 跨域实战

6.1 项目结构

koa2-api/
├── src/
│   ├── app.js               # Koa主文件
│   ├── middleware/
│   │   └── cors.js          # CORS中间件
│   └── routes/
│       └── api.js           # API路由
├── package.json
└── config/
    └── cors.js              # CORS配置文件

6.2 配置文件(config/cors.js

module.exports = {
  allowedOrigins: new Set([
    'https://vue.example.com', // Vue生产环境
    'http://localhost:8080'    // Vue开发环境
  ]),
  credentials: true,
  maxAge: 86400
};

6.3 CORS 中间件(middleware/cors.js

const { allowedOrigins, credentials, maxAge } = require('../config/cors');

module.exports = async (ctx, next) => {
  const origin = ctx.get('Origin');
  if (allowedOrigins.has(origin)) {
    ctx.set('Access-Control-Allow-Origin', origin);
    ctx.set('Access-Control-Allow-Credentials', String(credentials));
    ctx.set('Access-Control-Max-Age', String(maxAge));
  }

  if (ctx.method === 'OPTIONS') {
    ctx.set('Access-Control-Allow-Methods', 'GET, POST');
    ctx.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    ctx.status = 204;
    return;
  }

  await next();
};

6.4 主文件(src/app.js

const Koa = require('koa');
const cors = require('./middleware/cors');
const apiRouter = require('./routes/api');

const app = new Koa();

app.use(cors);
app.use(apiRouter.routes());

app.listen(3000, () => {
  console.log('Koa2 API server running on port 3000');
});

6.5 前端(Vue3)请求示例

<template>
  <button @click="fetchData">获取数据</button>
</template>

<script setup>
import { ref } from 'vue';
import axios from 'axios';

const data = ref('');

const fetchData = async () => {
  try {
    const response = await axios.get('https://api.example.com/api/data', {
      withCredentials: true, // 携带Cookie
      headers: {
        Authorization: `Bearer ${localStorage.getItem('token')}`
      }
    });
    data.value = response.data;
  } catch (error) {
    console.error('跨域请求失败:', error);
  }
};
</script>

第七章 未来趋势:跨域方案的演进

7.1 HTTP2 与跨域优化

HTTP2 的多路复用特性可减少跨域请求的性能损耗,配合Alt-Svc头实现域名切换:

Alt-Svc: h2=":443"; ma=2592000

7.2 边缘计算与 CORS 卸载

通过 CDN 边缘节点处理 CORS 头,减轻源站压力:

# 示例Nginx配置
location /api/ {
  proxy_pass http://koa-server;
  add_header Access-Control-Allow-Origin $http_origin;
  add_header Access-Control-Allow-Credentials true;
}

7.3 同站策略(SameSite)替代跨域

通过调整 Cookie 的SameSite属性,将跨域请求转为同站请求(需前后端共享域名):

// 后端设置
ctx.cookies.set('sessionId', 'xxx', {
  sameSite: 'lax', // 允许跨站GET请求携带Cookie
  domain: '.example.com' // 共享同一域名
});

结语

withCredentials场景下,Koa2 通过动态源校验、中间件封装及安全策略配置,可有效解决跨域凭证携带问题。核心原则是:严格限制允许的源,避免滥用通配符,始终遵循最小权限原则。通过结合环境变量、配置文件及安全加固措施,既能满足开发效率需求,又能保障生产环境的安全性。未来随着浏览器策略的升级,建议持续关注 CORS 规范的演进,采用更高效的同站策略或边缘计算方案优化跨域体验。

React 闭包陷阱攻防:函数式编程思想的应用

在 React 开发中,闭包陷阱是一个常见且令人头疼的问题。当我们在 useEffectuseCallbacksetTimeoutsetInterval 或其他异步回调中使用 stateprops 时,这些函数会捕获其定义时作用域内的变量值。如果这些值后续发生了变化,而闭包本身没有被重新创建(例如,useEffect 的依赖项数组不正确或为空),那么回调函数内部引用的依然是陈旧的数据,导致各种难以预料的 Bug。

函数式编程思想,尤其是纯函数的概念,为我们提供了优雅的解决方案来应对这些挑战。本文将重点介绍两种广泛应用且充分体现纯函数优势的方法:函数式更新useReducer

闭包陷阱的核心问题

简单来说,当一个函数(回调函数)在另一个函数(例如组件函数或 useEffect 的 setup 函数)内部定义时,它会“记住”其外部作用域的变量。如果外部变量更新了,但这个回调函数没有重新创建以捕获新的值,它就会继续使用旧的值。

function MyComponent() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // 假设这个 effect 只在组件挂载时运行一次
    const intervalId = setInterval(() => {
      // 问题:这里的 count 是 effect 创建时捕获的初始值 0
      // 即使外部 count 已经通过 setCount 更新,这里仍然是 0
      console.log('Stale count:', count);
      // setCount(count + 1); // 这样做会导致 count 永远是 1
    }, 1000);

    return () => clearInterval(intervalId);
  }, []); // 空依赖数组,effect 只运行一次

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

1. 函数式更新 (Functional Updates)

React 的 setState 函数(来自 useState Hook)提供了一种强大的机制来避免闭包陷阱:函数式更新。你可以传递一个函数给 setState,这个函数的参数是当前最新的状态值,返回值是新的状态。

原理与纯函数特性

当你使用 setCount(prevCount => prevCount + 1) 时:

  1. React 会确保传递给你的回调函数 (prevCount => ...) 的 prevCount 参数始终是最新的状态值,无论这个 setCount 调用是在哪个闭包中。
  2. 你传递给 setState 的回调函数 prevState => newState 本身就是一个纯函数。它接收当前状态,计算并返回新状态,不依赖外部变量,也没有副作用。

代码示例

import React, { useState, useEffect } from 'react';

function IntervalCounter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      // 正确:使用函数式更新
      // prevCount 总是最新的 count 值
      setCount(prevCount => prevCount + 1);
    }, 1000);

    // 清理函数
    return () => clearInterval(intervalId);
  }, []); // 依赖数组为空,setInterval 只设置一次,但 setCount 依然能获取最新状态

  return <h1>Count: {count}</h1>;
}

export default IntervalCounter;

常用场景

  • setInterval / setTimeout 回调:如上例,当需要在定时器回调中基于前一个状态更新状态时,函数式更新是首选,因为你不需要将 state 加入 useEffect 的依赖数组,避免了不必要的定时器重置。
  • 异步操作回调:在 fetch 或其他异步操作的 thencatch 回调中更新状态,如果该回调是在 useEffect 中定义且不希望因 state 变化而重新触发 useEffect
    useEffect(() => {
      fetchData().then(newData => {
        setData(prevData => ({ ...prevData, ...newData }));
      });
    }, []); // 假设 fetchData 和 setData 引用稳定
    
  • 复杂的事件处理器:当事件处理器需要在多次触发后累积或修改状态,而事件处理器本身通过 useCallback 缓存且不希望因依赖的状态变化而频繁重建。
    const handleScroll = useCallback(() => {
      // ... 一些计算
      setScrollPositions(prevPositions => [...prevPositions, window.scrollY]);
    }, []); // 如果 setScrollPositions 不依赖其他 state/props
    
  • 任何你希望在回调中安全地更新状态,而不必担心闭包捕获了旧状态的场景。

2. 使用 useReducer 管理复杂状态

对于更复杂的状态逻辑,或者当下一个状态依赖于前一个状态并且涉及到多个子值时,useReducer 是一个更强大的选择。

原理与纯函数特性

const [state, dispatch] = useReducer(reducer, initialState);

  1. Reducer 函数的纯粹性reducer(currentState, action) 函数本身被设计为纯函数。它接收当前状态和描述操作的 action 对象,然后返回一个新的状态对象。它不修改原状态,也没有副作用。
  2. dispatch 函数的稳定性:React 保证 dispatch 函数的引用在组件的整个生命周期内是稳定的。这意味着你可以安全地将其传递给子组件或在 useEffectuseCallback 的回调中使用,而无需将其添加到依赖数组中(ESLint 插件通常会自动处理或允许忽略)。

代码示例

import React, { useReducer, useEffect } from 'react';

// 初始状态
const initialState = {
  count: 0,
  step: 1,
  lastAction: null,
};

// Reducer 纯函数
function counterReducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + state.step, lastAction: 'increment' };
    case 'decrement':
      return { ...state, count: state.count - state.step, lastAction: 'decrement' };
    case 'setStep':
      return { ...state, step: action.payload, lastAction: 'setStep' };
    case 'reset':
      return { ...initialState, lastAction: 'reset' }; // 重置到初始状态
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

function ComplexCounter() {
  const [state, dispatch] = useReducer(counterReducer, initialState);

  useEffect(() => {
    // 示例:一个异步操作,完成后需要更新计数
    const simulateAsyncIncrement = () => {
      setTimeout(() => {
        // dispatch 是稳定的,可以直接在回调中使用
        // reducer 函数会接收到最新的 state
        dispatch({ type: 'increment' });
        console.log('Dispatched increment from timeout.');
      }, 1500);
    };

    if (state.count < 5 && state.lastAction !== 'increment') { // 仅在特定条件下触发
        simulateAsyncIncrement();
    }

    // 注意:如果 useEffect 逻辑依赖 state 中的某些值,
    // 应该将这些值加入依赖数组,以确保逻辑在它们变化时重新运行。
    // 这里为了演示 dispatch 的稳定性,假设异步操作的触发条件不频繁改变。
  }, [state.count, state.lastAction]); // 依赖 state 中的值

  return (
    <div>
      <p>Count: {state.count} (Step: {state.step})</p>
      <p>Last Action: {state.lastAction || 'None'}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>Increment</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>Decrement</button>
      <button onClick={() => dispatch({ type: 'setStep', payload: state.step + 1 })}>Increase Step</button>
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
    </div>
  );
}

export default ComplexCounter;

常用场景

  • 复杂的状态对象:当你的组件状态是一个包含多个字段的对象,并且这些字段之间可能存在依赖关系或需要一起更新时。
  • 状态转换逻辑复杂:当状态的下一个值不仅仅是前一个值的简单修改,而是需要根据不同的“动作”(action)类型执行不同的计算逻辑。
  • 多个事件处理器更新同一状态:如果多个用户交互(如按钮点击、表单提交等)都会以不同方式影响同一块状态数据。
  • 状态逻辑需要跨组件共享或易于测试:Reducer 函数是纯函数,易于独立测试。结合 Context API,useReducer 可以用于管理全局或局部共享的状态。
  • 优化性能:由于 dispatch 函数引用稳定,传递给子组件时不会导致不必要的重渲染(如果子组件用了 React.memo)。

其他重要的辅助策略

虽然函数式更新和 useReducer 是解决闭包问题的核心函数式方法,但以下策略同样重要:

  1. 正确使用React Hooks的依赖数组 (useEffect, useCallback, useMemo): 这是React官方推荐的确保闭包捕获最新值的方式。务必将回调函数内部引用的所有会随时间变化的 propsstate 都列入依赖数组。ESLint 插件 eslint-plugin-react-hooks 对此有很大帮助。

  2. 使用 useRef 存储可变值 (作为“逃生舱口”): 在某些情况下,你可能确实需要一个在多次渲染之间保持不变的引用,并且其 .current 属性可以被修改而不会触发重新渲染。你可以用它来存储那些你想在回调中访问的最新值,而又不想让回调本身因为这些值的变化而重新创建(例如,避免频繁添加/移除事件监听器,但监听器内部需要最新状态)。

    const latestCountRef = useRef(count);
    useEffect(() => {
      latestCountRef.current = count; // 每次 count 变化时更新 ref
    });
    
    const handleClick = useCallback(() => {
      setTimeout(() => {
        console.log('Latest count via ref:', latestCountRef.current);
      }, 1000);
    }, []); // handleClick 只创建一次
    

    注意:修改 ref.current 不会触发组件重新渲染。

总结

通过拥抱函数式编程思想,特别是利用纯函数(如函数式更新的回调和 Reducer 函数)以及 React Hooks 提供的机制,我们可以有效地避免和解决闭包陷阱带来的问题。函数式更新和 useReducer 不仅使状态管理更加清晰、可预测,还因为其对纯粹性和不变性的强调,使得代码更易于理解、测试和维护。

在实际开发中,根据状态的复杂度和更新逻辑的特性,灵活选择最适合的策略,是编写高质量 React 应用的关键。

常用DOM

目录

获取DOM节点

通过ID查找节点

通过标签名查找节点

通过类名查找节点

通过CSS选择器查找单个节点

通过CSS选择器查找所有节点

通过关系获取节点

获取下一个兄弟节点

获取上一个兄弟节点

获取所有子节点(元素节点)

获取父级节点

创建、删除、替换和克隆节点

创建节点

删除节点(自删除)

删除子节点

替换节点

克隆节点

操作节点属性

设置属性

获取属性

删除属性

类名操作

直接设置类名(会覆盖原有的类名)

使用classList操作类名

向标签内部写入内容

写入文本

事件处理

基本事件

阻止事件冒泡

阻止默认行为

输入框事件

键盘事件

绑定事件(不推荐,因为无法绑定多个事件处理器)

事件监听(推荐方式)

移除事件监听


获取DOM节点

  1. 通过ID查找节点

    let element = document.getElementById('elementId');
    

  2. 通过标签名查找节点

    let elements = document.getElementsByTagName('tagName'); // 返回HTMLCollection
    

  3. 通过类名查找节点

    let elements = document.getElementsByClassName('className'); 
    // 返回HTMLCollection
    

  4. 通过CSS选择器查找单个节点

    let element = document.querySelector('.className #elementId'); 
    // 返回第一个匹配的元素
    

  5. 通过CSS选择器查找所有节点

    let elements = document.querySelectorAll('.className, #elementId'); 
    // 返回NodeList
    

通过关系获取节点

  1. 获取下一个兄弟节点

    let nextSibling = element.nextElementSibling;
    

  2. 获取上一个兄弟节点

    let prevSibling = element.previousElementSibling;
    

  3. 获取所有子节点(元素节点)

    let children = element.children; // 返回HTMLCollection,只包含元素节点
    

  4. 获取父级节点

    let parent = element.parentNode; 
    //如果需要父元素节点,可以使用element.parentElement
    

创建、删除、替换和克隆节点

  1. 创建节点

    let newElement = document.createElement('tagName');
    

  2. 删除节点(自删除)

    element.remove();
    

  3. 删除子节点

    parentElement.removeChild(childNode);
    

  4. 替换节点

    parentElement.replaceChild(newNode, oldNode);
    

  5. 克隆节点

    let clonedNode = originalNode.cloneNode(true); 
    // true表示深拷贝,false表示浅拷贝
    

操作节点属性

  1. 设置属性

    element.setAttribute('attributeName', 'value');
    

  2. 获取属性

    let value = element.getAttribute('attributeName');
    

  3. 删除属性

    element.removeAttribute('attributeName');
    

类名操作

  1. 直接设置类名(会覆盖原有的类名)

    element.className = 'newClassName';
    

  2. 使用classList操作类名

    element.classList.add('className'); // 添加
    element.classList.remove('className'); // 删除
    element.classList.toggle('className'); // 切换
    let hasClass = element.classList.contains('className'); // 判断是否存在
    

向标签内部写入内容

  1. 写入文本

    element.innerText = 'text'; // 不会解析HTML
    element.textContent = 'text'; // 与innerText类似,但兼容性更好
    

  2. 写入文本或标签

    element.innerHTML = '<p>HTML content</p>'; // 会解析HTML
    

事件处理

  1. 基本事件

    element.onclick = function() { /* code */ };
    element.onmouseenter = function() { /* code */ };
    element.onmouseleave = function() { /* code */ };
    element.onmousemove = function() { /* code */ };
    

  2. 阻止事件冒泡

    event.stopPropagation();
    

  3. 阻止默认行为

    event.preventDefault();
    

  4. 输入框事件

    inputElement.onblur = function() { /* code when element loses focus */ };
    inputElement.onfocus = function() { /* code when element gains focus */ };
    inputElement.oninput = function() { /* code when input value changes */ };
    inputElement.onchange = function() { 
    /* code when input value is committed (e.g., after losing focus and value has changed) */ 
    };
    

  5. 键盘事件

    element.onkeydown = function(event) { 
    /* code; event.keyCode is deprecated, use event.key or event.code */ 
    };
    element.onkeyup = function(event) { /* code */ };
    

  6. 绑定事件(不推荐,因为无法绑定多个事件处理器)

    element.onclick = function() { /* code */ };
    

  7. 事件监听(推荐方式)

    element.addEventListener('click', function(event) { /* code */ });
    element.addEventListener('mouseenter', function(event) { /* code */ }, false); 
    // 第三个参数为捕获阶段(可选,默认为false,即冒泡阶段)
    

  8. 移除事件监听

    element.removeEventListener('click', functionReference); 
    // 必须传递与添加时相同的函数引用
    

注意:event.keyCode 在现代浏览器中已被弃用,建议使用 event.key 或 event.code 来获取键盘事件的键值。同时,当使用 addEventListener 和 removeEventListener 时,传递给 removeEventListener 的函数必须与传递给 addEventListener 的函数完全相同(通常是同一个函数引用)。如果传递的是匿名函数,则无法正确移除事件监听器。

大学生常用-原生js实现:点击切换图片,轮播图,tab切换,分页符,秒杀等等......直接copy就能使

目录

一、交互组件类

轮播图实现

Tab切换功能

模态框 (Modal)

下拉菜单

手风琴效果

自定义右键菜单

二、表单处理类

表单验证增强

密码强度检测(输入安全相关)

三、数据存储类

本地存储记忆功能(localStorage/cookie操作)

四、动态内容处理

实时搜索过滤

动态加载更多内容

随机名言生成器

五、实用工具类

网页计时器/倒计时

复制到剪贴板

简易画板功能(图形交互工具)

六、用户体验优化

回到顶部按钮

图片懒加载(性能优化)

  原生js图片放大镜效果

暗黑模式切换

网页主题色切换

七、视觉效果类

点击切换图片(基础视觉交互)

滚动动画触发

视差滚动效果


除了以下功能,还想要什么功能可写评论,作者大大看到就会回复哦

一、交互组件类

轮播图实现

 index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>轮播图示例</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
    <div class="carousel">
        ![404](images/image1.jpg)
        ![457](images/image2.jpg)
        ![495](images/image3.jpg)
        <!-- 可以继续添加更多图片 -->
        <div class="controls">
            <button id="prev">Prev</button>
            <button id="next">Next</button>
        </div>
    </div>
    <script src="script.js"></script>
</body>
</html>

styles.css

.carousel {
    position: relative;
    width: 600px;
    height: 400px;
    overflow: hidden;
    margin: 0 auto;
    border: 2px solid #ddd;
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}

.carousel img {
    width: 100%;
    height: 100%;
    display: none;
    transition: opacity 0.5s ease;
}

.carousel img.active {
    display: block;
    opacity: 1;
}

.controls {
    position: absolute;
    top: 50%;
    width: 100%;
    display: flex;
    justify-content: space-between;
    transform: translateY(-50%);
}

.controls button {
    background-color: rgba(0, 0, 0, 0.5);
    color: white;
    border: none;
    padding: 10px;
    cursor: pointer;
    border-radius: 5px;
    transition: background-color 0.3s ease;
}

.controls button:hover {
    background-color: rgba(0, 0, 0, 0.7);
}

script.js

document.addEventListener('DOMContentLoaded', () => {
    let currentIndex = 0;
    const images = document.querySelectorAll('.carousel img');
    const prevButton = document.getElementById('prev');
    const nextButton = document.getElementById('next');

    function showImage(index) {
        images.forEach((img, i) => {
            img.classList.toggle('active', i === index);
        });
    }

    function showNextImage() {
        currentIndex = (currentIndex + 1) % images.length;
        showImage(currentIndex);
    }

    function showPrevImage() {
        currentIndex = (currentIndex - 1 + images.length) % images.length;
        showImage(currentIndex);
    }

    nextButton.addEventListener('click', showNextImage);
    prevButton.addEventListener('click', showPrevImage);

    // 自动播放功能(可选)
    setInterval(showNextImage, 3000); // 每3秒切换一次图片

    // 添加淡入淡出效果
    images.forEach(img => {
        img.addEventListener('transitionend', () => {
            if (!img.classList.contains('active')) {
                img.style.opacity = 0;
            }
        });
    });

    // 初始化时设置非活动图片的透明度
    images.forEach((img, i) => {
        if (i !== currentIndex) {
            img.style.opacity = 0;
        }
    });
});

Tab切换功能

 html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Tab Switch</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
    <div class="tabs">
        <button class="tab-button" data-tab="1">Tab 1</button>
        <button class="tab-button" data-tab="2">Tab 2</button>
        <button class="tab-button" data-tab="3">Tab 3</button>
    </div>
    <div class="tab-content">
        <div class="tab-pane" id="tab-1">Content for Tab 1</div>
        <div class="tab-pane" id="tab-2">Content for Tab 2</div>
        <div class="tab-pane" id="tab-3">Content for Tab 3</div>
    </div>
    <script src="script.js"></script>
</body>
</html>

css

/* styles.css */
body {
    font-family: Arial, sans-serif;
}

.tabs {
    display: flex;
    margin-bottom: 20px;
}

.tab-button {
    padding: 10px 20px;
    margin-right: 5px;
    background-color: #f0f0f0;
    border: 1px solid #ccc;
    cursor: pointer;
}

.tab-button:hover {
    background-color: #e0e0e0;
}

.tab-pane {
    display: none;
    padding: 20px;
    border: 1px solid #ccc;
    border-radius: 5px;
}

.tab-pane.active {
    display: block;
}

js

// script.js
document.addEventListener('DOMContentLoaded', function() {
    const tabButtons = document.querySelectorAll('.tab-button');
    const tabPanes = document.querySelectorAll('.tab-pane');

    tabButtons.forEach(button => {
        button.addEventListener('click', function() {
            tabButtons.forEach(b => b.classList.remove('active'));
            tabPanes.forEach(p => p.classList.remove('active'));

            const tabId = this.getAttribute('data-tab');
            this.classList.add('active');
            document.getElementById(`tab-${tabId}`).classList.add('active');
        });
    });

    if (tabButtons.length > 0) {
        tabButtons[0].click();
    }
});

模态框 (Modal)

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>模态框示例</title>
  <style>
    .modal {
      display: none;
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background-color: rgba(0, 0, 0, 0.5);
      z-index: 1000;
    }
    .modal-content {
      position: relative;
      margin: 10% auto;
      padding: 20px;
      width: 80%;
      max-width: 500px;
      background-color: white;
      border-radius: 5px;
    }
    .close {
      position: absolute;
      top: 10px;
      right: 15px;
      font-size: 24px;
      cursor: pointer;
    }
    .open-modal {
      padding: 10px 20px;
      background-color: #4CAF50;
      color: white;
      border: none;
      border-radius: 5px;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <button class="open-modal">打开模态框</button>
  <div id="modal" class="modal">
    <div class="modal-content">
      <span class="close">&times;</span>
      <h2>模态框标题</h2>
      <p>这是一个模态框内容。</p>
    </div>
  </div>
  <script>
    const modal = document.getElementById('modal');
    const openBtn = document.querySelector('.open-modal');
    const closeBtn = document.querySelector('.close');

    openBtn.onclick = () => {
      modal.style.display = 'block';
    };

    closeBtn.onclick = () => {
      modal.style.display = 'none';
    };

    window.onclick = (e) => {
      if (e.target === modal) {
        modal.style.display = 'none';
      }
    };
  </script>
</body>
</html>

下拉菜单

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>下拉菜单示例</title>
  <style>
    .dropdown {
      position: relative;
      display: inline-block;
    }
    .dropdown-content {
      display: none;
      position: absolute;
      background-color: white;
      min-width: 160px;
      box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
      z-index: 1;
    }
    .dropdown-content a {
      color: black;
      padding: 12px 16px;
      text-decoration: none;
      display: block;
    }
    .dropdown-content.show {
      display: block;
    }
    .dropdown-btn {
      padding: 10px 20px;
      background-color: #4CAF50;
      color: white;
      border: none;
      border-radius: 5px;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <div class="dropdown">
    <button class="dropdown-btn">下拉菜单</button>
    <div class="dropdown-content">
      <a href="#">链接 1</a>
      <a href="#">链接 2</a>
      <a href="#">链接 3</a>
    </div>
  </div>
  <script>
    const dropdown = document.querySelector('.dropdown');
    const dropdownContent = document.querySelector('.dropdown-content');

    dropdown.addEventListener('mouseover', () => {
      dropdownContent.classList.add('show');
    });

    dropdown.addEventListener('mouseout', () => {
      dropdownContent.classList.remove('show');
    });
  </script>
</body>
</html>

手风琴效果

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>手风琴效果示例</title>
  <style>
    .accordion {
      margin: 20px 0;
    }
    .accordion-header {
      padding: 10px;
      background-color: #f1f1f1;
      cursor: pointer;
      display: flex;
      justify-content: space-between;
    }
    .accordion-content {
      padding: 0;
      max-height: 0;
      overflow: hidden;
      transition: max-height 0.3s ease-out;
    }
    .accordion-content.active {
      padding: 10px;
      max-height: 200px;
    }
    .arrow {
      transition: transform 0.3s ease;
    }
    .arrow.rotate {
      transform: rotate(180deg);
    }
  </style>
</head>
<body>
  <div class="accordion">
    <div class="accordion-header">
      <span>标题 1</span>
      <span class="arrow"></span>
    </div>
    <div class="accordion-content">
      <p>这是手风琴内容 1。</p>
    </div>
  </div>
  <div class="accordion">
    <div class="accordion-header">
      <span>标题 2</span>
      <span class="arrow"></span>
    </div>
    <div class="accordion-content">
      <p>这是手风琴内容 2。</p>
    </div>
  </div>
  <script>
    document.querySelectorAll('.accordion-header').forEach(header => {
      header.addEventListener('click', () => {
        const content = header.nextElementSibling;
        content.classList.toggle('active');
        header.querySelector('.arrow').classList.toggle('rotate');
      });
    });
  </script>
</body>
</html>

自定义右键菜单

        自定义右键菜单需要阻止默认的右键菜单,显示自定义的菜单,并根据点击位置定位。同时,点击页面其他位置时要隐藏菜单。这里可能需要动态创建菜单元素,并处理事件冒泡。

<div id="customMenu" style="display:none; position:absolute; background:#fff; box-shadow:0 0 5px #999">
  <div class="menu-item">复制</div>
  <div class="menu-item">粘贴</div>
  <div class="menu-item">刷新</div>
</div>

<script>
document.addEventListener('contextmenu', (e) => {
  e.preventDefault();
  const menu = document.getElementById('customMenu');
  menu.style.display = 'block';
  menu.style.left = e.pageX + 'px';
  menu.style.top = e.pageY + 'px';
});

document.addEventListener('click', () => {
  document.getElementById('customMenu').style.display = 'none';
});
</script>


二、表单处理类

表单验证增强

<form id="myForm">
  <input type="email" id="email" placeholder="邮箱">
  <div class="error" id="emailError"></div>
  
  <input type="password" id="password" placeholder="密码(6-12位)">
  <div class="error" id="pwdError"></div>

  <button type="submit">提交</button>
</form>

<script>
const form = document.getElementById('myForm');

// 实时验证
document.getElementById('email').addEventListener('input', (e) => {
  const email = e.target.value;
  const regex = /^[^\s@]+@[^\s@]+.[^\s@]+$/;
  document.getElementById('emailError').textContent = 
    regex.test(email) ? '' : '邮箱格式不正确';
});

// 提交验证
form.addEventListener('submit', (e) => {
  e.preventDefault();
  const pwd = document.getElementById('password').value;
  
  if(pwd.length < 6 || pwd.length > 12) {
    document.getElementById('pwdError').textContent = '密码长度需6-12位';
    return;
  }
  
  // 验证通过后提交
  form.submit();
});
</script>

密码强度检测(输入安全相关)

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>密码强度检测示例</title>
  <style>
    #password {
      width: 100%;
      padding: 10px;
      margin-bottom: 20px;
    }
    #strengthMeter {
      width: 100%;
      height: 10px;
      background-color: #ddd;
      border-radius: 5px;
      margin-bottom: 10px;
    }
    #strengthMeterFill {
      height: 100%;
      width: 0;
      border-radius: 5px;
      transition: width 0.3s, background-color 0.3s;
    }
    #strengthText {
      font-size: 14px;
      color: #666;
    }
  </style>
</head>
<body>
  <input type="password" id="password" placeholder="输入密码">
  <div id="strengthMeter">
    <div id="strengthMeterFill"></div>
  </div>
  <div id="strengthText"></div>
  <script>
    document.getElementById('password').addEventListener('input', (e) => {
      const password = e.target.value;
      const meterFill = document.getElementById('strengthMeterFill');
      const strengthText = document.getElementById('strengthText');
      const strength = calculateStrength(password);
      meterFill.style.width = strength + '%';
      meterFill.style.backgroundColor = getStrengthColor(strength);
      strengthText.textContent = getStrengthText(strength);
    });

    function calculateStrength(password) {
      let strength = 0;
      if (password.length >= 8) strength += 25;
      if (/[A-Z]/.test(password)) strength += 25;
      if (/[a-z]/.test(password)) strength += 25;
      if (/[0-9]/.test(password)) strength += 25;
      return strength;
    }

    function getStrengthColor(strength) {
      if (strength < 25) return '#ff0000';
      if (strength < 50) return '#ffaa00';
      if (strength < 75) return '#ffdd00';
      return '#4CAF50';
    }

    function getStrengthText(strength) {
      if (strength < 25) return '弱';
      if (strength < 50) return '一般';
      if (strength < 75) return '强';
      return '非常强';
    }
  </script>
</body>
</html>


三、数据存储类

本地存储记忆功能(localStorage/cookie操作)

        本地存储记忆功能。这需要利用localStorage来保存用户的数据,比如表单输入或主题设置。在页面加载时读取存储的数据,并在数据变化时更新存储。例如,记住用户的搜索历史或表单填写内容。

// 保存数据
const saveData = () => {
  const data = {
    username: document.getElementById('name').value,
    theme: document.body.classList.contains('dark') ? 'dark' : 'light'
  };
  localStorage.setItem('userPrefs', JSON.stringify(data));
};

// 读取数据
window.addEventListener('load', () => {
  const saved = JSON.parse(localStorage.getItem('userPrefs'));
  if(saved) {
    document.getElementById('name').value = saved.username;
    if(saved.theme === 'dark') document.body.classList.add('dark');
  }
});

// 输入时自动保存
document.getElementById('name').addEventListener('input', saveData);


四、动态内容处理

实时搜索过滤

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>实时搜索过滤示例</title>
  <style>
    #searchInput {
      width: 100%;
      padding: 10px;
      margin-bottom: 20px;
    }
    .item {
      padding: 10px;
      margin-bottom: 5px;
      background-color: #f1f1f1;
    }
  </style>
</head>
<body>
  <input type="text" id="searchInput" placeholder="搜索...">
  <div class="item">项目 1</div>
  <div class="item">项目 2</div>
  <div class="item">项目 3</div>
  <div class="item">项目 4</div>
  <div class="item">项目 5</div>
  <script>
    document.getElementById('searchInput').addEventListener('input', (e) => {
      const searchTerm = e.target.value.toLowerCase();
      document.querySelectorAll('.item').forEach(item => {
        const text = item.textContent.toLowerCase();
        item.style.display = text.includes(searchTerm) ? 'block' : 'none';
      });
    });
  </script>
</body>
</html>

动态加载更多内容

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>动态加载更多内容示例</title>
  <style>
    #loadMore {
      padding: 10px 20px;
      background-color: #4CAF50;
      color: white;
      border: none;
      border-radius: 5px;
      cursor: pointer;
    }
    .item {
      padding: 10px;
      margin-bottom: 5px;
      background-color: #f1f1f1;
    }
  </style>
</head>
<body>
  <div id="content">
    <div class="item">项目 1</div>
    <div class="item">项目 2</div>
    <div class="item">项目 3</div>
  </div>
  <button id="loadMore">加载更多</button>
  <script>
    let currentPage = 1;
    const loadMoreBtn = document.getElementById('loadMore');

    loadMoreBtn.addEventListener('click', () => {
      currentPage++;
      // 模拟数据加载
      setTimeout(() => {
        const data = [`项目 ${currentPage * 3 - 2}`, `项目 ${currentPage * 3 - 1}`, `项目 ${currentPage * 3}`];
        renderItems(data);
      }, 1000);
    });

    function renderItems(data) {
      const content = document.getElementById('content');
      data.forEach(item => {
        const div = document.createElement('div');
        div.className = 'item';
        div.textContent = item;
        content.appendChild(div);
      });
    }
  </script>
</body>
</html>

随机名言生成器

        随机名言生成器需要预定义名言数组,随机选择一条显示,并可能通过按钮点击切换。如果用户需要,可以扩展为从API获取数据,但当前需求是纯JS,所以先使用本地数据。

<div id="quoteDisplay"></div>
<button onclick="newQuote()">新语录</button>

<script>
const quotes = [
  "代码写得好,头发掉得少",
  "Ctrl+C/V 是程序员最高礼仪",
  "程序不工作?试试console.log()",
  "永远相信:下一个版本会更好"
];

function newQuote() {
  const randomIndex = Math.floor(Math.random() * quotes.length);
  document.getElementById('quoteDisplay').textContent = quotes[randomIndex];
}

// 初始化
newQuote();
</script>


五、实用工具类

网页计时器/倒计时

        网页计时器/倒计时需要用到setInterval来更新时间显示,处理开始、暂停和重置功能。需要注意时间的格式化,如补零显示,并在倒计时结束时触发事件。

<div id="timer">00:00:00</div>
<button onclick="startTimer()">开始</button>
<button onclick="pauseTimer()">暂停</button>

<script>
let seconds = 0;
let timerId;

function formatTime(sec) {
  const h = Math.floor(sec/3600).toString().padStart(2,'0');
  const m = Math.floor(sec%3600/60).toString().padStart(2,'0');
  const s = (sec%60).toString().padStart(2,'0');
  return `${h}:${m}:${s}`;
}

function startTimer() {
  if(!timerId) {
    timerId = setInterval(() => {
      seconds++;
      document.getElementById('timer').textContent = formatTime(seconds);
    }, 1000);
  }
}

function pauseTimer() {
  clearInterval(timerId);
  timerId = null;
}
</script>

复制到剪贴板

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>复制到剪贴板示例</title>
  <style>
    #copyText {
      width: 100%;
      padding: 10px;
      margin-bottom: 20px;
      background-color: #f1f1f1;
    }
    #copyBtn {
      padding: 10px 20px;
      background-color: #4CAF50;
      color: white;
      border: none;
      border-radius: 5px;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <div id="copyText">这是要复制的文本</div>
  <button id="copyBtn">复制到剪贴板</button>
  <script>
    document.getElementById('copyBtn').addEventListener('click', () => {
      const text = document.getElementById('copyText').innerText;
      navigator.clipboard.writeText(text)
        .then(() => {
          alert('已复制!');
        })
        .catch(err => {
          console.error('复制失败:', err);
          // 降级方案
          const textarea = document.createElement('textarea');
          textarea.value = text;
          document.body.appendChild(textarea);
          textarea.select();
          document.execCommand('copy');
          document.body.removeChild(textarea);
          alert('已复制!');
        });
    });
  </script>
</body>
</html>

简易画板功能(图形交互工具)

        简易画板功能需要处理canvas的鼠标事件,跟踪鼠标位置来绘制路径。可能需要设置画笔属性,如颜色和粗细,并实现清除画布的功能。

<canvas id="myCanvas" width="500" height="300"></canvas>
<button onclick="clearCanvas()">清空</button>

<script>
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
let isDrawing = false;

canvas.addEventListener('mousedown', startDrawing);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', stopDrawing);

function startDrawing(e) {
  isDrawing = true;
  ctx.beginPath();
  ctx.moveTo(e.offsetX, e.offsetY);
}

function draw(e) {
  if(!isDrawing) return;
  ctx.lineTo(e.offsetX, e.offsetY);
  ctx.stroke();
}

function stopDrawing() {
  isDrawing = false;
}

function clearCanvas() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
}
</script>


六、用户体验优化

回到顶部按钮

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>回到顶部按钮示例</title>
  <style>
    body {
      height: 2000px;
    }
    #topBtn {
      position: fixed;
      bottom: 20px;
      right: 20px;
      display: none;
      width: 50px;
      height: 50px;
      background-color: #4CAF50;
      color: white;
      border-radius: 50%;
      text-align: center;
      line-height: 50px;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <div style="height: 1500px; background-color: #f1f1f1;"></div>
  <button id="topBtn"></button>
  <script>
    window.onscroll = () => {
      document.getElementById('topBtn').style.display = 
        (document.body.scrollTop > 20 || document.documentElement.scrollTop > 20) ? 'block' : 'none';
    };

    document.getElementById('topBtn').onclick = () => {
      window.scrollTo({
        top: 0,
        behavior: 'smooth'
      });
    };

    window.onload = () => {
      document.getElementById('topBtn').style.display = 'none';
    };
  </script>
</body>
</html>

图片懒加载(性能优化)

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>图片懒加载示例</title>
  <style>
    .lazyload {
      width: 100%;
      height: 200px;
      background-color: #f1f1f1;
      margin: 20px 0;
    }
  </style>
</head>
<body>
  <img data-src="https://via.placeholder.com/800x200" class="lazyload" alt="Lazy-loaded image">
  <img data-src="https://via.placeholder.com/800x200" class="lazyload" alt="Lazy-loaded image">
  <img data-src="https://via.placeholder.com/800x200" class="lazyload" alt="Lazy-loaded image">
  <div style="height: 1000px;"></div>
  <script>
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const img = entry.target;
          img.src = img.dataset.src;
          observer.unobserve(img);
        }
      });
    });

    document.querySelectorAll('.lazyload').forEach(img => observer.observe(img));
  </script>
</body>
</html>

原生js图片放大镜效果

        图片放大镜效果比较复杂。需要监听鼠标移动事件,计算放大区域的位置和比例,使用canvas来绘制放大后的图像。可能需要处理图片的加载和缩放比例,确保放大镜跟随鼠标移动。

<div class="img-container">
  <img src="small.jpg" id="targetImg">
  <div id="magnifier" style="display:none; width:200px; height:200px; position:absolute"></div>
</div>

<script>
const img = document.getElementById('targetImg');
const magnifier = document.getElementById('magnifier');

img.addEventListener('mousemove', (e) => {
  const rect = img.getBoundingClientRect();
  const x = e.clientX - rect.left;
  const y = e.clientY - rect.top;
  
  magnifier.style.display = 'block';
  magnifier.style.background = `
    url(${img.src}) 
    no-repeat 
    ${-x*2}px ${-y*2}px
  `;
  magnifier.style.left = e.pageX + 15 + 'px';
  magnifier.style.top = e.pageY + 15 + 'px';
});

img.addEventListener('mouseleave', () => {
  magnifier.style.display = 'none';
});
</script>

暗黑模式切换

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>暗黑模式切换示例</title>
  <style>
    body {
      transition: background-color 0.3s, color 0.3s;
    }
    body.dark-mode {
      background-color: #333;
      color: white;
    }
    #darkModeToggle {
      padding: 10px 20px;
      background-color: #4CAF50;
      color: white;
      border: none;
      border-radius: 5px;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <button id="darkModeToggle">切换暗黑模式</button>
  <script>
    const toggle = document.getElementById('darkModeToggle');

    toggle.addEventListener('click', () => {
      document.body.classList.toggle('dark-mode');
      const theme = document.body.classList.contains('dark-mode') ? 'dark' : 'light';
      localStorage.setItem('theme', theme);
    });

    window.onload = () => {
      const savedTheme = localStorage.getItem('theme');
      if (savedTheme === 'dark') {
        document.body.classList.add('dark-mode');
      }
    };
  </script>
</body>
</html>

网页主题色切换

        网页主题色切换可以通过切换CSS类或直接修改样式属性,使用localStorage记住用户选择的主题。需要考虑如何动态改变主题颜色,并在页面加载时应用已保存的主题。

<button onclick="toggleTheme()">切换主题</button>

<script>
function toggleTheme() {
  const root = document.documentElement;
  const isDark = root.classList.toggle('dark-theme');
  
  // 保存主题状态
  localStorage.setItem('theme', isDark ? 'dark' : 'light');
}

// 初始化主题
document.addEventListener('DOMContentLoaded', () => {
  const savedTheme = localStorage.getItem('theme') || 'light';
  document.documentElement.classList.toggle('dark-theme', savedTheme === 'dark');
});

/* CSS需配合:
:root { --bg: #fff; --text: #333; }
.dark-theme { --bg: #222; --text: #fff; }
body { background: var(--bg); color: var(--text); }
*/
</script>


七、视觉效果类

点击切换图片(基础视觉交互)

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>产品展示/图片画廊</title>
<style>
    * {
        margin: 0;
        padding: 0;
    }
    .nav {
        width: 80%;
        margin: 0 auto;
        height: 65px;
        display: flex;
    }
    .nav span {
        flex: 1;
        height: 65px;
        text-align: center;
        line-height: 65px;
        font-size: 24px;
        font-weight: bold;
        cursor: pointer; /* 添加鼠标指针样式 */
    }
    .nav .siz {
        background: orange; /* 高亮显示的导航项背景色 */
    }
    .content {
        width: 80%;
        height: 500px;
        margin: 20px auto; /* 调整内容与导航之间的间距 */
        display: flex;
        flex-wrap: wrap; /* 允许内容换行 */
    }
    .content div {
        display: none; /* 默认隐藏所有内容区域 */
        width: 49%; /* 设置每个内容区域的宽度 */
        margin: 0.5%; /* 设置内容区域之间的间距 */
        box-sizing: border-box; /* 包含内边距和边框在内计算元素的总宽度和高度 */
    }
    .content .show {
        display: block; /* 显示当前高亮的内容区域 */
    }
    .content img {
        width: 100%; /* 图片宽度自适应内容区域 */
        height: auto; /* 图片高度自动调整以保持比例 */
    }
</style>
</head>
<body>

<div class="nav"></div>
<div class="content"></div>

<script>
    let car = {
        data: [
            { name: '产品1', pics: ['img1-1.jpg', 'img1-2.jpg'] },
            { name: '产品2', pics: ['img2-1.jpg', 'img2-2.jpg'] },
            // ... 可以继续添加更多产品数据
        ]
    };

    let data = car.data;
    let nav = document.querySelector(".nav");
    let content = document.querySelector(".content");

    // 渲染导航和内容区域
    for (let i = 0; i < data.length; i++) {
        let span = document.createElement("span");
        span.innerText = data[i].name;
        span.onclick = function () {
            // 移除所有span和div的siz和show类
            let navSpans = document.querySelectorAll(".nav span");
            let contentDivs = document.querySelectorAll(".content div");
            navSpans.forEach(span => span.className = '');
            contentDivs.forEach(div => div.className = '');

            // 给当前点击的span和对应的div添加siz和show类
            this.className = 'siz';
            contentDivs[i].className = 'show';
        }
        nav.appendChild(span);

        let div = document.createElement("div");
        let img1 = document.createElement("img");
        let img2 = document.createElement("img"); 
        // 这里虽然创建了两个img元素,但实际上每个div只显示一个(根据需求调整)
        // 为了简化示例,这里只设置第一个img的src,第二个可以隐藏或根据需求处理
        img1.setAttribute("src", data[i].pics[0]);
        // img2.setAttribute("src", data[i].pics[1]); 
        // 如果需要显示两张图片,可以取消注释并调整CSS布局
        div.appendChild(img1);
        // div.appendChild(img2); 
        // 如果需要显示两张图片,可以取消注释
        content.appendChild(div);
    }

    // 设置初始状态:默认显示第一个产品/项目
    let navSpans = document.querySelectorAll(".nav span");
    let contentDivs = document.querySelectorAll(".content div");
    if (navSpans.length > 0 && contentDivs.length > 0) {
        navSpans[0].className = 'siz';
        contentDivs[0].className = 'show';
    }
</script>

</body>
</html>

滚动动画触发

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>滚动动画触发示例</title>
  <style>
    .animate-on-scroll {
      opacity: 0;
      transform: translateY(20px);
      transition: opacity 0.5s, transform 0.5s;
    }
    .animate-on-scroll.active {
      opacity: 1;
      transform: translateY(0);
    }
    .box {
      width: 100%;
      height: 200px;
      background-color: #f1f1f1;
      margin: 20px 0;
      padding: 20px;
      box-sizing: border-box;
    }
    .spacer {
      height: 1000px;
      background-color: #f9f9f9;
    }
  </style>
</head>
<body>
  <div class="spacer"></div>
  <div class="animate-on-scroll box">动画内容 1</div>
  <div class="animate-on-scroll box">动画内容 2</div>
  <div class="animate-on-scroll box">动画内容 3</div>
  <div class="spacer"></div>
  <script>
    window.addEventListener('scroll', () => {
      document.querySelectorAll('.animate-on-scroll').forEach(el => {
        const rect = el.getBoundingClientRect();
        if (rect.top < window.innerHeight * 0.8) {
          el.classList.add('active');
        }
      });
    });
  </script>
</body>
</html>

视差滚动效果

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>视差滚动效果示例</title>
  <style>
    .parallax {
      position: relative;
      height: 500px;
      background-image: url('https://via.placeholder.com/1920x1080');
      background-size: cover;
      background-position: center;
    }
    .content {
      height: 1000px;
      background-color: #f1f1f1;
      padding: 20px;
    }
  </style>
</head>
<body>
  <div class="parallax"></div>
  <div class="content">
    <p>滚动页面查看视差效果。</p>
    <!-- 添加更多内容 -->
  </div>
  <script>
    window.addEventListener('scroll', () => {
      const scrolled = window.pageYOffset;
      document.querySelector('.parallax').style.transform = `translateY(${scrolled * 0.5}px)`;
    });
  </script>
</body>
</html>

此文尚需更新,敬请期待

码字不易,各位大佬点点赞呗

JS进阶-异步编程、跨域、懒加载

目录

一、异步编程

1.1.异步编程的基本概念与重要性

1.2.事件循环(Event Loop)机制

1.3.JavaScript异步编程的常见方式及详解

示例

1.4.异步编程的最佳实践

二、跨域

2.1.什么是跨域

2.2.怎么解决跨域

1. JSONP(JSON with Padding)

2. CORS(Cross-Origin Resource Sharing)

3. 反向代理/Nginx反向代理

4. WebSocket

5. postMessage

6. Node.js中间件代理

三、懒加载

1. 懒加载原理

2. 懒加载思路及实现

3. 图片的懒加载

案例


一、异步编程

1.1.异步编程的基本概念与重要性

        异步编程是一种编程方式,它允许程序在等待某些操作(如网络请求、文件读取、定时器等)完成的同时,继续执行其他任务。这种编程方式可以显著提高程序的执行效率,特别是在处理I/O密集型任务时表现出色。

        在JavaScript中,由于它是单线程语言,即同一时间只能执行一个任务,因此异步编程显得尤为重要。如果使用同步编程模型来处理需要大量时间的任务,会阻塞整个线程,导致页面或程序卡顿。而异步编程则可以让程序在执行这些长时间任务时,继续执行其他代码,从而优化用户体验。

1.2.事件循环(Event Loop)机制

1.事件循环(Event Loop)

        JavaScript的执行环境是单线程的,这意味着它同一时间只能执行一个任务。

        事件循环是JavaScript实现异步编程的核心机制。它包含一个执行栈(Call Stack)和一个或多个任务队列(Task Queue)。

        执行栈用于执行同步代码,而任务队列则用于存放异步任务的回调函数。

        当执行栈为空时,事件循环会从任务队列中取出任务并执行。

2.任务类型

宏任务(Macro Task) :如setTimeoutsetInterval、I/O操作等。

微任务(Micro Task) :如Promise的回调、process.nextTick等。

        微任务的优先级高于宏任务。在每一次事件循环中,执行栈中的任务执行完毕后,会先检查微任务队列,执行所有微任务,再执行下一个宏任务。

1.3.JavaScript异步编程的常见方式及详解

1、回调函数(Callback Functions)

                回调函数是最早的异步处理方式。它通过将一个函数作为参数传递给另一个函数,在异步任务完成后调用该函数来处理结果。

        回调函数的优点是简单易懂,但在处理多个异步任务时,会导致回调函数嵌套过多,形成“回调地狱”,使代码难以维护和理解。

2、Promise对象

        Promise是ES6引入的一种异步编程解决方案,它是一个表示未来某个事件(通常是一个异步操作)的结果的对象。

        Promise对象有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)。状态一旦改变就不能再变。

        Promise对象提供了then()catch()方法来处理异步操作的结果和异常。then()方法用于处理成功的情况,catch()方法用于处理失败的情况。

        Promise支持链式调用,可以避免回调地狱,使代码更加简洁易读。

        Promise.all()和Promise.race()是Promise对象的两个静态方法。

        Promise.all()接受一个包含多个Promise的数组,并返回一个新的Promise。当所有Promise都完成时,这个Promise才会完成;如果其中有一个Promise失败,Promise.all()会立即失败。

        Promise.race()则只要数组中的任意一个Promise完成或失败,就会立即完成或失败。

        有关promise的更详细讲解在ES6基础知识https://blog.csdn.net/m0_64455070/article/details/143714359

3、async/await

        async/await是基于Promise的语法糖,是ES2017引入的用于处理异步代码的方式。

  async用来声明一个异步函数,该函数会隐式地返回一个Promise。

  await只能在async函数内部使用,用于等待一个Promise的完成。它返回的是Promise成功的结果值,如果Promise失败,则抛出异常。

        async/await使得异步代码看起来像是同步的,提高了代码的可读性和可维护性。它避免了回调地狱,并且错误处理更加方便。

        有关async/await更详细讲解在本文作者的ES6基础知识https://blog.csdn.net/m0_64455070/article/details/143714359

示例

以下是一个使用async/await实现异步网络请求的示例代码:

async function fetchData(url) {
  try {
    const response = await fetch(url); // 等待fetch函数的Promise完成
    if (!response.ok) {
      throw new Error('Network response was not ok');
    }
    const data = await response.json(); // 等待响应的JSON数据解析完成
    return data;
  } catch (error) {
    console.error('Fetch error:', error);
    throw error; // 将错误抛出给调用者处理
  }
}

// 使用 async/await 处理异步请求结果
(async () => {
  try {
    const data = await fetchData('https://api.example.com/data');
    console.log('Request succeeded:', data);
  } catch (error) {
    console.error('Request failed:', error);
  }
})();

在这个示例中,fetchData函数是一个异步函数,它使用await关键字等待fetch函数的Promise完成,并获取响应的JSON数据。然后,我们使用一个立即执行的异步函数来调用fetchData函数,并处理异步请求的结果。如果请求失败,则捕获异常并打印错误信息。

1.4.异步编程的最佳实践

避免回调地狱:使用Promise或async/await来避免回调地狱,使代码更加简洁易读。

错误处理:在异步代码中添加适当的错误处理逻辑,以确保程序的健壮性。

代码可读性:使用有意义的函数名和变量名,以及适当的注释来提高代码的可读性。

性能优化:避免不必要的异步操作,以减少资源消耗和提高程序性能。

二、跨域

2.1.什么是跨域

跨域是指一个域下的文档或脚本试图去请求另一个域下的资源。在Web开发中,由于同源策略(Same-Origin Policy)的限制,浏览器默认不允许跨域请求。如果尝试进行跨域请求,浏览器会抛出安全错误。

什么是同源策略?

同源策略是一种安全功能,它要求协议、域名和端口三者必须相同,才允许访问资源。

2.2.怎么解决跨域

1. JSONP(JSON with Padding)

原理:

JSONP是一种通过<script>标签实现跨域请求的技术。由于<script>标签不受同源策略的限制,可以加载其他域的JavaScript文件。

优点:

兼容性好,可以解决主流浏览器的跨域问题

缺点:

仅支持GET请求。

不安全,可能遭受XSS攻击。

实现步骤

JSONP的工作流程如下:

前端:动态创建一个<script>标签,并设置其src属性为跨域的URL,同时在URL中传入一个回调函数名(如callback=myFunction)。

后端:服务器接收到请求后,将返回的数据作为回调函数的参数,并包装成JavaScript代码返回(如myFunction({"key":"value"}))。

前端:当浏览器加载这段JavaScript代码时,会自动调用回调函数myFunction,从而实现跨域数据传输。

但JSONP只能发送GET请求,且存在安全风险,容易受到XSS攻击。

2. CORS(Cross-Origin Resource Sharing)

原理

CORS是一种由服务器设置响应头来允许跨域请求的机制。服务器通过设置特定的HTTP响应头,如Access-Control-Allow-Origin,来指定哪些域名或IP地址可以跨域访问资源。

优点

灵活,可以细粒度地控制哪些源可以访问资源。

前端无需配置,只需后端设置响应头。

缺点

需要服务器支持

实现步骤

前端:无需特殊配置,只需发送跨域请求即可。

后端:服务器需要在响应头中设置Access-Control-Allow-Origin等CORS相关的头部字段。

CORS支持所有类型的HTTP请求,且安全性较高,是跨域请求的推荐解决方案。但需要注意的是,CORS需要服务器和浏览器同时支持才能实现。

3. 反向代理/Nginx反向代理

原理

反向代理是一种在服务器端设置代理服务器,将前端的跨域请求转发到目标服务器,并将目标服务器的响应返回给前端的技术。Nginx是一种常见的反向代理服务器。

优点

解决跨域问题:通过反向代理,前端可以与目标服务器进行跨域通信,而无需修改前端代码。

安全性:反向代理可以作为一道安全屏障,隐藏目标服务器的真实地址,防止直接攻击。

负载均衡:在大型应用中,反向代理可以将请求分发到多个目标服务器,实现负载均衡,提高系统的可用性和性能。

缓存:反向代理可以缓存目标服务器的响应,减少对目标服务器的请求次数,提高响应速度。

缺点

配置复杂性:反向代理需要服务器端的配置和管理,这可能需要一定的技术知识和经验。

单点故障:如果反向代理服务器出现故障,整个系统可能会受到影响,导致服务中断。

性能瓶颈:虽然反向代理可以提高性能,但在高并发场景下,它也可能成为性能瓶颈。

实现步骤

前端:无需特殊配置,只需发送请求到反向代理服务器即可。

后端:需要在服务器端(如Nginx)配置反向代理规则,将请求转发到目标服务器。

通过反向代理,前端可以认为是在与同源服务器通信,从而绕过浏览器的同源策略限制。但需要注意的是,反向代理需要服务器端的配置和管理。

4. WebSocket

原理

WebSocket是一种在单个TCP连接上进行全双工通讯的协议。它默认支持跨域请求,可以在客户端和服务器之间建立持久的连接,并通过该连接进行实时数据传输。

优点

支持全双工通信,实时性好。

不受同源策略限制。

缺点

需要服务器支持WebSocket协议。

可能存在性能问题,如页面建立多个WebSocket连接。

实现步骤

前端:使用WebSocket API(如new WebSocket('ws://example.com/socket'))建立连接。

后端:服务器需要支持WebSocket协议,并处理连接和数据传输。

WebSocket适用于需要实时通信或双向通信的场景,如聊天室、实时数据监控等。

5. postMessage

原理

postMessage是HTML5中提供的一个API,用于在不同窗口、标签页或iframe之间进行跨域通信。

优点

安全,可以通过origin参数控制消息的来源。

支持复杂的通信场景,如跨域iframe之间的通信。

缺点

需要双方配合实现。

实现步骤

前端:使用window.postMessage方法发送消息到目标窗口或iframe。

接收端:目标窗口或iframe需要监听message事件,并处理接收到的消息。

postMessage适用于不同窗口之间的通信,如父子窗口、跨域iframe等。但需要在接收端进行安全验证以防止恶意攻击。

6. Node.js中间件代理

原理

当前端发送跨域请求到Node.js服务器时,Node.js服务器使用中间件拦截并处理这些请求。中间件可以检查请求的头信息、参数等,并根据配置将请求转发到目标服务器。目标服务器处理请求后,将响应返回给Node.js服务器,Node.js服务器再将响应转发给前端。通过这种方式,Node.js服务器作为中间层,实现了跨域通信。常见的中间件有corshttp-proxy-middleware等。

优点

灵活性:Node.js中间件代理可以灵活地处理跨域请求,并与其他Node.js应用集成。

易于配置:与Nginx等反向代理服务器相比,Node.js中间件代理的配置通常更加简单和直观。

可扩展性:Node.js中间件代理可以轻松地与其他中间件和路由集成,实现更复杂的功能。

缺点

性能:与Nginx等高性能反向代理服务器相比,Node.js中间件代理的性能可能稍逊一筹。

资源消耗:Node.js中间件代理需要额外的资源来运行和处理请求,这可能会增加服务器的负载。

维护成本:与Nginx等成熟的反向代理服务器相比,Node.js中间件代理可能需要更多的维护和支持

实现步骤

前端:发送请求到Node.js服务器。

后端:Node.js服务器使用中间件拦截并处理跨域请求,将请求转发到目标服务器,并将响应返回给前端。

Node.js中间件代理需要服务器端的配置和管理,但可以灵活地处理跨域请求,并与其他Node.js应用集成。

三、懒加载

1. 懒加载原理

        懒加载,即按需加载或延迟加载,是指当页面或应用中的某些资源(如图片、视频、数据等)在需要时才进行加载,而不是在页面初始化时一次性加载所有资源。这种技术的核心原理是减少初始加载时间和网络流量,提高页面响应速度和用户体验。

懒加载的原理主要基于以下几点:

按需加载:只加载用户当前需要或即将需要的资源,避免加载无用资源。

异步加载:将资源的加载推迟到用户需要使用时再进行,不阻塞用户界面的渲染。

动态加载:根据用户的行为和需求,动态地生成和加载页面内容或资源。

2. 懒加载思路及实现

  1. 确定需要懒加载的资源:根据页面或应用的需求,确定哪些资源可以或应该进行懒加载。
  2. 设置占位符:在资源实际加载之前,使用占位符(如低分辨率图片、默认图标等)来占据资源的位置。
  3. 监听用户行为:通过事件监听(如滚动事件、点击事件等)来检测用户何时需要加载资源。
  4. 加载资源:当用户需要资源时,触发加载逻辑,从服务器获取资源并替换占位符。

懒加载的实现方式有多种,包括但不限于:

  1. 图片懒加载:通过监听滚动事件和判断图片是否进入可视区域来实现图片的延迟加载。
  2. 视频懒加载:在视频播放器进入用户视野或用户点击播放按钮时才开始加载视频内容。
  3. 数据懒加载:在用户滚动到页面底部或进行分页操作时,加载更多的数据。

3. 图片的懒加载

图片的懒加载是懒加载技术中最常见的应用之一。

步骤

在HTML中为需要懒加载的图片设置占位符,并使用自定义属性(如data-src)存储真实图片的路径。

在JavaScript中监听滚动事件,判断图片是否进入可视区域。

当图片进入可视区域时,将占位符替换为真实图片

// 获取页面中的所有懒加载图片
var imgs = document.querySelectorAll('img[data-src]');

// 监听滚动事件
window.addEventListener('scroll', function() {
    // 遍历所有懒加载图片
    imgs.forEach(function(img) {
        // 判断图片是否进入可视区域
        if (isImageInViewport(img)) {
            // 替换占位符为真实图片
            img.src = img.getAttribute('data-src');
            // 移除data-src属性,避免重复加载
            img.removeAttribute('data-src');
        }
    });
});

// 判断图片是否进入可视区域的函数
function isImageInViewport(img) {
    var rect = img.getBoundingClientRect();
    var inViewport = (
        rect.top >= 0 &&
        rect.left >= 0 &&
        rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
        rect.right <= (window.innerWidth || document.documentElement.clientWidth)
    );
    return inViewport;
}

现代浏览器支持

现代浏览器(如Chrome、Firefox、Safari等)通常支持图片的懒加载属性。可以在<img>标签中使用loading="lazy"属性来告诉浏览器延迟加载图片,直到它们出现在视口中。

例如:<img src="placeholder.jpg" data-src="real-image.jpg" loading="lazy" alt="Description">

案例

1. 图片懒加载(使用JavaScript和Intersection Observer API)

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Lazy Load Example</title>
<style>
  .lazy-load {
    opacity: 0;
    transition: opacity 0.3s;
  }
  .lazy-load.loaded {
    opacity: 1;
  }
</style>
</head>
<body>

<!-- 图片列表,使用 data-src 属性存储真实图片 URL -->
<img class="lazy-load" data-src="image1.jpg" alt="Image 1">
<img class="lazy-load" data-src="image2.jpg" alt="Image 2">
<img class="lazy-load" data-src="image3.jpg" alt="Image 3">
<!-- ...更多图片... -->

<script>
  // 获取所有需要懒加载的图片
  const lazyImages = document.querySelectorAll('.lazy-load');

  // 创建 Intersection Observer 实例
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        // 当图片进入可视区域时,加载图片
        const img = entry.target;
        img.src = img.dataset.src;
        img.classList.add('loaded');
        // 停止观察这个图片
        observer.unobserve(img);
      }
    });
  });

  // 开始观察每个图片
  lazyImages.forEach(img => {
    observer.observe(img);
  });
</script>

</body>
</html>

  1. 视频懒加载(使用JavaScript和事件监听)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Video Lazy Load Example</title>
</head>
<body>

<!-- 视频列表,使用 data-src 属性存储视频 URL -->
<div class="video-container">
  <video class="lazy-load" controls>
    <source data-src="video1.mp4" type="video/mp4">
    Your browser does not support the video tag.
  </video>
</div>
<div class="video-container">
  <video class="lazy-load" controls>
    <source data-src="video2.mp4" type="video/mp4">
    Your browser does not support the video tag.
  </video>
</div>
<!-- ...更多视频... -->

<script>
  // 获取所有需要懒加载的视频
  const lazyVideos = document.querySelectorAll('.lazy-load video');

  // 为每个视频容器添加点击事件监听器
  lazyVideos.forEach(videoContainer => {
    const video = videoContainer.querySelector('video');
    const source = video.querySelector('source');

    videoContainer.addEventListener('click', () => {
      // 当视频容器被点击时,加载视频
      video.src = source.dataset.src;
      video.load(); // 触发浏览器加载视频资源
      // 移除 data-src 属性,避免重复加载
      source.removeAttribute('data-src');
      // 移除点击事件监听器,避免重复操作
      videoContainer.removeEventListener('click', arguments.callee);
    });
  });
</script>

</body>
</html>

  1. 数据懒加载(使用JavaScript和滚动事件监听)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Data Lazy Load Example</title>
<style>
  #content {
    max-height: 400px;
    overflow-y: auto;
  }
  .item {
    padding: 10px;
    border-bottom: 1px solid #ccc;
  }
</style>
</head>
<body>

<div id="content">
  <!-- 初始内容 -->
  <div class="item">Item 1</div>
  <div class="item">Item 2</div>
  <!-- ...更多初始内容... -->

  <!-- 占位符,用于加载更多内容 -->
  <div id="load-more">Load More</div>
</div>

<script>
  let currentPage = 1; // 当前页码

  // 为“加载更多”按钮添加点击事件监听器
  document.getElementById('load-more').addEventListener('click', () => {
    loadMoreData(currentPage);
    currentPage++; // 更新页码
  });

  // 滚动事件监听器,当用户滚动到底部时自动加载更多数据
  window.addEventListener('scroll', () => {
    const content = document.getElementById('content');
    const loadMore = document.getElementById('load-more');
    const bottomOfWindow = window.scrollY + window.innerHeight === document.body.offsetHeight;
    const bottomOfContent = content.scrollTop + content.clientHeight >= content.scrollHeight - 10; // 减去10是为了避免因为滚动条或内容高度计算误差导致的重复加载

    if (bottomOfWindow || bottomOfContent) {
      loadMoreData(currentPage);
      currentPage++; // 更新页码
      // 可以选择隐藏或禁用“加载更多”按钮,直到新数据加载完成
      loadMore.style.display = 'none';
    }
  });

  // 模拟加载更多数据的函数
  function loadMoreData(page) {
    setTimeout(() => {
      const content = document.getElementById('content');
      const loadMore = document.getElementById('load-more');

      // 清除之前的占位符(如果存在)
      if (loadMore.style.display === 'none') {
        loadMore.style.display = 'block';
      }

      // 创建新的内容项并添加到页面中
      for (let i = 0; i < 5; i++) {
        const newItem = document.createElement('div');
        newItem.className = 'item';
        newItem.textContent = `Item ${(page - 1) * 5 + i + 3}`; // 假设每页加载5个项,从Item 3开始
        content.appendChild(newItem);
      }
    }, 1000); // 模拟网络延迟,设置为1秒
  }
</script>

</body>
</html>

Agentic Loop与MCP:大模型能力扩展技术解析

一、什么是MCP

MCP(Model Context Protocol)是一种用于大语言模型与外部工具交互的协议框架。它允许大语言模型能够调用各种外部工具来扩展其能力边界,如访问文件系统、搜索引擎、数据库等。

MCP的核心价值

  1. 能力扩展:使大语言模型突破知识边界和计算能力的限制
  2. 标准化接口:提供统一的工具调用标准,降低集成成本
  3. 复杂任务处理:通过递进式工具调用,解决复杂问题
  4. 灵活扩展:开发者可方便地添加自定义工具

MCP通过宿主应用、客户端、服务器和大语言模型的协作,实现了AI与工具的无缝对接,为AI应用提供了强大的扩展性。

二、各组件职责

宿主应用(Host)

  • 提供用户界面,接收用户输入
  • 展示AI响应和工具执行结果
  • 管理用户会话和界面状态
  • 维护对话历史和上下文管理

宿主应用最简实现:

// host.ts - 宿主应用简化实现
import { MCPClient } from './client';

class MCPHost {
  private client: MCPClient;
  private conversationHistory: any[] = [];

  constructor() {
    // 初始化MCP客户端
    this.client = new MCPClient();
  }

  // 应用启动
  async start() {
    // 初始化客户端
    await this.client.initialize();
    console.log("MCP宿主应用启动成功");
  }

  // 处理用户消息
  async handleUserMessage(userInput: string) {
    // 添加用户消息到历史
    this.conversationHistory.push({ role: "user", content: userInput });
    
    // 发送消息给客户端处理
    const response = await this.client.processMessage(userInput, this.conversationHistory);
    
    // 添加响应到历史
    this.conversationHistory.push({ role: "assistant", content: response });
    
    // 返回响应给UI
    return response;
  }
}

MCP客户端(Client)

  • 管理注册连接mcp server
  • 解析模型输出中的工具调用指令
  • 使用stdio标准IO以及RPC规范与mcp server通信让其执行工具方法

注意:实际开发中,推荐使用Model Context Protocol官方SDK,官方提供了Python、TypeScript、Java、Kotlin、C#等多种语言的SDK实现。本文为了更清晰地展示底层实现细节,自行实现了一些核心方法,帮助读者理解MCP客户端的工作原理。

2.1 客户端核心结构

MCP客户端最简实现:

// client.ts - MCP客户端简化实现
import { LLMProvider } from './llm-provider';
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';

export class MCPClient {
  private servers: Map<string, any> = new Map();
  private availableTools: any[] = [];
  private maxAgentLoops = 20;
  private llmProvider: LLMProvider;
  
  constructor() {
    this.llmProvider = new LLMProvider();
  }
  
  // 初始化客户端
  async initialize() {
    // 读取MCP服务器配置
    const configPath = path.join(process.env.HOME || process.env.USERPROFILE || '', 'mcp_config.json');
    const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
    
    // 连接到每个配置的MCP服务器
    for (const [serverName, serverConfig] of Object.entries(config.mcpServers)) {
      console.log(`正在连接到MCP服务器: ${serverName}`);
      
      try {
        // 创建与服务器的连接
        const serverConnection = await this.connectToServer(serverName, serverConfig);
        this.servers.set(serverName, serverConnection);
        
        // 获取服务器提供的工具列表
        const tools = await this.fetchToolsFromServer(serverConnection);
        this.availableTools.push(...tools.map(tool => ({
          ...tool,
          serverName
        })));
        
        console.log(`已连接MCP服务器 ${serverName} 并发现 ${tools.length} 个工具`);
      } catch (error) {
        console.error(`连接MCP服务器 ${serverName} 失败:`, error);
      }
    }
    
    console.log(`已连接 ${this.servers.size} 个MCP服务器,共发现 ${this.availableTools.length} 个工具`);
  }
  
  // 连接到MCP服务器
  private async connectToServer(serverName: string, config: any) {
    // 根据配置启动服务器进程
    const childProcess = spawn(
      config.command,
      config.args || [],
      {
        cwd: config.cwd || process.cwd(),
        stdio: ['pipe', 'pipe', 'pipe']
      }
    );
    
    // 简单封装连接对象
    const serverConnection = {
      name: serverName,
      process: childProcess,
      async sendRequest(request: any) {
        return new Promise((resolve, reject) => {
          // 将请求写入标准输入
          childProcess.stdin.write(JSON.stringify(request) + '\n');
          
          // 从标准输出读取响应
          childProcess.stdout.once('data', (data) => {
            try {
              const response = JSON.parse(data.toString());
              resolve(response);
            } catch (error) {
              reject(new Error(`无法解析服务器响应: ${error.message}`));
            }
          });
          
          // 处理错误
          childProcess.on('error', reject);
        });
      }
    };
    
    return serverConnection;
  }
  
  // 从服务器获取工具列表
  private async fetchToolsFromServer(serverConnection: any) {
    const request = {
      jsonrpc: "2.0",
      id: this.generateId(),
      method: "listTools",
      params: {}
    };
    
    const response = await serverConnection.sendRequest(request);
    
    if (response.error) {
      throw new Error(`获取工具列表失败: ${response.error.message}`);
    }
    
    return response.result.tools || [];
  }
  
  // 处理消息并执行Agentic Loop
  async processMessage(userInput: string, history: any[]) {
    // 准备LLM请求
    const llmRequest = this.prepareLLMRequest(userInput, history);
    
    // 执行Agentic Loop
    let loopCount = 0;
    let finalResponse = "";
    
    while (loopCount < this.maxAgentLoops) {
      // 调用LLM获取响应
      const llmResponse = await this.llmProvider.generateResponse(llmRequest);
      
      // 检查是否包含工具调用
      const toolCalls = this.extractToolCalls(llmResponse);
      
      if (toolCalls.length === 0) {
        // 无工具调用,循环结束
        finalResponse = llmResponse.content;
        break;
      }
      
      // 首先添加包含工具调用的assistant消息到历史
      llmRequest.messages.push({
        role: "assistant",
        content: null,
        tool_calls: toolCalls.map((toolCall, index) => ({
          id: `call_${Date.now()}_${index}`,
          type: 'function',
          function: {
            name: toolCall.name,
            arguments: JSON.stringify(toolCall.arguments)
          }
        }))
      });
      
      // 执行工具调用
      for (const toolCall of toolCalls) {
        const result = await this.callTool(toolCall);
        
        // 将工具结果添加到对话历史
        llmRequest.messages.push({
          role: "tool",
          name: toolCall.name,
          content: JSON.stringify(result)
        });
      }
      
      loopCount++;
    }
    
    return finalResponse;
  }
  
  // 调用工具
  async callTool(toolCall: { name: string, arguments: Record<string, any> }) {
    console.log(`客户端调用工具: ${toolCall.name},参数:`, toolCall.arguments);
    
    // 查找支持此工具的服务器
    const tool = this.availableTools.find(t => t.name === toolCall.name);
    if (!tool) {
      throw new Error(`未知工具: ${toolCall.name}`);
    }
    
    const serverConnection = this.servers.get(tool.serverName);
    if (!serverConnection) {
      throw new Error(`未找到支持工具 ${toolCall.name} 的服务器`);
    }
    
    // 创建标准JSON-RPC请求
    const request = {
      jsonrpc: "2.0",
      id: this.generateId(),
      method: "callTool",
      params: {
        name: toolCall.name,
        arguments: toolCall.arguments || {}
      }
    };
    
    // 发送请求并获取响应
    const response = await serverConnection.sendRequest(request);
    
    // 处理错误
    if (response.error) {
      throw new Error(`工具调用失败: ${response.error.message}`);
    }
    
    // 返回结果
    return response.result;
  }
  
  // 准备LLM请求
  private prepareLLMRequest(userInput: string, history: any[]) {
    return {
      messages: [...history],
      tools: this.availableTools.map(tool => ({
        type: "function",
        function: {
          name: tool.name,
          description: tool.description,
          parameters: {
            type: tool.inputSchema.type,
            properties: tool.inputSchema.properties,
            required: tool.inputSchema.required
          }
        }
      }))
    };
  }
  
  // 从LLM响应中提取工具调用
  private extractToolCalls(response: any) {
    // 简化实现,假设response包含tool_calls属性
    return response.tool_calls || [];
  }
  
  // 生成唯一ID
  private generateId() {
    return `req-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
  }
}

@modelcontextprotocol/sdk 的callTool方法实现

官方SDK也是基于JSON-RPC 2.0协议实现,是客户端与服务器交互的核心接口:

sequenceDiagram
    participant Client as SDK Client
    participant Transport as Transport层
    participant MCPServer as MCP Server
    participant Tool as 工具实现
    
    Client->>Client: callTool({name, arguments})
    Client->>Client: 验证工具能力支持
    Client->>Client: 构建JSON-RPC请求
    Client->>Client: 调用sendRequest方法
    
    Client->>Transport: 序列化并发送JSON-RPC请求
    Transport->>MCPServer: 通过HTTP/WebSocket/Stdio传输
    
    MCPServer->>MCPServer: 解析请求
    MCPServer->>Tool: 调用对应工具
    Tool-->>MCPServer: 返回执行结果
    
    MCPServer->>MCPServer: 构建JSON-RPC响应
    MCPServer-->>Transport: 返回响应
    Transport-->>Client: 接收并解析响应
    
    Client->>Client: 处理响应或错误
    Client-->>Client: 返回标准化结果

JSON-RPC 2.0格式

MCP客户端和服务器之间的通信基于JSON-RPC 2.0协议:

请求格式

{
  "jsonrpc": "2.0",
  "id": "request-123",
  "method": "callTool",
  "params": {
    "name": "calculator",
    "arguments": {
      "a": 5,
      "b": 3,
      "operation": "multiply"
    }
  }
}

成功响应格式

{
  "jsonrpc": "2.0",
  "id": "request-123",
  "result": {
    "content": [
      {
        "type": "text",
        "text": "15"
      }
    ],
    "isError": false
  }
}

错误响应格式

{
  "jsonrpc": "2.0",
  "id": "request-123",
  "error": {
    "code": -32000,
    "message": "Unknown tool 'calculator'"
  }
}

大语言模型(LLM)

  • 处理用户输入并生成回复
  • 根据需要输出标准格式的工具调用指令
  • 根据工具执行结果给出自然语言结果或暂时由于信息不足需要调用更多的工具而继续对话

LLM提供者最简实现:

// llm-provider.ts - LLM提供者简化实现
export class LLMProvider {
  // 向大语言模型发送请求并获取响应
  async generateResponse(request: any) {
    console.log("向LLM发送请求:", request);
    
    // 这里应该调用实际的LLM API
    // 为简化示例,返回模拟响应
    
    // 模拟LLM可能返回工具调用或直接回答
    const useTool = Math.random() > 0.5;
    
    if (useTool) {
      return {
        content: "我需要先获取一些信息...",
        tool_calls: [
          {
            name: "calculator",
            arguments: {
              a: 5,
              b: 3,
              operation: "multiply"
            }
          }
        ]
      };
    } else {
      return {
        content: "这是我直接生成的回答,不需要使用工具。",
        tool_calls: []
      };
    }
  }
}

MCP服务器(Server)

  • 接收来自Client的RPC工具调用请求
  • 管理和执行注册的工具
  • 将工具执行结果返回给Client
  • 可能包含多种工具的实现或与外部工具服务的连接

MCP服务器最简实现(使用官方SDK):

// mcp-server.js - 基于SDK的MCP服务器简单实现
const { createServer } = require('@modelcontextprotocol/server');

// 创建一个MCP服务器实例
const server = createServer({
  name: 'simple-tools'
});

// 注册一个简单的计算器工具
server.registerTool({
  name: 'calculator',
  description: '执行简单的数学计算',
  parameters: {
    type: 'object',
    properties: {
      a: { type: 'number', description: '第一个数字' },
      b: { type: 'number', description: '第二个数字' },
      operation: { 
        type: 'string', 
        description: '操作类型',
        enum: ['add', 'subtract', 'multiply', 'divide']
      }
    },
    required: ['a', 'b', 'operation']
  },
  execute: async ({ a, b, operation }) => {
    let result;
    
    switch (operation) {
      case 'add':
        result = a + b;
        break;
      case 'subtract':
        result = a - b;
        break;
      case 'multiply':
        result = a * b;
        break;
      case 'divide':
        if (b === 0) {
          throw new Error('除数不能为零');
        }
        result = a / b;
        break;
      default:
        throw new Error(`不支持的操作: ${operation}`);
    }
    
    return { result: result.toString() };
  }
});

// 启动服务器,使用标准输入/输出作为传输层
server.start({ transport: 'stdio' });
console.error('计算器MCP服务器已启动');

三、MCP系统工作流程

3.1 系统初始化过程

系统启动时,会进行以下初始化步骤:

sequenceDiagram
    participant Host as 宿主应用(Host)
    participant Client as MCP Client
    participant MCPServer as MCP Server
    
    Host->>Host: 应用启动
    Host->>Client: 创建Client实例
    Client->>Client: 加载配置
    
    loop 对每个MCP服务器配置
        Client->>MCPServer: 建立连接
        MCPServer-->>Client: 返回连接状态
        Client->>MCPServer: 请求工具列表
        MCPServer-->>Client: 返回可用工具定义
        Client->>Client: 注册工具信息
    end
    
    Client-->>Host: 初始化完成,返回可用工具
    Host->>Host: 更新UI显示可用工具

初始化关键步骤

  1. Host启动

    • 宿主应用启动,加载mcp server配置文件
    • 创建MCP Client实例
  2. Client初始化

    • 加载MCPServer配置列表
    • 为每个配置创建连接
  3. 工具发现与注册

    • Client连接到各MCPServer
    • 请求并获取每个Server提供的工具列表
    • 注册工具信息(名称、描述、输入模式、所属服务器)
    • 返回完整工具列表给Host
  4. UI初始化

    • Host更新UI展示可用工具
    • 准备就绪,等待用户输入

MCP服务器配置示例:

{
  "mcpServers": {
    "calculator": {
      "command": "node",
      "args": [
        "/absolute/path/to/mcp-server.js"  // 请替换为实际的服务器脚本路径
      ]
    }
  }
}

注意:请根据实际环境替换服务器脚本路径

  • Windows示例: "C:\path\to\mcp-server.js" 或 "C:/path/to/mcp-server.js"
  • MacOS/Linux示例: "/Users/username/projects/mcp-server.js" 或 "/home/username/projects/mcp-server.js"

3.2 完整交互流程

MCP的各个组件之间有清晰的调用关系,下图展示了从用户输入到最终输出的完整流程:

sequenceDiagram
    actor User
    participant Host as 宿主应用(Host)
    participant Client as MCP Client
    participant LLM as 大语言模型(LLM)
    participant MCPServer as MCP Server
    participant Tools as 工具实现
    
    User->>Host: 输入请求
    Host->>Client: 发送消息
    
    Client->>Client: 构建包含对话历史和工具定义的请求
    Client->>Client: 将用户问题及MCP工具添加到对话历史
    
    loop Agentic Loop
        Client->>LLM: 发送完整请求
        LLM-->>Client: 返回响应
        
        alt 响应中包含工具调用
            Client->>Client: 解析工具调用指令
            Client->>MCPServer: 调用SDK的callTool方法
            MCPServer->>Tools: 执行对应工具
            Tools-->>MCPServer: 返回执行结果
            MCPServer-->>Client: 返回工具执行结果
            Client->>Client: 将结果添加到历史
            note right of Client: 循环继续,再次调用LLM
        else 无工具调用或达到最大次数
            Client->>Client: 循环终止
        end
    end
    
    Client-->>Host: 返回最终响应
    Host-->>User: 展示结果

四、Agentic Loop循环实现

Agentic Loop是MCP系统实现工具调用的核心机制,它允许模型进行多次连续的工具调用,实现复杂任务分解和递进式解决问题。

4.1 Agentic Loop工作原理

Agentic Loop的核心思想是让大语言模型根据情况决定是直接回答还是通过工具获取更多信息。循环机制使模型能够分步骤解决复杂问题。

4.2 循环控制与终止条件

MCP系统通常设置最大循环次数(如20次)以防止无限循环。循环在以下情况终止:

  1. 模型返回不包含工具调用的完整回答
  2. 达到最大循环次数
  3. 遇到无法处理的错误

4.3 关键步骤详解

  1. 循环初始化

    • 用户发送请求
    • Client构建所有可用工具的大模型请求参数
    • Client将用户问题以及MCP工具添加到对话历史
    • 设置循环计数器和最大循环次数
  2. LLM推理

    • Client向LLM发送完整请求
    • LLM返回响应,可能会包含工具调用指令,也可能会直接做出回答,由大模型自主推理是否需要借助工具
  3. 工具调用处理

    • Client解析响应,检查是否包含工具调用
    • 如有工具调用,Client调用@modelcontextprotocol/sdk里client的callTool方法
    • 获取mcp server工具执行结果并添加到历史
  4. 循环继续或终止

    • 若大模型继续返回工具调用且未达到最大循环次数,回到步骤2继续
    • 若大模型未返回工具调用或达到最大次数,终止循环,输出大模型最终回答

4.4 对话历史管理

在Agentic Loop中,每轮工具调用的结果都会被添加到对话历史中,这样LLM可以利用之前工具调用的结果进行推理。这种方式使LLM能够参考历史信息,实现多步骤的复杂问题解决。

完整系统协作流程示例:

// example.ts - 展示MCP系统协作
import { MCPHost } from './host';

async function runMCPExample() {
  // 创建并启动宿主应用
  const host = new MCPHost();
  await host.start();
  
  // 模拟用户提问
  const userQuestion = "计算5乘以3等于多少?";
  console.log(`用户: ${userQuestion}`);
  
  // 宿主应用处理用户消息
  const response = await host.handleUserMessage(userQuestion);
  console.log(`助手: ${response}`);
}

// 运行示例
runMCPExample().catch(console.error);

五、最终实现效果

f4ab7814ca108d04ad9b822c832e313.png

六、总结

MCP的技术实现是一个多层次、多组件协作的过程,它通过标准化的协议实现了大语言模型与外部工具的无缝交互。通过Agentic Loop循环机制,系统能够支持复杂任务的递进式解决,而基于JSON-RPC 2.0协议的通信方式则确保了通信的可靠性和跨平台兼容性。这种设计使得开发者可以轻松地扩展和定制工具功能,从而大幅提升AI应用的能力边界。

Flutter中的Key详解

在 Flutter 开发中,Key 是一个经常被提及但又容易被忽视的概念。它在 Widget 树的更新和状态管理中扮演着至关重要的角色。本文将详细介绍 Flutter 中 Key 的作用、类型、常见使用场景以及最佳实践。

Key 的作用

在 Flutter 中,Key 用于标识 Widget、Element 和 State。它的主要作用是帮助 Flutter 框架在 Widget 树发生变化时正确地匹配和复用组件,确保状态的正确传递和维护。

常见场景:

  • 列表项的增删改查
  • 动画组件的切换
  • 需要精确控制 Widget 状态的场合

Key 的类型

Flutter 提供了几种常用的 Key 类型:

ValueKey

ValueKey 通过一个值(如字符串、数字等)来唯一标识 Widget。

ListView(
  children: [
    ListTile(key: ValueKey('item_1'), title: Text('Item 1')),
    ListTile(key: ValueKey('item_2'), title: Text('Item 2')),
  ],
)

ObjectKey

ObjectKey 通过对象的引用来标识 Widget,适用于对象实例唯一的场景。

class Person {
  final String name;
  Person(this.name);
}

ListView(
  children: people.map((person) => ListTile(
    key: ObjectKey(person),
    title: Text(person.name),
  )).toList(),
)

UniqueKey

UniqueKey 每次创建都是唯一的,常用于临时需要唯一标识的场景。

Container(
  key: UniqueKey(),
  child: Text('I am unique!'),
)

GlobalKey

GlobalKey 可以在整个应用中唯一标识一个 Widget,常用于跨 Widget 树访问 State 或操作。

final GlobalKey<FormState> _formKey = GlobalKey<FormState>();

Form(
  key: _formKey,
  child: // ...
)

Key 的使用场景

列表重排序

当列表项可以被拖拽或重排序时,使用 Key 可以确保每个列表项的状态不会混乱。

ReorderableListView(
  children: items.map((item) => ListTile(
    key: ValueKey(item.id),
    title: Text(item.title),
  )).toList(),
  onReorder: (oldIndex, newIndex) {
    // 处理重排序逻辑
  },
)

保持 Widget 状态

当 Widget 的顺序或父级发生变化时,Key 可以帮助 Flutter 正确地复用 State。

Row(
  children: [
    MyWidget(key: ValueKey('A')),
    MyWidget(key: ValueKey('B')),
  ],
)

如果没有 Key,交换顺序后,状态可能会错位。

跨 Widget 树访问 State

使用 GlobalKey 可以在父 Widget 之外访问子 Widget 的 State。

final GlobalKey<_MyWidgetState> myWidgetKey = GlobalKey();

MyWidget(key: myWidgetKey);

// 在其他地方访问
myWidgetKey.currentState?.doSomething();

Key 的最佳实践

  • 只在必要时使用 Key:大多数情况下,Flutter 能自动处理 Widget 树的更新,只有在需要精确控制状态时才需要 Key。
  • 优先使用 ValueKey 或 ObjectKey:它们更易于维护和理解。
  • 避免滥用 GlobalKey:GlobalKey 会影响性能,只有在确实需要跨 Widget 树访问 State 时才使用。
  • 为可重排序、增删的列表项分配 Key:确保状态不会错乱。

总结

Key 是 Flutter 框架中用于标识和管理 Widget 状态的重要工具。合理使用 Key 可以避免许多难以发现的 bug,提升应用的健壮性和可维护性。希望本文能帮助你更好地理解和使用 Flutter 中的 Key。

你以为你很忙,其实你只是在拼命挖坑

许多开发者提出过真实担忧:

“我封装做得太好,结果每次产品一改就要改我写的组件,反而更累,更危险。”

🧠 一种隐形的勤奋:封装≠高效,反而可能在制造“维护地狱”

许多开发者以为,封装做得多、抽象做得早,就等于“技术含量高”。但你有没有想过:

你以为你在筑高楼,其实你在加速挖坑。

当你的封装方案:

  • 抽象了一堆“可能用到”的场景;
  • 把逻辑硬塞进组件内部;
  • 每次业务一变,就得拆自己造的轮子;

你封装的,不是系统能力,而是系统负担。

我们来理性分析这个现象:

✅ 封装不是目标,而是提升系统能力的手段

很多“被封装害惨”的例子,其实是封装时闭门造车、抽象过度

  • 把所有逻辑塞进一个巨型组件
  • 写死流程,缺乏可配置点
  • 不留扩展口,导致每次改动都要大拆大建

这类封装,其实是耦合增强,不是复用增强。

✅ 真正的好封装:以业务为中心、支持变化、渐进抽象

  • 职责单一:组件小而精,单一职责更易复用
  • 扩展性强:留好 slots、hooks、配置项,支持被覆盖和组合
  • 业务驱动:先实现真实场景,再抽象共性;不要一开始就封装一堆“未来可能用到的功能”

✅ 封装能力 = 抽象能力 + 协作能力

封装不是“你写了一堆组件”,而是你能:

  • 拆解问题,抽象共性方案
  • 组织配置,服务多场景
  • 兼顾协同,推动团队对齐

封装不仅是代码能力,更是系统思维 + 协作能力 + 业务理解力的体现。

✅ 封装 ≠ 危险,盲封装才危险

被裁掉的从来不是“能封装的那个人”,而是“封得死、改得痛、协作差”的人。 真正的高级工程师,写的不是组件,是系统能力;堆的不是代码,是组织效率。

搜索区域设计进化

列表页的搜索框相当于“筛选引擎”,但往往会有很多条件,让用户一眼都找不到重点。初级阶段,我们习惯每个页面都手写一堆 <Input><Select>,校验逻辑也各自写一套,结果不同页面的搜索区几乎同质化。进阶思路是:把表单和字段配置化动态化。比如封装一个通用的 SearchForm 组件,只要传入字段配置数组(label、字段名、类型、选项等),表单就能自动生成。再进一步,可以采用 JSON Schema 驱动(或类似 Formily 这样的方案)来定义搜索表单,也就是让数据来驱动界面。利用 Schema,我们只需关注“有哪些字段、要怎么校验”,而让表单组件自动渲染对应的输入项。最后可以将字段信息放到一个中心(Field Center)管理:统一字段的标签、数据源和校验规则,这样修改一次,所有页面都能同步生效。这样的演进往往会带来惊喜——当项目A和项目B都需要一个“日期区间”字段时,我们只需在字段中心配置,而不用再抄一遍代码。

  • 抽象表单组件:将常用的表单渲染成可复用组件,只在配置里声明字段属性。
  • Schema 驱动表单:使用 JSON Schema 规范来描述表单(例如 Formily),前端根据 Schema 自动生成 UI。
  • 字段中心(Field Center) :集中管理所有字段的 label、组件类型、校验规则等。任何变更都只需在配置里改一次,避免四处改动。

这样做后,搜索区从冗杂的代码堆砌变成了参数化配置。记得有次项目中,有十多个列表页需要几乎相同的搜索字段,以前改一个字段就要逐页修改,现在只改字段中心的一个 JSON 配置文件,就完成了所有联动,效率飞跃。🎉

表格区域抽象

表格区域是中后台的核心展示。但在细节上,很多需求都差不多:列(columns)格式化状态映射 等工作高度重复。比如上一个项目写好的状态列,我们在下个项目可能又写了一遍,或是在导出等功能里踩坑。进阶思考是:建立一个通用的表格配置机制。我们可以将列的定义抽象出来,类似列数组的配置对象,并在多个页面间复用。例如:[ { title: '名称', dataIndex: 'name' }, { title: '创建时间', dataIndex: 'createdAt', render: formatDate } ] 这样的配置可以放到共用文件里,不同页面只需要引用即可。对一些复杂字段,再配合数据字典做映射,将后台返回的状态码、性别码等自动渲染为可读文字(比如 {'M': '男', 'F': '女'}),避免在每个 render 里 switchif。如果有权限需求,还可以在列配置中加上 requiredRoleshow: checkPermission(...),让表格自动根据用户角色隐藏列或操作按钮。

  • 统一列配置:将 Table 的 columns 数组封装成可复用配置文件或函数,不同列表页共用相同列名、类型等定义。
  • 格式化统一:比如日期、货币、百分比等统一使用工具函数,甚至在列配置里就设置好 render: formatDate,整个项目中保证风格一致。
  • 权限/字典映射:在列定义中声明数据字典和权限要求,让组件自动根据字典表(如状态、枚举)渲染标签,以及根据权限动态控制显隐。

例如在一个项目中,我们有多个表都需要“状态”这一列,以前代码里每个页面都写一段 switch 逻辑。后来我们把所有状态码和含义写进一个字典模块,表格渲染时统一调用,让状态列直接用 <StatusBadge status={record.status} /> 来渲染,省了不少重复代码。正如业界所说,很多组件库(如 AntD ProTable)已内置分页和筛选逻辑,但对于自定义场景,我们需要自己定义表格行为。总之,把表格想象成一组组件的集合,每个组件(列)都应该职责单一,只做自己的事,不用每页重复造轮子。

编辑与跳转抽象

中后台列表页通常还会有“新增/编辑”功能:用户点一条记录可以跳转到详情或弹窗编辑。刚开始,很多团队都是每个页面再抄一套跳转逻辑:例如在 Vue 里写死路由路径、带上 ID,然后在详情页里写条件逻辑区分「新增还是编辑」。进阶思维是把页面行为模式化,统一状态驱动。一个常见做法是:定义好路由 schema,比如 /users/list/users/form/:mode(add|edit)/:id?,然后写一个通用的 表单页组件,它根据 URL 中的 mode 参数决定是「创建态」还是「编辑态」。也可以统一使用对话框,当 modeaddedit 时弹出一个 Modal;无论用何种呈现方式,数据流与界面分离才是一致思想。

  • 状态驱动页面:用路由参数或页面变量明确「新增 / 编辑 / 详情」三种状态,组件只根据状态渲染不同的表单或接口调用。
  • 弹窗 vs 页面:在可行时把逻辑抽离出来,用同一个组件支持两种模式——比如 props 传入 isModal,控制以对话框或独立页面形式展示。
  • 模式化接口调用:把「新增调用 POST,编辑调用 PUT」等网络请求逻辑放在统一的服务层,页面组件只调用相同的 saveItem(data) 接口,让后端根据 ID 自动区分新旧。

换句话说,我们让「去哪儿编辑」「以什么模式编辑」「如何保存」这些事情,都交给配置或统一的逻辑去管理,而不是散落在各个列表页里重复编写。这样,当我们决定把用户编辑表单从页面改成弹窗时,只改一处配置或组件;新增字段时,也只改表单配置,不用每个链路都瞎写一处。例如,在一次项目迭代中,我们给用户编辑新增了几行输入字段,最开始是加在弹窗页面和独立页面各写一遍,后来改用配置驱动后,字段自动体现在所有地方,开发效率和质量都上去了。

工程化提升维度

开发列表页不仅仅是写功能,更要从工程化角度思考:把样式、状态管理、权限、字段配置这些要素标准化。比如在样式层面,统一使用设计系统和主题,颜色、间距、字体等统一变量,避免各页面自定义一堆重复样式。状态管理上,可以定义一套固定的数据流方案:列表 loading、搜索条件、分页信息、刷新函数等都规范成约定好的写法(比如结合 Vuex/Redux 或者 Hooks),一行代码即可控制同事列表组件的刷新。权限层面,可以统一设计权限配置入口:例如一个 permissions.js 文件里定义所有页面和功能点的权限标识,每个组件只需引用该配置检查显隐。字段配置则可抽象成元数据,例如后端返回的 API 数据结构,在前端做成模型对象,保证字段名称一致,减少前端 hard code 字段出现 bug。

  • 样式标准化:使用公共样式变量、主题包、组件库设计语言,让 UI 风格统一可维护。
  • 状态管理规划:列表的分页、查询条件、选中项等状态都放入统一管理(Redux/Vuex 或 hooks 中),使不同页面结构一致。
  • 权限统一处理:全局集中定义权限点和角色表,组件只要调用 hasPermission(key),无需自己分散实现复杂逻辑。
  • 字段配置中心:将 API 字段定义、表单字段规则、字典映射等放到统一配置文件或系统中,字段增删改查一处变更即可。

这样的工程化提升,就是在为自己腾出更多时间做创新。记得在某项目中,我们把表格分页器、导出按钮、全选逻辑封装进了一个基础组件,后来只在该组件上设置 showExport=true 就能应用到所有表格,项目后期新增需求时几乎不用写重复代码。透过这层认知跃迁,你会觉得自己从“搬运工”向“系统工程师”进化了一步:不再只是重复敲键盘,而是设计规则、制定流程,把“机械活”交给机器去做。

开发行为的进阶思维

做开发不只是机械地配合需求,更要主动思考如何提高效率。不要只等着后端提供接口,再慢慢把页面一项项填上;可以尝试Mock 驱动开发:先画出 API 和数据的假样子,前后端并行推进,这样自己有更多空间探索最佳方案。比如接手新项目时,可以先用 Postman 或 MockServer 写一个数据结构,然后基于它封装列表页面原型,再随时调整;这样设计往往比等接口到位后再来补坑,节省很多来回。主动推进还体现在日常中:如果发现团队里有重复工作,如每次都要配置某个重复的工具,不妨拿出来讨论,引入一个通用解决方案。

  • 主动驱动:从“被动接需求”变为“主动提方案”,培养前瞻性思维。问自己:有没有办法做成更通用的组件?有没有工具可以自动化?
  • Mock 驱动设计:提前模拟接口和数据,先画雏形再完善细节。这样前端可以先行实现页面,后端更清楚需求。
  • 跨团队协作:定期总结共性问题,共同推动设计规范。如与后端达成契约式开发(约定接口格式),与产品沟通前端可实现的快速方案

比如我曾和团队一起,引入了低代码构建工具,让产品同学也能拖拉拽生成页面雏形,极大减少重复工作量。在这个过程中,开发者的思维从「这个功能怎么写」跃迁到「如何让更多人更快写出这个功能」。正如一句常挂在嘴边的话:授人以鱼不如授人以渔,我们要教自己和团队“钓鱼”而非仅仅拎鱼上钩。

识别并重构低水平重复劳动

如果你发现自己经常复制粘贴相似代码、调整几乎一样的界面、解决一模一样的问题,就说明可以进行 重构 了。这些低效重复劳动的例子很多:一个页面里做过的搜索、表格、导出逻辑,在下一个页面往往又从零开始;或者写接口时又忘记处理分页参数,手工补了一遍。好的做法是:举一反三,把共用逻辑提炼成工具函数、公共组件或通用页面配置。

  • 辨别重复点:留意每天敲的键盘里有哪些是“搬砖”,列个清单(例如:每个列表都写一遍分页逻辑、每个表单都写一份校验)。把它们画出来,你就知道要抽象什么。
  • 重构封装:针对清单里的项目,逐个拆分成可复用的模块。比如做一个useTable Hook 包含分页和刷新逻辑;写一个通用导出功能只需要传入数据接口即可。
  • 持续迭代:重构不是一次完成的。随着经验积累,会有新的重复点出现。比如之前没想到,现在发现条件列表都用下拉搜索效果不好,就统一写个封装组件,之后再也不用在不同页面重复那段逻辑。

关键是要勇于停下手头的「塑形任务」,抽时间搭梯子和开路。开始阶段可能感觉重构费劲(反正「搬砖」也能做完页面),但回头来看,每多写一个抽象,后面至少省几天。长期下来,你会庆幸当初迈出的这一步,因为它让你从简单地执行变成敏锐地整合资源,能力也就跟着飞跃了。

组件理解与职责边界

组件就像一个个乐高积木,每个积木都要只做一件事。组件的单一职责原则告诉我们:一个组件只负责一个功能点。例如,既然有了统一的SearchForm组件,就不该在这个组件里写数据库请求,也不要混合太多业务逻辑;而是把数据获取留给上层容器组件来做。实践里,一个列表页通常会拆成“容器组件”和“展示组件”两层:**容器组件(页面级)**负责接口请求、状态管理等业务处理;展示组件只负责接收 props 渲染 UI,比如表格、表单、按钮等。把这两层分开好处很多——展示组件不关心业务,可以在多个场景复用;容器组件专注业务逻辑,也容易测试。

  • 单一职责:遵循单一职责原则,每个组件只完成「定义好的那件事」。不要让一个组件同时管数据请求和多处 UI 布局,否则代码会纠结难以维护。
  • 容器 vs 展示:将业务逻辑(数据获取、计算)放在容器/页面组件里,将视图渲染放在纯展示组件里。这样 UI 组件就可以“傻瓜式”接收数据,重用性更强。
  • 接口简洁:组件通信使用明确的输入输出(props),尽量避免跨组件调用或全局状态的隐式依赖。这降低了耦合度,让组件更易于理解和测试。

可以想象:如果组件设计得像万能积木,要啥有啥,后期维护就跟炒杂碎一样乱。相反,每个组件就像乐高的标准块,只露出简单接口,才能像模块化积木那样灵活组合。随着经验丰富,你会发现能否正确划分组件边界常常决定了页面能否优雅地扩展和维护。优秀的组件划分,让你不必为小改动而翻遍整个代码库。

页面能力 vs 系统建设力

写好一个页面固然重要,但更大的进阶是提升整体开发效能的能力——也就是系统建设力。与其把时间都花在“造每辆车”,更应该考虑如何“造出造车的工厂”。前面所有模块的经验都是这一思想的体现:我们不只是写单页 CRUD,而是在构建适用于所有列表页的能力和规则。当团队的开发者都能轻松地调用现成组件、自动生成页面时,开发效率将成倍提升。

  • 迁移心态:从“完成一个功能”转变为“建立一套方式” 。 每增加一个列表页之前,先问问:有没有机会把它变成模板或配置?如果大多数列表页都长得差不多,那么我们就该建设“列表页平台”,而不是重写第二个复制品。
  • 工具与脚手架:思考能否做出页面生成器或 CLI 脚本。一些团队会自研脚手架:输入表名、字段配置,就能自动生成 React/Vue 页面骨架和接口代码。这样的工具投入产出比极高,能让新人快速上线新功能。
  • 价值最大化:集中精力解决通用问题,而不是单例问题。如同前文提到的中后台场景占比高达 86%,我们就在这 86% 的场景上持续打磨,能让日后千篇一律的页面开发变得一次搞定。

页面能力 ≠ 项目交付能力

能写页面 ≠ 能撑住一个系统。还需要:

  • 多模块协作(前后端联动,产品对齐)
  • 权限、字段、模块抽象设计
  • 异常兜底、流程控制能力

🧭 最后的总结:你以为你很忙,其实你在为过去的自己还债

你很忙,不代表你在成长。每天加班、应急、救火的你,可能是在为自己曾经草率写下的组件买单。

忙 ≠ 有价值。 不要被眼前的任务束缚住脚步。当别人在忙着搬砖时,你正在设计和优化搬砖的“机器”。每改进一个流程、统一一套规范,就是在积累“乘法效应”,让未来的开发工作变得越来越轻松。正如一句金句所说:“别只抬头看天花板,你要在天花板上打洞。”(意思是别满足于现状,去创造新可能。)通过以上各个方面的思维转变与实践提升,你的认知也会随之跃迁,对中后台开发的理解将从“简单 CRUD”变成“整体治理”,最终实现真正的技术升级与成长。🚀

UniApp组件开发七日通-单元格

首先看模板部分,组件是一个包含标题、图标和右侧内容的单元格。根据props,有title、value、link、border和icon等属性。link控制右侧箭头显示,border控制下边框。

<view class="cell " :class="{'cell-active':prop.link,'cell-border':prop.border}" @click="emit('click',$event)">
<view class="cell-title">
<h-icon class="cell-icon" :name="prop.icon" v-if="prop.icon"></h-icon>
{{prop.title}}
</view>
<view class='cell-value' :class="{'cell-link':prop.link}">
<slot v-if="$slots.default"></slot>
<view class="cell-value-title" v-else>{{prop.value}}</view>
</view>
</view>

接下来是script部分,定义了TypeScript类型,props的类型包括title、value、link、border、icon,这些都是可选的。emit了一个click事件。

// type
type types = {
title ?: string,
value ?: string | number,
link ?: boolean,
border ?: boolean,
icon ?: string,
}
// prop
const prop = defineProps<types>()
// emit
const emit = defineEmits(['click'])

然后是样式部分,使用了SCSS,包括布局、边框、图标间距和右侧箭头的样式。注意到link类有伪元素实现的箭头,border类有下边框。

.cell {
font-size: 14px;
display: flex;
justify-content: space-between;
padding: 0 10px;
line-height: 44px;
background: #fff;
position: relative;
align-items: center;

&-border {
border-bottom: solid 1px #f1f1f1;
}

&-title {
flex: 1;
display: flex;
align-items: center;
}

&-icon {
margin-right: 4px;
}

&-value {
flex: 1;
display: flex;
justify-content: flex-end;
&-title{
color: #999;
}
}

&-link {
padding-right: 8px;
position: relative;
box-sizing: border-box;

&::after {
content: '';
width: 6px;
height: 6px;
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%) rotate(135deg);
border-left: solid 1px #999;
border-top: solid 1px #999;
}
}
}

现在需要整理属性表格,包括每个属性的类型、默认值、说明。例如,title是字符串,没有默认值;link和border是布尔值,默认false。事件方面只有click事件。

样式逻辑部分需要描述布局方式(flex布局,两端对齐)、边框条件、图标间距、右侧箭头如何实现,以及禁用状态等。注意到link为true时,右侧会有箭头,使用伪元素的边框旋转实现。

使用示例部分,要展示基础用法、带图标的、带边框的、带箭头的例子。同时需要说明依赖关系,比如需要h-icon组件,以及样式使用了SCSS。

检查是否有遗漏的props或功能,比如disabled状态在代码中没有体现,可能用户之前的h-button有,但h-cell没有,所以不能加入。确保文档准确反映当前代码。

最后,按照之前的h-button文档格式组织内容,保持一致性。确认所有属性和事件都被涵盖,示例代码正确无误,没有假设未实现的功能。

h-cell 组件文档

1. 组件概述

h-cell 是一个基于 Vue 3 的跨端单元格组件(适用于 uni-app 框架),主要用于展示列表项信息。支持标题、图标、右侧内容、箭头指示和下边框等功能。


2. 组件属性

属性名 类型 默认值 说明
title string - 左侧标题文字内容
value string|number - 右侧显示内容(当没有 slot 内容时生效)
link boolean false 是否显示右侧箭头指示
border boolean false 是否显示下边框
icon string - 左侧图标名称(需配合 h-icon 组件使用)

3. 组件事件

事件名 说明 参数
click 单元格点击时触发 原生事件对象 $event

4. 样式逻辑说明

4.1 布局结构

  • 采用 flex 布局实现左右内容排列
  • 默认左右内容区域等分空间(flex: 1)
  • 右侧内容默认右对齐(justify-content: flex-end)

4.2 边框控制

  • 通过 border 属性控制是否显示 1px #f1f1f1 的下边框
  • 边框使用相对定位实现,不影响布局

4.3 箭头指示

  • link=true 时右侧显示旋转 135° 的边框箭头
  • 箭头通过伪元素 ::after 实现
  • 箭头颜色与右侧文字颜色一致(#999)

4.4 图标显示

  • 图标与标题间距为 4px
  • 图标尺寸继承父级字体大小

4.5 交互状态

  • 点击时无特殊样式变化(可根据需要自行扩展)

5. 使用示例

基础用法

<!-- 简单单元格 -->
<h-cell title="姓名" value="张三"></h-cell>

<!-- 带图标单元格 -->
<h-cell title="手机号" icon="phone" value="138****8888"></h-cell>

<!-- 带边框单元格 -->
<h-cell title="地址" border value="北京市朝阳区"></h-cell>

<!-- 带箭头指示 -->
<h-cell title="更多设置" link></h-cell>

自定义插槽

<!-- 自定义右侧内容 -->
<h-cell title="订单状态">
  <text class="status-text">已发货</text>
</h-cell>

6. 依赖说明

  • 需配合 h-icon 组件使用(用于显示 icon 属性对应的图标)

  • 样式使用 SCSS 预处理器编写,需确保项目已配置 SCSS 编译环境

  • 基础字号为 14px,行高固定为 44px

纯血鸿蒙开发之广告服务(1)

前言

大家好,我是青蓝逐码的云杰,今天我想来聊一聊学习一下鸿蒙的广告服务!

Ads Kit(广告服务)

Ads Kit(广告服务)依托华为终端平台与数据能力为您提供流量变现服务,帮助您解决流量变现的难题;同时为广告主提供广告服务,配合华为终端平台向用户提供个性化的营销活动或商业广告。

鲸鸿动能流量变现服务(以下简称流量变现服务)是广告服务依托华为终端强大的平台与数据能力为您提供的App流量变现服务,您通过该服务可以在自己的App中获取并向用户展示精美的、高价值的广告内容,并从中获得广告收益。 为满足App不同场景下的内容形式,流量变现服务提供了横幅广告、原生广告、激励广告、插屏广告、开屏广告、贴片广告六种广告形式。

广告形式 展示形式 应用场景
横幅广告 图片 以通知栏或矩形固定展示在应用内页面顶部、中部或底部,适合用于用户停留较久或者访问频繁的页面。
原生广告 图片、视频 界面内插入广告,与媒体内容无缝融合。
激励广告 视频 游戏通关、复活、获取道具、积分、继续机会、人物技能升级时等展示。
插屏广告 图片、视频 游戏或流媒体开启、暂停、过关、跳转、加载、退出时弹出。
开屏广告 图片、视频 打开App时,以开屏形式全屏展现,展示时长3s-5s,展示完毕后自动关闭并进入应用主页面。
贴片广告 图片、视频 前贴:视频播放前。中贴:视频播放中。后贴:视频播放结束后。说明:您可以根据自身需求设置广告的播放时长。

具体如何进入这项服务,我在之前的文章中专门介绍过接入广告服务,这里就不过多介绍这个,本系列文章主要介绍的是广告的使用和展示

广告展示

1.横幅广告

横幅广告又名Banner广告,是在应用程序顶部、中部或底部占据一个位置的矩形图片,广告内容每隔一段时间会自动刷新。

1.1 解释说明

横幅广告是所有广告里较为简单的。

  1. 获取OAID。

    若需提升广告推送精准度,可以在请求参数AdRequestParams中添加oaid属性以提升广告推送精准度和广告填充率。

    如何获取OAID参考获取OAID信息

    说明

    使用以下示例中提供的测试广告位时,必须先获取OAID信息。

    identifier.getOAID().then((data: string) => {
      this.oaid = data;
    }).catch((error: BusinessError) => {
      hilog.error(0x0000, 'testTag', 'Failed to get OAID');
    });
    
    

  2. 请求和展示广告。

    在您的页面中使用AutoAdComponent组件展示横幅广告。

    请求广告关键参数如下所示:

    请求广告参数名 类型 必填 说明
    adType number 请求广告类型,横幅广告类型为8。
    adId string 广告位ID。如果仅调测广告,可使用测试广告位ID:testw6vs28auh3。如果要接入正式广告,则需要申请正式的广告位ID。可在应用发布前进入流量变现官网,点击“开始变现”,登录鲸鸿动能媒体服务平台进行申请,具体操作详情请参见展示位创建
    adWidth number 广告位宽,单位vp。宽和高支持36057和360144两种尺寸。
    adHeight number 广告位高,单位vp。宽和高支持36057和360144两种尺寸。
    oaid string 开放匿名设备标识符,用于精准推送广告。不填无法获取到个性化广告。

    展示广告关键参数如下所示:

    展示广告参数名 类型 必填 说明
    refreshTime number 横幅广告轮播时间。单位ms,取值范围[30000, 120000]。如果不设置或取值为非数字或小于等于0的数字,则不轮播。设置小于30000的数字取值30000,设置大于120000的数字取值120000。
      AutoAdComponent({
        adParam: this.adParam,
        adOptions: this.adOptions,
        displayOptions: this.displayOptions,
        interactionListener: {
          onStatusChanged: (status: string, ad: advertising.Advertisement, data: string) => {
            hilog.info(0x0000, 'testTag', '%{public}s', `status is ${status}`);
            switch (status) {
              case AdStatus.AD_OPEN:
                hilog.info(0x0000, 'testTag', '%{public}s', 'Status is onAdOpen');
                break;
              case AdStatus.AD_CLICKED:
                hilog.info(0x0000, 'testTag', '%{public}s', 'Status is onAdClick');
                break;
              case AdStatus.AD_CLOSED:
                hilog.info(0x0000, 'testTag', '%{public}s', 'Status is onAdClose');
                this.visibilityState = Visibility.None;
                break;
              case AdStatus.AD_LOAD:
                hilog.info(0x0000, 'testTag', '%{public}s', 'Status is onAdLoad');
                break;
              case AdStatus.AD_FAIL:
                hilog.error(0x0000, 'testTag', '%{public}s', 'Status is onAdFail');
                this.visibilityState = Visibility.None;
                break;
            }
          }
        }
      })

1.2示例代码

为了小伙伴之后,可以直接使用,这边我已经将他封装成自定义组件咯

import { advertising, AutoAdComponent } from '@kit.AdsKit';
import { hilog } from '@kit.PerformanceAnalysisKit';


@Component
export struct BannerView {
 @State adParam: advertising.AdRequestParams = {
   // 广告类型:横幅广告
   adType: 8,
   // 'testw6vs28auh3'为测试专用的广告位ID,应用正式发布时需要改为正式的广告位ID
   adId: 'testw6vs28auh3',
   // 广告位宽
   adWidth: 360,
   // 广告位高
   adHeight: 57
 };
 private adOptions: advertising.AdOptions = {
   // 是否允许流量下载0:不允许,1:允许,不设置以广告主设置为准
   allowMobileTraffic: 0,
   // 是否希望根据 COPPA 的规定将您的内容视为面向儿童的内容: -1默认值,不确定 0不希望 1希望
   tagForChildProtection: -1,
   // 是否希望按适合未达到法定承诺年龄的欧洲经济区 (EEA) 用户的方式处理该广告请求: -1默认值,不确定 0不希望 1希望
   tagForUnderAgeOfPromise: -1,
   // 设置广告内容分级上限: W: 3+,所有受众 PI: 7+,家长指导 J:12+,青少年 A: 16+/18+,成人受众
   adContentClassification: 'A'
 };
 private displayOptions: advertising.AdDisplayOptions = {
   // 广告轮播的时间间隔,单位ms,取值范围[30000, 120000]
   refreshTime: 30000
 }
 private ratio: number = 1;
 private adWidth: number = -1;
 private adHeight: number = -1;
 @State visibilityState: Visibility = Visibility.Visible;

 aboutToAppear() {
   if (this.adParam?.adWidth && typeof (this.adParam?.adWidth) === 'number' && this.adParam?.adWidth > 0) {
     this.adWidth = this.adParam?.adWidth;
   }
   if (this.adParam?.adHeight && typeof (this.adParam?.adHeight) === 'number' && this.adParam?.adHeight > 0) {
     this.adHeight = this.adParam?.adHeight;
   }
   if (this.adWidth > 0 && this.adHeight > 0) {
     this.ratio = this.adWidth / this.adHeight;
   }
 }

 build() {
   Stack({ alignContent: Alignment.Bottom }) {
     this.buildBannerView()
   }
 }

 @Builder
 buildBannerView() {
   Row() {
     AutoAdComponent({
       adParam: this.adParam,
       adOptions: this.adOptions,
       displayOptions: this.displayOptions,
       interactionListener: {
         onStatusChanged: (status: string, ad: advertising.Advertisement, data: string) => {
           hilog.info(0x0000, 'testTag', '%{public}s', `status is ${status}`);
           switch (status) {
             case AdStatus.AD_OPEN:
               hilog.info(0x0000, 'testTag', '%{public}s', 'Status is onAdOpen');
               break;
             case AdStatus.AD_CLICKED:
               hilog.info(0x0000, 'testTag', '%{public}s', 'Status is onAdClick');
               break;
             case AdStatus.AD_CLOSED:
               hilog.info(0x0000, 'testTag', '%{public}s', 'Status is onAdClose');
               this.visibilityState = Visibility.None;
               break;
             case AdStatus.AD_LOAD:
               hilog.info(0x0000, 'testTag', '%{public}s', 'Status is onAdLoad');
               break;
             case AdStatus.AD_FAIL:
               hilog.error(0x0000, 'testTag', '%{public}s', 'Status is onAdFail');
               this.visibilityState = Visibility.None;
               break;
           }
         }
       }
     })
   }
   .width('100%')
   .aspectRatio(this.ratio)
   .visibility(this.visibilityState)
 }
}

enum AdStatus {
 AD_LOAD = 'onAdLoad',
 AD_FAIL = 'onAdFail',
 AD_OPEN = 'onAdOpen',
 AD_CLICKED = 'onAdClick',
 AD_CLOSED = 'onAdClose',
 AD_REWARDED = 'onAdReward',
 AD_VIDEO_START = 'onVideoPlayBegin',
 AD_COMPLETED = 'onVideoPlayEnd'
}

1.3 效果演示

1.4 总结

  • 广告请求:通过 AdLoader 请求广告并处理回调。
  • 广告显示:使用 AdComponent 组件展示广告数据,并通过 interactionListener 监听广告状态。

2.开屏广告

开屏广告是一种在应用启动时且在应用主界面显示之前展示的广告。它分为以下两种类型:

  • 全屏开屏广告:广告覆盖整个屏幕。
  • 半屏开屏广告:广告占据部分屏幕,并可自定义布局,通常会显示广告的图标和版权信息。

2.1 开发步骤

2.1.1 获取OAID

  • OAID(Open Advertising ID)是设备的唯一广告标识符,用于精准推送广告。建议在请求广告时传递 OAID 以提升广告投放的精准度。
  • 获取 OAID 示例代码:
identifier.getOAID().then((data: string) => {
  this.oaid = data;
}).catch((error: BusinessError) => {
  hilog.error(0x0000, 'testTag', 'Failed to get OAID');
});

2.1.2 请求广告

需要创建一个AdLoader对象,通过AdLoader的loadAd方法请求广告,最后通过AdLoadListener来监听广告的加载状态。测试开屏广告时,需要使用专门的测试广告位来获取测试开屏广告,示例代码中提供了两种开屏广告类型对应的广告位:半屏开屏(图片)(testq6zq98hecj)和全屏开屏(视频)(testd7c5cewoj6),测试广告位ID仅作为调试使用,不可用于广告变现。

请求广告关键参数如下所示:

请求广告参数名 类型 必填 说明
adType number 请求广告类型,开屏广告类型为1。
adId string 广告位ID。如果仅调测广告,可使用测试广告位ID:testq6zq98hecj半屏开屏(图片)和testd7c5cewoj6全屏开屏(视频)。如果要接入正式广告,则需要申请正式的广告位ID。可在应用发布前进入流量变现官网,点击“开始变现”,登录鲸鸿动能媒体服务平台进行申请,具体操作详情请参见展示位创建
adCount number 广告数量。
返回广告参数名 类型 说明
isFullScreen boolean 标识返回的广告是否为全屏,true为全屏广告,false为半屏广告。

说明

  1. 如果超时没有请求到广告,应用自行跳转到默认首页。
  2. 为保证开屏展示效果,建议开发者在请求广告前,设置屏幕方向为竖屏。

2.1.3 设置监听回调

  • 通过 AdLoadListener 监听广告加载的回调:
    • onAdLoadFailure:广告加载失败。
    • onAdLoadSuccess:广告加载成功。
const adLoaderListener: advertising.AdLoadListener = {
  onAdLoadFailure: (errorCode: number, errorMsg: string) => {
    hilog.error(0x0000, 'testTag', `Failed to load ad. errorCode: ${errorCode}, errorMsg: ${errorMsg}`);
  },
  onAdLoadSuccess: (ads: Array<advertising.Advertisement>) => {
    hilog.info(0x0000, 'testTag', 'Ad loaded successfully!');
    // 展示广告
  }
};

2.2 开发使用

2.2.1 请求封装

将请求开屏广告(全屏和半屏)的方法进行封装

import { router } from '@kit.ArkUI';
import { advertising } from '@kit.AdsKit';
import { common } from '@kit.AbilityKit';
import { emitter } from '@kit.BasicServicesKit';


/**
 * 开屏广告
 * 1.视频  2.图片
 */

export enum AdType {
  // 开屏广告的类型
  SPLASH_AD = 1
}

class SplashAdUtil {
  private context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
  private isTimeOut: boolean = false;
  // 超时时间(单位毫秒),开发者可根据实际情况修改
  private timeOutDuration: number = 1 * 1000;
  // 超时index
  private timeOutIndex: number = -1;
  // 广告展示参数
  private adDisplayOptions: advertising.AdDisplayOptions = {
    // 是否静音,默认不静音
    mute: false
  }
  // 广告配置
  private adOptions: advertising.AdOptions = {
    // 是否允许流量下载0:不允许,1:允许,不设置以广告主设置为准
    allowMobileTraffic: 0,
    // 是否希望根据 COPPA 的规定将您的内容视为面向儿童的内容: -1默认值,不确定 0不希望 1希望
    tagForChildProtection: -1,
    // 是否希望按适合未达到法定承诺年龄的欧洲经济区 (EEA) 用户的方式处理该广告请求: -1默认值,不确定 0不希望 1希望
    tagForUnderAgeOfPromise: -1,
    // 设置广告内容分级上限: W: 3+,所有受众 PI: 7+,家长指导 J:12+,青少年 A: 16+/18+,成人受众
    adContentClassification: 'A'
  }
  // 开屏视频广告请求参数
  private splashVideoAdReqParams: advertising.AdRequestParams = {
    // 'testd7c5cewoj6'为测试专用的广告位ID,App正式发布时需要改为正式的广告位ID
    adId: 'testd7c5cewoj6',
    adType: AdType.SPLASH_AD,
    adCount: 1,
  }
  // 开屏图片广告请求参数
  private splashImageAdReqParams: advertising.AdRequestParams = {
    // 'testq6zq98hecj'为测试专用的广告位ID,App正式发布时需要改为正式的广告位ID
    adId: 'testq6zq98hecj',
    adType: AdType.SPLASH_AD,
    adCount: 1,
  }

  private requestAd(adReqParams: advertising.AdRequestParams, adOptions: advertising.AdOptions): void {
    // 广告请求回调监听
    const adLoaderListener: advertising.AdLoadListener = {
      // 广告请求失败回调
      onAdLoadFailure: (errorCode: number, errorMsg: string) => {
        clearTimeout(this.timeOutIndex);
        if (this.isTimeOut) {
          return;
        }
      },
      // 广告请求成功回调
      onAdLoadSuccess: (ads: Array<advertising.Advertisement>) => {
        clearTimeout(this.timeOutIndex);
        if (this.isTimeOut) {
          return;
        }
        // 保存请求到的广告内容用于展示
        if (canIUse("SystemCapability.Advertising.Ads")) {
          if (ads[0].adType === AdType.SPLASH_AD) {
            // 调用开屏广告展示页面
            if (ads[0]?.isFullScreen === true) {
              emitter.emit('SplashFullScreenAdPage',
                { data: { 'ads': ads, 'adDisplayOptions': this.adDisplayOptions } })

            } else {
              emitter.emit('SplashHalfScreenAdPage',
                { data: { 'ads': ads, 'adDisplayOptions': this.adDisplayOptions } })
            }
          } else {

          }
        }
      }
    };
    // 创建AdLoader广告对象
    const load: advertising.AdLoader = new advertising.AdLoader(this.context);
    // 调用广告请求接口
    // adReqParams.oaid = this.oaid;
    this.timeOutHandler();
    load.loadAd(adReqParams, adOptions, adLoaderListener);
  }

  private timeOutHandler(): void {
    this.isTimeOut = false;
    // 超时处理
    this.timeOutIndex = setTimeout(() => {
      this.isTimeOut = true;
      const options: router.RouterOptions = {
        // 开发者可根据项目实际情况修改超时之后要跳转的目标页面
        url: 'pages/Index',
      };
      router.pushUrl(options);
    }, this.timeOutDuration);
  }

  //视频
  splashVideoAd() {
    this.requestAd(this.splashVideoAdReqParams, this.adOptions);
  }

  //图片
  splashImageAd() {
    this.requestAd(this.splashImageAdReqParams, this.adOptions);
  }
}

export const splashAdUtil = new SplashAdUtil()

2.2.2 广告参数传递

当发送广告请求,得到应有的广告参数之后,我们将参数传递给全屏广告或者半屏广告(利用emitter线性通信)

            if (ads[0]?.isFullScreen === true) {
              emitter.emit('SplashFullScreenAdPage',
                { data: { 'ads': ads, 'adDisplayOptions': this.adDisplayOptions } })

            } else {
              emitter.emit('SplashHalfScreenAdPage',
                { data: { 'ads': ads, 'adDisplayOptions': this.adDisplayOptions } })
            }

2.2.3 开屏(全屏)广告展示

  1. 解释广告请求时间点
  aboutToAppear() {
    emitter.on('SplashFullScreenAdPage', (eventData: emitter.EventData) => {
      const data = eventData.data as Record<string, object>
      this.ads = data['ads'] as Array<advertising.Advertisement>
      this.displayOptions = data['adDisplayOptions'] as advertising.AdDisplayOptions
      this.isShow = true
    })
    splashAdUtil.splashVideoAd()
  }

  1. 全屏广告页面
import { router } from '@kit.ArkUI';
import { AdComponent, advertising } from '@kit.AdsKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { emitter } from '@kit.BasicServicesKit';
import { splashAdUtil } from '../utils/SplashAdUtil';

const TAG = 'Ads Demo-SplashFullScreenAdPage';

@Entry
@Component
struct SplashFullScreenAdPage {
  private ads: Array<advertising.Advertisement> = [];
  private displayOptions?: advertising.AdDisplayOptions;
  @State isShow: boolean = false

  aboutToAppear() {
    emitter.on('SplashFullScreenAdPage', (eventData: emitter.EventData) => {
      const data = eventData.data as Record<string, object>
      this.ads = data['ads'] as Array<advertising.Advertisement>
      this.displayOptions = data['adDisplayOptions'] as advertising.AdDisplayOptions
      this.isShow = true
    })
    splashAdUtil.splashVideoAd()
  }

  build() {
    if (this.isShow) {
      Column() {
        // 运行在提供方进程里
        AdComponent({
          ads: this.ads, displayOptions: this.displayOptions,
          interactionListener: {
            onStatusChanged: (status: string, ad: advertising.Advertisement, data: string) => {
              switch (status) {
                case AdStatus.AD_OPEN:
                  hilog.info(0x0000, 'testTag', '%{public}s', 'Status is onAdOpen');

                  break;
                case AdStatus.AD_CLICKED:
                  hilog.info(0x0000, 'testTag', '%{public}s', 'Status is onAdClick');

                  break;
                case AdStatus.AD_CLOSED:
                  hilog.info(0x0000, 'testTag', '%{public}s', 'Status is onAdClose');
                  router.replaceUrl({
                    url: 'pages/Index',
                  })
                  break;
              }
            }
          }
        })
          .width('100%')
          .height('100%')

      }
      .width('100%')
      .height('100%')
    }

  }
}

export enum AdStatus {
  AD_OPEN = 'onAdOpen',
  AD_CLICKED = 'onAdClick',
  AD_CLOSED = 'onAdClose'
}
  1. 效果展示

2.2.4 开屏(半屏)广告展示

  1. 解释广告请求时间点
  aboutToAppear() {
    emitter.on('SplashHalfScreenAdPage', (eventData: emitter.EventData) => {
      const data = eventData.data as Record<string, object>
      this.ads = data['ads'] as Array<advertising.Advertisement>
      this.displayOptions = data['adDisplayOptions'] as advertising.AdDisplayOptions
      this.isShow = true
    })
    splashAdUtil.splashImageAd()
  }
  1. 半屏广告页面

    import { router } from '@kit.ArkUI';
    import { AdComponent, advertising } from '@kit.AdsKit';
    import { splashAdUtil } from '../utils/SplashAdUtil';
    import { emitter } from '@kit.BasicServicesKit';
    
    @Entry
    @Component
    struct SplashHalfScreenAdPage {
      private ads: Array<advertising.Advertisement> = [];
      private displayOptions?: advertising.AdDisplayOptions;
      @State isShow: boolean = false
    
      aboutToAppear() {
        emitter.on('SplashHalfScreenAdPage', (eventData: emitter.EventData) => {
          const data = eventData.data as Record<string, object>
          this.ads = data['ads'] as Array<advertising.Advertisement>
          this.displayOptions = data['adDisplayOptions'] as advertising.AdDisplayOptions
          this.isShow = true
        })
        splashAdUtil.splashImageAd()
      }
    
      build() {
        if (this.isShow) {
          Column() {
            // 运行在提供方进程里
            AdComponent({
              ads: this.ads, displayOptions: this.displayOptions,
              interactionListener: {
                onStatusChanged: (status: string, ad: advertising.Advertisement, data: string) => {
                  switch (status) {
                    case AdStatus.AD_OPEN:
    
                      break;
                    case AdStatus.AD_CLICKED:
                      break;
                    case AdStatus.AD_CLOSED:
    
                      router.replaceUrl({
                        url: 'pages/Index',
                      })
                      break;
                  }
                }
              }
            })
              .width('100%')
              .height('87%')
    
            // 展示媒体自定义icon、应用名称、版权信息
            Column({}) {
              Text('广告')
            }.width('100%').height('100%')
          }
        }
    
      }
    }
    
    export enum AdStatus {
      AD_OPEN = 'onAdOpen',
      AD_CLICKED = 'onAdClick',
      AD_CLOSED = 'onAdClose'
    }
    
  2. 效果展示

后续

今天介绍了广告服务中的两个,后续也会继续学习剩余的广告服务;如果我的内容对您有帮助,可以点赞、关注+收藏,谢谢大家!如果小伙伴对鸿蒙的其他内容感兴趣,欢迎加入我们青蓝逐码!

青蓝逐码官网:www.qinglanzhuma.cn/

函数基础与种类/形实参及映射/变量类型

📌 函数基础与种类

函数.png

函数定义方式

对代码块进行封装,方便反复使用

// 函数声明
function 函数名() {
  ...
    执行代码块
}

// 匿名函数表达式-函数字面量
// 表达式定义方式 会自动忽略函数名 ——> 即匿名函数
var 函数名 = function() {
  ...
     执行代码块
}
  • 函数字面量(匿名函数表达式)定义函数可以是命名的,也可以是匿名的,函数名在作用域外是不可访问获取的

📌 形实参及映射

  • 🔧 函数式编程

一个固定的功能或程序段被封装的过程,实现一个固定的功能或程序,整个封装体中需要一个入口和一个出口。

入口就是函数的参数,出口就是返回值

函数的组成

多个实参、形参,参数间用逗号隔开

// 定义
function 函数名(形参1,形参2,形参3...) {
    代码块
}

// 调用
函数名( 实参1,实参2,实参3.... )
  • 形参形式上占位,声明时的参数
  • 实参实际的数值,调用时的参数
  • 形参与实参匹配,JS中可以不一致
    • 实参 == 形参 ——> 按顺序依次赋值
    • 实参 > 形参 ——> 按顺序赋值,多余实参丢弃
    • 实参 < 形参 ——> 按顺序赋值,未接收的形参默认undefined

arguments 函数内置对象

  • 接收函数所有传入的实参,只能在函数中使用
  • 伪数组:与数组相似,具有数组的特性,可通过索引取值
    • 有长度(具有length属性,arguments.length)
    • 包括了所有传入的实参,可以通过索引值获取元素

函数的返回值return

函数调用整体的值 == 函数的返回值 == return后面的数据

  • 如果没有写return,函数内部会隐式添加return,值是undefined
  • return可以中止函数运行,return后面的语句不会执行

📌 变量类型

作用域

提高程序可靠性,减少命名冲突

  • 全局作用域:整个页面
  • 局部作用域:函数只有在调用的时候可以在全局作用域中划分出局部作用域

变量类型

  • 全局变量:全局作用域声明的变量,函数内部没有声明即赋值的变量
    • 使用范围:全局作用域中任何位置都可以使用
  • 局部变量:局部作用域中声明的变量
    • 使用范围:当前局部作用域才可以使用
❌