新的Odnoklassniki前端:在Java中启动React。第二部分



我们继续讲述在Odnoklassniki内部如何借助GraalVM设法与Java和JavaScript成为朋友并开始迁移到具有大量旧代码的大型系统的故事

在本文的第二部分中,我们将详细讨论新堆栈上应用程序的启动,组装和集成,深入探讨它们在客户端和服务器上的工作细节,并讨论在我们的方法中遇到的困难并描述帮助他们克服的解决方案。

如果您尚未阅读第一部分我强烈建议您这样做。从中您将了解Odnoklassniki前端的历史,并了解其历史特征,通过寻找解决方案来解决我们13年项目中积累的问题,最后,您将深入了解我们所做出决策的服务器实现的技术特征。

UI配置


为了编写UI代码,我们选择了最先进的工具:React以及MobX,CSS模块,ESLint,TypeScript,Lerna。所有这些都是使用Webpack收集的。



应用架构


如本文前面所述,为了实现逐步迁移,我们将在网站上将新组件插入具有自定义名称的DOM元素中,这些元素将在新的UI堆栈中使用,而对于网站的其余部分,它将看起来像是具有DOM元素的DOM元素。其API。这些元素的内容可以在服务器上呈现。

它是什么?内部有一个很酷,时尚的现代MVC应用程序,它在React上运行,并向外提供标准的DOM API:属性,此DOM元素上的方法和事件。



为了运行这些组件,我们开发了一种特殊的机制。他在做什么?首先,它根据其描述初始化应用程序。其次,它将组件绑定到启动它的特定DOM节点。还有两个引擎(用于客户端和服务器)可以找到并呈现这些组件。



为什么需要这个?事实是,当整个站点都在React上构建时,通常会将站点组件呈现到页面的根元素中,而该组件无关紧要什么,而只有内部有趣。

在我们的情况下,一切都变得更加复杂:许多应用程序都需要有机会在网站上告诉我们的页面“我是,我正在发生变化。”例如,日历需要引发一个事件,即用户单击了按钮,并且日期已更改,或者您需要具有外部功能,以便日历内部可以更改日期。为此,应用程序引擎在应用程序的基本功能中实现了外观。

在向客户端交付组件时,旧站点的引擎必须可以启动此组件。为此,在构建期间,将收集启动所需的信息。

{
    "events-calendar": {
        "bundleName": "events-calendar",
        "js": "events-calendar-h4h5m.js",
        "css": "events-calendar-h4h5m.css"
    }
}


特殊标记被添加到component标记的属性中,这表示此应用程序是新类型的,其代码可以从特定的JS文件中获取。同时,它具有初始化此组件所需的自己的属性:它们形成商店中组件的初始状态。

<events-calendar	data-module="react-loader"
			data-bundle="events-calendar.js"
			date=".."
			marks="[{..}]"
			…
/>


对于补液,不使用应用程序状态的强制转换,而是使用属性,以节省流量。它们以规范化形式出现,通常小于应用程序创建的商店。同时,从客户机上的属性重新创建存储的时间很短,因此通常可以忽略它们。

例如,对于日历,属性仅具有突出显示的日期,并且商店已经具有一个包含月份完整信息的矩阵。显然,从服务器传输它毫无意义。

如何运行代码?


该概念已通过简单的功能进行了测试,这些功能要么为服务器提供一行,要么为客户端编写了innerHTML。但是在实际代码中,有模块和TypeScript。

有一些针对客户端的标准解决方案,例如,使用Webpack收集代码,Webpack本身将研磨所有内容并将其以捆绑形式提供给客户端。使用GraalVM时对服务器做什么?



让我们考虑两个选项。第一种是像在Node.js中那样在JavaScript中键入TypeScript。不幸的是,当JavaScript是GraalVM中的来宾语言时,此选项在我们的配置中不起作用。在这种情况下,JavaScript没有模块化系统,甚至没有异步性。因为模块化和与异步一起使用提供了特定的运行时:NodeJS或浏览器。在我们的案例中,服务器具有只能同步执行代码的JavaScript。

第二个选项-您可以简单地从为客户端收集的相同文件中的服务器代码上运行。并且此选项有效。但是存在一个问题,服务器需要多种方法的其他实现。例如,将在服务器上调用renderToString()函数来渲染组件,并在客户端上调用ReactDOM.render()。还是上一篇文章中的另一个示例:要在服务器上获取文本和设置,将调用Java提供的功能,而在客户端上它将是JS中的实现。

作为此问题的解决方案,您可以使用Webpack中的别名。它们使您可以创建我们需要的类的两个实现:用于客户端和服务器。然后,在客户端和服务器的配置文件中,指定适当的实现。



但是两个配置文件是两个程序集。每次,分别收集服务器和客户端的所有内容很长,而且很难获得支持。

您需要提出这样的配置,以便一次性收集所有内容。

Webpack配置以在服务器和客户端上运行JS


为了找到解决该问题的方法,让我们看一下该项目包括哪些部分:



首先,该项目具有第三方运行时(供应商),客户端和服务器都相同。它几乎永远不会改变。可以将Rantime分配给用户,他将被缓存在客户端上,直到我们更新第三方库的版本为止。

其次,有我们的运行时(核心),可确保启动应用程序。它为客户端和服务器提供了具有不同实现的方法。例如,获取本地化文本,设置等。该运行时也很少更改。

第三,有一个组件代码。客户端和服务器都相同,这使您可以在浏览器中调试应用程序代码,而无需启动服务器。如果客户端出现问题,您可以在浏览器控制台中看到错误,并牢记所有内容,并确保在服务器上启动时不会出现错误。

总共获得了三个需要组装的零件。我们想要:
  • 分别配置每个零件的装配。
  • 放下它们之间的依赖关系,以使每个部分都不属于另一个部分。
  • 一口气收集所有东西。


如何分别描述装配体组成的零件?Webpack中有多种配置:您只需放弃每个部分中包含的模块导出的数组即可。

module.exports = [{
  entry: './vendors.js',
}, {
  entry: './core.js'
}, {
 entry: './app.js'
}];


一切都会好起来的,但是在这些部分的每个部分中,该部分所依赖的模块的代码将被复制:



幸运的是,在基本的Webpack插件集中有DllPlugin,它允许您获取每个组装部件所包含的模块的列表。例如,对于供应商,您可以找出此部分中包含哪些特定模块。

在构建其他部分(例如核心库)时,可以说它们取决于供应商部分。



然后,在webpack组装期间,DllPlugin会根据供应商中已有的某些库来查看核心,并且不会将其添加到核心中,而只是将链接添加到该核心中。

结果,一次组装了三块并且彼此依赖。当第一个应用程序下载到客户端时,运行时库和核心库将保存在浏览器缓存中。由于Odnoklassniki是一个站点,因此用户可以“永远”打开的选项卡很少出现。在大多数情况下,随着网站新版本的发布,只会更新应用程序代码。

资源交付


通过使用存储在单独数据库中的本地化文本的示例来考虑该问题。

如果服务器上较早的某个位置需要组件中的文本,则可以调用该函数以获取文本。

const pkg = l10n('smiles');

<div>
    : { pkg.getText('title') }
</div>


在服务器上获取文本并不困难,因为服务器应用程序可以向数据库发出快速请求,甚至可以将所有文本缓存在内存中。

如何在GraalVM的服务器上呈现的React组件中获取文本?

如本文第一部分所述,您可以在JS上下文中将方法添加到要从JavaScript访问的全局对象中。决定使用所有可用于JavaScript的方法创建一个类。

public class ServerMethods {
    
    /**
     *     
     */
    public String getText(String pkg, String key) {
    }
    
}


然后将此类的实例放在全局JavaScript上下文中:

//     Java   
js.putMember("serverMethods", serverMethods);


结果,在服务器实现中的JavaScript中,我们只需调用以下函数:

function getText(pkg: string, key: string): string {
    return global.serverMethods.getText(pkg, key);
}


实际上,这将是Java中的函数调用,它将返回请求的文本。直接同步交互,无HTTP调用。

不幸的是,在客户端上,通过HTTP接收组件中每次插入文本插入函数的每次调用都需要花费很长时间。您可以将所有文本预下载到客户端,但是仅这些文本就占了几十兆字节,并且还有其他类型的资源。



用户将厌倦于等待所有内容下载完毕后再启动应用程序。因此,此方法不合适。

我只想接收特定应用程序中需要的那些文本。我们的文本分为几包。因此,您可以收集应用程序所需的软件包,并将它们与捆绑软件一起下载。当应用程序启动时,所有文本将已经在客户端缓存中。

如何找出应用程序需要哪些文本?

我们达成了一个协议,代码中的文本包是通过调用l10n()函数获得的,其中包名仅以字符串文字形式传输:

const pkg = l10n('smiles');

<div>
    { pkg.getLMsg('title') }
</div>


我们编写了一个webpack插件,该插件通过分析组件代码的AST树,查找对l10n()函数的所有调用,并从参数中收集包名称。同样,插件收集有关应用程序所需其他资源类型的信息。

在为每个应用程序组装之后的输出中,我们获得了一个包含其资源的配置:

{
    "events-calendar": {
       "pkg":  [
           "calendar",
           "dates"
       ],
       "cfg":  [
           "config1",
           "config2"
       ],
       "bundleName":  "events-calendar",
       "js":  "events-calendar.js",
       "css":  "events-calendar.css",
    }
}


当然,我们一定不要忘记更新文本。因为在服务器上,所有文本始终都是最新的,并且客户端需要单独的缓存更新机制,例如观察程序或推送。

新旧代码


在平稳过渡的情况下,会出现在新组件中重用旧代码的问题,因为存在大型而复杂的组件(例如视频播放器),重写将花费大量时间,并且您现在需要在新堆栈中使用它们。



有什么问题?

  • 旧站点和新React应用程序的生命周期完全不同。
  • 如果您将旧样本的代码粘贴到React应用程序中,则该代码将不会启动,因为React不知道如何激活它。
  • 由于生命周期不同,React和旧引擎可能会同时尝试修改旧代码的内容,这可能会导致令人不快的副作用。


为了解决这些问题,为包含旧代码的组件分配了一个通用的基类。该类允许继承人协调React和旧式应用程序的生命周期。

export class OldCodeBase<T> extends React.Component<T> {

    ref: React.RefObject<HTMLElement> = React.createRef();

    componentDidMount() {
        //       DOM
        this.props.activate(this.ref.current!); 
    }

    componentWillUnmount() {
        //       DOM
        this.props.deactivate(this.ref.current!); 
    }

    shouldComponentUpdate() {
        // React     , 
        //   React-. 
        //     .
        return false;
    }

    render() {
        return (
            <div ref={this.ref}></div>
        );
    }
}


该类使您可以创建按旧方式工作的代码片段,或者销毁它们,而不会同时进行交互。

将旧代码粘贴到服务器上


实际上,需要包装器组件(例如,弹出窗口),其内容可以是任何内容,包括那些使用旧技术制作的内容。您需要弄清楚如何在此类组件内部的服务器上嵌入任何代码。

在上一篇文章中,我们讨论了如何使用属性将参数传递给客户端和服务器上的新组件。

<cool-app users="[1,2,3]" />


现在,我们仍然要在此处插入一个标记,这实际上不是属性。为此,决定使用插槽系统。

<cool-app>
    <ui:part id="old-code">
        <div>old component</div>
    </ui:part>
</cool-app>


如上例所示,在cool-app组件的代码内部,描述了一个包含旧组件的旧代码槽。然后,在react组件内部,指示您要粘贴此插槽内容的位置:

render() {
    return (
        <div>
            <UiPart id="old-code" />
        </div>
    );
}


服务器引擎呈现此react组件,并在<ui-part>标记中构造插槽的内容,并为其分配data-part-id =“ old-code”属性。

<cool-app>
    <div>
        <ui-part data-part-id="old-code">
            old code
        </ui-part>
    </div>
</cool-app>


如果GraalVM中JS的服务器端渲染不适合超时,则我们将回退到客户端渲染。为此,服务器上的引擎仅提供插槽,将它们放入模板标记中,以便浏览器不与它们的代码进行交互。

<cool-app>
    <template>
        <ui-part data-part-id="old-code">
            old code
        </ui-part>
    </template>
</cool-app>


客户发生了什么事?客户端引擎只需扫描组件代码,收集<ui-part>标记,以字符串形式接收其内容,然后将其与其余参数一起传递给呈现函数。

var tagName = 'cool-app';
var reactComponent = components[tagName];
reactComponent.render({
       tagName: tagName,
       attrs: attrs,
       parts: parts,
       node: element
});


将插槽插入所需位置的组件代码如下:

export class UiPart extends OldCodeBase<IProps> {

	render() {
		const id = this.props.id;
		const parts = this.props.parts;

		if (!parts.hasOwnProperty(id)) {
			return null;
		}

		return React.createElement('ui-part', {
			'data-part-id': id,
			ref: this.ref,
			dangerouslySetInnerHTML: { __html: parts[id] }
		});
	}
}


同时,它继承自OldCodeBase类,解决了新旧堆栈之间的交互问题。



现在,您可以编写一个弹出窗口,并使用新的堆栈填充它,或使用旧方法从服务器请求。在这种情况下,组件将正常工作。

这使您可以逐步将站点组件迁移到新堆栈。
只是这是新前端的主​​要要求之一。

摘要


每个人都想知道GraalVM有多快。Odnoklassniki开发人员对React应用程序进行了各种测试。

一个预热后返回字符串的简单函数大约需要1微秒。

组件(再次预热后)-从0.5到6毫秒,具体取决于它们的大小。

GraalVM的加速速度比V8慢。但是,由于客户端渲染的回退,在预热期间,情况得以缓解。由于用户太多,因此虚拟机会快速升温。

你做了什么



  • 在Classmates的Java世界中的服务器上运行JavaScript。
  • 为UI制作同构代码。
  • 使用所有前端供应商都知道的现代堆栈。
  • 创建用于编写UI的通用平台和单一方法。
  • 在不使操作复杂化且不减慢服务器渲染速度的情况下开始平稳过渡。


我们希望Odnoklassniki的经验和示例对您有所帮助,并且您会发现它们可用于您的工作。

Source: https://habr.com/ru/post/undefined/


All Articles