React SSR 实践过程(三)

1,571 阅读6分钟

前言

上一节对资源稍微作了处理,接下来把生产环境配置一下,对js进行按动态加载处理,开发中很多情况下,需要基于路由分包或者对一些比较少用到的库进行分包。

前端配置生产环境

前面webpack.base.js是把服务端和客户端的相同部分抽离出来,现在既然有了生产环境配置,那么就修改一下webpack.base.js,把这个作为客户端开发环境和生产环境的相同部分抽离。而服务端,则通过process.env.NODE_ENV进行判断开发环境和生产环境。

目录结构:

├── webpack // 配置文件夹
│ ├── loader //自定义loader文件夹
│ ├── scripts //node脚本文件夹
│ ├── webpack.base.js // 前端开发环境和生产环境相同部分抽离
│ ├── webpack.client.dev.js // 前端开发环境配置文件
│ ├── webpack.client.pro.js // 前端生产环境配置文件
│ ├── webpack.server.js // 服务端配置文件

安装依赖

$ npm i optimize-css-assets-webpack-plugin webpack-manifest-plugin -D

optimize-css-assets-webpack-plugin是用来压缩css文件用的,而webpack-manifest-plugin是用来生成资源映射表json用的,像生成环境,前端生成的文件肯定是带着hash的嘛,那服务端像获得相应的正确文件名,就可以通过这个plugin生成的json获取到。 先用webpack.base.js把前端开发环境和生产环境的相同配置抽离出来。

module.exports = {
  entry: resolvePath('../src/client/index.js'),
  resolve: {
    extensions: ['.js', '.jsx']
  },
  module: {
    rules: [{
      test: /.jsx?$/,
      use: 'babel-loader',
      exclude: /node_modules/
    }, {
      test: /\.css$/,
      use: [MiniCssExtractPlugin.loader, 'css-loader']
    }, {
      test: /\.(jpg|png|jpeg)$/,
      use: [{
        loader: 'url-loader',
        options: {
          limit: 10240,
          name: 'img/[name].[hash:7].[ext]',
          esModule: false
        }
      }]
    }]
  },
  optimization: {
    splitChunks: {
      cacheGroups: {
        libs: {
          test: /node_modules/,
          chunks: 'initial',
          name: 'libs'
        }
      }
    }
  },
  plugins: [
    new webpack.DefinePlugin({
      '__isServer': false
    }),
    new CleanWebpackPlugin()
  ]
}

那么前端开发环境配置就比较简单了,除却base部分,就只有mode output pluginsdevtool。这里devtool是为了方便开发环境出现错误调试使用,我用的是cheap-module-eval-source-map

module.exports = merge(base, {
  mode: 'development',
  output: {
    filename: 'index.js',
    path: resolvePath('../dist/client')
  },
  devtool: 'cheap-module-eval-source-map',
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'index.css'
    }),
  ]
});

至于前端的生产环境,主要就是增加hash,生产资源映射表,进行css代码压缩,顺便一提,webpack4mode = production的时候,已经做了很多处理,比如js压缩,所以我们就可以省去对js压缩处理步骤了。

module.exports = merge(base, {
  mode: 'production',
  output: {
    filename: 'index-[contentHash:10].js',
    path: resolvePath('../dist/client')
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'index-[contentHash:10].css'
    }),
    new webpackManifestPlugin({
      fileName: '../mainfest.json', // 这个是fileName,上面的是filename,迷惑
      filter: ({ name }) => {
        const ext = name.slice(name.lastIndexOf('.'));
        return ext === '.js' || ext === '.css';
      }
    }),
    new OptimizeCssAssetsWebpackPlugin()
  ]
})

webpackManifestPlugin设置filter的目的,是为了仅仅生产cssjs文件的资源映射表,对于其他类型文件,例如img则已经被正确引用。

app.use(express.static('./dist/client'));相当于是在dist/client目录下进行相对路径引用,所以图片ssr输出没啥问题。

那么现在,前端就可以打包生成文件&资源映射表:

同时,css文件也被压缩辽:

服务端配置生产环境

那么现在对于服务端又有了新的问题,服务端在html拼接的时候,是该引入什么文件名,即引入index.js还是index-xxx.js

这个其实可以直接通过webpack.definePlugin设置全局常量,在不同NODE_ENV,引入不同文件名,即可解决。

package.json命令上,设置NODE_ENV=production,即可。

同时,针对路径,设置alias,方便引入文件。

const isDev = process.env.NODE_ENV === 'development';

module.exports = {
  mode: process.env.NODE_ENV,
  target: 'node',
  entry: resolvePath('../src/server/index.js'),
  output: {
    filename: 'index.js',
    path: resolvePath('../dist/server')
  },
  externals: [nodeExternals()],
  module: {
    rules: [{
      test: /.css$/,
      loader: './webpack/loader/css-remove-loader'
    }, {
      test: /.jsx?$/,
      use: 'babel-loader',
      exclude: /node_modules/
    }, {
      test: /.(jpg|jpeg|png)$/,
      use: {
        loader: 'url-loader',
        options: {
          limit: 10240,
          name: 'img/[name].[hash:7].[ext]',
          esModule: false
        }
      }
    }]
  },
  resolve: {
    alias: {
      '@dist': resolvePath('../dist'),
    }
  },
  plugins: [
    new webpack.DefinePlugin({
      '__isServer': true,
      '__isDev': isDev
    }),
    new CleanWebpackPlugin()
  ]
};

服务端webpack配置文件中,通过process.env.NODE_ENV==='development',设置了全局常量__isDev。那么在html拼接文件中,就可以通过该全局变量进行判断,是引入index.js还是引入index-xxx.js

export const handleHtml = ({ reactStr, initialData, styles }) => {
  const jsKeys = ['libs.js', 'main.js'];
  const cssKeys = ['main.css'];

  let jsValues = [];
  let cssValues = [];

  if (__isDev) {
    jsValues = ['libs.index.js', 'index.js'];
    cssValues = ['index.css'];

  } else {
    const mainfest = require('@dist/mainfest.json');

    jsValues = jsKeys.map(v => mainfest[v]);
    cssValues = cssKeys.map(v => mainfest[v]);
  };
  return `<html>.....`;
};

代码还是看起来,清晰易懂的,就不做解释了。

最后在html中,引入我们的jsValuescssValues,即可。

`<!DOCTYPE html>
  <html lang="en">
  <head>
      <meta charset="UTF-8">
      <title></title>
      ${cssValues.map(v => `<link rel="stylesheet" href="${v}"></link>`).join('')}
  </head>
  <body>
      <div id="root">${reactStr}</div>
      <textarea id="textareaSsrData" style="display: none">${initialData}</textarea>
  </body>
  ${jsValues.map(v => `<script type="text/javascript" src="${v}"></script>`).join('')}
  </html>`

配置运行脚本&运行

最后,我们在package.json里,配置我们的前端打包&服务端打包等命令。

{
    "client:build": "webpack --config ./webpack/webpack.client.pro.js",
    "server:build": "NODE_ENV=production webpack --config ./webpack/webpack.server.js",
    "build": "npm run client:build && npm run server:build",
    "start": "node ./dist/server/index.js"
}

同时修改之前的server:dev,毕竟项目改成了传入NODE_ENV形式。

{
    "server:dev": "NODE_ENV=development webpack --watch --config ./webpack/webpack.server.js"
}

顺便一提,如果觉得写&&&挺麻烦的,可以用一些node任务管理模块,例如:npm-run-allscript-runner等等。 那么至此,就可以开始编译,并运行起来。

完整代码(ssr-pro)

JS动态加载

js动态加载主要通过import()方法,该方法使用promise回调,获取我们要异步加载的模块。被import()加载的组件,会被达成单独的js文件 ,但是有一些情况是不会处理成单独文件。

比如在CommonJS规范下,如果引入了文件的某个组件 ,但是对于其余组件进行动态加载,就会失败,其他组件也已经被打入到了main.js。这个是因为CommonJS模块是动态定义的,分析他们比较困难,这也是为什么使用CommonJS会导致程序包变大,一般解决方法是采用tree sharking

安装依赖

$ npm i @babel/{plugin-proposal-class-properties,plugin-syntax-dynamic-import} -D

并添加到.babelrc

{
  "plugins": [
    "@babel/plugin-syntax-dynamic-import",
    "@babel/plugin-proposal-class-properties"
  ]
}

React.lazy

对于页面组件比较少用到的,但是很占js文件体积的组件,可以采用React.lazy去异步加载组件。lazy其实就是对动态导入方式的一种优化方法 ,用法比较简单,没啥好说的。

const Comp = React.lazy(() => import('./components/child'));
const Index = () => {
  const [show, setShow] = useState(false);
  const click = () => setShow(true);

  return (
    <div>
      <div onClick={click}>page: Orange</div>
      {
        show ?
          <React.Suspense fallback={<div>loading</div>}>
            <Comp />
          </React.Suspense> : null
      }
    </div>
  )
};

基于路由进行动态加载

对于路由的组件而言,我们应该是传入函数()=>import(),在组件内部去调用这个函数。在回调中,再去渲染出异步加载的组件。

我这里是采用函数,传入加载函数返回组件,然后把加载函数,作为组件的静态方法,方便于后面服务端ssr的时候,直接加载出这个组件。

const asyncComp = (load) => {
  return class AsyncComp extends React.Component {
    constructor(props) {
      super(props)
      this.state = {
        Comp: null
      };
    };

    static _load = load;

    asyncLoad = async () => {
      const { default: Comp } = await AsyncComp._load();
      this.setState({ Comp });
    };

    componentDidMount() {
      if (!this.state.Comp) {
        console.log('加载异步组件');
        this.asyncLoad();
      }
    };

    render() {
      const { Comp } = this.state;
      return (
        Comp ? <Comp {...this.props} /> : <div>loading</div>
      )
    };
  };
};

那么对于路由配置文件而言,需要进行修改,对于需要异步加载的组件,采用以下修改

import { Fruit } from '../pages/index';
export default [{
  component: Fruit,
  path: '/',
  exact: true,
  name: 'Fruits'
}, {
  component: asyncComp(() => import('../pages/apple/index')),
  path: '/apple',
  exact: true,
  name: 'Apple'
},//...
]

但是采用以上配置,并没有打出若干个包

网上很多说法是,在webpack打包的时候,对于.jsx?文件,是采用babel去处理,babelmodules默认值是CommonJS。也就是,现在我们的代码是采用ES Module但是babel会转换成CommonJS导致的结果。

但是这里采用的是babel-loader8.x版本,其实已经不会默认转换成CommonJS了。

同时如果把后面的异步加载删除,仅仅保留Fruits,开启tree sharking后,其余page组件都会被移除,说明tree sharking并没有失效。

但是依旧被打包进了mian.js,说明tree sharking不能处理这种情况需要采用其他方法。

解决办法也比较直接:

  • 在相应目录下,去引入组件
  • 使用sideEffects去除代码

直接引入

import Fruit from '../pages/fruit/index';

sideEffects

webpack4中可以通过package.json添加sideEffects来标记无副作用的文件,那么webpack就会删除该文件中未使用的代码。

sideEffectstree sharking不太一样,tree sharking只能移除没有用到的代码成员,而sideEffects可以完整移除整个模块。

想要使用sideEffects需要在optimization里开启sideEffects: true,当然,production模式下,默认开启。

{
  "sideEffects": [
    "./src/client/pages/index.js"
  ]
}

那么以上两种方法,都可以让webpack将异步组件,分成单独的一个包。

可以看到,在切换路由的时候,会异步加载js文件。

但是如果直接访问对应的路由,服务端并没有返回相应组件,而是loading状态,说明在服务端并没有获取到组件。

因为服务端采用的路由配置是和客户端一样的,但是服务端并不需要异步加载,而是需要直接返回组件。那么需要对路由配置进行处理。

服务端处理动态加载问题

我们需要对路由进行处理,目前路由配置的component是采用异步加载,那么我们需要获取到静态路由配置。而前面,我们将动态加载函数放入到组件的静态方法里,在服务端我们就可以通过调用组件的静态方法,来获取异步加载的组件。

export const getStaticRoute = async (asyncRoute) => {
  const staitcRoute = [];
  const len = asyncRoute.length;

  for (let i = 0; i < len; i++) {
    const item = asyncRoute[i];
    staitcRoute.push({
      ...item
    });
    if (item.component._load) {
      const component = (await item.component._load()).default;
      staitcRoute[staitcRoute.length - 1].component = component;
    }
  };

  return staitcRoute;
};

ssr中间件里,我们需要缓存这个获取静态路由的结果。在运行时,通过自执行函数, 将静态路由结果保存到变量中。

let staticRoute = [];
(async () => {
  staticRoute = await getStaticRoute(routeConfig);
})();

并修改前面的App组件,将路由数组作为它的参数之一获取。那么在客户端获取到的是含异步组件的路由配置,在服务端则是静态路由配置。

const App = ({ pathname, initialData, routeConfig }) => {
  return (
    <Fragment>
      <Header />
      <Switch>
        {
          routeConfig.map(v => {
            const { name, ...rest } = v;
            if (pathname === v.path) {
              const { component: Component, ..._rest } = rest;
              return <Route key={v.path} {..._rest} render={(props) => {
                props.initialData = initialData;
                return <Component {...props} />
              }} />
            } else {
              return <Route key={v.path} {...rest} />
            }
          })
        }
        <Route component={() => <Redirect to="/" />} />
      </Switch>
    </Fragment>
  )
};

那么现在直接访问相应路由,ssr会返回直接返回组件结果。

但是在浏览器接手后,客户端渲染覆盖了服务端渲染结果。

这是因为,虽然服务端ssr返回了Banana组件渲染结果。但是浏览器并没有请求组件分割得到的xx.jsloading也就是在加载xx.js的过程。

那么解决办法就是在客户端渲染前,先进行js文件请求,在js文件请求回调中,再去进行渲染。同时去修改路由配置数组,将component设置成对应组件,那么就不会再去异步加载啦。

const clientRender = () => {
  ReactDOM.hydrate(
    <BrowserRouter>
      <App pathname={pathname} initialData={initialData} routeConfig={routeConfig} />
    </BrowserRouter>,
    document.getElementById('root')
  );
};

const judgeAsync = () => {
  const route = getAimComp(routeConfig, pathname);
  const asyncLoad = route.component._load;
  if (asyncLoad) {
    asyncLoad().then(res => {
      route.component = res.default; // 修改路由配置数组
      clientRender();
    });
  } else {
    clientRender();
  }
};

judgeAsync();

那么基于路由进行动态加载就完成辽,完整代码如下。

完整代码

其他章节:

React SSR 实践过程(一)

React SSR 实践过程(二)