webpack 專案接入Vite的通用方案介紹(上)

粥裡有勺糖發表於2021-11-14

願景

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

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

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

背景

在當下的業務開發中處處可見webpack的身影,大部分的業務專案採用的構建工具也都是它。

隨著時間的推移,存量老專案體積越來越大,開發啟動(dev)/構建(build) 需要的時間越來越長。針對webpack的優化手段越來越有限。

於是乎某些場景出現了用其它語言寫的工具,幫助構建/開發提效。如SWC(Rust),esbuild(Go)

當然上述工具並不是一個完整的構建工具,不能取代webpack直接使用,只是通過plugin,為webpack工作提效

當下另一種火熱的方案是bundleless,利用瀏覽器原生支援ES Module的特性,讓瀏覽器接管"打包"工作,工具只負責對瀏覽器請求的資源進行相應的轉換,從而極大的減少服務的啟動時間,提升開發體驗與開發幸福感

比較出名的兩個產品就是snowpackVite

本文的主角就是Vite下一代前端開發與構建工具

由於Vite的周邊還處於建設期,要完全替代webpack,還需要一定時日,為了保證存量線上專案的穩定性,Vite作為一個開發時可選的能力接入是比較推薦的一個做法。

# webpack devServer
npm run dev

# Vite devServer
npm run vite

目標

為webpack專案開發環境提供最簡單的Vite接入方案

待接入專案只需要做極小的變動就能享受到Vite帶來的開發樂趣

方案

  1. 做一個CLI工具,封裝Vite啟動專案的能力
  2. 將Vite相關的配置全部收斂於外掛內,自動將webpack配置轉化為Vite配置
  3. 對外提供一些可選引數,用於手動指定配置檔案的位置

demo效果

Vue SPA

圖片

React SPA

圖片

在最簡單的Demo工程中,Vite的啟動/HMR速度也是明顯比webpack快不少的

其它常見專案型別的demo也會逐漸的完善到原始碼倉庫中

實現

1. 初始化工程

完整的工程結構移步倉庫

註冊一個啟動方法start

src/bin.ts

#!/usr/bin/env node
import { Command } from 'commander';
import { startCommand } from './command';
program.command('start')
  .alias('s')
  .action(startCommand);

program.parse(process.argv);
export default function startCommand() {
  console.log('hello vite');
}

package.json中新增指令

  • 其中wvs為自定義的指令
  • npm run dev:利用typescript依賴提供的指令,監聽檔案變動,自動將其轉換js檔案
{
  "bin": {
    "wvs": "./dist/bin.js"
  },
  "scripts": {
    "dev": "tsc -w -p .",
    "build": "rimraf dist && tsc -p ."
  },
}

專案根目錄執行npm link,註冊指令

npm link

測試

wvs start

圖片

緊接著我們用Vue-CLICreate React App分別建立兩個webpack的SPA應用進行接下來的實驗

vue create vue-spa

圖片

npx create-react-app react-spa

2. 收斂Vite啟動

Vite的啟動比較簡單,只需要執行vite這個指令就行s

圖片

在我們的CLI工具裡使用spawn建立子程式啟動Vite

  • 其中cwd用於指定子程式的工作目錄
  • stdio:子程式的標準輸入輸出配置
import { spawn } from 'child_process';

export default function startCommand() {
  const viteService = spawn('vite', ['--host', '0.0.0.0'], {
    cwd: process.cwd(),
    stdio: 'inherit',
  });

  viteService.on('close', (code) => {
    process.exit(code);
  });
}

這裡為了方便除錯,我們們全域性安裝一下Vite

npm i -g vite

在啟動模板public/index.html裡新增一個<h1>Hello Vite</h1>

在demo專案裡執行wvs start

圖片

開啟對應地址

# vue
http://localhost:3000/
# react
http://localhost:3001/

得到了如下的結果,提示找不到頁面(意料之中)

圖片

通過文件得知,Vite會預設尋找index.html作為專案的入口檔案

圖片

這就帶來了第一個要處理的問題,多頁應用下可能有多個模板檔案

如何根據訪問路由動態的指定這個x.html的入口

在解決問題之前,我們們再簡單完善一下啟動指令,為其指定一個vite.config.js 配置檔案

通過vite --help,可以看到通過--config引數指定配置檔案位置

圖片

export default function startCommand() {
  const configPath = require.resolve('./../config/vite.js');
  const viteService = spawn('vite', ['--host', '0.0.0.0', '--config', configPath], {
    cwd: process.cwd(),
    stdio: 'inherit',
  });
}

這裡指向配置檔案的絕對路徑

config/vite.ts

import { defineConfig } from 'vite';

module.exports = defineConfig({
  plugins: [],
  optimizeDeps: {},
});

3. html模板處理

擴充Vite的能力就是定製各種的外掛,根據外掛文件

編寫一個簡單的plugin,利用configServer鉤子,讀取瀏覽器發起的資源請求

import type { PluginOption } from 'vite';

export default function HtmlTemplatePlugin(): PluginOption {
  return {
    name: 'wvs-html-tpl',
    apply: 'serve',
    configureServer(server) {
      const { middlewares: app } = server;
      app.use(async (req, res, next) => {
        const { url } = req;
        console.log(url);
        next();
      });
    },
  };
}

在上述的配置檔案中引入

import { htmlTemplatePlugin } from '../plugins/index';
module.exports = defineConfig({
  plugins: [
    htmlTemplatePlugin(),
  ]
});

再次啟動服務觀察

  • 訪問http://localhost:3000,終端中輸出/
  • 訪問http://localhost:3000/path1/path2,終端中輸出/path1/path2
  • 訪問http://localhost:3000/path1/path2?param1=123,終端中輸出/path1/path2?param1=123

在 devTools皮膚內容中可以看到,第一個資源請求頭上的Accept欄位中帶有text/html,application/xhtml+xml等內容,我們們就以這個欄位表明請求的是html文件

圖片

再次修改一下處理資源請求的程式碼

import { readFileSync } from 'fs';
import path from 'path';
import { URL } from 'url';

function loadHtmlContent(reqPath) {
  // 單頁預設 public/index.html
  const tplPath = 'public/index.html';
  // 可以根據請求的path:reqPath 作進一步的判斷
  return readFileSync(path.resolve(process.cwd(), tplPath));
}

// 省略了前面出現過的程式碼
app.use(async (req, res, next) => {
  const { pathname } = new URL(req.url, `http://${req.headers.host}`);
  const htmlAccepts = ['text/html', 'application/xhtml+xml'];
  const isHtml = !!htmlAccepts.find((a) => req.headers.accept.includes(a));
  if (isHtml) {
    const html = loadHtmlContent(pathname);
    res.end(html);
    return;
  }
  next();
});

再次在demo中啟動服務,訪問就能正確看到Hello Vite

在終端中會發現一個報錯

UnhandledPromiseRejectionWarning: URIError: URI malformed

開啟模板可以發現是由於有一些其它的內容,裡面包含一些變數,這部分在webpack中是由 html-webpack-plugin外掛處理

<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>
  <%= htmlWebpackPlugin.options.title %>
</title>

這裡編寫一個簡單的方法對模板先做一些簡單處理(這個方法只處理了當前遇到的這種情況)

/**
 * 初始化模板內容(替換 <%= varName %> 一些內容)
 */
function initTpl(tplStr:string, data = {}, ops?:{
  backup?:string
  matches?:RegExp[]
}) {
  const { backup = '', matches = [] } = ops || {};
  // match %Name% <%Name%>
  return [/<?%=?(.*)%>?/g].concat(matches).reduce((tpl, r) => tpl.replace(r, (_, $1) => {
    const keys = $1.trim().split('.');
    const v = keys.reduce((pre, k) => (pre instanceof Object ? pre[k] : pre), data);
    return (v === null || v === undefined) ? backup : v;
  }), tplStr);
}

如果模板中還有複雜的ejs語法可以使用 ejs 庫做進一步處理

import ejs from 'ejs';

/**
 * ejs渲染
 */
function transformEjsTpl(html:string, data = {}) {
  return ejs.render(html, data);
}

當然如果還有其它未考慮到的case,可根據特定情況,再對模板做進一步的處理

下面將上述編寫的方法整合到外掛中

export default function HtmlTemplatePlugin(): PluginOption {
  return {
    configureServer(server) {
      const { middlewares: app } = server;
      app.use(async (req, res, next) => {
        // 省略程式碼
        if (isHtml) {
          const originHtml = loadHtmlContent(pathname);
          // 呼叫外掛中的transformIndexHtml 鉤子對模板做進一步處理
          const html = await server.transformIndexHtml(req.url, originHtml, req.originalUrl);
          res.end(html);
          return;
        }
        next();
      });
    },
    transformIndexHtml(html) {
      // data可以傳入模板中包含的一些變數
      // 可以再此處獲取webpack配置,做自動轉換
      return initTpl(html, {
        PUBLIC_URL: '.',
        BASE_URL: './',
        htmlWebpackPlugin: {
          options: {
            title: 'App',
          },
        },
      });
    },
  };
}

到此再次在demo中執行,頁面跑起來了,終端中也無報錯,頁面的模板到此算是處理完畢

有了初始的模板,就意味著我們已經為Vite提供了頁面的入口,但其中還沒有處理的js/ts的依賴即 entry

下面將介紹往模板中插入entry

4. 指定entry入口

入口檔名(entryName)通常為(main|index).js|ts|jsx|tsx

  • 單頁應用(SPA)中entryBase通常為:src
  • 多頁應用(MPA)中entryBase通常為:src/pages/${pageName}

利用transformIndexHtml鉤子往模板中插入<script type="module" src="entryFile"></script>

export default function pageEntryPlugin(): PluginOption {
  return {
    name: 'wvs-page-entry',
    apply: 'serve',
    transformIndexHtml(html, ctx) {
      return html.replace('</body>', `<script type="module" src="${getPageEntry(ctx.originalUrl)}"></script>
        </body>
        `);
    },
  };
}

這裡以SPA為例

function getPageEntry(reqUrl) {
  // SPA
  const SPABase = 'src';
  return getEntryFullPath(SPABase);
}

getEntryFullPath 實現如下

  • 先判斷目錄是否存在
  • 讀取目錄,遍歷檔案利用正則/(index|main)\.[jt]sx?$/判斷檔案是否為目標檔案
const resolved = (...p) => path.resolve(getCWD(), ...p);
const getEntryFullPath = (dirPath) => {
  if (!existsSync(resolved(dirPath))) {
    return false;
  }
  // main|index.js|ts|jsx|tsx
  const entryName = /(index|main)\.[jt]sx?$/;
  const entryNames = readdirSync(resolved(dirPath), { withFileTypes: true })
    .filter((v) => {
      entryName.lastIndex = 0;
      return v.isFile() && entryName.test(v.name);
    });
  return entryNames.length > 0 ? path.join(dirPath, entryNames[0].name) : false;
};

將這個外掛加入到配置裡

import { pageEntryPlugin } from '../plugins/index';
module.exports = defineConfig({
  plugins: [
    pageEntryPlugin(),
  ]
});

啟動demo檢視效果,丟擲了一堆錯誤

wvs start

下面是針對框架特定的處理

React

  1. React: the content contains invalid JS syntax

React中將帶有jsx語法的js檔案字尾改為jsx,關於直接在js中使用jsx語法的處理方案,見文章:解決Vite-React專案中.js使用jsx語法報錯的問題

  1. Uncaught ReferenceError: React is not defined

在 react元件頂部引入React,或引入@vitejs/plugin-react外掛,同下3處理方案

import React from 'react';
  1. HMR支援

引入@vitejs/plugin-react外掛

import react from '@vitejs/plugin-react'

module.exports = defineConfig({
  plugins: [
    react(),
  ]
});

Vue

需要新增外掛處理.vue檔案

引入@vitejs/plugin-vue外掛

import vue from '@vitejs/plugin-vue'

module.exports = defineConfig({
  plugins: [
    vue(),
  ]
});

同時 @vitejs/plugin-vue 需要 vue (>=3.2.13)

由於前面採用的是npm link建立軟連線進行的除錯,配置檔案中會在開發目錄下去查詢Vue依賴,不會在指令執行目錄下查詢,會不斷的丟擲上述問題

這裡在demo專案裡本地安裝我們的依賴,然後在package.json新增相關指令

yarn add file:webpack-vite-service-workspace-path
{
  "scripts": {
    "vite": "wvs start -f vue"
  },
}

Vue專案中並沒有React相關依賴,所以在Vue專案中不能引入@vitejs/plugin-react外掛

可以在指令入口新增框架相關引數判斷處理一下,只引入對應框架的外掛

// src/bin.ts
program.command('start')
  .option('-f, --framework <type>', 'set project type [vue/react]')
  .action(startCommand);

// src/command/start.ts
export default function startCommand(options:{[key:string]:string}) {
  const { framework = '' } = options;
  process.env.framework = framework.toUpperCase();
}

// src/config/vite.ts
import react from '@vitejs/plugin-react';
import vue from '@vitejs/plugin-vue';

const extraPlugins: any[] = [
  process.env.framework === 'REACT' ? [react()] : [],
  process.env.framework === 'VUE' ? [vue()] : [],
];
module.exports = defineConfig({
  plugins: [
    htmlTemplatePlugin(),
    pageEntryPlugin(),
    ...extraPlugins,
  ],
});

到此最關鍵的兩個步驟就算完成了

5. 其它工程能力

目前針對webpack常見的能力,社群已經有了許多外掛和方案,下面只做簡單介紹

這些外掛當然也有些場景可能處理不了,還是期望廣大開發者,勇於實驗,然後向外掛作者提交PR/issues

  • Sass/Less:在依賴中安裝Sass/Less即可
  • 元件庫按需引入:vite-plugin-style-import
  • process.env:vite-plugin-env-compatible
  • window.xx/xx undefined:使用transformIndexHtml鉤子開發外掛,在模板中提前引入這個方法的polyfill或者兜底處理
  • ...

總結

企業:大部分是擁有自己的研發框架,在研發框架中只需要加入一個Vite啟動的CLI指令,這樣對接入方的影響與使用成本是最小的

個人:喜歡折騰/不想改動原來的程式碼,可以按上述流程自己接一下,新專案可以直接使用Vite官方模板開發

總之:開發中使用Vite還是很香的

由於篇幅與時間都有限,文中部分地方只介紹了實現思路,並沒貼上完整程式碼,完整程式碼可在原始碼倉庫中檢視,也可fork直接進行二次開發

webpackvite配置的轉換這部分的內容將放在下期做介紹

相關文章