阅读视图

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

电影院选座功能:Canvas 的实战艺术与性能哲学

千个座位如何丝滑渲染?


一、问题场景:被 DOM 击溃的选座系统

你在接手重构某影院 Web 选座系统时,遭遇了性能噩梦:

  • 座位数量:单厅 300+ 座位,用 div + CSS 布局
  • 用户痛点:缩放卡顿、选择延迟、移动端崩溃
  • 性能检测:Chrome Performance 显示每秒 5 帧,重绘耗时 200ms+

核心矛盾:DOM 的盒模型计算成本 vs 座位图的密集渲染需求。


二、解决方案:Canvas 的降维打击

1. 架构草图

// 初始化画布  
const canvas = document.getElementById('seat-map');
const ctx = canvas.getContext('2d');
const seats = generateSeatGrid(15, 20); // 15行20列

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  seats.forEach(row => {
    row.forEach(seat => {
      drawSeat(seat.x, seat.y, seat.status); // 🔍 动态绘制座位状态
    });
  });
}

关键决策点

  • ctx.clearRect 替代 DOM 的逐元素删除
  • 状态驱动绘制(seat.status 控制颜色)
  • 脱离 DOM 树,规避回流重绘

2. 交互处理(坐标映射)

canvas.addEventListener('click', (e) => {
  const rect = canvas.getBoundingClientRect();
  const x = e.clientX - rect.left; 
  const y = e.clientY - rect.top;
  
  // 🔍 数学映射:像素坐标 → 座位索引
  const col = Math.floor(x / SEAT_SIZE);
  const row = Math.floor(y / SEAT_SIZE);
  
  if (seats[row]?.[col]) {
    seats[row][col].status = 
      seats[row][col].status === 'selected' ? 'free' : 'selected';
    render(); // 状态更新后重绘
  }
});

坐标转换公式
索引 = floor( (点击坐标 - canvas偏移量) / 单个座位像素尺寸 )


三、原理剖析:Canvas 的高性能本质

1. 分层渲染架构

graph TD
A[用户交互] --> B[计算座位状态]
B --> C[清空画布]
C --> D[遍历座位数据]
D --> E[根据状态绘制图形]
E --> F[完成渲染]

2. 性能对比表(Canvas vs DOM)

指标 DOM 方案 Canvas 方案
300座位渲染耗时 120-200ms 5-15ms
内存占用 高(每个元素独立对象) 低(共享上下文)
事件处理 原生事件代理 需手动坐标映射
缩放支持 依赖CSS Transform 内建scale()变换

3. 设计哲学

Canvas 是命令式绘图:开发者描述“如何画”而非“画什么”。这与 DOM 的声明式本质背道而驰,却完美契合高频重绘场景。


四、实战优化:从可用到极致

1. 双缓存策略(解决闪屏)

// 创建离屏Canvas作为缓存
const offscreen = document.createElement('canvas');
const offCtx = offscreen.getContext('2d');

function cacheSeats() {
  // 在离屏Canvas绘制静态座位图
  seats.forEach(seat => drawSeatOn(offCtx, seat)); 
}

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  // 🔍 直接绘制缓存内容(避免重复计算)
  ctx.drawImage(offscreen, 0, 0); 
  // 再绘制动态选中的座位(黄色)
  drawSelectedSeats(ctx); 
}

2. 按需渲染(性能救星)

let isRendering = false;
function lazyRender() {
  if (isRendering) return;
  isRendering = true;
  requestAnimationFrame(() => {
    render();
    isRendering = false;
  });
}
// 在交互事件中调用 lazyRender 而非直接 render

五、举一反三:Canvas 的工程化变体

  1. 演唱会选座方案

    • 挑战:异形舞台 + 座位区环形分布
    • 解法:极坐标映射 (r, θ) → (x, y)
  2. 飞机座位图

    • 特性:舱位等级隔离 + 过道标识
    • 优化:分层绘制(背景层 + 动态层)
  3. 体育馆3D视角

    • 进阶:WebGL + Three.js 实现视角旋转
    • 降级:Canvas 模拟透视(近大远小变换)

小结

当你的界面是空间密集型(座位图、地图、拓扑图)而非内容密集型(表单、列表)时,拥抱 Canvas 就是选择性能的自由。它用最原始的绘图指令,构建了最流畅的视觉宇宙。

如何优雅地实现每 5 秒轮询请求?

在做实时监控系统时,比如服务器状态面板、订单处理中心或物联网设备看板,每隔 5 秒自动拉取最新数据是再常见不过的需求了。 但你有没有遇到过这些问题? 页面切到后台还在疯狂发请求,浪费资源 上一次请求还

Vue 双向数据绑定原理

在开发企业级管理系统时,表单处理几乎是绕不开的核心场景。比如一个用户信息编辑页,输入框内容变化时,数据模型要实时更新;反过来,当 JS 逻辑修改了数据,界面上的输入框也得同步刷新——这就是典型的双向数

一个 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 的薯片,再配上一张能续命的“进度表”,浏览器就能稳稳地吃下任何体积的视频。

一次“秒开”变成“转菊花”的线上事故

上周运营在群里炸锅:
“东南亚用户打开活动页要 7 秒,国内只要 1 秒,是不是 CDN 挂了?”
第一反应是 CDN 节点问题,结果 curl 一看,TLS 握手 30 ms,首包却 2.8 s——典型的 DNS 拖后腿。
最后发现是新买的 .app 域名没开 EDNS0-Client-Subnet,权威 DNS 把新加坡用户解析到美国去了。
要根治,得先把 DNS 协议从“输入网址”到“拿到 IP”整条链路拆个透。


解决方案:把一次域名解析拆成 5 张“车票”

🔍 关键决策点

  • 第 2 步没加 ECS,权威 DNS 只能按递归出口 IP(北京)返回最近节点,导致新加坡绕路。
  • 第 8 步 CNAME 链太长,每多一跳就加一次 RTT。

原理剖析:三层报文结构,一层比一层“黑盒”

层级 协议字段 作用 常见坑
传输层 UDP 53 / TCP 53 0-RTT vs 可靠传输 截断后 fallback TCP 多一次 RTT
应用层 QR、Opcode、RD、RA、RCODE 控制递归/权威行为 RCODE=2 SERVFAIL 时浏览器会重试 3 次
扩展层 EDNS0-Client-Subnet 把用户真实网段带给权威 长度 > 512 字节时部分老旧防火墙直接丢包

抓包片段(tcpdump -nn -s0 -vvv port 53)

;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 0x1234
;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION: EDNS: version: 0, flags:; udp: 4096
;; CLIENT-SUBNET: 116.12.34.0/24/24

看到 CLIENT-SUBNET 就说明 ECS 生效,权威 DNS 能按用户真实地理位置返回最优 A 记录。


应用扩展:一条“秒开”配置模板

我们在 CDN 控制台 + 权威 DNS 做了三件事,把东南亚首包降到 300 ms:

# 1. 权威 DNS 打开 ECS 支持(以 NS1 为例)
curl -X PUT https://api.nsone.net/v1/zones/xxx.com/api \
  -d '{"edns_client_subnet":true}'

# 2. 缩短 CNAME 链
api.xxx.com 60 IN A 1.2.3.4   # 直接 A 记录,不再 CNAME 到 CDN 二级域名

# 3. 运营商递归 DNS 预热
dig @8.8.8.8 api.xxx.com      # 上线前手动触发全球递归缓存

环境适配说明

  • 国内用户走 114.114.114.114,海外走 8.8.8.8,两边 TTL 设 60 s,方便灰度回滚。
  • 老旧安卓 4.x 不支持 ECS,需要单独在边缘节点做 GeoIP fallback。

对比主流“加速”方案

方案 原理 延迟收益 实施成本
ECS 把用户子网带给权威 100-300 ms 改权威 DNS 配置
HTTPDNS 绕过本地递归,直接 HTTP 拿结果 50-200 ms 客户端集成 SDK
DoH/DoT 加密防劫持,复用 HTTP/2 多路复用 0-50 ms 升级系统解析库

最终组合:

  • 国内 App 内嵌 HTTPDNS 兜底;
  • 海外浏览器走 DoH(Cloudflare 1.1.1.1);
  • 权威 DNS 全量开 ECS。

举一反三:三个变体场景思路

  1. 灰度发布
    在权威 DNS 把 10% 流量解析到新版本 IP,TTL 设 30 s,出问题 30 秒全局回滚。

  2. 多活容灾
    利用 DNS 的 weighted 记录,主站 100 权重,灾备 0 权重;故障时一键把灾备调到 100,无需改 CDN。

  3. 防劫持
    客户端预埋一份“可信 IP 列表”,如果 DNS 返回的 A 记录不在列表里,立即切换到 DoH 重查,并上报异常。


小结

  • DNS 不是“黑魔法”,而是一张 5 跳的车票,每一跳都能量化。
  • 打开 ECS、缩短 CNAME、预热递归缓存,是跨国业务的三板斧。
  • 把 DNS 指标(解析耗时、TTL、ECS 命中率)接进 Prometheus,比“用户说慢”提前 5 分钟发现异常。

一次诡异的登录失效

线上告警:用户上午 10:00 登录成功,10:15 刷新页面却跳回登录页。
排查发现,你给 token Cookie 设置了 Max-Age=900,但运维在 Nginx 又加了一层 proxy_cookie_path,把过期时间改写成 Session
用户浏览器里同时出现了两条同名 Cookie,一条 15 min 一条 Session,结果请求带的是后者——登录状态瞬间蒸发。
要彻底堵这种坑,得先拆清楚 Cookie 到底由哪些“零件”组成。


解决方案:把 Cookie 拆成 7 个可配置字段

下面这段代码来自我们统一封装的 setCookie 工具,把每个字段都做成可选参数,避免手写字符串拼错。

// utils/cookie.ts
export const setCookie = (opts: {
  name: string;
  value: string;
  maxAge?: number;      // 秒
  expires?: Date;
  path?: string;
  domain?: string;
  secure?: boolean;
  sameSite?: 'Strict' | 'Lax' | 'None';
  httpOnly?: boolean;   // 🔥 仅服务端可写
}) => {
  const parts = [
    `${opts.name}=${encodeURIComponent(opts.value)}`,
    opts.maxAge   && `Max-Age=${opts.maxAge}`,
    opts.expires  && `Expires=${opts.expires.toUTCString()}`,
    opts.path     && `Path=${opts.path}`,
    opts.domain   && `Domain=${opts.domain}`,
    opts.secure   && 'Secure',
    opts.sameSite && `SameSite=${opts.sameSite}`,
    opts.httpOnly && 'HttpOnly',
  ].filter(Boolean);
  document.cookie = parts.join('; ');
};

🔍 关键决策点

  • encodeURIComponent 兜住中文或特殊符号。
  • Max-AgeExpires 二选一,现代浏览器优先 Max-Age
  • SameSite=None 必须同时带 Secure,否则 Chrome 直接拒绝。

原理剖析:从“键值对”到“安全策略”的三层视角

层级 字段 作用域 设计哲学
数据层 name=value 浏览器 ↔ 服务器 纯业务键值,必须 URL 编码
作用域层 Domain / Path 决定 Cookie 随哪些请求头走 最小权限原则,防跨域污染
安全层 Secure / HttpOnly / SameSite 决定谁能读、谁能写、何时发 纵深防御,层层收紧

时序文字图

  1. 浏览器收到 Set-Cookie: token=abc; Path=/api; Secure; SameSite=Lax
  2. 下次请求匹配 Path=/api 且为 HTTPS,才把 token=abc 塞进 Cookie: 头。
  3. 前端 JS 无法读取 HttpOnly,XSS 偷不走;SameSite=Lax 挡住 CSRF 的 POST 跨站。

应用扩展:一条 Cookie 的“生命周期”配置片段

生产环境我们拆成三档:

// 会话级:关闭标签即失效
setCookie({ name: 'ui-theme', value: 'dark', path: '/', sameSite: 'Lax' });

// 持久化:7 天免登录
setCookie({
  name: 'refreshToken',
  value: jwt,
  maxAge: 7 * 24 * 3600,
  path: '/auth',
  secure: true,
  sameSite: 'Strict',
});

// 服务端专用:前端不可见
setCookie({
  name: 'sessionId',
  value: uuid,
  httpOnly: true,
  secure: true,
  sameSite: 'None',
});

环境适配说明

  • 本地 localhost 开发时,Chrome 允许 Secure Cookie 通过 HTTP,但 Safari 不行,需加 start --ignore-certificate-errors 或直接用 HTTPS 自签证书。
  • 子域共享登录态时,Domain=.example.com 记得带点,否则只对当前子域生效。

举一反三:三个变体场景实现思路

  1. 多租户隔离
    Path 设成 /tenant/:id,前端切换租户时动态改写 Cookie,避免跨租户串号。

  2. A/B 实验灰度
    Max-Age=3600 的短期 Cookie 存分组 ID,服务端按 Cookie 值路由到不同版本,实验结束自然过期,无需清理脚本。

  3. 第三方埋点兼容
    埋点域名 analytics.xxx.com 需要回写 Cookie,但主站是 www.xxx.com,设置 Domain=xxx.com + SameSite=None; Secure,既跨子域又防 CSRF。


小结

  • Cookie 不是“一串字符串”,而是 7 个独立旋钮的组合。
  • 把每个旋钮显式写成参数,就能在代码层面杜绝“拼错分号”这类低级 Bug。
  • 真正上线前,用 Chrome DevTools → Application → Cookies 面板再核对一次,比读 RFC 6265 更直观。

一张 8K 海报差点把首屏拖垮

你给后台管理系统加了一个「企业风采」模块,运营同学一口气上传了 200 张 8K 宣传海报。首屏直接飙到 8.3 s,LCP 红得发紫。
老板一句「能不能像朋友圈那样滑到哪看到哪?」——于是你把懒加载重新翻出来折腾了一轮。


解决方案:三条技术路线,你全踩了一遍

1. 最偷懒:原生 loading="lazy"

一行代码就能跑,浏览器帮你搞定。

<img
  src="https://cdn.xxx.com/poster1.jpg"
  loading="lazy"
  decoding="async"
  width="800" height="450"
/>

🔍 关键决策点

  • loading="lazy" 2020 年后现代浏览器全覆盖,IE 全军覆没。
  • 必须写死 width/height,否则 CLS 会抖成 PPT。

适用场景:内部系统、用户浏览器可控,且图片域名已开启 Accept-Ranges: bytes(支持分段加载)。


2. 最稳妥:scroll 节流 + getBoundingClientRect

老项目里还有 5% 的 IE11 用户,我们只能回到石器时代。

// utils/lazyLoad.js
const lazyImgs = [...document.querySelectorAll('[data-src]')];
let ticking = false;

const loadIfNeeded = () => {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    lazyImgs.forEach((img, idx) => {
      const { top } = img.getBoundingClientRect();
      if (top < window.innerHeight + 200) { // 提前 200px 预加载
        img.src = img.dataset.src;
        lazyImgs.splice(idx, 1); // 🔍 及时清理,防止重复计算
      }
    });
    ticking = false;
  });
};

window.addEventListener('scroll', loadIfNeeded, { passive: true });

🔍 关键决策点

  • requestAnimationFrame 把 30 ms 的节流降到 16 ms,肉眼不再掉帧。
  • 预加载阈值 200 px,实测 4G 网络滑动不白屏。

缺点:滚动密集时 CPU 占用仍高,列表越长越卡。


3. 最优雅:IntersectionObserver 精准观测

新项目直接上 Vue3 + TypeScript,我们用 IntersectionObserver 做统一调度。

// composables/useLazyLoad.ts
export const useLazyLoad = (selector = '.lazy') => {
  onMounted(() => {
    const imgs = document.querySelectorAll<HTMLImageElement>(selector);
    const io = new IntersectionObserver(
      (entries) => {
        entries.forEach((e) => {
          if (e.isIntersecting) {
            const img = e.target as HTMLImageElement;
            img.src = img.dataset.src!;
            img.classList.add('fade-in'); // 🔍 加过渡动画
            io.unobserve(img);            // 观测完即销毁
          }
        });
      },
      { rootMargin: '100px', threshold: 0.01 } // 🔍 提前 100px 触发
    );
    imgs.forEach((img) => io.observe(img));
  });
};
  1. 浏览器合成线程把「目标元素与视口交叉状态」异步推送到主线程。
  2. 主线程回调里只做一件事:把 data-src 搬到 src,然后 unobserve
  3. 整个滚动期间,零事件监听,CPU 占用 < 1%。

原理剖析:从「事件驱动」到「观测驱动」

维度 scroll + 节流 IntersectionObserver
触发时机 高频事件(~30 ms) 浏览器内部合成帧后回调
计算量 每帧遍历 N 个元素 仅通知交叉元素
线程占用 主线程 合成线程 → 主线程
兼容性 IE9+ Edge79+(可 polyfill)
代码体积 0.5 KB 0.3 KB(含 polyfill 2 KB)

一句话总结:把「我每隔 16 ms 问一次」变成「浏览器你告诉我啥时候到」。


应用扩展:把懒加载做成通用指令

在 Vue3 项目里,我们干脆封装成 v-lazy 指令,任何元素都能用。

// directives/lazy.ts
const lazyDirective = {
  mounted(el: HTMLImageElement, binding) {
    const io = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          el.src = binding.value; // 🔍 binding.value 就是 data-src
          io.disconnect();
        }
      },
      { rootMargin: '50px 0px' }
    );
    io.observe(el);
  },
};

app.directive('lazy', lazyDirective);

模板里直接写:

<img v-lazy="item.url" :alt="item.title" />

举一反三:三个变体场景思路

  1. 无限滚动列表
    IntersectionObserver 绑在「加载更多」占位节点上,触底即请求下一页,再把新节点继续 observe,形成递归观测链。

  2. 广告曝光统计
    广告位 50% 像素可见且持续 1 s 才算一次曝光。设置 threshold: 0.5 并在回调里用 setTimeout 延迟 1 s 上报,离开视口时 clearTimeout

  3. 背景图懒加载
    背景图没有 src,可以把真实地址塞在 style="--bg: url(...)",交叉时把 background-image 设成 var(--bg),同样零回流。


小结

  • 浏览器新特性能救命的,就别再卷节流函数了。
  • 写死尺寸、加过渡、及时 unobserve,是懒加载不翻车的三件套。
  • 把观测器做成指令/组合式函数,后续业务直接零成本接入。

现在你的「企业风采」首屏降到 1.2 s,老板滑得开心,运营继续传 8K 图,世界和平。

为什么浏览器那条“假进度”救不了我们?

你在做「企业级低代码平台」时,客户把 200+ 微应用一次性嵌进门户首页。
浏览器自带的进度条只认主文档,微应用懒加载的 JS/CSS/图片它一概不管,用户盯着 100 % 的进度条却白屏 3 秒,投诉直接拉满。
于是,你撸了一条 完全受控的自定义加载进度条,从 0 % 到 100 % 与真实资源一一对应,投诉率当天掉到 0。


方案:15 分钟搭一条“真·进度条”

1. 骨架 HTML(30 秒)

<!-- index.html -->
<div id="progress-bar">
  <div class="fill"></div>
  <span class="text">0%</span>
</div>

2. 核心 JS(5 分钟)

// progress.js
class Loader {
  #total = 0
  #loaded = 0
  #fill = document.querySelector('.fill')
  #text = document.querySelector('.text')

  add(url) {
    this.#total++
    fetch(url).then(() => {
      this.#loaded++
      this.#render()
    })
  }

  #render() {
    const p = Math.round((this.#loaded / this.#total) * 100)
    this.#fill.style.transform = `scaleX(${p / 100})`
    this.#text.textContent = `${p}%`
    if (p === 100) this.#fill.parentElement.remove()
  }
}

window.loader = new Loader()

逐行解析

  • add 把每一个资源注册进来,用原生 fetch 自带 Promise 跟踪完成。
  • scaleX 做宽度动画,GPU 加速不掉帧。
  • 100 % 时自毁 DOM,避免污染全局。

3. 使用姿势(1 分钟)

<script type="module">
  import './progress.js'
  // 🔍 业务代码里显式注册资源
  loader.add('/static/chunk-a.js')
  loader.add('/static/theme.css')
  loader.add('/static/logo.png')
</script>

原理深挖:三层视角看“真进度”

层级 表面用法 底层机制 设计哲学
资源层 fetch 监听 HTTP 缓存协商 + TCP 多路复用 用浏览器能力,不重复造轮子
渲染层 scaleX + requestAnimationFrame 合成层动画,60 FPS 视觉反馈优先,主线程不阻塞
生命周期 100 % 自毁 垃圾回收自动清理 用完即走,零副作用

扩展:3 个真实业务变体

1. Vue3 组合式封装(微应用场景)

// useProgress.ts
import { ref, computed } from 'vue'
export function useProgress() {
  const total = ref(0), loaded = ref(0)
  const percent = computed(() => (loaded.value / total.value) * 100 || 0)
  const add = (url: string) => {
    total.value++
    fetch(url).finally(() => loaded.value++)
  }
  return { percent, add }
}

<Suspense>@pending 里调用 add,进度与组件懒加载天然同步。

2. 大文件切片上传(断点续传)

// 伪代码
chunks.forEach((chunk, i) => {
  loader.add(`/upload?index=${i}`) // 每个切片算 1 个进度单位
})

3. Service Worker 离线缓存

// sw.js
self.addEventListener('install', e => {
  const urls = [...] // 预缓存列表
  e.waitUntil(
    caches.open('v1').then(cache =>
      Promise.all(
        urls.map(u => fetch(u).then(r => cache.put(u, r)))
      )
    )
  )
  // 向主线程 postMessage 更新进度
})

一键复用片段

/* 进度条样式,直接拷 */
#progress-bar {
  position: fixed; top: 0; left: 0; right: 0; height: 3px;
  background: rgba(0,0,0,.1); z-index: 9999;
}
.fill {
  height: 100%; background: #0076ff;
  transform-origin: left; transition: transform .3s ease;
}
.text {
  position: absolute; top: 4px; right: 8px;
  font-size: 12px; color: #0076ff;
}

小结

浏览器那条“假进度”只能骗自己,自定义进度条才是用户信任的起点。
把上面的 30 行代码丢进任何项目,3 分钟就能让白屏时间变成“可控的等待”。
下次老板再说“体验优化”,你就可以把这篇文章甩给他,然后安心下班。

老板突然要看“代码当量 KPI”

你刚把 Vue-lite 交付给私有化客户,领导转头就在群里甩了一句:“下周评审,把各模块代码行数统计出来,软著申请要用。”
用 cloc?当然可以,但客户环境没外网,装不了二进制。于是,你 15 分钟搓了一个 零依赖 的 Node CLI,直接 npx 就能跑,还能按扩展名过滤,结果输出成 JSON 方便后续自动化。


方案:三步做出 count-lines 命令

1. 初始化项目(1 分钟)

mkdir count-lines && cd count-lines
npm init -y
# 🔍 把 bin 字段挂到全局命令
npm pkg set bin.count-lines=./bin/index.js

2. 核心脚本(10 分钟)

bin/index.js

#!/usr/bin/env node
import { readdir, readFile, stat } from 'node:fs/promises'
import { extname, join, resolve } from 'node:path'
import { createWriteStream } from 'node:fs'

const [, , targetDir = '.', ...extArgs] = process.argv
const exts = extArgs.length ? extArgs : ['.js', '.ts', '.vue', '.css', '.html']

async function* walk(dir) {
  for (const name of await readdir(dir)) {
    const full = join(dir, name)
    if ((await stat(full)).isDirectory()) yield* walk(full)
    else yield full
  }
}

async function main() {
  const result = { total: 0, files: {} }
  for await (const file of walk(resolve(targetDir))) {
    if (!exts.includes(extname(file))) continue
    const lines = (await readFile(file, 'utf8')).split('\n').length
    result.total += lines
    result.files[file] = lines
  }
  const out = createWriteStream('count.output.json')
  out.write(JSON.stringify(result, null, 2))
  console.log(`✅ 统计完成,共 ${result.total} 行,详情见 count.output.json`)
}

main()

逐行拆解

  • walk 是一个异步生成器,递归遍历目录,避免一次性读爆内存。
  • exts 支持命令行传参,比如 npx count-lines ./src .vue .ts 只统计 Vue 和 TS。
  • 结果写本地 JSON,方便后续脚本直接 require('./count.output.json')

package.json 关键字段

{
  "type": "module",
  "bin": { "count-lines": "./bin/index.js" }
}

3. 本地测试 & 发布(4 分钟)

chmod +x bin/index.js
npm link          # 本地全局可用
count-lines ./src .vue .ts

原理深挖:从“读文件”到“流式输出”

表面用法 底层机制 设计哲学
readFile 读文本 Node 线程池异步 IO,不阻塞事件循环 单线程也能高并发
split('\n') 计数 利用 V8 内建字符串分割,比正则 /\r?\n/ 快 20% 能省一次正则就省
结果写 JSON 文件 流式写入 createWriteStream,内存占用 < 5 MB 大仓库也吃得下

扩展:3 个变体场景

场景 改动点 思路
软著申请 输出 Word 模板 officegen 把 JSON 渲染成 .docx,自动插入目录
CI 门禁 行数增量报警 在 GitHub Action 里对比 count.output.json 两次 commit 差异
多语言仓库 支持 .py .go exts 换成 Map,key 为扩展名,value 为注释正则,过滤空行和注释

一键复用片段

# 全局安装(私有 npm 源)
npm i -g @yourscope/count-lines
# 在 CI 里
- run: count-lines . .ts .vue
- run: node scripts/check-diff.js

把脚本丢进公司私有仓库,下次谁再要 KPI,一行命令搞定。

为什么我们要亲手“捏”一个 Vue 项目?

你在一家做 B 端 SaaS 的公司,产品迭代节奏极快。某天,老板突然甩来一句:“客户要私有化部署,包体必须 < 500 KB,脚手架里那些没用到的依赖全给我砍掉!”
那一刻,你深刻体会到:脚手架是“通用解”,而私有化场景需要“定制解”。于是,你决定从零手搓一个 Vue 应用,既能极致瘦身,又能随时插拔功能。


解决方案:30 分钟搭出可交付的“裸奔” Vue3 项目

1. 环境准备(2 分钟)

# 🔍 用 volta 锁 Node 版本,避免“在我电脑能跑”
volta install node@20
mkdir vue-lite && cd vue-lite
npm init -y

2. 最小依赖安装(3 分钟)

# 只装运行时 + 编译时刚需
npm i vue@next
npm i -D vite @vitejs/plugin-vue

为什么选 Vite?
对比 Webpack5(webpage 3 的做法),Vite 在 dev 阶段用 esbuild 做预构建,冷启动 < 300 ms,正适合我们“边改边看”的私有化调试场景。

3. 目录结构(5 分钟)

vue-lite
├─ public
│  └─ favicon.ico
├─ src
│  ├─ main.ts          # 应用入口
│  ├─ App.vue
│  └─ components
│     └─ Hello.vue
├─ index.html          # Vite 的“钩子”
└─ vite.config.ts

4. 核心文件代码(10 分钟)

index.html

<!doctype html>
<html>
  <head>
    <title>Vue-Lite</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  build: {
    lib: {
      entry: 'src/main.ts',
      name: 'VueLite',
      fileName: 'vue-lite',
    },
    rollupOptions: {
      external: ['vue'], // 🔍 把 vue 打成 external,私有化时再外链 CDN
    },
  },
})

src/main.ts

import { createApp } from 'vue'
import App from './App.vue'

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

src/App.vue

<template>
  <Hello />
</template>

<script setup lang="ts">
import Hello from './components/Hello.vue'
</script>

src/components/Hello.vue

<template>
  <h1>{{ msg }}</h1>
</template>

<script setup lang="ts">
const msg = 'Hello, 私有化客户!'
</script>

原理剖析:从“裸文件”到“可交付产物”的三层视角

层级 表面用法 底层机制 设计哲学
Dev 阶段 npm run dev 秒开浏览器 Vite 把 .vue 文件实时编译成 ESModule,浏览器直接 import 浏览器原生能力优先,工具链只做“翻译”
Build 阶段 npm run build 生成 dist/vue-lite.umd.js Rollup 做 tree-shaking + 代码分割,external vue 减少 80 KB 私有化场景下,业务代码与框架解耦
Runtime 阶段 客户页面 <script src="vue-lite.umd.js"></script> UMD 格式自动判断宿主环境(CommonJS / AMD / 全局) 不侵入客户构建体系,即插即用

应用扩展:把“裸奔”项目武装到牙齿

1. 插拔式路由(不打包路由库)

// src/router/index.ts
import { ref, computed } from 'vue'

const routes = {
  '/': () => import('../pages/Home.vue'),
  '/about': () => import('../pages/About.vue'),
}

export const path = ref(location.pathname)
window.addEventListener('popstate', () => (path.value = location.pathname))

export const currentView = computed(() => routes[path.value] || routes['/'])

原理:利用浏览器原生 popstate + Vue3 的响应式,实现 0 依赖路由。
场景:后台管理系统只有 3-4 个页面,无需整包 vue-router。

2. 按需引入 UI 组件(以 ElementPlus 为例)

// vite.config.ts 新增
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'

plugins: [
  vue(),
  Components({ resolvers: [ElementPlusResolver()] }),
]

结果:按钮、表格用到多少就打包多少,未使用的组件 0 成本。

3. 私有化环境变量注入

// src/env.ts
export const API_BASE = import.meta.env.VITE_API_BASE || 'https://saas.example.com'

客户部署时只需在 Nginx 加一行:

location / {
  sub_filter 'https://saas.example.com' 'https://customer.internal';
}

举一反三:3 个变体场景实现思路

场景 关键差异 实现思路
微前端子应用 需要生命周期钩子 main.ts 导出 bootstrap / mount / unmount,用 Vite 的 library 模式打包成 systemjs 格式
低代码平台渲染器 动态组件量巨大 defineAsyncComponent + import.meta.glob 做运行时加载,配合 CDN 缓存
Chrome 插件 popup 包体 < 100 KB 关闭 Vite 的代码分割,用 build.minify='terser' + pure_funcs=['console.log'] 删除所有日志

可复用配置片段

// vite.config.ts(私有化专用)
export default defineConfig(({ command }) => ({
  base: command === 'build' ? '/static/vue-lite/' : '/', // 🔍 适配客户子路径
  plugins: [vue()],
  build: {
    rollupOptions: {
      external: ['vue'],
      output: {
        globals: { vue: 'Vue' }, // 告诉 UMD 外部依赖叫 Vue
      },
    },
  },
}))

小结

不用脚手架,不是“为了酷”,而是“为了活”。当你面对包体、合规、私有化这些真实约束时,手搓项目就像给自己开了一条逃生通道:

  • 想砍依赖?直接删 package.json 一行。
  • 想换构建?Vite 换 Rollup 换 esbuild,5 分钟搞定。

下次老板再提“极限瘦身”,你就可以淡定地打开这篇文章,30 分钟交差。

❌