项目从Vue-cli 切换到 Vite

1,183 阅读3分钟

背景

项目中使用了 Vue-cli,实际上用的就是 Webpack,但是 Webpack 在项目启动和热更新的时候会有点慢,而 Vite 目前热度高并且速度快,趁有空将项目从Vue-cli 切换到 Vite。

转换工具

我使用的是 wp2vite 转换工具,支持将 Webpack 转为 Vite。

# 安装
npm install -g wp2vite

# 运行
wp2vite

wp2vite 命令运行完成后,会在根目录生成 vite.config.js 文件,运行一下项目,不出意外收获一片报错,接下来就是解决报错啦。

缺少扩展名

Vite 引入文件都是不加扩展名的,会报 [vite] Internal server error: Failed to resolve import "./c-dialog" from xxx 的错,需要配置:

resolve: {
  extensions: ['.vue', '.js', '.json']
}

缺少 alias

wp2vite 转换后会自动加上 alias 别名,如 @vue$,但我在项目的css中使用了 ~@/assets/icons/xxx.svg 这样的引入方式,而 Vite 不会解析 '~' 符号。转换一下思路,我可以给 ~@ 加到 alias 上:

resolve: {
  '@': path.resolve(__dirname, './src'),
  '~@': path.resolve(__dirname, './src'),
}

配置 less 全局变量

css: {
  preprocessorOptions: {
    less: {
      modifyVars: {
        variables: `true;@import '${path.resolve('./src/assets/styles/variables.less')}';`
      },
      // 支持内联 JavaScript
      javascriptEnabled: true
    }
  }
}

将require改为import

由于 Vite 不支持 require 引入文件,需要将 require 全部改为 import,如:

require('script-loader!jsonlint') 

// =》改为
import jsonlint from 'jsonlint-mod'
window.jsonlint = jsonlint

html 插入变量

在项目中,有将配置文件和构建时间插入到 html,Webpack 写法:

<script src="<%= htmlWebpackPlugin.options.configFile %>"></script>

<script>
  console.log('build: ' + '<%= new Date().getTime() %>')
</script>

转为 Vite 后,我自己写了个插件简单地兼容一下:

const htmlPlugin = () => {
  // 定义变量
  const define = {
    buildTime: new Date().getTime(),
    configFile: 'xxx.js'
  }
  return {
    name: 'html-transform',
    transformIndexHtml (html) {
      return html.replace(
        /<%=(\s*)(.*?)(\s*)%>/img,
        ($1, $2, $3) => {
          return define[$3] || $1
        })
    }
  }
}

...
...
plugins: [
  htmlPlugin()
]

那么在 html 文件中使用:

<script src="<%= configFile %>"></script>

<script>
  console.log('build: ' + '<%= buildTime %>')
</script>

svg-icon 组件兼容

使用 vite-plugin-svg-icons 插件对 svg-icon 组件进行兼容:

import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'

...
...

plugins:[
  // 插件作用:使用<svg-icon />组件
  createSvgIconsPlugin({
    // svg图标所在目录
    iconDirs: [path.resolve(process.cwd(), 'src/assets/svg-icons')],
    // 执行icon name的格式
    symbolId: 'icon-[dir]-[name]',
    svgoOptions: {
      plugins: [
        // 添加插件移除 svg 中的 fill 属性
        {
          name: 'removeAttrs',
          params: {
            attrs: 'fill'
          }
        },
        // 设置默认不删除stroke
        {
          name: 'preset-default',
          params: {
            overrides: {
              removeUselessStrokeAndFill: false
            }
          }
        }
      ]
    }
  })
]

svg-icon 组件:

<template>
  <svg class="svg-icon" :style="getStyle" :class="[link && 'is-link']">
    <use :xlink:href="`#icon-${name}`" :fill="isHover && hoverColor ? hoverColor : color" />
  </svg>
</template>

<script>
import 'virtual:svg-icons-register'

export default {
  name: 'SvgIcon',
  props: {
    name: {
      type: String,
      required: true
    },
    color: {
      type: String
    },
    hoverColor: {
      type: String
    },
    width: {
      type: String
    },
    height: {
      type: String
    },
    size: {
      type: Number
    },
    link: {
      type: Boolean,
      default: false
    }
  },
  data () {
    return {
      isHover: false,
      once: true
    }
  },
  computed: {
    getStyle () {
      const width = typeof this.width === 'number' ? this.width + 'px' : this.width
      const height = typeof this.height === 'number' ? this.height + 'px' : this.height
      const size = typeof this.size === 'number' ? this.size + 'px' : this.size
      return {
        width,
        height,
        fontSize: size
      }
    }
  },
  methods: {
    addEvent () {
      if (!this.$el || !this.once || !this.hoverColor) return

      this.$el.addEventListener('mouseenter', this.onMouseenter, false)
      this.$el.addEventListener('mouseleave', this.onMouseleave, false)
      this.once = false
    },
    removeEvent () {
      if (!this.$el) return

      this.$el.removeEventListener('mouseenter', this.onMouseenter)
      this.$el.removeEventListener('mouseleave', this.onMouseleave)
    },
    onMouseenter () {
      this.isHover = true
    },
    onMouseleave () {
      this.isHover = false
    }
  },
  watch: {
    hoverColor: {
      immediate: true,
      handler () {
        this.$nextTick(() => {
          this.addEvent()
        })
      }
    }
  },
  beforeDestroy () {
    this.removeEvent()
  }
}
</script>

<style lang="less" scoped>
.svg-icon {
  width: 1em;
  height: 1em;
  vertical-align: -0.15em;
  fill: currentColor;

  &.is-link {
    cursor: pointer;
  }
}
</style>

不足: 使用 vite-plugin-svg-icons 插件,会在首屏的时候将所有svg图标一次性引入,没办法做到按需引入。

Vite 打包问题

使用 Vite 打包后,我发现有些文件大小超乎我的想象,1M 左右的文件都有好几个,那我先装个文件分析工具看看吧。

import visualizer from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    vue(),
    visualizer({
      open:true, 
      gzipSize: true,
      brotliSize: true
    })
  ],
})

经过分析,我发现第三方库都打包到一起了,那么,只要将第三方库的文件拆分出来就行了。

build: {
  rollupOptions: {
    output: {
      manualChunks (id) {
        if (id.includes('node_modules')) {
          const arr = id.toString().split('node_modules/')[1].split('/')
          switch (arr[0]) {
            case 'vue':
            case 'vue-router':
            case 'vuex':
              return 'vueBase'
            default:
              return '_' + arr[0]
          }
        }
      }
    }
  }
}

完整配置

/* eslint-disable */
import legacyPlugin from '@vitejs/plugin-legacy'
import * as path from 'path'
import {
  createVuePlugin
} from 'vite-plugin-vue2'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import visualizer from 'rollup-plugin-visualizer'

// @see https://cn.vitejs.dev/config/

const htmlPlugin = () => {
  // 定义变量
  const define = {
    buildTime: new Date().getTime(),
    configFile: 'xxx.js'
  }
  return {
    name: 'html-transform',
    transformIndexHtml (html) {
      return html.replace(
        /<%=(\s*)(.*?)(\s*)%>/img,
        ($1, $2, $3) => {
          return define[$3] || $1
        })
    }
  }
}

export default ({
  command,
  mode
}) => {
  process.env.VUE_APP_ENV = mode

  let proxy = {
    '/api': {
      "target": "http://demo.api.com:8081",
      "logLevel": "debug",
      "changeOrigin": true
    }
  }

  // todo 替换为原有变量
  let define = {
    'process.env.NODE_ENV': command === 'serve' ? '"development"' : '"production"',
    'process.env.VUE_APP_ENV': `"${mode}"`
  }

  return {
    base: './', // index.html文件所在位置
    root: './', // js导入的资源路径,src
    resolve: {
      alias: {
        'vue$': 'vue/dist/vue.runtime.esm.js',
        '@': path.resolve(__dirname, './src'),
        '~@': path.resolve(__dirname, './src'),
      },
      extensions: ['.vue', '.js', '.json']
    },
    define: define,
    server: {
      // 代理
      proxy
    },
    build: {
      target: 'es2015',
      minify: 'terser', // 是否进行压缩,boolean | 'terser' | 'esbuild',默认使用terser
      manifest: false, // 是否产出maifest.json
      sourcemap: false, // 是否产出soucemap.json
      outDir: 'release/dist', // 产出目录
      rollupOptions: {
        output: {
          manualChunks (id) {
            if (id.includes('node_modules')) {
              const arr = id.toString().split('node_modules/')[1].split('/')
              switch (arr[0]) {
                case 'vue':
                case 'vue-router':
                case 'vuex':
                  return 'vueBase'
                default:
                  return '_' + arr[0]
              }
            }
          }
        }
      }
    },
    plugins: [
      htmlPlugin(),
      createVuePlugin(),
      legacyPlugin({
        targets: ['Android > 39', 'Chrome >= 60', 'Safari >= 10.1', 'iOS >= 10.3', 'Firefox >= 54', 'Edge >= 15'],
      }),
      // 插件作用:使用<svg-icon />组件
      createSvgIconsPlugin({
        // 指定要缓存的图标文件夹
        iconDirs: [path.resolve(process.cwd(), 'src/assets/svg-icon')],
        // 执行icon name的格式
        symbolId: 'icon-[dir]-[name]',
        svgoOptions: {
          plugins: [
            // 添加插件移除 svg 中的 fill 属性
            {
              name: 'removeAttrs',
              params: {
                attrs: 'fill'
              }
            },
            // 设置默认不删除stroke
            {
              name: 'preset-default',
              params: {
                overrides: {
                  removeUselessStrokeAndFill: false
                }
              }
            }
          ]
        }
      }),
      visualizer({
        open: true,
        gzipSize: true,
        brotliSize: true
      })
    ],
    css: {
      preprocessorOptions: {
        less: {
          modifyVars: {
            variables: `true;@import '${path.resolve('./src/assets/styles/variables.less')}';`
          },
          // 支持内联 JavaScript
          javascriptEnabled: true
        }
      }
    }
  }
}

遇到的问题

  • 不支持 contentHash,打包时不能根据内容生成hash,没办法利用浏览器缓存

  • 没有找到图片转为base64的办法

  • Vite 打包时文件拆分没有 Webpack 那么智能

  • svg-icon 组件引入的图标无法按需加载

结尾

Vite 改造后,项目启动速度达到秒开级别,但是首屏页面加载需要10s左右,由于 Vite 的缓存,首屏之后页面加载和热更新几乎都是很快且无感知的。