前端的框架普遍使用的 web app 组织方式都是用嵌套结构的路由表配置地址与组件的映射关系。可以清晰有效的表现出各个组件和组件之前的层叠关系,但是没有页面的概念。bnorth 提出了页面的概念,即在路由表中配置的不是地址映射,而是页面的列表。地址的每个 path 段被映射成不同的页面,页面有了自己的实例,有了页面级别的属性方法,有了页面的事件,有了页面的生命周期,更接近 app 的页面组织方式。
路由是 spa 应用的重要概念,与 ajax 和 h5 api 配合,创造了 web app 概念,带来了本地应用般的用户体验。当然也带来了首页加载过慢和不能很好支持 seo 等问题,这不在本文讨论范围。react 最佳实践中,使用 react-router 实现路由功能,通过层叠的路由表将地址与组件进行映射。在使用过程中遇到一些问题:
bnorth 完整使用了 react-router 的浏览器地址管理库。但是修改了地址的解析方式,将路由表由可嵌套的地址匹配模式修改为扁平的页面定义模式,并创造了页面的概念。
bnorth 页面管理器解析地址时根据 url 规则,将地址中 ‘/’ 分隔的每段作为一个 path,每个 path 对应一个页面。path 中可以通过通过参数分隔符(比如 ‘-‘),携带 0 或多个参数,非参数的部分叫做页面名称。
比如以下例子中,一共对应 3 个页面,分别是 pageA,pageB 和 pageC,其中 pageB 还携带了 2 个参数 param1 和 param2。
'/pageA/pageB-param1-param2/pageC'
在通过一个商品列表和商品详情的例子,看看 bnorth 的地址解析和 react-router 的区别。
react-router 模式:
'list/detail/xxx' // xxx 为商品 id
bnorht 模式:
'list/detail-xxx' // xxx 为商品 id
路由表不再是嵌套的地址的匹配模式,而是扁平化的页面声明模式。还是以上面的商品列表与详情举例看看与 react-router 的区别。
原模式:
export default (
<Route path="list" component={List} >
<Route path="detail/:id" component={Detail} >
</Route>
)
新模式:
export default {
'list': List,
'detail-id': Detail,
}
react-router 的路由表实际是嵌套了的 react 组件,根据地址决定子组件的显示方式,层次固定。而偏平化的声明模式其实路由表是一个页面定义对象,没有限定层次。使得页面递归成为可能,比如从商品列表进入详情,再从商品详情中多次点击推荐商品的例子,可对应如下地址,映射为一个商品列表页面和 3 个商品详情页面,实现递归需求。
'/list/detail:xxx1/detail:xxx2/detail:xxx3/' // xxx[n] 为商品 id
地址默认会以 ‘/’ 开头,因此地址匹配时,首先匹配根地址,再分段地址中的其他 path 去匹配。路由表中也需要对根地址进行声明。
无论跳转到哪个地址,根地址是一定会被匹配的,除非路由表中某一地址是 ‘/’ 开头,并且该地址被匹配。比如:
export default {
'/': Root,
'/login': Login,
'list': List,
}
'/', '/list' // 两个地址都会匹配到跟页面
'/login' // 只匹配 login 页面,不会匹配到根页面
与 react-router 不同,bnorth 没有直接渲染路由表配置的组件,而是创造了页面的概念。Router 组件实现页面的匹配,并使 Page 组件成为 路由配置的组件的父组件,为组件提供了路由属性,声明周期管理和控制逻辑。
路由是内容的静态声明,页面则是所定义的内容被地址映射并显示的实例。路由声明时有两个重要属性一个是页面的 component,一个是页面的 controller,分别是页面的组件和页面的控制器。
页面的 component 是普通的 react 组件,是 Page 组件的子组件,是页面具体的呈现的内容。页面组件实现的描画函数,只需要完成描画操作,无需关心描画时具体是数据的哪个部分发生改变,这得益于 react 的算法思想。Page 组件为页面组件注入了重要的页面属性:
页面的 controller 是页面的逻辑部分,是可选的。可以实现页面的属性和方法,定义数据单元,管理事件处理函数,实现事件的处理和为页面操作提供 action 函数。编写页面 controller 实际是编写一个声明对象或者返回声明对象的函数,例如:
Component.controller = (app,page)=>{
stateData: {initialization: 0}, // 定义一个初值为 0,名称为 Data 的数据单元
onActive: ()=>(page._timer = window.setInterval(()=>page.actionAdd(),1000)), // 页面活动时开始计时
onInactive: ()=>window.clearInterval(page._timer), // 页面非活动时取消计时
actionAdd: ()=>page.stateData.update(page.stateData.data()+page.step), // 计时器加一的 action 函数,数据单元操作将因为页面自动更新
step: 5, // 定义页面的属性,步长设置为5,
}
action 是页面的动作函数,页面上的用户操作,比如按钮事件等建议调用页面的动作函数。使用动作函数的好处有如下几点:
动作函数除了在页面声明中定义,还为页面上的简单操作,提供了快捷的构造方式,但是在 render 函数中动态构造,有一定性能消耗。构造时第二个参数指定动作函数的名称,如果不设置参数,将建立匿名函数。
export default props=>{
let { app, page } = props;
return <div onClick={page.action(()=>app.router.back())}>back</div>
}
页面具有自己的生命周期管理:
页面 id 是页面的唯一标示,实际是对匹配路径的一些计算。同一路由可能被带递归页面的地址映射多次,多个页面将获得相同的页面组件和页面控制器,但是路由匹配信息不同,页面 id 也不同。
页面匹配后,匹配的路由信息,匹配时地址所带的页面参数,查询字符串对象,页面状态对象等
页面管理允许页面嵌套,但是为了简单和性能考虑,仅可嵌套一层。嵌套的页面组件实例将以键值对的方式(子页面名=>子页面组件示例),由父页面的 children 属性维护,父页面可自由选择子页面的显示方式,可实现 tabs 或者 tabbar 类型的需求。
首先在路由表中,定义子页面,子页面也是路由表中的页面。
export defualt {
'main:type': {component: Main, subPages: ['sub1', 'sub2']},
'sub1': Sub1,
'sub2': Sub2,
}
在 main 页面中可以使用子页面。
export default props=>{
let {route:{params:{type='sub1'}}, children} = props;
return <div>{children[type]}</div>
}
页面管理模块提供了通过页面 id 或者位置的方式获取页面的方法。页面的逻辑代码中,还可以通过页面的实例的获取方法,获取相关的页面的实例,包括获取前一页面,获取父页面的方法。
得到页面的实例就可以使用页面的属性和方法,操作页面的 action 函数和数据单元,还可以通过 react 函数操作实例,实现对页面的控制。
页面是与用户交互和数据处理的单元,但是有时页面和页面之间存在复杂的关系。比如:
页面之间共享数据,根页面获取和处理了数据,其他页面直接使用根页面的数据,子页面使用父页面的数据或者方法等等
共享根页面数据,根页面数据更新时,当前页面自动更新
Component.controller = (app,page)=>{
stateData: app.router.getPage(0).stateData._id
}
获取父页面的数据
Component.controller = (app,page)=>{
actionSubmit: ()=>console.log(page.getParrentPage().stateData.data())
}
某页面是对之前页面的数据选择,比如一个城市选择页面,在选择后,需要将结果带回。可以通过向指定页面发事件的方式实现。对于指定页面可以通过固定获取前面页面的固定方式,也可以通过给城市选择页面传递一个参数方式将需要放会数据的页面的 id 带过来的方式实现。
主页面
Component.controller = (app,page)=>{
actionSelector: ()=>{
app.router.push(['city',page._id]);
},
onPageCity: city=>{
console.log(city);
}
}
城市选择页面
Component.controller = (app,page)=>{
actionSubmit: city=>{
app.event.emit(page.route.params.id, 'onPageCity', city);
}
}
弹出层是由页面管理器管理的悬浮在页面上面的小组件,可阻塞也可以不阻塞页面的操作和键盘事件。一般用来实现对话框,遮罩,进度条,提示框等内容。
通过页面管理器的弹出层管理函数可以创建和销毁弹出层,可以传递参数,包括弹出层要显示的内容,属性和弹出层的配置。其中弹出层的配置可以指定弹出层是否是模态的,弹出层的内容是以元素渲染,还是以组件方式渲染等等。比如弹出一个页首提示框。实际上 bnorth 组件库以插件形势提供了很多弹出层的函数。
app.router.addPopLayer(<div className="padding-a-">hellow world!</div>);
页面管理器除了需要提供路由与页面的功能,还需要提供页面导航的功能
页面导航实际上是对地址历史记录的操作,跳转,替换,返回等操作。跳转,替换等其实就是对地址上的一段段的 path 以及隐含在 path 中的页面参数,还查询字符串,状态数据,锚点字符串的处理。因此函数参数也是可变参数,多个参数可能对应多个 path 段。
每个参数可能有 3 种形态:
多段 path 组合后将追加在当前地址后面,如果希望从根路径开始,则将某一 path 设置为 ‘/’。如果希望从当前地址中最后 path 的前一 path 开始,则将某一 path 设置为 ‘..’,可多次设置。
有的时候在跳转到某一页面时,可能需要先跳到别的页面处理后,再跳转到该页面。比如该页面需要登录时,先跳转到登录页面,登录成功后再跳转到该页面。页面管理器通过阻塞与恢复机制实现上面需求。在跳转到某页面后发现需要登录时,首先调用阻塞,记录当前页面,然后跳转到登录页面,登录成功后调用恢复函数,即可跳转到之前阻塞的页面。
比如 user 插件就实现了该功能,user 插件注册了 onPageAdd 事件。事件触发时如果页面的路由匹配参数中设置了开启检测标志,而用户没有登录,则阻塞并跳转到登录页面。
页面管理器还提供了错误信息显示页面,调用后将跳转到错误显示页面。不过建议通过 app 的 render 模块接口统一调用。除此之外 render 模块还对错误分级,已经提供了信息显示等接口,具体参见参考手册。