React with TypeScript 2nd

2woongjae@gmail.com

Server Side Rendering

Why

  • SEO
    • Search Engine Optimization
    • 검색 엔진에서 자바스크립트를 해석해서 처리하지 않으므로, 크롤러에 대비해줘야 한다.
      • 구글은 자바스크립트로 랜더링 해서 크롤링한다. (Good!)
      • SNS 를 위한 메타 태그만 대응해주기도 한다. (귀찮긴 마찬가지)
      • PhantomJS 같은 것을 이용, 자바스크립트 랜더링한 HTML 을 만들어 대응하기도 한다.
        • Headless Chrome 으로 인해 유물이 되어버림.
      • 검색 엔진만을 대응하는 서비스도 등장 (https://prerender.io/)
  • 초기 로드 시간 단축
    • 서버가 빵빵하다는 전제하에,
    • 단축의 이유는 다음 그림을 통해...

React Client Side Rendering

React Server Side Rendering

react-dom/server

https://facebook.github.io/react/docs/react-dom-server.html

ReactDOM

import * as ReactDOM from 'react-dom';

ReactDOM.render(/*React.Element*/, document.querySelector('#root'));

// type definition
export function render<P extends DOMAttributes<T>, T extends Element>(
    element: DOMElement<P, T>,
    container: Element | null,
    callback?: (element: T) => any
): T;
export function render<P>(
    element: SFCElement<P>,
    container: Element | null,
    callback?: () => any
): void;
export function render<P, T extends Component<P, ComponentState>>(
    element: CElement<P, T>,
    container: Element | null,
    callback?: (component: T) => any
): T;
export function render<P>(
    element: ReactElement<P>,
    container: Element | null,
    callback?: (component?: Component<P, ComponentState> | Element) => any
): Component<P, ComponentState> | Element | void;
export function render<P>(
    parentComponent: Component<any>,
    element: SFCElement<P>,
    container: Element,
    callback?: () => any
): void;

ReactDOMServer

import * as ReactDOMServer from 'react-dom/server';

ReactDOMServer.renderToString(/*React.Element*/);

ReactDOMServer.renderToStaticMarkup(/*React.Element*/);

// type definition
export function renderToString(element: ReactElement<any>): string;

export function renderToStaticMarkup(element: ReactElement<any>): string;

renderToString

<div class="App" data-reactroot="" data-reactid="1" data-react-checksum="-272470372">
    <div class="App-header" data-reactid="2">
        <h2 data-reactid="3">Server</h2>
    </div>
    <p class="App-intro" data-reactid="4">
        <!-- react-text: 5 -->To get started, edit 
        <!-- /react-text -->
        <code data-reactid="6">src/App.tsx</code>
        <!-- react-text: 7 --> and save to reload.
        <!-- /react-text -->
    </p>
</div>

/*

1. data-react-checksum 를 이용해서 클라이언트에서 이 html 을 재사용한다.

*/

renderToStaticMarkup

<div class="App">
    <div class="App-header">
        <h2>Server</h2>
    </div>
    <p class="App-intro">To get started, edit 
        <code>src/App.tsx</code> and save to reload.
    </p>
</div>

/*

1. 추가하는 어트리뷰트(data-react-checksum) 없이 리턴
2. 용량이 적겠죠?
3. 클라이언트에서 바뀐부분만 바꿔치기 하지 않을때

*/

Isomorphic

Isomorphic

CSR 제공하기

https://github.com/zeit/serve

Marks:ssr-basic mark$ serve -h

  Usage: serve [options] [command]
  
  Commands:
  
    help  Display help
  
  Options:
  
    -a, --auth       Serve behind basic auth
    -c, --cache <n>  Time in milliseconds for caching files in the browser (defaults to 3600)
    -n, --clipless   Don't copy address to clipboard (disabled by default)
    -C, --cors       Setup * CORS headers to allow requests from any origin (disabled by default)
    -h, --help       Output usage information
    -i, --ignore     Files and directories to ignore
    -o, --open       Open local address in browser (disabled by default)
    -p, --port <n>   Port to listen on (defaults to 5000)
    -S, --silent     Don't log anything to the console
    -s, --single     Serve single page apps with only one index.html
    -u, --unzipped   Disable GZIP compression
    -v, --version    Output the version number

nginx

server {
    listen 80 default_server;
    server_name /var/www/example.com;

    root /var/www/example.com;
    index index.html index.htm;      

    location ~* \.(?:manifest|appcache|html?|xml|json)$ {
      expires -1;
      # access_log logs/static.log; # I don't usually include a static log
    }

    location ~* \.(?:css|js)$ {
      try_files $uri =404;
      expires 1y;
      access_log off;
      add_header Cache-Control "public";
    }

    # Any route containing a file extension (e.g. /devicesfile.js)
    location ~ ^.+\..+$ {
      try_files $uri =404;
    }

    # Any route that doesn't have a file extension (e.g. /devices)
    location / {
        try_files $uri $uri/ /index.html;
    }
}

expressjs

import * as path from 'path';
import * as express from 'express';

const app = express();

app.use(express.static(__dirname + '/build'));

app.get('*', (req, res) => {
  res.sendFile(path.resolve(__dirname, 'build', 'index.html'));
});

app.listen(3000);

Node (Express) 에서 SSR 도 함께 제공하기 (Basic)

react-ts-ssr-basic-cra

앗, TS 를 바로 실행할 순 없다능...

// tsconfig.server.json
{
  "compilerOptions": {
    "outDir": "build-server",
    "module": "commonjs",
    "target": "es6",
    "noImplicitAny": true,
    "sourceMap": true,
    "jsx": "react",
    "moduleResolution": "node",
    "baseUrl": "src"
  }
}

tsc -p tsconfig.server.json

  • - p 옵션
    • Compile the project given the path to its configuration file, or to a folder with a 'tsconfig.json'.

    • 폴더를 인자로 하면,
      • 폴더에 있는 tsconfig.json 을 설정으로 하여 tsc 실행
    • 별도의 tsconfig 파일을 인자 하면,
      • 그 설정으로 tsc 실행
  • - p 옵션을 사용하지 않으면,
    • 프로젝트 ROOT 폴더 밑에 있는 tsconfig.json 파일로 tsc 실행

express 서버 전체 과정

  • create-react-app 의 `yarn build` 스크립트를 이용해서, 프로덕션 빌드
    • <PROJECT_ROOT>/build 폴더에 static 파일들이 생성된다.
  • `tsc -p tsconfig.server.json` 명령을 통해, src 폴더 내의 ts / tsx 파일들을 컴파일
    • <PROJECT_ROOT>/build-server 폴더에 컴파일된 파일들이 생성된다.
  • ​<PROJECT_ROOT>/src/server.ts 파일에서 express 서버 실행
    • index.html 파일을 제외하고는 파일로 제공
    • 그 외 파일 경로일 경우, index.html 파일을 읽어서 제공
      • 여기까지는 CSR만 제공
    • index.html 파일에 React 컴포넌트를 html string 으로 만들어서 삽입
      • SSR 제공

package.json & add package

{
  "scripts": {
    "start": "react-scripts-ts start",
    "build": "react-scripts-ts build && tsc -p tsconfig.server.json",
    "test": "react-scripts-ts test --env=jsdom",
    "eject": "react-scripts-ts eject",
    "serve": "yarn build && node build-server/server.js"
  }
}

// tsc -p tsconfig.server.json 사용을 위해
yarn add typescript -D

// express
yarn add express @types/express

server.ts

import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
import * as express from 'express';

import * as React from 'react';
import * as ReactDOMServer from 'react-dom/server';

import App from './App';

const app = express();

const server = http.createServer(app);

const staticFiles = [
    '/static/*',
    '/asset-manifest.json',
    '/manifest.json',
    '/service-worker.js',
    '/favicon.ico'
];

staticFiles.forEach(file => {
    app.get(file, (req, res) => {
        const filePath = path.join(__dirname, '../build', req.url);
        console.log(filePath);
        res.sendFile(filePath);
    });
});

app.get('*', (req, res) => {
    const html = path.join(__dirname, '../build/index.html');
    const htmlData = fs.readFileSync(html).toString();

    const ReactApp = ReactDOMServer.renderToString(React.createElement(App));
    const renderedHtml = htmlData.replace('{{SSR}}', ReactApp);
    res.status(200).send(renderedHtml);
});

server.listen(3000);

public/index.html

  <body>
    <noscript>
      You need to enable JavaScript to run this app.
    </noscript>
    <div id="root">{{SSR}}</div>
    <!--
      This HTML file is a template.
      If you open it directly in the browser, you will see an empty page.

      You can add webfonts, meta tags, or analytics to this file.
      The build step will place the bundled scripts into the <body> tag.

      To begin the development, run `npm start` or `yarn start`.
      To create a production bundle, use `npm run build` or `yarn build`.
    -->
  </body>

error

module.js:471
    throw err;
    ^

Error: Cannot find module './App.css'
    at Function.Module._resolveFilename (module.js:469:15)
    at Function.Module._load (module.js:417:25)
    at Module.require (module.js:497:17)
    at require (internal/module.js:20:19)
    at Object.<anonymous> (/Users/mark/Project/react-with-typescript-test/react-ts-ssr-basic-cra/build-
server/App.js:4:1)
    at Module._compile (module.js:570:32)
    at Object.Module._extensions..js (module.js:579:10)
    at Module.load (module.js:487:32)
    at tryModuleLoad (module.js:446:12)
    at Function.Module._load (module.js:438:3)
error Command failed with exit code 1.

또 error

module.js:471
    throw err;
    ^

Error: Cannot find module './logo.svg'
    at Function.Module._resolveFilename (module.js:469:15)
    at Function.Module._load (module.js:417:25)
    at Module.require (module.js:497:17)
    at require (internal/module.js:20:19)
    at Object.<anonymous> (/Users/mark/Project/react-with-typescript-test/react-ts-ssr-basic-cra/build-
server/App.js:4:14)
    at Module._compile (module.js:570:32)
    at Object.Module._extensions..js (module.js:579:10)
    at Module.load (module.js:487:32)
    at tryModuleLoad (module.js:446:12)
    at Function.Module._load (module.js:438:3)
error Command failed with exit code 1.

css, svg

  • 클라이언트
    • webpack 을 이용해서, JS 로 합쳐짐.
  • 서버
    • tsc 를 이용했기 때문에 사용할 수 없음.
      • 서버에서도 webpack 을 이용하거나,
      • App.tsx 를 수정
        • App.css 를 index.tsx 로 이동
        • logo.svg 를 public 으로 이동
          • express 에서 static 으로 제공

https://github.com/2woongjae/react-ts-ssr-basic-cra

요청에 따라 다른 결과물 주기

react-ts-ssr-request-cra

server-side-rendering 에서 요청이라는 건?

  • http / https 리퀘스트 시 보낼수 있는 여러가지
    • URL
    • Request Header
    • Request Data (POST data)
  • 서버에서 확인 가능한 데이터
    • express 의 라우터 콜백 인자 중 request 로 확인 가능한 데이터
      • ex) req.url

props 에 담아서 보내기

app.get('*', (req, res) => {
    console.log(req.url);
    const html = path.join(__dirname, '../build/index.html');
    const htmlData = fs.readFileSync(html).toString();

    const ReactApp = ReactDOMServer.renderToString(React.createElement(App, {}, req.url));
    const renderedHtml = htmlData.replace('{{SSR}}', ReactApp);
    res.status(200).send(renderedHtml);
});

SSR 결과

하지만, CSR 결과는 ?

server.ts

app.get('*', (req, res) => {
    console.log(req.url);
    const html = path.join(__dirname, '../build/index.html');
    const htmlData = fs.readFileSync(html).toString();

    const ReactApp = ReactDOMServer.renderToString(React.createElement(App, {}, req.url));
    const renderedHtml = htmlData.replace('<div id="root">{{SSR}}</div>', `<div id="root">${ReactApp}</div><script id="initial-data" type="text/plain" data-json="${req.url}"></script>`);
    res.status(200).send(renderedHtml);
});

SSR Result

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
        <meta name="theme-color" content="#000000">
        <link rel="manifest" href="/manifest.json">
        <link rel="shortcut icon" href="/favicon.ico">
        <title>React App</title>
        <link href="/static/css/main.cacbacc7.css" rel="stylesheet">
    </head>
    <body>
        <noscript>You need to enable JavaScript to run this app.</noscript>
        <div id="root">
            <div class="App" data-reactroot="" data-reactid="1" data-react-checksum="1624593946">
                <div class="App-header" data-reactid="2">
                    <img src="logo.svg" class="App-logo" alt="logo" data-reactid="3"/>
                    <h2 data-reactid="4">Welcome to React</h2>
                </div>
                <p class="App-intro" data-reactid="5">/hello</p>
            </div>
        </div>
        <script id="initial-data" type="text/plain" data-json="/hello"></script>
        <script type="text/javascript" src="/static/js/main.80616c3f.js"></script>
    </body>
</html>

index.tsx

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import App from './App';
import registerServiceWorker from './registerServiceWorker';
import './index.css';
import './App.css';

const initialDataDom = document.querySelector('#initial-data');
let data: string = '';
if (initialDataDom !== null) {
  const dataJson = initialDataDom.getAttribute('data-json');
  if (dataJson !== null) {
    data = dataJson;
  }
}

ReactDOM.render(
  <App>{data}</App>,
  document.getElementById('root') as HTMLElement
);
registerServiceWorker();

CSR

CSR

https://github.com/2woongjae/react-ts-ssr-request-cra

  • 처음에는 서버 사이드 랜더링에서 props.children 이 잘 전달.
  • 그 다음 새로고침부터는 서버 사이드 랜더링을 하지 않기 때문에 이상한 값이 나옴.
    • 따로 스토어에 저장해두던가...

react-router 를 이용한 server-side-rendering

개요

  • 클라이언트
    • react-router-dom
    • BrowserRouter

  • ​서버

    • ​react-router

    • StaticRouter

일단 라우팅 결과물 2개만

// path="/"
const Home = () => {
  return (
    <div>
      <h2>Home</h2>
      <p><Link to="/">Home</Link></p>
      <p><Link to="/hello">Hello</Link></p>
      <p><Link to="/hi">Hi</Link></p>
    </div>
  );
};

// path="/hello"
const Hello = () => {
  return (
    <div>
      <h2>Hello</h2>
      <p><Link to="/">Home</Link></p>
      <p><Link to="/hello">Hello</Link></p>
      <p><Link to="/hi">Hi</Link></p>
    </div>
  );
};

App 컴포넌트에 Route 정의

class App extends React.Component<{}, {}> {
  render() {
    return (
      <Switch>
        <Route exact path="/" component={Home} />
        <Route path="/hello" component={Hello} />
        <Redirect from="*" to="/" />
      </Switch>
    );
  }
}

CSR 에서 App 컴포넌트 사용

import {BrowserRouter} from 'react-router-dom';

ReactDOM.render(
  <BrowserRouter>
    <App/>
  </BrowserRouter>,
  document.getElementById('root') as HTMLElement
);

SSR 에서 App 컴포넌트 사용

import {StaticRouter} from 'react-router';

const ReactApp = ReactDOMServer.renderToString(
    React.createElement(
        StaticRouter,
        {location: req.url, context: context},
        React.createElement(App)
    )
);

SSR 에서 redirect 처리

app.get('*', (req, res) => {
    const html = path.join(__dirname, '../build/index.html');
    const htmlData = fs.readFileSync(html).toString();

    const context: {url?: string}  = {};

    const ReactApp = ReactDOMServer.renderToString(
        React.createElement(
            StaticRouter,
            {location: req.url, context: context},
            React.createElement(App)
        )
    );
    
    if (context.url) {
        res.redirect(301, '/');
    } else {
        const renderedHtml = htmlData.replace('{{SSR}}', ReactApp);
        res.status(200).send(renderedHtml);
    }
});

https://github.com/2woongjae/react-ts-ssr-router-cra

진정한 Isomorphic

SSR Lifecycle

  componentWillMount() {
    console.log('App componentWillMount');
  }
  componentDidMount() {
    console.log('App componentDidMount');
  }
  componentWillUnmount() {
    console.log('App componentWillUnmount');
  }
  componentWillReceiveProps(nextProps: {}) {
    console.log(`App componentWillReceiveProps : ${JSON.stringify(nextProps)}`);
  }
  shouldComponentUpdate(nextProps: {}, nextState: {}): boolean {
    console.log(`App shouldComponentUpdate : ${JSON.stringify(nextProps)}, ${JSON.stringify(nextState)}`);
    return true;
  }
  componentWillUpdate(nextProps: {}, nextState: {}) {
    console.log(`App componentWillUpdate : ${JSON.stringify(nextProps)}, ${JSON.stringify(nextState)}`);
  }
  componentDidUpdate(prevProps: {}, prevState: {}) {
    console.log(`App componentDidUpdate : ${JSON.stringify(prevProps)}, ${JSON.stringify(prevState)}`);
  }

// App componentWillMount 만 발동

SSR vs CSR Lifecycle

  // 서버 랜더링과 클라이언트 랜더링 다 사용
  componentWillMount() {
    console.log('App componentWillMount');
  }
  // 클라이언트 랜더링만
  componentDidMount() {
    console.log('App componentDidMount');
  }
  // 클라이언트 랜더링만
  componentWillUnmount() {
    console.log('App componentWillUnmount');
  }

public static async

  public static async getInitialState(): Promise<string> {
    const res = await request.get('https://api.github.com/users');
    // error 처리를 해서 props 를 넘길수도 있다.
    return res.body[0].login;
  }

InitialState 를 먼저 가져와서 클라이언트로 동기

const user = await App.getInitialState(); 

const ReactApp = ReactDOMServer.renderToString(React.createElement(App, {user}));

const renderedHtml = htmlData.replace('<div id="root">{{SSR}}</div>', `<div id="root">${ReactApp}</div><script id="initial-data" type="text/plain" data-json="${user}"></script>`);

res.status(200).send(renderedHtml);

클라이언트에서 받아서 props 로 넣기

const initialDataDom = document.querySelector('#initial-data');
let data: string = '';
if (initialDataDom !== null) {
  const dataJson = initialDataDom.getAttribute('data-json');
  if (dataJson !== null) {
    data = dataJson;
  }
}

ReactDOM.render(
  <App user={data} />,
  document.getElementById('root') as HTMLElement
);

클라이언트에서 하지 말아야 함

  componentDidMount() {
    console.log('App componentDidMount');
    if (!this.props.user) {
      App.getInitialState().then(user => {
        this.setState({user: user});
      });
    }
  }

https://github.com/2woongjae/react-ts-ssr-fetch-cra

redux 와 server-side-rendering

server 와 client 의 스토어를 일치시키기

// server 에서 이런 요소를 넣어서 내려줍니다.
<script>
    window.__PRELOADED_STATE__ = "값";
</script>

// client 에서 preloadedState 를 만들어 Redux 의 초기 스토어 값으로 넣습니다.
const preloadedState = window.__PRELOADED_STATE__;

다른 서버에서 renderToString 해오기

https://github.com/airbnb/hypernova

AWS API Gateway + Lambda + S3

  • Serverless
  • S3 에 정적 파일
  • API Gateway 에서 http endpoint 를 설정하여, 요청을 받으면
  • Lambda 를 통해 서버 사이드 랜더링
  • 실제 프로덕션 사례가 있음
  • 캐싱 처리까지

Next.js 2.0 (곧 3.0) => 마지막 시간에 만나요

React with TypeScript (2) - server-side-rendering

By Woongjae Lee

React with TypeScript (2) - server-side-rendering

타입스크립트 한국 유저 그룹 리액트 스터디 201706

  • 2,346