阅读视图

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

👂《侦听器(watch)》— 监听数据变化执行副作用逻辑

在 Vue 中,有时你不仅仅是想让 “数据驱动视图”,你还可能想:

  • 数据变了,发一个请求
  • 数据变了,写一条日志
  • 数据变了,更新 localStorage 或调用浏览器 API

这时候,你就可以使用 watch —— Vue 提供的“观察者”函数。


🛠 什么是watch?

watch() 用于观察响应式数据的变化,并在变化时执行回调函数

它可以理解为是:

✅ 一个“数据监听器”,而不是“模板驱动者”。


✅ 用法一:监听一个ref(最常见)

<script setup> import { ref, watch } from 'vue' // 响应式数据 

const count = ref(0) // 用于显示日志信息的 ref

const log = ref('初始值:0') // 监听 count 的变化 

watch(count, (newVal, oldVal) => { log.value = `count 从 ${oldVal} 变成了 ${newVal}` }) 

</script>

<template>
<div>
<p>当前 count 值:{{ count }}</p>
<button @click="count++">点击增加 count</button> 
</div> 

<div> 
<p>监听日志:</p> 
<p style="color: green;">{{ log }}</p>
</div>
</template>

🧠 解释:

  • count 是一个 ref,Vue 会追踪它;
  • 每次 count.value 变化,回调就执行;
  • 参数顺序是:(新值, 旧值),可以用来比较或触发操作。

✅ 用法二:监听一个 getter 函数(推荐监听对象的属性)

const user = ref({ name: '90后晨仔' })

watch(
  () => user.value.name,  // 使用 getter 精确侦听某个属性
  (newName, oldName) => {
    console.log(`用户名从 ${oldName} 改成了 ${newName}`)
  }
)

🔍 为什么不直接监听user?

如果直接监听整个对象:

watch(user, () => { ... })

那么 Vue 只知道“对象变了”,不确定哪个字段变了

而用 getter 明确指定 user.value.name,可以更精确、更高性能地侦听。


✅ 用法三:高级选项(立即执行 & 深度监听)

watch(user, (newVal) => {
  console.log('用户对象发生了变化', newVal)
}, {
  immediate: true, // 初始化就触发一次
  deep: true       // 深度监听内部属性变化
})

🧠 配置项详解:

  • immediate: true:第一次绑定就执行(不等数据变化);

  • deep: true:深度递归追踪对象的所有属性(适合监听嵌套对象)。

⚠️ 注意:deep 监听性能开销较大,推荐配合精确 getter 使用


🧩 watch 与 computed 有什么区别?

对比点 watch computed
是否缓存 ❌ 不缓存,每次变化都触发 ✅ 有缓存,依赖不变不重新计算
是否返回值 ❌ 没有返回值,主要执行副作用 ✅ 返回值,可直接绑定到模板
用途 监听变化 + 副作用(请求、写缓存等) 派生状态 + 模板展示

✅ 结论:

  • computed 用来“声明派生数据”,更适合模板展示;
  • watch 用来“响应变化执行操作”,更适合副作用处理。

🧠 使用场景总结:

场景 推荐用法
请求接口(数据变化时) ✅ watch
打印日志 / 调试变化 ✅ watch
操作浏览器 API(如 localStorage) ✅ watch
定义 UI 展示数据 ❌ 用 computed 更合适

📦 示例源码仓库

你可以在我的 GitHub 仓库中查看完整 Vue3 响应式示例代码,包括 ref、computed 和 watch 的实践演示:

👉学习demo


❤️ 如果你觉得有帮助:

  • 点个赞 👍 让我知道你看见了~
  • 收藏 📌 随时查阅不怕忘~
  • 评论 💬 说说你对 watch 的理解 or 疑惑~
  • 关注 🔔 持续更新 Vue3 核心系列内容!

⚙️ 《响应式原理》— Vue 是怎么做到自动更新的?

🔍 Vue 响应式原理全解:Proxy、依赖追踪与视图更新是怎么协作的?

在 Vue3 中,最核心也最神奇的机制之一就是它的 响应式系统

它可以做到:

你只管改数据,界面自动变得和你想要的一样。

但这背后的“魔法”,其实主要靠 三大机制 协同工作:

✅ JavaScript Proxy

✅ 依赖追踪(依赖收集)

✅ 数据变动 → 触发视图更新

我们逐一拆开讲,最后你会发现:Vue 的响应式其实并不神秘。


1️⃣ Proxy:给数据加一个“监听器”

Vue3 用 Proxy 替代了 Vue2 的 Object.defineProperty,能对任意对象的读取和设置行为进行拦截

👇 模拟实现:

const state = new Proxy({ count: 0 }, {
  get(target, key) {
    console.log(`读取了属性:${key}`)
    return target[key]
  },
  set(target, key, value) {
    console.log(`设置了属性:${key} = ${value}`)
    target[key] = value
    // 通知依赖更新视图
    return true
  }
})

state.count     // 打印:读取了属性:count
state.count++   // 打印:设置了属性:count = 1

🧠 注释解析:

  • get() 拦截访问属性,比如模板中 {{ count }};
  • set() 拦截属性修改,比如执行 state.count++;
  • 在这两个钩子中,Vue 会插入自己的逻辑,追踪依赖并触发更新

🏷️ 类比一下:

把数据对象 state 想象成“仓库货架”,每个属性是一个“货物”。

  • get() 是你伸手去拿货,Vue 就偷偷记下“你拿了哪个货物(依赖)”;
  • set() 是你换了一个新货上去,Vue 就通知“所有拿过这个货的地方该更新了”。

2️⃣ 依赖追踪:谁用我,我就记住谁

每当模板或计算属性中访问响应式数据,Vue 会自动记录“谁用了这个变量”。

<template>
  <p>{{ count }}</p>
</template>

➡️ Vue 会记录:“这个

标签使用了 count”。

✅ Vue 怎么知道是谁在访问?

因为在模板编译成渲染函数后,Vue 会在执行这些函数时设置一个全局“正在渲染谁”的标记,然后在 get() 被触发时,记录下这个依赖。

👉 比喻一下:

就像老师点名时偷偷做记录:

谁抬头听讲了,就记下来了。以后要发新通知,就通知这些认真听讲的同学。


3️⃣ 触发更新:数据一改,立刻广播

当你修改响应式数据时,例如:

state.count++  // 或 count.value++

Vue 会在 set() 中查找有哪些地方依赖了这个属性,并重新执行相关渲染函数或计算属性,从而触发 DOM 更新。

✅ 整个流程串起来长这样:

用户访问 count --> Proxy.get 拦截 --> Vue 记录依赖
用户修改 count --> Proxy.set 拦截 --> Vue 通知所有依赖:你要更新了!
  • 详细的流程如下图所示:

deepseek_mermaid_20250726_ce6e70.png

流程说明:

  1. 依赖收集阶段 (读操作)

    • 用户读取 count 时触发 Proxy 的 get 拦截
    • Vue 将当前执行的函数(如组件渲染函数)注册为依赖
    • 依赖关系存储在 Dep 依赖集合中
  2. 更新通知阶段 (写操作)

    • 用户修改 count 时触发 Proxy 的 set 拦截
    • Vue 从 Dep 中获取所有关联的依赖项
    • 通知所有依赖项(如组件、计算属性)执行更新
    • 最终触发 UI 重新渲染或计算逻辑更新

💡 关键点:Vue 3 通过 Proxy 实现细粒度依赖跟踪,仅在数据变化时更新相关组件,避免不必要的渲染开销。也就是我们常说的:“数据变 → 视图自动变”

这一切发生得毫无感知,你甚至不用关心“如何更新”,因为 Vue 全自动完成了。


🧠 小总结:Vue 的响应式系统三部曲

原理 核心作用
Proxy 劫持对象属性的读写操作
依赖追踪 记录使用了哪些响应式数据
触发更新 当数据变化时,通知所有用到它的地方更新

🧪 深入学习建议

如果你想进一步理解 Vue 响应式系统底层的实现原理,可以参考我整理的示例源码和学习记录 👇

📦 我的 GitHub 仓库(含 Vue 响应式 DEMO):

👉 github.com/chenZai90/m…


🥳 如果你觉得这篇文章还不错…

点赞 鼓励一下作者

🧠 收藏 方便以后查阅

📣 转发 给你正在学 Vue 的朋友

💬 评论区 欢迎你来交流更多疑问和见解!

[LeetCode] 最长连续序列

Instruction

**难度: **中等; **分类: **哈希

给定一个未排序的整数数组 nums ,找出数字连续的最长序列(不要求序列元素在原数组中连续)的长度。

请你设计并实现时间复杂度为 O(n) **的算法解决此问题。

示例 1:

输入: nums = [100,4,200,1,3,2]
输出: 4
解释: 最长数字连续序列是 [1, 2, 3, 4]。它的长度为 4。

示例 2:

输入: nums = [0,3,7,2,5,8,4,6,0,1]
输出: 9

示例 3:

输入: nums = [1,0,1,2]
输出: 3

提示:

  • 0 <= nums.length <= 105
  • -109 <= nums[i] <= 109

思路

仍然给定一个哈希表,遍历里面每一个元素x,判断哈希表内是否有x - 1的元素, 若不存在,那么以x作为起点的x + 1, x + 2... x + y的最长连续序列即为x, x + 1, x + 2 ... x + y,长度为 y + 1

题解

/**
 * @param {number[]} nums
 * @return {number}
 */
var longestConsecutive = function(nums) {
   const numSet = new Set(nums);
   let longestStreak = 0;

   for(const num of numSet) {
    if(!numSet.has(num - 1)) {
        let currentNum = num;
        let currentStreak = 1;

        while(numSet.has(currentNum + 1)) {
            currentNum += 1;
            currentStreak += 1;
        }

        longestStreak = Math.max(longestStreak, currentStreak)
    }
   }

   return longestStreak;
};

🧮《计算属性》— 自动根据其它响应式数据得出结果

学习代码案例

🧮Vue 中的计算属性 computed:即聪明又节省性能的“智能变量”

在开发 Vue 应用时,我们经常会遇到这样的需求:

  • 根据多个状态变量组合出一个新的值;
  • 页面显示的内容是“派生”自已有的数据;
  • 想避免重复逻辑和无意义的重复计算。

这时,Vue 提供的 computed(计算属性)就能帮上大忙了!


🧠 什么是计算属性?

计算属性(computed)是 基于其它响应式状态自动计算出来的值。它本质上是一个带有缓存的 getter 函数,只有依赖的数据发生变化时才会重新执行计算逻辑。

可以把它想象成一个 聪明的公式变量,自动根据输入算出结果,但不会没事就重算,性能也很棒!


✅ 基本用法与示例

我们来实现一个显示用户“全名”的例子:

import { ref, computed } from 'vue'

// 定义两个响应式变量,分别代表姓和名
const firstName = ref('Tom')
const lastName = ref('Hanks')

// 创建一个计算属性 fullName,动态拼接姓和名
const fullName = computed(() => {
  return `${firstName.value} ${lastName.value}`
})

🔍 解释一下每一步:

  • firstName 和 lastName 是通过 ref() 创建的响应式变量;
  • computed() 接收一个函数,这个函数的返回值将被 Vue 当作“计算属性”的值;
  • 在函数中访问的所有响应式变量(这里是 firstName.value 和 lastName.value)会被 Vue 自动追踪;
  • 当它们发生变化时,fullName 也会自动更新。

🖼️ 在模板中使用 computed(无需 .value)

<template>
  <p>Full name: {{ fullName }}</p>
</template>

在模板里你不需要写 fullName.value,Vue 会帮你自动解包。写起来就像普通变量一样简洁。

这就是 Vue 的模板自动解包机制,为开发者省去了不少麻烦。


⚡computed 的缓存机制(性能优势)

Vue 会缓存计算结果,只有依赖的值发生变化时才重新计算。

举个例子:

const double = computed(() => {
  console.log('执行了计算逻辑')
  return count.value * 2
})

每次访问 double.value,如果 count.value 没变,console.log 不会执行。也就是说:多次使用,不会重复运算

这在以下场景中特别有用:

  • 复杂的逻辑运算;
  • 大量 DOM 更新时的性能优化;
  • 防止重复执行耗时操作。

✅ computed vs 普通函数

你可能会想:我直接用一个方法 function 不也能返回拼接值吗?

function getFullName() {
  return `${firstName.value} ${lastName.value}`
}

虽然结果一样,但这个函数 每次调用都会重新执行,没有缓存优化。而 computed 会在依赖不变时返回之前的结果,性能更优。


✅ computed vs watch?

特性 computed watch
类型 变量(getter) 监听器(副作用)
是否缓存 ✅ 是 ❌ 否,每次变化都执行
使用场景 派生状态、显示数据 触发请求、写入 localStorage 等副作用
写法 computed(() => {…}) watch(source, callback)

📦 常见使用场景

  1. 拼接字符串或数值

    如全名、带单位的价格、拼接地址等;

  2. 过滤/排序列表

    根据关键词筛选 todo 列表,根据价格排序商品等;

  3. 格式化数据展示

    比如金额千分位、时间戳格式化等;

  4. 结合 UI 状态

    判断按钮是否可用、是否高亮等逻辑处理;


🚫 computed 的使用误区

❌ 把异步请求写在 computed 里

计算属性是同步的,不能放异步逻辑,否则结果永远是 undefined。

// 🚫 错误写法
const userInfo = computed(async () => {
  const res = await fetch(...)
  return res.data
})

✅ 正确方式应该是用 watch() 或 onMounted() 发请求。


❤️ 总结一句话

计算属性是 Vue 给你的一个性能高、逻辑清晰、使用方便的“智能变量”。只要你能通过已有状态推导出一个新值,就应该优先用 computed!


🙌 如果你觉得这篇内容对你有帮助…

非常感谢你读到这里!如果你觉得这篇文章清晰易懂,帮助你更好地理解了 Vue 的 computed 计算属性:

👉 点赞 是对我最大的鼓励!

👉 收藏 方便你以后回顾~

👉 评论交流 我会一一回复你的问题!

👉 转发分享 给更多在学习 Vue 的朋友~

九、把异常当回事,代码才靠谱

把异常当回事,代码才靠谱

《代码整洁之道》第七章专门讲错误处理,核心观点很明确:错误处理不是边角料,而是代码健壮性的核心,必须认真设计。糟糕的错误处理会让代码混乱不堪,而优雅的处理方式能让主逻辑清晰可读。

用异常代替返回码

以前写代码总爱用返回码表示错误,比如:

// 反例:返回码让调用者被迫处理错误
public int deletePage(String pageId) {
    if (pageId == null) return -1;
    if (!pageExists(pageId)) return -2;
    // 执行删除逻辑
    return 0;
}

// 调用者必须嵌套判断
int code = deletePage("123");
if (code == 0) {
    // 处理成功
} else if (code == -1) {
    log.error("pageId为空");
} else if (code == -2) {
    log.error("页面不存在");
}

这种方式会导致调用者代码充满if-else,主逻辑被错误处理淹没。改用异常后清爽很多:

// 正例:异常分离错误处理和主逻辑
public void deletePage(String pageId) throws InvalidPageIdException, PageNotFoundException {
    if (pageId == null) throw new InvalidPageIdException("pageId不能为空");
    if (!pageExists(pageId)) throw new PageNotFoundException("页面不存在: " + pageId);
    // 执行删除逻辑
}

// 调用者集中处理异常
try {
    deletePage("123");
    // 处理成功逻辑
} catch (InvalidPageIdException e) {
    log.error(e.getMessage());
} catch (PageNotFoundException e) {
    log.error(e.getMessage());
}

异常的优势:错误处理代码从主逻辑中抽离,调用者可以选择立即处理或向上传递,灵活性更高。

先写Try-Catch-Finally,再填逻辑

作者建议“先写try-catch-finally”,这是个反直觉但有效的技巧。比如要实现一个读取文件并解析的功能:

// 先搭好异常处理框架
public String parseFile(String path) {
    FileReader reader = null;
    try {
        // 后续填读取逻辑
    } catch (FileNotFoundException e) {
        log.error("文件不存在: " + path, e);
        throw new ParsingException("解析失败", e);
    } catch (IOException e) {
        log.error("读取失败: " + path, e);
        throw new ParsingException("解析失败", e);
    } finally {
        if (reader != null) {
            try {
                reader.close();
            } catch (IOException e) {
                log.warn("关闭文件失败", e);
            }
        }
    }
}

先确定异常边界和资源释放方式,再填充核心逻辑,能避免遗漏错误处理。这本质是把“异常安全”作为前提,而不是事后补救

别返回null,别传递null

返回null是很多bug的根源,比如:

// 反例:返回null让调用者防不胜防
public List<User> getUsers() {
    if (dbConnection == null) return null; // 潜在NPE风险
    // 查询数据库
}

// 调用者忘记判空就会炸
List<User> users = getUsers();
users.forEach(u -> log.info(u.getName())); // NPE!

更安全的做法是返回空集合或抛出异常:

// 正例:返回空集合或抛异常
public List<User> getUsers() {
    if (dbConnection == null) return Collections.emptyList(); // 空集合更安全
    // 或者抛异常:throw new DbConnectionException("数据库连接未初始化");
    // 查询数据库
}

同理,也别在参数中传递null,可以用Objects.requireNonNull提前校验:

public void createUser(User user) {
    Objects.requireNonNull(user, "user不能为null");
    Objects.requireNonNull(user.getName(), "用户名不能为null");
    // 执行创建逻辑
}

null的问题:它不携带任何信息,出了问题很难定位根源。用空集合、特殊值或异常,能传递更丰富的上下文。

异常处理的其他实践

  • 异常要包含上下文:别只抛new Exception("错误"),要说明“什么操作失败,原因是什么”,比如new PaymentFailedException("订单123支付失败,余额不足")

  • 使用 unchecked 异常:可控异常(checked exception)会导致接口僵化,一旦新增异常,所有调用者都得改。优先用非可控异常,让调用者自主决定是否处理。

  • 错误处理也是“一件事”try块里只放可能出错的核心逻辑,catch块专注处理一种错误,别在里面塞额外逻辑。

代码对比:混乱 vs 整洁

混乱的错误处理:

public void processOrder(String orderId) {
    if (orderId == null) {
        System.out.println("订单ID为空");
        return;
    }
    Order order = orderDao.getById(orderId);
    if (order == null) {
        System.out.println("订单不存在");
        return;
    }
    if (!order.isPaid()) {
        System.out.println("订单未支付");
        return;
    }
    // 处理订单逻辑
}

整洁的错误处理:

public void processOrder(String orderId) {
    try {
        validateOrderId(orderId);
        Order order = getValidatedOrder(orderId);
        // 处理订单逻辑
    } catch (InvalidOrderIdException | OrderNotFoundException | UnpaidOrderException e) {
        log.error("处理订单失败: " + orderId, e);
    }
}

private void validateOrderId(String orderId) {
    if (orderId == null || orderId.isEmpty()) {
        throw new InvalidOrderIdException("订单ID不能为空");
    }
}

private Order getValidatedOrder(String orderId) {
    Order order = orderDao.getById(orderId);
    if (order == null) throw new OrderNotFoundException("订单不存在: " + orderId);
    if (!order.isPaid()) throw new UnpaidOrderException("订单未支付: " + orderId);
    return order;
}

核心差异:整洁的代码把错误判断和主逻辑分离,通过异常传递错误信息,调用者能快速定位问题,主流程一目了然。

错误处理的终极目标是:让正常流程的代码像“没有错误可能”一样简洁,而错误情况的处理又足够清晰,能快速排查问题。这需要把异常当“一等公民”来设计,而不是随便加个try-catch应付了事。

Vue3 + Pinia:子组件修改 Pinia 数据,竟然影响了原始数据?

1、业务场景

在组件通信中,我们经常遇到这样的需求:

  • 表格中的每一行都有一个“编辑”按钮
  • 点击按钮,打开弹窗,展示该行数据用于编辑
  • 修改完成后提交,或取消编辑

初始实现代码

1️、父组件中使用 Pinia 设置当前行数据
// store/station.ts
export const useStationStore = defineStore('station', () => {
  const rowData = ref<RowType>({
    name: '',
    id: '',
    // ...
  })

  const setRowData = (row: RowType) => {
    rowData.value = row //  注意这里
  }

  return { rowData, setRowData }
})

2、父组件中点击编辑按钮
const edit = (row: RowType) => {
  stationStore.setRowData(row)
  visible.value = true
}
3、子组件中直接使用 rowData
const { rowData } = storeToRefs(useStationStore())

// 在表单中双向绑定使用
<el-input v-model="rowData.name" />

好像到这里一切都没有问题,当我们点击编辑、修改数据的时候,就会发现 表格中的原始数据被修改了,我们分析一下原因

引用传递 + 响应式副作用

rowData.value = row

这里做的是引用赋值,也就是说:

  • rowData.valuerow 指向同一块内存
  • 子组件改 rowData.value.xxx,就是在改原始 row.xxx
  • Vue 的响应式系统会让一切看起来都“流畅同步”,但这不一定是你想要的!
正确做法:使用深拷贝隔离数据

这里不做第三方库的推荐,自行查找,也不建议自己手写,寻找一个好的轮子会事半功倍。这边主要介绍的是思路。

一些“取巧”的办法

1、如果你的数据只是修第一层,浅拷贝就可以解决 如:

row = JSON.parse((JSON.stringify(row)))
Object.assign(form.value, stationStore.rowData)

2、如果要深层上面的这两个方法就无法保证数据不会被修改

欢迎大家留言探讨你们的实践经验,如有疏漏或不妥之处,欢迎在评论区指出,互相学习,共同进步!

🧱 《响应式基础》— Vue 如何追踪你的数据变化?

Vue 的“响应式系统”是它最核心的机制之一,它的任务就是:让数据变化时,自动驱动视图更新。你不需要手动操作 DOM,也不用写一堆监听代码,只需要用 Vue 提供的 API 声明你的数据,Vue 就能帮你搞定数据和页面的同步问题。

来看这张简化的响应式流程图👇:

        响应式系统全景图
┌────────────────────────┐
│      响应式变量        │
│ ┌──────────────┐       │
│ │  ref()       │ <─────┐  ←—— 适合基本类型
│ │  reactive()  │       │  ←—— 适合对象/数组
│ └──────────────┘       │
│       ↓                │
│   自动依赖追踪         │ ←—— Vue 自动记录谁用到了这个数据
│       ↓                │
│   模板渲染 / watch     │ ←—— 页面或副作用函数都能感知变化
│       ↓                │
│   自动视图更新         │ ←—— 数据一改,界面就更新,0 操作 DOM
└────────────────────────┘

🔧 响应式数据的创建方式(详细解析)

Vue 3 提供了两个核心 API 来创建响应式状态:


✅ref():用于基本类型(字符串、数字、布尔值等)

import { ref } from 'vue'
// 创建一个响应式的基本类型变量 count,初始值为 0
const count = ref(0)
// 修改 count 的值时,要使用 .value 访问
count.value++

🧠 ref 特点:

  • ref() 会将原始值(比如数字)包裹成一个对象,结构类似于:{ value: 0 }
  • 所有对这个值的读取和修改,都必须通过 .value 属性;
  • 这是 Vue 通过 包一层 的方式让原始类型变得可以追踪变化;
  • 在模板中使用时,Vue 会帮你自动解包,所以可以直接用·{{ count }} 而不用 {{ count.value }}
  • 非常适合处理 表单字段值布尔开关计数器变量 等基本数据。

❓为什么不能直接用普通变量?

因为普通变量不是响应式的,Vue 没法知道它什么时候变了。用 ref 包装后,Vue 就能在 .value 变化时触发更新。


✅reactive():用于对象类型(对象、数组、Map、Set 等)

import { reactive } from 'vue'
// 创建一个响应式的对象 user
const user = reactive({
  name: 'Tom',
  age: 25
})

// 修改对象属性时,可以直接使用点语法,无需 .value
user.age++ // 页面会自动感知更新

🧠 reactive 特点:

  • reactive() 是基于 ES6 的 Proxy 实现的,它会对对象的所有属性做拦截
  • 当你读取 user.nameVue 内部会记录这个操作;
  • 当你修改 user.ageVue 会通知相关的依赖去更新视图;
  • 不用 .value,写起来更像我们平时写 JavaScript;
  • 非常适合管理结构性的数据,比如:用户信息、表单数据、列表、配置对象等。

⚠️ ref vs reactive 区别与选择

特性 ref() reactive()
适用类型 基本类型(string、number 等) 引用类型(对象、数组、Map 等)
是否使用 .value ✅ 需要(模板中可省略) ❌ 不需要
内部原理 包装成 { value: 原始值 } 使用 Proxy 做对象劫持
使用场景 表单输入、单个状态值 结构化数据(如 user、settings)

🔚 总结一句话

在 Vue 中,只要你用 ref()reactive() 声明了变量,Vue 就能帮你自动追踪依赖、检测变化,并更新视图,彻底解放你的手动 DOM 操作!


❤️ 如果你觉得有收获…

如果这篇文章让你对 Vue 的响应式系统有了更清晰的认识,欢迎点赞 👍、收藏 ⭐、评论交流 💬 或转发给朋友 📣!喜欢我的内容的话,也欢迎点击关注我!你的一次点赞和关注,是我持续创作最大的动力 ❤️

html2canvas + jspdf 前端PDF分页优化方案:像素级分析解决文字、表格内容截断问题

项目地址: github.com/baozjj/Seam…

在前端开发中,将HTML内容导出为PDF是个常见需求。但传统的实现方式在处理长内容时经常出现文字、表格被中间截断的问题。本文分享一种基于像素分析的解决思路,通过"渲染后分析"来实现更合理的分页切割。

问题背景:固定高度分页的局限性

纯前端PDF导出的常见方案是使用 html2canvas + jsPDF。这套方案的基本流程是:

  1. 使用html2canvas将HTML元素渲染为Canvas
  2. 按照PDF页面的固定高度切割Canvas
  3. 将每个切片作为一页添加到PDF中

这种方式的问题在于分页逻辑过于简单:

// 传统方案的分页逻辑
const pageHeight = 841.89; // A4页面高度
let currentY = 0;

while (currentY < totalHeight) {
  const slice = canvas.getImageData(0, currentY, width, pageHeight);
  pdf.addImage(slice, 'JPEG', 0, 0);
  currentY += pageHeight;
}

这种固定高度的切割方式不会考虑内容的语义边界,导致文字行被从中间切断,表格数据被拆分到不同页面,影响文档的可读性。如下图所示:

image.png

解决思路:渲染后的像素级分析

SeamlessPDF采用了不同的策略:先完整渲染内容,再通过像素分析找到合适的分页位置。优化后的效果如图所示:

image.png

整体架构

export async function generateIntelligentPdf({
  headerElement,
  contentElement,
  footerElement,
  onFooterUpdate,
}: PdfGenerationOptions): Promise<jsPDF> {
  // 第一阶段:将页面元素转换为 Canvas
  const canvasElements = await renderElementsToCanvas({
    headerElement,
    contentElement,
    footerElement,
  });

  // 第二阶段:计算页面布局参数
  const layoutMetrics = calculatePageLayoutMetrics(canvasElements);

  // 第三阶段:智能分页计算
  const pageBreakCoordinates = calculateIntelligentPageBreaks(
    canvasElements.content,
    layoutMetrics.contentPageHeightInPixels
  );

  // 第四阶段:生成 PDF 文档
  return await generatePdfDocument({
    canvasElements,
    layoutMetrics,
    pageBreakCoordinates,
    footerElement,
    onFooterUpdate,
  });
}

核心思路是将分页决策从渲染前移到渲染后,通过分析已渲染的Canvas来确定最佳分页位置。

技术实现细节

1. 像素级的行分析

系统会逐行分析Canvas的像素数据,识别适合分页的位置:

export function analyzePageBreakLine(
  yCoordinate: number,
  canvas: HTMLCanvasElement
): PageBreakAnalysisResult {
  const context = canvas.getContext("2d")!;
  const currentLineImageData = context.getImageData(
    0,
    yCoordinate,
    canvas.width,
    1
  ).data;

  const colorDistribution = analyzeColorDistribution(currentLineImageData);
  const lineCharacteristics = determineLineCharacteristics(
    colorDistribution,
    canvas.width
  );

  return {
    isCleanBreakPoint: lineCharacteristics.isPureWhite || lineCharacteristics.isTableLine,
    isTableBorder: lineCharacteristics.isTableLine,
  };
}

这个函数会分析每一行的颜色分布,识别两种适合分页的位置:

  • 纯白行:段落间的空白区域
  • 表格边框行:颜色为rgb(221,221,221)且占比超过80%的行

2. 向上搜索最优切割点

当分页位置不适合时,系统会向上搜索最近的合适位置:

export function findOptimalPageBreak(
  startYCoordinate: number,
  canvas: HTMLCanvasElement
): OptimalBreakPointResult {
  for (let y = startYCoordinate; y > 0; y--) {
    const analysisResult = analyzePageBreakLine(y, canvas);

    if (analysisResult.isCleanBreakPoint) {
      return {
        cutY: y + 1,
        isTableBorder: analysisResult.isTableBorder,
      };
    }
  }

  return {
    cutY: startYCoordinate,
    isTableBorder: false,
  };
}

这种向上搜索的策略确保了分页位置的合理性,避免在内容中间强制切割。

3. 表格边框的特殊处理

考虑到表格的特殊性,系统会检测表格顶部边框,避免在表格开始位置分页:

function shouldAdjustOffsetForPreviousTableBorder(
  pageBreakCoordinates: PageBreakCoordinate[]
): boolean {
  return (
    pageBreakCoordinates.length > 0 &&
    pageBreakCoordinates[pageBreakCoordinates.length - 1].isTableBorderBreak
  );
}

当检测到上一页以表格边框结束时,会调整下一页的起始位置,保持表格内容的连续性。

使用方式

从开发者角度看,使用方式相对简单:

const handleExport = async () => {
  const headerElement = reportHeader.value?.$el as HTMLElement;
  const contentElement = reportContent.value?.$el as HTMLElement;
  const footerElement = reportFooter.value?.$el as HTMLElement;

  const pdf = await generateIntelligentPdf({
    headerElement,
    contentElement,
    footerElement,
    onFooterUpdate: (currentPage: number, totalPages: number) => {
      footerState.currentPage = currentPage;
      footerState.totalPages = totalPages;
    },
  });

  await pdf.save(`报告.pdf`, { returnPromise: true });
};

只需要提供页眉、内容、页脚三个DOM元素,系统会自动处理分页逻辑。

方案局限性

这种方案也有一些限制需要注意:

1. 依赖特定的视觉特征

系统依赖特定的颜色值来识别表格边框(rgb(221,221,221))。如果表格使用了不同的边框样式,识别效果会受影响:

.report-table {
  border: 1px solid #ddd; /* 需要保持边框颜色的一致性 */
}

适用场景

这种方案比较适合以下场景:

  • 报表和数据展示页面的PDF导出
  • 包含大量表格的文档生成
  • 内容结构相对规整的页面

总结

通过"渲染后分析"的思路,可以在一定程度上改善前端PDF导出的分页质量。虽然这种方案有其局限性,但对于常见的报表导出场景,能够有效避免内容被不合理截断的问题。

项目地址: github.com/baozjj/Seam…

欢迎有类似需求的开发者尝试使用,也欢迎提出改进建议。

在Vue3+ElementPlus前端中增加表格记录选择的自定义组件,通过结合Popover 弹出框和Input输入框或者按钮选择实现

上次客户过来讨论的时候,说起其旧系统很多字段选择是通过弹出表格选择记录的,希望沿袭这个使用习惯,否则客户对新系统开发可能不适应,问我如何在Vue3+ElementPlus前端中是否可以实现,我说你基于JQuery的都可以实现,那么Vue3上开发肯定没问题的,而且响应会更加丝滑的,于是我就琢磨做一个通用的案例,整合在我的SqlSugar开发框架的Vue3+ElementPlus前端中。既然要弄就弄个通用的自定义表格选择组件,以便在更多的场合下可以使用,通过动态配置表格字段和相关的属性即可显示和选择。

本篇例子结合Popover 弹出框和Input输入框实现用户记录的选择,以及结合Popover 弹出框和按钮选择实现菜单中多语言键值的选择两项功能实现进行介绍。

 1、结合Popover 弹出框和Input输入框实现表格记录选择

在el-Popover的组件中,我们也都看到了他的一个弹出表格的简单案例,如下所示。

image

不过这个例子太过简单,参考下可以,但是和我们实现通用的数据记录查询以及表格内容可以变化等需求不符合,我们需要根据这样的模式,把Input输入和表格显示整合到里面去,这样可以在对应的表格上进行数据查询过滤,这样可以快速选择到记录。

我的大概需求如下:

使用 Vue 3 + Element Plus 实现的自定义组件示例,它可以用在不同的业务表中,要求通用化,功能上它集成了:

  • el-popover 弹出框,用于显示选择面板
  • el-input 输入框,点击触发弹出
  • el-table 表格控件,展示可选项,还可以对数据进行过滤查询
  • 可点击表格行来选择记录并回填到输入框中

该组件适用于选择一个记录项(如客户、产品等)并回传给父组件。

你可以根据需要扩展以下功能:

  • ✅ 支持远程搜索(使用 el-table 的 @filter-change 或增加 el-input 搜索框)
  • ✅ 支持分页(结合 el-pagination
  • ✅ 支持多选(表格加 type="selection",回传数组)
  • ✅ 支持懒加载(点击时加载 options)

我们先来看看成品的效果,下面案例是我基于用户记录表进行选择的处理下效果。

image

 单击选择用户的输入框,就会弹出对应的数据表格进行显示,我们可以在其中进行过滤查询,然后单击记录可以选中返回。

image

我们自定义组件名称命名为 TableSelector,那么它的使用代码如下所示。

复制代码

<el-form-item label="选择用户名">
  <TableSelector
    v-model="selectedUser"
    filter-placeholder="请输入人员编号/姓名/电话/住址等搜索"
    :columns="[
      { prop: 'id', label: 'ID', width: 80 },
      { prop: 'name', label: '用户名' },
      { prop: 'fullname', label: '真实姓名' },
      { prop: 'mobilephone', label: '移动电话' },
      { prop: 'email', label: '邮箱' }
    ]"
    :fetchData="loadUsers"
    row-key="name"
    placeholder="请选择用户"
  />
</el-form-item>

复制代码

v-model绑定选中的记录对象,其中 columns 属性我们可以根据不同的业务表进行配置显示,而 fetchData 则是传入一个函数给它获取数据的,交给调用的父组件进行数据的过滤和处理即可。

其中的loadUsers的实现,我们根据不同的业务实现它的数据请求查询即可,如下是函数代码。

复制代码

//加载用户,供选择表格处理
async function loadUsers(filter: string) {
  const params = {
    maxresultcount: 100,
    skipcount: 0,
    sorting: '',
    filter: filter
  };
  const res = await user.GetAllByFilter(params);
  return res.items;
}

复制代码

这样我们就可以实现通用的处理,不同的业务记录显示,我们配置表格内容显示和获取数据的逻辑即可。

自定义组件的props定义和emits事件如下所示。

复制代码

// Props
const props = defineProps<{
  columns: ColumnDef[];
  data?: any[];
  modelValue?: any | any[]; // 当前选中值(单个对象或数组)
  rowKey?: string; // 要显示的字段 key(如 name)
  multiple?: boolean;
  pagination?: {
    page: number;
    pageSize: number;
    total: number;
    onPageChange: (page: number) => void;
  };
  fetchData?: (filter: string) => Promise<any[]>; // 外部提供的数据加载函数
  placeholder?: string;
  filterPlaceholder?: string;
}>();

// Emits
const emit = defineEmits<{
  (e: 'update:modelValue', val: any | any[]): void;
}>();

复制代码

我们为了剥离具体表格字段的处理,因此配置了动态的columns参数,因此在自定义组件显示表格的时候,根据配置的信息进行显示字段即可,如下所示。

复制代码

<template>
  <div>
    <el-popover
      v-model:visible="visible"
      placement="bottom-start"
      width="600"
      trigger="focus"
      :teleported="false"
    >
      <div style="margin-bottom: 8px">
        <el-input
          v-model="filterText"
          :placeholder="filterPlaceholder || '输入关键字筛选'"
          clearable
        />
      </div>
      <!-- 表格区域 -->
      <el-table
        v-loading="loading"
        :data="tableData"
        :height="300"
        @selection-change="handleSelectionChange"
        @row-click="handleRowClick"
        :row-key="rowKey"
        :highlight-current-row="isSingleSelect"
        style="width: 100%"
      >
        <el-table-column v-if="multiple" type="selection" width="40" />
<template v-for="col in columns" :key="col.prop">
          <el-table-column
            :prop="col.prop"
            :label="col.label"
            :width="col.width"
          />
        </template>
      </el-table>

复制代码

在输入框的处理上,我们设置它的显示内容和清空按钮等处理,如下所示。

复制代码

      <!-- 弹出内容插槽的触发器 -->
      <template #reference>
        <el-input
          v-model="displayText"
          readonly
          :placeholder="placeholder || '请选择...'"
          suffix-icon="el-icon-arrow-down"
          @click.stop="visible = true"
          :clearable="true"
        >
          <template #suffix>
            <el-icon
              v-if="displayText"
              class="cursor-pointer"
              @click.stop="clearSelection"
            >
              <CircleClose />
            </el-icon> </template
        ></el-input>
      </template>

复制代码

而且输入框里面的显示displaytext是根据选中记录的属性进行计算显示的,如下代码所示。

复制代码

const displayText = computed(() => {
  const data = props.modelValue;
  if (!data) return '';
  if (Array.isArray(data)) {
    return data.map(v => v?.[props.rowKey || 'id']).join(', ');
  }
  return data?.[props.rowKey || 'id'] || '';
});

复制代码

通过对显示和过滤的属性进行监控,我们可以对数据的加载逻辑进行处理,从而实现数据的动态展示和过滤。

复制代码

const loadData = async () => {
  loading.value = true;
  try {
    if (props.fetchData) {
      tableData.value = await props.fetchData(filterText.value);
    } else if (props.data) {
      tableData.value = props.data;
    }

    // 恢复选中状态(仅多选模式)
    if (props.multiple && Array.isArray(props.modelValue)) {
      selectedRows.value = props.modelValue;
    }
  } finally {
    loading.value = false;
  }
};

watch(
  () => visible.value,
  async val => {
    if (val) {
      loadData(); 
    }
  }
);

// 输入过滤条件时防抖加载
watch(
  filterText,
  debounce(() => {
    if (visible.value) loadData();
  }, 300)
);

复制代码

我们也可以使用它实现多选记录的处理,多选提供复选框选择多个记录,并通过确认按钮返回,如下所示。

image

 

2、结合Popover 弹出框和按钮选择实现菜单中多语言键值的选择

除了上面通过输入框的方式进行弹出表格数据供用户选择外,有时候我们想在不影响常规输入框的情况下,提供一个额外的按钮,触发弹出选择记录的对话框。

如下面案例,我需要把多语言的键值列出来供我们定义菜单的或者一些界面元素的名称,界面效果如下所示。

image

 选中后,我们就可以及时的显示多语言的键和对应的语言内容了。

image

上面的自定义控件的使用代码如下所示。

复制代码

  <el-form-item label="显示名称" prop="name" class="flex flex-row">
    <el-input v-model="editForm.name" style="width: 180px" />
    <TableSelectorButton v-model="selectedLocal"
      filter-placeholder="请输入键名搜索"
      :columns="[
        { prop: 'key', label: '语言键名' },
        { prop: 'text', label: '国际化名称' }
      ]"
      :fetchData="loadLocals"
      row-key="key"
      @update:model-value="updateLocaleName"
    ></TableSelectorButton>
  </el-form-item>

复制代码

通过对事件

 @update:model-value="updateLocaleName" 

进行跟踪,我们就可以在内容变化的时候,及时通知其他控件进行内容更新了。 

上面的多语言处理和选择用户的输入框控件很相似,只是为了可以不影响输入框的可编辑性,我们通过按钮来选择在某些场合可能更为合理,因此扩展了这个选择组件,他们的差异只是把输入框换为按钮的处理,如下代码。

复制代码

      <!-- 弹出内容插槽的触发器 -->
      <template #reference>
        <el-button
          :type="props.buttonType || 'primary'"
          class="m-2"
          round
          plain
        >
          {{ props.buttonText || '...' }}
        </el-button>
      </template>

复制代码

这样我们定义的属性中,需要增加按钮的类型Type和按钮的名称来自定义即可,其他属性不变。

复制代码

// Props
const props = defineProps({
  columns: {
    type: Array as PropType<ColumnDef[]>,
    required: true
  },
  data: {
    type: Array as PropType<any[]>,
    required: false
  },
  modelValue: {
    type: [Object, Array] as PropType<any | any[]>, // 可以是对象或数组
    required: false
  },
  rowKey: {
    type: String,
    required: false
  },
  multiple: {
    type: Boolean,
    required: false
  },
  pagination: {
    type: Object as PropType<{
      page: number;
      pageSize: number;
      total: number;
      onPageChange: (page: number) => void;
    }>,
    required: false
  },
  fetchData: {
    type: Function as PropType<(filter: string) => Promise<any[]>>,
    required: false
  },
 buttonText: {
    type: String,
    required: false
  },
  buttonType: {
    type: String as PropType<'default' | 'text' | 'success' | 'primary' | 'warning' | 'info' | 'danger'>,
    required: false }, 
  filterPlaceholder: {
    type: String,
    required: false
  }
});

复制代码

以上两个例子,都是基于Popover 弹出框进行的自定义控件封装,主要就是为用户选择其他表或记录更加友好 ,从而快速实现数据的查询和选择处理的过程。

熟悉对前端自定义控件的封装,可以根据我们实际业务的需求进行界面逻辑的抽离和重用,实现统一化的界面效果处理。

在SqlSugar的开发框架的Vue3+ElementPlus前端中增加对报表模块的封装处理,实现常规报表的快速处理

在我们开发业务系统的时候,往往都需要一些数据报表进行统计查看,本篇内容介绍如何在实际的前端中对报表内容进行的一些封装操作,以便提高报表模块开发的效率,报表模块的展示主要是结合Vue3中比较广泛使用的echarts图表组件进行展示。

1、ECharts 图表组件介绍

ECharts 是一款基于 JavaScript 的开源可视化图表库,它非常高效,能够支持大量数据的渲染。与 Vue 3 配合时,ECharts 能够快速响应视图更新,确保报表的平滑渲染和高性能表现。ECharts 提供了多种类型的图表(如折线图、柱状图、饼图、散点图、雷达图等),并且支持多种交互方式(如缩放、提示框、动态数据等)。

Vue 3 提供了强大的组件化开发方式,可以将不同的图表封装成独立的组件,方便维护、重用和组合。每个图表组件可以根据不同的报表需求定制,实现高度复用。

Vue 3 提供了双向绑定和响应式的数据流机制,当 Vue 组件的状态发生变化时,图表可以自动更新。例如,通过绑定数据到 chartOption,一旦数据变化,ECharts 会自动重新渲染相应的图表。因此我们可以通过动态绑定数据的方式,实现报表模块的图表展示。

ECharts 的官网地址:echarts.apache.org/zh/index.ht…

ECharts 的各种案例地址:echarts.apache.org/examples/zh…l

image

我们单击每个具体的图表例子,可以查看对应的数据和形状,根据具体业务的数据和相关设置,替换这些数据就可以了。

image

2、定义通用图表组件和具体业务图表组件

我们为了方便开发各类型的业务图表,我们可以针对性的对图表类型、折线类型、条状类型图表进行一些简单的封装,以便方便统一使用相关的数据。

image

上面我在组件目录里面创建了几个不同类型的图表组件,组件主要公布一些props参数来传递,如下说是定义数据的属性。

复制代码

//声明Props的接口类型
interface Props {
  data?: any[]; // 固定列表方式,直接绑定,项目包括id,label属性
}
//使用默认值定义Props
const props = withDefaults(defineProps<Props>(), {
  data: () => {
    return [];
  },
});

复制代码

然后对data的属性监控,变化的时候,加载图表数据即可。

复制代码

watch(
  () => props.data,
  (newValue = []) => {
    loadChart(newValue);
  },
  { immediate: true } // 可选:如果你希望首次立即触发
);

// 加载图表数据
async function loadChart(res) {
  setOptions(
    {
      tooltip: {
        trigger: "item"
      },
      legend: { //图例设置
        orient: "vertical",
        right: 'right'
      },
      series: [
        {
          name: "标题信息", //图表标题
          type: "pie",    //图表类型,饼图
          radius: "60%",
          center: ["30%", "50%"],
          data: res, //动态数据
          emphasis: {
            itemStyle: {
              shadowBlur: 10,
              shadowOffsetX: 0,
              shadowColor: "rgba(0, 0, 0, 0.5)"
            }
          }
        }
      ]
    },
    {
      name: "click",
      callback: params => {
        console.log("click", params);
      }
    },
    {
      type: "zrender",
      name: "click",
      callback: params => {
        console.log("点击空白处", params);
      }
    }
  );
};

复制代码

而通用图表组件的界面代码比较简单,只需要标记下控件即可,如下代码所示。

<template>
  <div ref="pieChartRef" style="width: 100%; height: 35vh" />
</template>

有了简单的组件,我们再次在此基础上,对不同业务表现类型的图表进行更高层次的组件封装,以便可以用在首页或者其他地方,实现多个案例重用显示的处理。

例如,对应统计某个类型的业务图表,如下所示。

image

 对于数据的处理,我们通过接口动态获取图表统计的data数据,如下所示。

复制代码

// 饼图处理
async function searchPie() {
  const data = await report.CarbonSummaryCategory(Number(year.value));
  pieData.value = data.map(item => ({
    value: item.value,
    name: `${item.category} (${item.percentage})`
  }));
}

复制代码

组件封装的时候,我们直接使用前面封装的组件。

import Pie from '@/components/echarts/Pie.vue';

这个业务图表组件,我们为了通用,也需要提供一些属性供外部传入,实现数据的动态化处理,因此通过提供prop的属性方式处理。

复制代码

//声明Props的接口类型
interface Props {
  year?: number | string;
  month?: number | string;
  stack?: boolean; //是否堆叠
  type?: string; //图表类型
  showLink?: boolean;
}

//使用默认值定义Props
const props = withDefaults(defineProps<Props>(), {
  year: 0,
  month: 0,
  stack: true,
  type: 'bar',
  showLink: false,
});

复制代码

这样组件的处理逻辑代码如下所示。

复制代码

// 获取当前日期
const currentDate = new Date();
const currentYear = ref(currentDate.getFullYear());
const currentMonth = ref(currentDate.getMonth() + 1); // 月份从0开始,所以加1

// ✅ 在 setup 中补充默认值(只当 props 没传时才使用当前时间)
const year = computed(() => Number(props.year || currentYear.value))
const month = computed(() => Number(props.month || currentMonth.value))

const loading = ref(true);
const barData = ref(); // 折线图数据
const pieData = ref([]); // 饼图数据

//页面初始化加载
onMounted(async () => {
  await search();
});

async function search() {
  loading.value = true;
  await searchPie();

  setTimeout(() => {
    loading.value = false;
  }, 500);
}

// 监听 Props 变化
watch(
  () => [props.year, props.month, props.stack, props.type],
  async () => {
    await search();
  }
);

// 饼图处理
async function searchPie() {
  const data = await report.CarbonSummaryCategory(Number(year.value));
  pieData.value = data.map(item => ({
    value: item.value,
    name: `${item.category} (${item.percentage})`
  }));
}

复制代码

界面代码处理上,我们使用第一次封装的饼图组件,并通过提供外部卡片的显示封装,使它更加好看一些。如下所示。

复制代码

<template>
  <div class="welcome">
    <el-card>
      <template #header>
        <span style="font-size: 16px; font-weight: 500"> {{ year }} 年碳排放占比 </span>
        <div class="float-end" v-if="showLink">
          <router-link to="/report/carbon_summary">
            <el-link type="primary">查看详细</el-link>
          </router-link>
        </div>
      </template>
      <el-skeleton animated :rows="7" :loading="loading">
        <template #default>
          <Pie :data="pieData" />
        </template>
      </el-skeleton>
    </el-card>
  </div>
</template>

复制代码

其他条状图表、折线图表等其他类型的图表,依次通过这样的处理方式,可以实现业务组件的重用。

如我们可能在首页上放置一些图表组件,具体报表页面上也放置相同的图表组件,这样就可以很好的重用了。

image

在前端界面开发中,良好的组件封装和使用,可以给我们提供更好的开发效率,因此为了业务的快速开发,我们不仅在代码生成代码的方面持续优化,也在一些前端页面的开发中,提取一些常用的场景组件,最大化的实现代码的快速开发。

🚀 Vue 声明式渲染:让 HTML 跟着数据走(超详解)

Vue 最强大的能力之一,就是它的“声明式渲染”。简单来说,就是你只需要描述“数据长什么样”,Vue 会自动帮你把它渲染成对应的页面,并在数据变化时自动更新视图。

你不再需要手动操作 DOM,比如 document.querySelectorinnerHTML,Vue 全帮你搞定。


💡 什么是“响应式状态”?

Vue 通过一个“响应式系统”来追踪数据的变化。只要你的数据是响应式的,Vue 就能知道它变了,然后自动更新页面。

我们可以用两种方式创建响应式状态:


reactive():让对象变响应式

import { reactive } from 'vue'

// 创建一个响应式对象,包含一个 count 属性
const counter = reactive({
  count: 0
})

console.log(counter.count) // 输出 0
counter.count++ // 改变 count 的值,页面会自动响应更新

🔍 说明:

  • reactive() 接收一个对象,并返回这个对象的响应式“代理版”。
  • 它内部使用了 Proxy,可以追踪对象的属性访问与修改。
  • 适用于:对象、数组、Map、Set 等 复合类型

ref():让任意类型变响应式

import { ref } from 'vue'

// 创建一个字符串类型的响应式变量
const message = ref('Hello World!')

console.log(message.value) // 输出 "Hello World!"
message.value = 'Changed' // 修改 value,页面会自动更新

🔍 说明:

  • ref() 适用于基本类型(如 stringnumberboolean)。
  • 它会返回一个包裹对象,值存在 .value 属性里。
  • 相当于把普通变量“包裹”进一个响应式盒子中。

💡 什么是“自动解包”?—— Vue 帮你更舒服地写代码!

你可能注意到了:在 JavaScript 里,我们通过 .value 来访问 ref

console.log(message.value)

但在模板(<template>)里,却能直接这样写:

<template>
<h1>{{ message }}</h1>
</template>

为什么不需要 .value?这就是 Vue 的一个贴心设计:模板中会自动“解包” ref 的值

✅ 自动解包的好处:

  • 省去 .value,让模板更直观。
  • 你只管把变量塞进模板里,Vue 会自动帮你取 .value

❗ 注意:

  • 只有在模板里(或者 setup() 返回值中)才自动解包
  • 在 JS 代码中,仍然需要 .value 来访问或修改 ref 的值。

✨ 使用响应式数据渲染模板

我们可以这样结合上面的 ref()reactive(),在模板中实现动态展示:

<template>
  <!-- message 是 ref,会自动解包 -->
  <h1>{{ message }}</h1>

  <!-- counter 是 reactive 对象,直接访问属性 -->
  <p>Count is: {{ counter.count }}</p>
</template>

<script setup>
import { ref, reactive } from 'vue'

// 创建响应式的字符串
const message = ref('Hello Vue!')

// 创建响应式对象,包含计数器
const counter = reactive({
  count: 0
})
</script>

🧪 模板中还能用 JavaScript 表达式

你可以直接在 {{ }} 中写合法的 JS 表达式,比如对字符串进行处理:

<!-- 将 message 倒序显示 -->
<h1>{{ message.split('').reverse().join('') }}</h1>

⛳ 提示:

  • 表达式不要太复杂,保持模板简洁易读。
  • 如果逻辑过多,建议写在 <script> 中,用计算属性或方法处理。

📌 总结重点

功能点 说明
reactive() 创建响应式对象(如对象、数组等),基于 Proxy
ref() 创建可响应的基本类型(如字符串、数字等)
.value ref 包裹的值必须通过 .value 访问(除模板中)
自动解包 模板中使用 ref 时无需 .value,Vue 自动处理
声明式渲染 页面展示内容由数据状态决定,状态变化自动更新

🛠️ 小练习:试试这个!

试着写一个简单组件,包含一个输入框,实时展示用户输入的内容:

<template>
  <input v-model="inputText" placeholder="输入点什么..." />
  <p>你刚才输入的是:{{ inputText }}</p>
</template>

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

// 创建响应式的输入文本
const inputText = ref('')
</script>

这就是声明式渲染 + 响应式的魅力:你只管写数据逻辑,界面会自己动起来!


📖 结语

Vue 的响应式系统是它强大功能的核心之一。通过 reactive()ref(),我们可以轻松地将数据变为响应式状态,而 Vue 的自动解包机制也大大提升了开发体验。

希望这篇文章能帮助你更好地理解 Vue 的声明式渲染机制。如果你是 Vue 新手,建议多动手实践,加深理解。


📌 关注我,获取更多 Vue 学习干货!


package.json 中 dependencies 的版本号:它真的是版本号吗?

在 Node.js 或前端项目中,package.json 是项目依赖管理的核心文件。我们常常在 dependenciesdevDependenciespeerDependencies 等字段中指定每个依赖的“版本号”。然而,这些“版本号”并不总是真正的版本号,它们还可以是本地路径、Git 地址、文件系统地址,甚至是通配符等。本文将全面介绍这些用法及其含义,并通过示例加深理解。

一、常规版本号语法

在介绍一些你可能不熟悉的用法前,让我们先回顾一下你所熟悉的用法。

1. 精确版本

"lodash": "4.17.21"

表示只能安装 4.17.21 版本,不能有任何波动。

2. 范围版本

^:兼容主版本

"lodash": "^4.17.0"

表示安装 >=4.17.0 <5.0.0 的版本。常用于库依赖,确保 API 向后兼容。

~:兼容小版本

"lodash": "~4.17.0"

表示安装 >=4.17.0 <4.18.0 的版本。适用于只允许 patch 更新的情况。

区间范围

"lodash": ">=4.17.0 <5.0.0"

明确指定版本范围,更加灵活。

二、非常规版本号语法

下面让我们来瞧一瞧一些非常规的版本号。

1. 星号 *

"lodash": "*"

表示任意版本都可以安装。这种用法在生产环境中风险较高,容易引入不兼容版本,通常只在快速原型或测试中使用。在使用monorepo的时候, * 还有一些特殊的用处,将在 第八节 中进行介绍。

2. 最新版本标签(例如 latestbeta

json
"some-lib": "latest"
"some-lib": "next"
"some-lib": "beta"

这些是 NPM 的 dist-tag,会安装对应 tag 指向的版本。例如 latest 通常是当前稳定版。

三、本地路径引用

在本地开发多个包时联调,这种用法非常有用,可以让包管理工具从本地文件夹安装依赖。

1. file: 协议

json
复制编辑
"my-lib": "file:../my-local-lib"

表示从本地文件夹安装依赖。你也可以指定 .tgz 包文件:

"my-lib": "file:./libs/my-lib-1.0.0.tgz"

2. 省略 file: 前缀后的行为分析

有时候,你可能会看到不少人在使用file: 协议的时候,省略了 file: 前缀。其实,这种写法是有一些问题的,因为它的行为取决于你使用的包管理器(npm、yarn、pnpm)以及路径的格式。

✅ 可以省略 file: 的情况(部分工具 & 格式)

某些情况下,比如你写的是一个相对路径(不带协议),部分包管理器会自动推断为本地路径,并当作 file: 来处理

例如:

"my-lib": "../my-lib"

等价于:

"my-lib": "file:../my-lib"

在以下情况下大多数工具都可以正确解析

  • 相对路径:../lib./lib
  • 绝对路径:/Users/xxx/project/lib

🚫 不能省略 file: 的情况

以下情况必须带 file: 前缀:

  • 路径为压缩文件(如 .tgz)时,必须加 file:
"my-lib": "file:../my-lib-1.0.0.tgz""my-lib": "../my-lib-1.0.0.tgz" ❌(npm 会报错)
  • Monorepo 使用 Yarn workspace 时,推荐显式加 file:

虽然 Yarn 可以自动识别 workspace 下的路径,但显式指定 file:workspace: 更清晰且可读性更强。

各工具行为差异总结

场景 / 工具 相对路径是否可以省略 file: .tgz 是否可以省略 file: 推荐做法
npm ✅ 是(对文件夹) ❌ 否(对文件) 显式写 file: 更稳妥
Yarn Classic ✅ 是 ❌ 否 显式写 file: 更清晰
Yarn Berry ✅ 是 ❌ 否 更推荐 workspace:file:
pnpm ✅ 是(支持路径 auto 推断) ❌ 否 推荐使用 file:

3. 推荐实践

为了 最大兼容性可读性清晰,建议始终显式使用 file:

"my-lib": "file:../my-lib"
"my-lib": "file:../my-lib-1.0.0.tgz"

四、Monorepo 场景中的 workspace 协议

在使用Monorep的时候,工作区中的package.json(即非根package.json)中可以使用 workspace:*workspace:^workspace:~,例如:

"my-shared-lib": "workspace:*"

这是 Yarnpnpm 在 Monorepo 项目中支持的特性,用于声明依赖于工作区中其它包。

  • workspace:*:匹配任意版本。
  • workspace:^1.2.0:等价于 ^1.2.0,但强制来自 workspace 中的包。
  • workspace:~1.2.0:与上类似,强制来自 workspace。

注意npm 从 v7 之后也开始支持 workspaces,但不支持 workspace:* 这种语法。

五、Git 仓库引用

1. Git 地址(使用 HTTPS 或 SSH)

"my-lib": "git+https://github.com/username/my-lib.git"

"my-lib": "git+ssh://git@github.com:username/my-lib.git"

默认会安装该仓库的 master/main 分支的最新提交。

2. Git 地址 + tag / branch / commit

"my-lib": "git+https://github.com/username/my-lib.git#v1.2.3"
"my-lib": "git+https://github.com/username/my-lib.git#develop"
"my-lib": "git+https://github.com/username/my-lib.git#6db6f8a"
  • #v1.2.3:指定 tag
  • #develop:指定分支
  • #commit-hash:指定具体 commit

六、URL(HTTP 资源)

"my-lib": "https://example.com/path/to/my-lib.tgz"

可以直接从远程地址下载 .tgz 包。这种方式不常见,适用于自建仓库或发布测试包。

七、其他不常见用法

GitHub 缩写(npm 特有语法)

"my-lib": "username/my-lib"

等价于:

"my-lib": "git+https://github.com/username/my-lib.git"

还可以加上 tag:

"my-lib": "username/my-lib#v1.0.0"

八、混用情况分析:版本冲突怎么处理?

以 Monorepo 中为例:

// 根 package.json
"lodash": "^4.17.0"

// 工作区中某个子包的 package.json
"lodash": "*"

在这种情况下,具体安装哪个版本由依赖管理工具(Yarn、PNPM、npm)决定:

  • Yarn Berry/PNPM(hoist=false) 会使用子包中的声明版本(也可能安装多个版本)
  • NPM/Yarn classic(hoist=true) 优先使用根目录中的版本(如果符合子包声明)

所以建议统一声明版本,或在子包中使用 workspace:* 强制引用根版本。

九、总结

类型 示例 说明
精确版本 "lodash": "4.17.21" 只安装指定版本
版本范围 "lodash": "^4.17.0" 安装范围内最新版本
任意版本 "lodash": "*" 安装任意版本(不推荐)
dist-tag "my-lib": "latest" 安装发布标签对应版本
本地文件夹 "my-lib": "file:../my-lib" 引用本地包
本地 tar 包 "my-lib": "file:./lib.tgz" 本地 tgz 文件
Git 仓库 "my-lib": "git+https://github.com/u/lib.git" 从 Git 拉取
Git + tag/commit "my-lib": "git+https://...#v1.0.0" 指定 tag 或提交
URL 下载 "my-lib": "https://example.com/lib.tgz" HTTP 下载
workspace 协议 "my-lib": "workspace:*" Monorepo 工作区依赖

十、推荐实践

  • 生产环境中避免使用 *latest,防止出现意料之外的版本升级。
  • Monorepo 中尽量使用 workspace: ,确保一致性与版本对齐。
  • 本地开发联调建议使用 file: ,快速迭代。
  • 使用 ^~ 时要结合语义化版本管理(SemVer)策略,避免不兼容变更。

ArkUI 玩转水平滑动视图:超全实战教程与项目应用解析

在这里插入图片描述

摘要

随着移动设备和智能终端的普及,用户界面交互体验的丰富性变得越来越重要。水平滑动视图作为一种常见的 UI 交互方式,在图片轮播、标签切换、内容分页等场景中都有广泛应用。ArkUI 作为 HarmonyOS 的前端 UI 框架,提供了灵活的组件支持实现各种滑动效果。本文将详细介绍如何在 ArkUI 中实现水平滑动视图,包含示例代码和实际应用场景,助你快速上手并结合项目需求灵活应用。

引言

如今各种应用无论是新闻资讯、社交电商还是多媒体浏览,都不可避免地需要滑动视图来提升用户体验。水平滑动视图允许用户通过左右滑动浏览不同内容,给界面带来更高的交互效率和视觉美感。

在 ArkUI 中,实现水平滑动的关键组件主要是 ScrollerList,它们都支持设置滚动方向为水平方向。Scroller 更适合静态或者简单的滑动区域,而 List 更适合处理数据量大、需要动态渲染的长列表,支持懒加载和性能优化。

接下来,我们一步步来看如何使用 ArkUI 创建一个简单又实用的水平滑动视图。

ArkUI 中水平滑动视图的实现方式

通过 Scroller 组件实现水平滑动

Scroller 是 ArkUI 中实现滚动效果的基础组件。只要设置它的 direction 属性为 Axis.Horizontal,就能实现水平方向的滑动。

代码示例

@Entry
@Component
struct HorizontalScrollExample {
  build() {
    Scroller({ direction: Axis.Horizontal }) {
      Row() {
        for (let i = 1; i <= 5; i++) {
          Box()
            .width(150)
            .height(150)
            .margin(10)
            .backgroundColor(`#${Math.floor(Math.random() * 16777215).toString(16)}`);
        }
      }
    }.height(200);
  }
}

代码解析:

  • Scroller({ direction: Axis.Horizontal }):设置滚动方向为水平。
  • Row():横向排列子组件,配合 Scroller 实现滑动。
  • Box():单个滑动项,固定大小并随机背景色,方便视觉区分。
  • 整体容器高度设置为 200,确保显示合适的滑动区域。

通过 List 组件实现水平滑动

List 组件支持水平滑动和动态数据加载,适合需要展示大量数据或复杂内容的场景。

代码示例

@Entry
@Component
struct HorizontalListExample {
  data = Array.from({ length: 20 }, (_, i) => i + 1);

  build() {
    List({ direction: Axis.Horizontal, itemCount: this.data.length }) {
      (index) => {
        Box()
          .width(120)
          .height(120)
          .margin(8)
          .backgroundColor(`#${Math.floor(Math.random() * 16777215).toString(16)}`)
          .child(
            Text(`Item ${this.data[index]}`)
              .fontSize(16)
              .textColor(Color.White)
              .textAlign(TextAlign.Center)
              .width('100%')
              .height('100%')
              .alignment(Alignment.Center)
          );
      }
    }.height(160);
  }
}

代码解析:

  • Listdirection 设置为水平滑动。
  • 通过 itemCountindex 动态渲染内容。
  • 每个 Box 作为列表项,包含居中文本。
  • 适合大数据量展示,且可实现懒加载,性能表现更好。

水平滑动视图的实际应用场景及示例

图片轮播

图片轮播是最常见的水平滑动场景,用户通过左右滑动查看多张图片。

@Entry
@Component
struct ImageCarousel {
  images = [
    'https://example.com/image1.jpg',
    'https://example.com/image2.jpg',
    'https://example.com/image3.jpg',
  ];

  build() {
    Scroller({ direction: Axis.Horizontal }) {
      Row() {
        for (const url of this.images) {
          Image()
            .width(300)
            .height(200)
            .margin(10)
            .src(url)
            .objectFit(ImageFit.Cover);
        }
      }
    }.height(220);
  }
}

介绍:通过 Scroller 实现水平滑动图片列表,配合 Image 组件展示,适合轻量轮播。

标签导航栏

水平滑动标签栏,用于分类切换、标签筛选等。

@Entry
@Component
struct TagNav {
  tags = ['全部', '热门', '推荐', '最新', '精选', '关注'];

  build() {
    Scroller({ direction: Axis.Horizontal }) {
      Row() {
        for (const tag of this.tags) {
          Text(tag)
            .fontSize(18)
            .padding(10)
            .backgroundColor(Color.LightGray)
            .borderRadius(15)
            .margin(5);
        }
      }
    }.height(50);
  }
}

介绍:简单的横向标签栏,通过滑动浏览更多分类。

商品横向列表

电商应用中常见的商品横向推荐列表。

@Entry
@Component
struct ProductList {
  products = [
    { name: '商品A', price: '¥100' },
    { name: '商品B', price: '¥150' },
    { name: '商品C', price: '¥200' },
  ];

  build() {
    List({ direction: Axis.Horizontal, itemCount: this.products.length }) {
      (index) => {
        const product = this.products[index];
        Column()
          .width(140)
          .height(180)
          .margin(10)
          .backgroundColor(Color.White)
          .borderRadius(10)
          .padding(10)
          .child(
            Image()
              .width('100%')
              .height(100)
              .src('https://example.com/product.jpg')
              .objectFit(ImageFit.Cover)
          )
          .child(
            Text(product.name)
              .fontSize(16)
              .marginTop(8)
          )
          .child(
            Text(product.price)
              .fontSize(14)
              .textColor(Color.Red)
              .marginTop(4)
          );
      }
    }.height(200);
  }
}

介绍:使用 List 动态渲染商品卡片,结合图片、名称、价格组成横向滑动列表。

QA环节

Q1:Scroller 和 List 区别是什么?

  • Scroller 适合内容较少且简单的滑动区域,代码简单易用。
  • List 适合内容多、数据动态变化的场景,支持虚拟列表、懒加载,性能更优。

Q2:如何控制滑动条样式?

可以通过 Scroller 的样式属性,比如 scrollbar 相关配置,或者自定义样式来隐藏/美化滑动条。

Q3:如何处理滑动性能问题?

使用 List 组件代替大量静态子组件,避免一次性渲染所有内容,开启虚拟列表功能,合理设置 item 尺寸。

总结

ArkUI 提供了非常灵活的方式来实现水平滑动视图,既可以用简单的 Scroller + Row 组合快速完成,也可以用功能更丰富的 List 来处理复杂动态数据。通过合理选择组件和优化性能,水平滑动视图可以极大提升应用的交互体验。希望这篇文章和示例代码能帮助你快速上手 ArkUI 水平滑动开发,在实际项目中灵活应用。

ArkUI Canvas 实战:快速绘制柱状图图表组件

在这里插入图片描述

摘要

在 HarmonyOS 应用开发中,数据可视化越来越常见,比如健康数据、销售业绩、学习进度等都离不开图表展示。不过目前 ArkUI 并没有提供现成的图表组件,这时候开发者就需要用 Canvas 自己“动手”画图了。本文就带你一步步实战一个简单的柱状图绘制方法,并结合一些实际场景演示 ArkUI 在图表方面的可行方案。

引言

在 Web 前端或 Flutter、React Native 这类跨平台框架中,我们通常可以直接使用像 Chart.jsEChartsfl_chart 这样的图表库。但在 ArkUI(特别是 HarmonyOS NEXT 的 Stage 模型中),官方目前并没有内置图表组件,开发者只能通过 Canvas API 来自己绘制。

虽然听起来有点“原始”,但其实操作起来也没那么复杂,只要掌握基本的绘图逻辑,就能灵活实现柱状图、折线图、饼图等功能。而且自绘的图表灵活度更高,样式定制也更自由,非常适合做自定义可视化控件。

如何使用 ArkUI 的 Canvas 实现柱状图

搭建基本框架

ArkUI 的 Canvas 就像一块画布,我们可以在上面使用 rect()fillStyle()fill() 等指令去绘制各种图形。我们以一个简单的柱状图为例:

代码示例

@Entry
@Component
struct BarChartExample {
  private data: number[] = [50, 100, 150, 200, 250];

  build() {
    Column() {
      Text('销售数据柱状图')
        .fontSize(20)
        .margin({ bottom: 20 })

      Canvas(() => {
        this.data.forEach((value, index) => {
          const x = index * 60 + 10;  // 每根柱子之间留点空隙
          const y = 300 - value;      // y 坐标从顶部开始,越小越高
          const width = 40;
          const height = value;

          rect(x, y, width, height);         // 绘制矩形
          fillStyle('#007DFF');              // 设置颜色
          fill();                            // 填充颜色
        });
      }).width('100%').height(300).backgroundColor('#f4f4f4')
    }
    .padding(20)
  }
}

实际应用场景案例分析

应用场景一:健康类应用展示步数统计

你可以用柱状图来展示用户一周每天的步数,图表通过 Canvas 实现:

示例代码

private steps: number[] = [3000, 5000, 7000, 10000, 9000, 6000, 4000];

Canvas(() => {
  this.steps.forEach((step, index) => {
    const x = index * 50 + 15;
    const y = 200 - step / 100;
    const width = 30;
    const height = step / 100;

    rect(x, y, width, height);
    fillStyle('#34C759'); // 健康绿色
    fill();

    // 绘制底部文字
    fillStyle('#000');
    fillText(['日', '一', '二', '三', '四', '五', '六'][index], x, 210);
  });
}).width('100%').height(250);

分析说明:

  • 使用 step / 100 做了缩放处理,避免柱子过高。
  • 使用 fillText() 添加了底部说明,方便用户识别每一天。
  • 这种形式适合健康、健身类应用快速实现统计展示。

应用场景二:财务类 App 展示月度收支

假设你做的是一个财务应用,每个月的收入可以用柱状图来表示:

private incomeData: number[] = [3200, 2800, 3500, 4000, 3800, 4100];

Canvas(() => {
  this.incomeData.forEach((value, index) => {
    const scale = 3000; // 设置一个基准高度做缩放
    const x = index * 55 + 20;
    const y = 300 - (value / scale) * 200;
    const width = 35;
    const height = (value / scale) * 200;

    rect(x, y, width, height);
    fillStyle('#FF9500'); // 收入用橘黄色表示
    fill();
  });
}).width('100%').height(300);

场景亮点:

  • 数据来源可直接绑定后台 API。
  • UI 可以根据收入值动态调整柱子高度,便于用户直观看到趋势。
  • 柱状图也可以通过动画逐步增长,增强用户体验(后续可配合 animateTo() 实现)。

应用场景三:学习类平台展示课程完成进度

如果你开发的是一款教育类 App,柱状图可以展示各个课程模块的学习完成百分比:

private progress: number[] = [0.6, 0.8, 0.3, 0.9, 0.5];

Canvas(() => {
  this.progress.forEach((rate, index) => {
    const x = index * 60 + 10;
    const y = 200 - rate * 150;
    const width = 40;
    const height = rate * 150;

    rect(x, y, width, height);
    fillStyle('#5856D6'); // 紫色代表学习进度
    fill();
  });
}).width('100%').height(250);

场景说明:

  • rate 是 0~1 之间的浮点数,表示进度百分比。
  • 这种图表可以配合课程名称和进度条文本一同展示,提升用户交互感知。

QA 问答环节

Q1:Canvas 的性能表现怎么样?会不会卡顿? **A:**对于一般的数据量(比如几十个图形元素),Canvas 绘制性能完全没问题。如果数据量较大,可以考虑分页渲染或懒加载策略。

Q2:可以做动态动画效果吗?比如柱状图“长高”? **A:**目前 ArkUI Canvas 不直接支持动画指令,但可以通过定时更新数据 + update() 的方式实现动画效果,比如每 16ms 高度加 5px 实现平滑增长。

Q3:有没有第三方库可以快速做图表? **A:**目前社区有一些 HarmonyOS 图表组件项目,比如 harmonyos-chart(需要查看社区仓库),但集成复杂度略高。Canvas 是目前最通用、最灵活的方式。

总结

虽然 ArkUI 目前没有内置图表组件,但借助 Canvas 你完全可以实现各种自定义图表展示——无论是柱状图、折线图还是饼图,只要理解基本绘图逻辑,就能灵活扩展。

如果你正在做健康管理、数据统计、教育进度类的 App,不妨试着用 Canvas 自绘图表,既能灵活定制样式,又能锻炼你的绘图功力。

后续你还可以尝试:

  • 加入动画效果(逐步增长)
  • 支持图例、标签、动态点击交互
  • 使用手势放大缩小图表区域

HarmonyOS 的图表之路还在不断探索中,但一步步手绘,也是一种极好的成长方式。

一个前端开发者的救赎之路——JS基础回顾(二)

空语句

代码第一行是{,将其识别为代码块,例如:

/**
* 因为第一行是{},被识别成一个代码块,
* 然后代码块内部什么都没有就是空代码块,无操作
* 就等同于 + 0,所以结果为数字0
**/
{} + 0 = 0
// 这个逻辑和上面的一样,- 0 = -0
{} - 0 = -0
/**
* 因为第一行是{},被识别成一个空代码块
* 就成了 + {} => + Number([object object]) => + NaN
**/
{} + {} = NaN

关于上面的{}+{}=NaN,后面的步骤,D老师给的解释

  • 一元 + 运算符ToNumber 抽象操作):
    根据规范 §13.5.1+ 会调用 ToNumber 对操作数进行强制转换:
    • 对第二个 {}(对象)应用 ToNumber
      1. 调用 ToPrimitivehint: "number")转换为原始值(§7.1.1

        • 调用 valueOf():返回对象本身(非原始值)。
        • 调用 toString():返回 "[object Object]"(字符串)。
      2. 对字符串 "[object Object]" 应用 ToNumber§7.1.3):

        • 无法解析为数字,返回 NaN

分支语句

1. if/else

  • 基本语法:

        // else if 和 else 非必须且不可单独使用
        if(exp){
            statement1
        } else if (exp2) {
            statement2
        } else {
            statement3
        }
    
  • 案例一:判断奇偶数

    /**
    * 判断奇偶数
    * 数学:能整除2的就是偶数,否则就是奇数
    **/
    if (num % 2) {
        console.log(num + '是一个奇数')
    } else {
         console.log(num + '是一个偶数')
    }
    
  • 案例二:根据0 ~ 100输出成绩

    /**
    * 根据0 ~ 100输出成绩
    *  [90, 100] 输出A
    *  [80, 90] 输出B
    *  [70, 80] 输出C
    *  [60, 70] 输出D
    *  [0, 60] 输出E
    **/
    if (score >= 90 && score <= 100) {
        console.log('您的成绩为:A')
    } else if(score >= 80) {
        console.log('您的成绩为:B')
    } else if(score >= 70) {
        console.log('您的成绩为:C')
    } else if(score >= 60) {
        console.log('您的成绩为:D')
    } else {
        console.log('您的成绩为:E')
    }
    
  • 案例三:判断闰年

    /**
    * 什么是闰年?
    * 世纪闰年:公历年份是整百的,必须是400的倍数才是闰年
    * 普通闰年:公历年份是4的倍数,且不是100的倍数的
    **/
    if (!(year % 400 || !(year % 4) && year % 100) {
        console.log(year + '年,是闰年!')
    } else {
        console.log(year + '年,是平年!')
    }
    

2. switch

  • 基础语法

    switch(n) {
    case 1:          // 如果n === 1,从这里开始执行
        // 执行第一个代码块
        break;       // 到这里停止
    case 2:          // 如果n === 2,从这里开始执行
        // 执行第二个代码块
        break;       // 到这里停止
    case 3:          // 如果n === 3,从这里开始执行
        // 执行第三个代码块
        break;       // 到这里停止
    default:         // 如果前面都不匹配,从这里开始执行
        // 执行第四个代码块
        break;       // 到这里停止
    
  • 案例一:Switch语句在实际开发中常用来做一些状态标签展示

    switch(n) {
    case '001':          
        document.write("未付款")
        break;       
    case '002':          
        document.write("已付款")
        break;       
    case '003':          
        document.write("已发货")
        break;       
    case '004':         
        document.write("已完成")
        break; 
    default:         // 如果前面都不匹配,从这里开始执行
        document.write("出错了")
        break;       // 到这里停止
    
  • 案例二:判断一个月有多少天

    // 根据1 ~ 12的数字来输出一个月有多少天,不考虑闰年
    switch(month) {
        case 1:
        case 3:
        case 5:
        case 7:
        case 8:
        case 10:
        case 12:
            console.log(month + '月有31天')
            break;
        case 4:
        case 6:
        case 9:
        case 11:
            console.log(month + '月有30天')
            break;
        case 2:
            console.log(month + '月有28天')
            break;
    }
    

  switch语句中的case子句只指定了预期代码的起点,并没有指定终点。在没有break语句的情况下,switch语句从匹配其表达式值的case代码块开始执行,一直执行到代码块结束。

  注意在前边两个例子中,case关键字后面分别是数值和字符串字面量。这是实践中使用switch语句的常见方式,但注意ECMAScript标准孕育每个case后面跟任意表达式。(不建议这样,不如直接使用if语句

  switch语句的首先对跟在switch关键字后面的表达式求值,然后再按照顺序求值case表达式,直至遇到匹配的值。这里的匹配使用的是===全等操作符,而不是==相等操作符,因此表达式必须在没有类型转换的情况下匹配。

  考虑到在switch语句执行时,并不是所有case表达式都会被求值,所以应该避免使用包含副作用的case表达式,比如函数调用或赋值表达式。最可靠的做法是在case后面只写常量表达式。

循环语句

循环语句必须要有某些固定的内容:

  1. 初始化
  2. 条件判断
  3. 要执行的代码
  4. 自身改变

while

  • 基本语法

    while (exp) {
        statement
    }
    
  • 案例一:求数字1-100的和

        var num = 1;
        var sum = 0;
        while (num <= 100) {
            sum += num;
            num++;
        }
    
  • 案例二:求一个数的阶乘

    var factorial = 1, i = 1;
    while(i < num) {
        factorial *= i;
        i++;
    }
    

do/while

  do/while循环与while循环类似,区别是对循环表达式的测试在循环底部而不是顶部。这意味着循环体始终会至少执行一次。语法如下:

```js
do {
    statement
} while (exp)
```

  do/while循环可以说是实际开发中使用的最少的一个循环了,几乎没用过,下面给一个do/while循环的场景

```js
// 进入一个系统前必须输入密码或者验证码
do {
    const input = prompt('请输入验证码');
} while (!input);
```

注意: do/while循环必须始终以分号终止。而while循环在循环体使用花括号时不需要分号。

for

  • 基础语法:

    for(initialize; test ; increment)
        statement
    
    • initialize, test, increment是三个表达式(以分号隔开),分别负责初始化、测试和递增寻喊变量。
    • 对for循环,三个表达式中任何一个都可以省略,只有两个分号是必需的
    • 因此,for(;;)与while(true)一样,是另一种编写无穷循环的方式
  • 案例一:求1-100的和

    var sum = 0;
    for (num = 1; num <= 100; num++) {
        sum += num;
    }
    
  • 案例二:九九乘法表

    // 使用 var 声明变量
    for (var i = 1; i <= 9; i++) {
      var row = ''; // 存储当前行的字符串
      for (var j = 1; j <= i; j++) {
        // 拼接每个乘法式,\t 用于对齐
        row += j + ' × ' + i + ' = ' + (i * j) + '\t';
      }
      console.log(row); // 输出当前行
    }
    

三种循环语句如何选择

graph TD
    A[开始循环] --> B{循环次数是否明确?}
    B -->|是| C[使用 for]
    B -->|否| D{是否需要至少执行一次?}
    D -->|是| E[使用 do-while]
    D -->|否| F[使用 while]

【HarmonyOS6】获取华为用户信息

背景

这篇文章主要记录,个人开发者在获取华为用户授权后,拿到用户的头像和名字。获取用户的电话号码的获取需要应用获得scope权限,现仅对企业开发者开放。 在这里插入图片描述

环境配置

在项目中生成证书请求文件(CSR)

  • 在build->Generate Key and CSR中选择 在这里插入图片描述
  • 根据下面填写内容生成CSR文件
  • 创建Key store,密码要记住的哈~~ 在这里插入图片描述
  • 输入Alias(别名)后面再项目结构配置的时候需要填写的哈,要记住。(尴尬,写到才发现单词写错了。。。)

在这里插入图片描述

  • 保存CSR地址,点击Finish完成创建
  • 在这里插入图片描述

在AGC中创建项目

在这里插入图片描述

  • 在证书、APPID和Profile中创建APP

在这里插入图片描述

  • 新建证书,然后下载证书,后续的项目配置需要使用

在这里插入图片描述在这里插入图片描述

  • 新建Profile,并下载后续给项目配置使用

在这里插入图片描述在这里插入图片描述

  • 在项目中添加公钥指纹,选择自己新建的公钥 在这里插入图片描述

在项目结构中手动添加证书

  • 先查看Bundle name和AGC的项目上填写的是否一致。像我这里的,AGC是com.myapp.accentdemo,项目的是com.example.accountdemo,因此,需要先调整好Bundle name 在这里插入图片描述
  • 在AppScope的app.json5文件中进行修改 在这里插入图片描述在这里插入图片描述
  • 在Signing Configs选项卡中配置项目信息在这里插入图片描述
  • 配置Client ID,在Entry->module.json5中添加Client ID,在AGC中复制ID 在这里插入图片描述在这里插入图片描述

获取用户信息代码编写

UI

  • 需要引用 authentication 和 ImageType
import { ImageType } from '@kit.UIDesignKit'
import { authentication } from '@kit.AccountKit'
import { util } from '@kit.ArkTS'

@Entry
@ComponentV2
struct Index {
  @Local UserIcon: ImageType = $r('app.media.user_dark')
  @Local UserName: string = "炸鸡仔"
  AuthRequest?: authentication.AuthorizationWithHuaweiIDRequest

  build() {
    Column({ space: 10 }) {
      Image(this.UserIcon)
        .width(80)
        .height(80)
        .borderRadius(40)
      Text(this.UserName)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 5 })
      Button("获取用户头像和名字")
        .width("80%")
        .onClick(async () => {
         }
    }
    .height('100%')
    .width('100%')
  }
}

在这里插入图片描述

获取用户信息请求对象代码

  • 通过authentication.HuaweiIDProvider().createAuthorizationWithHuaweiIDRequest()的方式创建请求对象
this.AuthRequest = new authentication.HuaweiIDProvider().createAuthorizationWithHuaweiIDRequest();
 // 获取头像昵称需要传如下scope
          this.AuthRequest.scopes = ['profile', 'openid'];
          // 若开发者需要进行服务端开发,则需传如下permission获取authorizationCode
          this.AuthRequest.permissions = ['serviceauthcode'];
          // 用户是否需要登录授权,该值为true且用户未登录或未授权时,会拉起用户登录或授权页面
          this.AuthRequest.forceAuthorization = false;
          // 用于防跨站点请求伪造
          this.AuthRequest.state = util.generateRandomUUID();
          // 用户没有授权的时候,是否弹窗提示用户授权
          this.AuthRequest.forceAuthorization = true;
  • 授权请求对象几个重要的属性:

    • scopes:获取用户数据,与permissions属性不能同时为空,否则会报错,默认值:['openid']。其中的参数有

      • profile:华为账号用户的基本信息,如昵称头像等。
      • openid:华为账号用户的OpenID、UnionID。UnionID作为用户标识,OpenID为用户在当前应用的用户标识。
      • phone:华为账号快速验证手机号,需要scope权限,也就是企业用户哈。
      • quickLoginAnonymousPhone:获取华为账号绑定的匿名手机号,需要scope权限,也就是企业用户哈。
    • permissions:用于获取用户授权临时凭据和用户身份认证信息,与scopes属性不能同时为空。

      • serviceauthcode:用户授权临时凭据。
      • idtoken:用户身份认证信息。
    • forceAuthorization:表示华为账号未登录时,是否需要强制拉起华为账号登录页。默认值:true。

    • state:随机数并做一致性校验。该参数与响应体中返回的state比较。

    • nonce:该参数会包含在返回的ID Token中,通过校验一致性,可用于防止重放攻击。

    • idTokenSignAlgorithm:默认值:PS256,用于指定ID Token的签名算法。

    • supportAtomicService:在元服务场景下,当传入scopes包含profile时,是否支持获取用户头像昵称。如果该值为true,可以正常获取用户头像昵称。如果该值为false,执行授权请求将返回1001500003 错误代码。

获取请求数据

const controller = new authentication.AuthenticationController(this.getUIContext().getHostContext());
          const data: authentication.AuthorizationWithHuaweiIDResponse =
            await controller.executeRequest(this.AuthRequest);
          const authorizationWithHuaweiIDResponse = data as authentication.AuthorizationWithHuaweiIDResponse;
          const state = authorizationWithHuaweiIDResponse.state;
          if (state && this.AuthRequest.state !== state) {
            console.error(`Failed to authorize. The state is different, response state: ${state}`);
            return;
          }
          if (authorizationWithHuaweiIDResponse && authorizationWithHuaweiIDResponse.data) {
            //用户头像链接,有效期较短,建议先将头像下载保存后再使用,这里只是用于演示哈
            if (authorizationWithHuaweiIDResponse.data.avatarUri) {
              this.UserIcon = authorizationWithHuaweiIDResponse.data.avatarUri;
            }
            //用户昵称
            if (authorizationWithHuaweiIDResponse.data.nickName) {
              this.UserName = authorizationWithHuaweiIDResponse.data.nickName;
            }
            //唯一ID
            const userUnionID = authorizationWithHuaweiIDResponse?.data?.unionID;
            //当前应用ID
            const userOpenID = authorizationWithHuaweiIDResponse?.data?.openID;
          }

请求返回的结果

完整代码

import { ImageType } from '@kit.UIDesignKit'
import { authentication } from '@kit.AccountKit'
import { util } from '@kit.ArkTS'

@Entry
@ComponentV2
struct Index {
  @Local UserIcon: ImageType = $r('app.media.user_dark')
  @Local UserName: string = "炸鸡仔"
  AuthRequest?: authentication.AuthorizationWithHuaweiIDRequest

  build() {
    Column({ space: 10 }) {
      Image(this.UserIcon)
        .width(80)
        .height(80)
        .borderRadius(40)
      Text(this.UserName)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 5 })
      Button("获取用户头像和名字")
        .width("80%")
        .onClick(async () => {
          this.AuthRequest = new authentication.HuaweiIDProvider().createAuthorizationWithHuaweiIDRequest();
          // 获取头像昵称需要传如下scope
          this.AuthRequest.scopes = ['profile', 'openid'];
          // 若开发者需要进行服务端开发,则需传如下permission获取authorizationCode
          this.AuthRequest.permissions = ['serviceauthcode'];
          // 用户是否需要登录授权,该值为true且用户未登录或未授权时,会拉起用户登录或授权页面
          this.AuthRequest.forceAuthorization = false;
          // 用于防跨站点请求伪造
          this.AuthRequest.state = util.generateRandomUUID();
          // 用户没有授权的时候,是否弹窗提示用户授权
          this.AuthRequest.forceAuthorization = true;
          const controller = new authentication.AuthenticationController(this.getUIContext().getHostContext());
          const data: authentication.AuthorizationWithHuaweiIDResponse =
            await controller.executeRequest(this.AuthRequest);
          const authorizationWithHuaweiIDResponse = data as authentication.AuthorizationWithHuaweiIDResponse;
          const state = authorizationWithHuaweiIDResponse.state;
          if (state && this.AuthRequest.state !== state) {
            console.error(`Failed to authorize. The state is different, response state: ${state}`);
            return;
          }
          if (authorizationWithHuaweiIDResponse && authorizationWithHuaweiIDResponse.data) {
            //用户头像链接,有效期较短,建议先将头像下载保存后再使用,这里只是用于演示哈
            if (authorizationWithHuaweiIDResponse.data.avatarUri) {
              this.UserIcon = authorizationWithHuaweiIDResponse.data.avatarUri;
            }
            //用户昵称
            if (authorizationWithHuaweiIDResponse.data.nickName) {
              this.UserName = authorizationWithHuaweiIDResponse.data.nickName;
            }
            //唯一ID
            const userUnionID = authorizationWithHuaweiIDResponse?.data?.unionID;
            //当前应用ID
            const userOpenID = authorizationWithHuaweiIDResponse?.data?.openID;
          }
        })
    }
    .height('100%')
    .width('100%')
  }
}

实现的效果如下

  • 未授权时,会有弹窗提示:

在这里插入图片描述

  • 然后就可以显示用户头像和名字了

在这里插入图片描述

一个 ID 溢出引发的线上资损

你给某支付平台做「交易流水导出」功能。
需求很直接:把数据库里 bigint(20) 的订单 ID 渲染到表格里。
你顺手写了这么一行:

// ❌ 线上事故代码
const row = `<tr><td>${order.id}</td><td>${order.amount}</td></tr>`;

结果上线第二天,财务发现:
“有笔 1.8e+17 的订单,点进去详情金额对不上!”

排查发现:

  • 数据库 ID 是 18012345678901234567
  • 但 JS 里 Number(order.id) 变成了 18012345678901234000 —— 尾部 567 直接丢了
  • 因为它超过了 Number.MAX_SAFE_INTEGER(9007199254740991),JS 的 64 位浮点数精度崩了。

这可不是显示问题,而是 ID 错位导致查串了订单,差点引发资损。


解决方案:三层防御,把大数关进“安全笼”

1. 表面用法:用 BigInt 代替 Number

// ✅ 正确处理大数
const bigId = BigInt("18012345678901234567"); // 🔍 字符串转 BigInt
console.log(bigId.toString()); // "18012345678901234567"

// 用于计算
const nextId = bigId + 1n; // 🔍 必须加后缀 n

关键点:

  • 必须用字符串初始化BigInt(18012345678901234567) 会先被转成 Number 再转 BigInt,已经丢精度了;
  • 运算时操作数必须都是 BigInt,不能和 Number 混算;
  • 比较可以用 ===,但 == 会自动转换,有坑。

2. 底层机制:为什么 JS 数字会“失精”?

类型 存储方式 范围 精度
Number IEEE 754 双精度浮点 ±1.79e+308 53 位有效数字
BigInt 任意长度整数 无上限 完全精确

原理图(文字版):

flowchart LR
    A["Number: [1位符号][11位指数][52位尾数]"] --> B["实际精度 2^53 - 1 = 9007199254740991"]
    B --> C["超过这个值,尾数不够用,低位被舍入"]

所以 9007199254740992 === 9007199254740993 在 JS 里居然是 true

3. 设计哲学:从“传输”到“渲染”全链路防溢出

(1)接口层:后端传字符串,前端不碰大数

{
  "order_id": "18012345678901234567",  // 🔍 ID 用字符串
  "amount": 123456789,                 // 数值小,可用 Number
  "user_id": "18012345678901234568"
}

(2)状态层:用 BigInt 做计算,但不存进 Redux

// calc.js
export function addId(idStr, offset) {
  const id = BigInt(idStr);
  return (id + BigInt(offset)).toString(); // 🔍 计算完转回字符串
}

(3)渲染层:永远用字符串插值

// ✅ 安全渲染
const row = `<tr data-id="${order.id}">  // 🔍 直接用字符串,不转 Number
  <td>${order.id}</td>
</tr>`;

应用扩展:可复用的配置片段

1. Axios 自动转换大数字段

// axios.interceptor.js
axios.defaults.transformResponse = [
  (data, headers) => {
    if (headers['content-type']?.includes('json')) {
      return JSON.parse(data, (key, value) => {
        // 🔍 指定字段转 BigInt
        if (['order_id', 'user_id'].includes(key) && /^\d{16,}$/.test(value)) {
          return value; // 🔍 保持字符串,由业务层决定是否转 BigInt
        }
        return value;
      });
    }
    return data;
  }
];

2. 环境适配说明

场景 注意点
IE 浏览器 BigInt 不支持,需降级用 string + bignumber.js
TypeScript 类型定义用 bigintstring,别用 number
JSON 序列化 BigInt 不能直接 JSON.stringify(),需自定义 toJSON

举一反三:3 个变体场景

  1. 金融计算(高精度小数)
    BigInt 模拟定点数:123.45 存为 12345n(单位:分),运算后再除 100
  2. 数据库主键生成(Snowflake ID)
    前端生成 ID 时用 BigInt 拼接时间戳、机器码、序列号,避免重复;
  3. 区块链地址校验
    以太坊地址是 256 位整数,用 BigInt 做范围校验和签名计算。

小结

别让 Number 碰超过 16 位的数字。
传用字符串,算用 BigInt,渲染不转 Number,三招封死精度陷阱。

一个链接,两种命运

你在给某连锁健身房做「会员扫码签到」系统。
需求很明确:

  • 前台贴一张二维码,教练和会员都扫它;
  • 教练用 PC 后台管理,要打开 Web 管理系统(React);
  • 会员用手机扫码,要跳转 H5 轻应用(Vue);

结果第一版上线,会员扫完直接进了后台首页,一脸懵:“这表格是干啥的?”

你立刻意识到:同一个链接,必须智能分流


解决方案:三层识别 + 渐进式加载

1. 表面用法:用 User-Agent 做第一道筛子

// router.js
const PC_UA = /Windows|Macintosh|Linux/;
const MOBILE_UA = /Android|iPhone|iPad|iPod/;

function getDeviceType() {
  const ua = navigator.userAgent;
  if (PC_UA.test(ua)) return 'pc';
  if (MOBILE_UA.test(ua)) return 'mobile';
  // 🔍 降级:看屏幕宽度
  return window.innerWidth < 768 ? 'mobile' : 'pc';
}
// entry.js
async function main() {
  const type = getDeviceType();
  if (type === 'pc') {
    await import('./web-app.js');      // 🔍 动态加载 PC 版
  } else {
    location.href = 'https://m.corp.com/app'; // 🔍 跳 H5
  }
}
main();

关键点:

  • PC 端直接加载 SPA,不跳转,体验无缝;
  • 手机端跳转到独立 H5 域名,便于独立迭代和 SEO。

2. 底层机制:为什么不能只靠 UA?

识别方式 准确率 风险 适用场景
User-Agent 90% 可伪造,部分平板 UA 模糊 快速分流
屏幕尺寸 85% 折叠屏、横屏 iPad 易误判 降级兜底
触摸支持 95% maxTouchPoints > 1 更准 辅助判断

最终用 组合判断 提升准确率:

function isMobile() {
  const ua = navigator.userAgent;
  if (/Android|iPhone/.test(ua)) return true;
  if (/iPad|Macintosh/.test(ua) && 'ontouchend' in document) return true; // 🔍 iPadOS
  return window.innerWidth <= 768 && navigator.maxTouchPoints > 1;
}

3. 设计哲学:把“分流”做成中间层服务

与其在每个项目里重复写判断逻辑,不如抽成一个 轻量级网关层

// gateway.js (Node.js)
app.get('/entry', (req, res) => {
  const { userAgent, 'x-forwarded-for': ip } = req.headers;
  const isMobile = /Android|iPhone|Mobile/.test(userAgent);
  
  if (isMobile) {
    res.redirect('https://m.corp.com/app?from=' + ip); // 🔍 带来源信息
  } else {
    res.sendFile(path.join(__dirname, 'web/index.html')); // 🔍 直接吐 PC 页面
  }
});

这样前端只维护一个入口页,逻辑全在服务端,便于灰度、埋点、拦截爬虫


应用扩展:可复用的配置片段

1. 静态页分流模板(零后端依赖)

<!-- index.html -->
<script>
  (function() {
    const mobileRegex = /Android|iPhone|iPad|iPod|Mobile/;
    if (mobileRegex.test(navigator.userAgent)) {
      location.replace('https://m.corp.com/app');
    }
    // 否则继续加载 React/Vue 脚本
  })();
</script>

2. 环境适配说明

场景 注意点
微信内置浏览器 UA 含 MicroMessenger,但仍是 mobile,正常跳 H5
iPadOS 默认 UA 像 Mac,需检测 ontouchendmaxTouchPoints
PWA 安装后 window.matchMedia('(display-mode: standalone)') 可识别,但分流不依赖它

举一反三:3 个变体场景

  1. App 内嵌 H5 智能跳转
    在 UA 里加自定义字段 MyApp/1.0,识别到后跳 myapp://page/signin 唤起原生页。
  2. PC 平板混合设备(Surface)
    先按 PC 加载,但监听页面 touchstart 事件,3 秒内有触摸则提示:“检测到触屏,是否切换平板模式?”
  3. AB 测试分流
    在 gateway 层加逻辑:Math.random() < 0.1 的 mobile 用户强制看 PC 版,收集体验反馈。

小结

别让设备类型决定代码,而要让代码聪明地认识设备。
用 UA 做快筛,尺寸和触摸做兜底,服务端做管控,一个链接也能走出两条路。

一个 4.7 GB 视频把浏览器拖进 OOM

你给一家在线教育平台做「课程视频批量上传」功能。
需求听起来很朴素:讲师后台一次性拖 20 个 4K 视频,浏览器要稳、要快、要能断网续传。
你第一版直接 <input type="file"> + FormData,结果上线当天就炸:

  • 讲师 A 上传 4.7 GB 的 .mov,Chrome 直接 内存溢出 崩溃;
  • 讲师 B 网断了 3 分钟,重新上传发现进度条归零,心态跟着归零;
  • 运营同学疯狂 @ 前端:“你们是不是没做分片?”

解决方案:三层防线,把 4 GB 切成 2 MB 的“薯片”

1. 表面用法:分片 + 并发,浏览器再也不卡

// upload.js
const CHUNK_SIZE = 2 * 1024 * 1024;    // 🔍 2 MB 一片,内存友好
export async function* sliceFile(file) {
  let cur = 0;
  while (cur < file.size) {
    yield file.slice(cur, cur + CHUNK_SIZE);
    cur += CHUNK_SIZE;
  }
}
// uploader.js
import pLimit from 'p-limit';
const limit = pLimit(5);               // 🔍 最多 5 并发,防止占满带宽
export async function upload(file) {
  const hash = await calcHash(file);   // 🔍 秒传、断点续传都靠它
  const tasks = [];
  for await (const chunk of sliceFile(file)) {
    tasks.push(limit(() => uploadChunk({ hash, chunk })));
  }
  await Promise.all(tasks);
  await mergeChunks(hash, file.name);  // 🔍 通知后端合并
}

逐行拆解:

  • sliceFilefile.slice 生成 Blob 片段,不占额外内存
  • p-limit 控制并发,避免 100 个请求同时打爆浏览器;
  • calcHash 用 WebWorker 算 MD5,页面不卡顿(后面细讲)。

2. 底层机制:断点续传到底续在哪?

角色 存储位置 内容 生命周期
前端 IndexedDB hash → 已上传分片索引数组 浏览器本地,清缓存即失效
后端 Redis / MySQL hash → 已接收分片索引数组 可配置 TTL,支持跨端续传
sequenceDiagram
    participant F as 前端
    participant B as 后端

    F->>B: POST /prepare {hash, totalChunks}
    B-->>F: 200 OK {uploaded:[0,3,7]}

    loop 上传剩余分片
        F->>B: POST /upload {hash, index, chunkData}
        B-->>F: 200 OK
    end

    F->>B: POST /merge {hash}
    B-->>F: 200 OK
    Note over B: 按顺序写磁盘

  1. 前端先 POST /prepare 带 hash + 总分片数;
  2. 后端返回已上传索引 [0, 3, 7]
  3. 前端跳过这 3 片,只传剩余;
  4. 全部完成后 POST /merge,后端按顺序写磁盘。

3. 设计哲学:把“上传”做成可插拔的协议

interface Uploader {
  prepare(file: File): Promise<PrepareResp>;
  upload(chunk: Blob, index: number): Promise<void>;
  merge(): Promise<string>;            // 🔍 返回文件 URL
}

我们实现了三套:

  • BrowserUploader:纯前端分片;
  • TusUploader:遵循 tus.io 协议,天然断点续传;
  • AliOssUploader:直传 OSS,用 OSS 的断点 SDK。
方案 并发控制 断点续传 秒传 代码量
自研 手动 自己实现 手动 300 行
tus 内置 协议级 需后端 100 行
OSS 内置 SDK 级 自动 50 行

应用扩展:拿来即用的配置片段

1. WebWorker 算 Hash(防卡顿)

// hash.worker.js
importScripts('spark-md5.min.js');
self.onmessage = ({ data: file }) => {
  const spark = new SparkMD5.ArrayBuffer();
  const reader = new FileReaderSync();
  for (let i = 0; i < file.size; i += CHUNK_SIZE) {
    spark.append(reader.readAsArrayBuffer(file.slice(i, i + CHUNK_SIZE)));
  }
  self.postMessage(spark.end());
};

2. 环境适配

环境 适配点
浏览器 需兼容 Safari 14 以下无 File.prototype.slice(用 webkitSlice 兜底)
Node fs.createReadStream 分片,Hash 用 crypto.createHash('md5')
Electron 渲染进程直接走浏览器方案,主进程可复用 Node 逻辑

举一反三:3 个变体场景

  1. 秒传
    上传前先算 hash → 调后端 /exists?hash=xxx → 已存在直接返回 URL,0 流量完成。
  2. 加密上传
    uploadChunk 里加一层 AES-GCM 加密,后端存加密块,下载时由前端解密。
  3. P2P 协同上传
    用 WebRTC 把同局域网学员的浏览器变成 CDN,分片互传后再统一上报,节省 70% 出口带宽。

小结

大文件上传的核心不是“传”,而是“断”。
把 4 GB 切成 2 MB 的薯片,再配上一张能续命的“进度表”,浏览器就能稳稳地吃下任何体积的视频。

❌