嗯…微信小程序主包又双叒叕不够用了!!!

作者:潘宇

一、背景

又到新的一年。随着古茗点单小程序在过去一年的持续迭代,主包体积也在一路"膨胀"。最终,我们再次直面一个熟悉的问题------主包容量,又双叒叕不够用了。

以下是我们已经使用过的优化手段:

  • 分包异步加载:通过微信提供的require.async方法可以实现跨分包 JS 代码引用
  • 页面分包:主包仅保留默认「启动页/TabBar页」,其他页面模块均迁移至分包
  • 图片资源优化:上传本地资源,并通过Babel插件实现本地资源替换为网络地址。
  • 公共模块分包:Taro项目配置mini.optimizeMainPackage,将主包未使用的公共模块打包至对应分包。

那么还有什么我们能做的呢?比如:

减少兼容代码

随着移动设备硬件性能的提升以及微信版本的不断升级,用户设备对ES6及以上语法的支持度已显著提高。在这一背景下,大量为兼容ES5而引入的降级与垫片代码逐渐失去必要性,反而成为包体体积的负担,具备明确的优化空间。

分包异步加载样式文件

上一次主包体积优化的分享中,我们提到通过 动态import配合splitChunks,将 React 组件的 JavaScript 逻辑拆分并编译到分包中,从而有效降低了主包的 JS 体积。然而,由于微信小程序本身缺乏样式层面的异步加载机制,这些异步组件所依赖的样式文件最终仍然需要在主包的app.wxss中统一引入。

对打包产物进行分析后可以发现,这部分异步组件的样式体积占比不小,并且会随着异步组件数量的增加而持续膨胀。由此可见,样式加载策略成为当前主包体积优化中的一个关键突破点,也是后续重点的优化方向之一。

二、目标

  • 删除为兼容ES5而引入的降级与垫片代码
  • 实现样式文件的分包异步加载

三、优化方案

ES5兼容代码剔除

实现思路

调整Browserslist

Browserslist 用来描述代码最终运行环境的能力范围,构建工具会根据它决定哪些语法需要被转成 ES5、哪些兼容代码可以被剔除。因此,编译产物中是否包含 ES5 兼容代码,本质上取决于 Browserslist 的配置。

通过MiniProgram ECMAScript compatibility table可以了解到,微信小程序的语法兼容性主要取决于小程序基础库版本;再结合微信官方提供的基础库版本分布信息,确定当前需要支持的最低基础库版本。

微信官方同时提供了 miniprogram-compat,用于将基础库能力转换为 Browserslist 配置。 在此基础上,只需要确认最低支持的基础库版本,并通过 miniprogram-compat 生成对应的 Browserslist 配置,在构建阶段由发布脚本将该配置注入 process.env.BROWSERSLIST,实现 ES5 兼容代码的精准剔除。

踩的一些坑

灰度过程中,部分 iOS 14 设备出现白屏

最终确认原因是 Browserslist 配置过于激进,低版本基础库在老 iOS 环境下对部分 ES 能力支持不稳定,导致必要的转译和兼容代码被提前剔除。

最后将微信端最低兼容基础库版本调整为 miniprogram-compat 的配置调整为 2.29.0 后,问题消失。

代码实现

javascript 复制代码
import { getBrowsersList } from 'miniprogram-compat'
import type { IPluginContext } from '@tarojs/service'

interface Opt {
    minBaseLibraryVersion: string
}

export default (ctx: IPluginContext, pluginOpts:Opt) => {
  ctx.onBuildStart(() => {
    if (process.env.TARO_ENV !== 'weapp') return

    const browsersList = getBrowsersList(pluginOpts.minBaseLibraryVersion)

    if (!browsersList) return

    process.env.BROWSERSLIST = browsersList?.join(',')

    console.log('✅ Successfully set BROWSERSLIST from minBaseLibraryVersion')
  })
}

样式文件分包异步加载(失败了😭,就在这里分享一下踩坑的过程吧)

实现思路

分包页面样式是否能影响主包?

我们试着在分包里加一个空页面,希望分包加载时能把这个页面的样式一起带进来,从而影响主包页面的样式。

但实际测试下来,这条路走不通。(ps:也可能是实现有问题不一定对)

查阅微信小程序组件系统文档可以确认,组件之间本身就存在样式隔离。虽然文档里没有明确说明页面级别的隔离规则,但结合这次尝试的结论猜测页面之间同样是隔离的。因此单纯指望通过分包页面来加载样式,并不能实现样式文件分包异步加载。

分包自定义组件样式是否能影响主包?

查阅微信小程序自定义组件样式隔离文档后发现,组件的样式隔离其实是可以配置的。只要把自定义组件的styleIsolation设置为shared,组件样式就可以直接作用到页面上。

再结合跨分包自定义组件引用这一能力,就可以把样式放进分包组件里,随着组件的加载一起生效,从而实现样式的分包异步加载。

经过实际验证,这套方案是可行的。既然机制成立,下一步就是把思路变成工程能力。将其实现为一个 Taro 插件,把样式拆分、分包组件生成以及跨分包引用等逻辑自动化,尽量减少对业务侧的改造成本。

无法彻底解决的问题

我们在实际用下来之后,还是存在一个无法解决的问题:页面会短暂闪一下"没样式的 DOM"

本质原因其实也很简单:样式是动态加载的,但它到得比 JS 慢。JS 已经跑完了,组件也渲染出来了,但样式还没到位,于是用户就能看到一瞬间的"裸页面"。

此处为语雀卡片,点击链接查看

结合下来,主要有这么几种情况:

冷启动的时候顺序不对

在冷启动场景下,用来注入样式的自定义组件 ,挂到页面上的时机会比 require.async 加载的 JS 还慢

结果就是:

  • JS 先执行
  • React 组件先渲染
  • 样式对应的自定义组件还没准备好

于是首屏就会闪一下没样式的内容。

JS 有缓存,样式没有

异步 JS 这块,用的是 React.lazy加载过一次之后是有缓存的,后面再进页面基本就是同步的。

但样式这边不一样:自定义组件注入样式是没法缓存的,每次进页面都得重新走一遍流程。

所以就会出现这种情况:

  • JS 一下子就执行完了
  • 样式却还是慢悠悠地在加载
  • 最终样式总是追不上渲染节奏
无法准确的知道样式文件生效的时机

在组件实例进入页面节点树时触发的 attached,以及渲染线程初始化完成时触发的 ready,都无法作为"样式已生效"的可靠判定时机。

在小程序渲染线程繁忙的情况下,样式实际生效可能会明显延迟,导致页面短暂出现"无样式 DOM"的闪烁现象

这也是该方案无法从根本上解决样式分包问题的核心原因

当前可行的缓解方案

为了解决「冷启动的时候顺序不对」与「JS 有缓存,样式没有」,我们在样式真正注入完成之前,不让异步组件内容出来

具体做法是:在自定义组件把样式注入完之前,先展示 Suspense 的 fallback,等样式 ready 了再一起把内容放出来,这样用户就看不到那一下没样式的闪屏了。

所以后面我们自己实现了一套 react-lazy-enhanced :在每次页面渲染异步组件的时候 ,都会额外等一下自定义组件的样式注入完成。在这之前,页面就一直停在 fallback 状态,不会提前渲染真实 DOM。

简单说就是一句话:JS 可以先到,但组件一定要等样式准备好再出来

最终放弃的方案

针对「无法准确判断样式文件何时真正生效」的问题,我们也考虑过一种方案: 在页面中插入一个用户不可见的检测节点,通过轮询该节点的计算样式(getComputedStyle),判断目标样式是否已经生效,从而人为构造一个"样式 ready"钩子。 也就是说,不再依赖组件生命周期,而是直接通过渲染结果反向推断样式是否生效。

但这种方式存在明显问题:检测节点本身可能会受到其他用户自定义样式、全局样式或样式覆盖规则的影响,导致计算结果不稳定。也就是说,我们无法保证检测到的样式变化一定来自目标样式文件本身。 一旦样式优先级、合并策略或运行环境发生变化,判断逻辑就可能失效。

因此,这种方案本质上仍然是基于副作用的黑盒推断,稳定性和可维护性都难以保证,最终没有采用。

代码实现(基于上一次主包体积优化的分享中提到的插件进行改造)

尽管方案没有达到预期目标,但其中的探索过程和实现思路仍然值得讨论,欢迎感兴趣的同学查看我们的代码实现并交流想法。

插件入口
  • 改造splitChunk逻辑,让样式文件和js文件统一输出到分包目录
  • 调用transformWebpackRuntimeTransformBeforeCompressionPluginName修改runtime.js
  • 拷贝编译后SingletonPromisedist目录
  • 调用transformAppConfig修改app.json输出
  • 调用transformPagesWXml为全局页面插入自定义组件
javascript 复制代码
import fs from 'fs';
import path from 'path';
import type { IPluginContext } from '@tarojs/service';
import { RawSource } from 'webpack-sources';
import {
  InjectStyleComponentPlugin,
  PLUGIN_NAME as InjectStyleComponentPluginName,
  InjectStyleComponentName,
} from './inject-style-component';
import { transformAppConfig } from './transform-app-config';
import {
  TransformOpt,
  TransformBeforeCompressionPlugin,
  PLUGIN_NAME as TransformBeforeCompressionPluginName,
} from './transform-before-compression-plugin';
import { transformPagesWXml } from './transform-pages-wxml';
import { transformWebpackRuntime } from './transform-webpack-runtime';
import { AsyncPackOpts } from './types';

export { AsyncPackOpts } from './types';

const dynamicPackOptsDefaultOpt: AsyncPackOpts = {
  dynamicPackageName: 'dynamic-common',
};

export default (ctx: IPluginContext, pluginOpts: AsyncPackOpts) => {
  const finalOpts = { ...dynamicPackOptsDefaultOpt, ...pluginOpts };

  if (process.env.TARO_ENV !== 'weapp') return;

  ctx.modifyWebpackChain(({ chain }) => {
    // 动态获取现有的 splitChunks 配置
    const existingSplitChunks = chain.optimization.get('splitChunks') || {};

    const { common, vendors } = existingSplitChunks.cacheGroups;

    const newCommonChunks = common ? { ...common, chunks: 'initial' } : common;

    const newVendorsChunks = vendors ? { ...vendors, chunks: 'initial' } : vendors;

    chain.optimization.merge({
      splitChunks: {
        ...existingSplitChunks,
        cacheGroups: {
          ...existingSplitChunks.cacheGroups,
          common: newCommonChunks,
          vendors: newVendorsChunks,
        },
      },
    });

    chain.merge({
      output: {
        chunkFilename: `${finalOpts.dynamicPackageName}/[chunkhash].js`, // 异步模块输出路径
        path: ctx.paths.outputPath,
        clean: true,
      },
    });

    chain.plugin('miniCssExtractPlugin').tap((args) => {
      const [options] = args;
      const chunkFilename = `${finalOpts.dynamicPackageName}/[chunkhash].wxss`;
      const finalOption = { ...options, chunkFilename };
      return [finalOption];
    });

    chain.module
      .rule('script')
      .use('babelLoader')
      .tap((opts) => {
        const pluginConfig = path.resolve(__dirname, './transform-react-lazy');
        return { ...opts, plugins: [pluginConfig, ...(opts.plugins || [])] };
      });

    chain.plugin(TransformBeforeCompressionPluginName).use(TransformBeforeCompressionPlugin, [
      {
        test: /^(runtime\.js)$/,
        transform: (opt: TransformOpt) => {
          const { source, assets } = opt;
          const transformOpts = { ...finalOpts, assets };
          return transformWebpackRuntime(source as string, transformOpts);
        },
      },
    ]);

    chain.plugin(InjectStyleComponentPluginName).use(InjectStyleComponentPlugin, [finalOpts]);
  });

  ctx.modifyBuildAssets(({ assets }) => {
    const hasDynamicModule = Object.keys(assets).some((key) =>
      key.startsWith(`${finalOpts.dynamicPackageName}/`)
    );

    if (!hasDynamicModule) return;

    const asyncComponentPath = `./${InjectStyleComponentPlugin.generateComponentPath(finalOpts)}`;

    const asyncComponents = { [InjectStyleComponentName]: asyncComponentPath };

    const singletonPromisePath = path.resolve(__dirname, './singleton-promise.js');

    const fileContent = fs.readFileSync(singletonPromisePath, { encoding: 'utf-8' });

    assets['singleton-promise.js'] = new RawSource(fileContent);

    transformAppConfig({ ...finalOpts, assets, asyncComponents });

    transformPagesWXml({ assets, asyncComponents });
  });
};
实现增强lazy
  • 用于每次组件加载触发Suspensefallback
javascript 复制代码
import React, {
  ComponentPropsWithoutRef,
  ComponentRef,
  ComponentType,
  ForwardRefExoticComponent,
  useEffect,
} from 'react';

enum Status {
  Uninitialized = 'uninitialized',
  Pending = 'pending',
  Resolved = 'resolved',
  Rejected = 'rejected',
}

interface Result<T> {
  default: T;
}

interface LoadData<T> {
  status: Status;
  result?: T | Error;
  promise?: Promise<void>;
}

type Factory<T extends ComponentType<any>> = () => Promise<Result<T>>;

export const lazy = <T extends ComponentType<any>>(factory: Factory<T>) => {
  const LazyComponent = React.lazy(factory) as ForwardRefExoticComponent<any>;
  const loadData: LoadData<T> = { status: Status.Uninitialized };

  const load = () => {
    if (loadData.status !== Status.Uninitialized) return;
    const successCallback = (res: Result<T>) => {
      loadData.status = Status.Resolved;
      loadData.result = res.default;
    };
    const errorCallback = (err: Error) => {
      loadData.status = Status.Rejected;
      loadData.result = err;
    };
    loadData.promise = factory().then(successCallback, errorCallback);
    loadData.status = Status.Pending;
  };

  const resetLoadData = () => {
    loadData.status = Status.Uninitialized;
    loadData.result = undefined;
    loadData.promise = undefined;
  };

  return React.forwardRef<ComponentRef<T>, ComponentPropsWithoutRef<T>>((props, ref) => {
    if (loadData.status === Status.Uninitialized) load();

    if (loadData.status === Status.Pending) throw loadData.promise;

    if (loadData.status === Status.Rejected) throw loadData.result;

    useEffect(() => {
      return resetLoadData();
    }, []);

    return <LazyComponent {...props} ref={ref} />;
  });
};
实现单例promise(singleton-promise)
  • 用于自定义组件通知runtime.js样式文件已加载完成
javascript 复制代码
export class SingletonPromise {
  // 静态属性存放单例
  private static instance?: Map<string, SingletonPromise>;

  static getInstance() {
    if (!SingletonPromise.instance) SingletonPromise.instance = new Map();
    return SingletonPromise.instance;
  }

  static wait() {
    const instance = this.getInstance();
    const currentPages = getCurrentPages();
    return Promise.all(
      currentPages.map((page) => {
        if (!instance.has(page.route)) instance.set(page.route, new SingletonPromise());
        return instance.get(page.route)?.promise;
      })
    );
  }

  static loaded(pageRoute: string) {
    const instance = this.getInstance();
    if (!instance.has(pageRoute)) instance.set(pageRoute, new SingletonPromise());
    instance.get(pageRoute)?.resolve?.();
  }

  static unloaded(pageRoute: string) {
    const instance = this.getInstance();
    instance.delete(pageRoute);
  }

  private promise?: Promise<void>;

  private resolve?: () => void;

  constructor() {
    this.resetPromise();
  }

  private resetPromise() {
    this.promise = new Promise<void>((resolve) => {
      this.resolve = resolve;
    });
  }
}
插入样式自定义组件(inject-style-component)
  • 调用singleton-promise通知runtime.js样式注册完成
javascript 复制代码
import path from 'path';
import { Compiler, Compilation } from 'webpack';
import { AsyncPackOpts } from './types';

export const PLUGIN_NAME = 'InjectStyleComponent';

export const InjectStyleComponentName = 'inject-style';

const injectStyleComponentCode = `
const { SingletonPromise } = require('~/singleton-promise.js')
Component({
  properties: {
    route: String,
  },
  lifetimes: {
    attached: function () {
      // 在这里通知页面样式已加载完成(但实际测试当渲染进程忙时还是会出现裸样式的情况)
      return SingletonPromise.loaded(this.data.route)
    },
    detached: function () {
      return SingletonPromise.unloaded(this.data.route)
    }
  }
})
`;

type Opt = AsyncPackOpts;

export class InjectStyleComponentPlugin {
  static generateComponentPath(opt: Opt) {
    return `${opt.dynamicPackageName}/${InjectStyleComponentName}`;
  }

  private readonly opt: Opt;

  private readonly WXmlContent: string = '<block/>';

  private readonly JsonContent: string = '{"component": true,"styleIsolation": "shared"}';

  private readonly JsContent: string = injectStyleComponentCode;

  constructor(opt: Opt) {
    this.opt = opt;
  }

  apply(compiler: Compiler) {
    compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation: Compilation) => {
      const stage = compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL; // 最早阶段,在优化前

      compilation.hooks.processAssets.tap({ name: PLUGIN_NAME, stage }, (assets) => {
        const { dynamicPackageName } = this.opt;

        const dynamicPackageStyleFileRegExp = new RegExp(`^${dynamicPackageName}\\/.*\\.wxss$`);

        const styleFileContent = Object.keys(assets).reduce((result, assetPath) => {
          if (!dynamicPackageStyleFileRegExp.test(assetPath)) return result;
          const relativePath = path.relative(dynamicPackageName, assetPath);
          const code = `@import './${relativePath}';`;
          return `${result + code}\n`;
        }, '');

        const componentPath = InjectStyleComponentPlugin.generateComponentPath(this.opt);

        const { RawSource } = compiler.webpack.sources;

        compilation.assets[`${componentPath}.wxss`] = new RawSource(styleFileContent);
        compilation.assets[`${componentPath}.wxml`] = new RawSource(this.WXmlContent);
        compilation.assets[`${componentPath}.json`] = new RawSource(this.JsonContent);
        compilation.assets[`${componentPath}.js`] = new RawSource(this.JsContent);
      });
    });
  }
}
转换页面wxml(transformPagesWXml)
  • 为所有页面插入自定义组件
javascript 复制代码
import { AppConfig } from '@tarojs/taro';
import { RawSource } from 'webpack-sources';

interface Opts {
  assets: Record<string, RawSource>;
  asyncComponents: Record<string, string>;
}

const appConfigAssetKey = 'app.json';

export const transformPagesWXml = (opts: Opts) => {
  const { assets, asyncComponents } = opts;

  const curAppConfig: AppConfig = JSON.parse(assets[appConfigAssetKey].source() as string);

  const pageWXmlPaths = (() => {
    const { pages = [], subpackages, subPackages, tabBar } = curAppConfig;
    const tabBarPagePaths = tabBar?.list?.map((item) => item.pagePath) || [];
    const curSubPackages = subPackages || subpackages || [];
    const subPackagePagePaths = curSubPackages.reduce<string[]>((result, item) => {
      const subPackagePagePath = item.root || '';
      return [...result, ...(item.pages || []).map((page) => `${subPackagePagePath}/${page}`)];
    }, []);
    return [...pages, ...tabBarPagePaths, ...subPackagePagePaths].map((item) => `${item}.wxml`);
  })();

  Object.keys(assets).forEach((assetPath) => {
    if (!pageWXmlPaths.includes(assetPath)) return;
    const source = assets[assetPath].source() as string;
    const pageRoute = assetPath.replace(/\.wxml$/, '');
    const asyncComponentCode = Object.keys(asyncComponents).map(
      (item) => `<${item} route="${pageRoute}"/>`
    );
    const tempCode = [source, ...asyncComponentCode].join('\n');
    assets[assetPath] = new RawSource(tempCode);
  });
};
转换Appconfig(transform-app-config)
  • 注册异步分包
  • 注册异步样式自定义组件
  • 注册路径别名
javascript 复制代码
import { RawSource } from 'webpack-sources';
import type { AsyncPackOpts } from './types';

interface Opts extends AsyncPackOpts {
  assets: Record<string, RawSource>;
  asyncComponents: Record<string, string>;
}

const appConfigAssetKey = 'app.json';

export const transformAppConfig = (opts: Opts) => {
  const { dynamicPackageName, asyncComponents, assets } = opts;

  const curAppConfig = JSON.parse(assets[appConfigAssetKey].source() as string);

  const {
    subPackages,
    subpackages,
    resolveAlias = {},
    usingComponents = {},
    componentPlaceholder = {},
    ...otherAppJSON
  } = curAppConfig;

  const finalSubPackages = subPackages || subpackages || [];

  const dynamicPackagesConfig = { root: dynamicPackageName, pages: [] };

  const asyncComponentPlaceholder = Object.keys(asyncComponents).reduce((result, item) => {
    return { ...result, [item]: 'block' };
  }, {});

  const finalAppConfig = {
    ...otherAppJSON,
    usingComponents: { ...usingComponents, ...asyncComponents },
    componentPlaceholder: { ...componentPlaceholder, ...asyncComponentPlaceholder },
    subPackages: [...finalSubPackages, dynamicPackagesConfig],
    resolveAlias: { ...resolveAlias, '~/*': '/*' },
  };

  assets[appConfigAssetKey] = new RawSource(JSON.stringify(finalAppConfig));
};
全局替换React.lazy
javascript 复制代码
import path from 'path';
import { PluginObj, NodePath, PluginPass } from '@babel/core';
import * as types from '@babel/types';
import { CallExpression, ImportDeclaration, Program, VariableDeclarator } from '@babel/types';

const customLazySource = '@guming/taro-plugin-async-pack/build/esm/react-lazy-enhanced';

const normalFilename = (filename: string) => path.normalize(filename).replace(/\\/g, '/');

interface State extends PluginPass {
  reactNamespaces: Set<string>;
  reactLazyBindings: Set<string>;
}

export default (): PluginObj<State> => {
  return {
    visitor: {
      Program: {
        enter(programPath: NodePath<Program>, state: State) {
          const { filename = '' } = state;

          if (new RegExp(customLazySource).test(normalFilename(filename))) return;

          state.reactNamespaces = new Set();

          state.reactLazyBindings = new Set();

          programPath.traverse({
            ImportDeclaration(path: NodePath<ImportDeclaration>) {
              if (!types.isStringLiteral(path.node.source, { value: 'react' })) return;

              const { specifiers } = path.node;

              specifiers.forEach((spec) => {
                const { name } = spec.local;

                //  import React from 'react'
                if (types.isImportDefaultSpecifier(spec)) return state.reactNamespaces.add(name);

                //  import * as React from 'react'
                if (types.isImportNamespaceSpecifier(spec)) return state.reactNamespaces.add(name);

                // import  { lazy } from 'react'
                // eslint-disable-next-line prettier/prettier
                if (types.isImportSpecifier(spec) && types.isIdentifier(spec.imported, { name: 'lazy' })) {
                  state.reactLazyBindings.add(name);
                }
              });
            },
          });

          programPath.traverse({
            VariableDeclarator(path: NodePath<VariableDeclarator>) {
              const { id, init } = path.node;

              // const a = React.lazy
              if (types.isIdentifier(id)) {
                if (!types.isMemberExpression(init)) return;

                if (!types.isIdentifier(init.object)) return;

                if (!types.isIdentifier(init.property, { name: 'lazy' })) return;

                if (!state.reactNamespaces.has(init.object.name)) return;

                state.reactLazyBindings.add(id.name);
              }

              // const { lazy: a } = React;
              if (types.isObjectPattern(id) && types.isIdentifier(init)) {
                const { properties } = id;

                if (!state.reactNamespaces.has(init.name)) return;

                properties.forEach((prop) => {
                  if (!types.isObjectProperty(prop)) return;

                  if (!types.isIdentifier(prop.key, { name: 'lazy' })) return;

                  if (!types.isIdentifier(prop.value)) return;

                  state.reactLazyBindings.add(prop.value.name);
                });
              }
            },
          });
        },

        exit(programPath: NodePath<Program>, state: State) {
          let needInject = false;

          const customLazyId = programPath.scope.generateUidIdentifier('customLazy');

          const { filename = '' } = state;

          if (new RegExp(customLazySource).test(normalFilename(filename))) return;

          programPath.traverse({
            CallExpression(path: NodePath<CallExpression>) {
              const { callee } = path.node;

              if (types.isMemberExpression(callee)) {
                if (!types.isIdentifier(callee.object)) return;

                if (!state.reactNamespaces.has(callee.object.name)) return;

                if (!types.isIdentifier(callee.property, { name: 'lazy' })) return;

                path.node.callee = customLazyId;

                needInject = true;
              }

              if (types.isIdentifier(callee) && state.reactLazyBindings.has(callee.name)) {
                path.node.callee = customLazyId;
                needInject = true;
              }
            },
          });

          const hasImport = programPath.node.body.some((node) => {
            return types.isImportDeclaration(node) && node.source.value === customLazySource;
          });

          if (hasImport || !needInject) return;

          const specifier = types.importSpecifier(customLazyId, types.identifier('lazy'));

          const nodes = types.importDeclaration([specifier], types.stringLiteral(customLazySource));

          programPath.unshiftContainer('body', nodes);
        },
      },
    },
  };
};
实现插件在webpack压缩前进行代码转换
  • 用于调用transform-webpack-runtime
javascript 复制代码
import webpack from 'webpack';
import { CompilationAssets } from './types';

export interface TransformOpt {
  assetName: string;
  source: string | Buffer;
  assets: CompilationAssets;
}

export interface PluginOpt {
  test?: RegExp;
  transform: (opt: TransformOpt) => string;
}

export const PLUGIN_NAME = 'TransformBeforeCompression';

export class TransformBeforeCompressionPlugin {
  private readonly opt: PluginOpt;

  constructor(opt: PluginOpt) {
    this.opt = opt;
  }

  apply(compiler: webpack.Compiler) {
    compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation: webpack.Compilation) => {
      const stage = webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE; // 压缩前

      compilation.hooks.processAssets.tap({ name: PLUGIN_NAME, stage }, (assets) => {
        const { test, transform } = this.opt;

        const assetNames = Object.keys(assets);

        assetNames.forEach((assetName) => {
          if (!test || !test.test(assetName)) return;

          const source = assets[assetName].source();

          const transformResult = transform({ assetName, source, assets } as TransformOpt);

          compilation.updateAsset(assetName, new webpack.sources.RawSource(transformResult));
        });
      });
    });
  }
}
转换webpack产物runtime
  • 转换产物runtime
javascript 复制代码
import { NodePath, template } from '@babel/core';
import generator from '@babel/generator';
import * as parser from '@babel/parser';
import traverse, { Node } from '@babel/traverse';
import * as types from '@babel/types';
import type {
  AssignmentExpression,
  ObjectMethod,
  ObjectProperty,
  SpreadElement,
  VariableDeclarator,
} from '@babel/types';
import type { CompilationAssets, AsyncPackOpts } from './types';

interface Opts extends AsyncPackOpts {
  assets: CompilationAssets;
}

const webpackLoadDynamicModuleTemplateDep = `
  var loadedDynamicModules = {};
  var loadDynamicModule = function (dynamicModulePath) {
    var loadDynamicModuleFn = loadDynamicModuleFnMap[dynamicModulePath];
    return loadDynamicModuleFn ? loadDynamicModuleFn() : Promise.reject();
  };
  var promiseRetry = function (apply,retries = 6,delay = 500) {
    return apply().catch(function (error) {
      if (retries <= 0) return Promise.reject(error);
      return new Promise(function (resolve) {
          setTimeout(resolve, delay);
       })
       .then(function () {
          return promiseRetry(apply, retries - 1, delay)
       });
    })
  }
`;

const webpackLoadDynamicModuleTemplate = `
  __webpack_require__.l = function (dynamicModulePath, done, key, chunkId) {
  if (inProgress[dynamicModulePath]) {
    inProgress[dynamicModulePath].push(done);
    return;
  }

  const target = { src: dynamicModulePath };

  if (loadedDynamicModules[dynamicModulePath]) return done({ type: 'loaded', target });

  promiseRetry(function () {
    return loadDynamicModule(dynamicModulePath);
  }).then(function () {
    return done({ type: 'loaded', target });
  }).catch(function () {
    return done({ type: 'error', target });
  });
};
`;

const replaceWebpackLoadScriptFn = (
  assignmentExpressionNodePath: NodePath<AssignmentExpression>,
  opts: Opts
) => {
  const { left, right } = assignmentExpressionNodePath.node || {};

  if (!types.isMemberExpression(left)) return;

  if (!types.isFunctionExpression(right)) return;

  if (!types.isIdentifier(left.object, { name: '__webpack_require__' })) return;

  if (!types.isIdentifier(left.property, { name: 'l' })) return;

  const isProcessed = right.params.some((item) => {
    return types.isIdentifier(item, { name: 'dynamicModulePath' });
  });

  if (isProcessed) return;

  const { assets, dynamicPackageName } = opts;

  const dynamicJsAssets = Object.keys(assets).filter((assetName) => {
    return new RegExp(`^${dynamicPackageName}/.*\\.js$`).test(assetName);
  });

  const dynamicWXssAssets = Object.keys(assets).filter((assetName) => {
    return new RegExp(`^${dynamicPackageName}/.*\\.wxss$`).test(assetName);
  });

  const loadDynamicModuleFnMapCode = (() => {
    const dynamicAssetsRequireTempCode = dynamicJsAssets.map((dynamicJsAsset) => {
      return `'/${dynamicJsAsset}':function (){ return require.async('~/${dynamicJsAsset}'); }`;
    });

    return `var loadDynamicModuleFnMap = {${dynamicAssetsRequireTempCode.join(',')}}`;
  })();

  const hasStyleDynamicAssetsListCode = (() => {
    const hasStyleDynamicAssetsList = dynamicJsAssets.filter((dynamicJsAsset) => {
      const matchWXssAssets = dynamicJsAsset.replace(/\.js$/, '.wxss');
      return dynamicWXssAssets.includes(matchWXssAssets);
    });
    return `var hasStyleDynamicModuleList = [${hasStyleDynamicAssetsList
      .map((item) => `'/${item}'`)
      .join(',')}]`;
  })();

  const templateCodeAst = template.ast(webpackLoadDynamicModuleTemplate);

  const templateCodeDepAst = template.ast(webpackLoadDynamicModuleTemplateDep);

  const loadDynamicModuleFnMapAst = template.ast(loadDynamicModuleFnMapCode);

  const hasStyleDynamicAssetsListAst = template.ast(hasStyleDynamicAssetsListCode);

  assignmentExpressionNodePath.replaceWith(templateCodeAst as Node);

  assignmentExpressionNodePath.insertBefore(hasStyleDynamicAssetsListAst);

  assignmentExpressionNodePath.insertBefore(loadDynamicModuleFnMapAst);

  assignmentExpressionNodePath.insertBefore(templateCodeDepAst);
};

const webpackLoadDynamicStylesheetTemplate = `
  __webpack_require__.f.miniCss = function (dynamicStylesheetChunkId, promises) {
    var cssChunks = CSS_CHUNKS;
    if(installedCssChunks[dynamicStylesheetChunkId] !== 0 && cssChunks[dynamicStylesheetChunkId]){
      promises.push(loadStylesheet())
    }
  }
`;

const replaceWebpackLoadDynamicModuleStylesheetFn = (
  assignmentExpressionNodePath: NodePath<AssignmentExpression>
) => {
  const { left, right } = assignmentExpressionNodePath.node || {};

  if (!types.isMemberExpression(left)) return;

  if (!types.isFunctionExpression(right)) return;

  if (!types.isMemberExpression(left.object)) return;

  if (!types.isIdentifier(left.object.object, { name: '__webpack_require__' })) return;

  if (!types.isIdentifier(left.object.property, { name: 'f' })) return;

  if (!types.isIdentifier(left.property, { name: 'miniCss' })) return;

  const isProcessed = right.params.some((item) => {
    return types.isIdentifier(item, { name: 'dynamicStylesheetChunkId' });
  });

  if (isProcessed) return;

  const cssChunksValueAst: Array<ObjectMethod | ObjectProperty | SpreadElement> = [];

  assignmentExpressionNodePath.traverse({
    VariableDeclarator: (nodePath: NodePath<VariableDeclarator>) => {
      const { id, init } = nodePath.node || {};
      if (!types.isIdentifier(id, { name: 'cssChunks' })) return;
      if (!types.isObjectExpression(init)) return;
      cssChunksValueAst.push(...init.properties);
    },
  });

  const CSS_CHUNKS = types.objectExpression(cssChunksValueAst);

  const templateCodeAst = template.statement(webpackLoadDynamicStylesheetTemplate)({ CSS_CHUNKS });

  assignmentExpressionNodePath.replaceWith(templateCodeAst as Node);
};

const webpackLoadStylesheetTemplate = `
loadStylesheet = function () {
  const { SingletonPromise } = require('~/singleton-promise.js');
  return SingletonPromise.wait();
}
`;

const replaceLoadStylesheetFn = (nodePath: NodePath<VariableDeclarator>) => {
  const { id, init } = nodePath.node || {};
  if (!types.isIdentifier(id, { name: 'loadStylesheet' })) return;
  if (!types.isFunctionExpression(init)) return;
  const isProcessed = !init.params.length;
  if (isProcessed) return;
  const templateCodeAst = template.ast(webpackLoadStylesheetTemplate);
  nodePath.replaceWith(templateCodeAst as Node);
};

const removeCreateStylesheetFn = (nodePath: NodePath<VariableDeclarator>) => {
  const { id } = nodePath.node || {};
  if (!types.isIdentifier(id, { name: 'createStylesheet' })) return;
  nodePath.remove();
};

const removeFindStylesheetFn = (nodePath: NodePath<VariableDeclarator>) => {
  const { id } = nodePath.node || {};
  if (!types.isIdentifier(id, { name: 'findStylesheet' })) return;
  nodePath.remove();
};

export const transformWebpackRuntime = (code: string, opts: Opts) => {
  const ast = parser.parse(code); // 将代码解析为 AST
  traverse(ast, {
    AssignmentExpression: (nodePath: NodePath<AssignmentExpression>) => {
      replaceWebpackLoadScriptFn(nodePath, opts);
      replaceWebpackLoadDynamicModuleStylesheetFn(nodePath);
    },
    VariableDeclarator(nodePath: NodePath<VariableDeclarator>) {
      replaceLoadStylesheetFn(nodePath);
      removeCreateStylesheetFn(nodePath);
      removeFindStylesheetFn(nodePath);
    },
  });
  return generator(ast).code;
};
类型定义
javascript 复制代码
import { Source } from 'webpack-sources';

export interface AsyncPackOpts {
  dynamicPackageName: string;
}

export type CompilationAssets = Record<string, Source>;

四、总结

通过剔除 ES5 兼容代码,我们成功优化了小程序主包体积;但在样式分包的异步加载能力上,仍然无法做到真正完善。

核心问题始终在于:开发者无法准确获知"样式已真正生效"的时机。缺少一个明确、可靠的生命周期或回调信号,使得我们的方案在机制层面始终存在不确定性。

在此也想向微信官方表达一个小小的期待:未来是否有可能提供一个明确的"样式已生效"钩子或事件?哪怕只是一个样式注入完成的回调机制,也足以让这类能力变得真正可控、可维护。

或者进一步,是否可以提供官方支持的异步样式加载 API,使样式的加载、注入与生效具备可观测、可编排的生命周期。

相关推荐
寅时码1 天前
React 正在演变为一场不可逆的赛博瘟疫:AI 投毒、编译器迷信与装死的官方
前端·react.js·设计模式
学高数就犯困1 天前
React:一个例子讲清楚 useEffect 和 useReducer
react.js
Wect1 天前
JSX & ReactElement 核心解析
前端·react.js·面试
codingWhat2 天前
手撸一个「能打」的 React Table 组件
前端·javascript·react.js
程序员ys2 天前
前端权限控制设计
前端·vue.js·react.js
不会敲代码12 天前
从零开始用 TypeScript + React 打造类型安全的 Todo 应用
前端·react.js·typescript
小时前端3 天前
React性能优化的完整方法论,附赠大厂面试通关技巧
前端·react.js
阿慧勇闯大前端3 天前
在AI时代,再去了解react19新特性还有用吗? 最近总有朋友问我:“现在AI写代码这么厉害了,我写个需求丢给ChatGPT,几秒钟就生成一堆组件,还学新特
前端·react.js
喵爱吃鱼3 天前
关于我明明用了ref还是陷入React闭包陷阱
前端·react.js