浅探前端组件化

4,765 阅读16分钟

背景:

UI演变

首先我们先来回顾下历史,在70年代,诞生了著名的MVC架构。

继MVC之后,经过了十几年的发展,到了90年代,有一个公司的CTO,叫Mike,他在MVC的基础上,提出来了MVP.

又到了2005年,微软的一个架构师,提出了MVVM模式。转换2014年左右的时候,出现了FLUX,这个是Facebook为了它的JSX和React提出的一种模式。

后来隔了短短的一年,2015年,同样是在React社区,出现了REDUX。

对于前端来说,我们为用户创造价值才是特别需要关注的一个问题,这么多年过去了,前端到底为用户创造了什么价值呢?

这是 70 年代,施乐公司做的一个软件管理的流程图软件,那个时代,整个的界面就是这个样子,施乐已经算比较先进的了。

再到 90 年代,当时这个画面还是很惊艳,按钮键是立体的。现在来看这个东西就有不那么美观了。

2006 年左右的时候,Vista 的界面已经开始有了一个非常大的变化了,这时已经是设计师在主导这个界面的了,但是性能并不佳。

再之后,手机出现了,比如 iPhone 的界面,这时不但交互模式发生了巨大的改变,而且屏幕也变了,甚至我们熟悉的鼠标不见了,变成了触屏。虽然两者之间操作上有一定的相似,但是变化还是非常大的。

视图

计算机的功能也在演变。70 年代,计算机主要用来计算。

我们今天计算机主要用来上网,基本上,大家的计算机都是 24 小时联网的,你的手机也是 24 小时联网的,所以计算机的职责在发生变化。

这个变化对于 UI 有很大的影响,1970 年的那个 MVC 那篇论文里的图,model 很大,view 很小,而到了 2018 年,今天我们很多的 model,都是放在服务端的,而今天 model 的大小已经不是说一台机器上能去存的,你存在本地的只是视图展现一点点的 model,这个是很小的一部分的东西。而这部分,对应的可能是不同终端的view,view变得越来越大了。

问题:

视图变的越来越重要,内容越来越丰富,技术上越来越复杂。一个页面内,可能有导航头,轮播图,滚动等动画,各种手势交互,信息流等与后端的交互。

通常我们会根据天然的职责划分把交互都写在代JS文件里,布局都写在HTML里,样式写在CSS里面,当目标页面内容很少的时候,看起来没什么问题,但是当目标页面变得足够复杂,我们就会发现,JS里面的逻辑代码越来越多,如果一个手势交互,要同时触发页面的加载,滚动动画还有导航变动,那么开发者,要处理的情况就会很复杂。这个时候,如果另一个人来接手这个项目,或者又要加上一个新的交互去触发更多的联动。就会非常痛苦。

这是因为很多时候,在项目开始时需求不多,所以就将一个页面做成了整块应用,而后随着业务的增长或者变更,项目的复杂富会呈现指数级的增长,等项目足够复杂后,就会发生一个小小的改动或者一个功能的增加可能会引起整体逻辑的修改,造成牵一发而动全身的状况。

目标:

那么如何解决这种状况:

其实业界早就有了一些探索,如果一个大且复杂的场景能够被分解成几个小的部分,这些小的部分彼此之间互不干扰,可以单独开发,单独维护,而且他们之间可以随意的进行组合。就拿电脑主机来说,一台整机包括CPU,主板,内存,硬盘等等,而这些部件其实都是由不同的公司进行生产的,他们彼此之间根据一套标准分别生产,最后组装在一起。当某个部件出现问题时,不需要将整台主机都进行维修,只需要将坏的部件拿下来,维修之后再将其组合上就可以了。这种化繁为简的思想,在前端开发中 就是: 组件化,后端就是微服务。

随着React,Vue,angular等以组件(指令等)为主的优秀前端框架的出现后,前端组件化逐渐成为前端开发的迫切需求,当然这一迫切需求也逐渐成为一种主流,它不仅提高了前端的开发效率,同时也降低了维护成本。开发者们不需要再面对一堆复杂且难阅读的代码,转而只需要关注以组件方式存在的代码片段。

作为前端开发者,如何面对层出不穷的前端框架?就需要我们善用抽象,找出他们共同点和基本特征,了解其根本,下面是我对组件化的一些理解。

那什么是组件化呢?如何做组件?

组件化

我查找了一些资料,发现组件化并没有一个明确的定义,但是往往提到组件化,都会伴随着这样一句话,那就是:对内高内聚,对外低耦合

对内各个元素彼此紧密结合、相互依赖。

对外和其他组件的联系最少且接口简单。可复用,可组合。

这看起来像是作组件的时候要遵循的一个规范或者标准。就像是我们要把组件作成这个样子的。

组件也是对象

对象我们在熟悉不过了,对象是我们对事物的一种抽象,其基本特征是:属性(properties),方法(menthod)和继承(inherit)。

组件其实是一种专门用来描述UI结构的对象,当然也具备对象的特征,属性(properties),方法(menthod)和继承(inherit),但是又因为组件是要描述UI的,所以组件本身也有自己的一些特征:attribute(特征),外部调用注册回调函数事件接口Event,初始化的时候是否需要传入config,组件自己的状态State和Lisfecycle。总结起来就是:

组件的特征:properties:属性、methods、inherit、attribute:特征:特性、config&&state、Event、Lisfecycle、children、

下面我们来了解下这些特征:

Attribute vs Property

Attribute 强调描述性,像是发型很帅。

Property 强调从属关系,单词原意:所有物,财产。

很多翻译会把Attribute和Property都译为,属性,下面有三个例子,来分析下Attribute和Property的行为。

<div class="cls1 cls2"></div> 
<script> 
var div =  document.getElementByTagName(‘div’);  div.className // cls1 cls2 
</script> 

第一个一样:名字不一样,attribute是class, property是className。

<a href="//m.taobao.com" ></div> 
<script>
var a = document.getElementByTagName('a’); 
a.href // “http://m.taobao.com”,这个 URL 是 resolve 过的结果 a.getAttribute(‘href’) // “//m.taobao.com”,跟 HTML 代码中完全一致 
</script> 

第二个不一样:值不一样。

<input value = "cute" />
<script>
var input = document.getElementByTagName(‘input’); // 若 property 没有设置,则结果是 attribute 
input.value // cute 
input.getAttribute(‘value’); // cute 
input.value = ‘hello’; // 若 value 属性已经设置,则 attribute 不变,property 变化,元素上实际的效果是 property 优先 
input.value // hello 
input.getAttribute(‘value’); // cute 
</script> 

第三个不一样:行为不一样,若property没有设值则结果是attribute,若 value 属性已经设置,则 attribute 不变,property 变化,元素上实际的效果是 property 优先,看起来像是attibute一次性的,单向的同步property。

这三个例子是想告诉我们:attribute和property不紧有区别,还完全是两种东西。 从html的角度看不一样是常态,一样是特殊。

但是在React里面两者是完全一直的。React在实现过程中,把attrbuie和property的行为表现,实现的完全一直,没有区分。

一个数据应该存在组件的那个特征上。

这里定义了4个场景和组件的4个特征:

  1. 通过Markup去设置
  2. 通过JavaScript设置
  3. 通过JavaScript修改
  4. 用户输入修改

property:是不能通过Markup设置的,JS可以设置,可以修改,用户输入修改也可以不修改。拿html举例:input的value可以修改,nodeName等不可以修改。

attrubite: Markup、JS都可以设置,JS可以修改,用户输入可以修改也可以不修改,根据组件设计的不同,可能跟property有所区别。

state:只能由用户的输入改变,用户的输入也包括时间,标签语言不可以改状态,JS语言也不可以(这里的JS是值调用组件的JS),所以state对组件来说是一个非常安全的东西。如果把所有东西都放到state上,外部就完全定制不了这个组件,暴露出来的越多,需要写的代码逻辑越复杂。

config:一般情况下config只允许js设置一次,几乎没有听说过,构建函数传的config可以修改吧。有很多组件会把property和config 设置成同名的,也是一次性同步关系。

那么如果一个数据进来,我们到底应该把它存储在哪里呢?比如 position这个东西,如果把它同时给这四个值都都负值。可不可以呢?当然也是可以的,这样当用书输入的时候,就很方便,但是维护代码的人就要写更多的代码去适配这个场景。在设计上更多的是一种取舍。没有对错,只能说某方面好或者不好。

除了存储外,组件还有一个很重要的部分是Lifecycle

说到生命周期,其实就是组件的 constructor 和 destroy了。

constructor -> destroyed,一般来说组件的初始就是constructor每个组件初始化所必须调用的函数,销毁就是destroy了,一般组件内部是调用不到的,偶尔我们会看到bedore/will destroyed的方法来在组件销毁前执行某些任务。

接下来我们来思考下:在初始和销毁之间都会发生什么呢?

大致会发生三件事

  1. 挂载上去,显示到UI上。
  2. JS代码修改。
  3. 用户输入修改。

根据这三件事情,可以得知,组件创建出来需要挂载到一颗树上,它才能能够显示,那么一定有mount,有挂载那么也会有unmount,unmount是把组件从树上摘下来。JS代码会发生修改,会使得组件发生update/render,用户输入也一样。

用过React框架的同学应该都会使用过 WillMount/DidMount方法,一个发生在挂载前,一个发生在mount后,其实不论是React或者Vue或者再出现一个新的框架,从逻辑上来分析,生命周期应该差不多都有这些,所以一旦理解了组件生命周期这个事情,就算再来一个新框架,你也不会觉得它有多意外,当然如果一个组件造出了一个新的生命周期,也不用太意外,无非就是两种情况,要不就是它造错了,我们可以给它提一个bug,要不就是我们的分析不够严谨,那么这也是好事,我们可以完善自己的知识体系。

面对这些层出不穷的新框架,我们需要学习它们本质的内容和背后的逻辑,像那些在社区非常流行的框架(或者看似愚蠢的设计),背后都是有很多Trade-off的,比如React,用一个render打天下,即使改了一个小文字,它也要把render全部执行一遍。那么这些render带来的开销如何处理呢?react设计了一个virtual dom的机制,它的原理就是,虽然在js代码里把这些地方全更新了,但是实际上在native的层面只更新了那一个,因为它在中间做了diff。理解了这个地方后,就会发现,其实react的virtual dom 和 组件的生命周期是绑定在一起的。所以使用react的时候,很少有人会去监听willupdate或是didupdate。因为只需要把render写好(因为render既能拿到state又能拿到property/attribute),就可以很好的更新组件,所以它在组件生命周期这里,作到了简化,写起来很简单,但是它的代价就是写了一个巨大的虚拟dom系统。

Event

Event 一般是由组件通知JS代码的时候使用的一种机制,表现上一般来看是通过onClick或者onEvent,JS代码先注册上回调函数,等组件内部触发的时候执行。

Children

根据组件的使用场景,需要接手的children也不相同,可以分为Content 型 Children 与 Template 型 Children Content型。

<component-form>
    <input /> //children
</component-form>

content型,组件会把外部传入的元素或者组件接当成组件的children来处理。

<component-carousel data={['src1', 'src2']} >
    <div>  <img src={''} /> </div>
</component-carousel>

Template型,组件内部定义好了template,接受外部传入参数,来创建children。

组件内部,要根部不同类型的children作不同处理,这就需要设计者,根据组件的使用场景等因素来考量,children的模式是什么,因为这会影响组件的实现。

看下React对组件的实现

import React from 'react'

export default class Demo extends React.Component{
    constructor(props){
        super(props);
        this.state = {
            name: 'demo'
        }
    }
    componentDidMount(){
    }
    componentWillReceiveProps(){
    }
    componentWillUnmount(){
    }
    render(){
        const = { text } = this.props;
        return(
            <div>{text}</div>
            )
    }
}

/* html */
<Demo text="componet" />
<p>这是一个p</p>


首先,我们需要创建一个Demo组件类去继承React.Component来创建组件。 其次,需要写一个render函数返回组件核心的UI元素,可以看到其对生命周期的Trade-off。 最后,使用Matkup Code的方式来调用组件。

React很好的实现了组件的生命周期,config,state, prop集合了attribute和property集一身,children等等的基本特征,并且引入JSX来实现Markup Code的方式调用组件。用户可以很方便的定制自己碎片化的组件需求。当然你也可以自己设置一套组件的实现。这只是个参考。

接下来看一下Web Component:


<!DOCTYPE html>
<html>
<body>
    <!--
            一:定义模板
            二:定义内部CSS样式
            三:定义JavaScript行为
    -->
    <template id="compont-t">
        <style>
            p {
                background-color: brown;
                color: cornsilk
            }
            div {
                width: 200px;
                background-color: bisque;
                border: 3px solid chocolate;
                border-radius: 10px;
            }
        </style>
        <div>
            <p>component</p>
        </div>
        <script>
            function foo() {
                console.log('inner log')
            }
        </script>
    </template>
    <script>
        class Demo extends HTMLElement {
            constructor() {
                super()
                //获取组件模板
                const content = document.querySelector('#compont-t').content
                //创建影子DOM节点
                const shadowDOM = this.attachShadow({ mode: 'open' })
                //将模板添加到影子DOM上
                shadowDOM.appendChild(content.cloneNode(true))
            }
        }
        customElements.define('compoentd-emo', Demo)
    </script>
    <compoentd-emo></compoentd-emo>
    <div>
        <p>这是一个测试!</p>
    </div>
    <compoentd-emo></compoentd-emo>
</body>
</html>

首先,使用 template 属性来创建模板, 其次,我们需要创建一个 Demo的类。在该类的构造函数中要完成三件事:

  1. 查找模板内容;
  2. 创建影子 DOM;
  3. 再将模板添加到影子 DOM 上。 最后,也是像Markup一样的方式来调用组件。

影子 DOM 的作用主要有以下两点:

  1. 影子 DOM 中的元素对于整个网页是不可见的;
  2. 影子 DOM 的 CSS 不会影响到整个网页的 CSSOM,影子 DOM 内部的 CSS 只对内部的元素起作用。

WebCompoent,这套体系带给我们最大的好处就是 引入影子DOM的机制,真正意义上实现了,组件内的模版与CSS样式 和 全局DOM的隔离封装。

这是因为DOM 和 CSSOM是全局的,一个可能发生的情况是 组件内的DOM或者CSS样式会 影响到组件外部。为了避免这种情况的发生,有了影子DOM这样的实现,这是一个非常契合组件化思想的实现。

一个组件化的页面

组件化以后,你的页面可能是这样的。

<!DOCTYPE html>
<html lang="en">
<head>
    <title>Document</title>
</head>
<body>
    <Component1 />
    <Component2 />
    ....
    <ComponentN />
</body>
</html>

前端组件化的项目

组件化以后你的项目可能是这个样子的。

项目内部对应多个页面,页面内部对应多个组件,组件内部又可能对应了多个不同的库。

总结

首先了解了前端的发展历史,随着前端的发展,视图变的越发庞大,伴随而来的就是页面的内容越来越多,复杂度越来越高,开发者面临着,要为页面编写越来越多的代码,往往面临着,难以维护,代码不可复用,应对需求繁琐问题。

为了解决开发效率低,维护成本高的问题,引入前端组件化的思想。 我们希望一个大且复杂的场景能够被分解成几个小的部分,这些小的部分彼此之间互不干扰,可以单独开发,单独维护,而且他们之间可以随意的进行组合

组件化的核心思想:对内高内聚,对外低耦合。对内各个元素彼此紧密结合、相互依赖。对外和其他组件的联系最少且接口简单,可复用,可组合。

遵循组件化的思想,我们抽象了组件的特征:properties:属性、methods、inherit、attribute:特征:特性、config&&state、Event、Lisfecycle、children以及他们的各自的含义或者职责。

最后了解了React 和 WebComponent在其实现各自的组件化时候,带来了哪些的思考和值得学习的优点,一个组件化的页面和一个组件化的项目大致的样子。