webpack 項目接入Vite的通用方案介紹(下)


願景

希望通過此系列文章,能給讀者提供一個存/增量項目接入Vite的點子,起拋磚引玉的作用,減少這方面能力的建設成本

在闡述過程中同時也會逐漸完善webpack-vite-serve這個工具

讀者可直接fork這個工具倉庫,針對個人/公司項目場景進行定制化的二次開發

前言

上一篇的文章中,大概介紹了webpack項目接入Vite的處理思路,大體就是以下步驟:

這些內容的處理都是可以通過vite插件實現

webpack-vite-serve介紹

這段時間就在不斷完善這個庫的功能,下面先簡單介紹一下其使用,再闡述一些插件的實現原理

目標:為webpack項目提供一鍵接入Vite的能力

安裝依賴

npm install webpack-vite-serve -D
# or
yarn add webpack-vite-serve -D
# or
pnpm add webpack-vite-serve -D

添加啟動指令

# devServer
wvs start [options]
# build
wvs build [options]

可選參數

  • -f,--framework <type>:指定使用的業務框架 (vue,react),自動引入業務框架相關的基礎插件
  • -s,--spa:按照單頁應用目錄結構處理 src/${entryJs}
  • -m,--mpa:按照多頁應用目錄結構處理 src/pages/${entryName}/${entryJs}
  • -d,--debug [feat]:打印debug信息
  • -w,--wp2vite:使用 wp2vite 自動轉換webpack文件

其它說明

項目遵循常規的 單頁/多頁應用 項目的目錄結構即可

vite配置通過官方的vite.config.[tj]s配置文件拓展即可

效果

圖片

在線體驗demo地址:已創建stackblitz

如由於網絡原因無法訪問,可clone倉庫訪問其中demo體驗

MPA支持

Dev-頁面模板

首先是devServer環境的頁面模板處理

根據請求路徑獲取entryName

  • 使用/拆分請求路徑得到paths
  • 遍歷尋找第一個src/pages/${path}存在的path,此path即為entryName
function getEntryName(reqUrl:string, cfg?:any) {
  const { pathname } = new URL(reqUrl, 'http://localhost');
  const paths = pathname.split('/').filter((v) => !!v);
  const entryName = paths.find((p) => existsSync(path.join(getCWD(), 'src/pages', p)));
  if (!entryName) {
    console.log(pathname, 'not match any entry');
  }
  return entryName || '';
}

尋找模板文件,按照如下順序探尋

  • src/pages/${entryName}/${entryName}.html
  • src/pages/${entryName}/index.html
  • public/${entryName}.html
  • public/index.html
function loadHtmlContent(reqPath:string) {
  // 兜底頁面
  const pages = [path.resolve(__dirname, '../../public/index.html')];

  // 單頁/多頁默認 public/index.html
  pages.unshift(resolved('public/index.html'));

  // 多頁應用可以根據請求的 路徑 作進一步的判斷
  if (isMPA()) {
    const entryName = getEntryName(reqPath);
    if (entryName) {
      pages.unshift(resolved(`public/${entryName}.html`));
      pages.unshift(resolved(`src/pages/${entryName}/index.html`));
      pages.unshift(resolved(`src/pages/${entryName}/${entryName}.html`));
    }
  }
  const page = pages.find((v) => existsSync(v));
  return readFileSync(page, { encoding: 'utf-8' });
}

Dev-entryJs

多頁應用的entryJs就按約定讀取src/pages/${entryName}/${main|index}文件

function getPageEntry(reqUrl) {
  if (isMPA()) {
    const entryName = getEntryName(reqUrl);
    return !!entryName && getEntryFullPath(`src/pages/${entryName}`);
  }
  // 默認SPA
  const SPABase = 'src';
  return getEntryFullPath(SPABase);
}

Build

vite構建的入口是html模板,可以通過build.rollup.input屬性設置

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      input: {
        index: 'src/pages/index/index.html',
        second: 'src/pages/second/second.html',
      },
    },
  },
});

按照如上配置,構建產物中的html目錄將會如下

* dist
  * src/pages/index/index.html
  * src/pages/second/second.html
  * assets

不太符合通常的習慣,常規格式如下

* dist
  * index.html
  * second.html
  * assets

所以需要通過插件處理構建入口文件調整構建后的產物位置

插件結構

export default function BuildPlugin(): PluginOption {
  let userConfig:ResolvedConfig = null;
  return {
    name: 'wvs-build',
    // 只在構建階段生效
    apply: 'build',
    // 獲取最終配置
    configResolved(cfg) {
      userConfig = cfg;
    },
    // 插件配置處理
    config() {
      
    },
    resolveId(id) {

    },
    load(id) {

    },
    // 構建完成后
    closeBundle() {
      
    },
  };
}

通過configResolved鈎子獲取最終配置,配置提供給其它鈎子使用

獲取entry

首先獲取src/pages下所有的entry

const entry = [];
if (isMPA()) {
  entry.push(...getMpaEntry());
} else {
  // 單頁應用
  entry.push({
    entryName: 'index',
    entryHtml: 'public/index.html',
    entryJs: getEntryFullPath('src'),
  });
}

entry的定義為

interface Entry{
  entryHtml:string
  entryName:string
  entryJs:string
}

獲取邏輯如下

  • 先獲取所有的EntryName
  • 在遍歷獲取每個entry對應的entryJsentryHtml
export function getMpaEntry(baseDir = 'src/pages') {
  const entryNameList = readdirSync(resolved(baseDir), { withFileTypes: true })
    .filter((v) => v.isDirectory())
    .map((v) => v.name);

  return entryNameList
    .map((entryName) => ({ entryName, entryHtml: '', entryJs: getEntryFullPath(path.join(baseDir, entryName)) }))
    .filter((v) => !!v.entryJs)
    .map((v) => {
      const { entryName } = v;
      const entryHtml = [
        resolved(`src/pages/${entryName}/${entryName}.html`),
        resolved(`src/pages/${entryName}/index.html`),
        resolved(`public/${entryName}.html`),
        resolved('public/index.html'),
        path.resolve(__dirname, '../../public/index.html'),
      ].find((html) => existsSync(html));
      return {
        ...v,
        entryHtml,
      };
    });
}

生成構建配置

根據得到的entry生成 build.rollup.input

  • 獲取每個entryHtml的內容,然后使用map進行臨時的存儲
  • 構建入口模板路徑htmlEntryPathentryJs的目錄加index.html

實際上htmlEntryPath這個路徑並不存在任何文件

所以需要通過其它鈎子,利用htmlContentMap存儲的內容進行進一步的處理

const htmlContentMap = new Map();
// 省略其它無關代碼
{
  config() {
    const input = entry.reduce((pre, v) => {
      const { entryName, entryHtml, entryJs } = v;
      const html = getEntryHtml(resolved(entryHtml), path.join('/', entryJs));
      const htmlEntryPath = resolved(path.parse(entryJs).dir, tempHtmlName);
      // 存儲內容
      htmlContentMap.set(htmlEntryPath, html);
      pre[entryName] = htmlEntryPath;
      return pre;
    }, {});
    return {
      build: {
        rollupOptions: {
          input,
        },
      },
    };
  }
}

構建入口內容生成

其中resolveIdload鈎子一起完成入口文件的處理

  • 其中id即為資源請求的路徑
  • 接着直接從htmlContentMap去除模板的內容即可
{
  load(id) {
    if (id.endsWith('.html')) {
      return htmlContentMap.get(id);
    }
    return null;
  },
  resolveId(id) {
    if (id.endsWith('.html')) {
      return id;
    }
    return null;
  },
}

產物目錄調整

使用closeBundle鈎子,在構建完成后,服務關閉前進行文件調整

  • 遍歷entrydist/src/pages/entryName/index.html移動到dist
  • 移除dist/src下的內容
closeBundle() {
  const { outDir } = userConfig.build;
  // 目錄調整
  entry.forEach((e) => {
    const { entryName, entryJs } = e;
    const outputHtmlPath = resolved(outDir, path.parse(entryJs).dir, tempHtmlName);
    writeFileSync(resolved(outDir, `${entryName}.html`), readFileSync(outputHtmlPath));
  });
  // 移除臨時資源
  rmdirSync(resolved(outDir, 'src'), { recursive: true });
}

webpack配置轉換

目前社區有一個CLI工具:wp2vite支持了這個功能,所以筆者不打算從0-1再建設一個

由於是cli工具,沒有提供一些直接調用的方法去獲取轉換前后的配置,所以接入插件中的使用體驗還不是很好,后續准備提PR改造一下這個工具

接入wp2vite的插件實現如下

import wp2vite from 'wp2vite';
// 省略不重要的 import
export default function wp2vitePlugin(): PluginOption {
  return {
    name: 'wvs-wp2vite',
    enforce: 'pre',
    async config(_, env) {
      const cfgFile = resolved('vite.config.js');
      const tplFile = resolved('index.html');
      const contentMap = new Map([[cfgFile, ''], [tplFile, '']]);
      const files = [cfgFile, tplFile];

      console.time('wp2vite');
      // 判斷是否存在vite.config.js 、index.html
      // 避免 wp2vite 覆蓋
      files.forEach((f) => {
        if (existsSync(f)) {
          contentMap.set(f, readFileSync(f, { encoding: 'utf-8' }));
        }
      });

      // 轉換出配置文件vite.config.js
      await wp2vite.start(getCWD(), {
        force: false,
        // 統一開啟debug
        debug: !!process.env.DEBUG,
      });

      // TODO:提PR優化
      // 轉換耗時計算
      console.timeEnd('wp2vite');

      // 獲取wp2vite轉換出的配置
      const cfg = await getUserConfig(env, 'js');

      contentMap.forEach((v, k) => {
        if (v) {
          // 如果修改了內容,還原內容
          writeFileSync(k, v);
        } else {
          // 移除創建的文件
          unlinkSync(k);
        }
      });

      if (cfg.config) {
        const { config } = cfg || {};
        // 留下需要的配置
        return {
          resolve: config?.resolve,
          server: config?.server,
          css: config?.css,
        };
      }

      return null;
    },
  };
}

wp2vite,對外暴露了一個start方法調用

調用后會根據項目的webpack配置生成2個新文件(vite.config.jsindex.html),並修改package.json添加指令與依賴

所以在生成前如果項目中存在這些文件則需要先將這些內容存儲起來

其中獲取用戶配置的getUserConfig實現如下

import { loadConfigFromFile, ConfigEnv } from 'vite';

export function getUserConfig(configEnv:ConfigEnv, suffix = '') {
  const configName = 'vite.config';
  const _suffix = ['ts', 'js', 'mjs', 'cjs'];
  if (suffix) {
    _suffix.unshift(suffix);
  }
  const configFile = _suffix.map((s) => `${configName}.${s}`).find((s) => existsSync(s));
  return loadConfigFromFile(configEnv, configFile);
}

vite提供了loadConfigFromFile方法,只需要在此方法中做一層簡單的封裝即可直接使用,方法內部使用esbuild自動對ts與es語法進行了轉換

總結

到目前為止,建設的能力已基本能夠滿足常規項目的開發

能力未及之處用戶亦可直接在工程中添加vite配置文件進行自行的拓展

后續規划

  1. 目前wp2vite在配置轉換這一塊,還不能太滿足使用要求,准備提PR增強一下
  2. 將內部能力抽成一個個單獨的vite插件


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM