Mithril route(root, defaultRoute, routes)

route(root, defaultRoute, routes)

描述

路由用于在应用的不同页面之间跳转

var Home = {
    view: function() {
        return "Welcome"
    }
}

m.route(document.body, "/home", {
    "/home": Home, // defines `http://localhost/#!/home`
})

每个应用只能调用一次 m.route

签名

m.route(root, defaultRoute, routes)
参数 类型 是否必须 描述
root Element 一个 DOM 元素,其他元素会被放置在该元素内
defaultRoute String 如果当前 URL 没有匹配的路由,则会跳转到这个默认路由
routes Object 一个对象,key 是路由字符串,value 是组件或RouteResolver
返回 返回 undefined

静态成员

m.route.set

跳转到匹配的路由,如果没有匹配的路由,则跳转到默认路由。

m.route.set(path, data, options)
参数 类型 是否必须 描述
path String 路由路径,不含前缀。该路径可以包含路由参数的动态变量
data Object 路由参数。如果 path 中含路由参数的动态变量,则会将该对象中的对应属性的值添加到路由路径中
options.replace Boolean 是否创建新的历史记录,还是替换当前的历史记录,默认为 false
options.state Object 传递给 history.pushState/history.replaceState 调用的 state 参数。该参数可以通过 history.state 属性调用,并且会被合并到路由参数对象。注意该参数只有在使用 pushState API 时才有效,如果路由降级到使用 hashchange 则无效(如浏览器不支持 pushState API)
options.title String 传递给 history.pushState/history.replaceState 调用的 title 参数
返回 返回 undefined

m.route.get

返回最后一个完全解析的路由路径,不含前缀。当路由正在等待解析时,它可能和浏览器地址栏中显示的路径不同。

path = m.route.get()
参数 类型 是否必须 描述
返回 String 返回最后一个完全解析的路由路径

m.route.prefix

定义路由前缀。路由前缀是一个 URL 片段,表示路由所使用的策略

m.route.prefix(prefix)
参数 类型 是否必须 描述
prefix String 该前缀指定了 Mithril 使用的路由策略
返回 返回 undefined
eventHandler = m.route.link(vnode)
参数 类型 是否必须 描述
vnode Vnode 这个方法意味着和 <a> vnodeoncreate 钩子一起使用
返回 Function(e) 返回事件处理函数,用链接的 href 作为 path 调用 m.route.set

RouteResolver

RouteResolver 是一个包含 onmatch 和/或 render 方法的对象。两个方法都是可选的,但至少要有其中一个。RouteResolver 不是组件,因此没有生命周期方法。RouteResolver 应该和 m.route 位于相同的文件中,而组件定义应该在对应的模块中。

routeResolver = {onmatch, render}

routeResolver.onmatch

当路由在寻找需要渲染的组件时,会调用 onmatch 钩子。每次路由路径改变时会调用一次,但随后在同一个路径中的重绘不会调用。它可以用于在组件初始化之前执行一些逻辑(例如验证登录、数据预加载等)。

该方法还允许你异步定义要渲染的组件,使其适用于代码分割和异步加载模块。如果要异步渲染组件,则返回解析组件的 Promise。

更多有关 onmatch 的信息,详见高级组件方案部分。

routeResolver.onmatch(args, requestedPath)
参数 类型 描述
args Object 路由参数
requestedPath String 最后的路由操作请求的路由路径,包括动态变量的值,但不含前缀。当 onmatch 被调用时,路由路径的解析还是未完成的,且 m.route.get() 得到的依然时前一个路径。
返回 Component|Promise 返回组件、或者解析到组件的 Promise

如果 onmatch 返回组件、或者解析到组件的 Promise,则该组件将被用作 RouteResolver 的 render 方法中第一个参数的 vnode.tag。否则,vnode.tag 会被设置为 "div"。如果省略 onmatch 方法,vnode.tag 同样会被设置为 "div"

如果 onmatch 返回被拒绝的 Promise,则路由会跳转到 defaultRoute。你可以在 Promise 返回前调用 .catch 来覆盖它的行为。

routeResolver.render

在匹配的路由进行重绘时会调用 render 方法。它类似组件中的 view 方法,它可以简化组件的组成

vnode = routeResolve.render(vnode)
参数 类型 描述
vnode Object 一个 vnode,且它的属性对象包含了路由参数。如果 onmatch 没有返回组件或解析到组件的 Promise,则 vnode 的 tag 默认为 "div"
vnode.attrs Object URL 参数值的映射
返回 Vnode 返回 vnode

工作原理

路由是一个允许创建单页面应用(SPA)的系统。例如应用可以从一个页面跳转到另一个页面,但不会导致整个页面刷新。

无刷新的路由切换功能由 history.pushState API 实现。使用该 API,开发者可以在页面载入后手动修改 URL,并载入该 URL 对应的内容,而无需刷新整个页面。

路由策略

路由策略决定了一个库如何实现路由。有三个常用的策略来实现 SPA 路由系统,每个策略都有各自的注意事项:

使用 hash 策略可以在不支持 history.pushState 的浏览器上运行(IE9 及以下),因为它可以降级到使用 onhashchange。如果你的应用需要支持 IE9,请使用该策略。

使用 querystring 策略也可以支持 IE9,但它会降级使用重新加载整个页面。如果你需要支持锚链接,且服务端不支持 pathname 策略时,可以使用该策略。

使用 pathname 策略可以产生看起来很干净的 URL,但不支持 IE9,且需要在服务器为每个路由进行设置。如果你想要干净的 URL,且不需要支持 IE9,可以使用该策略。

使用 hash 策略的单页面应用通常会在 # 后面添加一个叹号,以指示使用 hash 作为路由机制,而不是链接到锚点。#! 字符串被称为 hashbang。

默认策略使用 hashbang。

典型用法

通常,你需要先创建几个组件

var Home = {
    view: function() {
        return [
            m(Menu),
            m("h1", "Home")
        ]
    }
}

var Page1 = {
    view: function() {
        return [
            m(Menu),
            m("h1", "Page 1")
        ]
    }
}

在上面的代码中,有两个组件:HomePage1。每个组件都包含一个菜单和一些文本,菜单本身也被定义成了一个组件:

var Menu = {
    view: function() {
        return m("nav", [
            m("a[href=/]", {oncreate: m.route.link}, "Home"),
            m("a[href=/page1]", {oncreate: m.route.link}, "Page 1"),
        ])
    }
}

现在我们可以定义路由,并把组件映射到路由:

m.route(document.body, "/", {
    "/": Home,
    "/page1": Page1,
})

这里我们指定了两个路由://page1,当用户切换到指定 URL 时,将渲染对应的组件。默认状态下,路由前缀为 #!

在上面的例子中,Menu 组件有两个链接。你可以添加钩子 {oncreate: m.route.link},来指定 href 属性是一个路由链接(而不是跳转到其他页面的常规链接)。

你也可以调用 m.route.set(route) 来手动切换路由。例如 m.route.set("/page1")

切换路由时,不需要指定路由前缀。也就是说,当使用 m.route.linkm.route.set(route) 时,不要在路由路径前加上 #!

路由参数

有时我们需要在路由中添加一个变量,Mithril 支持参数化路由:

var Edit = {
    view: function(vnode) {
        return [
            m(Menu),
            m("h1", "Editing " + vnode.attrs.id)
        ]
    }
}
m.route(document.body, "/edit/1", {
    "/edit/:id": Edit,
})

在上面的例子中,我们定义了一个路由 /edit/:id。它是一个动态路由,可以匹配以 /edit/ 开头,且后面跟着一些数据的 URL(例如 /edit/1/edit/234)。id 的值会作为组件的 vnode 的属性(vnode.attrs.id)。

一个路由可以有多个参数,例如 /edit/:projectID/:userID 路由会给组件的 vnode 的属性对象添加 projectIDuserID 两个属性。

除了路由参数之外,attrs 对象还包含一个表示当前路由路径的 path 属性,和表示当前路由的 route 属性。

key 参数

当用户跳转到含不同参数的同一路由时(例如 /page/:id 路由,从 /page/1 跳转到 /page/2 时),不会重新创建组件,因为两条路由解析的是同一个组件,但是会产生虚拟 DOM diff。这会触发 onupdate 钩子,但不会触发 oninit/oncreate

但是,也有的开发者希望在路由改变时重建组件。为了实现这一点,可以把路由参数和 key 功能结合使用:

m.route(document.body, "/edit/1", {
    "/edit/:key": Edit,
})

路由参数中使用了 key。因为路由参数会称为 vnode 的属性,所以页面切换时,导致 key 改变,从而使组件重新创建(key 的改变告诉虚拟 DOM 引擎旧的组件和新的组件是不同的实体)。

你可以利用该特性,在重新加载路由时,重新创建组件:

m.route.set(m.route.get(), {key: Date.now()})

或者使用 history state 功能实现可重新加载的组件,且不会污染 URL:

m.route.set(m.route.get(), null, {state: {key: Date.now()}})

含复杂参数路由

路由中可以包含复杂的参数。例如,用含斜线的 URL 路径作为路由参数:

m.route(document.body, "/files/pictures/image.jpg", {
    "/files/:file...": Edit,
})

History state

可以充分利用底层的 history.pushState API 来改善用户体验。例如,当用户离开一个页面时,应用可以记住表单状态,在用户通过点击浏览器的返回按钮回到这个页面时,表单中保留这上次填写的内容。

例如,你可以创建一个如下表单:

var state = {
    term: "",
    search: function() {
        // 保存此路由的状态
        // 等效于 `history.replaceState({term: state.term}, null, location.href)`
        m.route.set(m.route.get(), null, {replace: true, state: {term: state.term}})

        // 离开页面
        location.href = "https://google.com/?q=" + state.term
    }
}

var Form = {
    oninit: function(vnode) {
        state.term = vnode.attrs.term || "" // 如果用户点击了返回按钮,则从 `history.state` 属性填充数据
    },
    view: function() {
        return m("form", [
            m("input[placeholder='Search']", {oninput: m.withAttr("value", function(v) {state.term = v}), value: state.term}),
            m("button", {onclick: state.search}, "Search")
        ])
    }
}

m.route(document.body, "/", {
    "/": Form,
})

这样,如果用户搜索时离开了页面,然后通过返回按钮又回到这个页面,则搜索框仍然会填充着搜索词。这种技术可以改善大型应用的表单、以及其他需要持久化状态的应用的用户体验。

修改路由前缀

路由前缀是一个 URL 片段,表示路由使用的基本策略

// 设置为路径名
m.route.prefix("")

// 设置为 querystring
m.route.prefix("?")

// 设置为 hash
m.route.prefix("#")

// 设置为路径名,并用一段路径当前缀
// 例如,应用位于 `http://localhost/my-app`,而其他东西位于 `http://localhost`
m.route.prefix("/my-app")

高级组件方案

你可以指定一个 RouteResolver 对象,而不是把组件映射到路由。RouteResolver 对象包含 onmatch() 和/或 render() 方法。两个方法都是可选的,但至少要又一个方法。

m.route(document.body, "/", {
    "/": {
        onmatch: function(args, requestedPath) {
            return Home
        },
        render: function(vnode) {
            return vnode // 等效于 m(Home)
        },
    }
})

RouteResolvers 对于实现复杂的路由很有用。

封装布局组件

通常需要将组件包裹在可重用的布局中。为了做到这一点,你首先需要创建一个包含可重用部分的组件,用于包裹各种不同的组件:

var Layout = {
    view: function(vnode) {
        return m(".layout", vnode.children)
    }
}

在上面的例子中,布局只包含 <div class="layout"> 和它的子元素,但真正在开发项目时,会复杂的多。

包裹布局的一种方法是在路由中定义一个匿名组件:

// 示例 1
m.route(document.body, "/", {
    "/": {
        view: function() {
            return m(Layout, m(Home))
        },
    },
    "/form": {
        view: function() {
            return m(Layout, m(Form))
        },
    }
})

但是,请注意,因为顶级组件是匿名组件,从 / 路由跳转到 /form 路由(反之依然)将会移除匿名组件,并重新创建 DOM。如果布局组件定义了生命周期方法,每次路由改变时,会触发 oninitoncreate 钩子。

如果你希望布局组件能进行 diff,而不是从头创建,则应该使用 RouteResolver 作为根对象:

// 示例 2
m.route(document.body, "/", {
    "/": {
        render: function() {
            return m(Layout, m(Home))
        },
    },
    "/form": {
        render: function() {
            return m(Layout, m(Form))
        },
    }
})

在这种情况下,布局组件的 oninitoncreate 生命周期方法只有在第一次路由改变时才会触发(假设所有路由使用相同的布局)。

为了理清两个示例之间的区别,示例 1 相当于:

// 功能和示例 1 相同
var Anon1 = {
    view: function() {
        return m(Layout, m(Home))
    },
}
var Anon2 = {
    view: function() {
        return m(Layout, m(Form))
    },
}

m.route(document.body, "/", {
    "/": {
        render: function() {
            return m(Anon1)
        }
    },
    "/form": {
        render: function() {
            return m(Anon2)
        }
    },
})

因为 Anon1Anon2 是不同的组件,它们的子树(包括 Layout)是从头开始创建的。这也是直接使用组件,而不使用 RouteResolver 时会发生的情况。

在示例 2 中,因为 Layout 是所有路由的顶级组件,Layout 组件会进行 diff,并且只有从 Home 切换到 Form 才会触发子元素的重建。

验证

RouterResolver 的 onmatch 钩子可以在路由的顶级组件初始化之前执行一些逻辑。下面的示例显示了如何实现登录验证,除非用户登录,否则阻止用户看到 /secret 页面。

var isLoggedIn = false

var Login = {
    view: function() {
        return m("form", [
            m("button[type=button]", {
                onclick: function() {
                    isLoggedIn = true
                    m.route.set("/secret")
                }
            }, "Login")
        ])
    }
}

m.route(document.body, "/secret", {
    "/secret": {
        onmatch: function() {
            if (!isLoggedIn) m.route.set("/login")
            else return Home
        }
    },
    "/login": Login
})

当应用加载时,onmatch 方法会被调用,以为 isLoggedIn 是 false,所以会跳转到 /login。用户点击登录按钮后,isLoggedIn 被设置为 true,且应用跳转到 /secretonmatch 钩子会再次被调用,因为这次 isLoggedIn 已经是 true 了,所以应用会渲染 Home 组件。

为了简单起见,在上例中,用户的登录状态保存在全局变量中,并且用户点击登录按钮时,仅仅只是改变该变量的值。在真正的项目中,用户需要输入正确的用户名密码,点击登录按钮后向服务器发送请求来验证用户:

var Auth = {
    username: "",
    password: "",

    setUsername: function(value) {
        Auth.username = value
    },
    setPassword: function(value) {
        Auth.password = value
    },
    login: function() {
        m.request({
            url: "/api/v1/auth",
            data: {username: Auth.username, password: Auth.password}
        }).then(function(data) {
            localStorage.setItem("auth-token": data.token)
            m.route.set("/secret")
        })
    }
}

var Login = {
    view: function() {
        return m("form", [
            m("input[type=text]", {oninput: m.withAttr("value", Auth.setUsername), value: Auth.username}),
            m("input[type=password]", {oninput: m.withAttr("value", Auth.setPassword), value: Auth.password}),
            m("button[type=button]", {onclick: Auth.login, "Login")
        ])
    }
}

m.route(document.body, "/secret", {
    "/secret": {
        onmatch: function() {
            if (!localStorage.getItem("auth-token")) m.route.set("/login")
            else return Home
        }
    },
    "/login": Login
})

预加载数据

通常,组件可以在初始化时加载数据。以这种方法加载数据会渲染组件两次(路由一次,请求完成一次)。

var state = {
    users: [],
    loadUsers: function() {
        return m.request("/api/v1/users").then(function(users) {
            state.users = users
        })
    }
}

m.route(document.body, "/user/list", {
    "/user/list": {
        oninit: state.loadUsers,
        view: function() {
            return state.users.length > 0 ? state.users.map(function(user) {
                return m("div", user.id)
            }) : "loading"
        }
    },
})

在上面的例子中,第一次渲染后,会显示 “loading”,因为在请求完成前,state.users 是一个空数组。一旦数据加载完成,组件会进行重绘,并显示用户 id 列表。

RouteResolvers 可以作为渲染组件之前预加载数据的机制,以避免 UI 闪烁:

var state = {
    users: [],
    loadUsers: function() {
        return m.request("/api/v1/users").then(function(users) {
            state.users = users
        })
    }
}

m.route(document.body, "/user/list", {
    "/user/list": {
        onmatch: state.loadUsers,
        render: function() {
            return state.users.map(function(user) {
                return m("div", user.id)
            })
        }
    },
})

上面的示例中,只有请求完成后才会执行 render,因此不再需要三元操作符。

代码拆分

在大型项目中,可能需要按需下载每个路由对应的代码,而不是预先加载所有代码。这种方式称为代码分割或延迟加载。在 Mithril 中,可以通过从 onmatch 钩子返回 Promise 来实现。

下面是最简单的形式:

module.export = {
    view: function() {
        return [
            m(Menu),
            m("h1", "Home")
        ]
    }
}
function load(file) {
    return m.request({
        method: "GET",
        url: file,
        extract: function(xhr) {
            return new Function("var module = {};" + xhr.responseText + ";return module.exports;")
        }
    })
}

m.route(document.body, "/", {
    "/": {
        onmatch: function() {
            return load("Home.js")
        },
    },
})

但是,为了在生产级规模上使用,需要把 Home.js 模块的所有依赖打包到一个单独的文件中。

又许多工具可以实现模块打包进行延迟加载。以下是使用 Webpack 的代码分割系统的示例:

m.route(document.body, "/", {
    "/": {
        onmatch: function() {
            // using Webpack async code splitting
            return new Promise(function(resolve) {
                require(['./Home.js'], resolve)
            })
        },
    },
})