页面性能优化

260 阅读3分钟

效果对比

2、性能指标

优化之前:
image.png

优化之后:
image.png

页面渲染流程

whiteboard_exported_image (13).png

哪些原因导致页面首屏时间慢?

  1. 首屏渲染所需要的js体积太大
  2. 接口耗时太长(串行调用时间大约为1.2s)
  3. 长页面,大概有10屏,渲染时间过长

优化手段

减小静态资源体积

1、webpack分包
使用webpack-bundle-analyzer分析依赖的结构 + webpack splitChunks配置自定义分包,将单个js的体积控制在100k左右。

通过观察,在http2的加持下,将js的体积控制在100kb以下的效果是比较好的。

image.png

下面也贴一下nextjs中客户端webpack的配置 (超出160kB则进行拆分)

const frameworkCacheGroup = {
    chunks: "all",
    name: "framework",
    // Ensures the framework chunk is not created for App Router.
    layer: _utils.isWebpackDefaultLayer,
    test (module1) {
        const resource = module1.nameForCondition == null ? void 0 : module1.nameForCondition.call(module1);
        return resource ? topLevelFrameworkPaths.some((pkgPath)=>resource.startsWith(pkgPath)) : false;
    },
    priority: 40,
    // Don't let webpack eliminate this chunk (prevents this chunk from
    // becoming a part of the commons chunk)
    enforce: true
};
const libCacheGroup = {
    test (module1) {
        var _module_type;
        return !((_module_type = module1.type) == null ? void 0 : _module_type.startsWith("css")) && module1.size() > 160000 && /node_modules[/\\]/.test(module1.nameForCondition() || "");
    },
    name (module1) {
        const hash = _crypto.default.createHash("sha1");
        if (isModuleCSS(module1)) {
            module1.updateHash(hash);
        } else {
            if (!module1.libIdent) {
                throw new Error(`Encountered unknown module type: ${module1.type}. Please open an issue.`);
            }
            hash.update(module1.libIdent({
                context: dir
            }));
        }
        // Ensures the name of the chunk is not the same between two modules in different layers
        // E.g. if you import 'button-library' in App Router and Pages Router we don't want these to be bundled in the same chunk
        // as they're never used on the same page.
        if (module1.layer) {
            hash.update(module1.layer);
        }
        return hash.digest("hex").substring(0, 8);
    },
    priority: 30,
    minChunks: 1,
    reuseExistingChunk: true
};
// client chunking
return {
    // Keep main and _app chunks unsplitted in webpack 5
    // as we don't need a separate vendor chunk from that
    // and all other chunk depend on them so there is no
    // duplication that need to be pulled out.
    chunks: (chunk)=>!/^(polyfills|main|pages\/_app)$/.test(chunk.name),
    cacheGroups: {
        framework: frameworkCacheGroup,
        lib: libCacheGroup
    },
    maxInitialRequests: 25,
    minSize: 20000
};

2、动态引入 & 按需加载
分包只能将单个大的js拆分为多个小的js,考虑到带宽的影响,还需要减少同时请求的js体积。
对于一些一开始不需要的js,可以使用动态加载的方式引入。 项目中对html2canvas进行了动态引入,(注意配合webpackPrefetch

import(
    /* webpackChunkName: "html2canvas", webpackPrefetch: true */
    'html2canvas'
).then(async ({default: html2canvas}) => {
    ...
    }, 'image/jpeg');
}).catch(() => {
    Toast.show('生成报告图片失败,请点击重试');
});

3、其他静态资源的优化

  • 图片

  • 字体

    1. 注意font-display的使用
    2. fonttools字体集抽离 + unicode-range 实现字体集的按需加载

接口调用时机调整

因为是csr的项目,在react的组件中发出接口请求,需要等待js全部加载完成,才可以发出请求,然后接收响应数据进行渲染。

所以想到了将接口请求时机提前的方式。

开始我使用的方式是,在index.html中head标签内去编写内联的js,发送请求,然后将请求对应的promise挂载到windows上,在组件中,使用window上的promise来获取数据,大概的代码如下:

//挂载promise在window上
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="utf-8">
    <title>xxx</title>
    <link rel="shortcut icon" href="/planning-report-fe/static/favicon.ico">
    <meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no">
    <script>
        (() => {
                window.submitQuestionnairePromise = new Promise((resolve, reject) => {
                    submitQuestionnaire()
                        .then(submitResStr => {
                            const submitRes = JSON.parse(submitResStr);
                            if (submitRes.code === 0 && submitRes.data) {
                                resolve(true);
                            }
                            else {
                                reject(submitRes);
                            }

                        })
                        .catch(e => {
                            reject(e);
                        });
                });
                window.submitQuestionnairePromise
                    .then(() => {
                        window.queryReportDataPromise = new Promise((resolve, reject) => {
                            queryReportData()
                                .then(reportDataResStr => {
                                    const reportDataRes = JSON.parse(reportDataResStr);
                                    if (reportDataRes.code === 0 && reportDataRes.data) {
                                        resolve(reportDataRes.data);
                                    }
                                })
                                .catch(e => {
                                    reject(e);
                                });
                        });
                    })
                    .catch(e => {
                        console.error(e);
                    });
        })();
    </script>
</head>
<body>
    <div id="app"></div>
</body>
</html>

//组件中获取数据
window.submitQuestionnairePromise?.then(submitRes => {
    if (submitRes) {
        ...
        window.queryReportDataPromise?.then(reportDataRes => {
            ...
        }).catch(() => {
            setIsShowErrorPage(true);
        });
    }
}).catch(() => {
    setIsShowErrorPage(true);
});

但是在上线后,我发现出现了一个问题,第1个接口是在js请求之前就发出了,但是第2个请求,有时还是会等js全部加载和执行完之后才去请求。

image.png

经过分析,我发现是js的事件循环机制导致的。

whiteboard_exported_image (14).png

解决方法:webworker将请求的线程独立于主线程之外。

whiteboard_exported_image (15).png

分段渲染(先让首屏的内容渲染出来)

/**
 * 页面上方的模块静态引入,下方的模块动态引入
 */

import {lazy} from 'react';
import TeacherNodeModule from './teacherNoteModule';
import HeaderModule from './headerModule';
import BaseInfoModule from './baseInfoModule';
import StudentDiagnosticModule from './studentDiagnosticModule';

const RecommondSubjectModule = lazy(() => import(

    /* webpackChunkName: "recommondSubjectModule" */
    './recommondSubjectModule'
));

const ScorePositionModule = lazy(() => import(

    /* webpackChunkName: "scorePositionModule" */
    './scorePositionModule'
));

const ImportScorePlanModule = lazy(() => import(

    /* webpackChunkName: "importScorePlanModule" */
    './improveScorePlanModule'
));

const RecommondSchoolModule = lazy(() => import(

    /* webpackChunkName: "recommondSchoolModule" */
    './recommondSchoolModule'
));

const ImprovementPlanModule = lazy(() => import(

    /* webpackChunkName: "improvementPlanModule" */
    './improvementPlanModule'
));

const SubjectScoreTargetModule = lazy(() => import(

    /* webpackChunkName: "subjectScoreTargetModule" */
    './subjectScoreTargetModule'
));

const SubjectImprovePlanModule = lazy(() => import(

    /* webpackChunkName: "subjectImprovePlanModule" */
    './subjectImprovePlanModule'
));

const ExamAnalysisModule = lazy(() => import(

    /* webpackChunkName: "examAnalysisModule" */
    './examAnalysisModule'
));

const IdealSchoolModule = lazy(() => import(

    /* webpackChunkName: "idealSchoolModule" */
    './idealSchoolModule'
));

const EndModule = lazy(() => import(

    /* webpackChunkName: "endModule" */
    './endModule'
));

const CalendarNotesModule = lazy(() => import(

    /* webpackChunkName: "calendarNotesModule" */
    './calendarNotesModule'
));

export {
    BaseInfoModule,
    HeaderModule,
    TeacherNodeModule,
    StudentDiagnosticModule,
    RecommondSubjectModule,
    ScorePositionModule,
    ImportScorePlanModule,
    RecommondSchoolModule,
    ImprovementPlanModule,
    CalendarNotesModule,
    SubjectScoreTargetModule,
    SubjectImprovePlanModule,
    ExamAnalysisModule,
    IdealSchoolModule,
    EndModule,
};