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

前言

微信小程序因为其便捷的使用方式,以极快的速度传播开来吸引了大量的使用者。市场需求急剧增加的情况下,每家互联网企业都想一尝甜头,因此掌握小程序开发这一技术无疑是一名前端开发者不可或缺的技能。但小程序开发当中总有一些不便一直让开发者诟病不已,主要表现在:

  • 初期缺乏方便的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的插件,在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的plugin相关知识不在我们这篇文章的讨论范畴,所以我只简单的介绍一下它是如何介入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优化

小程序对包的大小有严格的要求,单个包的大小不能超过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 内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • 利用Webpack实现小程序多项目管理的方法

    故事是这样的 产品小姐姐:"我要做一堆小程序,一周上线一到两个没问题吧" 码畜小哥哥:"你他喵是不是傻,做那么多干什么" 产品小姐姐:"蹭些流量呀,用户量多了就可以考虑转化流量给公司的 App" 码畜小哥哥:"fuck 好的" 码畜小哥开始架构 小程序杂,放一个项目方便管理 小程序多,代码要能够复用 团队开发,代码风格要统一 码畜小哥开始建项目 这是单个小程序的基本目录结构,没问题 当一个项目有多个小程序的时候,好像也没问题

  • 用webpack4开发小程序的实现方法

    哈,本人是REACT系开发者,工作中需要不停的折腾webpack,为了顺带学习VUE的开发思想和思路,顺理成章的请缨为公司小程序打个框架基础.前期也去了解了下各个小程序开发框架,大体上是通过转义的思路来解决小程序和VUE/REACT的模板.逻辑关系,不做展开讨论了.只是从本人角度分享通过webpack来构建小程序的开发架构. 通过观察小程序的原有架构,不难发现其已经是一套比较完善的mvvm架构了(类VUE),融合了VUE及REACT的一些特点(以VUE为主),但却有一些不足,缺失了前端开发人员常

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

    前言 微信小程序因为其便捷的使用方式,以极快的速度传播开来吸引了大量的使用者.市场需求急剧增加的情况下,每家互联网企业都想一尝甜头,因此掌握小程序开发这一技术无疑是一名前端开发者不可或缺的技能.但小程序开发当中总有一些不便一直让开发者诟病不已,主要表现在: 初期缺乏方便的npm包管理机制(现阶段确实可以使用npm包,但是操作确实不便) 不能使用预编译语言处理样式 无法通过脚本命令切换不同的开发环境,需手动修改对应环境所需配置(常规项目至少具备开发与生产环境) 无法将规范检查工具结合到项目工程中(

  • 详解微信小程序 同步异步解决办法

    详解微信小程序 同步异步解决办法 小程序中函数体还没有完成,下一个函数就开始执行了,而且两个函数之间需要传参.那是因为微信小程序函数是异步执行的.但微信小程序增加了ES6的promise特性支持,微信小程序新版本中移除了promise的支持,需要自己使用第三方库来自行实现ES6的promise特性. WxService.js import Tools from 'Tools' import es6 from '../assets/plugins/es6-promise' class Servic

  • 详解微信小程序Radio选中样式切换

    详解微信小程序Radio选中样式切换 本篇文章主要讲解在微信小程序中如何根据Radio选中来切换样式.效果如下: 原理主要是通过判断一个radio-group中哪个被选中,就让它加上一个"active"的样式. 代码如下: <!--index.wxml--> <view class="container"> <radio-group bindchange="radioCheckedChange"> <vi

  • 详解微信小程序 登录获取unionid

    详解微信小程序 登录获取unionid 首先公司开发了小程序, 公众号网页和app等, 之前都是用的openid来区分用户, 但openid只能标识用户在当前小程序或公众号里唯一, 我们希望用户可以在公司各个产品(比如公众号, 小程序, app里的微信登录)之间, 可以保持用户的唯一性, 还好微信给出了unionid. 下面分两步介绍一下 微信小程序 获取unionid的过程. 1. 首先 在微信公众平台注册小程序 , 然后在小程序上模拟登录流程. 注 : 这里只是简单登录流程, 实际中需要维护

  • 详解微信小程序 通过控制CSS实现view隐藏与显示

    详解微信小程序 通过控制CSS实现view隐藏与显示 实现效果图: 视图代码,使用变量控制隐藏类名 <scroll-view scroll-y="true" > <view class="user_freeback"> <view class="txt"> <text> 为了更好地帮助您解决问题,请准确填写您的邮箱地址和电话号码,以便管理员给你答复.</text> </view&g

  • 详解微信小程序 template添加绑定事件

    详解微信小程序 template添加绑定事件 对于模板的使用,我是想将模板的事件单独出来,其他引用模板的页面中不再掺杂模板事件,比较方便管理,如果还有其他好的解决办法, 请赐教. template.wxml <view bindtap="clickView" class="tempClass">temp模板</view> template.js var temp = { clickView: function () { console.log

  • 详解微信小程序设置底部导航栏目方法

    详解微信小程序设置底部导航栏目方法 小程序底部想要有一个漂亮的导航栏目,不知道怎么制作,于是百度找到了本篇文章,分享给大家. 好了 小程序的头部标题 设置好了,我们来说说底部导航栏是如何实现的. 我们先来看个效果图 这里,我们添加了三个导航图标,因为我们有三个页面,小程序最多能加5个. 那他们是怎么出现怎么着色的呢?两步就搞定! 1. 图标准备 阿里图标库  http://www.iconfont.cn/collections/show/29 我们进入该网站,鼠标滑到一个喜欢的图标上面  点击下

  • 详解微信小程序中的页面代码中的模板的封装

    详解微信小程序中的页面代码中的模板的封装 最近在进行微信小程序中的页面开发,其实在c++或者说是js中都会出现这种情况,就是相同的代码会反复出现,这就是进行一定的封装,封装的好处就是可以是程序中在于减少一定的代码量,并且可是使代码结构更加清晰.那今天所要记录的就是关于微信小程序中的页面的模板封装. 在微信小程序中的文件名都带有wxml等样式,在wxml中提供了模板,即可以在模板中定义代码片段,然后可以在页面中的不同位置进行调用,模板的定义: <templatename="products&

  • 详解微信小程序 相对定位和绝对定位

    详解微信小程序 相对定位和绝对定位 相对定位:元素是相对自身进行定位,参照物是自己. 绝对定位:元素是相对离它最近的一个已定位的父级元素进行定位 相对定位: position:relative;    /*启用相对定位*/         left:150rpx;    /*相对于自己往右偏离150*/         top:50rpx;     /*相对于自己往下偏离150*/ 绝对定位: position: absolute;           left: 50rpx;         

  • 详解微信小程序审核不通过的解决方法

    前言 近来,微信小程序一直活跃在开发者的眼球中.很多开发者都投身微信小程序的开发中,而这些开发者,总是需要面对最后一道难题:如何以一种优雅的姿势来通过微信官方的审核.本文基于几天前提交审核的一次总结,写得有不当的地方,请各位大佬指正. 问题描述 先上一下微信小程序平台常见拒绝情形的说明文件.由于我提交的小程序中包含了"分享群"的按钮,所以审核未通过,未通过的原因如下: 3.2.1 小程序的页面内容中,存在诱导类行为,包括但不限于诱导分享.诱导添加.诱导关注公众号.诱导下载等,要求用户分

随机推荐