Дорога полного стека на JS (1)

Node.js JavaScript React.js koa
Дорога полного стека на JS (1)

(Это серия статей: ожидается, что будет три этапа, первый этап будет посвящен построению интерфейсных и серверных приложений с изоморфизмом, второй этап будет посвящен GraphQL и MySQL, а третий этап основное внимание будет уделено Docker с сигналами онлайн-развертывания)

Автор: Чжао Вэйлун (Почему это всегда я, из-за безграничной поддержки моих товарищей по команде!!!)

Прежде всего, я заявляю о первоначальном намерении написать эту статью, и я все еще остаюсь учеником, изучающим путь полного стека. Первая часть написания серии статей состоит в том, чтобы зафиксировать часть моего опыта постройки всей станции (говорят, что есть боевое искусство, которое вы забудете, как только вы его изучите, то есть писать код...), и во-вторых, чтобы сообщить об этом читателям.Ямы и дизайнерские идеи, с которыми вы столкнулись, приходите сюда с менталитетом обучения из предстоящей области сообщений~~


Разделительная линия позитивного фильма

Я не буду подробно останавливаться на преимуществах и недостатках самого изоморфного приложения, и было много направлений и аргументов для обсуждения, поэтому мы не будем здесь его расширять. Конечно, если вы ставите под сомнение необходимость изоморфных приложений, я не отрицаю, например,эта статьяХорошо сказано. Тогда вы можете задаться вопросом, почему я все еще пишу на эту тему, причина в том, что наш путь полного стека позволяет нам делать все, что мы хотим, не ограничиваясь технологиями. Что, если бы мне было любопытно, о чем они спорили, и это сбылось? (Надеюсь, вы часто учитесь с таким настроем, тогда вы обязательно пойдете дальше!)

Выбор всех стеков технологий в этой статье выглядит следующим образом:

  • node = 10.0.0
  • react >= 16.3.0
  • react-router >= 4.2.0
  • webpack >= 4.6.0
  • isomorphic-fetch >= 2.2.0
  • koa >= 2.5.0
  • koa-router >= 7.4.0
  • react-redux >= 5.0.0
  • redux >= 4.0.0

Если вы обнаружите, что многое в написании изменилось, пришло время обновить стек технологий, мальчик~

Прежде чем мы начнем, подумайте, какие задачи должно решать однородное приложение:

  • Совместимость кода (несогласованный узел среды хостинга js, браузер)
  • Рендеринг выше сгиба
  • Проблема с синхронизацией данных после рендеринга первого экрана
  • Синхронизация маршрутизации внешних и внутренних страниц

проблемы с совместимостью кода

Прежде всего, в начале проекта давайте зададим вопрос: может ли код, работающий на стороне браузера, идеально работать на стороне узла? Конечно, не может, но не является ли целью нашего изоморфизма просто улучшить ценность повторного использования кода?Давайте подумаем о том, какие места не поддерживаются на стороне узла и должны использоваться на стороне браузера. Например, сторона узла глобального объекта окна является global , а сторона узла v10 поддерживает практически все синтаксисы ES6. На стороне браузера это не так из-за проблем с совместимостью браузера.Однако на стороне модуля, сторона узла не поддерживает импорт статических ссылок, а веб-пакет на стороне браузера уже поддерживает встряхивание дерева на основе импорта. Столкнулся со многими проблемами совместимости. . Приходится вздыхать о несогласованности среды выполнения js.Не лучше ли их унифицировать в v8 и убрать модуль глобальных переменных?Впереди еще долгий путь.

Сначала настроим привычный .babelrc (метод написания на стороне клиента я подробно рассказывал в первой статье, можно перенестиздесь) На самом деле изоморфным приложениям нужно только сделать сторону ноды совместимой с import и react jsx. Конечно, если мы будем использовать Babel в будущем, естественный код узла не будет выполняться непосредственно на удаленной машине, а будет скомпилирован и затем запущен. На самом деле, в дополнение к компиляции и упаковке веб-пакета, есть небольшая проблема, которая представляет собой не что иное, как нативные модули узла, такие как require('path'), require('stream'). Мы не хотим быть упакованными, это нужно только установить target: node webpack поможет нам игнорировать удаление этих модулей. Сказав так много, мы просто надеемся, что наш предыдущий .babelrc может упаковать код узла, поэтому нам нужно только добавить хук @babel/register в файл входа (это @ написано в новой версии bable7, моя первая статья упоминала в статье). Давайте посмотрим на первую яму, с которой мы можем столкнуться: на этапе локальной разработки нам нужно использовать наши собственные существующие службы узлов для компиляции файла веб-пакета в процессе разработки. Убедитесь, что код клиента может выполняться гладко.

const webpack = require('webpack');
const logger = require('koa-logger');
const config = require('./webpack.config');
const webpackDevMiddleware = require('./middleware/koa-middleware-dev');

const router = new Router();
const app = new koa();

// const Production = process.env.NODE_ENV === 'production';

const compiler = webpack(config);
// logger记录
app.use(logger());
// 替换原有的webpack-dev-middleware
app.use(webpackDevMiddleware(compiler, {
  publicPath: config.output.publicPath,
}));

Давайте сначала поговорим о первой яме.Из кода видно, что мы реализовали собственный webpackDevMiddleware. Причина в том, что у самого koa нет зрелого webpack-dev-middleware. Сам плагин основан на экспрессе, поэтому мы внедряем это сами тоже не хлопотно:

const devMiddleware = require('webpack-dev-middleware');

module.exports = (compiler, option) => {
  const expressMiddleware = devMiddleware(compiler, option);

  const koaMiddleware = async (ctx, next) => {
    const { req } = ctx;
    // 修改res的兼容方法
    const runNext = await expressMiddleware(ctx.req, {
      end(content) {
        ctx.body = content;
      },
      locals: ctx.state,
      setHeader(name, value) {
        ctx.set(name, value);
      }
    }, next);
  };

// 把webpack-dev-middleware的方法属性拷贝到新的对象函数
  Object.keys(expressMiddleware).forEach(p => {
    koaMiddleware[p] = expressMiddleware[p];
  });

  return koaMiddleware
}

Видно, что мы в основном совместимы с асинхронной функцией koa и параметрами в ней. (req, res, next) => {} и промежуточное ПО koa (ctx, next) => {}, поэтому нам нужно преобразовать форму, а в экспрессе будут некоторые несоответствия между API и экспрессом, поэтому нам нужно преобразовать method, Если вас интересуют методы, используемые webpack-dev-middleware, вы можете просмотреть его исходный код Мы не будем проводить здесь анализ исходного кода. Вкратце, используются только три метода.

express => koa

res.end => ctx.body            关闭http请求链接,并且设置回复报文体
res.locals => ctx.state        设置挂载穿透namespace
res.setHeader => ctx.set       header设置

Рендеринг первого экрана (охватывающий синхронизацию маршрута)

Проблемы, с которыми мы столкнемся при рендеринге первого экрана, будут связаны с изоморфизмом front-end и back-end маршрутизации, поэтому мы поговорим об этом здесь. Первый шаг на первом экране сервера должен соответствовать маршруту (непосредственно в коде):

// 采用koa-router的用法
app.use(router.routes())
   .use(router.allowedMethods());

appRouter(router);

// 然后设置appRouter函数
module.exports = function(app, options={}) {
  // 页面router设置
  app.get(`${staticPrefix}/*`, async (ctx, next) => {
    // ...内容
  }
  // api路由
  app.get(`${apiPrefix}/user/info`, async(ctx, next) => {
    // ...内容
  }
}  
// 我们发现为了和服务的请求api区分开我们会在路由的前缀做一下区分当然名字如你所愿

Поскольку мы сопоставляем страницу / * маршрут, как одностраничное приложение, нам также нужно иметь зависимый шаблон макета, шаблоны должны думать о том, какая информация должна быть заменена:

  • Название каждой страницы отличается
  • Корневой узел операции реагирования (заменить тело)
  • Он может заменить __initial_state__ под объектом окна в теге скрипта (это будет подробно объяснено позже на синхронизации данных)
  • В качестве альтернативно JS-файлов (для выполнения кода клиентского бокового кода, среда производства JS и онлайн-среда будут отличаться в зависимости от основной линии пакета исполняемого кода, WebPack на работе, мы пошли на пост-сериал - будет выпущена, когда ссылка будет упоминаться этот вопрос!)

Что ж, исходя из этих моментов, давайте посмотрим, как должен выглядеть наш шаблон макета:

const Production = process.env.NODE_ENV === 'production';

module.exports = function renderFullPage(html, initialState) {
  html.scriptUrl = Production ? '' : '/bundle.js';
  return `
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8">
        <meta http-equiv="x-ua-compatible" content="IE=edge, chrome=1">
        <meta httpEquiv='Cache-Control' content='no-siteapp' />
        <meta name='renderer' content='webkit' />
        <meta name='keywords' content='demo' />
        <meta name="format-detection" content="telephone=no" />
        <meta name='description' content='demo' />
        <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no">
        <title>${html.title}</title>
      </head>
      <body>
        <div id="root">${html.body}</div>
        <script type="application/javascript">
          window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}
        </script>
        <script src=${html.scriptUrl}></script>   
      </body>
    </>
  `
}

// 其中 scriptUrl 会根据后期上线设置的全局变量来改变。我们开发环境只是把 webpack-dev-middleware 帮我们打包好放在内存中的bundle.js文件放入html,生产环境的js文件我们后放到后期系列去说

Кроме scriptUrl и initialState в процессе отправки нам нужен заменяемый title и body заменяемый title.Мы используем react-helmet.Не будем вдаваться в подробности о конкретном способе использования. Кому интересно, могут посмотретьздесь.

Прежде чем говорить о том, как вставить тело, сначала поговорим о блок-схеме всего процесса рендеринга:

+-------------+                     +--------------+
|             |     api, js         |              |
|             +--------------------->              |
|   SERVER    |                     |    CLIENT    |
|             |                     |              |
|             <---------------------+              |
+---+---------+     api, js         +-------^------+
    |                                       |
    |                                       |
    |            +----------------+         | render
    |            |                |         |
    |            |      HTML      |         |
    +------------>                +---------+
                 +----------------+


Как мы видим на картинке, это первый раз, когда запрос кода js в полном html, охватывающем весь контент, который необходимо отобразить на первом экране, — это scriptUrl, который мы вставили в шаблон.Если есть какое-либо поведение пользователя в дальнейшем он будет передаваться через js.API запроса взаимодействует с сервером. Они ничем не отличаются от обычной клиентской логики. Таким образом, ключевым моментом является то, что сервер должен отображать полный HTML. Мы начинаем здесь:

// 页面route match
export const staticPrefix = '/page';

// routes定义
export const routes = [
  {
    path: `${staticPrefix}/user`,
    component: User,
    exact: true,
  },
  {
    path: `${staticPrefix}/home`,
    component: Home,
    exact: true,
  },
];
// route里的component筛选以及拿到相应component里相应的需要首屏展示依赖的fetchData
const promises = routes.map(
  route => {
    const match = matchPath(ctx.path, route);
    if (match) {
      let serverFetch = route.component.loadData
      return serverFetch(store.dispatch)
    }
  }
)
// 注意这时候需要在确认我们的数据拿到之后才能去正确的渲染我们的首屏页面
const serverStream = await Promise.all(promises)
.then(
  () => {
    return ReactDOMServer.renderToNodeStream(
      <Provider store={store}>
        <StaticRouter
          location={ctx.url}
          context={context}
          >
          <App/>
        </StaticRouter>
      </Provider>
    );
  }
);
// 这里的关键点我们会在后面详细阐述,我们采用了react 16新的api renderToNodeStream
// 正如这个api的名称一样,我们可以得到的不是一个字符串了,而是一个流
// console.log(serverStream.readable);  可以发现这是一个可读流
await streamToPromise(serverStream).then(
  (data) => {
    options.body = data.toString();
    if (context.status === 301 && context.url) {
      ctx.status = 301;
      ctx.redirect(context.url);
      return ;
    }

    if (context.status === 404) {
      ctx.status = 404;
      ctx.body = renderFullPage(options, store.getState());
      return ;
    }
    ctx.status = 200;
    ctx.set({
      'Content-Type': 'text/html; charset=utf-8'
    });
    ctx.body = renderFullPage(options, store.getState());
})
// console.log(serverStream instanceof Stream); 同样你可以检测这个serverStream的数据类型

Мы сосредоточены на этом потоке, а также асинхронные обратные вызовы в узле. Сначала знакомы с узлом, студенты определенно не очень странные. Здесь мы только концептуально. Если вы хотите понять поток очень подробно, рекомендуется перейти на официальный веб-сайт и некоторые другие посты. Например, отечественный форум CNODE.

Потоки — это наборы данных, как массивы или строки. Разница в том, что данные в потоке могут быть доступны не все сразу, и вам не нужно помещать их все сразу в память. Это делает потоки полезными при манипулировании большими объемами данных или при отправке данных по частям из внешнего источника.

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

  • Доступные для записи события потока~: данные, завершение, ошибка, закрытие, конвейер/неконвейер

  • Доступные для записи функции потоков~: write(), end(), cork(), uncork()

  • читаемые события потока~: данные, конец, ошибка, закрытие, чтение,

  • Функции читаемого потока~: pipe(), unpipe(), read(), unshift(), резюме(), setEncoding()

Здесь я могу использовать читаемый поток.Два console.log() в приведенном выше коде также помогают нам определить тип потока реакции. Поскольку это доступный для чтения поток, нам нужно отправить его клиенту.Вы можете использовать событие прослушивания, чтобы отслеживать отправку и остановку потока, или использовать канал, чтобы напрямую импортировать его в наш доступный для записи поток res.write для отправки или завершения. (), вот магия метода канала. Восходящий канал должен быть потоком для чтения, нисходящий поток - доступным для записи потоком, конечно, двунаправленный поток также возможен. Итак, подумайте о коде выше:

const serverStream = await Promise.all(promises)
.then(
  // ...内容
);

// 依然可以发送我们的可读流,但是之所以我没有这么写原因还是在于我希望动态的拼写html,并且在代码组织上把html模版单独提出一个文件
res.write('<!DOCTYPE html><html><head><title>My Page</title></head><body>')
res.write('<div id='root'>')
serverStream.pipe(res, { end: false });
serverStream.on('end', () => {
  res.write("</div></body></html>");
    res.end();
})
// 这么做会利用流的逐步发送功能达到数据传输效率的提升。但是我个人觉得代码的耦合性比这一些性能优化要来的更加重要,这个也要根据你的个人需求来定制你喜欢和需要的模式

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

await streamToPromise(serverStream).then(
  // ...内容
)
// 你可能觉得有点奇怪为什么我不用监听事件呢?而要把这个流包装在streamToPromise里,我是怎么拿到流的变化的呢?

Эту подробную информацию можно посмотретьИсходный код streamToPromiseНа самом деле исходный код не сложный. Наша цель - преобразовать поток в формат обещания.В процессе изменения мы в основном слушаем разные события потоков чтения и записи и используем формат данных буфера для создания обещаний в различных соответствующих состояниях.Зачем нам это нужно делать ? Причина также кроется в используемом нами коа.

Мы все знаем принцип асинхронной функции, если вы хотите узнать больше о принципе koa, рекомендую прочитатьисходный код.我们这里要说明下整体原因,我们的回调函数会被 koa-router 放到 koa 的中间件use里,那么在koa中间件执行顺序中是和 async 的执行顺序一样除非你调用 next() 方法,那么如果你放在stream事件监听的回调函数里异步执行,其实这个 router 会因为你没有设置 res.end() 和 ctx.body 而执行koa 默认的代码返回404 NotFound所以我们必须在 await 里执行我们的有效返回代码!在我们有效返回我们的模版之后他会涵盖了我们的有效模版代码:

html内容

В дополнение к этому мы также сделаем соответствующий редирект и страницу ошибки 4** на стороне сервера, чтобы перенаправить нашу страницу, готовую к ответу:

// redirect include from to status(3**)
const RedirectWithStatus = ({ from, to, status }) => (
  <Route
    render={
      ({ staticContext }) => {
        if (staticContext) {
          staticContext.status = status;
        }
        return <Redirect from={from} to={to} />
      }
    }
  />
);

// 传递status给服务端
const Status = ({ code, children }) => (
  <Route
    render={
      ({ staticContext }) => {
        if (staticContext) {
          staticContext.status = code;
        }
        return children
       }
    }
  />
);

// 404 page
const NotFound = () => (
  <Status code={404}>
    <div>
      <h1>Sorry, we can't find page!</h1>
    </div>
  </Status>
);


const App = () => (
  <Switch>
    {
      routes.map((route, index) => (
        <Route {...route} key={index} />
      ))
    }
    <RedirectWithStatus
      from='/page/fuck'
      to='/page/user'
      status={301}
      exact
    />
    <Route component={NotFound} />
  </Switch>
);

Мы видим, что они на самом деле совместимы в реагирующем маршрутизаторе, поэтому, как мы можем получить соответствующий статус на стороне сервера, например, 4 **, 3 ** эти значения состояния, нам нужно отслеживать их на стороне сервера. перенаправление или страница не найдена. Здесь react-router 4 предоставляет мне контекстную переменную.Обратите внимание, что она доступна только на стороне сервера, поэтому при совместном использовании набора кода она должна быть совместима с методом записи if (staticContext), чтобы гарантировать, что код не будет сообщать об ошибках, и этот контекст является вашим собственным. Вы можете определить любые свойства, которые хотите передать, и получить их на стороне сервера:

//  例如这样的判断
if (context.status === 301 && context.url) {}

Проблема с синхронизацией данных после рендеринга первого экрана

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

// 放在页面html中带过去,让客户端从window对象上拿
<script type="application/javascript">
  window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}
</script>

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

Итак, пройдя вышеперечисленные ямы, поздравляю, у вас уже есть прототип изоморфного приложения. В начале серии статей часто бывает необходимо продать ключ.Полная демонстрация полного стека проекта даст адрес github после завершения серии, так что следите за обновлениями!

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