0 Star 2 Fork 1

cjbgitee / viewer

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
该仓库未声明开源许可证文件(LICENSE),使用请关注具体项目描述及其代码上游依赖。
克隆/下载
贡献代码
同步代码
取消
提示: 由于 Git 不支持空文件夾,创建文件夹后会生成空的 .keep 文件
Loading...
README
ISC

导读:内容简介

viewerjs是一款强大的图形查看器,本文是对其源码进行解读和学习

viewerjs可以学习搭建一个框架的设计流程,提升自身的编程能力

学习本项目需要熟练掌握html、js、css,了解nodejs、npm、es6、scss

一 搭建项目

本项目使用npm管理相关依赖,因此初始化项目前需要安装nodejs环境

使用命令行工具cmd创建文件夹viewer作为项目根目录,参数使用默认回车即可

D:\> mkdir viewer
D:\> cd viewer
D:\viewer> npm init
...
package name: (viewer)
version: (1.0.0)
description:
entry point: (index.js)
test command:
git repository:
keywords:
author:
license: (ISC)

项目初始化以后会生成package.json

# 注:以下开始 -xxx 为注释信息
{
  "name": "viewer",		-项目名称
  "version": "1.0.0",	-项目版本
  "description": "",	-项目描述
  "main": "index.js",	-入口文件
  "scripts": {			-运行命令
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",			-作者名称
  "license": "ISC"		-项目协议
}

.editorconfig 定义文件格式

# 根目录,往下匹配文件
root = true

# 匹配全部文件
[*]
# 设置字符集
charset = utf-8
# 设置换行符
end_of_line = lf
# 设置缩进空格数
indent_size = 4
# 设置缩进风格 space/tab
indent_style = space
# 设置文件结尾插入空行
insert_final_newline = true
# 设置删除一行中的前后空格
trim_trailing_whitespace = true

二 测试环境

本项目使用以下测试库,编写对应测试案例,可以对项目功能进行自动化测试,提升开发效率:

karma提供多种浏览器运行环境,使用插件可以在控制台查看对应测试结果

karma以mocha测试库和chai断言库作为插件,搭建测试环境

1.开发依赖

D:\viewer> cnpm install --save-dev
	karma mocha chai karma-mocha karma-chai
	karma-mocha-reporter karma-chrome-launcher
D:\viewer> npx karma

安装成功以后package.json中添加devDependencies,可以使用npx命令查看依赖包参数信息

{
  ...
  "devDependencies": {		-开发依赖
    "chai": "^4.3.4",
    "karma": "^6.3.2",
    "karma-chai": "^0.1.0",
    "karma-chrome-launcher": "^3.1.0",
    "karma-mocha": "^2.0.1",
    "karma-mocha-reporter": "^2.2.5",
    "mocha": "^8.4.0"
  }
  ...
}

2.初始化karma

D:\viewer> karma init
...
# 测试框架
Which testing framework do you want to use ?
> mocha
# 是否使用Require.js
Do you want to use Require.js ?
> no
# 是否自动捕获浏览器
Do you want to capture any browsers automatically ?
> Chrome
>
# 测试文件的位置
What is the location of your source and test files ?
> ./tests/*.spec.js
>
# 是否应排除以前模式包含的任何文件
Should any of the files included by the previous patterns be excluded ?
> node_modules
>
# 你想让Karma监视所有的文件并运行变更测试吗
Do you want Karma to watch all the files and run the tests on change ?
> yes
...

生成后的配置文件karma.conf.js

module.exports = function (config) {
    config.set({
        basePath: '',					-项目路径
        frameworks: ['mocha'],			-测试框架,添加chai
        files: ['./tests/*.spec.js'],	-文件位置
        exclude: ['node_modules'],		-排除文件
        preprocessors: {},				-预处理器
        reporters: ['progress'],		-测试报告,改为mocha
        port: 9876,						 -使用默认值
        colors: true,					 -使用默认值
        logLevel: config.LOG_INFO,		 -使用默认值
        autoWatch: true,				-自动监测代码
        browsers: ['Chrome'],			-使用浏览器
        singleRun: false,
        concurrency: Infinity
    })
}

3.测试案例

本小节创建一个测试案例,启动测试环境后显示图片

tests/help.js

let url = "https://fengyuanchen.github.io/viewerjs/images";
window.createContainer = function () {
    const container = document.createElement('div');
    container.className = 'container';
    document.body.appendChild(container);
    return container;
};
window.createImage = function () {
    const container = window.createContainer();
    const image = document.createElement('img');
    image.src = `${url}/tibet-1.jpg`;
    container.appendChild(image);
    return image;
};
window.createImageList = function () {
    const container = window.createContainer();
    const list = document.createElement('ul');
    list.innerHTML = (`
    <li><img src="${url}/tibet-1.jpg"></li>
    <li><img src="${url}/tibet-2.jpg"></li>
    <li><img src="${url}/tibet-3.jpg"></li>
    <li><img src="${url}/tibet-4.jpg"></li>
    <li><img src="${url}/tibet-5.jpg"></li>
  `);
    container.appendChild(list);
    return list;
};

tests/image.spec.js

describe('image test', function () {
    it('image test case', function () {
        let image = window.createImage();
    });
});

karma.conf.js

module.exports = function (config) {
    config.set({
        ...
        files: [
            './tests/help.js',	-加载help.js才能引用里面的方法
            './tests/*.spec.js'
        ]
        ...
    })
}

package.json

{
  ...
  "scripts": {
    "test": "karma start"
  }
  ...
}

npm run test 启动测试环境

项目启动以后会自动打开Chrome浏览器,点击DEBUG查看图片

三 项目环境

1.项目结构

src/index.js/scss		-入口文件
src/css/viewer.scss		-css文件
src/js/viewer.js		-定义框架
src/js/defaults.js		-定义变量
src/js/consts.js		-定义常量
src/js/utils.js			-定义工具
src/js/template.js		-定义模版
src/js/methods.js		-定义方法
src/js/events.js		-事件绑定
src/js/handlers.js		-事件处理
src/js/render.js		-图片渲染
tests/*					-测试文件

2.开发依赖

项目开发中使用es6、scss:

  • rollup 编译 es6 为浏览器可以执行的 javascript

  • sass 预处理器将 scss 编译为可执行的 css

  • 安装 node-sass 时需要访问 github.com

D:\viewer> cnpm install --save-dev
	rollup rollup-plugin-babel @babel/core karma-rollup-preprocessor
	node-sass @metahub/karma-sass-preprocessor

更新karma.conf.js

const babel = require('rollup-plugin-babel')
module.exports = function (config) {
    config.set({
        basePath: '',
        frameworks: ['mocha', 'chai'],
        files: [							-加载源文件及测试文件
            'src/index.js',
            'src/index.scss',
            './tests/help.js',
            './tests/*.spec.js'
        ],
        exclude: ['node_modules'],
        preprocessors: {					-配置预处理器
            'src/index.js': ['rollup'],
            'src/index.scss': ['sass']
        },
        plugins: [							-加载karma插件
            'karma-*',
            '@metahub/karma-sass-preprocessor'
        ],
        rollupPreprocessor: {				-设置rollup参数
             plugins: [babel()],
            output: {
                format: 'iife',
                name: 'Viewer',
                sourcemap: 'inline'
            }
        },
        sassPreprocessor: {					-设置sass参数
            options: { sourcemap: true }
        },
        reporters: ['mocha'],
        autoWatch: true,
        browsers: ['Chrome'],
        singleRun: false,
        concurrency: Infinity
    })
}

为项目添加 js 检查工具 eslint 和 css 检查工具 stylelint

D:\viewer> cnpm install --save-dev eslint stylelint stylelint-config-standard

初始化 eslint 配置文件 (.eslintrc.js)

D:\viewer> eslint --init
√ How would you like to use ESLint? · problems
√ What type of modules does your project use? · esm
√ Which framework does your project use? · none
√ Does your project use TypeScript? · No / Yes
√ Where does your code run? · browser, node
√ What format do you want your config file to be in? · JavaScript
Successfully created .eslintrc file in D:\viewer

也可以直接创建 .eslintrc

{
  "env": {
    "browser": true,
    "es2021": true,
    "node": true
  },
  "extends": "eslint:recommended",
  "parserOptions": {
    "ecmaVersion": 12,
    "sourceType": "module"
  },
  "rules": {
    "no-param-reassign": "off",
    "no-restricted-properties": "off",
    "no-unused-vars": "off"		-关闭变量未使用
  },
  "overrides": [	-设置测试文件规则
    {
      "files": "tests/**/*.spec.js",
      "env": {
        "mocha": true
      },
      "rules": {
        "no-undef": "off"		-关闭变量未定义
      }
    }
  ]
}

创建stylelint配置文件.stylelintrc

{
  "extends": "stylelint-config-standard"
}

package.json添加测试命令

{
  ...
  "scripts": {
    "test": "karma start",
    "lint": "npm run lint:js && npm run lint:css",
    "lint:js": "eslint src tests *.js --fix",
    "lint:css": "stylelint src/**/*.{css,scss,html} --fix"
  },
  ...
}
// 执行命令
// npm run lint
// npm run lint:js
// npm run lint:css

3.申明文件

申明文件可以预定义方法和类型,避免工具中出现不存在的提示

types/index.d.ts

declare class Viewer {
    constructor(element: Element);
}
declare module 'viewer' {
    export default Viewer;
}

package.json

{
  ...
  "main": "index.js",
  "types": "types/index.d.ts",		-引用申明文件
  ...
}

4.调用Viewer

本小节使用es6定义Viewer,并在测试Case中调用Viewer,启动测试后查看打印信息

src/js/viewer.js

// 定义Viewer
class Viewer {
    // 默认构造器
    constructor(element) {
        console.log('viewer', 'constructor');
        this.init();
    }
    // 初始化方法
    init() {
        console.log('viewer', 'init');
    }
}
// 对外暴露Viewer
export default Viewer;

src/index.js

// 入口文件引用Viewer
import Viewer from "./js/viewer";

export default Viewer;

tests/image.spec.js

// 编写测试案例
describe('image test', function () {
    it('image test case', function () {
        let image = window.createImage();
        let viewer = new Viewer(image);
    });
});

cmd 中 运行 npm run test 命令,在控制台可以看到测试输出结果

在测试Chrome浏览器中F12调用控制台,在Console中也可以看到结果

四 项目开发

1.Event

元素添加事件

  • 元素上添加
<input type="button" value="点击" onclick="show()">
function show() { alert('点击'); }
  • document添加
<input type="button" value="添加" id="add">
document.getElementById('add').onclick = function () { alert('添加'); };
  • addEventListener添加

    element.addEventListener(type, listener, options)

<input type="button" value="元素" id="ele">
document.getElementById('ele').addEventListener('click', function () {
	alert('元素');
});

listener默认接受参数为event,使用对象解构可以从事件中获取到点击的对象target

element.addEventListener('click', function (event) { console.log(event); });
// 解构对象event的target属性: const {target}=event 等价于 const target=event.target
element.addEventListener('click', function ({ target }) { console.log(target); });
// function匿名函数可以使用箭头函数: (param) => {...} 等价于 fucntion (param) {...}
element.addEventListener('click', ({ target }) => { console.log(target); });
// element.dispatchEvent(event)可以触发元素事件,默认返回true
// element的事件可取消且调用event.preventDefault()后调用dispatchEvent返回false

2.Viewer click

本小节对事件进行封装,且对元素添加点击事件,启动测试后查看点击后的打印结果

src/js/utils.js

// 对addEventListener进行封装
export function addListener(element, type, listener, options = {}) {
    element.addEventListener(type, listener, options);
}

src/js/methods.js

// 定义方法
export default {
    show() {
        console.log('method', 'show');
        return this;
    },
    view() {
        console.log('method', 'view');
        return this;
    }
}

types/index.d.ts

...
declare class Viewer {
    constructor(element: Element);
    show(): Viewer;
    view(): Viewer;
}
...

src/js/viewer.js

// 引用listener,methods
import {addListener} from "./utils";
import methods from "./methods";

class Viewer {
    constructor(element) {
        console.log('viewer', 'constructor');
        this.element = element;
        this.init();
    }
    init() {
        console.log('viewer', 'init');
        const {element} = this;
        // 为element添加点击事件
        addListener(element, 'click', ({target}) => {
            if (target.tagName.toLowerCase() === 'img') {
                // 点击图片时调用view方法
                this.view();
            }
        });
    }
}
// 将methods绑定为Viewer的属性
Object.assign(Viewer.prototype, methods);
export default Viewer;

​ 以上流程思路如下:

  • 封装listener事件
  • 定义方法view()
  • init()中添加点击事件
  • 点击对象为图片时调用view()

npm run test 运行后点击图片,可以在浏览器控制台查看点击后的输出结果

3.Container

本小节创建加载图片的容器,在点击图片时显示该容器

src/js/utils.js

...
// 添加元素Class属性
export function addClass(element, value) {
    if (element.classList) {
        element.classList.add(value)
        return;
    }
    const className = element.className.trim();
    if (!className) {
        element.className = value;
    } else {
        element.className = `${className} ${value}`;
    }
}

src/js/template.js

// 容器模版
export default (`<div class="viewer-container" touch-action="none"></div>`)

src/js/viewer.js

import TEMPLATE from './template'
import {addClass, addListener} from "./utils";
import methods from "./methods";
class Viewer {
    constructor(element) {
        console.log('viewer', 'constructor');
        this.element = element;
        this.isShown = false;	-标记状态
        this.ready = false;		-标记状态
        this.init();
    }
    init() {
        console.log('viewer', 'init');
        const {element} = this;
        const {ownerDocument} = element;
        const body = ownerDocument.body || ownerDocument.documentElement;
        this.body = body;
        // 获取页面滚动条宽度和右边距
        this.scrollbarWidth = window.innerWidth
            - ownerDocument.documentElement.clientWidth;
        this.initialBodyPaddingRight = window.getComputedStyle(body).paddingRight;
        addListener(element, 'click', ({target}) => {
            if (target.tagName.toLowerCase() === 'img') {
                this.view();
            }
        });
    }
    // ready container
    build() {
        // 获取element
        const {element} = this;
        // 创建templat
        const template = document.createElement('div');
        // 获取容器模版
        template.innerHTML = TEMPLATE;
		// 获取容器中div
        const viewer = template.querySelector('.viewer-container');
        this.viewer = viewer;
        addClass(viewer, 'viewer-fixed');
        // 为容器添加背景
        addClass(viewer, 'viewer-backdrop');
        // 将容器置为顶层
        viewer.style.zIndex = '2021';
        // 获取页面body
        let container = element.ownerDocument.querySelector('body');
        // 追加容器到body
        container.appendChild(viewer);
        // 设置状态
        this.ready = true;
    }
}
Object.assign(Viewer.prototype, methods);
export default Viewer;

src/css/viewer.scss

// 除了添加div(container),也需要为div设置css样式
html, body {
  margin: 0;
  padding: 0;
}
.viewer {
  &-container {	-容器样式
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    direction: ltr;
    font-size: 0;
    line-height: 0;
    overflow: hidden;
    position: absolute;
  }
  &-fixed {		-填充界面
    position: fixed;
  }
  &-open {		-隐藏滚动条
    overflow: hidden;
  }
  &-backdrop {	-容器背景色
    background-color: rgba(0, 0, 0, 0.5);
  }
}

src/index.scss

@import "./css/viewer.scss";

src/js/methods.js

// 在前面已经为元素添加点击事件,点击图片时触发view方法
import {addClass} from "./utils";
export default {
    show() {
        console.log('method', 'show');
        // 容器未添加调用build()
        if (!this.ready) {
            this.build();
        }
        // 隐藏滚动条
        this.open();
        // 标记显示状态
        this.isShown = true;
        return this;
    },
    view() {
        console.log('method', 'view');
        // 容器未显示调用show()
        if (!this.isShown) {
            this.show();
        }
        return this;
    },
    open() {
        const {body} = this;
        // 添加样式,隐藏页面滚动条
        addClass(body, 'viewer-open');
        body.style.paddingRight = `${this.scrollbarWidth
            + (parseFloat(this.initialBodyPaddingRight) || 0)}px`;
    }
}

启动测试环境后点击图片,Viewer会加载container并设置背景色

4.Close

本小节为Container添加关闭按钮

4.1添加按钮

  • container中增加关闭按钮div,调整div样式
  • div添加伪元素.viewer-close::before

src/js/template.js

export default (`
<div class="viewer-container" touch-action="none">
  <div role="button" class="viewer-button" data-viewer-action="mix"></div>
</div>
`)

src/js/viewer.js

...
class Viewer {
	...
    build() {
        ...
        const viewer = template.querySelector('.viewer-container');
        const button = template.querySelector('.viewer-button');
        this.viewer = viewer;
        this.button = button;
        // 添加样式
        addClass(button, 'viewer-close');
        addClass(viewer, 'viewer-fixed');
        ...
    }
}
...

src/css/viewer.scss

...
.viewer {
  &-button {			-调整div样式
    background-color: rgba(0, 0, 0, 0.5);
    width: 80px;			-宽度
    height: 80px;			-高度
    position: absolute;		-绝对定位
    border-radius: 50%;		-调为圆形
    top: -40px;				-向上移动
    right: -40px;			-向右移动
    cursor: pointer;		-鼠标样式
    overflow: hidden;		-隐藏滚动条

    &:focus, &:hover {	-状态颜色
      background-color: rgba(0, 0, 0, 0.8);
    }

    &::before {			-内部伪元素位置
      left: 15px;
      bottom: 15px;
      position: absolute;
    }
  }
  &-close {
    &::before {			-伪元素背景图
      background-image: url('images/icons.png');
      background-repeat: no-repeat;		-是否重复
      background-size: 280px;			-背景图大小
      color: transparent;
      display: block;
      font-size: 0;
      line-height: 0;
      height: 20px;			-伪元素高度
      width: 20px;			-伪元素宽度
    }
  }
  &-close::before {		-调整伪元素背景图位置
    background-position: -260px 0;		-背景图位置
    content: 'Close';
  }
}

karma.conf.js

// 对图片所在文件夹进行设置
module.exports = function (config) {
    config.set({
        ...
        files: [
            'src/index.js',
            'src/index.scss',
            './tests/help.js',
            './tests/*.spec.js',
            {		-排除images文件夹
                pattern: '*/images/*',
                included: false
            }
        ]
        ...
    })
}

设计思路

  • 用绝对位置调整button位置至右侧
  • 用border-radius将div调整为圆形
  • 向上向右移动宽高的一半,保留四分之一
  • 伪元素背景图设置为包含多个图标的图片icons.png
  • 调整伪元素及其背景图片大小位置

4.2点击事件

本小节为关闭按钮添加点击事件,获取按钮 data-viewer-action 属性

src/js/utils.js

// 获取小写字母加数字,大写字母
const REGEXP_HYPHENATE = /([a-z\d])([A-Z])/g;

// 字符串从驼峰式(camelCase)转为短横线式(kebab-case)
export function hyphenate(value) {
    return value.replace(REGEXP_HYPHENATE, '$1-$2').toLowerCase();
}

// 获取元素属性
export function getData(element, name) {
    if (element.dataset) {
        return element.dataset[name];
    }

    return element.getAttribute(`data-${hyphenate(name)}`);
}

src/js/handlers.js

import {getData} from "./utils";

export default {
    click(event) {		-点击事件处理函数
        const {target} = event
        console.log(getData(target, 'viewerAction'));
    }
}

src/js/events.js

import {addListener} from "./utils";

export default {
    bind() {
        // 绑定处理函数handlers.click到button点击事件上
        addListener(this.button, 'click', this.click.bind(this))
    }
}

src/js/methods.js

import {addClass} from "./utils";

export default {
    show() {
        console.log('method', 'show');
        if (!this.ready) {
            this.build();
        }
        this.open();
        // 显示container时触发bind
        this.bind();
        this.isShown = true;
        return this;
    },
    ...
}

src/js/viewer.js

import TEMPLATE from './template'
import {addClass, addListener} from "./utils";
import methods from "./methods";
import events from "./events";
import handlers from "./handlers";
...
// 将事件绑定及处理函数设置为Viewer的属性
Object.assign(Viewer.prototype, events, handlers, methods);

export default Viewer;

启动测试环境点击关闭按钮时,打印模版中关闭按钮元素的 data-viewer-action 属性:mix

4.3Close函数

src/js/utils.js

// 移除元素class,改变样式
export function removeClass(element, value) {
    if (element.classList) {
        element.classList.remove(value)
        return
    }
    if (element.className.indexOf(value) >= 0) {
        element.className = element.className.replace(value, '')
    }
}

src/css/viewer.js

.viewer {
  ...
  &-hide {
    display: none;
  }
  &-backdrop {
    background-color: rgba(0, 0, 0, 0.5);
  }
}

src/js/viewer.js

...
class Viewer {
    ...
    build() {
        const {element} = this;
        const template = document.createElement('div');
        template.innerHTML = TEMPLATE;

        const viewer = template.querySelector('.viewer-container');
        const button = template.querySelector('.viewer-button');
        this.viewer = viewer;
        this.button = button;
        addClass(button, 'viewer-close');
        addClass(viewer, 'viewer-fixed');
        addClass(viewer, 'viewer-backdrop');
		// 初始化Viewer时默认不显示
        addClass(viewer, 'viewer-hide');
        viewer.style.zIndex = '2021';
        let container = element.ownerDocument.querySelector('body');
        container.appendChild(viewer);
        this.ready = true;
    }
}

Object.assign(Viewer.prototype, events, handlers, methods);

export default Viewer;

src/js/methods.js

import {addClass, removeClass} from "./utils";

export default {
    show() {
        console.log('method', 'show');
        if (!this.ready) {
            this.build();
        }
        this.open();
        this.bind();
        // 点击图片时显示Viewer
        removeClass(this.viewer, 'viewer-hide')
        this.isShown = true;
        return this;
    },
    hide() {
        console.log('method', 'hide');
        // 点击关闭时隐藏Viewer
        addClass(this.viewer, 'viewer-hide')
        this.close()
        this.isShown = false
        return this;
    },
    ...
    close() {
    	// 修改页面滚动条和右边距
        const {body} = this;
        removeClass(body, 'viewer-open');
        body.style.paddingRight = this.initialBodyPaddingRight;
    }
}

src/js/handlers.js

import {getData} from "./utils";

export default {
    click(event) {
        const {target} = event
        const action = getData(target, 'viewerAction')
        switch (action) {
            case 'mix':
                // 点击关闭按钮时调用hide
                this.hide()
        }
    }
}

启动测试环境,测试关闭按钮

5.Image

5.1显示图片

本小节在容器中添加一画布,点击原图片显示Container时显示图片

src/js/template.js

// 添加canvas
export default (`
<div class="viewer-container" touch-action="none">
  <div class="viewer-canvas"></div>
  <div class="viewer-footer">
    <div class="viewer-navbar">
      <ul class="viewer-list"></ul>
    </div>
  </div>
  <div role="button" class="viewer-button" data-viewer-action="mix"></div>
</div>
`)

src/js/utils.js

export const isNaN = Number.isNaN || Window.isNaN;
export function isNumber(value) {
    return typeof value === 'number' && !isNaN(value)
}
export function isObject(value) {
    return typeof value === 'object' && value !== null
}
export function isFunction(value) {
    return typeof value === 'function'
}
export function forEach(data, callback) {
    if (data && isFunction(callback)) {
        // 遍历数组对象
        if (Array.isArray(data) || isNumber(data.length)) {
            const {length} = data
            for (let i = 0; i < length; i++) {
                // 执行call时,function内部this变量指向call第一个参数data
                // 其余参数根据function实际参数进行接受
                if (callback.call(data, data[i], i, data) === false) {
                    break
                }
            }
        } else if (isObject(data)) {
            // 遍历普通对象
            Object.keys(data).forEach((key) => {
                callback.call(data, data[key], key, data)
            })
        }
    }
    return data
}
// 元素设置属性
export function setData(element, name, data) {
    if (element.dataset) {
        element.dataset[name] = data
    } else {
        element.setAttribute(`data-${hyphenate(name)}`, data)
    }
}

src/js/viewer.js

import TEMPLATE from './template'
import {addClass, addListener, forEach, setData} from "./utils";
import methods from "./methods";
import events from "./events";
import handlers from "./handlers";
import render from "./render";

class Viewer {
    ...
    init() {
        const {element} = this;
        const isImg = element.tagName.toLowerCase() === 'img';
        const images = [];
        forEach(isImg ? [element] : element.querySelector('img'), (image) => {
            images.push(image);
        })
        this.isImg = isImg;
        this.length = images.length;
        this.images = images;
        ...
    }
    build() {
        ...
        const viewer = template.querySelector('.viewer-container');
        const button = template.querySelector('.viewer-button');
        const canvas = template.querySelector('.viewer-canvas');
        this.viewer = viewer;
        this.button = button;
        this.canvas = canvas;
        this.list = template.querySelector('.viewer-list');
        // 设置dataViewerAction属性
        setData(canvas, 'viewerAction', 'hide');
        ...
    }
}
// 添加render
Object.assign(Viewer.prototype, render, events, handlers, methods);

export default Viewer;

src/js/render.js

import {forEach} from "./utils";

export default {
    render() {
        this.initContainer()
        this.initViewer()
        this.initList()
    },
    initContainer() {
        // 设置container宽高度
        this.containerData = {
            width: window.innerWidth,
            height: window.innerHeight
        }
    },
    initViewer() {
        // 设置Viewer宽高度
        this.viewerData = Object.assign({}, this.containerData)
    },
    initList() {
        // 遍历图片
        const {element, list} = this
        const items = []
        forEach(this.images, (image, index) => {
            const {src} = image
            const item = document.createElement('li')
            const img = document.createElement('img')
            img.setAttribute('data-index', index)
            img.setAttribute('data-original-url', src)
            img.setAttribute('data-viewer-action', 'viewer')
            img.setAttribute('role', 'button')
            item.appendChild(img)
            list.appendChild(item)
            items.push(item)
        })
        this.items = items
    }
}

src/js/methods.js

import {addClass, getData, removeClass} from "./utils";

export default {
    show() {
        if (!this.ready) {
            this.build();
        }
        this.open();
        this.bind();
        // 调用render
        this.render();
        removeClass(this.viewer, 'viewer-hide');
        this.isShown = true;
        return this;
    },
    ...
    view() {
        if (!this.isShown) {
            this.show();
        }
        const {element, canvas} = this;
        this.index = 0;
        const item = this.items[this.index];
        const img = item.querySelector('img');
        const image = document.createElement('img');
        image.src = getData(img, 'originalUrl');
        image.alt = img.getAttribute('alt');
        this.image = image;
        canvas.innerHTML = '';
        canvas.appendChild(image);
        return this;
    },
    ...
}

src/css/viewer.scss

.viewer {
  ...
  &-container {...}
  &-canvas {			-设置canvas及内部图片样式
    position: absolute;
    overflow: hidden;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;

    & > img {
      height: auto;
      margin: 15px auto;
      max-width: 90% !important;
      width: auto;
    }
  }
  ...
}

5.2加载动画

本小节在显示图片前显示加载动画

src/css/viewer.scss

.viewer {
  ...
  &-invisible {
    visibility: hidden;
  }

  @keyframes viewer-spinner {
    0% {
      transform: rotate(0deg);
    }
    100% {
      transform: rotate(360deg);
    }
  }

  &-loading {
    &::after {
      animation: viewer-spinner 1s linear infinite;
      display: inline-block;
      position: absolute;
      content: '';
      height: 40px;
      width: 40px;
      top: 50%;
      left: 50%;
      z-index: 1;
      border: 4px solid rgba(255, 255, 255, 0.1);
      border-left-color: rgba(255, 255, 255, 0.5);
      border-radius: 50%;
      margin-left: -20px;
      margin-top: -20px;
    }
  }
}

src/js/methods.js

export default {
    ...
    view() {
        ...
        this.image = image
        this.index = 0
        // 隐藏图片
        addClass(image, 'viewer-invisible')
        // 添加加载动画
        addClass(canvas, 'viewer-loading')
        canvas.innerHTML = ''
        canvas.appendChild(image)
        return this;
    },
    ...
}

5.3自适应

本小节根据Viewer大小,自适应调整图片大小及位置

设计思路

Viewer-transition 过渡动画完成时触发 transitionend 事件 shown

Items-image 加载完成时触发 load 事件 loadImage

保持图片宽高比调整图片大小及位置

计算图片宽度和高度(保持宽高比)

  1. 调整图片高度到 Viewer.height , 如果图片宽度大于 Viewer.width , 按照2号方案执行
  2. 调整图片宽度到 Viewer.width , 设置图片高度
// 0.保持宽高比
image.width/image.height = width/height (调整后的宽高度)
// 1.按高度调整
height = Viewer.height
width = Viewer.height*(image.width/image.height) = Viewer.height*aspectRatio
// 2.按宽度调整
width = Viewer.width
height = Viewer.width/(image.width/image.height) = Viewer.width/aspectRatio

load事件

// 任何带有 URL 的元素(比如图像、脚本、框架、内联框架)已加载时触发 load 事件
let img = document.createElement('img');
img.src = 'https://fengyuanchen.github.io/viewerjs/images/tibet-1.jpg'
img.addEventListener('load', (event) => {
    console.log(event)
})
document.body.appendChild(img)

transitionend

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>test transition</title>
    <style type="text/css">
        #viewer {
            width: 800px; height: 600px;
            background-color: gray; opacity: 1;
            transition: 1s;
        }
        #viewer:hover { width: 900px; }
    </style>
</head>
<body>
<div id="viewer"></div>
</body>
<script type="text/javascript">
    // transition 完成时触发 transitionend 事件
    document.getElementById('viewer').addEventListener('transitionend', (event) => {
        console.log(event)
    })
</script>
</html>

src/js/utils.js

// 添加事件
export function addListener(element, type, listener, options = {}) {
    console.log('utils', 'addListener');
    element.addEventListener(type, listener, options);
}
// 移除事件
export function removeListener(element, type, listener, options = {}) {
    console.log('utils', 'removeListener');
    element.removeEventListener(type, listener, options)
}
// 触发事件,常用于触发自定义事件
export function dispatchEvent(element, type, data) {
    console.log('utils', 'dispatchEvent');
    let event;
    if (isFunction(Event) && isFunction(CustomEvent)) {
        event = new CustomEvent(type, {
            detail: data,
            bubbles: true,
            cancelable: true
        })
    } else {
        event = document.createEvent('CustomEvent')
        event.initCustomEvent(type, true, true, data)
    }
    return element.dispatchEvent(event)
}

export function toggleClass(element, value, added) {
    if (!value) return
    if (isNumber(element.length)) {
        forEach(element, (elem) => {
            toggleClass(elem, value, added)
        })
        return;
    }
    if (added) {
        addClass(element, value)
    } else {
        removeClass(element, value)
    }
}

export function getImageNaturalSizes(image, callback) {
    const newImage = document.createElement('img')
    if (image.naturalWidth) {
        callback(image.naturalWidth, image.naturalHeight)
        return newImage;
    }
    const body = document.body || document.documentElement
    newImage.onload = () => {
        callback(newImage.width, newImage.height)
    }
    newImage.src = image.src
    newImage.style.cssText = (`
      top:0;left:0;opacity:0;
      z-index:-1;position:absolute;
      max-height:none!important;
      max-width:none!important;
      min-height:0!important;
      min-width:0!important;
    `)
    body.appendChild(newImage)

    return newImage
}

export function getTransforms({rotate, scaleX, scaleY, translateX, translateY}) {
    const values = []

    if (isNumber(translateX) && translateX !== 0) {
        values.push(`translateX(${translateX}px)`)
    }
    if (isNumber(translateY) && translateY !== 0) {
        values.push(`translateY(${translateY}px)`)
    }
    if (isNumber(rotate) && rotate !== 0) {
        values.push(`rotate(${rotate}deg`)
    }
    if (isNumber(scaleX) && scaleX !== 0) {
        values.push(`scaleX(${scaleX})`)
    }
    if (isNumber(scaleY) && scaleY !== 0) {
        values.push(`scaleY(${scaleY})`)
    }
    const transform = values.length ? values.join(' ') : 'none'
    return {WebkitTransform: transform, msTransform: transform, transform}
}

const REGEXP_SUFFIX = /^(?:width|height|left|top|marginLeft|marginTop)$/;

export function setStyle(element, styles) {
    const {style} = element;
    forEach(styles, (value, property) => {
        if (REGEXP_SUFFIX.test(property) && isNumber(value)) {
            value += 'px';
        }
        style[property] = value;
    });
}

src/js/viewer.js

class Viewer {
    constructor(element) {
        this.element = element
        this.fading = false
        this.hiding = false
        this.imageData = {}
        this.isImg = false
        this.length = 0
        this.isShown = false
        this.ready = false
        this.showing = false
        this.timeout = false
        this.viewed = false
        this.viewing = false
        this.zooming = false
        this.init()
    }
    ...
 	build() {
    	...
    	this.canvas = canvas
        this.footer = template.querySelector('.viewer-footer')
        this.list = template.querySelector('.viewer-list')
        addClass(viewer, 'viewer-fade')
    	...
    }
}

src/css/viewer.scss

.viewer {
    ...
    &-footer {
        left: 0;
        right: 0;
        bottom: 0;
        overflow: hidden;
        position: absolute;
        text-align: center;
    }

    &-navbar {
        background-color: rgba(0, 0, 0, 0.5);
        overflow: hidden;
    }

    &-list {
        box-sizing: content-box;
        height: 50px;
        margin: 0;
        overflow: hidden;
        padding: 1px 0;

        & > li {
            color: transparent;
            cursor: pointer;
            float: left;
            font-size: 0;
            height: 50px;
            width: 30px;
            line-height: 0;
            overflow: hidden;
            opacity: 0.5;
            transition: opacity 0.15s;

            &:hover {
                opacity: 0.75;
            }
        }
    }
	...
    &-invisible {
        visibility: hidden;
    }

    &-fade {
        opacity: 0;
    }

    &-in {
        opacity: 1;
    }

    &-transition {
        transition: all 0.3s;
    }
	...
}

src/js/handlers.js

import {getData, getImageNaturalSizes, getTransforms, removeClass, setStyle} from "./utils";

export default {
    click(event) {
        const {target} = event
        const action = getData(target, 'viewerAction')
        switch (action) {
            case 'mix':
                this.hide()
        }
    },
    load() {
        // image加载完时执行
        const {image, viewerData} = this
        removeClass(image, 'viewer-invisible')
        removeClass(this.canvas, 'viewer-loading')

        image.style.cssText = (`
          height:0;width:0;
          position:absolute;
          max-width:none!important;
          margin-left:${viewerData.width / 2}px;
          margin-top:${viewerData.height / 2}px;
        `)

        this.initImage(() => {
            this.renderImage(() => {
                this.viewed = true
                this.viewing = false
            })
        })
    },
    loadImage(event) {
        // list-img加载完时执行
        const image = event.target
        const parent = image.parentNode
        const parentWidth = parent.offsetWidth || 30
        const parentHeight = parent.offsetHeight || 50
        const filled = !!getData(image, 'filled')

        getImageNaturalSizes(image, (naturalWidth, naturalHeight) => {
            const aspectRatio = naturalWidth / naturalHeight
            let width = parentWidth
            let height = parentHeight
            if (parentHeight * aspectRatio > parentWidth) {
                if (filled) {
                    width = parentHeight * aspectRatio
                } else {
                    height = parentWidth / aspectRatio
                }
            } else if (filled) {
                height = parentWidth / aspectRatio
            } else {
                width = parentHeight * aspectRatio
            }

            setStyle(image, Object.assign({width, height}, getTransforms({
                translateX: (parentWidth - width) / 2,
                translateY: (parentHeight - height) / 2
            })))
        })
    }
}

src/js/render.js

import {addListener, forEach, getImageNaturalSizes, getTransforms, removeClass, setData, setStyle} from "./utils";

export default {
    ...
    initList() {
        ...
        this.items = items
        forEach(items, (item) => {
            const image = item.firstElementChild
            setData(image, 'filled', true)
            // image加载完时触发load
            addListener(image, 'load', (event) => {
                this.loadImage(event)
            }, {once: true})
        })
    },
    renderList() {
        console.log('render', 'renderList')
        const width = this.items[0].offsetWidth || 30
        const outerWidth = width + 1
        // 设置viewer-list样式
        setStyle(this.list, Object.assign({
            width: outerWidth * this.length
        }, getTransforms({
            translateX: ((this.viewerData.width - width) / 2) - outerWidth
        })))
    },
    resetList() {
        console.log('render', 'resetList')
        const {list} = this
        removeClass(list, 'viewer-transition')
        setStyle(list, getTransforms({translateX: 0}))
    },
    initImage(done) {
        console.log('render', 'initImage')
        const {image, viewerData} = this
        const footerHeight = this.footer.offsetHeight
        const viewerWidth = viewerData.width
        const viewerHeight = Math.max(viewerData.height - footerHeight, footerHeight)
        const oldImageData = this.imageData || {}
        let sizingImage

        sizingImage = getImageNaturalSizes(image, (naturalWidth, naturalHeight) => {
            const aspectRatio = naturalWidth / naturalHeight
            let width = viewerWidth
            let height = viewerHeight
            this.imageInitialzing = false
            if (viewerHeight * aspectRatio > viewerWidth) {
                // 按宽度调整
                height = viewerWidth / aspectRatio
            } else {
                // 按高度调整
                width = viewerHeight * aspectRatio
            }
            width = Math.min(width * 0.9, naturalWidth)
            height = Math.min(height * 0.9, naturalHeight)
            const imageData = {
                naturalWidth,
                naturalHeight,
                aspectRatio,
                ratio: width / naturalWidth,
                width,
                height,
                left: (viewerWidth - width) / 2,
                top: (viewerHeight - height) / 2
            }
            const initialImageData = Object.assign({}, imageData)
            this.imageData = imageData
            this.initialImageData = initialImageData

            if (done) done()
        })
    },
    renderImage(done) {
        console.log('render', 'renderImage')
        const {image, imageData} = this
        setStyle(image, Object.assign({
            width: imageData.width,
            height: imageData.height,
            marginLeft: imageData.left,
            marginTop: imageData.top
        }, getTransforms(imageData)))
        if (done) {
            if (this.viewing || this.zooming) {
                addListener(image, 'transitionend', (event) => {
                    this.imageRendering = false
                    done()
                }, {once: true})
            } else {
                done()
            }
        }
    },
    resetImage() {
        console.log('render', 'resetImage')
        if (this.viewing || this.viewed) {
            const {image} = this
            if (this.viewing) {
                this.viewing.abort()
            }
            image.parentNode.removeChild(image)
            this.image = null
        }
    }
}

src/js/methods.js

import {addClass, addListener, getData, removeClass} from "./utils";

export default {
    show() {
        console.log('method', 'show');
        if (this.showing || this.isShown) {
            return this
        }
        if (!this.ready) {
            this.build()
            if (this.ready) this.show()
            return this
        }
        this.showing = true
        this.open()
        removeClass(this.viewer, 'viewer-hide')

        const {viewer} = this
        const shown = this.shown.bind(this)
        // 添加动画
        addClass(viewer, 'viewer-transition')
        // 强制css3执行动画
        viewer.initialOffsetWidth = viewer.offsetWidth
        addListener(viewer, 'transitionend', shown, {once: true})
        addClass(viewer, 'viewer-in')
        return this;
    },
    hide() {
        console.log('method', 'hide');
        if (this.hiding || !(this.isShown || this.showing)) {
            return this
        }
        this.hiding = true
        removeClass(this.viewer, 'viewer-in')
        this.hidden()
        return this;
    },
    view() {
        console.log('method', 'view');
        if (!this.isShown) {
            return this.show()
        }
        const {element, canvas} = this
        this.index = 0
        const item = this.items[this.index]
        const img = item.querySelector('img')
        const image = document.createElement('img')
        image.src = getData(img, 'originalUrl')
        image.alt = img.getAttribute('alt')
        this.image = image
        addClass(image, 'viewer-invisible')
        addClass(canvas, 'viewer-loading')
        canvas.innerHTML = ''
        canvas.appendChild(image)

        this.renderList()
		// image加载完触发load事件
        addListener(image, 'load', this.load.bind(this), {once: true})
		// 定时清除invisible
        if (this.timeout) clearTimeout(this.timeout)
        this.timeout = setTimeout(() => {
            removeClass(image, 'viewer-invisible')
            this.timeout = false
        }, 1000)

        return this;
    },
    ...
    shown() {
        console.log('method', 'shown')
        this.fulled = true
        this.isShown = true
        this.render()
        this.bind()
        this.showing = false
        if (this.ready && this.isShown) {
            // 动画结束调用view
            this.view()
        }
    },
    hidden() {
        console.log('method', 'hidden')
        this.fulled = false
        this.viewed = false
        this.isShown = false
        this.close()
        this.unbind()
        // 隐藏viewer
        addClass(this.viewer, 'viewer-hide')
        // 初始化list
        this.resetList()
        this.resetImage()
        this.hiding = false
    }
}

6.Toolbar

本节为Viewer添加工具栏及其事件

6.1工具栏

src/js/costants.js

// 定义工具栏按钮名称常量
export const BUTTONS = [
    'zoom-in',
    'zoom-out',
    'one-to-one',
    'reset',
    'prev',
    'play',
    'next',
    'rotate-left',
    'rotate-right',
    'flip-horizontal',
    'flip-vertical'
]

src/js/template.js

export default (`
<div class="viewer-container" touch-action="none">
  <div class="viewer-canvas"></div>
  <div class="viewer-footer">
    <div class="viewer-title"></div>
    <div class="viewer-toolbar"></div>
    <div class="viewer-navbar">
      <ul class="viewer-list"></ul>
    </div>
  </div>
  <div role="button" class="viewer-button" data-viewer-action="mix"></div>
</div>
`)

src/css/viewer.scss

.viewer {
  // 工具栏图标样式
  &-zoom-in,
  &-zoom-out,
  &-one-to-one,
  &-reset,
  &-prev,
  &-play,
  &-next,
  &-rotate-left,
  &-rotate-right,
  &-flip-horizontal,
  &-flip-vertical,
  &-close {
    &::before {
      background-image: url('images/icons.png');
      background-repeat: no-repeat;
      background-size: 280px;
      color: transparent;
      display: block;
      font-size: 0;
      height: 20px;
      line-height: 0;
      width: 20px;
    }
  }

  &-zoom-in::before {
    background-position: 0 0;
    content: 'Zoom In';
  }

  &-zoom-out::before {
    background-position: -20px 0;
    content: 'Zoom Out';
  }

  &-one-to-one::before {
    background-position: -40px 0;
    content: 'One to One';
  }

  &-reset::before {
    background-position: -60px 0;
    content: 'Reset';
  }

  &-prev::before {
    background-position: -80px 0;
    content: 'Previous';
  }

  &-play::before {
    background-position: -100px 0;
    content: 'Play';
  }

  &-next::before {
    background-position: -120px 0;
    content: 'Next';
  }

  &-rotate-left::before {
    background-position: -140px 0;
    content: 'Rotate Left';
  }

  &-rotate-right::before {
    background-position: -160px 0;
    content: 'Rotate Right';
  }

  &-flip-horizontal::before {
    background-position: -180px 0;
    content: 'Flip Horizontal';
  }

  &-flip-vertical::before {
    background-position: -200px 0;
    content: 'Flip Vertical';
  }

  &-close::before {
    background-position: -260px 0;
    content: 'Close';
  }
  ...
  &-footer {
    left: 0;
    right: 0;
    bottom: 0;
    overflow: hidden;
    position: absolute;
    text-align: center;
  }

  &-title {
    color: #ccc;
    display: inline-block;
    font-size: 12px;
    line-height: 1;
    margin: 0 5% 5px;
    max-width: 90%;
    opacity: 0.8;
    overflow: hidden;
    text-overflow: ellipsis;
    transition: opacity 0.15s;
    white-space: nowrap;

    &:hover {
      opacity: 1;
    }
  }
  // 工具栏样式
  &-toolbar {
    & > ul {
      display: inline-block;
      margin: 0 auto 5px;
      overflow: hidden;
      padding: 3px 0;

      & > li {
        background-color: rgba(0, 0, 0, 0.5);
        border-radius: 50%;
        cursor: pointer;
        float: left;
        width: 24px;
        height: 24px;
        overflow: hidden;
        transition: background-color 0.15s;

        &:hover {
          background-color: rgba(0, 0, 0, 0.8);
        }

        &::before {
          margin: 2px;
        }

        & + li {
          margin-left: 1px;
        }
      }
    }
  }
  ...
}

src/js/viewer.js

class Viewer {
    ...
    build() {
        const {element} = this;
        const template = document.createElement('div');
        template.innerHTML = TEMPLATE;

        const viewer = template.querySelector('.viewer-container');
        const title = template.querySelector('.viewer-title')
        const toolbar = template.querySelector('.viewer-toolbar')
        const navbar = template.querySelector('.viewer-navbar')
        const button = template.querySelector('.viewer-button');
        const canvas = template.querySelector('.viewer-canvas');
        this.viewer = viewer;
        this.title = title
        this.toolbar = toolbar
        this.navbar = navbar
        this.button = button;
        this.canvas = canvas;
        this.footer = viewer.querySelector('.viewer-footer')
        this.list = template.querySelector('.viewer-list');
        addClass(viewer, 'viewer-backdrop');
        setData(canvas, 'viewerAction', 'hide');

        const list = document.createElement('ul')
        forEach(BUTTONS, (value, index) => {
            const item = document.createElement('li')
            item.setAttribute('role', 'button')
            addClass(item, `viewer-${value}`)
            setData(item, 'viewerAction', value)
            list.appendChild(item)
        })
        toolbar.appendChild(list)
		...
    }
}

src/js/handlers.js

export default {
    click(event) {
        console.log('handlers', 'click')
        const {target} = event
        let action = getData(target, 'viewerAction')
        switch (action) {
            case 'mix':
                this.hide()
                break
            case 'zoom-in':
                console.log('zoom-in')
                break
            case 'zoom-out':
                console.log('zoom-out');
                break
            case 'one-to-one':
                console.log('one-to-one');
                break
            case 'reset':
                console.log('reset');
                break
            case 'prev':
                console.log('prev');
                break
            case 'play':
                console.log('play');
                break
            case 'next':
                console.log('next');
                break
            case 'rotate-left':
                console.log('rotate-left');
                break
            case 'rotate-right':
                console.log('rotate-right');
                break
            case 'flip-horizontal':
                console.log('flip-horizontal');
                break
            case 'flip-vertical':
                console.log('flip-vertical');
                break
        }
    },
    ...
}

6.2Zoom函数

src/js/methods.js

export default {
    ...
    zoom(ratio, hasTooltip = false, _originalEvent = null) {
        const {imageData} = this
        ratio = Number(ratio)
        if (ratio < 0) {
            ratio = 1 / (1 - ratio)
        } else {
            ratio = 1 + ratio
        }
        this.zoomTo((imageData.width * ratio) / imageData.naturalWidth, hasTooltip, _originalEvent)

        return this
    },
    zoomTo(ratio, hasTooltip = false, _originalEvent = false, _zoomable = false) {
        const {imageData} = this
        const {width, height, naturalWidth, naturalHeight} = imageData
        ratio = Math.max(0, ratio)

        if (isNumber(ratio) && this.viewed) {
            if (!_zoomable) {
                // 限制缩放最大最小比例
                ratio = Math.min(Math.max(ratio, 0.01), 100)
            }
            this.zooming = true
            const newWidth = naturalWidth * ratio
            const newHeight = naturalHeight * ratio
            const offsetWidth = newWidth - width
            const offsetHeight = newHeight - height

            imageData.left -= offsetWidth / 2
            imageData.top -= offsetHeight / 2

            imageData.width = newWidth
            imageData.height = newHeight
            imageData.ratio = ratio
            this.renderImage(() => {
                this.zooming = false
            })
        }

        return this
    },
    ...
}

src/js/handlers.js

export default {
    click(event) {
        const {target} = event
        let action = getData(target, 'viewerAction')
        switch (action) {
            case 'mix':
                this.hide()
                break
            case 'zoom-in':
                this.zoom(0.1, true)
                break
            case 'zoom-out':
                this.zoom(-0.1, true)
                break
        }
    },
    ...
}
# 导读:内容简介 [viewerjs](https://github.com/fengyuanchen/viewerjs)是一款强大的图形查看器,本文是对其源码进行解读和学习 viewerjs可以学习搭建一个框架的设计流程,提升自身的编程能力 学习本项目需要熟练掌握html、js、css,了解nodejs、npm、es6、scss # 一 搭建项目 本项目使用npm管理相关依赖,因此初始化项目前需要安装nodejs环境 使用命令行工具cmd创建文件夹viewer作为项目根目录,参数使用默认回车即可 ```shell D:\> mkdir viewer D:\> cd viewer D:\viewer> npm init ... package name: (viewer) version: (1.0.0) description: entry point: (index.js) test command: git repository: keywords: author: license: (ISC) ``` 项目初始化以后会生成package.json ```json # 注:以下开始 -xxx 为注释信息 { "name": "viewer", -项目名称 "version": "1.0.0", -项目版本 "description": "", -项目描述 "main": "index.js", -入口文件 "scripts": { -运行命令 "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", -作者名称 "license": "ISC" -项目协议 } ``` > .editorconfig 定义文件格式 ```yaml # 根目录,往下匹配文件 root = true # 匹配全部文件 [*] # 设置字符集 charset = utf-8 # 设置换行符 end_of_line = lf # 设置缩进空格数 indent_size = 4 # 设置缩进风格 space/tab indent_style = space # 设置文件结尾插入空行 insert_final_newline = true # 设置删除一行中的前后空格 trim_trailing_whitespace = true ``` # 二 测试环境 本项目使用以下测试库,编写对应测试案例,可以对项目功能进行自动化测试,提升开发效率: [karma](https://www.npmjs.com/package/karma)提供多种浏览器运行环境,使用插件可以在控制台查看对应测试结果 karma以[mocha](https://mochajs.org/)测试库和[chai](https://www.chaijs.com/)断言库作为插件,搭建测试环境 ## 1.开发依赖 ```shell D:\viewer> cnpm install --save-dev karma mocha chai karma-mocha karma-chai karma-mocha-reporter karma-chrome-launcher D:\viewer> npx karma ``` 安装成功以后package.json中添加devDependencies,可以使用npx命令查看依赖包参数信息 ```json { ... "devDependencies": { -开发依赖 "chai": "^4.3.4", "karma": "^6.3.2", "karma-chai": "^0.1.0", "karma-chrome-launcher": "^3.1.0", "karma-mocha": "^2.0.1", "karma-mocha-reporter": "^2.2.5", "mocha": "^8.4.0" } ... } ``` ## 2.初始化karma ```shell D:\viewer> karma init ... # 测试框架 Which testing framework do you want to use ? > mocha # 是否使用Require.js Do you want to use Require.js ? > no # 是否自动捕获浏览器 Do you want to capture any browsers automatically ? > Chrome > # 测试文件的位置 What is the location of your source and test files ? > ./tests/*.spec.js > # 是否应排除以前模式包含的任何文件 Should any of the files included by the previous patterns be excluded ? > node_modules > # 你想让Karma监视所有的文件并运行变更测试吗 Do you want Karma to watch all the files and run the tests on change ? > yes ... ``` 生成后的配置文件karma.conf.js ```js module.exports = function (config) { config.set({ basePath: '', -项目路径 frameworks: ['mocha'], -测试框架,添加chai files: ['./tests/*.spec.js'], -文件位置 exclude: ['node_modules'], -排除文件 preprocessors: {}, -预处理器 reporters: ['progress'], -测试报告,改为mocha port: 9876, -使用默认值 colors: true, -使用默认值 logLevel: config.LOG_INFO, -使用默认值 autoWatch: true, -自动监测代码 browsers: ['Chrome'], -使用浏览器 singleRun: false, concurrency: Infinity }) } ``` ## 3.测试案例 本小节创建一个测试案例,启动测试环境后显示图片 > tests/help.js ```js let url = "https://fengyuanchen.github.io/viewerjs/images"; window.createContainer = function () { const container = document.createElement('div'); container.className = 'container'; document.body.appendChild(container); return container; }; window.createImage = function () { const container = window.createContainer(); const image = document.createElement('img'); image.src = `${url}/tibet-1.jpg`; container.appendChild(image); return image; }; window.createImageList = function () { const container = window.createContainer(); const list = document.createElement('ul'); list.innerHTML = (` <li><img src="${url}/tibet-1.jpg"></li> <li><img src="${url}/tibet-2.jpg"></li> <li><img src="${url}/tibet-3.jpg"></li> <li><img src="${url}/tibet-4.jpg"></li> <li><img src="${url}/tibet-5.jpg"></li> `); container.appendChild(list); return list; }; ``` > tests/image.spec.js ```js describe('image test', function () { it('image test case', function () { let image = window.createImage(); }); }); ``` > karma.conf.js ```js module.exports = function (config) { config.set({ ... files: [ './tests/help.js', -加载help.js才能引用里面的方法 './tests/*.spec.js' ] ... }) } ``` > package.json ```json { ... "scripts": { "test": "karma start" } ... } ``` > npm run test 启动测试环境 项目启动以后会自动打开Chrome浏览器,点击DEBUG查看图片 # 三 项目环境 ## 1.项目结构 ``` src/index.js/scss -入口文件 src/css/viewer.scss -css文件 src/js/viewer.js -定义框架 src/js/defaults.js -定义变量 src/js/consts.js -定义常量 src/js/utils.js -定义工具 src/js/template.js -定义模版 src/js/methods.js -定义方法 src/js/events.js -事件绑定 src/js/handlers.js -事件处理 src/js/render.js -图片渲染 tests/* -测试文件 ``` ## 2.开发依赖 项目开发中使用es6、scss: - rollup 编译 es6 为浏览器可以执行的 javascript - sass 预处理器将 scss 编译为可执行的 css - 安装 node-sass 时需要访问 github.com ```shell script D:\viewer> cnpm install --save-dev rollup rollup-plugin-babel @babel/core karma-rollup-preprocessor node-sass @metahub/karma-sass-preprocessor ``` 更新karma.conf.js ``` const babel = require('rollup-plugin-babel') module.exports = function (config) { config.set({ basePath: '', frameworks: ['mocha', 'chai'], files: [ -加载源文件及测试文件 'src/index.js', 'src/index.scss', './tests/help.js', './tests/*.spec.js' ], exclude: ['node_modules'], preprocessors: { -配置预处理器 'src/index.js': ['rollup'], 'src/index.scss': ['sass'] }, plugins: [ -加载karma插件 'karma-*', '@metahub/karma-sass-preprocessor' ], rollupPreprocessor: { -设置rollup参数 plugins: [babel()], output: { format: 'iife', name: 'Viewer', sourcemap: 'inline' } }, sassPreprocessor: { -设置sass参数 options: { sourcemap: true } }, reporters: ['mocha'], autoWatch: true, browsers: ['Chrome'], singleRun: false, concurrency: Infinity }) } ``` 为项目添加 js 检查工具 eslint 和 css 检查工具 stylelint ``` D:\viewer> cnpm install --save-dev eslint stylelint stylelint-config-standard ``` 初始化 eslint 配置文件 (.eslintrc.js) ``` D:\viewer> eslint --init √ How would you like to use ESLint? · problems √ What type of modules does your project use? · esm √ Which framework does your project use? · none √ Does your project use TypeScript? · No / Yes √ Where does your code run? · browser, node √ What format do you want your config file to be in? · JavaScript Successfully created .eslintrc file in D:\viewer ``` 也可以直接创建 .eslintrc ```json { "env": { "browser": true, "es2021": true, "node": true }, "extends": "eslint:recommended", "parserOptions": { "ecmaVersion": 12, "sourceType": "module" }, "rules": { "no-param-reassign": "off", "no-restricted-properties": "off", "no-unused-vars": "off" -关闭变量未使用 }, "overrides": [ -设置测试文件规则 { "files": "tests/**/*.spec.js", "env": { "mocha": true }, "rules": { "no-undef": "off" -关闭变量未定义 } } ] } ``` 创建stylelint配置文件.stylelintrc ```json { "extends": "stylelint-config-standard" } ``` package.json添加测试命令 ```json { ... "scripts": { "test": "karma start", "lint": "npm run lint:js && npm run lint:css", "lint:js": "eslint src tests *.js --fix", "lint:css": "stylelint src/**/*.{css,scss,html} --fix" }, ... } // 执行命令 // npm run lint // npm run lint:js // npm run lint:css ``` ## 3.申明文件 ​ [申明文件](https://www.tslang.cn/docs/handbook/declaration-files/introduction.html)可以预定义方法和类型,避免工具中出现不存在的提示 > types/index.d.ts ```typescript declare class Viewer { constructor(element: Element); } declare module 'viewer' { export default Viewer; } ``` > package.json ```json { ... "main": "index.js", "types": "types/index.d.ts", -引用申明文件 ... } ``` ## 4.调用Viewer 本小节使用es6定义Viewer,并在测试Case中调用Viewer,启动测试后查看打印信息 > src/js/viewer.js ```js // 定义Viewer class Viewer { // 默认构造器 constructor(element) { console.log('viewer', 'constructor'); this.init(); } // 初始化方法 init() { console.log('viewer', 'init'); } } // 对外暴露Viewer export default Viewer; ``` > src/index.js ```js // 入口文件引用Viewer import Viewer from "./js/viewer"; export default Viewer; ``` > tests/image.spec.js ```js // 编写测试案例 describe('image test', function () { it('image test case', function () { let image = window.createImage(); let viewer = new Viewer(image); }); }); ``` cmd 中 运行 `npm run test` 命令,在控制台可以看到测试输出结果 在测试Chrome浏览器中F12调用控制台,在Console中也可以看到结果 # 四 项目开发 ## 1.Event > 元素添加事件 - 元素上添加 ```html <input type="button" value="点击" onclick="show()"> function show() { alert('点击'); } ``` - document添加 ```html <input type="button" value="添加" id="add"> document.getElementById('add').onclick = function () { alert('添加'); }; ``` - addEventListener添加 element.addEventListener(type, listener, options) ```html <input type="button" value="元素" id="ele"> document.getElementById('ele').addEventListener('click', function () { alert('元素'); }); ``` listener默认接受参数为event,使用对象解构可以从事件中获取到点击的对象target ```js element.addEventListener('click', function (event) { console.log(event); }); // 解构对象event的target属性: const {target}=event 等价于 const target=event.target element.addEventListener('click', function ({ target }) { console.log(target); }); // function匿名函数可以使用箭头函数: (param) => {...} 等价于 fucntion (param) {...} element.addEventListener('click', ({ target }) => { console.log(target); }); // element.dispatchEvent(event)可以触发元素事件,默认返回true // element的事件可取消且调用event.preventDefault()后调用dispatchEvent返回false ``` ## 2.Viewer click 本小节对事件进行封装,且对元素添加点击事件,启动测试后查看点击后的打印结果 > src/js/utils.js ```js // 对addEventListener进行封装 export function addListener(element, type, listener, options = {}) { element.addEventListener(type, listener, options); } ``` > src/js/methods.js ```js // 定义方法 export default { show() { console.log('method', 'show'); return this; }, view() { console.log('method', 'view'); return this; } } ``` > types/index.d.ts ```typescript ... declare class Viewer { constructor(element: Element); show(): Viewer; view(): Viewer; } ... ``` > src/js/viewer.js ```js // 引用listener,methods import {addListener} from "./utils"; import methods from "./methods"; class Viewer { constructor(element) { console.log('viewer', 'constructor'); this.element = element; this.init(); } init() { console.log('viewer', 'init'); const {element} = this; // 为element添加点击事件 addListener(element, 'click', ({target}) => { if (target.tagName.toLowerCase() === 'img') { // 点击图片时调用view方法 this.view(); } }); } } // 将methods绑定为Viewer的属性 Object.assign(Viewer.prototype, methods); export default Viewer; ``` ​ 以上流程思路如下: - 封装listener事件 - 定义方法view() - init()中添加点击事件 - 点击对象为图片时调用view() `npm run test` 运行后点击图片,可以在浏览器控制台查看点击后的输出结果 ## 3.Container 本小节创建加载图片的容器,在点击图片时显示该容器 > src/js/utils.js ```js ... // 添加元素Class属性 export function addClass(element, value) { if (element.classList) { element.classList.add(value) return; } const className = element.className.trim(); if (!className) { element.className = value; } else { element.className = `${className} ${value}`; } } ``` > src/js/template.js ```js // 容器模版 export default (`<div class="viewer-container" touch-action="none"></div>`) ``` > src/js/viewer.js ```js import TEMPLATE from './template' import {addClass, addListener} from "./utils"; import methods from "./methods"; class Viewer { constructor(element) { console.log('viewer', 'constructor'); this.element = element; this.isShown = false; -标记状态 this.ready = false; -标记状态 this.init(); } init() { console.log('viewer', 'init'); const {element} = this; const {ownerDocument} = element; const body = ownerDocument.body || ownerDocument.documentElement; this.body = body; // 获取页面滚动条宽度和右边距 this.scrollbarWidth = window.innerWidth - ownerDocument.documentElement.clientWidth; this.initialBodyPaddingRight = window.getComputedStyle(body).paddingRight; addListener(element, 'click', ({target}) => { if (target.tagName.toLowerCase() === 'img') { this.view(); } }); } // ready container build() { // 获取element const {element} = this; // 创建templat const template = document.createElement('div'); // 获取容器模版 template.innerHTML = TEMPLATE; // 获取容器中div const viewer = template.querySelector('.viewer-container'); this.viewer = viewer; addClass(viewer, 'viewer-fixed'); // 为容器添加背景 addClass(viewer, 'viewer-backdrop'); // 将容器置为顶层 viewer.style.zIndex = '2021'; // 获取页面body let container = element.ownerDocument.querySelector('body'); // 追加容器到body container.appendChild(viewer); // 设置状态 this.ready = true; } } Object.assign(Viewer.prototype, methods); export default Viewer; ``` > src/css/viewer.scss ```scss // 除了添加div(container),也需要为div设置css样式 html, body { margin: 0; padding: 0; } .viewer { &-container { -容器样式 top: 0; left: 0; right: 0; bottom: 0; direction: ltr; font-size: 0; line-height: 0; overflow: hidden; position: absolute; } &-fixed { -填充界面 position: fixed; } &-open { -隐藏滚动条 overflow: hidden; } &-backdrop { -容器背景色 background-color: rgba(0, 0, 0, 0.5); } } ``` > src/index.scss ```scss @import "./css/viewer.scss"; ``` > src/js/methods.js ```js // 在前面已经为元素添加点击事件,点击图片时触发view方法 import {addClass} from "./utils"; export default { show() { console.log('method', 'show'); // 容器未添加调用build() if (!this.ready) { this.build(); } // 隐藏滚动条 this.open(); // 标记显示状态 this.isShown = true; return this; }, view() { console.log('method', 'view'); // 容器未显示调用show() if (!this.isShown) { this.show(); } return this; }, open() { const {body} = this; // 添加样式,隐藏页面滚动条 addClass(body, 'viewer-open'); body.style.paddingRight = `${this.scrollbarWidth + (parseFloat(this.initialBodyPaddingRight) || 0)}px`; } } ``` 启动测试环境后点击图片,Viewer会加载container并设置背景色 ## 4.Close 本小节为Container添加关闭按钮 ### 4.1添加按钮 - container中增加关闭按钮div,调整div样式 - div添加伪元素.viewer-close::before > src/js/template.js ```js export default (` <div class="viewer-container" touch-action="none"> <div role="button" class="viewer-button" data-viewer-action="mix"></div> </div> `) ``` > src/js/viewer.js ```js ... class Viewer { ... build() { ... const viewer = template.querySelector('.viewer-container'); const button = template.querySelector('.viewer-button'); this.viewer = viewer; this.button = button; // 添加样式 addClass(button, 'viewer-close'); addClass(viewer, 'viewer-fixed'); ... } } ... ``` > src/css/viewer.scss ```scss ... .viewer { &-button { -调整div样式 background-color: rgba(0, 0, 0, 0.5); width: 80px; -宽度 height: 80px; -高度 position: absolute; -绝对定位 border-radius: 50%; -调为圆形 top: -40px; -向上移动 right: -40px; -向右移动 cursor: pointer; -鼠标样式 overflow: hidden; -隐藏滚动条 &:focus, &:hover { -状态颜色 background-color: rgba(0, 0, 0, 0.8); } &::before { -内部伪元素位置 left: 15px; bottom: 15px; position: absolute; } } &-close { &::before { -伪元素背景图 background-image: url('images/icons.png'); background-repeat: no-repeat; -是否重复 background-size: 280px; -背景图大小 color: transparent; display: block; font-size: 0; line-height: 0; height: 20px; -伪元素高度 width: 20px; -伪元素宽度 } } &-close::before { -调整伪元素背景图位置 background-position: -260px 0; -背景图位置 content: 'Close'; } } ``` > karma.conf.js ```js // 对图片所在文件夹进行设置 module.exports = function (config) { config.set({ ... files: [ 'src/index.js', 'src/index.scss', './tests/help.js', './tests/*.spec.js', { -排除images文件夹 pattern: '*/images/*', included: false } ] ... }) } ``` > 设计思路 - 用绝对位置调整button位置至右侧 - 用border-radius将div调整为圆形 - 向上向右移动宽高的一半,保留四分之一 - 伪元素背景图设置为包含多个图标的图片icons.png - 调整伪元素及其背景图片大小位置 ### 4.2点击事件 本小节为关闭按钮添加点击事件,获取按钮 data-viewer-action 属性 > src/js/utils.js ```js // 获取小写字母加数字,大写字母 const REGEXP_HYPHENATE = /([a-z\d])([A-Z])/g; // 字符串从驼峰式(camelCase)转为短横线式(kebab-case) export function hyphenate(value) { return value.replace(REGEXP_HYPHENATE, '$1-$2').toLowerCase(); } // 获取元素属性 export function getData(element, name) { if (element.dataset) { return element.dataset[name]; } return element.getAttribute(`data-${hyphenate(name)}`); } ``` > src/js/handlers.js ```js import {getData} from "./utils"; export default { click(event) { -点击事件处理函数 const {target} = event console.log(getData(target, 'viewerAction')); } } ``` > src/js/events.js ```js import {addListener} from "./utils"; export default { bind() { // 绑定处理函数handlers.click到button点击事件上 addListener(this.button, 'click', this.click.bind(this)) } } ``` > src/js/methods.js ```js import {addClass} from "./utils"; export default { show() { console.log('method', 'show'); if (!this.ready) { this.build(); } this.open(); // 显示container时触发bind this.bind(); this.isShown = true; return this; }, ... } ``` > src/js/viewer.js ```js import TEMPLATE from './template' import {addClass, addListener} from "./utils"; import methods from "./methods"; import events from "./events"; import handlers from "./handlers"; ... // 将事件绑定及处理函数设置为Viewer的属性 Object.assign(Viewer.prototype, events, handlers, methods); export default Viewer; ``` 启动测试环境点击关闭按钮时,打印模版中关闭按钮元素的 data-viewer-action 属性:mix ### 4.3Close函数 > src/js/utils.js ```js // 移除元素class,改变样式 export function removeClass(element, value) { if (element.classList) { element.classList.remove(value) return } if (element.className.indexOf(value) >= 0) { element.className = element.className.replace(value, '') } } ``` > src/css/viewer.js ```scss .viewer { ... &-hide { display: none; } &-backdrop { background-color: rgba(0, 0, 0, 0.5); } } ``` > src/js/viewer.js ```js ... class Viewer { ... build() { const {element} = this; const template = document.createElement('div'); template.innerHTML = TEMPLATE; const viewer = template.querySelector('.viewer-container'); const button = template.querySelector('.viewer-button'); this.viewer = viewer; this.button = button; addClass(button, 'viewer-close'); addClass(viewer, 'viewer-fixed'); addClass(viewer, 'viewer-backdrop'); // 初始化Viewer时默认不显示 addClass(viewer, 'viewer-hide'); viewer.style.zIndex = '2021'; let container = element.ownerDocument.querySelector('body'); container.appendChild(viewer); this.ready = true; } } Object.assign(Viewer.prototype, events, handlers, methods); export default Viewer; ``` > src/js/methods.js ```js import {addClass, removeClass} from "./utils"; export default { show() { console.log('method', 'show'); if (!this.ready) { this.build(); } this.open(); this.bind(); // 点击图片时显示Viewer removeClass(this.viewer, 'viewer-hide') this.isShown = true; return this; }, hide() { console.log('method', 'hide'); // 点击关闭时隐藏Viewer addClass(this.viewer, 'viewer-hide') this.close() this.isShown = false return this; }, ... close() { // 修改页面滚动条和右边距 const {body} = this; removeClass(body, 'viewer-open'); body.style.paddingRight = this.initialBodyPaddingRight; } } ``` > src/js/handlers.js ```js import {getData} from "./utils"; export default { click(event) { const {target} = event const action = getData(target, 'viewerAction') switch (action) { case 'mix': // 点击关闭按钮时调用hide this.hide() } } } ``` 启动测试环境,测试关闭按钮 ## 5.Image ### 5.1显示图片 本小节在容器中添加一画布,点击原图片显示Container时显示图片 > src/js/template.js ```js // 添加canvas export default (` <div class="viewer-container" touch-action="none"> <div class="viewer-canvas"></div> <div class="viewer-footer"> <div class="viewer-navbar"> <ul class="viewer-list"></ul> </div> </div> <div role="button" class="viewer-button" data-viewer-action="mix"></div> </div> `) ``` > src/js/utils.js ```js export const isNaN = Number.isNaN || Window.isNaN; export function isNumber(value) { return typeof value === 'number' && !isNaN(value) } export function isObject(value) { return typeof value === 'object' && value !== null } export function isFunction(value) { return typeof value === 'function' } export function forEach(data, callback) { if (data && isFunction(callback)) { // 遍历数组对象 if (Array.isArray(data) || isNumber(data.length)) { const {length} = data for (let i = 0; i < length; i++) { // 执行call时,function内部this变量指向call第一个参数data // 其余参数根据function实际参数进行接受 if (callback.call(data, data[i], i, data) === false) { break } } } else if (isObject(data)) { // 遍历普通对象 Object.keys(data).forEach((key) => { callback.call(data, data[key], key, data) }) } } return data } // 元素设置属性 export function setData(element, name, data) { if (element.dataset) { element.dataset[name] = data } else { element.setAttribute(`data-${hyphenate(name)}`, data) } } ``` > src/js/viewer.js ```js import TEMPLATE from './template' import {addClass, addListener, forEach, setData} from "./utils"; import methods from "./methods"; import events from "./events"; import handlers from "./handlers"; import render from "./render"; class Viewer { ... init() { const {element} = this; const isImg = element.tagName.toLowerCase() === 'img'; const images = []; forEach(isImg ? [element] : element.querySelector('img'), (image) => { images.push(image); }) this.isImg = isImg; this.length = images.length; this.images = images; ... } build() { ... const viewer = template.querySelector('.viewer-container'); const button = template.querySelector('.viewer-button'); const canvas = template.querySelector('.viewer-canvas'); this.viewer = viewer; this.button = button; this.canvas = canvas; this.list = template.querySelector('.viewer-list'); // 设置dataViewerAction属性 setData(canvas, 'viewerAction', 'hide'); ... } } // 添加render Object.assign(Viewer.prototype, render, events, handlers, methods); export default Viewer; ``` > src/js/render.js ```js import {forEach} from "./utils"; export default { render() { this.initContainer() this.initViewer() this.initList() }, initContainer() { // 设置container宽高度 this.containerData = { width: window.innerWidth, height: window.innerHeight } }, initViewer() { // 设置Viewer宽高度 this.viewerData = Object.assign({}, this.containerData) }, initList() { // 遍历图片 const {element, list} = this const items = [] forEach(this.images, (image, index) => { const {src} = image const item = document.createElement('li') const img = document.createElement('img') img.setAttribute('data-index', index) img.setAttribute('data-original-url', src) img.setAttribute('data-viewer-action', 'viewer') img.setAttribute('role', 'button') item.appendChild(img) list.appendChild(item) items.push(item) }) this.items = items } } ``` > src/js/methods.js ```js import {addClass, getData, removeClass} from "./utils"; export default { show() { if (!this.ready) { this.build(); } this.open(); this.bind(); // 调用render this.render(); removeClass(this.viewer, 'viewer-hide'); this.isShown = true; return this; }, ... view() { if (!this.isShown) { this.show(); } const {element, canvas} = this; this.index = 0; const item = this.items[this.index]; const img = item.querySelector('img'); const image = document.createElement('img'); image.src = getData(img, 'originalUrl'); image.alt = img.getAttribute('alt'); this.image = image; canvas.innerHTML = ''; canvas.appendChild(image); return this; }, ... } ``` > src/css/viewer.scss ```scss .viewer { ... &-container {...} &-canvas { -设置canvas及内部图片样式 position: absolute; overflow: hidden; left: 0; right: 0; top: 0; bottom: 0; & > img { height: auto; margin: 15px auto; max-width: 90% !important; width: auto; } } ... } ``` ### 5.2加载动画 本小节在显示图片前显示加载动画 > src/css/viewer.scss ```scss .viewer { ... &-invisible { visibility: hidden; } @keyframes viewer-spinner { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } &-loading { &::after { animation: viewer-spinner 1s linear infinite; display: inline-block; position: absolute; content: ''; height: 40px; width: 40px; top: 50%; left: 50%; z-index: 1; border: 4px solid rgba(255, 255, 255, 0.1); border-left-color: rgba(255, 255, 255, 0.5); border-radius: 50%; margin-left: -20px; margin-top: -20px; } } } ``` > src/js/methods.js ```js export default { ... view() { ... this.image = image this.index = 0 // 隐藏图片 addClass(image, 'viewer-invisible') // 添加加载动画 addClass(canvas, 'viewer-loading') canvas.innerHTML = '' canvas.appendChild(image) return this; }, ... } ``` ### 5.3自适应 本小节根据Viewer大小,自适应调整图片大小及位置 > 设计思路 Viewer-transition 过渡动画完成时触发 transitionend 事件 shown Items-image 加载完成时触发 load 事件 loadImage 保持图片宽高比调整图片大小及位置 > 计算图片宽度和高度(保持宽高比) 1. 调整图片高度到 Viewer.height , 如果图片宽度大于 Viewer.width , 按照2号方案执行 2. 调整图片宽度到 Viewer.width , 设置图片高度 ```js // 0.保持宽高比 image.width/image.height = width/height (调整后的宽高度) // 1.按高度调整 height = Viewer.height width = Viewer.height*(image.width/image.height) = Viewer.height*aspectRatio // 2.按宽度调整 width = Viewer.width height = Viewer.width/(image.width/image.height) = Viewer.width/aspectRatio ``` > [load](https://www.w3school.com.cn/jquery/event_load.asp)事件 ```js // 任何带有 URL 的元素(比如图像、脚本、框架、内联框架)已加载时触发 load 事件 let img = document.createElement('img'); img.src = 'https://fengyuanchen.github.io/viewerjs/images/tibet-1.jpg' img.addEventListener('load', (event) => { console.log(event) }) document.body.appendChild(img) ``` > transitionend ```html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>test transition</title> <style type="text/css"> #viewer { width: 800px; height: 600px; background-color: gray; opacity: 1; transition: 1s; } #viewer:hover { width: 900px; } </style> </head> <body> <div id="viewer"></div> </body> <script type="text/javascript"> // transition 完成时触发 transitionend 事件 document.getElementById('viewer').addEventListener('transitionend', (event) => { console.log(event) }) </script> </html> ``` > src/js/utils.js ```js // 添加事件 export function addListener(element, type, listener, options = {}) { console.log('utils', 'addListener'); element.addEventListener(type, listener, options); } // 移除事件 export function removeListener(element, type, listener, options = {}) { console.log('utils', 'removeListener'); element.removeEventListener(type, listener, options) } // 触发事件,常用于触发自定义事件 export function dispatchEvent(element, type, data) { console.log('utils', 'dispatchEvent'); let event; if (isFunction(Event) && isFunction(CustomEvent)) { event = new CustomEvent(type, { detail: data, bubbles: true, cancelable: true }) } else { event = document.createEvent('CustomEvent') event.initCustomEvent(type, true, true, data) } return element.dispatchEvent(event) } export function toggleClass(element, value, added) { if (!value) return if (isNumber(element.length)) { forEach(element, (elem) => { toggleClass(elem, value, added) }) return; } if (added) { addClass(element, value) } else { removeClass(element, value) } } export function getImageNaturalSizes(image, callback) { const newImage = document.createElement('img') if (image.naturalWidth) { callback(image.naturalWidth, image.naturalHeight) return newImage; } const body = document.body || document.documentElement newImage.onload = () => { callback(newImage.width, newImage.height) } newImage.src = image.src newImage.style.cssText = (` top:0;left:0;opacity:0; z-index:-1;position:absolute; max-height:none!important; max-width:none!important; min-height:0!important; min-width:0!important; `) body.appendChild(newImage) return newImage } export function getTransforms({rotate, scaleX, scaleY, translateX, translateY}) { const values = [] if (isNumber(translateX) && translateX !== 0) { values.push(`translateX(${translateX}px)`) } if (isNumber(translateY) && translateY !== 0) { values.push(`translateY(${translateY}px)`) } if (isNumber(rotate) && rotate !== 0) { values.push(`rotate(${rotate}deg`) } if (isNumber(scaleX) && scaleX !== 0) { values.push(`scaleX(${scaleX})`) } if (isNumber(scaleY) && scaleY !== 0) { values.push(`scaleY(${scaleY})`) } const transform = values.length ? values.join(' ') : 'none' return {WebkitTransform: transform, msTransform: transform, transform} } const REGEXP_SUFFIX = /^(?:width|height|left|top|marginLeft|marginTop)$/; export function setStyle(element, styles) { const {style} = element; forEach(styles, (value, property) => { if (REGEXP_SUFFIX.test(property) && isNumber(value)) { value += 'px'; } style[property] = value; }); } ``` > src/js/viewer.js ```js class Viewer { constructor(element) { this.element = element this.fading = false this.hiding = false this.imageData = {} this.isImg = false this.length = 0 this.isShown = false this.ready = false this.showing = false this.timeout = false this.viewed = false this.viewing = false this.zooming = false this.init() } ... build() { ... this.canvas = canvas this.footer = template.querySelector('.viewer-footer') this.list = template.querySelector('.viewer-list') addClass(viewer, 'viewer-fade') ... } } ``` src/css/viewer.scss ```scss .viewer { ... &-footer { left: 0; right: 0; bottom: 0; overflow: hidden; position: absolute; text-align: center; } &-navbar { background-color: rgba(0, 0, 0, 0.5); overflow: hidden; } &-list { box-sizing: content-box; height: 50px; margin: 0; overflow: hidden; padding: 1px 0; & > li { color: transparent; cursor: pointer; float: left; font-size: 0; height: 50px; width: 30px; line-height: 0; overflow: hidden; opacity: 0.5; transition: opacity 0.15s; &:hover { opacity: 0.75; } } } ... &-invisible { visibility: hidden; } &-fade { opacity: 0; } &-in { opacity: 1; } &-transition { transition: all 0.3s; } ... } ``` > src/js/handlers.js ```js import {getData, getImageNaturalSizes, getTransforms, removeClass, setStyle} from "./utils"; export default { click(event) { const {target} = event const action = getData(target, 'viewerAction') switch (action) { case 'mix': this.hide() } }, load() { // image加载完时执行 const {image, viewerData} = this removeClass(image, 'viewer-invisible') removeClass(this.canvas, 'viewer-loading') image.style.cssText = (` height:0;width:0; position:absolute; max-width:none!important; margin-left:${viewerData.width / 2}px; margin-top:${viewerData.height / 2}px; `) this.initImage(() => { this.renderImage(() => { this.viewed = true this.viewing = false }) }) }, loadImage(event) { // list-img加载完时执行 const image = event.target const parent = image.parentNode const parentWidth = parent.offsetWidth || 30 const parentHeight = parent.offsetHeight || 50 const filled = !!getData(image, 'filled') getImageNaturalSizes(image, (naturalWidth, naturalHeight) => { const aspectRatio = naturalWidth / naturalHeight let width = parentWidth let height = parentHeight if (parentHeight * aspectRatio > parentWidth) { if (filled) { width = parentHeight * aspectRatio } else { height = parentWidth / aspectRatio } } else if (filled) { height = parentWidth / aspectRatio } else { width = parentHeight * aspectRatio } setStyle(image, Object.assign({width, height}, getTransforms({ translateX: (parentWidth - width) / 2, translateY: (parentHeight - height) / 2 }))) }) } } ``` > src/js/render.js ```js import {addListener, forEach, getImageNaturalSizes, getTransforms, removeClass, setData, setStyle} from "./utils"; export default { ... initList() { ... this.items = items forEach(items, (item) => { const image = item.firstElementChild setData(image, 'filled', true) // image加载完时触发load addListener(image, 'load', (event) => { this.loadImage(event) }, {once: true}) }) }, renderList() { console.log('render', 'renderList') const width = this.items[0].offsetWidth || 30 const outerWidth = width + 1 // 设置viewer-list样式 setStyle(this.list, Object.assign({ width: outerWidth * this.length }, getTransforms({ translateX: ((this.viewerData.width - width) / 2) - outerWidth }))) }, resetList() { console.log('render', 'resetList') const {list} = this removeClass(list, 'viewer-transition') setStyle(list, getTransforms({translateX: 0})) }, initImage(done) { console.log('render', 'initImage') const {image, viewerData} = this const footerHeight = this.footer.offsetHeight const viewerWidth = viewerData.width const viewerHeight = Math.max(viewerData.height - footerHeight, footerHeight) const oldImageData = this.imageData || {} let sizingImage sizingImage = getImageNaturalSizes(image, (naturalWidth, naturalHeight) => { const aspectRatio = naturalWidth / naturalHeight let width = viewerWidth let height = viewerHeight this.imageInitialzing = false if (viewerHeight * aspectRatio > viewerWidth) { // 按宽度调整 height = viewerWidth / aspectRatio } else { // 按高度调整 width = viewerHeight * aspectRatio } width = Math.min(width * 0.9, naturalWidth) height = Math.min(height * 0.9, naturalHeight) const imageData = { naturalWidth, naturalHeight, aspectRatio, ratio: width / naturalWidth, width, height, left: (viewerWidth - width) / 2, top: (viewerHeight - height) / 2 } const initialImageData = Object.assign({}, imageData) this.imageData = imageData this.initialImageData = initialImageData if (done) done() }) }, renderImage(done) { console.log('render', 'renderImage') const {image, imageData} = this setStyle(image, Object.assign({ width: imageData.width, height: imageData.height, marginLeft: imageData.left, marginTop: imageData.top }, getTransforms(imageData))) if (done) { if (this.viewing || this.zooming) { addListener(image, 'transitionend', (event) => { this.imageRendering = false done() }, {once: true}) } else { done() } } }, resetImage() { console.log('render', 'resetImage') if (this.viewing || this.viewed) { const {image} = this if (this.viewing) { this.viewing.abort() } image.parentNode.removeChild(image) this.image = null } } } ``` > src/js/methods.js ```js import {addClass, addListener, getData, removeClass} from "./utils"; export default { show() { console.log('method', 'show'); if (this.showing || this.isShown) { return this } if (!this.ready) { this.build() if (this.ready) this.show() return this } this.showing = true this.open() removeClass(this.viewer, 'viewer-hide') const {viewer} = this const shown = this.shown.bind(this) // 添加动画 addClass(viewer, 'viewer-transition') // 强制css3执行动画 viewer.initialOffsetWidth = viewer.offsetWidth addListener(viewer, 'transitionend', shown, {once: true}) addClass(viewer, 'viewer-in') return this; }, hide() { console.log('method', 'hide'); if (this.hiding || !(this.isShown || this.showing)) { return this } this.hiding = true removeClass(this.viewer, 'viewer-in') this.hidden() return this; }, view() { console.log('method', 'view'); if (!this.isShown) { return this.show() } const {element, canvas} = this this.index = 0 const item = this.items[this.index] const img = item.querySelector('img') const image = document.createElement('img') image.src = getData(img, 'originalUrl') image.alt = img.getAttribute('alt') this.image = image addClass(image, 'viewer-invisible') addClass(canvas, 'viewer-loading') canvas.innerHTML = '' canvas.appendChild(image) this.renderList() // image加载完触发load事件 addListener(image, 'load', this.load.bind(this), {once: true}) // 定时清除invisible if (this.timeout) clearTimeout(this.timeout) this.timeout = setTimeout(() => { removeClass(image, 'viewer-invisible') this.timeout = false }, 1000) return this; }, ... shown() { console.log('method', 'shown') this.fulled = true this.isShown = true this.render() this.bind() this.showing = false if (this.ready && this.isShown) { // 动画结束调用view this.view() } }, hidden() { console.log('method', 'hidden') this.fulled = false this.viewed = false this.isShown = false this.close() this.unbind() // 隐藏viewer addClass(this.viewer, 'viewer-hide') // 初始化list this.resetList() this.resetImage() this.hiding = false } } ``` ## 6.Toolbar 本节为Viewer添加工具栏及其事件 ### 6.1工具栏 > src/js/costants.js ```js // 定义工具栏按钮名称常量 export const BUTTONS = [ 'zoom-in', 'zoom-out', 'one-to-one', 'reset', 'prev', 'play', 'next', 'rotate-left', 'rotate-right', 'flip-horizontal', 'flip-vertical' ] ``` > src/js/template.js ```js export default (` <div class="viewer-container" touch-action="none"> <div class="viewer-canvas"></div> <div class="viewer-footer"> <div class="viewer-title"></div> <div class="viewer-toolbar"></div> <div class="viewer-navbar"> <ul class="viewer-list"></ul> </div> </div> <div role="button" class="viewer-button" data-viewer-action="mix"></div> </div> `) ``` > src/css/viewer.scss ```scss .viewer { // 工具栏图标样式 &-zoom-in, &-zoom-out, &-one-to-one, &-reset, &-prev, &-play, &-next, &-rotate-left, &-rotate-right, &-flip-horizontal, &-flip-vertical, &-close { &::before { background-image: url('images/icons.png'); background-repeat: no-repeat; background-size: 280px; color: transparent; display: block; font-size: 0; height: 20px; line-height: 0; width: 20px; } } &-zoom-in::before { background-position: 0 0; content: 'Zoom In'; } &-zoom-out::before { background-position: -20px 0; content: 'Zoom Out'; } &-one-to-one::before { background-position: -40px 0; content: 'One to One'; } &-reset::before { background-position: -60px 0; content: 'Reset'; } &-prev::before { background-position: -80px 0; content: 'Previous'; } &-play::before { background-position: -100px 0; content: 'Play'; } &-next::before { background-position: -120px 0; content: 'Next'; } &-rotate-left::before { background-position: -140px 0; content: 'Rotate Left'; } &-rotate-right::before { background-position: -160px 0; content: 'Rotate Right'; } &-flip-horizontal::before { background-position: -180px 0; content: 'Flip Horizontal'; } &-flip-vertical::before { background-position: -200px 0; content: 'Flip Vertical'; } &-close::before { background-position: -260px 0; content: 'Close'; } ... &-footer { left: 0; right: 0; bottom: 0; overflow: hidden; position: absolute; text-align: center; } &-title { color: #ccc; display: inline-block; font-size: 12px; line-height: 1; margin: 0 5% 5px; max-width: 90%; opacity: 0.8; overflow: hidden; text-overflow: ellipsis; transition: opacity 0.15s; white-space: nowrap; &:hover { opacity: 1; } } // 工具栏样式 &-toolbar { & > ul { display: inline-block; margin: 0 auto 5px; overflow: hidden; padding: 3px 0; & > li { background-color: rgba(0, 0, 0, 0.5); border-radius: 50%; cursor: pointer; float: left; width: 24px; height: 24px; overflow: hidden; transition: background-color 0.15s; &:hover { background-color: rgba(0, 0, 0, 0.8); } &::before { margin: 2px; } & + li { margin-left: 1px; } } } } ... } ``` > src/js/viewer.js ```js class Viewer { ... build() { const {element} = this; const template = document.createElement('div'); template.innerHTML = TEMPLATE; const viewer = template.querySelector('.viewer-container'); const title = template.querySelector('.viewer-title') const toolbar = template.querySelector('.viewer-toolbar') const navbar = template.querySelector('.viewer-navbar') const button = template.querySelector('.viewer-button'); const canvas = template.querySelector('.viewer-canvas'); this.viewer = viewer; this.title = title this.toolbar = toolbar this.navbar = navbar this.button = button; this.canvas = canvas; this.footer = viewer.querySelector('.viewer-footer') this.list = template.querySelector('.viewer-list'); addClass(viewer, 'viewer-backdrop'); setData(canvas, 'viewerAction', 'hide'); const list = document.createElement('ul') forEach(BUTTONS, (value, index) => { const item = document.createElement('li') item.setAttribute('role', 'button') addClass(item, `viewer-${value}`) setData(item, 'viewerAction', value) list.appendChild(item) }) toolbar.appendChild(list) ... } } ``` > src/js/handlers.js ```js export default { click(event) { console.log('handlers', 'click') const {target} = event let action = getData(target, 'viewerAction') switch (action) { case 'mix': this.hide() break case 'zoom-in': console.log('zoom-in') break case 'zoom-out': console.log('zoom-out'); break case 'one-to-one': console.log('one-to-one'); break case 'reset': console.log('reset'); break case 'prev': console.log('prev'); break case 'play': console.log('play'); break case 'next': console.log('next'); break case 'rotate-left': console.log('rotate-left'); break case 'rotate-right': console.log('rotate-right'); break case 'flip-horizontal': console.log('flip-horizontal'); break case 'flip-vertical': console.log('flip-vertical'); break } }, ... } ``` ### 6.2Zoom函数 > src/js/methods.js ```js export default { ... zoom(ratio, hasTooltip = false, _originalEvent = null) { const {imageData} = this ratio = Number(ratio) if (ratio < 0) { ratio = 1 / (1 - ratio) } else { ratio = 1 + ratio } this.zoomTo((imageData.width * ratio) / imageData.naturalWidth, hasTooltip, _originalEvent) return this }, zoomTo(ratio, hasTooltip = false, _originalEvent = false, _zoomable = false) { const {imageData} = this const {width, height, naturalWidth, naturalHeight} = imageData ratio = Math.max(0, ratio) if (isNumber(ratio) && this.viewed) { if (!_zoomable) { // 限制缩放最大最小比例 ratio = Math.min(Math.max(ratio, 0.01), 100) } this.zooming = true const newWidth = naturalWidth * ratio const newHeight = naturalHeight * ratio const offsetWidth = newWidth - width const offsetHeight = newHeight - height imageData.left -= offsetWidth / 2 imageData.top -= offsetHeight / 2 imageData.width = newWidth imageData.height = newHeight imageData.ratio = ratio this.renderImage(() => { this.zooming = false }) } return this }, ... } ``` > src/js/handlers.js ```js export default { click(event) { const {target} = event let action = getData(target, 'viewerAction') switch (action) { case 'mix': this.hide() break case 'zoom-in': this.zoom(0.1, true) break case 'zoom-out': this.zoom(-0.1, true) break } }, ... } ```

简介

viewerjs学习文档及代码 展开 收起
JavaScript 等 2 种语言
ISC
取消

发行版

暂无发行版

贡献者

全部

近期动态

加载更多
不能加载更多了
马建仓 AI 助手
尝试更多
代码解读
代码找茬
代码优化
JavaScript
1
https://gitee.com/cjbgitee/viewer.git
git@gitee.com:cjbgitee/viewer.git
cjbgitee
viewer
viewer
master

搜索帮助

344bd9b3 5694891 D2dac590 5694891