├── .all-contributorsrc ├── .babelrc.js ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .stylelintrc ├── .travis.yml ├── LICENSE ├── README-zh.md ├── README.md ├── build.sh ├── build └── rollup.config.js ├── docs ├── autocrop.md ├── basic.md ├── customize.md ├── directive.md ├── extra-query.md ├── faq.md ├── loading.md ├── prefer-https.md ├── svg.md └── with-v-img-preview.md ├── netlify.sh ├── notify.sh ├── package.json ├── src ├── compute-layout.js ├── directive.js ├── index.js ├── load-script.js ├── provider-config.js ├── reload.png ├── spinner.svg ├── ua.js ├── v-img.d.ts └── v-img.vue ├── styleguide.config.js ├── styleguide └── register.js ├── test ├── compute-layout.spec.js ├── provider-config.spec.js └── ua.spec.js └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "v-img", 3 | "projectOwner": "FEMessage", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": false, 11 | "commitConvention": "angular", 12 | "contributors": [ 13 | { 14 | "login": "colmugx", 15 | "name": "ColMugX", 16 | "avatar_url": "https://avatars1.githubusercontent.com/u/21327913?v=4", 17 | "profile": "https://colmugx.github.io", 18 | "contributions": [ 19 | "code", 20 | "doc", 21 | "test", 22 | "translation" 23 | ] 24 | }, 25 | { 26 | "login": "donaldshen", 27 | "name": "Donald Shen", 28 | "avatar_url": "https://avatars3.githubusercontent.com/u/19591950?v=4", 29 | "profile": "https://donaldshen.github.io/portfolio", 30 | "contributions": [ 31 | "code", 32 | "test", 33 | "doc", 34 | "review" 35 | ] 36 | }, 37 | { 38 | "login": "evillt", 39 | "name": "EVILLT", 40 | "avatar_url": "https://avatars3.githubusercontent.com/u/19513289?v=4", 41 | "profile": "https://evila.me", 42 | "contributions": [ 43 | "code", 44 | "test", 45 | "doc" 46 | ] 47 | }, 48 | { 49 | "login": "lianghx-319", 50 | "name": "Han", 51 | "avatar_url": "https://avatars2.githubusercontent.com/u/27187946?v=4", 52 | "profile": "https://github.com/lianghx-319", 53 | "contributions": [ 54 | "code", 55 | "bug" 56 | ] 57 | }, 58 | { 59 | "login": "xrr2016", 60 | "name": "Cold Stone", 61 | "avatar_url": "https://avatars1.githubusercontent.com/u/18013127?v=4", 62 | "profile": "https://coldstone.fun", 63 | "contributions": [ 64 | "doc" 65 | ] 66 | }, 67 | { 68 | "login": "levy9527", 69 | "name": "levy", 70 | "avatar_url": "https://avatars3.githubusercontent.com/u/9384365?v=4", 71 | "profile": "https://github.com/levy9527/blog", 72 | "contributions": [ 73 | "projectManagement", 74 | "ideas" 75 | ] 76 | }, 77 | { 78 | "login": "gd4Ark", 79 | "name": "4Ark", 80 | "avatar_url": "https://avatars0.githubusercontent.com/u/27952659?v=4", 81 | "profile": "https://4ark.me", 82 | "contributions": [ 83 | "code" 84 | ] 85 | } 86 | ], 87 | "contributorsPerLine": 7, 88 | "skipCi": true 89 | } 90 | -------------------------------------------------------------------------------- /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = api => { 2 | return { 3 | presets: [['@babel/env', {modules: api.env('test') ? 'commonjs' : false}]], 4 | plugins: [ 5 | [ 6 | '@babel/transform-runtime', 7 | { 8 | regenerator: true, 9 | } 10 | ] 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | indent_size = 2 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | insert_final_newline = false 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | node: true 6 | }, 7 | parserOptions: { 8 | parser: 'babel-eslint' 9 | }, 10 | extends: [ 11 | 'eslint:recommended', 12 | 'plugin:jest/recommended', 13 | 'plugin:vue/recommended', 14 | 'plugin:prettier/recommended', 15 | 'prettier/vue' 16 | ], 17 | plugins: ['vue', 'prettier'], 18 | rules: { 19 | 'no-console': [ 20 | 'error', 21 | { 22 | allow: ['warn', 'error'] 23 | } 24 | ], 25 | 'no-debugger': 'error', 26 | 'prettier/prettier': 'error' 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | dist 7 | docs/build 8 | docs/index.html 9 | docs/*.woff 10 | docs/*.ttf 11 | 12 | # Editor directories and files 13 | .idea 14 | .vscode 15 | *.suo 16 | *.ntvs* 17 | *.njsproj 18 | *.sln 19 | .env 20 | 21 | .npmrc 22 | example 23 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | docs 2 | dist 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | semi: false 2 | singleQuote: true 3 | bracketSpacing: false 4 | trailingComma: 'es5' 5 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "rules": { 4 | "no-empty-source": null 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | branches: 2 | only: 3 | - master 4 | language: node_js 5 | node_js: 6 | - lts/* 7 | git: 8 | depth: 30 9 | install: 10 | - yarn --frozen-lockfile 11 | - yarn test 12 | script: 13 | - ./build.sh 14 | after_script: 15 | - ./notify.sh 16 | cache: yarn 17 | deploy: 18 | - provider: pages 19 | local-dir: docs 20 | github-token: $GITHUB_TOKEN 21 | skip-cleanup: true 22 | keep-history: true 23 | - provider: npm 24 | email: levy9527@qq.com 25 | api_key: $NPM_TOKEN 26 | skip-cleanup: true 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 FEMessage 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README-zh.md: -------------------------------------------------------------------------------- 1 | # v-img 2 | 3 | [![Build Status](https://badgen.net/travis/FEMessage/v-img/master)](https://travis-ci.com/FEMessage/v-img) 4 | [![NPM Download](https://badgen.net/npm/dm/@femessage/v-img)](https://www.npmjs.com/package/@femessage/v-img) 5 | [![NPM Version](https://badgen.net/npm/v/@femessage/v-img)](https://www.npmjs.com/package/@femessage/v-img) 6 | [![NPM License](https://badgen.net/npm/license/@femessage/v-img)](https://github.com/FEMessage/v-img/blob/master/LICENSE) 7 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/FEMessage/v-img/pulls) 8 | [![Automated Release Notes by gren](https://img.shields.io/badge/%F0%9F%A4%96-release%20notes-00B2EE.svg)](https://github-tools.github.io/github-release-notes/) 9 | 10 | 取代原生 img 元素,使用 webp! 11 | 12 | ## Table of Contents 13 | 14 | - [Features](#features) 15 | - [Install](#install) 16 | - [Usage](#Usage) 17 | - [Links](#links) 18 | - [Contributing](#contributing) 19 | - [Contributors](#contributors) 20 | - [License](#license) 21 | 22 | ## Features 23 | 24 | - 输入 jpg/png,输出 webp(svg/gif 原样返回,不做转换处理) 25 | - 根据浏览器环境自动选择是否使用 webp 26 | - 支持云服务 27 | - [x] 阿里云 28 | - [x] 华为云 29 | - [x] 七牛 30 | - 支持 SSR 31 | 32 | ## Install 33 | 34 | ```bash 35 | yarn add @femessage/v-img 36 | ``` 37 | 38 | [⬆ Back to Top](#table-of-contents) 39 | 40 | ## Usage 41 | 42 | ```vue 43 | 44 | ``` 45 | 46 | ### provider 47 | 48 | 设置 `provider` 来使用不同的图片处理方案,`provider` 参数有以下选项: 49 | 50 | - alibaba(默认,同样适用于华为云) 51 | - qiniu 52 | - self 53 | - none 54 | 55 | --- 56 | 57 | - 默认值为 alibaba。这意味着,只需要上传 jpg/png 到阿里云 OSS,使用 v-img 来显示图片,则会使用阿里云的图片处理服务,根据情况自动返回 webp。如果项目中已使用阿里云 OSS 进行图片存储,则可省略设置`provider` 58 | 59 | - 当 `provider=self` 时,也即图片放到自有主机,一般出现在项目私有化部署的情况,此时需为每一份图片自行准备相应的 webp 文件。 例如: 60 | 61 | ```sh 62 | images/ 63 | avatar.png # 原有图片 64 | avatar.png.webp # 需要生成的 webp 文件 65 | ``` 66 | 67 | 生成 webp 副本的方法可查看[此文](https://www.yuque.com/docs/share/3eaa556c-0780-4018-8ac1-4e217fb0efdb)。 68 | 69 | - 当`provider=none` 时,仅启用图片懒加载功能 70 | 71 | 总结一下就是: 72 | 73 | 1. 当 `provider=alibaba或qiniu` 时,使用云服务商图片处理功能,自动转 webp,默认对图片瘦身 74 | 2. 当 `provider=self` 时,期望用户准备好 webp 文件,判断浏览器环境支持 webp 时,请求 webp 图片 75 | 3. 当 `provider=none` 时,不对 src 做处理 76 | 77 | ### width/height 78 | 79 | 最好设置一下 width 或 height 属性,不需要带单位(100 而不是 100px),以便懒加载功能能更好地运行(有一种情况是,如果图片高度为 0,则极有可能导致一次性加载多张图片)。 80 | 81 | ### lazyload 82 | 83 | 组件借助了[lazysizes](https://github.com/aFarkas/lazysizes)来实现懒加载功能,自动开启。 84 | 85 | [⬆ Back to Top](#table-of-contents) 86 | 87 | ## Links 88 | 89 | - [把图片优化指南做成一个组件:v-img 介绍](https://zhuanlan.zhihu.com/p/99769484) 90 | - [api](https://FEMessage.github.io/v-img/) 91 | - [设计文档](https://www.yuque.com/docs/share/6edaadbb-9260-4b49-90d7-0a8d8d03b1de) 92 | - [webp](https://developers.google.com/speed/webp) 93 | - [alibaba oss guide](https://www.alibabacloud.com/help/doc-detail/47505.html?spm=a2c5t.11065259.1996646101.searchclickresult.2c802d29Uot0hD) 94 | - [qiniu images processing doc](https://developer.qiniu.com/dora/api/1270/the-advanced-treatment-of-images-imagemogr2) 95 | - [如何居中缩放 svg 图片](https://stackoverflow.com/a/11671373) 96 | - [svg 缩放属性详解](https://css-tricks.com/scale-svg/) 97 | 98 | [⬆ Back to Top](#table-of-contents) 99 | 100 | ## Contributing 101 | 102 | For those who are interested in contributing to this project, such as: 103 | 104 | - report a bug 105 | - request new feature 106 | - fix a bug 107 | - implement a new feature 108 | 109 | Please refer to our [contributing guide](https://github.com/FEMessage/.github/blob/master/CONTRIBUTING.md). 110 | 111 | [⬆ Back to Top](#table-of-contents) 112 | 113 | ## Contributors 114 | 115 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 116 | 117 | 118 | 119 | 120 |
ColMugX
ColMugX

💻 📖 ⚠️ 🌍
Donald Shen
Donald Shen

💻 ⚠️ 📖 👀
EVILLT
EVILLT

💻 ⚠️ 📖
Han
Han

💻 🐛
Cold Stone
Cold Stone

📖
levy
levy

📆 🤔
121 | 122 | 123 | 124 | 125 | 126 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 127 | 128 | [⬆ Back to Top](#table-of-contents) 129 | 130 | ## License 131 | 132 | [MIT](./LICENSE) 133 | 134 | [⬆ Back to Top](#table-of-contents) 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # v-img 2 | 3 | [![Build Status](https://badgen.net/travis/FEMessage/v-img/master)](https://travis-ci.com/FEMessage/v-img) 4 | [![NPM Download](https://badgen.net/npm/dm/@femessage/v-img)](https://www.npmjs.com/package/@femessage/v-img) 5 | [![NPM Version](https://badgen.net/npm/v/@femessage/v-img)](https://www.npmjs.com/package/@femessage/v-img) 6 | [![NPM License](https://badgen.net/npm/license/@femessage/v-img)](https://github.com/FEMessage/v-img/blob/master/LICENSE) 7 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/FEMessage/v-img/pulls) 8 | [![Automated Release Notes by gren](https://img.shields.io/badge/%F0%9F%A4%96-release%20notes-00B2EE.svg)](https://github-tools.github.io/github-release-notes/) 9 | 10 | This component aims to replace native img element and use webp! 11 | 12 | [中文文档](./README-zh.md) 13 | 14 | ## Table of Contents 15 | 16 | - [Features](#features) 17 | - [Install](#install) 18 | - [Usage](#Usage) 19 | - [Links](#links) 20 | - [Contributing](#contributing) 21 | - [Contributors](#contributors) 22 | - [License](#license) 23 | 24 | ## Features 25 | 26 | - Input jpg/png, output webp(svg/gif not be processed) 27 | - Automatically check whether your browser support webp and use it 28 | - Support cloud image service 29 | - [x] Alibaba Cloud 30 | - [x] Huawei Cloud 31 | - [x] Qiniu Cloud 32 | - Support SSR 33 | 34 | [⬆ Back to Top](#table-of-contents) 35 | 36 | ## Install 37 | 38 | ```bash 39 | yarn add @femessage/v-img 40 | ``` 41 | 42 | [⬆ Back to Top](#table-of-contents) 43 | 44 | ## Usage 45 | 46 | ```vue 47 | 48 | ``` 49 | 50 | ### provider 51 | 52 | The component use `provider` to choose image processing strategy, here are available values: 53 | 54 | - alibaba(default value, and it's compatible with Huawei Cloud) 55 | - qiniu 56 | - self 57 | - none 58 | 59 | --- 60 | 61 | - Alibaba OSS services are used by default, so if you host images on Alibaba OSS, `provider` can be omitted, this means jpg/png on Alibaba OSS, you can get webp when using v-img 62 | 63 | - When `provider=self`, means you host images on your server(like Nginx), this needs you need to prepare a webp file for each image, for example: 64 | 65 | ```sh 66 | images/ 67 | avatar.png # your original image file 68 | avatar.png.webp # webp file need to be generated 69 | ``` 70 | 71 | look at this [article](https://www.yuque.com/docs/share/3eaa556c-0780-4018-8ac1-4e217fb0efdb?translate=en) to see how to use node.js to generate webp from jpg/png 72 | 73 | - When `provider=none`, it only enable lazyload images function 74 | 75 | ### width/height 76 | 77 | You'd better set image's width or height attribute(like 100, not 100px) to make sure lazyload function can work correctly 78 | 79 | ### lazyload 80 | 81 | The `lazyload` function is supported by [lazysizes](https://github.com/aFarkas/lazysizes), and it is auto enabled. 82 | 83 | [⬆ Back to Top](#table-of-contents) 84 | 85 | ## Links 86 | 87 | - [api](https://FEMessage.github.io/v-img/) 88 | - [design doc](https://www.yuque.com/docs/share/6edaadbb-9260-4b49-90d7-0a8d8d03b1de?translate=en) 89 | - [webp](https://developers.google.com/speed/webp) 90 | - [alibaba oss guide](https://www.alibabacloud.com/help/doc-detail/47505.html?spm=a2c5t.11065259.1996646101.searchclickresult.2c802d29Uot0hD) 91 | - [qiniu images processing doc](https://developer.qiniu.com/dora/api/1270/the-advanced-treatment-of-images-imagemogr2) 92 | - [how to scale svg from center](https://stackoverflow.com/a/11671373) 93 | - [more about scaling svg](https://css-tricks.com/scale-svg/) 94 | 95 | [⬆ Back to Top](#table-of-contents) 96 | 97 | ## Contributing 98 | 99 | For those who are interested in contributing to this project, such as: 100 | 101 | - report a bug 102 | - request new feature 103 | - fix a bug 104 | - implement a new feature 105 | 106 | Please refer to our [contributing guide](https://github.com/FEMessage/.github/blob/master/CONTRIBUTING.md). 107 | 108 | [⬆ Back to Top](#table-of-contents) 109 | 110 | ## Contributors 111 | 112 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 |

ColMugX

💻 📖 ⚠️ 🌍

Donald Shen

💻 ⚠️ 📖 👀

EVILLT

💻 ⚠️ 📖

Han

💻 🐛

Cold Stone

📖

levy

📆 🤔

4Ark

💻
128 | 129 | 130 | 131 | 132 | 133 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 134 | 135 | [⬆ Back to Top](#table-of-contents) 136 | 137 | ## License 138 | 139 | [MIT](./LICENSE) 140 | 141 | [⬆ Back to Top](#table-of-contents) 142 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | yarn stdver 3 | 4 | yarn build 5 | -------------------------------------------------------------------------------- /build/rollup.config.js: -------------------------------------------------------------------------------- 1 | // rollup.config.js 2 | import vue from 'rollup-plugin-vue' 3 | import babel from 'rollup-plugin-babel' 4 | import commonjs from 'rollup-plugin-commonjs' 5 | import {terser} from 'rollup-plugin-terser' 6 | import img from 'rollup-plugin-img' 7 | import minimist from 'minimist' 8 | 9 | const argv = minimist(process.argv.slice(2)) 10 | 11 | const config = { 12 | input: 'src/index.js', 13 | output: { 14 | name: 'VImg', 15 | exports: 'named' 16 | }, 17 | plugins: [ 18 | commonjs(), 19 | img(), 20 | vue({ 21 | css: true, 22 | compileTemplate: true 23 | }), 24 | babel({ 25 | runtimeHelpers: true, 26 | extensions: ['.js', '.jsx', '.es6', '.es', '.mjs', '.vue'], 27 | exclude: 'node_modules/**' 28 | }) 29 | ] 30 | } 31 | 32 | // Only minify browser (iife) version 33 | if (argv.format === 'iife') { 34 | config.plugins.push(terser()) 35 | } 36 | 37 | export default config 38 | -------------------------------------------------------------------------------- /docs/autocrop.md: -------------------------------------------------------------------------------- 1 | ## 自动裁剪 2 | 如果传入 width 或 height 属性,会默认按照客户端的 devicePixelRatio(默认是 2) 获取合适大小的图片。节省流量的同时,保证图片最佳显示效果 3 | 4 | ```vue 5 | 19 | 20 | 29 | ``` 30 | -------------------------------------------------------------------------------- /docs/basic.md: -------------------------------------------------------------------------------- 1 | 不需要任何配置,即可拥有: 2 | - 懒加载 3 | - 优先返回webp 4 | - 默认使用阿里云的图片优化服务 5 | 6 | these features are out of box: 7 | - lazyload 8 | - webp first 9 | - set up alibaba image transform services 10 | 11 | ```vue 12 | 22 | 23 | 37 | 38 | 46 | ``` 47 | -------------------------------------------------------------------------------- /docs/customize.md: -------------------------------------------------------------------------------- 1 | ## 自定义 loading 和 error 2 | load 时可以用类名 `.on-loading`, error 时可以用类名 `.on-error` 覆盖默认的样式 3 | 4 | ```vue 5 | 21 | 22 | 27 | ``` 28 | 29 | ### 可在注册组件时全局设置,适合全局修改 30 | ```javascript 31 | import Vue from 'vue' 32 | import VImg from '@femessage/v-img' 33 | 34 | Vue.ues(VImg, { 35 | placeholder: 'https://deepexi-moby.oss-cn-shenzhen.aliyuncs.com/femessage/bean_eater.svg', 36 | error: 'https://deepexi-moby.oss-cn-shenzhen.aliyuncs.com/femessage/iconmonstr-refresh-6.svg' 37 | }) 38 | ``` 39 | 40 | ### 可在使用组件时,单独给组件设置 41 | 自定义图案的使用逻辑: 组件属性设置 > 全局设置 > 默认设置 42 | 43 | -------------------------------------------------------------------------------- /docs/directive.md: -------------------------------------------------------------------------------- 1 | 使用指令 `v-img` 可以对 background-image 进行图片优化。 2 | 3 | 与 v-img 一样的参数设置,如 `v-img="{src: '', provider: 'qiniu'}"` 4 | 5 | 6 | use `v-img` directive, can let background-image to use webp。 7 | 8 | `v-img="{src: '', provider: 'qiniu'}"` 9 | 10 | ```vue 11 | 17 | 18 | 27 | ``` 28 | -------------------------------------------------------------------------------- /docs/extra-query.md: -------------------------------------------------------------------------------- 1 | 该例子展示了使用阿里云图片参数实现图片处理 2 | 3 | This example shows using alibaba image service to process images. 4 | 5 | ```vue 6 | 15 | 16 | 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | ## 在 TypeScript 中指定组件的类型 2 | 3 | ```html 4 | 7 | 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/loading.md: -------------------------------------------------------------------------------- 1 | 懒加载时,会先出现 loading 的图片;图片加载失败,会出现 reload 的占位图。 2 | 不同尺寸时的样式如下 3 | 4 | ```vue 5 | 17 | ``` 18 | -------------------------------------------------------------------------------- /docs/prefer-https.md: -------------------------------------------------------------------------------- 1 | if `preferHttps` enable and the provider is not (none | self):
2 | 1. //img-url will transform to https://img-url
3 | 2. http://img-url will get warning on the console 4 | 5 | ```vue 6 | 40 | ``` 41 | -------------------------------------------------------------------------------- /docs/svg.md: -------------------------------------------------------------------------------- 1 | svg 图片不会转换 2 | 3 | svg will not be transformed 4 | 5 | ```vue 6 | 9 | 10 | 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/with-v-img-preview.md: -------------------------------------------------------------------------------- 1 | 与 v-img-preview 指令组合使用 2 | 3 | v-img 内部提供了一个未经过裁剪的 url 属性(data-uncropped-src), 因此可以向 v-img-preview 指令传递这个参数 4 | 5 | 由于 v-img 内部不提供 @femessage/img-preview 组件, 因此需要安装并且通过 Vue.use(ImgPreview), 才能使用这个指令 6 | 7 | ```vue 8 | 16 | 17 | 26 | ``` -------------------------------------------------------------------------------- /netlify.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "is netlify: $NETLIFY" 3 | echo "in branch: $BRANCH" 4 | echo "head: $HEAD" 5 | 6 | if [ "$NETLIFY" != "true" ] 7 | then 8 | echo "this script only runs in netlify, bye" 9 | exit 1 10 | fi 11 | 12 | if [ "$BRANCH" != "dev" ] && [ "$HEAD" != "dev" ] 13 | then 14 | yarn doc 15 | else 16 | echo "this script only runs in targeting dev's PR deploy preview, bye" 17 | fi 18 | -------------------------------------------------------------------------------- /notify.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # https://stackoverflow.com/questions/13872048/bash-script-what-does-bin-bash-mean 3 | echo "1/5: checking TRAVIS_TEST_RESULT" 4 | if [ "$TRAVIS_TEST_RESULT" != "0" ] 5 | then 6 | echo "build not success, bye" 7 | exit 1 8 | fi 9 | 10 | ORG_NAME=$(echo "$TRAVIS_REPO_SLUG" | cut -d '/' -f 1) 11 | REPO_NAME=$(echo "$TRAVIS_REPO_SLUG" | cut -d '/' -f 2) 12 | 13 | echo "2/5: pushing commit and tag to github" 14 | # 该命令很可能报错,但不影响实际进行,因而不能简单地在脚本开头 set -e 15 | git remote add github https://$GITHUB_TOKEN@github.com/$TRAVIS_REPO_SLUG.git > /dev/null 2>&1 16 | git push github HEAD:master --follow-tags 17 | 18 | echo "3/5: generating github release notes" 19 | GREN_GITHUB_TOKEN=$GITHUB_TOKEN yarn release 20 | 21 | # 避免发送错误信息 22 | if [ $? -ne 0 ] 23 | then 24 | echo "gren fails, bye" 25 | exit 1 26 | fi 27 | 28 | echo "4/5: downloading github release info" 29 | url=https://api.github.com/repos/$TRAVIS_REPO_SLUG/releases/latest 30 | resp_tmp_file=resp.tmp 31 | 32 | curl -H "Authorization: token $GITHUB_TOKEN" $url > $resp_tmp_file 33 | 34 | html_url=$(sed -n 5p $resp_tmp_file | sed 's/\"html_url\"://g' | awk -F '"' '{print $2}') 35 | body=$(grep body < $resp_tmp_file | sed 's/\"body\"://g;s/\"//g') 36 | version=$(echo $html_url | awk -F '/' '{print $NF}') 37 | 38 | echo "5/5: notifying with dingtalk bot" 39 | msg='{"msgtype": "markdown", "markdown": {"title": "'$REPO_NAME'更新", "text": "@所有人\n# ['$REPO_NAME'('$version')]('$html_url')\n'$body'"}}' 40 | 41 | curl -X POST https://oapi.dingtalk.com/robot/send\?access_token\=$DINGTALK_ROBOT_TOKEN -H 'Content-Type: application/json' -d "$msg" 42 | 43 | rm $resp_tmp_file 44 | 45 | echo "executing notify.sh successfully" 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@femessage/v-img", 3 | "version": "1.0.0", 4 | "description": "📸 use webp and lazyload images", 5 | "author": "https://github.com/FEMessage", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/FEMessage/v-img.git" 10 | }, 11 | "keywords": [ 12 | "vue", 13 | "sfc", 14 | "component" 15 | ], 16 | "files": [ 17 | "src", 18 | "dist" 19 | ], 20 | "main": "dist/v-img.umd.js", 21 | "module": "dist/v-img.esm.js", 22 | "unpkg": "dist/v-img.min.js", 23 | "browser": { 24 | "./sfc": "src/v-img.vue" 25 | }, 26 | "types": "src/v-img.d.ts", 27 | "scripts": { 28 | "dev": "vue-styleguidist server", 29 | "test": "jest --verbose", 30 | "doc": "vue-styleguidist build", 31 | "build": "npm run build:unpkg & npm run build:es & npm run build:umd & npm run doc", 32 | "build:umd": "rollup --config build/rollup.config.js --format umd --file dist/v-img.umd.js", 33 | "build:es": "rollup --config build/rollup.config.js --format es --file dist/v-img.esm.js", 34 | "build:unpkg": "rollup --config build/rollup.config.js --format iife --file dist/v-img.min.js", 35 | "stdver": "standard-version -m '[skip ci] chore(release): v%s'", 36 | "lint": "eslint \"**/*.@(js|vue)\" --fix", 37 | "release": "gren release --override" 38 | }, 39 | "dependencies": {}, 40 | "devDependencies": { 41 | "@babel/core": "^7.4.3", 42 | "@babel/plugin-transform-runtime": "^7.4.3", 43 | "@babel/preset-env": "^7.4.3", 44 | "@femessage/github-release-notes": "latest", 45 | "@femessage/img-preview": "^1.4.0", 46 | "babel-eslint": "^10.0.3", 47 | "babel-loader": "^8.0.5", 48 | "eslint": "^6.8.0", 49 | "eslint-config-prettier": "^6.9.0", 50 | "eslint-plugin-jest": "^23.6.0", 51 | "eslint-plugin-prettier": "^3.1.2", 52 | "eslint-plugin-vue": "^6.1.2", 53 | "file-loader": "^3.0.1", 54 | "glob": "^7.1.3", 55 | "husky": "1.3.1", 56 | "jest": "^24.8.0", 57 | "less": "^3.9.0", 58 | "less-loader": "^5.0.0", 59 | "lint-staged": "^8.1.0", 60 | "minimist": "^1.2.0", 61 | "prettier": "1.18.2", 62 | "rollup": "^1.9.0", 63 | "rollup-plugin-babel": "^4.3.2", 64 | "rollup-plugin-commonjs": "^9.3.4", 65 | "rollup-plugin-img": "^1.1.0", 66 | "rollup-plugin-terser": "^4.0.4", 67 | "rollup-plugin-vue": "^4.7.2", 68 | "standard-version": "^6.0.1", 69 | "stylelint": "^9.10.0", 70 | "stylelint-config-standard": "^18.2.0", 71 | "vue": "^2.6.10", 72 | "vue-loader": "^15.7.1", 73 | "vue-styleguidist": "^3.16.3", 74 | "vue-template-compiler": "^2.5.16", 75 | "webpack": "^4.29.6" 76 | }, 77 | "publishConfig": { 78 | "access": "public" 79 | }, 80 | "vue-sfc-cli": "1.12.0", 81 | "engines": { 82 | "node": ">= 4.0.0", 83 | "npm": ">= 3.0.0" 84 | }, 85 | "husky": { 86 | "hooks": { 87 | "pre-commit": "lint-staged", 88 | "post-commit": "git update-index --again", 89 | "pre-push": "yarn test" 90 | } 91 | }, 92 | "lint-staged": { 93 | "*.@(md|json)": [ 94 | "prettier --write", 95 | "git add" 96 | ], 97 | "*.js": [ 98 | "eslint --fix", 99 | "prettier --write", 100 | "git add" 101 | ], 102 | "*.vue": [ 103 | "eslint --fix", 104 | "prettier --write", 105 | "stylelint --fix", 106 | "git add" 107 | ] 108 | }, 109 | "gren": "@femessage/grenrc" 110 | } 111 | -------------------------------------------------------------------------------- /src/compute-layout.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by levy on 2021/1/13. 3 | */ 4 | function isPixel(dimension) { 5 | return ('' + dimension).indexOf('px') > -1 6 | } 7 | 8 | export default function(vm) { 9 | let width, height 10 | if (Number.isInteger(vm.width) || isPixel(vm.width)) 11 | width = parseInt(vm.width) 12 | if (Number.isInteger(vm.height) || isPixel(vm.height)) 13 | height = parseInt(vm.height) 14 | 15 | if (isNaN(width) && vm.$el) { 16 | let styleWidth = window.getComputedStyle(vm.$el).width 17 | if (isPixel(styleWidth)) width = parseInt(styleWidth) 18 | } 19 | if (isNaN(height) && vm.$el) { 20 | let styleHeight = window.getComputedStyle(vm.$el).height 21 | if (isPixel(styleHeight)) height = parseInt(styleHeight) 22 | } 23 | 24 | // TODO 这里假设 auto 没有返回数值,待验证 25 | if ((isNaN(width) && isNaN(height)) || (!width && !height)) return {} 26 | 27 | let naturalWidth = vm.$el.naturalWidth, 28 | naturalHeight = vm.$el.naturalHeight 29 | 30 | if (width > naturalWidth) width = naturalWidth 31 | if (height > naturalHeight) height = naturalHeight 32 | 33 | // getComputedStyle 有可能会返回 natural[Width/Height],这使得其返回值不可直接使用,还需要最后 resize 一下。 34 | let scaleWidth = (height * vm.$el.naturalWidth) / vm.$el.naturalHeight 35 | let scaleHeight = (width * vm.$el.naturalHeight) / vm.$el.naturalWidth 36 | 37 | if (!scaleWidth || width * scaleHeight <= height * scaleWidth) 38 | return {width, height: scaleHeight} 39 | else if (!scaleHeight || scaleWidth * height <= scaleHeight * width) 40 | return {width: scaleWidth, height} 41 | return {} 42 | } 43 | -------------------------------------------------------------------------------- /src/directive.js: -------------------------------------------------------------------------------- 1 | import getImageSrc from './provider-config' 2 | import ua from './ua' 3 | 4 | function getSrc(config) { 5 | const { 6 | provider = 'alibaba', 7 | extraQuery, 8 | src, 9 | width, 10 | height, 11 | autocrop = true, 12 | preferHttps = true, 13 | webp = true, 14 | } = config 15 | if (!src) return 16 | 17 | let isSupportWebp = false 18 | if (webp) { 19 | // TODO only simply check in sync way 20 | isSupportWebp = 21 | ua.isSupportWebp(navigator.userAgent) || 22 | JSON.parse(localStorage.getItem('isSupportWebp')) || 23 | false 24 | } 25 | 26 | return getImageSrc({ 27 | autocrop, 28 | provider, 29 | src, 30 | isSupportWebp, 31 | extraQuery, 32 | width, 33 | height, 34 | preferHttps, 35 | }).$src 36 | } 37 | 38 | export default { 39 | init(el, {value = {}}) { 40 | const size = { 41 | width: el.offsetWidth, 42 | height: el.offsetHeight, 43 | } 44 | const src = getSrc({...size, ...value}) 45 | el.classList.add('lazyload') 46 | el.setAttribute('data-bgset', src) 47 | }, 48 | 49 | update(el, {value = {}}) { 50 | const src = getSrc(value) 51 | el.style.backgroundImage = `url(${src})` 52 | }, 53 | } 54 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Import vue component 2 | import Component from './v-img.vue' 3 | import background from './directive' 4 | import placeholder from './spinner.svg' 5 | import reload from './reload.png' 6 | import loadScript from './load-script' 7 | 8 | const defaultOptions = { 9 | placeholder, 10 | error: reload, 11 | } 12 | 13 | const lazysizes = 'https://unpkg.com/lazysizes/lazysizes.min.js' 14 | const bgset = 15 | 'https://unpkg.com/lazysizes/plugins/bgset/ls.bgset.min.js' 16 | 17 | // `Vue.use` automatically prevents you from using 18 | // the same plugin more than once, 19 | // so calling it multiple times on the same plugin 20 | // will install the plugin only once 21 | Component.install = (Vue, options = {}) => { 22 | Vue.prototype.$vImg = {...defaultOptions, ...options} 23 | 24 | if (typeof window !== 'undefined' && !window.lazySizes) { 25 | // https://github.com/aFarkas/lazysizes/tree/gh-pages/plugins/bgset 26 | Promise.all([ 27 | loadScript({name: 'bgset', url: bgset}), 28 | loadScript({name: 'lazysizes', url: lazysizes}), 29 | ]).catch(console.error) 30 | } 31 | 32 | Vue.component(Component.name, Component) 33 | 34 | Vue.directive('img', { 35 | inserted: background.init, 36 | update: background.update, 37 | }) 38 | } 39 | 40 | // To auto-install when vue is found 41 | let GlobalVue = null 42 | if (typeof window !== 'undefined') { 43 | GlobalVue = window.Vue 44 | } else if (typeof global !== 'undefined') { 45 | GlobalVue = global.Vue 46 | } 47 | if (GlobalVue) { 48 | GlobalVue.use(Component) 49 | } 50 | 51 | // To allow use as module (npm/webpack/etc.) export component 52 | export default Component 53 | 54 | // It's possible to expose named exports when writing components that can 55 | // also be used as directives, etc. - eg. import { RollupDemoDirective } from 'rollup-demo'; 56 | // export const RollupDemoDirective = component; 57 | -------------------------------------------------------------------------------- /src/load-script.js: -------------------------------------------------------------------------------- 1 | export default function({name, url}) { 2 | return new Promise((resolve, reject) => { 3 | if (!url) { 4 | reject(new Error('to load script, url cannot be null')) 5 | } 6 | if (document.getElementById(name)) { 7 | resolve(true) 8 | } else { 9 | const node = document.getElementsByTagName('script')[0], 10 | script = document.createElement('script') 11 | script.setAttribute('id', name) 12 | script.setAttribute('src', url) 13 | 14 | script.onload = function() { 15 | resolve(true) 16 | } 17 | script.onerror = function(err) { 18 | script.remove() 19 | reject(err) 20 | } 21 | node.parentNode.insertBefore(script, null) 22 | } 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /src/provider-config.js: -------------------------------------------------------------------------------- 1 | const png = /\.png(\?.*)?$/ 2 | const jpg = /\.jpe?g(\?.*)?$/ 3 | const webp = /\.webp(\?.*)?$/ 4 | const svg = /\.svg(\?.*)?$/ 5 | /** 6 | * https://helpx.adobe.com/hk_en/experience-manager/6-3/assets/using/best-practices-for-optimizing-the-quality-of-your-images.html 7 | * https://sirv.com/help/articles/jpeg-quality-comparison/#use-sirv-to-find-the-perfect-quality 8 | */ 9 | export const quality = 80 10 | 11 | function is(types, src) { 12 | return Array.isArray(types) ? types.some(t => t.test(src)) : types.test(src) 13 | } 14 | 15 | const srcProcess = { 16 | CONVERT_WEBP: 'convertWebp', 17 | CROP_IMAGE: 'cropImage', 18 | APPEND_QUERY: 'appendQuery', 19 | } 20 | 21 | const pipe = function(fns) { 22 | return function(item) { 23 | return fns.reduce(function(prev, fn) { 24 | if (typeof fn !== 'function') { 25 | fn = v => v 26 | } 27 | return fn(prev) 28 | }, item) 29 | } 30 | } 31 | 32 | /** 33 | * //img-url => https://img-url 34 | * origin 参数纯粹是为了警告 35 | * @param url 36 | */ 37 | function preferHttps(url, origin) { 38 | if (url.startsWith('//')) { 39 | return 'https:' + url 40 | } else if (!url.startsWith('https')) { 41 | console.warn( 42 | 'preferHttps is true, but this img is using http protocol: ' + 43 | (origin || url) 44 | ) 45 | } 46 | return url 47 | } 48 | 49 | export const providerConfig = { 50 | alibaba: { 51 | [srcProcess.CONVERT_WEBP](vm) { 52 | const {src, isSupportWebp} = vm 53 | if (!src) return vm 54 | 55 | let query = '' 56 | if (isSupportWebp && is([png, jpg], src)) query += '/format,webp' 57 | /** 58 | * 质量变换仅对jpg、webp有效。(png已被转为webp) 59 | * @see https://help.aliyun.com/document_detail/44705.html?spm=a2c4g.11186623.6.1256.347d69cb9tB4ZR 60 | */ 61 | if (is([png, jpg, webp], src)) query += `/quality,Q_${quality}` 62 | 63 | vm.$src = query 64 | return vm 65 | }, 66 | // https://help.aliyun.com/document_detail/183902.html?spm=a2c4g.11186623.2.12.738828fbVGaPAf#section-tx1-qtj-ar8 67 | [srcProcess.CROP_IMAGE](vm) { 68 | const {$src = '', width, height, autocrop, src} = vm 69 | 70 | if (!autocrop || is(svg, src) || !src) return vm 71 | if (isNaN(width) && isNaN(height)) return vm 72 | 73 | const DPR = 2 74 | let dpr = 75 | (typeof window !== 'undefined' && window.devicePixelRatio) || DPR 76 | if (dpr === 1) { 77 | dpr = DPR 78 | } 79 | const actions = ['/resize'] 80 | const WIDTH = `w_${parseInt(width * dpr)}` 81 | const HEIGHT = `h_${parseInt(height * dpr)}` 82 | const AUTOCROP = `m_fill` 83 | 84 | if (!isNaN(width) && !isNaN(height)) { 85 | actions.push(AUTOCROP) 86 | } 87 | 88 | if (!isNaN(height)) { 89 | actions.push(HEIGHT) 90 | } 91 | 92 | if (!isNaN(width)) { 93 | actions.push(WIDTH) 94 | } 95 | 96 | const resizeQuery = actions.join(',') 97 | 98 | vm.$src = resizeQuery + $src 99 | vm.$resizeQuery = resizeQuery 100 | 101 | return vm 102 | }, 103 | 104 | [srcProcess.APPEND_QUERY](vm) { 105 | const {src, extraQuery} = vm 106 | // null 无法通过解构设置默认值的方式改变值,依然会是 null,不如直接返回 107 | if (!src) return vm 108 | 109 | let query = vm.$src || '' 110 | if (extraQuery) query += '/' + extraQuery 111 | if (query) { 112 | query = 113 | src + 114 | (src.indexOf('?') > -1 ? '&' : '?') + 115 | 'x-oss-process=image' + 116 | query 117 | } 118 | 119 | vm.$src = query || src 120 | return vm 121 | }, 122 | }, 123 | qiniu: { 124 | [srcProcess.CONVERT_WEBP](vm) { 125 | const {src, isSupportWebp} = vm 126 | if (!src) return vm 127 | 128 | let query = vm.$src || '' 129 | // imageMogr2 接口可支持处理的原图片格式有 psd、jpeg、png、gif、webp、tiff、bmp 130 | if (is(svg, src)) { 131 | return vm 132 | } 133 | if (isSupportWebp && is([png, jpg], src)) query += '/format/webp' 134 | query += `/quality/${quality}` 135 | 136 | vm.$src = query 137 | return vm 138 | }, 139 | 140 | [srcProcess.APPEND_QUERY](vm) { 141 | const {src, extraQuery} = vm 142 | if (!src) return vm 143 | 144 | let query = vm.$src || '' 145 | if (extraQuery) query += '/' + extraQuery 146 | if (query) { 147 | query = src + (src.indexOf('?') > -1 ? '&' : '?') + 'imageMogr2' + query 148 | } 149 | 150 | vm.$src = query || src 151 | return vm 152 | }, 153 | }, 154 | self: { 155 | [srcProcess.CONVERT_WEBP](vm) { 156 | const {src, isSupportWebp} = vm 157 | if (!src) return vm 158 | 159 | if (isSupportWebp && is([png, jpg], src)) { 160 | vm.$src = 161 | src.indexOf('?') > -1 ? src.replace('?', '.webp?') : src + '.webp' 162 | } else { 163 | vm.$src = src 164 | } 165 | return vm 166 | }, 167 | }, 168 | none: { 169 | [srcProcess.CONVERT_WEBP](vm) { 170 | vm.$src = vm.src || '' 171 | return vm 172 | }, 173 | }, 174 | } 175 | 176 | /** 177 | * 传入配置,根据 src 注入 $src,在 v-img 组件中使用的是 $src 178 | * 但在 APPEND_QUERY 步骤之前,其实 $src 代表的是 query 179 | * @param vm 180 | * @returns 注入了 $src 的 vm 181 | */ 182 | export default vm => { 183 | vm.$src = '' 184 | const providerPipe = providerConfig[vm.provider] 185 | const output = pipe([ 186 | providerPipe[srcProcess.CONVERT_WEBP], 187 | providerPipe[srcProcess.CROP_IMAGE], 188 | providerPipe[srcProcess.APPEND_QUERY], 189 | ])(vm) 190 | vm.$uncroppedSrc = vm.$src.replace(vm.$resizeQuery, '') 191 | 192 | if (vm.preferHttps && ['self', 'none'].indexOf(vm.provider) == -1) { 193 | vm.$src = preferHttps(vm.$src, vm.src) 194 | } 195 | 196 | return output 197 | } 198 | -------------------------------------------------------------------------------- /src/reload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FEMessage/v-img/2f1fa01a15565d39a3ccb8296ae4e273907aba5d/src/reload.png -------------------------------------------------------------------------------- /src/spinner.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/ua.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by levy on 2020/12/25. 3 | * https://developers.google.com/speed/webp/faq#how_can_i_detect_browser_support_for_webp 4 | * https://textslashplain.com/2019/05/01/edge-76-vs-edge-18-vs-chrome/ 5 | * https://en.wikipedia.org/wiki/History_of_the_Opera_web_browser#Opera_2013 6 | * simple logic: if (chrome >= 79 || firefox >= 65) supports webp 7 | * it is including edge >= 79 && opera >= 66 8 | * 9 | * for more powerful user agent detection, please use: https://github.com/faisalman/ua-parser-js 10 | */ 11 | const regex = /(firefox|chrome(?=\/))\/?\s*(\d+)/i 12 | export const minChromeVersion = 79 13 | export const minFirefoxVersion = 65 14 | export const Chrome = 'Chrome' 15 | export const Firefox = 'Firefox' 16 | 17 | function getBrowser(ua) { 18 | const matches = regex.exec(ua) || [] 19 | // eslint-disable-next-line no-unused-vars 20 | const [str, browser, version] = matches 21 | return { 22 | browser, 23 | version: parseInt(version) 24 | } 25 | } 26 | 27 | function isSupportWebp(ua) { 28 | const {browser, version} = getBrowser(ua) 29 | return ( 30 | (browser === Chrome && version >= minChromeVersion) || 31 | (browser === Firefox && version >= minFirefoxVersion) 32 | ) 33 | } 34 | 35 | export default { 36 | isSupportWebp, 37 | getBrowser 38 | } 39 | -------------------------------------------------------------------------------- /src/v-img.d.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | declare module '@femessage/v-img' { 4 | class FemessageComponent extends Vue { 5 | static install(vue: typeof Vue): void 6 | } 7 | 8 | type CombinedVueInstance< 9 | Instance extends Vue, 10 | Data, 11 | Methods, 12 | Computed, 13 | Props 14 | > = Data & Methods & Computed & Props & Instance 15 | 16 | type ExtendedVue< 17 | Instance extends Vue, 18 | Data, 19 | Methods, 20 | Computed, 21 | Props 22 | > = VueConstructor< 23 | CombinedVueInstance & Vue 24 | > 25 | 26 | type Combined = Data & 27 | Methods & 28 | Computed & 29 | Props 30 | 31 | type VImgData = { 32 | isSupportWebp: boolean | null 33 | status: number 34 | transparentImg: string 35 | } 36 | 37 | type VImgMethods = { 38 | checkLayout: () => void 39 | 40 | checkSupportWebp: () => Promise 41 | 42 | forceUpdateSrc: () => void 43 | 44 | onLoad: () => void 45 | 46 | onError: () => void 47 | 48 | onClick: () => void 49 | } 50 | 51 | type VImgComputed = { 52 | classname: string 53 | 54 | style: {[key: string]: any} 55 | 56 | imageSrc: any 57 | 58 | loadingImage: string 59 | 60 | reloadImage: string 61 | } 62 | 63 | type VImgProps = { 64 | src: string 65 | 66 | width: string | number 67 | 68 | height: string | number 69 | 70 | hasLoading: boolean 71 | 72 | provider: string 73 | 74 | extraQuery: string 75 | 76 | placeholder: string 77 | 78 | error: string 79 | 80 | autocrop: string 81 | } 82 | 83 | type VImg = Combined 84 | 85 | export interface VImgType extends FemessageComponent, VImg {} 86 | 87 | const VImgConstruction: ExtendedVue< 88 | Vue, 89 | VImgData, 90 | VImgMethods, 91 | VImgComputed, 92 | VImgProps 93 | > 94 | 95 | export default VImgConstruction 96 | } 97 | -------------------------------------------------------------------------------- /src/v-img.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 241 | -------------------------------------------------------------------------------- /styleguide.config.js: -------------------------------------------------------------------------------- 1 | const {VueLoaderPlugin} = require('vue-loader') 2 | const path = require('path') 3 | const glob = require('glob') 4 | 5 | const sections = (() => { 6 | const docs = glob 7 | .sync('docs/*.md') 8 | .map(p => ({name: path.basename(p, '.md'), content: p})) 9 | const demos = [] 10 | let faq = '' // 约定至多只有一个faq.md 11 | const guides = [] 12 | docs.forEach(d => { 13 | if (/^faq$/i.test(d.name)) { 14 | d.name = d.name.toUpperCase() 15 | faq = d 16 | } else if (/^guide-/.test(d.name)) { 17 | guides.push(d) 18 | } else { 19 | demos.push(d) 20 | } 21 | }) 22 | return [ 23 | { 24 | name: 'Components', 25 | components: 'src/*.vue', 26 | usageMode: 'expand' 27 | }, 28 | { 29 | name: 'Demo', 30 | sections: demos, 31 | sectionDepth: 2 32 | }, 33 | ...(faq ? [faq] : []), 34 | ...(guides.length 35 | ? [{name: 'Guide', sections: guides, sectionDepth: 2}] 36 | : []) 37 | ] 38 | })() 39 | 40 | module.exports = { 41 | require: ['./styleguide/register.js'], 42 | styleguideDir: 'docs', 43 | pagePerSection: true, 44 | ribbon: { 45 | url: 'https://github.com/FEMessage/v-img' 46 | }, 47 | sections, 48 | webpackConfig: { 49 | module: { 50 | rules: [ 51 | { 52 | test: /\.vue$/, 53 | loader: 'vue-loader' 54 | }, 55 | { 56 | test: /\.js?$/, 57 | exclude: /node_modules/, 58 | loader: 'babel-loader' 59 | }, 60 | { 61 | test: /\.css$/, 62 | loaders: ['style-loader', 'css-loader'] 63 | }, 64 | { 65 | test: /\.less$/, 66 | loaders: ['vue-style-loader', 'css-loader', 'less-loader'] 67 | }, 68 | { 69 | test: /\.(svg|png)$/, 70 | loader: 'file-loader' 71 | } 72 | ] 73 | }, 74 | plugins: [new VueLoaderPlugin()] 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /styleguide/register.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import ImgPreview from '@femessage/img-preview' 3 | import VImg from '../src' 4 | 5 | Vue.use(ImgPreview) 6 | Vue.use(VImg) 7 | -------------------------------------------------------------------------------- /test/compute-layout.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by levy on 2021/1/13. 3 | */ 4 | import computeLayout from '../src/compute-layout' 5 | describe('compute img layout', () => { 6 | let vm 7 | let expected 8 | let natural 9 | 10 | beforeEach(() => { 11 | vm = { 12 | width: '', // 理想值是数字 13 | height: '', // 理想值是数字 14 | $el: { 15 | naturalWidth: 200, 16 | naturalHeight: 200, 17 | style: { 18 | width: '', // 可能是 auto, 可能是 100%,可能是 300px 或其他单位。。。 19 | height: '', 20 | }, 21 | }, 22 | } 23 | expected = {width: 100, height: 100} 24 | natural = {width: 200, height: 200} 25 | }) 26 | 27 | // https://developer.mozilla.org/en-US/docs/Web/API/Window/getComputedStyle#notes 28 | // width, height, they are the same as used values 29 | Object.defineProperty(window, 'getComputedStyle', { 30 | value: jest.fn(el => { 31 | let width = el.style.width || el.naturalWidth 32 | let height = el.style.height || el.naturalHeight 33 | 34 | return { 35 | width: Number.isInteger(width) ? width + 'px' : width, 36 | height: Number.isInteger(height) ? height + 'px' : height, 37 | } 38 | }), 39 | }) 40 | 41 | // 此时优先使用 width 42 | test('width/height 有数值, style.width/style.height 有px值', () => { 43 | vm.width = 100 44 | vm.height = 100 45 | vm.$el.style.width = '50px' 46 | vm.$el.style.height = '50px' 47 | expect(computeLayout(vm)).toStrictEqual(expected) 48 | }) 49 | 50 | test('width/height 有数值', () => { 51 | vm.width = 100 52 | vm.height = 100 53 | expect(computeLayout(vm)).toStrictEqual(expected) 54 | 55 | // 兼容处理不正确的输入值 56 | vm.width = '100px' 57 | expect(computeLayout(vm)).toStrictEqual(expected) 58 | }) 59 | 60 | test('width/height 无数值,style.width/style.height 有px值', () => { 61 | vm.$el.style.width = '100px' 62 | vm.$el.style.height = '100px' 63 | expect(computeLayout(vm)).toStrictEqual(expected) 64 | }) 65 | 66 | test('width 有数值,height/style.height 无值, height 自动计算', () => { 67 | vm.width = 100 68 | expect(computeLayout(vm)).toStrictEqual(expected) 69 | }) 70 | 71 | test('style.width 有px值,height/style.height 无值, height 自动计算', () => { 72 | vm.$el.style.width = '100px' 73 | expect(computeLayout(vm)).toStrictEqual(expected) 74 | }) 75 | 76 | test('只有 naturalWidth/naturalHeight', () => { 77 | vm.$el.naturalWidth = 100 78 | vm.$el.naturalHeight = 100 79 | expect(computeLayout(vm)).toStrictEqual(expected) 80 | }) 81 | 82 | test('width/height 不是纯数字, 暂不支持', () => { 83 | vm.width = '100%' 84 | vm.height = 'auto' 85 | expect(computeLayout(vm)).toStrictEqual(natural) 86 | }) 87 | 88 | test('style.width/style.height 不是px,暂不支持', () => { 89 | vm.$el.style.width = 'auto' 90 | vm.$el.style.height = '100%' 91 | expect(computeLayout(vm)).toStrictEqual({}) 92 | }) 93 | 94 | test('width/height 设置为 0, 忽略不计', () => { 95 | vm.width = 0 96 | vm.height = 0 97 | expect(computeLayout(vm)).toStrictEqual({}) 98 | 99 | vm.width = '' 100 | vm.height = '' 101 | vm.$el.style.width = '0' 102 | vm.$el.style.height = '0' 103 | expect(computeLayout(vm)).toStrictEqual({}) 104 | }) 105 | 106 | test('设置了不恰当比例的宽高,要防止变形', () => { 107 | vm.$el.style.width = '100px' 108 | vm.$el.style.height = '80px' 109 | expect(computeLayout(vm)).toStrictEqual({width: 80, height: 80}) 110 | 111 | vm.width = 100 112 | vm.height = 150 113 | expect(computeLayout(vm)).toStrictEqual(expected) 114 | 115 | vm.width = 300 116 | vm.height = 300 117 | expect(computeLayout(vm)).toStrictEqual(natural) 118 | 119 | vm.width = 100 120 | expect(computeLayout(vm)).toStrictEqual(expected) 121 | }) 122 | }) 123 | -------------------------------------------------------------------------------- /test/provider-config.spec.js: -------------------------------------------------------------------------------- 1 | import _getSrc, {quality} from '../src/provider-config' 2 | 3 | const getSrc = val => _getSrc(val).$src 4 | const getUncroppedSrc = val => _getSrc(val).$uncroppedSrc 5 | 6 | describe('alibaba', () => { 7 | const defaultQuery = `?x-oss-process=image/quality,Q_${quality}` 8 | const src = 'http://image-demo.oss-cn-hangzhou.aliyuncs.com/panda.png' 9 | const relative = '//image-demo.oss-cn-hangzhou.aliyuncs.com/panda.png' 10 | const https = 'https://image-demo.oss-cn-hangzhou.aliyuncs.com/panda.png' 11 | 12 | test('preferHttps', () => { 13 | const defaultConfig = { 14 | provider: 'alibaba', 15 | preferHttps: true, 16 | } 17 | 18 | expect( 19 | getSrc({ 20 | src: relative, 21 | ...defaultConfig, 22 | }) 23 | ).toBe(https + defaultQuery) 24 | 25 | expect( 26 | getSrc({ 27 | src: relative, 28 | ...defaultConfig, 29 | preferHttps: false, 30 | }) 31 | ).toBe(relative + defaultQuery) 32 | 33 | expect( 34 | getSrc({ 35 | src: relative, 36 | ...defaultConfig, 37 | provider: 'none', 38 | }) 39 | ).toBe(relative) 40 | 41 | expect( 42 | getSrc({ 43 | src, 44 | ...defaultConfig, 45 | }) 46 | ).toBe(src + defaultQuery) 47 | 48 | expect( 49 | getSrc({ 50 | src: https, 51 | ...defaultConfig, 52 | }) 53 | ).toBe(https + defaultQuery) 54 | }) 55 | 56 | test('浏览器支持webp', () => { 57 | expect( 58 | getSrc({ 59 | provider: 'alibaba', 60 | src, 61 | isSupportWebp: true, 62 | }) 63 | ).toBe(`${src}?x-oss-process=image/format,webp/quality,Q_${quality}`) 64 | }) 65 | test('浏览器不支持webp', () => { 66 | expect( 67 | getSrc({ 68 | provider: 'alibaba', 69 | src, 70 | isSupportWebp: false, 71 | }) 72 | ).toBe(`${src}${defaultQuery}`) 73 | }) 74 | test('浏览器支持webp,但图片不是(png|jpe?g)', () => { 75 | const webp = src.replace('png', 'webp') 76 | expect( 77 | getSrc({ 78 | provider: 'alibaba', 79 | src: webp, 80 | isSupportWebp: true, 81 | }) 82 | ).toBe(`${webp}${defaultQuery}`) 83 | }) 84 | test('svg不处理,除非有extraQuery', () => { 85 | const svg = src.replace('png', 'svg') 86 | const extraQuery = 'rotate,10' 87 | expect(getSrc({provider: 'alibaba', src: svg})).toBe(svg) 88 | expect( 89 | getSrc({ 90 | provider: 'alibaba', 91 | src: svg, 92 | extraQuery, 93 | }) 94 | ).toBe(`${svg}?x-oss-process=image/${extraQuery}`) 95 | }) 96 | test('带extraQuery的情况', () => { 97 | const extraQuery = 'rotate,10' 98 | expect( 99 | getSrc({ 100 | provider: 'alibaba', 101 | src, 102 | isSupportWebp: true, 103 | extraQuery, 104 | }) 105 | ).toBe( 106 | `${src}?x-oss-process=image/format,webp/quality,Q_${quality}/${extraQuery}` 107 | ) 108 | }) 109 | 110 | test('自动裁剪,只传 width', () => { 111 | expect( 112 | getSrc({ 113 | provider: 'alibaba', 114 | src, 115 | isSupportWebp: true, 116 | autocrop: true, 117 | width: 100, 118 | }) 119 | ).toBe( 120 | `${src}?x-oss-process=image/resize,w_200/format,webp/quality,Q_${quality}` 121 | ) 122 | }) 123 | test('自动裁剪,只传 height', () => { 124 | expect( 125 | getSrc({ 126 | provider: 'alibaba', 127 | src, 128 | isSupportWebp: true, 129 | autocrop: true, 130 | height: 100, 131 | }) 132 | ).toBe( 133 | `${src}?x-oss-process=image/resize,h_200/format,webp/quality,Q_${quality}` 134 | ) 135 | }) 136 | test('自动裁剪,height 和 width 都传', () => { 137 | expect( 138 | getSrc({ 139 | provider: 'alibaba', 140 | src, 141 | isSupportWebp: true, 142 | autocrop: true, 143 | height: 100, 144 | width: 100, 145 | }) 146 | ).toBe( 147 | `${src}?x-oss-process=image/resize,m_fill,h_200,w_200/format,webp/quality,Q_${quality}` 148 | ) 149 | }) 150 | test('自动裁剪,height 和 width 都不传', () => { 151 | expect( 152 | getSrc({ 153 | provider: 'alibaba', 154 | src, 155 | isSupportWebp: true, 156 | autocrop: true, 157 | }) 158 | ).toBe(`${src}?x-oss-process=image/format,webp/quality,Q_${quality}`) 159 | }) 160 | 161 | test('获取不经过裁剪的 url', () => { 162 | expect( 163 | getUncroppedSrc({ 164 | provider: 'alibaba', 165 | src, 166 | isSupportWebp: true, 167 | autocrop: true, 168 | height: 100, 169 | width: 100, 170 | }) 171 | ).toBe(`${src}?x-oss-process=image/format,webp/quality,Q_${quality}`) 172 | }) 173 | 174 | test('测试 src 为空', () => { 175 | expect( 176 | getSrc({ 177 | provider: 'alibaba', 178 | src: '', 179 | }) 180 | ).toBe('') 181 | 182 | expect( 183 | getSrc({ 184 | provider: 'alibaba', 185 | src: null, 186 | }) 187 | ).toBe('') 188 | }) 189 | }) 190 | 191 | describe('qiniu', () => { 192 | const src = 'https://odum9helk.qnssl.com/resource/gogopher.jpg' 193 | test('浏览器支持webp', () => { 194 | expect( 195 | getSrc({ 196 | provider: 'qiniu', 197 | src, 198 | isSupportWebp: true, 199 | }) 200 | ).toBe(`${src}?imageMogr2/format/webp/quality/${quality}`) 201 | }) 202 | test('浏览器不支持webp', () => { 203 | expect( 204 | getSrc({ 205 | provider: 'qiniu', 206 | src, 207 | isSupportWebp: false, 208 | }) 209 | ).toBe(`${src}?imageMogr2/quality/${quality}`) 210 | }) 211 | test('浏览器支持webp,但图片不是(png|jpe?g)', () => { 212 | const webp = src.replace('jpg', 'webp') 213 | expect( 214 | getSrc({ 215 | provider: 'qiniu', 216 | src: webp, 217 | isSupportWebp: true, 218 | }) 219 | ).toBe(`${webp}?imageMogr2/quality/${quality}`) 220 | }) 221 | test('svg不处理', () => { 222 | const svg = src.replace('jpg', 'svg') 223 | expect(getSrc({provider: 'qiniu', src: svg})).toBe(svg) 224 | }) 225 | test('带extraQuery的情况', () => { 226 | const extraQuery = 'rotate/10' 227 | expect( 228 | getSrc({ 229 | provider: 'qiniu', 230 | src, 231 | isSupportWebp: true, 232 | extraQuery, 233 | }) 234 | ).toBe(`${src}?imageMogr2/format/webp/quality/${quality}/${extraQuery}`) 235 | }) 236 | }) 237 | 238 | describe('self', () => { 239 | test('精确的资源路径', () => { 240 | const src = 'https://cumming.com/creampie.png' 241 | expect( 242 | getSrc({ 243 | provider: 'self', 244 | src, 245 | isSupportWebp: true, 246 | }) 247 | ).toBe(`${src}.webp`) 248 | }) 249 | 250 | test('带参数的资源路径', () => { 251 | const src = 'https://cumming.com/creampie.png' 252 | const query = '?format/jpeg' 253 | expect( 254 | getSrc({ 255 | provider: 'self', 256 | src: src + query, 257 | isSupportWebp: true, 258 | }) 259 | ).toBe(`${src}.webp${query}`) 260 | }) 261 | 262 | test('资源路径存在相同后缀名', () => { 263 | const src = 'https://cumming.com/creampie.png.creampie.png' 264 | expect( 265 | getSrc({ 266 | provider: 'self', 267 | src, 268 | isSupportWebp: true, 269 | }) 270 | ).toBe(`${src}.webp`) 271 | }) 272 | 273 | test('使用本地资源路径', () => { 274 | const src = './com/creampie.png.creampie.png' 275 | expect( 276 | getSrc({ 277 | provider: 'self', 278 | src, 279 | isSupportWebp: true, 280 | }) 281 | ).toBe(`${src}.webp`) 282 | }) 283 | 284 | test('默认情况下不转换svg', () => { 285 | const src = './com/creampie.png.creampie.svg' 286 | expect( 287 | getSrc({ 288 | provider: 'self', 289 | src, 290 | isSupportWebp: true, 291 | }) 292 | ).toBe(src) 293 | }) 294 | }) 295 | -------------------------------------------------------------------------------- /test/ua.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by levy on 2020/12/25. 3 | */ 4 | import ua from '../src/ua' 5 | import {minChromeVersion, minFirefoxVersion, Chrome, Firefox} from '../src/ua' 6 | 7 | describe('user-agent detection', () => { 8 | const Chrome87 = 9 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36' 10 | const Edge = 11 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.74 Safari/537.36 Edg/79.0.309.43' 12 | const Opera = 13 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.135 Safari/537.36 OPR/70.0.3728.178 (Edition Baidu)' 14 | const Firefox51 = 15 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:51.0) Gecko/20100101 Firefox/51.0' 16 | const Android = 17 | 'Mozilla/5.0 (Linux; Android 4.4.4; HUAWEI H891L Build/HuaweiH891L) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/33.0.0.0 Mobile Safari/537.36' 18 | const iPhone = 19 | 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1' 20 | const Safari = 21 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/603.3.8 (KHTML, like Gecko) Version/10.1.2 Safari/603.3.8' 22 | 23 | test('unknown user agent so assumes it do not support webp', () => { 24 | expect(ua.isSupportWebp(iPhone)).toBe(false) 25 | expect(ua.isSupportWebp(Safari)).toBe(false) 26 | }) 27 | 28 | test('isChrome but not support webp', () => { 29 | expect(ua.getBrowser(Android).browser).toBe(Chrome) 30 | expect(ua.getBrowser(Android).version).toBeLessThan(minChromeVersion) 31 | expect(ua.isSupportWebp(Android)).toBe(false) 32 | }) 33 | 34 | test('isChrome and support webp', () => { 35 | expect(ua.getBrowser(Chrome87).browser).toBe(Chrome) 36 | expect(ua.getBrowser(Chrome87).version).toBeGreaterThanOrEqual( 37 | minChromeVersion 38 | ) 39 | expect(ua.isSupportWebp(Chrome87)).toBe(true) 40 | expect(ua.isSupportWebp(Edge)).toBe(true) 41 | expect(ua.isSupportWebp(Opera)).toBe(true) 42 | }) 43 | 44 | test('isFirefox but not support webp', () => { 45 | expect(ua.getBrowser(Firefox51).browser).toBe(Firefox) 46 | expect(ua.getBrowser(Firefox51).version).toBeLessThan(minFirefoxVersion) 47 | expect(ua.isSupportWebp(Firefox51)).toBe(false) 48 | }) 49 | }) 50 | --------------------------------------------------------------------------------