前端计算机视觉:使用 OpenCV.js 在浏览器中实现图像处理

一、OpenCV.js 简介与环境搭建

OpenCV(Open Source Computer Vision Library)是一个强大的计算机视觉库,广泛应用于图像和视频处理领域。传统上,OpenCV 主要在后端使用 Python 或 C++ 等语言。但随着 WebAssembly (Wasm) 技术的发展,OpenCV 也有了 JavaScript 版本 ------OpenCV.js,它可以直接在浏览器中高效运行,为前端开发者提供了前所未有的计算机视觉能力。

1.1 引入 OpenCV.js

在浏览器中使用 OpenCV.js 有多种方式,最简单的是通过 CDN 引入:

复制代码
<script async src="https://docs.opencv.org/4.5.5/opencv.js" onload="onOpenCvReady();" type="text/javascript"></script>

这种方式适合快速测试和开发。另一种方式是将 OpenCV.js 下载到本地项目中:

复制代码
npm install @techstark/opencv-js

然后在 HTML 中引入:

复制代码
<script async src="node_modules/@techstark/opencv-js/opencv.js" onload="onOpenCvReady();" type="text/javascript"></script>

1.2 初始化与加载检查

由于 OpenCV.js 是一个较大的库,需要异步加载。我们可以通过以下方式确保库加载完成后再执行相关代码:

复制代码
function onOpenCvReady() {
  document.getElementById('status').innerHTML = 'OpenCV.js 已加载完成';
  // 在这里开始使用 OpenCV.js
  cvVersion = cv.getVersion();
  console.log('OpenCV 版本:', cvVersion);
}

在 HTML 中添加状态显示元素:

复制代码
<body>
  <div id="status">正在加载 OpenCV.js...</div>
  <!-- 其他页面内容 -->
</body>

二、基本图像处理操作

2.1 图像读取与显示

OpenCV.js 主要处理 cv.Mat 对象(矩阵),这是存储图像数据的核心结构。下面是一个从 HTML Image 元素读取图像并显示的完整示例:

复制代码
<!DOCTYPE html>
<html>
<head>
  <title>OpenCV.js 图像读取与显示示例</title>
  <script async src="https://docs.opencv.org/4.5.5/opencv.js" onload="onOpenCvReady();" type="text/javascript"></script>
  <style>
    .container {
      display: flex;
      flex-direction: column;
      align-items: center;
      margin-top: 20px;
    }
    .canvas-container {
      display: flex;
      gap: 20px;
      margin-top: 20px;
    }
    canvas {
      border: 1px solid #ccc;
    }
  </style>
</head>
<body>
  <div class="container">
    <h2>OpenCV.js 图像读取与显示</h2>
    <div id="status">正在加载 OpenCV.js...</div>
    <img id="imageSrc" src="example.jpg" alt="示例图片" crossorigin="anonymous" style="display: none;">
    <div class="canvas-container">
      <div>
        <p>原始图像</p>
        <canvas id="inputCanvas"></canvas>
      </div>
      <div>
        <p>处理后图像</p>
        <canvas id="outputCanvas"></canvas>
      </div>
    </div>
  </div>

  <script>
    let src, dst, inputCanvas, outputCanvas;

    function onOpenCvReady() {
      document.getElementById('status').innerHTML = 'OpenCV.js 已加载完成';
      // 初始化画布和图像矩阵
      inputCanvas = document.getElementById('inputCanvas');
      outputCanvas = document.getElementById('outputCanvas');
      
      // 等待图像加载完成
      const img = document.getElementById('imageSrc');
      img.onload = function() {
        // 设置画布大小
        inputCanvas.width = img.width;
        inputCanvas.height = img.height;
        outputCanvas.width = img.width;
        outputCanvas.height = img.height;
        
        // 读取图像到 Mat 对象
        src = cv.imread(img);
        dst = new cv.Mat();
        
        // 在输入画布上显示原始图像
        cv.imshow(inputCanvas, src);
        
        // 示例:复制图像到输出画布
        src.copyTo(dst);
        cv.imshow(outputCanvas, dst);
        
        // 释放资源
        // 注意:在实际应用中,当不再需要 Mat 对象时应及时释放
        // src.delete();
        // dst.delete();
      }
      
      // 如果图像已经加载
      if (img.complete) {
        img.onload();
      }
    }
  </script>
</body>
</html>

这个示例展示了 OpenCV.js 的基本工作流程:加载图像、创建 Mat 对象、处理图像、显示结果。需要注意的是,OpenCV.js 使用的内存需要手动管理,通过调用 delete () 方法释放不再使用的 Mat 对象。

2.2 颜色空间转换

颜色空间转换是图像处理中的常见操作。例如,将彩色图像转换为灰度图像:

复制代码
// 假设 src 是已经加载的彩色图像
dst = new cv.Mat();
// 使用 COLOR_RGB2GRAY 标志进行转换
cv.cvtColor(src, dst, cv.COLOR_RGB2GRAY);
// 显示灰度图像
cv.imshow(outputCanvas, dst);

也可以在不同的颜色空间之间进行转换,比如从 RGB 到 HSV:

复制代码
cv.cvtColor(src, dst, cv.COLOR_RGB2HSV);

2.3 图像滤波

图像滤波是平滑图像、去除噪声或增强特定特征的常用技术。以下是几种常见的滤波操作:

2.3.1 高斯模糊
复制代码
// 定义核大小,必须是奇数
let ksize = new cv.Size(5, 5);
// 定义标准差
let sigmaX = 0;
let sigmaY = 0;
cv.GaussianBlur(src, dst, ksize, sigmaX, sigmaY, cv.BORDER_DEFAULT);
2.3.2 中值滤波
复制代码
// 定义核大小,必须是大于 1 的奇数
let ksize = 5;
cv.medianBlur(src, dst, ksize);
2.3.3 双边滤波
复制代码
// 定义参数
let d = 9; // 过滤时使用的像素领域直径
let sigmaColor = 75; // 颜色空间滤波器的sigma值
let sigmaSpace = 75; // 坐标空间中滤波器的sigma值
cv.bilateralFilter(src, dst, d, sigmaColor, sigmaSpace);

2.4 边缘检测

边缘检测是计算机视觉中的重要任务,常用于特征提取和图像分割。

2.4.1 Canny 边缘检测
复制代码
// 转换为灰度图像
let gray = new cv.Mat();
cv.cvtColor(src, gray, cv.COLOR_RGB2GRAY);

// 应用 Canny 边缘检测
let edges = new cv.Mat();
let threshold1 = 100;
let threshold2 = 200;
let apertureSize = 3;
let L2gradient = false;
cv.Canny(gray, edges, threshold1, threshold2, apertureSize, L2gradient);

// 显示结果
cv.imshow(outputCanvas, edges);

// 释放资源
gray.delete();
edges.delete();
2.4.2 Sobel 算子
复制代码
// 转换为灰度图像
let gray = new cv.Mat();
cv.cvtColor(src, gray, cv.COLOR_RGB2GRAY);

// 创建输出矩阵
let sobelx = new cv.Mat();
let sobely = new cv.Mat();
let abs_sobelx = new cv.Mat();
let abs_sobely = new cv.Mat();
let sobel_edges = new cv.Mat();

// 计算 x 和 y 方向的梯度
cv.Sobel(gray, sobelx, cv.CV_16S, 1, 0, 3, 1, 0, cv.BORDER_DEFAULT);
cv.Sobel(gray, sobely, cv.CV_16S, 0, 1, 3, 1, 0, cv.BORDER_DEFAULT);

// 转换为 8 位无符号整数
cv.convertScaleAbs(sobelx, abs_sobelx);
cv.convertScaleAbs(sobely, abs_sobely);

// 合并两个方向的梯度
cv.addWeighted(abs_sobelx, 0.5, abs_sobely, 0.5, 0, sobel_edges);

// 显示结果
cv.imshow(outputCanvas, sobel_edges);

// 释放资源
gray.delete();
sobelx.delete();
sobely.delete();
abs_sobelx.delete();
abs_sobely.delete();
sobel_edges.delete();

三、特征提取与描述

3.1 Harris 角点检测

角点是图像中重要的局部特征,Harris 角点检测是一种经典的角点检测方法:

复制代码
// 转换为灰度图像
let gray = new cv.Mat();
cv.cvtColor(src, gray, cv.COLOR_RGB2GRAY);

// 创建输出矩阵
let dstHarris = new cv.Mat();
let dstNorm = new cv.Mat();
let dstNormScaled = new cv.Mat();

// 应用 Harris 角点检测
let blockSize = 2;
let apertureSize = 3;
let k = 0.04;
cv.cornerHarris(gray, dstHarris, blockSize, apertureSize, k, cv.BORDER_DEFAULT);

// 归一化结果
cv.normalize(dstHarris, dstNorm, 0, 255, cv.NORM_MINMAX, cv.CV_32FC1, new cv.Mat());
cv.convertScaleAbs(dstNorm, dstNormScaled);

// 在原图上绘制角点
for (let j = 0; j < dstNorm.rows; j++) {
  for (let i = 0; i < dstNorm.cols; i++) {
    if (parseInt(dstNorm.ptr(j, i)[0]) > 100) {
      cv.circle(dstNormScaled, new cv.Point(i, j), 5, [0, 255, 0], 2, 8, 0);
    }
  }
}

// 显示结果
cv.imshow(outputCanvas, dstNormScaled);

// 释放资源
gray.delete();
dstHarris.delete();
dstNorm.delete();
dstNormScaled.delete();

3.2 ORB (Oriented FAST and Rotated BRIEF)

ORB 是一种结合了 FAST 特征点检测和 BRIEF 特征描述子的高效特征提取方法:

复制代码
// 转换为灰度图像
let gray = new cv.Mat();
cv.cvtColor(src, gray, cv.COLOR_RGB2GRAY);

// 创建 ORB 检测器
let orb = new cv.ORB();

// 检测关键点并计算描述符
let keypoints = new cv.KeyPointVector();
let descriptors = new cv.Mat();
orb.detectAndCompute(gray, new cv.Mat(), keypoints, descriptors);

// 在原图上绘制关键点
let output = new cv.Mat();
cv.cvtColor(gray, output, cv.COLOR_GRAY2BGR);
cv.drawKeypoints(gray, keypoints, output, [0, 255, 0], 0);

// 显示结果
cv.imshow(outputCanvas, output);

// 释放资源
gray.delete();
orb.delete();
keypoints.delete();
descriptors.delete();
output.delete();

四、图像分割

4.1 阈值分割

阈值分割是最简单的图像分割方法,根据像素值与阈值的比较将图像分为不同区域:

复制代码
// 转换为灰度图像
let gray = new cv.Mat();
cv.cvtColor(src, gray, cv.COLOR_RGB2GRAY);

// 应用阈值分割
let dst = new cv.Mat();
let thresholdValue = 127;
let maxValue = 255;
let thresholdType = cv.THRESH_BINARY;
cv.threshold(gray, dst, thresholdValue, maxValue, thresholdType);

// 显示结果
cv.imshow(outputCanvas, dst);

// 释放资源
gray.delete();
dst.delete();

4.2 自适应阈值分割

自适应阈值分割根据像素周围区域的局部特性计算阈值,适合处理光照不均匀的图像:

复制代码
// 转换为灰度图像
let gray = new cv.Mat();
cv.cvtColor(src, gray, cv.COLOR_RGB2GRAY);

// 应用自适应阈值分割
let dst = new cv.Mat();
let maxValue = 255;
let adaptiveMethod = cv.ADAPTIVE_THRESH_GAUSSIAN_C;
let thresholdType = cv.THRESH_BINARY;
let blockSize = 11;
let C = 2;
cv.adaptiveThreshold(gray, dst, maxValue, adaptiveMethod, thresholdType, blockSize, C);

// 显示结果
cv.imshow(outputCanvas, dst);

// 释放资源
gray.delete();
dst.delete();

4.3 基于轮廓的分割

轮廓检测可以识别图像中的连续区域,常用于物体分割:

复制代码
// 转换为灰度图像
let gray = new cv.Mat();
cv.cvtColor(src, gray, cv.COLOR_RGB2GRAY);

// 应用阈值处理
let thresh = new cv.Mat();
cv.threshold(gray, thresh, 127, 255, cv.THRESH_BINARY);

// 查找轮廓
let contours = new cv.MatVector();
let hierarchy = new cv.Mat();
cv.findContours(thresh, contours, hierarchy, cv.RETR_TREE, cv.CHAIN_APPROX_SIMPLE);

// 在原图上绘制轮廓
let drawing = cv.Mat.zeros(thresh.size(), cv.CV_8UC3);
for (let i = 0; i < contours.size(); i++) {
  let color = new cv.Scalar(Math.random() * 255, Math.random() * 255, Math.random() * 255);
  cv.drawContours(drawing, contours, i, color, 2, cv.LINE_8, hierarchy, 0);
}

// 显示结果
cv.imshow(outputCanvas, drawing);

// 释放资源
gray.delete();
thresh.delete();
contours.delete();
hierarchy.delete();
drawing.delete();

五、视频处理

OpenCV.js 也可以处理视频流,包括摄像头实时视频。以下是一个简单的视频处理示例:

复制代码
<!DOCTYPE html>
<html>
<head>
  <title>OpenCV.js 视频处理示例</title>
  <script async src="https://docs.opencv.org/4.5.5/opencv.js" onload="onOpenCvReady();" type="text/javascript"></script>
  <style>
    .container {
      display: flex;
      flex-direction: column;
      align-items: center;
      margin-top: 20px;
    }
    .video-container {
      display: flex;
      gap: 20px;
      margin-top: 20px;
    }
    video, canvas {
      border: 1px solid #ccc;
      width: 640px;
      height: 480px;
    }
    button {
      margin-top: 10px;
      padding: 10px 20px;
      font-size: 16px;
    }
  </style>
</head>
<body>
  <div class="container">
    <h2>OpenCV.js 视频处理</h2>
    <div id="status">正在加载 OpenCV.js...</div>
    <div class="video-container">
      <div>
        <p>原始视频</p>
        <video id="inputVideo" autoplay muted playsinline></video>
      </div>
      <div>
        <p>处理后视频</p>
        <canvas id="outputCanvas"></canvas>
      </div>
    </div>
    <button id="startButton">开始</button>
    <button id="stopButton" disabled>停止</button>
  </div>

  <script>
    let video, outputCanvas, outputContext;
    let src, dst, gray;
    let processing = false;
    let requestId;

    function onOpenCvReady() {
      document.getElementById('status').innerHTML = 'OpenCV.js 已加载完成';
      video = document.getElementById('inputVideo');
      outputCanvas = document.getElementById('outputCanvas');
      outputContext = outputCanvas.getContext('2d');
      
      // 获取摄像头访问权限
      navigator.mediaDevices.getUserMedia({ video: true, audio: false })
        .then(function(stream) {
          video.srcObject = stream;
          video.onloadedmetadata = function(e) {
            video.play();
            document.getElementById('startButton').disabled = false;
          };
        })
        .catch(function(err) {
          console.error('摄像头访问错误: ' + err);
          document.getElementById('status').innerHTML = '无法访问摄像头';
        });
      
      // 按钮事件处理
      document.getElementById('startButton').addEventListener('click', startProcessing);
      document.getElementById('stopButton').addEventListener('click', stopProcessing);
    }

    function startProcessing() {
      if (processing) return;
      
      // 初始化 OpenCV 矩阵
      src = new cv.Mat(video.height, video.width, cv.CV_8UC4);
      dst = new cv.Mat(video.height, video.width, cv.CV_8UC4);
      gray = new cv.Mat(video.height, video.width, cv.CV_8UC1);
      
      processing = true;
      document.getElementById('startButton').disabled = true;
      document.getElementById('stopButton').disabled = false;
      
      // 开始处理视频帧
      processVideo();
    }

    function stopProcessing() {
      if (!processing) return;
      
      processing = false;
      document.getElementById('startButton').disabled = false;
      document.getElementById('stopButton').disabled = true;
      
      // 释放资源
      if (src) src.delete();
      if (dst) dst.delete();
      if (gray) gray.delete();
      
      // 取消动画帧请求
      if (requestId) {
        cancelAnimationFrame(requestId);
      }
    }

    function processVideo() {
      if (!processing) return;
      
      try {
        // 从视频帧读取数据到 src
        cv.imread(video, src);
        
        // 示例处理:转换为灰度图
        cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
        cv.cvtColor(gray, dst, cv.COLOR_GRAY2RGBA);
        
        // 在处理后的帧上绘制文字
        let text = 'OpenCV.js 视频处理';
        let org = new cv.Point(10, 30);
        let fontFace = cv.FONT_HERSHEY_SIMPLEX;
        let fontScale = 1;
        let color = new cv.Scalar(255, 0, 0, 255);
        let thickness = 2;
        cv.putText(dst, text, org, fontFace, fontScale, color, thickness);
        
        // 将处理结果显示在 canvas 上
        cv.imshow(outputCanvas, dst);
        
        // 继续处理下一帧
        requestId = requestAnimationFrame(processVideo);
      } catch (err) {
        console.error('处理视频帧时出错:', err);
        stopProcessing();
      }
    }
  </script>
</body>
</html>

这个示例展示了如何捕获摄像头视频流并使用 OpenCV.js 进行实时处理。你可以根据需要修改 processVideo 函数中的处理逻辑,实现更复杂的视频处理效果。

六、实际应用案例

6.1 实时人脸检测

结合 OpenCV.js 和 Haar 级联分类器,可以实现浏览器中的实时人脸检测:

复制代码
// 加载人脸检测模型
let faceCascade = new cv.CascadeClassifier();
let utils = new Utils('errorMessage');

// 加载预训练的人脸检测模型
utils.createFileFromUrl('haarcascade_frontalface_default.xml',
                        'haarcascade_frontalface_default.xml',
                        () => {
                          faceCascade.load('haarcascade_frontalface_default.xml');
                          document.getElementById('status').innerHTML = '人脸检测模型已加载';
                        },
                        () => {
                          document.getElementById('status').innerHTML = '模型加载失败';
                        });

// 在视频处理循环中添加人脸检测逻辑
function processVideo() {
  if (!processing) return;
  
  try {
    // 从视频帧读取数据到 src
    cv.imread(video, src);
    
    // 转换为灰度图以提高检测速度
    cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
    
    // 检测人脸
    let faces = new cv.RectVector();
    let msize = new cv.Size(0, 0);
    // 检测参数:scaleFactor=1.1, minNeighbors=3, flags=0, minSize=msize
    faceCascade.detectMultiScale(gray, faces, 1.1, 3, 0, msize);
    
    // 在原图上绘制检测到的人脸
    for (let i = 0; i < faces.size(); i++) {
      let face = faces.get(i);
      let point1 = new cv.Point(face.x, face.y);
      let point2 = new cv.Point(face.x + face.width, face.y + face.height);
      cv.rectangle(src, point1, point2, [255, 0, 0, 255], 2);
    }
    
    // 显示结果
    cv.imshow(outputCanvas, src);
    
    // 释放资源
    faces.delete();
    
    // 继续处理下一帧
    requestAnimationFrame(processVideo);
  } catch (err) {
    console.error('处理视频帧时出错:', err);
    stopProcessing();
  }
}

6.2 图像匹配

使用 OpenCV.js 进行图像匹配,可以在一个图像中查找另一个图像的位置:

复制代码
// 加载源图像和模板图像
let src = cv.imread('sourceImage');
let templ = cv.imread('templateImage');

// 创建结果矩阵
let result = new cv.Mat();
let result_cols = src.cols - templ.cols + 1;
let result_rows = src.rows - templ.rows + 1;
result.create(result_rows, result_cols, cv.CV_32FC1);

// 应用模板匹配
let method = cv.TM_CCOEFF_NORMED;
cv.matchTemplate(src, templ, result, method);

// 找到最佳匹配位置
let minMaxLoc = cv.minMaxLoc(result);
let matchLoc;
if (method === cv.TM_SQDIFF || method === cv.TM_SQDIFF_NORMED) {
  matchLoc = minMaxLoc.minLoc;
} else {
  matchLoc = minMaxLoc.maxLoc;
}

// 在原图上绘制匹配区域
let point1 = new cv.Point(matchLoc.x, matchLoc.y);
let point2 = new cv.Point(matchLoc.x + templ.cols, matchLoc.y + templ.rows);
cv.rectangle(src, point1, point2, [0, 255, 0, 255], 2);

// 显示结果
cv.imshow('outputCanvas', src);

// 释放资源
src.delete();
templ.delete();
result.delete();

七、性能优化与最佳实践

7.1 内存管理

在使用 OpenCV.js 时,正确的内存管理非常重要。每个 cv.Mat 对象都占用内存,不再使用时应调用 delete () 方法释放:

复制代码
// 创建 Mat 对象
let mat = new cv.Mat();

// 使用 mat 对象进行各种操作

// 不再使用时释放内存
mat.delete();

对于在循环中创建的临时 Mat 对象,更要特别注意及时释放,避免内存泄漏。

7.2 异步处理

对于复杂的图像处理任务,考虑使用 Web Workers 进行异步处理,避免阻塞主线程:

复制代码
// main.js
// 创建 Web Worker
const worker = new Worker('worker.js');

// 发送图像数据到 worker
worker.postMessage({ imageData: imageData }, [imageData.data.buffer]);

// 接收处理结果
worker.onmessage = function(e) {
  // 在 canvas 上显示处理结果
  outputContext.putImageData(e.data.processedImageData, 0, 0);
};

// worker.js
self.onmessage = function(e) {
  // 加载 OpenCV.js
  importScripts('https://docs.opencv.org/4.5.5/opencv.js');
  
  self.cv['onRuntimeInitialized'] = function() {
    // 处理图像
    let src = cv.matFromImageData(e.data.imageData);
    let dst = new cv.Mat();
    
    // 执行图像处理操作
    cv.cvtColor(src, dst, cv.COLOR_RGBA2GRAY);
    
    // 转换回 ImageData
    let imageData = new ImageData(
      new Uint8ClampedArray(dst.data),
      dst.cols,
      dst.rows
    );
    
    // 发送结果回主线程
    self.postMessage({ processedImageData: imageData }, [imageData.data.buffer]);
    
    // 释放资源
    src.delete();
    dst.delete();
  };
};

7.3 优化处理参数

对于计算密集型操作,如特征检测或视频处理,可以通过调整参数来平衡性能和精度:

复制代码
// 调整 Canny 边缘检测参数以提高性能
let threshold1 = 100;
let threshold2 = 200;
let apertureSize = 3; // 可以增大以减少计算量
let L2gradient = false; // 使用更简单的梯度计算方法
cv.Canny(src, dst, threshold1, threshold2, apertureSize, L2gradient);

八、局限性与挑战

尽管 OpenCV.js 提供了强大的功能,但在前端使用仍有一些局限性:

  1. 性能限制:WebAssembly 虽然比纯 JavaScript 快得多,但对于复杂的计算机视觉任务,仍然可能比原生实现慢。

  2. 内存管理:与原生 OpenCV 相比,JavaScript 环境中的内存管理更加复杂,需要开发者手动释放资源。

  3. 模型加载:预训练模型(如 Haar 级联分类器)体积较大,加载时间较长。

  4. 浏览器兼容性:不同浏览器对 WebAssembly 和 OpenCV.js 的支持程度可能不同。

  5. 长时间运行任务:长时间运行的计算密集型任务可能导致页面无响应,需要使用 Web Workers 进行优化。

九、总结与未来展望

OpenCV.js 为前端开发者打开了计算机视觉的大门,使我们能够在浏览器中实现图像和视频处理功能,而无需依赖后端服务。从简单的图像处理到复杂的实时视频分析,OpenCV.js 提供了丰富的功能和工具。

随着 WebAssembly 技术的不断发展和浏览器性能的提升,我们可以期待 OpenCV.js 在未来会有更好的表现和更广泛的应用场景。例如,增强现实 (AR)、实时视频编辑、智能监控等领域都可能受益于 OpenCV.js 的发展。

相关推荐
姑苏洛言12 分钟前
编写产品需求文档:黄历日历小程序
前端·javascript·后端
知识分享小能手36 分钟前
Vue3 学习教程,从入门到精通,使用 VSCode 开发 Vue3 的详细指南(3)
前端·javascript·vue.js·学习·前端框架·vue·vue3
姑苏洛言1 小时前
搭建一款结合传统黄历功能的日历小程序
前端·javascript·后端
你的人类朋友2 小时前
🤔什么时候用BFF架构?
前端·javascript·后端
知识分享小能手2 小时前
Bootstrap 5学习教程,从入门到精通,Bootstrap 5 表单验证语法知识点及案例代码(34)
前端·javascript·学习·typescript·bootstrap·html·css3
前端小趴菜053 小时前
react状态管理库 - zustand
前端·react.js·前端框架
Jerry Lau3 小时前
go go go 出发咯 - go web开发入门系列(二) Gin 框架实战指南
前端·golang·gin
我命由我123454 小时前
前端开发问题:SyntaxError: “undefined“ is not valid JSON
开发语言·前端·javascript·vue.js·json·ecmascript·js