vite6迁移H5脚手架(一) —— 基本框架

216 阅读6分钟

一. 背景

现有的公司脚手架是webpack4 + react16 + ts + ant-mobile@2 + @reduxjs/toolkit + lodash + moment依赖的node环境最高就是14了

二. what(问题是什么)

  • 执行环境:node14, 使用的npm装包,项目一多,电脑存储给你干爆,而pnpm起码要在node18及以上版本才能使用。
  • 开发:项目存在特别老,功能少,性能一般,打包后还重的库,比如ant-mobile,现在都出到5了,lodash和moment都是打包后比较重的框架,可替代品轻量的es-toolkit和dayjs都出了好几年了,也比较稳定
  • 构建:webpack4是好多年前的框架了,启动慢,构建慢,项目虽然是多enrty的,但单entry(h5的单页面基本都是比较简单的)启动时间都需要7s起, 构建就别说了,而且React 官方已明确建议开发者逐步淘汰 Create React App (CRA)  ,转而使用 Vite 等现代框架或工具来创建新项目。
  • 发布:gitlab cicd只能每次打包时候重新装包(全公司的项目共用两台机器)基本7-9分钟安装时间(包太多),加上构建和发布机器上,十几分钟下来,要是发现有一点问题,又再来一次。人都等睡着了

三.HOW (怎么解决)

针对上述问题,需要重新弄一套干净的,打包产物尽可能的轻(5g时代几年了,一看首屏和资源加载数据还是那么差, 带宽上来了用户开的应用也多了,分给你页面的就少了),起码几年内不需要重构的h5脚手架。首先得了解现有的项目结构和打包产物dist目录下的结构来进行适配

3.1 项目结构

image.png 对于活动h5这种多entry目录大家应该都不陌生,pages下的每一个目录都是一个entry,打包后,也要生成对应的目录方便增量发布(只发布这次开发的entry目录)

3.2 框架选型

核心构建框架vite,官网都推荐了,不浪费时间观察其他的了,而且高性能的vite版本Rolldown也在生成中。其他的包

  • react16 => react18 为啥不19,稳一波,好些UI组件和库都没支持呢
  • moment => dayjs 功能虽然少了,但是确实轻,满足绝大部分需求,个别部分可以通过自定义插件部分实现
  • lodash => es-toolkit 虽然有个loadsh-es,但是功能实现还是重了些,es-toolkit看了下star不少
  • ant-mobile@2 => ant-mobile@5 2的官网组件太少了,升级后react可能还不支持2的部分组件内使用的生命周期方法
  • npm install => pnpm install 本地和打包机上能节省存储和安装时间
  • node14 =》 node20以上,20以后的node做了大改动,速度快了很多

3.3 打包配置vite.config.ts

从四个方向来看构建工具配置

  • html 怎么适应不同的entry
  • css 支持less、css module
  • js ts转译=》js转换=》压缩
  • 静态资源处理,比如png, svg, font

3.31 html 怎么适应不同的entry

每一个entry可能会对html得处理不一样,比如rem的处理是基于375还是750,又或者需要对微信分享处理的entry,需要映入微信sdk,所以html就得设计成一个ejs模版文件,通过变量去注入。 vite默认处理html, 不管你有几个entry,最终只会生成一个html,所以为了达到3.1的要求,需要自己去实现htmlplugin 首先先确定entry的引入方式,按照cra,我们可以在src下建立一个view目录,然后我们可以这样引入entry view/index.ts

const DEMO750 = {
  'banner/202503/demo': {
    title: 'demo750页面',
    keywords = '视频',
    description = '社交软件',
    isWeChat = false,
    headFirst = [],
    headLast = [],
    bodyFirst = [],
    bodyLast = [],
    baseSize: 750
  }
}

const DEMO375 = {
  'banner/202503/demo2': {
    title: 'demo375页面',
    keywords = '视频',
    description = '社交软件',
    isWeChat = false,
    headFirst = [],
    headLast = [],
    bodyFirst = [],
    bodyLast = [],
    baseSize: 375
  }
}

const viewObj = {
  ...DEMO750,
  ...DEMO375
}

const views = []
Object.keys(viewObj).forEach((key) => {
  const view = viewObj[key]
  views.push({
    key,
    entry: `src/pages/${key}/index.ts`,
    filename: `${key}/index.html`,
    entryDir: `src/pages/${key}`,
    template: 'index.html', // 根目录下的模版文件
    data: view
  })
})

export default views

这样我们就可以拿到了带有配置的entry信息,再来看看我们的模版文件带有ejs语法的index.html文件长啥样

<!DOCTYPE html>
<html lang="en" data-prerendered="false" base-rem="750">
  <head>
    <%- headFirst %>
    <meta charset="utf-8" />
    <meta name="referer" content="never" />
    <link rel="shortcut icon" href="/favicon.png" />
    <meta name="keywords" content="<%= keywords %>" />
    <meta name="description" content="<%= description %>" />
    <meta http-equiv="x-dns-prefetch-control" content="on" />
    <!--DNS预获取-->
    <link rel="dns-prefetch" href="//unpkg.com" />
    <!--预链接-->
    <link rel="preconnect" href="//unpkg.com" />
    <!-- 分享三方后的缩略图 -->
    <meta property="og:image" content="/favicon.png" />
    <title><%= title %></title>

    <script>
      if (!window.Promise) {
        document.writeln('<script  src="/es6-promise.min.js"' + '>' + '<' + '/' + 'script>')
      }
    </script>
    <script src="/flexible.js"></script>
    <%- headLast %>
  </head>
  <body ontouchstart>
    <%- bodyFirst %>
    <webview-bridge-container style="display: none">
      <div id="share_title"></div>
      <div id="share_img"></div>
      <div id="share_url"></div>
    </webview-bridge-container>
    <div id="root"></div>
    <!-- 多entry的自定义模板处理 -->
    <%- bodyLast %>
  </body>
</html>

和你们项目下的模版html没啥区别,接下来就是重头戏html plugin实现了,这里区分开发环境下和生成环境

模版文件渲染

无论哪一个环境下html都需要把模版进行处理,所以单独抽成一个独立的函数

import { render } from 'ejs'
import { normalizePath as _normalizePath } from 'vite'

const INJECT_ENTRY = /<\/body>/

function slash(p: string): string {
  return p.replace(/\\/g, '/')
}

// Process the normalized path again
export function normalizePath(id: string) {
  if (id) {
    return id
  }
  const fsPath = slash(relative(process.cwd(), _normalizePath(`${id}`)))
  if (fsPath.startsWith('/') || fsPath.startsWith('../')) {
    return fsPath
  }
  return `/${fsPath}`
}

export async function renderHtml(
  html: string,
  pageOptions: {
    inject?: InjectOptions
    entry?: string
  },
  viteConfig: ResolvedConfig,
  env: Record<string, any>
) {
  const { inject, entry } = pageOptions
  const { data = {}, ejsOptions = {} } = inject || {}
  const ejsData: Data = {
    ...(viteConfig?.env ?? {}),
    ...(viteConfig?.define ?? {}),
    ...(env || {}),
    ...data
  }

  let result = await render(html, ejsData, ejsOptions)

  if (entry) {
    result = result.replace(
      INJECT_ENTRY,
      `<script type="module" src="${normalizePath(`${entry}`)}"></script>\n</body>`
    )
  }
  return result
}

这样我们就可以在两种不同的环境下的html plugin中调用模版渲染成正常html

开发环境下

不产出文件,根据dev server拿到对应的url路径去views中查找对应的entry配置,然后调用上面的模版渲染方法返回正常的html

import history from 'connect-history-api-fallback'
function createDevHtmlPlugin(pages: Pages): Plugin {
  // 开发环境
  let viteConfig: ResolvedConfig
  const input: Record<string, string> = {}
  let env: Record<string, any>
  const rewrites = []

  // 根据页面配置,生成输入文件路径映射
  pages.forEach((page) => {
    input[page.entry] = resolve(process.cwd(), page.template)
    rewrites.push({
      from: new RegExp(`${item.filename}`),
      to: item.filename
    })
  })

  return {
    name: 'vite-plugin-html',
    enforce: 'pre',
    config() {
      // 配置Vite构建选项,设置应用类型为多页面应用,并配置入口文件
      return {
        appType: 'mpa',
        build: {
          rollupOptions: {
            input
          }
        }
      }
    },
    async configResolved(config) {
      // 加载环境变量并解析配置
      viteConfig = config
      env = loadEnv(config.mode, process.cwd())
    },
    transformIndexHtml: {
      order: 'pre',
      async handler(html, ctx) {
        // 查找与当前URL匹配的页面配置
        const page = pages.find((page) => ctx.originalUrl?.includes(page.filename))
        if (!page) {
          // 如果找不到匹配的页面,返回空HTML和标签
          return {
            html: '',
            tags: []
          }
        }

        // 渲染HTML,注入页面特定的脚本和数据
        const _html = await renderHtml(
          html,
          {
            inject: page.inject,
            entry: page.entry
          },
          viteConfig,
          env
        )

        return {
          html: _html,
          tags: []
        }
      }
    },
    configureServer(server) {
      const { historyApiFallback, pages = [] } = options
      const { base } = viteConfig
      if (historyApiFallback?.rewrites) {
        rewrites = [...rewrites, ...historyApiFallback.rewrites]
        Reflect.deleteProperty(historyApiFallback, 'rewrites')
      } else {
        rewrites = genHistoryApiFallbackRewrites(base, pages)
      }

      server.middlewares.use(async (req, _res, next) => {
        const page = tryFindPage(req, rewrites, pages)
        if (page) {
          req.url = _normalizePath(`/${page.template}`)
        }
        next()
      })

      server.middlewares.use(
        history({
          disableDotRule: undefined,
          htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'],
          rewrites,
          ...historyApiFallback
        }) as Connect.NextHandleFunction
      )
    }
  }
}

这里核心逻辑就是,configureServer中处理我们在浏览器中访问的url,比如http://localhost:3001/banner/202503/demo/index.html 在这里的拿到的url就是/banner/202503/demo/index.html,然后我们找到对应entry中模版路径重定向到这个路径即可,再通过transformIndexHtml钩子中去把拿到的模版文件进行处理

生产环境下
const PREFIX = '\0virtual-entry:' // 虚拟路径前缀
function createBuildHtmlPlugin(pages: Views): Plugin {
  // 存储解析后的Vite配置
  let viteConfig: ResolvedConfig
  // 输入配置,用于Rollup构建配置,键为入口文件名,值为对应的输出文件路径
  const input: Record<string, string> = {}
  let env: Record<string, any>

  // 遍历页面配置,设置每个页面的输入输出路径
  pages.forEach((page) => {
    input[page.entry] = `${PREFIX}${page.filename}`
  })

  // 返回一个Vite插件对象
  return {
    name: 'vite-plugin-html',
    enforce: 'pre',
    // 配置插件的构建配置
    config() {
      return {
        build: {
          rollupOptions: {
            input
          }
        }
      }
    },
    // 在配置解析后,加载环境变量并进行页面HTML的渲染和写入
    async configResolved(config) {
      viteConfig = config
      env = loadEnv(config.mode, process.cwd())
    },
    resolveId(id) {
      return id.startsWith(PREFIX) ? resolve(process.cwd(), id.slice(PREFIX.length)) : undefined
    },
    load(id) {
      const page = pages.find((page) => {
        return id === resolve(process.cwd(), page.filename)
      })
      if (!page) return null

      const templateContent = fs.readFileSync(page.template, 'utf-8')

      return renderHtml(
        templateContent,
        {
          inject: page.inject,
          entry: page.entry
        },
        viteConfig,
        env
      )
    }
  }
}

生产环境不存在configureServer,在transformIndexHtml是拿不到对应url的,所以我们需要使用虚拟路径的技巧,在input里的每一个entry路径补上虚拟路径在resolveId阶段去掉,在真正加载文件的load阶段,找到我们的entry配置,调用上面的renderHtml返回对应的处理模版后的html文件内容

3.32 css 支持less、css module

vite对于css处理很简单,用啥装啥,配置都不需要改的,一般也就是改一下css module生成的命名方式,方便排查问题

import { getHashDigest, interpolateName } from 'loader-utils'
export default defineConfig(({ command, mode, isPreview }) => {
  return {
    mode,
    css: {
      modules: {
        scopeBehaviour: 'local',
        // css module 生成类名
        generateScopedName: (name, filename) => {
          const fileNameOrFolder = filename.match(/index\.module\.(css|scss|less)$/)
            ? '[folder]'
            : '[name]'

          const str: any = filename.replace(__dirname, '') + name
          // Create a hash based on a the file location and class name. Will be unique across a project, and close to globally unique.
          const hash = getHashDigest(str, 'md5', 'base32', 5)
          // Use loaderUtils to find the file or folder name
          const className = interpolateName(
            { resourcePath: filename, resourceQuery: '' } as any,
            fileNameOrFolder + '_' + name + '__' + hash
          )

          // // Remove the .module that appears in every classname when based on the file and replace all "." with "_".
          return className.replace('.module_', '_').replace(/\./g, '_')
        }
      }
    }
  }
})

3.33 js ts转译=》js转换=》压缩

import react from '@vitejs/plugin-react'
export default defineConfig(({ command, mode, isPreview }) => {
  return {
    mode,
    plugins: [
        react({
          babel: {
            plugins: [
              '@babel/plugin-transform-react-jsx',
              ['@babel/plugin-proposal-decorators', { legacy: true }],
              ['@babel/plugin-transform-class-properties', { loose: true }]
            ]
          }
        }),
        htmlPlugin(views)
    ]
  }
})

主要是引入babel包进行处理装饰器、jsx

3.34 静态资源处理

export default defineConfig(({ command, mode, isPreview }) => {
  return {
    mode,
    build: {
      // target: ['es2015', 'edge88', 'firefox78', 'chrome87', 'safari14'],
      assetsDir: '',
      rollupOptions: {
        output: {
          experimentalMinChunkSize: 10 * 1024, // 单位b 没有副作用,合并较小的模块
          entryFileNames: (entryItem) => {
            const name = entryItem.name || ''
            const pathName = name
              .replace('/index.html', '')
              .replace('src/pages/', '')
              .replace('.ts', '')
              .replace('.js', '')
              .replace(/\//g, '-')
            const newEntryFileName = `${name.replace(
              /.*index.(t|j)s$/,
              `static/js/runtime/${pathName}-[hash].js`
            )}`
            return newEntryFileName
          },
          manualChunks: (id) => {
            if (/(axios|redux|redux-thunk|react-redux|react-router|react-router-dom)/.test(id)) {
              return 'libs'
            }
          },
          assetFileNames: (assetItem) => {
            if (assetItem.names.join().includes('.css')) {
              if (assetItem.names.includes('index.css') && assetItem.originalFileNames.length) {
                const map = new Map<string, number>()
                let entryName = ''
                assetItem.originalFileNames.forEach((moduleId) => {
                  const key = moduleId.split('/').at(-2)
                  if (key && !map.has(moduleId)) {
                    map.set(key, 1)
                  }
                  if (!entryName && moduleId.includes('src/pages')) {
                    entryName += moduleId
                      .replace(/.*src\/pages\//, '')
                      .replace(new RegExp(`components.*|common.*|${key}.*`), '')
                  }
                })
                const path: string = `static/css/${entryName}${[...map].reduce(
                  (pre, cur) => `${pre}${pre ? '-' : ''}${cur[0].toLowerCase()}`,
                  ''
                )}-[hash][extname]`
                return path
              }
              return 'static/css/[name].[hash][extname]'
            }
            return 'static/media/[name].[hash][extname]'
          },
          chunkFileNames(chunkItem) {
            if (chunkItem.name === 'index' && chunkItem.moduleIds.length) {
              const map = new Map<string, number>()
              let entryName = ''
              chunkItem.moduleIds.forEach((moduleId) => {
                if (!moduleId.includes('src/pages')) {
                  return
                }
                const key = moduleId.split('/').at(-2)
                if (key && !map.has(moduleId)) {
                  map.set(key, 1)
                }
                if (!entryName && moduleId.includes('src/pages')) {
                  entryName += moduleId
                    .replace(/.*src\/pages\//, '')
                    .replace(new RegExp(`components.*|common.*|${key}.*`), '')
                }
              })
              const fileName = [...map].reduce(
                (pre, cur) => `${pre}${pre ? '-' : ''}${cur[0].toLowerCase()}`,
                ''
              )
              const path: string = `static/js/${entryName}${fileName || 'common'}-[hash].js`
              return path
            }
            return 'static/js/[name]-[hash].js'
          }
        }
      }
    },
  }
})

取消默认的assetsDir静态资源路径,通过entryFileNames, assetFileNames,chunkFileNames去重新生成,不然它会给你来个大杂烩,不屈分entry去对产物目录分类

image.png

至于manualChunks分包策略,各有所爱

结语

这样一个基本的vite h5脚手架就基本完成了,接下来的这几篇文章会继续加强这个脚手架,已经写完的文章会把文字换成链接,方便大家点开

  • 开发动态代理插件,即每一个entry都有自己的代理文件
  • 开发动态mock插件,即每一个entry都有自己的mock文件, 通过server.middlewares拦截req.url实现
  • 开发全局引入三分库插件,比如cdn上的react
  • 开发预渲染插件
  • 开发支持rem vw并存转换插件