用 JSX 实现 Carousel 轮播组件 - 前端组件化

Posted 三钻

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了用 JSX 实现 Carousel 轮播组件 - 前端组件化相关的知识,希望对你有一定的参考价值。

在我们用 JSX 建立组件系统之前,我们先来用一个例子学习一下组件的实现原理和逻辑。这里我们就用一个轮播图的组件作为例子进行学习。轮播图的英文叫做 Carousel,它有一个旋转木马的意思。

上一篇文章《使用 JSX 建立 Markup 组件风格》中我们实现的代码,其实还不能称为一个组件系统,顶多是可以充当 DOM 的一个简单封装,让我们有能力定制 DOM。

要做这个轮播图的组件,我们应该先从一个最简单的 DOM 操作入手。使用 DOM 操作把整个轮播图的功能先实现出来,然后在一步一步去考虑怎么把它设计成一个组件系统。

TIPS:在开发中我们往往一开始做一个组件的时候,都会过度思考一个功能应该怎么设计,然后就把它实现的非常复杂。其实更好的方式是反过来的,先把功能实现了,然后通过分析这个功能从而设计出一个组件架构体系。

因为是轮播图,那我们当然需要用到图片,所以这里我准备了 4 张来源于 Unsplash 的开源图片,当然大家也可以换成自己的图片。首先我们把这 4 张图片都放入一个 gallery 的变量当中:

let gallery = [
  'https://source.unsplash.com/Y8lCoTRgHPE/1142x640',
  'https://source.unsplash.com/v7daTKlZzaw/1142x640',
  'https://source.unsplash.com/DlkF4-dbCOU/1142x640',
  'https://source.unsplash.com/8SQ6xjkxkCo/1142x640',
];

而我们的目标就是让这 4 张图可以轮播起来。


组件底层封装

首先我们需要给我们之前写的代码做一下封装,便于我们开始编写这个组件。

  • 根目录建立 framework.js
    • createElementElementWrapperTextWrapper 这三个移到我们的 framework.js 文件中
    • 然后 createElement 方法是需要 export 出去让我们可以引入这个基础创建元素的方法。
    • ElementWrapperTextWrapper 是不需要 export 的,因为它们都属于内部给 createElement 使用的
  • 封装 Wrapper 类中公共部分
    • ElementWrapperTextWrapper之中都有一样的 setAttributeappendChildmountTo ,这些都是重复并且可公用的
    • 所以我们可以建立一个 Component 类,把这三个方法封装进入
    • 然后让 ElementWrapperTextWrapper 继承 Component
  • Component 加入 render() 方法
    • 在 Component 类中加入 构造函数

这样我们就封装好我们组件的底层框架的代码,代码示例如下:

function createElement(type, attributes, ...children) 
  // 创建元素
  let element;
  if (typeof type === 'string') 
    element = new ElementWrapper(type);
   else 
    element = new type();
  

  // 挂上属性
  for (let name in attributes) 
    element.setAttribute(name, attributes[name]);
  
  // 挂上所有子元素
  for (let child of children) 
    if (typeof child === 'string') child = new TextWrapper(child);
    element.appendChild(child);
  
  // 最后我们的 element 就是一个节点
  // 所以我们可以直接返回
  return element;


export class Component 
  constructor() 
  
  // 挂載元素的属性
  setAttribute(name, attribute) 
    this.root.setAttribute(name, attribute);
  
  // 挂載元素子元素
  appendChild(child) 
    child.mountTo(this.root);
  
  // 挂載当前元素
  mountTo(parent) 
    parent.appendChild(this.root);
  


class ElementWrapper extends Component 
  // 构造函数
  // 创建 DOM 节点
  constructor(type) 
    this.root = document.createElement(type);
  


class TextWrapper extends Component 
  // 构造函数
  // 创建 DOM 节点
  constructor(content) 
    this.root = document.createTextNode(content);
  



实现 Carousel

接下来我们就要继续改造我们的 main.js。首先我们需要把 Div 改为 Carousel 并且让它继承我们写好的 Component 父类,这样我们就可以省略重复实现一些方法。

继承了 Component后,我们就要从 framework.js 中 import 我们的 Component。

这里我们就可以正式开始开发组件了,但是如果每次都需要手动 webpack 打包一下,就特别的麻烦。所以为了让我们可以更方便的调试代码,这里我们就一起来安装一下 webpack dev server 来解决这个问题。

执行一下代码,安装 webpack-dev-server

npm install --save-dev webpack-dev-server webpack-cli

看到上面这个结果,就证明我们安装成功了。我们最好也配置一下我们 webpack 服务器的运行文件夹,这里我们就用我们打包出来的 dist 作为我们的运行目录。

设置这个我们需要打开我们的 webpack.config.js,然后加入 devServer 的参数, contentBase 给予 ./dist 这个路径。

module.exports = 
  entry: './main.js',
  mode: 'development',
  devServer: 
    contentBase: './dist',
  ,
  module: 
    rules: [
      
        test: /\\.js$/,
        use: 
          loader: 'babel-loader',
          options: 
            presets: ['@babel/preset-env'],
            plugins: [['@babel/plugin-transform-react-jsx',  pragma: 'createElement' ]],
          ,
        ,
      ,
    ],
  ,
;

用过 Vue 或者 React 的同学都知道,启动一个本地调试环境服务器,只需要执行 npm 命令就可以了。这里我们也设置一个快捷启动命令。打开我们的 package.json,在 scripts 的配置中添加一行 "start": "webpack start" 即可。


  "name": "jsx-component",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": 
    "test": "echo \\"Error: no test specified\\" && exit 1",
    "start": "webpack serve"
  ,
  "author": "",
  "license": "ISC",
  "devDependencies": 
    "@babel/core": "^7.12.3",
    "@babel/plugin-transform-react-jsx": "^7.12.5",
    "@babel/preset-env": "^7.12.1",
    "babel-loader": "^8.1.0",
    "webpack": "^5.4.0",
    "webpack-cli": "^4.2.0",
    "webpack-dev-server": "^3.11.0"
  ,
  "dependencies": 

这样我们就可以直接执行下面这个命令启动我们的本地调试服务器啦!

npm start

开启了这个之后,当我们修改任何文件时都会被监听到,这样就会实时给我们打包文件,非常方便我们调试。看到上图里面表示,我们的实时本地服务器地址就是 http://localhost:8080。我们在浏览器直接打开这个地址就可以访问这个项目。

这里要注意的一个点,我们把运行的目录改为了 dist,因为我们之前的 main.html 是放在根目录的,这样我们就在 localhost:8080 上就找不到这个 HTML 文件了,所以我们需要把 main.html 移动到 dist 目录下,并且改一下 main.js 的引入路径。

<!-- main.html 代码 -->
<body></body>

<script src="./main.js"></script>

打开链接后我们发现 Carousel 组件已经被挂載成功了,这个证明我们的代码封装是没有问题的。

接下来我们继续来实现我们的轮播图功能,首先要把我们的图片数据传进去我们的 Carousel 组件里面。

let a = <Carousel src=gallery/>;

这样我们的 gallery 数组就会被设置到我们的 src 属性上。但是我们的这个 src 属性不是给我们的 Carousel 自身的元素使用的。也就说我们不是像之前那样直接挂載到 this.root 上。

所以我们需要另外储存这个 src 上的数据,后面使用它来生成我们轮播图的图片展示元素。在 React 里面是用 props 来储存元素属性,但是这里我们就用一个更加接近属性意思的 attributes 来储存。

因为我们需要储存进来的属性到 this.attributes 这个变量中,所以我们需要在 Component 类的 constructor 中先初始化这个类属性。

然后这个 attributes 是需要我们另外存储到类属性中,而不是挂載到我们元素节点上。所以我们需要在组件类中重新定义我们的 setAttribute 方法。

我们需要在组件渲染之前能拿到 src 属性的值,所以我们需要把 render 的触发放在 mountTo 之内。

class Carousel extends Component 
  // 构造函数
  // 创建 DOM 节点
  constructor() 
    super();
    this.attributes = Object.create(null);
  
  setAttribute(name, value) 
    this.attributes[name] = value;
  
  render() 
	console.log(this.attributes);
    return document.createElement('div');
  
  mountTo() 
    parent.appendChild(this.render());
  

接下来我们看看实际运行的结果,看看是不是能够获得图片的数据。

接下来我们就去把这些图给显示出来。这里我们需要改造一下 render 方法,在这里加入渲染图片的逻辑:

  • 首先我们需要把创建的新元素储起来
  • 循环我们的图片数据,给每条数据创建一个 img 元素
  • 给每一个 img 元素附上 src = 图片 url
  • 把附上 src 属性的图片元素挂載到我们的组件元素 this.root
  • 最后让 render 方法返回 this.root
class Carousel extends Component 
  // 构造函数
  // 创建 DOM 节点
  constructor() 
    super();
    this.attributes = Object.create(null);
  
  setAttribute(name, value) 
    this.attributes[name] = value;
  
  render() 
    this.root = document.createElement('div');

    for (let picture of this.attributes.src) 
      let child = document.createElement('img');
      child.src = picture;
      this.root.appendChild(child);
    

    return this.root;
  
  mountTo(parent) 
    parent.appendChild(this.render());
  

就这样我们就可以看到我们的图片被正确的显示在我们的页面上。


排版与动画

首先我们图片的元素都是 img 标签,但是使用这个标签的话,当我们点击并且拖动的时候它自带就是可以被拖拽的。当然这个也是可以解决的,但是为了更简单的解决这个问题,我们就把 img 换成 div,然后使用 background-image。

默认 div 是没有宽高的,所以我们需要在组件的 div 这一层加一个 class 叫 carousel,然后在 HTML 中加入 css 样式表,直接选择 carousel 下的每一个 div,然后给他们合适的样式。

// main.js
class Carousel extends Component 
  // 构造函数
  // 创建 DOM 节点
  constructor() 
    super();
    this.attributes = Object.create(null);
  
  setAttribute(name, value) 
    this.attributes[name] = value;
  
  render() 
    this.root = document.createElement('div');
	this.root.addClassList('carousel'); // 加入 carousel class

    for (let picture of this.attributes.src) 
      let child = document.createElement('div');
      child.backgroundImage = `url('$picture')`;
      this.root.appendChild(child);
    

    return this.root;
  
  mountTo(parent) 
    parent.appendChild(this.render());
  

<!-- main.html -->
<head>
  <style>
    .carousel > div 
      width: 500px;
      height: 281px;
      background-size: contain;
    
  </style>
</head>

<body></body>

<script src="./main.js"></script>

这里我们的宽是 500px,但是如果我们设置一个高是 300px,我们会发现图片的底部出现了一个图片重复的现象。这是因为图片的比例是 1600 x 900,而 500 x 300 比例与图片原来的比例不一致。

所以通过比例计算,我们可以得出这样一个高度: 500 ÷ 1900 × 900 = 281. x x x 500\\div1900\\times900 = 281.xxx 500÷1900×900=281.xxx。所以 500px 宽对应比例的高大概就是 281px。这样我们的图片就可以正常的显示在一个 div 里面了。

一个轮播图显然不可能所有的图片都显示出来的,我们认知中的轮播图都是一张一张图片显示的。首先我们需要让图片外层的 carousel div 元素有一个和它们一样宽高的盒子,然后我们设置 overflow: hidden。这样其他图片就会超出盒子所以被隐藏了。

这里有些同学可能问:“为什么不把其他图片改为 display: hidden 或者 opacity:0 呢?” 因为我们的轮播图在轮播的时候,实际上是可以看到当前的图片和下一张图片的。所以如果我们用了 display: hidden 这种隐藏属性,我们后面的效果就不好做了。

然后我们又有一个问题,轮播图一般来说都是左右滑动的,很少见是上下滑动的,但是我们这里图片就是默认从上往下排布的。所以这里我们需要调整图片的布局,让它们拍成一行。

这里我们使用正常流就可以了,所以只需要给 div 加上一个 display: inline-block,就可以让它们排列成一行,但是只有这个属性的话,如果图片超出了窗口宽度就会自动换行,所以我们还需要在它们父级加入强制不换行的属性 white-space: nowrap。这样我们就大功告成了。

<head>
  <style>
    .carousel 
      width: 500px;
      height: 281px;
      white-space: nowrap;
      overflow: hidden;
    

    .carousel > div 
      width: 500px;
      height: 281px;
      background-size: contain;
      display: inline-block;
    
  </style>
</head>

<body></body>

<script src="./main.js"></script>

接下来我们来实现自动轮播效果,在做这个之前我们先给这些图片元素加上一些动画属性。这里我们用 transition 来控制元素动效的时间,一般来说我们播一帧会用 0.5 秒 的 ease

Transition 一般来说都只用 ease 这个属性,除非是一些非常特殊的情况,ease-in 会用在推出动画当中,而 ease-out 就会用在进入动画当中。在同一屏幕上的,我们一般默认都会使用 ease,但是 linear 在大部分情况下我们是永远不会去用的。因为 ease 是最符合人类的感觉的一种运动曲线。

<head>
  <style>
    .carousel 
      width: 500px;
      height: 281px;
      white-space: nowrap;
      overflow: hidden;
    

    .carousel > div 
      width: 500px;
      height: 281px;
      background-size: contain;
      display: inline-block;
      transition: ease 0.5s;
    
  </style>
</head>

<body></body>

<script src="./main.js"></script>

实现自动轮播

有了动画效果属性,我们就可以在 javascript 中加入我们的定时器,让我们的图片在每三秒钟切换一次图片。我们使用 setInerval() 这个函数就可以解决这个问题了。

但是我们怎么才能让图片轮播,或者移动呢?想到 HTML 中的移动,大家有没有想到 CSS 当中有什么属性可以让我们移动元素的呢?

对没错,就是使用 transform,它就是在 CSS 当中专门用于挪动元素的。所以这里我们的逻辑就是,每 3 秒往左边挪动一次元素自身的长度,这样我们就可以挪动到下一张图的开始。

但是这样只能挪动一张图,所以如果我们需要挪动第二次,到达第三张图,我们就要让每一张图偏移 200%,以此类推。所以我们需要一个当前页数的值,叫做 current,默认值为 0。每次挪动的时候时就加一,这样偏移的值就是 − 100 × 页 数 -100\\times页数 100×。这样我们就完成了图片多次移动,一张一张图片展示了。

class Carousel extends Component 
  // 构造函数
  // 创建 DOM 节点
  constructor() 
    super();
    this.attributes = Object.create(null);
  
  setAttribute(name, value) 
    this.attributes[name] = value;
  
  render() 
    this.root = document.createElement('div');
    this.root.classList.add('carousel');

    for (let picture of this.attributes.src) 
      let child = document.createElement('div');
      child.style.backgroundImage = `url('$picture')`;
      this.root.appendChild(child);
    

    let current = 0;
    setInterval(() => 
      let children = this.root.children;
      ++current;
      for (let child of children) 
        child.style.transform = `translateX(-$100 * current%)`;
      
    , 3000);

    return this.root;
  
  mountTo(parent) 
    parent.appendChild(this.render());
  

这里我们发现一个问题,这个轮播是不会停止的,一直往左偏移没有停止。而我们需要轮播到最后一张的时候是回到一张图的。

要解决这个问题,我们可以利用一个数学的技巧,如果我们想要一个数是在 1 到 N 之间不断循环,我们就让它对 n 取余就可以了。在我们元素中,children 的长度是 4,所以当我们 current 到达 4 的时候, 4 ÷ 4 4\\div4 4÷4 的余数就是 0,所以每次把 current 设置成 current 除以 children 长度的余数就可以达到无限循环了。

这里 current 就不会超过 4, 到达 4 之后就会回到 0。

用这个逻辑来实现我们的轮播,确实能让我们的图片无限循环,但是如果我们运行一下看看的话,我们又会发现另外一个问题。当我们播放到最后一个图片之后,就会快速滑动到第一个张图片,我们会看到一个快速回退的效果。这个确实不是那么好,我们想要的效果是,到达最后一张图之后,第一张图就直接在后面接上。

那么我们就一起去尝试解决这个问题,经过观察其实在屏幕上一次最多就只能看到两张图片。那么其实我们就把这两张图片挪到正确的位置就可以了。

所以我们需要找到当前看到的图片,还有下一张图片,然后每次移动到下一张图片就找到再下一张图片,把下一张图片挪动到正确的位置。

讲到这里可能还是有点懵,但是不要紧,我们来整理一下逻辑。