switer / switer.github.io

Personal homepage
https://switer.github.io
5 stars 0 forks source link

我们为什么需要组件化开发? #31

Closed switer closed 8 years ago

switer commented 8 years ago

前言

腾讯视频相关的前端项目,已逐步切换到组件化开发模式中,在业务也积累了丰富的实践经验,希望通过自身的理解与思考,向大家描绘前端组件化开发是怎么一回事。不是React,也不是Web Components,只是纯粹的组件化开发

JS的交互逻辑

HTML是一门标签语言,通过它,我们以一种声明式的方法去描述页面,配合上CSS做样式上的修饰,即可得到一个静态的Web页面,我们一般称之为重构稿

在前端开发流程中,通常,重构稿是由设计稿转换为线上产品这个流程中的一个上游步骤,在它之后,还需要使用JavaScript添加页面交互或者数据交互逻辑,该步骤我们暂且称之为JS施工。如果重构稿说一块平地,那么经过JS施工后,上面可能铺满了各种坑或标识,JS逻辑就是在交互事件触发时根据标识去填坑

回到真实的场景,那么就是前端开发所熟悉的JS交互逻辑:

初始化:(添加选择器) => (选中元素) => (绑定事件) 交互时:(选中元素) => (操作DOM)

流程逻辑看上去没什么不对,所有的步骤也是完成对应交互所必需的,会有什么问题吗?所以接下来我们以“两性”作为入口分析下。

前端工程的“两性”

HTML提供了页面元素与结构的描述,JS负责修饰操作,所以需要给定选择器用以JS逻辑执行时定位到对应的元素,可以说选择器就是对一段或多段JS逻辑的绑定

此类选择器代表了元素的名称,往往是没有语意的。在没有对逻辑进行抽象的前提下,是没法赋予选择器相对应的含意,也就不能简单通过选择器的名字来读懂背后的逻辑。

在逻辑绑定方面,如果选择器与逻辑是一对一的绑定,那是较为理想的状态,但是一对多的绑定,那就是场灾难。因为,多个逻辑由选择器作为约束而关联在一起了,好比关系型数据库中的一对多关系,例如下面这些代码片段:

HTML:

<div class="header"></div>
<div class="content"></div>

我们交互逻辑可能是这样子的(模块A):

产品:“这个头部要在下滑50像素后,固定显示在顶部”

var $header = $('.header')

setInterval(function () {
    window.scrollTop > 50
        ? $header.addClass('fixed')
        : $header.removeClass('fixed')
}, 100)

还是这样的(模块B):

产品:“点击头部要回到首屏”

$header.on('click', function () {
    window.scrollTo(0, 0)
})

甚至是这样的(模块C):

产品:“页首页脚的文案要配置下发”

var $content = $('.content')
$.get(url, function (data) {
    $header.html(data.head)
    $content.html(data.content)
})

和这样的(模块D):

产品:“点击查看内容时隐藏头部”

$content.on('click', function () {
    $header.hide()
})

最终:

产品:“优化版本把头部去掉,你注释掉就好了”

友谊的小船说翻就翻...

这时候研发需要把所有与 “header” 相关逻辑删掉,像A,B这样纯粹只有“header”相关的逻辑可能会简单点,找齐全部相关模块注释掉(可维护性与干涉的模块数量成反比),但对于C,D这样结合了其它模块逻辑的,就需要仔细看次具体逻辑注释掉,还要保证不影响其它逻辑(可维护性与干涉的逻辑数量成反比),给跪了。

所以,要想提高可维护性,需要从减少干涉模块减少干涉逻辑两方面入手来改善逻辑结构。

复用性是与开发效率休戚相关的,在开发一个前端项目的过程中,某些交互逻辑是可以被复用的,在被复用之前,我们需要完成抽象封装的工作。

在维护性的描述中提到了选择器的语意化,也就是要让选择器代表一段逻辑。如此一来,在开发的时候,只需要在HTML元素添加一个选择器标识,就可以复用一段逻辑。

以上面的模块A代码片段作为示例,现在我们要抽象一些逻辑:

A逻辑由两部分组成,

  1. 定时检查页面当前滚动的高度

    setInterval(function () {
       window.scrollTop > 50 // => state
    }, 100)
  2. 根据高度状态修改DOM的样式

    state
       ? $header.addClass('fixed')
       : $header.removeClass('fixed')  

可以认为逻辑1是非DOM操作相关的,也就是说不需要通过选择器来声明使用,我们将它称为状态逻辑。逻辑2直接表达了DOM操作的强烈意愿,我们可以将它抽象为一种DOM操作逻辑,然后通过HTML属性声明使用:

<div class="header" bindClass="fixed: state"></div>

背后绑定的实现逻辑流程:

var data = {
    state: false
}
function bindClass($el, expr) {
    var $el = $(el)
    var clazz = expr.split(':')[0]
    var key = expr.split(':')[1]

    // 返回一个更新方法
    return function () {
        var state = data[key]
        if (state) $el.addClass(clazz)
        else $el.removeClass(clazz)
    }
}
var $el = $('[bindClass]')
var update = bindClass(
    $el[0], 
    $el.attr('bindClass')
)
setInterval(function () {
    data.state = window.scrollTop > 50
    update()
}, 100)

在所有相同逻辑的DOM操作场景下,只需要通过在HTML上声明依赖数据,与及数据变更时触发更新,这两个简单的操作就完成了一个交互逻辑的复用。这样的逻辑表达方式我们称之为数据绑定,它就是一种DOM操作逻辑的声明式绑定。

数据绑定让“选择器”变得有语意,我们“举个栗子”:

<div bindStyle="position: state ? 'fixed':'relative'"></div>

对于上面的例子,我们可以清晰地读懂该选择器与表达式背后的含义。

还有,绑定声明所依赖的是一个数据,而数据逻辑也不依赖DOM,两者是解耦的,也就是说,我们可以轻松地注释掉HTML元素无需担心逻辑代码出错,反之,能随意迭代数据逻辑而不担心DOM结构。显然,从“减少干涉逻辑”指标上提高了可维护性

所以,我们可以说:

“数据绑定也提高了可维护性”

为什么需要组件化开发?

从上面的“两性”说明中,我们得到了好几个关于交互逻辑开发方式优化的指标:

数据绑定涉及到三个指标,但还有一个指标问题需要解决的,我们该怎样做,也就是如何减少交互逻辑所干涉的模块数量?

“减少干涉模块”那就需要分而治之,将属于你的任务交付给你,自己单独办法处理, 分治策略 Get√。

而具体点的行为就是:对相同HTML元素的交互逻辑放在相同的模块中,该模块的生命周期都是负责该HTML范围内的交互逻辑,这样的方式就是组件化的原型。

业界对于组件化的定义是比较含糊的,没有唯一标准,你可以认为Web Components 是一种组件化方式,React也是一种,又如我们团队所用的框架Real ,也是一种组件化方式。总的来说,组件化就是按照某种规范对相关交互逻辑的封装。

对于组件化,我们就这样简单明确地理解:

一个组件负责一块HTML,对于该HTML的处理逻辑只存在该组件的JS模块中。(CSS与HTML共存

  • 减少干涉模块 √

最终,组件的成分是:HTML模板与交互逻辑代码,在运行时环境就是:DOM元素与组件的JS实例。

组件化的实例

以上大篇幅介绍了组件化/数据绑定的原理与概念,但前端在开发的时候,直接打交道的是具体的JS库或者框架,是各种具体的接口方法,所以下文会以一个框架为例介绍组件化开发在应用层面的表现。

作为例子的框架:Real ,是我们团队在组件化开发模式的主力框架,gzip后体积 8kb ,还有一个不值一提的长处:兼容IE6+

诞生背景是为了解决服务端渲染场景下的交互逻辑组件化问题,为此,它有部分接口是为了解决在前端运行时的组件反射问题。

Real只运行于前端渲染,后端渲染而由它的兄弟Comps负责,两者结合使用追求的是前后端重用而不是同构

所谓的组件反射:后端模板如果是按照组件组合方式吐出,那么前端运行的时候也是保持一致的,那么框架需要将静态的HTML片段转换为具体组件实例,转换的过程就是反射。

对于前端开发来说,需要关注的是组件定义使用交互,那么对应着组件化框架来看,就是构造实例更新

数据,模板,生命周期是组件的三大要素,在Real中,对应的就是 data, template, created/ready/destroy 这三类构造选项。

Real有三种构造组件的方法,每种的适用场景不一样:

Real.create( ) 与 Real.component( ) 的区别在于是否能被声明式使用,在接下来的实例的话题中会有描述。

下面示例使用Real构造一个具名的组件:

var Header = Real.component('header', {
    data: function () {
        return {
            fixed: false
        }
    },

    template: '<div class="header" \
                    r-class="{fixed: fixed}"\
                ></div>',

    ready: function () {
        assert(this.$el.className, 'header')
    }
})

示例用于前端渲染,在运行时能访问实例对应根元素(this.$el),构造出来的组件是天然多例化的,只负责实例对应的DOM元素。

r-*的属性标识是Real的数据绑定声明,如示例中的r-class 是Real的绑定class的声明。对应前文中的bindClass

Real用许多通用的内置的绑定声明,与Angular一样,绑定声明称为: directive,也可以通过Real.directive( )方法自定义directive

实例

任何的组件都可以被其它组件使用,Real提供了两种不同的使用方式:

var Header = require('header')

var $header = new Header({
    data: { ... },
    methods: { ... }
})
container.appendChild($header.$el)

通过 data 传递数据给子组件(header); 通过 methods 传递回调方法给子组件; 获取组件实例根元素,添加到父容器中。

<div class="parent">
    <div r-component="header"
        r-data="{
            fixed: offsetTop > 50; 
        }"
        r-methods="{
            onClick: onClickHeader;
        }"
        r-ref="header"
    ></div>
</div>

声明式的使用方式接口是与命令式保持一致的,区别在于:

  1. r-data 会自动建立与父组件的绑定,也就是说父组件数据更新会出发子组件的更新

    出于优化需求,可以通过 r-binding=false 来解除绑定,或者 shouldUpdate( ) 选项方法自定义更新判断逻辑

  2. 组件的元素会在当前声明位置自动插入到父组件的DOM元素中
  3. r-ref 获取该组件实例的引用

    this.$refs.header // => $header

r-data/r-methods 是directive的一种键值对格式,左值是key, 右值可以是任何JS表达式,包括使用function,多个值以分号 “;” 分隔。 directive 还有另外一种表达式格式:

r-show="{ items.some(function (i) { return i }) }"

声明的属性值包括在花括号里就会当成JS语句来执行。

更新

Real有三个触发更新的方法:

Real在触发更新时会执行模版中声明的所有表达式,如果值有变更,就会触发该绑定的更新方法。可以认为表达式是轻量的(避免在模板表达式中写过多逻辑),所以每次更新执行并不影响性能。

One More Thing

这里要介绍的东西是Real关于后端渲染的一些方法。

后端渲染模式吐出的是HTML,有些场景下在前端的执行环境是需要后端的数据的,Real也提供了注入组件数据的接口:

<div r-component="header" 
    r-props="{ title: '<%= title %>' }"
>
    <button r-show="{ title }">{title}</button>
</div>

<%= title %> 是后端使用的模板语法,也可能是:ejs, handlebars, art-template...,

通过 r-props 注入数据,在组件实例中可以直接通过 this.$data获取到,这样的接口对于获取后端的初始数据是至关重要的。

关于Real还有一样东西是需要一提的:动态编译。 在后端渲染模式下,会出现组件的子模版只能在前端渲染,例如:一个长列表加载更多的交互逻辑。

使用Real的处理方式:

var vm = this
var tpl = require('list.tpl')

$.ajax(url, function (results) {
    vm.$data.list.concat(results)
    var el = vm.$compile(tpl)
    vm.$el.appendChild(el)
})

就如上面示例,使用 $compile 实例方法可以在异步数据回来后,再编译子模板并渲染添加到父组件中。