Showing Posts From

前端

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

做标注 / 高频点击类工作,鼠标点半天累。给页面注入一个悬浮面板 + 快捷键,数字键触发对应元素的 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 框架点击、输入框内不拦截。做高频标注 / 批量操作时效率提升明显。

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"、坐标始终以原图为基准存储。三件事做对,标注永远不错位。

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(且大概率你不该用它)、"数据不更新"先查是不是解构丢了响应式。

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

给页面加自定义快捷键(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 把 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 报错。

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 高亮,是最直接可用的方案。

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:等 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()。

Effect:把副作用塞进类型系统的 TypeScript 框架

Effect 是最近一两年在 TS 圈里越来越火的一个函数式框架。目标是——用类型系统管理副作用,让异步、错误、依赖都变成显式约束。 如果你在写"复杂异步 + 到处 try/catch + 依赖注入乱得一团"的项目,Effect 是一个解法。如果只是写小工具、静态站,用它就是杀鸡用牛刀。 一眼看懂它在做什么 普通 async: async function getUser(id: string) { const res = await fetch(`/api/user/${id}`); return res.json(); }Effect 风格: const getUser = (id: string) => Effect.gen(function* () { const client = yield* HttpClient.HttpClient; const res = yield* client.get(`/api/user/${id}`); return yield* res.json; });多了很多东西——但类型签名会告诉你所有事: Effect.Effect<User, HttpError | JsonError, HttpClient> // ↑ ↑ ↑ // 成功值类型 可能的错误 需要注入的依赖三元组:Effect<A, E, R>泛型 含义 例子A 成功返回值 UserE 可能的错误 NotFoundError | DBErrorR 依赖环境 Database & UserRepo跟 Promise 只有 A(成功值)相比多了两维,这就是 Effect 的核心卖点:错误和依赖都在类型里,没处理就编译不过。 它擅长解决的问题 1. 类型安全的错误处理 普通 TS 的 catch (e) 里 e 是 unknown——你根本不知道会抛什么。Effect 里你必须显式列出错误类型: class NotFoundError { readonly _tag = "NotFoundError"; } class DBError { readonly _tag = "DBError"; constructor(readonly cause: unknown) {} }const findUser = (id: string): Effect.Effect<User, NotFoundError | DBError, Database> => Effect.gen(function* () { const db = yield* Database; const row = yield* db.query(`SELECT ...`).pipe( Effect.mapError(cause => new DBError({ cause })) ); if (!row) return yield* Effect.fail(new NotFoundError()); return row; });处理错误用 catchTag 按 tag 分派: findUser("123").pipe( Effect.catchTag("NotFoundError", () => Effect.succeed(defaultUser)), Effect.catchTag("DBError", err => Effect.log(`DB 挂了: ${err.cause}`)), );2. 依赖注入靠类型驱动 服务用 Context.Tag 声明: class Database extends Context.Tag("Database")<Database, { query: (sql: string) => Effect.Effect<Row[], DBError>; }>() {}用的时候 yield* Database 就注入进来。运行前必须提供实现,缺了就编译不过: const program = findUser("1"); const runnable = program.pipe( Effect.provideService(Database, realDatabaseImpl) ); Effect.runPromise(runnable);跟 NestJS / Angular 的 DI 类似,但类型是硬约束,不是运行时 assert。 3. 并发是一等公民 Effect.all([task1, task2, task3], { concurrency: 5 }); // 限并发 Effect.race([primary, fallback]); // 抢先 task.pipe(Effect.timeout("2 seconds")); // 超时 task.pipe(Effect.retry(Schedule.exponential("100 millis")));// 指数退避重试底层是 Fiber(协程),比 Promise 强得多——真的支持取消传播。 4. Resource 自动管理 const withDb = Effect.acquireRelease( openConnection, // 拿资源 conn => closeConnection(conn),// 释放资源(成功、失败、被取消都会调) );withDb.pipe( Effect.andThen(conn => queryAll(conn)) );再也不用记着写 try { ... } finally { conn.close() }。 为什么很多人学不动 不吹了,说实话——Effect 有几个门槛:FP 味道重:pipe、Effect.gen、yield* 这套语法看着就劝退 API 大:Schedule、Layer、Fiber、Context、Ref、Stream 一堆概念 教程少:中文资料尤其稀 和主流库有摩擦:接第三方 Promise-based 库要用 Effect.tryPromise 包一层什么时候值得用 合适:大型 Node.js 后端(几万行以上) 复杂业务逻辑,错误路径多 团队认可 FP 思路 长期维护别用:前端简单 CRUD 小工具 / CLI 团队不熟 FP,学习成本远大于收益 只是想装酷最小可运行示例 npm i effectimport { Effect, Console } from "effect";const program = Effect.gen(function* () { yield* Console.log("start"); const n = yield* Effect.succeed(42); yield* Console.log(`n = ${n}`); return n; });Effect.runPromise(program).then(console.log);跑起来: start n = 42 42一句话总结 Effect = TypeScript 版 ZIO。把返回值、错误、依赖三件事都刻进类型里,代价是学习曲线陡。大型项目用它能治很多老毛病,小项目就别折腾。

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"。

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 就会消失。

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,那部分归后端管。

在浏览器里 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 也照样能用。

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 就行。

TypeScript `import type` 和 "没有导出的成员" 的坑

Vue 项目里想约束一个变量只能是 element-plus tag 的合法类型: import type { TagType } from "element-plus";const t: TagType = "success";结果 TS 报错: 模块 "element-plus" 没有导出的成员 "TagType"。 你是想改用 "import TagType from 'element-plus'" 吗?问题不是 import type 用错了,是 element-plus 根本没在主入口导出 TagType。 import type 是干嘛的 先说 import type 本身,它只导入类型信息,编译成 JS 时会完全消失: import type { User } from "./types";const u: User = { id: 1 }; // 编译后 import 语句被抹掉对比普通 import: import { User } from "./types"; // 编译后仍然 require("./types") — 运行时如果这个模块只导出类型就报错import type 的三个作用:避免运行时副作用 — 只用类型的场景不会拉进模块 配合 verbatimModuleSyntax — 现代 TS 项目里"类型 vs 值"必须显式区分 减小打包体积 — 无用运行时代码会被 tree-shake 掉为什么 element-plus 报"没有导出的成员" TagType 在 element-plus 内部定义: // packages/components/tag/src/tag.ts export type TagType = "" | "success" | "info" | "warning" | "danger";但没转发到主入口 element-plus/es/index.d.ts。所以: import type { TagType } from "element-plus"; // 找不到两种解法 方案 1:自己定义(推荐) 三秒钟搞定,稳定: type TagType = "" | "success" | "info" | "warning" | "danger";const t: TagType = "success";对内部类型 element-plus 后续升级也不会一言不合就改。自己定义等于 API 边界完全掌控。 方案 2:从深路径引 理论上可以: import type { TagType } from "element-plus/es/components/tag/src/tag";局限:这是非公开 API,element-plus 版本升级可能改路径 需要构建工具能解析这种深路径 不同版本的 element-plus 目录结构可能不同(lib/ vs es/ vs dist/)做工具库或第三方组件时优先方案 1,业务代码里图省事偶尔可以用方案 2。 import type 的常见变体 内联 type(TS 4.5+): import { type User, createUser } from "./user"; // ^^^^ 只这个是类型同一 import 里混装类型和值,编译后 User 被抹掉,createUser 保留。 全部当值 import(老式,不推荐): import { User } from "./user"; type MyRole = User["role"]; // 运行时会真的加载模块export type: export type { User } from "./user";再转发类型,同样只影响类型系统,不产生运行时依赖。 一张速查表场景 写法只用类型 import type { X } from "..."同一 import 混装 import { type X, y } from "..."转发类型 export type { X } from "..."库没有导出的内部类型 自己定义 or 深路径引(慎用)verbatimModuleSyntax=true 类型必须用 import type,否则报错一句话总结 import type = 只要类型不要运行时代码。库没导出的内部类型别硬拽——自己定义一份最稳,深路径 import 只是应急。

electron 开发中运行失败

解决方案 pnpm exec node node_modules/electron/install.js

Plasmo 浏览器扩展项目 tsconfig 与 `~` 别名

用 Plasmo 写 Chrome 扩展项目,脚手架给的 tsconfig.json 是这样: { "extends": "plasmo/templates/tsconfig.base", "exclude": ["node_modules"], "include": [ ".plasmo/index.d.ts", "./**/*.ts", "./**/*.tsx" ], "compilerOptions": { "baseUrl": ".", "paths": { "~*": ["./*"] } } }一份不到 20 行,但每一行都干活。 每行在做什么extends: "plasmo/templates/tsconfig.base" — 继承 Plasmo 的严格模式基线(strict、esModuleInterop、jsx: react 等),只覆盖必要项 exclude: ["node_modules"] — 不给 TS 检查依赖 include: [".plasmo/index.d.ts", ...] — 包含 Plasmo 生成的类型声明和项目源码 baseUrl: "." — 相对根目录解析 paths: { "~*": ["./*"] } — ~utils 会被解析成 ./utils想把代码搬进 src/ Plasmo 默认允许代码散在根目录(popup.tsx、background.ts 直接扔外面)。你想集中放 src/ 下,tsconfig 需要相应改: { "extends": "plasmo/templates/tsconfig.base", "exclude": ["node_modules"], "include": [ "src/**/*.ts", "src/**/*.tsx", ".plasmo/index.d.ts" ], "compilerOptions": { "baseUrl": "src", "paths": { "~*": ["./*"], "@*": ["./*"] } } }三处变化:include 从 ./** 改成 src/** baseUrl 从 . 改成 src paths 里 ~ 和 @ 都指到 src 根之后代码里: import Button from "~/components/Button"; import { fetchUser } from "@/api/user";会被解析为 src/components/Button.tsx 和 src/api/user.ts。 Plasmo 的 url: 前缀 Plasmo 基于 Parcel 打包,导入 HTML 或图片资源有个特殊语法: import fontPickerHTML from "url:./panels/font-picker/index.html";url: 前缀告诉 Parcel"不要把这个当代码解析、当静态资源处理,导出打包后的 URL"。编译产物大致是: const fontPickerHTML = "/assets/font-picker.abc123.html";用途:chrome.windows.create({ url: fontPickerHTML }) 打开一个扩展页面。 代码搬进 src 后要配合修改: import fontPickerHTML from "url:~/panels/font-picker/index.html";因为 ~ 现在指向 src/。 除了 url: 还有 Parcel 支持多个前缀,Plasmo 都能用:url: — 当资源,返回 URL 字符串 data-url: — 编码成 base64 data URL raw: — 读文件原始内容为字符串 bundle: — 把整个 JS 打成单独 bundle data-text: — 直接嵌入文本例如把某个 css 内联进背景 script: import css from "raw:~/styles/inject.css"; chrome.scripting.insertCSS({ target: { tabId }, css });Chrome 扩展 tsconfig 常见字段 Plasmo base 已经配好,但了解一下: { "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "Bundler", "jsx": "react", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "types": ["chrome"] // 让 chrome.* API 有类型 } }types: ["chrome"] 是关键——扩展项目里 chrome.storage、chrome.runtime 得靠它。Plasmo base 里默认带了。 一句话总结 Plasmo 的 ~ 别名跟着 baseUrl 走:搬到 src/ 就把 baseUrl 改成 src、include 换成 src/**。url: 前缀是 Parcel 特有的静态资源导入,改路径时别忘了同步。

Uniapp+tailwindcss+uview start

Uniapp+tailwindcss+uview start

官方资源 官网:https://weapp-tw.icebreaker.top/模板项目 Vue2模板 https://github.com/sonofmagic/uni-app-vue2-tailwind-vscode-template Vue3模板 https://github.com/sonofmagic/uni-app-vite-vue3-tailwind-vscode-template