项目 i 记账 Vue3 + TSX + Rails 总结

543 阅读7分钟

项目介绍

技术选型

Vue3 + TSX + Rails

Vue3 + TSX 的优点

  • 像使用 React 一样使用 Vue3,但体验比React更好
  • 大部分前端还没采用这种搭配,可以享受踩坑的乐趣

Vue3 + TSX 的缺点

  • 无法利用模板的性能优化:静态提升,修补标记,树结构打平,加速 SSR 激活

项目架构

Snipaste_2022-12-01_10-11-17.png

设计稿

链接

用到的编程语言

  • Ruby
  • TypeScript
  • JavaScript(ES 6+)

搭建开发环境

两种环境

2.png

问:如果开发环境与生产环境系统不一致会不会有 bug 呢?

答:可能会有 bug,如缺少依赖库、API 不兼容等

为了避免这种问题,尽量保证环境一致。

推荐使用 Docker

优点:可以在 WindowsmacOSLinux 里运行 Linux 容器

Docker + VScode 使用

在 VScode 中打开文件进入开发环境

搭建前端项目

Snipaste_2022-12-11_13-10-48.png 项目需要在持久化文件中运行

用到的命令

  • pnpm create vite i-tally-fe -- --template vue-ts 创建项目
  • code i-tally-fe VS Code 打开
  • pnpm install 初始化
  • pnpm run dev 运行
  • pnpm run build 打包
  • pnpm run preview 打包后预览
  • npm config set save-prefix=''锁死版本号

用到的知识点

用到的文档

slot 使用

import { defineComponent } from "vue";
import s from "./welcomeLayout.module.scss";

export const WelcomeLayout = defineComponent({
  setup: (props, context) => {
    const { slots } = context;
    return () => (
      <div class={s.wrapper}>
        <div class={s.card}>
          {slots.icon?.()} // 在子组件中放入 slot
          {slots.text?.()}
        </div>
        <div class={s.actions}>{slots.buttons?.()}</div>
      </div>
    );
  },
});
import { defineComponent } from "vue";
import icon from "@/assets/icons/pig.svg";
import s from "./welcomeLayout.module.scss";
import { RouterLink } from "vue-router";
import { WelcomeLayout } from "./WelcomeLayout";
export const First = defineComponent({
  setup: (props, centext) => {
    return () => (
      <WelcomeLayout>
        {{ // 使用 slot
          icon: () => <img src={icon} class={s.icon} />,
          text: () => (
            <h2 class={s.text}>
              会挣钱
              <br />
              还要会省钱
            </h2>
          ),
          buttons: () => (
            <>
              <RouterLink class={s.fake} to="/start">
                跳过
              </RouterLink>
              <RouterLink to="/welcome/2">下一页</RouterLink>
              <RouterLink to="/welcome/start">跳过</RouterLink>
            </>
          ),
        }}
      </WelcomeLayout>
    );
  },
});

Vue Router 使用

import { RouteRecordRaw } from "vue-router";
import { First } from "../components/welcome/First";
import { Fourth } from "../components/welcome/Fourth";
import { Second } from "../components/welcome/Second";
import { Third } from "../components/welcome/Third";
import { Welcome } from "../views/Welcome";

export const routes: RouteRecordRaw[] = [
  { path: "/", redirect: "/welcome" }, // redirect 重定向
  {
    path: "/welcome",
    component: Welcome,
    children: [ // children 子路由
      { path: "", redirect: "/welcome/1" },
      { path: "1", component: First },
      { path: "2", component: Second },
      { path: "3", component: Third },
      { path: "4", component: Fourth },
    ],
  },
];

实现 RouterView 中加入 Transition

aef913e9-eacb-47e6-8644-55dc4303036a.gif

import { defineComponent, Transition, VNode } from 'vue'
import { RouteLocationNormalizedLoaded, RouterView } from 'vue-router'
import s from '@/views/welcome.module.scss'
import logo from '@/assets/icons/wallet.svg'
export const Welcome = defineComponent({
  setup: (props, context) => {
    return () => (
      <div class={s.wrapper}>
        <header class={s.header}>
          <img src={logo} class={s.logo} />
          <h1 class={s.text}>i 记账</h1>
        </header>
        <main class={s.main}>
          <RouterView name="main">
            {({
              Component: X,
              route: R,
            }: {
              Component: VNode
              route: RouteLocationNormalizedLoaded
            }) => (
              <Transition
                enterFromClass={s.slide_fade_enter_from}
                enterActiveClass={s.slide_fade_enter_active}
                leaveToClass={s.slide_fade_leave_to}
                leaveActiveClass={s.slide_fade_leave_active}
              >
                {X}
              </Transition>
            )}
          </RouterView>
        </main>
        <footer class={s.footer}>
          <RouterView name="footer" />
        </footer>
      </div>
    )
  },
})
// scss
.slide_fade_enter_active,
.slide_fade_leave_active {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  transition: all 0.5s ease-out;
}

.slide_fade_enter_from {
  transform: translateX(100vw);
}
.slide_fade_leave_to {
  transform: translateX(-100vw);
}

路由改为

export const routes: RouteRecordRaw[] = [
  { path: "/", redirect: "/welcome" },
  {
    path: "/welcome",
    component: Welcome,
    children: [
      { path: "", redirect: "/welcome/1" },
      { path: "1", components: { main: First, footer: FirstActions } },
      { path: "2", components: { main: Second, footer: SecondActions } },
      { path: "3", components: { main: Third, footer: ThirdActions } },
      { path: "4", components: { main: Fourth, footer: FourthActions } },
    ],
  },
];

使用 Transition 时遇到的 bug

12月22日.gif

当点击下一页时,页面中会出现滚动条

原因:退场动画还没结束,进场动画就进来了,导致动画效果重叠,页面展示效果出现问题

解决方法:可以在加入 Transition 动画的父元素上加 overflow: hidden;,还可以在 <Transition></Transition> 标签上加 mode="out-in" 属性,根据具体需求选择

自制 Vite SVG Sprites 插件

由于上面是使用 <img src={logo} class={s.logo} /> 加载 svg 的,所以 svg 是在页面渲染后再请求 svg 图片的,会导致 svg 渲染时间有延迟

所以自制 Vite SVG Sprites 插件可以解决该问题

1、安装 svgo 和 svgstore

 // sh
pnpm i svgo
pnpm i svgstore

2、创建 svgstore.js 文件

/* eslint-disable */
import path from 'path'
import fs from 'fs'
import store from 'svgstore' // 用于制作 SVG Sprites
import { optimize } from 'svgo' // 用于优化 SVG 文件

export const svgstore = (options = {}) => {
  const inputFolder = options.inputFolder || 'src/assets/icons';
  return {
    name: 'svgstore',
    resolveId(id) {
      if (id === '@svgstore') {
        return 'svg_bundle.js'
      }
    },
    load(id) {
      if (id === 'svg_bundle.js') {
        const sprites = store(options);
        const iconsDir = path.resolve(inputFolder);
        for (const file of fs.readdirSync(iconsDir)) {
          const filepath = path.join(iconsDir, file);
          const svgid = path.parse(file).name
          let code = fs.readFileSync(filepath, { encoding: 'utf-8' });
          sprites.add(svgid, code)
        }
        const { data: code } = optimize(sprites.toString({ inline: options.inline }), {
          plugins: [
            'cleanupAttrs', 'removeDoctype', 'removeComments', 'removeTitle', 'removeDesc', 
            'removeEmptyAttrs',
            { name: "removeAttrs", params: { attrs: "(data-name|data-xxx)" } }
          ]
        })
        return `const div = document.createElement('div')
div.innerHTML = \`${code}\`
const svg = div.getElementsByTagName('svg')[0]
if (svg) {
  svg.style.position = 'absolute'
  svg.style.width = 0
  svg.style.height = 0
  svg.style.overflow = 'hidden'
  svg.setAttribute("aria-hidden", "true")
}
// listen dom ready event
document.addEventListener('DOMContentLoaded', () => {
  if (document.body.firstChild) {
    document.body.insertBefore(div, document.body.firstChild)
  } else {
    document.body.appendChild(div)
  }
})`
      }
    }
  }
}

3、tsconfig.node.json 加入

{
  ... ,
  "include": [... , "src/vite_plugins/**/*"]
}

4、vite.config.ts 使用 svgstore()

// @ts-nocheck
import { svgstore } from './src/vite_plugins/svgstore.js';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    ... , 
    svgstore()
  ]
})

5、全局引用该文件

import '@svgstore'

这时控制台 Element 会在页面中加载一个 div 元素 里面就是这网页所有的 svg,在页面渲染时,所有的 svg 也会被加载,但不会显示在页面中

1.png

6、使用 svg

<svg>
    <use xlinkHref="#pig" /> // # 后面加的是文件名
</svg>

使用 useSwipe.tsx 在手机页面中左滑实现页面滑动

import { computed, onMounted, onUnmounted, ref, Ref } from 'vue'

type Point = { x: number; y: number }
type Options = {
  beforeStart?:(e:TouchEvent)=>void
  afterStart?:(e:TouchEvent)=>void
  beforeMove?:(e:TouchEvent)=>void
  afterMove?:(e:TouchEvent)=>void
  beforeEnd?:(e:TouchEvent)=>void
  afterEnd?:(e:TouchEvent)=>void
}
export const useSwipe = (element: Ref<HTMLElement | undefined>,options?:Options) => {
  const start = ref<Point>()
  const end = ref<Point>()
  const swiping = ref(false)
  const distance = computed(() => {
    if (!start.value || !end.value) {
      return
    }

    return {
      x: end.value.x - start.value.x,
      y: end.value.y - start.value.y,
    }
  })
  const direction = computed(() => {
    if (!distance.value) {
      return ''
    }
    const { x, y } = distance.value

    if (Math.abs(x) > Math.abs(y)) {
      return x > 0 ? 'right' : 'left'
    } else {
      return y > 0 ? 'down' : 'up'
    }
  })
  const onStart = (e: TouchEvent) => {
    options?.beforeStart?.(e)
    start.value = {
      x: e.touches[0].clientX,
      y: e.touches[0].clientY,
    }
    end.value = undefined
    swiping.value = true
    options?.afterStart?.(e)
  }
  const onMove = (e: TouchEvent) => {
    options?.beforeMove?.(e)
    if (!start.value) {
      return
    }
    end.value = {
      x: e.touches[0].clientX,
      y: e.touches[0].clientY,
    }
    options?.afterMove?.(e)
  }
  const onEnd = (e: TouchEvent) => {
    options?.beforeEnd?.(e)
    swiping.value = false
    options?.afterEnd?.(e)
  }
  onMounted(() => {
    if (!element.value) {
      return
    }
    element.value?.addEventListener('touchstart', onStart)
    element.value?.addEventListener('touchmove', onMove)
    element.value?.addEventListener('touchend', onEnd)
  })
  onUnmounted(() => {
    if (!element.value) {
      return
    }
    element.value?.removeEventListener('touchstart', onStart)
    element.value?.removeEventListener('touchmove', onMove)
    element.value?.removeEventListener('touchend', onEnd)
  })
  return {
    swiping,
    distance,
    direction,
  }
}

加入 节流 throttle.tsx

export const throttle = (fn: Function, time: number) => {
  let timer: number | undefined = undefined
  return (...args: any[]) => {
    if (timer) {
      return
    } else {
      fn(...args)
      timer = window.setTimeout(() => {
        timer = undefined
      }, time)
    }
  }
}

改造 throttle 代码

export const throttle = <T extends ((...args: unknown[]) => any)>(fn: T, time: number) => {
  let timer: number | undefined = undefined
  let result: ReturnType<T>
  return (...args: Parameters<T>) => {
    if (timer) {
      return result
    } else {
      result = fn(...args)
      timer = window.setTimeout(() => {
        timer = undefined
      }, time)
      return result
    }
  }
}

使用

const pushMap: Record<string, string> = {
  welcome1: '/welcome/2',
  welcome2: '/welcome/3',
  welcome3: '/welcome/4',
  welcome4: '/start',
}
export const Welcome = defineComponent({
  setup: (props, context) => {
    const main = ref<HTMLElement>()
    const { swiping, direction } = useSwipe(main, {
      beforeStart: (e) => e.preventDefault(),
    })
    const route = useRoute()
    const router = useRouter()
    const replace = throttle(() => {
      const name = (route.name || 'welcome1').toString()
      router.replace(pushMap[name])
    }, 500)
    watchEffect(() => {
      if (swiping.value && direction.value === 'left') {
        replace()
      }
    })

表单验证 validate.tsx

type FDate = {
  [k: string]: string | number | null | undefined | FDate
}
type Rule<T> = {
  key: keyof T
  message: string
} & ({ type: 'required' } | { type: 'pattern'; regex: RegExp })
type Rules<T> = Rule<T>[]
export type { FDate, Rule, Rules }
export const validate = <T extends FDate>(formDate: T, rules: Rules<T>) => {
  type Errors = {
    [k in keyof T]?: string[]
  }
  const errors: Errors = {}
  rules.map((rule) => {
    const { key, type, message } = rule
    const value = formDate[key]
    switch (type) {
      case 'required':
        if (value === null || value === undefined || value === '') {
          errors[key] = errors[key] ?? []
          errors[key]?.push(message)
        }
        break
      case 'pattern':
        if (value && !rule.regex.test(value.toString())) {
          errors[key] = errors[key] ?? []
          errors[key]?.push(message)
        }
        break
      default:
        return
    }
  })
  return errors
}

使用

<form> 上监听 onSubmit 事件

const formData = reactive({
      name: '',
      sign: '',
    })
    const errors = reactive<{ [k in keyof typeof formData]?: string[] }>({})
    const onSubmit = (e: Event) => {
      const rules: Rules<typeof formData> = [
        { key: 'name', type: 'required', message: '必填' },
        {
          key: 'name',
          type: 'pattern',
          regex: /^.{1,4}$/,
          message: '只能填 1 到 4 个字符',
        },
        {
          key: 'sign',
          type: 'required',
          message: '必填',
        },
      ]
      Object.assign(errors, {
        name: undefined,
        sign: undefined,
      })
      Object.assign(errors, validate(formData, rules))
      e.preventDefault()
      console.log(errors)
    }

vite 做代理转发

在 vite.config.js 中添加

export default defineConfig(({ command }) => {
  return {
     server: {
      proxy: {
        '/api/v1': { // 访问的路径
          target: 'http://121.196.236.94:8080/' // 转发的网址
        }
      }
    },
  }

封装 axios

  1. 封装了中间层 Http.tsx,增强对 axios 的控制
import axios, { AxiosError, AxiosHeaders, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'

type GetConfig = Omit<AxiosRequestConfig, 'params' | 'url' | 'method'>
type PostConfig = Omit<AxiosRequestConfig, 'url' | 'data' | 'method'>
type PatchConfig = Omit<AxiosRequestConfig, 'url' | 'data'>
type DeleteConfig = Omit<AxiosRequestConfig, 'params'>

export class Http {
  instance: AxiosInstance
  constructor(baseURL: string) {
    this.instance = axios.create({
      baseURL,
    })
  }
  get<R = unknown>(url: string, query?: Record<string, JSONValue>, config?: GetConfig) {
    return this.instance.request<R>({
      ...config,
      url: url,
      params: query,
      method: 'get',
    })
  }
  post<R = unknown>(url: string, data?: Record<string, JSONValue>, config?: PostConfig) {
    return this.instance.request<R>({ ...config, url, data, method: 'post' })
  }
  patch<R = unknown>(url: string, data?: Record<string, JSONValue>, config?: PatchConfig) {
    return this.instance.request<R>({ ...config, url, data, method: 'patch' })
  }
  delete<R = unknown>(url: string, query?: Record<string, string>, config?: DeleteConfig) {
    return this.instance.request<R>({
      ...config,
      url: url,
      params: query,
      method: 'delete',
    })
  }
}
  1. 做统一错误处理 --- 429 请求太频繁
// 在开发模式下 
function isDev() {
  if (location.hostname !== 'localhost' && location.hostname !== '127.0.0.1' && location.hostname !== '192.168.1.7') {
    return false
  }
  return true
}

export const http = new Http(isDev() ? '/api/v1' : 'http://************/api/v1')

// 统一错误处理
http.instance.interceptors.response.use( 
  (response) => response,
  (error) => {
    if (error.response) {
      const axiosError = error as AxiosError
      if (axiosError.response?.status === 429) {
        alert('你太频繁了')
      }
    }
    throw error
  }
)
  1. 向后端发请求时,做统一的加载中动画
import { closeToast, showLoadingToast } from 'vant'

http.instance.interceptors.request.use((config) => {
  if (config._autoLoading === true) {
    showLoadingToast({
      message: '加载中...',
      forbidClick: true,
      duration: 0,
    })
  }
  return config
})

http.instance.interceptors.response.use(
  (response) => {
    closeToast()
    return response
  },
  (error) => {
    throw error
  }
)

使用 jwt 登录

我写的另一篇文章中有讲到

第一次打开应用才会出现广告

在 VurRouter 中实现,skipFeatures 为判断是否为第一次进入。

export const routes: RouteRecordRaw[] = [
  {
    path: '/welcome',
    component: () => import('../views/WelcomePage'),
    beforeEnter: (to, from, next) => {
      localStorage.getItem('skipFeatures') === 'yes' ? next('/item') : next()
    },
  }  
]

登录鉴权和路由守卫

需求:只要没有登录,就不能进行记账功能。

鉴权

import { http } from '@/shared/Http'
import { AxiosResponse } from 'axios'
import { defineStore } from 'pinia'

type MeState = {
  me?: User
  mePromise?: Promise<AxiosResponse<Resource<User>>>
}

type MeActions = {
  refreshMe: () => void
  fetchMe: () => void
}

export const useMeStore = defineStore<string, MeState, {}, MeActions>('me', {
  state: () => ({
    me: undefined,
    mePromise: undefined,
  }),
  actions: {
    refreshMe() {
      this.mePromise = http.get<Resource<User>>('/me')
    },
    fetchMe() {
      this.refreshMe()
    },
  },
})

只有在白名单里的路由在不用检查,其他都要检查是否未登录状态。

// 白名单 
const whiteList: Record<string, 'exact' | 'startsWith'> = {
  '/': 'exact',
  '/item': 'exact',
  '/welcome': 'startsWith',
  '/sign_in': 'startsWith',
}

router.beforeEach((to, from) => {
  for (const key in whiteList) {
    const value = whiteList[key]
    if (value === 'exact' && to.path === key) {
      return true
    }
    if (value === 'startsWith' && to.path.startsWith(key)) {
      return true
    }
  }
  return meStore.mePromise!.then(
    () => true,
    () => '/sign_in?return_to=' + to.path
  )
})

只要登陆了就把 jwt 放到请求头里

http.instance.interceptors.request.use((config) => {
  const jwt = localStorage.getItem('jwt')
  if (jwt) {
    ;(config.headers as AxiosHeaders).set('Authorization', `Bearer ${jwt}`)
  }
  if (config._autoLoading === true) {
    showLoadingToast({
      message: '加载中...',
      forbidClick: true,
      duration: 0,
    })
  }
  return config
})

前端 mock

在后端还没部署好代码,你已经写完了前端代码,需要调试时,可以使用 mock ,造一些假的接口和数据。 在使用 vite 请求任何路径都有结果,都可以成功,不会报 404 错误。所以,接口路径有了,但是响应不对,我们可以用 axios 拦截器将响应拦截,改成自己想要的数据。

// 登录 mock
import { AxiosRequestConfig } from 'axios'
import { faker } from '@faker-js/faker'
type Mock = (config: AxiosRequestConfig) => [number, any]
export const mockSession: Mock = (config) => {
  return [
    200,
    {
      jwt: faker.random.word(),
    },
  ]
}
const mock = (response: AxiosResponse) => {
        if (
          (location.hostname !== 'localhost' &&
            location.hostname !== '127.0.0.1' &&
            location.hostname !== '192.168.1.7')
        ) {
          return false
        }
        switch (response.config?.params?._mock) {
          case 'session':
            ;[response.status, response.data] = mockSession(response.config)
            return true
        }
        return false

http.instance.interceptors.response.use(
        (response) => {
        // 篡改 response
          mock(response)
          if (response.status >= 400) {
            throw { response }
          } else {
            return response
          }
        },
        (error) => {
          mock(error.response)
          if (error.response.status >= 400) {
            throw error
          } else {
            return error.response
          }
        }
      )

Pinia 全局状态管理

我写了一篇文章有讲

import { http } from '@/shared/Http'
import { AxiosResponse } from 'axios'
import { defineStore } from 'pinia'

type MeState = {
  me?: User
  mePromise?: Promise<AxiosResponse<Resource<User>>>
}

type MeActions = {
  refreshMe: () => void
  fetchMe: () => void
}

export const useMeStore = defineStore<string, MeState, {}, MeActions>('me', {
  state: () => ({
    me: undefined,
    mePromise: undefined,
  }),
  actions: {
    refreshMe() {
      this.mePromise = http.get<Resource<User>>('/me')
    },
    fetchMe() {
      this.refreshMe()
    },
  },
})

前端部署

想办法把 build base 配置正确,Vite 中使用的是 base ,在 vite.config.ts 文件中,加入

export default defineConfig({
    base:'/xxx', // xxx 为路径名
})

在使用其他脚手架时,base 参数可能不一样,可能为 build urlbuild pathhomepage 等。如果发现 build 后,预览出现 404 错误,就要想上面的参数是否写错。

Axios 跨域

  • Nginx反向代理

如果请求的路径是/api开头,后端直接转发到:3000/api

  • CORS配置
// 如果是开发阶段,直接访问 /api/v1 
// 如果是生产环境,需要加上域名
export const http = new Http(isDev() ? '/api/v1' : 'http://121.196.236.94:3000/api/v1')

后端需要允许前端的域名访问

性能优化:优化前端打包

  • dynamic import 动态导入
优化前
{  // 路由中
 path: '/tag',
    component: TagPage, // 直接写组件名
    redirect: '/tag/create',
    children: [
      { path: 'create', component: TagCreate },
      { path: ':id/edit', component: TagEdit },
    ],
  },
优化后
// TagPage.tsx
export default TagPage
{ // 路由中
    path: '/tag',
    component: () => import('../views/TagPage'), // 动态导入
    redirect: '/tag/create',
    children: [
      { path: 'create', component: TagCreate },
      { path: ':id/edit', component: TagEdit },
    ],
  },

所有动态引入的组件会单独打包,并在路由跳转时,才会加载文件。

  • Rollup chunk 优化

在 vite 中有讲

export default defineConfig(({ command }) => {
  return {
  ...,
    build: {
      rollupOptions: {
        output: {
          manualChunks(id: any) {
            if (id.includes('echarts')) {
              return 'echarts';
            }
            if (id.includes('mock') || id.includes('faker')) {
              return 'mock';
            }
            if (id.includes('vant')) {
              return 'vant';
            }
            if (id.includes('node_modules')) {
              return 'vendor';
            }
          }
        }
      }
    },
  }
}

会将 build 文件再细分。

在生产阶段,mock将不会打包,减少代码体积

export default defineConfig(({ command }) => {
  return {
    define: command === 'build' ? {
      DEBUG: false
    } : {
      DEBUG: true
    },
  }
}