= [];
171 |
172 | map(dataset, (example: {
173 | "sepalLength": number,
174 | "sepalWidth": number,
175 | "petalLength": number,
176 | "petalWidth": number,
177 | "species": string,
178 | }) => {
179 | const input: any = omit(example, 'species');
180 | inputValues = inputValues.concat(values(input));
181 | });
182 |
183 | const input = new Array2D(
184 | [datasetSize, inputValues.length / datasetSize],
185 | inputValues,
186 | ).transpose();
187 |
188 | return input;
189 | }
190 | ```
191 |
192 | ## 小结
193 | 在理解了数据向量化及矩阵的概念后,相信大家已经可以将大样本量,以数组形式存储的数据转换为适合进行深度学习模型训练的大型矩阵了,接下来让我们从如何初始化参数开始,一步步搭建我们的第一个深度学习模型。
--------------------------------------------------------------------------------
/2018/写给 Web 开发者的深度学习教程 - 数据标准化 & 参数初始化/equation1.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/2018/React v16.3 版本新生命周期函数浅析及升级方案/React v16.3 版本新生命周期函数浅析及升级方案.md:
--------------------------------------------------------------------------------
1 | # React v16.3 版本新生命周期函数浅析及升级方案
2 | 一个月前,React 官方正式发布了 v16.3 版本。在这次的更新中,除了前段时间被热烈讨论的[新 Context API](https://zhuanlan.zhihu.com/p/33925435) 之外,新引入的两个生命周期函数 `getDerivedStateFromProps`,`getSnapshotBeforeUpdate` 以及在未来 v17.0 版本中即将被移除的三个生命周期函数 `componentWillMount`,`componentWillReceiveProps`,`componentWillUpdate` 也非常值得我们花点时间去探究一下其背后的原因以及在具体项目中的升级方案。
3 |
4 | ## componentWillMount
5 | ### 首屏无数据导致白屏
6 | 在 React 应用中,许多开发者为了避免第一次渲染时页面因为没有获取到异步数据导致的白屏,而将数据请求部分的代码放在了 `componentWillMount` 中,希望可以避免白屏并提早异步请求的发送时间。但事实上在 `componentWillMount` 执行后,第一次渲染就已经开始了,所以如果在 `componentWillMount` 执行时还没有获取到异步数据的话,页面首次渲染时也仍然会处于没有异步数据的状态。换句话说,组件在首次渲染时总是会处于没有异步数据的状态,所以不论在哪里发送数据请求,都无法直接解决这一问题。而关于[提早发送数据请求](https://gist.github.com/bvaughn/89700e525ff423a75ffb63b1b1e30a8f),官方也鼓励将数据请求部分的代码放在组件的 `constructor` 中,而不是 `componentWillMount`。
7 |
8 | 另一个常见的 `componentWillMount` 的用例是在服务端渲染时获取数据,因为在服务端渲染时 `componentDidMount` 是不会被调用的。针对这个问题,笔者这里提供两种解法。第一个简单的解法是将所有的数据请求都放在 `componentDidMount` 中,即只在客户端请求异步数据。这样做可以避免在服务端和客户端分别请求两次相同的数据(`componentWillMount` 在客户端渲染时同样会被调用到),但很明显的缺点就是无法在服务端渲染时获取到页面渲染所需的所有数据,所以如果我们需要保证服务端返回的 HTML 就是用户最终看到的 HTML 的话,我们可以将每个页面的数据获取逻辑单独抽离出来,然后一一对应到相应的页面,在服务端根据当前页面的路由找到相应的数据请求,利用链式的 Promise 在渲染最终的页面前就将数据塞入 redux store 或其他数据管理工具中,这样服务端返回的 HTML 就是包含异步数据的结果了。
9 |
10 | ### 事件订阅
11 | 另一个常见的用例是在 `componentWillMount` 中订阅事件,并在 `componentWillUnmount` 中取消掉相应的事件订阅。但事实上 React 并不能够保证在 `componentWillMount` 被调用后,同一组件的 `componentWillUnmount` 也一定会被调用。一个当前版本的例子如服务端渲染时,`componentWillUnmount` 是不会在服务端被调用的,所以在 `componentWillMount` 中订阅事件就会直接导致服务端的内存泄漏。另一方面,在未来 React 开启异步渲染模式后,在 `componentWillMount` 被调用之后,组件的渲染也很有可能会被其他的事务所打断,导致 `componentWillUnmount` 不会被调用。而 `componentDidMount` 就不存在这个问题,在 `componentDidMount` 被调用后,`componentWillUnmount` 一定会随后被调用到,并根据具体代码清除掉组件中存在的事件订阅。
12 |
13 | ### 升级方案
14 | 将现有 `componentWillMount` 中的代码迁移至 `componentDidMount` 即可。
15 |
16 | ## componentWillReceiveProps
17 | ### 更新由 props 决定的 state 及处理特定情况下的回调
18 | 在老版本的 React 中,如果组件自身的某个 state 跟其 props 密切相关的话,一直都没有一种很优雅的处理方式去更新 state,而是需要在 `componentWillReceiveProps` 中判断前后两个 props 是否相同,如果不同再将新的 props 更新到相应的 state 上去。这样做一来会破坏 state 数据的单一数据源,导致组件状态变得不可预测,另一方面也会增加组件的重绘次数。类似的业务需求也有很多,如一个可以横向滑动的列表,当前高亮的 Tab 显然隶属于列表自身的状态,但很多情况下,业务需求会要求从外部跳转至列表时,根据传入的某个值,直接定位到某个 Tab。
19 |
20 | 在新版本中,React 官方提供了一个更为简洁的生命周期函数:
21 |
22 | ```javascript
23 | static getDerivedStateFromProps(nextProps, prevState)
24 | ```
25 |
26 | 一个简单的例子如下:
27 |
28 | ```javascript
29 | // before
30 | componentWillReceiveProps(nextProps) {
31 | if (nextProps.translateX !== this.props.translateX) {
32 | this.setState({
33 | translateX: nextProps.translateX,
34 | });
35 | }
36 | }
37 |
38 | // after
39 | static getDerivedStateFromProps(nextProps, prevState) {
40 | if (nextProps.translateX !== prevState.translateX) {
41 | return {
42 | translateX: nextProps.translateX,
43 | };
44 | }
45 | return null;
46 | }
47 | ```
48 |
49 | 乍看下来这二者好像并没有什么本质上的区别,但这却是笔者认为非常能够体现 React 团队对于软件工程深刻理解的一个改动,即 **React 团队试图通过框架级别的 API 来约束或者说帮助开发者写出可维护性更佳的 JavaScript 代码**。为了解释这点,我们再来看一段代码:
50 |
51 | ```javascript
52 | // before
53 | componentWillReceiveProps(nextProps) {
54 | if (nextProps.isLogin !== this.props.isLogin) {
55 | this.setState({
56 | isLogin: nextProps.isLogin,
57 | });
58 | }
59 | if (nextProps.isLogin) {
60 | this.handleClose();
61 | }
62 | }
63 | ```
64 |
65 | ```javascript
66 | // after
67 | static getDerivedStateFromProps(nextProps, prevState) {
68 | if (nextProps.isLogin !== prevState.isLogin) {
69 | return {
70 | isLogin: nextProps.isLogin,
71 | };
72 | }
73 | return null;
74 | }
75 |
76 | componentDidUpdate(prevProps, prevState) {
77 | if (!prevState.isLogin && this.props.isLogin) {
78 | this.handleClose();
79 | }
80 | }
81 | ```
82 |
83 | 通常来讲,在 `componentWillReceiveProps` 中,我们一般会做以下两件事,一是根据 props 来更新 state,二是触发一些回调,如动画或页面跳转等。在老版本的 React 中,这两件事我们都需要在 `componentWillReceiveProps` 中去做。而在新版本中,官方将更新 state 与触发回调重新分配到了 `getDerivedStateFromProps` 与 `componentDidUpdate` 中,使得组件整体的更新逻辑更为清晰。而且在 `getDerivedStateFromProps` 中还禁止了组件去访问 this.props,强制让开发者去比较 nextProps 与 prevState 中的值,以确保当开发者用到 `getDerivedStateFromProps` 这个生命周期函数时,就是在根据当前的 props 来更新组件的 state,而不是去做其他一些让组件自身状态变得更加不可预测的事情。
84 |
85 | ### 升级方案
86 | 将现有 `componentWillReceiveProps` 中的代码根据更新 state 或回调,分别在 `getDerivedStateFromProps` 及 `componentDidUpdate` 中进行相应的重写即可,注意新老生命周期函数中 `prevProps`,`this.props`,`nextProps`,`prevState`,`this.state` 的不同。
87 |
88 | ## componentWillUpdate
89 | ### 处理因为 props 改变而带来的副作用
90 | 与 `componentWillReceiveProps` 类似,许多开发者也会在 `componentWillUpdate` 中根据 props 的变化去触发一些回调。但不论是 `componentWillReceiveProps` 还是 `componentWillUpdate`,都有可能在一次更新中被调用多次,也就是说写在这里的回调函数也有可能会被调用多次,这显然是不可取的。与 `componentDidMount` 类似,`componentDidUpdate` 也不存在这样的问题,一次更新中 `componentDidUpdate` 只会被调用一次,所以将原先写在 `componentWillUpdate` 中的回调迁移至 `componentDidUpdate` 就可以解决这个问题。
91 |
92 | ### 在组件更新前读取 DOM 元素状态
93 | 另一个常见的 `componentWillUpdate` 的用例是在组件更新前,读取当前某个 DOM 元素的状态,并在 `componentDidUpdate` 中进行相应的处理。但在 React 开启异步渲染模式后,render 阶段和 commit 阶段之间并不是无缝衔接的,也就是说在 render 阶段读取到的 DOM 元素状态并不总是和 commit 阶段相同,这就导致在
94 | `componentDidUpdate` 中使用 `componentWillUpdate` 中读取到的 DOM 元素状态是不安全的,因为这时的值很有可能已经失效了。
95 |
96 | 为了解决上面提到的这个问题,React 提供了一个新的生命周期函数:
97 |
98 | ```javascript
99 | getSnapshotBeforeUpdate(prevProps, prevState)
100 | ```
101 |
102 | 与 `componentWillUpdate` 不同,`getSnapshotBeforeUpdate` 会在最终的 render 之前被调用,也就是说在 `getSnapshotBeforeUpdate` 中读取到的 DOM 元素状态是可以保证与 `componentDidUpdate` 中一致的。虽然 `getSnapshotBeforeUpdate` 不是一个静态方法,但我们也应该尽量使用它去返回一个值。这个值会随后被传入到 `componentDidUpdate` 中,然后我们就可以在 `componentDidUpdate` 中去更新组件的状态,而不是在 `getSnapshotBeforeUpdate` 中直接更新组件状态。
103 |
104 | 官方提供的一个例子如下:
105 |
106 | ```javascript
107 | class ScrollingList extends React.Component {
108 | listRef = null;
109 |
110 | getSnapshotBeforeUpdate(prevProps, prevState) {
111 | // Are we adding new items to the list?
112 | // Capture the scroll position so we can adjust scroll later.
113 | if (prevProps.list.length < this.props.list.length) {
114 | return (
115 | this.listRef.scrollHeight - this.listRef.scrollTop
116 | );
117 | }
118 | return null;
119 | }
120 |
121 | componentDidUpdate(prevProps, prevState, snapshot) {
122 | // If we have a snapshot value, we've just added new items.
123 | // Adjust scroll so these new items don't push the old ones out of view.
124 | // (snapshot here is the value returned from getSnapshotBeforeUpdate)
125 | if (snapshot !== null) {
126 | this.listRef.scrollTop =
127 | this.listRef.scrollHeight - snapshot;
128 | }
129 | }
130 |
131 | render() {
132 | return (
133 |
134 | {/* ...contents... */}
135 |
136 | );
137 | }
138 |
139 | setListRef = ref => {
140 | this.listRef = ref;
141 | };
142 | }
143 | ```
144 |
145 | ### 升级方案
146 | 将现有的 `componentWillUpdate` 中的回调函数迁移至 `componentDidUpdate`。如果触发某些回调函数时需要用到 DOM 元素的状态,则将对比或计算的过程迁移至 `getSnapshotBeforeUpdate`,然后在 `componentDidUpdate` 中统一触发回调或更新状态。
147 |
148 | ## 小结
149 | 最后,让我们从整体的角度再来看一下 React 这次生命周期函数调整前后的异同:
150 |
151 | #### Before
152 | 
153 |
154 | #### After
155 | 
156 |
157 | 在第一张图中被红框圈起来的三个生命周期函数就是在新版本中即将被移除的。通过上述的两张图,我们可以清楚地看到将要被移除的三个生命周期函数都是在 render 之前会被调用到的。而根据原来的设计,在这三个生命周期函数中都可以去做一些诸如发送请求,setState 等包含副作用的事情。在老版本的 React 中,这样做也许只会带来一些性能上的损耗,但在 React 开启异步渲染模式之后,就无法再接受这样的副作用产生了。举一个 Git 的例子就是在开发者 commit 了 10 个文件更新后,又对当前或其他的文件做了另外的更新,但在 push 时却仍然只 push 了刚才 commit 的 10 个文件更新。这样就会导致提交记录与实际更新不符,如果想要避免这个问题,就需要保证每一次的文件更新都要经过 commit 阶段,再被提交到远端,而这也就是 React 在开启异步渲染模式之后要做到的。
158 |
159 | 另一方面,为了验证个人的理解及测试新版本的稳定性,笔者已经将个人负责的几个项目全部都升级到了 React 16.3 并根据上述提到的升级方案替换了所有即将被移除的生命周期函数。目前,所有项目在生产环境中都运行良好,没有收到任何不良的用户反馈。
160 |
161 | 当然,以上的这些生命周期函数的改动,一直要到 React 17.0 中才会实装,这给广大的 React 开发者们预留了充足的时间去适应这次改动。但如果你是 React 开源项目(尤其是组件库)的维护者的话,不妨花点时间去详细了解一下这次生命周期函数的改动。因为这不仅仅可以帮助你将开源项目更好地升级到 React 的最新版本,更重要的是可以帮助你提前理解即将到来的异步渲染模式。
162 |
163 | 同时,笔者也相信在 React 正式开启异步渲染模式之后,许多常用组件的性能将很有可能迎来一次整体的提升。进一步来说,配合异步渲染,许多现在的复杂组件都可以被处理得更加优雅,在代码层面得到更精细粒度上的控制,并最终为用户带来更加直观的使用体验。
--------------------------------------------------------------------------------
/2017/前端数据层不完全指北/前端数据层不完全指北.md:
--------------------------------------------------------------------------------
1 | # 前端数据层不完全指北
2 | 不知不觉间时间已经来到了 2017 年末尾。
3 |
4 | 在过去一年中,关于前端数据层的讨论依然在持续升温。不论是数据类型层面的 TypeScript,Flow,PropTypes,应用架构层面的 MVC,MVP,MVVM,还是应用状态层面的 Redux,MobX,RxJS,都各自拥有一批忠实的拥趸,却又谁都无法说服别人认同自己的观点。
5 |
6 | 关于技术选型上的讨论,笔者一直所持的态度都是求同存异。在讨论上述方案差异的文章已汗牛充栋的今天,不如让我们暂且放缓脚步,回头去看一下这些方案所要解决的共同的问题,并试图给出一些最简单的解法。
7 |
8 | 接下来让我们以通用的 MVVM 架构为例,逐层剖析前端数据层的共同痛点。
9 |
10 | ## Model 层
11 | 作为应用数据链路的最下游,前端的 Model 层与后端的 Model 层其实有着很大的区别。相较于后端 Model,前端 Model 并不能起到定义数据结构的作用,而更像是一个容器,用于存放后端接口返回的数据。
12 |
13 | 在这样的前提下,在 RESTful 风格的接口已然成为业界标准的今天,如果后端数据是按照数据资源的最小粒度返回给前端的话,我们是不是可以直接将每个接口的标准返回,当做我们最底层的数据 Model 呢?换句话说,我们好像也别无选择,因为接口返回的数据就是前端数据层的最上游,也是接下来一切数据流动的起点。
14 |
15 | 在明确了 Model 层的定义之后,让我们来看一下 Model 层存在的问题。
16 |
17 | ### 数据资源粒度过细
18 | 数据资源粒度过细通常会导致以下两个问题,一是单个页面需要访问多个接口以获取所有的显示数据,二是各个数据资源之间存在获取顺序的问题,需要按顺序依次异步获取。
19 |
20 | 对于第一个问题,常见的解法为搭建一个 Node.js 的数据中间层,来做接口整合,最终暴露给客户端以页面为粒度的接口,并与客户端路由保持一致。
21 |
22 | 这种解法的优点和缺点都非常明显,优点是每个页面都只需要访问一个接口,在生产环境下的页面加载速度可以得到有效的提升。另一方面,因为服务端已经准备好了所有的数据,做起服务端渲染来也很轻松。但从开发效率的角度来讲,不过是将业务复杂度后置的一种做法,并且只适用于页面与页面之间关联较少,应用复杂度较低的项目,毕竟页面级别的 ViewModel 粒度还是太粗了,而且因为是接口级别的解决方案,可复用性几乎为零。
23 |
24 | 对于第二个问题,笔者提供一个基于最简单的 redux-thunk 的工具函数来链接两个异步请求。
25 |
26 | ```javascript
27 | import isArray from 'lodash/isArray';
28 |
29 | function createChainedAsyncAction(firstAction, handlers) {
30 | if (!isArray(handlers)) {
31 | throw new Error('[createChainedAsyncAction] handlers should be an array');
32 | }
33 |
34 | return dispatch => (
35 | firstAction(dispatch)
36 | .then((resultAction) => {
37 | for (let i = 0; i < handlers.length; i += 1) {
38 | const { status, callback } = handlers[i];
39 | const expectedStatus = `_${status.toUpperCase()}`;
40 |
41 | if (resultAction.type.indexOf(expectedStatus) !== -1) {
42 | return callback(resultAction.payload)(dispatch);
43 | }
44 | }
45 |
46 | return resultAction;
47 | })
48 | );
49 | }
50 | ```
51 |
52 | 基于此,我们再提供一个常见的业务场景来帮助大家理解。比如一个类似于知乎的网站,前端在先获取登录用户信息后,才可以根据用户 id 去获取该用户的回答。
53 |
54 | ```javascript
55 | // src/app/action.js
56 | function getUser() {
57 | return createAsyncAction('APP_GET_USER', () => (
58 | api.get('/api/me')
59 | ));
60 | }
61 |
62 | function getAnswers(user) {
63 | return createAsyncAction('APP_GET_ANSWERS', () => (
64 | api.get(`/api/answers/${user.id}`)
65 | ));
66 | }
67 |
68 | function getUserAnswers() {
69 | const handlers = [{
70 | status: 'success',
71 | callback: getAnswers,
72 | }, {
73 | status: 'error',
74 | callback: payload => (() => {
75 | console.log(payload);
76 | }),
77 | }];
78 |
79 | return createChainedAsyncAction(getUser(), handlers);
80 | }
81 |
82 | export default {
83 | getUser,
84 | getAnswers,
85 | getUserAnswers,
86 | };
87 | ```
88 |
89 | 在输出时,我们可以将三个 actions 全部输出,供不同的页面根据情况按需取用。
90 |
91 | ### 数据不可复用
92 | 每一次的接口调用都意味着一次网络请求,在没有全局数据中心的概念之前,许多前端在开发新需求时都不会在意所要用到的数据是否已经在其他地方被请求过了,而是粗暴地再次去完整地请求一遍所有需要用到的数据。
93 |
94 | 这也就是 Redux 中的 Store 所想要去解决的问题,有了全局的 store,不同页面之间就可以方便地共享同一份数据,从而达到了接口层面也就是 Model 层面的可复用。这里需要注意的一点是,因为 Redux Store 中的数据是存在内存中的,一旦用户刷新页面就会导致所有数据的丢失,所以在使用 Redux Store 的同时,我们也需要配合 `Cookie` 以及 `LocalStorage` 去做核心数据的持久化存储,以保证在未来再次初始化 Store 时能够正确地还原应用状态。特别是在做同构时,一定要保证服务端可以将 Store 中的数据注入到 HTML 的某个位置,以供客户端初始化 Store 时使用。
95 |
96 | ## ViewModel 层
97 | ViewModel 层作为客户端开发中特有的一层,从 MVC 的 Controller 一步步发展而来,虽然 ViewModel 解决了 MVC 中 Model 的改变将直接反应在 View 上这一问题,却仍然没有能够彻底摆脱 Controller 最为人所诟病的一大顽疾,即业务逻辑过于臃肿。另一方面,单单一个 ViewModel 的概念,也无法直接抹平客户端开发所特有的,业务逻辑与显示逻辑之间的巨大鸿沟。
98 |
99 | ### 业务逻辑与显示逻辑之间对应关系复杂
100 | 举例来说,常见的应用中都有使用社交网络账号登录这一功能,产品经理希望实现在用户连接了社交账户之后,首先尝试直接登录应用,如果未注册则为用户自动注册应用账户,特殊情况下如果社交网络返回的用户信息不满足直接注册的条件(如缺少邮箱或手机号),则跳转至补充信息页面。
101 |
102 | 在这个场景下,登录与注册是业务逻辑,根据接口返回在页面上给予用户适当的反馈,进行相应的页面跳转则是显示逻辑,如果从 Redux 的思想来看,这二者分别就是 action 与 reducer。使用上文中的链式异步请求函数,我们可以将登录与注册这两个 action 链接起来,定义二者之间的关系(登录失败后尝试验证用户信息是否足够直接注册,足够则继续请求注册接口,不足够则跳转至补充信息页面)。代码如下:
103 |
104 | ```javascript
105 | function redirectToPage(redirectUrl) {
106 | return {
107 | type: 'APP_REDIRECT_USER',
108 | payload: redirectUrl,
109 | }
110 | }
111 |
112 | function loginWithFacebook(facebookId, facebookToken) {
113 | return createAsyncAction('APP_LOGIN_WITH_FACEBOOK', () => (
114 | api.post('/auth/facebook', {
115 | facebook_id: facebookId,
116 | facebook_token: facebookToken,
117 | })
118 | ));
119 | }
120 |
121 | function signupWithFacebook(facebookId, facebookToken, facebookEmail) {
122 | if (!facebookEmail) {
123 | redirectToPage('/fill-in-details');
124 | }
125 |
126 | return createAsyncAction('APP_SIGNUP_WITH_FACEBOOK', () => (
127 | api.post('/accounts', {
128 | authentication_type: 'facebook',
129 | facebook_id: facebookId,
130 | facebook_token: facebookToken,
131 | email: facebookEmail,
132 | })
133 | ));
134 | }
135 |
136 | function connectWithFacebook(facebookId, facebookToken, facebookEmail) {
137 | const firstAction = loginWithFacebook(facebookId, facebookToken);
138 | const callbackAction = signupWithFacebook(facebookId, facebookToken, facebookEmail);
139 |
140 | const handlers = [{
141 | status: 'success',
142 | callback: () => (() => {}), // 用户登陆成功
143 | }, {
144 | status: 'error',
145 | callback: callbackAction, // 使用 facebook 账户登陆失败,尝试帮用户注册新账户
146 | }];
147 |
148 | return createChainedAsyncAction(firstAction, handlers);
149 | }
150 | ```
151 |
152 | 这里,只要我们将可复用的 action 拆分到了合适的粒度,并在链式 action 中将他们按照业务逻辑组合起来之后,Redux 就会在不同的情况下 dispatch 不同的 action。可能的几种情况如下:
153 |
154 | ```text
155 | // 直接登录成功
156 | APP_LOGIN_WITH_FACEBOOK_REQUEST
157 | APP_LOGIN_WITH_FACEBOOK_SUCCESS
158 |
159 | // 直接登录失败,注册信息充足
160 | APP_LOGIN_WITH_FACEBOOK_REQUEST
161 | APP_LOGIN_WITH_FACEBOOK_ERROR
162 | APP_SIGNUP_WITH_FACEBOOK_REQUEST
163 | APP_LOGIN_WITH_FACEBOOK_SUCCESS
164 |
165 | // 直接登录失败,注册信息不足
166 | APP_LOGIN_WITH_FACEBOOK_REQUEST
167 | APP_LOGIN_WITH_FACEBOOK_ERROR
168 | APP_REDIRECT_USER
169 | ```
170 |
171 | 于是,在 reducer 中,我们只要在相应的 action 被 dispatch 时,对 ViewModel 中的数据做相应的更改即可,也就做到了业务逻辑与显示逻辑相分离。
172 |
173 | 这一解法与 MobX 及 RxJS 有相同又有不同。相同的是都定义好了数据的流动方式(action 的 dispatch 顺序),在合适的时候通知 ViewModel 去更新数据,不同的是 Redux 不会在某个数据变动时自动触发某条数据管道,而是需要使用者显式地去调用某一条数据管道,如上述例子中,在用户点击『连接社交网络』按钮时。综合起来和 redux-observable 的思路可能更为一致,即没有完全抛弃 redux,又引入了数据管道的概念,只是限于工具函数的不足,无法处理更复杂的场景。但从另一方面来说,如果业务中确实没有非常复杂的场景,在理解了 redux 之后,使用最简单的 redux-thunk 就可以完美地覆盖到绝大部分需求。
174 |
175 | ### 业务逻辑臃肿
176 | 拆分并组合可复用的 action 解决了一部分的业务逻辑,但另一方面,Model 层的数据需要通过组合及格式化后才能成为 ViewModel 的一部分,也是困扰前端开发的一大难题。
177 |
178 | 这里推荐使用抽象出通用的 **Selector** 和 **Formatter** 的概念来解决这一问题。
179 |
180 | 上面我们提到了,后端的 Model 会随着接口直接进入到各个页面的 reducer,这时我们就可以通过 Selector 来组合不同 reducer 中的数据,并通过 Formatter 将最终的数据格式化为可以直接显示在 View 上的数据。
181 |
182 | 举个例子,在用户的个人中心页面,我们需要显示用户在各个分类下喜欢过的回答,于是我们需要先获取所有的分类,并在所有分类前加上一个后端并不存在的『热门』分类。又因为分类是一个非常常用的数据,所以我们之前已经在首页获取过并存在了首页的 reducer 中。代码如下:
183 |
184 | ```javascript
185 | // src/views/account/formatter.js
186 | import orderBy from 'lodash/orderBy';
187 |
188 | function categoriesFormatter(categories) {
189 | const customCategories = orderBy(categories, 'priority');
190 | const popular = {
191 | id: 0,
192 | name: '热门',
193 | shortname: 'popular',
194 | };
195 | customCategories.unshift(popular);
196 |
197 | return customCategories;
198 | }
199 |
200 | // src/views/account/selector.js
201 | import formatter from './formatter.js';
202 | import homeSelector from '../home/selector.js';
203 |
204 | const categoriesWithPopularSelector = state =>
205 | formatter.categoriesFormatter(homeSelector.categoriesSelector(state));
206 |
207 | export default {
208 | categoriesWithPopularSelector,
209 | };
210 | ```
211 |
212 | 在明确了 ViewModel 层需要解决的问题后,有针对性地去复用并组合 action,selector,formatter 就可以得到一个思路非常清晰的解决方案。在保证所有数据都只在相应的 reducer 中存储一份的前提下,各个页面数据不一致的问题也迎刃而解。反过来说,数据不一致问题的根源就是代码的可复用性太低,才导致了同一份数据以不同的方式流入了不同的数据管道并最终得到了不同的结果。
213 |
214 | ## View 层
215 | 在理清楚前面两层之后,作为前端最重要的 View 层反而简单了许多,通过 `mapStateToProps` 和 `mapDispatchToProps`,我们就可以将粒度极细的显示数据与组合完毕的业务逻辑直接映射到 View 层的相应位置,从而得到一个纯净,易调试的 View 层。
216 |
217 | ### 可复用 View
218 | 但问题好像又并没有那么简单,因为 View 层的可复用性也是困扰前端的一大难题,基于以上思路,我们又该怎样处理呢?
219 |
220 | 受益于 React 等框架,前端组件化不再是一个问题,我们也只需要遵守以下几个原则,就可以较好地实现 View 层的复用。
221 |
222 | * 所有的页面都隶属于一个文件夹,只有页面级别的组件才会被 connect 到 redux store。每个页面又都是一个独立的文件夹,存放自己的 action,reducer,selector 及 formatter。
223 | * components 文件夹中存放业务组件,业务组件不会被 connect 到 redux store,只能从 props 中获取数据,从而保证其可维护性与可复用性。
224 | * 另一个文件夹或 npm 包中存放 UI 组件,UI 组件与业务无关,只包含显示逻辑,不包含业务逻辑。
225 |
226 | ## 小结
227 | 虽然说开发灵活易用的组件库是一件非常难的事情,但在积累了足够多的可复用的业务组件及 UI 组件之后,新的页面在数据层面,又可以从其他页面的 action,selector,formatter 中寻找可复用的业务逻辑时,新需求的开发速度应当是越来越快的。而不是越来越多的业务逻辑与显示逻辑交织在一起,最终导致整个项目内部复杂度过高无法维护后只能推倒重来。
228 |
229 | ## 一点心得
230 | 在新技术层出不穷的今天,在我们执着于说服别人接受自己的技术观点时,我们还是需要回到当前业务场景下,去看一看要解决的到底是一个什么样的问题。
231 |
232 | 抛去少部分极端复杂的前端应用来看,目前大部分的前端应用都还是以展示数据为主,在这样的场景下,再前沿的技术与框架都无法直接解决上面提到的这些问题,反倒是一套清晰的数据处理思路及对核心概念的深入理解,再配合上严谨的团队开发规范才有可能将深陷复杂数据泥潭的前端开发者们拯救出来。
233 |
234 | 作为工程学的一个分支,软件工程的复杂度从来都不在于那些无法解决的难题,而是如何制定简单的规则让不同的模块各司其职。这也是为什么在各种框架,库,解决方案层出不穷的今天,我们还是在强调基础,强调经验,强调要看到问题的本质。
235 |
236 | 王阳明所说的知行合一,现代人往往是知道却做不到。但在软件工程方面,我们又常常会陷入照猫画虎地做到了,却并不理解其中原理的另一极端,而这二者显然都是不可取的。
--------------------------------------------------------------------------------
/2016/重新设计 React 组件库/重新设计 React 组件库.md:
--------------------------------------------------------------------------------
1 | # 重新设计 React 组件库
2 | 在 react + redux 已经成为大部分前端项目底层架构的今天,让我们再回到软件工程界一个永恒的问题上来,那就是如何提升一个开发团队的开发效率?
3 | 从宏观的角度来讲,只有对具体业务的良好抽象才能真正提高一个团队的开发效率,又囿于不同产品所面临的不同业务需求,当我们抽丝剥茧般地将一个个前端项目抽象到最后一层,那么剩下的就只有按钮、输入框、对话框、图标等这些毫无业务意义的纯 UI 组件了。
4 |
5 | 选择或开发一套适合自己团队使用的 UI 组件库应该是每一个前端团队在底层架构达成共识后下一件就要去做的事情,那么我们就以今天为始,分别从以下几个方面来探讨如何构建一套优秀的 UI 组件库。
6 |
7 | ## 第一个问题:选择开源 vs 自己造轮子
8 | 在 React 界,优秀且开源的 UI 组件库有很多,国外的如 [Material-UI](http://www.material-ui.com/),国内的如 [Ant Design](https://ant.design/),都是经过众多使用者检验,组件丰富且代码质量过硬的组件库。所以当我们决定再造一套 UI 组件库之前,不妨先尝试下这些在 UI 组件库界口碑良好的标品,再决定是否要进入这个看似简单实则困难重重的领域。
9 |
10 | 在这里,我们并不会去比较任何组件库之间的区别或优劣,但却可以从产品层面给出几个开发自有组
11 | 件库的判断依据,以供参考。
12 |
13 | * 产品有独立的设计规范,包括但不限于组件样式、交互模式。
14 | * 产品业务场景较为复杂,需要深度定制某些常用组件。
15 | * 前端团队需要同时支撑多条业务线。
16 |
17 | ## 设计思想:规范 vs. 自由
18 | 在选择了自己造轮子这样一条路之后,下一个摆在面前的艰难选择就是,要造一个规范的组件库还是一个自由的组件库?
19 |
20 | 规范的组件库可以从源码层面保证产品视觉、交互风格的一致性,也可以很大程度上降低业务开发的复杂度,从而提升团队整体的开发效率。但在遇到一些看似相似实则不同的业务需求时,规范的组件库往往会走入一个难以避免的死循环,那就是实现 A 需求需要使用 a 组件,但是现有的 a 组件又不能完全支持 A 需求。
21 |
22 | 这时摆在工程师面前的就只有两条路:
23 |
24 | * 重新开发一个完美支持 A 需求的 a+ 组件
25 | * 修改 a 组件源码使其支持 A 需求
26 |
27 | 方法一费时费力,会极大地增加本次项目的开发成本,而方法二又会导致 a 组件代码膨胀速度过快且逻辑复杂,极大地增加组件库后期的维护成本。
28 |
29 | 在多次陷入上面所描述的这个困境之后,在最近的一次内部组件库重构时,我们选择了拥抱自由,这其中既有业务方面的考虑,也有 React 在组件自由组合方面的天然优势,让我们来看一个例子。
30 |
31 | ### Select
32 |
33 | ```javascript
34 | // traditional select
35 |
36 |
41 | {value}
42 |
43 |
44 | {menu}
45 |
46 | ```
47 |
48 | 这是一个非常传统的 Select 组件,触发下拉菜单的区域为一段文字加一个箭头。我们来看下面的一个业务场景:
49 |
50 | 
51 |
52 | 这里触发下拉菜单的区域不再是传统的一段文字加一个箭头,而是一个自定义元素,点击后展开下拉列表。虽然它的交互模式和 Select 一模一样,但因为二者在 DOM 结构上的巨大差别,导致我们无法复用上面的这个 Select 来实现它。
53 |
54 | ```javascript
55 | // Customizeable Select
56 |
57 | {
58 | children
59 | ||
60 |
61 |
62 | {label ? {label} : null}
63 |
64 | {currentValue !== '' ? currentValue : selectPlaceholder}
65 |
66 |
67 |
68 |
69 | }
70 | {this.renderPopup()}
71 |
72 | ```
73 |
74 | 在支持传统的文字加箭头之外,更自由的 Select 添加了对 label 及 children 支持,分别可以对应有名称的 Select
75 |
76 | 
77 |
78 | 及类似前面提到的自定义元素。
79 |
80 | ### Dropdown
81 |
82 | 类似的还有 Select 的孪生兄弟 Dropdown。
83 |
84 | ```javascript
85 | // Customizeable Dropdown
86 |
87 | {data.map((value, idx) => {
88 | return (
89 |
95 | );
96 | })}
97 |
98 |
99 | // Using Dropdown
100 | const demoData = [{ text: 'Robb Stark', age: 36 }]
101 | const DropdownItem = (props) => (
102 |
103 |
{props.data.text}
104 |
is {props.data.age} years old.
105 |
106 | );
107 | ```
108 |
109 | 这是一个常见的下拉列表组件,是否允许用户传入 ItemComponent 其实就是一个规范与自由之间的取舍。在选择了拥抱自由之后,组件的使用者终于不会再被组件内部的 DOM 结构所束缚,转而可以自由地定制子元素的 DOM 结构。
110 |
111 | 相较于传统的规范的组件,自由的组件需要使用者在业务项目中多写一些代码,但如果我们往深处再看一层,这些特殊的下拉元素本就是属于某个业务所特有的,将其放在业务代码层恰恰是一种更合适的分层方法。
112 |
113 | 另一方面,我们在这里所定义的自由,绝不仅仅是多暴露几个渲染函数那么简单,这里的自由指的是组件内部 DOM 结构的自由。因为一旦某个组件定死了自己的 DOM 结构,外部使用时除了重写样式去强行覆盖外没有任何其他可行的方式去改变它。
114 |
115 | 虽然我们上面提到了许多自由的好处,但很多时候我们还是会被一个问题所挑战,那就是自由的组件在大部分时候不如规范的组件来得好用,因为调用起来很麻烦。
116 |
117 | 这个问题其实是有解的,那就是默认值。我们可以在组件库中内置许多常用的子元素,当用户不指定子元素时,使用默认的子元素来完成渲染,这样就可以在规范与自由之间达成一个良好的平衡,但这里需要注意的是,添加常用子元素的工作量也非常巨大,团队内部也需要对“常用”这个词有一个统一的认识。
118 |
119 | 或者你也可以选择针对不同的使用场景,做两套不同的解决方案。例如前端开源 UI 框架界的翘楚 antd,其底层依赖的 [react-component](https://github.com/react-component) 也是非常解耦的设计,几乎看不到任何固定的 DOM 结构,而是使用自定义组件或 children prop 将 DOM 结构的决定权交给使用者。
120 |
121 | ```javascript
122 | // react-component/dropdown
123 | return (
124 |
144 | {children}
145 |
146 | );
147 | ```
148 |
149 | ## 数据处理:耦合 vs. 解耦
150 | 如果你问一个工程师在某个场景下,两个模块是耦合好还是解耦好?我想他甚至可能都不会问你是什么场景就脱口而出:“当然解耦好,耦合的代码根本没办法维护!”
151 |
152 | 但事实上,在传统的组件库设计中,我们一直都默认组件是可以和数据源(一般的组件都会有 data 这个 prop)相耦合的,这样就导致了我们在给某个组件赋值之前,要先写一个数据处理方法,将后端返回回来的数据处理成组件要求的数据结构,再传给组件进行渲染。
153 |
154 | 这时,如果后端返回的或组件要求的数据结构再变态一些(如数组嵌套),这个数据处理方法就很有可能会写得非常复杂,甚至还会导致许多的 edge case 使得组件在获取某个特定的 attribute 时直接报错。
155 |
156 | 如何将组件与数据源解耦呢?答案就是不要在组件代码(不论是视图层还是控制层)中出现 `data.xxx`,而是在回调时将整个对象都抛给调用者供其按需使用。这样组件就可以无缝适配于各种各样的后端接口,大大降低使用者在数据处理时犯错误的可能。
157 |
158 | 承接前文,其实这样的数据处理方式和前面提到的自由的设计思想是一脉相承的,正是因为我们赋予了使用者自由定制 DOM 结构的能力,所以我们同时也可以赋予他们在数据处理上的自由。
159 |
160 | 看到这里,支持规范组件的朋友可能已经有些崩溃了,因为听起来自由组件既不定义 DOM 结构,也不处理数据,那么我为什么还要用这个组件呢?
161 |
162 | 让我们以 Select 组件为例来回答这个问题。
163 |
164 | 是的,自由的 Select 组件需要使用者自定义下拉元素,还需要在回调中自己处理使用 data 的哪个 attribute 来完成下一步的业务逻辑,但 Select 组件真的什么都没有做吗?其实并不是,Select 组件规范了“选择”这个交互方式,处理了什么时候显示或隐藏下拉列表,响应了下拉列表元素的 `hover` 和 `click` 事件,并控制了绝对定位的下拉列表的弹出位置。这些通用的交互逻辑,才是 Select 组件的核心,至于多变的渲染和数据处理逻辑,打包开放出来反而更利于使用者在多变的业务场景下方便地使用 Select 组件。
165 |
166 | 讲完了组件与数据源之间的解耦,我们再来谈一下组件各个 props 之间解耦的必要性。
167 |
168 | 假设一个需求:按照中国、美国、英国、日本、加拿大的顺序显示当地时间,当地时间需从服务端获取且显示格式不同。
169 |
170 | 我们可以设计一个组件,接收不同国家的时间数据作为其 data prop,展示一个当地时间至少需要英文唯一标识符 `region`,中文显示名 `name`,当前时间 `time`,显示格式 `format` 等四个属性,由此我们可以设计组件的 data 属性为:
171 |
172 | ```javascript
173 | data: [{
174 | region: 'china'
175 | name: '中国',
176 | time: 1481718888,
177 | format: 'MMMM Do YYYY, h:mm:ss a',
178 | }, {
179 | ...
180 | }]
181 | ```
182 |
183 | 看起来不错,但事实真的是这样吗?我相信如果你把这份数据结构拿给后端同事看时,他一定会立刻指出一个问题,那就是后端数据库中是不会保存 `name` 及 `format` 字段的,因为这是由具体产品定义的展示逻辑,而接口只负责告诉你这个地区是哪里 `region` 以及这个地区的当前时间是多少 `time`。事情到这里也许还不算那么糟糕,因为我们可以在调用组件前,将异步获取到的数据再重新格式化一遍,补上缺失的字段。但这时一个更棘手的问题来了,那就是接口返回的数组数据一般是不保证顺序的,你还需要按照产品的要求,在补充完缺失的字段后,对整个数组进行一次重排以保证每一次渲染出来的地区都保持同样的顺序。
184 |
185 | 换一种方式,如果我们这样去设计组件的 props 呢?
186 |
187 | ```javascript
188 | {
189 | data: {
190 | china: {
191 | time: 1481718888,
192 | },
193 | ...
194 | },
195 | timeList: [{
196 | region: 'china',
197 | name: '中国',
198 | format: 'MMMM Do YYYY, h:mm:ss a',
199 | }, {
200 | ...
201 | }],
202 | ...
203 | }
204 | ```
205 |
206 | 当我们将需要异步获取的 props 抽离后,这个组件就变得非常 data & api friendly 了,仅通过配置 timeList prop 就可以完美地控制组件的渲染规则及渲染顺序并且再也不需要对接口返回的数据进行补全或定制了。甚至我们还可以通过设置默认值的方式,先将组件同步渲染出来,在异步数据请求完成后再重绘数值部分,给予用户更好的视觉体验。
207 |
208 | 除了分离非必须耦合的 props 之外,细心的朋友可能还会发现上面的 data prop 的数据结构从数组变为了对象,这又是为什么呢?
209 |
210 | ## 回调规范:数组 vs. 对象
211 | 设计思想可以是自由的,数据处理也可以是自由的,但一个成熟的 UI 组件库作为一个独立的前端项目,在代码层面必须要建立起自己的规范。抛开老生常谈的 JavaScript 及 Sass/Less 层面的代码规范不表,让我们从 CSS 类名、组件类别及回调规范三个方面分享一些最佳实践。
212 |
213 | 在组件库项目中,并不推荐使用 CSS Modules,一方面是因为其编译出来的复杂类名不便于使用者在业务项目里进行简单覆盖,更重要的是我们可以将每一个组件都看作是一个独立的模块,用添加 `xui-componentName` 类名前缀的方式来实现一套简化版的 CSS Modules。另外,在 jsx 中我们可以参考 antd 的做法,为每一个组件添加一个名为 `prefixCls` 的 prop,并将其默认值也设置为 `xui-componentName`,这样就在 jsx 层面也保证了代码的统一性,方便团队成员阅读及维护。
214 |
215 | 在这次内部组件库重构项目中,我们将所有的组件分为了纯渲染组件与智能组件两类,并规范其写法为纯函数与 ES6 class 两种,彻底抛弃了 `React.createClass` 的写法。这样一方面可以进一步规范代码,增强可读性,另一方面也可以让后续的维护者在一秒钟内判断出某个组件是纯渲染组件还是智能组件。
216 |
217 | 在回调函数方面,所有的组件内部函数都以 `handleXXX`(`handleClick`,`handleHover`,`handleMouseover` 等)为命名模板,所有对外暴露的回调函数都以 `onXXX`(`onChange`,`onSelect` 等)为命名模板。这样在维护一些依赖层级较深的底层组件时,就可以在 render 方法中一眼看出某个回调是在处理内部状态,还是将回调至更高一层。
218 |
219 | 在设计回调数据的数据结构时,我们只使用了单一值(如 Input 组件的回调)和对象两种数据结构,尽量避免了使用传统组件库中常用的数组。相较于对象,数组其实是一种含义更为丰富的数据结构,因为它是有向的(包含顺序的),比如在上面的例子中,timeList prop 就被设计为数组,这样它就可以在承载数据的同时包含数据展示的顺序,极大地方便了组件的使用。但在给使用者抛出回调数据时,并不是每一位使用者都能够像组件设计者那样清楚回调数据的顺序,使用数组实际上变相增加了使用者的记忆成本,而且笔者一直都不赞成在代码中出现类似于 `const value = data[0];` 这样的表达式。因为没有人能够保证数组的长度满足需要且当前位上的元素就是要取的值。另一方面,对象因为键值对的存在,在具体到某一个元素的表意上要比数组更为丰富。例如选择日历区间后的回调需要同时返回开始日期及结束日期:
220 |
221 | ```javascript
222 | // array
223 | ['2016-11-11', '2016-12-12']
224 |
225 | // object
226 | {
227 | firstDay: '2016-11-11',
228 | lastDay: '2016-12-12',
229 | }
230 | ```
231 |
232 | 严格来讲上述的两种方式并没有对错之分,只是对象的数据结构更能够清晰地表达每个元素的含义并消除顺序的影响,更利于不了解组件库内部代码的使用者快速上手。
233 |
234 | ## 小结
235 | 在本文中,我们从设计思想、数据处理、回调规范三个方面为各位剖析了在前端组件化已经成为既定事实的今天,我们还能在组件库设计方面做出怎样新的尝试与突破。也许这些新的尝试与突破并不会像一个新的框架那样给你带来全新的震撼,但我们相信这些实用的思考与经验可以让你少走许多弯路并打开一些新的思路,并且跳出前端这个“狭小”的圈子,站在软件工程的高度去看待这些看似简单实则复杂的工作。
236 |
237 | 在以后的文章中,我们还会从组件库整体代码架构、组件库国际化方案及复杂组件架构设计等方面为大家带来更多细节上的经验与体会,也会穿插更多的具体的代码片段来阐述我们的设计思想与理念,敬请期待。
238 |
--------------------------------------------------------------------------------
/2017/组件库设计实战 - 复杂组件设计/组件库设计实战 - 复杂组件设计.md:
--------------------------------------------------------------------------------
1 | # 组件库设计实战 - 复杂组件设计
2 | 一个成熟的组件库通常都由数十个常用的 UI 组件构成,这其中既有按钮(Button),输入框(Input)等基础组件,也有表格(Table),日期选择器(DatePicker),轮播(Carousel)等自成一体的复杂组件。
3 |
4 | 这里我们提出一个**组件复杂度**的概念,一个组件复杂度的主要来源就是其自身的状态,即组件自身需要维护多少个不依赖于外部输入的状态。参考原先文章中提到过的木偶组件(dumb component)与智能组件(smart component),二者的区别就是是否需要在组件内部维护不依赖于外部输入的状态。
5 |
6 | ## 实战案例 - 轮播组件
7 | 在本篇文章中,我们将以轮播(Carousel)组件为例,一步一步还原如何实现一个交互流畅的轮播组件。
8 |
9 | ### 最简单的轮播组件
10 | 抛去所有复杂的功能,轮播组件的实质,实际上就是在一个固定区域实现不同元素之间的切换。在明确了这点后,我们就可以设计轮播组件的基础 DOM 结构为:
11 |
12 | ```jsx
13 |
14 |
15 |
16 | ...
17 |
18 |
19 |
20 | ```
21 |
22 | 如下图所示:
23 |
24 | 
25 |
26 | `Frame` 即轮播组件的真实显示区域,其宽高为内部由使用者输入的 `SlideItem` 决定。这里需要注意的一点是需要设置 `Frame` 的 `overflow` 属性为 `hidden`,即隐藏超出其本身宽高的部分,每次只显示一个 `SlideItem`。
27 |
28 | `SlideList` 为轮播组件的轨道容器,改变其 `translateX` 的值即可实现在轨道的滑动,以显示不同的轮播元素。
29 |
30 | `SlideItem` 是使用者输入的轮播元素的一层抽象,内部可以是 `img` 或 `div` 等 DOM 元素,并不影响轮播组件本身的逻辑。
31 |
32 | ### 实现轮播元素之前的切换
33 | 为了实现在不同 `SlideItem` 之间的切换,我们需要定义轮播组件的第一个内部状态,即 `currentIndex`,即当前显示轮播元素的 `index` 值。上文中我们提到了改变 `SlideList` 的 `translateX` 是实现轮播元素切换的关键,所以这里我们需要将 `currentIndex` 与 `SlideList` 的 `translateX` 对应起来,即:
34 |
35 | ```javascript
36 | translateX = -(width) * currentIndex
37 | ```
38 |
39 | `width` 即为单个轮播元素的宽度,与 `Frame` 的宽度相同,所以我们可以在 `componentDidMount` 时拿到 `Frame` 的宽度并以此计算出轨道的总宽度。
40 |
41 | ```javascript
42 | componentDidMount() {
43 | const width = get(this.container.getBoundingClientRect(), 'width');
44 | }
45 |
46 | render() {
47 | const rest = omit(this.props, Object.keys(defaultProps));
48 | const classes = classnames('ui-carousel', this.props.className);
49 | return (
50 | { this.container = node; }}
54 | >
55 | {this.renderSildeList()}
56 | {this.renderDots()}
57 |
58 | );
59 | }
60 | ```
61 |
62 | 至此,我们只需要改变轮播组件中的 `currentIndex`,即可间接改变 `SlideList` 的 `translateX`,以此实现轮播元素之间的切换。
63 |
64 | ### 响应用户操作
65 | 轮播作为一个常见的通用组件,在桌面和移动端都有着非常广泛的应用,这里我们先以移动端为例,来阐述如何响应用户操作。
66 |
67 | ```javascript
68 | {map(children, (child, i) => (
69 |
78 | {child}
79 |
80 | ))}
81 | ```
82 |
83 | 在移动端,我们需要监听三个事件,分别响应滑动开始,滑动中与滑动结束。其中滑动开始与滑动结束都是一次性事件,而滑动中则是持续性事件,以此我们可以确定在三个事件中我们分别需要确定哪些值。
84 |
85 | #### 滑动开始
86 | * startPositionX:此次滑动的起始位置
87 |
88 | ```javascript
89 | handleTouchStart = (e) => {
90 | const { x } = getPosition(e);
91 | this.setState({
92 | startPositionX: x,
93 | });
94 | }
95 | ```
96 |
97 | #### 滑动中
98 | * moveDeltaX:此次滑动的实时距离
99 | * direction:此次滑动的实时方向
100 | * translateX:此次滑动中轨道的实时位置,用于渲染
101 |
102 | ```javascript
103 | handleTouchMove = (e) => {
104 | const { width, currentIndex, startPositionX } = this.state;
105 | const { x } = getPosition(e);
106 |
107 | const deltaX = x - startPositionX;
108 | const direction = deltaX > 0 ? 'right' : 'left';
109 | this.setState({
110 | moveDeltaX: deltaX,
111 | direction,
112 | translateX: -(width * currentIndex) + deltaX,
113 | });
114 | }
115 | ```
116 |
117 | #### 滑动结束
118 | * currentIndex:此次滑动结束后新的 currentIndex
119 | * endValue:此次滑动结束后轨道的 translateX
120 |
121 | ```javascript
122 | handleTouchEnd = () => {
123 | this.handleSwipe();
124 | }
125 |
126 | handleSwipe = () => {
127 | const { children, speed } = this.props;
128 | const { width, currentIndex, direction, translateX } = this.state;
129 | const count = size(children);
130 |
131 | let newIndex;
132 | let endValue;
133 | if (direction === 'left') {
134 | newIndex = currentIndex !== count ? currentIndex + 1 : START_INDEX;
135 | endValue = -(width) * (currentIndex + 1);
136 | } else {
137 | newIndex = currentIndex !== START_INDEX ? currentIndex - 1 : count;
138 | endValue = -(width) * (currentIndex - 1);
139 | }
140 |
141 | const tweenQueue = this.getTweenQueue(translateX, endValue, speed);
142 | this.rafId = requestAnimationFrame(() => this.animation(tweenQueue, newIndex));
143 | }
144 | ```
145 |
146 | 因为我们在滑动中会实时更新轨道的 translateX,我们的轮播组件便可以做到**跟手**的用户体验,即在单次滑动中,轮播元素会跟随用户的操作向左或向右滑动。
147 |
148 | ### 实现顺滑的切换动画
149 | 在实现了滑动中跟手的用户体验后,我们还需要在滑动结束后将显示的轮播元素定位到新的 `currentIndex`。根据用户的滑动方向,我们可以对当前的 `currentIndex` 进行 +1 或 -1 以得到新的 `currentIndex`。但在处理第一个元素向左滑动或最后一个元素向右滑动时,新的 `currentIndex` 需要更新为最后一个或第一个。
150 |
151 | 这里的逻辑并不复杂,但却带来了一个非常难以解决的用户体验问题,那就是假设我们有 3 个轮播元素,每个轮播元素的宽度都为 300px,即显示最后一个元素时,轨道的 translateX 为 -600px,在我们将最后一个元素向左滑动后,轨道的 translateX 将被重新定义为 0px,此时若我们使用原生的 CSS 动画:
152 |
153 | ```css
154 | transition: 1s ease-in-out;
155 | ```
156 |
157 | 轨道将会在一秒内从左向右滑动至第一个轮播元素,而这是反直觉的,因为用户一个向左滑动的操作导致了一个向右的动画,反之亦然。
158 |
159 | 这个问题从上古时期就困扰着许多前端开发者,笔者也见过以下几种解决问题的方法:
160 |
161 | * 将轨道宽度定义为无限长(几百万 px),无限次重复有限的轮播元素。这种解决方案显然是一种 hack,并没有从实质上解决轮播组件的问题。
162 | * 只渲染三个轮播元素,即前一个,当前一个,下一个,每次滑动后同时更新三个元素。这种解决方案实现起来非常复杂,因为组件内部要维护的状态从一个 currentIndex 增加到了三个拥有各自状态的 DOM 元素,且因为要不停的删除和新增 DOm 节点导致性能不佳。
163 |
164 | 这里让我们再来思考一下滑动操作的本质。除去第一和最后两个元素,所有中间元素滑动后新的 translateX 的值都是固定的,即 `-(width * currentIndex)`,这种情况下的动画都可以轻松地完美实现。而在最后一个元素向左滑动时,因为轨道的 `translateX` 已经到达了极限,面对这种情况我们如何才能实现顺滑的切换动画呢?
165 |
166 | 这里我们选择将最后一个及第一个元素分别拼接至轨道的头尾,以保证在 DOM 结构不需要改变的前提下实现顺滑的切换动画:
167 |
168 | 
169 |
170 | 这样我们就统一了每次滑动结束后 `endValue` 的计算方式,即
171 |
172 | ```javascript
173 | // left
174 | endValue = -(width) * (currentIndex + 1)
175 |
176 | // right
177 | endValue = -(width) * (currentIndex - 1)
178 | ```
179 |
180 | ### 使用 requestAnimationFrame 实现高性能动画
181 | `requestAnimationFrame` 是浏览器提供的一个专注于实现动画的 API,感兴趣的朋友可以再重温一下[《React Motion 缓动函数剖析》](https://zhuanlan.zhihu.com/p/20458251)这篇专栏。
182 |
183 | 所有的动画本质上都是一连串的时间轴上的值,具体到轮播场景下即:以用户停止滑动时的值为起始值,以新 `currentIndex` 时 `translateX` 的值为结束值,在使用者设定的动画时间(如0.5秒)内,依据使用者设定的缓动函数,计算每一帧动画时的 `translateX` 值并最终得到一个数组,以每秒 60 帧的速度更新在轨道的 `style` 属性上。每更新一次,将消耗掉动画值数组中的一个中间值,直到数组中所有的中间值被消耗完毕,动画结束并触发回调。
184 |
185 | 具体代码如下:
186 |
187 | ```javascript
188 | const FPS = 60;
189 | const UPDATE_INTERVAL = 1000 / FPS;
190 |
191 | animation = (tweenQueue, newIndex) => {
192 | if (isEmpty(tweenQueue)) {
193 | this.handleOperationEnd(newIndex);
194 | return;
195 | }
196 |
197 | this.setState({
198 | translateX: head(tweenQueue),
199 | });
200 | tweenQueue.shift();
201 | this.rafId = requestAnimationFrame(() => this.animation(tweenQueue, newIndex));
202 | }
203 |
204 | getTweenQueue = (beginValue, endValue, speed) => {
205 | const tweenQueue = [];
206 | const updateTimes = speed / UPDATE_INTERVAL;
207 | for (let i = 0; i < updateTimes; i += 1) {
208 | tweenQueue.push(
209 | tweenFunctions.easeInOutQuad(UPDATE_INTERVAL * i, beginValue, endValue, speed),
210 | );
211 | }
212 | return tweenQueue;
213 | }
214 | ```
215 |
216 | 在回调函数中,根据变动逻辑统一确定组件当前新的稳定态值:
217 |
218 | ```javascript
219 | handleOperationEnd = (newIndex) => {
220 | const { width } = this.state;
221 |
222 | this.setState({
223 | currentIndex: newIndex,
224 | translateX: -(width) * newIndex,
225 | startPositionX: 0,
226 | moveDeltaX: 0,
227 | dragging: false,
228 | direction: null,
229 | });
230 | }
231 | ```
232 |
233 | 完成后的轮播组件效果如下图:
234 |
235 | 
236 |
237 | ### 优雅地处理特殊情况
238 | * 处理用户误触:在移动端,用户经常会误触到轮播组件,即有时手不小心滑过或点击时也会触发 `onTouch` 类事件。对此我们可以采取对滑动距离添加阈值的方式来避免用户误触,阈值可以是轮播元素宽度的 10% 或其他合理值,在每次滑动距离超过阈值时,才会触发轮播组件后续的滑动。
239 | * 桌面端适配:对于桌面端而言,轮播组件所需要响应的事件名称与移动端是完全不同的,但又可以相对应地匹配起来。这里还需要注意的是,我们需要为轮播组件添加一个 dragging 的状态来区分移动端与桌面端,从而安全地复用 handler 部分的代码。
240 |
241 | ```javascript
242 | // mobile
243 | onTouchStart={this.handleTouchStart}
244 | onTouchMove={this.handleTouchMove}
245 | onTouchEnd={this.handleTouchEnd}
246 | // desktop
247 | onMouseDown={this.handleMouseDown}
248 | onMouseMove={this.handleMouseMove}
249 | onMouseUp={this.handleMouseUp}
250 | onMouseLeave={this.handleMouseLeave}
251 | onMouseOver={this.handleMouseOver}
252 | onMouseOut={this.handleMouseOut}
253 | onFocus={this.handleMouseOver}
254 | onBlur={this.handleMouseOut}
255 |
256 | handleMouseDown = (evt) => {
257 | evt.preventDefault();
258 | this.setState({
259 | dragging: true,
260 | });
261 | this.handleTouchStart(evt);
262 | }
263 |
264 | handleMouseMove = (evt) => {
265 | if (!this.state.dragging) {
266 | return;
267 | }
268 | this.handleTouchMove(evt);
269 | }
270 |
271 | handleMouseUp = () => {
272 | if (!this.state.dragging) {
273 | return;
274 | }
275 | this.handleTouchEnd();
276 | }
277 |
278 | handleMouseLeave = () => {
279 | if (!this.state.dragging) {
280 | return;
281 | }
282 | this.handleTouchEnd();
283 | }
284 |
285 | handleMouseOver = () => {
286 | if (this.props.autoPlay) {
287 | clearInterval(this.autoPlayTimer);
288 | }
289 | }
290 |
291 | handleMouseOut = () => {
292 | if (this.props.autoPlay) {
293 | this.autoPlay();
294 | }
295 | }
296 | ```
297 |
298 | ## 小结
299 | 至此我们就实现了一个只有 `tween-functions` 一个第三方依赖的轮播组件,打包后大小不过 2KB,完整的源码大家可以参考这里 [carousel/index.js](https://github.com/AlanWei/sea-ui/blob/master/components/carousel/index.js)。
300 |
301 | 除了节省的代码体积,更让我们欣喜的还是彻底弄清楚了轮播组件的实现模式以及如何使用 `requestAnimationFrame` 配合 `setState` 来在 react 中完成一组动画。
302 |
303 | ## 感想
304 |
305 | 
306 |
307 | 大家应该都看过上面这幅漫画,有趣之余也蕴含着一个朴素却深刻的道理,那就是在解决一个复杂问题时,最重要的是思路,但仅仅有思路也仍是远远不够的,还需要具体的执行方案。这个具体的执行方案,必须是连续的,其中不可以欠缺任何一环,不可以有任何思路或执行上的跳跃。所以解决任何复杂问题都没有银弹也没有捷径,我们必须把它弄清楚,搞明白,然后才能真正地解决它。
308 |
309 | 至此,组件库设计实战系列文章也将告一段落。在全部四篇文章中,我们分别讨论了组件库架构,组件分类,文档组织,国际化以及复杂组件设计这几个核心的话题,因笔者能力所限,其中自然有许多不足之处,烦请各位谅解。
310 |
311 | 组件库作为提升前端团队工作效率的重中之重,花再多时间去研究它都不为过。再加上与设计团队对接,形成设计语言,与后端团队对接,统一数据结构,组件库也可以说是前端工程师在拓展自身工作领域上的必经之路。
312 |
313 | **不要害怕重复造轮子,关键是每造一次轮子后,从中学到了什么。**
314 |
315 | 与各位共勉。
--------------------------------------------------------------------------------
/2018/从新的 Context API 看 React 应用设计模式/从新的 Context API 看 React 应用设计模式.md:
--------------------------------------------------------------------------------
1 | # 从新的 Context API 看 React 应用设计模式
2 | 在即将发布的 React v16.3.0 中,React 引入了新的声明式的,可透传 props 的 [Context API](https://github.com/facebook/react/pull/11818),对于新版 Context API 还不太了解朋友可以看一下笔者之前的一个[回答](https://www.zhihu.com/question/267168180/answer/319754359)。
3 |
4 | 受益于这次改动,React 开发者终于拥有了一个官方提供的安全稳定的 global store,子组件跨层级获取父组件数据及后续的更新都不再成为一个问题。这让我们不禁开始思考,相较于 Redux 等其他的第三方数据(状态)管理工具,使用 Context API 这种 vanilla React 支持的方式是不是一个更好的选择呢?
5 |
6 | ## Context vs. Redux
7 | 在 react + redux 已经成为了开始一个 React 项目标配的今天,我们似乎忘记了其实 react 本身是可以使用 state 和 props 来管理数据的,甚至对于目前市面上大部分的应用来说,对 redux 的不正确使用实际上增加了应用整体的复杂度及代码量。
8 |
9 | ### Vanilla React Global Store
10 | ```javascript
11 | import React from "react";
12 | import { render } from "react-dom";
13 |
14 | const initialState = {
15 | theme: "dark",
16 | color: "blue"
17 | };
18 |
19 | const GlobalStoreContext = React.createContext({
20 | ...initialState
21 | });
22 |
23 | class GlobalStoreContextProvider extends React.Component {
24 | // initialState
25 | state = {
26 | ...initialState
27 | };
28 |
29 | // reducer
30 | handleContextChange = action => {
31 | switch (action.type) {
32 | case "UPDATE_THEME":
33 | return this.setState({
34 | theme: action.theme
35 | });
36 | case "UPDATE_COLOR":
37 | return this.setState({
38 | color: action.color
39 | });
40 | case "UPDATE_THEME_THEN_COLOR":
41 | return new Promise(resolve => {
42 | resolve(action.theme);
43 | })
44 | .then(theme => {
45 | this.setState({
46 | theme
47 | });
48 | return action.color;
49 | })
50 | .then(color => {
51 | this.setState({
52 | color
53 | });
54 | });
55 | default:
56 | return;
57 | }
58 | };
59 |
60 | render() {
61 | return (
62 |
69 | {this.props.children}
70 |
71 | );
72 | }
73 | }
74 |
75 | const SubComponent = props => (
76 |
77 | {/* action */}
78 |
88 |
{props.theme}
89 | {/* action */}
90 |
100 |
{props.color}
101 | {/* action */}
102 |
113 |
114 | );
115 |
116 | class App extends React.Component {
117 | render() {
118 | return (
119 |
120 |
121 | {context => (
122 |
127 | )}
128 |
129 |
130 | );
131 | }
132 | }
133 |
134 | render(, document.getElementById("root"));
135 | ```
136 |
137 | 在上面的例子中,我们使用 Context API 实现了一个简单的 redux + react-redux,这证明了在新版 Context API 的支持下,原先 react-redux 帮我们做的一些工作现在我们可以自己来做了。另一方面,对于已经厌倦了整天都在写 action 和 reducer 的朋友们来说,在上面的例子中忽略掉 dispatch,action 等这些 Redux 中的概念,直接调用 React 中常见的 handleXXX 方法来 setState 也是完全没有问题的,可以有效地缓解 Redux 模板代码过多的问题。而对于 React 的初学者来说,更是省去了学习 Redux 及函数式编程相关概念与用法的过程。
138 |
139 | ### 正确地使用 Redux
140 | 从上面 Context 版本的 Redux 中可以看出,如果我们只需要 Redux 来做全局数据源并配合 props 透传使用的话,新版的 Context 可能是一个可以考虑的更简单的替代方案。另一方面,原生版本 Redux 的核心竞争力其实也并不在于此,而是其**中间件机制**以及社区中一系列非常成熟的中间件。
141 |
142 | 在 Context 版本中,用户行为(click)会直接调用 reducer 去更新数据。而在原生版本的 Redux 中,因为整个 action dispatch cycle 的存在,开发者可以在 dispatch action 前后,中心化地利用中间件机制去更好地跟踪/管理整个过程,如常用的 action logger,time travel 等中间件都受益于此。
143 |
144 | ### 渐进式地选择数据流工具
145 | #### Context
146 | * 我需要一个全局数据源且其他组件可以直接获取/改变全局数据源中的数据
147 |
148 | #### Redux
149 | * 我需要一个全局数据源且其他组件可以直接获取/改变全局数据源中的数据
150 | * 我需要全程跟踪/管理 action 的分发过程/顺序
151 |
152 | #### redux-thunk
153 | * 我需要一个全局数据源且其他组件可以直接获取/改变全局数据源中的数据
154 | * 我需要全程跟踪/管理 action 的分发过程/顺序
155 | * 我需要组件对同步或异步的 action 无感,调用异步 action 时不需要显式地传入 dispatch
156 |
157 | #### redux-saga
158 | * 我需要一个全局数据源且其他组件可以直接获取/改变全局数据源中的数据
159 | * 我需要全程跟踪/管理 action 的分发过程/顺序
160 | * 我需要组件对同步或异步的 action 无感,调用异步 action 时不需要显式地传入 dispatch
161 | * 我需要声明式地来表述复杂异步数据流(如长流程表单,请求失败后重试等),命令式的 thunk 对于复杂异步数据流的表现力有限
162 |
163 | ## Presentational vs. Container
164 | 时间回到 2015 年,那时 React 刚刚发布了 0.13 版本,Redux 也还没有成为 React 应用的标配,前端开发界讨论的[主题](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0)是 **React 组件的最佳设计模式**,后来大家得出的结论是将所有组件分为 Presentational(展示型) 及 Container(容器型)两类可以极大地提升组件的可复用性。
165 |
166 | 但后来 Redux 的广泛流行逐渐掩盖了这个非常有价值的结论,开发者们开始习惯性地将所有组件都 connect 到 redux store 上,以方便地获取所需要的数据。
167 |
168 | 组件与组件之间的层级结构渐渐地只存在于 DOM 层面,大量展示型的组件被 connect 到了 redux store 上,以至于在其他页面想要复用这个组件时,开发者们更倾向于复制粘贴部分代码。最终导致了 redux store 越来越臃肿,应用的数据流并没有因为引入 Redux 而变得清晰,可复用的展示型组件越来越少,应用与应用之间越来越独立,没有人再愿意去思考应用层面的抽象与复用,项目越做越多,收获的却越来越少。
169 |
170 | 当所有的组件都与数据耦合在一起,视图层与数据层之间的界限也变得越来越模糊,这不仅彻底打破了 React 本身的分形结构,更是造成应用复杂度陡增的罪魁祸首。
171 |
172 | ## Context + Redux = 更好的 React 应用设计模式
173 | 除了更克制地使用 connect,区分展示型与容器型组件之外,受制于现在 Context API,开发者通常也会将主题,语言文件等数据挂在 redux store 的某个分支上。对于这类不常更新,却需要随时可以注入到任意组件的数据,使用新的 Context API 来实现依赖注入显然是一个更好的选择。
174 |
175 | ```javascript
176 | import React from "react";
177 | import { render } from "react-dom";
178 | import { createStore } from "redux";
179 | import { Provider, connect } from "react-redux";
180 |
181 | const ThemeContext = React.createContext("light");
182 | class ThemeProvider extends React.Component {
183 | state = {
184 | theme: "light"
185 | };
186 |
187 | render() {
188 | return (
189 |
190 | {this.props.children}
191 |
192 | );
193 | }
194 | }
195 | const LanguageContext = React.createContext("en");
196 | class LanguageProvider extends React.Component {
197 | state = {
198 | laguage: "en"
199 | };
200 |
201 | render() {
202 | return (
203 |
204 | {this.props.children}
205 |
206 | );
207 | }
208 | }
209 | const initialState = {
210 | todos: []
211 | };
212 | const todos = (state, action) => {
213 | switch (action.type) {
214 | case "ADD_TODO":
215 | return {
216 | todos: state.todos.concat([action.text])
217 | };
218 | default:
219 | return state;
220 | }
221 | };
222 | function AppProviders({ children }) {
223 | const store = createStore(todos, initialState);
224 | return (
225 |
226 |
227 | {children}
228 |
229 |
230 | );
231 | }
232 | function ThemeAndLanguageConsumer({ children }) {
233 | return (
234 |
235 | {language => (
236 |
237 | {theme => children({ language, theme })}
238 |
239 | )}
240 |
241 | );
242 | }
243 |
244 | const TodoList = props => (
245 |
246 |
247 | {props.theme} and {props.language}
248 |
249 | {props.todos.map((todo, idx) =>
{todo}
)}
250 |
251 |
252 | );
253 |
254 | const mapStateToProps = state => ({
255 | todos: state.todos
256 | });
257 |
258 | const mapDispatchToProps = {
259 | handleClick: () => ({
260 | type: "ADD_TODO",
261 | text: "Awesome"
262 | })
263 | };
264 |
265 | const ToDoListContainer = connect(mapStateToProps, mapDispatchToProps)(
266 | TodoList
267 | );
268 |
269 | class App extends React.Component {
270 | render() {
271 | return (
272 |
273 |
274 | {({ theme, language }) => (
275 |
276 | )}
277 |
278 |
279 | );
280 | }
281 | }
282 |
283 | render(, document.getElementById("root"));
284 | ```
285 |
286 | 在上面的这个完整的例子中,通过组合多个 Context Provider,我们最终得到了一个组合后的 Context Consumer:
287 |
288 | ```javascript
289 |
290 | {({ theme, language }) => (
291 |
292 | )}
293 |
294 | ```
295 |
296 | 另一方面,通过分离展示型组件和容器型组件,我们得到了一个纯净的 `TodoList` 组件:
297 |
298 | ```javascript
299 | const TodoList = props => (
300 |
301 |
302 | {props.theme} and {props.language}
303 |
304 | {props.todos.map((todo, idx) =>
{todo}
)}
305 |
306 |
307 | );
308 | ```
309 |
310 | ## 小结
311 | 在 React v16.3.0 正式发布后,用 Context 来做依赖注入(theme,intl,buildConfig),用 Redux 来管理数据流,渐进式地根据业务场景选择 redux-thunk,redux-saga 或 redux-observable 来处理复杂异步情况,可能会是一种更好的 React 应用设计模式。
312 |
313 | 选择用什么样的工具从来都不是决定一个开发团队成败的关键,根据业务场景选择恰当的工具,并利用工具反过来约束开发者,最终达到控制整体项目复杂度的目的,才是促进一个开发团队不断提升的核心动力。
--------------------------------------------------------------------------------
/2017/服务端渲染与 Universal React App/服务端渲染与 Universal React App.md:
--------------------------------------------------------------------------------
1 | # 服务端渲染与 Universal React App
2 | 随着 Webpack 等前端构建工具的普及,客户端渲染因为其构建方便,部署简单等方面的优势,逐渐成为了现代网站的主流渲染模式。而在刚刚发布的 [React v16.0](https://reactjs.org/blog/2017/09/26/react-v16.0.html) 中,改进后更为优秀的服务端渲染性能作为六大更新点之一,被 React 官方重点提及。为此笔者还专门做了一个小调查,分别询问了二十位国内外(国内国外各十位)前端开发者,希望能够了解一下服务端渲染在使用 React 公司中所占的比例。
3 |
4 | 出人意料的是,十位国内的前端开发者中在生产环境使用服务端渲染的只有三位。而在国外的十位前端开发者中,使用服务端渲染的达到了惊人的八位。
5 |
6 | 这让人不禁开始思考,同是 React 的深度使用者,为什么国内外前端开发者在服务端渲染这个 React 核心功能的使用率上有着如此巨大的差别?在经过又一番刨根问底地询问后,真正的答案逐渐浮出水面,那就是可靠的 SEO(reliable SEO)。
7 |
8 | 相比较而言,国外公司对于 SEO 的重视程度要远高于国内公司,在这方面积累的经验也要远多于国内公司,前端页面上需要服务端塞入的内容也绝不仅仅是用户所看到的那些而已。所以对于国外的前端开发者来说,除去公司内部系统不谈,所有的客户端应用都需要做大量的 SEO 工作,服务端渲染也就顺理成章地成为了一个必选项。这也从一个侧面证明了国内外互联网环境的一个巨大差异,即虽然国际上也有诸如 Google,Facebook,Amazon 这样的巨头公司,但放眼整个互联网,这些巨头公司所产生的**黑洞效应**并没有国内 BAT 三家那样如此得明显,中小型公司依然有其生存的空间,搜索引擎所带来的自然流量就足够中小型公司可以活得很好。在这样的前提下,SEO 的重要性自然也就不言而喻了。
9 |
10 | 除去 SEO,服务端渲染对于前端应用的首屏加载速度也有着质的提升。特别是在 React v16.0 发布之后,新版 React 的服务端渲染性能相较于老版提升了三倍之多,这让已经在生产环境中使用服务端渲染的公司“免费”获得了一次网站加载速度提升的机会,同时也吸引了许多还未在生产环境中使用服务端渲染的开发者。
11 |
12 | ## 客户端渲染 vs. 服务端渲染 vs. 同构
13 | 在深入服务端渲染的细节之前,让我们先明确几个概念的具体含义。
14 |
15 | * 客户端渲染:页面在 JavaScript,CSS 等资源文件加载完毕后开始渲染,路由为客户端路由,也就是我们经常谈到的 SPA(Single Page Application)。
16 | * 服务端渲染:页面由服务端直接返回给浏览器,路由为服务端路由,URL 的变更会刷新页面,原理与 ASP,PHP 等传统后端框架类似。
17 | * 同构:英文表述为 Isomorphic 或 Universal,即编写的 JavaScript 代码可同时运行在浏览器及 Node.js 两套环境中,用服务端渲染来提升首屏的加载速度,首屏之后的路由由客户端控制,即在用户到达首屏后,整个应用仍是一个 SPA。
18 |
19 | 在明确了这三种渲染方案的具体含义后,我们可以发现,不论是客户端渲染还是服务端渲染,都有着其明显的缺陷,而同构显然是结合了二者优点之后的一种更好的解决方案。
20 |
21 | 但想在客户端写出一套完全符合同构要求的 React 代码并不是一件容易的事,与此同时还需要额外部署一套稳定的服务端渲染服务,这二者相加起来的开发或迁移成本都足以击溃许多想要尝试服务端渲染的 React 开发者的信心。
22 |
23 | 那么今天就让我们来一起总结下,符合同构要求的 React 代码都有哪些需要注意的地方,以及如何搭建起一个基础的服务端渲染服务。
24 |
25 | ## 总体架构
26 | 为了方便各位理解同构的具体实现过程,笔者基于 `react`,`react-router`,`redux` 以及 `webpack3` 实现了一个简单的[脚手架项目](https://github.com/AlanWei/react-boilerplate-ssr),支持客户端渲染和服务端渲染两种开发方式,供各位参考。
27 |
28 | 
29 |
30 | 1. 服务端预先获取编译好的客户端代码及其他资源。
31 | 2. 服务端接收到用户的 HTTP 请求后,触发服务端的路由分发,将当前请求送至服务端渲染模块处理。
32 | 3. 服务端渲染模块根据当前请求的 URL 初始化 memory history 及 redux store。
33 | 4. 根据路由获取渲染当前页面所需要的异步请求(thunk)并获取数据。
34 | 5. 调用 renderToString 方法渲染 HTML 内容并将初始化完毕的 redux store 塞入 HTML 中,供客户端渲染时使用。
35 | 6. 客户端收到服务端返回的已渲染完毕的 HTML 内容并开始同步加载客户端 JavaScript,CSS,图片等其他资源。
36 | 7. 之后的流程与客户端渲染完全相同,客户端初始化 redux store,路由找到当前页面的组件,触发组件的生命周期函数,再次获取数据。唯一不同的是 redux store 的初始状态将由服务端在 HTML 中塞入的数据提供,以保证客户端渲染时可以得到与服务端渲染相同的结果。受益于 Virtual DOM 的 diff 算法,这里并不会触发一次冗余的客户端渲染。
37 |
38 | 在了解了同构的大致思路后,接下来再让我们对同构中需要注意的点逐一进行分析,与各位一起探讨同构的最佳实践。
39 |
40 | ## 客户端与服务端构建过程不同
41 | 因为运行环境与渲染目的的不同,共用一套代码的客户端与服务端在构建方面有着许多的不同之处。
42 |
43 | ### 入口(entry)不同
44 | 客户端的入口为 `ReactDOM.render` 所在的文件,即将根组件挂载在 DOM 节点上。而服务端因为没有 DOM 的存在,只需要拿到需要渲染的 react 组件即可。为此我们需要在客户端抽离出独立的 `createApp` 及 `createStore` 的方法。
45 |
46 | ```javascript
47 | // createApp.js
48 |
49 | import React from 'react';
50 | import { Provider } from 'react-redux';
51 | import Router from './router';
52 |
53 | const createApp = (store, history) => (
54 |
55 |
56 |
57 | );
58 |
59 | export default createApp;
60 | ```
61 |
62 | ```javascript
63 | // createStore.js
64 |
65 | import { createStore, combineReducers, applyMiddleware, compose } from 'redux';
66 | import { routerReducer, routerMiddleware } from 'react-router-redux';
67 | import reduxThunk from 'redux-thunk';
68 | import reducers from './reducers';
69 | import routes from './router/routes';
70 |
71 | function createAppStore(history, preloadedState = {}) {
72 | // enhancers
73 | let composeEnhancers = compose;
74 |
75 | if (typeof window !== 'undefined') {
76 | // eslint-disable-next-line no-underscore-dangle
77 | composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
78 | }
79 |
80 | // middlewares
81 | const routeMiddleware = routerMiddleware(history);
82 | const middlewares = [
83 | routeMiddleware,
84 | reduxThunk,
85 | ];
86 |
87 | const store = createStore(
88 | combineReducers({
89 | ...reducers,
90 | router: routerReducer,
91 | }),
92 | preloadedState,
93 | composeEnhancers(applyMiddleware(...middlewares)),
94 | );
95 |
96 | return {
97 | store,
98 | history,
99 | routes,
100 | };
101 | }
102 |
103 | export default createAppStore;
104 | ```
105 |
106 | 并在 `app` 文件夹中将这两个方法一起输出出去:
107 |
108 | ```javascript
109 | import createApp from './createApp';
110 | import createStore from './createStore';
111 |
112 | export default {
113 | createApp,
114 | createStore,
115 | };
116 | ```
117 |
118 | ### 出口(output)不同
119 | 为了最大程度地提升用户体验,在客户端渲染时我们将根据路由对代码进行拆分,但在服务端渲染时,确定某段代码与当前路由之间的对应关系是一件非常繁琐的事情,所以我们选择将所有客户端代码打包成一个完整的 js 文件供服务端使用。
120 |
121 | 理想的打包结果如下:
122 | ```text
123 | ├── build
124 | │ └── v1.0.0
125 | │ ├── assets
126 | │ │ ├── 0.0.257727f5.js
127 | │ │ ├── 0.0.257727f5.js.map
128 | │ │ ├── 1.1.c3d038b9.js
129 | │ │ ├── 1.1.c3d038b9.js.map
130 | │ │ ├── 2.2.b11f6092.js
131 | │ │ ├── 2.2.b11f6092.js.map
132 | │ │ ├── 3.3.04ff628a.js
133 | │ │ ├── 3.3.04ff628a.js.map
134 | │ │ ├── client.fe149af4.js
135 | │ │ ├── client.fe149af4.js.map
136 | │ │ ├── css
137 | │ │ │ ├── style.db658e13004910514f8f.css
138 | │ │ │ └── style.db658e13004910514f8f.css.map
139 | │ │ ├── images
140 | │ │ │ └── 5d5d9eef.svg
141 | │ │ ├── vendor.db658e13.js
142 | │ │ └── vendor.db658e13.js.map
143 | │ ├── favicon.ico
144 | │ ├── index.html
145 | │ ├── manifest.json
146 | │ └── server (服务端需要的资源将被打包至这里)
147 | │ ├── assets
148 | │ │ ├── server.4b6bcd12.js
149 | │ │ └── server.4b6bcd12.js.map
150 | │ └── manifest.json
151 | ```
152 |
153 | ### 使用的插件(plugin)不同
154 | 与客户端不同,除去 JavaScript 之外,服务端并不需要任何其他的资源,如 HTML 及 CSS 等,所以在构建服务端 JavaScript 时,诸如 `HtmlWebpackPlugin` 等客户端所特有的插件就可以省去了,具体细节各位可以参考项目中的 [webpack.config.js](https://github.com/AlanWei/react-boilerplate-ssr/blob/master/client/webpack.config.js)。
155 |
156 | ### 数据获取方式不同
157 | 异步数据获取一直都是服务端渲染做得不够优雅的一个地方,其主要问题在于无法直接复用客户端的数据获取方法。如在 redux 的前提下,服务端没有办法像客户端那样直接在组件的`componentDidMount` 中调用 action 去获取数据。
158 |
159 | 为了解决这一问题,我们针对每一个 view 为其抽象出了一个 thunk 文件,并将其绑定在客户端的路由文件中。这样我们就可以在服务端通过 `react-router-config` 提供的 `matchRoutes` 方法找到当前页面的 thunk,并在 `renderToString` 之前 dispatch 这些异步方法,将数据更新至 redux store 中,以保证 `renderToString` 的渲染结果是包含异步数据的。
160 |
161 | ```javascript
162 | // thunk.js
163 | import homeAction from '../home/action';
164 | import action from './action';
165 |
166 | const thunk = store => ([
167 | store.dispatch(homeAction.getMessage()),
168 | store.dispatch(action.getUser()),
169 | ]);
170 |
171 | export default thunk;
172 |
173 | // createAsyncThunk.js
174 | import get from 'lodash/get';
175 | import isArrayLikeObject from 'lodash/isArrayLikeObject';
176 |
177 | function promisify(value) {
178 | if (typeof value.then === 'function') {
179 | return value;
180 | }
181 |
182 | if (isArrayLikeObject(value)) {
183 | return Promise.all(value);
184 | }
185 |
186 | return value;
187 | }
188 |
189 | function createAsyncThunk(thunk) {
190 | return store => (
191 | thunk()
192 | .then(component => get(component, 'default', component))
193 | .then(component => component(store))
194 | .then(component => promisify(component))
195 | );
196 | }
197 |
198 | export default createAsyncThunk;
199 |
200 | // routes.js
201 | const routes = [{
202 | path: '/',
203 | exact: true,
204 | component: AsyncHome,
205 | thunk: createAsyncThunk(() => import('../../views/home/thunk')),
206 | }, {
207 | path: '/user',
208 | component: AsyncUser,
209 | thunk: createAsyncThunk(() => import('../../views/user/thunk')),
210 | }];
211 | ```
212 |
213 | 服务端核心的页面渲染模块:
214 |
215 | ```javascript
216 | const ReactDOM = require('react-dom/server');
217 | const { matchRoutes } = require('react-router-config');
218 | const { Helmet } = require('react-helmet');
219 | const serialize = require('serialize-javascript');
220 | const createHistory = require('history/createMemoryHistory').default;
221 | const get = require('lodash/get');
222 | const head = require('lodash/head');
223 | const { getClientInstance } = require('../client');
224 |
225 | // Initializes the store with the starting url = require( request.
226 | function configureStore(req, client) {
227 | console.info('server path', req.originalUrl);
228 |
229 | const history = createHistory({ initialEntries: [req.originalUrl] });
230 | const preloadedState = {};
231 |
232 | return client.app.createStore(history, preloadedState);
233 | }
234 |
235 | // This essentially starts passing down the "context"
236 | // object to the Promise "then" chain.
237 | function setContextForThenable(context) {
238 | return () => context;
239 | }
240 |
241 | // Prepares the HTML string and the appropriate headers
242 | // and subequently string replaces them into their placeholders
243 | function renderToHtml(context) {
244 | const { client, store, history } = context;
245 | const appObject = client.app.createApp(store, history);
246 | const appString = ReactDOM.renderToString(appObject);
247 | const helmet = Helmet.renderStatic();
248 | const initialState = serialize(context.store.getState(), {isJSON: true});
249 |
250 | context.renderedHtml = client
251 | .html()
252 | .replace(//g, appString)
253 | .replace(//g, ``)
254 | .replace(/<\/head>/g, [
255 | helmet.title.toString(),
256 | helmet.meta.toString(),
257 | helmet.link.toString(),
258 | '',
259 | ].join('\n'))
260 | .replace(//g, ``)
261 | .replace(//g, ``);
262 |
263 | return context;
264 | }
265 |
266 | // SSR Main method
267 | // Note: Each function in the promise chain beyond the thenable context
268 | // should return the context or modified context.
269 | function serverRender(req, res) {
270 | const client = getClientInstance(res.locals.clientFolders);
271 | const { store, history, routes } = configureStore(req, client);
272 |
273 | const branch = matchRoutes(routes, req.originalUrl);
274 | const thunk = get(head(branch), 'route.thunk', () => {});
275 |
276 | Promise.resolve(null)
277 | .then(thunk(store))
278 | .then(setContextForThenable({ client, store, history }))
279 | .then(renderToHtml)
280 | .then((context) => {
281 | res.send(context.renderedHtml);
282 | return context;
283 | })
284 | .catch((err) => {
285 | console.error(`SSR error: ${err}`);
286 | });
287 | }
288 |
289 | module.exports = serverRender;
290 | ```
291 |
292 | 在客户端,我们可以直接在 `componentDidMount` 中调用这些 action:
293 |
294 | ```javascript
295 | const mapDispatchToProps = {
296 | getUser: action.getUser,
297 | getMessage: homeAction.getMessage,
298 | };
299 |
300 | componentDidMount() {
301 | this.props.getMessage();
302 | this.props.getUser();
303 | }
304 | ```
305 |
306 | 在分离了服务端与客户端 dispatch 异步请求的方式后,我们还可以针对性地对服务端的 thunk 做进一步的优化,即只请求首屏渲染需要的数据,剩下的数据交给客户端在 js 加载完毕后再请求。
307 |
308 | 但这里又引出了另一个问题,比如在上面的例子中,getUser 和 getMessage 这两个异步请求分别在服务端与客户端各请求了一次,即我们在很短的时间内重复请求了同一个接口两次,这是可以避免的吗?
309 |
310 | 这样的数据获取方式在纯服务端渲染时自然是冗余的,但在同构的架构下,其实是无法避免的。因为我们并不知道用户在访问客户端的某个页面时,是从服务端路由来的(即首屏),还是从客户端路由(首屏之后的后续路由)来的。也就是说如果我们不在组件的 `componentDidMount` 中去获取异步数据的话,一旦用户到达了某个页面,再点击页面中的某个元素跳转至另一页面时,是不会触发服务端的数据获取的,因为这时走的实际上是客户端路由。
311 |
312 | ## 服务端渲染还能做些什么
313 | 除去 SEO 与首屏加速,在额外部署了一套服务端渲染服务后,我们当然希望它能为我们分担更多的事情,那么究竟有哪些事情放在服务端去做是更为合适的呢?笔者总结了以下几点。
314 |
315 | ### 初始化应用状态
316 | 除去获取当前页面的数据,在做了同构之后,客户端还可以将获取应用全局状态的一些请求也交由服务端去做,如获取当前时区,语言,设备信息,用户等通用的全局数据。这样客户端在初始化 redux store 时就可以直接获取到上述数据,从而加快其他页面的渲染速度。与此同时,在分离了这部分业务逻辑到服务端之后,客户端的业务逻辑也会变得更加清晰。当然,如果你想做一个纯粹的 Universal App,也可以把初始化应用状态封装成一个方法,让服务端与客户端都可以自由地去调用它。
317 |
318 | ### 更早的路由处理
319 | 相较于客户端,服务端可以更早地对当前 URL 进行一些业务逻辑上的判断。比如 `404` 时,服务端可以直接将另一个 `error.html` 的模板发送至客户端,用户也就可以在第一时间收到相应的反馈,而不需要等到所有 JavaScript 等客户端资源加载完毕之后,才看到由客户端渲染的 `404` 页面。
320 |
321 | ### Node.js 中间层
322 | 有了服务端渲染这一层后,服务端还可以帮助客户端向 Cookie 中注入一些后端 API 中没有的数据,甚至做一些接口聚合,数据格式化的工作。这时,我们所写的 Node.js 服务端就不再是一个单纯的渲染服务了,而是进化为了一个 Node.js 中间层,可以帮助客户端完成许多在客户端做不到或很难做到的事情。
323 |
324 | ## 要不要做同构
325 | 在分析了同构的具体实现细节并了解了同构的好处之后,我们也需要知道这一切的好处并不是没有代价的,同构或者说服务端渲染最大的瓶颈就是服务端的性能。
326 |
327 | 在用户规模大到一定程度之后,客户端渲染本身就是一个完美的分布式系统,我们可以充分地利用用户的电脑去运行 JavaScript 中那些复杂的运算,而服务端渲染却将这些工作全部揽了回来并加到了网站自己的服务器上。
328 |
329 | 所以,考虑到投入产出比,同构可能并不适用于前端需要大量计算(如包含大量图表的页面)且用户量非常巨大的应用,却非常适用于大部分的内容展示型网站,比如知乎就是一个很好的例子。以知乎为例,服务端渲染与客户端渲染的成本几乎是相同的,重点都在于获取用户时间线上的数据,这时多页面的服务端渲染可以很好地加快首屏渲染的速度,又因为运行 `renderToString` 时的计算量并不大,即使用户量很大,也仍然是一件值得去做的事情。
330 |
331 | ## 小结
332 | 结合之前文章中提到的[前端数据层](https://github.com/AlanWei/blog/issues/5)的概念,服务端渲染服务其实是一个很好的前端开发介入服务端开发的切入点,在完成了服务端渲染服务后,对数据接口做一些代理或整合也是非常值得去尝试的工作。
333 |
334 | 一个代码库之所以复杂,很多时候就是因为分层架构没有做好而导致其中某一个模块过于臃肿,集中了大部分的业务复杂度,但其他模块又根本帮不上忙。想要做好前端数据层的工作,只把眼光局限在客户端是远远不够的,将业务复杂度均分到客户端及服务端,并让两方分别承担各自适合的工作,可能会是一种更好的解法。
--------------------------------------------------------------------------------