
Перед нами стояла задача реализовать конструктор сайтов. На фронте всем управляет React-приложение, которое на основе действий пользователя, формирует JSON с информацией о том, как построить HTML, и сохраняет его на PHP бэкенд. Вместо дублирования логики сборки HTML на бэкенде, мы решили переиспользовать JS-код. Очевидно, что это упросит поддержку, так как код будет меняться только в одном месте одним человеком. Тут нам на помощь приходит Server Side Rendering вместе с движком V8 и PHP-extension V8JS.
В этой статье мы расскажем, как мы использовали V8Js для нашей конкретной задачи, но варианты использования не ограничиваются только этим. Самым очевидным выглядит возможность использовать Server Side Rendering для реализации SEO-потребностей.
Настройка
Мы используем Symfony и Docker, поэтому первым делом необходимо инициализировать пустой проект и настроить окружение. Отметим основные моменты:
- В Dockerfile необходимо установить V8Js-extension:
...RUN apt-get install -y software-properties-commonRUN add-apt-repository ppa:stesie/libv8 && apt-get updateRUN apt-get install -y libv8-7.5 libv8-7.5-dev g++ expectRUN 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 installRUN echo extension=v8js.so > /etc/php/7.2/fpm/conf.d/99-v8js.iniRUN echo extension=v8js.so > /etc/php/7.2/cli/conf.d/99-v8js.ini...
- Устанавливаем React и ReactDOM самым простым способом
- Добавляем index роут и дефолтный контроллер:
<?phpdeclare(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'); }}
- Добавляем шаблон 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):

- БД
create table data( id serial not null primary key, data json not null);
- Роут
/*** @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 (http://personeltest.ru/aways/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 <<<JSvar 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) );}
Здесь:
- reactPath путь до react.js
- domPath путь до react-dom.js
- domServerPath путь до react-dom-server.js
- ssrPath путь до нашего скрипта ssr.js
Переходим по ссылке /publish/3:

Как видно, все было отрисовано именно так, как нам нужно.
Заключение
В заключении хочется сказать, что Server Side Rendering оказывается не таким уж сложным и может быть очень полезным. Единственное что стоит здесь добавить рендер может занимать достаточно долгое время, и сюда лучше добавить очередь RabbitMQ или Gearman.
P.P.S. Исходный код можно посмотреть тут https://github.com/damir-in/ssr-php-symfony
Авторы
damir_in zinvapel