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 |
m.route.link
eventHandler = m.route.link(vnode)
参数 | 类型 | 是否必须 | 描述 |
---|---|---|---|
vnode | Vnode | 是 | 这个方法意味着和 <a> vnode 的 oncreate 钩子一起使用 |
返回 | 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。使用这种策略的 URL 看起来是:
http://localhost/#!/page1
- 使用 querystring。使用这种策略的 URL 看起来是:
http://localhost/?/page1
- 使用 pathname。使用这种策略的 URL 看起来是:
http://localhost/page1
使用 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")
]
}
}
在上面的代码中,有两个组件:Home
和 Page1
。每个组件都包含一个菜单和一些文本,菜单本身也被定义成了一个组件:
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.link
或 m.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 的属性对象添加 projectID
和 userID
两个属性。
除了路由参数之外,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。如果布局组件定义了生命周期方法,每次路由改变时,会触发 oninit
和 oncreate
钩子。
如果你希望布局组件能进行 diff,而不是从头创建,则应该使用 RouteResolver 作为根对象:
// 示例 2
m.route(document.body, "/", {
"/": {
render: function() {
return m(Layout, m(Home))
},
},
"/form": {
render: function() {
return m(Layout, m(Form))
},
}
})
在这种情况下,布局组件的 oninit
和 oncreate
生命周期方法只有在第一次路由改变时才会触发(假设所有路由使用相同的布局)。
为了理清两个示例之间的区别,示例 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)
}
},
})
因为 Anon1
和 Anon2
是不同的组件,它们的子树(包括 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,且应用跳转到 /secret
,onmatch
钩子会再次被调用,因为这次 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)
})
},
},
})