咱们的业务在展开的过程当中,前端渲染的模式主要经历了三个阶段:服务端渲染、前端渲染和目前的同构直出渲染方案。javascript
服务端渲染的主要特色是先后端没有分离,前端写完页面样式和结构后,再将页面交给后端套数据,最后再一块儿联调。同时前端的发布也依赖于后端的同窗;可是优势也很明显:页面渲染速度快,同时 SEO 效果好。css
为了解决先后端没有分离的问题,后来就出现了前端渲染的这种模式,路由选择和页面渲染,所有放在前端进行。先后端经过接口进行交互,各端能够更加专一本身的业务,发布时也是独立发布。但缺点是页面渲染慢,严重依赖 js 文件的加载速度,当 js 文件加载失败或者 CDN 出现波动时,页面会直接挂掉。咱们以前大部分的业务都是前端渲染的模式,有部分的用户反馈页面 loading 时间长,页面渲染速度慢,尤为是在老旧的 Android 机型上这个问题更加地明显。html
node同构直出渲染方案
能够避免服务端渲染和前端渲染存在的缺点,同时先后端都是用 js 写的,可以实现数据、组件、工具方法等能实现先后端的共享。前端
首先来看下统计数据的结果,能够看到从前端渲染模式切换到 node 同构直出渲染模式后,整页的加载耗时从 3500ms 下降到了 2100 毫秒左右,总体的加载速度提升了将近 40%。java
但这个数据也不是最终的数据,由于当时要赶着上线的时间,不少东西还没来及优化,在后续的优化完成后,能够看到总体的的加载耗时又降低到了 1600ms 左右,再次降低了 500ms 左右。node
从 3500ms 下降到 1600ms,整整加快了 1900ms 的加载速度,总体提高了 54%。优化的手段在稍后也会讲解到。linux
在进行同构直出渲染方案,也对目前存在的技术,并结合自身的技术栈,对总体的架构进行梳理。ios
梳理出接下来存在的重点和难点:nginx
针对咱们项目初期的规划中,可能出现的问题一一进行解决,最终咱们的项目也可以实现的差不离了,某些比较大的模块我可能须要单独拿出来写一篇文章进行总结。git
使用 node 服务端同构指出渲染方案,最主要的是数据等结构可以实现先后端的同构共享
。
同构方面主要是实现:数据同构、状态同构、组件同构和路由同构等。
数据同构:对于相同的虚拟 DOM 元素,在服务端使用 renderToNodeStream 把渲染结果以“流“的形式塞给 response 对象,这样就不用等到 html 都渲染出来才能给浏览器端返回结果,“流”的做用就是有多少内容给多少内容,可以进一步改进了“第一次有意义的渲染时间”。同时,在浏览器端,使用 hydrate 把虚拟 dom 渲染为真实的 DOM 元素。若浏览器端对比服务端渲染的组件数,若发生不一致的状况时,再也不直接丢掉所有的内容,而是进行局部的渲染。所以在使用服务端的渲染过程当中,要保证前端后组件数据的一致性。这里将服务端请求的数据,插入到 js 的全局变量中,随着 html 一块儿渲染到浏览器端(脱水);这是在浏览器端,就能够拿到脱水的数据来初始化组件,添加交互等等(注水)。
状态同构方面:咱们这里使用mobx
为每一个用户建立一个全局的状态管理,这样数据能够进行统一的管理,而不用组件之间衣岑层传递。
组件同构:编写的基础组件或其余组件能够在服务端和客户端都能使用,同时使用typeof window==='undefined'
或process.browser
来判断当前是客户端仍是服务端,以此来屏蔽某端不支持的操做。
路由统一:客户端使用BrowserRouter
,服务端使用StaticRouter
。
在同构的过程当中,最开始时还没太理解这个概念,在编码阶段就遇到了这样的问题。例如咱们有个小轮播,这个轮播是将数组打乱随机展现的,我将从服务端请求到的数据打乱后渲染到页面上,结果调试窗口中输出一条错误信息(咱们这里用个样例数据来代替):
const list = ['勋章', '答题卡', '达人榜', '红包', '公告'];
复制代码
在render()
中随机输出:
{
list.sort(() => (Math.random() < 0.5 ? 1 : -1)).map(item => (
<p key={item}>{item}</p>
));
}
复制代码
结果在控制台输出了警告信息,同时最终展现出来的信息并非打乱排序:
Warning: Text content did not match. Server: "红包" Client: "答题卡"
输出的警告信息是由于客户端发现当前与服务端的数据不一致后,客户端从新进行了渲染,并给出了警告信息。咱们在渲染的时候才把数组打乱顺序,服务端是按照打乱顺序后的数据渲染的,可是传递给客户端的数据仍是原始数据,形成了先后端数据不一致的问题。
若是真的想要随机排序,能够在获取服务端的数据后,直接先排好序,而后再渲染,这样服务端和客户端的数据就会保持一致。在 nextjs 中就是getInitialProps
中操做。
基于咱们项目主要是在新闻客户端内运行的特色,咱们要考虑多种数据请求的方式:服务端、浏览器端、新闻客户端内,是否跨域等特色,而后造成一个完整的统一的多终端数据请求体系。
fetch
,而后使用XMLHttpRequest
发起接口请求。这里将多终端的数据进行封装,对外提供统一而稳定的调用方式,业务层无需关心当前的请求从哪一个终端发起。
// 发起接口请求
// @params {string} url 请求的地址
// @params {object} opts 请求的参数
const request = (url: string, opts: any): Promise<any> => {};
复制代码
同时,咱们也在请求接口的方法中添加上监控处理,如监控接口的请求量、耗时、失败率等信息,作到详细的信息记录,快速地进行定位和相应。
工程化是一个很大的概念,咱们这里仅仅从几个小点上进行说明。
咱们的项目目前都是部署在 skte 上,经过设置不一样的环境变量来区分当前是测试环境、预发布环境和正式环境。
同时,由于咱们的业务主要是在新闻客户端内访问的特色,不少的单元测试没法彻底覆盖,只能进行部分的单元测试,确保基础功能的正常运做。
如今接入了彻底自动化的 CI(持续集成)/CD(持续部署),基于 git 分支的方式进行发布构建,当开发者完成编码工做后,推送到 test/pre/master 分支后,进行单元测试的校验,经过后就会自动集成和部署。
缓存的优势自没必要多说:
但同时增长缓存,总体项目的复杂度也会增长,咱们须要评估下项目是否适合缓存、适用于哪一种缓存机制、缓存失效时如何处理。
缓存的机制主要有:
ETag
值,若 etag 值相同则使用缓存,不然请求服务器的数据,这就会形成不一样进程之间缓存的数据可能不同,etag 屡次失效的问题。内存缓存尤为要注意内存泄露的问题不一样的项目或者不一样的页面采用不一样的缓存策略。
在对接口的数据缓存时,尤为要注意的是接口正常返回时,才缓存数据,不然交给业务层处理。
同时,在使用缓存的过程当中,还注意缓存失效的问题。
缓存失效 | 含义 | 解决方案 |
---|---|---|
缓存雪崩 | 全部的缓存同一时间失效 | 设置随机的缓存时间 |
缓存穿透 | 缓存中不存在,数据库中也不存在 | 缓存中设置一个空值,且缓存时间较短 |
随机 key 请求 | 恶意地使用随机 key 请求,致使没法命中缓存 | 布隆过滤器,未在过滤器中的数据直接拦截 |
为缓存的 key | 缓存中没有单数据库中有 | 请求成功后,缓存数据,并将数据返回 |
详细的日志记录可以让咱们很方便地了解项目效果和排查问题。先后端的表现形式不同,咱们也区分先后端进行日志的上报。
前端主要上报页面的性能信息,服务端主要上报程序的异常、CPU 和内存的使用情况等。
在前端方面,咱们可使用window.performance
通过简单的计算获得一些网页的性能数据:
同时咱们也须要捕获前端代码中的一些报错:
window.addEventListener(
'error',
(message, filename, lineNo, colNo, stackError) => {
console.log(message); // 错误信息的描述
console.log(filename); // 错误所在的文件
console.log(lineNo); // 错误所在的行号
console.log(colNo); // 错误所在的列号
console.log(stackError); // 错误的堆栈信息
}
);
复制代码
当 Promise 被 reject 且没有 reject 处理器的时候,会触发 unhandledrejection 事件;这可能发生在 window 下,但也可能发生在 Worker 中。 这对于调试回退错误处理很是有用。
window.addEventListener('unhandledrejection', event => {
console.log(event);
});
复制代码
这里能够对fetch
和XMLHttpRequest
进行从新的封装,既不影响正常的业务逻辑,也能够进行错误上报。
XMLHttpRequest 的封装:
const xmlhttp = window.XMLHttpRequest;
const _oldSend = xmlhttp.prototype.send;
xmlhttp.prototype.send = function() {
if (this['addEventListener']) {
this['addEventListener']('error', _handleEvent);
this['addEventListener']('load', _handleEvent);
this['addEventListener']('abort', _handleEvent);
} else {
var _oldStateChange = this['onreadystatechange'];
this['onreadystatechange'] = function(event) {
if (this.readyState === 4) {
_handleEvent(event);
}
_oldStateChange && _oldStateChange.apply(this, arguments);
};
}
return _oldSend.apply(this, arguments);
};
复制代码
fetch 的封装:
const oldFetch = window.fetch;
window.fetch = function() {
return _oldFetch
.apply(this, arguments)
.then(res => {
if (!res.ok) {
// True if status is HTTP 2xx
// 上报错误
}
return res;
})
.catch(error => {
// 上报错误
throw error;
});
};
复制代码
服务端的日志根据严重程度,主要能够分为如下的几个类别:
咱们针对可能出现的异常程度进行不一样类别(level)的上报,这里咱们采用了两种记录策略,分别使用网络日志boss
和本地日志winston
分别进行记录。boss 日志里记录较为简单的信息,方便经过浏览器进行快速地排查;winston 记录详细的本地日志,当经过简单的日志信息没法定位时,则使用更为详细的本地日志进行排查。
使用winston
进行服务端日志的上报,按照日期进行分类,上报的主要信息有:当前时间、服务器、进程 ID、消息、堆栈追踪等:
// https://github.com/winstonjs/winston
logger = createLogger({
level: 'info',
format: combine(label({ label: 'right meow!' }), timestamp(), myFormat), // winston.format.json(),
defaultMeta: { service: 'user-service' },
transports: [
new transports.File({
filename: `/data/log/question/answer.error.${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}.log`,
level: 'error'
})
]
});
复制代码
同时 nodejs 服务自己的监控机制也充分利用上,例如包括 http 状态码,内存占用(process.memoryUsage)等。
在日志的统计过程当中,加入告警机制,当告警数量或者数值超过必定的范围,则向开发者的微信和邮箱发出告警信息和设备。例如其中的一条告警规则是:当页面的加载时间小于 10ms 或者超过 6000ms 则发出告警信息,小于 10ms 时说明页面挂掉了,大于 6000ms 说明服务器可能出现异常,致使资源加载时间过长。
同时也要及时地关注用户反馈平台,若产生了一个用户的反馈,必然是有更多的用户存在这样的问题。
日志记录和告警等都是事故发生后才产生的行为,咱们应当如何保证在咱们看到日志信息并修复问题以前的这段时间里,服务至少可以仍是是正常运行的,而不是白屏或者 5xx 等信息。这里咱们要作的就是线上服务的容灾处理。
可能存在的问题 | 容灾措施 |
---|---|
后端接口异常 | 使用默认数据,并及时告知接口方 |
瞬时流量高、CPU 负载率太高 | 自动扩容,并告警 |
node 服务异常,如 4xx,5xx 等 | nginx 自动将服务转向静态页面,并告警转发的次数 |
静态资源致使的样式异常 | 将首屏或者首页的样式嵌入到页面中 |
容灾处理与日志信息的记录,保障咱们项目可以正常地在线上运行。
nodejs 做为一种单线程、单进程运行的程序,若是只是简单的使用的话(node app.js
),存在着以下一些问题:
所幸,nodejs 为咱们提供了cluster
模块,什么是cluster:
简单的说,
其中:
cluster 模块能够建立共享服务器端口的子进程。这里举一个著名的官方案例:
const cluster = require('cluster');
const http = require('http');
const os = require('os');
if (cluster.isMaster) {
// 当前为主进程
console.log(`主进程 ${process.pid} 正在运行`);
// 启动子进程
for (let i = 0, len = os.cpus().length; i < len; i++) {
cluster.fork();
}
cluster.on('exit', worker => {
console.log(`子进程 ${worker.process.pid} 已退出`);
});
} else {
http.createServer((req, res) => {
res.writeHead(200);
res.end('hello world\n');
}).listen(8000);
console.log(`子进程 ${process.pid} 已启动`);
}
复制代码
当有进程退出时,则会触发exit
事件,例如咱们 kill 掉 69030 的进程时:
> kill -9 69030
子进程 69030 已退出
复制代码
咱们尝试 kill 掉某个进程,发现子进程是不会自动从新建立的,这里我能够修改下exit
事件,当触发这个事件后从新建立一个子进程:
cluster.on('exit', worker => {
console.log(`子进程 ${worker.process.pid} 已退出`);
// log日志记录
cluster.fork();
});
复制代码
主进程与子进程之间的通讯:每一个进程之间是相互独立的,但是每一个进程均可以与主进程进行通讯。这样就能把不少须要每一个子进程都须要处理的问题,放到主进程里处理,例如日志记录、缓存等。咱们在 3.4 缓存小节中也有讲“内存缓存没法达到进程之间的共享”,但是咱们能够把缓存提升到主进程中进行缓存。
if (cluster.isMaster) {
Object.values(cluster.workers).forEach(worker => {
// 向全部的进程都发布一条消息
worker.send({ timestamp: Date.now() });
// 接收当前worker发送的消息
worker.on('message', msg => {
console.log(
`主进程接收到 ${worker.process.pid} 的消息:` +
JSON.stringify(msg)
);
});
});
} else {
process.on('message', msg => {
console.log(`子进程 ${process.pid} 获取信息:${JSON.stringify(msg)}`);
process.send({
timestamp: msg.timestamp,
random: Math.random()
});
});
}
复制代码
不过若线上生产环境使用的话,咱们须要给这套代码添加不少的逻辑。这里可使用pm2
来维护咱们的 node 项目,同时 pm2 也能启用 cluster 模式。
pm2 的官网是pm2.keymetrics.io,github 是github.com/Unitech/pm2。主要特色有:
- 原生的集群化支持(使用 Node cluster 集群模块)
- 记录应用重启的次数和时间
- 后台 daemon 模式运行
- 0 秒停机重载,很是适合程序升级
- 中止不稳定的进程(避免无限循环)
- 控制台监控
- 实时集中 log 处理
- 强健的 API,包含远程控制和实时的接口 API ( Nodejs 模块,容许和 PM2 进程管理器交互 )
- 退出时自动杀死进程
- 内置支持开机启动(支持众多 linux 发行版和 macos)
nodejs 服务的工做均可以托管给 pm2 处理。
pm2 以当前最大的 CPU 数量启动 cluster 模式:
pm2 start server.js -i max
复制代码
不过咱们的项目使用配置文件来启动的,ecosystem.config.js:
module.exports = {
apps: [
{
name: 'question',
script: 'server.js',
instances: 'max',
exec_mode: 'cluster',
autorestart: true,
watch: false,
max_memory_restart: '1G',
env_test: {
NEXT_APP_ENV: 'testing'
},
env_pre: {
NEXT_APP_ENV: 'pre'
},
env: {
NEXT_APP_ENV: 'production'
}
}
]
};
复制代码
而后启动便可:
pm2 start ecosystem.config.js
复制代码
关于使用 node 来编写 cluster 模式,仍是用 pm2 来启动 cluster 模式,仍是要看项目的须要。使用 node 编写时,本身能够控制各个进程之间的通讯,让每一个进程作本身的事情;而 pm2 来启动的话,在总体健壮性上更好一些。
咱们应当首先保证首页和首屏的加载,一个是首屏须要的样式直接嵌入到页面中加载,再一个是首屏和次屏的数据分开加载。咱们在首页的数据主要是瀑布流的方式加载,而瀑布流是须要 js 计算的,所以这里咱们先加载几条数据,保证首屏是有数据的,而后接下来的数据使用 js 计算应当放在哪一个位置。
再一个是使用 service worker 来本地缓存 css 和 js 资源,更具体的使用,能够访问service worker 在新闻红包活动中的应用。
这里咱们使用 IntersectionObserver 封装了通用的组件懒加载方案,由于在使用 scroll 事件中,咱们可能还须要手动节流和防抖动,同时,由于图片加载的快慢,致使须要屡次获取元素的 offsetTop 值。而 IntersectionObserver 就能完美地避免这些问题,同时,咱们也能看到,这一属性在高版本浏览器中也获得了支持,在低版本浏览器中,我可使用 polyfill 的方式进行兼容处理处理;
我将这个功能封装为一个组件,对外提供几个监听方法,将须要懒加载的组件或者资源做为子组件,进行包裹,同时,咱们这里也建议建议使用者,使用默认的骨架屏撑起元素未渲染时的页面。由于在直接使用懒加载渲染时,假如不使用骨架屏的话,用户是先看到白屏,而后忽然渲染内容,页面给用户一种强烈抖动的感受。真实组件在最后真正展现出来时,须要必定的时间和空间,时间是从资源加载到渲染完毕须要时间;而空间指的是页面布局中须要给真实组件留出必定的问题,一个是为了不页面,再一个使用骨架屏后:
这里实现的通用懒加载组件,对外提供了几个回调方法:onInPage, onOutPage, onInited 等。
这个通用的组件懒加载方案可使用在以下的场景下:
固然,长列表无限滚动的优先,不只限于使用可见性代替滚动事件,也还有其余的优化手段。
虽然啰里啰嗦了一大堆,但也这是咱们同构直出渲染方案的开始,咱们还有很长的路要走。应用型技术的难点不是在克服技术问题,而是在于可以不断的结合自身的产品体验,发现其中存在的体验问题,不断使用更好的技术方案去优化用户的体验,为整个产品发展添砖加瓦。
若是你喜欢,欢迎关注个人公众号: