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

f-heyhpnwz4j7wuqr2y-icfwuja.png

Перед нами стояла задача реализовать конструктор сайтов. На фронте всем управляет 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 роут и дефолтный контроллер:
    render('index.html.twig');
       }
    }
    

  4. Добавляем шаблон index.html.twig с подключенным React
    
    
        


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


Для демонстрации 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 (
           
  • ); } 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 (
    {this.state.message ? this.state.message : ''}
      { this.state.items.map((item, i) => { return ( ); }) }
    ); } 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):

    lggpcantspoobni3-tzai8k7lkc.png

    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('

    Page not found

    ', 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 <<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:

    k-ldth_rrmka1fz3ddjpmmhbgki.png

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

    Заключение


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

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

    Авторы
    damir_in zinvapel

    © Habrahabr.ru