Files
DefectingCat.github.io/pages/p/create-a-mini-router-for-react.mdx
DefectingCat 39c5711e75 Rename folder
2022-08-11 16:42:13 +08:00

329 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
title: 现代前端的Web应用路由-为React打造一个迷你路由器
date: '2022-08-03'
tags: [JavaScript, React]
---
import Layout from 'layouts/MDXLayout';
import Image from 'components/mdx/Image';
import image1 from 'assets/images/p/create-a-mini-router-for-react/router.webp';
import image2 from 'assets/images/p/create-a-mini-router-for-react/Web架构.svg';
import image3 from 'assets/images/p/create-a-mini-router-for-react/迷你路由器.svg';
import image4 from 'assets/images/p/create-a-mini-router-for-react/image-20210823154009498.webp';
export const meta = {
title: '现代前端的Web应用路由-为React打造一个迷你路由器',
date: '2022-08-03',
tags: ['JavaScript', 'React'],
};
export default ({ children }) => <Layout {...meta}>{children}</Layout>;
路由不仅仅只是网络的代名词,它更像是一种表达路径的概念。与网络中的路由相似,前端中的页面路由也是带领我们前往指定的地方。
<Image src={image1} placeholder="" priority />
## 现代前端的 Web 应用路由
时代在变迁过去Web 应用的基本架构使用了一种不同于现代路由的方法。曾经的架构通常都是由后端生成的 HTML 模板来发送给浏览器。当我们单击一个标签导航到另一个页面时,浏览器会发送一个新的请求给服务器,然后服务器再将对应的页面渲染好发过来。也就是说,每个请求都会刷新页面。
自那时起Web 服务器在设计和构造方面经历了很多发展前端也是。如今JavaScript 框架和浏览器技术已经足够先进,允许我们利用 JavaScript 在客户端渲染 HTML 页面,以至于 Web 应用可以采用更独特的前后的分离机制。在第一次由服务端下发了对应的 JavaScript 代码后,后续的工作就全部交给客户端 JavaScript。而后端服务器负责发送原始数据通常是 JSON 等。
<Image src={image2} alt="Web架构" />
在旧架构中,动态内容由 Web 服务器生成,服务器会在数据库中获取数据,并利用数据渲染 HTML 模板发送给浏览器。每次切换页面都会获取新的由服务端渲染的页面发送给浏览器。
在新架构中,服务端通常只下发主要的 JavaScript 和基本的 HTML 框架。之后页面的渲染就会由我们的 JavaScript 接管后续的动态内容也还是由服务器在数据库中获取但不同的是后续数据由服务器发送原始格式JSON 等)。前端 JavaScript 由 AJAX 等技术获取到了新数据,再在客户端完成新的 HTML 渲染。
好像 SPA 的大概就是将原先服务端干的活交给了前端的 JavaScript 来做。事实上确实如此。AJAX 技术的发展,带动了客户端 JavaScript 崛起,使得原先需要在服务端才能完成渲染的动态内容,现在交给 JavaScript 就可以了。
这么做的好处有很多:
- 主要渲染工作在客户端,减少服务器的压力。简单的场景甚至只需要静态服务器。
- 新内容获取只需要少量交互,而不是服务端发送渲染好的 HTML 页面。
- 可以利用 JavaScript 在客户端修改和渲染任意内容,同时无需刷新整个页面。
- ……
当然同时也有一些缺点,目前最主要的痛点:
- 首屏/白屏时间:由于 HTML 内容需要客户端 JavaScript 完成渲染,前端架构以及多种因素会影响首次内容的出现时间。
- 爬虫/SEO由于 HTML 内容需要客户端 JavaScript 完成渲染,早期的爬虫可能不会在浏览器环境下执行 JavaScript这就导致了根本爬取不到 HTML 内容。
> Google 的爬虫貌似已经可以爬取 SPA。
## React 的路由
React 是现代众多成熟的 SPA 应用框架之一它自然也需要使用路由来切换对应的组件。React Router 是一个成熟的 React 路由组件库,它实现了许多功能,同时也非常还用。
首先来看下本次路由的基本工作原理,本质上很简单,我们需要一个可以渲染任意组件的父组件 `<Router />` 或者叫 `<router-view>` 之类的。然后再根据浏览器地址的变化,渲染注册路由时对应的组件即可。
<Image src={image3} alt="迷你路由器" />
### 配置文件
这里选择类似 Vue Router 的配置文件风格,而不是使用类似 `<Route />` 这样的 DOM 结构来注册路由。我们的目的是为了实现一个非常简单的迷你路由器,所以配置文件自然也就很简单:
```ts
import { lazy } from 'react';
export default [
{
path: '/',
component: lazy(() => import('../pages/Home')),
},
{
path: '/about',
component: lazy(() => import('../pages/About')),
},
];
```
一个 `path` 属性,对应了浏览器的地址,或者说 `location.pathname`;一个 `component` 属性,对应了到该地址时所需要渲染的组件。
甚至还可以使用 `lazy()` 配合 `<Suspense>` 来实现代码分割。
### 展示路由
一个非常简单的路由就这样注册好了接下来就是将对应的组件展示出来。我们都知道JSX 最终会被 babel 转义为渲染函数,而一个组件的 `<Home />` 写法,基本等同于 `React.createElement(Home)`。[元素渲染 React (reactjs.org)](https://zh-hans.reactjs.org/docs/rendering-elements.html#updating-the-rendered-element)
所以动态的渲染指定的组件基本上也就很容易解决,接下来的思路也就很简单了,我们需要:
- 一个状态:记录当前地址 `location.pathname`
- 根据当前地址在配置文件中寻找对应注册的组件,并将它渲染出来;
- 一个副作用:当用户手动切换路由时,该组件需要重新渲染为对应注册的路由;
先不考虑切换路由的问题,前两个基本上已经就实现了:
```tsx
// Router.tsx
import React, { useState, Suspense } from 'react';
import routes from './routes';
const Router: React.FC = () => {
// 获取地址,并保存到状态中
const [path, setPath] = useState(location.pathname);
// 根据地址,寻找对应的组件
const element = routes.find((route) => route.path === path)?.component;
return (
<>
<Suspense fallback={<p>loading...</p>}>
{/* 使用 React.createElement() 渲染组件 */}
{element ? React.createElement(element) : void 0}
</Suspense>
</>
);
};
export default Router;
```
看上去很简单,事实上,确实很简单。
现在我们直接从地址栏访问对应的路由,我们的 Router 组件应该就可以根据已经注册好的路由配置文件来找到正确的组件,并将其渲染出来了。
<Image src={image4} alt="image-20210823154009498" />
### 切换路由
到目前为止,我们实现了根据对应地址访问到对应组件的功能。这是一个路由必不可少的功能,但它还不能称得上是一个简单的路由器,因为它还无法处理用户手动切换的路由,也就是点击标签前往对应的页面。
简单梳理一下我们需要实现的功能:
- 一个 Link 组件,用于点击后导航到指定的地址;
- 导航到地址后,还要修改浏览器的地址栏,并不真正的发送请求;
- 通知 Router 组件,地址已经改变,重新渲染对应路由的组件;
```tsx
import React from 'react';
interface Props {
to: string;
children?: React.ReactNode;
}
const RouterLink: React.FC<Props> = ({ to, children }: Props) => {
/**
* 处理点击事件
* 创建自定义事件监听器
* 并将 path 发送给 router
* @param e
*/
const handleClick = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
e.preventDefault();
const nowPath = location.pathname;
if (nowPath === to) return; // 原地跳转
history.pushState(null, '', to);
document.dispatchEvent(
new CustomEvent<string>('route', {
detail: to,
})
);
};
return (
<>
<a href="" onClick={handleClick}>
{children}
</a>
</>
);
};
export default RouterLink;
```
我们将 Link 组件实际的渲染为一个 `<a></a>` 标签,这样就能模拟跳转到指定的导航了。这个组件它接收两个参数:`{ to, children }`,分别是前往的路由地址和标签的内容。标签的内容 children 就是展示在 a 标签内的文本。
我们需要解决的第一个问题就是点击标签后跳转到指定的导航,这里其实需要分成两个部分,第一个部分是悄悄的修改浏览器地址栏,第二个部分则是通知 Router 组件去渲染对应的组件。
修改地址栏很简单,利用到浏览器的 history API可以很方便的修改 `pathName` 而不发送实际请求,这里只需要修改到第一个参数 to 即可:`history.pushState(null, '', to);`。
但使用 `pushState()` 并不会发出任何通知,我们需要自己实现去通知 Router 组件地址已经变化。本来像利用第三方库来实现一个 发布/订阅 的模型的,但这样这个路由器可能就没有那么迷你了。最后发现利用 HTML 的 CustomEvent 可以实现一个简单的消息订阅与发布模型。
HTML 自定义事件也很简单,我们在对应的 DOM 上 `dispatchEvent` 即可触发一个事件,而触发的这个事件,就是 `CustomEvent` 的实例,甚至还能传递一些信息。在这里,我们将路由地址传递过去。
```ts
document.dispatchEvent(
new CustomEvent<string>('route', {
detail: to,
})
);
```
而在 Router 组件中,只需要和以前一样在对应的 DOM 上去监听一个事件,这个事件就是刚刚发布的 `CustomEvent` 的实例。
```ts
// Router.tsx
const handleRoute = (e: CustomEvent<string>) => {
console.log(e.detail);
setPath(e.detail);
};
useEffect(() => {
/**
* 监听自定义 route 事件
* 并根据 path 修改路由
*/
document.addEventListener('route', handleRoute as EventListener);
return () => {
// 清除副作用
document.removeEventListener('route', handleRoute as EventListener);
};
}, []);
```
在 Router 组件中,根据接收到的变化,将新的地址保存到状态中,并触发组件重新渲染。
这样一个最简单的 React 路由就做好了。
## Vue 的路由
与 React 同理,二者的路由切换都差不多,其主要思路还是使用自定义事件来订阅路由切换的请求。但 Vue 的具体实现与 React 还是有点不同的。
### 配置文件
路由的配置文件还是同理不同的是Vue 的异步组件需要在引入时同时引入一个 Loading 组件来实现 Loading 的效果:
```ts
import { defineAsyncComponent } from 'vue';
import Loading from '../components/common/Loading.vue';
export default [
{
path: '/',
name: 'Home',
component: defineAsyncComponent({
loader: () => import('../views/Home.vue'),
loadingComponent: Loading,
}),
},
{
path: '/about',
name: 'About',
component: defineAsyncComponent({
loader: () => import('../views/About.vue'),
loadingComponent: Loading,
}),
},
];
```
### 展示路由
同理Vue 也是利用根据条件来渲染对应路由的组件。不同的是,我们可以使用模板语法来实现,也可以利用 `render()` 方法来直接渲染组件。
首先来看看和 React 类似的 `render()` 方法。在 Vue3 中,使用 `setup()` 方法后,可以直接返回一个 `createVNode()` 的函数,这就是 `render()` 方法。所以可以直接写 TypeScript 文件。
与 React 不同的地方在于React 每次调用 `setPath(e.detail)` 存储状态时都会重新渲染组件,从而重新执行组件的函数,获取到对应的路由组件。
但 Vue 不同,如果我们仅仅将路由名称 `e.detail` 保存到状态,但没有实际在 VNode 中使用的话,更新状态时不会重新渲染组件的,也就是说,不会获取到对应的路由组件。所以最佳的办法就是将整个路由组件保存到状态,可保存整个组件无疑太过庞大。好在 Vue3 给了我们另一种解决方法:`shallowRef()`。它会创建一个跟踪自身 `.value` 变化的 ref但不会使其值也变成响应式的。
```ts
import { createVNode, defineComponent, shallowRef } from 'vue';
import routes from './routes';
export default defineComponent({
name: 'RouterView',
setup() {
let currentPath = window.location.pathname;
const component = shallowRef(
routes.find((item) => item.path === currentPath)?.component ??
'Note found'
);
const handleEvent = (e: CustomEvent<string>) => {
console.log(e.detail);
currentPath = e.detail;
component.value =
routes.find((item) => item.path === currentPath)?.component ??
'Note found';
};
document.addEventListener('route', handleEvent as EventListener);
return () => createVNode(component.value);
},
});
```
而使用模板语法主要是利用到了全局的 `component` 组件,其他部分与 `render()` 方法相同:
```vue
<template>
<component :is="component"></component>
</template>
<script lang="ts" setup>
import { onUpdated, shallowRef } from 'vue';
import routes from './routes';
let currentPath = window.location.pathname;
const component = shallowRef(
routes.find((item) => item.path === currentPath)?.component
);
const handleEvent = (e: CustomEvent<string>) => {
console.log(e.detail);
currentPath = e.detail;
component.value = routes.find((item) => item.path === currentPath)?.component;
};
document.addEventListener('route', handleEvent as EventListener);
onUpdated(() => {
console.log(component);
});
</script>
```
## 总结
如今的 JavaScript 做能做到的比以前更加强大,配合多种 HTML API可以将曾经不可能实现的事变为现实。这个简单的迷你路由主要的思路就是利用 HTML API 来通知 Router 组件该渲染哪个组件了。配合上 `lazy()` 方法,甚至还能实现代码分割。
## Demo
[DefectingCat/react-tiny-router: A tiny react router. (github.com)](https://github.com/DefectingCat/react-tiny-router)