🖖 Vue2.x 改造 ⚡️ Vite

avatar
@哈啰出行

🚧 🚧 🚧 🚧 我更推荐你看 🖖 Vue2.x 改造 ⚡️ Vite - 2.0 🚧 🚧 🚧 🚧

2021-09-28


前言

  • vite 已经发布大半年了势头很猛,github 活跃度非常高
  • 2.x 开始加入了预编译,能够很好的兼容 CommonJs 模式,预编译后有着相当快的冷启动速度
  • 当系统维护越来越大启动速度就会越慢 @vue/cli 创建的项目(vue2.x)使用的 webpack@4.x 版本,这个问题愈发严重 是时候集成到“年迈” vue2 的老项目中了

📢 注意: 本次改造只推荐在开发模式下运行 vite 生产环境依然用之前的方式;毕竟 webpack 在打包方面更加成熟

项目背景

  • 本次改造的工程是公司一个很重要,迭代又很频繁的系统;现在已经有 100+ 张页面了
  • 工程模板由 @vue/cli 创建的 vue2.x 版本,内部使用 webpack4.x 构建
  • 随着项目越来越大(一年50增加张页面左右),对项目冷启动速度的追求就越显得迫切

技术分析

  • 虽然 vite 发展很快,npm 上面关于 vite 的插件也跟进的很快;但是总有一些鞭长莫及的情况出现在我们的老项目中

  • 这里我主要以我实际改造中碰到的问题做下技术总结,如果你在使用过程中还有碰到其他的问题,本文的解决问题思路也有一定的参考价值

  • 以下是我碰到的改造问题点

    1. 入口文文件需要指向 public/index.html ---- vite 启动入口
    2. 转换 @import '~normalize.css/normalize.css 中的 ~ 别名 ---- vite 报错
    3. 转换 import('@/pages/xxxx') ---- vite 警告、报错
    4. 转换 requireimport 形式 ---- vite 报错

vite-plugins

  • 我们对于 vite 工程的改造都是基于插件的,可以理解为就是写了好多插件解决对应问题
  • 你可能需要先了解下如何写一个 vite 插件
  • 这次几个关于转换的插件都是用的插件中的 transform 钩子,相对比较简单容易理解

public/index.html -> index.html

你可能已经注意到,在一个 Vite 项目中,index.html 在项目最外层而不是在 public 文件夹内。这是有意而为之的:在开发期间 Vite 是一个服务器,而 index.html 是该 Vite 项目的入口文件。

  • vite 入口文件为 html 文件(webpack为 js 文件);

为此我们需要将 public/index.html 作为入口,并且要正确处理 js 的引入

  • 这里我们通过插件的 configureServer 钩子做一层拦截并处理(public/index.htm),使其可以再 vite 中工作

  • template.ts

import fs from 'fs'
import path from 'path'
import { Plugin as VitePlugin } from 'vite'
import _template from 'lodash.template'

export function template(options: {
  /**
   * @default public/index.html
   */
  template?: string
  // 兼容 html-webpack-plugin 中的编译注入
  templateDate?: Record<string, unknown>
  // index.html 中的 js 文件入口
  entry?: string
}): VitePlugin {
  return {
    name: 'cxmh:template',
    configureServer(server) {
      server.middlewares.use((req, res, next) => {
        // 跳过非 index.html 请求
        if (!req.url?.endsWith('.html') && req.url !== '/') {
          return next()
        }

        const templatePath = options.template || path.join(process.cwd(), 'public/index.html')
        const entry = options.entry || '/src/main.js'
        try {
          // 读取 public/index.html
          let indexHtml = fs.readFileSync(templatePath, 'utf8')
          const compiled = _template(indexHtml, { interpolate: /<%=([\s\S]+?)%>/g })
          // 注入 html-webpack-plugin 变量
          indexHtml = compiled(options.templateDate)
          // 指定 src 入口
          indexHtml = indexHtml.split('\n')
            .map(line => line.includes('</body>')
              ? ` <script type="module" src="${entry}"></script>${line}`
              : line
            ).join('\n')
          res.end(indexHtml)
        } catch (error) {
          res.end(`<h2>${error}</h2>`)
        }
      })
    },
  }
}

转换 @import ~ 别名

  • gonzales-pe css AST 工具
  • node-source-walk css AST 遍历工具
  • style-import.ts
    import path from 'path'
    import { Plugin } from 'vite'
    import { convertVueFile } from './utils'
    import Walker from 'node-source-walk'
    import gonzales from 'gonzales-pe'
    
    export function styleImport(options?: Record<string, unknown>): Plugin {
      const walker = new Walker as any
      // 判断是否为 @import 语句
      const isImportStatement = (node) => {
        if (node.type !== 'atrule') { return false }
        if (!node.content.length || node.content[0].type !== 'atkeyword') { return false }
        const atKeyword = node.content[0]
        if (!atKeyword.content.length) { return false }
        const importKeyword = atKeyword.content[0]
        if (importKeyword.type !== 'ident' || importKeyword.content !== 'import') { return false }
        return true
      }
      // 去掉字符串两边的引号部分
      const extractDependencies = (importStatementNode) => {
        return importStatementNode.content
          .filter(function (innerNode) {
            return innerNode.type === 'string' || innerNode.type === 'ident'
          })
          .map(function (identifierNode) {
            return identifierNode.content.replace(/["']/g, '')
          })
      }
    
      return {
        enforce: 'pre',
        name: 'vite-plugin-vue2-compatible:styleImport',
        transform(code, id) {
          if (!id.endsWith('.vue')) return
          let _code = code
    
          try {
            // 提出所有的 @import 语句
            const imports = convertVueFile(code).styles.reduce((dependencies, cur) => {
              const ast = (gonzales as any).parse(cur.content, { syntax: cur.lang })
              let deps = dependencies
              walker.walk(ast, (node: any) => {
                if (!isImportStatement(node)) return
                deps = deps.concat(extractDependencies(node))
              })
              return deps
            }, [])
    
            // 转换 @import 语句中的 ~ 别名
            for (const importPath of imports) {
              if (importPath.startsWith('~')) {
                const node_modules = path.join(process.cwd(), 'node_modules')
                const targetPath = path.join(
                  path.relative(path.parse(id).dir, node_modules),
                  importPath.slice(1),
                )
                // Replace alias '~' to 'node_modules'
                _code = _code.replace(importPath, targetPath)
              }
            }
            return _code
          } catch (error) {
            throw error
          }
        },
      }
    }
    

转换 import('@/pages/xxxx')

  • 这个还是挺麻烦的,需要考虑两个点

    1. @ 这种别名替换 ---- vite 报错
    2. xxxx 动态路径分析 ---- vite 警告
  • 实现原理

    1. impot('@/pages/' + path) 本质上是将 pages 下的所有文件列举处理,然后生成一个 switch 提供匹配

    如有目录结构如下:

    src
      pages
        foo.vue
        bar/index.vue
    

    将会生成:

    function __variableDynamicImportRuntime__(path) {
      switch (path) {
        case '../pages/foo': return import('../pages/foo.vue');
        case '../pages/foo.vue': return import('../pages/foo.vue');
          break;
        case '../pages/bar': return import('../pages/bar/index.vue');
        case '../pages/bar/index': return import('../pages/bar/index.vue');
        case '../pages/bar/index.vue': return import('../pages/bar/index.vue');
          break;
      }
    }
    
    1. 参考链接 dynamic-import-vars
  • dynamic-import 代码有点长,完整代码 github 链接

require to import

  • 这个问题就是 CommonJs to ESModule 方案,npm 上面找了好几个包都没实现我的功能(要么不转化,要么注入环境变量报错); 索性自己写了一个简化版的,也算给自己拓宽下技术线路(不能吃现成的,得会自己做不是)

  • 技术选型

    1. acorn js 抽象语法树(AST)工具
    2. acorn-walk 语法树 遍历工具
  • 实现原理

    1. 先用 acorn 将代码转化为 AST
    2. 在使用 acorn-walk 遍历 AST 分析出 require 加载得文件,然后转换成 import 格式即可
  • cjs-esm 代码有点长,完整代码 github 链接

  • 基于 cjs-esm 写一个 vite-plugin-commonjs

    如果有代码如下

    const pkg = require('../package.json');
    
    const routers = [{
      path: '/foo',
      component: require('@/pages/foo.vue').default;
    }];
    

    将会生成:

    import pkg  from "../package.json";
    import _MODULE_default___EXPRESSION_object__ from "@/pages/foo.vue";
    
    const routers = [{
      path: '/foo',
      component: _MODULE_default___EXPRESSION_object__;
    }];
    

最后我们将所有插件打包到一个 npm 包中

注意:下面的配置可能需要结合你项目的情况做一些调整


import path from 'path'
import { defineConfig } from 'vite'
import { createVuePlugin } from 'vite-plugin-vue2'
import { vitePluginCommonjs } from 'vite-plugin-commonjs'
import {
  dynamicImport,
  styleImport,
  template,
} from 'vite-plugin-vue2-compatible'
import pkg from './package.json'

export default defineConfig({
  plugins: [
    /**
     * @Repository https://github.com/underfin/vite-plugin-vue2
     */
    createVuePlugin({
      jsx: true,
      jsxOptions: {
        compositionAPI: true,
      },
    }),
    /**
     * 处理 webpack 项目中 require 写法
     */
    vitePluginCommonjs(),
    /**
     * 兼容 import('@xxxx') 写法别名
     */
    dynamicImport(),
    /**
     * 兼容 @import alias
     * @import '~normalize.css/normalize.css'
     * ↓
     * @import 'node_modules/normalize.css/normalize.css'
     */
    styleImport(),
    /**
     * 使用 public/index.html
     */
    template({
      // 指向 @vue/cli 创建项目的 public/index.html 文件
      template: path.join(__dirname, 'public/index.html'),
      // 告诉 vite 的入口文件 index.html 加载哪个 js;既 webpack 配置中的 entry
      entry: '/src/main.js',
      // 兼容 html-webpack-plugin 中的编译注入
      templateDate: {
        BASE_URL: '/',
        htmlWebpackPlugin: {
          options: {
            title: pkg.name,
          },
        },
      },
    }),
  ],
  resolve: {
    alias: {
      // 同 webpack 中的 alias
      '@': path.join(__dirname, './src'),
    },
    // 同 webpack 中的 extensions
    extensions: ['.vue', '.ts', '.tsx', '.js', '.jsx', '.mjs'],
  },
  define: {
    // 同 webpack.DefinePlugin
    'process.env': process.env,
  }
})

运行

  1. npm i -D vite vite-plugin-vue2 vite-plugin-vue2-compatible
  2. 添加 packge.json 中 scripts 命令
{
  "scripts": {
+    "vite": "export NODE_ENV=development; vite"
  }
}
  1. npm run vite

🎉 Boom shakalaka!