1 登录鉴权与路由守卫

1.1 登录跳转

  • return_to属性标识了登录成功后所要返回的路由路径,有三种途径可以实现return_to

    • localStorage

      • 1
        2
        
        const returnTo = localStorage.getItem('return_to')
        router.push(returnTo || '/')
        
    • 状态管理(刷新会失效)

    • queryString,即/sign_in?return_to=/tags

      • 1
        2
        3
        
        router.push('/sign_in?return_to='+ encodeURIComponent(route.fullpath))
        const returnTo = route.query.return_to?.toString()
        router.push(returnTo || '/')
        
  • 记录跳过欢迎页的状态

    • 只要用户第一次跳过欢迎页,那么后面的欢迎页就自动跳过
    • How:需要在localStorage中定义SkipFeatures状态,一般值为时间戳,以确保用户能看到最新的广告(呸)
    • When:当用户点击跳过完成按钮时
  • 欢迎(广告)页需要添加路由守卫。当用户访问欢迎页路由时,如果SkipFeatures状态存在,那么自动跳转到首页

1.2 路由守卫

导航守卫 | Vue Router (vuejs.org)

  • src/config/routes.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
export const routes: RouteRecordRaw[] = [
  {
    path: "/welcome",
    component: Welcome,
    // 路由守卫
    beforeEnter: (to, from, next) => {
      // 还可以请求权限等,总之在进入路由之前的操作都可以在此进行
      localStorage.getItem("skipFeatures") !== null ? next("/start") : next();
    },
  },
	...
];

1.3 登录检查

  • 利用路由守卫,对记账等业务页面进行登录检查,否则跳转到登录页面
  • 以/items为例
1
2
3
4
5
6
7
8
9
{
	path: "/items",
	beforeEnter: async (to, from, next) => {
		await http.get("/me").catch(e => {
			next(`/sign_in?return_to=${to.path}`)
		});
		next()
	},
},
  • 登录后的每次请求,都应该将jwt放到请求头中
1
2
3
4
5
6
7
8
// src/shared/HttpClient.tsx
http.instance.interceptors.request.use((config) => {
  const token = localStorage.getItem("jwt");
  if (token) {
    config.headers!.Authorization = `Bearer ${token}`;
  }
  return config;
});
  • 对于除了//welcome/sign_in其他路由,都需要添加路由守卫来验证权限,出现大量重复的代码
  • 因此鉴权可以写在全局的路由守卫
  • 然后少数几个路由作为鉴权守卫的白名单
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// src/main.ts
...
const whiteList: Record<string, "exact" | "startsWith"> = {
  "/": "exact",
  "/start": "exact",
  "/welcome": "startsWith",
  "/sign_in": "startsWith",
};
// 全局路由守卫
// 返回true或false或url
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 http.get("/me").then(
  	() => true,
  	() => "/sign_in?return_to=" + to.path
  );
});
...
  • 问题:每次路由跳转都会请求当前用户,非常浪费资源

    • 解决:可以把请求的promise提取到最外层,只在第一次访问时请求,后面只是读取这个promise中的值
  • 问题:在登录后应该刷新当前用户,即刷新promise

    • 解决:封装me.ts,提供promise和refreshMe等接口
  • 最终方案

    • src/main.ts
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
    ...
    fetchMe();
    router.beforeEach((to, from) => {
      for (const key in whiteList) {
        const value = whiteList[key];
        return (value === "exact" && to.path === key) 
        || (value === "startsWith" && to.path.startsWith(key))
      }
      return mePromise?.then(
        () => true,
        () => "/sign_in?return_to=" + to.path
      );
    });
    ...
    
    • src/shared/me.tsx
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    import { AxiosResponse } from "axios";
    import { http } from "./HttpClient";
    export let mePromise: Promise<AxiosResponse<
            {resources: {id: number}}, any
          >> | undefined;
    export const refreshMe = () => {
      mePromise = http.get<{ resources: { id: number } }>("/me");
      return mePromise;
    };
    export const fetchMe = refreshMe;
    
    • src/views/SignInPage.tsx
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    const onSubmit = async (e: Event) => {
      ...
      if (!hasErrors(errors)) {
        ...
        localStorage.setItem("jwt", response.data.jwt);
        refreshMe();
        router.push(route.query.return_to?.toString() || "/");
      }
    };
    

2 自制前端mock系统

  • 思路:基于vite开发服务器与响应拦截器,如果识别到是mock请求(请求参数中有标识),则拦截响应,并返回假的响应数据

2.1 封装mock

  • 将mock封装为一个函数,接收响应体
    • 如果mock成功,则返回假数据,结束响应;如果mock失败,则返回false,不会影响正常响应;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// src/shared/HttpClient.tsx
http.instance.interceptors.response.use(
  (resp) => {
  	// 篡改response
    mock(resp);
    return resp;
  },
  (error) => {
    // 对请求失败的情况也尝试mock
    if (mock(error)) {
      // return表示错误已被解决
      return error;
    } else {
      // throw表示此处并没有解决错误
      throw error;
    }
  }
);

// src/mock/mock.tsx
import { AxiosResponse } from "axios";
import { mockSession } from "./mockSession";

export const mock = (response: AxiosResponse) => {
  // 如果不是本地开发环境的host,那么不会mock数据
  if (
    location.hostname !== "localhost" &&
    location.hostname !== "127.0.0.1" &&
    location.hostname !== "192.168.3.57"
  )
    return false;
  // _m就是mock的标识
  switch (response.config.params._m) {
    case "session":
      // 状态码和响应体就是mock出来的数据
      // 解构赋值 相当于
      // response.status = mockSession(response.config)[0]
      [response.status, response.data] = mockSession(response.config);
      return true;
  }
  return false;
};

// src/mock/mockSession.tsx
import { faker } from "@faker-js/faker";
import { AxiosRequestConfig } from "axios";

type Mock = (config?: AxiosRequestConfig) => [number, any];
faker.setLocale("zh_CN");

export const mockSession: Mock = () => {
  return [200, { jwt: faker.random.word() }];
};

2.2 使用mock

  • 以登录接口为例
1
2
3
4
5
6
// src/views/SignInPage.tsx
const response = await http
  .post<{ jwt: string }>("/session", formData, {
  	params: { _m: "session" }, // mock标识
  	timeout: 1000,// 超时时间,因为服务器压根没开,不用等太久
  })         
  • 以记账接口为例,获取全部tags
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// src/mock/mock.tsx
export const mock = (response: AxiosResponse) => {
  ...
  switch (response.config?.params._m) {
    case "tagIndex":
      [response.status, response.data] = mockTagIndex(response.config);
      return true;
  }
  return false;
};

// src/components/item/ItemCreate.tsx
setup(props, context) {
  // 挂载获取数据
  onMounted(async () => {
    const resp = await http.get<{ resources: Tag[] }>(
      "/tags",
      { kind: "expenses", _m: "tagIndex" },
      { timeout: 1000 }
    );
    refExpensesTags.value = resp.data.resources;
  });
  onMounted(async () => {
    const resp = await http.get<{ resources: Tag[] }>(
      "/tags",
      { kind: "income", _m: "tagIndex" },
      { timeout: 1000 }
    );
    refIncomeTags.value = resp.data.resources;
  });
  const refExpensesTags = ref<Tag[]>([]);
  const refIncomeTags = ref<Tag[]>([]);

// src/mock/mockTagIndex.tsx
type Mock = (config?: AxiosRequestConfig) => [number, any];
type CreateItem = (
  config: AxiosRequestConfig | undefined,
  n: number,
  attr?: any
) => Tag[];
// 自动生成tags
const createItem: CreateItem = (config, n = 1, attr) => {
  let id = 1;
  const createId = () => (id += 1);
  return Array.from({ length: n }).map(() => ({
    id: createId(),
    kind: config?.params.kind,
    sign: faker.internet.emoji(),
    name: faker.random.word(),
    ...attr,
  }));
};
export const mockTagIndex: Mock = (config) => {
  if (config?.params.kind === "expenses") {
    return [200, { resources: createItem(config, 20) }];
  } else {
    return [200, { resources: createItem(config, 10) }];
  }
};

3 封装Tags hook&组件

  1. 先把重复的数据请求逻辑抽离成hooks
  2. 再把html、css抽离为组件
  3. 也可以直接使用hooks返回组件

3.1 完善ItemCreate组件(加载更多)

  • 构造分页假数据
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// src/mock/mockTagIndex.tsx
...
// 自动生成pager
const per_page = 25;
const count = 26;
const createPager = (page = 1) => ({
  page,
  per_page,
  count,
});
export const mockTagIndex: Mock = (config) => {
  const { kind, page } = config?.params;
  if (kind === "expenses" && (!page || page === 1)) {
    return [
      200,
      {
        resources: createItem(kind, 25),
        pager: createPager(page),
      },
    ];
  } else if (kind === "expenses" && page === 2) {
    return [
      200,
      {
        resources: createItem(kind, 1),
        pager: createPager(page),
      },
    ];
  } else {
    return [200, { resources: createItem(kind, 10) }];
  }
};
  • 无论是onMounted还是onLoadMore,都是在请求数据,没有任何区别
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
const fetchTags = async () => {
	const resp = await http.get<Resources<Tag>>(
		"/tags",
		{
			kind: "expenses",
			_m: "tagIndex",
			page: refPage.value + 1,
		},
		{
			timeout: 1000,
		}
	);
	const { pager, resources } = resp.data;
	refExpensesTags.value.push(...resources);
	refPage.value += 1;
	refHasMore.value =
		(pager.page - 1) * pager.per_page + resources.length < pager.count;
};
// 此时
onMounted(fetchTags)
onLoadMore = fetchTags
  • 于是可以将请求数据的逻辑封装为hooks

3.2 useTags

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/hooks/useTags.tsx
type Fetcher = (page: number) => Promise<AxiosResponse<Resources<Tag>>>;
export const useTags = (fetcher: Fetcher) => {
  // 接收数据请求函数
  const page = ref(0);
  const hasMore = ref(false);
  const tags = ref<Tag[]>([]);
  const fetchTags = async () => {
    const resp = await fetcher(page.value);
    const { pager, resources } = resp.data;
    tags.value.push(...resources);
    page.value += 1;
    hasMore.value =
      (pager.page - 1) * pager.per_page + resources.length < pager.count;
  };
  // 第一次挂载请求数据
  onMounted(fetchTags);
  // 将变量返回出去
  return {
    page,
    hasMore,
    tags,
    fetchTags,
  };
};

3.3 Tags组件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import { defineComponent } from "vue";
import { Icon } from "./Icon";
import { useTags } from "../hooks/useTags";
import { http } from "./HttpClient";
import { Button } from "./Button";
import s from "./Tags.module.scss";
export const Tags = defineComponent({
  props: {
    kind: {
      type: String,
      required: true,
    },
  },
  setup(props, context) {
    const { kind } = props;
    const { fetchTags, tags, hasMore } = useTags((page) => {
      return http.get<Resources<Tag>>(
        "/tags",
        {
          kind,
          _m: "tagIndex",
          page: page + 1,
        },
        {
          timeout: 1000,
        }
      );
    });
    return () => (
      <>
        <div class={s.tags_wrapper}>
          <div class={s.tag}>
            <div class={s.sign}>
              <Icon name="add" class={s.createTag} />
            </div>
            <div class={s.name}>新增</div>
          </div>
          {tags.value.map((tag) => (
            <div class={[s.tag, s.selected]}>
              <div class={s.sign}>{tag.sign}</div>
              <div class={s.name}>{tag.name}</div>
            </div>
          ))}
        </div>
        <div class={s.more}>
          {hasMore.value ? (
            <Button onClick={fetchTags}>加载更多</Button>
          ) : (
            <span>没有更多</span>
          )}
        </div>
      </>
    );
  },
});

3.4 小问题

  • 在切换“支出”与“收入”的Tab时,组件并没有切换

  • 这是因为组件被缓存了,Vue 发现组件名称相同的组件,会更新组件属性,而不是切换

  • 解决方案

    1. 两个Tags组件分别添加key属性加以区分,但每次切换都要重新请求数据,影响交互体验
    2. 通过v-show切换
    1
    2
    3
    4
    5
    6
    
    // src/shared/Tabs.tsx
    <div>
      {nodeArr.map((item) => (
        <div v-show={item.props?.name === props.selected}>{item}</div>
      ))}
    </div>