头条PC站基于RIOT的组件化开发实践

Riotjs是一种小而美的js框架

背景

1、头条PC站业务前端重构

* 旧站架构强依赖后端模板,维护和扩展非常不灵活
* 资源文件存在依赖,无法做到最简压缩合并
* 代码组织混乱,虽有基本的模块化开发方式,但整体维护和扩展非常麻烦

2、为什么选择Riot?

* Angularjs学习成本相对比较高、大而全(比较重),后期升级维护不便
* Reactjs有一定学习成本,而且其JSX语法对于习惯模板、样式和行为分离开发方式的coder不太能接受
* VUE简单易用,满足大部分需求,但不兼容IE8

Riotjs是一种小而美的js框架,2.2.4稳定版本兼容IE8。采用该框架在头条pc站进行了组件化开发方式的实践,有效地提高了开发效率和扩展能力,并大大降低了维护成本

RIOT简介

Riot是一个类似React的微型UI库

jQuery框架虽然给操作DOM提供了巨大的便利,但是业务比较复杂的时候,页面逻辑代码充斥着$符,直接维护DOM与数据之间的关系是非常麻烦的。React等框架的兴起,让数据驱动成为很多前端开发者关注的焦点。利用这些框架,我们只需要控制和维护数据的逻辑,框架就会自动帮我们做好数据到样式呈现上的同步,代码编写和维护变得相当简单

RiotReact一样提供的是V层解决方案。Riot非常轻量(v2.6.2版本Gzip压缩后只有9.35KB),非常容易与其他类库结合来提升整个项目的开发效率

Riot小而美,语法简单,API数量少,开发人员可迅速上手。其提供的自定义标签可以使代码具有组件特性,整个应用维护与阅读都非常友好

提供工具,而不是策略!Riot尽量不使用强制的规则,而是提供最基本的工具,希望你能够有创意地使用它们。这种灵活的方式将应用层面的大的架构决策交还给开发者

更多riot相关可查看官方文档

Hello Riot

盗用官方的基础事例,自定义一个简单的sample标签。riot自定义模板标签语法特别简单,可以在官网上尝试用一下,看看展现效果

自定义标签代码,保存为sample.tag

<sample>  
  <h3>{ message }</h3>
  <ul>
    <li each={ techs }>{ name }</li>
  </ul>

  // 标签业务逻辑
  <script>
    this.message = 'Hello, Riot!'
    this.techs = [
      { name: 'HTML' },
      { name: 'JavaScript' },
      { name: 'CSS' }
    ]
  </script>

  // 标签基本样式
  <style scoped>
    :scope { font-size: 2rem }
    h3 { color: #444 }
    ul { color: #999 }
  </style>
</sample>  

创建页面index.html使用该标签

<html>  
  <head>
    <title>Hello Riot.</title>
  </head>
  <body>
    <!-- 在body中任何位置放置自定义标签 -->
    <sample></sample>

    <!-- 另一种写法 -->
    <div riot-tag="sample"></div>

    <!-- 包含标签定义 -->
    <script type="riot/tag" src="sample.tag"></script>

    <!-- 包含 riot.js+compiler.js -->
    <script src="https://cdn.jsdelivr.net/riot/2.3/riot+compiler.min.js"></script>

    <!-- 加载标签实例 -->
    <script>
        riot.mount('sample');
    </script>
  </body>
</html>  

平时,在使用riot编码的时候一般使用官方提供的工具实时编译.tag文件到可执行.js文件,然后直接引入.js文件到页面,如上面的sample自定义标签,编译后代码如下:

sample.js

riot.tag('sample', '<h3>{ message }</h3> <ul> <li each="{ techs }">{ name }</li> </ul>', 'sample, [riot-tag="sample"]{ font-size: 2rem } sample h3, [riot-tag="sample"] h3{ color: #444 } sample ul, [riot-tag="sample"] ul{ color: #999 }', 'class="sample"', function(opts) {  
    this.message = 'Hello, Riot!'
    this.techs = [
      { name: 'HTML' },
      { name: 'JavaScript' },
      { name: 'CSS' }
    ]
});

基本执行原理

由上小节可知,一个riot自定义标签在日常开发中从源码到呈现在页面上主要分为三步:编译(一般利用官方自带编译工具)、注册(riot.tag())和加载(riot.mount()),如下图所示:

image

编译

编译阶段的主要工作就是将riot语法写的.tag文件转换为可执行的.js文件,如上面提到的sample.js

注册

注册阶段的主要工作就是将该自定义标签以某种方式存储到一个全局对象中__tagImpl,2.6.2版本注册功能的核心代码如下,一个自定义标签按名称name、模板tmpl、属性attrs和初始化函数fn存储

__tagImpl[name] = { name: name, tmpl: html, attrs: attrs, fn: fn }  

加载

自定义标签加载到页面上主要是利用riot提供的riot.mount()接口,该接口会依据传入参数生成指定的自定义标签实例(框架内部专门定义了一个Tag类),剩下的工作交由Tag实例处理,该类主要做了以下几个工作:

  • 实例继承事件接口(通过riot.observable模块赋予on, off, one, trigger方法),用于生命周期等事件上面的管理
  • 建立与父元素的关联,单项数据流管理
  • 提取并保存模板上的表达式到数组expressions,数据到模板的更新就是对比该数组中元素的新旧值
  • 创建一个空的DOM元素作为容器,暂时存放内部元素(dom = mkdom(impl.tmpl, innerHTML)),所有操作会先对该容器进行解析处理,最后同步到真正的页面模板
  • 定义对象的updatemixinmountunmount方法

更新原理

riot框架数据到模板的更新机制有点类似angular的脏检查,但riot实现的更简单,而且只有一轮检查(单项数据流),具体更新原理如下:

  • 遍历和该元素相关的表达式数组expressions
  • 对比表达式的值,如果无变更,则不做处理
  • 根据表达式关联的dom和其类型(元素属性,文本等),更新DOM元素

riot里面的更新主要由dom事件和主动调用update()两种方式触发,并不像很多其他框架能够做到实时的更新处理,毕竟riot比较小,功能上不够完善,但实时的更新又会带来一定性能上面的问题

上面简单介绍了riot框架总体的一个执行原理,具体细节可以自己打个断点看一下,源码还是清晰易读的

组件化实践

前端组件化拆分的根本目标是分而治之的开发维护方式,复用只是第二位的需求(非常认同fis作者张云龙的这句话)

为什么要组件化

  • 页面/系统功能以组件为单位划分,页面模块化
  • 分而治之,功能单位分的越精细,后期维护扩展越方便
  • 团队协作成员可独立并行开发调试组件,减少时序上的相互依赖
  • 组件插拔简单,可快速安装、及时卸载
  • 新人介入项目开发,可从点到面逐渐深入,快速融入项目开发

组件化理念

以下理念来自前端工程-基础篇,这些理念非常实用,且目前业界很多团队基本都按照这样的理念思路进行前端页面的组件化开发

  1. 页面上的每个独立的可视/可交互区域视为一个组件
  2. 每个组件对应一个工程目录,组件所需的各种资源都在这个目录下就近维护
  3. 由于组件具有独立性,因此组件与组件之间可以自由组合
  4. 页面只不过是组件的容器,负责组合组件形成功能完整的界面
  5. 当不需要某个组件,或者想要替换组件时,可以整个目录删除/替换

除了以上几点,我认为一个组件还需要有自身的一套生命周期管理策略以便开发者对组件有更好的控制,还需要有一种低耦合的方式与其他独立组件进行通信。

基于riot的组件化

遵循上诉的组件化理念,结合riot框架和头条前端自有的技术栈,头条pc站围绕组件化进行了一次架构上的重构

脚手架组织

- project -------------------------- 工程名
    - mock ------------------------- 模拟数据
        + html --------------------- 同步数据
        + ajax --------------------- 异步数据
    - page ------------------------- 前端模板
        - demo --------------------- 组件demo页
            - tab.html ------------- tab组件demo
            - carousel.html -------- 轮播组件demo
            - ⋅⋅⋅
        - index -------------------- index页面目录
            - component1 ----------- 组件1
                - component1.tag --- riot组件文件
                - component1.scss -- 组件样式
            + component2 ----------- 组件2
            + component⋅⋅⋅ --------- 组件n
            - index.html ----------- 页面模板
            - index.scss ----------- 页面css打包
            - index.js ------------- 页面js打包
            - index.data ----------- 后端下发数据
    - static ----------------------- 静态资源
        - js ----------------------- js资源模块
            - http.js -------------- ajax封装
            - utils.js ------------- 工具函数
            - user.js -------------- 用户管理
            - ⋅⋅⋅
        - style -------------------- css资源模块
            - base.scss ------------ 全站基础样式
            - icon.scss ------------ 字体文件
            - ⋅⋅⋅
        + image -------------------- 图片资源
    - tags ------------------------- 基础组件
        - nav ---------------------- 导航组件
            - nav.tag 
            - nav.scss
        + tab
        + ⋅⋅⋅
    - fis-config.js ---------------- fis基础配置
    - fis-remote-conf.js ----------- 开发机配置
    - fat-conf.js ------------------ 本地调试配置
    - online-conf ------------------ 上线配置
    - README.md -------------------- 项目说明

完整的工程脚手架定义及说明如上。一个完整的前端工程包含了以下几个部分:

  • 基础js模块:独立功能的js文件。工具函数、动画函数、统计脚本等
  • 基础css模块:独立功能的css文件。基础样式、iconfont文件等
  • 独立UI组件:独立的可视/可交互功能单元。焦点图、导航等
  • 页面模板:UI组件容器。首页、详情页等
  • 模拟数据:本地调试的模拟数据
  • 构建配置:前端代码打包、联调、上线等配置

riot组件

使用riot编写的自定义标签(组件)是以.tag后缀结尾的文件,模板(html标签)、样式(css)和行为(js)都可以放在该文件中(如:sample.tag)。这里我们将样式独立出来,这样就可以利用scss这样的css预处理工具编写样式代码,方便后续开发和维护。所以一个riot组件文件组织定义如下:

- component -------------------------- 组件名称
    - component.tag ------------------ 模板+行为
    - component.scss ----------------- 样式

.tag文件中我们抽象出组件的基础模板并赋予其行为,配套的.scss文件中编写其样式

生命周期

riot自定义标签中可以监听组件在各个时期的状态,方便开发者在不同时期进行组件的有效控制,riot组件状态分为以下几个部分:

  • before-mount:标签被加载之前
  • mount:标签实例被加载到页面上以后
  • update:允许在更新之前重新计算上下文数据
  • updated:标签模板更新后
  • before-unmount:标签实例被卸载之前
  • unmount:标签实例被从页面上卸载后
  • all:监听所有事件

使用方式

<sample>  
    <script>
        this.on('before-mount', function() {
            // 标签被加载之前回调处理
        });
        ...
    </script>
</sample>  

组件通信

自定义标签构成了应用的视图部分。在组件化的应用中,这些标签不应知晓互相之间的存在,应被隔离。为了减少耦合,让标签之间互相监听消息而不是进行直接调用,riot提供了发布/订阅系统模块——riot.observable

一种常用实践是将应用划分成单一的核心和多个扩展。这个核心在某些事情发生时(添加了新项,旧项被删除,或从服务端获取了数据)触发事件。通过使用observable,扩展部分可以监听这些事件并对其进行响应。核心并不知道这些扩展模块的存在

只要慎重设计好核心部分和事件接口,团队成员就可以各自独立开发,而互相不打扰

在头条pc实战中,我们将window对象作为事件核心。需要事件交互的模块中只需要对window对象进行操作就行,例如:

// 全局让window成为observable对象
riot.observable(window);

// 组件1监听事件feedRefresh
<component1>

    <script>
    window.on('feedRefresh', function(data) {
        // 逻辑处理
    });
    </script>
</component1>

// 组件2某种情况下触发事件feedRefresh
<component2>

    <script>
    function _getData() {
        http({
            url: 'xx',
            method: 'get',
            success: function(rs) {
                window.trigger('feedRefresh', rs);
            }
        })
    }
    </script>
</component2>  

当然我们也可以利用riot提供的mixin功能,这样就可以避免污染全局变量window,更优雅地处理该问题:

// 定义一个mixin对象,对象中包含一个`observable`实例作为通信中介
var SharedMixin = {  
    observableObj: riot.observable()
};
// 混入所有riot实例中
riot.mixin(SharedMixin);  

上面的代码定义了一个所有标签共享的通信中介,我们即可在标签中自由操作事件了

<com1>

    <script>
    this.observableObj.on('refresh', function(data) {});
    </script>
</com1>

<com2>

    <script>
    this.observableObj.trigger('refresh', data);
    </script>
</com2>  

开发环境

上一节介绍了头条pc前端新架构脚手架及riot组件的定义,本节将简要介绍该架构如何在头条前端技术栈中实践运用的

构建工具

头条前端采用fis3b(基于fis3封装的集成化构建工具)处理前端代码的压缩、打包、静态资源自动上传到cdn、缓存等工作

注意旧架构工程采用了fispack功能进行js代码、css代码的压缩合并处理,但该功能存在文件依赖的致命缺陷,比如:A页面使用了1.js、2.js、...、10.js,B页面就使用了1.js、11.js,利用pack功能将这两个页面的js文件分别打包成A.js和B.js。按预期页面构建完之后,A页面应该只引入A.js,B页面应该只引入B.js,可是由于依赖关系(A、B都引用了1.js),这个时候A和B两个页面将会把所有包含1.js的打包文件引入,即:A和B同时会引入A.js和B.js,造成资源浪费。这种打包方式如果是单个页面或者多个页面之前没有互相依赖的模块是没有问题的,可是一旦有依赖,问题就来了

为了解决pack粗暴的打包问题,新架构我们统一使用fis提供的__inline功能进行js的打包(这个功能真心好用),采用预处理器@import功能进行css的打包

采用__inline手动合并js,可能会说不同组件间的js变量很容易相互影响,这个问题没有必要担心。.tag文件编译后会生成如下格式的文件:

riot.tag('tab', '<div></div>', function(opts) {  
    this.init = function() {
        // code
    }.bind(this);
});

一个类似沙箱的机制有么有,所以手动合并完全不用担心组件之间的变量冲突问题

本地开发工具

前端依赖后端的服务做页面上的开发,会严重影响开发进度。如果在本地能够模拟后端的接口数据、实时调试,切图效率将大大提升。受idt本地调试工具启发,我们编写了工具fat-byte,该工具使用简单,主要功能如下:

  • 自动拉取开发模板脚手架
  • 接入本地构建工具fis3b,并且支持扩展
  • 自定义测试mock数据,包括同步数据和异步数据
  • 本地django模板渲染,并且支持扩展
  • 本地代理,线上请求映射到本地文件
  • 支持自动启动子进程服务

解决了构建和本地调试问题后,开发相当便捷,切页面的效率大幅提升

组件实例

下面以头条pc站首页左侧导航为例,介绍riot组件是如何编写并且应用到线上实践中去的

channel.tag

<channel>  
  <div class="channel {channel-fixed: options.isSuspendion}" ga_event="left-channel-click">
    <ul>
      <li class="channel-item {active: item.url == options.tag}" each={item, i in options.navItem}>
        <a href="{item.url}" target="{_blank: item.independent}">
          <i class="y-icon icon-{item.icon}"></i><span>{item.name}</span>
        </a>
      </li>

      <li class="channel-item channel-more">
        <a href="javascript:;">
          <i class="y-icon icon-morechannel"></i><span>更多</span>
        </a>
        <div class="channel-more-layer">
          <ul>
            <li class="y-left channel-item" each={item, i in options.navMore}>
              <a href="{item.url}" target="{_blank: item.independent}">
                <i class="y-icon icon-{item.icon}"></i><span>{item.name}</span>
              </a>
            </li>
          </ul>
        </div>
      </li>
    </ul>
  </div>


  <script>
  var self = this;

  // 导航数据
  this.options = {
    navItem: [
      {name: '推荐', url: '/', icon: 'recommandchannel'},
      {name: '热点', url: '/news_hot/', icon: 'hotchannel'},
      {name: '视频', url: '/video/', icon: 'videochannel'},
      {name: '图片', url: '/news_image/', icon: 'imagechannel', independent: true},
      {name: '段子', url: '/essay_joke/', icon: 'jokechannel'},
      {name: '社会', url: '/news_society/', icon: 'socialchannel'},
      {name: '娱乐', url: '/news_entertainment/', icon: 'entertainmentchannel'},
      {name: '科技', url: '/news_tech/', icon: 'technologychannel'},
      {name: '体育', url: '/news_sports/', icon: 'sportschannel'},
      {name: '汽车', url: '/news_car/', icon: 'carchannel', independent: true},
      {name: '财经', url: '/news_finance/', icon: 'financechannel'},
      {name: '搞笑', url: '/funny/', icon: 'funnychannel'}
    ],
    navMore: [
      {name: '军事', url: '/news_military/', icon: 'militarychannel'},
      {name: '国际', url: '/news_world/', icon: 'internationalchannel'},
      {name: '时尚', url: '/news_fashion/', icon: 'fashionchannel'},
      {name: '旅游', url: '/news_travel/', icon: 'travelchannel'},
      {name: '探索', url: '/news_discovery/', icon: 'explorechannel'},
      {name: '育儿', url: '/news_baby/', icon: 'childcarechannel'},
      {name: '养生', url: '/news_regimen/', icon: 'healthchannel'},
      {name: '故事', url: '/news_story/', icon: 'storychannel'},
      {name: '美文', url: '/news_essay/', icon: 'articlechannel'},
      {name: '游戏', url: '/news_game/', icon: 'gamechannel'},
      {name: '历史', url: '/news_history/', icon: 'historychannel'},
      {name: '美食', url: '/news_food/', icon: 'foodchannel'},
      {name: '药品', url: '/medicine/', icon: 'medicinechannel'}
    ],
    isSuspendion: false,
    tag: opts.tag
  };

  // 初始化
  init() {
    var tag = opts.tag,
        moreLen = this.options.navMore.length;

    for(var i = 0; i < moreLen; i++) {
      var item = this.options.navMore[i];

      if (item.url === tag) {
        _changeItem(i);
        break;
      }
    }
  }

  this.init();

  // 悬浮
  utils.on(window, 'scroll', _.throttle(function(){
    var scrollTop = utils.scrollTop();

    self.options.isSuspendion = scrollTop > 78;

    self.update();
  }, 10));

  // 更多里面的频道置换到当前
  function _changeItem(i) {
    var mainLen = self.options.navItem.length,
        tmp = self.options.navItem[mainLen-1];

    self.options.navItem[mainLen-1] = self.options.navMore[i];
    self.options.navMore[i] = tmp;
  }
  </script>
</channel>  

channel.scss

.channel {
  &-fixed {
    position: fixed;
    top: 0;
    z-index: 20;
  }

  &-item {
    margin-bottom: 10px;
    width: 90px;
    height: 36px;
    line-height: 36px;
    border-radius: 18px;
    text-align: center;

    &:hover, &.active {
      background-color: #f1f2f3;
    }

    a {
      color: #444;
    }
    i {
      color: #444;
      font-size: 26px;
      top: 3px;
    }
    span {
      display: inline-block;
      padding-left: 6px;
      font-size: 16px;
    }
  }

  .channel-more {
    position: relative;
    &:hover {
      .channel-more-layer {
        display: block;
      }
    }
  }

  .channel-more-layer {
    display: none;
    position: absolute;
    bottom: 0;
    left: 90px;
    width: 180px;
    background: #fff;
    z-index: 10;
    border: 1px solid #f5f6f7;
    box-shadow: 0 1px 4px 0 rgba(0,0,0,.12);
    padding: 5px 5px 0 5px;
  }
}

注意:pc站引入了iconfont技术以解决小图标的合并和高清适配问题,所以在导航频道数据每个item添加了一个icon属性。头条最近首页换了新样式,左侧导航的图标都去掉了,但上诉逻辑代码几乎没有变。

本地测试

本地调试的时候,只需创建一个普通的模板,然后引入对应的组件文件,采用fat-byte工具实现实时编译,实时调试功能

总结

本文主要介绍了头条pc站基于riot框架进行前端组件化的实践方案。首先介绍了方案背景和riot框架,然后介绍组件化理念和头条pc基于riot的组件化实践方案,最后用一个具体实例来证明riot编写代码的简单高效性。目前头条pc站的信息流首页文章详情页个人媒体主页已经采用该方式应用到实践中去,后续涉及到页面的改版重构将进一步做页面的迁移工作。

本文介绍了一种PC端兼容IE8的组件化开发方案,可能还有诸多问题,欢迎大家指正,欢迎参与组件化方案共建。

参考