3

客户端渲染

上文讲到服务端输出hello world,这次我们加入react,服务端输出html,让js去进行客户端渲染页面。

客户端代码

大家都知道react组件对应的文件后缀名是jsx,而使用ts的话,后缀名是tsx。

安装react相关依赖

目前用到的依赖有react和react-dom,还需要安装对应的@types

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

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

配置tslint

写代码前先做好代码规范相关的配置,养成良好习惯还是有必要的。

  1. 安装tslinttslint-react

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

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

    这里的规则(rules)我自己修改了两个,也可以不改或改其它的(看个人习惯),一个是引号(使用单引号),一个是console(不报错,仅警告)

  3. 安装vs code的tslint插件
    图片描述

    选择图中红框里的图标,然后输入tslint,可以看的,没安装过的同学会看的和下面两个一样的Install按钮,安装了的和我这里一样,安装完毕后vs code会重启当前窗口以使得插件生效。

疑问一:之前安装过tslint插件,在新的项目里添加tslint.json后不生效?

答:这个也正是我现在遇到的一个小问题,随便打开一个tsx文件,可以发现tslint没有生效,然后看右下角有提示:
图片描述

点开后我们可以看到这样一段话:

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


devlee
1.3k 声望31 粉丝