[]

基本使用

安装react-router

~ pnpm add react-router-dom
~ pnpm add @types/react-router-dom -D

路由页面

// src/pages/Home.tsx
const Home: FC = () => <div>Home Page</div>;
export default Home;
// src/pages/About.tsx
const About: FC = () => <div>About Page</div>;
export default About;

嵌套路由

// src/pages/User.tsx
type UserParams = { username: string };
const User: FC = () => {
    const { username } = useParams<UserParams>();
    return <div>current user is {username}</div>;
};

const UsersIndex: FC = () => {
    const match = useRouteMatch();
    return (
        <div>
            <ul>
                <li>
                    <Link to={`{match.url}`}>User List</Link>
                </li>
                <li>
                    <Link to={`{match.url}/pincman`}>Pincman</Link>
                </li>
            </ul>
            <Switch>
                <Route path={`${match.path}/:username`}>
                    <User />
                </Route>
                <Route path={match.path}>
                    <h3>Please select a user.</h3>
                </Route>
            </Switch>
        </div>
    );
};
export default UsersIndex;

链接菜单

// src/components/Menu.tsx
const Menu: FC = () => (
    <div>
        <ul>
            <li>
                <Link to="/">Home</Link>
            </li>
            <li>
                <Link to="/about">About</Link>
            </li>
            <li>
                <Link to="/users">Users</Link>
            </li>
        </ul>
    </div>
);
export default Menu;

构建路由

// src/App.tsx
...
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
const App: FC = () => {
    return (
        <Router>
            <div className="app flex items-center bg-white dark:bg-gray-800 flex-col place-content-between dark:text-white">
                <Menu />
                <div className="flex justify-center">
                    <Switch>
                        <Route path="/about">
                            <About />
                        </Route>
                        <Route path="/users">
                            <UsersIndex />
                        </Route>
                        <Route path="/">
                            <Home />
                        </Route>
                    </Switch>
                </div>
                <footer>footer</footer>
            </div>
        </Router>
    );
};
export default App;

路由模式

路由模式有真实地址hash两种,真实地址在生产环境下需要服务器软件URL重写(如: nginx)的支持,其生成的urlexample.com/some/path这样的格式.而hash则不需要,只要有CDN的地方扔上去就可以,其url看上去是这样example.com/#/your/page

真实地址通过BrowserRouter组件生成,hash模式通过HashRouter生成,他们可以通过使用Router这个底层组件自由切换,如下

安装history

history版本必须为4.x.x,因为react-router v5版本还不支持history v5

~ pnpm add history@"<5.0.0"
// src/App.tsx
const App: FC<{ hash?: boolean }> = ({ hash }) => {
    const historyCreator = hash ? createHashHistory() : createBrowserHistory();
    return (
        <Router history={historyCreator}>
        ...
        </Router>
    );
};

现在如果需要hash模式,可以这样

// src/main.tsx
ReactDOM.render(
    <React.StrictMode>
        <App hash />
    </React.StrictMode>,
    document.getElementById('root'),
);

当然也可以把hash选项放到状态管理(比如我们前面讲得context+userReducer或者后续将会讲到的redux,[mobx][]等)中手动更换,这样会更灵活方便,甚至还可以把这个配置放到localstorage中储存!

精确匹配

Route组件用于根据URL路径来匹配路由,Switch组件则用于匹配第一个路由,如果在Route列表外部不包装Switch则会匹配所有符合条件的路由,试一下把App.tsx中的Switch组件去除,然后访问/about,这时Home页面也会呈现,因为/about同时匹配了/

由于Switch总是匹配第一个符合的路由,那么如果我们把Home放到最前面会发生什么?这将导致我们访问任何其它路由都只会显示Home页面,这时候就需要为/路由添加添加一个exact参数来设置精确匹配了

// src/App.tsx
const App: FC<{ hash?: boolean }> = ({ hash }) => {
  ...
                <div className="flex justify-center">
                    <Switch>
                        <Route exact path="/">
                            <Home />
                        </Route>
                        <Route path="/about">
                            <About />
                        </Route>
                        <Route path="/users">
                            <UsersIndex />
                        </Route>
                    </Switch>
                </div>
};

这时访问/则只会匹配Home页面了

路由导航

导航组件

一般导航用Link就可以了,但是在需要对匹配的链接添加一个CSS类的时候可以使用NavLink(如导航栏),具体使用如下

<NavLink to="/react" activeClassName="actived">
  React
</NavLink>

路由跳转

Redirect组件用于设置跳转路由,如下

// src/App.tsx
<Switch>
  ...
    <Route path="/users">
        <UsersIndex />
    </Route>
    <Redirect path="/test" to="/" />
</Switch>

在导航链接上添加/test

// src/components/Menu.tsx
...
<ul>
    <li>
        <Link to="/test">redirect to home</Link>
    </li>
</ul>

编程式导航

通过useHistory这个hook可以获得history对象,通过这个对象可以实现push,replace等方式的手动跳转

const Menu: FC = () => {
    const history = useHistory();
    return (
        <div>
            <Button onClick={() => history.push('/about')}>跳转到About</Button>
            ...
        </div>
    );
};
export default Menu;

代码分割

动态导入

通过动态导入的方式实现代码分割,可以使每个组件和页面只在需要的时候加载

动态导入需要安装@loadable/component这个库

~ pnpm add @loadable/component
~ pnpm add @types/loadable__component -D

现在可以尝试一下动态导入About页面

// src/App.tsx
const About = loadable(() => import('./pages/About'));

当我们从http://localhost:4000导航到/about时,开发者工具->networks中会显示一个异步加载的About.tsx页面,表示已经异步加载

对于每个页面都进行一次loadable的HOC包装是一件非常繁琐的事情,为了简单,可以一次性把所有需要的页面进行代码分割出去,并通过页面名称的字符串来动态加载页面,在vitejs下使用glob的方式实现,而cra或者webpack等可以直接使用import(./${props.page})的方式

vitejs通过const pages = import.meta.glob('../../pages/**/*.{tsx,jsx}')这样的语法可加载pages目录下的所有页面并最终生成如下代码

const modules = {
  './dir/foo.js': () => import('./dir/foo.js'),
  './dir/bar.js': () => import('./dir/bar.js')
}

我们在添加一个函数,作用是通过正则去除前缀路径,比如../../pages/和后缀,比如.tsx,.jsx等,就获得最终的动态导入对象

// src/components/Router/view.tsx
const getAsyncImports = (imports: Record<string, () => Promise<any>>, reg: RegExp) => {
    return Object.keys(imports)
        .map((key) => {
            const names = reg.exec(key);
            return Array.isArray(names) && names.length >= 2
                ? { [names[1]]: imports[key] }
                : undefined;
        })
        .filter((m) => !!m)
        .reduce((o, n) => ({ ...o, ...n }), []) as unknown as Record<string, () => Promise<any>>;
};
const pages = getAsyncImports(
    import.meta.glob('../../pages/**/*.{tsx,jsx}'),
    /..\/..\/pages\/([\w+.?/?]+).tsx|.jsx/i,
);

最终pages对象会像这样

如果生产环境下,后缀会是jsx

const modules = {
  Home: () => import('../../pages/Home.tsx')
  About: () => import('../../pages/About.tsx'),
  'users/Index': () => import('../../pages/users/Index.tsx')
}

现在loadable('Home')就会异步加载Home页面了

export const AsyncPage = ({ page }: { page: string }) => {
    const View = loadable(pages[page], {
        cacheKey: () => page.replaceAll('/', '-'),
    });
    return <View />;
};

修改App.tsx试一下

<Switch>
<Route exact path="/">
    <AsyncPage page="Home" />
</Route>
<Route path="/about">
    <AsyncPage page="About" />
</Route>
<Route path="/users">
    <AsyncPage page="users/Index" />
</Route>
    <Redirect path="/test" to="/" />
</Switch>

添加”加载中”组件

为了保证不会因为loadding组件的每次计算而导致页面总是被重新渲染,使用useMemo缓存

const Loading: FC = () => (
    <div className="fixed w-full h-full top-0 left-0 dark:bg-white bg-gray-800 bg-opacity-25 flex items-center justify-center">
        <span>加载中</span>
    </div>
);

export const AsyncPage: FC<{ page: string; loading?: JSX.Element }> = ({ page, loading }) => {
    const fallback = useMemo(() => loading ?? <Loading />, []);
    const View = loadable(pages[page], {
        cacheKey: () => page.replaceAll('/', '-'),
        fallback,
    });
    return <View />;
};

以上代码看不出效果,可以使用p-min-delay来做一下延迟测试(请注意: 生产环境中不要添加延迟),并且需要设置一下超时

~ pnpm add p-min-delay promise-timeout
~ pnpm add @types/promise-timeout -D

代码

const ViewHoc = loadable(() => timeout(pMinDelay(pages[page](), 500), 12000), {
    cacheKey: () => page.replaceAll('/', '-'),
    fallback,
});

render函数

[react router][]加载组件页面有三种方案

  • component加载,如<Route path="/home" component={Home} />
  • children方式加载,如<Route path="/home"><Home /></Route>
  • render加载,如<Route path="/home" render={Home} />

通过componentrender加载,可像页面传递route props,而component每次都会创建新的渲染实例,所以一般会选择render函数来加载页面

首先定义一下类型

export interface PageOptions {
    page: string;
    loading?: JSX.Element;
}

export type PageProps<
    // 传递给Page的额外参数
    T extends Record<string, any> = Record<string, any>,
    Path extends string = string,
    Params extends { [K: string]: string | undefined } = ExtractRouteParams<Path, string>,
> = PageOptions &
    T & {
        route: RouteComponentProps<Params>;
    };

export interface RouteProps
    extends PageOptions,
        Omit<BaseRouterProps, 'children' | 'component' | 'render'> {
    // 传递给Page的额外参数
    params?: Record<string, any>;

改造AsyncPage

export const AsyncPage: FC<PageProps> = ({ page, loading, ...rest }) => {
    ...
    return <View {...rest} />;
};

定义一个AsyncRoute作为异步路由组件

export const AsyncRoute: FC<RouteProps> = (props) => {
    const { page, loading, params = {}, ...rest } = props;
    return (
        <Route
            {...rest}
            render={(route) => (
                <AsyncPage route={route} page={page} loading={loading} {...params} />
            )}
        />
    );
};

App.tsx中使用

<Switch>
    <AsyncRoute page="Home" path="/" exact />
    <AsyncRoute page="About" path="/about" />
    <AsyncRoute page="users/Index" path="/users" />
    <Redirect path="/test" to="/" />
</Switch>

同时也可以在User/Index页面中使用

提取单个用户详情组件为独立页面

// src/pages/users/Detail.tsx
type UserParams = { username: string };
const UserDetail: FC = () => {
    const { username } = useParams<UserParams>();
    return <div>current user is {username}</div>;
};
export default UserDetail;

提取用户列表组件为独立页面

// src/pages/users/List.tsx
const UserList: FC = () => {
    return <div>User List</div>;
};
export default UserList;

原来的用户页面修改为用户布局页面

// src/pages/users/Index.tsx
const UsersIndex: FC = () => {
    const match = useRouteMatch();
    const [count, setCount] = useState(0);
    return (
        <div>
            <Button onClick={() => setCount(count + 1)}>增数</Button>
            <span>{count}</span>
            <ul>
                <li>
                    <Link to={`{match.url}`}>User List</Link>
                </li>
                <li>
                    <Link to={`{match.url}/pincman`}>Pincman</Link>
                </li>
            </ul>
            <Switch>
                <AsyncRoute path={match.path} page="users/List" exact />
                <AsyncRoute path={`${match.path}/:username`} page="users/Detail" />
            </Switch>
        </div>
    );
};
export default UsersIndex;

缓存布局

运行上面的代码会发现每次在访问子页面时会造成Index重新渲染,表现为点击”增数”按钮count变成1,在点击User list或者Pincman链接,count重置为0,造成以上问题的原因是loadable会在每次访问时生成一个新的实例,解决办法就是上一节讲到的缓存hook-useMemo

单独使用useMemo会导致页面一直缓存无法渲染,所以需要一个判断重新渲染页面时机的变量,此变量可以是match.url,因为切换子页面时,父级布局页的match.url是不会变动的

export const AsyncPage: FC<PageProps> = ({ page, loading, ...rest }) => {
    const { url } = rest.route.match;
    const View = useMemo(
        () =>
            loadable(() => timeout(pMinDelay(pages[page](), 500), 12000), {
                cacheKey: () => page.replaceAll('/', '-'),
                fallback,
            }),
        [url],
    );
    ...
};

现在试试点击”增数”再切换子页面,已经不会再重新渲染父级了

为了后续的配置式路由可以更简便地实现,我们还可以为AsyncRouteAsyncRoute添加一个children

export const AsyncPage: FC<PageProps> = ({ page, loading, children, ...rest }) => {
    return <View {...rest}>{children}</View>;
};


export const AsyncRoute: FC<RouteProps> = (props) => {
...
        render={(route) => (
            <AsyncPage route={route} page={page} loading={loading} {...params}>
                {children}
            </AsyncPage>
        )}
};

const UserIndex: FC = ({ children }) => {
    ...
    return (
        <div>
            <ul>...</ul>
            {children}
        </div>
    );
};
export default UserIndex;

现在可以在App.tsx中添加所有路由配置了

<Switch>
    <AsyncRoute page="Home" path="/" exact />
    <AsyncRoute page="About" path="/about" />
    <AsyncRoute page="users/Index" path="/users">
        <Switch>
            <AsyncRoute path="/users" page="users/List" exact />
            <AsyncRoute path="/users/:username" page="users/Detail" />
        </Switch>
    </AsyncRoute>
    <Redirect path="/test" to="/" />
</Switch>

可用API

这部分介绍一些常用的[react router][]的API

因为本教程是Typescript编码,所以不必把所有的API参数和属性列出来,大家可以点开类型文件自己查看

Hooks及函数

修改render

有了Hooks之后不必要再往render里的组件传递route参数

// src/components/Router/types.ts
export type PageProps<
    // 传递给Page的额外参数
    T extends Record<string, any> = Record<string, any>,
> = PageOptions & T;
export const AsyncPage: FC<PageProps> = ({ page, loading, children, ...rest }) => {
    const { url } = useRouteMatch();
    ...
};

// src/components/Router/view.tsx
export const AsyncRoute: FC<RouteProps> = (props) => {
    const { page, loading, params = {}, children, ...rest } = props;
    ...
                <AsyncPage page={page} loading={loading} {...params}>
                    {children}
                </AsyncPage>
};

useHistory

用于编程化导航,比如history.push("/home");

useLocation

获取当前URL的location对象,比如location.pathname

useRouteMatch

通过path参数与某个Routepath匹配,如果不传入参数,则与当前导航到的Route匹配,匹配后即可获取其被导航后的信息,比如match.url

matchPath

在组件渲染的生命周期之前获取match对象,其结果与渲染后使用useRouteMatch一样,用法如下

matchPath("/users/2", {
  path: "/users/:id",
  exact: true,
  strict: true
});
// 结果
//  {
//    isExact: true
//    params: {
//        id: "2"
//    }
//    path: "/users/:id"
//    url: "/users/2"
//  }

组件

  • Router: 底层路由构建器
  • BrowserRouter: html5 history api路由构建器
  • HashRouter: hash路由构建器
  • MemoryRouter: 不把URL写入浏览器地址栏而把历史记录保存在内存中的路由构建器,适用于测试或[react native][]等
  • Switch: 在路由列表中只渲染第一个与当前地址匹配的路由
  • Route: 定义路由,参数包含path(可以是个数组,对应多个地址),extra(精确匹配),render/component
  • Redirect: 跳转路由,部分参数与Route一样
  • Link: 通过组件模式导航路由
  • NavLink: 比Link多了个可以为当前链接激活时添加的CSS类
  • Prompt: 在用户离开页面之前跳出”是否离开”提示

其它知识

以下知识后续教程会讲解

  • 配置式路由
  • 认证和权限路由
  • 基于context的独立路由组件
  • 路由切换时的转场动画

以下知识自行查看官网示例

  • query: 通过Link组件中的query参数可指定query,如要获取query看[这里][https://reactrouter.com/web/example/query-parameters]
  • 弹出框: 想要点击一个路由可弹出一个modal,看[这里][https://reactrouter.com/web/example/modal-gallery]