详解微信小程序工程化探索之webpack实战

作者:望舒

时间:2026-10-23

浏览:499

本文深入探讨微信小程序的工程化实践,重点讲解如何利用Webpack替代Gulp进行构建。内容涵盖基础配置、动态入口生成插件编写、样式预编译与ESLint集成、多环境切换以及代码分包优化。通过实战案例,解决原生开发中的痛点,提升开发效率与代码质量。

文章目录

前言

前言

微信小程序凭借便捷的使用体验迅速普及,吸引了海量用户。面对激增的市场需求,互联网企业纷纷涉足此领域,掌握小程序开发已成为前端工程师的核心技能。然而,原生小程序开发存在一些长期被开发者诟病的痛点,主要体现在以下几个方面:

  • 早期缺乏便捷的npm包管理机制(尽管目前支持npm,但操作流程较为繁琐)。
  • 不支持使用预编译语言处理样式文件。
  • 无法通过脚本命令自动切换开发环境,必须手动修改配置文件(通常项目需区分开发与生产环境)。
  • 难以将代码规范检查工具(如EsLint、StyleLint)集成到项目中。

面对上述问题,我开始思考如何将现代工程化技术与小程序开发相结合。在查阅社区资料初期,许多前辈基于Gulp进行了大量实践,对于这种多页应用类型,Gulp的流式处理似乎更为简便。但在实际尝试后,我对Gulp方案并不满意,因此转而探索Webpack的工程化实践。我认为,尽管Webpack的实现难度高于Gulp,但从长远发展来看,其带来的效果将更为显著。

基础构建实践

实践

暂时不考虑预编译、代码规范等复杂问题,我们的首要目标是如何利用Webpack将源代码文件夹下的文件输出到目标文件夹。接下来,我们将逐步创建这个工程项目:


/* 创建项目 */
$ mkdir wxmp-base
$ cd ./wxmp-base
/* 创建package.json */
$ npm init
/* 安装依赖包 */
$ npm install webpack webpack-cli --dev

安装好依赖后,为项目创建基础目录结构,如图所示:

前言

上图展示了一个最简化的微信小程序结构,仅包含app全局配置文件和一个home页面。无论全局配置还是页面逻辑,我们按文件类型划分:需要加工的JS文件,以及无需加工、直接拷贝的wxml、wxss、json文件。基于此思路,我们开始编写供Webpack执行的配置文件,在项目根目录下创建build目录,并存放webpack.config.js文件。


$ mkdir build
$ cd ./build
$ touch webpack.config.js

/** webpack.config.js */
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');

const ABSOLUTE_PATH = process.cwd();

module.exports = {
 context: path.resolve(ABSOLUTE_PATH, 'src'),
 entry: {
  app: './app.js',
  'pages/home/index': './pages/home/index.js'
 },
 output: {
  filename: '[name].js',
  path: path.resolve(ABSOLUTE_PATH, 'dist')
 },
 module: {
  rules: [
   {
    test: /\.js$/,
    exclude: /node_modules/,
    use: {
     loader: 'babel-loader',
     options: {
      presets: ['@babel/preset-env'],
      plugins: ['@babel/plugin-transform-runtime'],
     },
    },
   }
  ]
 },
 plugins: [
  new CopyPlugin([
   {
    from: '**/*.wxml',
    toType: 'dir',
   },
   {
    from: '**/*.wxss',
    toType: 'dir',
   },
   {
    from: '**/*.json',
    toType: 'dir',
   }
  ])
 ]
};

编写完上述代码后,下面解释其具体作用:

  • 在入口entry对象中,我定义了两个属性,旨在将app.js和home/index.js作为Webpack的构建入口。Webpack会以这些文件为起点创建各自的依赖关系,这样当入口文件引入其他模块时,被引入的文件也能被正确处理。
  • 在module配置中,我使用了babel-loader对JS文件进行ES6转ES5处理,并加入了新语法的处理支持,从而解决了原生小程序开发中需反复引入regenerator-runtime的问题(此步骤需安装@babel/core、@babel/preset-env、@babel/plugin-transform-runtime、@babel/runtime、babel-loader等依赖包)。
  • 使用copy-webpack-plugin处理无需二次加工的文件,该插件可直接将文件复制到目标目录。

了解代码作用后,可在终端运行webpack --config build/webpack.config.js命令。Webpack会将源代码编译至dist文件夹,该文件夹中的内容即可在开发者工具中运行、预览及上传。

策略优化与升级

优化

完成基础Webpack构建策略后,我们实现了app和home页面的转换,但这远远不够。还需解决以下问题:

  • 页面文件增多时如何处理?组件如何管理?
  • 如何实现预期的预编译功能?
  • 如何将代码规范检查集成到工程中?
  • 如何处理环境变量切换?

接下来,我们将针对以上几点升级Webpack策略:

动态页面与组件管理

页面与组件

起初,我的实现方法是编写一个工具函数,利用glob收集pages和components下的JS文件,然后生成入口对象传递给entry。但在实践中发现这种做法存在两个弊端:

  • 当终端中已启动命令时,新增页面或组件不会自动生成新入口,即需重新运行命令。
  • 工具函数写死了匹配pages和components文件夹下的文件,不利于项目扩展。若需分包或更改文件夹命名,则需修改工具函数。

本着程序员应极度懒惰、能交给机器完成绝不动手的信条,我开始研究新的入口生成方案。最终确定编写一个Webpack插件,在构建生命周期中生成入口。代码如下:


/** build/entry-extract-plugin.js */
const fs = require('fs');
const path = require('path');
const chalk = require('chalk');
const replaceExt = require('replace-ext');
const { difference } = require('lodash');
const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin');
const MultiEntryPlugin = require('webpack/lib/MultiEntryPlugin');

class EntryExtractPlugin {
 constructor() {
  this.appContext = null;
  this.pages = [];
  this.entries = [];
 }

 /**
  * 收集app.json文件中注册的pages和subpackages生成一个待处理数组
  */
 getPages() {
  const app = path.resolve(this.appContext, 'app.json');
  const content = fs.readFileSync(app, 'utf8');
  const { pages = [], subpackages = [] } = JSON.parse(content);
  const { length: pagesLength } = pages;
  if (!pagesLength) {
   console.log(chalk.red('ERROR in "app.json": pages字段缺失'));
   process.exit();
  }
  /** 收集分包中的页面 */
  const { length: subPackagesLength } = subpackages;
  if (subPackagesLength) {
   subpackages.forEach((subPackage) => {
    const { root, pages: subPages = [] } = subPackage;
    if (!root) {
     console.log(chalk.red('ERROR in "app.json": 分包配置中root字段缺失'));
     process.exit();
    }
    const { length: subPagesLength } = subPages;
    if (!subPagesLength) {
     console.log(chalk.red(`ERROR in "app.json": 当前分包 "${root}" 中pages字段为空`));
     process.exit();
    }
    subPages.forEach((subPage) => pages.push(`${root}/${subPage}`));
   });
  }
  return pages;
 }

 /**
  * 以页面为起始点递归去寻找所使用的组件
  * @param {String} 当前文件的上下文路径
  * @param {String} 依赖路径
  * @param {Array} 包含全部入口的数组
  */
 addDependencies(context, dependPath, entries) {
  /** 生成绝对路径 */
  const isAbsolute = dependPath[0] === '/';
  let absolutePath = '';
  if (isAbsolute) {
   absolutePath = path.resolve(this.appContext, dependPath.slice(1));
  } else {
   absolutePath = path.resolve(context, dependPath);
  }
  /** 生成以源代码目录为基准的相对路径 */
  const relativePath = path.relative(this.appContext, absolutePath);
  /** 校验该路径是否合法以及是否在已有入口当中 */
  const jsPath = replaceExt(absolutePath, '.js');
  const isQualification = fs.existsSync(jsPath);
  if (!isQualification) {
   console.log(chalk.red(`ERROR: in "${replaceExt(relativePath, '.js')}": 当前文件缺失`));
   process.exit();
  }
  const isExistence = entries.includes((entry) => entry === absolutePath);
  if (!isExistence) {
   entries.push(relativePath);
  }
  /** 获取json文件内容 */
  const jsonPath = replaceExt(absolutePath, '.json');
  const isJsonExistence = fs.existsSync(jsonPath);
  if (!isJsonExistence) {
   console.log(chalk.red(`ERROR: in "${replaceExt(relativePath, '.json')}": 当前文件缺失`));
   process.exit();
  }
  try {
   const content = fs.readFileSync(jsonPath, 'utf8');
   const { usingComponents = {} } = JSON.parse(content);
   const components = Object.values(usingComponents);
   const { length } = components;
   /** 当json文件中有再引用其他组件时执行递归 */
   if (length) {
    const absoluteDir = path.dirname(absolutePath);
    components.forEach((component) => {
     this.addDependencies(absoluteDir, component, entries);
    });
   }
  } catch (e) {
   console.log(chalk.red(`ERROR: in "${replaceExt(relativePath, '.json')}": 当前文件内容为空或书写不正确`));
   process.exit();
  }
 }

 /**
  * 将入口加入到webpack中
  */
 applyEntry(context, entryName, module) {
  if (Array.isArray(module)) {
   return new MultiEntryPlugin(context, module, entryName);
  }
  return new SingleEntryPlugin(context, module, entryName);
 }

 apply(compiler) {
  /** 设置源代码的上下文 */
  const { context } = compiler.options;
  this.appContext = context;

  compiler.hooks.entryOption.tap('EntryExtractPlugin', () => {
   /** 生成入口依赖数组 */
   this.pages = this.getPages();
   this.pages.forEach((page) => void this.addDependencies(context, page, this.entries));
   this.entries.forEach((entry) => {
    this.applyEntry(context, entry, `./${entry}`).apply(compiler);
   });
  });

  compiler.hooks.watchRun.tap('EntryExtractPlugin', () => {
   /** 校验页面入口是否增加 */
   const pages = this.getPages();
   const diffPages = difference(pages, this.pages);
   const { length } = diffPages;
   if (length) {
    this.pages = this.pages.concat(diffPages);
    const entries = [];
    /** 通过新增的入口页面建立依赖 */
    diffPages.forEach((page) => void this.addDependencies(context, page, entries));
    /** 去除与原有依赖的交集 */
    const diffEntries = difference(entries, this.entries);
    diffEntries.forEach((entry) => {
     this.applyEntry(context, entry, `./${entry}`).apply(compiler);
    });
    this.entries = this.entries.concat(diffEntries);
   }
  });
 }
}

module.exports = EntryExtractPlugin;

由于Webpack插件相关知识不在本文讨论范围,仅简要介绍其如何介入工作流程并生成入口(若感兴趣可私信,后续可能整理资料)。该插件实际做了两件事:

  1. 通过compiler的entryOption钩子,将递归生成的入口数组逐项加入entry中。
  2. 通过compiler的watchRun钩子监听重新编译时是否有新页面加入,若有,则以新页面生成依赖数组,再加入entry中。

现在将该插件应用到之前的Webpack策略中,将配置更改为:(记得安装chalk replace-ext依赖)


/** build/webpack.config.js */
const EntryExtractPlugin = require('./entry-extract-plugin');

module.exports = {
 ...
 entry: {
  app: './app.js'
 },
 plugins: [
  ...
  new EntryExtractPlugin()
 ]
}

样式预编译与代码规范

样式预编译与EsLint

样式预编译和EsLint的应用已有许多优秀文章,此处仅展示我们的实践代码:


/** build/webpack.config.js */
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
 ...
 module: {
  rules: [
   ...
   {
    enforce: 'pre',
    test: /\.js$/,
    exclude: /node_modules/,
    loader: 'eslint-loader',
    options: {
     cache: true,
     fix: true,
    },
   },
   {
    test: /\.less$/,
    use: [
     {
      loader: MiniCssExtractPlugin.loader,
     },
     {
      loader: 'css-loader',
     },
     {
      loader: 'less-loader',
     },
    ],
   },
  ]
 },
 plugins: [
  ...
  new MiniCssExtractPlugin({ filename: '[name].wxss' })
 ]
}

修改策略后,可将wxss后缀名文件更改为less后缀名(若使用其他预编译语言,可自行修改loader),然后在JS文件中加入import './index.less'语句,即可看到样式文件正常编译生成。样式文件能正常生成的最大功臣是mini-css-extract-plugin工具包,它帮助我们转换了后缀名并生成到目标目录中。

多环境配置切换

环境切换

环境变量切换使用cross-env工具包进行配置,我们在package.json文件中添加两句脚本命令:


"scripts": {
 "dev": "cross-env OPERATING_ENV=development webpack --config build/webpack.config.js --watch",
 "build": "cross-env OPERATING_ENV=production webpack --config build/webpack.config.js
}

相应地,我们也修改一下webpack的配置文件,将应用的环境也告诉webpack,这样webpack会针对环境对代码进行优化处理。


/** build/webpack.config.js */
const { OPERATING_ENV } = process.env;

module.exports = {
 ...
 mode: OPERATING_ENV,
 devtool: OPERATING_ENV === 'production' ? 'source-map' : 'inline-source-map'
}

虽然也可以通过命令为webpack设置mode,这样也可以在项目中通过process.env.NODE_ENV访问环境变量,但我还是推荐使用工具包,因为你可能会有多个环境uat test pre等等。

JS代码体积优化

针对JS优化

小程序对包的大小有严格要求,单个包的大小不能超过2M,因此应对JS做进一步优化,这有利于控制包的大小。我所做的优化主要针对runtime和多个入口页面之间引用的公共部分,修改配置文件为:


/** build/webpack.config.js */
module.exports = {
 ...
 optimization: {
  splitChunks: {
   cacheGroups: {
    commons: {
     chunks: 'initial',
     name: 'commons',
     minSize: 0,
     maxSize: 0,
     minChunks: 2,
    },
   },
  },
  runtimeChunk: {
   name: 'manifest',
  },
 },
}

Webpack会将公共部分抽离出来,在dist文件夹根目录中生成common.js和manifest.js文件,这样整个项目的体积就会有明显的缩小,但是你会发现当我们运行命令是开发者工具里面项目其实是无法正常运行的,这是为什么?

这主要是因为这种优化使小程序其他的js文件丢失了对公共部分的依赖,我们对webpack配置文件做如下修改就可以解决了:


/** build/webpack.config.js */
module.exports = {
 ...
 output: {
  ...
  globalObject: 'global'
 },
 plugins: [
  new webpack.BannerPlugin({
   banner: 'const commons = require("./commons");\nconst runtime = require("./runtime");',
   raw: true,
   include: 'app.js',
  })
 ]
}

框架选择思考

小小解惑

许多读者可能会有疑惑,为什么你不直接使用已有的框架进行开发,这些能力已经有许多框架支持了。选择框架确实是一个不错的选择,毕竟开箱即用为开发者带来了许多便利。但是这个选择是有利有弊的,我也对市面上的较流行框架做了一段时间的研究和实践。较为早期的腾讯的wepy、美团的mpvue,后来者居上的京东的taro、Dcloud的uni-app等,这些在应用当中我认为有以下一些点不受我青睐:

  • 黑盒使我们有时很难定位问题究竟是出在自身的代码当中还是在框架的编译流程中(这让我踩了不少坑)
  • 围绕框架展开的可以使用的资源有限,例如UI的使用基本依赖于官方团队进行配套开发,如果没有社区也极难找到需要的资源(这一点我认为uni-app的社区做得挺不错)
  • 与已有的一些原生的资源无法结合,这些框架基本都是基于编译原理提供了以react或者vue为开发语言的能力,这使得原生的资源要无缝接入很难实现(假如你们公司已经积淀了一些业务组件那你会很头疼)。
  • 最后一点,也是我担心的最重要的一点,框架的升级速度是否能跟得上官方的迭代速度,如果滞后了已有的项目该如何处理

以上基本是我为什么要自己探索小程序工程化的理由(其实还有一点就是求知欲,嘻嘻)

总结

写在最后

以上是我对原生小程序工程化的探索,在我所在的团队中还应用了一些相关的样式规范,在这篇文章中我没有具体的说,有兴趣的话可以查看我的专栏中《团队规范之样式规范实践》一文。其实还有静态资源的管理,项目的目录的补充这些细节可以依照团队的需要去完善补充。本文希望对有需要做这方面实践的团队有所帮助,如有观点不正确或需要改进的地方,望可以评论告知我。

到此这篇关于详解微信小程序工程化探索之webpack实战的文章就介绍到这了,更多相关小程序 webpack 内容请搜索本站以前的文章或继续浏览下面的相关文章希望大家以后多多支持本站!

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章