30、React服务器端渲染与集成React Router

React服务器端渲染与集成React Router

1. 首次服务器端渲染

在开始使用React Router之前,我们已经完成了首次服务器端渲染。通过React创建了一个React组件的字符串表示,并将其发送到客户端。不过此时React尚未加载,无法从服务器结束的地方继续工作,但一旦加载,它就能接管后续操作。你可以尝试运行相同的命令,但使用 renderToStaticMarkup ,观察服务器的HTTP响应有何不同。

2. 切换到React Router

之前构建的路由器是为处理浏览器中的路由而优化的,并未考虑服务器端渲染。虽然它能满足示例应用相对简单的需求,但存在一些不足,例如API较为基础,缺乏对路由钩子(路由之间的过渡)、中间件(可应用于多个路由的逻辑)等功能的支持。随着深入研究React的服务器端渲染,我们需要更多功能,如根据请求URL生成要渲染的组件树,因此决定切换到React Router V3。

React Router(https://github.com/ReactTraining/react-router)是React最常用且开发最完善的路由解决方案,在GitHub上有大量的用户和贡献者社区,并且经过了多次重大修订。截至撰写本文时,React Router的最新主要版本是4,但它仍在不断变化,可能在你阅读时已被新的主要版本取代。我们选择使用版本3,因为其API与我们自己创建的路由器相似,使用时无需进行太多更改,而且它是由React开源社区开发的成熟技术,功能比我们简单的路由器更强大,甚至超出了我们的需求。

需要注意的是,React Router是一项重要的技术,我们这里只是浅尝其潜力。该项目包含了适用于各种情况的多种路由功能,最新主要版本(撰写时为4)甚至为React Native平台提供了路由解决方案。众多开发者使用和参与React Router的开发,使其变得非常有用,但也存在主要版本之间变化较大的缺点。因此,由于与我们自己构建的路由器相似,我们不使用React Router的最新版本。如果你想使用最新版本,可以参考我博客上的文章:https://ifelse.io/2017/09/07/server-rendering-with-react-router-and-react-16-fiber。虽然React Router不同版本的API有所变化,但大多数概念是相同的,在过渡时只需将功能重新映射到新的API即可。

3. 设置React Router

我们已决定将React Router作为生产环境中替换自己路由器的解决方案,接下来看看如何进行设置。

首先,确保已安装React Router并将其替换当前的路由器。尽管技术不同,但我们使用的API应该相似。通常,React Router会随着项目依赖项一起安装。现在,需要开始将项目过渡到React Router,并进行能实现服务器端渲染(SSR)的设置。

从当前的 src/index.js 文件开始,这是一个入口文件,之前在其中设置了应用的主要部分,包括监听浏览器历史记录、渲染路由器组件和激活身份验证事件监听器。但这些代码在SSR设置中无法正常工作,因为其中很多代码依赖于浏览器环境,而且实现应用功能并不需要React Router的所有功能,实际上只需要保留身份验证监听器。

在添加任何内容之前,创建一个辅助工具,用于检查是否处于浏览器环境。以下是创建该工具的代码:

export function isServer() {
    return typeof window === 'undefined';
}

现在可以使用这个辅助函数来确定当前所处的环境,并根据需要有条件地执行代码。虽然它没有进行详尽的检查以确保处于浏览器环境,但应该能满足我们的需求。在构建具有SSR功能的应用或在客户端和服务器之间共享代码的应用(有时称为通用或同构应用)时,考虑代码运行的环境是很常见的情况。根据我的经验,这也可能是难以追踪的常见错误源,特别是在安装没有考虑环境感知的第三方依赖项时。

目前,React社区中的许多现有技术通常要么支持SSR,要么会指出可能出现问题的地方。但情况并非一直如此,几年前使用早期版本的React时,我曾遇到过React本身的错误,导致某些库的某些方面无法预测地失败。不过现在情况好多了,SSR不仅是React社区的考虑因素,也是核心团队的关注点。

在继续之前,需要对其中一个reducer进行小调整,以考虑服务器环境。 user reducer会使用 js-cookie 在浏览器上设置cookie,但服务器通常不允许存储cookie(尽管有一些库可以模拟这种行为,如 tough-cookie (https://github.com/salesforce/tough-cookie)),因此需要使用环境辅助函数来调整此代码。以下是需要进行的修改:

export function user(state = initialState.user, action) {
    switch (action.type) {
        case types.auth.LOGIN_SUCCESS:
            const { user, token } = action;
            if (!isServer()) {                         
                Cookies.set('letters-token', token);   
            }                                          
            return Object.assign({}, state.user, {
                authenticated: true,
                name: user.name,
                id: user.id,
                profilePicture: user.profilePicture || 
'/static/assets/users/4.jpeg',
                token
            });
        case types.auth.LOGOUT_SUCCESS:
            Cookies.remove('letters-token');
            return initialState.user;
        default:
            return state;
    }
}

接下来,需要设置React Router。与我们自己的路由器类似,React Router(版本3)允许使用嵌套的 <Route/> 组件层次结构来指示哪些组件应映射到哪些URL。由于它是一个广泛使用且经过实战检验的解决方案,具有许多我们自己的路由器没有的功能,这里我们直接将其替换自己的路由器,而不深入探索其所有功能。

创建一个新文件 src/routes.js 来存放路由配置。将路由拆分成单独的文件,是因为服务器和客户端都需要访问这些路由。对于客户端代码与服务器代码共存的应用来说,这样做很方便,但如果路由托管在其他地方(通过npm、Git子模块等),可能需要找到其他方法将路由引入服务器。路由文件应与我们自己创建的路由器类似,但有一些小差异。我们在同一个 <Route/> 组件中添加了指定索引组件的功能,而React Router为此提供了单独的组件。以下是如何将React Router集成到路由设置中的代码:

import React from 'react';
import App from './pages/app';
import Home from './pages/index';
import SinglePost from './pages/post';
import Login from './pages/login';
import NotFound from './pages/404';
import { Route, IndexRoute } from 'react-router';

export const routes = (
    <Route path="/" component={App}>        
        <IndexRoute component={Home} />                  
        <Route path="posts/:post" component={SinglePost} />   
        <Route path="login" component={Login} />              
        <Route path="*" component={NotFound} />               
    </Route>
);

现在已经设置了一些路由,可以将它们导入到主应用文件中,供React Router使用。这些路由将在客户端和服务器上使用,这就是我们可能听说过的SSR的通用或同构特性的一部分。在客户端和服务器上重用代码是一件很重要的事情,但在这个有限的示例中,可能还无法看到其更显著的好处。这里获得的优势是能够以“正常”的React方式轻松地将客户端组件暴露给服务器。

接下来,将路由导入服务器。以下是如何将路由引入服务器并在渲染过程中使用它们的代码:

//...
import { renderToString } from 'react-dom/server';              
import React from 'react';                                      
import { match, RouterContext } from 'react-router';            
import { Provider } from 'react-redux';                         
import configureStore from '../src/store/configureStore';       
import initialReduxState from '../src/constants/initialState';  
import { routes } from '../src/routes';                         

//...
app.use('*', (req, res) => {
    match({ routes: routes, location: req.originalUrl },   
   (err, redirectLocation, props) => {                             
        if (redirectLocation && req.originalUrl !== '/login') {
            return res.redirect(302, redirectLocation.pathname + 
redirectLocation.search);                                     
        }
        const store = configureStore(initialReduxState);   
        const appHtml = renderToString(                         
               <Provider store={store}>
                    <RouterContext {...props} />
               </Provider>
            );
        const html = `                      
            <!doctype html> 
            <html>
                <head>                      
                    <link rel="stylesheet" 
href="http://localhost:3100/static/styles.css" />
                    <meta charset=utf-8/>
                    <meta http-equiv="X-UA-Compatible" content="IE=edge">
                    <title>Letters Social | React In Action by Mark 
Thomas</title>
                    <meta name="viewport" content="width=device-
width,initial-scale=1">
                </head>
                <body>
                    <div id="app">          
                        ${appHtml}          
                    </div>
                <script src="http://localhost:3000/bundle.js" 
type='text/javascript'></script>       
                </body>
            </html>
        `.trim();
        res.setHeader('Content-type', 'text/html');   
        res.send(html).end();                         
    });
});
//... Error handling
export default app;

服务器如何获取要渲染的正确组件呢?由于路由本质上是将URL映射到操作(这里是HTTP响应),我们需要能够查找与路径关联的正确组件。在自己的路由器中,使用基本的URL正则表达式匹配库来确定URL是否映射到路由器中的组件,它会根据URL确定是否有组件需要渲染。React Router允许在服务器上做同样的事情,这样就可以使用HTTP请求到服务器的传入URL来匹配要渲染为静态标记的组件。这就是React Router与实现SSR目标的关键连接点,React Router会像在正常情况下一样使用URL渲染组件或组件树,但这次是在服务器上。

4. React Router路由钩子的使用
4.1 路由钩子概述

现在服务器已经设置好,可以对应用的客户端部分进行一些清理工作。需要确保使用新的路由设置,并重新组织与身份验证相关的逻辑,以便更好地利用React Router的功能。这里将使用React Router提供的一组特性:钩子。

这些钩子类似于组件的生命周期方法,用于在路由之间进行过渡。使用这些钩子有多种方式,例如:
- 可以在允许用户完成URL过渡之前触发页面的数据获取或检查用户是否已登录。
- 当用户离开页面时,可以处理任何清理工作或结束分析会话,不限于进入相关的事件。
- 借助React Router的钩子,甚至可以执行同步或异步工作。
- 可以向分析平台(如Google Analytics)发送页面视图事件。

以下是React Router v3中可以使用的钩子的基本流程图:

graph LR
    A[开始过渡(用户发起或编程触发)] --> B{是否需要重定向}
    B -- 是 --> C[重定向]
    B -- 否 --> D[onEnter(nextState, replace, callback?)]
    D --> E{位置是否改变且路由未进入或离开}
    E -- 是 --> F[onChange(prevState, nextState, replace, callback?)]
    E -- 否 --> G{是否离开路由}
    G -- 是 --> H[onLeave(prevState)]
    G -- 否 --> I[过渡结束]
    C --> I
    F --> I
    H --> I

如果想了解更多关于React Router V3 API的信息以及社区编写的其他有用指南,可以查看GitHub上的文档:https://github.com/ReactTraining/react-router/blob/v3/docs/API.md。

4.2 使用onEnter钩子进行身份验证

我们将使用 onEnter 钩子来检查某些路由的用户是否已登录,如果没有经过身份验证的用户,则将其重定向到登录页面。在实际应用中,需要从安全角度全面考虑应用,并投入大量时间来防止用户过渡到他们不应该访问的页面,同时确保安全策略也适用于服务器。但目前,Firebase和路由钩子应该足以保护一些路由。以下是如何为受保护页面设置 onEnter 钩子的代码:

import React from 'react';
import { Route, IndexRoute } from 'react-router';
import App from './pages/app';
import Home from './pages/index';
import SinglePost from './pages/post';
import Login from './pages/login';
import Profile from './pages/profile';
import NotFound from './pages/error';
import { firebase } from './backend';                                
import { isServer } from './utils/environment';                      
import { getFirebaseUser, getFirebaseToken } from './backend/auth';  

async function requireUser(nextState, replace, callback) {  
    if (isServer()) {                    
       return callback();
    }
    try {
        const isOnLoginPage = nextState.location.pathname === '/login';    
        const firebaseUser = await getFirebaseUser();       
        const fireBaseToken = await getFirebaseToken();     
        const noUser = !firebaseUser || !fireBaseToken;   
        if (noUser && !isOnLoginPage && !isServer()) {    
            replace({
                pathname: '/login'
            });
            return callback();
        }
        if (noUser && isOnLoginPage) {    
            return callback();
        }
        return callback();
    } catch (err) {
        return callback(err);        
    }
}

export const routes = (
    <Route path="/" component={App}>
        <IndexRoute component={Home} onEnter={requireUser} />  
        <Route path="/posts/:postId" component={SinglePost} 
onEnter={requireUser} />
        <Route path="/login" component={Login} />
        <Route path="*" component={NotFound} />
    </Route>
);
4.3 清理主应用文件

在继续之前,还需要清理主应用文件并替换链接组件。以下是精简后的主客户端文件代码:

import React from 'react';
import { hydrate } from 'react-dom';
import { Provider } from 'react-redux';     
import { Router, browserHistory } from 'react-router';    
import configureStore from './store/configureStore';
import initialReduxState from './constants/initialState';
import { routes } from './routes';                   
import './shared/crash';
import './shared/service-worker';
import './shared/vendor';
// NOTE: this isn't ES*-compliant/possible, but works because we use 
Webpack as a build tool
import './styles/styles.scss';

// Create the Redux store
const store = configureStore(initialReduxState);
hydrate(                                     
    <Provider store={store}>                        
        <Router history={browserHistory} routes={routes} />   
    </Provider>,
    document.getElementById('app')
);

我们使用 browserHistory 设置了React Router,但也可以使用基于哈希或内存的历史记录来设置。这些历史记录与浏览器历史记录略有不同,因为它们不使用相同的浏览器History API。基于哈希的历史记录通过更改URL中的哈希片段来工作,但不会改变用户的浏览器历史记录;内存历史记录API根本不操作URL,更适合本地开发或React Native(下一章会涉及)。有关可用的不同历史记录实现的更多信息,请参阅:https://github.com/ReactTraining/react-router/blob/v3/docs/guides/Histories.md。

如果在本地运行应用,应该能够看到所有内容在服务器上渲染并发送到客户端,React将接管后续工作,一切应该如预期般具有交互性。不过,你可能会注意到一个问题:使用链接进行路由似乎不起作用。这是因为我们自己构建的链接组件与React Router不兼容,后续需要对其进行调整。

通过以上步骤,我们完成了React服务器端渲染与React Router的集成,并且实现了基本的身份验证路由保护。在实际应用中,可以根据需求进一步扩展和优化这些功能。

React服务器端渲染与集成React Router

5. 解决链接路由问题

在上文中我们提到,本地运行应用时使用链接进行路由似乎不起作用,原因是我们自己构建的链接组件与React Router不兼容。为了解决这个问题,我们需要使用React Router提供的 Link 组件来替换自定义的链接组件。

首先,安装 react-router-dom (如果还未安装),它包含了 Link 组件。然后在需要使用链接的组件中引入并使用 Link 组件。以下是一个简单的示例:

import React from 'react';
import { Link } from 'react-router-dom';

const Navbar = () => {
    return (
        <nav>
            <ul>
                <li><Link to="/">Home</Link></li>
                <li><Link to="/posts">Posts</Link></li>
                <li><Link to="/login">Login</Link></li>
            </ul>
        </nav>
    );
};

export default Navbar;

在这个示例中,我们创建了一个简单的导航栏,使用 Link 组件来创建链接。 to 属性指定了链接的目标路径。

6. 服务器端渲染的性能优化
6.1 代码分割

代码分割是一种优化策略,它可以将应用的代码拆分成多个较小的块,只有在需要时才加载这些块。在React中,可以使用动态导入(Dynamic Import)来实现代码分割。

例如,在路由配置中,可以使用动态导入来加载组件:

import React from 'react';
import { Route, IndexRoute } from 'react-router';

const Home = React.lazy(() => import('./pages/index'));
const SinglePost = React.lazy(() => import('./pages/post'));
const Login = React.lazy(() => import('./pages/login'));
const NotFound = React.lazy(() => import('./pages/404'));

export const routes = (
    <Route path="/" component={App}>
        <IndexRoute component={Home} />
        <Route path="posts/:post" component={SinglePost} />
        <Route path="login" component={Login} />
        <Route path="*" component={NotFound} />
    </Route>
);

在这个示例中,使用 React.lazy import() 动态导入组件。这样,只有当用户访问相应的路由时,才会加载这些组件的代码,从而减少初始加载时间。

6.2 缓存策略

在服务器端渲染中,可以使用缓存策略来提高性能。例如,可以使用内存缓存或分布式缓存(如Redis)来缓存已经渲染好的HTML页面。

以下是一个简单的内存缓存示例:

const cache = {};

app.use('*', (req, res) => {
    const cacheKey = req.originalUrl;
    if (cache[cacheKey]) {
        res.setHeader('Content-type', 'text/html');
        res.send(cache[cacheKey]).end();
        return;
    }

    match({ routes: routes, location: req.originalUrl }, (err, redirectLocation, props) => {
        if (redirectLocation && req.originalUrl !== '/login') {
            return res.redirect(302, redirectLocation.pathname + redirectLocation.search);
        }
        const store = configureStore(initialReduxState);
        const appHtml = renderToString(
            <Provider store={store}>
                <RouterContext {...props} />
            </Provider>
        );
        const html = `
            <!doctype html>
            <html>
                <head>
                    <link rel="stylesheet" href="http://localhost:3100/static/styles.css" />
                    <meta charset=utf-8/>
                    <meta http-equiv="X-UA-Compatible" content="IE=edge">
                    <title>Letters Social | React In Action by Mark Thomas</title>
                    <meta name="viewport" content="width=device-width,initial-scale=1">
                </head>
                <body>
                    <div id="app">
                        ${appHtml}
                    </div>
                    <script src="http://localhost:3000/bundle.js" type='text/javascript'></script>
                </body>
            </html>
        `.trim();

        cache[cacheKey] = html;
        res.setHeader('Content-type', 'text/html');
        res.send(html).end();
    });
});

在这个示例中,使用一个简单的对象 cache 来缓存渲染好的HTML页面。每次请求时,先检查缓存中是否存在对应的页面,如果存在则直接返回缓存的页面,否则进行渲染并将结果存入缓存。

7. 错误处理与日志记录
7.1 服务器端错误处理

在服务器端,需要对可能出现的错误进行处理,以避免应用崩溃。可以在Express应用中使用错误处理中间件来捕获和处理错误。

以下是一个简单的错误处理中间件示例:

app.use((err, req, res, next) => {
    console.error(err);
    res.status(500).send('Internal Server Error');
});

在这个示例中,当发生错误时,会将错误信息打印到控制台,并向客户端发送一个500状态码和错误信息。

7.2 客户端错误处理

在客户端,同样需要处理可能出现的错误。可以使用 componentDidCatch 生命周期方法来捕获子组件中抛出的错误。

以下是一个简单的错误边界组件示例:

import React from 'react';

class ErrorBoundary extends React.Component {
    constructor(props) {
        super(props);
        this.state = { hasError: false };
    }

    componentDidCatch(error, errorInfo) {
        // 记录错误信息
        console.error(error, errorInfo);
        this.setState({ hasError: true });
    }

    render() {
        if (this.state.hasError) {
            return <div>Something went wrong.</div>;
        }
        return this.props.children;
    }
}

export default ErrorBoundary;

在这个示例中, ErrorBoundary 组件是一个错误边界组件,它可以捕获子组件中抛出的错误,并显示一个错误信息。

8. 总结与展望

通过以上步骤,我们完成了React服务器端渲染与React Router的集成,实现了基本的身份验证路由保护,解决了链接路由问题,并且对服务器端渲染的性能进行了优化,同时实现了错误处理与日志记录。

在未来的开发中,我们可以进一步扩展和优化这些功能。例如:
- 可以使用更高级的缓存策略,如分布式缓存(Redis),以提高服务器的性能和可扩展性。
- 可以集成更多的第三方服务,如日志分析服务(Sentry),以更好地监控和处理应用中的错误。
- 可以探索React Router的更多高级功能,如路由守卫、路由过渡动画等,以提升用户体验。

以下是一个总结表格,对比了我们自己构建的路由器和React Router的特点:
| 特性 | 自己构建的路由器 | React Router |
| — | — | — |
| API 丰富度 | 基础 | 丰富 |
| 路由钩子支持 | 无 | 支持 |
| 中间件支持 | 无 | 支持 |
| 服务器端渲染支持 | 有限 | 强大 |
| 社区支持 | 无 | 广泛 |

通过使用React Router,我们可以更轻松地实现复杂的路由功能,并且利用其丰富的社区资源和成熟的技术来提高开发效率和应用的质量。

最后,以下是整个集成过程的流程图:

graph LR
    A[开始] --> B[首次服务器端渲染]
    B --> C[切换到React Router]
    C --> D[设置React Router]
    D --> E[使用路由钩子进行身份验证]
    E --> F[解决链接路由问题]
    F --> G[性能优化]
    G --> H[错误处理与日志记录]
    H --> I[结束]

通过这个流程图,我们可以清晰地看到整个集成过程的步骤和顺序。希望本文对你理解React服务器端渲染与集成React Router有所帮助。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值