SSR: рендеринг ReactJS приложения на бекэнде используя PHP

от автора

Перед нами стояла задача реализовать конструктор сайтов. На фронте всем управляет React-приложение, которое на основе действий пользователя, формирует JSON с информацией о том, как построить HTML, и сохраняет его на PHP бэкенд. Вместо дублирования логики сборки HTML на бэкенде, мы решили переиспользовать JS-код. Очевидно, что это упросит поддержку, так как код будет меняться только в одном месте одним человеком. Тут нам на помощь приходит Server Side Rendering вместе с движком V8 и PHP-extension V8JS.

В этой статье мы расскажем, как мы использовали V8Js для нашей конкретной задачи, но варианты использования не ограничиваются только этим. Самым очевидным выглядит возможность использовать Server Side Rendering для реализации SEO-потребностей.

Настройка

Мы используем Symfony и Docker, поэтому первым делом необходимо инициализировать пустой проект и настроить окружение. Отметим основные моменты:

  1. В Dockerfile необходимо установить V8Js-extension:
    ... RUN apt-get install -y software-properties-common RUN add-apt-repository ppa:stesie/libv8 && apt-get update RUN apt-get install -y libv8-7.5 libv8-7.5-dev g++ expect RUN git clone https://github.com/phpv8/v8js.git /usr/local/src/v8js && \    cd /usr/local/src/v8js && phpize && ./configure --with-v8js=/opt/libv8-7.5 && \    export NO_INTERACTION=1 && make all -j4 && make test install  RUN echo extension=v8js.so > /etc/php/7.2/fpm/conf.d/99-v8js.ini RUN echo extension=v8js.so > /etc/php/7.2/cli/conf.d/99-v8js.ini ... 

  2. Устанавливаем React и ReactDOM самым простым способом
  3. Добавляем index роут и дефолтный контроллер:
    <?php declare(strict_types=1);  namespace App\Controller;  use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route;  final class DefaultController extends AbstractController {    /**     * @Route(path="/")     */    public function index(): Response    {        return $this->render('index.html.twig');    } } 

  4. Добавляем шаблон index.html.twig с подключенным React
    <html> <body>     <div id="app"></div>     <script src="{{ asset('assets/react.js') }}"></script>     <script src="{{ asset('assets/react-dom.js') }}"></script>     <script src="{{ asset('assets/babel.js') }}"></script>     <script type="text/babel" src="{{ asset('assets/front.jsx') }}"></script> </body> </html> 

Использование

Для демонстрации V8 создадим простой скрипт рендеринга H1 и P с текстом assets/front.jsx:

'use strict';  class DataItem extends React.Component {    constructor(props) {        super(props);         this.state = {            checked: props.name,            names: ['h1', 'p']        };         this.change = this.change.bind(this);        this.changeText = this.changeText.bind(this);    }     render() {        return (            <li>                <select value={this.state.checked} onChange={this.change} >                    {                        this.state.names.map((name, k) => {                            return (                                <option key={k} value={name}>{name}</option>                            );                        })                    }                </select>                <input type='text' value={this.state.value} onChange={this.changeText} />            </li>        );    }     change(e) {        let newval = e.target.value;        if (this.props.onChange) {            this.props.onChange(this.props.number, newval)        }        this.setState({checked: newval});    }     changeText(e) {        let newval = e.target.value;        if (this.props.onChangeText) {            this.props.onChangeText(this.props.number, newval)        }    } }  class DataList extends React.Component {    constructor(props) {        super(props);        this.state = {            message: null,            items: []        };         this.add = this.add.bind(this);        this.save = this.save.bind(this);        this.updateItem = this.updateItem.bind(this);        this.updateItemText = this.updateItemText.bind(this);    }     render() {        return (            <div>                {this.state.message ? this.state.message : ''}                <ul>                    {                        this.state.items.map((item, i) => {                            return (                                <DataItem                                    key={i}                                    number={i}                                    value={item.name}                                    onChange={this.updateItem}                                    onChangeText={this.updateItemText}                                />                            );                        })                    }                </ul>                <button onClick={this.add}>Добавить</button>                <button onClick={this.save}>Сохранить</button>            </div>        );    }     add() {        let items = this.state.items;        items.push({            name: 'h1',            value: ''        });         this.setState({message: null, items: items});    }     save() {        fetch(            '/save',            {                method: 'POST',                headers: {                    'Content-Type': 'application/json;charset=utf-8'                },                body: JSON.stringify({                    items: this.state.items                })            }        ).then(r => r.json()).then(r => {            this.setState({                message: r.id,                items: []            })        });    }     updateItem(k, v) {        let items = this.state.items;        items[k].name = v;         this.setState({items: items});    }     updateItemText(k, v) {        let items = this.state.items;        items[k].value = v;         this.setState({items: items});    } }  const domContainer = document.querySelector('#app'); ReactDOM.render(React.createElement(DataList), domContainer); 

Переходим на localhost:8088 (8088 указан в docker-compose.yml как порт nginx):

  1. БД
    create table data(    id serial not null primary key,    data json not null );

  2. Роут
    /** * @Route(path="/save") */ public function save(Request $request): Response {    $em = $this->getDoctrine()->getManager();     $data = (new Data())->setData(json_decode($request->getContent(), true));    $em->persist($data);    $em->flush();     return new JsonResponse(['id' => $data->getId()]); }

Нажимаем кнопку сохранить, при нажатии на наш роут отправляется JSON:

{   "items":[      {         "name":"h1",         "value":"Сначала заголовок"      },      {         "name":"p",         "value":"Немного текста"      },      {         "name":"h1",         "value":"И еще заголовок"      },      {         "name":"p",         "value":"А под ним текст"      }   ] }

В ответ отдается идентификатор записи в БД:

/** * @Route(path="/save") */ public function save(Request $request): Response {    $em = $this->getDoctrine()->getManager();     $data = (new Data())->setData(json_decode($request->getContent(), true));    $em->persist($data);    $em->flush();     return new JsonResponse(['id' => $data->getId()]); }

Теперь, когда есть тестовые данные, можно попробовать V8 в действии. Для этого необходимо будет набросать React скрипт, который будет формировать из переданных пропсов Dom компоненты. Положим его рядом с другими assets и назовем ssr.js:

'use strict';  class Render extends React.Component {    constructor(props) {        super(props);    }     render() {        return React.createElement(            'div',            {},            this.props.items.map((item, k) => {                return React.createElement(item.name, {}, item.value);            })        );    } }

Для того, чтобы сформировать из сформированного DOM дерева строку, воспользуемся компонентом ReactDomServer (https://unpkg.com/browse/react-dom@16.13.0/umd/react-dom-server.browser.production.min.js). Напишем роут с получением готового HTML:

 /** * @Route(path="/publish/{id}") */ public function renderPage(int $id): Response {    $data = $this->getDoctrine()->getManager()->find(Data::class, $id);     if (!$data) {        return new Response('<h1>Page not found</h1>', Response::HTTP_NOT_FOUND);    }     $engine = new \V8Js();     ob_start();    $engine->executeString($this->createJsString($data));     return new Response(ob_get_clean()); }  private function createJsString(Data $data): string {    $props = json_encode($data->getData());    $bundle = $this->getRenderString();     return <<<JS var global = global || this, self = self || this, window = window || this; $bundle; print(ReactDOMServer.renderToString(React.createElement(Render, $props))); JS; }  private function getRenderString(): string {    return        sprintf(            "%s\n%s\n%s\n%s",            file_get_contents($this->reactPath, true),            file_get_contents($this->domPath, true),            file_get_contents($this->domServerPath, true),            file_get_contents($this->ssrPath, true)        ); } 

Здесь:

  1. reactPath — путь до react.js
  2. domPath — путь до react-dom.js
  3. domServerPath — путь до react-dom-server.js
  4. ssrPath — путь до нашего скрипта ssr.js

Переходим по ссылке /publish/3:

Как видно, все было отрисовано именно так, как нам нужно.

Заключение

В заключении хочется сказать, что Server Side Rendering оказывается не таким уж сложным и может быть очень полезным. Единственное что стоит здесь добавить — рендер может занимать достаточно долгое время, и сюда лучше добавить очередь — RabbitMQ или Gearman.

P.P.S. Исходный код можно посмотреть тут https://github.com/damir-in/ssr-php-symfony

Авторы
damir_in zinvapel

ссылка на оригинал статьи https://habr.com/ru/post/513682/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *