钉钉红包性能优化之路

一、业务背景

请客红包、小礼物作为饿了么自研的业务产品,在钉钉的一方化入口中常驻,作为高UV、PV的toB产品,面对不同设备环境的用户,经常会偶尔得到一些用户反馈,如【页面白屏太久了】、【卡住了】等等,本文将以产品环境为出发点(App中的H5),以前端基础、加载链路、端能力三个方向进行性能优化。

基于现阶段业务架构,在应用层进行通用的性能优化action拆解。

整体优化后接近秒开。

二、前端基础优化

2.1. 构建产物瘦身

作为前端基础优化的出水口,可通过webpack analyzer插件分析dist产物的具体分布,主要action如下:

  • 按需加载antd,减少79.28kb
  • 大型通用库接入cdn,基于externals排出构建包,减少65.08kb
  • debug工具生产环境不引入:vconsole,减少一次生产的http js请求
  • polyfill拆分,减少28.45kb
  • 钉钉、饿了么域接口返回图片裁剪,平均单张图片减少80%大小,请求时间减少80%
  • 压缩器从esbuild切换至terser(牺牲时间、提升压缩率),减少100.2kb
  • 移除无用的包:deepcopy、md5-js,减少3.58kb
  • 按需引入lodash、dingtalk-jsapi、crypto-js,减少49.18kb

关键优化代码:

ts 复制代码
// case1:按需引入大npm包
import setTitle from '@ali/dingtalk-jsapi/api/biz/navigation/setTitle';
import openLink from '@ali/dingtalk-jsapi/api/biz/util/openLink';
import setScreenKeepOn from '@ali/dingtalk-jsapi/api/biz/util/setScreenKeepOn';

// case2:externals拆包,转cdn引入
  externals: {
    react: 'React',
    'react-dom': 'ReactDOM',
  }

// case3:图片裁剪,降低质量、大小降低图片请求耗时
import getActualSize from './getActualSize';
import getImageType from './getImageType';

const getActualEleImageUrl = (url: string, size: number) => {
  if (typeof url === 'string' && url.includes('cube.elemecdn.com')) {
    const imageType = getImageType(url);
    const relSize = getActualSize(size);
    const end = `?x-oss-process=image/resize,m_mfit,w_${relSize},h_${relSize}/format,${imageType}/quality,q_90`;
    return `${url}${end}`;
  }
  return url;
};

// case4:按需加载antd-mobile
  extraBabelPlugins: [
    [
      'import',
      {
        libraryName: 'antd-mobile',
        libraryDirectory: 'es/components',
      },
    ],
  ],

结果:

  • 性能优化构建包gzip压缩后大小减少305.59KB,优化后大小474.74KB,下降39.1%;
  • 性能优化首屏加载冷启动FP减少400ms,热启动FP减少1s;

2.2. 预加载&预解析

将应用中所用到的所有请求资源的能力统一前置配置在html head,减少所有资源类请求的耗时。

ts 复制代码
  links: [
    {
      rel: 'dns-prefetch',
      href: 'https://g.alicdn.com/',
    },
    {
      rel: 'preconnect',
      href: 'https://g.alicdn.com/',
    },
    {
      rel: 'dns-prefetch',
      href: 'https://gw.alicdn.com/',
    },
    {
      rel: 'preconnect',
      href: 'https://gw.alicdn.com/',
    },
    {
      rel: 'dns-prefetch',
      href: 'https://img.alicdn.com/',
    },
    {
      rel: 'preconnect',
      href: 'https://img.alicdn.com/',
    },
    {
      rel: 'dns-prefetch',
      href: 'https://assets.elemecdn.com/',
    },
    {
      rel: 'preconnect',
      href: 'https://assets.elemecdn.com/',
    },
    {
      rel: 'dns-prefetch',
      href: 'https://static-legacy.dingtalk.com/',
    },
    {
      rel: 'preconnect',
      href: 'https://static-legacy.dingtalk.com/',
    },
    {
      rel: 'dns-prefetch',
      href: 'https://cube.elemecdn.com/',
    },
    {
      rel: 'preconnect',
      href: 'https://cube.elemecdn.com/',
    },
    {
      rel: 'preload',
      as: 'script',
      href: 'https://g.alicdn.com/??/code/lib/react/18.2.0/umd/react.production.min.js,/code/lib/react-dom/18.2.0/umd/react-dom.production.min.js',
    },
  ],

2.3 分包

在整个项目中以页面组件、公共组件、大npm包三个方向进行分包拆解,大体分包策略是尽可能减少SPA首次访问路由的chunk体积,策略如下:

  • node_modules里面大于160kb的模块拆分成单独的chunk;
  • 公共组件至少被引入3次拆分成单独的chunk;

分包关键代码:

ts 复制代码
  optimization: {
    moduleIds: 'deterministic', // 确保模块id稳定
    chunkIds: 'named', // 确保chunk id稳定
    minimizer: [
      new TerserJSPlugin({
        parallel: true, // 开启多进程压缩
        extractComments: false,
      }),
      new CssMinimizerPlugin({
        minimizerOptions: {
          parallel: true, // 开启多进程压缩
          preset: [
            'default',
            {
              discardComments: { removeAll: true },
            },
          ],
        },
      }),
    ],
    splitChunks: {
      chunks: 'all',
      maxAsyncRequests: 5, // 同时最大请求数
      cacheGroups: {
        // 第三方依赖
        vendors: {
          test: /[\/]node_modules[\/]/,
          name(module) {
            const packageName = module.context.match(/[\/]node_modules[\/](.*?)([\/]|$)/)[1];
            return `vendor-${packageName.replace('@', '')}`;
          },
          priority: 20,
        },
        // 公共组件
        commons: {
          test: /[\/]src[\/]components[\/]/,
          name: 'commons',
          minChunks: 3, // 共享模块最少被引用次数
          priority: 15,
          reuseExistingChunk: true,
        },
        lib: {
          // 把node_modules里面大于160kb的模块拆分成单独的chunk
          test(module) {
            return (
              module.size() > 160 * 1024 &&
              /node_modules[/\]/.test(module.nameForCondition() || '')
            );
          },
          // 把剩余的包打成一个chunk
          name(module) {
            const packageNameArr = module.context.match(/[\/]node_modules[\/](.*?)([\/]|$)/);
            const packageName = packageNameArr ? packageNameArr[1] : '';
            return `chunk-lib.${packageName.replace('@', '')}`;
          },
          priority: 15,
          minChunks: 1,
          reuseExistingChunk: true,
        },
        // 默认配置
        default: {
          minChunks: 2,
          priority: 10,
          reuseExistingChunk: true,
          name(module, chunks) {
            const allChunksNames = chunks.map((item) => item.name).join('~');
            return `common-${allChunksNames}`;
          },
        },
      },
    },
  },

三、加载链路优化

3.1 DOM load前置优化

在SPA所有JS文件解析完成(整个页面呈现),在前置可增加loading态替代白屏减少用户的等待焦虑,具体的思路是在html response -> js chunk全部解析完成中间,增加一个loading状态,提升FP、FCP性能指标,具体行动是编写了一个webpack html构建完的插件,在构建结果中的html手动注入loading组件。

插件实现比较简单:

ts 复制代码
import { IApi } from 'umi';

export default (api: IApi) => {
  // 用于在html ready到SPA应用js ready之间增加钉钉标准loading
  api.modifyHTML(($) => {
    $('head').prepend(`
      <style>
        body, html {
          width: 100%;
          height: 100%;
          margin: 0;
          padding: 0;
          border: 0;
          box-sizing: border-box;
        }
        #html-ding-loading-container {
          width: 100vw;
          height: 100vh;
          background: rgba(0, 0, 0, 0.2);
          opacity: 1;
        }
        #ding-loading {
          position: absolute;
          top: 50%;
          left: 50%;
          transform: translate(-50%, -50%);
          animation: lightningAnimate 1s steps(1, start) infinite;
          width: 3.6rem;
          height: 3.6rem;
          background-repeat: no-repeat;
          background-position: 0rem 0rem;
          background-size: 100%;
          background-image: url('https://img.alicdn.com/imgextra/i4/O1CN014kqXkX22y9iy5WhI6_!!6000000007188-2-tps-120-3720.png');
        }
        @keyframes lightningAnimate {
          0% { background-position: 0rem 0rem; }
          3.3% { background-position: 0rem calc(-1 * 3.6rem); }
          6.6% { background-position: 0rem calc(-2 * 3.6rem); }
          10% { background-position: 0rem calc(-3 * 3.6rem); }
          13.3% { background-position: 0rem calc(-4 * 3.6rem); }
          16.6% { background-position: 0rem calc(-5 * 3.6rem); }
          20% { background-position: 0rem calc(-6 * 3.6rem); }
          23.3% { background-position: 0rem calc(-7 * 3.6rem); }
          26.6% { background-position: 0rem calc(-8 * 3.6rem); }
          30% { background-position: 0rem calc(-9 * 3.6rem); }
          33.3% { background-position: 0rem calc(-10 * 3.6rem); }
          36.6% { background-position: 0rem calc(-11 * 3.6rem); }
          40% { background-position: 0rem calc(-12 * 3.6rem); }
          43.3% { background-position: 0rem calc(-13 * 3.6rem); }
          46.6% { background-position: 0rem calc(-14 * 3.6rem); }
          50% { background-position: 0rem calc(-15 * 3.6rem); }
          53.3% { background-position: 0rem calc(-16 * 3.6rem); }
          56.6% { background-position: 0rem calc(-17 * 3.6rem); }
          60% { background-position: 0rem calc(-18 * 3.6rem); }
          63.3% { background-position: 0rem calc(-19 * 3.6rem); }
          66.6% { background-position: 0rem calc(-20 * 3.6rem); }
          70% { background-position: 0rem calc(-21 * 3.6rem); }
          73.3% { background-position: 0rem calc(-22 * 3.6rem); }
          76.6% { background-position: 0rem calc(-23 * 3.6rem); }
          80% { background-position: 0rem calc(-24 * 3.6rem); }
          83.3% { background-position: 0rem calc(-25 * 3.6rem); }
          86.6% { background-position: 0rem calc(-26 * 3.6rem); }
          90% { background-position: 0rem calc(-27 * 3.6rem); }
          93.3% { background-position: 0rem calc(-28 * 3.6rem); }
          96.6% { background-position: 0rem calc(-29 * 3.6rem); }
          100% { background-position: 0rem calc(-30 * 3.6rem); }
        }
      </style>
    `);

    $('body').prepend(`
      <div id='html-ding-loading-container'>
        <div id='ding-loading'></div>
      </div>
    `);
  });
};

// 在umi中注入
  plugins: [
    '@umijs/plugins/dist/initial-state',
    '@umijs/plugins/dist/model',
    './plugins/loading.ts',
  ],

实现效果:

3.2 session管理持久化

系统中在前端资源全部response解析完成后,对所有的业务接口请求执行前都需要确保getUserInfo接口响应成功并在前端接收sessionId,然后在所有的业务接口中携带在参数中,在系统交互链路的前置流程过长的背景下,前端基于storage实现getUserInfo数据持久化从而节省一次关键串行接口的请求。

关键用户信息读取的代码:

ts 复制代码
import getCurrentUserInfo$ from '@ali/dingtalk-jsapi/api/internal/user/getCurrentUserInfo';

export async function fetchUserInfo() {
  const dingUid = await getCurrentUserInfo();
  let storageUserInfo = getUser();
  let res: UserDto;
  if (storageUserInfo && +dingUid?.uid === +storageUserInfo?.dingUserId) {
    // 如果缓存中的用户信息是当前用户,使用缓存
    res = {
      userType: 'dingtalkUid',
      userId: storageUserInfo.dingUserId,
      userName: storageUserInfo.nick,
      name: storageUserInfo.nick,
      mobile: storageUserInfo.mobile,
      avatarUrl: storageUserInfo.avatarUrl,
    };
    window.enjoyDrinkTrace.logError('命中storageUserInfo缓存');
  } else {
    // 未命中缓存,走请求用户信息流程
    const corpId = getQueryString('corpId');
    const userInfo = await getUserInfo({
      corpId,
      userChannel: getQueryString('__from__') || '',
    });
    res = {
      userType: 'dingtalkUid',
      userId: userInfo.data.data?.userID,
      userName: userInfo.data.data?.userName,
      name: userInfo.data.data?.userName,
      mobile: userInfo.data.data?.mobile,
      avatarUrl: userInfo.data.data?.avatarUrl,
    };
  }
  return res;
}

这一步优化在FP节点之后,减少了与FCP中间的耗时,减少量为一次接口请求的时间,约100ms。

四、做好基本的,再借助一下端能力

4.1 离线策略

做好前端基本的优化+H5加载链路的优化后结合cdn自带的缓存,整体的首屏用户体验已经很不错了。

那如native般的秒开,怎么实现?由于业务运行在钉钉中,咨询了钉钉同学,对于产品首页、红包页等页面布局不大的场景中尝试接入离线。

结合实际业务场景,在请客红包中,所有资源都可根据离线预置到App本地,在用户访问页面时,可以尽早为页面渲染铺垫;此外还可以推送相应的 js 缓存文件,减少 js 下载时长,让用户可交互时间提前;页面中的固定图片,也可以通过 zcache 缓存,提升页面图片整体的缓存命中率。

结合了所有的优化后,请客红包的IM消息主入口基本做到秒开。

五、未来规划

结合各类性能优化的手段,沉淀出相对应的代码、文档、prompt、tools等,集成到agent中,在未来的相关新产品设计中,让业务在起步阶段就有相对应稳定、体验较好的体感。

基于ARMS性能插件、钉钉容器性能监控看板,持续提升业务性能,保障业务用户体验。

对于场景投放类页面(目前是MPA多页方案),后续考虑转SSR

相关推荐
前端大卫5 分钟前
Vue3 里的 h 函数的运用场景!
前端·vue.js
ladymorgana36 分钟前
【OSS】 前端如何直接上传到OSS 上返回https链接,如果做到OSS图片资源加密访问
前端·网络协议·https
鬼多不菜1 小时前
一篇学习CSS的笔记
java·前端·css
慌糖1 小时前
Vue组件化
前端·javascript·vue.js
祺简1 小时前
CSS--background-repeat详解
前端·css
烛阴1 小时前
从零打造属于你的Python容器类型:全流程图解+实战案例
前端·python
blues_C2 小时前
十一、【核心功能篇】测试用例管理:设计用例新增&编辑界面
前端·vue.js·测试用例·element plus·测试平台
前端snow2 小时前
用cursor写一个微信小程序-购物网站实操
前端·javascript·后端
书语时2 小时前
ES6 深克隆与浅克隆详解:原理、实现与应用场景
前端·javascript·es6
Magnum Lehar2 小时前
vulkan游戏引擎的核心交换链swapchain实现
java·前端·游戏引擎