小程序gulp构建优化之道

4,107 阅读4分钟

1. 背景

众所周知,小程序主包的大小限制在 2M 以内,对于日益庞大的工程项目,开发者们无所不用其极地进行优化。原生小程序基本采用gulp进行构建,能否在gulp构建流程中做文章,达到“优化”的目的。

首先明确gulp构建优化的目的是压缩小程序包体积,提升用户开发体验。作者在熟悉gulp开发后,推翻了原有的构建流程,重新设计,本着精益求精的目的进行优化,取得如下三点进展:

  1. 小程序包体积减小:优化前包体积 2212KB,主包体积 1668.9KB;优化后包体积 2012KB,主包体积 1470.7KB;包体积减小 9.04%,主包体积减小 11.9%
  2. 构建时间缩短:优化前 dev 模式构建时间 58 秒,build 模式构建时间 62 秒;优化后 dev 模式构建时间 27 秒,build 模式构建时间 32 秒;dev 模式构建时间缩短 53.4%,build 模式构建时间缩短 48.4%
  3. 用户开发体验提升:优化前需要点击开发者工具中的菜单栏:工具 --> 构建 npm,生成小程序专用的npm包才能成功编译代码,同时常常会因为各种路径引入问题导致不得不重新编辑或者重新构建 npm。优化后再无此类烦恼,gulp 构建时自动分析生成小程序专用的npm包,大大提高了开发效率。

2. gulp 工作流架构

gulp构建优化

对小程序来说,除了app.js作为程序入口之外,每个 page 页面都可以作为一个页面入口,更倾向是固定路径模式的多页应用。gulp 构建的目的是将开发路径的代码翻译转到小程序专用路径,该路径下的代码能够被微信开发者工具读取、编译、构建。通过 gulp 工具可实现:

  • .ts 文件编译为 .js.less 文件编译为 .wxss ,以支持 TypeScriptLess 语法。
  • 支持 sourcemaps 方便错误调试与定位。
  • 压缩图片和各类文件,减少小程序代码包大小。
  • 分析代码,依赖自动提取,支持提取普通 npm 包与小程序专用 npm 包。
  • 其余文件将直接拷贝至目标路径。
  • 添加watch,方便开发者调试。

2.1 拆分 task

采用原生框架开发的小程序主要有.js.json.wxml.wxss四种文件构成,为了提升开发效率,通常会引入.ts.less文件。由于每种文件的编译构建方法不尽相同,因此需要为不同类型的文件创建不同的task

const src = './src';

// 文件匹配路径
const globs = {
  ts: [`${src}/**/*.ts`, './typings/index.d.ts'], // 匹配 ts 文件
  js: `${src}/**/*.js`, // 匹配 js 文件
  json: `${src}/**/*.json`, // 匹配 json 文件
  less: `${src}/**/*.less`, // 匹配 less 文件
  wxss: `${src}/**/*.wxss`, // 匹配 wxss 文件
  image: `${src}/**/*.{png,jpg,jpeg,gif,svg}`, // 匹配 image 文件
  wxml: `${src}/**/*.wxml`, // 匹配 wxml 文件
  other:[`${src}/**`,`!${globs.ts[0]}`,...] // 除上述文件外的其它文件
};

// 创建不同的task
const ts = cb => {}; // 编译ts文件
const js = cb => {}; // 编译js文件
...
const copy = cb => {}; // 除上述文件外的其它文件复制到目标文件夹

2.2 dev 和 build 模式区分

如约定俗成一般,我们通常在dev模式下进行开发调试,在build模式下进行发布,这两种的 gulp 构建方案是需要区分的。如代码所示:dev模式下需要添加watch来监听文件的变化,及时地重新进行构建,同时需要添加sourcemap便于调试;而build模式下更需要的是对文件进行压缩以减少包体积。

// 默认dev模式配置
let config = {
  sourcemap: true, // 是否开启sourcemap
  compress: false, // 是否压缩wxml、json、less等各种文件
  ...
};
// 修改成build模式配置
const setBuildConfig = cb => {
  config = {...}; cb();
};
// 并发执行所有文件构建task
const _build = gulp.parallel(copy, ts, js, json, less, wxss, image, wxml);
// build模式构建
const build = gulp.series(
  setBuildConfig, // 设置成build模式配置
  gulp.parallel(clear, clearCache), // 清除目标目录文件和缓存
  _build, // 并发执行所有文件构建task
  ...
);
// dev模式构建
const build = gulp.series(
  clear, // 清除目标目录文件
  _build, // 并发执行所有文件构建task
  ...
  watch, // 添加监听
);

3. 优化之道

前面的篇幅讲述了 gulpfile 文件整体架构设计,下面讲述每个task的具体配置,以及优化之道。

3.1 npm 构建优化

3.1.1 官方通用方案的不足之处

① 安装 npm 包

安装 npm 包的方法有以下两种:

  1. 手动档: 在小程序 package.json 所在的目录中执行命令npm install安装 npm 包,此处要求参与构建 npm 的 package.json 需要在 project.config.js 定义的 miniprogramRoot 之内。
  2. 自动档: 通过gulptask进行处理,创建一个task,将根目录的package.json文件拷贝到小程序所在目录(本文命名为miniprogram),通过exec执行cd miniprogram && npm install --no-package-lock --production命令安装 npm 包。代码如下所示:
gulp.task('module:install', (cb) => {
	const destPath = './miniprogram/package.json';
	const sourcePath = './package.json';
	try {
		// ...省略代码,判断是否有 package.json 的变动,无变动则返回
		// 复制文件
		fs.copyFileSync(path.resolve(sourcePath), path.resolve(destPath));
		// 执行命令
		exec(
			'cd miniprogram && npm install --no-package-lock --production',
			(err) => {
				if (err) process.exit(1);
				cb();
			}
		);
	} catch (error) {
		// ...
	}
});

② 构建 npm 包

众所周知,我们使用npm install构建的 npm 包都会在node_modules目录,但是小程序规定node_modules目录不会参与编译、上传和打包中,所以小程序想要使用 npm 包必须走一遍 「构建 npm」 的过程,即点击开发者工具中的菜单栏:工具 --> 构建 npm。这时,node_modules 的同级目录下会生成一个 miniprogram_npm 目录,里面会存放构建打包后的 npm 包,也就是小程序真正使用的 npm 包。 构建npm前后目录结构变化

如上图所示,小程序真正使用的 npm 包和node_modules目录下的 npm 包的结构是有差异的,这个差异就是点击构建 npm这个操作执行的打包过程,分为两种:小程序 npm 包会直接拷贝构建文件生成目录下的所有文件到 miniprogram_npm 中;其他 npm 包则会从入口 js 文件开始走一遍依赖分析和打包过程(类似 webpack)。

显而易见,官方提供的 npm 构建方案暴露了以下两点问题:

  1. 耗时长:通过gulp打包构建小程序时,第一个task就是在miniprogram目录安装 npm 包,当依赖的 npm 包数量够多时,会消耗大量的时间。
  2. 开发流程繁琐:通过小程序开发者工具调试代码时,当引入新的 npm 包或者原有的 npm 包更新时,都要重新点击菜单栏:工具 --> 构建 npm,对开发者十分不友好。

因此我们希望能借助gulp工作流,在小程序构建时分析每个文件的依赖关系,将需要的 npm 包拷贝至 miniprogram_npm目录。一步到位,直接省略了上述安装npm包构建npm包两个步骤。

3.1.2 gulp-mp-npm 实现依赖分析与提取

所幸,社区无所不能,有作者开发了一个用以小程序提取 npm 依赖包的 gulp 插件 gulp-mp-npm ,有以下特点:

  • 依赖分析,仅会提取使用到的依赖与组件。
  • 支持提取普通 npm 包与小程序专用 npm 包。
  • 不会对依赖进行编译与打包(交给微信开发者工具或者其他 gulp 插件完成)。
  • 兼容官方方案及原理,同时支持自定义 npm 输出文件夹。

前面提到过构建npm包时,针对小程序 npm 包会直接拷贝构建文件生成目录下的所有文件到 miniprogram_npm 中;对于其它普通 npm 包则会从入口 js 文件开始走一遍依赖分析和打包过程(类似 webpack),打包生成的代码在同级目录下会生成 source map 文件,方便进行逆向调试。而gulp-mp-npm插件仅仅通过依赖分析,提取使用到的依赖和组件,将其复制到至 miniprogram_npm目录下对应的 npm 包文件夹内,并不会对依赖进行编译与打包,这一步交由微信开发者工具完成。

gulp-mp-npm构建原理

gulp-mp-npm的构建原理如上图所示,想要深入了解可以参考: 小程序 npm 依赖包 gulp 插件设计

插件的使用可以根据项目需求,在 gulpfile.js 中进行如下配置:

const gulp = require('gulp');
const mpNpm = require('gulp-mp-npm')

const js = () => gulp.src('src/**/*.js')
    .pipe(mpNpm()) // 分析提取 js 中用到的依赖
    .pipe(gulp.dest('dist'));

const less = () => gulp.src('src/**/*.less')
    .pipe(gulpLess()) // 编译less
    .pipe(rename({ extname: '.wxss' }))
    .pipe(mpNpm()) // 分析提取 less 中用到的依赖
    .pipe(gulp.dest('dist'));
...

通常在.ts.js.json.less.wxss 等文件中都会有可能使用到npm 依赖。因此,插件 gulp-mp-npm 在上述 5 个 tasks 中都需执行。分析 .json 文件是因为插件会尝试读取小程序页面配置中 usingComponents 字段,提取使用的 npm 小程序组件。

3.1.3 数据对比

npm构建优化数据对比

3.2 watch 增量编译

众所周知,gulptask都是单次执行的。某个文件变化后 gulp.watch 会重新执行整个 task 来完成构建,这样会导致未变化的文件重复构建,效能较低。可以采取以下两个措施进行效率优化:

3.2.1 拆分task,合理创建watch

前面提到,根据文件类型不同,划分成copy, ts, js, json, less, wxss, image, wxml八种不同的task,分别为每种task创建watch。假设修改index.ts文件,只会重新执行ts这个task,其它task不受影响。代码如下所示:

const watchOptions = { events: ['add', 'change', `unlink`] };
const watch = () => {
  gulp.watch(globs.copy, watchOptions, copy);
  gulp.watch(globs.ts, watchOptions, ts);
  gulp.watch(globs.js, watchOptions, js);
  gulp.watch(globs.json, watchOptions, json);
  gulp.watch(globs.less, watchOptions, less);
  gulp.watch(globs.wxss, watchOptions, wxss);
  gulp.watch(globs.image, watchOptions, image);
  gulp.watch(globs.wxml, watchOptions, wxml);
};

3.2.2 gulp.lastRun实现增量编译

在任何构建工具中增量编译都是一个必不可少的优化方式。即在编译过程中仅编译那些修改过的文件,可以减少许多不必要的资源消耗,也能减少编译时长gulp 4发布之前社区早早给出了一系列的解决方案,gulp-changedgulp-cachedgulp-remembergulp-newer 等等。gulp 4发布自带了增量更新的方案gulp.lastRun()

gulp.lastRun 方法返回当前运行进程中成功完成 task 的最后一次时间戳。将其作为 gulp.src 方法的参数 since 传入,将每个文件的mtime(文件内容最后被修改的时间)与since传入的值进行比较,可实现跳过自上次成功完成任务以来没有更改的文件,实现增量编译,加快执行时间。使用方法如下所示:

/* 以 ts / less 为例, js / json / wxss / copy / image 同理 */
const ts = () => gulp.src(
    'src/**/*.ts',
    { since: gulp.lastRun(ts) }
)...

const less = () => gulp.src(
    'src/**/*.less',
    { since: gulp.lastRun(less) }
)...

然而,该方法仅通过判别文件内容的修改来实现增量编译,那对于未修改的文件的移动又该如何?比如将某个 js 文件复制到另外一个目录中,该文件的mtime(文件内容最后被修改的时间)是不会发生变化的,这时,ctime(写入文件、更改所有者、权限或链接设置的最后修改时间)派上用场。代码改造如下,封装了since函数,当文件内容未变化,但文件路径发生改变时,返回时间戳为 0,将文件的mtime(文件内容最后被修改的时间)与since传入的值(此时为 0)对比,就可以增量编译该文件。

const since = task => file =>
  gulp.lastRun(task) > file.stat.ctime ? gulp.lastRun(task) : 0;

const ts = () => gulp.src(
    'src/**/*.ts',
    { since: since(ts) }
)

3.2.3 数据对比

增量编译数据对比

3.3 开启 sourcemap

gulp-sourcemaps这是一款用来生成映射文件的一个插件,SourceMap 文件记录了一个存储源代码与编译代码对应位置映射的信息文件。我们在调试时都是没办法像调试源码般轻松,这就需要 SourceMap 帮助我们在控制台中转换成源码,从而进行 debuggulp-sourcemaps主要用于解决代码混淆、typescriptless语言转换编译成jscss语言的问题。

使用 gulp-sourcemaps 插件,可为参与编译的 .ts.less 文件开启 Source Map :

const sourcemaps = require('gulp-sourcemaps');

/* 以 ts 为例, less 同理 */
const ts = () => gulp.src('src/**/*.ts')
    .pipe(sourcemaps.init())
    .pipe(tsProject())  // 编译ts
    .pipe(mpNpm())      // 分析提取 ts 中用到的依赖
    .pipe(sourcemaps.write('.'))   // 以 .map 文件形式导出至同级目录
    .pipe(gulp.dest('dist'));

注意:Source Map 文件不计入代码包大小计算,即编译上传的代码是不计算这部分体积的。开发版代码包中由于包含了 .map 文件,实际代码包大小会比体验版和正式版大。

3.4 编译 ts

编译 ts 的目标是将.ts文件转换编译成.js文件输出到目标文件夹。主要分为以下几个步骤:

  1. 创建一个流,用于从文件系统读取 Vinyl 对象。设置since属性,利用gulp.lastRun进行增量编译。
  2. 引入gulp-ts-alias插件处理路径别名问题,该插件根据tsconfig.json文件中paths属性的的配置内容,将别名替换为原路径。比如paths有一条配置如下"@/*": ["src/supermarket/*"],那么对于任意.ts文件中的import A from '@/components'都会替换成import A from 'src/supermarket/components'
  3. dev 模式下开启 sourcemap, 引入gulp-if来进行条件判断,当config.sourcemap值为 true 时,才执行sourcemaps.init(),否则流直接通向下一个 pipe。
  4. 引入gulp-typescript插件将.ts文件转换编译成.js文件。首先,在ts这个task外面使用gulpTs.createProject创建一个 ts 编译任务,之所以在外面创建的原因是当运行watch时,有.ts文件进行修改,需要重新构建ts这个task,在外面创建可以节省一半的时间。在默认配置下,有 ts 的编译错误会输出到控制台,并且编译器会因为编译错误而使这个构建任务崩溃,中断运行。所以需要在tsProject()后面添加一个错误处理程序来捕获错误。
  5. 引入gulp-mp-npm插件提取.ts文件引入的 npm 包。
  6. sourcemap 写入到目标目录。
  7. build 模式下引入gulp-uglify.js文件进行压缩。
  8. 将编译完成的.js文件输出到对应目录。
const gulpTs = require('gulp-typescript');
const tsAlias = require('gulp-ts-alias');
const gulpIf = require('gulp-if');
const uglifyjs = require('uglify-js');
const composer = require('gulp-uglify/composer');
const minify = composer(uglifyjs, console);
const pump = require('pump');

const tsProject = gulpTs.createProject(resolve('tsconfig.json'));   // 4. 外部创建一个ts编译任务

const ts = cb => {
  const tsResult = gulp
    .src(globs.ts, { ...srcOptions, since: since(ts) }) // 1. 增量编译
    .pipe(tsAlias({ configuration: tsProject.config })) // 2. 将路径别名替换为原路径
    .pipe(gulpIf(config.sourcemap, sourcemaps.init()))  // 3. dev模式开启sourcemap
    .pipe(tsProject())     // 4. 编译ts
    .on('error', () => {    // 4. 捕获错误,不添加会因为ts编译错误导致任务中断
      /** 忽略编译器错误**/
    });

  pump(
    [
      tsResult.js,
      mpNpm(mpNpmOptions),     // 5. 分析依赖
      gulpIf(config.sourcemap.ts, sourcemaps.write('.')), // 6. 写入sourcemap文件到对应的目录
      gulpIf(config.compress, minify({})),     // 7. build模式压缩js
      gulp.dest(dist),   // 8. 输出文件到目标目录
    ],
    cb,
  );
};

眼尖的读者应该注意到了task的后半段改用pump将流链接在一起。Pump是一个小型节点模块,可将流连接在一起并在其中任何一个关闭时将其全部销毁。通俗来讲,就是可以使我们更容易定位错误的发生点,常用来替代pipe,非常适合于修复gulp-uglify报错。如下图所示,使用pump后的报错能准确定位到具体的位置,而pipe抛出整个调用栈,让人无从下手。

pipe和pump的区别

3.5 编译 less

编译 less 的目标是将.less文件转换编译成.wxss文件输出到目标文件夹。主要分为以下几个步骤:

const gulpLess = require('gulp-less');
const weappAlias = require('gulp-wechat-weapp-src-alisa');
const prettyData = require('gulp-pretty-data');

const less = cb => {
  pump(
    [
      gulp.src(globs.less, { ...srcOptions, since: since(less) }), // 1. 增量编译
      gulpIf(config.sourcemap.less, sourcemaps.init()), // 2. 开启sourcemap
      weappAlias(weappAliasConfig), // 3. 将路径别名替换成原路径
      /** 此处省略 步骤A */
      gulpLess(), // 4. 编译less转换成css
      /** 此处省略 步骤B */
      rename({ extname: '.wxss' }), // 5. 文件.less后缀修改为.wxss
      mpNpm(mpNpmOptions), // 6. 依赖分析
      gulpIf(config.sourcemap.less, sourcemaps.write('.')), // 7. 写入sourcemap
      gulpIf(
        config.compress,  // 8. build模式下压缩wxss
        prettyData({
          type: 'minify',
          extensions: {
            wxss: 'css',
          },
        }),
      ),
      gulp.dest(dist), // 9. 输出文件到目标目录
    ],
    cb,
  );
};

如上所示,使用gulp-less插件将LESS代码编译成CSS代码,他的编译原理如下所示:

  • index.less文件引入variable.less变量文件,会将variable.less的内容复制到index.less文件中。
  • index.less文件引入style.less纯样式文件,会将style.less全部的内容复制到index.less文件中。
  • .less 文件编译,将使用到的变量替换成对应的值;样式嵌套平铺。
  • 清空style.lessvariable.less文件的内容。
less编译原理

设想一下,假设有 100 个文件引入style.less纯样式文件,那么style.less的内容便要复制一百份。对于复杂的工程项目,less 文件的依赖是十分复杂的,编译的结果是造成许多冗余的样式代码,与我们“精益求精”(尽可能地压缩小程序包)的理念背道而驰。基于此,我们可以在gulp-less插件编译前,将@import **相关的代码注释掉,gulp-less插件编译后,恢复注释的内容,并将引入路径的.less后缀修改为.wxss后缀,代码如下所示:

/** 前面代码片段省略的步骤A代码 */
tap(file => {
  const content = file.contents.toString(); // 将文件内容toString()
  const regNotes = /\/\*(\s|.)*?\*\//g;   // 匹配 /* */ 注释
  const removeComment = content.replace(regNotes, ''); // 删除注释内容
  const reg = /@import\s+['|"](.+)['|"];/g; // 匹配 @import ** 路径引入

  const str = removeComment.replace(reg, ($1, $2) => {
    const hasFilter = cssFilterFiles.filter(item => $2.indexOf(item) > -1);  // 过滤掉变量文件引入
    let path = hasFilter <= 0 ? `/** less: ${$1} **/` : $1;  // 将纯样式文件的引入 添加注释 /** less: ${$1} **/
    return path;
  });
  file.contents = Buffer.from(str, 'utf8'); // string恢复成文件流
});

这里需要注意的是,如果注释掉变量文件,比如上述提到的variable.less文件,那么引入的变量例如@color-primary就会取不到值,导致编译出错。因此处理时,可以写一个数组cssFilterFiles过滤掉变量文件,然后再注释所有的样式文件,比如上述提到的style.less纯样式文件。

执行步骤 A 代码后,紧接着使用gulp-lessLESS代码编译成CSS代码,之后在执行步骤 B 代码,如下所示,将路径注释还原,并将引入路径的.less后缀修改为小程序能识别的.wxss后缀。

/** 前面代码片段省略的步骤B代码 */
tap(file => {
  const content = file.contents.toString();
  const regNotes = /\/\*\* less: @import\s+['|"](.+)['|"]; \*\*\//g;
  const reg = /@import\s+['|"](.+)['|"];/g;
  const str = content.replace(regNotes, ($1, $2) => {
    let less = '';
    $1.replace(reg, $3 => (less = $3));
    return less.replace(/\.less/g, '.wxss');
  });
  file.contents = Buffer.from(str, 'utf8');
});

优化后的效果如下图所示:

less编译优化

3.6 图片压缩

小程序主包目前只支持最多 2M 的大小,而图片通常是占用空间最多的资源。因为在项目中对图片大小进行压缩很有必要的。

使用 gulp-image 插件,可压缩图片大小且能保证画质:

const gulpImage = require('gulp-image');
const cache = require('gulp-cache');

const image = () =>
  gulp
    .src(globs.image, { ...srcOptions, since: since(image) })
    .pipe(cache(gulpImage())) // 缓存压缩后的图片
    .pipe(gulp.dest(dist));

如下图所示,是项目所有图片压缩的结果,平均能节省 50%左右的体积。

image图片压缩

3.7 其它文件编译

.js.json.wxml.wxss编译的主要原理是将源文件拷贝至目标文件目录。代码如下所示:

const prettyData = require('gulp-pretty-data');

const wxml = () =>
  gulp
    .src(globs.wxml, { ...srcOptions, since: since(wxml) }) // 1. 增量编译
    .pipe(
      gulpIf(
        config.compress,    // 2. build模式下压缩文件
        prettyData({
          type: 'minify',
          extensions: {
            wxml: 'xml',
          },
        }),
      ),
    )
    .pipe(gulp.dest(dist)); // 3. 输出到对应目录

// .json、.wxml、.wxss代码参考如上

在微信开发者工具中,点击右上角 详情 --> 本地设置 --> 选中上传代码时自动压缩样式上传代码时自动压缩混淆,那么在小程序构建打包时会压缩.wxss文件和.js文件,所以在实际的 gulp 构建工作流中,可以不对.ts.js.less.wxss进行压缩处理,但对于.wxml.json文件,可以使用gulp-pretty-data插件对文件进行压缩。

4. 最后

小程序开发最大的限制是主包大小必须控制在 2M 以内,通过本文的优化方法,将原本的小程序主包体积减小 11.9%,想要进一步压缩体积,可以考虑tree-sharking,分析代码依赖关系,剔除未引用的代码。但现有的gulp工作流架构模式是很难完美地实现这个功能的,tree-sharkingrollupwebpack等构建方案的特长,这也是为啥有一部分成熟的小程序框架如tarompvue会选择webpack构建。其实,各有所长,全看取舍。

附上代码链接: codesandbox.io/s/miniprogr…