项目介绍
技术选型
Vue3 + TSX + Rails
Vue3 + TSX 的优点:
- 像使用 React 一样使用 Vue3,但体验比React更好
- 大部分前端还没采用这种搭配,可以享受踩坑的乐趣
Vue3 + TSX 的缺点:
- 无法利用模板的性能优化:静态提升,修补标记,树结构打平,加速 SSR 激活
项目架构
设计稿
用到的编程语言
- Ruby
- TypeScript
- JavaScript(ES 6+)
搭建开发环境
两种环境
问:如果开发环境与生产环境系统不一致会不会有 bug 呢?
答:可能会有 bug,如缺少依赖库、API 不兼容等
为了避免这种问题,尽量保证环境一致。
推荐使用 Docker
优点:可以在 Windows、 macOS、Linux 里运行 Linux 容器
在 VScode 中打开文件进入开发环境
搭建前端项目
项目需要在持久化文件中运行
用到的命令
pnpm create vite i-tally-fe -- --template vue-ts创建项目code i-tally-feVS 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
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
当点击下一页时,页面中会出现滚动条
原因:退场动画还没结束,进场动画就进来了,导致动画效果重叠,页面展示效果出现问题
解决方法:可以在加入 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 也会被加载,但不会显示在页面中
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
- 封装了中间层 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',
})
}
}
- 做统一错误处理 --- 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
}
)
- 向后端发请求时,做统一的加载中动画
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 url、 build path、 homepage 等。如果发现 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 优化
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
},
}
}