海外项目的 SSR 开发实践

总篇111篇 2021年第2篇

前言

进入海外项目的初期,从外包团队中接手两个 Vue
CSR
的项目。在国际化的背景下,项目对 SEO
及社媒分享的需求是优先级最高的。之前的项目在这些方面有很大的提升空间,如果在旧有项目的基础上去实现 SEO
的需求,需要团队去了解针对 CSR
架构的   Lambada
方案。另外旧有项目的性能存在一定优化空间。经过考虑,开启新的项目来继续完成海外产品的需求是更优的选择。 SSR
的优点是 SEO
友好,有更短的白屏时间,看上去和需求完美契合。那么接下来需要选择前端框架。在 Vue
React
之间进行调研,结果如下:

  1. Vue
    支持单向数据绑定和双向数据绑定,但是项目已经使用的   Vue
    的版本在 2.6.11, Vue3
    的发布时间不定,与之配套的UI库     Element-UI
    几乎是停止维护的状态。

  2. React
    的下载量更高,每个月都有更新的小版本,社区活跃。

    并且有着和国际化产品设计效果一致,使用 Google Material Design
    风格的 UI
    库   Material-UI

Next.js
Nuxt.js
是目前成熟的同构框架,前者基于 React
,后者基于 Vue
。有了这些框架,开发者可以方便地搭建一个同构应用:只对首屏同构直出。考虑到 Vue
React
的社区活跃度及 UI
生态,决定一边维护历史项目,一边循序渐进得使用   Next.js
开发新业务功能,对历史功能进行重构和优化,进行服务器端渲染。
接下来,笔者就分享一些项目中遇到的一些技术细节点,希望对大家有所帮助。

自定义页面路由

基于搜索引擎优化的需求,需要特定的页面路径。

比如英国经销商页面要求的路径为 /uk/dealer
,德国经销商页面要求的路径则为 de/handler

Next.js
的页面路由能够提供自定义的动态化路由配置,相应的文件名成需要依据路由的要求写成:    pages/[locale]/[content].js

对应的 query
为:

{ "locale": "uk", "content": "dealer" }
{ "locale": "de", "content": "handler" }

当页面路径有更多的参数要求时,这样的文件名就显得过长。对比之下,自定义的路由方式显得更加灵活。

router.get([
'/:locale(uk)/dealer',
'/:locale(de)/handler'], (req, res) => {
// 第四个参数 `query` 用于入口文件使用 getServerSideProps 方法时,将其传入到 useRouter 中
// 注意在命名路由时, query 与 params 不能重名
const query = { ...req.query, ...req.params };
return app.render(req, res, '/demo/post', query);
});

Next.js 获取数据

Next.js
会在每次请求时候,在服务端调用          getServerSideProps
这个方法。

这个方法只会在服务器端运行,主要是升级 9.3
之前的 getInitialProps
方法。

举个例子: NewsPage
是一个页面组件,它接收在服务器端就请求到的数据,并利用这些数据进行首屏的数据渲染。你可以在 getServerSideProps
中对得到的接口数据进行处理。

9.3
之前的 getInitialProps
方法有一个很大的缺陷是在浏览器中 req
re
对象会是 undefine
。也就是使用它的页面,如果是浏览器渲染你需要在组件内再显式地请求一次,开发体验不太好。

如果无特殊问题,建议使用 getServerSideProps
替代 getInitialProps
方法。

// 此函数在每次请求改页面时被调用
export async function getServerSideProps({ req, res }) {
const { query, cookies } = req;

// res 是 express 的方法,借助它去设置缓存
res.set('Cache-Control', 'public, max-age=60'); // 设置缓存1分钟
// 获取接口数据
const news = await request('https://.../detail',{
locale: query.locale
})
// 通过返回 { props: news } 对象,NewsPage 组件在渲染时将接收到 `news` 数据
return {
props: { news },
}
}

function NewsPage({ news }) { // 这里接收到 news 数据
// Render posts...
const { title = '' } = news;
render (
<h1>{title}</h1>
)
}

export default NewsPage

使用 SSR
需要注意的是:客户端和服务端有两套逻辑。服务端不能够使用 window
/ Document
等对象, cookie
需要从 getServerSideProps
req
中获取,如果页面有 CDN
的话,需要单独设置白名单来实现 cookie
透传。

客户端获取 cookie
和平时方法相同。下面是在客户端进行的 request
请求:

const getData = async ({ locale = 'de' }) => {
let obj = {};
try {
obj = await request({
url: `/api`,
params: {
locale,
ids: '1,3',
},
});
} catch (error) {
console.error(error);
}
return obj;
};

Next.js 内置 CSS 支持

如果想要在 Next.js
项目中使用 CSS Modules
,不需要任何额外的配置,只需要将 css
scss
文件重命名为 .module.css
.module.scss

CSS Modules
会为每一个 class
名字追加组件名称的前缀以及哈希值的后缀,这样就拥有了局部作用域的独一无二的 class name
,不会相互影响。CSS Module 中可以使用 global 关键字声明一个全局规则。

.title {
color: red;
}

:global(.title) {
color: green;
}

:global {
.title {
color: green;
}
.detail {
color: red;
}
}

或者使用下面的方式

function HelloWorld() {
return (
<div>
<p>scoped!</p>
<style jsx>{`
div {
background: red;
}
@media (max-width: 600px) {
div {
background: blue;
}
}
`}</style>
<style global jsx>{`
body {
background: black;
}
`}</style>
</div>

)
}

Material UI

Material UI
最初设计受到了在服务器端渲染的约束,但后续能够支持服务器端的渲染。详细见官方文档。这也是选择它的重要原因。数据拿到后,开始进行首屏数据的渲染。为尽可能的减少服务器的请求,首屏也有一些非必要数据可以放在客户端请求,对此 Material UI
提供了一个骨架屏的组件,在首屏数据或图片未加载出来之时,渲染基础形状的骨架屏,告知用户,页面正在加载更多内容。

{ imgSrc ? (
<img style={{ width: 210, height: 118 }} src={imgSrc} />
) : (
<Skeleton variant="rect" width={210} height={118} />
) }

RWD 的实现

可以依赖客户端的媒体查询进行断点的适配,也可以借助 Material UI
useMediaQuery
,编写一个通用的函数,方便使用页面的宽度或高度,判断页面当前的断点。

import { useMediaQuery } from '@material-ui/core';

function getDeviceType() {
const isDesktopOrLaptop = useMediaQuery('(min-width:1128px)')
const isBigScreen = useMediaQuery('(min-width:1920px)')
return {
isDesktopOrLaptop,
isBigScreen
}
}
const { isDesktopOrLaptop } = getDeviceType();

render (

{ isDesktopOrLaptop && <div className={styles.news_detail}>'detail'</div> }

)

动态组件

Webpack
根据 ECMAScript
提案实现了用于动态加载模块的 import
方法。 React v16.6
版本提供了 React.lazy
Suspend
,用于动态加载组件。

然而 React.lazy
Suspend
并不适用于 SSR
,   next/dynamic
能够支持服务器端的动态加载。

import dynamic from 'next/dynamic'

const DynamicComponent = dynamic(() => import('../components/hello'),
{ loading: () => <loading /> }, // 自定义加载组件
{ ssr: false } // 在服务器端不包括该模块
)

function Home() {
return (
<div>
<Header />
<DynamicComponent />
<p>HOME PAGE is here!</p>
</div>

)
}

Sentry 监控

按照 官方文档
接入 Sentry
之后,封装了一个上报异常的公共方法

export function logException(error, option = {}) {
const { Raven } = window;
if (!Raven || !Raven.captureMessage || !Raven.captureException) {
return;
}
Raven[typeof error === 'string' ? 'captureMessage' : 'captureException'](error, option);
}

在错误页面以及请求接口的公共方法中进行异常的上报。

另外,在 next.config.js
里注入一些动态的环境变量。比如版本号,该版本的构建时间,这样便能够快速的定位问题。

module.exports = ({
env: {
...(process.env.APP_RUNTIME_ENV !== 'PRODUCTION' && require('./scripts/webpack/api-env')),
VERSION: pkg.version,
BUILDTIME: String(Date.now()),
},

除了异常的上报,定期的去解决最近频繁出现的问题,并灵活的设定 Sentry
的引入。比如根据构建时间判断,上线一周以后便不再引入 Sentry
脚本。毕竟 Sentry
脚本的引入也还是会有一些对性能的影响。也可以选择在一天内分时段的引入 Sentry
脚本。

开发过程中,前端页面对接的后端接口涉及用户、经销商等多个部门。这些接口之间也有相互依赖路径。随着业务的演变,上游的接口变动在任何一个沟通环节没有同步到位,便会对线上原本稳定的页面造成影响。 Sentry
能够在项目出现异常的时候进行主动的收集上报,帮助我们更好,更快得解决项目的问题。

埋点

同时,我们还需封装通用的 Hook
或函数来处理一系列数据上报逻辑,包括:基础的 PV、曝光、点击、完读等事件以及大数据部门要求上报的推荐相关埋点。

import Track from '@utils/Track';

// 点击和曝光
<Track
type='click|show'
config={{ id: 'xxxx,xxxxx', action: 'xxxxx_xxxxx_click|xxxxx_xxxxx_show' }}
>

<button className={styles.track_button} onClick={testRouter}>测试点击&曝光同时上报埋点</button>
</Track>


// 曝光
<Track
type='show'
config={{ id: 'xxxx,xxxxx', action: 'xxxxx_xxxxx_show' }}
>

<button className={styles.track_button} onClick={testRouter}>测试曝光上报埋点</button>
</Track>

海外的特殊业务

SEO 和 社媒分享

对于 SEO
需求,封装一个通用的 SEO
组件供每个页面组件自行调用。

网页只要遵守开放式图形( OG
)协议, SNS
就能从页面上提取最有效的信息并呈现给用户:按照网页的类型,在
中添加入 meta
标签。

next/head
的作用就是给每个页面设置
标签的内容。

import Head from 'next/head';
return (
<Head>
<meta property="og:type" content="article" />
<meta property ="og:title" content ="标题"/>
<meta property ="og:image" content ="仅限图片的网址"/>
<meta property ="og:url" content ="网页的网址"/>
<meta property ="og:description" content ="网页的基本描述"/>
</Head>

)

如果是视频详情页面,一般标记为

<meta property="og:type" content="video.other" />
<meta property="og:video:url" content={videoUrl} />
<meta property="og:video:secure_url" content={videoUrl} />
<meta property="og:video:type" content="video/mp4" />
<meta property="og:video:width" content="600" />
<meta property="og:video:height" content="315" />

如果一个页面上有多个需要标识出的内容, 可以重复 meta
标签,将认为 og:type
标签是每一段内容的起始处。

特别针对于 Facebook
,需要加上指定的 id。

<meta property="fb:app_id" content="XXXXXXXXXXXXXXXXX" />

通过指定 twitter:site
的值, Twitter
可以将这些信息嵌入到分享的帖子中,展示图片等信息。


<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image:src" content={image} />
<meta name="twitter:url" content={url} />

如果当前页面对爬虫屏蔽,但又想要 Twitter
拿到 meta
标记内容,可以在 robots.txt
中特殊写明

User-agent: Twitterbot
Disallow:

User-agent: *
Disallow: /

如果网站内容很少,比如资讯列表页面在多种筛选条件的叠加下,几乎等同于空白页或者详情页,需要额外标记

<meta name="robots" content="noindex, follow" />

以避免造成重复以及低质量的页面。

Google Recaptcha

为更好的贴近海外用户的使用习惯,我们选用了 Google Recaptcha
来实现人机验证。

Google Recaptcha
分为二代机器人和三代机器人。三代机器人对于用户是无感知的,它会返回一个 0 到 1 之间的评分,在没有任何用户交互的情况下验证该用户是否合法,在调用一些涉及风控的接口之前去调用它,可以自行根据分数判断是否允许用户继续操作。
v2 版本是用户可感知的,登录按钮是最常见的使用场景。验证成功后就会看到下面的图片。

将类似的基础逻辑都封装为 Hook
,为多个业务服务。具体使用方式如下:

import useRecaptcha from './useRecaptcha';

const { validate } = useRecaptcha();

const vote = (recaptcha_code) => {
}

validate({
onSuccess: (recaptcha_code) => {
vote({ recaptcha_code });
},
});

onSuccess
Google Recaptcha
验证成功的回调,在里面我们再操作下一步的逻辑。

具体的接入细节可以参考 Google Recaptcha
的官方文档。

const useRecaptcha = () => {
useEffect(() => {
loadScript(`${API_PATH}?render=${RECAPTCHA_V3_KEY}`,
() => {
window.grecaptcha.ready(() => {

});
},
() => {});
})
const validate = (options = {}) => {
const { grecaptcha: Gre } = window;
const {
action = 'social',
onFail = () => {},
onSuccess = () => {},
} = options;
Gre.ready(async () => {
try {
const recaphta_response = await Gre.execute(RECAPTCHA_V3_KEY, { action });
onSuccess(recaphta_response);
} catch (err) {
onFail(err);
}
});
}
return { validate };
}
export default useRecaptcha

使用 hl
设置国际化语言

`${API_PATH}?render=${RECAPTCHA_V3_KEY}&hl=${region === 'GB' ? 'en-GB' : 'de'}`

国际化

文案

为了进一步缩短 FMP
的时间,可以减小加载文件的大小,这其中可以操作的包括 css
样式, js
文件,以及必要的国际化文本文件。

将文本文件配置在组件内部,并通过 Webpack
提供的 require.context
,对当前页面下所有       i18n
文件夹下定义的 JSON
文件,通过正则匹配语言环境并进行合并,将合并后的内容赋值到msgJson变量,最后得到的内容格式如下:

{
"comps.Header.Hello": "Hello"
}

使用时,无论如何命名 ID
, 有组件名称作为前缀,就不用担心 ID
冲突的问题。

import { useIntl } from 'react-intl';
const useI18n = () => {
const { formatMessage } = useIntl();
return {
// 文案
t_msg: (id, params) => formatMessage({ id }, params),
};
};

// 使用
const { t_msg } = useI18n();
return (
<div>{{t_msg('comps.Header.Hello')}}</div>
)

具体的详细介绍可见于 海外项目的 React 国际化开发实践

货币

import { useIntl } from 'react-intl';
const useI18n = () => {
const { formatNumber } = useIntl();
return {
// 货币
t_currency: (value) => formatNumber(value, { format: 'currency' }),
};
};

// 使用
const { t_currency } = useI18n();
const val = t_currency(value, { minimumFractionDigits: 2 });

React-Intl
插件依赖于原生 Intl API
,由于     NodeJS
默认13.0+版本开始完整支持,所以在13.0以下版本需要增加适配,可以使用 full-icu
库进行 polyfill
,安装该 npm
包,然后启动 node
时进行一个路径指向即可。

启动方式一 : NODE_ICU_DATA=node_modules/full-icu nodemon src/server

启动方式二 : nodemon --icu-data-dir=node_modules/full-icu src/server

总结

SSR
对页面的首屏渲染和 SEO
有很大的帮助,使用 Next.js
可以方便的实现 SSR
,其中关键在于 getServerSideProps
的使用。在此基础上,面对大量的业务需求,海外项目接入监控系统,用以及时发现和快速定位并收集记录线上异常,保证线上访问的稳定。尽最大可能的模块化和组件化,极大得提高开发效率和项目代码的可维护、可复用性。

  • 作者简介