正文
回想17年的京东首页改版,从上线到现在竟然已经过去了四个多月。这四个多月,除了不曾中断的日常维护需求,对首页孜孜不倦的优化工作,更多的是那些与拖延症抗争的日夜:是今天写,还是等好好休憩回味后再动手?很明显,在这几十上百个日夜里,我基本都选择了第三个选项:
不折腾了,先休息吧
。现在想起来,关于那一个月白加黑五加二加班生活的印象已经渐渐模糊。到现在依然能清晰记着的,大概是最为深刻的记忆了。
16版的京东首页,在性能、体验、灾备策略等各方面都做到了极致。站在如此高大的巨人肩上,除了满满的自信,我们心里更怕扑街。毫无疑问,我们在接到改版需求的那一刻,立马就敲定了新首页的技术选型:妥妥的
jQuery
+
SeaJS
!
但很快,我们就发现这样一点都不酷。
jQuery
是2006年的框架了,
SeaJS
停止维护也已经四年。这些项目的诞生都是为了解决当时业界的一些痛点:比如
jQuery
最开始是为了方便程序员在页面中操作DOM,绑定事件等;
SeaJS
则是为了在浏览器中实现CMD规范的模块开发和加载。但在各种VirtualDOM框架横飞的现在,程序员已经很少会直接操作DOM元素,而模块的开发和加载也有早已有了别的方案。
就在这时,Nerv项目的作者提出了建议:“
不然用Nerv来一发?
”我记得,当时他脸上洋溢着淳朴的笑,Nerv也仅仅是部门内部的一个小项目。我们回想了首页这个业务,技术栈已经好几年未曾更新过,开发流程也不够理想。如果再不做出改变,明年的这个时候我们依然会面对一堆陈年老代码头疼不已。抱着试一试的心态,我们接受了他的提议。没想到,这个决定让首页从此摆脱了落后的技术架构,而Nerv现在也已经成长为GitHub上3k+ Star的热门项目。
Q: 为什么不使用
React
/
Preact
/
Vue
?
A: 这三者都是前端圈子中相当流行的项目。
React
有完善的体系和浓厚的社区氛围,
Preact
有着羞涩的体积,
Vue
则使用了先进的html模板和数据绑定机制。但是,上边这三者都
无法兼容IE8
。我们在经过相关数据的论证后,发现IE8的用户还是有一定的价值,这才最终激发了我们团队内部自己造轮子的想法。当然,在造轮子的过程中,我们也不忘向上面这些优秀框架的看齐。最终,
Nerv
在完美兼容React语法的同时,具有着出众的性能表现,在Gzip后也只占用9Kb的体积。
整体架构
在这次的项目中,我们基于上一年久经考验的前端体系(详细介绍),进行了升级:
Athena前端工程化工具:团队自研的前端工程化工具。除了自动化编译、代码处理、依赖分析、文件压缩等常规需求,2.0版本还支持
基于npm的依赖管理
,
更加先进的引入、导出机制
,还有
最新的es语言特性
。
Athena管理平台:新增了
针对Nerv的项目模板
,另外还有针对H5项目的特色模板可选。
Athena基础库与组件库:新增了基于
jQuery
+
SeaJS
的组件重构,
全新升级的Nerv组件
。
Athena模拟接口:除了已有的mock接口数据的能力,还支持
接口文档生成
,便于沉淀项目接口信息。
Athena兜底接口:可以定时抓取线上接口的数据
生成兜底数据
,还支持
接口数据校验
,评估接口健康度。
Athena前端监控:我们部署了一系列的监控服务,对页面上的素材以及页面的完整功能进行监控。一旦图片尺寸/体积超限,某些特定的操作出现异常,或者接口成功率降低等异常情况,就会触发告警推送,开发者可以
实时收到告警信息
。
Athena可视化报表:Athena可视化报表平台上对上报的数据都有
直观的展示
。
开发模式
Athena2.0
1.0版本的
Athena
,基于
vinyl-fs
的流操作,或者说是类似于gulp的压缩、编译等等操作的任务流。而到了2017年,
webpack
早已在前端圈中流行。同行们也早已经习惯在项目中直接基于最新的语言特性去开发,在
webpack.config.js
加上一个
babel-loader
就可以完美支持新语法并完成打包。Athena 1.0背着太沉重的历史包袱,已经很难快速实现对babel转译的支持。所以在首页的开发前,我们将Athena升级到了全新的2.0版本。
一如既往,Athena会为项目提供
init
(初始化),
serve
(实时预览),
build
(编译),
publish
(发布)等功能。除此之外,由于2.0版本的Athena是基于
webpack
的,所以项目中可以
统一用npm来管理依赖
,也可以直接
使用最新的ES语言特性
来进行开发。
使用Athena2.0开发时,建议的文件架构如下:
前后端协作
我们依然是采用了
前后端分离
的协作模式,由后端给出json格式的数据,前端拉取json数据进行渲染。对于大部分的组件来说,都会在
constructor
中做好组件的初始化工作,在
componentDidMount
的生命周期中拉取数据写入组件的
state
,再通过
render
函数进行渲染。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import Nerv from 'nervjs'
class MyComponent extends Nerv
.Component {
constructor () {
super (...arguments)
this .state = {
xxx : 'xxx'
}
}
async requestData() {
}
componentDidMount() {
this .requestData()
.then(data => {
this .setState({
xxx : 'yyy'
})
}).catch(() => {
this .setState({
xxx : 'zzz'
})
})
}
render() {
return (
<div > {this.state.xxx}div >
)
}
}
export default MyComponent
代码规范约束
有一千个读者,就会有一千个哈姆雷特。
上面这句名言,深刻地体现在了16版首页的代码仓库中。同一个组件,如果是基于
jQuery
+
SeaJS
的模式,一千个程序猿就会有一千种写法。结果在同一个项目中,代码风格不尽相同,代码质量良莠不齐,多人协作也会无从下手。
何以解忧?唯有统一代码风格了。通过
ESLint
+
Husky
,我们对每次代码提交都做了代码风格检查,对是否使用prefer const的变量声明、代码缩进该使用Tab还是空格等等的规则都做了约束。一开始定下规范的时候,团队成员或多或少都会有些不习惯。但通过偷偷在
代码里下毒
Athena的生成的项目模板中添加对应的规则,潜移默化地,团队成员们也都开始接受、习惯这些约束。
禁用变量重声明等规则,在一定程度上保证了代码质量;而统一的代码样式风格,则使得项目的多人协作更加便利。
Q: 保证代码质量,促进多人协作的终极好处是什么?
A: 由于项目代码风格统一,通俗易懂容易上手,我们首页的开发团队终于开始
有妹纸加入了
!
一群雄性程序猿敲代码能敲出什么火花啊…
对性能优化的探索
首屏直出
直出可能是加快首屏加载最行之有效的办法了。它在减少页面加载时间、首屏请求数等方面的好处自然不必再提,结合
jQuery
,也可以很方便地在直出的DOM上进行更多的操作。
Nerv
框架对于它内部的组件、DOM有着良好的操作性,但是
对于体系外的DOM节点
,却是天生的
操作无力
。举个例子,比如在页面文件中我们直出一个轮播图:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<div id ="example" class ="mod_slider" >
<a class ="mod_slider_prev" />
<ul class ="mod_slider_list" >
<li class ="mod_slider_item" >
<a href ="javascript:;"
> <img src ="//www.example.com/example.png" /> a >
li >
<li class ="mod_slider_item" >
<a href ="javascript:;" > <img src ="//www.example.com/example.png" /> a >
li >
<li class ="mod_slider_item" >
<a href ="javascript:;" > <img src ="//www.example.com/example.png" /> a >
li >
<li class ="mod_slider_item" >
<a href ="javascript:;" > <img src ="//www.example.com/example.png" /> a >
li >
ul >
<a class ="mod_slider_next" />
div >
使用
Nerv
为这段HTML添加轮播逻辑,成为了非常艰难的操作。终极的解决方案,应该是使用
SSR(Server Side Render)
的方案,搭建
Nerv-server
中间层来将组件直出。但现在革命尚未成功,首屏直出尚且依赖后端的研发同学,首页上线又迫在眉睫。被逼急的我们最终选择了比较trick的方式来过渡这个问题:在组件初始化的时候先通过DOM操作获取渲染所需的数据,再将DOM替换成
Nerv
渲染后的内容。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import Slider from 'path/to/slider'
class Example extends Nerv .Component
{
constructor () {
const ctn = this .props.ctn
this .state = {
data : this .gatherInfos()
}
ctn.innerHTML = ''
}
gatherInfos() {
}
render() {
return (
<Slider >
{this.data.map(v => <li /> )}
Slider >
)
}
}
const el = document .querySelector('#example' )
Nerv.render(<Example ctn ={el} /> , el)
代码分割
在生产环境中,随着代码体积增大,浏览器解压Gzip、执行等操作也会需要更多的开销。在SeaJS的时代,我们尚且会通过
SeaJS.use
或者
require.async
异步加载模块代码,避免一次性加载过多内容。但
webpack
的默认行为却会将整个页面的代码打包为一个单独的文件,这明显不是最佳的实践。对此,webpack给出的解决方案是动态引入(Dynamic Imports)。我们可以通过如下的代码来使用这个便利的特性:
1
2
3
4
5
6
import (
'${chunkName}'
).then((loaded ) => {
})
与此同时,
webpack
会将使用了动态引入的组件从主bundle文件中抽离出来,这就
减小了主bundle文件的体积
。
对于我们的具体需求而言,需要做动态引入的一般是
Nerv
的组件。对于组件的动态引入,业界已经有非常好的实现方案 react-loadable。举个栗子,通过下面的代码,我们可以在页面中使用
来实现对组件
MyComponent
的动态引入,并且具有
加载超时、错误、加载中
等不同状态的展示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import Loadable from 'react-loadable' ;
const LoadableMyComponent = Loadable({
loader : () => import ('MyComponent' ),
delay : 300 ,
loading : (props ) => {
if (props.error) {
return <div > Error!div > ;
} else
if (props.timedOut) {
return <div > TimedOut!div > ;
} else if (props.pastDelay) {
return <div > Loading...div > ;
} else {
return null ;
}
},
render : (loaded, props ) => {
return <loaded.default {...props }/> ;
}
});
export default LoadableMyComponent
再进一步,我们希望对于屏幕外的组件,仅仅是在它进入用户视野后再开始加载,这也就是我们常说的滚动懒加载。这可以结合业界已有的懒加载组件react-lazyload来实现。针对上面的
,在下面的例子中,只有进入用户屏幕后,
MyComponent
才会开始加载:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import Nerv from 'nervjs'
import LazyLoad from 'react-lazyload' ;
import LoadableMyComponent from './loadableMyComponent' ;
class MyApp extends Nerv .Component {
render () {
return (
<div className ='app' >
<LazyLoad height ={200} placeholderClassName ={ 'mod_lazyload '}>
<LoadableMyComponent />
LazyLoad >
div >
)
}
}
export default MyApp
上面的例子为lazyload的组件设置了200px的占位高度。并且设定了占位元素的类名,方便设定样式。
代码延后加载
在给首页全面升级技术栈的时候,我们忽略了一个问题:页面上还引用着少量来自兄弟团队的SeaJS模块,我们升级了技术栈是可以,但是强迫兄弟团队也一起去掉SeaJS重构一遍代码,这就有点不合理了。我们也不能仅仅为了这部分模块,就把
SeaJS
给打包进代码里面,这也是不科学的。
上面讲到的
动态引入
功能,帮我们很好地解决了这个问题。我们在代码中单独抽离了一个
legacy
模块,其中包含了
SeaJS
、
SeaJS-combo
等老模块并做了导出。这部分代码在首屏中并不直接引入,而是在需要执行的时候,通过上面的
动态引入
功能,单独请求下来使用:
1
2
3
4
5
6
import ('../legacy' )
.then(({ SeaJS } ) => {
SeaJS.use('xxx' , function (XXX ) {
})
})
打包性能优化
webpack
默认会对整个项目引用到的文件进行编译、打包,其中还包括了
Nervjs
、
es5-polyfill
等基础的依赖库。这些文件从加入项目开始,基本都不会再有任何更改;然而在每次构建新版本时,webpack打包的这些基础库都会与上一版本有一些细微的区别,这会导致用户浏览器中对应的代码缓存失效。为此,我们考虑将这些基础库分开打包。
针对这种需求,
webpack
官方建议使用DLL插件来优化。DLL是
Dynamic Link Library
的简称,是windows系统中对于应用程序依赖的函数库的称呼。对于
webpack
,我们需要使用一个单独的
webpack
配置去生成DLL:
1
2
3
4
5
6
7
8
plugins: [
new webpack.DllPlugin({
path : path.join(__dirname, 'dist' , 'lib-manifest.json' ),
name : 'lib.dll.js'
})
]
接下来,在我们的项目的webpack配置中引用
DllReferencdPlugin
,传入上面生成的json文件:
1
2
3
4
5
6
7
8
plugins: [
new webpack.DllReferencePlugin({
context : __dirname,
manifest : require ('./dist/lib-manifest.json' )
})
]
这样就完成了动态链接库的生成和引用。除了最开始的一次编译,后续开发中如果基础库没有变动,DLL就再也不需要重新编译,这也就解决了上面的代码变动的问题。
体验优化探索
兼容IE8
兼容旧版本IE浏览器一直是前端开发人员心中永远的痛。过去,我们使用
jQuery
去统一不同浏览器的DOM操作和绑定事件,通过jQuery元素实例的map、each等类数组函数批量做JavaScript动画,等等。
但是在使用
Nerv
之后,从体系外直接操作DOM就显得很不优雅;更推荐的写法,是通过组件的ref属性来访问原生DOM。而map、each等函数,IE9+的浏览器也已经在
Array.prototype
下有了相应的实现。如果我们在代码中直接引入
jQuery
,这肯定是不科学的,这将使页面的脚本体积提高许多,同时还引入了很多我们根本用不上的多余功能。
面对这种情况,我们做了一个仅针对ie8的轻量级的兼容库es5-polyfill。它包括这些实现:Object的扩展函数、ES5对
Array.prototype
的扩充、标准的
addEventListener
和
removeEventListener
等。在入口文件顶部使用
require('es5-polyfill');
引入
es5-polyfill
后,只需3分钟,你就
会甘我一样,爱上这款框架
可以在代码中愉快地使用上面说到的那些IE8不支持的API了。
但是,通过上面的CMD方式引入不就意味着对于IE9+的用户都引入了这些代码吗?这并不符合我们“随用随取,避免浪费”的原则。我们更推荐的做法,是在
webpack
中为配置多个entry,再使用
HTMLWebpackPlugin
在HTML模板中为
es5-polyfill
输出一段针对IE8的条件注释。具体实现可以参考nerv-webpack-boilerplate。