前端监控SDK

1. 了解前端监控

前端监控的范围很广,如监控行为,监控性能,监控异常,监控告警等一系列的维度来确保我们的页面和功能是正常的,在出现问题时研发可以及时做出响应,及时追踪,定位问题。

对于前端而言,和后端处于同一个监控系统中,前端有自己的监控方案,后端也有自己等监控方案,但两者并不分离,因为一个用户在操作应用过程中如果出现异常,有可能是前端引起,也有可能是后端引起,需要有一个机制,将前后端串联起来,使监控本身统一于监控系统。因此,即使只讨论前端异常监控,其实也不能严格区分前后端界限,而要根据实际系统的设计,在最终的报表中体现出监控对开发和业务的帮助。

2. 前端监控目标

2.1 稳定性指标 stability

  • js错误:js执行错误、promise异常
  • 资源错误:js、css资源加载异常
  • 接口错误:ajax、fetch请求接口异常
  • 白屏:页面空白
  • 页面加载时长

2.2 用户体验指标 experience

2.3 业务指标 business

  • PV:Page View 即页面浏览量或点击量;
  • UV:指访问某个站点的不同 IP 地址的人数;
  • 页面的停留时间:用户在每一个页面的停留时间。

3. 前端监控流程

  • 前端埋点(通过 SDK 给页面的 DOM 都加上标记)
  • 数据上报(收集,存储)
  • 分析和计算(将采集到的数据进行加工汇总)
  • 可视化展示(按照纬度将数据展示)
  • 监控报警(发现异常后按一定的条件触发报警/飞书 钉钉机器人群推送)

3.1 前端埋点方案

代码埋点

代码埋点,就是项目中引入埋点 SDK,手动在业务代码中标记,触发埋点事件进行上报。比如页面中的某一个模块的点击事件,会在点击事件的监听中加入触发埋点的代码 this.$track('事件名', { 需要上传的业务数据 }),将数据上报到服务器端。

  • 优点:能够在任何时刻,更精确的发送需要的数据信息,上报数据更灵活;
  • 缺点:工作量大,代码侵入太强,过于耦合业务代码,一次埋点的更改就要引起发版之类的操作。

这个方案也是我们实际项目中现有的方案。

可视化埋点

通过可视化交互的手段,代替代码埋点,可以新建、编辑、修改埋点。在组件和页面的维度进行埋点的设计。

将业务代码和埋点代码分离,提供一个可视化交互的页面,输入为业务代码,通过这个可视化系统,可以在业务代码中自定义的增加埋点事件,最后输出的代码耦合了业务代码和埋点代码。

这个方案是可以解决第一种代码埋点的痛点,也是我们目前正准备做的方案。

无痕埋点

前端的任意一个事件都被绑定一个标识,所有的事件都被记录下来,通过定期上传记录文件,配合文件解析,解析出来我们想要的数据,并生成可视化报告。

无痕埋点的优点是采集全量数据,不会出现漏埋和误埋等现象。缺点是给数据传输和服务器增加压力,也无法灵活定制数据结构。针对业务数据的准确性不高。

4. 监控脚本

4.1 接入日志系统

  • 各公司一般都有自己的日志系统,接收数据上报,例如:阿里云

前端的埋点上报需要存储起来,这个可以使用阿里云的日志服务,不需要投入开发就可以采集。

新建一个项目比如:xxx-monitor

新建一个存储日志,根据阿里云的要求发起请求,携带需要上报的数据:

http://${project}.${host}/logstores/${logStore}/track

代码中调用 Track 上报日志:

日志的上报可以封装成公共的调用方式, monitor/utils/里面放所有的工具方法;

tracker.js 的实现就是按照阿里云的上报格式发送请求,并带上处理好的需要上报的业务数据即可,下面的都是固定的,在日志服务建好:

tracker.js 复制代码
// 主机
const host = "cn-xxx-log.aliyuncs.com"
// 项目名
const project = "wu-monitor"
// 存储名
const logstore = "wu-monitor-store"
const userAgent = require("user-agent")

function getExtraData() {
  return {
    title: document.title,
    url: location.href,
    timestamp: Date.now(),
    userAgent: userAgent.parse(navigator.userAgent).name,
  }
}

class SendTracker {
  constructor() {
    // 上报的路径
    this.url = `http://${project}.${host}/logstores/${logstore}/track`
    this.xhr = new XMLHttpRequest()
  }
  send(data = {}) {
    let extraData = getExtraData()
    let log = { ...data, ...extraData }
    // 阿里云要求值不能为数字
    for (const key in log) {
      if (typeof log[key] === "number") {
        log[key] = `${log[key]}`
      }
    }
    console.log("log", log)
    // 接入日志系统,此处以阿里云为例
    let body = JSON.stringify({
      __logs__: [log],
    })
    this.xhr.open("POST", this.url, true)
    this.xhr.setRequestHeader("Content-Type", "application/json")
    this.xhr.setRequestHeader("x-log-apiversion", "1.0.0")
    this.xhr.setRequestHeader("x-log-bodyrawsize", body.length)
    this.xhr.onload = function () {
      // console.log(this.xhr.response)
    }
    this.xhr.onerror = function (error) {
      console.log(error)
    }
    this.xhr.send(body)
  }
}

export default new SendTracker()

4.1.1 日志存储

4.2 监控错误

4.2.1 错误分类

  • js错误(js执行错误,promise异常)
  • 资源加载异常:监听error

4.2.2 数据结构分析

1. jsError
jsError.js 复制代码
{
    "title": "前端监控系统", // 页面标题
    "url": "http://localhost:8080/", // 页面URL
    "timestamp": "1590815288710", // 访问时间戳
    "userAgent": "Chrome", // 用户浏览器类型
    "kind": "stability", // 大类
    "type": "error", // 小类
    "errorType": "jsError", // 错误类型
    "message": "Uncaught TypeError: Cannot set property 'error' of undefined", // 类型详情
    "filename": "http://localhost:8080/", // 访问的文件名
    "position": "0:0", // 行列信息
    "stack": "btnClick (http://localhost:8080/:20:39)^HTMLInputElement.onclick (http://localhost:8080/:14:72)", // 堆栈信息
    "selector": "HTML BODY #container .content INPUT" // 选择器
}
2. promiseError
jsError.js 复制代码
tracker.send({
    kind: "stability", // 监控指标的大类,稳定性
    type: "error", // 小类型,这是一个错误
    errorType: "promiseError", // js执行错误
    message, // 报错信息
    filename, // 哪个文件报错了
    position: `${line}:${column}`, // 报错的行列位置
    stack,
    selector: lastEvent ? getSelector(lastEvent.path) : "", // 代表最后一个操作的元素
})
3. resourceError
jsError.js 复制代码
tracker.send({
  kind: "stability", // 监控指标的大类,稳定性
  type: "error", // 小类型,这是一个错误
  errorType: "resourceError", // js执行错误
  filename: event.target.src || event.target.href, // 哪个文件报错了
  tagName: event.target.tagName,
  selector: getSelector(event.target), // 代表最后一个操作的元素
})

4.2.3 完整实现

资源加载错误 + js执行错误 + promise异常
jsError.js 复制代码
import getLastEvent from "../utils/getLastEvent"
import getSelector from "../utils/getSelector"
import tracker from "../utils/tracker"

export function injectJsError() {
  // 监听全局未捕获的错误
  window.addEventListener(
    "error",
    (event) => {
      console.log("error+++++++++++", event)
      let lastEvent = getLastEvent() // 获取到最后一个交互事件
      // 1、资源加载错误-----------
      if (event.target && (event.target.src || event.target.href)) {
        tracker.send({
          kind: "stability", // 监控指标的大类,稳定性
          type: "error", // 小类型,这是一个错误
          errorType: "resourceError", // js执行错误
          filename: event.target.src || event.target.href, // 哪个文件报错了
          tagName: event.target.tagName,
          selector: getSelector(event.target), // 代表最后一个操作的元素
        })
      } else {
        // 2、js执行错误-----------
        tracker.send({
          kind: "stability", // 监控指标的大类,稳定性
          type: "error", // 小类型,这是一个错误
          errorType: "jsError", // js执行错误
          message: event.message, // 报错信息
          filename: event.filename, // 哪个文件报错了
          position: `${event.lineno}:${event.colno}`, // 报错的行列位置
          stack: getLines(event.error.stack),
          selector: lastEvent ? getSelector(lastEvent.path) : "", // 代表最后一个操作的元素
        })
      }
    },
    true
  )
  // 3、promise异常---------------
  window.addEventListener(
    "unhandledrejection",
    (event) => {
      console.log("unhandledrejection-------- ", event);
      let lastEvent = getLastEvent(); // 获取到最后一个交互事件
      let message;
      let filename;
      let line = 0;
      let column = 0;
      let stack = "";
      let reason = event.reason;
      if (typeof reason === "string") {
        message = reason;
      } else if (typeof reason === "object") {
        message = reason.message;
        if (reason.stack) {
          let matchResult = reason.stack.match(/at\s+(.+):(\d+):(\d+)/);
          filename = matchResult[1];
          line = matchResult[2];
          column = matchResult[3];
        }
        stack = getLines(reason.stack);
      }
      tracker.send({
        kind: "stability", // 监控指标的大类,稳定性
        type: "error", // 小类型,这是一个错误
        errorType: "promiseError", // js执行错误
        message, // 报错信息
        filename, // 哪个文件报错了
        position: `${line}:${column}`, // 报错的行列位置
        stack,
        selector: lastEvent ? getSelector(lastEvent.path) : "", // 代表最后一个操作的元素
      })
    },
    true  // true代表在捕获阶段调用,false代表在冒泡阶段捕获,使用true或false都可以
  )
}

function getLines(stack) {
  return stack
    .split("\n")
    .slice(1)
    .map((item) => item.replace(/^\s+at\s+/g, ""))
    .join("^");
}

4.3 接口异常采集脚本

4.3.1 数据设计

success 复制代码
{
  "title": "前端监控系统", //标题
  "url": "http://localhost:8080/", //url
  "timestamp": "1590817024490", //timestamp
  "userAgent": "Chrome", //浏览器版本
  "kind": "stability", //大类
  "type": "xhr", //小类
  "eventType": "load", //事件类型
  "pathname": "/success", //路径
  "status": "200-OK", //状态码
  "duration": "7", //持续时间
  "response": "{"id":1}", //响应内容
  "params": "" //参数
}
error 复制代码
{
  "title": "前端监控系统",
  "url": "http://localhost:8080/",
  "timestamp": "1590817025617",
  "userAgent": "Chrome",
  "kind": "stability",
  "type": "xhr",
  "eventType": "load",
  "pathname": "/error",
  "status": "500-Internal Server Error",
  "duration": "7",
  "response": "",
  "params": ""
}

4.3.2 实现

使用webpack devServer模拟请求

  • 重写xhr的open、send方法
  • 监听load、error、abort事件
xhr.js 复制代码
import tracker from "../utils/tracker"

export function injectXHR() {
  let XMLHttpRequest = window.XMLHttpRequest
  let oldOpen = XMLHttpRequest.prototype.open
  XMLHttpRequest.prototype.open = function (method, url, async) {
    // 把上报接口过滤掉
    if (!url.match(/logstores/) && !url.match(/sockjs/)) {
      this.logData = { method, url, async }
    }
    return oldOpen.apply(this, arguments)
  }
  let oldSend = XMLHttpRequest.prototype.send
  XMLHttpRequest.prototype.send = function (body) {
    if (this.logData) {
      let startTime = Date.now()
      let handler = (type) => (event) => {
        // 持续时间
        let duration = Date.now() - startTime
        let status = this.status
        let statusText = this.statusText
        tracker.send({
          kind: "stability",
          type: "xhr",
          eventType: type,
          pathname: this.logData.url,
          status: status + "-" + statusText, // 状态码
          duration,
          response: this.response ? JSON.stringify(this.response) : "", // 响应体
          params: body || "", // 入参
        })
      }
      this.addEventListener("load", handler("load"), false)
      this.addEventListener("error", handler, false)
      this.addEventListener("abort", handler, false)
    }
    return oldSend.apply(this, arguments)
  }
}

4.4 白屏

在页面加载完成之后,如果页面上的空白点很多,就说明页面是白屏的,需要上报,这个上报的时机是:document.readyState === 'complete' 表示文档和所有的子资源已完成加载,表示load(window.addEventListener('load')状态事件即将被触发。

document.readyState 有三个值:loading(document正在加载),interactive(可交互,表示正在加载的状态结束,但是图像,样式和框架之类的子资源仍在加载),complete 就是完成,所以监控白屏需要在文档都加载完成的情况下触发。

onload.js 复制代码
export default function (callback) {
  if (document.readyState === "complete") {
    callback()
  } else {
    window.addEventListener("load", callback)
  }
}

4.4.1 数据设计

json 复制代码
{
  "title": "前端监控系统",
  "url": "http://localhost:8080/",
  "timestamp": "1590822618759",
  "userAgent": "chrome",
  "kind": "stability", //大类
  "type": "blank", //小类
  "emptyPoints": "0", //空白点
  "screen": "2049x1152", //分辨率
  "viewPoint": "2048x994", //视口
  "selector": "HTML BODY #container" //选择器
}

4.4.2 实现

  • elementsFromPoint方法可以获取到当前视口内指定坐标处,由里到外排列的所有元素
  • 根据 elementsFromPoint api,获取屏幕水平中线和竖直中线所在的元素
blankScreen.js 复制代码
import tracker from "../utils/tracker"
import onload from "../utils/onload"

export function blankScreen() {
  let wrapperElements = ["html", "body", "#container", ".content"]
  let emptyPoints = 0
  function getSelector(element) {
    const { id, className, nodeName } = element
    if (id) {
      return "#" + id
    } else if (className) {
      // 过滤空白符 + 拼接
      return (
        "." +
        className
          .split(" ")
          .filter((item) => !!item)
          .join(".")
      )
    } else {
      return nodeName.toLowerCase()
    }
  }
  function isWrapper(element) {
    let selector = getSelector(element)
    if (wrapperElements.indexOf(selector) !== -1) {
      emptyPoints++
    }
  }
  // 刚开始页面内容为空,等页面渲染完成,再去做判断
  onload(function () {
    let xElements, yElements
    for (let i = 0; i < 9; i++) {
      xElements = document.elementsFromPoint(
        (window.innerWidth * i) / 10,
        window.innerHeight / 2
      )
      yElements = document.elementsFromPoint(
        window.innerWidth / 2,
        (window.innerHeight * i) / 10
      )
      isWrapper(xElements[0])
      isWrapper(yElements[0])
    }
    // 白屏
    if (emptyPoints >= 0) {
      const centerElements = document.elementsFromPoint(
        window.innerWidth / 2,
        window.innerHeight / 2
      )
      console.log("emptyPoints++++++++++++++", getSelector(centerElements[0]))
      tracker.send({
        kind: "stability",
        type: "blank",
        emptyPoints: emptyPoints + "",
        screen: window.screen.width + "X" + window.screen.height,
        viewPoint: window.innerWidth + "X" + window.innerHeight,
        selector: getSelector(centerElements[0]),
      })
    }
  })
}
//screen.width  屏幕的宽度   screen.height 屏幕的高度
//window.innerWidth 去除工具条与滚动条的窗口宽度 window.innerHeight 去除工具条与滚动条的窗口高度

4.5 加载时间

4.5.1 性能指标

字段 描述 备注 计算方式
FP First Paint(首次绘制) 包括了任何用户自定义的背景绘制,它是首先将像素绘制到屏幕的时刻
FCP First Content Paint(首次内容绘制) 是浏览器将第一个 DOM 渲染到屏幕的时间,可能是文本、图像、SVG等,这其实就是白屏时间
FMP First Meaningful Paint(首次有意义绘制) 页面有意义的内容渲染的时间
LCP (Largest Contentful Paint)(最大内容渲染) 代表在viewport中最大的页面元素加载的时间
DCL (DomContentLoaded)(DOM加载完成) 当 HTML 文档被完全加载和解析完成之后, DOMContentLoaded 事件被触发,无需等待样式表、图像和子框架的完成加载
L (onLoad) 当依赖的资源全部加载完毕之后才会触发
TTI (Time to Interactive) 可交互时间 用于标记应用已进行视觉渲染并能可靠响应用户输入的时间点
FID First Input Delay(首次输入延迟) 用户首次和页面交互(单击链接,点击按钮等)到页面响应交互的时间

4.5.2 数据结构

json 复制代码
{
  "title": "前端监控系统",
  "url": "http://localhost:8080/",
  "timestamp": "1590828364183",
  "userAgent": "chrome",
  "kind": "experience",
  "type": "timing",
  "connectTime": "0",
  "ttfbTime": "1",
  "responseTime": "1",
  "parseDOMTime": "80",
  "domContentLoadedTime": "0",
  "timeToInteractive": "88",
  "loadTime": "89"
}

4.5.3 实现

js 复制代码
import tracker from "../utils/tracker";
import onload from "../utils/onload";
import formatTime from "../utils/formatTime";
import getLastEvent from "../utils/getLastEvent";
import getSelector from "../utils/getSelector";

export function timing() {
  let FMP, LCP;
  // 增加一个性能条目的观察者
  new PerformanceObserver((entryList, observer) => {
    const perfEntries = entryList.getEntries();
    FMP = perfEntries[0];
    observer.disconnect(); // 不再观察了
  }).observe({ entryTypes: ["element"] }); // 观察页面中有意义的元素
  // 增加一个性能条目的观察者
  new PerformanceObserver((entryList, observer) => {
    const perfEntries = entryList.getEntries();
    const lastEntry = perfEntries[perfEntries.length - 1];
    LCP = lastEntry;
    observer.disconnect(); // 不再观察了
  }).observe({ entryTypes: ["largest-contentful-paint"] }); // 观察页面中最大的元素
  // 增加一个性能条目的观察者
  new PerformanceObserver((entryList, observer) => {
    const lastEvent = getLastEvent();
    const firstInput = entryList.getEntries()[0];
    if (firstInput) {
      // 开始处理的时间 - 开始点击的时间,差值就是处理的延迟
      let inputDelay = firstInput.processingStart - firstInput.startTime;
      let duration = firstInput.duration; // 处理的耗时
      if (inputDelay > 0 || duration > 0) {
        tracker.send({
          kind: "experience", // 用户体验指标
          type: "firstInputDelay", // 首次输入延迟
          inputDelay: inputDelay ? formatTime(inputDelay) : 0, // 延迟的时间
          duration: duration ? formatTime(duration) : 0,
          startTime: firstInput.startTime, // 开始处理的时间
          selector: lastEvent
            ? getSelector(lastEvent.path || lastEvent.target)
            : "",
        });
      }
    }
    observer.disconnect(); // 不再观察了
  }).observe({ type: "first-input", buffered: true }); // 第一次交互

  // 刚开始页面内容为空,等页面渲染完成,再去做判断
  onload(function () {
    setTimeout(() => {
      const {
        fetchStart,
        connectStart,
        connectEnd,
        requestStart,
        responseStart,
        responseEnd,
        domLoading,
        domInteractive,
        domContentLoadedEventStart,
        domContentLoadedEventEnd,
        loadEventStart,
      } = window.performance.timing;
      // 发送时间指标
      tracker.send({
        kind: "experience", // 用户体验指标
        type: "timing", // 统计每个阶段的时间
        connectTime: connectEnd - connectStart, // TCP连接耗时
        ttfbTime: responseStart - requestStart, // 首字节到达时间
        responseTime: responseEnd - responseStart, // response响应耗时
        parseDOMTime: loadEventStart - domLoading, // DOM解析渲染的时间
        domContentLoadedTime:
          domContentLoadedEventEnd - domContentLoadedEventStart, // DOMContentLoaded事件回调耗时
        timeToInteractive: domInteractive - fetchStart, // 首次可交互时间
        loadTime: loadEventStart - fetchStart, // 完整的加载时间
      });
      // 发送性能指标
      let FP = performance.getEntriesByName("first-paint")[0];
      let FCP = performance.getEntriesByName("first-contentful-paint")[0];
      console.log("FP", FP);
      console.log("FCP", FCP);
      console.log("FMP", FMP);
      console.log("LCP", LCP);
      tracker.send({
        kind: "experience",
        type: "paint",
        firstPaint: FP ? formatTime(FP.startTime) : 0,
        firstContentPaint: FCP ? formatTime(FCP.startTime) : 0,
        firstMeaningfulPaint: FMP ? formatTime(FMP.startTime) : 0,
        largestContentfulPaint: LCP
          ? formatTime(LCP.renderTime || LCP.loadTime)
          : 0,
      });
    }, 3000);
  });
}

关键时间节点通过window.performance.timing获取

4.7 卡顿

  • 响应用户交互的响应时间如果大于100ms,用户就会感觉卡顿

4.7.1 数据设计 longTask

json 复制代码
{
  "title": "前端监控系统",
  "url": "http://localhost:8080/",
  "timestamp": "1590828656781",
  "userAgent": "chrome",
  "kind": "experience",
  "type": "longTask",
  "eventType": "mouseover",
  "startTime": "9331",
  "duration": "200",
  "selector": "HTML BODY #container .content"
}

4.7.2 实现

  • new PerformanceObserver
  • entry.duration > 100 判断大于100ms,即可认定为长任务
  • 使用 requestIdleCallback上报数据
longTask.js 复制代码
import tracker from "../utils/tracker";
import formatTime from "../utils/formatTime";
import getLastEvent from "../utils/getLastEvent";
import getSelector from "../utils/getSelector";

export function longTask() {
  new PerformanceObserver((list) => {
    list.getEntries().forEach((entry) => {
      if (entry.duration > 100) {
        let lastEvent = getLastEvent();
        requestIdleCallback(() => {
          tracker.send({
            kind: "experience",
            type: "longTask",
            eventType: lastEvent.type,
            startTime: formatTime(entry.startTime), // 开始时间
            duration: formatTime(entry.duration), // 持续时间
            selector: lastEvent
              ? getSelector(lastEvent.path || lastEvent.target)
              : "",
          });
        });
      }
    });
  }).observe({ entryTypes: ["longtask"] });
}

4.8 PV、UV、用户停留时间

4.8.1 数据设计 business

json 复制代码
{
  "title": "前端监控系统",
  "url": "http://localhost:8080/",
  "timestamp": "1590829304423",
  "userAgent": "chrome",
  "kind": "business",
  "type": "pv",
  "effectiveType": "4g",
  "rtt": "50",
  "screen": "2049x1152"
}

4.8.2 PV、UV、用户停留时间

PV(page view) 是页面浏览量,UV(Unique visitor)用户访问量。PV 只要访问一次页面就算一次,UV 同一天内多次访问只算一次。

对于前端来说,只要每次进入页面上报一次 PV 就行,UV 的统计放在服务端来做,主要是分析上报的数据来统计得出 UV。

pv.js 复制代码
import tracker from "../utils/tracker";
export function pv() {
  var connection = navigator.connection;
  tracker.send({
    kind: "business",
    type: "pv",
    effectiveType: connection.effectiveType, //网络环境
    rtt: connection.rtt, //往返时间
    screen: `${window.screen.width}x${window.screen.height}`, //设备分辨率
  });
  let startTime = Date.now();
  window.addEventListener(
    "unload",
    () => {
      let stayTime = Date.now() - startTime;
      tracker.send({
        kind: "business",
        type: "stayTime",
        stayTime,
      });
    },
    false
  );
}

异常分析

按照 5W1H 法则来分析前端异常,需要知道以下信息

  1. What,发⽣了什么错误:JS错误、异步错误、资源加载、接口错误等
  2. When,出现的时间段,如时间戳
  3. Who,影响了多少用户,包括报错事件数、IP
  4. Where,出现的页面是哪些,包括页面、对应的设备信息
  5. Why,错误的原因是为什么,包括错误堆栈、⾏列、SourceMap、异常录屏
  6. How,如何定位还原问题,如何异常报警,避免类似的错误发生

使用第三方框架

目前市面上成熟的框架有很多,这里罗列一下我目前知道的

可参考文章

# 使用vue+node搭建前端异常监控系统

# 从无到有<前端异常监控系统>落地

# 前端监控想用Sentry?看这一篇就够了

# 前端异常监控平台之Sentry落地

# 浅析前端错误监控警报系统Sentry基本使用、私有化部署步骤及部署注意事项、使用webpack插件上传sourceMap

# Easy-Monitor 3.0 使用指南

相关推荐
燃先生._.3 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
高山我梦口香糖4 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
black^sugar5 小时前
纯前端实现更新检测
开发语言·前端·javascript
2401_857600957 小时前
SSM 与 Vue 共筑电脑测评系统:精准洞察电脑世界
前端·javascript·vue.js
2401_857600957 小时前
数字时代的医疗挂号变革:SSM+Vue 系统设计与实现之道
前端·javascript·vue.js
GDAL7 小时前
vue入门教程:组件透传 Attributes
前端·javascript·vue.js
小白学大数据7 小时前
如何使用Selenium处理JavaScript动态加载的内容?
大数据·javascript·爬虫·selenium·测试工具
2402_857583497 小时前
基于 SSM 框架的 Vue 电脑测评系统:照亮电脑品质之路
前端·javascript·vue.js
java_heartLake8 小时前
Vue3之性能优化
javascript·vue.js·性能优化