文章

前端学习笔记其之二-REACT路由系统

为什么我们需要前端路由

和app不同,web协议栈在协议构造的初期阶段,就拥有一个非常重要的工具:网址。它可以方便地让用户直接访问对应的模块,避免因功能嵌套过深而导致的使用不便。用户可以将某些功能的URL快捷地存储在收藏栏中,开发者也可以利用这个工具来设计和组织软件架构。

之前的开发者是怎么做的

在早期,开发人员普遍将web app看成不同页面的组合。任何页面的请求都会发向后端,后端会进行后端路由,返回对应的界面。

package main

import (
	"github.com/gin-gonic/gin"
	"net/http"
)

func main() {
	// 创建Gin路由引擎
	router := gin.Default()

	// 1. API 路由组
	api := router.Group("/api")
	{
		api.GET("/ping", func(c *gin.Context) {
			c.JSON(http.StatusOK, gin.H{
				"message": "pong",
			})
		})
		
		api.POST("/echo", func(c *gin.Context) {
			var json struct {
				Message string `json:"message"`
			}
			if err := c.ShouldBindJSON(&json); err != nil {
				c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
				return
			}
			c.JSON(http.StatusOK, gin.H{"message": json.Message})
		})
	}

	// 2. 网页路由
	router.LoadHTMLGlob("templates/*")
	router.GET("/", func(c *gin.Context) {
		c.HTML(http.StatusOK, "index.html", gin.H{
			"title": "主页",
		})
	})

	router.GET("/about", func(c *gin.Context) {
		c.HTML(http.StatusOK, "about.html", gin.H{
			"title": "关于我们",
		})
	})

	// 3. 静态资源路由
	router.Static("/assets", "./assets")

	// 启动服务器
	router.Run(":8080")
}

前端请求不同的网页,网页之间根据不同的打包、组织方式,可以共享一些部分(比如头部和尾部(备案))。后端根据前端传入的路径拆分出参数部分来格式化html文件,前端收到html后再根据api提取数据(或者可以直接写在html里面,比如baidu的前端)。此后去静态资源中加载css等。

笔者想起了当年某公司实习的时候写asp的往事,这公司没有前端……只有ui和大后端。

闻者伤心见者落泪,但是layui写起来非常方便,坏处就是只学了三剑客的语法和layui搬砖方法,可以说是一点技术没学到。(类比crud boy,我就是layui boy

SAP的不同之处

以react为例。与传统的网页不同,应用完全是不以页面这种形式组织的,它更像是python+tkinker,浏览器加载整一个web app的代码,内部以代码控制渲染。

参考 platform ,后端仅提供数据api。前端完全是vite打包出来的单文件应用。我直接用caddy的静态路由实现前端服务的。

https://plat.intmian.com {
    # 添加请求改写和反向代理规则
    handle_path /api/* {
        rewrite * {path}
        reverse_proxy 127.0.0.1:这里是一个端口,但是安全起见我没写
    }

    handle {
        root * /data/front/
        try_files {path} /index.html
        file_server
    }

    tls [email protected]
}

也就是无论请求什么网页,什么路由返回的都是同一个页面。

因此,应用需要在内部进行路由处理。

REACT的路由

在单页应用中处理路由有几个需要解决的问题:

  1. 用组件代替传统的页面,如何根据路由渲染对应的组件,如何将参数传入组件。换言之,我们如何读取url。
  2. 为了让用户知道当前应用的路径,如何以非跳转的方式改变浏览器的url
  3. 多级路由如何解决共用问题,例如 /people/profile/*之间与 /people/create之间一定存在某些共用的部分

react-router-dom

react-router-dom 库中集成了一些路由的用法,我们可以用它实现路由。这个库有两种模式,这里以将路由抽出组件树的方法做例子。

假如我们有两个组件 <Index/><Debug/>,我们希望 /通向<Index/>debug通向 <Debug/>,我们可以这么写

import Index from "./admin/index.jsx";
import {createBrowserRouter, RouterProvider} from "react-router-dom";
import {Debug} from "./debug/debug.jsx";


const router = createBrowserRouter([
    {
        path: '/',
        element: <Index/>,
    },
    {
        path: '/debug',
        element: <Debug/>,
    }
])

const App = () => {
    return <>
        <RouterProvider router={router}/>
    </>;
};
export default App;

这样就达成了对应的功能。

如果希望在界面里访问参数

path: '/:mode'

如果用户用/test?a=1这样的行为访问

Index组件中可以使用

const { mode } = useParams();  // 获取URL中的id参数

访问路由参数

const [searchParams] = useSearchParams();  // 获取查询字符串参数对象
const a = searchParams.get('a');  // 获取参数 a 的值

这样的方式访问查询参数

useNavigate

如果希望实现一个切页系统,点击不同切页显示不同的内容,我们可以非常简单做出以下系统。

export function MenuPlus({disable, label2node}) {
    let items = [];
    const defaultSelectedKeys = label2node.keys().next().value;
    for (let [label, node] of label2node) {
        items.push(getItem(label, label));
    }
    const [nowSelected, setNowSelected] = useState(defaultSelectedKeys);
    const nowNode = label2node.get(nowSelected);
    const [collapsed, setCollapsed] = useState(false);

    return (
        <Layout>
            <Sider
                // width={200}
                // style={{
                //     background: '#fff',
                // }}
                collapsible
                style={{
                    minHeight: '100vh',
                }}
                collapsed={collapsed}
                onCollapse={(value) => setCollapsed(value)}
            >
                <Menu
                    disabled={disable}
                    mode="inline"
                    defaultSelectedKeys={['monitor']}
                    style={{
                        // 根据页面的总大小来设置高度
                        height: '100%',
                    }}
                    items={items}
                    onSelect={({key}) => {
                        setNowSelected(key);
                    }}
                    theme="dark"
                />
            </Sider>
            <Content>
                {nowNode}
            </Content>
        </Layout>
    );

但随着切页数量和嵌套层次的增加,用户会感到操作越来越复杂。

产生需求:如何让用户第一次访问对应功能后提供url,之后用户访问url,就可以直接对应到对应的功能。

分解需求后产生两个问题:

  1. 组件状态变更后如何改变url,比如前文代码中的 nowSelected变更后如何
  2. 这样的形式有没有办法有机的和渲染组合在一起,而非需要写一套内部跳转,又要写一套外部路由传入 nowSelected

useNavigate,是一个react-router-dom提供的功能,可以直接修改url,并且触发上层重新根据路由生成代码。他有一个replace参数,可以控制需不需要重新渲染。

在组件中引入以下代码

const navigate = useNavigate();
//...
navigate("/xxx", { replace: true })

就可以在执行时修改路由。

我们可以修改我们的MenuPlus组件为

export function MenuPlus({disable, label2node, baseUrl}) {
    const {mode} = useParams();
    let mode2 = mode;
    let items = [];
    const navigate = useNavigate();
    if (mode2 === undefined) {
        mode2 = label2node.keys().next().value;
    }
    for (let [label, node] of label2node) {
        items.push(getItem(label, label));
    }
    const nowNode = label2node.get(mode2);
    useEffect(() => {
        navigate(baseUrl + mode2, {replace: true});
    }, []);
    const [collapsed, setCollapsed] = useState(false);

    return (
        <Layout>
            <Sider
                // width={200}
                // style={{
                //     background: '#fff',
                // }}
                collapsible
                style={{
                    minHeight: '100vh',
                }}
                collapsed={collapsed}
                onCollapse={(value) => setCollapsed(value)}
            >
                <Menu
                    disabled={disable}
                    mode="inline"
                    defaultSelectedKeys={[mode2]}
                    style={{
                        // 根据页面的总大小来设置高度
                        height: '100%',
                    }}
                    items={items}
                    onSelect={({key}) => {
                        navigate(baseUrl + key, {replace: true});
                    }}
                    theme="dark"
                />
            </Sider>
            <Content>
                {nowNode}
            </Content>
        </Layout>
    );
}

这样就会根据切页修改url,url也能直接导航到对应的切页。

在路由配置中,需要添加以下内容

{
    path: '/debug/:mode',
	element: <Debug/>,
}

同时注意 useParams可以在Debug的子组件中,不是一定要在其之中。

选择采用navigate(baseUrl + key, {replace: true});的形式,主要是出于性能的考量。不需要重新渲染,也不需要存储切换切页的历史记录。

但是需要注意,不重新渲染不代表useParams()不会触发局部渲染,这个钩子函数的返回值也是组件的状态。

但是需要注意这种改动不是没有代价的。在测试中发现,频繁切换切页的渲染效率存在肉眼可见的降低,但是依然在可以接受的范畴内。

复杂的例子

在一些极端例子下,可以频繁使用navigate的replace模式修改url,而完全没有重新渲染的问题。这里给出一个url可以同步文档状态或者导航到文档位置的文档组件。

import React, { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';

function Documentation() {
  const navigate = useNavigate();
  const sectionsRef = useRef([]);
  useEffect(() => {
    const { section } = useParams();  // 获取当前的 section 参数
    if (section) {
      // 查找匹配的 DOM 元素并滚动到该位置
      const element = document.getElementById(section);
      if (element) {
        element.scrollIntoView({ behavior: 'smooth' });
      }
    }
  }, []);
    
  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            const sectionId = entry.target.id;
            navigate(`/docs/${sectionId}`, { replace: true });  // 仅更新 URL 而不重新渲染组件
          }
        });
      },
      { threshold: 0.5 }
    );

    sectionsRef.current.forEach((section) => {
      observer.observe(section);
    });

    return () => {
      observer.disconnect();
    };
  }, [navigate]);

  return (
    <div>
      <nav>
        <button onClick={() => navigate('/docs/section1')}>Section 1</button>
        <button onClick={() => navigate('/docs/section2')}>Section 2</button>
        <button onClick={() => navigate('/docs/section3')}>Section 3</button>
      </nav>
      <div id="section1" ref={(el) => (sectionsRef.current[0] = el)}>
        <h2>Section 1</h2>
        <p>Content of section 1...</p>
      </div>
      <div id="section2" ref={(el) => (sectionsRef.current[1] = el)}>
        <h2>Section 2</h2>
        <p>Content of section 2...</p>
      </div>
      <div id="section3" ref={(el) => (sectionsRef.current[2] = el)}>
        <h2>Section 3</h2>
        <p>Content of section 3...</p>
      </div>
    </div>
  );
}

export default Documentation;

这样我们就实现了一个类似于react中教程里url随位置滚动的功能。

嵌套路由

形如我们上文的代码,我们可以轻松地实现类似二级路由的功能,如果我们在route里面继续拓展甚至可以实现多级路由,但是我这里仅推荐在路由的最后一层使用这种切页组件“伪路由”。因为

  • 定死了路由的层数
  • 太过于复杂
  • 难以共用。

当让我们也可以继续拓展这个功能,在每一个中间组件都做好抽象分离,读取下层可能得拓展做解耦,实现可变层数。甚至将"路由树"抽象到最上层的某一种context中,但是我们完全没必要重复造轮子,因为已经有轮子了。

看一个示例:

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    errorElement: <ErrorPage />,
    children: [
      {
        path: "contacts/:contactId",
        element: <Contact />,
      },
    ],
  },
]);
import { Outlet } from "react-router-dom";

export default function Root() {
  return (
    <>
      {/* all the other elements */}
      <div id="detail">
        <Outlet />
      </div>
    </>
  );
}

库中提供的这种写法就可以实现子路由的功能,其中Outlet用来表示被Root包裹的子组件,有点类似于{children},使用这种方法可以很方便简单的实现多层路由或者网页共用头、尾的机制。

拓展

react写法和传统前端完全不一样,有写过jquery的朋友应该很能感受到区别。越来越庞大的前端促使了专业化框架出现。使用路由、渲染来维护前端应用,对于降低心智负担还是比较有帮助的。

本文仅介绍了一种路由的实现方式,还有另一种更加直观的写法供读者自行研究。此外路由中还有很多有趣的特性,也可以看看。这里推荐官方教程

简单的写写玩具也许看几篇简单的教程或者使用gpt学习就行,但是假如想要书写大型工程、性能调优、或者深入了解机制避免bug并实现更多功能,哪怕是官方的教程草草看一遍都是不够的。

我之前也经常进行野路子学习,就是简单看下教程就开始写。有些地方看的甚至走马观花,基本上能简单的融会贯通了就开始code。但是这样容易吃尽苦头,趟不知道多少雷。例如我在dev时发现所有的组件都会刷新两次,查了好几个小时,甚至看了源码。最后仔细看了教程才发现这是严格模式下的特性。

很多的概念流程没有梳理清楚研究透,就按照自己的理解来,建立非常狭隘的个人术语体系、知识模型,然后陷入“为什么这里可以”,“为什么这里不行”,然后面多加水水多加面,接口的两边都写个适配器,形成屎山代码。事实上使用ts+仔细研究react文档中的状态和渲染流程就可以避免这个问题(后面我会单独写一篇)。

人的直觉有时会与真相背道而驰。一定要避免经验主义,采用科学全面富含逻辑的分析才行。不能高看人的直觉和未经训练的思维能力,一定要严格遵守逻辑学和统计学,君不见宗教和传统医学都有如此多的拥趸。

推荐一下费曼学习法,学会某种知识体系之后,在一团乱麻的阶段,尝试交给别人。在这个过程中必然存在高屋建瓴自顶而下的知识体系构建与自下而上的知识体系重构。写完这篇博客我都感觉收获良多。

扯远了,打住。每篇博客屁股都变成哲学课了哈哈哈哈哈哈。

后言

今年过年左右特别的振奋,过年前看完了react的官方教程,过年放假的时候写了 platform的雏形,过年后又猛猛写,基本上把 platform 的基础雏形弄好了。后面又陆续在后端加了ai、账号管理等不少功能又重构来几次前端(没认真看文档,变学边做也没办法。功能复杂了,共用的地方解耦也是一个原因)。

本来打算写篇笔记用来记录如何从0到1构建react SAP的,但是后面工作上就变得很忙,哪怕空下来也都处于某一种心力憔悴的状态。

忙完了以后又开始研究了会虚幻,花了半个月研究了下入门。又在反复重构后端的爬虫模块,加上新增了几个大的系统,就没时间写。然后又被虚幻折磨的死去活来,让我非常怀疑我自己的编程能力。

职业和生活都迎来了比较重大的转机,也让我腾不出手。

这一切都结束,我发现我其实已经想到高程度的学会了react体系。我难以以某种初学者的视角自顶而下的写出一篇教程。

这种现象,我称之为知识的诅咒。知道的越多越难以向别人解释,因为过于复杂的知识体系纠缠在了一起,使人很难心平气和的整理所学会的一切。往者不可谏,来着犹可追。借这几天重构路由系统痛定思痛写了这篇。

Q:废话这么多,面桑你究竟在说什么呢?

A:我前面鸽了,我忏悔

License:  CC BY 4.0