背景
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()">×</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) % 4 | 3 | (0 + 1) % 4 | 1 |
1 | (1 - 1 + 4) % 4 | 0 | (1 + 1) % 4 | 2 |
2 | (2 - 1 + 4) % 4 | 1 | (2 + 1) % 4 | 3 |
3 | (3 - 1 + 4) % 4 | 2 | (3 + 1) % 4 | 0 |
🧠 取模运算的特点总结
- 结果范围恒定:始终在
0 ~ length - 1
范围内。 - 自动循环:到达边界后自动跳转到另一端。
- 无需判断:省去繁琐的
if (index === 0)
或index === last
等条件判断。 - 保留符号: JS 的
%
运算符对负数处理是 保留符号的 => 所以上一张需要额外加上数组长度值
4.3. 🔍 为什么监听键盘事件的时候,更新视图需要使用$apply
原因如下:
🚀 Angular 脏检查机制
脏检查是 AngularJS 实现数据双向绑定的核心机制。它通过不断比较作用域(scope)中的数据是否发生变化来决定是否更新视图。
-
Angular 数据绑定:Angular 通过脏检查(digest cycle)实现数据绑定。
-
生命周期内的变更:只有在 Angular 生命周期内的变更才会触发脏检查。
-
内置指令自动触发:常见的 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. 总结
这样设计的好处是:
-
组件的封装性好,有独立的作用域
-
接口清晰,通过明确的绑定方式与父组件通信
-
可重用性强,可以在不同地方使用这个图片预览组件
-
维护性好,组件的依赖都是显式声明的
效果如下:
本文仅为工作记录,欢迎留言讨论,现在angularjs1.x用的实在是太少了。