你的网站或许不需要前端构建
大厂也好、培训班也罢,都针对 Webpack、Babel 、ESLint 前端工程工具三巨头贡献出了数不胜数分享和案例。
但是随之而来的是,前端项目几乎没有了往日的“简单愉快”,想用流行框架写一个项目,一般得先整一个脚手架,如果你写的程序没有“经历前端构建”,整的你都不好意思和同行打招呼。
这篇文章会以两个简单的例子来说明,即使不配置脚手架、使用一些“老家伙”一样可以开发出高性能的网站。
额外说明
本篇文章 并不完全适用 十几人乃至几十人以上团队规模的复杂、需要高密度的协作项目,仅针对中小型项目,诸如简单的后台、流程配置、甚至是 Demo。
碎碎念了这么多,让我们正式开始 回归愉快的前端开发 。
从一个简单的“单页”应用开始
不论是使用 React 、 Vue 还是使用更有年代感的 jQuery ,做一个简单的页面,不外乎分别完成 “页面结构”、“页面风格”、“页面功能” 三个部分的编写。
我们使用现在比较流行的 Vue 举个例子:
简单的页面 <!-- --> body{color:#2c3e50}#header{height:50px;background:#fff;border-bottom:1px solid #eceef1}#header-nav{float:left;height:50px}#header-search{float:right;width:180px;margin:4px}#header-button{float:right;height:50px;overflow:hidden;line-height:50px}#has-team-news{top:-7px;left:-3px}.logo{width:120px;height:100%;line-height:50px;font-weight:bold;background:rgba(255,255,255,.2);float:left}#left-menu{margin-top:10px}#left-menu-wrap{padding-left:10px;margin-left:10px}#top-switch{margin-top:10px;overflow:hidden}#top-switch-2{float:right;overflow:hidden;width:100px;height:20px;line-height:20px;margin-top:10px}#top-switch-2 a{font-size:12px}#top-switch-2 a.grey{color:gray}#top-divider{margin:10px}#post-container{margin:10px}.slick-slide{text-align:center;height:160px;line-height:160px;background:#364d79;overflow:hidden}.slick-slide h3{color:#fff}#carousel{margin:10px}.demo-loadmore-list{min-height:350px}.post-meta{display:inline-block;font-size:13px;line-height:13px;height:13px;overflow:hidden;font-style:italic;margin-right:4px}.desc{margin:14px 0;font-size:16px}#tag-list .ant-tag{margin-bottom:8px}.item-people{margin:10px 0}#ranking .ant-tabs-top-bar{margin-bottom:0}#car-list,#cars-list{border-top:none} Vue.use(antd); const posts = [[],[],[],[],[],[],] var app = new Vue({ el: '#app', data() { return { rootSubmenuKeys: ['sub0', 'sub1', 'sub2', 'sub3', 'sub4', 'sub5', 'sub6'], openKeys: ['sub0'], loading: true, loadingMore: false, showLoadingMore: true, postDataSource: posts, pagination: { onChange: (page) => { console.log('Change Page', page); }, pageSize: 3, }, } }, mounted() { this.getData((res) => { this.loading = false this.postDataSource = res }) }, methods: { onMenuOpenChange(openKeys) { const latestOpenKey = openKeys.find(key => this.openKeys.indexOf(key) === -1) if (this.rootSubmenuKeys.indexOf(latestOpenKey) === -1) { this.openKeys = openKeys } else { this.openKeys = latestOpenKey ? [latestOpenKey] : [] } }, handleButtonClick(e) { console.log('Button Clicked', e); }, handleTopMenuClick(e) { console.log('Top Menu Clicked', e); }, getData(callback) { setTimeout(() => { callback(posts) }, 300) }, onLoadMore() { this.loadingMore = true this.getData((res) => { this.postDataSource = this.postDataSource.concat(res) this.loadingMore = false this.$nextTick(() => { window.dispatchEvent(new Event('resize')) }) }) }, }, })
将上面的三百来行代码保存为 index.html
, 使用浏览器直接打开,不出意外你将看到下面的界面。
简单把玩之后,你一定会说,这个示例页面没有什么复杂交互,而且这不就是官方的 推荐用法之一 嘛。
是的,但希望你能够看到,像上面这样做一个样子还说的过去的页面, 真的不是必须把构建工具也“掺和”进来 ,即使你把组件交互的部分填充完毕。
而开发过程,就可以回归经典的“边改边刷新”,所见即所得了。
接下来,我们来聊聊如何将上面的程序拆分为模块使用,让多个页面之间可以复用模块,当然还是在不使用构建工具的前提下。
拆分功能模块
将单一职责的功能抽象模块化,可以说是工程师们的日常。这样做除了提高了可维护性、潜在的提升了页面性能、让软件构建更灵活之外、最大的收益便是增加了功能模块的可复用性。
我们日常使用 webpack 的时候,一定有看到过被分割为一堆名为 chunk
文件的脚本,或者名称可能叫做 vendor
、 app
、 component
的文件。这些便是构建程序帮我们切割的软件模块了,甚至是上面例子中引入的 *.min.js
. 也是如此。
如果我们不使用构建工具进行模块拆分,该怎么做呢?这里面常见的坑有哪些呢?
- 拆分为多个模块之后,会涉及到额外的网络资源获取和解析处理。
- 拆分为多个模块之后,可能会涉及到额外的模块依赖管理。
- 拆分为多个模块之后,会涉及到数据、状态同步管理。
想要解决前两个问题,可以通过使用 Require.js
之类的资源加载器,来控制拆分后多出来的资源文件的加载和对模块进行依赖管理,想了解这个老家伙的细节,可以浏览它的 官方网站 。
而拆分后的模块,想要保持书写上的简单明快,这里选择使用 Vue 的 Component 语法进行模块保存,所以需要额外引入一个 模块解析器 ,原理很简单,通过 XHR
方法将资源获取后,使用正则将内容分别抽取为“样式”、“脚本”、“模版”,然后在合适的时机在浏览器环境执行。为了简化操作,我在 requirejs-vue 的基础上进行了删减,有兴趣可以围观 源码 。
至于第三个问题,不论是使用单例共享数据源、亦或者使用发布订阅模式传递数据、或者使用观察者模式都可以,解决的手段还有很多,就不扩展了,本文暂且略过,你可以挑一个你觉得顺手的使用。
以上面的单页程序为例,我们先编写页面框架。
将模版分离 Vue.use(antd); requirejs && requirejs.config({ baseUrl: './assets', paths: { 'vue': 'common/require-vue' }, config: { 'vue': { 'css': 'inject', 'templateVar': '__template__' } } });requirejs([ 'vue!template/header.html', 'vue!template/footer.html', 'vue!template/navbar.html', 'vue!template/sidebar.html', 'vue!template/carousel.html', 'vue!template/feed.html', ], function (header, footer, navbar, sidebar, carousel, feed) { var appInst = new Vue({ el: '#app' }); var headerInst = new Vue({ el: '#header' }); header.$mount(); headerInst.$el.appendChild(header.$el); var footerInst = new Vue({ el: '#footer' }); footer.$mount(); footerInst.$el.appendChild(footer.$el); var navbarInst = new Vue({ el: '#navbar' }); navbar.$mount(); navbarInst.$el.appendChild(navbar.$el); var sidebarInst = new Vue({ el: '#sidebar' }); sidebar.$mount(); sidebarInst.$el.appendChild(sidebar.$el); var mainInst = new Vue({ el: '#main' }); carousel.$mount(); mainInst.$el.appendChild(carousel.$el); feed.$mount(); mainInst.$el.appendChild(feed.$el); });
相比较上一小节三百来行混杂了细节逻辑的代码,这个长度只有不到一百行的代码是不是逻辑清晰许多呢。
刚刚提到了模块复用,其实也很简单,比如我们想实现一个“列表页面”,可以这么写:
复用模块的例子 Vue.use(antd); requirejs && requirejs.config({ baseUrl: './assets', paths: { 'vue': 'common/require-vue' }, config: { 'vue': { 'css': 'inject', 'templateVar': '__template__' } } });requirejs([ 'vue!template/header.html', 'vue!template/footer.html', 'vue!template/list.html', ], function (header, footer, submit) { var appInst = new Vue({ el: '#app' }); var headerInst = new Vue({ el: '#header' }); header.$mount(); headerInst.$el.appendChild(header.$el); var footerInst = new Vue({ el: '#footer' }); footer.$mount(); footerInst.$el.appendChild(footer.$el); var mainInst = new Vue({ el: '#main' }); submit.$mount(); mainInst.$el.appendChild(submit.$el); });
聊完页面框架后,我们接着来看看拆分出的模块怎么写,以一个简单的 header
模块举例:
define([], function() { return new Vue({ template: __template__, data() { return {} }, mounted() {}, methods: {}, }) }); #header{height:50px;background:#fff;border-bottom:1px solid #eceef1}#header-nav{float:left;height:50px}#header-search{float:right;width:180px;margin:4px}#header-button{float:right;height:50px;overflow:hidden;line-height:50px}#has-team-news{top:-7px;left:-3px}.logo{width:120px;height:100%;line-height:50px;font-weight:bold;background:rgba(255, 255, 255, .2);float:left}博客Logo摆放首页 团队 标签
可以看到,这完全就是普通的 Vue 组件模版嘛。
其他模块的代码可以在 这里找到 ,拆分方式大同小异,在此就不进行赘述。
和上一小节不同的是,因为我们使用了 XHR
的方式获取资源,所以使用浏览器直接打开 HTML 页面的方法来预览效果,会得到类似下面的报错而无法得到想要的结果。
Access to XMLHttpRequest at 'file:///Users/soulteary/You-Dont-Need-Webpack/src/assets/template/header.html' from origin 'null' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: http, data, chrome, chrome-extension, https.
这里解决的方法很简单,将页面扔到一个能够提供 HTTP 服务的程序里就好了,可以使用 Node(HTTP Server、Express、KOA)等方案、也可以使用 Apache、Nginx、Caddy… 选择你顺手的工具就好啦。
在 GitHub 仓库中,我提供了一个 docker-compose.yml
编排文件,如果你本地有安装 Docker
的话,只需要 Clone 下来项目,接着执行 docker-compose up
,打开 localhost:10240/split.html
就能看到预览结果了。
体验增强
如果你想获取和 Webpack 实时刷新页面的开发体验,可以考虑全局安装 browsersync
这个工具,除了根据文件是否修改来刷新页面之外,这个老家伙还能同步不同设备上,当前调试页面的滚动、点击事件等交互操作。介绍这个工具的具体细节,不在本文范畴,有兴趣的小伙伴可以访问它的官方网站: https://www.browsersync.io/ 。
在本例中,我们将模块拆分为多个 .html
文件,虽然请求数多了,无法像传统脚本、样式资源一样享受服务端 combo 的能力。
但是因为使用了 HTML、又没有经过构建压缩混淆,配合 CMS 实时更新一些配置,改变页面功能反而变得更容易进行操作了。毕竟上线后毋需构建发版。(可以了解淘宝 TMS 模块化方案)
另外,如果实在对请求数敏感,可以针对模块加载器进行优化,实现类似 lsloader
之类的本地强缓存+资源版本管理的功能,减少请求获取。不过已经 2019 年,这点请求数对于多路复用的 HTTP2,随处可见的大带宽完全不是问题。
即使不使用 HTTPS(HTTP2)的方式打开页面,进行模块化拆分的页面首屏体验也优于未拆分页面。
未进行模块化拆分的页面刷新后,会明显出现页面白屏抖动。
而拆分模块的页面,展示则会“顺滑”许多,当然如果你追求极致,还可以添加骨架屏。
如果上面的动图还不够清楚的话,可以看两种情况下的性能测试。
未拆分页面首帧虽然快,但是随着业务脚本的复杂, Evaluate Script
的时间也会越来越长,导致 DOM Content Loaded
被无限滞后,在用户体验上会带来卡顿感。
而进行拆分了页面模块拆分后, DOM Content Loaded
时机被极大的提前,虽说整体脚本复杂度不变,但是单一模块复杂度变低,伴随 DCL
时间提前,模块脚本的解析完毕时间也提前了。
最后
再次重申,本篇文章不是说我们开发项目不进行脚手架配置、完全不使用 Webpack 等前端优秀工具。
重点是在拥有搭建开发环境的能力后,在适合的场景下,我们应该适当灵活变通,使用更简单轻快的方案进行开发,腾出配置环境、安装模块的时间去做更有意思的事情。
本文示例中的界面,参考了 https://love2.io/
、 掘金
的设计,感谢设计师们的辛苦付出。
—EOF