Skip to content

项目

  • what 是什么
  • why 解决了什么
  • how 怎么做

业务稳定

为了生产环境的稳定性,需要做哪些工作?

故障预防、发现、止损-开发阶段(代码)

  1. 代码质量检查(ESLint、Prettier、StyleLint)。我们需要使用代码质量检查工具来检查代码风格、代码规范、代码错误等问题。常用的代码质量检查工具包括 ESLint、Prettier、StyleLint 等。
  2. 代码类型强校验。项目基于typescript开发。
  3. 代码review。避免一些逻辑上的错误。
  4. 锁定依赖版本。基于一些lock文件,避免依赖的更新导致线上故障。
  5. 静态代码分析。可以分析代码的质量、安全性、可靠性、可维护性等方面,提供详细的代码分析报告和建议。

故障预防、发现、止损-发布阶段(发布)

  1. 代码先在预发布环境通过测试;
  2. 灰度发布或者AB实验;就是发布时只会触达到一小部分用户,以验证功能;
  3. 发布平台提供快速回滚能力;

故障预防、发现、止损-线上阶段(监控)

  1. 错误监控。使用错误监控工具来监测和记录网站的错误情况,及时通知开发人员进行修复。常用的错误监控工具 Sentry 。
  2. 性能监控。监控网站的性能指标,例如FCP、LCP等。
  3. 白屏检测。如果页面白屏了,上报后台。
  4. 安全防护。需要考虑到安全问题,例如 XSS、CSRF、SQL 注入等攻击方式。例如 CSP、HTTPS、TOKEN 等。
  5. 日志监控。监控网站的访问日志和服务器日志,以便及时发现和排查解决问题。日志监控工具可以帮助我们收集和分析日志数据,并提供报警和提示功能。
  6. 流量监控。页面的pv、uv,QPS等
  7. 运维侧的监控。容灾备份、服务端监控,比如CPU、内存使用率等。
  8. 自动化测试。我们测试用py跑自动化脚本,测试一些核心功能页面是否可用。

白屏监测

原因

  • JS错误

  • 请求异常

  • 静态资源异常

  • 检测页面关键DOM的是否渲染

  • 通用的DOM渲染监听

  • H5截图(canvas绘图)检测

  • native截图(容器截屏)检测

  • 利用performance.getEntries("paint")获取fp/fcp来感知,为0表示白了

  • 使用 elementsFromPoint 采样,看看X个采样点对应的DOM是不是除了全局容器组件外的其他dom,如果没有内容的点个数 Y === X,则为空白

  • 饿了么-Emonitor 白屏监控方案 饿了么的白屏监控方案,其原理是记录页面打开 4s 前后 html 长度变化,并将数据上传到饿了么自研的时序数据库。如果一个页面是稳定的,那么页面长度变化的分布应该呈现「幂次分布」曲线的形态,p10、p20 (排在文档前 10%、20%)等数据线应该是平稳的,在一定的区间内波动,如果页面出现异常,那么曲线一定会出现掉底的情况。

  • 字节 从 document.body 开始遍历 Dom 树,每次遍历到一个可见 Dom 元素根据 Dom 元素的层级增加一个分数,依次计算得到的分数。数值越小,白屏数越严重。

sentry

业务关心的指标,比如性能上哪些,报错上哪些

报错关心指标:

  • 一个新的issue发生500
  • 一个issue发生了在一小时内发生了1K

报错链接、错误信息、调用栈、用户设备环境、用户信息、用户点击行为等

  • 前端代码层面的,常见的如容错等
  • 接口报错

报错原理

错误拦截

原理是通过劫持error事件,然后上报后台。

一种错误简单分为两种:

  • 当资源加载失败或无法使用时,会在Window对象触发error事件。
    • 主要通过劫持 window.onerrorwindow.addEventListener error事件;
  • 当 Promise 被 reject 且没有 reject 处理器的时候,会触发 unhandledrejection 事件;
    • window.addEventListener("unhandledrejection", (error) => {})
  • 其他的一些错误处理,vue/react 的全局函数 app.config.errorHandler、ErrorBoundary-componentDidCatch

其他日志的收集,是通过重写相应的函数,加入自己的逻辑来实现的,比如重写 console

其中使用TraceKit,主要是用来进行抹平各浏览器之间的差异,使得错误处理的逻辑统一;

js
window.onerror = function(message, source, lineno, colno, error) { ... }
window.addEventListener('error', function(event) { ... }, true)
// 函数参数:
    // message:错误信息(字符串)。可用于HTML onerror=""处理程序中的event。
    // source:发生错误的脚本URL(字符串)
    // lineno:发生错误的行号(数字)
    // colno:发生错误的列号(数字)
    // error:Error对象(对象

// 大家可以看到 JS 错误监控里面有个 window.onError,
// 又用了 window.addEventListener('error'),
// 其实两者并不能互相代替。
// window.onError 是一个标准的错误捕获接口,它可以拿到对应的这种 JS 错误;
// window.addEventListener('error')也可以捕获到错误,
// 但是它拿到的 JS 报错堆栈往往是不完整的。
// 同时 window.onError 无法获取到资源加载失败的一个情况,
// 必须使用 window.addEventListener('error')来捕获资源加载失败的情况。
js
window.addEventListener('error', (event) => {
  if (event.target !== window) {
    // 这是一个资源加载错误
    ...
  }
}, true);

如果 event.target 等于 window,那么这个错误就是一个脚本错误。因为在浏览器中,脚本错误会冒泡到 window 对象。

如果 event.target 不等于 window,那么这个错误就是一个资源加载错误。因为资源加载错误(例如图片、样式表、脚本文件等)不会冒泡,它们会在发生错误的元素上触发一个 error 事件。所以 event.target 就是发生错误的元素。

性能监控

sentry主要使用 web vitals,主要依靠PerformanceObserver实现

什么是 Web Vitals ,Google 给的定义是一个良好网站的基本指标 而在 Web Vitals 指标中,Core Web Vitals 是其中最重要的核心,目前包含三个指标:

LCP 显示最大内容元素所需时间 (衡量网站初次载入速度) Largest Contentful Paint FID 首次输入延迟时间 (衡量网站互动顺畅程度) First Input Delay CLS 累计版面配置移转 (衡量网页元件视觉稳定性) Cumulative Layout Shift

关注的指标:FCP、LCP、FID、CLS

FP (First Paint) FCP (First Contentful Paint)

控制在1.8 秒或以内

FP:首次渲染的时间点。在性能统计指标中,从用户开始访问 Web 页面的时间点到 FP 的时间点这段时间可以被视为 白屏时间,也就是说在用户访问 Web 网页的过程中,FP 时间点之前,用户看到的都是没有任何内容的白色屏幕,用户在这个阶段感知不到任何有效的工作在进行。

FP与FCP这两个指标之间的主要区别是:

FP是当浏览器开始绘制内容到屏幕上的时候,只要在视觉上开始发生变化,无论是什么内容触发的视觉变化,在这一刻,这个时间点,叫做FP。 FCP指的是浏览器首次绘制来自DOM的内容。例如:文本,图片,SVG,canvas元素等,这个时间点叫FCP。FCP控制在1.8 秒或以内。

获取:performance.getEntriesByType('paint')

LCP Largest Contentful Paint

js
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    console.log('LCP candidate:', entry.startTime, entry);
  }
})
.observe({type: 'largest-contentful-paint', buffered: true});

FID

控制在100 毫秒或以内

FID 的计算需要用户真实操作页面,可以借助 Event Timing API 进行测量:创建 PerformanceObserver 对象,监听 first-input 事件,监听到 first-input 事件后,利用 Event Timing API,通过事件的开始处理时间,减去事件的发生时间,即为 FID。 因为 FID 的值严重依赖用户触发操作的时机,需要考虑 FID 值的分布曲线。一般情况下,建议采用 95 分位的指标值,能反映最差的用户交互体验对应的 FID 值。

js
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    const delay = entry.processingStart - entry.startTime;
    console.log('FID candidate:', delay, entry);
  }
}).observe({type: 'first-input', buffered: true});

CLS

累积布局偏移 (CLS) 是测量视觉稳定性的一个以用户为中心的重要指标,因为该项指标有助于量化用户经历意外布局偏移的频率,较低的 CLS 给用户呈现的效果是交互流程自然、没有延迟和卡顿。

Time to Interactive 即从页面加载开始到页面处于完全可交互状态所花费的时间。页面处于完全可交互状态时.

满足以下 3 个条件:

  • 页面已经显示有用内容。
  • 页面上的可见元素关联的事件响应函数已经完成注册。
  • 事件响应函数可以在事件发生后的 50ms 内开始执行。

上传source-map

vue2 使用 @sentry/webpack-plugin、vue3 使用 vite-plugin-sentry

大概的一个过程:

  • 在webpack、vite的afterEmit钩子,获取到打包之后的文件。
  • 过滤得出文件类型是/\.js$|\.map$/结尾的就上传到sentry的服务器上。
  • 上传完毕只删除/\.map$/结尾的文件,但是官方的没提供删除方法,在postbuild时执行删除:

当执行npm run build的时候,会按序执行npm run prebuild && npm run build && npm run postbuild。

Source map(源映射)是一种文件,它提供了源代码和编译后代码之间的映射关系。它通常用于调试和排除生产环境代码中的错误。 整个文件就是一个JavaScript对象,核心是位置映射,即计算前后代码的位置映射,组成数字,其中为了将这个数字缩小,使用了相对位置等优化,最后将这个数字用VLQ算法表达为更为精简的字符。

js
{
  version : 3,
  file: "out.js",
  sourceRoot : "",
  sources: ["foo.js", "bar.js"],
  names: ["src", "maps", "are", "fun"],
  mappings: "AAgBC,SAAQ,CAAEA"
}
- version:Source map的版本,目前为3。
- file:转换后的文件名。
- sourceRoot:转换前的文件所在的目录。如果与转换前的文件在同一目录,该项为空。
- sources:转换前的文件。该项是一个数组,表示可能存在多个文件合并。
- names:转换前的所有变量名和属性名。
- mappings:记录位置信息的字符串。

运维侧监控

监测服务端内存、磁盘使用率 域名、资源、接口的响应码,每秒500的占比大于50%

hybrid

桥接

JS 调用 Native 通信大致有三种方法:

  • 拦截 Scheme
  • 弹窗拦截
  • 注入 JS 上下文

拦截 Scheme 通过新建iframe,改变src,端侧拦截实现。

tips:

  • 不能使用location.href,webview对连续跳转有限制,多次触发,后面的会丢;
  • url有长度限制,超长会被截断;
  • JSON对象会被JSON.stringify后传递

弹窗拦截

通过JS 调起 alert、prompt、confirm,端侧监听来实现; WKWebview可以通过scriptMessageHandler

注入 JS 上下文

端侧直接在 JS上下文 window下注入方法,可以让JS直接调用原生。支持传JSON对象

Native 调用 JS 通信:

直接执行 JS 字符串,有点像eval

现状

分三种 一个是被动事件监听on。中间层在window下维护一个闭包,暴露一些函数。H5通过暴露的监听方法监听约定事件,函数会把监听回调以事件名为key,维护一个相应的回调队列,将监听回调push进去。端侧会在webview的生命周期调用window下的_dispatch方法,拿到这个回调队列,然后遍历执行。

第二种是主动发起异步通信dispatch。H5调用中间层,传入事件名、参数、回调;中间层为该事件生成一个自增的callbackId,并将事件名、参数、callbackId存储在一个调用队列中,同时维护一个回调事件中心,以callbackId为key,回调函数为值,以供后面触发回调;然后通过更改iframe.src,通知端侧;

端侧拿到通知,调用window下方法,通过更改iframe.src,拿到JSON.stringify调用的调用队列,然后清空队列。每执行完成一个调用,调用window下_dispatch方法,传入返回值,方法会根据返回值中的responseId作为callbackId,找到对应回调函数,传入参数并执行。

第三种是主动发起同步通信dispatchSync。IOS是通过端侧拦截window.prompt(),安卓不太清楚。

优缺点,就是url的长度有限。

原理、通信

https://juejin.cn/post/6916316666208976904https://juejin.cn/post/7199297355748458551https://juejin.cn/post/6844903585268891662

开发者工具

H5容器

Electron 中有三种方式可以在渲染进程中打开外部URL,分别是 iframe、webview 以及 BrowserViews。其中 iframe 因为调试起来较为复杂,BrowserViews 是独立渲染,无法被宿主渲染进程的组件遮盖,所以我们采用 webview 的方式打开外部页面。

默认情况下,webview 标签在 Electron >= 5 中被禁用,需要在 Electron 的 BrowserWindow 初始化配置中将 webviewTag 属性设置为 true 即可开启webview。

移动端模拟

js
const webviewEle = document.querySelector("webview");
webContents.debugger.sendCommand("Emulation.setDeviceMetricsOverride", {
  deviceScaleFactor: 3, // 设备dpi  
  mobile: true // 是否为移动端设备  
  width: webviewEle.clientWidth,
  height: webviewEle.clientHeight,
  screenWidth: 375, // 设备屏幕宽度  
  screenHeight: 812 // 设备屏幕高度
});

devtools调试

webview容器可以通过openDevtools()打开调试工具,但是会恒定是mode:'detach',即独立窗口打开,无法嵌入到electron中。

故需要在页面里再嵌入一个webview,通过webContents.setDevToolsWebContents(),使用 devToolsWebContents 作为目标 WebContents 来显示 devtools。

js
const { webContents } = require('electron')

ipcMain.handle('open-devtools', (event, targetContentsId, devtoolsContentsId) => {
  const target = webContents.fromId(targetContentsId)
  const devtools = webContents.fromId(devtoolsContentsId)
  target.setDevToolsWebContents(devtools)
  target.openDevTools()
  target.debugger.attach()
  devtools.once("dom-ready", () => {
  // 解决devtools无法没有内容的问题
    devtools.reload();
    setTimeout(() => target.reload(), 500)
  });
})

但是如此会有一个问题:当webview开启断点调试功能,会导致所有webview都无法点击。如: [Bug]: One webview paused by debugger prevents all webviews from receiving click events

Electron 6 BrowserWindow Debugger freezes indefinitely, without apparent pattern

解决方法:

1、设置devtools的 Disable paused state overlay 选项为true,关闭浮层 2、自编译Chrome devtools

devtools自编译

1、下载源码

bash
# 提前开启终端代理
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
# depot_tools本地绝对路径,以使用后续的命令
export PATH=/path/to/depot_tools:$PATH

mkdir devtools
cd devtools
# fetch devtools
fetch devtools-frontend
cd devtools-frontend

# 本地编译
gn gen out/Default
autoninja -C out/Default

2、修改源码

进入 /depot_tools/devtools/devtools-frontend/out/Default/gen/front_end/devtools_app.html,在 style 标签中添加如下代码:

css
#elements-content{
  background-color: red;
}

3、启动

使用 Google Chrome Canary 版本

本地文件启动:

bash
# 即 使用 Google Chrome Canary 命令行启动,通过 --custom-devtools-frontend 指定自编译的 devtools front_end 路径
# 注意:这里要使用 sudo
sudo /Applications/Google\ Chrome\ Canary.app/Contents/MacOS/Google\ Chrome\ Canary --custom-devtools-frontend=file:///Users/usernamexxx/Desktop/personal/depot_tools/devtools/devtools-frontend/out/Default/gen/front_end/

在 front_end 目录跑服务启动:

bash
sudo /Applications/Google\ Chrome\ Canary.app/Contents/MacOS/Google\ Chrome\ Canary --custom-devtools-frontend=http://127.0.0.1:5501/

连接 Chrome Devtools 和 webview

在主进程中创建 websocket 连接,这里使用 ws 库创建 ws 服务

js
const { WebSocketServer } = require("ws");
const wss = new WebSocketServer({ port: 3003 });
wss.on("connection", (ws, req) => {
  console.log("WebSocketServer connection ===>>>>>", req.url)
  const webContentsId = req.url.replace(/^\//, "");
  // H5 webContent
  const webContent = webContents.fromId(Number(webContentsId));
  if (!webContent.debugger.isAttached()) {
    webContent.debugger.attach();
  }
  webContent.debugger.on("message", (ev, method, params) => {
    // console.log("debugger message ===>>>>>", method, params)
    // H5 webContent 向 devtools 发送消息
    ws.send(JSON.stringify({ method, params }));
  });
  ws.on("close", () => {
    // webContent.debugger.off("message");
  });
  ws.on("message", (message) => {
    const { method, params, id } = JSON.parse(message.toString());
    // devtools 向 H5 webContent 发送消息
    console.log("devtools message ===>>>>>", method, params, id)
    webContent.debugger
      .sendCommand(method, params)
      .then((res) => {
        ws.send(JSON.stringify({ id, result: res }));
      })
      .catch((err) => {
        ws.send(JSON.stringify({ id, error: err }));
      });
  });
  ws.on("error", () => {
    wss.close();
  });
});

IPC通信的原理

IPC(Inter-Process Communication,进程间通信)

进程通信就是 ipc(Inter-Process Communication),两个进程可能是一台计算机的,也可能网络上的不同计算机的进程,所以进程通信方式分为两种:

  • 本地过程调用 LPC(local procedure call)

实现方式如:信号量、管道、消息队列、共享内存(管道是通过读写文件的方式来通信)

  • 远程过程调用 RPC(remote procedure call)

通过网络协议来通信,比如 http、websocket

electron 的IPC进程通信,基于消息队列实现:

js
// 主进程
import { ipcMain } from 'electron'

ipcMain.on('异步事件', (event, arg) => {
  event.sender.send('异步事件返回', 'yyy')
})

// 渲染进程
import { ipcRenderer } from 'electron'

ipcRender.on('异步事件返回', (event, arg) => {
  const message = `异步消息: ${arg}`
})
ipcRenderer.send('异步事件', 'xxx')

参考:深入了解Node.js和Electron是如何做进程通信的

loadFile本地文件,请求跨域怎么办

  1. 在 BrowserWindow 的 webPreferences 选项中设置 webSecurity 为 false,禁用跨域检查。但是这种方式会带来一些安全风险,不建议在生产环境中使用。
js
const { BrowserWindow } = require('electron')

const win = new BrowserWindow({
  webPreferences: {
    webSecurity: false
  }
})
  1. 在主进程中实现一个代理服务器,将 AJAX 请求转发到目标服务器,从而避免跨域问题。可以使用 http、https 模块或第三方库(如 express)实现代理服务器。这种方式可以在保证安全的前提下解决跨域问题,但需要额外编写代理服务器的代码。

Mock

核心是 Mock 的请求能体现在 devtools 的 Network 面板中,方便调试。

  1. 通过CDP协议,发送 Fetch.enable 命令,开启拦截XHR 和 Fetch请求,
  2. debugger webContent 监听 message 事件,如果是 Fetch.requestPaused 事件,拦截请求。
  3. 开关没开,或者未配置Mock,直接通过 Fetch.continueRequest 命令,继续请求。
  4. 开关开了,且配置了Mock,修改请求,然后通过 Fetch.fulfillRequest 命令,返回mock数据

多语言

1、自定义命名,npm安装后,在项目每次启动或者部署时,根据环境拉多语言配置,本地生成以语言命名的json文件; 2、通过vue plugin的形式,根据语言,动态注入语言文件到全局,同时提供类型,供业务使用; 3、提供函数工具包,如占位符替换、文案自适应大小、等;

优化:

  • 单复数问题,通过在多语言文案中配置占位符,通过正则去匹配,分条件使用。
  • 文件太多导致包太大。可以打包时,把文件单独打包,后面通过当前环境动态引入。
  • 怎么知道是哪种语言。站内通过navigator.language,站外通过url query。
  • SSR

多语言导致的UI问题

如文案长度、样式等;

  • 文案简化
  • 对于不重要的文本,文本溢出省略。
  • 使用流式布局,避免使用固定宽高。
  • 一些只能手动处理的,通过不同语言的class,写不同的样式。
  • 文案自适应大小函数。根据指定的宽高,和一段文本,如果溢出了,就缩小字体,直到不溢出,返回html或者字体大小
js
// 调整字体大小的函数
function adjustFontSizeToFit(containerWidth, containerHeight, text, initialFontSize = 16) {
  const tempDiv = document.createElement('div');
  tempDiv.style.position = 'absolute';
  tempDiv.style.visibility = 'hidden';
  tempDiv.style.height = 'auto';
  tempDiv.style.width = containerWidth + 'px';
  tempDiv.style.fontSize = initialFontSize + 'px';
  tempDiv.innerHTML = text;
  document.body.appendChild(tempDiv);

  let currentFontSize = initialFontSize;
  while ((tempDiv.scrollHeight > containerHeight || tempDiv.scrollWidth > containerWidth) && currentFontSize > 1) {
    currentFontSize--;
    tempDiv.style.fontSize = currentFontSize + 'px';
  }

  document.body.removeChild(tempDiv);

  return `<div style="width: ${containerWidth}px; height: ${containerHeight}px; font-size: ${currentFontSize}px; overflow: hidden;">${text}</div>`;
}

长文案加变量的翻译问题

  • 相当于模版语法,用约定的占位符,通过正则匹配,分条件使用。

单复数问题

以阿拉伯语举例,它的名词单复数格式变化有6种之多,俄罗斯语、乌克兰语、波兰语等有4种,拉脱维亚语、立陶宛语等有3种。更别说,各语言下的细则还有差异。

  1. new Intl.PluralRules()

Intl.PluralRules.prototype.select() 返回一个字符串,指示用于区域敏感的格式化的复数类别。

js
// 英文:会返回one、other
new Intl.PluralRules('en-US').select(0);
// → 'other'
new Intl.PluralRules('en-US').select(1);
// → 'one'
new Intl.PluralRules('en-US').select(2);
// → 'other'
new Intl.PluralRules('en-US').select(100);
// → 'other'
js
// 阿拉伯语:会区分数字zero、one、two、few、many、other
new Intl.PluralRules('ar-EG').select(0);
// → 'zero'
new Intl.PluralRules('ar-EG').select(1);
// → 'one'
new Intl.PluralRules('ar-EG').select(2);
// → 'two'
new Intl.PluralRules('ar-EG').select(6);
// → 'few'
new Intl.PluralRules('ar-EG').select(18);
// → 'many'
new Intl.PluralRules('ar-EG').select(100);
// → 'other'
  1. 直接写根据CLDR规则,区分 zero、one、two、few、many、other;后台语言也需要同一个key配置多个值,用 key_1 key_2 key_3 key_4 key_5 key_6 来区分。
  2. 通过类型判断,自动拼接正确的key读取

RTL

RTL(Right to Left)是一种文字排版方式,从右向左排版,是阿拉伯语、希伯来语等语言的书写方式。

1.html

在HTML文档的<html>标签中设置dir属性为"rtl",这将指示浏览器页面内容应该从右到左显示。

html
<html dir="rtl" lang="ar">

2.css

使用CSS的direction属性来设置文本方向,以及使用:lang()伪类或其他类选择器来应用RTL特定的样式。

css
body {
  direction: rtl;
}

html:lang(ar) {
  /* 针对阿拉伯语的特定样式 */
}
  1. 镜像翻转
css
html:lang(ar) {
   transform: scaleX(-1);
}

不需要的再翻转回来

多主题

干三件事 1、通过vue plugin的形式,在vue全局挂载当前主题值,供一些业务使用; 2、根据当前主题值,在body上挂载对应的class;早期考虑兼容性,通过scss写的mixin函数,自动在你写的色值class前自动添加当前主题对应的class;后来改成css变量方案。 3、监听主题状态的变化,安卓和ios12及以上通过媒体查询,以下通过和端侧约定的自定义事件;