在angular.js中封装图片预览组件

72 阅读9分钟

背景

angular.js 版本为:1.5 接手了公司传宗接代的项目,要求实现类似element-ui组件中图片预览的效果,由于使用的技术栈太过老旧了,没办法满足产品经理的需求,但是又找不到合适的插件,所以需要直接手写一个简易的图片预览组件。本文旨在记录一下在封装组件中遇到的问题,还有熟悉一下angular的语法和特性

🖼️ 1. 组件设计

  • 组件需要有的功能:
    • 初始化的时候,为缩略图
    • 点击放大
    • 点击蒙层可以直接关闭弹窗(可选)
    • 放大后底部具有工具栏,工具栏需要功能:
      • 当具有多张图片的时候,可以进行左右图片切换
      • 可以对图片进行放大
      • 可以对图片进行顺时针/逆时针旋转

📌 2. 静态搭建

创建imagePreview.html

<!-- 图片预览组件 -->
<div class="image-preview-wrapper" ng-if="visible" ng-click="handleWrapperClick()">
  <div class="image-preview-mask"></div>
  <span class="image-preview-close" ng-click="handlePreviewClose(); $event.stopPropagation()">&times;</span>

  <div class="image-preview-content" ng-click="stopPropagation($event)">
    <!-- 图片 -->
    <img class="image-preview-img" 
         ng-src="{{image.imageUrl}}" 
         alt="{{image.areaName}}"
         ng-style="imageStyle" />
    <!-- 图片信息指示器展示 -->
    <div class="image-preview-operations">
      <!-- 图片名称 -->
      <div class="image-preview-operations-name">{{image.areaName}}</div>
      <!-- 图片序号 -->
      <div class="image-preview-operations-info">
        {{currentIndex + 1}}/{{imageList.length}}
      </div>
    </div>

    <!-- 工具栏 -->
    <div class="toolbar-container">
      <!-- 上一张按钮 -->
      <span class="image-preview-switch image-preview-prev" 
            ng-click="handlePrevClick()"
            ng-show="imageList.length > 1">
        <i class="layui-icon layui-icon-left"></i>
      </span>
      <!-- 工具栏按钮 -->
      <div class="image-preview-toolbar">
        <!-- 缩小按钮 -->
        <span class="toolbar-btn" ng-click="handleZoomOut()" title="缩小">
          <span class="minus-icon"></span>
        </span>
        <!-- 放大按钮 -->
        <span class="toolbar-btn" ng-click="handleZoomIn()" title="放大">
          <i class="layui-icon layui-icon-add-1"></i>
        </span>
        <!-- 向左旋转按钮 -->
        <span class="toolbar-btn" ng-click="handleRotateLeft()" title="向左旋转">
          <i class="layui-icon layui-icon-refresh-1" style="transform: scaleX(-1)"></i>
        </span>
        <!-- 向右旋转按钮 -->
        <span class="toolbar-btn" ng-click="handleRotateRight()" title="向右旋转">
          <i class="layui-icon layui-icon-refresh-1"></i>
        </span>
      </div>

      <!-- 下一张按钮 -->
      <span class="image-preview-switch image-preview-next" 
            ng-click="handleNextClick()"
            ng-show="imageList.length > 1">
        <i class="layui-icon layui-icon-right"></i>
      </span>
    </div>
  </div>
</div> 

这里想吐槽一下:layui的版本是2.3.0,但是图标不全啊不全啊,layui-icon-subtraction没有,所以只能手写一个用负号代替一下

🔍 3. 设置样式

创建imagePreview.css

/* 图片预览的最外层容器 - 固定定位铺满整个视口 */
.image-preview-wrapper {
  position: fixed;  /* 固定定位,不随滚动条滚动 */
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 2000;  /* 较高层级确保显示在其他内容之上 */
}

/* 半透明黑色遮罩层 - 创建图片预览时的暗色背景效果 */
.image-preview-mask {
  position: absolute;
  width: 100%;
  height: 100%;
  top: 0;
  left: 0;
  background: rgba(0, 0, 0, 0.5);  /* 半透明黑色背景 */
  z-index: 2001;  /* 比wrapper高一层 */
}

/* 关闭按钮 - 位于右上角的大号关闭图标 */
.image-preview-close {
  position: absolute;
  top: 40px;
  right: 40px;
  width: 40px;
  height: 40px;
  font-size: 40px;  /* 较大字号提高可见性 */
  font-weight: 700;  /* 加粗使图标更清晰 */
  color: #fff;
  text-align: center;
  cursor: pointer;
  z-index: 99999;  /* 最高层级确保可点击 */
}

/* 主要内容区域 - 居中显示图片和操作栏 */
.image-preview-content {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);  /* 精确居中定位技巧 */
  z-index: 9999;
  display: flex;
  flex-direction: column;  /* 垂直排列子元素 */
  align-items: center;
  gap: 20px;  /* 子元素间距 */
}

/* 预览图片样式 - 自适应大小且保持原比例 */
.image-preview-img {
  max-width: 100%;
  max-height: calc(80vh - 160px);  /* 限制最大高度为视口高度的80%减去边距 */
  vertical-align: middle;
  transition: transform 0.3s;  /* 添加变换动画效果 */
}

/* 操作区域 - 半透明黑色背景的圆角容器 */
.image-preview-operations {
  color: #fff;
  text-align: center;
  background: rgba(0, 0, 0, 0.6);  /* 半透明背景提升可读性 */
  padding: 8px 15px;
  border-radius: 20px;  /* 大圆角营造现代感 */
  white-space: nowrap;  /* 防止文字换行 */
}

/* 图片信息显示区域 */
.image-preview-operations-info {
  font-size: 14px;
  margin-top: 6px;
}

/* 图片名称样式 */
.image-preview-operations-name {
  font-size: 16px;
  color: #fff;
}

/* 工具栏容器 - 使用flex布局实现水平排列 */
.toolbar-container {
  display: flex;
  align-items: center;
  gap: 20px;  /* 元素间距 */
  z-index: 2002;
}

/* 工具栏样式 - 半透明背景的圆角容器 */
.image-preview-toolbar {
  background: rgba(0, 0, 0, 0.6);
  border-radius: 20px;
  padding: 8px 15px;
  display: flex;
  gap: 15px;
}

/* 切换按钮(上一张/下一张) - 圆形按钮设计 */
.image-preview-switch {
  width: 40px;
  height: 40px;
  font-size: 24px;
  color: #fff;
  background-color: rgba(0, 0, 0, 0.6);
  border-radius: 50%;  /* 圆形效果 */
  cursor: pointer;
  text-align: center;
  line-height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;  /* flex布局实现完美居中 */
  transition: all 0.3s;  /* 平滑过渡效果 */
}

/* 切换按钮悬停效果 */
.image-preview-switch:hover {
  background-color: rgba(0, 0, 0, 0.8);  /* 加深背景色 */
}

/* 工具栏按钮 - 统一的按钮样式 */
.toolbar-btn {
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #fff;
  cursor: pointer;
  border-radius: 4px;
  transition: all 0.3s;
}

/* 工具栏按钮悬停效果 */
.toolbar-btn:hover {
  background: rgba(255, 255, 255, 0.2);  /* 添加微妙的高亮效果 */
}

/* 工具栏图标样式 */
.toolbar-btn i {
  font-size: 20px;
}

/* 缩小图标特殊样式 */
.minus-icon {
  font-size: 30px;
}

🔧 4. JavaScript 逻辑部分

imagePreview.js

'use strict'
// 创建图片预览指令,使用 imagePreview 作为指令名
angular.module('myApp.directives').directive('imagePreview', [
  function () {
    return {
      // 限制只能作为元素使用,提高代码可读性和语义化
      restrict: 'E',
      
      // 创建隔离作用域,防止与父作用域产生冲突
      scope: {
        visible: '=',      // 控制预览器显示/隐藏的双向绑定
        image: '=',        // 当前显示图片的双向绑定
        imageList: '=',    // 图片列表的双向绑定
        currentIndex: '=', // 当前图片索引的双向绑定
        onClose: '&',      // 关闭事件的方法绑定
        maskClosable: '=?' // 可选配置,控制点击蒙层是否可关闭
      },
      
      // 使用独立的模板文件,便于维护和复用
      templateUrl: 'components/imagePreview/imagePreview.html',
      
      // link 函数用于处理 DOM 操作和事件绑定
      link: function (scope) {
        // 逻辑代码如下...  
        // 设置蒙层点击关闭的默认值,如果未指定则默认为 true
        scope.maskClosable = scope.maskClosable !== false

        // 初始化图片样式对象,用于控制图片的缩放和旋转
        scope.imageStyle = {}

        // 使用局部变量存储状态,避免污染作用域
        let scale = 1      // 缩放比例
        let rotate = 0     // 旋转角度

        // 重置图片状态的工具函数,在切换图片时调用
        function resetImageState() {
          scale = 1
          rotate = 0
          updateImageStyle()
        }

        // 更新图片样式的工具函数,统一管理 transform 属性
        function updateImageStyle() {
          scope.imageStyle = {
            transform: `scale(${scale}) rotate(${rotate}deg)`
          }
        }

        // 放大图片,限制最大放大倍数为3倍,保护用户体验
        scope.handleZoomIn = function() {
          scale = Math.min(scale + 0.25, 3)
          updateImageStyle()
        }

        // 缩小图片,限制最小缩小倍数为0.5倍,保护用户体验
        scope.handleZoomOut = function() {
          scale = Math.max(scale - 0.25, 0.5)
          updateImageStyle()
        }

        // 逆时针旋转,每次90度,保持图片方向的规范性
        scope.handleRotateLeft = function() {
          rotate -= 90
          updateImageStyle()
        }

        // 顺时针旋转,每次90度,保持图片方向的规范性
        scope.handleRotateRight = function() {
          rotate += 90
          updateImageStyle()
        }

        // 查看上一张图片,使用取模运算实现循环切换
        scope.handlePrevClick = function() {
          if (!scope.imageList || scope.imageList.length <= 1) return
          scope.currentIndex = (scope.currentIndex - 1 + scope.imageList.length) % scope.imageList.length
          scope.image = scope.imageList[scope.currentIndex]
          resetImageState() // 重置图片状态,提供更好的用户体验
        }

        // 查看下一张图片,使用取模运算实现循环切换
        scope.handleNextClick = function() {
          if (!scope.imageList || scope.imageList.length <= 1) return
          scope.currentIndex = (scope.currentIndex + 1) % scope.imageList.length
          scope.image = scope.imageList[scope.currentIndex]
          resetImageState() // 重置图片状态,提供更好的用户体验
        }

        // 关闭预览器,同时重置图片状态
        scope.handlePreviewClose = function() {
          scope.onClose()
          resetImageState()
        }

        // 处理蒙层点击,支持配置是否可通过点击蒙层关闭
        scope.handleWrapperClick = function() {
          if (scope.maskClosable) {
            scope.handlePreviewClose()
          }
        }

        // 阻止事件冒泡的工具函数,防止事件传播导致意外关闭
        scope.stopPropagation = function($event) {
          $event.stopPropagation()
        }

        // 键盘事件处理,支持左右方向键切换图片
        function handleKeydown(e) {
          if (!scope.visible) return
          
          switch(e.keyCode) {
            case 37: // 左方向键
            case 39: // 右方向键
              // 阻止默认行为和事件冒泡
              e.preventDefault()
              e.stopPropagation()
              
              // 使用 $apply 确保在 Angular 的生命周期内更新视图
              scope.$apply(function() {
                e.keyCode === 37 ? scope.handlePrevClick() : scope.handleNextClick()
              })
              break
          }
        }

        // 在 document 上绑定键盘事件,实现全局快捷键支持
        angular.element(document).on('keydown', handleKeydown)

        // 组件销毁时清理事件监听,防止内存泄漏
        scope.$on('$destroy', function() {
          angular.element(document).off('keydown', handleKeydown)
        })
      }
    }
  }
])

💡 其他:

4.1. 🛠️ restrict属性

值说明:
  • E - 作为元素使用 (Element)
  • A - 只能作为属性使用 (Attribute)
  • C - 作为类使用 (Class)
  • M - 作为注释使用 (Comment)
    注意:使用 AE 可以提供更大的灵活性,让使用者根据具体场景选择使用方式;很少使用 C(类)和 M(注释)这两种方式,因为不够直观
推荐使用场景:
  • 创建独立组件时,推荐使用 E(元素)
    <!-- 作为元素 'E' -->
   <my-directive></my-directive>
   
   <!-- 作为属性 'A' -->
   <div my-directive></div>
   
   <!-- 作为类 'C' -->
   <div class="my-directive"></div>
   
   <!-- 作为注释 'M' -->
   <!-- directive:my-directive -->

4.2. 💡 图片切换逻辑详解:上一张 & 下一张

在图片预览组件中,实现“上一张”和“下一张”按钮的循环切换是关键逻辑。我们可以借助 取模运算 来优雅地处理索引边界问题,而无需使用 if-else 判断。

🔢 核心公式
操作表达式说明
上一张(currentIndex - 1 + imageList.length) % imageList.length防止负数索引,实现循环
下一张(currentIndex + 1) % imageList.length自动回到第一张
🧮 示例演示:4 张图片

假设 imageList.length = 4,图片索引为 [0, 1, 2, 3]

当前索引上一张公式结果下一张公式结果
0(0 - 1 + 4) % 43(0 + 1) % 41
1(1 - 1 + 4) % 40(1 + 1) % 42
2(2 - 1 + 4) % 41(2 + 1) % 43
3(3 - 1 + 4) % 42(3 + 1) % 40
🧠 取模运算的特点总结
  • 结果范围恒定:始终在 0 ~ length - 1 范围内。
  • 自动循环:到达边界后自动跳转到另一端。
  • 无需判断:省去繁琐的 if (index === 0) 或 index === last 等条件判断。
  • 保留符号: JS 的 % 运算符对负数处理是 保留符号的 => 所以上一张需要额外加上数组长度值

4.3. 🔍 为什么监听键盘事件的时候,更新视图需要使用$apply

原因如下:

🚀 Angular 脏检查机制

脏检查是 AngularJS 实现数据双向绑定的核心机制。它通过不断比较作用域(scope)中的数据是否发生变化来决定是否更新视图。

  1. Angular 数据绑定:Angular 通过脏检查(digest cycle)实现数据绑定。

  2. 生命周期内的变更:只有在 Angular 生命周期内的变更才会触发脏检查。

  3. 内置指令自动触发:常见的 Angular 内置指令如 ng-click 会自动触发脏检查。

🔥 异步事件的问题

键盘事件(keydown)是由浏览器触发的,不在 Angular 的生命周期内,这类 "外部事件" 包括:

  • DOM 事件(如 keydown, click)

  • setTimeout/setInterval

  • AJAX 回调

  • Promise 回调

💡 什么是脏检查 (Digest Cycle)?
基本工作原理:

监视列表(Watch List)

// 每当你使用数据绑定,Angular 就会创建一个监视器
<div>{{user.name}}</div>  // 创建了对 user.name 的监视

// 或者显式创建监视器
scope.$watch('user.name', function(newValue, oldValue) {
  // 当 user.name 改变时执行
});

脏检查循环过程

// 简化的脏检查过程伪代码
function digestCycle() {
  let changes;
  do {
    changes = false;
    
    // 遍历所有监视器
    for(let watcher of watchList) {
      let newValue = watcher.getValue();
      let oldValue = watcher.last;
      
      if(newValue !== oldValue) {
        watcher.listener(newValue, oldValue);
        changes = true;
        watcher.last = newValue;
      }
    }
  } while(changes); // 如果有变化,继续循环
}

4.4. 作用域绑定的符号说明:

  • = 双向绑定:
 visible'='    // 控制预览器显示/隐藏
 image'='      // 当前显示的图片
 imageList'='  // 图片列表
 currentIndex'=' // 当前图片在列表中的索引
  • & 方法绑定:

         onClose: '&'  : 关闭预览器时调用的方法

  • =? 可选的双向绑定:

         maskClosable: '=?' : 点击蒙层是否可关闭,问号表示这是可选参数

🎉 4.5. 什么时候会触发脏检查?

1. 用户交互: ng-click..等bom事件
2. Angular 内置指令:ng-model,ng-show,ng-repeat
3. XHR 请求: $http 服务会自动触发脏检查
4. 定时器: $timeout 会自动触发脏检查,注意,原生setTimeout 需要在定时器中使用scope.$apply手动触发

🧠 5.使用示例:

直接使用

<image-preview

  visible="isPreviewVisible"

  image="currentImage"

  image-list="images"

  current-index="selectedIndex"

  on-close="handleClose()"

  mask-closable="true">

</image-preview>

注意:还需要在index.html文件中引入相关代码

 <!-- 在其他JS文件之后添加 -->
 <link rel="stylesheet" href="components/imagePreview/imagePreview.css">
 <script src="components/imagePreview/imagePreview.js"></script>

📌 6. 总结

这样设计的好处是:

  • 组件的封装性好,有独立的作用域

  • 接口清晰,通过明确的绑定方式与父组件通信

  • 可重用性强,可以在不同地方使用这个图片预览组件

  • 维护性好,组件的依赖都是显式声明的

效果如下:

图片预览.gif

本文仅为工作记录,欢迎留言讨论,现在angularjs1.x用的实在是太少了。