從零開始構建react應用(四)客戶端渲染

客戶端渲染

上文講到服務端輸出hello world,此次咱們加入react,服務端輸出html,讓js去進行客戶端渲染頁面。html

客戶端代碼

你們都知道react組件對應的文件後綴名是jsx,而使用ts的話,後綴名是tsx。java

安裝react相關依賴

目前用到的依賴有react和react-dom,還須要安裝對應的@typesnode

npm install react react-dom --save
npm install @types/react @types/react-dom --save-dev

PS:版本號是最新的16.0,@types/react-dom主版本號仍是15react

配置tslint

寫代碼前先作好代碼規範相關的配置,養成良好習慣仍是有必要的。webpack

  1. 安裝tslinttslint-reactgit

    npm install tslint tslint-react --save-dev
  2. 在根目錄下新建配置文件tslint.jsongithub

    // ./tslint.json
    
    {
      "extends": ["tslint:latest", "tslint-react"],
      "rules": {
        "quotemark": [true, "single"],
        "no-console": [true, "warn"]
      }
    }

    這裏的規則(rules)我本身修改了兩個,也能夠不改或改其它的(看我的習慣),一個是引號(使用單引號),一個是console(不報錯,僅警告)web

  3. 安裝vs code的tslint插件
    圖片描述

    選擇圖中紅框裏的圖標,而後輸入tslint,能夠看的,沒安裝過的同窗會看的和下面兩個同樣的Install按鈕,安裝了的和我這裏同樣,安裝完畢後vs code會重啓當前窗口以使得插件生效。typescript

疑問一:以前安裝過tslint插件,在新的項目裏添加tslint.json後不生效?

答:這個也正是我如今遇到的一個小問題,隨便打開一個tsx文件,能夠發現tslint沒有生效,而後看右下角有提示:
圖片描述express

點開後咱們能夠看到這樣一段話:

To use TSLint in this workspace please install tslint using 'npm install tslint' or globally using 'npm install -g tslint'.
TSLint has a peer dependency on `typescript`, make sure that `typescript` is installed as well.
You need to reopen the workspace after installing tslint.

因此直接重啓一下當前vs code窗口就能夠了。

App組件

在client目錄下新建component目錄,用於存放組件,在該目錄下新建app子目錄,而後再在app目錄下新建index.tsx文件,這個文件會導出App這個組件,做爲根組件。

// ./src/client/component/app/index.tsx

import * as React from 'react';

class App extends React.PureComponent {
  public render() {
    return (
      <div>hello world</div>
    );
  }
}

export default App;

代碼如上,但實際在編輯器裏,你們會看到<div>下面有一條紅線,移上去能夠看到:
圖片描述
這個是由於須要配置tsconfig,使得能夠在tsx文件中支持JSX語法,配置以下:

// ./tsconfig.json

{
    ...
    "compilerOptions": {
        ...
        "jsx": "react",
        ...
    },
    ...
}

疑問二:App爲何繼承PureComponent而不是Component?

答:PureComponent相較於Component來講,其只會在props和state變動時纔會進行從新render,固然這種比較是淺比較,有潛在的問題,可使用Immutable.js來解決。

疑問三:render前面的public是什麼?

答:tslint中有一條rule是member-access,具體規定見https://palantir.github.io/ts...,簡單來講就是要定義好類屬性方法是公共的仍是私有的仍是受保護的,相似於java中的類。

客戶端入口文件

客戶端入口文件主要是將根組件引入並執行ReactDOM相關方法來渲染,在window.onload中執行:

// ./src/client/index.tsx

import * as React from 'react';

import * as ReactDOM from 'react-dom';

import App from './component/app';

function renderApp() {
  (ReactDOM as any).hydrate(
    <App />,
    document.getElementById('app'),
  );
}

window.onload = () => {
  renderApp();
};

疑問四:(ReactDOM as any).hydrate是什麼?不該該是ReactDOM.render嗎?

首先說一下爲何使用hydrate而不是render,這個是react 16版本中的一個變動,hydrate主要是用於給服務端渲染出的html結構進行「注水」,因爲新版本中ssr出的dom節點再也不帶有data-react,爲了能儘量複用ssr的html內容,因此須要使用新的hydrate方法進行事件綁定等客戶端獨有的操做。

參見原文說明:ReactDOM
參見知乎問題:react中出現的"hydrate"這個單詞究竟是什麼意思?

如今再來講一下爲啥要寫ReactDOM as any,這個是ts的語法,介於目前@types/react-dom主版本仍是15,並無hydrate方法的定義,因此將ReactDOM視爲any類型,則可使ts的類型檢測經過而不報錯。

參見ts任意值:任意值·TypeScript入門教程

配置webpack

如今咱們要作的就是將寫好的客戶端入口文件打包成瀏覽器能夠直接運行的js代碼文件,咱們使用webpack來進行配置。

安裝依賴

執行如下命令

npm install webpack lodash --save
npm install @types/webpack @types/lodash awesome-typescript-loader webpack-dev-middleware @types/webpack-dev-middleware --save-dev

因爲webpack配置根據環境不一樣(客戶端,服務端,開發,生產)而不一樣,故須要使用到深度複製庫來使得各個環境的配置繼承公共配置,這裏使用了lodash中的cloneDeep,因此依賴裏有lodash。
至於awesome-typescript-loader(後文稱at-loader),咱們選用它做爲webpack處理tsx?文件的loader。
webpack-dev-middleware用來和koa集成來實現webpack-dev-server的功能。

webpack客戶端配置文件

因爲環境,咱們可能最多會用到4種配置文件,因此咱們須要設計好配置文件,使得冗餘代碼降到最低。
基本設計思想以下:

  1. base.ts輸出全環境下公共的配置
  2. client(server).ts繼承(深度複製)base.ts提供的配置,輸出開發環境和生產環境下客戶端(服務端)的配置

咱們目前只使用到了客戶端配置文件,因此咱們在webpack目錄下新建兩個文件,base.ts和client.ts

// ./src/webpack/base.ts

import * as path from 'path';

import * as webpack from 'webpack';

export const baseDir = path.resolve(__dirname, '../..'); // 項目根目錄

export const getTsRule = (configFileName) => ({ // 傳入tsconfig配置文件返回rule
  test: /\.tsx?$/,
  use: [
    {
      loader: 'awesome-typescript-loader',
      options: {
        configFileName, // 指定at-loader使用的tsconfig文件
      },
    },
  ],
});

const baseConfig: webpack.Configuration = { // 客戶端+服務端全環境公共配置baseConfig
  module: {
    rules: [],
  },
  output: {
    path: path.resolve(baseDir, './bundle'), // 輸出打包文件至項目根目錄下的bundle目錄中去
    publicPath: '/assets/', // 打包出的資源文件引用的目錄,好比在html中引用a.js,src爲'/assets/a.js'
  },
  plugins: [],
  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.json'], // 用於webpack查找文件時自行補全文件後綴
  },
};

export default baseConfig;

疑問五:path是什麼?__dirname是什麼?後面的../..爲何這樣寫?

答:

  1. path是node的一個用於路徑處理的模塊,它能夠解決由於操做系統不一樣致使的路徑分隔符不一樣的問題。
  2. __dirname是node的一個全局變量,存儲的是當前文件所在目錄的完整目錄名
  3. path.resolve方法接收兩個參數,一個是源路徑,咱們這裏寫的是當前文件所在目錄,後面的是將被解析到絕對路徑的字符串,咱們這裏寫的是../..,一個..表明上一級目錄,兩個就是上兩級目錄,當前目錄是webpack,上一級就是src,上兩級就是react-app這個項目根目錄。

參見:path

疑問六:爲何要寫webpack.Configuration,這個baseConfig不就是一個object對象嗎?

答:baseConfig是一個對象沒錯,可是藉助於ts的類型系統,vs code能夠作到對聲明類型的變量進行屬性提示,這個功能對於不熟悉webpack配置屬性的同窗有必定幫助,效果以下圖:
圖片描述

// ./src/webpack/client.ts

import * as path from 'path';

import * as webpack from 'webpack';

import { cloneDeep } from 'lodash'; // lodash提供的深度複製方法cloneDeep

// 客戶端+服務端全環境公共配置baseConfig,項目根目錄路徑baseDir,獲取tsRule的方法getTsRule
import baseConfig, { baseDir, getTsRule } from './base';

const clientBaseConfig: webpack.Configuration = cloneDeep(baseConfig); // 客戶端全環境公共配置

clientBaseConfig.entry = { // 入口屬性配置
  client: [ // 打包成client.js
    './src/client/index.tsx', // 客戶端入口文件
  ],
  vendor: [ // 打包成vendor.js
    'react',
    'react-dom',
  ],
};

const clientDevConfig: webpack.Configuration = cloneDeep(clientBaseConfig); // 客戶端開發環境配置

clientDevConfig.cache = false; // 禁用緩存
clientDevConfig.output.filename = '[name].js'; // 直接使用源文件名做爲打包後文件名
(clientDevConfig.module as webpack.NewModule).rules.push(
  getTsRule('./src/webpack/tsconfig.client.json'),
);
clientDevConfig.plugins.push(
  new webpack.optimize.CommonsChunkPlugin({ // 提取公共代碼到vendor.js中去
    filename: 'vendor.js',
    name: 'vendor',
  }),
  new webpack.NoEmitOnErrorsPlugin(), // 編譯出錯時跳過輸出階段,以保證輸出的資源不包含錯誤。
);

const clientProdConfig: webpack.Configuration = cloneDeep(clientBaseConfig); // 客戶端生產環境配置

// TODO 客戶端生產環境配置暫不處理和使用

export default {
  development: clientDevConfig,
  production: clientProdConfig,
};

因爲環境的差別,咱們須要爲at-loader提供特定的tsconfig,上面提到的./src/webpack/tsconfig.client.json內容與根目錄下的tsconfig有所差別

// ./src/webpack/tsconfig.client.json

{
  "compilerOptions": {
    "target": "es5",
    "jsx": "react"
  },
  "include": [
    "../../src/client/**/*"
  ]
}

去除outDir配置,由於再也不須要,另添加include屬性,只處理其值對應的相關文件。

疑問七:../../src/client/**/*這個路徑爲什麼不直接寫成../client/**/*?

答:因爲咱們後續啓動webpack是集成到koa app server中去的,而咱們全部的源文件都是ts,node啓動的是對應./dist目錄下js文件,因此這個路徑能夠理解爲,從dist目錄下往上到根目錄,而後再到src裏的源文件,若是直接寫../client/*/,則對應的是dist目錄下的client下的文件,這並非咱們想要的。

服務端webpack中間件

咱們不使用webpack-dev-server提供的完整的靜態資源服務器,由於咱們後續會作同構,咱們有本身的koa app server,因此咱們須要使用webpack-dev-middleware配合koa app server來實現與webpack-dev-server相同的效果。

想要在koa裏使用基於express的webpack-dev-middleware中間件須要額外作一些改造,緣由就是koa和express的中間件函數格式根本不同啊~

// ./src/webpack/koa-webpack-dev-middleware.ts

import * as Koa from 'koa';

import * as webpack from 'webpack';

import * as webpackDevMiddleware from 'webpack-dev-middleware';

export default (compiler: webpack.Compiler, opts?: webpackDevMiddleware.Options) => {
  const devMiddleware = webpackDevMiddleware(compiler, opts);
  const koaMiddleware = (ctx: Koa.Context, next: () => Promise<any>): any => {
    const res: any = {};
    res.end = (data?: any): void => {
      ctx.body = data;
    };
    res.setHeader = (name: string, value: string | string[]) => {
      ctx.headers[name] = value;
      if (name === 'Content-Type' && typeof value === 'string') {
        ctx.type = value;
      }
    };
    return devMiddleware(ctx.req, res, next);
  };
  Object.keys(devMiddleware).forEach((p) => {
    (koaMiddleware as any)[p] = (devMiddleware as any)[p];
  });
  return koaMiddleware;
};

建立webpack-dev-server

這裏指咱們本身建立一個函數,接收koa的實例來作一些操做。

// ./src/webpack/webpack-dev-server.ts

import * as Koa from 'koa';

import * as webpack from 'webpack';

import koaWebpackDevMiddleware from './koa-webpack-dev-middleware';

import webpackClientConfig from './client';

export default (app: Koa) => {
  const clientDevConfig = webpackClientConfig.development;
  const clientCompiler = webpack(clientDevConfig);
  const { output } = clientDevConfig;
  const devMiddlewareOptions = {
    publicPath: output.publicPath,
    stats: {
      chunks: false,
      colors: true,
    },
  };

  app.use(koaWebpackDevMiddleware(clientCompiler, devMiddlewareOptions));
};

服務端代碼

咱們加入koa的一些中間件以配合webpack-dev-server來處理咱們的請求。

安裝koa相關中間件

執行如下命令

npm install koa-router koa-compress koa-favicon --save
npm install @types/koa-compress --save-dev

服務端入口文件

入口文件中須要使用新的中間件,我修改了config的來源,在src下額外創建config文件夾用於存放全局配置信息。

// ./src/server/index.ts

import * as Koa from 'koa';

import { isDev, port } from '../config';

import * as KoaRouter from 'koa-router';

import * as favicon from 'koa-favicon';

import * as path from 'path';

import * as compress from 'koa-compress';

import webpackDevServer from '../webpack/webpack-dev-server';

const app = new Koa();
const router = new KoaRouter();

router.get('/*', (ctx: Koa.Context, next) => { // 配置一個簡單的get通配路由
  ctx.type = 'html';
  ctx.body = `
    <!DOCTYPE html>
    <html lang="zh-cn">
      <head>
        <title>react-app</title>
      </head>
      <body>
        <div id="app"></div>
        <script src="/assets/vendor.js"></script>
        <script src="/assets/client.js"></script>
      </body>
    </html>
  `;
  next();
});

if (isDev) {
  webpackDevServer(app); // 僅在開發環境使用
}

app.use(compress()); // 壓縮處理

app.use(favicon(path.join(__dirname, '../../public/favicon.ico'))); // favicon處理

app.use(router.routes())
   .use(router.allowedMethods()); // 路由處理

app.listen(port, () => {
    console.log(`Koa app started at port ${port}`);
});

PS: webpackDevServer必定要在其它中間件以前,不然後續加入熱更新功能後將沒法生效。

Thanks

By devlee

相關文章
相關標籤/搜索