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。
