阅读视图

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

遍历链表(Python/Java/C++/C/Go/JS/Rust)

如何遍历一个链表?代码框架如下:

# 遍历链表 head
while head:  # 从链表头节点开始向后遍历,直到遇到空节点
    print(head.val)  # 当前节点值
    head = head.next  # 准备遍历下一个节点
// 遍历链表 head
while (head != null) { // 从链表头节点开始向后遍历,直到遇到空节点
    System.out.println(head.val); // 当前节点值
    head = head.next; // 准备遍历下一个节点
}
// 遍历链表 head
while (head) { // 从链表头节点开始向后遍历,直到遇到空节点
    cout << head->val << endl; // 当前节点值
    head = head->next; // 准备遍历下一个节点
}
// 遍历链表 head
while (head) { // 从链表头节点开始向后遍历,直到遇到空节点
    printf("%d\n", head->val); // 当前节点值
    head = head->next; // 准备遍历下一个节点
}
// 遍历链表 head
for head != nil { // 从链表头节点开始向后遍历,直到遇到空节点
    fmt.Println(head.Val) // 当前节点值
    head = head.Next // 准备遍历下一个节点
}
// 遍历链表 head
while (head) { // 从链表头节点开始向后遍历,直到遇到空节点
    console.log(head.val); // 当前节点值
    head = head.next; // 准备遍历下一个节点
}
// 遍历链表 head
let mut cur = &head; // 这样写,下面 let Some(node) = cur 不会转移 head 中节点的所有权
while let Some(node) = cur { // 从链表头节点开始向后遍历,直到遇到空节点
    println!("{}", node.val); // 当前节点值
    cur = &node.next; // 准备遍历下一个节点
}

问题相当于给你一串 $0$ 和 $1$,把它们拼成一个二进制数。

从我们熟悉的十进制开始。类比把字符串(字符数组)转成十进制整数的方式,比如 $[1,2,3]$ 转成 $123$:

  • 初始化答案为 $0$。
  • $0\times 10 + 1 = 1$。
  • $1\times 10 + 2 = 12$。
  • $12\times 10 + 3 = 123$。

本题是二进制,比如 $1,1,0$,目标是得到二进制数 $110_{(2)}$。

  • 初始化答案为 $0$。
  • $0_{(2)} \times 2 + 1 = 1_{(2)}$。
  • $1_{(2)} \times 2 + 1 = 11_{(2)}$。乘 $2$ 等价于左移 $1$。
  • $11_{(2)}\times 2 + 0 = 110_{(2)}$。
class Solution:
    def getDecimalValue(self, head: Optional[ListNode]) -> int:
        ans = 0
        while head:
            ans = ans * 2 + head.val
            head = head.next
        return ans
class Solution {
    public int getDecimalValue(ListNode head) {
        int ans = 0;
        while (head != null) {
            ans = ans * 2 + head.val;
            head = head.next;
        }
        return ans;
    }
}
class Solution {
public:
    int getDecimalValue(ListNode* head) {
        int ans = 0;
        while (head) {
            ans = ans * 2 + head->val;
            head = head->next;
        }
        return ans;
    }
};
int getDecimalValue(struct ListNode* head) {
    int ans = 0;
    while (head) {
        ans = ans * 2 + head->val;
        head = head->next;
    }
    return ans;
}
func getDecimalValue(head *ListNode) (ans int) {
for head != nil {
ans = ans*2 + head.Val
head = head.Next
}
return
}
var getDecimalValue = function(head) {
    let ans = 0;
    while (head !== null) {
        ans = ans * 2 + head.val;
        head = head.next;
    }
    return ans;
};
impl Solution {
    pub fn get_decimal_value(head: Option<Box<ListNode>>) -> i32 {
        let mut ans = 0;
        let mut cur = &head;
        while let Some(node) = cur {
            ans = ans * 2 + node.val;
            cur = &node.next;
        }
        ans
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是链表的长度。
  • 空间复杂度:$\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站@灵茶山艾府

每日一题-二进制链表转整数🟢

给你一个单链表的引用结点 head。链表中每个结点的值不是 0 就是 1。已知此链表是一个整数数字的二进制表示形式。

请你返回该链表所表示数字的 十进制值

 

示例 1:

输入:head = [1,0,1]
输出:5
解释:二进制数 (101) 转化为十进制数 (5)

示例 2:

输入:head = [0]
输出:0

示例 3:

输入:head = [1]
输出:1

示例 4:

输入:head = [1,0,0,1,0,0,1,1,1,0,0,0,0,0,0]
输出:18880

示例 5:

输入:head = [0,0]
输出:0

 

提示:

  • 链表不为空。
  • 链表的结点总数不超过 30
  • 每个结点的值不是 0 就是 1

7种方法:直接遍历 & 左移 & 递归 & 栈 & ArrayList & 遍历2次 & 转换为String

方法一:直接遍历,此种方法相当于反向操作,与十进制的链表转换成十进制数同理,用(res * 10 + head.val)就可以恢复成十进制数,不信 你拿笔模拟一下~

class Solution {
    public int getDecimalValue(ListNode head) {
        int res = 0;
        while(head != null){
            res = res * 2 + head.val;
            head = head.next;
        }
        return res;
    }
}

方法二,由于左移相当于乘以2,所以将方法一的乘以2替换成左移操作即可

class Solution {
    public int getDecimalValue(ListNode head) {
        int res = 0;
        while(head != null){
            res = (res << 1) + head.val;
            head = head.next;
        }
        return res;
    }
}

方法三:递归,参考leetcode题库中逆序打印链表的思路

class Solution {
    int count = 0;
    int res = 0;
    public int getDecimalValue(ListNode head) {
        if(head == null) return 0;
        res += getDecimalValue(head.next) + head.val * Math.pow(2, count);
        count++;
        return (int)res;
    }
}

方法四:栈

class Solution {
    public int getDecimalValue(ListNode head) {
        Stack<Integer> stack = new Stack<>();
        while(head != null){
            stack.push(head.val);
            head = head.next;
        }
        int n = 0;
        int res = 0;
        while(!stack.empty()){
            res += stack.pop() * Math.pow(2, n);
            n++;
        }
        return (int)res;
    }
}

方法五:ArrayList

class Solution {
    public int getDecimalValue(ListNode head) {
        List<Integer> list = new ArrayList<>();
        while(head != null){
            list.add(head.val);
            head = head.next;
        }
        int n = 0;
        int res = 0;
        for(int i = list.size()-1; i >= 0; i--){
            res += list.get(i) * Math.pow(2, n);
            n++;
        }
        return (int)res;
    }
}

方法六:比较原始的做法,先得出总长度,再从最低位恢复出十进制

class Solution {
    public int getDecimalValue(ListNode head) {
        int count = 0;
        int res = 0;
        ListNode p = head;
        while(p != null){
            count++;
            p = p.next;
        }
        while(head != null){
            res += head.val * Math.pow(2, count - 1);
            head = head.next;
            count--;
        }
        return (int)res;
    }
}

方法七:转化为字符串,再采用valueOf

class Solution {
    public int getDecimalValue(ListNode head) {
        StringBuilder sb = new StringBuilder();
        while(head != null){
            sb.append(head.val);
            head = head.next;
        }
        return Integer.valueOf(sb.toString(), 2);
    }
}

二进制链表转整数

方法一:模拟

由于链表中从高位到低位存放了数字的二进制表示,因此我们可以使用二进制转十进制的方法,在遍历一遍链表的同时得到数字的十进制值。

以示例 1 中给出的二进制链表为例:

以下用 $(n)_2$ 表示 $n$ 是二进制整数。

$$
(101)_2 = 1 \times 2^2 + 0 \times 2^1 + 1 \times 2^0
$$

链表的第 1 个节点的值是 $1$,这个 $1$ 是二进制的最高位,在十进制分解中,$1$ 作为系数对应的 $2^2$ 的指数是 $2$,这是因为链表的长度为 $3$。我们是不是有必要一定要先知道链表的长度,才可以确定指数 $2$ 呢?答案是不必要的。

  • 每读取链表的一个节点值,可以认为读到的节点值是当前二进制数的最低位;
  • 当读到下一个节点值的时候,需要将已经读到的结果乘以 $2$,再将新读到的节点值当作当前二进制数的最低位;
  • 如此进行下去,直到读到了链表的末尾。

###C++

class Solution {
public:
    int getDecimalValue(ListNode* head) {
        ListNode* cur = head;
        int ans = 0;
        while (cur != nullptr) {
            ans = ans * 2 + cur->val;
            cur = cur->next;
        }
        return ans;
    }
};

###Java

class Solution {
    public int getDecimalValue(ListNode head) {
        ListNode curNode = head;
        int ans = 0;
        while (curNode != null) {
            ans = ans * 2 + curNode.val;
            curNode = curNode.next;
        }
        return ans;
    }
}

###Python

class Solution:
    def getDecimalValue(self, head: ListNode) -> int:
        cur = head
        ans = 0
        while cur:
            ans = ans * 2 + cur.val
            cur = cur.next
        return ans

复杂度分析

  • 时间复杂度:$O(N)$,其中 $N$ 是链表中的节点个数。

  • 空间复杂度:$O(1)$。

一行代码生成绝对唯一 ID:告别 Date.now() 的不可靠方案

在现代 Web 开发中,生成唯一标识符(ID)是一个常见需求。无论是用户会话、临时文件还是数据库记录,我们都需要确保每个 ID 的绝对唯一性。然而,许多开发者仍在使用的传统方法其实存在严重缺陷。

常见误区与问题

误区一:时间戳 + 随机数组合

function generateNaiveId() {
    return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
// 输出示例: "l6n7f4v2am50k9m7o4"

这种方法看似合理,实则存在两大致命缺陷:

  1. 时间戳精度问题Date.now() 仅精确到毫秒,同一毫秒内的多次调用会导致 ID 前缀相同

  2. 伪随机性问题Math.random() 不是加密级随机数,存在极小概率的重复风险

误区二:简单的自增计数器

let counter = 0;
function generateIncrementalId() {
    return counter++;
}

这种方案的问题更加明显:

  • 浏览器刷新后计数器重置

  • 多标签页环境下计数器独立运行,导致 ID 冲突

  • 完全不适合分布式环境

现代解决方案:crypto.randomUUID()

现代浏览器和 Node.js 提供了内置的加密解决方案:

const uniqueId = crypto.randomUUID();
// 示例输出: "3a6c4b2a-4c26-4d0f-a4b7-3b1a2b3c4d5e"

为什么这是最佳选择?

  1. 极低碰撞概率:基于 122 位随机数生成,组合数量达到天文数字级别

  2. 加密级安全性:使用密码学安全伪随机数生成器(CSPRNG)

  3. 标准化格式:符合 RFC 4122 v4 规范,全栈兼容

  4. 原生高效:无需第三方库,性能优异

兼容性与使用建议

crypto.randomUUID() 已在所有现代浏览器中得到支持:

  • Chrome 92+

  • Firefox 90+

  • Safari 15.4+

  • Node.js 14+

对于新项目,这是生成唯一 ID 的推荐方案。对于需要支持旧版浏览器的项目,可以考虑使用 polyfill 或第三方库(如 uuid 库)。

结论

告别不可靠的 Date.now()Math.random() 组合,拥抱现代浏览器提供的标准解决方案。crypto.randomUUID() 以一行代码的形式,提供了真正安全、可靠、标准的唯一 ID 生成能力,是 Web 开发中的最佳实践。

pxcharts-pro, 支持百万数据渲染的多维表格编辑器

大家好,我是徐小夕, 今天继续聊聊多维表格编辑器。

花了半年的时间,专家团队的日夜奋战,百万投入,上万行高可用代码, pxcharts 多维表格编辑器终于上线了Pro版。

图片

上面的动画演示就是我实现的Pro版多维表格,先说说成果:

  1. 实现了高性虚拟滚动方案,支持百万数据渲染。
  2. 支持多表格管理,可以轻松切换和创建多维表格。
  3. 支持导入和导出数据,比如导入CSV文件,Excel文件,并支持导出为Excel。
  4. 多维表格内容支持组合逻辑和规则渲染。
  5. 强大的表格设置面板,可以对表格进行复杂属性的配置。
  6. 支持基于多维表格一键生成表单,并支持导出表单HTML。
  7. 性能监控面板,实时监控表格性能。

为了统一品牌,我把多维表格系列统一命名为pxcharts。

演示地址:pxcharts.com

图片

接下来就和大家分享一下多维表格Pro版的强大功能,包满意~

1. 百万数据渲染能力

图片

Pro版多维表格我们支持了性能测试模块,大家可以线上自定义数据来测试,感受我们多维表格的渲染性能。同时我们对表格渲染能力做了优化,支持更强大的虚拟滚动和性能优化设计,让表格操作更丝滑和优雅。下面是我们提供的性能测试板块:

图片

可以测试100万数据的渲染,我们把dom渲染优化到了极致,目前应该是市面上性能最强的表格编辑器了。

2. 表格管理能力

图片

在之前的版本中,聚焦于多维表格编辑器的能力建设上,Pro版本支持了更强大的表格管理能力,可以一键新建表格,进行不同表格的无缝切换。

3. 表格导入导出能力(支持CSV和Excel)

图片

这个是我们另一个非常核心的能力,从数据上传(支持CSV,Excel,TXT)到数据预览,再到数据映射,最后到数据字段的详细控制,我们都做了全面的功能设计,让数据导入能力达到行业领先水平。下面是导入Excel后的数据映射的界面:

图片

大家可以参考我们提供的Excel模版,来试试导入文件和解析文件的能力。

4. 多维表格的“宏”渲染能力

图片

“宏”渲染能力实际上就是对单元格的值,进行逻辑和规则的渲染,来达到更强大的表格分析能力,比如我们想高亮显示未完成的需求列表,可以配置“宏”规则,让多维表格根据规则自动高亮行。这个是表格高级用法,企业还能自定义扩展组合式渲染能力。下面是一个我设计的案例:

图片

上面的表格行出现红绿高亮,就是我配置的规则,指定符合规则的数据进行特殊样式渲染。这对数据分析和项目管理场景来说非常有价值。

5. 多维表格一键生成可配置表单能力

图片

这个是我们研发的多维表格的核心亮点之一,可以基于表格数据,一键生成精美表单,同时可以控制表单渲染的规则,并支持实时预览和一键导出可用的HTML代码。
即使是不懂技术的人,也能使用多维表格轻松制作各种表单,并一键生成代码。

  1. 多维表格的可视化分析看板

图片

同时,多维表格的数据已经和可视化报表打通, 我们可以基于数据,一键生成可视化分析图表,让数据管理更智能高效,助力企业高效数据分析。

7. 强大的表格设置面板

图片

同时,每一个多维表格我们都支持详细的配置,大家可以根据自己的需求全部配置表格,并进行自定义扩展。

演示地址:pxcharts.com

当然还有很多功能我会在接下来的文章中和大家持续分享。

后续我们会支持迭代,推出功能更强大的智能化 + 多维表格解决方案,大家有好的建议也欢迎在留言区交流反馈~

Vue 3.6 将正式进入「无虚拟 DOM」时代!

作者:前端开发爱好者

原文:mp.weixin.qq.com/s/zbUCreQ8F…

“干掉虚拟 DOM”  的口号喊了好几年,现在 Vue 终于动手了。

就在前天,Vue 3.6 alpha 带着 Vapor Mode 低调上线:编译期直接把模板编译成精准 DOM 操作,不写 VNode、不 diff,包更小、跑得更快。

图片

不同于社区实验,Vapor Mode 是 Vue 官方给出的「标准答案」:

  • 依旧是熟悉的单文件组件,只是 <script setup> 上加一个 vapor 开关;
  • 依旧是响应式系统,但运行时不再生成 VNode,编译期直接把模板转换成精准的原生 DOM 操作;
  • 与 SvelteSolid 的最新基准横向对比,性能曲线几乎重合,首屏 JS 体积却再降 60%。

换句话说,Vue 没有「另起炉灶」,而是让开发者用同一套心智模型,一键切换到「无虚拟 DOM」的快车道。

接下来 5 分钟,带你一次看懂 Vapor Mode 的底层逻辑、迁移姿势和未来路线图。

什么是 Vapor Mode?

一句话总结:把虚拟 DOM 编译掉,组件直接操作真实 DOM,包体更小、跑得更快。

  • 100% 可选,旧代码无痛共存。
  • 仅支持 <script setup> 的 SFC,加一个 vapor 开关即可。
  • 与 SolidSvelte 5 在第三方基准测试里打平,甚至局部领先。
<script setup vapor>
// 你的组件逻辑无需改动
</script>

性能有多夸张?

官方给出的数字:

场景 传统 VDOM Vapor Mode
Hello World 包体积 22.8 kB 7.9 kB ⬇️ 65%
复杂列表 diff 0.6× ⬇️ 40%
内存峰值 100% 58% ⬇️ 42%

一句话:首屏 JS 少了三分之二,运行时内存直接腰斩。

能不能直接上生产?

alpha 阶段,官方给出“三用三不用”原则:

✅ 推荐这样做

  • 局部替换:把首页、营销页等性能敏感模块切到 Vapor
  • 新项目:脚手架直接 createVaporApp,享受极简 bundle。
  • 内部尝鲜:提 Issue跑测试、贡献 PR,帮社区踩坑。

❌ 暂时别这样

  • 现有组件整体迁移(API 未 100% 对标)。
  • 依赖 NuxtTransitionKeepAlive 的项目(还在支持的路上)。
  • 深度嵌套第三方 VDOM 组件库(边界 case 仍可能翻车)。

开发者最关心的 5 个问题

  • 旧代码要改多少?
    不用改!只要 <script setup> 加 vapor。Options API 用户请原地踏步。
  • 自定义指令怎么办?
    新接口更简单:接收一个响应式 getter,返回清理函数即可。官方已给出 codemod,一键迁移。
  • 还能不能用 Element Plus / Ant Design Vue?
    可以,但需加 vaporInteropPlugin。目前仅限标准 props事件插槽,复杂组件可能有坑。
  • TypeScript 支持如何?
    完全保持现有类型推导,新增 VaporComponent 类型已同步到 @vue/runtime-core
  • 和 React Forget、Angular Signal 比谁快?
    基准测试在同一梯队,但 Vue 的迁移成本最低——同一份代码,加个属性就提速。

一行代码,立刻体验

  • 纯 Vapor 应用(最小体积)
import { createVaporApp } from 'vue'
import App from './App.vue'

createVaporApp(App).mount('#app')
  • 在现有 Vue 项目中混合使用
import { createApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'

createApp(App)
  .use(vaporInteropPlugin)
  .mount('#app')

使用时只需在单文件组件的 <script setup> 标签上加 vapor 属性即可启用新模式。

<script setup vapor>
// 你的组件逻辑无需改动
</script>

打开浏览器,Network 面板里 app.js 只有 8 kB,简直离谱。

写在最后

从 2014 年的响应式系统,到 2020 的 Composition API,再到 2025 的 Vapor Mode,Vue 每一次大版本都在 “把复杂留给自己,把简单留给开发者”

这一次,尤大不仅把虚拟 DOM 编译没了,还把“性能焦虑”一起编译掉了。

领先的不只是速度,还有对开发者体验的极致尊重。

Vue 3.6 正式版预计 Q3 发布,现在开始试 alpha,刚刚好。

  • v3.6.0-alpha.1 相关文档https://github.com/vuejs/core/releases/tag/v3.6.0-alpha.1

前端开发的你,其实并没有真的掌握img标签!

作者:前段界

原文:mp.weixin.qq.com/s/YhAUnHXFL…

通过本文,你将系统掌握 HTML <img> 标签的核心知识与实战技巧,具体包括:图片

  1. 标签基础用法:掌握 <img> 标签的基本结构、src 和 alt 等必要属性的规范写法。
  2. 关键属性深度解析
    • src/alt:理解图片路径类型(相对 / 绝对 / URL)与替代文本的无障碍及 SEO 价值;
    • width/height:通过预定义尺寸避免页面布局抖动(CLS);
    • loading:运用懒加载(lazy)优化首屏性能,区分首屏与非首屏图片加载策略;
    • decoding:通过异步解码(async)减少主线程阻塞;
    • srcset/sizes:实现响应式图片,根据设备分辨率和屏幕宽度动态加载最优资源;
    • crossorigin/referrerpolicy:控制跨域请求策略与请求来源隐私保护;
    • usemap/ismap:实现图片热点链接与坐标传递等进阶交互。
  3. 性能优化实践:结合懒加载、CDN、WebP 格式压缩等技术提升图片加载效率。
  4. SEO 与可访问性:通过规范 alt 文本、语义化标签使用,提升搜索引擎理解与无障碍体验。
  5. 最佳实践:从属性配置(如必写 alt/width/height)到工程化优化(如避免 base64 大图)的全流程经验总结。

正文从这里开始~

img 基本用法

HTML 的 <img> 标签用于在网页中嵌入图片,是 Web 最常用的媒体标签之一。

<img src="/images/example.png" alt="示例图片" />
  • <img> 是自闭合标签,无需结束标签。
  • 必须指定 src 属性,推荐始终添加 alt 属性(替代文本:SEO + 可访问性优化)。

常用原生属性详解

src

  • 作用:指定图片的路径(本地或远程 URL),即浏览器实际请求图片资源的地址。

  • 浏览器请求原理

    • 当页面渲染到 <img src="..."> 时,浏览器会自动向 src 指定的地址发起 HTTP 请求,下载图片并显示。
  • 路径类型区别

    • 相对路径:如 src="images/logo.png",相对于当前 HTML 文件所在目录。
    • 绝对路径:如 src="/images/logo.png",以网站根目录为起点,推荐用于站内图片。
    • 完整 URL:如 src="https://cdn.example.com/img.png",可加载任意站点或 CDN 上的图片。
  • 实际效果

    • 路径不同,浏览器请求的目标服务器和图片资源不同,影响加载速度、跨域策略和缓存。
  • 示例

    <img src="/images/logo.png" alt="Logo" />
    <img src="images/banner.jpg" alt="Banner" />
    <img src="https://cdn.example.com/photo.jpg" alt="CDN图片" />
    

alt

  • 作用:为图片提供替代文本。

  • 意义

    • 图片无法加载时显示
    • 屏幕阅读器辅助访问
    • SEO 友好,帮助搜索引擎理解图片内容
  • 示例

    <img src="/images/avatar.png" alt="用户头像" />
    
  • 最佳实践:alt 文本应简洁、准确描述图片内容。

width 和 height

  • 作用:指定图片的显示宽高(单位为像素)。

  • 意义

    • 保留图片空间,防止页面布局抖动(CLS)
    • 浏览器可提前分配空间,提升渲染性能
  • CLS(Cumulative Layout Shift)布局抖动

    • 定义:CLS 是衡量页面内容在加载过程中发生意外移动的指标。常见于图片、广告等资源未提前分配空间,加载后导致页面元素跳动。
    • 真实现象:用户正在阅读或点击内容时,图片加载进来把内容"挤下去"或"挤偏",影响体验。
    • width/height 的作用:提前为图片预留空间,浏览器可在图片加载前就分配好区域,避免内容跳动。
  • 示例

    <img src="/images/photo.jpg" alt="风景" width="400" height="300" />
    
  • 优化建议:始终为图片指定 width 和 height,或用 CSS 明确尺寸。

loading

  • 作用:控制图片的加载时机。

  • 可选值

    • auto(默认):浏览器自动决定
    • lazy:懒加载,图片进入视口时才加载
    • eager:优先加载
  • lazy 的详细解释

    • 原理:当图片距离用户当前可见区域(视口,viewport)足够近时,浏览器才会发起请求加载图片。
    • 视口(viewport)概念:视口是用户当前屏幕上可见的网页区域。只有进入视口或接近视口的图片才会被加载。
    • 流程:页面初始渲染时,非首屏图片不会立即加载,只有用户滚动到图片附近时才加载。
  • 首屏与非首屏

    • 首屏图片:用户打开页面时,无需滚动即可看到的图片。
    • 非首屏图片:需要滚动页面才能看到的图片。
  • 预加载与懒加载场景

    • 预加载:首屏图片、重要视觉内容,建议用 loading="eager" 或不加 loading 属性。
    • 懒加载:非首屏图片、长列表、瀑布流等,建议用 loading="lazy"
  • 最佳实践:首屏图片优先加载,非首屏图片懒加载,提升首屏速度和整体性能。

  • 示例

    <img src="/images/large.jpg" alt="大图" loading="lazy" />
    
  • 性能优化:为非首屏图片加 loading="lazy" 可显著提升页面加载速度。

decoding

  • 作用:控制图片解码方式。

  • 可选值

    • auto(默认):浏览器自动决定
    • sync:同步解码
    • async:异步解码(推荐)
  • 示例

    <img src="/images/photo.jpg" alt="风景" decoding="async" />
    

图片请求响应格式

当浏览器请求图片时,服务器返回的是图片的二进制数据(binary data) 并且这些二进制数据可能是各种格式,比如JPEG、PNG、WebP 等。这些二进制格式是经过压缩的,不能直接在浏览器页面显示。

Decoding(解码)具体是什么

解码是将压缩的图片二进制数据转换为浏览器可以显示和使用的像素数据过程,具体流程如下

  1. 解压缩图片数据

  2. 将数据转换为位图(bitmap)格式

  3. 处理颜色空间转换

  4. 应用透明等效果

图片

解码是浏览器原生支持的,不同浏览器使用不同的图片解码引擎,现代浏览器都是支持多种图片格式的解码

async 解码 vs sync 解码

同步解码(decoding="async"):

  • 图片加载完成后立即进行解码
  • 解码过程会阻塞主线程

使用场景:需要立即使用图片的场景;图片较小,解码时间短;需要确保图片完全准备好才能显示

异步解码(decoding="async")

  • 图片加载完成后,解码过程在后台进行
  • 不会阻塞主线程

使用场景:大图片加载;非关键图片;需要保持页面响应性的场景

大多数场景推荐用 decoding="async",因为不会阻塞主线程,提升页面响应,提升渲染流畅度。只有在特殊场景比如立即在Canvas上绘制图片或者图片是页面关键内容,需要立即显示。

srcset 和 sizes

  • 作用:实现响应式图片,根据设备分辨率和屏幕宽度加载最合适的图片。

  • srcset:定义多个图片资源及其尺寸/分辨率。

  • sizes:定义不同视口宽度下图片的显示尺寸。

  • 示例

    <img
      src="/images/banner-800.jpg"
      srcset="/images/banner-400.jpg 400w, /images/banner-800.jpg 800w, /images/banner-1600.jpg 1600w"
      sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1600px"
      alt="横幅"
    />
    
  • 原理:浏览器根据设备和 CSS 选择最优图片,节省流量、提升清晰度。

crossorigin

  • 作用:控制图片的跨域请求策略;决定是否在请求图片时发送凭证信息(如cookie,http认证等),影响着图片资源的安全性和访问权限。
  • 可选值
    • anonymous:不带凭证,请求图片是不会发送任何凭证信息(cookie等),适用于公开的图片资源、CDN 上的静态图片
    • use-credentials:带凭证(如 cookies),请求图片时会发送凭证信息,浏览器会自动携带当前域名的 cookie,适用于需要认证的图片资源、私有图片内容。通俗点讲

凭证发送机制

  • 浏览器会自动发送当前域名的cookies
  • 不需要在标签中显示指定cookies
  • 浏览器默认行为 具体发送流程如下
用户浏览器                   目标服务器
     |                          |
     |  1. 发起图片请求         |
     |  2. 自动携带当前域名cookies |
     |  3. 发送 HTTP 请求头     |
     |     Cookie: sessionId=xxx |
     |------------------------->|
     |                          |
     |  4. 服务器验证 cookies   |
     |  5. 返回图片数据         |
     |<-------------------------|
访问控制机制
合法访问(在网站中):
1. 用户登录网站
2. 获得 cookies
3. 网站使用 <img crossorigin="use-credentials"> 请求图片
4. 浏览器自动携带 cookies
5. 服务器验证 cookies
6. 返回图片

非法访问(直接访问 URL):
1. 直接在浏览器输入图片 URL(请求中不会包含crossorigin属性)
2. 请求中不包含 cookies
3. 服务器验证失败
4. 返回错误或占位图

访问控制主要是通过服务器端的 cookies 验证来实现的,crossorigin 属性是告诉浏览器如何发送请求,只是确保在网站中正确发送 cookies 的一个机制。直接访问 URL 时,由于没有 cookies,所以会被服务器拒绝,这与 crossorigin 属性无关。

使用场景

需要带凭证的场景:

  1. 用户认证图片(如用户头像)
  2. 私有内容图片(如付费内容)
  3. 需要用户登录状态的图片
  4. 企业内部图片资源

不需要带凭证的场景:

  1. 公开 CDN 图片
  2. 第三方公开图片
  3. 静态资源图片

crossorigin使用时的安全考虑

不能使用通配符 * 作为 Access-Control-Allow-Origin 的值; 必须指定具体的允许域名;

服务器响应头需要包含

  Access-Control-Allow-Credentials: true
  Access-Control-Allow-Origin: https://example.com

示例:

<img src="https://cdn.com/img.png" alt="CDN图片" crossorigin="anonymous" />

图片的跨域请求策略是什么?什么场景需要带凭证,带了凭证图片会怎样?

referrerpolicy

作用:控制图片请求时的 Referer 头部, 只是控制请求头中 Referer 字段的值,可以隐藏或修改 Referer 信息,一定注意它本身并不具备防盗功能,它只是一个控制 referer 头的工具。

常用值介绍

no-referrer

  • 不发送 Referer 头
  • 完全隐藏请求来源
  • 适用于:需要完全隐私保护的场景

no-referrer-when-downgrade(默认值)

  • 从 HTTPS 到 HTTPS:发送完整 URL
  • 从 HTTPS 到 HTTP:不发送
  • 适用于:大多数普通场景

origin

  • 只发送源站域名
  • 例如:从 https://example.com/page.html 发送请求
  • Referer: https://example.com
  • 适用于:需要知道来源但不需要具体页面的场景

origin-when-cross-origin

  • 同源请求:发送完整 URL
  • 跨域请求:只发送源站域名
  • 适用于:区分同源和跨域请求的场景

same-origin

  • 同源请求:发送完整 URL
  • 跨域请求:不发送
  • 适用于:只在同源时显示来源的场景

strict-origin

  • 从 HTTPS 到 HTTPS:发送源站域名
  • 从 HTTPS 到 HTTP:不发送
  • 适用于:需要保护 HTTPS 来源的场景

strict-origin-when-cross-origin

  • 同源请求:发送完整 URL
  • 跨域请求:从 HTTPS 到 HTTPS 发送源站域名,其他情况不发送
  • 适用于:需要严格保护跨域请求的场景

unsafe-url

  • 总是发送完整 URL
  • 即使从 HTTPS 到 HTTP 也发送
  • 适用于:需要完整来源信息的场景

referer

开始说防盗链之前,我觉得非常有必要讲一下 referer 这个属性。referer 是请求头中的内容。

图片

image.png

referer的设置规则

  1. 只能通过 referrerpolicy 属性控制
  2. 浏览器会根据当前页面的 URL 自动设置
  3. JavaScript 代码或者手动 HTTP 请求不能直接修改 referer 头

这规则是浏览器的安全机制,防止伪造请求来源。

图片加载时候不设置任何 referrerpolicy 属性,浏览器会给referrerpolicy默认值为 no-referrer-when-downgrade ,它对应的 referer 值为

  • 从 HTTPS 到 HTTPS:发送完整 URL 作为 Referer
  • 从 HTTPS 到 HTTP:不发送 Referer
  • 从 HTTP 到 HTTP:发送完整 URL 作为 Referer

举个具体例子:

场景1:HTTPS 页面请求 HTTPS 图片
页面:https://example.com/page.html
图片:https://example.com/image.jpg
Referer: https://example.com/page.html

场景2:HTTPS 页面请求 HTTP 图片
页面:https://example.com/page.html
图片:http://example.com/image.jpg
Referer: 不发送 (其实默认行为也是为了保护用户隐私,防止从HTTPS页面泄露信息到HTTP请求,这也体现了浏览器的安全机制)

场景3:HTTP 页面请求 HTTP 图片
页面:http://example.com/page.html
图片:http://example.com/image.jpg
Referer: http://example.com/page.html

防盗链详解

什么是防盗链

防盗链是指通过检查图片请求的 Referer 来源,防止其他网站盗用你的图片资源和带宽。

出现场景

你的图片被其他网站直接引用,导致你的服务器或 CDN 带宽被消耗。

出现流程
  1. 其他网站用 <img src="你的图片地址"> 引用你的图片
  2. 用户访问该网站时,浏览器向你的服务器请求图片,并带上 Referer 头
  3. 你的服务器检查 Referer,如果不是你自己的网站,则拒绝请求或返回占位图
如何避免

需要配合服务器/CDN配置

Nginx 配置示例

location /images/ {
    # 只允许来自 example.com 的请求
    valid_referers none blocked example.com;
    
    # 如果 Referer 不合法,返回 403
    if ($invalid_referer) {
        return 403;
    }
}

CDN 配置示例

  • 设置允许的 Referer域名白名单
  • 设置防盗链规则
  • 配置控制好访问策略
referrerpolicy 相对于防盗链条的常见用途
  • 隐私保护:使用 no-referer 隐藏请求来源
  • 配合防盗链:使用strict-origin 只显示域名
示例
<!-- 隐私保护场景 -->
<img src="/images/private.jpg" 
     referrerpolicy="no-referrer" 
     alt="隐私图片" />

<!-- 配合防盗链场景 -->
<img src="/images/protected.jpg" 
     referrerpolicy="strict-origin" 
     alt="受保护图片" />

usemap 和 ismap

  • usemap:将图片与 HTML 的 <map> 区域映射结合,实现图片热点链接。

  • ismap:配合 <a> 标签和服务器端脚本,实现图片点击坐标传递。

  • 示例

    <img src="/images/map.png" alt="地图" usemap="#mymap" />
    <map name="mymap">
      <area shape="rect" coords="34,44,270,350" href="/link1" alt="区域1" />
      <area shape="circle" coords="337,300,44" href="/link2" alt="区域2" />
    </map>
    

响应式图片实践

(还有一个响应式图片需要处理) 通过 srcset 和 sizes,可为不同设备和网络环境提供最优图片资源。

<img
  src="/images/photo-800.jpg"
  srcset="/images/photo-400.jpg 400w, /images/photo-800.jpg 800w, /images/photo-1600.jpg 1600w"
  sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1600px"
  alt="风景"
/>
  • 浏览器自动选择最合适的图片,兼顾清晰度和加载速度。
  • 移动端优先加载小图,桌面端加载大图。

SEO 与可访问性注意事项

  • alt 属性必填,描述图片内容,提升无障碍和SEO
  • 避免 alt 为空或堆砌关键词。
  • 图片与页面内容相关,提升语义。
  • 对装饰性图片可用 alt="",让屏幕阅读器跳过。
  • 图片周围配合标题、描述,提升搜索引擎理解。

最佳实践和注意点

  • 始终写 alt、width、height,指定 width 和 height,防止 CLS(布局抖动)
  • 合理用这几个高级属性 loading、decoding、srcset、sizes
    • 为非首屏图片加 loading="lazy",减少首屏资源压力。
    • 合理使用 srcset 和 sizes,提升响应式体验。
  • 图片压缩、格式优化、使用CDN 加速图片分发
  • 图片尽量压缩,优化格式,WebP 使用起来,防止浪费带宽
  • 结合现代框架(如 Next.js``<Image />)自动优化
  • 避免滥用大图,影响加载速度
  • 避免忽略 alt 属性,影响 SEO 和可访问性,避免 alt 为空或堆砌关键词。
  • 避免直接使用base64 大图,影响性能。

总结

HTML <img> 标签是网页图片展示的基础,合理使用原生属性、关注性能和可访问性,是现代前端开发和 SEO 优化的必备技能。

建议结合响应式、懒加载、CDN、WebP 等技术,打造高性能、友好的图片体验。

前端学C++可太简单了:-> 操作符

-> 是C++中的成员访问操作符,专门用于通过指针访问对象的成员

C++中的对象访问有两种情况

// 情况1:直接对象(栈上对象)
PerspectiveCamera camera;
camera.position.z = 2;  // 用点号访问,类似JavaScript

// 情况2:指针对象(堆上对象)
auto camera = PerspectiveCamera::create(); // 返回智能指针
camera->position.z = 2;  // 用箭头访问!这就是->的作用
graph TD
    A["C++对象存储位置"] --> B["栈(Stack)"]
    A --> C["堆(Heap)"]
    
    B --> D["直接对象<br/>Class obj;<br/>用 . 访问成员"]
    C --> E["指针对象<br/>Class* obj = new Class();<br/>用 -> 访问成员"]
    
    F["JavaScript对比"] --> G["所有对象都在堆上<br/>const obj = new Class();<br/>统一用 . 访问"]
    
    style B fill:#e1f5fe
    style C fill:#fff3e0
    style G fill:#f3e5f5

作为JavaScript开发者,你肯定会觉得C++的这种区分很奇怪,为什么要这么麻烦,用两种不同的符号来处理,这增加了阅读。

从设计哲学的角度理解 -> 操作符

JavaScript隐藏了内存管理的复杂性,而C++选择让程序员明确知道正在操作什么类型的数据。

graph TD
    A["JavaScript设计哲学"] --> B["隐藏复杂性<br/>统一接口<br/>obj.property"]
    
    C["C++设计哲学"] --> D["暴露底层细节<br/>让程序员控制<br/>obj.prop vs ptr->prop"]
    
    B --> E["优点:简单易用<br/>缺点:性能不可控"]
    D --> F["优点:性能可控<br/>缺点:学习成本高"]
    
    style A fill:#e8f5e8
    style C fill:#fff3e0
    style E fill:#e1f5fe
    style F fill:#fce4ec

原因1:内存位置决定访问成本

// 栈上对象(快速访问)
Canvas canvas("title");
canvas.size();  // 直接内存访问,0次间接寻址

// 堆上对象(需要间接访问)
auto scene = Scene::create();  // 返回指针
scene->add(obj);  // 需要1次间接寻址:先找到指针指向的地址,再访问成员
// JavaScript隐藏了这个差异
const canvas = new Canvas("title");  // 实际上都在堆上
canvas.size();  // 看起来一样,但底层都是间接访问

原因2:类型安全和错误防护

C++的区分操作符是一种编译时安全检查

Canvas canvas("title");
Canvas* canvasPtr = &canvas;

// 编译器强制你使用正确的操作符
canvas.size();     // ✅ 对象用点号
canvasPtr->size(); // ✅ 指针用箭头

// 如果用错了,编译器会报错
canvas->size();    // ❌ 编译错误!对象不能用箭头
canvasPtr.size();  // ❌ 编译错误!指针不能用点号

这比JavaScript的运行时错误要好得多

// JavaScript中的潜在问题
let obj = null;
obj.property;  // ❌ 运行时错误:Cannot read property of null

原因3:明确表达程序员的意图

不同的操作符告诉代码阅读者(包括你自己)

// 看到 . 就知道:这是栈对象,生命周期明确,不用担心内存泄漏
canvas.animate([&]() { 
    // canvas会在作用域结束时自动销毁
});

// 看到 -> 就知道:这是堆对象,需要关注内存管理
scene->add(object);  // scene是智能指针,但仍需小心循环引用等问题

虽然这增加了学习成本,增加了阅读成本,但在系统级编程中,这种"显式胜过隐式"的哲学是非常有价值的

参透JavaScript —— 花十分钟搞懂作用域

前言

本篇文章主要讲解 JavaScript 中的作用域

作用域(Scope)的概念

很多编程语言都具有作用域的概念,它是计算机程序设计中的一个核心概念,定义了变量(或函数)的可访问范围

在书籍《你不知道的 Javascript》上卷中有这样一句话:作用域是根据名称查找变量的一套规则

所以通俗来讲,作用域在 JavaScript 里可以理解为:作用域是一套规则,可以决定一个变量或函数的有效访问范围

JavaScript 有哪些作用域

不同的编程语言可能有不同的作用域及规则,除去特殊的 eval 作用域,在 JavaScript 中主要有三种作用域:

  • 全局作用域
  • 函数作用域
  • 块级作用域

全局作用域

全局作用域很好理解,在程序执行时就会被创建,特点是程序的任何地方都可以访问到,如果是浏览器环境,则挂载在 window 对象上

var a = 'fifteen'
console.log(window.a); // fifteen

函数作用域

函数作用域是指函数内部的区域,特点是:在程序外部无法访问到函数内部的变量

function foo() {
    var b = "fifteen"
    console.log(b);
}
foo() // fifteen

比如我们定义一个 foo 函数,在函数内部定义一个变量 b

此时在 window 对象上只会挂载一个 foo 函数,而不存在变量 b,在外部访问会报错:Uncaught ReferenceError: b is not defined

块级作用域

块级作用域,使用 {} 包裹的代码,并且使用 letconst 等关键字声明的变量,会形成块级作用域

也就是说,像常用的 ifforwhiletrycatch 等,都可以形成块级作用域

一个最小化的例子是,在 if 内定义的变量 a,无法在外部访问

if(true){
    const a = "fifteen"
}
console.log(a); // Uncaught ReferenceError: a is not defined

而在此之前,通过 var 定义的变量,由于存在变量提升的问题,会污染外部变量

if(true){
    var b = "fifteen"
}
console.log(b); // fifteen

《Javascript 高级程序设计》第四版和《你不知道的 Javascript》上卷在相关内容中,都举了一个 for 循环的例子

for(var i = 0; i < 3; i++){
    console.log(i);
}
console.log(i); // 3

在这段循环中,我们用 var 定义了一个 i 变量,定义这个变量的初衷是让它在循环内部控制循环次数,但现在情况不是想象中的这样,i 现在污染了外部环境,是绑定在 window 对象上的全局变量

使用 ES6 推出的 let 定义,情况就不一样了

for(let i = 0; i < 3; i++){
    console.log(i);
}

现在,i 只能在 for 循环内部使用,外部访问会报错:Uncaught ReferenceError: i is not defined

IIFE 立即执行函数表达式

这一节我想聊聊 IIFE,主要搞懂以下三个问题:

  1. IIFE 是什么?概念
  2. 如何定义一个 IIFE?
  3. IIFE 解决了什么问题,起什么作用

首先,IIFE 是一个缩写,它的全称英文是 Immediately Invoked Function Expression,翻译过来就是:立即调用的函数表达式

IIFE 的行为,可以理解为一个函数在定义后就会立即执行,表现形式通常用 () 包裹的匿名函数,尾部再接一个 (),触发函数的执行

比如下面这段代码:

(function (){
    /** code */
})()

这样的操作带来了一些好处,借用 MDN 中介绍 IIFE 的内容:通过创建新的作用域来避免污染全局命名空间

也就是说,IIFE 可以解决变量(函数)污染的问题,形成一个独立的作用域,不会在 window 对象中挂载

它与块级作用域的作用很相似,在 ES6 之前,IIFE 常被用来生成独立的作用域

MDN - IIFE 中列出了 IIFE 的三点作用:

  • 通过创建新的作用域来避免污染全局命名空间。
  • 创建新的异步上下文以在非异步上下文中使用 await。
  • 使用复杂的逻辑计算值,例如将多个语句用作单个表达式。

总结

我们上面讲了三种作用域,分别是:全局作用域、函数作用域、块级作用域,可能你写了很多年的JS,但你没有注意到这些概念,深入学习 Javascript 时,这都是不必可少的基础知识

最后的话,聊了一下 IIFE ,它可以解决变量污染的问题,形成一个独立的作用域,在 ES6 块作用域之前比较常用

参考资料

参透JavaScript系列

本文已收录至《参透 JavaScript 系列》,全文地址:我的 GitHub 博客 | 掘金专栏

交流讨论

对文章内容有任何疑问、建议,或发现有错误,欢迎交流和指正

前端网络性能优化

在现代 Web 开发中,网络性能优化是提升用户体验的关键环节。加载缓慢的网站可能导致用户流失,因此,掌握网络性能优化的方法对于前端开发者来说至关重要。本文将详细介绍多种优化网络性能的策略,帮助你打造更快速、更流畅的 Web 应用。

一、优化打包体积

压缩与混淆代码

利用 Webpack、Rollup 等打包工具,可以对最终打包的代码进行压缩和混淆。通过移除代码中的注释、空格、换行符,以及缩短变量名,可以显著减少文件体积。例如,使用 Webpack 的 TerserPlugin 插件,可以自动压缩 JavaScript 代码。

// webpack.config.js
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [new TerserPlugin()],
  },
};

多目标打包

针对不同浏览器打包出不同的兼容性版本,可以减少每个版本中的兼容性代码。例如,使用 @babel/preset-env 插件,可以根据目标浏览器自动添加所需的 polyfill。

// babel.config.js
module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        targets: {
          browsers: ['> 1%', 'last 2 versions'],
        },
      },
    ],
  ],
};

二、利用压缩技术

现代浏览器普遍支持压缩格式,如 Gzip 和 Brotli。服务器可以在响应文件时进行压缩,只要解压时间小于优化的传输时间,压缩就是可行的。

启用 Gzip 压缩

在 Nginx 服务器中,可以通过以下配置启用 Gzip 压缩:

gzip on;
gzip_types text/plain text/css application/json application/javascript;

三、使用 CDN

内容分发网络(CDN)可以大幅缩减静态资源的访问时间。特别是对于公共库,可以使用知名的 CDN 资源,这样可以实现跨越站点的缓存。

引入 CDN 资源

<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>

四、合理设置缓存

对于除 HTML 外的所有静态资源,可以开启协商缓存。利用构建工具打包产生的文件 hash 值来置换缓存。

协商缓存

服务器在响应头中添加 ETagLast-Modified 字段,浏览器在后续请求中通过 If-None-MatchIf-Modified-Since 字段进行验证。

ETag: "5d8c72a5edcf8"
Last-Modified: Mon, 23 May 2021 09:00:00 GMT

五、启用 HTTP/2

HTTP/2 具有多路复用、头部压缩等特点,可以充分利用带宽传递大量的文件数据。

多路复用

HTTP/2 允许在单个连接上并行传输多个请求和响应,避免了 HTTP/1.1 中的队头阻塞问题。

六、雪碧图与图片优化

对于不使用 HTTP/2 的场景,可以将多个图片合并为雪碧图,以减少文件数量。

雪碧图

.icon {
  background-image: url('sprite.png');
  background-position: -10px -10px;
}

七、异步加载 JavaScript

通过 deferasync 属性,可以让页面尽早加载 JavaScript 文件,而不会阻塞 HTML 解析。

defer 与 async

<script src="app.js" defer></script>
<script src="analytics.js" async></script>
  • defer:脚本在 HTML 解析完成后执行。
  • async:脚本在下载完成后立即执行。

八、资源预加载

通过 prefetchpreload 属性,可以让页面预先下载可能用到的资源。

prefetch

<link rel="prefetch" href="next-page.js">

preload

<link rel="preload" href="critical-resource.js" as="script">

九、多个静态资源域

对于不使用 HTTP/2 的场景,将相对独立的静态资源分到多个域中保存,可以让浏览器同时开启多个 TCP 连接,并行下载。

多域名策略

<script src="https://static1.example.com/js/app.js"></script>
<link rel="stylesheet" href="https://static2.example.com/css/style.css">

总结

优化网络性能是一个持续的过程,需要开发者不断探索和实践。通过上述方法,可以显著提升 Web 应用的加载速度和用户体验。在实际开发中,应根据具体情况选择合适的优化策略,以达到最佳效果。

栗子前端技术周刊第 89 期 - TypeScript 5.9 Beta、VSCode v1.102、Angular 20.1...

🌰栗子前端技术周刊第 89 期 (2025.07.07 - 2025.07.13):浏览前端一周最新消息,学习国内外优秀文章视频,让我们保持对前端的好奇心。

📰 技术资讯

  1. TypeScript 5.9 Beta:TypeScript 5.9 Beta 已发布,内容包括精简 tsc -init 生成的 tsconfig.json 文件;支持 import defer 语法;支持 --module node20 选项等等。

  2. VSCode v1.102:VSCode v1.102 发布,主要增强了 AI 体验,内容包括:Copilot Chat 完全开源;支持直接导入提示文件;支持生成自定义指令;MCP 管理面板正式发布等等。

  3. Angular 20.1:Angular 20.1 正式发布,内容包括:为 NgComponentOutlet 指令添加对自定义 EnvironmentInjector 的支持、在 NgOptimizedImage 中支持解码功能,编译器添加对新二进制赋值运算符的支持等等。

📒 技术文章

  1. What’s the Difference Between Ordinary Functions and Arrow Functions?:普通函数和箭头函数有什么区别?- 这听起来像是基础内容,但作者总能深入挖掘并进行解释,即便只是 “我应该使用哪种函数声明语法?” 这样的问题,他也能让你对相关概念有更细致入微的理解。

  2. Default parameters: your code just got smarter:默认参数:你的代码变得更智能了 - 简洁快速地介绍了默认参数的定义、使用时的注意事项和使用场景。

  3. React 事件机制:从代码到原理,彻底搞懂合成事件的核心逻辑:本文围绕 React 事件机制展开,介绍了合成事件与原生事件的区别。

🔧 开发工具

  1. jsonrepair:修复无效的 JSON 数据,你可以通过 Node 环境使用它,也可以将其作为命令行界面(CLI)工具,还能在线试用基础版本。
image-20250713150110537
  1. Next.js Boilerplate 5.0:适用于 Next.js 的模板应用,包含身份验证、数据库支持、国际化(i18n)、表单等功能。
image-20250713151025873
  1. URL to Any:一款全能转换工具 —— 通过输入网址,该工具可转换或提取网页内容,支持转换为 Markdown、HTML、PDF、图片、JSON、XML 或纯文本格式。
image-20250713151222600

🚀🚀🚀 以上资讯文章选自常见周刊,如 JavaScript Weekly 等,周刊内容也会不断优化改进,希望你们能够喜欢。

💖 欢迎关注微信公众号:栗子前端

多个组件库混用导致JS爆炸?看我如何瘦身70%!

大家好,我是小杨,一个被 "Bundle Size过大" 折磨了6年的前端老鸟。最近接手一个项目,发现打包后的JS居然有5MB+ !一查原因:同时用了Element UI、Ant Design、Vant三个组件库!今天就来分享我的极限压缩实战经验


1. 先看问题有多严重

webpack-bundle-analyzer分析打包结果,发现:

  • 重复的组件:三个库都有Button、Modal
  • 冗余的工具函数:每个库都自带utils
  • 未按需加载:全量引入了所有组件

fake-url-for-example.com/bundle-anal…
(示意图:各种库的代码像俄罗斯方块一样堆叠)


2. 我的七步瘦身大法

✅ 第一步:按需加载(立减50%)

// 错误写法(全量引入)  
import ElementUI from 'element-ui'  

// 正确写法(按需引入)  
import { Button, Select } from 'element-ui'  

效果:从500KB → 250KB

✅ 第二步:共用同版本依赖(解决重复打包)

// webpack配置  
resolve: {  
  alias: {  
    'moment': path.resolve('./node_modules/moment'),  
    'lodash': path.resolve('./node_modules/lodash')  
  }  
}  

原理:强制所有库用同一个版本的moment/lodash

✅ 第三步:开启Gzip/Brotli压缩(再减60%)

# Nginx配置  
gzip on;  
gzip_types application/javascript;  
brotli on;  

效果:2MB → 800KB

✅ 第四步:抽离公共代码(CommonsChunkPlugin)

// webpack 4+  
optimization: {  
  splitChunks: {  
    chunks: 'all'  
  }  
}  

✅ 第五步:动态导入(懒加载)

// 非首屏组件改用动态导入  
const HeavyComponent = () => import('@/components/HeavyComponent')  

✅ 第六步:移除SourceMap(生产环境)

// vue.config.js  
productionSourceMap: false  

✅ 第七步:终极杀招——换轻量库

  • Day.js替代Moment.js(从200KB → 2KB)
  • lodash-es按需导入

3. 我的翻车现场

事故1:某次优化后页面白屏
原因:误删了公共依赖的polyfill
解法

// 显式声明核心依赖  
import 'core-js/stable'  
import 'regenerator-runtime/runtime'  

事故2:IE11报错
原因:用了Brotli压缩但IE不支持
解法

# Nginx回退方案  
brotli_static off;  
gzip_static on;  

4. 效果对比

优化阶段 JS体积 首屏加载时间
原始状态 5.2MB 8.7s
按需加载 2.8MB 5.2s
公共代码抽离 1.9MB 3.8s
Gzip压缩后 750KB 2.1s

5. 写给架构师的建议

  1. 设计阶段选型:避免混用同类库(比如同时用AntD和Element)

  2. 制定规范

    • 所有组件必须按需引入
    • 工具库统一版本
  3. 监控机制

    // 打包大小阈值警告  
    performance: {  
      maxEntrypointSize: 500000,  
      maxAssetSize: 500000  
    }  
    

6. 高级技巧:组件库CDN化

<!-- 把Vue/ElementUI等移出Bundle -->  
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js"></script>  
<script src="https://cdn.jsdelivr.net/npm/element-ui@2.15.6/lib/index.min.js"></script>  

注意:要配置externals避免重复打包

// webpack配置  
externals: {  
  'vue': 'Vue',  
  'element-ui': 'ELEMENT'  
}  

最后一句忠告

"Bundle Size优化就像减肥——快速瘦身容易反弹,长期控制才是王道!"

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

Vue懒加载全揭秘:从2.x到3.0,我是这样优化首屏速度的!

大家好,我是小杨,一个和Vue相爱相杀6年的老司机。今天要聊的是个既基础又容易踩坑的话题——Vue中的懒加载。最近团队新人问我:"小杨哥,Vue 2.0是不是不能实现懒加载啊?" 我当场就笑了...

1. 先破谣言:Vue 2.x当然能懒加载!

真相:Vue 2.x通过动态import+异步组件完美支持懒加载,这是ES6特性,和Vue版本无关!

// Vue 2.x 标准写法
const MyComponent = () => import('./MyComponent.vue')

2. 我的性能优化实战

场景1:路由懒加载(最常用)

// router.js
const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue') // 关键在这!
  }
]

效果:首次加载只下载当前路由的代码,其他路由等访问时再加载

场景2:组件级懒加载

// 父组件中
export default {
  components: {
    'my-heavy-component': () => import('./HeavyComponent.vue')
  }
}

3. 原理深挖(看过源码的来)

Vue 2.x的懒加载核心是:

  1. webpack的代码分割(生成单独的chunk)
  2. Vue的异步组件工厂函数
  3. 底层使用Promise

源码关键点(简化版):

// vue/src/core/vdom/async-component.js
function resolveAsyncComponent(
  factory: Function,
  baseCtor: Class<Component>
): Class<Component> | void {
  if (isPromise(factory.resolved)) {
    return factory.resolved
  }
  const resolve = (res: Object | Class<Component>) => {
    factory.resolved = ensureCtor(res, baseCtor)
    if (!sync) {
      forceRender(false)
    }
  }
  const res = factory(resolve, reject) // 这里执行import()
  if (isObject(res)) {
    if (isPromise(res)) {
      res.then(resolve, reject)
    }
  }
}

4. Vue 2.x懒加载的三大坑

坑① 魔法注释失效

// 有时候webpack魔法注释不生效
const Foo = () => import(/* webpackChunkName: "my-chunk" */ './Foo.vue')

解法:检查babel配置是否转译了注释

坑② 预加载时机难控

// 可能提前加载非必要资源
const Foo = () => import('./Foo.vue' /* webpackPrefetch: true */)

解法:慎用prefetch,优先用preload

坑③ 错误处理缺失

// 网络出错会白屏
const Foo = () => import('./Foo.vue')

解法:加错误边界

const Foo = () => ({
  component: import('./Foo.vue'),
  loading: LoadingComponent,
  error: ErrorComponent,
  delay: 200,
  timeout: 3000
})

5. Vue 3的超级升级

Vue 3的defineAsyncComponent更强大:

import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Foo.vue'),
  delay: 200, // 延迟显示loading
  timeout: 3000, // 超时处理
  suspensible: false // 是否配合Suspense使用
})

6. 性能对比实测

我的一个项目优化数据:

方案 首屏体积 LCP时间
传统加载 1.2MB 2.8s
Vue 2.x懒加载 420KB 1.4s
Vue 3懒加载 380KB 1.2s

7. 写给新手的建议

  1. 路由必须懒加载:这是性价比最高的优化
  2. 大组件才懒加载:小于30KB的组件没必要
  3. 注意加载状态:一定要加loading效果
  4. 生产环境验证:记得检查chunk是否真的拆分

8. 高级玩法:动态懒加载

我在后台管理系统这样用:

// 根据用户权限动态加载模块
const getAdminComponent = () => {
  return user.isSuperAdmin 
    ? import('./SuperAdmin.vue')
    : import('./NormalAdmin.vue')
}

最后说句大实话

"懒加载不是银弹,用不好反而会降低用户体验" —— 这是我在性能优化分享会上反复强调的。

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

include和exclude傻傻分不清?3分钟让你彻底搞懂!

大家好,我是小杨,一个写了6年前端的老司机。今天咱们来聊聊开发中经常遇到的两个概念——includeexclude。别看它们长得像,用起来可是天差地别!

1. 先看生活化的例子

想象你在准备一场聚会:

  • include:邀请名单(明确指定谁来)
  • exclude:黑名单(明确指定谁不能来)

2. 代码中的经典应用

场景1:路由守卫(Vue Router)

// 只对/about和/contact路由生效
{
  path: '/',
  component: Home,
  meta: { requiresAuth: true },
  include: ['/about', '/contact'] // 白名单模式
}

// 对所有路由生效,除了/login
{
  path: '/',
  component: Home,
  meta: { requiresAuth: true },
  exclude: ['/login'] // 黑名单模式
}

场景2:Webpack配置

// 只处理src目录下的js文件
{
  test: /.js$/,
  include: path.resolve(__dirname, 'src'), // 白名单
  loader: 'babel-loader'
}

// 处理所有js文件,除了node_modules
{
  test: /.js$/,
  exclude: /node_modules/, // 黑名单
  loader: 'babel-loader'
}

3. 我踩过的血泪坑

案例1:有次我写了个权限中间件:

// 错误写法!
const allowedRoutes = ['/home', '/profile']
if (!allowedRoutes.includes(req.path)) {
  return res.status(403).send('无权访问')
}

结果把登录页也拦截了!应该用exclude:

const blockedRoutes = ['/admin']
if (blockedRoutes.includes(req.path)) {
  return res.status(403).send('无权访问')
}

案例2:Webpack打包时不小心:

{
  test: /.css$/,
  exclude: /styles/, // 本意是排除node_modules
  loader: 'css-loader'
}

结果把自己的/styles目录也排除了!应该写成:

exclude: /node_modules/

4. 核心区别总结

特性 include exclude
中文意思 包含 排除
适用场景 明确知道要哪些 明确知道不要哪些
安全性 更安全(白名单) 风险更高(可能漏网)
性能影响 范围小性能好 范围大时性能差
典型应用 路由守卫、loader处理范围 跳过不需要处理的资源

5. 黄金选择法则

  1. 优先用include:当你知道确切需要什么时(更安全)
  2. 谨慎用exclude:当你知道确切不需要什么时
  3. 不要混合用:容易导致逻辑混乱(见过有人同时写include和exclude结果相互抵消)

6. 面试常考题目

面试官:"你们项目为什么用exclude而不是include?"

我的回答:
"我们只在处理第三方库时用exclude跳过node_modules,其他场景都用include精确控制范围,这样既能保证安全又避免性能浪费。"

7. 趣味记忆法

  • include → "in"(在里面)→ 白名单
  • exclude → "ex"(前任)→ 黑名单

最后送大家一句话

"include是圈地养羊,exclude是篱笆防狼" —— 这是我在团队内部分享时说的,现在送给你们。

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

Vue的响应式魔法:从惊艳到看透,6年老司机带你揭秘

大家好,我是小杨,一个写了6年前端的"魔法破解师"。今天咱们来聊聊Vue最核心的魔法——响应式系统。看完这篇,你会从"哇好神奇"变成"哦原来如此"!

1. 先看个魔法现场

data() {
  return {
    message: '你好'
  }
}
// 修改数据
this.message = '新消息' // 页面自动更新!

这魔法怎么实现的?咱们一层层扒开看!

2. 核心三板斧

Vue的响应式靠这三个家伙:

  • Observer(侦察兵):负责数据劫持
  • Dep(调度中心):管理依赖关系
  • Watcher(跑腿小哥):执行更新

3. 手撕源码级实现

① 数据劫持(Object.defineProperty)

function defineReactive(obj, key) {
  const dep = new Dep() // 每个属性配个调度中心
  let value = obj[key]
  
  Object.defineProperty(obj, key, {
    get() {
      if (Dep.target) { // 如果有跑腿小哥在待命
        dep.depend() // 登记依赖关系
      }
      return value
    },
    set(newVal) {
      value = newVal
      dep.notify() // 通知所有跑腿小哥
    }
  })
}

② 依赖收集(Dep类)

class Dep {
  constructor() {
    this.subs = [] // 存所有跑腿小哥
  }
  
  depend() {
    if (Dep.target) {
      this.subs.push(Dep.target)
    }
  }
  
  notify() {
    this.subs.forEach(sub => sub.update())
  }
}
Dep.target = null // 全局标记位

③ 更新触发(Watcher类)

class Watcher {
  constructor(vm, exp, cb) {
    this.cb = cb
    Dep.target = this // 立个flag
    vm._data[exp] // 触发getter,完成依赖收集
    Dep.target = null
  }
  
  update() {
    this.cb() // 执行更新
  }
}

4. 我踩过的三个大坑

坑① 对象新增属性不响应

this.user.age = 25 // 不触发更新!

解法this.$set(this.user, 'age', 25)

坑② 数组变异方法

this.items[0] = '新值' // 不触发!
this.items.length = 0 // 也不触发!

解法:重写数组方法(push/pop等)

坑③ 性能问题
深层嵌套对象劫持会消耗较大内存

5. Vue 3的超级升级(Proxy)

Vue 3改用Proxy,解决了Vue 2的痛点:

const data = new Proxy({ message: '你好' }, {
  get(target, key) {
    track(target, key) // 依赖收集
    return target[key]
  },
  set(target, key, value) {
    target[key] = value
    trigger(target, key) // 触发更新
    return true
  }
})

优势

  • 直接监听新增/删除属性
  • 更好的性能
  • 原生支持数组

6. 响应式的三大短板

  1. 初始化性能开销:递归劫持大对象较慢
  2. 内存占用:每个属性都要维护Dep实例
  3. 无法劫持ES6+新数据结构(Map/Set等)

7. 实战中的骚操作

我在低代码平台这样用:

// 动态添加响应式属性
function addReactiveProp(obj, key) {
  let value = obj[key]
  Object.defineProperty(obj, key, {
    get() { return value },
    set(newVal) {
      value = newVal
      publishChange(key) // 自定义发布逻辑
    }
  })
}

8. 写给新人的建议

  1. 理解原理比会用API更重要

  2. 遇到"数据变了视图不更新"先检查:

    • 是否在data中声明
    • 是否使用了非响应式API
  3. 复杂场景考虑用Vuex/Pinia

最后说句大实话

"Vue的响应式就像自动挡汽车,开起来爽但爆胎时得知道怎么换备胎" —— 这是我在团队内部分享时说的,现在送给你们。

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

不用Vue,手搓一个数据双向绑定?教你用原生JS造轮子!

大家好,我是小杨,一个写了6年前端的老码农。今天咱们不聊Vue,来点刺激的——用原生JS实现Vue的数据双向绑定!看完这篇,你会恍然大悟:"原来Vue的黑魔法这么简单?"

1. 先看Vue的双向绑定多香

<!-- Vue版 -->
<input v-model="message">
<p>{{ message }}</p>

数据一变,视图自动更新,舒服吧?那原生JS咋实现呢?

2. 核心原理拆解

双向绑定其实就是:

  1. 数据变 → 视图变(数据劫持)
  2. 视图变 → 数据变(事件监听)

3. 手把手实现

第一步:数据劫持(Object.defineProperty)

const data = {
  message: '我是初始值'
}

// 劫持数据
function observe(obj) {
  Object.keys(obj).forEach(key => {
    let value = obj[key]
    Object.defineProperty(obj, key, {
      get() {
        console.log(`读取了${key}`)
        return value
      },
      set(newVal) {
        console.log(`${key}被修改为${newVal}`)
        value = newVal
        updateView() // 数据变时更新视图
      }
    })
  })
}

observe(data)

第二步:更新视图

function updateView() {
  document.getElementById('text').innerText = data.message
}

第三步:监听输入(事件绑定)

<input id="input" type="text">
<p id="text"></p>

<script>
document.getElementById('input').addEventListener('input', (e) => {
  data.message = e.target.value // 视图变时修改数据
})
</script>

4. 效果演示

现在试试:

  1. 在控制台修改 data.message = "新值" → 页面自动更新
  2. 在输入框打字 → data.message 同步变化

这不就是简易版v-model吗!

5. 我踩过的坑

第一次实现时我忘了处理嵌套对象:

const data = {
  user: {
    name: '小杨' // 这个子对象没被劫持!
  }
}

解决方案:递归劫持

function observe(obj) {
  if (typeof obj !== 'object') return
  
  Object.keys(obj).forEach(key => {
    let value = obj[key]
    observe(value) // 递归劫持
    // ...原来的defineProperty逻辑
  })
}

6. 进阶版:用Proxy实现(ES6)

更优雅的现代写法:

const data = new Proxy({ message: '你好' }, {
  set(target, key, value) {
    target[key] = value
    updateView()
    return true
  }
})

// 使用方式完全一样
data.message = '新消息' // 自动触发更新

7. 和Vue的差别在哪?

我们实现的简易版缺少:

  • 虚拟DOM优化
  • 依赖收集(Dep/Watcher)
  • 批量异步更新
  • 数组特殊处理

但核心思想一模一样!

8. 实际应用场景

我曾在老项目中用这个思路:

  • 实现表单联动(A输入框变,B选择框选项变)
  • 低代码平台的数据绑定
  • 简单的状态管理

最后送大家两句话:

  1. "理解原理最好的方式就是自己造轮子"
  2. "框架用着爽,但别忘记原生JS才是基本功"

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

Android 运维平台搭建之shell篇

作者:张义飞

一、背景

在物联网领域,Android 设备的广泛应用使其管理与运维需求变得不可或缺。对于非 Android 开发人员来说,如何操控和运维这些设备是个不小的挑战。比如本地要搭建 Android 的开发环境,要熟悉各种调试命令。如果使用 Web 端的图形化设备管理可以解决很多问题,随时随地管理设备,实时查看日志,批量执行命令,提高运维效率,降低管理成本。无论是在日常开发、测试,还是在设备维护、故障排查中,Web 版 Android Shell 终端都展现出了强大的功能和无限的潜力。

二、前期方案

当然在运维平台搭建之前我们也探索了一些其他方案。使用 mqtt 进行指令下发,android 设备收到指令后进行相关指令的执行,执行之后将执行结果上报到日志平台,然后再去日志平台去查询执行结果。

这种设计方式简单,前端和Android 开发人员设计好指令协议,就可以各自实现了,但是会存在一些问题,比如每加一个指令,Android 开发人员都要开发相关功能。而且如果是一个比较耗时,或者网络不好的时候。android 设备没收到 mqtt 指令或者没上报日志平台,对于发送指令的人就会感觉到比较疑惑,不知道运维指令是否下达或执行成功。后来我们探索出来了新的方案, 使用 adb 和 socket 进行长连接的方式,来进行各种指令的转发。

三、ADB

ADB(Android Debug Bridge)是一种允许计算机与 Android 设备通信的命令行工具。它常用于调试应用、安装 APK、复制文件等操作。通常 ADB 是通过 USB 协议或者 TCP 协议来进行数据传输的。

常用的一些命令

# 连接设备
adb devices  # 列出已连接的设备
adb connect <IP地址>:<端口>  # 通过Wi-Fi连接
# 应用管理
adb install path/to/app.apk  # 安装APK
adb uninstall com.example.app  # 卸载应用(包名)
# 文件传输
adb push local/path /sdcard/remote/path  # 从电脑复制到设备
adb pull /sdcard/remote/path local/path  # 从设备复制到电脑
# 执行shell 
adb shell  # 进入设备 shell 环境
adb shell <命令>  # 直接执行 shell 命令(如 `adb shell ls /sdcard`)


更详细的一些操作可参考 Android 开发文档

ADB的基本原理

  • ADB Client:运行在PC上,通过在命令行执行adb,就启动了ADB Client程序
  • ADB Server:运行于PC的后台进程,用于管理ADB Client和Daemon间的通信
  • ADB Daemon (即adbd) :运行在模拟器或移动设备上的后台服务。当Android系统启动时,由init程序启动adbd。如果adbd挂了,则adbd会由init重新启动。

ADB Clinet 和 ADB Server 进行通信时会建立一个本地 TCP 连接,在本地 5037 端口侦听,ADB Client 通过本地随机端口与 5037 端口进行连接。

Android Shell

介绍

Android Shell 是 Android 系统提供的一种命令行接口(CLI),允许用户通过输入文本命令与系统进行交互,执行各种底层操作。它本质上是一个 基于 Linux 内核的 shell 环境,是 Android 系统与开发者、运维人员之间的重要桥梁。

shell 环境

通过 usb 连接到设备,或者使用 adb connect 连接到设备后执行 adb shell 就可以执行各种命令了

比如上面的ls 命令,是列出当前目录下的文件和文件夹。

揭开Web与Android Shell交互的神秘面纱

大家应该听说过Jump Server(跳板机)吧?运维人员或开发人员可借助跳板机管理多台服务器,通过Web端利用SSH或其他协议登录服务器。类似地,Android设备的Shell环境可通过ADB连接实现——执行adb shell命令后,即可进入设备的Shell交互界面,进而执行文件查看(如ls命令)、系统配置修改等操作。例如在RK3288设备中,通过adb shell进入后,能查看INIT.RK30BOARD.RC等系统初始化脚本,或访问/data/system等目录结构。这种方式就像为Android设备搭建了专属的“跳板通道”,让开发者能高效管理设备的系统资源与运行状态。

服务器端我们需要 adb 环境和 node 环境,然后客户端通过 socket 客户端连接到服务器的 socket 端,服务器上的 socket 和 pty 终端来进行数据传输。

四、双向奔赴

当 Android 通过 usb 或者 使用 adb connect 进行连接后。 在终端中我们执行完 android shell 后,会进入到一个交互模式,可以输入各种指令。我们可以使用 node-pty 这个库来伪造终端。然后我们还需要编写 web 端的终端输入模拟器,xterm.js 可以给我们提供在浏览器中比较齐全的终端。我们现在有了 xterms.js 来输入我们各种命令,然后通过 node-pty 执行我们的输入命令,那么他们之间还需要一个桥梁来进行输入和输出的传输。我们可以通过 socket来进行数据传输。

建立连接

Android 设备可通过 usb,或者在局域网内通过 adb connect ip:port方式进行连接。如果要控制非局域网内的设备,需要将 Android 端的端口通过 frp 映射到外网端口上,然后通过 adb connect ip:port的方式建立连接。

await execa`adb connect ip:port`;

如果要控制远程设备需要将远程设备端口映射到外网的 ip 端口上。

frp 映射就是将某个端口的数据通过 tcp 或者其他协议,转发到服务器上的某个开发端口上。这里我们使用的是 tcp,我们在通过 node 服务器和 frp 服务器的端口进行连接。这样我们就能连接上远程设备了。但是当 adb 所在服务器如果连接了多台设备,我们应该怎么准确的去操控我们想要操作的设备。“都是端口”,每台设备都映射到 frp 服务器上某个端口上,node 服务器通过 adb connect ip:port 端口连接上远程服务器。

$ adb devices
List of devices attached
emulator-5554device
192.168.1.101:5555device

这个时候我们想要操控某台设备就需要需要在 adb 后面加个参数来进行了 例如 <font style="color:rgb(28, 31, 35);">adb -s 192.168.1.101:5554 shell</font>,这个命令我们就能进入到192.168.1.101:5555这台设备的 shell 环境了。

进入 shell 环境

创建 pty 进程,进入 shell 环境

  const ptyProcess = pty.spawn('adb', ['shell'], {
    name: 'xterm-color',
    cols: 80,
    rows: 30
  });

pty进程和socket绑定

  const server = new Server(socket.server);
  server.on('connection', (socket) => {
    // 接收客户端发送数据
    socket.on('data', (data: string) => {
      // 发送数据到pty进程
      ptyProcess.write(data);
    });
  });
  ptyProcess.onData((data: string) => {
    // 接收pty进程数据, 发送给客户端
    server.emit('data', data);
  });

客户端和服务器绑定

  let socket: Socket<DefaultEventsMap, DefaultEventsMap>;
  await fetch(`/api/node-pty`);
  socket = io();

至此客户端和服务器端,以及服务器端和 Android 设备端都建立了绑定,这样客户端的输入和 android 端的输出都能有效到达对方了。

最终效果

上面给搭建介绍了 shell 的一个实现过程,当然我们的运维平台还有桌面控制,文件管理等功能。后续还会做一些运维脚本的管理。

五、总结

这里是一个比较简单的 demo 来解释,如何通过 web shell 控制 andoird 设备的,要想控制远程android 设备的话需要使用到 frp 技术将 android 端口映射到远程服务器上。当然一个好的运维平台要让非专业人员操作起来更加便捷才是我们做技术人员要考虑的问题。

六、附录

本示例通过 nextjs 实现,如果有想实现的小伙伴,可参考下发代码

  1. 服务器和 adb 建立连接
import { Server } from 'socket.io';
import { NextApiRequest, NextApiResponse } from 'next';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pty = require('node-pty');

const handler = async(req: NextApiRequest, res: NextApiResponse) => {
  // 创建pty进程
  const ptyProcess = pty.spawn('adb', ['shell'], {
    name: 'xterm-color',
    cols: 80,
    rows: 30
  });
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const socket = res.socket as any;
  const server = new Server(socket.server);
  server.on('connection', (socket) => {
    // 接收客户端发送数据
    socket.on('data', (data: string) => {
      // 发送数据到pty进程
      ptyProcess.write(data);
    });
  });
  ptyProcess.onData((data: string) => {
    // 接收pty进程数据, 发送给客户端
    server.emit('data', data);
  });
  return res.status(200).json({ message: 'success' });
};

export default handler;

  1. 将客户端的输入通过socket 传送给服务端,并将服务端的响应显示到客户端上
'use client';
import React, { useEffect, useRef } from 'react';
import 'xterm/css/xterm.css';
import { io, Socket } from 'socket.io-client';
import { DefaultEventsMap } from '@socket.io/component-emitter';

const TerminalComponent: React.FC = () => {
  const terminalRef = useRef<HTMLDivElement>(null);
  useEffect(() => {
    let socket: Socket<DefaultEventsMap, DefaultEventsMap>;
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    let xterm: any;

    const initializeTerminal = async() => {
      const FitAddon = (await import('xterm-addon-fit')).FitAddon;
      const Terminal = (await import('xterm')).Terminal;
      await fetch(`/api/node-pty`);
      socket = io();

      if (terminalRef.current) {
        xterm = new Terminal({
          cols: 100,
          rows: 60,
          cursorBlink: true,
          cursorStyle: 'block',
          fontSize: 14,
          convertEol: true,
          theme: {
            background: '#000000',
            foreground: '#ffffff',
            cursor: '#2dea5f'
          }
        });

        const fitAddon = new FitAddon();
        xterm.loadAddon(fitAddon);
        xterm.open(terminalRef.current);
        fitAddon.fit();
        xterm.focus();
        socket.on('data', (data: ArrayBuffer) => {
          // 接收 socket server 数据
          xterm.write(data);
        });

        xterm.onData((data: string) => {
          // 发送数据导 socket server
          socket.emit('data', data);
        });
        // 自动输入su
        setTimeout(() => {
          socket.emit('data', 's');
          socket.emit('data', 'u');
          socket.emit('data', '\r');
        }, 1000);
      }
    };

    initializeTerminal();

    return () => {
      if (xterm) xterm.dispose();
    };
  }, []);

  return <div ref={terminalRef} className='h-full w-full' />;
};

export default TerminalComponent;

我学习到的获取.vsix文件方法

一、官方市场下载方法

  1. URL构造法

    • 访问VS Code Marketplace,搜索目标插件(如Live Server)。

    • 从插件详情页获取以下参数:

      • 发布者ID‌:如ritwickdey(Live Server的发布者)
      • 插件名‌:如LiveServer
      • 版本号‌:在详情页的"Version History"中查看
    • 拼接下载链接模板:

      https://marketplace.visualstudio.com/_apis/public/gallery/publishers/{发布者}/vsextensions/{插件名}/{版本号}/vspackage
      
    • 示例(Live Server):

      https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ritwickdey/vsextensions/LiveServer/5.7.9/vspackage
      
  2. 开发者工具辅助下载

    • 在插件详情页按F12打开开发者工具,在控制台执行以下代码自动生成下载链接:

      const identifier = document.querySelector('.ux-item-name').textContent.split('.');
      const version = document.querySelector('[aria-labelledby="version"]').textContent;
      console.log(`https://marketplace.visualstudio.com/_apis/public/gallery/publishers/${identifier}/vsextensions/${identifier‌:ml-citation{ref="4" data="citationList"}}/${version}/vspackage`);
      

二、第三方资源下载

  1. Open VSX Registry

    • 访问open-vsx.org,搜索插件后直接下载.vsix文件。
    • 适用于部分开源插件(如Live Server可通过此平台获取)‌67。
  2. GitHub Releases

    • 部分插件(如Live Server)的GitHub仓库会发布.vsix文件:

  3. VSIXHub等存档站点

三、特定插件(Live Server)的获取步骤

  1. 参数确认

    • 发布者ID:ritwickdey
    • 插件名:LiveServer
    • 最新版本:通过Marketplace或GitHub查看‌810。
  2. 下载方式选择

    来源 操作步骤
    官方Marketplace 构造URL或使用开发者工具提取链接
    Open VSX 直接搜索下载
    GitHub 从Releases页下载.vsix文件

四、离线安装步骤

  1. 通过VSCode安装

    • 打开VSCode,进入扩展视图(Ctrl+Shift+X)。
    • 点击右上角...选择"Install from VSIX",导入下载的.vsix文件‌511。
  2. 手动安装(无GUI环境)

    • 将.vsix文件复制到VSCode的扩展目录:

      • Windows:%USERPROFILE%.vscode\extensions
      • macOS/Linux:~/.vscode/extensions
    • 重启VSCode生效‌1112。

注意事项

  • 版本兼容性‌:确保.vsix文件与VSCode版本匹配。
  • 安全性‌:优先从官方或可信源下载,避免第三方站点的篡改风险。
  • 平台差异‌:部分插件需指定平台参数(如?targetPlatform=win32-x64)‌313。
❌