Инженерное исследование апплета Wechat реального боя с веб-пакетом

Апплет WeChat

предисловие

Благодаря удобному использованию мини-программы WeChat быстро распространились и привлекли большое количество пользователей. В условиях стремительного роста рыночного спроса каждая интернет-компания желает вкусить сладости, поэтому владение технологией разработки небольших программ, несомненно, является незаменимым навыком для фронтенд-разработчика. Однако в разработке небольших программ всегда есть некоторые неудобства, которые подвергались критике со стороны разработчиков, в основном в следующих аспектах:

  • Отсутствие удобного механизма управления npm-пакетами на начальном этапе (на этом этапе можно использовать npm-пакеты, но операция крайне неудобна)
  • Невозможно использовать предварительно скомпилированные стили обработки языка
  • Невозможно переключаться между разными средами разработки с помощью команд сценария, и вам необходимо вручную изменить конфигурацию, необходимую для соответствующей среды (обычные проекты имеют как минимум среду разработки и рабочую среду)
  • Невозможность включить инструменты проверки спецификаций в разработку проекта (например, использование EsLint, StyleLint)

После множества вопросов я начал думать о том, как совместить современные инженерные технологии с небольшими программами. При поиске материалов в сообществе в первые дни многие пожилые люди много практиковались, используя gulp.Для многостраничных приложений, таких как небольшие программы, потоковый метод gulp кажется более удобным. После реальной практики применение gulp меня не устроило, поэтому я обратился к практическому изучению webpack. Я думаю, что выбор веб-пакета в качестве инженерной поддержки, хотя его сложнее реализовать, чем gulp, безусловно, будет иметь исключительные последствия в будущем развитии.

упражняться

Не будем рассматривать более сложные вопросы предварительной компиляции, спецификации и т. д. Наша первая цель — как применить вебпак для вывода файлов из папки с исходным кодом в целевую папку.Далее мы шаг за шагом создадим этот проект. :

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

После установки зависимостей мы создаем базовую структуру каталогов для этого проекта, как показано на рисунке:

На картинке выше показан простейший апплет, который содержит толькоappглобальный файл конфигурации иhomeстраница. Далее, неважно глобальный или страничный, делим тип файла на файлы, которые необходимо обработать.jsТип файлов и могут быть скопированы напрямую без повторной обработкиwxml,wxss,jsonдокумент. Таким образом, мы начинаем писать файл конфигурации для выполнения веб-пакета и создаем каталог сборки в корневом каталоге проекта для хранения файла 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',
      }
    ])
  ]
};

После написания приведенного выше кода позвольте мне объяснить, что делает приведенный выше код:

  1. ВходentryВ объекте я написал два свойства, которые предназначены дляapp.jsа такжеhome/index.jsВ качестве записи сборки веб-пакета он будет использовать этот файл в качестве отправной точки для создания собственных зависимостей, поэтому, когда мы добавляем другие файлы в файл записи, импортированные файлы также могут обрабатываться веб-пакетом.
  2. moduleя использовалbabel-loaderправильноjsФайл преобразован из ES6 в ES5, и добавлена ​​обработка нового синтаксиса, так что мы решаем проблему постоянного внедрения в разработку нативных апплетовregenerator-runtimeЭта проблема. (Этот шаг нам нужно установить@babel/core,@babel/preset-env,@babel/plugin-transform-runtime,@babel/runtime,babel-loaderэти зависимости)
  3. использоватьcopy-webpack-pluginДля обработки файлов, которые не нуждаются в повторной обработке, этот плагин может напрямую копировать файлы в целевой каталог.

После того, как мы поймем, что на самом деле делают эти коды, мы можем запустить их в терминале.webpack --config build/webpack.config.jsЗаказ. webpack скомпилирует исходный код вdistпапку, содержимое этой папки можно запускать, просматривать и загружать в инструменты разработчика.

оптимизация

После завершения самой базовой стратегии сборки веб-пакета мы реализовалиappа такжеhomeКонверсии страниц, но этого недостаточно. Нам еще предстоит решить множество задач:

  • Как бороться с увеличением файлов подкачки и как бороться с компонентами
  • Как выполняется ожидаемая прекомпиляция
  • Как спецификации включаются в разработку
  • Как работать с переменными окружения

Далее мы обновим стратегию веб-пакета по вышеуказанным пунктам:

Страницы и компоненты

В начале мой метод реализации состоял в том, чтобы написать служебную функцию для использованияglobСоберите страницы и компоненты подjsфайл, а затем сгенерируйте объект записи для перехода кentry. Но на практике я обнаружил, что у этого подхода есть два недостатка:

  1. Когда команда была запущена в терминале, новая страница или компонент не будут автоматически генерировать новую запись, то есть нам нужно снова запустить команду.
  2. Функция инструмента жестко закодирована, чтобы соответствовать файлам в папках страниц и компонентов, что не способствует масштабируемости проекта.Если нам нужно передать субдоговор или изменить имя папки, нам нужно изменить функцию инструмента.

Основываясь на убеждении, что программисты должны быть чрезвычайно ленивыми и никогда не смогут делать то, что могут делать машины, я начал изучать новую схему генерации записей. В итоге решил написать плагин для 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;

Благодаря веб-пакетуpluginСоответствующие знания выходят за рамки нашего обсуждения в этой статье, поэтому я лишь кратко расскажу, как он вмешивается в рабочий процесс веб-пакета и генерирует записи. (Если вам интересно узнать об этом, вы можете написать мне лично, и если у вас есть время, я могу организовать для вас некоторую информацию) Плагин на самом деле делает две вещи:

  1. Через хук entryOption компилятора мы добавим рекурсивно сгенерированный массив записей поэлементноentryсередина.
  2. Используйте хук компилятора watchRun, чтобы отслеживать, добавляется ли новая страница во время перекомпиляции.Если да, массив зависимостей будет сгенерирован с вновь добавленной страницей, а затем добавленentryсередина.

Теперь мы применим этот плагин к предыдущей стратегии веб-пакета и изменим приведенную выше конфигурацию на: (не забудьте установить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суффикс имени (если вы хотите использовать другие прекомпилированные языки, вы можете модифицировать загрузчик самостоятельно), то мы находимся вjsдобавить в файлimport './index.less'заявление, вы можете видеть, что файл стиля скомпилирован и сгенерирован нормально. Самый большой вклад в нормальное создание файлов стилейmini-css-extract-pluginToolkit, он помогает нам преобразовать имя суффикса и сгенерировать его в целевой каталог.

переключатель среды

Переключение переменных окружения, которые мы используем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
}

Соответственно, мы также модифицируем файл конфигурации веб-пакета и сообщаем веб-пакету среду нашего приложения, чтобы веб-пакет оптимизировал код для среды.

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

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

Хотя мы также можем установить это для веб-пакета с помощью командыmode, который также можно передать в проектеprocess.env.NODE_ENVПолучите доступ к переменным среды, но я все же рекомендую использовать инструментарий, поскольку у вас может быть несколько сред.uat test preи т.п.

Оптимизирован для JS

У апплета есть строгие требования к размеру пакета, а размер одного пакета не может превышать 2M, поэтому мы должны дополнительно оптимизировать JS, что помогает нам контролировать размер пакета. Оптимизация, которую я сделал, в основном для общих частей, на которые ссылаются среда выполнения и страницы с несколькими входами, и измененный файл конфигурации:

/** 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Файл теряет свои зависимости от публичной части, мы можем решить эту проблему, изменив файл конфигурации веб-пакета следующим образом:

/** 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 от Tencent, mpvue от Meituan, таро от JD.com, uni-app от Dcloud и т. д., которые опоздали, я думаю, что следующие моменты мне не нравятся:

  • Черный ящик иногда мешает нам определить, в чем проблема: в нашем собственном коде или в процессе компиляции фреймворка (это заставило меня наступить на множество ям).
  • Доступные ресурсы вокруг фреймворка ограничены.Например, использование пользовательского интерфейса в основном зависит от официальной команды поддержки разработки.Если нет сообщества, крайне сложно найти необходимые ресурсы (я думаю, что сообщество uni-app хорошо поработал)
  • Его нельзя комбинировать с некоторыми существующими нативными ресурсами, эти фреймворки в основном предоставляют возможность использовать react или vue в качестве языка разработки по принципу компиляции, что затрудняет достижение беспрепятственного доступа к нативным ресурсам (если ваша компания накопила у вас есть какие-то бизнес-составляющие, у вас будет головная боль).
  • Последний и самый важный момент, о котором я беспокоюсь, — может ли скорость обновления фреймворка соответствовать официальной скорости итерации, и что делать, если существующие проекты отстают?

Вышеизложенное в основном является причиной, по которой я хочу самостоятельно изучить разработку небольших программ (на самом деле, есть еще один любопытный момент, хи-хи).

напиши в конце

Вышеизложенное является моим исследованием разработки нативных апплетов. Я также применил некоторые связанные спецификации стиля в своей команде. Я ничего не сказал в этой статье. Если вам интересно, вы можете проверить мою колонку "Спецификации команды" Практика спецификаций стиля . На самом деле там тоже есть управление статическими ресурсами, а детали пополнения каталога проекта можно улучшать и дополнять по потребностям команды. Эта статья надеется быть полезной для команд, которым необходимо выполнить эту практику.Если есть неправильные мнения или области, которые необходимо улучшить, сообщите мне об этом в комментариях.