成长:将一个青少年网站带入现代SPA世界

通过小鲁本·马丁内斯。

多年来,网络社区一直称赞单页应用程序(SPA)的好处架构。几乎任何一次网络会议或流行的技术博客,你都会遇到大量关于其好处的讨论。这就是为什么我们如此热衷于这些讨论的原因:每次我们导航到一个网站的不同页面时,等待加载并初始化一整页HTML、JavaScript和样式表的速度都很慢;加载sma所有的JavaScript随需应变都很快。SPA还允许我们避免页面加载之间可怕的空白屏幕闪烁,而不是让我们提供更用户友好的加载屏幕。这使得SPA比传统架构更适合连接稀缺的环境,如移动web,甚至某些国家的桌面web。如果如果时间安排得当,即使是第一页的加载量也会相当少;我甚至不会进入第二页提供快速浏览体验的众所周知的财务激励。简体版为:要走了快.

然而,许多SPA讨论没有考虑到迁移到单一页面体系结构的过程是多么艰巨。

特别是当您处理一个成熟的应用程序时,它有数百万用户和大量难以理解的代码。我们可以理解为什么人们对这个话题谈论得不多——很多直言不讳的科技公司往往被归为两类:要么它们是一家相对较新的初创公司,科技债务相对较少;或者他们是更成熟的faang类型,拥有几乎无限的资源来处理技术债务。

在Ok188bet金宝搏官网Cupid,我们从2004年就开始了,只有一个相当小的(大约30人)的工程团队负责整个过程。我们的web代码库比Ruby on Rails早,当您了解到我们的技术堆栈依赖于我们称之为Pub的自成体系的类似php的服务器端渲染框架时,您可能会感到惊讶(或者适当地感到震惊)。直到最近,我们的产品都是Pub、香草JS、jQuery、Coffescript、React、Reflux、Redux等等的混合体,很大程度上依赖于服务器端补水状态而不是适当的api。此外,还有大量可能失效的代码路径和有条件注入的有用的第三方脚本。所以,当我们谈到遗留代码和技术债务时,这就是我们要讨论的内容。

正如你们中的许多人所知道的,通常很难为一个产品的彻底重写提供商业理由。那么,如何将一个比一些JavaScript程序员更古老的web应用程序带入现代时代呢?好吧,我们实际上已经在这一目标上取得了一段时间的重大进展。事实上,React可以很容易地实现最早在2016年年中,y render进入现有应用程序对于帮助我们开始采用它至关重要,今天,我们的产品几乎完全由React驱动。我也很高兴地报告,我们所有的产品页面都是API驱动的(尽管我们还没有完全达到GraphQL驱动的乌托邦,我们稍后会努力实现更多).这两项都是我们多年来一直致力于的缓慢、渐进的转变,这使得我们最终转向水疗变得非常容易。

但是,尽管有了这些巨大的进步,直到最近,我们仍然依赖于Pub来实际生成每个请求的每个HTML页面。这阻碍了我们做一些很酷的事情,比如让我们的程序员不必学习一门编造的语言(这对招聘非常有用)。所以大约6个月前,我开始着手一个项目,打算把我们从酒吧搬出去。我们从移动网络产品开始转变,并在3个月后将这些经验应用到桌面网络。这两个平台的任务列表很广泛,但也相当简单:

  • 在编译时生成一个HTML文件来服务我们的JavaScript包。
  • 确保快速(足够)进行首次喷漆。
  • 使用代码拆分以避免最终出现臃肿的捆绑包。
  • 为样板逻辑(如第三方脚本)确定更好的抽象。
  • 更新传统的原始JavaScript工具,依赖于React的直接DOM操作。
  • 消除我在过程中发现的任何未使用的代码。

在这个大规模迁移的过程中,我做了一些观察并设计了一些抽象,我希望您会发现它们在清理旧代码和编写新代码方面都很有用。

追踪失效代码

因此,让我们开始以最合乎逻辑的方式来解决这些要点:按字母顺序。在从遗留基础设施迁移时,消除未使用的代码是一个巨大的目标。部分是理想主义的(耶,删除代码!),部分是务实的(耶,翻译的代码更少!)但是,一个令人难以置信的恼人的挑战是如何辨别哪些遗留代码对保持网站正常运行至关重要,哪些代码可以安全删除,因为自OkCupid主持期刊和论坛以来,这些代码就不再相关了(是的,我们过去经常这样做;不,我不能告诉你为什么我们认为这是个好主意).然而,在我们的工具带中有一个非常基本的工具,它被证明对于找出哪些代码正在运行,哪些代码没有运行是非常宝贵的:分析!188bet金宝搏官网

甜蜜,甜蜜的沉默。

我偶然发现了很多代码,我的工程先辈们有远见地将分析事件添加到这些代码中。当我找到这些代码时,我会检查它们是否在过去一年左右被解雇,数量有多大,并判断是否可以删除这些违规代码。老实说,这让我想要在我写过的每个React组件中添加一个分析事件。谁来劝我别这么做。

如果一切都像发射一个分析事件那样简单就好了!

优化第一道漆

迁移到一个单页应用的另一个困难的方面是我们的web应用程序依赖于某些数据被保证在任何时候都可用在我们应用反应。虽然这很容易确保与服务器端数据水合作用,这是更具挑战性,搬到一个静态的HTML模板。对于QA来说,摆脱所有这些依赖关系是非常乏味和困难的,所以我们必须开发策略来解决这个问题。最后,我们确定了如下内容:

//index.js-我们的主要应用程序入口点
从“React”中导入React;
从“react dom”导入react dom;

从“/API”导入API;
从“/Helpers”导入帮助程序;
import App from "./ App ";
从"./app_error"中导入AppError;

const root = document.getElementById("root");

//加载绝对必需品。
API.loadCriticalData ()
不要犹豫(()= > Helpers.initializeGlobals ())
不要犹豫(()= > ReactDOM。呈现(< App / >,根))
.catch((错误)= > ReactDOM。render(, root));

这样做的目的是,它加载对我们的应用程序绝对重要的数据。然后,它通过初始化一些全局库使数据对应用程序的其余部分可用。最后,它将应用程序呈现给DOM。如果此过程中出现任何故障,我们将返回错误状态。

但是,这种方法有一个显著的缺点:在等待数据加载时,呈现的是什么?当这种情况发生时,我们不希望向用户显示一个空白屏幕——即使它在每个会话中只发生一次,向用户显示一个空白屏幕从来都不是理想的。为了解决这个问题,我们用一个应用程序外壳填充模板文件:

/ / index . html
< html >

188bet金宝搏官网OkCupid
> < /头


< div class = " some-loading-state”>
< div class = " some-flashy-loader " > < / div >
< / div >
< noscript >
打开JavaScript,混蛋!

< / div >
身体< / >
< / html >

React最终会渲染到“根”中div,把里面的一切都抹去;在此之前,我们可以渲染其中的任何内容div包括一个在此期间显示给用户的应用程序外壳或者乔治·毛内使用UI显示未启用JavaScript的用户。这确保了当JavaScript在线加载、解析、编译并加载其必需的数据时,用户不会被迫坐在空白屏幕前。在这里了解更多关于JavaScript启动性能的信息。

代码分离

确保快速第一次绘制的另一个关键方面是确保我们的JavaScript包不会随着单页应用程序添加越来越多的路由而膨胀。幸运的是,React现在提供了一些很好的开箱即用的工具来防止这种情况发生。即:懒惰反应,悬念.

如果你不熟悉懒惰,这是一个很棒的添加,是最近发布的react@16.6.0,它允许我们根据需要动态加载任何组件。不再需要第三方库根据当前路由进行代码拆分;相反,您可以这样做:

/ / Routes.jsx
从“React”中导入React;
从“react router dom”导入{Switch,Route};

import Loading from“。/Loading”;//一些加载状态UI。

const Home = React.lazy(() => import("pages/ Home "));
const Login=React.lazy(()=>import(“页面/登录”);
const Signup = React.lazy(() => import("pages/ Signup "));

const Routes = () => (
<反应。悬念后备={<加载/ >}>


.

< /开关>
< /反应。悬念>
);

这里我使用的是React Router的开关路线组件来处理在给定路径上呈现组件。它完全不知道围绕它发生的惰性加载逻辑,您可以选择使用任何库或不使用库来代替它。

这里发生的是开关组成部分将决定路线渲染基于当前路径。当它找到一个匹配的路线将渲染组件传递给它组件道具。路由器不知道的是,在这种情况下,每个组件都是动态加载的只渲染一次.反应,悬念将实际延迟渲染,直到组件加载。加载时,它将渲染您传递给其退路这意味着向我们的水疗中心添加额外的路线对主捆绑包几乎没有成本!

在未来,React可能会允许我们使用反应,悬念在获取数据时暂停呈现(继电器, React的GraphQL框架已经实现了这个),或者发生任何其他异步操作。但它已经足够稳定,可以像我们一样用于生产环境中的代码分割!在这里了解更多关于React代码分割的信息.

使用useScript的第三方脚本

在SPA迁移过程中,我发现自己做的最常见的事情之一是粘贴到一些第三方脚本中,几乎所有这些脚本都是这样的:


<脚本>
(函数(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]| |函数(){
(我[r]。q = [r]。q | | []) .push(参数)},我[r]。l = 1 *新的日期();a = s.createElement (o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(窗口、文档、“脚本”和https://www.bizviewz.com/analytics.js","ga",;

ga(“创建”、“UA-XXXXX-Y”、“自动”);
ga(“发送”,“页面”);
> < /脚本
<!--结束谷歌分析-->

这些代码通常会像上面那样预先简化,但每个代码的要点都是:向主体添加一个script标签,从CDN加载一些第三方JavaScript,并运行一些相关代码。

但是其中一些脚本我们只是想在某些情况下加载(例如,仅针对登录用户,仅针对有广告的用户,仅针对具有更高数据连通性的用户,等等),在静态HTML模板中没有太多空间容纳这种细微差别。幸运的是,React Hooks最终成为了一个处理这个问题的好工具!我们最终得到的结果是这样的:

//useScript.js
import {useRef, useEffect} from " react ";

/**
动态加载JavaScript。
* @param {string} url -要加载的脚本的url。
* @param {object} [options={}] -脚本元素的任何重写。
*/
函数useScript(url,选项={}){
//创建脚本元素一次。
const scriptRef=useRef(document.createElement(“脚本”);
useffect(()=>{
//基本设置。
const script=scriptRef.current;
script.type=“text/javascript”;
script.src=url;

//高级修改和/或订阅事件。
种(选项)
.forEach((key) => script[key] = props[key]);

//如果需要,添加到主体。
如果(! document.body.contains(脚本)){
document.body.appendChild(脚本);

}, [url选项]);


出口默认useScript;

因为这些脚本通常需要更多的设置,所以我通常的方法是这样包装它们:

/ / useGoogleAnalytics.js
从“react”导入{useState,useffect,useRef};
从" ./useScript "中导入useScript;

const GOOGLE_ANALYTICS_SDK_URL=“//www.GOOGLE-ANALYTICS.com/ANALYTICS.js”;

/**
*加载并使用谷歌Analytics SDK。
* @param {object} location -当前用户的位置。
*/
function useGoogleAnalytics(location = window.location) {
//跟踪加载状态。
const [hasLoaded, setHasLoaded] = useState(false);

//加载SDK。
const sdkOptions = useRef({onload: () => setHasLoaded(true)});
useScript (GOOGLE_ANALYTICS_SDK_URL sdkOptions.current);

//初始化谷歌分析。
useffect(()=>{
//如果SDK尚未加载,请退出。
如果(! hasLoaded) {
返回;


window.ga('create','UA-XXXXX-Y','auto');
},[hasload]);

//跟踪页面视图。
const path=location.pathname;
useffect(()=>{
//如果SDK尚未加载,请退出。
如果(! hasLoaded) {
返回;


window.ga。当前(“发送”、“浏览”);
},路径,hasLoaded);


导出默认useGoogleAnalytics;

当我们需要使用它们时,就像这样简单:

//Page.jsx
import {useLocation} from " react-router-dom ";
import useGoogleAnalytics from " ./useGoogleAnalytics ";

函数BasicPage(){
const location = useLocation();
使用谷歌分析(位置);

回报(
< div >


<页脚/ >
< / div >
);

注:我通过位置作为参数,而不是放置使用地点在钩子里呼叫。令人沮丧的是,我很早就发现,如果您试图在服务器外部使用React路由器的钩子,则会抛出运行时错误路由器上下文,事实证明这对于与非spa页面的互操作性很麻烦。通过位置作为参数而不是调用使用地点直接在钩子中,我们能够更容易地跨SPA和非SPA代码重用它。

这种加载脚本的方法适用于任意数量的助手脚本,同时对于扫描代码的开发人员来说仍然易于阅读!要了解有关React钩子的更多信息,请看我之前关于这个主题的博客.

迁移香草JavaScript工具

我们遇到的另一个问题是如何迁移一些传统的UI工具,这些工具依赖于普通JavaScript和手动DOM操作的组合

/ / legacy_popover.js
const legacpopover = {
初始化({主题}){
const dom=document.getElementById(“popover dom”);

//一些有趣的dom操作。
this.applyTheme(dom,theme);
},

主题applyTheme (dom) {
/*这里的附加逻辑*/
},
};

在我们的Pub模板中,我们有一些代码如下所示:

/ / index . html
< div id = " popover-dom " > < / div >
<脚本>
LegacyPopover.init({
// ↓↓↓ 神奇地可用↓↓↓ 用户数据!耶
主题:“%{user.preferences.theme}”
});
> < /脚本

虽然对于这样的迁移并没有一个万能的解决方案,但我确实找到了一个在95%的情况下运行良好的方法,只是在这里和那里做了一些调整。同样,这里的目标通常是尽可能少地修改原始代码,以减少潜在bug的表面积。

对于这样的东西,我可以采用两种方法:我可以选择通过编程方式创建遗留实用程序需要的任何DOMdocument.createElement;或者我可以将引用传递给从其他地方获得的DOM节点。我通常会根据需要创建的DOM的复杂程度来决定采用哪种方法。在本例中,我将选择参考方法。结果看起来像这样:

/ / legacy_popover.js
const legacpopover = {
初始化({dom, theme}) {
//一些有趣的dom操作。
this.applyTheme(dom,theme);
},

主题applyTheme (dom) {
/*这里的附加逻辑*/
},
};

导出默认LegacyPover;

那么在哪里dom节点来自何方?当然要反应!

/ / Popover.jsx
从“React”导入React;
从“lodash/get”中导入get;
从“graphql-tag”中导入GQL;
import {useQuery} from " @apollo/react-hooks ";

从" ./legacy_popover "中导入LegacyPopover;

//阿波罗查询获取所需的用户数据。
const THEME_QUERY = gql '
查询getUserTheme($userid:String!){
用户(id:$userid){
id
偏好{
主题



`;

const Popover=React.memo({userid})=>{
//加载用户主题首选项。
const {data} = useQuery(THEME_QUERY, {userid});
const theme=get(数据,“user.preferences.theme”);

//保留对DOM节点的引用。
const domRef = useRef(null);
useffect(()=>{
//如果我们没有准备好,提前退出。
如果(! domRef。当前|| !主题){
返回;


//初始化LegacyPover。
const dom=domRef.current;
LegacyPopover.init({dom, theme });
},[theme,domRef]);

回报(

);
});

导出默认的Popover;

这里有几件事需要注意。首先,遗留工具(导入为LegacyPopover)在加载所需的数据和呈现DOM节点之前,不会进行初始化。其次,初始化只应在每次装入组件时发生一次(除非主题应该改变),由于方式使用效果最后,我对组件进行了记忆,以避免不必要的重新渲染,这可能会弄乱DOM。

有几次我迁移的遗留实用程序处理了一些导航逻辑。在SPA中,最好使用React Router导航,而不是通过锚定标签导航,因为这些标签不会给我们带来良好的过渡。为了实现这一点,我将传递遗留实用程序a戈图尔函数作为参数,它要么通过React Router的导航历史推送在SPA内或通过手动设置窗口位置在non-SPA环境中。

接下来是什么?

虽然OkCupid网站的大部分内容现188bet金宝搏官网在都是通过我们新的单页应用程序体系结构提供的,但在我们已经迁移之后,还有很多工作要做,还有很多捆绑优化需要探索。其中,我们希望在我们的大部分页面上使用GraphQL,这样我们就可以在我们的应用程序中共享一个规范化的实体存储产品(即跨页面共享缓存数据)。此外,我们已经在考虑采用先进的Web应用技术,如服务工作者,以实现资源的长期本地缓存,并继续优化我们的产品,以适应连接更加匮乏的环境。我们相信,这种新的体系结构将使我们能够更快地移动,并更定期地向用户提供新功能。正如就我个人而言,我期待着返回并删除很多很多行遗留代码。

原载于https://tech.188bet金宝搏官网okcupid.com2020年2月21日。

188bet金宝搏官网OkCupid技术博客

阅读来自OkCupid工程团队的故事,每天连188bet金宝搏官网接着数百万人

188bet金宝搏官网OkCupid技术博客

188bet金宝搏官网OkCupid的工程团队负责每天为数百万人配对。在OkCupid科技博客上阅读他们的故事188bet金宝搏官网

188bet金宝搏官网OkCupid技术博客

188bet金宝搏官网OkCupid的工程团队负责每天为数百万人配对。在OkCupid科技博客上阅读他们的故事188bet金宝搏官网