Статьи

Работа с асинхронными API-интерфейсами в рендеринге сервера

Если вы когда-либо создавали базовую страницу приложения React, она, вероятно, страдала от плохого SEO и проблем с производительностью на медленных устройствах. Вы можете добавить традиционный рендеринг веб-страниц на стороне сервера, обычно с NodeJS, но это не простой процесс, особенно с асинхронными API.

Два основных преимущества, которые вы получаете от рендеринга вашего кода на сервере:

  • повышенная производительность во время загрузки
  • улучшение гибкости вашего SEO.

Помните, что Google ожидает загрузки вашего JavaScript, поэтому простые вещи, такие как содержание заголовка, будут меняться без проблем. (Хотя я не могу говорить о других поисковых системах или о том, насколько это надежно.)

В этом посте я расскажу о получении данных из асинхронных API-интерфейсов при использовании рендеринг-кода сервера. Код React имеет всю структуру приложения, встроенную в JavaScript. Это означает, что, в отличие от традиционных шаблонов MVC с контроллером, вы не знаете, какие данные вам нужны, пока приложение не будет отображено. С помощью такой инфраструктуры, как Create React App, вы можете быстро создать работающее приложение очень высокого качества, но оно требует от вас рендеринга только на клиенте. С этим связана проблема с производительностью, а также проблема с SEO / данными, когда в традиционных шаблонизаторах вы можете изменять голову по своему усмотрению.

Проблема

React рендерится по большей части синхронно, поэтому, если у вас нет данных, вы отображаете загрузочный экран и ждете, когда данные появятся. Это не очень хорошо работает с сервера, потому что вы не знаете, что вам нужно, пока вы не визуализируете, или вы знаете, что вам нужно, но вы уже отрендерили.

Проверьте этот стандартный метод рендеринга:

ReactDOM.render( <provider store={store}> <browserrouter> <app></app> </browserrouter> </provider> , document.getElementById('root') ) 

Вопросы:

  1. Это DOM-рендеринг с поиском корневого элемента. Это не существует на моем сервере, поэтому мы должны отделить это.
  2. У нас нет доступа ни к чему, кроме нашего основного корневого элемента. Мы не можем устанавливать теги Facebook, заголовок, описание, различные теги SEO, и у нас нет контроля над остальной частью DOM вне элемента, особенно над головой.
  3. Мы предоставляем некоторое состояние, но сервер и клиент имеют разные состояния. Нам нужно рассмотреть, как обрабатывать это состояние (в данном случае Redux).

Итак, я использовал две библиотеки здесь, и они довольно популярны, так что, надеюсь, это перенесется на другие библиотеки, которые вы используете.

Redux : сохранение состояния, в котором синхронизируются ваш сервер и клиент, является кошмарной проблемой. Это очень дорого, и обычно приводит к сложным ошибкам. На стороне сервера, в идеале, вы не хотите ничего делать с Redux, кроме того, что достаточно для правильной работы и рендеринга. (Вы все еще можете использовать его как обычно; просто установите достаточно состояния, чтобы он выглядел как клиент.) Если вы хотите попробовать, ознакомьтесь с руководствами по различным распределенным системам в качестве отправной точки.

React-Router : FYI, это версия v4, которая установлена ​​по умолчанию, но она существенно отличается, если у вас есть более старый существующий проект. Вам нужно убедиться, что вы обрабатываете сервер маршрутизации и клиентскую часть, а также v4 — и это очень хорошо.

В конце концов, что, если вам нужно сделать вызов базы данных? Внезапно это становится большой проблемой, потому что это асинхронно, и это внутри вашего компонента. Конечно, это не новая проблема: посмотрите это на официальном репозитории React .

Вы должны выполнить рендеринг, чтобы определить, какие зависимости вам нужны — какие должны быть определены во время выполнения — и извлечь эти зависимости перед обслуживанием вашему клиенту.

Существующие решения

Ниже я рассмотрю решения, которые в настоящее время предлагаются для решения этой проблемы.

Next.js

Прежде чем мы пойдем куда-либо, если вы хотите получить рабочий код, код React на стороне сервера или универсальное приложение, вам нужно перейти к Next.js ]. Это работает, это чисто, и у него есть Zeit, поддерживающий это.

Тем не менее, это само собой разумеющееся, вы должны использовать их набор инструментов, и способ, которым они обрабатывают асинхронную загрузку данных, не обязательно настолько гибок.

Проверьте эту прямую копию из документации репозитория Next.js:

 import React from 'react' export default class extends React.Component { static async getInitialProps ({ req }) { return req ? { userAgent: req.headers['user-agent'] } : { userAgent: navigator.userAgent } } render () { return <div> Hello World {this.props.userAgent} </div> } } 

getInitialProps — это ключ, который возвращает обещание, которое разрешается для объекта, который заполняет реквизиты, и только на странице. Замечательно то, что он просто встроен в их набор инструментов: добавьте его, и он работает, никакой работы не требуется!

Итак, как вы получаете данные базы данных? Вы делаете вызов API. Вы не хотите? Ну, это очень плохо. (Хорошо, так что вы можете добавлять пользовательские вещи, но вы должны полностью реализовать это самостоятельно.) Однако, если вы подумаете об этом, это очень разумная и, вообще говоря, хорошая практика, потому что в противном случае ваш клиент все равно будет делать тот же вызов API, и задержка на вашем сервере практически ничтожна.

Вы также ограничены в том, что у вас есть доступ — в значительной степени только объект запроса; и опять же, это кажется хорошей практикой, потому что у вас нет доступа к вашему состоянию, которое в любом случае будет отличаться на вашем сервере по сравнению с клиентом. О, и в случае, если вы не поймали его раньше, он работает только на компонентах страницы верхнего уровня.

Redux Connect

Redux Connect — очень самоуверенный серверный рендер, с достойной философией, но если вы не используете все инструменты, которые они описывают, это может быть не для вас. В этом пакете много всего, но он настолько сложен и еще не обновлен до React Router v4. Для этого есть много настроек, но давайте возьмем самую важную часть, просто чтобы выучить некоторые уроки:

 // 1. Connect your data, similar to react-redux @connect @asyncConnect([{ key: 'lunch', promise: ({ params, helpers }) => Promise.resolve({ id: 1, name: 'Borsch' }) }]) class App extends React.Component { render() { // 2. access data as props const lunch = this.props.lunch return ( <div>{lunch.name}</div> ) } } 

Декораторы не являются стандартными в JavaScript. На момент написания статьи это 2-й этап, поэтому используйте по своему усмотрению. Это просто еще один способ добавления компонентов более высокого порядка. Идея довольно проста: ключ к тому, что передать вашим реквизитам, и тогда у вас есть список обещаний, которые разрешаются и передаются. Это кажется довольно хорошим. Возможно, альтернатива просто так:

 @asyncConnect([{ lunch: ({ params, helpers }) => Promise.resolve({ id: 1, name: 'Borsch' }) }]) 

Это кажется выполнимым с JavaScript без особых проблем.

реагирую-предварительное распределение

В репозитории response-frontload нет большого количества документации или объяснений, но, возможно, лучшее понимание, которое я смог получить, было из тестов (таких как этот )
и просто чтение исходного кода. Когда что-то монтируется, оно добавляется в очередь обещаний, а когда это разрешается, оно обслуживается. То, что он делает, довольно хорошо, хотя трудно порекомендовать что-то, что плохо документировано, поддерживается или используется:

 const App = () => ( <frontload isServer > <component1 entityId='1' store={store}></component1> </frontload> ) return frontloadServerRender(() => ( render(<app></app>) )).then((serverRenderedMarkup) => { console.log(serverRenderedMarkup) }) предварительное const App = () => ( <frontload isServer > <component1 entityId='1' store={store}></component1> </frontload> ) return frontloadServerRender(() => ( render(<app></app>) )).then((serverRenderedMarkup) => { console.log(serverRenderedMarkup) }) 

В поисках лучшего решения

Ни одно из вышеперечисленных решений на самом деле не отвечало гибкости и простоте, которые я ожидал от библиотеки, поэтому сейчас я представлю свою собственную реализацию. Цель не в том, чтобы написать пакет, а в том, чтобы вы поняли, как написать свой собственный пакет, для вашего случая использования.

Репозиторий для данного примера решения находится здесь .

теория

Идея, лежащая в основе этого, относительно проста, хотя в конечном итоге она становится частью кода. Это должно дать обзор идей, которые мы обсуждаем.

Сервер должен визуализировать код React дважды, и мы просто используем для этого renderToString . Мы хотим сохранить контекст между первым и вторым рендерами. На нашем первом рендере мы пытаемся убрать любые вызовы API, обещания и асинхронные действия. При втором рендеринге мы хотим получить все полученные данные и вернуть их в наш контекст, поэтому выкладываем нашу рабочую страницу для распространения. Это также означает, что код приложения должен выполнять действия (или нет) в зависимости от контекста, например, на сервере или на клиенте, независимо от того, выбираются ли данные в любом случае.

Кроме того, мы можем настроить это, как мы хотим. В этом случае мы меняем код статуса и заголовок в зависимости от нашего контекста.

Первый рендер

Внутри вашего кода вы должны знать, что вы работаете вне сервера или браузера, и в идеале вы хотите иметь комплексный контроль над этим. С React Router вы получаете статическую контекстную поддержку, и это здорово, поэтому мы будем ее использовать. Сейчас мы только что добавили объект данных и данные запроса, как мы узнали из Next.js. Наши API-интерфейсы различаются для сервера и клиента, поэтому вам необходимо предоставить серверный API-интерфейс, предпочтительно с интерфейсом, аналогичным интерфейсу API на стороне клиента:

 const context = {data: {}, head: [], req, api} const store = configureStore() renderToString( <provider store={store}> <staticrouter location={req.url} context={context} > <app></app> </staticrouter> </provider> ) 

Второй рендер

Сразу после вашего первого рендеринга мы просто возьмем эти ожидающие обещания и подождем, пока эти обещания не будут выполнены, а затем повторно выполним рендеринг, обновив контекст:

 const keys = Object.keys(context.data) const promises = keys.map(k=>context.data[k]) try { const resolved = await Promise.all(promises) resolved.forEach((r,i)=>context.data[keys[i]]=r) } catch (err) { // Render a better page than that? or just send the original markup, let the front end handle it. Many options here return res.status(400).json({message: "Uhhh, some thing didn't work"}) } const markup = renderToString( <provider store={store}> <staticrouter location={req.url} context={context} > <app></app> </staticrouter> </provider> ) 

Приложение

Быстрый переход от нашего сервера к коду приложения: в любом из наших компонентов, которые имеют подключение к маршрутизатору, теперь мы можем получить следующее:

 class FirstPage extends Component { async componentWillMount(){ this.state = {text: 'loading'} this._handleData('firstPage') } async _handleData(key){ const {staticContext} = this.props if (staticContext && staticContext.data[key]){ const {text, data} = staticContext.data[key] this.setState({text, data}) staticContext.head.push( <meta name="description" content={"Some description: "+text}/> ) } else if (staticContext){ staticContext.data[key] = this._getData() } else if (!staticContext && window.DATA[key]){ const {text, data} = window.DATA[key] this.state = {...this.state, text, data} window.DATA[key] = null } else if (!staticContext) { const {text, data} = await this._getData() this.setState({text, data}) } } async _getData(){ const {staticContext} = this.props const myApi = staticContext ? staticContext.api : api const resp = await butter.post.list() const {data} = resp.data const {text} = await myApi.getMain() return {text, data} } render() { const text = this.state.text return ( <div className='FirstPage'> {text} </div> ) } } 

Вау, это много сложного кода. На этом этапе вы, вероятно, захотите использовать более релейный подход, когда вы разделяете свой код извлечения данных на другой компонент.

К этому компоненту добавляются вещи, с которыми вы, вероятно, знакомы — шаг рендеринга и шаг componentWillMount . Четырехступенчатый оператор if обрабатывает различные состояния — предварительная выборка, пост-выборка, рендеринг сохранителя, рендеринг почтового сервера. Мы также добавляем в голову после загрузки наших данных.

Наконец, есть шаг получения данных. В идеале, ваш API и база данных имеют одинаковый API, что делает выполнение одинаковым. Возможно, вы захотите поместить их в действие в Thunk или Saga, чтобы сделать его более расширяемым.

За дополнительной информацией обращайтесь к статье « Рендеринг React на стороне сервера » и Рендеринг React на стороне сервера репозитория . Помните, что вам все равно нужно обрабатывать состояние, когда ваши данные не загружаются! Вы будете выполнять рендеринг сервера только при первой загрузке, поэтому вы будете показывать экраны загрузки на последующих страницах.

Измените index.html для добавления данных

Нам нужно отправить любые предварительно выбранные данные как часть нашего запроса страницы, поэтому мы добавим тег script:

 <script> window.DATA = {data:{}} // It doesn't really matter what this is, just keep it valid and replaceable </script> 

порция

Затем нам нужно добавить его в наш поиск и заменить. Тем не менее, HTML использует очень простой скрипт поиска тегов, поэтому вам нужно кодировать его в base-64, если у вас есть теги скрипта. Также не забывайте о наших бирках!

 // earlier on const headMarkup = context.head.map(h=>( renderToStaticMarkup(h) )).join('') // then render const RenderedApp = htmlData.replace('{{SSR}}', markup) .replace('{{head}}', headMarkup) .replace('{data:{}}', JSON.stringify(new Buffer(JSON.stringify(context.data)).toString('base64'))) if (context.code) res.status(context.code) res.send(RenderedApp) 

Мы также обрабатываем изменения кода состояния — например, для 404 — поэтому, если у вас есть страница 404, вы можете просто сделать это:

 class NoMatch extends Component { componentWillMount(){ const {staticContext} = this.props if (staticContext){ staticContext.code = 404 } } render() { return ( <div> Sorry, page not found </div> ) } } 

Резюме

Если вы не уверены, что делаете, просто используйте Next.js. Он предназначен для рендеринга на стороне сервера и универсальных приложений, или если вы хотите гибко делать все вручную, так, как вы хотите. Примером может быть, если у вас есть выборка данных в подкомпонентах, а не на уровне страницы.

Надеюсь, эта статья помогла вам на вашем пути! Не забудьте проверить репозиторий GitHub для работающей реализации.