本节参考代码:
react-sider
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ycnXW1SE-1672221029969)(null)]
在大部分企业管理系统中,页面的基础布局所采取的一般都是侧边栏菜单加页面内容这样的组织形式。在成熟的组件库支持下,UI 层面想要做出一个漂亮的侧边栏菜单并不困难,但因为在企业管理系统中菜单还承担着页面导航的功能,于是就导致了两大难题,一是多级菜单如何处理,二是菜单项的子页面(如点击门店管理中的某一个门店进入的门店详情页在菜单中并没有对应的菜单项)如何高亮其隶属于的父级菜单。
为了增强系统的可扩展性,企业管理系统中的菜单一般都需要提供多级支持,对应的数据结构就是在每一个菜单项中都要有 children 属性来配置下一级菜单项。
const menuData = [{
name: '仪表盘',
icon: 'dashboard',
path: 'dashboard',
children: [{
name: '分析页',
path: 'analysis',
children: [{
name: '实时数据',
path: 'realtime',
}, {
name: '离线数据',
path: 'offline',
}],
}],
}];
想要支持多级菜单,首先要解决的问题就是如何统一不同级别菜单项的交互。
在大多数的情况下,每一个菜单项都代表着一个不同的页面路径,点击后会触发 url 的变化并跳转至相应页面,也就是上面配置中的 path 字段。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VwyrSjP3-1672221030167)(null)]
但对于一个父菜单来说,点击还意味着打开或关闭相应的子菜单,这就与点击跳转页面发生了冲突。为了简化这个问题,我们先统一菜单的交互为点击父菜单(包含 children 属性的菜单项)为打开或关闭子菜单,点击子菜单(不包含 children 属性的菜单项)为跳转至相应页面。
首先,为了成功地渲染多级菜单,菜单的渲染函数是需要支持递归的,即如果当前菜单项含有 children 属性就将其渲染为父菜单并优先渲染其 children 字段下的子菜单,这在算法上被叫做深度优先遍历。
renderMenu = data => (
map(data, (item) => {
if (item.children) {
return (
<SubMenu
key={item.path}
title={
<span>
<Icon type={item.icon} />
<span>{item.name}</span>
</span>
}
>
{this.renderMenu(item.children)}
</SubMenu>
);
}
return (
<Menu.Item key={item.path}>
<Link to={item.path} href={item.path}>
<Icon type={item.icon} />
<span>{item.name}</span>
</Link>
</Menu.Item>
);
})
)
这样我们就拥有了一个支持多级展开、子菜单分别对应页面路由的侧边栏菜单。细心的朋友可能还发现了,虽然父菜单并不对应一个具体的路由但在配置项中依然还有 path 这个属性,这是为什么呢?
在传统的企业管理系统中,为不同的页面配置页面路径是一件非常痛苦的事情,对于页面路径,许多开发者唯一的要求就是不重复即可,如上面的例子中,我们把菜单数据配置成这样也是可以的。
const menuData = [{
name: '仪表盘',
icon: 'dashboard',
children: [{
name: '分析页',
children: [{
name: '实时数据',
path: '/realtime',
}, {
name: '离线数据',
path: '/offline',
}],
}],
}];
<Router>
<Route path="/realtime" render={() => <div />}
<Route path="/offline" render={() => <div />}
</Router>
用户在点击菜单项时一样可以正确地跳转到相应页面。但这样做的一个致命缺陷就是,对于 /realtime
这样一个路由,如果只根据当前的 pathname
去匹配菜单项中 path
属性的话,要怎样才能同时也匹配到「分析页」与「仪表盘」呢?因为如果匹配不到的话,「分析页」和「仪表盘」就不会被高亮了。我们能不能在页面的路径中直接体现出菜单项之间的继承关系呢?来看下面这个工具函数。
import map from 'lodash/map';
const formatMenuPath = (data, parentPath = '/') => (
map(data, (item) => {
const result = {
...item,
path: `${parentPath}${item.path}`,
};
if (item.children) {
result.children = formatMenuPath(item.children, `${parentPath}${item.path}/`);
}
return result;
})
);
这个工具函数把菜单项中可能有的 children
字段考虑了进去,将一开始的菜单数据传入就可以得到如下完整的菜单数据。
[{
name: '仪表盘',
icon: 'dashboard',
path: '/dashboard', // before is 'dashboard'
children: [{
name: '分析页',
path: '/dashboard/analysis', // before is 'analysis'
children: [{
name: '实时数据',
path: '/dashboard/analysis/realtime', // before is 'realtime'
}, {
name: '离线数据',
path: '/dashboard/analysis/offline', // before is 'offline'
}],
}],
}];
然后让我们再对当前页面的路由做一下逆向推导,即假设当前页面的路由为 /dashboard/analysis/realtime
,我们希望可以同时匹配到 ['/dashboard', '/dashboard/analysis', '/dashboard/analysis/realtime']
,方法如下:
import map from 'lodash/map';
const urlToList = (url) => {
if (url) {
const urlList = url.split('/').filter(i => i);
return map(urlList, (urlItem, index) => `/${urlList.slice(0, index + 1).join('/')}`);
}
return [];
};
上面的这个数组代表着不同级别的菜单项,将这三个值分别与菜单数据中的 path
属性进行匹配就可以一次性地匹配到所有当前页面应当被高亮的菜单项了。
这里需要注意的是,虽然菜单项中的 path
一般都是普通字符串,但有些特殊的路由也可能是正则的形式,如 /outlets/:id
。所以我们在对二者进行匹配时,还需要引入 path-to-regexp
这个库来处理类似 /outlets/1
和 /outlets/:id
这样的路径。又因为初始时菜单数据是树形结构的,不利于进行 path
属性的匹配,所以我们还需要先将树形结构的菜单数据扁平化,然后再传入 getMeunMatchKeys
中。
import pathToRegexp from 'path-to-regexp';
import reduce from 'lodash/reduce';
import filter from 'lodash/filter';
const getFlatMenuKeys = menuData => (
reduce(menuData, (keys, item) => {
keys.push(item.path);
if (item.children) {
return keys.concat(getFlatMenuKeys(item.children));
}
return keys;
}, [])
);
const getMeunMatchKeys = (flatMenuKeys, paths) =>
reduce(paths, (matchKeys, path) => (
matchKeys.concat(filter(flatMenuKeys, item => pathToRegexp(item).test(path)))
), []);
在这些工具函数的帮助下,多级菜单的高亮也不再是问题了。
在侧边栏菜单中,有两个重要的状态:一个是 selectedKeys
,即当前选定的菜单项;另一个是 openKeys
,即多个多级菜单的打开状态。这二者的含义是不同的,因为在 selectedKeys
不变的情况下,用户在打开或关闭其他多级菜单后,openKeys
是会发生变化的,如下面二图所示,selectedKeys
相同但 openKeys
不同。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5ZlOaCBQ-1672221030035)(null)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GfAJuHj5-1672221030003)(null)]
对于 selectedKeys
来说,由于它是由页面路径(pathname
)决定的,所以每一次 pathname
发生变化都需要重新计算 selectedKeys
的值。又因为通过 pathname
以及最基础的菜单数据 menuData
去计算 selectedKeys
是一件非常昂贵的事情(要做许多数据格式处理和计算),有没有什么办法可以优化一下这个过程呢?
Memoization 可以赋予普通函数记忆输出结果的功能,它会在每次调用函数之前检查传入的参数是否与之前执行过的参数完全相同,如果完全相同则直接返回上次计算过的结果,就像常用的缓存一样。
import memoize from 'memoize-one';
constructor(props) {
super(props);
this.fullPathMenuData = memoize(menuData => formatMenuPath(menuData));
this.selectedKeys = memoize((pathname, fullPathMenu) => (
getMeunMatchKeys(getFlatMenuKeys(fullPathMenu), urlToList(pathname))
));
const { pathname, menuData } = props;
this.state = {
openKeys: this.selectedKeys(pathname, this.fullPathMenuData(menuData)),
};
}
在组件的构造器中我们可以根据当前 props 传来的 pathname
及 menuData
计算出当前的 selectedKeys
并将其当做 openKeys
的初始值初始化组件内部 state。因为 openKeys
是由用户所控制的,所以对于后续 openKeys
值的更新我们只需要配置相应的回调将其交给 Menu
组件控制即可。
import Menu from 'antd/lib/menu';
handleOpenChange = (openKeys) => {
this.setState({
openKeys,
});
};
<Menu
style={{ padding: '16px 0', width: '100%' }}
mode="inline"
theme="dark"
openKeys={openKeys}
selectedKeys={this.selectedKeys(pathname, this.fullPathMenuData(menuData))}
onOpenChange={this.handleOpenChange}
>
{this.renderMenu(this.fullPathMenuData(menuData))}
</Menu>
这样我们就实现了对于 selectedKeys
及 openKeys
的分别管理,开发者在使用侧边栏组件时只需要将应用当前的页面路径同步到侧边栏组件中的 pathname
属性即可,侧边栏组件会自动处理相应的菜单高亮(selectedKeys
)和多级菜单的打开与关闭(openKeys
)。
上述这个场景也是一个非常经典的关于如何正确区分 prop 与 state 的例子。
selectedKeys
由传入的 pathname
决定,于是我们就可以将 selectedKeys
与 pathname
之间的转换关系封装在组件中,使用者只需要传入正确的 pathname
就可以获得相应的 selectedKeys
而不需要关心它们之间的转换是如何完成的。而 pathname
作为组件渲染所需的基础数据,组件无法从自身内部获得,所以就需要使用者通过 props 将其传入进来。
另一方面, openKeys
作为组件内部的 state,初始值可以由 pathname
计算而来,后续的更新则与组件外部的数据无关而是会根据用户的操作在组件内部完成,那么它就是一个 state,与其相关的所有逻辑都可以彻底地被封装在组件内部而不需要暴露给使用者。
简而言之,一个数据如果想成为 prop 就必须是组件内部无法获得的,而且在它成为了 prop 之后,所有可以根据它的值推导出来的数据都不再需要成为另外的 props,否则将违背 React 单一数据源的原则。对于 state 来说也是同样,如果一个数据想成为 state,那么它就不应该再能够被组件外部的值所改变,否则也会违背单一数据源的原则而导致组件的表现不可预测,产生难解的 bug。
严格来说,在这一小节中着重探讨的应用菜单部分的思路并不属于组合式开发思想的范畴,更多地是如何写出一个支持无限级子菜单及自动匹配当前路由的菜单组件。组件当然是可以随意插拔的,但前提是应用该组件的父级部分不依赖于组件所提供的信息。这也是我们在编写组件时所应当遵循的一个规范,即组件可以从外界获取信息并在此基础上进行组件内部的逻辑判断。但当组件向其外界抛出信息时,更多的时候应该是以回调的形式让调用者去主动触发,然后更新外部的数据再以 props 的形式传递给组件以达到更新组件的目的,而不是强制需要在外部再配置一个回调的接收函数去直接改变组件的内部状态。
从这点上来说,组合式开发与组件封装其实是有着异曲同工之妙的,关键都在于对内部状态的严格控制。不论一个模块或一个组件需要向外暴露多少接口,在它的内部都应该是解决了某一个或某几个具体问题的。就像工厂产品生产流水线上的一个环节,在经过了这一环节后产品相较于进入前一定产生了某种区别,不论是增加了某些功能还是被打上某些标签,产品一定会变得更利于下游合作者使用。更理想的情况则是即使删除掉了这一环节,原来这一环节的上下游依然可以无缝地衔接在一起继续工作,这就是我们所说的模块或者说组件的可插拔性。
在本节中我们讨论了配置式菜单的数据结构设计以及基于这样的数据结构,如何做到在页面路径中体现页面与页面之间的父子级关系并在侧边栏菜单中高亮其对应的菜单项,还引出了「记忆化」的概念,为各位在处理组件中的复杂计算提供了一种优化方案以及如何正确地区分组件的 props 与 state,如何在不牺牲组件功能的前提下降低组件使用时的复杂度并提升组件的稳定性。
在下一节中我们将会探讨如何处理企业管理系统中的系统通知以及全局级别的用户操作反馈。
如果你想参与到文章中内容的讨论,欢迎在下面的评论区留言,期待与大家的交流。
本节参考代码:
react-boilerplate-pro
在传统的企业管理系统中一个经常被忽略掉的细节就是对于用户操作的反馈。一方面是因为复杂的业务流程边界条件太多,每一个分支都给予适当的反馈是一件非常烦琐的事情。另一方面往往是因为系统架构设计得不够灵活,只是显示一个操作成功或失败的通知就要写大量重复的逻辑代码,导致专注于业务流程的开发者不愿意花时间去处理操作反馈这一“可有可无”的需求。
在操作反馈通知(Notification)之外,完善的企业管理系统还需要一个全局的消息系统以方便系统管理员向某些或全部成员发送系统消息(Notice),即应用页眉中的通知栏,如 ant-design-pro 中的例子。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gepD5kpU-1672221051173)(null)]
与前端主导的通知系统不同,系统消息更依赖后端的实现。这里我们假设后端提供了获取当前用户未读消息的接口 /notices
,创建相应的 redux action 为:
const getNotices = () => (
createAsyncAction('APP_GET_NOTICES', () => (
api.get('/notices')
))
);
在实现系统消息之前,我们首先要解决应用数据初始化的问题。以系统消息为例,用户在登录系统后点击应用页眉中的通知栏就应当立刻可以看到当前的未读消息。但获取用户未读消息是一个异步的过程,这里的异步请求应该在什么时候发出呢?
最简单的解决方案当然是在用户点击通知栏时发出,但这样做的弊端是用户在阅读消息前需要等待时间,没有充分利用到用户登录系统后但未点击通知栏这段空闲时间,而且如果用户频繁点击通知栏的话还会导致大量的冗余异步请求被发送至后端。
另一个解决方案就是在用户成功登录后发送请求去取得最新的未读消息:
const login = (username, password) => (
createAsyncAction('APP_LOGIN', () => (
api.post('/login', {
username,
password,
})
))
);
const loginUser = (username, password) => {
const action = login(username, password);
return dispatch => (
action(dispatch)
.then(((callbackAction) => {
if (callbackAction.type === 'APP_LOGIN_SUCCESS') {
return getNotices()(dispatch);
}
return null;
}))
);
};
但这样做会有一个例外就是因为系统会记录用户的登录状态,在鉴权过期前用户刷新页面是不需要重新登录的。这时我们就需要在系统初始化时,判断如果用户已经登录就发送获取系统消息的请求。
const initClient = (dispatch) => {
const commonActions = [
dispatch(appAction.getLocale()),
];
const isLogin = !isNil(Cookie.get('user'));
if (isLogin) {
commonActions.push(dispatch(appAction.getNotices()));
}
return commonActions;
};
并在应用的入口文件中将 redux 的 dispatch
方法传入 initClient
:
const { store, history } = createStore(createBrowserHistory(), {});
const application = createApp(store, history);
initClient(store.dispatch);
ReactDOM.render(application, window.document.getElementById('app'));
按照同样的逻辑,我们可以将这一解决方案拓展到更多的需求,如在应用初始化时获取用户信息、国家时区信息等。
在解决了数据获取的问题后,我们的布局组件就可以直接访问 redux store 中 app reducer 下的 notices
数据了。这里关于把 notices
存在哪个 reducer 中可能会有争议,比如它可以属于 app
的 reducer,即存放全局数据的地方,也可以单独创建一个 basicLayout
的 reducer 来存放布局组件所需要的数据,两种方案都是可行的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3rgHP9VY-1672221051083)(null)]
接下来我们要实现的需求是在用户点击某一条消息后将其从消息列表中删除,因为系统消息列表是由后端控制的,所以这时我们需要向后端发送一个 DELETE
的请求以删除当前用户点击的某条消息。这时又会出现一个分歧是,后端在接收到 DELETE
请求后会不会将最新的消息列表再返回给前端。如果后端能够返回的话,我们只需要在 reducer 中替换掉原先的消息列表即可,但如果后端只返回操作成功或失败的话,我们还需要再发送一遍 getNotices
请求去拉取最新的消息列表。
最后,我们还需要处理无未读消息时的情况。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NjsFdy5k-1672221051129)(null)]
回到一开始提到的操作反馈部分。
如果系统中的每个页面都需要独立去处理操作反馈的话,可以预见的是
组件几乎会出现在所有的页面。这样的解决方案不仅非常烦琐而且不利于统一处理通用的逻辑。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-h0AHaai8-1672221051210)(null)]
既然通知组件可以使用绝对定位的方式出现在不同页面的同一位置,那么我们能不能将它的显示和隐藏逻辑也放在全局的层面上进行处理呢?
在回答这个问题前,我们先来写一个简单的基于绝对定位、支持自动隐藏的 UI 通知组件。
class Notification extends Component {
componentDidMount() {
this.timeout = setTimeout(this.props.onDismiss, this.props.timeout);
}
componentWillUnmount() {
clearTimeout(this.timeout);
}
render() {
const {
prefixCls,
title,
content,
onDismiss,
} = this.props;
return (
<div className={prefixCls}>
<Icon className={`${prefixCls}-close`} type="close" onClick={onDismiss} />
<div className={`${prefixCls}-title`}>{title}</div>
<div>{content}</div>
</div>
);
}
}
从上述代码中可以看出,onDismiss
回调就是为使用者隐藏全局通知时准备的。为了在全局的层面上解决通知的问题,让我们在 app reducer 中为通知提供一个存放数据的位置,暂且称为 notification
,其中包含 title
及 content
两个字段。
// app reducer
const defaultState = () => ({
isLogin: false,
user: {},
notification: {
title: '',
content: '',
},
});
// app action
const updateNotification = notification => ({
type: 'APP_UPDATE_NOTIFICATION',
payload: notification,
});
const resetNotification = () => ({
type: 'APP_RESET_NOTIFICATION',
});
// app reducer
const updateNotification = (state, action) => ({
...state,
notification: action.payload,
});
const resetNotification = state => ({
...state,
notification: {
title: '',
content: '',
},
});
再将通知的渲染逻辑添加到 BasicLayout
布局组件中:
renderNotification = () => {
const { notification: { title, content }, resetNotification } = this.props;
if (isEmpty(title) && isEmpty(content)) {
return null;
}
return (
<Notification title={title} content={content} onDismiss={resetNotification} />
);
}
即如果 notification
的 title
和 content
字段都不为空的话,就显示全局通知。同时我们也将重置通知的 action 配置给了 Notification
组件的 onDismiss
回调,即关闭通知相当于重置 notification
字段为空对象。
等这些准备工作都做好后,在具体的页面中显示通知就变得非常容易了:
const mapDispatchToProps = {
updateNotification: appAction.updateNotification,
};
<Button
type="primary"
onClick={() => this.props.updateNotification({
title: 'Notification Title',
content: 'Notification will dismiss after 4.5s.',
})}
>
>
</Button>
将 app action 中的 updateNotification
函数 connect 至相应的页面组件即可。然后在这个页面中,使用者就可以直接调用 this.props.updateNotification
来显示相应的通知了。又因为通知组件本身就支持自动隐藏的功能,使用者也不再需要去处理隐藏的逻辑。
全局通知这个例子很好地为我们诠释了「数据驱动视图」的含义,即根据数据中心是否存在 notification
对象来决定是否渲染通知组件。这打破了原先显示或隐藏通知这样命令式的代码逻辑,让每一次的用户操作从执行命令变为了修改数据,然后再由更新后的新数据去驱动视图进行相应的更新。
熟悉 React 的朋友一定看过下面这个公式:
view = f(state)
即视图是由当前应用状态推导而来。我们再尝试将 redux 也包含进来,在这个公式的基础上进行一下拓展。
根据
view = f1(state) && nextState = f2(currentState, action)
可以推导出
view = f1(f2(currentState, action))
对应到全局通知的例子,f2 就是 app reducer 中处理数据的逻辑,而 f1 就是 BasicLayout
中的渲染逻辑。有了这两个函数,currentState 是确定的一份数据,在具体 action 的驱动下视图就可以自动地进行更新。
因为视图的不可枚举性(无限种可能),命令式的编码方式一直以来都非常不适合前端应用的开发,因为它会导致非常多的边界情况且不可测试。在传统的前端开发中我们很难说出「在什么情况下视图一定是什么样」这样的话,但根据上面的公式推导,如果我们善加利用 React + Redux 的特性的话前端开发也是可以有底气做到「视图结果可预测」的。
组合式开发从本质上来说应用的是分层的思想,合理的分层可以明显地降低应用复杂度并且在应用出现问题时也可以帮助开发者快速定位问题发生的位置。而在分层固定下来后,开发新需求时第一个要去考虑的问题就是新需求应当被放在应用的哪一层去解决。如这一小节中的操作通知,如果被错误地放在页面层去处理的话就会导致许多冗余的代码,以及出现问题时页面的其他逻辑和操作通知的显示逻辑互相混淆,而放在应用层处理就可以帮助页面层屏蔽掉这些问题。
另一方面,在应用层增加了「显示操作通知」这样一个 action 后,对于其下属的页面层来说相当于是增加了一种「显示操作通知」的能力,是否要使用这种能力以及何时使用这种能力的决定权是在页面层手中的。也就是说在增加了这样一种能力之后并没有加重页面层的负担,页面层可以自己决定在适当的时候使用这种能力,而不需要担心不使用这种能力会带来任何的副作用。这种无副作用的特性也从另一个侧面解释了组合式开发中的可插拔性。
在本节中我们以系统消息为例引出了应用数据初始化的解决方案,并结合 React + Redux 实现了一套基于数据驱动的全局操作通知系统,简化了在页面组件中显示全局通知的逻辑。
在下一节中我们将会探讨需要支持多种语言的前端应用架构。
如果你想参与到文章中内容的讨论,欢迎在下面的评论区留言,期待与大家的交流。
本节参考代码:
- react-intl-context
- react-boilerplate-pro/src/app/init/router.js
- react-boilerplate-pro/src/views/login/index.js
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-s7wUs4rJ-1672221068630)(null)]
随着国内市场的逐渐饱和越来越多的中国公司开始将目光投向了国际市场,其中竞争最为激烈的莫过于东南亚。拥有 6.5 亿人口的东南亚,整个地区的 GDP 总和高达 2.6 万亿美元,作为一个互联网行业刚刚进入快速发展阶段的市场其未来的想象空间十分巨大。但与此同时,在这块面积并不算庞大的土地上却分布着大大小小 11 个国家,说着印尼语、马来语、英语、泰语、越南语、中文等几十种不同的语言。
互联网作为一个规模效应非常明显的行业天然就带有扩张的属性,但许多中国公司在出海后面临的第一个挑战就是产品不支持多种语言无法直接进入相应国家的市场。又因为现有系统在架构初期并没有将多语言支持的需求考虑进去,所以临时增加这个功能就变成了一件牵一发而动全身的事情,最后往往无功而返只得重新再做一个新的国际版,也就在这样来来回回的反复中白白浪费掉了许多宝贵的竞争机会。在吃过了这样的亏后,许多公司现在在开始一个新项目时就非常重视产品国际化的需求,希望能在架构初期就打下坚实的基础以至于在需要时可以轻松地产出多个不同语言的版本。
语言文件,顾名思义就是一套对应不同语言翻译的键值对匹配。如果我们要把多语言支持的工作放在前端来做的话,最简单的一个方法就是以 JSON 的格式存储这些语言文件以方便应用在运行时读取相应的翻译值。
locale.json
{
"en-us": {
"appName": "React App Pro",
"siderMenu_dashboard": "Dashboard",
"siderMenu_analysis": "Analysis",
...
},
"zh-cn": {
"appName": "React 中后台应用",
"siderMenu_dashboard": "仪表盘",
"siderMenu_analysis": "分析页",
...
}
}
在存储方面对于追求开发效率的团队来说,将语言文件直接 commit 到项目的代码仓库是一种可行的做法。但如果有条件的话还是应该将下载语言文件这一步放在项目持续集成的流程中,每一次构建项目时从存放语言文件的远端服务器拉取最新的版本,以保证发布到生产环境中的语言文件永远是最新的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AlDdLWCh-1672221068911)(null)]
在讨论具体的产品国际化方案前,首先要明确的一点是,多语言支持这样一个需求根据具体的产品形态可以有两种不同的解决方案。
一是多语言版本切换,也就是在同一个应用中支持用户切换产品的不同语言版本。
二是多语言版本构建,即将同一个应用打包成不同语言的版本,分别发布到不同的生产环境中,但在应用内部不支持多语言切换。
对于多语言版本切换来说,因为在运行时可能会用到所有不同的语言,所以建议将所有语言的翻译都存在同一个 JSON 文件中,这里我们暂且将它命名为 locale.json
,并将不同语言的区域码设置为第一层的 key 值,第二层则为具体页面占位符的 key 值,命名时建议采取页面_模块_值(login_loginPanel_usernameplaceholder)
的方式扁平化地存储这些值以加快查询时的速度。如果有跨平台需求的话,也可以在页面前面加上平台,如 web_login_loginPanel_usernamePlaceholder
、mobileWeb_login_loginPanel_usernamePlaceholder
,便于统一管理不同平台之间语言文件的 key 值。
对于多语言版本构建来说就没有必要把所有的语言翻译都存在一个文件中了,可以将不同的翻译分别存在各自的语言文件中。
en-us.json
{
"appName": "React App Pro",
"siderMenu_dashboard": "Dashboard",
"siderMenu_analysis": "Analysis",
...
}
zh-cn.json
{
"appName": "React 中后台应用",
"siderMenu_dashboard": "仪表盘",
"siderMenu_analysis": "分析页",
...
}
准备好了语言文件,下一步就是将它集成到由 webpack 主导的应用构建过程中。
首先将语言文件 import 到 webpack.config.js
中,然后再通过 webpack 本身提供的 webpack.DefinePlugin
将它注入为应用的一个全局常量。
const localeMessages = require('./src/i18n/locale.json');
new webpack.DefinePlugin({
'process.env.BUILD_LOCALE_MESSAGES': JSON.stringify(localeMessages),
})
在这里,上面提到的多语言版本切换以及多语言版本构建的区别就体现出来了。对于多语言版本切换来说,像上面这样直接将唯一的语言文件注入为应用常量即可。但如果我们想要构建多个不同语言版本应用的话,又该怎么做呢?
为了解决这一问题,让我们再引入一个应用构建时的配置文件,称为 buildConfig.js
。
module.exports = {
'PROD-US': {
locale: 'en-us',
},
'PROD-CN': {
locale: 'zh-cn',
},
localhost: {
locale: 'zh-cn',
},
};
并在 package.json
中分别配置不同语言版本的构建命令。
"scripts": {
"build:PROD-US": "cross-env NODE_ENV=production BUILD_DOMAIN=PROD-US webpack -p --progress --colors",
"build:PROD-CN": "cross-env NODE_ENV=production BUILD_DOMAIN=PROD-CN webpack -p --progress --colors",
}
这样就可以在 webpack 的配置中读取到当前要构建的目标版本语言,然后再据此去匹配相应的语言文件,如 en-us.json
。
const BUILD_DOMAIN = process.env.BUILD_DOMAIN || 'localhost';
const config = buildConfig[BUILD_DOMAIN];
const localeMessages = require(`./src/i18n/${config.locale}.json`);
new webpack.DefinePlugin({
'process.env.BUILD_LOCALE_MESSAGES': JSON.stringify(localeMessages),
})
在成功通过 webpack 将语言文件注入为全局常量后,我们就可以在应用中读取到构建时传入的语言文件了。这里为了方便其他文件引用构建配置及语言文件,我们可以提供一个统一的接口。
src/app/config/buildConfig.js
const buildConfig = process.env.BUILD_CONFIG;
const messages = process.env.BUILD_LOCALE_MESSAGES;
export {
messages,
buildConfig,
};
src/app/init/router.js
import { messages, buildConfig } from '../config/buildConfig';
const { locale } = buildConfig;
const Router = props => (
<ConnectedRouter history={props.history}>
<MultiIntlProvider
defaultLocale={locale}
messageMap={messages}
>
...
</MultiIntlProvider>
</ConnectedRouter>
);
React 在 16.3 版本中引入了新的声明式、可透传 props 的 Context API。受益于这次改动 React 开发者们终于拥有了一个官方提供的安全稳定的 global store,子组件跨层级获取父组件数据及后续的更新都不再是问题。
语言文件注入恰巧就是一个非常适合使用 Context API 来解决的用例,因为:第一,语言文件需要能够跨层级传递到每一个组件中因为每一个组件中都可能存在需要翻译的部分;第二,语言文件并不会经常更新。这里的不经常更新指的是在应用运行时而不是开发过程中不经常更新,于是也就避免了 Context 中的数据变化引起应用整体重绘所带来的性能问题。
让我们先来创建一个存放语言文件的 Context。
import React from 'react';
const { Provider, Consumer } = React.createContext({
locale: '',
messages: {},
formatMessage: () => {},
});
export {
Provider,
Consumer,
};
再将通过 props 传入的国家码、语言文件及读取语言文件中某一个 key 值的函数注入到 Context 的 value
对象中。
import { Provider } from './IntlContext';
class IntlProvider extends Component {
constructor(props) {
super(props);
this.state = {
value: {
locale: props.locale,
messages: props.messages,
formatMessage: this.formatMessage,
},
};
}
formatMessage = (config) => {
const { id } = config;
const message = this.state.value.messages[id];
if (message === undefined) {
console.warn(`[react-intl-context]: Message key ${id} is undefined. Fallback to empty string.`);
return '';
}
return message;
}
render() {
return (
<Provider value={this.state.value}>
{this.props.children}
</Provider>
);
}
}
然后再写一个高阶组件作为 Context 的 Consumer。
import React from 'react';
import { Consumer } from './IntlContext';
const injectIntl = (WrappedComponent) => {
const InjectIntl = props => (
<Consumer>
{value => <WrappedComponent {...props} intl={value} />}
</Consumer>
);
return InjectIntl;
};
export default injectIntl;
最后我们只需要将页面组件包裹在 injectIntl
这个高阶组件中,页面组件就可以多接收到一个名为 intl
的 props,直接调用 this.props.intl.formatMessage
并传入相应的占位符 key 值即可读取到语言文件中的相应翻译。
import { injectIntl } from 'react-intl-context';
class OutletDetail extends Component {
render() {
const { intl } = this.props;
return (
<div className="view-outletDetail">
<Button>
{intl.formatMessage({ id: 'outletDetail_showNotification' })}
</Button>
</div>
);
}
}
export default injectIntl(OutletDetail);
在读取语言文件 key 值的方法上,多语言版本切换与多语言版本构建之间也有着细微的差别,具体的处理方法可以参考 react-intl-context 中的 MultiIntlProvider 和 IntlProvider。
来看一下最终的效果。
英文:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ul8D3cLe-1672221068988)(null)]
中文:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3mlM6mtS-1672221068699)(null)]
在处理多语言支持这样一个需求时,我们再次印证了组合式开发其实是一个赋能的过程,即在增加了某一层或某一个模块后,实际上为其下游使用者赋予了某种没有副作用的能力。如多语言支持中的 formatMessage
方法,在页面有国际化的需求时可以随时调用它来获取翻译值,而在页面没有国际化的需求时又可以安全地忽略它。甚至在抽掉 IntlContext
后其下属的页面层虽然失去了获取翻译的能力,却并不会影响到页面层原先拥有的其他能力。
这也就是组合式开发思想的精髓所在,它不需要外部为了它去进行复杂的适配而是通过自身向外部赋能。如果应用中的每一个模块都可以达到可组合、可插拔的程度,那么很多时候我们解决一个问题的方式就会从增加一个新模块变为灵活地组合已有模块,这将大大减少所需要的开发时间并降低 bug 出现的几率。
在本节中我们剖析了多语言版本切换及多语言版本构建之间的相同与不同,为搭建一个支持多种语言的前端应用打下了良好的基础。
如果你想参与到文章中内容的讨论,欢迎在下面的评论区留言,期待与大家的交流。
在人们的传统印象中,前端一直都是很薄的一层,向上不能影响后端数据,向下不能改变产品设计,只是相当于数据与界面之间的一个连接层,单独拿出来后就将失去其大部分的价值。但随着单页应用的普及,越来越多的重型前端应用被开发了出来并逐渐成为了人们常用的生产力工具中重要的组成部分。
这其中最经典的一个应用莫过于「可视化页面搭建工具」。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-C5HguAOs-1672221078420)(null)]
对于任何一家有运营需求的公司,「可视化页面搭建工具」都是一个刚需,我们很难想象有哪家公司的前端工程师每天的工作就是做生命周期只有几天甚至几小时的活动页。所以一直以来「可视化页面搭建工具」在前端开发界都不是一个新鲜的议题。从 20 年前的 Dreamweaver 开始,一直到最近淘宝推出的 飞冰(ice),其本质上的思路都是类似的,即基于组件的模块化页面搭建。
在讨论具体的页面搭建工具之前,我们首先要明确一个问题,那就是谁是页面搭建工具的目标用户以及页面搭建工具能够帮助这些目标用户解决什么问题?
结合目前市面上已经推出的产品,可视化页面搭建工具的目标用户大致可以分为两类:一类是非技术的运营(产品)人员,主要使用场景为更新较为频繁的促销页、活动页等;另一类是非前端开发的技术人员,主要使用场景为简单的内部管理系统搭建。而根据不同的使用场景及需求,页面搭建工具最终交付的成品也不尽相同。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-B9PqKmMl-1672221078271)(null)]
常见的可视化页面搭建工具一般都会包含页面预览区、组件选择区及布局调整区(如调整组件顺序等)等三个部分。在从组件选择区选择了某几个组件后,每个被选用的组件还会有各自的属性编辑界面,一般为弹窗的形式,如下图所示的表格组件编辑界面。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-H21qKSSh-1672221078353)(null)]
最终的产出就是一段描述当前页面布局与内容的 DSL,通常以 JSON 的格式存储。
{
"pageId": 1,
"pageUrl": "/11-11-promo/electronics",
"pageTitle": "双十一大促 - 家电专场",
"layout": "two-columns",
"components": {
"two-columns": {
"firstCol": [{
"componentName": "list",
"componentProps": [{
"title": "促销家电列表",
"data": [{
"name": "电视机"
}, {
"name": "洗衣机"
}, {
"name": "冰箱"
}]
}]
}],
"secondCol": [{
"componentName": "list",
"componentProps": [{
"title": "促销家电列表",
"data": [{
"name": "电视机"
}, {
"name": "洗衣机"
}, {
"name": "冰箱"
}]
}]
}]
}
}
}
与之相配合的在客户端代码中还需要有两个解析器。第一个解析器是路由解析器,即根据当前页面路径向后端发送请求拿到对应页面的 DSL 数据。第二个解析器是在拿到这段 DSL 数据后对 components
字段进行解析然后按照设置的布局逐个渲染配置好的组件。
这种架构非常适合处理内容展示页面的需求,从技术角度来讲也很适合做服务端渲染因为每个页面的渲染结果完全是数据驱动的,后端返回的服务端渲染结果就是最终前端展示的 HTML。但这种方案的局限性在于无法动态更新页面数据,因为数据和组件的配置是完全绑定的想要更新页面数据就需要去更改组件的配置。
为了实现动态更新数据的需求,我们需要将组件的数据源与组件的配置解耦,也就是说我们需要将原先组件中配置好的数据替换为一个后端的数据接口,让后端的数据接口可以直接与组件进行对接。这样就实现了数据与配置之间的解耦,即不需要更新组件的配置就可以直接更新组件的展示数据。这样的灵活性对于促销页、活动页等数据变动频繁的业务场景来说是非常有帮助的。
{
"pageId": 1,
"pageUrl": "/11-11-promo/electronics",
"pageTitle": "双十一大促 - 家电专场",
"layout": "two-columns",
"components": {
"two-columns": {
"firstCol": [{
"componentName": "list",
"componentApi": "/api/11-11-promo/electronics/list",
"componentProps": [{
"title": "促销家电列表"
}]
}],
"secondCol": [{
"componentName": "list",
"componentApi": "/api/11-11-promo/electronics/list",
"componentProps": [{
"title": "促销家电列表"
}]
}]
}
}
}
除了直接配置数据接口外,另一种常见的做法是将数据接口统一处理为数据资产,在使用者配置组件的数据源时,让其可以在所有相关的数据资产中选择需要的部分,然后再转化为具体的数据接口,保存在组件配置中。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rc0ZbQ5K-1672221078516)(null)]
但在引入了异步数据之后,有一个必须要解决的问题就是何时发出这些数据请求。这里推荐使用高阶组件的方法来解决这一问题,即抽象出一个专门根据组件的 componentApi
属性发送请求的高阶组件,并将它包裹在所有需要发送异步请求的组件之上。
function fetchData(WrappedComponent) {
class FetchData extends Component {
state = {
data: {},
}
componentDidMount() {
const { componentApi } = this.props;
api.get(componentApi)
.then((response) => {
this.setState({
data: response,
});
});
}
render() {
return <WrappedComponent {...props} data={this.state.data} />;
}
}
return FetchData;
}
上面提到的动态页面虽然做到了动态更新数据,但组件与组件之间却仍是独立工作的沙盒模式无法交换数据,也无法感知或响应其他组件的变化。
为了实现组件之间的通信与简单交互,我们需要将不同的组件通过一些自定义的钩子 hook 起来。如下面这个例子中,list 组件 componentQuery
中的 type
字段就来自于 dropdown 组件的 activeKey
。用户在改变 dropdown 组件的 activeKey
时,也会更新 list 组件获取数据所调用的 API,如 "/api/11-11-promo/electronics/list?type=kitchen"
或 "/api/11-11-promo/electronics/list?type=living"
等。
{
"pageId": 1,
"pageUrl": "/11-11-promo/electronics",
"pageTitle": "双十一大促 - 家电专场",
"layout": "two-columns",
"components": {
"two-columns": {
"firstCol": [{
"componentName": "dropdown",
"componentProps": [{
"title": "选择家电种类",
"defaultActiveKey": "kitchen",
"data": [{
"key": "kitchen",
"value": "厨房"
}, {
"key": "living",
"value": "客厅"
}, {
"key": "bedroom",
"value": "卧室"
}]
}]
}],
"secondCol": [{
"componentName": "list",
"componentApi": "/api/11-11-promo/electronics/list",
"componentQuery": {
"type": "dropdown_activeKey"
},
"componentProps": [{
"title": "促销家电列表"
}]
}]
}
}
}
这样我们就实现了组件与组件之间的联动,大大拓展了页面搭建工具可覆盖的需求范围。在达到了这个阶段之后,我们甚至可以说使用页面搭建工具搭建出来的页面与日常工程师手写的页面之间区别已经不大了。但与此同时随着业务需求的复杂程度越来越高,使用页面搭建工具生成的 DSL 也会越来越复杂,它的表现力相较于代码究竟孰优孰劣,这就很考验平台设计者的内功了。
可视化页面搭建工具的核心价值就是以最小的代价快速创建大量时效性较强的页面,在创建页面不再是一个问题后,如何管理这些被创建出来的页面成为了下一个待解决的问题。
假设我们现在已经创建了一个营销页面的 MongoDB 集合,每个页面都有一个自己的 uuid 如 580d69e57f038c01cc41127e
。最简单的情况下,我们可以在应用中创建一个例如 /promotion/:id
这样的路由,然后根据每个页面的 uuid 来获取页面的 DSL 数据。这样的做法非常简洁,但存在的问题是所有营销页的 url 都是无含义的 uuid,既不利于 SEO,也不利于用户以输入 url 的方式到达页面。
针对这个问题,我们需要在页面的 url 和 uuid 之间再建立起一个一一对应的关系,即后端除了要提供获取页面 DSL 数据的接口外,还需要再提供一个处理动态路由的接口。如 580d69e57f038c01cc41127e
对应的页面 url 为 /double11-promotion
,那么在用户到达 /double11-promotion
页面后,前端需要先将页面的 url 发送至后端的动态路由接口以拿到页面真正的 uuid,然后再调用获取页面 DSL 数据的接口拿到页面中配置好的组件数据并渲染。
这时另一个问题出现了,前端如何区分 /double11-promotion
这种动态路由和 /home
这种固定路由呢?在前文中我们提到过 react-router 是按照所有路由定义的顺序逐一去匹配路由的,如果当前的页面路径和所有的路由都匹配不上的话,则会渲染在最后定义的 404 页面。换句话说,在简单的应用中路由只分为两种,一种是定义好的固定路由,另一种是会由 404 页面统一处理的其他路由。
const Router = ({ history }) => (
<ConnectedRouter history={history}>
<div>
<Route path="/home" component={Home} />
<Route path="/login" component={Login} />
<Route path="/store/:id" component={Store} />
<Route path="/404" component={NotFound} />
<Route component={DynamicRoute} /> // try to query the url from backend
</div>
</ConnectedRouter>
);
但在引入了动态路由后,第三种路由就出现了,首先它需要和定义好的固定路由之间没有冲突,即如果应用中已经定义了 /home
的话,由页面搭建平台搭建出来的页面的 url 就不能够再是 /home
。否则的话因为固定路由 /home
的匹配优先级较高,用户在到达 /home
页面后永远都只会看到固定路由 /home
的界面。其次在和所有固定路由尝试匹配失败后,我们不再直接将当前 url 交给 404 页面处理,而是交给动态路由组件,由动态路由组件尝试将当前 url 发送至后端的路由服务,查找当前 url 是否是页面集合中的一个有效 url,如果是则返回页面的 uuid,如果不是则返回查找失败再由前端主动地将页面 url 替换为 /404
并渲染 404 页面。也就是说,对于所有无法和固定路由相匹配的 url,我们都先假定它是一个动态路由,尝试调用后端的路由服务来获取页面数据,如果后端的路由服务也查找不到它的话,再将其认定为是 404 的情况。
在前后端分离架构的背景下,前端已经逐渐代替后端接管了所有固定路由的判断与处理,但在动态路由这样一个场景下,我们会发现单纯前端路由服务的灵活度是远远不够的。在用户到达某个页面后,可供下一步逻辑判断的依据就只有当前页面的 url,而根据 url 后端的路由服务是可以返回非常丰富的数据的。
常见的例子如页面的类型。假设应用中营销页和互动页的渲染逻辑并不相同,那么在页面的 DSL 数据之外,我们就还需要获取到页面的类型以进行相应的渲染。再比如页面的 SEO 数据,创建和更新时间等等,这些数据都对应用能够在前端灵活地展示页面,处理业务逻辑有着巨大的帮助。
甚至我们还可以推而广之,彻底抛弃掉由 react-router 等提供的前端路由服务,转而写一套自己的路由分发器,即根据页面类型的不同分别调用不同的页面渲染服务,以多种类型页面的方式来组成一个完整的前端应用。
在了解了可视化页面搭建工具大体的工作流程后,我们不得不承认目前的可视化页面搭建工具仍存在着诸多不足。尤其是在搭建动态可交互页面方面,组件之间烦琐的依赖关系甚至比源代码更难管理,出了问题之后 debug 的过程也非常令人头痛。另一方面,上述提到的这种页面搭建方式最终都要落地到一个具体的包含两个特殊解析器的应用中,再加上应用本身的构建和部署过程全程无专业前端开发参与几乎是不可能的。
为了解决这一问题,许多专业的前端团队也在尝试着从工程的角度出发,将项目脚手架部分也一并 GUI 化,提供可视化的操作界面并覆盖项目构建、打包、发布的全过程。但这让整个工具的使用复杂度又上升了一个等级,虽然拥有了对于最终产出结果源码级别的控制能力,但对非技术人员非常不友好,极大地限制了工具可以覆盖到的用户群体。
关于这一问题,笔者这里提供另一种不成熟的思路供各位一起讨论。
其实从本质上讲,项目脚手架及后续的打包、发布与页面搭建之间是没有直接的联系的,也就是说我们能不能将二者完全拆分开来当成两个独立的工具分别开发?让页面的归页面,应用的归应用。再结合小册中一直强调的组合式开发的理念,假设我们现在已经拥有了一个可以很好地解决独立页面开发的工具,使用者在配置完了应用中所有的页面(只包含页面的具体内容,不包含菜单、页眉、页脚等全局组件)后,再使用另一个应用构建工具将配置好的页面嵌入应用路由中,然后选择性地开启一些全局功能,如页面布局、权限配置、菜单管理等,并最终将配置好的应用通过 webpack 等打包工具编译成生产环境中可以运行的 HTML、CSS 和 JavaScript,再通过持续集成工具打上版本 tag 发布到服务器上。
当然,目前这些都仍只是抽象的想法,具体落地时一定还会遇到各种各样的问题。但简而言之,软件工程行业与传统行业最大的区别就是软件工程行业从不重复自己,我们坚信同样的事情在第二次做时受益于第一次积累下来的经验,我们一定会做得比上一次更好。
至此,小册的核心内容部分就已经完结了,感谢每一位能够坚持读到这里的朋友,也推荐各位在读小册的同时再多花点时间将小册中提到的五个示例项目 clone 到本地跑一遍并阅读下相关的源码,相信会有更多的收获。
这本小册从如何搭建一个前端项目的脚手架讲起,一步步带领大家完成了一个基础的企业管理系统脚手架,其中包含了页面基础布局、页面级别的前端权限管理系统、自动匹配路由的无限级菜单、数据驱动的全局通知以及支持按需加载的系统多语言切换。这些当然不是企业管理系统的全部,但希望大家都能够在消化吸收了这些模块中的最佳实践后,举一反三地将这些知识与经验迁移到更多具体的业务需求中去。
更重要的是希望大家能够理解「组合式开发」的真正含义。软件应用作为一个复杂系统,归根结底其降低内部复杂度的方式就是分层。通过不断地分层将整体复杂度合理地分散在每一个模块中并将其封装起来,从而达到极大地降低拼接不同模块时复杂度的目的。
而对于任意一个模块来说,衡量其优秀程度的维度除了能否和当前系统配合起来完成具体任务外还有两个重要的维度。
一是这个模块是否和其上下游的模块强耦合,即其他模块是否需要为了适应它而做出特殊的调整。好的模块是为其他模块赋能的,即赋予其他模块更多的能力而不需要其他模块做出任何的妥协或牺牲。
二是这个模块是否能够无缝地迁移到其他的系统并完成同样的工作。这就涉及软件工程中经常讲的可复用性,即一个模块自身的封装是否足够优秀以至于并不会因为所处系统的不同而需要做特殊的调整。
在这三个维度上都能够达标的模块才称得上是优秀的模块。但有时因为具体的业务需求我们可能会在这三个维度上有所取舍,比如牺牲一些可复用性去追求和上下游模块的弱耦合,这就需要具体情况具体分析了。
在小册的开篇我们提到过「组件化」并不是解决软件开发的银弹,同样的「组合式开发」也不是。它们都是工程师们在开发项目中积累下来的经验,只有在领会了其中要领并将多种方法与经验融会贯通后,才能够真正地在日常工作中不断提升自己。
这个小册由于是从一个具体的前端应用直接切入开发技巧与理念的讲解,所以对于刚入门 React 的朋友来说可能存在着一定的基础知识部分梳理的缺失,这里为大家提供一份较为详细的 React 开发者学习路线图,希望能够为刚入门 React 的朋友提供一条规范且便捷的学习之路。
react-developer-roadmap