Web SPA 应用首屏加载优化

3,747 阅读6分钟

开篇

目前团队内的项目所使用的技术为 SPA 单页面应用(React)客户端方式渲染。

客户端渲染方式相较于服务端渲染,能够轻松实现局部渲染效果(无需请求完整页面),用户体验更好。

但客户端渲染也存在一个很大的弊端:首屏加载缓慢,因为要等待 JS 主文件资源加载完毕后,执行 render 渲染页面,这样就会造成等待白屏时间太长。

这对于一个产品化的应用来说,面对大量的用户,若打开应用白屏时间过长,非常容易造成用户流失。

对于 首屏渲染优化 的思路和方法网上资料也有很多,下面,笔者将自己在公司产品项目上所做的首屏优化实践分享于大家。

  1. 延迟(动态)加载 辅助 资源;
  2. 页面路由按需加载;
  3. 代码分割(打包层面);
  4. 静态图片资源采用 cnd 方式。

一、延迟(动态)加载 辅助 资源

通常,在 HTML 文件中除了要引入应用程序的打包资源,可能还会涉及引入其他功能脚本资源。

比如,公司内有一个云文件应用,里面的文件需要支持进行预览,而预览的具体实现是在一个独立的工程中,提供脚本文件来覆盖使用所有产品化预览场景。

这时候,云文件应用就需要引入预览相关的 JS 资源,在点击文件时,调用预览 API 进行预览。

最初,预览资源是在主程序的打包资源之前引入的。我们都知道 script 标签会同步执行并阻塞后面的资源加载,这就会导致主程序的资源被延后加载,从而增加了白屏时长。

其实在初始化时加载预览资源可能意义不大(除非是程序初始化后立刻预览一个文件),在我们这个场景下,云文件应用初始化后进入首页,显示的是文件列表,只有用户点击列表中的文件后,开始进行预览。

那我们其实可以借助 JS 动态创建标签 的方式,将预览资源的加载时机移动在点击文件时(仅在第一次预览时动态加载资源),这样,就不会影响主应用的资源加载,减少白屏时长。

对于 动态加载资源,你的代码实现可能如下:

const usePreviewApi = () => {
  const loadResourceFlag = useRef<{ [key: string]: boolean }>({ css: false, js: false });
  ...
  
  const seePreview = (previewParams: SeePreviewParams) => {
    // 避免重复的加载
    if (Object.keys(loadResourceFlag.current).some(key => loadResourceFlag.current[key])) return;
    
    // 第一次加载预览资源
    if (!window.JwPreviewFile) {
      // 设置重复开关
      Object.keys(loadResourceFlag.current).forEach(key => loadResourceFlag.current[key] = true);
      
      // Message.open -> 正在加载程序资源...
      
      // 动态加载资源完成后的处理
      const onLoaded = (resource: string) => {
        loadResourceFlag.current[resource] = false;
        if (Object.keys(loadResourceFlag.current).every(key => loadResourceFlag.current[key] === false)) {
          previewFile(previewParams); // 调用预览 API 进行预览文件
          // Message.close
        }
      }

      // 加载 css
      const link = document.createElement('link');
      link.rel = 'stylesheet';
      link.href = location.origin + `/preview/main.css?v=${window.resourceVersion}`;
      link.onload = () => onLoaded('css');
      document.body.appendChild(link);

      // 加载 js
      const script = document.createElement('script');
      script.src = location.origin + `/preview/main.js?v=${window.resourceVersion}`;
      script.onload = () => onLoaded('js');
      document.body.appendChild(script);
    } else {
      // 预览资源已加载过,调用预览 API 即可
      previewFile(previewParams);
    }
  };
  
  return {
    seePreview,
  }
})

二、页面路由按需加载

通常我们在定义页面路由 Path 和路由组件时,使用方式可能如下:

import React from 'react';
import { HashRouter, Switch, Route } from 'react-router-dom';
import Home from '@/pages/Home';

const App = () => {
  return (
    <HashRouter basename="/">
      <Switch>
        <Route exact path='/' render={props => <Home {...props} />} />
        ...
      </Switch>
    </HashRouter>
  )
}

我们一个应用一般会有很多 <Route /> 路由页面,这种路由引用方式在经过 Webpack 打包 后会生成一个 bundle.js 文件。

但其实,对于首屏渲染,我们只是期望加载 Home 页面相关的资源进行渲染,其他页面的资源可以在访问其他路由 Path 时进行加载。

而现在将所有路由页面打包到一个 bundle.js 后,会增加加载资源的时长,延后了程序 render 渲染时机。

那么路由的按需加载如何实现呢?

我们需要将上例中的 ESModule import 模块方式改为 webpack import() 引入方式。

它会将模块看做一个分割点并将其打包为一个独立的 chunk 文件,在程序匹配到指定路由后,动态加载 chunk 文件进行视图呈现。import() 会以模块名称作为参数名并且返回一个 Promise对象

我们编写一个 高阶组件,封装 webpack import() 来按需加载路由组件。

// src/components/HighComponents/LazyComponent.tsx
import React from 'react';

const lazyCaches: { [key: string]: React.ComponentType<any> } = {};

function lazyComponent(lazyName: string, loadComponent: () => Promise<any>) {
  lazyCaches[lazyName] = lazyCaches[lazyName] || (
    class AsyncComponent extends React.PureComponent<any, { Component: React.ComponentType<any> | null }> {
      constructor(props: any) {
        super(props);
        this.state = { Component: null };
      }
      async componentDidMount() {
        const { default: Component } = await loadComponent();
        this.setState({ Component });
      }
      render() {
        const Component = this.state.Component;
        return (
          Component ? <Component {...this.props} /> : null
        )
      }
    }
  )

  return lazyCaches[lazyName];
}

export default lazyComponent;

lazyComponent 第一个参数是模块名称(用于 cache),第二参数是一个函数,用于执行并返回 webpakc import(),得到的将是一个 Promise

路由定义改造如下:

// src/App.tsx
import React from 'react';
import { HashRouter, Switch, Route, Redirect } from 'react-router-dom';
import lazyComponent from '@/components/HighComponents/LazyComponent';

const App = () => {
  return (
    <HashRouter basename="/">
      <Switch>
        ...
        <Route exact path='/outerShare/:shareId' component={lazyComponent('outerShare', () => import(/* webpackChunkName: "outerShare" */ '@/pages/OuterShare'))} />
        <Route path='/' component={lazyComponent('home', () => import(/* webpackChunkName: "home" */ '@/pages/home'))} />
        <Redirect from="/*" to="/" />
      </Switch>
    </HashRouter>
  )
}

三、代码分割

上面我们提到,webpack 将打包资源都打包在了一个 bundle.js 中,其中主要包含了开发的源代码第三方依赖 node_modules

我们可以对 node_modules 第三方依赖 打包资源拆分细化成多个资源文件,借助浏览器支持 HTTP 同时发起多个请求特性,使得资源异步并行加载,从而提高资源加载速度。

webpack 提供了 splitChunks 来支持这一配置。你的配置可能如下:

// webpack.config.js
module.exports = function ({ production = false, development = false }) {
  ...
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: 'static/js/[name].[contenthash:8].js', // 主文件分割出的文件命名
    chunkFilename: 'static/js/[name].[contenthash:8].chunk.js', // splitChunks 分割出的文件命名
  },
  
  optimization: {
      minimize: true,
      ...
      splitChunks: {
        chunks: 'all',
        cacheGroups: {
          default: false,
          vendors: false,
          // 多页面应用,或者 webpack import() 多个 chunk 文件中,有 import 其他模块两次或者多次时,会打包生成 common
          common: {
            chunks: "all",
            minChunks: 2,
            name: 'common',
            enforce: true,
            priority: 5
          }, 
          // node_modules 中的公共模块抽离
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            chunks: 'initial',
            enforce: true,
            priority: 10,
            name: 'vendor'
          },
          // @materual-ui
          material: {
            name: 'chunk-material',
            priority: 20, // 优先级高于 vendor
            test: /[\\/]node_modules[\\/]_?@material-ui(.*)/
          },
        }
      },
      runtimeChunk: { // 运行时代码(webpack执行时所需的代码)从主文件中抽离
        name: entrypoint => `runtime-${entrypoint.name}`,
      },
    },
})

四、静态图片资源采用 cdn 方式

最初,项目工程下对 import 的图片资源模块有这样一条打包规则:

  • 小于 10kb 的图片,打包成 base64 字符串形式;
  • 大于 10kb 的图片,打包生成新的静态资源文件使用。

采用 Base64 字符串格式特点虽然可以节省 HTTP 请求发起次数,但它的体积大也是一大缺陷。

而在上述提到的 云文件应用 这个工程中,会使用到几十个文件后缀类型 PNG 图片,它们的大小在 8-9 kb 左右。

这样一来,这些图片资源都会被打包成 Base64 格式存放在 bundle.js 中,这无疑是增大了打包资源的体积,影响首次加载资源的速度。

因此,对于这类图片资源,应该将 import 方式(src={module})改为 src="url" 静态资源服务上的图片地址更为合适。

这里推荐一个 webpack 打包体积和内容可视化分析插件 webpack-bundle-analyzer,分析打包资源从而选择合适的方式去优化处理。

// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; // 打包资源分析工具

module.exports = function ({ production = false, development = false }) {
  ...
   plugins: [
     ...
     new BundleAnalyzerPlugin(),
   ].filter(Boolean),
})

最后

感谢阅读,如有不足之处,欢迎指正👏。

参考: React router 动态加载组件