Showing Posts From

JavaScript

React 回到顶部按钮:自实现 vs Ant Design FloatButton vs react-scroll-to-top

自己实现(推荐,无依赖) 约 30 行代码,支持平滑滚动,可自由定制样式: import { useEffect, useState } from "react";export default function BackTop() { const [show, setShow] = useState(false); useEffect(() => { const onScroll = () => { setShow(window.scrollY > 300); }; window.addEventListener("scroll", onScroll); return () => window.removeEventListener("scroll", onScroll); }, []); if (!show) return null; return ( <button onClick={() => window.scrollTo({ top: 0, behavior: "smooth", }) } style={{ position: "fixed", right: 20, bottom: 20, width: 50, height: 50, borderRadius: "50%", cursor: "pointer", }} > ↑ </button> ); }用法: function App() { return ( <> <YourPage /> <BackTop /> </> ); }滚动超过 300px 才显示,点击后平滑回到顶部。useEffect 里记得清理监听器,避免内存泄漏。 Ant Design:FloatButton.BackTop 已经用了 Ant Design 的项目,直接用现成组件: import { FloatButton } from "antd";function App() { return ( <> <YourPage /> <FloatButton.BackTop /> </> ); }支持自动显示/隐藏、动画、自定义位置和图标,零配置可用。 Material UI MUI 没有专门的 BackTop 组件,官方示例用 Fab 实现: import Fab from "@mui/material/Fab"; import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";<Fab color="primary" size="small" onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })} > <KeyboardArrowUpIcon /> </Fab>第三方库:react-scroll-to-top 如果项目没有 UI 库但想省事: npm install react-scroll-to-topimport ScrollToTop from "react-scroll-to-top";function App() { return ( <> <YourPage /> <ScrollToTop smooth /> </> ); }支持平滑滚动、自定义颜色、自定义 SVG 图标、自定义触发高度。 选哪个场景 推荐方案普通 React 项目 自实现,30 行以内已用 Ant Design FloatButton.BackTop已用 MUI Fab + window.scrollTo想快速加功能 react-scroll-to-top不要为了一个回到顶部按钮引入专门的依赖,自实现版本代码量极小且完全可控。

JS 注入标注快捷键面板:数据驱动 radio 分组与可拖拽悬浮窗

基础版:数字键触发单个标签 click (function () { const labels = ["路中障碍物", "施工区域", "基本可见", "部分可见", "运动", "静上"]; // 防止重复注入 const old = document.getElementById("label-hotkey-panel"); if (old) old.remove(); const panel = document.createElement("div"); panel.id = "label-hotkey-panel"; panel.style.cssText = "position:fixed;top:20px;right:20px;z-index:999999;background:rgba(0,0,0,.85);" + "color:#fff;padding:12px;border-radius:10px;font-size:14px;min-width:220px;"; function getEl(name) { return document.querySelector(`span.label[title="${name}"]`); } labels.forEach((name, idx) => { const row = document.createElement("div"); row.style.cssText = "display:flex;align-items:center;justify-content:space-between;margin:6px 0;"; const info = document.createElement("span"); info.textContent = `[${idx + 1}] ${name}`; const btn = document.createElement("button"); btn.textContent = getEl(name) ? "点击" : "不存在"; btn.style.cssText = `cursor:pointer;border:none;padding:4px 8px;border-radius:6px; background:${getEl(name) ? "#409eff" : "#666"};color:white;`; btn.onclick = () => { const el = getEl(name); el ? el.click() : console.warn("未找到:", name); }; row.append(info, btn); panel.appendChild(row); }); document.body.appendChild(panel); document.addEventListener("keydown", (e) => { const n = parseInt(e.key); if (!isNaN(n) && n >= 1 && n <= labels.length) { const el = getEl(labels[n - 1]); el ? el.click() : console.warn("未找到:", labels[n - 1]); } }); })();数据驱动 radio 分组版(推荐维护方式) 多个分组各自单选,按反引号 ` 执行当前选择: (function () { if (document.getElementById("label-panel")) return; // 只改这里就能增减标签 const groups = { g1: { title: "第一组", options: ["路中障碍物", "施工区域"] }, g2: { title: "第二组", options: ["基本可见", "部分可见", "基本不可见"] }, g3: { title: "���三组", options: ["运动", "禁止"] } }; const panel = document.createElement("div"); panel.id = "label-panel"; panel.style.cssText = "position:fixed;top:20px;right:20px;z-index:999999;background:#fff;" + "border:1px solid #ccc;border-radius:8px;padding:12px;" + "box-shadow:0 0 10px rgba(0,0,0,.2);font-size:14px;width:240px;"; // 动态渲染 HTML const html = Object.entries(groups) .map(([key, group]) => { const radios = group.options .map((opt, idx) => ` <label style="display:block;margin:2px 0;"> <input type="radio" name="${key}" value="${opt}" ${idx === 0 ? "checked" : ""}>${opt} </label>`) .join(""); return `<div style="margin-bottom:8px;"><b>${group.title}</b><div>${radios}</div></div>`; }) .join(""); panel.innerHTML = ` <div style="font-weight:bold;margin-bottom:10px;">标签快捷选择</div> ${html} <hr> <div style="font-size:12px;color:#666;">反引号 = 执行</div> `; document.body.appendChild(panel); function clickLabel(title) { const el = document.querySelector(`span.label[title="${title}"]`); el ? el.click() : console.warn("未找到:", title); } function execute() { Object.keys(groups).forEach(key => { const selected = document.querySelector(`input[name="${key}"]:checked`); if (selected) clickLabel(selected.value); }); } document.addEventListener("keydown", e => { if (["INPUT", "TEXTAREA"].includes(e.target.tagName)) return; if (e.key === "`") { e.preventDefault(); execute(); } if (e.key === "Tab") { e.preventDefault(); document.querySelector('input[type="checkbox"][value="开启高层过滤"]')?.click(); } }); })();添加拖拽与折叠 // 拖动 let dragging = false, ox = 0, oy = 0; panel.addEventListener("mousedown", e => { if (e.target.tagName === "INPUT") return; dragging = true; ox = e.clientX - panel.offsetLeft; oy = e.clientY - panel.offsetTop; }); document.addEventListener("mousemove", e => { if (!dragging) return; panel.style.left = (e.clientX - ox) + "px"; panel.style.top = (e.clientY - oy) + "px"; panel.style.right = "auto"; }); document.addEventListener("mouseup", () => dragging = false);// 折叠 let collapsed = false; panel.querySelector("#toggle-btn").onclick = () => { collapsed = !collapsed; panel.querySelector(".body").style.display = collapsed ? "none" : "block"; };面板头部加折叠按钮: <div style="display:flex;justify-content:space-between;align-items:center;cursor:pointer"> <b>label</b> <span id="toggle-btn">[-]</span> </div> <div class="body">...</div>Python 批量转义 JS 中的汉字 把 JS 文件里的中文字符替换成 \uXXXX,代码仍然可以在浏���器运行: import redef encode_chinese(js): def repl(m): return ''.join(f'\\u{ord(c):04x}' for c in m.group(0)) return re.sub(r'[一-鿿]+', repl, js)js_code = ''' const title = "路中障碍���"; document.querySelector('span.label[title="施工区域"]'); '''print(encode_chinese(js_code)) # const title = "路中障碍物"; # document.querySelector('span.label[title="施工区域"]');只替换汉字,JS 语法和 ASCII 字符保持不变,浏览器完全等价执行。

给页面注入一个快捷键面板:数字键快速点击标签

做标注 / 高频点击类工作,鼠标点半天累。给页面注入一个悬浮面板 + 快捷键,数字键触发对应元素的 click()——效率能翻几倍。 直接粘贴到浏览器 Console (() => { const items = [ { key: "1", title: "标签A" }, { key: "2", title: "标签B" }, { key: "3", title: "标签C" }, { key: "4", title: "标签D" }, { key: "5", title: "标签E" }, ]; function findEl(title) { return document.querySelector(`span.label[title="${title}"]`); } function clickByTitle(title) { const el = findEl(title); if (!el) return console.warn(`未找到: ${title}`); // 框架页面用 dispatchEvent 更稳 el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window, })); console.log(`点击: ${title}`); } // 面板 const panel = document.createElement("div"); panel.style.cssText = ` position: fixed; top: 80px; right: 20px; width: 220px; background: rgba(0,0,0,.85); color: #fff; padding: 12px; border-radius: 12px; z-index: 999999; font-family: system-ui; font-size: 14px; `; panel.innerHTML = "<b>快捷键面板</b>"; function render() { panel.querySelectorAll(".item").forEach(e => e.remove()); for (const it of items) { const row = document.createElement("div"); row.className = "item"; const ok = !!findEl(it.title); row.style.cssText = ` display:flex; justify-content:space-between; margin:6px 0; padding:4px 6px; border-radius:6px; background:${ok ? "rgba(0,255,0,.12)" : "rgba(255,0,0,.12)"}; cursor:pointer; `; row.innerHTML = `<span>[${it.key}] ${it.title}</span><span>${ok ? "🟢" : "🔴"}</span>`; row.onclick = () => clickByTitle(it.title); panel.appendChild(row); } const tip = document.createElement("div"); tip.className = "item"; tip.style.cssText = "margin-top:10px;color:#aaa;font-size:12px;"; tip.textContent = "F8 显示/隐藏"; panel.appendChild(tip); } render(); document.body.appendChild(panel); // 键盘监听 document.addEventListener("keydown", e => { if (e.key === "F8") { panel.style.display = panel.style.display === "none" ? "block" : "none"; return; } // 输入框里不触发 const t = e.target; if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement || t?.isContentEditable) return; const item = items.find(x => x.key === e.key); if (item) { e.preventDefault(); clickByTitle(item.title); } }); // 动态渲染的页面定时刷新状态 setInterval(render, 2000); })();功能:右上角浮 220px 宽面板 每项显示 [键] 标题 + 找到/未找到指示 按 1~5 触发对应点击 F8 切换显示/隐藏 面板项本身也能点击 输入框获得焦点时不拦截(不然打字触发 bug)打包成油猴脚本 // ==UserScript== // @name 快捷键面板 // @match https://your-site.com/* // @run-at document-end // @grant none // ==/UserScript==(function () { // ... 上面的代码 })();document-end 保证 DOM 建好之后再挂面板。SPA 页面切路由后可能面板还在但按键失效——加一个 MutationObserver 监听页面变化重新 render 即可。 复杂点击的兼容 某些 Vue / React 页面直接 el.click() 触发不到框架事件(因为它们监听的是自己合成的事件,而不是原生 DOM 事件),改用: el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window, }));冒泡是关键——很多框架的 delegation 是在文档根节点上监听的。 更进一步的想法 如果是标注工具,还能加:Q/W/E/R/T/Y 左手键位(比数字键位好按) 组合标签(Shift+1 = 同时点 1 和 3) 自动跳下一张图(连点两个标签 → 自动 next) 今日已标数统计 本地保存自定义键位(localStorage) CSV 导出统计放在 localStorage 里存 key 配置: const config = JSON.parse(localStorage.getItem("hotkey-config") || "[]"); if (config.length) items.splice(0, items.length, ...config);一句话总结 Console 里几十行 JS 就能注入一个快捷键面板,dispatchEvent 兼容 Vue/React 框架点击、输入框内不拦截。做高频标注 / 批量操作时效率提升明显。

Permissions Policy Violation: unload is not allowed 是什么

Chrome 控制台偶尔出现: [Violation] Permissions policy violation: unload is not allowed in this document.不是代码报错,是一条警告。 原因 Chrome 正在逐步废弃 unload 事件,因为注册了 unload 的页面无法进入 BFCache(Back/Forward Cache),会降低导航性能。某些页面策略或 iframe 设置下,unload 会被直接禁止,此时任何注册 unload 的代码都会打印这条 Violation。 常见触发场景:第三方 SDK(Sentry、埋点、广告脚本)注册了 unload 旧版 Citrix/Enterprise 插件 页面在 <iframe> 里,父页面设置了 Permissions-Policy: unload=()是否影响功能 通常不影响。[Violation] 只是警告,页面照常运行,unload 回调不会执行而已。如果你的业务逻辑依赖 unload(如发送离开事件),需要改用 visibilitychange 或 pagehide。 查看当前页面的 Permissions Policy 方法一:Network 响应头 打开 DevTools → Network,刷新页面,点击 HTML 文档请求 → Response Headers,查找: Permissions-Policy: unload=()方法二:控制台查询 // 查看 unload 是否被策略禁止 document.permissionsPolicy?.allowsFeature("unload")// 查看所有允许的特性 document.permissionsPolicy.allowedFeatures()// 抓取当前页面响应头 fetch(location.href) .then(r => console.log(r.headers.get("Permissions-Policy")))定位是谁注册了 unload // 查看 window 上注册了哪些 unload 监听器 getEventListeners(window).unload// 查看 beforeunload getEventListeners(window).beforeunload如果有结果,点击 listener 里的函数名可以跳转到 Sources 对应代码。 也可以在 Sources 全局搜索: addEventListener("unload" window.onunload beforeunload推荐替代方案 // 替代 unload:页面对用户不可见时触发 document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") { // 发送最后的数据 navigator.sendBeacon("/api/leave", payload); } });// 替代 beforeunload(不阻塞 BFCache) window.addEventListener("pagehide", (e) => { if (e.persisted) { // 页面进入 BFCache,不是真正关闭 } });navigator.sendBeacon 在页面隐藏时异步发送请求,不阻塞页面关闭,是发送离开事件的首选方案。 Chrome Extension 的本地存储 Chrome 扩展用 chrome.storage.local 存储的数据保存在: Windows: %LOCALAPPDATA%\Google\Chrome\User Data\Default\Local Extension Settings\<扩展ID> macOS: ~/Library/Application Support/Google/Chrome/Default/Local Extension Settings/<扩展ID>目录内容是 LevelDB 格式(.log、.ldb 文件),不是纯文本。查看方法: # 在扩展页面控制台读取(需要 Inspect views 权限) chrome.storage.local.get(null, console.log)或用 Python plyvel 解析: import plyveldb = plyvel.DB("/path/to/extension/storage", create_if_missing=False) for k, v in db: print(k.decode(errors="replace")) print(v.decode(errors="replace")) db.close()注意:读取时浏览器需要关闭,否则 LevelDB 文件被锁。

Chrome 扩展数据存放位置:LevelDB 与 IndexedDB

Chrome 扩展的 chrome.storage.local 数据并不存在普通的 localStorage 里,而是存在用户数据目录下以 LevelDB 格式保存的独立数据库中。 数据存放路径 Windows # Chrome C:\Users\用户名\AppData\Local\Google\Chrome\User Data\Default\Local Extension Settings\# Edge C:\Users\用户名\AppData\Local\Microsoft\Edge\User Data\Default\Local Extension Settings\每个扩展一个子目录,目录名是扩展 ID: Local Extension Settings\ ├── nkbihfbeogaeaoehlefnkodbefgpgknn\ ← MetaMask │ ├── LOG │ ├── CURRENT │ ├── MANIFEST-000001 │ └── 000003.ldb └── cjpalhdlnbpafiamejdnhcphjbkeiagm\ ← uBlock Origin里面是 LevelDB 格式,不能直接用文本编辑器读取。 macOS ~/Library/Application Support/Google/Chrome/Default/Local Extension Settings/找到扩展 ID 打开 chrome://extensions/,开启右上角开发者模式,即可看到每个扩展的 ID(32 位小写字母)。 在 DevTools 中读取扩展数据 打开扩展的背景页(Service Worker):chrome://extensions/ → 点击扩展的"检查视图:Service Worker"或"background page" 在 DevTools Console 中:// 读取所有 storage.local 数据 chrome.storage.local.get(null, console.log);// 或 const data = await chrome.storage.local.get(); console.log(data);其他存储类型路径 Default\ ├── Local Extension Settings\ ← chrome.storage.local(LevelDB) ├── Extension State\ ← chrome.storage.session ├── IndexedDB\ ← IndexedDB │ └── chrome-extension_xxx.indexeddb.leveldb └── Session Storage\ ← sessionStorageAdsPower 多配置文件批量同步扩展数据 AdsPower 的每个浏览器配置文件存在各自的 cache 目录下,批量同步扩展 LevelDB 数据: import json import shutil import sys from pathlib import Path from datetime import datetime from tqdm import tqdmCONFIG_FILE = Path("config.json") EXT_DIR = "mcohilncbfahbmgdjkbpemcciiolgcge" # 扩展 IDDEFAULT_CONFIG = { "src": r"D:\.ADSPOWER_GLOBAL\cache", "dst": r"D:\ads\.ADSPOWER_GLOBAL\cache" }if not CONFIG_FILE.exists(): with open(CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(DEFAULT_CONFIG, f, indent=4, ensure_ascii=False) print("已创建 config.json,请修改后重新运行") sys.exit(0)with open(CONFIG_FILE, "r", encoding="utf-8") as f: config = json.load(f)src_root = Path(config["src"]) dst_root = Path(config["dst"])# 找出所有包含目标扩展数据的配置文件目录 tasks = [ d for d in src_root.iterdir() if d.is_dir() and (d / "Default" / "Local Extension Settings" / EXT_DIR).exists() ]print(f"发现 {len(tasks)} 个配置需要同步")for index, cache_dir in enumerate(tasks, 1): src = cache_dir / "Default" / "Local Extension Settings" / EXT_DIR dst = dst_root / cache_dir.name / "Default" / "Local Extension Settings" / EXT_DIR print(f"\n[{index}/{len(tasks)}] {cache_dir.name}") # 备份旧目录(加时间戳,不直接删除) if dst.exists(): backup = dst.parent / f"{dst.name}_backup_{datetime.now():%Y%m%d_%H%M%S}" dst.rename(backup) # 只保留最近 5 个备份 backups = sorted(dst.parent.glob(f"{dst.name}_backup_*"), key=lambda p: p.stat().st_mtime, reverse=True) for old in backups[5:]: shutil.rmtree(old) dst.mkdir(parents=True, exist_ok=True) files = [f for f in src.rglob("*") if f.is_file()] with tqdm(total=len(files), desc=cache_dir.name, unit="file", ncols=100) as pbar: for file in files: target = dst / file.relative_to(src) target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(file, target) pbar.update(1)print("\n全部同步完成")关键点:shutil.rmtree 太暴力,改用 rename 加时间戳备份 保留最近 5 个备份,自动清理旧版本 tqdm 显示每个目录的文件级复制进度打包成独立 exe: pip install pyinstaller tqdm pyinstaller -F sync_ext.py

Binance WebSocket 多流订阅:Combined Stream 与 SUBSCRIBE 动态管理

Binance Spot WebSocket 的一个连接可以订阅多个代币的多个数据流,不需要为每个代币单独建连接。 方法一:Combined Stream URL 连接时直接在 URL 里拼接多个 stream,用 / 分隔: wss://stream.binance.com:9443/stream?streams=btcusdt@trade/ethusdt@tradewss://stream.binance.com:9443/stream?streams=btcusdt@bookTicker/ethusdt@bookTicker/solusdt@bookTicker收到的消息会包含 stream 字段标识来源: { "stream": "btcusdt@trade", "data": { ... } }方法二:SUBSCRIBE 消息(动态添加) 先连接基础 endpoint: wss://stream.binance.com:9443/ws连接建立后发送 JSON 消息订阅: { "method": "SUBSCRIBE", "params": [ "btcusdt@aggTrade", "ethusdt@aggTrade", "solusdt@aggTrade" ], "id": 1 }id 是请求 ID,服务端会返回对应的响应(result: null 表示成功): { "result": null, "id": 1 }取消订阅用 UNSUBSCRIBE,结构相同: { "method": "UNSUBSCRIBE", "params": ["btcusdt@aggTrade"], "id": 2 }常用 Stream 类型Stream 名称 说明<symbol>@trade 逐笔成交<symbol>@aggTrade 归集成交(合并同一时间同方向同价格的成交)<symbol>@bookTicker 最优挂单(bid/ask 实时更新)<symbol>@ticker 24 小时价格统计<symbol>@kline_1m K 线(1 分钟,其他周期类似)<symbol>@depth5 5 档深度快照<symbol>@depth@100ms 深度增量推送(100ms 间隔)<symbol> 全小写,如 btcusdt、ethusdt。 数量限制单个 WebSocket 连接最多订阅 1024 个 stream 超出上限需要新开连接 一般订阅几十到几百个代币的 bookTicker,一个连接完全够用选哪种方式场景 推荐启动时订阅固定代币列表 Combined Stream URL运行时动态增减代币 SUBSCRIBE / UNSUBSCRIBE需要区分消息来源 Combined Stream(有 stream 字段)两种方式可以混用:先用 Combined Stream URL 订阅一批,再用 SUBSCRIBE 动态添加新的代币。

JavaScript 属性访问断点:Object.defineProperty getter 与 Proxy 监听

问题场景 想知道 window.__VINFO_DATA__.proxyhttp 在哪段代码中被读取,但不知道从哪里打断点。 方法一:Object.defineProperty getter(最直接) 在 Console 执行: const oldValue = window.__VINFO_DATA__.proxyhttp;Object.defineProperty(window.__VINFO_DATA__, "proxyhttp", { configurable: true, enumerable: true, get() { debugger; // 读取时触发断点 console.trace("proxyhttp get"); // 打印调用栈 return oldValue; }, set(v) { console.log("proxyhttp set", v); } });之后任何代码执行 window.__VINFO_DATA__.proxyhttp 时都会暂停在 debugger,可以在调用栈里找到来源。 方法二:Proxy 监听指定属性 如果属性值后续会变化,用 Proxy 更灵活: window.__VINFO_DATA__ = new Proxy(window.__VINFO_DATA__, { get(target, prop, receiver) { if (prop === "proxyhttp") { debugger; console.trace("proxyhttp get"); } return Reflect.get(target, prop, receiver); }, set(target, prop, value, receiver) { if (prop === "proxyhttp") { console.log("proxyhttp set", value); } return Reflect.set(target, prop, value, receiver); } });Reflect.get/set 保持原始行为,不破坏已有逻辑。 方法三:监控所有属性访问 不知道哪个属性会被访问时,监听整个对象: window.__VINFO_DATA__ = new Proxy(window.__VINFO_DATA__, { get(target, prop, receiver) { console.log("GET", prop); return Reflect.get(target, prop, receiver); } });会打印所有被读取的属性名,帮助定位目标属性。 检查已有描述符 在 hook 之前先检查属性的描述符,避免覆盖已有 getter: Object.getOwnPropertyDescriptor(window.__VINFO_DATA__, "proxyhttp") // {value: "...", writable: true, enumerable: true, configurable: true} // 或者 // {get: f, set: f, enumerable: true, configurable: true}如果已经有 getter,需要先保存原 getter 再包装: const desc = Object.getOwnPropertyDescriptor(window.__VINFO_DATA__, "proxyhttp"); const originalGet = desc.get;Object.defineProperty(window.__VINFO_DATA__, "proxyhttp", { ...desc, get() { debugger; return originalGet ? originalGet.call(this) : desc.value; } });适用场景场景 推荐方案监听特定属性读取 Object.defineProperty getter属性值会动态变化 Proxy不知道具体属性名 全对象 Proxy GET 打印属性已有 getter 保存原 getter 后包装这类调试技巧常用于逆向分析第三方网页、油猴脚本开发和 SDK 调试。

Konva 做标注工具:Stage 缩放平移的正确姿势

Konva 是我做图片标注类工具时首选的 canvas 库。做缩放平移功能时,一个常犯的错——去缩放图片节点本身——会导致标注框和图片坐标错位。正确的做法是缩放整个 Stage。 缩放 Image 还是缩放 Stage 先看差别: // ❌ 缩放 Image 节点 imageNode.scale({ x: 2, y: 2 }); // 只有图片变大,标注框不动// ✅ 缩放整个 Stage stage.scale({ x: 2, y: 2 }); // 图片和所有标注框一起变大,坐标对齐CVAT、LabelStudio、AnyLabeling 这类专业标注工具全走 Stage 缩放路线。 鼠标位置为中心的滚轮缩放 图片查看器的标配——滚轮向前放大、向后缩小、以鼠标所在位置为中心。核心逻辑: stage.on("wheel", e => { e.evt.preventDefault(); const scaleBy = 1.05; const oldScale = stage.scaleX(); const pointer = stage.getPointerPosition(); // 缩放前鼠标在"世界坐标"里对应的点 const mousePointTo = { x: (pointer.x - stage.x()) / oldScale, y: (pointer.y - stage.y()) / oldScale, }; const newScale = e.evt.deltaY > 0 ? oldScale / scaleBy : oldScale * scaleBy; stage.scale({ x: newScale, y: newScale }); // 保持鼠标下的那个点不移动 stage.position({ x: pointer.x - mousePointTo.x * newScale, y: pointer.y - mousePointTo.y * newScale, }); stage.batchDraw(); });关键在这两步——先记录鼠标下的世界坐标,缩放后把 stage.position 反算回来,让那个点仍然停在鼠标下。这个技巧不止 Konva,任何 canvas 缩放都能套。 平移(拖拽画布) 按住空格或者中键拖动画布: stage.draggable(true); // 全局启用拖拽或者只在按住空格时拖: window.addEventListener("keydown", e => { if (e.code === "Space") stage.draggable(true); }); window.addEventListener("keyup", e => { if (e.code === "Space") stage.draggable(false); });缩放限制 别让用户无脑放大 100 倍或缩小成一个点: const newScale = Math.max(0.1, Math.min(oldScale * factor, 20));屏幕坐标 → 原图坐标 标注框保存到后端时,得存原图坐标,不能存屏幕坐标(用户下次打开缩放级别不一样就错了): function stagePointToImage(pos) { return { x: (pos.x - stage.x()) / stage.scaleX(), y: (pos.y - stage.y()) / stage.scaleY(), }; }// 用法 const pos = stage.getPointerPosition(); const realPos = stagePointToImage(pos);反过来"原图坐标 → 屏幕位置"用于展示已有标注: function imageToStagePoint(p) { return { x: p.x * stage.scaleX() + stage.x(), y: p.y * stage.scaleY() + stage.y(), }; }推荐的图层结构 标注类项目一般这样组织: Stage └─ Layer ├─ Image (背景图) ├─ Group (标注) │ ├─ Rect │ ├─ Rect │ └─ Rect └─ Group (UI) (十字线、悬浮提示等)Layer 里可以再分组,方便批量控制显隐(比如"隐藏所有标注只看图"): annotationsGroup.visible(false);一句话总结 Konva 做标注工具:缩放 Stage 而不是 Image 节点、鼠标为中心的缩放靠"世界坐标反算 position"、坐标始终以原图为基准存储。三件事做对,标注永远不错位。

Konva.js 滚轮缩放与平移:以鼠标为中心缩放 Stage 的正确实现

scale() vs width()/height() 缩放单个图片节点有两种方式: // scale():保持节点尺寸不变,视觉上缩放 imageNode.scale({ x: 2, y: 2 });// width()/height():真正修改节点尺寸 imageNode.width(600); imageNode.height(400);两者的区别在于 scale() 不改变节点的逻辑尺寸(width()/height() 返回值不变),而后者实际修改了尺寸。大多数场景用 scale() 更灵活。 标注工具:缩放整个 Stage 如果 Stage 上同时有图片和标注框(Rect/Line),不要缩放单个图片节点,应缩放整个 Stage: stage.scale({ x: newScale, y: newScale }); stage.position(newPos); stage.batchDraw();这样图片、标注框、文字所有节点都会同步缩放,坐标关系保持一致。CVAT、LabelImg 等标注工具都使用这种方式。 滚轮缩放(以鼠标位置为中心) const SCALE_BY = 1.05;stage.on('wheel', (e) => { e.evt.preventDefault(); const oldScale = stage.scaleX(); const pointer = stage.getPointerPosition(); // 鼠标在画布坐标系中的位置(排除当前 scale 和偏移) const mousePointTo = { x: (pointer.x - stage.x()) / oldScale, y: (pointer.y - stage.y()) / oldScale, }; const newScale = e.evt.deltaY > 0 ? oldScale / SCALE_BY // 向下:缩小 : oldScale * SCALE_BY; // 向上:放大 // 限制缩放范围 const clampedScale = Math.max(0.1, Math.min(newScale, 20)); stage.scale({ x: clampedScale, y: clampedScale }); // 调整偏移,使鼠标下方的点保持不动 stage.position({ x: pointer.x - mousePointTo.x * clampedScale, y: pointer.y - mousePointTo.y * clampedScale, }); stage.batchDraw(); });核心思路:缩放前记录鼠标在画布坐标系(未缩放坐标)的位置,缩放后重新计算 Stage 偏移使该点回到鼠标位置,从而实现"以鼠标为中心"的缩放效果。 拖拽平移 stage.draggable(true);启用后可以直接拖动整个 Stage。如果需要区分拖拽图片和拖拽画布,禁用 stage.draggable() 改为手动处理: let isDragging = false; let lastPos = null;stage.on('mousedown', () => { isDragging = true; lastPos = stage.getPointerPosition(); });stage.on('mousemove', () => { if (!isDragging) return; const pos = stage.getPointerPosition(); stage.position({ x: stage.x() + pos.x - lastPos.x, y: stage.y() + pos.y - lastPos.y, }); lastPos = pos; stage.batchDraw(); });stage.on('mouseup', () => { isDragging = false; });坐标系转换:获取原图坐标 点击事件的 stage.getPointerPosition() 返回的是屏幕坐标,需要转换为原图坐标: stage.on('click', () => { const pointer = stage.getPointerPosition(); const scale = stage.scaleX(); const imageX = (pointer.x - stage.x()) / scale; const imageY = (pointer.y - stage.y()) / scale; console.log('原图坐标:', imageX, imageY); });

HTML 自定义鼠标指针:十字准星、全屏辅助线与 CSS cursor 类型

跟随鼠标的十字准星 用 ::before 和 ::after 绘制十字线,mousemove 更新位置: <style> #crosshair { position: fixed; width: 20px; height: 20px; pointer-events: none; /* 不阻挡下层元素的鼠标事件 */ z-index: 99999; }#crosshair::before, #crosshair::after { content: ""; position: absolute; background: red; }/* 垂直线 */ #crosshair::before { left: 50%; top: 0; width: 1px; height: 100%; transform: translateX(-50%); }/* 水平线 */ #crosshair::after { top: 50%; left: 0; width: 100%; height: 1px; transform: translateY(-50%); } </style><div id="crosshair"></div><script> const crosshair = document.getElementById("crosshair");document.addEventListener("mousemove", (e) => { crosshair.style.left = `${e.clientX - 10}px`; crosshair.style.top = `${e.clientY - 10}px`; }); </script>pointer-events: none 让准星元素"透明"——鼠标事件穿透到下层元素。 全屏辅助线(贯穿整个视口) <style> .guide-h, .guide-v { position: fixed; pointer-events: none; z-index: 99999; opacity: 0.4; }.guide-h { width: 100vw; height: 1px; left: 0; background: #00aaff; }.guide-v { height: 100vh; width: 1px; top: 0; background: #00aaff; } </style><div class="guide-h" id="guideH"></div> <div class="guide-v" id="guideV"></div><script> const guideH = document.getElementById("guideH"); const guideV = document.getElementById("guideV");document.addEventListener("mousemove", (e) => { guideH.style.top = `${e.clientY}px`; guideV.style.left = `${e.clientX}px`; }); </script>水平线跟随 clientY,垂直线跟随 clientX,始终贯穿整个视口。 显示/隐藏辅助线(快捷键切换) let visible = true;document.addEventListener("keydown", (e) => { if (e.key === "h") { // 按 H 键切换 visible = !visible; guideH.style.display = visible ? "block" : "none"; guideV.style.display = visible ? "block" : "none"; } });CSS cursor 常用值 /* 标注/选择场景 */ cursor: crosshair; /* 十字准星 */ cursor: cell; /* 单元格十字 *//* 拖拽场景 */ cursor: grab; /* 手型(可拖拽) */ cursor: grabbing; /* 手型(拖拽中)*/ cursor: move; /* 四方向箭头 *//* 调整大小 */ cursor: col-resize; /* 左右调宽 */ cursor: row-resize; /* 上下调高 */ cursor: nwse-resize; /* 左上-右下对角 */ cursor: nesw-resize; /* 右上-左下对角 *//* 缩放 */ cursor: zoom-in; cursor: zoom-out;/* 禁止操作 */ cursor: not-allowed;自定义图片作为鼠标 .custom-cursor { cursor: url("/img/cursor.png") 8 8, crosshair; /* url(...) 8 8 表示热点坐标(点击点相对图片左上角的偏移) */ /* 第二个值是 fallback cursor,浏览器不支持时使用 */ }图片尺寸建议不超过 128x128px,PNG 格式,透明背景。 Canvas 中覆盖系统鼠标 // 隐藏系统鼠标指针 canvas.style.cursor = "none";// 在 Canvas 内自行绘制准星 canvas.addEventListener("mousemove", (e) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; ctx.clearRect(0, 0, canvas.width, canvas.height); // ... 重绘内容 ... // 绘制准星 ctx.strokeStyle = "red"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x - 10, y); ctx.lineTo(x + 10, y); ctx.moveTo(x, y - 10); ctx.lineTo(x, y + 10); ctx.stroke(); });Canvas 场景下用 cursor: none + 手动绘制是最常见的方式,可以精确控制准星样式和动画。

Canvas 拖拽标注框:startBox 模式避免累计误差与 offsetX 自然拖拽感

问题:mousemove 中累加导致误差 // 错误写法:每次 mousemove 都叠加 canvas.addEventListener('mousemove', (e) => { if (!isDragging) return; const dx = e.clientX - lastX; const dy = e.clientY - lastY; bbox.x += dx; // 浮点累加,误差逐帧积累 bbox.y += dy; lastX = e.clientX; lastY = e.clientY; });每帧的 dx 存在浮点舍入,经过几百次 mousemove 后,bbox 位置与鼠标实际位置产生偏差。 解决:startBox 模式 mousedown 时保存初始状态,mousemove 始终基于初始状态计算绝对位置: let dragState = null;canvas.addEventListener('mousedown', (e) => { const mouseX = getCanvasX(e); const mouseY = getCanvasY(e); if (isInsideBbox(mouseX, mouseY, bbox)) { dragState = { startX: mouseX, startY: mouseY, startBox: { ...bbox } // 拷贝初始位置 }; } });canvas.addEventListener('mousemove', (e) => { if (!dragState) return; const mouseX = getCanvasX(e); const mouseY = getCanvasY(e); const dx = mouseX - dragState.startX; const dy = mouseY - dragState.startY; // 每帧都基于 startBox 计算,不累加 bbox.x = dragState.startBox.x + dx; bbox.y = dragState.startBox.y + dy; redraw(); });canvas.addEventListener('mouseup', () => { dragState = null; });bbox.x = startBox.x + dx 而不是 bbox.x += dx,误差不再累积。 offsetX 模式:自然拖拽手感 上面的写法有个体验问题:点击框的右下角时,鼠标移动会让框的左上角跳到鼠标位置。 offsetX 模式记录点击点与框左上角的偏移量,保持鼠标与抓取点的相对位置不变: canvas.addEventListener('mousedown', (e) => { const mouseX = getCanvasX(e); const mouseY = getCanvasY(e); if (isInsideBbox(mouseX, mouseY, bbox)) { dragState = { offsetX: mouseX - bbox.x, // 鼠标在框内的相对位置 offsetY: mouseY - bbox.y }; } });canvas.addEventListener('mousemove', (e) => { if (!dragState) return; const mouseX = getCanvasX(e); const mouseY = getCanvasY(e); bbox.x = mouseX - dragState.offsetX; bbox.y = mouseY - dragState.offsetY; redraw(); });鼠标无论点击框内哪个位置,拖拽过程中该点始终跟随鼠标,手感自然。 三种方案对比方案 误差 拖拽手感 适用场景bbox.x += dx(lastX 累加) 有累计误差 一般 不推荐startBox 模式 无误差 左上角跳到鼠标 精确位移要求高offsetX 模式 无误差 自然,保持抓取点 用户交互体验优先大多数标注工具选择 offsetX 模式,既无误差又有自然手感。 坐标转换 Canvas 内部坐标需要考虑缩放和滚动: function getCanvasX(e) { const rect = canvas.getBoundingClientRect(); return (e.clientX - rect.left) / scale; }function getCanvasY(e) { const rect = canvas.getBoundingClientRect(); return (e.clientY - rect.top) / scale; }scale 是 Canvas 的缩放比例,不除以 scale 会导致缩放后拖拽位置错误。

油猴脚本加 IndexedDB 本地备份:掉线也不丢数据

在油猴脚本里做数据备份,通常只往本地服务器 POST 一份。但服务器挂掉或网络问题时,这批数据就丢了。IndexedDB 可以作为本地兜底——不管服务器是否正常,先把数据存本地一份。 IndexedDB 基础封装 const DB_NAME = "localBackupDB"; const STORE_NAME = "backups"; let db;function initDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1); req.onupgradeneeded = (e) => { const d = e.target.result; if (!d.objectStoreNames.contains(STORE_NAME)) { d.createObjectStore(STORE_NAME, { keyPath: "id" }); } }; req.onsuccess = (e) => { db = e.target.result; resolve(db); }; req.onerror = (e) => reject(e); }); }function saveToIndexedDB(fileName, content) { if (!db) return; const tx = db.transaction(STORE_NAME, "readwrite"); const store = tx.objectStore(STORE_NAME); store.put({ id: fileName, content, timestamp: Date.now() }); }function getAllBackups() { return new Promise((resolve, reject) => { if (!db) return resolve([]); const tx = db.transaction(STORE_NAME, "readonly"); const req = tx.objectStore(STORE_NAME).getAll(); req.onsuccess = () => resolve(req.result); req.onerror = (e) => reject(e); }); }集成到备份队列 已有服务端备份逻辑时,在 enqueueSave 里同时存 IndexedDB: const savedSet = new Set(); const queue = []; let timer = null;function enqueueSave(fileName, content) { if (savedSet.has(fileName)) return; savedSet.add(fileName); // 本地 IndexedDB 兜底(不管服务端是否正常) saveToIndexedDB(fileName, content); queue.push({ fileName, content }); if (!timer) timer = setTimeout(flushQueue, 1000); }function flushQueue() { const batch = [...queue]; queue.length = 0; timer = null; batch.forEach(({ fileName, content }) => saveToServer(fileName, content)); }这样即使 saveToServer 失败,IndexedDB 里还有一份。 可视化面板 挂到 window.showPanel,在控制台调用即可弹出历史备份列表: function showPanel() { if (document.getElementById("__backup_panel")) return; const panel = document.createElement("div"); panel.id = "__backup_panel"; Object.assign(panel.style, { position: "fixed", top: "20px", right: "20px", width: "320px", maxHeight: "420px", overflowY: "auto", background: "#fff", border: "1px solid #ccc", boxShadow: "0 0 10px rgba(0,0,0,.3)", padding: "12px", zIndex: 99999, fontSize: "12px", fontFamily: "monospace" }); panel.innerHTML = `<div style="font-weight:bold;margin-bottom:8px">📦 历史备份</div> <div id="__backup_list"></div> <button id="__backup_close" style="margin-top:10px">关闭</button>`; document.body.appendChild(panel); document.getElementById("__backup_close").onclick = () => panel.remove(); getAllBackups().then(backups => { const list = document.getElementById("__backup_list"); backups.sort((a, b) => b.timestamp - a.timestamp); backups.forEach(item => { const row = document.createElement("div"); row.style.cssText = "display:flex;justify-content:space-between;margin-bottom:4px"; const name = document.createElement("span"); name.textContent = item.id; name.style.cssText = "flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"; const btn = document.createElement("button"); btn.textContent = "恢复"; btn.onclick = () => { try { console.log("🔄 恢复备份:", item.id); console.log(JSON.parse(item.content)); } catch { console.log(item.content); } }; row.appendChild(name); row.appendChild(btn); list.appendChild(row); }); }); }window.showPanel = showPanel;使用时在浏览器控制台输入 showPanel() 即可查看。 初始化调用 initDB() .then(() => console.log("IndexedDB 已就绪")) .catch(console.error);注意事项IndexedDB 存储受同源策略限制,每个域名独立,跨域无法访问 浏览器私有模式下 IndexedDB 数据在关闭标签页后会清除 存储上限一般是磁盘空间的 50% 左右(浏览器决定),备份大量 JSON 不用担心空间 用 store.put()(不是 add()),同 key 会覆盖而不报错 可以在 DevTools → Application → IndexedDB 直接查看和清理数据

油猴脚本 XHR Hook 数据落地:IndexedDB 本地备份 + 本地服务双写

场景 油猴脚本 Hook XHR/fetch 后捕获接口数据,需要持久化保存。常见方案是 POST 到本机 http://127.0.0.1:PORT,但本地服务可能未启动或崩溃,导致丢失数据。 双写方案:先写 IndexedDB(浏览器本地,无外部依赖),再异步 POST 到本地服务,两者互为备份。 IndexedDB 封装 const DB_NAME = 'XhrBackup'; const STORE_NAME = 'records';let db = null;function initDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1); req.onupgradeneeded = (e) => { const store = e.target.result.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }); store.createIndex('fileName', 'fileName', { unique: false }); }; req.onsuccess = (e) => { db = e.target.result; resolve(db); }; req.onerror = (e) => reject(e.target.error); }); }function saveToIDB(fileName, content) { return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readwrite'); const store = tx.objectStore(STORE_NAME); store.add({ fileName, content, savedAt: Date.now() }); tx.oncomplete = resolve; tx.onerror = (e) => reject(e.target.error); }); }队列去重与批量 flush 频繁触发(如分页列表接口每页都调用)时需要去重,避免重复保存同一数据: const savedSet = new Set(); const queue = []; let flushTimer = null;function enqueueSave(fileName, content) { if (savedSet.has(fileName)) return; // 去重 savedSet.add(fileName); queue.push({ fileName, content }); // 1 秒防抖,批量 flush if (!flushTimer) { flushTimer = setTimeout(flushQueue, 1000); } }function flushQueue() { const batch = [...queue]; queue.length = 0; flushTimer = null; batch.forEach(item => saveItem(item.fileName, item.content)); }双写实现 async function saveItem(fileName, content) { // 1. 先写 IndexedDB(不依赖本地服务) try { await saveToIDB(fileName, content); console.log('✅ IDB 保存成功:', fileName); } catch (e) { console.error('❌ IDB 保存失败:', fileName, e); } // 2. 再 POST 到本地服务(可选) const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); fetch('http://127.0.0.1:8099/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName, content }), signal: controller.signal }) .then(res => { clearTimeout(timeout); if (!res.ok) throw new Error(`HTTP ${res.status}`); console.log('✅ 服务保存成功:', fileName); }) .catch(err => { clearTimeout(timeout); if (err.name === 'AbortError') { console.warn('⚠️ 服务超时,已保留 IDB 备份:', fileName); } else { console.error('❌ 服务保存失败(IDB 已备份):', fileName, err.message); } }); }XHR Hook 集成 const originOpen = XMLHttpRequest.prototype.open; const originSend = XMLHttpRequest.prototype.send;XMLHttpRequest.prototype.open = function (method, url, ...rest) { this._method = method; this._url = url; return originOpen.apply(this, [method, url, ...rest]); };XMLHttpRequest.prototype.send = function (body) { this.addEventListener('readystatechange', function () { if (this.readyState !== 4) return; // 匹配目标接口 URL,按业务修改正则 if (/\/api\/data\/\d+/.test(this._url)) { const url = new URL(this._url, location.origin); const id = url.searchParams.get('id') || Date.now(); enqueueSave(`data/${id}_response.json`, this.responseText); } }); return originSend.apply(this, arguments); };读取 IndexedDB 中已保存的数据 function getAllRecords() { return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readonly'); const store = tx.objectStore(STORE_NAME); const req = store.getAll(); req.onsuccess = () => resolve(req.result); req.onerror = (e) => reject(e.target.error); }); }// 控制台查看所有备份 getAllRecords().then(records => { console.log(`共 ${records.length} 条备份`); console.table(records.map(r => ({ id: r.id, fileName: r.fileName, savedAt: new Date(r.savedAt).toLocaleString() }))); });初始化入口 // 油猴脚本主入口 (async function () { await initDB(); // 在这里挂 XHR Hook console.log('备份系统就绪'); })();IndexedDB 在同源页面(同协议+域名+端口)下持久存储,浏览器关闭后数据不丢失,只有手动清除浏览器数据时才会清空。

浏览器 XHR Hook + IndexedDB:油猴脚本实现网络请求本地备份

在油猴脚本或浏览器控制台中,可以通过覆写 XMLHttpRequest.prototype 方法拦截页面发出的所有 XHR 请求,配合本地服务实现数据自动备份。 XHR Hook 核心实现 const originOpen = XMLHttpRequest.prototype.open; const originSend = XMLHttpRequest.prototype.send;XMLHttpRequest.prototype.open = function (method, url, ...rest) { this._method = method; this._url = url; return originOpen.apply(this, [method, url, ...rest]); };XMLHttpRequest.prototype.send = function (body) { this.addEventListener('readystatechange', function () { if (this.readyState === 4) { // 匹配目标接口 if (/\/api\/data/.test(this._url)) { enqueueSave(`data_${Date.now()}.json`, this.responseText); } } }); return originSend.apply(this, arguments); };readyState === 4 表示请求完成,this.responseText 是响应体。 Set 去重 + 队列节流 高频请求下需要防止重复保存和并发过多: const savedSet = new Set(); const queue = []; let timer = null;function enqueueSave(fileName, content) { if (savedSet.has(fileName)) return; // 去重 savedSet.add(fileName); queue.push({ fileName, content }); // 1秒后批量发送 if (!timer) { timer = setTimeout(flushQueue, 1000); } }function flushQueue() { const batch = [...queue]; queue.length = 0; timer = null; batch.forEach(item => saveToServer(item.fileName, item.content)); }保存到本地 HTTP 服务(带超时) function saveToServer(fileName, content) { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), 5000); return fetch('http://127.0.0.1:8099/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName, content }), signal: controller.signal }) .then(res => { clearTimeout(id); return res; }) .catch(err => console.error('保存失败:', fileName, err)); }IndexedDB 兜底备份 当本地服务不可用时,IndexedDB 可以作为浏览器内持久化存储的备份: const DB_NAME = 'localBackupDB'; const STORE_NAME = 'backups'; let db;function initDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1); req.onupgradeneeded = (e) => { const d = e.target.result; if (!d.objectStoreNames.contains(STORE_NAME)) { d.createObjectStore(STORE_NAME, { keyPath: 'id' }); } }; req.onsuccess = (e) => { db = e.target.result; resolve(db); }; req.onerror = (e) => reject(e); }); }function saveToIndexedDB(fileName, content) { if (!db) return; const tx = db.transaction(STORE_NAME, 'readwrite'); tx.objectStore(STORE_NAME).put({ id: fileName, content, timestamp: Date.now() }); }在 enqueueSave 中同时写入: function enqueueSave(fileName, content) { if (savedSet.has(fileName)) return; savedSet.add(fileName); saveToIndexedDB(fileName, content); // 本地先存一份 queue.push({ fileName, content }); if (!timer) { timer = setTimeout(flushQueue, 1000); } }读取 IndexedDB 备份 function getAllBackups() { return new Promise((resolve) => { const tx = db.transaction(STORE_NAME, 'readonly'); const req = tx.objectStore(STORE_NAME).getAll(); req.onsuccess = () => resolve(req.result); }); }getAllBackups().then(records => { records.forEach(r => console.log(r.id, r.timestamp)); });IndexedDB 在同源页面刷新后仍然保留数据,是浏览器端容量最大的持久化存储方案(通常限额数百 MB 到 GB 级别)。

Playwright 使用本地已安装的 Chrome:channel、executablePath 与 CDP 连接

Playwright 默认使用自带的 Chromium 浏览器。以下四种方式可以改用本地安装的 Google Chrome。 方法一:channel='chrome'(最简单) 需要本机已安装 Google Chrome。 Python: from playwright.sync_api import sync_playwrightwith sync_playwright() as p: browser = p.chromium.launch(channel="chrome", headless=False) page = browser.new_page() page.goto("https://example.com") browser.close()JavaScript: const { chromium } = require('playwright');const browser = await chromium.launch({ channel: 'chrome', headless: false }); const page = await browser.newPage(); await page.goto('https://example.com'); await browser.close();方法二:指定 executablePath Python(各平台路径): # Windows executable_path = r"C:\Program Files\Google\Chrome\Application\chrome.exe"# macOS executable_path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"# Linux executable_path = "/usr/bin/google-chrome"browser = p.chromium.launch(executable_path=executable_path, headless=False)方法三:launchPersistentContext(复用登录状态) 使用已登录账号、Cookie、插件的 Chrome 用户数据目录: Python: from playwright.sync_api import sync_playwrightwith sync_playwright() as p: context = p.chromium.launch_persistent_context( r"C:\Users\用户名\AppData\Local\Google\Chrome\User Data", channel="chrome", headless=False, ) page = context.pages[0] or context.new_page() page.goto("https://example.com")注意:Chrome 运行时会独占用户目录,使用此方法前需要关闭所有 Chrome 窗口,否则报错: Profile is already in use方法四:connectOverCDP(连接已启动的 Chrome) 先启动 Chrome 并开启远程调试端口(不影响正常使用): # Windows "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222# macOS /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222然后 Playwright 连接: Python: browser = p.chromium.connect_over_cdp("http://127.0.0.1:9222") page = browser.contexts[0].pages[0] page.goto("https://example.com")JavaScript: const browser = await chromium.connectOverCDP('http://127.0.0.1:9222'); const page = browser.contexts()[0].pages()[0];这种方式完全复用当前 Chrome 的登录状态、Cookie 和扩展,且不独占用户目录。适合需要手动操作与自动化混合使用的场景。 方案对比方法 复用登录 Chrome 需关闭 保留插件channel='chrome' ✗ 否 ✗executablePath ✗ 否 ✗launchPersistentContext ✓ 是 ✓connectOverCDP ✓ 否 ✓

JS 逻辑赋值运算符:||= / &&= / ??= 用法和区别

ES2021 引入了三个逻辑赋值运算符:||=、&&=、??=。这三个符号都是"条件赋值"的简写,但触发条件各不相同,混淆了容易出 bug。 ||=(逻辑或赋值) 只有左侧为 falsy 时才赋值: a ||= b // 等价于 a = a || b // 即:if (!a) a = blet x = null; x ||= "default"; console.log(x); // "default"let y = 0; y ||= 100; console.log(y); // 100 ← 0 是 falsy,会被覆盖会触发赋值的值:null、undefined、false、0、''、NaN 典型用途:缓存计算结果 function checkIsRowVisible(data) { viewportRect ||= scrollContainer.getBoundingClientRect(); // 第一次为 undefined,执行右侧并缓存 // 之后 viewportRect 有值,跳过 const rowEl = data._rowEl.getBoundingClientRect(); return rowEl.bottom >= viewportRect.top && rowEl.top <= viewportRect.bottom; }??=(空值合并赋值) 只有左侧为 null 或 undefined 时才赋值: a ??= b // 等价于 a = a ?? b // 即:if (a === null || a === undefined) a = blet x = null; x ??= "default"; console.log(x); // "default"let y = 0; y ??= 100; console.log(y); // 0 ← 0 不是 null/undefined,不会被覆盖??= 比 ||= 更"精准":0、false、'' 都不会触发赋值。 典型用途:对象属性的惰性初始化 user.preferences ??= {}; user.preferences.theme ??= "dark";&&=(逻辑与赋值) 只有左侧为 truthy 时才赋值: a &&= b // 等价于 a = a && b // 即:if (a) a = blet arr = [1, 2, 3]; arr &&= arr.map(x => x * 2); console.log(arr); // [2, 4, 6]let empty = null; empty &&= [1, 2, 3]; console.log(empty); // null ← null 是 falsy,不赋值典型用途:只在值存在时更新 element &&= element.querySelector(".item"); // element 为 null 时不执行,避免报错三者对比运算符 触发赋值条件 不触发的值||= 左侧为 falsy truthy 值??= 左侧为 null/undefined 0, false, '', NaN 等&&= 左侧为 truthy falsy 值let a = 0; a ||= 1; // a = 1 (0 是 falsy) a = 0; a ??= 1; // a = 0 (0 不是 null/undefined) a = 0; a &&= 1; // a = 0 (0 是 falsy,不赋值)let b = 5; b ||= 1; // b = 5 (5 是 truthy,不赋值) b ??= 1; // b = 5 (5 不是 null/undefined,不赋值) b &&= 10; // b = 10 (5 是 truthy,赋值)选哪个想要"没有值就用默认值",且 0 / false / '' 也算有效值 → 用 ??= 想要"空/假就用默认值" → 用 ||= 想要"有值才更新" → 用 &&=对于对象初始化类的场景,??= 更安全,不会意外覆盖 false 或 0 这类合法值。

JavaScript 逻辑赋值运算符:||=、&&=、??= 和可选链动态属性访问

逻辑或赋值 ||= ES2021 引入的 ||= 只在左侧是 falsy 时才赋值: viewportRect ||= scrollContainer.getBoundingClientRect();等价于: if (!viewportRect) { viewportRect = scrollContainer.getBoundingClientRect(); }常用于"懒初始化缓存":第一次访问时计算并保存,后续复用已有值,避免重复执行开销大的操作。 触发赋值的 falsy 值:null、undefined、false、0、''、NaN。 空值合并赋值 ??= ??= 只在左侧是 null 或 undefined 时才赋值: config.timeout ??= 5000;等价于: if (config.timeout === null || config.timeout === undefined) { config.timeout = 5000; }||= 与 ??= 的区别 let x = 0; x ||= 10; console.log(x); // 10 ← 0 是 falsy,触发了赋值let y = 0; y ??= 10; console.log(y); // 0 ← 0 不是 nullish,不触发赋值场景 推荐变量为空对象/数组/字符串时初始化 ??=变量为任何假值时初始化 `变量已赋值则不覆盖 ??=(更安全)逻辑与赋值 &&= &&= 只在左侧是 truthy 时才赋值: user &&= { ...user, lastLogin: Date.now() }; // 仅当 user 存在时才更新可选链结合动态属性访问 ?.[] 是可选链加计算属性的组合: const currentValue = projection.user_property?.[attrDef.value];等价于: const currentValue = projection.user_property == null ? undefined : projection.user_property[attrDef.value];attrDef.value 是运行时计算的属性名,不能用 ?. 点语法(点语法只能接静态标识符),必须用方括号形式 ?.[]。 // 深层链式安全访问 const val = obj?.a?.b?.[dynamicKey]?.value; // 任何一级为 null/undefined 都返回 undefined,不会抛错

Vue 3 前端导出 Excel:xlsx + file-saver 封装 useExportExcel

安装 npm install xlsx file-saver基础用法 import * as XLSX from 'xlsx' import { saveAs } from 'file-saver'const data = [ { name: '张三', age: 18, city: '北京' }, { name: '李四', age: 20, city: '上海' } ]const ws = XLSX.utils.json_to_sheet(data) const wb = XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, 'Sheet1')const buf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }) const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })saveAs(blob, '数据.xlsx')封装 useExportExcel // useExportExcel.js import * as XLSX from 'xlsx' import { saveAs } from 'file-saver' import { ref } from 'vue'export function useExportExcel() { const exporting = ref(false) const exportExcel = (data = [], fileName = '数据表', headerMap = {}) => { if (!data.length) return exporting.value = true try { // 按 headerMap 重命名列 const exportData = data.map(row => { const newRow = {} Object.keys(row).forEach(key => { newRow[headerMap[key] || key] = row[key] }) return newRow }) const ws = XLSX.utils.json_to_sheet(exportData) const wb = XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, 'Sheet1') const buf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }) const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) saveAs(blob, `${fileName}.xlsx`) } finally { exporting.value = false } } const exportSelected = (allData, selectedRows, fileName, headerMap = {}) => { exportExcel(selectedRows?.length ? selectedRows : allData, fileName, headerMap) } return { exporting, exportExcel, exportSelected } }配合 Element Plus el-table 使用 <template> <el-button :loading="exporting" @click="onExportAll">导出全部</el-button> <el-button :loading="exporting" @click="onExportSelected">导出选中</el-button> <el-table :data="tableData" @selection-change="selectedRows = $event"> <el-table-column type="selection" /> <el-table-column prop="name" label="姓名" /> <el-table-column prop="age" label="年龄" /> <el-table-column prop="city" label="城市" /> </el-table> </template><script setup> import { ref } from 'vue' import { useExportExcel } from './useExportExcel'const tableData = ref([ { name: '张三', age: 18, city: '北京' }, { name: '李四', age: 20, city: '上海' } ])const selectedRows = ref([])const { exporting, exportExcel, exportSelected } = useExportExcel()const headerMap = { name: '姓名', age: '年龄', city: '城市' }const onExportAll = () => exportExcel(tableData.value, '用户列表', headerMap) const onExportSelected = () => exportSelected(tableData.value, selectedRows.value, '选中用户', headerMap) </script>导出 DOM table 如果页面已有 <table> 元素,不需要操作数据,直接从 DOM 生成: const table = document.getElementById('myTable') const wb = XLSX.utils.table_to_book(table) XLSX.writeFile(wb, 'table.xlsx')大数据后端流式下载 超过 5 万行时前端导出会明显卡顿(浏览器内存限制),推荐后端生成流式 Excel: const exportExcelFromServer = async (params) => { const res = await axios({ url: '/api/export', method: 'get', params, responseType: 'blob' }) const blob = new Blob([res.data]) const link = document.createElement('a') link.href = URL.createObjectURL(blob) link.download = '数据.xlsx' link.click() URL.revokeObjectURL(link.href) }后端(Laravel + Maatwebsite\Excel): return Excel::download(new UserExport(), 'users.xlsx');

油猴脚本获取 __webpack_require__:webpackChunk.push 注入比 Hook call 更稳

为什么 Hook Function.prototype.call 不好用 一种常见思路是拦截 webpack 模块执行时的 call: const oldCall = Function.prototype.call;Function.prototype.call = function (...args) { if (this.name == '84686') { webpackRequire = args[3]; } return oldCall.apply(this, args); };这个方法有两个核心问题:webpack 模块函数通常没有名字,this.name 是 '' 或 anonymous,而不是模块 ID,所以 this.name == '84686' 几乎永远不成立。性能灾难:现代前端框架(React、Vue、Webpack Runtime)每秒调用 call 数以万次,全部经过你的 hook,页面直接卡死。另外,如果 webpack 已经执行完才开始 hook,Function.prototype.call 根本拿不到任何东西——模块早就加载完了。 正确方案:向 webpackChunk 注入入口模块 webpack 5 会在 window 上挂一个全局数组,名字通常是 webpackChunk 加项目名前缀。向这个数组 push 一个特殊的 chunk,可以在 runtime 函数里拿到 __webpack_require__: // 找到 webpackChunk 数组 const chunkKey = Object.keys(window).find(k => k.startsWith('webpackChunk'));if (chunkKey) { let webpackRequire; window[chunkKey].push([ [Symbol()], // chunk ID(用 Symbol 避免冲突) {}, // 模块定义(空) function (require) { webpackRequire = require; // 在这里拿到 __webpack_require__ } ]); console.log(webpackRequire); // 现在可以用 webpackRequire(模块ID) 访问任意模块 }这个方法有效的原因:即使 webpack 已经执行完,也可以通过这种方式从现有的 chunk 数组里拿到 __webpack_require__,因为 webpack 会立即处理新 push 进来的 chunk。 在 webpack 执行前 Hook(如果需要监听所有模块) 如果你的目标是在每个模块加载时做点什么(比如修改特定模块的导出),需要在 webpack 执行之前 hook push 方法: const chunkKey = Object.keys(window).find(k => k.startsWith('webpackChunk'));const oldPush = window[chunkKey].push;window[chunkKey].push = function (...args) { const runtime = args[0][2]; // 拦截 runtime 函数 if (typeof runtime === 'function') { args[0][2] = function (__webpack_require__) { // 保存引用 window._webpackRequire = __webpack_require__; return runtime.apply(this, arguments); }; } return oldPush.apply(this, args); };用 webpack_require 访问模块 拿到 __webpack_require__ 后,可以直接访问任意 webpack 模块: // 按模块 ID 获取模块导出 const lodash = webpackRequire(96486); const mtopModule = webpackRequire(82122);console.log(lodash); console.log(mtopModule.default);模块 ID 可以在浏览器 Sources 面板里搜索特征字符串定位,也可以遍历 webpackRequire.m(模块注册表)查找: Object.keys(webpackRequire.m).forEach(id => { const src = webpackRequire.m[id].toString(); if (src.includes('targetFunction')) { console.log('found at module id:', id); } });油猴脚本注意:@run-at 时机 在 Tampermonkey 里使用时,需要根据目的选择合适的 @run-at:时机 说明document-start 最早,适合在 webpack 执行前 hook pushdocument-end 适合页面加载完后注入 chunk 拿引用如果 webpack 在 DOMContentLoaded 之前执行完,用 document-start + push hook;如果只是要访问模块,用 document-end + push 注入即可。

分页 API 全量遍历:Python 与 JavaScript 实现模式

分页接口通常返回 totalCount、pageSize、当页数据。需要全量数据时,用 while 循环按页请求即可。 Python 实现 按 totalCount 推进(稳定写法) import mathpage_size = 20 current = 1 all_items = []while True: params = { "status": "ON_SALE", "current": current, "pageSize": page_size } response = fetchProductList(params) data = response.get("model", []) all_items.extend(data) total_count = response.get("totalCount", 0) total_pages = math.ceil(total_count / page_size) print(f"已获取第 {current}/{total_pages} 页") if current >= total_pages: break current += 1print(f"总共获取 {len(all_items)} 条")按当页数量判断末页(防 totalCount 不准) 如果接口的 totalCount 有时不准确,用当页返回数量是否小于 pageSize 来判断末页更稳: page_size = 20 current = 1 all_items = []while True: params = {"status": "ON_SALE", "current": current, "pageSize": page_size} response = fetchProductList(params) items = response.get("model", []) if not items: break all_items.extend(items) print(f"获取第 {current} 页,{len(items)} 条") if len(items) < page_size: break current += 1生成器模式(内存友好) 适合商品数量很大、需要边翻页边处理的场景: def iter_products(): current = 1 page_size = 20 while True: resp = fetchProductList({ "status": "ON_SALE", "current": current, "pageSize": page_size }) items = resp.get("model", []) if not items: break yield from items if len(items) < page_size: break current += 1for item in iter_products(): print(item["title"])JavaScript 实现 async/await 版本 async function getAllProducts() { const pageSize = 20; let current = 1; const allItems = []; while (true) { const response = await fetchProductList({ status: "ON_SALE", current, pageSize }); const items = response?.model || []; if (items.length === 0) { break; } allItems.push(...items); console.log(`获取第 ${current} 页,${items.length} 条`); if (items.length < pageSize) { break; } current++; } console.log(`总共获取 ${allItems.length} 条`); return allItems; }const products = await getAllProducts();按 totalCount 计算总页数 async function getAllProducts() { const pageSize = 20; let current = 1; let totalPages = 1; const allItems = []; while (current <= totalPages) { const response = await fetchProductList({ status: "ON_SALE", current, pageSize }); const items = response?.model || []; allItems.push(...items); totalPages = Math.ceil((response.totalCount || 0) / pageSize); console.log(`获取第 ${current}/${totalPages} 页`); current++; } return allItems; }流式处理(不存全量数据) 适合直接对每条数据执行操作(改价格、同步库存等),避免一次性把几千条数据加载到内存: let current = 1; const pageSize = 20;while (true) { const response = await fetchProductList({ status: "ON_SALE", current, pageSize }); const items = response?.model || []; if (!items.length) { break; } for (const item of items) { // 在这里处理每条商品 await updateStock(item.id, item.quantity); } if (items.length < pageSize) { break; } current++; }两种终止条件对比策略 适用场景 风险current >= totalPages totalCount 准确 totalCount 不准时多/少请求items.length < pageSize 更通用 最后一页恰好整除时多一次空请求两种可以结合使用:先按 totalPages 推进,同时检查当页数量作为保险。

JavaScript 动态执行代码:new Function、eval 与 Node.js vm 沙箱

三种方式对比方式 能访问外部作用域 安全性 推荐场景eval() 是 低 几乎不推荐new Function() 否 中 浏览器/Node 通用vm.runInContext() 否(隔离) 高 Node.js 执行用户代码new Function(推荐) // 无参数 const fn = new Function("return 1 + 2"); console.log(fn()); // 3// 带参数 const add = new Function("a", "b", "return a + b"); console.log(add(1, 2)); // 3// 多行代码 const code = ` const x = 10; return x * 2; `; console.log(new Function(code)()); // 20new Function 创建的函数只能访问全局作用域,不能访问创建它时的局部变量——这是它比 eval 更安全的原因。 eval(不推荐) const result = eval("1 + 2"); // 3eval 可以访问当前闭包内的变量,是潜在的代码注入入口,大多数代码规范禁止使用。 注入上下文:with(data) 在表单引擎、规则配置等场景中,需要让动态代码访问对象属性: function runExpression(expr, data) { return new Function("data", `with(data){ return ${expr} }`)(data); }runExpression("price * count", { price: 10, count: 2 }); // 20 runExpression("amount > 100", { amount: 200 }); // truewith(data) 把 data 的属性提升为词法作用域,表达式中可以直接使用属性名。 执行用户脚本(带函数体) 从数据库加载 JS 函数字符串并执行: const scriptStr = ` function check(order) { return order.amount > 100; } `;// 提取函数体,传入参数执行 const fn = new Function("order", ` ${scriptStr} return check(order); `);console.log(fn({ amount: 200 })); // true异步版本:AsyncFunction // 获取异步函数构造器 const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;const code = ` const res = await Promise.resolve(123); return res; `;const fn = new AsyncFunction(code); fn().then(console.log); // 123Node.js vm 沙箱(服务端执行用户代码) vm 模块提供真正的上下文隔离,适合服务端运行不受信任的代码: const vm = require("vm");// 创建隔离上下文 const context = { a: 1, b: 2, result: 0 }; vm.createContext(context);vm.runInContext("result = a + b", context); console.log(context.result); // 3设置超时,防止死循环: try { vm.runInContext( "while(true){}", context, { timeout: 1000 } // 1秒超时 ); } catch (e) { console.log("超时:", e.message); }完整执行流程: function safeExec(code, data, timeoutMs = 500) { const ctx = { ...data, __result: undefined }; vm.createContext(ctx); vm.runInContext( `__result = (function(){ ${code} })()`, ctx, { timeout: timeoutMs } ); return ctx.__result; }safeExec("return a * b", { a: 3, b: 4 }); // 12vm 模块的局限 vm 不是完全安全的沙箱,能被构造特定代码逃逸(prototype chain escape)。对安全要求更高的场景,使用 vm2 库(已停止维护)或 isolated-vm(推荐): import ivm from "isolated-vm";const isolate = new ivm.Isolate({ memoryLimit: 64 }); const ctx = await isolate.createContext(); const result = await ctx.eval("1 + 2");

JS 动态执行字符串代码:new Function、eval 与 Node.js vm 沙箱

new Function(推荐) // 无参数 const code = "return 1 + 2"; const fn = new Function(code); console.log(fn()); // 3// 带参数 const fn2 = new Function("a", "b", "return a + b"); console.log(fn2(1, 2)); // 3// 多行代码 const fn3 = new Function(` const x = 10; return x * 2; `); console.log(fn3()); // 20和 eval 的主要区别:有独立函数作用域,不会污染外部变量 可以明确传入参数,接口更清晰 不能访问外部局部变量(只能访问全局)eval(不推荐) const result = eval("1 + 2"); console.log(result); // 3问题:和外部作用域共享变量,容易出现意外副作用 引擎无法优化,性能差 各种代码规范和 CSP 都禁止使用异步代码执行 const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;const code = ` const res = await Promise.resolve(123); return res; `;const fn = new AsyncFunction(code); fn().then(console.log); // 123Node.js vm 模块(沙箱执行) 适合在 Node 服务端执行用户提交的代码,比 eval 更安全: const vm = require("vm");const context = { a: 1, b: 2 }; vm.createContext(context);const result = vm.runInContext("a + b", context); console.log(result); // 3设置超时防止死循环: try { vm.runInContext("while(true){}", context, { timeout: 1000 }); } catch (e) { console.error("超时:", e.message); // Script execution timed out }执行来自数据库的函数字符串: const str = ` function check(order) { return order.amount > 100; } `;const check = new Function(`${str}; return check;`)(); console.log(check({ amount: 200 })); // true实战:规则引擎表达式 带上下文变量执行表达式: function runExpression(expr, data) { return new Function( "data", `with(data){ return ${expr} }` )(data); }runExpression("price * count", { price: 10, count: 2 }); // 20微信小程序:eval 和 Function 均被禁用 小程序运行环境为了安全禁止动态代码生成: eval("1+1"); // eval is not a function new Function("return 1"); // Function constructor is disabled替代方案一:JSON 规则引擎 { "op": "and", "rules": [ { "field": "amount", "operator": ">", "value": 100 }, { "field": "vip", "operator": "=", "value": true } ] }function runRule(rule, data) { return rule.rules.every(item => { switch (item.operator) { case ">": return data[item.field] > item.value; case "=": return data[item.field] === item.value; } }); }替代方案二:globalThis 注册函数表 // 初始化时注册所有可调用函数 globalThis.actions = { checkVip(data) { return data.vip; }, checkAmount(data) { return data.amount > 100; } };// 运行时按名称调用 function dispatch(funcName, data) { return globalThis.actions[funcName]?.(data); }dispatch("checkAmount", { amount: 200 }); // trueglobalThis 在小程序中可用(等价于浏览器的 window),但 window、document 在小程序里不存在。这种函数表模式是小程序低代码/规则引擎的常见做法。

Solidity 合约安全优化:SimpleDeposit 代码审查

一份简单的 EVM 充值合约,在上主网前需要做几项安全优化。 原始合约 // SPDX-License-Identifier: MIT pragma solidity ^0.8.28;contract SimpleDeposit { address public owner; bool public paused; event Deposit( address indexed user, uint256 amount, uint256 indexed orderId, uint256 timestamp ); modifier onlyOwner() { require(msg.sender == owner, "Not owner"); _; } constructor() { owner = msg.sender; } function deposit(uint256 orderId) public payable { require(!paused, "Paused"); require(msg.value > 0, "Amount zero"); emit Deposit(msg.sender, msg.value, orderId, block.timestamp); } function withdraw(uint256 amount) external onlyOwner { (bool success, ) = payable(owner).call{value: amount}(""); require(success, "Transfer failed."); } function setPaused(bool _paused) external onlyOwner { paused = _paused; } receive() external payable { emit Deposit(msg.sender, msg.value, 0, block.timestamp); } }优化 1:withdraw 前检查余额 余额不足时 call 会失败,但报错信息不明确: function withdraw(uint256 amount) external onlyOwner { require(address(this).balance >= amount, "Insufficient balance"); (bool success, ) = payable(owner).call{value: amount}(""); require(success, "Transfer failed"); }优化 2:补充 Withdraw 事件 充值有事件,提现没有事件,后端无法追踪提现记录: event Withdraw( address indexed to, uint256 amount, uint256 timestamp );function withdraw(uint256 amount) external onlyOwner { require(address(this).balance >= amount, "Insufficient balance"); (bool success, ) = payable(owner).call{value: amount}(""); require(success, "Transfer failed"); emit Withdraw(owner, amount, block.timestamp); }优化 3:自定义 error 替代 require 字符串 require("string") 会把字符串编码进 calldata,消耗更多 gas。用 custom error 替代: error NotOwner(); error ContractPaused(); error ZeroAmount(); error InsufficientBalance(); error TransferFailed();modifier onlyOwner() { if (msg.sender != owner) revert NotOwner(); _; }function deposit(uint256 orderId) public payable { if (paused) revert ContractPaused(); if (msg.value == 0) revert ZeroAmount(); emit Deposit(msg.sender, msg.value, orderId, block.timestamp); }自定义 error 平均节省 30~50% gas。 优化 4:owner 转移功能 现在 owner 一旦设置就无法更改,新增两步转移以防误操作: address public pendingOwner;event OwnershipTransferred(address indexed oldOwner, address indexed newOwner);function transferOwnership(address newOwner) external onlyOwner { require(newOwner != address(0), "Zero address"); pendingOwner = newOwner; }function acceptOwnership() external { require(msg.sender == pendingOwner, "Not pending owner"); emit OwnershipTransferred(owner, pendingOwner); owner = pendingOwner; pendingOwner = address(0); }优化 5:ReentrancyGuard 防重入 withdraw 使用 call,存在重入风险(尽管对当前合约逻辑影响有限): bool private _locked;modifier nonReentrant() { require(!_locked, "Reentrant call"); _locked = true; _; _locked = false; }function withdraw(uint256 amount) external onlyOwner nonReentrant { require(address(this).balance >= amount, "Insufficient balance"); (bool success, ) = payable(owner).call{value: amount}(""); require(success, "Transfer failed"); emit Withdraw(owner, amount, block.timestamp); }或者直接引入 OpenZeppelin: import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";contract SimpleDeposit is ReentrancyGuard { // ... function withdraw(uint256 amount) external onlyOwner nonReentrant { ... } }可升级代理(按需) 如果合约逻辑以后可能更新,考虑 UUPS 代理模式。但对于简单充值合约,升级能力也意味着合约不再是纯粹的"不可更改",用户信任模型会变化,需要权衡。 最终版本概览优化点 作用withdraw 余额检查 明确报错,节省 gasWithdraw 事件 链上可追踪提现记录Custom error 减少 gas 30~50%两步 owner 转移 防止误转到无效地址ReentrancyGuard 防重入攻击

为什么 Hook eval 会让 webpack 报 __webpack_require__ is not defined

问题复现 油猴脚本 hook eval 后报错: ReferenceError: __webpack_require__ is not defined at eval (service-mark.js:1:1) at eval (<anonymous>) at unsafeWindow.eval (my-script.user.js:28:24) at ./javascript/common/service-mark.js (task-common.js?61:240:1)hook 写法: const oldEval = window.eval;window.eval = function (code) { console.log(code); return oldEval.apply(this, args); // ← 问题在这里 };根本原因:直接调用 vs 间接调用 JS 中只有一种 direct eval(直接调用): eval(code)字面上写 eval(...) 才是直接调用,执行时能访问当前词法作用域。 其他所有形式都是 indirect eval(间接调用),在全局作用域执行: const e = eval; e(code); // indirecteval.call(null, code); // indirect eval.apply(this, [code]); // indirect (0, eval)(code); // indirect(常见技巧)webpack 模块代码是在模块工厂函数的作用域里 eval 执行的: function(module, exports, __webpack_require__) { eval("代码内容..."); // direct eval,能访问 __webpack_require__ }当你 hook 了 window.eval 并用 oldEval.apply(...) 转发时: window.eval = function(code) { return oldEval.apply(this, args); // indirect eval // ↑ 变成了间接调用,在全局作用域执行 };__webpack_require__ 是工厂函数的局部变量,全局作用域里不存在,所以报错。 为什么 return eval(code) 也不行 即使改成字面调用: unsafeWindow.eval = function(code) { return eval(code); // direct eval };这里的 eval(code) 是在你的 hook 函数作用域里执行的,不是在 webpack 工厂函数里。作用域仍然不对。 结论:无法通过 hook eval 的方式同时拦截代码和保持 webpack 的模块作用域。 这是 JS 引擎层面的限制,不是 hook 写法问题。 正确方案:hook webpackChunk.push 如果你需要修改 webpack 模块的源码(比如删掉 debugger),正确方式是在模块执行前 hook 模块定义,而不是 hook eval: const chunkKey = Object.keys(window).find(k => k.startsWith('webpackChunk'));const oldPush = window[chunkKey].push;window[chunkKey].push = function(chunk) { const modules = chunk[1]; for (const id in modules) { const fn = modules[id]; modules[id] = function(module, exports, __webpack_require__) { // 在这里可以拿到模块源码字符串 let code = fn.toString(); // 修改源码(例如移除 debugger) code = code.replace(/\bdebugger\b/g, ''); // 重建函数并在正确的参数上下文里执行 const newFn = eval('(' + code + ')'); return newFn(module, exports, __webpack_require__); }; } return oldPush.call(this, chunk); };这个方法的优势:在模块函数还没执行之前修改,不破坏作用域 __webpack_require__ 仍然由 webpack 正常传入 可以针对特定模块 ID 修改,不影响其他模块只是想看 eval 执行了什么代码 如果目的只是监听而不是转发执行,可以用 Object.defineProperty 劫持赋值时机: let _eval = window.eval;Object.defineProperty(window, 'eval', { get() { return _eval; }, set(v) { console.log('eval 被替换', v); _eval = v; }, configurable: true });或者用 Proxy 记录参数但不干预执行: const rawEval = window.eval;// 只记录,不修改执行上下文 const logFn = function(code) { console.log('[eval]', typeof code === 'string' ? code.slice(0, 200) : code); };// 在外层包一层,直接调用原始 eval window.__logEval = function(code) { logFn(code); return rawEval.call(this, code); };但真正的"无损 hook eval"做不到,只能选择:记录参数(丢失 webpack 作用域)或不 hook(不记录)。

JavaScript 函数 Hook:拦截 fetch、XHR、Function.prototype.call 与 setInterval

Hook 的核心模式 JavaScript 函数 Hook 的通用模式: const original = window.targetFunction;window.targetFunction = function (...args) { // 前置处理 console.log('before call:', args); const result = original.apply(this, args); // 后置处理 console.log('after call:', result); return result; };apply(this, args) 保持原始的 this 上下文,...args 透传所有参数。 Hook fetch const rawFetch = window.fetch;window.fetch = async function (...args) { console.log('[fetch]', args[0]); const response = await rawFetch.apply(this, args); // clone() 避免 body 被消费后无法再读 const clone = response.clone(); clone.text().then(body => { console.log('[fetch response]', body.slice(0, 200)); }); return response; };Hook XMLHttpRequest const RawXHR = window.XMLHttpRequest;window.XMLHttpRequest = function () { const xhr = new RawXHR(); const rawOpen = xhr.open.bind(xhr); xhr.open = function (method, url, ...args) { console.log('[XHR]', method, url); return rawOpen(method, url, ...args); }; const rawSend = xhr.send.bind(xhr); xhr.send = function (body) { xhr.addEventListener('load', function () { console.log('[XHR response]', xhr.responseText.slice(0, 200)); }); return rawSend(body); }; return xhr; };Hook Function.prototype.call(监听所有函数调用) (function () { const rawCall = Function.prototype.call; Function.prototype.call = function (...args) { // this 就是被调用的函数 if (this.name === 'targetFunction') { console.log('[call hook]', this.name, args); } return rawCall.apply(this, args); }; })();影响范围极大,生产环境谨慎使用,建议只在调试时临时注入。 Hook 某个对象的方法 const original = window.api.submit;window.api.submit = function (...args) { console.log('[hook api.submit]', args); const result = original.apply(this, args); console.log('[result]', result); return result; };Hook setInterval(拦截 debugger) 部分网页在 setInterval 里运行 debugger 语句来阻止调试: (() => { const rawSetInterval = window.setInterval; const rawClearInterval = window.clearInterval; const fakeTimers = new Set(); let fakeId = 1_000_000; function shouldBlock(fn) { try { let code = typeof fn === 'function' ? Function.prototype.toString.call(fn) : String(fn); if (/\bdebugger\b/.test(code)) { console.warn('[blocked debugger interval]'); return true; } } catch (e) {} return false; } window.setInterval = function (fn, delay, ...args) { if (shouldBlock(fn)) { fakeTimers.add(++fakeId); return fakeId; } return rawSetInterval(fn, delay, ...args); }; window.clearInterval = function (id) { if (fakeTimers.has(id)) { fakeTimers.delete(id); return; } return rawClearInterval(id); }; })();fakeId 返回一个虚假的 timer ID,避免调用 clearInterval 时报错。 Hook WebAssembly.instantiate const rawInstantiate = WebAssembly.instantiate;WebAssembly.instantiate = async function (...args) { const result = await rawInstantiate.apply(this, args); // 拦截导出函数 const exports = result.instance.exports; for (const key of Object.keys(exports)) { if (typeof exports[key] === 'function') { const original = exports[key]; exports[key] = function (...callArgs) { console.log(`[wasm] ${key}(`, callArgs, ')'); return original.apply(this, callArgs); }; } } return result; };油猴中的注入时机 // ==UserScript== // @run-at document-start // ==/UserScript==document-start 是最早的时机,在页面任何脚本执行之前运行,适合 hook 全局 API。如果用 document-end 或 document-idle,页面原始代码可能已经运行了。

Vue 里强制组件重新渲染:改 key 才是正解

Vue 里"刷新组件"是个模糊需求。要重新 render 一次?重新执行 setup 拉数据?还是彻底把内部 state 归零?不同目标要用不同手段。 四种方式的差别手段 会销毁组件 会重新执行 setup 会重置 state改 :key ✅ ✅ ✅$forceUpdate() ❌ ❌ ❌重新赋值 ref/reactive ❌ ❌ 只改被赋值的字段v-if 切换 ✅ ✅ ✅场景一:改 :key(推荐) 想"完全刷新"一个组件(比如切换 tab 后要重新拉数据、重置表单): <template> <UserForm :key="refreshKey" /> <button @click="refresh">重新加载</button> </template><script setup> import { ref } from "vue";const refreshKey = ref(0); const refresh = () => refreshKey.value++; </script>refreshKey 一变,Vue 视这是新组件——旧的销毁、新的挂载、setup() 重跑、生命周期钩子从 onMounted 全走一遍。 场景二:$forceUpdate(几乎用不到) 只想强制重新 render,不动 state: import { getCurrentInstance } from "vue"; const instance = getCurrentInstance(); instance.proxy.$forceUpdate();适用场景其实很窄——一般是把不响应的对象塞进模板,或者用了第三方非响应库。只要还能改成响应式,就别用 $forceUpdate。 场景三:v-if 切换 粗暴但清晰: <template> <UserForm v-if="show" /> </template><script setup> const show = ref(true); async function reset() { show.value = false; await nextTick(); show.value = true; } </script>等价于改 key,用于"父组件不方便传 key"的场景。 数据变了但界面不更新 Vue3 大多数场景响应式自动生效,出问题基本是下面几种: 1. 解构丢了响应式 const state = reactive({ count: 1 }); const { count } = state; // count 是快照,不再响应改成 toRefs: const { count } = toRefs(state); count.value++; // 会触发更新2. 用了 shallowRef 但改的是深层 const data = shallowRef({ a: 1 }); data.value.a = 2; // 不会更新手动触发: import { triggerRef } from "vue"; triggerRef(data);或者直接换整个引用: data.value = { ...data.value, a: 2 };3. 用了 markRaw / Object.freeze 这些对象上禁用响应式,任你怎么改视图都不动。 4. Vue 2 的经典坑(Vue 3 无此问题)this.arr[index] = value 不触发 → 用 this.$set 或 splice(index, 1, value) this.obj.newKey = 1 不触发 → 用 this.$set(this.obj, "newKey", 1)nextTick:拿最新 DOM 修改数据后立刻读 DOM 大小、滚动位置: import { nextTick } from "vue";count.value++; await nextTick(); console.log(el.value.scrollHeight); // 这时才是新高度一句话总结 "重置组件"改 :key、"只重画"用 $forceUpdate(且大概率你不该用它)、"数据不更新"先查是不是解构丢了响应式。

Vue 3 触发更新:key 强制重渲、triggerRef、nextTick 与解构丢失响应式

Vue 3 响应式自动更新 Vue 3 中 ref 和 reactive 的数据修改会自动触发组件重新渲染,无需手动操作: const count = ref(0); count.value++; // 自动触发更新const state = reactive({ name: 'hello' }); state.name = 'world'; // 自动触发更新强制重建组件:修改 :key 当需要完全销毁并重新创建一个组件(而不只是更新数据)时,修改 :key 是最推荐的方式: <template> <MyComponent :key="refreshKey" :data="data" /> <button @click="refreshKey++">强制刷新</button> </template><script setup> import { ref } from 'vue'; const refreshKey = ref(0); </script>:key 变化会让 Vue 销毁旧组件实例并创建新的,适合需要重置内部状态的场景。 shallowRef 深层修改:triggerRef shallowRef 只追踪引用本身的变化,不追踪对象内部属性: import { shallowRef, triggerRef } from 'vue';const data = shallowRef({ list: [] });data.value.list.push(1); // 不触发更新! triggerRef(data); // 手动通知 Vue 重新渲染非必要不用 shallowRef,直接用 ref 更简单。 nextTick:等待 DOM 更新完成 数据变更后,DOM 不会立即同步更新(Vue 批量异步更新),需要 nextTick 等待: import { ref, nextTick } from 'vue';const visible = ref(false); visible.value = true;await nextTick(); // 此时 DOM 已更新,可以安全操作 DOM 元素 const el = document.querySelector('.new-element');常见陷阱:解构 reactive 丢失响应式 const state = reactive({ count: 1, name: 'hello' });// 错误:解构后 count 是普通变量,不是响应式 const { count } = state; count; // 修改不会触发更新// 正确:用 toRefs 保留响应式 import { toRefs } from 'vue'; const { count, name } = toRefs(state); count.value++; // 正确触发更新ref 不存在这个问题,因为 ref 返回的是包装对象,count.value 始终访问同一个响应式引用。 Vue 2 迁移注意 Vue 2 中直接对数组索引赋值、添加新属性不响应,需要 $set: // Vue 2 this.$set(this.arr, 0, newValue); this.$set(this.obj, 'newKey', 1);Vue 3 用 Proxy 实现响应式,这两种写法都能正确追踪,不需要 $set。 $forceUpdate(不推荐) import { getCurrentInstance } from 'vue'; const instance = getCurrentInstance(); instance.proxy.$forceUpdate();$forceUpdate 只强制当前组件实例重渲染,不更新子组件,且不解决数据不响应的根本问题。遇到更新问题应优先排查响应式数据的使用方式,而不是调用 $forceUpdate。

页面快捷键监听不冲突:五条守则

给页面加自定义快捷键(Ctrl+K 打开搜索、Alt+1 切换 tab 之类),最容易踩的坑是和别的监听打架——把系统快捷键、编辑器热键、别的组件 hotkey 全干掉。列几条实用守则。 守则一:用 addEventListener,别赋值 错的写法: document.onkeydown = handler;这会覆盖之前的所有 keydown 监听——包括别的组件、别的库、浏览器扩展设置的。 正确写法: window.addEventListener("keydown", handler, { passive: false });// 组件卸载时移除 window.removeEventListener("keydown", handler);addEventListener 是往队列里追加,多个监听会一起执行。 守则二:判断 defaultPrevented Monaco Editor、CodeMirror、富文本组件、浏览器本身、系统快捷键——很多东西会调 e.preventDefault()。尊重它们: const handler = e => { if (e.defaultPrevented) return; // 别人已经处理了 // 你的逻辑 };不做这个判断,用户在 CodeMirror 里按 Ctrl+S 会既触发编辑器保存、又触发你的东西,行为不确定。 守则三:输入框里不触发 const t = e.target; const isEditable = t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement || t?.isContentEditable;if (isEditable) return;打字时的每个键都会走 keydown,你的 按 K 打开面板 会毁掉用户所有 K 字母的输入。 守则四:少用 stopPropagation 看到这种写法要警觉: e.stopPropagation();stopPropagation 阻止事件冒泡到父级,等于把父组件、全局快捷键全干死。 除非你明确需要(比如自己写一个 modal,希望里面的键盘操作不冒到外面),否则别用。 守则五:单字母触发要慎重 if (e.key === "s") { ... } // 危险s 太常见了——浏览器 Ctrl+S 保存、编辑器保存、任何输入框都会用到。要监听单字母,几乎必须搭配 modifier: if (e.ctrlKey && e.key.toLowerCase() === "k") { ... } if (e.altKey && e.key === "1") { ... } if (e.shiftKey && e.key === "?") { ... }或者监听几乎没人用的组合:F8、Ctrl+Shift+P、?(在没输入框时)。 Vue3 封装 import { onMounted, onUnmounted } from "vue";type KeyPredicate = (e: KeyboardEvent) => boolean;export function useHotkey(predicate: KeyPredicate, callback: (e: KeyboardEvent) => void) { const handler = (e: KeyboardEvent) => { if (e.defaultPrevented) return; const t = e.target as Element | null; if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement || (t as HTMLElement)?.isContentEditable) return; if (predicate(e)) { e.preventDefault(); callback(e); } }; onMounted(() => window.addEventListener("keydown", handler, { passive: false })); onUnmounted(() => window.removeEventListener("keydown", handler)); }// 用法 useHotkey( e => e.ctrlKey && e.key.toLowerCase() === "k", () => openSearch(), );React 版 import { useEffect } from "react";export function useHotkey( predicate: (e: KeyboardEvent) => boolean, callback: (e: KeyboardEvent) => void, ) { useEffect(() => { const h = (e: KeyboardEvent) => { if (e.defaultPrevented) return; const t = e.target as HTMLElement; if (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable) return; if (predicate(e)) { e.preventDefault(); callback(e); } }; window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, [predicate, callback]); }passive: false 是什么 现代浏览器为了滚动性能,某些事件默认变成 passive——你在里面调 e.preventDefault() 会报警告并被忽略。keydown 不受影响,但显式写 passive: false 是好习惯,声明"我可能会 preventDefault": window.addEventListener("keydown", h, { passive: false });对 keydown 来说是无用功但无害。对 touchstart / wheel 就必须写了。 特殊环境的坑Electron:主进程 globalShortcut 会拦截全局键,注意别覆盖用户系统的 iframe:跨域 iframe 里的 keydown 拿不到,需要 postMessage 转发 Monaco / CodeMirror:编辑器有自己的键映射系统,用它们提供的 API 注册,别在外面挂 keydown 浏览器扩展 content script:可能碰到页面已经拦截了某些键,要么 capture: true 抢先,要么改键位一句话总结 addEventListener 追加、检查 defaultPrevented、跳过输入框、避免 stopPropagation、单字母必须搭 modifier——五条守则遵守,你的快捷键就不会踩别人也不会被别人踩。

JS 安全随机整数:Math.random() 还是 crypto.getRandomValues()

Math.random() 的问题 const randomInt = (num, offset) => Math.floor(Math.random() * num) + offset;Math.random() 生成的是伪随机数,不适合安全场景:某些 JS 引擎实现可预测 不能用于:token、验证码、session ID、抽奖、游戏反作弊用于 UI 动画、随机颜色、普通业务逻辑完全没问题,够快够简单。 安全随机整数 浏览器 export const randomInt = (min, max) => { const range = max - min + 1; const bytes = new Uint32Array(1); crypto.getRandomValues(bytes); return min + (bytes[0] % range); };有轻微的 modulo bias(取模偏差)——当 range 不能整除 0xFFFFFFFF 时,某些值出现概率略高。对大多数业务足够,抽奖/密码学场景用下面的拒绝采样版: export const randomInt = (min, max) => { const range = max - min + 1; if (range <= 0 || range > 0xFFFFFFFF) throw new Error("invalid range"); const maxUint = 0xFFFFFFFF; const limit = maxUint - (maxUint % range); // 去掉尾部偏差区间 const bytes = new Uint32Array(1); let rand; do { crypto.getRandomValues(bytes); rand = bytes[0]; } while (rand >= limit); // 拒绝偏差区间内的值 return min + (rand % range); };平均循环次数 < 2,性能影响可以忽略。 Node.js import crypto from "crypto";export const randomInt = (min, max) => { return crypto.randomInt(min, max + 1); // 内部已处理 modulo bias };Node.js 的 crypto.randomInt 直接用就行,自动处理了偏差问题。 API 设计建议 原来的写法 randomInt(num, offset) 语义模糊——不清楚 num 是最大值还是范围长度: // ❌ 不直观 randomInt(10, 5) // 5~14?还是 0~5 + 10 的偏移?改成 (min, max) 更清晰: // ✅ 直观 randomInt(5, 14) // [5, 14] 范围内均匀取整场景选择场景 推荐UI 动画、随机颜色 Math.random()随机数组元素、占位数据 Math.random()验证码、token crypto.getRandomValues()抽奖、游戏随机 crypto.getRandomValues() + 拒绝采样Node.js 服务端 crypto.randomInt()性能上 Math.random() 比 crypto.getRandomValues() 快一个数量级,安全要求不高时不必引入加密随机数的开销。

电商 ERP 商品 JSON 解析:类目 ID、规格模板与 SKU 明细

电商 ERP 接口返回的商品 JSON 通常有几个容易混淆的字段。 类目字段 "categoryId": 1000016, "leafCategoryId": 51000671, "categoryIds": ["1000016", "51000671"]categoryId:主类目(一级) leafCategoryId:叶子类目,即商品实际发布的最末级类目 categoryIds:完整类目路径数组实际使用的是 leafCategoryId,而不是 categoryId。 如果接口返回的 leafCategoryName 为 null,需要单独查询类目名称: SELECT * FROM category WHERE id = 51000671或调用类目接口: GET /category/get?id=51000671specifications 与 skuList 的区别 这是最常混淆的地方:字段 含义 用途specifications 规格定义模板 描述有哪些规格维度和可选值skuList 实际销售 SKU 每个 SKU 的具体价格、库存、规格组合specifications 示例 "specifications": [ { "name": "颜色", "values": [{"value": "米白色"}] }, { "name": "尺码", "values": [ {"value": "M", "note": "建议80-100斤内"}, {"value": "L", "note": "建议100-115斤内"}, {"value": "XL"}, {"value": "2XL"}, {"value": "3XL"}, {"value": "4XL"} ] } ]这是模板,说明商品有"颜色"和"尺码"两个规格维度,以及每个维度的可选值。 skuList 示例 "skuList": [ { "skuOuterId": "12644-米白色-M", "specValue": "米白色;M", "salesPrice": 59.8, "stock": 5000, "_skuid": "v_1776944461271_970-v_M_1776944482303_3644" } ]每个 SKU 对应一个规格组合,有独立的价格和库存。 skuOuterId 与 specValueskuOuterId:商家自定义编码,格式通常是 商品编号-颜色-尺码 specValue:用分号拼接的规格值,如 米白色;M _skuid:系统内部键,由各规格的 valueKey 拼接,用于唯一标识规格组合遍历所有 SKU 的写法 for (const item of products) { for (const sku of item.skuList || []) { console.log({ title: item.title, sku: sku.skuOuterId, spec: sku.specValue, price: sku.salesPrice, stock: sku.stock }); } }for item in products: for sku in item.get("skuList", []): print( item["title"], sku["skuOuterId"], sku["specValue"], sku["salesPrice"], sku["stock"] )核心字段速查字段 含义leafCategoryId 真正的叶子类目 IDspecifications 规格维度定义(模板)skuList 实际销售 SKU 列表specValue SKU 规格值拼接字符串skuOuterId 商家 SKU 编码stock 该 SKU 库存salesPrice 销售价marketPrice 划线价(原价展示)

Cheerio 解析 HTML 表格:动态定位列索引

HTML 表格的列顺序经常变动,硬编码列索引(cells[2])在结构改变后立刻失效。正确做法是先扫描表头行确定目标列的实际位置,再按索引取数据。 核心思路 1. 找到目标表格 2. 读取表头行,确定"销售"列的索引 3. 遍历数据行,按列索引取值 4. 匹配产品名称,验证数值范围完整实现 import * as cheerio from "cheerio";class PriceParser { constructor() { this.validationRules = { gold: { min: 400, max: 1000 }, // 元/克 silver: { min: 3, max: 20 }, }; } parsePrices(html) { const $ = cheerio.load(html); const result = { goldPrice: null, silverPrice: null }; $("table").each((_, table) => { const rows = $(table).find("tr"); if (rows.length < 2) return; // 扫描表头行,定位"销售"列 let saleColumnIndex = -1; rows.first().find("th, td").each((i, cell) => { if ($(cell).text().trim().includes("销售")) { saleColumnIndex = i; return false; // break } }); if (saleColumnIndex < 0) return; // 遍历数据行 rows.each((rowIndex, row) => { if (rowIndex === 0) return; // 跳过表头 const cells = $(row).find("td, th"); if (!cells.length) return; const productName = $(cells[0]).text().trim(); // 匹配黄金9999 if ( (productName === "黄金9999" || productName === "黄金 9999") && !result.goldPrice ) { result.goldPrice = this.extractPrice( $(cells[saleColumnIndex]).text().trim(), this.validationRules.gold ); } // 匹配白银(排除含"黄金"的行) if ( productName === "白银" && !productName.includes("黄金") && !result.silverPrice ) { result.silverPrice = this.extractPrice( $(cells[saleColumnIndex]).text().trim(), this.validationRules.silver ); } }); }); return result; } extractPrice(text, { min, max }) { const match = text.match(/(\d+\.?\d*)/); if (!match) return null; const price = parseFloat(match[1]); if (price < min || price > max) return null; // 超出合理范围 return price.toFixed(2); } }// 使用 const parser = new PriceParser(); const { goldPrice, silverPrice } = parser.parsePrices(html);关键点解析 动态列定位:用 .includes("销售") 而不是固定索引,表格新增或删除列时代码无需修改。 数值范围验证:validationRules 定义合理价格区间,过滤掉格式异常或明显错误的数据(如抓到表格里的其他数字)。 产品名精确匹配:productName === "黄金9999" 而不是 .includes("黄金"),防止把"黄金饰品9999"也匹配进来。!productName.includes("黄金") 在白银行再加一层保护。 多语言/别名的处理 如果表格里的产品名不统一(黄金9999、黄金 9999、Au9999 混用): const GOLD_NAMES = new Set(["黄金9999", "黄金 9999", "Au9999", "足金9999"]);if (GOLD_NAMES.has(productName) && !result.goldPrice) { ... }调试技巧 遇到解析不到数据时,先打印表格 HTML 确认结构: $("table").each((i, table) => { console.log(`Table ${i}:`, $.html(table).slice(0, 500)); });再检查表头文本是否有空格或全角字符: rows.first().find("th, td").each((i, cell) => { console.log(`Header ${i}: "${$(cell).text().trim()}"`); });页面改版后表格结构变化,这两步能快速定位问题所在。

用 Proxy hook setInterval 拦截反调试 debugger

初版的问题 常见的 setInterval debugger 拦截写法有几个缺陷: (function(){ window._setInterval = window.setInterval; // 污染全局 window.setInterval = function (fn, delay, ...args) { let code = typeof fn === "function" ? fn.toString() : fn; if (code.includes("debugger")) { return null; // ❌ 返回 null 会让部分站点报错 } return _setInterval(fn, delay, ...args); // ❌ _setInterval 在闭包外查找可能丢失 }; })()问题汇总:fn.toString() 可被重写 getter 绕过 返回 null 时,站点调用 clearInterval(null) 或存 timer ID 会报错 _setInterval 挂到 window 上,污染全局命名空间 没有 hook clearInterval,假 ID 泄漏改进版:Proxy + 假 timer ID (() => { const rawSetInterval = window.setInterval; const rawClearInterval = window.clearInterval; const fakeTimers = new Set(); let fakeId = 1_000_000; function shouldBlock(fn) { try { let code = ""; if (typeof fn === "function") { code = Function.prototype.toString.call(fn); // 绕过 toString 重写 } else if (typeof fn === "string") { code = fn; } if (!code) return false; if (/\bdebugger\b/.test(code)) { console.warn("拦截到 debugger interval:", code); return true; } if (/while\s*\(\s*true\s*\)/.test(code)) { console.warn("拦截到死循环 interval:", code); return true; } } catch (e) { console.error("检测 interval 出错:", e); } return false; } window.setInterval = new Proxy(rawSetInterval, { apply(target, thisArg, args) { const [fn] = args; if (shouldBlock(fn)) { const id = fakeId++; fakeTimers.add(id); return id; // 返回真实格式的假 ID,不返回 null } return Reflect.apply(target, thisArg, args); } }); window.clearInterval = new Proxy(rawClearInterval, { apply(target, thisArg, args) { const [id] = args; if (fakeTimers.has(id)) { fakeTimers.delete(id); // 吞掉对假 ID 的 clearInterval 调用 return; } return Reflect.apply(target, thisArg, args); } }); console.log("setInterval hook 已启动"); })();关键设计点 为什么用 Function.prototype.toString.call(fn) 而不是 fn.toString() 站点可以重写 fn.toString 或者在函数对象上挂 getter: const fn = () => {}; fn.toString = () => "() => {}"; // 伪造源码而 Function.prototype.toString 直接从函数底层获取源码,无法被实例属性覆盖。 为什么返回假 timer ID 而不是 null 很多站点会存 timer ID 用于后续 clearInterval: const tid = setInterval(check, 100); // 之后 clearInterval(tid);如果 tid === null,clearInterval(null) 在部分引擎下会抛异常,或者日志里产生噪声。返回 1_000_000 以上的数字既像真实 ID,又不会和浏览器分配的 ID 冲突。 为什么同步 hook clearInterval 假 ID 进入 fakeTimers Set 之后,站点持有这个 ID 并在后续调用 clearInterval(fakeId)。如果不 hook,这个调用会传给原生 clearInterval,可能误清真实定时器(原生 ID 碰巧相同)或产生错误。 为什么用 Proxy 而不是直接赋值 Proxy 保留了 length、name 等函数元信息,instanceof 检测也更接近原生。部分站点会用 typeof setInterval === "function" 或特征检测来判断是否被 hook,Proxy 比直接赋值的闭包函数更难被识别。 可扩展检测项 如果需要更宽泛的反调试拦截,可以在 shouldBlock 里补充: // 检测 setTimeout debugger(同理 hook setTimeout) // 检测 constructor/eval 执行 debugger 字符串 if (/\bFunction\s*\(/.test(code)) return true; if (/\beval\s*\(/.test(code)) return true;现代站点已经较少用 setInterval(debugger) 这种低级方式,更多见的是混淆后的字节码或 WebAssembly 版本的反调试,这类情况 setInterval hook 就无法覆盖了。

JS 把 img 元素转 Base64:canvas + 跨域 tainted 问题

前端要把一张 <img> 转成 Base64——通常是为了上传、缓存、内联到别的地方。最直接的方式是 canvas。 基础版:canvas.toDataURL function imgToBase64(img, type = "image/png") { const canvas = document.createElement("canvas"); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0); return canvas.toDataURL(type); }const img = document.querySelector("img"); img.onload = () => console.log(imgToBase64(img));关键点:用 naturalWidth/naturalHeight,不是 width/height(后者是显示尺寸,会被 CSS 缩放) 图片必须加载完成才能 draw,判 img.complete 或者监听 onload type 可选 image/png、image/jpeg、image/webp JPEG 可以传第二个参数控制画质:canvas.toDataURL("image/jpeg", 0.8)只有 URL 没有 img 元素 async function urlToBase64(url) { const img = new Image(); img.crossOrigin = "anonymous"; // 关键,让 canvas 不 tainted img.src = url; await new Promise((res, rej) => { img.onload = res; img.onerror = rej; }); const canvas = document.createElement("canvas"); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; canvas.getContext("2d").drawImage(img, 0, 0); return canvas.toDataURL("image/png"); }跨域的 tainted 坑 跨域图片经 canvas 一处理,toDataURL 直接抛: SecurityError: Tainted canvases may not be exported原因:canvas 一旦画了跨域资源就"被污染",浏览器不让你导出数据(防止读取用户其它站的私有图片)。 要出数据,需要满足:图片服务器返回 Access-Control-Allow-Origin: *(或匹配你的域) <img> 元素设置 crossOrigin="anonymous"如果对方服务器没配 CORS,只能:走自己的 Nginx 反代加 CORS 头 fetch + blob + FileReader 绕开 canvas更稳的方案:fetch + blob + FileReader async function urlToBase64(url) { const res = await fetch(url); const blob = await res.blob(); return await new Promise(resolve => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.readAsDataURL(blob); }); }返回的就是: data:image/png;base64,iVBORw0KGgoAAAANS...优势:不经过 canvas,没有 tainted 问题 支持任何图片格式,包括 GIF 动图(canvas 会把动图变成第一帧) 保持原始质量,不会重新压缩但 fetch 依然受 CORS 约束——服务器不配 CORS 时仍然会失败,只是不再看到"tainted"的错误,而是看到网络请求被拦。 想跳过 CORS 的最后一招 在自己的服务端加个代理: 浏览器 → your-server/proxy?url=xxx → 目标图片服务端拿到图片,加上 CORS 头再回给浏览器。前端就走同源了。 Base64 有多大 Base64 编码后体积变大约 33%。10 KB 的图变 13.3 KB 左右。做本地缓存 / 内联小图(favicon、图标)合适;大图别用 Base64,走 URL 更省。 一句话总结 canvas.toDataURL 一行搞定,跨域首选 fetch → blob → FileReader。用 canvas 就得先解决 CORS,别硬撞 tainted 报错。

JavaScript 将 img 元素导出为 Base64:canvas.toDataURL 与跨域处理

基础方法:canvas.toDataURL 将页面中已有的 <img> 元素转为 Base64: function imgToBase64(img, type = 'image/png') { const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); return canvas.toDataURL(type); // 'data:image/png;base64,...' }// 等待图片加载后调用 const img = document.querySelector('img'); img.onload = () => { const base64 = imgToBase64(img); console.log(base64); };// 图片已加载时直接调用 if (img.complete) { const base64 = imgToBase64(img); }naturalWidth/naturalHeight 是图片的原始尺寸,避免取到 CSS 设置的显示尺寸。 跨域图片:crossOrigin='anonymous' 图片来自不同域名时,需要设置 crossOrigin 才能在 canvas 中使用: async function urlToBase64(url) { const img = new Image(); img.crossOrigin = 'anonymous'; img.src = url; await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; }); const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; canvas.getContext('2d').drawImage(img, 0, 0); return canvas.toDataURL('image/png'); }服务端必须在响应头中返回 Access-Control-Allow-Origin: *,否则 canvas 被标记为"污染(tainted)",调用 toDataURL() 会报错: SecurityError: Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.跨域无 CORS 头:fetch + FileReader 当目标服务器没有设置 CORS 头时,可以走服务端代理,或在同源页面中用 fetch 请求(如果存在跨域会被拦截)。另一种方案是在已有权限的上下文(如油猴脚本设置 @grant GM_xmlhttpRequest)中绕过同源限制: // 同源或有权限的上下文 async function urlToBase64ViaFetch(url) { const res = await fetch(url); const blob = await res.blob(); return new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.readAsDataURL(blob); }); }指定输出格式和质量 canvas.toDataURL('image/png'); // PNG,无损 canvas.toDataURL('image/jpeg', 0.8); // JPEG,质量 80% canvas.toDataURL('image/webp', 0.9); // WebP(非所有浏览器支持)JPEG 不支持透明通道,带透明背景的图片转 JPEG 后透明部分会变黑,建议用 PNG。

Three.js 点云矩形框选:屏幕空间投影法

用 Three.js 显示点云(THREE.Points)后,做"鼠标拖矩形框选一批点"这类交互,标准做法是屏幕空间投影——把每个 3D 点 project 到 2D 屏幕,判是否落在鼠标矩形里。 为什么不用 Raycaster Three.js 的 Raycaster 主要针对射线,判"鼠标下选中哪个物体"用得多。但做矩形框选用它效率极差——要么在 CPU 上做 AABB,要么写 GPU shader,两条路都比"直接投影"复杂得多。 生产上标注工具、CAD、自动驾驶点云可视化基本都用屏幕空间法。 整体流程 鼠标 mousedown → 记录 (startX, startY) mousemove → 更新矩形 DOM mouseup → 遍历 points.geometry ├─ 每个点 → applyMatrix4(worldMatrix) ├─ → .project(camera) 转 NDC ├─ → NDC → 屏幕坐标 └─ 判断是否落在矩形内完整最小实现 先做一个 DOM 矩形跟着鼠标: const box = document.createElement("div"); box.style.cssText = ` position: fixed; border: 1px solid #00aaff; background: rgba(0,170,255,0.1); pointer-events: none; display: none; `; document.body.appendChild(box);let startX = 0, startY = 0, endX = 0, endY = 0, dragging = false;window.addEventListener("mousedown", e => { dragging = true; startX = e.clientX; startY = e.clientY; Object.assign(box.style, { left: startX + "px", top: startY + "px", width: "0px", height: "0px", display: "block", }); });window.addEventListener("mousemove", e => { if (!dragging) return; endX = e.clientX; endY = e.clientY; Object.assign(box.style, { left: Math.min(startX, endX) + "px", top: Math.min(startY, endY) + "px", width: Math.abs(endX - startX) + "px", height: Math.abs(endY - startY) + "px", }); });window.addEventListener("mouseup", () => { dragging = false; box.style.display = "none"; selectPoints(); });框选核心 function selectPoints() { const minX = Math.min(startX, endX); const maxX = Math.max(startX, endX); const minY = Math.min(startY, endY); const maxY = Math.max(startY, endY); const attr = pointCloud.geometry.attributes.position; const vec = new THREE.Vector3(); const selected = []; pointCloud.updateMatrixWorld(); for (let i = 0; i < attr.count; i++) { vec.fromBufferAttribute(attr, i); vec.applyMatrix4(pointCloud.matrixWorld); // 本地 → 世界 vec.project(camera); // 世界 → NDC (-1 ~ 1) // NDC → 屏幕 const sx = (vec.x * 0.5 + 0.5) * window.innerWidth; const sy = (-vec.y * 0.5 + 0.5) * window.innerHeight; if (sx >= minX && sx <= maxX && sy >= minY && sy <= maxY) { selected.push(i); } } console.log("选中", selected.length, "个点"); highlight(selected); }高亮选中的点 改颜色最简单的做法:给点云加一个 color attribute。 const geo = pointCloud.geometry; if (!geo.attributes.color) { const colors = new Float32Array(geo.attributes.position.count * 3); colors.fill(1); // 全白 geo.setAttribute("color", new THREE.BufferAttribute(colors, 3)); pointCloud.material.vertexColors = true; }function highlight(indices) { const colors = geo.attributes.color; // 先全部还原 for (let i = 0; i < colors.count; i++) colors.setXYZ(i, 1, 1, 1); // 选中的染红 for (const i of indices) colors.setXYZ(i, 1, 0, 0); colors.needsUpdate = true; }剔除相机背后的点 vec.project(camera) 对相机背后的点 z 会 > 1,也可能被误判进屏幕矩形。加一道过滤: if (vec.z < -1 || vec.z > 1) continue; // 相机视锥体外性能 十万点级别这套办法很流畅(一次 mouseup 内做完)。到一百万点就要考虑:用 Worker 并发做投影 GPU picking:把点索引编码到颜色渲染到 offscreen canvas,读像素判索引 空间分区(Octree/KDTree)预筛选一般标注场景十万级足够。 一句话总结 点云框选 = 每个点 project 到屏幕 + 矩形内包含判定。做完投影后加"背后点剔除",用 color attribute 高亮,是最直接可用的方案。

Three.js 点云矩形框选:屏幕投影与 SelectionBox

三种方案概览方案 适合场景 复杂度屏幕空间投影 通用标注、框选 低SelectionBox(Frustum) 需要真 3D 选取(不受遮挡) 中GPU Picking 超大点云(百万级)性能优先 高方案一:屏幕空间投影(推荐) 原理:把每个 3D 点投影到屏幕坐标,判断是否落在鼠标拖出的矩形内。 绘制选择框 const box = document.createElement('div'); box.style.cssText = ` position: fixed; border: 1px solid #00aaff; background: rgba(0, 170, 255, 0.1); pointer-events: none; `; document.body.appendChild(box);let startX = 0, startY = 0, endX = 0, endY = 0; let dragging = false;window.addEventListener('mousedown', e => { dragging = true; startX = endX = e.clientX; startY = endY = e.clientY; box.style.display = 'block'; });window.addEventListener('mousemove', e => { if (!dragging) return; endX = e.clientX; endY = e.clientY; const left = Math.min(startX, endX); const top = Math.min(startY, endY); box.style.left = left + 'px'; box.style.top = top + 'px'; box.style.width = Math.abs(endX - startX) + 'px'; box.style.height = Math.abs(endY - startY) + 'px'; });window.addEventListener('mouseup', () => { dragging = false; box.style.display = 'none'; selectPoints(); });框选核心逻辑 function selectPoints() { const minX = Math.min(startX, endX); const maxX = Math.max(startX, endX); const minY = Math.min(startY, endY); const maxY = Math.max(startY, endY); const positions = pointCloud.geometry.attributes.position; const selected = []; const vector = new THREE.Vector3(); for (let i = 0; i < positions.count; i++) { vector.fromBufferAttribute(positions, i); // 应用模型变换 vector.applyMatrix4(pointCloud.matrixWorld); // 投影到 NDC(Normalized Device Coordinates) vector.project(camera); // NDC [-1,1] 转屏幕像素 const sx = (vector.x * 0.5 + 0.5) * window.innerWidth; const sy = (vector.y * -0.5 + 0.5) * window.innerHeight; if (sx >= minX && sx <= maxX && sy >= minY && sy <= maxY) { selected.push(i); } } console.log('选中点数:', selected.length); highlightPoints(selected); }高亮选中点 function highlightPoints(indices) { const colors = pointCloud.geometry.attributes.color; // 重置所有点为白色 for (let i = 0; i < colors.count; i++) { colors.setXYZ(i, 1, 1, 1); } // 选中点设为红色 for (const idx of indices) { colors.setXYZ(idx, 1, 0, 0); } colors.needsUpdate = true; }方案二:官方 SelectionBox(3D Frustum) Three.js examples 提供了现成的框选工具: import { SelectionBox } from 'three/addons/interactive/SelectionBox.js'; import { SelectionHelper } from 'three/addons/interactive/SelectionHelper.js';const selectionBox = new SelectionBox(camera, scene); const helper = new SelectionHelper(renderer, 'selectBox');// CSS:.selectBox { border: 1px solid #55aaff; background: rgba(75,160,255,.1); }document.addEventListener('pointerdown', e => { selectionBox.startPoint.set( (e.clientX / window.innerWidth) * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1, 0.5 ); });document.addEventListener('pointermove', e => { if (!helper.isDown) return; selectionBox.endPoint.set( (e.clientX / window.innerWidth) * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1, 0.5 ); // 实时更新选中(可选,性能开销大) // selectionBox.select(); });document.addEventListener('pointerup', () => { const allSelected = selectionBox.select(); // allSelected 是 Object3D 数组(Mesh/Points 等) console.log(allSelected); });SelectionBox 用 Frustum 判断,不受相机遮挡影响,适合需要选取被遮挡点的场景。 方案三:GPU Picking 对百万级点云,CPU 遍历太慢,改用 Render-To-Texture(RTT):每个点用唯一颜色(编码点 index)渲染到离屏 FBO 读取矩形区域的像素颜色 解码颜色得到点 index// 伪代码 renderer.setRenderTarget(pickingTexture); renderer.render(scene, camera); // 用 picking shader 渲染const pixels = new Uint8Array(width * height * 4); renderer.readRenderTargetPixels(pickingTexture, x, y, width, height, pixels);const selected = decodeIndices(pixels); renderer.setRenderTarget(null);适合:自动驾驶标注平台、大规模点云编辑器等专业场景。 注意事项矩形太小时:endX - startX < 2 可跳过框选避免误触 相机控件冲突:拖动框选时需要禁用 OrbitControls(controls.enabled = false) 性能:方案一遍历所有点,百万级点云需要 Worker 或 GPU 方案

JS 判断空值:不把 0 当空的几种写法

JS 里的"空值"判断经常被 !value 搞坏——它会把 0、false、NaN 都当成空,实际业务里这三个通常是有效值。 不推荐:!value if (!value) { /* 错误率很高 */ }会把这些都当"空":null、undefined ✓(确实空) '' ✓(确实空) 0 ✗(数字零是有效值) false ✗(布尔假是有效值) NaN ✗(通常需要单独处理)推荐:精确判断 if (value == null || value === '') { // 空值:null、undefined、空字符串 }value == null 利用了宽松相等,同时捕获 null 和 undefined,不会误伤 0 或 false。 封装成工具函数: function isEmpty(value) { return value == null || value === ''; }isEmpty(null) // true isEmpty(undefined) // true isEmpty('') // trueisEmpty(0) // false isEmpty(false) // false isEmpty([]) // false(空数组不是空) isEmpty({}) // false(空对象不是空)加上空白字符串处理 如果 " " 也要算空: function isEmpty(value) { return value == null || (typeof value === 'string' && value.trim() === ''); }isEmpty(' ') // true isEmpty(' x ') // false用 ??= 做默认值赋值 ES2021 的空值合并赋值更精确: let count = 0; count ||= 10; // count = 10,因为 0 是 falsy(错误) count ??= 10; // count = 0,因为 0 不是 null/undefined(正确)只想在 null / undefined 时设默认值,用 ??= 而不是 ||=。 几种场景对应的写法场景 推荐写法只判断 null/undefined value == nullnull/undefined/空字符串 value == null || value === ''加上空白字符串 value == null || String(value).trim() === ''设默认值(0 是有效值) value ??= defaultVal设默认值(0 也算空) value ||= defaultVal表单校验里的典型误用 // ❌ 数量字段输入 0,会被误判为"未填写" if (!form.quantity) { showError('数量不能为空'); }// ✓ 正确写法 if (form.quantity == null || form.quantity === '') { showError('数量不能为空'); }记住:!value 是"falsy 检查",不是"空值检查"。两者在大多数场景下不等价。

JavaScript 判断空值:0 不算空的正确写法与 !value 的陷阱

JavaScript 里"空值"的定义因场景而异,!value 是最常见的误用——它会把 0、false、NaN、'' 全部当作空值处理,往往不符合预期。 陷阱:!value 的误判范围 const values = [0, false, NaN, '', null, undefined]; values.filter(v => !v); // [0, false, NaN, '', null, undefined] ← 全部被误判为空如果 0 是有效值(比如数量、评分、坐标),用 !value 会导致它被误过滤。 == null:同时覆盖 null 和 undefined == 宽松相等只在 null 和 undefined 之间成立,是检测这两者最简洁的方式: null == null // true undefined == null // true 0 == null // false '' == null // false false == null // false用法: function isNullish(value) { return value == null; // 等价于 value === null || value === undefined }加上空字符串判断 大多数场景下"空值"包括 null、undefined 和空字符串: function isEmpty(value) { return value == null || value === ''; }isEmpty(null) // true isEmpty(undefined) // true isEmpty('') // true isEmpty(0) // false ← 0 不算空 isEmpty(false) // false isEmpty([]) // false isEmpty(' ') // false(有空格的字符串不算空)加上空白字符串判断 如果全是空格的字符串也要视为空: function isEmpty(value) { return value == null || (typeof value === 'string' && value.trim() === ''); }isEmpty(' ') // true场景对照表场景 推荐写法只判断 null/undefined value == nullnull/undefined/空字符串 value == null || value === ''同时包含空白字符串 value == null || value?.trim() === ''非空对象(有属性) Object.keys(value).length > 0非空数组 Array.isArray(value) && value.length > 0空值合并运算符 ?? ES2020 引入的 ?? 只在左侧为 null 或 undefined 时取右侧默认值,不误判 0 和 false: const count = userInput ?? 0; // userInput 为 null/undefined 时取 0// 对比 ||: const count2 = userInput || 0; // userInput 为 0 时也会取右侧!需要默认值时优先用 ?? 而非 ||。

JS 判断空值排除 0 与 Canvas 滚轮缩放图片实现

JS 判断空值但 0 不算空 直接用 !value 的问题: if (!value) { ... } // 会把 0、false、NaN、''、null、undefined 全算空 // 经常误伤数字 0正确写法 —— 只排除 null、undefined、空字符串: if (value == null || value === '') { // 空值 }value == null 会同时匹配 null 和 undefined(宽松相等),不会匹配 0 或 false。 封装函数 function isEmpty(value) { return value == null || value === ''; }isEmpty(null) // true isEmpty(undefined) // true isEmpty('') // trueisEmpty(0) // false isEmpty(false) // false isEmpty([]) // false isEmpty({}) // false同时排除全空格字符串 function isEmpty(value) { return value == null || (typeof value === 'string' && value.trim() === ''); }isEmpty(' ') // true isEmpty(' a ') // falseCanvas 滚轮缩放图片 核心思路:记录 scale(缩放比)和 offsetX/Y(画布偏移量),每次绘制时先平移再缩放。 <!DOCTYPE html> <html> <head> <meta charset="UTF-8" /> <style> html, body { margin: 0; height: 100%; overflow: hidden; background: #111; } canvas { display: block; width: 100vw; height: 100vh; cursor: grab; } </style> </head> <body> <canvas id="canvas"></canvas> <script> const canvas = document.getElementById("canvas"); const ctx = canvas.getContext("2d");canvas.width = window.innerWidth; canvas.height = window.innerHeight;const img = new Image(); img.src = "https://picsum.photos/1200/800";let scale = 1; let offsetX = 0; let offsetY = 0;img.onload = () => draw();function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.translate(offsetX, offsetY); ctx.scale(scale, scale); ctx.drawImage(img, 0, 0); ctx.restore(); }// 滚轮缩放 canvas.addEventListener("wheel", (e) => { e.preventDefault(); const mouseX = e.offsetX; const mouseY = e.offsetY; // 鼠标在图片坐标系中的位置(缩放前) const x = (mouseX - offsetX) / scale; const y = (mouseY - offsetY) / scale; const zoom = e.deltaY < 0 ? 1.1 : 0.9; scale *= zoom; scale = Math.max(0.1, Math.min(scale, 20)); // 限制范围 // 缩放后修正偏移,保证鼠标所在点不漂移 offsetX = mouseX - x * scale; offsetY = mouseY - y * scale; draw(); }, { passive: false });// 拖拽移动 let dragging = false; let lastX = 0, lastY = 0;canvas.addEventListener("mousedown", (e) => { dragging = true; lastX = e.clientX; lastY = e.clientY; canvas.style.cursor = "grabbing"; });window.addEventListener("mousemove", (e) => { if (!dragging) return; offsetX += e.clientX - lastX; offsetY += e.clientY - lastY; lastX = e.clientX; lastY = e.clientY; draw(); });window.addEventListener("mouseup", () => { dragging = false; canvas.style.cursor = "grab"; });window.addEventListener("resize", () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight; draw(); }); </script> </body> </html>鼠标中心缩放原理 缩放时要保持鼠标位置不变,关键公式: // 缩放前:鼠标在图片坐标系的位置 x = (mouseX - offsetX) / scale// 缩放后:offsetX 要满足 mouseX = x * newScale + newOffsetX newOffsetX = mouseX - x * newScale即先算出鼠标对应图片上的点坐标,缩放后重新反算偏移量。不做这一步,图片会向左上角方向漂移。 { passive: false } 的作用 wheel 事件的 e.preventDefault() 阻止页面滚动,必须在监听器选项里显式传 { passive: false },否则浏览器忽略 preventDefault() 并输出警告。

JS 判断空值别只写 !value:0 和 false 会被误伤

前端最常见的"低级 bug"之一——接口返回: { "status": 0, "count": 0 }代码里: if (!data.status) { // 走这里,本意是"没返回 status" }结果 status 明明是 0(业务成功),这段错误逻辑也被触发。!value 把 falsy 全当空,误伤面太大: !0 // true !false // true !NaN // true !"" // true !null // true !undefined// true精确的"空"判断 如果空的定义是 null | undefined | "",那写: function isEmpty(value) { return value == null || value === ""; }关键是 value == null——用宽松等号,同时匹配 null 和 undefined(这是 ESLint 少数允许 == 的地方)。 isEmpty(null) // true isEmpty(undefined) // true isEmpty("") // trueisEmpty(0) // false isEmpty(false) // false isEmpty([]) // false想把 " " 也算空 用户输入常见"全是空格"的字符串: function isEmpty(value) { return value == null || (typeof value === "string" && value.trim() === ""); }isEmpty(" ") // true数组 / 对象也想算空 深一点的版本: function isEmpty(value) { if (value == null) return true; if (typeof value === "string") return value.trim() === ""; if (Array.isArray(value)) return value.length === 0; if (typeof value === "object") return Object.keys(value).length === 0; return false; }isEmpty([]) // true isEmpty({}) // true isEmpty(0) // false isEmpty(false) // falselodash 的 _.isEmpty 差不多是这个思路。 ?? vs ||:也是同一个坑 新版 JS 的空值合并: const port = config.port ?? 3000; // 只有 null/undefined 才取默认 const port = config.port || 3000; // 0 也会被换掉,多半是 bug配置里 port: 0 是合法的(让系统随机分配端口),用 || 会把它替换成 3000。这类场景统一用 ??。 表格速查表达式 什么算"空"!value falsy:0, false, NaN, "", null, undefinedvalue == null null, undefinedvalue === "" || value == null null, undefined, ""lodash _.isEmpty(value) null, undefined, "", [], {}, Map(), Set()一句话总结 !value 判空是懒省事,业务代码里最好写 value == null || value === "",避免把 0 和 false 一起干掉。

OpenCV.js 等比例模板匹配:Canvas 框选 + 局部搜索

问题场景 图 A(已标注,尺寸 W1×H1)在图 B(待匹配,尺寸 W2×H2)中找对应区域。如果 W1≠W2,直接用原始模板做 matchTemplate 会因为尺寸不匹配找错位置。 核心函数 /** * 等比例自适应模板匹配 * @param {HTMLCanvasElement} canvasA - 模板来源图(图A) * @param {HTMLCanvasElement} canvasB - 待搜索图(图B) * @param {{x,y,w,h}} cropRect - 图A中框选的区域 * @param {number} padding - 搜索区域外扩像素(默认150) */ function matchCanvasRegion(canvasA, canvasB, cropRect, padding = 150) { let matA = cv.imread(canvasA); let matB = cv.imread(canvasB); // 计算两图的尺寸比例 const scale = ((matB.cols / matA.cols) + (matB.rows / matA.rows)) / 2; // 从图A切出模板,并按比例缩放 let rawTempl = matA.roi(new cv.Rect( Math.round(cropRect.x), Math.round(cropRect.y), Math.round(cropRect.w), Math.round(cropRect.h) )); let templ = new cv.Mat(); cv.resize(rawTempl, templ, new cv.Size( Math.max(1, Math.round(cropRect.w * scale)), Math.max(1, Math.round(cropRect.h * scale)) ), 0, 0, cv.INTER_LINEAR ); rawTempl.delete(); // 在图B中计算模板的理论位置,加 padding 限定搜索 ROI const mappedX = cropRect.x * scale; const mappedY = cropRect.y * scale; const roiX = Math.max(0, Math.round(mappedX - padding)); const roiY = Math.max(0, Math.round(mappedY - padding)); const roiW = Math.min(matB.cols - roiX, templ.cols + padding * 2); const roiH = Math.min(matB.rows - roiY, templ.rows + padding * 2); let roi = matB.roi(new cv.Rect(roiX, roiY, roiW, roiH)); // 模板匹配(TM_SQDIFF_NORMED 取最小值) let resultMat = new cv.Mat(); let mask = new cv.Mat(); cv.matchTemplate(roi, templ, resultMat, cv.TM_SQDIFF_NORMED, mask); const result = cv.minMaxLoc(resultMat); // 局部坐标转全图坐标 const finalX = result.minLoc.x + roiX; const finalY = result.minLoc.y + roiY; // 清理 Mat,避免内存泄漏 roi.delete(); resultMat.delete(); mask.delete(); templ.delete(); matA.delete(); matB.delete(); return { x: finalX, y: finalY, width: templ.cols, height: templ.rows, score: result.minVal, // 越小越匹配 scale }; }Canvas 框选实现 let isDragging = false; let startX, startY;canvas.addEventListener("mousedown", e => { isDragging = true; const rect = canvas.getBoundingClientRect(); startX = e.clientX - rect.left; startY = e.clientY - rect.top; });canvas.addEventListener("mousemove", e => { if (!isDragging) return; const rect = canvas.getBoundingClientRect(); const curX = e.clientX - rect.left; const curY = e.clientY - rect.top; // 绘制选择框预览 drawSelectionBox(startX, startY, curX - startX, curY - startY); });canvas.addEventListener("mouseup", e => { isDragging = false; const rect = canvas.getBoundingClientRect(); const endX = e.clientX - rect.left; const endY = e.clientY - rect.top; cropRect = { x: Math.min(startX, endX), y: Math.min(startY, endY), w: Math.abs(endX - startX), h: Math.abs(endY - startY) }; });canvas 显示尺寸和图像实际尺寸可能不同(canvas.width vs CSS 宽度),传给 matchCanvasRegion 前需换算: const scaleX = canvas.width / canvas.offsetWidth; const scaleY = canvas.height / canvas.offsetHeight; const realCrop = { x: cropRect.x * scaleX, y: cropRect.y * scaleY, w: cropRect.w * scaleX, h: cropRect.h * scaleY };关键设计点 为什么用 TM_SQDIFF_NORMED:该方法返回差异值(越小越匹配),对光照变化比 TM_CCOEFF_NORMED 稍宽容,且结果在 0~1 之间便于设置阈值。 为什么限定 ROI 搜索:全图 matchTemplate 在高分辨率图上耗时长。图 A 和图 B 通常是同一场景的不同分辨率版本,目标位置按比例平移,padding 150px 足以覆盖拍摄角度差异。 内存管理:OpenCV.js 是 WebAssembly,new cv.Mat() 分配的内存不受 JS GC 管理,每次使用后必须手动 .delete(),否则会逐渐 OOM。

动态加载 OpenCV.js:createElement 与油猴 @require 的区别

createElement 动态加载 把原来的 <script> 标签写法: <script async src="https://docs.opencv.org/4.x/opencv.js" onload="onOpenCvReady();" type="text/javascript"></script>转成 JS 动态创建: const script = document.createElement("script"); script.src = "https://docs.opencv.org/4.x/opencv.js"; script.async = true; script.onload = () => { onOpenCvReady(); }; document.head.appendChild(script);避免重复加载 if (!window.cv) { const script = document.createElement("script"); script.src = "https://docs.opencv.org/4.x/opencv.js"; script.async = true; script.onload = () => { console.log("OpenCV loaded"); onOpenCvReady(); }; document.head.appendChild(script); } else { onOpenCvReady(); }onload 不等于 OpenCV 初始化完成 script.onload 只表示 JS 文件下载解析完毕,但 OpenCV.js 内部有一个 WebAssembly 初始化过程,cv 对象此时可能还未就绪。 如果在 onload 里立刻调用 new cv.Mat() 会报错。正确做法是等 cv.onRuntimeInitialized: script.onload = () => { cv["onRuntimeInitialized"] = () => { // 这里才能安全使用 cv API const mat = new cv.Mat(); console.log("OpenCV Ready"); }; };封装成 Promise function loadOpenCV() { return new Promise((resolve, reject) => { if (window.cv) { resolve(window.cv); return; } const script = document.createElement("script"); script.src = "https://docs.opencv.org/4.x/opencv.js"; script.async = true; script.onload = () => { cv["onRuntimeInitialized"] = () => { resolve(cv); }; }; script.onerror = reject; document.head.appendChild(script); }); }loadOpenCV().then((cv) => { console.log("Ready"); const mat = new cv.Mat(100, 100, cv.CV_8UC3); console.log(mat); });油猴脚本:@require 绕过 CSP 有些网页设置了 Content-Security-Policy(CSP),会阻止加载非白名单来源的外部脚本。这时用 createElement 注入 script 会直接被浏览器拦截。 油猴的 @require 是在油猴自身的沙箱里加载,不经过页面的 CSP,可以绕过这个限制: // ==UserScript== // @name Load OpenCV // @namespace http://tampermonkey.net/ // @version 1.0 // @match *://*/* // @require https://docs.opencv.org/4.x/opencv.js // @grant none // ==/UserScript==(function () { 'use strict'; cv.onRuntimeInitialized = () => { console.log('OpenCV Ready'); const mat = new cv.Mat(100, 100, cv.CV_8UC3); console.log(mat.rows, mat.cols); }; })();@require 里的脚本在油猴加载阶段执行,cv 全局变量在脚本运行时已经存在。 两种方式对比方式 适用场景 CSP 限制createElement + onload 普通页面、自己控制的网页 受页面 CSP 限制@require 油猴脚本 不受页面 CSP 影响如果网页没有 CSP,两种都能用,@require 写法更简洁;有 CSP 时只能用 @require。

浏览器里动态加载 opencv.js:等 onRuntimeInitialized

想在网页里用 OpenCV.js 做图像处理,标准写法是: <script async src="https://docs.opencv.org/4.x/opencv.js" onload="onOpenCvReady();"></script>想改成 JS 动态加载(比如按需加载、或者写扩展 / 油猴脚本),有几个坑。 基础动态加载 const script = document.createElement("script"); script.src = "https://docs.opencv.org/4.x/opencv.js"; script.async = true; script.onload = () => onOpenCvReady(); document.head.appendChild(script);但这个 onload 触发时,OpenCV 还没准备好——window.cv 存在,但里面的 native 函数还没初始化(emscripten 编译的 WASM 需要额外时间)。 正确姿势:等 onRuntimeInitialized script.onload = () => { cv.onRuntimeInitialized = () => { console.log("OpenCV Ready"); onOpenCvReady(); }; };cv.onRuntimeInitialized 是 emscripten 的钩子,WASM 加载并 instantiate 完毕后调用。这时 cv.imread、cv.Mat 才能真正用。 Promise 化封装 function loadOpenCV() { return new Promise((resolve, reject) => { // 已加载过 if (window.cv && cv.Mat) return resolve(cv); // 加载中(多次调用不重复插 script) if (window.cv) { cv.onRuntimeInitialized = () => resolve(cv); return; } const script = document.createElement("script"); script.src = "https://docs.opencv.org/4.x/opencv.js"; script.async = true; script.onload = () => { cv.onRuntimeInitialized = () => resolve(cv); }; script.onerror = reject; document.head.appendChild(script); }); }// 用 loadOpenCV().then(cv => { const mat = new cv.Mat(100, 100, cv.CV_8UC3); console.log(mat); });关键点:if (window.cv && cv.Mat) — 双重判断,cv 存在不代表 ready 支持并发调用(多次 loadOpenCV() 只加载一次真正的 script) 错误传给 reject(网络挂了能捕获)油猴脚本里加载 油猴脚本里 document.createElement("script") 遇到有 CSP(Content-Security-Policy)的网站会失败——unsafe-inline 被禁的情况下,外链 script 加载被阻。 用 @require 声明,油猴会把脚本预先拉下来注入到你的沙箱里: // ==UserScript== // @name Load OpenCV // @match *://*/* // @require https://docs.opencv.org/4.x/opencv.js // @grant none // ==/UserScript==(function () { "use strict"; cv.onRuntimeInitialized = () => { console.log("OpenCV Ready"); const mat = new cv.Mat(100, 100, cv.CV_8UC3); console.log(mat); }; })();@require 走的是 Tampermonkey / Violentmonkey / ScriptCat 自己的加载机制,不走页面 CSP。这是给受 CSP 保护的网站注 OpenCV 最稳的方式。 常见问题 1. cv is not defined script 还没加载完就用 cv。用 Promise 版本,.then 里再用。 2. cv.imread is not a function onRuntimeInitialized 没等——script.onload 触发早于 runtime init。 3. 用了 async/await 但还是异步崩溃 外面 async 函数忘了 await: async function process() { const cv = await loadOpenCV(); // 别忘 await const mat = new cv.Mat(...); }4. 内存泄漏 OpenCV.js 的 Mat 是 WASM 分配的内存,必须手动 delete: const mat = cv.imread("input"); try { // 处理 mat } finally { mat.delete(); }不 delete,跑几张图页面就 OOM。 想省点带宽 opencv.js 从官方 CDN 拉大约 8MB。有几个选择:换镜像:https://cdn.jsdelivr.net/npm/@techstark/opencv-js@4.x.x/dist/opencv.js 裁剪版本:自己 build 只带需要的模块,webnn、videoio 之类不需要就砍 本地缓存:Service Worker 缓存 opencv.js,二次加载秒开 动态判断需求:只有用户点了"图像处理"按钮再加载第一次加载 8MB 是常态,别期望闪电般。 一句话总结 动态加载 opencv.js 要等 cv.onRuntimeInitialized,封成 Promise 用起来最顺。油猴脚本里遇到 CSP 就用 @require。用完 Mat 记得 delete()。

读懂 webpack 混淆代码:(0, fn)() 模式与 RxJS 链解析

(0, fn)(...) 是什么 这是 webpack / Babel 生成代码中常见的间接调用写法: (0, s.p)(args)等价于: const _fn = s.p; _fn(args); // 普通函数调用,this === undefined(严格模式)为什么不直接 s.p(args)? s.p(args) 会将 this 绑定为 s;逗号运算符写法 (0, s.p) 会把方法"脱离"对象,变成普通函数调用,this 不再指向 s。 这在以下场景有实际意义:s.p 是从模块导入的函数(如 rxjs.from),不应绑 this 打包工具为了兼容 ES Modules 的 strict mode 语义,确保 this === undefined Tree-shaking 友好:逗号写法对某些打包器更易分析常见形式: (0, rxjs.of)(1, 2, 3) // 等价 rxjs.of(1, 2, 3) (0, lodash.map)(arr, fn) // 等价 lodash.map(arr, fn) (0, react.createElement)(...) // 等价 react.createElement(...) (0, o.Z)(list) // 混淆后的模块导出函数读到这种代码,直接把 (0, expr) 去掉,剩下的 expr(...) 就是实际调用。 真实打包代码示例 下面是一段典型的混淆打包代码(来自实际页面逆向): (0, s.p)( (t = window.$).when.apply(t, (0, o.Z)(window.beforeSubmitQueue.map(function(e) { return e(); })) ) ).switchMap( window.MarkLib.submitMarkAnswer.bind(window.MarkLib, !0, e) ).filter(function(t) { var n = t.fire_status, r = void 0 === n || n; var a = t.reject, o = void 0 !== a && a; return !e && w(!1), r && !o; }).map(JSON.stringify.bind(JSON)).do(function(t) { return y({ type: "markpage/saveAnswer", payload: { isTemporary: 1, packetId: Number(p), ... } }); }).subscribe();逐步还原 第一步:去掉 (0, expr) 包装 s.p(...) // s.p 可能是 rxjs.from / rxjs.fromPromise o.Z(list) // o.Z 是某个导出函数,根据上下文判断第二步:提取 window 上的对象引用 const { $, beforeSubmitQueue, MarkLib } = window;第三步:还原 .apply 和 .bind // (t = window.$).when.apply(t, args) → $.when(...args) // fn.bind(obj, true, e) → () => obj.fn(true, e)第四步:改写旧版 RxJS API旧版 新版.do(fn) .tap(fn)fromPromise(p) from(p).map(fn) .map(fn) 不变还原结果: const { $, beforeSubmitQueue, MarkLib } = window;from($.when(...beforeSubmitQueue.map(fn => fn()))) .pipe( switchMap(() => MarkLib.submitMarkAnswer(true, e)), filter(({ fire_status = true, reject = false }) => { if (!e) w(false); return fire_status && !reject; }), map(JSON.stringify), tap(answer => { y({ type: "markpage/saveAnswer", payload: { isTemporary: 1, packetId: Number(p), pageId: Number(a), round: f, stage: k, answer: j(answer, c, u) }, callback() { if (!e) w(false); } }); }) ) .subscribe();逻辑说明 还原后的业务逻辑只有一句话:执行提交前钩子(beforeSubmitQueue)→ 提交答案(submitMarkAnswer)→ 过滤无效结果 → 序列化 → 保存到 Redux store关键点:$.when(...tasks):jQuery Deferred,等待所有 before-hooks 完成 switchMap:前一个 Observable 完成后切换到新的,丢弃之前未完成的 filter:fire_status 不为 false 且 reject 不为 true 才继续 .subscribe():没有 subscribe,RxJS 链不会执行(冷 Observable)其他常见混淆模式 // void 0 === undefined void 0 // → undefined// !! 转布尔 !!value // → Boolean(value)// 逗号运算符返回最后一个值 (a(), b(), c()) // 执行 a、b、c,返回 c 的值// 短路赋值 x = void 0 === x ? defaultVal : x // → x ?? defaultVal(现代写法)读打包代码时,把这些模式认出来后,理解代码逻辑会快很多。

JavaScript JSON 深层字符串替换:replaceAll 与递归遍历

常见写法 e = JSON.parse( JSON.stringify(e).replace( /https:\/\/old-domain\.obs\.cn-east-4\.example\.com:443/g, '/api' ) );先序列化为 JSON 字符串,全局替换目标子串,再反序列化回对象。适合简单的 URL 替换场景。 用 replaceAll 替代正则 对字面字符串使用正则容易漏掉 . 的转义(.com 写成 .com 会匹配任意字符),直接用 replaceAll 更安全: const target = 'https://old-domain.obs.cn-east-4.example.com:443';e = JSON.parse( JSON.stringify(e).replaceAll(target, '/api') );replaceAll 是纯字符串匹配,不会误伤相邻字符,可读性也更强。 JSON.stringify 会丢失特殊值 // 以下类型经 JSON.stringify 后会丢失或变形 new Date() // 变为 ISO 字符串,失去 Date 类型 new Map() // 变为 {} new Set() // 变为 {} undefined // 对象属性被整体删除 BigInt // 抛出 TypeError function // 被删除如果对象中包含这些类型,序列化 + 替换 + 反序列化会静默丢失数据。 推荐:递归遍历替换 function deepReplace(obj, from, to) { if (typeof obj === 'string') { return obj.replaceAll(from, to); } if (Array.isArray(obj)) { return obj.map(v => deepReplace(v, from, to)); } if (obj && typeof obj === 'object') { for (const k in obj) { obj[k] = deepReplace(obj[k], from, to); } } return obj; }deepReplace(e, 'https://old-domain.com', '/api');不走序列化路径,Date / Map / Set / 原型链全部保留,也适合在 hook 网络返回数据时做在线 URL 替换。 适用场景对比场景 推荐方式简单纯字符串对象 JSON.parse(JSON.stringify(e).replaceAll(...))含 Date / Map / Set 的对象 deepReplace() 递归遍历只替换字面字符串(无转义歧义) replaceAll 替代正则hook 网络数据在线修改 deepReplace()

version.json 结构设计:客户端更新检查和热更新

version.json 没有统一标准,但根据用途不同有一套通用的最佳实践字段设计。 最简结构 { "version": "1.0.0" }适合油猴脚本、小工具。只做版本对比时够用。 推荐通用结构 { "version": "1.2.3", "build": 10203, "name": "MyApp", "releaseDate": "2026-05-16T10:00:00Z", "description": "修复登录问题和性能优化", "forceUpdate": false, "downloadUrl": "https://cdn.example.com/app-1.2.3.zip", "sha256": "a1b2c3d4..." }字段 作用version 语义化版本号,方便理解build 数字版本,方便代码比较(newBuild > currentBuild)forceUpdate 是否强制更新(旧版本无法继续使用)sha256 文件校验,防止下载被篡改downloadUrl 客户端直接取用,不用硬编码Electron / 桌面应用更新 { "version": "2.1.0", "minimumVersion": "2.0.0", "forceUpdate": true, "url": "https://cdn.example.com/app-2.1.0.exe", "sha256": "xxxxx", "size": 104857600, "releaseNotes": [ "新增插件系统", "修复内存泄漏问题" ] }minimumVersion 表示低于此版本的客户端必须强制升级,配合 forceUpdate: true 使用。size 可用于计算下载进度。 前端静态资源版本控制 { "version": "2026.05.16", "commit": "8f3ab21", "buildTime": 1747372000, "assets": { "app.js": "a1b2c3", "style.css": "d4e5f6" } }前端通过定期 fetch('/version.json') 检测资源 hash 变化,自动提示用户刷新。commit 和 buildTime 方便追踪具体是哪次 CI 构建。 油猴脚本自动更新 { "version": "1.0.5", "meta": "https://example.com/script.meta.js", "script": "https://example.com/script.user.js" }脚本内: GM_xmlhttpRequest({ url: "https://example.com/version.json", onload(res) { const remote = JSON.parse(res.response); if (remote.version > GM_info.script.version) { GM_notification("有新版本 " + remote.version); } } });远程配置 + Feature Flags { "version": "1.3.0", "env": "production", "api": "https://api.example.com", "featureFlags": { "newUI": true, "betaFeature": false } }这已经接近"远程配置中心",避免了把 API 地址硬编码进客户端。 版本号规范建议 推荐 语义化版本:变更类型 示例不兼容的 API 变更 2.0.0新增功能(向下兼容) 1.3.0Bug 修复 1.3.1避免用纯数字 "version": 15——后期无法区分是大版本还是补丁。 Python 读写 version.json import json# 读取 with open("version.json", "r", encoding="utf-8") as f: data = json.load(f)# 修改后写回(覆盖) data["version"] = "1.2.4" with open("version.json", "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2)不要用 open("file", "rw") — Python 不支持 "rw" 模式,会报 ValueError: must have exactly one of create/read/write/append mode。读写合一用 "r+" 加 f.seek(0) + f.truncate(),但最简单的方案是分开读和写。

Effect:TypeScript 函数式副作用框架,类型安全的异步与错误处理

Effect 是一个偏函数式(FP)的 TypeScript 框架,类似 Scala 生态的 ZIO,核心目标是用类型系统统一管理:副作用、错误处理、依赖注入和并发控制。 核心类型:Effect<A, Error, R> Effect.Effect<User, NotFoundError, Database>三个泛型参数分别是:A — 成功时的返回值类型 Error — 可能抛出的错误类型(编译期已知) R — 运行时需要注入的依赖环境相比普通 async/await,错误类型不再是 unknown,依赖关系从运行时隐式变为类型系统显式。 四个核心能力 类型安全的错误处理 // 传统方式:不知道会抛什么 try { await fetchUser(id); } catch (e) { }// Effect 方式:错误类型在签名里 const fetchUser = (id: string): Effect.Effect<User, NotFoundError | DbError, Database> => Effect.gen(function* () { const db = yield* Database; return yield* db.findUser(id); });依赖注入 const Database = Context.Tag<Database>("Database");// 运行时注入具体实现 Effect.runPromise( program.pipe( Effect.provide(DatabaseLive) ) );并发控制 // 限制并发数为 5 await Effect.runPromise( Effect.all(tasks, { concurrency: 5 }) );内置 fiber(轻量协程)、timeout、retry、race、cancellation,都是一等公民。 Resource 管理(自动释放) const withDb = Effect.acquireRelease( Effect.sync(() => new DbConnection()), (conn) => Effect.sync(() => conn.close()) );不用手写 try/finally,连接/文件/WebSocket 用完自动关闭。 基础用法示例 import { Effect, pipe } from "effect";const program = pipe( Effect.succeed(1), Effect.map(n => n + 1), Effect.flatMap(n => Effect.succeed(n * 2)) );Effect.runPromise(program).then(console.log); // 4Generator 风格(更接近 async/await): const program = Effect.gen(function* () { const a = yield* Effect.succeed(1); const b = yield* Effect.succeed(a + 1); return b * 2; });与其他框架对比框架 风格 适合场景Express / Hono 极简 快速 APINestJS OOP + DI 企业级 WebRxJS 响应式流 事件流处理Effect FP + 类型系统 复杂异步系统适合的场景 Effect 的学习曲线较陡(大量 pipe、flatMap、generator),不适合小型项目。适合:大型 Node.js 后端服务 AI Agent / 工作流调度系统 微服务(高并发 + 复杂错误处理) CLI 框架 爬虫调度核心标准:"复杂异步 + 大量错误路径"——这正是 Effect 最能体现价值的地方。如果只是 await fetch(),用 Promise 就够了。

JS 模板字符串里包含反引号:三种不转义方案

模板字符串内部出现反引号时,必须转义成 \`。内容量大或来自外部时,转义很烦。 方案一:数组 join(最稳) 完全不需要考虑转义,任意字符都行: const sql = [ "SELECT * FROM `users`", "WHERE `status` = 'active'", "AND `name` LIKE `%test%`", ].join('\n');大段 HTML、SQL、Prompt 推荐这种写法——可读性好,又不怕特殊字符。 方案二:String.raw(不处理转义序列) const pattern = String.raw`\d+\.\d+`; // 等价于 "\\d+\\.\\d+" // 不需要手动双反斜杠在 String.raw 里,反斜杠不被解释为转义符,但反引号仍然需要转义,所以它主要解决的是 \n、\t 这类转义序列问题,而不是反引号嵌套问题。 const py = String.raw` print("hello") # 反引号仍需 \` `;方案三:外部文件(大段文本首选) // Vite / webpack 环境 import template from './prompt.txt?raw'; import html from './template.html?raw';// Node.js 环境 const template = fs.readFileSync('./prompt.txt', 'utf8');AI Prompt、HTML 模板、大段代码存外部文件,完全不需要处理引号或转义问题,也方便单独编辑和版本追踪。 油猴脚本里 hook window 属性 油猴脚本里经常需要拦截页面的某个全局变量或 API,Object.defineProperty 比整体 Proxy 更可靠: // hook 单个属性 const rawFetch = window.fetch;Object.defineProperty(window, 'fetch', { configurable: true, get() { return function(...args) { console.log('fetch called:', args[0]); return rawFetch.apply(this, args); }; } });整体 new Proxy(window, ...) 只是创建了一个代理对象,不会改变其他代码里访问的真实 window,所以通常没有实际效果。Object.defineProperty 是直接修改全局对象,才能真正拦截到。 hook document.cookie const cookieSetter = Document.prototype.__lookupSetter__('cookie'); const cookieGetter = Document.prototype.__lookupGetter__('cookie');Object.defineProperty(Document.prototype, 'cookie', { configurable: true, get() { return cookieGetter.call(this); }, set(val) { console.log('cookie set:', val); cookieSetter.call(this, val); } });安全读取 cookie 值 正则匹配 cookie 时,结尾分号可能没有(最后一项),用 [^;]* 比 .*?; 更稳: function getCookie(name) { const match = document.cookie.match( new RegExp(`${name}=([^;]*)`) ); return match ? match[1] : undefined; }[^;]* 含义:匹配零个或多个不是分号的字符,不管 cookie 是不是最后一项都能正确提取。

JS 模板字符串含反引号的处理与 Object.defineProperty Hook window 属性

模板字符串里包含反引号 反引号是模板字符串的开始/结束符,中间再出现反引号会直接语法错误: // ❌ 语法错误 ` asdasd`` `方案一:转义(最标准) const str = `asd\`asd`;方案二:String.raw(不处理转义) const str = String.raw`asd\`asd`;String.raw 是标签模板,适合存放正则、shell 命令、SQL、Prompt 等原始文本,不会解释 \n、\t 等转义序列。 方案三:数组 join(不用转义,推荐) const s = [ '`', 'asdasd``', '`' ].join('\n');大型项目里代码生成器常用这种方式,完全不需要考虑转义问题,任意字符都行。 方案四:外部文件 内容包含大量反引号、${} 或换行的情况,最推荐直接存外部文件: // Vite / 现代构建工具 import txt from './prompt.txt?raw';// Node.js const txt = fs.readFileSync('./prompt.txt', 'utf8');AI prompt、HTML 模板、大段 SQL 都适合这种做法。 unsafeWindow 与 Proxy 油猴脚本里直接 window = unsafeWindow 会在某些环境报错,推荐用 Proxy: const windowProxy = new Proxy(unsafeWindow, { get(target, prop) { return target[prop]; }, set(target, prop, value) { target[prop] = value; return true; } });关键限制:new Proxy(window) 只影响局部变量 windowProxy,其他代码里的 window.xxx 仍然访问原生 window,不会走代理。真正要"全局 hook"必须用 Object.defineProperty。 Object.defineProperty Hook window 属性 这是浏览器里最常用的 hook 方式,直接修改全局对象的属性描述符: 监听自定义属性的 get/set let value;Object.defineProperty(window, 'myToken', { configurable: true, get() { console.log('GET myToken'); return value; }, set(v) { console.log('SET myToken', v); value = v; } });其他脚本只要访问 window.myToken 就会触发上面的拦截器。 Hook fetch const rawFetch = window.fetch;Object.defineProperty(window, 'fetch', { configurable: true, get() { return function (...args) { console.log('fetch called:', args[0]); return rawFetch.apply(this, args); }; } });Hook document.cookie const rawSetter = Document.prototype.__lookupSetter__('cookie');Document.prototype.__defineSetter__('cookie', function (val) { console.log('cookie set:', val); rawSetter.call(this, val); });通用 hook 工具函数 function hookGlobal(name) { let value; Object.defineProperty(window, name, { configurable: true, get() { console.log('GET', name); return value; }, set(v) { console.log('SET', name, v); value = v; } }); }hookGlobal('abc');操作前先检查 configurable 部分内置属性(location、top、self、document)不可重定义,直接操作会抛错: const desc = Object.getOwnPropertyDescriptor(window, 'fetch'); console.log(desc.configurable, desc.writable);configurable: true 才能用 defineProperty 重新定义;writable: true 才能直接赋值覆盖。 Proxy vs Object.defineProperty 选哪个场景 推荐方式Hook 单个全局 API(fetch、XHR) Object.defineProperty局部作用域内代理对象 Proxy监控任意属性读写 Proxy(但不适用于真正的 window)跨越沙箱访问页面 window unsafeWindow + Proxy 或 script 注入整体代理 window 在浏览器里基本行不通,原生 window 是宿主对象,有大量不可配置属性。工程上的最佳实践是 hook 关键 API(fetch、XMLHttpRequest、WebSocket、JSON.parse)而不是整体代理。

JavaScript 模板字符串进阶:反引号转义、String.raw、标签模板

反引号转义 模板字符串用反引号包裹,内部出现反引号需要转义: // 错误:会产生语法错误 const str = `hello `world``;// 正确:用反斜杠转义 const str = `hello \`world\``; // 输出:hello `world`String.raw:禁止转义处理 String.raw 是内置标签函数,使反斜杠不被解释为转义字符: // 普通模板:\n 被处理为换行 const a = `line1\nline2`;// String.raw:\n 保持原样 const b = String.raw`line1\nline2`; // 输出:line1\nline2(字面量)适合场景: // Windows 路径(不用写 \\) const path = String.raw`C:\Users\admin\Desktop`;// 正则(不用双重转义) const pattern = new RegExp(String.raw`\d{4}-\d{2}-\d{2}`);// Prompt / Python 代码片段 const prompt = String.raw` 请用 Python 处理:print("hello\nworld") `;数组 join:最稳的多行字符串 当内容包含大量特殊字符时,数组 join 完全避免转义问题: const html = [ '<script>', 'console.log(`hello`)', '</script>' ].join('\n');缺点是没有插值语法,需要手动拼接变量。 标签模板(Tagged Template) 模板字符串前加函数名即为标签模板,函数接收字符串片段和插值变量: function highlight(strings, ...values) { return strings.reduce((result, str, i) => result + str + (values[i] !== undefined ? `<b>${values[i]}</b>` : ''), '' ); }const name = 'World'; const msg = highlight`Hello ${name}!`; // 输出:Hello <b>World</b>!标签模板的 strings.raw 属性包含未处理转义的原始字符串片段,这是 String.raw 的实现原理。 实际应用:库 用途styled-components CSS-in-JSgraphql-tag (gql) GraphQL 查询lit-html (html) Web Components 模板sql (SQL 防注入) 参数化查询外部文件替代超长模板 模板字符串不适合存放超长内容(HTML、SQL、Prompt)。更好的方式: // Node.js:读取外部文件 const template = fs.readFileSync('./templates/email.html', 'utf8');// Vite/Webpack:作为原始文本导入 import template from './templates/email.html?raw';规则:代码逻辑放 JS,内容放文件,不要混在一起。

Cloudflare Workers 开发指南:路由、反向代理、KV 存储和 TCP Socket

Cloudflare Workers 是部署在 Cloudflare 全球边缘节点上的无服务器运行环境,写 JavaScript/TypeScript 即可,无需管理服务器。 快速开始 npm create cloudflare@latest my-worker cd my-worker npm run dev # 本地调试 npm run deploy # 部署基础结构 export default { async fetch(request, env, ctx) { return new Response("hello world"); }, };路由处理 export default { async fetch(request) { const url = new URL(request.url); if (url.pathname === "/api/user") { return Response.json({ name: "soulock" }); } return new Response("404", { status: 404 }); }, };获取 query 参数: const name = url.searchParams.get("name");获取请求体: const data = await request.json();反向代理 export default { async fetch(request) { const url = new URL(request.url); const target = "https://example.com"; const targetUrl = new URL(url.pathname + url.search, target); const headers = new Headers(request.headers); headers.set("Host", targetUrl.host); const resp = await fetch(targetUrl.toString(), { method: request.method, headers, body: request.body, redirect: "follow", }); return new Response(resp.body, { status: resp.status, headers: resp.headers, }); }, };流式返回(AI 接口必须用这种写法): // 正确:保持流式 return new Response(resp.body, resp);// 错误:会阻塞到完整响应 const text = await resp.text();CORS 处理 function corsHeaders() { return { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "*", "Access-Control-Allow-Headers": "*", }; }export default { async fetch(request) { if (request.method === "OPTIONS") { return new Response(null, { headers: corsHeaders() }); } const resp = await fetch("https://example.com"); return new Response(resp.body, { status: resp.status, headers: { ...Object.fromEntries(resp.headers), ...corsHeaders() }, }); }, };环境变量 # wrangler.toml [vars] API_KEY = "xxx"代码里用 env.API_KEY。生产环境推荐用 secret(不写入代码): wrangler secret put API_KEYKV 存储 [[kv_namespaces]] binding = "MY_KV" id = "xxxx"await env.MY_KV.put("key", "value"); const value = await env.MY_KV.get("key");D1 数据库(SQLite) wrangler d1 create mydb[[d1_databases]] binding = "DB" database_name = "mydb" database_id = "xxxx"const result = await env.DB.prepare("SELECT * FROM users").all();定时任务 export default { async scheduled(event, env, ctx) { // 定时执行的逻辑 }, };[triggers] crons = ["*/5 * * * *"]TCP Socket Workers 支持主动发起 TCP 连接(不能监听 TCP): import { connect } from "cloudflare:sockets";const socket = connect({ hostname: "example.com", port: 80 }); const writer = socket.writable.getWriter(); await writer.write(new TextEncoder().encode("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")); writer.releaseLock(); return new Response(socket.readable);常见用途:WebSocket → TCP 隧道,代理 Redis、PostgreSQL 等 TCP 协议。 Workers 不能替代大流量 TCP 服务(有连接数和执行时间限制),适合轻量中转。 推荐:Hono 框架 npm i honoimport { Hono } from "hono";const app = new Hono();app.get("/", (c) => c.text("hello")); app.get("/user", (c) => c.json({ name: "soulock" }));// 反向代理 app.all("*", async (c) => { const url = new URL(c.req.url); const target = "https://example.com" + url.pathname + url.search; const resp = await fetch(target, { method: c.req.method, headers: c.req.raw.headers, body: c.req.raw.body }); return new Response(resp.body, resp); });export default app;适合与不适合的场景适合 不适合API 网关 / 鉴权 大型 CPU 运算AI 接口中转 ffmpeg / 视频编码Webhook / Bot Puppeteer反向代理 本地文件读写短链接 / 边缘缓存 高并发大流量 VPNWorkers 没有 fs、child_process、net 等 Node.js 原生模块,运行在沙盒环境中。

油猴脚本阻止页面脚本加载:hook appendChild 与 createElement

执行时机:document-start 油猴脚本的执行时机由 @run-at 控制,document-start 是最早的时机,在浏览器开始解析 HTML 前执行: // ==UserScript== // @name Block Scripts // @match *://*/* // @run-at document-start // ==/UserScript==此时 document.body 还不存在,但 DOM API 已可用,可以提前 hook 原型方法。 方案1:hook appendChild 拦截动态 script // ==UserScript== // @name Block Dynamic Scripts // @match *://example.com/* // @run-at document-start // ==/UserScript==(function () { const rawAppend = Element.prototype.appendChild; Element.prototype.appendChild = function (el) { if (el.tagName === 'SCRIPT') { const src = el.src || '(inline)'; console.log('[blocked]', src); // 返回 el 但不真正追加,阻止加载 return el; } return rawAppend.call(this, el); }; })();rawAppend 保存原始方法,避免无限递归。只拦截 SCRIPT 标签,其他元素正常追加。 方案2:hook createElement,在 src 赋值时拦截 部分页面通过 createElement('script') 创建后再设置 src: const rawCreate = document.createElement.bind(document);document.createElement = function (tag) { const el = rawCreate(tag); if (tag === 'script') { Object.defineProperty(el, 'src', { configurable: true, set(v) { if (v.includes('ads.example.com')) { console.log('[blocked src]', v); return; } el.setAttribute('src', v); }, get() { return el.getAttribute('src') || ''; } }); } return el; };Object.defineProperty 劫持 src 的 setter,在 src 被赋值时决定是否放行。 方案3:MutationObserver 监控已插入的 script 上面两种方案针对动态插入。对于 HTML 中静态的 <script> 标签,可以用 MutationObserver 在插入前禁用: const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.tagName === 'SCRIPT' && node.src.includes('tracker.js')) { node.type = 'text/blocked'; // 改掉 type,浏览器不执行 console.log('[neutralized]', node.src); } } } });observer.observe(document.documentElement, { childList: true, subtree: true });将 type 改为非 JavaScript MIME 类型,浏览器不会执行该脚本但 DOM 中仍有该节点。 三种方案对比方案 拦截时机 适用场景hook appendChild 追加时 动态 document.body.appendChild(script)hook createElement src src 赋值时 el.src = '...' 模式MutationObserver 节点插入 DOM 后 任意方式插入,包括 innerHTML实际页面可能混合使用多种方式,建议组合 hook。 注意事项document-start 时 document.body 为 null,不要访问 DOM 元素 hook 原型方法影响所有调用,测试时先在控制台验证 浏览器扩展的 Content Script 比油猴更底层,如需更强的拦截能力可考虑开发扩展

Chrome CDP 远程调试:--remote-debugging-port 与 Node.js Hook 框架

CDP 远程调试 启动浏览器并开放调试端口 # Chrome chrome.exe --remote-debugging-port=9222# Edge(建议指定独立 user-data-dir) msedge.exe --remote-debugging-port=9222 --user-data-dir=D:\edge_debug获取 WebSocket 调试地址 浏览器启动后,访问: http://127.0.0.1:9222/json返回当前所有 Tab 的信息,其中 webSocketDebuggerUrl 是关键字段: { "id": "ABC123", "title": "test page", "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/ABC123" }连接 DevTools 前端 devtools://devtools/bundled/devtools_app.html?ws=127.0.0.1:9222/devtools/page/ABC123整体链路:--remote-debugging-port 开放 CDP 端口 → /json 获取 ws 地址 → DevTools 前端通过 ws 接入。 Node.js Hook 三件套 在 Node 程序里注入以下 hook 可以拦截运行时数据流。 Hook JSON.parse(记录解析数据) const fs = require('fs'); const _parse = JSON.parse;JSON.parse = function (text, reviver) { const result = _parse.call(this, text, reviver); // 只保存含 "raw" key 的对象 if (result && typeof result === 'object' && 'raw' in result) { fs.appendFileSync('./json_log.txt', JSON.stringify({ time: Date.now(), data: result }) + '\n'); } return result; };过滤条件也可以改为 /"raw"\s*:/.test(text) 在原始字符串层面筛选,减少反序列化开销。 Hook TextDecoder.decode(截获二进制转字符串) const { TextDecoder } = require('util'); const _decode = TextDecoder.prototype.decode;TextDecoder.prototype.decode = function (input, options) { const result = _decode.call(this, input, options); console.log('[TextDecoder]', result); return result; };适合抓 WebSocket 二进制帧、CDP 协议数据等场景。注意 Buffer.toString('utf-8') 走的是另一条路径,需要单独 hook Buffer.prototype.toString。 Hook AES 加密(抓密钥与明文) const crypto = require('crypto'); const _createCipheriv = crypto.createCipheriv;crypto.createCipheriv = function (algorithm, key, iv, options) { console.log('[AES key]', { algorithm, key: key?.toString?.('hex'), iv: iv?.toString?.('hex') }); const cipher = _createCipheriv.call(this, algorithm, key, iv, options); const _update = cipher.update; cipher.update = function (data) { console.log('[AES plaintext]', data?.toString?.() ?? data); return _update.apply(this, arguments); }; return cipher; };Hook createDecipheriv 的结构完全对称,可以抓解密后的明文。 注意事项JSON.parse 被局部引用绕过时(const p = JSON.parse; p(...)),改 global.JSON.parse 更彻底 大流量场景建议加过滤条件(text.length < 5000)避免日志刷屏 Node.js 版本 >= 11 才有全局 TextDecoder,旧版用 require('util').TextDecoder

Edge/Chrome CDP 远程调试与 Node.js Hook TextDecoder、JSON、AES

Edge/Chrome CDP 远程调试 1. 启动浏览器开启调试端口 # Edge msedge.exe --remote-debugging-port=9222 --user-data-dir=D:\edge_debug# Chrome chrome.exe --remote-debugging-port=92222. 获取 WebSocket 调试地址 访问: http://127.0.0.1:9222/json返回 JSON,找 webSocketDebuggerUrl: { "title": "test page", "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/ABC123" }3. 用 DevTools 前端连接 把 ws 地址传给 DevTools 前端: devtools://devtools/bundled/devtools_app.html?ws=127.0.0.1:9222/devtools/page/ABC123在 Edge/Chrome 地址栏直接输入即可,打开完整的开发者工具界面。 核心结构: 浏览器(Edge/Chrome) ↓ WebSocket (CDP) DevTools Frontend(网页 UI)Node.js TextDecoder 支持情况 Node.js ≥ 11 支持 TextDecoder,Node ≥ 16/18 更稳定: const { TextDecoder, TextEncoder } = require('util');const decoder = new TextDecoder('utf-8'); const buf = Buffer.from([0xe4, 0xbd, 0xa0]); // "你" console.log(decoder.decode(buf)); // 你Hook TextDecoder.decode 用于拦截 WebSocket / CDP 协议里的二进制转文本: const { TextDecoder } = require('util');const _decode = TextDecoder.prototype.decode;TextDecoder.prototype.decode = function (input, options) { const result = _decode.call(this, input, options); console.log('[TextDecoder.decode]', result); return result; };全局 hook(拦截所有实例): const orig = TextDecoder;global.TextDecoder = function (...args) { const instance = new orig(...args); const _decode = instance.decode; instance.decode = function (...params) { const res = _decode.apply(this, params); console.log('[WS decode]', res); return res; }; return instance; };Hook JSON.parse 并落盘 const fs = require('fs'); const _parse = JSON.parse;JSON.parse = function (text, reviver) { try { const result = _parse.call(this, text, reviver); // 只保存含 "raw" 字段的数据 if (result && typeof result === 'object' && 'raw' in result) { fs.appendFileSync( './json_raw_log.txt', JSON.stringify({ time: Date.now(), data: result }) + '\n' ); } return result; } catch (e) { return _parse.call(this, text, reviver); } };按关键字过滤避免刷屏: // 只记录长度 < 5000、包含关键字的 if (typeof text === 'string' && text.length < 5000 && text.includes('method')) { fs.appendFileSync('./log.txt', text + '\n'); }Hook Node.js AES 加密 Hook crypto.createCipheriv 抓加密前的明文、key、IV: const crypto = require('crypto'); const _createCipheriv = crypto.createCipheriv;crypto.createCipheriv = function (algorithm, key, iv, options) { console.log('[AES createCipheriv]', { algorithm, key: Buffer.isBuffer(key) ? key.toString('hex') : key, iv: Buffer.isBuffer(iv) ? iv.toString('hex') : iv }); const cipher = _createCipheriv.call(this, algorithm, key, iv, options); const _update = cipher.update; const _final = cipher.final; cipher.update = function (data, inputEncoding, outputEncoding) { console.log('[AES encrypt input]', data?.toString?.() ?? data); return _update.apply(this, arguments); }; cipher.final = function (outputEncoding) { const res = _final.apply(this, arguments); console.log('[AES encrypt output]', res); return res; }; return cipher; };解密 hook(对称): const _createDecipheriv = crypto.createDecipheriv;crypto.createDecipheriv = function (algorithm, key, iv) { const decipher = _createDecipheriv.call(this, algorithm, key, iv); const _update = decipher.update; decipher.update = function (data) { const res = _update.apply(this, arguments); console.log('[AES decrypt result]', res.toString()); return res; }; return decipher; };组合 hook(实战抓包) WebSocket + 加密接口逆向的标准三件套:TextDecoder.prototype.decode — 抓二进制转文本 JSON.parse — 抓接口返回 JSON crypto.createCipheriv/createDecipheriv — 抓 AES 明文和密钥部分代码直接用 Buffer.toString('utf-8') 而不经过 TextDecoder,还需要额外 hook: const _toString = Buffer.prototype.toString;Buffer.prototype.toString = function (encoding, ...rest) { const result = _toString.call(this, encoding, ...rest); if (encoding === 'utf8' || encoding === 'utf-8') { console.log('[Buffer.toString]', result.slice(0, 200)); } return result; };

SyntaxError: Unexpected identifier '目':文本被贴进 JS 文件

看到这个报错,几乎不用想: 题 目 AI辅助的全方位科研管理与创作平台 ^ SyntaxError: Unexpected identifier '目' at wrapSafe (node:internal/modules/cjs/loader:1637:18)Node 把这一行当代码解析了,而中文字符不是合法 JS 标识符,直接崩。 真正原因 八成是你想读的文件内容被复制粘贴到 JS 源码里了。你的 .js 文件现在长这样: const fs = require('fs') rd = fs.readFileSync("1.txt") console.log(rd.length)题 目 AI辅助的全方位科研管理与创作平台 // ← 这一行是裸文本裸文本既不是字符串(没引号)也不是注释(没 // 或 /* */),Node 只能当代码解析,然后崩在第一个中文字符上。 正确姿势 JS 文件里只留代码,文本内容留在 1.txt 里: // index.js const fs = require("fs");const text = fs.readFileSync("1.txt", "utf8"); console.log(text.length);# 1.txt 题 目 AI辅助的全方位科研管理与创作平台顺手补:readFileSync 不传 encoding 的坑 fs.readFileSync("1.txt") 返回的是 Buffer,不是字符串: const rd = fs.readFileSync("1.txt"); console.log(rd.length); // 字节数,中文会算错想拿字符长度就要传编码: const text = fs.readFileSync("1.txt", "utf8"); console.log(text.length); // 字符数 console.log(Buffer.byteLength(text, "utf8"));// 字节数一个中文字符在 UTF-8 里是 3 字节,字符长度和字节长度会差好几倍——涉及"长度"就要先想清楚要哪种。 触类旁通:类似的 SyntaxError 同样是"文件混了不该混的东西",报错方向不同:报错 通常原因Unexpected identifier '目' 中文文本被贴进 .jsUnexpected token '<' HTML 页面被当 JS 加载(一般是路径错)Unexpected token 'export' ESM 语法进了 CJS 文件(改 .mjs 或加 "type": "module")Unexpected token '?' / ?? / &&= 老 Node 不认新语法,升级 NodeInvalid or unexpected token 指向  文件带了 UTF-8 BOM,重存为无 BOM一句话总结 看到 Unexpected identifier '中文'——先打开源码文件,把混进去的文本挪出去。readFileSync 记得传 "utf8"。

Node.js SyntaxError: Unexpected identifier:非 ASCII 字符混入代码的排查

中文字符导致 SyntaxError SyntaxError: Unexpected identifier '目' at wrapSafe (node:internal/modules/cjs/loader:...)报错指向中文字符"目",不是代码写法问题,而是文本内容被误粘贴进了 JS 文件: // 错误:文本直接出现在代码里 const text = fs.readFileSync("1.txt", "utf8"); console.log(text.length);题 目 AI辅助的全方位科研管理与创作平台 // ← 这行是数据,不是代码最后一行是从 1.txt 复制过来的内容,Node 解析时把它当代码执行,"题"和"目"之间有空格被当作两个标识符。 修复方法:删除误粘贴的文本,JS 文件只保留代码逻辑。 readFileSync 的编码参数 // 不指定编码:返回 Buffer const buf = fs.readFileSync("1.txt"); console.log(buf.length); // 字节数(中文 UTF-8 每字 3 字节)// 指定 utf8:返回字符串 const text = fs.readFileSync("1.txt", "utf8"); console.log(text.length); // 字符数(JavaScript 的 UTF-16 码元数) console.log(Buffer.byteLength(text)); // UTF-8 字节数中文字符用 buf.length 统计结果是字节数(通常是字符数的 3 倍),必须指定 "utf8" 才能拿到字符串后再用 .length。 用 Map 统计数组重复项 Map 是计数/去重的高效结构: const arr = ['apple', 'banana', 'apple', 'cherry', 'banana', 'apple'];const counter = new Map(); for (const item of arr) { counter.set(item, (counter.get(item) ?? 0) + 1); } // Map { 'apple' => 3, 'banana' => 2, 'cherry' => 1 }找出出现超过 1 次的元素: const duplicates = [...counter.entries()] .filter(([, count]) => count > 1) .map(([key]) => key); // ['apple', 'banana']一遍遍历即可,时间复杂度 O(n),比嵌套 filter/indexOf 更高效。 去重(保留唯一值) 用 Set 更简洁: const unique = [...new Set(arr)]; // ['apple', 'banana', 'cherry']如果同时需要计数和去重,先建 Map 再从 Map 的 key 拿唯一值: const unique = [...counter.keys()];

JS 生成 UUID:crypto.randomUUID 与 uuid npm 包

crypto.randomUUID(现代标准方案) 浏览器和 Node.js 都内置支持: const id = crypto.randomUUID(); console.log(id); // "3f6c2a6f-8d7d-4f9c-b7d5-3fcb52c8a1aa"这是 UUID v4(随机),格式固定为 xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx。Chrome / Edge / Firefox 均支持 Node.js ≥ 19 直接用全局 crypto;Node.js 15–18 从 crypto 模块导入// Node.js 15-18 const { randomUUID } = require('crypto'); console.log(randomUUID());uuid npm 包 适合需要 v1 / v5 或更好兼容性的场景: npm install uuidimport { v4 as uuidv4 } from 'uuid';console.log(uuidv4()); // "110e8400-e29b-41d4-a716-446655440000"各版本:版本 特点 场景v1 时间 + MAC 地址 需要可排序的 IDv4 全随机 最常用,通用唯一 IDv5 命名空间 + hash 相同输入生成相同 ID老版本兼容写法 不能用 crypto.randomUUID 时的回退: function uuid() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' .replace(/[xy]/g, c => { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); }Math.random() 不是密码学安全随机数,高并发下理论上存在碰撞风险,生产环境优先用 crypto.randomUUID()。 短 ID(不是 UUID) 只需要一个不太长的唯一标识,不要求标准格式时: const id = Math.random().toString(36).slice(2); // "k9x2m8q"toString(36) 用 0–9 + a–z 36进制,slice(2) 去掉开头的 0.。这个不是 UUID,不保证唯一性,只适合临时标识(如 DOM id、session key)。 更长更安全的短 ID: const id = crypto.getRandomValues(new Uint8Array(8)) .reduce((acc, b) => acc + b.toString(16).padStart(2, '0'), ''); // "a3f2c1d9e4b70821"使用场景数据库主键:用 UUID v4,全随机防枚举 请求追踪:X-Request-ID header 用 crypto.randomUUID() 前端元素 key:用短 ID 即可 内容寻址:用 UUID v5,同 URL 生成相同 ID

logTimingOnce 是啥 + 前端性能计时的正规做法

有人问 logTimingOnce 是 JS 内置什么函数——不是。这个名字不是浏览器标准 API,也不在 Node 内置里。多半是:某个项目里自己定义的工具函数 某个框架 / SDK 内部封装 被压缩混淆后的名字从名字看是 log + Timing + Once——只记录一次耗时日志。列几种可能的实现和前端性能计时的正规做法。 猜测的实现 版本 1:一次性计时器 function logTimingOnce(name, fn) { const start = performance.now(); const result = fn(); const end = performance.now(); console.log(`${name}: ${(end - start).toFixed(2)}ms`); return result; }// 用法 const data = logTimingOnce("parseJSON", () => JSON.parse(bigStr));版本 2:同一 key 只打一次 const logged = new Set();function logTimingOnce(key, cost) { if (logged.has(key)) return; logged.add(key); console.log(`[timing] ${key}: ${cost}ms`); }// 用法 const start = performance.now(); initWebGL(); logTimingOnce("webgl-init", performance.now() - start); // 只打一次大型 SPA / 游戏 / WebGL / AI 前端里这种模式常见——首屏耗时、初始化时间只统计一次。 想知道具体项目里 logTimingOnce 是哪种,直接: console.log(logTimingOnce.toString());正规姿势:performance.now Date.now() 精度只到毫秒,且系统时钟被调整还会跳(NTP 校时、用户改时间)。计时永远用 performance.now(): const start = performance.now(); // ... 做事 const cost = performance.now() - start; console.log(cost); // 精度到微秒(0.001ms)单调时钟,不会倒退,浏览器和 Node 都支持。 User Timing API:让 DevTools 看得见 上面的 console.log 输出到控制台,看着乱。用 User Timing API 把标记打到 Performance 面板: performance.mark("webgl-init-start"); initWebGL(); performance.mark("webgl-init-end");performance.measure( "webgl-init", "webgl-init-start", "webgl-init-end" );之后 Chrome DevTools → Performance 面板里能看到 webgl-init 这条竖线,跟其它内置事件(LCP、FCP)在一起。 拿测量结果: const [entry] = performance.getEntriesByName("webgl-init"); console.log(entry.duration);PerformanceObserver:订阅性能事件 要监听所有 measure(比如上报到日志系统): const observer = new PerformanceObserver(list => { for (const entry of list.getEntries()) { console.log(entry.name, entry.duration); // 上报到你的日志服务 } }); observer.observe({ entryTypes: ["measure"] });也能监听浏览器内置事件: // LCP(Largest Contentful Paint) new PerformanceObserver(list => { const last = list.getEntries().pop(); console.log("LCP:", last.startTime); }).observe({ type: "largest-contentful-paint", buffered: true });常用性能指标一览指标 什么意思 怎么拿FCP (First Contentful Paint) 首次有内容渲染 paint entryTypeLCP (Largest Contentful Paint) 最大内容渲染 largest-contentful-paintFID (First Input Delay) / INP 首次 / 最长交互延迟 event、first-inputCLS (Cumulative Layout Shift) 布局偏移 layout-shiftTTFB (Time to First Byte) 首字节时间 navigation entryType想一次性拿 Web Vitals,直接用 Google 的库: npm install web-vitalsimport { onCLS, onLCP, onINP } from "web-vitals"; onCLS(console.log); onLCP(console.log); onINP(console.log);顺带:JS 生成 UUID 原对话里还问了 UUID,顺便说下——现代浏览器和 Node 直接用: crypto.randomUUID(); // "3f6c2a6f-8d7d-4f9c-b7d5-3fcb52c8a1aa"比自己写的 Math.random 版本安全(用 CSPRNG)、代码短。老浏览器不支持时才需要 polyfill。 一句话总结 logTimingOnce 是项目自定义函数,不是标准 API。真正测性能用 performance.now() 做基础,performance.mark/measure 让 DevTools 看得见,PerformanceObserver 采集上报。UUID 直接 crypto.randomUUID()。

为什么 forEach 里的 await 没用:JS 异步循环踩坑记

前几天写脚本,想在数组遍历里挨个 await 一下: const arr = [1, 2, 3];arr.forEach(async (item) => { await sleep(1000); console.log(item); });console.log('结束');预期看到 1 2 3 结束,结果输出是: 结束 1 2 3forEach 完全没等异步回调结束,console.log('结束') 就先跑了。 为什么 forEach 不等待 看 Array.prototype.forEach 的内部实现,本质就是: for (let i = 0; i < arr.length; i++) { callback(arr[i]); }它 不关心 callback 返回的是不是 Promise,也不收集 Promise,更不会 await。所以哪怕你写: await arr.forEach(async x => await fn(x));等价于: await undefined因为 forEach 的返回值就是 undefined。 正确写法 需要顺序 await:用 for...of 一个执行完再执行下一个,最稳、最好读: for (const item of arr) { await doSomething(item); }适合请求限流、数据库写入、有依赖关系的自动化脚本。 需要并发 await:用 Promise.all + map 全部同时开跑,最快: await Promise.all( arr.map(async (item) => { await doSomething(item); }) );注意别把上游接口打爆——需要限流的话可以配合 p-limit 这类库。 记忆表场景 写法顺序 await for...of并发 await Promise.all + map不需要等待 forEach 也无所谓一条经验 只要方法里出现 await,就别用 forEach。 想清楚这一步之后,很多"数据处理完了但结果不对"的 bug 就会消失。

JavaScript forEach 不支持 await:for...of 顺序执行与 Promise.all 并发执行

Array.prototype.forEach 不会等待异步回调完成,这是 JavaScript 里最常见的异步陷阱之一。 问题演示 const arr = [1, 2, 3];arr.forEach(async (item) => { await sleep(1000); console.log(item); });console.log('结束');输出顺序是: 结束 1 2 3forEach 本身是同步的,不关心回调返回的是否是 Promise,调用后立即继续执行。 await arr.forEach(...) 也没有意义,因为 forEach 的返回值始终是 undefined: await undefined // 无效等待顺序执行:用 for...of for (const item of arr) { await doSomething(item); }特点:一个完成后再执行下一个,顺序稳定。适合:限流的 API 请求(防止并发过多) 数据库写入(避免竞争) 依赖上一步结果的操作并发执行:用 Promise.all + map await Promise.all( arr.map(async (item) => { await doSomething(item); }) );特点:全部同时启动,等所有完成。适合独立任务批量执行,注意不要把接口打爆。 限制并发数量(每次最多 N 个): async function runConcurrent(arr, fn, concurrency = 5) { const results = []; for (let i = 0; i < arr.length; i += concurrency) { const batch = arr.slice(i, i + concurrency); results.push(...await Promise.all(batch.map(fn))); } return results; }选择参考场景 推荐写法顺序 await,依赖上一步 for...of全部并发,互不依赖 Promise.all + map不需要等待结果 forEach(此时没问题)requestIdleCallback:避免长循环阻塞主线程 如果是大量数据的 CPU 密集计算(如点云、坐标变换),不要在主线程单次执行,而是用 requestIdleCallback 分片处理: function processChunked(data, chunkSize = 1000) { return new Promise((resolve) => { let i = 0; const results = []; function step(deadline) { while (i < data.length && deadline.timeRemaining() > 0) { results.push(expensiveOp(data[i])); i++; } if (i < data.length) { requestIdleCallback(step); } else { resolve(results); } } requestIdleCallback(step); }); }特点:浏览器空闲时处理,不阻塞动画和用户交互。Safari 不支持 requestIdleCallback,可以用 setTimeout(fn, 0) 作为降级方案。 一句话总结 涉及 await 就别用 forEach,用 for...of(顺序)或 Promise.all(并发)。

document.cookie 设置有效期:max-age vs expires 与删除的坑

document.cookie 是一个字符串 API,"设置有效期"其实就是在 cookie 字符串里追加 expires 或 max-age 属性。 规则很简单:不写有效期 = 会话 cookie(浏览器关掉就没)、写了 = 持久化 cookie。 方式一:expires(老写法) 传一个 UTC 时间字符串: const d = new Date(); d.setTime(d.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 天document.cookie = `token=abc; expires=${d.toUTCString()}; path=/`;注意必须是 toUTCString(),用 toString() 或 toISOString() 会挂。 方式二:max-age(现代推荐) 直接指定"多少秒后过期": document.cookie = `token=abc; max-age=${7 * 24 * 60 * 60}; path=/`;单位是秒。max-age 更直观,也不用管时区。老到 IE 那一档才要考虑 expires。 删除 cookie 本质就是"立刻过期": document.cookie = "token=; max-age=0; path=/"; // 或 document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/";三个删不掉的坑 1. path 不一致 创建时用 path=/,删除也必须带上 path=/。不带就等于删的是当前路径下的 cookie,很可能和实际存放的 cookie 不是同一个。 2. domain 不一致 .example.com 和 sub.example.com 是两条不同的 cookie。删除时必须匹配写入时的 domain。 3. HttpOnly cookie 前端改不了 服务端设置了 HttpOnly 的 cookie,document.cookie 完全读不到、也删不掉。只能后端接口去清。 封装模板 前端项目里放一份省心: export function setCookie(name, value, days = 7, options = {}) { const d = new Date(); d.setTime(d.getTime() + days * 864e5); // 864e5 = 24*60*60*1000 const parts = [ `${name}=${encodeURIComponent(value)}`, `expires=${d.toUTCString()}`, `path=${options.path || "/"}`, ]; if (options.domain) parts.push(`domain=${options.domain}`); if (options.secure) parts.push("secure"); if (options.sameSite) parts.push(`samesite=${options.sameSite}`); document.cookie = parts.join("; "); }export function getCookie(name) { return document.cookie .split("; ") .find(row => row.startsWith(name + "=")) ?.split("=")[1]; }export function deleteCookie(name, options = {}) { setCookie(name, "", -1, options); // 复用同样的 path/domain }上生产别忘的属性 正经站点几乎必配的三个属性:Secure:只走 HTTPS SameSite=Lax(登录态用 Strict 更安全) HttpOnly(防 XSS 盗 token,只能后端 Set-Cookie 时写)前端能设的就 Secure 和 SameSite,HttpOnly 必须走服务端。 一句话总结 现代写法用 max-age、删除记得带 path=/、跨域跨子域记得对齐 domain。真安全靠 HttpOnly + Secure + SameSite,那部分归后端管。

document.cookie 有效期设置:expires 与 max-age 的区别和常见坑

document.cookie 通过在赋值字符串中附加 expires 或 max-age 属性来控制有效期。不写任何有效期,则默认为会话 Cookie,浏览器关闭后自动清除。 expires:指定过期时间点 const d = new Date(); d.setTime(d.getTime() + 7 * 24 * 60 * 60 * 1000); // 7天后document.cookie = "token=abc123; expires=" + d.toUTCString() + "; path=/";expires 的值必须是 UTC 格式时间字符串(toUTCString() 输出符合要求)。兼容性好,适合需要支持老浏览器的场景。 max-age:指定存活秒数 // 设置 7 天后过期 document.cookie = "token=abc123; max-age=" + (7 * 24 * 60 * 60) + "; path=/";// 设置 30 分钟后过期 document.cookie = "session=xyz; max-age=1800; path=/";max-age 单位是秒,语义更直观,是现代浏览器推荐用法。max-age 优先级高于 expires,两者同时存在时以 max-age 为准。 删除 Cookie 本质是将有效期设为过去: // 方法一:max-age=0 立即过期 document.cookie = "token=; max-age=0; path=/";// 方法二:expires 设为历史时间 document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/";常见坑 path 不一致无法删除 设置时用了 path=/,删除时也必须带上相同的 path: // 错误:path 不匹配,删除失败 document.cookie = "token=; max-age=0";// 正确 document.cookie = "token=; max-age=0; path=/";domain 不一致无法删除 .example.com 和 sub.example.com 是不同 domain,设置与删除必须一致。 HttpOnly Cookie 前端无权操作 带 HttpOnly 标志的 Cookie 在 document.cookie 中不可见,只能由服务端通过 Set-Cookie 响应头设置和清除。 实用封装 function setCookie(name, value, days) { const d = new Date(); d.setTime(d.getTime() + days * 24 * 60 * 60 * 1000); document.cookie = `${name}=${encodeURIComponent(value)}; expires=${d.toUTCString()}; path=/`; }function getCookie(name) { return document.cookie .split('; ') .find(row => row.startsWith(name + '=')) ?.split('=')[1]; }function deleteCookie(name) { document.cookie = `${name}=; max-age=0; path=/`; }使用示例: setCookie('token', 'abc123', 7); // 7天有效期 getCookie('token'); // 'abc123' deleteCookie('token');如果需要跨子域共享 Cookie,添加 domain=.example.com;HTTPS 环境下建议同时设置 Secure; SameSite=Strict。

油猴脚本虚拟文件输入 + FileReader 封装

在油猴脚本里读取本地文件,思路是:动态创建隐藏的 <input type="file">,触发点击,再用 FileReader 读取内容。 封装函数 /** * 选择文件并读取内容 * @param {Object} options * @param {string} [options.accept] - 文件类型限制,如 ".json,.txt" * @param {string} [options.readAs="text"] - text | dataURL | arrayBuffer * @param {string} [options.encoding="utf-8"] - 文本编码(仅 text 模式有效) * @returns {Promise<{file: File, content: any}>} */ function selectFileAndRead(options = {}) { const { accept = "", readAs = "text", encoding = "utf-8" } = options; return new Promise((resolve, reject) => { let input = document.getElementById("__file_input__"); if (!input) { input = document.createElement("input"); input.type = "file"; input.id = "__file_input__"; input.style.display = "none"; document.body.appendChild(input); } input.accept = accept; input.onchange = () => { const file = input.files[0]; if (!file) { reject(new Error("未选择文件")); return; } const reader = new FileReader(); reader.onload = (e) => resolve({ file, content: e.target.result }); reader.onerror = () => reject(new Error("读取失败")); switch (readAs) { case "dataURL": reader.readAsDataURL(file); break; case "arrayBuffer": reader.readAsArrayBuffer(file); break; default: reader.readAsText(file, encoding); } }; // 必须重置 value,否则选同一个文件不会触发 change 事件 input.value = ""; input.click(); }); }使用示例 读取文本文件 selectFileAndRead({ accept: ".txt,.json" }).then(({ file, content }) => { console.log(file.name, content); });读取 JSON 并解析 selectFileAndRead({ accept: ".json" }) .then(({ content }) => JSON.parse(content)) .then((data) => console.log("JSON 数据:", data)) .catch((err) => console.error(err));读取图片为 base64 selectFileAndRead({ accept: "image/*", readAs: "dataURL" }).then( ({ content }) => { const img = document.createElement("img"); img.src = content; document.body.appendChild(img); } );async/await 版 document.addEventListener("keydown", async (e) => { if (e.ctrlKey && e.key === "u") { try { const { file, content } = await selectFileAndRead({ accept: ".json" }); const config = JSON.parse(content); applyConfig(config); } catch (err) { console.error(err); } } });关键坑 浏览器安全限制:input.click() 必须在用户事件(点击、键盘)的同步调用链里触发,不能在 setTimeout 或 Promise 回调里调用,否则会被拦截。 同一文件无法重复选择:input.value = "" 在 click() 前必须执行,否则选同一个文件第二次不会触发 change 事件。 中文乱码:Windows 下生成的文件有时是 GBK 编码: selectFileAndRead({ encoding: "gbk" });DOM 污染:函数复用同一个 __file_input__ 元素,不会重复创建,避免 DOM 堆积导致的性能问题。 在油猴脚本中的完整示例 // ==UserScript== // @name 配置文件导入 // @namespace http://tampermonkey.net/ // @version 1.0 // @match https://example.com/* // @grant none // ==/UserScript==(function () { "use strict"; // 插入导入按钮 const btn = document.createElement("button"); btn.textContent = "导入配置"; btn.style.cssText = "position:fixed;top:10px;right:10px;z-index:9999"; document.body.appendChild(btn); btn.addEventListener("click", async () => { const { content } = await selectFileAndRead({ accept: ".json" }); const config = JSON.parse(content); console.log("已加载配置:", config); }); // ... selectFileAndRead 函数定义 ... })();

JS 动态注入 CSS 样式:setStyle 函数

在油猴脚本或 Chrome 扩展里需要动态修改页面样式时,直接写 element.style.xxx 只能改行内样式,无法用 CSS 选择器批量设置。更好的方案是动态创建 <style> 标签。 setStyle 函数 function setStyle(id, css) { let style = document.getElementById(id); if (!style) { style = document.createElement("style"); style.id = id; document.head.appendChild(style); } style.textContent = css; }用 id 做唯一标识——如果该 id 的 <style> 已存在就直接更新内容,不会重复插入。 使用示例 // 隐藏广告 setStyle("ad-blocker", ` .ad-banner, .sidebar-ad, [class*="advertisement"] { display: none !important; } `);// 强制暗色模式 setStyle("dark-mode", ` body { background: #1a1a1a !important; color: #e0e0e0 !important; } a { color: #7eb8f7 !important; } `);// 动态更新(复用同一个 style 元素) setStyle("custom-theme", `body { font-size: ${fontSize}px; }`);为什么不用 innerHTML // ❌ 安全风险:可能被 XSS 利用 style.innerHTML = css;// ✅ 推荐:textContent 不会解析 HTML style.textContent = css;textContent 只设置文本内容,不会解析 HTML 标签,比 innerHTML 更安全。 移除样式 function removeStyle(id) { const el = document.getElementById(id); if (el) el.remove(); }removeStyle("dark-mode");油猴脚本中的注意事项 油猴脚本默认在 document-end 注入,此时 document.head 已存在。如果设置了 @run-at document-start,需要等待 head 可用: // @run-at document-start function setStyle(id, css) { const target = document.head || document.documentElement; let style = document.getElementById(id); if (!style) { style = document.createElement("style"); style.id = id; target.appendChild(style); } style.textContent = css; }document.documentElement(即 <html>)在 document-start 阶段就已存在,可以作为 fallback。 GM_addStyle(油猴内置 API) Tampermonkey 提供了 GM_addStyle 作为快捷方式: // @grant GM_addStyle GM_addStyle(` .target-element { color: red; } `);不需要去重处理,但无法动态更新——每次调用都会新增一个 <style> 标签。频繁更新时还是用自定义的 setStyle。

JS 动态插入 style 标签与油猴虚拟 input 读取本地文件

动态创建 style 标签 基础写法 const style = document.createElement('style'); style.textContent = ` .my-class { color: red; font-size: 16px; } `; document.head.appendChild(style);缺点是多次调用会重复插入,页面上堆积大量 <style> 标签影响性能。 用 id 防重复插入 function addStyle(id, css) { if (document.getElementById(id)) return; const style = document.createElement('style'); style.id = id; style.textContent = css; document.head.appendChild(style); }addStyle('my-style', ` .box { background: #000; color: #fff; } `);适合油猴脚本或浏览器插件,确保只插入一次。 可覆盖更新(推荐) function setStyle(id, css) { let style = document.getElementById(id); if (!style) { style = document.createElement('style'); style.id = id; document.head.appendChild(style); } style.textContent = css; }已存在则覆盖 textContent,不堆积。 按规则粒度插入 const style = document.createElement('style'); document.head.appendChild(style);style.sheet.insertRule('.title { color: blue; }', 0);适合动态拼接规则的场景,比如根据条件添加不同的选择器。 油猴中直接用 GM_addStyle // @grant GM_addStyle GM_addStyle(` .my-class { color: green; } `);油猴环境推荐用内置 API,不需要手动操作 DOM。 注意事项style 建议放在 head 里,放 body 部分浏览器不会立即生效 频繁整块更新 textContent 会触发页面重排,CSS 内容大时注意 insertRule 插入的规则不会显示在开发者工具的 style 面板里(只在 Computed 里可见)油猴虚拟 input 读取本地文件 浏览器安全限制:文件选择框必须由用户主动触发(点击/按键),不能在页面加载时自动弹出。 封装成 Promise /** * @param {Object} options * @param {string} [options.accept] - 文件类型限制,如 ".json,.txt" * @param {string} [options.readAs] - "text" | "dataURL" | "arrayBuffer" * @param {string} [options.encoding] - 文本编码,默认 "utf-8" * @returns {Promise<{file: File, content: any}>} */ function selectFileAndRead(options = {}) { const { accept = '', readAs = 'text', encoding = 'utf-8' } = options; return new Promise((resolve, reject) => { let input = document.getElementById('__file_input__'); if (!input) { input = document.createElement('input'); input.type = 'file'; input.id = '__file_input__'; input.style.display = 'none'; document.body.appendChild(input); } input.accept = accept; input.onchange = () => { const file = input.files[0]; if (!file) { reject(new Error('未选择文件')); return; } const reader = new FileReader(); reader.onload = (e) => resolve({ file, content: e.target.result }); reader.onerror = () => reject(new Error('读取失败')); switch (readAs) { case 'dataURL': reader.readAsDataURL(file); break; case 'arrayBuffer': reader.readAsArrayBuffer(file); break; default: reader.readAsText(file, encoding); } }; input.value = ''; // 清空,否则同一文件第二次选不会触发 change input.click(); }); }使用示例 读取 JSON 文件: selectFileAndRead({ accept: '.json' }) .then(({ content }) => JSON.parse(content)) .then(data => console.log('解析结果:', data)) .catch(err => console.error(err));读取图片转 base64: selectFileAndRead({ accept: 'image/*', readAs: 'dataURL' }) .then(({ content }) => { const img = document.createElement('img'); img.src = content; document.body.appendChild(img); });Windows 文件中文乱码: selectFileAndRead({ accept: '.txt', encoding: 'gbk' }) .then(({ content }) => console.log(content));关键细节复用 input:用 id 缓存,避免多次点击在 DOM 里堆积隐藏元素 input.value = '':选同一个文件时 change 不会重触发,必须先清空 必须用户主动触发:把 selectFileAndRead() 放在按钮点击或快捷键回调里

在浏览器里 hook setInterval:原理和五种写法

在浏览器里"hook" 某个内置函数,本质就是——把原函数偷出来,用你自己的函数替换它。setInterval 是最常见的 hook 目标:调试疯狂轮询、监控页面定时器、干掉反调试轮询。 一句话原理 const _origin = window.setInterval;window.setInterval = function (fn, delay, ...args) { // 你的自定义逻辑 return _origin(fn, delay, ...args); // 回到原函数 };_origin 是原函数引用,window.setInterval 是你替换后的新函数。之后所有 setInterval(...) 调用都会先经过你。 场景一:监控所有调用 (function () { const _origin = window.setInterval; window.setInterval = function (fn, delay, ...args) { console.log("setInterval:", { delay, fn: fn.toString().slice(0, 80) }); return _origin(fn, delay, ...args); }; })();用途:找出页面里到底谁在挂定时器。 场景二:按条件拦截 只关心 1 秒轮询: window.setInterval = function (fn, delay, ...args) { if (delay === 1000) { console.trace("1s interval:", fn); } return _origin(fn, delay, ...args); };console.trace 直接打出调用栈,能追到是哪段代码挂的。 场景三:阻止高频轮询 页面每 50ms 疯狂检查某个东西(典型反调试或广告轮询),CPU 直接吃满: window.setInterval = function (fn, delay, ...args) { if (delay < 500) { console.log("阻止高频 interval:", delay); return null; // 直接返回 null,不真的开定时器 } return _origin(fn, delay, ...args); };场景四:记录所有 ID 方便统一清 (function () { const _origin = window.setInterval; const ids = new Set(); window.setInterval = function (fn, delay, ...args) { const id = _origin(fn, delay, ...args); ids.add(id); return id; }; window.__clearAll = () => { ids.forEach(clearInterval); ids.clear(); console.log("全部清除"); }; })();在 devtools 里手动 __clearAll() 就能一键把当前页面所有定时器停了。 场景五:油猴脚本里 hook 必须 @run-at document-start——否则页面脚本先跑,等你 hook 上去已经晚了: // ==UserScript== // @name hook setInterval // @match https://example.com/* // @run-at document-start // @grant none // ==/UserScript==(function () { const _origin = window.setInterval; window.setInterval = function (fn, delay, ...args) { console.log("intercepted:", delay); return _origin.call(window, fn, delay, ...args); }; })();注意用 _origin.call(window, ...)——某些浏览器的 setInterval 严格要求 this === window,不 bind 会抛 Illegal invocation。 常见反 hook 手法 页面可能这样绕过你: // 提前保存原始引用 const _real = window.setInterval.bind(window); _real(fn, 100);页面代码保存的是你的 hook 前的原函数,你就拦不到 或者 iframe 里跑,window 隔离 或者用 postMessage + Worker,压根不走主线程 setInterval对策:@run-at document-start 越早越好 同时 hook setTimeout / requestAnimationFrame——很多站点会混着用 iframe 里的定时器要另外注入用 Proxy 更隐蔽 上面的写法暴露痕迹(window.setInterval.toString() 会变),要更隐蔽: window.setInterval = new Proxy(window.setInterval, { apply(target, thisArg, args) { const [fn, delay] = args; console.log("intercepted:", delay); return Reflect.apply(target, thisArg, args); }, });Proxy 版本的 .toString() 还是原始的,页面用 toString().includes("[native code]") 检测更难发现。 一句话总结 hook = 存原函数引用 + 替换 window 属性。油猴里必须 run-at document-start,进阶用 Proxy 更隐蔽。同一套模板套 setTimeout / fetch / XMLHttpRequest 也照样能用。

JSC 文件分析:V8 字节码、bytenode 产物与字符串提取

JSC 文件的三种类型 .jsc 不是一种统一格式,常见有三类:类型 本质 能否反编译bytenode 生成 V8 字节码 基本不能还原源码JavaScriptCore(iOS/WebKit) JSCore 缓存 无稳定通用工具微信小程序 V8 字节码或混淆加密体 几乎不可能还原核心结论:JSC 是编译产物,不是源码压缩。变量名、结构、注释已丢失,不存在完美反编译。 View8 报错原因 RuntimeError: Failed to detect version for file index.jscView8 要先检测 V8 版本,失败通常是这几个原因:文件不是 V8 cache——bytenode 生成的 .jsc 结构与 V8 code cache 不同 .jsc 被加了 header、base64 或 zip 包装 V8 版本超出 View8 支持范围(通常只支持 8.x~11.x)用 Python 快速判断: with open("index.jsc", "rb") as f: print(f.read(32).hex())V8 code cache 开头有固定的 magic bytes。如果头部是乱码或压缩特征,View8 直接放弃。 Python 提取可读字符串 不管是哪种 JSC,都可以先提取二进制文件中的可读字符串,判断是否有逆向价值: import rewith open("index.jsc", "rb") as f: data = f.read()strings = re.findall(rb"[ -~]{6,}", data)seen = set() for s in strings: text = s.decode("utf-8", errors="ignore") if text not in seen: seen.add(text) print(text)正则 [ -~]{6,} 匹配长度 >= 6 的 ASCII 可读字符串。 结果判断:出现 /api/、token、userInfo 等 → 文件未加密,有分析价值 全是乱码 → 已加密或压缩,需换思路bytenode JSC 的运行时分析 bytenode 产物无法静态反编译,但可以在 Node.js 中加载后 hook: require("bytenode");// hook 所有函数调用 const originalCall = Function.prototype.call; Function.prototype.call = function(...args) { if (this.name) { console.log("[call]", this.name, args.slice(0, 2)); } return originalCall.apply(this, args); };require("./index.jsc");或者用 Node.js inspect 调试: node --inspect-brk -e "require('bytenode'); require('./index.jsc')"然后用 Chrome DevTools 连接 chrome://inspect,可以打断点、查看调用栈。 三种方案对比方案 成功率 适用场景Python strings 提取 高(未加密) 快速判断有无价值运行时 hook 高 bytenode 产物Node inspect 调试 高 有 require 入口静态 V8 字节码分析 低 仅作辅助对于加密 JSC(AES/RC4 加密,或微信小游戏自定义 VM),以上方法均不适用,需要先逆向解密逻辑。

JS 获取今天零点并前后偏移 N 小时:Date setHours 与时区格式化

获取今天零点 const now = new Date(); const midnight = new Date(now); midnight.setHours(0, 0, 0, 0);console.log(midnight); // Mon Apr 14 2026 00:00:00 GMT+0800setHours(0, 0, 0, 0) 四个参数分别是小时、分钟、秒、毫秒,全设为 0 就是当天零点(本地时间)。 往后推 N 小时 const now = new Date(); const start = new Date(now); start.setHours(0, 0, 0, 0);const N = 10000; const end = new Date(start.getTime() + N * 60 * 60 * 1000);console.log("开始:", start); console.log("结束:", end); // 10000 小时 ≈ 416 天 + 16 小时后往前推 N 小时 const before = new Date(start.getTime() - N * 60 * 60 * 1000);console.log("今天零点:", start); console.log("往前 10000 小时:", before); // 约 416 天前时区问题:toISOString() 是 UTC console.log(start.toISOString()); // "2026-04-13T16:00:00.000Z" ← UTC 时间,比北京时间少 8 小时toISOString() 始终输出 UTC 时间。中国时区(UTC+8)的零点在 UTC 是前一天 16:00,如果直接用这个输出给后端或用于显示会有偏差。 本地时间格式化: function formatLocal(date) { return [ date.getFullYear(), String(date.getMonth() + 1).padStart(2, "0"), String(date.getDate()).padStart(2, "0") ].join("-") + " " + [ String(date.getHours()).padStart(2, "0"), String(date.getMinutes()).padStart(2, "0"), String(date.getSeconds()).padStart(2, "0") ].join(":"); }console.log(formatLocal(start)); // "2026-04-14 00:00:00" console.log(formatLocal(end)); // "2027-06-30 16:00:00"生成时间序列 K 线拉取、回测等场景需要每小时一个时间点的数组: function generateHourlyRange(startDate, hours) { const step = 60 * 60 * 1000; const arr = []; for (let i = 0; i <= hours; i++) { arr.push(new Date(startDate.getTime() + i * step)); } return arr; }const start = new Date(); start.setHours(0, 0, 0, 0);const range = generateHourlyRange(start, 24); console.log(range.slice(0, 3).map(formatLocal)); // ["2026-04-14 00:00:00", "2026-04-14 01:00:00", "2026-04-14 02:00:00"]往前推的时间序列只需把 + i * step 改成 - i * step。 与后端接口对接 如果后端接受的是 Unix 时间戳(秒): const startTs = Math.floor(start.getTime() / 1000); const endTs = Math.floor(end.getTime() / 1000);fetch(`/api/kline?start=${startTs}&end=${endTs}`)如果后端接受 ISO 字符串(UTC): const params = new URLSearchParams({ start: start.toISOString(), end: end.toISOString() });两者都是基于 getTime() 的毫秒时间戳,不存在时区歧义,只是显示层面需要处理本地时区。

ScriptCat / Tampermonkey 用 GM_xmlhttpRequest 调用 AI 视觉接口

为什么必须用 GM_xmlhttpRequest 用户脚本环境里调用第三方 API,普通 fetch 会被浏览器 CORS 限制卡死。GM_xmlhttpRequest 是油猴环境提供的跨域请求接口,可以绕过这个限制: // ❌ 这样会报 CORS 错误 fetch("https://open.bigmodel.cn/api/paas/v4/chat/completions", {...})// ✅ 用 GM_xmlhttpRequest GM_xmlhttpRequest({ method: "POST", url: "https://open.bigmodel.cn/api/paas/v4/chat/completions", ... })脚本头部必须声明权限: // @grant GM_xmlhttpRequest完整示例:调用视觉模型分析页面图片 // ==UserScript== // @name AI 图片分析 // @namespace http://tampermonkey.net/ // @version 1.0 // @match *://*/* // @grant GM_xmlhttpRequest // ==/UserScript==(function () { const API_KEY = "your-api-key"; // 换成你的 key function getImagesFromPage() { const imgs = document.querySelectorAll("img"); return Array.from(imgs) .slice(0, 3) .map(img => ({ type: "image_url", image_url: { url: img.src || img.dataset.src // 处理懒加载 } })) .filter(item => item.image_url.url); } function requestAI(imageItems) { GM_xmlhttpRequest({ method: "POST", url: "https://open.bigmodel.cn/api/paas/v4/chat/completions", headers: { "Content-Type": "application/json", "Authorization": "Bearer " + API_KEY }, data: JSON.stringify({ model: "glm-4v", messages: [ { role: "user", content: [ ...imageItems, { type: "text", text: "请分析这些图片的内容" } ] } ] }), onload: function (res) { try { const data = JSON.parse(res.responseText); const answer = data?.choices?.[0]?.message?.content; console.log("AI 回答:", answer); alert(answer); } catch (e) { console.error("解析失败", e); } }, onerror: function (err) { console.error("请求失败", err); } }); } const images = getImagesFromPage(); if (images.length > 0) { requestAI(images); } })();图片来源处理 指定区域内的图片 function getQuestionImages() { const container = document.querySelector(".question-area"); if (!container) return []; return Array.from(container.querySelectorAll("img")).map(img => ({ type: "image_url", image_url: { url: new URL(img.getAttribute("src") || "", location.href).href } })); }new URL(src, location.href).href 可以把相对路径补全为绝对 URL。 canvas 图片转 base64 页面是 canvas 渲染(反爬、PDF 等)时,需要截图再传: function getCanvasImage() { const canvas = document.querySelector("canvas"); if (!canvas) return []; return [{ type: "image_url", image_url: { url: canvas.toDataURL("image/png") // data:image/png;base64,... } }]; }大多数视觉模型 API 同时支持 URL 和 base64 格式的图片。 常见问题 GM_xmlhttpRequest is not a function脚本头部没有声明 @grant GM_xmlhttpRequest。 图片 URL 404 / 模型报错 图片 URL 必须能公网访问,localhost、blob:、file:// 都不行 带签名的临时 URL(如 S3 presigned URL)容易过期,建议转 base64@grant none 和 @grant GM_xmlhttpRequest 冲突用了 GM API 就不能同时 @grant none,两者互斥。 绑定到按钮 把 AI 调用绑定到页面按钮,点击时分析当前可见的图片: const btn = document.createElement("button"); btn.textContent = "AI 分析"; Object.assign(btn.style, { position: "fixed", right: "20px", bottom: "20px", padding: "10px 18px", background: "#2563eb", color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer", zIndex: 9999 }); btn.onclick = () => requestAI(getImagesFromPage()); document.body.appendChild(btn);

ethers v6 里 provider / signer 怎么拿,以及手写一个 signer

前端 dApp 连 MetaMask 用 ethers.js,v5 和 v6 API 变了。写代码总卡在"provider 怎么初始化"这一步。 ethers v6 的最小骨架 <script src="https://cdn.jsdelivr.net/npm/ethers@6.7.1/dist/ethers.min.js"></script> <script> let provider, signer;if (window.ethereum) { provider = new ethers.BrowserProvider(window.ethereum); } else { alert("请先安装 MetaMask"); }document.getElementById("connect").addEventListener("click", async () => { await provider.send("eth_requestAccounts", []); signer = await provider.getSigner(); const address = await signer.getAddress(); const balance = await provider.getBalance(address); console.log(address, ethers.formatEther(balance), "ETH"); }); </script>要点:v6 用 ethers.BrowserProvider(v5 是 ethers.providers.Web3Provider) getSigner() 现在返回 Promise,要 await ethers.formatEther(v6)替代了 ethers.utils.formatEther(v5)v5 vs v6 对照场景 v5 v6浏览器 Provider new ethers.providers.Web3Provider(eth) new ethers.BrowserProvider(eth)RPC Provider ethers.providers.JsonRpcProvider(url) ethers.JsonRpcProvider(url)formatEther ethers.utils.formatEther(v) ethers.formatEther(v)parseEther ethers.utils.parseEther(s) ethers.parseEther(s)BigNumber ethers.BigNumber.from(x) 原生 BigInt手写一个 signer(不依赖 ethers) 有些教学场景或轻量项目不想引入整个 ethers.js,直接靠 window.ethereum 也能拼出一个 signer: async function myGetSigner() { const accounts = await window.ethereum.request({ method: "eth_requestAccounts", }); const address = accounts[0]; return { address, async getAddress() { return address; }, async signMessage(message) { const hex = typeof message === "string" ? toHex(message) : message; return window.ethereum.request({ method: "personal_sign", params: [hex, address], }); }, async sendTransaction(tx) { const params = { from: address, to: tx.to, value: tx.value ? "0x" + BigInt(tx.value).toString(16) : "0x0", gas: tx.gas ? "0x" + BigInt(tx.gas).toString(16) : undefined, data: tx.data || "0x", }; return window.ethereum.request({ method: "eth_sendTransaction", params: [params], }); }, }; }// 浏览器没有 Buffer,自己写一个字符串转 hex function toHex(str) { let hex = ""; for (const ch of str) { hex += ch.charCodeAt(0).toString(16).padStart(2, "0"); } return "0x" + hex; }常见踩坑 Buffer is not defined 从 Node 教程抄的 Buffer.from(...).toString('hex') 在浏览器不能用。用上面的 toHex 或 TextEncoder: const hex = "0x" + Array.from(new TextEncoder().encode(str)) .map(b => b.toString(16).padStart(2, "0")).join("");BigInt 序列化 v6 起金额和 nonce 都是原生 BigInt,直接 JSON.stringify 会崩: TypeError: Do not know how to serialize a BigInt要么手动转字符串,要么全局 patch: BigInt.prototype.toJSON = function () { return this.toString(); };window.ethereum 是数组 装了多个钱包(MetaMask + Coinbase + OKX)后,window.ethereum 可能变成聚合对象,isMetaMask 判断失效。EIP-6963 出来后的做法: window.addEventListener("eip6963:announceProvider", (event) => { console.log(event.detail.info.name, event.detail.provider); }); window.dispatchEvent(new Event("eip6963:requestProvider"));一句话总结 ethers v6:new BrowserProvider(window.ethereum) + await provider.getSigner();不想引 ethers 时手写 signer 也不难,注意浏览器里没 Buffer 就行。

跨域 CORS 完整解决方案:后端、Nginx、前端代理全覆盖

跨域的本质 浏览器的同源策略阻止了跨源请求:协议、域名、端口任一不同即为跨域。服务端不受此限制,同源策略是浏览器行为,后端直接请求后端不存在跨域问题。 浏览器在发送非简单请求前会先发一个 OPTIONS 预检请求(preflight),服务端必须正确响应: Access-Control-Allow-Origin: https://example.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS Access-Control-Allow-Headers: Content-Type, Authorization后端配置(根本解法) Node.js / Express 最简单的做法是手动加响应头,或使用 cors 中间件: // 手动写 app.use((req, res, next) => { res.header("Access-Control-Allow-Origin", "*"); res.header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS"); res.header("Access-Control-Allow-Headers", "Content-Type,Authorization"); if (req.method === "OPTIONS") return res.sendStatus(200); next(); });// 或使用 cors 包 const cors = require("cors"); app.use(cors({ origin: "https://your-frontend.com", methods: ["GET", "POST"], allowedHeaders: ["Content-Type", "Authorization"] }));生产环境不要用 *,写具体允许的 origin。带 credentials 时更不能用 *: app.use(cors({ origin: "https://your-frontend.com", credentials: true // 允许 Cookie }));Spring Boot 单个接口: @CrossOrigin(origins = "*", methods = {RequestMethod.GET, RequestMethod.POST}) @RestController public class MyController { @GetMapping("/api/data") public String getData() { ... } }全局配置(推荐统一管理): @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("https://your-frontend.com") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }Nginx 反向代理配置 适合把前端和后端统一在同一域名下,或给第三方接口套一层代理: location /api/ { proxy_pass http://backend:8080; add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always; add_header Access-Control-Allow-Headers "Content-Type, Authorization" always; add_header Access-Control-Allow-Credentials true always; if ($request_method = OPTIONS) { return 204; } }always 参数确保在非 200 响应(如 4xx、5xx)时也带上跨域头。 前端开发环境代理 开发阶段用本地代理把请求转发到后端,不产生跨域: Vite: // vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } });Vue CLI / webpack-dev-server: // vue.config.js module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } };Create React App: // package.json { "proxy": "http://localhost:8080" }JSONP(仅 GET,老项目兼容) 利用 <script> 标签不受同源限制: <script src="https://api.example.com/data?callback=onData"></script> <script> function onData(data) { console.log(data); // { "name": "test" } } </script>服务端返回: onData({"name":"test"})JSONP 只支持 GET,现代项目不推荐,但对接老旧第三方接口时可能还会遇到。 后端转发(BFF 模式) 前端请求自己的服务端,由服务端再去请求第三方接口: 浏览器 → 自己的后端(同源)→ 第三方 API这样浏览器始终只看到同源请求,完全绕开 CORS 限制。适合第三方接口不支持配置 CORS 的情况。 方案速查场景 推荐方案生产环境,自己控制后端 后端配置 CORS 响应头生产环境,用 Nginx Nginx 加 Access-Control-* 头开发环境调试 本地代理(Vite/Vue CLI/CRA)第三方接口不支持 CORS 后端转发(BFF)老项目只有 GET 需求 JSONP临时调试(不上线) 浏览器 CORS 插件