项目
- what 是什么
- why 解决了什么
- how 怎么做
业务稳定
为了生产环境的稳定性,需要做哪些工作?
故障预防、发现、止损-开发阶段(代码)
- 代码质量检查(ESLint、Prettier、StyleLint)。我们需要使用代码质量检查工具来检查代码风格、代码规范、代码错误等问题。常用的代码质量检查工具包括 ESLint、Prettier、StyleLint 等。
- 代码类型强校验。项目基于typescript开发。
- 代码review。避免一些逻辑上的错误。
- 锁定依赖版本。基于一些lock文件,避免依赖的更新导致线上故障。
- 静态代码分析。可以分析代码的质量、安全性、可靠性、可维护性等方面,提供详细的代码分析报告和建议。
故障预防、发现、止损-发布阶段(发布)
- 代码先在预发布环境通过测试;
- 灰度发布或者AB实验;就是发布时只会触达到一小部分用户,以验证功能;
- 发布平台提供快速回滚能力;
故障预防、发现、止损-线上阶段(监控)
- 错误监控。使用错误监控工具来监测和记录网站的错误情况,及时通知开发人员进行修复。常用的错误监控工具 Sentry 。
- 性能监控。监控网站的性能指标,例如FCP、LCP等。
- 白屏检测。如果页面白屏了,上报后台。
- 安全防护。需要考虑到安全问题,例如 XSS、CSRF、SQL 注入等攻击方式。例如 CSP、HTTPS、TOKEN 等。
- 日志监控。监控网站的访问日志和服务器日志,以便及时发现和排查解决问题。日志监控工具可以帮助我们收集和分析日志数据,并提供报警和提示功能。
- 流量监控。页面的pv、uv,QPS等
- 运维侧的监控。容灾备份、服务端监控,比如CPU、内存使用率等。
- 自动化测试。我们测试用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.onerror
和window.addEventListener
error事件;
- 主要通过劫持
- 当 Promise 被 reject 且没有 reject 处理器的时候,会触发 unhandledrejection 事件;
window.addEventListener("unhandledrejection", (error) => {})
- 其他的一些错误处理,vue/react 的全局函数
app.config.errorHandler、ErrorBoundary-componentDidCatch
等
其他日志的收集,是通过重写相应的函数,加入自己的逻辑来实现的,比如重写 console
其中使用TraceKit,主要是用来进行抹平各浏览器之间的差异,使得错误处理的逻辑统一;
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')来捕获资源加载失败的情况。
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
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 值。
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算法表达为更为精简的字符。
{
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。
移动端模拟
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。
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、下载源码
# 提前开启终端代理
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 标签中添加如下代码:
#elements-content{
background-color: red;
}
3、启动
本地文件启动:
# 即 使用 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 目录跑服务启动:
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 服务
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进程通信,基于消息队列实现:
// 主进程
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本地文件,请求跨域怎么办
- 在 BrowserWindow 的 webPreferences 选项中设置 webSecurity 为 false,禁用跨域检查。但是这种方式会带来一些安全风险,不建议在生产环境中使用。
const { BrowserWindow } = require('electron')
const win = new BrowserWindow({
webPreferences: {
webSecurity: false
}
})
- 在主进程中实现一个代理服务器,将 AJAX 请求转发到目标服务器,从而避免跨域问题。可以使用 http、https 模块或第三方库(如 express)实现代理服务器。这种方式可以在保证安全的前提下解决跨域问题,但需要额外编写代理服务器的代码。
Mock
核心是 Mock 的请求能体现在 devtools 的 Network 面板中,方便调试。
- 通过CDP协议,发送
Fetch.enable
命令,开启拦截XHR 和 Fetch请求, - debugger webContent 监听
message
事件,如果是Fetch.requestPaused
事件,拦截请求。 - 开关没开,或者未配置Mock,直接通过
Fetch.continueRequest
命令,继续请求。 - 开关开了,且配置了Mock,修改请求,然后通过
Fetch.fulfillRequest
命令,返回mock数据
多语言
1、自定义命名,npm安装后,在项目每次启动或者部署时,根据环境拉多语言配置,本地生成以语言命名的json文件; 2、通过vue plugin的形式,根据语言,动态注入语言文件到全局,同时提供类型,供业务使用; 3、提供函数工具包,如占位符替换、文案自适应大小、等;
优化:
- 单复数问题,通过在多语言文案中配置占位符,通过正则去匹配,分条件使用。
- 文件太多导致包太大。可以打包时,把文件单独打包,后面通过当前环境动态引入。
- 怎么知道是哪种语言。站内通过navigator.language,站外通过url query。
- SSR
多语言导致的UI问题
如文案长度、样式等;
- 文案简化
- 对于不重要的文本,文本溢出省略。
- 使用流式布局,避免使用固定宽高。
- 一些只能手动处理的,通过不同语言的class,写不同的样式。
- 文案自适应大小函数。根据指定的宽高,和一段文本,如果溢出了,就缩小字体,直到不溢出,返回html或者字体大小
// 调整字体大小的函数
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种。更别说,各语言下的细则还有差异。
Intl.PluralRules.prototype.select()
返回一个字符串,指示用于区域敏感的格式化的复数类别。
// 英文:会返回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'
// 阿拉伯语:会区分数字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'
- 直接写根据CLDR规则,区分
zero、one、two、few、many、other
;后台语言也需要同一个key配置多个值,用key_1
key_2
key_3
key_4
key_5
key_6
来区分。 - 通过类型判断,自动拼接正确的key读取
RTL
RTL(Right to Left)是一种文字排版方式,从右向左排版,是阿拉伯语、希伯来语等语言的书写方式。
1.html
在HTML文档的<html>
标签中设置dir属性为"rtl",这将指示浏览器页面内容应该从右到左显示。
<html dir="rtl" lang="ar">
2.css
使用CSS的direction属性来设置文本方向,以及使用:lang()伪类或其他类选择器来应用RTL特定的样式。
body {
direction: rtl;
}
html:lang(ar) {
/* 针对阿拉伯语的特定样式 */
}
- 镜像翻转
html:lang(ar) {
transform: scaleX(-1);
}
不需要的再翻转回来
多主题
干三件事 1、通过vue plugin的形式,在vue全局挂载当前主题值,供一些业务使用; 2、根据当前主题值,在body上挂载对应的class;早期考虑兼容性,通过scss写的mixin函数,自动在你写的色值class前自动添加当前主题对应的class;后来改成css变量方案。 3、监听主题状态的变化,安卓和ios12及以上通过媒体查询,以下通过和端侧约定的自定义事件;