我们先来回顾一下 React ,Facebook 是这么描述的:
A JavaScript library for building user interfaces
官方定义其为 UI 库,这名字似乎太低调了些。从 React-Native 的发展就能看出来其野心勃勃,但官方的定义反而使其成为了开发者的宠儿 —— “为什么要用React?” “它只是个UI库”。
从 jQuery 开始,前端组件遍地花开,有jQuery官方提供的成套组件,也有活跃社区提供的第三方组件,从最简单的文本截断功能,到复杂的拖拽排序都应有尽有。业务使用的时候,经常会给 window 挂上 $,代码中组织也非常灵活,在需要复杂 dom 操作时,jQuery 总能帮忙轻松完成。
React 这个 UI 库进入大家的视野后,我们猛然发现『万物皆组件』,就连最不加修饰的业务代码也可以作为组件被其它模块所引用,这极大的激发了大家的热情。写代码的时候感觉在造轮子,在写导航栏、稍微通用点儿的功能时都自觉的将其拆了出来,刚要把标题写死,猛然想到 “如果这里用传参变量,UI加个参数配置,这不就成通用组件了吗!”。最早、最彻底的把后端模块思维引入到前端,所以 React 组件生态迅速壮大。
应该说 React 的出现加快了前端发展的进程,拉近了前端与后端开发的距离,之后各个框架便纷纷效仿,逐渐青睐对 Commonjs 规范的支持。业务开发中,将组件化思想彻底贯彻其中,许多人都迫不及待的希望发布自己平时积累的组件,下面就来谈谈如何从零开始构建组件库。
如何从零构建组件库
组件库的教程不只对 React 适用,其中提到的思想,对大多数通用组件编写都有效。
本篇介绍的全部构建脚本代码都可以在 https://github.com/fex-team/fit/blob/master/scripts 找到。
分散维护 VS 集中维护
准备搭建组件库之初,这估计是大家第一个会考虑到的问题:到底把组件库的代码放在一起,还是分散在各个仓库?
调查发现 Antd 是将所有组件都写入一个项目中,这样方便组件统一管理,开发时不需要在多个仓库之间切换,而且预览效果只需运行跟项目,而不是为每个组件开启一个端口进行预览。其依赖的 react-components 组件库中的组件以 rc 开头,不过这个项目没有进行集中管理。
Material-UI、 React-UI 采用集中式管理等等。
但是集中管理有一些弊端。
- 引用默认是载入全部,虽然可以通过配置方式避免,(Antd 还提供了 webpack 插件做这个事情),但安装时必须全量。
- 无法对每个组件做更细粒度的版本控制。
- 协作开发困难,每个人都要搭建一套全环境,提 pr 也具有不少难度。
分散维护的弊端更明显,无法在同一个项目中观察全局,修改组件后引发的连带风险无法观察,组件之间引用需要发布或者 mock,不直观,甚至组件之间的版本关联、依赖分析都没法有效进行管理。
因此 Fit 组件库在设计时,也经历了一番酝酿,最后采用了两者结合的方案,分散部署+集中维护的折中方式,而且竟能结合了两者各自的优点:
- 建立根项目 Root,用来做整体容器,顺便还可以当对外网站
- 建立 Group,并在其中建立多个组件仓库
- 开发时只要用到项目 Root,根据依赖文件编写脚本自动拉取每个仓库中的内容
- 主要负责人拉取全部子项目仓库,子组件维护者只需要下载对应组件
- 发布时独立发布每个组件
- 管理时,统一管理所有组件
package 版本统一
组件的依赖版本号需要统一,比如 fit-input ,fit-checkbox,fit-auto-complete 都依赖了 lodash,但因为先后开发时隔久远,安装时分别依赖了 2.x 3.x 4.x,当别人一起使用你最新版的时候,就会无辜的额外增加了两个 lodash 文件大小。
更可怕的是,连 React 的版本都不可靠,之前就遇到过一半组件留在 0.14.x ,一半新组件装了 15.x 的情况,直接导致了线上编译后项目出错,因为多个 React 组件不能同时兼容,这只是不能并存的其中一个例子。
因为项目开发时组件在一起,使统一版本号成为可能。我们将所有依赖到的组件都安装在 Root 项目中,每个组件的 package.json 由脚本自动生成,这个脚本需要静态扫描每个组件的 Import 或 require 语法,分析到依赖的模块后,使用根目录的版本号,填写在组件的 package.json 中,核心代码如下:
先收集每个组件中的依赖, 如果在根目录的 package.json 中找到了,就使用根目录的版本号。
完整代码仓库:https://github.com/fex-team/fit/blob/master/scripts/module-manage/utils/upgrade-dependencies.js
依赖联动
依赖联动是指,fit-button 更新了代码,如果 fit-table 依赖了 fit-button,那么其也要发布一个版本,更新 fit-button 依赖的版本号。
除了依赖第三方模块,组件之间可能也有依赖,如果将模块分散维护,想更新一下依赖模块都需要发布+下载,非常消耗时间,而且依赖联动根本没法做。集中维护使用 webpack 的 alias 方案,在 typescript 找不到引用,总之不想找麻烦就不能写 hack 的代码。
回到 Fit 组件库结构,因为所有组件都被下载到了 Root 仓库下,因此组件之间的引用也自然而然的使用了相对路径,这样组件更新麻烦的问题迎刃而解,唯一需要注意的是,发布后,将所有引入非本组件目录的引用,替换成为 npm 名称,例如:
1 2 3 4 |
// 源码的内容 import Button from '../../../button' // 发布时,通过编译脚本替换为 import Button from 'fit-button' |
依赖联动,需要在发布时,扫描所有组件,找出所有有更新的组件,并生成一项依赖配置,最后将所有更新、或者被依赖的组件统一升级版本号加入发布队列。
完整代码仓库:https://github.com/fex-team/fit/blob/master/scripts/module-manage/utils/version.js
inline Or ClassName?
React 组件使用 inline-style 还是 className 是个一直有争论的话题,在此我把自己的观点摆出:className 比 inline-style 更具有拓展性。
首先 className 更符合 css 使用习惯,inline-style 无疑是一种退步,既抛弃了 sass less post-css 等强大预编译工具的支持,也大大减弱了对内部样式的控制能力,它让 css 退化到了没有优先级,没有强大选择器的荒蛮时代。
其次没有预编译工具的支持,别忘了许多 css 的实验属性都需要加上浏览器前缀,除非用库把强大的 autoprefixer 再实现一遍。
使用 className 可以很好的加上前缀,在追查文件时能得到清晰的定位,下面是我们对 CSS 命名空间的一种实现 ——html-path-loader css-path-loader 插件 配合 webpack 后得到的调试效果:
文件结构
DOM结构对应 className
(https://cloud.githubusercontent.com/assets/7970947/17137106/edee7cc8-536b-11e6-9e5f-3dda7a87cf39.png)
直接从 dom 结构就能顺藤摸瓜找到文件,上线时再将路径 md5 处理。
这个插件会自动对当前目录下的 scss或less 文件包一层目录名,在 jsx
中,使用 className="_namespace"
,html-path-loader 会自动将 _namespace 替换为与 css 一致的目录名称。
typescript 支持
既然前端模块化向后端看齐,强类型也成为了无可阻挡的未来趋势,我们需要让开发出的组件原生支持 typescript 的项目,得到更好的开发体验,同时对 js 项目也能优雅降级。
由于现在 typescript 已原生支持 npm 生态,如果组件本身使用 typescript 开发,我们只需要使用 tsc -d
命令在目录下生成对应的 d.ts
定义文件,当业务项目使用 typescript 的时候,会自动解析 d.ts 作为组件的定义。
再给 package.json 再上 typings
定义指向入口文件的 d.ts
,那么整体工作基本就完成了。
最后,对于某些没有定义文件的第三方模块,我们在根项目 Root 中写上定义文件后, 导入时将文件拷贝一份到组件目录内,并修正相对引用的位置,保证组件独立发布后还可以找到依赖文件。
完整代码仓库:https://github.com/fex-team/fit/blob/master/scripts/module-manage/push.js
更强的拓展性
React 组件的拓展性似乎永远也争论不休,无论你怎样做组件,都会有人给你抱怨:要是这里支持 xxx 参数就好了。
毕竟使用了组件,就一定不如自己定制的拓展性更强,节省了劳动力,就要付出被约束的代价,Fit 作为一个大量被业务线使用的组件库,使用了透传方式尽可能的增强组件拓展性。
我们写了一个很简单的透传组件:fit-transmit-transparently
,使用方法如下:
1 2 3 |
import {others} from 'fit-transmit-transparently' const _others = others(new Component.defaultProps, this.props) // ... <div {..._others}/> |
它会将 this.props 中,除了 defaultProps 定义了的字段抽到 _others 中,直接透传给外围组件,因为 defaultProps 中定义了的字段默认是有含义的,因此不会对其进行操作,避免多次定义产生的风险。
现在 fit-input 就将 props 透传到了原生 Input 组件上,因此虽然我没有处理各类事件,但依然可以响应任意的 onKeyDown onKeyUp onChange onClick 等事件,也可以定义 style 来覆盖样式等等。
fit-number 继承了 fit-input,因此依然支持所有原生事件,fit-auto-complete 也继承了 fit-input,对其添加的例如 onBlur 等事件依然会被透传到 input 框中。
组件的 dom 结构要尽量精简,透传属性一般放置在最外层,但对于 input 这种重要标签,透传属性最好放置与其之上,因为用户的第一印象是 onChange 应该被 input 触发。
同构模块引用技巧
当依赖的模块不支持 node 环境,但还必须加载它的时候,我们希望在后端忽略掉它,而在前端加载它;当依赖模块只处理了后端逻辑,在前端没必要加载时,我们希望前端忽略它,后端加载它,下面是实现的例子:
1 2 3 4 5 6 |
// 前端加载 & 后端不加载 if (process.browser) { require ('module-only-support-in-browser'); } // 后端加载 & 前端不加载 require ('module-only' + '-support-in-node') |
前端加载&后端不加载的原理是,前端静态扫描到了这个模块,因此无条件加载了它(前端引用是静态扫描),后端会因为判断语句而忽略掉这个引用(后端引用是运行时)。
后端加载&前端不加载的原理是,将模块引用拆成非字面量,前端静态扫描发现,这是什么鬼?忽略掉吧,而 node 会老老实实的把模块拼凑起来,发现还真有 module-only-support-in-node
这个模块,因此引用了它。
一份代码 Demo & 源码显示
webpack 提供了如下 api 拓展 require 行为:
- ! 打头的,忽略配置文件的 preLoaders 设置
- !!打头的,忽略所有配置文件的设置
- -! 打头的,忽略 preLoaders 和 loaders ,但 postLoaders 依然有效
一般来说,我们都在配置文件设置了对 js 文件的 loader,如果想引用源码,正好可以用 !! 打头把所有 loaders 都干掉,然后直接用 text-loader 引用,这样我们就得到了一份纯源码以供展示。
组件编写一些注意点
理解 value 与 defaultValue
defaultValue 属性用于设置组件初始值,之后组件内部触发的值的改变,不会受到这个属性的影响,当父级组件触发 render 后,组件的值应当重新被赋予 defaultValue。
value 是受控属性,也用来设置值,但除了可以设置初始值(优先级比 defaultValue 高)之外,还应满足只要设置了 value,组件内部就无法修改状态的要求,这个组件的状态只能由父级授予并控制,所以叫受控属性。
value 与 defaultValue 不应该同时存在,最好做一下检查。
render 函数中最小化代码逻辑
React 的宗旨是希望通过修改状态来修改渲染内容,尽量不要在 render 函数中编写过多的业务逻辑和判断语句,最好将能抽离成状态的放在 state
中,在 componentWillReceiveProps
中改变它
使用 auto-bind
如果你也使用 ES6 写法,那么最好注意使用 auto-bind 插件,将所有成员函数自动绑定 this,否则.bind(this)
会返回一个新的函数,一来损耗性能,二来非常影响子组件的 shouldComponentUpdate
判断!
慎用 componentWillMount
对于同构模块,React 组件的生命周期 componentWillMount
会在 node 环境中执行,而componentDidMount
不会。
要避免在 willMount 中操作浏览器的 api,也要避免将可有可无的逻辑写在其中,导致后端服务器渲染吃力(目前 React 渲染是同步的),无关初始化逻辑应当放在 didMount 中,由客户端均摊计算压力。对于影响到页面渲染的逻辑还是要放在 willMount 中,不然后端渲染就没有意义。
巧用 key 做性能优化
React 组件生命周期中 shouldComponentUpdate
方法是控制组件状态改变时是否要触发渲染的,但当同级组件量非常庞大时,即便在每个组件做是否渲染的判断都会花费几百毫秒,这时我们就要选择更好的优化方式了。
新的优化方式还是基于 shouldComponentUpdate
,只不过判断条件非常苛刻,我们设定为只有 state 发生变化才会触发 render,其它任何情况都不会触发。这种方式排除了对复杂 props 条件的判断,当 props 结构非常复杂时,对没有使用 immutable 的代码简直是一场灾难,我们现在完全忽略 props 的影响,组件变成为了完完全全封闭的王国,不会听从任何人的指挥。
当我们实在需要更新它时,所有的 props 都不起作用,但是可以通过 key
的改变来绕过shouldComponentUpdate
进行强制刷新,这样组件的一举一动完全被我们控制在手,最大化提升了渲染效率。
组件级 Redux 如何应用
组件级 Redux 使用场景主要在于组件逻辑非常复杂、或使用时,父子 dom 强依赖,但可能不会被用于直接父子级的场景,例如 fit-scroll-listen 组件,用来做滚动监听:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import { ScrollListenBox, ScrollListenNail , ScrollListen, createStore } from 'fit-scroll-listen' const store = createStore() export default class Demo extends React.Component { render() { return ( <div> <ScrollListenBox store={store}> <ScrollListenNail store={store} title="第一位置">第一个位置</ScrollListenNail> 内容 </ScrollListenBox> <ScrollListen store={store}/> </div> ) } } |
ScrollListenBox
是需要监听滚动的区域,ScrollListenNail
是滚动区域中需要被标记的节点,ScrollListen
是显示滚动监听状态的 dom 结构。
由于业务需求,这三个节点很可能无法满足直接父级子关系,而且上图应用中,ScrollListen
就与ScrollListenBox
是同级关系,两者也无办法通信,因此需要使用 Redux 作数据通信。
我们从 createStore
实例化了一个 store,并传递给每一个 fit-scroll-listen
,这样他们即便隔着千山万水,也能畅快无阻的通信了。
npm 资源加载简析
webpack&fis 最核心的功能可以说就是对 npm 生态的支持了,社区是编译工具的衣食父母,支持了生态才会有未来。
为了解决业务线可能遇到的各种 npm 环境问题,我们要有刨根问底的精神,了解 npm 包加载原理。下面会一步一步介绍一个 npm 模块是如何被解析加载的。
文件查找
无论是 webpack、fis,还是其它构建工具,都有文件查找的钩子,当解析了类似 import '../index.js'
时,会优先查找相对路径,但解析到了 import 'react'
便无从下手,因为这时构建工具还不知道这种模块应该从哪查找,我们就从这里开始截断,当出现无法找到的模块时,就优先从 node_modules 文件夹下进行查找(node_modules 下查找模块放到后面讲)。
由于 npm 模块打平&嵌套两种方案可能并存,每次都递归查找的效率太低,因此我们首先会把 node_modules 下所有模块缓存起来,这里分为两种方案:
- 根据node_modules 下文件夹遍历读取,优点是扫描全面,缺点是效率低。
- 根据 package.json 中 deps(可以设置忽略devDeps)进行扫描,优先是效率高,缺点是忘记 –save 模块会被忽略。
将所有模块存到 map 后,我们直接就能 get
到想要的模块,但是要注意版本问题:如果这个模块是打平安装的,那毫无疑问不会存在同模块多版本号问题,npm@3.x 后即便是打平安装,但遇到依赖模块已经在根目录存在,但版本号不一致,还是会采用嵌套方式,而 npm@2.x 无论如何都会用嵌套的方式。
因此我们的目的就明确了,不用区分 npm 的版本,如果这个当前文件位于非 node_modules 文件夹中,直接从根目录引用它需要的模块,如果这个当前位于 node_modules 中,优先从当前文件夹中的 node_modules 获取,如果当前文件夹的 node_modules 不存在依赖文件,就从根目录取。
解读 package.json
找到了依赖在 node_modules 里的根目录,我们就要解析 package.json 进行引用了,main
这个属性是我们的指明灯,告诉我们在复杂的包结构中,哪个文件才是真正的入口文件。
我们还要注意 package.json 里设置了 browser 属性的模块,由于我们做的是前端文件加载,所以这个属性对我们有效,将依赖模块的路径用 browser 做修正即可,一般都是同构模块使用它,特意将前端实现重写了一遍。所以当 browser 属性为字符串时我们就放弃对 main
信任,转而使用 browser
属性来代替入口路径。
当 browser 属性为对象时,情况复杂一些,因为此时 browser 指代的含义不是入口文件的相对路径,而是对这个模块内部使用的包引用的重定向,此时我们还不能信任 main
对入口的引导,初始化时将 browser
对象保存,整体查找顺序是:优先查找当前模块的 browser 设置,替换 require 路径,找到模块后,如果 browser 是字符串,优先用其路径,否则使用 main 的路径。
环境变量
npm 生态非常惯着用户,我们希望直接在模块中使用 Buffer process.env.NODE_ENV 等变量,而且通常会根据当前传入的变量环境做判断,可能开发过程中载入了不少影响性能,但方便调试的插件,当NODE_ENV
为production
时会自动干掉,如果我们不对这种情况做处理,上线后无法达到模块的最佳性能(甚至报错,因为 process 没有定义)。
编译脚本要根据用户的设置,比如 CLI 使用了 NODE_ENV=production ,或者在插件中申明,就将代码中process.env.NODE_ENV
替换为对应的字符串,对与 Buffer 这类模块也要单独拎出来替换成 require。
模块加载
为了让浏览器识别 module.exports (es6 的 export 语法交给 babel 或者 typescript 转换为 module.exports)、define、require,需要给模块包一层 Define,同时把模块名缓存到 map 中,可以根据文件路径起名字,也可以使用 hash,最后 require 就从这里取即可。
由于是简析,不做更深入的分析,剩下的工作基本上是优化缓存、对更多功能语法的支持。
同构方案
为了保证传统的首屏体验,同时维持单页应用的优势,替代方案走了不少弯路。从单独写一份给爬虫看的页面,到使用 phantomjs 抓取静态页面信息,现在已经步入了后端渲染阶段,由于其可维护性与用户体验两者兼顾,所以才快速壮大起来。
后端渲染
无论何种后端渲染方案,其本质都是在后端使用 nodejs 运行前端的 js 代码,有的库使用同步渲染,也有异步,React 目前官方实现属于同步渲染,关于同步渲染遇到的问题与解决方案,会在 “同构请求” 这一节说明。
使用 React 进行后端渲染代码aces
官方定义其为 UI 库,这名字似乎太低调了些。从 React-Native 的发展就能看出来其野心勃勃,但官方的定义反而使其成为了开发者的宠儿 —— “为什么要用React?” “它只是个UI库”。
从 jQuery 开始,前端组件遍地花开,有jQuery官方提供的成套组件,也有活跃社区提供的第三方组件,从最简单的文本截断功能,到复杂的拖拽排序都应有尽有。业务使用的时候,经常会给 window 挂上 $,代码中组织也非常灵活,在需要复杂 dom 操作时,jQuery 总能帮忙轻松完成。
React 这个 UI 库进入大家的视野后,我们猛然发现『万物皆组件』,就连最不加修饰的业务代码也可以作为组件被其它模块所引用,这极大的激发了大家的热情。写代码的时候感觉在造轮子,在写导航栏、稍微通用点儿的功能时都自觉的将其拆了出来,刚要把标题写死,猛然想到 “如果这里用传参变量,UI加个参数配置,这不就成通用组件了吗!”。最早、最彻底的把后端模块思维引入到前端,所以 React 组件生态迅速壮大。
应该说 React 的出现加快了前端发展的进程,拉近了前端与后端开发的距离,之后各个框架便纷纷效仿,逐渐青睐对 Commonjs 规范的支持。业务开发中,将组件化思想彻底贯彻其中,许多人都迫不及待的希望发布自己平时积累的组件,下面就来谈谈如何从零开始构建组件库。
如何从零构建组件库
组件库的教程不只对 React 适用,其中提到的思想,对大多数通用组件编写都有效。
本篇介绍的全部构建脚本代码都可以在 https://github.com/fex-team/fit/blob/master/scripts 找到。
分散维护 VS 集中维护
准备搭建组件库之初,这估计是大家第一个会考虑到的问题:到底把组件库的代码放在一起,还是分散在各个仓库?
调查发现 Antd 是将所有组件都写入一个项目中,这样方便组件统一管理,开发时不需要在多个仓库之间切换,而且预览效果只需运行跟项目,而不是为每个组件开启一个端口进行预览。其依赖的 react-components 组件库中的组件以 rc 开头,不过这个项目没有进行集中管理。
Material-UI、 React-UI 采用集中式管理等等。
但是集中管理有一些弊端。
- 引用默认是载入全部,虽然可以通过配置方式避免,(Antd 还提供了 webpack 插件做这个事情),但安装时必须全量。
- 无法对每个组件做更细粒度的版本控制。
- 协作开发困难,每个人都要搭建一套全环境,提 pr 也具有不少难度。
分散维护的弊端更明显,无法在同一个项目中观察全局,修改组件后引发的连带风险无法观察,组件之间引用需要发布或者 mock,不直观,甚至组件之间的版本关联、依赖分析都没法有效进行管理。
因此 Fit 组件库在设计时,也经历了一番酝酿,最后采用了两者结合的方案,分散部署+集中维护的折中方式,而且竟能结合了两者各自的优点:
- 建立根项目 Root,用来做整体容器,顺便还可以当对外网站
- 建立 Group,并在其中建立多个组件仓库
- 开发时只要用到项目 Root,根据依赖文件编写脚本自动拉取每个仓库中的内容
- 主要负责人拉取全部子项目仓库,子组件维护者只需要下载对应组件
- 发布时独立发布每个组件
- 管理时,统一管理所有组件
package 版本统一
组件的依赖版本号需要统一,比如 fit-input ,fit-checkbox,fit-auto-complete 都依赖了 lodash,但因为先后开发时隔久远,安装时分别依赖了 2.x 3.x 4.x,当别人一起使用你最新版的时候,就会无辜的额外增加了两个 lodash 文件大小。
更可怕的是,连 React 的版本都不可靠,之前就遇到过一半组件留在 0.14.x ,一半新组件装了 15.x 的情况,直接导致了线上编译后项目出错,因为多个 React 组件不能同时兼容,这只是不能并存的其中一个例子。
因为项目开发时组件在一起,使统一版本号成为可能。我们将所有依赖到的组件都安装在 Root 项目中,每个组件的 package.json 由脚本自动生成,这个脚本需要静态扫描每个组件的 Import 或 require 语法,分析到依赖的模块后,使用根目录的版本号,填写在组件的 package.json 中,核心代码如下:
先收集每个组件中的依赖, 如果在根目录的 package.json 中找到了,就使用根目录的版本号。
完整代码仓库:https://github.com/fex-team/fit/blob/master/scripts/module-manage/utils/upgrade-dependencies.js
依赖联动
依赖联动是指,fit-button 更新了代码,如果 fit-table 依赖了 fit-button,那么其也要发布一个版本,更新 fit-button 依赖的版本号。
除了依赖第三方模块,组件之间可能也有依赖,如果将模块分散维护,想更新一下依赖模块都需要发布+下载,非常消耗时间,而且依赖联动根本没法做。集中维护使用 webpack 的 alias 方案,在 typescript 找不到引用,总之不想找麻烦就不能写 hack 的代码。
回到 Fit 组件库结构,因为所有组件都被下载到了 Root 仓库下,因此组件之间的引用也自然而然的使用了相对路径,这样组件更新麻烦的问题迎刃而解,唯一需要注意的是,发布后,将所有引入非本组件目录的引用,替换成为 npm 名称,例如:
1 2 3 4 |
// 源码的内容 import Button from '../../../button' // 发布时,通过编译脚本替换为 import Button from 'fit-button' |
依赖联动,需要在发布时,扫描所有组件,找出所有有更新的组件,并生成一项依赖配置,最后将所有更新、或者被依赖的组件统一升级版本号加入发布队列。
完整代码仓库:https://github.com/fex-team/fit/blob/master/scripts/module-manage/utils/version.js
inline Or ClassName?
React 组件使用 inline-style 还是 className 是个一直有争论的话题,在此我把自己的观点摆出:className 比 inline-style 更具有拓展性。
首先 className 更符合 css 使用习惯,inline-style 无疑是一种退步,既抛弃了 sass less post-css 等强大预编译工具的支持,也大大减弱了对内部样式的控制能力,它让 css 退化到了没有优先级,没有强大选择器的荒蛮时代。
其次没有预编译工具的支持,别忘了许多 css 的实验属性都需要加上浏览器前缀,除非用库把强大的 autoprefixer 再实现一遍。
使用 className 可以很好的加上前缀,在追查文件时能得到清晰的定位,下面是我们对 CSS 命名空间的一种实现 ——html-path-loader css-path-loader 插件 配合 webpack 后得到的调试效果:
文件结构
DOM结构对应 className
(https://cloud.githubusercontent.com/assets/7970947/17137106/edee7cc8-536b-11e6-9e5f-3dda7a87cf39.png)
直接从 dom 结构就能顺藤摸瓜找到文件,上线时再将路径 md5 处理。
这个插件会自动对当前目录下的 scss或less 文件包一层目录名,在 jsx
中,使用 className="_namespace"
,html-path-loader 会自动将 _namespace 替换为与 css 一致的目录名称。
typescript 支持
既然前端模块化向后端看齐,强类型也成为了无可阻挡的未来趋势,我们需要让开发出的组件原生支持 typescript 的项目,得到更好的开发体验,同时对 js 项目也能优雅降级。
由于现在 typescript 已原生支持 npm 生态,如果组件本身使用 typescript 开发,我们只需要使用 tsc -d
命令在目录下生成对应的 d.ts
定义文件,当业务项目使用 typescript 的时候,会自动解析 d.ts 作为组件的定义。
再给 package.json 再上 typings
定义指向入口文件的 d.ts
,那么整体工作基本就完成了。
最后,对于某些没有定义文件的第三方模块,我们在根项目 Root 中写上定义文件后, 导入时将文件拷贝一份到组件目录内,并修正相对引用的位置,保证组件独立发布后还可以找到依赖文件。
完整代码仓库:https://github.com/fex-team/fit/blob/master/scripts/module-manage/push.js
更强的拓展性
React 组件的拓展性似乎永远也争论不休,无论你怎样做组件,都会有人给你抱怨:要是这里支持 xxx 参数就好了。
毕竟使用了组件,就一定不如自己定制的拓展性更强,节省了劳动力,就要付出被约束的代价,Fit 作为一个大量被业务线使用的组件库,使用了透传方式尽可能的增强组件拓展性。
我们写了一个很简单的透传组件:fit-transmit-transparently
,使用方法如下:
1 2 3 |
import {others} from 'fit-transmit-transparently' const _others = others(new Component.defaultProps, this.props) // ... <div {..._others}/> |
它会将 this.props 中,除了 defaultProps 定义了的字段抽到 _others 中,直接透传给外围组件,因为 defaultProps 中定义了的字段默认是有含义的,因此不会对其进行操作,避免多次定义产生的风险。
现在 fit-input 就将 props 透传到了原生 Input 组件上,因此虽然我没有处理各类事件,但依然可以响应任意的 onKeyDown onKeyUp onChange onClick 等事件,也可以定义 style 来覆盖样式等等。
fit-number 继承了 fit-input,因此依然支持所有原生事件,fit-auto-complete 也继承了 fit-input,对其添加的例如 onBlur 等事件依然会被透传到 input 框中。
组件的 dom 结构要尽量精简,透传属性一般放置在最外层,但对于 input 这种重要标签,透传属性最好放置与其之上,因为用户的第一印象是 onChange 应该被 input 触发。
同构模块引用技巧
当依赖的模块不支持 node 环境,但还必须加载它的时候,我们希望在后端忽略掉它,而在前端加载它;当依赖模块只处理了后端逻辑,在前端没必要加载时,我们希望前端忽略它,后端加载它,下面是实现的例子:
1 2 3 4 5 6 |
// 前端加载 & 后端不加载 if (process.browser) { require ('module-only-support-in-browser'); } // 后端加载 & 前端不加载 require ('module-only' + '-support-in-node') |
前端加载&后端不加载的原理是,前端静态扫描到了这个模块,因此无条件加载了它(前端引用是静态扫描),后端会因为判断语句而忽略掉这个引用(后端引用是运行时)。
后端加载&前端不加载的原理是,将模块引用拆成非字面量,前端静态扫描发现,这是什么鬼?忽略掉吧,而 node 会老老实实的把模块拼凑起来,发现还真有 module-only-support-in-node
这个模块,因此引用了它。
一份代码 Demo & 源码显示
webpack 提供了如下 api 拓展 require 行为:
- ! 打头的,忽略配置文件的 preLoaders 设置
- !!打头的,忽略所有配置文件的设置
- -! 打头的,忽略 preLoaders 和 loaders ,但 postLoaders 依然有效
一般来说,我们都在配置文件设置了对 js 文件的 loader,如果想引用源码,正好可以用 !! 打头把所有 loaders 都干掉,然后直接用 text-loader 引用,这样我们就得到了一份纯源码以供展示。
组件编写一些注意点
理解 value 与 defaultValue
defaultValue 属性用于设置组件初始值,之后组件内部触发的值的改变,不会受到这个属性的影响,当父级组件触发 render 后,组件的值应当重新被赋予 defaultValue。
value 是受控属性,也用来设置值,但除了可以设置初始值(优先级比 defaultValue 高)之外,还应满足只要设置了 value,组件内部就无法修改状态的要求,这个组件的状态只能由父级授予并控制,所以叫受控属性。
value 与 defaultValue 不应该同时存在,最好做一下检查。
render 函数中最小化代码逻辑
React 的宗旨是希望通过修改状态来修改渲染内容,尽量不要在 render 函数中编写过多的业务逻辑和判断语句,最好将能抽离成状态的放在 state
中,在 componentWillReceiveProps
中改变它
使用 auto-bind
如果你也使用 ES6 写法,那么最好注意使用 auto-bind 插件,将所有成员函数自动绑定 this,否则.bind(this)
会返回一个新的函数,一来损耗性能,二来非常影响子组件的 shouldComponentUpdate
判断!
慎用 componentWillMount
对于同构模块,React 组件的生命周期 componentWillMount
会在 node 环境中执行,而componentDidMount
不会。
要避免在 willMount 中操作浏览器的 api,也要避免将可有可无的逻辑写在其中,导致后端服务器渲染吃力(目前 React 渲染是同步的),无关初始化逻辑应当放在 didMount 中,由客户端均摊计算压力。对于影响到页面渲染的逻辑还是要放在 willMount 中,不然后端渲染就没有意义。
巧用 key 做性能优化
React 组件生命周期中 shouldComponentUpdate
方法是控制组件状态改变时是否要触发渲染的,但当同级组件量非常庞大时,即便在每个组件做是否渲染的判断都会花费几百毫秒,这时我们就要选择更好的优化方式了。
新的优化方式还是基于 shouldComponentUpdate
,只不过判断条件非常苛刻,我们设定为只有 state 发生变化才会触发 render,其它任何情况都不会触发。这种方式排除了对复杂 props 条件的判断,当 props 结构非常复杂时,对没有使用 immutable 的代码简直是一场灾难,我们现在完全忽略 props 的影响,组件变成为了完完全全封闭的王国,不会听从任何人的指挥。
当我们实在需要更新它时,所有的 props 都不起作用,但是可以通过 key
的改变来绕过shouldComponentUpdate
进行强制刷新,这样组件的一举一动完全被我们控制在手,最大化提升了渲染效率。
组件级 Redux 如何应用
组件级 Redux 使用场景主要在于组件逻辑非常复杂、或使用时,父子 dom 强依赖,但可能不会被用于直接父子级的场景,例如 fit-scroll-listen 组件,用来做滚动监听:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import { ScrollListenBox, ScrollListenNail , ScrollListen, createStore } from 'fit-scroll-listen' const store = createStore() export default class Demo extends React.Component { render() { return ( <div> < |