Preact技术栈:router和redux

上篇文章中对Preact源码进行了分析,了解了Componentrenderdiff等核心方法,以及页面渲染的大致流程。在这一篇文章中,将分析preact-router的源码实现,并了解在Preact项目中使用react-redux的方法,构建一个完整的web应用。

<!--more-->

1. Preact-router源码分析

首先构建一个示例demo,来演示preact-router的基本使用

import { createElement, render, Component } from 'preact';
// 引入源码文件,方便调试
import { Router, Link } from 'preact-router/src/index'; 
// 构建两个页面组件
class Route1 extends Component {
    render() {
        return (<div>Route1</div>);
    }
}
class Route2 extends Component {
    render() {
        return (<div>Route2</div>);
    }
}
// 整个应用根组件
class App extends Component {
    render(props) {
        let { url } = props;
        return (
            <div>
                <nav>
                    <Link href="/" activeClassName="active">index</Link>
                    <Link href="/route1" activeClassName="active">router1</Link>
                    <Link href="/route2" activeClassName="active">router2</Link>
                </nav>
                <main>
                    <Router url={url}>
                        <div path="/">
                            <h1>index</h1>
                        </div>
                        <Route1 path="/route1"/>
                        <Route2 path="/route2"/>
                    </Router>
                </main>
            </div>
        );
    }
}
// 挂载根组件
let root = document.createElement('div');
document.body.appendChild(root);
render(<App/>, root);

可见其使用十分简单

1.1. Router

Router最主要的作用就是根据当前的url来决定要渲染哪一个组件。因此我们直接来查看它的render函数

render({ children, onChange }, { url }) {
      // 根据当前state上的url,从children中找到能够匹配vnode
        let active = this.getMatchingChildren(toChildArray(children), url, true);
        // 渲染优先级最高的那个vnode
        let current = active[0] || null;
        this._didRoute = !!current;

        let previous = this.previousUrl;
      // 当路由发生变化时,触发props.onChange处理函数
        if (url!==previous) {
            this.previousUrl = url;
            if (typeof onChange==='function') {
                onChange({
                    router: this,
                    url,
                    previous,
                    active,
                    current
                });
            }
        }

        return current;
}
// 从children列表中找到可与url匹配的vnode
getMatchingChildren(children, url, invoke) {
        return children
            .filter(prepareVNodeForRanking)
            .sort(pathRankSort)
            .map( vnode => {
                let matches = exec(url, vnode.props.path, vnode.props);
                if (matches) {
                    if (invoke !== false) {
                        let newProps = { url, matches };
                        assign(newProps, matches);
                        delete newProps.ref;
                        delete newProps.key;
                        return cloneElement(vnode, newProps);
                    }
                    return vnode;
                }
            }).filter(Boolean);
}

其中的匹配逻辑在getMatchingChildren方法中实现,我们甚至不需要关注其具体的逻辑,简单地把他理解为一个哈希映射即可:

  • 键名为url,
  • 键值为path属性为url的vnode

1.2. Link组件

我们知道了当页面url发生变化时,会触发Router组件的更新,并渲染新的页面组件。那么Link组件是如何触发url变化的呢?

const Link = (props) => (
    createElement('a', assign({ onClick: handleLinkClick }, props))
);

function handleLinkClick(e) {
  // 鼠标左键点击
    if (e.button==0) {
        routeFromLink(e.currentTarget || e.target || this);
        return prevent(e);
    }
}
function routeFromLink(node) {
  // 此处省略了一些检测条件
    let href = node.getAttribute('href')
    return route(href);
}

function route(url, replace=false) {
    if (typeof url!=='string' && url.url) {
        replace = url.replace;
        url = url.url;
    }
    // canRoute调用的是Router组件的canRoute方法,判断当前url是否存在可匹配的页面组件用于渲染
    if (canRoute(url)) {
    // 调用history.replaceState或调用history.pushState
        setUrl(url, replace ? 'replace' : 'push');
    }

    return routeTo(url);
}

function routeTo(url) {
    let didRoute = false;
  // ROUTERS是存放Router组件的数组
    for (let i=0; i<ROUTERS.length; i++) {
    // 调用Router组件的routeTo方法,渲染页面
        if (ROUTERS[i].routeTo(url)===true) {
            didRoute = true;
        }
    }
  // 处理路由变化的全局回调函数
    for (let i=subscribers.length; i--; ) {
        subscribers[i](url);
    }
    return didRoute;
}

然后回过头看一看组件的routeTo方法

routeTo(url) {
        this._didRoute = false;
        this.setState({ url }); // 调用setState,重新渲染组件

        if (this.updating) return this.canRoute(url);

        this.forceUpdate();
        return this._didRoute;
}

现在总结一下整个流程

  • 点击Link组件,触发组件上的handleLinkClick方法,通过e.target.getAttribute('href')获取当前组件的href属性

  • 调用route(url, replace)方法,内部调用

    • setUrl方法,触发history.pushState事件,修改浏览器地址栏的url
    • routeTo方法,内部调用Router组件的routeTo方法
    • 遍历subscribers,依次调用注册的全局回调函数
  • 组件的routeTo方法,其内部调用setStateforceUpdate,重新调用render方法

  • diff操作完成,整个页面重新渲染

到目前为止,我们应该了解到路由是如何前进(pushState)和重定向(replaceState)的了。那么,路由是如何回退的呢?

1.3. 事件注册

Router组件的构造函数中,注册了全局事件

function initEventListeners() {
    if (eventListenersInitialized) return;

  // 实际是window.addEventListener
    if (typeof addEventListener==='function') {
        if (!customHistory) {
      // 注册popstate事件
            addEventListener('popstate', () => {
                routeTo(getCurrentUrl());
            });
        }
        addEventListener('click', delegateLinkHandler);
    }
    eventListenersInitialized = true;
}

// 处理直接通过a标签跳转的情况
function delegateLinkHandler(e) {
    if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.button!==0) return;

    let t = e.target;
  // 向上找到a标签
    do {
        if (String(t.nodeName).toUpperCase()==='A' && t.getAttribute('href')) {
            if (t.hasAttribute('native')) return;
            if (routeFromLink(t)) {
                return prevent(e);
            }
        }
    } while ((t=t.parentNode));
}

在事件初始化时,主要注册了两个事件

  • window.popstate,触发该事件时重新渲染到上一个页面组件
  • window.click,当e.targeta标签上或内部触发时,执行与Link组件相同跳转逻辑

1.4. 小结

总体来说,preact-router的实现还是比较简单的。根据HTML5的History新特性,监听浏览器的前进和后退,并在路由的变化中,通过更新Router组件的state.url来实现页面组件的重新渲染。

2. redux

2.1. 在preact项目中使用react-redux

在preact中,也可以使用redux和react-redux。下面是一个使用react-redux实现多组件之间数据通信的官方demo

import { createElement, render, Component } from 'preact';

import { createStore } from 'redux';
import { connect, Provider } from 'react-redux';
// 定义reducer,根据不同的action返回对应的state
const reducer = (state = { value: 0 }, action) => {
    switch (action.type) {
        case 'increment':
            return { value: state.value + 1 };
        case 'decrement':
            return { value: state.value - 1 };
        default:
            return state;
    }
}
// 初始化store
const store = createStore(reducer);
// 子组件1
class Child extends Component {
    render() {
        return (
            <div>
                <div>Child #1: {this.props.foo}</div>
                <ConnectedChild2/>
            </div>
        );
    }
}
// 通过connect方法将store的state通过props的形式注入
const ConnectedChild = connect(store => ({ foo: store.value }))(Child);

// 子组件2
class Child2 extends Component {
    render() {
        return <div>Child #2: {this.props.foo}</div>;
    }
}
// 同上注入store
const ConnectedChild2 = connect(store => ({ foo: store.value }))(Child2);

// 根组件
class App extends Component {
    render() {
        return (
            <div>
                <h1>Counter</h1>
                <Provider store={store}>
                    <ConnectedChild/>
                </Provider>
                <br/>
                <button onClick={() => store.dispatch({ type: 'increment' })}>+</button>
                <button onClick={() => store.dispatch({ type: 'decrement' })}>-</button>
            </div>
        );
    }
}

// 挂载根组件
let root = document.createElement('div');
document.body.appendChild(root);
render(<App/>, root);

上面的代码展示了react-redux的在preact项目中的基本使用:

  • 编写一个reducer,根据不同的action返回对应的state,
  • reduccreateStore(reducer)方法创建一个store仓库
  • 然后通过connect方法将组件与store关联起来,将store的state数据通过props的形式注入到组件
  • 在页面上,通过Provider注入全局的store,当state更新时,同步所有订阅state的组件

可以看见,在preact中使用react-redux的方式并没有额外的改动。

2.2. preact-redux

在github上还发现了preact-redux这个库,查看其源码发现其主要功能是将reac-redux中部分API导出,因此如果想了解具体实现,直接翻阅react-redux源码比较合适

import { Provider, connect, connectAdvanced, ReactReduxContext } from 'react-redux';
export { Provider, connect, connectAdvanced, ReactReduxContext };
export default { Provider, connect, connectAdvanced, ReactReduxContext };

2.3. 小结

状态管理是开发大型应用必备的知识点。之前写过一篇博客:理解数据状态管理,简单整理了我对于状态管理的认识。

3. 小结

本文主要分析了preact-router的源码,可以看见类似单页面应有路由管理的实现思路

  • 通过Router组件控制对应url需要渲染的组件
  • 通过事件代理,a标签上的点击事件会调用history.pushStatehistory.replaceState来实现页面url的前进和重定向;
  • 通过window.popState事件来实现页面后退的重新渲染

然后了解了在preact项目中使用react-redux的方式,基本与在React中的使用方式保持一致。

如此看来,preact虽然是一个简易版的类React框架,但也可以用来构建前端应用了。